From 13bf16d5109a22d90bbbaa88ede31df9946c6e05 Mon Sep 17 00:00:00 2001 From: Emil Shanaty Date: Tue, 27 Oct 2026 18:52:10 +0300 Subject: [PATCH] Bevy Forge 0.1.0: visual scene editor for Bevy 0.20 BSN --- .gitignore | 30 + Cargo.lock | 4354 +++++++++++++++++ Cargo.toml | 35 + LICENSE-APACHE | 202 + LICENSE-MIT | 18 + README.md | 321 ++ compat/bsn-smoke/.gitignore | 2 + compat/bsn-smoke/Cargo.lock | 4232 ++++++++++++++++ compat/bsn-smoke/Cargo.toml | 29 + compat/bsn-smoke/README.md | 126 + compat/bsn-smoke/src/generated.rs | 130 + compat/bsn-smoke/src/generated_2d.rs | 191 + compat/bsn-smoke/src/lib.rs | 334 ++ compat/bsn-smoke/src/rust_export.rs | 98 + crates/bevy_forge_cli/Cargo.toml | 21 + crates/bevy_forge_cli/src/commands.rs | 424 ++ crates/bevy_forge_cli/src/main.rs | 131 + crates/bevy_forge_cli/src/scaffold.rs | 319 ++ crates/bevy_forge_cli/tests/cli.rs | 644 +++ crates/bevy_forge_core/Cargo.toml | 18 + crates/bevy_forge_core/src/assets.rs | 470 ++ crates/bevy_forge_core/src/codegen.rs | 80 + crates/bevy_forge_core/src/codegen/bsn.rs | 1331 +++++ crates/bevy_forge_core/src/codegen/rust.rs | 1053 ++++ crates/bevy_forge_core/src/components.rs | 553 +++ crates/bevy_forge_core/src/config.rs | 128 + crates/bevy_forge_core/src/error.rs | 90 + crates/bevy_forge_core/src/history.rs | 307 ++ crates/bevy_forge_core/src/lib.rs | 44 + crates/bevy_forge_core/src/math.rs | 593 +++ crates/bevy_forge_core/src/project.rs | 385 ++ crates/bevy_forge_core/src/samples.rs | 1014 ++++ crates/bevy_forge_core/src/scene.rs | 570 +++ crates/bevy_forge_core/src/validation.rs | 1549 ++++++ crates/bevy_forge_core/tests/codegen.rs | 764 +++ crates/bevy_forge_core/tests/common/mod.rs | 139 + crates/bevy_forge_core/tests/model.rs | 603 +++ crates/bevy_forge_core/tests/persistence.rs | 582 +++ crates/bevy_forge_core/tests/validation.rs | 538 ++ crates/bevy_forge_editor/Cargo.toml | 21 + crates/bevy_forge_editor/src/action.rs | 40 + crates/bevy_forge_editor/src/app.rs | 983 ++++ crates/bevy_forge_editor/src/assets_panel.rs | 1551 ++++++ crates/bevy_forge_editor/src/dialogs.rs | 1396 ++++++ crates/bevy_forge_editor/src/hierarchy.rs | 1929 ++++++++ crates/bevy_forge_editor/src/inspector.rs | 2175 ++++++++ crates/bevy_forge_editor/src/lib.rs | 24 + crates/bevy_forge_editor/src/main.rs | 34 + crates/bevy_forge_editor/src/menu.rs | 501 ++ crates/bevy_forge_editor/src/palette.rs | 791 +++ crates/bevy_forge_editor/src/shortcuts.rs | 387 ++ crates/bevy_forge_editor/src/state.rs | 279 ++ crates/bevy_forge_editor/src/status_bar.rs | 483 ++ crates/bevy_forge_editor/src/texture_cache.rs | 300 ++ crates/bevy_forge_editor/src/theme.rs | 113 + crates/bevy_forge_editor/src/toast.rs | 370 ++ crates/bevy_forge_editor/src/viewport2d.rs | 3202 ++++++++++++ crates/bevy_forge_editor/src/viewport3d.rs | 2371 +++++++++ crates/bevy_forge_editor/src/welcome.rs | 540 ++ crates/bevy_forge_editor/src/widgets.rs | 237 + crates/bevy_forge_editor/tests/ui_smoke.rs | 774 +++ docs/architecture.md | 376 ++ docs/bsn-export.md | 486 ++ docs/format.md | 569 +++ docs/screenshot-3d.png | Bin 0 -> 810026 bytes docs/screenshot.png | Bin 0 -> 487949 bytes examples/demo-project/Cargo.toml | 12 + examples/demo-project/README.md | 57 + .../assets/textures/background.png | Bin 0 -> 4509 bytes .../demo-project/assets/textures/coin.png | Bin 0 -> 345 bytes .../demo-project/assets/textures/crate.png | Bin 0 -> 336 bytes examples/demo-project/assets/textures/hat.png | Bin 0 -> 217 bytes .../demo-project/assets/textures/player.png | Bin 0 -> 496 bytes .../demo-project/assets/textures/tile.png | Bin 0 -> 583 bytes examples/demo-project/scenes/garden.forge.ron | 437 ++ .../demo-project/scenes/platformer.forge.ron | 975 ++++ examples/demo-project/src/main.rs | 57 + .../demo-project/tools/generate_textures.py | 318 ++ scripts/check-generated-code.sh | 113 + 79 files changed, 44353 insertions(+) create mode 100644 .gitignore create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 LICENSE-APACHE create mode 100644 LICENSE-MIT create mode 100644 README.md create mode 100644 compat/bsn-smoke/.gitignore create mode 100644 compat/bsn-smoke/Cargo.lock create mode 100644 compat/bsn-smoke/Cargo.toml create mode 100644 compat/bsn-smoke/README.md create mode 100644 compat/bsn-smoke/src/generated.rs create mode 100644 compat/bsn-smoke/src/generated_2d.rs create mode 100644 compat/bsn-smoke/src/lib.rs create mode 100644 compat/bsn-smoke/src/rust_export.rs create mode 100644 crates/bevy_forge_cli/Cargo.toml create mode 100644 crates/bevy_forge_cli/src/commands.rs create mode 100644 crates/bevy_forge_cli/src/main.rs create mode 100644 crates/bevy_forge_cli/src/scaffold.rs create mode 100644 crates/bevy_forge_cli/tests/cli.rs create mode 100644 crates/bevy_forge_core/Cargo.toml create mode 100644 crates/bevy_forge_core/src/assets.rs create mode 100644 crates/bevy_forge_core/src/codegen.rs create mode 100644 crates/bevy_forge_core/src/codegen/bsn.rs create mode 100644 crates/bevy_forge_core/src/codegen/rust.rs create mode 100644 crates/bevy_forge_core/src/components.rs create mode 100644 crates/bevy_forge_core/src/config.rs create mode 100644 crates/bevy_forge_core/src/error.rs create mode 100644 crates/bevy_forge_core/src/history.rs create mode 100644 crates/bevy_forge_core/src/lib.rs create mode 100644 crates/bevy_forge_core/src/math.rs create mode 100644 crates/bevy_forge_core/src/project.rs create mode 100644 crates/bevy_forge_core/src/samples.rs create mode 100644 crates/bevy_forge_core/src/scene.rs create mode 100644 crates/bevy_forge_core/src/validation.rs create mode 100644 crates/bevy_forge_core/tests/codegen.rs create mode 100644 crates/bevy_forge_core/tests/common/mod.rs create mode 100644 crates/bevy_forge_core/tests/model.rs create mode 100644 crates/bevy_forge_core/tests/persistence.rs create mode 100644 crates/bevy_forge_core/tests/validation.rs create mode 100644 crates/bevy_forge_editor/Cargo.toml create mode 100644 crates/bevy_forge_editor/src/action.rs create mode 100644 crates/bevy_forge_editor/src/app.rs create mode 100644 crates/bevy_forge_editor/src/assets_panel.rs create mode 100644 crates/bevy_forge_editor/src/dialogs.rs create mode 100644 crates/bevy_forge_editor/src/hierarchy.rs create mode 100644 crates/bevy_forge_editor/src/inspector.rs create mode 100644 crates/bevy_forge_editor/src/lib.rs create mode 100644 crates/bevy_forge_editor/src/main.rs create mode 100644 crates/bevy_forge_editor/src/menu.rs create mode 100644 crates/bevy_forge_editor/src/palette.rs create mode 100644 crates/bevy_forge_editor/src/shortcuts.rs create mode 100644 crates/bevy_forge_editor/src/state.rs create mode 100644 crates/bevy_forge_editor/src/status_bar.rs create mode 100644 crates/bevy_forge_editor/src/texture_cache.rs create mode 100644 crates/bevy_forge_editor/src/theme.rs create mode 100644 crates/bevy_forge_editor/src/toast.rs create mode 100644 crates/bevy_forge_editor/src/viewport2d.rs create mode 100644 crates/bevy_forge_editor/src/viewport3d.rs create mode 100644 crates/bevy_forge_editor/src/welcome.rs create mode 100644 crates/bevy_forge_editor/src/widgets.rs create mode 100644 crates/bevy_forge_editor/tests/ui_smoke.rs create mode 100644 docs/architecture.md create mode 100644 docs/bsn-export.md create mode 100644 docs/format.md create mode 100644 docs/screenshot-3d.png create mode 100644 docs/screenshot.png create mode 100644 examples/demo-project/Cargo.toml create mode 100644 examples/demo-project/README.md create mode 100644 examples/demo-project/assets/textures/background.png create mode 100644 examples/demo-project/assets/textures/coin.png create mode 100644 examples/demo-project/assets/textures/crate.png create mode 100644 examples/demo-project/assets/textures/hat.png create mode 100644 examples/demo-project/assets/textures/player.png create mode 100644 examples/demo-project/assets/textures/tile.png create mode 100644 examples/demo-project/scenes/garden.forge.ron create mode 100644 examples/demo-project/scenes/platformer.forge.ron create mode 100644 examples/demo-project/src/main.rs create mode 100644 examples/demo-project/tools/generate_textures.py create mode 100755 scripts/check-generated-code.sh diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..53dd684 --- /dev/null +++ b/.gitignore @@ -0,0 +1,30 @@ +# Swarm coordination artifacts (local, not part of the product) +.swarm/ + +# Rust build output +/target +/compat/bsn-smoke/target + +# Bevy Forge local state: autosave files and default export folder +.forge/ +/export/ + +# Atomic scene-save temporaries (..tmp-) +*.tmp-* + +# OS metadata +.DS_Store +._* +.Spotlight-V100 +.Trashes +Thumbs.db +ehthumbs.db +desktop.ini + +# Editor / IDE junk +.idea/ +.vscode/ +*.swp +*.swo +*~ +*.rs.bk diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..e4a5010 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,4354 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "ab_glyph" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01c0457472c38ea5bd1c3b5ada5e368271cb550be7a4ca4a0b4634e9913f6cc2" +dependencies = [ + "ab_glyph_rasterizer", + "owned_ttf_parser", +] + +[[package]] +name = "ab_glyph_rasterizer" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618" + +[[package]] +name = "accesskit" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e25ae84c0260bdf5df07796d7cc4882460de26a2b406ec0e6c42461a723b271b" + +[[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", + "atspi-common", + "serde", + "thiserror 1.0.69", + "zvariant", +] + +[[package]] +name = "accesskit_consumer" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8bfae7c152994a31dc7d99b8eeac7784a919f71d1b306f4b83217e110fd3824c" +dependencies = [ + "accesskit", + "hashbrown 0.15.5", +] + +[[package]] +name = "accesskit_macos" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "692dd318ff8a7a0ffda67271c4bd10cf32249656f4e49390db0b26ca92b095f2" +dependencies = [ + "accesskit", + "accesskit_consumer", + "hashbrown 0.15.5", + "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_atspi_common", + "async-channel", + "async-executor", + "async-task", + "atspi", + "futures-lite", + "futures-util", + "serde", + "zbus", +] + +[[package]] +name = "accesskit_windows" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70a042b62c9c05bf7b616f015515c17d2813f3ba89978d6f4fc369735d60700a" +dependencies = [ + "accesskit", + "accesskit_consumer", + "hashbrown 0.15.5", + "static_assertions", + "windows 0.61.3", + "windows-core 0.61.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_unix", + "accesskit_windows", + "raw-window-handle", + "winit", +] + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "getrandom 0.3.4", + "once_cell", + "version_check", + "zerocopy", +] + +[[package]] +name = "android-activity" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f2a1bb052857d5dd49572219344a7332b31b76405648eabac5bc68978251bcd" +dependencies = [ + "android-properties", + "bitflags 2.13.2", + "cc", + "jni", + "libc", + "log", + "ndk", + "ndk-context", + "ndk-sys 0.6.0+11769913", + "num_enum", + "thiserror 2.0.21", +] + +[[package]] +name = "android-properties" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7eb209b1518d6bb87b283c20095f5228ecda460da70b44f0802523dea6da04" + +[[package]] +name = "android_system_properties" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" +dependencies = [ + "libc", +] + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "arboard" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aeb9553e62312c8756a5979254fc6d7da8bbeb5986a1e17037fab932f7037917" +dependencies = [ + "clipboard-win", + "image", + "log", + "objc2 0.6.5", + "objc2-app-kit 0.3.2", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation 0.3.2", + "parking_lot", + "percent-encoding", + "windows-sys 0.61.2", + "x11rb", +] + +[[package]] +name = "arrayref" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" + +[[package]] +name = "arrayvec" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" + +[[package]] +name = "as-raw-xcb-connection" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175571dd1d178ced59193a6fc02dde1b972eb0bc56c892cde9beeceac5bf0f6b" + +[[package]] +name = "ash" +version = "0.38.0+1.3.281" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bb44936d800fea8f016d7f2311c6a4f97aebd5dc86f09906139ec848cf3a46f" +dependencies = [ + "libloading", +] + +[[package]] +name = "async-broadcast" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" +dependencies = [ + "event-listener", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-channel" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-executor" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a" +dependencies = [ + "async-task", + "concurrent-queue", + "fastrand", + "futures-lite", + "pin-project-lite", + "slab", +] + +[[package]] +name = "async-io" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" +dependencies = [ + "autocfg", + "cfg-if", + "concurrent-queue", + "futures-io", + "futures-lite", + "parking", + "polling", + "rustix 1.1.6", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-lock" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" +dependencies = [ + "event-listener", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "async-process" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" +dependencies = [ + "async-channel", + "async-io", + "async-lock", + "async-signal", + "async-task", + "blocking", + "cfg-if", + "event-listener", + "futures-lite", + "rustix 1.1.6", +] + +[[package]] +name = "async-recursion" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f8abc12baad266b1c8cec146854c195b5864b4221d4b2ca7296a7ae82d9e451" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "async-signal" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485" +dependencies = [ + "async-io", + "async-lock", + "atomic-waker", + "cfg-if", + "futures-core", + "futures-io", + "rustix 1.1.6", + "signal-hook-registry", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-task" +version = "4.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" + +[[package]] +name = "async-trait" +version = "0.1.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "atspi" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c83247582e7508838caf5f316c00791eee0e15c0bf743e6880585b867e16815c" +dependencies = [ + "atspi-common", + "atspi-connection", + "atspi-proxies", +] + +[[package]] +name = "atspi-common" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33dfc05e7cdf90988a197803bf24f5788f94f7c94a69efa95683e8ffe76cfdfb" +dependencies = [ + "enumflags2", + "serde", + "static_assertions", + "zbus", + "zbus-lockstep", + "zbus-lockstep-macros", + "zbus_names", + "zvariant", +] + +[[package]] +name = "atspi-connection" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4193d51303d8332304056ae0004714256b46b6635a5c556109b319c0d3784938" +dependencies = [ + "atspi-common", + "atspi-proxies", + "futures-lite", + "zbus", +] + +[[package]] +name = "atspi-proxies" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2eebcb9e7e76f26d0bcfd6f0295e1cd1e6f33bedbc5698a971db8dc43d7751c" +dependencies = [ + "atspi-common", + "serde", + "zbus", +] + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "base64" +version = "0.21.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" + +[[package]] +name = "bevy_forge_cli" +version = "0.1.0" +dependencies = [ + "anyhow", + "bevy_forge_core", + "clap", + "serde_json", + "tempfile", +] + +[[package]] +name = "bevy_forge_core" +version = "0.1.0" +dependencies = [ + "indexmap", + "ron", + "serde", + "tempfile", + "thiserror 2.0.21", + "toml", +] + +[[package]] +name = "bevy_forge_editor" +version = "0.1.0" +dependencies = [ + "bevy_forge_core", + "eframe", + "egui", + "image", + "tempfile", +] + +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" +dependencies = [ + "serde_core", +] + +[[package]] +name = "block" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d8c1fef690941d3e7788d328517591fecc684c084084702d6ff1641e993699a" + +[[package]] +name = "block2" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c132eebf10f5cad5289222520a4a058514204aed6d791f1cf4fe8088b82d15f" +dependencies = [ + "objc2 0.5.3", +] + +[[package]] +name = "blocking" +version = "1.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a70e4329df6cb94385eed412ec92375c3cdd8a6e502493d1229b6414e4036dfa" +dependencies = [ + "async-channel", + "async-task", + "futures-io", + "futures-lite", + "piper", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytemuck" +version = "1.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" +dependencies = [ + "bytemuck_derive", +] + +[[package]] +name = "bytemuck_derive" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a1f896587b6f2c069c73d2f0913e2d590c3990285cd2f0b6aa02b786b4c679c" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "calloop" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b99da2f8558ca23c71f4fd15dc57c906239752dd27ff3c00a1d56b685b7cbfec" +dependencies = [ + "bitflags 2.13.2", + "log", + "polling", + "rustix 0.38.44", + "slab", + "thiserror 1.0.69", +] + +[[package]] +name = "calloop" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43b32313e937c8721c9d17ee9bf60699a6402b4b7c2cb6ac1bee0ffcaa4b4e5e" +dependencies = [ + "bitflags 2.13.2", + "polling", + "rustix 1.1.6", + "tracing", +] + +[[package]] +name = "calloop-wayland-source" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95a66a987056935f7efce4ab5668920b5d0dac4a7c99991a67395f13702ddd20" +dependencies = [ + "calloop 0.13.0", + "rustix 0.38.44", + "wayland-backend", + "wayland-client", +] + +[[package]] +name = "calloop-wayland-source" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "138efcf0940a02ebf0cc8d1eff41a1682a46b431630f4c52450d6265876021fa" +dependencies = [ + "calloop 0.14.5", + "rustix 1.1.6", + "wayland-backend", + "wayland-client", +] + +[[package]] +name = "cc" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86e055fa6e3e40ad6187ba25b6855eeb635d5271f8e0f0e1e9a5826b8d75bea0" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "cgl" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ced0551234e87afee12411d535648dd89d2e7f34c78b753395567aff3d447ff" +dependencies = [ + "libc", +] + +[[package]] +name = "clap" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa8876b300ab35ba921adea3dfd70157a46249b33f95c9084ae5709785478946" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0797fb7aeb1406c84efac526901f7ec3ead2124f946b494e72879d4b54704d" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9c751b79415d4e559e3d1fcf128e09e720eb673a06d26cf6f392d37d75b66e0" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "clap_lex" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c133bc6a41be0d194c306b5506d15e6feeea7b1d6604bd3f8310dfb2ca96486" + +[[package]] +name = "clipboard-win" +version = "5.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "052af7878466feccda12ba7a6a67d019d712f296809559b47c12c8cc196d3a9a" +dependencies = [ + "error-code", +] + +[[package]] +name = "codespan-reporting" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe6d2e5af09e8c8ad56c969f2157a3d4238cebc7c55f0a517728c38f7b200f81" +dependencies = [ + "serde", + "termcolor", + "unicode-width", +] + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "combine" +version = "4.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4356eba6976435498d05a311ee9fc486da3bc81c200d3f0d7dd9703b41913f92" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "core-graphics" +version = "0.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c07782be35f9e1140080c6b96f0d44b739e2278479f64e02fdab4e32dfd8b081" +dependencies = [ + "bitflags 1.3.2", + "core-foundation", + "core-graphics-types", + "foreign-types", + "libc", +] + +[[package]] +name = "core-graphics-types" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45390e6114f68f718cc7a830514a96f903cccd70d02a8f6d9f643ac4ba45afaf" +dependencies = [ + "bitflags 1.3.2", + "core-foundation", + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ba106e844045ddc02c20768ac4b0b7cb45f2cc2ecbd833ac9f5dacb183c2055" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6" + +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + +[[package]] +name = "cursor-icon" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f" + +[[package]] +name = "dispatch" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd0c93bb4b0c6d9b77f4435b0ae98c24d17f1c45b2ff844c6151a07256ca923b" + +[[package]] +name = "dispatch2" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" +dependencies = [ + "bitflags 2.13.2", + "objc2 0.6.5", +] + +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "dlib" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab8ecd87370524b461f8557c119c405552c396ed91fc0a8eec68679eab26f94a" +dependencies = [ + "libloading", +] + +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + +[[package]] +name = "downcast-rs" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" + +[[package]] +name = "dpi" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" + +[[package]] +name = "ecolor" +version = "0.32.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94bdf37f8d5bd9aa7f753573fdda9cf7343afa73dd28d7bfe9593bd9798fc07e" +dependencies = [ + "bytemuck", + "emath", +] + +[[package]] +name = "eframe" +version = "0.32.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "14d1c15e7bd136b309bd3487e6ffe5f668b354cd9768636a836dd738ac90eb0b" +dependencies = [ + "ahash", + "bytemuck", + "document-features", + "egui", + "egui-wgpu", + "egui-winit", + "egui_glow", + "glow", + "glutin", + "glutin-winit", + "image", + "js-sys", + "log", + "objc2 0.5.3", + "objc2-app-kit 0.2.2", + "objc2-foundation 0.2.2", + "parking_lot", + "percent-encoding", + "profiling", + "raw-window-handle", + "static_assertions", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "web-time", + "winapi", + "windows-sys 0.59.0", + "winit", +] + +[[package]] +name = "egui" +version = "0.32.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d5d0306cd61ca75e29682926d71f2390160247f135965242e904a636f51c0dc" +dependencies = [ + "accesskit", + "ahash", + "bitflags 2.13.2", + "emath", + "epaint", + "log", + "nohash-hasher", + "profiling", + "smallvec", + "unicode-segmentation", +] + +[[package]] +name = "egui-wgpu" +version = "0.32.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c12eca13293f8eba27a32aaaa1c765bfbf31acd43e8d30d5881dcbe5e99ca0c7" +dependencies = [ + "ahash", + "bytemuck", + "document-features", + "egui", + "epaint", + "log", + "profiling", + "thiserror 1.0.69", + "type-map", + "web-time", + "wgpu", + "winit", +] + +[[package]] +name = "egui-winit" +version = "0.32.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f95d0a91f9cb0dc2e732d49c2d521ac8948e1f0b758f306fb7b14d6f5db3927f" +dependencies = [ + "accesskit_winit", + "ahash", + "arboard", + "bytemuck", + "egui", + "log", + "profiling", + "raw-window-handle", + "smithay-clipboard", + "web-time", + "webbrowser", + "winit", +] + +[[package]] +name = "egui_glow" +version = "0.32.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc7037813341727937f9e22f78d912f3e29bc3c46e2f40a9e82bb51cbf5e4cfb" +dependencies = [ + "ahash", + "bytemuck", + "egui", + "glow", + "log", + "memoffset", + "profiling", + "wasm-bindgen", + "web-sys", + "winit", +] + +[[package]] +name = "emath" +version = "0.32.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45fd7bc25f769a3c198fe1cf183124bf4de3bd62ef7b4f1eaf6b08711a3af8db" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "endi" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" + +[[package]] +name = "enumflags2" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" +dependencies = [ + "enumflags2_derive", + "serde", +] + +[[package]] +name = "enumflags2_derive" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "epaint" +version = "0.32.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63adcea970b7a13094fe97a36ab9307c35a750f9e24bf00bb7ef3de573e0fddb" +dependencies = [ + "ab_glyph", + "ahash", + "bytemuck", + "ecolor", + "emath", + "epaint_default_fonts", + "log", + "nohash-hasher", + "parking_lot", + "profiling", +] + +[[package]] +name = "epaint_default_fonts" +version = "0.32.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1537accc50c9cab5a272c39300bdd0dd5dca210f6e5e8d70be048df9596e7ca2" + +[[package]] +name = "equivalent" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "error-code" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5343afd4a8365a643ac588dab4cf234a190c7f6c88c9f6dd6ffe00837661b7" + +[[package]] +name = "event-listener" +version = "5.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2" +dependencies = [ + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "fax" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caf1079563223d5d59d83c85886a56e586cfd5c1a26292e971a0fa266531ac5a" + +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c66915cc5391097de7a2b0b972e8505aa33c0df401fd3a6811dbdb32881ba48" + +[[package]] +name = "flate2" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb" +dependencies = [ + "crc32fast", + "miniz_oxide 0.9.1", + "zlib-rs", +] + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "foreign-types" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" +dependencies = [ + "foreign-types-macros", + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-macros" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea5190182e6915eb873ddbc16e23b711b6eb1f9c00a0d0a3a91b5f6228475225" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "foreign-types-shared" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-io" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" + +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + +[[package]] +name = "futures-macro" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-core", + "futures-macro", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "gethostname" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" +dependencies = [ + "rustix 1.1.6", + "windows-link 0.2.1", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", +] + +[[package]] +name = "gl_generator" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a95dfc23a2b4a9a2f5ab41d194f8bfda3cabec42af4e39f08c339eb2a0c124d" +dependencies = [ + "khronos_api", + "log", + "xml-rs", +] + +[[package]] +name = "glow" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5e5ea60d70410161c8bf5da3fdfeaa1c72ed2c15f8bbb9d19fe3a4fad085f08" +dependencies = [ + "js-sys", + "slotmap", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "glutin" +version = "0.32.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12124de845cacfebedff80e877bb37b5b75c34c5a4c89e47e1cdd67fb6041325" +dependencies = [ + "bitflags 2.13.2", + "cfg_aliases", + "cgl", + "dispatch2", + "glutin_egl_sys", + "glutin_glx_sys", + "glutin_wgl_sys", + "libloading", + "objc2 0.6.5", + "objc2-app-kit 0.3.2", + "objc2-core-foundation", + "objc2-foundation 0.3.2", + "once_cell", + "raw-window-handle", + "wayland-sys", + "windows-sys 0.52.0", + "x11-dl", +] + +[[package]] +name = "glutin-winit" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85edca7075f8fc728f28cb8fbb111a96c3b89e930574369e3e9c27eb75d3788f" +dependencies = [ + "cfg_aliases", + "glutin", + "raw-window-handle", + "winit", +] + +[[package]] +name = "glutin_egl_sys" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c4680ba6195f424febdc3ba46e7a42a0e58743f2edb115297b86d7f8ecc02d2" +dependencies = [ + "gl_generator", + "windows-sys 0.52.0", +] + +[[package]] +name = "glutin_glx_sys" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7bb2938045a88b612499fbcba375a77198e01306f52272e692f8c1f3751185" +dependencies = [ + "gl_generator", + "x11-dl", +] + +[[package]] +name = "glutin_wgl_sys" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c4ee00b289aba7a9e5306d57c2d05499b2e5dc427f84ac708bd2c090212cf3e" +dependencies = [ + "gl_generator", +] + +[[package]] +name = "gpu-alloc" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45cf04b2726f02df5508c6de726acdc90cdf97ac771a9a0ffd8ba10a6e696bf9" +dependencies = [ + "bitflags 2.13.2", + "gpu-alloc-types", +] + +[[package]] +name = "gpu-alloc-types" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2bbed164dd10ed526c2e4fe3e721ca4a71c61730e5aafac6844b417b3227058" +dependencies = [ + "bitflags 2.13.2", +] + +[[package]] +name = "gpu-allocator" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c151a2a5ef800297b4e79efa4f4bec035c5f51d5ae587287c9b952bdf734cacd" +dependencies = [ + "log", + "presser", + "thiserror 1.0.69", + "windows 0.58.0", +] + +[[package]] +name = "gpu-descriptor" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b89c83349105e3732062a895becfc71a8f921bb71ecbbdd8ff99263e3b53a0ca" +dependencies = [ + "bitflags 2.13.2", + "gpu-descriptor-types", + "hashbrown 0.15.5", +] + +[[package]] +name = "gpu-descriptor-types" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdf242682df893b86f33a73828fb09ca4b2d3bb6cc95249707fc684d27484b91" +dependencies = [ + "bitflags 2.13.2", +] + +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "num-traits", + "zerocopy", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hermit-abi" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e17592d60ebacc7d5e169f4663c5f84f9161cc90328abcfe8456f41e4dfcb284" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hexf-parse" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfa686283ad6dd069f105e5ab091b04c62850d3e4cf5d67debad1933f55023df" + +[[package]] +name = "icu_collections" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe090a42a0d3140fed8c63263ed1fecc02422cb2f4bfe55cd8de2de1b8e105dd" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25d29b57c17c6528d0dc4c2141cfa2d08200465acb537a3909c8b9b8a4a984c0" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "404a3b8c486f205c87fe7f23ab6894d84a4ad76d6e4671ae1981ba7def87c599" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20f0088bfb7441f7a37fa0c9e20cb1e9cba1cac6b9ce8cd2a6dc44294f8e8cbc" + +[[package]] +name = "icu_properties" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ea83d991c56158f7cf898bf86436c1dfe7c07ee82fe88951f2695c9f37a379f" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b98f29af82d02b163fe50161388350e743bb3107cc8e8c2f7314cb505faddfcf" + +[[package]] +name = "icu_provider" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413099315636c588ee8f011190b329b971a09a8fae9d157e01dcae8379468514" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "image" +version = "0.25.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" +dependencies = [ + "bytemuck", + "byteorder-lite", + "moxcms", + "num-traits", + "png", + "tiff", + "zune-core", + "zune-jpeg", +] + +[[package]] +name = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", + "serde", + "serde_core", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys 0.4.1", + "log", + "simd_cesu8", + "thiserror 2.0.21", + "walkdir", + "windows-link 0.2.1", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn 2.0.119", +] + +[[package]] +name = "jni-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" +dependencies = [ + "jni-sys 0.4.1", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn 2.0.119", +] + +[[package]] +name = "jobserver" +version = "0.1.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" +dependencies = [ + "getrandom 0.4.3", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7883d941dae510fb2d978fc3fe018c71c9e2892fd38854de3e8b92c2e5ad9cc5" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "khronos-egl" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6aae1df220ece3c0ada96b8153459b67eebe9ae9212258bb0134ae60416fdf76" +dependencies = [ + "libc", + "libloading", + "pkg-config", +] + +[[package]] +name = "khronos_api" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" + +[[package]] +name = "libc" +version = "0.2.191" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a" + +[[package]] +name = "libloading" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +dependencies = [ + "cfg-if", + "windows-link 0.2.1", +] + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "libredox" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61ff90caf6077a803a240f62fdbe88645a890bbca49ef8174c3cb0404362171d" +dependencies = [ + "bitflags 2.13.2", + "libc", + "plain", + "redox_syscall 0.9.5", +] + +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" + +[[package]] +name = "litrs" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4744e383959f0db86ede514b809b1c53251889093803c05267acc7d4e7030d70" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "malloc_buf" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62bb907fe88d54d8d9ce32a3cceab4218ed2f6b7d35617cafe9adf84e43919cb" +dependencies = [ + "libc", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "memmap2" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b0ca1155a9ecc25aa12e8313a119f32edf0255fea2f44839c4ac0a12b2ac157" +dependencies = [ + "libc", +] + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "metal" +version = "0.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f569fb946490b5743ad69813cb19629130ce9374034abe31614a36402d18f99e" +dependencies = [ + "bitflags 2.13.2", + "block", + "core-graphics-types", + "foreign-types", + "log", + "objc", + "paste", +] + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "miniz_oxide" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "moxcms" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b" +dependencies = [ + "num-traits", + "pxfm", +] + +[[package]] +name = "naga" +version = "25.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b977c445f26e49757f9aca3631c3b8b836942cb278d69a92e7b80d3b24da632" +dependencies = [ + "arrayvec", + "bit-set", + "bitflags 2.13.2", + "cfg_aliases", + "codespan-reporting", + "half", + "hashbrown 0.15.5", + "hexf-parse", + "indexmap", + "log", + "num-traits", + "once_cell", + "rustc-hash 1.1.0", + "spirv", + "strum", + "thiserror 2.0.21", + "unicode-ident", +] + +[[package]] +name = "ndk" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" +dependencies = [ + "bitflags 2.13.2", + "jni-sys 0.3.1", + "log", + "ndk-sys 0.6.0+11769913", + "num_enum", + "raw-window-handle", + "thiserror 1.0.69", +] + +[[package]] +name = "ndk-context" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" + +[[package]] +name = "ndk-sys" +version = "0.5.0+25.2.9519653" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c196769dd60fd4f363e11d948139556a344e79d451aeb2fa2fd040738ef7691" +dependencies = [ + "jni-sys 0.3.1", +] + +[[package]] +name = "ndk-sys" +version = "0.6.0+11769913" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873" +dependencies = [ + "jni-sys 0.3.1", +] + +[[package]] +name = "nohash-hasher" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451" + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", + "libm", +] + +[[package]] +name = "num_enum" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" +dependencies = [ + "num_enum_derive", + "rustversion", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "objc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "915b1b472bc21c53464d6c8461c9d3af805ba1ef837e1cac254428f4a77177b1" +dependencies = [ + "malloc_buf", +] + +[[package]] +name = "objc-sys" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb91bdd390c7ce1a8607f35f3ca7151b65afc0ff5ff3b34fa350f7d7c7e4310" + +[[package]] +name = "objc2" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19d5490aaf8f1d7cf7688dfa9b0ce07900e168852c45cd2c03f534dfd27cfd0b" +dependencies = [ + "objc-sys", + "objc2-encode", +] + +[[package]] +name = "objc2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08849bbd4767dfae9457696856ae1c84fe4e0281bbe4a7abff2d0e06fb7981f8" +dependencies = [ + "objc2-encode", +] + +[[package]] +name = "objc2-app-kit" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4e89ad9e3d7d297152b17d39ed92cd50ca8063a89a9fa569046d41568891eff" +dependencies = [ + "bitflags 2.13.2", + "block2", + "libc", + "objc2 0.5.3", + "objc2-core-data", + "objc2-core-image", + "objc2-foundation 0.2.2", + "objc2-quartz-core", +] + +[[package]] +name = "objc2-app-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" +dependencies = [ + "bitflags 2.13.2", + "objc2 0.6.5", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation 0.3.2", +] + +[[package]] +name = "objc2-cloud-kit" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74dd3b56391c7a0596a295029734d3c1c5e7e510a4cb30245f8221ccea96b009" +dependencies = [ + "bitflags 2.13.2", + "block2", + "objc2 0.5.3", + "objc2-core-location", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-contacts" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5ff520e9c33812fd374d8deecef01d4a840e7b41862d849513de77e44aa4889" +dependencies = [ + "block2", + "objc2 0.5.3", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-core-data" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "617fbf49e071c178c0b24c080767db52958f716d9eabdf0890523aeae54773ef" +dependencies = [ + "bitflags 2.13.2", + "block2", + "objc2 0.5.3", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags 2.13.2", + "dispatch2", + "objc2 0.6.5", +] + +[[package]] +name = "objc2-core-graphics" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" +dependencies = [ + "bitflags 2.13.2", + "dispatch2", + "objc2 0.6.5", + "objc2-core-foundation", + "objc2-io-surface", +] + +[[package]] +name = "objc2-core-image" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55260963a527c99f1819c4f8e3b47fe04f9650694ef348ffd2227e8196d34c80" +dependencies = [ + "block2", + "objc2 0.5.3", + "objc2-foundation 0.2.2", + "objc2-metal", +] + +[[package]] +name = "objc2-core-location" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "000cfee34e683244f284252ee206a27953279d370e309649dc3ee317b37e5781" +dependencies = [ + "block2", + "objc2 0.5.3", + "objc2-contacts", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-foundation" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ee638a5da3799329310ad4cfa62fbf045d5f56e3ef5ba4149e7452dcf89d5a8" +dependencies = [ + "bitflags 2.13.2", + "block2", + "dispatch", + "libc", + "objc2 0.5.3", +] + +[[package]] +name = "objc2-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +dependencies = [ + "bitflags 2.13.2", + "objc2 0.6.5", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-io-surface" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" +dependencies = [ + "bitflags 2.13.2", + "objc2 0.6.5", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-link-presentation" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1a1ae721c5e35be65f01a03b6d2ac13a54cb4fa70d8a5da293d7b0020261398" +dependencies = [ + "block2", + "objc2 0.5.3", + "objc2-app-kit 0.2.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-metal" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd0cba1276f6023976a406a14ffa85e1fdd19df6b0f737b063b95f6c8c7aadd6" +dependencies = [ + "bitflags 2.13.2", + "block2", + "objc2 0.5.3", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-quartz-core" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e42bee7bff906b14b167da2bac5efe6b6a07e6f7c0a21a7308d40c960242dc7a" +dependencies = [ + "bitflags 2.13.2", + "block2", + "objc2 0.5.3", + "objc2-foundation 0.2.2", + "objc2-metal", +] + +[[package]] +name = "objc2-symbols" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a684efe3dec1b305badae1a28f6555f6ddd3bb2c2267896782858d5a78404dc" +dependencies = [ + "objc2 0.5.3", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-ui-kit" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8bb46798b20cd6b91cbd113524c490f1686f4c4e8f49502431415f3512e2b6f" +dependencies = [ + "bitflags 2.13.2", + "block2", + "objc2 0.5.3", + "objc2-cloud-kit", + "objc2-core-data", + "objc2-core-image", + "objc2-core-location", + "objc2-foundation 0.2.2", + "objc2-link-presentation", + "objc2-quartz-core", + "objc2-symbols", + "objc2-uniform-type-identifiers", + "objc2-user-notifications", +] + +[[package]] +name = "objc2-uniform-type-identifiers" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44fa5f9748dbfe1ca6c0b79ad20725a11eca7c2218bceb4b005cb1be26273bfe" +dependencies = [ + "block2", + "objc2 0.5.3", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-user-notifications" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76cfcbf642358e8689af64cee815d139339f3ed8ad05103ed5eaf73db8d84cb3" +dependencies = [ + "bitflags 2.13.2", + "block2", + "objc2 0.5.3", + "objc2-core-location", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "orbclient" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5df339f526ea9a60e371768d50efc2f2508c7203290731565d1f7a6f71d21747" +dependencies = [ + "libc", + "libredox", +] + +[[package]] +name = "ordered-float" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7bb71e1b3fa6ca1c61f383464aaf2bb0e2f8e772a1f01d486832464de363b951" +dependencies = [ + "num-traits", +] + +[[package]] +name = "ordered-stream" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50" +dependencies = [ + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "owned_ttf_parser" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36820e9051aca1014ddc75770aab4d68bc1e9e632f0f5627c4086bc216fb583b" +dependencies = [ + "ttf-parser", +] + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall 0.5.18", + "smallvec", + "windows-link 0.2.1", +] + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "piper" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" +dependencies = [ + "atomic-waker", + "fastrand", + "futures-io", +] + +[[package]] +name = "pkg-config" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" + +[[package]] +name = "plain" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" + +[[package]] +name = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags 2.13.2", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide 0.8.9", +] + +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix 1.1.6", + "windows-sys 0.61.2", +] + +[[package]] +name = "portable-atomic" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" + +[[package]] +name = "potential_utf" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" +dependencies = [ + "zerovec", +] + +[[package]] +name = "presser" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8cf8e6a8aa66ce33f63993ffc4ea4271eb5b0530a9002db8455ea6050c77bfa" + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit 0.25.17+spec-1.1.0", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "profiling" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5" + +[[package]] +name = "pxfm" +version = "0.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea" + +[[package]] +name = "quick-error" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" + +[[package]] +name = "quick-xml" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1" +dependencies = [ + "memchr", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "range-alloc" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca45419789ae5a7899559e9512e58ca889e41f04f1f2445e9f4b290ceccd1d08" + +[[package]] +name = "raw-window-handle" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" + +[[package]] +name = "redox_syscall" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4722d768eff46b75989dd134e5c353f0d6296e5aaa3132e776cbdb56be7731aa" +dependencies = [ + "bitflags 1.3.2", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.13.2", +] + +[[package]] +name = "redox_syscall" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "580c875845b41ecb1564ad1ae4703044cef774145e3d7a94dc2b3fa7c97868fd" +dependencies = [ + "bitflags 2.13.2", +] + +[[package]] +name = "renderdoc-sys" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832" + +[[package]] +name = "ron" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b91f7eff05f748767f183df4320a63d6936e9c6107d97c9e6bdd9784f4289c94" +dependencies = [ + "base64", + "bitflags 2.13.2", + "serde", + "serde_derive", +] + +[[package]] +name = "rustc-hash" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags 2.13.2", + "errno", + "libc", + "linux-raw-sys 0.4.15", + "windows-sys 0.59.0", +] + +[[package]] +name = "rustix" +version = "1.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5e76742e5294153d4dde1207dbec5bdd81eef6fabdc0d2118babadabfd858aa" +dependencies = [ + "bitflags 2.13.2", + "errno", + "libc", + "linux-raw-sys 0.12.1", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "scoped-tls" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "sctk-adwaita" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6277f0217056f77f1d8f49f2950ac6c278c0d607c45f5ee99328d792ede24ec" +dependencies = [ + "ab_glyph", + "log", + "memmap2", + "smithay-client-toolkit 0.19.2", + "tiny-skia", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "serde_json" +version = "1.0.154" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_repr" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + +[[package]] +name = "simd_cesu8" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "slotmap" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bdd58c3c93c3d278ca835519292445cb4b0d4dc59ccfdf7ceadaab3f8aeb4038" +dependencies = [ + "version_check", +] + +[[package]] +name = "smallvec" +version = "1.16.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0d5d4b1981b8bbbb7aed204cae5726d683d5d6ac7e74ec54122ea28f315e7c8" + +[[package]] +name = "smithay-client-toolkit" +version = "0.19.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3457dea1f0eb631b4034d61d4d8c32074caa6cd1ab2d59f2327bd8461e2c0016" +dependencies = [ + "bitflags 2.13.2", + "calloop 0.13.0", + "calloop-wayland-source 0.3.0", + "cursor-icon", + "libc", + "log", + "memmap2", + "rustix 0.38.44", + "thiserror 1.0.69", + "wayland-backend", + "wayland-client", + "wayland-csd-frame", + "wayland-cursor", + "wayland-protocols", + "wayland-protocols-wlr", + "wayland-scanner", + "xkeysym", +] + +[[package]] +name = "smithay-client-toolkit" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0512da38f5e2b31201a93524adb8d3136276fa4fe4aafab4e1f727a82b534cc0" +dependencies = [ + "bitflags 2.13.2", + "calloop 0.14.5", + "calloop-wayland-source 0.4.1", + "cursor-icon", + "libc", + "log", + "memmap2", + "rustix 1.1.6", + "thiserror 2.0.21", + "wayland-backend", + "wayland-client", + "wayland-csd-frame", + "wayland-cursor", + "wayland-protocols", + "wayland-protocols-experimental", + "wayland-protocols-misc", + "wayland-protocols-wlr", + "wayland-scanner", + "xkeysym", +] + +[[package]] +name = "smithay-clipboard" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71704c03f739f7745053bde45fa203a46c58d25bc5c4efba1d9a60e9dba81226" +dependencies = [ + "libc", + "smithay-client-toolkit 0.20.0", + "wayland-backend", +] + +[[package]] +name = "smol_str" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd538fb6910ac1099850255cf94a94df6551fbdd602454387d0adb2d1ca6dead" +dependencies = [ + "serde", +] + +[[package]] +name = "spirv" +version = "0.3.0+sdk-1.3.268.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eda41003dc44290527a59b13432d4a0379379fa074b70174882adfbdfd917844" +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 = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "strict-num" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "strum" +version = "0.26.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "rustversion", + "syn 2.0.119", +] + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "synstructure" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix 1.1.6", + "windows-sys 0.61.2", +] + +[[package]] +name = "termcolor" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" +dependencies = [ + "thiserror-impl 2.0.21", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "tiff" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63feaf3343d35b6ca4d50483f94843803b0f51634937cc2ec519fc32232bc52" +dependencies = [ + "fax", + "flate2", + "half", + "quick-error", + "weezl", + "zune-jpeg", +] + +[[package]] +name = "tiny-skia" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83d13394d44dae3207b52a326c0c85a8bf87f1541f23b0d143811088497b09ab" +dependencies = [ + "arrayref", + "arrayvec", + "bytemuck", + "cfg-if", + "log", + "tiny-skia-path", +] + +[[package]] +name = "tiny-skia-path" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c9e7fc0c2e86a30b117d0462aa261b72b7a99b7ebd7deb3a14ceda95c5bdc93" +dependencies = [ + "arrayref", + "bytemuck", + "strict-num", +] + +[[package]] +name = "tinystr" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abc840c238154d58f9e86b2792261edfde565a17696e1cda1a449de27979801a" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tokio" +version = "1.54.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b52fd3255258d26fac7264ab5a76b53c13367136dc011d3d6ad4f8ed070396a3" +dependencies = [ + "pin-project-lite", +] + +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime 0.6.11", + "toml_edit 0.22.27", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_datetime" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b86d767906c6c42421dcba507eb9d203e779497710a47782a224bb871653053" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "serde", + "serde_spanned", + "toml_datetime 0.6.11", + "toml_write", + "winnow 0.7.15", +] + +[[package]] +name = "toml_edit" +version = "0.25.17+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3641d5bbb5349a79e1020a242d251efbc546ad8048d133958323ce9c40a9c9c" +dependencies = [ + "indexmap", + "toml_datetime 1.1.2+spec-1.1.0", + "toml_parser", + "winnow 1.0.4", +] + +[[package]] +name = "toml_parser" +version = "1.1.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baa693a8032d7e1cada7d0041e96126df243179ff061456783ac7f12bda4744c" +dependencies = [ + "winnow 1.0.4", +] + +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "ttf-parser" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31" + +[[package]] +name = "type-map" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb30dbbd9036155e74adad6812e9898d03ec374946234fbcebd5dfc7b9187b90" +dependencies = [ + "rustc-hash 2.1.3", +] + +[[package]] +name = "uds_windows" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" +dependencies = [ + "memoffset", + "tempfile", + "windows-sys 0.61.2", +] + +[[package]] +name = "unicode-ident" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" + +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "uuid" +version = "1.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a0e2d11620c50695fe12bbbfde96b3477fe606e7284c22d505c12ce51214514" +dependencies = [ + "js-sys", + "serde_core", + "wasm-bindgen", +] + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9bb54f33acc68fd454578d9820b0bde1a1a3d17aa17bb7b6595806d02886d409" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.79" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3cbab34de2d982e9b48e18d216d04c4a6f641066ff19ffb699980f591ee3610e" +dependencies = [ + "js-sys", + "tokio", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e29d0c35b16e224a7eeb5cd2d25e3e1968fbd65604117b44d3b789d00ee8535" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f501a8bc3719dba86ef8ae4728879c08001bea749eb1333ac5b91e040e2a6b7" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 3.0.9", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23f0c9c52aa7cd7d77769a4cfe2a9adb1b331f489a41d912ce14513d5ab995c6" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wayland-backend" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38a91b4eaddff87b1cd1074985e3713da4af2c49742d1b356b2c01670a67a078" +dependencies = [ + "cc", + "downcast-rs", + "rustix 1.1.6", + "scoped-tls", + "smallvec", + "wayland-sys", +] + +[[package]] +name = "wayland-client" +version = "0.31.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3c36a0f861ad76d0901f2800b46321410d9f73f2ea88aac0650d86c32688073" +dependencies = [ + "bitflags 2.13.2", + "rustix 1.1.6", + "wayland-backend", + "wayland-scanner", +] + +[[package]] +name = "wayland-csd-frame" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625c5029dbd43d25e6aa9615e88b829a5cad13b2819c4ae129fdbb7c31ab4c7e" +dependencies = [ + "bitflags 2.13.2", + "cursor-icon", + "wayland-backend", +] + +[[package]] +name = "wayland-cursor" +version = "0.31.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a52d18780be9b1314328a3de5f930b73d2200112e3849ca6cb11822793fb34d" +dependencies = [ + "rustix 1.1.6", + "wayland-client", + "xcursor", +] + +[[package]] +name = "wayland-protocols" +version = "0.32.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23d0c813de3daa2ed6520af85a3bd49b0e722a3078506899aa9686fea58dc4b6" +dependencies = [ + "bitflags 2.13.2", + "wayland-backend", + "wayland-client", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-experimental" +version = "20250721.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40a1f863128dcaaec790d7b4b396cc9b9a7a079e878e18c47e6c2d2c5a8dcbb1" +dependencies = [ + "bitflags 2.13.2", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-misc" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e9567599ef23e09b8dad6e429e5738d4509dfc46b3b21f32841a304d16b29c8" +dependencies = [ + "bitflags 2.13.2", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-plasma" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b6d8cf1eb2c1c31ed1f5643c88a6e53538129d4af80030c8cabd1f9fa884d91" +dependencies = [ + "bitflags 2.13.2", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-wlr" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb04e52f7836d7c7976c78ca0250d61e33873c34156a2a1fc9474828ec268234" +dependencies = [ + "bitflags 2.13.2", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-scanner" +version = "0.31.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "338e30461b3a2b67d70eb30a6d89f8e0c93a833e07d2ae89085cd070c4a00ac0" +dependencies = [ + "proc-macro2", + "quick-xml", + "quote", +] + +[[package]] +name = "wayland-sys" +version = "0.31.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8eab23fefc9e41f8e841df4a9c707e8a8c4ed26e944ef69297184de2785e3be" +dependencies = [ + "dlib", + "log", + "once_cell", + "pkg-config", +] + +[[package]] +name = "web-sys" +version = "0.3.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88261b9deccee56594c11a3460c462c41f58d148598fe70ad77070126a68aba4" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webbrowser" +version = "1.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2c8ddfbbf66e501434657400268cefe51b95140a0266236415cda7627732df7" +dependencies = [ + "jni", + "log", + "ndk-context", + "objc2 0.6.5", + "objc2-app-kit 0.3.2", + "objc2-foundation 0.3.2", + "url", + "web-sys", +] + +[[package]] +name = "weezl" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" + +[[package]] +name = "wgpu" +version = "25.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec8fb398f119472be4d80bc3647339f56eb63b2a331f6a3d16e25d8144197dd9" +dependencies = [ + "arrayvec", + "bitflags 2.13.2", + "cfg_aliases", + "document-features", + "hashbrown 0.15.5", + "js-sys", + "log", + "naga", + "parking_lot", + "portable-atomic", + "profiling", + "raw-window-handle", + "smallvec", + "static_assertions", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "wgpu-core", + "wgpu-hal", + "wgpu-types", +] + +[[package]] +name = "wgpu-core" +version = "25.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7b882196f8368511d613c6aeec80655160db6646aebddf8328879a88d54e500" +dependencies = [ + "arrayvec", + "bit-set", + "bit-vec", + "bitflags 2.13.2", + "cfg_aliases", + "document-features", + "hashbrown 0.15.5", + "indexmap", + "log", + "naga", + "once_cell", + "parking_lot", + "portable-atomic", + "profiling", + "raw-window-handle", + "rustc-hash 1.1.0", + "smallvec", + "thiserror 2.0.21", + "wgpu-core-deps-apple", + "wgpu-core-deps-emscripten", + "wgpu-core-deps-windows-linux-android", + "wgpu-hal", + "wgpu-types", +] + +[[package]] +name = "wgpu-core-deps-apple" +version = "25.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfd488b3239b6b7b185c3b045c39ca6bf8af34467a4c5de4e0b1a564135d093d" +dependencies = [ + "wgpu-hal", +] + +[[package]] +name = "wgpu-core-deps-emscripten" +version = "25.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f09ad7aceb3818e52539acc679f049d3475775586f3f4e311c30165cf2c00445" +dependencies = [ + "wgpu-hal", +] + +[[package]] +name = "wgpu-core-deps-windows-linux-android" +version = "25.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cba5fb5f7f9c98baa7c889d444f63ace25574833df56f5b817985f641af58e46" +dependencies = [ + "wgpu-hal", +] + +[[package]] +name = "wgpu-hal" +version = "25.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f968767fe4d3d33747bbd1473ccd55bf0f6451f55d733b5597e67b5deab4ad17" +dependencies = [ + "android_system_properties", + "arrayvec", + "ash", + "bit-set", + "bitflags 2.13.2", + "block", + "bytemuck", + "cfg-if", + "cfg_aliases", + "core-graphics-types", + "glow", + "glutin_wgl_sys", + "gpu-alloc", + "gpu-allocator", + "gpu-descriptor", + "hashbrown 0.15.5", + "js-sys", + "khronos-egl", + "libc", + "libloading", + "log", + "metal", + "naga", + "ndk-sys 0.5.0+25.2.9519653", + "objc", + "ordered-float", + "parking_lot", + "portable-atomic", + "profiling", + "range-alloc", + "raw-window-handle", + "renderdoc-sys", + "smallvec", + "thiserror 2.0.21", + "wasm-bindgen", + "web-sys", + "wgpu-types", + "windows 0.58.0", + "windows-core 0.58.0", +] + +[[package]] +name = "wgpu-types" +version = "25.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2aa49460c2a8ee8edba3fca54325540d904dd85b2e086ada762767e17d06e8bc" +dependencies = [ + "bitflags 2.13.2", + "bytemuck", + "js-sys", + "log", + "thiserror 2.0.21", + "web-sys", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd04d41d93c4992d421894c18c8b43496aa748dd4c081bac0dc93eb0489272b6" +dependencies = [ + "windows-core 0.58.0", + "windows-targets", +] + +[[package]] +name = "windows" +version = "0.61.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893" +dependencies = [ + "windows-collections", + "windows-core 0.61.2", + "windows-future", + "windows-link 0.1.3", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3beeceb5e5cfd9eb1d76b381630e82c4241ccd0d27f1a39ed41b2760b255c5e8" +dependencies = [ + "windows-core 0.61.2", +] + +[[package]] +name = "windows-core" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba6d44ec8c2591c134257ce647b7ea6b20335bf6379a27dac5f1641fcf59f99" +dependencies = [ + "windows-implement 0.58.0", + "windows-interface 0.58.0", + "windows-result 0.2.0", + "windows-strings 0.1.0", + "windows-targets", +] + +[[package]] +name = "windows-core" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" +dependencies = [ + "windows-implement 0.60.2", + "windows-interface 0.59.3", + "windows-link 0.1.3", + "windows-result 0.3.4", + "windows-strings 0.4.2", +] + +[[package]] +name = "windows-future" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e" +dependencies = [ + "windows-core 0.61.2", + "windows-link 0.1.3", + "windows-threading", +] + +[[package]] +name = "windows-implement" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bbd5b46c938e506ecbce286b6628a02171d56153ba733b6c741fc627ec9579b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053c4c462dc91d3b1504c6fe5a726dd15e216ba718e84a0e46a88fbe5ded3515" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-link" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-numerics" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1" +dependencies = [ + "windows-core 0.61.2", + "windows-link 0.1.3", +] + +[[package]] +name = "windows-result" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d1043d8214f791817bab27572aaa8af63732e11bf84aa21a45a78d6c317ae0e" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-result" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-strings" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd9b125c486025df0eabcb585e62173c6c9eddcec5d117d3b6e8c30e2ee4d10" +dependencies = [ + "windows-result 0.2.0", + "windows-targets", +] + +[[package]] +name = "windows-strings" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows-threading" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b66463ad2e0ea3bbf808b7f1d371311c80e115c0b71d60efc142cafbcfb057a6" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "winit" +version = "0.30.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6755fa58a9f8350bd1e472d4c3fcc25f824ec358933bba33306d0b63df5978d" +dependencies = [ + "ahash", + "android-activity", + "atomic-waker", + "bitflags 2.13.2", + "block2", + "bytemuck", + "calloop 0.13.0", + "cfg_aliases", + "concurrent-queue", + "core-foundation", + "core-graphics", + "cursor-icon", + "dpi", + "js-sys", + "libc", + "memmap2", + "ndk", + "objc2 0.5.3", + "objc2-app-kit 0.2.2", + "objc2-foundation 0.2.2", + "objc2-ui-kit", + "orbclient", + "percent-encoding", + "pin-project", + "raw-window-handle", + "redox_syscall 0.4.1", + "rustix 0.38.44", + "sctk-adwaita", + "smithay-client-toolkit 0.19.2", + "smol_str", + "tracing", + "unicode-segmentation", + "wasm-bindgen", + "wasm-bindgen-futures", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-protocols-plasma", + "web-sys", + "web-time", + "windows-sys 0.52.0", + "x11-dl", + "x11rb", + "xkbcommon-dl", +] + +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] + +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" +dependencies = [ + "memchr", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "writeable" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" + +[[package]] +name = "x11-dl" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38735924fedd5314a6e548792904ed8c6de6636285cb9fec04d5b1db85c1516f" +dependencies = [ + "libc", + "once_cell", + "pkg-config", +] + +[[package]] +name = "x11rb" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414" +dependencies = [ + "as-raw-xcb-connection", + "gethostname", + "libc", + "libloading", + "once_cell", + "rustix 1.1.6", + "x11rb-protocol", +] + +[[package]] +name = "x11rb-protocol" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" + +[[package]] +name = "xcursor" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "163b33ed8786455e2fa5d72f554057ce3f3182425434f756cd39c99839d88e23" + +[[package]] +name = "xkbcommon-dl" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d039de8032a9a8856a6be89cea3e5d12fdd82306ab7c94d74e6deab2460651c5" +dependencies = [ + "bitflags 2.13.2", + "dlib", + "log", + "once_cell", + "xkeysym", +] + +[[package]] +name = "xkeysym" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56" + +[[package]] +name = "xml-rs" +version = "0.8.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7" + +[[package]] +name = "yoke" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "417ef3fcf6c300b7dbd25c85531a746931c02ad536f3cf4786ed1e88223340b3" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec8ebde2db3681e8c9980cc27822030e68752690ddfa9473e739aeb4dbde6d71" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", + "synstructure", +] + +[[package]] +name = "zbus" +version = "5.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5db4be7c075cb421e4b7ee645541604239bd243ba7c357511f4ff3a74b555907" +dependencies = [ + "async-broadcast", + "async-executor", + "async-io", + "async-lock", + "async-process", + "async-recursion", + "async-task", + "async-trait", + "blocking", + "enumflags2", + "event-listener", + "futures-core", + "futures-lite", + "hex", + "libc", + "ordered-stream", + "rustix 1.1.6", + "serde", + "serde_repr", + "tracing", + "uds_windows", + "uuid", + "windows-sys 0.61.2", + "winnow 1.0.4", + "zbus_macros", + "zbus_names", + "zvariant", +] + +[[package]] +name = "zbus-lockstep" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6998de05217a084b7578728a9443d04ea4cd80f2a0839b8d78770b76ccd45863" +dependencies = [ + "zbus_xml", + "zvariant", +] + +[[package]] +name = "zbus-lockstep-macros" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10da05367f3a7b7553c8cdf8fa91aee6b64afebe32b51c95177957efc47ca3a0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "zbus-lockstep", + "zbus_xml", + "zvariant", +] + +[[package]] +name = "zbus_macros" +version = "5.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2990635d09ade6df1868f72f8cac69a876a90981e8bd3c40b1be413f8dc88f40" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 3.0.9", + "zbus_names", + "zvariant", + "zvariant_utils", +] + +[[package]] +name = "zbus_names" +version = "4.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812a12be4ead7a59e18ea8f047d08add0066b73bc62fc1467066a2472ae088dd" +dependencies = [ + "serde", + "winnow 1.0.4", + "zvariant", +] + +[[package]] +name = "zbus_xml" +version = "5.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1586c021a01ca0a9216dcd874e546382e156a5cbab5fab6cb5f10087e22682a" +dependencies = [ + "serde", + "winnow 1.0.4", + "zbus_names", + "zvariant", +] + +[[package]] +name = "zcheapstr" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1afec51604565183aeb5c54c20aeab286120d4e4460f7f76e3e8bb8c0d99473" +dependencies = [ + "serde", +] + +[[package]] +name = "zerocopy" +version = "0.8.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5fe1f8f1b06191a00962174c61aa5005e0bb391a6d80d07e24d115c01a92ed8" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "863ad3ac83293fb4d740aedbfdc9240dd8d1a50c1099acd76ce80ce7c7230c7f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", + "synstructure", +] + +[[package]] +name = "zerotrie" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f58a2799cfe9a1670c7ea8edbd52ef8edb93cb37a7f83bf31ba9e35d9dae6a40" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3743ddbf47738d929cd1ae2e199d40d0162979dfe31aef8ae646d2bf00bf97a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "zlib-rs" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112" + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" + +[[package]] +name = "zune-core" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56377fd46368984a170bc5aac5567e52ca5da874caa60bea39fcbca78fb658b" + +[[package]] +name = "zune-jpeg" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" +dependencies = [ + "zune-core", +] + +[[package]] +name = "zvariant" +version = "5.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80bf6d6cd6a4b601acb64cfb8284cdc643fcfa75ef26ae3c46ec8aa4e8a1fbb7" +dependencies = [ + "endi", + "enumflags2", + "serde", + "winnow 1.0.4", + "zcheapstr", + "zvariant_derive", + "zvariant_utils", +] + +[[package]] +name = "zvariant_derive" +version = "5.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef2672456ba1cb54b5eaec136e63135b3c7495fb938e53b28a62e633496d486d" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 3.0.9", + "zvariant_utils", +] + +[[package]] +name = "zvariant_utils" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bad0294361a320b694a328460dc73add56c306150f5cb6bfafc44446120008a3" +dependencies = [ + "proc-macro2", + "quote", + "serde", + "syn 3.0.9", + "winnow 1.0.4", +] diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..759692b --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,35 @@ +[workspace] +members = [ + "crates/bevy_forge_core", + "crates/bevy_forge_editor", + "crates/bevy_forge_cli", +] +exclude = ["compat/bsn-smoke"] +resolver = "2" + +[workspace.package] +version = "0.1.0" +edition = "2021" +rust-version = "1.85" +license = "MIT OR Apache-2.0" +authors = ["Bevy Forge contributors"] +repository = "https://github.com/bevy-forge/bevy-forge" + +[workspace.dependencies] +serde = { version = "1", features = ["derive"] } +serde_json = "1" +ron = "0.8" +indexmap = { version = "2", features = ["serde"] } +thiserror = "2" +toml = "0.8" +clap = { version = "4", features = ["derive"] } +anyhow = "1" +tempfile = "3" +image = { version = "0.25", default-features = false, features = ["png", "jpeg"] } +eframe = "0.32" +egui = "0.32" + +[profile.release] +lto = "thin" +codegen-units = 1 +strip = true diff --git a/LICENSE-APACHE b/LICENSE-APACHE new file mode 100644 index 0000000..d645695 --- /dev/null +++ b/LICENSE-APACHE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/LICENSE-MIT b/LICENSE-MIT new file mode 100644 index 0000000..5cc72aa --- /dev/null +++ b/LICENSE-MIT @@ -0,0 +1,18 @@ +MIT License + +Copyright (c) 2026 Bevy Forge contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and +associated documentation files (the "Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the +following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial +portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT +LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO +EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE +USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..2040633 --- /dev/null +++ b/README.md @@ -0,0 +1,321 @@ +# ◆ Bevy Forge + +**A native visual scene editor for the Bevy engine, built around Bevy 0.20's BSN scene notation.** + +Bevy Forge opens a Bevy project, lets you lay out a 2D or 3D scene with a hierarchy, inspector, +viewports and asset browser, then exports the result as paste-ready **BSN Rust code** (primary) +or imperative `Commands::spawn` code. Everything is editable by hand: scenes are plain RON files +and generated code is ordinary Rust. + +![Bevy Forge editor](docs/screenshot.png) + +> **Status: early alpha (0.1.0).** The 0.1 feature set is implemented: the workspace tests, +> formatting check, CLI and generated-BSN compile gate are green on this checkout. This is not a +> tagged release, so expect rough edges and API churn before 0.1.0 is published. + +## Why a standalone editor? + +Bevy Forge does **not** link Bevy. The editor and core crates build without pulling Bevy into the +dependency graph and are not affected by Bevy's release cadence; they target Bevy 0.20 through +generated code. The scene format is an editor-owned RON file, and export produces source code that +your game compiles. That keeps the tool independent of Bevy's runtime scene serialization and makes +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. + +## Two viewports + +| 2D scene editing | 3D wireframe preview | +|---|---| +| ![2D viewport with the platformer demo](docs/screenshot.png) | ![3D viewport with the garden demo](docs/screenshot-3d.png) | + +## Features + +- **Project-aware editor.** Opens a directory containing a `Cargo.toml` with a `bevy` dependency, + indexes its `assets/` folder and lists its `scenes/*.forge.ron` files. +- **Hierarchy panel.** Create empty entities, sprites, text, cameras, lights and primitive meshes; + rename inline; duplicate; delete; reparent and reorder by dragging (cycles rejected); lock and + hide entities; search the tree. +- **Inspector.** Name and Transform editing (translation, Euler rotation, scale, reset); add/remove + components; asset pickers for images and meshes; colour and numeric editors; mesh primitive + parameters and material presets; validation issues for the selected entity. +- **2D viewport.** Textured sprites, text and camera/light glyphs, grid and axes, pan and + cursor-anchored zoom, click selection, grid-snapped dragging, X/Y translate gizmo, drag-and-drop + of images from the asset browser, fit view. +- **3D viewport.** egui-painted wireframe preview (no GPU renderer): orbit/pan/zoom camera, grid, + primitive and sprite proxies, camera/light glyphs, click selection, ground-plane dragging, fit. +- **Asset browser.** Recursive index of the Bevy `assets/` directory with kind filters, search, + lazy thumbnails, double-click assignment and drag payloads for viewports. +- **Undo/redo.** Snapshot-based history (100 steps by default) for every structural and property + edit, including a single undo entry per gizmo drag. +- **Validation.** Structural, asset-reference and numeric range checks surfaced in the UI and by + the CLI, with `error` / `warning` / `info` severities. +- **Persistence.** Human-readable `.forge.ron` scenes, atomic writes, optional autosave. +- **Two export formats.** Bevy 0.20 BSN (`bsn!` / `bsn_list!`) and imperative Rust with + `Commands::spawn` + `.with_children`. +- **Command line.** `forge new`, `forge validate`, `forge export`, `forge info`, `forge scenes`. +- **Editor chrome.** Dark theme, menu bar, status bar, command palette, toasts, welcome screen, + keyboard-shortcut reference and unsaved-changes protection. + +## Requirements + +- **Rust stable, 1.85 or newer** (the workspace sets `rust-version = "1.85"`) and Cargo. +- A desktop session with a working GPU/windowing stack for `eframe`/`egui`: + Linux (X11 or Wayland), macOS, or Windows. Linux/Wayland is the primary development platform; + macOS and Windows go through the same `eframe` backend but are less exercised. +- **No Bevy installation is needed to build the editor or the CLI.** Bevy 0.20 is only needed when + you compile exported code inside a game, or when you check the excluded `compat/bsn-smoke` crate. +- A network connection the first time Cargo fetches dependencies, unless they are already cached. + In the prepared development environment, prefix routine builds with `CARGO_NET_OFFLINE=true`. + +## Quick start + +```bash +# 1. From this repository checkout. +cd bevy-forge + +# 2. Run the editor on the bundled demo project. +cargo run -p bevy_forge_editor -- examples/demo-project + +# 3. Or open a single scene directly (the project is discovered by walking up +# to the nearest Cargo.toml). +cargo run -p bevy_forge_editor -- examples/demo-project/scenes/garden.forge.ron +``` + +The editor binary is named `bevy-forge`. Launched without an argument it shows the welcome screen; +open a project by path (for example `examples/demo-project`), then pick a scene. Passing a project +or scene path on the command line opens it directly. From there: + +1. Select an entity in the hierarchy and edit it in the inspector. +2. Switch the viewport between 2D and 3D with `2` / `3`. +3. Press `Ctrl+E` (Cmd+E on macOS) to open the export dialog and copy the generated BSN. +4. Press `Ctrl+S` to save the scene to `scenes/.forge.ron`. + +A new scene can be started from **File > New scene** (empty, 2D starter or 3D starter) or scaffolded +together with a Bevy project from the CLI. + +## CLI + +`forge` is the command-line companion. Run it through Cargo, or install the binary from this +repository: + +```bash +# Run from the workspace root without installing: +cargo run -p bevy_forge_cli -- --help + +# Optional: install the `forge` binary into ~/.cargo/bin. +cargo install --path crates/bevy_forge_cli +``` + +Common commands (all paths are examples): + +```bash +# Scaffold a new Bevy 0.20 project. The target must not exist or must be empty. +forge new my-game --name my_game --template 3d + +# Validate a scene; --project is discovered from the scene path when omitted. +forge validate examples/demo-project/scenes/garden.forge.ron +forge validate examples/demo-project/scenes/garden.forge.ron --json + +# Export BSN (default) or imperative Rust. +forge export examples/demo-project/scenes/garden.forge.ron --format bsn -o /tmp/garden.rs +forge export examples/demo-project/scenes/platformer.forge.ron --format rust --function platformer + +# Summarise a scene and list a project's scenes. +forge info examples/demo-project/scenes/garden.forge.ron +forge scenes examples/demo-project +``` + +`forge new --template` accepts `empty`, `2d` (default) or `3d`. It writes a complete Bevy 0.20 +project: `Cargo.toml`, `src/main.rs`, `assets/` (generated textures where the template needs them), +`scenes/demo.forge.ron`, `.gitignore` and a `README.md`. + +Exit codes follow the CLI contract: + +| Command | `0` | `1` | `2` | +|---|---|---|---| +| `forge new` | project created | - | target exists and is not empty, I/O error | +| `forge validate` | no validation errors | validation errors found | I/O, RON parse or usage error | +| `forge export` | code generated | codegen error (empty scene, invalid function name, non-finite value, bad custom component) | I/O, RON parse or usage error | +| `forge info` | printed | - | I/O, RON parse or usage error | +| `forge scenes` | listed | - | not a project or I/O error | + +`--help` and `--version` are available on every command level. Human-readable output goes to +stdout; user errors go to stderr with a non-zero exit. + +## Getting BSN into a Bevy app + +Export BSN from the editor (**File > Export > BSN scene**) or the CLI. The result is a Rust module, +not a runtime asset: put it next to your game code and call the generated function from a startup +system. + +```bash +# From the game project's root: write the generated module into src/. +forge export scenes/garden.forge.ron --format bsn -o src/garden.rs +``` + +```rust +// src/main.rs of a Bevy 0.20 game +use bevy::prelude::*; + +mod garden; // the exported file; its function is `garden::scene(..)` + +fn main() { + App::new() + .add_plugins(DefaultPlugins) + .add_systems(Startup, spawn_garden) + .run(); +} + +fn spawn_garden( + mut commands: Commands, + mut meshes: ResMut>, + mut materials: ResMut>, +) { + // The generated signature includes only the asset parameters the scene needs. + commands.spawn_scene(garden::scene(&mut meshes, &mut materials)); +} +``` + +- A scene without meshes generates `pub fn scene() -> impl Scene` - call it with no arguments. +- A scene with meshes generates `meshes` / `materials` parameters (only the ones it uses). Pass + `&mut Assets` / `&mut Assets` from your systems. +- A scene with several roots generates `pub fn scene() -> impl SceneList`; spawn it with + `commands.spawn_scene_list(...)`. +- Custom components are emitted as ` { field: }`. The component type + must derive `Component` plus either `Default + Clone` or `FromTemplate`, and must be in scope + where the generated code is compiled. + +The full template reference, derive requirements, material caveats and a troubleshooting table are +in the [BSN export guide](docs/bsn-export.md). + +## Scene format + +Scenes are human-readable RON files named `*.forge.ron`, stored in the project's `scenes/` +directory. They are Bevy Forge's own format - not Bevy's `.scn.ron` dynamic-scene serialization - +and contain no generated code. + +- [Scene format reference](docs/format.md) - every field and component variant, versioning, + identity and forest invariants, asset paths, save/autosave and validation. +- [Architecture](docs/architecture.md) - crates, data flow, key types and the panel/action model. + +## Project layout + +```text +bevy-forge/ + Cargo.toml workspace (core, editor, CLI) + crates/ + bevy_forge_core/ scene model, project, undo, assets, validation, codegen + bevy_forge_editor/ eframe/egui desktop app; binary `bevy-forge` + bevy_forge_cli/ `forge` command line + examples/demo-project/ Bevy 0.20 demo project with garden (3D) and platformer (2D) scenes + compat/bsn-smoke/ excluded crate that compiles generated BSN against Bevy 0.20 + docs/ architecture, scene format, BSN export + scripts/ generated-code verification helpers +``` + +## Keyboard shortcuts + +`Ctrl` means `Cmd` on macOS. Shortcuts that mutate the scene are ignored while a text field has +keyboard focus. + +| Shortcut | Action | +|---|---| +| `Ctrl+S` | Save the current scene | +| `Ctrl+Shift+S` | Save the scene as... | +| `Ctrl+O` | Open a project... | +| `Ctrl+K` | Command palette | +| `F1` | Keyboard-shortcut reference | +| `Ctrl+Z` | Undo | +| `Ctrl+Shift+Z`, `Ctrl+Y` | Redo | +| `Ctrl+D` | Duplicate selection | +| `Delete` | Delete selection | +| `Ctrl+E` | Export BSN scene | +| `Ctrl+Shift+E` | Export imperative Rust | +| `F` | Fit the active viewport to the scene | +| `G` | Toggle the grid | +| `2` / `3` | Switch to the 2D / 3D viewport | +| `Ctrl+B` | Toggle the asset browser | +| `Ctrl+Shift+L` | Toggle the hierarchy panel | +| `Esc` | Clear the selection (while not typing) | + +Command palette: type to filter, `Up`/`Down` to move, `Enter` to run, `Esc` to close. + +Mouse and pointer interactions: + +| Context | Input | Action | +|---|---|---| +| Hierarchy | click / `Ctrl`+click / `Shift`+click | Select / toggle / extend selection | +| Hierarchy | double-click, right-click | Rename inline, context menu (rename, duplicate, delete, focus, lock) | +| Hierarchy | drag | Reparent or reorder (cycle-creating drops are rejected) | +| 2D viewport | middle mouse, or `Space`+left mouse | Pan | +| 2D viewport | mouse wheel | Zoom around the cursor | +| 2D viewport | click / `Shift`+click | Select topmost / toggle | +| 2D viewport | drag selection, X/Y gizmo handles | Move (grid-snapped when enabled) | +| 3D viewport | middle mouse, or `Alt`+left mouse | Orbit | +| 3D viewport | `Shift`+middle mouse | Pan | +| 3D viewport | mouse wheel | Zoom | +| 3D viewport | click, drag | Select, move on the ground plane | + +Locked entities ignore viewport transforms; hidden entities are skipped by picking. Pan, zoom, +orbit and fit never modify the document. + +## Limitations and roadmap + +Bevy Forge is deliberately focused. Current limitations: + +- **No live editing.** The editor is standalone; there is no in-game plugin, remote protocol or + hot reload of a running Bevy app. +- **BSN is source code, not an asset.** Export writes Rust that your game compiles. Runtime `.bsn` + asset loading/caching is not implemented. +- **Custom components are raw expressions.** The editor does not parse your Rust types, so custom + component fields are stored as raw Rust/BSN expressions with only basic identifier and + non-empty-expression checks. +- **Validation is static.** It checks structure, asset references and value ranges; it does not run + `rustc` on exported code. +- **Material asset caveat.** There is no `StandardMaterial` asset loader by default. Generated BSN + uses inline `materials.add(StandardMaterial { ... })`, so it does not need a registered material + loader; hand-referenced material assets such as `materials/cube.toml` do need one in your game. +- **3D preview, not a renderer.** The 3D viewport paints wireframes and proxies with egui. There is + no PBR preview, shadow preview or GPU scene rendering. +- **Editor features out of scope for 0.1:** animation editor, `bevy_ui` editor, material graph, + terrain tools, collaboration, asset import pipeline. +- **Bevy 0.20 only.** Generated code targets Bevy 0.20.0 BSN. Legacy dynamic-scene (`.scn.ron`) + syntax and broad multi-version support are out of scope. +- **No native file dialogs.** Paths are typed into in-app dialogs. +- **Undo history is in-memory** (100 steps by default) and is cleared when a scene is opened. + +Roadmap direction: in-game live editing over a plugin/remote protocol, runtime scene assets, +richer asset previews and a plugin-facing API. Roadmap items are not implemented yet. + +## Contributing + +1. Read [the architecture notes](docs/architecture.md) and [the scene format](docs/format.md). + Public model APIs are small and should stay stable. +2. Keep the dependency rule: `bevy_forge_core` and `bevy_forge_editor` never depend on Bevy; the + excluded `compat/bsn-smoke` crate is the only Bevy-dependent crate. +3. Format with `cargo fmt` and keep `cargo check --workspace` warning-free for the crates you + touch. Do not add dependencies without agreement. +4. Tests live next to the code (`#[cfg(test)]` modules) and under `crates/*/tests/`; CLI tests run + the real binary with `CARGO_BIN_EXE_forge`. The generated-code gate compiles exported BSN in + `compat/bsn-smoke`. +5. Update the relevant page in `docs/` when behaviour or a template changes. + +Useful verification commands (run them in your checkout; no claim is made that they are green at +every commit): + +```bash +cargo check --workspace +cargo test --workspace +cargo fmt --all --check +cargo run -p bevy_forge_cli -- validate examples/demo-project/scenes/garden.forge.ron +cd compat/bsn-smoke && cargo check +``` + +## License + +Bevy Forge is dual-licensed under the **MIT OR Apache-2.0** license, at your option. See +[LICENSE-MIT](LICENSE-MIT) and [LICENSE-APACHE](LICENSE-APACHE). Unless you explicitly state +otherwise, any contribution intentionally submitted for inclusion in this project shall be +dual-licensed as above, without any additional terms or conditions. diff --git a/compat/bsn-smoke/.gitignore b/compat/bsn-smoke/.gitignore new file mode 100644 index 0000000..a3accbf --- /dev/null +++ b/compat/bsn-smoke/.gitignore @@ -0,0 +1,2 @@ +# Local Bevy build cache (several GB on the first check). +/target/ diff --git a/compat/bsn-smoke/Cargo.lock b/compat/bsn-smoke/Cargo.lock new file mode 100644 index 0000000..fe0aa8a --- /dev/null +++ b/compat/bsn-smoke/Cargo.lock @@ -0,0 +1,4232 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "accesskit" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3b7f7f85a7e5f68090000ed7622545829afd484d210358702ae4cb97dd0c320" +dependencies = [ + "uuid", +] + +[[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f2a1bb052857d5dd49572219344a7332b31b76405648eabac5bc68978251bcd" +dependencies = [ + "android-properties", + "bitflags", + "cc", + "jni", + "libc", + "log", + "ndk", + "ndk-context", + "ndk-sys", + "num_enum", + "thiserror 2.0.21", +] + +[[package]] +name = "android-properties" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys", +] + +[[package]] +name = "approx" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cab112f0a86d568ea0e627cc1d6be74a1e9cd55214684db5561995f6dad897c6" +dependencies = [ + "num-traits", +] + +[[package]] +name = "arrayvec" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" + +[[package]] +name = "ash" +version = "0.38.0+1.3.281" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bb44936d800fea8f016d7f2311c6a4f97aebd5dc86f09906139ec848cf3a46f" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" +dependencies = [ + "event-listener", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-channel" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-executor" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a" +dependencies = [ + "async-task", + "concurrent-queue", + "fastrand", + "futures-lite", + "pin-project-lite", + "slab", +] + +[[package]] +name = "async-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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" +dependencies = [ + "autocfg", + "cfg-if", + "concurrent-queue", + "futures-io", + "futures-lite", + "parking", + "polling", + "rustix", + "slab", + "windows-sys", +] + +[[package]] +name = "async-lock" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" +dependencies = [ + "event-listener", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "async-task" +version = "4.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" +dependencies = [ + "portable-atomic", +] + +[[package]] +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 = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[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", + "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", + "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", + "blake3", + "crossbeam-channel", + "derive_more", + "disqualified", + "downcast-rs", + "either", + "futures-io", + "futures-lite", + "futures-util", + "js-sys", + "ron", + "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", + "serde", + "smallvec", + "thiserror 2.0.21", + "wgpu-types", +] + +[[package]] +name = "bevy_clipboard" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0267b0beafd7e587a1b25b5ce14b271577f5fc1d8fd7828b2b66f0cf5dd649b" +dependencies = [ + "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", +] + +[[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", + "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", + "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_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_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", + "bytemuck", + "futures-lite", + "guillotiere", + "half", + "image", + "rectangle-pack", + "serde", + "thiserror 2.0.21", + "tracing", + "wgpu-types", +] + +[[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_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_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_material", + "bevy_math", + "bevy_mesh", + "bevy_pbr", + "bevy_platform", + "bevy_ptr", + "bevy_reflect", + "bevy_render", + "bevy_scene", + "bevy_shader", + "bevy_shape", + "bevy_sprite", + "bevy_sprite_render", + "bevy_state", + "bevy_tasks", + "bevy_text", + "bevy_time", + "bevy_transform", + "bevy_utils", + "bevy_window", +] + +[[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_image", + "bevy_log", + "bevy_math", + "bevy_mesh", + "bevy_platform", + "bevy_reflect", + "bevy_shape", + "bevy_transform", + "bevy_utils", + "half", + "smallvec", + "tracing", + "wgpu-types", +] + +[[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", +] + +[[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", +] + +[[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", + "bytemuck", + "derive_more", + "encase", + "glam", + "half", + "hexasphere", + "thiserror 2.0.21", + "tracing", + "wgpu-types", +] + +[[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", + "bytemuck", + "derive_more", + "fixedbitset", + "indexmap", + "nonmax", + "offset-allocator", + "smallvec", + "static_assertions", + "thiserror 2.0.21", + "tracing", + "wgpu-types", +] + +[[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", + "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", +] + +[[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", + "erased-serde", + "foldhash", + "glam", + "indexmap", + "inventory", + "serde", + "smallvec", + "smol_str", + "thiserror 2.0.21", + "uuid", + "variadics_please", + "wgpu-types", +] + +[[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", + "bytemuck", + "derive_more", + "downcast-rs", + "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", + "wgpu-types", +] + +[[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_scene" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98c4b1677c948e4c1453883f4441136003c53fd9caa44e0f7d78b37618bdb9ad" +dependencies = [ + "bevy_app", + "bevy_asset", + "bevy_derive", + "bevy_ecs", + "bevy_log", + "bevy_platform", + "bevy_reflect", + "bevy_scene_macros", + "bevy_utils", + "smallvec", + "thiserror 2.0.21", + "tracing", + "variadics_please", +] + +[[package]] +name = "bevy_scene_macros" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4da846d8418ca6e38943707c88075a4a0b8f6eea0fd286d33a024d0257439d0" +dependencies = [ + "bevy_ecs_macro_logic", + "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", +] + +[[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", +] + +[[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", + "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-executor", + "async-task", + "atomic-waker", + "bevy_platform", + "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", +] + +[[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_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 = "bit-set" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2f926cc3060f09db9ebc5b52823d85268d24bb917e472c0c4bea35780a7d" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b71798fca2c1fe1086445a7258a4bc81e6e49dcd24c8d0dd9a1e57395b603f51" + +[[package]] +name = "bitflags" +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 = "block2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" +dependencies = [ + "objc2", +] + +[[package]] +name = "blocking" +version = "1.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a70e4329df6cb94385eed412ec92375c3cdd8a6e502493d1229b6414e4036dfa" +dependencies = [ + "async-channel", + "async-task", + "futures-io", + "futures-lite", + "piper", +] + +[[package]] +name = "bsn-smoke" +version = "0.1.0" +dependencies = [ + "bevy", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytemuck" +version = "1.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" +dependencies = [ + "bytemuck_derive", +] + +[[package]] +name = "bytemuck_derive" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a1f896587b6f2c069c73d2f0913e2d590c3990285cd2f0b6aa02b786b4c679c" +dependencies = [ + "proc-macro2", + "quote", + "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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "cc" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86e055fa6e3e40ad6187ba25b6855eeb635d5271f8e0f0e1e9a5826b8d75bea0" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "combine" +version = "4.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4356eba6976435498d05a311ee9fc486da3bc81c200d3f0d7dd9703b41913f92" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "concurrent-queue" +version = "2.5.0" +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]] +name = "cpufeatures" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" +dependencies = [ + "libc", +] + +[[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6" + +[[package]] +name = "crunchy" +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", +] + +[[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 = "dispatch2" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" +dependencies = [ + "bitflags", + "block2", + "libc", + "objc2", +] + +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "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 = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + +[[package]] +name = "downcast-rs" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "117240f60069e65410b3ae1bb213295bd828f707b5bec6596a1afc8793ce0cbc" + +[[package]] +name = "either" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e9c71c2167ca323c882b99918929403426e2373ea17242ff5653e0d5e1058be" + +[[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 = "equivalent" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + +[[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2" +dependencies = [ + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "find-msvc-tools" +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 = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[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 = "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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-io" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" + +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + +[[package]] +name = "futures-macro" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-core", + "futures-macro", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi", +] + +[[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 = "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", +] + +[[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", + "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.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", + "serde", + "serde_core", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "foldhash", +] + +[[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 = "hermit-abi" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e17592d60ebacc7d5e169f4663c5f84f9161cc90328abcfe8456f41e4dfcb284" + +[[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 = "icu_collections" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe090a42a0d3140fed8c63263ed1fecc02422cb2f4bfe55cd8de2de1b8e105dd" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "404a3b8c486f205c87fe7f23ab6894d84a4ad76d6e4671ae1981ba7def87c599" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20f0088bfb7441f7a37fa0c9e20cb1e9cba1cac6b9ce8cd2a6dc44294f8e8cbc" + +[[package]] +name = "icu_properties" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ea83d991c56158f7cf898bf86436c1dfe7c07ee82fe88951f2695c9f37a379f" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b98f29af82d02b163fe50161388350e743bb3107cc8e8c2f7314cb505faddfcf" + +[[package]] +name = "icu_provider" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413099315636c588ee8f011190b329b971a09a8fae9d157e01dcae8379468514" +dependencies = [ + "displaydoc", + "icu_locale_core", + "serde", + "stable_deref_trait", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "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 = "image" +version = "0.25.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" +dependencies = [ + "bytemuck", + "byteorder-lite", + "moxcms", + "num-traits", +] + +[[package]] +name = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", + "serde", + "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 = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys 0.4.1", + "log", + "simd_cesu8", + "thiserror 2.0.21", + "walkdir", + "windows-link", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn 2.0.119", +] + +[[package]] +name = "jni-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" +dependencies = [ + "jni-sys 0.4.1", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn 2.0.119", +] + +[[package]] +name = "jobserver" +version = "0.1.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" +dependencies = [ + "getrandom", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7883d941dae510fb2d978fc3fe018c71c9e2892fd38854de3e8b92c2e5ad9cc5" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a" + +[[package]] +name = "libloading" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +dependencies = [ + "cfg-if", + "windows-link", +] + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[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.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" + +[[package]] +name = "litrs" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4744e383959f0db86ede514b809b1c53251889093803c05267acc7d4e7030d70" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +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 = "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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "memmap2" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b0ca1155a9ecc25aa12e8313a119f32edf0255fea2f44839c4ac0a12b2ac157" +dependencies = [ + "libc", +] + +[[package]] +name = "moxcms" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b" +dependencies = [ + "num-traits", + "pxfm", +] + +[[package]] +name = "mutants" +version = "0.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc0287524726960e07b119cebd01678f852f147742ae0d925e6a520dca956126" + +[[package]] +name = "naga" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a616d2fb8c89516ac2723a581f69d6c18576046bed761bd6b305e5618e6ae130" +dependencies = [ + "arrayvec", + "bit-set", + "bitflags", + "cfg-if", + "cfg_aliases", + "codespan-reporting", + "half", + "hashbrown 0.17.1", + "indexmap", + "libm", + "log", + "naga-types", + "num-traits", + "once_cell", + "rustc-hash 1.1.0", + "spirv", + "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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" +dependencies = [ + "bitflags", + "jni-sys 0.3.1", + "log", + "ndk-sys", + "num_enum", + "thiserror 1.0.69", +] + +[[package]] +name = "ndk-context" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" + +[[package]] +name = "ndk-sys" +version = "0.6.0+11769913" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873" +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", + "cfg-if", + "cfg_aliases", + "libc", +] + +[[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", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", + "libm", +] + +[[package]] +name = "num_enum" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" +dependencies = [ + "num_enum_derive", + "rustversion", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "objc2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08849bbd4767dfae9457696856ae1c84fe4e0281bbe4a7abff2d0e06fb7981f8" +dependencies = [ + "objc2-encode", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags", + "dispatch2", + "objc2", +] + +[[package]] +name = "objc2-core-graphics" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" +dependencies = [ + "dispatch2", + "objc2", + "objc2-core-foundation", + "objc2-io-surface", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +dependencies = [ + "bitflags", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-io-surface" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" +dependencies = [ + "bitflags", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-metal" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0125f776a10d00af4152d74616409f0d4a2053a6f57fa5b7d6aa2854ac04794" +dependencies = [ + "bitflags", + "block2", + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-quartz-core" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" +dependencies = [ + "bitflags", + "objc2", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation", + "objc2-metal", +] + +[[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "ordered-float" +version = "5.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f59006ce1e204c30956638535b74d317bca99477b9356dfaec54c37f6f600ab" +dependencies = [ + "num-traits", +] + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[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 = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "piper" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" +dependencies = [ + "atomic-waker", + "fastrand", + "futures-io", +] + +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix", + "windows-sys", +] + +[[package]] +name = "portable-atomic" +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", +] + +[[package]] +name = "presser" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8cf8e6a8aa66ce33f63993ffc4ea4271eb5b0530a9002db8455ea6050c77bfa" + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "profiling" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5" + +[[package]] +name = "pxfm" +version = "0.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea" + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +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", + "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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca45419789ae5a7899559e9512e58ca889e41f04f1f2445e9f4b290ceccd1d08" + +[[package]] +name = "raw-window-handle" +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", + "objc2-core-foundation", + "objc2-foundation", + "objc2-quartz-core", +] + +[[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.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832" + +[[package]] +name = "ron" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81116b9531d61eabc41aeb228e4b6b2435bcca3233b98cf3b3077d4e6e9debb3" +dependencies = [ + "bitflags", + "once_cell", + "serde", + "serde_derive", + "typeid", + "unicode-ident", +] + +[[package]] +name = "rustc-hash" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "1.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5e76742e5294153d4dde1207dbec5bdd81eef6fabdc0d2118babadabfd858aa" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "semver" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "simd_cesu8" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simdutf8" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "slotmap" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bdd58c3c93c3d278ca835519292445cb4b0d4dc59ccfdf7ceadaab3f8aeb4038" +dependencies = [ + "version_check", +] + +[[package]] +name = "smallvec" +version = "1.16.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0d5d4b1981b8bbbb7aed204cae5726d683d5d6ac7e74ec54122ea28f315e7c8" + +[[package]] +name = "smol_str" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd538fb6910ac1099850255cf94a94df6551fbdd602454387d0adb2d1ca6dead" +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.4.0+sdk-1.4.341.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9571ea910ebd84c86af4b3ed27f9dbdc6ad06f17c5f96146b2b671e2976744f" +dependencies = [ + "bitflags", +] + +[[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "synstructure" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02" +dependencies = [ + "proc-macro2", + "quote", + "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 = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" +dependencies = [ + "thiserror-impl 2.0.21", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" +dependencies = [ + "proc-macro2", + "quote", + "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 = "tinystr" +version = "0.8.5" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b52fd3255258d26fac7264ab5a76b53c13367136dc011d3d6ad4f8ed070396a3" +dependencies = [ + "pin-project-lite", +] + +[[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", + "toml_datetime", + "toml_parser", + "toml_writer", + "winnow", +] + +[[package]] +name = "toml_datetime" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b86d767906c6c42421dcba507eb9d203e779497710a47782a224bb871653053" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.25.17+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3641d5bbb5349a79e1020a242d251efbc546ad8048d133958323ce9c40a9c9c" +dependencies = [ + "indexmap", + "toml_datetime", + "toml_parser", + "winnow", +] + +[[package]] +name = "toml_parser" +version = "1.1.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baa693a8032d7e1cada7d0041e96126df243179ff061456783ac7f12bda4744c" +dependencies = [ + "winnow", +] + +[[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +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 = "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 = "unicode-ident" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" + +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "unicode-width" +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 = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "uuid" +version = "1.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a0e2d11620c50695fe12bbbfde96b3477fe606e7284c22d505c12ce51214514" +dependencies = [ + "getrandom", + "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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9bb54f33acc68fd454578d9820b0bde1a1a3d17aa17bb7b6595806d02886d409" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.79" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3cbab34de2d982e9b48e18d216d04c4a6f641066ff19ffb699980f591ee3610e" +dependencies = [ + "js-sys", + "tokio", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e29d0c35b16e224a7eeb5cd2d25e3e1968fbd65604117b44d3b789d00ee8535" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f501a8bc3719dba86ef8ae4728879c08001bea749eb1333ac5b91e040e2a6b7" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 3.0.9", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23f0c9c52aa7cd7d77769a4cfe2a9adb1b331f489a41d912ce14513d5ab995c6" +dependencies = [ + "unicode-ident", +] + +[[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88261b9deccee56594c11a3460c462c41f58d148598fe70ad77070126a68aba4" +dependencies = [ + "js-sys", + "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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[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", + "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 = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "527ccdf43dd5b2e8676eed9984ce00e2bbb0a1b85b70c1969dcb6cd2eb55ab9e" +dependencies = [ + "arrayvec", + "bitflags", + "bytemuck", + "cfg-if", + "cfg_aliases", + "document-features", + "hashbrown 0.17.1", + "log", + "portable-atomic", + "profiling", + "raw-window-handle", + "smallvec", + "static_assertions", + "wgpu-core", + "wgpu-hal", + "wgpu-types", +] + +[[package]] +name = "wgpu-core" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "14c018fce9b6270aa203c2fdd56f3cce996713534bd757e4ea58c8560b121f14" +dependencies = [ + "arrayvec", + "bit-set", + "bit-vec", + "bitflags", + "bytemuck", + "cfg_aliases", + "document-features", + "hashbrown 0.17.1", + "indexmap", + "log", + "naga", + "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", + "wgpu-core-deps-windows-linux-android", + "wgpu-hal", + "wgpu-naga-bridge", + "wgpu-types", +] + +[[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", +] + +[[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", +] + +[[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", + "bitflags", + "block2", + "bytemuck", + "cfg-if", + "cfg_aliases", + "gpu-allocator", + "hashbrown 0.17.1", + "libc", + "libloading", + "log", + "naga", + "naga-types", + "objc2", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation", + "objc2-metal", + "objc2-quartz-core", + "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", + "windows", + "windows-core", +] + +[[package]] +name = "wgpu-naga-bridge" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f62e73117bb7a62bfd9c5a5841438a823f6566c6442a808ee269d2d055c081" +dependencies = [ + "naga", + "wgpu-types", +] + +[[package]] +name = "wgpu-types" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "99dad6f1fbdbbdb4c278a6508b059d44688f5cebddf78d005a46a31340269286" +dependencies = [ + "bitflags", + "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-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "windows" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" +dependencies = [ + "windows-collections", + "windows-core", + "windows-future", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" +dependencies = [ + "windows-core", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-future" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" +dependencies = [ + "windows-core", + "windows-link", + "windows-threading", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-numerics" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" +dependencies = [ + "windows-core", + "windows-link", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-threading" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" +dependencies = [ + "windows-link", +] + +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" +dependencies = [ + "memchr", +] + +[[package]] +name = "writeable" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" + +[[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "417ef3fcf6c300b7dbd25c85531a746931c02ad536f3cf4786ed1e88223340b3" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec8ebde2db3681e8c9980cc27822030e68752690ddfa9473e739aeb4dbde6d71" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", + "synstructure", +] + +[[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5fe1f8f1b06191a00962174c61aa5005e0bb391a6d80d07e24d115c01a92ed8" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "863ad3ac83293fb4d740aedbfdc9240dd8d1a50c1099acd76ce80ce7c7230c7f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", + "synstructure", +] + +[[package]] +name = "zerotrie" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "zerovec" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f58a2799cfe9a1670c7ea8edbd52ef8edb93cb37a7f83bf31ba9e35d9dae6a40" +dependencies = [ + "serde", + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3743ddbf47738d929cd1ae2e199d40d0162979dfe31aef8ae646d2bf00bf97a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] diff --git a/compat/bsn-smoke/Cargo.toml b/compat/bsn-smoke/Cargo.toml new file mode 100644 index 0000000..92fc04b --- /dev/null +++ b/compat/bsn-smoke/Cargo.toml @@ -0,0 +1,29 @@ +[package] +name = "bsn-smoke" +version = "0.1.0" +edition = "2021" +publish = false +description = "Compiles Bevy Forge's generated BSN against real Bevy 0.20.0 (excluded verification crate)" + +# Standalone crate: it is excluded from the Bevy Forge workspace so that the +# editor/core/CLI crates never depend on Bevy. Do not add this to the root +# workspace. +[workspace] + +[dependencies] +# Keep this feature set in sync with `.swarm/recon/R02.md`: it is the minimal +# set that provides scenes, sprites, text, 3D primitives/materials, cameras and +# lights for the code-generation smoke test. +bevy = { version = "0.20", default-features = false, features = [ + "std", + "bevy_scene", + "bevy_sprite", + "bevy_asset", + "bevy_color", + "bevy_text", + "bevy_pbr", + "bevy_mesh", + "bevy_camera", + "bevy_light", + "bevy_image", +] } diff --git a/compat/bsn-smoke/README.md b/compat/bsn-smoke/README.md new file mode 100644 index 0000000..b4b8585 --- /dev/null +++ b/compat/bsn-smoke/README.md @@ -0,0 +1,126 @@ +# bsn-smoke — Bevy 0.20 generated-code compile check + +`bsn-smoke` is Bevy Forge's proof that exported scenes are **real Bevy 0.20 code**, not +just plausible-looking text. It is the only crate in this repository that depends on +Bevy; it is excluded from the workspace (`Cargo.toml` → `exclude = ["compat/bsn-smoke"]`) +so the editor, core and CLI crates stay Bevy-free and build in seconds. + +```text +compat/bsn-smoke/ +├── Cargo.toml standalone package (empty [workspace]), pinned Bevy 0.20.0 +├── Cargo.lock lockfile for the verification dependency graph +├── src/lib.rs hand-written scenes mirroring every codegen shape + support types +├── src/generated.rs BSN export of the 3D garden demo (written by the gate script) +├── src/generated_2d.rs BSN export of the 2D platformer demo (written by the gate script) +└── src/rust_export.rs imperative `Commands::spawn` export shape, also compiled +``` + +## What is verified + +Compile-checked against `bevy 0.20.0` (`default-features = false`): + +- **Roots:** single root `bsn!` / `-> impl Scene`; multiple roots `bsn_list!` / + `-> impl SceneList` with `--` separators. +- **Hierarchy:** nested `Children [ ... -- ... ]`, including a `Visibility` patch on a + grandchild and components before/after `Children`. +- **Names and transforms:** `Name("...")`, full three-field `Transform` with + `Quat::from_euler(EulerRot::XYZ, x.to_radians(), ...)`. +- **Sprites:** image path handles, `Color::srgba`, `flip_x`/`flip_y`, and braced + `custom_size: { Some(Vec2::new(w, h)) }` (the brace form is mandatory in BSN patches). +- **Text:** `Text2d`, `TextFont { font_size: FontSize::Px(..) }`, `TextColor`. +- **Cameras:** bare `Camera2d`, `Camera3d::default()`. +- **Lights:** `PointLight` (color/intensity/range/shadows) and `DirectionalLight` + (color/illuminance/shadows). +- **Custom components:** struct-with-fields patches (`Health { current: 10.0, max: 10.0 }`, + `forge_demo::Orbit { radius: 2.4, speed: 0.45 }`) and bare unit-component patches + (`Marker`). +- **3D meshes:** all five primitive shapes (`Cuboid`, `Sphere`, `Plane3d`, `Cylinder`, + `Capsule3d`) with braced method-call handles (`Mesh3d({ meshes.add(..) })`) and inline + materials (`MeshMaterial3d::({ materials.add(StandardMaterial { ..default() }) })`), + plus an asset mesh path (`Mesh3d("models/...#Mesh0/Primitive0")`) with an inline material. +- **Generated signatures:** mesh scenes take `&mut Assets` and + `&mut Assets` only when needed. +- **Imperative Rust export shape** (`src/rust_export.rs`): `commands.spawn`, + `with_children`, `Name::new`, `Transform::from_xyz(..).with_rotation(..).with_scale(..)`, + `Sprite { ..default() }`, `Mesh3d`/`MeshMaterial3d`, `Text2d::new`, `TextFont`, + `TextColor`, `Camera3d::default()`, lights and `Visibility`. + +The committed `src/generated*.rs` files are the CLI exports produced by the last +successful gate run (plus the shim described below), so the crate compiles on a fresh +checkout even before the script runs. `scripts/check-generated-code.sh` regenerates them +from the demo project and recompiles, verifying the generator + demo scenes end to end. +The exports are deterministic, so re-running the gate leaves the files unchanged. + +### Compatibility shim for custom type paths + +Generated BSN references custom components by their project type path, e.g. +`forge_demo::Orbit`. In a real Bevy project the generated function is pasted where that +path is already in scope. Here the generated file is a module, so the gate script prepends +four lines (`#![allow(unused_imports)]`, two comments, and `use crate::*;`) to expose the +crate-root support types; **the generated code itself is appended unchanged**. `lib.rs` +provides `forge_demo::Orbit` with the same fields as `examples/demo-project/src/main.rs`. +If a demo scene introduces another custom type, add its support type to `lib.rs`. + +## Bevy version and feature set + +Pinned to **Bevy 0.20.0** through `Cargo.lock`. Feature set (chosen by +`.swarm/recon/R02.md` to keep compile time and APIs aligned with the verified BSN forms): + +```toml +bevy = { version = "0.20", default-features = false, features = [ + "std", "bevy_scene", "bevy_sprite", "bevy_asset", "bevy_color", "bevy_text", + "bevy_pbr", "bevy_mesh", "bevy_camera", "bevy_light", "bevy_image", +] } +``` + +No windowing or audio backends are compiled in and no GPU device is initialized: this is a +**compile** check that needs no display, asset files or `App`. + +## Running + +Directly (the load-bearing check): + +```bash +cd compat/bsn-smoke +CARGO_NET_OFFLINE=true cargo check +``` + +End to end (generator → CLI → Bevy compile): + +```bash +bash scripts/check-generated-code.sh +``` + +The script exports `examples/demo-project/scenes/garden.forge.ron` and +`.../platformer.forge.ron` with `cargo run -q -p bevy_forge_cli -- export ... --format bsn`, +writes the results into this crate, and runs the check. It fails loudly on any error; if +the CLI or demo project does not exist yet it fails with a clear message instead of +silently passing. `FORGE_SKIP_GENERATED=1` skips with an explicit message (bootstrap only); +`FORGE_SKIP_SECOND_SCENE=1` skips the 2D export; `FORGE_GARDEN_SCENE` / +`FORGE_PLATFORMER_SCENE` override the scene paths; `CARGO_TARGET_DIR` is passed through. + +## Expected cost + +Measured on this Linux machine (Rust 1.99.1, 2026-10): + +| Build | Wall time | `target/` size | +|---|---|---| +| First `cargo check`, cold target dir | **2 min 15 s** | ~803 MB | +| Subsequent `cargo check`, warm target dir | < 1 s | — | +| Reusing an existing Bevy 0.20 `target/` (e.g. recon) | ~1 s | — | + +`compat/bsn-smoke/target/` is ignored by `.gitignore`; never commit it. If you already +have a warm Bevy 0.20 target directory, point `CARGO_TARGET_DIR` at it to avoid the cold +build (the dependency feature set must match exactly). + +## Runtime caveats (compile ≠ loaded assets) + +- Asset **paths** (`Sprite { image: "textures/x.png" }`, `Mesh3d("models/...gltf#...")`) + compile, but Bevy turns them into `HandleTemplate::Path` values that are resolved by the + `AssetServer` at spawn time. If the project has no loader/asset for the path, the handle + stays loading/failed and nothing renders — it is not a compile error (see R02 §9). +- Bevy Forge never emits material **asset paths** (`MeshMaterial3d::("...")`), + because those need a project-registered material loader; materials are always inline + `materials.add(StandardMaterial { ..default() })` handles. +- The check is compile-only: it does not spawn the scenes or validate asset existence. + Asset references are validated separately by `forge validate` / the editor. diff --git a/compat/bsn-smoke/src/generated.rs b/compat/bsn-smoke/src/generated.rs new file mode 100644 index 0000000..ed039d6 --- /dev/null +++ b/compat/bsn-smoke/src/generated.rs @@ -0,0 +1,130 @@ +// compat/bsn-smoke shim: expose crate-root support types (demo custom components). +// The generated code below this line is unchanged. +#![allow(unused_imports)] // the crate::* glob also provides the Bevy prelude names +use crate::*; +use bevy::prelude::*; +use bevy::scene::bsn_list; + +pub fn scene( + meshes: &mut Assets, + materials: &mut Assets, +) -> impl SceneList { + bsn_list! { + Name("Camera") + Transform { + translation: Vec3::new(7.5, 5.0, 12.0), + rotation: Quat::from_euler(EulerRot::XYZ, -16.0f32.to_radians(), 30.0f32.to_radians(), 0.0f32.to_radians()), + scale: Vec3::new(1.0, 1.0, 1.0), + } + Camera3d::default() + -- + Name("Sun") + Transform { + translation: Vec3::new(0.0, 0.0, 0.0), + rotation: Quat::from_euler(EulerRot::XYZ, -55.0f32.to_radians(), -25.0f32.to_radians(), 0.0f32.to_radians()), + scale: Vec3::new(1.0, 1.0, 1.0), + } + DirectionalLight { color: Color::srgba(1.0, 0.95, 0.85, 1.0), illuminance: 11000.0, shadow_maps_enabled: true } + -- + Name("Ground") + Transform { + translation: Vec3::new(0.0, 0.0, 0.0), + rotation: Quat::from_euler(EulerRot::XYZ, 0.0f32.to_radians(), 0.0f32.to_radians(), 0.0f32.to_radians()), + scale: Vec3::new(1.0, 1.0, 1.0), + } + Mesh3d({ meshes.add(Plane3d::new(Vec3::Y, Vec2::new(13.0, 13.0))) }) + MeshMaterial3d::({ materials.add(StandardMaterial { + base_color: Color::srgba(0.31, 0.48, 0.23, 1.0), + metallic: 0.0, + perceptual_roughness: 0.92, + unlit: false, + ..default() + }) }) + -- + Name("Shrub") + Transform { + translation: Vec3::new(-3.4, 1.0, 1.8), + rotation: Quat::from_euler(EulerRot::XYZ, 0.0f32.to_radians(), 0.0f32.to_radians(), 0.0f32.to_radians()), + scale: Vec3::new(1.25, 0.9, 1.25), + } + Mesh3d({ meshes.add(Sphere::new(1.0)) }) + MeshMaterial3d::({ materials.add(StandardMaterial { + base_color: Color::srgba(0.24, 0.45, 0.22, 1.0), + metallic: 0.0, + perceptual_roughness: 0.85, + unlit: false, + ..default() + }) }) + -- + Name("Flower Stem") + Transform { + translation: Vec3::new(2.6, 0.75, 1.4), + rotation: Quat::from_euler(EulerRot::XYZ, 0.0f32.to_radians(), 0.0f32.to_radians(), 0.0f32.to_radians()), + scale: Vec3::new(1.0, 1.0, 1.0), + } + Mesh3d({ meshes.add(Cylinder::new(0.09, 1.5)) }) + MeshMaterial3d::({ materials.add(StandardMaterial { + base_color: Color::srgba(0.35, 0.55, 0.27, 1.0), + metallic: 0.0, + perceptual_roughness: 0.8, + unlit: false, + ..default() + }) }) + Children [ + Name("Flower Bloom") + Transform { + translation: Vec3::new(0.0, 1.0, 0.0), + rotation: Quat::from_euler(EulerRot::XYZ, 0.0f32.to_radians(), 0.0f32.to_radians(), 0.0f32.to_radians()), + scale: Vec3::new(1.0, 0.75, 1.0), + } + Mesh3d({ meshes.add(Sphere::new(0.42)) }) + MeshMaterial3d::({ materials.add(StandardMaterial { + base_color: Color::srgba(0.91, 0.42, 0.65, 1.0), + metallic: 0.0, + perceptual_roughness: 0.4, + unlit: false, + ..default() + }) }) + ] + -- + Name("Orbit Crystal") + Transform { + translation: Vec3::new(0.0, 2.0, 0.0), + rotation: Quat::from_euler(EulerRot::XYZ, 0.0f32.to_radians(), 0.0f32.to_radians(), 0.0f32.to_radians()), + scale: Vec3::new(1.0, 1.0, 1.0), + } + Mesh3d({ meshes.add(Sphere::new(0.8)) }) + MeshMaterial3d::({ materials.add(StandardMaterial { + base_color: Color::srgba(0.27, 0.76, 0.69, 1.0), + metallic: 0.15, + perceptual_roughness: 0.2, + unlit: false, + ..default() + }) }) + forge_demo::Orbit { radius: 2.4, speed: 0.45 } + -- + Name("Lantern Post") + Transform { + translation: Vec3::new(3.8, 1.2, -1.2), + rotation: Quat::from_euler(EulerRot::XYZ, 0.0f32.to_radians(), 0.0f32.to_radians(), 0.0f32.to_radians()), + scale: Vec3::new(1.0, 1.0, 1.0), + } + Mesh3d({ meshes.add(Cylinder::new(0.12, 2.4)) }) + MeshMaterial3d::({ materials.add(StandardMaterial { + base_color: Color::srgba(0.18, 0.2, 0.25, 1.0), + metallic: 0.6, + perceptual_roughness: 0.35, + unlit: false, + ..default() + }) }) + Children [ + Name("Lantern Glow") + Transform { + translation: Vec3::new(0.0, 1.25, 0.0), + rotation: Quat::from_euler(EulerRot::XYZ, 0.0f32.to_radians(), 0.0f32.to_radians(), 0.0f32.to_radians()), + scale: Vec3::new(1.0, 1.0, 1.0), + } + PointLight { color: Color::srgba(1.0, 0.82, 0.55, 1.0), intensity: 600.0, range: 12.0, shadow_maps_enabled: true } + ] + } +} diff --git a/compat/bsn-smoke/src/generated_2d.rs b/compat/bsn-smoke/src/generated_2d.rs new file mode 100644 index 0000000..a14132d --- /dev/null +++ b/compat/bsn-smoke/src/generated_2d.rs @@ -0,0 +1,191 @@ +// compat/bsn-smoke shim: expose crate-root support types (demo custom components). +// The generated code below this line is unchanged. +#![allow(unused_imports)] // the crate::* glob also provides the Bevy prelude names +use crate::*; +use bevy::prelude::*; +use bevy::scene::bsn_list; + +pub fn scene() -> impl SceneList { + bsn_list! { + Name("Backdrop") + Transform { + translation: Vec3::new(0.0, 0.0, -30.0), + rotation: Quat::from_euler(EulerRot::XYZ, 0.0f32.to_radians(), 0.0f32.to_radians(), 0.0f32.to_radians()), + scale: Vec3::new(1.0, 1.0, 1.0), + } + Sprite { image: "textures/background.png", color: Color::srgba(1.0, 1.0, 1.0, 1.0), custom_size: { Some(Vec2::new(960.0, 540.0)) }, flip_x: false, flip_y: false } + -- + Name("Camera") + Transform { + translation: Vec3::new(0.0, 0.0, 10.0), + rotation: Quat::from_euler(EulerRot::XYZ, 0.0f32.to_radians(), 0.0f32.to_radians(), 0.0f32.to_radians()), + scale: Vec3::new(1.0, 1.0, 1.0), + } + Camera2d + -- + Name("Title") + Transform { + translation: Vec3::new(0.0, 214.0, 5.0), + rotation: Quat::from_euler(EulerRot::XYZ, 0.0f32.to_radians(), 0.0f32.to_radians(), 0.0f32.to_radians()), + scale: Vec3::new(1.0, 1.0, 1.0), + } + Text2d("Bevy Forge Platformer") + TextFont { font_size: FontSize::Px(44.0) } + TextColor(Color::srgba(0.96, 0.94, 0.88, 1.0)) + -- + Name("Hint") + Transform { + translation: Vec3::new(0.0, 176.0, 5.0), + rotation: Quat::from_euler(EulerRot::XYZ, 0.0f32.to_radians(), 0.0f32.to_radians(), 0.0f32.to_radians()), + scale: Vec3::new(1.0, 1.0, 1.0), + } + Text2d("Collect the coins!") + TextFont { font_size: FontSize::Px(20.0) } + TextColor(Color::srgba(0.95, 0.67, 0.25, 1.0)) + -- + Name("Ground 1") + Transform { + translation: Vec3::new(-336.0, -224.0, -2.0), + rotation: Quat::from_euler(EulerRot::XYZ, 0.0f32.to_radians(), 0.0f32.to_radians(), 0.0f32.to_radians()), + scale: Vec3::new(1.0, 1.0, 1.0), + } + Sprite { image: "textures/tile.png", color: Color::srgba(1.0, 1.0, 1.0, 1.0), custom_size: { Some(Vec2::new(96.0, 96.0)) }, flip_x: false, flip_y: false } + -- + Name("Ground 2") + Transform { + translation: Vec3::new(-240.0, -224.0, -2.0), + rotation: Quat::from_euler(EulerRot::XYZ, 0.0f32.to_radians(), 0.0f32.to_radians(), 0.0f32.to_radians()), + scale: Vec3::new(1.0, 1.0, 1.0), + } + Sprite { image: "textures/tile.png", color: Color::srgba(1.0, 1.0, 1.0, 1.0), custom_size: { Some(Vec2::new(96.0, 96.0)) }, flip_x: false, flip_y: false } + -- + Name("Ground 3") + Transform { + translation: Vec3::new(-144.0, -224.0, -2.0), + rotation: Quat::from_euler(EulerRot::XYZ, 0.0f32.to_radians(), 0.0f32.to_radians(), 0.0f32.to_radians()), + scale: Vec3::new(1.0, 1.0, 1.0), + } + Sprite { image: "textures/tile.png", color: Color::srgba(1.0, 1.0, 1.0, 1.0), custom_size: { Some(Vec2::new(96.0, 96.0)) }, flip_x: false, flip_y: false } + -- + Name("Ground 4") + Transform { + translation: Vec3::new(-48.0, -224.0, -2.0), + rotation: Quat::from_euler(EulerRot::XYZ, 0.0f32.to_radians(), 0.0f32.to_radians(), 0.0f32.to_radians()), + scale: Vec3::new(1.0, 1.0, 1.0), + } + Sprite { image: "textures/tile.png", color: Color::srgba(1.0, 1.0, 1.0, 1.0), custom_size: { Some(Vec2::new(96.0, 96.0)) }, flip_x: false, flip_y: false } + -- + Name("Ground 5") + Transform { + translation: Vec3::new(48.0, -224.0, -2.0), + rotation: Quat::from_euler(EulerRot::XYZ, 0.0f32.to_radians(), 0.0f32.to_radians(), 0.0f32.to_radians()), + scale: Vec3::new(1.0, 1.0, 1.0), + } + Sprite { image: "textures/tile.png", color: Color::srgba(1.0, 1.0, 1.0, 1.0), custom_size: { Some(Vec2::new(96.0, 96.0)) }, flip_x: false, flip_y: false } + -- + Name("Ground 6") + Transform { + translation: Vec3::new(144.0, -224.0, -2.0), + rotation: Quat::from_euler(EulerRot::XYZ, 0.0f32.to_radians(), 0.0f32.to_radians(), 0.0f32.to_radians()), + scale: Vec3::new(1.0, 1.0, 1.0), + } + Sprite { image: "textures/tile.png", color: Color::srgba(1.0, 1.0, 1.0, 1.0), custom_size: { Some(Vec2::new(96.0, 96.0)) }, flip_x: false, flip_y: false } + -- + Name("Ground 7") + Transform { + translation: Vec3::new(240.0, -224.0, -2.0), + rotation: Quat::from_euler(EulerRot::XYZ, 0.0f32.to_radians(), 0.0f32.to_radians(), 0.0f32.to_radians()), + scale: Vec3::new(1.0, 1.0, 1.0), + } + Sprite { image: "textures/tile.png", color: Color::srgba(1.0, 1.0, 1.0, 1.0), custom_size: { Some(Vec2::new(96.0, 96.0)) }, flip_x: false, flip_y: false } + -- + Name("Ground 8") + Transform { + translation: Vec3::new(336.0, -224.0, -2.0), + rotation: Quat::from_euler(EulerRot::XYZ, 0.0f32.to_radians(), 0.0f32.to_radians(), 0.0f32.to_radians()), + scale: Vec3::new(1.0, 1.0, 1.0), + } + Sprite { image: "textures/tile.png", color: Color::srgba(1.0, 1.0, 1.0, 1.0), custom_size: { Some(Vec2::new(96.0, 96.0)) }, flip_x: false, flip_y: false } + -- + Name("Ledge 1") + Transform { + translation: Vec3::new(-48.0, -64.0, -2.0), + rotation: Quat::from_euler(EulerRot::XYZ, 0.0f32.to_radians(), 0.0f32.to_radians(), 0.0f32.to_radians()), + scale: Vec3::new(1.0, 1.0, 1.0), + } + Sprite { image: "textures/tile.png", color: Color::srgba(1.0, 1.0, 1.0, 1.0), custom_size: { Some(Vec2::new(96.0, 96.0)) }, flip_x: false, flip_y: false } + -- + Name("Ledge 2") + Transform { + translation: Vec3::new(48.0, -64.0, -2.0), + rotation: Quat::from_euler(EulerRot::XYZ, 0.0f32.to_radians(), 0.0f32.to_radians(), 0.0f32.to_radians()), + scale: Vec3::new(1.0, 1.0, 1.0), + } + Sprite { image: "textures/tile.png", color: Color::srgba(1.0, 1.0, 1.0, 1.0), custom_size: { Some(Vec2::new(96.0, 96.0)) }, flip_x: false, flip_y: false } + -- + Name("Ledge 3") + Transform { + translation: Vec3::new(144.0, -64.0, -2.0), + rotation: Quat::from_euler(EulerRot::XYZ, 0.0f32.to_radians(), 0.0f32.to_radians(), 0.0f32.to_radians()), + scale: Vec3::new(1.0, 1.0, 1.0), + } + Sprite { image: "textures/tile.png", color: Color::srgba(1.0, 1.0, 1.0, 1.0), custom_size: { Some(Vec2::new(96.0, 96.0)) }, flip_x: false, flip_y: false } + -- + Name("Player") + Transform { + translation: Vec3::new(-192.0, -128.0, 0.0), + rotation: Quat::from_euler(EulerRot::XYZ, 0.0f32.to_radians(), 0.0f32.to_radians(), 0.0f32.to_radians()), + scale: Vec3::new(1.0, 1.0, 1.0), + } + Sprite { image: "textures/player.png", color: Color::srgba(1.0, 1.0, 1.0, 1.0), custom_size: { Some(Vec2::new(64.0, 96.0)) }, flip_x: false, flip_y: false } + Children [ + Name("Hat") + Transform { + translation: Vec3::new(0.0, 42.0, 1.0), + rotation: Quat::from_euler(EulerRot::XYZ, 0.0f32.to_radians(), 0.0f32.to_radians(), 0.0f32.to_radians()), + scale: Vec3::new(1.0, 1.0, 1.0), + } + Sprite { image: "textures/hat.png", color: Color::srgba(1.0, 1.0, 1.0, 1.0), custom_size: { Some(Vec2::new(56.0, 28.0)) }, flip_x: false, flip_y: false } + ] + -- + Name("Coin 1") + Transform { + translation: Vec3::new(-288.0, -104.0, 1.0), + rotation: Quat::from_euler(EulerRot::XYZ, 0.0f32.to_radians(), 0.0f32.to_radians(), 0.0f32.to_radians()), + scale: Vec3::new(1.0, 1.0, 1.0), + } + Sprite { image: "textures/coin.png", color: Color::srgba(1.0, 1.0, 1.0, 1.0), custom_size: { Some(Vec2::new(48.0, 48.0)) }, flip_x: false, flip_y: false } + -- + Name("Coin 2") + Transform { + translation: Vec3::new(48.0, 28.0, 1.0), + rotation: Quat::from_euler(EulerRot::XYZ, 0.0f32.to_radians(), 0.0f32.to_radians(), 0.0f32.to_radians()), + scale: Vec3::new(1.0, 1.0, 1.0), + } + Sprite { image: "textures/coin.png", color: Color::srgba(1.0, 1.0, 1.0, 1.0), custom_size: { Some(Vec2::new(48.0, 48.0)) }, flip_x: false, flip_y: false } + -- + Name("Coin 3") + Transform { + translation: Vec3::new(240.0, -96.0, 1.0), + rotation: Quat::from_euler(EulerRot::XYZ, 0.0f32.to_radians(), 0.0f32.to_radians(), 0.0f32.to_radians()), + scale: Vec3::new(1.0, 1.0, 1.0), + } + Sprite { image: "textures/coin.png", color: Color::srgba(1.0, 1.0, 1.0, 1.0), custom_size: { Some(Vec2::new(48.0, 48.0)) }, flip_x: false, flip_y: false } + -- + Name("Crate") + Transform { + translation: Vec3::new(352.0, -144.0, -1.0), + rotation: Quat::from_euler(EulerRot::XYZ, 0.0f32.to_radians(), 0.0f32.to_radians(), 0.0f32.to_radians()), + scale: Vec3::new(1.0, 1.0, 1.0), + } + Sprite { image: "textures/crate.png", color: Color::srgba(1.0, 1.0, 1.0, 1.0), custom_size: { Some(Vec2::new(64.0, 64.0)) }, flip_x: false, flip_y: false } + -- + Name("Crate 2") + Transform { + translation: Vec3::new(352.0, -80.0, -1.0), + rotation: Quat::from_euler(EulerRot::XYZ, 0.0f32.to_radians(), 0.0f32.to_radians(), 0.0f32.to_radians()), + scale: Vec3::new(1.0, 1.0, 1.0), + } + Sprite { image: "textures/crate.png", color: Color::srgba(1.0, 1.0, 1.0, 1.0), custom_size: { Some(Vec2::new(64.0, 64.0)) }, flip_x: false, flip_y: false } + } +} diff --git a/compat/bsn-smoke/src/lib.rs b/compat/bsn-smoke/src/lib.rs new file mode 100644 index 0000000..90d23d8 --- /dev/null +++ b/compat/bsn-smoke/src/lib.rs @@ -0,0 +1,334 @@ +//! Compile-smoke crate for Bevy Forge's generated code. +//! +//! This crate is intentionally **not** a workspace member: it is the only place +//! in the repository that depends on Bevy (0.20.0). It compiles: +//! +//! * hand-written scenes that mirror `ExportFormat::Bsn` output for every +//! supported component, hierarchy shape and asset pattern (this file), and +//! * the real files written by `scripts/check-generated-code.sh` +//! (`src/generated.rs`, `src/generated_2d.rs`). +//! +//! See `README.md` for the feature set, commands and expected build cost. +//! The generated code targets the compile-verified forms in `.swarm/recon/R02.md`. + +use bevy::prelude::*; +use bevy::scene::bsn; +use bevy::scene::bsn_list; + +/// Bevy Forge's BSN export for `examples/demo-project/scenes/garden.forge.ron`. +/// +/// Committed with a harmless starter scene so this crate compiles before the +/// end-to-end gate has run; `scripts/check-generated-code.sh` replaces it with +/// the real CLI export. +#[path = "generated.rs"] +pub mod generated; + +/// Bevy Forge's BSN export for `examples/demo-project/scenes/platformer.forge.ron`. +#[path = "generated_2d.rs"] +pub mod generated_2d; + +/// Imperative `Commands::spawn` export shape (`ExportFormat::Rust`), mirroring +/// what the CLI emits. It is compiled here so the secondary export format is +/// verified against real Bevy types as well. +pub mod rust_export; + +/// A custom component with fields, as emitted for `ComponentData::Custom`. +/// +/// `Component + Default + Clone` is enough for Bevy 0.20's blanket +/// `FromTemplate` implementation (see R02 §4), so generated +/// `Health { current: 10.0, max: 10.0 }` patches compile. +#[derive(Component, Default, Clone)] +pub struct Health { + pub current: f32, + pub max: f32, +} + +/// A unit custom component: Bevy Forge emits the bare type path when a custom +/// component has no fields. +#[derive(Component, Default, Clone)] +pub struct Marker; + +/// Support types from `examples/demo-project/src/main.rs`. +/// +/// The demo garden scene carries a custom `forge_demo::Orbit` component, so the +/// generated garden BSN references that path. Providing the module here lets the +/// export compile in the smoke crate without editing the generated file. +pub mod forge_demo { + use bevy::prelude::*; + + /// Slowly orbits an entity around the world origin (demo project component). + #[derive(Component, Default, Clone)] + pub struct Orbit { + pub radius: f32, + pub speed: f32, + } +} + +/// 2D scene: single root, `Name`/`Transform`/`Sprite` with image and braced +/// `custom_size`, a custom component with fields, a unit custom component, and +/// nested `Children` (including a visibility patch on a grandchild). +/// +/// Mirrors the shape generated for a typical platformer root entity. +pub fn scene_2d() -> impl Scene { + bsn! { + Name("Player") + Transform { + translation: Vec3::new(0.0, 1.0, 0.0), + rotation: Quat::from_euler(EulerRot::XYZ, 0.0, 0.0, 0.0), + scale: Vec3::new(1.0, 1.0, 1.0), + } + Sprite { + image: "textures/player.png", + color: Color::srgba(1.0, 1.0, 1.0, 1.0), + custom_size: { Some(Vec2::new(64.0, 64.0)) }, + flip_x: false, + flip_y: false, + } + Health { current: 10.0, max: 10.0 } + forge_demo::Orbit { radius: 2.5, speed: 0.8 } + Children [ + Name("Hat") + Transform { + translation: Vec3::new(0.0, 40.0, 0.0), + rotation: Quat::from_euler(EulerRot::XYZ, 0.0, 0.0, 0.0), + scale: Vec3::new(1.0, 1.0, 1.0), + } + Sprite { color: Color::srgba(1.0, 0.5, 0.2, 1.0) } + Marker + Children [ + Name("Glow") + Transform { + translation: Vec3::new(0.0, 0.5, 0.0), + rotation: Quat::from_euler(EulerRot::XYZ, 0.0, 0.0, 0.0), + scale: Vec3::new(0.5, 0.5, 1.0), + } + Sprite { color: Color::srgba(1.0, 1.0, 0.4, 0.6) } + Visibility::Visible + ] + ] + } +} + +/// Multiple roots: the generator emits `bsn_list!` plus `--` separators and a +/// `-> impl SceneList` return type. +pub fn scene_2d_list() -> impl SceneList { + bsn_list! { + Name("Left") + Transform { + translation: Vec3::new(-2.0, 0.0, 0.0), + rotation: Quat::from_euler(EulerRot::XYZ, 0.0, 0.0, 0.0), + scale: Vec3::new(1.0, 1.0, 1.0), + } + Sprite { image: "textures/tile.png", color: Color::srgba(1.0, 1.0, 1.0, 1.0) } + -- + Name("Right") + Transform { + translation: Vec3::new(2.0, 0.0, 0.0), + rotation: Quat::from_euler(EulerRot::XYZ, 0.0, 0.0, 0.0), + scale: Vec3::new(1.0, 1.0, 1.0), + } + Sprite { color: Color::srgba(0.9, 0.6, 0.3, 1.0) } + Children [ + Name("Right Child") + Transform { + translation: Vec3::new(0.0, 1.0, 0.0), + rotation: Quat::from_euler(EulerRot::XYZ, 0.0, 0.0, 0.0), + scale: Vec3::new(1.0, 1.0, 1.0), + } + Sprite { color: Color::srgba(0.2, 0.8, 0.9, 1.0) } + ] + } +} + +/// Text: `Text2d` payload plus `TextFont`/`TextColor` patches. +pub fn scene_text() -> impl Scene { + bsn! { + Name("Title") + Text2d("Bevy Forge\nHello \"BSN\"") + TextFont { font_size: FontSize::Px(32.0) } + TextColor(Color::srgba(1.0, 0.9, 0.8, 1.0)) + Transform { + translation: Vec3::new(0.0, 200.0, 0.0), + rotation: Quat::from_euler(EulerRot::XYZ, 0.0, 0.0, 0.0), + scale: Vec3::new(1.0, 1.0, 1.0), + } + } +} + +/// Bare `Camera2d` patch, as emitted for 2D scenes. +pub fn scene_camera_2d() -> impl Scene { + bsn! { + Name("Main Camera") + Camera2d + Transform { + translation: Vec3::new(0.0, 0.0, 999.0), + rotation: Quat::from_euler(EulerRot::XYZ, 0.0, 0.0, 0.0), + scale: Vec3::new(1.0, 1.0, 1.0), + } + } +} + +/// 3D stage: `Camera3d::default()`, both light types and the `Visibility` +/// variants on children (a single-root scene with three child entities). +pub fn scene_3d_stage() -> impl Scene { + bsn! { + Name("Stage") + Transform { + translation: Vec3::new(0.0, 0.0, 0.0), + rotation: Quat::from_euler(EulerRot::XYZ, 0.0, 0.0, 0.0), + scale: Vec3::new(1.0, 1.0, 1.0), + } + Children [ + Name("Main Camera") + Camera3d::default() + Transform { + translation: Vec3::new(0.0, 4.0, 10.0), + rotation: Quat::from_euler(EulerRot::XYZ, -0.35, 0.0, 0.0), + scale: Vec3::new(1.0, 1.0, 1.0), + } + -- + Name("Sun") + DirectionalLight { + color: Color::srgb(1.0, 0.95, 0.9), + illuminance: 10000.0, + shadow_maps_enabled: true, + } + Transform { + translation: Vec3::new(0.0, 8.0, 0.0), + rotation: Quat::from_euler(EulerRot::XYZ, -0.8, 0.4, 0.0), + scale: Vec3::new(1.0, 1.0, 1.0), + } + Visibility::Visible + -- + Name("Lantern") + PointLight { + color: Color::srgb(1.0, 0.6, 0.25), + intensity: 800.0, + range: 20.0, + shadow_maps_enabled: true, + } + Transform { + translation: Vec3::new(1.5, 1.0, 1.5), + rotation: Quat::from_euler(EulerRot::XYZ, 0.0, 0.0, 0.0), + scale: Vec3::new(1.0, 1.0, 1.0), + } + Visibility::Inherited + ] + } +} + +/// 3D primitives and inline materials: the generated function takes the +/// `meshes`/`materials` asset parameters when a scene contains meshes. +/// +/// Covers all five `PrimitiveShape` variants from the codegen contract. +pub fn scene_3d_primitives( + meshes: &mut Assets, + materials: &mut Assets, +) -> impl Scene { + bsn! { + Name("Primitives") + Transform { + translation: Vec3::new(0.0, 0.0, 0.0), + rotation: Quat::from_euler(EulerRot::XYZ, 0.0, 0.0, 0.0), + scale: Vec3::new(1.0, 1.0, 1.0), + } + Children [ + Name("Cube") + Mesh3d({ meshes.add(Cuboid::new(1.0, 1.0, 1.0)) }) + MeshMaterial3d::({ materials.add(StandardMaterial { + base_color: Color::srgb(0.9, 0.35, 0.3), + metallic: 0.0, + perceptual_roughness: 0.5, + unlit: false, + ..default() + }) }) + Transform { + translation: Vec3::new(-2.0, 0.5, 0.0), + rotation: Quat::from_euler(EulerRot::XYZ, 0.0, 0.0, 0.0), + scale: Vec3::new(1.0, 1.0, 1.0), + } + -- + Name("Sphere") + Mesh3d({ meshes.add(Sphere::new(0.5)) }) + MeshMaterial3d::({ materials.add(StandardMaterial { + base_color: Color::srgb(0.3, 0.6, 0.9), + metallic: 0.0, + perceptual_roughness: 0.5, + unlit: false, + ..default() + }) }) + Transform { + translation: Vec3::new(-1.0, 0.5, 0.0), + rotation: Quat::from_euler(EulerRot::XYZ, 0.0, 0.0, 0.0), + scale: Vec3::new(1.0, 1.0, 1.0), + } + -- + Name("Plane") + Mesh3d({ meshes.add(Plane3d::new(Vec3::Y, Vec2::new(6.0, 6.0))) }) + MeshMaterial3d::({ materials.add(StandardMaterial { + base_color: Color::srgb(0.35, 0.5, 0.3), + metallic: 0.0, + perceptual_roughness: 0.9, + unlit: false, + ..default() + }) }) + Transform { + translation: Vec3::new(0.0, 0.0, 0.0), + rotation: Quat::from_euler(EulerRot::XYZ, 0.0, 0.0, 0.0), + scale: Vec3::new(1.0, 1.0, 1.0), + } + -- + Name("Cylinder") + Mesh3d({ meshes.add(Cylinder::new(0.4, 1.6)) }) + MeshMaterial3d::({ materials.add(StandardMaterial { + base_color: Color::srgb(0.8, 0.7, 0.25), + metallic: 0.1, + perceptual_roughness: 0.4, + unlit: false, + ..default() + }) }) + Transform { + translation: Vec3::new(1.0, 0.8, 0.0), + rotation: Quat::from_euler(EulerRot::XYZ, 0.0, 0.0, 0.0), + scale: Vec3::new(1.0, 1.0, 1.0), + } + -- + Name("Capsule") + Mesh3d({ meshes.add(Capsule3d::new(0.3, 1.0)) }) + MeshMaterial3d::({ materials.add(StandardMaterial { + base_color: Color::srgb(0.55, 0.4, 0.8), + metallic: 0.0, + perceptual_roughness: 0.6, + unlit: false, + ..default() + }) }) + Transform { + translation: Vec3::new(2.0, 0.8, 0.0), + rotation: Quat::from_euler(EulerRot::XYZ, 0.0, 0.0, 0.0), + scale: Vec3::new(1.0, 1.0, 1.0), + } + ] + } +} + +/// Asset mesh: `Mesh3d("")` uses a path handle while the material is an +/// inline `materials.add(..)` handle (the generator never emits asset material +/// paths, which would need a project-registered loader at runtime). +pub fn scene_asset_mesh(materials: &mut Assets) -> impl Scene { + bsn! { + Name("Crate") + Mesh3d("models/crate.gltf#Mesh0/Primitive0") + MeshMaterial3d::({ materials.add(StandardMaterial { + base_color: Color::srgb(0.6, 0.45, 0.3), + metallic: 0.0, + perceptual_roughness: 0.8, + unlit: false, + ..default() + }) }) + Transform { + translation: Vec3::new(0.0, 0.5, 0.0), + rotation: Quat::from_euler(EulerRot::XYZ, 0.0, 0.0, 0.0), + scale: Vec3::new(1.0, 1.0, 1.0), + } + } +} diff --git a/compat/bsn-smoke/src/rust_export.rs b/compat/bsn-smoke/src/rust_export.rs new file mode 100644 index 0000000..80dc6c4 --- /dev/null +++ b/compat/bsn-smoke/src/rust_export.rs @@ -0,0 +1,98 @@ +//! Compile check for Bevy Forge's secondary export format: imperative +//! `Commands::spawn` code (`ExportFormat::Rust`, contract §3.2). +//! +//! This mirrors what a user gets when pasting the Rust export into a system. +//! It is hand-written from the contract shape (A04's generator output is +//! verified separately by its tests); the value here is that every Bevy API call +//! the exporter emits is checked against real Bevy 0.20.0. + +use bevy::prelude::*; + +use crate::Health; + +/// Representative Rust export: a 2D sprite with a child and a custom component, +/// a primitive mesh with an inline material, text and a 3D camera + light. +/// +/// The parameter set matches the contract: `commands` always, `asset_server` +/// only because a sprite image is loaded, `meshes`/`materials` only because a +/// primitive mesh is spawned. +pub fn scene( + commands: &mut Commands, + asset_server: &AssetServer, + meshes: &mut Assets, + materials: &mut Assets, +) { + commands + .spawn(( + Name::new("Player"), + Transform::from_xyz(0.0, 1.0, 0.0) + .with_rotation(Quat::from_euler(EulerRot::XYZ, 0.0, 0.0, 0.0)) + .with_scale(Vec3::new(1.0, 1.0, 1.0)), + Sprite { + image: asset_server.load("textures/player.png"), + color: Color::srgba(1.0, 1.0, 1.0, 1.0), + custom_size: Some(Vec2::new(64.0, 64.0)), + ..default() + }, + Health { + current: 10.0, + max: 10.0, + }, + )) + .with_children(|parent| { + parent.spawn(( + Name::new("Hat"), + Transform::from_xyz(0.0, 40.0, 0.0), + Sprite { + color: Color::srgba(1.0, 0.5, 0.2, 1.0), + ..default() + }, + )); + }); + + commands.spawn(( + Name::new("Cube"), + Mesh3d(meshes.add(Cuboid::new(1.0, 1.0, 1.0))), + MeshMaterial3d(materials.add(StandardMaterial { + base_color: Color::srgb(0.9, 0.35, 0.3), + metallic: 0.0, + perceptual_roughness: 0.5, + unlit: false, + ..default() + })), + Transform::from_xyz(0.0, 0.5, 0.0), + )); + + commands.spawn(( + Name::new("Title"), + Text2d::new("Bevy Forge"), + TextFont { + font_size: FontSize::Px(32.0), + ..default() + }, + TextColor(Color::WHITE), + Transform::from_xyz(0.0, 2.0, 0.0), + )); + + commands.spawn(( + Name::new("Main Camera"), + Camera3d::default(), + Transform::from_xyz(0.0, 4.0, 10.0), + )); + + commands.spawn(( + Name::new("Sun"), + DirectionalLight { + illuminance: 10000.0, + shadow_maps_enabled: true, + ..default() + }, + Transform::default(), + )); + + commands.spawn(( + Name::new("Hidden Helper"), + Transform::default(), + Visibility::Hidden, + )); +} diff --git a/crates/bevy_forge_cli/Cargo.toml b/crates/bevy_forge_cli/Cargo.toml new file mode 100644 index 0000000..b3dba0b --- /dev/null +++ b/crates/bevy_forge_cli/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "bevy_forge_cli" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +authors.workspace = true +description = "Command line tools for Bevy Forge scenes and projects" + +[[bin]] +name = "forge" +path = "src/main.rs" + +[dependencies] +bevy_forge_core = { path = "../bevy_forge_core" } +clap.workspace = true +serde_json.workspace = true +anyhow.workspace = true + +[dev-dependencies] +tempfile.workspace = true diff --git a/crates/bevy_forge_cli/src/commands.rs b/crates/bevy_forge_cli/src/commands.rs new file mode 100644 index 0000000..3159339 --- /dev/null +++ b/crates/bevy_forge_cli/src/commands.rs @@ -0,0 +1,424 @@ +//! Implementations of the `forge` subcommands. + +use std::collections::BTreeMap; +use std::fs; +use std::io::Write; +use std::path::{Path, PathBuf}; + +use anyhow::{Context, Result}; +use bevy_forge_core::{ + validate_scene, AssetIndex, CodegenOptions, ComponentData, ExportFormat, Issue, Project, + SceneDoc, Severity, +}; +use serde_json::{json, Value}; + +use crate::scaffold; +use crate::{Commands, FormatArg, TemplateArg}; + +/// Execute one `forge` subcommand, returning its process exit code. +pub(crate) fn run(command: Commands) -> Result { + match command { + Commands::New { + dir, + name, + template, + } => new_project(&dir, name.as_deref(), template), + Commands::Validate { + scene, + project, + json, + } => validate(&scene, project.as_deref(), json), + Commands::Export { + scene, + format, + output, + function, + } => export(&scene, format, output.as_deref(), &function), + Commands::Info { scene, json } => info(&scene, json), + Commands::Scenes { project } => scenes(&project), + } +} + +// --------------------------------------------------------------------------- +// new +// --------------------------------------------------------------------------- + +fn new_project(dir: &Path, name: Option<&str>, template: TemplateArg) -> Result { + let project_template = template.into(); + let created = scaffold::create(dir, name, project_template)?; + let mut output = String::new(); + output.push_str(&format!( + "Created Bevy project `{}` at {}\n", + created.name, + created.root.display() + )); + output.push_str(&format!(" template: {}\n", template_label(template))); + output.push_str(&format!(" scene: {}\n", created.scene_path.display())); + output.push_str("\nNext steps:\n"); + output.push_str(&format!(" cd {}\n", created.root.display())); + output.push_str(" cargo run\n"); + output.push_str(" forge validate scenes/demo.forge.ron\n"); + if matches!(template, TemplateArg::Empty) { + output + .push_str(" # add entities in Bevy Forge before exporting the empty starter scene\n"); + } else { + output.push_str(" forge export scenes/demo.forge.ron --format bsn -o src/scene.rs\n"); + } + print_stdout(&output)?; + Ok(0) +} + +fn template_label(template: TemplateArg) -> &'static str { + match template { + TemplateArg::Empty => "empty", + TemplateArg::TwoD => "2D", + TemplateArg::ThreeD => "3D", + } +} + +// --------------------------------------------------------------------------- +// validate +// --------------------------------------------------------------------------- + +fn validate(scene: &Path, project_arg: Option<&Path>, json: bool) -> Result { + let project = resolve_project(scene, project_arg)?; + let document = load_scene(scene, project.as_ref())?; + let assets = project + .as_ref() + .map(|project| AssetIndex::scan(&project.assets_dir)); + if let Some(error) = assets.as_ref().and_then(|assets| assets.error.as_deref()) { + eprintln!("forge: warning: could not scan assets: {error}"); + } + let report = validate_scene(&document, assets.as_ref()); + + if json { + let issues: Vec = report.sorted().into_iter().map(issue_json).collect(); + let payload = json!({ + "valid": report.is_ok(), + "errors": report.errors(), + "warnings": report.warnings(), + "issues": issues, + }); + print_stdout(&format!("{}\n", serde_json::to_string(&payload)?))?; + } else { + let mut output = String::new(); + for issue in report.sorted() { + output.push_str(&format_issue(issue)); + output.push('\n'); + } + output.push_str(&format!( + "Summary: {} errors, {} warnings, {} info — {}\n", + report.errors(), + report.warnings(), + report.count(Severity::Info), + if report.is_ok() { "valid" } else { "invalid" } + )); + print_stdout(&output)?; + } + + Ok(if report.is_ok() { 0 } else { 1 }) +} + +fn format_issue(issue: &Issue) -> String { + let mut line = issue.severity.label().to_string(); + if let Some(entity) = issue.entity { + line.push_str(&format!(" [entity {entity}]")); + } + if let Some(field) = &issue.field { + line.push_str(&format!(" [{field}]")); + } + line.push(' '); + line.push_str(&issue.message); + line +} + +fn issue_json(issue: &Issue) -> Value { + json!({ + "severity": issue.severity.label(), + "entity": issue.entity, + "field": issue.field, + "message": issue.message, + }) +} + +// --------------------------------------------------------------------------- +// export +// --------------------------------------------------------------------------- + +fn export(scene: &Path, format: FormatArg, output: Option<&Path>, function: &str) -> Result { + let document = read_scene_file(scene)?; + let export_format: ExportFormat = format.into(); + let options = CodegenOptions { + function_name: function.to_string(), + // The CLI contract requires exported files to start with the Bevy + // prelude import; the editor adds its own attribution header for + // previews. + include_header: false, + }; + let mut code = match export_format.generate(&document, &options) { + Ok(code) => code, + Err(error) => { + eprintln!("forge: export failed: {error}"); + return Ok(1); + } + }; + // The codegen contract promises a trailing newline; make sure we never + // write a file without one even if a future generator forgets it. + if !code.ends_with('\n') { + code.push('\n'); + } + + match output { + Some(path) => { + if let Some(parent) = path.parent() { + if !parent.as_os_str().is_empty() { + fs::create_dir_all(parent).with_context(|| { + format!("could not create output directory {}", parent.display()) + })?; + } + } + fs::write(path, code.as_bytes()) + .with_context(|| format!("could not write {}", path.display()))?; + print_stdout(&format!( + "Wrote {} to {}\n", + export_format.label(), + path.display() + ))?; + } + None => print_stdout(&code)?, + } + Ok(0) +} + +// --------------------------------------------------------------------------- +// info +// --------------------------------------------------------------------------- + +fn info(scene: &Path, json: bool) -> Result { + let project = discover_project(scene); + let document = load_scene(scene, project.as_ref())?; + let assets = project + .as_ref() + .map(|project| AssetIndex::scan(&project.assets_dir)); + if let Some(error) = assets.as_ref().and_then(|assets| assets.error.as_deref()) { + eprintln!("forge: warning: could not scan assets: {error}"); + } + let report = validate_scene(&document, assets.as_ref()); + let stats = document.stats(); + let path = display_path(scene); + + let mut component_counts: BTreeMap = BTreeMap::new(); + let mut asset_references: Vec = Vec::new(); + for node in document.entities.values() { + for component in &node.components { + let key = component_key(component); + *component_counts.entry(key).or_insert(0) += 1; + for (field, path) in component.asset_references() { + asset_references.push(AssetReference { + entity: node.id, + field, + path: path.to_string(), + }); + } + } + } + + if json { + let components: BTreeMap<&str, usize> = component_counts + .iter() + .map(|(key, count)| (key.as_str(), *count)) + .collect(); + let references: Vec = asset_references + .iter() + .map(|reference| { + json!({ + "entity": reference.entity, + "field": reference.field, + "path": reference.path, + }) + }) + .collect(); + let project_json = project.as_ref().map(|project| { + json!({ + "name": project.name, + "root": path_string(&project.root), + }) + }); + let payload = json!({ + "name": document.name, + "path": path_string(&path), + "format_version": document.format_version, + "entities": stats.entities, + "roots": stats.roots, + "stats": { + "sprites": stats.sprites, + "meshes": stats.meshes, + "lights": stats.lights, + "cameras": stats.cameras, + "custom_components": stats.custom_components, + }, + "components": components, + "asset_references": references, + "project": project_json, + "validation": { + "valid": report.is_ok(), + "errors": report.errors(), + "warnings": report.warnings(), + "info": report.count(Severity::Info), + }, + }); + print_stdout(&format!("{}\n", serde_json::to_string(&payload)?))?; + } else { + let mut output = String::new(); + output.push_str(&format!("Scene: {}\n", document.name)); + output.push_str(&format!("Path: {}\n", path.display())); + output.push_str(&format!("Format: version {}\n", document.format_version)); + match &project { + Some(project) => output.push_str(&format!( + "Project: {} ({})\n", + project.name, + project.root.display() + )), + None => output.push_str("Project: (not found)\n"), + } + output.push_str(&format!( + "Entities: {} (roots: {})\n", + stats.entities, stats.roots + )); + output.push_str(&format!( + "Stats: sprites {}, meshes {}, lights {}, cameras {}, custom components {}\n", + stats.sprites, stats.meshes, stats.lights, stats.cameras, stats.custom_components + )); + if component_counts.is_empty() { + output.push_str("Components: (none)\n"); + } else { + output.push_str("Components:\n"); + for (key, count) in &component_counts { + output.push_str(&format!(" {key}: {count}\n")); + } + } + if asset_references.is_empty() { + output.push_str("Asset references: (none)\n"); + } else { + output.push_str("Asset references:\n"); + for reference in &asset_references { + output.push_str(&format!( + " [entity {}] {} {}\n", + reference.entity, reference.field, reference.path + )); + } + } + output.push_str(&format!( + "Validation: {} errors, {} warnings, {} info ({})\n", + report.errors(), + report.warnings(), + report.count(Severity::Info), + if report.is_ok() { "valid" } else { "invalid" } + )); + print_stdout(&output)?; + } + + Ok(0) +} + +struct AssetReference { + entity: u64, + field: &'static str, + path: String, +} + +fn component_key(component: &ComponentData) -> String { + match component { + ComponentData::Custom(custom) => format!("custom:{}", custom.type_path), + other => other.key(), + } +} + +// --------------------------------------------------------------------------- +// scenes +// --------------------------------------------------------------------------- + +fn scenes(project_arg: &Path) -> Result { + let project = Project::open(project_arg)?; + let mut scenes = project.list_scenes(); + scenes.sort(); + let mut output = String::new(); + for scene in scenes { + output.push_str(&path_string(&scene)); + output.push('\n'); + } + print_stdout(&output)?; + Ok(0) +} + +// --------------------------------------------------------------------------- +// Shared helpers +// --------------------------------------------------------------------------- + +/// Resolve the project for a scene: explicit flag first, then walk up. +fn resolve_project(scene: &Path, explicit: Option<&Path>) -> Result> { + match explicit { + Some(root) => Ok(Some(Project::open(root)?)), + None => Ok(discover_project(scene)), + } +} + +/// Walk up from `scene`, returning the first directory that opens as a Bevy project. +fn discover_project(scene: &Path) -> Option { + let scene = std::path::absolute(scene).ok()?; + let mut current = scene.parent(); + while let Some(directory) = current { + if directory.join("Cargo.toml").is_file() { + if let Ok(project) = Project::open(directory) { + return Some(project); + } + } + current = directory.parent(); + } + None +} + +/// Load a scene, using the project loader when a project is available. +fn load_scene(scene: &Path, project: Option<&Project>) -> Result { + match project { + Some(project) => Ok(project.load_scene(scene)?), + None => read_scene_file(scene), + } +} + +/// Load a scene file that may live outside a Bevy project. +fn read_scene_file(scene: &Path) -> Result { + // `Project::load_scene` is a pure RON file reader; a minimal project + // description keeps parsing and version normalisation in one place for + // standalone scenes as well. + let root = scene + .parent() + .unwrap_or_else(|| Path::new(".")) + .to_path_buf(); + let loader = Project { + root: root.clone(), + name: String::new(), + assets_dir: root, + bevy_version: None, + }; + Ok(loader.load_scene(scene)?) +} + +fn display_path(path: &Path) -> PathBuf { + fs::canonicalize(path) + .unwrap_or_else(|_| std::path::absolute(path).unwrap_or_else(|_| path.to_path_buf())) +} + +fn path_string(path: &Path) -> String { + path.to_string_lossy().into_owned() +} + +/// Write to stdout without panicking on a broken pipe. +fn print_stdout(text: &str) -> Result<()> { + let stdout = std::io::stdout(); + let mut handle = stdout.lock(); + handle + .write_all(text.as_bytes()) + .context("could not write to stdout")?; + handle.flush().context("could not flush stdout") +} diff --git a/crates/bevy_forge_cli/src/main.rs b/crates/bevy_forge_cli/src/main.rs new file mode 100644 index 0000000..f29c0d4 --- /dev/null +++ b/crates/bevy_forge_cli/src/main.rs @@ -0,0 +1,131 @@ +//! `forge` — command line tools for Bevy Forge scenes. +//! +//! The binary validates and exports `.forge.ron` scenes and scaffolds new Bevy +//! 0.20 projects. Every subcommand prints human readable output by default and +//! supports machine-readable output where the contract asks for it (`--json`). +//! +//! Exit codes: +//! +//! * `0` — success (and `validate` found no errors) +//! * `1` — validation errors or a code generation failure +//! * `2` — usage, I/O, parse errors, or a directory that is not a Bevy project + +mod commands; +mod scaffold; + +use std::path::PathBuf; +use std::process::ExitCode; + +use bevy_forge_core::{ExportFormat, ProjectTemplate}; +use clap::{Parser, Subcommand, ValueEnum}; + +#[derive(Debug, Parser)] +#[command( + name = "forge", + version, + about = "Validate, export and scaffold Bevy Forge scenes for Bevy 0.20 BSN", + propagate_version = true +)] +struct Cli { + #[command(subcommand)] + command: Commands, +} + +#[derive(Debug, Subcommand)] +pub(crate) enum Commands { + /// Create a new Bevy 0.20 project with a starter scene. + New { + /// Target directory (must not exist or be empty). + dir: PathBuf, + /// Cargo package name; defaults to a sanitised directory name. + #[arg(long, value_name = "NAME")] + name: Option, + /// Starter scene template. + #[arg(long, value_enum, value_name = "TEMPLATE", default_value_t = TemplateArg::TwoD)] + template: TemplateArg, + }, + /// Validate a .forge.ron scene and its asset references. + Validate { + /// Scene file to validate. + scene: PathBuf, + /// Project root; discovered by walking up from the scene when omitted. + #[arg(long, value_name = "DIR")] + project: Option, + /// Machine-readable output. + #[arg(long)] + json: bool, + }, + /// Export a scene as Bevy 0.20 BSN or imperative Rust code. + Export { + /// Scene file to export. + scene: PathBuf, + /// Generated code style. + #[arg(long, value_enum, value_name = "FORMAT", default_value_t = FormatArg::Bsn)] + format: FormatArg, + /// Output file; code is printed to stdout when omitted. + #[arg(short, long, value_name = "FILE")] + output: Option, + /// Name of the generated Rust function. + #[arg(long, value_name = "NAME", default_value = "scene")] + function: String, + }, + /// Print scene information and validation status. + Info { + /// Scene file to inspect. + scene: PathBuf, + /// Machine-readable output. + #[arg(long)] + json: bool, + }, + /// List the scenes of a Bevy project, one absolute path per line. + Scenes { + /// Bevy project root. + project: PathBuf, + }, +} + +#[derive(Clone, Copy, Debug, ValueEnum)] +pub(crate) enum TemplateArg { + Empty, + #[value(name = "2d")] + TwoD, + #[value(name = "3d")] + ThreeD, +} + +impl From for ProjectTemplate { + fn from(value: TemplateArg) -> Self { + match value { + TemplateArg::Empty => Self::Empty, + TemplateArg::TwoD => Self::TwoD, + TemplateArg::ThreeD => Self::ThreeD, + } + } +} + +#[derive(Clone, Copy, Debug, ValueEnum)] +pub(crate) enum FormatArg { + Bsn, + Rust, +} + +impl From for ExportFormat { + fn from(value: FormatArg) -> Self { + match value { + FormatArg::Bsn => Self::Bsn, + FormatArg::Rust => Self::Rust, + } + } +} + +fn main() -> ExitCode { + // Clap handles `--help` (exit 0) and usage errors (exit 2) itself. + let cli = Cli::parse(); + match commands::run(cli.command) { + Ok(code) => ExitCode::from(code), + Err(error) => { + eprintln!("forge: {error}"); + ExitCode::from(2) + } + } +} diff --git a/crates/bevy_forge_cli/src/scaffold.rs b/crates/bevy_forge_cli/src/scaffold.rs new file mode 100644 index 0000000..97e66f6 --- /dev/null +++ b/crates/bevy_forge_cli/src/scaffold.rs @@ -0,0 +1,319 @@ +//! Project scaffolding for `forge new`. + +use std::fs; +use std::path::{Path, PathBuf}; + +use anyhow::{bail, Context, Result}; +use bevy_forge_core::codegen::{CodegenOptions, ExportFormat}; +use bevy_forge_core::{samples, Project, ProjectTemplate}; + +/// Default scene written into a freshly scaffolded project. +const DEMO_SCENE_FILE: &str = "demo.forge.ron"; + +/// A project created by [`create`]. +pub(crate) struct CreatedProject { + pub name: String, + pub root: PathBuf, + pub scene_path: PathBuf, +} + +/// Create a Bevy 0.20 project in `dir` with a starter scene for `template`. +/// +/// `dir` must not exist yet or must be an empty directory. Existing content is +/// never overwritten: the emptiness check runs before the first write. +pub(crate) fn create( + dir: &Path, + name: Option<&str>, + template: ProjectTemplate, +) -> Result { + ensure_empty_target(dir)?; + let package_name = match name { + Some(name) => validate_package_name(name)?, + None => default_package_name(dir), + }; + + fs::create_dir_all(dir) + .with_context(|| format!("could not create target directory {}", dir.display()))?; + for subdirectory in ["src", "assets", "scenes"] { + fs::create_dir_all(dir.join(subdirectory)) + .with_context(|| format!("could not create {}", dir.join(subdirectory).display()))?; + } + + write_file(&dir.join("Cargo.toml"), &manifest(&package_name))?; + write_file(&dir.join("assets/.gitkeep"), "")?; + write_file(&dir.join(".gitignore"), GITIGNORE)?; + write_file(&dir.join("README.md"), &readme(&package_name, template))?; + + // Re-open the project so the starter scene goes through the same atomic + // serialisation path the editor uses. + let project = Project::open(dir).with_context(|| { + format!( + "could not open the newly created project at {}", + dir.display() + ) + })?; + let document = samples::for_template(template); + let scene_path = project.scenes_dir().join(DEMO_SCENE_FILE); + project + .save_scene(&document, Some(&scene_path)) + .with_context(|| { + format!( + "could not write the starter scene to {}", + scene_path.display() + ) + })?; + + // Make `cargo run` show the starter scene immediately instead of a blank + // window: generate the BSN scene module and call it from a startup system. + if template != ProjectTemplate::Empty { + let code = ExportFormat::Bsn + .generate( + &document, + &CodegenOptions { + function_name: "scene".to_string(), + include_header: true, + }, + ) + .context("could not generate starter BSN scene")?; + write_file(&dir.join("src/scene.rs"), &code)?; + let needs_meshes = code.contains("meshes: &mut Assets"); + let main = if needs_meshes { + r#"//! Bevy 0.20 app scaffolded by Bevy Forge. + +mod scene; + +use bevy::prelude::*; + +fn main() { + App::new() + .add_plugins(DefaultPlugins) + .add_systems(Startup, setup) + .run(); +} + +fn setup( + mut commands: Commands, + mut meshes: ResMut>, + mut materials: ResMut>, +) { + commands.spawn_scene_list(scene::scene(&mut meshes, &mut materials)); +} +"# + } else { + r#"//! Bevy 0.20 app scaffolded by Bevy Forge. + +mod scene; + +use bevy::prelude::*; + +fn main() { + App::new() + .add_plugins(DefaultPlugins) + .add_systems(Startup, setup) + .run(); +} + +fn setup(mut commands: Commands) { + commands.spawn_scene_list(scene::scene()); +} +"# + }; + write_file(&dir.join("src/main.rs"), main)?; + } else { + write_file(&dir.join("src/main.rs"), MAIN_RS)?; + } + + Ok(CreatedProject { + name: project.name.clone(), + root: project.root.clone(), + scene_path, + }) +} + +/// Refuse to scaffold into a non-empty directory. +fn ensure_empty_target(dir: &Path) -> Result<()> { + if !dir.exists() { + return Ok(()); + } + if !dir.is_dir() { + bail!( + "target path exists and is not a directory: {}", + dir.display() + ); + } + let mut entries = fs::read_dir(dir) + .with_context(|| format!("could not read target directory {}", dir.display()))?; + if let Some(entry) = entries.next() { + let entry = + entry.with_context(|| format!("could not read the contents of {}", dir.display()))?; + bail!( + "the target directory already exists and is not empty: {} (found {})", + dir.display(), + entry.file_name().to_string_lossy() + ); + } + Ok(()) +} + +/// Derive a Cargo package name from the target directory name. +fn default_package_name(dir: &Path) -> String { + let base = dir + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or("bevy-forge-project"); + let mut slug = String::new(); + let mut last_was_dash = false; + for character in base.chars() { + let character = character.to_ascii_lowercase(); + if character.is_ascii_alphanumeric() || character == '_' { + slug.push(character); + last_was_dash = false; + } else if !last_was_dash && !slug.is_empty() { + slug.push('-'); + last_was_dash = true; + } + } + let slug = slug.trim_matches('-').to_string(); + if slug.is_empty() { + "bevy-forge-project".to_string() + } else if slug.starts_with(|character: char| character.is_ascii_digit()) { + format!("forge-{slug}") + } else { + slug + } +} + +/// Reject `--name` values Cargo would refuse. +fn validate_package_name(name: &str) -> Result { + if name.is_empty() { + bail!("invalid package name: the name must not be empty"); + } + let valid_first = name + .chars() + .next() + .is_some_and(|character| character.is_ascii_alphabetic() || character == '_'); + if !valid_first { + bail!("invalid package name {name:?}: it must start with an ASCII letter or '_'"); + } + if !name + .chars() + .all(|character| character.is_ascii_alphanumeric() || character == '_' || character == '-') + { + bail!("invalid package name {name:?}: use letters, digits, '-' and '_' only"); + } + Ok(name.to_string()) +} + +fn write_file(path: &Path, contents: &str) -> Result<()> { + fs::write(path, contents).with_context(|| format!("could not write {}", path.display())) +} + +fn manifest(package_name: &str) -> String { + format!( + "[package]\n\ + name = \"{package_name}\"\n\ + version = \"0.1.0\"\n\ + edition = \"2021\"\n\ + \n\ + [dependencies]\n\ + bevy = \"0.20\"\n" + ) +} + +fn readme(package_name: &str, template: ProjectTemplate) -> String { + let scene_hint = match template { + ProjectTemplate::Empty => { + "`scenes/demo.forge.ron` is an empty starter scene. Add entities in the Bevy Forge\n\ + editor, then export it to paste-ready Rust." + } + _ => { + "`scenes/demo.forge.ron` is a starter scene. Open it with the Bevy Forge\n\ + editor, or export it to paste-ready Rust." + } + }; + let spawn_hint = match template { + ProjectTemplate::Empty => { + "Once the scene has entities, export it with `forge export`; an empty scene\n\ + cannot be exported until something is added." + } + _ => { + "The starter scenes have multiple roots, so spawn the exported function with\n\ + `commands.spawn_scene_list(scene())`. A single-root scene uses\n\ + `commands.spawn_scene(scene())`; mesh scenes also need the `meshes` and\n\ + `materials` parameters. Add `use bevy::scene::SceneList` (or `Scene`) if the\n\ + trait is not already in scope." + } + }; + format!( + "# {package_name}\n\ + \n\ + A Bevy 0.20 project scaffolded with [Bevy Forge](https://github.com/bevy-forge/bevy-forge).\n\ + \n\ + ## Run\n\ + \n\ + ```sh\n\ + cargo run\n\ + ```\n\ + \n\ + ## Scene\n\ + \n\ + {scene_hint}\n\ + \n\ + ```sh\n\ + forge export scenes/demo.forge.ron --format bsn -o src/scene.rs\n\ + ```\n\ + \n\ + Generated BSN code is ordinary Rust for Bevy 0.20. {spawn_hint}\n" + ) +} + +const MAIN_RS: &str = r#"//! Minimal Bevy 0.20 app scaffolded by Bevy Forge. + +use bevy::prelude::*; + +fn main() { + App::new().add_plugins(DefaultPlugins).run(); +} +"#; + +const GITIGNORE: &str = "/target\n.forge/\n"; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn package_name_validation_accepts_common_names() { + assert_eq!(validate_package_name("my-game").unwrap(), "my-game"); + assert_eq!(validate_package_name("game_2").unwrap(), "game_2"); + assert!(validate_package_name("").is_err()); + assert!(validate_package_name("2fast").is_err()); + assert!(validate_package_name("my game").is_err()); + assert!(validate_package_name("game!").is_err()); + } + + #[test] + fn default_package_name_sanitises_directory_names() { + assert_eq!( + default_package_name(Path::new("/tmp/My Game 2")), + "my-game-2" + ); + assert_eq!( + default_package_name(Path::new("/tmp/***")), + "bevy-forge-project" + ); + assert_eq!(default_package_name(Path::new("/tmp/3d")), "forge-3d"); + } + + #[test] + fn empty_target_is_accepted_but_non_empty_is_rejected() { + let tmp = tempfile::tempdir().unwrap(); + assert!(ensure_empty_target(&tmp.path().join("fresh")).is_ok()); + let empty = tmp.path().join("empty-target"); + std::fs::create_dir_all(&empty).unwrap(); + assert!(ensure_empty_target(&empty).is_ok()); + std::fs::write(tmp.path().join("occupied.txt"), "x").unwrap(); + let error = ensure_empty_target(tmp.path()).unwrap_err().to_string(); + assert!(error.contains("not empty"), "unexpected error: {error}"); + } +} diff --git a/crates/bevy_forge_cli/tests/cli.rs b/crates/bevy_forge_cli/tests/cli.rs new file mode 100644 index 0000000..27f8320 --- /dev/null +++ b/crates/bevy_forge_cli/tests/cli.rs @@ -0,0 +1,644 @@ +//! End-to-end tests for the `forge` binary. +//! +//! Scenes are produced through the core API so these tests exercise the real +//! serialisation path instead of hand-written RON. + +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::{Command, Output}; + +use bevy_forge_core::{ComponentData, Project, SceneDoc, SpriteData, Text2dData}; +use serde_json::Value; + +fn forge(args: &[&str]) -> Output { + Command::new(env!("CARGO_BIN_EXE_forge")) + .args(args) + .output() + .expect("failed to spawn the forge binary") +} + +fn exit_code(output: &Output) -> i32 { + output + .status + .code() + .expect("the forge process was terminated by a signal") +} + +fn stdout(output: &Output) -> String { + String::from_utf8_lossy(&output.stdout).into_owned() +} + +fn stderr(output: &Output) -> String { + String::from_utf8_lossy(&output.stderr).into_owned() +} + +/// Create a minimal Bevy project (manifest + empty assets dir) in `dir`. +fn create_project(dir: &Path) -> Project { + fs::create_dir_all(dir.join("assets")).unwrap(); + fs::write( + dir.join("Cargo.toml"), + "[package]\nname = \"test-project\"\nversion = \"0.1.0\"\nedition = \"2021\"\n\n[dependencies]\nbevy = \"0.20\"\n", + ) + .unwrap(); + Project::open(dir).unwrap() +} + +fn save_scene(project: &Project, file: &str, document: &SceneDoc) -> PathBuf { + let path = project.scenes_dir().join(file); + project.save_scene(document, Some(&path)).unwrap(); + path +} + +/// A tiny scene with one textured sprite and one text entity. +fn sprite_scene(name: &str, image: &str) -> SceneDoc { + let mut document = SceneDoc::new(name); + let player = document.add_entity("Player", None); + document.add_component( + player, + ComponentData::Sprite(SpriteData { + image: Some(image.to_string()), + ..Default::default() + }), + ); + let label = document.add_entity("Label", None); + document.add_component( + label, + ComponentData::Text2d(Text2dData { + text: "Hello".to_string(), + ..Default::default() + }), + ); + document +} + +/// Run `forge new` and return the project root plus its demo scene path. +fn scaffold_2d(dir: &Path) -> (PathBuf, PathBuf) { + let output = forge(&["new", dir.to_str().unwrap()]); + assert_eq!( + exit_code(&output), + 0, + "forge new failed\nstdout: {}\nstderr: {}", + stdout(&output), + stderr(&output) + ); + let project = Project::open(dir).unwrap(); + let scene = project.scenes_dir().join("demo.forge.ron"); + (project.root.clone(), scene) +} + +// --------------------------------------------------------------------------- +// Top-level CLI behaviour +// --------------------------------------------------------------------------- + +#[test] +fn help_lists_every_subcommand() { + let output = forge(&["--help"]); + assert_eq!(exit_code(&output), 0, "stderr: {}", stderr(&output)); + let text = stdout(&output); + for subcommand in ["new", "validate", "export", "info", "scenes"] { + assert!( + text.contains(subcommand), + "`{subcommand}` missing from --help output:\n{text}" + ); + } +} + +#[test] +fn version_prints_the_package_version() { + let output = forge(&["--version"]); + assert_eq!(exit_code(&output), 0, "stderr: {}", stderr(&output)); + assert!( + stdout(&output).contains(env!("CARGO_PKG_VERSION")), + "unexpected version output: {}", + stdout(&output) + ); +} + +#[test] +fn unknown_command_exits_with_a_usage_error() { + let output = forge(&["frobnicate"]); + assert_eq!(exit_code(&output), 2); + assert!(!stderr(&output).is_empty()); +} + +// --------------------------------------------------------------------------- +// new +// --------------------------------------------------------------------------- + +#[test] +fn new_creates_a_valid_project() { + let tmp = tempfile::tempdir().unwrap(); + let dir = tmp.path().join("my-game"); + let output = forge(&["new", dir.to_str().unwrap()]); + assert_eq!( + exit_code(&output), + 0, + "forge new failed\nstdout: {}\nstderr: {}", + stdout(&output), + stderr(&output) + ); + + for relative in [ + "Cargo.toml", + "src/main.rs", + "assets/.gitkeep", + "scenes/demo.forge.ron", + ".gitignore", + "README.md", + ] { + assert!(dir.join(relative).is_file(), "missing {relative}"); + } + + let manifest = fs::read_to_string(dir.join("Cargo.toml")).unwrap(); + assert!( + manifest.contains("name = \"my-game\""), + "manifest: {manifest}" + ); + assert!(manifest.contains("bevy = \"0.20\""), "manifest: {manifest}"); + + let project = Project::open(&dir).unwrap(); + let scene_path = project.scenes_dir().join("demo.forge.ron"); + let document = project.load_scene(&scene_path).unwrap(); + assert!( + !document.entities.is_empty(), + "starter scene should contain entities" + ); + assert!( + !document.roots.is_empty(), + "starter scene should have roots" + ); + + let output = forge(&["validate", scene_path.to_str().unwrap()]); + assert_eq!( + exit_code(&output), + 0, + "scaffolded scene should validate\nstdout: {}\nstderr: {}", + stdout(&output), + stderr(&output) + ); +} + +#[test] +fn new_refuses_a_non_empty_directory() { + let tmp = tempfile::tempdir().unwrap(); + let dir = tmp.path().join("occupied"); + fs::create_dir_all(&dir).unwrap(); + fs::write(dir.join("keep.txt"), "keep me").unwrap(); + + let output = forge(&["new", dir.to_str().unwrap()]); + assert_eq!(exit_code(&output), 2); + assert!( + stderr(&output).contains("not empty"), + "stderr: {}", + stderr(&output) + ); + assert!(!dir.join("Cargo.toml").exists()); + assert_eq!(fs::read_to_string(dir.join("keep.txt")).unwrap(), "keep me"); +} + +#[test] +fn new_fills_an_existing_empty_directory() { + let tmp = tempfile::tempdir().unwrap(); + let dir = tmp.path().join("already-there"); + fs::create_dir_all(&dir).unwrap(); + + let output = forge(&["new", dir.to_str().unwrap()]); + assert_eq!( + exit_code(&output), + 0, + "stdout: {}\nstderr: {}", + stdout(&output), + stderr(&output) + ); + assert!(dir.join("Cargo.toml").is_file()); + assert!(dir.join("scenes/demo.forge.ron").is_file()); +} + +#[test] +fn new_accepts_name_and_3d_template() { + let tmp = tempfile::tempdir().unwrap(); + let dir = tmp.path().join("space"); + let output = forge(&[ + "new", + dir.to_str().unwrap(), + "--name", + "space-game", + "--template", + "3d", + ]); + assert_eq!( + exit_code(&output), + 0, + "stdout: {}\nstderr: {}", + stdout(&output), + stderr(&output) + ); + + let manifest = fs::read_to_string(dir.join("Cargo.toml")).unwrap(); + assert!( + manifest.contains("name = \"space-game\""), + "manifest: {manifest}" + ); + + let project = Project::open(&dir).unwrap(); + let document = project + .load_scene(project.scenes_dir().join("demo.forge.ron")) + .unwrap(); + assert!( + document.stats().meshes > 0, + "the 3D starter scene should contain mesh entities" + ); +} + +// --------------------------------------------------------------------------- +// validate +// --------------------------------------------------------------------------- + +#[test] +fn validate_accepts_a_good_scene_and_emits_json() { + let tmp = tempfile::tempdir().unwrap(); + let (_root, scene) = scaffold_2d(&tmp.path().join("good-project")); + + let output = forge(&["validate", scene.to_str().unwrap()]); + assert_eq!( + exit_code(&output), + 0, + "stdout: {}\nstderr: {}", + stdout(&output), + stderr(&output) + ); + assert!(stdout(&output).contains("Summary:")); + + let output = forge(&["validate", scene.to_str().unwrap(), "--json"]); + assert_eq!(exit_code(&output), 0); + let value: Value = + serde_json::from_str(&stdout(&output)).expect("validate --json must emit JSON"); + assert_eq!(value["valid"], true); + assert_eq!(value["errors"], 0); + assert!(value["warnings"].is_u64()); + assert!(value["issues"].is_array()); + for issue in value["issues"].as_array().unwrap() { + for key in ["severity", "entity", "field", "message"] { + assert!(issue.get(key).is_some(), "issue missing `{key}`: {issue}"); + } + assert!(["error", "warning", "info"].contains(&issue["severity"].as_str().unwrap())); + } +} + +#[test] +fn validate_reports_parse_errors_with_exit_code_two() { + let tmp = tempfile::tempdir().unwrap(); + let broken = tmp.path().join("broken.forge.ron"); + fs::write(&broken, "this is definitely not RON").unwrap(); + + let output = forge(&["validate", broken.to_str().unwrap()]); + assert_eq!(exit_code(&output), 2); + assert!( + stderr(&output).contains("parse"), + "stderr should explain the parse error: {}", + stderr(&output) + ); + assert!(stdout(&output).is_empty()); +} + +#[test] +fn validate_reports_a_missing_asset_as_a_validation_error() { + let tmp = tempfile::tempdir().unwrap(); + let project = create_project(tmp.path()); + let document = sprite_scene("Missing asset", "textures/missing.png"); + let scene = save_scene(&project, "missing.forge.ron", &document); + + let output = forge(&["validate", scene.to_str().unwrap()]); + assert_eq!(exit_code(&output), 1); + let text = stdout(&output); + assert!(text.contains("error"), "stdout: {text}"); + assert!(text.contains("textures/missing.png"), "stdout: {text}"); + assert!(text.contains("Summary:"), "stdout: {text}"); + + let output = forge(&["validate", scene.to_str().unwrap(), "--json"]); + assert_eq!(exit_code(&output), 1); + let value: Value = serde_json::from_str(&stdout(&output)).unwrap(); + assert_eq!(value["valid"], false); + assert!(value["errors"].as_u64().unwrap() >= 1); + let issues = value["issues"].as_array().unwrap(); + assert!(issues.iter().any(|issue| issue["message"] + .as_str() + .is_some_and(|message| message.contains("textures/missing.png")))); +} + +#[test] +fn validate_uses_an_explicit_project_for_assets() { + let tmp = tempfile::tempdir().unwrap(); + // The scene lives outside the project; `--project` must still resolve assets. + let project = create_project(&tmp.path().join("project")); + let outside = tmp.path().join("outside"); + fs::create_dir_all(&outside).unwrap(); + let document = sprite_scene("Outside", "textures/hero.png"); + fs::create_dir_all(project.assets_dir.join("textures")).unwrap(); + fs::write(project.assets_dir.join("textures/hero.png"), b"png").unwrap(); + // Serialise inside the project, then move the file out of it. + let saved = save_scene(&project, "outside.forge.ron", &document); + let scene = outside.join("outside.forge.ron"); + fs::copy(&saved, &scene).unwrap(); + fs::remove_file(&saved).unwrap(); + + let output = forge(&[ + "validate", + scene.to_str().unwrap(), + "--project", + project.root.to_str().unwrap(), + ]); + assert_eq!( + exit_code(&output), + 0, + "stdout: {}\nstderr: {}", + stdout(&output), + stderr(&output) + ); +} + +// --------------------------------------------------------------------------- +// export +// --------------------------------------------------------------------------- + +#[test] +fn export_bsn_to_stdout_and_file() { + let tmp = tempfile::tempdir().unwrap(); + let project = create_project(tmp.path()); + let document = sprite_scene("Export scene", "textures/player.png"); + let scene = save_scene(&project, "export.forge.ron", &document); + + let output = forge(&["export", scene.to_str().unwrap(), "--format", "bsn"]); + assert_eq!( + exit_code(&output), + 0, + "stdout: {}\nstderr: {}", + stdout(&output), + stderr(&output) + ); + let stdout_code = stdout(&output); + assert!( + stdout_code.starts_with("use bevy::prelude::*;"), + "generated code must start with the Bevy prelude import:\n{stdout_code}" + ); + assert!(stdout_code.ends_with('\n')); + assert!( + stdout_code.contains("bsn!") || stdout_code.contains("bsn_list!"), + "expected BSN macro code:\n{stdout_code}" + ); + + let target = tmp.path().join("generated/nested/scene.rs"); + let output = forge(&[ + "export", + scene.to_str().unwrap(), + "--format", + "bsn", + "-o", + target.to_str().unwrap(), + ]); + assert_eq!( + exit_code(&output), + 0, + "stdout: {}\nstderr: {}", + stdout(&output), + stderr(&output) + ); + let written = fs::read_to_string(&target).expect("output file must exist"); + assert_eq!(written, stdout_code); + assert!( + target.parent().unwrap().is_dir(), + "parent directories must be created" + ); +} + +#[test] +fn export_bsn_single_root_uses_the_bsn_macro() { + let tmp = tempfile::tempdir().unwrap(); + let project = create_project(tmp.path()); + let mut document = SceneDoc::new("Single root"); + let player = document.add_entity("Player", None); + document.add_component( + player, + ComponentData::Sprite(SpriteData { + image: Some("textures/player.png".to_string()), + ..Default::default() + }), + ); + let scene = save_scene(&project, "single.forge.ron", &document); + + let output = forge(&["export", scene.to_str().unwrap(), "--format", "bsn"]); + assert_eq!( + exit_code(&output), + 0, + "stdout: {}\nstderr: {}", + stdout(&output), + stderr(&output) + ); + let code = stdout(&output); + assert!(code.contains("bsn! {"), "code:\n{code}"); + assert!(!code.contains("bsn_list!"), "code:\n{code}"); + assert!(code.contains("-> impl Scene"), "code:\n{code}"); +} + +#[test] +fn export_rust_format() { + let tmp = tempfile::tempdir().unwrap(); + let project = create_project(tmp.path()); + let document = sprite_scene("Export scene", "textures/player.png"); + let scene = save_scene(&project, "export.forge.ron", &document); + + let output = forge(&["export", scene.to_str().unwrap(), "--format", "rust"]); + assert_eq!( + exit_code(&output), + 0, + "stdout: {}\nstderr: {}", + stdout(&output), + stderr(&output) + ); + let code = stdout(&output); + assert!(code.starts_with("use bevy::prelude::*;"), "code:\n{code}"); + assert!( + code.contains("Commands"), + "expected a Commands parameter:\n{code}" + ); + assert!( + code.contains("commands.spawn"), + "expected imperative spawn code:\n{code}" + ); + assert!(code.ends_with('\n')); +} + +#[test] +fn export_rejects_an_invalid_function_name() { + let tmp = tempfile::tempdir().unwrap(); + let project = create_project(tmp.path()); + let document = sprite_scene("Export scene", "textures/player.png"); + let scene = save_scene(&project, "export.forge.ron", &document); + + let output = forge(&[ + "export", + scene.to_str().unwrap(), + "--format", + "bsn", + "--function", + "not a valid name", + ]); + assert_eq!(exit_code(&output), 1); + assert!( + stderr(&output).contains("identifier"), + "stderr: {}", + stderr(&output) + ); + assert!(stdout(&output).is_empty()); +} + +#[test] +fn export_reports_parse_errors_with_exit_code_two() { + let tmp = tempfile::tempdir().unwrap(); + let broken = tmp.path().join("broken.forge.ron"); + fs::write(&broken, "not ron at all").unwrap(); + let target = tmp.path().join("never-written.rs"); + + let output = forge(&[ + "export", + broken.to_str().unwrap(), + "-o", + target.to_str().unwrap(), + ]); + assert_eq!(exit_code(&output), 2); + assert!( + !target.exists(), + "no output must be written on a parse error" + ); +} + +// --------------------------------------------------------------------------- +// info +// --------------------------------------------------------------------------- + +#[test] +fn info_json_has_the_documented_fields() { + let tmp = tempfile::tempdir().unwrap(); + let project = create_project(tmp.path()); + let document = sprite_scene("Info scene", "textures/hero.png"); + let scene = save_scene(&project, "info.forge.ron", &document); + + let output = forge(&["info", scene.to_str().unwrap(), "--json"]); + assert_eq!( + exit_code(&output), + 0, + "stdout: {}\nstderr: {}", + stdout(&output), + stderr(&output) + ); + let value: Value = serde_json::from_str(&stdout(&output)).expect("info --json must emit JSON"); + assert_eq!(value["name"], "Info scene"); + assert_eq!(value["entities"], 2); + assert_eq!(value["roots"], 2); + assert!(value["path"] + .as_str() + .is_some_and(|path| Path::new(path).is_absolute())); + assert_eq!(value["components"]["sprite"], 1); + assert_eq!(value["components"]["text2d"], 1); + let references = value["asset_references"].as_array().unwrap(); + assert!(references + .iter() + .any(|reference| reference["path"] == "textures/hero.png")); + assert!(value["validation"].is_object()); + // A missing texture is an error, but `info` itself always succeeds. + assert_eq!(value["validation"]["valid"], false); + + let output = forge(&["info", scene.to_str().unwrap()]); + assert_eq!(exit_code(&output), 0); + let text = stdout(&output); + assert!(text.contains("Scene:")); + assert!(text.contains("Entities: 2")); + assert!(text.contains("Validation:")); +} + +// --------------------------------------------------------------------------- +// scenes +// --------------------------------------------------------------------------- + +#[test] +fn scenes_lists_absolute_paths_in_sorted_order() { + let tmp = tempfile::tempdir().unwrap(); + let project = create_project(tmp.path()); + save_scene(&project, "beta.forge.ron", &SceneDoc::new("Beta")); + save_scene(&project, "alpha.forge.ron", &SceneDoc::new("Alpha")); + + let output = forge(&["scenes", project.root.to_str().unwrap()]); + assert_eq!( + exit_code(&output), + 0, + "stdout: {}\nstderr: {}", + stdout(&output), + stderr(&output) + ); + let lines: Vec = stdout(&output).lines().map(ToOwned::to_owned).collect(); + let expected: Vec = project + .list_scenes() + .iter() + .map(|path| path.to_string_lossy().into_owned()) + .collect(); + assert_eq!(lines, expected); + assert_eq!(lines.len(), 2); + assert!(lines.iter().all(|line| Path::new(line).is_absolute())); + assert!(lines[0].ends_with("alpha.forge.ron")); +} + +#[test] +fn scenes_rejects_a_directory_that_is_not_a_bevy_project() { + let tmp = tempfile::tempdir().unwrap(); + fs::write( + tmp.path().join("Cargo.toml"), + "[package]\nname = \"not-bevy\"\nversion = \"0.1.0\"\n", + ) + .unwrap(); + + let output = forge(&["scenes", tmp.path().to_str().unwrap()]); + assert_eq!(exit_code(&output), 2); + assert!( + stderr(&output).contains("not a Bevy project"), + "stderr: {}", + stderr(&output) + ); +} + +// --------------------------------------------------------------------------- +// Demo project integration +// --------------------------------------------------------------------------- + +#[test] +fn demo_project_scene_validates_and_exports() { + let demo = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../examples/demo-project"); + let scene = demo.join("scenes/garden.forge.ron"); + assert!( + scene.is_file(), + "demo project scene is missing: {}", + scene.display() + ); + + let output = forge(&["validate", scene.to_str().unwrap()]); + assert_eq!( + exit_code(&output), + 0, + "demo garden scene should validate\nstdout: {}\nstderr: {}", + stdout(&output), + stderr(&output) + ); + + let output = forge(&["export", scene.to_str().unwrap(), "--format", "bsn"]); + assert_eq!( + exit_code(&output), + 0, + "stdout: {}\nstderr: {}", + stdout(&output), + stderr(&output) + ); + assert!(stdout(&output).starts_with("use bevy::prelude::*;")); + + let output = forge(&["scenes", demo.to_str().unwrap()]); + assert_eq!(exit_code(&output), 0); + assert!(stdout(&output).contains("garden.forge.ron")); +} diff --git a/crates/bevy_forge_core/Cargo.toml b/crates/bevy_forge_core/Cargo.toml new file mode 100644 index 0000000..52006c5 --- /dev/null +++ b/crates/bevy_forge_core/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "bevy_forge_core" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +authors.workspace = true +description = "Scene model, project handling and Bevy 0.20 BSN code generation for Bevy Forge" + +[dependencies] +serde.workspace = true +ron.workspace = true +indexmap.workspace = true +thiserror.workspace = true +toml.workspace = true + +[dev-dependencies] +tempfile.workspace = true diff --git a/crates/bevy_forge_core/src/assets.rs b/crates/bevy_forge_core/src/assets.rs new file mode 100644 index 0000000..3d2f1c1 --- /dev/null +++ b/crates/bevy_forge_core/src/assets.rs @@ -0,0 +1,470 @@ +//! Asset discovery for the project's Bevy `assets/` folder. + +use std::path::{Path, PathBuf}; + +/// Deepest directory nesting the scanner descends into, counting the assets +/// directory itself as level 0. Anything deeper is ignored so that pathological +/// or cyclic-looking trees cannot exhaust the stack. +const MAX_SCAN_DEPTH: usize = 32; + +/// Directory names that are never part of a Bevy asset tree but can be huge +/// (or self-referential) when a project root is used as the assets directory. +const SKIPPED_DIR_NAMES: &[&str] = &["target", "build", "node_modules"]; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub enum AssetKind { + Image, + Mesh, + Audio, + Font, + Scene, + Shader, + Material, + Other, +} + +impl AssetKind { + pub fn from_path(path: &Path) -> Self { + let ext = path + .extension() + .and_then(|ext| ext.to_str()) + .unwrap_or_default() + .to_ascii_lowercase(); + match ext.as_str() { + "png" | "jpg" | "jpeg" | "webp" | "bmp" | "tga" | "ktx2" | "dds" => Self::Image, + "gltf" | "glb" | "obj" | "fbx" => Self::Mesh, + "ogg" | "wav" | "mp3" | "flac" => Self::Audio, + "ttf" | "otf" => Self::Font, + "scn" | "ron" => Self::Scene, + "wgsl" | "wesl" => Self::Shader, + "material" | "toml" | "material.ron" => Self::Material, + _ => Self::Other, + } + } + + pub const fn label(self) -> &'static str { + match self { + Self::Image => "Images", + Self::Mesh => "Meshes", + Self::Audio => "Audio", + Self::Font => "Fonts", + Self::Scene => "Scenes", + Self::Shader => "Shaders", + Self::Material => "Materials", + Self::Other => "Other", + } + } + + pub const fn all() -> [Self; 8] { + [ + Self::Image, + Self::Mesh, + Self::Audio, + Self::Font, + Self::Scene, + Self::Shader, + Self::Material, + Self::Other, + ] + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct AssetEntry { + /// Path relative to the Bevy `assets/` directory, using `/` separators. + pub path: String, + pub abs_path: PathBuf, + pub kind: AssetKind, + pub size_bytes: u64, +} + +impl AssetEntry { + pub fn file_name(&self) -> &str { + self.path.rsplit('/').next().unwrap_or(&self.path) + } +} + +#[derive(Clone, Debug, Default)] +pub struct AssetIndex { + pub entries: Vec, + pub scanned_dir: PathBuf, + /// Set when the assets directory could not be read. + pub error: Option, +} + +impl AssetIndex { + /// Recursively scan `assets_dir`, skipping hidden files, symlinks and + /// editor/build directories. Results are sorted by path. + pub fn scan(assets_dir: &Path) -> Self { + let mut index = Self { + entries: Vec::new(), + scanned_dir: assets_dir.to_path_buf(), + error: None, + }; + match std::fs::read_dir(assets_dir) { + Ok(read_dir) => { + let mut components = Vec::new(); + collect_files(read_dir, &mut components, 0, &mut index.entries); + } + Err(error) => { + index.error = Some(format!( + "cannot read assets directory `{}`: {error}", + assets_dir.display() + )); + return index; + } + } + index.entries.sort_by(|a, b| a.path.cmp(&b.path)); + // A lossy conversion of exotic non-UTF-8 names can theoretically map two + // distinct files onto the same asset path; never expose duplicates. + index.entries.dedup_by(|a, b| a.path == b.path); + index + } + + /// Rescan the directory used by the last call to [`Self::scan`]. + pub fn refresh(&mut self) { + let scanned_dir = self.scanned_dir.clone(); + *self = Self::scan(&scanned_dir); + } + + pub fn find(&self, path: &str) -> Option<&AssetEntry> { + self.entries.iter().find(|entry| entry.path == path) + } + + pub fn by_kind(&self, kind: AssetKind) -> Vec<&AssetEntry> { + self.entries + .iter() + .filter(|entry| entry.kind == kind) + .collect() + } + + pub fn images(&self) -> Vec<&AssetEntry> { + self.by_kind(AssetKind::Image) + } + + /// Case-insensitive substring search on the asset path. + pub fn search(&self, query: &str) -> Vec<&AssetEntry> { + let query = query.trim().to_ascii_lowercase(); + self.entries + .iter() + .filter(|entry| query.is_empty() || entry.path.to_ascii_lowercase().contains(&query)) + .collect() + } + + pub fn len(&self) -> usize { + self.entries.len() + } + + pub fn is_empty(&self) -> bool { + self.entries.is_empty() + } +} + +/// Walk one already-opened directory, appending regular files to `entries`. +/// +/// `components` is the sequence of directory names between the scanned root and +/// the directory being walked. Unreadable entries and subdirectories are skipped +/// silently; only the root read error is surfaced by [`AssetIndex::scan`]. +fn collect_files( + read_dir: std::fs::ReadDir, + components: &mut Vec, + depth: usize, + entries: &mut Vec, +) { + for item in read_dir { + let entry = match item { + Ok(entry) => entry, + Err(_) => continue, + }; + let file_name = entry.file_name(); + let name = file_name.to_string_lossy(); + if name.starts_with('.') { + continue; + } + let file_type = match entry.file_type() { + Ok(file_type) => file_type, + Err(_) => continue, + }; + // `DirEntry::file_type` does not follow links, so this skips symlinks + // to both files and directories without touching their targets. + if file_type.is_symlink() { + continue; + } + if file_type.is_dir() { + if depth >= MAX_SCAN_DEPTH || SKIPPED_DIR_NAMES.contains(&name.as_ref()) { + continue; + } + components.push(name.into_owned()); + if let Ok(child_read_dir) = std::fs::read_dir(entry.path()) { + collect_files(child_read_dir, components, depth + 1, entries); + } + components.pop(); + } else if file_type.is_file() { + let path = join_relative(components, &name); + let size_bytes = entry.metadata().map(|metadata| metadata.len()).unwrap_or(0); + entries.push(AssetEntry { + path, + abs_path: entry.path(), + kind: AssetKind::from_path(Path::new(name.as_ref())), + size_bytes, + }); + } + } +} + +/// Join directory components and a file name into a `/`-separated asset path. +fn join_relative(components: &[String], file_name: &str) -> String { + let mut path = String::with_capacity( + file_name.len() + components.iter().map(|c| c.len() + 1).sum::(), + ); + for component in components { + path.push_str(component); + path.push('/'); + } + path.push_str(file_name); + path +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + + fn touch(root: &Path, relative: &str, contents: &[u8]) -> PathBuf { + let path = root.join(relative); + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).unwrap(); + } + fs::write(&path, contents).unwrap(); + path + } + + fn asset_paths(index: &AssetIndex) -> Vec<&str> { + index + .entries + .iter() + .map(|entry| entry.path.as_str()) + .collect() + } + + fn search_paths<'a>(index: &'a AssetIndex, query: &str) -> Vec<&'a str> { + index + .search(query) + .into_iter() + .map(|entry| entry.path.as_str()) + .collect() + } + + #[test] + fn classifies_common_extensions() { + assert_eq!(AssetKind::from_path(Path::new("a.png")), AssetKind::Image); + assert_eq!(AssetKind::from_path(Path::new("a.GLB")), AssetKind::Mesh); + assert_eq!(AssetKind::from_path(Path::new("a.ogg")), AssetKind::Audio); + assert_eq!(AssetKind::from_path(Path::new("a.ron")), AssetKind::Scene); + assert_eq!(AssetKind::from_path(Path::new("a.xyz")), AssetKind::Other); + } + + #[test] + fn scans_nested_project_tree_with_slash_separated_paths() { + let temp = tempfile::tempdir().unwrap(); + let assets = temp.path().join("assets"); + let hero = touch(&assets, "textures/hero.png", b"png-bytes"); + touch(&assets, "audio/loop.ogg", b"ogg-bytes!"); + touch(&assets, "models/robot.glb", b"glb"); + touch(&assets, ".git/config", b"[core]"); + touch(&assets, ".hidden.png", b"hidden"); + #[cfg(unix)] + { + std::os::unix::fs::symlink(&hero, assets.join("textures/hero-link.png")).unwrap(); + std::os::unix::fs::symlink(assets.join("textures"), assets.join("linked")).unwrap(); + } + + let index = AssetIndex::scan(&assets); + + assert_eq!(index.error, None); + assert_eq!(index.scanned_dir, assets); + assert_eq!( + asset_paths(&index), + vec!["audio/loop.ogg", "models/robot.glb", "textures/hero.png"] + ); + + let entry = index.find("textures/hero.png").unwrap(); + assert_eq!(entry.kind, AssetKind::Image); + assert_eq!(entry.size_bytes, 9); + assert_eq!(entry.abs_path, hero); + assert_eq!(entry.file_name(), "hero.png"); + assert!(entry.abs_path.is_file()); + assert_eq!(index.find("audio/loop.ogg").unwrap().kind, AssetKind::Audio); + assert_eq!( + index.find("models/robot.glb").unwrap().kind, + AssetKind::Mesh + ); + } + + #[test] + fn skips_hidden_entries_and_build_directories() { + let temp = tempfile::tempdir().unwrap(); + let assets = temp.path().join("assets"); + touch(&assets, "textures/visible.png", b"x"); + touch(&assets, ".hidden.png", b"x"); + touch(&assets, ".cache/secret.png", b"x"); + touch(&assets, "target/debug/cache.bin", b"x"); + touch(&assets, "build/out.bin", b"x"); + touch(&assets, "node_modules/pkg/icon.png", b"x"); + + let index = AssetIndex::scan(&assets); + + assert_eq!(index.error, None); + assert_eq!(asset_paths(&index), vec!["textures/visible.png"]); + } + + #[test] + fn missing_assets_directory_is_empty_with_error() { + let temp = tempfile::tempdir().unwrap(); + let missing = temp.path().join("does-not-exist"); + + let index = AssetIndex::scan(&missing); + + assert!(index.is_empty()); + assert_eq!(index.scanned_dir, missing); + let error = index.error.expect("a missing directory must set an error"); + assert!( + error.contains("cannot read assets directory"), + "unexpected error: {error}" + ); + assert!( + error.contains("does-not-exist"), + "unexpected error: {error}" + ); + } + + #[test] + fn entries_are_sorted_by_path_and_scans_are_deterministic() { + let temp = tempfile::tempdir().unwrap(); + let assets = temp.path().join("assets"); + touch(&assets, "z.png", b"z"); + touch(&assets, "A.PNG", b"a"); + touch(&assets, "m/z.png", b"z"); + touch(&assets, "m/a.png", b"a"); + touch(&assets, "b.txt", b"b"); + + let first = AssetIndex::scan(&assets); + let second = AssetIndex::scan(&assets); + + let paths = asset_paths(&first); + assert_eq!(paths, vec!["A.PNG", "b.txt", "m/a.png", "m/z.png", "z.png"]); + let mut sorted = paths.clone(); + sorted.sort_unstable(); + assert_eq!(paths, sorted); + assert_eq!(first.entries, second.entries); + } + + #[test] + fn search_filters_case_insensitively_and_kind_helpers_work() { + let temp = tempfile::tempdir().unwrap(); + let assets = temp.path().join("assets"); + touch(&assets, "textures/Hero.PNG", b"x"); + touch(&assets, "audio/Loop.OGG", b"x"); + touch(&assets, "fonts/ui.ttf", b"x"); + touch(&assets, "models/Robot.GLB", b"x"); + touch(&assets, "notes.txt", b"x"); + + let index = AssetIndex::scan(&assets); + + assert_eq!(index.search("").len(), 5); + assert_eq!(search_paths(&index, "hero"), vec!["textures/Hero.PNG"]); + assert_eq!(search_paths(&index, "HERO"), vec!["textures/Hero.PNG"]); + assert_eq!(search_paths(&index, "png"), vec!["textures/Hero.PNG"]); + assert!(index.search("does-not-exist").is_empty()); + assert_eq!(index.by_kind(AssetKind::Image).len(), 1); + assert_eq!(index.images().len(), 1); + assert_eq!(index.by_kind(AssetKind::Audio).len(), 1); + assert_eq!(index.by_kind(AssetKind::Font).len(), 1); + assert_eq!(index.by_kind(AssetKind::Mesh).len(), 1); + assert_eq!(index.by_kind(AssetKind::Other).len(), 1); + assert_eq!( + search_paths(&index, ""), + vec![ + "audio/Loop.OGG", + "fonts/ui.ttf", + "models/Robot.GLB", + "notes.txt", + "textures/Hero.PNG", + ] + ); + } + + #[test] + fn refresh_picks_up_added_and_removed_files() { + let temp = tempfile::tempdir().unwrap(); + let assets = temp.path().join("assets"); + let hero = touch(&assets, "textures/hero.png", b"x"); + let mut index = AssetIndex::scan(&assets); + assert_eq!(asset_paths(&index), vec!["textures/hero.png"]); + + fs::remove_file(&hero).unwrap(); + touch(&assets, "sprites/new.png", b"x"); + index.refresh(); + + assert_eq!(index.error, None); + assert_eq!(index.scanned_dir, assets); + assert_eq!(asset_paths(&index), vec!["sprites/new.png"]); + } + + #[test] + fn refresh_reports_error_when_directory_disappears() { + let temp = tempfile::tempdir().unwrap(); + let assets = temp.path().join("assets"); + touch(&assets, "textures/hero.png", b"x"); + let mut index = AssetIndex::scan(&assets); + assert!(index.error.is_none()); + + fs::remove_dir_all(&assets).unwrap(); + index.refresh(); + + assert!(index.is_empty()); + assert!(index.error.is_some()); + } + + #[test] + fn caps_directory_recursion_depth() { + let temp = tempfile::tempdir().unwrap(); + let assets = temp.path().join("assets"); + touch(&assets, "shallow.png", b"x"); + let mut deep = assets.clone(); + for _ in 0..40 { + deep.push("d"); + } + fs::create_dir_all(&deep).unwrap(); + fs::write(deep.join("deep.png"), b"x").unwrap(); + + let index = AssetIndex::scan(&assets); + + assert_eq!(index.error, None); + assert_eq!(asset_paths(&index), vec!["shallow.png"]); + } + + #[cfg(unix)] + #[test] + fn unreadable_subdirectory_is_skipped_without_failing_the_scan() { + use std::os::unix::fs::PermissionsExt; + + let temp = tempfile::tempdir().unwrap(); + let assets = temp.path().join("assets"); + touch(&assets, "visible.png", b"x"); + touch(&assets, "locked/secret.png", b"x"); + let locked = assets.join("locked"); + let mut permissions = fs::metadata(&locked).unwrap().permissions(); + permissions.set_mode(0o000); + fs::set_permissions(&locked, permissions).unwrap(); + + let index = AssetIndex::scan(&assets); + + let mut permissions = fs::metadata(&locked).unwrap().permissions(); + permissions.set_mode(0o755); + fs::set_permissions(&locked, permissions).unwrap(); + + assert_eq!(index.error, None); + assert!(index.find("visible.png").is_some()); + } +} diff --git a/crates/bevy_forge_core/src/codegen.rs b/crates/bevy_forge_core/src/codegen.rs new file mode 100644 index 0000000..7f9fcb1 --- /dev/null +++ b/crates/bevy_forge_core/src/codegen.rs @@ -0,0 +1,80 @@ +//! Shared code-generation options and dispatch. + +use crate::error::CodegenError; +use crate::scene::SceneDoc; + +pub mod bsn; +pub mod rust; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ExportFormat { + /// Bevy 0.20 BSN (`bsn!` / `bsn_list!`) Rust code. + Bsn, + /// Imperative `Commands::spawn` Rust code. + Rust, +} + +impl ExportFormat { + pub const fn label(self) -> &'static str { + match self { + Self::Bsn => "BSN scene", + Self::Rust => "Rust spawn code", + } + } + + pub const fn description(self) -> &'static str { + match self { + Self::Bsn => "Modern Bevy 0.20 scene notation, ready to paste into a system", + Self::Rust => "Imperative Commands::spawn code for any Bevy 0.15+ project", + } + } + + pub const fn extension(self) -> &'static str { + "rs" + } + + pub fn generate( + self, + doc: &SceneDoc, + options: &CodegenOptions, + ) -> Result { + match self { + Self::Bsn => bsn::generate(doc, options), + Self::Rust => rust::generate(doc, options), + } + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct CodegenOptions { + /// Name of the generated function; must be a valid Rust identifier. + pub function_name: String, + /// Include a "generated by Bevy Forge" header with usage hints. + pub include_header: bool, +} + +impl Default for CodegenOptions { + fn default() -> Self { + Self { + function_name: "scene".to_string(), + include_header: true, + } + } +} + +impl CodegenOptions { + pub fn with_function_name(mut self, name: impl Into) -> Self { + self.function_name = name.into(); + self + } + + pub(crate) fn validate(&self) -> Result<(), CodegenError> { + if !crate::components::is_rust_ident(&self.function_name) { + return Err(CodegenError::InvalidIdentifier { + what: "function name".into(), + value: self.function_name.clone(), + }); + } + Ok(()) + } +} diff --git a/crates/bevy_forge_core/src/codegen/bsn.rs b/crates/bevy_forge_core/src/codegen/bsn.rs new file mode 100644 index 0000000..47fe049 --- /dev/null +++ b/crates/bevy_forge_core/src/codegen/bsn.rs @@ -0,0 +1,1331 @@ +//! Bevy 0.20 BSN code generation. +//! +//! See `.swarm/recon/R02.md` for the verified syntax matrix. Every construct emitted +//! here compiles against Bevy 0.20.0 when the generated function is pasted into a +//! project; the tricky cases are: +//! +//! - one root uses `bsn!`, several roots use `bsn_list!` with `--` separators; +//! - `Children [ .. ]` separates sibling scenes with `--`; +//! - `Some(..)` values must be wrapped in braces (`custom_size: { Some(..) }`); +//! - method calls such as `meshes.add(..)` must be raw Rust blocks (`{ .. }`); +//! - negative field literals are not accepted by the BSN patch parser, so they are +//! emitted as braced, `f32`-suffixed expressions; +//! - f32 fields need float literals (`10.0`, never `10`), and the receiver of +//! `.to_radians()` needs an explicit `f32` suffix so the literal's type is known. + +use std::collections::HashSet; + +use crate::codegen::CodegenOptions; +use crate::components::{ + is_rust_ident, is_rust_path, ComponentData, CustomComponent, DirectionalLightData, + MaterialData, Mesh3dData, MeshSource, PointLightData, PrimitiveShape, SpriteData, Text2dData, + VisibilityMode, +}; +use crate::error::CodegenError; +use crate::math::Color; +use crate::scene::{EntityId, SceneDoc}; + +/// Maximum scene-nesting depth we walk; guards against malformed documents. +const MAX_DEPTH: usize = 512; + +/// Generate a `bsn!`/`bsn_list!` scene function for `doc`. +pub fn generate(doc: &SceneDoc, options: &CodegenOptions) -> Result { + options.validate()?; + for node in doc.entities.values() { + let mut seen = std::collections::HashSet::new(); + for component in &node.components { + if !seen.insert(component.key()) { + return Err(CodegenError::DuplicateComponent { + entity: node.id, + key: component.key(), + }); + } + } + } + if is_rust_keyword(&options.function_name) { + return Err(CodegenError::InvalidIdentifier { + what: "function name".into(), + value: options.function_name.clone(), + }); + } + + let roots = collect_roots(doc); + if roots.is_empty() { + return Err(CodegenError::EmptyScene); + } + + let mut ids = Vec::new(); + let mut visited = HashSet::new(); + for root in &roots { + collect_entities(doc, *root, 0, &mut visited, &mut ids); + } + + let mut uses_meshes = false; + let mut uses_materials = false; + for id in &ids { + let Some(node) = doc.entity(*id) else { + continue; + }; + for component in &node.components { + if let ComponentData::Mesh3d(mesh) = component { + uses_materials = true; + if matches!(mesh.mesh, MeshSource::Primitive(_)) { + uses_meshes = true; + } + } + } + } + + let multiple_roots = roots.len() > 1; + let mut out = String::new(); + if options.include_header { + out.push_str("// Generated by Bevy Forge. Paste into a Bevy 0.20 project.\n"); + } + out.push_str("use bevy::prelude::*;\n"); + if multiple_roots { + // `bsn!` comes from the prelude; only `bsn_list!` needs an explicit import. + out.push_str("use bevy::scene::bsn_list;\n"); + } + out.push('\n'); + out.push_str(&function_signature( + &options.function_name, + multiple_roots, + uses_meshes, + uses_materials, + )); + + if multiple_roots { + out.push_str(" bsn_list! {\n"); + } else { + out.push_str(" bsn! {\n"); + } + + let mut emitted = HashSet::new(); + for (index, root) in roots.iter().enumerate() { + if index > 0 { + out.push_str(" --\n"); + } + emit_entity(doc, *root, 2, &mut emitted, &mut out)?; + } + + out.push_str(" }\n}\n"); + Ok(out) +} + +// --------------------------------------------------------------------------- +// Traversal +// --------------------------------------------------------------------------- + +fn collect_roots(doc: &SceneDoc) -> Vec { + let mut roots = Vec::new(); + let mut seen = HashSet::new(); + for root in &doc.roots { + if doc.entity(*root).is_some() && seen.insert(*root) { + roots.push(*root); + } + } + roots +} + +fn collect_entities( + doc: &SceneDoc, + id: EntityId, + depth: usize, + visited: &mut HashSet, + out: &mut Vec, +) { + if depth > MAX_DEPTH || !visited.insert(id) { + return; + } + let Some(node) = doc.entity(id) else { + return; + }; + out.push(id); + for child in &node.children { + collect_entities(doc, *child, depth + 1, visited, out); + } +} + +// --------------------------------------------------------------------------- +// Header and function signature +// --------------------------------------------------------------------------- + +/// Rust keywords cannot be used as function names even though they are lexically +/// identifiers; `is_rust_ident` alone would accept e.g. `fn`. +fn is_rust_keyword(value: &str) -> bool { + matches!( + value, + "abstract" + | "as" + | "async" + | "await" + | "become" + | "box" + | "break" + | "const" + | "continue" + | "crate" + | "do" + | "dyn" + | "else" + | "enum" + | "extern" + | "false" + | "final" + | "fn" + | "for" + | "gen" + | "if" + | "impl" + | "in" + | "let" + | "loop" + | "macro" + | "match" + | "mod" + | "move" + | "mut" + | "override" + | "priv" + | "pub" + | "ref" + | "return" + | "self" + | "Self" + | "static" + | "struct" + | "super" + | "trait" + | "true" + | "try" + | "type" + | "typeof" + | "unsafe" + | "unsized" + | "use" + | "virtual" + | "where" + | "while" + | "yield" + ) +} + +fn function_signature( + name: &str, + multiple_roots: bool, + uses_meshes: bool, + uses_materials: bool, +) -> String { + let mut params = Vec::new(); + if uses_meshes { + params.push("meshes: &mut Assets"); + } + if uses_materials { + params.push("materials: &mut Assets"); + } + let returns = if multiple_roots { + "impl SceneList" + } else { + "impl Scene" + }; + match params.as_slice() { + [] => format!("pub fn {name}() -> {returns} {{\n"), + [single] => format!("pub fn {name}({single}) -> {returns} {{\n"), + [first, second] => { + format!("pub fn {name}(\n {first},\n {second},\n) -> {returns} {{\n") + } + // The parameter list only has two possible entries; keep the compiler happy + // without panicking on the impossible case. + _ => format!("pub fn {name}() -> {returns} {{\n"), + } +} + +// --------------------------------------------------------------------------- +// Entity / component emission +// --------------------------------------------------------------------------- + +fn emit_entity( + doc: &SceneDoc, + id: EntityId, + depth: usize, + emitted: &mut HashSet, + out: &mut String, +) -> Result<(), CodegenError> { + if depth > MAX_DEPTH || !emitted.insert(id) { + return Ok(()); + } + let Some(node) = doc.entity(id) else { + return Ok(()); + }; + let line_indent = indent(depth); + let field_indent = indent(depth + 1); + + out.push_str(&format!( + "{line_indent}Name({})\n", + string_literal(&node.name) + )); + + let transform = node.transform; + let translation_x = checked_float(id, "transform.translation.x", transform.translation.x)?; + let translation_y = checked_float(id, "transform.translation.y", transform.translation.y)?; + let translation_z = checked_float(id, "transform.translation.z", transform.translation.z)?; + let rotation_x = checked_float(id, "transform.rotation.x", transform.rotation.x)?; + let rotation_y = checked_float(id, "transform.rotation.y", transform.rotation.y)?; + let rotation_z = checked_float(id, "transform.rotation.z", transform.rotation.z)?; + let scale_x = checked_float(id, "transform.scale.x", transform.scale.x)?; + let scale_y = checked_float(id, "transform.scale.y", transform.scale.y)?; + let scale_z = checked_float(id, "transform.scale.z", transform.scale.z)?; + out.push_str(&format!("{line_indent}Transform {{\n")); + out.push_str(&format!( + "{field_indent}translation: Vec3::new({translation_x}, {translation_y}, {translation_z}),\n" + )); + out.push_str(&format!( + "{field_indent}rotation: Quat::from_euler(EulerRot::XYZ, \ + {rotation_x}f32.to_radians(), {rotation_y}f32.to_radians(), {rotation_z}f32.to_radians()),\n" + )); + out.push_str(&format!( + "{field_indent}scale: Vec3::new({scale_x}, {scale_y}, {scale_z}),\n" + )); + out.push_str(&format!("{line_indent}}}\n")); + + for component in &node.components { + emit_component(node.id, component, depth, out)?; + } + + let children: Vec = node + .children + .iter() + .copied() + .filter(|child| doc.entity(*child).is_some()) + .collect(); + if !children.is_empty() { + let child_indent = indent(depth + 1); + out.push_str(&format!("{line_indent}Children [\n")); + for (index, child) in children.iter().enumerate() { + if index > 0 { + out.push_str(&format!("{child_indent}--\n")); + } + emit_entity(doc, *child, depth + 1, emitted, out)?; + } + out.push_str(&format!("{line_indent}]\n")); + } + + Ok(()) +} + +fn emit_component( + entity: EntityId, + component: &ComponentData, + depth: usize, + out: &mut String, +) -> Result<(), CodegenError> { + let indent = indent(depth); + match component { + ComponentData::Sprite(sprite) => { + emit_sprite(entity, sprite, &indent, out)?; + } + ComponentData::Text2d(text) => { + emit_text2d(entity, text, &indent, out)?; + } + ComponentData::Camera2d => { + out.push_str(&format!("{indent}Camera2d\n")); + } + ComponentData::Camera3d => { + out.push_str(&format!("{indent}Camera3d::default()\n")); + } + ComponentData::PointLight(light) => { + emit_point_light(entity, light, &indent, out)?; + } + ComponentData::DirectionalLight(light) => { + emit_directional_light(entity, light, &indent, out)?; + } + ComponentData::Mesh3d(mesh) => { + emit_mesh(entity, mesh, &indent, out)?; + } + ComponentData::Visibility(mode) => { + let value = match mode { + VisibilityMode::Inherited => "Visibility::Inherited", + VisibilityMode::Visible => "Visibility::Visible", + VisibilityMode::Hidden => "Visibility::Hidden", + }; + out.push_str(&format!("{indent}{value}\n")); + } + ComponentData::Custom(custom) => { + emit_custom(custom, &indent, out)?; + } + } + Ok(()) +} + +fn emit_sprite( + entity: EntityId, + sprite: &SpriteData, + indent: &str, + out: &mut String, +) -> Result<(), CodegenError> { + let mut fields = Vec::new(); + if let Some(image) = &sprite.image { + fields.push(format!("image: {}", string_literal(image))); + } + fields.push(format!( + "color: {}", + color_expression(entity, "sprite.color", sprite.color)? + )); + if let Some(size) = sprite.custom_size { + let width = checked_float(entity, "sprite.custom_size.x", size.x)?; + let height = checked_float(entity, "sprite.custom_size.y", size.y)?; + // `Some(..)` must be braced in a BSN component patch. + fields.push(format!( + "custom_size: {{ Some(Vec2::new({width}, {height})) }}" + )); + } + fields.push(format!("flip_x: {}", sprite.flip_x)); + fields.push(format!("flip_y: {}", sprite.flip_y)); + out.push_str(&format!("{indent}Sprite {{ {} }}\n", fields.join(", "))); + Ok(()) +} + +fn emit_text2d( + entity: EntityId, + text: &Text2dData, + indent: &str, + out: &mut String, +) -> Result<(), CodegenError> { + let font_size = checked_float(entity, "text2d.font_size", text.font_size)?; + let color = color_expression(entity, "text2d.color", text.color)?; + out.push_str(&format!("{indent}Text2d({})\n", string_literal(&text.text))); + out.push_str(&format!( + "{indent}TextFont {{ font_size: FontSize::Px({font_size}) }}\n" + )); + out.push_str(&format!("{indent}TextColor({color})\n")); + Ok(()) +} + +fn emit_point_light( + entity: EntityId, + light: &PointLightData, + indent: &str, + out: &mut String, +) -> Result<(), CodegenError> { + let color = color_expression(entity, "point_light.color", light.color)?; + let intensity = direct_float_field(entity, "point_light.intensity", light.intensity)?; + let range = direct_float_field(entity, "point_light.range", light.range)?; + out.push_str(&format!( + "{indent}PointLight {{ color: {color}, intensity: {intensity}, range: {range}, \ + shadow_maps_enabled: {} }}\n", + light.shadows + )); + Ok(()) +} + +fn emit_directional_light( + entity: EntityId, + light: &DirectionalLightData, + indent: &str, + out: &mut String, +) -> Result<(), CodegenError> { + let color = color_expression(entity, "directional_light.color", light.color)?; + let illuminance = + direct_float_field(entity, "directional_light.illuminance", light.illuminance)?; + out.push_str(&format!( + "{indent}DirectionalLight {{ color: {color}, illuminance: {illuminance}, \ + shadow_maps_enabled: {} }}\n", + light.shadows + )); + Ok(()) +} + +fn emit_mesh( + entity: EntityId, + mesh: &Mesh3dData, + indent: &str, + out: &mut String, +) -> Result<(), CodegenError> { + // A braced Rust expression is the only way to pass the result of a method call + // such as `meshes.add(..)` as a BSN component argument. + let mesh_expression = match &mesh.mesh { + MeshSource::Primitive(shape) => { + format!("{{ meshes.add({}) }}", primitive_expression(entity, shape)?) + } + MeshSource::Asset(path) => string_literal(path), + }; + out.push_str(&format!("{indent}Mesh3d({mesh_expression})\n")); + + emit_material(entity, &mesh.material, indent, out) +} + +fn emit_material( + entity: EntityId, + material: &MaterialData, + indent: &str, + out: &mut String, +) -> Result<(), CodegenError> { + let base_color = color_expression(entity, "mesh3d.material.base_color", material.base_color)?; + let metallic = checked_float(entity, "mesh3d.material.metallic", material.metallic)?; + let roughness = checked_float( + entity, + "mesh3d.material.perceptual_roughness", + material.perceptual_roughness, + )?; + let field_indent = format!("{indent} "); + // `..default()` is allowed inside a raw Rust constructor argument; the turbofish + // on `MeshMaterial3d` is mandatory because the material type is a generic param. + out.push_str(&format!( + "{indent}MeshMaterial3d::({{ materials.add(StandardMaterial {{\n" + )); + out.push_str(&format!("{field_indent}base_color: {base_color},\n")); + out.push_str(&format!("{field_indent}metallic: {metallic},\n")); + out.push_str(&format!( + "{field_indent}perceptual_roughness: {roughness},\n" + )); + out.push_str(&format!("{field_indent}unlit: {},\n", material.unlit)); + out.push_str(&format!("{field_indent}..default()\n")); + out.push_str(indent); + out.push_str("}) })\n"); + Ok(()) +} + +fn emit_custom( + custom: &CustomComponent, + indent: &str, + out: &mut String, +) -> Result<(), CodegenError> { + validate_custom(custom)?; + if custom.fields.is_empty() { + out.push_str(&format!("{indent}{}\n", custom.type_path)); + return Ok(()); + } + let fields: Vec = custom + .fields + .iter() + .map(|field| format!("{}: {}", field.name, custom_field_expression(&field.expr))) + .collect(); + out.push_str(&format!( + "{indent}{} {{ {} }}\n", + custom.type_path, + fields.join(", ") + )); + Ok(()) +} + +fn validate_custom(custom: &CustomComponent) -> Result<(), CodegenError> { + if !is_rust_path(&custom.type_path) { + return Err(CodegenError::InvalidCustomComponent(format!( + "`{}` is not a valid Rust type path", + custom.type_path + ))); + } + let mut seen = HashSet::new(); + for field in &custom.fields { + if !is_rust_ident(&field.name) { + return Err(CodegenError::InvalidCustomComponent(format!( + "`{}` is not a valid Rust field name", + field.name + ))); + } + if field.expr.trim().is_empty() { + return Err(CodegenError::InvalidCustomComponent(format!( + "field `{}` has an empty expression", + field.name + ))); + } + if !seen.insert(field.name.as_str()) { + return Err(CodegenError::InvalidCustomComponent(format!( + "duplicate field `{}`", + field.name + ))); + } + } + Ok(()) +} + +// --------------------------------------------------------------------------- +// Expressions and literals +// --------------------------------------------------------------------------- + +fn primitive_expression(entity: EntityId, shape: &PrimitiveShape) -> Result { + let expression = match shape { + PrimitiveShape::Cuboid { size } => { + let x = checked_float(entity, "mesh3d.size.x", size.x)?; + let y = checked_float(entity, "mesh3d.size.y", size.y)?; + let z = checked_float(entity, "mesh3d.size.z", size.z)?; + format!("Cuboid::new({x}, {y}, {z})") + } + PrimitiveShape::Sphere { radius } => { + let radius = checked_float(entity, "mesh3d.radius", *radius)?; + format!("Sphere::new({radius})") + } + PrimitiveShape::Plane { size } => { + // `Plane3d` takes half extents, while the editor stores the full size. + let width = checked_float(entity, "mesh3d.size.x", size.x * 0.5)?; + let height = checked_float(entity, "mesh3d.size.y", size.y * 0.5)?; + format!("Plane3d::new(Vec3::Y, Vec2::new({width}, {height}))") + } + PrimitiveShape::Cylinder { radius, height } => { + let radius = checked_float(entity, "mesh3d.radius", *radius)?; + let height = checked_float(entity, "mesh3d.height", *height)?; + format!("Cylinder::new({radius}, {height})") + } + PrimitiveShape::Capsule { radius, length } => { + let radius = checked_float(entity, "mesh3d.radius", *radius)?; + let length = checked_float(entity, "mesh3d.length", *length)?; + format!("Capsule3d::new({radius}, {length})") + } + }; + Ok(expression) +} + +fn color_expression(entity: EntityId, field: &str, color: Color) -> Result { + let red = checked_float(entity, &format!("{field}.r"), color.r)?; + let green = checked_float(entity, &format!("{field}.g"), color.g)?; + let blue = checked_float(entity, &format!("{field}.b"), color.b)?; + let alpha = checked_float(entity, &format!("{field}.a"), color.a)?; + Ok(format!("Color::srgba({red}, {green}, {blue}, {alpha})")) +} + +/// Emit a valid float literal: finite, containing a decimal point or exponent. +fn float_literal(value: f32) -> String { + if value == 0.0 { + // Also normalizes `-0.0`, which has no meaning for any generated field. + return "0.0".to_string(); + } + let text = format!("{value:?}"); + if text.contains('.') || text.contains('e') || text.contains('E') { + text + } else { + format!("{text}.0") + } +} + +/// Validate a numeric value and return its float literal. +fn checked_float(entity: EntityId, field: &str, value: f32) -> Result { + if !value.is_finite() { + return Err(CodegenError::NonFinite { + entity: Some(entity), + field: field.to_string(), + value, + }); + } + Ok(float_literal(value)) +} + +/// A literal used directly as a BSN patch field value. +/// +/// The patch parser only accepts a literal/ident/path-call there, so a negative +/// number needs a braced expression (with an `f32` suffix to avoid a fallback warning). +fn direct_float_field(entity: EntityId, field: &str, value: f32) -> Result { + let literal = checked_float(entity, field, value)?; + if literal.starts_with('-') { + Ok(format!("{{ {literal}f32 }}")) + } else { + Ok(literal) + } +} + +/// Emit a raw custom-component expression, bracing forms that are not bare BSN values. +fn custom_field_expression(expression: &str) -> String { + let expression = expression.trim(); + let needs_braces = expression.starts_with('-') || is_bare_variant_call(expression); + if needs_braces { + // Suffix a plain negative float so rustc does not warn about the + // f64 -> f32 literal fallback in a future edition. + let value = if expression.starts_with('-') && expression.parse::().is_ok() { + format!("{expression}f32") + } else { + expression.to_string() + }; + format!("{{ {value} }}") + } else { + expression.to_string() + } +} + +/// Whether the expression is a bare enum-variant call such as `Some(Vec2::new(..))`. +/// +/// Those are not valid bare BSN field values (verified in R02) but are valid inside a +/// braced Rust expression. +fn is_bare_variant_call(expression: &str) -> bool { + let Some(open) = expression.find('(') else { + return false; + }; + let head = expression[..open].trim(); + if head.is_empty() || head.contains("::") { + return false; + } + let mut chars = head.chars(); + let Some(first) = chars.next() else { + return false; + }; + first.is_ascii_uppercase() && chars.all(|c| c.is_ascii_alphanumeric() || c == '_') +} + +/// A Rust string literal with the scene's text escaped (Unicode passes through). +fn string_literal(value: &str) -> String { + let mut literal = String::with_capacity(value.len() + 2); + literal.push('"'); + for character in value.chars() { + match character { + '"' => literal.push_str("\\\""), + '\\' => literal.push_str("\\\\"), + '\n' => literal.push_str("\\n"), + '\r' => literal.push_str("\\r"), + '\t' => literal.push_str("\\t"), + '\0' => literal.push_str("\\0"), + control if control.is_control() => { + literal.push_str(&format!("\\u{{{:x}}}", control as u32)); + } + other => literal.push(other), + } + } + literal.push('"'); + literal +} + +fn indent(depth: usize) -> String { + " ".repeat(depth) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::components::{ + CustomComponent, CustomField, DirectionalLightData, MaterialData, Mesh3dData, MeshSource, + PointLightData, PrimitiveShape, SpriteData, Text2dData, VisibilityMode, + }; + use crate::math::{Color, Transform, Vec2, Vec3}; + use crate::scene::SceneDoc; + + fn generate_ok(doc: &SceneDoc) -> String { + generate(doc, &CodegenOptions::default()).expect("generation should succeed") + } + + fn line(code: &str, expected: &str) -> bool { + code.lines().any(|candidate| candidate == expected) + } + + fn assert_line(code: &str, expected: &str) { + assert!( + line(code, expected), + "missing exact line {expected:?}; output:\n{code}" + ); + } + + fn sprite(image: Option<&str>) -> ComponentData { + ComponentData::Sprite(SpriteData { + image: image.map(str::to_string), + color: Color::srgba(0.5, 0.25, 0.125, 0.75), + custom_size: None, + flip_x: false, + flip_y: false, + }) + } + + fn mesh(shape: PrimitiveShape) -> ComponentData { + ComponentData::Mesh3d(Mesh3dData { + mesh: MeshSource::Primitive(shape), + material: MaterialData { + base_color: Color::srgba(1.0, 0.0, 0.0, 1.0), + metallic: 0.25, + perceptual_roughness: 0.75, + unlit: false, + }, + }) + } + + fn custom(type_path: &str, fields: Vec<(&str, &str)>) -> ComponentData { + ComponentData::Custom(CustomComponent { + type_path: type_path.to_string(), + fields: fields + .into_iter() + .map(|(name, expr)| CustomField { + name: name.to_string(), + expr: expr.to_string(), + }) + .collect(), + }) + } + + #[test] + fn single_root_scene_matches_exact_output() { + let mut doc = SceneDoc::new("Single"); + let player = doc.add_entity("Player", None); + doc.entity_mut(player).unwrap().transform = Transform::from_xyz(1.0, 2.0, 3.0); + doc.add_component( + player, + ComponentData::Sprite(SpriteData { + image: Some("textures/player.png".into()), + color: Color::srgba(1.0, 0.5, 0.25, 1.0), + custom_size: None, + flip_x: false, + flip_y: false, + }), + ); + + let code = generate_ok(&doc); + let expected = r#"// Generated by Bevy Forge. Paste into a Bevy 0.20 project. +use bevy::prelude::*; + +pub fn scene() -> impl Scene { + bsn! { + Name("Player") + Transform { + translation: Vec3::new(1.0, 2.0, 3.0), + rotation: Quat::from_euler(EulerRot::XYZ, 0.0f32.to_radians(), 0.0f32.to_radians(), 0.0f32.to_radians()), + scale: Vec3::new(1.0, 1.0, 1.0), + } + Sprite { image: "textures/player.png", color: Color::srgba(1.0, 0.5, 0.25, 1.0), flip_x: false, flip_y: false } + } +} +"#; + assert_eq!(code, expected); + assert!(code.ends_with("}\n")); + assert!(!code.ends_with("\n\n")); + assert!(!code.contains("bsn_list")); + } + + #[test] + fn multiple_roots_use_bsn_list_and_scene_list() { + let mut doc = SceneDoc::new("Multi"); + let left = doc.add_entity("Left", None); + doc.entity_mut(left).unwrap().transform = Transform::from_xyz(-2.0, 0.0, 0.0); + let right = doc.add_entity("Right", None); + doc.entity_mut(right).unwrap().transform = Transform::from_xyz(2.0, 0.0, 0.0); + + let code = generate_ok(&doc); + assert!(code.contains("use bevy::prelude::*;\nuse bevy::scene::bsn_list;\n")); + assert!(code.contains("pub fn scene() -> impl SceneList {")); + assert_line(&code, " bsn_list! {"); + assert!(code.contains(" --\n")); + assert!(!code.contains(" bsn! {")); + let left_index = code.find("Name(\"Left\")").unwrap(); + let separator_index = code.find(" --\n").unwrap(); + let right_index = code.find("Name(\"Right\")").unwrap(); + assert!(left_index < separator_index && separator_index < right_index); + assert!(code.contains("translation: Vec3::new(-2.0, 0.0, 0.0),")); + assert!(code.contains("translation: Vec3::new(2.0, 0.0, 0.0),")); + } + + #[test] + fn nested_children_use_children_blocks_and_separators() { + let mut doc = SceneDoc::new("Nested"); + let parent = doc.add_entity("Parent", None); + let first = doc.add_entity("Child A", Some(parent)); + let second = doc.add_entity("Child B", Some(parent)); + let grandchild = doc.add_entity("Grandchild", Some(second)); + doc.entity_mut(grandchild).unwrap().transform = Transform::from_xyz(0.0, 1.0, 0.0); + + // Move Child A after Child B to prove emission follows `children` order. + doc.reparent(first, Some(parent), Some(1)).unwrap(); + assert_eq!(doc.entity(parent).unwrap().children, vec![second, first]); + + let code = generate_ok(&doc); + assert_line(&code, " Children ["); + assert_line(&code, " --"); + assert_line(&code, " Name(\"Child B\")"); + assert_line(&code, " Name(\"Child A\")"); + assert_line(&code, " Children ["); + assert_line(&code, " Name(\"Grandchild\")"); + let child_b = code.find("Name(\"Child B\")").unwrap(); + let child_a = code.find("Name(\"Child A\")").unwrap(); + let grandchild_index = code.find("Name(\"Grandchild\")").unwrap(); + // Grandchild is nested inside Child B, so it is emitted before Child A. + assert!(child_b < grandchild_index && grandchild_index < child_a); + assert_eq!(code.matches(" --").count(), 1); + } + + #[test] + fn all_builtin_components_emit_verified_syntax() { + let mut doc = SceneDoc::new("All"); + let entity = doc.add_entity("Everything", None); + doc.add_component( + entity, + ComponentData::Sprite(SpriteData { + image: Some("textures/hero.png".into()), + color: Color::srgba(0.5, 0.25, 0.125, 0.75), + custom_size: Some(Vec2::new(64.0, 32.0)), + flip_x: true, + flip_y: false, + }), + ); + doc.add_component( + entity, + ComponentData::Text2d(Text2dData { + text: "Hello Forge".into(), + font_size: 24.0, + color: Color::srgb(0.1, 0.2, 0.3), + }), + ); + doc.add_component(entity, ComponentData::Camera2d); + doc.add_component(entity, ComponentData::Camera3d); + doc.add_component( + entity, + ComponentData::PointLight(PointLightData { + color: Color::srgb(1.0, 0.5, 0.2), + intensity: 800.0, + range: 20.0, + shadows: true, + }), + ); + doc.add_component( + entity, + ComponentData::DirectionalLight(DirectionalLightData { + color: Color::WHITE, + illuminance: 10_000.0, + shadows: true, + }), + ); + doc.add_component( + entity, + mesh(PrimitiveShape::Cuboid { + size: Vec3::new(1.0, 2.0, 3.0), + }), + ); + doc.add_component(entity, ComponentData::Visibility(VisibilityMode::Hidden)); + + let code = generate_ok(&doc); + assert_line( + &code, + " Sprite { image: \"textures/hero.png\", color: Color::srgba(0.5, 0.25, 0.125, 0.75), custom_size: { Some(Vec2::new(64.0, 32.0)) }, flip_x: true, flip_y: false }", + ); + assert_line(&code, " Text2d(\"Hello Forge\")"); + assert_line(&code, " TextFont { font_size: FontSize::Px(24.0) }"); + assert_line(&code, " TextColor(Color::srgba(0.1, 0.2, 0.3, 1.0))"); + assert_line(&code, " Camera2d"); + assert_line(&code, " Camera3d::default()"); + assert_line( + &code, + " PointLight { color: Color::srgba(1.0, 0.5, 0.2, 1.0), intensity: 800.0, range: 20.0, shadow_maps_enabled: true }", + ); + assert_line( + &code, + " DirectionalLight { color: Color::srgba(1.0, 1.0, 1.0, 1.0), illuminance: 10000.0, shadow_maps_enabled: true }", + ); + assert_line( + &code, + " Mesh3d({ meshes.add(Cuboid::new(1.0, 2.0, 3.0)) })", + ); + assert_line( + &code, + " MeshMaterial3d::({ materials.add(StandardMaterial {", + ); + assert_line( + &code, + " base_color: Color::srgba(1.0, 0.0, 0.0, 1.0),", + ); + assert_line(&code, " metallic: 0.25,"); + assert_line(&code, " perceptual_roughness: 0.75,"); + assert_line(&code, " unlit: false,"); + assert_line(&code, " ..default()"); + assert_line(&code, " }) })"); + assert_line(&code, " Visibility::Hidden"); + assert!(code.contains("pub fn scene(\n meshes: &mut Assets,\n materials: &mut Assets,\n) -> impl Scene {")); + } + + #[test] + fn all_visibility_variants() { + let mut doc = SceneDoc::new("Visibility"); + for (name, mode, expected) in [ + ( + "Inherited", + VisibilityMode::Inherited, + "Visibility::Inherited", + ), + ("Visible", VisibilityMode::Visible, "Visibility::Visible"), + ("Hidden", VisibilityMode::Hidden, "Visibility::Hidden"), + ] { + let id = doc.add_entity(name, None); + doc.add_component(id, ComponentData::Visibility(mode)); + assert!(generate_ok(&doc).contains(&format!(" {expected}"))); + } + } + + #[test] + fn custom_components_with_and_without_fields() { + let mut doc = SceneDoc::new("Custom"); + let health = doc.add_entity("Player", None); + doc.add_component( + health, + custom( + "game::Health", + vec![("current", "10.0"), ("max", "Vec3::X")], + ), + ); + let marker = doc.add_entity("Marker", None); + doc.add_component(marker, custom("game::Marker", Vec::new())); + + let code = generate_ok(&doc); + assert_line( + &code, + " game::Health { current: 10.0, max: Vec3::X }", + ); + assert_line(&code, " game::Marker"); + + // Variant and negative expressions are braced so the BSN patch parser accepts them. + let with_expr = doc.add_entity("Braced", None); + doc.add_component( + with_expr, + custom( + "game::Extras", + vec![("icon", "Some(Vec2::new(1.0, 2.0))"), ("offset", "-5.0")], + ), + ); + let code = generate_ok(&doc); + assert_line( + &code, + " game::Extras { icon: { Some(Vec2::new(1.0, 2.0)) }, offset: { -5.0f32 } }", + ); + } + + #[test] + fn invalid_custom_components_are_rejected() { + let mut doc = SceneDoc::new("BadCustom"); + let entity = doc.add_entity("Bad", None); + doc.add_component(entity, custom("game::", Vec::new())); + match generate(&doc, &CodegenOptions::default()) { + Err(CodegenError::InvalidCustomComponent(_)) => {} + other => panic!("expected InvalidCustomComponent, got {other:?}"), + } + + doc.set_component(entity, custom("game::Health", vec![("2bad", "1.0")])); + match generate(&doc, &CodegenOptions::default()) { + Err(CodegenError::InvalidCustomComponent(_)) => {} + other => panic!("expected InvalidCustomComponent for field name, got {other:?}"), + } + + doc.set_component(entity, custom("game::Health", vec![("current", " ")])); + match generate(&doc, &CodegenOptions::default()) { + Err(CodegenError::InvalidCustomComponent(_)) => {} + other => panic!("expected InvalidCustomComponent for empty expression, got {other:?}"), + } + + doc.set_component( + entity, + custom("game::Health", vec![("current", "1.0"), ("current", "2.0")]), + ); + match generate(&doc, &CodegenOptions::default()) { + Err(CodegenError::InvalidCustomComponent(_)) => {} + other => panic!("expected InvalidCustomComponent for duplicate field, got {other:?}"), + } + } + + #[test] + fn strings_are_escaped_as_rust_literals() { + let mut doc = SceneDoc::new("Escaping"); + let entity = doc.add_entity("Line \"one\"\\two\nthree\ttab ✓", None); + doc.add_component( + entity, + ComponentData::Text2d(Text2dData { + text: "say \"hi\"\nback\\slash".into(), + font_size: 16.0, + color: Color::WHITE, + }), + ); + + let code = generate_ok(&doc); + assert_line( + &code, + " Name(\"Line \\\"one\\\"\\\\two\\nthree\\ttab ✓\")", + ); + assert_line(&code, " Text2d(\"say \\\"hi\\\"\\nback\\\\slash\")"); + assert!(code.contains("✓")); + } + + #[test] + fn invalid_function_name_is_rejected() { + let mut doc = SceneDoc::new("Name"); + doc.add_entity("Root", None); + for name in ["my scene", "1scene", "", "scene-name", "fn"] { + let options = CodegenOptions::default().with_function_name(name); + match generate(&doc, &options) { + Err(CodegenError::InvalidIdentifier { what, .. }) => { + assert_eq!(what, "function name"); + } + other => panic!("expected InvalidIdentifier for {name:?}, got {other:?}"), + } + } + let generated = generate( + &doc, + &CodegenOptions::default().with_function_name("my_scene"), + ) + .unwrap(); + assert!(generated.contains("pub fn my_scene() -> impl Scene {")); + } + + #[test] + fn empty_scene_is_rejected() { + let doc = SceneDoc::new("Empty"); + match generate(&doc, &CodegenOptions::default()) { + Err(CodegenError::EmptyScene) => {} + other => panic!("expected EmptyScene, got {other:?}"), + } + } + + #[test] + fn non_finite_values_are_rejected() { + let mut doc = SceneDoc::new("NonFinite"); + let entity = doc.add_entity("Bad", None); + doc.entity_mut(entity).unwrap().transform.translation.x = f32::NAN; + match generate(&doc, &CodegenOptions::default()) { + Err(CodegenError::NonFinite { + entity: Some(id), + field, + value, + }) => { + assert_eq!(id, entity); + assert_eq!(field, "transform.translation.x"); + assert!(value.is_nan()); + } + other => panic!("expected NonFinite for transform, got {other:?}"), + } + + doc.entity_mut(entity).unwrap().transform = Transform::IDENTITY; + doc.add_component( + entity, + ComponentData::PointLight(PointLightData { + color: Color::WHITE, + intensity: 1.0, + range: f32::INFINITY, + shadows: false, + }), + ); + match generate(&doc, &CodegenOptions::default()) { + Err(CodegenError::NonFinite { field, .. }) => { + assert_eq!(field, "point_light.range") + } + other => panic!("expected NonFinite for light, got {other:?}"), + } + + // Repair the light so the next check reaches the material. + doc.set_component( + entity, + ComponentData::PointLight(PointLightData { + color: Color::WHITE, + intensity: 1.0, + range: 20.0, + shadows: false, + }), + ); + doc.set_component( + entity, + ComponentData::DirectionalLight(DirectionalLightData { + color: Color::WHITE, + illuminance: 1.0, + shadows: false, + }), + ); + doc.set_component( + entity, + mesh(PrimitiveShape::Cuboid { + size: Vec3::new(1.0, 1.0, 1.0), + }), + ); + let ComponentData::Mesh3d(mesh_data) = doc + .entity_mut(entity) + .unwrap() + .component_mut("mesh3d") + .unwrap() + else { + panic!("expected a mesh component"); + }; + mesh_data.material.metallic = f32::NEG_INFINITY; + match generate(&doc, &CodegenOptions::default()) { + Err(CodegenError::NonFinite { field, .. }) => { + assert_eq!(field, "mesh3d.material.metallic") + } + other => panic!("expected NonFinite for material, got {other:?}"), + } + } + + #[test] + fn asset_mesh_uses_path_and_only_material_parameter() { + let mut doc = SceneDoc::new("AssetMesh"); + let entity = doc.add_entity("Robot", None); + doc.add_component( + entity, + ComponentData::Mesh3d(Mesh3dData { + mesh: MeshSource::Asset("models/robot.gltf#Mesh0/Primitive0".into()), + material: MaterialData::default(), + }), + ); + + let code = generate_ok(&doc); + assert_line( + &code, + " Mesh3d(\"models/robot.gltf#Mesh0/Primitive0\")", + ); + assert!( + code.contains("pub fn scene(materials: &mut Assets) -> impl Scene {") + ); + assert!(!code.contains("meshes: &mut Assets")); + assert_line( + &code, + " MeshMaterial3d::({ materials.add(StandardMaterial {", + ); + } + + #[test] + fn primitive_meshes_use_verified_constructors() { + let cases = [ + ( + PrimitiveShape::Cuboid { + size: Vec3::new(2.0, 3.0, 4.0), + }, + "Mesh3d({ meshes.add(Cuboid::new(2.0, 3.0, 4.0)) })", + ), + ( + PrimitiveShape::Sphere { radius: 1.5 }, + "Mesh3d({ meshes.add(Sphere::new(1.5)) })", + ), + ( + PrimitiveShape::Plane { + size: Vec2::new(10.0, 6.0), + }, + "Mesh3d({ meshes.add(Plane3d::new(Vec3::Y, Vec2::new(5.0, 3.0))) })", + ), + ( + PrimitiveShape::Cylinder { + radius: 0.5, + height: 2.0, + }, + "Mesh3d({ meshes.add(Cylinder::new(0.5, 2.0)) })", + ), + ( + PrimitiveShape::Capsule { + radius: 0.25, + length: 1.0, + }, + "Mesh3d({ meshes.add(Capsule3d::new(0.25, 1.0)) })", + ), + ]; + for (shape, expected) in cases { + let mut doc = SceneDoc::new("Primitive"); + let entity = doc.add_entity("Mesh", None); + doc.add_component(entity, mesh(shape)); + let code = generate_ok(&doc); + assert_line(&code, &format!(" {expected}")); + assert!(code.contains("pub fn scene(\n meshes: &mut Assets,\n materials: &mut Assets,\n) -> impl Scene {")); + } + } + + #[test] + fn multiple_roots_with_meshes_returns_scene_list() { + let mut doc = SceneDoc::new("MultiMesh"); + let first = doc.add_entity("First", None); + doc.add_component(first, mesh(PrimitiveShape::Sphere { radius: 1.0 })); + doc.add_entity("Second", None); + + let code = generate_ok(&doc); + assert!(code.contains( + "pub fn scene(\n meshes: &mut Assets,\n materials: &mut Assets,\n) -> impl SceneList {" + )); + assert_line(&code, " bsn_list! {"); + } + + #[test] + fn optional_sprite_fields_are_omitted() { + let mut doc = SceneDoc::new("Optional"); + let entity = doc.add_entity("Sprite", None); + doc.add_component(entity, sprite(None)); + + let code = generate_ok(&doc); + assert_line( + &code, + " Sprite { color: Color::srgba(0.5, 0.25, 0.125, 0.75), flip_x: false, flip_y: false }", + ); + assert!(!code.contains("image:")); + assert!(!code.contains("custom_size:")); + } + + #[test] + fn negative_light_values_are_braced_and_suffixed() { + let mut doc = SceneDoc::new("Negative"); + let entity = doc.add_entity("Light", None); + doc.add_component( + entity, + ComponentData::PointLight(PointLightData { + color: Color::WHITE, + intensity: -5.0, + range: 20.0, + shadows: false, + }), + ); + doc.add_component( + entity, + ComponentData::DirectionalLight(DirectionalLightData { + color: Color::WHITE, + illuminance: -1200.0, + shadows: false, + }), + ); + + let code = generate_ok(&doc); + assert_line( + &code, + " PointLight { color: Color::srgba(1.0, 1.0, 1.0, 1.0), intensity: { -5.0f32 }, range: 20.0, shadow_maps_enabled: false }", + ); + assert_line( + &code, + " DirectionalLight { color: Color::srgba(1.0, 1.0, 1.0, 1.0), illuminance: { -1200.0f32 }, shadow_maps_enabled: false }", + ); + } + + #[test] + fn rotation_converts_degrees_to_radians() { + let mut doc = SceneDoc::new("Rotation"); + let entity = doc.add_entity("Rotated", None); + doc.entity_mut(entity).unwrap().transform.rotation = Vec3::new(90.0, -45.0, 0.0); + + let code = generate_ok(&doc); + assert_line( + &code, + " rotation: Quat::from_euler(EulerRot::XYZ, 90.0f32.to_radians(), -45.0f32.to_radians(), 0.0f32.to_radians()),", + ); + } + + #[test] + fn output_is_deterministic() { + let mut doc = SceneDoc::new("Deterministic"); + let root = doc.add_entity("Root", None); + doc.add_component(root, sprite(Some("textures/a.png"))); + doc.add_component(root, mesh(PrimitiveShape::Sphere { radius: 2.0 })); + let child = doc.add_entity("Child", Some(root)); + doc.add_component(child, ComponentData::Visibility(VisibilityMode::Visible)); + doc.add_entity("Second Root", None); + + let first = generate_ok(&doc); + let second = generate_ok(&doc); + assert_eq!(first, second); + let index = first.find("Name(\"Second Root\")").unwrap(); + assert!(index > first.find("Name(\"Child\")").unwrap()); + } + + #[test] + fn header_can_be_disabled() { + let mut doc = SceneDoc::new("Header"); + doc.add_entity("Root", None); + let options = CodegenOptions { + include_header: false, + ..CodegenOptions::default() + }; + let code = generate(&doc, &options).unwrap(); + assert!(code.starts_with("use bevy::prelude::*;\n")); + assert!(!code.contains("Generated by Bevy Forge")); + let with_header = generate_ok(&doc); + assert!(with_header.starts_with("// Generated by Bevy Forge.")); + assert!(with_header.contains("\nuse bevy::prelude::*;\n")); + } + + #[test] + fn float_literals_always_have_a_decimal_point_or_exponent() { + assert_eq!(float_literal(0.0), "0.0"); + assert_eq!(float_literal(-0.0), "0.0"); + assert_eq!(float_literal(1.0), "1.0"); + assert_eq!(float_literal(0.5), "0.5"); + assert_eq!(float_literal(-5.0), "-5.0"); + assert_eq!(float_literal(10_000.0), "10000.0"); + assert_eq!(float_literal(1e20), "1e20"); + assert_eq!(float_literal(1e-7), "1e-7"); + assert!(float_literal(f32::MAX).contains('e')); + assert!(!float_literal(1.5).ends_with("f32")); + } +} diff --git a/crates/bevy_forge_core/src/codegen/rust.rs b/crates/bevy_forge_core/src/codegen/rust.rs new file mode 100644 index 0000000..1484c9e --- /dev/null +++ b/crates/bevy_forge_core/src/codegen/rust.rs @@ -0,0 +1,1053 @@ +//! Imperative Rust (`Commands::spawn`) code generation. +//! +//! This is the version-agnostic alternative to the BSN exporter: it emits a +//! paste-ready function that spawns the whole scene with [`Commands`], taking +//! optional asset parameters only when the scene actually needs them. +//! +//! ```text +//! commands.spawn((Name::new("Player"), Transform::from_xyz(..), ..)) +//! .with_children(|parent| { parent.spawn((..)); }); +//! ``` + +use crate::codegen::CodegenOptions; +use crate::components::{ + ComponentData, CustomComponent, DirectionalLightData, Mesh3dData, MeshSource, PointLightData, + PrimitiveShape, SpriteData, Text2dData, VisibilityMode, +}; +use crate::error::CodegenError; +use crate::math::{Color, Vec2, Vec3}; +use crate::scene::{EntityId, SceneDoc}; + +/// Guards against malformed (cyclic) documents loaded from disk. +const MAX_DEPTH: usize = 256; + +/// Rust keywords (2015, 2018, 2021 and 2024) that cannot be used as a function +/// name even though [`crate::components::is_rust_ident`] only checks characters. +const RUST_KEYWORDS: &[&str] = &[ + "abstract", "as", "async", "await", "become", "box", "break", "const", "continue", "crate", + "do", "dyn", "else", "enum", "extern", "false", "final", "fn", "for", "gen", "if", "impl", + "in", "let", "loop", "macro", "match", "mod", "move", "mut", "override", "priv", "pub", "ref", + "return", "self", "Self", "static", "struct", "super", "trait", "true", "try", "type", + "typeof", "unsafe", "unsized", "use", "virtual", "where", "while", "yield", +]; + +const HEADER: &str = concat!( + "// Generated by Bevy Forge. Paste into a Bevy 0.20 project.\n", + "// Imperative `Commands::spawn` code: call this function from a startup system.\n", + "\n", +); + +/// Generate a `pub fn scene(commands: &mut Commands, ...)` spawn function. +pub fn generate(doc: &SceneDoc, options: &CodegenOptions) -> Result { + options.validate()?; + for node in doc.entities.values() { + let mut seen = std::collections::HashSet::new(); + for component in &node.components { + if !seen.insert(component.key()) { + return Err(CodegenError::DuplicateComponent { + entity: node.id, + key: component.key(), + }); + } + } + } + if RUST_KEYWORDS.contains(&options.function_name.as_str()) { + return Err(CodegenError::InvalidIdentifier { + what: "function name".to_string(), + value: options.function_name.clone(), + }); + } + if doc.entities.is_empty() || doc.roots.is_empty() { + return Err(CodegenError::EmptyScene); + } + + let requirements = Requirements::scan(doc); + let mut out = String::new(); + if options.include_header { + out.push_str(HEADER); + } + out.push_str("use bevy::prelude::*;\n\n"); + out.push_str(&signature(&options.function_name, requirements)); + out.push_str(" {\n"); + for root in &doc.roots { + emit_entity(&mut out, doc, *root, 1, 0, "commands")?; + } + out.push_str("}\n"); + Ok(out) +} + +/// Asset parameters the generated function must accept. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +struct Requirements { + asset_server: bool, + meshes: bool, +} + +impl Requirements { + fn scan(doc: &SceneDoc) -> Self { + let mut requirements = Self::default(); + for root in &doc.roots { + requirements.visit(doc, *root, 0); + } + requirements + } + + fn visit(&mut self, doc: &SceneDoc, id: EntityId, depth: usize) { + if depth > MAX_DEPTH { + return; + } + let Some(node) = doc.entity(id) else { + return; + }; + for component in &node.components { + match component { + ComponentData::Sprite(sprite) => { + if sprite.image.is_some() { + self.asset_server = true; + } + } + ComponentData::Mesh3d(mesh) => { + self.meshes = true; + if matches!(mesh.mesh, MeshSource::Asset(_)) { + self.asset_server = true; + } + } + _ => {} + } + } + for child in &node.children { + self.visit(doc, *child, depth + 1); + } + } +} + +fn signature(function_name: &str, requirements: Requirements) -> String { + let mut parameters = vec!["commands: &mut Commands"]; + if requirements.asset_server { + parameters.push("asset_server: &AssetServer"); + } + if requirements.meshes { + parameters.push("meshes: &mut Assets"); + parameters.push("materials: &mut Assets"); + } + + if parameters.len() == 1 { + return format!("pub fn {function_name}({})", parameters[0]); + } + + let mut signature = format!("pub fn {function_name}(\n"); + for parameter in parameters { + signature.push_str(" "); + signature.push_str(parameter); + signature.push_str(",\n"); + } + signature.push(')'); + signature +} + +/// Emit one `spawn(..)` statement and its `with_children` block. +/// +/// `indent` is measured in 4-space units; `receiver` is `commands` for roots and +/// `parent` inside a `with_children` closure. +fn emit_entity( + out: &mut String, + doc: &SceneDoc, + id: EntityId, + indent: usize, + depth: usize, + receiver: &str, +) -> Result<(), CodegenError> { + if depth > MAX_DEPTH { + return Ok(()); + } + let Some(node) = doc.entity(id) else { + return Ok(()); + }; + + let pad = " ".repeat(indent); + let element = " ".repeat(indent + 1); + let continuation = " ".repeat(indent + 2); + + out.push_str(&format!("{pad}{receiver}.spawn((\n")); + out.push_str(&format!( + "{element}Name::new(\"{}\"),\n", + escape_string(&node.name) + )); + + let translation = finite_vec3(node.transform.translation, id, "transform.translation")?; + let rotation = finite_vec3(node.transform.rotation, id, "transform.rotation")?; + let scale = finite_vec3(node.transform.scale, id, "transform.scale")?; + out.push_str(&format!( + "{element}Transform::from_xyz({}, {}, {})\n", + float(translation.x), + float(translation.y), + float(translation.z) + )); + out.push_str(&format!( + "{continuation}.with_rotation(Quat::from_euler(EulerRot::XYZ, {}, {}, {}))\n", + radians(rotation.x), + radians(rotation.y), + radians(rotation.z) + )); + out.push_str(&format!( + "{continuation}.with_scale(Vec3::new({}, {}, {})),\n", + float(scale.x), + float(scale.y), + float(scale.z) + )); + + for component in &node.components { + emit_component(out, &element, id, component)?; + } + + if node.children.is_empty() { + out.push_str(&format!("{pad}));\n")); + } else { + out.push_str(&format!("{pad})).with_children(|parent| {{\n")); + for child in &node.children { + emit_entity(out, doc, *child, indent + 1, depth + 1, "parent")?; + } + out.push_str(&format!("{pad}}});\n")); + } + Ok(()) +} + +fn emit_component( + out: &mut String, + indent: &str, + entity: EntityId, + component: &ComponentData, +) -> Result<(), CodegenError> { + match component { + ComponentData::Sprite(sprite) => emit_sprite(out, indent, entity, sprite), + ComponentData::Text2d(text) => emit_text2d(out, indent, entity, text), + ComponentData::Camera2d => { + push_line(out, indent, "Camera2d,"); + Ok(()) + } + ComponentData::Camera3d => { + push_line(out, indent, "Camera3d::default(),"); + Ok(()) + } + ComponentData::PointLight(light) => emit_point_light(out, indent, entity, light), + ComponentData::DirectionalLight(light) => { + emit_directional_light(out, indent, entity, light) + } + ComponentData::Mesh3d(mesh) => emit_mesh(out, indent, entity, mesh), + ComponentData::Visibility(mode) => { + let variant = match mode { + VisibilityMode::Inherited => "Visibility::Inherited", + VisibilityMode::Visible => "Visibility::Visible", + VisibilityMode::Hidden => "Visibility::Hidden", + }; + push_line(out, indent, &format!("{variant},")); + Ok(()) + } + ComponentData::Custom(custom) => emit_custom(out, indent, custom), + } +} + +fn emit_sprite( + out: &mut String, + indent: &str, + entity: EntityId, + sprite: &SpriteData, +) -> Result<(), CodegenError> { + let color = finite_color(sprite.color, entity, "sprite.color")?; + + let mut fields: Vec = Vec::new(); + if let Some(image) = &sprite.image { + fields.push(format!( + "image: asset_server.load(\"{}\")", + escape_string(image) + )); + } + fields.push(format!("color: {}", color_expr(color))); + if let Some(custom_size) = sprite.custom_size { + let custom_size = finite_vec2(custom_size, entity, "sprite.custom_size")?; + fields.push(format!( + "custom_size: Some(Vec2::new({}, {}))", + float(custom_size.x), + float(custom_size.y) + )); + } + fields.push(format!("flip_x: {}", sprite.flip_x)); + fields.push(format!("flip_y: {}", sprite.flip_y)); + fields.push("..default()".to_string()); + + push_line(out, indent, &format!("Sprite {{ {} }},", fields.join(", "))); + Ok(()) +} + +fn emit_text2d( + out: &mut String, + indent: &str, + entity: EntityId, + text: &Text2dData, +) -> Result<(), CodegenError> { + let font_size = finite(text.font_size, entity, "text2d.font_size")?; + let color = finite_color(text.color, entity, "text2d.color")?; + + push_line( + out, + indent, + &format!("Text2d::new(\"{}\"),", escape_string(&text.text)), + ); + push_line( + out, + indent, + &format!( + "TextFont {{ font_size: FontSize::Px({}), ..default() }},", + float(font_size) + ), + ); + push_line(out, indent, &format!("TextColor({}),", color_expr(color))); + Ok(()) +} + +fn emit_point_light( + out: &mut String, + indent: &str, + entity: EntityId, + light: &PointLightData, +) -> Result<(), CodegenError> { + let color = finite_color(light.color, entity, "point_light.color")?; + let intensity = finite(light.intensity, entity, "point_light.intensity")?; + let range = finite(light.range, entity, "point_light.range")?; + + push_line( + out, + indent, + &format!( + "PointLight {{ color: {}, intensity: {}, range: {}, shadow_maps_enabled: {}, ..default() }},", + color_expr(color), + float(intensity), + float(range), + light.shadows + ), + ); + Ok(()) +} + +fn emit_directional_light( + out: &mut String, + indent: &str, + entity: EntityId, + light: &DirectionalLightData, +) -> Result<(), CodegenError> { + let color = finite_color(light.color, entity, "directional_light.color")?; + let illuminance = finite(light.illuminance, entity, "directional_light.illuminance")?; + + push_line( + out, + indent, + &format!( + "DirectionalLight {{ color: {}, illuminance: {}, shadow_maps_enabled: {}, ..default() }},", + color_expr(color), + float(illuminance), + light.shadows + ), + ); + Ok(()) +} + +fn emit_mesh( + out: &mut String, + indent: &str, + entity: EntityId, + mesh: &Mesh3dData, +) -> Result<(), CodegenError> { + let mesh_expr = match &mesh.mesh { + MeshSource::Primitive(shape) => format!("meshes.add({})", primitive_expr(shape, entity)?), + MeshSource::Asset(path) => { + format!("asset_server.load(\"{}\")", escape_string(path)) + } + }; + push_line(out, indent, &format!("Mesh3d({mesh_expr}),")); + + let material = &mesh.material; + let base_color = finite_color(material.base_color, entity, "material.base_color")?; + let metallic = finite(material.metallic, entity, "material.metallic")?; + let perceptual_roughness = finite( + material.perceptual_roughness, + entity, + "material.perceptual_roughness", + )?; + push_line( + out, + indent, + &format!( + "MeshMaterial3d(materials.add(StandardMaterial {{ base_color: {}, metallic: {}, perceptual_roughness: {}, unlit: {}, ..default() }})),", + color_expr(base_color), + float(metallic), + float(perceptual_roughness), + material.unlit + ), + ); + Ok(()) +} + +fn primitive_expr(shape: &PrimitiveShape, entity: EntityId) -> Result { + Ok(match shape { + PrimitiveShape::Cuboid { size } => { + let size = finite_vec3(*size, entity, "mesh.size")?; + format!( + "Cuboid::new({}, {}, {})", + float(size.x), + float(size.y), + float(size.z) + ) + } + PrimitiveShape::Sphere { radius } => { + let radius = finite(*radius, entity, "mesh.radius")?; + format!("Sphere::new({})", float(radius)) + } + PrimitiveShape::Plane { size } => { + let size = finite_vec2(*size, entity, "mesh.size")?; + // `Plane3d` is defined in the XZ plane and stores half extents. + format!( + "Plane3d::new(Vec3::Y, Vec2::new({}, {}))", + float(size.x * 0.5), + float(size.y * 0.5) + ) + } + PrimitiveShape::Cylinder { radius, height } => { + let radius = finite(*radius, entity, "mesh.radius")?; + let height = finite(*height, entity, "mesh.height")?; + format!("Cylinder::new({}, {})", float(radius), float(height)) + } + PrimitiveShape::Capsule { radius, length } => { + let radius = finite(*radius, entity, "mesh.radius")?; + let length = finite(*length, entity, "mesh.length")?; + format!("Capsule3d::new({}, {})", float(radius), float(length)) + } + }) +} + +fn emit_custom( + out: &mut String, + indent: &str, + custom: &CustomComponent, +) -> Result<(), CodegenError> { + if !custom.is_valid() { + return Err(CodegenError::InvalidCustomComponent(format!( + "`{}` must be a valid Rust type path with valid field names", + custom.type_path + ))); + } + for field in &custom.fields { + if field.expr.trim().is_empty() { + return Err(CodegenError::InvalidCustomComponent(format!( + "field `{}` on `{}` has an empty expression", + field.name, custom.type_path + ))); + } + } + + if custom.fields.is_empty() { + push_line(out, indent, &format!("{},", custom.type_path)); + } else { + let fields: Vec = custom + .fields + .iter() + .map(|field| format!("{}: {}", field.name, field.expr.trim())) + .collect(); + push_line( + out, + indent, + &format!("{} {{ {} }},", custom.type_path, fields.join(", ")), + ); + } + Ok(()) +} + +fn push_line(out: &mut String, indent: &str, text: &str) { + out.push_str(indent); + out.push_str(text); + out.push('\n'); +} + +fn finite(value: f32, entity: EntityId, field: &str) -> Result { + if value.is_finite() { + Ok(value) + } else { + Err(CodegenError::NonFinite { + entity: Some(entity), + field: field.to_string(), + value, + }) + } +} + +fn finite_vec2(value: Vec2, entity: EntityId, field: &str) -> Result { + Ok(Vec2::new( + finite(value.x, entity, &format!("{field}.x"))?, + finite(value.y, entity, &format!("{field}.y"))?, + )) +} + +fn finite_vec3(value: Vec3, entity: EntityId, field: &str) -> Result { + Ok(Vec3::new( + finite(value.x, entity, &format!("{field}.x"))?, + finite(value.y, entity, &format!("{field}.y"))?, + finite(value.z, entity, &format!("{field}.z"))?, + )) +} + +fn finite_color(value: Color, entity: EntityId, field: &str) -> Result { + Ok(Color::srgba( + finite(value.r, entity, &format!("{field}.r"))?, + finite(value.g, entity, &format!("{field}.g"))?, + finite(value.b, entity, &format!("{field}.b"))?, + finite(value.a, entity, &format!("{field}.a"))?, + )) +} + +/// Format an `f32` as a Rust literal that always contains a decimal point or an +/// exponent (`{:?}` is the shortest round-tripping representation). +fn float(value: f32) -> String { + format!("{value:?}") +} + +/// Euler angles are authored in degrees; Bevy expects radians. +/// +/// The `_f32` suffix is required: `90.0.to_radians()` does not compile because +/// the receiver type would be ambiguous. +fn radians(degrees: f32) -> String { + format!("{}_f32.to_radians()", float(degrees)) +} + +fn color_expr(color: Color) -> String { + format!( + "Color::srgba({}, {}, {}, {})", + float(color.r), + float(color.g), + float(color.b), + float(color.a) + ) +} + +/// Escape a string for emission inside a Rust `"..."` literal. +fn escape_string(value: &str) -> String { + let mut escaped = String::with_capacity(value.len()); + for character in value.chars() { + match character { + '\\' => escaped.push_str("\\\\"), + '"' => escaped.push_str("\\\""), + '\n' => escaped.push_str("\\n"), + '\r' => escaped.push_str("\\r"), + '\t' => escaped.push_str("\\t"), + '\0' => escaped.push_str("\\0"), + other if other.is_control() => { + escaped.push_str(&format!("\\u{{{:x}}}", other as u32)); + } + other => escaped.push(other), + } + } + escaped +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::components::{CustomField, MaterialData}; + + fn options() -> CodegenOptions { + CodegenOptions { + function_name: "scene".to_string(), + include_header: false, + } + } + + fn header_options() -> CodegenOptions { + CodegenOptions { + function_name: "scene".to_string(), + include_header: true, + } + } + + fn generate_plain(doc: &SceneDoc) -> String { + generate(doc, &options()).expect("generation should succeed") + } + + fn scene_with(component: ComponentData) -> SceneDoc { + let mut doc = SceneDoc::new("Test"); + let id = doc.add_entity("Entity", None); + assert!(doc.add_component(id, component), "component was rejected"); + doc + } + + fn assert_non_finite(doc: &SceneDoc, expected_field: &str) { + match generate(doc, &options()) { + Err(CodegenError::NonFinite { + entity, + field, + value, + }) => { + assert!(entity.is_some(), "entity should be reported"); + assert_eq!(field, expected_field); + assert!(!value.is_finite()); + } + other => panic!("expected NonFinite for {expected_field}, got {other:?}"), + } + } + + #[test] + fn single_root_golden_output() { + let mut doc = SceneDoc::new("Golden"); + let id = doc.add_entity("Player", None); + doc.entity_mut(id).expect("entity exists").transform = + crate::math::Transform::from_xyz(1.0, 2.0, 3.0) + .with_rotation(Vec3::new(10.0, 20.0, 30.0)); + + let code = generate_plain(&doc); + assert_eq!( + code, + concat!( + "use bevy::prelude::*;\n", + "\n", + "pub fn scene(commands: &mut Commands) {\n", + " commands.spawn((\n", + " Name::new(\"Player\"),\n", + " Transform::from_xyz(1.0, 2.0, 3.0)\n", + " .with_rotation(Quat::from_euler(EulerRot::XYZ, 10.0_f32.to_radians(), 20.0_f32.to_radians(), 30.0_f32.to_radians()))\n", + " .with_scale(Vec3::new(1.0, 1.0, 1.0)),\n", + " ));\n", + "}\n", + ) + ); + } + + #[test] + fn multiple_roots_are_emitted_in_scene_order() { + let mut doc = SceneDoc::new("Roots"); + doc.add_entity("Left", None); + doc.add_entity("Right", None); + + let code = generate_plain(&doc); + assert_eq!(code.matches("commands.spawn((").count(), 2); + assert!(!code.contains("with_children")); + let left = code.find("Name::new(\"Left\")").expect("left root"); + let right = code.find("Name::new(\"Right\")").expect("right root"); + assert!(left < right); + } + + #[test] + fn hierarchy_uses_with_children_in_depth_first_order() { + let mut doc = SceneDoc::new("Tree"); + let root = doc.add_entity("Root", None); + let child = doc.add_entity("Child", Some(root)); + doc.add_entity("Grandchild", Some(child)); + + let code = generate_plain(&doc); + assert!(code.contains(" )).with_children(|parent| {\n parent.spawn((\n")); + assert!(code.contains(" )).with_children(|parent| {\n parent.spawn((\n")); + assert_eq!(code.matches("with_children").count(), 2); + + let root_at = code.find("Name::new(\"Root\")").expect("root"); + let child_at = code.find("Name::new(\"Child\")").expect("child"); + let grandchild_at = code.find("Name::new(\"Grandchild\")").expect("grandchild"); + assert!(root_at < child_at); + assert!(child_at < grandchild_at); + assert!(doc.check_integrity().is_empty()); + } + + #[test] + fn every_builtin_component_is_emitted() { + let mut doc = SceneDoc::new("Everything"); + let id = doc.add_entity("Everything", None); + assert!(doc.add_component( + id, + ComponentData::Sprite(SpriteData { + image: Some("textures/hero.png".into()), + color: Color::srgba(1.0, 0.5, 0.25, 0.8), + custom_size: Some(Vec2::new(64.0, 32.0)), + flip_x: true, + flip_y: false, + }) + )); + assert!(doc.add_component( + id, + ComponentData::Text2d(Text2dData { + text: "Hello Forge".into(), + font_size: 32.0, + color: Color::WHITE, + }) + )); + assert!(doc.add_component(id, ComponentData::Camera2d)); + assert!(doc.add_component(id, ComponentData::Camera3d)); + assert!(doc.add_component(id, ComponentData::PointLight(PointLightData::default()))); + assert!(doc.add_component( + id, + ComponentData::DirectionalLight(DirectionalLightData::default()) + )); + assert!(doc.add_component(id, ComponentData::Mesh3d(Mesh3dData::default()))); + assert!(doc.add_component(id, ComponentData::Visibility(VisibilityMode::Hidden))); + + let code = generate_plain(&doc); + assert!(code.contains( + "Sprite { image: asset_server.load(\"textures/hero.png\"), color: Color::srgba(1.0, 0.5, 0.25, 0.8), custom_size: Some(Vec2::new(64.0, 32.0)), flip_x: true, flip_y: false, ..default() }," + )); + assert!(code.contains("Text2d::new(\"Hello Forge\"),")); + assert!(code.contains("TextFont { font_size: FontSize::Px(32.0), ..default() },")); + assert!(code.contains("TextColor(Color::srgba(1.0, 1.0, 1.0, 1.0)),")); + assert!(code.contains("Camera2d,")); + assert!(code.contains("Camera3d::default(),")); + assert!(code.contains( + "PointLight { color: Color::srgba(1.0, 0.95, 0.9, 1.0), intensity: 800.0, range: 20.0, shadow_maps_enabled: true, ..default() }," + )); + assert!(code.contains( + "DirectionalLight { color: Color::srgba(1.0, 1.0, 1.0, 1.0), illuminance: 10000.0, shadow_maps_enabled: true, ..default() }," + )); + assert!(code.contains("Mesh3d(meshes.add(Cuboid::new(1.0, 1.0, 1.0))),")); + assert!(code.contains( + "MeshMaterial3d(materials.add(StandardMaterial { base_color: Color::srgba(0.45, 0.62, 0.95, 1.0), metallic: 0.0, perceptual_roughness: 0.5, unlit: false, ..default() }))," + )); + assert!(code.contains("Visibility::Hidden,")); + } + + #[test] + fn visibility_modes_are_emitted() { + for (mode, variant) in [ + (VisibilityMode::Inherited, "Visibility::Inherited,"), + (VisibilityMode::Visible, "Visibility::Visible,"), + (VisibilityMode::Hidden, "Visibility::Hidden,"), + ] { + let doc = scene_with(ComponentData::Visibility(mode)); + assert!(generate_plain(&doc).contains(variant)); + } + } + + #[test] + fn sprite_omits_missing_image_and_custom_size() { + let doc = scene_with(ComponentData::Sprite(SpriteData { + image: None, + color: Color::WHITE, + custom_size: None, + flip_x: false, + flip_y: true, + })); + + let code = generate_plain(&doc); + assert!(code.contains( + " Sprite { color: Color::srgba(1.0, 1.0, 1.0, 1.0), flip_x: false, flip_y: true, ..default() }," + )); + assert!(!code.contains("image:")); + assert!(!code.contains("custom_size:")); + assert!(!code.contains("asset_server")); + } + + #[test] + fn primitive_meshes_use_bevy_constructors() { + let mut doc = SceneDoc::new("Primitives"); + let shapes = [ + ( + PrimitiveShape::Cuboid { + size: Vec3::new(2.0, 3.0, 4.0), + }, + "Mesh3d(meshes.add(Cuboid::new(2.0, 3.0, 4.0))),", + ), + ( + PrimitiveShape::Sphere { radius: 1.5 }, + "Mesh3d(meshes.add(Sphere::new(1.5))),", + ), + ( + PrimitiveShape::Plane { + size: Vec2::new(4.0, 6.0), + }, + "Mesh3d(meshes.add(Plane3d::new(Vec3::Y, Vec2::new(2.0, 3.0)))),", + ), + ( + PrimitiveShape::Cylinder { + radius: 0.5, + height: 2.0, + }, + "Mesh3d(meshes.add(Cylinder::new(0.5, 2.0))),", + ), + ( + PrimitiveShape::Capsule { + radius: 0.4, + length: 1.2, + }, + "Mesh3d(meshes.add(Capsule3d::new(0.4, 1.2))),", + ), + ]; + for (shape, _) in &shapes { + let id = doc.add_entity(shape.label(), None); + assert!(doc.add_component( + id, + ComponentData::Mesh3d(Mesh3dData { + mesh: MeshSource::Primitive(shape.clone()), + material: MaterialData::default(), + }) + )); + } + + let code = generate_plain(&doc); + for (_, expected) in shapes { + assert!(code.contains(expected), "missing {expected}\n{code}"); + } + } + + #[test] + fn mesh_assets_require_asset_server() { + let doc = scene_with(ComponentData::Mesh3d(Mesh3dData { + mesh: MeshSource::Asset("models/robot.gltf#Mesh0/Primitive0".into()), + material: MaterialData::default(), + })); + + let code = generate_plain(&doc); + assert!(code.contains( + "pub fn scene(\n commands: &mut Commands,\n asset_server: &AssetServer,\n meshes: &mut Assets,\n materials: &mut Assets,\n) {" + )); + assert!(code.contains("Mesh3d(asset_server.load(\"models/robot.gltf#Mesh0/Primitive0\")),")); + } + + #[test] + fn parameters_are_conditional() { + // No components: only `commands`. + let mut plain = SceneDoc::new("Plain"); + plain.add_entity("Empty", None); + let code = generate_plain(&plain); + assert!(code.contains("pub fn scene(commands: &mut Commands) {")); + assert!(!code.contains("asset_server")); + assert!(!code.contains("meshes")); + + // Sprite image: `asset_server` only. + let code = generate_plain(&scene_with(ComponentData::Sprite(SpriteData { + image: Some("textures/hero.png".into()), + ..Default::default() + }))); + assert!(code.contains( + "pub fn scene(\n commands: &mut Commands,\n asset_server: &AssetServer,\n) {" + )); + assert!(!code.contains("Assets")); + + // Primitive mesh: `meshes` + `materials`, no `asset_server`. + let code = generate_plain(&scene_with(ComponentData::Mesh3d(Mesh3dData::default()))); + assert!(code.contains( + "pub fn scene(\n commands: &mut Commands,\n meshes: &mut Assets,\n materials: &mut Assets,\n) {" + )); + assert!(!code.contains("asset_server")); + + // Mesh + sprite image: all four parameters in contract order. + let mut doc = SceneDoc::new("Mixed"); + let root = doc.add_entity("Root", None); + assert!(doc.add_component(root, ComponentData::Mesh3d(Mesh3dData::default()))); + let child = doc.add_entity("Child", Some(root)); + assert!(doc.add_component( + child, + ComponentData::Sprite(SpriteData { + image: Some("textures/hero.png".into()), + ..Default::default() + }) + )); + let code = generate_plain(&doc); + assert!(code.contains( + "pub fn scene(\n commands: &mut Commands,\n asset_server: &AssetServer,\n meshes: &mut Assets,\n materials: &mut Assets,\n) {" + )); + } + + #[test] + fn custom_components_use_struct_literals() { + let doc = scene_with(ComponentData::Custom(CustomComponent { + type_path: "my_game::Health".into(), + fields: vec![ + CustomField { + name: "current".into(), + expr: "10.0".into(), + }, + CustomField { + name: "max".into(), + expr: "100.0".into(), + }, + ], + })); + assert!( + generate_plain(&doc).contains(" my_game::Health { current: 10.0, max: 100.0 },") + ); + + let doc = scene_with(ComponentData::Custom(CustomComponent { + type_path: "my_game::Marker".into(), + fields: Vec::new(), + })); + assert!(generate_plain(&doc).contains(" my_game::Marker,")); + } + + #[test] + fn invalid_custom_components_are_rejected() { + let invalid_path = scene_with(ComponentData::Custom(CustomComponent { + type_path: "not a path".into(), + fields: Vec::new(), + })); + assert!(matches!( + generate(&invalid_path, &options()), + Err(CodegenError::InvalidCustomComponent(_)) + )); + + let invalid_field = scene_with(ComponentData::Custom(CustomComponent { + type_path: "my_game::Health".into(), + fields: vec![CustomField { + name: "1current".into(), + expr: "10.0".into(), + }], + })); + assert!(matches!( + generate(&invalid_field, &options()), + Err(CodegenError::InvalidCustomComponent(_)) + )); + + let empty_expr = scene_with(ComponentData::Custom(CustomComponent { + type_path: "my_game::Health".into(), + fields: vec![CustomField { + name: "current".into(), + expr: " ".into(), + }], + })); + assert!(matches!( + generate(&empty_expr, &options()), + Err(CodegenError::InvalidCustomComponent(_)) + )); + } + + #[test] + fn strings_and_names_are_rust_escaped() { + let mut doc = SceneDoc::new("Escapes"); + let id = doc.add_entity("A\"B\\C\nD\tE", None); + assert!(doc.add_component( + id, + ComponentData::Text2d(Text2dData { + text: "line1\n\"quoted\"\t".into(), + font_size: 16.0, + color: Color::WHITE, + }) + )); + doc.add_entity("Héllo 世界", None); + + let code = generate_plain(&doc); + assert!( + code.contains("Name::new(\"A\\\"B\\\\C\\nD\\tE\"),"), + "{code}" + ); + assert!( + code.contains("Text2d::new(\"line1\\n\\\"quoted\\\"\\t\"),"), + "{code}" + ); + assert!(code.contains("Name::new(\"Héllo 世界\"),"), "{code}"); + } + + #[test] + fn empty_scene_is_an_error() { + let doc = SceneDoc::new("Empty"); + assert!(matches!( + generate(&doc, &options()), + Err(CodegenError::EmptyScene) + )); + } + + #[test] + fn non_finite_transforms_are_rejected() { + let mut doc = SceneDoc::new("NaN"); + let id = doc.add_entity("Broken", None); + doc.entity_mut(id) + .expect("entity exists") + .transform + .translation + .x = f32::NAN; + assert_non_finite(&doc, "transform.translation.x"); + } + + #[test] + fn non_finite_material_light_and_sprite_values_are_rejected() { + let mut material = Mesh3dData::default(); + material.material.metallic = f32::INFINITY; + assert_non_finite( + &scene_with(ComponentData::Mesh3d(material)), + "material.metallic", + ); + + let mut light = PointLightData::default(); + light.range = f32::NAN; + assert_non_finite( + &scene_with(ComponentData::PointLight(light)), + "point_light.range", + ); + + let mut sprite = SpriteData::default(); + sprite.custom_size = Some(Vec2::new(f32::NAN, 1.0)); + assert_non_finite( + &scene_with(ComponentData::Sprite(sprite)), + "sprite.custom_size.x", + ); + } + + #[test] + fn invalid_function_names_are_rejected() { + let mut doc = SceneDoc::new("Scene"); + doc.add_entity("Root", None); + for name in ["", "scene generator", "2scene", "my-scene", "fn"] { + let options = CodegenOptions { + function_name: name.to_string(), + include_header: false, + }; + match generate(&doc, &options) { + Err(CodegenError::InvalidIdentifier { what, value }) => { + assert_eq!(what, "function name"); + assert_eq!(value, name); + } + other => panic!("expected InvalidIdentifier for {name:?}, got {other:?}"), + } + } + } + + #[test] + fn valid_function_name_is_used() { + let mut doc = SceneDoc::new("Scene"); + doc.add_entity("Root", None); + let options = CodegenOptions { + function_name: "spawn_garden".into(), + include_header: false, + }; + let code = generate(&doc, &options).expect("valid identifier"); + assert!(code.contains("pub fn spawn_garden(commands: &mut Commands) {")); + } + + #[test] + fn output_is_deterministic() { + let doc = { + let mut doc = SceneDoc::new("Deterministic"); + let root = doc.add_entity("Root", None); + let child = doc.add_entity("Child", Some(root)); + assert!(doc.add_component( + child, + ComponentData::Sprite(SpriteData { + image: Some("textures/hero.png".into()), + ..Default::default() + }) + )); + doc + }; + let first = generate_plain(&doc); + let second = generate_plain(&doc); + assert_eq!(first, second); + } + + #[test] + fn header_is_optional_and_output_ends_with_one_newline() { + let mut doc = SceneDoc::new("Header"); + doc.add_entity("Root", None); + + let with_header = generate(&doc, &header_options()).expect("generation"); + assert!(with_header.starts_with("// Generated by Bevy Forge.")); + assert!(with_header.contains("use bevy::prelude::*;\n\npub fn scene")); + + let without_header = generate_plain(&doc); + assert!(without_header.starts_with("use bevy::prelude::*;\n")); + assert!(!without_header.contains("Generated by Bevy Forge")); + + for code in [with_header, without_header] { + assert!(code.ends_with('\n')); + assert!(!code.ends_with("\n\n")); + } + } +} diff --git a/crates/bevy_forge_core/src/components.rs b/crates/bevy_forge_core/src/components.rs new file mode 100644 index 0000000..756176a --- /dev/null +++ b/crates/bevy_forge_core/src/components.rs @@ -0,0 +1,553 @@ +//! Component data authored in the editor and emitted as Bevy 0.20 BSN components. + +use crate::math::{Color, Vec2, Vec3}; +use serde::{Deserialize, Serialize}; + +/// The built-in component types the inspector can add. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ComponentKind { + Sprite, + Text2d, + Camera2d, + Camera3d, + PointLight, + DirectionalLight, + Mesh3d, + Visibility, +} + +impl ComponentKind { + pub const ALL: [Self; 8] = [ + Self::Sprite, + Self::Text2d, + Self::Camera2d, + Self::Camera3d, + Self::PointLight, + Self::DirectionalLight, + Self::Mesh3d, + Self::Visibility, + ]; + + pub const fn label(self) -> &'static str { + match self { + Self::Sprite => "Sprite", + Self::Text2d => "Text2d", + Self::Camera2d => "Camera2d", + Self::Camera3d => "Camera3d", + Self::PointLight => "PointLight", + Self::DirectionalLight => "DirectionalLight", + Self::Mesh3d => "Mesh3d", + Self::Visibility => "Visibility", + } + } + + pub const fn key(self) -> &'static str { + match self { + Self::Sprite => "sprite", + Self::Text2d => "text2d", + Self::Camera2d => "camera2d", + Self::Camera3d => "camera3d", + Self::PointLight => "point_light", + Self::DirectionalLight => "directional_light", + Self::Mesh3d => "mesh3d", + Self::Visibility => "visibility", + } + } + + /// One-line description used in the "Add component" menu. + pub const fn description(self) -> &'static str { + match self { + Self::Sprite => "2D sprite with an optional texture", + Self::Text2d => "World-space text", + Self::Camera2d => "2D camera", + Self::Camera3d => "3D perspective camera", + Self::PointLight => "Point light with range", + Self::DirectionalLight => "Directional (sun) light", + Self::Mesh3d => "3D primitive or mesh asset with a material", + Self::Visibility => "Inherited, visible or hidden", + } + } +} + +/// One component attached to an entity. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(tag = "type", content = "data", rename_all = "snake_case")] +pub enum ComponentData { + Sprite(SpriteData), + Text2d(Text2dData), + Camera2d, + Camera3d, + PointLight(PointLightData), + DirectionalLight(DirectionalLightData), + Mesh3d(Mesh3dData), + Visibility(VisibilityMode), + Custom(CustomComponent), +} + +impl ComponentData { + /// Stable identity used to prevent duplicate components of the same type. + /// + /// Custom components are keyed by their full Rust type path, so a project can + /// attach `my_game::Health` and `my_game::Mana` side by side. + pub fn key(&self) -> String { + match self { + Self::Sprite(_) => "sprite".into(), + Self::Text2d(_) => "text2d".into(), + Self::Camera2d => "camera2d".into(), + Self::Camera3d => "camera3d".into(), + Self::PointLight(_) => "point_light".into(), + Self::DirectionalLight(_) => "directional_light".into(), + Self::Mesh3d(_) => "mesh3d".into(), + Self::Visibility(_) => "visibility".into(), + Self::Custom(custom) => format!("custom:{}", custom.type_path), + } + } + + pub fn kind(&self) -> Option { + match self { + Self::Sprite(_) => Some(ComponentKind::Sprite), + Self::Text2d(_) => Some(ComponentKind::Text2d), + Self::Camera2d => Some(ComponentKind::Camera2d), + Self::Camera3d => Some(ComponentKind::Camera3d), + Self::PointLight(_) => Some(ComponentKind::PointLight), + Self::DirectionalLight(_) => Some(ComponentKind::DirectionalLight), + Self::Mesh3d(_) => Some(ComponentKind::Mesh3d), + Self::Visibility(_) => Some(ComponentKind::Visibility), + Self::Custom(_) => None, + } + } + + pub fn label(&self) -> String { + match self { + Self::Custom(custom) => custom.short_name().to_string(), + other => other + .kind() + .map(|k| k.label().to_string()) + .unwrap_or_else(|| "Component".into()), + } + } + + /// Default value for a built-in component. + pub fn from_kind(kind: ComponentKind) -> Self { + match kind { + ComponentKind::Sprite => Self::Sprite(SpriteData::default()), + ComponentKind::Text2d => Self::Text2d(Text2dData::default()), + ComponentKind::Camera2d => Self::Camera2d, + ComponentKind::Camera3d => Self::Camera3d, + ComponentKind::PointLight => Self::PointLight(PointLightData::default()), + ComponentKind::DirectionalLight => { + Self::DirectionalLight(DirectionalLightData::default()) + } + ComponentKind::Mesh3d => Self::Mesh3d(Mesh3dData::default()), + ComponentKind::Visibility => Self::Visibility(VisibilityMode::Visible), + } + } + + /// Asset paths referenced by this component, with a human-readable field name. + pub fn asset_references(&self) -> Vec<(&'static str, &str)> { + match self { + Self::Sprite(sprite) => sprite + .image + .as_deref() + .map(|path| vec![("image", path)]) + .unwrap_or_default(), + Self::Mesh3d(mesh) => match &mesh.mesh { + MeshSource::Asset(path) => vec![("mesh", path.as_str())], + MeshSource::Primitive(_) => Vec::new(), + }, + _ => Vec::new(), + } + } + + pub fn is_builtin(&self) -> bool { + !matches!(self, Self::Custom(_)) + } +} + +// --------------------------------------------------------------------------- +// Sprite +// --------------------------------------------------------------------------- + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct SpriteData { + /// Path relative to the Bevy `assets/` folder, e.g. `textures/player.png`. + pub image: Option, + pub color: Color, + /// `None` uses the texture's native size. + pub custom_size: Option, + pub flip_x: bool, + pub flip_y: bool, +} + +impl Default for SpriteData { + fn default() -> Self { + Self { + image: None, + color: Color::WHITE, + custom_size: None, + flip_x: false, + flip_y: false, + } + } +} + +// --------------------------------------------------------------------------- +// Text +// --------------------------------------------------------------------------- + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct Text2dData { + pub text: String, + pub font_size: f32, + pub color: Color, +} + +impl Default for Text2dData { + fn default() -> Self { + Self { + text: "Text".into(), + font_size: 32.0, + color: Color::WHITE, + } + } +} + +// --------------------------------------------------------------------------- +// Lights +// --------------------------------------------------------------------------- + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct PointLightData { + pub color: Color, + pub intensity: f32, + pub range: f32, + pub shadows: bool, +} + +impl Default for PointLightData { + fn default() -> Self { + Self { + color: Color::srgba(1.0, 0.95, 0.9, 1.0), + intensity: 800.0, + range: 20.0, + shadows: true, + } + } +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct DirectionalLightData { + pub color: Color, + pub illuminance: f32, + pub shadows: bool, +} + +impl Default for DirectionalLightData { + fn default() -> Self { + Self { + color: Color::WHITE, + illuminance: 10_000.0, + shadows: true, + } + } +} + +// --------------------------------------------------------------------------- +// 3D meshes +// --------------------------------------------------------------------------- + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct Mesh3dData { + pub mesh: MeshSource, + pub material: MaterialData, +} + +impl Default for Mesh3dData { + fn default() -> Self { + Self { + mesh: MeshSource::Primitive(PrimitiveShape::Cuboid { size: Vec3::ONE }), + material: MaterialData::default(), + } + } +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(tag = "source", content = "value", rename_all = "snake_case")] +pub enum MeshSource { + /// A procedural Bevy primitive, emitted as `meshes.add(Cuboid::new(..))`. + Primitive(PrimitiveShape), + /// A mesh asset path such as `models/robot.gltf#Mesh0/Primitive0`. + Asset(String), +} + +impl MeshSource { + pub fn label(&self) -> String { + match self { + Self::Primitive(shape) => shape.label().to_string(), + Self::Asset(path) => path.clone(), + } + } +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum PrimitiveShape { + Cuboid { size: Vec3 }, + Sphere { radius: f32 }, + Plane { size: Vec2 }, + Cylinder { radius: f32, height: f32 }, + Capsule { radius: f32, length: f32 }, +} + +impl PrimitiveShape { + pub fn label(&self) -> &'static str { + match self { + Self::Cuboid { .. } => "Cube", + Self::Sphere { .. } => "Sphere", + Self::Plane { .. } => "Plane", + Self::Cylinder { .. } => "Cylinder", + Self::Capsule { .. } => "Capsule", + } + } + + /// Approximate local-space bounds, used by the 3D preview and picking. + pub fn half_extents(&self) -> Vec3 { + match self { + Self::Cuboid { size } => *size * 0.5, + Self::Sphere { radius } => Vec3::splat(*radius), + Self::Plane { size } => Vec3::new(size.x * 0.5, 0.01, size.y * 0.5), + Self::Cylinder { radius, height } => Vec3::new(*radius, *height * 0.5, *radius), + Self::Capsule { radius, length } => { + Vec3::new(*radius, *length * 0.5 + *radius, *radius) + } + } + } +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct MaterialData { + pub base_color: Color, + pub metallic: f32, + pub perceptual_roughness: f32, + pub unlit: bool, +} + +impl Default for MaterialData { + fn default() -> Self { + Self { + base_color: Color::srgba(0.45, 0.62, 0.95, 1.0), + metallic: 0.0, + perceptual_roughness: 0.5, + unlit: false, + } + } +} + +// --------------------------------------------------------------------------- +// Visibility & custom components +// --------------------------------------------------------------------------- + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum VisibilityMode { + Inherited, + Visible, + Hidden, +} + +impl VisibilityMode { + pub const fn label(self) -> &'static str { + match self { + Self::Inherited => "Inherited", + Self::Visible => "Visible", + Self::Hidden => "Hidden", + } + } +} + +/// Escape hatch for project-specific Bevy components. +/// +/// Field values are raw Rust/BSN expressions (`10.0`, `Vec3::new(0.0, 1.0, 0.0)`, +/// `Color::WHITE`, ...). The editor stores them verbatim and emits them into the +/// generated struct literal, so the project's component type must be in scope. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct CustomComponent { + pub type_path: String, + pub fields: Vec, +} + +impl Default for CustomComponent { + fn default() -> Self { + Self { + type_path: "MyComponent".into(), + fields: Vec::new(), + } + } +} + +impl CustomComponent { + pub fn short_name(&self) -> &str { + self.type_path + .rsplit("::") + .next() + .unwrap_or(self.type_path.as_str()) + } + + pub fn is_valid(&self) -> bool { + is_rust_path(&self.type_path) && self.fields.iter().all(|field| is_rust_ident(&field.name)) + } +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct CustomField { + pub name: String, + pub expr: String, +} + +/// Whether a string is a valid (possibly qualified) Rust type path. +pub fn is_rust_path(value: &str) -> bool { + !value.is_empty() + && value + .split("::") + .all(|segment| is_rust_ident(segment.trim())) +} + +/// Whether a string is a reserved Rust keyword (including `_`), which cannot be +/// used as an identifier even though it is lexically valid. +pub fn is_rust_keyword(value: &str) -> bool { + matches!( + value, + "abstract" + | "as" + | "async" + | "await" + | "become" + | "box" + | "break" + | "const" + | "continue" + | "crate" + | "do" + | "dyn" + | "else" + | "enum" + | "extern" + | "false" + | "final" + | "fn" + | "for" + | "gen" + | "if" + | "impl" + | "in" + | "let" + | "loop" + | "macro" + | "match" + | "mod" + | "move" + | "mut" + | "override" + | "priv" + | "pub" + | "ref" + | "return" + | "self" + | "Self" + | "static" + | "struct" + | "super" + | "trait" + | "true" + | "try" + | "type" + | "typeof" + | "unsafe" + | "unsized" + | "use" + | "virtual" + | "where" + | "while" + | "yield" + | "_" + ) +} + +/// Whether a string is a valid Rust identifier (ASCII subset + keyword check; +/// good enough for component/field names and generated identifiers). +pub fn is_rust_ident(value: &str) -> bool { + if value.is_empty() || is_rust_keyword(value) { + return false; + } + let mut chars = value.chars(); + match chars.next() { + Some(first) if first == '_' || first.is_ascii_alphabetic() => {} + _ => return false, + } + chars.all(|c| c == '_' || c.is_ascii_alphanumeric()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn component_keys_are_stable_and_unique_per_custom_type() { + assert_eq!( + ComponentData::from_kind(ComponentKind::Sprite).key(), + "sprite" + ); + let a = ComponentData::Custom(CustomComponent { + type_path: "game::Health".into(), + fields: vec![], + }); + let b = ComponentData::Custom(CustomComponent { + type_path: "game::Mana".into(), + fields: vec![], + }); + assert_ne!(a.key(), b.key()); + assert_eq!(a.label(), "Health"); + } + + #[test] + fn rust_ident_validation() { + assert!(is_rust_ident("Health")); + assert!(is_rust_ident("_health2")); + assert!(!is_rust_ident("2health")); + assert!(!is_rust_ident("my health")); + assert!(is_rust_path("game::components::Health")); + assert!(!is_rust_path("game::")); + } + + #[test] + fn asset_references_finds_sprite_image() { + let component = ComponentData::Sprite(SpriteData { + image: Some("textures/hero.png".into()), + ..Default::default() + }); + assert_eq!( + component.asset_references(), + vec![("image", "textures/hero.png")] + ); + } + + #[test] + fn ron_round_trip_of_all_builtin_kinds() { + for kind in ComponentKind::ALL { + let original = ComponentData::from_kind(kind); + let text = ron::ser::to_string(&original).unwrap(); + let parsed: ComponentData = ron::from_str(&text).unwrap(); + assert_eq!(original, parsed, "round-trip failed for {kind:?}: {text}"); + } + let custom = ComponentData::Custom(CustomComponent { + type_path: "game::Health".into(), + fields: vec![CustomField { + name: "current".into(), + expr: "10.0".into(), + }], + }); + let text = ron::ser::to_string(&custom).unwrap(); + assert_eq!(custom, ron::from_str(&text).unwrap()); + } +} diff --git a/crates/bevy_forge_core/src/config.rs b/crates/bevy_forge_core/src/config.rs new file mode 100644 index 0000000..2275c69 --- /dev/null +++ b/crates/bevy_forge_core/src/config.rs @@ -0,0 +1,128 @@ +//! Persistent editor preferences (recent projects, grid, autosave interval). + +use crate::error::ConfigError; +use serde::{Deserialize, Serialize}; +use std::path::{Path, PathBuf}; + +pub const CONFIG_FILE: &str = "config.ron"; + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct EditorConfig { + pub recent_projects: Vec, + pub last_project: Option, + pub show_grid: bool, + pub snap_to_grid: bool, + pub grid_size: f32, + pub show_assets: bool, + pub autosave_secs: u64, + pub welcome_dismissed: bool, +} + +impl Default for EditorConfig { + fn default() -> Self { + Self { + recent_projects: Vec::new(), + last_project: None, + show_grid: true, + snap_to_grid: false, + grid_size: 1.0, + show_assets: true, + autosave_secs: 60, + welcome_dismissed: false, + } + } +} + +impl EditorConfig { + /// Platform config directory, honouring `XDG_CONFIG_HOME` on Unix. + pub fn config_dir() -> Option { + if let Some(dir) = std::env::var_os("XDG_CONFIG_HOME") { + if !dir.is_empty() { + return Some(PathBuf::from(dir).join("bevy-forge")); + } + } + std::env::var_os("HOME") + .filter(|home| !home.is_empty()) + .map(|home| PathBuf::from(home).join(".config/bevy-forge")) + } + + pub fn config_path() -> Option { + Self::config_dir().map(|dir| dir.join(CONFIG_FILE)) + } + + /// Load the config, returning defaults when the file is missing or unreadable. + pub fn load_lossy() -> Self { + Self::config_path() + .and_then(|path| Self::load_from(&path).ok()) + .unwrap_or_default() + } + + pub fn load_from(path: &Path) -> Result { + let text = std::fs::read_to_string(path).map_err(|source| ConfigError::Io { + path: path.to_path_buf(), + source, + })?; + ron::from_str(&text).map_err(|source| ConfigError::Parse { + path: path.to_path_buf(), + source, + }) + } + + pub fn save(&self) -> Result<(), ConfigError> { + let Some(path) = Self::config_path() else { + return Ok(()); + }; + self.save_to(&path) + } + + pub fn save_to(&self, path: &Path) -> Result<(), ConfigError> { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).map_err(|source| ConfigError::Io { + path: parent.to_path_buf(), + source, + })?; + } + let pretty = ron::ser::PrettyConfig::default() + .new_line("\n".to_string()) + .indentor(" ".to_string()); + let text = ron::ser::to_string_pretty(self, pretty)?; + std::fs::write(path, text).map_err(|source| ConfigError::Io { + path: path.to_path_buf(), + source, + }) + } + + /// Record a project as most-recent, deduplicating and capping the list. + pub fn push_recent(&mut self, project: &Path) { + let project = project.to_path_buf(); + self.recent_projects.retain(|entry| entry != &project); + self.recent_projects.insert(0, project.clone()); + self.recent_projects.truncate(10); + self.last_project = Some(project); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn round_trip_config() { + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join("config.ron"); + let mut config = EditorConfig::default(); + config.push_recent(Path::new("/tmp/game-one")); + config.push_recent(Path::new("/tmp/game-two")); + config.push_recent(Path::new("/tmp/game-one")); + assert_eq!(config.recent_projects.len(), 2); + assert_eq!(config.recent_projects[0], PathBuf::from("/tmp/game-one")); + config.save_to(&path).unwrap(); + let loaded = EditorConfig::load_from(&path).unwrap(); + assert_eq!(loaded, config); + } + + #[test] + fn missing_config_is_default() { + assert!(EditorConfig::load_from(Path::new("/definitely/not/here.ron")).is_err()); + } +} diff --git a/crates/bevy_forge_core/src/error.rs b/crates/bevy_forge_core/src/error.rs new file mode 100644 index 0000000..5e600c1 --- /dev/null +++ b/crates/bevy_forge_core/src/error.rs @@ -0,0 +1,90 @@ +//! Error types shared by the core, CLI and editor. + +use crate::scene::EntityId; +use std::path::PathBuf; +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum SceneError { + #[error("entity {0} was not found")] + EntityNotFound(EntityId), + #[error("parent entity {0} was not found")] + ParentNotFound(EntityId), + #[error("entity {0} cannot be parented to itself")] + SelfParent(EntityId), + #[error("reparenting entity {entity} under {parent} would create a cycle")] + Cycle { entity: EntityId, parent: EntityId }, +} + +#[derive(Debug, Error)] +pub enum ProjectError { + #[error("not a Bevy project (Cargo.toml with a `bevy` dependency not found): {0}")] + NotAProject(PathBuf), + #[error("I/O error at {path}: {source}")] + Io { + path: PathBuf, + #[source] + source: std::io::Error, + }, + #[error("could not parse Cargo.toml: {0}")] + Manifest(#[from] toml::de::Error), + #[error("could not parse scene {path}: {source}")] + SceneParse { + path: PathBuf, + #[source] + source: ron::de::SpannedError, + }, + #[error("could not serialize scene: {0}")] + SceneSerialize(#[from] ron::Error), + #[error("scene path {0} is not inside the project")] + SceneOutsideProject(PathBuf), + #[error("invalid scene file extension (expected .forge.ron): {0}")] + InvalidSceneExtension(PathBuf), + #[error("the target directory already exists and is not empty: {0}")] + DirectoryNotEmpty(PathBuf), +} + +impl ProjectError { + pub fn io(path: impl Into, source: std::io::Error) -> Self { + Self::Io { + path: path.into(), + source, + } + } +} + +#[derive(Debug, Error)] +pub enum CodegenError { + #[error("cannot generate code for a scene with no entities")] + EmptyScene, + #[error("non-finite {field} on entity {entity:?}: {value}")] + NonFinite { + entity: Option, + field: String, + value: f32, + }, + #[error("invalid Rust identifier for {what}: {value:?}")] + InvalidIdentifier { what: String, value: String }, + #[error("scene contains invalid custom component data: {0}")] + InvalidCustomComponent(String), + #[error("entity {entity} has more than one {key} component")] + DuplicateComponent { entity: EntityId, key: String }, +} + +#[derive(Debug, Error)] +pub enum ConfigError { + #[error("I/O error at {path}: {source}")] + Io { + path: PathBuf, + #[source] + source: std::io::Error, + }, + #[error("could not parse config {path}: {source}")] + Parse { + path: PathBuf, + #[source] + source: ron::de::SpannedError, + }, + #[error("could not serialize config: {0}")] + Serialize(#[from] ron::Error), +} diff --git a/crates/bevy_forge_core/src/history.rs b/crates/bevy_forge_core/src/history.rs new file mode 100644 index 0000000..2054c23 --- /dev/null +++ b/crates/bevy_forge_core/src/history.rs @@ -0,0 +1,307 @@ +//! Snapshot-based undo/redo and the editing session wrapper. + +use crate::scene::SceneDoc; + +#[derive(Clone, Debug)] +pub struct Snapshot { + pub label: String, + pub doc: SceneDoc, +} + +/// Bounded undo/redo stacks of full document snapshots. +/// +/// Scene files are small (hundreds of entities at most), so snapshots are simpler +/// and far less error-prone than per-operation inverse commands. +#[derive(Clone, Debug)] +pub struct History { + pub undo: Vec, + pub redo: Vec, + pub limit: usize, +} + +impl History { + pub fn new(limit: usize) -> Self { + Self { + undo: Vec::new(), + redo: Vec::new(), + limit: limit.max(1), + } + } + + pub fn record(&mut self, label: impl Into, before: SceneDoc) { + self.redo.clear(); + self.undo.push(Snapshot { + label: label.into(), + doc: before, + }); + if self.undo.len() > self.limit { + self.undo.remove(0); + } + } + + pub fn undo(&mut self, current: &mut SceneDoc) -> Option { + let snapshot = self.undo.pop()?; + self.redo.push(Snapshot { + label: snapshot.label.clone(), + doc: current.clone(), + }); + *current = snapshot.doc; + Some(snapshot.label) + } + + pub fn redo(&mut self, current: &mut SceneDoc) -> Option { + let snapshot = self.redo.pop()?; + self.undo.push(Snapshot { + label: snapshot.label.clone(), + doc: current.clone(), + }); + *current = snapshot.doc; + Some(snapshot.label) + } + + pub fn clear(&mut self) { + self.undo.clear(); + self.redo.clear(); + } + + pub fn can_undo(&self) -> bool { + !self.undo.is_empty() + } + + pub fn can_redo(&self) -> bool { + !self.redo.is_empty() + } + + pub fn undo_label(&self) -> Option<&str> { + self.undo.last().map(|snapshot| snapshot.label.as_str()) + } + + pub fn redo_label(&self) -> Option<&str> { + self.redo.last().map(|snapshot| snapshot.label.as_str()) + } +} + +impl Default for History { + fn default() -> Self { + Self::new(100) + } +} + +/// A scene document plus undo history and dirty tracking. +/// +/// All undoable mutations should go through [`Session::edit`] (or +/// [`Session::commit`] for continuous drags). [`Session::doc_mut`] is an escape +/// hatch for read-modify-write UI code and marks the session dirty without adding +/// an undo entry. +#[derive(Clone, Debug)] +pub struct Session { + doc: SceneDoc, + history: History, + dirty: bool, + /// Snapshot of the last saved revision, used to clear the dirty flag when + /// undo/redo brings the document back to exactly that state. + saved: SceneDoc, +} + +impl Session { + pub fn new(doc: SceneDoc) -> Self { + Self { + saved: doc.clone(), + doc, + history: History::default(), + dirty: false, + } + } + + pub fn with_history_limit(doc: SceneDoc, limit: usize) -> Self { + Self { + saved: doc.clone(), + doc, + history: History::new(limit), + dirty: false, + } + } + + fn refresh_dirty(&mut self) { + self.dirty = self.doc != self.saved; + } + + pub fn doc(&self) -> &SceneDoc { + &self.doc + } + + /// Mutable access without an undo snapshot. Prefer [`Session::edit`]. + pub fn doc_mut(&mut self) -> &mut SceneDoc { + self.dirty = true; + &mut self.doc + } + + /// Apply an edit, recording an undo snapshot when the document changed. + pub fn edit(&mut self, label: impl Into, f: impl FnOnce(&mut SceneDoc) -> R) -> R { + let before = self.doc.clone(); + let result = f(&mut self.doc); + if self.doc != before { + self.history.record(label, before); + self.refresh_dirty(); + } + result + } + + /// Commit a pre-edit snapshot, for continuous interactions (gizmo drags) that + /// update the document every frame and should produce one undo entry. + pub fn commit(&mut self, before: SceneDoc, label: impl Into) { + if self.doc != before { + self.history.record(label, before); + self.refresh_dirty(); + } + } + + pub fn undo(&mut self) -> Option { + let label = self.history.undo(&mut self.doc)?; + self.refresh_dirty(); + Some(label) + } + + pub fn redo(&mut self) -> Option { + let label = self.history.redo(&mut self.doc)?; + self.refresh_dirty(); + Some(label) + } + + pub fn can_undo(&self) -> bool { + self.history.can_undo() + } + + pub fn can_redo(&self) -> bool { + self.history.can_redo() + } + + pub fn undo_label(&self) -> Option<&str> { + self.history.undo_label() + } + + pub fn redo_label(&self) -> Option<&str> { + self.history.redo_label() + } + + pub fn is_dirty(&self) -> bool { + self.dirty + } + + pub fn mark_saved(&mut self) { + self.saved = self.doc.clone(); + self.dirty = false; + } + + /// Replace the document and clear history (opening a scene). + pub fn replace(&mut self, doc: SceneDoc) { + self.saved = doc.clone(); + self.doc = doc; + self.history.clear(); + self.dirty = false; + } + + pub fn history(&self) -> &History { + &self.history + } + + pub fn history_mut(&mut self) -> &mut History { + &mut self.history + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn doc_with_entity() -> SceneDoc { + let mut doc = SceneDoc::new("Test"); + doc.add_entity("A", None); + doc + } + + #[test] + fn edit_records_only_when_changed() { + let mut session = Session::new(doc_with_entity()); + session.edit("noop", |_| {}); + assert!(!session.can_undo()); + session.edit("add", |doc| { + doc.add_entity("B", None); + }); + assert!(session.can_undo()); + assert_eq!(session.undo_label(), Some("add")); + assert!(session.is_dirty()); + } + + #[test] + fn undo_redo_round_trip() { + let mut session = Session::new(doc_with_entity()); + session.edit("add", |doc| { + doc.add_entity("B", None); + }); + let after_add = session.doc().clone(); + assert_eq!(session.undo().as_deref(), Some("add")); + assert_eq!(session.doc().entities.len(), 1); + assert_eq!(session.redo().as_deref(), Some("add")); + assert_eq!(session.doc(), &after_add); + } + + #[test] + fn new_edit_clears_redo() { + let mut session = Session::new(doc_with_entity()); + session.edit("add B", |doc| { + doc.add_entity("B", None); + }); + session.undo(); + assert!(session.can_redo()); + session.edit("add C", |doc| { + doc.add_entity("C", None); + }); + assert!(!session.can_redo()); + } + + #[test] + fn commit_uses_external_snapshot() { + let mut session = Session::new(doc_with_entity()); + let before = session.doc().clone(); + session.doc_mut().add_entity("B", None); + session.commit(before, "drag"); + assert_eq!(session.undo_label(), Some("drag")); + } + + #[test] + fn replace_clears_state() { + let mut session = Session::new(doc_with_entity()); + session.edit("add", |doc| { + doc.add_entity("B", None); + }); + session.replace(doc_with_entity()); + assert!(!session.can_undo()); + assert!(!session.is_dirty()); + } + + #[test] + fn undo_back_to_saved_revision_clears_dirty() { + let mut session = Session::new(doc_with_entity()); + session.edit("add", |doc| { + doc.add_entity("B", None); + }); + session.mark_saved(); + session.edit("rename", |doc| { + doc.rename(1, "Renamed"); + }); + assert!(session.is_dirty()); + session.undo(); + assert!(!session.is_dirty(), "undo to the saved revision is clean"); + } + + #[test] + fn history_limit_drops_oldest() { + let mut history = History::new(2); + for i in 0..4 { + history.record(format!("edit {i}"), SceneDoc::new("x")); + } + assert_eq!(history.undo.len(), 2); + assert_eq!(history.undo[0].label, "edit 2"); + } +} diff --git a/crates/bevy_forge_core/src/lib.rs b/crates/bevy_forge_core/src/lib.rs new file mode 100644 index 0000000..6b9e2bb --- /dev/null +++ b/crates/bevy_forge_core/src/lib.rs @@ -0,0 +1,44 @@ +//! # Bevy Forge Core +//! +//! Scene model, project handling, validation and code generation for +//! [Bevy Forge](https://github.com/bevy-forge/bevy-forge), a visual scene editor +//! for the Bevy game engine (Bevy 0.20 BSN). +//! +//! Scene files use the `.forge.ron` extension and are human-readable RON. They are +//! intentionally independent from Bevy's dynamic scene serialization: the editor +//! exports BSN or imperative Rust code, which is compiled by the game. +//! +//! ```no_run +//! use bevy_forge_core::codegen::{CodegenOptions, ExportFormat}; +//! use bevy_forge_core::samples; +//! +//! let doc = samples::starter_2d(); +//! let code = ExportFormat::Bsn.generate(&doc, &CodegenOptions::default()).unwrap(); +//! assert!(code.contains("bsn!")); +//! ``` + +pub mod assets; +pub mod codegen; +pub mod components; +pub mod config; +pub mod error; +pub mod history; +pub mod math; +pub mod project; +pub mod samples; +pub mod scene; +pub mod validation; + +pub use assets::{AssetEntry, AssetIndex, AssetKind}; +pub use codegen::{CodegenOptions, ExportFormat}; +pub use components::{ + ComponentData, ComponentKind, CustomComponent, CustomField, DirectionalLightData, MaterialData, + Mesh3dData, MeshSource, PointLightData, PrimitiveShape, SpriteData, Text2dData, VisibilityMode, +}; +pub use config::EditorConfig; +pub use error::{CodegenError, ConfigError, ProjectError, SceneError}; +pub use history::{History, Session, Snapshot}; +pub use math::{Bounds2, Color, Transform, Vec2, Vec3}; +pub use project::{Project, ProjectTemplate, SCENE_EXTENSION}; +pub use scene::{EntityId, EntityNode, SceneDoc, SceneStats, FORMAT_VERSION}; +pub use validation::{validate_scene, Issue, Severity, ValidationReport}; diff --git a/crates/bevy_forge_core/src/math.rs b/crates/bevy_forge_core/src/math.rs new file mode 100644 index 0000000..06fb70e --- /dev/null +++ b/crates/bevy_forge_core/src/math.rs @@ -0,0 +1,593 @@ +//! Small, dependency-free math types used by the scene model. +//! +//! Bevy Forge stores rotations as XYZ Euler angles in degrees because that is what +//! humans edit in the inspector and what the viewport gizmos manipulate. Conversion +//! helpers to quaternions are used for hierarchy composition and 3D preview. + +use serde::{Deserialize, Serialize}; +use std::ops::{Add, Div, Mul, Neg, Sub}; + +/// A two-dimensional vector in world units or pixels, depending on context. +#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)] +pub struct Vec2 { + pub x: f32, + pub y: f32, +} + +impl Vec2 { + pub const ZERO: Self = Self::new(0.0, 0.0); + pub const ONE: Self = Self::new(1.0, 1.0); + pub const X: Self = Self::new(1.0, 0.0); + pub const Y: Self = Self::new(0.0, 1.0); + + pub const fn new(x: f32, y: f32) -> Self { + Self { x, y } + } + + pub fn splat(v: f32) -> Self { + Self::new(v, v) + } + + pub fn length(self) -> f32 { + self.length_squared().sqrt() + } + + pub fn length_squared(self) -> f32 { + self.x * self.x + self.y * self.y + } + + pub fn distance(self, other: Self) -> f32 { + (self - other).length() + } + + pub fn normalized_or_zero(self) -> Self { + let len = self.length(); + if len > f32::EPSILON { + self / len + } else { + Self::ZERO + } + } + + pub fn dot(self, other: Self) -> f32 { + self.x * other.x + self.y * other.y + } + + pub fn min(self, other: Self) -> Self { + Self::new(self.x.min(other.x), self.y.min(other.y)) + } + + pub fn max(self, other: Self) -> Self { + Self::new(self.x.max(other.x), self.y.max(other.y)) + } + + pub fn is_finite(self) -> bool { + self.x.is_finite() && self.y.is_finite() + } + + pub const fn to_array(self) -> [f32; 2] { + [self.x, self.y] + } +} + +impl Add for Vec2 { + type Output = Self; + fn add(self, rhs: Self) -> Self { + Self::new(self.x + rhs.x, self.y + rhs.y) + } +} + +impl Sub for Vec2 { + type Output = Self; + fn sub(self, rhs: Self) -> Self { + Self::new(self.x - rhs.x, self.y - rhs.y) + } +} + +impl Mul for Vec2 { + type Output = Self; + fn mul(self, rhs: f32) -> Self { + Self::new(self.x * rhs, self.y * rhs) + } +} + +impl Div for Vec2 { + type Output = Self; + fn div(self, rhs: f32) -> Self { + Self::new(self.x / rhs, self.y / rhs) + } +} + +impl Neg for Vec2 { + type Output = Self; + fn neg(self) -> Self { + Self::new(-self.x, -self.y) + } +} + +/// A three-dimensional vector. +#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)] +pub struct Vec3 { + pub x: f32, + pub y: f32, + pub z: f32, +} + +impl Vec3 { + pub const ZERO: Self = Self::new(0.0, 0.0, 0.0); + pub const ONE: Self = Self::new(1.0, 1.0, 1.0); + pub const X: Self = Self::new(1.0, 0.0, 0.0); + pub const Y: Self = Self::new(0.0, 1.0, 0.0); + pub const Z: Self = Self::new(0.0, 0.0, 1.0); + + pub const fn new(x: f32, y: f32, z: f32) -> Self { + Self { x, y, z } + } + + pub fn splat(v: f32) -> Self { + Self::new(v, v, v) + } + + pub fn length(self) -> f32 { + self.length_squared().sqrt() + } + + pub fn length_squared(self) -> f32 { + self.x * self.x + self.y * self.y + self.z * self.z + } + + pub fn distance(self, other: Self) -> f32 { + (self - other).length() + } + + pub fn normalized_or_zero(self) -> Self { + let len = self.length(); + if len > f32::EPSILON { + self / len + } else { + Self::ZERO + } + } + + pub fn dot(self, other: Self) -> f32 { + self.x * other.x + self.y * other.y + self.z * other.z + } + + pub fn cross(self, other: Self) -> Self { + Self::new( + self.y * other.z - self.z * other.y, + self.z * other.x - self.x * other.z, + self.x * other.y - self.y * other.x, + ) + } + + pub fn is_finite(self) -> bool { + self.x.is_finite() && self.y.is_finite() && self.z.is_finite() + } + + pub const fn to_array(self) -> [f32; 3] { + [self.x, self.y, self.z] + } + + /// XY part as a 2D vector (useful for picking sprites in the 3D view). + pub const fn xy(self) -> Vec2 { + Vec2::new(self.x, self.y) + } +} + +impl Add for Vec3 { + type Output = Self; + fn add(self, rhs: Self) -> Self { + Self::new(self.x + rhs.x, self.y + rhs.y, self.z + rhs.z) + } +} + +impl Sub for Vec3 { + type Output = Self; + fn sub(self, rhs: Self) -> Self { + Self::new(self.x - rhs.x, self.y - rhs.y, self.z - rhs.z) + } +} + +impl Mul for Vec3 { + type Output = Self; + fn mul(self, rhs: f32) -> Self { + Self::new(self.x * rhs, self.y * rhs, self.z * rhs) + } +} + +impl Mul for Vec3 { + type Output = Self; + fn mul(self, rhs: Vec3) -> Self { + Self::new(self.x * rhs.x, self.y * rhs.y, self.z * rhs.z) + } +} + +impl Div for Vec3 { + type Output = Self; + fn div(self, rhs: f32) -> Self { + Self::new(self.x / rhs, self.y / rhs, self.z / rhs) + } +} + +impl Neg for Vec3 { + type Output = Self; + fn neg(self) -> Self { + Self::new(-self.x, -self.y, -self.z) + } +} + +/// A linear RGBA color with components in `0.0..=1.0`. +/// +/// The editor exposes these as sRGB values; conversion functions are provided for +/// hex display and for Bevy's `Color::srgba` in generated code. +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +pub struct Color { + pub r: f32, + pub g: f32, + pub b: f32, + pub a: f32, +} + +impl Default for Color { + fn default() -> Self { + Self::WHITE + } +} + +impl Color { + pub const WHITE: Self = Self::srgba(1.0, 1.0, 1.0, 1.0); + pub const BLACK: Self = Self::srgba(0.0, 0.0, 0.0, 1.0); + pub const TRANSPARENT: Self = Self::srgba(0.0, 0.0, 0.0, 0.0); + pub const RED: Self = Self::srgba(0.9, 0.25, 0.25, 1.0); + pub const GREEN: Self = Self::srgba(0.35, 0.8, 0.45, 1.0); + pub const BLUE: Self = Self::srgba(0.35, 0.55, 0.95, 1.0); + + pub const fn srgba(r: f32, g: f32, b: f32, a: f32) -> Self { + Self { r, g, b, a } + } + + pub const fn srgb(r: f32, g: f32, b: f32) -> Self { + Self::srgba(r, g, b, 1.0) + } + + pub fn from_rgba8(r: u8, g: u8, b: u8, a: u8) -> Self { + Self::srgba( + r as f32 / 255.0, + g as f32 / 255.0, + b as f32 / 255.0, + a as f32 / 255.0, + ) + } + + pub fn to_rgba8(self) -> [u8; 4] { + let channel = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round() as u8; + [ + channel(self.r), + channel(self.g), + channel(self.b), + channel(self.a), + ] + } + + /// Parse `#RRGGBB` or `#RRGGBBAA` (the leading `#` is optional). + /// + /// Non-ASCII input returns `None`; never panics on arbitrary user text. + pub fn from_hex(hex: &str) -> Option { + let hex = hex.trim().trim_start_matches('#'); + if !hex.is_ascii() || !hex.bytes().all(|byte| byte.is_ascii_hexdigit()) { + return None; + } + let parse = |start: usize| u8::from_str_radix(&hex[start..start + 2], 16).ok(); + match hex.len() { + 6 => Some(Self::from_rgba8(parse(0)?, parse(2)?, parse(4)?, 255)), + 8 => Some(Self::from_rgba8(parse(0)?, parse(2)?, parse(4)?, parse(6)?)), + _ => None, + } + } + + /// Format as `#RRGGBB` (alpha is dropped). + pub fn to_hex(self) -> String { + let [r, g, b, _] = self.to_rgba8(); + format!("#{r:02X}{g:02X}{b:02X}") + } + + pub fn is_finite(self) -> bool { + self.r.is_finite() && self.g.is_finite() && self.b.is_finite() && self.a.is_finite() + } + + pub const fn to_array(self) -> [f32; 4] { + [self.r, self.g, self.b, self.a] + } +} + +/// A local transform, mirroring the fields of Bevy's `Transform`. +/// +/// `rotation` is stored as XYZ Euler angles in degrees. Bevy itself stores a +/// quaternion; the generated code converts back with +/// `Quat::from_euler(EulerRot::XYZ, x, y, z)`. +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +pub struct Transform { + pub translation: Vec3, + pub rotation: Vec3, + pub scale: Vec3, +} + +impl Default for Transform { + fn default() -> Self { + Self::IDENTITY + } +} + +impl Transform { + pub const IDENTITY: Self = Self { + translation: Vec3::ZERO, + rotation: Vec3::ZERO, + scale: Vec3::ONE, + }; + + pub const fn from_translation(translation: Vec3) -> Self { + Self { + translation, + rotation: Vec3::ZERO, + scale: Vec3::ONE, + } + } + + pub const fn from_xyz(x: f32, y: f32, z: f32) -> Self { + Self::from_translation(Vec3::new(x, y, z)) + } + + pub const fn with_rotation(mut self, rotation: Vec3) -> Self { + self.rotation = rotation; + self + } + + pub const fn with_scale(mut self, scale: Vec3) -> Self { + self.scale = scale; + self + } + + pub fn is_finite(self) -> bool { + self.translation.is_finite() && self.rotation.is_finite() && self.scale.is_finite() + } + + pub fn is_identity(self) -> bool { + self.translation == Vec3::ZERO && self.rotation == Vec3::ZERO && self.scale == Vec3::ONE + } + + /// Compose a parent transform with a child local transform. + pub fn compose(parent: Self, child: Self) -> Self { + let parent_q = quat_from_euler_deg(parent.rotation); + let child_q = quat_from_euler_deg(child.rotation); + let q = quat_mul(parent_q, child_q); + Self { + translation: parent.translation + + quat_rotate_vec3(parent_q, parent.scale * child.translation), + rotation: quat_to_euler_deg(quat_normalize(q)), + scale: parent.scale * child.scale, + } + } + + /// Transform a point by this transform. + pub fn transform_point(self, point: Vec3) -> Vec3 { + self.translation + quat_rotate_vec3(quat_from_euler_deg(self.rotation), self.scale * point) + } + + /// Transform a direction (ignores translation, applies scale and rotation). + pub fn transform_direction(self, direction: Vec3) -> Vec3 { + quat_rotate_vec3(quat_from_euler_deg(self.rotation), self.scale * direction) + } +} + +/// An axis-aligned 2D rectangle used for viewport fitting and picking. +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +pub struct Bounds2 { + pub min: Vec2, + pub max: Vec2, +} + +impl Bounds2 { + pub fn new(min: Vec2, max: Vec2) -> Self { + Self { + min: min.min(max), + max: min.max(max), + } + } + + pub fn from_center_size(center: Vec2, size: Vec2) -> Self { + let half = size * 0.5; + Self::new(center - half, center + half) + } + + pub fn empty() -> Self { + Self { + min: Vec2::splat(f32::INFINITY), + max: Vec2::splat(f32::NEG_INFINITY), + } + } + + pub fn is_empty(self) -> bool { + self.min.x > self.max.x || self.min.y > self.max.y + } + + pub fn center(self) -> Vec2 { + (self.min + self.max) * 0.5 + } + + pub fn size(self) -> Vec2 { + self.max - self.min + } + + pub fn contains(self, point: Vec2) -> bool { + point.x >= self.min.x + && point.x <= self.max.x + && point.y >= self.min.y + && point.y <= self.max.y + } + + pub fn union(self, other: Self) -> Self { + if self.is_empty() { + return other; + } + if other.is_empty() { + return self; + } + Self::new(self.min.min(other.min), self.max.max(other.max)) + } + + pub fn expand(self, amount: f32) -> Self { + Self::new( + self.min - Vec2::splat(amount), + self.max + Vec2::splat(amount), + ) + } + + pub fn corners(self) -> [Vec2; 4] { + [ + self.min, + Vec2::new(self.max.x, self.min.y), + self.max, + Vec2::new(self.min.x, self.max.y), + ] + } +} + +/// Quaternion as `[x, y, z, w]`. +pub type Quat = [f32; 4]; + +pub const QUAT_IDENTITY: Quat = [0.0, 0.0, 0.0, 1.0]; + +pub fn quat_from_euler_deg(euler: Vec3) -> Quat { + let (x, y, z) = ( + euler.x.to_radians() * 0.5, + euler.y.to_radians() * 0.5, + euler.z.to_radians() * 0.5, + ); + let (sx, cx) = x.sin_cos(); + let (sy, cy) = y.sin_cos(); + let (sz, cz) = z.sin_cos(); + // XYZ order, matching Bevy's `Quat::from_euler(EulerRot::XYZ, ..)`. + [ + sx * cy * cz + cx * sy * sz, + cx * sy * cz - sx * cy * sz, + cx * cy * sz + sx * sy * cz, + cx * cy * cz - sx * sy * sz, + ] +} + +pub fn quat_mul(a: Quat, b: Quat) -> Quat { + let [ax, ay, az, aw] = a; + let [bx, by, bz, bw] = b; + [ + aw * bx + ax * bw + ay * bz - az * by, + aw * by - ax * bz + ay * bw + az * bx, + aw * bz + ax * by - ay * bx + az * bw, + aw * bw - ax * bx - ay * by - az * bz, + ] +} + +pub fn quat_normalize(q: Quat) -> Quat { + let len = (q[0] * q[0] + q[1] * q[1] + q[2] * q[2] + q[3] * q[3]).sqrt(); + if len > f32::EPSILON { + [q[0] / len, q[1] / len, q[2] / len, q[3] / len] + } else { + QUAT_IDENTITY + } +} + +pub fn quat_rotate_vec3(q: Quat, v: Vec3) -> Vec3 { + let [x, y, z, w] = quat_normalize(q); + // q * v * q^-1, expanded. + let uv = Vec3::new(y * v.z - z * v.y, z * v.x - x * v.z, x * v.y - y * v.x); + let uuv = Vec3::new( + y * uv.z - z * uv.y, + z * uv.x - x * uv.z, + x * uv.y - y * uv.x, + ); + v + (uv * w + uuv) * 2.0 +} + +pub fn quat_to_euler_deg(q: Quat) -> Vec3 { + // Inverse of `quat_from_euler_deg` for intrinsic XYZ, matching glam's + // `Quat::to_euler(EulerRot::XYZ)`. The rotation matrix is stored row-major; + // glam indexes columns first, hence the transposed reads in the formulas. + let [x, y, z, w] = quat_normalize(q); + let m00 = 1.0 - 2.0 * (y * y + z * z); + let m01 = 2.0 * (x * y - w * z); + let m02 = 2.0 * (x * z + w * y); + let m11 = 1.0 - 2.0 * (x * x + z * z); + let m12 = 2.0 * (y * z - w * x); + let m21 = 2.0 * (y * z + w * x); + let m22 = 1.0 - 2.0 * (x * x + y * y); + + let cy = (m00 * m00 + m01 * m01).sqrt(); + let (ex, ey, ez) = if cy > 16.0 * f32::EPSILON { + (m12.atan2(m22), (-m02).atan2(cy), m01.atan2(m00)) + } else { + // Gimbal lock: only x and z are coupled; keep z at zero. + ((-m21).atan2(m11), (-m02).atan2(cy), 0.0) + }; + + Vec3::new((-ex).to_degrees(), (-ey).to_degrees(), (-ez).to_degrees()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn color_hex_round_trip() { + let color = Color::from_hex("#ff8000").unwrap(); + assert_eq!(color.to_rgba8(), [255, 128, 0, 255]); + assert_eq!(color.to_hex(), "#FF8000"); + assert_eq!(Color::from_hex("#FF800080").unwrap().a, 128.0 / 255.0); + assert!(Color::from_hex("nope").is_none()); + assert!(Color::from_hex("aéabc").is_none()); + assert!(Color::from_hex("#123456").is_none()); + } + + #[test] + fn identity_composition_is_neutral() { + let t = Transform::from_xyz(1.0, 2.0, 3.0).with_rotation(Vec3::new(10.0, 20.0, 30.0)); + let composed = Transform::compose(Transform::IDENTITY, t); + assert!((composed.translation - t.translation).length() < 1e-5); + assert!((composed.rotation - t.rotation).length() < 1e-3); + assert!((composed.scale - t.scale).length() < 1e-5); + } + + #[test] + fn nested_translation_rotates_child_offset() { + let parent = Transform::from_xyz(10.0, 0.0, 0.0).with_rotation(Vec3::new(0.0, 0.0, 90.0)); + let child = Transform::from_xyz(0.0, 2.0, 0.0); + let world = Transform::compose(parent, child); + assert!( + (world.translation.x - 8.0).abs() < 1e-4, + "{:?}", + world.translation + ); + assert!( + (world.translation.y - 0.0).abs() < 1e-4, + "{:?}", + world.translation + ); + } + + #[test] + fn quat_rotates_z_by_ninety_degrees() { + let q = quat_from_euler_deg(Vec3::new(0.0, 0.0, 90.0)); + let v = quat_rotate_vec3(q, Vec3::new(1.0, 0.0, 0.0)); + assert!((v.x - 0.0).abs() < 1e-5); + assert!((v.y - 1.0).abs() < 1e-5); + } + + #[test] + fn bounds_union_and_empty() { + let a = Bounds2::from_center_size(Vec2::new(0.0, 0.0), Vec2::splat(2.0)); + let b = Bounds2::from_center_size(Vec2::new(5.0, 5.0), Vec2::splat(2.0)); + let u = a.union(b); + assert_eq!(u.min, Vec2::new(-1.0, -1.0)); + assert_eq!(u.max, Vec2::new(6.0, 6.0)); + assert!(Bounds2::empty().union(a) == a); + } +} diff --git a/crates/bevy_forge_core/src/project.rs b/crates/bevy_forge_core/src/project.rs new file mode 100644 index 0000000..deb8919 --- /dev/null +++ b/crates/bevy_forge_core/src/project.rs @@ -0,0 +1,385 @@ +//! Opening Bevy projects and reading/writing `.forge.ron` scene files. + +use crate::error::ProjectError; +use crate::scene::{SceneDoc, FORMAT_VERSION}; +use serde::{Deserialize, Serialize}; +use std::path::{Path, PathBuf}; + +pub const SCENE_EXTENSION: &str = "forge.ron"; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Project { + pub root: PathBuf, + pub name: String, + pub assets_dir: PathBuf, + pub bevy_version: Option, +} + +impl Project { + /// Open a Bevy project rooted at `root`. + /// + /// A valid project has a `Cargo.toml` with a `bevy` dependency. The assets + /// directory defaults to `/assets` when it exists, otherwise ``. + pub fn open(root: impl AsRef) -> Result { + let root = root + .as_ref() + .canonicalize() + .map_err(|error| ProjectError::io(root.as_ref().to_path_buf(), error))?; + let manifest_path = root.join("Cargo.toml"); + let manifest_text = std::fs::read_to_string(&manifest_path) + .map_err(|_| ProjectError::NotAProject(root.clone()))?; + let manifest: toml::Value = toml::from_str(&manifest_text)?; + let bevy_version = bevy_dependency_version(&manifest); + if bevy_version.is_none() { + return Err(ProjectError::NotAProject(root)); + } + let name = manifest + .get("package") + .and_then(|package| package.get("name")) + .and_then(|name| name.as_str()) + .map(ToOwned::to_owned) + .unwrap_or_else(|| { + root.file_name() + .and_then(|name| name.to_str()) + .unwrap_or("Bevy project") + .to_string() + }); + let assets_dir = if root.join("assets").is_dir() { + root.join("assets") + } else { + root.clone() + }; + Ok(Self { + root, + name, + assets_dir, + bevy_version, + }) + } + + pub fn scenes_dir(&self) -> PathBuf { + self.root.join("scenes") + } + + pub fn forge_dir(&self) -> PathBuf { + self.root.join(".forge") + } + + pub fn autosave_path(&self) -> PathBuf { + self.forge_dir().join("autosave.forge.ron") + } + + /// Suggested path for a new scene with the given display name. + pub fn scene_path(&self, name: &str) -> PathBuf { + self.scenes_dir() + .join(format!("{}.{SCENE_EXTENSION}", slugify(name))) + } + + pub fn load_scene(&self, path: impl AsRef) -> Result { + let path = path.as_ref(); + let text = std::fs::read_to_string(path) + .map_err(|error| ProjectError::io(path.to_path_buf(), error))?; + let mut doc: SceneDoc = + ron::from_str(&text).map_err(|source| ProjectError::SceneParse { + path: path.to_path_buf(), + source, + })?; + // Be permissive with older files: normalise the format version but keep data. + if doc.format_version == 0 { + doc.format_version = FORMAT_VERSION; + } + Ok(doc) + } + + /// Save a scene into the project's `scenes/` directory. + /// + /// Returns the path written. When `path` is `None`, the scene name is used. + pub fn save_scene(&self, doc: &SceneDoc, path: Option<&Path>) -> Result { + let requested = match path { + Some(path) if path.is_absolute() => path.to_path_buf(), + Some(path) => self.root.join(path), + None => self.scene_path(&doc.name), + }; + let file_name = requested + .file_name() + .ok_or_else(|| ProjectError::SceneOutsideProject(requested.clone()))? + .to_owned(); + if !file_name.to_string_lossy().ends_with(".forge.ron") { + return Err(ProjectError::InvalidSceneExtension(requested)); + } + + // Reject lexical escapes before creating any directories outside the + // project. `Path::starts_with` alone accepts `root/../outside`. + let lexical = normalize_path(&requested); + if !lexical.starts_with(&self.root) { + return Err(ProjectError::SceneOutsideProject(lexical)); + } + + let parent = lexical.parent().unwrap_or(&self.root); + // Check the nearest existing ancestor before creating directories, so a + // symlinked parent cannot make us create directories outside the project + // and only discover it afterwards. + if let Some(existing) = nearest_existing_ancestor(parent) { + let canonical = existing + .canonicalize() + .map_err(|error| ProjectError::io(existing.to_path_buf(), error))?; + if !canonical.starts_with(&self.root) { + return Err(ProjectError::SceneOutsideProject(lexical)); + } + } + std::fs::create_dir_all(parent) + .map_err(|error| ProjectError::io(parent.to_path_buf(), error))?; + // Resolve symlinks in the parent so an in-project symlink cannot redirect + // the write outside the project. + let canonical_parent = parent + .canonicalize() + .map_err(|error| ProjectError::io(parent.to_path_buf(), error))?; + let path = canonical_parent.join(&file_name); + if !path.starts_with(&self.root) { + return Err(ProjectError::SceneOutsideProject(path)); + } + + let pretty = ron::ser::PrettyConfig::default() + .new_line("\n".to_string()) + .indentor(" ".to_string()) + .struct_names(false); + let text = ron::ser::to_string_pretty(doc, pretty)?; + atomic_write(&path, text.as_bytes()) + .map_err(|error| ProjectError::io(path.clone(), error))?; + Ok(path) + } + + /// Resolve an editor-authored asset path (relative to `assets/`) to a file. + pub fn resolve_asset(&self, asset_path: &str) -> PathBuf { + self.assets_dir.join(asset_path) + } + + /// Convert an absolute path inside the assets directory to a Bevy asset path. + pub fn asset_path_for(&self, path: &Path) -> Option { + let relative = path.strip_prefix(&self.assets_dir).ok()?; + let parts: Vec = relative + .components() + .map(|component| component.as_os_str().to_string_lossy().to_string()) + .collect(); + Some(parts.join("/")) + } + + /// All `.forge.ron` scenes directly inside `scenes/`. + pub fn list_scenes(&self) -> Vec { + let mut scenes: Vec = std::fs::read_dir(self.scenes_dir()) + .into_iter() + .flatten() + .filter_map(Result::ok) + .map(|entry| entry.path()) + .filter(|path| { + path.extension().and_then(|ext| ext.to_str()) == Some("ron") + && path + .file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| name.ends_with(".forge.ron")) + }) + .collect(); + scenes.sort(); + scenes + } +} + +/// Walk up from `start` looking for a directory with a `Cargo.toml`. +pub fn find_project_root(start: &Path) -> Option { + let start = if start.is_file() { + start.parent()? + } else { + start + }; + let mut current = Some(start); + while let Some(dir) = current { + if dir.join("Cargo.toml").is_file() { + return Some(dir.to_path_buf()); + } + current = dir.parent(); + } + None +} + +/// Extract the Bevy dependency version from a parsed Cargo.toml. +pub fn bevy_dependency_version(manifest: &toml::Value) -> Option { + let candidates = [ + manifest + .get("dependencies") + .and_then(|deps| deps.get("bevy")), + manifest + .get("workspace") + .and_then(|workspace| workspace.get("dependencies")) + .and_then(|deps| deps.get("bevy")), + manifest + .get("dev-dependencies") + .and_then(|deps| deps.get("bevy")), + ]; + for candidate in candidates.into_iter().flatten() { + match candidate { + toml::Value::String(version) => return Some(version.clone()), + toml::Value::Table(table) => { + if let Some(version) = table.get("version").and_then(|v| v.as_str()) { + return Some(version.to_string()); + } + if table.get("path").is_some() { + return Some("path".to_string()); + } + if table + .get("workspace") + .and_then(|value| value.as_bool()) + .unwrap_or(false) + { + return Some("workspace".to_string()); + } + } + _ => {} + } + } + None +} + +/// Sanitise a scene name into a portable file stem. +pub fn slugify(name: &str) -> String { + let mut slug = String::new(); + for c in name.chars() { + if c.is_ascii_alphanumeric() { + slug.push(c.to_ascii_lowercase()); + } else if c.is_whitespace() || matches!(c, '-' | '_' | '.') { + if !slug.ends_with('-') && !slug.is_empty() { + slug.push('-'); + } + } + } + let slug = slug.trim_matches('-').to_string(); + if slug.is_empty() { + "scene".to_string() + } else { + slug + } +} + +fn nearest_existing_ancestor(path: &Path) -> Option<&Path> { + let mut current = Some(path); + while let Some(candidate) = current { + if candidate.exists() { + return Some(candidate); + } + current = candidate.parent(); + } + None +} + +/// Lexically remove `.` and `..` components without touching the filesystem. +fn normalize_path(path: &Path) -> PathBuf { + use std::path::Component; + 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 +} + +fn atomic_write(path: &Path, contents: &[u8]) -> std::io::Result<()> { + let parent = path.parent().unwrap_or_else(|| Path::new(".")); + let file_name = path + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or("scene.forge.ron"); + let temp = parent.join(format!(".{file_name}.tmp-{}", std::process::id())); + std::fs::write(&temp, contents)?; + std::fs::rename(&temp, path)?; + Ok(()) +} + +/// A template used by `forge new`. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ProjectTemplate { + #[default] + Empty, + TwoD, + ThreeD, +} + +#[cfg(test)] +mod tests { + use super::*; + + fn write_project(dir: &Path, name: &str) -> Project { + std::fs::write( + dir.join("Cargo.toml"), + format!( + "[package]\nname = \"{name}\"\nversion = \"0.1.0\"\nedition = \"2021\"\n\n[dependencies]\nbevy = \"0.20\"\n" + ), + ) + .unwrap(); + std::fs::create_dir_all(dir.join("assets/textures")).unwrap(); + Project::open(dir).unwrap() + } + + #[test] + fn opens_bevy_project_and_reads_version() { + let tmp = tempfile::tempdir().unwrap(); + let project = write_project(tmp.path(), "demo"); + assert_eq!(project.name, "demo"); + assert_eq!(project.bevy_version.as_deref(), Some("0.20")); + assert_eq!(project.assets_dir, tmp.path().join("assets")); + } + + #[test] + fn rejects_directory_without_bevy() { + let tmp = tempfile::tempdir().unwrap(); + std::fs::write(tmp.path().join("Cargo.toml"), "[package]\nname=\"x\"\n").unwrap(); + assert!(matches!( + Project::open(tmp.path()), + Err(ProjectError::NotAProject(_)) + )); + } + + #[test] + fn scene_save_and_load_round_trip() { + let tmp = tempfile::tempdir().unwrap(); + let project = write_project(tmp.path(), "demo"); + let mut doc = SceneDoc::new("Level 01"); + doc.add_entity("Player", None); + let path = project.save_scene(&doc, None).unwrap(); + assert_eq!(path, project.scenes_dir().join("level-01.forge.ron")); + let loaded = project.load_scene(&path).unwrap(); + assert_eq!(loaded, doc); + assert_eq!(project.list_scenes(), vec![path]); + } + + #[test] + fn asset_paths_are_relative_to_assets_dir() { + let tmp = tempfile::tempdir().unwrap(); + let project = write_project(tmp.path(), "demo"); + let abs = project.assets_dir.join("textures/hero.png"); + assert_eq!( + project.asset_path_for(&abs).as_deref(), + Some("textures/hero.png") + ); + assert_eq!(project.resolve_asset("textures/hero.png"), abs); + } + + #[test] + fn slugify_is_safe() { + assert_eq!(slugify("Level 01"), "level-01"); + assert_eq!(slugify("My Scene!!"), "my-scene"); + assert_eq!(slugify("***"), "scene"); + } + + #[test] + fn manifest_can_have_version_table() { + let manifest: toml::Value = + toml::from_str("[dependencies.bevy]\nversion = \"0.20\"\nfeatures = [\"2d\"]\n") + .unwrap(); + assert_eq!(bevy_dependency_version(&manifest).as_deref(), Some("0.20")); + } +} diff --git a/crates/bevy_forge_core/src/samples.rs b/crates/bevy_forge_core/src/samples.rs new file mode 100644 index 0000000..18f304c --- /dev/null +++ b/crates/bevy_forge_core/src/samples.rs @@ -0,0 +1,1014 @@ +//! Ready-made scenes used by the editor (welcome screen, New Scene menu), the CLI +//! (`forge new`) and the test suite. +//! +//! The starter scenes are deliberately asset-free: a freshly scaffolded project must +//! open and export without needing any files on disk. The two demo scenes instead +//! reference the committed textures in `examples/demo-project/assets/textures`. + +use crate::components::{ + ComponentData, CustomComponent, CustomField, DirectionalLightData, MaterialData, Mesh3dData, + MeshSource, PointLightData, PrimitiveShape, SpriteData, Text2dData, +}; +use crate::math::{Color, Transform, Vec2, Vec3}; +use crate::project::ProjectTemplate; +use crate::scene::{EntityId, SceneDoc}; + +// --------------------------------------------------------------------------- +// Palette +// --------------------------------------------------------------------------- + +const NIGHT: Color = Color::srgb(0.10, 0.11, 0.22); +const GRASS: Color = Color::srgb(0.28, 0.55, 0.30); +const AMBER: Color = Color::srgb(0.95, 0.67, 0.25); +const CORAL: Color = Color::srgb(0.89, 0.35, 0.30); +const SKY: Color = Color::srgb(0.36, 0.62, 0.90); +const CREAM: Color = Color::srgb(0.96, 0.94, 0.88); +const SUNLIGHT: Color = Color::srgb(1.0, 0.95, 0.85); +const TERRACOTTA: Color = Color::srgb(0.80, 0.42, 0.28); +const LANTERN: Color = Color::srgb(1.0, 0.82, 0.55); +const MEADOW: Color = Color::srgb(0.31, 0.48, 0.23); +const SHRUB_GREEN: Color = Color::srgb(0.24, 0.45, 0.22); +const STEM_GREEN: Color = Color::srgb(0.35, 0.55, 0.27); +const BLOSSOM: Color = Color::srgb(0.91, 0.42, 0.65); +const CRYSTAL: Color = Color::srgb(0.27, 0.76, 0.69); +const IRON: Color = Color::srgb(0.18, 0.20, 0.25); + +// --------------------------------------------------------------------------- +// Demo assets (relative to the project's `assets/` directory) +// --------------------------------------------------------------------------- + +const PLAYER_TEXTURE: &str = "textures/player.png"; +const HAT_TEXTURE: &str = "textures/hat.png"; +const TILE_TEXTURE: &str = "textures/tile.png"; +const COIN_TEXTURE: &str = "textures/coin.png"; +const CRATE_TEXTURE: &str = "textures/crate.png"; +const BACKGROUND_TEXTURE: &str = "textures/background.png"; + +// --------------------------------------------------------------------------- +// Builders +// --------------------------------------------------------------------------- + +/// An empty scene named `Untitled`. +pub fn empty_scene() -> SceneDoc { + SceneDoc::new("Untitled") +} + +/// A small 2D scene with a camera, a player sprite, a child and some text. +/// +/// Uses flat colors only, so it works in a freshly created project without assets. +pub fn starter_2d() -> SceneDoc { + let mut doc = SceneDoc::new("Starter 2D"); + + let camera = add_entity( + &mut doc, + "Camera", + None, + Transform::from_xyz(0.0, 0.0, 10.0), + ); + doc.add_component(camera, ComponentData::Camera2d); + + let backdrop = add_entity( + &mut doc, + "Backdrop", + None, + Transform::from_xyz(0.0, 0.0, -30.0), + ); + doc.add_component(backdrop, sprite(None, NIGHT, Vec2::new(960.0, 540.0))); + + let ground = add_entity( + &mut doc, + "Ground", + None, + Transform::from_xyz(0.0, -160.0, -5.0), + ); + doc.add_component(ground, sprite(None, GRASS, Vec2::new(960.0, 96.0))); + + let player = add_entity( + &mut doc, + "Player", + None, + Transform::from_xyz(0.0, -64.0, 0.0), + ); + doc.add_component(player, sprite(None, AMBER, Vec2::new(64.0, 96.0))); + + let hat = add_entity( + &mut doc, + "Hat", + Some(player), + Transform::from_xyz(0.0, 44.0, 1.0), + ); + doc.add_component(hat, sprite(None, CORAL, Vec2::new(48.0, 16.0))); + + let title = add_entity( + &mut doc, + "Title", + None, + Transform::from_xyz(0.0, 170.0, 5.0), + ); + doc.add_component(title, text2d("Bevy Forge", 48.0, CREAM)); + + doc +} + +/// A small 3D scene with a camera, sun, ground plane and a few primitives. +pub fn starter_3d() -> SceneDoc { + let mut doc = SceneDoc::new("Starter 3D"); + + let camera = add_entity( + &mut doc, + "Camera", + None, + Transform::from_xyz(0.0, 4.0, 10.0).with_rotation(Vec3::new(-15.0, 0.0, 0.0)), + ); + doc.add_component(camera, ComponentData::Camera3d); + + let sun = add_entity( + &mut doc, + "Sun", + None, + Transform::IDENTITY.with_rotation(Vec3::new(-50.0, -30.0, 0.0)), + ); + doc.add_component( + sun, + ComponentData::DirectionalLight(DirectionalLightData { + color: SUNLIGHT, + illuminance: 10_000.0, + shadows: true, + }), + ); + + let ground = add_entity(&mut doc, "Ground", None, Transform::from_xyz(0.0, 0.0, 0.0)); + doc.add_component( + ground, + mesh( + PrimitiveShape::Plane { + size: Vec2::new(24.0, 24.0), + }, + material(GRASS, 0.0, 0.95), + ), + ); + + let cube = add_entity(&mut doc, "Cube", None, Transform::from_xyz(-2.0, 0.8, 0.0)); + doc.add_component( + cube, + mesh( + PrimitiveShape::Cuboid { + size: Vec3::new(1.6, 1.6, 1.6), + }, + material(TERRACOTTA, 0.05, 0.45), + ), + ); + + let sphere = add_entity(&mut doc, "Sphere", None, Transform::from_xyz(2.0, 0.9, 0.0)); + doc.add_component( + sphere, + mesh( + PrimitiveShape::Sphere { radius: 0.9 }, + material(SKY, 0.2, 0.3), + ), + ); + + let point_light = add_entity( + &mut doc, + "Point Light", + None, + Transform::from_xyz(3.0, 3.0, 3.0), + ); + doc.add_component( + point_light, + ComponentData::PointLight(PointLightData { + color: SUNLIGHT, + intensity: 600.0, + range: 15.0, + shadows: true, + }), + ); + + doc +} + +/// A more complete 2D demo used by the demo project and screenshots. +pub fn platformer_demo() -> SceneDoc { + let mut doc = SceneDoc::new("Platformer Demo"); + + let backdrop = add_entity( + &mut doc, + "Backdrop", + None, + Transform::from_xyz(0.0, 0.0, -30.0), + ); + doc.add_component( + backdrop, + sprite( + Some(BACKGROUND_TEXTURE), + Color::WHITE, + Vec2::new(960.0, 540.0), + ), + ); + + let camera = add_entity( + &mut doc, + "Camera", + None, + Transform::from_xyz(0.0, 0.0, 10.0), + ); + doc.add_component(camera, ComponentData::Camera2d); + + let title = add_entity( + &mut doc, + "Title", + None, + Transform::from_xyz(0.0, 214.0, 5.0), + ); + doc.add_component(title, text2d("Bevy Forge Platformer", 44.0, CREAM)); + + let hint = add_entity(&mut doc, "Hint", None, Transform::from_xyz(0.0, 176.0, 5.0)); + doc.add_component(hint, text2d("Collect the coins!", 20.0, AMBER)); + + // Dusk ground row: eight 96x96 tiles, from x = -336 to x = 336. + for index in 0..8 { + let x = -336.0 + index as f32 * 96.0; + let tile = add_entity( + &mut doc, + format!("Ground {}", index + 1), + None, + Transform::from_xyz(x, -224.0, -2.0), + ); + doc.add_component( + tile, + sprite(Some(TILE_TEXTURE), Color::WHITE, Vec2::splat(96.0)), + ); + } + + // A floating ledge to make the scene feel like a platformer. + for index in 0..3 { + let x = -48.0 + index as f32 * 96.0; + let tile = add_entity( + &mut doc, + format!("Ledge {}", index + 1), + None, + Transform::from_xyz(x, -64.0, -2.0), + ); + doc.add_component( + tile, + sprite(Some(TILE_TEXTURE), Color::WHITE, Vec2::splat(96.0)), + ); + } + + let player = add_entity( + &mut doc, + "Player", + None, + Transform::from_xyz(-192.0, -128.0, 0.0), + ); + doc.add_component( + player, + sprite(Some(PLAYER_TEXTURE), Color::WHITE, Vec2::new(64.0, 96.0)), + ); + + let hat = add_entity( + &mut doc, + "Hat", + Some(player), + Transform::from_xyz(0.0, 42.0, 1.0), + ); + doc.add_component( + hat, + sprite(Some(HAT_TEXTURE), Color::WHITE, Vec2::new(56.0, 28.0)), + ); + + for (index, (x, y)) in [(-288.0, -104.0), (48.0, 28.0), (240.0, -96.0)] + .into_iter() + .enumerate() + { + let coin = add_entity( + &mut doc, + format!("Coin {}", index + 1), + None, + Transform::from_xyz(x, y, 1.0), + ); + doc.add_component( + coin, + sprite(Some(COIN_TEXTURE), Color::WHITE, Vec2::splat(48.0)), + ); + } + + let crate_one = add_entity( + &mut doc, + "Crate", + None, + Transform::from_xyz(352.0, -144.0, -1.0), + ); + doc.add_component( + crate_one, + sprite(Some(CRATE_TEXTURE), Color::WHITE, Vec2::splat(64.0)), + ); + + let crate_two = add_entity( + &mut doc, + "Crate 2", + None, + Transform::from_xyz(352.0, -80.0, -1.0), + ); + doc.add_component( + crate_two, + sprite(Some(CRATE_TEXTURE), Color::WHITE, Vec2::splat(64.0)), + ); + + doc +} + +/// A more complete 3D demo used by the demo project and screenshots. +/// +/// The orbit crystal carries the custom `forge_demo::Orbit` component from the demo +/// project; every other entity only uses built-in components. +pub fn garden_demo() -> SceneDoc { + let mut doc = SceneDoc::new("Garden Demo"); + + let camera = add_entity( + &mut doc, + "Camera", + None, + Transform::from_xyz(7.5, 5.0, 12.0).with_rotation(Vec3::new(-16.0, 30.0, 0.0)), + ); + doc.add_component(camera, ComponentData::Camera3d); + + let sun = add_entity( + &mut doc, + "Sun", + None, + Transform::IDENTITY.with_rotation(Vec3::new(-55.0, -25.0, 0.0)), + ); + doc.add_component( + sun, + ComponentData::DirectionalLight(DirectionalLightData { + color: SUNLIGHT, + illuminance: 11_000.0, + shadows: true, + }), + ); + + let ground = add_entity(&mut doc, "Ground", None, Transform::from_xyz(0.0, 0.0, 0.0)); + doc.add_component( + ground, + mesh( + PrimitiveShape::Plane { + size: Vec2::new(26.0, 26.0), + }, + material(MEADOW, 0.0, 0.92), + ), + ); + + let shrub = add_entity( + &mut doc, + "Shrub", + None, + Transform::from_xyz(-3.4, 1.0, 1.8).with_scale(Vec3::new(1.25, 0.9, 1.25)), + ); + doc.add_component( + shrub, + mesh( + PrimitiveShape::Sphere { radius: 1.0 }, + material(SHRUB_GREEN, 0.0, 0.85), + ), + ); + + let stem = add_entity( + &mut doc, + "Flower Stem", + None, + Transform::from_xyz(2.6, 0.75, 1.4), + ); + doc.add_component( + stem, + mesh( + PrimitiveShape::Cylinder { + radius: 0.09, + height: 1.5, + }, + material(STEM_GREEN, 0.0, 0.8), + ), + ); + + let bloom = add_entity( + &mut doc, + "Flower Bloom", + Some(stem), + Transform::from_xyz(0.0, 1.0, 0.0).with_scale(Vec3::new(1.0, 0.75, 1.0)), + ); + doc.add_component( + bloom, + mesh( + PrimitiveShape::Sphere { radius: 0.42 }, + material(BLOSSOM, 0.0, 0.4), + ), + ); + + let crystal = add_entity( + &mut doc, + "Orbit Crystal", + None, + Transform::from_xyz(0.0, 2.0, 0.0), + ); + doc.add_component( + crystal, + mesh( + PrimitiveShape::Sphere { radius: 0.8 }, + material(CRYSTAL, 0.15, 0.2), + ), + ); + doc.add_component( + crystal, + ComponentData::Custom(CustomComponent { + type_path: "forge_demo::Orbit".into(), + fields: vec![ + CustomField { + name: "radius".into(), + expr: "2.4".into(), + }, + CustomField { + name: "speed".into(), + expr: "0.45".into(), + }, + ], + }), + ); + + let post = add_entity( + &mut doc, + "Lantern Post", + None, + Transform::from_xyz(3.8, 1.2, -1.2), + ); + doc.add_component( + post, + mesh( + PrimitiveShape::Cylinder { + radius: 0.12, + height: 2.4, + }, + material(IRON, 0.6, 0.35), + ), + ); + + let glow = add_entity( + &mut doc, + "Lantern Glow", + Some(post), + Transform::from_xyz(0.0, 1.25, 0.0), + ); + doc.add_component( + glow, + ComponentData::PointLight(PointLightData { + color: LANTERN, + intensity: 600.0, + range: 12.0, + shadows: true, + }), + ); + + doc +} + +/// Scene for a freshly scaffolded project. +pub fn for_template(template: ProjectTemplate) -> SceneDoc { + match template { + ProjectTemplate::Empty => empty_scene(), + ProjectTemplate::TwoD => starter_2d(), + ProjectTemplate::ThreeD => starter_3d(), + } +} + +// --------------------------------------------------------------------------- +// Small construction helpers +// --------------------------------------------------------------------------- + +fn add_entity( + doc: &mut SceneDoc, + name: impl Into, + parent: Option, + transform: Transform, +) -> EntityId { + let id = doc.add_entity(name, parent); + if let Some(node) = doc.entity_mut(id) { + node.transform = transform; + } + id +} + +fn sprite(image: Option<&str>, color: Color, size: Vec2) -> ComponentData { + ComponentData::Sprite(SpriteData { + image: image.map(ToOwned::to_owned), + color, + custom_size: Some(size), + flip_x: false, + flip_y: false, + }) +} + +fn text2d(text: &str, font_size: f32, color: Color) -> ComponentData { + ComponentData::Text2d(Text2dData { + text: text.to_string(), + font_size, + color, + }) +} + +fn material(base_color: Color, metallic: f32, perceptual_roughness: f32) -> MaterialData { + MaterialData { + base_color, + metallic, + perceptual_roughness, + unlit: false, + } +} + +fn mesh(shape: PrimitiveShape, material: MaterialData) -> ComponentData { + ComponentData::Mesh3d(Mesh3dData { + mesh: MeshSource::Primitive(shape), + material, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::scene::EntityNode; + use std::collections::{BTreeSet, HashSet}; + use std::path::{Path, PathBuf}; + + fn demo_project_dir() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")).join("../../examples/demo-project") + } + + fn pretty_config() -> ron::ser::PrettyConfig { + ron::ser::PrettyConfig::default() + .new_line("\n".to_string()) + .indentor(" ".to_string()) + .struct_names(false) + } + + fn node<'a>(doc: &'a SceneDoc, name: &str) -> &'a EntityNode { + let id = doc + .find_by_name(name) + .unwrap_or_else(|| panic!("scene {} has no entity named {name}", doc.name)); + doc.entity(id) + .unwrap_or_else(|| panic!("entity {name} disappeared from {}", doc.name)) + } + + fn sprite_data<'a>(doc: &'a SceneDoc, name: &str) -> &'a SpriteData { + match node(doc, name).component("sprite") { + Some(ComponentData::Sprite(sprite)) => sprite, + _ => panic!("entity {name} has no Sprite component"), + } + } + + fn mesh_shape<'a>(doc: &'a SceneDoc, name: &str) -> &'a PrimitiveShape { + match node(doc, name).component("mesh3d") { + Some(ComponentData::Mesh3d(mesh)) => match &mesh.mesh { + MeshSource::Primitive(shape) => shape, + MeshSource::Asset(path) => panic!("entity {name} uses mesh asset {path}"), + }, + _ => panic!("entity {name} has no Mesh3d component"), + } + } + + fn assert_finite(value: f32, context: &str) { + assert!(value.is_finite(), "{context} is not finite"); + } + + fn assert_component_is_finite(component: &ComponentData, entity: &str) { + match component { + ComponentData::Sprite(sprite) => { + assert!(sprite.color.is_finite(), "sprite color on {entity}"); + if let Some(size) = sprite.custom_size { + assert!(size.is_finite(), "sprite custom_size on {entity}"); + assert!( + size.x > 0.0 && size.y > 0.0, + "sprite custom_size on {entity} must be positive" + ); + } + } + ComponentData::Text2d(text) => { + assert!( + text.font_size.is_finite() && text.font_size > 0.0, + "text font_size on {entity}" + ); + assert!(text.color.is_finite(), "text color on {entity}"); + } + ComponentData::PointLight(light) => { + assert!(light.color.is_finite(), "point light color on {entity}"); + assert_finite(light.intensity, "point light intensity"); + assert_finite(light.range, "point light range"); + } + ComponentData::DirectionalLight(light) => { + assert!( + light.color.is_finite(), + "directional light color on {entity}" + ); + assert_finite(light.illuminance, "directional light illuminance"); + } + ComponentData::Mesh3d(mesh) => { + let material = &mesh.material; + assert!( + material.base_color.is_finite(), + "material base_color on {entity}" + ); + assert_finite(material.metallic, "material metallic"); + assert_finite(material.perceptual_roughness, "material roughness"); + match &mesh.mesh { + MeshSource::Primitive(shape) => match shape { + PrimitiveShape::Cuboid { size } => { + assert!(size.is_finite(), "cuboid size on {entity}") + } + PrimitiveShape::Sphere { radius } => { + assert_finite(*radius, "sphere radius") + } + PrimitiveShape::Plane { size } => { + assert!(size.is_finite(), "plane size on {entity}") + } + PrimitiveShape::Cylinder { radius, height } => { + assert_finite(*radius, "cylinder radius"); + assert_finite(*height, "cylinder height"); + } + PrimitiveShape::Capsule { radius, length } => { + assert_finite(*radius, "capsule radius"); + assert_finite(*length, "capsule length"); + } + }, + MeshSource::Asset(path) => assert!(!path.trim().is_empty()), + } + } + ComponentData::Custom(custom) => { + assert!(custom.is_valid(), "invalid custom component on {entity}"); + for field in &custom.fields { + assert!( + !field.expr.trim().is_empty(), + "empty custom field on {entity}" + ); + } + } + ComponentData::Camera2d | ComponentData::Camera3d | ComponentData::Visibility(_) => {} + } + } + + fn assert_scene_is_sound(doc: &SceneDoc) { + let problems = doc.check_integrity(); + assert!( + problems.is_empty(), + "scene {} has structural problems: {problems:?}", + doc.name + ); + let mut names = HashSet::new(); + for entity in doc.entities.values() { + assert!( + !entity.name.trim().is_empty(), + "scene {} has an unnamed entity", + doc.name + ); + assert!( + names.insert(entity.name.as_str()), + "scene {} has duplicate entity name {:?}", + doc.name, + entity.name + ); + assert!( + entity.transform.is_finite(), + "entity {:?} has a non-finite transform", + entity.name + ); + let mut keys = HashSet::new(); + for component in &entity.components { + assert!( + keys.insert(component.key()), + "entity {:?} has duplicate component {}", + entity.name, + component.key() + ); + assert_component_is_finite(component, &entity.name); + } + } + } + + fn referenced_assets(doc: &SceneDoc) -> BTreeSet { + let mut paths = BTreeSet::new(); + for entity in doc.entities.values() { + for component in &entity.components { + for (_, path) in component.asset_references() { + paths.insert(path.to_string()); + } + } + } + paths + } + + #[test] + fn empty_scene_is_untitled_and_empty() { + let doc = empty_scene(); + assert_eq!(doc.name, "Untitled"); + assert!(doc.entities.is_empty()); + assert!(doc.roots.is_empty()); + assert!(doc.check_integrity().is_empty()); + } + + #[test] + fn every_builder_is_structurally_valid_and_non_empty() { + for doc in [starter_2d(), starter_3d(), platformer_demo(), garden_demo()] { + assert!(!doc.entities.is_empty(), "{} is empty", doc.name); + assert!(!doc.roots.is_empty(), "{} has no roots", doc.name); + assert_scene_is_sound(&doc); + } + assert_scene_is_sound(&empty_scene()); + } + + #[test] + fn starter_2d_has_camera_player_child_text_and_no_assets() { + let doc = starter_2d(); + assert_eq!(doc.name, "Starter 2D"); + assert_scene_is_sound(&doc); + assert_eq!(doc.stats().cameras, 1); + assert_eq!(doc.stats().meshes, 0); + + let player = node(&doc, "Player"); + let player_sprite = sprite_data(&doc, "Player"); + assert_eq!(player_sprite.image, None, "starter_2d must be asset-free"); + assert_eq!(player_sprite.custom_size, Some(Vec2::new(64.0, 96.0))); + assert_eq!(player_sprite.color, AMBER); + + let hat = node(&doc, "Hat"); + assert_eq!(hat.parent, Some(player.id)); + assert_eq!(sprite_data(&doc, "Hat").image, None); + assert!(matches!( + node(&doc, "Title").component("text2d"), + Some(ComponentData::Text2d(text)) if text.text == "Bevy Forge" + )); + + for entity in doc.entities.values() { + if let Some(ComponentData::Sprite(sprite)) = entity.component("sprite") { + assert!( + sprite.image.is_none(), + "starter_2d entity {:?} references an asset", + entity.name + ); + } + } + } + + #[test] + fn starter_3d_has_camera_sun_ground_cube_sphere_and_point_light() { + let doc = starter_3d(); + assert_eq!(doc.name, "Starter 3D"); + assert_scene_is_sound(&doc); + let stats = doc.stats(); + assert_eq!(stats.cameras, 1); + assert_eq!(stats.lights, 2); + assert_eq!(stats.meshes, 3); + + let camera = node(&doc, "Camera"); + assert_eq!(camera.transform.translation, Vec3::new(0.0, 4.0, 10.0)); + assert!(camera.transform.rotation.x < 0.0, "camera looks down"); + assert!(matches!( + camera.component("camera3d"), + Some(ComponentData::Camera3d) + )); + + assert!(matches!( + mesh_shape(&doc, "Ground"), + PrimitiveShape::Plane { .. } + )); + assert!(matches!( + mesh_shape(&doc, "Cube"), + PrimitiveShape::Cuboid { .. } + )); + assert!(matches!( + mesh_shape(&doc, "Sphere"), + PrimitiveShape::Sphere { .. } + )); + + assert!(matches!( + node(&doc, "Sun").component("directional_light"), + Some(ComponentData::DirectionalLight(_)) + )); + assert!(matches!( + node(&doc, "Point Light").component("point_light"), + Some(ComponentData::PointLight(_)) + )); + } + + #[test] + fn platformer_demo_uses_textures_hierarchy_and_text() { + let doc = platformer_demo(); + assert_eq!(doc.name, "Platformer Demo"); + assert_scene_is_sound(&doc); + let stats = doc.stats(); + assert_eq!(stats.cameras, 1); + assert_eq!(stats.meshes, 0); + assert_eq!(stats.lights, 0); + assert!(stats.sprites >= 15, "expected a populated 2D scene"); + + let expected: BTreeSet = [ + PLAYER_TEXTURE, + HAT_TEXTURE, + TILE_TEXTURE, + COIN_TEXTURE, + CRATE_TEXTURE, + BACKGROUND_TEXTURE, + ] + .iter() + .map(|path| path.to_string()) + .collect(); + assert_eq!(referenced_assets(&doc), expected); + + let player = node(&doc, "Player"); + assert_eq!(player.transform.translation, Vec3::new(-192.0, -128.0, 0.0)); + let hat = node(&doc, "Hat"); + assert_eq!(hat.parent, Some(player.id)); + assert_eq!(sprite_data(&doc, "Hat").image.as_deref(), Some(HAT_TEXTURE)); + + let text_count = doc + .entities + .values() + .filter(|entity| entity.has_component("text2d")) + .count(); + assert_eq!(text_count, 2, "title and hint"); + assert_eq!( + sprite_data(&doc, "Backdrop").image.as_deref(), + Some(BACKGROUND_TEXTURE) + ); + } + + #[test] + fn garden_demo_has_primitives_lights_and_custom_orbit() { + let doc = garden_demo(); + assert_eq!(doc.name, "Garden Demo"); + assert_scene_is_sound(&doc); + let stats = doc.stats(); + assert_eq!(stats.cameras, 1); + assert_eq!(stats.lights, 2); + assert_eq!(stats.custom_components, 1); + assert!(stats.meshes >= 5, "garden needs ground plus primitives"); + + assert!(matches!( + node(&doc, "Camera").component("camera3d"), + Some(ComponentData::Camera3d) + )); + assert!(matches!( + mesh_shape(&doc, "Ground"), + PrimitiveShape::Plane { .. } + )); + assert!(matches!( + node(&doc, "Sun").component("directional_light"), + Some(ComponentData::DirectionalLight(_)) + )); + assert!(matches!( + node(&doc, "Lantern Glow").component("point_light"), + Some(ComponentData::PointLight(light)) if light.range > 0.0 && light.intensity > 0.0 + )); + + let mut orbits = Vec::new(); + for entity in doc.entities.values() { + for component in &entity.components { + if let ComponentData::Custom(custom) = component { + if custom.type_path == "forge_demo::Orbit" { + orbits.push((entity, custom)); + } + } + } + } + assert_eq!(orbits.len(), 1, "exactly one Orbit custom component"); + let (orbit_entity, orbit) = orbits[0]; + assert_eq!(orbit_entity.name, "Orbit Crystal"); + assert!(orbit_entity.has_component("mesh3d")); + let field_names: Vec<&str> = orbit + .fields + .iter() + .map(|field| field.name.as_str()) + .collect(); + assert_eq!(field_names, vec!["radius", "speed"]); + assert_eq!(orbit.fields[0].expr, "2.4"); + assert_eq!(orbit.fields[1].expr, "0.45"); + + let mut materials = BTreeSet::new(); + for entity in doc.entities.values() { + if let Some(ComponentData::Mesh3d(mesh)) = entity.component("mesh3d") { + materials.insert(mesh.material.base_color.to_hex()); + } + } + assert!( + materials.len() >= 5, + "expected varied material colors, got {materials:?}" + ); + } + + #[test] + fn for_template_returns_the_expected_samples() { + assert_eq!(for_template(ProjectTemplate::Empty), empty_scene()); + assert_eq!(for_template(ProjectTemplate::TwoD), starter_2d()); + assert_eq!(for_template(ProjectTemplate::ThreeD), starter_3d()); + } + + #[test] + fn sample_scenes_round_trip_through_ron() { + for doc in [ + empty_scene(), + starter_2d(), + starter_3d(), + platformer_demo(), + garden_demo(), + ] { + let text = ron::ser::to_string_pretty(&doc, pretty_config()).unwrap(); + let parsed: SceneDoc = ron::from_str(&text).unwrap(); + assert_eq!(parsed, doc, "RON round-trip changed {}", doc.name); + } + } + + #[test] + fn demo_scene_files_parse_and_match_builders() { + let scenes = demo_project_dir().join("scenes"); + let cases = [ + ("platformer.forge.ron", platformer_demo()), + ("garden.forge.ron", garden_demo()), + ]; + for (file, expected) in cases { + let path = scenes.join(file); + let text = std::fs::read_to_string(&path) + .unwrap_or_else(|error| panic!("failed to read {}: {error}", path.display())); + let doc: SceneDoc = ron::from_str(&text) + .unwrap_or_else(|error| panic!("failed to parse {}: {error}", path.display())); + assert_scene_is_sound(&doc); + assert_eq!( + doc, expected, + "{file} is stale; re-run the ignored write_demo_scenes test" + ); + } + } + + #[test] + fn demo_scene_assets_exist_and_are_png() { + let assets_dir = demo_project_dir().join("assets"); + let mut referenced = BTreeSet::new(); + for doc in [platformer_demo(), garden_demo()] { + referenced.extend(referenced_assets(&doc)); + } + assert!( + !referenced.is_empty(), + "demo scenes should reference assets" + ); + for asset_path in referenced { + assert!( + !asset_path.contains('\\'), + "asset paths must use forward slashes: {asset_path}" + ); + let path = assets_dir.join(&asset_path); + assert!(path.is_file(), "missing demo asset {}", path.display()); + let bytes = std::fs::read(&path) + .unwrap_or_else(|error| panic!("failed to read {}: {error}", path.display())); + assert!( + bytes.starts_with(&[0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A]), + "{} is not a PNG file", + path.display() + ); + assert!( + path.extension().and_then(|ext| ext.to_str()) == Some("png"), + "{} must use the .png extension", + path.display() + ); + } + } + + /// Writes the committed demo scenes. Run once with: + /// + /// ```text + /// FORGE_DEMO_DIR=examples/demo-project/scenes \ + /// cargo test -p bevy_forge_core write_demo_scenes -- --ignored + /// ``` + #[test] + #[ignore = "writes demo files"] + fn write_demo_scenes() { + let Ok(dir) = std::env::var("FORGE_DEMO_DIR") else { + eprintln!("FORGE_DEMO_DIR is not set; no demo scenes were written"); + return; + }; + let dir = PathBuf::from(dir); + let dir = if dir.is_absolute() { + dir + } else { + Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../..") + .join(dir) + }; + std::fs::create_dir_all(&dir) + .unwrap_or_else(|error| panic!("failed to create {}: {error}", dir.display())); + for (file, doc) in [ + ("platformer.forge.ron", platformer_demo()), + ("garden.forge.ron", garden_demo()), + ] { + let text = ron::ser::to_string_pretty(&doc, pretty_config()) + .unwrap_or_else(|error| panic!("failed to serialize {file}: {error}")); + let path = dir.join(file); + std::fs::write(&path, text) + .unwrap_or_else(|error| panic!("failed to write {}: {error}", path.display())); + println!("wrote {}", path.display()); + } + } +} diff --git a/crates/bevy_forge_core/src/scene.rs b/crates/bevy_forge_core/src/scene.rs new file mode 100644 index 0000000..a3c798d --- /dev/null +++ b/crates/bevy_forge_core/src/scene.rs @@ -0,0 +1,570 @@ +//! Scene document model and structural operations. +//! +//! A scene is a forest of entities. Every entity has a mandatory local transform +//! and an ordered list of optional components. `entities` preserves insertion order +//! for stable serialization; display order is defined by `roots` and each node's +//! `children` list. + +use crate::components::{ComponentData, VisibilityMode}; +use crate::error::SceneError; +use crate::math::{Bounds2, Transform, Vec2}; +use indexmap::IndexMap; +use serde::{Deserialize, Serialize}; +use std::collections::HashSet; + +/// Stable identifier of an entity inside a single scene file. +pub type EntityId = u64; + +pub const FORMAT_VERSION: u32 = 1; + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct EntityNode { + pub id: EntityId, + pub name: String, + pub parent: Option, + pub children: Vec, + pub transform: Transform, + pub components: Vec, + /// Editor-only lock: the entity cannot be transformed by viewport gizmos. + #[serde(default)] + pub locked: bool, +} + +impl EntityNode { + pub fn has_component(&self, key: &str) -> bool { + self.components + .iter() + .any(|component| component.key() == key) + } + + pub fn component(&self, key: &str) -> Option<&ComponentData> { + self.components + .iter() + .find(|component| component.key() == key) + } + + pub fn component_mut(&mut self, key: &str) -> Option<&mut ComponentData> { + self.components + .iter_mut() + .find(|component| component.key() == key) + } + + pub fn visibility(&self) -> VisibilityMode { + match self.component("visibility") { + Some(ComponentData::Visibility(mode)) => *mode, + _ => VisibilityMode::Inherited, + } + } + + pub fn is_hidden(&self) -> bool { + self.visibility() == VisibilityMode::Hidden + } + + pub fn has_component_kind(&self, kind: crate::components::ComponentKind) -> bool { + self.components + .iter() + .any(|component| component.kind() == Some(kind)) + } +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct SceneStats { + pub entities: usize, + pub roots: usize, + pub sprites: usize, + pub meshes: usize, + pub lights: usize, + pub cameras: usize, + pub custom_components: usize, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct SceneDoc { + pub format_version: u32, + pub name: String, + pub entities: IndexMap, + pub roots: Vec, + pub next_id: EntityId, +} + +impl Default for SceneDoc { + fn default() -> Self { + Self::new("Untitled") + } +} + +impl SceneDoc { + pub fn new(name: impl Into) -> Self { + Self { + format_version: FORMAT_VERSION, + name: name.into(), + entities: IndexMap::new(), + roots: Vec::new(), + next_id: 1, + } + } + + /// Add an empty entity (identity transform, no components) as a root or child. + pub fn add_entity(&mut self, name: impl Into, parent: Option) -> EntityId { + let id = self.allocate_entity_id(); + let node = EntityNode { + id, + name: name.into(), + parent, + children: Vec::new(), + transform: Transform::IDENTITY, + components: Vec::new(), + locked: false, + }; + self.insert_entity(node, parent, None) + } + + /// Insert a prepared node under `parent` at `index` (append when `None`). + /// + /// The node's `id` must be unique and its `children` are preserved as-is; the + /// caller is responsible for inserting descendants. `next_id` is advanced past + /// the inserted id. + pub fn insert_entity( + &mut self, + mut node: EntityNode, + parent: Option, + index: Option, + ) -> EntityId { + let id = if self.entities.contains_key(&node.id) { + // Hand-edited files and third-party callers may provide a duplicate + // id; never overwrite an existing entity. + self.allocate_entity_id() + } else { + node.id + }; + node.id = id; + // A missing parent is treated as a root insert instead of panicking; the + // editor normally supplies valid parents, but public callers should not + // be able to crash the process. + let parent = parent.filter(|parent_id| self.entities.contains_key(parent_id)); + node.parent = parent; + self.next_id = match id.checked_add(1) { + Some(next) => self.next_id.max(next), + None => self.next_id, + }; + let siblings = match parent { + Some(parent_id) => self + .entity_mut(parent_id) + .map(|parent_node| &mut parent_node.children) + .expect("insert_entity: parent must exist"), + None => &mut self.roots, + }; + let index = index.unwrap_or(siblings.len()).min(siblings.len()); + siblings.insert(index, id); + self.entities.insert(id, node); + id + } + + /// Remove an entity and all descendants. Returns removed ids depth-first. + pub fn remove_entity(&mut self, id: EntityId) -> Vec { + let mut removed = Vec::new(); + self.collect_subtree_ids(id, &mut removed); + if removed.is_empty() { + return removed; + } + let parent = self.entity(id).and_then(|node| node.parent); + match parent { + Some(parent_id) => { + if let Some(parent_node) = self.entity_mut(parent_id) { + parent_node.children.retain(|child| *child != id); + } + } + None => self.roots.retain(|root| *root != id), + } + for removed_id in &removed { + self.entities.shift_remove(removed_id); + } + removed + } + + /// Deep-copy an entity (and its subtree), inserting the copy right after the + /// original among its siblings. The copy's name is suffixed with `" Copy"`. + pub fn duplicate_entity(&mut self, id: EntityId) -> Option { + let original = self.entity(id)?.clone(); + let parent = original.parent; + let sibling_index = self + .siblings(parent) + .iter() + .position(|sibling| *sibling == id) + .unwrap_or(0); + let mut visited = HashSet::new(); + let new_id = self.clone_subtree(id, parent, sibling_index + 1, true, &mut visited)?; + Some(new_id) + } + + /// Move `id` under `new_parent` (or to the roots) at `index`. + /// + /// Fails when the entity would become its own ancestor, when either entity is + /// missing, or when `new_parent` is the entity itself. + pub fn reparent( + &mut self, + id: EntityId, + new_parent: Option, + index: Option, + ) -> Result<(), SceneError> { + if self.entity(id).is_none() { + return Err(SceneError::EntityNotFound(id)); + } + if new_parent == Some(id) { + return Err(SceneError::SelfParent(id)); + } + if let Some(parent_id) = new_parent { + if self.entity(parent_id).is_none() { + return Err(SceneError::ParentNotFound(parent_id)); + } + if self.is_ancestor(id, parent_id) { + return Err(SceneError::Cycle { + entity: id, + parent: parent_id, + }); + } + } + let old_parent = self.entity(id).and_then(|node| node.parent); + // Detach first so index calculations are relative to the destination list. + match old_parent { + Some(parent_id) => { + if let Some(parent_node) = self.entity_mut(parent_id) { + parent_node.children.retain(|child| *child != id); + } + } + None => self.roots.retain(|root| *root != id), + } + if let Some(node) = self.entity_mut(id) { + node.parent = new_parent; + } + let siblings = match new_parent { + Some(parent_id) => self + .entity_mut(parent_id) + .map(|parent_node| &mut parent_node.children) + .ok_or(SceneError::ParentNotFound(parent_id))?, + None => &mut self.roots, + }; + let index = index.unwrap_or(siblings.len()).min(siblings.len()); + siblings.insert(index, id); + Ok(()) + } + + pub fn entity(&self, id: EntityId) -> Option<&EntityNode> { + self.entities.get(&id) + } + + pub fn entity_mut(&mut self, id: EntityId) -> Option<&mut EntityNode> { + self.entities.get_mut(&id) + } + + pub fn parent(&self, id: EntityId) -> Option { + self.entity(id).and_then(|node| node.parent) + } + + /// Children of an entity, or the scene roots when `parent` is `None`. + pub fn siblings(&self, parent: Option) -> &[EntityId] { + match parent { + Some(parent_id) => self + .entity(parent_id) + .map(|node| node.children.as_slice()) + .unwrap_or(&[]), + None => &self.roots, + } + } + + /// World transform of the entity's parent (identity for roots). + pub fn parent_world_transform(&self, id: EntityId) -> Transform { + let mut chain = Vec::new(); + let mut seen = HashSet::new(); + let mut current = self.entity(id).and_then(|node| node.parent); + while let Some(parent_id) = current { + if !seen.insert(parent_id) { + break; + } + let Some(node) = self.entity(parent_id) else { + break; + }; + chain.push(node.transform); + current = node.parent; + } + let mut world = Transform::IDENTITY; + for transform in chain.into_iter().rev() { + world = Transform::compose(world, transform); + } + world + } + + /// World transform of an entity, or `None` when the id is unknown. + pub fn world_transform(&self, id: EntityId) -> Option { + let local = self.entity(id)?.transform; + Some(Transform::compose(self.parent_world_transform(id), local)) + } + + /// True when `ancestor` is an ancestor of (or equal to) `descendant`. + /// + /// Uses a visited set instead of a fixed depth cap, so arbitrarily deep valid + /// hierarchies are handled and `reparent` cannot create a cycle at depth 257. + pub fn is_ancestor(&self, ancestor: EntityId, descendant: EntityId) -> bool { + if ancestor == descendant { + return true; + } + let mut seen = HashSet::new(); + let mut current = self.parent(descendant); + while let Some(id) = current { + if !seen.insert(id) { + // Pre-existing cycle that does not contain `ancestor`. + return false; + } + if id == ancestor { + return true; + } + current = self.parent(id); + } + false + } + + /// Entity ids in depth-first display order. + pub fn depth_first_ids(&self) -> Vec { + let mut out = Vec::with_capacity(self.entities.len()); + for root in &self.roots { + self.collect_subtree_ids(*root, &mut out); + } + out + } + + pub fn find_by_name(&self, name: &str) -> Option { + self.entities + .values() + .find(|node| node.name == name) + .map(|node| node.id) + } + + /// World-space bounds of the scene's 2D content, used by "fit view". + /// + /// Sprites use their custom size or a 1x1 fallback; text uses an estimate based + /// on glyph count. Entities without 2D content are ignored. + pub fn bounds_2d(&self) -> Bounds2 { + let mut bounds = Bounds2::empty(); + for node in self.entities.values() { + let world = self.world_transform(node.id).unwrap_or(node.transform); + let size = match node.component("sprite") { + Some(ComponentData::Sprite(sprite)) => { + Some(sprite.custom_size.unwrap_or_else(|| Vec2::new(1.0, 1.0))) + } + _ => match node.component("text2d") { + Some(ComponentData::Text2d(text)) => { + let width = (text.text.chars().count() as f32 * text.font_size * 0.55) + .max(text.font_size); + Some(Vec2::new(width, text.font_size * 1.4)) + } + _ => None, + }, + }; + if let Some(size) = size { + let center = Vec2::new(world.translation.x, world.translation.y); + let scaled = Vec2::new(size.x * world.scale.x, size.y * world.scale.y); + bounds = bounds.union(Bounds2::from_center_size(center, scaled)); + } + } + if bounds.is_empty() { + Bounds2::from_center_size(Vec2::ZERO, Vec2::splat(10.0)) + } else { + bounds + } + } + + /// Add a component, refusing duplicates of the same component key. + pub fn add_component(&mut self, id: EntityId, component: ComponentData) -> bool { + let Some(node) = self.entity_mut(id) else { + return false; + }; + let key = component.key(); + if node.has_component(&key) { + return false; + } + node.components.push(component); + true + } + + /// Remove the component at `index`; returns it when it existed. + pub fn remove_component(&mut self, id: EntityId, index: usize) -> Option { + let node = self.entity_mut(id)?; + if index < node.components.len() { + Some(node.components.remove(index)) + } else { + None + } + } + + /// Replace the component with the same key, or append it when absent. + pub fn set_component(&mut self, id: EntityId, component: ComponentData) -> bool { + let Some(node) = self.entity_mut(id) else { + return false; + }; + let key = component.key(); + if let Some(existing) = node + .components + .iter_mut() + .find(|existing| existing.key() == key) + { + *existing = component; + } else { + node.components.push(component); + } + true + } + + pub fn rename(&mut self, id: EntityId, name: impl Into) -> bool { + match self.entity_mut(id) { + Some(node) => { + node.name = name.into(); + true + } + None => false, + } + } + + /// Toggle between hidden and visible (used by the hierarchy eye button). + pub fn toggle_hidden(&mut self, id: EntityId) -> bool { + let Some(node) = self.entity_mut(id) else { + return false; + }; + let hidden = matches!( + node.component("visibility"), + Some(ComponentData::Visibility(VisibilityMode::Hidden)) + ); + if hidden { + node.components + .retain(|component| component.key() != "visibility"); + } else { + node.components + .retain(|component| component.key() != "visibility"); + node.components + .push(ComponentData::Visibility(VisibilityMode::Hidden)); + } + true + } + + pub fn stats(&self) -> SceneStats { + let mut stats = SceneStats { + entities: self.entities.len(), + roots: self.roots.len(), + ..Default::default() + }; + for node in self.entities.values() { + for component in &node.components { + match component { + ComponentData::Sprite(_) => stats.sprites += 1, + ComponentData::Mesh3d(_) => stats.meshes += 1, + ComponentData::PointLight(_) | ComponentData::DirectionalLight(_) => { + stats.lights += 1 + } + ComponentData::Camera2d | ComponentData::Camera3d => stats.cameras += 1, + ComponentData::Custom(_) => stats.custom_components += 1, + _ => {} + } + } + } + stats + } + + /// Structural invariant check used by tests and by `forge validate`. + pub fn check_integrity(&self) -> Vec { + let mut problems = Vec::new(); + for (id, node) in &self.entities { + if node.id != *id { + problems.push(format!("entity {id}: node id mismatch ({})", node.id)); + } + if node.parent.is_some() && !self.entities.contains_key(&node.parent.unwrap()) { + problems.push(format!("entity {id}: parent does not exist")); + } + for child in &node.children { + let Some(child_node) = self.entities.get(child) else { + problems.push(format!("entity {id}: child {child} does not exist")); + continue; + }; + if child_node.parent != Some(*id) { + problems.push(format!("entity {id}: child {child} has wrong parent")); + } + } + } + for root in &self.roots { + match self.entities.get(root) { + None => problems.push(format!("root {root} does not exist")), + Some(node) if node.parent.is_some() => { + problems.push(format!("root {root} has a parent")) + } + _ => {} + } + } + let referenced: usize = self.entities.values().map(|n| n.children.len()).sum(); + if referenced + self.roots.len() != self.entities.len() { + problems.push("entity reference count does not match entity count".into()); + } + problems + } + + /// Allocate an unused id, repairing a corrupt `next_id` instead of reusing + /// or overwriting an existing entity. + fn allocate_entity_id(&mut self) -> EntityId { + let start = self.next_id.max(1); + let mut candidate = start; + loop { + if !self.entities.contains_key(&candidate) { + self.next_id = candidate.checked_add(1).unwrap_or(candidate); + return candidate; + } + candidate = match candidate.checked_add(1) { + Some(next) => next, + None => 1, + }; + if candidate == start { + panic!("scene has no free entity ids"); + } + } + } + + fn collect_subtree_ids(&self, id: EntityId, out: &mut Vec) { + let mut stack = vec![id]; + let mut visited = HashSet::new(); + while let Some(current) = stack.pop() { + if !visited.insert(current) { + continue; + } + if let Some(node) = self.entity(current) { + out.push(current); + for child in node.children.iter().rev() { + stack.push(*child); + } + } + } + } + + fn clone_subtree( + &mut self, + source_id: EntityId, + new_parent: Option, + index: usize, + rename: bool, + visited: &mut HashSet, + ) -> Option { + if !visited.insert(source_id) { + return None; + } + let source = self.entity(source_id)?.clone(); + let new_id = self.allocate_entity_id(); + let mut copy = source.clone(); + copy.id = new_id; + copy.parent = new_parent; + copy.children.clear(); + if rename { + copy.name = format!("{} Copy", source.name); + } + self.insert_entity(copy, new_parent, Some(index)); + for child in &source.children { + // `insert_entity` links each copy into its parent's `children` list. + self.clone_subtree(*child, Some(new_id), usize::MAX, false, visited)?; + } + Some(new_id) + } +} diff --git a/crates/bevy_forge_core/src/validation.rs b/crates/bevy_forge_core/src/validation.rs new file mode 100644 index 0000000..bf10ef9 --- /dev/null +++ b/crates/bevy_forge_core/src/validation.rs @@ -0,0 +1,1549 @@ +//! Scene validation used by the inspector, `forge validate`, and the editor's +//! problems panel. +//! +//! Validation is intentionally tolerant: a corrupt document must produce issues +//! instead of panicking, and the report is sorted so that output is deterministic +//! across runs (important for `forge validate` and snapshot-style tests). + +use crate::assets::{AssetIndex, AssetKind}; +use crate::components::{ + is_rust_ident, is_rust_path, ComponentData, CustomComponent, DirectionalLightData, + MaterialData, Mesh3dData, MeshSource, PointLightData, SpriteData, Text2dData, +}; +use crate::math::{Color, Transform, Vec3}; +use crate::scene::{EntityId, SceneDoc}; +use std::collections::{HashMap, HashSet}; +use std::path::Path; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] +pub enum Severity { + Info, + Warning, + Error, +} + +impl Severity { + pub const fn label(self) -> &'static str { + match self { + Self::Info => "info", + Self::Warning => "warning", + Self::Error => "error", + } + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Issue { + pub severity: Severity, + pub entity: Option, + /// Field or component path, e.g. `sprite.image`. + pub field: Option, + pub message: String, +} + +impl Issue { + pub fn error(message: impl Into) -> Self { + Self { + severity: Severity::Error, + entity: None, + field: None, + message: message.into(), + } + } + + pub fn warning(message: impl Into) -> Self { + Self { + severity: Severity::Warning, + entity: None, + field: None, + message: message.into(), + } + } + + pub fn info(message: impl Into) -> Self { + Self { + severity: Severity::Info, + entity: None, + field: None, + message: message.into(), + } + } + + pub fn with_entity(mut self, entity: EntityId) -> Self { + self.entity = Some(entity); + self + } + + pub fn with_field(mut self, field: impl Into) -> Self { + self.field = Some(field.into()); + self + } +} + +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct ValidationReport { + pub issues: Vec, +} + +impl ValidationReport { + pub fn is_ok(&self) -> bool { + !self + .issues + .iter() + .any(|issue| issue.severity == Severity::Error) + } + + pub fn errors(&self) -> usize { + self.count(Severity::Error) + } + + pub fn warnings(&self) -> usize { + self.count(Severity::Warning) + } + + pub fn count(&self, severity: Severity) -> usize { + self.issues + .iter() + .filter(|issue| issue.severity == severity) + .count() + } + + pub fn by_entity(&self, entity: EntityId) -> Vec<&Issue> { + self.issues + .iter() + .filter(|issue| issue.entity == Some(entity)) + .collect() + } + + pub fn sorted(&self) -> Vec<&Issue> { + let mut issues: Vec<&Issue> = self.issues.iter().collect(); + issues.sort_by_key(|issue| std::cmp::Reverse(issue.severity)); + issues + } +} + +/// Validate a scene. +/// +/// Without an asset index only structural/data checks run. With one, missing image +/// and mesh files are reported too. +pub fn validate_scene(doc: &SceneDoc, assets: Option<&AssetIndex>) -> ValidationReport { + let mut issues = Vec::new(); + + check_structure(doc, &mut issues); + check_duplicate_names(doc, &mut issues); + + let mut counts = SceneCounts::default(); + for (&id, node) in &doc.entities { + check_name(id, &node.name, &mut issues); + check_transform(id, &node.transform, &mut issues); + + if node.components.is_empty() { + issues.push( + Issue::info("entity has no components besides its Transform").with_entity(id), + ); + } + + let mut component_keys = HashSet::new(); + for component in &node.components { + if !component_keys.insert(component.key()) { + issues.push( + Issue::error(format!( + "entity {id} has more than one {} component", + component.label() + )) + .with_entity(id) + .with_field("components"), + ); + } + match component { + ComponentData::Sprite(sprite) => check_sprite(id, sprite, assets, &mut issues), + ComponentData::Text2d(text) => check_text(id, text, &mut issues), + ComponentData::Camera2d => counts.cameras_2d += 1, + ComponentData::Camera3d => counts.cameras_3d += 1, + ComponentData::PointLight(light) => { + counts.lights += 1; + check_point_light(id, light, &mut issues); + } + ComponentData::DirectionalLight(light) => { + counts.lights += 1; + check_directional_light(id, light, &mut issues); + } + ComponentData::Mesh3d(mesh) => { + counts.meshes += 1; + check_mesh(id, mesh, assets, &mut issues); + } + ComponentData::Visibility(_) => {} + ComponentData::Custom(custom) => check_custom_component(id, custom, &mut issues), + } + } + } + + let cameras = counts.cameras_2d + counts.cameras_3d; + match cameras { + 0 => issues.push(Issue::info("scene has no camera")), + 1 => {} + count => issues.push(Issue::info(format!( + "scene has {count} cameras; Bevy typically renders one per window" + ))), + } + if counts.cameras_2d > 0 && counts.cameras_3d > 0 { + issues.push(Issue::warning( + "scene mixes 2D and 3D cameras; a single camera mode is typical", + )); + } + if counts.meshes > 0 && counts.lights == 0 { + issues.push(Issue::info( + "scene contains 3D meshes but no light; they will render unlit or black", + )); + } + + sort_issues(&mut issues); + ValidationReport { issues } +} + +#[derive(Clone, Copy, Debug, Default)] +struct SceneCounts { + cameras_2d: usize, + cameras_3d: usize, + lights: usize, + meshes: usize, +} + +/// Sort issues by severity (most severe first), then entity id (scene-wide +/// issues with `entity: None` first, then ascending ids), then message, then +/// field. The last two keys make the order total even when messages collide. +fn sort_issues(issues: &mut [Issue]) { + issues.sort_by(|a, b| { + b.severity + .cmp(&a.severity) + .then_with(|| a.entity.cmp(&b.entity)) + .then_with(|| a.message.cmp(&b.message)) + .then_with(|| a.field.cmp(&b.field)) + }); +} + +// --------------------------------------------------------------------------- +// Structural invariants +// --------------------------------------------------------------------------- + +fn check_structure(doc: &SceneDoc, issues: &mut Vec) { + for problem in doc.check_integrity() { + issues.push(Issue::error(problem)); + } + + let max_id = doc.entities.keys().max().copied().unwrap_or(0); + if doc.next_id <= max_id || doc.next_id == u64::MAX { + issues.push( + Issue::error(format!( + "next_id {} is not greater than every entity id (max {max_id}) or cannot be incremented", + doc.next_id + )) + .with_field("next_id"), + ); + } + + // `check_integrity` checks every edge individually but cannot see duplicate + // edges or unreachable nodes, so count how often each node is referenced. + let mut references: HashMap = HashMap::new(); + for root in &doc.roots { + *references.entry(*root).or_insert(0) += 1; + } + for node in doc.entities.values() { + for child in &node.children { + *references.entry(*child).or_insert(0) += 1; + } + } + + let mut ids: Vec = doc.entities.keys().copied().collect(); + ids.sort_unstable(); + for id in ids { + match references.get(&id).copied().unwrap_or(0) { + 1 => {} + 0 => issues.push( + Issue::error(format!("entity {id} is not reachable from the scene roots")) + .with_entity(id) + .with_field("parent"), + ), + count => issues.push( + Issue::error(format!( + "entity {id} is referenced {count} times in the hierarchy" + )) + .with_entity(id) + .with_field("parent"), + ), + } + } + + for &id in doc.entities.keys() { + let self_parent = doc.entity(id).and_then(|node| node.parent) == Some(id); + if self_parent { + issues.push( + Issue::error(format!("entity {id} is its own parent")) + .with_entity(id) + .with_field("parent"), + ); + } else if parent_chain_has_cycle(doc, id) { + issues.push( + Issue::error(format!( + "entity {id} is part of a hierarchy cycle (its parent chain never reaches a root)" + )) + .with_entity(id) + .with_field("parent"), + ); + } + } +} + +/// Walk the parent chain with a visited set. Returns `true` when an id is +/// reached twice. There is no fixed depth cap: a long valid chain is legal, and +/// a long cycle must still be reported. +fn parent_chain_has_cycle(doc: &SceneDoc, start: EntityId) -> bool { + let mut seen = HashSet::new(); + let mut current = Some(start); + while let Some(id) = current { + if !seen.insert(id) { + return true; + } + current = doc.entity(id).and_then(|node| node.parent); + } + false +} + +fn check_duplicate_names(doc: &SceneDoc, issues: &mut Vec) { + let mut groups: HashMap<&str, Vec> = HashMap::new(); + for (&id, node) in &doc.entities { + groups.entry(node.name.as_str()).or_default().push(id); + } + for (name, ids) in groups { + if ids.len() < 2 { + continue; + } + let count = ids.len(); + for id in ids { + issues.push( + Issue::warning(format!( + "entity name {name:?} is not unique ({count} entities share it)" + )) + .with_entity(id) + .with_field("name"), + ); + } + } +} + +// --------------------------------------------------------------------------- +// Entity fields +// --------------------------------------------------------------------------- + +fn check_name(entity: EntityId, name: &str, issues: &mut Vec) { + if name.trim().is_empty() { + let message = if name.is_empty() { + "entity name is empty" + } else { + "entity name is only whitespace" + }; + issues.push( + Issue::warning(message) + .with_entity(entity) + .with_field("name"), + ); + } +} + +fn check_transform(entity: EntityId, transform: &Transform, issues: &mut Vec) { + for (field, value) in [ + ("transform.translation", transform.translation), + ("transform.rotation", transform.rotation), + ("transform.scale", transform.scale), + ] { + if !value.is_finite() { + issues.push( + Issue::error(format!("{field} contains a non-finite value")) + .with_entity(entity) + .with_field(field), + ); + } + } + + if transform.scale.is_finite() && has_zero_axis(transform.scale) { + issues.push( + Issue::warning("transform scale has a zero axis; the entity renders flat") + .with_entity(entity) + .with_field("transform.scale"), + ); + } +} + +fn has_zero_axis(value: Vec3) -> bool { + value.x == 0.0 || value.y == 0.0 || value.z == 0.0 +} + +// --------------------------------------------------------------------------- +// Built-in components +// --------------------------------------------------------------------------- + +fn check_sprite( + entity: EntityId, + sprite: &SpriteData, + assets: Option<&AssetIndex>, + issues: &mut Vec, +) { + if let Some(size) = sprite.custom_size { + if !(size.x.is_finite() && size.x > 0.0 && size.y.is_finite() && size.y > 0.0) { + issues.push( + Issue::error("sprite custom_size must be positive and finite") + .with_entity(entity) + .with_field("sprite.custom_size"), + ); + } + } + + match &sprite.image { + Some(path) => check_asset_reference( + entity, + path, + AssetKind::Image, + "sprite.image", + "sprite image", + assets, + issues, + ), + None if sprite.custom_size.is_none() => issues.push( + Issue::warning("sprite has no image and no custom_size; it renders nothing") + .with_entity(entity) + .with_field("sprite"), + ), + None => {} + } +} + +fn check_text(entity: EntityId, text: &Text2dData, issues: &mut Vec) { + if !(text.font_size.is_finite() && text.font_size > 0.0) { + issues.push( + Issue::error("text2d font_size must be positive and finite") + .with_entity(entity) + .with_field("text2d.font_size"), + ); + } + if text.text.trim().is_empty() { + issues.push( + Issue::warning("text2d text is empty") + .with_entity(entity) + .with_field("text2d.text"), + ); + } +} + +fn check_point_light(entity: EntityId, light: &PointLightData, issues: &mut Vec) { + check_color(entity, "point_light.color", &light.color, issues); + check_float( + entity, + "point_light.intensity", + light.intensity, + FloatRule::NonNegative, + issues, + ); + check_float( + entity, + "point_light.range", + light.range, + FloatRule::Positive, + issues, + ); +} + +fn check_directional_light( + entity: EntityId, + light: &DirectionalLightData, + issues: &mut Vec, +) { + check_color(entity, "directional_light.color", &light.color, issues); + check_float( + entity, + "directional_light.illuminance", + light.illuminance, + FloatRule::NonNegative, + issues, + ); +} + +fn check_mesh( + entity: EntityId, + mesh: &Mesh3dData, + assets: Option<&AssetIndex>, + issues: &mut Vec, +) { + if let MeshSource::Asset(path) = &mesh.mesh { + check_asset_reference( + entity, + path, + AssetKind::Mesh, + "mesh3d.mesh", + "mesh", + assets, + issues, + ); + } + check_material(entity, &mesh.material, issues); +} + +fn check_material(entity: EntityId, material: &MaterialData, issues: &mut Vec) { + check_color( + entity, + "mesh3d.material.base_color", + &material.base_color, + issues, + ); + check_unit_interval( + entity, + "mesh3d.material.metallic", + material.metallic, + issues, + ); + check_unit_interval( + entity, + "mesh3d.material.perceptual_roughness", + material.perceptual_roughness, + issues, + ); +} + +fn check_custom_component(entity: EntityId, custom: &CustomComponent, issues: &mut Vec) { + let type_ok = is_rust_path(&custom.type_path); + if !type_ok { + issues.push( + Issue::error(format!( + "custom component type path {:?} is not a valid Rust type path", + custom.type_path + )) + .with_entity(entity) + .with_field("custom.type_path"), + ); + } + // `custom.Health.current` is the documented field shape; fall back to a stable + // pseudo-field when the type path itself is invalid or a field has no name. + let type_label = if type_ok { + custom.short_name() + } else { + "component" + }; + + for (index, field) in custom.fields.iter().enumerate() { + let name_label = if field.name.trim().is_empty() { + format!("fields[{index}]") + } else { + field.name.clone() + }; + let field_path = format!("custom.{type_label}.{name_label}"); + + if !is_rust_ident(&field.name) { + issues.push( + Issue::error(format!( + "custom component field {:?} is not a valid Rust identifier", + field.name + )) + .with_entity(entity) + .with_field(field_path.as_str()), + ); + } + if field.expr.trim().is_empty() { + issues.push( + Issue::error(format!( + "custom component field {:?} has an empty expression", + field.name + )) + .with_entity(entity) + .with_field(field_path.as_str()), + ); + } + } +} + +// --------------------------------------------------------------------------- +// Numeric rules +// --------------------------------------------------------------------------- + +#[derive(Clone, Copy, Debug)] +enum FloatRule { + NonNegative, + Positive, +} + +fn check_float( + entity: EntityId, + field: &'static str, + value: f32, + rule: FloatRule, + issues: &mut Vec, +) { + if !value.is_finite() { + issues.push( + Issue::error(format!("{field} is not a finite number")) + .with_entity(entity) + .with_field(field), + ); + return; + } + let valid = match rule { + FloatRule::NonNegative => value >= 0.0, + FloatRule::Positive => value > 0.0, + }; + if !valid { + let message = match rule { + FloatRule::NonNegative => format!("{field} must not be negative"), + FloatRule::Positive => format!("{field} must be greater than zero"), + }; + issues.push(Issue::error(message).with_entity(entity).with_field(field)); + } +} + +fn check_color(entity: EntityId, field: &'static str, color: &Color, issues: &mut Vec) { + if !color.is_finite() { + issues.push( + Issue::error(format!("{field} contains a non-finite color channel")) + .with_entity(entity) + .with_field(field), + ); + } +} + +fn check_unit_interval(entity: EntityId, field: &'static str, value: f32, issues: &mut Vec) { + if !value.is_finite() { + issues.push( + Issue::error(format!("{field} is not a finite number")) + .with_entity(entity) + .with_field(field), + ); + } else if !(0.0..=1.0).contains(&value) { + issues.push( + Issue::warning(format!("{field} should be within 0.0..=1.0 (got {value})")) + .with_entity(entity) + .with_field(field), + ); + } +} + +// --------------------------------------------------------------------------- +// Asset references +// --------------------------------------------------------------------------- + +fn check_asset_reference( + entity: EntityId, + path: &str, + expected: AssetKind, + field: &'static str, + label: &'static str, + assets: Option<&AssetIndex>, + issues: &mut Vec, +) { + if let Some(problem) = asset_path_problem(path) { + issues.push( + Issue::error(format!("{label} reference {path:?} is invalid: {problem}")) + .with_entity(entity) + .with_field(field), + ); + return; + } + + // glTF subasset references look like `models/robot.gltf#Mesh0/Primitive0`; + // validate the base file and its extension, not the fragment. + let lookup_path = path.split('#').next().unwrap_or(path); + + if let Some(index) = assets { + // An index that failed to scan cannot prove a file is missing, so only + // fall back to extension-based kind checks in that case. + if index.error.is_none() { + match index.find(path).or_else(|| index.find(lookup_path)) { + None => { + issues.push( + Issue::error(format!("{label} not found in the project assets: {path:?}")) + .with_entity(entity) + .with_field(field), + ); + return; + } + Some(entry) => { + report_kind_mismatch( + entity, + path, + expected, + AssetKind::from_path(Path::new(&entry.path)), + field, + label, + issues, + ); + return; + } + } + } + } + + report_kind_mismatch( + entity, + path, + expected, + AssetKind::from_path(Path::new(lookup_path)), + field, + label, + issues, + ); +} + +fn report_kind_mismatch( + entity: EntityId, + path: &str, + expected: AssetKind, + actual: AssetKind, + field: &'static str, + label: &'static str, + issues: &mut Vec, +) { + if actual == expected { + return; + } + issues.push( + Issue::error(format!( + "{label} {path:?} is not {} (detected {})", + expected_asset_name(expected), + describe_asset_kind(actual) + )) + .with_entity(entity) + .with_field(field), + ); +} + +/// First structural problem of an asset path, in report priority order. +fn asset_path_problem(path: &str) -> Option<&'static str> { + if path.trim().is_empty() { + return Some("the path is empty"); + } + if is_absolute_asset_path(path) { + return Some("absolute paths are not allowed"); + } + if path.split(['/', '\\']).any(|component| component == "..") { + return Some("`..` path components are not allowed"); + } + if path.contains('\\') { + return Some("backslashes are not allowed (use `/`)"); + } + None +} + +fn is_absolute_asset_path(path: &str) -> bool { + let bytes = path.as_bytes(); + path.starts_with('/') + || path.starts_with('\\') + || (bytes.len() >= 2 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':') +} + +fn expected_asset_name(kind: AssetKind) -> &'static str { + match kind { + AssetKind::Image => "an image asset", + AssetKind::Mesh => "a mesh asset", + AssetKind::Audio => "an audio asset", + AssetKind::Font => "a font asset", + AssetKind::Scene => "a scene asset", + AssetKind::Shader => "a shader asset", + AssetKind::Material => "a material asset", + AssetKind::Other => "a known asset", + } +} + +fn describe_asset_kind(kind: AssetKind) -> &'static str { + match kind { + AssetKind::Image => "an image", + AssetKind::Mesh => "a mesh", + AssetKind::Audio => "audio", + AssetKind::Font => "a font", + AssetKind::Scene => "a scene", + AssetKind::Shader => "a shader", + AssetKind::Material => "a material", + AssetKind::Other => "an unknown file type", + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::assets::AssetEntry; + use crate::components::{ + CustomComponent, CustomField, DirectionalLightData, MaterialData, Mesh3dData, MeshSource, + PointLightData, PrimitiveShape, SpriteData, Text2dData, VisibilityMode, + }; + use crate::math::{Vec2, Vec3}; + use crate::scene::EntityNode; + use std::path::{Path, PathBuf}; + + // -- fixtures ---------------------------------------------------------- + + fn index(paths: &[&str]) -> AssetIndex { + AssetIndex { + entries: paths + .iter() + .map(|path| AssetEntry { + path: (*path).to_string(), + abs_path: PathBuf::from("/tmp/assets").join(path), + kind: AssetKind::from_path(Path::new(path)), + size_bytes: 4, + }) + .collect(), + scanned_dir: PathBuf::from("/tmp/assets"), + error: None, + } + } + + fn doc_with(name: &str, components: Vec) -> (SceneDoc, EntityId) { + let mut doc = SceneDoc::new("Validation Test"); + let id = doc.add_entity(name, None); + for component in components { + assert!(doc.add_component(id, component), "duplicate test component"); + } + (doc, id) + } + + fn sprite_component(image: Option<&str>, custom_size: Option) -> ComponentData { + ComponentData::Sprite(SpriteData { + image: image.map(str::to_string), + custom_size, + ..SpriteData::default() + }) + } + + fn text_component(text: &str, font_size: f32) -> ComponentData { + ComponentData::Text2d(Text2dData { + text: text.to_string(), + font_size, + ..Text2dData::default() + }) + } + + fn point_light_component(intensity: f32, range: f32) -> ComponentData { + ComponentData::PointLight(PointLightData { + intensity, + range, + ..PointLightData::default() + }) + } + + fn directional_light_component(illuminance: f32) -> ComponentData { + ComponentData::DirectionalLight(DirectionalLightData { + illuminance, + ..DirectionalLightData::default() + }) + } + + fn mesh_component(mesh: MeshSource, metallic: f32, roughness: f32) -> ComponentData { + ComponentData::Mesh3d(Mesh3dData { + mesh, + material: MaterialData { + metallic, + perceptual_roughness: roughness, + ..MaterialData::default() + }, + }) + } + + fn primitive() -> MeshSource { + MeshSource::Primitive(PrimitiveShape::Cuboid { size: Vec3::ONE }) + } + + fn custom_component(type_path: &str, fields: &[(&str, &str)]) -> ComponentData { + ComponentData::Custom(CustomComponent { + type_path: type_path.to_string(), + fields: fields + .iter() + .map(|(name, expr)| CustomField { + name: (*name).to_string(), + expr: (*expr).to_string(), + }) + .collect(), + }) + } + + fn node( + id: EntityId, + name: &str, + parent: Option, + children: Vec, + ) -> EntityNode { + EntityNode { + id, + name: name.to_string(), + parent, + children, + transform: crate::math::Transform::IDENTITY, + components: Vec::new(), + locked: false, + } + } + + // -- assertion helpers ------------------------------------------------- + + fn find_issue<'a>( + report: &'a ValidationReport, + severity: Severity, + entity: Option, + field: &str, + ) -> Option<&'a Issue> { + report.issues.iter().find(|issue| { + issue.severity == severity + && issue.entity == entity + && issue.field.as_deref() == Some(field) + }) + } + + fn assert_error(report: &ValidationReport, entity: Option, field: &str) { + assert!( + find_issue(report, Severity::Error, entity, field).is_some(), + "expected error for entity {entity:?} field {field:?}, got {:#?}", + report.issues + ); + } + + fn assert_warning(report: &ValidationReport, entity: Option, field: &str) { + assert!( + find_issue(report, Severity::Warning, entity, field).is_some(), + "expected warning for entity {entity:?} field {field:?}, got {:#?}", + report.issues + ); + } + + // -- tests ------------------------------------------------------------- + + #[test] + fn empty_scene_has_no_errors_and_reports_missing_camera() { + let doc = SceneDoc::new("Empty"); + let report = validate_scene(&doc, None); + assert!(report.is_ok()); + assert_eq!(report.errors(), 0); + assert_eq!(report.warnings(), 0); + assert_eq!(report.count(Severity::Info), 1); + let issue = &report.issues[0]; + assert_eq!(issue.entity, None); + assert!(issue.message.contains("no camera"), "{issue:?}"); + assert!(ValidationReport::default().is_ok()); + } + + #[test] + fn valid_scene_with_camera_and_known_assets_has_no_issues() { + let assets = index(&["textures/hero.png"]); + let (mut doc, _sprite) = doc_with( + "Hero", + vec![sprite_component( + Some("textures/hero.png"), + Some(Vec2::new(32.0, 32.0)), + )], + ); + let camera = doc.add_entity("Camera", None); + assert!(doc.add_component(camera, ComponentData::Camera2d)); + let light = doc.add_entity("Light", None); + assert!(doc.add_component(light, ComponentData::PointLight(PointLightData::default()))); + + let report = validate_scene(&doc, Some(&assets)); + assert!(report.is_ok(), "{:#?}", report.issues); + assert!(report.issues.is_empty(), "{:#?}", report.issues); + } + + #[test] + fn missing_sprite_and_mesh_assets_are_errors_with_entity_and_field() { + let empty_assets = index(&[]); + let (mut doc, sprite_id) = doc_with( + "Hero", + vec![sprite_component( + Some("textures/missing.png"), + Some(Vec2::ONE), + )], + ); + let mesh_id = doc.add_entity("Rock", None); + assert!(doc.add_component( + mesh_id, + mesh_component(MeshSource::Asset("models/rock.gltf".into()), 0.0, 0.5) + )); + + let report = validate_scene(&doc, Some(&empty_assets)); + assert_eq!(report.errors(), 2, "{:#?}", report.issues); + assert_error(&report, Some(sprite_id), "sprite.image"); + assert_error(&report, Some(mesh_id), "mesh3d.mesh"); + assert!(report + .issues + .iter() + .any(|issue| issue.message.contains("not found"))); + + // Without an index, missing files are never reported. + let without_index = validate_scene(&doc, None); + assert!(without_index.is_ok(), "{:#?}", without_index.issues); + assert_eq!(without_index.errors(), 0); + } + + #[test] + fn asset_kind_mismatch_is_an_error() { + let assets = index(&["sounds/boom.ogg", "textures/tile.png"]); + let (mut doc, sprite_id) = doc_with( + "Boom", + vec![sprite_component(Some("sounds/boom.ogg"), Some(Vec2::ONE))], + ); + let mesh_id = doc.add_entity("TileMesh", None); + assert!(doc.add_component( + mesh_id, + mesh_component(MeshSource::Asset("textures/tile.png".into()), 0.0, 0.5) + )); + + let report = validate_scene(&doc, Some(&assets)); + assert_error(&report, Some(sprite_id), "sprite.image"); + assert_error(&report, Some(mesh_id), "mesh3d.mesh"); + assert!(report + .issues + .iter() + .any(|issue| issue.message.contains("not an image asset"))); + assert!(report + .issues + .iter() + .any(|issue| issue.message.contains("not a mesh asset"))); + + // Extension classification also catches the mismatch without an index. + let without_index = validate_scene(&doc, None); + assert_error(&without_index, Some(sprite_id), "sprite.image"); + assert_error(&without_index, Some(mesh_id), "mesh3d.mesh"); + } + + #[test] + fn invalid_asset_paths_are_errors() { + let paths = [ + "", + " ", + "../secret.png", + "a/../../b.png", + "/etc/passwd", + "C:\\textures\\hero.png", + "textures\\hero.png", + ]; + for path in paths { + let (doc, id) = doc_with("Hero", vec![sprite_component(Some(path), Some(Vec2::ONE))]); + let report = validate_scene(&doc, None); + assert_error(&report, Some(id), "sprite.image"); + assert!( + report + .issues + .iter() + .any(|issue| issue.message.contains("invalid")), + "path {path:?} was not reported: {:#?}", + report.issues + ); + } + + let (doc, id) = doc_with( + "Rock", + vec![mesh_component( + MeshSource::Asset("/absolute/rock.gltf".into()), + 0.0, + 0.5, + )], + ); + assert_error(&validate_scene(&doc, None), Some(id), "mesh3d.mesh"); + } + + #[test] + fn parent_directory_substrings_are_not_path_traversal() { + let assets = index(&["textures/..hidden.png", "textures/a..b.png"]); + for path in ["textures/..hidden.png", "textures/a..b.png"] { + let (doc, _id) = doc_with("Hero", vec![sprite_component(Some(path), Some(Vec2::ONE))]); + let report = validate_scene(&doc, Some(&assets)); + assert!( + report.is_ok(), + "unexpected issues for {path:?}: {:#?}", + report.issues + ); + } + } + + #[test] + fn non_finite_transform_light_and_material_values_are_errors() { + let (mut doc, id) = doc_with("Bad", vec![]); + { + let transform = &mut doc.entity_mut(id).expect("fixture entity").transform; + transform.translation.x = f32::NAN; + transform.rotation.y = f32::INFINITY; + transform.scale.z = f32::NEG_INFINITY; + } + assert!(doc.add_component(id, point_light_component(f32::NAN, f32::INFINITY))); + + let mesh_id = doc.add_entity("Mesh", None); + let mut mesh = mesh_component(primitive(), f32::INFINITY, f32::NAN); + if let ComponentData::Mesh3d(data) = &mut mesh { + data.material.base_color.r = f32::NAN; + } + assert!(doc.add_component(mesh_id, mesh)); + + let report = validate_scene(&doc, None); + for field in [ + "transform.translation", + "transform.rotation", + "transform.scale", + "point_light.intensity", + "point_light.range", + ] { + assert_error(&report, Some(id), field); + } + for field in [ + "mesh3d.material.base_color", + "mesh3d.material.metallic", + "mesh3d.material.perceptual_roughness", + ] { + assert_error(&report, Some(mesh_id), field); + } + } + + #[test] + fn range_rules_are_errors() { + let (mut doc, sprite_id) = doc_with( + "Hero", + vec![sprite_component(None, Some(Vec2::new(0.0, 4.0)))], + ); + let text_id = doc.add_entity("Label", None); + assert!(doc.add_component(text_id, text_component("Hi", 0.0))); + let light_id = doc.add_entity("Light", None); + assert!(doc.add_component(light_id, point_light_component(-5.0, 0.0))); + let sun_id = doc.add_entity("Sun", None); + assert!(doc.add_component(sun_id, directional_light_component(-1.0))); + + let report = validate_scene(&doc, None); + assert_error(&report, Some(sprite_id), "sprite.custom_size"); + assert_error(&report, Some(text_id), "text2d.font_size"); + assert_error(&report, Some(light_id), "point_light.intensity"); + assert_error(&report, Some(light_id), "point_light.range"); + assert_error(&report, Some(sun_id), "directional_light.illuminance"); + + // Zero intensity/illuminance and a one-pixel range are legal. + let (mut doc, id) = doc_with("Light", vec![point_light_component(0.0, 1.0)]); + assert!(doc.add_component(id, directional_light_component(0.0))); + let report = validate_scene(&doc, None); + assert_eq!(report.errors(), 0, "{:#?}", report.issues); + } + + #[test] + fn zero_scale_warns_but_negative_scale_does_not() { + let (mut doc, id) = doc_with("Hero", vec![sprite_component(None, Some(Vec2::ONE))]); + doc.entity_mut(id).expect("fixture entity").transform.scale = Vec3::new(1.0, 0.0, 1.0); + let report = validate_scene(&doc, None); + assert_warning(&report, Some(id), "transform.scale"); + assert_eq!(report.count(Severity::Warning), 1); + assert!(report.is_ok()); + + doc.entity_mut(id).expect("fixture entity").transform.scale = Vec3::new(-1.0, -1.0, -1.0); + let report = validate_scene(&doc, None); + assert_eq!(report.count(Severity::Warning), 0); + } + + #[test] + fn material_outside_unit_range_warns() { + let (doc, id) = doc_with("Material", vec![mesh_component(primitive(), 1.5, -0.2)]); + let report = validate_scene(&doc, None); + assert_warning(&report, Some(id), "mesh3d.material.metallic"); + assert_warning(&report, Some(id), "mesh3d.material.perceptual_roughness"); + assert_eq!(report.count(Severity::Warning), 2); + assert!(report.is_ok()); + + let (doc, _id) = doc_with("Material", vec![mesh_component(primitive(), 1.0, 0.0)]); + let report = validate_scene(&doc, None); + assert_eq!(report.count(Severity::Warning), 0); + } + + #[test] + fn sprite_without_image_or_size_warns() { + let (doc, id) = doc_with("Bare", vec![sprite_component(None, None)]); + let report = validate_scene(&doc, None); + assert_warning(&report, Some(id), "sprite"); + + let (doc, _id) = doc_with("Sized", vec![sprite_component(None, Some(Vec2::ONE))]); + let report = validate_scene(&doc, None); + assert_eq!(report.count(Severity::Warning), 0); + + let assets = index(&["textures/hero.png"]); + let (doc, _id) = doc_with( + "Textured", + vec![sprite_component(Some("textures/hero.png"), None)], + ); + let report = validate_scene(&doc, Some(&assets)); + assert_eq!(report.count(Severity::Warning), 0); + } + + #[test] + fn empty_text_warns_and_font_size_must_be_positive() { + let (doc, id) = doc_with("Label", vec![text_component("", 32.0)]); + assert_warning(&validate_scene(&doc, None), Some(id), "text2d.text"); + + let (doc, id) = doc_with("Label", vec![text_component(" ", 32.0)]); + assert_warning(&validate_scene(&doc, None), Some(id), "text2d.text"); + + let (doc, id) = doc_with("Label", vec![text_component("Hello", 0.0)]); + let report = validate_scene(&doc, None); + assert_error(&report, Some(id), "text2d.font_size"); + assert!(!report + .issues + .iter() + .any(|issue| issue.field.as_deref() == Some("text2d.text"))); + } + + #[test] + fn duplicate_entity_names_warn_on_every_participant() { + let mut doc = SceneDoc::new("Duplicates"); + let a = doc.add_entity("Hero", None); + let b = doc.add_entity("Hero", None); + let c = doc.add_entity("Crate", None); + let report = validate_scene(&doc, None); + + let hero_warnings: Vec<&Issue> = report + .issues + .iter() + .filter(|issue| { + issue.severity == Severity::Warning + && issue.field.as_deref() == Some("name") + && issue.message.contains("Hero") + }) + .collect(); + assert_eq!(hero_warnings.len(), 2, "{:#?}", report.issues); + assert!(hero_warnings + .iter() + .all(|issue| issue.message.contains("not unique"))); + assert!(report + .by_entity(a) + .iter() + .any(|issue| issue.severity == Severity::Warning)); + assert!(report + .by_entity(b) + .iter() + .any(|issue| issue.severity == Severity::Warning)); + assert!(!report + .by_entity(c) + .iter() + .any(|issue| issue.field.as_deref() == Some("name"))); + } + + #[test] + fn empty_or_whitespace_names_warn() { + for name in ["", " "] { + let (doc, id) = doc_with(name, vec![sprite_component(None, Some(Vec2::ONE))]); + let report = validate_scene(&doc, None); + assert_warning(&report, Some(id), "name"); + } + + let (doc, _id) = doc_with("Hero", vec![sprite_component(None, Some(Vec2::ONE))]); + assert_eq!(validate_scene(&doc, None).count(Severity::Warning), 0); + } + + #[test] + fn camera_rules_produce_infos_and_camera_mix_warning() { + let mut doc = SceneDoc::new("Cameras"); + let a = doc.add_entity("A", None); + assert!(doc.add_component(a, ComponentData::Camera2d)); + let report = validate_scene(&doc, None); + assert_eq!(report.count(Severity::Info), 0); + assert!(report.is_ok()); + + let b = doc.add_entity("B", None); + assert!(doc.add_component(b, ComponentData::Camera3d)); + let report = validate_scene(&doc, None); + assert!(report.issues.iter().any(|issue| { + issue.severity == Severity::Warning + && issue.entity.is_none() + && issue.message.contains("2D and 3D") + })); + assert!(report.issues.iter().any(|issue| { + issue.severity == Severity::Info + && issue.entity.is_none() + && issue.message.contains("2 cameras") + })); + + let (doc, _id) = doc_with("Crate", vec![sprite_component(None, Some(Vec2::ONE))]); + let report = validate_scene(&doc, None); + assert!(report.issues.iter().any(|issue| { + issue.severity == Severity::Info + && issue.entity.is_none() + && issue.message.contains("no camera") + })); + } + + #[test] + fn more_than_one_camera_info_without_mixing_types() { + let (mut doc, a) = doc_with("A", vec![ComponentData::Camera2d]); + let b = doc.add_entity("B", None); + assert!(doc.add_component(b, ComponentData::Camera2d)); + let report = validate_scene(&doc, None); + assert!(report.issues.iter().any(|issue| { + issue.severity == Severity::Info && issue.message.contains("2 cameras") + })); + assert!(!report + .issues + .iter() + .any(|issue| issue.message.contains("2D and 3D"))); + assert_eq!(doc.entity(a).map(|node| node.components.len()), Some(1)); + } + + #[test] + fn mesh_without_light_is_info_and_disappears_with_a_light() { + let (mut doc, mesh_id) = doc_with("Rock", vec![mesh_component(primitive(), 0.0, 0.5)]); + let report = validate_scene(&doc, None); + assert!(report.issues.iter().any(|issue| { + issue.severity == Severity::Info && issue.message.contains("no light") + })); + assert!(doc.entity(mesh_id).is_some()); + + let light_id = doc.add_entity("Sun", None); + assert!(doc.add_component(light_id, directional_light_component(10_000.0))); + let report = validate_scene(&doc, None); + assert!(!report + .issues + .iter() + .any(|issue| issue.message.contains("no light"))); + + let (doc, _id) = doc_with("Hero", vec![sprite_component(None, Some(Vec2::ONE))]); + let report = validate_scene(&doc, None); + assert!(!report + .issues + .iter() + .any(|issue| issue.message.contains("no light"))); + } + + #[test] + fn entity_with_only_a_transform_is_info() { + let mut doc = SceneDoc::new("Only Transform"); + let id = doc.add_entity("Empty", None); + let report = validate_scene(&doc, None); + assert!(report.issues.iter().any(|issue| { + issue.severity == Severity::Info + && issue.entity == Some(id) + && issue.message.contains("no components") + })); + + assert!(doc.add_component(id, ComponentData::Visibility(VisibilityMode::Visible))); + let report = validate_scene(&doc, None); + assert!(!report.issues.iter().any(|issue| { + issue.severity == Severity::Info + && issue.entity == Some(id) + && issue.message.contains("no components") + })); + } + + #[test] + fn custom_component_rules() { + let (doc, _id) = doc_with( + "Health", + vec![custom_component("game::Health", &[("current", "10.0")])], + ); + let report = validate_scene(&doc, None); + assert!(report.is_ok(), "{:#?}", report.issues); + assert!(!report.issues.iter().any(|issue| issue + .field + .as_deref() + .is_some_and(|field| field.starts_with("custom.")))); + + let (doc, id) = doc_with("Broken", vec![custom_component("game::", &[])]); + assert_error(&validate_scene(&doc, None), Some(id), "custom.type_path"); + + let (doc, id) = doc_with( + "BadField", + vec![custom_component("Health", &[("my health", "1.0")])], + ); + assert_error( + &validate_scene(&doc, None), + Some(id), + "custom.Health.my health", + ); + + let (doc, id) = doc_with( + "EmptyExpr", + vec![custom_component("Health", &[("current", " ")])], + ); + let report = validate_scene(&doc, None); + assert_error(&report, Some(id), "custom.Health.current"); + assert!(report + .issues + .iter() + .any(|issue| issue.message.contains("empty expression"))); + } + + #[test] + fn structural_corruption_is_reported_instead_of_panicking() { + // Missing parent. + let (mut doc, id) = doc_with("Hero", vec![sprite_component(None, Some(Vec2::ONE))]); + doc.entity_mut(id).expect("fixture entity").parent = Some(999); + let report = validate_scene(&doc, None); + assert!(report.issues.iter().any(|issue| { + issue.severity == Severity::Error && issue.message.contains("parent") + })); + assert!(!report.is_ok()); + + // Missing child. + let (mut doc, id) = doc_with("Hero", vec![sprite_component(None, Some(Vec2::ONE))]); + doc.entity_mut(id) + .expect("fixture entity") + .children + .push(999); + let report = validate_scene(&doc, None); + assert!(report + .issues + .iter() + .any(|issue| issue.severity == Severity::Error && issue.message.contains("child 999"))); + + // Missing root. + let (mut doc, _id) = doc_with("Hero", vec![sprite_component(None, Some(Vec2::ONE))]); + doc.roots.push(999); + let report = validate_scene(&doc, None); + assert!(report + .issues + .iter() + .any(|issue| issue.severity == Severity::Error && issue.message.contains("root 999"))); + + // Mismatched node id. + let (mut doc, id) = doc_with("Hero", vec![sprite_component(None, Some(Vec2::ONE))]); + doc.entity_mut(id).expect("fixture entity").id = 42; + let report = validate_scene(&doc, None); + assert!(report.issues.iter().any(|issue| { + issue.severity == Severity::Error && issue.message.contains("mismatch") + })); + } + + #[test] + fn hierarchy_cycles_are_errors_and_never_loop_forever() { + let mut doc = SceneDoc::new("Cycle"); + doc.entities.insert(1, node(1, "A", Some(2), vec![2])); + doc.entities.insert(2, node(2, "B", Some(1), vec![1])); + doc.next_id = 3; + assert_eq!( + doc.check_integrity(), + Vec::::new(), + "fixture is a consistent two-cycle" + ); + let report = validate_scene(&doc, None); + assert_eq!(report.errors(), 2, "{:#?}", report.issues); + assert!(report + .issues + .iter() + .filter(|issue| issue.severity == Severity::Error) + .all(|issue| issue.message.contains("cycle"))); + + let mut doc = SceneDoc::new("Cycle3"); + doc.entities.insert(1, node(1, "A", Some(2), vec![3])); + doc.entities.insert(2, node(2, "B", Some(3), vec![1])); + doc.entities.insert(3, node(3, "C", Some(1), vec![2])); + doc.next_id = 4; + let report = validate_scene(&doc, None); + assert_eq!(report.errors(), 3, "{:#?}", report.issues); + + // A node that is its own parent is reported explicitly. + let mut doc = SceneDoc::new("Self"); + doc.entities.insert(1, node(1, "Loop", Some(1), vec![1])); + doc.next_id = 2; + let report = validate_scene(&doc, None); + assert!(report.issues.iter().any(|issue| { + issue.severity == Severity::Error && issue.message.contains("its own parent") + })); + } + + #[test] + fn duplicate_references_and_orphans_are_structural_errors() { + let mut doc = SceneDoc::new("Orphan"); + let _a = doc.add_entity("A", None); + let b = doc.add_entity("B", None); + doc.roots.retain(|root| *root != b); + let report = validate_scene(&doc, None); + assert!(report.issues.iter().any(|issue| { + issue.severity == Severity::Error && issue.message.contains("not reachable") + })); + + let mut doc = SceneDoc::new("Duplicate"); + let a = doc.add_entity("A", None); + let b = doc.add_entity("B", Some(a)); + doc.entity_mut(a).expect("fixture entity").children.push(b); + let report = validate_scene(&doc, None); + assert!(report.issues.iter().any(|issue| { + issue.severity == Severity::Error && issue.message.contains("referenced 2 times") + })); + } + + #[test] + fn deep_parent_chains_are_capped_and_not_errors() { + let mut doc = SceneDoc::new("Deep"); + let mut parent = doc.add_entity("N0", None); + for index in 1..300 { + parent = doc.add_entity(format!("N{index}"), Some(parent)); + } + let report = validate_scene(&doc, None); + assert_eq!(report.errors(), 0, "{:#?}", report.issues); + assert!(report.count(Severity::Info) >= 300); + } + + #[test] + fn issues_are_deterministic_and_sorted() { + let assets = index(&[]); + let mut doc = SceneDoc::new("Sorting"); + let a = doc.add_entity("Hero", None); + assert!(doc.add_component( + a, + sprite_component(Some("textures/missing.png"), Some(Vec2::ONE)) + )); + doc.entity_mut(a).expect("fixture entity").transform.scale = Vec3::new(0.0, 1.0, 1.0); + let b = doc.add_entity("Hero", None); + assert!(doc.add_component(b, text_component("", 0.0))); + let c = doc.add_entity("Mesh", None); + assert!(doc.add_component(c, mesh_component(primitive(), 2.0, 0.5))); + + let report = validate_scene(&doc, Some(&assets)); + let again = validate_scene(&doc, Some(&assets)); + assert_eq!(report, again); + + fn compare(a: &Issue, b: &Issue) -> std::cmp::Ordering { + b.severity + .cmp(&a.severity) + .then_with(|| a.entity.cmp(&b.entity)) + .then_with(|| a.message.cmp(&b.message)) + .then_with(|| a.field.cmp(&b.field)) + } + + let mut sorted = report.issues.clone(); + sorted.sort_by(compare); + assert_eq!( + report.issues, sorted, + "issues are not sorted deterministically" + ); + assert_eq!(report.issues[0].severity, Severity::Error); + assert_eq!( + report.issues.last().map(|issue| issue.severity), + Some(Severity::Info) + ); + // Scene-wide issues sort before entity issues of the same severity. + let first_info = report + .issues + .iter() + .find(|issue| issue.severity == Severity::Info) + .expect("info issue"); + assert_eq!(first_info.entity, None); + } + + #[test] + fn report_is_ok_only_without_errors() { + let (doc, _id) = doc_with("Hero", vec![sprite_component(None, Some(Vec2::ONE))]); + assert!(validate_scene(&doc, None).is_ok()); + + let (doc, _id) = doc_with("Hero", vec![sprite_component(None, None)]); + let report = validate_scene(&doc, None); + assert_eq!(report.warnings(), 1); + assert!(report.is_ok()); + + let (doc, _id) = doc_with("Hero", vec![text_component("Hi", 0.0)]); + assert!(!validate_scene(&doc, None).is_ok()); + } + + #[test] + fn unreadable_asset_index_suppresses_missing_file_errors() { + let (doc, _id) = doc_with( + "Hero", + vec![sprite_component(Some("textures/hero.png"), Some(Vec2::ONE))], + ); + let mut assets = index(&[]); + assets.error = Some("permission denied".into()); + let report = validate_scene(&doc, Some(&assets)); + assert!(report.is_ok(), "{:#?}", report.issues); + assert_eq!(report.errors(), 0); + } +} diff --git a/crates/bevy_forge_core/tests/codegen.rs b/crates/bevy_forge_core/tests/codegen.rs new file mode 100644 index 0000000..68f4caa --- /dev/null +++ b/crates/bevy_forge_core/tests/codegen.rs @@ -0,0 +1,764 @@ +//! Public-API integration tests for both code generators (contracts §3). + +mod common; + +use bevy_forge_core::{ + components::{ + ComponentData, CustomComponent, CustomField, DirectionalLightData, MaterialData, + Mesh3dData, MeshSource, PointLightData, PrimitiveShape, SpriteData, Text2dData, + VisibilityMode, + }, + math::{Color, Transform, Vec2, Vec3}, + samples, CodegenError, CodegenOptions, EntityId, ExportFormat, Project, SceneDoc, +}; + +fn generate(doc: &SceneDoc, format: ExportFormat) -> String { + format + .generate(doc, &CodegenOptions::default()) + .unwrap_or_else(|error| panic!("{} generation failed: {error}", format.label())) +} + +fn options_without_header() -> CodegenOptions { + CodegenOptions { + include_header: false, + ..CodegenOptions::default() + } +} + +fn doc_with(component: ComponentData) -> (SceneDoc, EntityId) { + let mut doc = SceneDoc::new("Codegen"); + let id = doc.add_entity("Entity", None); + assert!(doc.add_component(id, component), "component was rejected"); + (doc, id) +} + +fn string_component(text: &str) -> ComponentData { + ComponentData::Text2d(Text2dData { + text: text.to_string(), + font_size: 24.0, + color: Color::srgb(0.1, 0.2, 0.3), + }) +} + +#[test] +fn bsn_single_root_emits_the_expected_component_lines() { + let mut doc = SceneDoc::new("Single"); + let player = doc.add_entity("Player", None); + doc.entity_mut(player).unwrap().transform = + Transform::from_xyz(1.0, 2.0, 3.0).with_rotation(Vec3::new(0.0, 0.0, 90.0)); + doc.add_component( + player, + common::sprite(Some("textures/player.png"), Some(Vec2::new(64.0, 32.0))), + ); + + let code = generate(&doc, ExportFormat::Bsn); + common::assert_generated_shape(&code, ExportFormat::Bsn); + assert!(code.starts_with("// Generated by Bevy Forge.")); + assert!(code.contains("use bevy::prelude::*;\n\n")); + assert!(code.contains("pub fn scene() -> impl Scene {\n")); + common::assert_line(&code, " bsn! {"); + common::assert_line(&code, " Name(\"Player\")"); + common::assert_line(&code, " Transform {"); + common::assert_line(&code, " translation: Vec3::new(1.0, 2.0, 3.0),"); + common::assert_line( + &code, + " rotation: Quat::from_euler(EulerRot::XYZ, 0.0f32.to_radians(), 0.0f32.to_radians(), 90.0f32.to_radians()),", + ); + common::assert_line(&code, " scale: Vec3::new(1.0, 1.0, 1.0),"); + common::assert_line( + &code, + " Sprite { image: \"textures/player.png\", color: Color::srgba(1.0, 0.5, 0.25, 1.0), custom_size: { Some(Vec2::new(64.0, 32.0)) }, flip_x: false, flip_y: false }", + ); + common::assert_line(&code, " }"); + assert!( + !code.contains("bsn_list"), + "single root must not import bsn_list" + ); +} + +#[test] +fn bsn_multiple_roots_use_bsn_list_and_impl_scene_list() { + let mut doc = SceneDoc::new("Multi"); + let left = doc.add_entity("Left", None); + doc.entity_mut(left).unwrap().transform = Transform::from_xyz(-2.0, 0.0, 0.0); + let right = doc.add_entity("Right", None); + doc.entity_mut(right).unwrap().transform = Transform::from_xyz(2.0, 0.0, 0.0); + + let code = generate(&doc, ExportFormat::Bsn); + common::assert_generated_shape(&code, ExportFormat::Bsn); + assert!(code.contains("use bevy::prelude::*;\nuse bevy::scene::bsn_list;\n")); + assert!(code.contains("pub fn scene() -> impl SceneList {")); + common::assert_line(&code, " bsn_list! {"); + common::assert_line(&code, " --"); + common::assert_line(&code, " translation: Vec3::new(-2.0, 0.0, 0.0),"); + + let left_index = code.find("Name(\"Left\")").unwrap(); + let separator = code.find(" --\n").unwrap(); + let right_index = code.find("Name(\"Right\")").unwrap(); + assert!(left_index < separator && separator < right_index); + assert!(!code.contains(" bsn! {")); +} + +#[test] +fn bsn_nested_children_use_children_blocks_and_preserve_order() { + let mut doc = SceneDoc::new("Nested"); + let parent = doc.add_entity("Parent", None); + let first = doc.add_entity("Child A", Some(parent)); + let second = doc.add_entity("Child B", Some(parent)); + let grandchild = doc.add_entity("Grandchild", Some(second)); + doc.entity_mut(grandchild).unwrap().transform = Transform::from_xyz(0.0, 1.0, 0.0); + doc.reparent(first, Some(parent), Some(1)).unwrap(); + assert_eq!(doc.entity(parent).unwrap().children, vec![second, first]); + + let code = generate(&doc, ExportFormat::Bsn); + common::assert_line(&code, " Children ["); + common::assert_line(&code, " Children ["); + common::assert_line(&code, " Name(\"Child B\")"); + common::assert_line(&code, " Name(\"Child A\")"); + common::assert_line(&code, " Name(\"Grandchild\")"); + assert_eq!(code.matches(" --").count(), 1); + + let child_b = code.find("Name(\"Child B\")").unwrap(); + let grandchild_index = code.find("Name(\"Grandchild\")").unwrap(); + let child_a = code.find("Name(\"Child A\")").unwrap(); + assert!(child_b < grandchild_index && grandchild_index < child_a); +} + +#[test] +fn bsn_emits_every_builtin_component_with_verified_syntax() { + let mut doc = SceneDoc::new("All"); + let entity = doc.add_entity("Everything", None); + doc.add_component( + entity, + ComponentData::Sprite(SpriteData { + image: Some("textures/hero.png".into()), + color: Color::srgba(0.5, 0.25, 0.125, 0.75), + custom_size: Some(Vec2::new(64.0, 32.0)), + flip_x: true, + flip_y: false, + }), + ); + doc.add_component(entity, string_component("Hello Forge")); + doc.add_component(entity, ComponentData::Camera2d); + doc.add_component(entity, ComponentData::Camera3d); + doc.add_component( + entity, + ComponentData::PointLight(PointLightData { + color: Color::srgb(1.0, 0.5, 0.2), + intensity: -5.0, + range: 20.0, + shadows: true, + }), + ); + doc.add_component( + entity, + ComponentData::DirectionalLight(DirectionalLightData { + color: Color::WHITE, + illuminance: 10_000.0, + shadows: false, + }), + ); + doc.add_component( + entity, + ComponentData::Mesh3d(Mesh3dData { + mesh: MeshSource::Primitive(PrimitiveShape::Cuboid { + size: Vec3::new(1.0, 2.0, 3.0), + }), + material: MaterialData::default(), + }), + ); + doc.add_component(entity, ComponentData::Visibility(VisibilityMode::Hidden)); + + let code = generate(&doc, ExportFormat::Bsn); + common::assert_line( + &code, + " Sprite { image: \"textures/hero.png\", color: Color::srgba(0.5, 0.25, 0.125, 0.75), custom_size: { Some(Vec2::new(64.0, 32.0)) }, flip_x: true, flip_y: false }", + ); + common::assert_line(&code, " Text2d(\"Hello Forge\")"); + common::assert_line(&code, " TextFont { font_size: FontSize::Px(24.0) }"); + common::assert_line(&code, " TextColor(Color::srgba(0.1, 0.2, 0.3, 1.0))"); + common::assert_line(&code, " Camera2d"); + common::assert_line(&code, " Camera3d::default()"); + common::assert_line( + &code, + " PointLight { color: Color::srgba(1.0, 0.5, 0.2, 1.0), intensity: { -5.0f32 }, range: 20.0, shadow_maps_enabled: true }", + ); + common::assert_line( + &code, + " DirectionalLight { color: Color::srgba(1.0, 1.0, 1.0, 1.0), illuminance: 10000.0, shadow_maps_enabled: false }", + ); + common::assert_line( + &code, + " Mesh3d({ meshes.add(Cuboid::new(1.0, 2.0, 3.0)) })", + ); + common::assert_line( + &code, + " MeshMaterial3d::({ materials.add(StandardMaterial {", + ); + common::assert_line( + &code, + " base_color: Color::srgba(0.45, 0.62, 0.95, 1.0),", + ); + common::assert_line(&code, " metallic: 0.0,"); + common::assert_line(&code, " perceptual_roughness: 0.5,"); + common::assert_line(&code, " unlit: false,"); + common::assert_line(&code, " ..default()"); + common::assert_line(&code, " }) })"); + common::assert_line(&code, " Visibility::Hidden"); + assert!(code.contains( + "pub fn scene(\n meshes: &mut Assets,\n materials: &mut Assets,\n) -> impl Scene {" + )); +} + +#[test] +fn bsn_primitive_meshes_use_verified_constructors_and_required_params() { + let cases = [ + ( + PrimitiveShape::Cuboid { + size: Vec3::new(2.0, 3.0, 4.0), + }, + "Mesh3d({ meshes.add(Cuboid::new(2.0, 3.0, 4.0)) })", + ), + ( + PrimitiveShape::Sphere { radius: 1.5 }, + "Mesh3d({ meshes.add(Sphere::new(1.5)) })", + ), + ( + PrimitiveShape::Plane { + size: Vec2::new(10.0, 6.0), + }, + "Mesh3d({ meshes.add(Plane3d::new(Vec3::Y, Vec2::new(5.0, 3.0))) })", + ), + ( + PrimitiveShape::Cylinder { + radius: 0.5, + height: 2.0, + }, + "Mesh3d({ meshes.add(Cylinder::new(0.5, 2.0)) })", + ), + ( + PrimitiveShape::Capsule { + radius: 0.25, + length: 1.0, + }, + "Mesh3d({ meshes.add(Capsule3d::new(0.25, 1.0)) })", + ), + ]; + for (shape, expected) in cases { + let (doc, _) = doc_with(common::primitive_mesh(shape)); + let code = generate(&doc, ExportFormat::Bsn); + common::assert_line(&code, &format!(" {expected}")); + assert!( + code.contains("meshes: &mut Assets"), + "missing meshes param for {expected}" + ); + assert!(code.contains("materials: &mut Assets")); + } +} + +#[test] +fn bsn_asset_meshes_use_path_handles_and_only_require_materials() { + let (doc, _) = doc_with(ComponentData::Mesh3d(Mesh3dData { + mesh: MeshSource::Asset("models/robot.gltf#Mesh0/Primitive0".into()), + material: MaterialData::default(), + })); + let code = generate(&doc, ExportFormat::Bsn); + common::assert_line( + &code, + " Mesh3d(\"models/robot.gltf#Mesh0/Primitive0\")", + ); + assert!(code.contains("pub fn scene(materials: &mut Assets) -> impl Scene {")); + assert!(!code.contains("meshes: &mut Assets")); + common::assert_line( + &code, + " MeshMaterial3d::({ materials.add(StandardMaterial {", + ); +} + +#[test] +fn bsn_custom_components_brace_expression_forms() { + let mut doc = SceneDoc::new("Custom"); + let health = doc.add_entity("Player", None); + doc.add_component( + health, + common::custom("game::Health", &[("current", "10.0"), ("max", "Vec3::X")]), + ); + let marker = doc.add_entity("Marker", None); + doc.add_component(marker, common::custom("game::Marker", &[])); + let extras = doc.add_entity("Extras", None); + doc.add_component( + extras, + common::custom( + "game::Extras", + &[("icon", "Some(Vec2::new(1.0, 2.0))"), ("offset", "-5.0")], + ), + ); + + let code = generate(&doc, ExportFormat::Bsn); + // Three roots -> list form; inspect the component lines only. + common::assert_line( + &code, + " game::Health { current: 10.0, max: Vec3::X }", + ); + common::assert_line(&code, " game::Marker"); + common::assert_line( + &code, + " game::Extras { icon: { Some(Vec2::new(1.0, 2.0)) }, offset: { -5.0f32 } }", + ); +} + +#[test] +fn bsn_omits_optional_sprite_fields_and_escapes_strings() { + let (doc, _) = doc_with(common::sprite(None, None)); + let code = generate(&doc, ExportFormat::Bsn); + common::assert_line( + &code, + " Sprite { color: Color::srgba(1.0, 0.5, 0.25, 1.0), flip_x: false, flip_y: false }", + ); + assert!(!code.contains("image:")); + assert!(!code.contains("custom_size:")); + + let mut escaped = SceneDoc::new("Escaping"); + let entity = escaped.add_entity("Line \"one\"\\two\nthree\ttab ✓", None); + escaped.add_component(entity, string_component("say \"hi\"\nback\\slash")); + let code = generate(&escaped, ExportFormat::Bsn); + common::assert_line( + &code, + " Name(\"Line \\\"one\\\"\\\\two\\nthree\\ttab ✓\")", + ); + common::assert_line(&code, " Text2d(\"say \\\"hi\\\"\\nback\\\\slash\")"); + assert!(code.contains('✓')); +} + +#[test] +fn header_toggle_and_single_trailing_newline_are_stable() { + let mut doc = SceneDoc::new("Header"); + doc.add_entity("Root", None); + + let with_header = generate(&doc, ExportFormat::Bsn); + assert!(with_header.starts_with("// Generated by Bevy Forge.")); + assert!(with_header.contains("\nuse bevy::prelude::*;\n")); + assert!(with_header.ends_with('\n') && !with_header.ends_with("\n\n")); + + let without_header = ExportFormat::Bsn + .generate(&doc, &options_without_header()) + .unwrap(); + assert!(without_header.starts_with("use bevy::prelude::*;\n")); + assert!(!without_header.contains("Generated by Bevy Forge")); + assert!(without_header.ends_with('\n') && !without_header.ends_with("\n\n")); + + let rust_with_header = generate(&doc, ExportFormat::Rust); + assert!(rust_with_header.starts_with("// Generated by Bevy Forge.")); + assert!(rust_with_header.ends_with('\n') && !rust_with_header.ends_with("\n\n")); + let rust_without_header = ExportFormat::Rust + .generate(&doc, &options_without_header()) + .unwrap(); + assert!(rust_without_header.starts_with("use bevy::prelude::*;\n")); + assert!(!rust_without_header.contains("Generated by Bevy Forge")); + assert!(rust_without_header.ends_with('\n') && !rust_without_header.ends_with("\n\n")); +} + +#[test] +fn both_formats_reject_empty_and_rootless_scenes() { + for format in [ExportFormat::Bsn, ExportFormat::Rust] { + let empty = SceneDoc::new("Empty"); + assert!( + matches!( + format.generate(&empty, &CodegenOptions::default()), + Err(CodegenError::EmptyScene) + ), + "{} accepted an empty scene", + format.label() + ); + + let mut rootless = SceneDoc::new("Rootless"); + rootless.add_entity("Detached", None); + rootless.roots.clear(); + assert!( + matches!( + format.generate(&rootless, &CodegenOptions::default()), + Err(CodegenError::EmptyScene) + ), + "{} accepted a rootless scene", + format.label() + ); + } +} + +#[test] +fn both_formats_reject_invalid_function_names() { + let mut doc = SceneDoc::new("Names"); + doc.add_entity("Root", None); + + for name in ["", "my scene", "1scene", "scene-name", "fn"] { + for format in [ExportFormat::Bsn, ExportFormat::Rust] { + let options = CodegenOptions::default().with_function_name(name); + match format.generate(&doc, &options) { + Err(CodegenError::InvalidIdentifier { what, value }) => { + assert_eq!(what, "function name", "{} for {name:?}", format.label()); + assert_eq!(value, name, "{} for {name:?}", format.label()); + } + other => panic!( + "{} should reject function name {name:?}, got {other:?}", + format.label() + ), + } + } + } + + let valid = CodegenOptions::default().with_function_name("spawn_garden"); + assert!(ExportFormat::Bsn + .generate(&doc, &valid) + .unwrap() + .contains("pub fn spawn_garden() -> impl Scene")); + assert!(ExportFormat::Rust + .generate(&doc, &valid) + .unwrap() + .contains("pub fn spawn_garden(commands: &mut Commands)")); +} + +#[test] +fn both_formats_reject_non_finite_values() { + for format in [ExportFormat::Bsn, ExportFormat::Rust] { + let mut doc = SceneDoc::new("NaN"); + let entity = doc.add_entity("Broken", None); + doc.entity_mut(entity).unwrap().transform.translation.x = f32::NAN; + match format.generate(&doc, &CodegenOptions::default()) { + Err(CodegenError::NonFinite { + entity: Some(id), + field, + value, + }) => { + assert_eq!(id, entity, "{}", format.label()); + assert_eq!(field, "transform.translation.x", "{}", format.label()); + assert!(value.is_nan()); + } + other => panic!("{} should reject NaN transform: {other:?}", format.label()), + } + + let (material_doc, _) = doc_with(ComponentData::Mesh3d(Mesh3dData { + mesh: MeshSource::Primitive(PrimitiveShape::Sphere { radius: 1.0 }), + material: MaterialData { + base_color: Color::WHITE, + metallic: f32::INFINITY, + perceptual_roughness: 0.5, + unlit: false, + }, + })); + assert!( + matches!( + format.generate(&material_doc, &CodegenOptions::default()), + Err(CodegenError::NonFinite { value, .. }) if value.is_infinite() + ), + "{} should reject an infinite material value", + format.label() + ); + + let light = ComponentData::PointLight(PointLightData { + color: Color::WHITE, + intensity: 1.0, + range: f32::NAN, + shadows: false, + }); + let (light_doc, _) = doc_with(light); + assert!( + matches!( + format.generate(&light_doc, &CodegenOptions::default()), + Err(CodegenError::NonFinite { value, .. }) if value.is_nan() + ), + "{} should reject a NaN light range", + format.label() + ); + + let (sprite_doc, _) = doc_with(common::sprite(None, Some(Vec2::new(f32::NAN, 1.0)))); + assert!( + matches!( + format.generate(&sprite_doc, &CodegenOptions::default()), + Err(CodegenError::NonFinite { field, .. }) if field.contains("custom_size") + ), + "{} should reject a NaN sprite size", + format.label() + ); + } +} + +#[test] +fn both_formats_reject_invalid_custom_components() { + let invalid = [ + ("invalid type path", common::custom("not a path", &[])), + ( + "invalid field name", + common::custom("game::Health", &[("1current", "10.0")]), + ), + ( + "empty expression", + common::custom("game::Health", &[("current", " ")]), + ), + ("empty type path", common::custom("", &[])), + ]; + + for (label, component) in invalid { + for format in [ExportFormat::Bsn, ExportFormat::Rust] { + let (doc, _) = doc_with(component.clone()); + match format.generate(&doc, &CodegenOptions::default()) { + Err(CodegenError::InvalidCustomComponent(message)) => { + assert!(!message.is_empty(), "{} {label}", format.label()); + } + other => panic!("{} should reject {label}, got {other:?}", format.label()), + } + } + } +} + +#[test] +fn rust_parameters_are_conditional_on_used_assets() { + let mut plain = SceneDoc::new("Plain"); + plain.add_entity("Empty", None); + let code = generate(&plain, ExportFormat::Rust); + assert!(code.contains("pub fn scene(commands: &mut Commands) {")); + assert!(!code.contains("asset_server")); + assert!(!code.contains("Assets")); + assert!(!code.contains("Assets")); + + let (sprite_with_image, _) = doc_with(common::sprite(Some("textures/hero.png"), None)); + let code = generate(&sprite_with_image, ExportFormat::Rust); + assert!(code.contains( + "pub fn scene(\n commands: &mut Commands,\n asset_server: &AssetServer,\n) {" + )); + assert!(!code.contains("Assets")); + + let (sprite_without_image, _) = doc_with(common::sprite(None, Some(Vec2::splat(8.0)))); + let code = generate(&sprite_without_image, ExportFormat::Rust); + assert!(code.contains("pub fn scene(commands: &mut Commands) {")); + assert!(!code.contains("asset_server")); + + let (primitive, _) = doc_with(common::primitive_mesh(PrimitiveShape::Cuboid { + size: Vec3::ONE, + })); + let code = generate(&primitive, ExportFormat::Rust); + assert!(code.contains( + "pub fn scene(\n commands: &mut Commands,\n meshes: &mut Assets,\n materials: &mut Assets,\n) {" + )); + assert!(!code.contains("asset_server")); + + let (asset_mesh, _) = doc_with(ComponentData::Mesh3d(Mesh3dData { + mesh: MeshSource::Asset("models/robot.gltf".into()), + material: MaterialData::default(), + })); + let code = generate(&asset_mesh, ExportFormat::Rust); + assert!(code.contains(" commands: &mut Commands,\n asset_server: &AssetServer,\n meshes: &mut Assets,\n materials: &mut Assets,")); +} + +#[test] +fn rust_hierarchy_uses_with_children_in_depth_first_order() { + let mut doc = SceneDoc::new("Tree"); + let root = doc.add_entity("Root", None); + let child = doc.add_entity("Child", Some(root)); + doc.add_entity("Grandchild", Some(child)); + + let code = generate(&doc, ExportFormat::Rust); + common::assert_generated_shape(&code, ExportFormat::Rust); + assert_eq!(code.matches(".spawn((").count(), 3); + assert_eq!(code.matches("commands.spawn((").count(), 1); + assert_eq!(code.matches("parent.spawn((").count(), 2); + assert_eq!(code.matches("with_children").count(), 2); + assert!(code.contains(" )).with_children(|parent| {\n parent.spawn((\n")); + assert!(code.contains(" )).with_children(|parent| {\n parent.spawn((\n")); + + let root_at = code.find("Name::new(\"Root\")").unwrap(); + let child_at = code.find("Name::new(\"Child\")").unwrap(); + let grandchild_at = code.find("Name::new(\"Grandchild\")").unwrap(); + assert!(root_at < child_at && child_at < grandchild_at); +} + +#[test] +fn rust_emits_all_components_with_paste_ready_syntax() { + let mut doc = SceneDoc::new("All Rust"); + let entity = doc.add_entity("Everything", None); + doc.add_component( + entity, + ComponentData::Sprite(SpriteData { + image: Some("textures/hero.png".into()), + color: Color::srgba(1.0, 0.5, 0.25, 0.8), + custom_size: Some(Vec2::new(64.0, 32.0)), + flip_x: true, + flip_y: false, + }), + ); + doc.add_component(entity, string_component("Hello Forge")); + doc.add_component(entity, ComponentData::Camera2d); + doc.add_component(entity, ComponentData::Camera3d); + doc.add_component(entity, ComponentData::PointLight(PointLightData::default())); + doc.add_component( + entity, + ComponentData::DirectionalLight(DirectionalLightData::default()), + ); + doc.add_component(entity, ComponentData::Mesh3d(Mesh3dData::default())); + doc.add_component(entity, ComponentData::Visibility(VisibilityMode::Hidden)); + + let code = generate(&doc, ExportFormat::Rust); + common::assert_line( + &code, + " Sprite { image: asset_server.load(\"textures/hero.png\"), color: Color::srgba(1.0, 0.5, 0.25, 0.8), custom_size: Some(Vec2::new(64.0, 32.0)), flip_x: true, flip_y: false, ..default() },", + ); + common::assert_line(&code, " Text2d::new(\"Hello Forge\"),"); + common::assert_line( + &code, + " TextFont { font_size: FontSize::Px(24.0), ..default() },", + ); + common::assert_line( + &code, + " TextColor(Color::srgba(0.1, 0.2, 0.3, 1.0)),", + ); + common::assert_line(&code, " Camera2d,"); + common::assert_line(&code, " Camera3d::default(),"); + common::assert_line( + &code, + " PointLight { color: Color::srgba(1.0, 0.95, 0.9, 1.0), intensity: 800.0, range: 20.0, shadow_maps_enabled: true, ..default() },", + ); + common::assert_line( + &code, + " DirectionalLight { color: Color::srgba(1.0, 1.0, 1.0, 1.0), illuminance: 10000.0, shadow_maps_enabled: true, ..default() },", + ); + common::assert_line( + &code, + " Mesh3d(meshes.add(Cuboid::new(1.0, 1.0, 1.0))),", + ); + common::assert_line( + &code, + " MeshMaterial3d(materials.add(StandardMaterial { base_color: Color::srgba(0.45, 0.62, 0.95, 1.0), metallic: 0.0, perceptual_roughness: 0.5, unlit: false, ..default() })),", + ); + common::assert_line(&code, " Visibility::Hidden,"); + assert!(code.contains( + "pub fn scene(\n commands: &mut Commands,\n asset_server: &AssetServer,\n meshes: &mut Assets,\n materials: &mut Assets,\n) {" + )); +} + +#[test] +fn rust_custom_components_use_struct_literals() { + let mut doc = SceneDoc::new("Custom Rust"); + let entity = doc.add_entity("Player", None); + doc.add_component( + entity, + ComponentData::Custom(CustomComponent { + type_path: "my_game::Health".into(), + fields: vec![ + CustomField { + name: "current".into(), + expr: " 10.0 ".into(), + }, + CustomField { + name: "max".into(), + expr: "100.0".into(), + }, + ], + }), + ); + let marker = doc.add_entity("Marker", None); + doc.add_component( + marker, + ComponentData::Custom(CustomComponent { + type_path: "my_game::Marker".into(), + fields: Vec::new(), + }), + ); + + let code = generate(&doc, ExportFormat::Rust); + common::assert_line( + &code, + " my_game::Health { current: 10.0, max: 100.0 },", + ); + common::assert_line(&code, " my_game::Marker,"); +} + +#[test] +fn rust_output_is_deterministic_and_header_optional() { + let mut doc = SceneDoc::new("Rust Stable"); + let root = doc.add_entity("Root", None); + let child = doc.add_entity("Child", Some(root)); + doc.add_component( + child, + common::sprite(Some("textures/hero.png"), Some(Vec2::splat(8.0))), + ); + + let first = generate(&doc, ExportFormat::Rust); + let second = generate(&doc, ExportFormat::Rust); + assert_eq!(first, second); + common::assert_generated_shape(&first, ExportFormat::Rust); + assert!(first.contains("commands.spawn((")); + assert!(first.contains("with_children")); +} + +#[test] +fn generated_code_for_all_demo_and_starter_scenes_is_deterministic_and_non_empty() { + let demo = common::demo_project_dir(); + let project = Project::open(&demo).expect("demo project opens"); + + let mut scenes = vec![ + ("starter_2d", samples::starter_2d()), + ("starter_3d", samples::starter_3d()), + ("garden_demo", samples::garden_demo()), + ("platformer_demo", samples::platformer_demo()), + ]; + for (file, expected) in [ + ("garden.forge.ron", samples::garden_demo()), + ("platformer.forge.ron", samples::platformer_demo()), + ] { + let loaded = project + .load_scene(project.scenes_dir().join(file)) + .expect("demo scene loads"); + assert_eq!( + loaded, expected, + "{file} must match its builder before codegen" + ); + scenes.push((file, loaded)); + } + + for (label, doc) in scenes { + for format in [ExportFormat::Bsn, ExportFormat::Rust] { + let first = format + .generate(&doc, &CodegenOptions::default()) + .unwrap_or_else(|error| panic!("{label} {}: {error}", format.label())); + let second = format + .generate(&doc, &CodegenOptions::default()) + .unwrap_or_else(|error| panic!("{label} {} (second): {error}", format.label())); + assert_eq!( + first, + second, + "{label} {} is nondeterministic", + format.label() + ); + assert!( + !first.trim().is_empty(), + "{label} {} is empty", + format.label() + ); + let name_marker = match format { + ExportFormat::Bsn => "Name(", + ExportFormat::Rust => "Name::new(", + }; + assert!( + first.contains(name_marker), + "{label} {} lost names", + format.label() + ); + common::assert_generated_shape(&first, format); + + if format == ExportFormat::Bsn { + assert!( + first.contains("bsn! {") || first.contains("bsn_list! {"), + "{label} BSN lacks a macro invocation" + ); + assert!( + first.contains("impl Scene") || first.contains("impl SceneList"), + "{label} BSN lacks a scene return type" + ); + } else { + assert!( + first.contains("commands.spawn(("), + "{label} Rust lost spawns" + ); + } + } + } +} diff --git a/crates/bevy_forge_core/tests/common/mod.rs b/crates/bevy_forge_core/tests/common/mod.rs new file mode 100644 index 0000000..8a876d2 --- /dev/null +++ b/crates/bevy_forge_core/tests/common/mod.rs @@ -0,0 +1,139 @@ +//! Shared helpers for the public-API integration tests. +//! +//! Everything here goes through `bevy_forge_core`'s public API; no private module +//! items are used. The module is compiled into each integration-test binary, so +//! helpers only used by some binaries are allowed to be dead there. + +#![allow(dead_code)] + +use std::path::{Path, PathBuf}; + +use bevy_forge_core::components::{ComponentData, CustomComponent, CustomField, PrimitiveShape}; +use bevy_forge_core::math::{Vec2, Vec3}; +use bevy_forge_core::project::Project; +use bevy_forge_core::ProjectError; + +/// Repository `examples/demo-project` directory (scene + asset fixture). +pub fn demo_project_dir() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../examples/demo-project") + .canonicalize() + .expect("examples/demo-project must exist") +} + +pub fn demo_assets_dir() -> PathBuf { + demo_project_dir().join("assets") +} + +/// Write a `Cargo.toml` in `root`. +pub fn write_manifest(root: &Path, contents: &str) { + std::fs::write(root.join("Cargo.toml"), contents).expect("write Cargo.toml"); +} + +/// Standard manifest declaring `bevy = "0.20"`. +pub fn bevy_manifest(name: &str) -> String { + format!( + "[package]\nname = \"{name}\"\nversion = \"0.1.0\"\nedition = \"2021\"\n\n[dependencies]\nbevy = \"0.20\"\n" + ) +} + +/// Create a minimal valid project at `root` and open it. +pub fn create_project(root: &Path, name: &str) -> Project { + write_manifest(root, &bevy_manifest(name)); + Project::open(root).expect("valid minimal Bevy project") +} + +/// A temporary directory plus an opened project rooted in it. +pub fn temp_project(name: &str) -> (tempfile::TempDir, Project) { + let dir = tempfile::tempdir().expect("tempdir"); + let project = create_project(dir.path(), name); + (dir, project) +} + +/// `Some` image / optional custom size sprite. +pub fn sprite(image: Option<&str>, custom_size: Option) -> ComponentData { + ComponentData::Sprite(bevy_forge_core::SpriteData { + image: image.map(str::to_string), + color: bevy_forge_core::Color::srgba(1.0, 0.5, 0.25, 1.0), + custom_size, + flip_x: false, + flip_y: false, + }) +} + +/// A custom component built from raw field expressions. +pub fn custom(type_path: &str, fields: &[(&str, &str)]) -> ComponentData { + ComponentData::Custom(CustomComponent { + type_path: type_path.to_string(), + fields: fields + .iter() + .map(|(name, expr)| CustomField { + name: name.to_string(), + expr: expr.to_string(), + }) + .collect(), + }) +} + +/// A primitive mesh component with the default material. +pub fn primitive_mesh(shape: PrimitiveShape) -> ComponentData { + ComponentData::Mesh3d(bevy_forge_core::Mesh3dData { + mesh: bevy_forge_core::MeshSource::Primitive(shape), + material: bevy_forge_core::MaterialData::default(), + }) +} + +pub fn approx_vec2(actual: Vec2, expected: Vec2) -> bool { + (actual - expected).length() < 1e-4 +} + +pub fn approx_vec3(actual: Vec3, expected: Vec3) -> bool { + (actual - expected).length() < 1e-4 +} + +pub fn approx_f32(actual: f32, expected: f32) -> bool { + (actual - expected).abs() < 1e-4 +} + +pub fn assert_line(code: &str, expected: &str) { + assert!( + code.lines().any(|line| line == expected), + "missing exact line {expected:?}\n--- generated code ---\n{code}" + ); +} + +/// Assert a generated file contains the prelude import and ends with exactly one +/// newline (never a blank line). The attribution header, when enabled, comes first. +pub fn assert_generated_shape(code: &str, format: bevy_forge_core::ExportFormat) { + assert!( + code.starts_with("use bevy::prelude::*;") + || code.starts_with("// Generated by Bevy Forge."), + "{} output must start with the Bevy prelude import or its header:\n{code}", + format.label() + ); + assert!( + code.contains("\nuse bevy::prelude::*;\n") || code.starts_with("use bevy::prelude::*;"), + "{} output must import the Bevy prelude", + format.label() + ); + assert!( + code.ends_with('\n') && !code.ends_with("\n\n"), + "{} output must end with exactly one newline", + format.label() + ); +} + +/// Write a scene file with an arbitrary body and return a matching error. +pub fn expect_project_error(result: Result) -> ProjectError { + result.expect_err("expected a project error") +} + +/// Create `relative` under `root`, including parent directories. +pub fn touch(root: &Path, relative: &str, contents: &[u8]) -> PathBuf { + let path = root.join(relative); + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).expect("create parent dirs"); + } + std::fs::write(&path, contents).expect("write file"); + path +} diff --git a/crates/bevy_forge_core/tests/model.rs b/crates/bevy_forge_core/tests/model.rs new file mode 100644 index 0000000..8a8bc82 --- /dev/null +++ b/crates/bevy_forge_core/tests/model.rs @@ -0,0 +1,603 @@ +//! Public-API integration tests for the scene model (`bevy_forge_core::scene`). +//! +//! Every structural mutation is followed by a [`SceneDoc::check_integrity`] check +//! either directly or through the `assert_sound` helper. + +mod common; + +use bevy_forge_core::{ + components::{ComponentData, PrimitiveShape, VisibilityMode}, + math::{Bounds2, Color, Transform, Vec2, Vec3}, + EntityId, EntityNode, SceneDoc, SceneError, FORMAT_VERSION, +}; + +fn assert_sound(doc: &SceneDoc) { + let problems = doc.check_integrity(); + assert!( + problems.is_empty(), + "scene {:?} has structural problems: {problems:?}", + doc.name + ); +} + +fn subtree_ids(doc: &SceneDoc, root: EntityId) -> Vec { + let mut ids = Vec::new(); + fn walk(doc: &SceneDoc, id: EntityId, ids: &mut Vec) { + if let Some(node) = doc.entity(id) { + ids.push(id); + for child in &node.children { + walk(doc, *child, ids); + } + } + } + walk(doc, root, &mut ids); + ids +} + +fn text_component(text: &str, font_size: f32) -> ComponentData { + ComponentData::Text2d(bevy_forge_core::Text2dData { + text: text.to_string(), + font_size, + color: Color::WHITE, + }) +} + +#[test] +fn add_entity_preserves_stable_ids_and_display_order() { + let mut doc = SceneDoc::new("Order"); + assert_eq!(doc.format_version, FORMAT_VERSION); + assert_eq!(doc.next_id, 1); + + let a = doc.add_entity("A", None); + let b = doc.add_entity("B", None); + let c = doc.add_entity("C", Some(a)); + let d = doc.add_entity("D", Some(a)); + + assert_eq!((a, b, c, d), (1, 2, 3, 4)); + assert_eq!(doc.next_id, 5); + assert_eq!(doc.roots, vec![a, b]); + assert_eq!(doc.entity(a).unwrap().children, vec![c, d]); + assert_eq!(doc.parent(c), Some(a)); + assert_eq!(doc.siblings(Some(a)), &[c, d]); + assert_eq!(doc.siblings(None), &[a, b]); + assert_eq!(doc.depth_first_ids(), vec![a, c, d, b]); + assert_eq!(doc.entity(b).unwrap().parent, None); + + let stats = doc.stats(); + assert_eq!(stats.entities, 4); + assert_eq!(stats.roots, 2); + assert_sound(&doc); +} + +#[test] +fn insert_entity_honours_parent_and_index_and_advances_next_id() { + let mut doc = SceneDoc::new("Insert"); + let a = doc.add_entity("A", None); + let b = doc.add_entity("B", None); + + let prepared = EntityNode { + id: 50, + name: "Inserted Root".into(), + parent: Some(b), // overwritten by the parent argument + children: Vec::new(), + transform: Transform::from_xyz(7.0, 8.0, 9.0), + components: vec![common::sprite(None, Some(Vec2::new(2.0, 2.0)))], + locked: true, + }; + let inserted = doc.insert_entity(prepared, None, Some(0)); + assert_eq!(inserted, 50); + assert_eq!(doc.roots, vec![inserted, a, b]); + assert_eq!(doc.entity(inserted).unwrap().parent, None); + assert_eq!(doc.entity(inserted).unwrap().name, "Inserted Root"); + assert!(doc.entity(inserted).unwrap().locked); + assert_eq!( + doc.entity(inserted).unwrap().transform, + Transform::from_xyz(7.0, 8.0, 9.0) + ); + assert_eq!(doc.next_id, 51); + assert_sound(&doc); + + // Appending under a parent, with an out-of-range index clamped to the end. + let child = EntityNode { + id: 60, + name: "Inserted Child".into(), + parent: None, + children: Vec::new(), + transform: Transform::IDENTITY, + components: Vec::new(), + locked: false, + }; + let child = doc.insert_entity(child, Some(a), Some(usize::MAX)); + assert_eq!(child, 60); + assert_eq!(doc.entity(a).unwrap().children, vec![child]); + assert_eq!(doc.next_id, 61); + assert_eq!(doc.add_entity("Next", None), 61); + assert_sound(&doc); +} + +#[test] +fn remove_entity_removes_the_subtree_and_never_reuses_ids() { + let mut doc = SceneDoc::new("Remove"); + let a = doc.add_entity("A", None); + let b = doc.add_entity("B", Some(a)); + let c = doc.add_entity("C", Some(a)); + let d = doc.add_entity("D", Some(b)); + doc.add_component(b, common::sprite(None, None)); + + let removed = doc.remove_entity(a); + assert_eq!(removed, vec![a, b, d, c]); + assert!(removed.contains(&c)); + assert!(doc.entities.is_empty()); + assert!(doc.roots.is_empty()); + assert_eq!(doc.next_id, 5, "removal must not rewind id allocation"); + assert_sound(&doc); + + let next = doc.add_entity("New Root", None); + assert_eq!(next, 5, "stable ids are never reused"); + assert!(doc.remove_entity(999).is_empty()); + assert_sound(&doc); + + // Removing a single child detaches it from its parent only. + let child = doc.add_entity("Child", Some(next)); + assert_eq!(doc.remove_entity(child), vec![child]); + assert!(doc.entity(next).unwrap().children.is_empty()); + assert!(doc.entity(next).is_some()); + assert_sound(&doc); +} + +#[test] +fn duplicate_entity_deep_copies_the_subtree_with_new_ids_and_copy_name() { + let mut doc = SceneDoc::new("Duplicate"); + let a = doc.add_entity("A", None); + let b = doc.add_entity("B", Some(a)); + let c = doc.add_entity("C", Some(a)); + let d = doc.add_entity("D", Some(b)); + assert_eq!(doc.entity(c).unwrap().parent, Some(a)); + doc.entity_mut(a).unwrap().transform = Transform::from_xyz(1.0, 2.0, 3.0); + doc.entity_mut(a).unwrap().locked = true; + doc.add_component( + b, + common::sprite(Some("textures/hero.png"), Some(Vec2::new(8.0, 4.0))), + ); + doc.add_component(d, common::custom("game::Health", &[("current", "10.0")])); + + let copy = doc.duplicate_entity(a).expect("existing entity duplicates"); + assert_eq!( + doc.roots, + vec![a, copy], + "copy goes right after the original" + ); + + let copy_node = doc.entity(copy).unwrap(); + assert_eq!(copy_node.name, "A Copy"); + assert_eq!(copy_node.parent, None); + assert!(copy_node.locked, "editor flags are copied"); + assert_eq!(copy_node.transform, Transform::from_xyz(1.0, 2.0, 3.0)); + assert_eq!(copy_node.components, doc.entity(a).unwrap().components); + + let original_ids = subtree_ids(&doc, a); + let copied_ids = subtree_ids(&doc, copy); + assert_eq!(original_ids.len(), 4); + assert_eq!(copied_ids.len(), 4); + for left in &original_ids { + assert!( + !copied_ids.contains(left), + "copied subtree reused id {left}" + ); + } + assert_ne!(copy, a); + assert!(copied_ids.iter().all(|id| *id > d), "copy ids are fresh"); + + // Hierarchy is preserved; only the copied root gets the " Copy" suffix. + let copied_children = doc.entity(copy).unwrap().children.clone(); + assert_eq!( + copied_children + .iter() + .map(|id| doc.entity(*id).unwrap().name.as_str()) + .collect::>(), + vec!["B", "C"] + ); + let copied_b = copied_children[0]; + assert_eq!(doc.entity(copied_b).unwrap().parent, Some(copy)); + assert_eq!( + doc.entity(copied_b).unwrap().components, + doc.entity(b).unwrap().components + ); + let copied_d = doc.entity(copied_b).unwrap().children[0]; + assert_eq!(doc.entity(copied_d).unwrap().name, "D"); + assert_eq!( + doc.entity(copied_d).unwrap().components, + doc.entity(d).unwrap().components + ); + + assert!(doc.duplicate_entity(999).is_none()); + assert_sound(&doc); +} + +#[test] +fn duplicate_child_is_inserted_after_the_original_among_siblings() { + let mut doc = SceneDoc::new("Duplicate Sibling"); + let parent = doc.add_entity("Parent", None); + let a = doc.add_entity("A", Some(parent)); + let b = doc.add_entity("B", Some(parent)); + let c = doc.add_entity("C", Some(parent)); + + let copy = doc.duplicate_entity(b).unwrap(); + assert_eq!(doc.entity(parent).unwrap().children, vec![a, b, copy, c]); + assert_eq!(doc.entity(copy).unwrap().parent, Some(parent)); + assert_eq!(doc.entity(copy).unwrap().name, "B Copy"); + assert_sound(&doc); +} + +#[test] +fn reparent_moves_between_roots_and_children() { + let mut doc = SceneDoc::new("Reparent"); + let a = doc.add_entity("A", None); + let b = doc.add_entity("B", None); + let c = doc.add_entity("C", Some(a)); + + // Child -> root. + doc.reparent(c, None, None).unwrap(); + assert_eq!(doc.roots, vec![a, b, c]); + assert_eq!(doc.parent(c), None); + assert!(doc.entity(a).unwrap().children.is_empty()); + assert_sound(&doc); + + // Root -> child, at the front. + doc.reparent(b, Some(a), Some(0)).unwrap(); + assert_eq!(doc.roots, vec![a, c]); + assert_eq!(doc.entity(a).unwrap().children, vec![b]); + assert_eq!(doc.parent(b), Some(a)); + assert_sound(&doc); + + // Move the child back to the roots at a specific position. + doc.reparent(b, None, Some(1)).unwrap(); + assert_eq!(doc.roots, vec![a, b, c]); + assert!(doc.entity(a).unwrap().children.is_empty()); + assert_sound(&doc); +} + +#[test] +fn reparent_reorders_siblings_and_clamps_the_index() { + let mut doc = SceneDoc::new("Reorder"); + let parent = doc.add_entity("Parent", None); + let a = doc.add_entity("A", Some(parent)); + let b = doc.add_entity("B", Some(parent)); + let c = doc.add_entity("C", Some(parent)); + + doc.reparent(c, Some(parent), Some(0)).unwrap(); + assert_eq!(doc.entity(parent).unwrap().children, vec![c, a, b]); + + doc.reparent(c, Some(parent), None).unwrap(); + assert_eq!(doc.entity(parent).unwrap().children, vec![a, b, c]); + + doc.reparent(a, Some(parent), Some(99)).unwrap(); + assert_eq!(doc.entity(parent).unwrap().children, vec![b, c, a]); + assert_sound(&doc); +} + +#[test] +fn reparent_rejects_self_cycles_and_missing_entities() { + let mut doc = SceneDoc::new("Reject"); + let a = doc.add_entity("A", None); + let b = doc.add_entity("B", Some(a)); + let c = doc.add_entity("C", Some(b)); + + let before = doc.clone(); + assert!(matches!( + doc.reparent(a, Some(a), None), + Err(SceneError::SelfParent(id)) if id == a + )); + assert!(matches!( + doc.reparent(a, Some(c), None), + Err(SceneError::Cycle { entity, parent }) if entity == a && parent == c + )); + assert!(matches!( + doc.reparent(a, Some(999), None), + Err(SceneError::ParentNotFound(999)) + )); + assert!(matches!( + doc.reparent(999, None, None), + Err(SceneError::EntityNotFound(999)) + )); + assert_eq!( + doc, before, + "rejected reparents must not mutate the document" + ); + assert_sound(&doc); +} + +#[test] +fn depth_first_ids_follow_roots_and_children_order() { + let mut doc = SceneDoc::new("Depth First"); + let r1 = doc.add_entity("R1", None); + let r2 = doc.add_entity("R2", None); + let a = doc.add_entity("A", Some(r1)); + let b = doc.add_entity("B", Some(r1)); + let a1 = doc.add_entity("A1", Some(a)); + + assert_eq!(doc.depth_first_ids(), vec![r1, a, a1, b, r2]); + + doc.reparent(b, None, Some(0)).unwrap(); + assert_eq!(doc.depth_first_ids(), vec![b, r1, a, a1, r2]); + + doc.reparent(a1, Some(b), None).unwrap(); + assert_eq!(doc.depth_first_ids(), vec![b, a1, r1, a, r2]); + assert_sound(&doc); +} + +#[test] +fn world_transform_composes_multi_level_translation_rotation_and_scale() { + let mut doc = SceneDoc::new("World"); + let root = doc.add_entity("Root", None); + let child = doc.add_entity("Child", Some(root)); + let grandchild = doc.add_entity("Grandchild", Some(child)); + + doc.entity_mut(root).unwrap().transform = Transform { + translation: Vec3::new(10.0, 0.0, 0.0), + rotation: Vec3::new(0.0, 0.0, 90.0), + scale: Vec3::splat(2.0), + }; + doc.entity_mut(child).unwrap().transform = Transform::from_xyz(0.0, 2.0, 0.0); + doc.entity_mut(grandchild).unwrap().transform = Transform::from_xyz(0.0, 1.0, 0.0); + + let world_child = doc.world_transform(child).unwrap(); + assert!( + common::approx_vec3(world_child.translation, Vec3::new(6.0, 0.0, 0.0)), + "unexpected child translation {:?}", + world_child.translation + ); + assert!(common::approx_vec3(world_child.scale, Vec3::splat(2.0))); + assert!( + common::approx_f32(world_child.rotation.z, 90.0), + "unexpected child rotation {:?}", + world_child.rotation + ); + + let parent_world = doc.parent_world_transform(child); + assert!(common::approx_vec3( + parent_world.translation, + Vec3::new(10.0, 0.0, 0.0) + )); + assert!(common::approx_vec3(parent_world.scale, Vec3::splat(2.0))); + assert!(common::approx_f32(parent_world.rotation.z, 90.0)); + + let world_grandchild = doc.world_transform(grandchild).unwrap(); + assert!( + common::approx_vec3(world_grandchild.translation, Vec3::new(4.0, 0.0, 0.0)), + "unexpected grandchild translation {:?}", + world_grandchild.translation + ); + assert!(common::approx_vec3( + world_grandchild.scale, + Vec3::splat(2.0) + )); + + assert_eq!(doc.parent_world_transform(root), Transform::IDENTITY); + assert!(doc.world_transform(999).is_none()); + assert_sound(&doc); +} + +#[test] +fn components_are_unique_per_key_and_set_replaces_in_place() { + let mut doc = SceneDoc::new("Components"); + let id = doc.add_entity("Entity", None); + + assert!(doc.add_component(id, common::sprite(None, None))); + assert!( + !doc.add_component(id, common::sprite(Some("a.png"), None)), + "duplicate sprite key must be rejected" + ); + assert!(doc.add_component(id, text_component("Hi", 12.0))); + assert!(doc.add_component(id, common::custom("game::Health", &[("current", "1.0")]))); + assert!( + !doc.add_component(id, common::custom("game::Health", &[("current", "2.0")])), + "custom components are keyed by type path" + ); + assert!(doc.add_component(id, common::custom("game::Mana", &[]))); + + let node = doc.entity(id).unwrap(); + assert_eq!( + node.components.iter().map(|c| c.key()).collect::>(), + vec![ + "sprite", + "text2d", + "custom:game::Health", + "custom:game::Mana" + ] + ); + + let replacement = common::sprite(Some("textures/hero.png"), Some(Vec2::new(3.0, 4.0))); + assert!(doc.set_component(id, replacement.clone())); + assert_eq!(doc.entity(id).unwrap().components.len(), 4); + assert_eq!( + doc.entity(id).unwrap().component("sprite"), + Some(&replacement) + ); + assert_eq!( + doc.entity(id).unwrap().component("text2d"), + Some(&text_component("Hi", 12.0)), + "setting a component must not reorder or drop others" + ); + + let removed = doc.remove_component(id, 1); + assert!(matches!(removed, Some(ComponentData::Text2d(_)))); + assert!(!doc.entity(id).unwrap().has_component("text2d")); + assert!(doc.remove_component(id, 99).is_none()); + assert!(doc.remove_component(999, 0).is_none()); + assert!(!doc.set_component(999, ComponentData::Camera2d)); + assert!(!doc.add_component(999, ComponentData::Camera2d)); + assert!(doc.entity(id).unwrap().component("missing").is_none()); + assert_sound(&doc); +} + +#[test] +fn toggle_hidden_cycles_visibility_and_preserves_other_components() { + let mut doc = SceneDoc::new("Visibility"); + let id = doc.add_entity("Entity", None); + doc.add_component(id, common::sprite(None, None)); + assert_eq!( + doc.entity(id).unwrap().visibility(), + VisibilityMode::Inherited + ); + assert!(!doc.entity(id).unwrap().is_hidden()); + + assert!(doc.toggle_hidden(id)); + assert!(doc.entity(id).unwrap().is_hidden()); + assert_eq!( + doc.entity(id).unwrap().component("sprite"), + Some(&common::sprite(None, None)) + ); + assert_eq!( + doc.entity(id) + .unwrap() + .components + .iter() + .filter(|c| c.key() == "visibility") + .count(), + 1 + ); + + assert!(doc.toggle_hidden(id)); + assert!(!doc.entity(id).unwrap().is_hidden()); + assert_eq!( + doc.entity(id).unwrap().visibility(), + VisibilityMode::Inherited + ); + assert!(!doc.entity(id).unwrap().has_component("visibility")); + assert!(doc.entity(id).unwrap().has_component("sprite")); + + // A visible marker is replaced by a hidden one, never duplicated. + doc.set_component(id, ComponentData::Visibility(VisibilityMode::Visible)); + assert!(doc.toggle_hidden(id)); + assert!(doc.entity(id).unwrap().is_hidden()); + assert_eq!( + doc.entity(id) + .unwrap() + .components + .iter() + .filter(|c| c.key() == "visibility") + .count(), + 1 + ); + assert!(!doc.toggle_hidden(999)); + assert_sound(&doc); +} + +#[test] +fn bounds_2d_covers_world_space_sprites_and_text() { + let mut doc = SceneDoc::new("Bounds"); + let root = doc.add_entity("Root", None); + doc.entity_mut(root).unwrap().transform = Transform::from_xyz(10.0, 20.0, 0.0); + doc.add_component(root, common::sprite(None, Some(Vec2::new(4.0, 2.0)))); + + let child = doc.add_entity("Child", Some(root)); + doc.entity_mut(child).unwrap().transform = + Transform::from_xyz(0.0, 10.0, 0.0).with_scale(Vec3::splat(2.0)); + doc.add_component(child, common::sprite(None, Some(Vec2::new(2.0, 2.0)))); + + let bounds = doc.bounds_2d(); + assert!( + common::approx_vec2(bounds.min, Vec2::new(8.0, 19.0)), + "{bounds:?}" + ); + assert!( + common::approx_vec2(bounds.max, Vec2::new(12.0, 32.0)), + "{bounds:?}" + ); + + let mut text_doc = SceneDoc::new("Text Bounds"); + let text = text_doc.add_entity("Text", None); + text_doc.add_component(text, text_component("abc", 10.0)); + let text_bounds = text_doc.bounds_2d(); + // 3 chars * 10px * 0.55 = 16.5 wide (text uses estimated glyph extents). + assert!(common::approx_vec2(text_bounds.min, Vec2::new(-8.25, -7.0))); + assert!(common::approx_vec2(text_bounds.max, Vec2::new(8.25, 7.0))); + + let empty = SceneDoc::new("Empty").bounds_2d(); + assert_eq!( + empty, + Bounds2::from_center_size(Vec2::ZERO, Vec2::splat(10.0)) + ); + let mut camera_only = SceneDoc::new("Camera Only"); + let camera = camera_only.add_entity("Camera", None); + camera_only.add_component(camera, ComponentData::Camera2d); + assert_eq!( + camera_only.bounds_2d(), + Bounds2::from_center_size(Vec2::ZERO, Vec2::splat(10.0)) + ); + assert_sound(&doc); +} + +#[test] +fn stats_find_by_name_and_rename_follow_the_document() { + let mut doc = SceneDoc::new("Stats"); + let camera = doc.add_entity("Camera", None); + doc.add_component(camera, ComponentData::Camera2d); + let sprite = doc.add_entity("Sprite", None); + doc.add_component(sprite, common::sprite(Some("a.png"), None)); + let mesh = doc.add_entity("Mesh", None); + doc.add_component( + mesh, + common::primitive_mesh(PrimitiveShape::Cuboid { size: Vec3::ONE }), + ); + let light = doc.add_entity("Sun", None); + doc.add_component( + light, + ComponentData::DirectionalLight(bevy_forge_core::DirectionalLightData::default()), + ); + let custom = doc.add_entity("Marker", None); + doc.add_component(custom, common::custom("game::Marker", &[])); + + let stats = doc.stats(); + assert_eq!(stats.entities, 5); + assert_eq!(stats.roots, 5); + assert_eq!(stats.cameras, 1); + assert_eq!(stats.sprites, 1); + assert_eq!(stats.meshes, 1); + assert_eq!(stats.lights, 1); + assert_eq!(stats.custom_components, 1); + + assert_eq!(doc.find_by_name("Mesh"), Some(mesh)); + assert_eq!(doc.find_by_name("missing"), None); + assert!(doc.rename(mesh, "Mesh Renamed")); + assert_eq!(doc.find_by_name("Mesh"), None); + assert_eq!(doc.find_by_name("Mesh Renamed"), Some(mesh)); + assert!(!doc.rename(999, "Nope")); + assert_sound(&doc); +} + +#[test] +fn check_integrity_detects_inconsistent_forests() { + let mut doc = SceneDoc::new("Corrupt"); + let a = doc.add_entity("A", None); + let b = doc.add_entity("B", Some(a)); + let c = doc.add_entity("C", Some(b)); + + assert!(doc.check_integrity().is_empty()); + doc.roots.push(999); + doc.entity_mut(a).unwrap().children.push(998); + doc.entity_mut(b).unwrap().parent = Some(404); + doc.entity_mut(c).unwrap().id = 77; + + let problems = doc.check_integrity(); + assert!(!problems.is_empty()); + assert!( + problems + .iter() + .any(|problem| problem.contains("node id mismatch")), + "{problems:?}" + ); + assert!( + problems + .iter() + .any(|problem| problem.contains("does not exist")), + "{problems:?}" + ); + assert!( + problems + .iter() + .any(|problem| problem.contains("wrong parent")), + "{problems:?}" + ); +} diff --git a/crates/bevy_forge_core/tests/persistence.rs b/crates/bevy_forge_core/tests/persistence.rs new file mode 100644 index 0000000..079f271 --- /dev/null +++ b/crates/bevy_forge_core/tests/persistence.rs @@ -0,0 +1,582 @@ +//! Public-API integration tests for project persistence and asset discovery. + +mod common; + +use std::collections::BTreeSet; + +use bevy_forge_core::{ + components::{ComponentData, Mesh3dData, MeshSource, PrimitiveShape, VisibilityMode}, + math::{Color, Transform, Vec2, Vec3}, + project::{find_project_root, SCENE_EXTENSION}, + samples, validate_scene, AssetIndex, AssetKind, Project, ProjectError, SceneDoc, + FORMAT_VERSION, +}; + +fn sample_document() -> SceneDoc { + let mut doc = SceneDoc::new("Round Trip"); + let root = doc.add_entity("Root", None); + doc.entity_mut(root).unwrap().transform = + Transform::from_xyz(1.5, -2.25, 3.75).with_rotation(Vec3::new(10.0, 20.0, 30.0)); + doc.entity_mut(root).unwrap().locked = true; + + let sprite = doc.add_entity("Sprite", Some(root)); + doc.entity_mut(sprite).unwrap().transform = Transform::from_xyz(0.0, 4.0, 1.0); + doc.add_component( + sprite, + ComponentData::Sprite(bevy_forge_core::SpriteData { + image: Some("textures/player.png".into()), + color: Color::srgba(0.25, 0.5, 0.75, 1.0), + custom_size: Some(Vec2::new(64.0, 96.0)), + flip_x: true, + flip_y: false, + }), + ); + doc.add_component(sprite, ComponentData::Visibility(VisibilityMode::Hidden)); + doc.add_component( + sprite, + common::custom("game::Health", &[("current", "10.0"), ("max", "Vec3::X")]), + ); + + let camera = doc.add_entity("Camera", None); + doc.add_component(camera, ComponentData::Camera2d); + + let mesh = doc.add_entity("Mesh", None); + doc.add_component( + mesh, + ComponentData::Mesh3d(Mesh3dData { + mesh: MeshSource::Primitive(PrimitiveShape::Cylinder { + radius: 0.5, + height: 2.0, + }), + material: bevy_forge_core::MaterialData { + base_color: Color::srgb(0.2, 0.3, 0.4), + metallic: 0.6, + perceptual_roughness: 0.35, + unlit: true, + }, + }), + ); + + let light = doc.add_entity("Light", None); + doc.add_component( + light, + ComponentData::PointLight(bevy_forge_core::PointLightData { + color: Color::srgb(1.0, 0.9, 0.8), + intensity: 450.0, + range: 12.0, + shadows: false, + }), + ); + + let text = doc.add_entity("Text", None); + doc.add_component( + text, + ComponentData::Text2d(bevy_forge_core::Text2dData { + text: "Line \"one\"\nLine two".into(), + font_size: 18.0, + color: Color::WHITE, + }), + ); + doc +} + +#[test] +fn opens_minimal_bevy_project_and_reports_metadata() { + let dir = tempfile::tempdir().unwrap(); + let project = common::create_project(dir.path(), "demo-project"); + let canonical = dir.path().canonicalize().unwrap(); + + assert_eq!(project.root, canonical); + assert_eq!(project.name, "demo-project"); + assert_eq!(project.bevy_version.as_deref(), Some("0.20")); + // No assets directory: the project root doubles as the Bevy assets dir. + assert_eq!(project.assets_dir, canonical); + assert_eq!(project.scenes_dir(), canonical.join("scenes")); + assert_eq!(project.forge_dir(), canonical.join(".forge")); + + std::fs::create_dir_all(canonical.join("assets")).unwrap(); + let with_assets = Project::open(dir.path()).unwrap(); + assert_eq!(with_assets.assets_dir, canonical.join("assets")); +} + +#[test] +fn detects_bevy_dependencies_in_tables_workspaces_and_dev_dependencies() { + let cases: [(&str, &str, Option<&str>); 4] = [ + ( + "string", + "[package]\nname = \"string-dep\"\nversion = \"0.1.0\"\nedition = \"2021\"\n\n[dependencies]\nbevy = \"0.20\"\n", + Some("string-dep"), + ), + ( + "table", + "[package]\nname = \"table-dep\"\nversion = \"0.1.0\"\nedition = \"2021\"\n\n[dependencies.bevy]\nversion = \"0.20\"\nfeatures = [\"2d\"]\n", + Some("table-dep"), + ), + ( + "workspace", + "[workspace]\nmembers = []\n\n[workspace.dependencies]\nbevy = \"0.20\"\n", + None, + ), + ( + "dev", + "[package]\nname = \"dev-dep\"\nversion = \"0.1.0\"\nedition = \"2021\"\n\n[dev-dependencies]\nbevy = \"0.20\"\n", + Some("dev-dep"), + ), + ]; + + for (label, manifest, expected_name) in cases { + let dir = tempfile::tempdir().unwrap(); + common::write_manifest(dir.path(), manifest); + let project = Project::open(dir.path()) + .unwrap_or_else(|error| panic!("{label} manifest should open: {error}")); + assert_eq!( + project.bevy_version.as_deref(), + Some("0.20"), + "{label} dependency" + ); + if let Some(name) = expected_name { + assert_eq!(project.name, name, "{label} package name"); + } else { + assert_eq!( + project.name, + project.root.file_name().unwrap().to_string_lossy(), + "workspace-only manifest falls back to the directory name" + ); + } + } +} + +#[test] +fn rejects_non_projects_malformed_manifests_and_missing_roots() { + let empty = tempfile::tempdir().unwrap(); + assert!(matches!( + Project::open(empty.path()), + Err(ProjectError::NotAProject(_)) + )); + + let no_bevy = tempfile::tempdir().unwrap(); + common::write_manifest(no_bevy.path(), "[package]\nname = \"plain\"\n"); + assert!(matches!( + Project::open(no_bevy.path()), + Err(ProjectError::NotAProject(_)) + )); + + let malformed = tempfile::tempdir().unwrap(); + common::write_manifest(malformed.path(), "[dependencies\nbevy = \"0.20\"\n"); + assert!(matches!( + Project::open(malformed.path()), + Err(ProjectError::Manifest(_)) + )); + + let missing = tempfile::tempdir().unwrap().path().join("does-not-exist"); + assert!(matches!( + Project::open(&missing), + Err(ProjectError::Io { .. }) + )); +} + +#[test] +fn find_project_root_walks_up_from_directories_and_files() { + let (_dir, project) = common::temp_project("find-root"); + let nested = project.root.join("src/game/deep"); + std::fs::create_dir_all(&nested).unwrap(); + assert_eq!(find_project_root(&nested), Some(project.root.clone())); + + let scene = common::touch( + &project.root, + "scenes/level.forge.ron", + b"(format_version: 1)", + ); + assert_eq!(find_project_root(&scene), Some(project.root.clone())); +} + +#[test] +fn save_and_load_round_trip_preserves_ids_order_components_and_custom_data() { + let (_dir, project) = common::temp_project("round-trip"); + let doc = sample_document(); + let expected_path = project.scenes_dir().join("round-trip.forge.ron"); + + let saved = project.save_scene(&doc, None).unwrap(); + assert_eq!(saved, expected_path); + assert!(saved.is_file()); + + let loaded = project.load_scene(&saved).unwrap(); + assert_eq!(loaded, doc); + assert_eq!(loaded.name, "Round Trip"); + assert_eq!( + loaded.entities.keys().copied().collect::>(), + doc.entities.keys().copied().collect::>(), + "entity insertion order must survive persistence" + ); + assert_eq!(loaded.roots, doc.roots); + assert_eq!(loaded.next_id, doc.next_id); + assert!(loaded.entity(1).unwrap().locked); + + let custom = loaded + .entities + .values() + .find_map(|node| match node.component("custom:game::Health") { + Some(ComponentData::Custom(custom)) => Some(custom), + _ => None, + }) + .expect("custom component survives the round trip"); + assert_eq!(custom.type_path, "game::Health"); + assert_eq!(custom.fields.len(), 2); + assert_eq!(custom.fields[1].name, "max"); + assert_eq!(custom.fields[1].expr, "Vec3::X"); + + // An explicit in-project path also round-trips, and the file is real RON. + let explicit = project.scenes_dir().join("explicit.forge.ron"); + project.save_scene(&doc, Some(&explicit)).unwrap(); + assert_eq!(project.load_scene(&explicit).unwrap(), doc); + let text = std::fs::read_to_string(&explicit).unwrap(); + assert!(text.contains("format_version: 1"), "{text}"); + assert!(text.contains("Round Trip")); +} + +#[test] +fn save_scene_rejects_paths_outside_the_project() { + let (_dir, project) = common::temp_project("containment"); + let mut doc = SceneDoc::new("Contained"); + doc.add_entity("Root", None); + + let outside_dir = tempfile::tempdir().unwrap(); + let outside = outside_dir.path().join("escape.forge.ron"); + match project.save_scene(&doc, Some(&outside)) { + Err(ProjectError::SceneOutsideProject(path)) => assert_eq!(path, outside), + other => panic!("expected SceneOutsideProject, got {other:?}"), + } + assert!(!outside.exists(), "a rejected save must write nothing"); + + // The core contract only requires containment: a path inside the project is + // accepted even when it is not under scenes/ (the editor uses this for autosave). + let inside_root = project.root.join("root-level.forge.ron"); + project.save_scene(&doc, Some(&inside_root)).unwrap(); + assert_eq!(project.load_scene(&inside_root).unwrap(), doc); +} + +#[test] +fn scene_paths_use_slugs_and_the_forge_ron_extension() { + let (_dir, project) = common::temp_project("scene-paths"); + assert_eq!(SCENE_EXTENSION, "forge.ron"); + + let scene = project.scene_path("My Level 01"); + assert_eq!(scene, project.scenes_dir().join("my-level-01.forge.ron")); + assert!(scene + .file_name() + .unwrap() + .to_string_lossy() + .ends_with(".forge.ron")); + + assert_eq!( + project.scene_path("***"), + project.scenes_dir().join("scene.forge.ron") + ); + + let mut doc = SceneDoc::new("Boss Room!"); + doc.add_entity("Root", None); + let saved = project.save_scene(&doc, None).unwrap(); + assert_eq!(saved, project.scenes_dir().join("boss-room.forge.ron")); +} + +#[test] +fn save_is_atomic_and_leaves_no_temporary_files() { + let (_dir, project) = common::temp_project("atomic"); + let mut doc = SceneDoc::new("Atomic"); + doc.add_entity("Root", None); + let path = project.scenes_dir().join("atomic.forge.ron"); + project.save_scene(&doc, Some(&path)).unwrap(); + + common::touch(&project.scenes_dir(), "keep.txt", b"unrelated"); + doc.rename(1, "Root Renamed"); + project.save_scene(&doc, Some(&path)).unwrap(); + + let loaded = project.load_scene(&path).unwrap(); + assert_eq!(loaded, doc, "second save atomically replaces the first"); + assert_eq!( + std::fs::read_to_string(project.scenes_dir().join("keep.txt")).unwrap(), + "unrelated", + "saving must not disturb unrelated files" + ); + + let names: Vec = std::fs::read_dir(project.scenes_dir()) + .unwrap() + .map(|entry| entry.unwrap().file_name().to_string_lossy().to_string()) + .collect(); + assert!(names.iter().any(|name| name == "atomic.forge.ron")); + assert!( + names.iter().all(|name| !name.contains(".tmp-")), + "temporary files leaked: {names:?}" + ); + assert!( + names + .iter() + .all(|name| name != "." && name != ".." && !name.starts_with('.')), + "hidden temporary files leaked: {names:?}" + ); +} + +#[test] +fn list_scenes_is_sorted_and_lists_only_direct_forge_scenes() { + let (_dir, project) = common::temp_project("list-scenes"); + let beta = SceneDoc::new("Beta"); + let mut alpha = SceneDoc::new("Alpha"); + alpha.add_entity("Root", None); + project + .save_scene(&beta, Some(&project.scenes_dir().join("beta.forge.ron"))) + .unwrap(); + project + .save_scene(&alpha, Some(&project.scenes_dir().join("alpha.forge.ron"))) + .unwrap(); + common::touch(&project.scenes_dir(), "notes.txt", b"not a scene"); + common::touch(&project.scenes_dir(), "other.ron", b"not a forge scene"); + common::touch(&project.scenes_dir(), "sub/nested.forge.ron", b"nested"); + + assert_eq!( + project.list_scenes(), + vec![ + project.scenes_dir().join("alpha.forge.ron"), + project.scenes_dir().join("beta.forge.ron") + ] + ); + + let (_empty_dir, empty_project) = common::temp_project("no-scenes"); + assert!(empty_project.list_scenes().is_empty()); +} + +#[test] +fn autosave_path_is_nested_under_forge_and_round_trips() { + let (_dir, project) = common::temp_project("autosave"); + let autosave = project.autosave_path(); + assert_eq!(autosave, project.forge_dir().join("autosave.forge.ron")); + assert_eq!(autosave, project.root.join(".forge/autosave.forge.ron")); + assert!(!autosave.exists()); + + let mut doc = SceneDoc::new("Autosaved"); + doc.add_entity("Root", None); + project.save_scene(&doc, Some(&autosave)).unwrap(); + assert!(project.forge_dir().is_dir()); + assert_eq!(project.load_scene(&autosave).unwrap(), doc); +} + +#[test] +fn load_scene_reports_errors_and_normalises_legacy_files() { + let (_dir, project) = common::temp_project("load-errors"); + let missing = project.scenes_dir().join("missing.forge.ron"); + assert!(matches!( + project.load_scene(&missing), + Err(ProjectError::Io { .. }) + )); + + let broken = common::touch( + &project.scenes_dir(), + "broken.forge.ron", + b"this is not ron", + ); + match project.load_scene(&broken) { + Err(ProjectError::SceneParse { path, .. }) => assert_eq!(path, broken), + other => panic!("expected SceneParse, got {other:?}"), + } + + let mut doc = SceneDoc::new("Legacy"); + doc.add_entity("Root", None); + let path = project.save_scene(&doc, None).unwrap(); + let text = std::fs::read_to_string(&path).unwrap(); + let legacy = text + .replacen("format_version: 1", "format_version: 0", 1) + .lines() + .filter(|line| !line.contains("locked:")) + .collect::>() + .join("\n") + + "\n"; + assert_ne!(legacy, text, "fixture must actually change"); + std::fs::write(&path, legacy).unwrap(); + + let loaded = project.load_scene(&path).unwrap(); + assert_eq!(loaded.format_version, FORMAT_VERSION); + assert_eq!(loaded.name, "Legacy"); + assert_eq!(loaded.entities.len(), 1); + assert!(!loaded.entity(1).unwrap().locked); +} + +#[test] +fn demo_project_loads_validates_cleanly_and_all_assets_resolve() { + let demo_dir = common::demo_project_dir(); + let project = Project::open(&demo_dir).expect("demo project opens"); + assert_eq!(project.name, "forge-demo"); + assert_eq!(project.bevy_version.as_deref(), Some("0.20")); + assert_eq!(project.assets_dir, demo_dir.join("assets")); + + let scenes = project.list_scenes(); + assert_eq!( + scenes, + vec![ + demo_dir.join("scenes/garden.forge.ron"), + demo_dir.join("scenes/platformer.forge.ron") + ] + ); + + let index = AssetIndex::scan(&project.assets_dir); + assert_eq!(index.error, None); + assert_eq!(index.images().len(), 6); + + for (path, expected_doc, expected_asset_count) in [ + ( + demo_dir.join("scenes/garden.forge.ron"), + samples::garden_demo(), + 0usize, + ), + ( + demo_dir.join("scenes/platformer.forge.ron"), + samples::platformer_demo(), + 6usize, + ), + ] { + let doc = project.load_scene(&path).expect("demo scene loads"); + assert_eq!(doc, expected_doc, "committed scene must match its builder"); + assert!( + doc.check_integrity().is_empty(), + "{} integrity: {:?}", + doc.name, + doc.check_integrity() + ); + + let report = validate_scene(&doc, Some(&index)); + assert_eq!(report.errors(), 0, "{}: {:?}", doc.name, report.issues); + assert_eq!(report.warnings(), 0, "{}: {:?}", doc.name, report.issues); + assert!(report.is_ok()); + + let mut referenced = BTreeSet::new(); + for entity in doc.entities.values() { + for component in entity.components.iter() { + for (field, asset_path) in component.asset_references() { + assert!(!field.is_empty()); + assert!(!asset_path.contains('\\')); + referenced.insert(asset_path.to_string()); + let indexed = index + .find(asset_path) + .unwrap_or_else(|| panic!("{} not indexed", asset_path)); + assert!(indexed.abs_path.is_file(), "{asset_path} missing on disk"); + assert_eq!(indexed.kind, AssetKind::Image); + let resolved = project.resolve_asset(asset_path); + assert!(resolved.is_file(), "{} does not resolve", asset_path); + assert_eq!(resolved, project.assets_dir.join(asset_path)); + } + } + } + assert_eq!( + referenced.len(), + expected_asset_count, + "{} asset references: {referenced:?}", + doc.name + ); + } +} + +#[test] +fn scan_demo_assets_indexes_sorted_forward_slash_image_paths() { + let index = AssetIndex::scan(&common::demo_assets_dir()); + assert_eq!(index.error, None); + assert_eq!(index.len(), 6); + + let paths: Vec<&str> = index.entries.iter().map(|e| e.path.as_str()).collect(); + assert_eq!( + paths, + vec![ + "textures/background.png", + "textures/coin.png", + "textures/crate.png", + "textures/hat.png", + "textures/player.png", + "textures/tile.png", + ] + ); + let mut sorted = paths.clone(); + sorted.sort_unstable(); + assert_eq!(paths, sorted); + + for entry in &index.entries { + assert_eq!(entry.kind, AssetKind::Image); + assert!(entry.size_bytes > 0, "{} has no contents", entry.path); + assert!(!entry.path.starts_with('/')); + assert!( + !entry.path.contains('\\'), + "{} uses backslashes", + entry.path + ); + assert!(entry.abs_path.is_file()); + assert_eq!(entry.abs_path, common::demo_assets_dir().join(&entry.path)); + assert!(entry.file_name().ends_with(".png")); + } +} + +#[test] +fn asset_search_kind_filters_and_metadata_behave_as_documented() { + let index = AssetIndex::scan(&common::demo_assets_dir()); + + assert_eq!(index.search("").len(), 6); + assert_eq!(index.search("textures").len(), 6); + let player = index.search("player"); + assert_eq!(player.len(), 1); + assert_eq!(player[0].path, "textures/player.png"); + assert_eq!( + index.search(" PLAYER ").len(), + 1, + "search trims and folds case" + ); + assert!(index.search("missing-texture").is_empty()); + + assert_eq!(index.by_kind(AssetKind::Image).len(), 6); + assert!(index.by_kind(AssetKind::Mesh).is_empty()); + assert_eq!(index.images().len(), 6); + assert!(index.find("textures/hero.png").is_none()); + assert_eq!( + index.find("textures/player.png").unwrap().file_name(), + "player.png" + ); + assert!(!index.is_empty()); +} + +#[test] +fn missing_assets_directory_is_an_empty_index_with_an_error() { + let temp = tempfile::tempdir().unwrap(); + let missing = temp.path().join("assets-does-not-exist"); + + let index = AssetIndex::scan(&missing); + assert!(index.is_empty()); + assert_eq!(index.len(), 0); + assert_eq!(index.scanned_dir, missing); + let error = index.error.as_deref().expect("missing dir must set error"); + assert!(error.contains("cannot read assets directory"), "{error}"); + assert!(error.contains("assets-does-not-exist"), "{error}"); +} + +#[test] +fn asset_paths_are_project_relative_and_skip_hidden_and_build_dirs() { + let dir = tempfile::tempdir().unwrap(); + let assets = dir.path().join("assets"); + common::touch(&assets, "textures/hero.png", b"png"); + common::touch(&assets, "audio/loop.ogg", b"ogg"); + common::touch(&assets, ".hidden.png", b"hidden"); + common::touch(&assets, "target/debug/cache.bin", b"cache"); + let project = common::create_project(dir.path(), "asset-paths"); + assert_eq!(project.assets_dir, project.root.join("assets")); + + let index = AssetIndex::scan(&project.assets_dir); + assert_eq!(index.error, None); + let paths: Vec<&str> = index.entries.iter().map(|e| e.path.as_str()).collect(); + assert_eq!(paths, vec!["audio/loop.ogg", "textures/hero.png"]); + assert_eq!(index.find("audio/loop.ogg").unwrap().kind, AssetKind::Audio); + + let resolved = project.resolve_asset("textures/hero.png"); + assert_eq!(resolved, project.assets_dir.join("textures/hero.png")); + assert!(resolved.is_file()); + assert_eq!( + project.asset_path_for(&resolved).as_deref(), + Some("textures/hero.png") + ); + assert_eq!( + project.asset_path_for(&project.root.join("Cargo.toml")), + None + ); +} diff --git a/crates/bevy_forge_core/tests/validation.rs b/crates/bevy_forge_core/tests/validation.rs new file mode 100644 index 0000000..e369dfa --- /dev/null +++ b/crates/bevy_forge_core/tests/validation.rs @@ -0,0 +1,538 @@ +//! Public-API integration tests for scene validation (contracts §6). + +mod common; + +use bevy_forge_core::{ + components::{ + ComponentData, CustomComponent, CustomField, DirectionalLightData, MaterialData, + Mesh3dData, MeshSource, PointLightData, PrimitiveShape, Text2dData, VisibilityMode, + }, + math::{Color, Transform, Vec2, Vec3}, + validate_scene, AssetIndex, EntityId, Issue, SceneDoc, Severity, ValidationReport, +}; + +fn doc_with(name: &str, component: ComponentData) -> (SceneDoc, EntityId) { + let mut doc = SceneDoc::new("Validation"); + let id = doc.add_entity(name, None); + assert!(doc.add_component(id, component), "component was rejected"); + (doc, id) +} + +fn mesh_asset(path: &str) -> ComponentData { + ComponentData::Mesh3d(Mesh3dData { + mesh: MeshSource::Asset(path.to_string()), + material: MaterialData::default(), + }) +} + +fn has_issue(report: &ValidationReport, entity: EntityId, field: &str, severity: Severity) -> bool { + report.issues.iter().any(|issue| { + issue.entity == Some(entity) + && issue.field.as_deref() == Some(field) + && issue.severity == severity + }) +} + +fn assert_issue(report: &ValidationReport, entity: EntityId, field: &str, severity: Severity) { + assert!( + has_issue(report, entity, field, severity), + "missing {severity:?} issue for entity {entity} field {field}; issues: {:#?}", + report.issues + ); +} + +fn has_message(report: &ValidationReport, fragment: &str) -> bool { + report + .issues + .iter() + .any(|issue| issue.message.contains(fragment)) +} + +#[test] +fn invalid_asset_references_are_errors_with_entity_and_field_context() { + let cases = [ + ("/textures/hero.png", "absolute"), + ("textures/../hero.png", "`..`"), + ("textures\\hero.png", "backslash"), + ("", "empty"), + ]; + for (path, message_fragment) in cases { + let (doc, id) = doc_with("Sprite", common::sprite(Some(path), Some(Vec2::splat(8.0)))); + let report = validate_scene(&doc, None); + assert!(!report.is_ok(), "{path:?} should be invalid"); + assert_eq!(report.errors(), 1, "{path:?}: {:?}", report.issues); + let issue = &report.issues[0]; + assert_eq!(issue.severity, Severity::Error); + assert_eq!(issue.entity, Some(id)); + assert_eq!(issue.field.as_deref(), Some("sprite.image")); + assert!( + issue.message.contains(message_fragment), + "{path:?}: {}", + issue.message + ); + } +} + +#[test] +fn missing_asset_checks_require_a_readable_asset_index() { + let path = "textures/ghost.png"; + let (doc, id) = doc_with("Sprite", common::sprite(Some(path), Some(Vec2::splat(8.0)))); + + let without_index = validate_scene(&doc, None); + assert!(without_index.is_ok()); + assert!(!has_issue( + &without_index, + id, + "sprite.image", + Severity::Error + )); + + let empty_assets = tempfile::tempdir().unwrap(); + let empty_index = AssetIndex::scan(empty_assets.path()); + assert_eq!(empty_index.error, None); + let with_empty_index = validate_scene(&doc, Some(&empty_index)); + assert_issue(&with_empty_index, id, "sprite.image", Severity::Error); + assert!(has_message( + &with_empty_index, + "not found in the project assets" + )); + + // An index whose scan failed cannot prove an asset is missing, so the + // missing-file error is suppressed (kind checks still apply). + let missing_assets = tempfile::tempdir().unwrap().path().join("not-there"); + let broken_index = AssetIndex::scan(&missing_assets); + assert!(broken_index.error.is_some()); + let with_broken_index = validate_scene(&doc, Some(&broken_index)); + assert!(with_broken_index.is_ok(), "{:#?}", with_broken_index.issues); + assert!(!has_issue( + &with_broken_index, + id, + "sprite.image", + Severity::Error + )); +} + +#[test] +fn asset_kind_mismatches_are_errors_even_without_an_index() { + let mut doc = SceneDoc::new("Kind Mismatch"); + let sprite = doc.add_entity("Sprite", None); + doc.add_component( + sprite, + common::sprite(Some("audio/loop.ogg"), Some(Vec2::splat(8.0))), + ); + let mesh = doc.add_entity("Mesh", None); + doc.add_component(mesh, mesh_asset("textures/hero.png")); + + let report = validate_scene(&doc, None); + assert_eq!(report.errors(), 2, "{:#?}", report.issues); + assert_issue(&report, sprite, "sprite.image", Severity::Error); + assert_issue(&report, mesh, "mesh3d.mesh", Severity::Error); + assert!(has_message(&report, "is not an image")); + assert!(has_message(&report, "is not a mesh")); +} + +#[test] +fn numeric_component_and_transform_errors_carry_context() { + let mut doc = SceneDoc::new("Numeric Errors"); + + let sprite = doc.add_entity("Sprite", None); + doc.add_component(sprite, common::sprite(None, Some(Vec2::new(0.0, 2.0)))); + + let text = doc.add_entity("Text", None); + doc.add_component( + text, + ComponentData::Text2d(Text2dData { + text: "Hello".into(), + font_size: 0.0, + color: Color::WHITE, + }), + ); + + let light = doc.add_entity("Light", None); + doc.add_component( + light, + ComponentData::PointLight(PointLightData { + color: Color::WHITE, + intensity: -1.0, + range: 0.0, + shadows: false, + }), + ); + + let sun = doc.add_entity("Sun", None); + doc.add_component( + sun, + ComponentData::DirectionalLight(DirectionalLightData { + color: Color::WHITE, + illuminance: -5.0, + shadows: false, + }), + ); + + let mesh = doc.add_entity("Mesh", None); + doc.add_component( + mesh, + ComponentData::Mesh3d(Mesh3dData { + mesh: MeshSource::Primitive(PrimitiveShape::Cuboid { size: Vec3::ONE }), + material: MaterialData { + base_color: Color::WHITE, + metallic: f32::NAN, + perceptual_roughness: 0.5, + unlit: false, + }, + }), + ); + + let broken = doc.add_entity("Broken Transform", None); + doc.entity_mut(broken).unwrap().transform = Transform { + translation: Vec3::new(f32::NAN, 0.0, 0.0), + rotation: Vec3::new(0.0, f32::INFINITY, 0.0), + scale: Vec3::new(1.0, 1.0, f32::NAN), + }; + + let report = validate_scene(&doc, None); + assert_eq!(report.errors(), 9, "{:#?}", report.issues); + assert_issue(&report, sprite, "sprite.custom_size", Severity::Error); + assert_issue(&report, text, "text2d.font_size", Severity::Error); + assert_issue(&report, light, "point_light.intensity", Severity::Error); + assert_issue(&report, light, "point_light.range", Severity::Error); + assert_issue( + &report, + sun, + "directional_light.illuminance", + Severity::Error, + ); + assert_issue(&report, mesh, "mesh3d.material.metallic", Severity::Error); + assert_issue(&report, broken, "transform.translation", Severity::Error); + assert_issue(&report, broken, "transform.rotation", Severity::Error); + assert_issue(&report, broken, "transform.scale", Severity::Error); +} + +#[test] +fn custom_component_errors_have_documented_field_paths() { + let (mut doc, id) = doc_with("Custom", common::custom("not a path", &[])); + let report = validate_scene(&doc, None); + assert_eq!(report.errors(), 1, "{:#?}", report.issues); + assert_issue(&report, id, "custom.type_path", Severity::Error); + assert!(has_message(&report, "not a valid Rust type path")); + + doc.set_component(id, common::custom("game::Health", &[("2bad", "1.0")])); + let report = validate_scene(&doc, None); + assert_issue(&report, id, "custom.Health.2bad", Severity::Error); + assert!(has_message(&report, "not a valid Rust identifier")); + + doc.set_component(id, common::custom("game::Health", &[("current", " ")])); + let report = validate_scene(&doc, None); + assert_issue(&report, id, "custom.Health.current", Severity::Error); + assert!(has_message(&report, "empty expression")); +} + +#[test] +fn warnings_cover_names_scale_materials_sprites_and_text() { + let mut doc = SceneDoc::new("Warnings"); + + let empty_name = doc.add_entity("", None); + doc.add_component(empty_name, ComponentData::Camera2d); + + let duplicate_one = doc.add_entity("Same", None); + doc.add_component(duplicate_one, ComponentData::Camera2d); + let duplicate_two = doc.add_entity("Same", None); + doc.add_component(duplicate_two, ComponentData::Camera2d); + + let blank_name = doc.add_entity(" ", None); + doc.add_component(blank_name, ComponentData::Camera2d); + + let zero_scale = doc.add_entity("Zero Scale", None); + doc.add_component(zero_scale, ComponentData::Camera2d); + doc.entity_mut(zero_scale).unwrap().transform.scale = Vec3::new(1.0, 0.0, 1.0); + + let mesh = doc.add_entity("Mesh", None); + doc.add_component( + mesh, + ComponentData::Mesh3d(Mesh3dData { + mesh: MeshSource::Primitive(PrimitiveShape::Sphere { radius: 1.0 }), + material: MaterialData { + base_color: Color::WHITE, + metallic: 1.5, + perceptual_roughness: -0.1, + unlit: false, + }, + }), + ); + + let sprite = doc.add_entity("Sprite", None); + doc.add_component(sprite, common::sprite(None, None)); + + let text = doc.add_entity("Text", None); + doc.add_component( + text, + ComponentData::Text2d(Text2dData { + text: " ".into(), + font_size: 12.0, + color: Color::WHITE, + }), + ); + + let report = validate_scene(&doc, None); + assert!(report.is_ok(), "{:#?}", report.issues); + assert_eq!(report.errors(), 0); + assert_eq!(report.warnings(), 9, "{:#?}", report.issues); + + assert_issue(&report, empty_name, "name", Severity::Warning); + assert_issue(&report, blank_name, "name", Severity::Warning); + assert_issue(&report, duplicate_one, "name", Severity::Warning); + assert_issue(&report, duplicate_two, "name", Severity::Warning); + assert_issue(&report, zero_scale, "transform.scale", Severity::Warning); + assert_issue(&report, mesh, "mesh3d.material.metallic", Severity::Warning); + assert_issue( + &report, + mesh, + "mesh3d.material.perceptual_roughness", + Severity::Warning, + ); + assert_issue(&report, sprite, "sprite", Severity::Warning); + assert_issue(&report, text, "text2d.text", Severity::Warning); + + assert!(report.by_entity(duplicate_one).len() == 1); + assert!(has_message(&report, "not unique")); + assert!(has_message(&report, "no image and no custom_size")); +} + +#[test] +fn camera_and_light_rules_produce_infos_and_a_mix_warning() { + let mut doc = SceneDoc::new("Cameras"); + let mesh = doc.add_entity("Mesh", None); + doc.add_component( + mesh, + common::primitive_mesh(PrimitiveShape::Cuboid { size: Vec3::ONE }), + ); + + let report = validate_scene(&doc, None); + assert!(report.is_ok()); + assert!(has_message(&report, "scene has no camera")); + assert!(has_message(&report, "meshes but no light")); + assert_eq!(report.count(Severity::Info), 2, "{:#?}", report.issues); + + let camera = doc.add_entity("Camera 3D", None); + doc.add_component(camera, ComponentData::Camera3d); + let light = doc.add_entity("Light", None); + doc.add_component(light, ComponentData::PointLight(PointLightData::default())); + let report = validate_scene(&doc, None); + assert!(report.is_ok()); + assert!(!has_message(&report, "scene has no camera")); + assert!(!has_message(&report, "meshes but no light")); + assert_eq!(report.warnings(), 0); + assert_eq!(report.count(Severity::Info), 0, "{:#?}", report.issues); + + let second_camera = doc.add_entity("Camera 2D", None); + doc.add_component(second_camera, ComponentData::Camera2d); + let report = validate_scene(&doc, None); + assert!(report.is_ok()); + assert_eq!(report.warnings(), 1); + assert!(has_message(&report, "mixes 2D and 3D cameras")); + assert_eq!(report.count(Severity::Info), 1); + assert!(has_message(&report, "scene has 2 cameras")); + + let empty = SceneDoc::new("Empty"); + let empty_report = validate_scene(&empty, None); + assert!(empty_report.is_ok()); + assert_eq!(empty_report.errors(), 0); + assert_eq!(empty_report.warnings(), 0); + assert_eq!(empty_report.count(Severity::Info), 1); +} + +#[test] +fn entity_with_only_a_transform_is_reported_as_info() { + let mut doc = SceneDoc::new("Bare"); + let id = doc.add_entity("Bare Entity", None); + let report = validate_scene(&doc, None); + assert!(report.is_ok()); + let issue = report + .issues + .iter() + .find(|issue| issue.entity == Some(id)) + .expect("entity-specific issue"); + assert_eq!(issue.severity, Severity::Info); + assert!(issue.message.contains("no components")); + assert_eq!(issue.field, None); + assert_eq!(doc.entity(id).unwrap().components.len(), 0); +} + +#[test] +fn corrupt_forests_do_not_panic_and_report_errors() { + let mut doc = SceneDoc::new("Corrupt"); + let a = doc.add_entity("A", None); + let b = doc.add_entity("B", Some(a)); + let c = doc.add_entity("C", Some(b)); + + // Self-parent, a parent cycle, a missing child, a missing root, a duplicate + // root reference, and a node id that disagrees with its map key. + doc.entity_mut(a).unwrap().parent = Some(a); + doc.entity_mut(b).unwrap().parent = Some(c); + doc.entity_mut(c).unwrap().parent = Some(b); + doc.entity_mut(a).unwrap().children.push(999); + doc.roots.push(999); + doc.roots.push(a); + doc.entity_mut(c).unwrap().id = 77; + + let report = validate_scene(&doc, None); + assert!(!report.is_ok()); + assert!(report.errors() > 0); + assert!( + report + .issues + .iter() + .any(|issue| issue.field.as_deref() == Some("parent")), + "{:#?}", + report.issues + ); + assert!( + has_message(&report, "cycle") || has_message(&report, "referenced"), + "{:#?}", + report.issues + ); + + let again = validate_scene(&doc, None); + assert_eq!(report, again, "validation must be deterministic"); +} + +#[test] +fn deep_parent_chains_are_capped_without_looping_or_panicking() { + let mut doc = SceneDoc::new("Deep"); + let mut parent = None; + for index in 0..300 { + parent = Some(doc.add_entity(format!("Node {index}"), parent)); + } + assert!(doc.check_integrity().is_empty()); + + let report = validate_scene(&doc, None); + assert_eq!(report.errors(), 0, "{:#?}", report.issues); + assert_eq!(report.warnings(), 0); + assert!(report.count(Severity::Info) >= 300); + assert!( + !report + .issues + .iter() + .any(|issue| issue.message.contains("cycle")), + "the depth cap must not fabricate cycles" + ); +} + +#[test] +fn issues_are_sorted_by_severity_entity_and_message() { + let mut doc = SceneDoc::new("Sorting"); + let empty_name = doc.add_entity("", None); + doc.add_component(empty_name, ComponentData::Camera2d); + let point = doc.add_entity("Point", None); + doc.add_component( + point, + ComponentData::PointLight(PointLightData { + color: Color::WHITE, + intensity: -1.0, + range: 20.0, + shadows: false, + }), + ); + let bad_custom = doc.add_entity("Custom", None); + doc.add_component(bad_custom, common::custom("not a path", &[])); + let bad_transform = doc.add_entity("Transform", None); + doc.add_component(bad_transform, ComponentData::Camera2d); + doc.entity_mut(bad_transform) + .unwrap() + .transform + .translation + .x = f32::NAN; + let sprite = doc.add_entity("Sprite", None); + doc.add_component(sprite, common::sprite(None, None)); + + let report = validate_scene(&doc, None); + assert!(report.errors() >= 3); + assert!(report.warnings() >= 2); + + for pair in report.issues.windows(2) { + let (left, right) = (&pair[0], &pair[1]); + assert!( + left.severity >= right.severity, + "severity order: {left:?} before {right:?}" + ); + if left.severity == right.severity { + assert!( + left.entity <= right.entity, + "entity order: {left:?} before {right:?}" + ); + if left.entity == right.entity { + assert!( + left.message <= right.message, + "message order: {left:?} before {right:?}" + ); + } + } + } + + let again = validate_scene(&doc, None); + assert_eq!(report, again); +} + +#[test] +fn report_helpers_and_severity_ordering_are_public_contract() { + assert!(Severity::Info < Severity::Warning); + assert!(Severity::Warning < Severity::Error); + assert_eq!(Severity::Info.label(), "info"); + assert_eq!(Severity::Warning.label(), "warning"); + assert_eq!(Severity::Error.label(), "error"); + + let report = ValidationReport { + issues: vec![ + Issue::info("info without entity"), + Issue::warning("warning for two").with_entity(2), + Issue::error("error for one") + .with_entity(1) + .with_field("name"), + Issue::error("scene-wide error"), + ], + }; + assert!(!report.is_ok()); + assert_eq!(report.errors(), 2); + assert_eq!(report.warnings(), 1); + assert_eq!(report.count(Severity::Info), 1); + assert_eq!(report.by_entity(1).len(), 1); + assert_eq!(report.by_entity(1)[0].field.as_deref(), Some("name")); + assert_eq!(report.by_entity(2).len(), 1); + assert!(report.by_entity(3).is_empty()); + + let sorted = report.sorted(); + assert_eq!(sorted.len(), report.issues.len()); + assert_eq!(sorted[0].severity, Severity::Error); + assert_eq!(sorted[1].severity, Severity::Error); + assert_eq!(sorted[2].severity, Severity::Warning); + assert_eq!(sorted[3].severity, Severity::Info); + + let clean = ValidationReport::default(); + assert!(clean.is_ok()); + assert_eq!(clean.errors(), 0); + assert_eq!(clean.warnings(), 0); + assert!(clean.sorted().is_empty()); +} + +// Keep these public constructors referenced so the test module also documents +// that custom data is authored through the public component types. +#[test] +fn custom_component_public_constructors_are_inspectable() { + let custom = CustomComponent { + type_path: "game::Health".into(), + fields: vec![CustomField { + name: "current".into(), + expr: "10.0".into(), + }], + }; + assert!(custom.is_valid()); + assert_eq!(custom.short_name(), "Health"); + let component = ComponentData::Custom(custom); + assert_eq!(component.key(), "custom:game::Health"); + assert!(component.is_builtin() == false); + assert_eq!(component.asset_references().len(), 0); + assert_eq!( + ComponentData::Visibility(VisibilityMode::Hidden).key(), + "visibility" + ); +} diff --git a/crates/bevy_forge_editor/Cargo.toml b/crates/bevy_forge_editor/Cargo.toml new file mode 100644 index 0000000..18ec816 --- /dev/null +++ b/crates/bevy_forge_editor/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "bevy_forge_editor" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +authors.workspace = true +description = "Bevy Forge — a visual scene editor for the Bevy engine (Bevy 0.20 BSN)" + +[[bin]] +name = "bevy-forge" +path = "src/main.rs" + +[dependencies] +bevy_forge_core = { path = "../bevy_forge_core" } +eframe.workspace = true +egui.workspace = true +image.workspace = true + +[dev-dependencies] +tempfile.workspace = true diff --git a/crates/bevy_forge_editor/src/action.rs b/crates/bevy_forge_editor/src/action.rs new file mode 100644 index 0000000..581587d --- /dev/null +++ b/crates/bevy_forge_editor/src/action.rs @@ -0,0 +1,40 @@ +//! Deferred editor actions emitted by panels and handled in one place by the app. + +use bevy_forge_core::{ExportFormat, ProjectTemplate}; +use std::path::PathBuf; + +use crate::state::ViewMode; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Action { + NewScene(ProjectTemplate), + OpenProjectDialog, + OpenProject(PathBuf), + OpenSceneDialog, + OpenScene(PathBuf), + Save, + OpenSaveAsDialog, + SaveSceneTo(PathBuf), + OpenExportDialog(ExportFormat), + ExportTo { format: ExportFormat, path: PathBuf }, + CopyExport(ExportFormat), + Undo, + Redo, + DeleteSelection, + DuplicateSelection, + FocusSelection, + SetViewMode(ViewMode), + ToggleViewMode, + FitView, + ToggleGrid, + ToggleSnap, + ToggleAssets, + ToggleLeftPanel, + ToggleRightPanel, + OpenCommandPalette, + ShowShortcuts, + ShowAbout, + RequestQuit, + QuitNow, + ReloadAssets, +} diff --git a/crates/bevy_forge_editor/src/app.rs b/crates/bevy_forge_editor/src/app.rs new file mode 100644 index 0000000..ccaa4d6 --- /dev/null +++ b/crates/bevy_forge_editor/src/app.rs @@ -0,0 +1,983 @@ +//! Application shell: owns state, orders panels, dispatches deferred actions, +//! handles saving/exporting and the unsaved-changes close guard. + +use std::path::{Path, PathBuf}; +use std::time::{Duration, Instant}; + +use bevy_forge_core::{ + samples, AssetIndex, EditorConfig, ExportFormat, Project, ProjectTemplate, SceneDoc, Session, +}; +use eframe::egui; + +use crate::action::Action; +use crate::assets_panel::AssetsPanelState; +use crate::dialogs::DialogsState; +use crate::hierarchy::HierarchyPanelState; +use crate::inspector::InspectorPanelState; +use crate::palette::PaletteState; +use crate::state::{EditorContext, ExportState, Screen, UiState, ViewMode}; +use crate::texture_cache::TextureCache; +use crate::toast::Toasts; +use crate::viewport2d::Viewport2dState; +use crate::viewport3d::Viewport3dState; +use crate::welcome::WelcomeState; +use crate::{ + assets_panel, dialogs, hierarchy, inspector, menu, palette, shortcuts, status_bar, theme, + viewport2d, viewport3d, welcome, widgets, +}; + +pub struct ForgeApp { + pub session: Session, + pub ui: UiState, + pub project: Option, + pub assets: AssetIndex, + pub textures: TextureCache, + pub toasts: Toasts, + pub config: EditorConfig, + pub export: ExportState, + pub screen: Screen, + pub hierarchy: HierarchyPanelState, + pub inspector: InspectorPanelState, + pub assets_panel: AssetsPanelState, + pub viewport2d: Viewport2dState, + pub viewport3d: Viewport3dState, + pub palette: PaletteState, + pub dialogs: DialogsState, + pub welcome: WelcomeState, + pub current_scene_path: Option, + pub last_title: String, + pub allow_close: bool, + pub quit_now: bool, + last_autosave: Option, + /// Navigation interrupted by the unsaved-changes prompt. + pending_navigation: Option, + /// If set, capture a PNG after the UI settles and quit (CI/screenshot mode). + pub screenshot_path: Option, + screenshot_frames: u32, + screenshot_requested: bool, +} + +impl ForgeApp { + pub fn new(cc: &eframe::CreationContext<'_>, initial: Option) -> Self { + theme::apply(&cc.egui_ctx); + let config = EditorConfig::load_lossy(); + let mut app = Self::from_parts(SceneDoc::new("Untitled"), config); + if let Some(initial) = initial { + if initial.is_dir() { + if !app.open_project(&initial) { + app.screen = Screen::Welcome; + } + } else { + match bevy_forge_core::project::find_project_root(&initial) { + Some(root) => { + if app.open_project(&root) { + app.open_scene(&initial); + } + } + None => { + app.toasts.error(format!( + "Could not find a Bevy project containing {}", + initial.display() + )); + } + } + } + } else if let Some(last) = app.config.last_project.clone() { + app.welcome.project_path = last.display().to_string(); + } + app.screenshot_path = std::env::var_os("BEVY_FORGE_SCREENSHOT") + .filter(|value| !value.is_empty()) + .map(PathBuf::from); + if app.screenshot_path.is_some() { + // Screenshot mode: never paint a pointer tooltip over the clean UI. + cc.egui_ctx.style_mut(|style| { + style.interaction.tooltip_delay = f32::INFINITY; + }); + } + app + } + + /// Construct an editor with a preloaded scene, for headless UI tests. + pub fn new_for_test(ctx: &egui::Context, doc: SceneDoc) -> Self { + theme::apply(ctx); + let mut app = Self::from_parts(doc, EditorConfig::default()); + app.screen = Screen::Editor; + app + } + + fn from_parts(doc: SceneDoc, config: EditorConfig) -> Self { + let mut ui = UiState::default(); + ui.show_grid = config.show_grid; + ui.snap_to_grid = config.snap_to_grid; + ui.show_assets = config.show_assets; + ui.grid_size = config.grid_size; + Self { + session: Session::new(doc), + ui, + project: None, + assets: AssetIndex::default(), + textures: TextureCache::new(), + toasts: Toasts::new(), + config, + export: ExportState::default(), + screen: Screen::Welcome, + hierarchy: HierarchyPanelState::default(), + inspector: InspectorPanelState::default(), + assets_panel: AssetsPanelState::default(), + viewport2d: Viewport2dState::default(), + viewport3d: Viewport3dState::default(), + palette: PaletteState::default(), + dialogs: DialogsState::default(), + welcome: WelcomeState::default(), + current_scene_path: None, + last_title: String::new(), + allow_close: false, + quit_now: false, + last_autosave: Some(Instant::now()), + pending_navigation: None, + screenshot_path: None, + screenshot_frames: 0, + screenshot_requested: false, + } + } + + // ------------------------------------------------------------------ + // Project & scene lifecycle + // ------------------------------------------------------------------ + + pub fn open_project(&mut self, path: &Path) -> bool { + match Project::open(path) { + Ok(project) => { + self.assets = AssetIndex::scan(&project.assets_dir); + self.textures.clear(); + self.config.push_recent(&project.root); + let _ = self.config.save(); + self.project = Some(project); + self.current_scene_path = None; + self.screen = Screen::Editor; + self.ui.selection.clear(); + self.ui.error_banner = None; + let first_scene = self + .project + .as_ref() + .and_then(|project| project.list_scenes().into_iter().next()); + if let Some(scene) = first_scene { + if !self.open_scene(&scene) { + // Never leave the previous project's document paired + // with the newly opened project after a failed load. + self.session.replace(samples::empty_scene()); + self.current_scene_path = None; + self.ui.selection.clear(); + self.apply_auto_view_mode(); + self.ui.status = "Could not open the project's first scene".into(); + } + } else { + self.session.replace(samples::empty_scene()); + self.current_scene_path = None; + self.ui.selection.clear(); + self.apply_auto_view_mode(); + self.ui.status = "New empty scene".into(); + } + true + } + Err(error) => { + self.toasts.error(error.to_string()); + self.welcome.error = Some(error.to_string()); + false + } + } + } + + pub fn open_scene(&mut self, path: &Path) -> bool { + let Some(project) = &self.project else { + self.toasts + .error("Open a Bevy project before opening a scene"); + return false; + }; + match project.load_scene(path) { + Ok(doc) => { + self.session.replace(doc); + self.current_scene_path = Some(path.to_path_buf()); + self.ui.selection.clear(); + if let Some(id) = choose_initial_selection(self.session.doc()) { + self.ui.selection.select_only(id); + self.hierarchy.scroll_to = Some(id); + } + self.screen = Screen::Editor; + self.apply_auto_view_mode(); + // 2D auto-frames new scenes. 3D keeps its pleasant default orbit + // camera; F/Fit frames the whole scene on demand. + self.ui.fit_view_requested = false; + self.ui.status = format!("Opened {}", path.display()); + self.toasts.success(format!("Opened {}", file_name(path))); + true + } + Err(error) => { + self.ui.error_banner = Some(error.to_string()); + self.toasts.error(error.to_string()); + false + } + } + } + + pub fn new_scene(&mut self, template: ProjectTemplate) { + let doc = samples::for_template(template); + self.session.replace(doc); + self.current_scene_path = None; + self.screen = Screen::Editor; + self.ui.selection.clear(); + if let Some(id) = choose_initial_selection(self.session.doc()) { + self.ui.selection.select_only(id); + self.hierarchy.scroll_to = Some(id); + } + self.apply_auto_view_mode(); + self.ui.fit_view_requested = false; + self.ui.status = "New scene — remember to save".into(); + } + + pub fn save_scene(&mut self) { + let Some(project) = &self.project else { + self.open_save_as_dialog(); + return; + }; + let path = self + .current_scene_path + .clone() + .unwrap_or_else(|| project.scene_path(&self.session.doc().name)); + self.save_scene_to(&path); + } + + pub fn save_scene_to(&mut self, path: &Path) { + let Some(project) = &self.project else { + self.toasts + .error("Open a Bevy project before saving scenes"); + return; + }; + let mut path = path.to_path_buf(); + if path.is_relative() { + path = project.root.join(&path); + } + if !path + .file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| name.ends_with(".forge.ron")) + { + self.toasts.error("Scene files must end with .forge.ron"); + self.ui.error_banner = Some("Scene files must end with .forge.ron".into()); + return; + } + match project.save_scene(self.session.doc(), Some(&path)) { + Ok(written) => { + self.session.mark_saved(); + self.current_scene_path = Some(written.clone()); + self.ui.status = format!("Saved {}", written.display()); + self.toasts + .success(format!("Saved {}", file_name(&written))); + } + Err(error) => self.toasts.error(error.to_string()), + } + } + + pub fn open_save_as_dialog(&mut self) { + let default = self + .project + .as_ref() + .map(|project| project.scene_path(&self.session.doc().name)) + .unwrap_or_else(|| { + std::env::current_dir() + .unwrap_or_default() + .join(format!("{}.forge.ron", slug(&self.session.doc().name))) + }); + self.dialogs.save_as_path = default.display().to_string(); + self.dialogs.save_as_open = true; + } + + pub fn export(&mut self, format: ExportFormat, path: &Path) { + self.export.format = format; + let options = bevy_forge_core::CodegenOptions { + function_name: self.export.function_name.clone(), + include_header: true, + }; + match format.generate(self.session.doc(), &options) { + Ok(code) => { + let path = if path.is_relative() { + self.project + .as_ref() + .map(|project| project.root.join(path)) + .unwrap_or_else(|| path.to_path_buf()) + } else { + path.to_path_buf() + }; + if let Some(parent) = path.parent() { + if let Err(error) = std::fs::create_dir_all(parent) { + self.toasts + .error(format!("Could not create export folder: {error}")); + return; + } + } + match std::fs::write(&path, code) { + Ok(()) => { + self.toasts + .success(format!("Exported {}", file_name(&path))); + self.ui.status = format!("Exported {}", path.display()); + self.export.dialog_open = false; + } + Err(error) => { + self.export.error = Some(error.to_string()); + self.toasts.error(format!("Export failed: {error}")); + } + } + } + Err(error) => { + self.export.error = Some(error.to_string()); + self.toasts.error(error.to_string()); + } + } + } + + pub fn open_export_dialog(&mut self, format: ExportFormat) { + self.export.format = format; + self.export.function_name = function_name_for(&self.session.doc().name); + let path = self + .project + .as_ref() + .map(|project| project.root.join("export")) + .unwrap_or_else(|| PathBuf::from("export")); + self.export.path = path + .join(format!("{}.rs", self.export.function_name)) + .display() + .to_string(); + self.export.regenerate(self.session.doc()); + self.export.dialog_open = true; + } + + pub fn copy_export(&mut self, ctx: &egui::Context, format: ExportFormat) { + let name = if self.export.function_name.trim().is_empty() { + function_name_for(&self.session.doc().name) + } else { + self.export.function_name.clone() + }; + let options = bevy_forge_core::CodegenOptions { + function_name: name, + include_header: true, + }; + match format.generate(self.session.doc(), &options) { + Ok(code) => { + ctx.copy_text(code); + self.toasts.success("Copied generated code"); + } + Err(error) => self.toasts.error(error.to_string()), + } + } + + fn apply_auto_view_mode(&mut self) { + let doc = self.session.doc(); + let is_3d = doc.entities.values().any(|node| { + node.components.iter().any(|component| { + matches!( + component, + bevy_forge_core::ComponentData::Mesh3d(_) + | bevy_forge_core::ComponentData::Camera3d + | bevy_forge_core::ComponentData::DirectionalLight(_) + ) + }) + }); + self.ui.view_mode = if is_3d { + ViewMode::ThreeD + } else { + ViewMode::TwoD + }; + } + + // ------------------------------------------------------------------ + // Actions + // ------------------------------------------------------------------ + + fn delete_selection(&mut self) { + let ids: Vec<_> = self.ui.selection.ids().collect(); + if ids.is_empty() { + return; + } + self.session.edit("Delete entities", |doc| { + for id in ids { + doc.remove_entity(id); + } + }); + self.ui.selection.retain_existing(self.session.doc()); + self.ui.status = "Deleted entities".into(); + } + + fn duplicate_selection(&mut self) { + let ids: Vec<_> = self.ui.selection.ids().collect(); + if ids.is_empty() { + return; + } + let mut new_ids = Vec::new(); + self.session.edit("Duplicate entities", |doc| { + for id in ids { + if let Some(new_id) = doc.duplicate_entity(id) { + new_ids.push(new_id); + } + } + }); + if let Some(primary) = new_ids.first().copied() { + self.ui.selection.select_many(new_ids, Some(primary)); + } + self.ui.status = "Duplicated entities".into(); + } + + fn request_navigation(&mut self, navigation: PendingNavigation) { + if self.screen == Screen::Editor && self.session.is_dirty() { + self.pending_navigation = Some(navigation); + self.dialogs.confirm_quit_open = false; + } else { + self.perform_navigation(navigation); + } + } + + fn perform_navigation(&mut self, navigation: PendingNavigation) { + match navigation { + PendingNavigation::NewScene(template) => self.new_scene(template), + PendingNavigation::OpenProject(path) => { + self.open_project(&path); + } + PendingNavigation::OpenScene(path) => { + self.open_scene(&path); + } + } + } + + fn pending_navigation_ui(&mut self, ctx: &egui::Context) { + let Some(navigation) = self.pending_navigation.clone() else { + return; + }; + let action = match &navigation { + PendingNavigation::NewScene(_) => "Create a new scene", + PendingNavigation::OpenProject(_) => "Open another project", + PendingNavigation::OpenScene(_) => "Open another scene", + }; + let can_save = self.project.is_some() && self.current_scene_path.is_some(); + let mut discard = false; + let mut save_and_continue = false; + let mut cancel = false; + let response = egui::Modal::new(egui::Id::new("unsaved_navigation")).show(ctx, |ui| { + ui.set_width(440.0); + ui.heading("Unsaved changes"); + ui.add_space(4.0); + ui.label(format!( + "The current scene has unsaved changes and its undo history will be lost. {action} anyway?" + )); + if !can_save { + ui.add_space(2.0); + ui.label( + egui::RichText::new("This scene has no saved file yet; use Save As first if you want to keep it.") + .color(theme::MUTED) + .size(11.5), + ); + } + ui.add_space(10.0); + ui.horizontal(|ui| { + if can_save + && ui + .button("Save and continue") + .on_hover_text("Save the current scene, then continue") + .clicked() + { + save_and_continue = true; + ui.close(); + } + if ui.add(widgets::danger_button("Discard and continue")).clicked() { + discard = true; + ui.close(); + } + if ui.button("Cancel").clicked() { + cancel = true; + ui.close(); + } + }); + }); + if response.should_close() && !discard && !save_and_continue { + cancel = true; + } + if cancel { + self.pending_navigation = None; + self.ui.status = "Navigation cancelled".into(); + } + if save_and_continue { + self.save_scene(); + if !self.session.is_dirty() { + self.pending_navigation = None; + self.perform_navigation(navigation); + } + return; + } + if discard { + self.pending_navigation = None; + self.perform_navigation(navigation); + } + } + + fn handle_actions(&mut self, ctx: &egui::Context, actions: Vec) { + // Keep only the last view-mode action if a menu produced several. + for action in actions { + match action { + Action::NewScene(template) => { + self.request_navigation(PendingNavigation::NewScene(template)) + } + Action::OpenProjectDialog => { + self.dialogs.open_project_open = true; + if self.welcome.project_path.is_empty() { + self.welcome.project_path = self + .project + .as_ref() + .map(|project| project.root.display().to_string()) + .unwrap_or_default(); + } + self.dialogs.open_project_path = self.welcome.project_path.clone(); + } + Action::OpenProject(path) => { + self.request_navigation(PendingNavigation::OpenProject(path)) + } + Action::OpenSceneDialog => { + self.dialogs.open_scene_path = self + .current_scene_path + .as_ref() + .map(|path| path.display().to_string()) + .unwrap_or_default(); + self.dialogs.open_scene_open = true; + } + Action::OpenScene(path) => { + self.request_navigation(PendingNavigation::OpenScene(path)) + } + Action::Save => self.save_scene(), + Action::OpenSaveAsDialog => self.open_save_as_dialog(), + Action::SaveSceneTo(path) => { + self.dialogs.save_as_open = false; + self.save_scene_to(&path); + } + Action::OpenExportDialog(format) => self.open_export_dialog(format), + Action::ExportTo { format, path } => { + self.export(format, &path); + } + Action::CopyExport(format) => self.copy_export(ctx, format), + Action::Undo => { + if let Some(label) = self.session.undo() { + self.ui.selection.retain_existing(self.session.doc()); + self.ui.status = format!("Undid {label}"); + } + } + Action::Redo => { + if let Some(label) = self.session.redo() { + self.ui.selection.retain_existing(self.session.doc()); + self.ui.status = format!("Redid {label}"); + } + } + Action::DeleteSelection => self.delete_selection(), + Action::DuplicateSelection => self.duplicate_selection(), + Action::FocusSelection => { + self.ui.fit_view_requested = true; + } + Action::SetViewMode(mode) => self.ui.view_mode = mode, + Action::ToggleViewMode => self.ui.view_mode = self.ui.view_mode.toggled(), + Action::FitView => self.ui.fit_view_requested = true, + Action::ToggleGrid => { + self.ui.show_grid = !self.ui.show_grid; + self.config.show_grid = self.ui.show_grid; + let _ = self.config.save(); + } + Action::ToggleSnap => { + self.ui.snap_to_grid = !self.ui.snap_to_grid; + self.config.snap_to_grid = self.ui.snap_to_grid; + let _ = self.config.save(); + } + Action::ToggleAssets => { + self.ui.show_assets = !self.ui.show_assets; + self.config.show_assets = self.ui.show_assets; + let _ = self.config.save(); + } + Action::ToggleLeftPanel => self.ui.show_left_panel = !self.ui.show_left_panel, + Action::ToggleRightPanel => self.ui.show_right_panel = !self.ui.show_right_panel, + Action::OpenCommandPalette => { + self.palette.open = true; + self.palette.query.clear(); + self.palette.selected = 0; + } + Action::ShowShortcuts => self.dialogs.shortcuts_open = true, + Action::ShowAbout => self.dialogs.about_open = true, + Action::RequestQuit => { + if self.session.is_dirty() { + self.dialogs.confirm_quit_open = true; + } else { + self.quit_now = true; + } + } + Action::QuitNow => { + self.allow_close = true; + self.quit_now = true; + } + Action::ReloadAssets => { + if let Some(project) = &self.project { + self.assets = AssetIndex::scan(&project.assets_dir); + self.toasts + .success(format!("Indexed {} assets", self.assets.len())); + } + } + } + } + } + + // ------------------------------------------------------------------ + // Rendering + // ------------------------------------------------------------------ + + fn editor_ui(&mut self, ctx: &egui::Context) { + let mut actions = Vec::new(); + let modal_open = self.palette.open + || self.dialogs.save_as_open + || self.dialogs.open_project_open + || self.dialogs.open_scene_open + || self.dialogs.confirm_quit_open + || self.dialogs.shortcuts_open + || self.dialogs.about_open + || self.export.dialog_open + || self.pending_navigation.is_some(); + let mut cx = EditorContext { + session: &mut self.session, + project: self.project.as_ref(), + assets: &self.assets, + textures: &mut self.textures, + ui: &mut self.ui, + toasts: &mut self.toasts, + export: &mut self.export, + }; + + actions.extend(menu::show(ctx, &cx)); + if let Some(error) = cx.ui.error_banner.clone() { + let mut dismiss = false; + egui::TopBottomPanel::top("error_banner") + .frame( + egui::Frame::new() + .fill(egui::Color32::from_rgb(0x3a, 0x1f, 0x24)) + .inner_margin(egui::Margin::symmetric(10, 5)), + ) + .show(ctx, |ui| { + ui.horizontal(|ui| { + ui.label(egui::RichText::new("⚠").color(theme::ERROR).strong()); + ui.label(egui::RichText::new(error).color(theme::TEXT).size(12.0)); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if ui.button("Dismiss").clicked() { + dismiss = true; + } + }); + }); + }); + if dismiss { + cx.ui.error_banner = None; + } + } + if !modal_open { + actions.extend(shortcuts::handle(ctx, cx.session)); + } + status_bar::show(ctx, &cx); + + // Side panels come before the asset drawer so they keep the full editor + // height; the drawer then occupies only the central column. This keeps + // the selected hierarchy row and the inspector reachable at 1440x900 + // and at the 1000x640 minimum size. + if cx.ui.show_left_panel { + actions.extend(hierarchy::show(ctx, &mut cx, &mut self.hierarchy)); + } + if cx.ui.show_right_panel { + actions.extend(inspector::show(ctx, &mut cx, &mut self.inspector)); + } + actions.extend(assets_panel::show(ctx, &mut cx, &mut self.assets_panel)); + + let mode = cx.ui.view_mode; + egui::CentralPanel::default() + .frame(egui::Frame::new().fill(theme::BG)) + .show(ctx, |ui| match mode { + ViewMode::TwoD => { + actions.extend(viewport2d::show(ui, &mut cx, &mut self.viewport2d)); + } + ViewMode::ThreeD => { + actions.extend(viewport3d::show(ui, &mut cx, &mut self.viewport3d)); + } + }); + + actions.extend(palette::show(ctx, &mut cx, &mut self.palette)); + actions.extend(dialogs::show(ctx, &mut cx, &mut self.dialogs)); + drop(cx); + + self.handle_actions(ctx, actions); + self.autosave(); + } + + fn welcome_ui(&mut self, ctx: &egui::Context) { + let actions = welcome::show(ctx, &mut self.welcome, &self.config); + self.handle_actions(ctx, actions); + } + + fn autosave(&mut self) { + if self.config.autosave_secs == 0 || !self.session.is_dirty() { + return; + } + let Some(project) = &self.project else { + return; + }; + let due = self + .last_autosave + .map(|last| last.elapsed().as_secs() >= self.config.autosave_secs) + .unwrap_or(true); + if !due { + return; + } + let path = project.autosave_path(); + if project.save_scene(self.session.doc(), Some(&path)).is_ok() { + self.last_autosave = Some(Instant::now()); + self.ui.status = format!("Autosaved to {}", path.display()); + } + } + + fn update_title(&mut self, ctx: &egui::Context) { + let title = match (&self.project, self.screen) { + (Some(project), Screen::Editor) => format!( + "Bevy Forge — {} — {}{}", + project.name, + self.session.doc().name, + if self.session.is_dirty() { " *" } else { "" } + ), + _ => "Bevy Forge — Bevy scene editor".to_string(), + }; + if title != self.last_title { + ctx.send_viewport_cmd(egui::ViewportCommand::Title(title.clone())); + self.last_title = title; + } + } + + fn handle_screenshot(&mut self, ctx: &egui::Context) { + let Some(path) = self.screenshot_path.clone() else { + return; + }; + if ctx.input(|input| { + input + .events + .iter() + .any(|event| matches!(event, egui::Event::Screenshot { .. })) + }) { + let image = ctx.input(|input| { + input.events.iter().find_map(|event| match event { + egui::Event::Screenshot { image, .. } => Some(image.clone()), + _ => None, + }) + }); + if let Some(image) = image { + if let Some(parent) = path.parent() { + let _ = std::fs::create_dir_all(parent); + } + let result = image::save_buffer_with_format( + &path, + image.as_raw(), + image.width() as u32, + image.height() as u32, + image::ColorType::Rgba8, + image::ImageFormat::Png, + ); + match result { + Ok(()) => eprintln!("bevy-forge: screenshot written to {}", path.display()), + Err(error) => eprintln!("bevy-forge: screenshot failed: {error}"), + } + } + self.screenshot_path = None; + self.allow_close = true; + self.quit_now = true; + return; + } + self.screenshot_frames = self.screenshot_frames.saturating_add(1); + if self.screenshot_frames == 30 && !self.screenshot_requested { + self.screenshot_requested = true; + // Screenshot mode is for docs/CI: no transient toasts in the frame. + self.toasts.clear(); + ctx.send_viewport_cmd(egui::ViewportCommand::Screenshot(egui::UserData::default())); + } + if self.screenshot_frames < 32 { + ctx.request_repaint(); + } + } + + fn handle_close_request(&mut self, ctx: &egui::Context) { + if ctx.input(|input| input.viewport().close_requested()) + && self.session.is_dirty() + && !self.allow_close + && self.screen == Screen::Editor + { + ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose); + self.dialogs.confirm_quit_open = true; + } + } +} + +impl ForgeApp { + /// Render one frame. Exposed so headless tests can drive the full UI without + /// an eframe window or GPU. + pub fn ui(&mut self, ctx: &egui::Context) { + match self.screen { + Screen::Welcome => self.welcome_ui(ctx), + Screen::Editor => self.editor_ui(ctx), + } + self.pending_navigation_ui(ctx); + self.toasts.show(ctx); + // Autosave is time-based; without a periodic repaint an idle window + // would never run the timer. + if self.session.is_dirty() && self.project.is_some() && self.config.autosave_secs > 0 { + ctx.request_repaint_after(Duration::from_secs(1)); + } + self.update_title(ctx); + } + + /// Dispatch a single deferred action (used by the palette, tests and menus). + pub fn dispatch_action(&mut self, ctx: &egui::Context, action: Action) { + self.handle_actions(ctx, vec![action]); + } +} + +impl eframe::App for ForgeApp { + fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { + self.handle_close_request(ctx); + self.ui(ctx); + self.handle_screenshot(ctx); + if self.quit_now { + ctx.send_viewport_cmd(egui::ViewportCommand::Close); + } + } +} + +#[derive(Clone, Debug)] +enum PendingNavigation { + NewScene(ProjectTemplate), + OpenProject(PathBuf), + OpenScene(PathBuf), +} + +fn file_name(path: &Path) -> String { + path.file_name() + .and_then(|name| name.to_str()) + .unwrap_or("file") + .to_string() +} + +fn slug(name: &str) -> String { + bevy_forge_core::project::slugify(name) +} + +/// Pick the entity that makes the first frame most informative: a named content +/// entity (mesh/sprite) rather than a camera, a light or a giant backdrop. +fn choose_initial_selection(doc: &bevy_forge_core::SceneDoc) -> Option { + use bevy_forge_core::ComponentData; + + let mut best: Option<(i32, bevy_forge_core::EntityId)> = None; + for node in doc.entities.values() { + if node.is_hidden() { + continue; + } + let mut score = 0; + let mut area = 0.0; + for component in &node.components { + match component { + ComponentData::Camera2d | ComponentData::Camera3d => score -= 120, + ComponentData::DirectionalLight(_) | ComponentData::PointLight(_) => score -= 30, + ComponentData::Mesh3d(_) => score += 50, + ComponentData::Sprite(sprite) => { + score += 40; + if let Some(size) = sprite.custom_size { + area = (size.x * size.y).abs(); + } + } + ComponentData::Text2d(_) => score += 15, + ComponentData::Custom(_) => score += 20, + _ => {} + } + } + if !node.children.is_empty() { + score += 30; + } + if node.name.eq_ignore_ascii_case("player") + || node.name.to_ascii_lowercase().contains("crystal") + { + score += 100; + } + if area > 50_000.0 { + score -= 60; + } + if score <= 0 { + continue; + } + let candidate = (score, node.id); + if best + .map(|(best_score, _)| score > best_score) + .unwrap_or(true) + { + best = Some(candidate); + } + } + best.map(|(_, id)| id) +} + +fn function_name_for(scene_name: &str) -> String { + let mut name = slug(scene_name).replace('-', "_"); + if name + .chars() + .next() + .map(|c| c.is_ascii_digit()) + .unwrap_or(true) + { + name = format!("scene_{name}"); + } + name +} + +#[cfg(test)] +mod tests { + use super::*; + + fn temp_project() -> (tempfile::TempDir, Project) { + let dir = tempfile::tempdir().unwrap(); + std::fs::write( + dir.path().join("Cargo.toml"), + "[package]\nname = \"autosave-test\"\nversion = \"0.1.0\"\nedition = \"2021\"\n\n[dependencies]\nbevy = \"0.20\"\n", + ) + .unwrap(); + std::fs::create_dir_all(dir.path().join("assets")).unwrap(); + let project = Project::open(dir.path()).unwrap(); + (dir, project) + } + + #[test] + fn autosave_writes_when_due_and_dirty() { + let ctx = egui::Context::default(); + let (_dir, project) = temp_project(); + let mut app = ForgeApp::new_for_test(&ctx, SceneDoc::new("Autosave")); + app.assets = AssetIndex::scan(&project.assets_dir); + app.project = Some(project); + app.session.edit("add", |doc| { + doc.add_entity("Root", None); + }); + app.config.autosave_secs = 1; + app.last_autosave = Some(Instant::now() - Duration::from_secs(5)); + app.autosave(); + let path = app.project.as_ref().unwrap().autosave_path(); + assert!(path.is_file(), "autosave file should exist at {path:?}"); + } + + #[test] + fn dirty_navigation_is_prompted_before_replacing_the_scene() { + let ctx = egui::Context::default(); + let mut app = ForgeApp::new_for_test(&ctx, SceneDoc::new("Dirty")); + app.session.edit("add", |doc| { + doc.add_entity("Keep me", None); + }); + app.request_navigation(PendingNavigation::NewScene(ProjectTemplate::Empty)); + assert!(app.pending_navigation.is_some()); + assert_eq!(app.session.doc().entities.len(), 1); + assert_eq!(app.session.doc().name, "Dirty"); + } +} diff --git a/crates/bevy_forge_editor/src/assets_panel.rs b/crates/bevy_forge_editor/src/assets_panel.rs new file mode 100644 index 0000000..9a0fbf3 --- /dev/null +++ b/crates/bevy_forge_editor/src/assets_panel.rs @@ -0,0 +1,1551 @@ +//! Bottom asset drawer with thumbnails, filtering, drag-to-viewport and +//! one-click placement of images and meshes. +//! +//! Thumbnails are only decoded for the cards that are actually visible; the +//! panel asks [`TextureCache`] for a preview and falls back to a checkerboard +//! placeholder plus an explanatory tooltip when a file cannot be read. +//! +//! Double-click behaviour: +//! +//! * Images: set (or add) the `Sprite` image of the selected entity. With no +//! selection a new sprite entity is created at the cursor / origin. +//! * Meshes (`.gltf`/`.glb`): set (or add) a `Mesh3d` pointing at the asset with +//! the default material. With no selection a new entity is created at the +//! origin, because the 3D cursor is only a screen-space hint. +//! +//! Everything else can be dragged onto a viewport but has no one-click +//! placement. All scene mutations go through [`Session::edit`](bevy_forge_core::Session::edit). + +use std::path::{Path, PathBuf}; + +use bevy_forge_core::{ + AssetIndex, AssetKind, Color, ComponentData, EntityId, MaterialData, Mesh3dData, MeshSource, + Project, SceneDoc, SpriteData, Vec2, Vec3, +}; + +use crate::action::Action; +use crate::state::{AssetDragPayload, EditorContext}; +use crate::texture_cache::TextureCache; +use crate::{theme, widgets}; + +const DEFAULT_PANEL_HEIGHT: f32 = 240.0; +const MIN_PANEL_HEIGHT: f32 = 150.0; +const MAX_PANEL_HEIGHT: f32 = 420.0; +const MIN_THUMB_SIZE: f32 = 40.0; +const MAX_THUMB_SIZE: f32 = 128.0; +/// Height reserved at the bottom of the drawer for the footer hint. +const FOOTER_HEIGHT: f32 = 22.0; +/// Gap between the scrollable body and the footer hint. +const FOOTER_GAP: f32 = 6.0; + +#[derive(Clone, Debug)] +pub struct AssetsPanelState { + pub thumb_size: f32, + pub selected: Option, + pub open: bool, +} + +impl Default for AssetsPanelState { + fn default() -> Self { + Self { + thumb_size: 64.0, + selected: None, + open: true, + } + } +} + +pub fn show( + ctx: &egui::Context, + cx: &mut EditorContext<'_>, + state: &mut AssetsPanelState, +) -> Vec { + if !cx.ui.show_assets { + return Vec::new(); + } + // Drop a selection that refers to an asset which no longer exists. + if state + .selected + .as_deref() + .is_some_and(|path| cx.assets.find(path).is_none()) + { + state.selected = None; + } + + let mut actions = Vec::new(); + // Adapt the drawer to the window so it does not eat most of a 640 px-tall + // window. Side panels are now outside this drawer, so its height only + // affects the central viewport column. + let screen_height = ctx.screen_rect().height(); + let default_height = (screen_height * 0.22).clamp(MIN_PANEL_HEIGHT, DEFAULT_PANEL_HEIGHT); + let max_height = (screen_height * 0.34).clamp(MIN_PANEL_HEIGHT, MAX_PANEL_HEIGHT); + egui::TopBottomPanel::bottom("assets_panel") + .resizable(true) + .default_height(default_height) + .height_range(MIN_PANEL_HEIGHT..=max_height) + .show(ctx, |ui| { + show_header(ui, cx, state, &mut actions); + ui.separator(); + + // Pin the footer to the bottom of the drawer and let the body fill + // the space in between. Sizing the body from `available_height` + // would let the content grow the panel every frame instead. + let remaining = ui.available_rect_before_wrap(); + let footer_rect = egui::Rect::from_min_max( + egui::pos2(remaining.min.x, remaining.max.y - FOOTER_HEIGHT), + remaining.max, + ); + let body_bottom = (footer_rect.min.y - FOOTER_GAP).max(remaining.min.y + 48.0); + let body_rect = + egui::Rect::from_min_max(remaining.min, egui::pos2(remaining.max.x, body_bottom)); + ui.scope_builder( + egui::UiBuilder::new() + .max_rect(body_rect) + .layout(egui::Layout::top_down(egui::Align::Min)), + |ui| show_body(ui, cx, state, &mut actions), + ); + if footer_rect.min.y > body_rect.max.y { + ui.painter().hline( + remaining.x_range(), + footer_rect.min.y - FOOTER_GAP * 0.5, + ui.visuals().widgets.noninteractive.bg_stroke, + ); + } + ui.scope_builder( + egui::UiBuilder::new() + .max_rect(footer_rect) + .layout(egui::Layout::top_down(egui::Align::Min)), + |ui| show_footer(ui, cx, state), + ); + // Consume the whole remaining area so the panel never claims extra + // height from overflowing content. + ui.advance_cursor_after_rect(remaining); + }); + actions +} + +// --------------------------------------------------------------------------- +// Header: title, counts, refresh, thumbnail size, search and kind filter +// --------------------------------------------------------------------------- + +fn show_header( + ui: &mut egui::Ui, + cx: &mut EditorContext<'_>, + state: &mut AssetsPanelState, + actions: &mut Vec, +) { + let total = cx.assets.len(); + let visible = matching_count(&cx.assets, &cx.ui.asset_search, cx.ui.asset_filter); + ui.horizontal(|ui| { + ui.add_space(2.0); + ui.label(egui::RichText::new("Assets").strong().size(14.0)); + let count = if visible == total { + format!("{total} {}", if total == 1 { "file" } else { "files" }) + } else { + format!("{visible} of {total}") + }; + ui.label(theme::hint(count)); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + let refresh = ui + .button("Refresh") + .on_hover_text("Rescan the project's assets folder"); + if refresh.clicked() { + actions.push(Action::ReloadAssets); + } + ui.spacing_mut().slider_width = 96.0; + ui.add( + egui::Slider::new(&mut state.thumb_size, MIN_THUMB_SIZE..=MAX_THUMB_SIZE) + .show_value(false) + .fixed_decimals(0), + ) + .on_hover_text("Thumbnail size"); + ui.label(theme::hint("Size")); + }); + }); + + ui.horizontal(|ui| { + ui.add_space(2.0); + ui.add( + egui::TextEdit::singleline(&mut cx.ui.asset_search) + .hint_text("Search assets…") + .desired_width(230.0) + .margin(egui::Margin::symmetric(6, 3)), + ); + if !cx.ui.asset_search.is_empty() + && widgets::icon_button(ui, "✕", "Clear the search filter").clicked() + { + cx.ui.asset_search.clear(); + } + show_kind_filter(ui, cx); + ui.label(theme::hint( + "Double-click an image or mesh to place it in the scene", + )); + }); +} + +fn show_kind_filter(ui: &mut egui::Ui, cx: &mut EditorContext<'_>) { + let counts: Vec<(AssetKind, usize)> = AssetKind::all() + .into_iter() + .map(|kind| (kind, cx.assets.by_kind(kind).len())) + .collect(); + let selected_text = cx + .ui + .asset_filter + .map(AssetKind::label) + .unwrap_or("All kinds"); + egui::ComboBox::from_id_salt("assets_kind_filter") + .selected_text(selected_text) + .width(130.0) + .show_ui(ui, |ui| { + if ui + .selectable_label( + cx.ui.asset_filter.is_none(), + format!("All kinds ({})", cx.assets.len()), + ) + .clicked() + { + cx.ui.asset_filter = None; + } + for (kind, count) in counts { + let selected = cx.ui.asset_filter == Some(kind); + if ui + .selectable_label(selected, format!("{} ({count})", kind.label())) + .clicked() + { + cx.ui.asset_filter = Some(kind); + } + } + }); +} + +// --------------------------------------------------------------------------- +// Body: cards, empty states, index errors +// --------------------------------------------------------------------------- + +fn show_body( + ui: &mut egui::Ui, + cx: &mut EditorContext<'_>, + state: &mut AssetsPanelState, + actions: &mut Vec, +) { + if let Some(error) = cx.assets.error.clone() { + ui.horizontal_wrapped(|ui| { + ui.label(egui::RichText::new("⚠").color(theme::WARNING).size(12.0)); + ui.label(egui::RichText::new(error).color(theme::WARNING).size(12.0)); + }); + } + + match cx.project { + None => show_no_project(ui, actions), + Some(project) if cx.assets.is_empty() => show_empty_project(ui, project, actions), + Some(_) => { + let cards = collect_cards(&cx.assets, &cx.ui.asset_search, cx.ui.asset_filter); + if cards.is_empty() { + show_no_matches(ui, cx); + return; + } + show_card_grid(ui, cx, state, &cards); + } + } +} + +fn show_no_project(ui: &mut egui::Ui, actions: &mut Vec) { + ui.vertical_centered(|ui| { + ui.add_space(10.0); + ui.label( + egui::RichText::new("Open a Bevy project to browse assets") + .color(theme::MUTED) + .size(13.5), + ); + ui.label(theme::hint( + "Textures, meshes, audio and fonts will appear here.", + )); + ui.add_space(4.0); + if ui.add(widgets::primary_button("Open project…")).clicked() { + actions.push(Action::OpenProjectDialog); + } + }); +} + +fn show_empty_project(ui: &mut egui::Ui, project: &Project, actions: &mut Vec) { + ui.vertical_centered(|ui| { + ui.add_space(8.0); + ui.label( + egui::RichText::new("No assets found") + .color(theme::MUTED) + .size(13.0), + ); + ui.label( + egui::RichText::new(project.assets_dir.display().to_string()) + .monospace() + .color(theme::FAINT) + .size(11.0), + ); + ui.label(theme::hint("Add files to the assets folder, then rescan.")); + if ui + .button("Refresh") + .on_hover_text("Rescan the assets folder") + .clicked() + { + actions.push(Action::ReloadAssets); + } + }); +} + +fn show_no_matches(ui: &mut egui::Ui, cx: &mut EditorContext<'_>) { + ui.vertical_centered(|ui| { + ui.add_space(8.0); + ui.label( + egui::RichText::new("No assets match the current filters") + .color(theme::MUTED) + .size(13.0), + ); + let mut hints: Vec = Vec::new(); + let search = cx.ui.asset_search.trim(); + if !search.is_empty() { + hints.push(format!("search “{search}”")); + } + if let Some(kind) = cx.ui.asset_filter { + hints.push(format!("kind {}", kind.label())); + } + ui.label(theme::hint(if hints.is_empty() { + "Try another search or kind filter.".to_string() + } else { + format!("No match for {}.", hints.join(" + ")) + })); + if ui.button("Clear filters").clicked() { + cx.ui.asset_search.clear(); + cx.ui.asset_filter = None; + } + }); +} + +fn show_card_grid( + ui: &mut egui::Ui, + cx: &mut EditorContext<'_>, + state: &mut AssetsPanelState, + cards: &[AssetCard], +) { + egui::ScrollArea::vertical() + .auto_shrink([false, false]) + .show(ui, |ui| { + let spacing = 8.0; + let card_width = state.thumb_size + 16.0; + let columns = (((ui.available_width() + spacing) / (card_width + spacing)).floor() + as usize) + .max(1); + egui::Grid::new("assets_grid") + .num_columns(columns) + .spacing(egui::vec2(spacing, spacing)) + .show(ui, |ui| { + for (index, card) in cards.iter().enumerate() { + let selected = state.selected.as_deref() == Some(card.path.as_str()); + let interaction = draw_card( + ui, + card, + selected, + state.thumb_size, + cx.textures, + cx.project, + ); + if interaction.clicked || interaction.double_clicked { + state.selected = Some(card.path.clone()); + } + if interaction.double_clicked { + activate_asset(cx, state, card); + } + if (index + 1) % columns == 0 { + ui.end_row(); + } + } + }); + }); +} + +// --------------------------------------------------------------------------- +// Card drawing +// --------------------------------------------------------------------------- + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +struct CardInteraction { + clicked: bool, + double_clicked: bool, +} + +fn draw_card( + ui: &mut egui::Ui, + card: &AssetCard, + selected: bool, + thumb_size: f32, + textures: &mut TextureCache, + project: Option<&Project>, +) -> CardInteraction { + ui.push_id(("forge_asset_card", card.path.as_str()), |ui| { + draw_card_inner(ui, card, selected, thumb_size, textures, project) + }) + .inner +} + +fn draw_card_inner( + ui: &mut egui::Ui, + card: &AssetCard, + selected: bool, + thumb_size: f32, + textures: &mut TextureCache, + project: Option<&Project>, +) -> CardInteraction { + let card_width = thumb_size + 16.0; + let card_height = thumb_size + 34.0; + let (rect, response) = ui.allocate_exact_size( + egui::vec2(card_width, card_height), + egui::Sense::click_and_drag(), + ); + let response = response.on_hover_cursor(egui::CursorIcon::Grab); + let hovered = response.hovered(); + let painter = ui.painter().clone(); + + let fill = if selected { + theme::ELEVATED + } else if hovered { + theme::PANEL_ALT + } else { + theme::PANEL + }; + painter.rect_filled(rect, 6.0, fill); + let stroke = if selected { + egui::Stroke::new(2.0_f32, theme::SELECTION) + } else if hovered { + egui::Stroke::new(1.0_f32, theme::ACCENT_DIM) + } else { + egui::Stroke::new(1.0_f32, theme::BORDER) + }; + painter.rect_stroke(rect, 6.0, stroke, egui::StrokeKind::Inside); + + let thumb_rect = egui::Rect::from_min_size( + rect.min + egui::vec2(8.0, 8.0), + egui::vec2(thumb_size, thumb_size), + ); + let thumb_inner = thumb_rect.shrink(1.0); + let thumb_painter = painter.with_clip_rect(thumb_inner); + let mut preview_error: Option = None; + + if card.kind == AssetKind::Image { + let abs_path = project + .map(|project| project.resolve_asset(&card.path)) + .unwrap_or_else(|| card.abs_path.clone()); + let preview = match textures.get_or_load(ui.ctx(), &abs_path) { + Some(cached) => Some((cached.texture.id(), cached.size)), + None => { + preview_error = textures.error_for(&abs_path).map(str::to_owned); + None + } + }; + paint_checkerboard(&thumb_painter, thumb_inner, 10.0); + match preview { + Some((texture_id, source_size)) => { + thumb_painter.image( + texture_id, + fit_rect(thumb_inner, source_size), + egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)), + egui::Color32::WHITE, + ); + } + None => { + thumb_painter.text( + thumb_rect.center(), + egui::Align2::CENTER_CENTER, + "Preview\nunavailable", + egui::FontId::proportional(9.5), + theme::ERROR, + ); + } + } + } else { + painter.rect_filled(thumb_rect, 4.0, theme::BG); + painter.text( + thumb_rect.center() - egui::vec2(0.0, 6.0), + egui::Align2::CENTER_CENTER, + kind_badge(&card.path), + egui::FontId::monospace((thumb_size * 0.22).max(11.0)), + theme::ACCENT, + ); + painter.text( + thumb_rect.center() + egui::vec2(0.0, thumb_size * 0.22), + egui::Align2::CENTER_CENTER, + card.kind.label(), + egui::FontId::proportional(9.5), + theme::FAINT, + ); + } + + let max_chars = (((card_width - 12.0) / 6.2).floor() as usize).max(4); + painter.text( + egui::pos2(rect.center().x, thumb_rect.max.y + 15.0), + egui::Align2::CENTER_CENTER, + truncate_chars(&card.file_name, max_chars), + egui::FontId::proportional(11.0), + if selected { theme::TEXT } else { theme::MUTED }, + ); + + let mut tooltip = format!( + "{}\n{} · {}", + card.path, + card.kind.label(), + human_size(card.size_bytes) + ); + if let Some(error) = &preview_error { + tooltip.push_str(&format!("\n\nPreview error: {error}")); + } + match card.kind { + AssetKind::Image => { + tooltip.push_str("\nDouble-click to assign or create a sprite · drag onto a viewport") + } + AssetKind::Mesh => { + tooltip.push_str("\nDouble-click to place a mesh · drag onto a viewport") + } + _ => tooltip.push_str("\nDrag onto a viewport to use this asset"), + } + response.clone().on_hover_text(tooltip); + + response.widget_info(|| { + egui::WidgetInfo::selected(egui::WidgetType::Button, true, selected, card.path.as_str()) + }); + + // Dragging starts a payload consumed by the 2D/3D viewports. + response.dnd_set_drag_payload(AssetDragPayload { + path: card.path.clone(), + }); + if response.dragged() { + paint_drag_preview(ui.ctx(), card); + } + + CardInteraction { + clicked: response.clicked(), + double_clicked: response.double_clicked(), + } +} + +fn paint_drag_preview(ctx: &egui::Context, card: &AssetCard) { + let Some(pointer) = ctx.pointer_interact_pos() else { + return; + }; + let layer = ctx.layer_painter(egui::LayerId::new( + egui::Order::Tooltip, + egui::Id::new(("forge_asset_drag_preview", card.path.as_str())), + )); + let text = format!( + "{} {}", + kind_badge(&card.path), + truncate_chars(&card.file_name, 32) + ); + let galley = layer.layout_no_wrap(text, egui::FontId::proportional(12.0), theme::TEXT); + let padding = egui::vec2(8.0, 5.0); + let origin = pointer + egui::vec2(16.0, 12.0); + let background = egui::Rect::from_min_size(origin - padding, galley.size() + padding * 2.0); + layer.rect_filled(background, 5.0, theme::ELEVATED); + layer.rect_stroke( + background, + 5.0, + egui::Stroke::new(1.0_f32, theme::ACCENT), + egui::StrokeKind::Inside, + ); + layer.galley(origin, galley, theme::TEXT); +} + +fn paint_checkerboard(painter: &egui::Painter, rect: egui::Rect, cell: f32) { + let cell = cell.max(4.0); + let mut y = rect.min.y; + let mut row = 0; + while y < rect.max.y { + let mut x = rect.min.x; + let mut column = 0; + while x < rect.max.x { + let cell_rect = egui::Rect::from_min_max( + egui::pos2(x, y), + egui::pos2((x + cell).min(rect.max.x), (y + cell).min(rect.max.y)), + ); + let color = if (row + column) % 2 == 0 { + theme::PANEL_ALT + } else { + theme::BG + }; + painter.rect_filled(cell_rect, 0.0, color); + x += cell; + column += 1; + } + y += cell; + row += 1; + } +} + +// --------------------------------------------------------------------------- +// Double-click placement +// --------------------------------------------------------------------------- + +fn activate_asset(cx: &mut EditorContext<'_>, state: &mut AssetsPanelState, card: &AssetCard) { + match card.kind { + AssetKind::Image => activate_image(cx, state, card), + AssetKind::Mesh => activate_mesh(cx, state, card), + _ => { + cx.ui.status = format!( + "`{}` is a {} asset; only images and meshes can be placed from the browser", + card.path, + card.kind.label() + ); + cx.toasts.info(format!( + "{} assets cannot be placed directly", + card.kind.label() + )); + } + } +} + +fn activate_image(cx: &mut EditorContext<'_>, state: &mut AssetsPanelState, card: &AssetCard) { + let selected = current_selection(cx); + if let Some(id) = selected { + let outcome = cx.session.edit("Set sprite image", |doc| { + assign_sprite_image(doc, id, &card.path) + }); + if outcome != AssignOutcome::Missing { + let name = entity_name(cx, id); + let verb = if outcome == AssignOutcome::Updated { + "Updated" + } else { + "Added a Sprite to" + }; + cx.ui.status = format!("{verb} “{name}” · {}", card.path); + cx.toasts + .success(format!("{verb} “{name}” → {}", card.path)); + state.selected = Some(card.path.clone()); + return; + } + } + + let stem = asset_stem(&card.path); + let position = cx.ui.cursor_world.unwrap_or(Vec2::ZERO); + let (id, name) = cx.session.edit("Add sprite", |doc| { + spawn_sprite_for_asset(doc, &card.path, &stem, position) + }); + cx.ui.selection.select_only(id); + cx.ui.status = format!("Added sprite “{name}” · {}", card.path); + cx.toasts.success(format!("Added sprite “{name}”")); + state.selected = Some(card.path.clone()); +} + +fn activate_mesh(cx: &mut EditorContext<'_>, state: &mut AssetsPanelState, card: &AssetCard) { + let selected = current_selection(cx); + if let Some(id) = selected { + let outcome = cx.session.edit("Set mesh asset", |doc| { + assign_mesh_asset(doc, id, &card.path) + }); + if outcome != AssignOutcome::Missing { + let name = entity_name(cx, id); + let verb = if outcome == AssignOutcome::Updated { + "Updated" + } else { + "Added a Mesh3d to" + }; + cx.ui.status = format!("{verb} “{name}” · {}", card.path); + cx.toasts + .success(format!("{verb} “{name}” → {}", card.path)); + state.selected = Some(card.path.clone()); + return; + } + } + + let stem = asset_stem(&card.path); + let (id, name) = cx.session.edit("Add mesh", |doc| { + spawn_mesh_for_asset(doc, &card.path, &stem) + }); + cx.ui.selection.select_only(id); + cx.ui.status = format!("Added Mesh3d “{name}” · {}", card.path); + cx.toasts.success(format!("Added Mesh3d “{name}”")); + state.selected = Some(card.path.clone()); +} + +/// The primary selection, if that entity still exists. A stale selection is +/// cleared so the next double-click creates a fresh entity. +fn current_selection(cx: &mut EditorContext<'_>) -> Option { + let stale = cx + .ui + .selection + .primary() + .is_some_and(|id| cx.session.doc().entity(id).is_none()); + if stale { + cx.ui.selection.clear(); + } + cx.ui.selection.primary() +} + +fn entity_name(cx: &EditorContext<'_>, id: EntityId) -> String { + cx.session + .doc() + .entity(id) + .map(|node| node.name.clone()) + .unwrap_or_else(|| format!("#{id}")) +} + +// --------------------------------------------------------------------------- +// Pure scene helpers (unit tested without an egui context) +// --------------------------------------------------------------------------- + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum AssignOutcome { + /// An existing component was updated. + Updated, + /// A new component was added to an existing entity. + Added, + /// The entity disappeared between selection and edit. + Missing, +} + +fn assign_sprite_image(doc: &mut SceneDoc, id: EntityId, asset_path: &str) -> AssignOutcome { + if doc.entity(id).is_none() { + return AssignOutcome::Missing; + } + if doc + .entity(id) + .is_some_and(|node| node.has_component("sprite")) + { + if let Some(ComponentData::Sprite(sprite)) = doc + .entity_mut(id) + .and_then(|node| node.component_mut("sprite")) + { + sprite.image = Some(asset_path.to_string()); + return AssignOutcome::Updated; + } + return AssignOutcome::Missing; + } + let sprite = ComponentData::Sprite(SpriteData { + image: Some(asset_path.to_string()), + color: Color::WHITE, + ..Default::default() + }); + if doc.add_component(id, sprite) { + AssignOutcome::Added + } else { + AssignOutcome::Missing + } +} + +fn spawn_sprite_for_asset( + doc: &mut SceneDoc, + asset_path: &str, + name_hint: &str, + position: Vec2, +) -> (EntityId, String) { + let name = unique_entity_name(doc, name_hint, "Sprite"); + let id = doc.add_entity(name.clone(), None); + if let Some(node) = doc.entity_mut(id) { + node.transform.translation = Vec3::new(position.x, position.y, 0.0); + } + doc.add_component( + id, + ComponentData::Sprite(SpriteData { + image: Some(asset_path.to_string()), + color: Color::WHITE, + ..Default::default() + }), + ); + (id, name) +} + +fn assign_mesh_asset(doc: &mut SceneDoc, id: EntityId, asset_path: &str) -> AssignOutcome { + if doc.entity(id).is_none() { + return AssignOutcome::Missing; + } + if doc + .entity(id) + .is_some_and(|node| node.has_component("mesh3d")) + { + if let Some(ComponentData::Mesh3d(mesh)) = doc + .entity_mut(id) + .and_then(|node| node.component_mut("mesh3d")) + { + // Keep the authored material; only swap the mesh source. + mesh.mesh = MeshSource::Asset(asset_path.to_string()); + return AssignOutcome::Updated; + } + return AssignOutcome::Missing; + } + let mesh = ComponentData::Mesh3d(Mesh3dData { + mesh: MeshSource::Asset(asset_path.to_string()), + material: MaterialData::default(), + }); + if doc.add_component(id, mesh) { + AssignOutcome::Added + } else { + AssignOutcome::Missing + } +} + +fn spawn_mesh_for_asset( + doc: &mut SceneDoc, + asset_path: &str, + name_hint: &str, +) -> (EntityId, String) { + let name = unique_entity_name(doc, name_hint, "Mesh"); + let id = doc.add_entity(name.clone(), None); + doc.add_component( + id, + ComponentData::Mesh3d(Mesh3dData { + mesh: MeshSource::Asset(asset_path.to_string()), + material: MaterialData::default(), + }), + ); + (id, name) +} + +/// `base`, `base 2`, `base 3`, ... skipping names that are already in the scene. +fn unique_entity_name(doc: &SceneDoc, base: &str, fallback: &str) -> String { + let trimmed = base.trim(); + let base = if trimmed.is_empty() { + fallback + } else { + trimmed + }; + if doc.find_by_name(base).is_none() { + return base.to_string(); + } + for suffix in 2..=9999 { + let candidate = format!("{base} {suffix}"); + if doc.find_by_name(&candidate).is_none() { + return candidate; + } + } + format!("{base} {}", doc.entities.len() + 1) +} + +/// File stem of an asset path (which always uses `/` separators). +fn asset_stem(path: &str) -> String { + let file = path.rsplit('/').next().unwrap_or(path); + let stem = Path::new(file) + .file_stem() + .and_then(|stem| stem.to_str()) + .unwrap_or(file); + let trimmed = stem.trim(); + if trimmed.is_empty() { + file.trim().to_string() + } else { + trimmed.to_string() + } +} + +/// Short uppercase extension badge for non-image cards and drag previews. +fn kind_badge(path: &str) -> String { + let file = path.rsplit('/').next().unwrap_or(path); + Path::new(file) + .extension() + .and_then(|extension| extension.to_str()) + .map(|extension| { + let badge: String = extension.chars().take(5).collect(); + let badge = badge.to_ascii_uppercase(); + if badge.is_empty() { + "FILE".to_string() + } else { + badge + } + }) + .unwrap_or_else(|| "FILE".to_string()) +} + +fn human_size(bytes: u64) -> String { + const KB: f64 = 1024.0; + let value = bytes as f64; + if value < KB { + format!("{bytes} B") + } else if value < KB * KB { + format!("{:.1} KB", value / KB) + } else { + format!("{:.1} MB", value / (KB * KB)) + } +} + +/// Fit `source`'s aspect ratio inside `container`, centered. +fn fit_rect(container: egui::Rect, source: [usize; 2]) -> egui::Rect { + if container.width() <= 0.0 || container.height() <= 0.0 { + return container; + } + let source_width = source[0].max(1) as f32; + let source_height = source[1].max(1) as f32; + let scale = (container.width() / source_width).min(container.height() / source_height); + egui::Rect::from_center_size( + container.center(), + egui::vec2(source_width * scale, source_height * scale), + ) +} + +fn truncate_chars(text: &str, max_chars: usize) -> String { + let max_chars = max_chars.max(1); + if text.chars().count() <= max_chars { + text.to_string() + } else { + let mut truncated: String = text.chars().take(max_chars.saturating_sub(1)).collect(); + truncated.push('…'); + truncated + } +} + +// --------------------------------------------------------------------------- +// Filtering helpers +// --------------------------------------------------------------------------- + +/// Index entry plus the values the card needs, owned so drawing never holds a +/// borrow of [`AssetIndex`] while mutating the session or texture cache. +#[derive(Clone, Debug, PartialEq, Eq)] +struct AssetCard { + path: String, + abs_path: PathBuf, + kind: AssetKind, + file_name: String, + size_bytes: u64, +} + +fn matches_kind(entry: &bevy_forge_core::AssetEntry, kind: Option) -> bool { + kind.map_or(true, |kind| entry.kind == kind) +} + +fn matching_count(assets: &AssetIndex, query: &str, kind: Option) -> usize { + assets + .search(query) + .into_iter() + .filter(|entry| matches_kind(entry, kind)) + .count() +} + +fn collect_cards(assets: &AssetIndex, query: &str, kind: Option) -> Vec { + assets + .search(query) + .into_iter() + .filter(|entry| matches_kind(entry, kind)) + .map(|entry| AssetCard { + path: entry.path.clone(), + abs_path: entry.abs_path.clone(), + kind: entry.kind, + file_name: entry.file_name().to_string(), + size_bytes: entry.size_bytes, + }) + .collect() +} + +// --------------------------------------------------------------------------- +// Footer +// --------------------------------------------------------------------------- + +fn show_footer(ui: &mut egui::Ui, cx: &EditorContext<'_>, state: &AssetsPanelState) { + ui.horizontal(|ui| { + ui.add_space(2.0); + ui.label(theme::hint( + "Drag onto the viewport to place · double-click to assign", + )); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if let Some(selected) = &state.selected { + ui.label(theme::hint(format!("Selected · {selected}"))); + } + if let Some(project) = cx.project { + let label = match project.assets_dir.strip_prefix(&project.root) { + Ok(relative) if relative == Path::new("assets") => "assets/".to_string(), + Ok(relative) if relative.as_os_str().is_empty() => "project root".to_string(), + Ok(relative) => format!("{}/", relative.display()), + Err(_) => project.assets_dir.display().to_string(), + }; + ui.label(theme::hint(label)); + } + }); + }); +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::state::{ExportState, UiState}; + use crate::toast::Toasts; + use bevy_forge_core::Session; + use image::{Rgba, RgbaImage}; + use std::fs; + + fn touch(root: &Path, relative: &str) -> PathBuf { + let path = root.join(relative); + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).expect("create asset directory"); + } + fs::write(&path, b"x").expect("write placeholder asset"); + path + } + + fn write_png(path: &Path, width: u32, height: u32) { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).expect("create texture directory"); + } + RgbaImage::from_pixel(width, height, Rgba([12, 34, 56, 255])) + .save(path) + .expect("write test PNG"); + } + + fn test_project(root: &Path) -> Project { + fs::write( + root.join("Cargo.toml"), + "[package]\nname = \"demo\"\nversion = \"0.1.0\"\nedition = \"2021\"\n\n[dependencies]\nbevy = \"0.20\"\n", + ) + .expect("write manifest"); + fs::create_dir_all(root.join("assets/textures")).expect("create assets dir"); + Project::open(root).expect("project opens") + } + + fn paths(cards: &[AssetCard]) -> Vec<&str> { + cards.iter().map(|card| card.path.as_str()).collect() + } + + #[test] + fn cards_filter_by_search_and_kind() { + let temp = tempfile::tempdir().expect("tempdir"); + let assets = temp.path().join("assets"); + touch(&assets, "textures/Hero.PNG"); + touch(&assets, "textures/coin.png"); + touch(&assets, "audio/loop.ogg"); + touch(&assets, "models/robot.glb"); + let index = AssetIndex::scan(&assets); + + assert_eq!( + paths(&collect_cards(&index, "", None)), + vec![ + "audio/loop.ogg", + "models/robot.glb", + "textures/Hero.PNG", + "textures/coin.png", + ] + ); + assert_eq!( + paths(&collect_cards(&index, "", Some(AssetKind::Image))), + vec!["textures/Hero.PNG", "textures/coin.png"] + ); + assert_eq!( + paths(&collect_cards(&index, "HERO", None)), + vec!["textures/Hero.PNG"] + ); + assert_eq!( + paths(&collect_cards(&index, "png", Some(AssetKind::Image))), + vec!["textures/Hero.PNG", "textures/coin.png"] + ); + assert!(collect_cards(&index, "missing", None).is_empty()); + assert!(collect_cards(&index, "", Some(AssetKind::Audio)).len() == 1); + assert_eq!( + matching_count(&index, "png", Some(AssetKind::Image)), + collect_cards(&index, "png", Some(AssetKind::Image)).len() + ); + assert_eq!(matching_count(&index, "", None), index.len()); + } + + #[test] + fn card_names_come_from_the_file_stem() { + let temp = tempfile::tempdir().expect("tempdir"); + let assets = temp.path().join("assets"); + touch(&assets, "textures/player one.png"); + touch(&assets, "models/robot.glb"); + let index = AssetIndex::scan(&assets); + let cards = collect_cards(&index, "", None); + // The asset index is sorted by path: models/... before textures/... + assert_eq!(cards[0].file_name, "robot.glb"); + assert_eq!(cards[1].file_name, "player one.png"); + assert_eq!(asset_stem("textures/player one.png"), "player one"); + assert_eq!(asset_stem("models/robot.glb"), "robot"); + assert_eq!(asset_stem("no-extension"), "no-extension"); + } + + #[test] + fn entity_names_are_unique_and_non_empty() { + let mut doc = SceneDoc::new("test"); + doc.add_entity("player", None); + doc.add_entity("player 2", None); + assert_eq!(unique_entity_name(&doc, "player", "Sprite"), "player 3"); + assert_eq!(unique_entity_name(&doc, " ", "Sprite"), "Sprite"); + assert_eq!(unique_entity_name(&doc, "", "Mesh"), "Mesh"); + doc.add_entity("Sprite", None); + assert_eq!(unique_entity_name(&doc, " ", "Sprite"), "Sprite 2"); + } + + #[test] + fn spawning_a_sprite_sets_image_color_position_and_name() { + let mut doc = SceneDoc::new("test"); + let (id, name) = + spawn_sprite_for_asset(&mut doc, "textures/hero.png", "hero", Vec2::new(3.0, -2.0)); + assert_eq!(name, "hero"); + assert_eq!(doc.roots, vec![id]); + let node = doc.entity(id).expect("sprite entity exists"); + assert_eq!(node.transform.translation, Vec3::new(3.0, -2.0, 0.0)); + match node.component("sprite") { + Some(ComponentData::Sprite(sprite)) => { + assert_eq!(sprite.image.as_deref(), Some("textures/hero.png")); + assert_eq!(sprite.color, Color::WHITE); + assert_eq!(sprite.custom_size, None); + } + other => panic!("expected a Sprite component, got {other:?}"), + } + assert!(doc.check_integrity().is_empty()); + + let (_, second_name) = + spawn_sprite_for_asset(&mut doc, "textures/hero.png", "hero", Vec2::ZERO); + assert_eq!(second_name, "hero 2"); + } + + #[test] + fn assigning_a_sprite_adds_then_updates() { + let mut doc = SceneDoc::new("test"); + let id = doc.add_entity("target", None); + + assert_eq!( + assign_sprite_image(&mut doc, id, "textures/a.png"), + AssignOutcome::Added + ); + match doc + .entity(id) + .and_then(|node| node.component("sprite")) + .expect("sprite was added") + { + ComponentData::Sprite(sprite) => { + assert_eq!(sprite.image.as_deref(), Some("textures/a.png")); + } + other => panic!("expected Sprite, got {other:?}"), + } + + assert_eq!( + assign_sprite_image(&mut doc, id, "textures/b.png"), + AssignOutcome::Updated + ); + match doc + .entity(id) + .and_then(|node| node.component("sprite")) + .expect("sprite still exists") + { + ComponentData::Sprite(sprite) => { + assert_eq!(sprite.image.as_deref(), Some("textures/b.png")); + } + other => panic!("expected Sprite, got {other:?}"), + } + assert_eq!( + assign_sprite_image(&mut doc, 9999, "textures/c.png"), + AssignOutcome::Missing + ); + assert!(doc.check_integrity().is_empty()); + } + + #[test] + fn mesh_assignment_keeps_the_authored_material() { + let mut doc = SceneDoc::new("test"); + let (id, name) = spawn_mesh_for_asset(&mut doc, "models/robot.glb", "robot"); + assert_eq!(name, "robot"); + match doc + .entity(id) + .and_then(|node| node.component("mesh3d")) + .expect("Mesh3d added") + { + ComponentData::Mesh3d(mesh) => { + assert_eq!(mesh.mesh, MeshSource::Asset("models/robot.glb".into())); + assert_eq!(mesh.material, MaterialData::default()); + } + other => panic!("expected Mesh3d, got {other:?}"), + } + + if let Some(ComponentData::Mesh3d(mesh)) = doc + .entity_mut(id) + .and_then(|node| node.component_mut("mesh3d")) + { + mesh.material.base_color = Color::srgba(1.0, 0.0, 0.0, 1.0); + } + assert_eq!( + assign_mesh_asset(&mut doc, id, "models/table.glb"), + AssignOutcome::Updated + ); + match doc + .entity(id) + .and_then(|node| node.component("mesh3d")) + .expect("Mesh3d still exists") + { + ComponentData::Mesh3d(mesh) => { + assert_eq!(mesh.mesh, MeshSource::Asset("models/table.glb".into())); + assert_eq!(mesh.material.base_color, Color::srgba(1.0, 0.0, 0.0, 1.0)); + } + other => panic!("expected Mesh3d, got {other:?}"), + } + + let other_id = doc.add_entity("other", None); + assert_eq!( + assign_mesh_asset(&mut doc, other_id, "models/rock.glb"), + AssignOutcome::Added + ); + } + + // -- double-click activation (the handler called from `draw_card`) -------- + + fn card(path: &str, kind: AssetKind) -> AssetCard { + AssetCard { + path: path.to_string(), + abs_path: PathBuf::from(path), + kind, + file_name: path.rsplit('/').next().unwrap_or(path).to_string(), + size_bytes: 12, + } + } + + fn with_activation_context( + session: &mut Session, + ui_state: &mut UiState, + f: impl FnOnce(&mut EditorContext<'_>) -> R, + ) -> R { + let assets = AssetIndex::default(); + let mut textures = TextureCache::new(); + let mut toasts = Toasts::default(); + let mut export = ExportState::default(); + let mut cx = EditorContext { + session, + project: None, + assets: &assets, + textures: &mut textures, + ui: ui_state, + toasts: &mut toasts, + export: &mut export, + }; + f(&mut cx) + } + + #[test] + fn double_click_image_without_selection_creates_and_selects_a_sprite() { + let mut session = Session::new(SceneDoc::new("test")); + let mut ui_state = UiState { + cursor_world: Some(Vec2::new(2.5, -1.5)), + ..UiState::default() + }; + let mut state = AssetsPanelState::default(); + let hero = card("textures/hero.png", AssetKind::Image); + + with_activation_context(&mut session, &mut ui_state, |cx| { + activate_asset(cx, &mut state, &hero); + }); + + assert_eq!(state.selected.as_deref(), Some("textures/hero.png")); + let id = ui_state + .selection + .primary() + .expect("the new sprite is selected"); + let node = session.doc().entity(id).expect("sprite entity exists"); + assert_eq!(node.name, "hero"); + assert_eq!(node.transform.translation, Vec3::new(2.5, -1.5, 0.0)); + match node.component("sprite") { + Some(ComponentData::Sprite(sprite)) => { + assert_eq!(sprite.image.as_deref(), Some("textures/hero.png")); + } + other => panic!("expected Sprite, got {other:?}"), + } + assert_eq!(session.undo_label(), Some("Add sprite")); + assert!(session.doc().check_integrity().is_empty()); + } + + #[test] + fn double_click_image_with_selection_updates_the_sprite_and_keeps_selection() { + let mut session = Session::new(SceneDoc::new("test")); + let id = session.edit("setup", |doc| { + let id = doc.add_entity("Player", None); + doc.add_component( + id, + ComponentData::Sprite(SpriteData { + image: Some("textures/old.png".into()), + ..Default::default() + }), + ); + id + }); + let mut ui_state = UiState::default(); + ui_state.selection.select_only(id); + let mut state = AssetsPanelState::default(); + let hero = card("textures/hero.png", AssetKind::Image); + + with_activation_context(&mut session, &mut ui_state, |cx| { + activate_asset(cx, &mut state, &hero); + }); + + assert_eq!(ui_state.selection.primary(), Some(id)); + match session + .doc() + .entity(id) + .and_then(|node| node.component("sprite")) + { + Some(ComponentData::Sprite(sprite)) => { + assert_eq!(sprite.image.as_deref(), Some("textures/hero.png")); + } + other => panic!("expected Sprite, got {other:?}"), + } + assert_eq!(session.undo_label(), Some("Set sprite image")); + assert_eq!(session.doc().entities.len(), 1, "no entity was created"); + } + + #[test] + fn double_click_mesh_without_selection_creates_a_mesh_entity() { + let mut session = Session::new(SceneDoc::new("test")); + let mut ui_state = UiState::default(); + let mut state = AssetsPanelState::default(); + let robot = card("models/robot.glb", AssetKind::Mesh); + + with_activation_context(&mut session, &mut ui_state, |cx| { + activate_asset(cx, &mut state, &robot); + }); + + let id = ui_state.selection.primary().expect("mesh entity selected"); + let node = session.doc().entity(id).expect("mesh entity exists"); + assert_eq!(node.name, "robot"); + match node.component("mesh3d") { + Some(ComponentData::Mesh3d(mesh)) => { + assert_eq!(mesh.mesh, MeshSource::Asset("models/robot.glb".into())); + assert_eq!(mesh.material, MaterialData::default()); + } + other => panic!("expected Mesh3d, got {other:?}"), + } + assert_eq!(session.undo_label(), Some("Add mesh")); + assert!(session.doc().check_integrity().is_empty()); + } + + #[test] + fn double_click_non_placeable_kind_reports_but_does_not_edit() { + let mut session = Session::new(SceneDoc::new("test")); + session.edit("setup", |doc| { + doc.add_entity("Existing", None); + }); + let mut ui_state = UiState::default(); + let mut state = AssetsPanelState::default(); + let loop_asset = card("audio/loop.ogg", AssetKind::Audio); + + with_activation_context(&mut session, &mut ui_state, |cx| { + activate_asset(cx, &mut state, &loop_asset); + }); + + assert_eq!(session.doc().entities.len(), 1, "no entity was created"); + assert_eq!(session.undo_label(), Some("setup"), "no new undo entry"); + assert!(ui_state.selection.is_empty()); + assert!( + ui_state.status.contains("only images and meshes"), + "unexpected status: {}", + ui_state.status + ); + } + + #[test] + fn double_click_never_edits_a_stale_selection() { + let mut session = Session::new(SceneDoc::new("test")); + let stale = session.edit("setup", |doc| doc.add_entity("Gone", None)); + session.edit("remove", |doc| { + doc.remove_entity(stale); + }); + let mut ui_state = UiState::default(); + ui_state.selection.select_only(stale); + let mut state = AssetsPanelState::default(); + let hero = card("textures/hero.png", AssetKind::Image); + + with_activation_context(&mut session, &mut ui_state, |cx| { + activate_asset(cx, &mut state, &hero); + }); + + let id = ui_state + .selection + .primary() + .expect("new entity is selected"); + assert_ne!(id, stale); + assert_eq!( + session.doc().entity(id).map(|node| node.name.as_str()), + Some("hero") + ); + assert_eq!(session.undo_label(), Some("Add sprite")); + } + + #[test] + fn fit_rect_preserves_aspect_ratio_and_centers() { + let container = egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(100.0, 50.0)); + let wide = fit_rect(container, [200, 100]); + assert!((wide.width() - 100.0).abs() < 0.01); + assert!((wide.height() - 50.0).abs() < 0.01); + assert!((wide.center().x - container.center().x).abs() < 0.01); + + let tall = fit_rect(container, [100, 200]); + assert!((tall.width() - 25.0).abs() < 0.01); + assert!((tall.height() - 50.0).abs() < 0.01); + + let tiny = fit_rect(container, [1, 1]); + assert!((tiny.width() - 50.0).abs() < 0.01); + assert!((tiny.height() - 50.0).abs() < 0.01); + } + + #[test] + fn truncation_and_size_labels_are_stable() { + assert_eq!(truncate_chars("abc", 5), "abc"); + assert_eq!(truncate_chars("abcdef", 4), "abc…"); + assert_eq!(truncate_chars("abcdef", 1), "…"); + assert_eq!(human_size(512), "512 B"); + assert_eq!(human_size(1536), "1.5 KB"); + assert_eq!(human_size(3 * 1024 * 1024), "3.0 MB"); + assert_eq!(kind_badge("models/robot.glb"), "GLB"); + assert_eq!(kind_badge("noext"), "FILE"); + } + + fn run_show( + session: &mut Session, + project: Option<&Project>, + assets: &AssetIndex, + ui_state: &mut UiState, + textures: &mut TextureCache, + state: &mut AssetsPanelState, + ) -> Vec { + let ctx = egui::Context::default(); + let mut raw_input = egui::RawInput::default(); + raw_input.screen_rect = Some(egui::Rect::from_min_size( + egui::Pos2::ZERO, + egui::vec2(1280.0, 800.0), + )); + let mut actions = Vec::new(); + let _ = ctx.run(raw_input, |ctx| { + let mut toasts = Toasts::default(); + let mut export = ExportState::default(); + let mut cx = EditorContext { + session: &mut *session, + project, + assets, + textures: &mut *textures, + ui: &mut *ui_state, + toasts: &mut toasts, + export: &mut export, + }; + actions = show(ctx, &mut cx, &mut *state); + }); + actions + } + + #[test] + fn panel_height_stays_put_across_frames() { + let temp = tempfile::tempdir().expect("tempdir"); + let project = test_project(temp.path()); + write_png(&project.assets_dir.join("textures/hero.png"), 4, 3); + let assets = AssetIndex::scan(&project.assets_dir); + let mut session = Session::new(SceneDoc::new("test")); + let mut ui_state = UiState::default(); + let mut textures = TextureCache::new(); + let mut state = AssetsPanelState::default(); + let ctx = egui::Context::default(); + let screen = egui::vec2(1280.0, 800.0); + let mut raw = egui::RawInput::default(); + raw.screen_rect = Some(egui::Rect::from_min_size(egui::Pos2::ZERO, screen)); + + let mut heights = Vec::new(); + for _ in 0..8 { + let input = raw.clone(); + let _ = ctx.run(input, |ctx| { + let mut toasts = Toasts::default(); + let mut export = ExportState::default(); + let mut cx = EditorContext { + session: &mut session, + project: Some(&project), + assets: &assets, + textures: &mut textures, + ui: &mut ui_state, + toasts: &mut toasts, + export: &mut export, + }; + let _ = show(ctx, &mut cx, &mut state); + }); + let available = ctx.available_rect(); + heights.push(screen.y - available.max.y); + } + assert!( + heights.iter().all(|h| (*h - heights[0]).abs() < 1.0), + "panel height must not creep between frames: {heights:?}" + ); + } + + #[test] + fn headless_panel_renders_empty_state_without_project() { + let mut session = Session::new(SceneDoc::new("test")); + let assets = AssetIndex::default(); + let mut ui_state = UiState::default(); + let mut textures = TextureCache::new(); + let mut state = AssetsPanelState::default(); + + let actions = run_show( + &mut session, + None, + &assets, + &mut ui_state, + &mut textures, + &mut state, + ); + + assert!(actions.is_empty()); + assert!(textures.is_empty()); + } + + #[test] + fn headless_panel_skips_work_when_hidden() { + let temp = tempfile::tempdir().expect("tempdir"); + let project = test_project(temp.path()); + write_png(&project.assets_dir.join("textures/hero.png"), 4, 3); + let assets = AssetIndex::scan(&project.assets_dir); + let mut session = Session::new(SceneDoc::new("test")); + let mut ui_state = UiState { + show_assets: false, + ..UiState::default() + }; + let mut textures = TextureCache::new(); + let mut state = AssetsPanelState::default(); + + let actions = run_show( + &mut session, + Some(&project), + &assets, + &mut ui_state, + &mut textures, + &mut state, + ); + + assert!(actions.is_empty()); + assert!(textures.is_empty()); + } + + #[test] + fn headless_panel_loads_thumbnails_for_visible_image_cards() { + let temp = tempfile::tempdir().expect("tempdir"); + let project = test_project(temp.path()); + write_png(&project.assets_dir.join("textures/hero.png"), 4, 3); + let assets = AssetIndex::scan(&project.assets_dir); + let mut session = Session::new(SceneDoc::new("test")); + let mut ui_state = UiState::default(); + let mut textures = TextureCache::new(); + let mut state = AssetsPanelState::default(); + + let actions = run_show( + &mut session, + Some(&project), + &assets, + &mut ui_state, + &mut textures, + &mut state, + ); + + assert!(actions.is_empty()); + assert_eq!(textures.len(), 1, "one image card was drawn"); + assert_eq!( + textures.dimensions(&project.resolve_asset("textures/hero.png")), + Some([4, 3]) + ); + + // A search that filters everything out must not decode any new image. + let mut filtered = UiState { + asset_search: "no-such-asset".into(), + ..UiState::default() + }; + let mut filtered_textures = TextureCache::new(); + let _ = run_show( + &mut session, + Some(&project), + &assets, + &mut filtered, + &mut filtered_textures, + &mut state, + ); + assert!(filtered_textures.is_empty()); + } +} diff --git a/crates/bevy_forge_editor/src/dialogs.rs b/crates/bevy_forge_editor/src/dialogs.rs new file mode 100644 index 0000000..abd4131 --- /dev/null +++ b/crates/bevy_forge_editor/src/dialogs.rs @@ -0,0 +1,1396 @@ +//! Modal dialogs: save-as, open project/scene, export preview, shortcuts, about +//! and the unsaved-changes confirmation. +//! +//! Dialogs are pure UI: they validate paths and function names, but never write +//! files or open projects themselves. Anything that touches the filesystem is +//! emitted as an [`Action`] and handled by `app.rs`. + +use std::path::{Component, Path, PathBuf}; + +use bevy_forge_core::components::is_rust_ident; +use bevy_forge_core::{ExportFormat, Project}; + +use crate::action::Action; +use crate::state::EditorContext; +use crate::theme; +use crate::widgets; + +#[derive(Default)] +pub struct DialogsState { + pub save_as_open: bool, + pub save_as_path: String, + pub open_project_open: bool, + pub open_project_path: String, + pub open_scene_open: bool, + pub open_scene_path: String, + pub confirm_quit_open: bool, + pub shortcuts_open: bool, + pub about_open: bool, + pub error: Option, + + // Internal bookkeeping, public so headless tests can observe/reset it. + pub save_as_was_open: bool, + pub save_as_focus_pending: bool, + pub save_as_attempted: bool, + pub open_project_was_open: bool, + pub open_project_focus_pending: bool, + pub open_scene_was_open: bool, + pub open_scene_focus_pending: bool, + /// Snapshot of `project.list_scenes()` while the open-scene dialog is open. + pub open_scene_list: Vec, + pub export_was_open: bool, + pub export_focus_pending: bool, + pub export_attempted: bool, +} + +/// Render all currently open dialogs and return deferred actions. +pub fn show( + ctx: &egui::Context, + cx: &mut EditorContext<'_>, + state: &mut DialogsState, +) -> Vec { + let mut actions = Vec::new(); + save_as_dialog(ctx, cx, state, &mut actions); + open_project_dialog(ctx, cx, state, &mut actions); + open_scene_dialog(ctx, cx, state, &mut actions); + export_dialog(ctx, cx, state, &mut actions); + unsaved_changes_dialog(ctx, cx, state, &mut actions); + shortcuts_dialog(ctx, state); + about_dialog(ctx, state); + actions +} + +// --------------------------------------------------------------------------- +// Shared chrome +// --------------------------------------------------------------------------- + +fn modal_id(name: &str) -> egui::Id { + egui::Id::new(("forge_dialog", name)) +} + +fn dialog_area(name: &str) -> egui::Area { + egui::Modal::default_area(modal_id(name)).anchor(egui::Align2::CENTER_CENTER, egui::Vec2::ZERO) +} + +fn dialog_frame(ctx: &egui::Context) -> egui::Frame { + egui::Frame::popup(&ctx.style()) + .fill(theme::PANEL) + .stroke(egui::Stroke::new(1.0_f32, theme::BORDER)) + .corner_radius(egui::CornerRadius::same(10)) + .inner_margin(egui::Margin::symmetric(18, 16)) +} + +fn title_row(ui: &mut egui::Ui, title: &str, subtitle: &str) { + ui.horizontal(|ui| { + ui.vertical(|ui| { + ui.label( + egui::RichText::new(title) + .size(17.5) + .strong() + .color(theme::TEXT), + ); + if !subtitle.is_empty() { + ui.label(egui::RichText::new(subtitle).size(11.0).color(theme::FAINT)); + } + }); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Min), |ui| { + if ui + .add(egui::Button::new(egui::RichText::new("×").size(16.0)).frame(false)) + .on_hover_text("Close (Esc)") + .clicked() + { + ui.close(); + } + }); + }); + ui.add_space(6.0); +} + +fn error_box(ui: &mut egui::Ui, message: &str) { + egui::Frame::new() + .fill(egui::Color32::from_rgb(52, 24, 30)) + .stroke(egui::Stroke::new(1.0_f32, theme::ERROR)) + .corner_radius(egui::CornerRadius::same(5)) + .inner_margin(egui::Margin::symmetric(9, 6)) + .show(ui, |ui| { + ui.add( + egui::Label::new(egui::RichText::new(message).color(theme::ERROR).size(11.5)) + .wrap(), + ); + }); +} + +/// Draw the brand diamond. A painted shape avoids font-coverage surprises. +fn diamond_mark(ui: &mut egui::Ui, size: f32, color: egui::Color32) { + let (rect, _) = ui.allocate_exact_size(egui::vec2(size, size), egui::Sense::hover()); + let center = rect.center(); + let radius = size * 0.5; + ui.painter().add(egui::Shape::convex_polygon( + vec![ + egui::pos2(center.x, center.y - radius), + egui::pos2(center.x + radius, center.y), + egui::pos2(center.x, center.y + radius), + egui::pos2(center.x - radius, center.y), + ], + color, + egui::Stroke::NONE, + )); +} + +fn preview_path(ui: &mut egui::Ui, label: &str, path: &Path) { + ui.label(egui::RichText::new(label).size(10.5).color(theme::FAINT)); + ui.add( + egui::Label::new( + egui::RichText::new(path.display().to_string()) + .monospace() + .size(11.0) + .color(theme::MUTED), + ) + .wrap(), + ); +} + +/// Whether a dialog switched from closed to open this frame. +fn opening(open: bool, was_open: &mut bool) -> bool { + if !open { + *was_open = false; + return false; + } + let first = !*was_open; + *was_open = true; + first +} + +// --------------------------------------------------------------------------- +// Path helpers (pure; unit-tested) +// --------------------------------------------------------------------------- + +/// Trim whitespace and strip matching surrounding quotes (paths dropped into a +/// text field are sometimes quoted). +fn clean_input(input: &str) -> &str { + let trimmed = input.trim(); + let bytes = trimmed.as_bytes(); + if bytes.len() >= 2 { + let first = bytes[0]; + let last = bytes[bytes.len() - 1]; + if (first == b'"' && last == b'"') || (first == b'\'' && last == b'\'') { + return trimmed[1..trimmed.len() - 1].trim(); + } + } + trimmed +} + +fn validate_input_text(input: &str) -> Result<&str, String> { + if input.is_empty() { + return Err("Enter a path first.".to_string()); + } + if input.contains('\0') || input.contains('\n') || input.contains('\r') { + return Err("Path contains an invalid character.".to_string()); + } + Ok(input) +} + +/// Expand a leading `~` using the current home directory. +fn expand_tilde(input: &str) -> PathBuf { + let home = std::env::var_os("HOME") + .filter(|home| !home.is_empty()) + .map(PathBuf::from); + expand_tilde_with(input, home.as_deref()) +} + +fn expand_tilde_with(input: &str, home: Option<&Path>) -> PathBuf { + if input == "~" { + if let Some(home) = home { + return home.to_path_buf(); + } + } + if let Some(rest) = input.strip_prefix("~/") { + if let Some(home) = home { + return home.join(rest); + } + } + PathBuf::from(input) +} + +/// Enforce the `.forge.ron` extension and reject folder paths. +fn normalize_scene_path(input: &str) -> Result { + let path = clean_input(input); + validate_input_text(path)?; + if path.ends_with('/') || path.ends_with('\\') { + return Err("Enter a scene file name, not a folder.".to_string()); + } + if path.ends_with(".forge.ron") { + Ok(path.to_string()) + } else if let Some(stem) = path.strip_suffix(".ron") { + Ok(format!("{stem}.forge.ron")) + } else { + Ok(format!("{path}.forge.ron")) + } +} + +/// Enforce the `.rs` extension for exported code. +fn normalize_export_path(input: &str) -> Result { + let path = clean_input(input); + validate_input_text(path)?; + if path.ends_with('/') || path.ends_with('\\') { + return Err("Enter an output file name, not a folder.".to_string()); + } + if path.ends_with(".rs") { + Ok(path.to_string()) + } else { + Ok(format!("{path}.rs")) + } +} + +/// Resolve a save-as path against the project root, rejecting paths outside it. +fn resolve_save_path(project: Option<&Project>, input: &str) -> Result { + let Some(project) = project else { + return Err("Open a Bevy project before saving scenes.".to_string()); + }; + let normalized = normalize_scene_path(input)?; + let path = PathBuf::from(normalized); + if path + .components() + .any(|component| matches!(component, Component::ParentDir)) + { + return Err("Scene paths cannot contain '..'.".to_string()); + } + let resolved = if path.is_absolute() { + path + } else { + project.root.join(path) + }; + if !resolved.starts_with(&project.root) { + return Err(format!( + "Scene files must stay inside the project folder ({}).", + project.root.display() + )); + } + Ok(resolved) +} + +/// Resolve a typed open-scene path. Bare file names are looked up in `scenes/`. +fn resolve_open_scene_path(project: Option<&Project>, input: &str) -> Result { + let Some(project) = project else { + return Err("Open a Bevy project before opening scenes.".to_string()); + }; + let normalized = normalize_scene_path(input)?; + let path = PathBuf::from(normalized); + if path.is_absolute() { + Ok(path) + } else if path.components().count() <= 1 { + Ok(project.scenes_dir().join(path)) + } else { + Ok(project.root.join(path)) + } +} + +/// Resolve an export path: relative paths are relative to the project root. +fn resolve_export_path(project: Option<&Project>, input: &str) -> Result { + let normalized = normalize_export_path(input)?; + let path = PathBuf::from(normalized); + if path.is_absolute() { + Ok(path) + } else if let Some(project) = project { + Ok(project.root.join(path)) + } else { + Ok(path) + } +} + +/// Validate the folder typed into the open-project dialog. +pub fn validate_project_input(input: &str) -> Result { + let path = clean_input(input); + validate_input_text(path)?; + Ok(expand_tilde(path)) +} + +fn valid_function_name(name: &str) -> bool { + is_rust_ident(name.trim()) +} + +fn scene_name(path: &Path) -> String { + let name = path + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or("scene"); + name.strip_suffix(".forge.ron").unwrap_or(name).to_string() +} + +// --------------------------------------------------------------------------- +// Save as +// --------------------------------------------------------------------------- + +fn save_as_dialog( + ctx: &egui::Context, + cx: &mut EditorContext<'_>, + state: &mut DialogsState, + actions: &mut Vec, +) { + if !state.save_as_open { + state.save_as_was_open = false; + state.save_as_focus_pending = false; + return; + } + if opening(state.save_as_open, &mut state.save_as_was_open) { + state.save_as_focus_pending = true; + state.save_as_attempted = false; + state.error = None; + } + + let mut submit = false; + let mut close = false; + let project = cx.project; + let modal = egui::Modal::new(modal_id("save_as")) + .area(dialog_area("save_as")) + .frame(dialog_frame(ctx)) + .show(ctx, |ui| { + ui.set_width(540.0); + title_row( + ui, + "Save scene as", + "Writes a .forge.ron scene inside the project's scenes folder.", + ); + + ui.label(theme::hint("Scene file")); + let response = ui.add( + egui::TextEdit::singleline(&mut state.save_as_path) + .id_salt("dialogs_save_as_path") + .hint_text("scenes/my-scene.forge.ron") + .desired_width(f32::INFINITY) + .font(egui::TextStyle::Monospace), + ); + if state.save_as_focus_pending { + response.request_focus(); + state.save_as_focus_pending = false; + } + if response.changed() { + state.error = None; + state.save_as_attempted = false; + } + + ui.add_space(4.0); + match resolve_save_path(project, &state.save_as_path) { + Ok(path) => preview_path(ui, "Will write to", &path), + Err(message) => { + if state.save_as_attempted { + error_box(ui, &message); + } else if project.is_none() { + ui.label(theme::hint( + "No project is open yet — open one before saving a scene.", + )); + } else { + ui.label(theme::hint( + "A relative path is resolved inside the project folder.", + )); + } + } + } + if let Some(error) = &state.error { + error_box(ui, error); + } + + ui.add_space(10.0); + ui.horizontal(|ui| { + if ui + .add_enabled(project.is_some(), widgets::primary_button("Save")) + .clicked() + { + submit = true; + } + if ui.button("Cancel").clicked() { + close = true; + } + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + widgets::kbd(ui, "Enter"); + ui.label(egui::RichText::new("save").size(10.5).color(theme::FAINT)); + }); + }); + + if response.lost_focus() && ui.input(|input| input.key_pressed(egui::Key::Enter)) { + submit = true; + } + }); + + if submit { + match resolve_save_path(project, &state.save_as_path) { + Ok(path) => { + actions.push(Action::SaveSceneTo(path)); + close = true; + } + Err(_message) => { + state.save_as_attempted = true; + state.error = None; + } + } + } + if modal.should_close() || close { + state.save_as_open = false; + state.save_as_was_open = false; + state.save_as_focus_pending = false; + } +} + +// --------------------------------------------------------------------------- +// Open project +// --------------------------------------------------------------------------- + +fn open_project_dialog( + ctx: &egui::Context, + _cx: &mut EditorContext<'_>, + state: &mut DialogsState, + actions: &mut Vec, +) { + if !state.open_project_open { + state.open_project_was_open = false; + state.open_project_focus_pending = false; + return; + } + if opening(state.open_project_open, &mut state.open_project_was_open) { + state.open_project_focus_pending = true; + state.error = None; + } + + let mut submit = false; + let mut close = false; + let modal = egui::Modal::new(modal_id("open_project")) + .area(dialog_area("open_project")) + .frame(dialog_frame(ctx)) + .show(ctx, |ui| { + ui.set_width(540.0); + title_row( + ui, + "Open project", + "Point Bevy Forge at a Bevy project folder.", + ); + + ui.label(theme::hint("Project folder")); + let response = ui.add( + egui::TextEdit::singleline(&mut state.open_project_path) + .id_salt("dialogs_open_project_path") + .hint_text("/home/me/games/my-bevy-game") + .desired_width(f32::INFINITY) + .font(egui::TextStyle::Monospace), + ); + if state.open_project_focus_pending { + response.request_focus(); + state.open_project_focus_pending = false; + } + if response.changed() { + state.error = None; + } + + if !clean_input(&state.open_project_path).is_empty() { + preview_path( + ui, + "Folder", + &expand_tilde(clean_input(&state.open_project_path)), + ); + } + ui.add_space(3.0); + ui.label(theme::hint( + "A Bevy project has a Cargo.toml with a `bevy` dependency. Recent projects are on the welcome screen.", + )); + if let Some(error) = &state.error { + error_box(ui, error); + } + + ui.add_space(10.0); + ui.horizontal(|ui| { + if ui.add(widgets::primary_button("Open")).clicked() { + submit = true; + } + if ui.button("Cancel").clicked() { + close = true; + } + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + widgets::kbd(ui, "Enter"); + ui.label(egui::RichText::new("open").size(10.5).color(theme::FAINT)); + }); + }); + + if response.lost_focus() && ui.input(|input| input.key_pressed(egui::Key::Enter)) { + submit = true; + } + }); + + if submit { + match validate_project_input(&state.open_project_path) { + Ok(path) => { + actions.push(Action::OpenProject(path)); + close = true; + } + Err(message) => state.error = Some(message), + } + } + if modal.should_close() || close { + state.open_project_open = false; + state.open_project_was_open = false; + state.open_project_focus_pending = false; + } +} + +// --------------------------------------------------------------------------- +// Open scene +// --------------------------------------------------------------------------- + +fn open_scene_dialog( + ctx: &egui::Context, + cx: &mut EditorContext<'_>, + state: &mut DialogsState, + actions: &mut Vec, +) { + if !state.open_scene_open { + state.open_scene_was_open = false; + state.open_scene_focus_pending = false; + return; + } + let project = cx.project; + if opening(state.open_scene_open, &mut state.open_scene_was_open) { + state.open_scene_focus_pending = true; + state.error = None; + state.open_scene_list = project + .map(|project| project.list_scenes()) + .unwrap_or_default(); + } + + let mut submit = false; + let mut close = false; + let mut picked: Option = None; + let modal = egui::Modal::new(modal_id("open_scene")) + .area(dialog_area("open_scene")) + .frame(dialog_frame(ctx)) + .show(ctx, |ui| { + ui.set_width(560.0); + title_row( + ui, + "Open scene", + "Load a .forge.ron scene from the current project.", + ); + + ui.label(theme::hint("Scene file")); + let response = ui.add( + egui::TextEdit::singleline(&mut state.open_scene_path) + .id_salt("dialogs_open_scene_path") + .hint_text("scenes/garden.forge.ron") + .desired_width(f32::INFINITY) + .font(egui::TextStyle::Monospace), + ); + if state.open_scene_focus_pending { + response.request_focus(); + state.open_scene_focus_pending = false; + } + if response.changed() { + state.error = None; + } + + ui.add_space(2.0); + ui.horizontal(|ui| { + if ui + .add_enabled(project.is_some(), widgets::primary_button("Open")) + .clicked() + { + submit = true; + } + if ui.button("Cancel").clicked() { + close = true; + } + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + widgets::kbd(ui, "Enter"); + ui.label(egui::RichText::new("open").size(10.5).color(theme::FAINT)); + }); + }); + if let Some(error) = &state.error { + ui.add_space(2.0); + error_box(ui, error); + } + + ui.add_space(6.0); + ui.separator(); + widgets::section_label(ui, "Scenes in this project"); + match project { + None => { + widgets::empty_state( + ui, + "No project is open", + "Open a Bevy project first — scenes live in its scenes/ folder.", + ); + } + Some(project) => { + if state.open_scene_list.is_empty() { + widgets::empty_state( + ui, + "No scenes yet", + "Save the current scene with Ctrl+S, then it appears here.", + ); + } else { + egui::ScrollArea::vertical() + .max_height(210.0) + .auto_shrink([false, true]) + .show(ui, |ui| { + for scene_path in &state.open_scene_list { + let relative = scene_path + .strip_prefix(&project.root) + .unwrap_or(scene_path) + .display() + .to_string(); + let selected = + state.open_scene_path == scene_path.display().to_string(); + if scene_row(ui, &scene_name(scene_path), &relative, selected) { + state.open_scene_path = scene_path.display().to_string(); + picked = Some(scene_path.clone()); + } + } + }); + } + } + } + + if response.lost_focus() && ui.input(|input| input.key_pressed(egui::Key::Enter)) { + submit = true; + } + }); + + if let Some(path) = picked { + actions.push(Action::OpenScene(path)); + close = true; + } else if submit { + match resolve_open_scene_path(project, &state.open_scene_path) { + Ok(path) => { + actions.push(Action::OpenScene(path)); + close = true; + } + Err(message) => state.error = Some(message), + } + } + if modal.should_close() || close { + state.open_scene_open = false; + state.open_scene_was_open = false; + state.open_scene_focus_pending = false; + } +} + +fn scene_row(ui: &mut egui::Ui, name: &str, relative: &str, selected: bool) -> bool { + let width = ui.available_width(); + let (rect, response) = ui.allocate_exact_size(egui::vec2(width, 34.0), egui::Sense::click()); + let hovered = response.hovered(); + let painter = ui.painter(); + let row = rect.shrink2(egui::vec2(0.0, 1.0)); + painter.rect_filled( + row, + 5.0, + if selected { + theme::ACCENT_DIM + } else if hovered { + theme::ELEVATED + } else { + theme::PANEL_ALT + }, + ); + painter.text( + egui::pos2(row.left() + 10.0, row.top() + 9.0), + egui::Align2::LEFT_TOP, + name, + egui::FontId::proportional(12.5), + theme::TEXT, + ); + painter.text( + egui::pos2(row.left() + 10.0, row.top() + 22.0), + egui::Align2::LEFT_TOP, + relative, + egui::FontId::monospace(10.0), + theme::FAINT, + ); + painter.text( + egui::pos2(row.right() - 10.0, row.center().y), + egui::Align2::RIGHT_CENTER, + "Open", + egui::FontId::proportional(10.5), + theme::ACCENT, + ); + response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, name)); + let response = response.on_hover_text(format!("Open {relative}")); + response.clicked() +} + +// --------------------------------------------------------------------------- +// Export +// --------------------------------------------------------------------------- + +fn export_dialog( + ctx: &egui::Context, + cx: &mut EditorContext<'_>, + state: &mut DialogsState, + actions: &mut Vec, +) { + if !cx.export.dialog_open { + state.export_was_open = false; + state.export_focus_pending = false; + return; + } + if opening(cx.export.dialog_open, &mut state.export_was_open) { + state.export_focus_pending = true; + state.export_attempted = false; + } + + let project = cx.project; + let mut regenerate = false; + let mut submit = false; + let mut copy = false; + let mut close = false; + + let function_valid = valid_function_name(&cx.export.function_name); + let path_result = resolve_export_path(project, &cx.export.path); + + let modal = egui::Modal::new(modal_id("export")) + .area(dialog_area("export")) + .frame(dialog_frame(ctx)) + .show(ctx, |ui| { + ui.set_width(720.0); + title_row( + ui, + "Export scene", + "Generated code is never written until you save or copy it.", + ); + + ui.horizontal(|ui| { + widgets::section_label(ui, "Format"); + let previous = cx.export.format; + ui.selectable_value(&mut cx.export.format, ExportFormat::Bsn, "BSN (0.20)"); + ui.selectable_value(&mut cx.export.format, ExportFormat::Rust, "Rust commands"); + if cx.export.format != previous { + regenerate = true; + } + }); + ui.label(theme::hint(cx.export.format.description())); + ui.add_space(8.0); + + ui.horizontal(|ui| { + ui.allocate_ui_with_layout( + egui::vec2(96.0, 24.0), + egui::Layout::left_to_right(egui::Align::Center), + |ui| { + ui.label(theme::hint("Function name")); + }, + ); + let response = ui.add( + egui::TextEdit::singleline(&mut cx.export.function_name) + .id_salt("dialogs_export_function") + .desired_width(260.0) + .font(egui::TextStyle::Monospace), + ); + if state.export_focus_pending { + response.request_focus(); + state.export_focus_pending = false; + } + if response.changed() { + regenerate = true; + cx.export.error = None; + } + if response.lost_focus() && ui.input(|input| input.key_pressed(egui::Key::Enter)) { + regenerate = true; + } + }); + ui.horizontal(|ui| { + ui.allocate_ui_with_layout( + egui::vec2(96.0, 24.0), + egui::Layout::left_to_right(egui::Align::Center), + |ui| { + ui.label(theme::hint("Output file")); + }, + ); + let path_response = ui.add( + egui::TextEdit::singleline(&mut cx.export.path) + .id_salt("dialogs_export_path") + .desired_width(ui.available_width()) + .font(egui::TextStyle::Monospace), + ); + if path_response.changed() { + state.export_attempted = false; + } + }); + + if !function_valid { + ui.label( + egui::RichText::new(if cx.export.function_name.trim().is_empty() { + "Function name is required." + } else { + "Function name must be a valid Rust identifier (letters, digits, underscores)." + }) + .color(theme::ERROR) + .size(11.0), + ); + } + match &path_result { + Ok(path) => preview_path(ui, "Will write to", path), + Err(message) => { + if state.export_attempted { + error_box(ui, message); + } + } + } + if let Some(error) = &cx.export.error { + error_box(ui, error); + } + + ui.add_space(8.0); + ui.horizontal(|ui| { + ui.label( + egui::RichText::new("Preview") + .strong() + .size(12.5) + .color(theme::TEXT), + ); + ui.label(egui::RichText::new(cx.export.format.label()).size(10.5).color(theme::FAINT)); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if ui.button("Regenerate").clicked() { + regenerate = true; + } + }); + }); + egui::Frame::new() + .fill(theme::BG) + .stroke(egui::Stroke::new(1.0_f32, theme::BORDER)) + .corner_radius(egui::CornerRadius::same(6)) + .inner_margin(egui::Margin::symmetric(10, 8)) + .show(ui, |ui| { + egui::ScrollArea::both() + .max_height(300.0) + .auto_shrink([false, false]) + .show(ui, |ui| { + if let Some(code) = &cx.export.code { + ui.add( + egui::Label::new( + egui::RichText::new(code) + .monospace() + .size(11.0) + .color(theme::TEXT), + ) + .selectable(true) + .extend(), + ); + } else if let Some(error) = &cx.export.error { + ui.add( + egui::Label::new( + egui::RichText::new(error) + .color(theme::ERROR) + .size(11.5), + ) + .wrap(), + ); + } else { + ui.label(theme::hint( + "No preview yet — press Regenerate to build the snippet.", + )); + } + }); + }); + + ui.add_space(10.0); + ui.horizontal(|ui| { + if ui.button("Regenerate").clicked() { + regenerate = true; + } + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if ui + .add_enabled( + function_valid && path_result.is_ok(), + widgets::primary_button("Save to file"), + ) + .clicked() + { + submit = true; + } + if ui + .add_enabled(function_valid, egui::Button::new("Copy")) + .on_hover_text("Copy the generated snippet to the clipboard") + .clicked() + { + copy = true; + } + if ui.button("Close").clicked() { + close = true; + } + if cx.export.copied { + ui.label( + egui::RichText::new("Copied to clipboard") + .color(theme::SUCCESS) + .size(11.0), + ); + } + }); + }); + }); + + if regenerate { + cx.export.regenerate(cx.session.doc()); + } + if copy { + cx.export.copied = true; + actions.push(Action::CopyExport(cx.export.format)); + } + if submit { + let function_valid = valid_function_name(&cx.export.function_name); + match resolve_export_path(project, &cx.export.path) { + Ok(path) if function_valid => { + actions.push(Action::ExportTo { + format: cx.export.format, + path, + }); + } + _ => state.export_attempted = true, + } + } + if modal.should_close() || close { + cx.export.dialog_open = false; + state.export_was_open = false; + state.export_focus_pending = false; + state.export_attempted = false; + } +} + +// --------------------------------------------------------------------------- +// Unsaved changes +// --------------------------------------------------------------------------- + +fn unsaved_changes_dialog( + ctx: &egui::Context, + cx: &mut EditorContext<'_>, + state: &mut DialogsState, + actions: &mut Vec, +) { + if !state.confirm_quit_open { + return; + } + let scene_name = cx.session.doc().name.clone(); + let dirty = cx.session.is_dirty(); + let mut quit = false; + let mut close = false; + let modal = egui::Modal::new(modal_id("unsaved")) + .area(dialog_area("unsaved")) + .frame(dialog_frame(ctx)) + .show(ctx, |ui| { + ui.set_width(470.0); + title_row(ui, "Unsaved changes", ""); + ui.add( + egui::Label::new( + egui::RichText::new(format!( + "“{scene_name}” has changes that have not been saved." + )) + .size(13.0) + .color(theme::TEXT), + ) + .wrap(), + ); + if !dirty { + ui.label(theme::hint("The scene is currently marked as saved.")); + } + ui.add_space(6.0); + egui::Frame::new() + .fill(theme::PANEL_ALT) + .stroke(egui::Stroke::new(1.0_f32, theme::BORDER)) + .corner_radius(egui::CornerRadius::same(5)) + .inner_margin(egui::Margin::symmetric(9, 7)) + .show(ui, |ui| { + ui.add( + egui::Label::new( + egui::RichText::new( + "Save first with Ctrl+S (or File → Save as…), then quit again. This dialog never saves silently.", + ) + .size(11.0) + .color(theme::MUTED), + ) + .wrap(), + ); + }); + ui.add_space(10.0); + ui.horizontal(|ui| { + if ui.button("Cancel").clicked() { + close = true; + } + if ui.add(widgets::danger_button("Quit without saving")).clicked() { + quit = true; + } + }); + }); + + if quit { + state.confirm_quit_open = false; + actions.push(Action::QuitNow); + } + if modal.should_close() || close { + state.confirm_quit_open = false; + } +} + +// --------------------------------------------------------------------------- +// Shortcuts +// --------------------------------------------------------------------------- + +struct ShortcutGroup { + title: &'static str, + entries: &'static [(&'static str, &'static str)], +} + +const SHORTCUT_GROUPS: &[ShortcutGroup] = &[ + ShortcutGroup { + title: "File", + entries: &[ + ("Ctrl+S", "Save scene"), + ("Ctrl+Shift+S", "Save scene as…"), + ("Ctrl+O", "Open project"), + ("Ctrl+E", "Export BSN"), + ("F1", "Keyboard shortcuts"), + ], + }, + ShortcutGroup { + title: "Edit", + entries: &[ + ("Ctrl+Z", "Undo"), + ("Ctrl+Shift+Z", "Redo"), + ("Ctrl+Y", "Redo (alternate)"), + ("Ctrl+D", "Duplicate selection"), + ("Delete", "Delete selection"), + ("Ctrl+K", "Command palette"), + ], + }, + ShortcutGroup { + title: "View", + entries: &[ + ("2", "2D viewport"), + ("3", "3D viewport"), + ("F", "Fit view"), + ("G", "Toggle grid"), + ("Esc", "Close palette or dialog"), + ], + }, + ShortcutGroup { + title: "Navigation", + entries: &[ + ("Wheel", "Zoom the viewport"), + ("Middle drag", "Pan (2D) / orbit (3D)"), + ("Space + drag", "Pan the 2D viewport"), + ("Shift + middle drag", "Pan the 3D viewport"), + ("Alt + drag", "Orbit the 3D viewport"), + ], + }, +]; + +fn shortcuts_dialog(ctx: &egui::Context, state: &mut DialogsState) { + if !state.shortcuts_open { + return; + } + let modal = egui::Modal::new(modal_id("shortcuts")) + .area(dialog_area("shortcuts")) + .frame(dialog_frame(ctx)) + .show(ctx, |ui| { + ui.set_width(680.0); + title_row(ui, "Keyboard shortcuts", "Work faster in Bevy Forge."); + egui::ScrollArea::vertical() + .max_height(470.0) + .auto_shrink([false, true]) + .show(ui, |ui| { + ui.columns(2, |columns| { + for (index, group) in SHORTCUT_GROUPS.iter().enumerate() { + let column = if index < 2 { 0 } else { 1 }; + shortcut_group(&mut columns[column], group); + } + }); + }); + ui.add_space(6.0); + ui.horizontal(|ui| { + if ui.button("Close").clicked() { + ui.close(); + } + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + widgets::kbd(ui, "Esc"); + ui.label(egui::RichText::new("close").size(10.5).color(theme::FAINT)); + }); + }); + }); + if modal.should_close() { + state.shortcuts_open = false; + } +} + +fn shortcut_group(ui: &mut egui::Ui, group: &ShortcutGroup) { + widgets::section_label(ui, group.title); + egui::Grid::new(("shortcuts", group.title)) + .num_columns(2) + .spacing([16.0, 4.0]) + .show(ui, |ui| { + for (keys, description) in group.entries { + widgets::kbd(ui, keys); + ui.label( + egui::RichText::new(*description) + .size(12.0) + .color(theme::TEXT), + ); + ui.end_row(); + } + }); + ui.add_space(8.0); +} + +// --------------------------------------------------------------------------- +// About +// --------------------------------------------------------------------------- + +fn about_dialog(ctx: &egui::Context, state: &mut DialogsState) { + if !state.about_open { + return; + } + let modal = egui::Modal::new(modal_id("about")) + .area(dialog_area("about")) + .frame(dialog_frame(ctx)) + .show(ctx, |ui| { + ui.set_width(480.0); + title_row(ui, "About", ""); + ui.horizontal(|ui| { + diamond_mark(ui, 26.0, theme::ACCENT); + ui.add_space(2.0); + ui.vertical(|ui| { + ui.label( + egui::RichText::new("Bevy Forge") + .size(24.0) + .strong() + .color(theme::TEXT), + ); + ui.label( + egui::RichText::new(format!("version {}", env!("CARGO_PKG_VERSION"))) + .size(11.0) + .color(theme::FAINT), + ); + }); + }); + ui.add_space(6.0); + ui.add( + egui::Label::new( + egui::RichText::new( + "A visual scene editor for the Bevy engine. Arrange 2D and 3D scenes, edit components, then export paste-ready BSN (Bevy 0.20) or Rust code.", + ) + .size(12.5) + .color(theme::MUTED), + ) + .wrap(), + ); + ui.add_space(10.0); + egui::Grid::new("about_facts") + .num_columns(2) + .spacing([18.0, 5.0]) + .show(ui, |ui| { + for (label, value) in [ + ("Bevy target", "0.20 · BSN scene notation"), + ("License", "MIT OR Apache-2.0"), + ("Editor UI", "egui / eframe 0.32"), + ("Scene format", ".forge.ron (RON)"), + ] { + ui.label(theme::hint(label)); + ui.label( + egui::RichText::new(value) + .size(12.0) + .color(theme::TEXT), + ); + ui.end_row(); + } + }); + ui.add_space(8.0); + egui::Frame::new() + .fill(theme::PANEL_ALT) + .stroke(egui::Stroke::new(1.0_f32, theme::BORDER)) + .corner_radius(egui::CornerRadius::same(5)) + .inner_margin(egui::Margin::symmetric(9, 7)) + .show(ui, |ui| { + ui.add( + egui::Label::new( + egui::RichText::new( + "BSN scenes are Rust code in Bevy 0.20: paste generated snippets into your game and build them with `bsn!` / `bsn_list!`. The editor itself has no Bevy dependency.", + ) + .size(11.0) + .color(theme::MUTED), + ) + .wrap(), + ); + }); + ui.add_space(10.0); + ui.horizontal(|ui| { + if ui.button("Close").clicked() { + ui.close(); + } + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + ui.label( + egui::RichText::new("Made for the Bevy community") + .size(11.0) + .color(theme::ACCENT), + ); + }); + }); + }); + if modal.should_close() { + state.about_open = false; + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn test_project() -> Project { + Project { + root: PathBuf::from("/tmp/forge-project"), + name: "forge-project".into(), + assets_dir: PathBuf::from("/tmp/forge-project/assets"), + bevy_version: Some("0.20".into()), + } + } + + #[test] + fn scene_extension_is_appended_or_normalised() { + assert_eq!( + normalize_scene_path("scenes/level").unwrap(), + "scenes/level.forge.ron" + ); + assert_eq!( + normalize_scene_path("scenes/level.ron").unwrap(), + "scenes/level.forge.ron" + ); + assert_eq!( + normalize_scene_path("scenes/level.forge.ron").unwrap(), + "scenes/level.forge.ron" + ); + assert!(normalize_scene_path(" ").is_err()); + assert!(normalize_scene_path("scenes/").is_err()); + assert_eq!( + normalize_scene_path("\"scenes/quoted\"").unwrap(), + "scenes/quoted.forge.ron" + ); + } + + #[test] + fn export_extension_is_enforced() { + assert_eq!( + normalize_export_path("export/scene").unwrap(), + "export/scene.rs" + ); + assert_eq!( + normalize_export_path("export/scene.rs").unwrap(), + "export/scene.rs" + ); + assert!(normalize_export_path("").is_err()); + assert!(normalize_export_path("export/").is_err()); + } + + #[test] + fn save_path_requires_a_project() { + let error = resolve_save_path(None, "scenes/a.forge.ron").unwrap_err(); + assert!(error.contains("Open a Bevy project")); + } + + #[test] + fn save_path_resolves_inside_project() { + let project = test_project(); + let resolved = resolve_save_path(Some(&project), "scenes/a").unwrap(); + assert_eq!( + resolved, + PathBuf::from("/tmp/forge-project/scenes/a.forge.ron") + ); + let absolute = resolve_save_path( + Some(&project), + "/tmp/forge-project/scenes/absolute.forge.ron", + ) + .unwrap(); + assert_eq!( + absolute, + PathBuf::from("/tmp/forge-project/scenes/absolute.forge.ron") + ); + } + + #[test] + fn save_path_rejects_paths_outside_project() { + let project = test_project(); + assert!(resolve_save_path(Some(&project), "/tmp/elsewhere/a.forge.ron").is_err()); + assert!(resolve_save_path(Some(&project), "../escape.forge.ron").is_err()); + assert!(resolve_save_path(Some(&project), "scenes/../../escape.forge.ron").is_err()); + } + + #[test] + fn open_scene_paths_are_project_relative() { + let project = test_project(); + assert_eq!( + resolve_open_scene_path(Some(&project), "garden").unwrap(), + PathBuf::from("/tmp/forge-project/scenes/garden.forge.ron") + ); + assert_eq!( + resolve_open_scene_path(Some(&project), "scenes/garden").unwrap(), + PathBuf::from("/tmp/forge-project/scenes/garden.forge.ron") + ); + assert!(resolve_open_scene_path(None, "garden").is_err()); + } + + #[test] + fn project_input_is_validated_and_tilde_expanded() { + assert!(validate_project_input("").is_err()); + assert!(validate_project_input(" ").is_err()); + assert_eq!( + validate_project_input(" /tmp/game ").unwrap(), + PathBuf::from("/tmp/game") + ); + assert_eq!( + expand_tilde_with("~/games/demo", Some(Path::new("/home/emil"))), + PathBuf::from("/home/emil/games/demo") + ); + assert_eq!( + expand_tilde_with("/tmp/game", Some(Path::new("/home/emil"))), + PathBuf::from("/tmp/game") + ); + assert_eq!( + expand_tilde_with("~", Some(Path::new("/home/emil"))), + PathBuf::from("/home/emil") + ); + } + + #[test] + fn export_paths_are_resolved_against_the_project() { + let project = test_project(); + assert_eq!( + resolve_export_path(Some(&project), "export/scene").unwrap(), + PathBuf::from("/tmp/forge-project/export/scene.rs") + ); + assert_eq!( + resolve_export_path(None, "export/scene").unwrap(), + PathBuf::from("export/scene.rs") + ); + assert!(resolve_export_path(Some(&project), "").is_err()); + } + + #[test] + fn function_name_validation_matches_rust_identifiers() { + assert!(valid_function_name("scene")); + assert!(valid_function_name("_spawn_garden2")); + assert!(!valid_function_name("")); + assert!(!valid_function_name("2scene")); + assert!(!valid_function_name("my scene")); + assert!(valid_function_name(" scene ")); + } + + #[test] + fn scene_names_drop_the_extension() { + assert_eq!( + scene_name(Path::new("/tmp/forge/scenes/garden.forge.ron")), + "garden" + ); + assert_eq!( + scene_name(Path::new("/tmp/forge/scenes/other.ron")), + "other.ron" + ); + } + + #[test] + fn opening_flag_detects_transitions() { + let mut was_open = false; + assert!(opening(true, &mut was_open)); + assert!(!opening(true, &mut was_open)); + assert!(!opening(false, &mut was_open)); + assert!(opening(true, &mut was_open)); + } +} diff --git a/crates/bevy_forge_editor/src/hierarchy.rs b/crates/bevy_forge_editor/src/hierarchy.rs new file mode 100644 index 0000000..cfda0a7 --- /dev/null +++ b/crates/bevy_forge_editor/src/hierarchy.rs @@ -0,0 +1,1929 @@ +//! Scene hierarchy tree: selection, rename, drag-to-reparent, visibility and the +//! Add-entity menu. +//! +//! The panel is the editor's primary navigation surface. It is deliberately +//! self-contained: all undoable scene mutations go through [`Session::edit`] and +//! the only deferred work is emitted as [`Action`]s for the app shell. +//! +//! Layout is painted manually (one interaction rect per row) because a full-width +//! row needs click, double-click, drag and context-menu handling while the small +//! arrow/eye/lock targets must win the hit test. Filtering, row construction and +//! drop resolution are pure functions so they are unit-tested without egui. + +use std::collections::HashSet; + +use bevy_forge_core::{ + Color, ComponentData, DirectionalLightData, EntityId, EntityNode, MaterialData, Mesh3dData, + MeshSource, PointLightData, PrimitiveShape, SceneDoc, SceneError, Session, SpriteData, + Text2dData, Transform, Vec2, Vec3, +}; +use egui::{Align2, Color32, FontId, Id, Pos2, Rect, Sense, Stroke}; + +use crate::action::Action; +use crate::state::{EditorContext, Selection, ViewMode}; +use crate::{theme, widgets}; + +// --------------------------------------------------------------------------- +// Panel state +// --------------------------------------------------------------------------- + +#[derive(Default)] +pub struct HierarchyPanelState { + pub renaming: Option, + pub rename_buffer: String, + pub add_menu_open: bool, + pub dragging: Option, + pub collapsed: HashSet, + /// Anchor of a Shift-range selection (last plainly clicked row). + pub anchor: Option, + /// Focus the inline rename editor on the next frame. + pub rename_focus: bool, + /// Scroll this entity into view once (new entities, rename start). + pub scroll_to: Option, + /// Tracked to auto-expand ancestors when another panel selects an entity. + pub last_primary: Option, +} + +// --------------------------------------------------------------------------- +// Metrics +// --------------------------------------------------------------------------- + +const ROW_HEIGHT: f32 = 24.0; +const INDENT_WIDTH: f32 = 14.0; +const ARROW_SLOT: f32 = 16.0; +const BADGE_SLOT: f32 = 15.0; +const BADGE_SLOTS: usize = 3; +const BUTTON_SLOT: f32 = 21.0; +const RIGHT_PAD: f32 = 6.0; +const FOOTER_HEIGHT: f32 = 84.0; + +// --------------------------------------------------------------------------- +// Add menu +// --------------------------------------------------------------------------- + +/// Every entry of the hierarchy "Add" menu. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum AddKind { + Empty, + Sprite, + Text2d, + Camera2d, + Camera3d, + PointLight, + DirectionalLight, + Cube, + Sphere, + Plane, +} + +impl AddKind { + const ALL: [Self; 10] = [ + Self::Empty, + Self::Sprite, + Self::Text2d, + Self::Camera2d, + Self::Camera3d, + Self::PointLight, + Self::DirectionalLight, + Self::Cube, + Self::Sphere, + Self::Plane, + ]; + + const fn label(self) -> &'static str { + match self { + Self::Empty => "Empty", + Self::Sprite => "Sprite", + Self::Text2d => "Text2d", + Self::Camera2d => "Camera2d", + Self::Camera3d => "Camera3d", + Self::PointLight => "PointLight", + Self::DirectionalLight => "DirectionalLight", + Self::Cube => "Cube", + Self::Sphere => "Sphere", + Self::Plane => "Plane", + } + } + + /// Base display name for a newly created entity (before uniqueness suffixing). + fn default_name(self) -> &'static str { + match self { + Self::Empty => "Entity", + Self::PointLight => "Point Light", + Self::DirectionalLight => "Directional Light", + other => other.label(), + } + } + + fn undo_label(self) -> String { + format!("Add {}", self.label()) + } +} + +// --------------------------------------------------------------------------- +// Filtering and row construction (pure) +// --------------------------------------------------------------------------- + +#[derive(Clone, Debug, PartialEq, Eq)] +struct Row { + id: EntityId, + depth: usize, +} + +#[derive(Debug, Default)] +struct FilteredTree { + /// Entities that should be visible: the matches plus all their ancestors. + visible: HashSet, + /// Entities whose name matched the query, in scene insertion order. + matches: Vec, +} + +/// Case-insensitive name filter that keeps the ancestors of every match. +fn filter_tree(doc: &SceneDoc, query: &str) -> FilteredTree { + let needle = query.trim().to_lowercase(); + if needle.is_empty() { + return FilteredTree { + visible: doc.entities.keys().copied().collect(), + matches: Vec::new(), + }; + } + let mut visible = HashSet::new(); + let mut matches = Vec::new(); + for node in doc.entities.values() { + if !node.name.to_lowercase().contains(&needle) { + continue; + } + matches.push(node.id); + visible.insert(node.id); + let mut current = node.parent; + let mut guard = 0usize; + while let Some(parent) = current { + // `false` means the ancestor was already added (and so were its own + // ancestors), which terminates the walk. + if !visible.insert(parent) { + break; + } + guard += 1; + if guard > 256 { + break; + } + current = doc.parent(parent); + } + } + FilteredTree { visible, matches } +} + +/// Flatten the filtered forest into display rows honouring collapsed state. +/// +/// While a search is active every match is forced expanded so its ancestors and +/// the matches themselves stay visible. +fn build_rows( + doc: &SceneDoc, + filtered: &FilteredTree, + collapsed: &HashSet, + force_expanded: bool, +) -> Vec { + let mut rows = Vec::new(); + let mut seen = HashSet::new(); + for root in &doc.roots { + if filtered.visible.contains(root) { + collect_rows( + doc, + *root, + 0, + filtered, + collapsed, + force_expanded, + &mut seen, + &mut rows, + ); + } + } + rows +} + +#[allow(clippy::too_many_arguments)] +fn collect_rows( + doc: &SceneDoc, + id: EntityId, + depth: usize, + filtered: &FilteredTree, + collapsed: &HashSet, + force_expanded: bool, + seen: &mut HashSet, + rows: &mut Vec, +) { + if depth > 256 || !seen.insert(id) { + return; + } + let Some(node) = doc.entity(id) else { + return; + }; + rows.push(Row { id, depth }); + if !force_expanded && collapsed.contains(&id) { + return; + } + for child in &node.children { + if filtered.visible.contains(child) { + collect_rows( + doc, + *child, + depth + 1, + filtered, + collapsed, + force_expanded, + seen, + rows, + ); + } + } +} + +// --------------------------------------------------------------------------- +// Row presentation data +// --------------------------------------------------------------------------- + +#[derive(Clone, Copy)] +struct Badge { + letter: &'static str, + color: Color32, +} + +#[derive(Clone)] +struct RowView { + id: EntityId, + depth: usize, + name: String, + has_children: bool, + child_count: usize, + hidden: bool, + locked: bool, + badges: Vec, + /// Component labels for the row tooltip. + components: String, +} + +#[derive(Clone, Copy, Debug)] +struct RowGeom { + id: EntityId, + depth: usize, + rect: Rect, +} + +fn collect_views(doc: &SceneDoc, rows: &[Row]) -> Vec { + let mut views = Vec::with_capacity(rows.len()); + for row in rows { + let Some(node) = doc.entity(row.id) else { + continue; + }; + views.push(RowView { + id: node.id, + depth: row.depth, + name: node.name.clone(), + has_children: !node.children.is_empty(), + child_count: node.children.len(), + hidden: node.is_hidden(), + locked: node.locked, + badges: badges_for(node), + components: node + .components + .iter() + .map(|component| component.label()) + .collect::>() + .join(", "), + }); + } + views +} + +/// Composition badges: one compact letter per component family, in the order the +/// components appear on the entity. +fn badges_for(node: &EntityNode) -> Vec { + let mut badges = Vec::new(); + for component in &node.components { + let badge = match component { + ComponentData::Sprite(_) => Some(Badge { + letter: "S", + color: theme::SUCCESS, + }), + ComponentData::Text2d(_) => Some(Badge { + letter: "T", + color: theme::WARNING, + }), + ComponentData::Camera2d | ComponentData::Camera3d => Some(Badge { + letter: "C", + color: theme::ACCENT, + }), + ComponentData::PointLight(_) | ComponentData::DirectionalLight(_) => Some(Badge { + letter: "L", + color: theme::SELECTION, + }), + ComponentData::Mesh3d(_) => Some(Badge { + letter: "M", + color: theme::Z_AXIS, + }), + ComponentData::Custom(_) => Some(Badge { + letter: "X", + color: theme::MUTED, + }), + ComponentData::Visibility(_) => None, + }; + if let Some(badge) = badge { + if badges.len() < BADGE_SLOTS { + badges.push(badge); + } + } + } + badges +} + +// --------------------------------------------------------------------------- +// Entity creation +// --------------------------------------------------------------------------- + +/// A display name that does not collide with any entity in the scene. +fn unique_name(doc: &SceneDoc, base: &str) -> String { + if doc.find_by_name(base).is_none() { + return base.to_string(); + } + let mut index = 2u32; + loop { + let candidate = format!("{base} {index}"); + if doc.find_by_name(&candidate).is_none() { + return candidate; + } + index += 1; + if index > 10_000 { + return candidate; + } + } +} + +/// Default transform for a new root entity. Parented entities start at the +/// parent's origin (identity) so they follow the parent's placement. +fn default_transform( + doc: &SceneDoc, + kind: AddKind, + parent: Option, + view_mode: ViewMode, +) -> Transform { + if parent.is_some() { + return Transform::IDENTITY; + } + match kind { + AddKind::Empty => Transform::IDENTITY, + AddKind::Sprite | AddKind::Text2d => { + if view_mode == ViewMode::TwoD { + // Place new 2D content at the 2D camera's center, which is where + // the user is looking. + let center = doc + .entities + .values() + .find(|node| { + matches!(node.component("camera2d"), Some(ComponentData::Camera2d)) + }) + .map(|node| node.transform.translation) + .unwrap_or(Vec3::ZERO); + let z = if kind == AddKind::Text2d { 1.0 } else { 0.0 }; + Transform::from_xyz(center.x, center.y, z) + } else { + Transform::IDENTITY + } + } + AddKind::Camera2d => Transform::from_xyz(0.0, 0.0, 10.0), + AddKind::Camera3d => { + Transform::from_xyz(0.0, 4.0, 10.0).with_rotation(Vec3::new(-15.0, 0.0, 0.0)) + } + AddKind::PointLight => Transform::from_xyz(2.0, 3.0, 2.0), + AddKind::DirectionalLight => { + Transform::IDENTITY.with_rotation(Vec3::new(-50.0, -30.0, 0.0)) + } + AddKind::Cube | AddKind::Sphere => Transform::from_xyz(0.0, 0.5, 0.0), + AddKind::Plane => Transform::IDENTITY, + } +} + +/// Sensible default components for every add-menu entry. +fn default_components(kind: AddKind, view_mode: ViewMode, color: Color) -> Vec { + match kind { + AddKind::Empty => Vec::new(), + AddKind::Sprite => { + let size = if view_mode == ViewMode::TwoD { + Vec2::new(64.0, 64.0) + } else { + Vec2::ONE + }; + vec![ComponentData::Sprite(SpriteData { + image: None, + color, + custom_size: Some(size), + flip_x: false, + flip_y: false, + })] + } + AddKind::Text2d => vec![ComponentData::Text2d(Text2dData { + text: "Text".into(), + font_size: 32.0, + color: Color::WHITE, + })], + AddKind::Camera2d => vec![ComponentData::Camera2d], + AddKind::Camera3d => vec![ComponentData::Camera3d], + AddKind::PointLight => vec![ComponentData::PointLight(PointLightData::default())], + AddKind::DirectionalLight => vec![ComponentData::DirectionalLight( + DirectionalLightData::default(), + )], + AddKind::Cube => vec![mesh_component( + PrimitiveShape::Cuboid { size: Vec3::ONE }, + color, + )], + AddKind::Sphere => vec![mesh_component( + PrimitiveShape::Sphere { radius: 0.5 }, + color, + )], + AddKind::Plane => vec![mesh_component( + PrimitiveShape::Plane { + size: Vec2::new(4.0, 4.0), + }, + color, + )], + } +} + +fn mesh_component(shape: PrimitiveShape, color: Color) -> ComponentData { + ComponentData::Mesh3d(Mesh3dData { + mesh: MeshSource::Primitive(shape), + material: MaterialData { + base_color: color, + metallic: 0.05, + perceptual_roughness: 0.6, + unlit: false, + }, + }) +} + +/// Create one entity and record exactly one undo entry ("Add "). +fn create_entity( + session: &mut Session, + kind: AddKind, + parent: Option, + view_mode: ViewMode, + color: Color, +) -> EntityId { + let doc = session.doc(); + let parent = parent.filter(|id| doc.entity(*id).is_some()); + let name = unique_name(doc, kind.default_name()); + let transform = default_transform(doc, kind, parent, view_mode); + let components = default_components(kind, view_mode, color); + let label = kind.undo_label(); + session.edit(label, |doc| { + let id = doc.add_entity(name, parent); + if let Some(node) = doc.entity_mut(id) { + node.transform = transform; + } + for component in components { + doc.add_component(id, component); + } + id + }) +} + +/// Spawn from the panel: create, reveal, select and report the new entity. +fn spawn_entity( + cx: &mut EditorContext<'_>, + state: &mut HierarchyPanelState, + kind: AddKind, + parent: Option, +) -> EntityId { + let color = cx.default_color(); + let view_mode = cx.ui.view_mode; + let id = create_entity(cx.session, kind, parent, view_mode, color); + expand_ancestors(cx.session.doc(), id, &mut state.collapsed); + state.collapsed.remove(&id); + state.scroll_to = Some(id); + cx.ui.selection.select_only(id); + cx.ui.status = format!("Added {}", kind.label()); + id +} + +/// Parent for menu-created entities: exactly one selected entity, else root. +fn add_parent(selection: &Selection) -> Option { + if selection.len() == 1 { + selection.primary_or_single() + } else { + None + } +} + +fn expand_ancestors(doc: &SceneDoc, id: EntityId, collapsed: &mut HashSet) { + let mut current = doc.parent(id); + let mut guard = 0usize; + while let Some(parent) = current { + collapsed.remove(&parent); + guard += 1; + if guard > 256 { + break; + } + current = doc.parent(parent); + } +} + +fn collapse_all(doc: &SceneDoc, collapsed: &mut HashSet) { + for node in doc.entities.values() { + if !node.children.is_empty() { + collapsed.insert(node.id); + } + } +} + +// --------------------------------------------------------------------------- +// Selection +// --------------------------------------------------------------------------- + +/// Apply a click with Ctrl/Cmd toggle and best-effort Shift range semantics. +fn apply_click( + selection: &mut Selection, + flat: &[EntityId], + id: EntityId, + command: bool, + shift: bool, + anchor: &mut Option, +) { + if shift && !command { + if let Some(anchor_id) = *anchor { + if anchor_id != id { + let from = flat.iter().position(|candidate| *candidate == anchor_id); + let to = flat.iter().position(|candidate| *candidate == id); + if let (Some(from), Some(to)) = (from, to) { + let (low, high) = if from <= to { (from, to) } else { (to, from) }; + selection.select_many(flat[low..=high].iter().copied(), Some(id)); + return; + } + } + } + selection.select_only(id); + *anchor = Some(id); + } else if command { + selection.toggle(id); + *anchor = Some(id); + } else { + selection.select_only(id); + *anchor = Some(id); + } +} + +// --------------------------------------------------------------------------- +// Rename +// --------------------------------------------------------------------------- + +fn begin_rename(cx: &mut EditorContext<'_>, state: &mut HierarchyPanelState, id: EntityId) { + if state.renaming == Some(id) { + return; + } + if state.renaming.is_some() { + commit_rename(cx, state); + } + let name = match cx.session.doc().entity(id) { + Some(node) => node.name.clone(), + None => return, + }; + state.rename_buffer = name; + state.renaming = Some(id); + state.rename_focus = true; + state.scroll_to = Some(id); + expand_ancestors(cx.session.doc(), id, &mut state.collapsed); +} + +fn commit_rename(cx: &mut EditorContext<'_>, state: &mut HierarchyPanelState) { + let Some(id) = state.renaming.take() else { + return; + }; + let mut name = state.rename_buffer.trim().to_string(); + state.rename_buffer.clear(); + if name.is_empty() { + name = "Entity".to_string(); + cx.toasts + .warning("Entity names cannot be empty — renamed to \"Entity\""); + } + if cx.session.doc().entity(id).is_none() { + return; + } + cx.session.edit("Rename", |doc| { + doc.rename(id, &name); + }); + cx.ui.status = format!("Renamed to {name}"); +} + +// --------------------------------------------------------------------------- +// Drag & drop reparenting +// --------------------------------------------------------------------------- + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum DropTarget { + /// Insert as a sibling before this row. + Before(EntityId), + /// Insert as a sibling after this row. + After(EntityId), + /// Make this row the new parent (append as last child). + Inside(EntityId), + /// Append as the last scene root. + RootsEnd, +} + +/// Resolve the pointer position over the rendered rows into a drop target. +/// +/// The top and bottom 28% of a row act as sibling insertion points; the middle +/// makes the row a parent. The area below the tree appends to the scene roots. +fn drop_target_at(pointer: Pos2, geoms: &[RowGeom], viewport: Rect) -> Option { + if geoms.is_empty() + || pointer.x < viewport.left() + || pointer.x > viewport.right() + || pointer.y < viewport.top() + { + return None; + } + let last = geoms.last()?; + if pointer.y > last.rect.bottom() { + return (pointer.y <= viewport.bottom()).then_some(DropTarget::RootsEnd); + } + for (index, geom) in geoms.iter().enumerate() { + if pointer.y < geom.rect.top() { + if index == 0 { + return Some(DropTarget::Before(geom.id)); + } + return Some(DropTarget::After(geoms[index - 1].id)); + } + if pointer.y <= geom.rect.bottom() { + let relative = (pointer.y - geom.rect.top()) / geom.rect.height().max(1.0); + return Some(if relative < 0.28 { + DropTarget::Before(geom.id) + } else if relative > 0.72 { + DropTarget::After(geom.id) + } else { + DropTarget::Inside(geom.id) + }); + } + } + Some(DropTarget::RootsEnd) +} + +/// Turn a drop target into the `(parent, index)` arguments of +/// [`SceneDoc::reparent`]. The index is relative to the destination list with the +/// source removed, which is exactly what `reparent` expects after detaching. +fn resolve_drop( + doc: &SceneDoc, + source: EntityId, + target: DropTarget, +) -> Option<(Option, Option)> { + doc.entity(source)?; + match target { + DropTarget::Inside(parent) => { + doc.entity(parent)?; + Some((Some(parent), None)) + } + DropTarget::Before(sibling) | DropTarget::After(sibling) => { + if sibling == source { + // Dropping a row on its own insertion line is a no-op. + return None; + } + doc.entity(sibling)?; + let parent = doc.parent(sibling); + let mut siblings = doc.siblings(parent).to_vec(); + siblings.retain(|id| *id != source); + let position = siblings + .iter() + .position(|candidate| *candidate == sibling)?; + let index = if matches!(target, DropTarget::After(_)) { + position + 1 + } else { + position + }; + Some((parent, Some(index))) + } + DropTarget::RootsEnd => Some((None, None)), + } +} + +/// Apply a resolved drop as exactly one undo entry. +fn reparent_for_drop( + session: &mut Session, + source: EntityId, + target: DropTarget, +) -> Result<(), SceneError> { + let Some((parent, index)) = resolve_drop(session.doc(), source, target) else { + return Ok(()); + }; + let name = session + .doc() + .entity(source) + .map(|node| node.name.clone()) + .unwrap_or_default(); + session.edit(format!("Reparent {name}"), |doc| { + doc.reparent(source, parent, index) + }) +} + +fn perform_drop( + cx: &mut EditorContext<'_>, + state: &mut HierarchyPanelState, + source: EntityId, + target: DropTarget, +) { + let Some((parent, _)) = resolve_drop(cx.session.doc(), source, target) else { + return; + }; + let name = cx + .session + .doc() + .entity(source) + .map(|node| node.name.clone()) + .unwrap_or_default(); + match reparent_for_drop(cx.session, source, target) { + Ok(()) => { + if let Some(parent) = parent { + state.collapsed.remove(&parent); + expand_ancestors(cx.session.doc(), parent, &mut state.collapsed); + } + state.scroll_to = Some(source); + cx.ui.status = format!("Moved {name}"); + } + Err(error) => { + let message = match error { + SceneError::Cycle { .. } => { + format!("Cannot move \"{name}\" into its own descendant") + } + SceneError::SelfParent(_) => format!("Cannot move \"{name}\" into itself"), + other => format!("Could not move \"{name}\": {other}"), + }; + cx.toasts.error(message); + } + } +} + +fn paint_drop_indicator(painter: &egui::Painter, target: DropTarget, geoms: &[RowGeom]) { + let accent = theme::ACCENT; + let fill = Color32::from_rgba_unmultiplied(accent.r(), accent.g(), accent.b(), 46); + let right = painter.clip_rect().right() - 4.0; + let line = |y: f32, left: f32| { + painter.line_segment( + [egui::pos2(left, y), egui::pos2(right, y)], + Stroke::new(2.0_f32, accent), + ); + painter.circle_filled(egui::pos2(left, y), 3.0, accent); + }; + let geom_of = |id: EntityId| geoms.iter().find(|geom| geom.id == id).copied(); + match target { + DropTarget::Inside(id) => { + if let Some(geom) = geom_of(id) { + let rect = geom.rect.shrink2(egui::vec2(2.0, 1.0)); + painter.rect_filled(rect, 4.0, fill); + painter.rect_stroke( + rect, + 4.0, + Stroke::new(1.5_f32, accent), + egui::StrokeKind::Inside, + ); + } + } + DropTarget::Before(id) => { + if let Some(geom) = geom_of(id) { + let indent = 6.0 + geom.depth as f32 * INDENT_WIDTH; + line(geom.rect.top(), geom.rect.left() + indent); + } + } + DropTarget::After(id) => { + if let Some(geom) = geom_of(id) { + let indent = 6.0 + geom.depth as f32 * INDENT_WIDTH; + line(geom.rect.bottom() - 1.0, geom.rect.left() + indent); + } + } + DropTarget::RootsEnd => { + if let Some(geom) = geoms.last() { + line(geom.rect.bottom() - 1.0, geom.rect.left() + 6.0); + } + } + } +} + +// --------------------------------------------------------------------------- +// Add menu contents +// --------------------------------------------------------------------------- + +fn add_menu_items( + ui: &mut egui::Ui, + cx: &mut EditorContext<'_>, + state: &mut HierarchyPanelState, + parent: Option, +) { + let parent = parent.filter(|id| cx.session.doc().entity(*id).is_some()); + ui.label( + egui::RichText::new(match parent.and_then(|id| cx.session.doc().entity(id)) { + Some(node) => format!("Add as child of {}", short_label(&node.name, 22)), + None => "Add at scene root".to_string(), + }) + .color(theme::MUTED) + .size(11.0), + ); + ui.separator(); + for kind in AddKind::ALL { + if ui.button(kind.label()).clicked() { + spawn_entity(cx, state, kind, parent); + ui.close(); + } + } + ui.separator(); + ui.label( + egui::RichText::new("Select an entity first to add a child") + .color(theme::FAINT) + .size(10.5), + ); +} + +fn short_label(name: &str, max_chars: usize) -> String { + if name.chars().count() <= max_chars { + return name.to_string(); + } + let mut label: String = name.chars().take(max_chars.saturating_sub(1)).collect(); + label.push('…'); + label +} + +// --------------------------------------------------------------------------- +// Panel +// --------------------------------------------------------------------------- + +/// Render the left hierarchy panel. Returns deferred actions. +pub fn show( + ctx: &egui::Context, + cx: &mut EditorContext<'_>, + state: &mut HierarchyPanelState, +) -> Vec { + let mut actions = Vec::new(); + egui::SidePanel::left("hierarchy_panel") + .resizable(true) + .default_width(264.0) + .width_range(230.0..=460.0) + .show(ctx, |ui| { + ui.add_space(6.0); + let (scene_name, entity_count, root_count) = { + let doc = cx.session.doc(); + (doc.name.clone(), doc.entities.len(), doc.roots.len()) + }; + ui.add(egui::Label::new( + egui::RichText::new(&scene_name).strong().size(14.5), + )); + ui.label(theme::hint(format!( + "{entity_count} entities · {root_count} {}", + if root_count == 1 { "root" } else { "roots" } + ))); + + ui.add_space(4.0); + widgets::search_field(ui, "Search entities…", &mut cx.ui.hierarchy_search); + + let query = cx.ui.hierarchy_search.clone(); + let searching = !query.trim().is_empty(); + let filtered = filter_tree(cx.session.doc(), &query); + let force_expanded = searching; + + // Reveal entities selected by another panel (viewport click, undo...). + let primary = cx.ui.selection.primary(); + if primary != state.last_primary { + state.last_primary = primary; + if let Some(id) = primary { + if cx.session.doc().entity(id).is_some() { + expand_ancestors(cx.session.doc(), id, &mut state.collapsed); + } + } + } + + let rows = build_rows( + cx.session.doc(), + &filtered, + &state.collapsed, + force_expanded, + ); + let views = collect_views(cx.session.doc(), &rows); + + // A rename whose row disappeared (deleted, filtered, collapsed) is + // cancelled rather than silently committed. + if let Some(id) = state.renaming { + if !views.iter().any(|view| view.id == id) { + state.renaming = None; + state.rename_buffer.clear(); + } + } + if let Some(dragging) = state.dragging { + if cx.session.doc().entity(dragging).is_none() { + state.dragging = None; + } + } + + if entity_count == 0 { + ui.add_space(6.0); + widgets::empty_state( + ui, + "Your scene is empty", + "Add your first entity to start building the scene.", + ); + ui.vertical_centered(|ui| { + ui.menu_button("Add your first entity", |ui| { + add_menu_items(ui, cx, state, None); + }); + }); + return; + } + + // Toolbar: add menu, expand/collapse, and where new entities go. + ui.add_space(4.0); + ui.horizontal(|ui| { + let add = ui.add( + egui::Button::new(egui::RichText::new("+ Add").strong()) + .fill(theme::ACCENT_DIM) + .stroke(Stroke::new(1.0_f32, theme::ACCENT)), + ); + if add.clicked() { + state.add_menu_open = true; + } + let mut open = state.add_menu_open; + let parent = add_parent(&cx.ui.selection); + egui::Popup::menu(&add) + .open_bool(&mut open) + .show(|ui| add_menu_items(ui, cx, state, parent)); + state.add_menu_open = open; + + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if ui + .add(egui::Button::new("Collapse").frame(false).small()) + .on_hover_text("Collapse every entity with children") + .clicked() + { + collapse_all(cx.session.doc(), &mut state.collapsed); + } + if ui + .add(egui::Button::new("Expand").frame(false).small()) + .on_hover_text("Expand every collapsed entity") + .clicked() + { + state.collapsed.clear(); + } + }); + }); + + if searching { + ui.horizontal(|ui| { + if ui + .add(egui::Button::new("Clear").frame(false).small()) + .on_hover_text("Clear the search filter") + .clicked() + { + cx.ui.hierarchy_search.clear(); + } + if filtered.matches.is_empty() { + ui.label( + egui::RichText::new("No entities match your search") + .color(theme::WARNING) + .size(11.5), + ); + } else { + ui.label(theme::hint(format!( + "{} match{} · {} shown", + filtered.matches.len(), + if filtered.matches.len() == 1 { + "" + } else { + "es" + }, + views.len() + ))); + } + }); + } else { + let hint = + match add_parent(&cx.ui.selection).and_then(|id| cx.session.doc().entity(id)) { + Some(node) => format!( + "New entities become children of {}", + short_label(&node.name, 24) + ), + None => "New entities are added at the scene root".to_string(), + }; + ui.label(theme::hint(hint)); + } + + ui.separator(); + + let tree_height = (ui.available_height() - FOOTER_HEIGHT).max(96.0); + let flat_ids: Vec = rows.iter().map(|row| row.id).collect(); + let mut geoms: Vec = Vec::new(); + let scroll_output = egui::ScrollArea::vertical() + .id_salt("hierarchy_tree") + .auto_shrink([false, false]) + .max_height(tree_height) + .show(ui, |ui| { + ui.spacing_mut().item_spacing.y = 0.0; + ui.set_min_width(ui.available_width()); + if views.is_empty() { + widgets::empty_state( + ui, + "No matching entities", + "Try a different name or clear the search.", + ); + } else { + for view in &views { + geoms.push(row_ui( + ui, + cx, + state, + &mut actions, + view, + &flat_ids, + force_expanded, + )); + } + let (_, blank) = ui.allocate_exact_size( + egui::vec2(ui.available_width(), 6.0), + Sense::click(), + ); + if blank.clicked() && !ui.input(|input| input.modifiers.any()) { + cx.ui.selection.clear(); + state.anchor = None; + } + } + }); + // Drag & drop: compute the target from the row geometry of this + // frame, paint the insertion indicator, and apply on release. + if let Some(source) = state.dragging { + if ui.ctx().input(|input| input.key_pressed(egui::Key::Escape)) { + state.dragging = None; + } else { + let mut target = None; + if let Some(pointer) = ui.ctx().pointer_latest_pos() { + target = drop_target_at(pointer, &geoms, scroll_output.inner_rect); + } + if let Some(target) = target { + let painter = ui.painter().with_clip_rect(scroll_output.inner_rect); + paint_drop_indicator(&painter, target, &geoms); + } + ui.ctx().set_cursor_icon(egui::CursorIcon::Grabbing); + if ui.ctx().input(|input| input.pointer.any_released()) { + state.dragging = None; + if let Some(target) = target { + perform_drop(cx, state, source, target); + } + } + } + } + + // Footer: selection summary and bulk actions. + ui.add_space(2.0); + ui.separator(); + let selected = cx.ui.selection.len(); + ui.horizontal(|ui| { + if selected == 0 { + ui.label(theme::hint("No selection · click an entity")); + } else { + let primary_name = cx + .ui + .selection + .primary() + .and_then(|id| cx.session.doc().entity(id)) + .map(|node| short_label(&node.name, 22)); + match primary_name { + Some(name) if selected > 1 => { + ui.label(theme::hint(format!("{selected} selected · {name}"))); + } + Some(name) => { + ui.label(theme::hint(format!("{name} selected"))); + } + None => { + ui.label(theme::hint(format!("{selected} selected"))); + } + } + } + }); + ui.horizontal(|ui| { + let enabled = selected > 0; + if ui + .add_enabled(enabled, egui::Button::new("Duplicate")) + .on_hover_text("Duplicate the selected entities (Ctrl+D)") + .clicked() + { + actions.push(Action::DuplicateSelection); + } + if ui + .add_enabled(enabled, widgets::danger_button("Delete")) + .on_hover_text("Delete the selected entities (Delete)") + .clicked() + { + actions.push(Action::DeleteSelection); + } + if ui + .add_enabled(enabled, egui::Button::new("Focus")) + .on_hover_text("Frame the selection in the viewport (F)") + .clicked() + { + actions.push(Action::FocusSelection); + } + }); + }); + actions +} + +// --------------------------------------------------------------------------- +// One row +// --------------------------------------------------------------------------- + +#[allow(clippy::too_many_arguments)] +fn row_ui( + ui: &mut egui::Ui, + cx: &mut EditorContext<'_>, + state: &mut HierarchyPanelState, + actions: &mut Vec, + view: &RowView, + flat_ids: &[EntityId], + force_expanded: bool, +) -> RowGeom { + let width = ui.available_width().max(80.0); + let (rect, _) = ui.allocate_exact_size(egui::vec2(width, ROW_HEIGHT), Sense::hover()); + let response = ui.interact( + rect, + Id::new(("hierarchy_row", view.id)), + Sense::click_and_drag(), + ); + let painter = ui.painter().clone(); + let selected = cx.ui.selection.contains(view.id); + let row_rect = rect.shrink2(egui::vec2(2.0, 1.0)); + + if selected { + painter.rect_filled(row_rect, 4.0, theme::ACCENT_DIM); + } else if response.hovered() { + painter.rect_filled(row_rect, 4.0, theme::ELEVATED.gamma_multiply(0.7)); + } + if state.dragging == Some(view.id) { + painter.rect_stroke( + row_rect, + 4.0, + Stroke::new(1.0_f32, theme::ACCENT), + egui::StrokeKind::Inside, + ); + } + + let center_y = rect.center().y; + let text_font = egui::TextStyle::Body.resolve(ui.style()); + let expanded = force_expanded || !state.collapsed.contains(&view.id); + let mut x = rect.left() + 6.0 + view.depth as f32 * INDENT_WIDTH; + let mut button_clicked = false; + + // Expand/collapse arrow (painted triangle: no font dependency). + if view.has_children { + let arrow_rect = Rect::from_center_size( + egui::pos2(x + ARROW_SLOT * 0.5, center_y), + egui::vec2(ARROW_SLOT, ARROW_SLOT), + ); + let arrow = ui + .interact( + arrow_rect, + Id::new(("hierarchy_arrow", view.id)), + Sense::click(), + ) + .on_hover_text(if expanded { "Collapse" } else { "Expand" }); + if arrow.clicked() && !force_expanded { + if expanded { + state.collapsed.insert(view.id); + } else { + state.collapsed.remove(&view.id); + } + button_clicked = true; + } + let color = if arrow.hovered() { + theme::TEXT + } else { + theme::MUTED + }; + let center = arrow_rect.center(); + let points = if expanded { + vec![ + egui::pos2(center.x - 4.0, center.y - 2.0), + egui::pos2(center.x + 4.0, center.y - 2.0), + egui::pos2(center.x, center.y + 3.0), + ] + } else { + vec![ + egui::pos2(center.x - 2.0, center.y - 4.0), + egui::pos2(center.x + 3.0, center.y), + egui::pos2(center.x - 2.0, center.y + 4.0), + ] + }; + painter.add(egui::Shape::convex_polygon(points, color, Stroke::NONE)); + } + x += ARROW_SLOT; + + // Component-composition badges in fixed slots so names stay aligned. + for slot in 0..BADGE_SLOTS { + if let Some(badge) = view.badges.get(slot) { + let badge_rect = + Rect::from_center_size(egui::pos2(x + 6.0, center_y), egui::vec2(12.0, 13.0)); + let color = if view.hidden { + badge.color.gamma_multiply(0.55) + } else { + badge.color + }; + painter.rect_filled(badge_rect, 3.0, color.gamma_multiply(0.22)); + painter.text( + badge_rect.center(), + Align2::CENTER_CENTER, + badge.letter, + FontId::monospace(9.0), + color, + ); + } + x += BADGE_SLOT; + } + if view.badges.is_empty() { + painter.text( + egui::pos2(x - BADGE_SLOTS as f32 * BADGE_SLOT + 6.0, center_y), + Align2::CENTER_CENTER, + "·", + FontId::proportional(11.0), + theme::FAINT, + ); + } + + let right = rect.right() - RIGHT_PAD; + let lock_rect = Rect::from_center_size( + egui::pos2(right - BUTTON_SLOT * 0.5, center_y), + egui::vec2(BUTTON_SLOT, BUTTON_SLOT), + ); + let eye_rect = Rect::from_center_size( + egui::pos2(right - BUTTON_SLOT * 1.5, center_y), + egui::vec2(BUTTON_SLOT, BUTTON_SLOT), + ); + let name_right = (eye_rect.left() - 4.0).max(x + 6.0); + let name_rect = Rect::from_min_max( + egui::pos2(x, rect.top() + 1.0), + egui::pos2(name_right, rect.bottom() - 1.0), + ); + let renaming_this = state.renaming == Some(view.id); + + if renaming_this { + let response = ui.put( + name_rect, + egui::TextEdit::singleline(&mut state.rename_buffer) + .id_salt(("hierarchy_rename", view.id)) + .font(egui::TextStyle::Body) + .margin(egui::Margin::symmetric(4, 2)) + .desired_width(name_rect.width()), + ); + if state.rename_focus { + response.request_focus(); + state.rename_focus = false; + } + button_clicked = true; + let escape = ui.input(|input| input.key_pressed(egui::Key::Escape)); + let enter = ui.input(|input| input.key_pressed(egui::Key::Enter)); + if escape { + state.renaming = None; + state.rename_buffer.clear(); + } else if enter || response.lost_focus() { + commit_rename(cx, state); + } + } else { + let name_color = if view.hidden { + theme::FAINT + } else { + theme::TEXT + }; + if name_rect.width() > 4.0 { + let clipped = painter.with_clip_rect(name_rect.intersect(rect)); + let name = if view.name.trim().is_empty() { + "(unnamed)" + } else { + view.name.as_str() + }; + clipped.text( + egui::pos2(name_rect.left(), center_y), + Align2::LEFT_CENTER, + name, + text_font, + name_color, + ); + } + + // Eye: visibility toggle. + let eye = ui + .interact( + eye_rect, + Id::new(("hierarchy_eye", view.id)), + Sense::click(), + ) + .on_hover_text(if view.hidden { + "Hidden — click to show this entity" + } else { + "Visible — click to hide this entity" + }); + let eye_color = if eye.hovered() { + theme::TEXT + } else if view.hidden { + theme::FAINT + } else { + theme::MUTED + }; + painter.text( + eye_rect.center(), + Align2::CENTER_CENTER, + if view.hidden { "🚫" } else { "👁" }, + FontId::proportional(12.0), + eye_color, + ); + if eye.clicked() { + button_clicked = true; + let label = if view.hidden { + "Show entity" + } else { + "Hide entity" + }; + cx.session.edit(label, |doc| { + doc.toggle_hidden(view.id); + }); + cx.ui.status = format!( + "{} {}", + if view.hidden { "Shown" } else { "Hidden" }, + view.name + ); + } + + // Lock: blocks viewport transforms, not hierarchy edits. + let lock = ui + .interact( + lock_rect, + Id::new(("hierarchy_lock", view.id)), + Sense::click(), + ) + .on_hover_text(if view.locked { + "Locked — click to unlock viewport transforms" + } else { + "Click to lock viewport transforms" + }); + if view.locked { + painter.text( + lock_rect.center(), + Align2::CENTER_CENTER, + "🔒", + FontId::proportional(11.5), + theme::WARNING, + ); + } else if response.hovered() || lock.hovered() { + painter.text( + lock_rect.center(), + Align2::CENTER_CENTER, + "🔓", + FontId::proportional(11.5), + theme::FAINT, + ); + } + if lock.clicked() { + button_clicked = true; + let label = if view.locked { + "Unlock entity" + } else { + "Lock entity" + }; + cx.session.edit(label, |doc| { + if let Some(node) = doc.entity_mut(view.id) { + node.locked = !node.locked; + } + }); + cx.ui.status = format!( + "{} {}", + if view.locked { "Unlocked" } else { "Locked" }, + view.name + ); + } + } + + if !renaming_this { + let details = format!( + "{}\nID {} · {} {} · {}\nComponents: {}\nDouble-click to rename · drag to reparent · right-click for more", + if view.name.trim().is_empty() { + "(unnamed)" + } else { + view.name.as_str() + }, + view.id, + view.child_count, + if view.child_count == 1 { "child" } else { "children" }, + if view.hidden { + "Hidden" + } else if view.locked { + "Locked" + } else { + "Visible" + }, + if view.components.is_empty() { + "Transform only".to_string() + } else { + format!("Transform, {}", view.components) + } + ); + response.clone().on_hover_text(details); + } + + // Selection, inline rename and drag start. + if !renaming_this && !button_clicked { + let modifiers = ui.input(|input| input.modifiers); + if response.double_clicked() { + cx.ui.selection.select_only(view.id); + state.anchor = Some(view.id); + begin_rename(cx, state, view.id); + } else if response.clicked() { + apply_click( + &mut cx.ui.selection, + flat_ids, + view.id, + modifiers.command, + modifiers.shift, + &mut state.anchor, + ); + cx.ui.status = format!( + "Selected {}", + if view.name.trim().is_empty() { + "(unnamed)" + } else { + view.name.as_str() + } + ); + } + } + if !renaming_this && response.secondary_clicked() && !cx.ui.selection.contains(view.id) { + cx.ui.selection.select_only(view.id); + state.anchor = Some(view.id); + } + if !renaming_this && response.drag_started() { + state.dragging = Some(view.id); + if !cx.ui.selection.contains(view.id) { + cx.ui.selection.select_only(view.id); + state.anchor = Some(view.id); + } + ui.ctx().set_cursor_icon(egui::CursorIcon::Grabbing); + } + + if !renaming_this { + let locked = view.locked; + let name = view.name.clone(); + let id = view.id; + response.context_menu(|ui| { + ui.label(egui::RichText::new(&name).strong()); + ui.separator(); + if ui.button("Rename").clicked() { + begin_rename(cx, state, id); + ui.close(); + } + if ui.button("Duplicate").clicked() { + if !cx.ui.selection.contains(id) { + cx.ui.selection.select_only(id); + } + actions.push(Action::DuplicateSelection); + ui.close(); + } + if ui.button("Delete").clicked() { + if !cx.ui.selection.contains(id) { + cx.ui.selection.select_only(id); + } + actions.push(Action::DeleteSelection); + ui.close(); + } + if ui.button("Focus").clicked() { + if !cx.ui.selection.contains(id) { + cx.ui.selection.select_only(id); + } + actions.push(Action::FocusSelection); + ui.close(); + } + ui.separator(); + if ui.button(if locked { "Unlock" } else { "Lock" }).clicked() { + cx.session.edit( + if locked { + "Unlock entity" + } else { + "Lock entity" + }, + |doc| { + if let Some(node) = doc.entity_mut(id) { + node.locked = !node.locked; + } + }, + ); + ui.close(); + } + ui.menu_button("Add child", |ui| { + add_menu_items(ui, cx, state, Some(id)); + }); + }); + } + + if state.scroll_to == Some(view.id) { + response.scroll_to_me(Some(egui::Align::Center)); + state.scroll_to = None; + } + + RowGeom { + id: view.id, + depth: view.depth, + rect, + } +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + + fn tree_doc() -> SceneDoc { + let mut doc = SceneDoc::new("Tree"); + let root = doc.add_entity("Root", None); + let child = doc.add_entity("Child", Some(root)); + doc.add_entity("Leaf", Some(child)); + doc.add_entity("Other", None); + doc + } + + fn id(doc: &SceneDoc, name: &str) -> EntityId { + doc.find_by_name(name).expect("test entity exists") + } + + #[test] + fn unique_names_append_suffixes_and_skip_taken_ones() { + let mut doc = SceneDoc::new("Names"); + doc.add_entity("Sprite", None); + doc.add_entity("Sprite 2", None); + assert_eq!(unique_name(&doc, "Sprite"), "Sprite 3"); + assert_eq!(unique_name(&doc, "Cube"), "Cube"); + + let mut sparse = SceneDoc::new("Names"); + sparse.add_entity("Sprite 2", None); + assert_eq!(unique_name(&sparse, "Sprite"), "Sprite"); + } + + #[test] + fn search_keeps_ancestors_and_is_case_insensitive() { + let doc = tree_doc(); + let filtered = filter_tree(&doc, "LEAF"); + assert_eq!(filtered.matches.len(), 1); + assert!(filtered.visible.contains(&id(&doc, "Leaf"))); + assert!(filtered.visible.contains(&id(&doc, "Child"))); + assert!(filtered.visible.contains(&id(&doc, "Root"))); + assert!(!filtered.visible.contains(&id(&doc, "Other"))); + + let all = filter_tree(&doc, " "); + assert_eq!(all.visible.len(), doc.entities.len()); + assert!(all.matches.is_empty()); + } + + #[test] + fn rows_honour_order_depth_and_collapse() { + let doc = tree_doc(); + let all = filter_tree(&doc, ""); + let rows = build_rows(&doc, &all, &HashSet::new(), false); + assert_eq!( + rows, + vec![ + Row { + id: id(&doc, "Root"), + depth: 0 + }, + Row { + id: id(&doc, "Child"), + depth: 1 + }, + Row { + id: id(&doc, "Leaf"), + depth: 2 + }, + Row { + id: id(&doc, "Other"), + depth: 0 + }, + ] + ); + + let mut collapsed = HashSet::new(); + collapsed.insert(id(&doc, "Child")); + let rows = build_rows(&doc, &all, &collapsed, false); + assert_eq!( + rows.iter().map(|row| row.id).collect::>(), + vec![id(&doc, "Root"), id(&doc, "Child"), id(&doc, "Other")] + ); + + // A search forces the matching branch open even if it was collapsed. + let filtered = filter_tree(&doc, "leaf"); + let rows = build_rows(&doc, &filtered, &collapsed, true); + assert_eq!( + rows.iter().map(|row| row.id).collect::>(), + vec![id(&doc, "Root"), id(&doc, "Child"), id(&doc, "Leaf")] + ); + } + + #[test] + fn create_entity_gets_unique_name_components_and_one_undo_entry() { + let mut session = Session::new(SceneDoc::new("Add")); + let first = create_entity( + &mut session, + AddKind::Sprite, + None, + ViewMode::TwoD, + Color::WHITE, + ); + let node = session.doc().entity(first).expect("created entity"); + assert_eq!(node.name, "Sprite"); + assert!(node + .components + .iter() + .any(|component| matches!(component, ComponentData::Sprite(_)))); + assert_eq!(session.undo_label(), Some("Add Sprite")); + + let second = create_entity( + &mut session, + AddKind::Sprite, + None, + ViewMode::TwoD, + Color::WHITE, + ); + assert_eq!( + session.doc().entity(second).map(|node| node.name.as_str()), + Some("Sprite 2") + ); + session.undo(); + assert!(session.doc().entity(second).is_none()); + assert!(session.doc().entity(first).is_some()); + } + + #[test] + fn sprites_spawn_at_the_2d_camera_center() { + let mut doc = SceneDoc::new("2D"); + let camera = doc.add_entity("Camera", None); + doc.add_component(camera, ComponentData::Camera2d); + if let Some(node) = doc.entity_mut(camera) { + node.transform = Transform::from_xyz(10.0, 20.0, 10.0); + } + let mut session = Session::new(doc); + let sprite = create_entity( + &mut session, + AddKind::Sprite, + None, + ViewMode::TwoD, + Color::WHITE, + ); + assert_eq!( + session.doc().entity(sprite).map(|node| node.transform), + Some(Transform::from_xyz(10.0, 20.0, 0.0)) + ); + } + + #[test] + fn children_are_parented_at_local_identity_and_keep_integrity() { + let mut doc = SceneDoc::new("Parent"); + let root = doc.add_entity("Root", None); + let mut session = Session::new(doc); + let child = create_entity( + &mut session, + AddKind::Empty, + Some(root), + ViewMode::TwoD, + Color::WHITE, + ); + assert_eq!(session.doc().parent(child), Some(root)); + assert!(session + .doc() + .entity(root) + .map(|node| node.children.contains(&child)) + .unwrap_or(false)); + assert_eq!( + session.doc().entity(child).map(|node| node.transform), + Some(Transform::IDENTITY) + ); + assert!(session.doc().check_integrity().is_empty()); + } + + #[test] + fn every_add_kind_has_valid_unique_default_components() { + for kind in AddKind::ALL { + let components = default_components(kind, ViewMode::TwoD, Color::WHITE); + let mut keys = HashSet::new(); + for component in &components { + assert!( + keys.insert(component.key()), + "duplicate component key for {kind:?}" + ); + } + assert_eq!( + components.len(), + keys.len(), + "component keys must be unique for {kind:?}" + ); + } + let cube = default_components(AddKind::Cube, ViewMode::ThreeD, Color::WHITE); + assert!(matches!( + cube.as_slice(), + [ComponentData::Mesh3d(Mesh3dData { + mesh: MeshSource::Primitive(PrimitiveShape::Cuboid { .. }), + .. + })] + )); + assert!(matches!( + default_components(AddKind::Camera2d, ViewMode::TwoD, Color::WHITE).as_slice(), + [ComponentData::Camera2d] + )); + assert!(matches!( + default_components(AddKind::PointLight, ViewMode::ThreeD, Color::WHITE).as_slice(), + [ComponentData::PointLight(_)] + )); + } + + fn geom(id: EntityId, depth: usize, top: f32, bottom: f32) -> RowGeom { + RowGeom { + id, + depth, + rect: Rect::from_min_max(Pos2::new(0.0, top), Pos2::new(260.0, bottom)), + } + } + + #[test] + fn drop_targets_cover_child_and_sibling_zones() { + let viewport = Rect::from_min_max(Pos2::new(0.0, 90.0), Pos2::new(260.0, 300.0)); + let rows = vec![geom(1, 0, 100.0, 126.0), geom(2, 1, 126.0, 152.0)]; + assert_eq!( + drop_target_at(Pos2::new(100.0, 102.0), &rows, viewport), + Some(DropTarget::Before(1)) + ); + assert_eq!( + drop_target_at(Pos2::new(100.0, 113.0), &rows, viewport), + Some(DropTarget::Inside(1)) + ); + assert_eq!( + drop_target_at(Pos2::new(100.0, 124.0), &rows, viewport), + Some(DropTarget::After(1)) + ); + assert_eq!( + drop_target_at(Pos2::new(100.0, 151.0), &rows, viewport), + Some(DropTarget::After(2)) + ); + assert_eq!( + drop_target_at(Pos2::new(100.0, 200.0), &rows, viewport), + Some(DropTarget::RootsEnd) + ); + assert_eq!( + drop_target_at(Pos2::new(100.0, 80.0), &rows, viewport), + None + ); + assert_eq!( + drop_target_at(Pos2::new(400.0, 113.0), &rows, viewport), + None + ); + + // A gap between two rows reorders next to the upper row. + let gapped = vec![geom(1, 0, 100.0, 120.0), geom(2, 0, 130.0, 150.0)]; + assert_eq!( + drop_target_at(Pos2::new(100.0, 125.0), &gapped, viewport), + Some(DropTarget::After(1)) + ); + } + + #[test] + fn dropping_reorders_and_undoes_as_one_entry() { + let mut doc = SceneDoc::new("Drop"); + let a = doc.add_entity("A", None); + let b = doc.add_entity("B", None); + let c = doc.add_entity("C", None); + let mut session = Session::new(doc); + + reparent_for_drop(&mut session, c, DropTarget::Before(a)).expect("reorder"); + assert_eq!(session.doc().roots, vec![c, a, b]); + assert_eq!(session.undo_label(), Some("Reparent C")); + session.undo(); + assert_eq!(session.doc().roots, vec![a, b, c]); + + reparent_for_drop(&mut session, c, DropTarget::After(a)).expect("reorder after"); + assert_eq!(session.doc().roots, vec![a, c, b]); + + reparent_for_drop(&mut session, c, DropTarget::Before(a)).expect("to root start"); + assert_eq!(session.doc().roots, vec![c, a, b]); + } + + #[test] + fn reparenting_into_a_child_rejects_cycles_without_undo() { + let mut doc = SceneDoc::new("Cycle"); + let root = doc.add_entity("Root", None); + let child = doc.add_entity("Child", Some(root)); + let mut session = Session::new(doc); + + let error = reparent_for_drop(&mut session, root, DropTarget::Inside(child)) + .expect_err("cycle must be rejected"); + assert!(matches!(error, SceneError::Cycle { .. })); + assert!(!session.can_undo()); + assert_eq!(session.doc().roots, vec![root]); + + let error = reparent_for_drop(&mut session, root, DropTarget::Inside(root)) + .expect_err("self parent must be rejected"); + assert!(matches!(error, SceneError::SelfParent(_))); + } + + #[test] + fn roots_end_moves_a_child_out_of_its_parent() { + let mut doc = SceneDoc::new("Move"); + let a = doc.add_entity("A", None); + let b = doc.add_entity("B", Some(a)); + let mut session = Session::new(doc); + reparent_for_drop(&mut session, b, DropTarget::RootsEnd).expect("move to roots"); + assert_eq!(session.doc().roots, vec![a, b]); + assert_eq!(session.doc().parent(b), None); + assert!(session.doc().check_integrity().is_empty()); + } + + #[test] + fn short_labels_truncate_on_char_boundaries() { + assert_eq!(short_label("Camera", 10), "Camera"); + assert_eq!(short_label("A very long entity name", 8), "A very …"); + } + + #[test] + fn clicks_select_toggle_and_shift_extend() { + let mut selection = Selection::default(); + let flat = vec![1, 2, 3, 4]; + let mut anchor = None; + apply_click(&mut selection, &flat, 2, false, false, &mut anchor); + assert_eq!(anchor, Some(2)); + assert!(selection.contains(2)); + + apply_click(&mut selection, &flat, 4, false, true, &mut anchor); + assert_eq!(selection.len(), 3); + assert!(selection.contains(2) && selection.contains(3) && selection.contains(4)); + assert_eq!(selection.primary(), Some(4)); + + apply_click(&mut selection, &flat, 1, true, false, &mut anchor); + assert!(selection.contains(1)); + apply_click(&mut selection, &flat, 1, true, false, &mut anchor); + assert!(!selection.contains(1)); + } + + #[test] + fn rename_commits_trimmed_and_empty_names_with_warning() { + use crate::state::{ExportState, UiState}; + use crate::texture_cache::TextureCache; + use crate::toast::Toasts; + use bevy_forge_core::AssetIndex; + + let mut doc = SceneDoc::new("Rename"); + let entity = doc.add_entity("Old", None); + let mut session = Session::new(doc); + let assets = AssetIndex::default(); + let mut textures = TextureCache::new(); + let mut ui_state = UiState::default(); + let mut toasts = Toasts::new(); + let mut export = ExportState::default(); + let mut state = HierarchyPanelState::default(); + { + let mut cx = EditorContext { + session: &mut session, + project: None, + assets: &assets, + textures: &mut textures, + ui: &mut ui_state, + toasts: &mut toasts, + export: &mut export, + }; + begin_rename(&mut cx, &mut state, entity); + assert_eq!(state.renaming, Some(entity)); + assert!(state.rename_focus); + state.rename_buffer = " New Name ".into(); + commit_rename(&mut cx, &mut state); + } + assert_eq!(state.renaming, None); + assert_eq!( + session.doc().entity(entity).map(|node| node.name.as_str()), + Some("New Name") + ); + assert_eq!(session.undo_label(), Some("Rename")); + assert!(toasts.is_empty()); + + { + let mut cx = EditorContext { + session: &mut session, + project: None, + assets: &assets, + textures: &mut textures, + ui: &mut ui_state, + toasts: &mut toasts, + export: &mut export, + }; + begin_rename(&mut cx, &mut state, entity); + state.rename_buffer = " ".into(); + commit_rename(&mut cx, &mut state); + } + assert_eq!( + session.doc().entity(entity).map(|node| node.name.as_str()), + Some("Entity") + ); + assert_eq!(toasts.len(), 1); + } +} diff --git a/crates/bevy_forge_editor/src/inspector.rs b/crates/bevy_forge_editor/src/inspector.rs new file mode 100644 index 0000000..90fa477 --- /dev/null +++ b/crates/bevy_forge_editor/src/inspector.rs @@ -0,0 +1,2175 @@ +//! Right-hand inspector: entity name, transform, component cards, component +//! palette, custom components and per-entity validation issues. +//! +//! ## How edits are applied +//! +//! Every frame the panel takes a lightweight immutable snapshot of the selected +//! [`EntityNode`] and renders widgets from locally cloned values. Any change is +//! written back immediately through [`bevy_forge_core::Session::edit`] with a +//! descriptive undo label, so no borrow of the session is ever held across an +//! egui widget and every inspector edit is undoable. +//! +//! Locked entities stay fully editable here: [`EntityNode::locked`] is an +//! editor-only guard for viewport gizmos, not a property lock. + +use std::ops::RangeInclusive; + +use bevy_forge_core::components::{is_rust_ident, is_rust_path}; +use bevy_forge_core::{ + validate_scene, AssetKind, Color, ComponentData, ComponentKind, CustomComponent, CustomField, + DirectionalLightData, EntityId, EntityNode, Issue, MaterialData, Mesh3dData, MeshSource, + PointLightData, PrimitiveShape, SceneStats, Severity, SpriteData, Text2dData, Transform, + ValidationReport, Vec2, Vec3, VisibilityMode, +}; + +use crate::action::Action; +use crate::state::EditorContext; +use crate::{theme, widgets}; + +/// Panel-local state. `app.rs` constructs this with `Default`. +#[derive(Default)] +pub struct InspectorPanelState { + pub add_component_open: bool, + pub custom_dialog_open: bool, + pub custom_type_path: String, + pub custom_fields: Vec, + pub component_filter: String, + /// Entity whose name is currently held in `name_buffer`. + name_entity: Option, + name_buffer: String, + /// Last document name we synchronised `name_buffer` from. + name_last_doc: String, + scene_name_buffer: String, + scene_name_last_doc: String, + /// Entity the open "New custom component" form belongs to. + custom_dialog_entity: Option, +} + +/// Snapshot of everything the entity view needs for one frame. +struct InspectorEntity<'a> { + node: &'a EntityNode, + parent_name: Option<&'a str>, + report: &'a ValidationReport, + selection_len: usize, + image_assets: &'a [String], + mesh_assets: &'a [String], +} + +// --------------------------------------------------------------------------- +// Panel entry point +// --------------------------------------------------------------------------- + +pub fn show( + ctx: &egui::Context, + cx: &mut EditorContext<'_>, + state: &mut InspectorPanelState, +) -> Vec { + let mut actions = Vec::new(); + + // Snapshot the small amount of document data the panel displays. Cloning one + // node per frame keeps the render code borrow-free and panic-free even when an + // undo (or another panel) removes the entity mid-frame. + let scene_name = cx.session.doc().name.clone(); + let stats = cx.session.doc().stats(); + let report = validate_scene(cx.session.doc(), Some(cx.assets)); + let selection_len = cx.ui.selection.len(); + let node = cx + .ui + .selection + .primary_or_single() + .and_then(|id| cx.session.doc().entity(id).cloned()); + let parent_name = node + .as_ref() + .and_then(|node| node.parent) + .and_then(|parent| { + cx.session + .doc() + .entity(parent) + .map(|parent| parent.name.clone()) + }); + + sync_scene_name(state, &scene_name); + sync_entity_name(state, node.as_ref()); + if state.custom_dialog_open && state.custom_dialog_entity != node.as_ref().map(|node| node.id) { + close_custom_dialog(state); + } + + let image_assets = cx.assets_of_kind(AssetKind::Image); + let mesh_assets = cx.assets_of_kind(AssetKind::Mesh); + + egui::SidePanel::right("inspector_panel") + .resizable(true) + .default_width(328.0) + .width_range(260.0..=560.0) + .show(ctx, |ui| { + egui::ScrollArea::vertical() + .auto_shrink([false; 2]) + .show(ui, |ui| { + ui.add_space(6.0); + ui.horizontal(|ui| { + ui.heading("Inspector"); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + let selection = match selection_len { + 0 => "No selection".to_string(), + 1 => "1 entity".to_string(), + count => format!("{count} entities"), + }; + ui.label(theme::hint(selection)); + }); + }); + ui.add_space(2.0); + ui.separator(); + + match &node { + Some(node) => { + let entity = InspectorEntity { + node, + parent_name: parent_name.as_deref(), + report: &report, + selection_len, + image_assets: &image_assets, + mesh_assets: &mesh_assets, + }; + entity_inspector(ui, cx, state, &entity, &mut actions); + } + None => { + if selection_len > 0 { + ui.label( + egui::RichText::new( + "The selected entities no longer exist in this scene.", + ) + .color(theme::WARNING) + .size(12.0), + ); + } + scene_inspector(ui, cx, state, &scene_name, &stats, &report); + } + } + ui.add_space(14.0); + }); + }); + + actions +} + +// --------------------------------------------------------------------------- +// No selection: scene card + validation summary +// --------------------------------------------------------------------------- + +fn scene_inspector( + ui: &mut egui::Ui, + cx: &mut EditorContext<'_>, + state: &mut InspectorPanelState, + scene_name: &str, + stats: &SceneStats, + report: &ValidationReport, +) { + scene_card(ui, cx, state, scene_name, stats); + ui.add_space(6.0); + validation_card(ui, report); + widgets::empty_state( + ui, + "No entity selected", + "Select an entity in the Hierarchy to edit its transform, components and custom data.", + ); +} + +fn scene_card( + ui: &mut egui::Ui, + cx: &mut EditorContext<'_>, + state: &mut InspectorPanelState, + scene_name: &str, + stats: &SceneStats, +) { + theme::card_frame().show(ui, |ui| { + ui.horizontal(|ui| { + ui.label(egui::RichText::new("Scene").strong()); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + ui.label(theme::hint(format!("{} entities", stats.entities))); + }); + }); + ui.add_space(2.0); + ui.horizontal(|ui| { + widgets::field_label(ui, "Name"); + let response = ui.add( + egui::TextEdit::singleline(&mut state.scene_name_buffer) + .desired_width(f32::INFINITY) + .hint_text("Scene name"), + ); + let enter = ui.input(|input| input.key_pressed(egui::Key::Enter)); + if response.lost_focus() || (response.has_focus() && enter) { + commit_scene_name(cx, state, scene_name); + } + }); + ui.label(theme::hint( + "Press Enter or click elsewhere to rename the scene.", + )); + ui.add_space(4.0); + egui::Grid::new("inspector_scene_stats") + .num_columns(2) + .spacing(egui::vec2(14.0, 3.0)) + .show(ui, |ui| { + stat_row(ui, "Entities", stats.entities); + stat_row(ui, "Roots", stats.roots); + stat_row(ui, "Sprites", stats.sprites); + stat_row(ui, "Meshes", stats.meshes); + stat_row(ui, "Lights", stats.lights); + stat_row(ui, "Cameras", stats.cameras); + stat_row(ui, "Custom components", stats.custom_components); + }); + }); +} + +fn stat_row(ui: &mut egui::Ui, label: &str, value: usize) { + ui.label(theme::hint(label)); + ui.label(egui::RichText::new(value.to_string()).strong()); + ui.end_row(); +} + +fn validation_card(ui: &mut egui::Ui, report: &ValidationReport) { + theme::card_frame().show(ui, |ui| { + ui.horizontal(|ui| { + ui.label(egui::RichText::new("Validation").strong()); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + ui.label(theme::hint(format!("{} issue(s)", report.issues.len()))); + }); + }); + let color = if report.errors() > 0 { + theme::ERROR + } else if report.warnings() > 0 { + theme::WARNING + } else { + theme::SUCCESS + }; + ui.label( + egui::RichText::new(validation_summary(report)) + .color(color) + .size(12.0), + ); + if report.issues.is_empty() { + ui.label(theme::hint("Scene is ready to export.")); + } + }); +} + +// --------------------------------------------------------------------------- +// Single selection +// --------------------------------------------------------------------------- + +fn entity_inspector( + ui: &mut egui::Ui, + cx: &mut EditorContext<'_>, + state: &mut InspectorPanelState, + entity: &InspectorEntity<'_>, + actions: &mut Vec, +) { + entity_header_card(ui, cx, state, entity, actions); + + let issues = entity.report.by_entity(entity.node.id); + if !issues.is_empty() { + ui.add_space(6.0); + entity_problems_card(ui, &issues); + } + + ui.add_space(4.0); + transform_card(ui, cx, entity.node); + + components_section(ui, cx, state, entity); + + ui.add_space(8.0); + ui.label(theme::hint( + "Every inspector change is undoable with Ctrl+Z. Locked entities remain editable here.", + )); +} + +fn entity_header_card( + ui: &mut egui::Ui, + cx: &mut EditorContext<'_>, + state: &mut InspectorPanelState, + entity: &InspectorEntity<'_>, + actions: &mut Vec, +) { + let node = entity.node; + theme::card_frame().show(ui, |ui| { + ui.horizontal(|ui| { + ui.label(egui::RichText::new(entity_summary(node)).strong()); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if ui + .add(egui::Button::new("Focus").small()) + .on_hover_text("Frame this entity in the active viewport") + .clicked() + { + actions.push(Action::FocusSelection); + } + }); + }); + ui.horizontal_wrapped(|ui| { + ui.label(theme::hint(format!("#{}", node.id))); + if let Some(parent) = entity.parent_name { + ui.label(theme::hint(format!("child of {parent}"))); + } + if node.locked { + ui.label( + egui::RichText::new("locked") + .color(theme::WARNING) + .size(11.0), + ); + } + if node.is_hidden() { + ui.label(egui::RichText::new("hidden").color(theme::MUTED).size(11.0)); + } + }); + if entity.selection_len > 1 { + ui.label( + egui::RichText::new(format!( + "{} entities selected — editing the primary entity only", + entity.selection_len + )) + .color(theme::WARNING) + .size(11.5), + ); + } + ui.add_space(4.0); + + ui.horizontal(|ui| { + widgets::field_label(ui, "Name"); + let response = ui.add( + egui::TextEdit::singleline(&mut state.name_buffer) + .desired_width(f32::INFINITY) + .hint_text("Entity name"), + ); + let enter = ui.input(|input| input.key_pressed(egui::Key::Enter)); + if response.lost_focus() || (response.has_focus() && enter) { + commit_entity_name(cx, state, node.id, &node.name); + } + }); + + ui.horizontal(|ui| { + let mut locked = node.locked; + let lock_response = ui.checkbox(&mut locked, "Locked"); + if lock_response.changed() { + let label = if locked { + "Lock entity" + } else { + "Unlock entity" + }; + cx.session.edit(label, |doc| { + if let Some(entity) = doc.entity_mut(node.id) { + entity.locked = locked; + } + }); + cx.ui.status = if locked { + "Entity locked".into() + } else { + "Entity unlocked".into() + }; + } + lock_response.on_hover_text( + "Locked entities cannot be moved by viewport gizmos; the inspector remains editable.", + ); + + let mut hidden = node.is_hidden(); + let hidden_response = ui.checkbox(&mut hidden, "Hidden"); + if hidden_response.changed() { + set_entity_hidden(cx, node.id, hidden); + } + hidden_response.on_hover_text( + "Sets Visibility::Hidden. Unchecking removes the override so the entity inherits visibility from its parent.", + ); + }); + }); +} + +fn entity_problems_card(ui: &mut egui::Ui, issues: &[&Issue]) { + theme::card_frame().show(ui, |ui| { + ui.horizontal(|ui| { + ui.label(egui::RichText::new("Problems").strong()); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + ui.label(theme::hint(format!("{} for this entity", issues.len()))); + }); + }); + for issue in issues { + ui.horizontal_wrapped(|ui| { + ui.label( + egui::RichText::new(severity_glyph(issue.severity)) + .color(severity_color(issue.severity)) + .size(12.0), + ); + ui.label( + egui::RichText::new(issue_text(issue)) + .color(severity_color(issue.severity)) + .size(11.5), + ); + }); + } + }); +} + +fn transform_card(ui: &mut egui::Ui, cx: &mut EditorContext<'_>, node: &EntityNode) { + theme::card_frame().show(ui, |ui| { + ui.horizontal(|ui| { + ui.label(egui::RichText::new("Transform").strong()); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + let reset_response = ui.add_enabled( + !node.transform.is_identity(), + egui::Button::new("Reset").small(), + ); + if reset_response + .on_hover_text("Reset translation, rotation and scale to identity") + .clicked() + { + cx.session.edit("Reset transform", |doc| { + if let Some(node) = doc.entity_mut(node.id) { + node.transform = Transform::IDENTITY; + } + }); + cx.ui.status = "Reset transform".into(); + } + }); + }); + ui.add_space(2.0); + + transform_row( + ui, + cx, + node.id, + "Translation", + "Local position in world units. X = right, Y = up, Z = depth.", + "translation", + 0.05, + node.transform.translation, + |transform, value| transform.translation = value, + ); + transform_row( + ui, + cx, + node.id, + "Rotation (°)", + "Local rotation as XYZ Euler angles in degrees. Z rotates in the 2D plane.", + "rotation", + 0.5, + node.transform.rotation, + |transform, value| transform.rotation = value, + ); + transform_row( + ui, + cx, + node.id, + "Scale", + "Local scale per axis. Non-zero values keep the entity visible; negatives mirror it.", + "scale", + 0.01, + node.transform.scale, + |transform, value| transform.scale = value, + ); + }); +} + +#[allow(clippy::too_many_arguments)] +fn transform_row( + ui: &mut egui::Ui, + cx: &mut EditorContext<'_>, + id: EntityId, + label: &str, + tooltip: &str, + salt: &'static str, + speed: f32, + current: Vec3, + set: impl FnOnce(&mut Transform, Vec3), +) { + widgets::field_label(ui, label).on_hover_text(tooltip); + let mut value = current; + if widgets::vec3_editor(ui, (salt, id), &mut value, speed) { + if !value.is_finite() { + cx.toasts.error(format!("{label} must be finite")); + return; + } + if value != current { + cx.session.edit("Transform", |doc| { + if let Some(node) = doc.entity_mut(id) { + set(&mut node.transform, value); + } + }); + cx.ui.status = format!("Edited {label}"); + } + } +} + +// --------------------------------------------------------------------------- +// Components +// --------------------------------------------------------------------------- + +fn components_section( + ui: &mut egui::Ui, + cx: &mut EditorContext<'_>, + state: &mut InspectorPanelState, + entity: &InspectorEntity<'_>, +) { + let node = entity.node; + let id = node.id; + + ui.add_space(8.0); + ui.horizontal(|ui| { + let has_filter = !cx.ui.inspector_search.is_empty(); + let width = if has_filter { + (ui.available_width() - 30.0).max(90.0) + } else { + ui.available_width() + }; + ui.add( + egui::TextEdit::singleline(&mut cx.ui.inspector_search) + .hint_text("Filter components…") + .desired_width(width), + ); + if has_filter { + let clear = ui + .add(egui::Button::new("✕").small()) + .on_hover_text("Clear the component filter"); + if clear.clicked() { + cx.ui.inspector_search.clear(); + } + } + }); + let query = cx.ui.inspector_search.trim().to_ascii_lowercase(); + + widgets::section_label(ui, "Components"); + + let mut visible = 0usize; + for component in &node.components { + let key = component.key(); + let label = component.label(); + if !component_matches(&query, &key, &label) { + continue; + } + visible += 1; + let card_id = ui.make_persistent_id(("inspector_component", &key)); + + let mut removed = false; + let mut draft: Option = None; + + match component { + ComponentData::Sprite(sprite) => { + let mut value = sprite.clone(); + let (changed, remove) = component_card(ui, card_id, &label, &key, |ui| { + sprite_body(ui, &mut value, entity.image_assets) + }); + if remove { + removed = true; + } else if changed { + draft = Some(ComponentData::Sprite(value)); + } + } + ComponentData::Text2d(text) => { + let mut value = text.clone(); + let (changed, remove) = + component_card(ui, card_id, &label, &key, |ui| text2d_body(ui, &mut value)); + if remove { + removed = true; + } else if changed { + draft = Some(ComponentData::Text2d(value)); + } + } + ComponentData::Camera2d => { + let (_, remove) = component_card(ui, card_id, &label, &key, |ui| { + camera_note(ui, "Camera2d"); + false + }); + removed = remove; + } + ComponentData::Camera3d => { + let (_, remove) = component_card(ui, card_id, &label, &key, |ui| { + camera_note(ui, "Camera3d"); + false + }); + removed = remove; + } + ComponentData::PointLight(light) => { + let mut value = light.clone(); + let (changed, remove) = component_card(ui, card_id, &label, &key, |ui| { + point_light_body(ui, &mut value) + }); + if remove { + removed = true; + } else if changed { + draft = Some(ComponentData::PointLight(value)); + } + } + ComponentData::DirectionalLight(light) => { + let mut value = light.clone(); + let (changed, remove) = component_card(ui, card_id, &label, &key, |ui| { + directional_light_body(ui, &mut value) + }); + if remove { + removed = true; + } else if changed { + draft = Some(ComponentData::DirectionalLight(value)); + } + } + ComponentData::Mesh3d(mesh) => { + let mut value = mesh.clone(); + let (changed, remove) = component_card(ui, card_id, &label, &key, |ui| { + mesh3d_body(ui, &mut value, entity.mesh_assets) + }); + if remove { + removed = true; + } else if changed { + draft = Some(ComponentData::Mesh3d(value)); + } + } + ComponentData::Visibility(mode) => { + let mut value = *mode; + let (changed, remove) = component_card(ui, card_id, &label, &key, |ui| { + visibility_body(ui, &mut value) + }); + if remove { + removed = true; + } else if changed { + draft = Some(ComponentData::Visibility(value)); + } + } + ComponentData::Custom(custom) => { + let mut value = custom.clone(); + let title = format!("Custom · {}", custom.short_name()); + let (changed, remove) = + component_card(ui, card_id, &title, &key, |ui| custom_body(ui, &mut value)); + if remove { + removed = true; + } else if changed { + apply_custom_component(cx, id, &key, value); + } + } + } + + if removed { + remove_component_by_key(cx, id, &key, &label); + } else if let Some(component) = draft { + apply_component(cx, id, &format!("Edit {label}"), component); + } + } + + if node.components.is_empty() { + ui.label(theme::hint( + "No components yet. Add one below to start building this entity.", + )); + } else if visible == 0 { + ui.label(theme::hint(format!( + "No components match “{}”.", + cx.ui.inspector_search.trim() + ))); + } + + ui.add_space(6.0); + add_component_controls(ui, cx, state, node); +} + +fn component_card( + ui: &mut egui::Ui, + id: egui::Id, + title: &str, + key: &str, + body: impl FnOnce(&mut egui::Ui) -> bool, +) -> (bool, bool) { + let mut changed = false; + let mut remove = false; + theme::card_frame().show(ui, |ui| { + let header = + egui::collapsing_header::CollapsingState::load_with_default_open(ui.ctx(), id, true) + .show_header(ui, |ui| { + ui.label(egui::RichText::new(title).monospace().strong()); + ui.add_space(6.0); + ui.label( + egui::RichText::new(key) + .monospace() + .size(10.0) + .color(theme::FAINT), + ); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + let response = ui + .add(egui::Button::new("×").small().frame(false)) + .on_hover_text("Remove this component (undoable)"); + if response.clicked() { + remove = true; + } + }); + }); + header.body(|ui| { + changed = body(ui); + }); + }); + (changed, remove) +} + +fn sprite_body(ui: &mut egui::Ui, sprite: &mut SpriteData, images: &[String]) -> bool { + let mut changed = false; + ui.horizontal(|ui| { + widgets::field_label(ui, "Image"); + changed |= + widgets::asset_picker(ui, "sprite_image", &mut sprite.image, images, "No texture"); + }); + ui.horizontal(|ui| { + widgets::field_label(ui, "Color"); + changed |= widgets::color_editor(ui, "sprite_color", &mut sprite.color); + }); + ui.horizontal(|ui| { + let mut has_size = sprite.custom_size.is_some(); + if ui.checkbox(&mut has_size, "Custom size").changed() { + if has_size { + sprite.custom_size = Some(Vec2::ONE); + } else { + sprite.custom_size = None; + } + changed = true; + } + if let Some(size) = &mut sprite.custom_size { + if widgets::vec2_editor(ui, "sprite_size", size, 0.1) { + if size.is_finite() { + size.x = size.x.max(0.001); + size.y = size.y.max(0.001); + } else { + *size = Vec2::ONE; + } + changed = true; + } + } + }); + ui.horizontal(|ui| { + changed |= ui.checkbox(&mut sprite.flip_x, "Flip X").changed(); + changed |= ui.checkbox(&mut sprite.flip_y, "Flip Y").changed(); + }); + if sprite.image.is_none() && sprite.custom_size.is_none() { + ui.label(theme::hint( + "Without an image or custom size a sprite renders nothing (validation warning).", + )); + } + changed +} + +fn text2d_body(ui: &mut egui::Ui, text: &mut Text2dData) -> bool { + let mut changed = false; + widgets::field_label(ui, "Text"); + changed |= ui + .add( + egui::TextEdit::multiline(&mut text.text) + .desired_rows(2) + .desired_width(f32::INFINITY), + ) + .changed(); + ui.horizontal(|ui| { + widgets::field_label(ui, "Font size"); + changed |= drag_value(ui, &mut text.font_size, 1.0, 1, 1.0..=512.0); + ui.label(theme::hint("px")); + }); + ui.horizontal(|ui| { + widgets::field_label(ui, "Color"); + changed |= widgets::color_editor(ui, "text_color", &mut text.color); + }); + if sanitize_finite(&mut text.font_size, 32.0) { + changed = true; + } + if text.text.trim().is_empty() { + ui.label(theme::hint( + "Empty text renders nothing (validation warning).", + )); + } + changed +} + +fn camera_note(ui: &mut egui::Ui, label: &str) { + ui.label(theme::hint(format!( + "{label} uses this entity's Transform (edit it above). Projection, clear color and render target keep Bevy defaults in generated code." + ))); +} + +fn point_light_body(ui: &mut egui::Ui, light: &mut PointLightData) -> bool { + let mut changed = false; + ui.horizontal(|ui| { + widgets::field_label(ui, "Color"); + changed |= widgets::color_editor(ui, "point_light_color", &mut light.color); + }); + ui.horizontal(|ui| { + widgets::field_label(ui, "Intensity"); + changed |= drag_value(ui, &mut light.intensity, 10.0, 1, 0.0..=1.0e7); + }); + ui.horizontal(|ui| { + widgets::field_label(ui, "Range"); + changed |= drag_value(ui, &mut light.range, 0.05, 2, 0.01..=1.0e5); + }); + ui.horizontal(|ui| { + widgets::field_label(ui, "Shadows"); + changed |= ui + .checkbox(&mut light.shadows, "Cast shadows") + .on_hover_text("shadow_maps_enabled in generated BSN") + .changed(); + }); + changed |= sanitize_finite(&mut light.intensity, 800.0); + changed |= sanitize_finite(&mut light.range, 20.0); + changed +} + +fn directional_light_body(ui: &mut egui::Ui, light: &mut DirectionalLightData) -> bool { + let mut changed = false; + ui.horizontal(|ui| { + widgets::field_label(ui, "Color"); + changed |= widgets::color_editor(ui, "directional_light_color", &mut light.color); + }); + ui.horizontal(|ui| { + widgets::field_label(ui, "Illuminance"); + changed |= drag_value(ui, &mut light.illuminance, 100.0, 1, 0.0..=2.0e5); + }); + ui.horizontal(|ui| { + widgets::field_label(ui, "Shadows"); + changed |= ui + .checkbox(&mut light.shadows, "Cast shadows") + .on_hover_text("shadow_maps_enabled in generated BSN") + .changed(); + }); + changed |= sanitize_finite(&mut light.illuminance, 10_000.0); + changed +} + +fn mesh3d_body(ui: &mut egui::Ui, mesh: &mut Mesh3dData, mesh_assets: &[String]) -> bool { + let mut changed = mesh_source_ui(ui, &mut mesh.mesh, mesh_assets); + ui.add_space(4.0); + changed |= material_ui(ui, &mut mesh.material); + changed +} + +fn mesh_source_ui(ui: &mut egui::Ui, source: &mut MeshSource, mesh_assets: &[String]) -> bool { + let mut changed = false; + let mut use_primitive = matches!(source, MeshSource::Primitive(_)); + let mut switched = false; + ui.horizontal(|ui| { + if ui + .radio_value(&mut use_primitive, true, "Primitive") + .changed() + { + switched = true; + } + if ui.radio_value(&mut use_primitive, false, "Asset").changed() { + switched = true; + } + }); + if switched { + if use_primitive { + if !matches!(source, MeshSource::Primitive(_)) { + *source = MeshSource::Primitive(default_primitive(ShapeChoice::Cube)); + changed = true; + } + } else if !matches!(source, MeshSource::Asset(_)) { + match mesh_assets.first() { + Some(path) => { + *source = MeshSource::Asset(path.clone()); + changed = true; + } + None => { + ui.label( + egui::RichText::new( + "No mesh assets indexed — add a .gltf/.glb/.obj under the project's assets folder.", + ) + .color(theme::WARNING) + .size(11.0), + ); + } + } + } + } + + let mut clear_asset = false; + match source { + MeshSource::Primitive(shape) => { + let mut choice = ShapeChoice::from_primitive(shape); + let previous = choice; + egui::ComboBox::from_id_salt(ui.make_persistent_id("inspector_primitive_shape")) + .selected_text(choice.label()) + .width(160.0) + .show_ui(ui, |ui| { + for option in ShapeChoice::ALL { + ui.selectable_value(&mut choice, option, option.label()); + } + }); + if choice != previous { + *shape = default_primitive(choice); + changed = true; + } + changed |= primitive_parameters(ui, shape); + } + MeshSource::Asset(path) => { + let mut current = Some(path.clone()); + let picked = ui + .horizontal(|ui| { + widgets::field_label(ui, "Mesh asset"); + widgets::asset_picker( + ui, + "mesh_asset", + &mut current, + mesh_assets, + "No mesh asset", + ) + }) + .inner; + if picked { + match current { + Some(selected) => { + if *path != selected { + *path = selected; + changed = true; + } + } + None => clear_asset = true, + } + } + } + } + if clear_asset { + *source = MeshSource::Primitive(default_primitive(ShapeChoice::Cube)); + changed = true; + } + if mesh_assets.is_empty() { + ui.label(theme::hint( + "No mesh assets indexed. Primitive meshes are generated procedurally.", + )); + } + changed +} + +fn primitive_parameters(ui: &mut egui::Ui, shape: &mut PrimitiveShape) -> bool { + let mut changed = false; + match shape { + PrimitiveShape::Cuboid { size } => { + widgets::field_label(ui, "Size").on_hover_text("Full width, height and depth"); + let mut value = *size; + if widgets::vec3_editor(ui, "cuboid_size", &mut value, 0.05) { + *size = Vec3::new( + sanitize_dimension(value.x, size.x), + sanitize_dimension(value.y, size.y), + sanitize_dimension(value.z, size.z), + ); + changed = true; + } + } + PrimitiveShape::Sphere { radius } => { + changed |= dimension_row(ui, "Radius", radius, 0.01); + } + PrimitiveShape::Plane { size } => { + widgets::field_label(ui, "Size (XZ)"); + let mut value = *size; + if widgets::vec2_editor(ui, "plane_size", &mut value, 0.05) { + *size = Vec2::new( + sanitize_dimension(value.x, size.x), + sanitize_dimension(value.y, size.y), + ); + changed = true; + } + } + PrimitiveShape::Cylinder { radius, height } => { + changed |= dimension_row(ui, "Radius", radius, 0.01); + changed |= dimension_row(ui, "Height", height, 0.01); + } + PrimitiveShape::Capsule { radius, length } => { + changed |= dimension_row(ui, "Radius", radius, 0.01); + changed |= dimension_row(ui, "Length", length, 0.01); + } + } + changed +} + +fn material_ui(ui: &mut egui::Ui, material: &mut MaterialData) -> bool { + let mut changed = false; + egui::CollapsingHeader::new("Material") + .id_salt("inspector_material") + .default_open(true) + .show(ui, |ui| { + ui.horizontal(|ui| { + widgets::field_label(ui, "Base color"); + changed |= widgets::color_editor(ui, "material_color", &mut material.base_color); + }); + ui.horizontal(|ui| { + widgets::field_label(ui, "Metallic"); + changed |= drag_value(ui, &mut material.metallic, 0.005, 2, 0.0..=1.0); + }); + ui.horizontal(|ui| { + widgets::field_label(ui, "Perceptual roughness"); + changed |= drag_value(ui, &mut material.perceptual_roughness, 0.005, 2, 0.0..=1.0); + }); + ui.horizontal(|ui| { + widgets::field_label(ui, "Unlit"); + changed |= ui + .checkbox(&mut material.unlit, "Unlit") + .on_hover_text("Emit the base color without lighting") + .changed(); + }); + changed |= sanitize_finite(&mut material.metallic, 0.0); + changed |= sanitize_finite(&mut material.perceptual_roughness, 0.5); + if !material.base_color.is_finite() { + material.base_color = Color::WHITE; + changed = true; + } + }); + changed +} + +fn visibility_body(ui: &mut egui::Ui, mode: &mut VisibilityMode) -> bool { + let mut changed = false; + ui.horizontal(|ui| { + widgets::field_label(ui, "Visibility"); + egui::ComboBox::from_id_salt("inspector_visibility_mode") + .selected_text(mode.label()) + .width(150.0) + .show_ui(ui, |ui| { + for option in [ + VisibilityMode::Inherited, + VisibilityMode::Visible, + VisibilityMode::Hidden, + ] { + changed |= ui.selectable_value(mode, option, option.label()).changed(); + } + }); + }); + ui.label(theme::hint( + "Inherited follows the parent; Hidden excludes this entity and its children from rendering.", + )); + changed +} + +fn custom_body(ui: &mut egui::Ui, custom: &mut CustomComponent) -> bool { + let mut changed = false; + ui.horizontal(|ui| { + widgets::field_label(ui, "Type path"); + changed |= ui + .add( + egui::TextEdit::singleline(&mut custom.type_path) + .hint_text("game::Health") + .desired_width(f32::INFINITY) + .font(egui::TextStyle::Monospace), + ) + .changed(); + }); + if !is_rust_path(custom.type_path.trim()) { + ui.label( + egui::RichText::new("Invalid Rust type path — expected e.g. `game::Health`.") + .color(theme::ERROR) + .size(11.0), + ); + } + ui.add_space(2.0); + widgets::field_label(ui, "Fields (raw Rust/BSN expressions)"); + changed |= custom_fields_editor(ui, &mut custom.fields); + changed +} + +/// Editor for a list of raw custom-component fields. Returns true when the list +/// or any field text changed. Add/remove buttons mutate `fields` immediately. +fn custom_fields_editor(ui: &mut egui::Ui, fields: &mut Vec) -> bool { + let mut changed = false; + let mut removed: Option = None; + for (index, field) in fields.iter_mut().enumerate() { + ui.push_id(("custom_field", index), |ui| { + ui.horizontal(|ui| { + let name = ui.add( + egui::TextEdit::singleline(&mut field.name) + .hint_text("name") + .desired_width(78.0) + .font(egui::TextStyle::Monospace), + ); + changed |= name.changed(); + let expression_width = (ui.available_width() - 28.0).max(60.0); + let expression = ui.add( + egui::TextEdit::singleline(&mut field.expr) + .hint_text("expression") + .desired_width(expression_width) + .font(egui::TextStyle::Monospace), + ); + changed |= expression.changed(); + let remove = ui + .add(egui::Button::new("−").small().frame(false)) + .on_hover_text("Remove field"); + if remove.clicked() { + removed = Some(index); + } + }); + if let Some(error) = custom_field_error(field) { + ui.label(egui::RichText::new(error).color(theme::ERROR).size(11.0)); + } + }); + } + if let Some(index) = removed { + if index < fields.len() { + fields.remove(index); + changed = true; + } + } + ui.horizontal(|ui| { + if ui.small_button("+ Add field").clicked() { + fields.push(CustomField { + name: next_field_name(fields), + expr: "0.0".into(), + }); + changed = true; + } + if fields.is_empty() { + ui.label(theme::hint( + "No fields: the generated code is just the component type path.", + )); + } + }); + changed +} + +fn add_component_controls( + ui: &mut egui::Ui, + cx: &mut EditorContext<'_>, + state: &mut InspectorPanelState, + node: &EntityNode, +) { + let addable = addable_kinds(node); + let id = node.id; + + ui.menu_button(egui::RichText::new("+ Add component").strong(), |ui| { + ui.set_min_width(280.0); + if addable.is_empty() { + ui.label(theme::hint("All built-in components are already attached.")); + } + for kind in &addable { + let label = format!("{} — {}", kind.label(), kind.description()); + if ui.button(label).clicked() { + let kind = *kind; + let component = ComponentData::from_kind(kind); + cx.session.edit(format!("Add {}", kind.label()), |doc| { + doc.add_component(id, component); + }); + cx.ui.status = format!("Added {}", kind.label()); + cx.toasts.success(format!("Added {}", kind.label())); + ui.close(); + } + } + ui.separator(); + if ui.button("Custom component…").clicked() { + state.custom_dialog_open = true; + state.custom_dialog_entity = Some(id); + state.custom_type_path.clear(); + state.custom_fields.clear(); + ui.close(); + } + }); + + if state.custom_dialog_open { + ui.add_space(6.0); + custom_add_dialog(ui, cx, state, node); + } +} + +fn custom_add_dialog( + ui: &mut egui::Ui, + cx: &mut EditorContext<'_>, + state: &mut InspectorPanelState, + node: &EntityNode, +) { + theme::inset_frame().show(ui, |ui| { + ui.horizontal(|ui| { + ui.label(egui::RichText::new("New custom component").strong()); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if ui.small_button("Cancel").clicked() { + close_custom_dialog(state); + } + }); + }); + ui.horizontal(|ui| { + widgets::field_label(ui, "Type path"); + ui.add( + egui::TextEdit::singleline(&mut state.custom_type_path) + .hint_text("game::Health") + .desired_width(f32::INFINITY) + .font(egui::TextStyle::Monospace), + ); + }); + widgets::field_label(ui, "Fields (raw Rust/BSN expressions)"); + custom_fields_editor(ui, &mut state.custom_fields); + + let type_path = state.custom_type_path.trim().to_string(); + let key = format!("custom:{type_path}"); + let duplicate = !type_path.is_empty() && node.has_component(&key); + let errors = validate_custom_draft(&type_path, &state.custom_fields); + for error in &errors { + ui.label( + egui::RichText::new(format!("⚠ {error}")) + .color(theme::ERROR) + .size(11.0), + ); + } + if duplicate { + ui.label( + egui::RichText::new("A component with this type path is already attached.") + .color(theme::WARNING) + .size(11.0), + ); + } + + ui.horizontal(|ui| { + let can_add = errors.is_empty() && !duplicate; + if ui + .add_enabled(can_add, widgets::primary_button("Add component")) + .clicked() + { + let custom = CustomComponent { + type_path: type_path.clone(), + fields: state.custom_fields.clone(), + }; + let short = custom.short_name().to_string(); + cx.session + .edit(format!("Add custom component {short}"), |doc| { + doc.add_component(node.id, ComponentData::Custom(custom)); + }); + cx.ui.status = format!("Added custom component {short}"); + cx.toasts.success(format!("Added custom component {short}")); + close_custom_dialog(state); + } + if ui.button("Cancel").clicked() { + close_custom_dialog(state); + } + }); + }); +} + +fn close_custom_dialog(state: &mut InspectorPanelState) { + state.custom_dialog_open = false; + state.custom_dialog_entity = None; + state.custom_type_path.clear(); + state.custom_fields.clear(); +} + +// --------------------------------------------------------------------------- +// Edit helpers +// --------------------------------------------------------------------------- + +fn commit_scene_name( + cx: &mut EditorContext<'_>, + state: &mut InspectorPanelState, + current_name: &str, +) { + let new_name = state.scene_name_buffer.trim().to_string(); + if new_name == current_name { + state.scene_name_buffer = current_name.to_string(); + return; + } + if new_name.is_empty() { + cx.toasts + .warning("Scene name cannot be empty — keeping the previous name"); + state.scene_name_buffer = current_name.to_string(); + return; + } + cx.session.edit("Rename scene", |doc| { + doc.name = new_name.clone(); + }); + cx.ui.status = format!("Renamed scene to {new_name}"); +} + +fn commit_entity_name( + cx: &mut EditorContext<'_>, + state: &mut InspectorPanelState, + id: EntityId, + current_name: &str, +) { + let new_name = state.name_buffer.trim().to_string(); + if new_name == current_name { + state.name_buffer = current_name.to_string(); + return; + } + if new_name.is_empty() { + cx.toasts + .warning("Entity name cannot be empty — keeping the previous name"); + state.name_buffer = current_name.to_string(); + return; + } + cx.session.edit("Rename entity", |doc| { + doc.rename(id, new_name.clone()); + }); + cx.ui.status = format!("Renamed entity to {new_name}"); +} + +fn apply_component( + cx: &mut EditorContext<'_>, + id: EntityId, + undo_label: &str, + component: ComponentData, +) { + cx.session.edit(undo_label, |doc| { + doc.set_component(id, component); + }); + cx.ui.status = format!("{undo_label} (undoable)"); +} + +fn remove_component_by_key(cx: &mut EditorContext<'_>, id: EntityId, key: &str, label: &str) { + cx.session.edit(format!("Remove {label}"), |doc| { + if let Some(node) = doc.entity_mut(id) { + if let Some(index) = node + .components + .iter() + .position(|component| component.key() == key) + { + node.components.remove(index); + } + } + }); + cx.ui.status = format!("Removed {label}"); + cx.toasts.info(format!("Removed {label} — Ctrl+Z to undo")); +} + +/// Apply an edited custom component. Changing the type path changes the +/// component's uniqueness key, so the old entry is replaced in one undo step and +/// collisions with an existing component are rejected instead of corrupting the +/// document. +fn apply_custom_component( + cx: &mut EditorContext<'_>, + id: EntityId, + old_key: &str, + custom: CustomComponent, +) { + let new_key = format!("custom:{}", custom.type_path); + if new_key != old_key { + let duplicate = cx + .session + .doc() + .entity(id) + .map(|node| node.has_component(&new_key)) + .unwrap_or(false); + if duplicate { + cx.toasts + .warning("Another component already uses that type path"); + return; + } + cx.session.edit("Edit custom component", |doc| { + if let Some(node) = doc.entity_mut(id) { + if node.has_component(&new_key) { + return; + } + if let Some(index) = node + .components + .iter() + .position(|component| component.key() == old_key) + { + node.components[index] = ComponentData::Custom(custom); + } + } + }); + } else { + cx.session.edit("Edit custom component", |doc| { + doc.set_component(id, ComponentData::Custom(custom)); + }); + } + cx.ui.status = "Edited custom component".into(); +} + +fn set_entity_hidden(cx: &mut EditorContext<'_>, id: EntityId, hidden: bool) { + cx.session + .edit(if hidden { "Hide entity" } else { "Show entity" }, |doc| { + if let Some(node) = doc.entity_mut(id) { + node.components + .retain(|component| component.key() != "visibility"); + if hidden { + node.components + .push(ComponentData::Visibility(VisibilityMode::Hidden)); + } + } + }); + cx.ui.status = if hidden { + "Entity hidden".into() + } else { + "Entity visibility reset to inherited".into() + }; +} + +// --------------------------------------------------------------------------- +// Name buffer synchronisation +// --------------------------------------------------------------------------- + +fn sync_scene_name(state: &mut InspectorPanelState, scene_name: &str) { + sync_buffer( + &mut state.scene_name_buffer, + &mut state.scene_name_last_doc, + scene_name, + ); +} + +fn sync_entity_name(state: &mut InspectorPanelState, node: Option<&EntityNode>) { + match node { + Some(node) => { + if state.name_entity != Some(node.id) { + state.name_entity = Some(node.id); + state.name_buffer = node.name.clone(); + state.name_last_doc = node.name.clone(); + } else { + sync_buffer(&mut state.name_buffer, &mut state.name_last_doc, &node.name); + } + } + None => state.name_entity = None, + } +} + +/// Keep a text buffer in sync with the document without discarding uncommitted +/// user input: only overwrite the buffer when it still matches the last value we +/// synchronised from the document (i.e. the user has not typed since). +fn sync_buffer(buffer: &mut String, last_doc: &mut String, doc_value: &str) { + if *last_doc != doc_value { + if *buffer == *last_doc { + buffer.clear(); + buffer.push_str(doc_value); + } + last_doc.clear(); + last_doc.push_str(doc_value); + } +} + +// --------------------------------------------------------------------------- +// Pure helpers (unit-tested) +// --------------------------------------------------------------------------- + +fn entity_summary(node: &EntityNode) -> String { + if node.components.is_empty() { + return "Transform only".to_string(); + } + node.components + .iter() + .map(|component| component.label()) + .collect::>() + .join(" · ") +} + +fn addable_kinds(node: &EntityNode) -> Vec { + ComponentKind::ALL + .into_iter() + .filter(|kind| !node.has_component(kind.key())) + .collect() +} + +fn component_matches(query: &str, key: &str, label: &str) -> bool { + let query = query.trim(); + if query.is_empty() { + return true; + } + let query = query.to_ascii_lowercase(); + key.to_ascii_lowercase().contains(&query) || label.to_ascii_lowercase().contains(&query) +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum ShapeChoice { + Cube, + Sphere, + Plane, + Cylinder, + Capsule, +} + +impl ShapeChoice { + const ALL: [Self; 5] = [ + Self::Cube, + Self::Sphere, + Self::Plane, + Self::Cylinder, + Self::Capsule, + ]; + + const fn label(self) -> &'static str { + match self { + Self::Cube => "Cube", + Self::Sphere => "Sphere", + Self::Plane => "Plane", + Self::Cylinder => "Cylinder", + Self::Capsule => "Capsule", + } + } + + fn from_primitive(shape: &PrimitiveShape) -> Self { + match shape { + PrimitiveShape::Cuboid { .. } => Self::Cube, + PrimitiveShape::Sphere { .. } => Self::Sphere, + PrimitiveShape::Plane { .. } => Self::Plane, + PrimitiveShape::Cylinder { .. } => Self::Cylinder, + PrimitiveShape::Capsule { .. } => Self::Capsule, + } + } +} + +fn default_primitive(choice: ShapeChoice) -> PrimitiveShape { + match choice { + ShapeChoice::Cube => PrimitiveShape::Cuboid { size: Vec3::ONE }, + ShapeChoice::Sphere => PrimitiveShape::Sphere { radius: 0.5 }, + ShapeChoice::Plane => PrimitiveShape::Plane { + size: Vec2::new(2.0, 2.0), + }, + ShapeChoice::Cylinder => PrimitiveShape::Cylinder { + radius: 0.5, + height: 1.0, + }, + ShapeChoice::Capsule => PrimitiveShape::Capsule { + radius: 0.5, + length: 1.0, + }, + } +} + +fn next_field_name(fields: &[CustomField]) -> String { + let taken = |candidate: &str| fields.iter().any(|field| field.name == candidate); + if !taken("field") { + return "field".to_string(); + } + let mut index = 2usize; + loop { + let candidate = format!("field_{index}"); + if !taken(&candidate) { + return candidate; + } + index += 1; + if index > 10_000 { + return format!("field_{index}"); + } + } +} + +fn custom_field_error(field: &CustomField) -> Option<&'static str> { + if !is_rust_ident(field.name.trim()) { + return Some("Field name must be a valid Rust identifier"); + } + if field.expr.trim().is_empty() { + return Some("Field value expression is required"); + } + None +} + +fn validate_custom_draft(type_path: &str, fields: &[CustomField]) -> Vec { + let mut errors = Vec::new(); + let type_path = type_path.trim(); + if type_path.is_empty() { + errors.push("Type path is required (e.g. `game::Health`)".to_string()); + } else if !is_rust_path(type_path) { + errors.push("Type path is not a valid Rust path".to_string()); + } + for field in fields { + if let Some(error) = custom_field_error(field) { + let name = if field.name.trim().is_empty() { + "" + } else { + field.name.trim() + }; + errors.push(format!("{name}: {error}")); + } + } + errors +} + +fn severity_glyph(severity: Severity) -> &'static str { + match severity { + Severity::Error => "✖", + Severity::Warning => "⚠", + Severity::Info => "ℹ", + } +} + +fn severity_color(severity: Severity) -> egui::Color32 { + match severity { + Severity::Error => theme::ERROR, + Severity::Warning => theme::WARNING, + Severity::Info => theme::ACCENT, + } +} + +fn issue_text(issue: &Issue) -> String { + match &issue.field { + Some(field) => format!("{field}: {}", issue.message), + None => issue.message.clone(), + } +} + +fn validation_summary(report: &ValidationReport) -> String { + format!( + "{} {} errors · {} {} warnings · {} {} info", + severity_glyph(Severity::Error), + report.errors(), + severity_glyph(Severity::Warning), + report.warnings(), + severity_glyph(Severity::Info), + report.count(Severity::Info), + ) +} + +fn sanitize_finite(value: &mut f32, fallback: f32) -> bool { + if value.is_finite() { + false + } else { + *value = fallback; + true + } +} + +/// Clamp a mesh dimension to a sane positive range, replacing non-finite input. +fn sanitize_dimension(value: f32, fallback: f32) -> f32 { + if value.is_finite() { + value.clamp(0.001, 1.0e4) + } else if fallback.is_finite() { + fallback.clamp(0.001, 1.0e4) + } else { + 1.0 + } +} + +fn drag_value( + ui: &mut egui::Ui, + value: &mut f32, + speed: f32, + decimals: usize, + range: RangeInclusive, +) -> bool { + ui.add( + egui::DragValue::new(value) + .speed(speed) + .range(range) + .fixed_decimals(decimals), + ) + .changed() +} + +fn dimension_row(ui: &mut egui::Ui, label: &str, value: &mut f32, speed: f32) -> bool { + let mut changed = ui + .horizontal(|ui| { + widgets::field_label(ui, label); + drag_value(ui, value, speed, 3, 0.001..=1.0e4) + }) + .inner; + let sanitized = sanitize_dimension(*value, 1.0); + if sanitized != *value { + *value = sanitized; + changed = true; + } + changed +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + use bevy_forge_core::{samples, AssetIndex, SceneDoc, Session}; + + use crate::state::{EditorContext, ExportState, UiState}; + use crate::texture_cache::TextureCache; + use crate::toast::Toasts; + + fn node_with(components: Vec) -> EntityNode { + let mut doc = SceneDoc::new("Test"); + let id = doc.add_entity("Entity", None); + for component in components { + assert!(doc.add_component(id, component)); + } + doc.entity(id).expect("entity exists").clone() + } + + fn all_components_doc() -> (SceneDoc, EntityId) { + let mut doc = SceneDoc::new("All components"); + let id = doc.add_entity("Hero", None); + let components = vec![ + ComponentData::Sprite(SpriteData::default()), + ComponentData::Text2d(Text2dData::default()), + ComponentData::Camera2d, + ComponentData::Camera3d, + ComponentData::PointLight(PointLightData::default()), + ComponentData::DirectionalLight(DirectionalLightData::default()), + ComponentData::Mesh3d(Mesh3dData::default()), + ComponentData::Visibility(VisibilityMode::Visible), + ComponentData::Custom(CustomComponent { + type_path: "game::Health".into(), + fields: vec![CustomField { + name: "current".into(), + expr: "10.0".into(), + }], + }), + ]; + for component in components { + assert!(doc.add_component(id, component)); + } + (doc, id) + } + + fn render_inspector(doc: SceneDoc, selection: Option) -> usize { + let ctx = egui::Context::default(); + let mut session = Session::new(doc); + let assets = AssetIndex::default(); + let mut textures = TextureCache::new(); + let mut ui_state = UiState::default(); + if let Some(id) = selection { + ui_state.selection.select_only(id); + } + let mut toasts = Toasts::new(); + let mut export = ExportState::default(); + let mut state = InspectorPanelState::default(); + let mut cx = EditorContext { + session: &mut session, + project: None, + assets: &assets, + textures: &mut textures, + ui: &mut ui_state, + toasts: &mut toasts, + export: &mut export, + }; + let raw = egui::RawInput { + screen_rect: Some(egui::Rect::from_min_size( + egui::Pos2::ZERO, + egui::vec2(1280.0, 900.0), + )), + ..Default::default() + }; + let output = ctx.run(raw, |ctx| { + let actions = show(ctx, &mut cx, &mut state); + assert!( + actions.is_empty(), + "inspector must not emit actions without clicks" + ); + }); + output.shapes.len() + } + + fn editor_context<'a>( + session: &'a mut Session, + assets: &'a AssetIndex, + textures: &'a mut TextureCache, + ui: &'a mut UiState, + toasts: &'a mut Toasts, + export: &'a mut ExportState, + ) -> EditorContext<'a> { + EditorContext { + session, + project: None, + assets, + textures, + ui, + toasts, + export, + } + } + + #[test] + fn entity_summary_lists_component_labels() { + let empty = EntityNode { + id: 7, + name: "Empty".into(), + parent: None, + children: Vec::new(), + transform: Transform::IDENTITY, + components: Vec::new(), + locked: false, + }; + assert_eq!(entity_summary(&empty), "Transform only"); + + let node = node_with(vec![ + ComponentData::Sprite(SpriteData::default()), + ComponentData::Camera3d, + ComponentData::Custom(CustomComponent { + type_path: "game::Health".into(), + fields: Vec::new(), + }), + ]); + let summary = entity_summary(&node); + assert_eq!(summary, "Sprite · Camera3d · Health"); + } + + #[test] + fn addable_kinds_hides_attached_components() { + let node = node_with(vec![ + ComponentData::Sprite(SpriteData::default()), + ComponentData::Visibility(VisibilityMode::Hidden), + ]); + let addable = addable_kinds(&node); + assert_eq!(addable.len(), ComponentKind::ALL.len() - 2); + assert!(!addable.contains(&ComponentKind::Sprite)); + assert!(!addable.contains(&ComponentKind::Visibility)); + assert!(addable.contains(&ComponentKind::Mesh3d)); + } + + #[test] + fn component_filter_matches_key_and_label() { + assert!(component_matches("", "sprite", "Sprite")); + assert!(component_matches("spr", "sprite", "Sprite")); + assert!(component_matches("LIGHT", "point_light", "PointLight")); + assert!(component_matches("health", "custom:game::Health", "Health")); + assert!(!component_matches("mesh", "sprite", "Sprite")); + } + + #[test] + fn shape_choices_rebuild_default_primitives() { + for choice in ShapeChoice::ALL { + let primitive = default_primitive(choice); + assert_eq!(ShapeChoice::from_primitive(&primitive), choice); + for extent in primitive.half_extents().to_array() { + assert!(extent.is_finite() && extent > 0.0, "{primitive:?}"); + } + } + } + + #[test] + fn custom_field_names_are_unique() { + let mut fields = Vec::new(); + assert_eq!(next_field_name(&fields), "field"); + fields.push(CustomField { + name: "field".into(), + expr: "0.0".into(), + }); + assert_eq!(next_field_name(&fields), "field_2"); + fields.push(CustomField { + name: "field_2".into(), + expr: "1.0".into(), + }); + assert_eq!(next_field_name(&fields), "field_3"); + } + + #[test] + fn custom_draft_validation_rejects_bad_input() { + assert!(validate_custom_draft("game::Health", &[]).is_empty()); + assert!(!validate_custom_draft("", &[]).is_empty()); + assert!(!validate_custom_draft("game::", &[]).is_empty()); + let bad_name = [CustomField { + name: "2hp".into(), + expr: "10.0".into(), + }]; + assert!(!validate_custom_draft("Health", &bad_name).is_empty()); + let empty_expr = [CustomField { + name: "hp".into(), + expr: " ".into(), + }]; + assert!(!validate_custom_draft("Health", &empty_expr).is_empty()); + assert!(custom_field_error(&CustomField { + name: "hp".into(), + expr: "10.0".into(), + }) + .is_none()); + } + + #[test] + fn validation_summary_shows_counts_and_icons() { + let mut report = ValidationReport::default(); + report.issues.push(Issue::error("broken")); + report + .issues + .push(Issue::warning("missing texture").with_field("sprite.image")); + report.issues.push(Issue::info("no camera")); + let summary = validation_summary(&report); + assert!( + summary.contains('1') && summary.contains("errors"), + "{summary}" + ); + assert!(summary.contains("warnings"), "{summary}"); + assert!(summary.contains(severity_glyph(Severity::Error))); + assert!(summary.contains(severity_glyph(Severity::Warning))); + + assert_eq!( + issue_text(&Issue::warning("missing").with_field("sprite.image")), + "sprite.image: missing" + ); + assert_ne!( + severity_color(Severity::Error), + severity_color(Severity::Warning) + ); + assert_ne!( + severity_glyph(Severity::Error), + severity_glyph(Severity::Warning) + ); + } + + #[test] + fn empty_names_are_rejected_and_keep_previous_value() { + let mut session = Session::new(samples::starter_2d()); + let id = session.edit("add", |doc| doc.add_entity("Player", None)); + let assets = AssetIndex::default(); + let mut textures = TextureCache::new(); + let mut ui_state = UiState::default(); + let mut toasts = Toasts::new(); + let mut export = ExportState::default(); + let mut cx = editor_context( + &mut session, + &assets, + &mut textures, + &mut ui_state, + &mut toasts, + &mut export, + ); + let mut state = InspectorPanelState::default(); + + state.scene_name_buffer = " ".into(); + let scene_before = cx.session.doc().name.clone(); + commit_scene_name(&mut cx, &mut state, &scene_before); + assert_eq!(cx.session.doc().name, scene_before); + assert_eq!(state.scene_name_buffer, scene_before); + assert_eq!(cx.toasts.len(), 1); + + state.name_buffer = "".into(); + commit_entity_name(&mut cx, &mut state, id, "Player"); + assert_eq!(cx.session.doc().entity(id).expect("entity").name, "Player"); + assert_eq!(cx.toasts.len(), 2); + + state.name_buffer = "Renamed".into(); + commit_entity_name(&mut cx, &mut state, id, "Player"); + assert_eq!(cx.session.doc().entity(id).expect("entity").name, "Renamed"); + assert_eq!(cx.session.undo_label(), Some("Rename entity")); + cx.session.undo(); + assert_eq!(cx.session.doc().entity(id).expect("entity").name, "Player"); + } + + #[test] + fn inspector_component_edits_are_undoable() { + let mut session = Session::new(SceneDoc::new("Test")); + let id = session.edit("add", |doc| doc.add_entity("Player", None)); + let assets = AssetIndex::default(); + let mut textures = TextureCache::new(); + let mut ui_state = UiState::default(); + let mut toasts = Toasts::new(); + let mut export = ExportState::default(); + let mut cx = editor_context( + &mut session, + &assets, + &mut textures, + &mut ui_state, + &mut toasts, + &mut export, + ); + + apply_component( + &mut cx, + id, + "Add Sprite", + ComponentData::Sprite(SpriteData::default()), + ); + assert!(cx + .session + .doc() + .entity(id) + .expect("entity") + .has_component("sprite")); + assert_eq!(cx.session.undo_label(), Some("Add Sprite")); + cx.session.undo(); + assert!(!cx + .session + .doc() + .entity(id) + .expect("entity") + .has_component("sprite")); + cx.session.redo(); + assert!(cx + .session + .doc() + .entity(id) + .expect("entity") + .has_component("sprite")); + + remove_component_by_key(&mut cx, id, "sprite", "Sprite"); + assert!(!cx + .session + .doc() + .entity(id) + .expect("entity") + .has_component("sprite")); + cx.session.undo(); + assert!(cx + .session + .doc() + .entity(id) + .expect("entity") + .has_component("sprite")); + } + + #[test] + fn every_component_type_edit_is_undoable() { + let mut session = Session::new(SceneDoc::new("Test")); + let id = session.edit("add", |doc| doc.add_entity("Player", None)); + let assets = AssetIndex::default(); + let mut textures = TextureCache::new(); + let mut ui_state = UiState::default(); + let mut toasts = Toasts::new(); + let mut export = ExportState::default(); + let mut cx = editor_context( + &mut session, + &assets, + &mut textures, + &mut ui_state, + &mut toasts, + &mut export, + ); + + let components = vec![ + ComponentData::Sprite(SpriteData { + image: Some("textures/player.png".into()), + ..Default::default() + }), + ComponentData::Text2d(Text2dData { + text: "Hi".into(), + font_size: 48.0, + color: Color::WHITE, + }), + ComponentData::Camera2d, + ComponentData::Camera3d, + ComponentData::PointLight(PointLightData::default()), + ComponentData::DirectionalLight(DirectionalLightData::default()), + ComponentData::Mesh3d(Mesh3dData::default()), + ComponentData::Visibility(VisibilityMode::Hidden), + ComponentData::Custom(CustomComponent { + type_path: "game::Health".into(), + fields: Vec::new(), + }), + ]; + let expected = components.len(); + for component in components { + let label = format!("Edit {}", component.label()); + apply_component(&mut cx, id, &label, component); + } + assert_eq!( + cx.session + .doc() + .entity(id) + .expect("entity") + .components + .len(), + expected + ); + for _ in 0..expected { + assert!(cx.session.undo().is_some()); + } + assert!(cx + .session + .doc() + .entity(id) + .expect("entity") + .components + .is_empty()); + } + + #[test] + fn custom_component_path_change_is_one_undo_step() { + let mut session = Session::new(SceneDoc::new("Test")); + let id = session.edit("add", |doc| { + let id = doc.add_entity("Player", None); + doc.add_component( + id, + ComponentData::Custom(CustomComponent { + type_path: "game::Health".into(), + fields: Vec::new(), + }), + ); + id + }); + let assets = AssetIndex::default(); + let mut textures = TextureCache::new(); + let mut ui_state = UiState::default(); + let mut toasts = Toasts::new(); + let mut export = ExportState::default(); + let mut cx = editor_context( + &mut session, + &assets, + &mut textures, + &mut ui_state, + &mut toasts, + &mut export, + ); + + apply_custom_component( + &mut cx, + id, + "custom:game::Health", + CustomComponent { + type_path: "game::Mana".into(), + fields: Vec::new(), + }, + ); + let node = cx.session.doc().entity(id).expect("entity"); + assert!(node.has_component("custom:game::Mana")); + assert!(!node.has_component("custom:game::Health")); + assert_eq!(node.components.len(), 1); + assert_eq!(cx.session.undo_label(), Some("Edit custom component")); + cx.session.undo(); + let node = cx.session.doc().entity(id).expect("entity"); + assert!(node.has_component("custom:game::Health")); + assert_eq!(node.components.len(), 1); + } + + #[test] + fn demo_project_images_feed_the_sprite_picker() { + // The demo project is committed (A05); the inspector passes exactly the + // index's image paths to `widgets::asset_picker`. + let assets_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../examples/demo-project/assets"); + let assets = AssetIndex::scan(&assets_dir); + assert!(assets.error.is_none(), "{:?}", assets.error); + let mut session = Session::new(samples::platformer_demo()); + let mut textures = TextureCache::new(); + let mut ui_state = UiState::default(); + let mut toasts = Toasts::new(); + let mut export = ExportState::default(); + let cx = editor_context( + &mut session, + &assets, + &mut textures, + &mut ui_state, + &mut toasts, + &mut export, + ); + let images = cx.image_assets(); + for expected in [ + "textures/background.png", + "textures/player.png", + "textures/hat.png", + ] { + assert!( + images.iter().any(|path| path == expected), + "missing {expected} in {images:?}" + ); + } + } + + #[test] + fn renders_scene_card_headlessly() { + let shapes = render_inspector(samples::starter_2d(), None); + assert!(shapes > 0, "the scene inspector rendered no shapes"); + } + + #[test] + fn renders_every_component_card_headlessly() { + let (doc, id) = all_components_doc(); + let shapes = render_inspector(doc, Some(id)); + assert!(shapes > 0, "the entity inspector rendered no shapes"); + } + + #[test] + fn renders_multi_selection_headlessly() { + let doc = samples::platformer_demo(); + let ids: Vec = doc.depth_first_ids(); + assert!(ids.len() >= 2); + let ctx = egui::Context::default(); + let mut session = Session::new(doc); + let assets = AssetIndex::default(); + let mut textures = TextureCache::new(); + let mut ui_state = UiState::default(); + ui_state + .selection + .select_many(ids.iter().copied(), Some(ids[0])); + let mut toasts = Toasts::new(); + let mut export = ExportState::default(); + let mut state = InspectorPanelState::default(); + let mut cx = editor_context( + &mut session, + &assets, + &mut textures, + &mut ui_state, + &mut toasts, + &mut export, + ); + let raw = egui::RawInput { + screen_rect: Some(egui::Rect::from_min_size( + egui::Pos2::ZERO, + egui::vec2(1440.0, 900.0), + )), + ..Default::default() + }; + let output = ctx.run(raw, |ctx| { + let actions = show(ctx, &mut cx, &mut state); + assert!(actions.is_empty()); + }); + assert!(output.shapes.len() > 0); + } +} diff --git a/crates/bevy_forge_editor/src/lib.rs b/crates/bevy_forge_editor/src/lib.rs new file mode 100644 index 0000000..a24c757 --- /dev/null +++ b/crates/bevy_forge_editor/src/lib.rs @@ -0,0 +1,24 @@ +//! Bevy Forge editor library. Exposed for headless panel tests. + +pub mod action; +pub mod app; +pub mod assets_panel; +pub mod dialogs; +pub mod hierarchy; +pub mod inspector; +pub mod menu; +pub mod palette; +pub mod shortcuts; +pub mod state; +pub mod status_bar; +pub mod texture_cache; +pub mod theme; +pub mod toast; +pub mod viewport2d; +pub mod viewport3d; +pub mod welcome; +pub mod widgets; + +pub use action::Action; +pub use app::ForgeApp; +pub use state::{Screen, Selection, UiState, ViewMode}; diff --git a/crates/bevy_forge_editor/src/main.rs b/crates/bevy_forge_editor/src/main.rs new file mode 100644 index 0000000..9d309e9 --- /dev/null +++ b/crates/bevy_forge_editor/src/main.rs @@ -0,0 +1,34 @@ +use std::path::PathBuf; + +use eframe::egui; + +fn main() -> eframe::Result<()> { + let initial = std::env::args_os().nth(1).map(PathBuf::from); + let size = std::env::var("BEVY_FORGE_WINDOW_SIZE") + .ok() + .and_then(|value| { + let (width, height) = value.split_once('x')?; + Some([ + width.trim().parse::().ok()?, + height.trim().parse::().ok()?, + ]) + }) + .unwrap_or([1440.0, 900.0]); + let options = eframe::NativeOptions { + viewport: egui::ViewportBuilder::default() + .with_inner_size(size) + .with_min_inner_size([1000.0_f32, 640.0_f32]) + .with_title("Bevy Forge") + .with_app_id("bevy-forge"), + ..Default::default() + }; + + eframe::run_native( + "Bevy Forge", + options, + Box::new(move |cc| { + bevy_forge_editor::theme::apply(&cc.egui_ctx); + Ok(Box::new(bevy_forge_editor::ForgeApp::new(cc, initial))) + }), + ) +} diff --git a/crates/bevy_forge_editor/src/menu.rs b/crates/bevy_forge_editor/src/menu.rs new file mode 100644 index 0000000..fc14ef7 --- /dev/null +++ b/crates/bevy_forge_editor/src/menu.rs @@ -0,0 +1,501 @@ +//! Top menu bar: File, Edit, View and Help menus plus the project/scene badge. +//! +//! Menu entries only emit deferred [`Action`]s; the app owns all state changes. +//! The context is borrowed immutably, so toggles edit local copies and emit the +//! matching toggle action. Every action item closes its menu with `ui.close()`. +//! +//! Shortcut hints use [`egui::Modifiers::COMMAND`] conventions (`Ctrl` on +//! Linux/Windows, `Cmd` on macOS) and match [`crate::shortcuts`]. + +use bevy_forge_core::{ExportFormat, ProjectTemplate}; + +use crate::action::Action; +use crate::state::{EditorContext, ViewMode}; +use crate::theme; +use crate::widgets; + +/// Label of the platform's primary command modifier. +const COMMAND: &str = if cfg!(target_os = "macos") { + "Cmd" +} else { + "Ctrl" +}; + +pub fn show(ctx: &egui::Context, cx: &EditorContext<'_>) -> Vec { + let mut actions = Vec::new(); + + egui::TopBottomPanel::top("menu_bar") + .exact_height(32.0) + .frame( + egui::Frame::new() + .fill(theme::BG) + .inner_margin(egui::Margin::symmetric(10, 4)), + ) + .show(ctx, |ui| { + egui::MenuBar::new().ui(ui, |ui| { + ui.label( + egui::RichText::new("◊ Bevy Forge") + .color(theme::ACCENT) + .strong() + .size(14.0), + ) + .on_hover_text("Bevy Forge — visual scene editor for Bevy 0.20 BSN"); + ui.separator(); + + file_menu(ui, cx, &mut actions); + edit_menu(ui, cx, &mut actions); + view_menu(ui, cx, &mut actions); + help_menu(ui, &mut actions); + + project_badge(ui, cx); + }); + }); + + actions +} + +// --------------------------------------------------------------------------- +// Menus +// --------------------------------------------------------------------------- + +fn file_menu(ui: &mut egui::Ui, cx: &EditorContext<'_>, actions: &mut Vec) { + let project_open = cx.project.is_some(); + + ui.menu_button("File", |ui| { + // A click inside the "New scene" submenu closes the submenu but not the + // File menu (egui delegates close handling to the submenu), so remember + // to close the parent as well. + let mut close_file_menu = false; + ui.menu_button("New scene", |ui| { + if item(ui, "Empty scene", "") + .on_hover_text("A blank scene with nothing in it") + .clicked() + { + actions.push(Action::NewScene(ProjectTemplate::Empty)); + ui.close(); + close_file_menu = true; + } + if item(ui, "2D starter", "") + .on_hover_text("Camera, light and a sprite to build on") + .clicked() + { + actions.push(Action::NewScene(ProjectTemplate::TwoD)); + ui.close(); + close_file_menu = true; + } + if item(ui, "3D starter", "") + .on_hover_text("Camera, sun, ground and primitive shapes") + .clicked() + { + actions.push(Action::NewScene(ProjectTemplate::ThreeD)); + ui.close(); + close_file_menu = true; + } + }); + if close_file_menu { + ui.close(); + } + + ui.separator(); + + if item(ui, "Open project…", &shortcut(false, "O")).clicked() { + actions.push(Action::OpenProjectDialog); + ui.close(); + } + if item_enabled(ui, project_open, "Open scene…", "") + .on_disabled_hover_text("Open a project first") + .clicked() + { + actions.push(Action::OpenSceneDialog); + ui.close(); + } + + ui.separator(); + + let save = item(ui, "Save", &shortcut(false, "S")); + let save = if project_open { + save.on_hover_text("Save the current scene") + } else { + save.on_hover_text("No project open — this opens the Save as dialog") + }; + if save.clicked() { + actions.push(Action::Save); + ui.close(); + } + if item(ui, "Save as…", &shortcut(true, "S")).clicked() { + actions.push(Action::OpenSaveAsDialog); + ui.close(); + } + + ui.separator(); + + if item_enabled(ui, project_open, "Reload assets", "") + .on_disabled_hover_text("Open a project first") + .clicked() + { + actions.push(Action::ReloadAssets); + ui.close(); + } + + ui.separator(); + + if item(ui, "Export BSN…", &shortcut(false, "E")) + .on_hover_text("Bevy 0.20 scene notation, ready to paste into a project") + .clicked() + { + actions.push(Action::OpenExportDialog(ExportFormat::Bsn)); + ui.close(); + } + if item(ui, "Export Rust…", &shortcut(true, "E")) + .on_hover_text("Imperative Commands::spawn code") + .clicked() + { + actions.push(Action::OpenExportDialog(ExportFormat::Rust)); + ui.close(); + } + if item(ui, "Copy BSN", "") + .on_hover_text("Generate BSN code and copy it to the clipboard") + .clicked() + { + actions.push(Action::CopyExport(ExportFormat::Bsn)); + ui.close(); + } + + ui.separator(); + + if item(ui, "Quit", "").clicked() { + actions.push(Action::RequestQuit); + ui.close(); + } + }); +} + +fn edit_menu(ui: &mut egui::Ui, cx: &EditorContext<'_>, actions: &mut Vec) { + ui.menu_button("Edit", |ui| { + let undo_label = match cx.session.undo_label() { + Some(label) => format!("Undo {label}"), + None => "Undo".to_owned(), + }; + if item_enabled( + ui, + cx.session.can_undo(), + &undo_label, + &shortcut(false, "Z"), + ) + .on_disabled_hover_text("Nothing to undo yet") + .clicked() + { + actions.push(Action::Undo); + ui.close(); + } + + let redo_label = match cx.session.redo_label() { + Some(label) => format!("Redo {label}"), + None => "Redo".to_owned(), + }; + if item_enabled( + ui, + cx.session.can_redo(), + &redo_label, + &shortcut(false, "Y"), + ) + .on_disabled_hover_text("Nothing to redo") + .clicked() + { + actions.push(Action::Redo); + ui.close(); + } + + ui.separator(); + + let has_selection = !cx.ui.selection.is_empty(); + if item_enabled(ui, has_selection, "Duplicate", &shortcut(false, "D")) + .on_disabled_hover_text("Select an entity first") + .clicked() + { + actions.push(Action::DuplicateSelection); + ui.close(); + } + if item_enabled(ui, has_selection, "Delete", "Delete") + .on_disabled_hover_text("Select an entity first") + .clicked() + { + actions.push(Action::DeleteSelection); + ui.close(); + } + }); +} + +fn view_menu(ui: &mut egui::Ui, cx: &EditorContext<'_>, actions: &mut Vec) { + ui.menu_button("View", |ui| { + let mut mode = cx.ui.view_mode; + let two_d = ui + .radio_value(&mut mode, ViewMode::TwoD, "2D view") + .on_hover_text("Switch to the 2D viewport (2)"); + let three_d = ui + .radio_value(&mut mode, ViewMode::ThreeD, "3D view") + .on_hover_text("Switch to the 3D viewport (3)"); + if (two_d.clicked() || three_d.clicked()) && mode != cx.ui.view_mode { + actions.push(Action::SetViewMode(mode)); + ui.close(); + } + + ui.separator(); + + let mut grid = cx.ui.show_grid; + if ui + .checkbox(&mut grid, "Grid") + .on_hover_text("Show the grid (G)") + .changed() + { + actions.push(Action::ToggleGrid); + ui.close(); + } + let mut snap = cx.ui.snap_to_grid; + if ui + .checkbox(&mut snap, "Snap to grid") + .on_hover_text("Snap moves to whole grid steps") + .changed() + { + actions.push(Action::ToggleSnap); + ui.close(); + } + + ui.separator(); + + let mut assets = cx.ui.show_assets; + if ui + .checkbox(&mut assets, "Asset browser") + .on_hover_text(format!( + "Toggle the asset browser ({})", + shortcut(false, "B") + )) + .changed() + { + actions.push(Action::ToggleAssets); + ui.close(); + } + let mut left = cx.ui.show_left_panel; + if ui + .checkbox(&mut left, "Hierarchy panel") + .on_hover_text(format!( + "Toggle the hierarchy panel ({})", + shortcut(true, "L") + )) + .changed() + { + actions.push(Action::ToggleLeftPanel); + ui.close(); + } + let mut right = cx.ui.show_right_panel; + if ui + .checkbox(&mut right, "Inspector panel") + .on_hover_text("Toggle the inspector panel") + .changed() + { + actions.push(Action::ToggleRightPanel); + ui.close(); + } + + ui.separator(); + + if item(ui, "Fit view", "F").clicked() { + actions.push(Action::FitView); + ui.close(); + } + if item(ui, "Command palette…", &shortcut(false, "K")).clicked() { + actions.push(Action::OpenCommandPalette); + ui.close(); + } + }); +} + +fn help_menu(ui: &mut egui::Ui, actions: &mut Vec) { + ui.menu_button("Help", |ui| { + if item(ui, "Keyboard shortcuts", "F1").clicked() { + actions.push(Action::ShowShortcuts); + ui.close(); + } + if item(ui, "About Bevy Forge", "").clicked() { + actions.push(Action::ShowAbout); + ui.close(); + } + ui.separator(); + ui.hyperlink_to("Bevy 0.20 documentation", "https://bevy.org") + .on_hover_text("Open bevy.org in your browser"); + }); +} + +// --------------------------------------------------------------------------- +// Project / scene badge on the right +// --------------------------------------------------------------------------- + +fn project_badge(ui: &mut egui::Ui, cx: &EditorContext<'_>) { + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + widgets::help_marker( + ui, + "Ctrl+K opens the command palette · F1 lists every shortcut", + ); + + if cx.session.is_dirty() { + ui.label( + egui::RichText::new("• unsaved") + .color(theme::WARNING) + .size(11.0), + ) + .on_hover_text("Unsaved changes — Ctrl+S saves the scene"); + } + + let scene = &cx.session.doc().name; + ui.add( + egui::Label::new( + egui::RichText::new(shorten(scene, 36)) + .color(theme::TEXT) + .size(12.0), + ) + .truncate(), + ) + .on_hover_text(format!("Scene: {scene}")); + + ui.separator(); + + match cx.project { + Some(project) => { + ui.add( + egui::Label::new( + egui::RichText::new(shorten(&project.name, 24)) + .color(theme::MUTED) + .size(11.5), + ) + .truncate(), + ) + .on_hover_text(format!("Project: {}", project.root.display())); + } + None => { + ui.label( + egui::RichText::new("No project") + .color(theme::FAINT) + .size(11.0), + ) + .on_hover_text("Open a Bevy project to save scenes, browse assets and export"); + } + } + + ui.separator(); + }); +} + +// --------------------------------------------------------------------------- +// Small helpers +// --------------------------------------------------------------------------- + +/// Standard menu item with a right-aligned shortcut hint. +fn item(ui: &mut egui::Ui, label: &str, shortcut_text: &str) -> egui::Response { + let button = egui::Button::new(label); + let button = if shortcut_text.is_empty() { + button + } else { + button.shortcut_text(shortcut_text) + }; + ui.add(button) +} + +/// Menu item that can be greyed out, with an explanation on hover. +fn item_enabled( + ui: &mut egui::Ui, + enabled: bool, + label: &str, + shortcut_text: &str, +) -> egui::Response { + let button = egui::Button::new(label); + let button = if shortcut_text.is_empty() { + button + } else { + button.shortcut_text(shortcut_text) + }; + ui.add_enabled(enabled, button) +} + +/// `Ctrl+Z`-style hint for the menu (with `Shift` when requested). +fn shortcut(shift: bool, key: &str) -> String { + if shift { + format!("{COMMAND}+Shift+{key}") + } else { + format!("{COMMAND}+{key}") + } +} + +/// Elide the middle of a long scene/project name so both ends stay readable. +fn shorten(text: &str, max_chars: usize) -> String { + let count = text.chars().count(); + if count <= max_chars || max_chars < 4 { + return text.to_owned(); + } + let keep_left = (max_chars - 1) / 2; + let keep_right = max_chars - 1 - keep_left; + let left: String = text.chars().take(keep_left).collect(); + let right: String = text.chars().skip(count - keep_right).collect(); + format!("{left}…{right}") +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::state::{ExportState, UiState}; + use crate::texture_cache::TextureCache; + use crate::toast::Toasts; + use bevy_forge_core::{AssetIndex, SceneDoc, Session}; + + #[test] + fn shortcut_hints_use_the_platform_command_key() { + assert_eq!(shortcut(false, "S"), format!("{COMMAND}+S")); + assert_eq!(shortcut(true, "S"), format!("{COMMAND}+Shift+S")); + assert!(shortcut(false, "Z").ends_with('Z')); + } + + #[test] + fn scene_names_are_shortened_in_the_middle() { + assert_eq!(shorten("garden", 20), "garden"); + assert_eq!(shorten("abcdefghij", 6), "ab…hij"); + assert_eq!( + shorten("Édition — jardin.forge.ron", 14).chars().count(), + 14 + ); + } + + #[test] + fn menu_draws_headlessly_without_a_project() { + let ctx = egui::Context::default(); + theme::apply(&ctx); + + let mut session = Session::new(SceneDoc::new("Untitled")); + let assets = AssetIndex::default(); + let mut textures = TextureCache::new(); + let mut ui_state = UiState::default(); + let mut toasts = Toasts::new(); + let mut export = ExportState::default(); + let mut actions = Vec::new(); + + let raw_input = egui::RawInput { + screen_rect: Some(egui::Rect::from_min_size( + egui::Pos2::ZERO, + egui::vec2(1440.0, 900.0), + )), + ..Default::default() + }; + let _ = ctx.run(raw_input, |ctx| { + let cx = EditorContext { + session: &mut session, + project: None, + assets: &assets, + textures: &mut textures, + ui: &mut ui_state, + toasts: &mut toasts, + export: &mut export, + }; + actions = show(ctx, &cx); + }); + + assert!(actions.is_empty(), "opening the menu bar emits no action"); + } +} diff --git a/crates/bevy_forge_editor/src/palette.rs b/crates/bevy_forge_editor/src/palette.rs new file mode 100644 index 0000000..25bcf86 --- /dev/null +++ b/crates/bevy_forge_editor/src/palette.rs @@ -0,0 +1,791 @@ +//! Command palette (Ctrl+K): fuzzy command search with keyboard navigation. +//! +//! The palette only emits [`Action`]s; `app.rs` owns the actual behaviour. Commands +//! that cannot run in the current state (undo with no history, delete with no +//! selection, …) stay visible but are rendered as disabled. + +use bevy_forge_core::{ExportFormat, Project, ProjectTemplate, Session}; + +use crate::action::Action; +use crate::state::{EditorContext, Selection, ViewMode}; +use crate::theme; +use crate::widgets; + +/// Maximum number of command rows visible before the list scrolls. +const MAX_VISIBLE_ROWS: usize = 10; +/// Height of a single command row, in points. +const ROW_HEIGHT: f32 = 32.0; + +#[derive(Default)] +pub struct PaletteState { + pub open: bool, + pub query: String, + pub selected: usize, + /// Set once focus has been requested for the current opening. + pub focus_requested: bool, + /// Scroll the selected row into view on the next list pass. + pub scroll_to_selected: bool, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Category { + File, + Edit, + View, + Help, +} + +impl Category { + fn label(self) -> &'static str { + match self { + Self::File => "FILE", + Self::Edit => "EDIT", + Self::View => "VIEW", + Self::Help => "HELP", + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum CommandId { + NewEmptyScene, + New2dScene, + New3dScene, + OpenProject, + OpenScene, + Save, + SaveAs, + ExportBsn, + ExportRust, + CopyBsn, + ToggleBothPanels, + ToggleHierarchy, + ToggleInspector, + Undo, + Redo, + Duplicate, + Delete, + FitView, + ToggleGrid, + ToggleSnap, + ToggleAssets, + View2d, + View3d, + Shortcuts, + About, + Quit, +} + +#[derive(Clone, Copy, Debug)] +struct CommandSpec { + id: CommandId, + label: &'static str, + category: Category, + shortcut: Option<&'static str>, + hint: &'static str, +} + +const COMMANDS: &[CommandSpec] = &[ + CommandSpec { + id: CommandId::NewEmptyScene, + label: "New empty scene", + category: Category::File, + shortcut: None, + hint: "Start a blank scene with no entities.", + }, + CommandSpec { + id: CommandId::New2dScene, + label: "New 2D scene", + category: Category::File, + shortcut: None, + hint: "Create a 2D starter with a camera and sprites.", + }, + CommandSpec { + id: CommandId::New3dScene, + label: "New 3D scene", + category: Category::File, + shortcut: None, + hint: "Create a 3D starter with a camera, light and primitives.", + }, + CommandSpec { + id: CommandId::OpenProject, + label: "Open project…", + category: Category::File, + shortcut: Some("Ctrl+O"), + hint: "Open a Bevy project folder (Cargo.toml with a bevy dependency).", + }, + CommandSpec { + id: CommandId::OpenScene, + label: "Open scene…", + category: Category::File, + shortcut: None, + hint: "Pick one of the .forge.ron scenes in the current project.", + }, + CommandSpec { + id: CommandId::Save, + label: "Save scene", + category: Category::File, + shortcut: Some("Ctrl+S"), + hint: "Save the current scene into the project's scenes/ folder.", + }, + CommandSpec { + id: CommandId::SaveAs, + label: "Save scene as…", + category: Category::File, + shortcut: Some("Ctrl+Shift+S"), + hint: "Choose a path for the scene file.", + }, + CommandSpec { + id: CommandId::ExportBsn, + label: "Export BSN…", + category: Category::File, + shortcut: Some("Ctrl+E"), + hint: "Generate Bevy 0.20 BSN Rust code and copy or save it.", + }, + CommandSpec { + id: CommandId::ExportRust, + label: "Export Rust…", + category: Category::File, + shortcut: None, + hint: "Generate imperative Commands::spawn code.", + }, + CommandSpec { + id: CommandId::CopyBsn, + label: "Copy BSN to clipboard", + category: Category::File, + shortcut: None, + hint: "Copy the generated BSN snippet without opening the export dialog.", + }, + CommandSpec { + id: CommandId::Quit, + label: "Quit Bevy Forge", + category: Category::File, + shortcut: None, + hint: "Close the editor. Unsaved changes ask first.", + }, + CommandSpec { + id: CommandId::Undo, + label: "Undo", + category: Category::Edit, + shortcut: Some("Ctrl+Z"), + hint: "Revert the last scene edit.", + }, + CommandSpec { + id: CommandId::Redo, + label: "Redo", + category: Category::Edit, + shortcut: Some("Ctrl+Shift+Z"), + hint: "Reapply the last undone edit.", + }, + CommandSpec { + id: CommandId::Duplicate, + label: "Duplicate selection", + category: Category::Edit, + shortcut: Some("Ctrl+D"), + hint: "Deep-copy the selected entities.", + }, + CommandSpec { + id: CommandId::Delete, + label: "Delete selection", + category: Category::Edit, + shortcut: Some("Del"), + hint: "Remove the selected entities and their children.", + }, + CommandSpec { + id: CommandId::ToggleBothPanels, + label: "Toggle both side panels", + category: Category::View, + shortcut: None, + hint: "Show or hide the hierarchy and inspector together.", + }, + CommandSpec { + id: CommandId::ToggleHierarchy, + label: "Toggle hierarchy panel", + category: Category::View, + shortcut: None, + hint: "Show or hide the scene hierarchy on the left.", + }, + CommandSpec { + id: CommandId::ToggleInspector, + label: "Toggle inspector panel", + category: Category::View, + shortcut: None, + hint: "Show or hide the inspector on the right.", + }, + CommandSpec { + id: CommandId::FitView, + label: "Fit view", + category: Category::View, + shortcut: Some("F"), + hint: "Frame the scene content in the active viewport.", + }, + CommandSpec { + id: CommandId::ToggleGrid, + label: "Toggle grid", + category: Category::View, + shortcut: Some("G"), + hint: "Show or hide the viewport grid.", + }, + CommandSpec { + id: CommandId::ToggleSnap, + label: "Toggle snap to grid", + category: Category::View, + shortcut: None, + hint: "Snap transforms to the grid while dragging.", + }, + CommandSpec { + id: CommandId::ToggleAssets, + label: "Toggle asset browser", + category: Category::View, + shortcut: None, + hint: "Show or hide the asset browser panel.", + }, + CommandSpec { + id: CommandId::View2d, + label: "Switch to 2D view", + category: Category::View, + shortcut: Some("2"), + hint: "Use the orthographic 2D viewport.", + }, + CommandSpec { + id: CommandId::View3d, + label: "Switch to 3D view", + category: Category::View, + shortcut: Some("3"), + hint: "Use the 3D viewport with orbit controls.", + }, + CommandSpec { + id: CommandId::Shortcuts, + label: "Keyboard shortcuts", + category: Category::Help, + shortcut: Some("F1"), + hint: "Show every keyboard and mouse shortcut.", + }, + CommandSpec { + id: CommandId::About, + label: "About Bevy Forge", + category: Category::Help, + shortcut: None, + hint: "Version and project information.", + }, +]; + +/// Render the palette when open and return the commands chosen this frame. +pub fn show( + ctx: &egui::Context, + cx: &mut EditorContext<'_>, + state: &mut PaletteState, +) -> Vec { + if !state.open { + // Never hold keyboard focus while closed. + state.focus_requested = false; + state.scroll_to_selected = false; + return Vec::new(); + } + + let mut actions = Vec::new(); + let matches = filter_commands(&state.query); + if matches.is_empty() { + state.selected = 0; + } else if state.selected >= matches.len() { + state.selected = matches.len() - 1; + } + + let id = egui::Id::new("forge_command_palette"); + let area = + egui::Modal::default_area(id).anchor(egui::Align2::CENTER_TOP, egui::vec2(0.0, 96.0)); + let mut executed = false; + + let modal = egui::Modal::new(id) + .area(area) + .frame(dialog_frame(ctx)) + .backdrop_color(egui::Color32::from_black_alpha(150)) + .show(ctx, |ui| { + ui.set_width(520.0); + ui.horizontal(|ui| { + ui.label( + egui::RichText::new("Command palette") + .size(14.0) + .strong() + .color(theme::TEXT), + ); + ui.label( + egui::RichText::new("Bevy Forge") + .size(10.5) + .color(theme::FAINT), + ); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + widgets::kbd(ui, "Esc"); + ui.label(egui::RichText::new("close").size(10.5).color(theme::FAINT)); + }); + }); + ui.add_space(6.0); + + let search = ui.add( + egui::TextEdit::singleline(&mut state.query) + .id_salt("forge_palette_query") + .hint_text("Search commands…") + .desired_width(f32::INFINITY) + .font(egui::TextStyle::Body) + .margin(egui::Margin::symmetric(8, 6)), + ); + if !state.focus_requested { + search.request_focus(); + state.focus_requested = true; + } + // Arrow keys and Enter are only handled while the palette owns the + // keyboard, so the palette can never steal keys from another field. + let owns_keyboard = + search.has_focus() || search.lost_focus() || !ctx.wants_keyboard_input(); + if owns_keyboard { + let down = ctx.input_mut(|input| { + input.consume_key(egui::Modifiers::NONE, egui::Key::ArrowDown) + }); + let up = ctx.input_mut(|input| { + input.consume_key(egui::Modifiers::NONE, egui::Key::ArrowUp) + }); + if down || up { + state.selected = step_selection(state.selected, matches.len(), down); + state.scroll_to_selected = true; + } + if ctx.input(|input| input.key_pressed(egui::Key::Enter)) { + if let Some(&command_index) = matches.get(state.selected) { + let command = &COMMANDS[command_index]; + if command_enabled(command.id, cx.session, cx.project, &cx.ui.selection) { + actions.extend(command_actions(command.id)); + executed = true; + } + } + } + } + + ui.add_space(4.0); + ui.separator(); + + if matches.is_empty() { + widgets::empty_state( + ui, + "No commands match", + "Try “save”, “export”, “2D”, “undo” or “about”.", + ); + } else { + egui::ScrollArea::vertical() + .max_height(ROW_HEIGHT * MAX_VISIBLE_ROWS as f32) + .auto_shrink([false, false]) + .show(ui, |ui| { + // Rows carry their own height; avoid gaps so exactly + // MAX_VISIBLE_ROWS rows fit in the scroll viewport. + ui.spacing_mut().item_spacing.y = 0.0; + for (visible_index, &command_index) in matches.iter().enumerate() { + let command = &COMMANDS[command_index]; + let enabled = command_enabled( + command.id, + cx.session, + cx.project, + &cx.ui.selection, + ); + let selected = visible_index == state.selected; + let response = command_row(ui, command, selected, enabled); + if selected && state.scroll_to_selected { + response.scroll_to_me(Some(egui::Align::Center)); + state.scroll_to_selected = false; + } + if response.clicked() && enabled { + state.selected = visible_index; + actions.extend(command_actions(command.id)); + executed = true; + } + } + }); + } + + ui.add_space(2.0); + ui.separator(); + ui.horizontal(|ui| { + let hint = matches + .get(state.selected) + .map(|&index| COMMANDS[index].hint) + .unwrap_or("Type to filter commands."); + ui.label(egui::RichText::new(hint).size(10.5).color(theme::FAINT)); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + widgets::kbd(ui, "Enter"); + ui.label(egui::RichText::new("run").size(10.5).color(theme::FAINT)); + widgets::kbd(ui, "Up/Down"); + ui.label( + egui::RichText::new("navigate") + .size(10.5) + .color(theme::FAINT), + ); + }); + }); + }); + + if executed || modal.should_close() { + state.open = false; + state.focus_requested = false; + state.scroll_to_selected = false; + } + actions +} + +fn dialog_frame(ctx: &egui::Context) -> egui::Frame { + egui::Frame::popup(&ctx.style()) + .fill(theme::PANEL) + .stroke(egui::Stroke::new(1.0_f32, theme::BORDER)) + .corner_radius(egui::CornerRadius::same(10)) + .inner_margin(egui::Margin::symmetric(16, 14)) +} + +/// Move the selection by one row, wrapping around the ends. +fn step_selection(selected: usize, len: usize, down: bool) -> usize { + if len == 0 { + return 0; + } + if down { + if selected + 1 >= len { + 0 + } else { + selected + 1 + } + } else if selected == 0 { + len - 1 + } else { + selected - 1 + } +} + +/// Whether a command can run in the current editor state. +fn command_enabled( + id: CommandId, + session: &Session, + project: Option<&Project>, + selection: &Selection, +) -> bool { + match id { + CommandId::Undo => session.can_undo(), + CommandId::Redo => session.can_redo(), + CommandId::Duplicate | CommandId::Delete => !selection.is_empty(), + CommandId::OpenScene => project.is_some(), + _ => true, + } +} + +/// Actions produced by executing a command. Most commands map to one action; +/// "Toggle both side panels" is the exception. +fn command_actions(id: CommandId) -> Vec { + match id { + CommandId::NewEmptyScene => vec![Action::NewScene(ProjectTemplate::Empty)], + CommandId::New2dScene => vec![Action::NewScene(ProjectTemplate::TwoD)], + CommandId::New3dScene => vec![Action::NewScene(ProjectTemplate::ThreeD)], + CommandId::OpenProject => vec![Action::OpenProjectDialog], + CommandId::OpenScene => vec![Action::OpenSceneDialog], + CommandId::Save => vec![Action::Save], + CommandId::SaveAs => vec![Action::OpenSaveAsDialog], + CommandId::ExportBsn => vec![Action::OpenExportDialog(ExportFormat::Bsn)], + CommandId::ExportRust => vec![Action::OpenExportDialog(ExportFormat::Rust)], + CommandId::CopyBsn => vec![Action::CopyExport(ExportFormat::Bsn)], + CommandId::ToggleBothPanels => { + vec![Action::ToggleLeftPanel, Action::ToggleRightPanel] + } + CommandId::ToggleHierarchy => vec![Action::ToggleLeftPanel], + CommandId::ToggleInspector => vec![Action::ToggleRightPanel], + CommandId::Undo => vec![Action::Undo], + CommandId::Redo => vec![Action::Redo], + CommandId::Duplicate => vec![Action::DuplicateSelection], + CommandId::Delete => vec![Action::DeleteSelection], + CommandId::FitView => vec![Action::FitView], + CommandId::ToggleGrid => vec![Action::ToggleGrid], + CommandId::ToggleSnap => vec![Action::ToggleSnap], + CommandId::ToggleAssets => vec![Action::ToggleAssets], + CommandId::View2d => vec![Action::SetViewMode(ViewMode::TwoD)], + CommandId::View3d => vec![Action::SetViewMode(ViewMode::ThreeD)], + CommandId::Shortcuts => vec![Action::ShowShortcuts], + CommandId::About => vec![Action::ShowAbout], + CommandId::Quit => vec![Action::RequestQuit], + } +} + +/// Indices of commands matching `query`, best match first. An empty query keeps +/// the curated command order. +fn filter_commands(query: &str) -> Vec { + let query = query.trim(); + if query.is_empty() { + return (0..COMMANDS.len()).collect(); + } + let mut scored: Vec<(usize, i32)> = COMMANDS + .iter() + .enumerate() + .filter_map(|(index, command)| { + command_score(command.label, query).map(|score| (index, score)) + }) + .collect(); + scored.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0))); + scored.into_iter().map(|(index, _)| index).collect() +} + +/// Score a label against a query. Substring matches always beat subsequence +/// matches; prefix and word-boundary matches get additional weight. +fn command_score(label: &str, query: &str) -> Option { + let query = query.trim(); + if query.is_empty() { + return Some(0); + } + let haystack = label.to_lowercase(); + let needle = query.to_lowercase(); + + if let Some(position) = haystack.find(&needle) { + let before = haystack[..position].chars().next_back(); + let at_start = position == 0; + let at_boundary = at_start || before.is_some_and(|c| !c.is_alphanumeric()); + let mut score = 600 - position.min(240) as i32; + if at_boundary { + score += 250; + } + if at_start { + score += 150; + } + return Some(score); + } + + // Subsequence ("fuzzy") match. + let mut haystack_chars = haystack.char_indices(); + let mut previous_end: Option = None; + let mut score = 200_i32; + for needle_char in needle.chars() { + let mut found = None; + for (index, haystack_char) in haystack_chars.by_ref() { + if haystack_char == needle_char { + found = Some(index); + break; + } + } + let index = found?; + if let Some(previous) = previous_end { + let gap = index.saturating_sub(previous + 1); + score -= (gap.min(20) as i32) * 3; + score += 2; // Reward contiguous runs. + } + previous_end = Some(index); + } + Some(score.max(1)) +} + +fn command_row( + ui: &mut egui::Ui, + command: &CommandSpec, + selected: bool, + enabled: bool, +) -> egui::Response { + let width = ui.available_width(); + let (rect, response) = ui.allocate_exact_size( + egui::vec2(width, ROW_HEIGHT), + if enabled { + egui::Sense::click() + } else { + egui::Sense::hover() + }, + ); + let hovered = enabled && response.hovered(); + let background = if selected && enabled { + theme::ACCENT_DIM + } else if hovered { + theme::ELEVATED + } else { + egui::Color32::TRANSPARENT + }; + let painter = ui.painter(); + let row_rect = rect.shrink2(egui::vec2(0.0, 1.0)); + if background != egui::Color32::TRANSPARENT { + painter.rect_filled(row_rect, 5.0, background); + } + if selected && enabled { + painter.rect_stroke( + row_rect, + 5.0, + egui::Stroke::new(1.0_f32, theme::ACCENT), + egui::StrokeKind::Inside, + ); + } + + let text_color = if enabled { theme::TEXT } else { theme::FAINT }; + painter.text( + egui::pos2(rect.left() + 10.0, rect.center().y), + egui::Align2::LEFT_CENTER, + command.category.label(), + egui::FontId::proportional(9.0), + theme::FAINT, + ); + painter.text( + egui::pos2(rect.left() + 66.0, rect.center().y), + egui::Align2::LEFT_CENTER, + command.label, + egui::FontId::proportional(13.5), + text_color, + ); + if let Some(shortcut) = command.shortcut { + draw_keycap( + painter, + rect.right_center() - egui::vec2(10.0, 0.0), + shortcut, + enabled, + ); + } + + response.widget_info(|| { + egui::WidgetInfo::labeled(egui::WidgetType::Button, enabled, command.label) + }); + response.on_hover_text(command.hint) +} + +fn draw_keycap(painter: &egui::Painter, right_center: egui::Pos2, text: &str, enabled: bool) { + let color = if enabled { theme::MUTED } else { theme::FAINT }; + let galley = painter.layout_no_wrap(text.to_owned(), egui::FontId::monospace(10.0), color); + let padding = egui::vec2(6.0, 2.0); + let size = galley.size() + padding * 2.0; + let rect = egui::Rect::from_min_size( + egui::pos2(right_center.x - size.x, right_center.y - size.y * 0.5), + size, + ); + painter.rect_filled(rect, 3.0, theme::BG); + painter.rect_stroke( + rect, + 3.0, + egui::Stroke::new(1.0_f32, theme::BORDER), + egui::StrokeKind::Inside, + ); + painter.galley(rect.min + padding, galley, color); +} + +#[cfg(test)] +mod tests { + use super::*; + use bevy_forge_core::SceneDoc; + + fn label_of(index: usize) -> &'static str { + COMMANDS[index].label + } + + #[test] + fn empty_query_keeps_all_commands_in_order() { + let all = filter_commands(""); + assert_eq!(all.len(), COMMANDS.len()); + assert_eq!(all.first().copied(), Some(0)); + assert!(all.windows(2).all(|pair| pair[0] < pair[1])); + assert_eq!(filter_commands(" ").len(), COMMANDS.len()); + } + + #[test] + fn prefix_match_ranks_first() { + let matches = filter_commands("save"); + assert!(!matches.is_empty()); + assert_eq!(label_of(matches[0]), "Save scene"); + // "Save scene as…" is also a substring match and stays in the list. + assert!(matches + .iter() + .any(|&index| label_of(index) == "Save scene as…")); + } + + #[test] + fn substring_beats_fuzzy_match() { + // "undo" is a prefix of "Undo"; "U…n…d…o" also fuzzy-matches several labels. + let matches = filter_commands("undo"); + assert_eq!(label_of(matches[0]), "Undo"); + let scene_search = filter_commands("ne3"); + // "New 3D scene" must fuzzy-match while "New empty scene" must not. + assert!(scene_search + .iter() + .any(|&index| label_of(index) == "New 3D scene")); + assert!(!scene_search + .iter() + .any(|&index| label_of(index) == "New empty scene")); + } + + #[test] + fn fuzzy_subsequence_matches() { + let matches = filter_commands("oprj"); + assert!( + matches + .iter() + .any(|&index| label_of(index) == "Open project…"), + "expected a fuzzy match for Open project…, got {:?}", + matches.iter().map(|&i| label_of(i)).collect::>() + ); + } + + #[test] + fn unmatched_query_returns_nothing() { + assert!(filter_commands("zzzzzz").is_empty()); + assert!(command_score("Save scene", "zzzzzz").is_none()); + } + + #[test] + fn boundary_matches_score_above_mid_word_matches() { + let boundary = command_score("Open scene", "scene").unwrap(); + let mid_word = command_score("Obscene thing", "scene").unwrap(); + assert!(boundary > mid_word); + } + + #[test] + fn selection_step_wraps() { + assert_eq!(step_selection(0, 3, true), 1); + assert_eq!(step_selection(2, 3, true), 0); + assert_eq!(step_selection(0, 3, false), 2); + assert_eq!(step_selection(1, 3, false), 0); + assert_eq!(step_selection(0, 0, true), 0); + } + + #[test] + fn every_command_has_an_action_and_unique_id() { + for (index, command) in COMMANDS.iter().enumerate() { + assert!(!command.label.is_empty()); + assert!(!command_actions(command.id).is_empty()); + assert!( + COMMANDS[..index].iter().all(|other| other.id != command.id), + "duplicate command id {:?}", + command.id + ); + } + assert!(COMMANDS.len() >= 20); + } + + #[test] + fn availability_tracks_history_and_selection() { + let mut session = Session::new(SceneDoc::new("Test")); + let mut selection = Selection::default(); + assert!(!command_enabled( + CommandId::Undo, + &session, + None, + &selection + )); + assert!(!command_enabled( + CommandId::Delete, + &session, + None, + &selection + )); + session.edit("Add entity", |doc| { + doc.add_entity("Cube", None); + }); + selection.select_only(session.doc().roots[0]); + assert!(command_enabled(CommandId::Undo, &session, None, &selection)); + assert!(command_enabled( + CommandId::Delete, + &session, + None, + &selection + )); + assert!(command_enabled( + CommandId::New2dScene, + &session, + None, + &selection + )); + assert!(!command_enabled( + CommandId::OpenScene, + &session, + None, + &selection + )); + } +} diff --git a/crates/bevy_forge_editor/src/shortcuts.rs b/crates/bevy_forge_editor/src/shortcuts.rs new file mode 100644 index 0000000..b9073da --- /dev/null +++ b/crates/bevy_forge_editor/src/shortcuts.rs @@ -0,0 +1,387 @@ +//! Global keyboard shortcuts. +//! +//! [`handle`] runs once per frame before the panels are drawn, turns key presses +//! into deferred [`Action`]s and consumes the matching events so nothing else +//! reacts to the same press. Two rules keep this predictable: +//! +//! * A focused text field (inline rename, search boxes, the command palette, +//! dialogs) owns the keyboard: no shortcut fires while +//! [`egui::Context::wants_keyboard_input`] is true. This also leaves `Escape` +//! to the widget that has focus. +//! * Modifiers must match exactly. egui's `consume_key` deliberately ignores +//! extra Shift/Alt modifiers, so `Ctrl+Shift+S` would otherwise count as +//! `Ctrl+S`. The mapping below inspects key events and uses +//! [`egui::Modifiers::matches_exact`] instead. +//! +//! Clearing the selection with `Escape` is a focus-local action owned by the +//! viewport that has the canvas focused: the frozen [`Action`] enum has no +//! clear-selection variant, and a global handler must not consume `Escape` from +//! the widgets that do own it. + +use bevy_forge_core::{ExportFormat, Session}; + +use crate::action::Action; +use crate::state::ViewMode; + +/// Human-readable shortcut reference shared with Help → Keyboard shortcuts. +/// +/// `Ctrl` is `Cmd` on macOS: shortcuts are matched with +/// [`egui::Modifiers::COMMAND`], which maps to the platform convention. +pub const SHORTCUT_REFERENCE: &[(&str, &str)] = &[ + ("Ctrl+S", "Save the current scene"), + ("Ctrl+Shift+S", "Save the scene as…"), + ("Ctrl+O", "Open a project…"), + ("Ctrl+K", "Command palette"), + ("F1", "Keyboard shortcut reference"), + ("Ctrl+Z", "Undo"), + ("Ctrl+Shift+Z / Ctrl+Y", "Redo"), + ("Ctrl+D", "Duplicate the selection"), + ("Delete", "Delete the selection"), + ("Ctrl+E", "Export BSN scene"), + ("Ctrl+Shift+E", "Export imperative Rust"), + ("F", "Fit the active viewport"), + ("G", "Toggle the grid"), + ("2 / 3", "Switch to the 2D / 3D viewport"), + ("Ctrl+B", "Toggle the asset browser"), + ("Ctrl+Shift+L", "Toggle the hierarchy panel"), + ("Esc", "Clear the selection when the viewport has focus"), +]; + +/// Map one key press to the action it should emit. +/// +/// Pure: no input state, no session and no UI. `modifiers` must match a binding +/// exactly, so `Ctrl+Alt+S` or `Shift+2` do not trigger the plain bindings. +/// Whether undo/redo are currently possible is checked by [`handle`], not here. +pub fn action_for(key: egui::Key, modifiers: egui::Modifiers) -> Option { + use egui::{Key, Modifiers}; + + // Keep the more specific binding first: `matches_exact` rejects extra + // modifiers, so `Ctrl+Shift+Z` can never fall through to plain `Ctrl+Z`. + match key { + Key::S if modifiers.matches_exact(Modifiers::COMMAND | Modifiers::SHIFT) => { + Some(Action::OpenSaveAsDialog) + } + Key::S if modifiers.matches_exact(Modifiers::COMMAND) => Some(Action::Save), + Key::O if modifiers.matches_exact(Modifiers::COMMAND) => Some(Action::OpenProjectDialog), + Key::K if modifiers.matches_exact(Modifiers::COMMAND) => Some(Action::OpenCommandPalette), + Key::Z if modifiers.matches_exact(Modifiers::COMMAND | Modifiers::SHIFT) => { + Some(Action::Redo) + } + Key::Z if modifiers.matches_exact(Modifiers::COMMAND) => Some(Action::Undo), + Key::Y if modifiers.matches_exact(Modifiers::COMMAND) => Some(Action::Redo), + Key::D if modifiers.matches_exact(Modifiers::COMMAND) => Some(Action::DuplicateSelection), + Key::E if modifiers.matches_exact(Modifiers::COMMAND | Modifiers::SHIFT) => { + Some(Action::OpenExportDialog(ExportFormat::Rust)) + } + Key::E if modifiers.matches_exact(Modifiers::COMMAND) => { + Some(Action::OpenExportDialog(ExportFormat::Bsn)) + } + Key::B if modifiers.matches_exact(Modifiers::COMMAND) => Some(Action::ToggleAssets), + Key::L if modifiers.matches_exact(Modifiers::COMMAND | Modifiers::SHIFT) => { + Some(Action::ToggleLeftPanel) + } + + // Plain keys: no modifier at all (Shift+2 must not switch viewports). + Key::F1 if modifiers.matches_exact(Modifiers::NONE) => Some(Action::ShowShortcuts), + Key::F if modifiers.matches_exact(Modifiers::NONE) => Some(Action::FitView), + Key::G if modifiers.matches_exact(Modifiers::NONE) => Some(Action::ToggleGrid), + Key::Delete if modifiers.matches_exact(Modifiers::NONE) => Some(Action::DeleteSelection), + Key::Num2 if modifiers.matches_exact(Modifiers::NONE) => { + Some(Action::SetViewMode(ViewMode::TwoD)) + } + Key::Num3 if modifiers.matches_exact(Modifiers::NONE) => { + Some(Action::SetViewMode(ViewMode::ThreeD)) + } + _ => None, + } +} + +/// Translate keyboard input into at most one action per binding per frame. +pub fn handle(ctx: &egui::Context, session: &Session) -> Vec { + // Never steal keys from a focused text field (rename, search, palette, + // dialogs): the widget gets its own Ctrl+Z/Delete/Escape handling. + if ctx.wants_keyboard_input() { + return Vec::new(); + } + + let mut actions = Vec::new(); + ctx.input_mut(|input| take_shortcut_events(input, session, &mut actions)); + actions +} + +/// Remove matching key events from `input` and record their actions. +/// +/// Events are consumed even when the action is currently disabled (no undo +/// history, no redo history) so a disabled shortcut never leaks into a panel. +fn take_shortcut_events( + input: &mut egui::InputState, + session: &Session, + actions: &mut Vec, +) { + input.events.retain(|event| { + let egui::Event::Key { + key, + pressed: true, + modifiers, + .. + } = event + else { + return true; + }; + let Some(action) = action_for(*key, *modifiers) else { + return true; + }; + let enabled = match action { + Action::Undo => session.can_undo(), + Action::Redo => session.can_redo(), + _ => true, + }; + // Several repeats in one frame still produce a single action. + if enabled && !actions.contains(&action) { + actions.push(action); + } + false + }); +} + +#[cfg(test)] +mod tests { + use super::*; + use bevy_forge_core::SceneDoc; + use egui::{Key, Modifiers}; + + fn key_event(key: Key, modifiers: Modifiers) -> egui::Event { + egui::Event::Key { + key, + physical_key: None, + pressed: true, + repeat: false, + modifiers, + } + } + + #[test] + fn command_shortcuts_map_to_actions() { + let cmd = Modifiers::COMMAND; + assert_eq!(action_for(Key::S, cmd), Some(Action::Save)); + assert_eq!(action_for(Key::O, cmd), Some(Action::OpenProjectDialog)); + assert_eq!(action_for(Key::K, cmd), Some(Action::OpenCommandPalette)); + assert_eq!(action_for(Key::D, cmd), Some(Action::DuplicateSelection)); + assert_eq!(action_for(Key::B, cmd), Some(Action::ToggleAssets)); + assert_eq!( + action_for(Key::E, cmd), + Some(Action::OpenExportDialog(ExportFormat::Bsn)) + ); + assert_eq!( + action_for(Key::E, cmd | Modifiers::SHIFT), + Some(Action::OpenExportDialog(ExportFormat::Rust)) + ); + assert_eq!( + action_for(Key::L, cmd | Modifiers::SHIFT), + Some(Action::ToggleLeftPanel) + ); + } + + #[test] + fn undo_and_redo_require_exact_modifiers() { + assert_eq!(action_for(Key::Z, Modifiers::COMMAND), Some(Action::Undo)); + assert_eq!( + action_for(Key::Z, Modifiers::COMMAND | Modifiers::SHIFT), + Some(Action::Redo), + "Ctrl+Shift+Z is redo, never undo" + ); + assert_eq!(action_for(Key::Y, Modifiers::COMMAND), Some(Action::Redo)); + // Extra modifiers must not match a binding. + assert_eq!( + action_for(Key::Z, Modifiers::COMMAND | Modifiers::ALT), + None + ); + assert_eq!( + action_for(Key::S, Modifiers::COMMAND | Modifiers::ALT), + None + ); + } + + #[test] + fn plain_shortcuts_map_to_view_and_selection_actions() { + assert_eq!( + action_for(Key::F1, Modifiers::NONE), + Some(Action::ShowShortcuts) + ); + assert_eq!(action_for(Key::F, Modifiers::NONE), Some(Action::FitView)); + assert_eq!( + action_for(Key::G, Modifiers::NONE), + Some(Action::ToggleGrid) + ); + assert_eq!( + action_for(Key::Delete, Modifiers::NONE), + Some(Action::DeleteSelection) + ); + assert_eq!( + action_for(Key::Num2, Modifiers::NONE), + Some(Action::SetViewMode(ViewMode::TwoD)) + ); + assert_eq!( + action_for(Key::Num3, Modifiers::NONE), + Some(Action::SetViewMode(ViewMode::ThreeD)) + ); + // Shift+2/Shift+3 and Ctrl+F are not plain bindings. + assert_eq!(action_for(Key::Num2, Modifiers::SHIFT), None); + assert_eq!(action_for(Key::Num3, Modifiers::SHIFT), None); + assert_eq!(action_for(Key::F, Modifiers::COMMAND), None); + } + + #[test] + fn disabled_undo_is_consumed_without_emitting_an_action() { + let mut input = egui::InputState::default(); + input.events.push(key_event(Key::Z, Modifiers::COMMAND)); + let session = Session::new(SceneDoc::new("Empty")); + let mut actions = Vec::new(); + take_shortcut_events(&mut input, &session, &mut actions); + + assert!(actions.is_empty(), "nothing to undo yet"); + assert!( + input.events.is_empty(), + "the key press must still be consumed" + ); + } + + #[test] + fn undo_action_is_emitted_once_when_history_exists() { + let mut session = Session::new(SceneDoc::new("Empty")); + session.edit("Add entity", |doc| { + doc.add_entity("Cube", None); + }); + assert!(session.can_undo()); + + let mut input = egui::InputState::default(); + input.events.push(key_event(Key::Z, Modifiers::COMMAND)); + input.events.push(key_event(Key::Z, Modifiers::COMMAND)); + let mut actions = Vec::new(); + take_shortcut_events(&mut input, &session, &mut actions); + + assert_eq!(actions, vec![Action::Undo], "repeat events are collapsed"); + assert!(input.events.is_empty()); + } + + #[test] + fn redo_action_requires_redo_history() { + let mut session = Session::new(SceneDoc::new("Empty")); + session.edit("Add entity", |doc| { + doc.add_entity("Cube", None); + }); + // Without an undo the redo stack is empty. + let mut input = egui::InputState::default(); + input.events.push(key_event(Key::Y, Modifiers::COMMAND)); + let mut actions = Vec::new(); + take_shortcut_events(&mut input, &session, &mut actions); + assert!(actions.is_empty()); + + session.undo(); + assert!(session.can_redo()); + let mut input = egui::InputState::default(); + input.events.push(key_event(Key::Y, Modifiers::COMMAND)); + let mut actions = Vec::new(); + take_shortcut_events(&mut input, &session, &mut actions); + assert_eq!(actions, vec![Action::Redo]); + } + + #[test] + fn consumed_events_do_not_leak_unknown_keys() { + let input_session = Session::new(SceneDoc::new("Empty")); + let mut input = egui::InputState::default(); + input.events.push(key_event(Key::A, Modifiers::COMMAND)); + let mut actions = Vec::new(); + take_shortcut_events(&mut input, &input_session, &mut actions); + assert!(actions.is_empty()); + assert_eq!(input.events.len(), 1, "unbound keys stay available"); + } + + #[test] + fn text_field_focus_suppresses_every_shortcut() { + // Reproduce an inline rename: a text field owns the keyboard and the user + // presses Ctrl+S. No action may fire, and the key must not be consumed + // from the widget. + let ctx = egui::Context::default(); + let mut rename_buffer = String::from("Cube"); + let session = Session::new(SceneDoc::new("Typing")); + let mut actions = vec![Action::DeleteSelection]; // poison: must be reset + + let raw_input = egui::RawInput { + screen_rect: Some(egui::Rect::from_min_size( + egui::Pos2::ZERO, + egui::vec2(960.0, 640.0), + )), + events: vec![key_event(Key::S, Modifiers::COMMAND)], + ..Default::default() + }; + let _ = ctx.run(raw_input, |ctx| { + egui::CentralPanel::default().show(ctx, |ui| { + let response = ui.add(egui::TextEdit::singleline(&mut rename_buffer)); + response.request_focus(); + assert!(ctx.wants_keyboard_input(), "the rename field has focus"); + actions = handle(ctx, &session); + }); + }); + + assert!( + actions.is_empty(), + "Ctrl+S must not fire while renaming, got {actions:?}" + ); + } + + #[test] + fn app_dispatches_one_shortcut_action_per_press() { + // End-to-end: raw key events -> shortcuts::handle -> Action -> ForgeApp. + // One Ctrl+D must duplicate exactly once (no double-dispatch), Ctrl+Z must + // undo it, and Delete must delete the selected entity. + use crate::app::ForgeApp; + + let ctx = egui::Context::default(); + let mut doc = SceneDoc::new("Shortcut pipeline"); + let id = doc.add_entity("Cube", None); + let mut app = ForgeApp::new_for_test(&ctx, doc); + app.ui.selection.select_only(id); + + let frame = |events: Vec| egui::RawInput { + screen_rect: Some(egui::Rect::from_min_size( + egui::Pos2::ZERO, + egui::vec2(1440.0, 900.0), + )), + events, + ..Default::default() + }; + + // Warm-up frame: let the panels lay out and load fonts. + let _ = ctx.run(frame(Vec::new()), |ctx| app.ui(ctx)); + assert_eq!(app.session.doc().entities.len(), 1); + + let _ = ctx.run(frame(vec![key_event(Key::D, Modifiers::COMMAND)]), |ctx| { + app.ui(ctx) + }); + assert_eq!( + app.session.doc().entities.len(), + 2, + "Ctrl+D duplicates exactly once" + ); + + let _ = ctx.run(frame(vec![key_event(Key::Z, Modifiers::COMMAND)]), |ctx| { + app.ui(ctx) + }); + assert_eq!( + app.session.doc().entities.len(), + 1, + "Ctrl+Z undoes the duplicate" + ); + + app.ui.selection.select_only(id); + let _ = ctx.run( + frame(vec![key_event(Key::Delete, Modifiers::NONE)]), + |ctx| app.ui(ctx), + ); + assert!( + app.session.doc().entity(id).is_none(), + "Delete removes the selected entity" + ); + } +} diff --git a/crates/bevy_forge_editor/src/state.rs b/crates/bevy_forge_editor/src/state.rs new file mode 100644 index 0000000..e96f320 --- /dev/null +++ b/crates/bevy_forge_editor/src/state.rs @@ -0,0 +1,279 @@ +//! UI-wide state shared by panels, and the borrowed context passed to them. + +use bevy_forge_core::{ + AssetIndex, AssetKind, Color, ExportFormat, Project, SceneDoc, Session, Vec2, +}; +use std::collections::BTreeSet; +use std::path::PathBuf; + +use crate::texture_cache::TextureCache; +use crate::toast::Toasts; + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum Screen { + #[default] + Welcome, + Editor, +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum ViewMode { + #[default] + TwoD, + ThreeD, +} + +impl ViewMode { + pub const fn label(self) -> &'static str { + match self { + Self::TwoD => "2D", + Self::ThreeD => "3D", + } + } + + pub fn toggled(self) -> Self { + match self { + Self::TwoD => Self::ThreeD, + Self::ThreeD => Self::TwoD, + } + } +} + +/// Multi-selection with a primary entity for the inspector. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct Selection { + ids: BTreeSet, + primary: Option, +} + +impl Selection { + pub fn is_empty(&self) -> bool { + self.ids.is_empty() + } + + pub fn len(&self) -> usize { + self.ids.len() + } + + pub fn contains(&self, id: u64) -> bool { + self.ids.contains(&id) + } + + pub fn ids(&self) -> impl Iterator + '_ { + self.ids.iter().copied() + } + + pub fn as_set(&self) -> &BTreeSet { + &self.ids + } + + pub fn primary(&self) -> Option { + self.primary + } + + pub fn primary_or_single(&self) -> Option { + self.primary.or_else(|| { + if self.ids.len() == 1 { + self.ids.iter().next().copied() + } else { + None + } + }) + } + + pub fn select_only(&mut self, id: u64) { + self.ids.clear(); + self.ids.insert(id); + self.primary = Some(id); + } + + pub fn select_many(&mut self, ids: impl IntoIterator, primary: Option) { + self.ids = ids.into_iter().collect(); + self.primary = primary.or_else(|| self.ids.iter().next().copied()); + } + + pub fn add(&mut self, id: u64) { + self.ids.insert(id); + self.primary = Some(id); + } + + pub fn remove(&mut self, id: u64) { + self.ids.remove(&id); + if self.primary == Some(id) { + self.primary = self.ids.iter().next().copied(); + } + } + + pub fn toggle(&mut self, id: u64) { + if self.ids.contains(&id) { + self.remove(id); + } else { + self.add(id); + } + } + + pub fn retain_existing(&mut self, doc: &SceneDoc) { + self.ids.retain(|id| doc.entity(*id).is_some()); + if self.primary.is_some_and(|id| !self.ids.contains(&id)) { + self.primary = self.ids.iter().next().copied(); + } + } + + pub fn clear(&mut self) { + self.ids.clear(); + self.primary = None; + } +} + +#[derive(Clone, Debug)] +pub struct UiState { + pub selection: Selection, + pub view_mode: ViewMode, + pub show_assets: bool, + pub show_grid: bool, + pub snap_to_grid: bool, + pub grid_size: f32, + pub hierarchy_search: String, + pub inspector_search: String, + pub asset_search: String, + pub asset_filter: Option, + pub cursor_world: Option, + pub pointer_over_viewport: bool, + pub viewport_rect: egui::Rect, + pub status: String, + pub error_banner: Option, + pub show_left_panel: bool, + pub show_right_panel: bool, + /// Consumed by the active viewport to frame scene content. + pub fit_view_requested: bool, +} + +impl Default for UiState { + fn default() -> Self { + Self { + selection: Selection::default(), + view_mode: ViewMode::TwoD, + show_assets: true, + show_grid: true, + snap_to_grid: false, + grid_size: 1.0, + hierarchy_search: String::new(), + inspector_search: String::new(), + asset_search: String::new(), + asset_filter: None, + cursor_world: None, + pointer_over_viewport: false, + viewport_rect: egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(0.0, 0.0)), + status: "Ready".into(), + error_banner: None, + show_left_panel: true, + show_right_panel: true, + fit_view_requested: false, + } + } +} + +/// Drag-and-drop payload for dragging an asset from the browser into a viewport. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct AssetDragPayload { + pub path: String, +} + +/// State of the export dialog and the last generated snippet. +#[derive(Clone, Debug)] +pub struct ExportState { + pub dialog_open: bool, + pub format: ExportFormat, + pub function_name: String, + pub path: String, + pub code: Option, + pub error: Option, + pub copied: bool, +} + +impl Default for ExportState { + fn default() -> Self { + Self { + dialog_open: false, + format: ExportFormat::Bsn, + function_name: "scene".into(), + path: String::new(), + code: None, + error: None, + copied: false, + } + } +} + +impl ExportState { + pub fn regenerate(&mut self, doc: &SceneDoc) { + let options = bevy_forge_core::CodegenOptions { + function_name: self.function_name.clone(), + include_header: true, + }; + match self.format.generate(doc, &options) { + Ok(code) => { + self.code = Some(code); + self.error = None; + } + Err(error) => { + self.code = None; + self.error = Some(error.to_string()); + } + } + self.copied = false; + } +} + +/// Data every panel needs. Panels never touch `ForgeApp` directly, which keeps +/// them independently testable and avoids borrow-checker acrobatics. +pub struct EditorContext<'a> { + pub session: &'a mut Session, + pub project: Option<&'a Project>, + pub assets: &'a AssetIndex, + pub textures: &'a mut TextureCache, + pub ui: &'a mut UiState, + pub toasts: &'a mut Toasts, + pub export: &'a mut ExportState, +} + +impl EditorContext<'_> { + pub fn notify_info(&mut self, message: impl Into) { + self.toasts.info(message); + } + + pub fn notify_success(&mut self, message: impl Into) { + self.toasts.success(message); + } + + pub fn notify_error(&mut self, message: impl Into) { + self.toasts.error(message); + } + + /// Asset paths of a given kind, relative to the Bevy `assets/` directory. + pub fn assets_of_kind(&self, kind: AssetKind) -> Vec { + self.assets + .by_kind(kind) + .into_iter() + .map(|asset| asset.path.clone()) + .collect() + } + + pub fn image_assets(&self) -> Vec { + self.assets_of_kind(AssetKind::Image) + } + + pub fn mesh_assets(&self) -> Vec { + self.assets_of_kind(AssetKind::Mesh) + } + + /// Choose a sensible default component palette color for a new entity. + pub fn default_color(&self) -> Color { + Color::srgba(0.55, 0.72, 1.0, 1.0) + } + + /// Path for saving/exporting, if a project is open. + pub fn scenes_dir(&self) -> Option { + self.project.map(|project| project.scenes_dir()) + } +} diff --git a/crates/bevy_forge_editor/src/status_bar.rs b/crates/bevy_forge_editor/src/status_bar.rs new file mode 100644 index 0000000..31c016b --- /dev/null +++ b/crates/bevy_forge_editor/src/status_bar.rs @@ -0,0 +1,483 @@ +//! Bottom status bar: scene counts, selection, validation, view/save state and +//! the cursor position. +//! +//! Everything is 11 px muted text on a 25 px strip; long paths are elided in the +//! middle and shown in full in a tooltip. The panel itself never mutates state. + +use bevy_forge_core::{validate_scene, SceneDoc, SceneStats, Severity, ValidationReport, Vec2}; + +use crate::state::{EditorContext, Selection}; +use crate::theme; + +/// Height of the status strip in pixels. +pub const STATUS_BAR_HEIGHT: f32 = 25.0; + +pub fn show(ctx: &egui::Context, cx: &EditorContext<'_>) { + egui::TopBottomPanel::bottom("status_bar") + .exact_height(STATUS_BAR_HEIGHT) + .frame( + egui::Frame::new() + .fill(theme::BG) + .inner_margin(egui::Margin::symmetric(10, 0)), + ) + .show(ctx, |ui| { + // `validate_scene` is O(entities) and runs once per frame. Editor + // scenes are small (well under 1k entities) and this keeps the badge + // honest without any cache invalidation bugs; memoize only if a + // profile ever shows it matters. + let doc = cx.session.doc(); + let report = validate_scene(doc, Some(cx.assets)); + let stats = doc.stats(); + + ui.horizontal_centered(|ui| { + ui.label( + egui::RichText::new(counts_label(&stats)) + .color(theme::MUTED) + .size(11.0), + ) + .on_hover_text(stats_tooltip(&stats)); + + ui.separator(); + + let selection_color = if cx.ui.selection.is_empty() { + theme::FAINT + } else { + theme::SELECTION + }; + let selection = ui.label( + egui::RichText::new(selection_label(doc, &cx.ui.selection)) + .color(selection_color) + .size(11.0), + ); + if let Some(tooltip) = selection_tooltip(doc, &cx.ui.selection) { + selection.on_hover_text(tooltip); + } + + ui.separator(); + + let (validation, color) = validation_summary(&report); + ui.label(egui::RichText::new(validation).color(color).size(11.0)) + .on_hover_text(validation_tooltip(doc, &report)); + + // The right cluster is laid out from the right edge inwards; the + // status message is added last and therefore starts the cluster. + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + save_state(ui, cx); + ui.separator(); + cursor_state(ui, cx.ui.cursor_world); + ui.separator(); + snap_state(ui, cx.ui.snap_to_grid); + ui.separator(); + grid_state(ui, cx.ui.show_grid, cx.ui.grid_size); + ui.separator(); + ui.label( + egui::RichText::new(format!("{} view", cx.ui.view_mode.label())) + .color(theme::MUTED) + .size(11.0), + ) + .on_hover_text("Viewport mode — press 2 or 3 to switch"); + ui.separator(); + project_state(ui, cx); + ui.separator(); + + let status = status_message(&cx.ui.status); + ui.add( + egui::Label::new( + egui::RichText::new(shorten(&status, 80)) + .color(theme::FAINT) + .size(11.0), + ) + .truncate(), + ) + .on_hover_text(&status); + }); + }); + }); +} + +// --------------------------------------------------------------------------- +// Pure formatting helpers (unit-tested below) +// --------------------------------------------------------------------------- + +fn plural(count: usize, singular: &str, plural: &str) -> String { + if count == 1 { + format!("{count} {singular}") + } else { + format!("{count} {plural}") + } +} + +fn counts_label(stats: &SceneStats) -> String { + format!( + "{} · {} · {} · {} · {}", + plural(stats.entities, "entity", "entities"), + plural(stats.roots, "root", "roots"), + plural(stats.sprites, "sprite", "sprites"), + plural(stats.meshes, "mesh", "meshes"), + plural(stats.lights, "light", "lights"), + ) +} + +fn stats_tooltip(stats: &SceneStats) -> String { + format!( + "Scene contents\n{} entities · {} roots\n{} sprites · {} meshes · {} lights\n{} cameras · {} custom components", + stats.entities, + stats.roots, + stats.sprites, + stats.meshes, + stats.lights, + stats.cameras, + stats.custom_components, + ) +} + +fn selection_label(doc: &SceneDoc, selection: &Selection) -> String { + match selection.len() { + 0 => "No selection".to_owned(), + 1 => match selection.ids().next().and_then(|id| doc.entity(id)) { + // Keep the strip short even for very long entity names; the tooltip + // below always shows the full name. + Some(node) => format!("Selected {}", shorten(&node.name, 28)), + None => "1 selected".to_owned(), + }, + count => match selection + .primary_or_single() + .and_then(|id| doc.entity(id)) + .map(|node| node.name.as_str()) + { + Some(primary) => format!("{count} selected · {}", shorten(primary, 24)), + None => format!("{count} selected"), + }, + } +} + +fn selection_tooltip(doc: &SceneDoc, selection: &Selection) -> Option { + if selection.is_empty() { + return None; + } + let names: Vec<&str> = selection + .ids() + .filter_map(|id| doc.entity(id)) + .map(|node| node.name.as_str()) + .collect(); + if names.is_empty() { + return None; + } + let mut text = format!("Selection ({} entities):", names.len()); + for name in names.iter().take(8) { + text.push_str("\n• "); + text.push_str(name); + } + if names.len() > 8 { + text.push_str(&format!("\n… and {} more", names.len() - 8)); + } + Some(text) +} + +fn validation_summary(report: &ValidationReport) -> (String, egui::Color32) { + let errors = report.errors(); + let warnings = report.warnings(); + if errors > 0 { + // Errors dominate the color, but warnings stay visible in the text. + let label = if warnings > 0 { + format!( + "{} · {}", + plural(errors, "error", "errors"), + plural(warnings, "warning", "warnings") + ) + } else { + plural(errors, "error", "errors") + }; + (label, theme::ERROR) + } else if warnings > 0 { + (plural(warnings, "warning", "warnings"), theme::WARNING) + } else { + ("Valid".to_owned(), theme::SUCCESS) + } +} + +fn validation_tooltip(doc: &SceneDoc, report: &ValidationReport) -> String { + let mut text = format!( + "Validation\n{} · {} · {}", + plural(report.errors(), "error", "errors"), + plural(report.warnings(), "warning", "warnings"), + plural(report.count(Severity::Info), "info", "info") + ); + let issues = report.sorted(); + for issue in issues.iter().take(6) { + text.push('\n'); + if let Some(entity) = issue.entity { + let name = doc + .entity(entity) + .map(|node| node.name.as_str()) + .unwrap_or("missing entity"); + text.push_str(&format!( + "{}: {name} — {}", + issue.severity.label(), + issue.message + )); + } else { + text.push_str(&format!("{}: {}", issue.severity.label(), issue.message)); + } + } + if issues.len() > 6 { + text.push_str(&format!("\n… and {} more", issues.len() - 6)); + } + text +} + +fn cursor_label(cursor: Option) -> String { + match cursor { + Some(pos) => format!("x {:.2} y {:.2}", pos.x, pos.y), + None => "x — y —".to_owned(), + } +} + +fn grid_label(show_grid: bool, grid_size: f32) -> String { + if show_grid { + format!("Grid {}", trim_float(grid_size)) + } else { + "Grid off".to_owned() + } +} + +fn snap_label(snap_to_grid: bool) -> String { + if snap_to_grid { + "Snap on".to_owned() + } else { + "Snap off".to_owned() + } +} + +/// Format a small float without trailing zeros (`1`, `0.5`, `0.25`). +fn trim_float(value: f32) -> String { + let text = format!("{value:.2}"); + text.trim_end_matches('0').trim_end_matches('.').to_owned() +} + +fn status_message(status: &str) -> String { + if status.trim().is_empty() { + "Ready".to_owned() + } else { + status.to_owned() + } +} + +/// Shorten `text` to at most `max_chars` characters, eliding the middle so both +/// the operation ("Saved …") and the file name stay readable. +fn shorten(text: &str, max_chars: usize) -> String { + let count = text.chars().count(); + if count <= max_chars || max_chars < 4 { + return text.to_owned(); + } + let keep_left = (max_chars - 1) / 2; + let keep_right = max_chars - 1 - keep_left; + let left: String = text.chars().take(keep_left).collect(); + let right: String = text.chars().skip(count - keep_right).collect(); + format!("{left}…{right}") +} + +// --------------------------------------------------------------------------- +// Small drawing helpers +// --------------------------------------------------------------------------- + +fn save_state(ui: &mut egui::Ui, cx: &EditorContext<'_>) { + if cx.session.is_dirty() { + ui.label( + egui::RichText::new("• Unsaved changes") + .color(theme::WARNING) + .size(11.0), + ) + .on_hover_text("The scene has unsaved changes — Ctrl+S saves it"); + } else { + ui.label( + egui::RichText::new("Saved") + .color(theme::SUCCESS) + .size(11.0), + ) + .on_hover_text("All changes are saved"); + } +} + +fn cursor_state(ui: &mut egui::Ui, cursor: Option) { + ui.label( + egui::RichText::new(cursor_label(cursor)) + .color(theme::MUTED) + .monospace() + .size(11.0), + ) + .on_hover_text("Cursor position in world units (2D) or on the ground plane (3D)"); +} + +fn snap_state(ui: &mut egui::Ui, snap_to_grid: bool) { + let color = if snap_to_grid { + theme::ACCENT + } else { + theme::FAINT + }; + ui.label( + egui::RichText::new(snap_label(snap_to_grid)) + .color(color) + .size(11.0), + ) + .on_hover_text("Grid snapping — toggle with the hierarchy toolbar or Edit menu"); +} + +fn grid_state(ui: &mut egui::Ui, show_grid: bool, grid_size: f32) { + let color = if show_grid { + theme::MUTED + } else { + theme::FAINT + }; + ui.label( + egui::RichText::new(grid_label(show_grid, grid_size)) + .color(color) + .size(11.0), + ) + .on_hover_text("Grid visibility and spacing — G toggles the grid"); +} + +fn project_state(ui: &mut egui::Ui, cx: &EditorContext<'_>) { + match cx.project { + Some(project) => { + ui.label( + egui::RichText::new(shorten(&project.name, 28)) + .color(theme::MUTED) + .size(11.0), + ) + .on_hover_text(format!("Project: {}", project.root.display())); + } + None => { + ui.label( + egui::RichText::new("No project") + .color(theme::FAINT) + .size(11.0), + ) + .on_hover_text("Open a Bevy project to save scenes and browse assets"); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use bevy_forge_core::Issue; + + fn doc_with_two_entities() -> (SceneDoc, u64, u64) { + let mut doc = SceneDoc::new("Status bar test"); + let parent = doc.add_entity("Player", None); + let child = doc.add_entity("Hat", Some(parent)); + (doc, parent, child) + } + + #[test] + fn counts_are_pluralized() { + let mut stats = SceneStats::default(); + stats.entities = 1; + stats.roots = 2; + stats.sprites = 0; + stats.meshes = 3; + stats.lights = 1; + assert_eq!( + counts_label(&stats), + "1 entity · 2 roots · 0 sprites · 3 meshes · 1 light" + ); + } + + #[test] + fn selection_summary_uses_entity_names() { + let (doc, parent, child) = doc_with_two_entities(); + let mut selection = Selection::default(); + assert_eq!(selection_label(&doc, &selection), "No selection"); + assert!(selection_tooltip(&doc, &selection).is_none()); + + selection.select_only(parent); + assert_eq!(selection_label(&doc, &selection), "Selected Player"); + + selection.select_many([parent, child], Some(child)); + assert_eq!(selection_label(&doc, &selection), "2 selected · Hat"); + let tooltip = selection_tooltip(&doc, &selection).expect("tooltip for selection"); + assert!(tooltip.contains("Player")); + assert!(tooltip.contains("Hat")); + } + + #[test] + fn long_selection_names_are_shortened_in_the_strip() { + let mut doc = SceneDoc::new("Long names"); + let id = doc.add_entity("Extremely Long Player Character Name With Suffix", None); + let mut selection = Selection::default(); + selection.select_only(id); + let label = selection_label(&doc, &selection); + assert!( + label.len() < 60, + "status strip label stays compact: {label}" + ); + assert!(label.contains('…'), "the middle is elided: {label}"); + // The tooltip keeps the complete name. + let tooltip = selection_tooltip(&doc, &selection).expect("tooltip"); + assert!(tooltip.contains("Extremely Long Player Character Name With Suffix")); + } + + #[test] + fn validation_summary_prefers_the_worst_severity() { + let mut report = ValidationReport::default(); + assert_eq!(validation_summary(&report).0, "Valid"); + + report.issues.push(Issue::warning("duplicate name")); + let (label, color) = validation_summary(&report); + assert_eq!(label, "1 warning"); + assert_eq!(color, theme::WARNING); + + report.issues.push(Issue::error("sprite missing image")); + let (label, color) = validation_summary(&report); + assert_eq!(label, "1 error · 1 warning"); + assert_eq!(color, theme::ERROR); + + let mut only_errors = ValidationReport::default(); + only_errors + .issues + .push(Issue::error("sprite missing image")); + assert_eq!(validation_summary(&only_errors).0, "1 error"); + } + + #[test] + fn validation_tooltip_lists_issues_with_entity_names() { + let (doc, parent, _) = doc_with_two_entities(); + let mut report = ValidationReport::default(); + report + .issues + .push(Issue::error("scale is zero").with_entity(parent)); + let tooltip = validation_tooltip(&doc, &report); + assert!(tooltip.contains("1 error · 0 warnings · 0 info")); + assert!(tooltip.contains("Player")); + assert!(tooltip.contains("scale is zero")); + } + + #[test] + fn shorten_elides_the_middle_unicode_safely() { + assert_eq!(shorten("short", 20), "short"); + assert_eq!(shorten("abcdefghij", 6), "ab…hij"); + assert_eq!( + shorten("Sauvegardé — jardin.forge.ron", 14).chars().count(), + 14 + ); + let unicode = shorten("Édition — café.forge.ron", 12); + assert!(unicode.starts_with("Édit")); + assert!(unicode.contains('…')); + } + + #[test] + fn cursor_grid_and_snap_labels_are_explicit() { + assert_eq!(cursor_label(None), "x — y —"); + assert_eq!( + cursor_label(Some(Vec2::new(1.234, -5.0))), + "x 1.23 y -5.00" + ); + assert_eq!(grid_label(true, 1.0), "Grid 1"); + assert_eq!(grid_label(true, 0.5), "Grid 0.5"); + assert_eq!(grid_label(false, 1.0), "Grid off"); + assert_eq!(snap_label(true), "Snap on"); + assert_eq!(snap_label(false), "Snap off"); + assert_eq!(trim_float(0.25), "0.25"); + } +} diff --git a/crates/bevy_forge_editor/src/texture_cache.rs b/crates/bevy_forge_editor/src/texture_cache.rs new file mode 100644 index 0000000..0aa2080 --- /dev/null +++ b/crates/bevy_forge_editor/src/texture_cache.rs @@ -0,0 +1,300 @@ +//! Lazily loaded image textures for the viewport and asset browser. +//! +//! Images are decoded on first use and cached as egui GPU textures. Failure +//! results are cached too, so a broken asset is only read once and never blocks +//! the UI thread on repeated attempts. The cache is deliberately small and +//! unbounded for now: scene projects reference a handful of assets, and +//! [`TextureCache::clear`] drops every handle when the project changes. +//! +//! Two safety guards keep large files from stalling the editor: +//! +//! * Images wider or taller than [`MAX_DECODE_DIMENSION`] are rejected by the +//! decoder instead of being fully decoded into memory. +//! * Decoded images larger than [`MAX_UPLOAD_DIMENSION`] are downscaled with +//! [`image::DynamicImage::thumbnail`] before upload. The reported +//! [`CachedTexture::size`] always stays the *source* pixel size so viewports +//! can still lay out sprites at their native dimensions. + +use std::collections::HashMap; +use std::path::{Path, PathBuf}; + +/// Source images wider or taller than this are rejected without decoding them +/// into a full pixel buffer. +const MAX_DECODE_DIMENSION: u32 = 4096; + +/// GPU uploads are downscaled to at most this many pixels per side. The editor +/// only shows textures at a few hundred screen pixels, so full-resolution +/// uploads would waste VRAM. +const MAX_UPLOAD_DIMENSION: u32 = 512; + +pub struct CachedTexture { + pub texture: egui::TextureHandle, + /// Pixel size of the source image, before any upload downscale. + pub size: [usize; 2], +} + +#[derive(Default)] +pub struct TextureCache { + entries: HashMap, + failed: HashMap, +} + +impl TextureCache { + pub fn new() -> Self { + Self::default() + } + + /// Load an image once and cache the GPU texture. + /// + /// Returns `None` for missing, oversized or unsupported files. A failure is + /// cached permanently (until [`Self::clear`]) so the file is not re-read + /// every frame. The returned reference borrows from the cache and must not + /// outlive the next mutable use of it. + pub fn get_or_load(&mut self, ctx: &egui::Context, path: &Path) -> Option<&CachedTexture> { + // Decode and insert first, then return a borrow of the stored entry. All + // mutable borrows must finish before the final lookup so the returned + // reference can live as long as the caller needs it. + let already_cached = self.entries.contains_key(path); + let previously_failed = self.failed.contains_key(path); + if !already_cached && !previously_failed { + match decode_image(path) { + Ok((image, source_size)) => { + let texture = + ctx.load_texture(texture_name(path), image, egui::TextureOptions::LINEAR); + self.entries.insert( + path.to_path_buf(), + CachedTexture { + texture, + size: source_size, + }, + ); + } + Err(error) => { + self.failed.insert(path.to_path_buf(), error); + } + } + } + self.entries.get(path) + } + + pub fn texture(&self, path: &Path) -> Option<&egui::TextureHandle> { + self.entries.get(path).map(|entry| &entry.texture) + } + + pub fn dimensions(&self, path: &Path) -> Option<[usize; 2]> { + self.entries.get(path).map(|entry| entry.size) + } + + pub fn clear(&mut self) { + // Dropping the handles frees the GPU textures. + self.entries.clear(); + self.failed.clear(); + } + + pub fn len(&self) -> usize { + self.entries.len() + } + + pub fn is_empty(&self) -> bool { + self.entries.is_empty() + } + + /// The cached human-readable failure for `path`, if a load was attempted. + pub fn error_for(&self, path: &Path) -> Option<&str> { + self.failed.get(path).map(String::as_str) + } +} + +/// Stable, path-derived debug name for the egui texture. +fn texture_name(path: &Path) -> String { + format!("bevy-forge-asset:{}", path.display()) +} + +/// Decode `path` into an RGBA color image no larger than +/// [`MAX_UPLOAD_DIMENSION`], returning the upload image and the source size. +fn decode_image(path: &Path) -> Result<(egui::ColorImage, [usize; 2]), String> { + let mut reader = image::ImageReader::open(path) + .map_err(|error| format!("could not open `{}`: {error}", path.display()))? + .with_guessed_format() + .map_err(|error| { + format!( + "could not detect the format of `{}`: {error}", + path.display() + ) + })?; + let mut limits = image::Limits::default(); + limits.max_image_width = Some(MAX_DECODE_DIMENSION); + limits.max_image_height = Some(MAX_DECODE_DIMENSION); + reader.limits(limits); + + let decoded = reader + .decode() + .map_err(|error| format!("could not decode `{}`: {error}", path.display()))?; + let source_size = [decoded.width() as usize, decoded.height() as usize]; + + let upload = + if decoded.width() > MAX_UPLOAD_DIMENSION || decoded.height() > MAX_UPLOAD_DIMENSION { + decoded.thumbnail(MAX_UPLOAD_DIMENSION, MAX_UPLOAD_DIMENSION) + } else { + decoded + }; + let rgba = upload.to_rgba8(); + let upload_size = [rgba.width() as usize, rgba.height() as usize]; + // `ColorImage::from_rgba_unmultiplied` asserts the buffer length, which is + // guaranteed here because `to_rgba8` allocates exactly width * height * 4. + let image = egui::ColorImage::from_rgba_unmultiplied(upload_size, rgba.as_raw()); + Ok((image, source_size)) +} + +#[cfg(test)] +mod tests { + use super::*; + use image::{Rgba, RgbaImage}; + + fn write_png(path: &Path, width: u32, height: u32, color: [u8; 4]) { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).expect("create test asset directory"); + } + RgbaImage::from_pixel(width, height, Rgba(color)) + .save(path) + .expect("write test PNG"); + } + + #[test] + fn missing_file_fails_once_and_stays_cached_until_clear() { + let temp = tempfile::tempdir().expect("tempdir"); + let path = temp.path().join("textures/missing.png"); + let ctx = egui::Context::default(); + let mut cache = TextureCache::new(); + + assert!(cache.get_or_load(&ctx, &path).is_none()); + let error = cache + .error_for(&path) + .expect("a failed read must store an error"); + assert!( + error.contains("missing.png"), + "error should name the file: {error}" + ); + assert!(cache.is_empty()); + assert!(cache.texture(&path).is_none()); + assert!(cache.dimensions(&path).is_none()); + + // Failures are permanent: creating the file must not trigger a re-read. + write_png(&path, 2, 2, [0, 0, 0, 255]); + assert!( + cache.get_or_load(&ctx, &path).is_none(), + "failures are not retried until clear()" + ); + assert_eq!(cache.len(), 0); + + // ... and clear() drops the failure so a fixed asset can load. + cache.clear(); + assert!(cache.error_for(&path).is_none()); + assert!(cache.get_or_load(&ctx, &path).is_some()); + assert_eq!(cache.dimensions(&path), Some([2, 2])); + } + + #[test] + fn invalid_bytes_are_reported_without_panicking() { + let temp = tempfile::tempdir().expect("tempdir"); + let path = temp.path().join("not-an-image.png"); + std::fs::write(&path, b"this is definitely not a PNG").expect("write garbage"); + let ctx = egui::Context::default(); + let mut cache = TextureCache::new(); + + assert!(cache.get_or_load(&ctx, &path).is_none()); + let error = cache.error_for(&path).expect("error is cached"); + assert!( + error.contains("not-an-image.png"), + "error should name the file: {error}" + ); + assert!(cache.is_empty()); + } + + #[test] + fn valid_png_loads_once_and_same_path_reuses_the_texture() { + let temp = tempfile::tempdir().expect("tempdir"); + // Paths with spaces and unicode must work just like plain ones. + let path = temp.path().join("textures with space/héro.png"); + write_png(&path, 6, 4, [10, 20, 30, 255]); + let ctx = egui::Context::default(); + let mut cache = TextureCache::new(); + + let (texture_id, size) = { + let entry = cache + .get_or_load(&ctx, &path) + .expect("headless egui contexts support texture handles"); + (entry.texture.id(), entry.size) + }; + assert_eq!(size, [6, 4], "reports source dimensions"); + assert_eq!(cache.len(), 1); + assert!(cache.error_for(&path).is_none()); + + // Overwrite with a different image: the cached entry must win. + write_png(&path, 2, 2, [255, 0, 0, 255]); + let entry = cache + .get_or_load(&ctx, &path) + .expect("second call returns the cached entry"); + assert_eq!(entry.size, [6, 4]); + assert_eq!(entry.texture.id(), texture_id, "same GPU texture handle"); + assert_eq!(cache.len(), 1, "no duplicate entry for the same path"); + assert_eq!(cache.dimensions(&path), Some([6, 4])); + assert_eq!(cache.texture(&path).map(|t| t.id()), Some(texture_id)); + } + + #[test] + fn large_images_are_downscaled_for_upload_but_report_source_size() { + let temp = tempfile::tempdir().expect("tempdir"); + let path = temp.path().join("big.png"); + write_png(&path, 640, 320, [1, 2, 3, 255]); + let ctx = egui::Context::default(); + let mut cache = TextureCache::new(); + + let entry = cache.get_or_load(&ctx, &path).expect("load big image"); + assert_eq!(entry.size, [640, 320], "source size is preserved"); + assert_eq!( + entry.texture.size(), + [512, 256], + "upload is downscaled to the 512 px cap, preserving aspect ratio" + ); + } + + #[test] + fn oversized_images_fail_instead_of_being_decoded() { + let temp = tempfile::tempdir().expect("tempdir"); + let path = temp.path().join("huge.png"); + // 4097 px wide (height stays tiny) is just past the decode cap. + write_png(&path, MAX_DECODE_DIMENSION + 1, 1, [0, 0, 0, 255]); + let ctx = egui::Context::default(); + let mut cache = TextureCache::new(); + + assert!(cache.get_or_load(&ctx, &path).is_none()); + let error = cache.error_for(&path).expect("oversize error is cached"); + assert!( + error.contains("huge.png"), + "error should name the file: {error}" + ); + assert!(cache.is_empty()); + } + + #[test] + fn clear_drops_entries_and_failures() { + let temp = tempfile::tempdir().expect("tempdir"); + let good = temp.path().join("good.png"); + let bad = temp.path().join("bad.png"); + write_png(&good, 3, 3, [0, 0, 0, 255]); + std::fs::write(&bad, b"garbage").expect("write garbage"); + let ctx = egui::Context::default(); + let mut cache = TextureCache::new(); + + assert!(cache.get_or_load(&ctx, &good).is_some()); + assert!(cache.get_or_load(&ctx, &bad).is_none()); + assert_eq!(cache.len(), 1); + assert!(cache.error_for(&bad).is_some()); + + cache.clear(); + assert!(cache.is_empty()); + assert!(cache.error_for(&bad).is_none()); + assert!(cache.texture(&good).is_none()); + } +} diff --git a/crates/bevy_forge_editor/src/theme.rs b/crates/bevy_forge_editor/src/theme.rs new file mode 100644 index 0000000..d7c19da --- /dev/null +++ b/crates/bevy_forge_editor/src/theme.rs @@ -0,0 +1,113 @@ +//! Bevy Forge's visual identity: a calm, dark, slightly blue editor theme. + +use egui::{Color32, CornerRadius, FontFamily, FontId, Stroke, TextStyle, Visuals}; + +pub const BG: Color32 = Color32::from_rgb(0x13, 0x15, 0x1c); +pub const PANEL: Color32 = Color32::from_rgb(0x1a, 0x1d, 0x27); +pub const PANEL_ALT: Color32 = Color32::from_rgb(0x20, 0x24, 0x30); +pub const ELEVATED: Color32 = Color32::from_rgb(0x27, 0x2c, 0x3a); +pub const BORDER: Color32 = Color32::from_rgb(0x33, 0x39, 0x49); +pub const TEXT: Color32 = Color32::from_rgb(0xe8, 0xea, 0xf2); +pub const MUTED: Color32 = Color32::from_rgb(0x94, 0x9c, 0xb2); +pub const FAINT: Color32 = Color32::from_rgb(0x7f, 0x87, 0x9b); +pub const ACCENT: Color32 = Color32::from_rgb(0x7c, 0x9c, 0xff); +pub const ACCENT_DIM: Color32 = Color32::from_rgb(0x42, 0x54, 0x8c); +pub const SELECTION: Color32 = Color32::from_rgb(0xff, 0xd1, 0x66); +pub const ERROR: Color32 = Color32::from_rgb(0xff, 0x6b, 0x6b); +pub const WARNING: Color32 = Color32::from_rgb(0xff, 0xc8, 0x57); +pub const SUCCESS: Color32 = Color32::from_rgb(0x6e, 0xe7, 0xa0); +pub const X_AXIS: Color32 = Color32::from_rgb(0xff, 0x6b, 0x7a); +pub const Y_AXIS: Color32 = Color32::from_rgb(0x7e, 0xe7, 0x87); +pub const Z_AXIS: Color32 = Color32::from_rgb(0x6c, 0xa8, 0xff); +pub const GRID_MINOR: Color32 = Color32::from_rgb(0x22, 0x26, 0x31); +pub const GRID_MAJOR: Color32 = Color32::from_rgb(0x2d, 0x33, 0x42); + +pub fn apply(ctx: &egui::Context) { + // Force the dark theme even when the OS prefers light; `set_visuals` only + // affects the currently active theme. + ctx.set_theme(egui::ThemePreference::Dark); + let mut visuals = Visuals::dark(); + visuals.panel_fill = PANEL; + visuals.window_fill = PANEL; + visuals.extreme_bg_color = BG; + visuals.faint_bg_color = PANEL_ALT; + visuals.code_bg_color = BG; + visuals.override_text_color = Some(TEXT); + visuals.selection.bg_fill = ACCENT_DIM; + visuals.selection.stroke = Stroke::new(1.0_f32, ACCENT); + visuals.window_stroke = Stroke::new(1.0_f32, BORDER); + visuals.widgets.noninteractive.bg_fill = PANEL; + visuals.widgets.noninteractive.bg_stroke = Stroke::new(1.0_f32, BORDER); + visuals.widgets.inactive.bg_fill = PANEL_ALT; + visuals.widgets.inactive.weak_bg_fill = PANEL_ALT; + visuals.widgets.inactive.bg_stroke = Stroke::new(1.0_f32, BORDER); + visuals.widgets.inactive.corner_radius = CornerRadius::same(5); + visuals.widgets.hovered.bg_fill = ELEVATED; + visuals.widgets.hovered.weak_bg_fill = ELEVATED; + visuals.widgets.hovered.bg_stroke = Stroke::new(1.0_f32, ACCENT_DIM); + visuals.widgets.hovered.corner_radius = CornerRadius::same(5); + visuals.widgets.active.bg_fill = ACCENT_DIM; + visuals.widgets.active.weak_bg_fill = ACCENT_DIM; + visuals.widgets.active.bg_stroke = Stroke::new(1.0_f32, ACCENT); + visuals.widgets.active.corner_radius = CornerRadius::same(5); + visuals.widgets.open.bg_fill = ELEVATED; + visuals.window_corner_radius = CornerRadius::same(8); + visuals.menu_corner_radius = CornerRadius::same(6); + ctx.set_visuals(visuals); + + let mut style = (*ctx.style()).clone(); + style.spacing.item_spacing = egui::vec2(8.0, 6.0); + style.spacing.button_padding = egui::vec2(8.0, 4.0); + style.spacing.indent = 16.0; + style.spacing.interact_size.y = 24.0; + style.spacing.scroll.bar_width = 8.0; + style.text_styles.insert( + TextStyle::Heading, + FontId::new(19.0, FontFamily::Proportional), + ); + style + .text_styles + .insert(TextStyle::Body, FontId::new(13.5, FontFamily::Proportional)); + style.text_styles.insert( + TextStyle::Button, + FontId::new(13.0, FontFamily::Proportional), + ); + style.text_styles.insert( + TextStyle::Small, + FontId::new(11.5, FontFamily::Proportional), + ); + style.text_styles.insert( + TextStyle::Monospace, + FontId::new(12.5, FontFamily::Monospace), + ); + ctx.set_style(style); +} + +pub fn card_frame() -> egui::Frame { + egui::Frame::new() + .fill(PANEL_ALT) + .stroke(Stroke::new(1.0_f32, BORDER)) + .corner_radius(CornerRadius::same(7)) + .inner_margin(egui::Margin::symmetric(10, 8)) +} + +pub fn inset_frame() -> egui::Frame { + egui::Frame::new() + .fill(BG) + .stroke(Stroke::new(1.0_f32, BORDER)) + .corner_radius(CornerRadius::same(5)) + .inner_margin(egui::Margin::symmetric(8, 6)) +} + +pub fn subtle_button(text: impl Into) -> egui::Button<'static> { + egui::Button::new(text).frame(false) +} + +/// Muted secondary text used for hints and counts. +pub fn hint(text: impl Into) -> egui::RichText { + egui::RichText::new(text).color(MUTED).size(11.5) +} + +pub fn accent_text(text: impl Into) -> egui::RichText { + egui::RichText::new(text).color(ACCENT) +} diff --git a/crates/bevy_forge_editor/src/toast.rs b/crates/bevy_forge_editor/src/toast.rs new file mode 100644 index 0000000..4043f1e --- /dev/null +++ b/crates/bevy_forge_editor/src/toast.rs @@ -0,0 +1,370 @@ +//! Transient toast notifications. +//! +//! Toasts stack in the bottom-right corner, above the status bar. They expire on +//! their own ([`LIFETIME_SECS`]), can be dismissed with a click and never capture +//! pointer input outside their own small area. At most [`MAX_TOASTS`] are kept so +//! a burst of errors cannot cover the editor. +//! +//! Ages are advanced from egui's frame delta rather than wall-clock time, which +//! keeps the expiry logic pure and unit-testable via [`Toasts::advance`]. + +use std::time::Duration; + +use crate::theme; + +/// How long a toast stays visible before it expires, in seconds. +pub const LIFETIME_SECS: f32 = 4.5; +/// Maximum number of toasts kept on screen; the oldest is dropped first. +pub const MAX_TOASTS: usize = 5; + +/// Fade-in duration at the start of a toast's life. +const FADE_IN_SECS: f32 = 0.15; +/// Fade-out duration at the end of a toast's life. +const FADE_OUT_SECS: f32 = 0.45; +/// Text wrap width of a toast message. +const MAX_TOAST_WIDTH: f32 = 340.0; +/// One frame of animation never counts for more than this (avoids huge jumps +/// after the app was suspended). +const MAX_FRAME_DT: f32 = 0.25; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ToastLevel { + Info, + Success, + Warning, + Error, +} + +impl ToastLevel { + /// Single-glyph marker drawn in a colored pill. Every glyph is covered by the + /// bundled egui fonts (NotoEmoji for ✔, Ubuntu for the rest). + pub const fn icon(self) -> &'static str { + match self { + Self::Info => "i", + Self::Success => "✔", + Self::Warning => "!", + Self::Error => "×", + } + } + + /// Spoken name, used for tooltips and tests. + pub const fn label(self) -> &'static str { + match self { + Self::Info => "Info", + Self::Success => "Success", + Self::Warning => "Warning", + Self::Error => "Error", + } + } + + pub const fn color(self) -> egui::Color32 { + match self { + Self::Info => theme::ACCENT, + Self::Success => theme::SUCCESS, + Self::Warning => theme::WARNING, + Self::Error => theme::ERROR, + } + } +} + +#[derive(Clone, Debug)] +struct Toast { + /// Monotonic key for stable egui widget ids. Never reused, so removing one + /// toast cannot move another toast's widget state. + key: u64, + level: ToastLevel, + message: String, + /// Seconds since the toast was pushed. + age: f32, +} + +impl Toast { + /// Visibility multiplier in `0.0..=1.0`, fading in and out at the edges. + fn opacity(&self) -> f32 { + let fade_in = (self.age / FADE_IN_SECS).clamp(0.0, 1.0); + let fade_out = ((LIFETIME_SECS - self.age) / FADE_OUT_SECS).clamp(0.0, 1.0); + fade_in.min(fade_out) + } +} + +#[derive(Default)] +pub struct Toasts { + entries: Vec, + next_key: u64, +} + +impl Toasts { + pub fn new() -> Self { + Self::default() + } + + pub fn push(&mut self, level: ToastLevel, message: impl Into) { + let message = message.into(); + if message.trim().is_empty() { + return; + } + // A repeated message means the user did the same thing twice; refresh the + // existing toast instead of stacking duplicates. + if let Some(last) = self.entries.last_mut() { + if last.level == level && last.message == message { + last.age = 0.0; + return; + } + } + + let key = self.next_key; + self.next_key = self.next_key.wrapping_add(1); + self.entries.push(Toast { + key, + level, + message, + age: 0.0, + }); + while self.entries.len() > MAX_TOASTS { + self.entries.remove(0); + } + } + + pub fn info(&mut self, message: impl Into) { + self.push(ToastLevel::Info, message); + } + + pub fn success(&mut self, message: impl Into) { + self.push(ToastLevel::Success, message); + } + + pub fn warning(&mut self, message: impl Into) { + self.push(ToastLevel::Warning, message); + } + + pub fn error(&mut self, message: impl Into) { + self.push(ToastLevel::Error, message); + } + + pub fn len(&self) -> usize { + self.entries.len() + } + + pub fn is_empty(&self) -> bool { + self.entries.is_empty() + } + + pub fn clear(&mut self) { + self.entries.clear(); + } + + /// Age all toasts by `dt` seconds and drop the expired ones. + /// + /// Kept separate from [`Self::show`] so expiry is testable without an egui + /// context. + fn advance(&mut self, dt: f32) { + let dt = dt.max(0.0); + for entry in &mut self.entries { + entry.age += dt; + } + self.entries.retain(|entry| entry.age < LIFETIME_SECS); + } + + /// Dismiss one toast by its stable key. + fn dismiss(&mut self, key: u64) { + self.entries.retain(|entry| entry.key != key); + } + + /// Draw the toast stack and expire old entries. + pub fn show(&mut self, ctx: &egui::Context) { + if self.entries.is_empty() { + return; + } + + let dt = ctx.input(|input| input.stable_dt).clamp(0.0, MAX_FRAME_DT); + self.advance(dt); + if self.entries.is_empty() { + return; + } + + let mut dismissed = None; + egui::Area::new(egui::Id::new("bevy_forge_toasts")) + // Sit above the 25 px status bar with a small margin. + .anchor(egui::Align2::RIGHT_BOTTOM, egui::vec2(-16.0, -34.0)) + .order(egui::Order::Foreground) + .interactable(true) + .show(ctx, |ui| { + ui.set_max_width(MAX_TOAST_WIDTH + 32.0); + ui.vertical(|ui| { + // Oldest first: the newest toast is closest to the status bar. + for entry in &self.entries { + ui.push_id(entry.key, |ui| { + ui.set_opacity(entry.opacity()); + if toast_ui(ui, entry).clicked() { + dismissed = Some(entry.key); + } + }); + } + }); + }); + + if let Some(key) = dismissed { + self.dismiss(key); + } + + // Animate only while toasts are visible; an idle editor repaints on demand. + ctx.request_repaint_after(Duration::from_millis(16)); + } +} + +/// Draw a single toast. The returned response covers the whole card and is +/// clickable, so a click anywhere on the toast dismisses it. +fn toast_ui(ui: &mut egui::Ui, entry: &Toast) -> egui::Response { + let color = entry.level.color(); + let frame = egui::Frame::popup(ui.style()) + .fill(theme::ELEVATED) + .stroke(egui::Stroke::new(1.0_f32, color.gamma_multiply(0.65))) + .corner_radius(egui::CornerRadius::same(7)) + .inner_margin(egui::Margin::symmetric(10, 7)) + .outer_margin(egui::Margin::symmetric(0, 2)); + + let inner = frame.show(ui, |ui| { + ui.set_max_width(MAX_TOAST_WIDTH); + ui.horizontal(|ui| { + egui::Frame::new() + .fill(color.gamma_multiply(0.16)) + .corner_radius(egui::CornerRadius::same(9)) + .inner_margin(egui::Margin::symmetric(5, 1)) + .show(ui, |ui| { + ui.label( + egui::RichText::new(entry.level.icon()) + .color(color) + .strong() + .size(11.0), + ) + .on_hover_text(entry.level.label()); + }); + ui.add( + egui::Label::new( + egui::RichText::new(&entry.message) + .color(theme::TEXT) + .size(12.0), + ) + .wrap(), + ); + }); + }); + + inner + .response + .interact(egui::Sense::click()) + .on_hover_cursor(egui::CursorIcon::PointingHand) + .on_hover_text(format!("{} — click to dismiss", entry.level.label())) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn toasts_expire_after_their_lifetime() { + let mut toasts = Toasts::new(); + toasts.info("Saved garden.forge.ron"); + toasts.success("Exported scene.rs"); + assert_eq!(toasts.len(), 2); + + toasts.advance(LIFETIME_SECS - 0.1); + assert_eq!(toasts.len(), 2, "still visible just before the deadline"); + + toasts.advance(0.2); + assert!(toasts.is_empty(), "expired toasts are removed"); + } + + #[test] + fn at_most_five_toasts_are_kept() { + let mut toasts = Toasts::new(); + for index in 0..MAX_TOASTS + 3 { + toasts.warning(format!("warning {index}")); + } + assert_eq!(toasts.len(), MAX_TOASTS); + assert!(toasts.entries.iter().all(|entry| entry.age == 0.0)); + // The oldest three were dropped. + assert_eq!(toasts.entries[0].message, "warning 3"); + } + + #[test] + fn repeated_messages_refresh_instead_of_stacking() { + let mut toasts = Toasts::new(); + toasts.error("Export failed"); + toasts.advance(2.0); + assert!(toasts.entries[0].age > 0.0); + + toasts.error("Export failed"); + assert_eq!(toasts.len(), 1); + assert_eq!(toasts.entries[0].age, 0.0, "the toast timer restarts"); + } + + #[test] + fn empty_messages_are_ignored() { + let mut toasts = Toasts::new(); + toasts.info(" "); + assert!(toasts.is_empty()); + } + + #[test] + fn dismissal_and_clear_work() { + let mut toasts = Toasts::new(); + toasts.info("one"); + toasts.info("two"); + let key = toasts.entries[0].key; + toasts.dismiss(key); + assert_eq!(toasts.len(), 1); + assert_eq!(toasts.entries[0].message, "two"); + + toasts.clear(); + assert!(toasts.is_empty()); + } + + #[test] + fn opacity_fades_in_and_out() { + let mut entry = Toast { + key: 0, + level: ToastLevel::Info, + message: "fade".into(), + age: 0.0, + }; + assert_eq!(entry.opacity(), 0.0, "starts transparent"); + entry.age = FADE_IN_SECS; + assert_eq!(entry.opacity(), 1.0, "fully visible while live"); + entry.age = LIFETIME_SECS - FADE_OUT_SECS / 2.0; + assert!(entry.opacity() < 1.0 && entry.opacity() > 0.0, "fades out"); + entry.age = LIFETIME_SECS; + assert_eq!(entry.opacity(), 0.0); + } + + #[test] + fn level_markers_are_distinct() { + let levels = [ + ToastLevel::Info, + ToastLevel::Success, + ToastLevel::Warning, + ToastLevel::Error, + ]; + for (index, level) in levels.iter().enumerate() { + assert!(!level.icon().is_empty()); + assert!(!level.label().is_empty()); + assert_ne!(level.color(), levels[(index + 1) % levels.len()].color()); + } + } + + #[test] + fn drawing_is_headless_safe() { + let ctx = egui::Context::default(); + let mut toasts = Toasts::new(); + toasts.success("Rendered headlessly"); + let raw_input = egui::RawInput { + screen_rect: Some(egui::Rect::from_min_size( + egui::Pos2::ZERO, + egui::vec2(960.0, 640.0), + )), + ..Default::default() + }; + let _ = ctx.run(raw_input, |ctx| toasts.show(ctx)); + assert_eq!(toasts.len(), 1); + } +} diff --git a/crates/bevy_forge_editor/src/viewport2d.rs b/crates/bevy_forge_editor/src/viewport2d.rs new file mode 100644 index 0000000..25923e8 --- /dev/null +++ b/crates/bevy_forge_editor/src/viewport2d.rs @@ -0,0 +1,3202 @@ +//! 2D viewport: grid, textured sprites, selection, pan/zoom and translate gizmo. +//! +//! The viewport is a pure egui canvas: a world/screen camera around a pan/zoom +//! centre, a grid, textured sprites drawn from the lazily loaded [`TextureCache`], +//! markers for non-visual components, click selection, drag-to-move, a constant +//! screen-space X/Y translate gizmo and asset drag-and-drop. +//! +//! Scene mutations only happen through [`Session::edit`](bevy_forge_core::Session) +//! (asset drops) or a snapshot plus [`Session::commit`](bevy_forge_core::Session) +//! (drags), so one gesture is always exactly one undo entry. Pan/zoom/fit only +//! change [`Camera2dState`]. + +use std::collections::HashMap; +use std::path::Path; + +use bevy_forge_core::math::{quat_from_euler_deg, quat_normalize, quat_rotate_vec3}; +use bevy_forge_core::{ + Bounds2, ComponentData, EntityId, Project, SceneDoc, SpriteData, Text2dData, Transform, Vec2, + Vec3, +}; +use egui::{ + pos2, vec2, Align2, Color32, CursorIcon, FontId, Key, Modifiers, Pos2, Rect, Sense, Stroke, + StrokeKind, TextureHandle, Ui, +}; + +use crate::action::Action; +use crate::state::{AssetDragPayload, EditorContext, Selection}; +use crate::texture_cache::TextureCache; +use crate::{theme, widgets}; + +/// Default camera zoom, also used as the 100% reference in the toolbar. +pub const DEFAULT_ZOOM: f32 = 64.0; +/// Smallest interactive zoom in pixels per world unit. +pub const ZOOM_MIN: f32 = 2.0; +/// Largest zoom in pixels per world unit. +pub const ZOOM_MAX: f32 = 1024.0; +/// Fit may zoom further out than [`ZOOM_MIN`] so pixel-scale demo scenes (a +/// 960x540 backdrop is wider than the whole viewport at 2 px/unit) can actually +/// be framed. Once the user zooms back above [`ZOOM_MIN`] the regular limit +/// applies again. +pub const FIT_ZOOM_MIN: f32 = 0.05; +/// Empty space left around fitted content. +const FIT_PADDING: f32 = 40.0; +/// Screen-space length of the translate gizmo arrows. +const GIZMO_LEN: f32 = 58.0; +/// Gap between the gizmo centre and the arrow shafts. +const GIZMO_GAP: f32 = 13.0; +/// Picking radius for camera/light/custom component markers. +const MARKER_HIT_RADIUS: f32 = 14.0; +/// Number of screen cells used per axis for a missing-texture checker. +const CHECKER_CELLS: usize = 22; + +#[derive(Clone, Debug)] +pub struct Camera2dState { + /// World-space position at the center of the viewport. + pub center: Vec2, + /// Pixels per world unit. + pub zoom: f32, +} + +impl Default for Camera2dState { + fn default() -> Self { + Self { + center: Vec2::ZERO, + zoom: DEFAULT_ZOOM, + } + } +} + +impl Camera2dState { + /// Zoom as a percentage of [`DEFAULT_ZOOM`], shown in the toolbar. + pub fn zoom_percent(&self) -> f32 { + if self.zoom.is_finite() { + self.zoom / DEFAULT_ZOOM * 100.0 + } else { + 100.0 + } + } +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum Tool { + Select, + #[default] + Move, +} + +impl Tool { + pub const fn label(self) -> &'static str { + match self { + Self::Select => "Select", + Self::Move => "Move", + } + } +} + +/// Which part of the translate gizmo is hovered or being dragged. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum GizmoHandle { + #[default] + None, + /// Centre handle: free XY movement. + Free, + X, + Y, +} + +impl GizmoHandle { + pub const fn axis_label(self) -> Option<&'static str> { + match self { + Self::X => Some("X"), + Self::Y => Some("Y"), + Self::Free => Some("XY"), + Self::None => None, + } + } +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum Interaction { + #[default] + Idle, + Pan, + Move, + Gizmo, + Marquee, +} + +pub struct Viewport2dState { + pub camera: Camera2dState, + pub dragging: Option, + pub drag_before: Option, + pub marquee: Option, + pub show_help: bool, + pub tool: Tool, + pub interaction: Interaction, + /// Gizmo handle held for the current (or last) gizmo drag. + pub gizmo_handle: GizmoHandle, + /// Handle under the cursor this frame. + pub hover_gizmo: GizmoHandle, + pub drag_start_pointer: Option, + pub drag_start_world: Option, + /// Entities moved by the active drag (top level of the selection only). + pub drag_ids: Vec, + /// Zoom factor requested by the toolbar and applied once the canvas is known. + pub pending_zoom: Option, + /// Identity of the scene the camera was last fitted to. + pub last_scene: Option, + /// Set until the camera has been framed on the current scene. + pub auto_fit: bool, +} + +impl Default for Viewport2dState { + fn default() -> Self { + Self { + camera: Camera2dState::default(), + dragging: None, + drag_before: None, + marquee: None, + show_help: true, + tool: Tool::default(), + interaction: Interaction::default(), + gizmo_handle: GizmoHandle::default(), + hover_gizmo: GizmoHandle::default(), + drag_start_pointer: None, + drag_start_world: None, + drag_ids: Vec::new(), + pending_zoom: None, + last_scene: None, + auto_fit: true, + } + } +} + +impl std::fmt::Debug for Viewport2dState { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Viewport2dState") + .field("camera", &self.camera) + .field("dragging", &self.dragging) + .field("tool", &self.tool) + .field("interaction", &self.interaction) + .finish_non_exhaustive() + } +} + +// --------------------------------------------------------------------------- +// Camera math +// --------------------------------------------------------------------------- + +/// Convert a world-space point to canvas pixels. World +Y points up, screen +Y +/// points down, so the y axis is flipped around the viewport center. +pub fn world_to_screen(world: Vec2, rect: Rect, camera: &Camera2dState) -> Pos2 { + let center = rect.center(); + let zoom = safe_zoom(camera.zoom); + pos2( + center.x + (world.x - camera.center.x) * zoom, + center.y - (world.y - camera.center.y) * zoom, + ) +} + +/// Convert canvas pixels back to world units (inverse of [`world_to_screen`]). +pub fn screen_to_world(screen: Pos2, rect: Rect, camera: &Camera2dState) -> Vec2 { + let center = rect.center(); + let zoom = safe_zoom(camera.zoom); + Vec2::new( + camera.center.x + (screen.x - center.x) / zoom, + camera.center.y - (screen.y - center.y) / zoom, + ) +} + +/// A finite, non-zero zoom value for rendering math. The camera is clamped by +/// [`zoom_at`]/[`fit_camera`]; this is only a last-resort guard. +fn safe_zoom(zoom: f32) -> f32 { + if zoom.is_finite() && zoom.abs() > 1e-6 { + zoom + } else { + DEFAULT_ZOOM + } +} + +/// Clamp a zoom value into the supported `2..=1024` px/unit range. +pub fn clamp_zoom(zoom: f32) -> f32 { + if !zoom.is_finite() { + DEFAULT_ZOOM + } else { + zoom.clamp(ZOOM_MIN, ZOOM_MAX) + } +} + +/// Clamp an interactive zoom, allowing the continuous range below [`ZOOM_MIN`] +/// once Fit has put the camera into an overview below the normal limit. This +/// avoids a jarring jump when the first wheel event after Fit would otherwise +/// snap straight back to 2 px/unit. +fn clamp_interactive(zoom: f32, current: f32) -> f32 { + if !zoom.is_finite() { + return safe_zoom(current); + } + let min = if current < ZOOM_MIN { + FIT_ZOOM_MIN + } else { + ZOOM_MIN + }; + zoom.clamp(min, ZOOM_MAX) +} + +/// Zoom by `factor`, keeping the world point under `anchor` on the same pixel. +pub fn zoom_at(camera: &mut Camera2dState, anchor: Pos2, rect: Rect, factor: f32) { + if !factor.is_finite() || factor <= 0.0 { + return; + } + let target = camera.zoom * factor; + set_zoom_at(camera, anchor, rect, target); +} + +/// Set an absolute zoom, keeping the world point under `anchor` fixed. +pub fn set_zoom_at(camera: &mut Camera2dState, anchor: Pos2, rect: Rect, target_zoom: f32) { + let world = screen_to_world(anchor, rect, camera); + let zoom = clamp_interactive(target_zoom, camera.zoom); + let center = rect.center(); + camera.zoom = zoom; + let center_x = world.x - (anchor.x - center.x) / zoom; + let center_y = world.y + (anchor.y - center.y) / zoom; + if center_x.is_finite() && center_y.is_finite() { + camera.center = Vec2::new(center_x, center_y); + } +} + +/// Translate the camera by a screen-space pan delta (content follows the mouse). +fn pan_camera(camera: &mut Camera2dState, screen_delta: egui::Vec2) { + let zoom = safe_zoom(camera.zoom); + let x = camera.center.x - screen_delta.x / zoom; + let y = camera.center.y + screen_delta.y / zoom; + if x.is_finite() && y.is_finite() { + camera.center = Vec2::new(x, y); + } +} + +// --------------------------------------------------------------------------- +// Snapping / transform helpers +// --------------------------------------------------------------------------- + +/// Round a scalar to the nearest multiple of `grid`. Values that cannot be +/// snapped (non-finite value or a zero/negative grid) pass through unchanged. +pub fn snap_scalar(value: f32, grid: f32) -> f32 { + if value.is_finite() && grid.is_finite() && grid > 1e-6 { + (value / grid).round() * grid + } else { + value + } +} + +/// Round both components of a world-space vector to the grid. +pub fn snap_vec2(value: Vec2, grid: f32) -> Vec2 { + Vec2::new(snap_scalar(value.x, grid), snap_scalar(value.y, grid)) +} + +fn divide_scale(value: f32, scale: f32) -> f32 { + if scale.is_finite() && scale.abs() > 1e-6 { + value / scale + } else { + value + } +} + +/// Convert a world-space translation into the parent's local translation space. +/// +/// Used by drags so a child follows the pointer correctly even when its parent is +/// rotated or scaled. Zero scales fall back to the raw offset instead of +/// producing infinities. +fn local_translation(parent: &Transform, world: Vec3) -> Vec3 { + let q = quat_normalize(quat_from_euler_deg(parent.rotation)); + let inverse = [-q[0], -q[1], -q[2], q[3]]; + let relative = world - parent.translation; + let unrotated = quat_rotate_vec3(inverse, relative); + Vec3::new( + divide_scale(unrotated.x, parent.scale.x), + divide_scale(unrotated.y, parent.scale.y), + divide_scale(unrotated.z, parent.scale.z), + ) +} + +/// Top-level movable members of a selection. +/// +/// When a parent and one of its descendants are both selected the parent is +/// dragged and the child follows it; moving both by the same world delta would +/// otherwise double the child's displacement. Locked entities are skipped. +fn drag_targets(doc: &SceneDoc, selection: &Selection) -> Vec { + let movable: Vec = selection + .ids() + .filter(|id| doc.entity(*id).is_some_and(|node| !node.locked)) + .collect(); + movable + .iter() + .copied() + .filter(|id| { + !movable + .iter() + .any(|other| other != id && doc.is_ancestor(*other, *id)) + }) + .collect() +} + +fn unique_name(doc: &SceneDoc, base: &str) -> String { + if doc.find_by_name(base).is_none() { + return base.to_string(); + } + for suffix in 2..10_000 { + let candidate = format!("{base} {suffix}"); + if doc.find_by_name(&candidate).is_none() { + return candidate; + } + } + base.to_string() +} + +/// Adjust a Fit zoom so it never introduces non-finite values. +fn fit_zoom(available: egui::Vec2, size: Vec2) -> f32 { + let mut zoom = f32::INFINITY; + if size.x > 1e-6 { + zoom = zoom.min(available.x / size.x); + } + if size.y > 1e-6 { + zoom = zoom.min(available.y / size.y); + } + if !zoom.is_finite() || zoom <= 0.0 { + DEFAULT_ZOOM + } else { + zoom + } +} + +/// Frame `bounds` in the viewport, leaving [`FIT_PADDING`] pixels on each side. +pub fn fit_camera(camera: &mut Camera2dState, bounds: Bounds2, rect: Rect, padding: f32) { + if rect.width() <= 1.0 || rect.height() <= 1.0 { + return; + } + if bounds.is_empty() || !bounds.min.is_finite() || !bounds.max.is_finite() { + camera.center = Vec2::ZERO; + camera.zoom = DEFAULT_ZOOM; + return; + } + let available = egui::vec2( + (rect.width() - 2.0 * padding).max(16.0), + (rect.height() - 2.0 * padding).max(16.0), + ); + let zoom = fit_zoom(available, bounds.size()); + camera.zoom = if zoom.is_finite() { + zoom.clamp(FIT_ZOOM_MIN, ZOOM_MAX) + } else { + DEFAULT_ZOOM + }; + camera.center = bounds.center(); +} + +// --------------------------------------------------------------------------- +// Hit testing +// --------------------------------------------------------------------------- + +#[derive(Clone, Copy, Debug)] +enum HitShape { + /// Sprite quad in canvas pixels, in `[tl, tr, br, bl]` order. + Quad([Pos2; 4]), + Rect(Rect), + Circle { + center: Pos2, + radius: f32, + }, +} + +#[derive(Clone, Copy, Debug)] +struct HitRegion { + id: EntityId, + shape: HitShape, +} + +impl HitRegion { + fn rect(id: EntityId, rect: Rect) -> Self { + Self { + id, + shape: HitShape::Rect(rect), + } + } + + fn circle(id: EntityId, center: Pos2, radius: f32) -> Self { + Self { + id, + shape: HitShape::Circle { center, radius }, + } + } + + fn contains(&self, point: Pos2) -> bool { + match self.shape { + HitShape::Quad(corners) => point_in_polygon(&corners, point), + HitShape::Rect(rect) => rect.contains(point), + HitShape::Circle { center, radius } => center.distance(point) <= radius, + } + } +} + +/// Topmost region under a point. Regions must be ordered bottom-to-top (the +/// render order), matching the viewport's "later entity wins" picking rule. +fn hit_test(regions: &[HitRegion], point: Pos2) -> Option { + regions + .iter() + .rev() + .find(|region| region.contains(point)) + .map(|region| region.id) +} + +/// Standard even-odd point-in-polygon test, robust for either winding. +fn point_in_polygon(points: &[Pos2], point: Pos2) -> bool { + if points.len() < 3 { + return false; + } + let mut inside = false; + let mut previous = points.len() - 1; + for current in 0..points.len() { + let a = points[current]; + let b = points[previous]; + if (a.y > point.y) != (b.y > point.y) + && point.x < (b.x - a.x) * (point.y - a.y) / (b.y - a.y) + a.x + { + inside = !inside; + } + previous = current; + } + inside +} + +fn distance_to_segment(point: Pos2, a: Pos2, b: Pos2) -> f32 { + let ab = b - a; + let length_sq = ab.length_sq(); + if length_sq <= 1e-6 { + return point.distance(a); + } + let t = ((point - a).dot(ab) / length_sq).clamp(0.0, 1.0); + point.distance(a + ab * t) +} + +// --------------------------------------------------------------------------- +// Draw model +// --------------------------------------------------------------------------- + +#[derive(Clone, Copy, Debug)] +struct GizmoLayout { + center: Pos2, +} + +fn gizmo_layout( + doc: &SceneDoc, + selection: &Selection, + rect: Rect, + camera: &Camera2dState, +) -> Option { + let primary = selection.primary_or_single()?; + let node = doc.entity(primary)?; + if node.locked || is_effectively_hidden(doc, primary) { + return None; + } + let world = doc.world_transform(primary)?; + Some(GizmoLayout { + center: world_to_screen( + Vec2::new(world.translation.x, world.translation.y), + rect, + camera, + ), + }) +} + +fn gizmo_hit(layout: GizmoLayout, pointer: Pos2) -> Option { + if pointer.distance(layout.center) <= 9.0 { + return Some(GizmoHandle::Free); + } + let x_end = layout.center + vec2(GIZMO_LEN + 4.0, 0.0); + let x_start = layout.center + vec2(GIZMO_GAP, 0.0); + if distance_to_segment(pointer, x_start, x_end) <= 7.0 && pointer.x >= layout.center.x { + return Some(GizmoHandle::X); + } + let y_start = layout.center + vec2(0.0, -GIZMO_GAP); + let y_end = layout.center + vec2(0.0, -(GIZMO_LEN + 4.0)); + if distance_to_segment(pointer, y_start, y_end) <= 7.0 && pointer.y <= layout.center.y { + return Some(GizmoHandle::Y); + } + None +} + +#[derive(Clone, Debug)] +enum MarkerKind { + Camera2d, + Camera3d, + PointLight, + DirectionalLight, + Custom(String), + Mesh3d, +} + +#[derive(Clone, Debug)] +enum SpritePaint { + Textured, + Solid, + Missing, +} + +#[derive(Clone)] +struct SpriteDraw { + /// Screen corners `[tl, tr, br, bl]`. + corners: [Pos2; 4], + bounds: Rect, + paint: SpritePaint, + texture: Option, + color: Color32, + flip_x: bool, + flip_y: bool, + /// Asset path for the missing-texture label. + image: Option, +} + +#[derive(Clone, Debug)] +struct TextDraw { + bounds: Rect, + font_px: f32, + lines: Vec, + color: Color32, +} + +#[derive(Clone, Debug)] +struct MarkerDraw { + center: Pos2, + kind: MarkerKind, + label: String, +} + +#[derive(Clone)] +enum DrawContent { + Sprite(SpriteDraw), + Text(TextDraw), + Marker(MarkerDraw), + Empty { center: Pos2 }, +} + +#[derive(Clone)] +struct DrawEntity { + id: EntityId, + name: String, + z: f32, + order: usize, + content: DrawContent, + selected: bool, + locked: bool, +} + +impl DrawEntity { + fn bounds(&self) -> Rect { + match &self.content { + DrawContent::Sprite(sprite) => sprite.bounds, + DrawContent::Text(text) => text.bounds, + DrawContent::Marker(marker) => Rect::from_center_size(marker.center, vec2(30.0, 30.0)), + DrawContent::Empty { center } => Rect::from_center_size(*center, vec2(12.0, 12.0)), + } + } + + fn hit_region(&self) -> Option { + match &self.content { + DrawContent::Sprite(sprite) => Some(HitRegion { + id: self.id, + shape: HitShape::Quad(sprite.corners), + }), + DrawContent::Text(text) => Some(HitRegion::rect(self.id, text.bounds)), + DrawContent::Marker(marker) => { + Some(HitRegion::circle(self.id, marker.center, MARKER_HIT_RADIUS)) + } + DrawContent::Empty { center } => Some(HitRegion::circle(self.id, *center, 8.0)), + } + } +} + +fn build_hit_regions(draw: &[DrawEntity]) -> Vec { + draw.iter().filter_map(DrawEntity::hit_region).collect() +} + +fn is_effectively_hidden(doc: &SceneDoc, id: EntityId) -> bool { + let mut current = Some(id); + let mut guard = 0; + while let Some(entity) = current { + guard += 1; + if guard > 256 { + return false; + } + let Some(node) = doc.entity(entity) else { + return false; + }; + if node.is_hidden() { + return true; + } + current = node.parent; + } + false +} + +fn text_metrics(data: &Text2dData, world: &Transform, zoom: f32) -> (f32, egui::Vec2, Vec) { + let font_size = data.font_size * world.scale.y.abs() * zoom; + let font_px = if font_size.is_finite() { + font_size.clamp(8.0, 96.0) + } else { + 8.0 + }; + let lines: Vec = if data.text.is_empty() { + Vec::new() + } else { + data.text.split('\n').map(ToOwned::to_owned).collect() + }; + let max_chars = lines + .iter() + .map(|line| line.chars().count()) + .max() + .unwrap_or(1) + .max(1); + let width = (max_chars as f32 * font_px * 0.55).max(font_px * 0.4); + let height = (lines.len().max(1) as f32 * font_px * 1.25).max(font_px); + (font_px, vec2(width, height), lines) +} + +// --------------------------------------------------------------------------- +// Scene collection +// --------------------------------------------------------------------------- + +fn collect_draw( + ctx: &egui::Context, + doc: &SceneDoc, + selection: &Selection, + rect: Rect, + camera: &Camera2dState, + project: Option<&Project>, + textures: &mut TextureCache, +) -> Vec { + let mut textures_cache: HashMap> = HashMap::new(); + let order: HashMap = doc + .depth_first_ids() + .into_iter() + .enumerate() + .map(|(index, id)| (id, index)) + .collect(); + + let mut draw = Vec::with_capacity(doc.entities.len()); + for (id, node) in &doc.entities { + if is_effectively_hidden(doc, *id) { + continue; + } + let Some(world) = doc.world_transform(*id) else { + continue; + }; + if !world.translation.is_finite() || !world.scale.is_finite() { + continue; + } + let center = world_to_screen( + Vec2::new(world.translation.x, world.translation.y), + rect, + camera, + ); + let content = if let Some(ComponentData::Sprite(sprite)) = node.component("sprite") { + let (texture, tex_size) = load_sprite_texture( + ctx, + project, + textures, + &mut textures_cache, + sprite.image.as_deref(), + ); + let base_size = sprite + .custom_size + .or_else(|| tex_size.map(|size| Vec2::new(size[0] as f32, size[1] as f32))) + .unwrap_or(Vec2::new(1.0, 1.0)); + sprite_content(center, base_size, &world, camera, sprite, texture) + } else if let Some(ComponentData::Text2d(text)) = node.component("text2d") { + let (font_px, size, lines) = text_metrics(text, &world, safe_zoom(camera.zoom)); + let bounds = Rect::from_center_size(center, size); + DrawContent::Text(TextDraw { + bounds, + font_px, + lines, + color: widgets::color32(text.color), + }) + } else if let Some(kind) = marker_kind(node) { + if !center.x.is_finite() || !center.y.is_finite() { + continue; + } + DrawContent::Marker(MarkerDraw { + center, + label: marker_label(&node.name, &kind), + kind, + }) + } else { + DrawContent::Empty { center } + }; + draw.push(DrawEntity { + id: *id, + name: node.name.clone(), + z: world.translation.z, + order: order.get(id).copied().unwrap_or(usize::MAX), + content, + selected: selection.contains(*id), + locked: node.locked, + }); + } + + // Painter's algorithm: lowest z first, scene order breaks ties. + draw.sort_by(|a, b| a.z.total_cmp(&b.z).then_with(|| a.order.cmp(&b.order))); + draw +} + +fn load_sprite_texture( + ctx: &egui::Context, + project: Option<&Project>, + textures: &mut TextureCache, + cache: &mut HashMap>, + image: Option<&str>, +) -> (Option, Option<[usize; 2]>) { + let Some(image) = image else { + return (None, None); + }; + let Some(project) = project else { + return (None, None); + }; + if let Some(cached) = cache.get(image) { + return match cached { + Some((texture, size)) => (Some(texture.clone()), Some(*size)), + None => (None, None), + }; + } + let path = project.resolve_asset(image); + let loaded = textures + .get_or_load(ctx, &path) + .map(|entry| (entry.texture.clone(), entry.size)); + let result = match &loaded { + Some((texture, size)) => (Some(texture.clone()), Some(*size)), + None => (None, None), + }; + cache.insert(image.to_string(), loaded); + result +} + +fn sprite_content( + center: Pos2, + base_size: Vec2, + world: &Transform, + camera: &Camera2dState, + sprite: &SpriteData, + texture: Option, +) -> DrawContent { + let half_w = (base_size.x * world.scale.x).abs() * 0.5; + let half_h = (base_size.y * world.scale.y).abs() * 0.5; + let min_extent = 0.5 / safe_zoom(camera.zoom); + if !half_w.is_finite() || !half_h.is_finite() || (half_w < min_extent && half_h < min_extent) { + return DrawContent::Empty { center }; + } + let angle = world.rotation.z.to_radians(); + let (sin, cos) = angle.sin_cos(); + let zoom = safe_zoom(camera.zoom); + // Rotate in world space, then convert the offset to screen pixels. The + // screen y axis points down, hence the negative y in the corner mapping. + let corner = |x: f32, y: f32| -> Pos2 { + let rotated_x = x * cos - y * sin; + let rotated_y = x * sin + y * cos; + pos2(center.x + rotated_x * zoom, center.y - rotated_y * zoom) + }; + let corners = [ + corner(-half_w, half_h), + corner(half_w, half_h), + corner(half_w, -half_h), + corner(-half_w, -half_h), + ]; + let bounds = Rect::from_points(&corners); + let paint = match (&sprite.image, &texture) { + (Some(_), Some(_)) => SpritePaint::Textured, + (Some(_), None) => SpritePaint::Missing, + (None, _) => SpritePaint::Solid, + }; + DrawContent::Sprite(SpriteDraw { + corners, + bounds, + paint, + texture, + color: widgets::color32(sprite.color), + flip_x: sprite.flip_x ^ (world.scale.x < 0.0), + flip_y: sprite.flip_y ^ (world.scale.y < 0.0), + image: sprite.image.clone(), + }) +} + +fn marker_kind(node: &bevy_forge_core::EntityNode) -> Option { + for component in &node.components { + match component { + ComponentData::Camera2d => return Some(MarkerKind::Camera2d), + ComponentData::Camera3d => return Some(MarkerKind::Camera3d), + ComponentData::PointLight(_) => return Some(MarkerKind::PointLight), + ComponentData::DirectionalLight(_) => return Some(MarkerKind::DirectionalLight), + ComponentData::Mesh3d(_) => return Some(MarkerKind::Mesh3d), + ComponentData::Custom(custom) => { + return Some(MarkerKind::Custom(custom.short_name().to_string())) + } + _ => {} + } + } + None +} + +fn marker_label(name: &str, kind: &MarkerKind) -> String { + let name = truncate(name, 26); + match kind { + MarkerKind::Custom(short) => format!("{name} · {}", truncate(short, 18)), + _ => name, + } +} + +fn truncate(text: &str, max_chars: usize) -> String { + let mut out: String = text.chars().take(max_chars).collect(); + if text.chars().count() > max_chars { + out.push('…'); + } + out +} + +// --------------------------------------------------------------------------- +// Fit bounds +// --------------------------------------------------------------------------- + +/// Content bounds of one entity in world units (sprites, text and markers). +fn node_content_bounds(doc: &SceneDoc, id: EntityId) -> Option { + let node = doc.entity(id)?; + let world = doc.world_transform(id)?; + if !world.translation.is_finite() || !world.scale.is_finite() { + return None; + } + let base = if let Some(ComponentData::Sprite(sprite)) = node.component("sprite") { + sprite.custom_size.unwrap_or(Vec2::new(1.0, 1.0)) + } else if let Some(ComponentData::Text2d(text)) = node.component("text2d") { + let chars = text.text.chars().count().max(1) as f32; + Vec2::new( + (chars * text.font_size * 0.55).max(text.font_size), + text.font_size * 1.35, + ) + } else if marker_kind(node).is_some() { + Vec2::new(0.8, 0.8) + } else { + return None; + }; + let width = (base.x * world.scale.x).abs(); + let height = (base.y * world.scale.y).abs(); + let angle = world.rotation.z.to_radians(); + let (sin, cos) = angle.sin_cos(); + let extent = Vec2::new( + width * cos.abs() + height * sin.abs(), + width * sin.abs() + height * cos.abs(), + ); + Some(Bounds2::from_center_size( + Vec2::new(world.translation.x, world.translation.y), + extent, + )) +} + +/// Bounds of every visible entity; falls back to `SceneDoc::bounds_2d`. +fn scene_fit_bounds(doc: &SceneDoc) -> Bounds2 { + let mut bounds = Bounds2::empty(); + for id in doc.entities.keys().copied() { + if is_effectively_hidden(doc, id) { + continue; + } + if let Some(node_bounds) = node_content_bounds(doc, id) { + bounds = bounds.union(node_bounds); + } + } + if bounds.is_empty() { + doc.bounds_2d() + } else { + bounds + } +} + +/// Bounds of the current selection, if it contains anything frameable. +fn selection_fit_bounds(doc: &SceneDoc, selection: &Selection) -> Option { + let mut bounds = Bounds2::empty(); + for id in selection.ids() { + if let Some(node_bounds) = node_content_bounds(doc, id) { + bounds = bounds.union(node_bounds); + } + } + (!bounds.is_empty()).then_some(bounds) +} + +fn scene_key(doc: &SceneDoc) -> String { + format!("{}:{}", doc.name, doc.roots.first().copied().unwrap_or(0)) +} + +// --------------------------------------------------------------------------- +// Panel entry point +// --------------------------------------------------------------------------- + +pub fn show(ui: &mut Ui, cx: &mut EditorContext<'_>, state: &mut Viewport2dState) -> Vec { + let mut actions = Vec::new(); + toolbar(ui, cx, state, &mut actions); + + let available = ui.available_size(); + if available.x < 8.0 || available.y < 8.0 { + return actions; + } + let (response, painter) = ui.allocate_painter(available, Sense::click_and_drag()); + let rect = response.rect; + if rect.width() < 8.0 || rect.height() < 8.0 { + return actions; + } + cx.ui.viewport_rect = rect; + + // Frame the scene when it changes, while the surrounding panels settle, and + // whenever Fit is requested. + let key = scene_key(cx.session.doc()); + if state.last_scene.as_deref() != Some(key.as_str()) { + state.last_scene = Some(key); + state.auto_fit = true; + } + if state.auto_fit { + // Keep framing while the surrounding panels settle or the window is + // resized. The first pointer press (or any explicit navigation) takes + // over, after which the camera is entirely under user control. + let bounds = scene_fit_bounds(cx.session.doc()); + fit_camera(&mut state.camera, bounds, rect, FIT_PADDING); + } + if cx.ui.fit_view_requested && state.interaction == Interaction::Idle { + cx.ui.fit_view_requested = false; + let bounds = selection_fit_bounds(cx.session.doc(), &cx.ui.selection) + .unwrap_or_else(|| scene_fit_bounds(cx.session.doc())); + fit_camera(&mut state.camera, bounds, rect, FIT_PADDING); + state.auto_fit = false; + } + if let Some(factor) = state.pending_zoom.take() { + zoom_at(&mut state.camera, rect.center(), rect, factor); + state.auto_fit = false; + } + + // Navigation happens before collection so this frame paints the new camera. + let space_down = ui.input(|input| input.key_down(Key::Space)); + if state.interaction == Interaction::Idle + && (response.drag_started_by(egui::PointerButton::Middle) + || (space_down && response.drag_started_by(egui::PointerButton::Primary))) + { + state.interaction = Interaction::Pan; + state.auto_fit = false; + } + if state.interaction == Interaction::Pan { + if response.dragged_by(egui::PointerButton::Middle) + || response.dragged_by(egui::PointerButton::Primary) + { + let delta = response.drag_delta(); + if delta != egui::Vec2::ZERO { + pan_camera(&mut state.camera, delta); + } + } + if response.drag_stopped_by(egui::PointerButton::Middle) + || response.drag_stopped_by(egui::PointerButton::Primary) + { + state.interaction = Interaction::Idle; + } + } else if state.interaction == Interaction::Idle && response.contains_pointer() { + let (scroll, pinch) = ui.input(|input| (input.smooth_scroll_delta.y, input.zoom_delta())); + let factor = if (pinch - 1.0).abs() > 1e-4 { + pinch + } else if scroll.abs() > 0.01 { + (scroll * 0.0025).exp() + } else { + 1.0 + }; + if (factor - 1.0).abs() > 1e-4 { + let anchor = response.hover_pos().unwrap_or_else(|| rect.center()); + zoom_at(&mut state.camera, anchor, rect, factor); + state.auto_fit = false; + } + } + + let ctx = ui.ctx().clone(); + let mut draw = collect_draw( + &ctx, + cx.session.doc(), + &cx.ui.selection, + rect, + &state.camera, + cx.project, + cx.textures, + ); + let mut hit_regions = build_hit_regions(&draw); + let mut refresh = false; + + let pointer = response.interact_pointer_pos(); + let latest_pointer = pointer.or_else(|| ui.input(|input| input.pointer.latest_pos())); + let shift = ui.input(|input| input.modifiers.shift); + + let gizmo = if state.tool == Tool::Move { + gizmo_layout(cx.session.doc(), &cx.ui.selection, rect, &state.camera) + } else { + None + }; + if state.interaction == Interaction::Idle { + state.hover_gizmo = match (latest_pointer, gizmo) { + (Some(pointer), Some(layout)) if response.contains_pointer() => { + gizmo_hit(layout, pointer).unwrap_or(GizmoHandle::None) + } + _ => GizmoHandle::None, + }; + } else { + state.hover_gizmo = GizmoHandle::None; + } + + let pointer_pressed_on_canvas = + response.contains_pointer() && ui.input(|input| input.pointer.any_pressed()); + if pointer_pressed_on_canvas || response.drag_started() { + response.request_focus(); + state.auto_fit = false; + } + if response.has_focus() + && state.interaction == Interaction::Idle + && ui.input(|input| input.key_pressed(Key::Escape)) + { + if !cx.ui.selection.is_empty() { + cx.ui.selection.clear(); + cx.ui.status = "Selection cleared".into(); + refresh = true; + } + } + if response.has_focus() + && state.interaction == Interaction::Idle + && ui.input_mut(|input| input.consume_key(Modifiers::NONE, Key::F)) + { + cx.ui.fit_view_requested = true; + } + if cx.ui.fit_view_requested && state.interaction == Interaction::Idle { + cx.ui.fit_view_requested = false; + let bounds = selection_fit_bounds(cx.session.doc(), &cx.ui.selection) + .unwrap_or_else(|| scene_fit_bounds(cx.session.doc())); + fit_camera(&mut state.camera, bounds, rect, FIT_PADDING); + state.auto_fit = false; + refresh = true; + } + + // Left-drag starts a gizmo drag, an entity move, or a box selection. + if state.interaction == Interaction::Idle + && response.drag_started_by(egui::PointerButton::Primary) + { + let start_screen = pointer.unwrap_or_else(|| rect.center()) - response.drag_delta(); + let start_world = screen_to_world(start_screen, rect, &state.camera); + let handle = gizmo.and_then(|layout| gizmo_hit(layout, start_screen)); + if let Some(handle) = handle { + start_gizmo_drag(cx, state, handle, start_screen, start_world); + } else if let Some(hit) = hit_test(&hit_regions, start_screen) { + if shift { + cx.ui.selection.toggle(hit); + } else if !cx.ui.selection.contains(hit) { + cx.ui.selection.select_only(hit); + } + refresh = true; + let name = cx + .session + .doc() + .entity(hit) + .map(|node| node.name.clone()) + .unwrap_or_else(|| format!("entity {hit}")); + let locked = cx.session.doc().entity(hit).is_some_and(|node| node.locked); + if !cx.ui.selection.contains(hit) { + cx.ui.status = format!("Deselected {name}"); + } else if locked { + cx.ui.status = format!("{name} is locked"); + } else { + start_entity_drag(cx, state, hit, start_screen, start_world); + } + } else if state.tool == Tool::Select { + state.interaction = Interaction::Marquee; + state.drag_start_pointer = Some(start_screen); + state.marquee = Some(start_screen); + } + } + + // Continuous drags. + match state.interaction { + Interaction::Move | Interaction::Gizmo => { + if let Some(current) = latest_pointer { + if apply_drag( + cx, + state, + rect, + current, + cx.ui.snap_to_grid, + cx.ui.grid_size, + ) { + refresh = true; + } + } + if response.drag_stopped_by(egui::PointerButton::Primary) + || ui.input(|input| input.pointer.any_released()) + { + finish_drag(cx, state); + refresh = true; + } + } + Interaction::Marquee => { + if response.drag_stopped_by(egui::PointerButton::Primary) + || ui.input(|input| input.pointer.any_released()) + { + if let (Some(start), Some(end)) = (state.drag_start_pointer, latest_pointer) { + let selection_rect = Rect::from_two_pos(start, end); + let mut hits: Vec = draw + .iter() + .filter(|entity| entity.bounds().intersects(selection_rect)) + .map(|entity| entity.id) + .collect(); + if shift { + hits.retain(|id| !cx.ui.selection.contains(*id)); + let mut combined: Vec = + cx.ui.selection.as_set().iter().copied().collect(); + let primary = combined.first().copied().or(hits.first().copied()); + combined.extend(hits.iter().copied()); + if combined.is_empty() { + cx.ui.selection.clear(); + } else { + cx.ui.selection.select_many(combined, primary); + } + } else if hits.is_empty() { + cx.ui.selection.clear(); + } else { + let primary = hits.first().copied(); + cx.ui.selection.select_many(hits.drain(..), primary); + } + cx.ui.status = format!("{} selected", cx.ui.selection.len()); + } + state.marquee = None; + state.drag_start_pointer = None; + state.interaction = Interaction::Idle; + refresh = true; + } + } + _ => {} + } + + // Click selection (no drag). + if response.clicked() && state.interaction == Interaction::Idle { + let click_pos = pointer.or_else(|| response.hover_pos()); + if let Some(position) = click_pos { + let on_gizmo = gizmo + .and_then(|layout| gizmo_hit(layout, position)) + .is_some(); + if !on_gizmo { + match hit_test(&hit_regions, position) { + Some(hit) => { + if shift { + cx.ui.selection.toggle(hit); + } else { + cx.ui.selection.select_only(hit); + } + let name = cx + .session + .doc() + .entity(hit) + .map(|node| node.name.clone()) + .unwrap_or_else(|| format!("entity {hit}")); + cx.ui.status = format!("Selected {name}"); + refresh = true; + } + None => { + if !shift && !cx.ui.selection.is_empty() { + cx.ui.selection.clear(); + cx.ui.status = "Selection cleared".into(); + refresh = true; + } + } + } + } + } + } + + if response.clicked() || response.drag_started() { + state.auto_fit = false; + } + + // Asset drop: create a Sprite at the cursor. + let drop_payload = response.dnd_release_payload::(); + let hover_payload = if drop_payload.is_none() { + response.dnd_hover_payload::() + } else { + None + }; + let mut drop_preview = None; + if let Some(payload) = &hover_payload { + if let Some(position) = latest_pointer { + drop_preview = Some(DropPreview { + position, + name: asset_file_name(&payload.path).to_string(), + }); + } + } + if let Some(payload) = drop_payload { + if let Some(position) = latest_pointer.or_else(|| response.hover_pos()) { + let world = screen_to_world(position, rect, &state.camera); + if add_sprite_from_asset(cx, &payload.path, world) { + refresh = true; + drop_preview = None; + } + } + } + + // Cursor feedback and shared UI state. + let pointer_over = response.contains_pointer(); + cx.ui.pointer_over_viewport = pointer_over; + if let Some(position) = + latest_pointer.filter(|_| pointer_over || state.interaction != Interaction::Idle) + { + let world = screen_to_world(position, rect, &state.camera); + if world.is_finite() { + cx.ui.cursor_world = Some(world); + } + } else if !pointer_over { + cx.ui.cursor_world = None; + } + if pointer_over && state.interaction == Interaction::Idle { + const HINT: &str = "Drag to move · MMB pan · wheel zoom"; + if cx.ui.status.is_empty() || cx.ui.status.starts_with("Drag to move") { + cx.ui.status = HINT.into(); + } + if state.hover_gizmo != GizmoHandle::None { + ctx.set_cursor_icon(match state.hover_gizmo { + GizmoHandle::X => CursorIcon::ResizeHorizontal, + GizmoHandle::Y => CursorIcon::ResizeVertical, + _ => CursorIcon::Move, + }); + } + } + match state.interaction { + Interaction::Pan | Interaction::Move | Interaction::Gizmo => { + ctx.set_cursor_icon(CursorIcon::Grabbing); + } + Interaction::Marquee => { + ctx.set_cursor_icon(CursorIcon::Crosshair); + } + Interaction::Idle => { + if state.hover_gizmo == GizmoHandle::None + && pointer_over + && latest_pointer.is_some_and(|position| hit_test(&hit_regions, position).is_some()) + { + ctx.set_cursor_icon(CursorIcon::PointingHand); + } + } + } + + if refresh { + draw = collect_draw( + &ctx, + cx.session.doc(), + &cx.ui.selection, + rect, + &state.camera, + cx.project, + cx.textures, + ); + hit_regions = build_hit_regions(&draw); + } + let hovered = latest_pointer.and_then(|position| { + if pointer_over || state.interaction != Interaction::Idle { + hit_test(&hit_regions, position) + } else { + None + } + }); + + paint( + &painter, + rect, + cx, + state, + &draw, + hovered, + latest_pointer, + drop_preview.as_ref(), + ); + + actions +} + +fn toolbar( + ui: &mut Ui, + cx: &mut EditorContext<'_>, + state: &mut Viewport2dState, + actions: &mut Vec, +) { + egui::Frame::new() + .fill(theme::PANEL) + .corner_radius(5.0) + .inner_margin(egui::Margin::symmetric(6, 3)) + .show(ui, |ui| { + let compact = ui.available_width() < 620.0; + ui.horizontal(|ui| { + ui.label( + egui::RichText::new("2D") + .color(theme::ACCENT) + .strong() + .size(12.0), + ); + ui.separator(); + if !compact + && widgets::toolbar_toggle( + ui, + state.tool == Tool::Select, + "Select", + "Click to select · drag empty space to box-select", + ) + .clicked() + { + state.tool = Tool::Select; + } + if widgets::toolbar_toggle( + ui, + state.tool == Tool::Move, + "Move", + "Drag entities or use the X/Y gizmo", + ) + .clicked() + { + state.tool = Tool::Move; + } + ui.separator(); + if widgets::toolbar_toggle(ui, cx.ui.show_grid, "Grid", "Toggle grid (G)").clicked() + { + actions.push(Action::ToggleGrid); + } + if widgets::toolbar_toggle(ui, cx.ui.snap_to_grid, "Snap", "Snap to grid").clicked() + { + actions.push(Action::ToggleSnap); + } + ui.separator(); + if ui.small_button("-").on_hover_text("Zoom out").clicked() { + state.pending_zoom = Some(1.0 / 1.25); + } + let zoom_label = if state.camera.zoom >= 1.0 { + format!("{:.0} px/u", state.camera.zoom) + } else { + format!("{:.2} px/u", state.camera.zoom) + }; + ui.label( + egui::RichText::new(zoom_label) + .monospace() + .size(11.0) + .color(theme::MUTED), + ) + .on_hover_text(format!( + "{:.2} px per world unit · 100% = {:.0} px/unit", + state.camera.zoom, DEFAULT_ZOOM + )); + if ui.small_button("+").on_hover_text("Zoom in").clicked() { + state.pending_zoom = Some(1.25); + } + if ui + .button("Fit") + .on_hover_text("Frame the selection or the whole scene (F)") + .clicked() + { + cx.ui.fit_view_requested = true; + } + if !compact { + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if widgets::toolbar_toggle(ui, state.show_help, "?", "Show controls") + .clicked() + { + state.show_help = !state.show_help; + } + ui.label( + egui::RichText::new(if cx.ui.snap_to_grid { + format!("snap {:.2}", cx.ui.grid_size) + } else { + "free".to_string() + }) + .size(10.5) + .color(theme::FAINT), + ); + }); + } + }); + }); + ui.add_space(2.0); +} + +struct DropPreview { + position: Pos2, + name: String, +} + +fn asset_file_name(path: &str) -> &str { + path.rsplit('/').next().unwrap_or(path) +} + +fn add_sprite_from_asset(cx: &mut EditorContext<'_>, path: &str, world: Vec2) -> bool { + let kind = bevy_forge_core::AssetKind::from_path(Path::new(path)); + if kind != bevy_forge_core::AssetKind::Image { + cx.toasts.warning(format!("{path} is not an image asset")); + return false; + } + if !world.is_finite() { + return false; + } + let name = unique_name(cx.session.doc(), "Sprite"); + let id = cx.session.edit("Add sprite", |doc| { + let id = doc.add_entity(name.clone(), None); + if let Some(node) = doc.entity_mut(id) { + node.transform = Transform::from_xyz(world.x, world.y, 0.0); + node.components.push(ComponentData::Sprite(SpriteData { + image: Some(path.to_string()), + ..Default::default() + })); + } + id + }); + cx.ui.selection.select_only(id); + cx.ui.status = format!("Added sprite from {path}"); + cx.notify_success(format!( + "Added {} at {:.1}, {:.1}", + asset_file_name(path), + world.x, + world.y + )); + true +} + +fn start_entity_drag( + cx: &mut EditorContext<'_>, + state: &mut Viewport2dState, + hit: EntityId, + start_screen: Pos2, + start_world: Vec2, +) { + let targets = drag_targets(cx.session.doc(), &cx.ui.selection); + if targets.is_empty() { + // Every selected entity is locked. + return; + } + state.interaction = Interaction::Move; + state.dragging = Some(hit); + state.drag_ids = targets; + state.drag_start_pointer = Some(start_screen); + state.drag_start_world = Some(start_world); + state.drag_before = Some(cx.session.doc().clone()); +} + +fn start_gizmo_drag( + cx: &mut EditorContext<'_>, + state: &mut Viewport2dState, + handle: GizmoHandle, + start_screen: Pos2, + start_world: Vec2, +) { + let targets = drag_targets(cx.session.doc(), &cx.ui.selection); + let Some(primary) = cx.ui.selection.primary_or_single() else { + return; + }; + if targets.is_empty() { + return; + } + state.interaction = Interaction::Gizmo; + state.gizmo_handle = handle; + state.dragging = Some(primary); + state.drag_ids = targets; + state.drag_start_pointer = Some(start_screen); + state.drag_start_world = Some(start_world); + state.drag_before = Some(cx.session.doc().clone()); + if let Some(axis) = handle.axis_label() { + cx.ui.status = format!("Move {axis} · release to commit"); + } +} + +/// Apply the active drag. Returns true when the document was changed. +fn apply_drag( + cx: &mut EditorContext<'_>, + state: &mut Viewport2dState, + rect: Rect, + current_pointer: Pos2, + snap: bool, + grid: f32, +) -> bool { + let Some(before) = state.drag_before.as_ref() else { + return false; + }; + let Some(start_world) = state.drag_start_world else { + return false; + }; + let current_world = screen_to_world(current_pointer, rect, &state.camera); + let mut delta = current_world - start_world; + + if state.interaction == Interaction::Gizmo { + match state.gizmo_handle { + GizmoHandle::X => delta.y = 0.0, + GizmoHandle::Y => delta.x = 0.0, + _ => {} + } + } + if snap { + if let Some(primary) = state.dragging { + if let Some(primary_start) = before.world_transform(primary) { + let start = Vec2::new(primary_start.translation.x, primary_start.translation.y); + let snapped = snap_vec2(start + delta, grid); + delta = snapped - start; + if state.interaction == Interaction::Gizmo { + match state.gizmo_handle { + GizmoHandle::X => delta.y = 0.0, + GizmoHandle::Y => delta.x = 0.0, + _ => {} + } + } + } + } + } + if !delta.is_finite() || delta.length_squared() < 1e-12 { + return false; + } + + let ids = state.drag_ids.clone(); + let doc = cx.session.doc_mut(); + let mut changed = false; + for id in ids { + let Some(before_world) = before.world_transform(id) else { + continue; + }; + let before_parent = before.parent_world_transform(id); + let target_world = Vec3::new( + before_world.translation.x + delta.x, + before_world.translation.y + delta.y, + before_world.translation.z, + ); + let local = local_translation(&before_parent, target_world); + if let Some(node) = doc.entity_mut(id) { + if node.transform.translation != local { + node.transform.translation = local; + changed = true; + } + } + } + changed +} + +fn finish_drag(cx: &mut EditorContext<'_>, state: &mut Viewport2dState) { + if let Some(before) = state.drag_before.take() { + let changed = cx.session.doc() != &before; + let count = state.drag_ids.len(); + cx.session.commit(before, "Move entities"); + if changed { + cx.ui.status = format!( + "Moved {count} {}", + if count == 1 { "entity" } else { "entities" } + ); + } + } + state.dragging = None; + state.drag_ids.clear(); + state.drag_start_pointer = None; + state.drag_start_world = None; + state.gizmo_handle = GizmoHandle::None; + state.interaction = Interaction::Idle; +} + +// --------------------------------------------------------------------------- +// Painting +// --------------------------------------------------------------------------- + +fn paint( + painter: &egui::Painter, + rect: Rect, + cx: &EditorContext<'_>, + state: &Viewport2dState, + draw: &[DrawEntity], + hovered: Option, + pointer: Option, + drop: Option<&DropPreview>, +) { + paint_background(painter, rect); + if cx.ui.show_grid { + paint_grid( + painter, + rect, + &state.camera, + cx.ui.grid_size, + cx.ui.snap_to_grid, + ); + } + + for entity in draw { + paint_entity(painter, entity); + } + + // Hover feedback under selection outlines. + if let Some(hovered) = hovered { + if let Some(entity) = draw.iter().find(|entity| entity.id == hovered) { + if !entity.selected { + paint_outline( + painter, + entity, + Stroke::new(1.5_f32, theme::ACCENT_DIM), + false, + ); + } + } + } + + let primary = cx.ui.selection.primary_or_single(); + for entity in draw.iter().filter(|entity| entity.selected) { + paint_outline( + painter, + entity, + Stroke::new(2.0_f32, theme::SELECTION), + Some(entity.id) == primary, + ); + if entity.locked { + let bounds = entity.bounds(); + paint_lock( + painter, + bounds.right_top() + vec2(-9.0, 9.0), + theme::WARNING, + ); + } + } + for entity in draw + .iter() + .filter(|entity| entity.locked && !entity.selected) + { + let bounds = entity.bounds(); + paint_lock(painter, bounds.right_top() + vec2(-7.0, 7.0), theme::FAINT); + } + + if state.tool == Tool::Move { + if let Some(layout) = gizmo_layout(cx.session.doc(), &cx.ui.selection, rect, &state.camera) + { + let active = (state.interaction == Interaction::Gizmo) + .then_some(state.gizmo_handle) + .filter(|handle| *handle != GizmoHandle::None); + paint_gizmo(painter, layout, active, state.hover_gizmo); + } + } + + if state.interaction == Interaction::Marquee { + if let (Some(start), Some(end)) = (state.marquee, pointer) { + let marquee = Rect::from_two_pos(start, end); + painter.rect_filled( + marquee, + 1.0, + Color32::from_rgba_unmultiplied( + theme::ACCENT.r(), + theme::ACCENT.g(), + theme::ACCENT.b(), + 28, + ), + ); + painter.rect_stroke( + marquee, + 1.0, + Stroke::new(1.0_f32, theme::ACCENT), + StrokeKind::Inside, + ); + } + } + + if let Some(drop) = drop { + painter.rect_stroke( + rect.shrink(2.0), + 6.0, + Stroke::new(2.0_f32, theme::ACCENT), + StrokeKind::Inside, + ); + let ghost = Rect::from_center_size(drop.position, vec2(84.0, 64.0)); + painter.rect_filled( + ghost, + 4.0, + Color32::from_rgba_unmultiplied( + theme::ACCENT.r(), + theme::ACCENT.g(), + theme::ACCENT.b(), + 36, + ), + ); + painter.rect_stroke( + ghost, + 4.0, + Stroke::new(1.0_f32, theme::ACCENT), + StrokeKind::Inside, + ); + paint_tag( + painter, + ghost.center_bottom() + vec2(0.0, 5.0), + Align2::CENTER_TOP, + &format!("Add {} as Sprite", drop.name), + theme::ACCENT, + true, + ); + } + + if cx.session.doc().entities.is_empty() { + paint_empty_hint(painter, rect); + } + + if state.show_help { + paint_help(painter, rect); + } + + // Subtle border and inner vignette. + for (index, alpha) in [7u8, 6, 5].iter().enumerate() { + painter.rect_stroke( + rect.shrink(index as f32 * 5.0 + 1.0), + 0.0, + Stroke::new(5.0_f32, Color32::from_black_alpha(*alpha)), + StrokeKind::Inside, + ); + } + painter.rect_stroke( + rect, + 0.0, + Stroke::new(1.0_f32, theme::BORDER), + StrokeKind::Inside, + ); +} + +fn paint_background(painter: &egui::Painter, rect: Rect) { + let mut mesh = egui::Mesh::default(); + let top = Color32::from_rgb(0x0d, 0x0f, 0x15); + let bottom = Color32::from_rgb(0x14, 0x17, 0x21); + mesh.colored_vertex(rect.left_top(), top); + mesh.colored_vertex(rect.right_top(), top); + mesh.colored_vertex(rect.left_bottom(), bottom); + mesh.colored_vertex(rect.right_bottom(), bottom); + mesh.add_triangle(0, 1, 2); + mesh.add_triangle(2, 1, 3); + painter.add(egui::Shape::mesh(mesh)); +} + +fn paint_grid( + painter: &egui::Painter, + rect: Rect, + camera: &Camera2dState, + grid_size: f32, + snap: bool, +) { + let zoom = safe_zoom(camera.zoom); + let mut step = if grid_size.is_finite() && grid_size > 1e-4 { + grid_size + } else { + 1.0 + }; + // Keep minor lines between 8 and 32 screen pixels apart. + let mut guard = 0; + while step * zoom < 8.0 && guard < 64 { + step *= 2.0; + guard += 1; + } + while step * zoom > 32.0 && guard < 128 { + step /= 2.0; + guard += 1; + } + if !step.is_finite() || step <= 0.0 { + return; + } + let major = step * 5.0; + let left = screen_to_world(rect.left_top(), rect, camera).x; + let right = screen_to_world(rect.right_top(), rect, camera).x; + let bottom = screen_to_world(rect.left_bottom(), rect, camera).y; + let top = screen_to_world(rect.left_top(), rect, camera).y; + + let minor_stroke = Stroke::new(1.0_f32, theme::GRID_MINOR); + let major_stroke = Stroke::new(1.0_f32, theme::GRID_MAJOR); + draw_grid_lines( + painter, + rect, + camera, + left, + right, + bottom, + top, + step, + minor_stroke, + ); + draw_grid_lines( + painter, + rect, + camera, + left, + right, + bottom, + top, + major, + major_stroke, + ); + + // Origin axes: X red, Y green. Stronger than grid lines so the user can + // orient around (0, 0) at a glance. + let origin = world_to_screen(Vec2::ZERO, rect, camera); + if origin.x >= rect.left() && origin.x <= rect.right() { + painter.line_segment( + [ + pos2(origin.x.round(), rect.top()), + pos2(origin.x.round(), rect.bottom()), + ], + Stroke::new( + 1.0_f32, + Color32::from_rgba_unmultiplied( + theme::Y_AXIS.r(), + theme::Y_AXIS.g(), + theme::Y_AXIS.b(), + 150, + ), + ), + ); + painter.text( + pos2(origin.x + 4.0, rect.bottom() - 4.0), + Align2::LEFT_BOTTOM, + "Y", + FontId::proportional(10.0), + theme::Y_AXIS, + ); + } + if origin.y >= rect.top() && origin.y <= rect.bottom() { + painter.line_segment( + [ + pos2(rect.left(), origin.y.round()), + pos2(rect.right(), origin.y.round()), + ], + Stroke::new( + 1.0_f32, + Color32::from_rgba_unmultiplied( + theme::X_AXIS.r(), + theme::X_AXIS.g(), + theme::X_AXIS.b(), + 150, + ), + ), + ); + painter.text( + pos2(rect.right() - 4.0, origin.y - 4.0), + Align2::RIGHT_BOTTOM, + "X", + FontId::proportional(10.0), + theme::X_AXIS, + ); + } + + // Major interval labels along the bottom and left edges (bounded count). + let major_count = ((right - left) / major).abs().min(64.0) as usize; + if major_count <= 24 { + let start = (left / major).ceil() as i64; + let end = (right / major).floor() as i64; + for index in start..=end { + let value = index as f32 * major; + if value.abs() < major * 0.5 { + continue; + } + let x = world_to_screen(Vec2::new(value, 0.0), rect, camera) + .x + .round(); + painter.text( + pos2(x + 3.0, rect.bottom() - 3.0), + Align2::LEFT_BOTTOM, + format_axis(value), + FontId::proportional(9.0), + theme::FAINT, + ); + } + let start = (bottom / major).ceil() as i64; + let end = (top / major).floor() as i64; + for index in start..=end { + let value = index as f32 * major; + if value.abs() < major * 0.5 { + continue; + } + let y = world_to_screen(Vec2::new(0.0, value), rect, camera) + .y + .round(); + painter.text( + pos2(rect.left() + 3.0, y - 2.0), + Align2::LEFT_BOTTOM, + format_axis(value), + FontId::proportional(9.0), + theme::FAINT, + ); + } + } + if snap { + painter.circle_filled( + pos2(rect.right() - 14.0, rect.top() + 14.0), + 3.0, + theme::ACCENT, + ); + } +} + +#[allow(clippy::too_many_arguments)] +fn draw_grid_lines( + painter: &egui::Painter, + rect: Rect, + camera: &Camera2dState, + left: f32, + right: f32, + bottom: f32, + top: f32, + step: f32, + stroke: Stroke, +) { + let first = (left / step).ceil() as i64; + let last = (right / step).floor() as i64; + let count = (last - first).max(0); + if count > 4000 { + return; + } + for index in first..=last { + let value = index as f32 * step; + let x = world_to_screen(Vec2::new(value, 0.0), rect, camera) + .x + .round(); + if x >= rect.left() - 1.0 && x <= rect.right() + 1.0 { + painter.line_segment([pos2(x, rect.top()), pos2(x, rect.bottom())], stroke); + } + } + let first = (bottom / step).ceil() as i64; + let last = (top / step).floor() as i64; + let count = (last - first).max(0); + if count > 4000 { + return; + } + for index in first..=last { + let value = index as f32 * step; + let y = world_to_screen(Vec2::new(0.0, value), rect, camera) + .y + .round(); + if y >= rect.top() - 1.0 && y <= rect.bottom() + 1.0 { + painter.line_segment([pos2(rect.left(), y), pos2(rect.right(), y)], stroke); + } + } +} + +fn format_axis(value: f32) -> String { + let magnitude = value.abs(); + if magnitude >= 1000.0 { + format!("{value:.0}") + } else if magnitude >= 10.0 { + format!("{value:.0}") + } else if magnitude >= 1.0 { + format!("{value:.1}") + } else { + format!("{value:.2}") + } +} + +fn paint_entity(painter: &egui::Painter, entity: &DrawEntity) { + match &entity.content { + DrawContent::Sprite(sprite) => paint_sprite(painter, sprite), + DrawContent::Text(text) => paint_text(painter, text), + DrawContent::Marker(marker) => paint_marker(painter, marker), + DrawContent::Empty { center } => { + painter.circle_filled(*center, 2.5, theme::FAINT); + } + } +} + +fn paint_sprite(painter: &egui::Painter, sprite: &SpriteDraw) { + match sprite.paint { + SpritePaint::Textured => { + if let Some(texture) = &sprite.texture { + paint_textured_quad(painter, sprite, texture); + } + } + SpritePaint::Solid => { + if sprite.color.a() == 0 { + return; + } + painter.add(egui::Shape::convex_polygon( + sprite.corners.to_vec(), + sprite.color, + Stroke::new(1.0_f32, Color32::from_black_alpha(70)), + )); + } + SpritePaint::Missing => paint_missing_texture(painter, sprite), + } +} + +fn paint_textured_quad(painter: &egui::Painter, sprite: &SpriteDraw, texture: &TextureHandle) { + let color = sprite.color; + if color.a() == 0 { + return; + } + let u0 = if sprite.flip_x { 1.0 } else { 0.0 }; + let u1 = 1.0 - u0; + let v0 = if sprite.flip_y { 1.0 } else { 0.0 }; + let v1 = 1.0 - v0; + let mut mesh = egui::Mesh::default(); + mesh.texture_id = texture.id(); + mesh.vertices.extend_from_slice(&[ + egui::epaint::Vertex { + pos: sprite.corners[0], + uv: pos2(u0, v0), + color, + }, + egui::epaint::Vertex { + pos: sprite.corners[1], + uv: pos2(u1, v0), + color, + }, + egui::epaint::Vertex { + pos: sprite.corners[2], + uv: pos2(u1, v1), + color, + }, + egui::epaint::Vertex { + pos: sprite.corners[3], + uv: pos2(u0, v1), + color, + }, + ]); + mesh.indices.extend_from_slice(&[0, 1, 2, 0, 2, 3]); + painter.add(egui::Shape::mesh(mesh)); +} + +fn paint_missing_texture(painter: &egui::Painter, sprite: &SpriteDraw) { + let bounds = sprite.bounds.intersect(painter.clip_rect()); + if bounds.width() <= 1.0 || bounds.height() <= 1.0 { + return; + } + painter.rect_filled(bounds, 2.0, Color32::from_rgb(0x25, 0x29, 0x34)); + let cell = (bounds.width().max(bounds.height()) / CHECKER_CELLS as f32).max(6.0); + let mut row = 0; + let mut y = bounds.top(); + while y < bounds.bottom() && row < CHECKER_CELLS * 2 { + let mut col = 0; + let mut x = bounds.left(); + while x < bounds.right() && col < CHECKER_CELLS * 2 { + if (row + col) % 2 == 0 { + painter.rect_filled( + Rect::from_min_size( + pos2(x, y), + vec2(cell.min(bounds.right() - x), cell.min(bounds.bottom() - y)), + ), + 0.0, + Color32::from_rgb(0x33, 0x38, 0x46), + ); + } + x += cell; + col += 1; + } + y += cell; + row += 1; + } + painter.line_segment( + [bounds.left_top(), bounds.right_bottom()], + Stroke::new(1.0_f32, Color32::from_black_alpha(90)), + ); + painter.line_segment( + [bounds.right_top(), bounds.left_bottom()], + Stroke::new(1.0_f32, Color32::from_black_alpha(90)), + ); + painter.rect_stroke( + bounds, + 2.0, + Stroke::new(1.0_f32, theme::WARNING), + StrokeKind::Inside, + ); + if bounds.width() > 88.0 && bounds.height() > 26.0 { + let label = sprite + .image + .as_deref() + .map(asset_file_name) + .unwrap_or("missing texture"); + painter.text( + bounds.center(), + Align2::CENTER_CENTER, + truncate(label, 28), + FontId::proportional(10.5), + theme::WARNING, + ); + } +} + +fn paint_text(painter: &egui::Painter, text: &TextDraw) { + if text.lines.is_empty() { + if text.bounds.width() > 12.0 { + painter.rect_stroke( + text.bounds, + 2.0, + Stroke::new(1.0_f32, theme::FAINT), + StrokeKind::Inside, + ); + painter.text( + text.bounds.center(), + Align2::CENTER_CENTER, + "empty text", + FontId::proportional(9.0), + theme::FAINT, + ); + } + return; + } + let line_height = text.font_px * 1.25; + let top = text.bounds.center().y - (text.lines.len() as f32 * line_height) * 0.5; + for (index, line) in text.lines.iter().enumerate() { + let position = pos2( + text.bounds.center().x, + top + (index as f32 + 0.5) * line_height, + ); + painter.text( + position + vec2(1.0, 1.0), + Align2::CENTER_CENTER, + line, + FontId::proportional(text.font_px), + Color32::from_black_alpha(120), + ); + painter.text( + position, + Align2::CENTER_CENTER, + line, + FontId::proportional(text.font_px), + text.color, + ); + } +} + +fn paint_marker(painter: &egui::Painter, marker: &MarkerDraw) { + let center = marker.center; + match &marker.kind { + MarkerKind::Camera2d | MarkerKind::Camera3d => { + let color = if matches!(marker.kind, MarkerKind::Camera2d) { + theme::ACCENT + } else { + theme::Z_AXIS + }; + let body = Rect::from_center_size(center, vec2(22.0, 14.0)); + painter.rect_filled(body, 3.0, Color32::from_rgb(0x1c, 0x20, 0x2a)); + painter.rect_stroke(body, 3.0, Stroke::new(1.5_f32, color), StrokeKind::Inside); + painter.circle_filled(center, 3.5, color); + painter.line_segment( + [center + vec2(8.0, 0.0), center + vec2(13.0, -4.0)], + Stroke::new(1.0_f32, color), + ); + painter.text( + body.left_top() + vec2(2.0, 1.0), + Align2::LEFT_TOP, + if matches!(marker.kind, MarkerKind::Camera2d) { + "2D" + } else { + "3D" + }, + FontId::proportional(7.0), + color, + ); + } + MarkerKind::PointLight | MarkerKind::DirectionalLight => { + let color = theme::WARNING; + painter.circle_filled(center, 5.0, color); + painter.circle_stroke(center, 6.5, Stroke::new(1.0_f32, color)); + if matches!(marker.kind, MarkerKind::PointLight) { + for index in 0..8 { + let angle = index as f32 * std::f32::consts::TAU / 8.0; + let direction = egui::vec2(angle.cos(), angle.sin()); + painter.line_segment( + [center + direction * 8.0, center + direction * 11.0], + Stroke::new(1.0_f32, color), + ); + } + } else { + for offset in [-4.0f32, 0.0, 4.0] { + painter.line_segment( + [ + center + vec2(8.0, offset - 3.0), + center + vec2(14.0, offset - 3.0), + ], + Stroke::new(1.0_f32, color), + ); + } + } + } + MarkerKind::Custom(short) => { + let points = vec![ + center + vec2(0.0, -8.0), + center + vec2(8.0, 0.0), + center + vec2(0.0, 8.0), + center + vec2(-8.0, 0.0), + ]; + painter.add(egui::Shape::convex_polygon( + points, + Color32::from_rgba_unmultiplied( + theme::ACCENT.r(), + theme::ACCENT.g(), + theme::ACCENT.b(), + 70, + ), + Stroke::new(1.5_f32, theme::ACCENT), + )); + painter.text( + center, + Align2::CENTER_CENTER, + truncate(short, 3), + FontId::proportional(7.0), + theme::TEXT, + ); + } + MarkerKind::Mesh3d => { + let top = vec![ + center + vec2(0.0, -9.0), + center + vec2(9.0, -4.0), + center + vec2(0.0, 1.0), + center + vec2(-9.0, -4.0), + ]; + let left = vec![ + center + vec2(-9.0, -4.0), + center + vec2(0.0, 1.0), + center + vec2(0.0, 10.0), + center + vec2(-9.0, 5.0), + ]; + let right = vec![ + center + vec2(0.0, 1.0), + center + vec2(9.0, -4.0), + center + vec2(9.0, 5.0), + center + vec2(0.0, 10.0), + ]; + painter.add(egui::Shape::convex_polygon( + top, + Color32::from_rgba_unmultiplied(0x6c, 0xa8, 0xff, 120), + Stroke::new(1.0_f32, theme::Z_AXIS), + )); + painter.add(egui::Shape::convex_polygon( + left, + Color32::from_rgba_unmultiplied(0x35, 0x5f, 0x99, 150), + Stroke::new(1.0_f32, theme::Z_AXIS), + )); + painter.add(egui::Shape::convex_polygon( + right, + Color32::from_rgba_unmultiplied(0x46, 0x78, 0xbf, 130), + Stroke::new(1.0_f32, theme::Z_AXIS), + )); + } + } + paint_tag( + painter, + center + vec2(0.0, 20.0), + Align2::CENTER_TOP, + &marker.label, + theme::MUTED, + true, + ); +} + +fn paint_outline(painter: &egui::Painter, entity: &DrawEntity, stroke: Stroke, with_label: bool) { + match &entity.content { + DrawContent::Sprite(sprite) => { + painter.add(egui::Shape::closed_line(sprite.corners.to_vec(), stroke)); + if with_label { + paint_entity_label(painter, sprite.bounds, &entity.name, entity.locked); + } + } + DrawContent::Text(text) => { + painter.rect_stroke(text.bounds.expand(2.0), 2.0, stroke, StrokeKind::Inside); + if with_label { + paint_entity_label(painter, text.bounds, &entity.name, entity.locked); + } + } + DrawContent::Marker(marker) => { + painter.circle_stroke(marker.center, MARKER_HIT_RADIUS + 3.0, stroke); + if with_label { + paint_entity_label( + painter, + Rect::from_center_size(marker.center, vec2(28.0, 20.0)), + &entity.name, + entity.locked, + ); + } + } + DrawContent::Empty { center } => { + painter.circle_stroke(*center, 7.0, stroke); + painter.circle_filled( + *center, + 2.5, + if entity.selected { + theme::SELECTION + } else { + theme::FAINT + }, + ); + if with_label { + paint_tag( + painter, + *center + vec2(0.0, -10.0), + Align2::CENTER_BOTTOM, + &truncate(&entity.name, 32), + theme::SELECTION, + true, + ); + } + } + } +} + +fn paint_entity_label(painter: &egui::Painter, bounds: Rect, name: &str, locked: bool) { + let text = if locked { + format!("{name} · locked") + } else { + name.to_string() + }; + paint_tag( + painter, + bounds.center_top() + vec2(0.0, -4.0), + Align2::CENTER_BOTTOM, + &truncate(&text, 40), + theme::SELECTION, + true, + ); +} + +fn paint_tag( + painter: &egui::Painter, + position: Pos2, + align: Align2, + text: &str, + color: Color32, + filled: bool, +) { + let galley = painter.layout_no_wrap(text.to_owned(), FontId::proportional(10.5), color); + let size = galley.size() + vec2(8.0, 3.0); + let rect = align.anchor_size(position, size); + if filled { + painter.rect_filled(rect, 3.0, Color32::from_black_alpha(175)); + painter.rect_stroke( + rect, + 3.0, + Stroke::new( + 1.0_f32, + Color32::from_rgba_unmultiplied(color.r(), color.g(), color.b(), 80), + ), + StrokeKind::Inside, + ); + } + painter.galley(rect.min + vec2(4.0, 1.5), galley, color); +} + +fn paint_lock(painter: &egui::Painter, center: Pos2, color: Color32) { + let body = Rect::from_center_size(center + vec2(0.0, 1.5), vec2(7.0, 6.0)); + painter.rect_filled(body, 1.0, color); + let shackle: Vec = (0..=6) + .map(|index| { + let angle = std::f32::consts::PI * (index as f32 / 6.0); + center + vec2(3.0 * angle.cos(), -1.5 - 3.0 * angle.sin()) + }) + .collect(); + painter.add(egui::Shape::line(shackle, Stroke::new(1.0_f32, color))); +} + +fn paint_gizmo( + painter: &egui::Painter, + layout: GizmoLayout, + active: Option, + hovered: GizmoHandle, +) { + let highlighted = active.or((hovered != GizmoHandle::None).then_some(hovered)); + let center = layout.center; + let axes = [ + (GizmoHandle::X, vec2(1.0, 0.0), theme::X_AXIS, "X"), + (GizmoHandle::Y, vec2(0.0, -1.0), theme::Y_AXIS, "Y"), + ]; + for (handle, direction, color, label) in axes { + let hot = highlighted == Some(handle); + let start = center + direction * GIZMO_GAP; + let end = center + direction * GIZMO_LEN; + let stroke = Stroke::new( + if hot { 3.0_f32 } else { 2.0_f32 }, + if hot { Color32::WHITE } else { color }, + ); + painter.line_segment([start, end], stroke); + let base = end - direction * 9.0; + let perpendicular = vec2(-direction.y, direction.x) * 5.0; + painter.add(egui::Shape::convex_polygon( + vec![end, base + perpendicular, base - perpendicular], + if hot { Color32::WHITE } else { color }, + Stroke::new(0.5_f32, Color32::from_black_alpha(80)), + )); + painter.text( + end + direction * 10.0, + Align2::CENTER_CENTER, + label, + FontId::proportional(10.5), + color, + ); + } + let free_hot = highlighted == Some(GizmoHandle::Free); + painter.circle_filled( + center, + if free_hot { 7.0 } else { 5.5 }, + if free_hot { + Color32::WHITE + } else { + theme::ACCENT + }, + ); + painter.circle_stroke( + center, + if free_hot { 9.0 } else { 7.5 }, + Stroke::new(1.0_f32, Color32::from_white_alpha(190)), + ); +} + +fn paint_empty_hint(painter: &egui::Painter, rect: Rect) { + let center = rect.center(); + painter.circle_stroke( + center + vec2(0.0, -42.0), + 18.0, + Stroke::new(1.5_f32, theme::FAINT), + ); + painter.line_segment( + [center + vec2(-7.0, -42.0), center + vec2(7.0, -42.0)], + Stroke::new(1.5_f32, theme::FAINT), + ); + painter.line_segment( + [center + vec2(0.0, -49.0), center + vec2(0.0, -35.0)], + Stroke::new(1.5_f32, theme::FAINT), + ); + painter.text( + center + vec2(0.0, -8.0), + Align2::CENTER_CENTER, + "Empty 2D scene", + FontId::proportional(16.0), + theme::TEXT, + ); + painter.text( + center + vec2(0.0, 14.0), + Align2::CENTER_CENTER, + "Add a sprite from the Hierarchy panel, or drop an image here.", + FontId::proportional(12.0), + theme::MUTED, + ); + painter.text( + center + vec2(0.0, 34.0), + Align2::CENTER_CENTER, + "Tip: select an entity in the Hierarchy to see it framed here.", + FontId::proportional(11.0), + theme::FAINT, + ); +} + +fn paint_help(painter: &egui::Painter, rect: Rect) { + let lines = [ + "Drag to move · MMB pan · wheel zoom", + "Space+drag pan · F fit · Esc deselect", + ]; + let font = FontId::proportional(10.5); + let mut width: f32 = 0.0; + let mut galleys = Vec::new(); + for line in lines { + let galley = painter.layout_no_wrap(line.to_owned(), font.clone(), theme::MUTED); + width = width.max(galley.size().x); + galleys.push(galley); + } + let size = vec2(width + 16.0, galleys.len() as f32 * 14.0 + 9.0); + let help = Rect::from_min_size( + pos2(rect.left() + 10.0, rect.bottom() - 10.0 - size.y), + size, + ); + painter.rect_filled(help, 4.0, Color32::from_black_alpha(150)); + painter.rect_stroke( + help, + 4.0, + Stroke::new( + 1.0_f32, + Color32::from_rgba_unmultiplied( + theme::BORDER.r(), + theme::BORDER.g(), + theme::BORDER.b(), + 180, + ), + ), + StrokeKind::Inside, + ); + for (index, galley) in galleys.into_iter().enumerate() { + painter.galley( + pos2(help.left() + 8.0, help.top() + 5.0 + index as f32 * 14.0), + galley, + theme::MUTED, + ); + } +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + use crate::state::{ExportState, UiState}; + use crate::toast::Toasts; + use bevy_forge_core::{samples, AssetIndex, Color, Session}; + use egui::{vec2, Rect}; + + fn headless_input() -> egui::RawInput { + egui::RawInput { + screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(900.0, 640.0))), + ..Default::default() + } + } + + fn rectangle() -> Rect { + Rect::from_min_size(pos2(10.0, 20.0), vec2(800.0, 600.0)) + } + + fn camera() -> Camera2dState { + Camera2dState { + center: Vec2::new(2.0, -3.0), + zoom: 50.0, + } + } + + fn approx(a: f32, b: f32, epsilon: f32) -> bool { + (a - b).abs() <= epsilon + } + + #[test] + fn world_to_screen_flips_y_and_centers_camera() { + let rect = rectangle(); + let camera = camera(); + let origin = world_to_screen(camera.center, rect, &camera); + assert!(approx(origin.x, rect.center().x, 1e-4)); + assert!(approx(origin.y, rect.center().y, 1e-4)); + + let point = world_to_screen(Vec2::new(4.0, 1.0), rect, &camera); + assert!(approx(point.x, rect.center().x + 2.0 * 50.0, 1e-4)); + // World +Y is screen up, so a point above the camera has a smaller y. + assert!(approx(point.y, rect.center().y - 4.0 * 50.0, 1e-4)); + assert!(point.y < rect.center().y); + } + + #[test] + fn screen_to_world_is_the_inverse() { + let rect = rectangle(); + let camera = camera(); + for (x, y) in [(0.0, 0.0), (-12.5, 7.25), (1e3, -1e3)] { + let world = Vec2::new(x, y); + let screen = world_to_screen(world, rect, &camera); + let back = screen_to_world(screen, rect, &camera); + assert!(approx(back.x, world.x, 1e-3), "{back:?} != {world:?}"); + assert!(approx(back.y, world.y, 1e-3), "{back:?} != {world:?}"); + } + } + + #[test] + fn zoom_at_cursor_keeps_the_world_point_still() { + let rect = rectangle(); + let mut camera = camera(); + let anchor = pos2(180.0, 470.0); + let before = screen_to_world(anchor, rect, &camera); + zoom_at(&mut camera, anchor, rect, 3.0); + let after = screen_to_world(anchor, rect, &camera); + assert!(approx(before.x, after.x, 1e-3), "{before:?} != {after:?}"); + assert!(approx(before.y, after.y, 1e-3), "{before:?} != {after:?}"); + assert!(approx(camera.zoom, 150.0, 1e-3)); + } + + #[test] + fn fit_can_frame_pixel_scale_scenes_below_the_interactive_limit() { + let rect = rectangle(); + let mut camera = Camera2dState::default(); + let bounds = Bounds2::from_center_size(Vec2::ZERO, Vec2::new(960.0, 540.0)); + fit_camera(&mut camera, bounds, rect, 40.0); + assert!(camera.zoom < ZOOM_MIN, "{:?}", camera.zoom); + assert!(camera.zoom >= FIT_ZOOM_MIN); + // The wheel keeps working smoothly from the overview instead of jumping + // back to the interactive minimum. + let anchor = rect.center(); + zoom_at(&mut camera, anchor, rect, 1.25); + assert!(camera.zoom < ZOOM_MIN); + zoom_at(&mut camera, anchor, rect, 100.0); + assert!(camera.zoom > ZOOM_MIN); + } + + #[test] + fn zoom_is_clamped_to_the_supported_range() { + let rect = rectangle(); + let mut camera = camera(); + zoom_at(&mut camera, rect.center(), rect, 1e9); + assert_eq!(camera.zoom, ZOOM_MAX); + zoom_at(&mut camera, rect.center(), rect, 1e-9); + assert_eq!(camera.zoom, ZOOM_MIN); + let mut broken = Camera2dState { + center: Vec2::ZERO, + zoom: 10.0, + }; + zoom_at(&mut broken, rect.center(), rect, f32::NAN); + assert_eq!(broken.zoom, 10.0); + assert!(clamp_zoom(f32::INFINITY) <= ZOOM_MAX); + } + + #[test] + fn set_zoom_preserves_anchor_world_position() { + let rect = rectangle(); + let mut camera = camera(); + let anchor = pos2(77.0, 123.0); + let before = screen_to_world(anchor, rect, &camera); + set_zoom_at(&mut camera, anchor, rect, 7.5); + let after = screen_to_world(anchor, rect, &camera); + assert!(approx(before.x, after.x, 1e-3)); + assert!(approx(before.y, after.y, 1e-3)); + assert!(approx(camera.zoom, 7.5, 1e-3)); + } + + #[test] + fn fit_camera_frames_bounds_with_padding() { + let rect = rectangle(); + let mut camera = Camera2dState { + center: Vec2::new(99.0, 99.0), + zoom: 1.0, + }; + let bounds = Bounds2::from_center_size(Vec2::new(4.0, 2.0), Vec2::new(10.0, 5.0)); + fit_camera(&mut camera, bounds, rect, 40.0); + assert_eq!(camera.center, Vec2::new(4.0, 2.0)); + assert!(approx(camera.zoom, (800.0 - 80.0) / 10.0, 1e-3)); + // Tall content uses the vertical constraint. + let bounds = Bounds2::from_center_size(Vec2::ZERO, Vec2::new(2.0, 100.0)); + fit_camera(&mut camera, bounds, rect, 40.0); + assert!(approx(camera.zoom, 5.2, 1e-3)); + } + + #[test] + fn fit_camera_handles_empty_and_degenerate_bounds() { + let rect = rectangle(); + let mut empty = camera(); + fit_camera(&mut empty, Bounds2::empty(), rect, 40.0); + assert_eq!(empty.center, Vec2::ZERO); + assert_eq!(empty.zoom, DEFAULT_ZOOM); + + let mut degenerate = camera(); + fit_camera( + &mut degenerate, + Bounds2::from_center_size(Vec2::ZERO, Vec2::ZERO), + rect, + 40.0, + ); + assert_eq!(degenerate.zoom, DEFAULT_ZOOM); + } + + #[test] + fn snapping_rounds_to_the_nearest_grid_multiple() { + assert!(approx(snap_scalar(1.24, 0.5), 1.0, 1e-5)); + assert!(approx(snap_scalar(1.26, 0.5), 1.5, 1e-5)); + assert!(approx(snap_scalar(-0.3, 1.0), 0.0, 1e-5)); + assert_eq!(snap_scalar(3.2, 0.0), 3.2); + assert_eq!(snap_scalar(3.2, -1.0), 3.2); + let snapped = snap_vec2(Vec2::new(1.4, -2.6), 1.0); + assert_eq!(snapped, Vec2::new(1.0, -3.0)); + } + + #[test] + fn hit_test_picks_the_topmost_region() { + let under = HitRegion::rect(1, Rect::from_min_max(pos2(0.0, 0.0), pos2(50.0, 50.0))); + let over = HitRegion::rect(2, Rect::from_min_max(pos2(20.0, 20.0), pos2(80.0, 80.0))); + let outside = HitRegion::rect( + 3, + Rect::from_min_max(pos2(200.0, 200.0), pos2(250.0, 250.0)), + ); + let regions = vec![under, over, outside]; + assert_eq!(hit_test(®ions, pos2(30.0, 30.0)), Some(2)); + assert_eq!(hit_test(®ions, pos2(10.0, 10.0)), Some(1)); + assert_eq!(hit_test(®ions, pos2(100.0, 100.0)), None); + } + + #[test] + fn hit_test_supports_quads_and_markers() { + let quad = HitRegion { + id: 7, + shape: HitShape::Quad([ + pos2(0.0, 0.0), + pos2(40.0, 10.0), + pos2(30.0, 50.0), + pos2(-10.0, 40.0), + ]), + }; + let marker = HitRegion::circle(8, pos2(100.0, 100.0), 12.0); + let regions = vec![quad, marker]; + assert_eq!(hit_test(®ions, pos2(10.0, 20.0)), Some(7)); + assert_eq!(hit_test(®ions, pos2(108.0, 100.0)), Some(8)); + assert_eq!(hit_test(®ions, pos2(140.0, 100.0)), None); + } + + #[test] + fn point_in_polygon_handles_concave_ordering() { + let triangle = [pos2(0.0, 0.0), pos2(10.0, 0.0), pos2(0.0, 10.0)]; + assert!(point_in_polygon(&triangle, pos2(2.0, 2.0))); + assert!(!point_in_polygon(&triangle, pos2(8.0, 8.0))); + assert!(!point_in_polygon(&[], pos2(0.0, 0.0))); + } + + #[test] + fn gizmo_hit_detects_center_and_axes() { + let layout = GizmoLayout { + center: pos2(100.0, 100.0), + }; + assert_eq!( + gizmo_hit(layout, pos2(102.0, 101.0)), + Some(GizmoHandle::Free) + ); + assert_eq!(gizmo_hit(layout, pos2(140.0, 102.0)), Some(GizmoHandle::X)); + assert_eq!(gizmo_hit(layout, pos2(101.0, 50.0)), Some(GizmoHandle::Y)); + assert_eq!(gizmo_hit(layout, pos2(200.0, 200.0)), None); + assert_eq!(gizmo_hit(layout, pos2(40.0, 100.0)), None); + } + + #[test] + fn local_translation_inverts_parent_rotation_and_scale() { + let parent = Transform::from_xyz(10.0, 0.0, 0.0) + .with_rotation(Vec3::new(0.0, 0.0, 90.0)) + .with_scale(Vec3::splat(2.0)); + let desired_local = Vec3::new(1.0, 0.0, 0.0); + let desired_world = parent.transform_point(desired_local); + let local = local_translation(&parent, desired_world); + assert!((local - desired_local).length() < 1e-4, "{local:?}"); + } + + #[test] + fn local_translation_handles_zero_scale_without_infinity() { + let parent = Transform::IDENTITY.with_scale(Vec3::new(0.0, 0.0, 0.0)); + let local = local_translation(&parent, Vec3::new(4.0, 5.0, 6.0)); + assert!(local.is_finite()); + assert_eq!(local, Vec3::new(4.0, 5.0, 6.0)); + } + + #[test] + fn drag_targets_skips_descendants_and_locked_entities() { + let mut doc = SceneDoc::new("Test"); + let parent = doc.add_entity("Parent", None); + let child = doc.add_entity("Child", Some(parent)); + let locked = doc.add_entity("Locked", None); + doc.entity_mut(locked).unwrap().locked = true; + let mut selection = Selection::default(); + selection.select_many([parent, child, locked], Some(parent)); + assert_eq!(drag_targets(&doc, &selection), vec![parent]); + + selection.clear(); + selection.select_many([child, locked], Some(child)); + assert_eq!(drag_targets(&doc, &selection), vec![child]); + } + + #[test] + fn hidden_entities_inherit_their_parents_visibility() { + let mut doc = SceneDoc::new("Test"); + let parent = doc.add_entity("Parent", None); + let child = doc.add_entity("Child", Some(parent)); + assert!(!is_effectively_hidden(&doc, child)); + doc.toggle_hidden(parent); + assert!(is_effectively_hidden(&doc, parent)); + assert!(is_effectively_hidden(&doc, child)); + } + + #[test] + fn text_metrics_clamp_screen_font_size() { + let text = Text2dData { + text: "Hello".into(), + font_size: 32.0, + color: Color::WHITE, + }; + let (font, size, lines) = text_metrics(&text, &Transform::IDENTITY, 64.0); + assert_eq!(font, 96.0); + assert_eq!(lines, vec!["Hello".to_string()]); + assert!(size.x > 0.0 && size.y > 0.0); + let (font, _, _) = text_metrics(&text, &Transform::IDENTITY, 0.01); + assert_eq!(font, 8.0); + let multi = Text2dData { + text: "a\nbb".into(), + ..text + }; + let (_, _, lines) = text_metrics(&multi, &Transform::IDENTITY, 1.0); + assert_eq!(lines, vec!["a".to_string(), "bb".to_string()]); + } + + #[test] + fn unique_names_are_suffixed() { + let mut doc = SceneDoc::new("Test"); + doc.add_entity("Sprite", None); + doc.add_entity("Sprite 2", None); + assert_eq!(unique_name(&doc, "Sprite"), "Sprite 3"); + assert_eq!(unique_name(&doc, "Coin"), "Coin"); + } + + #[test] + fn scene_fit_bounds_covers_all_content() { + let mut doc = SceneDoc::new("Test"); + let id = doc.add_entity("Player", None); + doc.entity_mut(id).unwrap().transform = Transform::from_xyz(10.0, -4.0, 0.0); + doc.add_component( + id, + ComponentData::Sprite(SpriteData { + custom_size: Some(Vec2::new(4.0, 2.0)), + ..Default::default() + }), + ); + let bounds = scene_fit_bounds(&doc); + assert!(approx(bounds.center().x, 10.0, 1e-5)); + assert!(approx(bounds.center().y, -4.0, 1e-5)); + assert!(approx(bounds.size().x, 4.0, 1e-5)); + assert!(approx(bounds.size().y, 2.0, 1e-5)); + } + + struct Harness { + ctx: egui::Context, + session: Session, + assets: AssetIndex, + textures: TextureCache, + ui_state: UiState, + toasts: Toasts, + export: ExportState, + state: Viewport2dState, + } + + impl Harness { + fn new(doc: SceneDoc) -> Self { + Self { + ctx: egui::Context::default(), + session: Session::new(doc), + assets: AssetIndex::default(), + textures: TextureCache::default(), + ui_state: UiState::default(), + toasts: Toasts::new(), + export: ExportState::default(), + state: Viewport2dState::default(), + } + } + + fn frame(&mut self, events: Vec) { + let Self { + ctx, + session, + assets, + textures, + ui_state, + toasts, + export, + state, + } = self; + let raw = egui::RawInput { + screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(900.0, 640.0))), + events, + ..Default::default() + }; + let mut cx = EditorContext { + session, + project: None, + assets, + textures, + ui: ui_state, + toasts, + export, + }; + let _ = ctx.run(raw, |ctx| { + egui::CentralPanel::default().show(ctx, |ui| { + let _ = show(ui, &mut cx, state); + }); + }); + } + } + + fn pointer(pos: Pos2, pressed: bool) -> egui::Event { + egui::Event::PointerButton { + pos, + button: egui::PointerButton::Primary, + pressed, + modifiers: Modifiers::NONE, + } + } + + fn wheel(delta: egui::Vec2) -> egui::Event { + egui::Event::MouseWheel { + unit: egui::MouseWheelUnit::Point, + delta, + modifiers: Modifiers::NONE, + } + } + + #[test] + fn headless_wheel_zooms_without_mutating_the_scene() { + let mut harness = Harness::new(samples::starter_2d()); + harness.frame(vec![]); + let before_zoom = harness.state.camera.zoom; + let rect = harness.ui_state.viewport_rect; + let anchor = pos2(450.0, 335.0); + let before_world = screen_to_world(anchor, rect, &harness.state.camera); + harness.frame(vec![egui::Event::PointerMoved(anchor)]); + harness.frame(vec![wheel(vec2(0.0, 60.0))]); + harness.frame(vec![]); + harness.frame(vec![]); + assert!( + harness.state.camera.zoom > before_zoom, + "wheel up should zoom in: {} -> {}", + before_zoom, + harness.state.camera.zoom + ); + // Zoom is anchored at the cursor: the world point under it must not move. + let after_world = screen_to_world(anchor, rect, &harness.state.camera); + assert!( + (before_world - after_world).length() < 1e-3, + "{before_world:?} != {after_world:?}" + ); + assert!(!harness.session.can_undo()); + assert!(!harness.session.is_dirty()); + assert!(harness.ui_state.selection.is_empty()); + } + + #[test] + fn headless_space_drag_pans_without_mutating_the_scene() { + let mut harness = Harness::new(samples::starter_2d()); + harness.frame(vec![]); + let before_center = harness.state.camera.center; + let before_zoom = harness.state.camera.zoom; + let start = pos2(450.0, 335.0); + harness.frame(vec![ + egui::Event::Key { + key: egui::Key::Space, + physical_key: Some(egui::Key::Space), + pressed: true, + repeat: false, + modifiers: Modifiers::NONE, + }, + egui::Event::PointerMoved(start), + ]); + harness.frame(vec![pointer(start, true)]); + harness.frame(vec![egui::Event::PointerMoved(start + vec2(50.0, 25.0))]); + harness.frame(vec![egui::Event::PointerMoved(start + vec2(90.0, 40.0))]); + harness.frame(vec![ + egui::Event::PointerMoved(start + vec2(90.0, 40.0)), + pointer(start + vec2(90.0, 40.0), false), + ]); + assert_ne!( + harness.state.camera.center, before_center, + "space+drag should pan the camera" + ); + assert_eq!(harness.state.camera.zoom, before_zoom); + assert!(harness.state.camera.center.is_finite()); + assert!(!harness.session.can_undo()); + assert!(!harness.session.is_dirty()); + assert!(harness.ui_state.selection.is_empty()); + } + + #[test] + fn headless_gizmo_x_axis_drag_constrains_movement_and_undoes() { + let mut harness = Harness::new(samples::starter_2d()); + harness.frame(vec![egui::Event::PointerMoved(pos2(450.0, 335.0))]); + // Click the camera marker at the world origin (highest z at that point). + harness.frame(vec![pointer(pos2(450.0, 335.0), true)]); + harness.frame(vec![pointer(pos2(450.0, 335.0), false)]); + assert_eq!(harness.ui_state.selection.primary(), Some(1)); + + let original = harness + .session + .doc() + .entity(1) + .map(|node| node.transform) + .unwrap_or_default(); + let handle = pos2(490.0, 335.0); + harness.frame(vec![pointer(handle, true)]); + harness.frame(vec![egui::Event::PointerMoved(handle + vec2(80.0, 50.0))]); + harness.frame(vec![egui::Event::PointerMoved(handle + vec2(100.0, 60.0))]); + harness.frame(vec![ + egui::Event::PointerMoved(handle + vec2(100.0, 60.0)), + pointer(handle + vec2(100.0, 60.0), false), + ]); + + assert_eq!(harness.session.undo_label(), Some("Move entities")); + let moved = harness + .session + .doc() + .entity(1) + .map(|node| node.transform) + .unwrap_or_default(); + assert!(moved.translation.x > original.translation.x, "{moved:?}"); + assert_eq!(moved.translation.y, original.translation.y); + assert_eq!(moved.translation.z, original.translation.z); + harness.session.undo(); + assert_eq!( + harness + .session + .doc() + .entity(1) + .map(|node| node.transform) + .unwrap_or_default(), + original + ); + } + + #[test] + fn headless_click_selects_and_a_snapped_drag_commits_one_undo() { + let mut harness = Harness::new(samples::starter_2d()); + // Let auto-fit settle and place the pointer on the backdrop, away from + // the camera marker that sits at the world origin. + harness.frame(vec![egui::Event::PointerMoved(pos2(650.0, 340.0))]); + harness.frame(vec![pointer(pos2(650.0, 340.0), true)]); + harness.frame(vec![pointer(pos2(650.0, 340.0), false)]); + assert_eq!( + harness.ui_state.selection.primary(), + Some(2), + "clicking the backdrop should select it" + ); + + harness.ui_state.snap_to_grid = true; + harness.ui_state.grid_size = 10.0; + let original = harness + .session + .doc() + .entity(2) + .map(|node| node.transform) + .unwrap_or_default(); + let start = pos2(650.0, 340.0); + harness.frame(vec![egui::Event::PointerMoved(start), pointer(start, true)]); + harness.frame(vec![egui::Event::PointerMoved(start + vec2(60.0, 0.0))]); + harness.frame(vec![egui::Event::PointerMoved(start + vec2(120.0, 0.0))]); + harness.frame(vec![ + egui::Event::PointerMoved(start + vec2(120.0, 0.0)), + pointer(start + vec2(120.0, 0.0), false), + ]); + + assert!( + harness.session.can_undo(), + "drag should create one undo entry" + ); + assert_eq!(harness.session.undo_label(), Some("Move entities")); + let moved = harness + .session + .doc() + .entity(2) + .map(|node| node.transform.translation) + .unwrap_or_default(); + assert!(moved.x != 0.0, "the backdrop should have moved: {moved:?}"); + assert!( + (moved.x / 10.0).fract().abs() < 1e-3, + "snapped translation should be a multiple of 10: {moved:?}" + ); + assert_eq!(moved.y, original.translation.y); + assert_eq!(harness.session.undo().as_deref(), Some("Move entities")); + assert_eq!( + harness + .session + .doc() + .entity(2) + .map(|node| node.transform) + .unwrap_or_default(), + original + ); + } + + #[test] + fn asset_drop_adds_a_sprite_with_one_undo_entry() { + let mut session = Session::new(SceneDoc::new("Drop test")); + let assets = AssetIndex::default(); + let mut textures = TextureCache::default(); + let mut ui_state = UiState::default(); + let mut toasts = Toasts::new(); + let mut export = ExportState::default(); + let mut cx = EditorContext { + session: &mut session, + project: None, + assets: &assets, + textures: &mut textures, + ui: &mut ui_state, + toasts: &mut toasts, + export: &mut export, + }; + assert!(add_sprite_from_asset( + &mut cx, + "textures/coin.png", + Vec2::new(1.5, -2.0) + )); + let first = cx + .ui + .selection + .primary() + .expect("drop selects the new sprite"); + let node = cx.session.doc().entity(first).expect("sprite exists"); + assert_eq!(node.name, "Sprite"); + assert_eq!(node.transform.translation, Vec3::new(1.5, -2.0, 0.0)); + assert!(matches!( + node.component("sprite"), + Some(ComponentData::Sprite(sprite)) + if sprite.image.as_deref() == Some("textures/coin.png") + )); + assert_eq!(cx.session.undo_label(), Some("Add sprite")); + + assert!(add_sprite_from_asset( + &mut cx, + "textures/coin.png", + Vec2::ZERO + )); + let second = cx.ui.selection.primary().expect("second drop selects"); + assert_ne!(first, second); + assert_eq!( + cx.session + .doc() + .entity(second) + .map(|node| node.name.as_str()), + Some("Sprite 2") + ); + // Undo each drop independently. + assert_eq!(cx.session.undo().as_deref(), Some("Add sprite")); + assert!(cx.session.doc().entity(second).is_none()); + assert!(cx.session.doc().entity(first).is_some()); + assert_eq!(cx.session.undo().as_deref(), Some("Add sprite")); + assert!(cx.session.doc().entities.is_empty()); + + // Non-image payloads are rejected without mutating the scene. + assert!(!add_sprite_from_asset( + &mut cx, + "audio/boom.ogg", + Vec2::ZERO + )); + assert!(cx.session.doc().entities.is_empty()); + assert!(!cx.session.can_undo()); + } + + #[test] + fn show_renders_headlessly_and_updates_shared_state() { + let ctx = egui::Context::default(); + let mut session = Session::new(samples::starter_2d()); + let assets = AssetIndex::default(); + let mut textures = TextureCache::default(); + let mut ui_state = UiState::default(); + let mut toasts = Toasts::new(); + let mut export = ExportState::default(); + let mut state = Viewport2dState::default(); + let raw = headless_input(); + for _ in 0..2 { + let mut cx = EditorContext { + session: &mut session, + project: None, + assets: &assets, + textures: &mut textures, + ui: &mut ui_state, + toasts: &mut toasts, + export: &mut export, + }; + let _ = ctx.run(raw.clone(), |ctx| { + egui::CentralPanel::default().show(ctx, |ui| { + let actions = show(ui, &mut cx, &mut state); + assert!(actions.is_empty()); + }); + }); + } + assert!(ui_state.viewport_rect.width() > 100.0); + assert!(ui_state.viewport_rect.height() > 100.0); + assert!(state.camera.zoom.is_finite()); + assert!(state.last_scene.is_some()); + // The 960x540 backdrop needs the overview range below ZOOM_MIN. + assert!(state.camera.zoom < ZOOM_MIN); + } + + #[test] + fn draw_order_sorts_by_z_then_scene_order() { + // The sort itself is a one-liner; this guards the comparator contract. + let mut items = vec![ + (2.0f32, 1usize, 1u64), + (1.0, 2, 2), + (1.0, 1, 3), + (-3.0, 0, 4), + ]; + items.sort_by(|a, b| a.0.total_cmp(&b.0).then_with(|| a.1.cmp(&b.1))); + assert_eq!( + items.iter().map(|(_, _, id)| *id).collect::>(), + vec![4, 3, 2, 1] + ); + } +} diff --git a/crates/bevy_forge_editor/src/viewport3d.rs b/crates/bevy_forge_editor/src/viewport3d.rs new file mode 100644 index 0000000..0dbcab3 --- /dev/null +++ b/crates/bevy_forge_editor/src/viewport3d.rs @@ -0,0 +1,2371 @@ +//! 3D viewport: software-projected grid, primitive wireframes, orbit camera, +//! selection and ground-plane dragging. +//! +//! Everything is painted with egui's 2D painter — there is no GPU or renderer +//! dependency. The preview is deliberately schematic: meshes are wireframes, +//! sprites are translucent quads lying in the local XY plane (Bevy's 2D +//! convention), cameras and lights use small glyphs, and the ground grid fades +//! out towards its border so it reads as an infinite editor grid. + +use bevy_forge_core::{ + math::{quat_from_euler_deg, quat_rotate_vec3}, + ComponentData, DirectionalLightData, EntityId, Mesh3dData, MeshSource, PointLightData, + PrimitiveShape, SceneDoc, SpriteData, Text2dData, Transform, Vec3, VisibilityMode, +}; +use egui::{ + Align2, Color32, CursorIcon, FontId, PointerButton, Pos2, Rect, Sense, Stroke, StrokeKind, + Vec2 as EVec2, +}; + +use crate::action::Action; +use crate::state::{EditorContext, Selection}; +use crate::{theme, widgets}; + +/// Nothing closer to the camera than this is drawn; keeps the projection finite. +const NEAR_PLANE: f32 = 0.1; +const MIN_PITCH: f32 = -89.0; +const MAX_PITCH: f32 = 89.0; +const MIN_DISTANCE: f32 = 0.5; +const MAX_DISTANCE: f32 = 500.0; +/// Screen-space radius (pixels) for click picking and drag grab tests. +const PICK_RADIUS: f32 = 18.0; +/// Half-size of the ground grid in world units (20 x 20 in total). +const GRID_EXTENT: f32 = 10.0; +const GRID_LINES: i32 = 10; +const CUSTOM_COLOR: Color32 = Color32::from_rgb(0xC6, 0x8C, 0xFF); +const RADIANS: f32 = std::f32::consts::TAU; + +/// Orbit camera: position is derived from `target` and spherical coordinates. +/// +/// `yaw_deg` rotates around world Y (`0` looks along `-Z` from the positive Z +/// side), `pitch_deg` is the elevation above the horizon and `distance` is the +/// radius of the orbit. +#[derive(Clone, Debug)] +pub struct OrbitCamera { + pub yaw_deg: f32, + pub pitch_deg: f32, + pub distance: f32, + pub target: Vec3, + pub fov_deg: f32, +} + +impl Default for OrbitCamera { + fn default() -> Self { + Self { + yaw_deg: 35.0, + pitch_deg: 25.0, + distance: 12.0, + target: Vec3::ZERO, + fov_deg: 45.0, + } + } +} + +impl OrbitCamera { + fn eye(&self) -> Vec3 { + let yaw = self.yaw_deg.to_radians(); + let pitch = self.pitch_deg.clamp(MIN_PITCH, MAX_PITCH).to_radians(); + let horizontal = self.distance.clamp(MIN_DISTANCE, MAX_DISTANCE) * pitch.cos(); + self.target + + Vec3::new( + horizontal * yaw.sin(), + pitch.sin() * self.distance, + horizontal * yaw.cos(), + ) + } + + /// Orbit by a screen-space drag delta. Dragging up raises the camera. + fn orbit(&mut self, delta: EVec2) { + if !delta.x.is_finite() || !delta.y.is_finite() { + return; + } + self.yaw_deg = wrap_degrees(self.yaw_deg - delta.x * 0.35); + self.pitch_deg = (self.pitch_deg - delta.y * 0.35).clamp(MIN_PITCH, MAX_PITCH); + } + + /// Zoom by a wheel scroll amount: `distance *= exp(-scroll * 0.0015)`. + fn zoom_by(&mut self, scroll: f32) { + if !scroll.is_finite() { + return; + } + let exponent = (-scroll * 0.0015).clamp(-1.5, 1.5); + let factor = exponent.exp(); + if factor.is_finite() { + self.distance = (self.distance * factor).clamp(MIN_DISTANCE, MAX_DISTANCE); + } + } + + /// Pan the orbit target in the camera plane so the scene follows the mouse. + fn pan(&mut self, delta: EVec2, view: &CameraView) { + if !delta.x.is_finite() || !delta.y.is_finite() { + return; + } + let units_per_pixel = view.units_per_pixel(self.distance.max(NEAR_PLANE)); + self.target = self.target - view.right * (delta.x * units_per_pixel) + + view.up * (delta.y * units_per_pixel); + } + + fn set_preset(&mut self, yaw_deg: f32, pitch_deg: f32) { + self.yaw_deg = wrap_degrees(yaw_deg); + self.pitch_deg = pitch_deg.clamp(MIN_PITCH, MAX_PITCH); + } +} + +#[derive(Default)] +pub struct Viewport3dState { + pub camera: OrbitCamera, + pub dragging: Option, + pub drag_before: Option, + /// World Y of the horizontal plane the current drag slides along. + drag_plane_y: f32, + /// Entity world XZ when the drag started. + drag_start_world: [f32; 2], + /// Cursor ray/plane hit at the start of the drag. + drag_start_hit: [f32; 2], +} + +pub fn show( + ui: &mut egui::Ui, + cx: &mut EditorContext<'_>, + state: &mut Viewport3dState, +) -> Vec { + let actions = toolbar(ui, cx, state); + ui.add_space(2.0); + let (response, painter) = ui.allocate_painter(ui.available_size(), Sense::click_and_drag()); + let rect = response.rect; + cx.ui.viewport_rect = rect; + cx.ui.pointer_over_viewport = response.hovered() || response.dragged(); + // The status bar's 2D cursor readout is not meaningful in 3D. + cx.ui.cursor_world = None; + + if rect.width() < 16.0 || rect.height() < 16.0 { + painter.rect_filled(rect, 0.0, theme::BG); + return actions; + } + + let aspect = (rect.width() / rect.height().max(1.0)).clamp(0.05, 20.0); + if cx.ui.fit_view_requested { + cx.ui.fit_view_requested = false; + fit_camera(&mut state.camera, cx.session.doc(), aspect); + cx.ui.status = "Framed scene content".to_string(); + } + + let view = CameraView::new(&state.camera, rect); + handle_pointer(ui, &response, &view, cx, state); + // Input may have changed the camera; rebuild the projection for drawing. + let view = CameraView::new(&state.camera, rect); + + paint_background(&painter, rect); + paint_grid(&painter, &view, cx.ui.show_grid); + let pointer = response + .hover_pos() + .or_else(|| response.interact_pointer_pos()); + let dragging = state.dragging.is_some(); + let cursor_plane = if dragging { state.drag_plane_y } else { 0.0 }; + paint_ground_cursor(&painter, &view, pointer, cursor_plane, dragging); + + let doc = cx.session.doc(); + let mut items = Vec::new(); + for id in doc.depth_first_ids() { + entity_items(doc, &view, &cx.ui.selection, id, &mut items); + } + let empty_scene = doc.entities.is_empty(); + items.sort_by(|a, b| b.depth.total_cmp(&a.depth)); + draw_items(&painter, &view, &items); + draw_axis_gizmo(&painter, &view, state.camera.distance); + paint_overlays( + &painter, + &response, + rect, + empty_scene, + &state.camera, + dragging, + ); + painter.rect_stroke( + rect, + 0.0, + Stroke::new(1.0_f32, theme::BORDER), + StrokeKind::Inside, + ); + + actions +} + +// --------------------------------------------------------------------------- +// Toolbar +// --------------------------------------------------------------------------- + +fn toolbar( + ui: &mut egui::Ui, + cx: &mut EditorContext<'_>, + state: &mut Viewport3dState, +) -> Vec { + let mut actions = Vec::new(); + ui.spacing_mut().item_spacing.x = 6.0; + ui.horizontal(|ui| { + ui.label(egui::RichText::new("3D").strong().color(theme::ACCENT)); + ui.label(theme::hint("Perspective")); + ui.separator(); + if widgets::toolbar_toggle(ui, cx.ui.show_grid, "Grid", "Toggle the ground grid (G)") + .clicked() + { + actions.push(Action::ToggleGrid); + } + if widgets::toolbar_toggle( + ui, + cx.ui.snap_to_grid, + "Snap", + "Snap dragged entities to the grid", + ) + .clicked() + { + actions.push(Action::ToggleSnap); + } + if cx.ui.snap_to_grid { + ui.add( + egui::DragValue::new(&mut cx.ui.grid_size) + .speed(0.05) + .range(0.05..=10.0) + .fixed_decimals(2) + .prefix("step "), + ); + } + ui.separator(); + if ui + .small_button("Fit") + .on_hover_text("Frame all scene content (F)") + .clicked() + { + actions.push(Action::FitView); + } + if ui + .small_button("Reset") + .on_hover_text("Reset the orbit camera") + .clicked() + { + state.camera = OrbitCamera::default(); + cx.ui.status = "Reset 3D camera".to_string(); + } + ui.separator(); + if ui + .small_button("3D") + .on_hover_text("Perspective preset") + .clicked() + { + state.camera.set_preset(35.0, 25.0); + cx.ui.status = "Perspective view".to_string(); + } + if ui + .small_button("Top") + .on_hover_text("Look straight down") + .clicked() + { + state.camera.set_preset(0.0, MAX_PITCH); + cx.ui.status = "Top view".to_string(); + } + if ui + .small_button("Front") + .on_hover_text("Look along -Z") + .clicked() + { + state.camera.set_preset(0.0, 0.0); + cx.ui.status = "Front view".to_string(); + } + if ui.available_width() > 180.0 { + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + ui.label(theme::hint("MMB orbit | Shift+MMB pan | Wheel zoom")); + }); + } + }); + actions +} + +// --------------------------------------------------------------------------- +// Interaction +// --------------------------------------------------------------------------- + +fn handle_pointer( + ui: &egui::Ui, + response: &egui::Response, + view: &CameraView, + cx: &mut EditorContext<'_>, + state: &mut Viewport3dState, +) { + let modifiers = ui.input(|input| input.modifiers); + + // Wheel zoom (not while dragging an entity, so the drag plane stays put). + let scroll = ui.input(|input| input.smooth_scroll_delta.y); + if state.dragging.is_none() && response.hovered() && scroll.abs() > 0.01 { + state.camera.zoom_by(scroll); + cx.ui.status = format!("Zoom - distance {:.1}", state.camera.distance); + } + + let middle_drag = response.dragged_by(PointerButton::Middle); + let primary_drag = response.dragged_by(PointerButton::Primary); + if state.dragging.is_none() { + if middle_drag && modifiers.shift { + state.camera.pan(response.drag_delta(), view); + cx.ui.status = "Panned camera".to_string(); + ui.ctx().set_cursor_icon(CursorIcon::Grabbing); + } else if middle_drag || (primary_drag && modifiers.alt) { + state.camera.orbit(response.drag_delta()); + cx.ui.status = format!( + "Orbited camera - yaw {:.0} pitch {:.0}", + state.camera.yaw_deg, state.camera.pitch_deg + ); + ui.ctx().set_cursor_icon(CursorIcon::Grabbing); + } + } + + // Entity drag (primary button, not the Alt+LMB orbit gesture). + if response.drag_started_by(PointerButton::Primary) && !modifiers.alt { + begin_entity_drag(response, view, cx, state); + } + if state.dragging.is_some() && primary_drag && !modifiers.alt { + update_entity_drag(ui, response, view, cx, state); + } + if state.dragging.is_some() && !ui.input(|input| input.pointer.primary_down()) { + finish_entity_drag(cx, state); + } + + // Click selection: nearest projected origin within the pick radius. + if response.clicked() { + if let Some(pointer) = response.interact_pointer_pos() { + let picked = { + let doc = cx.session.doc(); + pick_entity(view, doc, pointer, PICK_RADIUS).map(|id| { + let name = doc + .entity(id) + .map(|node| node.name.clone()) + .unwrap_or_default(); + (id, name) + }) + }; + match picked { + Some((id, name)) => { + if modifiers.shift { + cx.ui.selection.toggle(id); + cx.ui.status = format!("Toggled {name}"); + } else { + cx.ui.selection.select_only(id); + cx.ui.status = format!("Selected {name}"); + } + } + None => { + if !modifiers.shift { + cx.ui.selection.clear(); + cx.ui.status = "Selection cleared".to_string(); + } + } + } + } + } + + // Double-click focuses the picked entity. + if response.double_clicked() { + if let Some(pointer) = response.interact_pointer_pos() { + let focus = { + let doc = cx.session.doc(); + pick_entity(view, doc, pointer, PICK_RADIUS).and_then(|id| { + doc.world_transform(id).map(|world| { + let name = doc + .entity(id) + .map(|node| node.name.clone()) + .unwrap_or_default(); + (world.translation, name) + }) + }) + }; + if let Some((position, name)) = focus { + state.camera.target = position; + state.camera.distance = (state.camera.distance * 0.35).clamp(2.0, 60.0); + cx.ui.status = format!("Focused {name}"); + } + } + } +} + +fn begin_entity_drag( + response: &egui::Response, + view: &CameraView, + cx: &mut EditorContext<'_>, + state: &mut Viewport3dState, +) { + let Some(pointer) = response.interact_pointer_pos() else { + return; + }; + let Some(primary) = cx.ui.selection.primary() else { + return; + }; + let doc = cx.session.doc(); + let Some(node) = doc.entity(primary) else { + return; + }; + let name = node.name.clone(); + let locked = node.locked; + let Some(world) = doc.world_transform(primary) else { + return; + }; + let Some(projected) = view.project(world.translation) else { + return; + }; + if projected.pos.distance(pointer) > PICK_RADIUS + 6.0 { + return; + } + if locked { + cx.ui.status = format!("{name} is locked - unlock it to move it"); + return; + } + let Some(hit) = intersect_plane_y(&view.ray(pointer), world.translation.y) else { + cx.ui.status = + "Cannot drag on this plane - tilt the camera away from the horizon".to_string(); + return; + }; + state.dragging = Some(primary); + state.drag_before = Some(doc.clone()); + state.drag_plane_y = world.translation.y; + state.drag_start_world = [world.translation.x, world.translation.z]; + state.drag_start_hit = [hit.x, hit.z]; + cx.ui.status = format!("Dragging {name} - release to commit"); +} + +fn update_entity_drag( + ui: &egui::Ui, + response: &egui::Response, + view: &CameraView, + cx: &mut EditorContext<'_>, + state: &mut Viewport3dState, +) { + let Some(id) = state.dragging else { + return; + }; + let Some(pointer) = response.interact_pointer_pos() else { + return; + }; + let Some(hit) = intersect_plane_y(&view.ray(pointer), state.drag_plane_y) else { + return; + }; + let constrain = ui.input(|input| input.modifiers.shift); + let snap = cx.ui.snap_to_grid; + let grid = cx.ui.grid_size; + + let mut x = state.drag_start_world[0] + (hit.x - state.drag_start_hit[0]); + let mut z = state.drag_start_world[1] + (hit.z - state.drag_start_hit[1]); + if constrain { + let dx = (x - state.drag_start_world[0]).abs(); + let dz = (z - state.drag_start_world[1]).abs(); + if dx >= dz { + z = state.drag_start_world[1]; + } else { + x = state.drag_start_world[0]; + } + } + if snap { + x = snap_value(x, grid); + z = snap_value(z, grid); + } + + let doc = cx.session.doc(); + let Some(world) = doc.world_transform(id) else { + return; + }; + let parent = doc.parent_world_transform(id); + let local = world_to_local(parent, Vec3::new(x, world.translation.y, z)); + let name = doc + .entity(id) + .map(|node| node.name.clone()) + .unwrap_or_default(); + if let Some(node) = cx.session.doc_mut().entity_mut(id) { + if node.locked { + return; + } + node.transform.translation = local; + cx.ui.status = format!("Move {name} - x {x:.2} z {z:.2}"); + } +} + +fn finish_entity_drag(cx: &mut EditorContext<'_>, state: &mut Viewport3dState) { + let Some(before) = state.drag_before.take() else { + state.dragging = None; + return; + }; + let changed = cx.session.doc() != &before; + let name = state + .dragging + .and_then(|id| cx.session.doc().entity(id).map(|node| node.name.clone())); + cx.session.commit(before, "Move entity"); + state.dragging = None; + cx.ui.status = match (changed, name) { + (true, Some(name)) => format!("Moved {name}"), + (true, None) => "Moved entity".to_string(), + (false, _) => "Drag ended without changes".to_string(), + }; +} + +// --------------------------------------------------------------------------- +// Camera math +// --------------------------------------------------------------------------- + +#[derive(Clone, Debug)] +struct CameraView { + eye: Vec3, + forward: Vec3, + right: Vec3, + up: Vec3, + tan_half: f32, + aspect: f32, + rect: Rect, +} + +impl CameraView { + fn new(camera: &OrbitCamera, rect: Rect) -> Self { + let eye = camera.eye(); + let mut forward = (camera.target - eye).normalized_or_zero(); + if forward.length_squared() < 0.5 { + forward = Vec3::new(0.0, 0.0, -1.0); + } + let mut right = forward.cross(Vec3::Y).normalized_or_zero(); + if right.length_squared() < 0.5 { + right = Vec3::X; + } + let mut up = right.cross(forward).normalized_or_zero(); + if up.length_squared() < 0.5 { + up = Vec3::Y; + } + let fov = camera.fov_deg.clamp(1.0, 179.0); + let tan_half = (fov * 0.5).to_radians().tan().max(0.01); + let width = rect.width().max(1.0); + let height = rect.height().max(1.0); + Self { + eye, + forward, + right, + up, + tan_half, + aspect: (width / height).clamp(0.05, 20.0), + rect, + } + } + + fn depth(&self, point: Vec3) -> f32 { + (point - self.eye).dot(self.forward) + } + + fn units_per_pixel(&self, depth: f32) -> f32 { + (2.0 * depth.max(NEAR_PLANE) * self.tan_half / self.rect.height().max(1.0)).max(0.0) + } + + fn project(&self, point: Vec3) -> Option { + let depth = self.depth(point); + if !depth.is_finite() || depth < NEAR_PLANE { + return None; + } + let relative = point - self.eye; + let camera_x = relative.dot(self.right); + let camera_y = relative.dot(self.up); + let ndc_x = camera_x / (depth * self.tan_half * self.aspect); + let ndc_y = camera_y / (depth * self.tan_half); + if !ndc_x.is_finite() || !ndc_y.is_finite() { + return None; + } + let center = self.rect.center(); + Some(ScreenPoint { + pos: egui::pos2( + center.x + ndc_x * self.rect.width() * 0.5, + center.y - ndc_y * self.rect.height() * 0.5, + ), + depth, + }) + } + + /// Project a world segment, clipping it against the near plane (in camera + /// space) and against the viewport (Liang–Barsky in screen space). + fn segment(&self, a: Vec3, b: Vec3) -> Option<[Pos2; 2]> { + let (a, b) = clip_near_plane(a, b, self.depth(a), self.depth(b))?; + let start = self.project(a)?.pos; + let end = self.project(b)?.pos; + clip_segment_2d(start, end, self.rect) + } + + fn ray(&self, screen: Pos2) -> Ray { + let center = self.rect.center(); + let half_width = (self.rect.width() * 0.5).max(1.0); + let half_height = (self.rect.height() * 0.5).max(1.0); + let ndc_x = (screen.x - center.x) / half_width; + let ndc_y = -(screen.y - center.y) / half_height; + let mut direction = (self.forward + + self.right * (ndc_x * self.tan_half * self.aspect) + + self.up * (ndc_y * self.tan_half)) + .normalized_or_zero(); + if direction.length_squared() < 0.5 { + direction = self.forward; + } + Ray { + origin: self.eye, + dir: direction, + } + } +} + +#[derive(Clone, Copy, Debug)] +struct ScreenPoint { + pos: Pos2, + depth: f32, +} + +#[derive(Clone, Copy, Debug)] +struct Ray { + origin: Vec3, + dir: Vec3, +} + +fn intersect_plane_y(ray: &Ray, plane_y: f32) -> Option { + if !plane_y.is_finite() || !ray.dir.is_finite() || !ray.origin.is_finite() { + return None; + } + // Parallel rays (or nearly parallel ones) never produce a stable hit. + if ray.dir.y.abs() < 1e-6 { + return None; + } + let t = (plane_y - ray.origin.y) / ray.dir.y; + if !t.is_finite() || t <= 0.0 { + return None; + } + let hit = ray.origin + ray.dir * t; + if hit.is_finite() { + Some(hit) + } else { + None + } +} + +fn clip_near_plane(a: Vec3, b: Vec3, depth_a: f32, depth_b: f32) -> Option<(Vec3, Vec3)> { + if depth_a <= NEAR_PLANE && depth_b <= NEAR_PLANE { + return None; + } + // Clip a hair in front of the near plane so the projection is stable. + let plane = NEAR_PLANE * 1.01; + if depth_a <= NEAR_PLANE { + let t = (plane - depth_a) / (depth_b - depth_a); + if !t.is_finite() { + return None; + } + Some((a + (b - a) * t, b)) + } else if depth_b <= NEAR_PLANE { + let t = (plane - depth_b) / (depth_a - depth_b); + if !t.is_finite() { + return None; + } + Some((a, b + (a - b) * t)) + } else { + Some((a, b)) + } +} + +fn clip_segment_2d(start: Pos2, end: Pos2, rect: Rect) -> Option<[Pos2; 2]> { + let dx = end.x - start.x; + let dy = end.y - start.y; + let mut t0 = 0.0_f32; + let mut t1 = 1.0_f32; + let checks = [ + (-dx, start.x - rect.min.x), + (dx, rect.max.x - start.x), + (-dy, start.y - rect.min.y), + (dy, rect.max.y - start.y), + ]; + for (p, q) in checks { + if p.abs() <= 1e-6 { + if q < 0.0 { + return None; + } + } else { + let r = q / p; + if p < 0.0 { + if r > t1 { + return None; + } + if r > t0 { + t0 = r; + } + } else { + if r < t0 { + return None; + } + if r < t1 { + t1 = r; + } + } + } + } + if !t0.is_finite() || !t1.is_finite() || t0 > t1 { + return None; + } + Some([ + egui::pos2(start.x + dx * t0, start.y + dy * t0), + egui::pos2(start.x + dx * t1, start.y + dy * t1), + ]) +} + +fn wrap_degrees(degrees: f32) -> f32 { + if !degrees.is_finite() { + return 0.0; + } + let wrapped = degrees.rem_euclid(360.0); + if wrapped > 180.0 { + wrapped - 360.0 + } else { + wrapped + } +} + +fn snap_value(value: f32, grid: f32) -> f32 { + if !value.is_finite() || !grid.is_finite() || grid <= 1e-4 { + return value; + } + let snapped = (value / grid).round() * grid; + if snapped.is_finite() { + snapped + } else { + value + } +} + +/// Convert a world-space point into the local space of `parent`. +/// +/// Degenerate (zero) parent scales are nudged instead of dividing by zero, so a +/// malformed transform can never produce non-finite drag results. +fn world_to_local(parent: Transform, world: Vec3) -> Vec3 { + let rotation = quat_from_euler_deg(parent.rotation); + let inverse = [-rotation[0], -rotation[1], -rotation[2], rotation[3]]; + let safe = |value: f32| { + if value.abs() < 1e-6 { + 1e-6 + } else { + value + } + }; + let relative = world - parent.translation; + let unscaled = Vec3::new( + relative.x / safe(parent.scale.x), + relative.y / safe(parent.scale.y), + relative.z / safe(parent.scale.z), + ); + quat_rotate_vec3(inverse, unscaled) +} + +// --------------------------------------------------------------------------- +// Scene queries: visibility, bounds, picking +// --------------------------------------------------------------------------- + +fn effective_hidden(doc: &SceneDoc, id: EntityId) -> bool { + let mut current = Some(id); + let mut guard = 0; + while let Some(current_id) = current { + let Some(node) = doc.entity(current_id) else { + return false; + }; + if matches!( + node.component("visibility"), + Some(ComponentData::Visibility(VisibilityMode::Hidden)) + ) { + return true; + } + current = node.parent; + guard += 1; + if guard > 256 { + break; + } + } + false +} + +fn pick_entity(view: &CameraView, doc: &SceneDoc, pointer: Pos2, radius: f32) -> Option { + let mut best: Option<(EntityId, f32)> = None; + for id in doc.depth_first_ids() { + if effective_hidden(doc, id) { + continue; + } + let Some(world) = doc.world_transform(id) else { + continue; + }; + let Some(projected) = view.project(world.translation) else { + continue; + }; + if projected.pos.distance(pointer) <= radius + && best.map_or(true, |(_, depth)| projected.depth < depth) + { + best = Some((id, projected.depth)); + } + } + best.map(|(id, _)| id) +} + +#[derive(Clone, Copy, Debug)] +struct Bounds3 { + min: Vec3, + max: Vec3, +} + +impl Bounds3 { + fn empty() -> Self { + Self { + min: Vec3::splat(f32::INFINITY), + max: Vec3::splat(f32::NEG_INFINITY), + } + } + + fn is_empty(&self) -> bool { + self.min.x > self.max.x || self.min.y > self.max.y || self.min.z > self.max.z + } + + fn include(&mut self, point: Vec3) { + if !point.is_finite() { + return; + } + self.min = Vec3::new( + self.min.x.min(point.x), + self.min.y.min(point.y), + self.min.z.min(point.z), + ); + self.max = Vec3::new( + self.max.x.max(point.x), + self.max.y.max(point.y), + self.max.z.max(point.z), + ); + } + + fn center(&self) -> Vec3 { + (self.min + self.max) * 0.5 + } + + fn radius(&self) -> f32 { + ((self.max - self.min) * 0.5).length() + } +} + +fn scene_bounds(doc: &SceneDoc) -> Bounds3 { + let mut bounds = Bounds3::empty(); + for node in doc.entities.values() { + let Some(world) = doc.world_transform(node.id) else { + continue; + }; + bounds.include(world.translation); + for component in &node.components { + match component { + ComponentData::Mesh3d(mesh) => { + let half = match &mesh.mesh { + MeshSource::Primitive(shape) => shape.half_extents(), + MeshSource::Asset(_) => Vec3::splat(0.75), + }; + for corner in local_box_corners(half) { + bounds.include(world.transform_point(corner)); + } + } + ComponentData::Sprite(sprite) => { + let (width, height) = sprite_size(sprite); + for corner in sprite_corners(width, height) { + bounds.include(world.transform_point(corner)); + } + } + ComponentData::Text2d(text) => { + let (width, height) = text_size(text); + for corner in sprite_corners(width, height) { + bounds.include(world.transform_point(corner)); + } + } + ComponentData::PointLight(_) | ComponentData::DirectionalLight(_) => { + bounds.include(world.translation + Vec3::Y); + } + ComponentData::Camera2d + | ComponentData::Camera3d + | ComponentData::Custom(_) + | ComponentData::Visibility(_) => {} + } + } + } + bounds +} + +fn fit_camera(camera: &mut OrbitCamera, doc: &SceneDoc, aspect: f32) { + let bounds = scene_bounds(doc); + if bounds.is_empty() { + return; + } + let center = bounds.center(); + if !center.is_finite() { + return; + } + let radius = bounds.radius(); + if !radius.is_finite() || radius <= 0.0 { + return; + } + let radius = radius.max(0.5); + let fov = camera.fov_deg.clamp(1.0, 179.0); + let tan_half = (fov * 0.5).to_radians().tan().max(0.01); + let aspect = aspect.clamp(0.05, 20.0); + let vertical = radius / tan_half; + let horizontal = radius / (tan_half * aspect).max(0.01); + camera.target = center; + camera.distance = (vertical.max(horizontal) * 1.25 + 0.5).clamp(MIN_DISTANCE, MAX_DISTANCE); +} + +// --------------------------------------------------------------------------- +// Drawing: background, grid, overlays +// --------------------------------------------------------------------------- + +fn paint_background(painter: &egui::Painter, rect: Rect) { + let top = Color32::from_rgb(0x17, 0x1a, 0x24); + let mut mesh = egui::Mesh::default(); + let base = mesh.vertices.len() as u32; + mesh.colored_vertex(rect.left_top(), top); + mesh.colored_vertex(rect.right_top(), top); + mesh.colored_vertex(rect.right_bottom(), theme::BG); + mesh.colored_vertex(rect.left_bottom(), theme::BG); + mesh.add_triangle(base, base + 1, base + 2); + mesh.add_triangle(base, base + 2, base + 3); + painter.add(egui::Shape::mesh(mesh)); +} + +fn paint_grid(painter: &egui::Painter, view: &CameraView, show_grid: bool) { + if !show_grid { + return; + } + for index in -GRID_LINES..=GRID_LINES { + let coordinate = index as f32; + let major = index % 5 == 0; + let base = if major { + Color32::from_rgb(0x46, 0x51, 0x66) + } else { + Color32::from_rgb(0x30, 0x38, 0x48) + }; + let fade = 1.0 - (coordinate.abs() / GRID_EXTENT); + let peak = if major { 255.0 } else { 185.0 }; + let alpha = (fade * fade * peak) as u8; + if alpha == 0 { + continue; + } + let color = tint(base, alpha); + let width: f32 = if major { 1.2 } else { 1.0 }; + if let Some(segment) = view.segment( + Vec3::new(coordinate, 0.0, -GRID_EXTENT), + Vec3::new(coordinate, 0.0, GRID_EXTENT), + ) { + painter.line_segment(segment, Stroke::new(width, color)); + } + if let Some(segment) = view.segment( + Vec3::new(-GRID_EXTENT, 0.0, coordinate), + Vec3::new(GRID_EXTENT, 0.0, coordinate), + ) { + painter.line_segment(segment, Stroke::new(width, color)); + } + } + // Ground axes are tinted rather than saturated: the small origin gizmo + // stays the brightest axis cue in the viewport. + if let Some(segment) = view.segment( + Vec3::new(-GRID_EXTENT, 0.0, 0.0), + Vec3::new(GRID_EXTENT, 0.0, 0.0), + ) { + painter.line_segment(segment, Stroke::new(1.3_f32, tint(theme::X_AXIS, 145))); + } + if let Some(segment) = view.segment( + Vec3::new(0.0, 0.0, -GRID_EXTENT), + Vec3::new(0.0, 0.0, GRID_EXTENT), + ) { + painter.line_segment(segment, Stroke::new(1.3_f32, tint(theme::Z_AXIS, 145))); + } + if let Some(segment) = view.segment(Vec3::ZERO, Vec3::new(0.0, 2.5, 0.0)) { + painter.line_segment(segment, Stroke::new(1.2_f32, tint(theme::Y_AXIS, 125))); + } +} + +fn paint_ground_cursor( + painter: &egui::Painter, + view: &CameraView, + pointer: Option, + plane_y: f32, + dragging: bool, +) { + let Some(pointer) = pointer else { + return; + }; + let Some(hit) = intersect_plane_y(&view.ray(pointer), plane_y) else { + return; + }; + let Some(projected) = view.project(hit) else { + return; + }; + let color = if dragging { + theme::SELECTION + } else { + tint(theme::ACCENT, 175) + }; + painter.circle_stroke(projected.pos, 7.0, Stroke::new(1.4_f32, color)); + painter.line_segment( + [ + projected.pos + egui::vec2(-12.0, 0.0), + projected.pos + egui::vec2(12.0, 0.0), + ], + Stroke::new(1.0_f32, tint(color, 110)), + ); + painter.line_segment( + [ + projected.pos + egui::vec2(0.0, -12.0), + projected.pos + egui::vec2(0.0, 12.0), + ], + Stroke::new(1.0_f32, tint(color, 110)), + ); + painter.text( + projected.pos + egui::vec2(14.0, -14.0), + Align2::LEFT_BOTTOM, + format!("{:.2}, {:.2}", hit.x, hit.z), + FontId::proportional(10.0), + tint(theme::TEXT, 185), + ); +} + +fn paint_overlays( + painter: &egui::Painter, + response: &egui::Response, + rect: Rect, + empty_scene: bool, + camera: &OrbitCamera, + dragging: bool, +) { + if empty_scene { + painter.text( + rect.center(), + Align2::CENTER_CENTER, + "Empty 3D scene", + FontId::proportional(16.0), + theme::MUTED, + ); + painter.text( + rect.center() + egui::vec2(0.0, 22.0), + Align2::CENTER_CENTER, + "Create an entity in the hierarchy to preview it here", + FontId::proportional(11.5), + theme::FAINT, + ); + } + if rect.width() > 700.0 { + painter.text( + rect.right_top() + egui::vec2(-10.0, 8.0), + Align2::RIGHT_TOP, + format!( + "perspective yaw {:.0}\u{00b0} pitch {:.0}\u{00b0} dist {:.1}", + camera.yaw_deg, camera.pitch_deg, camera.distance + ), + FontId::proportional(10.5), + tint(theme::FAINT, 215), + ); + } + if response.hovered() || dragging { + let hint = if dragging { + "Drag on the ground plane - Shift constrains to one axis - release to commit" + } else { + "MMB orbit Alt+LMB orbit Shift+MMB pan Wheel zoom Click select Drag to move" + }; + painter.text( + rect.left_bottom() + egui::vec2(10.0, -8.0), + Align2::LEFT_BOTTOM, + hint, + FontId::proportional(10.5), + tint(theme::MUTED, 205), + ); + } +} + +fn draw_axis_gizmo(painter: &egui::Painter, view: &CameraView, distance: f32) { + let length = (distance * 0.12).clamp(0.6, 3.0); + for (direction, color, label) in [ + (Vec3::X, theme::X_AXIS, "X"), + (Vec3::Y, theme::Y_AXIS, "Y"), + (Vec3::Z, theme::Z_AXIS, "Z"), + ] { + if let Some(segment) = view.segment(Vec3::ZERO, direction * length) { + painter.line_segment(segment, Stroke::new(2.0_f32, color)); + } + if let Some(tip) = view.project(direction * length) { + painter.text( + tip.pos, + Align2::CENTER_CENTER, + label, + FontId::proportional(11.0), + color, + ); + } + } + if let Some(origin) = view.project(Vec3::ZERO) { + painter.circle_filled(origin.pos, 2.4, theme::TEXT); + } +} + +// --------------------------------------------------------------------------- +// Drawing: scene items +// --------------------------------------------------------------------------- + +struct RenderItem { + /// Camera-space depth; larger is farther away (painted first). + depth: f32, + kind: ItemKind, +} + +enum ItemKind { + Wire { + segments: Vec<[Vec3; 2]>, + color: Color32, + width: f32, + }, + Fill { + points: Vec, + fill: Color32, + stroke: Color32, + width: f32, + }, + Label { + world: Vec3, + offset: EVec2, + text: String, + color: Color32, + size: f32, + align: Align2, + background: bool, + }, + Dot { + world: Vec3, + radius: f32, + color: Color32, + }, + Sun { + world: Vec3, + color: Color32, + }, + Lock { + world: Vec3, + color: Color32, + }, + ScreenRing { + world: Vec3, + radius: f32, + color: Color32, + width: f32, + }, +} + +fn entity_items( + doc: &SceneDoc, + view: &CameraView, + selection: &Selection, + id: EntityId, + items: &mut Vec, +) { + let Some(node) = doc.entity(id) else { + return; + }; + if effective_hidden(doc, id) { + return; + } + let Some(world) = doc.world_transform(id) else { + return; + }; + let selected = selection.contains(id); + let mut visual_count = 0_usize; + let mut custom_count = 0_usize; + for component in &node.components { + match component { + ComponentData::Mesh3d(mesh) => { + visual_count += 1; + mesh_items(view, world, mesh, selected, items); + } + ComponentData::Sprite(sprite) => { + visual_count += 1; + sprite_items(view, world, sprite, selected, items); + } + ComponentData::Text2d(text) => { + visual_count += 1; + text2d_items(view, world, text, selected, items); + } + ComponentData::Camera2d => { + visual_count += 1; + camera_items(view, world, true, selected, items); + } + ComponentData::Camera3d => { + visual_count += 1; + camera_items(view, world, false, selected, items); + } + ComponentData::PointLight(light) => { + visual_count += 1; + point_light_items(view, world, light, selected, items); + } + ComponentData::DirectionalLight(light) => { + visual_count += 1; + directional_light_items(view, world, light, selected, items); + } + ComponentData::Custom(_) => custom_count += 1, + ComponentData::Visibility(_) => {} + } + } + if custom_count > 0 { + push_dot(items, view, world.translation, 4.0, CUSTOM_COLOR); + } + if visual_count == 0 && custom_count == 0 { + // Transform-only entity: a small dot keeps it visible (and pickable). + push_dot(items, view, world.translation, 2.2, tint(theme::MUTED, 160)); + } + if node.locked { + push_lock(items, view, world.translation, theme::WARNING); + } + if selected { + push_screen_ring(items, view, world.translation, 9.0, theme::SELECTION, 1.6); + let label = if node.name.trim().is_empty() { + "entity".to_string() + } else { + node.name.clone() + }; + push_label( + items, + view, + world.translation, + EVec2::new(12.0, -10.0), + label, + theme::SELECTION, + 12.0, + Align2::LEFT_BOTTOM, + true, + Some(-1.0), + ); + } +} + +fn mesh_items( + view: &CameraView, + world: Transform, + mesh: &Mesh3dData, + selected: bool, + items: &mut Vec, +) { + let color = if selected { + theme::SELECTION + } else { + ensure_visible(widgets::color32(mesh.material.base_color)) + }; + let width = if selected { 2.3 } else { 1.25 }; + match &mesh.mesh { + MeshSource::Primitive(shape) => { + push_wire(items, view, primitive_segments(shape, world), color, width); + } + MeshSource::Asset(path) => { + push_wire( + items, + view, + box_segments(world, Vec3::splat(0.75)), + color, + width, + ); + push_label( + items, + view, + world.translation, + EVec2::new(0.0, -6.0), + format!("mesh: {}", short_asset_name(path)), + tint(theme::TEXT, 205), + 10.0, + Align2::CENTER_BOTTOM, + false, + None, + ); + } + } +} + +fn sprite_items( + view: &CameraView, + world: Transform, + sprite: &SpriteData, + selected: bool, + items: &mut Vec, +) { + let (width, height) = sprite_size(sprite); + let color = if selected { + theme::SELECTION + } else { + ensure_visible(widgets::color32(sprite.color)) + }; + let points = sprite_corners(width, height) + .iter() + .map(|corner| world.transform_point(*corner)) + .collect::>(); + push_fill( + items, + view, + points, + tint(color, if selected { 80 } else { 42 }), + color, + if selected { 2.2 } else { 1.4 }, + ); +} + +fn text2d_items( + view: &CameraView, + world: Transform, + text: &Text2dData, + selected: bool, + items: &mut Vec, +) { + let color = if selected { + theme::SELECTION + } else { + ensure_visible(widgets::color32(text.color)) + }; + push_dot(items, view, world.translation, 2.0, tint(color, 200)); + let label = if text.text.trim().is_empty() { + "(empty text)".to_string() + } else { + truncate(&text.text, 32) + }; + push_label( + items, + view, + world.translation, + EVec2::new(0.0, -9.0), + label, + color, + 11.5, + Align2::CENTER_BOTTOM, + false, + None, + ); +} + +fn camera_items( + view: &CameraView, + world: Transform, + is_2d: bool, + selected: bool, + items: &mut Vec, +) { + let color = if selected { + theme::SELECTION + } else { + theme::ACCENT + }; + let width = if selected { 2.0 } else { 1.25 }; + let mut segments = Vec::new(); + if is_2d { + let corners = [ + Vec3::new(-0.9, -0.55, 0.0), + Vec3::new(0.9, -0.55, 0.0), + Vec3::new(0.9, 0.55, 0.0), + Vec3::new(-0.9, 0.55, 0.0), + ]; + for index in 0..4 { + segments.push([ + world.transform_point(corners[index]), + world.transform_point(corners[(index + 1) % 4]), + ]); + } + segments.push([ + world.transform_point(Vec3::new(-0.9, 0.0, 0.0)), + world.transform_point(Vec3::new(0.9, 0.0, 0.0)), + ]); + } else { + let apex = world.translation; + let near = 0.35_f32; + let far = 1.6_f32; + let half_width = 0.55_f32; + let half_height = 0.35_f32; + let far_corners = [ + Vec3::new(-half_width, -half_height, -far), + Vec3::new(half_width, -half_height, -far), + Vec3::new(half_width, half_height, -far), + Vec3::new(-half_width, half_height, -far), + ]; + let near_scale = near / far; + let near_corners = far_corners + .map(|corner| Vec3::new(corner.x * near_scale, corner.y * near_scale, -near)); + for corner in far_corners { + segments.push([apex, world.transform_point(corner)]); + } + for index in 0..4 { + segments.push([ + world.transform_point(far_corners[index]), + world.transform_point(far_corners[(index + 1) % 4]), + ]); + segments.push([ + world.transform_point(near_corners[index]), + world.transform_point(near_corners[(index + 1) % 4]), + ]); + segments.push([ + world.transform_point(near_corners[index]), + world.transform_point(far_corners[index]), + ]); + } + } + push_wire(items, view, segments, color, width); + push_label( + items, + view, + world.translation, + EVec2::new(12.0, 10.0), + if is_2d { "Camera2d" } else { "Camera3d" }, + tint(color, 225), + 10.5, + Align2::LEFT_TOP, + true, + None, + ); +} + +fn point_light_items( + view: &CameraView, + world: Transform, + light: &PointLightData, + selected: bool, + items: &mut Vec, +) { + let color = if selected { + theme::SELECTION + } else { + ensure_visible(widgets::color32(light.color)) + }; + push_dot(items, view, world.translation, 4.0, color); + push_wire( + items, + view, + circle_xz_segments(world.translation, light.range.abs().clamp(0.05, 30.0), 40), + tint(color, 65), + 1.0, + ); + push_label( + items, + view, + world.translation, + EVec2::new(10.0, -10.0), + "PointLight", + tint(color, 225), + 10.0, + Align2::LEFT_BOTTOM, + true, + None, + ); +} + +fn directional_light_items( + view: &CameraView, + world: Transform, + light: &DirectionalLightData, + selected: bool, + items: &mut Vec, +) { + let color = if selected { + theme::SELECTION + } else { + ensure_visible(widgets::color32(light.color)) + }; + push_sun(items, view, world.translation, color); + let mut direction = world + .transform_direction(Vec3::new(0.0, 0.0, -1.0)) + .normalized_or_zero(); + if direction.length_squared() < 0.5 { + direction = Vec3::new(0.0, -1.0, 0.0); + } + let tip = world.translation + direction * 2.2; + let mut side = direction.cross(Vec3::Y).normalized_or_zero(); + if side.length_squared() < 0.5 { + side = Vec3::X; + } + let segments = vec![ + [world.translation, tip], + [tip, tip - direction * 0.35 + side * 0.16], + [tip, tip - direction * 0.35 - side * 0.16], + ]; + push_wire(items, view, segments, tint(color, 150), 1.2); + push_label( + items, + view, + world.translation, + EVec2::new(14.0, -12.0), + "DirectionalLight", + tint(color, 225), + 10.0, + Align2::LEFT_BOTTOM, + true, + None, + ); +} + +// --------------------------------------------------------------------------- +// Item construction helpers +// --------------------------------------------------------------------------- + +#[allow(clippy::too_many_arguments)] +fn push_label( + items: &mut Vec, + view: &CameraView, + world: Vec3, + offset: EVec2, + text: impl Into, + color: Color32, + size: f32, + align: Align2, + background: bool, + depth_override: Option, +) { + let depth = match depth_override { + Some(depth) => depth, + None => { + let depth = view.depth(world); + if !depth.is_finite() || depth <= NEAR_PLANE { + return; + } + depth + } + }; + items.push(RenderItem { + depth, + kind: ItemKind::Label { + world, + offset, + text: text.into(), + color, + size, + align, + background, + }, + }); +} + +fn push_wire( + items: &mut Vec, + view: &CameraView, + segments: Vec<[Vec3; 2]>, + color: Color32, + width: f32, +) { + if segments.is_empty() { + return; + } + let points = segments.iter().flat_map(|segment| segment.iter().copied()); + let Some(depth) = representative_depth(view, points) else { + return; + }; + items.push(RenderItem { + depth, + kind: ItemKind::Wire { + segments, + color, + width, + }, + }); +} + +fn push_fill( + items: &mut Vec, + view: &CameraView, + points: Vec, + fill: Color32, + stroke: Color32, + width: f32, +) { + if points.len() < 3 { + return; + } + let Some(depth) = representative_depth(view, points.iter().copied()) else { + return; + }; + items.push(RenderItem { + depth, + kind: ItemKind::Fill { + points, + fill, + stroke, + width, + }, + }); +} + +fn push_dot( + items: &mut Vec, + view: &CameraView, + world: Vec3, + radius: f32, + color: Color32, +) { + let depth = view.depth(world); + if !depth.is_finite() || depth <= NEAR_PLANE { + return; + } + items.push(RenderItem { + depth, + kind: ItemKind::Dot { + world, + radius, + color, + }, + }); +} + +fn push_sun(items: &mut Vec, view: &CameraView, world: Vec3, color: Color32) { + let depth = view.depth(world); + if !depth.is_finite() || depth <= NEAR_PLANE { + return; + } + items.push(RenderItem { + depth, + kind: ItemKind::Sun { world, color }, + }); +} + +fn push_lock(items: &mut Vec, view: &CameraView, world: Vec3, color: Color32) { + if view.project(world).is_none() { + return; + } + items.push(RenderItem { + // Locks are always readable, even when the entity is behind geometry. + depth: -0.7, + kind: ItemKind::Lock { world, color }, + }); +} + +fn push_screen_ring( + items: &mut Vec, + view: &CameraView, + world: Vec3, + radius: f32, + color: Color32, + width: f32, +) { + if view.project(world).is_none() { + return; + } + items.push(RenderItem { + depth: -0.55, + kind: ItemKind::ScreenRing { + world, + radius, + color, + width, + }, + }); +} + +fn representative_depth(view: &CameraView, points: impl Iterator) -> Option { + let mut sum = 0.0_f32; + let mut count = 0_u32; + for point in points { + let depth = view.depth(point); + if depth.is_finite() && depth > 0.0 { + sum += depth; + count += 1; + } + } + if count == 0 { + None + } else { + Some(sum / count as f32) + } +} + +fn draw_items(painter: &egui::Painter, view: &CameraView, items: &[RenderItem]) { + let clip = painter.clip_rect(); + let mut placed_labels: Vec = Vec::new(); + for item in items { + match &item.kind { + ItemKind::Wire { + segments, + color, + width, + } => { + let stroke = Stroke::new(*width, *color); + for [a, b] in segments { + if let Some([start, end]) = view.segment(*a, *b) { + painter.line_segment([start, end], stroke); + } + } + } + ItemKind::Fill { + points, + fill, + stroke, + width, + } => { + let projected = points + .iter() + .map(|point| view.project(*point).map(|projected| projected.pos)) + .collect::>>(); + if let Some(projected) = projected { + painter.add(egui::Shape::convex_polygon( + projected, + *fill, + Stroke::new(*width, *stroke), + )); + } + } + ItemKind::Label { + world, + offset, + text, + color, + size, + align, + background, + } => { + let Some(projected) = view.project(*world) else { + continue; + }; + let galley = + painter.layout_no_wrap(text.clone(), FontId::proportional(*size), *color); + let size = galley.size(); + let margin = 5.0; + let base = projected.pos + *offset; + let base = base.clamp( + clip.left_top() + egui::vec2(margin, margin), + (clip.right_bottom() - size - egui::vec2(margin, margin)) + .max(clip.left_top() + egui::vec2(margin, margin)), + ); + let label_rect = align.anchor_size(base, size); + // Keep labels inside the viewport and nudge them apart instead + // of letting long scene names collide with glyphs/each other. + let candidates = [ + egui::vec2(0.0, 0.0), + egui::vec2(0.0, 14.0), + egui::vec2(0.0, -14.0), + egui::vec2(14.0, 0.0), + egui::vec2(-14.0, 0.0), + egui::vec2(0.0, 28.0), + ]; + let mut chosen = None; + for delta in candidates { + let mut candidate = label_rect.translate(delta); + let shift = egui::vec2( + (clip.left() + margin - candidate.left()).max(0.0) + - (candidate.right() - clip.right() + margin).max(0.0), + (clip.top() + margin - candidate.top()).max(0.0) + - (candidate.bottom() - clip.bottom() + margin).max(0.0), + ); + candidate = candidate.translate(shift); + let overlaps = placed_labels + .iter() + .any(|placed| placed.expand2(egui::vec2(3.0, 2.0)).intersects(candidate)); + if !overlaps { + chosen = Some(candidate); + break; + } + } + let Some(label_rect) = chosen else { + continue; + }; + placed_labels.push(label_rect); + if *background { + painter.rect_filled( + label_rect.expand2(egui::vec2(4.0, 2.0)), + 3.0, + Color32::from_black_alpha(170), + ); + } + painter.galley(label_rect.min, galley, *color); + } + ItemKind::Dot { + world, + radius, + color, + } => { + if let Some(projected) = view.project(*world) { + painter.circle_filled(projected.pos, *radius, *color); + } + } + ItemKind::Sun { world, color } => { + if let Some(projected) = view.project(*world) { + draw_sun_glyph(painter, projected.pos, *color); + } + } + ItemKind::Lock { world, color } => { + if let Some(projected) = view.project(*world) { + draw_lock_glyph(painter, projected.pos + egui::vec2(10.0, 6.0), *color); + } + } + ItemKind::ScreenRing { + world, + radius, + color, + width, + } => { + if let Some(projected) = view.project(*world) { + painter.circle_stroke(projected.pos, *radius, Stroke::new(*width, *color)); + } + } + } + } +} + +fn draw_sun_glyph(painter: &egui::Painter, center: Pos2, color: Color32) { + painter.circle_stroke(center, 5.0, Stroke::new(1.4_f32, color)); + painter.circle_filled(center, 1.8, color); + for index in 0..8 { + let angle = index as f32 / 8.0 * RADIANS; + let direction = egui::vec2(angle.cos(), angle.sin()); + painter.line_segment( + [center + direction * 7.0, center + direction * 11.0], + Stroke::new(1.2_f32, color), + ); + } +} + +fn draw_lock_glyph(painter: &egui::Painter, center: Pos2, color: Color32) { + let body = Rect::from_center_size(center + egui::vec2(0.0, 3.0), egui::vec2(9.0, 6.0)); + painter.rect_stroke(body, 2.0, Stroke::new(1.2_f32, color), StrokeKind::Middle); + let mut shackle = Vec::with_capacity(9); + for index in 0..=8 { + let angle = std::f32::consts::PI + index as f32 / 8.0 * std::f32::consts::PI; + shackle.push( + center + egui::vec2(angle.cos() * 3.2, angle.sin() * 3.2) + egui::vec2(0.0, -1.0), + ); + } + painter.add(egui::Shape::line(shackle, Stroke::new(1.2_f32, color))); +} + +// --------------------------------------------------------------------------- +// Geometry helpers +// --------------------------------------------------------------------------- + +fn tint(color: Color32, alpha: u8) -> Color32 { + let [red, green, blue, _] = color.to_srgba_unmultiplied(); + Color32::from_rgba_unmultiplied(red, green, blue, alpha) +} + +fn ensure_visible(color: Color32) -> Color32 { + let [red, green, blue, alpha] = color.to_srgba_unmultiplied(); + let luminance = 0.2126 * red as f32 + 0.7152 * green as f32 + 0.0722 * blue as f32; + if luminance >= 70.0 { + return color; + } + let amount = ((70.0 - luminance) / 255.0).clamp(0.0, 0.45); + let mix = |channel: u8, target: u8| { + (channel as f32 * (1.0 - amount) + target as f32 * amount) + .round() + .clamp(0.0, 255.0) as u8 + }; + Color32::from_rgba_unmultiplied(mix(red, 220), mix(green, 224), mix(blue, 235), alpha) +} + +fn sprite_size(sprite: &SpriteData) -> (f32, f32) { + match sprite.custom_size { + Some(size) => (size.x.abs().max(0.02), size.y.abs().max(0.02)), + None => (1.0, 1.0), + } +} + +fn text_size(text: &Text2dData) -> (f32, f32) { + let font = text.font_size.abs().max(1.0); + let characters = text.text.chars().count().max(1) as f32; + ((characters * font * 0.55).max(font), font * 1.4) +} + +fn sprite_corners(width: f32, height: f32) -> [Vec3; 4] { + [ + Vec3::new(-width * 0.5, -height * 0.5, 0.0), + Vec3::new(width * 0.5, -height * 0.5, 0.0), + Vec3::new(width * 0.5, height * 0.5, 0.0), + Vec3::new(-width * 0.5, height * 0.5, 0.0), + ] +} + +fn local_box_corners(half: Vec3) -> [Vec3; 8] { + let h = Vec3::new( + half.x.abs().max(0.001), + half.y.abs().max(0.001), + half.z.abs().max(0.001), + ); + [ + Vec3::new(-h.x, -h.y, -h.z), + Vec3::new(h.x, -h.y, -h.z), + Vec3::new(h.x, h.y, -h.z), + Vec3::new(-h.x, h.y, -h.z), + Vec3::new(-h.x, -h.y, h.z), + Vec3::new(h.x, -h.y, h.z), + Vec3::new(h.x, h.y, h.z), + Vec3::new(-h.x, h.y, h.z), + ] +} + +fn box_segments(world: Transform, half: Vec3) -> Vec<[Vec3; 2]> { + let corners = local_box_corners(half).map(|corner| world.transform_point(corner)); + const EDGES: [(usize, usize); 12] = [ + (0, 1), + (1, 2), + (2, 3), + (3, 0), + (4, 5), + (5, 6), + (6, 7), + (7, 4), + (0, 4), + (1, 5), + (2, 6), + (3, 7), + ]; + EDGES + .iter() + .map(|(start, end)| [corners[*start], corners[*end]]) + .collect() +} + +fn ring_segments(world: Transform, radius: f32, y: f32, count: usize) -> Vec<[Vec3; 2]> { + let radius = radius.abs().max(0.01); + let count = count.max(3); + let mut segments = Vec::with_capacity(count); + let mut previous: Option = None; + for index in 0..=count { + let angle = index as f32 / count as f32 * RADIANS; + let point = world.transform_point(Vec3::new(angle.cos() * radius, y, angle.sin() * radius)); + if let Some(previous) = previous { + segments.push([previous, point]); + } + previous = Some(point); + } + segments +} + +fn circle_xz_segments(center: Vec3, radius: f32, count: usize) -> Vec<[Vec3; 2]> { + let radius = radius.abs().clamp(0.05, 30.0); + let count = count.max(3); + let mut segments = Vec::with_capacity(count); + let mut previous: Option = None; + for index in 0..=count { + let angle = index as f32 / count as f32 * RADIANS; + let point = center + Vec3::new(angle.cos() * radius, 0.0, angle.sin() * radius); + if let Some(previous) = previous { + segments.push([previous, point]); + } + previous = Some(point); + } + segments +} + +fn primitive_segments(shape: &PrimitiveShape, world: Transform) -> Vec<[Vec3; 2]> { + match shape { + PrimitiveShape::Cuboid { size } => box_segments(world, *size * 0.5), + PrimitiveShape::Sphere { radius } => { + let radius = radius.abs().max(0.01); + let mut segments = Vec::with_capacity(48); + for axis in 0..3 { + let mut previous: Option = None; + for index in 0..=16 { + let angle = index as f32 / 16.0 * RADIANS; + let (sin, cos) = angle.sin_cos(); + let local = match axis { + 0 => Vec3::new(cos * radius, sin * radius, 0.0), + 1 => Vec3::new(0.0, cos * radius, sin * radius), + _ => Vec3::new(cos * radius, 0.0, sin * radius), + }; + let point = world.transform_point(local); + if let Some(previous) = previous { + segments.push([previous, point]); + } + previous = Some(point); + } + } + // Two latitude rings turn the three great circles into a readable globe. + for latitude in [-0.55_f32, 0.55] { + let ring_radius = radius * (1.0 - latitude * latitude).sqrt(); + let mut previous: Option = None; + for index in 0..=16 { + let angle = index as f32 / 16.0 * RADIANS; + let (sin, cos) = angle.sin_cos(); + let point = world.transform_point(Vec3::new( + cos * ring_radius, + latitude * radius, + sin * ring_radius, + )); + if let Some(previous) = previous { + segments.push([previous, point]); + } + previous = Some(point); + } + } + segments + } + PrimitiveShape::Plane { size } => { + let half_x = size.x.abs().max(0.005) * 0.5; + let half_z = size.y.abs().max(0.005) * 0.5; + let corners = [ + Vec3::new(-half_x, 0.0, -half_z), + Vec3::new(half_x, 0.0, -half_z), + Vec3::new(half_x, 0.0, half_z), + Vec3::new(-half_x, 0.0, half_z), + ] + .map(|corner| world.transform_point(corner)); + let mut segments = Vec::with_capacity(4); + for index in 0..4 { + segments.push([corners[index], corners[(index + 1) % 4]]); + } + segments + } + PrimitiveShape::Cylinder { radius, height } => { + let radius = radius.abs().max(0.01); + let half_height = height.abs().max(0.01) * 0.5; + let mut segments = ring_segments(world, radius, -half_height, 16); + segments.extend(ring_segments(world, radius, half_height, 16)); + for index in 0..4 { + let angle = index as f32 / 4.0 * RADIANS; + let (sin, cos) = angle.sin_cos(); + segments.push([ + world.transform_point(Vec3::new(cos * radius, -half_height, sin * radius)), + world.transform_point(Vec3::new(cos * radius, half_height, sin * radius)), + ]); + } + segments + } + PrimitiveShape::Capsule { radius, length } => { + let radius = radius.abs().max(0.01); + let half_height = length.abs().max(0.0) * 0.5; + let mut segments = ring_segments(world, radius, -half_height, 16); + segments.extend(ring_segments(world, radius, half_height, 16)); + for index in 0..4 { + let angle = index as f32 / 4.0 * RADIANS; + let (sin, cos) = angle.sin_cos(); + segments.push([ + world.transform_point(Vec3::new(cos * radius, -half_height, sin * radius)), + world.transform_point(Vec3::new(cos * radius, half_height, sin * radius)), + ]); + } + for side in [-1.0_f32, 1.0] { + for plane in 0..2 { + let mut previous: Option = None; + for index in 0..=8 { + let angle = index as f32 / 8.0 * std::f32::consts::PI; + let (sin, cos) = angle.sin_cos(); + let local = if plane == 0 { + Vec3::new(0.0, side * (half_height + cos * radius), sin * radius) + } else { + Vec3::new(sin * radius, side * (half_height + cos * radius), 0.0) + }; + let point = world.transform_point(local); + if let Some(previous) = previous { + segments.push([previous, point]); + } + previous = Some(point); + } + } + } + segments + } + } +} + +fn short_asset_name(path: &str) -> String { + path.rsplit('/').next().unwrap_or(path).to_string() +} + +fn truncate(text: &str, max_characters: usize) -> String { + let flattened = text.replace(['\n', '\r'], " "); + if flattened.chars().count() <= max_characters { + flattened + } else { + let mut result: String = flattened + .chars() + .take(max_characters.saturating_sub(1)) + .collect(); + result.push('\u{2026}'); + result + } +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + use bevy_forge_core::{Mesh3dData, Vec2 as FVec2}; + + fn test_rect() -> Rect { + Rect::from_min_size(Pos2::new(10.0, 20.0), EVec2::new(800.0, 600.0)) + } + + fn add_mesh(doc: &mut SceneDoc, name: &str, translation: Vec3) -> EntityId { + let id = doc.add_entity(name, None); + if let Some(node) = doc.entity_mut(id) { + node.transform.translation = translation; + } + doc.add_component(id, ComponentData::Mesh3d(Mesh3dData::default())); + id + } + + fn mesh_doc() -> SceneDoc { + let mut doc = SceneDoc::new("Viewport test"); + add_mesh(&mut doc, "Left", Vec3::new(-3.0, 0.0, 0.0)); + add_mesh(&mut doc, "Right", Vec3::new(3.0, 0.0, 0.0)); + doc + } + + #[test] + fn camera_basis_and_projection_centering() { + let rect = test_rect(); + let camera = OrbitCamera::default(); + let view = CameraView::new(&camera, rect); + assert!((view.eye - camera.eye()).length() < 1e-4); + assert!((view.forward.length() - 1.0).abs() < 1e-4); + assert!(view.forward.dot(view.right).abs() < 1e-4); + assert!(view.forward.dot(view.up).abs() < 1e-4); + assert!(view.right.dot(view.up).abs() < 1e-4); + assert!(view.eye.y > 0.0, "default camera sits above the ground"); + + let center = view.project(camera.target).expect("target projects"); + assert!(center.pos.distance(rect.center()) < 0.01); + assert!((center.depth - camera.distance).abs() < 0.01); + + let right = view + .project(camera.target + view.right * 2.0) + .expect("right point projects"); + assert!(right.pos.x > rect.center().x); + let up = view + .project(camera.target + view.up * 2.0) + .expect("up point projects"); + assert!(up.pos.y < rect.center().y); + } + + #[test] + fn near_plane_and_screen_clipping() { + let rect = test_rect(); + let camera = OrbitCamera { + yaw_deg: 0.0, + pitch_deg: 0.0, + distance: 10.0, + target: Vec3::ZERO, + fov_deg: 45.0, + }; + let view = CameraView::new(&camera, rect); + // Behind the camera. + assert!(view.project(Vec3::new(0.0, 0.0, 25.0)).is_none()); + assert!(view + .segment(Vec3::new(0.0, 0.0, 0.0), Vec3::new(0.0, 0.0, 25.0)) + .is_some()); + assert!(view + .segment(Vec3::new(0.0, 0.0, 15.0), Vec3::new(0.0, 0.0, 25.0)) + .is_none()); + + // A segment crossing the whole screen is clipped to the viewport. + let clipped = view + .segment(Vec3::new(-500.0, 0.0, -5.0), Vec3::new(500.0, 0.0, -5.0)) + .expect("wide segment clips"); + let padded = rect.expand(0.5); + assert!(padded.contains(clipped[0])); + assert!(padded.contains(clipped[1])); + assert!(clipped[1].x > clipped[0].x); + } + + #[test] + fn ray_plane_intersection_guards() { + let rect = test_rect(); + let camera = OrbitCamera::default(); + let view = CameraView::new(&camera, rect); + let hit = intersect_plane_y(&view.ray(rect.center()), 0.0).expect("center ray hits ground"); + assert!(hit.distance(camera.target) < 0.05); + + let up = Ray { + origin: Vec3::new(0.0, 5.0, 0.0), + dir: Vec3::new(0.0, 1.0, 0.0), + }; + assert!(intersect_plane_y(&up, 0.0).is_none()); + let down = Ray { + origin: Vec3::new(0.0, 5.0, 0.0), + dir: Vec3::new(0.0, -1.0, 0.0), + }; + assert!(intersect_plane_y(&down, 0.0).is_some()); + let parallel = Ray { + origin: Vec3::new(0.0, 5.0, 0.0), + dir: Vec3::new(1.0, 0.0, 0.0), + }; + assert!(intersect_plane_y(¶llel, 0.0).is_none()); + + let slanted = Ray { + origin: Vec3::new(1.0, 5.0, 2.0), + dir: Vec3::new(0.0, -1.0, 0.0), + }; + let hit = intersect_plane_y(&slanted, 3.0).expect("higher plane hit"); + assert!(hit.distance(Vec3::new(1.0, 3.0, 2.0)) < 1e-5); + } + + #[test] + fn picking_uses_screen_distance_and_nearest_depth() { + let rect = test_rect(); + let camera = OrbitCamera::default(); + let view = CameraView::new(&camera, rect); + + let mut doc = SceneDoc::new("Pick"); + let near_entity = add_mesh(&mut doc, "Near", Vec3::ZERO); + let far_entity = add_mesh(&mut doc, "Far", Vec3::new(2.0, 0.0, 0.0)); + + let center = view.project(Vec3::ZERO).expect("origin projects").pos; + assert_eq!( + pick_entity(&view, &doc, center, PICK_RADIUS), + Some(near_entity) + ); + assert_eq!( + pick_entity(&view, &doc, center + EVec2::new(200.0, 0.0), PICK_RADIUS), + None + ); + + // Two entities on the same view ray: the nearer one wins. + let near_position = view.eye + view.forward * 4.0; + let far_position = view.eye + view.forward * 9.0; + if let Some(node) = doc.entity_mut(near_entity) { + node.transform.translation = near_position; + } + if let Some(node) = doc.entity_mut(far_entity) { + node.transform.translation = far_position; + } + let same_screen = view.project(near_position).expect("near projects").pos; + let far_screen = view.project(far_position).expect("far projects").pos; + assert!(same_screen.distance(far_screen) < 0.01); + assert_eq!( + pick_entity(&view, &doc, same_screen, PICK_RADIUS), + Some(near_entity) + ); + } + + #[test] + fn picking_skips_hidden_entities() { + let rect = test_rect(); + let camera = OrbitCamera::default(); + let view = CameraView::new(&camera, rect); + let mut doc = SceneDoc::new("Hidden"); + let entity = doc.add_entity("Hidden", None); + let center = view.project(Vec3::ZERO).expect("origin projects").pos; + assert_eq!(pick_entity(&view, &doc, center, PICK_RADIUS), Some(entity)); + + doc.add_component(entity, ComponentData::Visibility(VisibilityMode::Hidden)); + assert_eq!(pick_entity(&view, &doc, center, PICK_RADIUS), None); + } + + #[test] + fn hidden_state_is_inherited_from_parents() { + let mut doc = SceneDoc::new("Hidden parent"); + let parent = doc.add_entity("Parent", None); + let child = doc.add_entity("Child", Some(parent)); + assert!(!effective_hidden(&doc, child)); + doc.add_component(parent, ComponentData::Visibility(VisibilityMode::Hidden)); + assert!(effective_hidden(&doc, child)); + } + + #[test] + fn fit_camera_frames_the_scene() { + let rect = test_rect(); + let doc = mesh_doc(); + let mut camera = OrbitCamera::default(); + fit_camera(&mut camera, &doc, rect.width() / rect.height()); + assert!(camera.target.x.abs() < 0.01); + assert!(camera.target.y.abs() < 0.01); + assert!(camera.distance > 6.0); + assert!(camera.distance <= MAX_DISTANCE); + assert!(camera.eye().is_finite()); + + let mut empty_camera = OrbitCamera::default(); + let before = empty_camera.distance; + fit_camera(&mut empty_camera, &SceneDoc::new("Empty"), 1.6); + assert!((empty_camera.distance - before).abs() < 1e-3); + } + + #[test] + fn snap_and_parent_inverse_helpers() { + assert!((snap_value(1.24, 1.0) - 1.0).abs() < 1e-5); + assert!((snap_value(-0.6, 1.0) + 1.0).abs() < 1e-5); + assert!((snap_value(2.4, 0.0) - 2.4).abs() < 1e-5); + assert!((snap_value(f32::NAN, 1.0)).is_nan()); + + let parent = Transform::from_xyz(10.0, 2.0, -3.0) + .with_rotation(Vec3::new(0.0, 0.0, 90.0)) + .with_scale(Vec3::new(2.0, 2.0, 2.0)); + let local = Vec3::new(1.5, -0.5, 4.0); + let world = parent.transform_point(local); + let round_trip = world_to_local(parent, world); + assert!((round_trip - local).length() < 1e-4, "{round_trip:?}"); + } + + #[test] + fn orbit_and_zoom_are_clamped() { + let mut camera = OrbitCamera::default(); + camera.orbit(EVec2::new(10_000.0, 10_000.0)); + assert!(camera.yaw_deg >= -180.0 && camera.yaw_deg <= 180.0); + assert!((camera.pitch_deg + MAX_PITCH).abs() < 1e-4); + camera.orbit(EVec2::new(0.0, -100_000.0)); + assert!((camera.pitch_deg - MAX_PITCH).abs() < 1e-4); + + for _ in 0..50 { + camera.zoom_by(1000.0); + } + assert!((camera.distance - MIN_DISTANCE).abs() < 1e-4); + for _ in 0..80 { + camera.zoom_by(-1000.0); + } + assert!((camera.distance - MAX_DISTANCE).abs() < 1e-4); + } + + #[test] + fn primitive_wireframes_are_finite_and_non_empty() { + let world = Transform::IDENTITY; + let cases = [ + PrimitiveShape::Cuboid { size: Vec3::ONE }, + PrimitiveShape::Sphere { radius: 1.0 }, + PrimitiveShape::Plane { + size: FVec2::new(2.0, 4.0), + }, + PrimitiveShape::Cylinder { + radius: 0.5, + height: 2.0, + }, + PrimitiveShape::Capsule { + radius: 0.4, + length: 1.2, + }, + ]; + for shape in cases { + let segments = primitive_segments(&shape, world); + assert!(!segments.is_empty(), "{shape:?} draws nothing"); + assert!(segments + .iter() + .all(|segment| segment[0].is_finite() && segment[1].is_finite())); + } + assert_eq!(box_segments(world, Vec3::ONE).len(), 12); + // Degenerate sizes are sanitized instead of panicking. + let degenerate = primitive_segments(&PrimitiveShape::Sphere { radius: 0.0 }, world); + assert!(!degenerate.is_empty()); + } + + #[test] + fn ground_drag_round_trip_creates_one_undo_entry() { + use bevy_forge_core::Session; + + let mut doc = SceneDoc::new("Drag"); + let id = add_mesh(&mut doc, "Cube", Vec3::new(1.0, 0.0, 2.0)); + let mut session = Session::new(doc); + let before = session.doc().clone(); + + // Mirror the viewport drag path: snap the world XZ, convert through the + // parent transform, then commit the snapshot taken on drag start. + let parent = session.doc().parent_world_transform(id); + let world = session.doc().world_transform(id).expect("entity exists"); + let target = Vec3::new( + snap_value(world.translation.x + 1.4, 1.0), + world.translation.y, + snap_value(world.translation.z - 0.7, 1.0), + ); + let local = world_to_local(parent, target); + if let Some(node) = session.doc_mut().entity_mut(id) { + node.transform.translation = local; + } + session.commit(before, "Move entity"); + + assert_eq!(session.undo_label(), Some("Move entity")); + let moved = session.doc().entity(id).expect("entity exists"); + assert!((moved.transform.translation.x - 2.0).abs() < 1e-4); + assert!((moved.transform.translation.z - 1.0).abs() < 1e-4); + + assert_eq!(session.undo().as_deref(), Some("Move entity")); + let restored = session.doc().entity(id).expect("entity exists"); + assert!((restored.transform.translation - Vec3::new(1.0, 0.0, 2.0)).length() < 1e-5); + assert!(!session.can_undo(), "a drag produces exactly one entry"); + } +} diff --git a/crates/bevy_forge_editor/src/welcome.rs b/crates/bevy_forge_editor/src/welcome.rs new file mode 100644 index 0000000..996cf9f --- /dev/null +++ b/crates/bevy_forge_editor/src/welcome.rs @@ -0,0 +1,540 @@ +//! Welcome screen shown when no project is open: brand hero, project opener, +//! recent projects, starter scenes and demo hints. + +use std::path::{Path, PathBuf}; + +use bevy_forge_core::{EditorConfig, ProjectTemplate}; + +use crate::action::Action; +use crate::theme; +use crate::widgets; + +const FEATURES: &[(&str, &str)] = &[ + ("2D & 3D viewports", "pan, zoom and gizmos"), + ("Hierarchy & inspector", "transforms, components, assets"), + ("Undo, redo & validation", "safe scene edits"), + ("Asset browser", "thumbnails and drag-to-place"), + ("BSN & Rust export", "paste-ready Bevy 0.20 code"), +]; + +#[derive(Default)] +pub struct WelcomeState { + pub project_path: String, + pub error: Option, + /// Set once the path field has received initial focus. + pub focus_requested: bool, +} + +pub fn show(ctx: &egui::Context, state: &mut WelcomeState, config: &EditorConfig) -> Vec { + let mut actions = Vec::new(); + let margin = 56.0; + egui::TopBottomPanel::bottom("welcome_footer") + .exact_height(36.0) + .frame( + egui::Frame::new() + .fill(theme::BG) + .inner_margin(egui::Margin::symmetric(0, 4)), + ) + .show(ctx, |ui| footer(ui, margin)); + egui::CentralPanel::default() + .frame(egui::Frame::new().fill(theme::BG)) + .show(ctx, |ui| { + paint_backdrop(ui); + let content_height = ui.available_height(); + let gap = 48.0; + let inner_width = (ui.available_width() - 2.0 * margin - gap).max(520.0); + let right_width = (inner_width * 0.43).clamp(360.0, 500.0); + let left_width = inner_width - right_width; + + ui.allocate_ui_with_layout( + egui::vec2(ui.available_width(), content_height), + egui::Layout::left_to_right(egui::Align::Center), + |ui| { + ui.add_space(margin); + ui.allocate_ui_with_layout( + egui::vec2(left_width, content_height), + egui::Layout::top_down(egui::Align::Min), + |ui| { + ui.add_space((content_height * 0.22).min(200.0)); + brand_column(ui); + }, + ); + ui.add_space(gap); + ui.allocate_ui_with_layout( + egui::vec2(right_width, content_height), + egui::Layout::top_down(egui::Align::Min), + |ui| { + egui::ScrollArea::vertical() + .max_height(content_height) + .auto_shrink([false, true]) + .show(ui, |ui| { + ui.add_space((content_height * 0.27).min(240.0)); + project_card(ui, state, config, &mut actions); + }); + }, + ); + }, + ); + }); + actions +} + +// --------------------------------------------------------------------------- +// Hero (left column) +// --------------------------------------------------------------------------- + +fn brand_column(ui: &mut egui::Ui) { + ui.horizontal(|ui| { + diamond_mark(ui, 15.0, theme::ACCENT); + ui.label( + egui::RichText::new("BEVY FORGE") + .size(11.5) + .strong() + .color(theme::MUTED), + ); + }); + ui.add_space(2.0); + ui.label( + egui::RichText::new("Bevy Forge") + .size(52.0) + .strong() + .color(theme::TEXT), + ); + ui.add_space(2.0); + ui.add( + egui::Label::new( + egui::RichText::new("Design Bevy scenes visually. Export real BSN code.") + .size(18.0) + .color(theme::MUTED), + ) + .wrap(), + ); + ui.add_space(16.0); + let (rule, _) = ui.allocate_exact_size(egui::vec2(72.0, 2.0), egui::Sense::hover()); + ui.painter().rect_filled(rule, 1.0, theme::ACCENT); + ui.add_space(16.0); + + for (lead, detail) in FEATURES { + ui.horizontal_wrapped(|ui| { + ui.label(egui::RichText::new("•").size(14.0).color(theme::ACCENT)); + ui.label( + egui::RichText::new(*lead) + .size(14.0) + .strong() + .color(theme::TEXT), + ); + ui.label(egui::RichText::new(*detail).size(12.5).color(theme::MUTED)); + }); + ui.add_space(4.0); + } + + ui.add_space(18.0); + ui.horizontal(|ui| { + badge(ui, "ALPHA"); + badge(ui, concat!("v", env!("CARGO_PKG_VERSION"))); + badge(ui, "Bevy 0.20 · BSN"); + }); + ui.add_space(6.0); + ui.label( + egui::RichText::new("Open source · MIT OR Apache-2.0") + .size(10.5) + .color(theme::FAINT), + ); +} + +fn badge(ui: &mut egui::Ui, text: &str) { + egui::Frame::new() + .fill(theme::PANEL_ALT) + .stroke(egui::Stroke::new(1.0_f32, theme::BORDER)) + .corner_radius(egui::CornerRadius::same(4)) + .inner_margin(egui::Margin::symmetric(7, 3)) + .show(ui, |ui| { + ui.label(egui::RichText::new(text).size(10.0).color(theme::MUTED)); + }); +} + +// --------------------------------------------------------------------------- +// Project card (right column) +// --------------------------------------------------------------------------- + +fn project_card( + ui: &mut egui::Ui, + state: &mut WelcomeState, + config: &EditorConfig, + actions: &mut Vec, +) { + egui::Frame::new() + .fill(theme::PANEL_ALT) + .stroke(egui::Stroke::new(1.0_f32, theme::BORDER)) + .corner_radius(egui::CornerRadius::same(12)) + .inner_margin(egui::Margin::symmetric(22, 20)) + .show(ui, |ui| { + ui.set_width(ui.available_width()); + + widgets::section_label(ui, "Open a project"); + ui.label( + egui::RichText::new("Start from a Bevy project") + .size(19.0) + .strong() + .color(theme::TEXT), + ); + ui.add( + egui::Label::new(theme::hint( + "Scenes load from /scenes and assets from /assets.", + )) + .wrap(), + ); + ui.add_space(9.0); + + ui.horizontal(|ui| { + let field_width = (ui.available_width() - 84.0).max(140.0); + let response = ui.add_sized( + [field_width, 28.0], + egui::TextEdit::singleline(&mut state.project_path) + .id_salt("welcome_project_path") + .hint_text("/path/to/bevy-project") + .font(egui::TextStyle::Monospace) + .margin(egui::Margin::symmetric(7, 5)), + ); + if !state.focus_requested { + response.request_focus(); + state.focus_requested = true; + } + if response.changed() { + state.error = None; + } + let open_clicked = ui + .add_sized([76.0, 28.0], widgets::primary_button("Open")) + .clicked(); + let enter = + response.lost_focus() && ui.input(|input| input.key_pressed(egui::Key::Enter)); + if open_clicked || enter { + try_open(state, actions); + } + }); + + if let Some(error) = &state.error { + ui.add_space(3.0); + error_panel(ui, error); + } + + let recents: Vec<&PathBuf> = config.recent_projects.iter().take(5).collect(); + if !recents.is_empty() { + ui.add_space(10.0); + ui.separator(); + widgets::section_label(ui, "Recent projects"); + for path in recents { + if recent_row(ui, path) { + actions.push(Action::OpenProject(path.clone())); + } + } + } + + ui.add_space(10.0); + ui.separator(); + widgets::section_label(ui, "Start a new scene"); + let button_width = ((ui.available_width() - 8.0) / 2.0).max(120.0); + ui.horizontal(|ui| { + if ui + .add_sized( + [button_width, 30.0], + egui::Button::new(egui::RichText::new("2D starter").size(12.5)), + ) + .clicked() + { + actions.push(Action::NewScene(ProjectTemplate::TwoD)); + } + if ui + .add_sized( + [button_width, 30.0], + egui::Button::new(egui::RichText::new("3D starter").size(12.5)), + ) + .clicked() + { + actions.push(Action::NewScene(ProjectTemplate::ThreeD)); + } + }); + ui.add( + egui::Label::new(theme::hint( + "New scenes open in the editor. Save them into a project with Ctrl+S.", + )) + .wrap(), + ); + + ui.add_space(10.0); + ui.separator(); + widgets::section_label(ui, "Try the demo"); + ui.horizontal(|ui| { + if ui + .button("Open demo project") + .on_hover_text("examples/demo-project — a Bevy 0.20 project with two scenes") + .clicked() + { + actions.push(Action::OpenProject(demo_project_path())); + } + ui.label(theme::hint("garden (3D) + platformer (2D)")); + }); + ui.add( + egui::Label::new( + egui::RichText::new("cargo run -p bevy_forge_editor -- examples/demo-project") + .monospace() + .size(9.5) + .color(theme::FAINT), + ) + .selectable(true) + .truncate(), + ); + }); +} + +fn try_open(state: &mut WelcomeState, actions: &mut Vec) { + match crate::dialogs::validate_project_input(&state.project_path) { + Ok(path) => { + state.error = None; + actions.push(Action::OpenProject(path)); + } + Err(message) => state.error = Some(message), + } +} + +fn error_panel(ui: &mut egui::Ui, message: &str) { + egui::Frame::new() + .fill(egui::Color32::from_rgb(52, 24, 30)) + .stroke(egui::Stroke::new(1.0_f32, theme::ERROR)) + .corner_radius(egui::CornerRadius::same(5)) + .inner_margin(egui::Margin::symmetric(9, 6)) + .show(ui, |ui| { + ui.add( + egui::Label::new(egui::RichText::new(message).color(theme::ERROR).size(11.0)) + .wrap(), + ); + }); +} + +fn recent_row(ui: &mut egui::Ui, path: &Path) -> bool { + let name = folder_name(path); + let full = path.display().to_string(); + let subtitle = compact_path(&full, 52); + let (rect, response) = + ui.allocate_exact_size(egui::vec2(ui.available_width(), 36.0), egui::Sense::click()); + let hovered = response.hovered(); + let painter = ui.painter(); + let row = rect.shrink2(egui::vec2(0.0, 1.5)); + painter.rect_filled( + row, + 6.0, + if hovered { + theme::ELEVATED + } else { + theme::PANEL + }, + ); + painter.rect_stroke( + row, + 6.0, + egui::Stroke::new(1.0_f32, theme::BORDER), + egui::StrokeKind::Inside, + ); + painter.text( + egui::pos2(row.left() + 9.0, row.top() + 6.0), + egui::Align2::LEFT_TOP, + &name, + egui::FontId::proportional(12.5), + theme::TEXT, + ); + painter.text( + egui::pos2(row.left() + 9.0, row.top() + 21.0), + egui::Align2::LEFT_TOP, + subtitle, + egui::FontId::monospace(9.5), + theme::FAINT, + ); + painter.text( + egui::pos2(row.right() - 9.0, row.center().y), + egui::Align2::RIGHT_CENTER, + "Open", + egui::FontId::proportional(10.0), + theme::ACCENT, + ); + response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, &name)); + let response = response.on_hover_text(full); + response.clicked() +} + +// --------------------------------------------------------------------------- +// Chrome +// --------------------------------------------------------------------------- + +/// Draw the Bevy Forge diamond mark. Painted instead of a glyph so it renders +/// identically with every font configuration. +fn diamond_mark(ui: &mut egui::Ui, size: f32, color: egui::Color32) { + let (rect, _) = ui.allocate_exact_size(egui::vec2(size, size), egui::Sense::hover()); + let center = rect.center(); + let radius = size * 0.5; + ui.painter().add(egui::Shape::convex_polygon( + vec![ + egui::pos2(center.x, center.y - radius), + egui::pos2(center.x + radius, center.y), + egui::pos2(center.x, center.y + radius), + egui::pos2(center.x - radius, center.y), + ], + color, + egui::Stroke::NONE, + )); +} + +fn paint_backdrop(ui: &egui::Ui) { + let rect = ui.max_rect(); + let painter = ui.painter(); + painter.rect_filled(rect, 0.0, theme::BG); + painter.circle_filled( + egui::pos2( + rect.left() + rect.width() * 0.16, + rect.top() + rect.height() * 0.22, + ), + rect.width() * 0.24, + egui::Color32::from_rgba_unmultiplied(124, 156, 255, 10), + ); + painter.circle_filled( + egui::pos2( + rect.left() + rect.width() * 0.88, + rect.top() + rect.height() * 0.82, + ), + rect.width() * 0.18, + egui::Color32::from_rgba_unmultiplied(66, 84, 140, 14), + ); + painter.rect_filled( + egui::Rect::from_min_size(rect.min, egui::vec2(rect.width(), 1.0)), + 0.0, + theme::ACCENT_DIM, + ); +} + +fn footer(ui: &mut egui::Ui, margin: f32) { + ui.horizontal(|ui| { + ui.add_space(margin); + ui.label( + egui::RichText::new("Bevy 0.20 · BSN") + .size(11.0) + .color(theme::FAINT), + ); + ui.add_space(10.0); + ui.label(egui::RichText::new("·").size(11.0).color(theme::BORDER)); + ui.add_space(10.0); + ui.label( + egui::RichText::new("Made for the Bevy community") + .size(11.0) + .color(theme::FAINT), + ); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + ui.add_space(margin); + ui.label( + egui::RichText::new("command palette") + .size(11.0) + .color(theme::FAINT), + ); + widgets::kbd(ui, "Ctrl+K"); + ui.add_space(10.0); + ui.label( + egui::RichText::new("shortcuts") + .size(11.0) + .color(theme::FAINT), + ); + widgets::kbd(ui, "F1"); + }); + }); +} + +// --------------------------------------------------------------------------- +// Helpers (pure; unit-tested) +// --------------------------------------------------------------------------- + +/// The bundled demo project, resolved at compile time from the crate location. +pub fn demo_project_path() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../examples/demo-project") +} + +fn folder_name(path: &Path) -> String { + path.file_name() + .and_then(|name| name.to_str()) + .filter(|name| !name.is_empty()) + .map(ToOwned::to_owned) + .unwrap_or_else(|| path.display().to_string()) +} + +/// Shorten a path to its tail, keeping complete trailing components so recent +/// projects stay recognisable. Example: `…/bevy-forge/examples/demo-project`. +fn compact_path(path: &str, max_chars: usize) -> String { + let count = path.chars().count(); + if count <= max_chars { + return path.to_string(); + } + if max_chars < 8 { + return path.to_string(); + } + let keep = max_chars - 2; + let tail: String = path.chars().skip(count - keep).collect(); + // Drop the first (likely partial) component and keep the last components. + let tail = tail + .find('/') + .map(|index| &tail[index..]) + .unwrap_or(tail.as_str()); + format!("…{tail}") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn compacts_long_paths_to_their_tail() { + let path = "/home/emil/Documents/deepseek-harness/default-workspace/bevy-forge/examples/demo-project"; + let short = compact_path(path, 40); + assert!(short.chars().count() <= 40); + assert!(short.starts_with("…/")); + assert!(short.ends_with("examples/demo-project")); + } + + #[test] + fn keeps_short_paths_unchanged() { + assert_eq!(compact_path("/tmp/game", 40), "/tmp/game"); + assert_eq!(compact_path("/tmp/game", 4), "/tmp/game"); + } + + #[test] + fn compacting_handles_multibyte_characters() { + let path = "/home/émil/jeux/bevy-forge/examples/demo-project"; + let short = compact_path(path, 20); + assert!(short.chars().count() <= 20); + assert!(short.starts_with("…/")); + } + + #[test] + fn folder_name_prefers_the_last_component() { + assert_eq!(folder_name(Path::new("/tmp/games/demo")), "demo"); + assert_eq!(folder_name(Path::new("/")), "/"); + } + + #[test] + fn demo_project_path_points_at_the_bundled_example() { + let path = demo_project_path(); + assert!(path.ends_with("examples/demo-project")); + } + + #[test] + fn project_validation_errors_are_human_readable() { + let mut state = WelcomeState::default(); + let mut actions = Vec::new(); + try_open(&mut state, &mut actions); + assert!(actions.is_empty()); + assert!(state.error.as_deref().unwrap_or("").contains("path")); + + state.project_path = " /tmp/game ".into(); + try_open(&mut state, &mut actions); + assert_eq!( + actions, + vec![Action::OpenProject(PathBuf::from("/tmp/game"))] + ); + assert!(state.error.is_none()); + } +} diff --git a/crates/bevy_forge_editor/src/widgets.rs b/crates/bevy_forge_editor/src/widgets.rs new file mode 100644 index 0000000..baab7d7 --- /dev/null +++ b/crates/bevy_forge_editor/src/widgets.rs @@ -0,0 +1,237 @@ +//! Small reusable widgets shared by the inspector, hierarchy and viewports. + +use bevy_forge_core::{Color, Vec2 as FVec2, Vec3 as FVec3}; +use egui::{Response, RichText, Ui}; + +use crate::theme; + +pub fn color32(color: Color) -> egui::Color32 { + egui::Color32::from_rgba_unmultiplied( + (color.r.clamp(0.0, 1.0) * 255.0).round() as u8, + (color.g.clamp(0.0, 1.0) * 255.0).round() as u8, + (color.b.clamp(0.0, 1.0) * 255.0).round() as u8, + (color.a.clamp(0.0, 1.0) * 255.0).round() as u8, + ) +} + +pub fn color_from_egui(color: egui::Color32) -> Color { + Color::from_rgba8(color.r(), color.g(), color.b(), color.a()) +} + +pub fn section_label(ui: &mut Ui, text: impl Into) { + ui.add_space(2.0); + ui.label( + RichText::new(text.into().to_uppercase()) + .color(theme::MUTED) + .size(10.5) + .strong(), + ); +} + +pub fn field_label(ui: &mut Ui, text: &str) -> Response { + ui.label(RichText::new(text).color(theme::MUTED).size(12.0)) +} + +pub fn help_marker(ui: &mut Ui, text: &str) { + ui.label(RichText::new("?").color(theme::FAINT).size(11.0)) + .on_hover_text(text); +} + +pub fn vec2_editor( + ui: &mut Ui, + id_salt: impl std::hash::Hash, + value: &mut FVec2, + speed: f32, +) -> bool { + ui.push_id(id_salt, |ui| { + let mut changed = false; + ui.horizontal(|ui| { + ui.label(RichText::new("X").color(theme::X_AXIS).size(11.0)); + changed |= ui + .add( + egui::DragValue::new(&mut value.x) + .speed(speed) + .fixed_decimals(3), + ) + .changed(); + ui.label(RichText::new("Y").color(theme::Y_AXIS).size(11.0)); + changed |= ui + .add( + egui::DragValue::new(&mut value.y) + .speed(speed) + .fixed_decimals(3), + ) + .changed(); + }); + changed + }) + .inner +} + +pub fn vec3_editor( + ui: &mut Ui, + id_salt: impl std::hash::Hash, + value: &mut FVec3, + speed: f32, +) -> bool { + ui.push_id(id_salt, |ui| { + let mut changed = false; + ui.horizontal(|ui| { + ui.label(RichText::new("X").color(theme::X_AXIS).size(11.0)); + changed |= ui + .add( + egui::DragValue::new(&mut value.x) + .speed(speed) + .fixed_decimals(3), + ) + .changed(); + ui.label(RichText::new("Y").color(theme::Y_AXIS).size(11.0)); + changed |= ui + .add( + egui::DragValue::new(&mut value.y) + .speed(speed) + .fixed_decimals(3), + ) + .changed(); + ui.label(RichText::new("Z").color(theme::Z_AXIS).size(11.0)); + changed |= ui + .add( + egui::DragValue::new(&mut value.z) + .speed(speed) + .fixed_decimals(3), + ) + .changed(); + }); + changed + }) + .inner +} + +pub fn color_editor(ui: &mut Ui, id_salt: impl std::hash::Hash, value: &mut Color) -> bool { + ui.push_id(id_salt, |ui| { + let mut changed = false; + ui.horizontal(|ui| { + let mut color = color32(*value); + if ui.color_edit_button_srgba(&mut color).changed() { + *value = color_from_egui(color); + changed = true; + } + let mut hex = value.to_hex(); + let response = ui.add( + egui::TextEdit::singleline(&mut hex) + .desired_width(78.0) + .font(egui::TextStyle::Monospace), + ); + if response.changed() { + if let Some(parsed) = Color::from_hex(&hex) { + *value = Color::srgba(parsed.r, parsed.g, parsed.b, value.a); + changed = true; + } + } + if value.a < 1.0 { + changed |= ui + .add( + egui::DragValue::new(&mut value.a) + .speed(0.01) + .range(0.0..=1.0) + .fixed_decimals(2), + ) + .changed(); + } + }); + changed + }) + .inner +} + +/// Asset combo box. Returns true when the selection changed. `current` is +/// `None` for "no asset"; the first entry clears the selection. +pub fn asset_picker( + ui: &mut Ui, + id_salt: impl std::hash::Hash, + current: &mut Option, + assets: &[String], + placeholder: &str, +) -> bool { + ui.push_id(id_salt, |ui| { + let mut changed = false; + egui::ComboBox::from_id_salt(ui.id()) + .selected_text(current.as_deref().unwrap_or(placeholder).to_string()) + .width(ui.available_width().min(260.0)) + .show_ui(ui, |ui| { + if ui + .selectable_label(current.is_none(), format!("— {placeholder}")) + .clicked() + { + *current = None; + changed = true; + } + for asset in assets { + let selected = current.as_deref() == Some(asset.as_str()); + if ui.selectable_label(selected, asset).clicked() { + *current = Some(asset.clone()); + changed = true; + } + } + if assets.is_empty() { + ui.label(theme::hint("No matching assets in this project")); + } + }); + changed + }) + .inner +} + +pub fn search_field(ui: &mut Ui, hint: &str, text: &mut String) -> Response { + let response = ui.add( + egui::TextEdit::singleline(text) + .hint_text(hint) + .desired_width(ui.available_width()) + .margin(egui::Margin::symmetric(6, 4)), + ); + if !text.is_empty() && response.has_focus() { + // Keep the field responsive; Escape clears via the global shortcut handler. + } + response +} + +pub fn icon_button(ui: &mut Ui, icon: &str, tooltip: &str) -> Response { + ui.add(egui::Button::new(RichText::new(icon).size(14.0)).frame(false)) + .on_hover_text(tooltip) +} + +pub fn toolbar_toggle(ui: &mut Ui, selected: bool, label: &str, tooltip: &str) -> Response { + ui.selectable_label(selected, label).on_hover_text(tooltip) +} + +pub fn primary_button(text: &str) -> egui::Button<'static> { + egui::Button::new(RichText::new(text).strong()) + .fill(theme::ACCENT_DIM) + .stroke(egui::Stroke::new(1.0_f32, theme::ACCENT)) +} + +pub fn danger_button(text: &str) -> egui::Button<'static> { + egui::Button::new(RichText::new(text).color(theme::ERROR)) + .fill(theme::PANEL_ALT) + .stroke(egui::Stroke::new(1.0_f32, theme::ERROR)) +} + +pub fn kbd(ui: &mut Ui, keys: &str) { + ui.label( + RichText::new(keys) + .color(theme::MUTED) + .monospace() + .size(11.0) + .background_color(theme::BG), + ); +} + +pub fn empty_state(ui: &mut Ui, title: &str, hint: &str) { + ui.vertical_centered(|ui| { + ui.add_space(18.0); + ui.label(RichText::new(title).color(theme::MUTED).size(13.0)); + ui.add_space(3.0); + ui.label(RichText::new(hint).color(theme::FAINT).size(11.5)); + ui.add_space(18.0); + }); +} diff --git a/crates/bevy_forge_editor/tests/ui_smoke.rs b/crates/bevy_forge_editor/tests/ui_smoke.rs new file mode 100644 index 0000000..c7db632 --- /dev/null +++ b/crates/bevy_forge_editor/tests/ui_smoke.rs @@ -0,0 +1,774 @@ +//! Headless end-to-end UI tests for Bevy Forge. +//! +//! These tests drive the real [`ForgeApp`] through `egui::Context::run` with no +//! window, GPU, display or network. They cover: +//! +//! * render smoke at 1440x900 and 1000x640 in both 2D and 3D view modes, +//! * every panel/drawer toggle plus the command palette and all dialogs, +//! * app-level actions (undo/redo/duplicate/delete/view mode/grid), +//! * `.forge.ron` save/load round-trips through a temporary Bevy project, +//! * the unsaved-changes close guard and quit flags. +//! +//! Only the public editor API is used; panel internals are intentionally not +//! inspected. The tests are deterministic and independent (each builds its own +//! context/app) and finish in well under a second of CPU time. + +use std::path::{Path, PathBuf}; +use std::sync::Once; + +use bevy_forge_core::{samples, ComponentData, SceneDoc, SpriteData, Transform, Vec3}; +use bevy_forge_editor::action::Action; +use bevy_forge_editor::app::ForgeApp; +use bevy_forge_editor::state::ViewMode; +use bevy_forge_editor::Screen; +use eframe::egui; + +// --------------------------------------------------------------------------- +// Environment isolation +// --------------------------------------------------------------------------- + +/// Point the editor config at a throwaway directory. +/// +/// `ForgeApp::open_project` (and several toggle actions) persist the editor +/// preferences through `EditorConfig::save`, which honours `XDG_CONFIG_HOME`. +/// Keeping that inside a temp dir makes the tests hermetic and stops them from +/// adding temporary projects to the developer's real recent-projects list. +fn isolate_editor_config() { + static INIT: Once = Once::new(); + INIT.call_once(|| { + let dir = + std::env::temp_dir().join(format!("bevy-forge-test-config-{}", std::process::id())); + std::fs::create_dir_all(&dir).expect("create test config dir"); + std::env::set_var("XDG_CONFIG_HOME", &dir); + }); +} + +// --------------------------------------------------------------------------- +// Headless frame driver +// --------------------------------------------------------------------------- + +#[derive(Clone, Copy, Debug)] +struct FrameStats { + /// Number of top-level shapes emitted by egui this frame. + shapes: usize, + /// Number of tessellated, clipped primitives. + clipped_primitives: usize, + /// Number of tessellated meshes (text/rects/images become meshes). + meshes: usize, + /// Total vertex count across all tessellated meshes. + vertices: usize, + /// Area of the screen used by the panels this frame. + used_rect: egui::Rect, +} + +impl FrameStats { + fn empty() -> Self { + Self { + shapes: 0, + clipped_primitives: 0, + meshes: 0, + vertices: 0, + used_rect: egui::Rect::NOTHING, + } + } +} + +/// A `ForgeApp` plus the headless egui context driving it. +struct HeadlessEditor { + ctx: egui::Context, + app: ForgeApp, + size: egui::Vec2, + frame: u64, +} + +impl HeadlessEditor { + fn new(size: egui::Vec2, doc: SceneDoc) -> Self { + let ctx = egui::Context::default(); + let app = ForgeApp::new_for_test(&ctx, doc); + Self { + ctx, + app, + size, + frame: 0, + } + } + + fn dispatch(&mut self, action: Action) { + let ctx = self.ctx.clone(); + self.app.dispatch_action(&ctx, action); + } + + fn frame(&mut self) -> FrameStats { + self.frame_with_events(Vec::new()) + } + + fn frame_with_events(&mut self, events: Vec) -> FrameStats { + self.frame += 1; + let mut raw = egui::RawInput::default(); + raw.screen_rect = Some(egui::Rect::from_min_size(egui::Pos2::ZERO, self.size)); + raw.viewports + .entry(egui::ViewportId::ROOT) + .or_default() + .native_pixels_per_point = Some(1.0); + // Deterministic, monotonically increasing time keeps animations/toasts stable. + raw.time = Some(self.frame as f64 / 60.0); + raw.predicted_dt = 1.0 / 60.0; + raw.events = events; + + let ctx = self.ctx.clone(); + let output = ctx.run(raw, |ctx| self.app.ui(ctx)); + let shape_count = output.shapes.len(); + let clipped = ctx.tessellate(output.shapes, output.pixels_per_point); + let mut meshes = 0usize; + let mut vertices = 0usize; + for primitive in &clipped { + if let egui::epaint::Primitive::Mesh(mesh) = &primitive.primitive { + meshes += 1; + vertices += mesh.vertices.len(); + } + } + FrameStats { + shapes: shape_count, + clipped_primitives: clipped.len(), + meshes, + vertices, + used_rect: ctx.used_rect(), + } + } + + fn frames(&mut self, count: usize) -> FrameStats { + let mut last = FrameStats::empty(); + for _ in 0..count { + last = self.frame(); + } + last + } + + /// Render one frame and assert it painted something tessellable. + fn expect_frame(&mut self, context: &str) -> FrameStats { + let stats = self.frame(); + assert!( + stats.shapes > 0, + "{context}: the frame produced no egui shapes" + ); + assert!( + stats.clipped_primitives > 0, + "{context}: tessellation produced no clipped primitives" + ); + assert!( + stats.meshes > 0 && stats.vertices > 0, + "{context}: tessellation produced no mesh vertices" + ); + assert!( + stats.used_rect.is_finite(), + "{context}: non-finite used rect {:?}", + stats.used_rect + ); + stats + } +} + +fn key_event(key: egui::Key) -> egui::Event { + egui::Event::Key { + key, + physical_key: None, + pressed: true, + repeat: false, + modifiers: egui::Modifiers::NONE, + } +} + +fn assert_rendered(app: &ForgeApp, stats: &FrameStats, label: &str, size: egui::Vec2) { + assert!( + stats.shapes > 0, + "{label} at {size:?}: expected at least one painted egui shape" + ); + assert!( + stats.clipped_primitives > 0 && stats.meshes > 0 && stats.vertices > 0, + "{label} at {size:?}: tessellation was empty (shapes={}, prims={}, meshes={}, verts={})", + stats.shapes, + stats.clipped_primitives, + stats.meshes, + stats.vertices + ); + assert!( + stats.used_rect.is_finite() && stats.used_rect.is_positive(), + "{label} at {size:?}: used rect is not a finite, positive area: {:?}", + stats.used_rect + ); + + let viewport = app.ui.viewport_rect; + assert!( + viewport.is_finite(), + "{label} at {size:?}: viewport rect is non-finite: {viewport:?}" + ); + assert!( + viewport.width() > 1.0 && viewport.height() > 1.0, + "{label} at {size:?}: viewport rect has no area: {viewport:?}" + ); + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, size); + assert!( + viewport.intersects(screen), + "{label} at {size:?}: viewport rect {viewport:?} does not intersect the {size:?} screen" + ); + eprintln!( + "render-smoke {label} {size:?}: shapes={} prims={} meshes={} verts={} used={:?} viewport={:?}", + stats.shapes, stats.clipped_primitives, stats.meshes, stats.vertices, + stats.used_rect, viewport + ); +} + +// --------------------------------------------------------------------------- +// 1. Render smoke +// --------------------------------------------------------------------------- + +#[test] +fn render_smoke_both_sizes_and_view_modes() { + isolate_editor_config(); + + for size in [egui::vec2(1440.0, 900.0), egui::vec2(1000.0, 640.0)] { + // 2D demo: starts in 2D, renders, then switches to the 3D viewport. + let mut editor = HeadlessEditor::new(size, samples::platformer_demo()); + assert_eq!(editor.app.screen, Screen::Editor); + assert_eq!(editor.app.ui.view_mode, ViewMode::TwoD); + + let stats = editor.frames(8); + assert_rendered(&editor.app, &stats, "platformer demo (2D)", size); + + editor.dispatch(Action::SetViewMode(ViewMode::ThreeD)); + assert_eq!(editor.app.ui.view_mode, ViewMode::ThreeD); + let stats = editor.frames(6); + assert_rendered(&editor.app, &stats, "platformer demo (3D)", size); + + // 3D demo: the full garden scene rendered by the 3D viewport. + let mut editor = HeadlessEditor::new(size, samples::garden_demo()); + editor.dispatch(Action::SetViewMode(ViewMode::ThreeD)); + assert_eq!(editor.app.ui.view_mode, ViewMode::ThreeD); + let stats = editor.frames(8); + assert_rendered(&editor.app, &stats, "garden demo (3D)", size); + } +} + +// --------------------------------------------------------------------------- +// 2. Panels, palette and dialogs +// --------------------------------------------------------------------------- + +#[test] +fn panel_toggles_palette_and_dialogs_render() { + isolate_editor_config(); + + let mut editor = HeadlessEditor::new(egui::vec2(1280.0, 800.0), samples::platformer_demo()); + editor.frames(4); + + // Assets drawer. + assert!( + editor.app.ui.show_assets, + "assets drawer is visible by default" + ); + editor.dispatch(Action::ToggleAssets); + assert!( + !editor.app.ui.show_assets, + "ToggleAssets must hide the asset drawer" + ); + editor.expect_frame("assets drawer hidden"); + editor.dispatch(Action::ToggleAssets); + assert!(editor.app.ui.show_assets); + editor.expect_frame("assets drawer visible"); + + // Left hierarchy panel. + assert!(editor.app.ui.show_left_panel); + editor.dispatch(Action::ToggleLeftPanel); + assert!(!editor.app.ui.show_left_panel); + editor.expect_frame("hierarchy panel hidden"); + editor.dispatch(Action::ToggleLeftPanel); + assert!(editor.app.ui.show_left_panel); + editor.expect_frame("hierarchy panel visible"); + + // Right inspector panel. + assert!(editor.app.ui.show_right_panel); + editor.dispatch(Action::ToggleRightPanel); + assert!(!editor.app.ui.show_right_panel); + editor.expect_frame("inspector panel hidden"); + editor.dispatch(Action::ToggleRightPanel); + assert!(editor.app.ui.show_right_panel); + editor.expect_frame("inspector panel visible"); + + // Grid and snapping flags (both render in the viewport). + let grid = editor.app.ui.show_grid; + editor.dispatch(Action::ToggleGrid); + assert_eq!( + editor.app.ui.show_grid, !grid, + "ToggleGrid must flip the flag" + ); + editor.expect_frame("grid off"); + editor.dispatch(Action::ToggleGrid); + assert_eq!(editor.app.ui.show_grid, grid); + editor.expect_frame("grid on"); + + let snap = editor.app.ui.snap_to_grid; + editor.dispatch(Action::ToggleSnap); + assert_eq!( + editor.app.ui.snap_to_grid, !snap, + "ToggleSnap must flip the flag" + ); + editor.expect_frame("snap on"); + editor.dispatch(Action::ToggleSnap); + assert_eq!(editor.app.ui.snap_to_grid, snap); + + // Command palette: opened by action, closed by Escape. + editor.dispatch(Action::OpenCommandPalette); + assert!(editor.app.palette.open, "palette must open"); + editor.expect_frame("command palette open"); + let stats = editor.frame_with_events(vec![key_event(egui::Key::Escape)]); + assert!( + !editor.app.palette.open, + "Escape must close the command palette" + ); + assert!(stats.shapes > 0, "closing frame must still paint"); + editor.expect_frame("command palette closed"); + + // Save-as dialog. + editor.dispatch(Action::OpenSaveAsDialog); + assert!(editor.app.dialogs.save_as_open, "Save As must open"); + assert!( + !editor.app.dialogs.save_as_path.is_empty(), + "Save As pre-fills a path" + ); + editor.expect_frame("save-as dialog open"); + editor.app.dialogs.save_as_open = false; + editor.expect_frame("save-as dialog closed"); + + // Open-project dialog. + editor.dispatch(Action::OpenProjectDialog); + assert!(editor.app.dialogs.open_project_open); + editor.expect_frame("open-project dialog open"); + editor.app.dialogs.open_project_open = false; + editor.expect_frame("open-project dialog closed"); + + // Open-scene dialog. + editor.dispatch(Action::OpenSceneDialog); + assert!(editor.app.dialogs.open_scene_open); + editor.expect_frame("open-scene dialog open"); + editor.app.dialogs.open_scene_open = false; + editor.expect_frame("open-scene dialog closed"); + + // Export dialog (exercises code generation for the preview). + editor.dispatch(Action::OpenExportDialog(bevy_forge_core::ExportFormat::Bsn)); + assert!(editor.app.export.dialog_open, "export dialog must open"); + assert!( + editor.app.export.code.is_some() || editor.app.export.error.is_some(), + "export dialog must have a preview or an error" + ); + editor.expect_frame("export dialog open"); + editor.app.export.dialog_open = false; + editor.expect_frame("export dialog closed"); + + // Shortcuts + about dialogs. + editor.dispatch(Action::ShowShortcuts); + assert!(editor.app.dialogs.shortcuts_open); + editor.expect_frame("shortcuts dialog open"); + editor.app.dialogs.shortcuts_open = false; + editor.expect_frame("shortcuts dialog closed"); + + editor.dispatch(Action::ShowAbout); + assert!(editor.app.dialogs.about_open); + editor.expect_frame("about dialog open"); + editor.app.dialogs.about_open = false; + let stats = editor.expect_frame("all dialogs closed"); + + // Everything restored: the viewport is still laid out after all toggles. + assert!( + editor.app.ui.viewport_rect.is_positive(), + "viewport rect must be laid out after all toggles: {:?}", + editor.app.ui.viewport_rect + ); + assert!(stats.shapes > 0); +} + +// --------------------------------------------------------------------------- +// 3. Actions +// --------------------------------------------------------------------------- + +#[test] +fn actions_undo_redo_duplicate_delete_and_flags() { + isolate_editor_config(); + + let mut editor = HeadlessEditor::new(egui::vec2(1280.0, 800.0), samples::platformer_demo()); + editor.frames(2); + + let base_entities = editor.app.session.doc().entities.len(); + assert!(base_entities > 0, "demo scene must have entities"); + + // -- Undo / redo after an edit ----------------------------------------- + let player = editor + .app + .session + .doc() + .find_by_name("Player") + .expect("platformer demo contains a Player entity"); + editor.app.ui.selection.select_only(player); + editor.app.session.edit("Rename Player", |doc| { + doc.rename(player, "Hero"); + }); + assert!(editor.app.session.can_undo()); + assert!(editor.app.session.is_dirty()); + assert_eq!(editor.app.session.undo_label(), Some("Rename Player")); + + editor.dispatch(Action::Undo); + assert!( + editor.app.session.doc().find_by_name("Player").is_some(), + "Undo must restore the original name" + ); + assert!( + editor.app.ui.selection.contains(player), + "selection survives undo when the entity still exists" + ); + assert!(editor.app.session.can_redo()); + assert_eq!(editor.app.session.redo_label(), Some("Rename Player")); + editor.expect_frame("after undo"); + + editor.dispatch(Action::Redo); + assert!( + editor.app.session.doc().find_by_name("Hero").is_some(), + "Redo must reapply the renamed entity" + ); + editor.expect_frame("after redo"); + + // -- View mode flags ---------------------------------------------------- + editor.dispatch(Action::SetViewMode(ViewMode::ThreeD)); + assert_eq!(editor.app.ui.view_mode, ViewMode::ThreeD); + editor.expect_frame("3D view mode"); + assert!( + editor.app.ui.viewport_rect.is_positive(), + "3D viewport rect must be laid out: {:?}", + editor.app.ui.viewport_rect + ); + editor.dispatch(Action::SetViewMode(ViewMode::TwoD)); + assert_eq!(editor.app.ui.view_mode, ViewMode::TwoD); + editor.expect_frame("2D view mode"); + + // -- Grid toggle -------------------------------------------------------- + let grid = editor.app.ui.show_grid; + editor.dispatch(Action::ToggleGrid); + assert_eq!(editor.app.ui.show_grid, !grid); + editor.dispatch(Action::ToggleGrid); + assert_eq!(editor.app.ui.show_grid, grid); + + // -- Duplicate (deep copy of Player + child Hat) ------------------------ + assert_eq!(editor.app.ui.selection.len(), 1); + editor.dispatch(Action::DuplicateSelection); + assert_eq!( + editor.app.session.doc().entities.len(), + base_entities + 2, + "duplicating Player must also duplicate its child Hat" + ); + let after_duplicate = editor.app.session.doc().entities.len(); + let selection_after_duplicate = editor.app.ui.selection.len(); + assert_eq!( + editor.app.ui.selection.len(), + 1, + "the duplicated subtree root becomes the selection" + ); + let duplicate = editor + .app + .ui + .selection + .primary() + .expect("duplicate becomes primary"); + assert_ne!(duplicate, player); + assert!(editor.app.session.doc().entity(duplicate).is_some()); + assert_eq!(editor.app.session.undo_label(), Some("Duplicate entities")); + editor.expect_frame("after duplicate"); + + // -- Delete selection --------------------------------------------------- + editor.dispatch(Action::DeleteSelection); + assert_eq!( + editor.app.session.doc().entities.len(), + base_entities, + "deleting the duplicate removes the copied subtree" + ); + assert!(editor.app.ui.selection.is_empty()); + assert_eq!(editor.app.session.undo_label(), Some("Delete entities")); + editor.expect_frame("after delete"); + let after_delete = editor.app.session.doc().entities.len(); + + editor.dispatch(Action::Undo); + assert_eq!( + editor.app.session.doc().entities.len(), + base_entities + 2, + "undo restores the deleted duplicate subtree" + ); + let after_undo_delete = editor.app.session.doc().entities.len(); + editor.dispatch(Action::Redo); + assert_eq!( + editor.app.session.doc().entities.len(), + base_entities, + "redo deletes it again" + ); + eprintln!( + "actions: base_entities={base_entities}, after_duplicate={after_duplicate} \ + selection_after_duplicate={selection_after_duplicate}, after_delete={after_delete}, \ + after_undo_delete={after_undo_delete}, after_redo_delete={}", + editor.app.session.doc().entities.len() + ); + + // -- Palette command path ---------------------------------------------- + editor.dispatch(Action::OpenCommandPalette); + assert!(editor.app.palette.open); + editor.expect_frame("palette via action"); + editor.frame_with_events(vec![key_event(egui::Key::Escape)]); + assert!(!editor.app.palette.open); + + // -- Save without a project must ask for a path ------------------------- + editor.dispatch(Action::Save); + assert!( + editor.app.dialogs.save_as_open, + "Save without a project must open the Save As dialog" + ); +} + +// --------------------------------------------------------------------------- +// 4. Persistence +// --------------------------------------------------------------------------- + +/// Create a minimal Bevy project on disk and return its root. +fn write_temp_project(dir: &Path) -> PathBuf { + std::fs::write( + dir.join("Cargo.toml"), + "[package]\n\ + name = \"forge-test\"\n\ + version = \"0.1.0\"\n\ + edition = \"2021\"\n\ + \n\ + [dependencies]\n\ + bevy = \"0.20.0\"\n", + ) + .expect("write Cargo.toml"); + std::fs::create_dir_all(dir.join("assets")).expect("create assets dir"); + std::fs::create_dir_all(dir.join("scenes")).expect("create scenes dir"); + dir.to_path_buf() +} + +#[test] +fn persistence_save_load_round_trip_and_corrupt_file() { + isolate_editor_config(); + + let temp = tempfile::tempdir().expect("temp dir"); + let root = write_temp_project(temp.path()); + + // First session: open the project, author a scene, save it. + let ctx = egui::Context::default(); + let mut app = ForgeApp::new_for_test(&ctx, samples::empty_scene()); + assert!( + app.open_project(&root), + "open_project must accept a Bevy project" + ); + assert_eq!(app.screen, Screen::Editor); + let project = app.project.clone().expect("project is open"); + assert_eq!(project.name, "forge-test"); + assert_eq!(app.assets.len(), 0, "project assets dir is empty"); + + app.session.edit("Build round-trip scene", |doc| { + doc.name = "Round Trip".into(); + let level = doc.add_entity("Level", None); + let level_node = doc.entity_mut(level).expect("level exists"); + level_node.transform = Transform::from_xyz(1.5, -2.25, 3.75); + + let crate_id = doc.add_entity("Crate", Some(level)); + let crate_node = doc.entity_mut(crate_id).expect("crate exists"); + crate_node.transform = Transform { + translation: Vec3::new(0.5, 1.0, -0.5), + rotation: Vec3::new(0.0, 45.0, 0.0), + scale: Vec3::new(2.0, 2.0, 2.0), + }; + doc.add_component(crate_id, ComponentData::Sprite(SpriteData::default())); + let camera = doc.add_entity("Camera", None); + doc.add_component(camera, ComponentData::Camera2d); + }); + assert!(app.session.is_dirty()); + + let scene_path = project.scenes_dir().join("test.forge.ron"); + app.save_scene_to(&scene_path); + assert!( + scene_path.is_file(), + "save_scene_to must write {}", + scene_path.display() + ); + assert!(!app.session.is_dirty(), "saving must clear the dirty flag"); + assert_eq!( + app.current_scene_path.as_deref(), + Some(scene_path.as_path()), + "the saved path becomes the current scene" + ); + assert_eq!( + app.session.doc().check_integrity(), + Vec::::new(), + "saved scene must be structurally valid" + ); + eprintln!( + "persistence: saved {} ({} entities, dirty={})", + scene_path.display(), + app.session.doc().entities.len(), + app.session.is_dirty() + ); + + let saved_text = std::fs::read_to_string(&scene_path).expect("read saved scene"); + assert!( + saved_text.contains("Round Trip"), + "scene file must contain the scene name" + ); + let loaded = project.load_scene(&scene_path).expect("reload scene"); + assert_eq!(loaded.name, "Round Trip"); + assert_eq!(loaded.entities.len(), 3); + + // Second session: reopen the project and the scene, compare the document. + let ctx2 = egui::Context::default(); + let mut app2 = ForgeApp::new_for_test(&ctx2, samples::empty_scene()); + assert!(app2.open_project(&root), "reopen project"); + assert!( + app2.current_scene_path.is_some(), + "opening a project with a scene must auto-open it" + ); + assert!(app2.open_scene(&scene_path), "explicitly reopen the scene"); + assert_eq!(app2.screen, Screen::Editor); + assert!(!app2.session.is_dirty()); + + let doc2 = app2.session.doc(); + assert_eq!(doc2.name, "Round Trip"); + assert_eq!(doc2.entities.len(), 3); + assert_eq!(doc2.check_integrity(), Vec::::new()); + + let level = doc2.find_by_name("Level").expect("Level survives reload"); + let level_node = doc2.entity(level).expect("level node"); + assert_eq!( + level_node.transform.translation, + Vec3::new(1.5, -2.25, 3.75) + ); + + let crate_id = doc2.find_by_name("Crate").expect("Crate survives reload"); + let crate_node = doc2.entity(crate_id).expect("crate node"); + assert_eq!(crate_node.parent, Some(level)); + assert_eq!(crate_node.transform.translation, Vec3::new(0.5, 1.0, -0.5)); + assert_eq!(crate_node.transform.rotation, Vec3::new(0.0, 45.0, 0.0)); + assert_eq!(crate_node.transform.scale, Vec3::new(2.0, 2.0, 2.0)); + assert!(matches!( + crate_node.component("sprite"), + Some(ComponentData::Sprite(_)) + )); + + // Corrupt scene files must fail gracefully and leave the session untouched. + let corrupt = root.join("scenes").join("corrupt.forge.ron"); + std::fs::write(&corrupt, "this is not a SceneDoc {{{").expect("write corrupt scene"); + app2.toasts.clear(); + let before_name = app2.session.doc().name.clone(); + let before_entities = app2.session.doc().entities.len(); + assert!( + !app2.open_scene(&corrupt), + "opening a corrupt scene must report failure" + ); + assert_eq!( + app2.session.doc().name, + before_name, + "session must be unchanged" + ); + assert_eq!( + app2.session.doc().entities.len(), + before_entities, + "session entity count must be unchanged" + ); + assert_eq!(app2.screen, Screen::Editor, "the editor stays open"); + assert!( + !app2.toasts.is_empty(), + "a load failure must surface an error toast" + ); + + // Missing project / scene paths do not panic either. + let mut bare = ForgeApp::new_for_test(&ctx2, samples::empty_scene()); + assert!(!bare.open_scene(&root.join("scenes/nope.forge.ron"))); + assert!(!bare.toasts.is_empty()); +} + +// --------------------------------------------------------------------------- +// 5. Close guard / quit flags +// --------------------------------------------------------------------------- + +#[test] +fn close_guard_requests_confirmation_and_quit_flags() { + isolate_editor_config(); + let ctx = egui::Context::default(); + + // A clean editor quits immediately. + let mut clean = ForgeApp::new_for_test(&ctx, samples::starter_2d()); + let mut frame = 0u64; + run_one(&ctx, &mut clean, &mut frame); + assert!(!clean.session.is_dirty()); + clean.dispatch_action(&ctx, Action::RequestQuit); + assert!(clean.quit_now, "a clean editor quits without prompting"); + assert!( + !clean.dialogs.confirm_quit_open, + "no confirmation for a clean document" + ); + + // A dirty document asks for confirmation instead of quitting. + let mut dirty = ForgeApp::new_for_test(&ctx, samples::starter_2d()); + dirty.session.edit("Add entity", |doc| { + doc.add_entity("Extra", None); + }); + assert!(dirty.session.is_dirty()); + dirty.dispatch_action(&ctx, Action::RequestQuit); + assert!( + dirty.dialogs.confirm_quit_open, + "RequestQuit on a dirty document must open the confirmation dialog" + ); + assert!(!dirty.quit_now, "the app must not quit before confirmation"); + assert!(!dirty.allow_close, "the close guard must stay armed"); + + // The confirmation dialog renders without closing the context. + let mut frame = 2u64; + let stats = run_one(&ctx, &mut dirty, &mut frame); + assert!(stats.shapes > 0, "the unsaved-changes dialog must paint"); + + // Confirming via QuitNow sets both flags; the test ends without a window. + dirty.dispatch_action(&ctx, Action::QuitNow); + assert!(dirty.allow_close, "QuitNow must allow the close"); + assert!(dirty.quit_now, "QuitNow must request the quit"); + let stats = run_one(&ctx, &mut dirty, &mut frame); + assert!( + stats.shapes > 0, + "the app keeps painting until eframe closes" + ); +} + +/// One deterministic headless frame for a bare `ForgeApp` (close-guard test). +fn run_one(ctx: &egui::Context, app: &mut ForgeApp, frame: &mut u64) -> FrameStats { + let mut raw = egui::RawInput::default(); + raw.screen_rect = Some(egui::Rect::from_min_size( + egui::Pos2::ZERO, + egui::vec2(1200.0, 760.0), + )); + raw.viewports + .entry(egui::ViewportId::ROOT) + .or_default() + .native_pixels_per_point = Some(1.0); + *frame += 1; + raw.time = Some(*frame as f64 / 60.0); + raw.predicted_dt = 1.0 / 60.0; + let output = ctx.run(raw, |ctx| app.ui(ctx)); + let shapes = output.shapes.len(); + let clipped = ctx.tessellate(output.shapes, output.pixels_per_point); + let mut meshes = 0usize; + let mut vertices = 0usize; + for primitive in &clipped { + if let egui::epaint::Primitive::Mesh(mesh) = &primitive.primitive { + meshes += 1; + vertices += mesh.vertices.len(); + } + } + FrameStats { + shapes, + clipped_primitives: clipped.len(), + meshes, + vertices, + used_rect: ctx.used_rect(), + } +} diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..03f9381 --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,376 @@ +# Bevy Forge architecture + +This page describes how the Bevy Forge workspace is put together: the crates, the scene model, the +editor's panel/action model, persistence, code generation and the testing strategy. It documents the +architecture the code is being built against; individual modules may still be in progress in a given +checkout. + +- Product overview and quick start: [README](../README.md) +- Scene file reference: [Scene format](format.md) +- Export templates: [BSN export](bsn-export.md) + +## Design constraints + +1. **No Bevy dependency in the editor.** `bevy_forge_core` and `bevy_forge_editor` depend only on + serde/RON/IndexMap/thiserror/toml in core and eframe/egui/image in the editor. The only crate that + links Bevy is the excluded `compat/bsn-smoke` verification crate. This keeps editor builds fast + and independent of Bevy's release cadence. +2. **Export is source code.** A `.forge.ron` scene stores editor data only. BSN or imperative Rust + is generated on demand and never stored in the scene file. Code generation is deterministic for a + given document and options. +3. **One scene format.** `.forge.ron` (RON via serde) is the only scene extension. It is Forge's own + format, not Bevy's `.scn.ron` dynamic-scene format. +4. **Snapshot undo.** The document is small enough that full snapshots are simpler and safer than + inverse operations. Every undoable mutation goes through one session API. +5. **Immediate-mode UI with deferred actions.** Panels render and return `Action` values. Only the + app shell performs file I/O, spawning, saving and exporting. + +## Workspace at a glance + +```text + +--------------------------------+ + | bevy_forge_core | + | model - project - validation | + | history - assets - codegen | + +---------------+----------------+ + ^ + depends on | depends on + +--------------------+--------------------+ + | | ++------------+-------------+ +------------+-------------+ +| bevy_forge_editor | | bevy_forge_cli | +| eframe/egui application | | clap binary `forge` | +| binary: `bevy-forge` | | | ++--------------------------+ +--------------------------+ + + compat/bsn-smoke (excluded from the workspace, depends on Bevy 0.20.0) + compiles representative generated BSN to prove the export is valid Rust. +``` + +| Crate / directory | Role | Binary / artifact | +|---|---|---| +| `crates/bevy_forge_core` | Document model, structural ops, project and scene persistence, undo, asset index, validation, code generation | library | +| `crates/bevy_forge_editor` | Native editor (eframe/egui): panels, viewports, dialogs, palette, toasts | `bevy-forge` | +| `crates/bevy_forge_cli` | `forge new`, `validate`, `export`, `info`, `scenes` | `forge` | +| `examples/demo-project` | Real Bevy 0.20 project used as the first-run demo and screenshot subject | not built by the workspace | +| `compat/bsn-smoke` | Compiles generated BSN against real Bevy 0.20.0; excluded from the workspace | standalone crate | +| `scripts/` | End-to-end generated-code check | shell script | + +The root `Cargo.toml` declares the three workspace members and excludes `compat/bsn-smoke` so Bevy +is never built as part of the normal workspace. + +## Core model + +All types below are public and frozen for this release. + +| Type | Module | Meaning | +|---|---|---| +| `EntityId` (`u64`) | `scene` | Stable entity identity inside one scene file | +| `SceneDoc` | `scene` | `format_version`, `name`, ordered `entities`, ordered `roots`, `next_id` | +| `EntityNode` | `scene` | `id`, `name`, `parent`, ordered `children`, mandatory `transform`, `components`, editor-only `locked` | +| `Transform` | `math` | `translation: Vec3`, `rotation: Vec3` (XYZ Euler **degrees**), `scale: Vec3` | +| `ComponentData` | `components` | Tagged enum of built-in components plus `Custom(CustomComponent)` | +| `Session` | `history` | Document + undo/redo history + dirty flag | +| `History` / `Snapshot` | `history` | Bounded stack of full-document snapshots (default limit 100) | +| `Project` | `project` | Root, package name, Bevy version, assets dir; scene load/save/list | +| `AssetIndex` / `AssetEntry` / `AssetKind` | `assets` | Recursive index of the project's asset files | +| `ValidationReport` / `Issue` / `Severity` | `validation` | Deterministic validation output | +| `ExportFormat` / `CodegenOptions` | `codegen` | Export dispatch and options (function name, header) | +| `EditorConfig` | `config` | Recent projects and editor preferences | + +### Scene identity and ordering + +- IDs are `u64`, start at 1 and are never reused within a scene; `next_id` is monotonic. +- Each entity has exactly one parent (`None` for roots). Cycles are rejected by `reparent`. +- `roots` and every `children` vector define display order and are preserved by save/load. +- `SceneDoc::entities` is an `IndexMap`; insertion order is stable for serialization. +- `EntityNode.id` must equal its map key; `SceneDoc::check_integrity()` reports mismatches, dangling + parents/children, duplicate references and wrong root/child links. +- Every structural edit must leave `check_integrity() == []`. +- `locked` is editor-only metadata: it blocks viewport transforms, not deletion or hierarchy edits. + +### Transform semantics + +`Transform.rotation` stores XYZ Euler angles in degrees because that is what the inspector edits. +Hierarchy composition converts degrees to a quaternion (`quat_from_euler_deg`), composes parent and +child, and converts back to Euler degrees. Export converts the other way with +`Quat::from_euler(EulerRot::XYZ, x.to_radians(), y.to_radians(), z.to_radians())`. There is no +ambiguity between "editor degrees" and "Bevy radians" because conversion only happens at the +composition and code-generation boundaries. + +`Name` and `Transform` are node fields in `.forge.ron`, not entries in `components`; the BSN and Rust +generators always emit them for every entity. + +### Components + +`ComponentData` is a serde-tagged enum: + +- `Sprite(SpriteData)`, `Text2d(Text2dData)`, `Camera2d`, `Camera3d`, + `PointLight(PointLightData)`, `DirectionalLight(DirectionalLightData)`, `Mesh3d(Mesh3dData)`, + `Visibility(VisibilityMode)`, `Custom(CustomComponent)`. +- `ComponentData::key()` is the uniqueness key: `"sprite"`, `"text2d"`, `"camera2d"`, `"camera3d"`, + `"point_light"`, `"directional_light"`, `"mesh3d"`, `"visibility"`, and + `"custom:"`. `add_component` refuses duplicate keys; custom components are unique per + type path, so two different custom types can coexist. +- `Mesh3dData` holds `MeshSource` (`Primitive(PrimitiveShape)` or `Asset(String)`) and + `MaterialData`. Primitives are cuboid, sphere, plane, cylinder and capsule. +- `CustomComponent` stores a Rust type path plus a list of `(name, expr)` fields. Expressions are + raw Rust/BSN text, validated only for identifier shape and non-emptiness. + +## Undo/redo + +`Session` wraps the document and owns the history: + +```rust +// One undo entry, recorded only if the document actually changed. +session.edit("Rename entity", |doc| { + doc.rename(id, "Player"); +}); + +// Continuous interactions (gizmo drags): snapshot before, mutate every frame, +// commit once on release. +let before = session.doc().clone(); +// ... update transforms during the drag ... +session.commit(before, "Move entities"); +``` + +- `edit` clones the document before running the closure and records a snapshot only when the result + differs. +- `commit` accepts a caller-supplied pre-edit snapshot and records it once at the end of a drag. +- `doc_mut` is the escape hatch for read-modify-write UI code: it marks the session dirty but adds + no undo point, so it must not be used for user-visible edits. +- `undo`/`redo` swap snapshots and return the action label; the app puts that label in the status + bar. +- `mark_saved` clears the dirty flag, `replace` swaps in a new document and clears history (used + when opening or creating a scene), and `is_dirty` drives the title dot, autosave and quit guard. +- A new edit clears the redo stack. The default history limit is 100 snapshots. + +## Persistence + +### Opening a project + +`Project::open(root)` requires an existing directory with a `Cargo.toml` containing a `bevy` +dependency (normal, workspace or dev dependencies; a string or a `{ version = ... }` table). It +canonicalizes the root, reads the package name, and records the Bevy version string. The assets +directory is `/assets` when it exists, otherwise `` (so projects without a dedicated +assets folder still work). + +Scenes live in `/scenes/*.forge.ron`. `Project::load_scene` parses RON and normalizes a legacy +`format_version: 0` to the current version in memory. `Project::save_scene` accepts an optional +path (defaulting to a slugified scene name), refuses paths outside the project root, creates parent +directories, serializes with the pretty RON printer and writes atomically: a temporary file +`..tmp-` in the target directory, then `rename` over the destination. + +### Autosave and configuration + +When autosave is enabled (`autosave_secs > 0`, default 60), the app writes a dirty document to the +project's `.forge/autosave.forge.ron` no more often than that interval. Autosave never marks the +session saved and never replaces an explicit save; `.forge/` is ignored by version control. + +Editor preferences (recent projects, last project, grid/snap/show-assets flags, autosave interval) +are stored as RON in the platform config directory, honouring `XDG_CONFIG_HOME` on Unix: +`/bevy-forge/config.ron`. + +## Assets and validation + +`AssetIndex::scan(assets_dir)` recursively indexes regular files under the assets directory, skipping +hidden entries, symlinks, and `target` / `build` / `node_modules` directories, with a depth cap. +Paths are `/`-separated and relative to the assets directory and are sorted for determinism. Files +are classified by extension into `Image`, `Mesh`, `Audio`, `Font`, `Scene`, `Shader`, `Material` and +`Other`. A missing or unreadable assets directory yields an empty index with an `error` string; the +editor shows that state instead of failing. + +`validate_scene(doc, assets: Option<&AssetIndex>) -> ValidationReport` runs structural checks, +range checks and (only when an index is supplied) asset existence/kind checks. Severities order as +`Info < Warning < Error`; the report offers `is_ok`, `errors`, `warnings`, `count`, `by_entity` and +`sorted`. Validation must never panic on malformed forests and is deterministic (severity +descending, then entity, then message). See [the format reference](format.md#validation) for the +rule list. + +## Code generation + +```rust +pub enum ExportFormat { Bsn, Rust } + +pub struct CodegenOptions { + pub function_name: String, // a Rust identifier that is not a keyword + pub include_header: bool, +} +``` + +The dispatch method is `ExportFormat::generate(self, doc: &SceneDoc, options: &CodegenOptions) -> +Result`, implemented in `codegen.rs` and forwarded to `codegen::bsn` or +`codegen::rust`. + +- `codegen.rs` owns the shared options and dispatch; `codegen/bsn.rs` emits Bevy 0.20 BSN and + `codegen/rust.rs` emits imperative `Commands::spawn` code. +- Both generators walk roots and children in document order and emit `Name` and `Transform` for every + entity, followed by components in stored order. +- Generation is pure: no file access, no globals, no timestamps. The same document and options + produce identical text. +- Errors are explicit: `EmptyScene`, `InvalidIdentifier`, `NonFinite` (transform, material or light + value) and `InvalidCustomComponent`. Invalid scenes must not panic the generator. +- The BSN generator conditionally adds asset parameters (`meshes`, `materials`) to the function + signature, based on what the document uses, and chooses `bsn!` (single root, `impl Scene`) or + `bsn_list!` (multiple roots, `impl SceneList`). +- The CLI and editor are thin wrappers around `generate`: the CLI prints to stdout or writes `-o`; + the editor shows a preview, can copy to the clipboard and can save to a file. + +## Editor shell + +`ForgeApp` owns all long-lived state: `Session`, `UiState`, `Project`, `AssetIndex`, `TextureCache`, +`Toasts`, `EditorConfig`, `ExportState`, every panel state and the current scene path. Panels never +own the document. + +### Frame order + +Each frame, `ForgeApp` builds a borrowed `EditorContext` and runs, in order: + +1. `menu::show` - top menu bar. +2. `shortcuts::handle` - global keyboard shortcuts. +3. `status_bar::show` - counts, selection, dirty state, mode, cursor position. +4. `assets_panel::show` - asset browser (side panel). +5. `hierarchy::show` / `inspector::show` - left/right side panels when visible. +6. The central panel: `viewport2d::show` or `viewport3d::show` according to the view mode. +7. `palette::show`, then `dialogs::show` - overlays. +8. The collected `Vec` is dispatched by the app; autosave runs afterwards. + +Menus, shortcuts, panels and dialogs all return actions instead of performing operations. + +### Context and panels + +```rust +pub struct EditorContext<'a> { + pub session: &'a mut Session, + pub project: Option<&'a Project>, + pub assets: &'a AssetIndex, + pub textures: &'a mut TextureCache, + pub ui: &'a mut UiState, + pub toasts: &'a mut Toasts, + pub export: &'a mut ExportState, +} +``` + +```text +// Frozen panel entry points (types abridged; see the module sources). +hierarchy::show(ctx: &egui::Context, cx: &mut EditorContext<'_>, state: &mut HierarchyPanelState) -> Vec +inspector::show(ctx: &egui::Context, cx: &mut EditorContext<'_>, state: &mut InspectorPanelState) -> Vec +assets_panel::show(ctx: &egui::Context, cx: &mut EditorContext<'_>, state: &mut AssetsPanelState) -> Vec +viewport2d::show(ui: &mut egui::Ui, cx: &mut EditorContext<'_>, state: &mut Viewport2dState) -> Vec +viewport3d::show(ui: &mut egui::Ui, cx: &mut EditorContext<'_>, state: &mut Viewport3dState) -> Vec +menu::show(ctx: &egui::Context, cx: &EditorContext<'_>) -> Vec +palette::show(ctx: &egui::Context, cx: &mut EditorContext<'_>, state: &mut PaletteState) -> Vec +dialogs::show(ctx: &egui::Context, cx: &mut EditorContext<'_>, state: &mut DialogsState) -> Vec +welcome::show(ctx: &egui::Context, state: &mut WelcomeState, config: &EditorConfig) -> Vec +shortcuts::handle(ctx: &egui::Context, session: &Session) -> Vec +status_bar::show(ctx: &egui::Context, cx: &EditorContext<'_>) +``` + +Rules that keep panels independent and testable: + +- Selection changes are immediate (`cx.ui.selection.select_only/add/toggle/...`); app-level + operations become actions. +- Scene mutations go through `cx.session.edit(...)` (or snapshot + `commit` for drags). +- Panels never save files, spawn processes, open dialogs themselves or change the active screen. +- Each panel owns a `Default`-constructible state struct stored by `ForgeApp`. +- Input focus is not stolen except by the command palette and dialogs. +- User feedback uses `cx.ui.status` for routine messages and `cx.toasts` for successes/errors. + +### Actions + +`Action` (in `action.rs`) is the vocabulary between UI and app. It covers scene lifecycle (new, +open, save, save-as), export (BSN/Rust, copy, write), editing (undo, redo, duplicate, delete, +focus), view state (2D/3D, grid, snap, panels, fit), and shell operations (palette, shortcuts, +about, quit, reload assets). `ForgeApp::handle_actions` is the only place that executes them. +Adding an action is a shared-contract change: the variant must be added to `action.rs` and handled +in `app.rs`, never handled inside a panel. + +### UI state + +`UiState` holds multi-selection (ordered set plus a primary entity), view mode, panel visibility, +grid/snap settings, per-panel search strings and asset filter, cursor world position, viewport +rectangle, status text, error banner and the one-frame `fit_view_requested` flag. Panel-specific +state (zoom, pan, drag origins, inline-rename buffer, filters, dialog fields) stays in that panel's +own state struct. + +## Error handling and robustness + +- Core errors are `thiserror` enums (`SceneError`, `ProjectError`, `CodegenError`, `ConfigError`). + User input, file I/O and RON parsing never use `unwrap()`/`expect()`. +- Editor failures are surfaced as toasts or an error banner and leave the previous state usable; + opening a malformed scene must not panic the app. +- CLI user errors are printed to stderr with a non-zero exit code; no panics. +- Asset scanning and texture loading are failure-safe: missing files produce empty/placeholder + states, not crashes. +- Structural operations and validation bound parent-chain walks so malformed forests cannot hang + the UI. + +## Testing strategy + +The project separates pure logic from UI and from real-Bevy compilation so each layer can fail +independently: + +| Layer | What it proves | Mechanism | +|---|---|---| +| Core unit tests | Model, hierarchy, undo, project, config, assets, validation, both generators | `#[cfg(test)]` modules and `crates/bevy_forge_core/tests/` | +| CLI tests | Argument parsing, exit codes, file output, JSON shape | `std::process::Command` with `env!("CARGO_BIN_EXE_forge")` and `tempfile` project fixtures | +| Editor tests | Panels render and emit the expected actions without a window | Headless `egui::Context` frames driving `ForgeApp::new_for_test` / `ForgeApp::ui` and panel functions | +| Generated-code gate | Exported BSN is valid Rust against Bevy 0.20.0 | `compat/bsn-smoke` (`cargo check`) plus `scripts/check-generated-code.sh` | +| Manual/visual | The actual desktop app is usable and looks intentional | Launch with the demo project and capture `docs/screenshot.png` | + +Representative commands (run inside a checkout): + +```bash +cargo test -p bevy_forge_core +cargo test -p bevy_forge_cli +cargo test -p bevy_forge_editor +cargo check --workspace +cd compat/bsn-smoke && cargo check +``` + +Snapshot assumptions are asserted directly: tests create documents through the public structural API +and check `check_integrity()`, round-trip scenes through RON, compare undo/redo states, and compare +generated text against expected templates. The generated-code gate is the only test that links Bevy, +which is why it lives in an excluded crate. + +## Non-goals + +- No live in-game editor plugin, remote protocol or hot reload in 0.1. +- No Bevy dependency in the editor or core crates (and therefore no Bevy types in the scene model). +- No runtime `.bsn` asset format; BSN is generated Rust source. +- No animation editor, `bevy_ui` editor, material graph, terrain tools, collaboration or asset import + pipeline. +- No GPU-rendered viewport; the 3D view is an egui-painted preview. +- No support for Bevy versions other than 0.20 BSN, and no legacy `.scn.ron` dynamic scenes. +- No native file dialogs. + +## Extension points + +- **New built-in component.** Add a `ComponentData` variant plus its data struct, a `ComponentKind` + entry and `key`, default construction, asset references, validation rules, both generator arms, + the inspector's add/remove UI and tests. This is a contract change because `ComponentData` is + frozen. +- **New primitive shape.** Extend `PrimitiveShape` and `half_extents`, emit the verified Bevy + constructor in the BSN/Rust generators, and add inspector fields. Verify the constructor against + Bevy 0.20 before documenting it. +- **New export format.** Add an `ExportFormat` variant, a generator module and dispatch arm, a CLI + value, an editor menu entry and compile/test coverage. +- **New panel or action.** Panels follow the `show(...) -> Vec` pattern and keep their state + in a `Default` struct owned by `ForgeApp`. New actions require editing `action.rs` and handling + them in `app.rs`; panels must not acquire file I/O. +- **New CLI command.** Add a clap subcommand in `crates/bevy_forge_cli`, reuse core APIs, define exit + codes, and add process tests. +- **Project-specific components.** Prefer the custom-component escape hatch over adding built-ins: + store a type path and raw field expressions, and document the required derives. This keeps the + editor independent of game code. +- **Asset kinds.** Extend `AssetKind::from_path` and the asset filter when a new file type needs + first-class browsing. + +Public signatures are frozen for this release. Changing one requires updating the contract and all +call sites rather than editing a panel in isolation. + +## Further reading + +- [Scene format](format.md) - the `.forge.ron` reference. +- [BSN export](bsn-export.md) - templates, derive requirements, Bevy integration. +- [README](../README.md) - quick start, CLI, shortcuts, limitations. diff --git a/docs/bsn-export.md b/docs/bsn-export.md new file mode 100644 index 0000000..d1369bf --- /dev/null +++ b/docs/bsn-export.md @@ -0,0 +1,486 @@ +# BSN export + +Bevy Forge's primary export target is **Bevy 0.20 BSN**: Rust source code written with the `bsn!` +and `bsn_list!` macros from `bevy::scene`. This is the modern scene notation in Bevy 0.20 - not the +legacy `DynamicScene` / `.scn.ron` format. The output is ordinary Rust that your game compiles; there +is no runtime `.bsn` asset to load. + +Related pages: [scene format](format.md), [architecture](architecture.md), [README](../README.md). + +## Where the code comes from + +- **Editor:** File > Export > BSN scene. The dialog shows a preview with the generated function name, + lets you copy it to the clipboard, and can write it to a file (default `export/.rs`). +- **CLI:** + +```bash +cargo run -p bevy_forge_cli -- export examples/demo-project/scenes/garden.forge.ron \ + --format bsn -o /tmp/garden.rs +``` + +Without `-o`, the code is printed to stdout. `--function ` sets the generated function name; +the default is `scene`. The name must be a valid Rust identifier that is not a Rust keyword, +otherwise the exporter returns an error. + +Generated files begin with `use bevy::prelude::*;` (plus a macro import when needed). With +`CodegenOptions::include_header` enabled - the editor's default - a short "generated by Bevy Forge" +comment precedes the imports; the CLI exports without that header, so its stdout and `-o` files start +directly with the import. Generated files end with exactly one newline. Generation is deterministic +for a given scene and options. + +## Output shape + +One root scene: + +```rust +// Generated by Bevy Forge. Paste into a Bevy 0.20 project. +use bevy::prelude::*; + +pub fn scene() -> impl Scene { + bsn! { + Name("Player") + Transform { + translation: Vec3::new(0.0, 0.0, 0.0), + rotation: Quat::from_euler(EulerRot::XYZ, 0.0f32.to_radians(), 0.0f32.to_radians(), 0.0f32.to_radians()), + scale: Vec3::new(1.0, 1.0, 1.0), + } + Sprite { image: "textures/player.png", color: Color::srgba(1.0, 1.0, 1.0, 1.0), custom_size: { Some(Vec2::new(64.0, 64.0)) }, flip_x: false, flip_y: false } + Children [ + Name("Hat") + Transform { + translation: Vec3::new(0.0, 1.0, 0.0), + rotation: Quat::from_euler(EulerRot::XYZ, 0.0f32.to_radians(), 0.0f32.to_radians(), 0.0f32.to_radians()), + scale: Vec3::new(1.0, 1.0, 1.0), + } + Sprite { color: Color::srgba(1.0, 0.5, 0.2, 1.0), flip_x: false, flip_y: false } + ] + } +} +``` + +Multiple roots use `bsn_list!` and return `impl SceneList`. Roots (and children) are separated by +`--`: + +```rust +// Generated by Bevy Forge. Paste into a Bevy 0.20 project. +use bevy::prelude::*; +use bevy::scene::bsn_list; + +pub fn scene() -> impl SceneList { + bsn_list! { + Name("Left") + Transform { + translation: Vec3::new(-2.0, 0.0, 0.0), + rotation: Quat::from_euler(EulerRot::XYZ, 0.0f32.to_radians(), 0.0f32.to_radians(), 0.0f32.to_radians()), + scale: Vec3::new(1.0, 1.0, 1.0), + } + Sprite { color: Color::srgba(0.4, 0.7, 1.0, 1.0), flip_x: false, flip_y: false } + -- + Name("Right") + Transform { + translation: Vec3::new(2.0, 0.0, 0.0), + rotation: Quat::from_euler(EulerRot::XYZ, 0.0f32.to_radians(), 0.0f32.to_radians(), 0.0f32.to_radians()), + scale: Vec3::new(1.0, 1.0, 1.0), + } + Sprite { color: Color::srgba(1.0, 0.6, 0.4, 1.0), flip_x: false, flip_y: false } + } +} +``` + +Rules the generator follows: + +- `bsn!` accepts exactly one root. Two roots inside one `bsn!` are a parse error; use `bsn_list!`. +- `--` separates sibling scenes inside `bsn_list!` and `Children [ ... ]`. The editor's scene order is + preserved. +- Every entity emits `Name("")` and a complete `Transform` block with all + three fields. Display names are emitted as escaped Rust strings, never as `#Identifier` references. +- Rotation is converted from stored XYZ Euler **degrees** to a quaternion: + `Quat::from_euler(EulerRot::XYZ, 45.0f32.to_radians(), 0.0f32.to_radians(), 0.0f32.to_radians())`. + The `f32` suffix is part of the generated output; it keeps the literal type unambiguous. +- Components are emitted in stored order after `Transform`; children appear as a nested `Children` + block. +- Macro imports are normal `use` statements. Do not fully qualify the macros + (`bevy::scene::bsn_list! { ... }` compiles but prints a build-time macro-root warning). + +## Component templates + +### Name + +```rust +Name("Player") +``` + +The string is Rust-escaped. `Name` takes a string-like value; `Name(42)` does not compile. + +### Transform + +```rust +Transform { + translation: Vec3::new(1.0, 0.5, 0.0), + rotation: Quat::from_euler(EulerRot::XYZ, 0.0f32.to_radians(), 45.0f32.to_radians(), 0.0f32.to_radians()), + scale: Vec3::new(1.0, 1.0, 1.0), +} +``` + +All three fields are always present. (Here `45.0f32.to_radians()` is 45 degrees.) BSN component patches +do not support struct-update syntax: `Transform { ..default() }` fails with a type inference error, +so the generator writes every field. + +### Sprite + +```rust +Sprite { image: "textures/player.png", color: Color::srgba(1.0, 1.0, 1.0, 1.0), custom_size: { Some(Vec2::new(64.0, 32.0)) }, flip_x: false, flip_y: false } +``` + +- `image` is omitted when the sprite has no image; `custom_size` is omitted when it is `None`. +- `color` is always emitted. +- `Some(...)` **must be braced** inside a BSN patch; bare `Some(..)` is a parse error. Bare `None` + is accepted, but the generator simply omits optional fields instead. +- For a solid-colour sprite the output is `Sprite { color: ..., flip_x: false, flip_y: false }`. + +### Text2d + +```rust +Text2d("Hello, Bevy!") +TextFont { font_size: FontSize::Px(32.0) } +TextColor(Color::srgba(1.0, 1.0, 1.0, 1.0)) +``` + +Text is a Rust string literal; newlines and quotes are escaped normally. `font_size` is in pixels. + +### Cameras + +```rust +Camera2d +``` + +```rust +Camera3d::default() +``` + +`Camera2d` is emitted bare. `Camera3d` is a constructor call, not a bare identifier. + +### Point light + +```rust +PointLight { color: Color::srgba(1.0, 0.8, 0.6, 1.0), intensity: 800.0, range: 20.0, shadow_maps_enabled: true } +``` + +### Directional light + +```rust +DirectionalLight { color: Color::srgba(1.0, 0.95, 0.85, 1.0), illuminance: 10000.0, shadow_maps_enabled: true } +``` + +**Negative literal caveat:** BSN patches do not accept a leading `-` in a direct field value. A +negative number must be wrapped in a block and suffixed: +`intensity: { -5.0f32 }`. Values produced by calls or paths (`Vec3::new(-1.0, 0.0, 0.0)`, +`f32::NEG_INFINITY`) are fine. The generator uses the braced form when a direct literal is negative. + +### Visibility + +```rust +Visibility::Inherited +``` + +```rust +Visibility::Visible +``` + +```rust +Visibility::Hidden +``` + +An entity without a visibility component emits nothing; Bevy treats it as inherited. + +### Mesh3d and materials + +Primitive meshes and materials need asset handles. The generated function takes parameters for the +asset collections the scene uses: + +```rust +pub fn scene( + meshes: &mut Assets, + materials: &mut Assets, +) -> impl Scene { + bsn! { + Name("Ground") + Transform { + translation: Vec3::new(0.0, 0.0, 0.0), + rotation: Quat::from_euler(EulerRot::XYZ, 0.0f32.to_radians(), 0.0f32.to_radians(), 0.0f32.to_radians()), + scale: Vec3::new(1.0, 1.0, 1.0), + } + Mesh3d({ meshes.add(Cuboid::new(12.0, 0.2, 12.0)) }) + MeshMaterial3d::({ materials.add(StandardMaterial { + base_color: Color::srgba(0.35, 0.55, 0.3, 1.0), + metallic: 0.0, + perceptual_roughness: 0.9, + unlit: false, + ..default() + }) }) + } +} +``` + +- Primitive mesh handles must be **braced** because method calls are not accepted as direct BSN + values: `Mesh3d({ meshes.add(..) })`, not `Mesh3d(meshes.add(..))`. +- `MeshMaterial3d` requires the turbofish `::`; without it the compiler reports + missing generics. +- `..default()` is accepted inside the raw Rust `StandardMaterial` literal (it is a constructor + argument), but **not** inside a BSN component patch. +- The generator includes `meshes` only when a primitive mesh exists, and `materials` whenever a + `Mesh3d` exists. A scene with no meshes has no asset parameters. +- Primitive constructors (each wrapped in `Mesh3d({ meshes.add(...) })`): + - Cuboid: `Cuboid::new(x, y, z)` + - Sphere: `Sphere::new(radius)` + - Plane: `Plane3d::new(Vec3::Y, Vec2::new(width * 0.5, height * 0.5))` - the scene stores the full + size, while Bevy's `Plane3d` takes half extents along X and Z + - Cylinder: `Cylinder::new(radius, height)` + - Capsule: `Capsule3d::new(radius, length)` + +An asset mesh is addressed by path instead of an added handle: + +```rust +Mesh3d("models/robot.gltf#Mesh0/Primitive0") +MeshMaterial3d::({ materials.add(StandardMaterial { + base_color: Color::srgba(0.8, 0.8, 0.85, 1.0), + metallic: 0.1, + perceptual_roughness: 0.4, + unlit: false, + ..default() +}) }) +``` + +An asset mesh still needs `materials`, but not `meshes`. The path is resolved by Bevy's `AssetServer` +at spawn time; see [runtime asset caveats](#runtime-asset-caveats). + +### Custom components + +```rust +forge_demo::Orbit { + radius: 2.0, + speed: { -0.5f32 }, +} +``` + +A custom component exports as a struct literal: the stored `type_path`, then each `name: expr` pair. +A custom component with zero fields is emitted as the bare type path: + +```rust +forge_demo::Marker +``` + +Field expressions are copied from the scene file. The generator wraps an expression in +`{ ... }` when it starts with `-` or is a bare enum-variant call such as `Some(..)`, because neither +form is accepted as a direct BSN field value. Duplicate field names are rejected. In the example +above the scene expression is `-0.5f32`, so the `f32` suffix survives bracing. + +## Custom component requirements + +The generated code must compile in the game crate that defines the component. Bevy 0.20's BSN macro +needs a `FromTemplate` implementation, provided either by the blanket `Default + Clone + Unpin` +implementation or by a derived `FromTemplate`. + +| Component shape | Minimum derives | +|---|---| +| Simple data fields (`f32`, `Color`, `Vec3`, ...) | `#[derive(Component, Default, Clone)]` | +| Unit marker | `#[derive(Component, Default, Clone)]` | +| Fields like `Handle` or `Entity` | `#[derive(Component, FromTemplate)]` | +| `Option>` or other `#[template(built_in)]` fields | `#[derive(Component, FromTemplate)]` plus `#[template(built_in)]` on that field | + +Notes: + +- `FromTemplate` is in Bevy's prelude. The derive generates a companion template type, so it can be + combined with `Default + Clone` in 0.20. +- A component with `Handle` and only `Default + Clone` fails to compile because + `Handle` does not convert from a string path through the blanket template; use + `FromTemplate`. +- Add `#[template(built_in)]` in your Rust source where a field type needs it. The editor does not + introspect your types, so it cannot infer this option. +- The exported `type_path` must be importable where the generated file is compiled. Use a qualified + path such as `my_game::Health` if the type is not in scope. +- The generated BSN binds no entity references (`#Child`), so custom components that link to other + entities should use whatever handle/entity value your scene code provides; a raw `Entity` field + expression is the author's responsibility. + +Example component definitions: + +```rust +use bevy::prelude::*; + +#[derive(Component, Default, Clone)] +pub struct Health { + pub current: f32, + pub max: f32, +} + +#[derive(Component, FromTemplate)] +pub struct Icon { + pub image: Handle, + pub tint: Color, +} +``` + +## Pasting generated code into a Bevy app + +1. Export the file, for example into `src/scenes/garden.rs` in your game. +2. Declare the module and call the generated function from a startup system: + +```rust +use bevy::prelude::*; + +mod scenes { + pub mod garden; +} + +fn main() { + App::new() + .add_plugins(DefaultPlugins) + .add_systems(Startup, spawn_garden) + .run(); +} + +fn spawn_garden( + mut commands: Commands, + mut meshes: ResMut>, + mut materials: ResMut>, +) { + commands.spawn_scene(scenes::garden::scene(&mut meshes, &mut materials)); +} +``` + +For a scene whose generated signature has no asset parameters: + +```rust +commands.spawn_scene(scenes::garden::scene()); +``` + +For multiple roots: + +```rust +commands.spawn_scene_list(scenes::garden::scene()); +``` + +- `spawn_scene`, `spawn_scene_list` and the related traits are in Bevy's prelude. +- Matching the generated parameter list is required: passing unused asset collections is harmless, + but omitting a required one does not compile. +- On `World` (for example in exclusive systems or tests), use + `world.spawn_scene(scene)` or `world.spawn_scene_list(scene)`; `spawn_scene_list` returns a + `Result, _>`. +- `commands.queue_spawn_scene(..)` (and the list variant) defers the spawn. Handles created by + `meshes.add` / `materials.add` are evaluated when `scene()` is called and are owned by the scene, + so deferred spawning is safe. +- Use `DefaultPlugins` or the specific plugins your components need (`ScenePlugin`, `SpritePlugin`, + `PbrPlugin`, ...). A headless test harness needs at least task pool, asset and scene plugins. + +## Runtime asset caveats + +- **Primitives and inline materials work immediately.** `meshes.add(Cuboid::new(..))` and + `materials.add(StandardMaterial { .. })` create values when `scene()` runs; the entity renders as + soon as it is spawned and the asset is registered. +- **Sprite images and asset meshes are path handles.** A string like `"textures/player.png"` becomes + a path handled by `AssetServer` at spawn time. If the file is missing, or the relevant asset loader + is not enabled in your game (`bevy_gltf` for `.gltf`, for example), the handle silently stays + `Loading` or `Failed` - spawning does not error. +- **Material asset paths need a loader.** `MeshMaterial3d::("materials/cube.toml")` + compiles, but unless your project registers a `StandardMaterial` asset loader it never resolves. + This is why Forge emits inline material values instead of material asset paths. Do not replace the + generated material expression with a bare path and expect it to render. +- **No runtime BSN scene assets.** The generated code is compiled into your crate. Bevy's scene + asset/caching features are not used by this exporter. + +## Determinism and export errors + +`ExportFormat::generate` is pure and deterministic. It returns errors instead of panicking: + +| Error | Cause | +|---|---| +| `EmptyScene` | The scene has no root entities (including an entirely empty scene); there is nothing to generate. | +| `InvalidIdentifier` | The function name is not a valid Rust identifier, or is a Rust keyword. | +| `NonFinite` | A transform, material or light value is `NaN` or infinite. | +| `InvalidCustomComponent` | A custom type path or field name is invalid, or a field expression is empty. The BSN generator additionally rejects duplicate field names. | + +The CLI exits `1` for these codegen errors and `2` for I/O, RON parse or usage errors. The editor +shows the error in the export dialog instead of writing a file. + +## Troubleshooting compile errors + +| Compiler message | Cause | Fix | +|---|---|---| +| `error: Unexpected input after function name` | Method call used as a direct BSN value, e.g. `Mesh3d(meshes.add(..))` | Brace it: `Mesh3d({ meshes.add(..) })` | +| `error[E0107]: missing generics for struct MeshMaterial3d` | Missing turbofish | Use `MeshMaterial3d::(...)` | +| `error: expected type, found variant 'Some'` | Unbraced `Some(..)` in a patch | Use `custom_size: { Some(Vec2::new(..)) }` | +| `error: expected identifier` at a negative number | Negative direct field literal | Use `intensity: { -5.0f32 }` (or a call/path value) | +| `error: expected f32, found integer` | Integer literal in an `f32` field | Write `10.0`, not `10` | +| `error[E0282]: type annotations needed` | `..default()` or `..Default::default()` inside a BSN component patch | Not supported; write the fields or omit them to keep defaults. `..default()` is only valid inside raw Rust literals in `{}` arguments | +| `error: expected HashedStr, found integer` | `Name(42)` | `Name` takes a string: `Name("42")` | +| `error: unexpected token` at `--` inside `bsn!` | Multiple roots in a single-root macro | Use `bsn_list!` and return `impl SceneList` | +| `cannot find macro bsn_list` | Missing import | Add `use bevy::scene::bsn_list;` (or `use bevy::prelude::*;`) | +| `Unknown macro root!` printed to stderr | Fully qualified macro path | Import the macro with `use` instead | +| `warning: unused variable: meshes` | Asset parameter present but unused (e.g. hand-edited) | Let the exporter include only needed parameters, or allow the warning | +| `warning: float_literal_f32_fallback` | Unsuffixed negative float in a bracing block | Use an `f32` suffix: `{ -5.0f32 }` | +| Duplicate sibling entities visually merged | Missing `--` between scenes | Separate sibling patches with `--` | + +If an exported scene fails to compile, first check that the pasted file is in a crate that depends on +Bevy 0.20 and that custom component types and their derives are present. Then compare against the +templates above. The repository's `compat/bsn-smoke` crate compiles representative generated output +against real Bevy 0.20 and is the reference for supported syntax. + +## Imperative Rust alternative + +`forge export --format rust` (or File > Export > Rust spawn code) produces a function that spawns the +scene immediately with `Commands`. It targets the same Bevy 0.20 API surface but does not use BSN. + +```rust +// Generated by Bevy Forge. Paste into a Bevy 0.20 project. +// Imperative `Commands::spawn` code: call this function from a startup system. + +use bevy::prelude::*; + +pub fn scene( + commands: &mut Commands, + asset_server: &AssetServer, +) { + commands.spawn(( + Name::new("Player"), + Transform::from_xyz(0.0, 0.0, 0.0) + .with_rotation(Quat::from_euler(EulerRot::XYZ, 0.0_f32.to_radians(), 0.0_f32.to_radians(), 0.0_f32.to_radians())) + .with_scale(Vec3::new(1.0, 1.0, 1.0)), + Sprite { image: asset_server.load("textures/player.png"), color: Color::srgba(1.0, 1.0, 1.0, 1.0), flip_x: false, flip_y: false, ..default() }, + )).with_children(|parent| { + parent.spawn(( + Name::new("Hat"), + Transform::from_xyz(0.0, 1.0, 0.0) + .with_rotation(Quat::from_euler(EulerRot::XYZ, 0.0_f32.to_radians(), 0.0_f32.to_radians(), 0.0_f32.to_radians())) + .with_scale(Vec3::new(1.0, 1.0, 1.0)), + Sprite { color: Color::srgba(1.0, 0.5, 0.2, 1.0), flip_x: false, flip_y: false, ..default() }, + )); + }); +} +``` + +Usage in a system: + +```rust +fn spawn_scene(mut commands: Commands, asset_server: Res) { + scene(&mut commands, &asset_server); +} +``` + +Rules for the imperative generator: + +- `commands` is always a parameter. `asset_server` appears only when a sprite image or asset mesh is + used. `meshes` and `materials` are both emitted whenever the scene contains any `Mesh3d` + (primitive or asset), because the mesh handle and the inline material are created at the same + time. +- Each entity spawns a tuple of `Name::new(..)`, a `Transform`, then its components. Omitted `Sprite` + fields use `..default()`. +- Hierarchy uses `.with_children(|parent| { ... })`; sibling order is preserved. +- Primitive meshes and materials use the same constructors as BSN. Text2d uses `Text2d::new`, + `TextFont { font_size: FontSize::Px(..), ..default() }` and `TextColor(..)`. +- Custom components are emitted as struct literals; the type must be imported where the generated + code compiles. +- Empty scenes are an error, exactly as for BSN. + +The imperative form is useful when a project cannot use BSN macros or when code must run against an +API where a scene is spawned imperatively. BSN remains the primary, recommended export. diff --git a/docs/format.md b/docs/format.md new file mode 100644 index 0000000..d35b5be --- /dev/null +++ b/docs/format.md @@ -0,0 +1,569 @@ +# Scene format (`.forge.ron`) + +Bevy Forge stores scenes as human-readable [RON](https://github.com/ron-rs/ron) documents with the +`.forge.ron` extension. A scene file contains **editor data only** - entities, hierarchy, transforms +and components. It never contains generated code and is not Bevy's `.scn.ron` dynamic-scene format. +BSN or imperative Rust is generated from a scene on demand; see the [BSN export guide](bsn-export.md). + +This page documents format version **1** as written by the editor and understood by the core crate. +Related pages: [architecture](architecture.md), [README](../README.md). + +## Files and naming + +- Scenes live in `/scenes/`. `Project::list_scenes()` lists the `*.forge.ron` files there. +- The editor's suggested filename is the scene display name run through `slugify`: ASCII + letters/digits are lowercased, whitespace and `-`, `_`, `.` collapse to `-`, and an empty result + becomes `scene` (for example `Level 01` -> `level-01.forge.ron`). The name inside the file is + separate and can be any UTF-8 string. +- The editor refuses to save under any other extension, and `Project::save_scene` refuses paths + outside the project root. +- Files are UTF-8 RON produced with a two-space pretty printer. Writers use `\n` line endings. + RON comments are legal in the format but the editor does not write them. +- Whitespace and line breaks are not significant. The editor's writer puts one struct field per + line and expands nested vectors/colours the same way; the examples on this page are written more + compactly for readability and parse identically. + +## Complete example + +A small 3D scene with two roots, a directional light and a primitive mesh: + +```ron +( + format_version: 1, + name: "Garden", + entities: { + 1: ( + id: 1, + name: "Sun", + parent: None, + children: [], + transform: ( + translation: (x: -4.0, y: 8.0, z: -6.0), + rotation: (x: 45.0, y: 30.0, z: 0.0), + scale: (x: 1.0, y: 1.0, z: 1.0), + ), + components: [ + ( + type: directional_light, + data: ( + color: (r: 1.0, g: 0.95, b: 0.85, a: 1.0), + illuminance: 10000.0, + shadows: true, + ), + ), + ], + locked: false, + ), + 2: ( + id: 2, + name: "Ground", + parent: None, + children: [], + transform: ( + translation: (x: 0.0, y: 0.0, z: 0.0), + rotation: (x: 0.0, y: 0.0, z: 0.0), + scale: (x: 1.0, y: 1.0, z: 1.0), + ), + components: [ + ( + type: mesh3d, + data: ( + mesh: ( + source: primitive, + value: cuboid(size: (x: 12.0, y: 0.2, z: 12.0)), + ), + material: ( + base_color: (r: 0.35, g: 0.55, b: 0.3, a: 1.0), + metallic: 0.0, + perceptual_roughness: 0.9, + unlit: false, + ), + ), + ), + ], + locked: false, + ), + }, + roots: [1, 2], + next_id: 3, +) +``` + +A 2D scene with hierarchy, a sprite, text, a camera, a hidden component and a custom component: + +```ron +( + format_version: 1, + name: "Platformer", + entities: { + 1: ( + id: 1, + name: "Camera", + parent: None, + children: [], + transform: ( + translation: (x: 0.0, y: 0.0, z: 10.0), + rotation: (x: 0.0, y: 0.0, z: 0.0), + scale: (x: 1.0, y: 1.0, z: 1.0), + ), + components: [ + (type: camera2d), + ], + locked: false, + ), + 2: ( + id: 2, + name: "Player", + parent: None, + children: [3], + transform: ( + translation: (x: 0.0, y: 1.0, z: 0.0), + rotation: (x: 0.0, y: 0.0, z: 0.0), + scale: (x: 1.0, y: 1.0, z: 1.0), + ), + components: [ + ( + type: sprite, + data: ( + image: Some("textures/player.png"), + color: (r: 1.0, g: 1.0, b: 1.0, a: 1.0), + custom_size: Some((x: 32.0, y: 48.0)), + flip_x: false, + flip_y: false, + ), + ), + (type: visibility, data: visible), + ( + type: custom, + data: ( + type_path: "forge_demo::Orbit", + fields: [ + (name: "radius", expr: "2.0"), + (name: "speed", expr: "-0.5"), + ], + ), + ), + ], + locked: false, + ), + 3: ( + id: 3, + name: "Label", + parent: Some(2), + children: [], + transform: ( + translation: (x: 0.0, y: 32.0, z: 0.0), + rotation: (x: 0.0, y: 0.0, z: 0.0), + scale: (x: 1.0, y: 1.0, z: 1.0), + ), + components: [ + ( + type: text2d, + data: ( + text: "Bevy Forge", + font_size: 24.0, + color: (r: 0.9, g: 0.9, b: 1.0, a: 1.0), + ), + ), + ], + locked: false, + ), + }, + roots: [1, 2], + next_id: 4, +) +``` + +## Top-level fields + +`SceneDoc` is a RON struct with these fields, in this order: + +| Field | Type | Meaning | +|---|---|---| +| `format_version` | `u32` | Schema version. Currently `1`; see [Versioning](#versioning). | +| `name` | `String` | Scene display name, independent of the filename. May be empty (validation warns). | +| `entities` | map `u64 -> EntityNode` | All entities, keyed by ID. Map order is insertion order and is stable across saves. | +| `roots` | `[u64]` | Root entity IDs in display order. Every entity is either a root or a descendant of one. | +| `next_id` | `u64` | Next identity to allocate. Must be greater than every ID in `entities`; IDs are never reused. | + +## Entity fields + +| Field | Type | Meaning | +|---|---|---| +| `id` | `u64` | Must equal the key in `entities`. | +| `name` | `String` | Display name; arbitrary UTF-8. Used for hierarchy labels and generated `Name(...)`. | +| `parent` | `Option` | `None` for roots, otherwise the containing entity. | +| `children` | `[u64]` | Ordered child IDs. Order is preserved in the hierarchy and in generated code. | +| `transform` | `Transform` | Mandatory local transform; see below. | +| `components` | `[ComponentData]` | Ordered component list; at most one component per `key()`. | +| `locked` | `bool` | Editor-only. `true` blocks viewport transforms and gizmos, not hierarchy edits or deletion. Missing in old files defaults to `false`. | + +## Transforms and coordinates + +`Transform` mirrors Bevy's `Transform`: + +```ron +transform: ( + translation: (x: 1.0, y: 2.0, z: 0.5), + rotation: (x: 0.0, y: 45.0, z: 0.0), + scale: (x: 1.0, y: 1.0, z: 1.0), +) +``` + +- `translation` is the local position. 2D scenes use X/Y; Z is used as the draw-order axis by the + 2D viewport. 3D scenes use all three axes. +- `rotation` is **XYZ Euler angles in degrees**, not radians and not a quaternion. This is the value + the inspector edits. Export converts it with + `Quat::from_euler(EulerRot::XYZ, x.to_radians(), y.to_radians(), z.to_radians())`. +- `scale` is a per-axis multiplier. A zero scale is legal in the file but produces a validation + warning. +- World transforms are computed by composing local transforms down the parent chain. Parents affect + children; moving a parent moves its subtree in the viewports. Scene files always store local + transforms, never world transforms. +- All numbers are `f32` RON floats. Non-finite values (`NaN`, `inf`) are representable in RON but + rejected by validation and code generation. + +Colours use the four-field `Color` struct with channels in `0.0..=1.0`: + +```ron +color: (r: 0.35, g: 0.55, b: 0.95, a: 1.0) +``` + +The editor edits these as sRGB colours; the generated code emits `Color::srgba(r, g, b, a)`. + +## Components + +`components` is an ordered list of serde-tagged values. Every entry has a `type` tag and, except for +unit variants, a `data` payload. Tags and enum payloads are **bare RON identifiers**, not strings - +write `type: sprite`, not `type: "sprite"`: + +```ron +( + type: sprite, + data: ( + image: Some("textures/player.png"), + color: (r: 1.0, g: 1.0, b: 1.0, a: 1.0), + custom_size: Some((x: 32.0, y: 48.0)), + flip_x: false, + flip_y: false, + ), +) +``` + +The tag values and uniqueness keys are: + +| Tag | Payload | `key()` | Notes | +|---|---|---|---| +| `sprite` | `SpriteData` | `sprite` | 2D sprite | +| `text2d` | `Text2dData` | `text2d` | world-space text | +| `camera2d` | none | `camera2d` | unit: `(type: camera2d)` | +| `camera3d` | none | `camera3d` | unit: `(type: camera3d)` | +| `point_light` | `PointLightData` | `point_light` | | +| `directional_light` | `DirectionalLightData` | `directional_light` | | +| `mesh3d` | `Mesh3dData` | `mesh3d` | primitive or asset mesh plus material | +| `visibility` | `VisibilityMode` | `visibility` | `inherited`, `visible` or `hidden` | +| `custom` | `CustomComponent` | `custom:` | one per custom type path | + +`Name` and `Transform` are **not** components in this format - they are entity fields. Adding a +component whose key already exists is refused; use the inspector's edit controls to change values. + +### Sprite + +```ron +( + type: sprite, + data: ( + image: Some("textures/player.png"), + color: (r: 1.0, g: 1.0, b: 1.0, a: 1.0), + custom_size: Some((x: 64.0, y: 64.0)), + flip_x: false, + flip_y: false, + ), +) +``` + +| Field | Type | Meaning | +|---|---|---| +| `image` | `Option` | Path relative to `assets/`; omit `Some` for a solid-colour sprite | +| `color` | `Color` | Tint; defaults to white | +| `custom_size` | `Option` | Explicit size; `None` uses the texture's native size | +| `flip_x`, `flip_y` | `bool` | Mirror the texture on that axis | + +A sprite with no image and no custom size renders nothing; validation reports a warning. + +### Text2d + +```ron +( + type: text2d, + data: ( + text: "Hello\nBevy", + font_size: 32.0, + color: (r: 1.0, g: 1.0, b: 1.0, a: 1.0), + ), +) +``` + +`font_size` is in pixels and must be greater than zero. The text string is escaped as a normal RON +string and becomes a Rust string literal on export. Empty text is allowed but produces a warning. + +### Cameras + +```ron +(type: camera2d) +``` + +```ron +(type: camera3d) +``` + +A scene can contain both, but the 2D and 3D camera mix produces a warning. Optional cameras are +reported as info; more than one camera is also info. Neither variant stores projection fields in +this version. + +### Point light + +```ron +( + type: point_light, + data: ( + color: (r: 1.0, g: 0.8, b: 0.6, a: 1.0), + intensity: 800.0, + range: 20.0, + shadows: true, + ), +) +``` + +`intensity` must be non-negative and `range` must be positive; `shadows` maps to +`shadow_maps_enabled` in generated BSN. + +### Directional light + +```ron +( + type: directional_light, + data: ( + color: (r: 1.0, g: 0.95, b: 0.85, a: 1.0), + illuminance: 10000.0, + shadows: true, + ), +) +``` + +`illuminance` must be non-negative. The light's direction comes from the entity's rotation. + +### Mesh3d + +```ron +( + type: mesh3d, + data: ( + mesh: ( + source: primitive, + value: cuboid(size: (x: 1.0, y: 1.0, z: 1.0)), + ), + material: ( + base_color: (r: 0.45, g: 0.62, b: 0.95, a: 1.0), + metallic: 0.0, + perceptual_roughness: 0.5, + unlit: false, + ), + ), +) +``` + +`MeshSource` is tagged `source` / `value`: + +- `source: primitive` with one of the five `PrimitiveShape` values below. +- `source: asset` with a string mesh path, for example + `value: "models/robot.gltf#Mesh0/Primitive0"`. + +Primitive shapes (RON value for `value`): + +| Shape | RON | +|---|---| +| Cuboid | `cuboid(size: (x: 2.0, y: 1.0, z: 0.5))` | +| Sphere | `sphere(radius: 0.5)` | +| Plane | `plane(size: (x: 10.0, y: 10.0))` - width and depth in X/Z | +| Cylinder | `cylinder(radius: 0.5, height: 1.0)` | +| Capsule | `capsule(radius: 0.5, length: 1.0)` | + +`MaterialData` fields: + +| Field | Type | Meaning | +|---|---|---| +| `base_color` | `Color` | Albedo / base colour | +| `metallic` | `f32` | 0 = dielectric, 1 = metal; values outside `0..=1` warn | +| `perceptual_roughness` | `f32` | 0 = mirror, 1 = rough; values outside `0..=1` warn | +| `unlit` | `bool` | Emit an unlit material | + +Asset mesh paths are validated for existence and kind when an asset index is available, and are +emitted as `Mesh3d("")` in BSN. The material is always emitted inline, so no material asset +loader is required. + +### Visibility + +```ron +(type: visibility, data: hidden) +``` + +The payload is one of the bare RON identifiers `inherited`, `visible` or `hidden`. `hidden` is +equivalent to Bevy's `Visibility::Hidden`; toggling the hierarchy eye adds/removes this component. +An entity without a visibility component is treated as `inherited`. + +### Custom components + +```ron +( + type: custom, + data: ( + type_path: "forge_demo::Orbit", + fields: [ + (name: "radius", expr: "2.0"), + (name: "speed", expr: "-0.5"), + (name: "axis", expr: "Vec3::Y"), + ], + ), +) +``` + +| Field | Type | Meaning | +|---|---|---| +| `type_path` | `String` | A Rust type path such as `Health`, `my_game::Health` or `my_game::components::Health` | +| `fields` | `[CustomField]` | Ordered field initialisers | +| `fields[].name` | `String` | Rust field identifier | +| `fields[].expr` | `String` | Raw Rust/BSN expression, stored verbatim and emitted after `name:` | + +Custom components are the escape hatch for project-specific types. The editor validates only the +shape of the type path and field names, and that expressions are non-empty; it does not parse Rust. +The component type must derive `Component` plus `Default + Clone` or `FromTemplate` - see +[custom-component requirements](bsn-export.md#custom-component-requirements). Two custom components +with different type paths can coexist on one entity; the same path may appear only once. + +## Ids, forests and ordering + +Every scene is a forest of entities: + +- Each entity has exactly one parent (`None` for roots) and appears in exactly one place: either + `roots` or one parent's `children` list. +- `parent` and `children` must agree (`child.parent == Some(parent_id)`). `roots` entries must have + `parent: None`. +- Cycles are invalid and are rejected by `reparent`; a hand-edited file with a cycle fails + structural validation. +- `id` is unique and stable for the lifetime of the scene; IDs are never reused after deletion. + `next_id` must remain greater than every entity ID. Gaps are fine. +- Order matters: `roots` controls root order, each `children` list controls sibling order, and both + are preserved by duplicate/reparent and by code generation. +- Deleting an entity removes its whole subtree. Duplicating deep-copies the subtree, inserts the + copy immediately after the original among its siblings, and suffixes the root copy's name with + `" Copy"`. +- `locked` is editor metadata and has no effect on export. + +`SceneDoc::check_integrity()` returns a list of structural problems (empty when valid). Validation +includes these checks. + +## Versioning + +`format_version` is the scene schema version, not the Bevy Forge application version. It is currently +`1` (`FORMAT_VERSION`). + +- The editor writes `1`. +- On load, version `0` is normalized to `1` in memory and the data is kept (a permissive path for + early files). +- There is no migration framework yet: the loader otherwise parses the document as the current + schema. Treat version `1` as the only supported schema. +- Additive fields should be serde-defaultable so older files still load; breaking changes bump + `format_version`. +- Generated code is not stored in the file and is not versioned by this field. Export behaviour is + governed by the codegen contract and the target Bevy version (0.20). + +## Asset paths + +Sprite images and asset meshes use paths **relative to the project's Bevy assets directory**, with +`/` separators: + +```ron +image: Some("textures/player.png") +``` + +```ron +value: "models/robot.gltf#Mesh0/Primitive0" +``` + +Rules (violations are validation errors): + +- No absolute paths. +- No `..` path components. +- No backslashes. +- With an asset index, the file must exist and its kind must match the use (for example, an `.ogg` + cannot be used as a sprite image; `.png`, `.jpg`, `.jpeg`, `.webp`, `.bmp`, `.tga`, `.ktx2` and + `.dds` are images, while `.gltf`, `.glb`, `.obj` and `.fbx` are meshes). + +Projects with an `assets/` directory use it directly. Projects without one treat the project root as +the assets directory. The editor resolves asset paths through the project, never the process working +directory. + +## Saving and autosave + +- `Project::save_scene` serializes with the pretty RON printer and writes **atomically**: it writes + a temporary `..tmp-` file in the destination directory and renames it over the + target. A crash during save leaves the previous file intact. +- The editor refuses non-`.forge.ron` filenames and `Project::save_scene` refuses paths outside the + project root. Parent directories are created for save-as paths. +- Saving marks the session clean; the title dot and quit guard use that flag. +- When autosave is enabled (`autosave_secs > 0`, default 60 seconds), a dirty document is written to + `/.forge/autosave.forge.ron` at most once per interval. Autosave is best-effort: it does + not mark the session saved and is not a substitute for saving. Set `autosave_secs` to 0 to disable + it. +- Editor preferences are stored separately in `bevy-forge/config.ron` under the platform config + directory; that file is not a scene. + +## Validation + +`validate_scene(doc, assets: Option<&AssetIndex>)` returns a `ValidationReport` with `Error`, +`Warning` and `Info` issues. `forge validate` prints them and uses the exit code to distinguish +"valid" from "has errors"; the editor shows per-entity issues and summary counts. Passing `None` for +the asset index disables missing-asset checks. + +**Errors** + +- Structural invariant failures: missing parent/child/root, cycles, mismatched node ID, duplicate + references. +- Non-finite transform, rotation or scale values. +- Non-finite light or material values. +- Custom component: invalid type path, invalid field name, empty field expression. +- Asset reference containing an absolute path, a `..` component or a backslash. +- With an asset index: referenced sprite image or mesh asset missing, or the wrong asset kind. +- Sprite `custom_size` with a non-positive component. +- Text `font_size <= 0`. +- `PointLight.intensity < 0` or `range <= 0`. +- `DirectionalLight.illuminance < 0`. +- An entity is its own parent or introduces a cycle. + +**Warnings** + +- Duplicate entity names. +- A sprite with no image and no custom size (it renders nothing). +- A zero scale component. +- Material `metallic` or `perceptual_roughness` outside `0..=1`. +- Empty `Text2d` string. +- Both 2D and 3D cameras in one scene. +- An empty or whitespace-only entity name. + +**Info** + +- No camera in the scene. +- No light in a scene that contains meshes. +- An entity with only a `Transform`. +- More than one camera. + +Validation is deterministic (severity descending, then entity ID, then message) and must not panic +on malformed forests. + +## Relationship to BSN + +The `.forge.ron` file is the source of truth in the editor. Exporting walks the forest and emits +`Name`, `Transform` and the component list for every entity, converting Euler degrees to a quaternion +and choosing asset parameters for mesh scenes. The generated file is portable Rust, not a scene asset +that Bevy loads at runtime. See the [BSN export guide](bsn-export.md). diff --git a/docs/screenshot-3d.png b/docs/screenshot-3d.png new file mode 100644 index 0000000000000000000000000000000000000000..a3990b50dec3bdbf7b514a40d161d63cff6155a4 GIT binary patch literal 810026 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
FTru#X4HOIRE;*&iA%=qkgo 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
=*5)0` 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%0A?0;r2LXS2`^M;8h)nSt!5t1By3ymGKOCFhKIOU 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á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 z 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

6%4TI7q%H z^@2`A;e0Ks?VIngyxlTT@b3g)7~$O}0P@)vbJb@VW~qvSE4V<4;O8{1D_~jZuYukN zS}<1`C*ObE{MEzcz>3AF3JSSPKSp`6^99%~+ADCxrC77hd9N*8CM)uJr5R)btV$XMy-W=pS@;{t(!2X5D*h1W#h+WfP1oBKUQJ}WaY5E*Y#dQC^;!;-as(G z$9%AMtY6I&joV8JMuwk0U~0mp2DGRpw|`H^DYes#`8%1*ykDispC@cP*Mk2O!MXu0 z)jpcp2m7Ng<1$QsMhTOq-yQ^%GLN-7uaO;&4MSQEy428ilKwFrvbo-PV95z@Q(3*& zI+XxWSU$LZgziZ%Do~vtwbc)*eZ%F6L3xj)L=1`!;Mg*4T1my?%oo-2r7~eYLg5mfK=`KC&6Q2u?@+fJx1RyrimKU)%-RwCK2_|jf>sHDVFD^XJD1b_zp zV{(fK2&!Ew?lK>{y7O>~p-xRRuObFcb7dB?1iAswt3>&7DEK0T7S|;NALb6{m6=~F5b&nE4Obr zITC!)w;l1~)tjRG^5hcxBr2NPQy$m2H+p5WbGf;;a(2zRKH}pWGI1DS+H+ui)cN5`?z&u0EXGELltbkAmG(7%tqWjQPqQ93i=H3Df zA+H;Wgn&?0CPZBbuNMLjWP}AgW$03gEf49n1D63$*?$@k@Y2I+s*s5~y+9S^@(K>4uq1A9Y8-{4jml+FZC zw(pD60(OE5a_w;)9Z7rS8yOUaX1GM~NQCkMIHyShvhIxZ1d%HTwc;L3v4^P7wJq{3 zml~h?E47swJm_B2^D)p}XhNgY&v+;bUK_^~YzE{bJ0eL7R6_KDj2aGh+OUJQf7+;- z$Ht~RyJlFrPgn!n!*{9-d>7$T!wyrqEoaL%ncM$qeg_)lLhWm88EKnL+FLRlLdXk& z9OS!6WBX9hs$%9l*Rxe3-Pwn4kcuv0W1w3d5q{;rvh+O;x7aN+yhswtU&Sqo`+E)4 zgR{FLYK6!Bh@A)T&$1Y9U6XkuWXnK2ohDVZM;P;@|F}0$(3K|0^9f-a%%U2p#+8ap_>y?W-M8JX zvAxg3?>(h}_rXP>@Eo1STRf`Sd& zPa|?!f$;YWP$~S zOOSv>(;rUx9n_N2MpO>hQ-FKCFhEY%#MqME2GMZi>C#HvP@;2iQ-oO)pwl49IcejW zQhSV0V<0@@o;L?-u#XI2StqVlH67}(30y^=|B zum7#HH$rEaqP*)T8KW*pdHn{*1F!wX-|mxhdK>23Q;EFHa@+Se^l+*|kKmIy!MgEN zq7Y^A;RV1$BMjldra|e?&7%rbn{{p5587t=NX>M*lG0h8Qmyp;tx`wzZ=K5Ja)~pj)qalbHT<>*#iP~gvf;pFp6VQkBkRp0$oL$@I!o7|7(72L{ zU6*ELzLLQD_4S8sn8wMjIqQ~pIGN!C0h|I=W41hCyD0+Q%L$rC8<0!!dBu#&PIDbR zV(hZKSo`9tq)+AcJ(*eiifi01MD%>rR}ovc?1W+(d6>gr6wy0z@tpo9iQbNnZQNv) zpV>Aa+NoB5S#**l(t?GbkiBq=qo~KT<;@&5H9M<2aG?k4c=T&Vh*L=(LjjCz?OZwe z&%0#t4X5ea4^EKSMTDlfXNs{55g(JJ-Ev!Ije@>-bMJn*ydDMp7(mKkjvLp8g(^X= zYc_JI5!ev37E26|&u@f~Bxb_Q_U z;Jjqm?_Hfo9~X&THJ082a1^OWo-L4G1p0En$J)Fj!`jD{4%zM5Dha(@@YArtnqk6b zaLz7QD!(dADz9p^r^=4f+hVlF~ zNFf*nSkSRzKiGh;4aGA^%F%JJ>#Tm~IGdD)gKgsC62`{TB7JZ3zXumd`aV;%OR4I- zTvZpgvupbOPda&xy{{*K-K^9qi4F_CPiK3Xm{JPVFuD`USJ_A}3Em!iT;@=D6dFs# z80}AR_hCw%CM0XnstoYLruNgUAt$URA-u7U$!t�AKUcc~Pny1&J=147$vR)&Id{ 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ʧ*%- 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 Yn6=t9@bua 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

-rI@e8SXYQ-bb^d{2+zYPMs#_rYnKw`K?9 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 |etVq7%0ub55{?LvmDnex6kPvYtR`;>Lgh14_>g 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 literal 0 HcmV?d00001 diff --git a/examples/demo-project/Cargo.toml b/examples/demo-project/Cargo.toml new file mode 100644 index 0000000..3b9ad4f --- /dev/null +++ b/examples/demo-project/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "forge-demo" +version = "0.1.0" +edition = "2021" +publish = false + +[dependencies] +bevy = "0.20" + +# Standalone project on purpose: Bevy Forge opens this directory as a Bevy project, +# and the repository's editor workspace must not build Bevy. +[workspace] diff --git a/examples/demo-project/README.md b/examples/demo-project/README.md new file mode 100644 index 0000000..08bd2cb --- /dev/null +++ b/examples/demo-project/README.md @@ -0,0 +1,57 @@ +# Forge Demo + +A tiny Bevy 0.20 project used as the demo project for **Bevy Forge**, the visual scene +editor for Bevy BSN scenes. It contains two editable scenes and a small set of generated +pixel-art textures. + +## Open it in Bevy Forge + +From the repository root: + +```bash +cargo run -p bevy_forge_editor -- examples/demo-project +``` + +Bevy Forge discovers the Bevy project from `Cargo.toml`, scans `assets/` and opens the +first scene automatically. Open the other scene from the File menu, edit entities in the +hierarchy/inspector, and save with `Ctrl+S`. Use **Export** (or the `forge` CLI) to turn a +scene into paste-ready BSN Rust code or imperative `Commands::spawn` code. + +## Scenes + +| Scene | Contents | +|---|---| +| `scenes/garden.forge.ron` | 3D garden: camera, sun, point light, ground plane, shrub, flower, lantern post and an orbiting crystal with the custom `forge_demo::Orbit` component. | +| `scenes/platformer.forge.ron` | 2D platformer: camera, dusk background, ground tiles, floating ledge, player with a hat child, coins, crates and two Text2d labels. | + +Both scenes are plain RON and human-readable. Every referenced texture exists in +`assets/textures/`. + +## Textures + +All textures are procedurally generated (no external art, no network): + +| File | Size | +|---|---| +| `assets/textures/background.png` | 960x540 | +| `assets/textures/player.png` | 64x96 | +| `assets/textures/hat.png` | 64x32 | +| `assets/textures/tile.png` | 96x96 | +| `assets/textures/coin.png` | 64x64 | +| `assets/textures/crate.png` | 64x64 | + +Regenerate them with Pillow: + +```bash +python3 examples/demo-project/tools/generate_textures.py +``` + +## Running it as a Bevy app + +`src/main.rs` is a minimal Bevy app. The `.forge.ron` scenes are editor documents, not +Bevy runtime assets yet, so paste an export into `src/main.rs` to run it. The custom +`Orbit` component used by the garden scene is defined there as +`forge_demo::Orbit { radius, speed }`, matching the exported BSN path. + +This demo is deliberately **not** a member of the editor workspace: only the excluded +`compat/bsn-smoke` crate may depend on Bevy. diff --git a/examples/demo-project/assets/textures/background.png b/examples/demo-project/assets/textures/background.png new file mode 100644 index 0000000000000000000000000000000000000000..7dfcc57640dca57dde3778ffff128a5b192f8a45 GIT binary patch literal 4509 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 literal 0 HcmV?d00001 diff --git a/examples/demo-project/assets/textures/coin.png b/examples/demo-project/assets/textures/coin.png new file mode 100644 index 0000000000000000000000000000000000000000..d56ae392cb7c2530c489b540bc9778a51fcb5664 GIT binary patch literal 345 zcmeAS@N?(olHy`uVBq!ia0vp^4j|0I1|(Ny7T#lEU{vsQaSW-r^>+3~-a`fguFQ*; zE?vmJk(>Jf!-XINHda&S19Ccz9{$d!8J6{R)>z$@RrvYW%qz+E?teRLzsm|OS=SVP zv9^CziF|i|{``f>GS}i58CJ+1(75$@_SHMTx6DPjDaDWakia}er3%r?f80`-RI})`pk4-1v+#WPr&zI zrz-Exv2;@V(qlgL7Zcco$V)c)d+lEfuK+56fPX9aCp>$pfBtg_kiXz`!@SxrkN!FA zlRJO?^}R{oSbpj?urhqOZRc=?<*Cj4zp@RWaFII45y;5E&~W=d)5Y}G;{M_=32ugl g`ZlnsiY+l;*iP`o^y~}mX9C&b>FVdQ&MBb@0ObXUD*ylh literal 0 HcmV?d00001 diff --git a/examples/demo-project/assets/textures/crate.png b/examples/demo-project/assets/textures/crate.png new file mode 100644 index 0000000000000000000000000000000000000000..f3f419fb92cbdfd44e7148378f709e05b4dcfd5e GIT binary patch literal 336 zcmeAS@N?(olHy`uVBq!ia0vp^4j|0I1|(Ny7T#lEU=;UsaSW-r^>(%{-ysKq*62fw z98$fFEF#l_7Z^t9H9cj?5K(t{sOaeEVc=1h$57?I;buu1_ssV@b7%dlnfcu+bNSO9 z*DJ;Qu4>HTX1K>_^0e(}&X;iW4L9da{&qihUI@^p530;dqW&gssFVGE@K@FD|5tc@ zpHEKM8?|mFP;!A2o5y)?S-<`S+ZWq>^&{r_Ydx#f0!nzCZkVy;`Br1avON)M0gMa` z4Y_O?=}zzVF>wQBLEr`Rn<=+;N85h~nF9fzzB2CNKfna#+1EN;eZJ%Cb~liD7tS(d z_h+4F1{?KmO~JeGKQ{U#gh_BSEVvi>aM#f_24V~jlf3@P9%EuCSj(^P8^ro_XR|2C NC{I^Emvv4FO#qF+eO>?n literal 0 HcmV?d00001 diff --git a/examples/demo-project/assets/textures/hat.png b/examples/demo-project/assets/textures/hat.png new file mode 100644 index 0000000000000000000000000000000000000000..70a25e3cd5e0160c2755363822566e5a6dc76504 GIT binary patch literal 217 zcmeAS@N?(olHy`uVBq!ia0vp^4nVBH!3HE3&8=$zQVTp?978H@C8sPf-Vi?RXTSfy z2Y1zbzLf9_ALd|R%xG|lxnbh1%8CVyD^pKoh)M7^2=YZ}m?o_36yS*brmVp%WHYBn#tf`S<%#l?;3Un)jr>mdKI;Vst0JlF;Gynhq literal 0 HcmV?d00001 diff --git a/examples/demo-project/assets/textures/player.png b/examples/demo-project/assets/textures/player.png new file mode 100644 index 0000000000000000000000000000000000000000..b7c1421885498537f18a3687c6f328b98f13f3a2 GIT binary patch literal 496 zcmeAS@N?(olHy`uVBq!ia0vp^4nUm1!3HGP9xZvnz`(fO)5S5Q;?~>Q3%w6JiX8VZ zR9cZB*xshOLnT8X#^8;A#Fie`+Xj+4v$pE!oSfyT9lg-?X$Dj)f6C!fo7} ztHkT<`?lYf{bP6kTiNly@9g$`uPfhQ9n7WJBH-kCwWXw?_v+h+^}9Fxd|h(L{A}jg zyi%z}rao$SnSss#0nckwN*J!c|GVS)R;vmi|A8_43@ba!603)sFKfvG1z6@V`#k-2 z&UANOt-+;#ASDZ$eyP2%N;@la(A?39fg$lN^S#^aXYHL7+n-~edRv|!sN~Qo#x$PW z+m3(Ve-va!9Fd-+*8=h<2q7)~&5nj!zBe6OBD>3@w=oInu^?i-ogLU^)Smztja zvvbcOnA;c%w(y_0ch!Y|jjXQgw&~ij7k`}3k^`9!1`a!Siodw3-)3fNvp6h<_fkyW z{Axx9h6BlSA3VC|m-4^AbxN9kJ^z|{-@krZvO-ve1*Q>1FF01Gudr5l%iZ6%&$U~D z{PFnjeh0O?*Xv){SVB+(1aSW-r_4f9DJ(fU;wvU@P zbvmsm5#rkKbo<_!3x(I_P23ar>A(6+tJ-s= z2O^J8{{B21YD?O3^?$Yt=YM9MeEqfiy!lh>&f4vV*kk2xt=$~Bk5PQ`{J`h=`}R%IU#G5~?6 LtDnm{r-UW|qR98h literal 0 HcmV?d00001 diff --git a/examples/demo-project/scenes/garden.forge.ron b/examples/demo-project/scenes/garden.forge.ron new file mode 100644 index 0000000..79d78a2 --- /dev/null +++ b/examples/demo-project/scenes/garden.forge.ron @@ -0,0 +1,437 @@ +( + format_version: 1, + name: "Garden Demo", + entities: { + 1: ( + id: 1, + name: "Camera", + parent: None, + children: [], + transform: ( + translation: ( + x: 7.5, + y: 5.0, + z: 12.0, + ), + rotation: ( + x: -16.0, + y: 30.0, + z: 0.0, + ), + scale: ( + x: 1.0, + y: 1.0, + z: 1.0, + ), + ), + components: [ + ( + type: camera3d, + ), + ], + locked: false, + ), + 2: ( + id: 2, + name: "Sun", + parent: None, + children: [], + transform: ( + translation: ( + x: 0.0, + y: 0.0, + z: 0.0, + ), + rotation: ( + x: -55.0, + y: -25.0, + z: 0.0, + ), + scale: ( + x: 1.0, + y: 1.0, + z: 1.0, + ), + ), + components: [ + ( + type: directional_light, + data: ( + color: ( + r: 1.0, + g: 0.95, + b: 0.85, + a: 1.0, + ), + illuminance: 11000.0, + shadows: true, + ), + ), + ], + locked: false, + ), + 3: ( + id: 3, + name: "Ground", + parent: None, + children: [], + transform: ( + translation: ( + x: 0.0, + y: 0.0, + z: 0.0, + ), + rotation: ( + x: 0.0, + y: 0.0, + z: 0.0, + ), + scale: ( + x: 1.0, + y: 1.0, + z: 1.0, + ), + ), + components: [ + ( + type: mesh3d, + data: ( + mesh: ( + source: primitive, + value: plane( + size: ( + x: 26.0, + y: 26.0, + ), + ), + ), + material: ( + base_color: ( + r: 0.31, + g: 0.48, + b: 0.23, + a: 1.0, + ), + metallic: 0.0, + perceptual_roughness: 0.92, + unlit: false, + ), + ), + ), + ], + locked: false, + ), + 4: ( + id: 4, + name: "Shrub", + parent: None, + children: [], + transform: ( + translation: ( + x: -3.4, + y: 1.0, + z: 1.8, + ), + rotation: ( + x: 0.0, + y: 0.0, + z: 0.0, + ), + scale: ( + x: 1.25, + y: 0.9, + z: 1.25, + ), + ), + components: [ + ( + type: mesh3d, + data: ( + mesh: ( + source: primitive, + value: sphere( + radius: 1.0, + ), + ), + material: ( + base_color: ( + r: 0.24, + g: 0.45, + b: 0.22, + a: 1.0, + ), + metallic: 0.0, + perceptual_roughness: 0.85, + unlit: false, + ), + ), + ), + ], + locked: false, + ), + 5: ( + id: 5, + name: "Flower Stem", + parent: None, + children: [ + 6, + ], + transform: ( + translation: ( + x: 2.6, + y: 0.75, + z: 1.4, + ), + rotation: ( + x: 0.0, + y: 0.0, + z: 0.0, + ), + scale: ( + x: 1.0, + y: 1.0, + z: 1.0, + ), + ), + components: [ + ( + type: mesh3d, + data: ( + mesh: ( + source: primitive, + value: cylinder( + radius: 0.09, + height: 1.5, + ), + ), + material: ( + base_color: ( + r: 0.35, + g: 0.55, + b: 0.27, + a: 1.0, + ), + metallic: 0.0, + perceptual_roughness: 0.8, + unlit: false, + ), + ), + ), + ], + locked: false, + ), + 6: ( + id: 6, + name: "Flower Bloom", + parent: Some(5), + children: [], + transform: ( + translation: ( + x: 0.0, + y: 1.0, + z: 0.0, + ), + rotation: ( + x: 0.0, + y: 0.0, + z: 0.0, + ), + scale: ( + x: 1.0, + y: 0.75, + z: 1.0, + ), + ), + components: [ + ( + type: mesh3d, + data: ( + mesh: ( + source: primitive, + value: sphere( + radius: 0.42, + ), + ), + material: ( + base_color: ( + r: 0.91, + g: 0.42, + b: 0.65, + a: 1.0, + ), + metallic: 0.0, + perceptual_roughness: 0.4, + unlit: false, + ), + ), + ), + ], + locked: false, + ), + 7: ( + id: 7, + name: "Orbit Crystal", + parent: None, + children: [], + transform: ( + translation: ( + x: 0.0, + y: 2.0, + z: 0.0, + ), + rotation: ( + x: 0.0, + y: 0.0, + z: 0.0, + ), + scale: ( + x: 1.0, + y: 1.0, + z: 1.0, + ), + ), + components: [ + ( + type: mesh3d, + data: ( + mesh: ( + source: primitive, + value: sphere( + radius: 0.8, + ), + ), + material: ( + base_color: ( + r: 0.27, + g: 0.76, + b: 0.69, + a: 1.0, + ), + metallic: 0.15, + perceptual_roughness: 0.2, + unlit: false, + ), + ), + ), + ( + type: custom, + data: ( + type_path: "forge_demo::Orbit", + fields: [ + ( + name: "radius", + expr: "2.4", + ), + ( + name: "speed", + expr: "0.45", + ), + ], + ), + ), + ], + locked: false, + ), + 8: ( + id: 8, + name: "Lantern Post", + parent: None, + children: [ + 9, + ], + transform: ( + translation: ( + x: 3.8, + y: 1.2, + z: -1.2, + ), + rotation: ( + x: 0.0, + y: 0.0, + z: 0.0, + ), + scale: ( + x: 1.0, + y: 1.0, + z: 1.0, + ), + ), + components: [ + ( + type: mesh3d, + data: ( + mesh: ( + source: primitive, + value: cylinder( + radius: 0.12, + height: 2.4, + ), + ), + material: ( + base_color: ( + r: 0.18, + g: 0.2, + b: 0.25, + a: 1.0, + ), + metallic: 0.6, + perceptual_roughness: 0.35, + unlit: false, + ), + ), + ), + ], + locked: false, + ), + 9: ( + id: 9, + name: "Lantern Glow", + parent: Some(8), + children: [], + transform: ( + translation: ( + x: 0.0, + y: 1.25, + z: 0.0, + ), + rotation: ( + x: 0.0, + y: 0.0, + z: 0.0, + ), + scale: ( + x: 1.0, + y: 1.0, + z: 1.0, + ), + ), + components: [ + ( + type: point_light, + data: ( + color: ( + r: 1.0, + g: 0.82, + b: 0.55, + a: 1.0, + ), + intensity: 600.0, + range: 12.0, + shadows: true, + ), + ), + ], + locked: false, + ), + }, + roots: [ + 1, + 2, + 3, + 4, + 5, + 7, + 8, + ], + next_id: 10, +) \ No newline at end of file diff --git a/examples/demo-project/scenes/platformer.forge.ron b/examples/demo-project/scenes/platformer.forge.ron new file mode 100644 index 0000000..359f94f --- /dev/null +++ b/examples/demo-project/scenes/platformer.forge.ron @@ -0,0 +1,975 @@ +( + format_version: 1, + name: "Platformer Demo", + entities: { + 1: ( + id: 1, + name: "Backdrop", + parent: None, + children: [], + transform: ( + translation: ( + x: 0.0, + y: 0.0, + z: -30.0, + ), + rotation: ( + x: 0.0, + y: 0.0, + z: 0.0, + ), + scale: ( + x: 1.0, + y: 1.0, + z: 1.0, + ), + ), + components: [ + ( + type: sprite, + data: ( + image: Some("textures/background.png"), + color: ( + r: 1.0, + g: 1.0, + b: 1.0, + a: 1.0, + ), + custom_size: Some(( + x: 960.0, + y: 540.0, + )), + flip_x: false, + flip_y: false, + ), + ), + ], + locked: false, + ), + 2: ( + id: 2, + name: "Camera", + parent: None, + children: [], + transform: ( + translation: ( + x: 0.0, + y: 0.0, + z: 10.0, + ), + rotation: ( + x: 0.0, + y: 0.0, + z: 0.0, + ), + scale: ( + x: 1.0, + y: 1.0, + z: 1.0, + ), + ), + components: [ + ( + type: camera2d, + ), + ], + locked: false, + ), + 3: ( + id: 3, + name: "Title", + parent: None, + children: [], + transform: ( + translation: ( + x: 0.0, + y: 214.0, + z: 5.0, + ), + rotation: ( + x: 0.0, + y: 0.0, + z: 0.0, + ), + scale: ( + x: 1.0, + y: 1.0, + z: 1.0, + ), + ), + components: [ + ( + type: text2d, + data: ( + text: "Bevy Forge Platformer", + font_size: 44.0, + color: ( + r: 0.96, + g: 0.94, + b: 0.88, + a: 1.0, + ), + ), + ), + ], + locked: false, + ), + 4: ( + id: 4, + name: "Hint", + parent: None, + children: [], + transform: ( + translation: ( + x: 0.0, + y: 176.0, + z: 5.0, + ), + rotation: ( + x: 0.0, + y: 0.0, + z: 0.0, + ), + scale: ( + x: 1.0, + y: 1.0, + z: 1.0, + ), + ), + components: [ + ( + type: text2d, + data: ( + text: "Collect the coins!", + font_size: 20.0, + color: ( + r: 0.95, + g: 0.67, + b: 0.25, + a: 1.0, + ), + ), + ), + ], + locked: false, + ), + 5: ( + id: 5, + name: "Ground 1", + parent: None, + children: [], + transform: ( + translation: ( + x: -336.0, + y: -224.0, + z: -2.0, + ), + rotation: ( + x: 0.0, + y: 0.0, + z: 0.0, + ), + scale: ( + x: 1.0, + y: 1.0, + z: 1.0, + ), + ), + components: [ + ( + type: sprite, + data: ( + image: Some("textures/tile.png"), + color: ( + r: 1.0, + g: 1.0, + b: 1.0, + a: 1.0, + ), + custom_size: Some(( + x: 96.0, + y: 96.0, + )), + flip_x: false, + flip_y: false, + ), + ), + ], + locked: false, + ), + 6: ( + id: 6, + name: "Ground 2", + parent: None, + children: [], + transform: ( + translation: ( + x: -240.0, + y: -224.0, + z: -2.0, + ), + rotation: ( + x: 0.0, + y: 0.0, + z: 0.0, + ), + scale: ( + x: 1.0, + y: 1.0, + z: 1.0, + ), + ), + components: [ + ( + type: sprite, + data: ( + image: Some("textures/tile.png"), + color: ( + r: 1.0, + g: 1.0, + b: 1.0, + a: 1.0, + ), + custom_size: Some(( + x: 96.0, + y: 96.0, + )), + flip_x: false, + flip_y: false, + ), + ), + ], + locked: false, + ), + 7: ( + id: 7, + name: "Ground 3", + parent: None, + children: [], + transform: ( + translation: ( + x: -144.0, + y: -224.0, + z: -2.0, + ), + rotation: ( + x: 0.0, + y: 0.0, + z: 0.0, + ), + scale: ( + x: 1.0, + y: 1.0, + z: 1.0, + ), + ), + components: [ + ( + type: sprite, + data: ( + image: Some("textures/tile.png"), + color: ( + r: 1.0, + g: 1.0, + b: 1.0, + a: 1.0, + ), + custom_size: Some(( + x: 96.0, + y: 96.0, + )), + flip_x: false, + flip_y: false, + ), + ), + ], + locked: false, + ), + 8: ( + id: 8, + name: "Ground 4", + parent: None, + children: [], + transform: ( + translation: ( + x: -48.0, + y: -224.0, + z: -2.0, + ), + rotation: ( + x: 0.0, + y: 0.0, + z: 0.0, + ), + scale: ( + x: 1.0, + y: 1.0, + z: 1.0, + ), + ), + components: [ + ( + type: sprite, + data: ( + image: Some("textures/tile.png"), + color: ( + r: 1.0, + g: 1.0, + b: 1.0, + a: 1.0, + ), + custom_size: Some(( + x: 96.0, + y: 96.0, + )), + flip_x: false, + flip_y: false, + ), + ), + ], + locked: false, + ), + 9: ( + id: 9, + name: "Ground 5", + parent: None, + children: [], + transform: ( + translation: ( + x: 48.0, + y: -224.0, + z: -2.0, + ), + rotation: ( + x: 0.0, + y: 0.0, + z: 0.0, + ), + scale: ( + x: 1.0, + y: 1.0, + z: 1.0, + ), + ), + components: [ + ( + type: sprite, + data: ( + image: Some("textures/tile.png"), + color: ( + r: 1.0, + g: 1.0, + b: 1.0, + a: 1.0, + ), + custom_size: Some(( + x: 96.0, + y: 96.0, + )), + flip_x: false, + flip_y: false, + ), + ), + ], + locked: false, + ), + 10: ( + id: 10, + name: "Ground 6", + parent: None, + children: [], + transform: ( + translation: ( + x: 144.0, + y: -224.0, + z: -2.0, + ), + rotation: ( + x: 0.0, + y: 0.0, + z: 0.0, + ), + scale: ( + x: 1.0, + y: 1.0, + z: 1.0, + ), + ), + components: [ + ( + type: sprite, + data: ( + image: Some("textures/tile.png"), + color: ( + r: 1.0, + g: 1.0, + b: 1.0, + a: 1.0, + ), + custom_size: Some(( + x: 96.0, + y: 96.0, + )), + flip_x: false, + flip_y: false, + ), + ), + ], + locked: false, + ), + 11: ( + id: 11, + name: "Ground 7", + parent: None, + children: [], + transform: ( + translation: ( + x: 240.0, + y: -224.0, + z: -2.0, + ), + rotation: ( + x: 0.0, + y: 0.0, + z: 0.0, + ), + scale: ( + x: 1.0, + y: 1.0, + z: 1.0, + ), + ), + components: [ + ( + type: sprite, + data: ( + image: Some("textures/tile.png"), + color: ( + r: 1.0, + g: 1.0, + b: 1.0, + a: 1.0, + ), + custom_size: Some(( + x: 96.0, + y: 96.0, + )), + flip_x: false, + flip_y: false, + ), + ), + ], + locked: false, + ), + 12: ( + id: 12, + name: "Ground 8", + parent: None, + children: [], + transform: ( + translation: ( + x: 336.0, + y: -224.0, + z: -2.0, + ), + rotation: ( + x: 0.0, + y: 0.0, + z: 0.0, + ), + scale: ( + x: 1.0, + y: 1.0, + z: 1.0, + ), + ), + components: [ + ( + type: sprite, + data: ( + image: Some("textures/tile.png"), + color: ( + r: 1.0, + g: 1.0, + b: 1.0, + a: 1.0, + ), + custom_size: Some(( + x: 96.0, + y: 96.0, + )), + flip_x: false, + flip_y: false, + ), + ), + ], + locked: false, + ), + 13: ( + id: 13, + name: "Ledge 1", + parent: None, + children: [], + transform: ( + translation: ( + x: -48.0, + y: -64.0, + z: -2.0, + ), + rotation: ( + x: 0.0, + y: 0.0, + z: 0.0, + ), + scale: ( + x: 1.0, + y: 1.0, + z: 1.0, + ), + ), + components: [ + ( + type: sprite, + data: ( + image: Some("textures/tile.png"), + color: ( + r: 1.0, + g: 1.0, + b: 1.0, + a: 1.0, + ), + custom_size: Some(( + x: 96.0, + y: 96.0, + )), + flip_x: false, + flip_y: false, + ), + ), + ], + locked: false, + ), + 14: ( + id: 14, + name: "Ledge 2", + parent: None, + children: [], + transform: ( + translation: ( + x: 48.0, + y: -64.0, + z: -2.0, + ), + rotation: ( + x: 0.0, + y: 0.0, + z: 0.0, + ), + scale: ( + x: 1.0, + y: 1.0, + z: 1.0, + ), + ), + components: [ + ( + type: sprite, + data: ( + image: Some("textures/tile.png"), + color: ( + r: 1.0, + g: 1.0, + b: 1.0, + a: 1.0, + ), + custom_size: Some(( + x: 96.0, + y: 96.0, + )), + flip_x: false, + flip_y: false, + ), + ), + ], + locked: false, + ), + 15: ( + id: 15, + name: "Ledge 3", + parent: None, + children: [], + transform: ( + translation: ( + x: 144.0, + y: -64.0, + z: -2.0, + ), + rotation: ( + x: 0.0, + y: 0.0, + z: 0.0, + ), + scale: ( + x: 1.0, + y: 1.0, + z: 1.0, + ), + ), + components: [ + ( + type: sprite, + data: ( + image: Some("textures/tile.png"), + color: ( + r: 1.0, + g: 1.0, + b: 1.0, + a: 1.0, + ), + custom_size: Some(( + x: 96.0, + y: 96.0, + )), + flip_x: false, + flip_y: false, + ), + ), + ], + locked: false, + ), + 16: ( + id: 16, + name: "Player", + parent: None, + children: [ + 17, + ], + transform: ( + translation: ( + x: -192.0, + y: -128.0, + z: 0.0, + ), + rotation: ( + x: 0.0, + y: 0.0, + z: 0.0, + ), + scale: ( + x: 1.0, + y: 1.0, + z: 1.0, + ), + ), + components: [ + ( + type: sprite, + data: ( + image: Some("textures/player.png"), + color: ( + r: 1.0, + g: 1.0, + b: 1.0, + a: 1.0, + ), + custom_size: Some(( + x: 64.0, + y: 96.0, + )), + flip_x: false, + flip_y: false, + ), + ), + ], + locked: false, + ), + 17: ( + id: 17, + name: "Hat", + parent: Some(16), + children: [], + transform: ( + translation: ( + x: 0.0, + y: 42.0, + z: 1.0, + ), + rotation: ( + x: 0.0, + y: 0.0, + z: 0.0, + ), + scale: ( + x: 1.0, + y: 1.0, + z: 1.0, + ), + ), + components: [ + ( + type: sprite, + data: ( + image: Some("textures/hat.png"), + color: ( + r: 1.0, + g: 1.0, + b: 1.0, + a: 1.0, + ), + custom_size: Some(( + x: 56.0, + y: 28.0, + )), + flip_x: false, + flip_y: false, + ), + ), + ], + locked: false, + ), + 18: ( + id: 18, + name: "Coin 1", + parent: None, + children: [], + transform: ( + translation: ( + x: -288.0, + y: -104.0, + z: 1.0, + ), + rotation: ( + x: 0.0, + y: 0.0, + z: 0.0, + ), + scale: ( + x: 1.0, + y: 1.0, + z: 1.0, + ), + ), + components: [ + ( + type: sprite, + data: ( + image: Some("textures/coin.png"), + color: ( + r: 1.0, + g: 1.0, + b: 1.0, + a: 1.0, + ), + custom_size: Some(( + x: 48.0, + y: 48.0, + )), + flip_x: false, + flip_y: false, + ), + ), + ], + locked: false, + ), + 19: ( + id: 19, + name: "Coin 2", + parent: None, + children: [], + transform: ( + translation: ( + x: 48.0, + y: 28.0, + z: 1.0, + ), + rotation: ( + x: 0.0, + y: 0.0, + z: 0.0, + ), + scale: ( + x: 1.0, + y: 1.0, + z: 1.0, + ), + ), + components: [ + ( + type: sprite, + data: ( + image: Some("textures/coin.png"), + color: ( + r: 1.0, + g: 1.0, + b: 1.0, + a: 1.0, + ), + custom_size: Some(( + x: 48.0, + y: 48.0, + )), + flip_x: false, + flip_y: false, + ), + ), + ], + locked: false, + ), + 20: ( + id: 20, + name: "Coin 3", + parent: None, + children: [], + transform: ( + translation: ( + x: 240.0, + y: -96.0, + z: 1.0, + ), + rotation: ( + x: 0.0, + y: 0.0, + z: 0.0, + ), + scale: ( + x: 1.0, + y: 1.0, + z: 1.0, + ), + ), + components: [ + ( + type: sprite, + data: ( + image: Some("textures/coin.png"), + color: ( + r: 1.0, + g: 1.0, + b: 1.0, + a: 1.0, + ), + custom_size: Some(( + x: 48.0, + y: 48.0, + )), + flip_x: false, + flip_y: false, + ), + ), + ], + locked: false, + ), + 21: ( + id: 21, + name: "Crate", + parent: None, + children: [], + transform: ( + translation: ( + x: 352.0, + y: -144.0, + z: -1.0, + ), + rotation: ( + x: 0.0, + y: 0.0, + z: 0.0, + ), + scale: ( + x: 1.0, + y: 1.0, + z: 1.0, + ), + ), + components: [ + ( + type: sprite, + data: ( + image: Some("textures/crate.png"), + color: ( + r: 1.0, + g: 1.0, + b: 1.0, + a: 1.0, + ), + custom_size: Some(( + x: 64.0, + y: 64.0, + )), + flip_x: false, + flip_y: false, + ), + ), + ], + locked: false, + ), + 22: ( + id: 22, + name: "Crate 2", + parent: None, + children: [], + transform: ( + translation: ( + x: 352.0, + y: -80.0, + z: -1.0, + ), + rotation: ( + x: 0.0, + y: 0.0, + z: 0.0, + ), + scale: ( + x: 1.0, + y: 1.0, + z: 1.0, + ), + ), + components: [ + ( + type: sprite, + data: ( + image: Some("textures/crate.png"), + color: ( + r: 1.0, + g: 1.0, + b: 1.0, + a: 1.0, + ), + custom_size: Some(( + x: 64.0, + y: 64.0, + )), + flip_x: false, + flip_y: false, + ), + ), + ], + locked: false, + ), + }, + roots: [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 18, + 19, + 20, + 21, + 22, + ], + next_id: 23, +) \ No newline at end of file diff --git a/examples/demo-project/src/main.rs b/examples/demo-project/src/main.rs new file mode 100644 index 0000000..06fbb40 --- /dev/null +++ b/examples/demo-project/src/main.rs @@ -0,0 +1,57 @@ +//! Minimal Bevy 0.20 app for the Bevy Forge demo project. +//! +//! Bevy Forge opens this directory as a Bevy project: +//! +//! ```text +//! cargo run -p bevy_forge_editor -- examples/demo-project +//! ``` +//! +//! The files in `scenes/` are editor documents (`.forge.ron`), not runtime Bevy +//! assets. To run a scene, export it from Bevy Forge (or the `forge` CLI) and paste +//! the generated function into this file, then call it from a startup system: +//! +//! - a single-root scene uses `bsn!`, returns `impl Scene` and is spawned with +//! `commands.spawn_scene(scene(..))`; +//! - a multi-root scene uses `bsn_list!`, returns `impl SceneList` and is spawned +//! with `commands.spawn_scene_list(scene(..))`; +//! - generated mesh scenes take `&mut Assets` and +//! `&mut Assets` parameters; the imperative Rust export also +//! takes `Commands` and `AssetServer`. +//! +//! The exported garden scene refers to the custom `forge_demo::Orbit` component, so +//! that type lives in this crate (see the module below). Keep it, or replace the +//! path in the generated code, when pasting an export here. + +use bevy::prelude::*; + +fn main() { + App::new().add_plugins(DefaultPlugins).run(); +} + +/// Project-specific components used by the demo scenes. +/// +/// `scenes/garden.forge.ron` attaches `forge_demo::Orbit` to the Orbit Crystal, and +/// the BSN export emits `forge_demo::Orbit { radius: 2.4, speed: 0.45 }`. Deriving +/// `Component` plus `Default`/`Clone` is enough for Bevy 0.20's `FromTemplate` +/// blanket implementation, so the pasted scene compiles in this crate. +pub mod forge_demo { + use bevy::prelude::*; + + /// Animates an entity in a circle around its spawn point. + #[derive(Component, Debug, Clone)] + pub struct Orbit { + /// Orbit radius in world units. + pub radius: f32, + /// Angular speed in radians per second. + pub speed: f32, + } + + impl Default for Orbit { + fn default() -> Self { + Self { + radius: 2.4, + speed: 0.45, + } + } + } +} diff --git a/examples/demo-project/tools/generate_textures.py b/examples/demo-project/tools/generate_textures.py new file mode 100644 index 0000000..7931779 --- /dev/null +++ b/examples/demo-project/tools/generate_textures.py @@ -0,0 +1,318 @@ +#!/usr/bin/env python3 +"""Generate the pixel-art textures used by the Bevy Forge demo project. + +All textures are drawn procedurally from a small palette so the demo stays +license-clean and can be regenerated on any machine with Pillow installed: + + python3 examples/demo-project/tools/generate_textures.py + +The output is written to ``examples/demo-project/assets/textures``. +""" + +from __future__ import annotations + +import random +from pathlib import Path + +from PIL import Image, ImageDraw + +ROOT = Path(__file__).resolve().parents[1] +OUT = ROOT / "assets" / "textures" +OUT.mkdir(parents=True, exist_ok=True) + +# Shared palette (deliberately small, dusk / forest leaning). +OUTLINE = (43, 32, 51, 255) +SKIN = (244, 201, 158, 255) +SKIN_SHADOW = (217, 164, 122, 255) +HAIR = (122, 78, 45, 255) +TUNIC = (54, 126, 143, 255) +TUNIC_DARK = (38, 95, 110, 255) +BELT = (91, 62, 38, 255) +BUCKLE = (242, 179, 61, 255) +PANTS = (64, 79, 105, 255) +BOOT = (74, 56, 43, 255) +CREAM = (255, 244, 214, 255) + + +def logical(width: int, height: int) -> tuple[Image.Image, ImageDraw.ImageDraw]: + image = Image.new("RGBA", (width, height), (0, 0, 0, 0)) + return image, ImageDraw.Draw(image) + + +def upscale(image: Image.Image, factor: int) -> Image.Image: + return image.resize( + (image.width * factor, image.height * factor), + Image.Resampling.NEAREST, + ) + + +def save(image: Image.Image, name: str) -> None: + image.save(OUT / name, optimize=True) + print(f"wrote {name}: {image.width}x{image.height}") + + +def draw_player() -> None: + image, draw = logical(16, 24) + # Boots. + draw.rectangle((3, 20, 7, 23), fill=OUTLINE) + draw.rectangle((8, 20, 12, 23), fill=OUTLINE) + draw.rectangle((4, 21, 7, 22), fill=BOOT) + draw.rectangle((8, 21, 11, 22), fill=BOOT) + # Legs. + draw.rectangle((4, 16, 11, 20), fill=OUTLINE) + draw.rectangle((5, 17, 10, 20), fill=PANTS) + draw.rectangle((7, 17, 7, 19), fill=TUNIC_DARK) + # Torso and arms. + draw.rectangle((3, 10, 12, 16), fill=OUTLINE) + draw.rectangle((4, 11, 11, 15), fill=TUNIC) + draw.rectangle((2, 11, 3, 15), fill=OUTLINE) + draw.rectangle((12, 11, 13, 15), fill=OUTLINE) + draw.rectangle((3, 12, 3, 14), fill=TUNIC) + draw.rectangle((12, 12, 12, 14), fill=TUNIC) + draw.rectangle((3, 16, 4, 16), fill=SKIN) + draw.rectangle((11, 16, 12, 16), fill=SKIN) + draw.rectangle((3, 14, 12, 15), fill=BELT) + draw.rectangle((7, 14, 8, 15), fill=BUCKLE) + draw.rectangle((4, 11, 11, 11), fill=TUNIC_DARK) + # Head with a friendly face. + draw.rectangle((4, 2, 11, 10), fill=OUTLINE) + draw.rectangle((5, 3, 10, 9), fill=SKIN) + draw.rectangle((5, 2, 10, 4), fill=HAIR) + draw.rectangle((4, 4, 4, 6), fill=HAIR) + draw.rectangle((11, 4, 11, 6), fill=HAIR) + draw.rectangle((6, 6, 6, 6), fill=OUTLINE) + draw.rectangle((9, 6, 9, 6), fill=OUTLINE) + draw.rectangle((7, 8, 8, 8), fill=SKIN_SHADOW) + draw.rectangle((5, 8, 5, 8), fill=SKIN_SHADOW) + draw.rectangle((10, 8, 10, 8), fill=SKIN_SHADOW) + save(upscale(image, 4), "player.png") + + +def draw_hat() -> None: + image, draw = logical(16, 8) + hat = (228, 87, 46, 255) + hat_shine = (244, 138, 54, 255) + brim = (188, 62, 34, 255) + hat_outline = (63, 35, 28, 255) + draw.rectangle((3, 1, 12, 5), fill=hat_outline) + draw.rectangle((4, 2, 11, 4), fill=hat) + draw.rectangle((5, 2, 8, 3), fill=hat_shine) + draw.rectangle((7, 0, 8, 0), fill=hat_outline) + draw.rectangle((1, 5, 14, 7), fill=hat_outline) + draw.rectangle((2, 5, 13, 6), fill=brim) + draw.rectangle((4, 4, 11, 4), fill=brim) + save(upscale(image, 4), "hat.png") + + +def draw_tile() -> None: + image, draw = logical(24, 24) + dirt = (122, 78, 45, 255) + dirt_dark = (92, 58, 34, 255) + dirt_light = (155, 118, 75, 255) + grass = (78, 159, 61, 255) + grass_light = (111, 207, 95, 255) + grass_dark = (46, 91, 38, 255) + draw.rectangle((0, 0, 23, 23), fill=dirt) + draw.rectangle((0, 0, 23, 1), fill=grass_light) + draw.rectangle((0, 2, 23, 5), fill=grass) + draw.rectangle((0, 6, 23, 6), fill=grass_dark) + draw.rectangle((0, 7, 23, 7), fill=dirt_dark) + # Grass dribbling over the soil edge. + for x in (2, 3, 8, 9, 15, 20, 21): + draw.rectangle((x, 7, x, 8), fill=grass) + draw.rectangle((3, 7, 6, 8), fill=grass_dark) + draw.rectangle((16, 7, 18, 9), fill=grass_dark) + # Soil speckles and stones. + rng = random.Random(11) + for _ in range(16): + x = rng.randint(1, 21) + y = rng.randint(10, 22) + draw.rectangle((x, y, x + 1, y), fill=dirt_dark) + for _ in range(8): + x = rng.randint(2, 20) + y = rng.randint(12, 21) + draw.rectangle((x, y, x, y), fill=dirt_light) + draw.rectangle((5, 14, 7, 14), fill=(155, 122, 83, 255)) + draw.rectangle((17, 18, 19, 18), fill=(155, 122, 83, 255)) + draw.rectangle((0, 22, 23, 23), fill=dirt_dark) + # Tiny flowers so a long ground row reads as a garden. + for x in (6, 19): + draw.rectangle((x, 1, x, 1), fill=CREAM) + draw.rectangle((x - 1, 1, x + 1, 1), fill=CREAM) + draw.rectangle((x, 0, x, 0), fill=CREAM) + draw.rectangle((x, 3, x, 3), fill=(78, 159, 61, 255)) + draw.rectangle((x, 2, x, 2), fill=(242, 179, 61, 255)) + save(upscale(image, 4), "tile.png") + + +def draw_coin() -> None: + image, draw = logical(16, 16) + draw.ellipse((1, 1, 14, 14), fill=(140, 90, 22, 255)) + draw.ellipse((2, 2, 13, 13), fill=(242, 179, 61, 255)) + draw.ellipse((4, 4, 11, 11), fill=(255, 217, 102, 255)) + draw.rectangle((7, 5, 8, 10), fill=CREAM) + draw.rectangle((5, 7, 10, 8), fill=CREAM) + draw.rectangle((5, 4, 6, 4), fill=(255, 255, 255, 255)) + draw.rectangle((4, 5, 4, 5), fill=(255, 255, 255, 255)) + save(upscale(image, 4), "coin.png") + + +def draw_crate() -> None: + image, draw = logical(16, 16) + outline = (63, 43, 28, 255) + wood = (169, 116, 63, 255) + wood_light = (201, 143, 82, 255) + wood_dark = (122, 78, 45, 255) + draw.rectangle((0, 0, 15, 15), fill=outline) + draw.rectangle((1, 1, 14, 14), fill=wood) + draw.rectangle((1, 1, 14, 1), fill=wood_light) + draw.rectangle((1, 14, 14, 14), fill=wood_dark) + draw.rectangle((1, 1, 3, 14), fill=wood_light) + draw.rectangle((12, 1, 14, 14), fill=wood_dark) + draw.line((4, 3, 11, 12), fill=wood_dark) + draw.line((11, 3, 4, 12), fill=wood_dark) + for x, y in ((2, 2), (13, 2), (2, 13), (13, 13)): + draw.rectangle((x, y, x, y), fill=(217, 187, 130, 255)) + save(upscale(image, 4), "crate.png") + + +def dusk_gradient(t: float) -> tuple[int, int, int]: + stops = [ + (0.00, (15, 14, 34)), + (0.42, (45, 34, 70)), + (0.62, (96, 58, 78)), + (0.74, (176, 92, 70)), + (0.82, (232, 146, 84)), + (0.90, (244, 180, 110)), + (1.00, (28, 26, 48)), + ] + for index in range(len(stops) - 1): + start_t, start = stops[index] + end_t, end = stops[index + 1] + if start_t <= t <= end_t: + span = max(end_t - start_t, 1e-6) + blend = (t - start_t) / span + return tuple( + round(start[channel] + (end[channel] - start[channel]) * blend) + for channel in range(3) + ) + return stops[-1][1] + + +def draw_background() -> None: + width, height = 240, 135 + image = Image.new("RGB", (width, height)) + draw = ImageDraw.Draw(image) + for y in range(height): + draw.line(((0, y), (width, y)), fill=dusk_gradient(y / (height - 1))) + # Stars. + rng = random.Random(7) + for _ in range(46): + x = rng.randint(2, width - 3) + y = rng.randint(3, 62) + brightness = rng.choice([(255, 255, 255), (255, 244, 214), (210, 205, 235)]) + draw.point((x, y), fill=brightness) + if rng.random() < 0.18: + draw.point((x + 1, y), fill=brightness) + # Soft evening clouds. + draw.rectangle((0, 26, width, 28), fill=(66, 52, 92)) + draw.rectangle((20, 38, 190, 39), fill=(78, 60, 104)) + draw.rectangle((60, 47, 230, 48), fill=(90, 66, 105)) + # Sun low over the horizon. + draw.ellipse((150, 64, 191, 105), fill=(247, 190, 120)) + draw.ellipse((157, 71, 184, 98), fill=(255, 226, 164)) + draw.ellipse((162, 76, 179, 93), fill=(255, 244, 214)) + # Far mountain range. + draw.polygon( + [ + (0, 101), + (25, 82), + (48, 94), + (72, 76), + (95, 90), + (120, 80), + (150, 97), + (175, 84), + (205, 96), + (230, 88), + (240, 96), + (240, 135), + (0, 135), + ], + fill=(66, 52, 96), + ) + # Mid range. + draw.polygon( + [ + (0, 112), + (30, 100), + (55, 108), + (80, 94), + (110, 106), + (140, 98), + (168, 110), + (196, 100), + (220, 108), + (240, 102), + (240, 135), + (0, 135), + ], + fill=(47, 38, 72), + ) + # Rolling hills. + draw.polygon( + [ + (0, 123), + (35, 114), + (70, 121), + (110, 112), + (145, 120), + (180, 113), + (215, 121), + (240, 115), + (240, 135), + (0, 135), + ], + fill=(31, 29, 52), + ) + # Foreground silhouette with a few conifers. + draw.polygon( + [ + (0, 129), + (30, 126), + (60, 129), + (90, 125), + (120, 129), + (150, 126), + (180, 130), + (210, 126), + (240, 129), + (240, 135), + (0, 135), + ], + fill=(20, 19, 34), + ) + for x, base in ((28, 129), (66, 130), (148, 127), (196, 131)): + draw.polygon([(x, base - 12), (x - 4, base), (x + 4, base)], fill=(17, 24, 30)) + draw.rectangle((x - 1, base - 13, x + 1, base - 10), fill=(17, 24, 30)) + draw.rectangle((x, base - 14, x, base - 13), fill=(17, 24, 30)) + for x in range(4, width, 17): + draw.point((x, 132), fill=(36, 55, 42)) + image = upscale(image, 4) + image = image.quantize(colors=64, method=Image.Quantize.MEDIANCUT, dither=Image.Dither.NONE) + save(image.convert("RGB"), "background.png") + + +def main() -> None: + draw_background() + draw_player() + draw_hat() + draw_tile() + draw_coin() + draw_crate() + total = sum(path.stat().st_size for path in OUT.glob("*.png")) + print(f"total texture size: {total} bytes") + + +if __name__ == "__main__": + main() diff --git a/scripts/check-generated-code.sh b/scripts/check-generated-code.sh new file mode 100755 index 0000000..5570f92 --- /dev/null +++ b/scripts/check-generated-code.sh @@ -0,0 +1,113 @@ +#!/usr/bin/env bash +# +# Bevy Forge — end-to-end generated-code gate. +# +# 1. Exports the demo project's scenes with the real `forge` CLI (`--format bsn`). +# 2. Writes the exports into the excluded `compat/bsn-smoke` crate. +# 3. Runs `cargo check` there, compiling the generated code against real +# Bevy 0.20.0 (the only Bevy dependency in the repository). +# +# Usage (from anywhere): +# bash scripts/check-generated-code.sh +# +# Environment: +# FORGE_SKIP_GENERATED=1 Skip the gate with a clear message (used only +# before the CLI / demo project exist). +# FORGE_SKIP_SECOND_SCENE=1 Only export the 3D garden scene. +# FORGE_GARDEN_SCENE=... Override the 3D scene path. +# FORGE_PLATFORMER_SCENE=... Override the 2D scene path. +# CARGO_TARGET_DIR=... Passed through to cargo (useful for a shared +# target dir; the first cold check compiles Bevy). +# +set -euo pipefail + +SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +ROOT="$(cd -- "$SCRIPT_DIR/.." && pwd)" +cd "$ROOT" + +GARDEN_SCENE="${FORGE_GARDEN_SCENE:-examples/demo-project/scenes/garden.forge.ron}" +PLATFORMER_SCENE="${FORGE_PLATFORMER_SCENE:-examples/demo-project/scenes/platformer.forge.ron}" +GENERATED_DIR="compat/bsn-smoke/src" + +log() { printf '[bsn-gate] %s\n' "$*"; } +fail() { + printf '[bsn-gate] ERROR: %s\n' "$*" >&2 + exit 1 +} + +if [[ "${FORGE_SKIP_GENERATED:-0}" == "1" ]]; then + log "FORGE_SKIP_GENERATED=1: skipping the generated-code gate." + log "The committed starter scenes in compat/bsn-smoke/src were NOT regenerated." + log "The compat crate can still be checked with: (cd compat/bsn-smoke && cargo check)" + exit 0 +fi + +[[ -f "$GARDEN_SCENE" ]] || + fail "scene not found: $GARDEN_SCENE (is the demo project from A05 ready?)" +[[ -f "crates/bevy_forge_cli/Cargo.toml" ]] || + fail "CLI crate not found: crates/bevy_forge_cli (is A06 ready?)" +[[ -d "$GENERATED_DIR" ]] || + fail "compat crate not found: $GENERATED_DIR" + +# The generated files live inside modules of the smoke crate. Prefix a shim that +# (a) brings crate-root support types (the demo project's custom components) +# into scope and (b) silences the resulting redundant-prelude warning. The +# generated code itself is appended unchanged. +write_generated() { + local source="$1" destination="$2" + { + printf '%s\n' '// compat/bsn-smoke shim: expose crate-root support types (demo custom components).' + printf '%s\n' '// The generated code below this line is unchanged.' + printf '%s\n' '#![allow(unused_imports)] // the crate::* glob also provides the Bevy prelude names' + printf '%s\n' 'use crate::*;' + cat "$source" + } >"$destination" +} + +TMP_DIR="$(mktemp -d)" +trap 'rm -rf "$TMP_DIR"' EXIT + +log "Bevy Forge generated-code gate" +log "repo: $ROOT" +log "Bevy feature set: see compat/bsn-smoke/Cargo.toml (0.20.0, default-features = false)" + +# --- 1. Export the 3D garden scene as BSN ------------------------------------- +log "exporting BSN: $GARDEN_SCENE" +if ! CARGO_NET_OFFLINE=true cargo run -q -p bevy_forge_cli -- \ + export "$GARDEN_SCENE" --format bsn -o "$TMP_DIR/garden.rs"; then + fail "forge export failed for $GARDEN_SCENE (see the error above; not a generated-code failure yet)" +fi +[[ -s "$TMP_DIR/garden.rs" ]] || + fail "forge export produced no output for $GARDEN_SCENE" +grep -Eq 'bsn!|bsn_list!' "$TMP_DIR/garden.rs" || + fail "exported file does not contain a bsn! macro: $GARDEN_SCENE" +write_generated "$TMP_DIR/garden.rs" "$GENERATED_DIR/generated.rs" +log "wrote $GENERATED_DIR/generated.rs ($(wc -l <"$GENERATED_DIR/generated.rs") lines)" + +# --- 2. Export the 2D platformer scene (optional second module) --------------- +if [[ "${FORGE_SKIP_SECOND_SCENE:-0}" != "1" && -f "$PLATFORMER_SCENE" ]]; then + log "exporting BSN: $PLATFORMER_SCENE" + if ! CARGO_NET_OFFLINE=true cargo run -q -p bevy_forge_cli -- \ + export "$PLATFORMER_SCENE" --format bsn -o "$TMP_DIR/platformer.rs"; then + fail "forge export failed for $PLATFORMER_SCENE" + fi + [[ -s "$TMP_DIR/platformer.rs" ]] || + fail "forge export produced no output for $PLATFORMER_SCENE" + grep -Eq 'bsn!|bsn_list!' "$TMP_DIR/platformer.rs" || + fail "exported file does not contain a BSN macro: $PLATFORMER_SCENE" + write_generated "$TMP_DIR/platformer.rs" "$GENERATED_DIR/generated_2d.rs" + log "wrote $GENERATED_DIR/generated_2d.rs ($(wc -l <"$GENERATED_DIR/generated_2d.rs") lines)" + SECOND_SCENE_NOTE=" and $(basename "$PLATFORMER_SCENE")" +else + log "skipping the 2D scene export (second scene disabled or not present); committed starter kept" + SECOND_SCENE_NOTE="" +fi + +# --- 3. Compile generated code against Bevy 0.20.0 --------------------------- +log "compiling generated BSN against Bevy 0.20.0: (cd compat/bsn-smoke && cargo check)" +if ! (cd compat/bsn-smoke && CARGO_NET_OFFLINE=true cargo check); then + fail "generated BSN failed to compile against Bevy 0.20.0 (see rustc errors above; generated files were kept for diagnosis)" +fi + +log "SUCCESS: generated BSN from $(basename "$GARDEN_SCENE")${SECOND_SCENE_NOTE} compiled against Bevy 0.20.0" +log "generated files: $GENERATED_DIR/generated.rs, $GENERATED_DIR/generated_2d.rs"