commit 13bf16d5109a22d90bbbaa88ede31df9946c6e05 Author: Emil Shanaty Date: Tue Oct 27 18:52:10 2026 +0300 Bevy Forge 0.1.0: visual scene editor for Bevy 0.20 BSN 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 0000000..a3990b5 Binary files /dev/null and b/docs/screenshot-3d.png differ diff --git a/docs/screenshot.png b/docs/screenshot.png new file mode 100644 index 0000000..955cd9f Binary files /dev/null and b/docs/screenshot.png differ 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 0000000..7dfcc57 Binary files /dev/null and b/examples/demo-project/assets/textures/background.png differ diff --git a/examples/demo-project/assets/textures/coin.png b/examples/demo-project/assets/textures/coin.png new file mode 100644 index 0000000..d56ae39 Binary files /dev/null and b/examples/demo-project/assets/textures/coin.png differ diff --git a/examples/demo-project/assets/textures/crate.png b/examples/demo-project/assets/textures/crate.png new file mode 100644 index 0000000..f3f419f Binary files /dev/null and b/examples/demo-project/assets/textures/crate.png differ diff --git a/examples/demo-project/assets/textures/hat.png b/examples/demo-project/assets/textures/hat.png new file mode 100644 index 0000000..70a25e3 Binary files /dev/null and b/examples/demo-project/assets/textures/hat.png differ diff --git a/examples/demo-project/assets/textures/player.png b/examples/demo-project/assets/textures/player.png new file mode 100644 index 0000000..b7c1421 Binary files /dev/null and b/examples/demo-project/assets/textures/player.png differ diff --git a/examples/demo-project/assets/textures/tile.png b/examples/demo-project/assets/textures/tile.png new file mode 100644 index 0000000..e865400 Binary files /dev/null and b/examples/demo-project/assets/textures/tile.png differ 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"