Bevy Forge 0.1.0: visual scene editor for Bevy 0.20 BSN

This commit is contained in:
Emil Shanaty
2026-10-27 18:52:10 +03:00
commit 13bf16d510
79 changed files with 44353 additions and 0 deletions
+30
View File
@@ -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 (.<name>.tmp-<pid>)
*.tmp-*
# OS metadata
.DS_Store
._*
.Spotlight-V100
.Trashes
Thumbs.db
ehthumbs.db
desktop.ini
# Editor / IDE junk
.idea/
.vscode/
*.swp
*.swo
*~
*.rs.bk
Generated
+4354
View File
File diff suppressed because it is too large Load Diff
+35
View File
@@ -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
+202
View File
@@ -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.
+18
View File
@@ -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.
+321
View File
@@ -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/<name>.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<Assets<Mesh>>,
mut materials: ResMut<Assets<StandardMaterial>>,
) {
// 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<Mesh>` / `&mut Assets<StandardMaterial>` 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 `<type_path> { field: <raw expression> }`. 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.
+2
View File
@@ -0,0 +1,2 @@
# Local Bevy build cache (several GB on the first check).
/target/
+4232
View File
File diff suppressed because it is too large Load Diff
+29
View File
@@ -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",
] }
+126
View File
@@ -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::<StandardMaterial>({ materials.add(StandardMaterial { ..default() }) })`),
plus an asset mesh path (`Mesh3d("models/...#Mesh0/Primitive0")`) with an inline material.
- **Generated signatures:** mesh scenes take `&mut Assets<Mesh>` and
`&mut Assets<StandardMaterial>` 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::<StandardMaterial>("...")`),
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.
+130
View File
@@ -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<Mesh>,
materials: &mut Assets<StandardMaterial>,
) -> 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::<StandardMaterial>({ 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::<StandardMaterial>({ 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::<StandardMaterial>({ 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::<StandardMaterial>({ 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::<StandardMaterial>({ 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::<StandardMaterial>({ 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 }
]
}
}
+191
View File
@@ -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 }
}
}
+334
View File
@@ -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<Mesh>,
materials: &mut Assets<StandardMaterial>,
) -> 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::<StandardMaterial>({ 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::<StandardMaterial>({ 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::<StandardMaterial>({ 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::<StandardMaterial>({ 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::<StandardMaterial>({ 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("<path>")` 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<StandardMaterial>) -> impl Scene {
bsn! {
Name("Crate")
Mesh3d("models/crate.gltf#Mesh0/Primitive0")
MeshMaterial3d::<StandardMaterial>({ 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),
}
}
}
+98
View File
@@ -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<Mesh>,
materials: &mut Assets<StandardMaterial>,
) {
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,
));
}
+21
View File
@@ -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
+424
View File
@@ -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<u8> {
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<u8> {
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<u8> {
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<Value> = 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<u8> {
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<u8> {
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<String, usize> = BTreeMap::new();
let mut asset_references: Vec<AssetReference> = 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<Value> = 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<u8> {
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<Option<Project>> {
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<Project> {
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<SceneDoc> {
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<SceneDoc> {
// `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")
}
+131
View File
@@ -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<String>,
/// 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<PathBuf>,
/// 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<PathBuf>,
/// 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<TemplateArg> 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<FormatArg> 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)
}
}
}
+319
View File
@@ -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<CreatedProject> {
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<Mesh>");
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<Assets<Mesh>>,
mut materials: ResMut<Assets<StandardMaterial>>,
) {
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<String> {
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}");
}
}
+644
View File
@@ -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<String> = stdout(&output).lines().map(ToOwned::to_owned).collect();
let expected: Vec<String> = 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"));
}
+18
View File
@@ -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
+470
View File
@@ -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<AssetEntry>,
pub scanned_dir: PathBuf,
/// Set when the assets directory could not be read.
pub error: Option<String>,
}
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<String>,
depth: usize,
entries: &mut Vec<AssetEntry>,
) {
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::<usize>(),
);
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());
}
}
+80
View File
@@ -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<String, CodegenError> {
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<String>) -> 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(())
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+553
View File
@@ -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<ComponentKind> {
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<String>,
pub color: Color,
/// `None` uses the texture's native size.
pub custom_size: Option<Vec2>,
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<CustomField>,
}
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());
}
}
+128
View File
@@ -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<PathBuf>,
pub last_project: Option<PathBuf>,
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<PathBuf> {
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<PathBuf> {
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<Self, ConfigError> {
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());
}
}
+90
View File
@@ -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<PathBuf>, 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<EntityId>,
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),
}
+307
View File
@@ -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<Snapshot>,
pub redo: Vec<Snapshot>,
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<String>, 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<String> {
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<String> {
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<R>(&mut self, label: impl Into<String>, 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<String>) {
if self.doc != before {
self.history.record(label, before);
self.refresh_dirty();
}
}
pub fn undo(&mut self) -> Option<String> {
let label = self.history.undo(&mut self.doc)?;
self.refresh_dirty();
Some(label)
}
pub fn redo(&mut self) -> Option<String> {
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");
}
}
+44
View File
@@ -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};
+593
View File
@@ -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<f32> for Vec2 {
type Output = Self;
fn mul(self, rhs: f32) -> Self {
Self::new(self.x * rhs, self.y * rhs)
}
}
impl Div<f32> 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<f32> for Vec3 {
type Output = Self;
fn mul(self, rhs: f32) -> Self {
Self::new(self.x * rhs, self.y * rhs, self.z * rhs)
}
}
impl Mul<Vec3> 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<f32> 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<Self> {
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);
}
}
+385
View File
@@ -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<String>,
}
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 `<root>/assets` when it exists, otherwise `<root>`.
pub fn open(root: impl AsRef<Path>) -> Result<Self, ProjectError> {
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<Path>) -> Result<SceneDoc, ProjectError> {
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<PathBuf, ProjectError> {
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<String> {
let relative = path.strip_prefix(&self.assets_dir).ok()?;
let parts: Vec<String> = 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<PathBuf> {
let mut scenes: Vec<PathBuf> = 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<PathBuf> {
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<String> {
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"));
}
}
File diff suppressed because it is too large Load Diff
+570
View File
@@ -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<EntityId>,
pub children: Vec<EntityId>,
pub transform: Transform,
pub components: Vec<ComponentData>,
/// 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<EntityId, EntityNode>,
pub roots: Vec<EntityId>,
pub next_id: EntityId,
}
impl Default for SceneDoc {
fn default() -> Self {
Self::new("Untitled")
}
}
impl SceneDoc {
pub fn new(name: impl Into<String>) -> 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<String>, parent: Option<EntityId>) -> 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<EntityId>,
index: Option<usize>,
) -> 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<EntityId> {
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<EntityId> {
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<EntityId>,
index: Option<usize>,
) -> 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<EntityId> {
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>) -> &[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<Transform> {
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<EntityId> {
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<EntityId> {
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<ComponentData> {
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<String>) -> 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<String> {
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<EntityId>) {
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<EntityId>,
index: usize,
rename: bool,
visited: &mut HashSet<EntityId>,
) -> Option<EntityId> {
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)
}
}
File diff suppressed because it is too large Load Diff
+764
View File
@@ -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::<StandardMaterial>({ 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<Mesh>,\n materials: &mut Assets<StandardMaterial>,\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<Mesh>"),
"missing meshes param for {expected}"
);
assert!(code.contains("materials: &mut Assets<StandardMaterial>"));
}
}
#[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<StandardMaterial>) -> impl Scene {"));
assert!(!code.contains("meshes: &mut Assets<Mesh>"));
common::assert_line(
&code,
" MeshMaterial3d::<StandardMaterial>({ 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<Mesh>"));
assert!(!code.contains("Assets<StandardMaterial>"));
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<Mesh>"));
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<Mesh>,\n materials: &mut Assets<StandardMaterial>,\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<Mesh>,\n materials: &mut Assets<StandardMaterial>,"));
}
#[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<Mesh>,\n materials: &mut Assets<StandardMaterial>,\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"
);
}
}
}
}
+139
View File
@@ -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<Vec2>) -> 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<Project, ProjectError>) -> 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
}
+603
View File
@@ -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<EntityId> {
let mut ids = Vec::new();
fn walk(doc: &SceneDoc, id: EntityId, ids: &mut Vec<EntityId>) {
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<_>>(),
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<_>>(),
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:?}"
);
}
+582
View File
@@ -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::<Vec<_>>(),
doc.entities.keys().copied().collect::<Vec<_>>(),
"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<String> = 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::<Vec<_>>()
.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
);
}
+538
View File
@@ -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"
);
}
+21
View File
@@ -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
+40
View File
@@ -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,
}
+983
View File
@@ -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<Project>,
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<PathBuf>,
pub last_title: String,
pub allow_close: bool,
pub quit_now: bool,
last_autosave: Option<Instant>,
/// Navigation interrupted by the unsaved-changes prompt.
pending_navigation: Option<PendingNavigation>,
/// If set, capture a PNG after the UI settles and quit (CI/screenshot mode).
pub screenshot_path: Option<PathBuf>,
screenshot_frames: u32,
screenshot_requested: bool,
}
impl ForgeApp {
pub fn new(cc: &eframe::CreationContext<'_>, initial: Option<PathBuf>) -> 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<Action>) {
// 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<bevy_forge_core::EntityId> {
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");
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+24
View File
@@ -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};
+34
View File
@@ -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::<f32>().ok()?,
height.trim().parse::<f32>().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)))
}),
)
}
+501
View File
@@ -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<Action> {
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<Action>) {
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<Action>) {
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<Action>) {
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<Action>) {
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");
}
}
+791
View File
@@ -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<Action> {
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<Action> {
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<usize> {
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<i32> {
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<usize> = 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::<Vec<_>>()
);
}
#[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
));
}
}
+387
View File
@@ -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<Action> {
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<Action> {
// 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<Action>,
) {
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::Event>| 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"
);
}
}
+279
View File
@@ -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<u64>,
primary: Option<u64>,
}
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<Item = u64> + '_ {
self.ids.iter().copied()
}
pub fn as_set(&self) -> &BTreeSet<u64> {
&self.ids
}
pub fn primary(&self) -> Option<u64> {
self.primary
}
pub fn primary_or_single(&self) -> Option<u64> {
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<Item = u64>, primary: Option<u64>) {
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<AssetKind>,
pub cursor_world: Option<Vec2>,
pub pointer_over_viewport: bool,
pub viewport_rect: egui::Rect,
pub status: String,
pub error_banner: Option<String>,
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<String>,
pub error: Option<String>,
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<String>) {
self.toasts.info(message);
}
pub fn notify_success(&mut self, message: impl Into<String>) {
self.toasts.success(message);
}
pub fn notify_error(&mut self, message: impl Into<String>) {
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<String> {
self.assets
.by_kind(kind)
.into_iter()
.map(|asset| asset.path.clone())
.collect()
}
pub fn image_assets(&self) -> Vec<String> {
self.assets_of_kind(AssetKind::Image)
}
pub fn mesh_assets(&self) -> Vec<String> {
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<PathBuf> {
self.project.map(|project| project.scenes_dir())
}
}
+483
View File
@@ -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<String> {
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<Vec2>) -> 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<Vec2>) {
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");
}
}
@@ -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<PathBuf, CachedTexture>,
failed: HashMap<PathBuf, String>,
}
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());
}
}
+113
View File
@@ -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::WidgetText>) -> egui::Button<'static> {
egui::Button::new(text).frame(false)
}
/// Muted secondary text used for hints and counts.
pub fn hint(text: impl Into<String>) -> egui::RichText {
egui::RichText::new(text).color(MUTED).size(11.5)
}
pub fn accent_text(text: impl Into<String>) -> egui::RichText {
egui::RichText::new(text).color(ACCENT)
}
+370
View File
@@ -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<Toast>,
next_key: u64,
}
impl Toasts {
pub fn new() -> Self {
Self::default()
}
pub fn push(&mut self, level: ToastLevel, message: impl Into<String>) {
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<String>) {
self.push(ToastLevel::Info, message);
}
pub fn success(&mut self, message: impl Into<String>) {
self.push(ToastLevel::Success, message);
}
pub fn warning(&mut self, message: impl Into<String>) {
self.push(ToastLevel::Warning, message);
}
pub fn error(&mut self, message: impl Into<String>) {
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);
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+540
View File
@@ -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<String>,
/// 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<Action> {
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<Action>,
) {
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 <project>/scenes and assets from <project>/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<Action>) {
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());
}
}
+237
View File
@@ -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<String>) {
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<String>,
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);
});
}
+774
View File
@@ -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<egui::Event>) -> 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::<String>::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::<String>::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(),
}
}
+376
View File
@@ -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:<type_path>"`. `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 `<root>/assets` when it exists, otherwise `<root>` (so projects without a dedicated
assets folder still work).
Scenes live in `<root>/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
`.<name>.tmp-<pid>` 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:
`<config>/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<String, CodegenError>`, 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<Action>` 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<Action>
inspector::show(ctx: &egui::Context, cx: &mut EditorContext<'_>, state: &mut InspectorPanelState) -> Vec<Action>
assets_panel::show(ctx: &egui::Context, cx: &mut EditorContext<'_>, state: &mut AssetsPanelState) -> Vec<Action>
viewport2d::show(ui: &mut egui::Ui, cx: &mut EditorContext<'_>, state: &mut Viewport2dState) -> Vec<Action>
viewport3d::show(ui: &mut egui::Ui, cx: &mut EditorContext<'_>, state: &mut Viewport3dState) -> Vec<Action>
menu::show(ctx: &egui::Context, cx: &EditorContext<'_>) -> Vec<Action>
palette::show(ctx: &egui::Context, cx: &mut EditorContext<'_>, state: &mut PaletteState) -> Vec<Action>
dialogs::show(ctx: &egui::Context, cx: &mut EditorContext<'_>, state: &mut DialogsState) -> Vec<Action>
welcome::show(ctx: &egui::Context, state: &mut WelcomeState, config: &EditorConfig) -> Vec<Action>
shortcuts::handle(ctx: &egui::Context, session: &Session) -> Vec<Action>
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<Action>` 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.
+486
View File
@@ -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/<function>.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 <name>` 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("<escaped display 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<Mesh>,
materials: &mut Assets<StandardMaterial>,
) -> 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::<StandardMaterial>({ 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 `::<StandardMaterial>`; 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::<StandardMaterial>({ 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<Image>` or `Entity` | `#[derive(Component, FromTemplate)]` |
| `Option<Handle<T>>` 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<Image>` and only `Default + Clone` fails to compile because
`Handle<Image>` 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<Image>,
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<Assets<Mesh>>,
mut materials: ResMut<Assets<StandardMaterial>>,
) {
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<Vec<Entity>, _>`.
- `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::<StandardMaterial>("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::<StandardMaterial>(...)` |
| `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<AssetServer>) {
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.
+569
View File
@@ -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 `<project>/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<u64>` | `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:<type_path>` | 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<String>` | Path relative to `assets/`; omit `Some` for a solid-colour sprite |
| `color` | `Color` | Tint; defaults to white |
| `custom_size` | `Option<Vec2>` | 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("<path>")` 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 `.<filename>.tmp-<pid>` 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
`<project>/.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).
Binary file not shown.

After

Width:  |  Height:  |  Size: 791 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 476 KiB

+12
View File
@@ -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]
+57
View File
@@ -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.
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 345 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 336 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 217 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 496 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 583 B

@@ -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,
)
@@ -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,
)
+57
View File
@@ -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<Mesh>` and
//! `&mut Assets<StandardMaterial>` 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,
}
}
}
}
@@ -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()
+113
View File
@@ -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"