Bevy Forge 0.1.0: visual scene editor for Bevy 0.20 BSN
This commit is contained in:
@@ -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.
|
||||
Reference in New Issue
Block a user