Bevy Forge 0.1.0: visual scene editor for Bevy 0.20 BSN
@@ -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
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
|
||||

|
||||
|
||||
> **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 |
|
||||
|---|---|
|
||||
|  |  |
|
||||
|
||||
## 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.
|
||||
@@ -0,0 +1,2 @@
|
||||
# Local Bevy build cache (several GB on the first check).
|
||||
/target/
|
||||
@@ -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",
|
||||
] }
|
||||
@@ -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.
|
||||
@@ -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 }
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
));
|
||||
}
|
||||
@@ -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
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
@@ -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
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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};
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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:?}"
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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};
|
||||
@@ -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)))
|
||||
}),
|
||||
)
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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).
|
||||
|
After Width: | Height: | Size: 791 KiB |
|
After Width: | Height: | Size: 476 KiB |
@@ -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]
|
||||
@@ -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.
|
||||
|
After Width: | Height: | Size: 4.4 KiB |
|
After Width: | Height: | Size: 345 B |
|
After Width: | Height: | Size: 336 B |
|
After Width: | Height: | Size: 217 B |
|
After Width: | Height: | Size: 496 B |
|
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,
|
||||
)
|
||||
@@ -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()
|
||||
@@ -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"
|
||||