Compare commits

..

No commits in common. "0553a85220a25bdb31b8b6737ba698b3c2a9d6c5" and "b5e561904c5f2c0f3c010d1c083736ba352b3862" have entirely different histories.

165 changed files with 1700 additions and 34983 deletions

View File

@ -7,4 +7,3 @@ rustflags = ["-C", "link-arg=-fuse-ld=mold"]
[alias] [alias]
clean-target = "run -p xtask --bin clean-target --" clean-target = "run -p xtask --bin clean-target --"
validate-levels = "run -p xtask --features validate-levels --bin validate-levels --" validate-levels = "run -p xtask --features validate-levels --bin validate-levels --"
package-project = "run -p xtask --features validate-levels --bin package-project --"

View File

@ -1,105 +0,0 @@
# Skeletal Animation Import, Preview, And Authoring
Working implementation plan for Gitea issue
[`#46`](https://git.spacetrainclubhouse.com/Falling-Metal-Interactive/Blacksite/issues/46).
This is the minimum production animation loop required by the M7 content-production milestone.
## Status
Implementation and production acceptance are complete. The persistent/runtime contract, generated
manifest, stable model/clip identity, runtime adapter, browser drag workflow, typed inspector, and
headless validation are implemented. Live acceptance covers import, placement, preview, scrub,
save/reload hydration, PIE playback, source relocation, development packaging, and standalone
runtime playback with the committed RobotExpressive fixture.
## Outcome
An author can import a supported skinned model, inspect its skeleton and clips, place an animated
actor, preview and scrub a clip without dirtying the scene, save a small named state set, and drive
the same state changes in PIE and a packaged game. Missing content and skeleton mismatches fail with
owner-attributed repair guidance before packaging.
## Format Policy
- Animation authoring v1 supports glTF 2.0 (`.gltf` and `.glb`) through Bevy's native scene,
skinning, and animation assets.
- FBX remains supported for the existing static mesh import path. An FBX containing animation stacks
or skin deformers is not a supported animated source and must produce a blocking, actionable
diagnostic.
- Animated FBX support cannot be enabled until the loader constructs the source hierarchy, Bevy
skinned-mesh bindings, and `AnimationClip` assets. Metadata counts alone do not qualify as support.
## Architecture
- The asset registry UUID remains the parent model identity. A generated animation manifest under
`assets/animations/generated/` records the source fingerprint, skeleton signatures, stable clip
sub-asset IDs, clip metadata, and animation events.
- `shared::AnimationControllerDesc` is reflected and serializable. It references imported skeletons
and clips through stable `EditorAssetRef` values and owns one layer of named states, a default
state, playback range/loop/speed, and default crossfade duration.
- `game` owns the runtime adapter. It waits for the model `WorldAssetRoot` instance to become ready,
attaches transient animation graph/player state, and exposes a request API for changing an actor's
named state.
- `editor` owns import presentation, clip/skeleton shelves, drag assignment/placement, inspector
controls, and non-dirty preview/scrubbing. Preview uses the runtime adapter rather than a separate
interpretation of the authored contract.
- `scene` and package validation resolve the same manifests and stable references used by editor and
runtime hydration.
## Authoritative Data
The generated manifest is keyed by the model's stable registry UUID and includes:
- schema version and source path/fingerprint;
- scenes and exact skeleton signatures;
- stable skeleton and clip sub-asset IDs;
- clip label, source index, duration, target skeleton signature, and event markers;
- import diagnostics, including unsupported animated FBX content.
An exact skeleton signature is a deterministic hash of normalized ordered joint target paths and
bind-pose structure. Retargeting v1 accepts only an exact signature match. No implicit joint-name,
humanoid, or proportional remapping is allowed.
## Implementation Sequence
1. Add the versioned animation manifest schema, stable sub-asset identity rules, glTF extraction,
FBX blocking diagnostics, and focused importer tests.
2. Add `AnimationControllerDesc`, named state/event types, registration, migration defaults,
actor validation, history snapshots, scene round-trip coverage, and runtime-only strip policy.
3. Replace broad imported-model child cleanup with a dedicated generated instance root, then hydrate
the ready scene instance into a transient graph/player and implement the game state-request API.
4. Extend Asset Browser model shelves with skeleton and clip rows, compatibility status, preview,
scrub/range/loop controls, and clip drag placement or assignment.
5. Add the typed Animation Controller inspector, undoable state edits, PIE pause/stop reconciliation,
owner-attributed project validation, and package dependency collection.
6. Commit a small redistributable animated GLB and sample scene, then run import, round-trip,
headless, live editor/PIE, and packaged-runtime acceptance.
## Acceptance Gates
- A committed skinned GLB and its clips import, preview, save, hydrate, and play in PIE and a
packaged runtime.
- Moving or renaming the source inside the project preserves controller clip references through the
registry UUID and stable sub-asset IDs.
- Missing manifests/clips and mismatched skeleton signatures produce actor/property-attributed
diagnostics with a concrete repair action.
- Gameplay can request a valid named state through the public game API; invalid requests leave the
current state intact and report a diagnostic.
- Preview follows the authored loop/range/speed settings, stops cleanly, and never mutates the scene
descriptor, history, or dirty state.
- Generated graph/player/instance components never serialize into authored scenes, and model
rehydration never deletes authored hierarchy children.
- Animated or skinned FBX content blocks animation import with a diagnostic that explains the glTF
v1 requirement.
- Focused importer, validation, hydration, request-API, and editor operator tests pass alongside
workspace tests, `cargo validate-levels`, package validation, and live smoke testing.
## Deliberate Boundaries
- V1 provides one animation layer with named states, one default state, and a default crossfade. It
does not include a visual state graph, blend trees, transition-condition authoring, inverse
kinematics, motion matching, or root-motion extraction.
- Event markers are normalized data and runtime notifications; V1 has no timeline scripting system.
- Retargeting is exact signature match only.
- FBX static mesh import remains available, but animated FBX conversion is separate loader work and
cannot silently fall back to bind-pose or metadata-only playback.

View File

@ -1,72 +0,0 @@
# Audio Authoring And Runtime Parity
Working implementation plan for Gitea issue
[`#47`](https://git.spacetrainclubhouse.com/Falling-Metal-Interactive/Blacksite/issues/47).
This is the minimum complete audio loop required by the M7 content-production milestone.
## Status
Completed and production-accepted in Gitea #47. Permanent behavior is documented in
[ADR 0030](../../docs/adr/0030-audio-authoring-and-bus-schema.md) and
[Audio Authoring](../../docs/editor/audio-authoring.md).
## Outcome
An author can import a supported audio clip, place or assign it as an authored source, tune playback
and spatial behavior, audition it without dirtying the scene, save/reload it, and hear equivalent
behavior in PIE and a packaged game. Projects expose stable Master, Music, and SFX buses with runtime
gain/mute hooks and validation that also runs headlessly.
## Architecture
- `shared` owns reflected, serializable source/listener/attenuation descriptors and stable actor kinds.
- `settings` owns versioned project bus definitions and validation; bus references use stable IDs.
- `game` hydrates authoring descriptors into Bevy audio playback/listener components and applies bus
controls. Hydrated components remain derived and are never scene source of truth.
- `editor` owns asset discovery/import UX, inspector controls, drag/drop placement, visualizers, and
transient audition state. Audition never mutates an authored descriptor or marks a scene dirty.
- Scene and project validation share the same clip-reference, bus-reference, range, and listener rules
used by packaging. Missing, excluded, unsupported, or Git LFS pointer clips block release validation.
## Supported Content
- Ogg Vorbis is the baseline runtime format.
- WAV, MP3, and FLAC support is enabled explicitly in the workspace Bevy feature set.
- Audio sources support gain, pitch, looping, autoplay, spatial blend, attenuation, and bus routing.
- Listener authoring supports enabled state, priority, and ear gap; at runtime exactly one enabled
listener is selected deterministically.
- Default project buses are `master`, `music`, and `sfx`; Music and SFX route to Master.
## Implementation Sequence
1. Add shared descriptors, settings schema/defaults/validation, launcher scaffolding, and migrations.
2. Add audio clip catalog classification, import target, search/filter/details, and drag payloads.
3. Add runtime source/listener hydration, deterministic listener selection, and bus controls.
4. Add inspector cards with undoable edits and scene save/validation allowlists.
5. Add viewport icons/range visualizers and transient audition/stop using the editor camera listener.
6. Add a small sample clip/scene, focused round-trip and PIE tests, live QA, permanent docs, and Gitea
acceptance evidence.
## Acceptance Gates
- Dragging a clip into the viewport creates an audio source; dropping onto a compatible selected
source assigns the clip through one undoable command.
- Inspector edits survive save/reload and PIE hydration without generated playback state leaking into
the authored scene.
- Spatial preview follows the editor camera, stops on explicit Stop, mode/project changes, or source
deletion, and never changes scene dirty state.
- Bus definitions persist with stable IDs, invalid parent cycles/references are rejected, and runtime
gain/mute hooks affect routed sources.
- Headless validation reports missing/unpackageable clips, unsupported values, invalid buses, and
ambiguous enabled listeners with actor/property context.
- Focused unit/integration tests, `cargo validate-levels`, package validation, and live editor/game
smoke tests passed for issue `#47` closure.
## Deliberate Boundaries
- This slice does not include DSP graphs, reverb zones, waveform editing, audio streaming banks,
middleware integration, or platform-specific compression pipelines.
- Continuous spatial blend is represented authoritatively and implemented without claiming Bevy
supports a native blend control; the runtime adapter owns the mapping/mix behavior.
- A future mixer UI may expose additional buses and sends, but it must preserve the stable bus IDs and
parent graph established here.

View File

@ -1,101 +0,0 @@
# Blacksite Editor Production Readiness Program
Working milestone plan for the production expansion tracked by Gitea epic
[`#1`](https://git.spacetrainclubhouse.com/Falling-Metal-Interactive/Blacksite/issues/1).
The existing Jackdaw-inspired M0-M5 roadmap remains the authoring/tool foundation. This plan adds
the reliability and shipping gates required for daily indie game production.
## Definition Of Production Ready
Blacksite is production ready only when the evidence matrix in Gitea
[`#50`](https://git.spacetrainclubhouse.com/Falling-Metal-Interactive/Blacksite/issues/50)
is signed off. Feature presence alone is insufficient. Recovery, validation, packaging,
performance, soak, regression, and first-hour workflows must pass against a representative project.
## Immediate Sequence
1. Completed: hierarchy/prefab boundary hardening `#40`, transactional recovery `#39`, stable
sessions/diagnostics `#41`, and Project Browser/startup boundaries `#3`.
2. Completed: stable scene tabs and composed subscene ownership/validation in `#42`.
3. Completed: nested prefabs, variants, and structural overrides passed production acceptance in
`#43`; M6 implementation is complete.
4. Completed: authoritative content validation `#45` and the validation-gated packaging/Build
workflow `#44`.
5. Completed: audio source/listener/bus authoring and spatial preview passed production acceptance
in `#47`. Next: animation `#46`, navigation `#48`, source-control safety `#49`, then readiness
gate `#50`.
## M6 - Reliability, Recovery, And Project Workflow
**Exit:** A normal or interrupted editor session cannot silently lose or corrupt authored work, and
projects/scenes/prefabs have stable daily workflows.
| Issue | Capability | Priority |
|-------|------------|----------|
| #3 | Project launcher, templates, recent projects, resume | P0 |
| #39 | Transactional saves, autosave, crash recovery | P0 |
| #40 | Hierarchy, parenting, prefab structure hardening | P0 |
| #41 | Session persistence and crash diagnostics | P0 |
| #42 | Multi-scene tabs, subscenes, level composition | P1 |
| #43 | Nested prefabs, variants, structural overrides | P1 |
## M7 - Content Production And Shipping
**Exit:** A clean checkout can validate, build, package, and run a representative project with the
minimum animation, audio, and navigation authoring loops required by the game.
| Issue | Capability | Priority |
|-------|------------|----------|
| #44 | Build/package profiles and Build panel | P0 |
| #45 | Dependency manifest and content release gate | P0 |
| #46 | Skeletal animation import/preview/authoring | P1 |
| #47 | Audio sources/listeners/buses/spatial preview | P1 |
| #48 | Navigation authoring/bake/path preview | P1 |
| #49 | Source-control status and collaborative file safety | P2 |
| #50 | Production-readiness acceptance matrix | P0 |
## Cross-Cutting Gates
- Authored IDs, scene schema, hydration stripping, and extension boundaries remain authoritative.
- Every mutation supports meaningful undo/redo; modal tools also support cancel without dirty state.
- Editor-only helpers, recovery files, and caches never enter scenes or game packages.
- Interactive and headless validation use the same rules and produce equivalent blocking results.
- Large asset, terrain, navigation, and geometry work is debounced or dirty-region based.
- Visible behavior updates root controls and `docs/editor/`; hard-to-reverse decisions get ADRs.
- Production signoff requires a second-person first-hour QA pass and no open P0 blocker.
## Completed Slice - UI Identity And Structural Dragging
- Near-black Blacksite shell with compact tool identity rather than a Unity clone.
- Pointer-following asset drag preview, viewport reticle, boundary, and corner brackets.
- Full-width hierarchy attach targets plus explicit root/reorder/invalid feedback.
- World-transform-preserving reparent/unparent with grouped history.
- Cycle rejection, subtree-safe multi-drag, parent expansion, and focused regression coverage.
## Completed Slice - Multi-Scene Production Workflow
- Independent scene tabs integrated into the right end of the existing main toolbar.
- Stable `SceneComposition` resources with project-relative references and no runtime entity IDs.
- Recursive missing/cycle/duplicate/path-escape validation shared by editor and CI.
- Runtime-only composed ownership, load/lock/focus controls, and owner-save exclusion.
- Per-saved-tab recovery, composition undo/redo, PIE-active composition consistency, and fixtures.
## Completed Slice - Validation-Gated Build And Packaging
- Shared owner-attributed project validation and runtime-packageability policy in editor, CI, and
packaging; unknown schemas, missing LFS objects, excluded references, and unsafe traversal block.
- Versioned development/QA/release profiles with Cargo-reported artifacts, rendering/default-scene
application, Windows QA symbols, supported-target enforcement, and deterministic metadata.
- OS-locked authenticated staging, interrupted-build recovery, complete pre-publish hashing, safe
replacement, and unchanged-output reuse.
- Non-blocking editor Build/Run/Cancel/Reveal workflow with dirty-tab guard and process-tree cleanup.
- Gitea #44 and #45 closed after headless, focused regression, repeated-build, and live packaged
development-runtime acceptance.
## Completed Slice - Audio Authoring And Spatial Preview
The stable authoring/runtime contract is recorded in
[ADR 0030](../../docs/adr/0030-audio-authoring-and-bus-schema.md), with controls, runtime behavior,
validation, and the committed sample fixture documented in
[Audio Authoring](../../docs/editor/audio-authoring.md). Gitea #47 closed after automated,
headless, live editor/PIE, and packaged-runtime acceptance.

View File

@ -41,22 +41,34 @@ jobs:
run: rustup show run: rustup show
- name: Check formatting - name: Check formatting
run: cargo fmt --all --check run: cargo fmt --check
- name: Check workspace - name: Check workspace
run: cargo check --workspace --all-targets run: cargo check --workspace
- name: Clippy workspace - name: Clippy
run: cargo clippy --workspace --all-targets -- -D warnings run: cargo clippy --workspace
- name: Test workspace - name: Clippy foundation crates
run: cargo test --workspace run: cargo clippy -p game -p shared -- -D warnings
- name: Test foundation helpers
run: cargo test -p game --lib
- name: Test shared hydration and actor validation
run: cargo test -p shared
- name: Test sim crate
run: cargo test -p sim
- name: Validate level scenes - name: Validate level scenes
run: cargo validate-levels run: cargo validate-levels
- name: Clippy launch feature matrix - name: Test scene schema crate
run: cargo clippy -p editor -p game --bins --features dev,hot-reload -- -D warnings run: cargo test -p scene
- name: Check editor (dev)
run: cargo check -p editor --features dev
- name: Build binaries - name: Build binaries
run: | run: |

1
.gitignore vendored
View File

@ -1,6 +1,5 @@
# Rust build output # Rust build output
/target/ /target/
/dist/
# Local automation and assistant state # Local automation and assistant state
/.agents/ /.agents/

241
.vscode/launch.json vendored
View File

@ -4,251 +4,48 @@
{ {
"type": "lldb", "type": "lldb",
"request": "launch", "request": "launch",
"name": "Editor: Debug", "name": "Debug editor",
"cargo": { "preLaunchTask": "build editor (dev fast-link)",
"args": ["build", "-p", "editor", "--bin", "editor", "--features", "dev"], "program": "${workspaceFolder}/target/debug/editor",
"filter": { "name": "editor", "kind": "bin" },
"env": { "CARGO_TARGET_DIR": "${workspaceFolder}/target" },
"problemMatcher": "$rustc"
},
"args": [], "args": [],
"env": { "env": {
"RUST_BACKTRACE": "1",
"WGPU_VALIDATION": "0",
"LD_LIBRARY_PATH": "${workspaceFolder}/target/debug/deps:${env:LD_LIBRARY_PATH}"
},
"cwd": "${workspaceFolder}",
"terminal": "integrated",
"sourceLanguages": ["rust"],
"gracefulShutdown": "SIGINT"
},
{
"type": "lldb",
"request": "launch",
"name": "Editor: GPU validation",
"cargo": {
"args": ["build", "-p", "editor", "--bin", "editor", "--features", "dev"],
"filter": { "name": "editor", "kind": "bin" },
"env": { "CARGO_TARGET_DIR": "${workspaceFolder}/target" },
"problemMatcher": "$rustc"
},
"args": [],
"env": {
"RUST_BACKTRACE": "1",
"WGPU_VALIDATION": "1",
"LD_LIBRARY_PATH": "${workspaceFolder}/target/debug/deps:${env:LD_LIBRARY_PATH}"
},
"cwd": "${workspaceFolder}",
"terminal": "integrated",
"sourceLanguages": ["rust"],
"gracefulShutdown": "SIGINT"
},
{
"type": "lldb",
"request": "launch",
"name": "Editor: Hot reload",
"cargo": {
"args": [
"build",
"-p",
"editor",
"--bin",
"editor",
"--features",
"dev,hot-reload"
],
"filter": { "name": "editor", "kind": "bin" },
"env": { "CARGO_TARGET_DIR": "${workspaceFolder}/target" },
"problemMatcher": "$rustc"
},
"args": [],
"env": {
"RUST_BACKTRACE": "1",
"WGPU_VALIDATION": "0",
"LD_LIBRARY_PATH": "${workspaceFolder}/target/debug/deps:${env:LD_LIBRARY_PATH}"
},
"cwd": "${workspaceFolder}",
"terminal": "integrated",
"sourceLanguages": ["rust"],
"gracefulShutdown": "SIGINT"
},
{
"type": "lldb",
"request": "launch",
"name": "Editor: SDR fallback",
"cargo": {
"args": ["build", "-p", "editor", "--bin", "editor", "--features", "dev"],
"filter": { "name": "editor", "kind": "bin" },
"env": { "CARGO_TARGET_DIR": "${workspaceFolder}/target" },
"problemMatcher": "$rustc"
},
"args": [],
"env": {
"RUST_BACKTRACE": "1",
"BEVY_FPS_HDR": "0", "BEVY_FPS_HDR": "0",
"WGPU_VALIDATION": "0", "WGPU_VALIDATION": "0",
"CARGO_TARGET_DIR": "${workspaceFolder}/target",
"LD_LIBRARY_PATH": "${workspaceFolder}/target/debug/deps:${env:LD_LIBRARY_PATH}" "LD_LIBRARY_PATH": "${workspaceFolder}/target/debug/deps:${env:LD_LIBRARY_PATH}"
}, },
"cwd": "${workspaceFolder}", "cwd": "${workspaceFolder}"
"terminal": "integrated",
"sourceLanguages": ["rust"],
"gracefulShutdown": "SIGINT"
}, },
{ {
"type": "lldb", "type": "lldb",
"request": "launch", "request": "launch",
"name": "Editor: Release", "name": "Debug game",
"cargo": { "preLaunchTask": "build game (dev fast-link)",
"args": ["build", "--release", "-p", "editor", "--bin", "editor"], "program": "${workspaceFolder}/target/debug/game",
"filter": { "name": "editor", "kind": "bin" },
"env": { "CARGO_TARGET_DIR": "${workspaceFolder}/target" },
"problemMatcher": "$rustc"
},
"args": [],
"env": { "RUST_BACKTRACE": "1", "WGPU_VALIDATION": "0" },
"cwd": "${workspaceFolder}",
"terminal": "integrated",
"sourceLanguages": ["rust"],
"gracefulShutdown": "SIGINT"
},
{
"type": "lldb",
"request": "launch",
"name": "Editor: Project root...",
"cargo": {
"args": ["build", "-p", "editor", "--bin", "editor", "--features", "dev"],
"filter": { "name": "editor", "kind": "bin" },
"env": { "CARGO_TARGET_DIR": "${workspaceFolder}/target" },
"problemMatcher": "$rustc"
},
"args": ["--project", "${input:blacksiteProjectRoot}"],
"env": {
"RUST_BACKTRACE": "1",
"WGPU_VALIDATION": "0",
"LD_LIBRARY_PATH": "${workspaceFolder}/target/debug/deps:${env:LD_LIBRARY_PATH}"
},
"cwd": "${workspaceFolder}",
"terminal": "integrated",
"sourceLanguages": ["rust"],
"gracefulShutdown": "SIGINT"
},
{
"type": "lldb",
"request": "launch",
"name": "Game: Debug",
"cargo": {
"args": ["build", "-p", "game", "--bin", "game", "--features", "dev"],
"filter": { "name": "game", "kind": "bin" },
"env": { "CARGO_TARGET_DIR": "${workspaceFolder}/target" },
"problemMatcher": "$rustc"
},
"args": [], "args": [],
"env": { "env": {
"RUST_BACKTRACE": "1",
"WGPU_VALIDATION": "0",
"LD_LIBRARY_PATH": "${workspaceFolder}/target/debug/deps:${env:LD_LIBRARY_PATH}"
},
"cwd": "${workspaceFolder}",
"terminal": "integrated",
"sourceLanguages": ["rust"],
"gracefulShutdown": "SIGINT"
},
{
"type": "lldb",
"request": "launch",
"name": "Game: GPU validation",
"cargo": {
"args": ["build", "-p", "game", "--bin", "game", "--features", "dev"],
"filter": { "name": "game", "kind": "bin" },
"env": { "CARGO_TARGET_DIR": "${workspaceFolder}/target" },
"problemMatcher": "$rustc"
},
"args": [],
"env": {
"RUST_BACKTRACE": "1",
"WGPU_VALIDATION": "1",
"LD_LIBRARY_PATH": "${workspaceFolder}/target/debug/deps:${env:LD_LIBRARY_PATH}"
},
"cwd": "${workspaceFolder}",
"terminal": "integrated",
"sourceLanguages": ["rust"],
"gracefulShutdown": "SIGINT"
},
{
"type": "lldb",
"request": "launch",
"name": "Game: Hot reload",
"cargo": {
"args": [
"build",
"-p",
"game",
"--bin",
"game",
"--features",
"dev,hot-reload"
],
"filter": { "name": "game", "kind": "bin" },
"env": { "CARGO_TARGET_DIR": "${workspaceFolder}/target" },
"problemMatcher": "$rustc"
},
"args": [],
"env": {
"RUST_BACKTRACE": "1",
"WGPU_VALIDATION": "0",
"LD_LIBRARY_PATH": "${workspaceFolder}/target/debug/deps:${env:LD_LIBRARY_PATH}"
},
"cwd": "${workspaceFolder}",
"terminal": "integrated",
"sourceLanguages": ["rust"],
"gracefulShutdown": "SIGINT"
},
{
"type": "lldb",
"request": "launch",
"name": "Game: SDR fallback",
"cargo": {
"args": ["build", "-p", "game", "--bin", "game", "--features", "dev"],
"filter": { "name": "game", "kind": "bin" },
"env": { "CARGO_TARGET_DIR": "${workspaceFolder}/target" },
"problemMatcher": "$rustc"
},
"args": [],
"env": {
"RUST_BACKTRACE": "1",
"BEVY_FPS_HDR": "0", "BEVY_FPS_HDR": "0",
"WGPU_VALIDATION": "0", "WGPU_VALIDATION": "0",
"CARGO_TARGET_DIR": "${workspaceFolder}/target",
"LD_LIBRARY_PATH": "${workspaceFolder}/target/debug/deps:${env:LD_LIBRARY_PATH}" "LD_LIBRARY_PATH": "${workspaceFolder}/target/debug/deps:${env:LD_LIBRARY_PATH}"
}, },
"cwd": "${workspaceFolder}", "cwd": "${workspaceFolder}"
"terminal": "integrated",
"sourceLanguages": ["rust"],
"gracefulShutdown": "SIGINT"
}, },
{ {
"type": "lldb", "type": "lldb",
"request": "launch", "request": "launch",
"name": "Game: Release", "name": "Run editor (release)",
"cargo": { "cargo": {
"args": ["build", "--release", "-p", "game", "--bin", "game"], "args": ["build", "--release", "-p", "editor"],
"filter": { "name": "game", "kind": "bin" }, "filter": { "name": "editor", "kind": "bin" }
"env": { "CARGO_TARGET_DIR": "${workspaceFolder}/target" },
"problemMatcher": "$rustc"
}, },
"args": [], "args": [],
"env": { "RUST_BACKTRACE": "1", "WGPU_VALIDATION": "0" }, "env": {
"cwd": "${workspaceFolder}", "BEVY_FPS_HDR": "0",
"terminal": "integrated", "WGPU_VALIDATION": "0",
"sourceLanguages": ["rust"], "LD_LIBRARY_PATH": "${workspaceFolder}/target/release/deps:${env:LD_LIBRARY_PATH}"
"gracefulShutdown": "SIGINT" },
} "cwd": "${workspaceFolder}"
],
"inputs": [
{
"id": "blacksiteProjectRoot",
"type": "promptString",
"description": "Absolute path to a Blacksite project root",
"default": "${workspaceFolder}"
} }
] ]
} }

219
.vscode/tasks.json vendored
View File

@ -2,61 +2,42 @@
"version": "2.0.0", "version": "2.0.0",
"tasks": [ "tasks": [
{ {
"label": "cargo fmt (workspace check)", "label": "cargo fmt (check)",
"type": "cargo", "type": "cargo",
"command": "fmt", "command": "fmt",
"args": ["--all", "--check"], "args": ["--check"],
"problemMatcher": [], "problemMatcher": [],
"group": "build" "group": "build"
}, },
{ {
"label": "cargo check (workspace all targets)", "label": "cargo check (workspace)",
"type": "cargo", "type": "cargo",
"command": "check", "command": "check",
"args": ["--workspace", "--all-targets"],
"problemMatcher": ["$rustc"],
"group": "build"
},
{
"label": "cargo clippy (workspace strict)",
"type": "cargo",
"command": "clippy",
"args": ["--workspace", "--all-targets", "--", "-D", "warnings"],
"problemMatcher": ["$rustc"],
"group": "build"
},
{
"label": "cargo test (workspace)",
"type": "cargo",
"command": "test",
"args": ["--workspace"], "args": ["--workspace"],
"problemMatcher": ["$rustc"], "problemMatcher": ["$rustc"],
"group": "test" "group": "build"
}, },
{ {
"label": "cargo clippy (launch feature matrix)", "label": "cargo clippy (workspace)",
"type": "cargo", "type": "cargo",
"command": "clippy", "command": "clippy",
"args": [ "args": ["--workspace"],
"-p",
"editor",
"-p",
"game",
"--bins",
"--features",
"dev,hot-reload",
"--",
"-D",
"warnings"
],
"problemMatcher": ["$rustc"], "problemMatcher": ["$rustc"],
"group": "build" "group": "build"
}, },
{ {
"label": "validate level scenes", "label": "cargo clippy (foundation strict)",
"type": "cargo", "type": "cargo",
"command": "validate-levels", "command": "clippy",
"args": [], "args": ["-p", "game", "-p", "shared", "--", "-D", "warnings"],
"problemMatcher": ["$rustc"],
"group": "build"
},
{
"label": "cargo test (foundation)",
"type": "cargo",
"command": "test",
"args": ["-p", "game", "--lib"],
"problemMatcher": ["$rustc"], "problemMatcher": ["$rustc"],
"group": "test" "group": "test"
}, },
@ -64,7 +45,7 @@
"label": "build game (debug)", "label": "build game (debug)",
"type": "cargo", "type": "cargo",
"command": "build", "command": "build",
"args": ["-p", "game", "--bin", "game"], "args": ["-p", "game"],
"problemMatcher": ["$rustc"], "problemMatcher": ["$rustc"],
"group": "build" "group": "build"
}, },
@ -72,50 +53,47 @@
"label": "build editor (debug)", "label": "build editor (debug)",
"type": "cargo", "type": "cargo",
"command": "build", "command": "build",
"args": ["-p", "editor", "--bin", "editor"], "args": ["-p", "editor"],
"problemMatcher": ["$rustc"], "problemMatcher": ["$rustc"],
"group": "build" "group": { "kind": "build", "isDefault": true }
}, },
{ {
"label": "build editor (dev fast-link)", "label": "build editor (dev fast-link)",
"type": "cargo", "type": "cargo",
"command": "build", "command": "build",
"args": ["-p", "editor", "--bin", "editor", "--features", "dev"], "args": ["-p", "editor", "--features", "dev"],
"options": { "options": {
"cwd": "${workspaceFolder}", "env": {
"env": { "CARGO_TARGET_DIR": "${workspaceFolder}/target" } "CARGO_TARGET_DIR": "${workspaceFolder}/target"
}, }
"problemMatcher": ["$rustc"],
"group": { "kind": "build", "isDefault": true }
},
{
"label": "build game (dev fast-link)",
"type": "cargo",
"command": "build",
"args": ["-p", "game", "--bin", "game", "--features", "dev"],
"options": {
"cwd": "${workspaceFolder}",
"env": { "CARGO_TARGET_DIR": "${workspaceFolder}/target" }
}, },
"problemMatcher": ["$rustc"], "problemMatcher": ["$rustc"],
"group": "build" "group": "build"
}, },
{ {
"label": "clean dev-link crates", "label": "build game (dev fast-link)",
"type": "cargo", "type": "cargo",
"command": "clean", "command": "build",
"args": ["-p", "editor", "-p", "game", "-p", "game_hot", "-p", "shared"], "args": ["-p", "game", "--features", "dev"],
"options": { "options": {
"cwd": "${workspaceFolder}", "env": {
"env": { "CARGO_TARGET_DIR": "${workspaceFolder}/target" } "CARGO_TARGET_DIR": "${workspaceFolder}/target"
}
}, },
"problemMatcher": [] "problemMatcher": ["$rustc"],
"group": "build"
}, },
{ {
"label": "clean build editor (dev)", "label": "clean build editor (dev)",
"dependsOrder": "sequence", "type": "shell",
"dependsOn": ["clean dev-link crates", "build editor (dev fast-link)"], "command": "cargo clean -p editor && cargo build -p editor --features dev",
"problemMatcher": [], "options": {
"cwd": "${workspaceFolder}",
"env": {
"CARGO_TARGET_DIR": "${workspaceFolder}/target"
}
},
"problemMatcher": ["$rustc"],
"group": "build" "group": "build"
}, },
{ {
@ -124,7 +102,9 @@
"command": "cargo clean-target", "command": "cargo clean-target",
"options": { "options": {
"cwd": "${workspaceFolder}", "cwd": "${workspaceFolder}",
"env": { "CARGO_TARGET_DIR": "${workspaceFolder}/target" } "env": {
"CARGO_TARGET_DIR": "${workspaceFolder}/target"
}
}, },
"problemMatcher": [], "problemMatcher": [],
"group": "build" "group": "build"
@ -135,7 +115,9 @@
"command": "cargo clean-target --apply", "command": "cargo clean-target --apply",
"options": { "options": {
"cwd": "${workspaceFolder}", "cwd": "${workspaceFolder}",
"env": { "CARGO_TARGET_DIR": "${workspaceFolder}/target" } "env": {
"CARGO_TARGET_DIR": "${workspaceFolder}/target"
}
}, },
"problemMatcher": [], "problemMatcher": [],
"group": "build" "group": "build"
@ -146,7 +128,9 @@
"command": "cargo clean-target --include-artifacts --days 3 --apply", "command": "cargo clean-target --include-artifacts --days 3 --apply",
"options": { "options": {
"cwd": "${workspaceFolder}", "cwd": "${workspaceFolder}",
"env": { "CARGO_TARGET_DIR": "${workspaceFolder}/target" } "env": {
"CARGO_TARGET_DIR": "${workspaceFolder}/target"
}
}, },
"problemMatcher": [], "problemMatcher": [],
"group": "build" "group": "build"
@ -155,11 +139,10 @@
"label": "run editor (dev fast-link)", "label": "run editor (dev fast-link)",
"type": "cargo", "type": "cargo",
"command": "run", "command": "run",
"args": ["-p", "editor", "--bin", "editor", "--features", "dev"], "args": ["-p", "editor", "--features", "dev"],
"options": { "options": {
"cwd": "${workspaceFolder}",
"env": { "env": {
"RUST_BACKTRACE": "1", "BEVY_FPS_HDR": "0",
"WGPU_VALIDATION": "0", "WGPU_VALIDATION": "0",
"CARGO_TARGET_DIR": "${workspaceFolder}/target" "CARGO_TARGET_DIR": "${workspaceFolder}/target"
} }
@ -171,83 +154,9 @@
"label": "run game (dev fast-link)", "label": "run game (dev fast-link)",
"type": "cargo", "type": "cargo",
"command": "run", "command": "run",
"args": ["-p", "game", "--bin", "game", "--features", "dev"], "args": ["-p", "game", "--features", "dev"],
"options": { "options": {
"cwd": "${workspaceFolder}",
"env": { "env": {
"RUST_BACKTRACE": "1",
"WGPU_VALIDATION": "0",
"CARGO_TARGET_DIR": "${workspaceFolder}/target"
}
},
"problemMatcher": ["$rustc"],
"group": "build"
},
{
"label": "run editor (hot reload)",
"type": "cargo",
"command": "run",
"args": [
"-p",
"editor",
"--bin",
"editor",
"--features",
"dev,hot-reload"
],
"options": {
"cwd": "${workspaceFolder}",
"env": {
"RUST_BACKTRACE": "1",
"WGPU_VALIDATION": "0",
"CARGO_TARGET_DIR": "${workspaceFolder}/target"
}
},
"problemMatcher": ["$rustc"],
"group": "build"
},
{
"label": "run game (hot reload)",
"type": "cargo",
"command": "run",
"args": ["-p", "game", "--bin", "game", "--features", "dev,hot-reload"],
"options": {
"cwd": "${workspaceFolder}",
"env": {
"RUST_BACKTRACE": "1",
"WGPU_VALIDATION": "0",
"CARGO_TARGET_DIR": "${workspaceFolder}/target"
}
},
"problemMatcher": ["$rustc"],
"group": "build"
},
{
"label": "run editor (SDR fallback)",
"type": "cargo",
"command": "run",
"args": ["-p", "editor", "--bin", "editor", "--features", "dev"],
"options": {
"cwd": "${workspaceFolder}",
"env": {
"RUST_BACKTRACE": "1",
"BEVY_FPS_HDR": "0",
"WGPU_VALIDATION": "0",
"CARGO_TARGET_DIR": "${workspaceFolder}/target"
}
},
"problemMatcher": ["$rustc"],
"group": "build"
},
{
"label": "run game (SDR fallback)",
"type": "cargo",
"command": "run",
"args": ["-p", "game", "--bin", "game", "--features", "dev"],
"options": {
"cwd": "${workspaceFolder}",
"env": {
"RUST_BACKTRACE": "1",
"BEVY_FPS_HDR": "0", "BEVY_FPS_HDR": "0",
"WGPU_VALIDATION": "0", "WGPU_VALIDATION": "0",
"CARGO_TARGET_DIR": "${workspaceFolder}/target" "CARGO_TARGET_DIR": "${workspaceFolder}/target"
@ -262,28 +171,28 @@
"command": "cargo watch -w crates/game_hot -w crates/sim -x \"build -p game_hot --features dylib\"", "command": "cargo watch -w crates/game_hot -w crates/sim -x \"build -p game_hot --features dylib\"",
"options": { "options": {
"cwd": "${workspaceFolder}", "cwd": "${workspaceFolder}",
"env": { "CARGO_TARGET_DIR": "${workspaceFolder}/target" } "env": {
"CARGO_TARGET_DIR": "${workspaceFolder}/target"
}
}, },
"problemMatcher": ["$rustc"], "problemMatcher": ["$rustc"],
"group": "build", "group": "build",
"isBackground": true, "isBackground": true
"detail": "Requires cargo-watch: cargo install cargo-watch --locked"
}, },
{ {
"label": "verify workspace", "label": "verify foundation",
"dependsOrder": "sequence", "dependsOrder": "sequence",
"dependsOn": [ "dependsOn": [
"cargo fmt (workspace check)", "cargo fmt (check)",
"cargo check (workspace all targets)", "cargo check (workspace)",
"cargo clippy (workspace strict)", "cargo clippy (workspace)",
"cargo test (workspace)", "cargo clippy (foundation strict)",
"cargo clippy (launch feature matrix)", "cargo test (foundation)",
"validate level scenes",
"build game (debug)", "build game (debug)",
"build editor (debug)" "build editor (debug)"
], ],
"problemMatcher": [], "problemMatcher": [],
"group": "build" "group": { "kind": "build", "isDefault": true }
} }
] ]
} }

226
Cargo.lock generated
View File

@ -403,7 +403,7 @@ dependencies = [
"bevy_math", "bevy_math",
"bevy_transform_interpolation", "bevy_transform_interpolation",
"bitflags 2.13.0", "bitflags 2.13.0",
"derive_more 2.1.1", "derive_more",
"disqualified", "disqualified",
"glam_matrix_extras", "glam_matrix_extras",
"itertools 0.15.0", "itertools 0.15.0",
@ -550,7 +550,7 @@ dependencies = [
"bevy_transform", "bevy_transform",
"bevy_utils", "bevy_utils",
"blake3", "blake3",
"derive_more 2.1.1", "derive_more",
"downcast-rs 2.0.2", "downcast-rs 2.0.2",
"either", "either",
"petgraph", "petgraph",
@ -642,7 +642,7 @@ dependencies = [
"bitflags 2.13.0", "bitflags 2.13.0",
"blake3", "blake3",
"crossbeam-channel", "crossbeam-channel",
"derive_more 2.1.1", "derive_more",
"disqualified", "disqualified",
"downcast-rs 2.0.2", "downcast-rs 2.0.2",
"either", "either",
@ -707,7 +707,7 @@ dependencies = [
"bevy_transform", "bevy_transform",
"bevy_utils", "bevy_utils",
"bevy_window", "bevy_window",
"derive_more 2.1.1", "derive_more",
"downcast-rs 2.0.2", "downcast-rs 2.0.2",
"serde", "serde",
"smallvec", "smallvec",
@ -739,7 +739,7 @@ dependencies = [
"bevy_math", "bevy_math",
"bevy_reflect", "bevy_reflect",
"bytemuck", "bytemuck",
"derive_more 2.1.1", "derive_more",
"encase", "encase",
"serde", "serde",
"thiserror 2.0.18", "thiserror 2.0.18",
@ -866,7 +866,7 @@ dependencies = [
"bitflags 2.13.0", "bitflags 2.13.0",
"bumpalo", "bumpalo",
"concurrent-queue", "concurrent-queue",
"derive_more 2.1.1", "derive_more",
"fixedbitset", "fixedbitset",
"indexmap", "indexmap",
"log", "log",
@ -1160,7 +1160,7 @@ dependencies = [
"bevy_math", "bevy_math",
"bevy_platform", "bevy_platform",
"bevy_reflect", "bevy_reflect",
"derive_more 2.1.1", "derive_more",
"log", "log",
"serde", "serde",
"smol_str", "smol_str",
@ -1343,7 +1343,7 @@ dependencies = [
"approx", "approx",
"arrayvec", "arrayvec",
"bevy_reflect", "bevy_reflect",
"derive_more 2.1.1", "derive_more",
"glam 0.32.1", "glam 0.32.1",
"itertools 0.14.0", "itertools 0.14.0",
"libm", "libm",
@ -1372,7 +1372,7 @@ dependencies = [
"bevy_transform", "bevy_transform",
"bitflags 2.13.0", "bitflags 2.13.0",
"bytemuck", "bytemuck",
"derive_more 2.1.1", "derive_more",
"encase", "encase",
"glam 0.32.1", "glam 0.32.1",
"half", "half",
@ -1420,7 +1420,7 @@ dependencies = [
"bevy_utils", "bevy_utils",
"bitflags 2.13.0", "bitflags 2.13.0",
"bytemuck", "bytemuck",
"derive_more 2.1.1", "derive_more",
"fixedbitset", "fixedbitset",
"indexmap", "indexmap",
"nonmax", "nonmax",
@ -1521,7 +1521,7 @@ dependencies = [
"bevy_ptr", "bevy_ptr",
"bevy_reflect_derive", "bevy_reflect_derive",
"bevy_utils", "bevy_utils",
"derive_more 2.1.1", "derive_more",
"disqualified", "disqualified",
"downcast-rs 2.0.2", "downcast-rs 2.0.2",
"erased-serde", "erased-serde",
@ -1613,7 +1613,7 @@ dependencies = [
"bevy_window", "bevy_window",
"bitflags 2.13.0", "bitflags 2.13.0",
"bytemuck", "bytemuck",
"derive_more 2.1.1", "derive_more",
"downcast-rs 2.0.2", "downcast-rs 2.0.2",
"encase", "encase",
"glam 0.32.1", "glam 0.32.1",
@ -1726,7 +1726,7 @@ dependencies = [
"bevy_transform", "bevy_transform",
"bevy_utils", "bevy_utils",
"bytemuck", "bytemuck",
"derive_more 2.1.1", "derive_more",
"tracing", "tracing",
] ]
@ -1783,7 +1783,7 @@ dependencies = [
"bevy_utils", "bevy_utils",
"bitflags 2.13.0", "bitflags 2.13.0",
"bytemuck", "bytemuck",
"derive_more 2.1.1", "derive_more",
"fixedbitset", "fixedbitset",
"nonmax", "nonmax",
"tracing", "tracing",
@ -1829,7 +1829,7 @@ dependencies = [
"bevy_platform", "bevy_platform",
"concurrent-queue", "concurrent-queue",
"crossbeam-queue", "crossbeam-queue",
"derive_more 2.1.1", "derive_more",
"futures-lite", "futures-lite",
"heapless 0.9.3", "heapless 0.9.3",
"web-task", "web-task",
@ -1891,7 +1891,7 @@ dependencies = [
"bevy_reflect", "bevy_reflect",
"bevy_tasks", "bevy_tasks",
"bevy_utils", "bevy_utils",
"derive_more 2.1.1", "derive_more",
"serde", "serde",
"thiserror 2.0.18", "thiserror 2.0.18",
] ]
@ -1946,7 +1946,7 @@ dependencies = [
"bevy_transform", "bevy_transform",
"bevy_utils", "bevy_utils",
"bevy_window", "bevy_window",
"derive_more 2.1.1", "derive_more",
"parley", "parley",
"serde", "serde",
"smallvec", "smallvec",
@ -1985,7 +1985,7 @@ dependencies = [
"bevy_ui", "bevy_ui",
"bevy_utils", "bevy_utils",
"bytemuck", "bytemuck",
"derive_more 2.1.1", "derive_more",
"indexmap", "indexmap",
"tracing", "tracing",
] ]
@ -2094,7 +2094,7 @@ dependencies = [
"bevy_reflect", "bevy_reflect",
"bevy_transform", "bevy_transform",
"bevy_utils", "bevy_utils",
"derive_more 2.1.1", "derive_more",
"ron 0.12.1", "ron 0.12.1",
"serde", "serde",
"thiserror 2.0.18", "thiserror 2.0.18",
@ -2390,12 +2390,6 @@ version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
[[package]]
name = "claxon"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4bfbf56724aa9eca8afa4fcfadeb479e722935bb2a0900c2d37e0cc477af0688"
[[package]] [[package]]
name = "clipboard-win" name = "clipboard-win"
version = "5.4.1" version = "5.4.1"
@ -2472,27 +2466,6 @@ version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32b13ea120a812beba79e34316b3942a857c86ec1593cb34f27bb28272ce2cca" checksum = "32b13ea120a812beba79e34316b3942a857c86ec1593cb34f27bb28272ce2cca"
[[package]]
name = "const_format"
version = "0.2.36"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4481a617ad9a412be3b97c5d403fef8ed023103368908b9c50af598ff467cc1e"
dependencies = [
"const_format_proc_macros",
"konst",
]
[[package]]
name = "const_format_proc_macros"
version = "0.2.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d57c2eccfb16dbac1f4e61e206105db5820c9d26c3c472bc17c774259ef7744"
dependencies = [
"proc-macro2",
"quote",
"unicode-xid",
]
[[package]] [[package]]
name = "const_panic" name = "const_panic"
version = "0.2.15" version = "0.2.15"
@ -2523,12 +2496,6 @@ dependencies = [
"const_soft_float", "const_soft_float",
] ]
[[package]]
name = "convert_case"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6245d59a3e82a7fc217c5828a6692dbc6dfb63a0c8c90495621f7b9d79704a0e"
[[package]] [[package]]
name = "convert_case" name = "convert_case"
version = "0.10.0" version = "0.10.0"
@ -2813,19 +2780,6 @@ version = "2.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8"
[[package]]
name = "derive_more"
version = "0.99.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6edb4b64a43d977b8e99788fe3a04d483834fba1215a7e02caa415b626497f7f"
dependencies = [
"convert_case 0.4.0",
"proc-macro2",
"quote",
"rustc_version",
"syn",
]
[[package]] [[package]]
name = "derive_more" name = "derive_more"
version = "2.1.1" version = "2.1.1"
@ -2841,7 +2795,7 @@ version = "2.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb"
dependencies = [ dependencies = [
"convert_case 0.10.0", "convert_case",
"proc-macro2", "proc-macro2",
"quote", "quote",
"rustc_version", "rustc_version",
@ -2952,7 +2906,6 @@ dependencies = [
"bevy_egui", "bevy_egui",
"bevy_solari", "bevy_solari",
"bevy_ufbx", "bevy_ufbx",
"blake3",
"egui_dock", "egui_dock",
"egui_phosphor_icons", "egui_phosphor_icons",
"game", "game",
@ -2965,7 +2918,6 @@ dependencies = [
"ron 0.8.1", "ron 0.8.1",
"scene", "scene",
"serde", "serde",
"serde_json",
"settings", "settings",
"shared", "shared",
"sim", "sim",
@ -3072,15 +3024,6 @@ dependencies = [
"syn", "syn",
] ]
[[package]]
name = "encoding_rs"
version = "0.8.35"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3"
dependencies = [
"cfg-if",
]
[[package]] [[package]]
name = "enum_dispatch" name = "enum_dispatch"
version = "0.3.13" version = "0.3.13"
@ -3455,13 +3398,9 @@ dependencies = [
"avian3d", "avian3d",
"bevy", "bevy",
"bevy_ufbx", "bevy_ufbx",
"cpal",
"game_hot", "game_hot",
"hot-lib-reloader", "hot-lib-reloader",
"protocol", "protocol",
"ron 0.12.1",
"scene",
"serde",
"settings", "settings",
"shared", "shared",
"sim", "sim",
@ -3862,12 +3801,6 @@ dependencies = [
"syn", "syn",
] ]
[[package]]
name = "hound"
version = "3.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "62adaabb884c94955b19907d60019f4e145d091c75345379e70d1ee696f7854f"
[[package]] [[package]]
name = "http" name = "http"
version = "1.4.2" version = "1.4.2"
@ -4317,21 +4250,6 @@ version = "3.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc"
[[package]]
name = "konst"
version = "0.2.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "128133ed7824fcd73d6e7b17957c5eb7bacb885649bd8c69708b2331a10bcefb"
dependencies = [
"konst_macro_rules",
]
[[package]]
name = "konst_macro_rules"
version = "0.2.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4933f3f57a8e9d9da04db23fb153356ecaf00cbd14aee46279c33dc80925c37"
[[package]] [[package]]
name = "kqueue" name = "kqueue"
version = "1.2.0" version = "1.2.0"
@ -4488,22 +4406,6 @@ dependencies = [
"libc", "libc",
] ]
[[package]]
name = "macro_rules_attribute"
version = "0.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf0c9b980bf4f3a37fd7b1c066941dd1b1d0152ce6ee6e8fe8c49b9f6810d862"
dependencies = [
"macro_rules_attribute-proc_macro",
"paste",
]
[[package]]
name = "macro_rules_attribute-proc_macro"
version = "0.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "58093314a45e00c77d5c508f76e77c3396afbbc0d01506e7fae47b018bac2b1d"
[[package]] [[package]]
name = "matchers" name = "matchers"
version = "0.2.0" version = "0.2.0"
@ -4528,12 +4430,6 @@ dependencies = [
"libc", "libc",
] ]
[[package]]
name = "minimal-lexical"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
[[package]] [[package]]
name = "miniz_oxide" name = "miniz_oxide"
version = "0.8.9" version = "0.8.9"
@ -4670,16 +4566,6 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451" checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451"
[[package]]
name = "nom"
version = "7.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a"
dependencies = [
"memchr",
"minimal-lexical",
]
[[package]] [[package]]
name = "nonmax" name = "nonmax"
version = "0.5.5" version = "0.5.5"
@ -5921,13 +5807,10 @@ version = "0.22.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0a536bb79db59098ef71a4dd4246c02eb87b316deceb1b68e0cde7167ec01eb" checksum = "d0a536bb79db59098ef71a4dd4246c02eb87b316deceb1b68e0cde7167ec01eb"
dependencies = [ dependencies = [
"claxon",
"cpal", "cpal",
"dasp_sample", "dasp_sample",
"hound",
"lewton", "lewton",
"num-rational", "num-rational",
"symphonia",
"thiserror 2.0.18", "thiserror 2.0.18",
"tracing", "tracing",
] ]
@ -5958,17 +5841,6 @@ dependencies = [
"unicode-ident", "unicode-ident",
] ]
[[package]]
name = "ron-edit"
version = "0.2.0"
dependencies = [
"const_format",
"derive_more 0.99.20",
"macro_rules_attribute",
"nom",
"unicode-ident",
]
[[package]] [[package]]
name = "rstar" name = "rstar"
version = "0.12.2" version = "0.12.2"
@ -6058,11 +5930,8 @@ dependencies = [
name = "scene" name = "scene"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"blake3",
"ron 0.8.1", "ron 0.8.1",
"ron-edit",
"serde", "serde",
"settings",
"shared", "shared",
] ]
@ -6446,55 +6315,6 @@ dependencies = [
"zeno", "zeno",
] ]
[[package]]
name = "symphonia"
version = "0.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5773a4c030a19d9bfaa090f49746ff35c75dfddfa700df7a5939d5e076a57039"
dependencies = [
"lazy_static",
"symphonia-bundle-mp3",
"symphonia-core",
"symphonia-metadata",
]
[[package]]
name = "symphonia-bundle-mp3"
version = "0.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4872dd6bb56bf5eac799e3e957aa1981086c3e613b27e0ac23b176054f7c57ed"
dependencies = [
"lazy_static",
"log",
"symphonia-core",
"symphonia-metadata",
]
[[package]]
name = "symphonia-core"
version = "0.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ea00cc4f79b7f6bb7ff87eddc065a1066f3a43fe1875979056672c9ef948c2af"
dependencies = [
"arrayvec",
"bitflags 1.3.2",
"bytemuck",
"lazy_static",
"log",
]
[[package]]
name = "symphonia-metadata"
version = "0.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "36306ff42b9ffe6e5afc99d49e121e0bd62fe79b9db7b9681d48e29fa19e6b16"
dependencies = [
"encoding_rs",
"lazy_static",
"log",
"symphonia-core",
]
[[package]] [[package]]
name = "syn" name = "syn"
version = "2.0.118" version = "2.0.118"
@ -8061,13 +7881,7 @@ checksum = "3ae8337f8a065cfc972643663ea4279e04e7256de865aa66fe25cec5fb912d3f"
name = "xtask" name = "xtask"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"blake3",
"libc",
"ron 0.8.1",
"scene", "scene",
"serde",
"serde_json",
"settings",
"walkdir", "walkdir",
] ]

View File

@ -26,14 +26,7 @@ avian3d = { version = "0.7", default-features = false, features = [
"collider-from-mesh", "collider-from-mesh",
"serialize", "serialize",
] } ] }
bevy = { version = "0.19", features = [ bevy = { version = "0.19", features = ["serialize", "jpeg"] }
"serialize",
"jpeg",
"vorbis",
"wav",
"mp3",
"flac",
] }
bevy_core_pipeline = "0.19" bevy_core_pipeline = "0.19"
bevy_solari = "0.19" bevy_solari = "0.19"
bevy_ufbx = "0.18.1-rc.1" bevy_ufbx = "0.18.1-rc.1"
@ -54,8 +47,6 @@ scene = { path = "crates/scene" }
[patch.crates-io] [patch.crates-io]
# Local Bevy 0.19 compatibility patch until upstream publishes a matching FBX loader. # Local Bevy 0.19 compatibility patch until upstream publishes a matching FBX loader.
bevy_ufbx = { path = "third_party/bevy_ufbx" } bevy_ufbx = { path = "third_party/bevy_ufbx" }
# Lossless RON editing fixes used by transactional prefab source edits.
ron-edit = { path = "third_party/ron-edit" }
# Atmosphere-aware mesh view bind groups (WYSIWYG editor + transform gizmos). # Atmosphere-aware mesh view bind groups (WYSIWYG editor + transform gizmos).
transform-gizmo-bevy = { path = "third_party/transform-gizmo-bevy" } transform-gizmo-bevy = { path = "third_party/transform-gizmo-bevy" }
@ -82,19 +73,3 @@ opt-level = 3
[profile.release] [profile.release]
lto = "thin" lto = "thin"
codegen-units = 1 codegen-units = 1
strip = "symbols"
# Export profiles keep runtime optimization and compact binaries without forcing
# release LTO into normal editor iteration.
[profile.package-dev]
inherits = "release"
lto = false
codegen-units = 16
strip = "symbols"
[profile.package-qa]
inherits = "release"
lto = false
codegen-units = 8
debug = "line-tables-only"
strip = "none"

155
README.md
View File

@ -21,15 +21,12 @@ native Bevy scene save/load, and BRP support for external tooling.
```bash ```bash
# Check everything # Check everything
cargo fmt --all --check cargo fmt --check
cargo check --workspace --all-targets cargo check --workspace
cargo clippy --workspace --all-targets -- -D warnings cargo clippy --workspace
cargo clippy -p editor -p game --bins --features dev,hot-reload -- -D warnings cargo clippy -p game -p shared -p protocol -p sim -- -D warnings
cargo test --workspace cargo test -p game --lib
cargo validate-levels cargo test -p sim
# Machine-readable project dependency and finding report
cargo validate-levels --json
cargo package-project --profile development
# Runtime game # Runtime game
cargo run -p game cargo run -p game
@ -40,22 +37,6 @@ cargo run -p editor
cargo run -p editor --features dev cargo run -p editor --features dev
``` ```
The standalone `game` binary loads `default_level` from `assets/project.ron` as the initial authored
scene. That entrypoint must be a regular `.scn.ron` file under `assets/levels/` so validation audits
its complete runtime dependency graph. The in-process editor defers that bootstrap to its scene tabs
and `SceneIo`, so opening the editor never creates a competing runtime scene root.
Open a different validated Blacksite project before engine startup:
```bash
cargo run -p editor --features dev -- --project /path/to/project
cargo run -p editor --bin project_launcher --features dev
```
The installed **Blacksite Editor** desktop entry also exposes **Open Project Browser** from its
desktop action menu. In the editor, **File > Switch Project...** performs a clean shutdown and
opens the same browser; choosing a project starts a fresh editor process with that root.
### Hot reload (gameplay iteration) ### Hot reload (gameplay iteration)
`--features dev` turns on Bevy **dynamic linking** for faster rebuilds (recommended day-to-day). `--features dev` turns on Bevy **dynamic linking** for faster rebuilds (recommended day-to-day).
@ -72,7 +53,7 @@ cargo watch -w crates/game_hot -w crates/sim -x "build -p game_hot --features dy
cargo run -p editor --features dev,hot-reload cargo run -p editor --features dev,hot-reload
``` ```
Or use the VS Code task **watch game_hot (hot reload)** alongside **run editor (hot reload)**, or choose **Editor: Hot reload** in Run and Debug. The watcher task requires `cargo-watch` (`cargo install cargo-watch --locked`). Or use the VS Code task **watch game_hot (hot reload)** alongside **run editor (dev fast-link)** (enable the `hot-reload` feature on the editor run task when using dylib iteration).
| Input | Action | | Input | Action |
|-------|--------| |-------|--------|
@ -101,7 +82,7 @@ rebuild caused by `cargo clean`. The workspace test profile keeps line tables bu
debuginfo and incremental test caches, so routine test binaries stay materially smaller without debuginfo and incremental test caches, so routine test binaries stay materially smaller without
reducing normal editor debugging fidelity. The cleanup binary deliberately excludes the scene/Bevy reducing normal editor debugging fidelity. The cleanup binary deliberately excludes the scene/Bevy
validation dependency; `cargo clean-target` therefore stays cheap even from a cold target. Use validation dependency; `cargo clean-target` therefore stays cheap even from a cold target. Use
`cargo validate-levels` when level and prefab-graph validation is required. VS Code tasks expose dry-run, safe, and `cargo validate-levels` when scene validation is required. VS Code tasks expose dry-run, safe, and
deep-stale variants. deep-stale variants.
### Launch Troubleshooting ### Launch Troubleshooting
@ -109,8 +90,8 @@ deep-stale variants.
- The native game/editor windows force an opaque Wayland surface and opaque camera clears to avoid compositor alpha issues on mixed HDR/SDR desktops. - The native game/editor windows force an opaque Wayland surface and opaque camera clears to avoid compositor alpha issues on mixed HDR/SDR desktops.
- If the window maps but appears transparent on Hyprland or another Wayland compositor, launch with `BEVY_FPS_HDR=0` to force the SDR camera path while debugging monitor/compositor behavior. - If the window maps but appears transparent on Hyprland or another Wayland compositor, launch with `BEVY_FPS_HDR=0` to force the SDR camera path while debugging monitor/compositor behavior.
- Bevy 0.19 removed the prior local `bevy_render` swapchain-timeout patch; launch troubleshooting should start from current wgpu/driver/compositor logs. - Bevy 0.19 removed the prior local `bevy_render` swapchain-timeout patch; launch troubleshooting should start from current wgpu/driver/compositor logs.
- Normal Debug and run configurations preserve project HDR and set `WGPU_VALIDATION=0` to suppress known Bevy/Solari Vulkan memory-model VUID noise on this stack. Use **GPU validation** when actively debugging renderer work; it forces `WGPU_VALIDATION=1` and may report those known upstream/driver messages. Use **SDR fallback** for compositor/HDR mapping failures; it additionally sets `BEVY_FPS_HDR=0`. - Debug launch configs and run tasks set `WGPU_VALIDATION=0` to quiet the known wgpu/Vulkan validation-layer warning `VUID-StandaloneSpirv-MemorySemantics-10871`. This only disables the Vulkan validation layer for those launches; Rust panics and application errors still surface normally.
- If **CodeLLDB / mold** fails with hundreds of `undefined symbol` linker errors, the incremental `target/` cache is stale. Run the VS Code task **clean build editor (dev)** or `cargo clean -p editor -p game -p game_hot -p shared && cargo build -p editor --bin editor --features dev`, then launch **Editor: Debug** again. Use `cargo run -p editor --features dev` from the terminal if you need `libbevy_dylib` on `LD_LIBRARY_PATH` automatically. - If **CodeLLDB / mold** fails with hundreds of `undefined symbol` linker errors, the incremental `target/` cache is stale. Run the VS Code task **clean build editor (dev)** or `cargo clean -p editor && cargo build -p editor --features dev`, then launch **Debug editor** again. Use `cargo run -p editor --features dev` from the terminal if you need `libbevy_dylib` on `LD_LIBRARY_PATH` automatically.
## Editor Controls ## Editor Controls
@ -134,35 +115,27 @@ deep-stale variants.
| `Ctrl+D` | Duplicate selection | | `Ctrl+D` | Duplicate selection |
| `Ctrl+Z` / `Ctrl+Shift+Z` / `Ctrl+Y` | Undo / redo | | `Ctrl+Z` / `Ctrl+Shift+Z` / `Ctrl+Y` | Undo / redo |
| `F2` in Hierarchy | Rename selection | | `F2` in Hierarchy | Rename selection |
| `W` / `E` / `R` | Translate / rotate / scale gizmo; multi-selection uses one grouped gizmo and undo step | | `W` / `E` / `R` | Translate / rotate / scale gizmo |
| `X` | Toggle world/local gizmo orientation | | `X` | Toggle world/local gizmo orientation |
| `B` | Enter Draw Brush mode | | `B` | Enter Draw Brush mode |
| Draw Brush: LMB / `Enter` / mouse up-down / `Esc` / `Backspace` | Place floor points / enter height phase or create / set height / cancel / remove point or return to outline | | Draw Brush: LMB / `Enter` / mouse up-down / `Esc` / `Backspace` | Place floor points / enter height phase or create / set height / cancel / remove point or return to outline |
| Brush selected: `1` / `2` / `3` / `4` | Vertex / edge / face / clip edit modes | | Brush selected: `1` / `2` / `3` / `4` | Vertex / edge / face / clip edit modes |
| Brush edit mode: LMB / `Shift+LMB` / `W` / `E` / `R` / `Esc` | Select element / toggle element selection / move / rotate / scale selected brush elements / return to object mode | | Brush edit mode: LMB / `Shift+LMB` / `W` / `E` / `R` / `Esc` | Select element / toggle element selection / move / rotate / scale selected brush elements / return to object mode |
| Viewport toolbar (sun / brush / box icons) | Shading: Lit, Unlit (albedo), Colliders (mesh off) | | Viewport toolbar (sun / brush / box icons) | Shading: Lit, Unlit (albedo), Colliders (mesh off) |
| Viewport eye/options | Toggle actor root icon categories, adjust icon/gizmo size, and control colliders, lights, spawns, gameplay markers, volumes, prefab/model anchors, and runtime player/camera visualizers | | Viewport eye/options | Toggle actor root icon categories, adjust icon/gizmo size, and control colliders, lights, spawns, prefab/model anchors, and runtime player/camera visualizers |
| `Tab` in viewport | Cycle selection through overlapping objects at last click | | `Tab` in viewport | Cycle selection through overlapping objects at last click |
| Viewport selection/orientation HUD | Identify the primary selection, multi-selection count, overlapping-pick position, camera axes, shading mode, and active render path |
| Click Player visualizer in Edit mode | Select or create the authored `PlayerSpawn` (`Player Start`) | | Click Player visualizer in Edit mode | Select or create the authored `PlayerSpawn` (`Player Start`) |
| Select Project Sun | Inspect project default lighting; create a scene sun override | | Select Project Sun | Inspect project default lighting; create a scene sun override |
| Asset Browser project/file views | Browse `assets/`, search/filter/sort models, textures, materials, audio clips, levels, and prefabs; switch grid/list; expand model subassets; inspect file details; audition audio; drag supported assets/submeshes into the viewport | | Asset Browser project/file views | Browse `assets/`, search/filter/sort, switch grid/list, expand model subassets with generated thumbnails, inspect staged import/material settings, drag assets/submeshes into the viewport |
| Drag audio clip into viewport | Create an authored audio source; when an audio source is selected, assign the clip instead |
| Asset Browser context/details actions | Apply textures/materials, regenerate thumbnails, reimport models, place assets/submeshes, or move file assets to `assets/.trash/` | | Asset Browser context/details actions | Apply textures/materials, regenerate thumbnails, reimport models, place assets/submeshes, or move file assets to `assets/.trash/` |
| `Ctrl+P` | Centered command palette; search human labels or stable command IDs, use arrow keys to select, Enter to run | | `Ctrl+P` | Centered command palette; search human labels or stable command IDs, use arrow keys to select, Enter to run |
| `F7` | While paused in Play: advance one sim tick | | `F7` | While paused in Play: advance one sim tick |
| Shift/Ctrl + click (Hierarchy) | Additive selection | | Shift/Ctrl + click (Hierarchy) | Additive selection |
| Drag actor row onto another actor (Hierarchy) | Attach/reparent while preserving world placement; multi-selection keeps existing subtrees and commits one undo step | | Hierarchy context | Reparent to other selection / Unparent |
| Drag actor between rows / onto Scene Root (Hierarchy, Manual sort) | Reorder siblings / unparent to the root | | File menu | New, Open, Save, Save As, Import Assets, Export Selection, Save Selection As Prefab, Recent Scenes |
| Hierarchy lock | Excludes the actor from selection, gizmos, multi-drag, structural drops, and mutating context actions |
| Hierarchy context | Group selection, create authored local children below linked prefab roots, remove/reparent generated members through same-layer structural overrides, or unparent |
| File menu | New, Open, transactional Save/Save As, recovery restore/keep-copy/discard when available, Import Assets, Export Selection, Save Selection As Prefab (including linked-root variants), Recent Scenes |
| Main toolbar, right side | Switch or close independent scene tabs, create an untitled tab, and manage loaded/locked composed subscenes |
| Prefab Instance inspector | Inspect/recover source health, Apply/Revert overrides by scope, Apply overrides to source, create a variant, **Unpack Layer**, or recursively **Convert to Local** |
| Inspector component card | Collapse with caret, toggle active with status dot, or use triple-dot menu for reset/copy/paste/move/remove actions | | Inspector component card | Collapse with caret, toggle active with status dot, or use triple-dot menu for reset/copy/paste/move/remove actions |
| Inspector footer → Add Component | Expands an inline search shelf for registered authoring, rendering, physics, gameplay, and volume components with descriptions, availability hints, and undo | | Inspector footer → Add Component | Expands an inline search shelf for registered authoring, rendering, physics, gameplay, and volume components with descriptions, availability hints, and undo |
| Audio Source / Listener inspector | Assign and audition clips; edit gain, pitch, loop/autoplay, spatial blend, attenuation, bus, listener priority, and ear gap | | Edit → Project Settings… | Edit `assets/project.ron` (rendering, physics, input) |
| Edit → Project Settings… | Edit `assets/project.ron` rendering, audio buses, physics, and input |
Viewport shortcut keys require the pointer to be in the viewport and are suspended while typing in egui text fields or actively using camera navigation. `Delete`, `Backspace`, duplicate, and undo/redo also defer to text-field focus. Viewport shortcut keys require the pointer to be in the viewport and are suspended while typing in egui text fields or actively using camera navigation. `Delete`, `Backspace`, duplicate, and undo/redo also defer to text-field focus.
@ -202,18 +175,7 @@ Viewport shortcut keys require the pointer to be in the viewport and are suspend
## Scene Workflow ## Scene Workflow
- Levels are saved as native Bevy dynamic scenes under `assets/levels/`. - Levels are saved as native Bevy dynamic scenes under `assets/levels/`.
- Scene tabs live at the right end of the existing main toolbar. Open creates another tab; each tab
retains its own path and dirty state, and close prompts only for that document.
- The adjacent **Composition** menu adds validated project-relative subscenes, loads/unloads them,
applies read-only hierarchy locks, and focuses their actors. Recursive validation rejects missing
or cyclic references before replacing the active world. See
[multi-scene-composition.md](docs/editor/multi-scene-composition.md).
- Editable entities use reflectable authoring components from `crates/shared`. - Editable entities use reflectable authoring components from `crates/shared`.
- Linked prefabs keep source-generated actors out of owner-scene serialization through runtime
`HydratedPrefabMember` ownership. Generated actor properties/components and same-layer structural
changes use stable nested override paths; authored local children and nested linked instances can
live below an instance root. Saving that linked root and its local hierarchy as a prefab creates
the current variant representation. See [prefab-authoring.md](docs/editor/prefab-authoring.md).
- The editable player start is an authored `PlayerSpawn`; moving it with the transform gizmo changes - The editable player start is an authored `PlayerSpawn`; moving it with the transform gizmo changes
where Play mode boots the runtime player. where Play mode boots the runtime player.
- Project settings provide default ambient/sun lighting. A scene-authored directional `LightDesc` - Project settings provide default ambient/sun lighting. A scene-authored directional `LightDesc`
@ -227,13 +189,9 @@ Viewport shortcut keys require the pointer to be in the viewport and are suspend
artifacts in `assets/meshes/generated/`. Renderer slots reference imported content-browser mesh artifacts in `assets/meshes/generated/`. Renderer slots reference imported content-browser mesh
and material assets, while generated collision is stored separately in `ColliderDesc` plus and material assets, while generated collision is stored separately in `ColliderDesc` plus
`RigidBodyDesc`. Switch a model asset's placement mode to **Scene Instance** in Asset Browser `RigidBodyDesc`. Switch a model asset's placement mode to **Scene Instance** in Asset Browser
details when you need full `WorldAssetRoot` scene data instead of normalized static slots. details when you need full `WorldAssetRoot` loading for animation/skinning/scene data. Expanding a
Expanding a model in the Asset Browser exposes normalized mesh/material/texture plus generated model in the Asset Browser exposes normalized mesh/material/texture subassets; dragging a mesh
skeleton/animation-clip subassets. Dragging a mesh places that part through the static mesh path; subasset places that part through the same static mesh renderer path.
dragging a clip creates an animated scene instance or assigns an exact-rig-compatible state to
the selected imported model. The Animation Controller inspector edits state IDs, clips,
range/loop/speed/default/crossfade and provides non-dirty play/pause/stop/scrub preview. See the
[animation authoring guide](docs/editor/animation-authoring.md) and [ADR 0031](docs/adr/0031-animation-authoring-runtime-contract.md).
- Brush actors are persisted as `ActorKind::Brush + BrushDesc`; valid convex faces hydrate into - Brush actors are persisted as `ActorKind::Brush + BrushDesc`; valid convex faces hydrate into
generated preview meshes. Vertex, edge, and face selections use the standard transform gizmo, generated preview meshes. Vertex, edge, and face selections use the standard transform gizmo,
face material/UV fields are undoable, and clip/intersect/merge/subtract provide conservative face material/UV fields are undoable, and clip/intersect/merge/subtract provide conservative
@ -271,41 +229,7 @@ Viewport shortcut keys require the pointer to be in the viewport and are suspend
### Hierarchy scrolls during drag ### Hierarchy scrolls during drag
Scroll-to-drag is disabled while dragging actors. The pointer card reports the pending action: Scroll-to-drag is disabled while dragging actors. Release the mouse to finish or cancel the drag.
drop on a full actor row to attach, on the **Scene Root** zone to unparent, or between rows in
**Manual** sort to reorder. Amber means attach, cyan means reorder/root, and red blocks a hierarchy
cycle. Release away from a valid target to cancel.
### Scene recovery
Dirty saved scenes create a recovery snapshot every 120 seconds and retain the newest five
generations under `$XDG_STATE_HOME/blacksite-editor/recovery` or
`~/.local/state/blacksite-editor/recovery`. If a snapshot is newer than the authored scene, the
status strip shows `[recovery]` and recovery actions become available in **File**. **Restore
Recovery Snapshot** loads it into dirty editor state for inspection, **Save Recovery Copy As...**
writes a separate transactional scene copy without changing the active scene, and **Discard
Recovery Snapshot** retires all generations. Restore keeps the generation available until **Save**
commits the active scene or discard explicitly retires it. The **Diagnostics** window retains the
latest 32 scene I/O results, including exact recovery paths and failures, for the current session.
Unsaved new scenes are not yet covered by automatic recovery.
For recovery testing only, `BLACKSITE_RECOVERY_INTERVAL_SECS=<seconds>` overrides the interval for
the current process without changing preferences.
### Session recovery
Clean editor exits restore the last authored scene, panel visibility, and viewport bookmarks from
the versioned machine-local session document. After an abnormal exit, Blacksite opens the safe
startup scene and asks whether to **Resume Last Scene** or **Continue Safe**; modal tools and dirty
preview state are never restored. Session metadata lives under
`$XDG_STATE_HOME/blacksite-editor/session.ron` or `~/.local/state/blacksite-editor/session.ron` and
contains no scene contents or credentials. See [ADR 0024](docs/adr/0024-versioned-editor-session-state.md).
Open **Window → Diagnostics** and choose **Export Diagnostic Bundle** to write a transactional
support report under `$XDG_STATE_HOME/blacksite-editor/diagnostics` or
`~/.local/state/blacksite-editor/diagnostics`. The report includes build/platform and renderer
identity, project and active-scene paths, dirty flags, aggregate validation counts, prior-crash
state, and the bounded Scene I/O log. It excludes scene and asset contents, environment values,
host/user identity, credentials, access tokens, and modal tool state.
## Cursor / VSCode Setup ## Cursor / VSCode Setup
@ -314,8 +238,8 @@ The `.vscode/` folder is preconfigured:
- `extensions.json` recommends rust-analyzer, Even Better TOML, CodeLLDB, crates, and Error Lens. - `extensions.json` recommends rust-analyzer, Even Better TOML, CodeLLDB, crates, and Error Lens.
- `settings.json` runs `clippy` on save, enables proc-macro/build-script support, formats on save, - `settings.json` runs `clippy` on save, enables proc-macro/build-script support, formats on save,
and excludes `target/` from search/watch. and excludes `target/` from search/watch.
- `tasks.json` mirrors the full workspace formatting, all-target check, strict Clippy, test, level-validation, build/run, hot-reload, and target-cleanup workflows. **build editor (dev fast-link)** is the default build task. - `tasks.json` provides workspace check/clippy plus game/editor build/run tasks.
- `launch.json` uses CodeLLDB Cargo artifact filtering for editor/game Debug, GPU-validation, hot-reload, SDR fallback, and Release configurations. Normal launches keep HDR enabled while quieting known Vulkan validation noise; dynamic-link launches set the required `LD_LIBRARY_PATH` automatically. - `launch.json` provides CodeLLDB launch configs for the editor and game.
- `.github/workflows/ci.yml` mirrors local formatting, check, clippy, test, and binary build verification. - `.github/workflows/ci.yml` mirrors local formatting, check, clippy, test, and binary build verification.
## Architecture Decisions ## Architecture Decisions
@ -368,17 +292,11 @@ crates/
- [x] CI workflow for format, check, clippy, tests, and binary builds - [x] CI workflow for format, check, clippy, tests, and binary builds
- [x] ADRs for roadmap architecture and Bevy migration policy - [x] ADRs for roadmap architecture and Bevy migration policy
- [x] Determinism harness: same inputs over same ticks produce the same state summary/hash - [x] Determinism harness: same inputs over same ticks produce the same state summary/hash
- [x] Reusable editor operator harness for commit/cancel/block, helper cleanup, dirty state, grouped undo, and undo/redo round trips
- [x] Verify: `cargo fmt --check` / `cargo check --workspace` / `cargo clippy --workspace` / strict foundation clippy / `cargo test -p sim` - [x] Verify: `cargo fmt --check` / `cargo check --workspace` / `cargo clippy --workspace` / strict foundation clippy / `cargo test -p sim`
- [x] Stable asset registry with UUIDs + import settings in asset browser details - [x] Stable asset registry with UUIDs + import settings in asset browser details
- [x] Asset Browser expandable model subasset shelves, independent mesh/material/texture thumbnails, staged import/material details with shader-schema parameters, context actions, and trash-first file removal - [x] Asset Browser expandable model subasset shelves, independent mesh/material/texture thumbnails, staged import/material details with shader-schema parameters, context actions, and trash-first file removal
- [x] Audio clip catalog/import foundation for Ogg, WAV, MP3, and FLAC with dedicated filtering, file details, and stable runtime-resolvable asset references
- [x] Audio source/listener authoring, non-dirty spatial audition, viewport icons/range gizmos, stable buses, PIE/runtime parity, device diagnostics, and shared release validation ([ADR 0030](docs/adr/0030-audio-authoring-and-bus-schema.md); production acceptance completed in [Gitea #47](https://git.spacetrainclubhouse.com/Falling-Metal-Interactive/Blacksite/issues/47))
- [x] glTF/GLB skeletal animation manifests, stable controller states, non-dirty preview, PIE/runtime hydration, and exact-signature compatibility validation ([ADR 0031](docs/adr/0031-animation-authoring-runtime-contract.md); production acceptance completed in [Gitea #46](https://git.spacetrainclubhouse.com/Falling-Metal-Interactive/Blacksite/issues/46))
- [x] Prefab instances (`PrefabInstance`) + save-as-prefab + unpack - [x] Prefab instances (`PrefabInstance`) + save-as-prefab + unpack
- [x] Independent dirty-tab close confirmation and all-tab save guard when switching projects - [x] Unsaved-scene confirm on New/Open/Recent
- [x] Transactional scene writes + bounded user-local recovery snapshots ([ADR 0023](docs/adr/0023-transactional-scene-persistence-and-recovery.md))
- [x] Versioned clean/crash session resume + privacy-bounded diagnostic bundle ([ADR 0024](docs/adr/0024-versioned-editor-session-state.md))
- [x] Hierarchy multi-select, reparent undo, multi-entity gizmo transform - [x] Hierarchy multi-select, reparent undo, multi-entity gizmo transform
- [x] Typed inspector undo (light, rigid body, collider, primitive, material, static mesh renderer) + registry-driven Add Component footer - [x] Typed inspector undo (light, rigid body, collider, primitive, material, static mesh renderer) + registry-driven Add Component footer
- [x] Gameplay authoring markers + visualizers (`WeaponSpawn`, `TriggerVolume`, etc.) - [x] Gameplay authoring markers + visualizers (`WeaponSpawn`, `TriggerVolume`, etc.)
@ -387,18 +305,14 @@ crates/
- [x] `ActorInspectorSection` registry + game demo section (`game::editor_ext`) - [x] `ActorInspectorSection` registry + game demo section (`game::editor_ext`)
- [x] Command palette: reset lighting, group selection, focus selection - [x] Command palette: reset lighting, group selection, focus selection
- [x] CI: `cargo test -p shared`, scene authoring-only check on repo level, `cargo check -p editor --features dev` - [x] CI: `cargo test -p shared`, scene authoring-only check on repo level, `cargo check -p editor --features dev`
- [x] `scene` crate schema stamp/migrate/validate on save/load + CI `validate-levels` for levels and prefab graphs - [x] `scene` crate schema stamp/migrate/validate on save/load + CI `validate-levels`
- [x] Project Settings draft + Apply (HDR/swapchain safe) - [x] Project Settings draft + Apply (HDR/swapchain safe)
- [x] Project Browser UI, strict manifest validation, `--project` startup activation, recent filtering, sandbox scaffolding, clean process handoff, and desktop launcher action ([ADR 0025](docs/adr/0025-project-root-is-a-startup-boundary.md)) - [ ] Project launcher/scaffolding with startup-time asset-root selection; the unsafe in-process File → Project scaffold was removed pending BS-JD-001
- [x] Independent scene tabs + stable subscene composition, recursive validation, ownership locks, active-world PIE consistency, and per-saved-tab recovery ([ADR 0026](docs/adr/0026-stable-scene-composition-and-active-document.md))
- [x] Editor lib/bin split + `EditorPluginGroup`; game EditorPlugin dogfood panel - [x] Editor lib/bin split + `EditorPluginGroup`; game EditorPlugin dogfood panel
- [x] FBX/glTF model import + normalized `StaticMeshRenderer` placement; explicit scene-instance load via `bevy_ufbx` / `ModelRef` - [x] FBX/glTF model import + normalized `StaticMeshRenderer` placement; explicit scene-instance load via `bevy_ufbx` / `ModelRef`
- [x] Asset browser model thumbnails (unified `assets/thumbnails/` pipeline; `ThumbnailState` cache; FBX via `FbxThumbnailSource`) - [x] Asset browser model thumbnails (unified `assets/thumbnails/` pipeline; `ThumbnailState` cache; FBX via `FbxThumbnailSource`)
- [x] Material assets (`assets/materials/*.ron`, inspector picker, drag-drop to selection) - [x] Material assets (`assets/materials/*.ron`, inspector picker, drag-drop to selection)
- [x] Prefab v2 core: shared stable nested override paths, property/component/structural scopes, recursive graph validation, linked-root variants, conflict recovery, transactional source Apply, and undoable unpack/convert ([ADR 0027](docs/adr/0027-stable-prefab-ownership-and-variants.md)) - [x] Prefab instance overrides v2 (transform/material/child visibility apply-revert groups)
- [x] Prefab v2 production acceptance: committed base/nested/variant fixtures pass workspace tests, recursive headless validation, packaged release startup, and live editor placement/inspection regression coverage ([Gitea #43](https://git.spacetrainclubhouse.com/Falling-Metal-Interactive/Blacksite/issues/43))
- [x] Shared project validation: editor Diagnostics and `cargo validate-levels --json` use one owner-attributed dependency/finding report across project settings, registry/import artifacts, materials, shaders, scenes, prefabs, brushes, and colliders; valid/missing/cyclic/incompatible fixtures fail on blocking content errors ([ADR 0028](docs/adr/0028-authoritative-project-content-validation.md), [Gitea #45](https://git.spacetrainclubhouse.com/Falling-Metal-Interactive/Blacksite/issues/45))
- [x] Validation-gated package foundation: versioned development/QA/release profiles, deterministic runtime exclusions, stable input snapshots, transactional staged publication, Cargo-reported artifacts, BLAKE3 metadata, authored default-scene startup, and a non-blocking editor Build panel with live logs/cancel/run/reveal ([ADR 0029](docs/adr/0029-validation-gated-build-profiles-and-packaging.md), [build guide](docs/editor/build-and-package.md), [Gitea #44](https://git.spacetrainclubhouse.com/Falling-Metal-Interactive/Blacksite/issues/44))
- [x] Advanced rendering: `GiMode`, post-process volumes, Rendering panel, requested/effective render stack, Solari integration, emissive materials, post FX assets ([ADR 0013](docs/adr/0013-rendering-tiers-and-post-process-volumes.md), [ADR 0016](docs/adr/0016-unified-rendering-contract.md), [rendering guide](docs/editor/rendering.md)) - [x] Advanced rendering: `GiMode`, post-process volumes, Rendering panel, requested/effective render stack, Solari integration, emissive materials, post FX assets ([ADR 0013](docs/adr/0013-rendering-tiers-and-post-process-volumes.md), [ADR 0016](docs/adr/0016-unified-rendering-contract.md), [rendering guide](docs/editor/rendering.md))
- [x] Static mesh renderer component, generated normalized mesh artifacts, source/one-actor hierarchy placement, and inspector renderer slots ([ADR 0017](docs/adr/0017-normalized-static-mesh-assets.md)) - [x] Static mesh renderer component, generated normalized mesh artifacts, source/one-actor hierarchy placement, and inspector renderer slots ([ADR 0017](docs/adr/0017-normalized-static-mesh-assets.md))
- [x] Componentized actor inspector, unified component cards, thumbnail static mesh slots, imported asset Browse/Locate/Clear refs with inherited source defaults, collider split, shader-aware actor material data, and texture picker/drop refs ([ADR 0018](docs/adr/0018-componentized-actor-inspector-and-materials.md)) - [x] Componentized actor inspector, unified component cards, thumbnail static mesh slots, imported asset Browse/Locate/Clear refs with inherited source defaults, collider split, shader-aware actor material data, and texture picker/drop refs ([ADR 0018](docs/adr/0018-componentized-actor-inspector-and-materials.md))
@ -418,20 +332,9 @@ crates/
uses `StaticMeshRenderer` by default with imported asset refs and optional separate static mesh uses `StaticMeshRenderer` by default with imported asset refs and optional separate static mesh
collider components. Expanded mesh subassets generate independent thumbnails and can be placed independently. Asset details can switch collider components. Expanded mesh subassets generate independent thumbnails and can be placed independently. Asset details can switch
placement to **Scene Instance** for `ModelRef`/`WorldAssetRoot` playback, shared material assets can be placement to **Scene Instance** for `ModelRef`/`WorldAssetRoot` playback, shared material assets can be
edited from the browser, and delete actions move files to `assets/.trash/`. Animation authoring edited from the browser, and delete actions move files to `assets/.trash/`. Skeletal animation
supports glTF/GLB rig and clip manifests, stable controller refs, clip drag assignment/placement, playback is not supported by the static mesh path yet.
inspector preview, PIE/runtime playback, and headless compatibility diagnostics. Animated or - Prefab instances use stable asset IDs (`PrefabInstance` + registry) with serialized `PrefabOverrides` (transform, material, per-child visibility by name). Inspector **Apply/Revert** per field group; **Unpack** removes the instance link.
skinned FBX remains blocked until the loader can build hierarchy, Bevy skinned meshes, and clips.
See the [animation authoring guide](docs/editor/animation-authoring.md).
- Prefab instances use stable asset IDs and shared versioned override data keyed by nested
`PrefabActorPath` identity. Generated source members are excluded from owner save/export, while
property/component edits and same-layer remove/reparent operations persist as overrides. Revert
stays instance-local; explicit **Apply to source** validates and atomically patches the appropriate
source layer, with exact-file undo/redo guards against external edits. Source health exposes
changed, semantic-conflict, broken, and malformed states with keep/take/retry/relink recovery.
**Unpack Layer** preserves nested links; **Convert to Local** recursively removes them. Current
authoring UI coverage and production-acceptance gaps are tracked in
[prefab-authoring.md](docs/editor/prefab-authoring.md).
- Material assets are RON files under `assets/materials/`; shader schemas live under `assets/shaders/`. `MaterialDesc` stores shader kind, typed parameters, texture bindings, and StandardMaterial fields. The Asset Browser material details editor can apply shader schemas, edit typed parameters/textures, and regenerate sphere thumbnails. Actor inspector material edits live on the actor `MaterialDesc`; static mesh source-material refs remain imported defaults and shared material assets are edited from the Asset Browser. - Material assets are RON files under `assets/materials/`; shader schemas live under `assets/shaders/`. `MaterialDesc` stores shader kind, typed parameters, texture bindings, and StandardMaterial fields. The Asset Browser material details editor can apply shader schemas, edit typed parameters/textures, and regenerate sphere thumbnails. Actor inspector material edits live on the actor `MaterialDesc`; static mesh source-material refs remain imported defaults and shared material assets are edited from the Asset Browser.
- Per-field reflect undo for all components remains future work; typed `shared` inspectors cover the common authoring path. - Per-field reflect undo for all components remains future work; typed `shared` inspectors cover the common authoring path.
- The authoring/hydration layer is intentionally small so richer asset workflows (terrain, - The authoring/hydration layer is intentionally small so richer asset workflows (terrain,

View File

@ -1,36 +1,4 @@
[ [
(
id: ("c0a7d10e-53c1-4de0-b0dc-34a66f44ba77"),
path: "assets/audio/editor_audition_tone.ogg",
label: "editor_audition_tone",
kind_tag: "AudioClip",
import_settings: (
scale: 1.0,
generate_collider: true,
lod0_only: true,
placement_mode: StaticAsset,
hierarchy_mode: SingleActor,
material_policy: SourceMaterials,
static_mesh_manifest_path: None,
),
dependencies: [],
),
(
id: ("fe40db93-181f-4c79-86e6-26bd97464c4b"),
path: "assets/audio/Puzzle Lobby.mp3",
label: "Puzzle Lobby",
kind_tag: "AudioClip",
import_settings: (
scale: 1.0,
generate_collider: true,
lod0_only: true,
placement_mode: StaticAsset,
hierarchy_mode: SingleActor,
material_policy: SourceMaterials,
static_mesh_manifest_path: None,
),
dependencies: [],
),
( (
id: ("31963299-d4c1-45dc-8f67-2f469ca1ad6d"), id: ("31963299-d4c1-45dc-8f67-2f469ca1ad6d"),
path: "assets/post_fx/chromatic_aberration.ron", path: "assets/post_fx/chromatic_aberration.ron",
@ -79,6 +47,27 @@
), ),
dependencies: [], dependencies: [],
), ),
(
id: ("71071fc3-7a7f-454d-b9ab-93ebf0feccbe"),
path: "assets/models/metal_stool_01_2k.gltf",
label: "metal_stool_01_2k",
kind_tag: "Model",
import_settings: (
scale: 1.0,
generate_collider: true,
lod0_only: true,
placement_mode: SceneInstance,
hierarchy_mode: SingleActor,
material_policy: SourceMaterials,
static_mesh_manifest_path: Some("assets/meshes/generated/71071fc3-7a7f-454d-b9ab-93ebf0feccbe.static_mesh.ron"),
),
dependencies: [
"assets/models/metal_stool_01.bin",
"assets/models/textures/metal_stool_01_arm_2k.jpg",
"assets/models/textures/metal_stool_01_diff_2k.jpg",
"assets/models/textures/metal_stool_01_nor_gl_2k.jpg",
],
),
( (
id: ("c4abe41c-ac88-4aec-8168-27e8a7c90583"), id: ("c4abe41c-ac88-4aec-8168-27e8a7c90583"),
path: "assets/textures/metal_stool_01_nor_gl_2k.jpg", path: "assets/textures/metal_stool_01_nor_gl_2k.jpg",
@ -131,7 +120,7 @@
id: ("61802b85-fda9-4aff-94d9-fe5f0860c516"), id: ("61802b85-fda9-4aff-94d9-fe5f0860c516"),
path: "assets/levels/rendering_showcase.scn.ron", path: "assets/levels/rendering_showcase.scn.ron",
label: "rendering_showcase.scn", label: "rendering_showcase.scn",
kind_tag: "Level", kind_tag: "Prefab",
import_settings: ( import_settings: (
scale: 1.0, scale: 1.0,
generate_collider: true, generate_collider: true,
@ -147,23 +136,7 @@
id: ("48bcef13-e6a6-4260-bb1b-c29787bd8c70"), id: ("48bcef13-e6a6-4260-bb1b-c29787bd8c70"),
path: "assets/levels/editor_scene 3.scn.ron", path: "assets/levels/editor_scene 3.scn.ron",
label: "editor_scene 3.scn", label: "editor_scene 3.scn",
kind_tag: "Level", kind_tag: "Prefab",
import_settings: (
scale: 1.0,
generate_collider: true,
lod0_only: true,
placement_mode: StaticAsset,
hierarchy_mode: SingleActor,
material_policy: SourceMaterials,
static_mesh_manifest_path: None,
),
dependencies: [],
),
(
id: ("f3dfcd11-aa5b-4fd4-872b-06832b865c99"),
path: "assets/levels/audio_authoring_showcase.scn.ron",
label: "audio_authoring_showcase.scn",
kind_tag: "Level",
import_settings: ( import_settings: (
scale: 1.0, scale: 1.0,
generate_collider: true, generate_collider: true,
@ -179,7 +152,7 @@
id: ("5a2307d0-48e5-40d3-a5c9-527c0cfd30f4"), id: ("5a2307d0-48e5-40d3-a5c9-527c0cfd30f4"),
path: "assets/levels/editor_scene.scn.ron", path: "assets/levels/editor_scene.scn.ron",
label: "editor_scene.scn", label: "editor_scene.scn",
kind_tag: "Level", kind_tag: "Prefab",
import_settings: ( import_settings: (
scale: 1.0, scale: 1.0,
generate_collider: true, generate_collider: true,
@ -195,54 +168,6 @@
id: ("dc13ce01-c7ce-43c5-974c-0e659ae49ab9"), id: ("dc13ce01-c7ce-43c5-974c-0e659ae49ab9"),
path: "assets/levels/editor_scene 2.scn.ron", path: "assets/levels/editor_scene 2.scn.ron",
label: "editor_scene 2.scn", label: "editor_scene 2.scn",
kind_tag: "Level",
import_settings: (
scale: 1.0,
generate_collider: true,
lod0_only: true,
placement_mode: StaticAsset,
hierarchy_mode: SingleActor,
material_policy: SourceMaterials,
static_mesh_manifest_path: None,
),
dependencies: [],
),
(
id: ("c14f6240-5654-4e80-9edb-2b4c44cf8d61"),
path: "assets/prefabs/example_variant.scn.ron",
label: "example_variant.scn",
kind_tag: "Prefab",
import_settings: (
scale: 1.0,
generate_collider: true,
lod0_only: true,
placement_mode: StaticAsset,
hierarchy_mode: SingleActor,
material_policy: SourceMaterials,
static_mesh_manifest_path: None,
),
dependencies: [],
),
(
id: ("682191d6-59cd-46a1-9560-6cf7bc1583b2"),
path: "assets/prefabs/example_nested.scn.ron",
label: "example_nested.scn",
kind_tag: "Prefab",
import_settings: (
scale: 1.0,
generate_collider: true,
lod0_only: true,
placement_mode: StaticAsset,
hierarchy_mode: SingleActor,
material_policy: SourceMaterials,
static_mesh_manifest_path: None,
),
dependencies: [],
),
(
id: ("862530b2-8111-4ebb-9078-29723c002e08"),
path: "assets/prefabs/example_base.scn.ron",
label: "example_base.scn",
kind_tag: "Prefab", kind_tag: "Prefab",
import_settings: ( import_settings: (
scale: 1.0, scale: 1.0,
@ -319,54 +244,6 @@
), ),
dependencies: [], dependencies: [],
), ),
(
id: ("64fea29e-c123-4987-92dc-ca25f4dc7dea"),
path: "assets/build_profiles/qa.ron",
label: "qa",
kind_tag: "Level",
import_settings: (
scale: 1.0,
generate_collider: true,
lod0_only: true,
placement_mode: StaticAsset,
hierarchy_mode: SingleActor,
material_policy: SourceMaterials,
static_mesh_manifest_path: None,
),
dependencies: [],
),
(
id: ("9213efe6-12c7-45a8-8b64-9e1699f76ba3"),
path: "assets/build_profiles/development.ron",
label: "development",
kind_tag: "Level",
import_settings: (
scale: 1.0,
generate_collider: true,
lod0_only: true,
placement_mode: StaticAsset,
hierarchy_mode: SingleActor,
material_policy: SourceMaterials,
static_mesh_manifest_path: None,
),
dependencies: [],
),
(
id: ("de76f1c9-f05d-48d3-bb69-6b7ce05f29d8"),
path: "assets/build_profiles/release.ron",
label: "release",
kind_tag: "Level",
import_settings: (
scale: 1.0,
generate_collider: true,
lod0_only: true,
placement_mode: StaticAsset,
hierarchy_mode: SingleActor,
material_policy: SourceMaterials,
static_mesh_manifest_path: None,
),
dependencies: [],
),
( (
id: ("7b143cfb-a9e9-45d4-8f01-cfaa66fa9811"), id: ("7b143cfb-a9e9-45d4-8f01-cfaa66fa9811"),
path: "assets/shaders/unlit.shader.ron", path: "assets/shaders/unlit.shader.ron",

BIN
assets/audio/Puzzle Lobby.mp3 (Stored with Git LFS)

Binary file not shown.

BIN
assets/audio/editor_audition_tone.ogg (Stored with Git LFS)

Binary file not shown.

View File

@ -1,10 +0,0 @@
(
version: 1,
name: "Development",
cargo_profile: Development,
target: None,
features: [],
rendering_tier: "development",
default_scene: Some("assets/levels/editor_scene.scn.ron"),
output_dir: "dist/development",
)

View File

@ -1,10 +0,0 @@
(
version: 1,
name: "QA",
cargo_profile: Qa,
target: None,
features: [],
rendering_tier: "representative",
default_scene: Some("assets/levels/editor_scene.scn.ron"),
output_dir: "dist/qa",
)

View File

@ -1,10 +0,0 @@
(
version: 1,
name: "Release",
cargo_profile: Release,
target: None,
features: [],
rendering_tier: "shipping",
default_scene: Some("assets/levels/editor_scene.scn.ron"),
output_dir: "dist/release",
)

View File

@ -1,56 +0,0 @@
(schema_version: 2,
resources: {},
entities: {
4294969001: (
components: {
"bevy_ecs::name::Name": "Audition Tone Source",
"bevy_transform::components::transform::Transform": (
translation: (0.0, 1.5, -4.0),
rotation: (0.0, 0.0, 0.0, 1.0),
scale: (1.0, 1.0, 1.0),
),
"shared::components::LevelObject": (),
"shared::components::ActorId": ("audio-showcase-source"),
"shared::components::ActorKind": AudioSource,
"shared::components::AudioSourceDesc": (
clip: Some((
asset_id: "c0a7d10e-53c1-4de0-b0dc-34a66f44ba77",
sub_asset_id: "audio:source",
label: "Editor Audition Tone",
source_path: Some("assets/audio/editor_audition_tone.ogg"),
)),
gain_db: -6.0,
pitch: 1.0,
looping: false,
autoplay: true,
spatial_blend: 1.0,
attenuation: (
min_distance: 1.0,
max_distance: 18.0,
rolloff: Inverse,
rolloff_factor: 1.0,
),
bus: "sfx",
),
},
),
4294969002: (
components: {
"bevy_ecs::name::Name": "Showcase Listener",
"bevy_transform::components::transform::Transform": (
translation: (0.0, 1.7, 0.0),
rotation: (0.0, 0.0, 0.0, 1.0),
scale: (1.0, 1.0, 1.0),
),
"shared::components::LevelObject": (),
"shared::components::ActorId": ("audio-showcase-listener"),
"shared::components::ActorKind": AudioListener,
"shared::components::AudioListenerDesc": (
enabled: true,
priority: 100,
ear_gap: 0.2,
),
},
),
},
)

View File

@ -0,0 +1,55 @@
(
schema_version: 1,
asset_id: "71071fc3-7a7f-454d-b9ab-93ebf0feccbe",
label: "metal_stool_01_2k",
source: (
path: "assets/models/metal_stool_01_2k.gltf",
format: "gltf",
fingerprint: (
byte_len: 2790,
modified_unix_secs: 1780734867,
),
dependencies: [
"assets/models/metal_stool_01.bin",
"assets/models/textures/metal_stool_01_arm_2k.jpg",
"assets/models/textures/metal_stool_01_diff_2k.jpg",
"assets/models/textures/metal_stool_01_nor_gl_2k.jpg",
],
),
import: (
scale: 1.0,
generate_collider: true,
lod0_only: true,
placement_mode: SceneInstance,
hierarchy_mode: SingleActor,
material_policy: SourceMaterials,
),
metadata: (
mesh_count: 1,
material_count: 1,
node_count: 1,
animation_count: 0,
skin_count: 0,
light_count: 0,
camera_count: 0,
),
parts: [
(
id: "mesh:mesh0_primitive0",
name: "metal_stool_01 / Primitive 0",
mesh_label: "Mesh0/Primitive0",
material_id: Some("material:material0"),
material_slot_name: "metal_stool_01",
material_label: Some("Material0"),
local_transform: (
translation: (0.0, 0.0, 0.0),
rotation: (0.0, 0.0, 0.0, 1.0),
scale: (1.0, 1.0, 1.0),
),
source_node: Some("Node0"),
source_mesh: Some("Mesh0"),
source_material: Some("metal_stool_01"),
),
],
warnings: [],
)

BIN
assets/models/metal_stool_01_2k.gltf (Stored with Git LFS) Normal file

Binary file not shown.

View File

@ -1,12 +0,0 @@
# RobotExpressive License And Attribution
`robot_expressive.glb` is the RobotExpressive model from the Three.js example assets.
- Model: Tomas Laulhe (Quaternius)
- Modifications and glTF conversion: Don McCurdy
- License: CC0 1.0 Universal
- Source: https://github.com/mrdoob/three.js/tree/dev/examples/models/gltf/RobotExpressive
- Imported source SHA-256: `047f5e5fb3bb6d378bd1df16ca6137f2a596c99b3a1b5690b4020c05aaf6f319`
The upstream modifications add facial-expression morph targets, convert the source with FBX2GLTF,
remove duplicate materials, and reduce material metalness.

BIN
assets/models/robot_expressive.glb (Stored with Git LFS)

Binary file not shown.

View File

@ -1,10 +0,0 @@
# Prefab fixtures
The `example_base` -> `example_nested` -> `example_variant` chain is the production-format
fixture for linked prefab ownership, nested stable actor paths, and variant override layering.
The variant hides `base-mesh` through an `EditorVisibility` component override targeted as
`nested-link / base-link / base-mesh` when the variant is placed in a scene.
Keep these files loadable by the editor and valid under `cargo validate-levels`. Broken-source,
cycle, and conflict fixtures belong in temporary test projects so the shipped asset tree remains
healthy.

View File

@ -1,19 +0,0 @@
(schema_version: 2,
resources: {},
entities: {
1: (
components: {
"bevy_ecs::name::Name": "Example Base Mesh",
"bevy_transform::components::transform::Transform": (
translation: (0.0, 0.0, 0.0),
rotation: (0.0, 0.0, 0.0, 1.0),
scale: (1.0, 1.0, 1.0),
),
"shared::components::ActorId": ("base-mesh"),
"shared::components::ActorKind": Empty,
"shared::components::EditorVisibility": (visible: true),
"shared::components::LevelObject": (),
},
),
},
)

View File

@ -1,23 +0,0 @@
(schema_version: 2,
resources: {},
entities: {
1: (
components: {
"bevy_ecs::name::Name": "Example Base Link",
"bevy_transform::components::transform::Transform": (
translation: (0.0, 0.0, 0.0),
rotation: (0.0, 0.0, 0.0, 1.0),
scale: (1.0, 1.0, 1.0),
),
"shared::components::ActorId": ("base-link"),
"shared::components::ActorKind": PrefabAnchor,
"shared::components::LevelObject": (),
"shared::components::PrefabInstance": (
asset_id: "11111111-1111-4111-8111-111111111111",
source_path: "assets/prefabs/example_base.scn.ron",
overrides_ron: None,
),
},
),
},
)

View File

@ -1,23 +0,0 @@
(schema_version: 2,
resources: {},
entities: {
1: (
components: {
"bevy_ecs::name::Name": "Example Nested Variant Link",
"bevy_transform::components::transform::Transform": (
translation: (0.0, 0.0, 0.0),
rotation: (0.0, 0.0, 0.0, 1.0),
scale: (1.0, 1.0, 1.0),
),
"shared::components::ActorId": ("nested-link"),
"shared::components::ActorKind": PrefabAnchor,
"shared::components::LevelObject": (),
"shared::components::PrefabInstance": (
asset_id: "22222222-2222-4222-8222-222222222222",
source_path: "assets/prefabs/example_nested.scn.ron",
overrides_ron: Some("(format_version:2,source_revision:None,transform:None,material:None,child_visibility:{},properties:[],components:[(target:(instance_chain:[\"base-link\"],actor_id:\"base-mesh\"),component_type:\"shared::components::EditorVisibility\",base_component_ron:Some(\"(visible:true)\"),value_component_ron:Some(\"(visible:false)\"))],structural:[])"),
),
},
),
},
)

View File

@ -1,13 +1,8 @@
( (
version: 1, version: 1,
project_id: "blacksite-foundation",
name: "Bevy FPS Foundation", name: "Bevy FPS Foundation",
project_kind: Game,
template_version: 1,
capabilities: ["editor", "gameplay", "rendering", "audio", "solari"],
default_level: "assets/levels/editor_scene.scn.ron", default_level: "assets/levels/editor_scene.scn.ron",
asset_roots: [ asset_roots: [
"assets/audio",
"assets/models", "assets/models",
"assets/textures", "assets/textures",
"assets/materials", "assets/materials",
@ -34,31 +29,6 @@
shadow_first_cascade: 6.0, shadow_first_cascade: 6.0,
shadow_max_distance: 220.0, shadow_max_distance: 220.0,
), ),
audio: (
buses: [
(
id: "master",
label: "Master",
parent: None,
gain_db: 0.0,
muted: false,
),
(
id: "music",
label: "Music",
parent: Some("master"),
gain_db: 0.0,
muted: false,
),
(
id: "sfx",
label: "SFX",
parent: Some("master"),
gain_db: 0.0,
muted: false,
),
],
),
physics: ( physics: (
player_radius: 0.4, player_radius: 0.4,
player_length: 1.0, player_length: 1.0,

View File

@ -4,7 +4,6 @@ version.workspace = true
edition.workspace = true edition.workspace = true
license.workspace = true license.workspace = true
description = "In-process level editor for the Bevy FPS foundation" description = "In-process level editor for the Bevy FPS foundation"
default-run = "editor"
[lib] [lib]
name = "editor" name = "editor"
@ -14,10 +13,6 @@ path = "src/lib.rs"
name = "editor" name = "editor"
path = "src/bin/main.rs" path = "src/bin/main.rs"
[[bin]]
name = "project_launcher"
path = "src/bin/project_launcher.rs"
[features] [features]
dev = ["bevy/dynamic_linking", "game/dev"] dev = ["bevy/dynamic_linking", "game/dev"]
hot-reload = ["game/hot-reload", "dep:hot-lib-reloader", "dep:notify"] hot-reload = ["game/hot-reload", "dep:hot-lib-reloader", "dep:notify"]
@ -42,11 +37,9 @@ sim.workspace = true
settings.workspace = true settings.workspace = true
scene.workspace = true scene.workspace = true
serde.workspace = true serde.workspace = true
serde_json = "1"
transform-gizmo-bevy.workspace = true transform-gizmo-bevy.workspace = true
rfd = "0.17.2" rfd = "0.17.2"
ron = "0.8" ron = "0.8"
uuid = { version = "1", features = ["v4", "serde"] } uuid = { version = "1", features = ["v4", "serde"] }
walkdir = "2.5" walkdir = "2.5"
gltf = { version = "1.4", features = ["extras"] } gltf = "1.4"
blake3 = "1"

View File

@ -1,833 +0,0 @@
//! Generated animation artifacts for imported model sources.
use std::collections::BTreeSet;
use std::fs;
use std::path::Path;
use std::time::UNIX_EPOCH;
use serde::Deserialize;
use shared::{
animation_clip_sub_asset_id, animation_skeleton_sub_asset_id, AnimationClipRecord,
AnimationDiagnosticSeverity, AnimationEventDesc, AnimationImportDiagnostic, AnimationManifest,
AnimationManifestSource, AnimationSkeletonRecord, AnimationSkeletonSignature,
AnimationSourceFingerprint, ANIMATION_ARTIFACT_DIR, ANIMATION_MANIFEST_SCHEMA_VERSION,
};
use crate::asset_db::AssetRecord;
pub fn animation_manifest_path(asset_id: &str) -> String {
format!("{ANIMATION_ARTIFACT_DIR}/{asset_id}.animation.ron")
}
pub fn refresh_animation_artifact(record: &mut AssetRecord) -> Result<AnimationManifest, String> {
let mut manifest = build_animation_manifest(record)?;
manifest.source.dependencies.sort();
manifest.source.dependencies.dedup();
let path = animation_manifest_path(&record.id.as_string());
record.import_settings.animation_manifest_path = Some(path.clone());
record
.dependencies
.extend(manifest.source.dependencies.iter().cloned());
record.dependencies.sort();
record.dependencies.dedup();
let text = ron::ser::to_string_pretty(&manifest, ron::ser::PrettyConfig::default())
.map_err(|error| format!("could not serialize animation manifest: {error}"))?;
if let Some(parent) = Path::new(&path).parent() {
fs::create_dir_all(parent)
.map_err(|error| format!("could not create {}: {error}", parent.display()))?;
}
if fs::read_to_string(&path).ok().as_deref() != Some(text.as_str()) {
fs::write(&path, text)
.map_err(|error| format!("could not write animation manifest {path}: {error}"))?;
bevy::log::info!(
"Animation manifest refreshed: source={} artifact={} skeletons={} clips={} runtime_supported={}",
record.path,
path,
manifest.skeletons.len(),
manifest.clips.len(),
manifest.runtime_supported
);
}
Ok(manifest)
}
pub fn load_animation_manifest(path: &str) -> Result<AnimationManifest, String> {
let text =
fs::read_to_string(path).map_err(|error| format!("could not read {path}: {error}"))?;
ron::from_str(&text).map_err(|error| format!("could not parse {path}: {error}"))
}
pub fn build_animation_manifest(record: &AssetRecord) -> Result<AnimationManifest, String> {
let bytes = fs::read(&record.path)
.map_err(|error| format!("could not read {}: {error}", record.path))?;
let fingerprint = source_fingerprint(&record.path, &bytes)?;
let format = source_format(&record.path)?;
match format.as_str() {
"gltf" | "glb" => build_gltf_manifest(record, format, fingerprint, &bytes),
"fbx" => build_fbx_manifest(record, format, fingerprint, &bytes),
_ => Err(format!("unsupported animation source format `{format}`")),
}
}
#[derive(Debug, Clone)]
struct SkeletonCandidate {
compatible_nodes: BTreeSet<usize>,
signature: AnimationSkeletonSignature,
}
fn build_gltf_manifest(
record: &AssetRecord,
format: String,
fingerprint: AnimationSourceFingerprint,
bytes: &[u8],
) -> Result<AnimationManifest, String> {
let gltf = gltf::Gltf::from_slice(bytes)
.map_err(|error| format!("could not parse glTF {}: {error}", record.path))?;
let base = Path::new(&record.path).parent();
let buffers = gltf::import_buffers(&gltf.document, base, gltf.blob.clone())
.map_err(|error| format!("could not load glTF buffers for {}: {error}", record.path))?;
let mut dependencies = gltf
.document
.buffers()
.filter_map(|buffer| match buffer.source() {
gltf::buffer::Source::Uri(uri) if !uri.starts_with("data:") => {
Some(resolve_dependency(&record.path, uri))
}
_ => None,
})
.collect::<Vec<_>>();
dependencies.sort();
dependencies.dedup();
let node_identity_names = gltf
.document
.nodes()
.map(|node| bevy_node_segment(node.name(), node.index()))
.collect::<Vec<_>>();
let readable_node_names = gltf
.document
.nodes()
.map(|node| normalized_node_segment(node.name(), node.index()))
.collect::<Vec<_>>();
let mut parents = vec![None; node_identity_names.len()];
for node in gltf.document.nodes() {
for child in node.children() {
parents[child.index()] = Some(node.index());
}
}
let mut skeletons = Vec::new();
let mut candidates = Vec::new();
for skin in gltf.document.skins() {
let source_index = skin.index();
let label = skin
.name()
.filter(|name| !name.trim().is_empty())
.map(str::to_string)
.unwrap_or_else(|| format!("Skeleton {source_index}"));
let joint_indices = skin.joints().map(|joint| joint.index()).collect::<Vec<_>>();
let joint_identity_paths = joint_indices
.iter()
.map(|index| node_path_segments(*index, &node_identity_names, &parents))
.collect::<Vec<_>>();
let joint_paths = joint_indices
.iter()
.map(|index| node_path(*index, &readable_node_names, &parents))
.collect::<Vec<_>>();
let bind_poses = skin
.reader(|buffer| Some(buffers[buffer.index()].0.as_slice()))
.read_inverse_bind_matrices()
.map(|matrices| matrices.map(gltf_matrix_bytes).collect::<Vec<_>>())
.unwrap_or_else(|| vec![identity_matrix_bytes(); joint_paths.len()]);
let signature = skeleton_signature(&joint_identity_paths, &bind_poses);
let mut compatible_nodes = joint_indices.into_iter().collect::<BTreeSet<_>>();
if let Some(root) = skin.skeleton() {
compatible_nodes.insert(root.index());
}
candidates.push(SkeletonCandidate {
compatible_nodes,
signature: signature.clone(),
});
skeletons.push(AnimationSkeletonRecord {
id: animation_skeleton_sub_asset_id(source_index, &label),
label,
source_index,
signature,
joint_paths,
});
}
let mut diagnostics = Vec::new();
let mut clips = Vec::new();
let mut animation_roots = BTreeSet::new();
for animation in gltf.document.animations() {
let source_index = animation.index();
let label = animation
.name()
.filter(|name| !name.trim().is_empty())
.map(str::to_string)
.unwrap_or_else(|| format!("Animation {source_index}"));
let mut duration_seconds = 0.0_f32;
let mut target_nodes = BTreeSet::new();
for channel in animation.channels() {
let target_index = channel.target().node().index();
target_nodes.insert(target_index);
animation_roots.insert(top_level_node(target_index, &parents));
if let Some(inputs) = channel
.reader(|buffer| Some(buffers[buffer.index()].0.as_slice()))
.read_inputs()
{
for input in inputs {
if input.is_finite() {
duration_seconds = duration_seconds.max(input);
}
}
}
}
let target_skeleton_signature = matching_skeleton_signature(&target_nodes, &candidates);
if !candidates.is_empty() && target_skeleton_signature.is_none() {
diagnostics.push(AnimationImportDiagnostic {
severity: AnimationDiagnosticSeverity::Warning,
code: "animation.clip_skeleton_unresolved".into(),
message: format!(
"clip `{label}` does not target a uniquely identifiable imported skeleton"
),
repair: "Export the clip with channels targeting one skeleton, or split unrelated rigs into separate glTF assets.".into(),
});
}
let events = gltf_animation_events(&animation, duration_seconds, &label, &mut diagnostics);
clips.push(AnimationClipRecord {
id: animation_clip_sub_asset_id(source_index, &label),
label,
source_index,
duration_seconds,
target_skeleton_signature,
events,
});
}
let runtime_supported = animation_roots.len() <= 1;
if !runtime_supported {
let roots = animation_roots
.iter()
.map(|index| format!("`{}` (node {index})", node_identity_names[*index]))
.collect::<Vec<_>>()
.join(", ");
diagnostics.push(AnimationImportDiagnostic {
severity: AnimationDiagnosticSeverity::Error,
code: "animation.multiple_roots_unsupported".into(),
message: format!(
"animation channels target {} distinct top-level roots ({roots}); Bevy requires a separate AnimationPlayer for each root",
animation_roots.len()
),
repair: "Split the source into one animated rig per glTF asset, or export every clip under one common top-level root, then reimport.".into(),
});
}
Ok(AnimationManifest {
schema_version: ANIMATION_MANIFEST_SCHEMA_VERSION,
asset_id: record.id.as_string(),
label: record.label.clone(),
source: AnimationManifestSource {
path: record.path.clone(),
format,
fingerprint,
dependencies,
},
runtime_supported,
skeletons,
clips,
diagnostics,
})
}
fn build_fbx_manifest(
record: &AssetRecord,
format: String,
fingerprint: AnimationSourceFingerprint,
bytes: &[u8],
) -> Result<AnimationManifest, String> {
let scene = ufbx::load_memory(
bytes,
ufbx::LoadOpts {
target_unit_meters: 1.0,
target_axes: ufbx::CoordinateAxes::right_handed_y_up(),
..Default::default()
},
)
.map_err(|error| format!("could not parse FBX {}: {error:?}", record.path))?;
let mut skeletons = Vec::new();
for (source_index, skin) in scene.skin_deformers.as_ref().iter().enumerate() {
let label = if skin.element.name.is_empty() {
format!("Skeleton {source_index}")
} else {
skin.element.name.to_string()
};
let mut joint_identity_paths = Vec::new();
let mut joint_paths = Vec::new();
let mut bind_poses = Vec::new();
for cluster in skin.clusters.as_ref() {
let Some(bone) = cluster.bone_node.as_ref() else {
continue;
};
let segments = fbx_node_path_segments(bone.as_ref());
joint_paths.push(segments.join("/"));
joint_identity_paths.push(segments);
bind_poses.push(fbx_matrix_bytes(&cluster.bind_to_world));
}
let signature = skeleton_signature(&joint_identity_paths, &bind_poses);
skeletons.push(AnimationSkeletonRecord {
id: animation_skeleton_sub_asset_id(source_index, &label),
label,
source_index,
signature,
joint_paths,
});
}
let fallback_signature = (skeletons.len() == 1).then(|| skeletons[0].signature.clone());
let clips = scene
.anim_stacks
.as_ref()
.iter()
.enumerate()
.map(|(source_index, stack)| {
let label = if stack.element.name.is_empty() {
format!("Animation {source_index}")
} else {
stack.element.name.to_string()
};
AnimationClipRecord {
id: animation_clip_sub_asset_id(source_index, &label),
label,
source_index,
duration_seconds: (stack.time_end - stack.time_begin).max(0.0) as f32,
target_skeleton_signature: fallback_signature.clone(),
events: Vec::new(),
}
})
.collect::<Vec<_>>();
let (runtime_supported, diagnostics) = fbx_runtime_support(
scene.anim_stacks.as_ref().len(),
scene.skin_deformers.as_ref().len(),
);
Ok(AnimationManifest {
schema_version: ANIMATION_MANIFEST_SCHEMA_VERSION,
asset_id: record.id.as_string(),
label: record.label.clone(),
source: AnimationManifestSource {
path: record.path.clone(),
format,
fingerprint,
dependencies: Vec::new(),
},
runtime_supported,
skeletons,
clips,
diagnostics,
})
}
fn fbx_runtime_support(
animation_count: usize,
skin_count: usize,
) -> (bool, Vec<AnimationImportDiagnostic>) {
let mut diagnostics = Vec::new();
if animation_count > 0 || skin_count > 0 {
diagnostics.push(AnimationImportDiagnostic {
severity: AnimationDiagnosticSeverity::Error,
code: "animation.fbx_runtime_unsupported".into(),
message: format!(
"FBX contains {animation_count} animation stack(s) and {skin_count} skin deformer(s), but Blacksite's current FBX loader cannot build a runtime skeleton or clips"
),
repair: "Export the animated source as glTF 2.0 (.glb or .gltf). Keep FBX only for static mesh import until hierarchy, SkinnedMesh, and AnimationClip conversion are implemented.".into(),
});
}
(false, diagnostics)
}
#[derive(Debug, Default, Deserialize)]
struct GltfAnimationExtras {
#[serde(default)]
blacksite_events: Vec<GltfAnimationEvent>,
}
#[derive(Debug, Deserialize)]
struct GltfAnimationEvent {
id: String,
time_seconds: f32,
#[serde(default)]
payload: Option<String>,
}
fn gltf_animation_events(
animation: &gltf::Animation<'_>,
duration_seconds: f32,
clip_label: &str,
diagnostics: &mut Vec<AnimationImportDiagnostic>,
) -> Vec<AnimationEventDesc> {
let Some(raw) = animation.extras().as_ref() else {
return Vec::new();
};
let extras = match serde_json::from_str::<GltfAnimationExtras>(raw.get()) {
Ok(extras) => extras,
Err(error) => {
diagnostics.push(AnimationImportDiagnostic {
severity: AnimationDiagnosticSeverity::Warning,
code: "animation.events_invalid".into(),
message: format!("clip `{clip_label}` has invalid animation event metadata: {error}"),
repair: "Use an animation extras object with `blacksite_events` entries containing `id`, `time_seconds`, and optional string `payload`.".into(),
});
return Vec::new();
}
};
let mut events = Vec::new();
for event in extras.blacksite_events {
let valid_time = event.time_seconds.is_finite()
&& event.time_seconds >= 0.0
&& event.time_seconds <= duration_seconds;
if event.id.trim().is_empty() || !valid_time {
diagnostics.push(AnimationImportDiagnostic {
severity: AnimationDiagnosticSeverity::Warning,
code: "animation.event_out_of_range".into(),
message: format!(
"clip `{clip_label}` contains an event with an empty ID or time outside 0..={duration_seconds:.3}s"
),
repair: "Give every event a stable non-empty ID and place it within the imported clip duration.".into(),
});
continue;
}
events.push(AnimationEventDesc {
id: event.id,
time_seconds: event.time_seconds,
payload: event.payload,
});
}
events.sort_by(|left, right| {
left.time_seconds
.total_cmp(&right.time_seconds)
.then(left.id.cmp(&right.id))
.then(left.payload.cmp(&right.payload))
});
events
}
fn matching_skeleton_signature(
target_nodes: &BTreeSet<usize>,
candidates: &[SkeletonCandidate],
) -> Option<AnimationSkeletonSignature> {
let scored = candidates
.iter()
.map(|candidate| {
(
candidate
.compatible_nodes
.intersection(target_nodes)
.count(),
candidate,
)
})
.filter(|(score, _)| *score > 0)
.collect::<Vec<_>>();
let best_score = scored.iter().map(|(score, _)| *score).max()?;
let mut best = scored
.into_iter()
.filter(|(score, _)| *score == best_score)
.map(|(_, candidate)| candidate);
let candidate = best.next()?;
best.all(|other| other.signature == candidate.signature)
.then(|| candidate.signature.clone())
}
fn node_path(index: usize, names: &[String], parents: &[Option<usize>]) -> String {
node_path_segments(index, names, parents).join("/")
}
fn node_path_segments(index: usize, names: &[String], parents: &[Option<usize>]) -> Vec<String> {
let mut segments = Vec::new();
let mut current = Some(index);
while let Some(node_index) = current {
segments.push(names[node_index].clone());
current = parents[node_index];
}
segments.reverse();
segments
}
fn top_level_node(index: usize, parents: &[Option<usize>]) -> usize {
let mut current = index;
while let Some(parent) = parents[current] {
current = parent;
}
current
}
fn bevy_node_segment(name: Option<&str>, index: usize) -> String {
name.map(str::to_string)
.unwrap_or_else(|| format!("GltfNode{index}"))
}
fn normalized_node_segment(name: Option<&str>, index: usize) -> String {
name.map(str::trim)
.filter(|name| !name.is_empty())
.map(|name| name.replace(['/', '\\'], "_"))
.unwrap_or_else(|| format!("Node{index}"))
}
fn fbx_node_path_segments(node: &ufbx::Node) -> Vec<String> {
let mut segments = Vec::new();
let mut current = Some(node);
while let Some(node) = current {
segments.push(normalized_node_segment(
(!node.element.name.is_empty()).then(|| node.element.name.as_ref()),
node.element.element_id as usize,
));
current = node.parent.as_ref().map(|parent| parent.as_ref());
}
segments.reverse();
segments
}
fn skeleton_signature(
joint_path_segments: &[Vec<String>],
bind_pose_bytes: &[Vec<u8>],
) -> AnimationSkeletonSignature {
let mut hasher = blake3::Hasher::new();
hasher.update(b"blacksite-animation-skeleton-v2\0");
hasher.update(&(joint_path_segments.len() as u64).to_le_bytes());
for path in joint_path_segments {
hasher.update(&(path.len() as u64).to_le_bytes());
for segment in path {
let bytes = segment.as_bytes();
hasher.update(&(bytes.len() as u64).to_le_bytes());
hasher.update(bytes);
}
}
hasher.update(&(bind_pose_bytes.len() as u64).to_le_bytes());
for bytes in bind_pose_bytes {
hasher.update(&(bytes.len() as u64).to_le_bytes());
hasher.update(bytes);
}
AnimationSkeletonSignature::new(hasher.finalize().to_hex().to_string())
}
fn gltf_matrix_bytes(matrix: [[f32; 4]; 4]) -> Vec<u8> {
matrix
.into_iter()
.flatten()
.flat_map(f32::to_le_bytes)
.collect()
}
fn identity_matrix_bytes() -> Vec<u8> {
gltf_matrix_bytes([
[1.0, 0.0, 0.0, 0.0],
[0.0, 1.0, 0.0, 0.0],
[0.0, 0.0, 1.0, 0.0],
[0.0, 0.0, 0.0, 1.0],
])
}
fn fbx_matrix_bytes(matrix: &ufbx::Matrix) -> Vec<u8> {
[
matrix.m00, matrix.m10, matrix.m20, matrix.m01, matrix.m11, matrix.m21, matrix.m02,
matrix.m12, matrix.m22, matrix.m03, matrix.m13, matrix.m23,
]
.into_iter()
.flat_map(f64::to_le_bytes)
.collect()
}
fn source_fingerprint(path: &str, bytes: &[u8]) -> Result<AnimationSourceFingerprint, String> {
let metadata = fs::metadata(path).map_err(|error| format!("could not stat {path}: {error}"))?;
let modified_unix_secs = metadata
.modified()
.ok()
.and_then(|time| time.duration_since(UNIX_EPOCH).ok())
.map(|duration| duration.as_secs())
.unwrap_or_default();
Ok(AnimationSourceFingerprint {
byte_len: metadata.len(),
modified_unix_secs,
content_hash: blake3::hash(bytes).to_hex().to_string(),
})
}
fn source_format(path: &str) -> Result<String, String> {
Path::new(path)
.extension()
.and_then(|extension| extension.to_str())
.map(|extension| extension.to_ascii_lowercase())
.ok_or_else(|| format!("asset path `{path}` has no extension"))
}
fn resolve_dependency(source_path: &str, uri: &str) -> String {
Path::new(source_path)
.parent()
.unwrap_or_else(|| Path::new(""))
.join(uri)
.to_string_lossy()
.replace('\\', "/")
}
#[cfg(test)]
mod tests {
use super::*;
use crate::asset_db::{AssetId, ImportSettings};
use uuid::Uuid;
fn fixture_record(path: &Path) -> AssetRecord {
AssetRecord {
id: AssetId(Uuid::nil()),
path: path.to_string_lossy().replace('\\', "/"),
label: "Animated Hero".into(),
kind_tag: "Model".into(),
import_settings: ImportSettings::default(),
dependencies: Vec::new(),
}
}
fn write_animated_gltf(root: &Path) -> std::path::PathBuf {
fs::create_dir_all(root).unwrap();
let mut buffer = Vec::new();
for value in [0.0_f32, 1.25] {
buffer.extend_from_slice(&value.to_le_bytes());
}
for value in [0.0_f32, 0.0, 0.0, 1.0, 0.0, 0.38268343, 0.0, 0.9238795] {
buffer.extend_from_slice(&value.to_le_bytes());
}
fs::write(root.join("animated.bin"), buffer).unwrap();
let gltf = r#"{
"asset":{"version":"2.0"},
"scene":0,
"scenes":[{"nodes":[0]}],
"nodes":[{"name":"Armature","children":[1]},{"name":"Hip"}],
"skins":[{"name":"Hero Rig","skeleton":0,"joints":[1]}],
"buffers":[{"uri":"animated.bin","byteLength":40}],
"bufferViews":[
{"buffer":0,"byteOffset":0,"byteLength":8},
{"buffer":0,"byteOffset":8,"byteLength":32}
],
"accessors":[
{"bufferView":0,"componentType":5126,"count":2,"type":"SCALAR","min":[0.0],"max":[1.25]},
{"bufferView":1,"componentType":5126,"count":2,"type":"VEC4"}
],
"animations":[{
"name":"Idle Loop",
"samplers":[{"input":0,"output":1,"interpolation":"LINEAR"}],
"channels":[{"sampler":0,"target":{"node":1,"path":"rotation"}}],
"extras":{"blacksite_events":[{"id":"footstep.left","time_seconds":0.5,"payload":"stone"}]}
}]
}"#;
let path = root.join("animated.gltf");
fs::write(&path, gltf).unwrap();
path
}
fn write_multi_root_animated_gltf(root: &Path) -> std::path::PathBuf {
fs::create_dir_all(root).unwrap();
let mut buffer = Vec::new();
for value in [0.0_f32, 1.0] {
buffer.extend_from_slice(&value.to_le_bytes());
}
for value in [0.0_f32, 0.0, 0.0, 1.0, 0.0, 0.38268343, 0.0, 0.9238795] {
buffer.extend_from_slice(&value.to_le_bytes());
}
fs::write(root.join("multi-root.bin"), buffer).unwrap();
let gltf = r#"{
"asset":{"version":"2.0"},
"scene":0,
"scenes":[{"nodes":[0,2]}],
"nodes":[
{"name":"Character A","children":[1]},
{"name":"Joint A"},
{"name":"Character B","children":[3]},
{"name":"Joint B"}
],
"buffers":[{"uri":"multi-root.bin","byteLength":40}],
"bufferViews":[
{"buffer":0,"byteOffset":0,"byteLength":8},
{"buffer":0,"byteOffset":8,"byteLength":32}
],
"accessors":[
{"bufferView":0,"componentType":5126,"count":2,"type":"SCALAR","min":[0.0],"max":[1.0]},
{"bufferView":1,"componentType":5126,"count":2,"type":"VEC4"}
],
"animations":[{
"name":"Two Players Required",
"samplers":[
{"input":0,"output":1,"interpolation":"LINEAR"},
{"input":0,"output":1,"interpolation":"LINEAR"}
],
"channels":[
{"sampler":0,"target":{"node":1,"path":"rotation"}},
{"sampler":1,"target":{"node":3,"path":"rotation"}}
]
}]
}"#;
let path = root.join("multi-root.gltf");
fs::write(&path, gltf).unwrap();
path
}
#[test]
fn animation_manifest_path_uses_registry_uuid() {
assert_eq!(
animation_manifest_path("abc"),
"assets/animations/generated/abc.animation.ron"
);
}
#[test]
fn gltf_extraction_is_deterministic_and_preserves_duration_events_and_signature() {
let root = std::env::temp_dir().join(format!("blacksite-animation-{}", Uuid::new_v4()));
let path = write_animated_gltf(&root);
let record = fixture_record(&path);
let first = build_animation_manifest(&record).unwrap();
let second = build_animation_manifest(&record).unwrap();
assert_eq!(first, second);
assert_eq!(first.schema_version, 2);
assert!(first.runtime_supported);
assert_eq!(first.source.dependencies.len(), 1);
assert_eq!(first.skeletons.len(), 1);
assert_eq!(first.clips.len(), 1);
assert_eq!(first.skeletons[0].id, "animation:skeleton:0:hero_rig");
assert!(!first.skeletons[0].signature.is_empty());
assert_eq!(first.clips[0].id, "animation:clip:0:idle_loop");
assert_eq!(first.clips[0].duration_seconds, 1.25);
assert_eq!(
first.clips[0].target_skeleton_signature,
Some(first.skeletons[0].signature.clone())
);
assert_eq!(first.clips[0].events.len(), 1);
assert_eq!(first.clips[0].events[0].id, "footstep.left");
assert_eq!(first.clips[0].events[0].time_seconds, 0.5);
assert_eq!(first.diagnostics, Vec::new());
fs::remove_dir_all(root).unwrap();
}
#[test]
fn exact_signature_changes_with_joint_path_or_bind_pose() {
let identity = identity_matrix_bytes();
let first = skeleton_signature(
&[vec!["Armature".into(), "Hip".into()]],
std::slice::from_ref(&identity),
);
let renamed = skeleton_signature(
&[vec!["Armature".into(), "Pelvis".into()]],
std::slice::from_ref(&identity),
);
let mut changed_pose = identity;
changed_pose[0] ^= 1;
let rebound = skeleton_signature(&[vec!["Armature".into(), "Hip".into()]], &[changed_pose]);
assert_ne!(first, renamed);
assert_ne!(first, rebound);
}
#[test]
fn exact_signature_preserves_bevy_node_bytes_and_segment_boundaries() {
let identity = identity_matrix_bytes();
let slash_in_name = vec![vec!["Armature".into(), "Hip/Joint".into()]];
let slash_as_boundary = vec![vec!["Armature/Hip".into(), "Joint".into()]];
assert_eq!(slash_in_name[0].join("/"), slash_as_boundary[0].join("/"));
assert_ne!(
skeleton_signature(&slash_in_name, std::slice::from_ref(&identity)),
skeleton_signature(&slash_as_boundary, std::slice::from_ref(&identity))
);
let backslash = vec![vec!["Armature".into(), "Hip\\Joint".into()]];
assert_eq!(
normalized_node_segment(Some("Hip/Joint"), 1),
normalized_node_segment(Some("Hip\\Joint"), 1)
);
assert_ne!(
skeleton_signature(&slash_in_name, std::slice::from_ref(&identity)),
skeleton_signature(&backslash, std::slice::from_ref(&identity))
);
assert_eq!(bevy_node_segment(Some(" /Joint "), 7), " /Joint ");
assert_eq!(bevy_node_segment(None, 7), "GltfNode7");
}
#[test]
fn gltf_with_multiple_animation_roots_is_runtime_unsupported() {
let root =
std::env::temp_dir().join(format!("blacksite-animation-multi-root-{}", Uuid::new_v4()));
let path = write_multi_root_animated_gltf(&root);
let manifest = build_animation_manifest(&fixture_record(&path)).unwrap();
assert!(!manifest.runtime_supported);
let diagnostic = manifest
.diagnostics
.iter()
.find(|diagnostic| diagnostic.code == "animation.multiple_roots_unsupported")
.expect("multi-root glTF should include an actionable blocking diagnostic");
assert_eq!(diagnostic.severity, AnimationDiagnosticSeverity::Error);
assert!(diagnostic.message.contains("2 distinct top-level roots"));
assert!(diagnostic.repair.contains("one common top-level root"));
fs::remove_dir_all(root).unwrap();
}
#[test]
fn animated_or_skinned_fbx_is_runtime_unsupported_with_repair_guidance() {
let (supported, diagnostics) = fbx_runtime_support(2, 1);
assert!(!supported);
assert_eq!(diagnostics.len(), 1);
assert_eq!(diagnostics[0].severity, AnimationDiagnosticSeverity::Error);
assert_eq!(diagnostics[0].code, "animation.fbx_runtime_unsupported");
assert!(diagnostics[0].repair.contains("glTF 2.0"));
let (supported, diagnostics) = fbx_runtime_support(0, 0);
assert!(!supported);
assert!(diagnostics.is_empty());
}
#[test]
fn committed_robot_fixture_exposes_a_skin_and_multiple_named_states() {
let path =
Path::new(env!("CARGO_MANIFEST_DIR")).join("../../assets/models/robot_expressive.glb");
let manifest = build_animation_manifest(&fixture_record(&path)).unwrap();
assert!(manifest.runtime_supported);
assert_eq!(manifest.skeletons.len(), 2);
assert_eq!(manifest.skeletons[0].id, "animation:skeleton:0:skeleton_0");
assert_eq!(manifest.skeletons[1].id, "animation:skeleton:1:skeleton_1");
assert!(manifest
.skeletons
.iter()
.all(|skeleton| skeleton.joint_paths.len() == 43));
let signature = &manifest.skeletons[0].signature;
assert!(manifest
.skeletons
.iter()
.all(|skeleton| &skeleton.signature == signature));
assert_eq!(manifest.clips.len(), 14);
for expected in ["Idle", "Walking", "Running", "Jump", "Wave"] {
assert!(
manifest.clips.iter().any(|clip| clip.label == expected),
"missing expected fixture clip {expected}"
);
}
assert!(manifest
.clips
.iter()
.all(|clip| clip.duration_seconds.is_finite() && clip.duration_seconds > 0.0));
assert!(manifest
.clips
.iter()
.all(|clip| { clip.target_skeleton_signature.as_ref() == Some(signature) }));
assert!(manifest.diagnostics.is_empty());
}
}

View File

@ -2,7 +2,7 @@
use bevy::prelude::*; use bevy::prelude::*;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::collections::{HashMap, HashSet}; use std::collections::HashMap;
use uuid::Uuid; use uuid::Uuid;
/// Stable asset identity for dependency tracking and prefab references. /// Stable asset identity for dependency tracking and prefab references.
@ -59,8 +59,6 @@ pub struct ImportSettings {
pub material_policy: MaterialImportPolicy, pub material_policy: MaterialImportPolicy,
#[serde(default)] #[serde(default)]
pub static_mesh_manifest_path: Option<String>, pub static_mesh_manifest_path: Option<String>,
#[serde(default)]
pub animation_manifest_path: Option<String>,
} }
impl Default for ImportSettings { impl Default for ImportSettings {
@ -73,7 +71,6 @@ impl Default for ImportSettings {
hierarchy_mode: ModelHierarchyMode::default(), hierarchy_mode: ModelHierarchyMode::default(),
material_policy: MaterialImportPolicy::default(), material_policy: MaterialImportPolicy::default(),
static_mesh_manifest_path: None, static_mesh_manifest_path: None,
animation_manifest_path: None,
} }
} }
} }
@ -118,11 +115,6 @@ fn sync_registry_from_browser(
.drain(..) .drain(..)
.map(|record| (record.path.clone(), record)) .map(|record| (record.path.clone(), record))
.collect(); .collect();
let current_paths = assets
.assets
.iter()
.filter_map(|asset| asset.path.clone())
.collect::<HashSet<_>>();
let mut next_records = Vec::new(); let mut next_records = Vec::new();
let mut seen_paths = HashMap::new(); let mut seen_paths = HashMap::new();
@ -137,18 +129,7 @@ fn sync_registry_from_browser(
seen_paths.insert(path.clone(), ()); seen_paths.insert(path.clone(), ());
let kind_tag = format!("{:?}", asset.kind); let kind_tag = format!("{:?}", asset.kind);
let mut record = if let Some(mut prior) = existing.remove(&path).or_else(|| { let mut record = if let Some(mut prior) = existing.remove(&path) {
take_uniquely_moved_model_record(&mut existing, &current_paths, &path, &kind_tag)
}) {
if prior.path != path {
info!(
"Asset registry preserved model identity {} across move {} -> {}",
prior.id.as_string(),
prior.path,
path
);
prior.path = path.clone();
}
prior.label = asset.label.clone(); prior.label = asset.label.clone();
prior.kind_tag = kind_tag; prior.kind_tag = kind_tag;
prior prior
@ -163,9 +144,9 @@ fn sync_registry_from_browser(
} }
}; };
if record.kind_tag == "Model" { if record.kind_tag == "Model" {
if let Err(error) = super::refresh_model_artifacts(&mut record) { if let Err(error) = super::static_mesh::refresh_static_mesh_artifact(&mut record) {
warn!( warn!(
"Model artifact refresh failed for {} (asset id {}): {error}", "Static mesh processor failed for {} (asset id {}): {error}",
record.path, record.path,
record.id.as_string() record.id.as_string()
); );
@ -189,34 +170,6 @@ fn sync_registry_from_browser(
} }
} }
fn take_uniquely_moved_model_record(
existing: &mut HashMap<String, AssetRecord>,
current_paths: &HashSet<String>,
new_path: &str,
kind_tag: &str,
) -> Option<AssetRecord> {
if kind_tag != "Model" {
return None;
}
let bytes = std::fs::read(new_path).ok()?;
let content_hash = blake3::hash(&bytes).to_hex().to_string();
let candidates = existing
.iter()
.filter_map(|(old_path, record)| {
if current_paths.contains(old_path) || record.kind_tag != "Model" {
return None;
}
let manifest_path = record.import_settings.animation_manifest_path.as_deref()?;
let manifest = super::animation::load_animation_manifest(manifest_path).ok()?;
(manifest.source.fingerprint.content_hash == content_hash).then(|| old_path.clone())
})
.collect::<Vec<_>>();
if candidates.len() != 1 {
return None;
}
existing.remove(&candidates[0])
}
pub fn find_asset_by_path(registry: &AssetRegistry, path: &str) -> Option<AssetRecord> { pub fn find_asset_by_path(registry: &AssetRegistry, path: &str) -> Option<AssetRecord> {
registry registry
.records .records
@ -243,34 +196,6 @@ pub fn find_asset_mut_by_path<'a>(
.find(|record| record.path == path) .find(|record| record.path == path)
} }
pub fn ensure_asset_record(
registry: &mut AssetRegistry,
path: impl Into<String>,
label: impl Into<String>,
kind_tag: impl Into<String>,
) -> Result<AssetRecord, String> {
let path = path.into();
if let Some(record) = find_asset_by_path(registry, &path) {
return Ok(record);
}
let record = AssetRecord {
id: AssetId::new(),
path,
label: label.into(),
kind_tag: kind_tag.into(),
import_settings: ImportSettings::default(),
dependencies: Vec::new(),
};
registry.records.push(record.clone());
registry.index_dirty = true;
if let Err(error) = save_registry(registry) {
registry.records.pop();
return Err(error);
}
registry.index_dirty = false;
Ok(record)
}
pub fn update_import_settings( pub fn update_import_settings(
registry: &mut AssetRegistry, registry: &mut AssetRegistry,
path: &str, path: &str,
@ -315,98 +240,3 @@ pub fn load_registry() -> AssetRegistry {
Err(_) => AssetRegistry::default(), Err(_) => AssetRegistry::default(),
} }
} }
#[cfg(test)]
mod tests {
use super::*;
use shared::{
AnimationManifest, AnimationManifestSource, AnimationSourceFingerprint,
ANIMATION_MANIFEST_SCHEMA_VERSION,
};
fn moved_model_fixture(bytes: &[u8]) -> (std::path::PathBuf, std::path::PathBuf, AssetRecord) {
let key = Uuid::new_v4();
let source_path = std::env::temp_dir().join(format!("blacksite-moved-model-{key}.glb"));
let manifest_path =
std::env::temp_dir().join(format!("blacksite-moved-model-{key}.animation.ron"));
std::fs::write(&source_path, bytes).unwrap();
let id = AssetId::new();
let old_path = format!("assets/models/old-{key}.glb");
let manifest = AnimationManifest {
schema_version: ANIMATION_MANIFEST_SCHEMA_VERSION,
asset_id: id.as_string(),
label: "Moved Model".into(),
source: AnimationManifestSource {
path: old_path.clone(),
format: "glb".into(),
fingerprint: AnimationSourceFingerprint {
byte_len: bytes.len() as u64,
modified_unix_secs: 0,
content_hash: blake3::hash(bytes).to_hex().to_string(),
},
dependencies: Vec::new(),
},
runtime_supported: true,
skeletons: Vec::new(),
clips: Vec::new(),
diagnostics: Vec::new(),
};
std::fs::write(
&manifest_path,
ron::ser::to_string_pretty(&manifest, ron::ser::PrettyConfig::default()).unwrap(),
)
.unwrap();
let record = AssetRecord {
id,
path: old_path,
label: "Moved Model".into(),
kind_tag: "Model".into(),
import_settings: ImportSettings {
animation_manifest_path: Some(manifest_path.to_string_lossy().into_owned()),
..Default::default()
},
dependencies: Vec::new(),
};
(source_path, manifest_path, record)
}
#[test]
fn unique_content_match_preserves_model_record_across_move() {
let (source_path, manifest_path, record) = moved_model_fixture(b"stable model bytes");
let expected_id = record.id.clone();
let mut existing = HashMap::from([(record.path.clone(), record)]);
let moved = take_uniquely_moved_model_record(
&mut existing,
&HashSet::new(),
&source_path.to_string_lossy(),
"Model",
)
.expect("unique moved model should retain its registry record");
assert_eq!(moved.id, expected_id);
assert!(existing.is_empty());
let _ = std::fs::remove_file(source_path);
let _ = std::fs::remove_file(manifest_path);
}
#[test]
fn existing_source_path_is_not_reconciled_as_a_copy_move() {
let (source_path, manifest_path, record) = moved_model_fixture(b"copied model bytes");
let old_path = record.path.clone();
let mut existing = HashMap::from([(old_path.clone(), record)]);
let current_paths = HashSet::from([old_path]);
let moved = take_uniquely_moved_model_record(
&mut existing,
&current_paths,
&source_path.to_string_lossy(),
"Model",
);
assert!(moved.is_none());
assert_eq!(existing.len(), 1);
let _ = std::fs::remove_file(source_path);
let _ = std::fs::remove_file(manifest_path);
}
}

View File

@ -5,34 +5,28 @@ use bevy::prelude::*;
use settings::ProjectSettings; use settings::ProjectSettings;
use shared::{ use shared::{
ActorKind, AuthoringLightKind, ColliderDesc, ColorDesc, EditorVisibility, LightDesc, ActorKind, AuthoringLightKind, ColliderDesc, ColorDesc, EditorVisibility, LightDesc,
MaterialDesc, ModelRef, PostProcessVolumeDesc, PrefabRef, Primitive, PrimitiveShape, MaterialDesc, ModelRef, PostProcessVolumeDesc, PrefabInstance, PrefabRef, Primitive,
RigidBodyDesc, AUDIO_CLIP_SUB_ASSET_ID, PrimitiveShape, RigidBodyDesc,
}; };
use walkdir::WalkDir; use walkdir::WalkDir;
use crate::asset_db::{ use crate::asset_db::{
ensure_asset_record, find_asset_by_id, find_asset_by_path, find_asset_mut_by_path, find_asset_by_path, find_asset_mut_by_path, AssetRegistry, MaterialImportPolicy,
AssetRegistry, MaterialImportPolicy, ModelPlacementMode, ModelPlacementMode,
};
use crate::assets::static_mesh::{
load_static_mesh_manifest, refresh_static_mesh_artifact, renderer_from_manifest,
}; };
use crate::assets::animation::load_animation_manifest;
use crate::assets::static_mesh::{load_static_mesh_manifest, renderer_from_manifest};
use crate::history::{spawn_with_history, EditorEntitySnapshot}; use crate::history::{spawn_with_history, EditorEntitySnapshot};
pub const BUILTINS_FOLDER: &str = "__builtins__"; pub const BUILTINS_FOLDER: &str = "__builtins__";
pub const ASSETS_ROOT: &str = "assets"; pub const ASSETS_ROOT: &str = "assets";
pub const AUDIO_ASSET_EXTENSIONS: &[&str] = &["ogg", "oga", "spx", "wav", "mp3", "flac"];
pub const IMPORTABLE_ASSET_EXTENSIONS: &[&str] = &[
"gltf", "glb", "fbx", "png", "jpg", "jpeg", "webp", "ktx2", "ogg", "oga", "spx", "wav", "mp3",
"flac",
];
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum AssetSubAssetKind { pub enum AssetSubAssetKind {
Mesh, Mesh,
Material, Material,
Texture, Texture,
Skeleton,
AnimationClip,
} }
#[derive(Debug, Clone, PartialEq, Eq, Hash)] #[derive(Debug, Clone, PartialEq, Eq, Hash)]
@ -79,7 +73,6 @@ pub enum EditorAssetKind {
Model, Model,
Texture, Texture,
Material, Material,
AudioClip,
Level, Level,
Prefab, Prefab,
PostProcessVolume, PostProcessVolume,
@ -226,15 +219,7 @@ pub struct EditorAssetsPlugin;
impl Plugin for EditorAssetsPlugin { impl Plugin for EditorAssetsPlugin {
fn build(&self, app: &mut App) { fn build(&self, app: &mut App) {
app.init_resource::<EditorAssets>() app.init_resource::<EditorAssets>()
.init_resource::<super::prefab_overrides::PrefabHealthCache>() .add_systems(Update, refresh_assets_on_settings_change);
.add_observer(super::prefab_overrides::invalidate_prefab_health_on_ready)
.add_systems(
Update,
(
refresh_assets_on_settings_change,
super::prefab_overrides::guard_prefab_reimports,
),
);
} }
} }
@ -367,56 +352,50 @@ fn should_skip_asset_path(path: &Path) -> bool {
fn asset_from_file_path(path: &Path) -> Option<EditorAsset> { fn asset_from_file_path(path: &Path) -> Option<EditorAsset> {
let extension = path.extension()?.to_str()?.to_ascii_lowercase(); let extension = path.extension()?.to_str()?.to_ascii_lowercase();
let normalized_path = normalize_asset_path(path); let (kinds, extensions) = match extension.as_str() {
let (kinds, extensions) = if is_audio_asset_extension(&extension) { "gltf" | "glb" | "fbx" => (vec![EditorAssetKind::Model], vec!["gltf", "glb", "fbx"]),
( "png" | "jpg" | "jpeg" | "webp" | "ktx2" => (
vec![EditorAssetKind::AudioClip], vec![EditorAssetKind::Texture],
AUDIO_ASSET_EXTENSIONS.to_vec(), vec!["png", "jpg", "jpeg", "webp", "ktx2"],
) ),
} else { "ron" | "mat" | "material" => {
match extension.as_str() { if path.to_string_lossy().contains("/materials/") {
"gltf" | "glb" | "fbx" => (vec![EditorAssetKind::Model], vec!["gltf", "glb", "fbx"]), (
"png" | "jpg" | "jpeg" | "webp" | "ktx2" => ( vec![EditorAssetKind::Material],
vec![EditorAssetKind::Texture], vec!["ron", "mat", "material"],
vec!["png", "jpg", "jpeg", "webp", "ktx2"], )
), } else if path.to_string_lossy().contains("/post_fx/") {
"ron" | "mat" | "material" => { (vec![EditorAssetKind::PostProcessEffect], vec!["ron"])
if normalized_path.contains("/materials/") { } else if path.to_string_lossy().contains("/rendering_profiles/") {
( (vec![EditorAssetKind::RenderingProfile], vec!["ron"])
vec![EditorAssetKind::Material], } else if path.to_string_lossy().contains("/shaders/") {
vec!["ron", "mat", "material"], (vec![EditorAssetKind::ShaderSchema], vec!["ron"])
) } else {
} else if normalized_path.contains("/post_fx/") { (
(vec![EditorAssetKind::PostProcessEffect], vec!["ron"]) vec![EditorAssetKind::Level, EditorAssetKind::Prefab],
} else if normalized_path.contains("/rendering_profiles/") { vec!["ron"],
(vec![EditorAssetKind::RenderingProfile], vec!["ron"]) )
} else if normalized_path.contains("/shaders/") {
(vec![EditorAssetKind::ShaderSchema], vec!["ron"])
} else if normalized_path.ends_with(".scn.ron")
&& (normalized_path.starts_with("assets/prefabs/")
|| normalized_path.contains("/assets/prefabs/"))
{
(vec![EditorAssetKind::Prefab], vec!["ron"])
} else {
(vec![EditorAssetKind::Level], vec!["ron"])
}
} }
_ => return None,
} }
_ => return None,
}; };
if !has_extension(path, &extensions) { if !has_extension(path, &extensions) {
return None; return None;
} }
let path_string = normalized_path; let path_string = normalize_asset_path(path);
let folder_path = parent_folder_path(&path_string).unwrap_or_else(|| ASSETS_ROOT.to_string()); let folder_path = parent_folder_path(&path_string).unwrap_or_else(|| ASSETS_ROOT.to_string());
let label = path let label = path
.file_stem() .file_stem()
.and_then(|stem| stem.to_str()) .and_then(|stem| stem.to_str())
.unwrap_or(&path_string) .unwrap_or(&path_string)
.to_string(); .to_string();
let kind = kinds[0].clone(); let kind = if kinds.len() == 2 && path_string.ends_with(".scn.ron") {
kinds[1].clone()
} else {
kinds[0].clone()
};
Some(EditorAsset { Some(EditorAsset {
label, label,
@ -468,54 +447,8 @@ fn has_extension(path: &Path, extensions: &[&str]) -> bool {
}) })
} }
fn is_audio_asset_extension(extension: &str) -> bool { pub fn snapshot_for_asset(asset: &EditorAsset, translation: Vec3) -> Option<EditorEntitySnapshot> {
AUDIO_ASSET_EXTENSIONS let mut snapshot = EditorEntitySnapshot {
.iter()
.any(|candidate| extension.eq_ignore_ascii_case(candidate))
}
pub fn audio_clip_ref_for_asset(
world: &mut World,
asset: &EditorAsset,
) -> Result<shared::EditorAssetRef, String> {
if !matches!(asset.kind, EditorAssetKind::AudioClip) {
return Err(format!("{} is not an audio clip", asset.label));
}
let path = asset
.path
.as_deref()
.ok_or_else(|| format!("audio clip {} has no source path", asset.label))?;
let record = world
.get_resource_mut::<AssetRegistry>()
.ok_or_else(|| "asset registry is unavailable".to_string())
.and_then(|mut registry| {
ensure_asset_record(
&mut registry,
path.to_string(),
asset.label.clone(),
"AudioClip",
)
})?;
Ok(shared::EditorAssetRef::new(
record.id.as_string(),
AUDIO_CLIP_SUB_ASSET_ID,
asset.label.clone(),
)
.with_source_path(path))
}
pub fn audio_source_desc_for_asset(
world: &mut World,
asset: &EditorAsset,
) -> Result<shared::AudioSourceDesc, String> {
Ok(shared::AudioSourceDesc {
clip: Some(audio_clip_ref_for_asset(world, asset)?),
..Default::default()
})
}
fn empty_snapshot_for_asset(asset: &EditorAsset, translation: Vec3) -> EditorEntitySnapshot {
EditorEntitySnapshot {
actor_id: None, actor_id: None,
actor_kind: ActorKind::Empty, actor_kind: ActorKind::Empty,
actor_name: None, actor_name: None,
@ -530,9 +463,6 @@ fn empty_snapshot_for_asset(asset: &EditorAsset, translation: Vec3) -> EditorEnt
collider: None, collider: None,
physics: None, physics: None,
light: None, light: None,
animation_controller: None,
audio_source: None,
audio_listener: None,
player_spawn: false, player_spawn: false,
model: None, model: None,
prefab: None, prefab: None,
@ -545,11 +475,7 @@ fn empty_snapshot_for_asset(asset: &EditorAsset, translation: Vec3) -> EditorEnt
hierarchy_sibling_index: 0, hierarchy_sibling_index: 0,
editor_visibility: EditorVisibility::default(), editor_visibility: EditorVisibility::default(),
children: Vec::new(), children: Vec::new(),
} };
}
pub fn snapshot_for_asset(asset: &EditorAsset, translation: Vec3) -> Option<EditorEntitySnapshot> {
let mut snapshot = empty_snapshot_for_asset(asset, translation);
match &asset.kind { match &asset.kind {
EditorAssetKind::Primitive(shape) => { EditorAssetKind::Primitive(shape) => {
@ -596,15 +522,13 @@ pub fn snapshot_for_asset(asset: &EditorAsset, translation: Vec3) -> Option<Edit
snapshot.model = Some(ModelRef::new(path.clone())); snapshot.model = Some(ModelRef::new(path.clone()));
snapshot.transform.translation.y += 0.1; snapshot.transform.translation.y += 0.1;
} }
EditorAssetKind::Prefab => { EditorAssetKind::Prefab | EditorAssetKind::Level => {
snapshot.actor_kind = ActorKind::PrefabAnchor; snapshot.actor_kind = ActorKind::PrefabAnchor;
let path = asset.path.as_ref()?; let path = asset.path.as_ref()?;
snapshot.prefab = Some(PrefabRef::new(path.clone())); snapshot.prefab = Some(PrefabRef::new(path.clone()));
} }
EditorAssetKind::Level => return None,
EditorAssetKind::Texture EditorAssetKind::Texture
| EditorAssetKind::Material | EditorAssetKind::Material
| EditorAssetKind::AudioClip
| EditorAssetKind::PostProcessEffect | EditorAssetKind::PostProcessEffect
| EditorAssetKind::RenderingProfile | EditorAssetKind::RenderingProfile
| EditorAssetKind::ShaderSchema => return None, | EditorAssetKind::ShaderSchema => return None,
@ -614,24 +538,7 @@ pub fn snapshot_for_asset(asset: &EditorAsset, translation: Vec3) -> Option<Edit
} }
pub fn spawn_asset_at(world: &mut World, asset: &EditorAsset, translation: Vec3) -> Option<Entity> { pub fn spawn_asset_at(world: &mut World, asset: &EditorAsset, translation: Vec3) -> Option<Entity> {
let mut snapshot = if matches!(asset.kind, EditorAssetKind::AudioClip) { let mut snapshot = snapshot_for_asset(asset, translation)?;
let descriptor = match audio_source_desc_for_asset(world, asset) {
Ok(descriptor) => descriptor,
Err(error) => {
warn!("Audio clip placement failed for {}: {error}", asset.label);
if let Some(mut scene_io) = world.get_resource_mut::<crate::scene_io::SceneIo>() {
scene_io.status = format!("Audio placement failed: {error}");
}
return None;
}
};
let mut snapshot = empty_snapshot_for_asset(asset, translation);
snapshot.actor_kind = ActorKind::AudioSource;
snapshot.audio_source = Some(descriptor);
snapshot
} else {
snapshot_for_asset(asset, translation)?
};
if let Some(light) = snapshot.light.as_mut() { if let Some(light) = snapshot.light.as_mut() {
if matches!(light.kind, AuthoringLightKind::Directional) { if matches!(light.kind, AuthoringLightKind::Directional) {
light.intensity = world light.intensity = world
@ -641,32 +548,12 @@ pub fn spawn_asset_at(world: &mut World, asset: &EditorAsset, translation: Vec3)
} }
} }
if let Some(path) = asset.path.as_ref() { if let Some(path) = asset.path.as_ref() {
let mut record = world let record = world
.get_resource::<AssetRegistry>() .get_resource::<AssetRegistry>()
.and_then(|registry| find_asset_by_path(registry, path)); .and_then(|registry| find_asset_by_path(registry, path));
if matches!(asset.kind, EditorAssetKind::Prefab) && record.is_none() {
record = world
.get_resource_mut::<AssetRegistry>()
.and_then(|mut registry| {
ensure_asset_record(&mut registry, path.clone(), asset.label.clone(), "Prefab")
.map_err(|error| {
error!("Could not register prefab asset {path}: {error}");
})
.ok()
});
}
if matches!(asset.kind, EditorAssetKind::Prefab) && record.is_none() {
if let Some(mut scene_io) = world.get_resource_mut::<crate::scene_io::SceneIo>() {
scene_io.status = format!("Prefab placement failed: could not register {path}");
}
return None;
}
if let Some(record) = record { if let Some(record) = record {
match asset.kind { match asset.kind {
EditorAssetKind::Model => { EditorAssetKind::Model => {
if let Some(model) = snapshot.model.as_mut() {
model.asset_id = record.id.as_string();
}
snapshot.transform.scale *= record.import_settings.scale; snapshot.transform.scale *= record.import_settings.scale;
if matches!( if matches!(
record.import_settings.placement_mode, record.import_settings.placement_mode,
@ -693,11 +580,7 @@ pub fn spawn_asset_at(world: &mut World, asset: &EditorAsset, translation: Vec3)
} }
EditorAssetKind::Prefab => { EditorAssetKind::Prefab => {
snapshot.prefab_instance = snapshot.prefab_instance =
Some(super::prefab_overrides::new_tracked_prefab_instance( Some(PrefabInstance::new(record.id.as_string(), path.clone()));
world,
record.id.as_string(),
path.clone(),
));
} }
_ => {} _ => {}
} }
@ -706,37 +589,11 @@ pub fn spawn_asset_at(world: &mut World, asset: &EditorAsset, translation: Vec3)
Some(spawn_with_history(world, snapshot)) Some(spawn_with_history(world, snapshot))
} }
pub fn validate_prefab_asset(world: &World, asset: &EditorAsset) -> Result<(), String> {
if !matches!(asset.kind, EditorAssetKind::Prefab) {
return Ok(());
}
let source_path = asset
.path
.as_deref()
.ok_or_else(|| "Prefab asset has no source path".to_string())?;
let project_root = world
.get_resource::<crate::project_io::ProjectWorkspace>()
.map(|workspace| PathBuf::from(&workspace.root))
.ok_or_else(|| "Project workspace is unavailable".to_string())?;
scene::validate_prefab_graph(&project_root.join(source_path), &project_root)
.map_err(|error| format!("Prefab dependency validation failed: {error}"))
}
pub fn spawn_subasset_at( pub fn spawn_subasset_at(
world: &mut World, world: &mut World,
selection: &AssetSelection, selection: &AssetSelection,
translation: Vec3, translation: Vec3,
) -> Option<Entity> { ) -> Option<Entity> {
if matches!(
selection,
AssetSelection::SubAsset {
kind: AssetSubAssetKind::AnimationClip,
..
}
) {
return spawn_animation_clip_at(world, selection, translation);
}
let AssetSelection::SubAsset { let AssetSelection::SubAsset {
parent_path, parent_path,
sub_asset_id, sub_asset_id,
@ -806,9 +663,6 @@ pub fn spawn_subasset_at(
collider: None, collider: None,
physics: None, physics: None,
light: None, light: None,
animation_controller: None,
audio_source: None,
audio_listener: None,
player_spawn: false, player_spawn: false,
model: None, model: None,
prefab: None, prefab: None,
@ -831,251 +685,6 @@ pub fn spawn_subasset_at(
Some(spawn_with_history(world, snapshot)) Some(spawn_with_history(world, snapshot))
} }
#[derive(Debug, Clone)]
pub struct AnimationClipAuthoringData {
pub model_asset_id: String,
pub model_path: String,
pub model_label: String,
pub model_scale: f32,
pub skeleton: shared::EditorAssetRef,
pub skeleton_signature: shared::AnimationSkeletonSignature,
pub state: shared::AnimationStateDesc,
}
pub fn animation_clip_authoring_data(
world: &World,
selection: &AssetSelection,
) -> Result<AnimationClipAuthoringData, String> {
let AssetSelection::SubAsset {
parent_path,
sub_asset_id,
kind: AssetSubAssetKind::AnimationClip,
..
} = selection
else {
return Err("the selected sub-asset is not an animation clip".into());
};
let record = world
.get_resource::<AssetRegistry>()
.and_then(|registry| find_asset_by_path(registry, parent_path))
.ok_or_else(|| format!("model `{parent_path}` is missing from the asset registry"))?;
let manifest_path = record
.import_settings
.animation_manifest_path
.as_deref()
.ok_or_else(|| format!("model `{parent_path}` has no generated animation manifest"))?;
let manifest = load_animation_manifest(manifest_path)?;
if !manifest.runtime_supported {
return Err(format!(
"model `{parent_path}` is not supported by the animation runtime; animation v1 requires glTF/GLB"
));
}
let clip = manifest
.clips
.iter()
.find(|clip| clip.id == *sub_asset_id)
.ok_or_else(|| {
format!(
"animation clip `{sub_asset_id}` is missing from `{manifest_path}`; reimport the model"
)
})?;
let target_signature = clip.target_skeleton_signature.as_ref().ok_or_else(|| {
format!(
"animation clip `{}` has no uniquely resolved target skeleton",
clip.label
)
})?;
let skeleton = manifest
.skeletons
.iter()
.find(|skeleton| &skeleton.signature == target_signature)
.ok_or_else(|| {
format!(
"animation clip `{}` targets skeleton `{}` but no exact match exists in `{parent_path}`",
clip.label, target_signature.0
)
})?;
let asset_id = record.id.as_string();
let skeleton_ref = shared::EditorAssetRef::new(
asset_id.clone(),
skeleton.id.clone(),
skeleton.label.clone(),
)
.with_source_path(parent_path);
let clip_ref = shared::EditorAssetRef::new(asset_id, clip.id.clone(), clip.label.clone())
.with_source_path(parent_path);
let state_id = animation_state_id(&clip.label, clip.source_index);
Ok(AnimationClipAuthoringData {
model_asset_id: record.id.as_string(),
model_path: parent_path.clone(),
model_label: record.label,
model_scale: record.import_settings.scale,
skeleton: skeleton_ref,
skeleton_signature: skeleton.signature.clone(),
state: shared::AnimationStateDesc {
id: state_id,
label: clip.label.clone(),
clip: clip_ref,
looping: true,
speed: 1.0,
range: shared::AnimationPlaybackRange::default(),
},
})
}
pub fn animation_skeleton_signature_for_ref(
world: &World,
reference: &shared::EditorAssetRef,
) -> Result<shared::AnimationSkeletonSignature, String> {
let record = world
.get_resource::<AssetRegistry>()
.and_then(|registry| find_asset_by_id(registry, &reference.asset_id))
.ok_or_else(|| {
format!(
"skeleton asset `{}` is missing from the asset registry",
reference.asset_id
)
})?;
let manifest_path = record
.import_settings
.animation_manifest_path
.as_deref()
.ok_or_else(|| format!("model `{}` has no animation manifest", record.path))?;
let manifest = load_animation_manifest(manifest_path)?;
manifest
.skeletons
.iter()
.find(|skeleton| skeleton.id == reference.sub_asset_id)
.map(|skeleton| skeleton.signature.clone())
.ok_or_else(|| {
format!(
"skeleton `{}` is missing from `{manifest_path}`; reimport the model",
reference.sub_asset_id
)
})
}
pub fn compatible_animation_skeleton_for_model(
world: &World,
model: &ModelRef,
signature: &shared::AnimationSkeletonSignature,
) -> Result<shared::EditorAssetRef, String> {
let record = world
.get_resource::<AssetRegistry>()
.and_then(|registry| {
if model.asset_id.trim().is_empty() {
find_asset_by_path(registry, &model.path)
} else {
find_asset_by_id(registry, &model.asset_id)
}
})
.ok_or_else(|| {
format!(
"model `{}` ({}) is missing from the asset registry",
model.path, model.asset_id
)
})?;
let model_path = record.path.as_str();
let manifest_path = record
.import_settings
.animation_manifest_path
.as_deref()
.ok_or_else(|| format!("model `{model_path}` has no animation manifest"))?;
let manifest = load_animation_manifest(manifest_path)?;
let skeleton = manifest
.skeletons
.iter()
.find(|skeleton| &skeleton.signature == signature)
.ok_or_else(|| {
format!(
"model `{model_path}` has no skeleton matching animation rig `{}`",
signature.0
)
})?;
Ok(shared::EditorAssetRef::new(
record.id.as_string(),
skeleton.id.clone(),
skeleton.label.clone(),
)
.with_source_path(model_path))
}
fn animation_state_id(label: &str, source_index: usize) -> String {
let mut id = String::new();
for character in label.chars() {
if character.is_ascii_alphanumeric() {
id.push(character.to_ascii_lowercase());
} else if !id.ends_with('_') {
id.push('_');
}
}
let id = id.trim_matches('_');
if id.is_empty() {
format!("clip_{source_index}")
} else {
id.to_string()
}
}
fn spawn_animation_clip_at(
world: &mut World,
selection: &AssetSelection,
translation: Vec3,
) -> Option<Entity> {
let authored = match animation_clip_authoring_data(world, selection) {
Ok(authored) => authored,
Err(error) => {
warn!("Animation clip placement failed: {error}");
if let Some(mut scene_io) = world.get_resource_mut::<crate::scene_io::SceneIo>() {
scene_io.status = format!("Animation placement failed: {error}");
}
return None;
}
};
let default_state = authored.state.id.clone();
let snapshot = EditorEntitySnapshot {
actor_id: None,
actor_kind: ActorKind::ImportedModel,
actor_name: None,
name: Some(authored.model_label),
transform: Transform {
translation: translation + Vec3::Y * 0.1,
scale: Vec3::splat(authored.model_scale),
..default()
},
primitive: None,
brush: None,
static_mesh_renderer: None,
material: None,
material_override: None,
rigid_body: None,
collider: None,
physics: None,
light: None,
animation_controller: Some(shared::AnimationControllerDesc {
skeleton: Some(authored.skeleton),
states: vec![authored.state],
default_state,
..default()
}),
audio_source: None,
audio_listener: None,
player_spawn: false,
model: Some(ModelRef::new(authored.model_path).with_asset_id(authored.model_asset_id)),
prefab: None,
prefab_instance: None,
weapon_spawn: None,
trigger_volume: None,
post_process_volume: None,
team_spawn: None,
objective: None,
hierarchy_sibling_index: 0,
editor_visibility: EditorVisibility::default(),
children: Vec::new(),
};
Some(spawn_with_history(world, snapshot))
}
fn apply_static_mesh_placement_mode( fn apply_static_mesh_placement_mode(
snapshot: &mut EditorEntitySnapshot, snapshot: &mut EditorEntitySnapshot,
renderer: shared::StaticMeshRenderer, renderer: shared::StaticMeshRenderer,
@ -1124,9 +733,6 @@ fn apply_static_mesh_placement_mode(
collider, collider,
physics: None, physics: None,
light: None, light: None,
animation_controller: None,
audio_source: None,
audio_listener: None,
player_spawn: false, player_spawn: false,
model: None, model: None,
prefab: None, prefab: None,
@ -1182,13 +788,13 @@ fn static_mesh_renderer_for_asset(
if let Some(mut registry) = world.get_resource_mut::<AssetRegistry>() { if let Some(mut registry) = world.get_resource_mut::<AssetRegistry>() {
let mut renderer = None; let mut renderer = None;
if let Some(record) = find_asset_mut_by_path(&mut registry, path) { if let Some(record) = find_asset_mut_by_path(&mut registry, path) {
match super::refresh_model_artifacts(record) { match refresh_static_mesh_artifact(record) {
Ok(manifest) => { Ok(manifest) => {
renderer = Some(renderer_from_manifest(&manifest, &record.import_settings)); renderer = Some(renderer_from_manifest(&manifest, &record.import_settings));
registry.index_dirty = true; registry.index_dirty = true;
} }
Err(error) => { Err(error) => {
warn!("Model artifact refresh failed for {path}: {error}"); warn!("Static mesh manifest refresh failed for {path}: {error}");
manifest_path = record.import_settings.static_mesh_manifest_path.clone(); manifest_path = record.import_settings.static_mesh_manifest_path.clone();
} }
} }
@ -1241,9 +847,6 @@ pub fn import_external_assets(paths: &[PathBuf]) -> Result<usize, String> {
} }
fn import_dir_for_extension(extension: &str) -> &'static str { fn import_dir_for_extension(extension: &str) -> &'static str {
if is_audio_asset_extension(extension) {
return "assets/audio";
}
match extension.to_ascii_lowercase().as_str() { match extension.to_ascii_lowercase().as_str() {
"gltf" | "glb" | "fbx" => "assets/models", "gltf" | "glb" | "fbx" => "assets/models",
"png" | "jpg" | "jpeg" | "webp" | "ktx2" => "assets/textures", "png" | "jpg" | "jpeg" | "webp" | "ktx2" => "assets/textures",
@ -1267,85 +870,3 @@ pub fn asset_cache_key(asset: &EditorAsset) -> String {
None => format!("builtin:{}", asset.label), None => format!("builtin:{}", asset.label),
} }
} }
#[cfg(test)]
mod tests {
use super::*;
use crate::asset_db::{AssetId, AssetRecord};
use uuid::Uuid;
#[test]
fn scene_assets_are_classified_by_authoring_folder() {
let level = asset_from_file_path(Path::new("assets/levels/arena.scn.ron")).unwrap();
let prefab = asset_from_file_path(Path::new("assets/prefabs/crate.scn.ron")).unwrap();
assert_eq!(level.kind, EditorAssetKind::Level);
assert_eq!(prefab.kind, EditorAssetKind::Prefab);
}
#[test]
fn bevy_audio_extensions_are_classified_and_imported_to_audio() {
for extension in AUDIO_ASSET_EXTENSIONS {
let path = format!("assets/audio/impact.{extension}");
let asset = asset_from_file_path(Path::new(&path)).unwrap();
assert_eq!(asset.kind, EditorAssetKind::AudioClip);
assert_eq!(asset.folder_path, "assets/audio");
assert_eq!(import_dir_for_extension(extension), "assets/audio");
assert!(IMPORTABLE_ASSET_EXTENSIONS.contains(extension));
}
assert!(asset_from_file_path(Path::new("assets/audio/impact.m4a")).is_none());
assert_eq!(import_dir_for_extension("WAV"), "assets/audio");
}
#[test]
fn audio_clip_reference_keeps_stable_registry_identity_and_runtime_path() {
let asset = EditorAsset {
label: "Impact".into(),
path: Some("assets/audio/impact.ogg".into()),
folder_path: "assets/audio".into(),
kind: EditorAssetKind::AudioClip,
};
let stable_id = AssetId(Uuid::nil());
let mut world = World::new();
world.insert_resource(AssetRegistry {
records: vec![AssetRecord {
id: stable_id.clone(),
path: asset.path.clone().unwrap(),
label: asset.label.clone(),
kind_tag: "AudioClip".into(),
import_settings: Default::default(),
dependencies: Vec::new(),
}],
index_dirty: false,
});
world.init_resource::<crate::history::EditorHistory>();
world.init_resource::<crate::selection::SelectedEntity>();
let descriptor = audio_source_desc_for_asset(&mut world, &asset).unwrap();
let reference = descriptor.clip.unwrap();
assert_eq!(reference.asset_id, stable_id.as_string());
assert_eq!(reference.sub_asset_id, AUDIO_CLIP_SUB_ASSET_ID);
assert_eq!(reference.label, "Impact");
assert_eq!(reference.source_path.as_deref(), asset.path.as_deref());
assert_eq!(
audio_clip_ref_for_asset(&mut world, &asset)
.unwrap()
.asset_id,
reference.asset_id
);
let entity = spawn_asset_at(&mut world, &asset, Vec3::new(1.0, 2.0, 3.0)).unwrap();
assert_eq!(
world.get::<ActorKind>(entity),
Some(&ActorKind::AudioSource)
);
let placed = world.get::<shared::AudioSourceDesc>(entity).unwrap();
assert_eq!(placed.clip.as_ref(), Some(&reference));
assert_eq!(
world.get::<Transform>(entity).unwrap().translation,
Vec3::new(1.0, 2.0, 3.0)
);
assert!(world.resource::<crate::history::EditorHistory>().can_undo());
}
}

View File

@ -5,7 +5,7 @@ use bevy_egui::egui;
use shared::{LevelObject, MaterialAsset, MaterialDesc}; use shared::{LevelObject, MaterialAsset, MaterialDesc};
use crate::assets::{EditorAsset, EditorAssetKind, EditorAssets}; use crate::assets::{EditorAsset, EditorAssetKind, EditorAssets};
use crate::history::set_material_group_with_history; use crate::history::set_material_with_history;
use crate::ui::inspector::property_row; use crate::ui::inspector::property_row;
pub fn load_material_asset(catalog_path: &str) -> Result<MaterialAsset, String> { pub fn load_material_asset(catalog_path: &str) -> Result<MaterialAsset, String> {
@ -23,21 +23,28 @@ pub fn apply_material_asset_to_selection(
world: &mut World, world: &mut World,
asset: &EditorAsset, asset: &EditorAsset,
selected: &bevy_inspector_egui::bevy_inspector::hierarchy::SelectedEntities, selected: &bevy_inspector_egui::bevy_inspector::hierarchy::SelectedEntities,
) -> Result<(), String> { ) {
let path = asset let Some(path) = asset.path.clone() else {
.path return;
.clone() };
.ok_or_else(|| format!("Material has no source path: {}", asset.label))?; let desc = match material_desc_from_asset(&path) {
let desc = material_desc_from_asset(&path)?; Ok(desc) => desc,
let changes = crate::ui::helpers::selected_level_entities(world, selected) Err(err) => {
.into_iter() world.resource_mut::<crate::scene_io::SceneIo>().status = err;
.filter(|entity| world.get::<LevelObject>(*entity).is_some()) return;
.map(|entity| (entity, desc.clone())) }
.collect::<Vec<_>>(); };
set_material_group_with_history(world, changes); for entity in crate::ui::helpers::selected_level_entities(world, selected) {
if world.get::<LevelObject>(entity).is_none() {
continue;
}
if world.get::<MaterialDesc>(entity).is_none() {
world.entity_mut(entity).insert(desc.clone());
}
set_material_with_history(world, entity, desc.clone());
}
world.resource_mut::<crate::scene_io::SceneIo>().status = world.resource_mut::<crate::scene_io::SceneIo>().status =
format!("Applied material {}", asset.label); format!("Applied material {}", asset.label);
Ok(())
} }
pub fn material_asset_picker_ui( pub fn material_asset_picker_ui(

View File

@ -1,6 +1,5 @@
//! Asset catalog, database, thumbnails, materials, and prefab overrides. //! Asset catalog, database, thumbnails, materials, and prefab overrides.
pub mod animation;
pub mod asset_db; pub mod asset_db;
mod catalog; mod catalog;
mod import; mod import;
@ -12,25 +11,6 @@ pub mod thumbnails;
pub use catalog::*; pub use catalog::*;
pub use thumbnails::{ pub use thumbnails::{
draw_asset_cell_with, gltf_skinned_primitive_labels, invalidate_on_catalog_refresh, kind_icon, draw_asset_cell_with, invalidate_on_catalog_refresh, kind_icon, prefetch_folder_thumbnails,
prefetch_folder_thumbnails, AssetThumbnailCache, ThumbnailCacheSnapshot, ThumbnailState, AssetThumbnailCache, ThumbnailCacheSnapshot, ThumbnailState, ThumbnailStudio, ThumbnailsPlugin,
ThumbnailStudio, ThumbnailsPlugin,
}; };
/// Refreshes both generated contracts derived from one imported model source.
pub fn refresh_model_artifacts(
record: &mut asset_db::AssetRecord,
) -> Result<static_mesh::StaticMeshManifest, String> {
let static_mesh = static_mesh::refresh_static_mesh_artifact(record);
let animation = animation::refresh_animation_artifact(record);
match (static_mesh, animation) {
(Ok(static_mesh), Ok(_)) => Ok(static_mesh),
(Err(static_error), Ok(_)) => Err(format!("static mesh processor failed: {static_error}")),
(Ok(_), Err(animation_error)) => {
Err(format!("animation processor failed: {animation_error}"))
}
(Err(static_error), Err(animation_error)) => Err(format!(
"static mesh processor failed: {static_error}; animation processor failed: {animation_error}"
)),
}
}

View File

@ -8,11 +8,7 @@ use crate::ui::helpers::{
apply_material_asset_to_selection, apply_texture_to_selection, selected_level_entities, apply_material_asset_to_selection, apply_texture_to_selection, selected_level_entities,
}; };
use super::{ use super::{spawn_asset_at, spawn_subasset_at, AssetSelection, EditorAsset};
animation_clip_authoring_data, animation_skeleton_signature_for_ref, audio_clip_ref_for_asset,
compatible_animation_skeleton_for_model, spawn_asset_at, spawn_subasset_at,
validate_prefab_asset, AssetSelection, EditorAsset,
};
fn selection_availability(world: &World, selected: &SelectedEntities) -> OperatorAvailability { fn selection_availability(world: &World, selected: &SelectedEntities) -> OperatorAvailability {
if selected_level_entities(world, selected).is_empty() { if selected_level_entities(world, selected).is_empty() {
@ -30,7 +26,6 @@ pub fn place_asset_operator(world: &mut World, asset: EditorAsset, translation:
&label, &label,
OperatorAvailability::Ready, OperatorAvailability::Ready,
move |world| { move |world| {
validate_prefab_asset(world, &asset)?;
spawn_asset_at(world, &asset, translation) spawn_asset_at(world, &asset, translation)
.map(|_| ()) .map(|_| ())
.ok_or_else(|| format!("Asset cannot be placed: {}", asset.label)) .ok_or_else(|| format!("Asset cannot be placed: {}", asset.label))
@ -69,7 +64,10 @@ pub fn apply_texture_operator(
"assets.apply_texture", "assets.apply_texture",
&label, &label,
availability, availability,
move |world| apply_texture_to_selection(world, &asset, selected), move |world| {
apply_texture_to_selection(world, &asset, selected);
Ok(())
},
) )
} }
@ -85,414 +83,9 @@ pub fn apply_material_operator(
"assets.apply_material", "assets.apply_material",
&label, &label,
availability, availability,
move |world| apply_material_asset_to_selection(world, &asset, selected),
)
}
pub fn assign_audio_clip_operator(world: &mut World, asset: EditorAsset, entity: Entity) -> bool {
let availability = if world.get::<shared::AudioSourceDesc>(entity).is_some() {
OperatorAvailability::Ready
} else {
OperatorAvailability::Disabled("Select an authored audio source".to_string())
};
let label = format!("Assign Audio Clip {}", asset.label);
run_immediate_operator(
world,
"assets.assign_audio_clip",
&label,
availability,
move |world| { move |world| {
let clip = audio_clip_ref_for_asset(world, &asset)?; apply_material_asset_to_selection(world, &asset, selected);
let mut source = world
.get::<shared::AudioSourceDesc>(entity)
.cloned()
.ok_or_else(|| "Selected actor is not an audio source".to_string())?;
source.clip = Some(clip);
crate::history::set_audio_source_with_history(world, entity, source);
Ok(()) Ok(())
}, },
) )
} }
pub fn assign_animation_clip_operator(
world: &mut World,
selection: AssetSelection,
entity: Entity,
) -> bool {
let availability = if world.get::<shared::ModelRef>(entity).is_some() {
OperatorAvailability::Ready
} else {
OperatorAvailability::Disabled("Select an authored imported-model actor".to_string())
};
let label = format!("Assign Animation Clip {}", selection.display_label());
run_immediate_operator(
world,
"assets.assign_animation_clip",
&label,
availability,
move |world| {
let authored = animation_clip_authoring_data(world, &selection)?;
let model = world
.get::<shared::ModelRef>(entity)
.cloned()
.ok_or_else(|| "Selected actor has no ModelRef".to_string())?;
let mut controller = world
.get::<shared::AnimationControllerDesc>(entity)
.cloned()
.unwrap_or_default();
let skeleton = match controller.skeleton.as_ref() {
Some(reference) => {
let existing_signature =
animation_skeleton_signature_for_ref(world, reference)?;
if existing_signature != authored.skeleton_signature {
return Err(format!(
"Clip `{}` targets rig `{}` but the actor uses `{}`. Animation v1 requires an exact skeleton match.",
authored.state.label,
authored.skeleton_signature.0,
existing_signature.0
));
}
reference.clone()
}
None => compatible_animation_skeleton_for_model(
world,
&model,
&authored.skeleton_signature,
)?,
};
if controller.states.iter().any(|state| {
state.clip.asset_id == authored.state.clip.asset_id
&& state.clip.sub_asset_id == authored.state.clip.sub_asset_id
}) {
return Err(format!(
"Animation controller already contains clip `{}`.",
authored.state.label
));
}
let mut state = authored.state;
let base_id = state.id.clone();
let mut suffix = 2;
while controller.states.iter().any(|entry| entry.id == state.id) {
state.id = format!("{base_id}_{suffix}");
suffix += 1;
}
controller.skeleton = Some(skeleton);
if controller.default_state.trim().is_empty() {
controller.default_state = state.id.clone();
}
controller.states.push(state);
crate::history::set_animation_controller_with_history(world, entity, controller);
Ok(())
},
)
}
#[cfg(test)]
mod tests {
use super::*;
use shared::{
animation_clip_sub_asset_id, animation_skeleton_sub_asset_id, ActorKind,
AnimationClipRecord, AnimationControllerDesc, AnimationManifest, AnimationManifestSource,
AnimationSkeletonRecord, AnimationSkeletonSignature, AnimationSourceFingerprint,
AudioSourceDesc, LevelObject, MaterialDesc, ModelRef, PrimitiveShape,
ANIMATION_MANIFEST_SCHEMA_VERSION,
};
use crate::asset_db::{AssetId, AssetRecord, AssetRegistry, ImportSettings};
use crate::history::EditorHistory;
use crate::operators::test_harness::{assert_undo_redo_round_trip, OperatorInvariantHarness};
use crate::operators::ActiveOperator;
use crate::scene_io::SceneIo;
use crate::selection::SelectedEntity;
fn authored_count(world: &mut World) -> usize {
let mut query = world.query_filtered::<Entity, With<LevelObject>>();
query.iter(world).count()
}
fn animation_fixture() -> (std::path::PathBuf, AssetRecord, AssetSelection) {
let model_path = "assets/models/operator_fixture.glb".to_string();
let asset_id = AssetId::new();
let skeleton_id = animation_skeleton_sub_asset_id(0, "Rig");
let clip_id = animation_clip_sub_asset_id(0, "Idle");
let manifest = AnimationManifest {
schema_version: ANIMATION_MANIFEST_SCHEMA_VERSION,
asset_id: asset_id.as_string(),
label: "Operator Fixture".into(),
source: AnimationManifestSource {
path: model_path.clone(),
format: "glb".into(),
fingerprint: AnimationSourceFingerprint {
byte_len: 1,
modified_unix_secs: 0,
content_hash: "fixture".into(),
},
dependencies: Vec::new(),
},
runtime_supported: true,
skeletons: vec![AnimationSkeletonRecord {
id: skeleton_id,
label: "Rig".into(),
source_index: 0,
signature: AnimationSkeletonSignature::new("exact-rig"),
joint_paths: vec!["Root".into()],
}],
clips: vec![AnimationClipRecord {
id: clip_id.clone(),
label: "Idle".into(),
source_index: 0,
duration_seconds: 1.0,
target_skeleton_signature: Some(AnimationSkeletonSignature::new("exact-rig")),
events: Vec::new(),
}],
diagnostics: Vec::new(),
};
let path = std::env::temp_dir().join(format!(
"blacksite-animation-operator-{}.animation.ron",
uuid::Uuid::new_v4()
));
std::fs::write(
&path,
ron::ser::to_string_pretty(&manifest, ron::ser::PrettyConfig::default()).unwrap(),
)
.unwrap();
let record = AssetRecord {
id: asset_id,
path: model_path.clone(),
label: "Operator Fixture".into(),
kind_tag: "Model".into(),
import_settings: ImportSettings {
animation_manifest_path: Some(path.to_string_lossy().into_owned()),
..Default::default()
},
dependencies: Vec::new(),
};
let selection = AssetSelection::SubAsset {
parent_path: model_path.clone(),
sub_asset_id: clip_id,
label: "Idle".into(),
kind: super::super::AssetSubAssetKind::AnimationClip,
source_path: Some(model_path),
};
(path, record, selection)
}
#[test]
fn asset_placement_commits_one_undo_group_and_round_trips() {
let mut world = World::new();
world.init_resource::<ActiveOperator>();
world.init_resource::<EditorHistory>();
world.init_resource::<SceneIo>();
world.init_resource::<SelectedEntity>();
let harness = OperatorInvariantHarness::capture(&mut world);
let asset = EditorAsset {
label: "Cube".to_string(),
path: None,
folder_path: "Built-ins".to_string(),
kind: super::super::EditorAssetKind::Primitive(PrimitiveShape::Box),
};
assert!(place_asset_operator(&mut world, asset, Vec3::ZERO));
harness.assert_committed(&mut world, 1, 1);
assert_undo_redo_round_trip(&mut world, 0, 1, authored_count);
}
#[test]
fn audio_clip_assignment_is_one_undoable_operator() {
let mut world = World::new();
world.init_resource::<ActiveOperator>();
world.init_resource::<EditorHistory>();
world.init_resource::<SceneIo>();
let path = "assets/audio/impact.ogg".to_string();
world.insert_resource(AssetRegistry {
records: vec![AssetRecord {
id: AssetId::new(),
path: path.clone(),
label: "Impact".into(),
kind_tag: "AudioClip".into(),
import_settings: Default::default(),
dependencies: Vec::new(),
}],
index_dirty: false,
});
let entity = world
.spawn((
LevelObject,
ActorKind::AudioSource,
Transform::IDENTITY,
AudioSourceDesc::default(),
))
.id();
let asset = EditorAsset {
label: "Impact".into(),
path: Some(path.clone()),
folder_path: "assets/audio".into(),
kind: super::super::EditorAssetKind::AudioClip,
};
let harness = OperatorInvariantHarness::capture(&mut world);
let read_clip = move |world: &mut World| {
world
.get::<AudioSourceDesc>(entity)
.and_then(|source| source.clip.clone())
};
assert!(assign_audio_clip_operator(&mut world, asset, entity));
let assigned = read_clip(&mut world).expect("clip assigned");
assert_eq!(assigned.source_path.as_deref(), Some(path.as_str()));
harness.assert_committed(&mut world, 1, 0);
assert_undo_redo_round_trip(&mut world, None, Some(assigned), read_clip);
}
#[test]
fn animation_clip_assignment_is_one_undoable_operator() {
let mut world = World::new();
world.init_resource::<ActiveOperator>();
world.init_resource::<EditorHistory>();
world.init_resource::<SceneIo>();
let (manifest_path, record, selection) = animation_fixture();
let model_path = record.path.clone();
world.insert_resource(AssetRegistry {
records: vec![record],
index_dirty: false,
});
let entity = world
.spawn((
LevelObject,
ActorKind::ImportedModel,
ModelRef::new(model_path),
))
.id();
let harness = OperatorInvariantHarness::capture(&mut world);
let read_controller =
move |world: &mut World| world.get::<AnimationControllerDesc>(entity).cloned();
assert!(assign_animation_clip_operator(
&mut world, selection, entity
));
let assigned = read_controller(&mut world).expect("controller assigned");
assert_eq!(assigned.default_state, "idle");
assert_eq!(assigned.states.len(), 1);
assert_eq!(assigned.states[0].id, "idle");
assert_eq!(
assigned.skeleton.as_ref().unwrap().sub_asset_id,
animation_skeleton_sub_asset_id(0, "Rig")
);
harness.assert_committed(&mut world, 1, 0);
assert_undo_redo_round_trip(&mut world, None, Some(assigned), read_controller);
let _ = std::fs::remove_file(manifest_path);
}
#[test]
fn multi_actor_texture_assignment_is_one_undo_group() {
let mut world = World::new();
world.init_resource::<ActiveOperator>();
world.init_resource::<EditorHistory>();
world.init_resource::<SceneIo>();
let first = world.spawn((LevelObject, MaterialDesc::default())).id();
let second = world.spawn((LevelObject, MaterialDesc::default())).id();
let mut selected = SelectedEntities::default();
selected.select_replace(first);
selected.select_maybe_add(second, true);
let asset = EditorAsset {
label: "Grid".to_string(),
path: Some("assets/textures/grid.png".to_string()),
folder_path: "assets/textures".to_string(),
kind: super::super::EditorAssetKind::Texture,
};
let harness = OperatorInvariantHarness::capture(&mut world);
let read_textures = move |world: &mut World| {
[first, second]
.into_iter()
.map(|entity| {
world
.get::<MaterialDesc>(entity)
.and_then(|material| material.base_color_texture.clone())
})
.collect::<Vec<_>>()
};
assert!(apply_texture_operator(&mut world, asset, &selected));
harness.assert_committed(&mut world, 1, 0);
assert_undo_redo_round_trip(
&mut world,
vec![None, None],
vec![
Some("assets/textures/grid.png".to_string()),
Some("assets/textures/grid.png".to_string()),
],
read_textures,
);
}
#[test]
fn missing_subasset_commit_cancels_without_side_effects() {
let mut world = World::new();
world.init_resource::<ActiveOperator>();
world.init_resource::<EditorHistory>();
world.init_resource::<SceneIo>();
let harness = OperatorInvariantHarness::capture(&mut world);
let selection = AssetSelection::SubAsset {
parent_path: "assets/models/missing.glb".to_string(),
sub_asset_id: "mesh:missing".to_string(),
label: "Missing Mesh".to_string(),
kind: super::super::AssetSubAssetKind::Mesh,
source_path: None,
};
assert!(!place_subasset_operator(&mut world, selection, Vec3::ZERO));
harness.assert_canceled(&mut world);
}
#[test]
fn missing_material_source_cancels_without_side_effects() {
let mut world = World::new();
world.init_resource::<ActiveOperator>();
world.init_resource::<EditorHistory>();
world.init_resource::<SceneIo>();
let entity = world.spawn(LevelObject).id();
let mut selected = SelectedEntities::default();
selected.select_replace(entity);
let harness = OperatorInvariantHarness::capture(&mut world);
let asset = EditorAsset {
label: "Missing Material".to_string(),
path: Some("assets/materials/does-not-exist.material.ron".to_string()),
folder_path: "assets/materials".to_string(),
kind: super::super::EditorAssetKind::Material,
};
assert!(!apply_material_operator(&mut world, asset, &selected));
harness.assert_canceled(&mut world);
}
#[test]
fn texture_assignment_without_authored_selection_is_blocked() {
let mut world = World::new();
world.init_resource::<ActiveOperator>();
world.init_resource::<EditorHistory>();
world.init_resource::<SceneIo>();
let harness = OperatorInvariantHarness::capture(&mut world);
let asset = EditorAsset {
label: "Grid".to_string(),
path: Some("assets/textures/grid.png".to_string()),
folder_path: "assets/textures".to_string(),
kind: super::super::EditorAssetKind::Texture,
};
assert!(!apply_texture_operator(
&mut world,
asset,
&SelectedEntities::default()
));
harness.assert_blocked(&mut world);
}
}

File diff suppressed because it is too large Load Diff

View File

@ -6,7 +6,7 @@ use bevy::prelude::*;
use bevy_egui::{egui, EguiPrimaryContextPass, EguiTextureHandle, EguiUserTextures}; use bevy_egui::{egui, EguiPrimaryContextPass, EguiTextureHandle, EguiUserTextures};
use egui_phosphor_icons::icons; use egui_phosphor_icons::icons;
use super::sources::gltf::{gltf_base_color_texture_path, validate_gltf_dependencies}; use super::sources::gltf::gltf_base_color_texture_path;
use super::studio::{model_file_exists, ThumbnailStudio}; use super::studio::{model_file_exists, ThumbnailStudio};
use super::ThumbnailJobSource; use super::ThumbnailJobSource;
use crate::assets::{ use crate::assets::{
@ -100,12 +100,13 @@ impl AssetThumbnailCache {
return; return;
} }
if !self.model_source_ready(&key, &model_path) { if let Some(texture_path) = gltf_base_color_texture_path(&model_path) {
self.request_texture(key, texture_path, asset_server);
return; return;
} }
if let Some(texture_path) = gltf_base_color_texture_path(&model_path) { if !model_file_exists(&model_path) {
self.request_texture(key, texture_path, asset_server); self.mark_studio_failed(&key, "file not found", false);
return; return;
} }
@ -120,7 +121,6 @@ impl AssetThumbnailCache {
model_path: String, model_path: String,
mesh_label: String, mesh_label: String,
material_label: Option<String>, material_label: Option<String>,
requires_scene_instance: bool,
studio: &mut ThumbnailStudio, studio: &mut ThumbnailStudio,
) { ) {
if matches!( if matches!(
@ -130,19 +130,18 @@ impl AssetThumbnailCache {
{ {
return; return;
} }
if !self.model_source_ready(&key, &model_path) { if !model_file_exists(&model_path) {
self.mark_studio_failed(&key, "file not found", false);
return; return;
} }
let source = if requires_scene_instance { if studio.enqueue_source(
ThumbnailJobSource::Model { model_path } key.clone(),
} else {
ThumbnailJobSource::MeshSubAsset { ThumbnailJobSource::MeshSubAsset {
model_path, model_path,
mesh_label, mesh_label,
material_label, material_label,
} },
}; ) {
if studio.enqueue_source(key.clone(), source) {
self.studio_pending.insert(key); self.studio_pending.insert(key);
} }
} }
@ -161,7 +160,8 @@ impl AssetThumbnailCache {
{ {
return; return;
} }
if !self.model_source_ready(&key, &model_path) { if !model_file_exists(&model_path) {
self.mark_studio_failed(&key, "file not found", false);
return; return;
} }
if studio.enqueue_source( if studio.enqueue_source(
@ -206,18 +206,6 @@ impl AssetThumbnailCache {
} }
} }
fn model_source_ready(&mut self, key: &str, model_path: &str) -> bool {
if !model_file_exists(model_path) {
self.mark_studio_failed(key, "file not found", false);
return false;
}
if let Err(reason) = validate_gltf_dependencies(model_path) {
self.mark_studio_failed(key, &reason, false);
return false;
}
true
}
pub(crate) fn complete_studio_thumbnail( pub(crate) fn complete_studio_thumbnail(
&mut self, &mut self,
key: &str, key: &str,
@ -295,7 +283,6 @@ pub fn kind_icon(kind: &EditorAssetKind) -> egui_phosphor_icons::Icon {
EditorAssetKind::Model => icons::CUBE_TRANSPARENT, EditorAssetKind::Model => icons::CUBE_TRANSPARENT,
EditorAssetKind::Texture => icons::IMAGE, EditorAssetKind::Texture => icons::IMAGE,
EditorAssetKind::Material => icons::PAINT_BRUSH, EditorAssetKind::Material => icons::PAINT_BRUSH,
EditorAssetKind::AudioClip => icons::WAVEFORM,
EditorAssetKind::Level => icons::MAP_TRIFOLD, EditorAssetKind::Level => icons::MAP_TRIFOLD,
EditorAssetKind::Prefab => icons::PACKAGE, EditorAssetKind::Prefab => icons::PACKAGE,
EditorAssetKind::PostProcessVolume => icons::CAMERA, EditorAssetKind::PostProcessVolume => icons::CAMERA,

View File

@ -12,9 +12,7 @@ pub use cache::{
AssetThumbnailCache, AssetThumbnailsPlugin, ThumbnailCacheSnapshot, ThumbnailState, AssetThumbnailCache, AssetThumbnailsPlugin, ThumbnailCacheSnapshot, ThumbnailState,
}; };
pub use job::{ThumbnailJob, ThumbnailJobSource}; pub use job::{ThumbnailJob, ThumbnailJobSource};
pub use sources::{ pub use sources::{FbxThumbnailSource, GltfThumbnailSource, ThumbnailModelSource};
gltf_skinned_primitive_labels, FbxThumbnailSource, GltfThumbnailSource, ThumbnailModelSource,
};
pub use studio::{ pub use studio::{
model_file_exists, model_scene_asset_path, ThumbnailStudio, ThumbnailStudioPlugin, model_file_exists, model_scene_asset_path, ThumbnailStudio, ThumbnailStudioPlugin,
}; };

View File

@ -1,6 +1,5 @@
//! glTF/GLB fast-path texture extraction for asset browser thumbnails. //! glTF/GLB fast-path texture extraction for asset browser thumbnails.
use std::collections::HashSet;
use std::path::Path; use std::path::Path;
/// Returns an asset-server path for the first base-color texture referenced by a glTF/GLB file. /// Returns an asset-server path for the first base-color texture referenced by a glTF/GLB file.
@ -30,67 +29,6 @@ pub fn gltf_base_color_texture_path(model_asset_path: &str) -> Option<String> {
None None
} }
/// Verifies local external buffers and images before Bevy starts an async glTF load.
pub fn validate_gltf_dependencies(model_asset_path: &str) -> Result<(), String> {
let model_path = Path::new(model_asset_path);
if !is_gltf_path(model_path) {
return Ok(());
}
let gltf = gltf::Gltf::open(model_path).map_err(|error| format!("invalid glTF: {error}"))?;
let mut uris = Vec::new();
for buffer in gltf.buffers() {
if let gltf::buffer::Source::Uri(uri) = buffer.source() {
uris.push(uri);
}
}
for image in gltf.images() {
if let gltf::image::Source::Uri { uri, .. } = image.source() {
uris.push(uri);
}
}
let model_dir = model_path.parent().unwrap_or_else(|| Path::new(""));
for uri in uris {
if uri.starts_with("data:") || uri.contains("://") {
continue;
}
let dependency = model_dir.join(uri);
if !dependency.is_file() {
let display = normalize_asset_path(&dependency)
.unwrap_or_else(|| dependency.to_string_lossy().replace('\\', "/"));
return Err(format!("missing dependency: {display}"));
}
}
Ok(())
}
/// Returns primitive labels that cannot be rendered without their source skin hierarchy.
pub fn gltf_skinned_primitive_labels(model_asset_path: &str) -> HashSet<String> {
let model_path = Path::new(model_asset_path);
if !is_gltf_path(model_path) {
return HashSet::new();
}
let Ok(gltf) = gltf::Gltf::open(model_path) else {
return HashSet::new();
};
gltf.meshes()
.flat_map(|mesh| {
let mesh_index = mesh.index();
mesh.primitives()
.filter(|primitive| {
primitive.attributes().any(|(semantic, _)| {
matches!(
semantic,
gltf::Semantic::Joints(_) | gltf::Semantic::Weights(_)
)
})
})
.map(move |primitive| format!("Mesh{mesh_index}/Primitive{}", primitive.index()))
})
.collect()
}
pub struct GltfThumbnailSource; pub struct GltfThumbnailSource;
fn is_gltf_path(path: &Path) -> bool { fn is_gltf_path(path: &Path) -> bool {
@ -123,9 +61,7 @@ fn normalize_asset_path(path: &Path) -> Option<String> {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use std::fs;
use std::path::PathBuf; use std::path::PathBuf;
use std::time::{SystemTime, UNIX_EPOCH};
#[test] #[test]
fn normalize_asset_path_keeps_assets_prefix() { fn normalize_asset_path_keeps_assets_prefix() {
@ -143,37 +79,4 @@ mod tests {
Some("assets/models/textures/chair_albedo.png".into()) Some("assets/models/textures/chair_albedo.png".into())
); );
} }
#[test]
fn dependency_validation_reports_missing_external_buffer() {
let nonce = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
let directory = std::env::temp_dir().join(format!("blacksite-gltf-{nonce}"));
fs::create_dir_all(&directory).unwrap();
let model = directory.join("missing-buffer.gltf");
fs::write(
&model,
r#"{"asset":{"version":"2.0"},"buffers":[{"uri":"missing.bin","byteLength":4}]}"#,
)
.unwrap();
let error = validate_gltf_dependencies(model.to_str().unwrap()).unwrap_err();
assert!(error.contains("missing.bin"));
fs::remove_dir_all(directory).unwrap();
}
#[test]
fn committed_robot_fixture_identifies_skinned_primitives() {
let path =
Path::new(env!("CARGO_MANIFEST_DIR")).join("../../assets/models/robot_expressive.glb");
let labels = gltf_skinned_primitive_labels(path.to_str().unwrap());
assert!(labels.contains("Mesh12/Primitive0"));
assert!(labels.contains("Mesh13/Primitive0"));
assert!(!labels.contains("Mesh0/Primitive0"));
assert!(!labels.contains("Mesh999/Primitive0"));
}
} }

View File

@ -4,7 +4,7 @@ mod fbx;
pub(crate) mod gltf; pub(crate) mod gltf;
pub use fbx::FbxThumbnailSource; pub use fbx::FbxThumbnailSource;
pub use gltf::{gltf_skinned_primitive_labels, GltfThumbnailSource}; pub use gltf::GltfThumbnailSource;
use bevy::prelude::*; use bevy::prelude::*;

View File

@ -11,7 +11,7 @@ use bevy::mesh::VertexAttributeValues;
use bevy::prelude::*; use bevy::prelude::*;
use bevy::render::render_resource::TextureFormat; use bevy::render::render_resource::TextureFormat;
use bevy_egui::EguiUserTextures; use bevy_egui::EguiUserTextures;
use shared::{material_from_desc, standard_material_asset_path, ModelRef}; use shared::{material_from_desc, ModelRef};
use super::cache::AssetThumbnailCache; use super::cache::AssetThumbnailCache;
use super::job::{ThumbnailJob, ThumbnailJobSource}; use super::job::{ThumbnailJob, ThumbnailJobSource};
@ -315,9 +315,6 @@ fn process_thumbnail_studio(
ThumbnailStudioLayer, ThumbnailStudioLayer,
RenderLayers::layer(THUMBNAIL_LAYER), RenderLayers::layer(THUMBNAIL_LAYER),
Transform::default(), Transform::default(),
Visibility::Visible,
InheritedVisibility::default(),
ViewVisibility::default(),
)) ))
.id(); .id();
@ -413,7 +410,7 @@ fn spawn_thumbnail_job_content(
commands, commands,
mesh_storage, mesh_storage,
root, root,
asset_server.load(standard_material_asset_path( asset_server.load(labeled_asset_path(
&asset_server_path(model_path), &asset_server_path(model_path),
material_label, material_label,
)), )),
@ -437,7 +434,7 @@ fn spawn_mesh_subasset_preview(
let source_path = asset_server_path(model_path); let source_path = asset_server_path(model_path);
let mesh: Handle<Mesh> = asset_server.load(labeled_asset_path(&source_path, mesh_label)); let mesh: Handle<Mesh> = asset_server.load(labeled_asset_path(&source_path, mesh_label));
let material = if let Some(material_label) = material_label { let material = if let Some(material_label) = material_label {
asset_server.load(standard_material_asset_path(&source_path, material_label)) asset_server.load(labeled_asset_path(&source_path, material_label))
} else { } else {
materials.add(StandardMaterial { materials.add(StandardMaterial {
base_color: Color::srgb(0.72, 0.72, 0.75), base_color: Color::srgb(0.72, 0.72, 0.75),

View File

@ -1,27 +1,6 @@
//! In-process editor binary entry point. //! In-process editor binary entry point.
fn main() { fn main() {
let startup = match editor::launcher::parse_startup_arguments(std::env::args().skip(1)) {
Ok(startup) => startup,
Err(error) => {
eprintln!(
"Blacksite Editor: {error}\n\n{}",
editor::launcher::command_line_help()
);
std::process::exit(2);
}
};
if startup.help {
println!("{}", editor::launcher::command_line_help());
return;
}
if let Some(root) = startup.project {
if let Err(error) = editor::launcher::activate_project_root(&root) {
eprintln!("Blacksite Editor: {error}");
std::process::exit(2);
}
}
if std::env::var_os("WGPU_VALIDATION").is_none() { if std::env::var_os("WGPU_VALIDATION").is_none() {
// SAFETY: called before any threads or GPU init. // SAFETY: called before any threads or GPU init.
unsafe { std::env::set_var("WGPU_VALIDATION", "0") }; unsafe { std::env::set_var("WGPU_VALIDATION", "0") };

View File

@ -1,9 +0,0 @@
//! Dedicated pre-engine Blacksite project browser.
fn main() {
if std::env::var_os("WGPU_VALIDATION").is_none() {
// SAFETY: called before Bevy starts worker threads or initializes the renderer.
unsafe { std::env::set_var("WGPU_VALIDATION", "0") };
}
editor::launcher::run_project_launcher();
}

View File

@ -1,11 +1,8 @@
use std::path::PathBuf;
use bevy::prelude::*; use bevy::prelude::*;
use shared::{ use shared::{
ActorId, ActorKind, ActorName, AnimationControllerDesc, AudioListenerDesc, AudioSourceDesc, ActorId, ActorKind, ActorName, BrushDesc, ColliderDesc, EditorVisibility, InspectorOrder,
BrushDesc, ColliderDesc, EditorVisibility, InspectorOrder, LightDesc, MaterialDesc, LightDesc, MaterialDesc, MaterialOverride, ModelRef, ObjectiveMarker, PhysicsBody,
MaterialOverride, ModelRef, ObjectiveMarker, PhysicsBody, PostProcessVolumeDesc, PostProcessVolumeDesc, PrefabInstance, PrefabRef, Primitive, RigidBodyDesc, StaticMeshRenderer,
PrefabInstance, PrefabRef, Primitive, RigidBodyDesc, SceneComposition, StaticMeshRenderer,
TeamSpawn, TriggerVolume, WeaponSpawn, TeamSpawn, TriggerVolume, WeaponSpawn,
}; };
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
@ -24,9 +21,6 @@ pub struct EditorEntitySnapshot {
pub collider: Option<ColliderDesc>, pub collider: Option<ColliderDesc>,
pub physics: Option<PhysicsBody>, pub physics: Option<PhysicsBody>,
pub light: Option<LightDesc>, pub light: Option<LightDesc>,
pub animation_controller: Option<AnimationControllerDesc>,
pub audio_source: Option<AudioSourceDesc>,
pub audio_listener: Option<AudioListenerDesc>,
pub player_spawn: bool, pub player_spawn: bool,
pub model: Option<ModelRef>, pub model: Option<ModelRef>,
pub prefab: Option<PrefabRef>, pub prefab: Option<PrefabRef>,
@ -48,17 +42,6 @@ pub struct SiblingChange {
pub new_parent: Option<Entity>, pub new_parent: Option<Entity>,
pub old_index: i32, pub old_index: i32,
pub new_index: i32, pub new_index: i32,
pub old_transform: Option<Transform>,
pub new_transform: Option<Transform>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct PrefabEditState {
pub instance: Option<PrefabInstance>,
pub prefab_ref: Option<PrefabRef>,
pub blocked_reason: Option<String>,
pub transform: Option<Transform>,
pub material: Option<MaterialDesc>,
} }
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
@ -94,11 +77,6 @@ pub enum EditorCommand {
old: Option<MaterialDesc>, old: Option<MaterialDesc>,
new: MaterialDesc, new: MaterialDesc,
}, },
SetMaterialGroup {
entities: Vec<Entity>,
olds: Vec<Option<MaterialDesc>>,
news: Vec<MaterialDesc>,
},
SetMaterialOverride { SetMaterialOverride {
entity: Entity, entity: Entity,
old: Option<MaterialOverride>, old: Option<MaterialOverride>,
@ -109,21 +87,6 @@ pub enum EditorCommand {
old: Option<LightDesc>, old: Option<LightDesc>,
new: LightDesc, new: LightDesc,
}, },
SetAnimationController {
entity: Entity,
old: Option<AnimationControllerDesc>,
new: AnimationControllerDesc,
},
SetAudioSource {
entity: Entity,
old: Option<AudioSourceDesc>,
new: AudioSourceDesc,
},
SetAudioListener {
entity: Entity,
old: Option<AudioListenerDesc>,
new: AudioListenerDesc,
},
SetPhysics { SetPhysics {
entity: Entity, entity: Entity,
old: Option<PhysicsBody>, old: Option<PhysicsBody>,
@ -202,35 +165,6 @@ pub enum EditorCommand {
old: Option<InspectorOrder>, old: Option<InspectorOrder>,
new: InspectorOrder, new: InspectorOrder,
}, },
SetSceneComposition {
old: SceneComposition,
new: SceneComposition,
},
SetPrefabState {
entity: Entity,
old: PrefabEditState,
new: PrefabEditState,
},
ApplyPrefabToSource {
entity: Entity,
source_path: PathBuf,
before_source: String,
after_source: String,
old: PrefabEditState,
new: PrefabEditState,
},
UnpackPrefab {
entity: Entity,
instance: PrefabInstance,
prefab_ref: PrefabRef,
linked_actor_kind: ActorKind,
fully_local: bool,
linked_local_snapshots: Vec<EditorEntitySnapshot>,
linked_local_roots: Vec<Entity>,
unpacked_snapshots: Vec<EditorEntitySnapshot>,
unpacked_parented_to_anchor: Vec<bool>,
unpacked_roots: Vec<Entity>,
},
AddComponent { AddComponent {
entity: Entity, entity: Entity,
snapshot: EditorEntitySnapshot, snapshot: EditorEntitySnapshot,
@ -251,12 +185,8 @@ impl EditorCommand {
EditorCommand::Rename { .. } => "Rename", EditorCommand::Rename { .. } => "Rename",
EditorCommand::SetTransform { .. } => "Move", EditorCommand::SetTransform { .. } => "Move",
EditorCommand::SetMaterial { .. } => "Set Material", EditorCommand::SetMaterial { .. } => "Set Material",
EditorCommand::SetMaterialGroup { .. } => "Apply Material to Selection",
EditorCommand::SetMaterialOverride { .. } => "Set Material Override", EditorCommand::SetMaterialOverride { .. } => "Set Material Override",
EditorCommand::SetLight { .. } => "Set Light", EditorCommand::SetLight { .. } => "Set Light",
EditorCommand::SetAnimationController { .. } => "Set Animation Controller",
EditorCommand::SetAudioSource { .. } => "Set Audio Source",
EditorCommand::SetAudioListener { .. } => "Set Audio Listener",
EditorCommand::SetPhysics { .. } => "Set Physics", EditorCommand::SetPhysics { .. } => "Set Physics",
EditorCommand::SetRigidBody { .. } => "Set Rigid Body", EditorCommand::SetRigidBody { .. } => "Set Rigid Body",
EditorCommand::SetCollider { .. } => "Set Collider", EditorCommand::SetCollider { .. } => "Set Collider",
@ -272,15 +202,6 @@ impl EditorCommand {
EditorCommand::ReorderSibling { .. } => "Reorder Hierarchy", EditorCommand::ReorderSibling { .. } => "Reorder Hierarchy",
EditorCommand::SetEditorVisibility { .. } => "Set Visibility", EditorCommand::SetEditorVisibility { .. } => "Set Visibility",
EditorCommand::SetInspectorOrder { .. } => "Set Component Order", EditorCommand::SetInspectorOrder { .. } => "Set Component Order",
EditorCommand::SetSceneComposition { .. } => "Set Scene Composition",
EditorCommand::SetPrefabState { .. } => "Edit Prefab Instance",
EditorCommand::ApplyPrefabToSource { .. } => "Apply Prefab to Source",
EditorCommand::UnpackPrefab {
fully_local: true, ..
} => "Convert Prefab to Local",
EditorCommand::UnpackPrefab {
fully_local: false, ..
} => "Unpack Prefab Layer",
EditorCommand::AddComponent { .. } => "Add Component", EditorCommand::AddComponent { .. } => "Add Component",
EditorCommand::RemoveComponent { .. } => "Remove Component", EditorCommand::RemoveComponent { .. } => "Remove Component",
} }

File diff suppressed because it is too large Load Diff

View File

@ -22,10 +22,7 @@ pub use ext::extensibility;
pub use ext::hot_reload; pub use ext::hot_reload;
pub use play::net_editor; pub use play::net_editor;
pub use play::state; pub use play::state;
pub use project::diagnostics_bundle;
pub use project::launcher;
pub use project::project_io; pub use project::project_io;
pub use project::session;
pub use project::settings_ui; pub use project::settings_ui;
pub use scene::scene_io; pub use scene::scene_io;
pub use scene::scene_schema; pub use scene::scene_schema;
@ -46,8 +43,7 @@ use bevy::app::PluginGroupBuilder;
use bevy::prelude::*; use bevy::prelude::*;
use bevy_egui::EguiPlugin; use bevy_egui::EguiPlugin;
use game::{ use game::{
launch, GameInputEnabled, GameInputFocused, GamePlugin, GameRenderBootstrap, launch, GameInputEnabled, GameInputFocused, GamePlugin, GameRenderBootstrap, SimEnabled,
GameSceneBootstrap, SimEnabled,
}; };
use transform_gizmo_bevy::prelude::TransformGizmoPlugin; use transform_gizmo_bevy::prelude::TransformGizmoPlugin;
@ -62,7 +58,6 @@ use gizmos::EditorGizmoPlugin;
use history::EditorHistoryPlugin; use history::EditorHistoryPlugin;
use infra::EditorInfraPlugin; use infra::EditorInfraPlugin;
use operators::OperatorPlugin; use operators::OperatorPlugin;
use play::audio_preview::AudioPreviewPlugin;
use play::PlaySessionPlugin; use play::PlaySessionPlugin;
use project_io::ProjectIoPlugin; use project_io::ProjectIoPlugin;
use render_view::RenderViewPlugin; use render_view::RenderViewPlugin;
@ -70,7 +65,6 @@ use scene_io::SceneIoPlugin;
use scene_view::SceneViewPlugin; use scene_view::SceneViewPlugin;
use selection::EditorSelectionPlugin; use selection::EditorSelectionPlugin;
use selection_outline::SelectionOutlinePlugin; use selection_outline::SelectionOutlinePlugin;
use session::EditorSessionPlugin;
use settings_ui::SettingsUiPlugin; use settings_ui::SettingsUiPlugin;
use state::EditorStatePlugin; use state::EditorStatePlugin;
use ui::EditorUiPlugin; use ui::EditorUiPlugin;
@ -103,7 +97,6 @@ impl PluginGroup for EditorPluginGroup {
.add(BrushEditPlugin) .add(BrushEditPlugin)
.add(BrushToolPlugin) .add(BrushToolPlugin)
.add(EditorCameraPlugin) .add(EditorCameraPlugin)
.add(AudioPreviewPlugin)
.add(ActorIconsPlugin) .add(ActorIconsPlugin)
.add(EditorSelectionPlugin) .add(EditorSelectionPlugin)
.add(SelectionOutlinePlugin) .add(SelectionOutlinePlugin)
@ -112,7 +105,6 @@ impl PluginGroup for EditorPluginGroup {
.add(EditorHistoryPlugin) .add(EditorHistoryPlugin)
.add(SceneIoPlugin) .add(SceneIoPlugin)
.add(EditorUiPlugin) .add(EditorUiPlugin)
.add(EditorSessionPlugin)
.add(brp::BrpPlugin); .add(brp::BrpPlugin);
#[cfg(feature = "hot-reload")] #[cfg(feature = "hot-reload")]
let group = group.add(hot_reload::HotReloadPlugin); let group = group.add(hot_reload::HotReloadPlugin);
@ -129,9 +121,6 @@ pub fn configure_editor_app(app: &mut App) {
.insert_resource(GameRenderBootstrap { .insert_resource(GameRenderBootstrap {
defer_player_camera_fx: true, defer_player_camera_fx: true,
}) })
.insert_resource(GameSceneBootstrap {
defer_default_level: true,
})
.add_plugins(GamePlugin) .add_plugins(GamePlugin)
.add_plugins(MeshPickingPlugin) .add_plugins(MeshPickingPlugin)
.add_plugins(TransformGizmoPlugin) .add_plugins(TransformGizmoPlugin)

View File

@ -4,9 +4,6 @@ use bevy::prelude::*;
use crate::history::EditorHistory; use crate::history::EditorHistory;
#[cfg(test)]
pub(crate) mod test_harness;
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub enum OperatorPhase { pub enum OperatorPhase {
Idle, Idle,
@ -230,19 +227,12 @@ fn set_operator_status(world: &mut World, status: OperatorStatus) {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use crate::history::EditorHistory;
use crate::operators::test_harness::OperatorInvariantHarness;
use crate::scene_io::SceneIo;
#[derive(Resource, Default)] #[derive(Resource, Default)]
struct Counter(i32); struct Counter(i32);
#[derive(Component)]
struct PreviewHelper;
struct CounterOperator { struct CounterOperator {
before: i32, before: i32,
helper: Option<Entity>,
} }
impl EditorOperator for CounterOperator { impl EditorOperator for CounterOperator {
@ -256,7 +246,6 @@ mod tests {
fn begin(&mut self, world: &mut World) -> Result<(), String> { fn begin(&mut self, world: &mut World) -> Result<(), String> {
self.before = world.resource::<Counter>().0; self.before = world.resource::<Counter>().0;
self.helper = Some(world.spawn(PreviewHelper).id());
Ok(()) Ok(())
} }
@ -265,50 +254,34 @@ mod tests {
Ok(()) Ok(())
} }
fn commit(&mut self, world: &mut World) -> Result<(), String> { fn commit(&mut self, _world: &mut World) -> Result<(), String> {
self.remove_helper(world);
Ok(()) Ok(())
} }
fn cancel(&mut self, world: &mut World) { fn cancel(&mut self, world: &mut World) {
world.resource_mut::<Counter>().0 = self.before; world.resource_mut::<Counter>().0 = self.before;
self.remove_helper(world);
} }
} }
impl CounterOperator {
fn remove_helper(&mut self, world: &mut World) {
if let Some(helper) = self.helper.take() {
if let Ok(entity) = world.get_entity_mut(helper) {
entity.despawn();
}
}
}
}
fn test_world() -> World {
let mut world = World::new();
world.init_resource::<ActiveOperator>();
world.init_resource::<Counter>();
world.init_resource::<EditorHistory>();
world.init_resource::<SceneIo>();
world
}
#[test] #[test]
fn operator_commit_keeps_previewed_change() { fn operator_commit_keeps_previewed_change() {
let mut world = test_world(); let mut world = World::new();
let harness = OperatorInvariantHarness::capture(&mut world); world.init_resource::<ActiveOperator>();
world.init_resource::<Counter>();
let mut operator = CounterOperator { let mut operator = CounterOperator { before: 0 };
before: 0,
helper: None,
};
assert!(run_operator(&mut world, &mut operator)); assert!(run_operator(&mut world, &mut operator));
assert_eq!(world.resource::<Counter>().0, 1); assert_eq!(world.resource::<Counter>().0, 1);
harness.assert_committed(&mut world, 0, 0); assert_eq!(
harness.assert_no_helpers::<PreviewHelper>(&mut world); world
.resource::<ActiveOperator>()
.status
.as_ref()
.unwrap()
.phase,
OperatorPhase::Committed
);
} }
#[test] #[test]
@ -341,38 +314,22 @@ mod tests {
} }
} }
let mut world = test_world(); let mut world = World::new();
let harness = OperatorInvariantHarness::capture(&mut world); world.init_resource::<ActiveOperator>();
world.init_resource::<Counter>();
let mut operator = FailingCounterOperator(CounterOperator { let mut operator = FailingCounterOperator(CounterOperator { before: 0 });
before: 0,
helper: None,
});
assert!(!run_operator(&mut world, &mut operator)); assert!(!run_operator(&mut world, &mut operator));
assert_eq!(world.resource::<Counter>().0, 0); assert_eq!(world.resource::<Counter>().0, 0);
harness.assert_canceled(&mut world); assert_eq!(
harness.assert_no_helpers::<PreviewHelper>(&mut world); world
} .resource::<ActiveOperator>()
.status
#[test] .as_ref()
fn blocked_operator_never_begins_or_mutates_world() { .unwrap()
let mut world = test_world(); .phase,
let harness = OperatorInvariantHarness::capture(&mut world); OperatorPhase::Canceled
let result = run_immediate_operator(
&mut world,
"test.blocked",
"Blocked Operator",
OperatorAvailability::Disabled("fixture unavailable".to_string()),
|world| {
world.resource_mut::<Counter>().0 += 1;
Ok(())
},
); );
assert!(!result);
assert_eq!(world.resource::<Counter>().0, 0);
harness.assert_blocked(&mut world);
} }
} }

View File

@ -1,136 +0,0 @@
//! Reusable invariant assertions for modal tools and editor operators.
use std::fmt::Debug;
use bevy::prelude::*;
use shared::LevelObject;
use crate::history::{apply_command_redo, apply_command_undo, EditorHistory};
use crate::operators::{ActiveOperator, OperatorPhase};
use crate::scene_io::SceneIo;
#[derive(Debug)]
pub(crate) struct OperatorInvariantHarness {
undo_depth: usize,
dirty: bool,
authored_count: usize,
}
impl OperatorInvariantHarness {
pub(crate) fn capture(world: &mut World) -> Self {
Self {
undo_depth: world
.get_resource::<EditorHistory>()
.map(EditorHistory::undo_depth)
.unwrap_or_default(),
dirty: world
.get_resource::<SceneIo>()
.is_some_and(|scene_io| scene_io.dirty),
authored_count: authored_count(world),
}
}
pub(crate) fn assert_committed(
&self,
world: &mut World,
undo_delta: usize,
authored_delta: isize,
) {
self.assert_phase(world, OperatorPhase::Committed);
assert_eq!(
world.resource::<EditorHistory>().undo_depth(),
self.undo_depth + undo_delta,
"committed operator must create the expected number of undo groups"
);
if undo_delta > 0 {
assert!(
world.resource::<SceneIo>().dirty,
"a committed authored mutation must mark the scene dirty"
);
}
assert_eq!(
authored_count(world) as isize,
self.authored_count as isize + authored_delta,
"committed operator changed an unexpected number of authored entities"
);
}
pub(crate) fn assert_canceled(&self, world: &mut World) {
self.assert_phase(world, OperatorPhase::Canceled);
assert_eq!(
world.resource::<EditorHistory>().undo_depth(),
self.undo_depth,
"canceled operator must not create undo history"
);
assert_eq!(
world.resource::<SceneIo>().dirty,
self.dirty,
"canceled operator must preserve the prior dirty state"
);
assert_eq!(
authored_count(world),
self.authored_count,
"canceled operator must restore authored entity state"
);
}
pub(crate) fn assert_blocked(&self, world: &mut World) {
self.assert_phase(world, OperatorPhase::Blocked);
assert_eq!(
world.resource::<EditorHistory>().undo_depth(),
self.undo_depth
);
assert_eq!(world.resource::<SceneIo>().dirty, self.dirty);
assert_eq!(authored_count(world), self.authored_count);
}
pub(crate) fn assert_no_helpers<T: Component>(&self, world: &mut World) {
let mut query = world.query_filtered::<Entity, With<T>>();
assert_eq!(
query.iter(world).count(),
0,
"operator left preview/helper entities behind"
);
}
fn assert_phase(&self, world: &World, expected: OperatorPhase) {
let phase = world
.resource::<ActiveOperator>()
.status
.as_ref()
.map(|status| status.phase.clone());
assert_eq!(phase, Some(expected), "operator reported the wrong phase");
}
}
pub(crate) fn assert_undo_redo_round_trip<T>(
world: &mut World,
initial: T,
committed: T,
mut read: impl FnMut(&mut World) -> T,
) where
T: Debug + PartialEq,
{
assert_eq!(
read(world),
committed,
"commit state did not match expectation"
);
apply_command_undo(world);
assert_eq!(
read(world),
initial,
"undo did not restore the initial state"
);
apply_command_redo(world);
assert_eq!(
read(world),
committed,
"redo did not restore the committed state"
);
}
fn authored_count(world: &mut World) -> usize {
let mut query = world.query_filtered::<Entity, With<LevelObject>>();
query.iter(world).count()
}

View File

@ -1,468 +0,0 @@
//! Transient, non-dirty audio audition owned by the editor.
use bevy::asset::LoadState;
use bevy::audio::{
AudioPlayer, AudioSink, AudioSinkPlayback, AudioSource, PlaybackMode, PlaybackSettings,
SpatialAudioSink, SpatialListener, SpatialScale, Volume,
};
use bevy::prelude::*;
use settings::AUDIO_BUS_SFX_ID;
use shared::{asset_server_path, AudioAttenuationDesc, AudioSourceDesc};
use crate::assets::{EditorAsset, EditorAssetKind};
use crate::camera::EditorCamera;
use crate::infra::EditorOnly;
use crate::state::EditorMode;
#[derive(Resource, Debug, Clone, Default)]
pub struct AudioPreviewState {
pub voices: Vec<Entity>,
pub source: Option<Entity>,
pub clip_path: Option<String>,
pub status: String,
clip_handle: Option<Handle<AudioSource>>,
listener: Option<Entity>,
}
#[derive(Component, Debug, Clone)]
struct AudioPreviewVoice {
source: Option<Entity>,
bus: String,
source_gain_db: f32,
mix_weight: f32,
spatial: bool,
attenuation: AudioAttenuationDesc,
}
#[derive(Component, Debug, Clone, Copy)]
struct EditorAudioPreviewListener;
pub struct AudioPreviewPlugin;
impl Plugin for AudioPreviewPlugin {
fn build(&self, app: &mut App) {
app.init_resource::<AudioPreviewState>()
.add_systems(Update, update_audio_preview)
.add_systems(OnEnter(EditorMode::Playing), stop_audio_preview);
}
}
pub fn audition_audio_source(world: &mut World, source: Entity) -> Result<(), String> {
ensure_edit_mode(world)?;
let descriptor = world
.get::<AudioSourceDesc>(source)
.cloned()
.ok_or_else(|| "Selected actor has no AudioSourceDesc".to_string())?;
let path = descriptor
.clip
.as_ref()
.and_then(|clip| clip.source_path.as_deref())
.filter(|path| !path.trim().is_empty())
.ok_or_else(|| "Assign an audio clip before auditioning this source".to_string())?
.to_string();
spawn_preview(world, path, Some((source, descriptor)))
}
pub fn audition_audio_asset(world: &mut World, asset: &EditorAsset) -> Result<(), String> {
ensure_edit_mode(world)?;
if !matches!(asset.kind, EditorAssetKind::AudioClip) {
return Err("Selected asset is not an audio clip".into());
}
let path = asset
.path
.as_deref()
.filter(|path| !path.trim().is_empty())
.ok_or_else(|| "Audio clip has no project path".to_string())?
.to_string();
spawn_preview(world, path, None)
}
fn ensure_edit_mode(world: &World) -> Result<(), String> {
if *world.resource::<State<EditorMode>>().get() != EditorMode::Editing {
return Err("Audio audition is available only in Edit mode".into());
}
Ok(())
}
fn spawn_preview(
world: &mut World,
path: String,
source: Option<(Entity, AudioSourceDesc)>,
) -> Result<(), String> {
stop_audio_preview(world);
let descriptor = source
.as_ref()
.map(|(_, descriptor)| descriptor.clone())
.unwrap_or_else(|| AudioSourceDesc {
autoplay: false,
spatial_blend: 0.0,
bus: AUDIO_BUS_SFX_ID.into(),
..Default::default()
});
let source_entity = source.as_ref().map(|(entity, _)| *entity);
let mixes = game::audio::audio_voice_mix(descriptor.spatial_blend);
let panning_scale = game::audio::spatial_panning_scale(&descriptor.attenuation, 0.2);
let needs_listener = mixes.iter().any(|(spatial, _)| *spatial);
let listener = if needs_listener {
let mut query = world
.query_filtered::<(Entity, &GlobalTransform, Has<SpatialListener>), With<EditorCamera>>(
);
Some(
query
.iter(world)
.next()
.map(|(entity, transform, has_listener)| (entity, *transform, has_listener))
.ok_or_else(|| "Editor camera is unavailable for spatial audition".to_string())?,
)
} else {
None
};
let listener = listener.map(|(camera, camera_transform, has_listener)| {
if has_listener {
camera
} else {
world
.spawn((
Name::new("Editor Audio Preview Listener"),
EditorOnly,
Transform::IDENTITY,
camera_transform,
SpatialListener::new(0.2),
EditorAudioPreviewListener,
ChildOf(camera),
))
.id()
}
});
let asset_server = world.resource::<AssetServer>().clone();
let handle: Handle<AudioSource> = asset_server.load(asset_server_path(&path));
let mut voices = Vec::with_capacity(mixes.len());
for (spatial, mix_weight) in mixes {
let mut voice = world.spawn((
Name::new(if spatial {
"Editor Spatial Audio Preview"
} else {
"Editor 2D Audio Preview"
}),
EditorOnly,
Transform::IDENTITY,
AudioPlayer::new(handle.clone()),
PlaybackSettings {
mode: PlaybackMode::Once,
volume: Volume::Linear(0.0),
speed: descriptor.pitch,
paused: false,
muted: false,
spatial,
spatial_scale: spatial.then(|| SpatialScale::new(panning_scale)),
start_position: None,
duration: None,
},
AudioPreviewVoice {
source: source_entity,
bus: descriptor.bus.clone(),
source_gain_db: descriptor.gain_db,
mix_weight,
spatial,
attenuation: descriptor.attenuation,
},
));
if let Some(source) = source_entity {
voice.insert(ChildOf(source));
}
voices.push(voice.id());
}
*world.resource_mut::<AudioPreviewState>() = AudioPreviewState {
voices,
source: source_entity,
clip_path: Some(path.clone()),
status: format!("Auditioning {}", file_label(&path)),
clip_handle: Some(handle),
listener,
};
Ok(())
}
fn file_label(path: &str) -> &str {
std::path::Path::new(path)
.file_name()
.and_then(|name| name.to_str())
.unwrap_or(path)
}
pub fn stop_audio_preview(world: &mut World) {
stop_audio_preview_with_status(world, "Audio preview stopped");
}
fn stop_audio_preview_with_status(world: &mut World, status: impl Into<String>) {
let voices = std::mem::take(&mut world.resource_mut::<AudioPreviewState>().voices);
for voice in voices {
if let Some(sink) = world.get::<AudioSink>(voice) {
sink.stop();
}
if let Some(sink) = world.get::<SpatialAudioSink>(voice) {
sink.stop();
}
if let Ok(entity_mut) = world.get_entity_mut(voice) {
entity_mut.despawn();
}
}
let mut query = world.query_filtered::<Entity, With<EditorAudioPreviewListener>>();
let listeners: Vec<Entity> = query.iter(world).collect();
for listener in listeners {
if let Ok(entity_mut) = world.get_entity_mut(listener) {
entity_mut.despawn();
}
}
let state = world.resource_mut::<AudioPreviewState>().into_inner();
state.source = None;
state.clip_path = None;
state.clip_handle = None;
state.listener = None;
state.status = status.into();
}
fn update_audio_preview(world: &mut World) {
let state = world.resource::<AudioPreviewState>().clone();
if state.voices.is_empty() {
return;
}
if *world.resource::<State<EditorMode>>().get() != EditorMode::Editing {
stop_audio_preview_with_status(world, "Audio preview stopped on entering Play mode");
return;
}
if state.source.is_some_and(|source| {
world.get_entity(source).is_err() || world.get::<AudioSourceDesc>(source).is_none()
}) {
stop_audio_preview_with_status(world, "Audio preview stopped because its source changed");
return;
}
if state
.listener
.is_some_and(|listener| world.get::<GlobalTransform>(listener).is_none())
{
stop_audio_preview_with_status(
world,
"Audio preview stopped because the editor camera changed",
);
return;
}
if let Some(LoadState::Failed(error)) = state
.clip_handle
.as_ref()
.map(|handle| world.resource::<AssetServer>().load_state(handle.id()))
{
let status = format!("Audio preview failed: {error}");
stop_audio_preview_with_status(world, status.clone());
if let Some(mut scene_io) = world.get_resource_mut::<crate::scene_io::SceneIo>() {
scene_io.set_status(status);
}
return;
}
let listener_position = state
.listener
.and_then(|listener| world.get::<GlobalTransform>(listener))
.map(GlobalTransform::translation);
let project_audio = world.resource::<settings::ProjectSettings>().audio.clone();
let bus_runtime = world.resource::<game::audio::AudioBusRuntime>().clone();
let mut finished = 0usize;
for voice_entity in &state.voices {
let Some(voice) = world.get::<AudioPreviewVoice>(*voice_entity).cloned() else {
finished += 1;
continue;
};
let source_position = voice
.source
.and_then(|source| world.get::<GlobalTransform>(source))
.map(GlobalTransform::translation);
let attenuation = if voice.spatial {
match (listener_position, source_position) {
(Some(listener), Some(source)) => {
game::audio::attenuation_gain(&voice.attenuation, listener.distance(source))
}
_ => 0.0,
}
} else {
1.0
};
let gain = game::audio::db_to_linear(voice.source_gain_db)
* game::audio::effective_bus_gain_linear(&project_audio, &bus_runtime, &voice.bus)
* voice.mix_weight
* attenuation;
if let Some(mut sink) = world.get_mut::<AudioSink>(*voice_entity) {
sink.set_volume(Volume::Linear(gain));
if sink.empty() {
finished += 1;
}
} else if let Some(mut sink) = world.get_mut::<SpatialAudioSink>(*voice_entity) {
sink.set_volume(Volume::Linear(gain));
if sink.empty() {
finished += 1;
}
}
}
if finished == state.voices.len() {
stop_audio_preview_with_status(world, "Audio preview finished");
}
}
#[cfg(test)]
mod tests {
use super::*;
use bevy::asset::{AssetApp, AssetPlugin};
use bevy::state::app::StatesPlugin;
use shared::{EditorAssetRef, AUDIO_CLIP_SUB_ASSET_ID};
#[test]
fn file_label_is_stable_for_project_paths() {
assert_eq!(file_label("assets/audio/impact.ogg"), "impact.ogg");
assert_eq!(file_label("clip.wav"), "clip.wav");
}
fn preview_app() -> App {
let mut app = App::new();
app.add_plugins((MinimalPlugins, AssetPlugin::default(), StatesPlugin))
.init_asset::<AudioSource>()
.init_state::<EditorMode>()
.add_plugins(AudioPreviewPlugin)
.init_resource::<crate::scene_io::SceneIo>()
.init_resource::<crate::history::EditorHistory>()
.init_resource::<game::audio::AudioBusRuntime>()
.insert_resource(settings::ProjectSettings::default());
app
}
fn spawn_camera(app: &mut App, listener: Option<SpatialListener>) -> Entity {
let mut camera = app.world_mut().spawn((
EditorCamera::default(),
Transform::IDENTITY,
GlobalTransform::IDENTITY,
));
if let Some(listener) = listener {
camera.insert(listener);
}
camera.id()
}
fn spawn_source(app: &mut App) -> Entity {
app.world_mut()
.spawn((
Transform::from_xyz(0.0, 0.0, -2.0),
GlobalTransform::IDENTITY,
AudioSourceDesc {
clip: Some(
EditorAssetRef::new("audio-id", AUDIO_CLIP_SUB_ASSET_ID, "Impact")
.with_source_path("assets/audio/impact.ogg"),
),
..Default::default()
},
))
.id()
}
#[test]
fn audition_and_stop_do_not_dirty_scene_or_push_history() {
let mut app = preview_app();
spawn_camera(&mut app, None);
let source = spawn_source(&mut app);
audition_audio_source(app.world_mut(), source).unwrap();
assert!(!app
.world()
.resource::<AudioPreviewState>()
.voices
.is_empty());
assert!(!app.world().resource::<crate::scene_io::SceneIo>().dirty);
assert_eq!(
app.world()
.resource::<crate::history::EditorHistory>()
.undo_depth(),
0
);
stop_audio_preview(app.world_mut());
assert!(app
.world()
.resource::<AudioPreviewState>()
.voices
.is_empty());
assert!(!app.world().resource::<crate::scene_io::SceneIo>().dirty);
assert_eq!(
app.world()
.resource::<crate::history::EditorHistory>()
.undo_depth(),
0
);
}
#[test]
fn spatial_preview_preserves_an_existing_editor_camera_listener() {
let mut app = preview_app();
let camera = spawn_camera(&mut app, Some(SpatialListener::new(0.5)));
let source = spawn_source(&mut app);
let expected = app.world().get::<SpatialListener>(camera).unwrap().clone();
audition_audio_source(app.world_mut(), source).unwrap();
let actual = app.world().get::<SpatialListener>(camera).unwrap();
assert_eq!(actual.left_ear_offset, expected.left_ear_offset);
assert_eq!(actual.right_ear_offset, expected.right_ear_offset);
assert_eq!(
app.world().resource::<AudioPreviewState>().listener,
Some(camera)
);
let mut preview_listeners = app
.world_mut()
.query_filtered::<Entity, With<EditorAudioPreviewListener>>();
assert_eq!(preview_listeners.iter(app.world()).count(), 0);
stop_audio_preview(app.world_mut());
let actual = app.world().get::<SpatialListener>(camera).unwrap();
assert_eq!(actual.left_ear_offset, expected.left_ear_offset);
assert_eq!(actual.right_ear_offset, expected.right_ear_offset);
}
#[test]
fn source_component_removal_cleans_up_preview() {
let mut app = preview_app();
spawn_camera(&mut app, None);
let source = spawn_source(&mut app);
audition_audio_source(app.world_mut(), source).unwrap();
app.world_mut()
.entity_mut(source)
.remove::<AudioSourceDesc>();
update_audio_preview(app.world_mut());
let state = app.world().resource::<AudioPreviewState>();
assert!(state.voices.is_empty());
assert!(state.source.is_none());
assert!(state.status.contains("source changed"));
}
#[test]
fn entering_play_mode_cleans_up_preview() {
let mut app = preview_app();
spawn_camera(&mut app, None);
let source = spawn_source(&mut app);
audition_audio_source(app.world_mut(), source).unwrap();
app.world_mut()
.resource_mut::<NextState<EditorMode>>()
.set(EditorMode::Playing);
app.update();
let state = app.world().resource::<AudioPreviewState>();
assert!(state.voices.is_empty());
assert!(state.source.is_none());
let mut preview_listeners = app
.world_mut()
.query_filtered::<Entity, With<EditorAudioPreviewListener>>();
assert_eq!(preview_listeners.iter(app.world()).count(), 0);
}
}

View File

@ -1,6 +1,5 @@
//! Play-in-editor session, editor mode state, and net editor profiles. //! Play-in-editor session, editor mode state, and net editor profiles.
pub mod audio_preview;
pub mod net_editor; pub mod net_editor;
mod session; mod session;
pub mod state; pub mod state;

View File

@ -7,6 +7,7 @@ use protocol::PlayerInputIntent;
use sim::{Crouching, Grounded, JumpState, Player, PlayerVelocity}; use sim::{Crouching, Grounded, JumpState, Player, PlayerVelocity};
use crate::camera::EditorCamera; use crate::camera::EditorCamera;
use crate::scene_io::SceneIo;
/// Editor/game mode. `Playing` runs the real game sim; use [`PlayPossession`] for camera control. /// Editor/game mode. `Playing` runs the real game sim; use [`PlayPossession`] for camera control.
#[derive(States, Debug, Clone, Copy, PartialEq, Eq, Hash, Default)] #[derive(States, Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
@ -251,8 +252,6 @@ fn sync_play_input_focus(
} }
fn enter_play_mode(world: &mut World) { fn enter_play_mode(world: &mut World) {
// Edit-mode scrubbing must not override authored controller playback in PIE.
game::animation::stop_animation_preview(world);
let snapshot = snapshot_player_sim(world); let snapshot = snapshot_player_sim(world);
world.resource_mut::<PlayModeSnapshot>().player = Some(snapshot); world.resource_mut::<PlayModeSnapshot>().player = Some(snapshot);
} }
@ -261,9 +260,9 @@ fn exit_play_mode(world: &mut World) {
if let Some(snapshot) = world.resource_mut::<PlayModeSnapshot>().player.take() { if let Some(snapshot) = world.resource_mut::<PlayModeSnapshot>().player.take() {
restore_player_sim(world, &snapshot); restore_player_sim(world, &snapshot);
} }
world.resource_mut::<SimEnabled>().0 = false; if let Some(mut scene_io) = world.get_resource_mut::<SceneIo>() {
game::animation::reset_authored_animation_runtime(world); scene_io.dirty = true;
game::audio::reset_authored_audio_runtime(world); }
} }
fn snapshot_player_sim(world: &mut World) -> PlayerSimSnapshot { fn snapshot_player_sim(world: &mut World) -> PlayerSimSnapshot {

View File

@ -1,278 +0,0 @@
//! Privacy-bounded support bundle export for editor troubleshooting.
use std::path::PathBuf;
use std::time::{SystemTime, UNIX_EPOCH};
use bevy::prelude::*;
use bevy::render::renderer::RenderAdapterInfo;
use serde::{Deserialize, Serialize};
use shared::{brush_math::validate_brush, BrushDesc, LevelObject};
use crate::project_io::ProjectWorkspace;
use crate::scene::recovery::{atomic_write, default_state_root};
use crate::scene_io::{SceneIo, SceneIoEventSeverity};
use crate::session::EditorSessionRuntime;
const DIAGNOSTICS_SCHEMA_VERSION: u32 = 1;
const DIAGNOSTICS_DIR: &str = "diagnostics";
const MAX_EVENT_MESSAGE_CHARS: usize = 512;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct DiagnosticBuildInfo {
pub editor_version: String,
pub bevy_version: String,
pub target_os: String,
pub target_arch: String,
pub build_profile: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct DiagnosticRendererInfo {
pub adapter_name: String,
pub device_type: String,
pub backend: String,
pub driver: String,
pub driver_info: String,
pub vendor_id: u32,
pub device_id: u32,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct DiagnosticProjectInfo {
pub project_root: String,
pub settings_path: String,
pub active_scene: Option<String>,
pub scene_dirty: bool,
pub project_settings_dirty: bool,
pub layout_dirty: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct DiagnosticValidationSummary {
pub level_object_count: usize,
pub brush_actor_count: usize,
pub invalid_brush_count: usize,
pub brush_error_count: usize,
pub brush_warning_count: usize,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct DiagnosticSceneIoEvent {
pub id: u64,
pub severity: String,
pub message: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct DiagnosticBundleDocument {
pub schema_version: u32,
pub created_unix_seconds: u64,
pub build: DiagnosticBuildInfo,
pub renderer: Option<DiagnosticRendererInfo>,
pub project: DiagnosticProjectInfo,
pub validation: DiagnosticValidationSummary,
pub prior_abnormal_shutdown: bool,
pub scene_io_events: Vec<DiagnosticSceneIoEvent>,
}
/// Writes an allowlisted support report without scene data, environment values, or credentials.
pub fn export_diagnostic_bundle(world: &mut World) -> Result<PathBuf, String> {
let document = capture_diagnostic_bundle(world);
let directory = default_state_root()
.ok_or_else(|| "XDG_STATE_HOME and HOME are both unavailable".to_string())?
.join(DIAGNOSTICS_DIR);
let timestamp = document.created_unix_seconds;
let path = directory.join(format!(
"blacksite-diagnostics-{timestamp:020}-{}.ron",
std::process::id()
));
let text = serialize_diagnostic_bundle(&document)?;
atomic_write(&path, text.as_bytes())?;
Ok(path)
}
fn capture_diagnostic_bundle(world: &mut World) -> DiagnosticBundleDocument {
let validation = validation_summary(world);
let renderer =
world
.get_resource::<RenderAdapterInfo>()
.map(|adapter| DiagnosticRendererInfo {
adapter_name: adapter.name.clone(),
device_type: format!("{:?}", adapter.device_type),
backend: format!("{:?}", adapter.backend),
driver: adapter.driver.clone(),
driver_info: adapter.driver_info.clone(),
vendor_id: adapter.vendor,
device_id: adapter.device,
});
let scene_io = world.resource::<SceneIo>();
let workspace = world.resource::<ProjectWorkspace>();
let project = DiagnosticProjectInfo {
project_root: workspace.root.clone(),
settings_path: workspace.settings_path.clone(),
active_scene: scene_io
.active_path
.as_ref()
.map(|path| path.display().to_string()),
scene_dirty: scene_io.has_unsaved_tabs(),
project_settings_dirty: workspace.settings_dirty,
layout_dirty: workspace.layout_dirty,
};
let scene_io_events = scene_io
.events
.iter()
.map(|event| DiagnosticSceneIoEvent {
id: event.id,
severity: match event.severity {
SceneIoEventSeverity::Info => "info",
SceneIoEventSeverity::Error => "error",
}
.to_string(),
message: bounded_message(&event.message),
})
.collect();
DiagnosticBundleDocument {
schema_version: DIAGNOSTICS_SCHEMA_VERSION,
created_unix_seconds: SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs(),
build: build_info(),
renderer,
project,
validation,
prior_abnormal_shutdown: world
.get_resource::<EditorSessionRuntime>()
.is_some_and(|runtime| runtime.prior_abnormal_shutdown),
scene_io_events,
}
}
fn validation_summary(world: &mut World) -> DiagnosticValidationSummary {
let mut level_query = world.query_filtered::<Entity, With<LevelObject>>();
let level_object_count = level_query.iter(world).count();
let mut brush_query = world.query_filtered::<&BrushDesc, With<LevelObject>>();
let mut summary = DiagnosticValidationSummary {
level_object_count,
brush_actor_count: 0,
invalid_brush_count: 0,
brush_error_count: 0,
brush_warning_count: 0,
};
for brush in brush_query.iter(world) {
summary.brush_actor_count += 1;
let report = validate_brush(brush);
let errors = report
.diagnostics
.iter()
.filter(|diagnostic| {
diagnostic.severity == shared::brush_math::BrushDiagnosticSeverity::Error
})
.count();
let warnings = report.diagnostics.len().saturating_sub(errors);
summary.brush_error_count += errors;
summary.brush_warning_count += warnings;
summary.invalid_brush_count += usize::from(errors > 0);
}
summary
}
fn build_info() -> DiagnosticBuildInfo {
DiagnosticBuildInfo {
editor_version: env!("CARGO_PKG_VERSION").to_string(),
bevy_version: "0.19".to_string(),
target_os: std::env::consts::OS.to_string(),
target_arch: std::env::consts::ARCH.to_string(),
build_profile: if cfg!(debug_assertions) {
"debug"
} else {
"release"
}
.to_string(),
}
}
fn bounded_message(message: &str) -> String {
message
.chars()
.filter(|character| !character.is_control() || *character == ' ')
.take(MAX_EVENT_MESSAGE_CHARS)
.collect()
}
fn serialize_diagnostic_bundle(document: &DiagnosticBundleDocument) -> Result<String, String> {
ron::ser::to_string_pretty(document, ron::ser::PrettyConfig::default())
.map_err(|error| format!("could not serialize diagnostics: {error}"))
}
#[cfg(test)]
mod tests {
use super::*;
fn fixture() -> DiagnosticBundleDocument {
DiagnosticBundleDocument {
schema_version: DIAGNOSTICS_SCHEMA_VERSION,
created_unix_seconds: 42,
build: build_info(),
renderer: None,
project: DiagnosticProjectInfo {
project_root: "/project".to_string(),
settings_path: "assets/project.ron".to_string(),
active_scene: Some("assets/levels/test.scn.ron".to_string()),
scene_dirty: true,
project_settings_dirty: false,
layout_dirty: false,
},
validation: DiagnosticValidationSummary {
level_object_count: 12,
brush_actor_count: 2,
invalid_brush_count: 1,
brush_error_count: 1,
brush_warning_count: 0,
},
prior_abnormal_shutdown: true,
scene_io_events: vec![DiagnosticSceneIoEvent {
id: 1,
severity: "error".to_string(),
message: "Save failed: disk full".to_string(),
}],
}
}
#[test]
fn serialized_bundle_contains_only_allowlisted_sections() {
let text = serialize_diagnostic_bundle(&fixture()).unwrap();
for allowed in [
"build",
"renderer",
"project",
"validation",
"scene_io_events",
] {
assert!(text.contains(allowed));
}
for forbidden in [
"scene_contents",
"asset_contents",
"environment",
"credential",
"access_token",
"hostname",
"username",
"modal_operator",
] {
assert!(!text.to_ascii_lowercase().contains(forbidden));
}
}
#[test]
fn event_messages_are_control_free_and_bounded() {
let message = format!("failure\n{}", "x".repeat(MAX_EVENT_MESSAGE_CHARS + 20));
let bounded = bounded_message(&message);
assert!(bounded.chars().count() <= MAX_EVENT_MESSAGE_CHARS);
assert!(!bounded.contains('\n'));
}
}

View File

@ -1,847 +0,0 @@
//! Pre-engine project validation, recent-project discovery, and sandbox scaffolding.
use std::collections::HashSet;
use std::path::{Component, Path, PathBuf};
use std::process::Command;
use bevy::app::AppExit;
use bevy::prelude::*;
use bevy::window::{CompositeAlphaMode, PresentMode, WindowPlugin};
use bevy_egui::{egui, EguiContext, EguiPlugin, EguiPrimaryContextPass, PrimaryEguiContext};
use settings::{
save_project_settings_to_string, ProjectKind, ProjectSettings, DEFAULT_PROJECT_PATH,
PROJECT_TEMPLATE_VERSION,
};
use crate::project_io::UserPreferences;
use crate::scene::recovery::atomic_write;
use crate::ui::theme::{
apply_editor_theme, ACCENT, BORDER, CANVAS_BG, PANEL_BG, PANEL_BG_DARK, SELECTION, TEXT,
TEXT_DIM, TEXT_MUTED,
};
const SANDBOX_DIRECTORIES: &[&str] = &[
"assets/.index",
"assets/.trash",
"assets/audio",
"assets/build_profiles",
"assets/levels",
"assets/materials",
"assets/meshes/generated",
"assets/models",
"assets/post_fx",
"assets/rendering_profiles",
"assets/shaders",
"assets/textures",
];
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProjectSummary {
pub root: PathBuf,
pub project_id: String,
pub name: String,
pub project_kind: ProjectKind,
pub template_version: u32,
pub capabilities: Vec<String>,
pub default_level: PathBuf,
pub last_scene: Option<PathBuf>,
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct StartupArguments {
pub project: Option<PathBuf>,
pub help: bool,
}
#[derive(Resource)]
struct ProjectLauncherUi {
recents: Vec<ProjectSummary>,
sandbox_name: String,
sandbox_destination: Option<PathBuf>,
status: Option<String>,
}
impl Default for ProjectLauncherUi {
fn default() -> Self {
let mut preferences = crate::project_io::read_user_preferences();
if let Ok(root) = std::env::current_dir().and_then(|root| root.canonicalize()) {
crate::project_io::remember_project_root(&mut preferences, &root);
}
Self {
recents: recent_project_summaries(&preferences),
sandbox_name: "New Sandbox".to_string(),
sandbox_destination: None,
status: None,
}
}
}
pub fn run_project_launcher() {
let mut app = App::new();
app.add_plugins(DefaultPlugins.set(WindowPlugin {
primary_window: Some(Window {
title: "Blacksite Project Browser".to_string(),
name: Some("blacksite-project-launcher".to_string()),
resolution: (960, 620).into(),
present_mode: PresentMode::AutoVsync,
transparent: false,
composite_alpha_mode: CompositeAlphaMode::Opaque,
..default()
}),
..default()
}))
.add_plugins(EguiPlugin::default())
.init_resource::<ProjectLauncherUi>()
.add_systems(Startup, spawn_project_launcher_camera)
.add_systems(EguiPrimaryContextPass, project_launcher_ui_system);
app.run();
}
fn spawn_project_launcher_camera(mut commands: Commands) {
commands.spawn((Camera2d, PrimaryEguiContext));
}
pub fn spawn_project_launcher_process() -> Result<(), String> {
let executable = sibling_executable("project_launcher")?;
let root = std::env::current_dir()
.map_err(|error| format!("could not resolve active project root: {error}"))?;
spawn_detached(&executable, &[], &root)
}
pub fn reveal_project_root(root: &Path) -> Result<(), String> {
Command::new("xdg-open")
.arg(root)
.spawn()
.map_err(|error| format!("could not reveal {}: {error}", root.display()))?;
Ok(())
}
fn project_launcher_ui_system(world: &mut World) {
let Ok(context) = world
.query_filtered::<&mut EguiContext, With<PrimaryEguiContext>>()
.single(world)
else {
return;
};
let mut context = context.clone();
world.resource_scope(|world, mut state: Mut<ProjectLauncherUi>| {
project_launcher_ui(world, context.get_mut(), &mut state);
});
}
#[expect(
deprecated,
reason = "bevy_egui exposes a Context during EguiPrimaryContextPass, requiring one top-level context-based panel"
)]
fn project_launcher_ui(world: &mut World, context: &egui::Context, state: &mut ProjectLauncherUi) {
apply_editor_theme(context);
egui::CentralPanel::default()
.frame(
egui::Frame::new()
.fill(CANVAS_BG)
.inner_margin(egui::Margin::same(24)),
)
.show(context, |ui| {
let content_width = ui.available_width().min(1240.0);
let side_space = ((ui.available_width() - content_width) * 0.5).max(0.0);
ui.horizontal(|ui| {
ui.add_space(side_space);
ui.vertical(|ui| {
ui.set_width(content_width);
ui.horizontal(|ui| {
ui.vertical(|ui| {
ui.label(
egui::RichText::new("BLACKSITE")
.size(22.0)
.strong()
.color(ACCENT),
);
ui.label(
egui::RichText::new("PROJECT BROWSER")
.size(11.0)
.color(TEXT_DIM),
);
});
ui.add_space(18.0);
ui.separator();
ui.add_space(10.0);
ui.label(
egui::RichText::new(
"Choose a validated workspace before the engine starts",
)
.color(TEXT_DIM),
);
});
ui.add_space(22.0);
ui.horizontal(|ui| {
ui.vertical(|ui| {
ui.set_width(340.0);
launcher_actions(world, ui, state);
});
ui.add_space(18.0);
ui.vertical(|ui| {
ui.set_width((content_width - 358.0).max(360.0));
recent_projects(world, ui, state);
});
});
if let Some(status) = state.status.as_deref() {
ui.add_space(12.0);
ui.separator();
ui.label(egui::RichText::new(status).color(TEXT_DIM));
}
});
});
});
}
fn launcher_actions(world: &mut World, ui: &mut egui::Ui, state: &mut ProjectLauncherUi) {
egui::Frame::new()
.fill(PANEL_BG_DARK)
.stroke(egui::Stroke::new(1.0, BORDER))
.inner_margin(egui::Margin::same(16))
.show(ui, |ui| {
ui.set_min_height(455.0);
ui.heading("Open Project");
ui.label(
egui::RichText::new("Select a root containing assets/project.ron").color(TEXT_DIM),
);
ui.add_space(8.0);
if ui.button("Browse Existing...").clicked() {
if let Some(root) = rfd::FileDialog::new().pick_folder() {
open_project_from_launcher(world, state, root);
}
}
ui.add_space(24.0);
ui.separator();
ui.add_space(14.0);
ui.heading("New Sandbox");
ui.label(
egui::RichText::new("Creates a clean content workspace with a valid empty level")
.color(TEXT_DIM),
);
ui.add_space(8.0);
ui.label(egui::RichText::new("Project name").color(TEXT_DIM));
ui.text_edit_singleline(&mut state.sandbox_name);
ui.add_space(6.0);
let destination = state
.sandbox_destination
.as_ref()
.map(|path| path.display().to_string())
.unwrap_or_else(|| "No destination selected".to_string());
ui.add(
egui::Label::new(
egui::RichText::new(destination)
.monospace()
.color(TEXT_MUTED),
)
.truncate(),
);
if ui.button("Choose Empty Folder...").clicked() {
state.sandbox_destination = rfd::FileDialog::new().pick_folder();
}
let can_create =
state.sandbox_destination.is_some() && !state.sandbox_name.trim().is_empty();
if ui
.add_enabled(can_create, egui::Button::new("Create And Open Sandbox"))
.clicked()
{
let destination = state.sandbox_destination.clone().unwrap();
match scaffold_sandbox_project(&destination, &state.sandbox_name) {
Ok(summary) => launch_editor_and_close(world, state, &summary),
Err(error) => state.status = Some(error),
}
}
});
}
fn recent_projects(world: &mut World, ui: &mut egui::Ui, state: &mut ProjectLauncherUi) {
ui.horizontal(|ui| {
ui.heading("Recent Projects");
ui.add_space(8.0);
ui.label(egui::RichText::new(state.recents.len().to_string()).color(TEXT_MUTED));
});
ui.label(
egui::RichText::new("Missing and invalid projects are filtered automatically")
.color(TEXT_DIM),
);
ui.add_space(10.0);
if state.recents.is_empty() {
egui::Frame::new()
.fill(PANEL_BG)
.stroke(egui::Stroke::new(1.0, BORDER))
.inner_margin(egui::Margin::same(16))
.show(ui, |ui| {
ui.label(egui::RichText::new("No valid recent projects").color(TEXT));
ui.label(
egui::RichText::new("Browse an existing project or create a sandbox.")
.color(TEXT_DIM),
);
});
return;
}
let recents = state.recents.clone();
egui::ScrollArea::vertical()
.id_salt("project_launcher_recents")
.show(ui, |ui| {
for summary in recents {
let response = egui::Frame::new()
.fill(PANEL_BG)
.stroke(egui::Stroke::new(1.0, BORDER))
.inner_margin(egui::Margin::same(14))
.show(ui, |ui| {
ui.horizontal(|ui| {
ui.vertical(|ui| {
ui.label(egui::RichText::new(&summary.name).strong().color(TEXT));
ui.label(
egui::RichText::new(summary.root.display().to_string())
.monospace()
.size(11.0)
.color(TEXT_MUTED),
);
let detail = summary.last_scene.as_ref().map_or_else(
|| format!("{:?} | default scene", summary.project_kind),
|scene| {
format!(
"{:?} | {}",
summary.project_kind,
scene
.strip_prefix(&summary.root)
.unwrap_or(scene)
.display()
)
},
);
ui.label(egui::RichText::new(detail).size(11.0).color(TEXT_DIM));
});
ui.with_layout(
egui::Layout::right_to_left(egui::Align::Center),
|ui| {
if ui.button("Open").clicked() {
launch_editor_and_close(world, state, &summary);
}
},
);
});
});
if response.response.hovered() {
ui.painter().rect_stroke(
response.response.rect,
0.0,
egui::Stroke::new(1.0, SELECTION),
egui::StrokeKind::Inside,
);
}
ui.add_space(8.0);
}
});
}
fn open_project_from_launcher(world: &mut World, state: &mut ProjectLauncherUi, root: PathBuf) {
match inspect_project(&root, None) {
Ok(summary) => launch_editor_and_close(world, state, &summary),
Err(error) => state.status = Some(error),
}
}
fn launch_editor_and_close(
world: &mut World,
state: &mut ProjectLauncherUi,
summary: &ProjectSummary,
) {
match spawn_editor_process(&summary.root) {
Ok(()) => {
state.status = Some(format!("Opening {}", summary.name));
world.write_message(AppExit::Success);
}
Err(error) => state.status = Some(error),
}
}
fn spawn_editor_process(root: &Path) -> Result<(), String> {
let executable = sibling_executable("editor")?;
spawn_detached(
&executable,
&[std::ffi::OsStr::new("--project"), root.as_os_str()],
root,
)
}
#[cfg(target_os = "linux")]
fn spawn_detached(
executable: &Path,
arguments: &[&std::ffi::OsStr],
cwd: &Path,
) -> Result<(), String> {
let mut command = Command::new("systemd-run");
command
.args(["--user", "--collect", "--quiet", "--service-type=exec"])
.arg(format!("--working-directory={}", cwd.display()));
for variable in [
"PATH",
"WAYLAND_DISPLAY",
"DISPLAY",
"XAUTHORITY",
"XDG_RUNTIME_DIR",
"HYPRLAND_INSTANCE_SIGNATURE",
"DBUS_SESSION_BUS_ADDRESS",
"XDG_CURRENT_DESKTOP",
"XDG_SESSION_DESKTOP",
"XDG_SESSION_TYPE",
"XDG_DATA_HOME",
"XDG_CONFIG_HOME",
"XDG_STATE_HOME",
"GDK_BACKEND",
"LD_LIBRARY_PATH",
"WGPU_VALIDATION",
"BEVY_FPS_HDR",
] {
if let Some(value) = std::env::var_os(variable) {
command.arg(format!("--setenv={variable}={}", value.to_string_lossy()));
}
}
let status = command
.arg(executable)
.args(arguments)
.status()
.map_err(|error| format!("could not invoke systemd-run: {error}"))?;
status.success().then_some(()).ok_or_else(|| {
format!(
"systemd-run could not start {} (status {status})",
executable.display()
)
})
}
#[cfg(not(target_os = "linux"))]
fn spawn_detached(
executable: &Path,
arguments: &[&std::ffi::OsStr],
cwd: &Path,
) -> Result<(), String> {
Command::new(executable)
.args(arguments)
.current_dir(cwd)
.spawn()
.map_err(|error| format!("could not start {}: {error}", executable.display()))?;
Ok(())
}
fn sibling_executable(name: &str) -> Result<PathBuf, String> {
let current = std::env::current_exe()
.map_err(|error| format!("could not locate current executable: {error}"))?;
let extension = current
.extension()
.map(|extension| extension.to_os_string());
let mut executable = current.with_file_name(name);
if let Some(extension) = extension {
executable.set_extension(extension);
}
executable
.is_file()
.then_some(executable.clone())
.ok_or_else(|| format!("required executable is not built: {}", executable.display()))
}
pub fn parse_startup_arguments(
arguments: impl IntoIterator<Item = String>,
) -> Result<StartupArguments, String> {
let mut parsed = StartupArguments::default();
let mut arguments = arguments.into_iter();
while let Some(argument) = arguments.next() {
match argument.as_str() {
"--project" => {
if parsed.project.is_some() {
return Err("--project may only be provided once".to_string());
}
let path = arguments
.next()
.ok_or_else(|| "--project requires a project root path".to_string())?;
parsed.project = Some(PathBuf::from(path));
}
"--help" | "-h" => parsed.help = true,
unknown => return Err(format!("unknown editor argument `{unknown}`")),
}
}
Ok(parsed)
}
pub fn command_line_help() -> &'static str {
"Blacksite Editor\n\nUSAGE:\n editor [--project <root>]\n\nOPTIONS:\n --project <root> Open a validated Blacksite project before engine startup\n -h, --help Print this help"
}
pub fn activate_project_root(root: &Path) -> Result<ProjectSummary, String> {
let summary = inspect_project(root, None)?;
std::env::set_current_dir(&summary.root).map_err(|error| {
format!(
"could not activate project root {}: {error}",
summary.root.display()
)
})?;
Ok(summary)
}
pub fn inspect_project(root: &Path, last_scene: Option<&Path>) -> Result<ProjectSummary, String> {
let root = root
.canonicalize()
.map_err(|error| format!("could not open project root {}: {error}", root.display()))?;
if !root.is_dir() {
return Err(format!(
"project root is not a directory: {}",
root.display()
));
}
let manifest_path = root.join(DEFAULT_PROJECT_PATH);
let text = std::fs::read_to_string(&manifest_path)
.map_err(|error| format!("could not read {}: {error}", manifest_path.display()))?;
let manifest: ProjectSettings = ron::from_str(&text).map_err(|error| {
format!(
"invalid project manifest {}: {error}",
manifest_path.display()
)
})?;
if manifest.name.trim().is_empty() {
return Err(format!(
"project name is empty in {}",
manifest_path.display()
));
}
manifest.audio.validate().map_err(|error| {
format!(
"invalid audio settings in {}: {error}",
manifest_path.display()
)
})?;
let default_reference = Path::new(&manifest.default_level);
if !default_reference.starts_with("assets/levels")
|| !manifest.default_level.ends_with(".scn.ron")
{
return Err("default_level must be a .scn.ron scene under assets/levels/".into());
}
let default_level = safe_project_path(&root, default_reference, "default_level")?;
let default_metadata = std::fs::symlink_metadata(&default_level).ok();
if !default_metadata
.as_ref()
.is_some_and(std::fs::Metadata::is_file)
|| default_metadata.is_some_and(|metadata| metadata.file_type().is_symlink())
{
return Err(format!(
"default level is missing or is not a regular file: {}",
default_level.display()
));
}
let default_level_text = std::fs::read_to_string(&default_level).map_err(|error| {
format!(
"could not read default level {}: {error}",
default_level.display()
)
})?;
scene::validate_level_text(&default_level_text).map_err(|error| {
format!(
"default level failed validation {}: {error}",
default_level.display()
)
})?;
scene::validate_composition_graph(&default_level, &root).map_err(|error| {
format!(
"default level composition failed validation {}: {error}",
default_level.display()
)
})?;
let last_scene = last_scene
.and_then(|path| safe_project_path(&root, path, "last_scene").ok())
.filter(|path| path.is_file());
let project_id = if manifest.project_id.trim().is_empty() {
format!("legacy:{}", root.display())
} else {
manifest.project_id
};
Ok(ProjectSummary {
root,
project_id,
name: manifest.name,
project_kind: manifest.project_kind,
template_version: manifest.template_version,
capabilities: manifest.capabilities,
default_level,
last_scene,
})
}
pub fn recent_project_summaries(preferences: &UserPreferences) -> Vec<ProjectSummary> {
let mut seen = HashSet::new();
preferences
.recent_projects
.iter()
.filter_map(|root| {
let root = PathBuf::from(root);
let last_scene = preferences
.recent_levels
.iter()
.map(PathBuf::from)
.find(|scene| root.join(scene).is_file());
inspect_project(&root, last_scene.as_deref()).ok()
})
.filter(|summary| seen.insert(summary.root.clone()))
.collect()
}
pub fn scaffold_sandbox_project(destination: &Path, name: &str) -> Result<ProjectSummary, String> {
let name = name.trim();
if name.is_empty() {
return Err("sandbox project name cannot be empty".to_string());
}
if destination.exists() {
let mut entries = std::fs::read_dir(destination)
.map_err(|error| format!("could not inspect {}: {error}", destination.display()))?;
if entries.next().is_some() {
return Err(format!(
"sandbox destination must be empty: {}",
destination.display()
));
}
}
for directory in SANDBOX_DIRECTORIES {
std::fs::create_dir_all(destination.join(directory)).map_err(|error| {
format!(
"could not create sandbox directory {}: {error}",
destination.join(directory).display()
)
})?;
}
let default_level = "assets/levels/main.scn.ron";
let empty_scene = format!(
"(schema_version: {},\n resources: {{}},\n entities: {{}},\n)\n",
scene::CURRENT_SCENE_SCHEMA_VERSION
);
scene::validate_level_text(&empty_scene)?;
atomic_write(&destination.join(default_level), empty_scene.as_bytes())?;
let manifest = ProjectSettings {
project_id: uuid::Uuid::new_v4().to_string(),
name: name.to_string(),
project_kind: ProjectKind::Sandbox,
template_version: PROJECT_TEMPLATE_VERSION,
capabilities: vec![
"editor".into(),
"gameplay".into(),
"rendering".into(),
"audio".into(),
],
default_level: default_level.to_string(),
asset_roots: vec![
"assets/audio".into(),
"assets/levels".into(),
"assets/materials".into(),
"assets/meshes".into(),
"assets/models".into(),
"assets/textures".into(),
],
..ProjectSettings::default()
};
let manifest_text = save_project_settings_to_string(&manifest)
.map_err(|error| format!("could not serialize sandbox manifest: {error}"))?;
atomic_write(
&destination.join(DEFAULT_PROJECT_PATH),
manifest_text.as_bytes(),
)?;
for (name, profile) in sandbox_build_profiles(default_level) {
atomic_write(
&destination.join("assets/build_profiles").join(name),
profile.as_bytes(),
)?;
}
inspect_project(destination, None)
}
fn sandbox_build_profiles(default_level: &str) -> [(&'static str, String); 3] {
[
(
"development.ron",
format!(
"(version:1,name:\"Development\",cargo_profile:Development,target:None,features:[],rendering_tier:\"development\",default_scene:Some(\"{default_level}\"),output_dir:\"dist/development\")\n"
),
),
(
"qa.ron",
format!(
"(version:1,name:\"QA\",cargo_profile:Qa,target:None,features:[],rendering_tier:\"representative\",default_scene:Some(\"{default_level}\"),output_dir:\"dist/qa\")\n"
),
),
(
"release.ron",
format!(
"(version:1,name:\"Release\",cargo_profile:Release,target:None,features:[],rendering_tier:\"shipping\",default_scene:Some(\"{default_level}\"),output_dir:\"dist/release\")\n"
),
),
]
}
fn safe_project_path(root: &Path, path: impl AsRef<Path>, field: &str) -> Result<PathBuf, String> {
let path = path.as_ref();
if path.as_os_str().is_empty() || path.is_absolute() {
return Err(format!("{field} must be a non-empty project-relative path"));
}
if path.components().any(|component| {
matches!(
component,
Component::ParentDir | Component::RootDir | Component::Prefix(_)
)
}) {
return Err(format!("{field} cannot escape the project root"));
}
Ok(root.join(path))
}
#[cfg(test)]
mod tests {
use super::*;
fn temp_root(label: &str) -> PathBuf {
std::env::temp_dir().join(format!("blacksite-{label}-{}", uuid::Uuid::new_v4()))
}
#[test]
fn startup_arguments_parse_project_and_reject_unknown_flags() {
let parsed =
parse_startup_arguments(["--project".to_string(), "/tmp/project".to_string()]).unwrap();
assert_eq!(parsed.project, Some(PathBuf::from("/tmp/project")));
assert!(parse_startup_arguments(["--mystery".to_string()]).is_err());
assert!(parse_startup_arguments(["--project".to_string()]).is_err());
}
#[test]
fn sandbox_scaffold_can_be_inspected_and_reloaded() {
let root = temp_root("sandbox");
let created = scaffold_sandbox_project(&root, "Prototype").unwrap();
let reopened =
inspect_project(&root, Some(Path::new("assets/levels/main.scn.ron"))).unwrap();
assert_eq!(created.project_id, reopened.project_id);
assert_eq!(reopened.name, "Prototype");
assert_eq!(reopened.project_kind, ProjectKind::Sandbox);
assert_eq!(reopened.template_version, PROJECT_TEMPLATE_VERSION);
assert!(reopened.capabilities.iter().any(|item| item == "audio"));
let manifest = settings::load_project_settings_from_path(
root.join(DEFAULT_PROJECT_PATH).to_string_lossy().as_ref(),
);
assert!(manifest.audio.validate().is_ok());
assert!(manifest.audio.bus(settings::AUDIO_BUS_SFX_ID).is_some());
assert_eq!(
reopened.last_scene,
Some(root.join("assets/levels/main.scn.ron"))
);
for directory in SANDBOX_DIRECTORIES {
assert!(root.join(directory).is_dir(), "missing {directory}");
}
for profile in ["development.ron", "qa.ron", "release.ron"] {
let text =
std::fs::read_to_string(root.join("assets/build_profiles").join(profile)).unwrap();
assert!(text.contains("assets/levels/main.scn.ron"), "{profile}");
assert!(text.contains("output_dir"), "{profile}");
}
std::fs::remove_dir_all(root).unwrap();
}
#[test]
fn recent_projects_filter_missing_invalid_and_duplicate_roots() {
let valid = temp_root("recent-valid");
let invalid = temp_root("recent-invalid");
scaffold_sandbox_project(&valid, "Valid").unwrap();
std::fs::create_dir_all(&invalid).unwrap();
let preferences = UserPreferences {
recent_projects: vec![
valid.display().to_string(),
"/definitely/missing/blacksite-project".to_string(),
invalid.display().to_string(),
valid.display().to_string(),
],
recent_levels: vec!["assets/levels/main.scn.ron".to_string()],
..Default::default()
};
let summaries = recent_project_summaries(&preferences);
assert_eq!(summaries.len(), 1);
assert_eq!(summaries[0].name, "Valid");
assert!(summaries[0].last_scene.is_some());
std::fs::remove_dir_all(valid).unwrap();
std::fs::remove_dir_all(invalid).unwrap();
}
#[test]
fn project_inspection_rejects_invalid_audio_bus_graphs() {
let root = temp_root("invalid-audio-buses");
scaffold_sandbox_project(&root, "Invalid Audio").unwrap();
let manifest_path = root.join(DEFAULT_PROJECT_PATH);
let mut manifest =
settings::load_project_settings_from_path(manifest_path.to_string_lossy().as_ref());
manifest
.audio
.bus_mut(settings::AUDIO_BUS_SFX_ID)
.unwrap()
.parent = Some("missing".into());
std::fs::write(
&manifest_path,
save_project_settings_to_string(&manifest).unwrap(),
)
.unwrap();
let error = inspect_project(&root, None).unwrap_err();
assert!(error.contains("invalid audio settings"), "{error}");
std::fs::remove_dir_all(root).unwrap();
}
#[test]
fn sandbox_scaffold_refuses_nonempty_destination_and_path_escape() {
let root = temp_root("sandbox-nonempty");
std::fs::create_dir_all(&root).unwrap();
std::fs::write(root.join("keep.txt"), "user data").unwrap();
assert!(scaffold_sandbox_project(&root, "Unsafe").is_err());
assert!(safe_project_path(&root, "../outside", "test").is_err());
assert_eq!(
std::fs::read_to_string(root.join("keep.txt")).unwrap(),
"user data"
);
std::fs::remove_dir_all(root).unwrap();
}
#[test]
fn project_default_level_must_use_the_audited_levels_tree() {
let root = temp_root("default-level-contract");
scaffold_sandbox_project(&root, "Contract").unwrap();
std::fs::create_dir_all(root.join("assets/custom")).unwrap();
std::fs::write(
root.join("assets/custom/start.scn.ron"),
format!(
"(schema_version:{},resources:{{}},entities:{{}})",
scene::CURRENT_SCENE_SCHEMA_VERSION
),
)
.unwrap();
let mut manifest = settings::load_project_settings_from_path(
root.join(DEFAULT_PROJECT_PATH).to_string_lossy().as_ref(),
);
manifest.default_level = "assets/custom/start.scn.ron".into();
std::fs::write(
root.join(DEFAULT_PROJECT_PATH),
save_project_settings_to_string(&manifest).unwrap(),
)
.unwrap();
let error = inspect_project(&root, None).unwrap_err();
assert!(error.contains("assets/levels"), "{error}");
std::fs::remove_dir_all(root).unwrap();
}
}

View File

@ -1,7 +1,4 @@
//! Project workspace I/O, settings UI, and recent-level sync. //! Project workspace I/O, settings UI, and recent-level sync.
pub mod diagnostics_bundle;
pub mod launcher;
pub mod project_io; pub mod project_io;
pub mod session;
pub mod settings_ui; pub mod settings_ui;

View File

@ -2,7 +2,7 @@
use bevy::prelude::*; use bevy::prelude::*;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use settings::{ProjectKind, ProjectSettings, ProjectSettingsIo, DEFAULT_PROJECT_PATH}; use settings::{ProjectSettings, ProjectSettingsIo, DEFAULT_PROJECT_PATH};
use crate::scene_io::SceneIo; use crate::scene_io::SceneIo;
@ -10,7 +10,7 @@ const USER_CONFIG_DIR: &str = ".config/bevy-fps";
const USER_PREFS_FILE: &str = "editor_prefs.ron"; const USER_PREFS_FILE: &str = "editor_prefs.ron";
/// User-local editor preferences (not checked into the repo). /// User-local editor preferences (not checked into the repo).
#[derive(Debug, Clone, Serialize, Deserialize, Resource)] #[derive(Debug, Clone, Default, Serialize, Deserialize, Resource)]
pub struct UserPreferences { pub struct UserPreferences {
pub recent_projects: Vec<String>, pub recent_projects: Vec<String>,
pub recent_levels: Vec<String>, pub recent_levels: Vec<String>,
@ -31,53 +31,16 @@ pub struct UserPreferences {
pub hierarchy_show_runtime: bool, pub hierarchy_show_runtime: bool,
#[serde(default)] #[serde(default)]
pub hierarchy_sort_mode: crate::ui::hierarchy_state::HierarchySort, pub hierarchy_sort_mode: crate::ui::hierarchy_state::HierarchySort,
#[serde(default = "default_true")]
pub scene_autosave_enabled: bool,
#[serde(default = "default_autosave_interval_secs")]
pub scene_autosave_interval_secs: u64,
#[serde(default = "default_recovery_generations")]
pub scene_recovery_generations: usize,
} }
fn default_true() -> bool { fn default_true() -> bool {
true true
} }
const fn default_autosave_interval_secs() -> u64 {
120
}
const fn default_recovery_generations() -> usize {
5
}
impl Default for UserPreferences {
fn default() -> Self {
Self {
recent_projects: Vec::new(),
recent_levels: Vec::new(),
last_project_root: None,
dock_layout: None,
save_before_play: false,
hierarchy_filter: String::new(),
hierarchy_expanded_paths: Vec::new(),
hierarchy_show_generated: true,
hierarchy_show_runtime: false,
hierarchy_sort_mode: crate::ui::hierarchy_state::HierarchySort::default(),
scene_autosave_enabled: true,
scene_autosave_interval_secs: default_autosave_interval_secs(),
scene_recovery_generations: default_recovery_generations(),
}
}
}
/// Active project workspace metadata (mirrors manifest fields in `ProjectSettings`). /// Active project workspace metadata (mirrors manifest fields in `ProjectSettings`).
#[derive(Resource, Debug, Clone)] #[derive(Resource, Debug, Clone)]
pub struct ProjectWorkspace { pub struct ProjectWorkspace {
pub root: String, pub root: String,
pub project_id: String,
pub project_kind: ProjectKind,
pub last_opened_scene: Option<String>,
pub settings_path: String, pub settings_path: String,
pub settings_dirty: bool, pub settings_dirty: bool,
pub layout_dirty: bool, pub layout_dirty: bool,
@ -91,9 +54,6 @@ impl Default for ProjectWorkspace {
.unwrap_or_else(|| ".".into()); .unwrap_or_else(|| ".".into());
Self { Self {
root, root,
project_id: String::new(),
project_kind: ProjectKind::default(),
last_opened_scene: None,
settings_path: DEFAULT_PROJECT_PATH.into(), settings_path: DEFAULT_PROJECT_PATH.into(),
settings_dirty: false, settings_dirty: false,
layout_dirty: false, layout_dirty: false,
@ -113,13 +73,7 @@ impl Plugin for ProjectIoPlugin {
} }
fn load_user_preferences(mut commands: Commands) { fn load_user_preferences(mut commands: Commands) {
let mut prefs = read_user_preferences(); let prefs = read_user_preferences();
if let Ok(root) = std::env::current_dir().and_then(|root| root.canonicalize()) {
remember_project_root(&mut prefs, &root);
if let Err(error) = write_user_preferences(&prefs) {
warn!("Failed to remember active project: {error}");
}
}
commands.insert_resource(prefs); commands.insert_resource(prefs);
} }
@ -131,13 +85,12 @@ pub(crate) fn load_user_preferences_on_startup(commands: Commands) {
fn sync_workspace_from_settings_system( fn sync_workspace_from_settings_system(
settings: Res<ProjectSettings>, settings: Res<ProjectSettings>,
settings_io: Res<ProjectSettingsIo>, settings_io: Res<ProjectSettingsIo>,
scene_io: Res<SceneIo>,
mut workspace: ResMut<ProjectWorkspace>, mut workspace: ResMut<ProjectWorkspace>,
) { ) {
if !settings_io.is_changed() && !settings.is_changed() && !scene_io.is_changed() { if !settings_io.is_changed() && !settings.is_changed() {
return; return;
} }
sync_workspace_from_settings(&mut workspace, &settings, &settings_io, &scene_io); sync_workspace_from_settings(&mut workspace, &settings, &settings_io);
} }
fn user_prefs_path() -> Option<std::path::PathBuf> { fn user_prefs_path() -> Option<std::path::PathBuf> {
@ -174,26 +127,14 @@ pub fn push_recent(list: &mut Vec<String>, entry: String, max: usize) {
list.truncate(max); list.truncate(max);
} }
pub fn remember_project_root(preferences: &mut UserPreferences, root: &std::path::Path) {
let root = root.display().to_string();
push_recent(&mut preferences.recent_projects, root.clone(), 12);
preferences.last_project_root = Some(root);
}
pub fn sync_workspace_from_settings( pub fn sync_workspace_from_settings(
workspace: &mut ProjectWorkspace, workspace: &mut ProjectWorkspace,
settings: &ProjectSettings, settings: &ProjectSettings,
settings_io: &ProjectSettingsIo, settings_io: &ProjectSettingsIo,
scene_io: &SceneIo,
) { ) {
workspace.project_id.clone_from(&settings.project_id);
workspace.project_kind = settings.project_kind;
workspace.last_opened_scene = scene_io
.active_path
.as_ref()
.map(|path| path.display().to_string());
workspace.settings_path = settings_io.path.clone(); workspace.settings_path = settings_io.path.clone();
workspace.settings_dirty = settings_io.dirty; workspace.settings_dirty = settings_io.dirty;
let _ = settings;
} }
pub fn window_title( pub fn window_title(
@ -219,21 +160,3 @@ pub fn window_title(
} }
parts.join("") parts.join("")
} }
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn remembering_project_deduplicates_and_updates_last_root() {
let mut preferences = UserPreferences {
recent_projects: vec!["/old".into(), "/project".into()],
..Default::default()
};
remember_project_root(&mut preferences, std::path::Path::new("/project"));
assert_eq!(preferences.last_project_root.as_deref(), Some("/project"));
assert_eq!(preferences.recent_projects, vec!["/project", "/old"]);
}
}

View File

@ -1,419 +0,0 @@
//! Versioned machine-local editor session persistence and crash boundary.
use std::path::{Path, PathBuf};
use bevy::app::AppExit;
use bevy::prelude::*;
use bevy_egui::egui;
use serde::{Deserialize, Serialize};
use crate::project_io::{ProjectWorkspace, UserPreferences};
use crate::scene::recovery::{atomic_write, default_state_root};
use crate::scene_io::{SceneIo, SceneIoRequest};
use crate::settings_ui::ProjectSettingsPanel;
use crate::ui::{BrushDiagnosticsPanel, DiagnosticsPanel};
use crate::viewport::rendering_diagnostics::RenderingDiagnosticsPanel;
use crate::viewport::CameraBookmarks;
const SESSION_FILE: &str = "session.ron";
const SESSION_SCHEMA_VERSION: u32 = 1;
const SESSION_WRITE_INTERVAL_SECS: f32 = 2.0;
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
pub struct SessionPanelState {
#[serde(default)]
pub diagnostics: bool,
#[serde(default)]
pub brush_diagnostics: bool,
#[serde(default)]
pub rendering: bool,
#[serde(default)]
pub project_settings: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct CameraBookmarkRecord {
pub scene: String,
pub translation: [f32; 3],
pub rotation: [f32; 4],
pub scale: [f32; 3],
}
impl CameraBookmarkRecord {
fn from_transform(scene: String, transform: Transform) -> Self {
Self {
scene,
translation: transform.translation.to_array(),
rotation: transform.rotation.to_array(),
scale: transform.scale.to_array(),
}
}
fn to_transform(&self) -> Transform {
Transform {
translation: Vec3::from_array(self.translation),
rotation: Quat::from_array(self.rotation).normalize(),
scale: Vec3::from_array(self.scale),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct EditorSessionDocument {
#[serde(default)]
pub schema_version: u32,
#[serde(default)]
pub clean_shutdown: bool,
#[serde(default)]
pub project_root: String,
#[serde(default)]
pub active_scene: Option<String>,
#[serde(default)]
pub dock_layout: Option<String>,
#[serde(default)]
pub hierarchy_expanded_paths: Vec<String>,
#[serde(default)]
pub panels: SessionPanelState,
#[serde(default)]
pub camera_bookmarks: Vec<CameraBookmarkRecord>,
}
impl Default for EditorSessionDocument {
fn default() -> Self {
Self {
schema_version: SESSION_SCHEMA_VERSION,
clean_shutdown: true,
project_root: String::new(),
active_scene: None,
dock_layout: None,
hierarchy_expanded_paths: Vec::new(),
panels: SessionPanelState::default(),
camera_bookmarks: Vec::new(),
}
}
}
#[derive(Resource, Debug, Default)]
pub struct EditorSessionRuntime {
pub prior_abnormal_shutdown: bool,
pub resume_scene: Option<PathBuf>,
pub resume_prompt_open: bool,
save_elapsed_secs: f32,
}
pub struct EditorSessionPlugin;
impl Plugin for EditorSessionPlugin {
fn build(&self, app: &mut App) {
app.init_resource::<EditorSessionRuntime>()
.add_systems(Startup, initialize_session)
.add_systems(Update, persist_running_session)
.add_systems(
Last,
persist_clean_session_on_exit.after(bevy::window::ExitSystems),
);
}
}
pub fn session_resume_window(world: &mut World, ctx: &egui::Context) {
let (open, scene) = {
let runtime = world.resource::<EditorSessionRuntime>();
(runtime.resume_prompt_open, runtime.resume_scene.clone())
};
if !open {
return;
}
let Some(scene) = scene else {
world
.resource_mut::<EditorSessionRuntime>()
.resume_prompt_open = false;
return;
};
egui::Window::new("Recover Editor Session")
.collapsible(false)
.resizable(false)
.anchor(egui::Align2::CENTER_CENTER, egui::Vec2::ZERO)
.show(ctx, |ui| {
ui.label("The previous editor session did not close cleanly.");
ui.small("The safe startup scene is active. Resume only the last authored scene; modal tools and dirty preview state are never restored.");
ui.monospace(scene.display().to_string());
ui.separator();
ui.horizontal(|ui| {
if ui.button("Resume Last Scene").clicked() {
world.resource_mut::<SceneIo>().request =
Some(SceneIoRequest::OpenPath(scene.clone()));
world
.resource_mut::<EditorSessionRuntime>()
.resume_prompt_open = false;
}
if ui.button("Continue Safe").clicked() {
world
.resource_mut::<EditorSessionRuntime>()
.resume_prompt_open = false;
world
.resource_mut::<SceneIo>()
.set_status("Continued with safe startup after abnormal shutdown");
}
});
});
}
fn session_path() -> Option<PathBuf> {
default_state_root().map(|root| root.join(SESSION_FILE))
}
fn read_session(path: &Path) -> Result<EditorSessionDocument, String> {
let text = std::fs::read_to_string(path)
.map_err(|error| format!("could not read {}: {error}", path.display()))?;
let document: EditorSessionDocument =
ron::from_str(&text).map_err(|error| format!("invalid session document: {error}"))?;
normalize_session(document)
}
fn normalize_session(mut document: EditorSessionDocument) -> Result<EditorSessionDocument, String> {
match document.schema_version {
0 => document.schema_version = SESSION_SCHEMA_VERSION,
SESSION_SCHEMA_VERSION => {}
version => {
return Err(format!(
"session schema {version} is newer than supported {SESSION_SCHEMA_VERSION}"
));
}
}
document.camera_bookmarks.retain(valid_bookmark);
Ok(document)
}
fn valid_bookmark(bookmark: &CameraBookmarkRecord) -> bool {
bookmark
.translation
.iter()
.chain(bookmark.rotation.iter())
.chain(bookmark.scale.iter())
.all(|value| value.is_finite())
}
fn write_session(path: &Path, document: &EditorSessionDocument) -> Result<(), String> {
let text = ron::ser::to_string_pretty(document, ron::ser::PrettyConfig::default())
.map_err(|error| format!("could not serialize session: {error}"))?;
atomic_write(path, text.as_bytes())
}
fn initialize_session(world: &mut World) {
let previous = session_path().and_then(|path| read_session(&path).ok());
let prior_abnormal = previous
.as_ref()
.is_some_and(|document| !document.clean_shutdown);
if let Some(document) = previous.as_ref() {
restore_non_destructive_session_state(world, document);
if !prior_abnormal {
restore_active_scene(world, document);
}
}
let resume_scene = previous
.as_ref()
.and_then(|document| document.active_scene.as_deref())
.map(PathBuf::from)
.filter(|path| path.exists());
world.insert_resource(EditorSessionRuntime {
prior_abnormal_shutdown: prior_abnormal,
resume_prompt_open: prior_abnormal && resume_scene.is_some(),
resume_scene,
save_elapsed_secs: 0.0,
});
if let Some(path) = session_path() {
let document = capture_session(world, false);
if let Err(error) = write_session(&path, &document) {
warn!("Failed to write running session marker: {error}");
}
}
}
fn restore_active_scene(world: &mut World, document: &EditorSessionDocument) {
let Some(path) = document.active_scene.as_deref().map(PathBuf::from) else {
return;
};
if path.exists() {
world.resource_mut::<SceneIo>().active_path = Some(path);
}
}
fn restore_non_destructive_session_state(world: &mut World, document: &EditorSessionDocument) {
let poses = document
.camera_bookmarks
.iter()
.map(|bookmark| (bookmark.scene.clone(), bookmark.to_transform()))
.collect();
world.resource_mut::<CameraBookmarks>().poses = poses;
world.resource_mut::<DiagnosticsPanel>().open = document.panels.diagnostics;
world.resource_mut::<BrushDiagnosticsPanel>().open = document.panels.brush_diagnostics;
world.resource_mut::<RenderingDiagnosticsPanel>().open = document.panels.rendering;
world.resource_mut::<ProjectSettingsPanel>().open = document.panels.project_settings;
}
fn capture_session(world: &World, clean_shutdown: bool) -> EditorSessionDocument {
let prefs = world.resource::<UserPreferences>();
EditorSessionDocument {
schema_version: SESSION_SCHEMA_VERSION,
clean_shutdown,
project_root: world.resource::<ProjectWorkspace>().root.clone(),
active_scene: world
.resource::<SceneIo>()
.active_path
.as_ref()
.map(|path| path.display().to_string()),
dock_layout: prefs.dock_layout.clone(),
hierarchy_expanded_paths: prefs.hierarchy_expanded_paths.clone(),
panels: SessionPanelState {
diagnostics: world.resource::<DiagnosticsPanel>().open,
brush_diagnostics: world.resource::<BrushDiagnosticsPanel>().open,
rendering: world.resource::<RenderingDiagnosticsPanel>().open,
project_settings: world.resource::<ProjectSettingsPanel>().open,
},
camera_bookmarks: world
.resource::<CameraBookmarks>()
.poses
.iter()
.map(|(scene, transform)| {
CameraBookmarkRecord::from_transform(scene.clone(), *transform)
})
.collect(),
}
}
fn persist_running_session(world: &mut World) {
let delta = world.resource::<Time>().delta_secs();
let should_write = {
let mut runtime = world.resource_mut::<EditorSessionRuntime>();
runtime.save_elapsed_secs += delta;
if runtime.save_elapsed_secs < SESSION_WRITE_INTERVAL_SECS {
false
} else {
runtime.save_elapsed_secs = 0.0;
true
}
};
if !should_write {
return;
}
let Some(path) = session_path() else {
return;
};
if let Err(error) = write_session(&path, &capture_session(world, false)) {
warn!("Failed to persist running editor session: {error}");
}
}
#[allow(clippy::too_many_arguments)]
fn persist_clean_session_on_exit(
mut exits: MessageReader<AppExit>,
scene_io: Res<SceneIo>,
workspace: Res<ProjectWorkspace>,
prefs: Res<UserPreferences>,
bookmarks: Res<CameraBookmarks>,
diagnostics: Res<DiagnosticsPanel>,
brush_diagnostics: Res<BrushDiagnosticsPanel>,
rendering: Res<RenderingDiagnosticsPanel>,
project_settings: Res<ProjectSettingsPanel>,
) {
if exits.read().next().is_none() {
return;
}
let Some(path) = session_path() else {
return;
};
let document = EditorSessionDocument {
schema_version: SESSION_SCHEMA_VERSION,
clean_shutdown: true,
project_root: workspace.root.clone(),
active_scene: scene_io
.active_path
.as_ref()
.map(|path| path.display().to_string()),
dock_layout: prefs.dock_layout.clone(),
hierarchy_expanded_paths: prefs.hierarchy_expanded_paths.clone(),
panels: SessionPanelState {
diagnostics: diagnostics.open,
brush_diagnostics: brush_diagnostics.open,
rendering: rendering.open,
project_settings: project_settings.open,
},
camera_bookmarks: bookmarks
.poses
.iter()
.map(|(scene, transform)| {
CameraBookmarkRecord::from_transform(scene.clone(), *transform)
})
.collect(),
};
if let Err(error) = write_session(&path, &document) {
warn!("Failed to persist clean editor shutdown: {error}");
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn legacy_unversioned_session_migrates_to_v1() {
let legacy = r#"(
clean_shutdown: true,
project_root: "/project",
active_scene: Some("assets/levels/test.scn.ron"),
)"#;
let document: EditorSessionDocument = ron::from_str(legacy).unwrap();
let migrated = normalize_session(document).unwrap();
assert_eq!(migrated.schema_version, SESSION_SCHEMA_VERSION);
assert_eq!(migrated.project_root, "/project");
}
#[test]
fn newer_session_schema_is_rejected() {
let document = EditorSessionDocument {
schema_version: SESSION_SCHEMA_VERSION + 1,
..default()
};
assert!(normalize_session(document)
.unwrap_err()
.contains("newer than supported"));
}
#[test]
fn invalid_camera_pose_is_removed_during_migration() {
let mut document = EditorSessionDocument::default();
document.camera_bookmarks.push(CameraBookmarkRecord {
scene: "scene".to_string(),
translation: [f32::NAN, 0.0, 0.0],
rotation: [0.0, 0.0, 0.0, 1.0],
scale: [1.0; 3],
});
assert!(normalize_session(document)
.unwrap()
.camera_bookmarks
.is_empty());
}
#[test]
fn serialized_session_contains_only_allowlisted_metadata() {
let document = EditorSessionDocument {
project_root: "/project".to_string(),
active_scene: Some("assets/levels/test.scn.ron".to_string()),
..default()
};
let text = ron::ser::to_string(&document).unwrap();
assert!(text.contains("active_scene"));
for forbidden in ["scene_contents", "credential", "token", "environment"] {
assert!(!text.to_ascii_lowercase().contains(forbidden));
}
}
}

View File

@ -210,97 +210,6 @@ fn draw_project_settings_form(
); );
}); });
ui.collapsing("Audio Buses", |ui| {
let bus_ids: Vec<String> = draft.audio.buses.iter().map(|bus| bus.id.clone()).collect();
let mut remove_bus = None;
for (index, bus) in draft.audio.buses.iter_mut().enumerate() {
egui::Frame::new()
.fill(egui::Color32::from_rgb(24, 26, 29))
.stroke(egui::Stroke::new(1.0, egui::Color32::from_rgb(53, 57, 63)))
.corner_radius(4.0)
.inner_margin(8.0)
.show(ui, |ui| {
ui.horizontal(|ui| {
ui.strong(&bus.label);
ui.small(
egui::RichText::new(format!("[{}]", bus.id))
.color(egui::Color32::from_rgb(139, 146, 156)),
);
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
let required = matches!(
bus.id.as_str(),
settings::AUDIO_BUS_MASTER_ID
| settings::AUDIO_BUS_MUSIC_ID
| settings::AUDIO_BUS_SFX_ID
);
if ui
.add_enabled(!required, egui::Button::new("Remove"))
.clicked()
{
remove_bus = Some(index);
}
});
});
ui.horizontal_wrapped(|ui| {
ui.label("Name");
ui.add_sized([140.0, 20.0], egui::TextEdit::singleline(&mut bus.label));
ui.checkbox(&mut bus.muted, "Mute");
});
ui.add(
egui::Slider::new(
&mut bus.gain_db,
settings::AUDIO_GAIN_DB_MIN..=settings::AUDIO_GAIN_DB_MAX,
)
.text("Gain")
.suffix(" dB"),
);
if bus.id != settings::AUDIO_BUS_MASTER_ID {
let parent_label = bus
.parent
.as_deref()
.and_then(|parent| bus_ids.iter().find(|id| id.as_str() == parent))
.map(String::as_str)
.unwrap_or("None");
egui::ComboBox::from_id_salt(("audio_bus_parent", index))
.selected_text(parent_label)
.show_ui(ui, |ui| {
for candidate in &bus_ids {
if candidate != &bus.id {
ui.selectable_value(
&mut bus.parent,
Some(candidate.clone()),
candidate,
);
}
}
});
}
});
ui.add_space(4.0);
}
if let Some(index) = remove_bus {
draft.audio.buses.remove(index);
}
if ui.button("Add Bus").clicked() {
let mut suffix = 1usize;
let id = loop {
let candidate = format!("bus_{suffix}");
if draft.audio.bus(&candidate).is_none() {
break candidate;
}
suffix += 1;
};
draft.audio.buses.push(settings::AudioBusSettings::new(
id,
"New Bus",
Some(settings::AUDIO_BUS_MASTER_ID),
));
}
if let Err(error) = draft.audio.validate() {
ui.colored_label(egui::Color32::from_rgb(235, 105, 105), error.to_string());
}
});
ui.separator(); ui.separator();
ui.heading("Physics"); ui.heading("Physics");
ui.add(egui::Slider::new(&mut draft.physics.walk_speed, 1.0..=15.0).text("Walk speed")); ui.add(egui::Slider::new(&mut draft.physics.walk_speed, 1.0..=15.0).text("Walk speed"));
@ -322,18 +231,11 @@ fn draw_project_settings_form(
); );
ui.separator(); ui.separator();
let settings_valid = draft.audio.validate().is_ok(); if ui.button("Apply").clicked() {
if ui
.add_enabled(settings_valid, egui::Button::new("Apply"))
.clicked()
{
commit_project_settings(world, draft); commit_project_settings(world, draft);
*io_dirty = true; *io_dirty = true;
} }
if ui if ui.button("Save to disk").clicked() {
.add_enabled(settings_valid, egui::Button::new("Save to disk"))
.clicked()
{
commit_project_settings(world, draft); commit_project_settings(world, draft);
let settings = world.resource::<ProjectSettings>().clone(); let settings = world.resource::<ProjectSettings>().clone();
let mut io = world.resource_mut::<ProjectSettingsIo>(); let mut io = world.resource_mut::<ProjectSettingsIo>();

View File

@ -1,6 +1,5 @@
//! Scene I/O, schema validation, and viewport render-target setup. //! Scene I/O, schema validation, and viewport render-target setup.
pub(crate) mod recovery;
pub mod scene_io; pub mod scene_io;
pub mod scene_schema; pub mod scene_schema;
pub mod scene_view; pub mod scene_view;

View File

@ -1,328 +0,0 @@
//! Transactional scene persistence and user-local recovery snapshot storage.
use std::fs::{self, File, OpenOptions};
use std::hash::Hasher;
use std::io::Write;
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
const STATE_DIR: &str = "blacksite-editor";
const RECOVERY_DIR: &str = "recovery";
pub(crate) fn atomic_write(path: &Path, bytes: &[u8]) -> Result<(), String> {
atomic_write_with_pre_rename(path, bytes, || Ok(()))
}
fn atomic_write_with_pre_rename(
path: &Path,
bytes: &[u8],
before_rename: impl FnOnce() -> Result<(), String>,
) -> Result<(), String> {
let parent = path
.parent()
.filter(|parent| !parent.as_os_str().is_empty())
.unwrap_or_else(|| Path::new("."));
fs::create_dir_all(parent)
.map_err(|error| format!("could not create {}: {error}", parent.display()))?;
let file_name = path
.file_name()
.and_then(|name| name.to_str())
.unwrap_or("scene");
let nonce = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_nanos();
let temporary = parent.join(format!(".{file_name}.tmp-{}-{nonce}", std::process::id()));
let result = (|| {
let mut file = OpenOptions::new()
.create_new(true)
.write(true)
.open(&temporary)
.map_err(|error| format!("could not create {}: {error}", temporary.display()))?;
file.write_all(bytes)
.map_err(|error| format!("could not write {}: {error}", temporary.display()))?;
file.sync_all()
.map_err(|error| format!("could not sync {}: {error}", temporary.display()))?;
before_rename()?;
fs::rename(&temporary, path).map_err(|error| {
format!(
"could not replace {} with {}: {error}",
path.display(),
temporary.display()
)
})?;
sync_parent_directory(parent);
Ok(())
})();
if result.is_err() {
let _ = fs::remove_file(&temporary);
}
result
}
#[cfg(unix)]
fn sync_parent_directory(parent: &Path) {
if let Ok(directory) = File::open(parent) {
let _ = directory.sync_all();
}
}
#[cfg(not(unix))]
fn sync_parent_directory(_parent: &Path) {}
pub(crate) fn default_state_root() -> Option<PathBuf> {
if let Some(path) = std::env::var_os("XDG_STATE_HOME") {
return Some(PathBuf::from(path).join(STATE_DIR));
}
std::env::var_os("HOME")
.map(PathBuf::from)
.map(|home| home.join(".local/state").join(STATE_DIR))
}
pub(crate) fn write_recovery_snapshot(
state_root: &Path,
project_root: &Path,
scene_path: &Path,
bytes: &[u8],
max_generations: usize,
) -> Result<PathBuf, String> {
let directory = recovery_directory(state_root, project_root, scene_path);
fs::create_dir_all(&directory)
.map_err(|error| format!("could not create {}: {error}", directory.display()))?;
let timestamp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_millis();
let snapshot = directory.join(format!(
"recovery-{timestamp:020}-{}.scn.ron",
std::process::id()
));
atomic_write(&snapshot, bytes)?;
prune_recovery_generations(&directory, max_generations.max(1))?;
Ok(snapshot)
}
pub(crate) fn latest_recovery_snapshot(
state_root: &Path,
project_root: &Path,
scene_path: &Path,
) -> Option<PathBuf> {
let directory = recovery_directory(state_root, project_root, scene_path);
let source_modified = fs::metadata(scene_path)
.and_then(|metadata| metadata.modified())
.unwrap_or(UNIX_EPOCH);
recovery_files(&directory)
.into_iter()
.filter(|path| {
fs::metadata(path)
.and_then(|metadata| metadata.modified())
.is_ok_and(|modified| modified > source_modified)
})
.max_by_key(|path| path.file_name().map(|name| name.to_os_string()))
}
pub(crate) fn discard_recovery_snapshots(
state_root: &Path,
project_root: &Path,
scene_path: &Path,
) -> Result<(), String> {
let directory = recovery_directory(state_root, project_root, scene_path);
if !directory.exists() {
return Ok(());
}
fs::remove_dir_all(&directory)
.map_err(|error| format!("could not remove {}: {error}", directory.display()))
}
fn recovery_directory(state_root: &Path, project_root: &Path, scene_path: &Path) -> PathBuf {
state_root
.join(RECOVERY_DIR)
.join(stable_path_hash(project_root))
.join(stable_path_hash(scene_path))
}
fn stable_path_hash(path: &Path) -> String {
let normalized = path.to_string_lossy().replace('\\', "/");
let mut hasher = Fnv1a64::default();
hasher.write(normalized.as_bytes());
format!("{:016x}", hasher.finish())
}
fn prune_recovery_generations(directory: &Path, max_generations: usize) -> Result<(), String> {
let mut files = recovery_files(directory);
files.sort_by_key(|path| path.file_name().map(|name| name.to_os_string()));
let remove_count = files.len().saturating_sub(max_generations);
for path in files.into_iter().take(remove_count) {
fs::remove_file(&path)
.map_err(|error| format!("could not prune {}: {error}", path.display()))?;
}
Ok(())
}
fn recovery_files(directory: &Path) -> Vec<PathBuf> {
let Ok(entries) = fs::read_dir(directory) else {
return Vec::new();
};
entries
.filter_map(Result::ok)
.map(|entry| entry.path())
.filter(|path| {
path.file_name()
.and_then(|name| name.to_str())
.is_some_and(|name| name.starts_with("recovery-") && name.ends_with(".scn.ron"))
})
.collect()
}
#[derive(Default)]
struct Fnv1a64(u64);
impl Hasher for Fnv1a64 {
fn finish(&self) -> u64 {
if self.0 == 0 {
0xcbf29ce484222325
} else {
self.0
}
}
fn write(&mut self, bytes: &[u8]) {
let mut hash = self.finish();
for byte in bytes {
hash ^= u64::from(*byte);
hash = hash.wrapping_mul(0x100000001b3);
}
self.0 = hash;
}
}
#[cfg(test)]
mod tests {
use super::*;
fn temp_directory(label: &str) -> PathBuf {
std::env::temp_dir().join(format!("blacksite-{label}-{}", uuid::Uuid::new_v4()))
}
#[test]
fn atomic_write_replaces_content_without_leaving_temporary_files() {
let root = temp_directory("atomic-write");
let path = root.join("scene.scn.ron");
fs::create_dir_all(&root).unwrap();
fs::write(&path, b"old").unwrap();
atomic_write(&path, b"new scene").unwrap();
assert_eq!(fs::read(&path).unwrap(), b"new scene");
assert_eq!(fs::read_dir(&root).unwrap().count(), 1);
fs::remove_dir_all(root).unwrap();
}
#[test]
fn interrupted_atomic_write_preserves_the_last_authored_scene() {
let root = temp_directory("atomic-interruption");
let path = root.join("scene.scn.ron");
fs::create_dir_all(&root).unwrap();
fs::write(&path, b"last known good scene").unwrap();
let result = atomic_write_with_pre_rename(&path, b"partial replacement", || {
Err("simulated interruption before rename".to_string())
});
assert_eq!(result.unwrap_err(), "simulated interruption before rename");
assert_eq!(fs::read(&path).unwrap(), b"last known good scene");
assert_eq!(fs::read_dir(&root).unwrap().count(), 1);
fs::remove_dir_all(root).unwrap();
}
#[test]
fn recovery_paths_are_stable_and_scene_specific() {
let state = Path::new("/state");
let project = Path::new("/project");
let first = recovery_directory(state, project, Path::new("assets/levels/a.scn.ron"));
let repeated = recovery_directory(state, project, Path::new("assets/levels/a.scn.ron"));
let second = recovery_directory(state, project, Path::new("assets/levels/b.scn.ron"));
assert_eq!(first, repeated);
assert_ne!(first, second);
assert!(first.starts_with("/state/recovery"));
}
#[test]
fn newer_recovery_generation_is_discovered_for_authored_scene() {
let root = temp_directory("recovery-discovery");
let project = Path::new("/project");
let scene = root.join("assets/levels/test.scn.ron");
fs::create_dir_all(scene.parent().unwrap()).unwrap();
fs::write(&scene, b"authored").unwrap();
std::thread::sleep(std::time::Duration::from_millis(10));
let snapshot = write_recovery_snapshot(&root, project, &scene, b"recovered", 5).unwrap();
assert_eq!(
latest_recovery_snapshot(&root, project, &scene),
Some(snapshot)
);
fs::remove_dir_all(root).unwrap();
}
#[test]
fn recovery_generations_are_bounded() {
let root = temp_directory("recovery-prune");
let project = Path::new("/project");
let scene = Path::new("assets/levels/test.scn.ron");
let directory = recovery_directory(&root, project, scene);
fs::create_dir_all(&directory).unwrap();
for index in 0..5 {
fs::write(
directory.join(format!("recovery-{index:020}-1.scn.ron")),
index.to_string(),
)
.unwrap();
}
prune_recovery_generations(&directory, 3).unwrap();
let mut names: Vec<String> = recovery_files(&directory)
.into_iter()
.map(|path| path.file_name().unwrap().to_string_lossy().into_owned())
.collect();
names.sort();
assert_eq!(
names,
vec![
"recovery-00000000000000000002-1.scn.ron",
"recovery-00000000000000000003-1.scn.ron",
"recovery-00000000000000000004-1.scn.ron",
]
);
fs::remove_dir_all(root).unwrap();
}
#[test]
fn discard_removes_all_scene_generations() {
let root = temp_directory("recovery-discard");
let project = Path::new("/project");
let scene = Path::new("assets/levels/test.scn.ron");
let directory = recovery_directory(&root, project, scene);
fs::create_dir_all(&directory).unwrap();
fs::write(
directory.join("recovery-00000000000000000001-1.scn.ron"),
b"one",
)
.unwrap();
fs::write(
directory.join("recovery-00000000000000000002-1.scn.ron"),
b"two",
)
.unwrap();
discard_recovery_snapshots(&root, project, scene).unwrap();
assert!(!directory.exists());
fs::remove_dir_all(root).unwrap();
}
}

File diff suppressed because it is too large Load Diff

View File

@ -5,12 +5,11 @@ mod transform;
use bevy::prelude::*; use bevy::prelude::*;
use bevy_egui::egui; use bevy_egui::egui;
use egui_phosphor_icons::icons; use egui_phosphor_icons::icons;
use shared::{ActorId, ActorKind, ActorName, HydratedPrefabMember, LevelObject}; use shared::{ActorId, ActorKind, ActorName, LevelObject};
use crate::ext::extensibility::ActorInspectorSectionRegistry; use crate::ext::extensibility::ActorInspectorSectionRegistry;
use crate::ui::inspector; use crate::ui::inspector;
use crate::ui::selection_ops::is_linked_read_only; use crate::ui::theme::{panel_heading, BORDER, TEXT_DIM, WIDGET_BG};
use crate::ui::theme::{panel_heading, BORDER, SELECTION, TEXT, TEXT_DIM, WIDGET_BG};
use crate::ui::widgets::phosphor_icon; use crate::ui::widgets::phosphor_icon;
use transform::transform_inspector_ui; use transform::transform_inspector_ui;
@ -18,8 +17,6 @@ use transform::transform_inspector_ui;
const COMPACT_ACTOR_HEADER_WIDTH: f32 = 360.0; const COMPACT_ACTOR_HEADER_WIDTH: f32 = 360.0;
pub fn draw_actor_inspector(world: &mut World, ui: &mut egui::Ui, entity: Entity) { pub fn draw_actor_inspector(world: &mut World, ui: &mut egui::Ui, entity: Entity) {
let linked_read_only = is_linked_read_only(world, entity);
let prefab_member = world.get::<HydratedPrefabMember>(entity).is_some();
let Some(entity_ref) = world.get_entity(entity).ok() else { let Some(entity_ref) = world.get_entity(entity).ok() else {
ui.label("Entity no longer exists."); ui.label("Entity no longer exists.");
return; return;
@ -82,59 +79,34 @@ pub fn draw_actor_inspector(world: &mut World, ui: &mut egui::Ui, entity: Entity
}); });
ui.add_space(6.0); ui.add_space(6.0);
if linked_read_only { egui::ScrollArea::vertical()
ui.small( .auto_shrink([false, false])
egui::RichText::new(if prefab_member { .show(ui, |ui| {
"Linked prefab member" transform_inspector_ui(world, ui, entity);
} else {
"Linked scene member"
})
.color(TEXT_DIM),
);
ui.add_space(4.0);
}
if prefab_member { match kind {
egui::ScrollArea::vertical() ActorKind::StaticMesh
.auto_shrink([false, false]) | ActorKind::Brush
.show(ui, |ui| { | ActorKind::ImportedModel
crate::assets::prefab_overrides::prefab_member_inspector_ui(world, ui, entity); | ActorKind::Light
}); | ActorKind::Empty
return; | ActorKind::PrefabAnchor
} | ActorKind::PlayerSpawn
| ActorKind::WeaponSpawn
ui.add_enabled_ui(!linked_read_only, |ui| { | ActorKind::TriggerVolume
egui::ScrollArea::vertical() | ActorKind::PostProcessVolume
.auto_shrink([false, false]) | ActorKind::TeamSpawn
.show(ui, |ui| { | ActorKind::Objective => {
transform_inspector_ui(world, ui, entity); inspector::authoring_inspector_ui(world, ui, entity);
match kind {
ActorKind::StaticMesh
| ActorKind::Brush
| ActorKind::ImportedModel
| ActorKind::Light
| ActorKind::Empty
| ActorKind::PrefabAnchor
| ActorKind::PlayerSpawn
| ActorKind::WeaponSpawn
| ActorKind::TriggerVolume
| ActorKind::PostProcessVolume
| ActorKind::TeamSpawn
| ActorKind::Objective
| ActorKind::AudioSource
| ActorKind::AudioListener => {
inspector::authoring_inspector_ui(world, ui, entity);
}
} }
}
world.resource_scope(|world, registry: Mut<ActorInspectorSectionRegistry>| { world.resource_scope(|world, registry: Mut<ActorInspectorSectionRegistry>| {
registry.draw_sections(world, ui, entity); registry.draw_sections(world, ui, entity);
});
inspector::add_component_footer(world, ui, entity);
}); });
});
inspector::add_component_footer(world, ui, entity);
});
} }
fn actor_icon(kind: ActorKind) -> egui_phosphor_icons::Icon { fn actor_icon(kind: ActorKind) -> egui_phosphor_icons::Icon {
@ -147,52 +119,14 @@ fn actor_icon(kind: ActorKind) -> egui_phosphor_icons::Icon {
ActorKind::TriggerVolume => icons::SELECTION, ActorKind::TriggerVolume => icons::SELECTION,
ActorKind::PostProcessVolume => icons::APERTURE, ActorKind::PostProcessVolume => icons::APERTURE,
ActorKind::TeamSpawn | ActorKind::Objective => icons::FLAG, ActorKind::TeamSpawn | ActorKind::Objective => icons::FLAG,
ActorKind::AudioSource => icons::SPEAKER_HIGH,
ActorKind::AudioListener => icons::HEADPHONES,
ActorKind::Empty => icons::CIRCLE, ActorKind::Empty => icons::CIRCLE,
} }
} }
pub fn draw_multi_actor_inspector(world: &mut World, ui: &mut egui::Ui, entities: &[Entity]) { pub fn draw_multi_actor_inspector(world: &mut World, ui: &mut egui::Ui, entities: &[Entity]) {
if entities.is_empty() { ui.heading(format!("{} objects selected", entities.len()));
egui::Frame::new() ui.label("Multi-edit: use the viewport gizmo for shared transform.");
.fill(WIDGET_BG)
.stroke(egui::Stroke::new(1.0, BORDER))
.corner_radius(egui::CornerRadius::same(4))
.inner_margin(egui::Margin::symmetric(10, 10))
.show(ui, |ui| {
ui.horizontal(|ui| {
ui.label(phosphor_icon(icons::SELECTION, 24.0).color(TEXT_DIM));
ui.label(egui::RichText::new("No selection").color(TEXT_DIM).strong());
});
});
return;
}
if entities.len() == 1 { if entities.len() == 1 {
draw_actor_inspector(world, ui, entities[0]); draw_actor_inspector(world, ui, entities[0]);
return;
} }
egui::Frame::new()
.fill(WIDGET_BG)
.stroke(egui::Stroke::new(1.0, BORDER))
.corner_radius(egui::CornerRadius::same(4))
.inner_margin(egui::Margin::symmetric(10, 10))
.show(ui, |ui| {
ui.horizontal(|ui| {
ui.label(phosphor_icon(icons::SELECTION_ALL, 26.0).color(SELECTION));
ui.vertical(|ui| {
ui.label(
egui::RichText::new(format!("{} objects selected", entities.len()))
.color(TEXT)
.strong(),
);
ui.label(
egui::RichText::new("Shared transform gizmo")
.color(TEXT_DIM)
.small(),
);
});
});
});
} }

File diff suppressed because it is too large Load Diff

View File

@ -29,18 +29,16 @@ use crate::asset_db::{
find_asset_by_path, find_asset_mut_by_path, save_registry, update_import_settings, find_asset_by_path, find_asset_mut_by_path, save_registry, update_import_settings,
ImportSettings, MaterialImportPolicy, ModelHierarchyMode, ModelPlacementMode, ImportSettings, MaterialImportPolicy, ModelHierarchyMode, ModelPlacementMode,
}; };
use crate::assets::animation::load_animation_manifest;
use crate::assets::operators::{ use crate::assets::operators::{
apply_material_operator, apply_texture_operator, assign_animation_clip_operator, apply_material_operator, apply_texture_operator, place_asset_operator, place_subasset_operator,
place_asset_operator, place_subasset_operator,
}; };
use crate::assets::static_mesh::{ use crate::assets::static_mesh::{
load_static_mesh_manifest, material_id_from_label, part_id_from_label, load_static_mesh_manifest, material_id_from_label, part_id_from_label,
refresh_static_mesh_artifact,
}; };
use crate::assets::{ use crate::assets::{
asset_cache_key, draw_asset_cell_with, invalidate_on_catalog_refresh, kind_icon, asset_cache_key, draw_asset_cell_with, invalidate_on_catalog_refresh, kind_icon,
prefetch_folder_thumbnails, refresh_model_artifacts, AssetThumbnailCache, prefetch_folder_thumbnails, AssetThumbnailCache, ThumbnailCacheSnapshot, ThumbnailStudio,
ThumbnailCacheSnapshot, ThumbnailStudio,
}; };
use crate::ui::helpers::asset_label; use crate::ui::helpers::asset_label;
use crate::ui::theme::{panel_heading, ACCENT, BORDER, ELEVATED_BG, TEXT, TEXT_DIM, WIDGET_BG}; use crate::ui::theme::{panel_heading, ACCENT, BORDER, ELEVATED_BG, TEXT, TEXT_DIM, WIDGET_BG};
@ -83,7 +81,6 @@ struct EmbeddedAsset {
thumbnail_key: String, thumbnail_key: String,
mesh_label: Option<String>, mesh_label: Option<String>,
material_label: Option<String>, material_label: Option<String>,
requires_scene_instance: bool,
} }
fn fit_width(ui: &egui::Ui, min: f32, max: f32) -> f32 { fn fit_width(ui: &egui::Ui, min: f32, max: f32) -> f32 {
@ -346,188 +343,115 @@ fn embedded_assets_for_asset(world: &World, asset: &EditorAsset) -> Vec<Embedded
else { else {
return Vec::new(); return Vec::new();
}; };
let Some(manifest_path) = record.import_settings.static_mesh_manifest_path.as_deref() else {
return Vec::new();
};
let Ok(manifest) = load_static_mesh_manifest(manifest_path) else {
return Vec::new();
};
let mut embedded = Vec::new(); let mut embedded = Vec::new();
let static_manifest = record for part in &manifest.parts {
.import_settings let mesh_id = part_effective_id(part);
.static_mesh_manifest_path let material_label = part.material_label.clone();
.as_deref() embedded.push(EmbeddedAsset {
.and_then(|path| load_static_mesh_manifest(path).ok()); selection: AssetSelection::SubAsset {
let skinned_primitives = crate::assets::gltf_skinned_primitive_labels(parent_path.as_str()); parent_path: parent_path.clone(),
if let Some(manifest) = static_manifest.as_ref() { sub_asset_id: mesh_id,
for part in &manifest.parts {
let mesh_id = part_effective_id(part);
let material_label = part.material_label.clone();
let requires_scene_instance = skinned_primitives.contains(&part.mesh_label);
embedded.push(EmbeddedAsset {
selection: AssetSelection::SubAsset {
parent_path: parent_path.clone(),
sub_asset_id: mesh_id,
label: part.name.clone(),
kind: AssetSubAssetKind::Mesh,
source_path: None,
},
label: part.name.clone(), label: part.name.clone(),
kind: AssetSubAssetKind::Mesh, kind: AssetSubAssetKind::Mesh,
detail: if requires_scene_instance { source_path: None,
"Skinned | place model or animation clip".to_string() },
} else { label: part.name.clone(),
part.source_mesh kind: AssetSubAssetKind::Mesh,
.clone() detail: part
.unwrap_or_else(|| part.mesh_label.clone()) .source_mesh
}, .clone()
texture_path: None, .unwrap_or_else(|| part.mesh_label.clone()),
thumbnail_key: subasset_thumbnail_key( texture_path: None,
parent_path, thumbnail_key: subasset_thumbnail_key(
AssetSubAssetKind::Mesh, parent_path,
&part_effective_id(part), AssetSubAssetKind::Mesh,
), &part_effective_id(part),
mesh_label: Some(part.mesh_label.clone()), ),
material_label, mesh_label: Some(part.mesh_label.clone()),
requires_scene_instance, material_label,
}); });
}
let mut material_ids = HashSet::new();
for part in &manifest.parts {
let Some(material_id) = part_effective_material_id(part) else {
continue;
};
if !material_ids.insert(material_id.clone()) {
continue;
}
let label = if part.material_slot_name.trim().is_empty() {
part.material_label
.clone()
.unwrap_or_else(|| "Source Material".to_string())
} else {
part.material_slot_name.clone()
};
embedded.push(EmbeddedAsset {
selection: AssetSelection::SubAsset {
parent_path: parent_path.clone(),
sub_asset_id: material_id,
label: label.clone(),
kind: AssetSubAssetKind::Material,
source_path: None,
},
label,
kind: AssetSubAssetKind::Material,
detail: "Embedded source material".to_string(),
texture_path: None,
thumbnail_key: subasset_thumbnail_key(
parent_path,
AssetSubAssetKind::Material,
&part_effective_material_id(part).unwrap_or_else(|| {
material_id_from_label(
part.material_label
.as_deref()
.unwrap_or(&part.material_slot_name),
)
}),
),
mesh_label: None,
material_label: part.material_label.clone(),
requires_scene_instance: false,
});
}
let mut texture_paths = HashSet::new();
for dependency in &manifest.source.dependencies {
if !is_texture_path(dependency) || !texture_paths.insert(dependency.clone()) {
continue;
}
let label = Path::new(dependency)
.file_stem()
.and_then(|stem| stem.to_str())
.unwrap_or(dependency)
.to_string();
embedded.push(EmbeddedAsset {
selection: AssetSelection::SubAsset {
parent_path: parent_path.clone(),
sub_asset_id: format!("texture:{}", stable_subasset_slug(dependency)),
label: label.clone(),
kind: AssetSubAssetKind::Texture,
source_path: Some(dependency.clone()),
},
label,
kind: AssetSubAssetKind::Texture,
detail: dependency.clone(),
texture_path: Some(dependency.clone()),
thumbnail_key: subasset_thumbnail_key(
parent_path,
AssetSubAssetKind::Texture,
&stable_subasset_slug(dependency),
),
mesh_label: None,
material_label: None,
requires_scene_instance: false,
});
}
} }
let animation_manifest = record let mut material_ids = HashSet::new();
.import_settings for part in &manifest.parts {
.animation_manifest_path let Some(material_id) = part_effective_material_id(part) else {
.as_deref() continue;
.and_then(|path| load_animation_manifest(path).ok()); };
if let Some(manifest) = animation_manifest { if !material_ids.insert(material_id.clone()) {
for skeleton in manifest.skeletons { continue;
let signature = skeleton.signature.0;
let signature_short = signature.get(..8).unwrap_or(&signature);
embedded.push(EmbeddedAsset {
selection: AssetSelection::SubAsset {
parent_path: parent_path.clone(),
sub_asset_id: skeleton.id.clone(),
label: skeleton.label.clone(),
kind: AssetSubAssetKind::Skeleton,
source_path: Some(parent_path.clone()),
},
label: skeleton.label,
kind: AssetSubAssetKind::Skeleton,
detail: format!(
"{} joints | rig {}",
skeleton.joint_paths.len(),
signature_short
),
texture_path: None,
thumbnail_key: subasset_thumbnail_key(
parent_path,
AssetSubAssetKind::Skeleton,
&skeleton.id,
),
mesh_label: None,
material_label: None,
requires_scene_instance: false,
});
} }
for clip in manifest.clips { let label = if part.material_slot_name.trim().is_empty() {
let event_suffix = if clip.events.is_empty() { part.material_label
String::new() .clone()
} else { .unwrap_or_else(|| "Source Material".to_string())
format!(" | {} events", clip.events.len()) } else {
}; part.material_slot_name.clone()
embedded.push(EmbeddedAsset { };
selection: AssetSelection::SubAsset { embedded.push(EmbeddedAsset {
parent_path: parent_path.clone(), selection: AssetSelection::SubAsset {
sub_asset_id: clip.id.clone(), parent_path: parent_path.clone(),
label: clip.label.clone(), sub_asset_id: material_id,
kind: AssetSubAssetKind::AnimationClip, label: label.clone(),
source_path: Some(parent_path.clone()), kind: AssetSubAssetKind::Material,
}, source_path: None,
label: clip.label, },
kind: AssetSubAssetKind::AnimationClip, label,
detail: format!("{:.2}s{event_suffix}", clip.duration_seconds), kind: AssetSubAssetKind::Material,
texture_path: None, detail: "Embedded source material".to_string(),
thumbnail_key: subasset_thumbnail_key( texture_path: None,
parent_path, thumbnail_key: subasset_thumbnail_key(
AssetSubAssetKind::AnimationClip, parent_path,
&clip.id, AssetSubAssetKind::Material,
), &part_effective_material_id(part).unwrap_or_else(|| {
mesh_label: None, material_id_from_label(
material_label: None, part.material_label
requires_scene_instance: false, .as_deref()
}); .unwrap_or(&part.material_slot_name),
)
}),
),
mesh_label: None,
material_label: part.material_label.clone(),
});
}
let mut texture_paths = HashSet::new();
for dependency in &manifest.source.dependencies {
if !is_texture_path(dependency) || !texture_paths.insert(dependency.clone()) {
continue;
} }
let label = Path::new(dependency)
.file_stem()
.and_then(|stem| stem.to_str())
.unwrap_or(dependency)
.to_string();
embedded.push(EmbeddedAsset {
selection: AssetSelection::SubAsset {
parent_path: parent_path.clone(),
sub_asset_id: format!("texture:{}", stable_subasset_slug(dependency)),
label: label.clone(),
kind: AssetSubAssetKind::Texture,
source_path: Some(dependency.clone()),
},
label,
kind: AssetSubAssetKind::Texture,
detail: dependency.clone(),
texture_path: Some(dependency.clone()),
thumbnail_key: subasset_thumbnail_key(
parent_path,
AssetSubAssetKind::Texture,
&stable_subasset_slug(dependency),
),
mesh_label: None,
material_label: None,
});
} }
embedded embedded
@ -642,7 +566,6 @@ fn asset_toolbar(world: &mut World, ui: &mut egui::Ui) {
AssetKindFilter::Material, AssetKindFilter::Material,
"Materials", "Materials",
); );
ui.selectable_value(&mut state.kind_filter, AssetKindFilter::Audio, "Audio");
ui.selectable_value(&mut state.kind_filter, AssetKindFilter::Level, "Levels"); ui.selectable_value(&mut state.kind_filter, AssetKindFilter::Level, "Levels");
ui.selectable_value(&mut state.kind_filter, AssetKindFilter::Prefab, "Prefabs"); ui.selectable_value(&mut state.kind_filter, AssetKindFilter::Prefab, "Prefabs");
ui.selectable_value( ui.selectable_value(
@ -789,9 +712,6 @@ fn breadcrumb(world: &mut World, ui: &mut egui::Ui, current_folder: &str) {
fn selection_footer(world: &mut World, ui: &mut egui::Ui, selected_entities: &SelectedEntities) { fn selection_footer(world: &mut World, ui: &mut egui::Ui, selected_entities: &SelectedEntities) {
let selection = world.resource::<EditorAssets>().selected.clone(); let selection = world.resource::<EditorAssets>().selected.clone();
let selected_embedded = selection
.as_ref()
.and_then(|selection| embedded_asset_for_selection(world, selection));
if let Some(AssetSelection::SubAsset { label, kind, .. }) = selection.clone() { if let Some(AssetSelection::SubAsset { label, kind, .. }) = selection.clone() {
ui.add( ui.add(
egui::Label::new(format!( egui::Label::new(format!(
@ -803,14 +723,7 @@ fn selection_footer(world: &mut World, ui: &mut egui::Ui, selected_entities: &Se
); );
ui.horizontal_wrapped(|ui| match kind { ui.horizontal_wrapped(|ui| match kind {
AssetSubAssetKind::Mesh => { AssetSubAssetKind::Mesh => {
if selected_embedded if ui.button("Place At Origin").clicked() {
.as_ref()
.is_some_and(|embedded| embedded.requires_scene_instance)
{
ui.small(
egui::RichText::new("Place the model or an animation clip").color(TEXT_DIM),
);
} else if ui.button("Place At Origin").clicked() {
if let Some(selection) = selection.as_ref() { if let Some(selection) = selection.as_ref() {
place_subasset_operator(world, selection.clone(), Vec3::ZERO); place_subasset_operator(world, selection.clone(), Vec3::ZERO);
} }
@ -826,30 +739,6 @@ fn selection_footer(world: &mut World, ui: &mut egui::Ui, selected_entities: &Se
AssetSubAssetKind::Material => { AssetSubAssetKind::Material => {
ui.small(egui::RichText::new("Embedded source material").color(TEXT_DIM)); ui.small(egui::RichText::new("Embedded source material").color(TEXT_DIM));
} }
AssetSubAssetKind::Skeleton => {
ui.small(egui::RichText::new("Inspect-only rig metadata").color(TEXT_DIM));
}
AssetSubAssetKind::AnimationClip => {
let animated_actor = selected_entities
.as_slice()
.iter()
.copied()
.find(|entity| world.get::<shared::ModelRef>(*entity).is_some());
let action = if animated_actor.is_some() {
"Assign To Selected Actor"
} else {
"Create Animated Actor"
};
if ui.button(action).clicked() {
if let Some(selection) = selection.as_ref() {
if let Some(entity) = animated_actor {
assign_animation_clip_operator(world, selection.clone(), entity);
} else {
place_subasset_operator(world, selection.clone(), Vec3::ZERO);
}
}
}
}
}); });
} else if let Some(asset) = world.resource::<EditorAssets>().selected_asset().cloned() { } else if let Some(asset) = world.resource::<EditorAssets>().selected_asset().cloned() {
ui.add(egui::Label::new(format!("Selected: {}", asset_label(&asset))).truncate()); ui.add(egui::Label::new(format!("Selected: {}", asset_label(&asset))).truncate());
@ -864,15 +753,12 @@ fn selection_footer(world: &mut World, ui: &mut egui::Ui, selected_entities: &Se
EditorAssetKind::Primitive(_) EditorAssetKind::Primitive(_)
| EditorAssetKind::Light(_) | EditorAssetKind::Light(_)
| EditorAssetKind::Model | EditorAssetKind::Model
| EditorAssetKind::AudioClip
| EditorAssetKind::Prefab | EditorAssetKind::Prefab
| EditorAssetKind::Level
) && ui.button("Place At Origin").clicked() ) && ui.button("Place At Origin").clicked()
{ {
place_asset_operator(world, asset.clone(), Vec3::ZERO); place_asset_operator(world, asset.clone(), Vec3::ZERO);
} }
if matches!(asset.kind, EditorAssetKind::Level) && ui.button("Open Scene").clicked() {
open_level_asset(world, &asset);
}
}); });
} else { } else {
ui.label(egui::RichText::new("No asset selected").color(TEXT_DIM)); ui.label(egui::RichText::new("No asset selected").color(TEXT_DIM));
@ -1257,7 +1143,6 @@ fn prefetch_embedded_thumbnails(
parent_path.clone(), parent_path.clone(),
mesh_label, mesh_label,
embedded_asset.material_label.clone(), embedded_asset.material_label.clone(),
embedded_asset.requires_scene_instance,
&mut studio, &mut studio,
); );
} }
@ -1282,7 +1167,6 @@ fn prefetch_embedded_thumbnails(
&asset_server, &asset_server,
); );
} }
AssetSubAssetKind::Skeleton | AssetSubAssetKind::AnimationClip => {}
} }
} }
}); });
@ -1317,13 +1201,6 @@ fn draw_embedded_asset_cell(
let thumb_size = thumbnail_size.clamp(44.0, 64.0); let thumb_size = thumbnail_size.clamp(44.0, 64.0);
let cell_size = egui::vec2(104.0, thumb_size + 50.0); let cell_size = egui::vec2(104.0, thumb_size + 50.0);
let (rect, response) = ui.allocate_exact_size(cell_size, egui::Sense::click_and_drag()); let (rect, response) = ui.allocate_exact_size(cell_size, egui::Sense::click_and_drag());
let response = if embedded.requires_scene_instance {
response.on_hover_text(
"Skinned primitives require their model hierarchy. Place the model or an animation clip instead.",
)
} else {
response
};
let fill = if selected { let fill = if selected {
crate::ui::theme::SELECTION_BG_MUTED crate::ui::theme::SELECTION_BG_MUTED
} else if response.hovered() { } else if response.hovered() {
@ -1400,10 +1277,7 @@ fn draw_embedded_asset_cell(
.resource_mut::<EditorAssets>() .resource_mut::<EditorAssets>()
.select(embedded.selection.clone()); .select(embedded.selection.clone());
} }
if response.drag_started() if response.drag_started() {
&& embedded.kind != AssetSubAssetKind::Skeleton
&& !embedded.requires_scene_instance
{
world world
.resource_mut::<EditorAssets>() .resource_mut::<EditorAssets>()
.start_drag(embedded.selection.clone()); .start_drag(embedded.selection.clone());
@ -1420,10 +1294,7 @@ fn compact_subasset_label(embedded: &EmbeddedAsset) -> String {
.split_once(" / ") .split_once(" / ")
.map(|(head, _)| head) .map(|(head, _)| head)
.unwrap_or(&embedded.label), .unwrap_or(&embedded.label),
AssetSubAssetKind::Material AssetSubAssetKind::Material | AssetSubAssetKind::Texture => embedded.label.as_str(),
| AssetSubAssetKind::Texture
| AssetSubAssetKind::Skeleton
| AssetSubAssetKind::AnimationClip => embedded.label.as_str(),
}; };
compact_text(base, 16) compact_text(base, 16)
} }
@ -1468,8 +1339,6 @@ fn subasset_icon(kind: AssetSubAssetKind) -> Icon {
AssetSubAssetKind::Mesh => icons::CUBE, AssetSubAssetKind::Mesh => icons::CUBE,
AssetSubAssetKind::Material => icons::PALETTE, AssetSubAssetKind::Material => icons::PALETTE,
AssetSubAssetKind::Texture => icons::IMAGE, AssetSubAssetKind::Texture => icons::IMAGE,
AssetSubAssetKind::Skeleton => icons::BONE,
AssetSubAssetKind::AnimationClip => icons::FILM_STRIP,
} }
} }
@ -1478,8 +1347,6 @@ fn subasset_kind_label(kind: AssetSubAssetKind) -> &'static str {
AssetSubAssetKind::Mesh => "Mesh", AssetSubAssetKind::Mesh => "Mesh",
AssetSubAssetKind::Material => "Material", AssetSubAssetKind::Material => "Material",
AssetSubAssetKind::Texture => "Texture", AssetSubAssetKind::Texture => "Texture",
AssetSubAssetKind::Skeleton => "Skeleton",
AssetSubAssetKind::AnimationClip => "Animation Clip",
} }
} }
@ -1874,39 +1741,6 @@ fn top_level_asset_details_panel(
ui.label(panel_heading("Material")); ui.label(panel_heading("Material"));
material_asset_editor(world, ui, asset, path); material_asset_editor(world, ui, asset, path);
} }
if matches!(asset.kind, EditorAssetKind::AudioClip) {
detail_row(ui, "Format", audio_format_label(path));
let previewing = world
.resource::<crate::play::audio_preview::AudioPreviewState>()
.clip_path
.as_deref()
== Some(path);
ui.horizontal_wrapped(|ui| {
if ui
.add_enabled(
!previewing,
egui::Button::new(format!("{} Audition", icons::PLAY.as_str())),
)
.clicked()
{
if let Err(error) =
crate::play::audio_preview::audition_audio_asset(world, asset)
{
world.resource_mut::<SceneIo>().status =
format!("Audio audition failed: {error}");
}
}
if ui
.add_enabled(
previewing,
egui::Button::new(format!("{} Stop", icons::STOP.as_str())),
)
.clicked()
{
crate::play::audio_preview::stop_audio_preview(world);
}
});
}
} }
ui.separator(); ui.separator();
@ -1970,14 +1804,7 @@ fn subasset_details_panel(
ui.separator(); ui.separator();
match embedded.kind { match embedded.kind {
AssetSubAssetKind::Mesh => { AssetSubAssetKind::Mesh => {
if embedded.requires_scene_instance { if ui.button("Place At Origin").clicked() {
ui.small(
egui::RichText::new(
"This skinned primitive needs its source hierarchy. Place the model or an animation clip instead.",
)
.color(TEXT_DIM),
);
} else if ui.button("Place At Origin").clicked() {
place_subasset_operator(world, selection.clone(), Vec3::ZERO); place_subasset_operator(world, selection.clone(), Vec3::ZERO);
} }
} }
@ -1998,31 +1825,6 @@ fn subasset_details_panel(
.color(TEXT_DIM), .color(TEXT_DIM),
); );
} }
AssetSubAssetKind::Skeleton => {
ui.small(
egui::RichText::new("Skeleton metadata is generated from the model source.")
.color(TEXT_DIM),
);
}
AssetSubAssetKind::AnimationClip => {
let animated_actor = selected_entities
.as_slice()
.iter()
.copied()
.find(|entity| world.get::<shared::ModelRef>(*entity).is_some());
let action = if animated_actor.is_some() {
"Assign To Selected Actor"
} else {
"Create Animated Actor"
};
if ui.button(action).clicked() {
if let Some(entity) = animated_actor {
assign_animation_clip_operator(world, selection.clone(), entity);
} else {
place_subasset_operator(world, selection.clone(), Vec3::ZERO);
}
}
}
} }
if ui.button("Select Parent Asset").clicked() { if ui.button("Select Parent Asset").clicked() {
world world
@ -2050,15 +1852,12 @@ fn asset_action_buttons(
EditorAssetKind::Primitive(_) EditorAssetKind::Primitive(_)
| EditorAssetKind::Light(_) | EditorAssetKind::Light(_)
| EditorAssetKind::Model | EditorAssetKind::Model
| EditorAssetKind::AudioClip
| EditorAssetKind::Prefab | EditorAssetKind::Prefab
| EditorAssetKind::Level
) && ui.button("Place At Origin").clicked() ) && ui.button("Place At Origin").clicked()
{ {
place_asset_operator(world, asset.clone(), Vec3::ZERO); place_asset_operator(world, asset.clone(), Vec3::ZERO);
} }
if matches!(asset.kind, EditorAssetKind::Level) && ui.button("Open Scene").clicked() {
open_level_asset(world, asset);
}
if matches!(asset.kind, EditorAssetKind::Model) if matches!(asset.kind, EditorAssetKind::Model)
&& has_embedded_assets(world, asset) && has_embedded_assets(world, asset)
&& ui.button("Toggle Contents").clicked() && ui.button("Toggle Contents").clicked()
@ -2076,13 +1875,6 @@ fn asset_action_buttons(
}); });
} }
fn open_level_asset(world: &mut World, asset: &EditorAsset) {
let Some(path) = asset.path.as_deref() else {
return;
};
world.resource_mut::<SceneIo>().request = Some(SceneIoRequest::OpenPath(PathBuf::from(path)));
}
fn import_settings_editor( fn import_settings_editor(
world: &mut World, world: &mut World,
ui: &mut egui::Ui, ui: &mut egui::Ui,
@ -2197,8 +1989,8 @@ fn apply_import_settings(
update_import_settings(&mut registry, path, settings); update_import_settings(&mut registry, path, settings);
if matches!(asset.kind, EditorAssetKind::Model) { if matches!(asset.kind, EditorAssetKind::Model) {
if let Some(record) = find_asset_mut_by_path(&mut registry, path) { if let Some(record) = find_asset_mut_by_path(&mut registry, path) {
if let Err(error) = refresh_model_artifacts(record) { if let Err(error) = refresh_static_mesh_artifact(record) {
status = format!("Model artifact refresh failed for {}: {error}", asset.label); status = format!("Static mesh refresh failed for {}: {error}", asset.label);
warn!("{status}"); warn!("{status}");
} }
} }
@ -2913,7 +2705,6 @@ fn asset_matches_kind_filter(asset: &EditorAsset, filter: AssetKindFilter) -> bo
AssetKindFilter::Model => matches!(asset.kind, EditorAssetKind::Model), AssetKindFilter::Model => matches!(asset.kind, EditorAssetKind::Model),
AssetKindFilter::Texture => matches!(asset.kind, EditorAssetKind::Texture), AssetKindFilter::Texture => matches!(asset.kind, EditorAssetKind::Texture),
AssetKindFilter::Material => matches!(asset.kind, EditorAssetKind::Material), AssetKindFilter::Material => matches!(asset.kind, EditorAssetKind::Material),
AssetKindFilter::Audio => matches!(asset.kind, EditorAssetKind::AudioClip),
AssetKindFilter::Level => matches!(asset.kind, EditorAssetKind::Level), AssetKindFilter::Level => matches!(asset.kind, EditorAssetKind::Level),
AssetKindFilter::Prefab => matches!(asset.kind, EditorAssetKind::Prefab), AssetKindFilter::Prefab => matches!(asset.kind, EditorAssetKind::Prefab),
AssetKindFilter::Builtin => asset.path.is_none(), AssetKindFilter::Builtin => asset.path.is_none(),
@ -2926,7 +2717,6 @@ fn kind_filter_label(filter: AssetKindFilter) -> &'static str {
AssetKindFilter::Model => "Models", AssetKindFilter::Model => "Models",
AssetKindFilter::Texture => "Textures", AssetKindFilter::Texture => "Textures",
AssetKindFilter::Material => "Materials", AssetKindFilter::Material => "Materials",
AssetKindFilter::Audio => "Audio",
AssetKindFilter::Level => "Levels", AssetKindFilter::Level => "Levels",
AssetKindFilter::Prefab => "Prefabs", AssetKindFilter::Prefab => "Prefabs",
AssetKindFilter::Builtin => "Built-ins", AssetKindFilter::Builtin => "Built-ins",
@ -2949,7 +2739,6 @@ fn kind_label(kind: &EditorAssetKind) -> &'static str {
EditorAssetKind::Model => "Model", EditorAssetKind::Model => "Model",
EditorAssetKind::Texture => "Texture", EditorAssetKind::Texture => "Texture",
EditorAssetKind::Material => "Material", EditorAssetKind::Material => "Material",
EditorAssetKind::AudioClip => "Audio Clip",
EditorAssetKind::Level => "Level", EditorAssetKind::Level => "Level",
EditorAssetKind::Prefab => "Prefab", EditorAssetKind::Prefab => "Prefab",
EditorAssetKind::PostProcessVolume => "Post Process Volume", EditorAssetKind::PostProcessVolume => "Post Process Volume",
@ -2963,22 +2752,6 @@ fn file_size(path: &str) -> Option<u64> {
fs::metadata(path).ok().map(|metadata| metadata.len()) fs::metadata(path).ok().map(|metadata| metadata.len())
} }
fn audio_format_label(path: &str) -> &'static str {
match Path::new(path)
.extension()
.and_then(|extension| extension.to_str())
.map(str::to_ascii_lowercase)
.as_deref()
{
Some("ogg" | "oga") => "Ogg audio",
Some("spx") => "Speex",
Some("wav") => "WAV",
Some("mp3") => "MP3",
Some("flac") => "FLAC",
_ => "Unknown",
}
}
fn modified_time(path: &str) -> Option<SystemTime> { fn modified_time(path: &str) -> Option<SystemTime> {
fs::metadata(path) fs::metadata(path)
.ok() .ok()
@ -3040,17 +2813,13 @@ fn asset_context_menu(
EditorAssetKind::Primitive(_) EditorAssetKind::Primitive(_)
| EditorAssetKind::Light(_) | EditorAssetKind::Light(_)
| EditorAssetKind::Model | EditorAssetKind::Model
| EditorAssetKind::AudioClip
| EditorAssetKind::Prefab | EditorAssetKind::Prefab
| EditorAssetKind::Level
) && ui.button("Place at Origin").clicked() ) && ui.button("Place at Origin").clicked()
{ {
place_asset_operator(world, asset.clone(), Vec3::ZERO); place_asset_operator(world, asset.clone(), Vec3::ZERO);
ui.close(); ui.close();
} }
if matches!(asset.kind, EditorAssetKind::Level) && ui.button("Open Scene").clicked() {
open_level_asset(world, asset);
ui.close();
}
if matches!(asset.kind, EditorAssetKind::Model) && has_embedded_assets(world, asset) { if matches!(asset.kind, EditorAssetKind::Model) && has_embedded_assets(world, asset) {
let expanded = asset.path.as_deref().is_some_and(|path| { let expanded = asset.path.as_deref().is_some_and(|path| {
world world
@ -3119,12 +2888,7 @@ fn subasset_context_menu(
} }
match embedded.kind { match embedded.kind {
AssetSubAssetKind::Mesh => { AssetSubAssetKind::Mesh => {
if embedded.requires_scene_instance { if ui.button("Place at Origin").clicked() {
ui.label(
egui::RichText::new("Skinned mesh: place parent model or animation clip")
.color(TEXT_DIM),
);
} else if ui.button("Place at Origin").clicked() {
place_subasset_operator(world, embedded.selection.clone(), Vec3::ZERO); place_subasset_operator(world, embedded.selection.clone(), Vec3::ZERO);
ui.close(); ui.close();
} }
@ -3142,35 +2906,8 @@ fn subasset_context_menu(
AssetSubAssetKind::Material => { AssetSubAssetKind::Material => {
ui.label(egui::RichText::new("Embedded source material").color(TEXT_DIM)); ui.label(egui::RichText::new("Embedded source material").color(TEXT_DIM));
} }
AssetSubAssetKind::Skeleton => {
ui.label(egui::RichText::new("Inspect-only rig metadata").color(TEXT_DIM));
}
AssetSubAssetKind::AnimationClip => {
let animated_actor = selected_entities
.as_slice()
.iter()
.copied()
.find(|entity| world.get::<shared::ModelRef>(*entity).is_some());
let action = if animated_actor.is_some() {
"Assign To Selected Actor"
} else {
"Create Animated Actor"
};
if ui.button(action).clicked() {
if let Some(entity) = animated_actor {
assign_animation_clip_operator(world, embedded.selection.clone(), entity);
} else {
place_subasset_operator(world, embedded.selection.clone(), Vec3::ZERO);
}
ui.close();
}
}
} }
if matches!( if ui.button("Regenerate Thumbnail").clicked() {
embedded.kind,
AssetSubAssetKind::Mesh | AssetSubAssetKind::Material | AssetSubAssetKind::Texture
) && ui.button("Regenerate Thumbnail").clicked()
{
regenerate_subasset_thumbnail(world, embedded); regenerate_subasset_thumbnail(world, embedded);
ui.close(); ui.close();
} }
@ -3232,7 +2969,6 @@ fn regenerate_subasset_thumbnail(world: &mut World, embedded: &EmbeddedAsset) {
parent_path.clone(), parent_path.clone(),
mesh_label, mesh_label,
embedded.material_label.clone(), embedded.material_label.clone(),
embedded.requires_scene_instance,
&mut studio, &mut studio,
); );
}); });
@ -3255,7 +2991,6 @@ fn regenerate_subasset_thumbnail(world: &mut World, embedded: &EmbeddedAsset) {
cache.request_texture(key.clone(), texture_path, &asset_server); cache.request_texture(key.clone(), texture_path, &asset_server);
} }
} }
AssetSubAssetKind::Skeleton | AssetSubAssetKind::AnimationClip => {}
} }
}); });
world.resource_mut::<SceneIo>().status = world.resource_mut::<SceneIo>().status =
@ -3269,7 +3004,7 @@ fn reimport_asset(world: &mut World, asset: &EditorAsset) {
let mut status = format!("Reimported {}", asset.label); let mut status = format!("Reimported {}", asset.label);
if let Some(mut registry) = world.get_resource_mut::<crate::asset_db::AssetRegistry>() { if let Some(mut registry) = world.get_resource_mut::<crate::asset_db::AssetRegistry>() {
if let Some(record) = find_asset_mut_by_path(&mut registry, path) { if let Some(record) = find_asset_mut_by_path(&mut registry, path) {
if let Err(error) = refresh_model_artifacts(record) { if let Err(error) = refresh_static_mesh_artifact(record) {
status = format!("Reimport failed for {}: {error}", asset.label); status = format!("Reimport failed for {}: {error}", asset.label);
warn!("{status}"); warn!("{status}");
} }
@ -3303,9 +3038,6 @@ fn build_delete_request(world: &World, asset: &EditorAsset) -> Option<AssetDelet
if let Some(manifest) = record.import_settings.static_mesh_manifest_path { if let Some(manifest) = record.import_settings.static_mesh_manifest_path {
files.push(manifest); files.push(manifest);
} }
if let Some(manifest) = record.import_settings.animation_manifest_path {
files.push(manifest);
}
if !record.dependencies.is_empty() { if !record.dependencies.is_empty() {
warnings.push(format!( warnings.push(format!(
"{} imported dependencies will be kept because they may be shared.", "{} imported dependencies will be kept because they may be shared.",
@ -3458,35 +3190,3 @@ fn dedup_strings(values: &mut Vec<String>) {
let mut seen = HashSet::new(); let mut seen = HashSet::new();
values.retain(|value| seen.insert(value.clone())); values.retain(|value| seen.insert(value.clone()));
} }
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn audio_filter_matches_only_audio_clips() {
let audio = EditorAsset {
label: "Impact".into(),
path: Some("assets/audio/impact.ogg".into()),
folder_path: "assets/audio".into(),
kind: EditorAssetKind::AudioClip,
};
let texture = EditorAsset {
kind: EditorAssetKind::Texture,
..audio.clone()
};
assert!(asset_matches_kind_filter(&audio, AssetKindFilter::Audio));
assert!(!asset_matches_kind_filter(&texture, AssetKindFilter::Audio));
assert_eq!(kind_filter_label(AssetKindFilter::Audio), "Audio");
}
#[test]
fn audio_details_label_supported_formats() {
assert_eq!(audio_format_label("assets/audio/music.oga"), "Ogg audio");
assert_eq!(audio_format_label("assets/audio/voice.spx"), "Speex");
assert_eq!(audio_format_label("assets/audio/source.WAV"), "WAV");
assert_eq!(audio_format_label("assets/audio/source.mp3"), "MP3");
assert_eq!(audio_format_label("assets/audio/source.flac"), "FLAC");
}
}

View File

@ -28,7 +28,6 @@ pub(crate) enum AssetKindFilter {
Model, Model,
Texture, Texture,
Material, Material,
Audio,
Level, Level,
Prefab, Prefab,
Builtin, Builtin,

View File

@ -1,283 +0,0 @@
//! Audio source/listener authoring cards and transient audition controls.
use bevy::prelude::*;
use bevy_egui::egui;
use egui_phosphor_icons::icons;
use settings::{AUDIO_GAIN_DB_MAX, AUDIO_GAIN_DB_MIN};
use shared::{
AudioListenerDesc, AudioRolloff, AudioSourceDesc, COMPONENT_AUDIO_LISTENER_DESC,
COMPONENT_AUDIO_SOURCE_DESC,
};
use crate::assets::{audio_clip_ref_for_asset, EditorAsset, EditorAssetKind, EditorAssets};
use crate::history::{set_audio_listener_with_history, set_audio_source_with_history};
use crate::play::audio_preview::{audition_audio_source, stop_audio_preview, AudioPreviewState};
use crate::ui::inspector::{
apply_component_card_response, component_card, component_card_context, property_row,
ComponentCardOptions,
};
use crate::ui::theme::{SELECTION, TEXT_DIM};
pub fn audio_source_inspector_ui(world: &mut World, ui: &mut egui::Ui, entity: Entity) {
let Some(mut source) = world.get::<AudioSourceDesc>(entity).cloned() else {
return;
};
let original = source.clone();
let audio_assets: Vec<EditorAsset> = world
.resource::<EditorAssets>()
.assets
.iter()
.filter(|asset| matches!(asset.kind, EditorAssetKind::AudioClip))
.cloned()
.collect();
let dragged_audio = world
.resource::<EditorAssets>()
.dragging_selection()
.and_then(|selection| {
world
.resource::<EditorAssets>()
.asset_for_selection(selection)
})
.filter(|asset| matches!(asset.kind, EditorAssetKind::AudioClip))
.cloned();
let buses = world
.resource::<settings::ProjectSettings>()
.audio
.buses
.clone();
let previewing = world.resource::<AudioPreviewState>().source == Some(entity);
let diagnostics = world
.resource::<game::audio::AudioRuntimeDiagnostics>()
.clone();
let mut pending_clip: Option<EditorAsset> = None;
let mut clear_clip = false;
let mut audition = false;
let mut stop = false;
let card = component_card_context(
world,
entity,
ComponentCardOptions::removable(
COMPONENT_AUDIO_SOURCE_DESC,
"Audio Source",
icons::SPEAKER_HIGH,
),
);
let card_response = component_card(ui, &card, |ui| {
let clip_response = property_row(ui, "Clip", |ui| {
ui.horizontal_wrapped(|ui| {
let selected_text = source
.clip
.as_ref()
.map(|clip| clip.label.as_str())
.filter(|label| !label.trim().is_empty())
.unwrap_or("None");
egui::ComboBox::from_id_salt(("audio_clip", entity))
.selected_text(selected_text)
.width(150.0)
.show_ui(ui, |ui| {
for asset in &audio_assets {
let selected = source.clip.as_ref().is_some_and(|clip| {
clip.source_path.as_deref() == asset.path.as_deref()
});
if ui.selectable_label(selected, &asset.label).clicked() {
pending_clip = Some(asset.clone());
}
}
});
if ui
.add_enabled(source.clip.is_some(), egui::Button::new(icons::X.as_str()))
.on_hover_text("Clear clip")
.clicked()
{
clear_clip = true;
}
})
.response
});
if dragged_audio.is_some() && clip_response.hovered() {
ui.painter().rect_stroke(
clip_response.rect.expand(3.0),
3.0,
egui::Stroke::new(1.5, SELECTION),
egui::StrokeKind::Outside,
);
if ui.input(|input| input.pointer.any_released()) {
pending_clip = dragged_audio.clone();
}
}
property_row(ui, "Playback", |ui| {
ui.horizontal_wrapped(|ui| {
if ui
.add_enabled(
source.clip.is_some() && !previewing,
egui::Button::new(format!("{} Audition", icons::PLAY.as_str())),
)
.clicked()
{
audition = true;
}
if ui
.add_enabled(
previewing,
egui::Button::new(format!("{} Stop", icons::STOP.as_str())),
)
.clicked()
{
stop = true;
}
});
});
property_row(ui, "Gain", |ui| {
ui.add(
egui::Slider::new(&mut source.gain_db, AUDIO_GAIN_DB_MIN..=AUDIO_GAIN_DB_MAX)
.suffix(" dB"),
);
});
property_row(ui, "Pitch", |ui| {
ui.add(
egui::DragValue::new(&mut source.pitch)
.range(0.25..=4.0)
.speed(0.01),
);
});
property_row(ui, "Mode", |ui| {
ui.horizontal_wrapped(|ui| {
ui.checkbox(&mut source.autoplay, "Autoplay");
ui.checkbox(&mut source.looping, "Loop");
});
});
property_row(ui, "Spatial blend", |ui| {
ui.add(egui::Slider::new(&mut source.spatial_blend, 0.0..=1.0));
});
property_row(ui, "Min distance", |ui| {
ui.add(
egui::DragValue::new(&mut source.attenuation.min_distance)
.range(0.01..=10_000.0)
.speed(0.1)
.suffix(" m"),
);
});
property_row(ui, "Max distance", |ui| {
ui.add(
egui::DragValue::new(&mut source.attenuation.max_distance)
.range(0.02..=100_000.0)
.speed(0.25)
.suffix(" m"),
);
});
property_row(ui, "Rolloff", |ui| {
ui.horizontal_wrapped(|ui| {
egui::ComboBox::from_id_salt(("audio_rolloff", entity))
.selected_text(format!("{:?}", source.attenuation.rolloff))
.show_ui(ui, |ui| {
for (rolloff, label) in [
(AudioRolloff::Linear, "Linear"),
(AudioRolloff::Inverse, "Inverse"),
(AudioRolloff::Exponential, "Exponential"),
] {
ui.selectable_value(&mut source.attenuation.rolloff, rolloff, label);
}
});
ui.add(
egui::DragValue::new(&mut source.attenuation.rolloff_factor)
.range(0.01..=8.0)
.speed(0.05),
);
});
});
property_row(ui, "Bus", |ui| {
egui::ComboBox::from_id_salt(("audio_bus", entity))
.selected_text(
buses
.iter()
.find(|bus| bus.id == source.bus)
.map(|bus| bus.label.as_str())
.unwrap_or(source.bus.as_str()),
)
.show_ui(ui, |ui| {
for bus in &buses {
ui.selectable_value(&mut source.bus, bus.id.clone(), &bus.label);
}
});
});
ui.small(
egui::RichText::new(&diagnostics.message)
.color(TEXT_DIM)
.italics(),
);
});
if clear_clip {
source.clip = None;
}
if let Some(asset) = pending_clip {
match audio_clip_ref_for_asset(world, &asset) {
Ok(clip) => {
source.clip = Some(clip);
world.resource_mut::<EditorAssets>().clear_drag();
}
Err(error) => {
world.resource_mut::<crate::scene_io::SceneIo>().status =
format!("Audio clip assignment failed: {error}");
}
}
}
source.attenuation.max_distance = source
.attenuation
.max_distance
.max(source.attenuation.min_distance + 0.01);
if source != original {
set_audio_source_with_history(world, entity, source);
}
if stop {
stop_audio_preview(world);
} else if audition {
if let Err(error) = audition_audio_source(world, entity) {
world.resource_mut::<crate::scene_io::SceneIo>().status =
format!("Audio audition failed: {error}");
}
}
apply_component_card_response(world, entity, card_response);
}
pub fn audio_listener_inspector_ui(world: &mut World, ui: &mut egui::Ui, entity: Entity) {
let Some(mut listener) = world.get::<AudioListenerDesc>(entity).copied() else {
return;
};
let original = listener;
let card = component_card_context(
world,
entity,
ComponentCardOptions::removable(
COMPONENT_AUDIO_LISTENER_DESC,
"Audio Listener",
icons::HEADPHONES,
),
);
let card_response = component_card(ui, &card, |ui| {
property_row(ui, "Enabled", |ui| {
ui.checkbox(&mut listener.enabled, "Receive spatial audio");
});
property_row(ui, "Priority", |ui| {
ui.add(egui::DragValue::new(&mut listener.priority).speed(1));
});
property_row(ui, "Ear gap", |ui| {
ui.add(
egui::DragValue::new(&mut listener.ear_gap)
.range(0.01..=2.0)
.speed(0.01)
.suffix(" m"),
);
});
ui.small(
egui::RichText::new("Highest priority enabled listener wins; actor ID breaks ties.")
.color(TEXT_DIM),
);
});
if listener != original {
set_audio_listener_with_history(world, entity, listener);
}
apply_component_card_response(world, entity, card_response);
}

View File

@ -1,619 +0,0 @@
//! Non-blocking build/package jobs backed by the headless package command.
use std::collections::VecDeque;
use std::io::{BufRead, BufReader};
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use std::time::Duration;
use bevy::prelude::*;
use bevy_egui::egui;
use egui_phosphor_icons::icons;
use crate::project_io::ProjectWorkspace;
use crate::scene_io::SceneIo;
const MAX_LOG_LINES: usize = 1_500;
#[derive(Debug, Clone, PartialEq, Eq)]
enum BuildResult {
Succeeded(PathBuf),
Failed(String),
Cancelled,
}
#[derive(Debug)]
struct BuildJob {
log: Arc<Mutex<VecDeque<String>>>,
result: Arc<Mutex<Option<BuildResult>>>,
cancel: Arc<AtomicBool>,
run_after: bool,
worker: Option<std::thread::JoinHandle<()>>,
}
impl Drop for BuildJob {
fn drop(&mut self) {
self.cancel.store(true, Ordering::Release);
if let Some(worker) = self.worker.take() {
let _ = worker.join();
}
}
}
#[derive(Resource, Debug)]
pub struct BuildPanel {
profiles: Vec<String>,
selected_profile: String,
job: Option<BuildJob>,
last_result: Option<BuildResult>,
last_output: Option<PathBuf>,
last_log: Vec<String>,
}
impl Default for BuildPanel {
fn default() -> Self {
Self {
profiles: Vec::new(),
selected_profile: "development".into(),
job: None,
last_result: None,
last_output: None,
last_log: Vec::new(),
}
}
}
impl BuildPanel {
pub fn is_running(&self) -> bool {
self.job.is_some()
}
pub fn has_output(&self) -> bool {
self.last_output.is_some()
}
}
impl Drop for BuildPanel {
fn drop(&mut self) {
if let Some(job) = &self.job {
job.cancel.store(true, Ordering::Release);
}
}
}
pub fn request_build(world: &mut World, run_after: bool) {
refresh_profiles(world);
if world.resource::<BuildPanel>().is_running() {
world
.resource_mut::<SceneIo>()
.set_status("A package build is already running");
return;
}
if world.resource::<SceneIo>().has_unsaved_tabs() {
world
.resource_mut::<SceneIo>()
.set_status("Save all modified scene tabs before building a package");
return;
}
let engine_root = engine_workspace_root();
let root = PathBuf::from(&world.resource::<ProjectWorkspace>().root);
let profile = world.resource::<BuildPanel>().selected_profile.clone();
let mut panel = world.resource_mut::<BuildPanel>();
panel.last_result = None;
panel.last_log.clear();
panel.job = Some(spawn_build_job(
engine_root,
root,
profile.clone(),
run_after,
));
world
.resource_mut::<SceneIo>()
.set_status(format!("Building {profile} package..."));
}
pub fn cancel_build(world: &mut World) {
if let Some(job) = world.resource::<BuildPanel>().job.as_ref() {
job.cancel.store(true, Ordering::Release);
world
.resource_mut::<SceneIo>()
.set_status("Cancelling package build...");
}
}
pub fn reveal_last_output(world: &mut World) {
let output = world.resource::<BuildPanel>().last_output.clone();
let status = match output {
Some(path) if path.is_dir() => match Command::new("xdg-open").arg(&path).spawn() {
Ok(child) => {
reap_child(child);
format!("Revealed {}", path.display())
}
Err(error) => format!("Could not reveal {}: {error}", path.display()),
},
_ => "No completed package output is available".into(),
};
world.resource_mut::<SceneIo>().set_status(status);
}
pub fn tick_build_job(world: &mut World) {
let completed = world.resource::<BuildPanel>().job.as_ref().and_then(|job| {
job.result
.lock()
.ok()?
.clone()
.map(|result| (result, job.run_after))
});
let Some((result, run_after)) = completed else {
return;
};
let status = match &result {
BuildResult::Succeeded(path) => {
world.resource_mut::<BuildPanel>().last_output = Some(path.clone());
if run_after {
launch_package(path)
.map(|_| format!("Package built and launched from {}", path.display()))
.unwrap_or_else(|error| error)
} else {
format!("Package ready at {}", path.display())
}
}
BuildResult::Failed(error) => format!("Package build failed: {error}"),
BuildResult::Cancelled => "Package build cancelled".into(),
};
let completed_log = world
.resource::<BuildPanel>()
.job
.as_ref()
.and_then(|job| {
job.log
.lock()
.ok()
.map(|log| log.iter().cloned().collect::<Vec<_>>())
})
.unwrap_or_default();
let mut panel = world.resource_mut::<BuildPanel>();
panel.last_result = Some(result);
panel.last_log = completed_log;
panel.job = None;
world.resource_mut::<SceneIo>().set_status(status);
}
pub fn build_ui(world: &mut World, ui: &mut egui::Ui) {
if world.resource::<BuildPanel>().profiles.is_empty() {
refresh_profiles(world);
}
let running = world.resource::<BuildPanel>().is_running();
let profiles = world.resource::<BuildPanel>().profiles.clone();
let mut selected = world.resource::<BuildPanel>().selected_profile.clone();
ui.horizontal(|ui| {
ui.label("Profile");
egui::ComboBox::from_id_salt("build_profile")
.selected_text(&selected)
.show_ui(ui, |ui| {
for profile in &profiles {
ui.selectable_value(&mut selected, profile.clone(), profile);
}
});
if ui
.add_enabled(
!running,
egui::Button::new(format!("{} Build", icons::HAMMER.as_str())),
)
.clicked()
{
world.resource_mut::<BuildPanel>().selected_profile = selected.clone();
request_build(world, false);
}
if ui
.add_enabled(
!running,
egui::Button::new(format!("{} Build and Run", icons::PLAY.as_str())),
)
.clicked()
{
world.resource_mut::<BuildPanel>().selected_profile = selected.clone();
request_build(world, true);
}
if ui
.add_enabled(
running,
egui::Button::new(format!("{} Cancel", icons::X.as_str())),
)
.clicked()
{
cancel_build(world);
}
let can_reveal = world.resource::<BuildPanel>().last_output.is_some();
if ui
.add_enabled(
can_reveal,
egui::Button::new(format!("{} Reveal Output", icons::FOLDER_OPEN.as_str())),
)
.clicked()
{
reveal_last_output(world);
}
});
world.resource_mut::<BuildPanel>().selected_profile = selected;
ui.separator();
if running {
ui.horizontal(|ui| {
ui.spinner();
ui.label("Compiler and packager are running in the background");
});
} else if let Some(result) = world.resource::<BuildPanel>().last_result.clone() {
let (label, color) = match result {
BuildResult::Succeeded(path) => (
format!("Ready: {}", path.display()),
egui::Color32::from_rgb(125, 210, 145),
),
BuildResult::Failed(error) => (
format!("Failed: {error}"),
egui::Color32::from_rgb(255, 137, 129),
),
BuildResult::Cancelled => ("Cancelled".into(), egui::Color32::from_rgb(255, 190, 110)),
};
ui.colored_label(color, label);
} else {
ui.label("No package build has run in this session");
}
let lines = {
let panel = world.resource::<BuildPanel>();
panel
.job
.as_ref()
.and_then(|job| {
job.log
.lock()
.ok()
.map(|log| log.iter().cloned().collect::<Vec<_>>())
})
.unwrap_or_else(|| panel.last_log.clone())
};
egui::ScrollArea::vertical()
.id_salt("build_output_log")
.stick_to_bottom(true)
.show(ui, |ui| {
for line in lines {
ui.monospace(line);
}
});
}
fn refresh_profiles(world: &mut World) {
let root = PathBuf::from(&world.resource::<ProjectWorkspace>().root);
let mut profiles = fs_profiles(&root.join("assets/build_profiles"));
if profiles.is_empty() {
profiles.push("development".into());
}
let mut panel = world.resource_mut::<BuildPanel>();
if !profiles.contains(&panel.selected_profile) {
panel.selected_profile = profiles[0].clone();
}
panel.profiles = profiles;
}
fn fs_profiles(root: &Path) -> Vec<String> {
let mut profiles = std::fs::read_dir(root)
.into_iter()
.flatten()
.flatten()
.filter_map(|entry| {
let path = entry.path();
(path.extension().and_then(|extension| extension.to_str()) == Some("ron"))
.then(|| path.file_stem()?.to_str().map(str::to_string))?
})
.collect::<Vec<_>>();
profiles.sort();
profiles
}
fn spawn_build_job(
engine_root: PathBuf,
project_root: PathBuf,
profile: String,
run_after: bool,
) -> BuildJob {
let log = Arc::new(Mutex::new(VecDeque::new()));
let result = Arc::new(Mutex::new(None));
let cancel = Arc::new(AtomicBool::new(false));
let thread_log = Arc::clone(&log);
let thread_result = Arc::clone(&result);
let thread_cancel = Arc::clone(&cancel);
let worker = std::thread::spawn(move || {
let mut command = Command::new("cargo");
command
.current_dir(&engine_root)
.args([
"package-project",
"--project",
&project_root.to_string_lossy(),
"--profile",
&profile,
])
.stdout(Stdio::piped())
.stderr(Stdio::piped());
#[cfg(unix)]
{
use std::os::unix::process::CommandExt;
command.process_group(0);
}
let mut child = match command.spawn() {
Ok(child) => child,
Err(error) => {
set_result(
&thread_result,
BuildResult::Failed(format!("could not start Cargo: {error}")),
);
return;
}
};
let output_path = Arc::new(Mutex::new(None));
let stdout_thread = child.stdout.take().map(|stdout| {
stream_output(
stdout,
Arc::clone(&thread_log),
Some(Arc::clone(&output_path)),
)
});
let stderr_thread = child
.stderr
.take()
.map(|stderr| stream_output(stderr, Arc::clone(&thread_log), None));
let mut poll_error = None;
let status = loop {
if thread_cancel.load(Ordering::Acquire) {
terminate_process_tree(&mut child);
let _ = child.wait();
break None;
}
match child.try_wait() {
Ok(Some(status)) => break Some(status),
Ok(None) => std::thread::sleep(Duration::from_millis(100)),
Err(error) => {
terminate_process_tree(&mut child);
let _ = child.wait();
poll_error = Some(format!("could not poll build process: {error}"));
break None;
}
}
};
if let Some(thread) = stdout_thread {
let _ = thread.join();
}
if let Some(thread) = stderr_thread {
let _ = thread.join();
}
let final_result = match (poll_error, status) {
(Some(error), _) => BuildResult::Failed(error),
(None, None) => BuildResult::Cancelled,
(None, Some(status)) if !status.success() => {
BuildResult::Failed(format!("packager exited with {status}"))
}
(None, Some(_)) => output_path
.lock()
.ok()
.and_then(|path| path.clone())
.map(BuildResult::Succeeded)
.unwrap_or_else(|| {
BuildResult::Failed("packager did not report an output directory".into())
}),
};
set_result(&thread_result, final_result);
});
BuildJob {
log,
result,
cancel,
run_after,
worker: Some(worker),
}
}
fn engine_workspace_root() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR"))
.parent()
.and_then(Path::parent)
.expect("editor crate must live under <workspace>/crates/editor")
.to_path_buf()
}
fn terminate_process_tree(child: &mut std::process::Child) {
#[cfg(unix)]
{
let process_group = format!("-{}", child.id());
let _ = Command::new("kill")
.args(["-TERM", "--", &process_group])
.stdout(Stdio::null())
.stderr(Stdio::null())
.status();
std::thread::sleep(Duration::from_millis(150));
let _ = Command::new("kill")
.args(["-KILL", "--", &process_group])
.stdout(Stdio::null())
.stderr(Stdio::null())
.status();
}
#[cfg(windows)]
{
let _ = Command::new("taskkill")
.args(["/PID", &child.id().to_string(), "/T", "/F"])
.stdout(Stdio::null())
.stderr(Stdio::null())
.status();
}
let _ = child.kill();
}
fn stream_output(
stream: impl std::io::Read + Send + 'static,
log: Arc<Mutex<VecDeque<String>>>,
output_path: Option<Arc<Mutex<Option<PathBuf>>>>,
) -> std::thread::JoinHandle<()> {
std::thread::spawn(move || {
for line in BufReader::new(stream).lines().map_while(Result::ok) {
if let Some(path) = line.strip_prefix("package-project: ready at ") {
if let Some(output_path) = &output_path {
if let Ok(mut output_path) = output_path.lock() {
*output_path = Some(PathBuf::from(path));
}
}
}
if let Ok(mut log) = log.lock() {
log.push_back(line);
while log.len() > MAX_LOG_LINES {
log.pop_front();
}
}
}
})
}
fn set_result(result: &Arc<Mutex<Option<BuildResult>>>, value: BuildResult) {
if let Ok(mut result) = result.lock() {
*result = Some(value);
}
}
fn launch_package(output: &Path) -> Result<(), String> {
let metadata_path = output.join("build-metadata.json");
let metadata = std::fs::read_to_string(&metadata_path)
.map_err(|error| format!("Could not read {}: {error}", metadata_path.display()))?;
let metadata: serde_json::Value = serde_json::from_str(&metadata)
.map_err(|error| format!("Invalid {}: {error}", metadata_path.display()))?;
let target = metadata
.get("target")
.and_then(serde_json::Value::as_str)
.ok_or_else(|| format!("Package target is missing from {}", metadata_path.display()))?;
validate_launch_target(target, std::env::consts::ARCH, std::env::consts::OS)?;
let native_name = if cfg!(windows) {
"blacksite.exe"
} else {
"blacksite"
};
let foreign_name = if cfg!(windows) {
"blacksite"
} else {
"blacksite.exe"
};
let executable = output.join(native_name);
if !executable.is_file() {
let foreign = output.join(foreign_name);
if foreign.is_file() {
return Err(format!(
"Package {} targets a different operating system and cannot run on this host",
foreign.display()
));
}
return Err(format!(
"Package executable is missing at {}",
executable.display()
));
}
let child = Command::new(&executable)
.current_dir(output)
.spawn()
.map_err(|error| format!("Could not launch {}: {error}", executable.display()))?;
reap_child(child);
Ok(())
}
fn validate_launch_target(target: &str, host_arch: &str, host_os: &str) -> Result<(), String> {
let segments = target.split('-').collect::<Vec<_>>();
let target_os = if segments.contains(&"windows") {
"windows"
} else if segments.contains(&"linux") && !segments.contains(&"android") {
"linux"
} else {
return Err(format!(
"Package target `{target}` is not a runnable desktop target"
));
};
let target_arch = segments.first().copied().unwrap_or_default();
let target_arch = normalize_architecture(target_arch);
let host_arch = normalize_architecture(host_arch);
if target_os != host_os || target_arch != host_arch {
return Err(format!(
"Package targets {target_arch}-{target_os}, but this host is {host_arch}-{host_os}"
));
}
Ok(())
}
fn normalize_architecture(architecture: &str) -> &str {
match architecture {
"i386" | "i486" | "i586" | "i686" | "x86" => "x86",
architecture if architecture.starts_with("armv") => "arm",
architecture => architecture,
}
}
fn reap_child(mut child: std::process::Child) {
std::thread::spawn(move || {
let _ = child.wait();
});
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn profile_scan_is_sorted_and_uses_file_stems() {
let root =
std::env::temp_dir().join(format!("blacksite-build-profiles-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&root);
std::fs::create_dir_all(&root).unwrap();
std::fs::write(root.join("release.ron"), "()").unwrap();
std::fs::write(root.join("development.ron"), "()").unwrap();
std::fs::write(root.join("ignore.txt"), "").unwrap();
assert_eq!(fs_profiles(&root), vec!["development", "release"]);
std::fs::remove_dir_all(root).unwrap();
}
#[test]
fn engine_workspace_contains_the_game_package() {
let root = engine_workspace_root();
assert!(root.join("Cargo.toml").is_file());
assert!(root.join("crates/game/Cargo.toml").is_file());
}
#[cfg(unix)]
#[test]
fn cancellation_terminates_the_compiler_process_group() {
use std::os::unix::process::CommandExt;
use std::time::Instant;
let mut command = Command::new("sh");
command
.args(["-c", "(trap '' TERM; sleep 30) & wait"])
.stdout(Stdio::null())
.stderr(Stdio::null())
.process_group(0);
let mut child = command.spawn().unwrap();
let started = Instant::now();
terminate_process_tree(&mut child);
let status = child.wait().unwrap();
assert!(!status.success());
assert!(started.elapsed() < Duration::from_secs(3));
}
#[test]
fn package_launch_rejects_same_os_cross_architecture_targets() {
assert!(validate_launch_target("x86_64-unknown-linux-gnu", "x86_64", "linux").is_ok());
assert!(
validate_launch_target("aarch64-unknown-linux-gnu", "x86_64", "linux")
.unwrap_err()
.contains("aarch64-linux")
);
assert!(
validate_launch_target("x86_64-pc-windows-msvc", "x86_64", "linux")
.unwrap_err()
.contains("windows")
);
}
}

View File

@ -7,8 +7,6 @@ use egui_phosphor_icons::icons;
pub enum EditorComponentCategory { pub enum EditorComponentCategory {
Authoring, Authoring,
Rendering, Rendering,
Animation,
Audio,
Physics, Physics,
Gameplay, Gameplay,
Volumes, Volumes,
@ -138,54 +136,6 @@ impl Default for EditorComponentRegistry {
conflicts_with: &[], conflicts_with: &[],
hydration_effect: "Hydrates into runtime Bevy light components.", hydration_effect: "Hydrates into runtime Bevy light components.",
}, },
EditorComponentDescriptor {
type_name: "shared::animation::AnimationControllerDesc",
display_name: "Animation Controller",
category: EditorComponentCategory::Animation,
addable: true,
removable: true,
reorderable: true,
hidden: false,
icon: icons::FILM_SLATE.as_str(),
description: "Authors named animation states for an imported model rig.",
search_terms: &["animation", "controller", "state", "clip", "skeleton", "rig"],
recommended: &[],
conflicts_with: &[],
hydration_effect:
"Hydrates into a Bevy animation graph and player bound to the imported model.",
},
EditorComponentDescriptor {
type_name: "shared::components::AudioSourceDesc",
display_name: "Audio Source",
category: EditorComponentCategory::Audio,
addable: true,
removable: true,
reorderable: true,
hidden: false,
icon: icons::SPEAKER_HIGH.as_str(),
description: "Plays a routed 2D or spatial audio clip from this actor.",
search_terms: &["audio", "sound", "clip", "speaker", "spatial"],
recommended: &[],
conflicts_with: &["shared::components::AudioListenerDesc"],
hydration_effect:
"Hydrates into runtime Bevy audio voices with bus and attenuation control.",
},
EditorComponentDescriptor {
type_name: "shared::components::AudioListenerDesc",
display_name: "Audio Listener",
category: EditorComponentCategory::Audio,
addable: true,
removable: true,
reorderable: true,
hidden: false,
icon: icons::HEADPHONES.as_str(),
description: "Receives spatial audio at this actor transform.",
search_terms: &["audio", "sound", "listener", "ears", "spatial"],
recommended: &[],
conflicts_with: &["shared::components::AudioSourceDesc"],
hydration_effect:
"The highest-priority enabled listener becomes the runtime listener.",
},
EditorComponentDescriptor { EditorComponentDescriptor {
type_name: "shared::components::Primitive", type_name: "shared::components::Primitive",
display_name: "Primitive", display_name: "Primitive",

View File

@ -8,19 +8,16 @@ use shared::{
}; };
use crate::assets::EditorAssets; use crate::assets::EditorAssets;
use crate::diagnostics_bundle::export_diagnostic_bundle;
use crate::history::set_brush_with_history; use crate::history::set_brush_with_history;
use crate::history::EditorHistory; use crate::history::EditorHistory;
use crate::project_io::ProjectWorkspace; use crate::project_io::ProjectWorkspace;
use crate::scene_io::{SceneIo, SceneIoEventSeverity}; use crate::scene_io::SceneIo;
use crate::selection::SelectedEntity; use crate::selection::SelectedEntity;
use crate::ui::UiState; use crate::ui::UiState;
#[derive(Resource, Default)] #[derive(Resource, Default)]
pub struct DiagnosticsPanel { pub struct DiagnosticsPanel {
pub open: bool, pub open: bool,
pub bundle_status: Option<String>,
pub project_validation: Option<scene::ProjectValidationReport>,
} }
#[derive(Resource, Default)] #[derive(Resource, Default)]
@ -29,18 +26,10 @@ pub struct BrushDiagnosticsPanel {
} }
pub fn diagnostics_ui(world: &mut World, ui: &mut egui::Ui) { pub fn diagnostics_ui(world: &mut World, ui: &mut egui::Ui) {
let (scene_label, dirty, status, scene_events) = { let scene_io = world.resource::<SceneIo>();
let scene_io = world.resource::<SceneIo>(); let dirty = if scene_io.dirty { " (dirty)" } else { "" };
( ui.label(format!("Scene: {}{dirty}", scene_io.active_path_label()));
scene_io.active_path_label(), ui.label(format!("Status: {}", scene_io.status));
scene_io.dirty,
scene_io.status.clone(),
scene_io.events.iter().rev().cloned().collect::<Vec<_>>(),
)
};
let dirty = if dirty { " (dirty)" } else { "" };
ui.label(format!("Scene: {scene_label}{dirty}"));
ui.label(format!("Status: {status}"));
ui.label(format!( ui.label(format!(
"History: {}", "History: {}",
world.resource::<EditorHistory>().status world.resource::<EditorHistory>().status
@ -69,125 +58,6 @@ pub fn diagnostics_ui(world: &mut World, ui: &mut egui::Ui) {
let count = level_query.iter(world).count(); let count = level_query.iter(world).count();
ui.label(format!("Level objects: {count}")); ui.label(format!("Level objects: {count}"));
ui.separator();
ui.horizontal(|ui| {
ui.strong("Project validation");
if ui.button("Validate Project").clicked() {
let root = world.resource::<ProjectWorkspace>().root.clone();
let report = scene::validate_project(std::path::Path::new(&root));
let summary = format!(
"Project validation: {} dependencies, {} findings, {} blocking errors",
report.dependencies.len(),
report.findings.len(),
report.blocking_error_count()
);
world.resource_mut::<SceneIo>().set_status(summary);
world.resource_mut::<DiagnosticsPanel>().project_validation = Some(report);
}
});
if let Some(report) = world
.resource::<DiagnosticsPanel>()
.project_validation
.as_ref()
.cloned()
{
let color = if report.is_release_ready() {
egui::Color32::from_rgb(125, 210, 145)
} else {
egui::Color32::from_rgb(255, 137, 129)
};
ui.colored_label(
color,
format!(
"{} dependencies, {} blocking errors",
report.dependencies.len(),
report.blocking_error_count()
),
);
for finding in report.findings.iter().take(12) {
let actor = finding
.owner_actor_id
.as_deref()
.map(|actor| format!(" / {actor}"))
.unwrap_or_default();
ui.horizontal_wrapped(|ui| {
ui.monospace(format!("{}{}", finding.source_path, actor));
ui.label(&finding.message);
if let Some(actor_id) = finding.owner_actor_id.as_deref() {
if ui.small_button("Select").clicked() {
let mut query = world.query::<(Entity, &shared::ActorId)>();
if let Some(entity) = query
.iter(world)
.find_map(|(entity, id)| (id.0 == actor_id).then_some(entity))
{
world.resource_mut::<SelectedEntity>().0 = Some(entity);
world
.resource_mut::<UiState>()
.selected_entities
.select_replace(entity);
}
}
}
});
}
if report.findings.len() > 12 {
ui.small(format!(
"{} additional findings",
report.findings.len() - 12
));
}
}
ui.separator();
ui.horizontal(|ui| {
ui.strong("Support bundle");
if ui.button("Export Diagnostic Bundle").clicked() {
let status = match export_diagnostic_bundle(world) {
Ok(path) => format!("Exported {}", path.display()),
Err(error) => format!("Export failed: {error}"),
};
world.resource_mut::<DiagnosticsPanel>().bundle_status = Some(status);
}
});
ui.small("Allowlisted metadata only; scene and asset contents, environment values, credentials, and modal state are excluded.");
if let Some(status) = world
.resource::<DiagnosticsPanel>()
.bundle_status
.as_deref()
{
ui.monospace(status);
}
ui.separator();
ui.horizontal(|ui| {
ui.strong("Scene I/O log");
if ui
.add_enabled(!scene_events.is_empty(), egui::Button::new("Clear"))
.clicked()
{
world.resource_mut::<SceneIo>().clear_events();
}
});
if scene_events.is_empty() {
ui.small("No scene I/O operations recorded this session.");
} else {
egui::ScrollArea::vertical()
.id_salt("scene_io_diagnostics_log")
.max_height(180.0)
.show(ui, |ui| {
for event in scene_events {
let color = match event.severity {
SceneIoEventSeverity::Info => ui.visuals().weak_text_color(),
SceneIoEventSeverity::Error => egui::Color32::from_rgb(244, 110, 105),
};
ui.horizontal_wrapped(|ui| {
ui.colored_label(color, format!("#{:02}", event.id));
ui.colored_label(color, event.message);
});
}
});
}
ui.separator(); ui.separator();
ui.small("Native Bevy scenes are saved to assets/levels/*.scn.ron"); ui.small("Native Bevy scenes are saved to assets/levels/*.scn.ron");
ui.small("BRP is enabled on the editor for external tooling."); ui.small("BRP is enabled on the editor for external tooling.");

View File

@ -36,7 +36,7 @@ impl PanelNodes {
EditorTab::Viewport | EditorTab::GameView => self.main_view(), EditorTab::Viewport | EditorTab::GameView => self.main_view(),
EditorTab::Hierarchy => self.hierarchy(), EditorTab::Hierarchy => self.hierarchy(),
EditorTab::Inspector => self.inspector(), EditorTab::Inspector => self.inspector(),
EditorTab::AssetBrowser | EditorTab::Build | EditorTab::Toolbar => self.bottom(), EditorTab::AssetBrowser | EditorTab::Toolbar => self.bottom(),
} }
} }
@ -51,21 +51,18 @@ impl PanelNodes {
EditorTab::Viewport | EditorTab::GameView => nodes.main_view = path.node.0, EditorTab::Viewport | EditorTab::GameView => nodes.main_view = path.node.0,
EditorTab::Hierarchy => nodes.hierarchy = path.node.0, EditorTab::Hierarchy => nodes.hierarchy = path.node.0,
EditorTab::Inspector => nodes.inspector = path.node.0, EditorTab::Inspector => nodes.inspector = path.node.0,
EditorTab::AssetBrowser | EditorTab::Build | EditorTab::Toolbar => { EditorTab::AssetBrowser | EditorTab::Toolbar => nodes.bottom = path.node.0,
nodes.bottom = path.node.0
}
} }
} }
nodes nodes
} }
} }
pub const PANEL_TABS: [EditorTab; 5] = [ pub const PANEL_TABS: [EditorTab; 4] = [
EditorTab::Viewport, EditorTab::Viewport,
EditorTab::Hierarchy, EditorTab::Hierarchy,
EditorTab::Inspector, EditorTab::Inspector,
EditorTab::AssetBrowser, EditorTab::AssetBrowser,
EditorTab::Build,
]; ];
pub fn tab_label(tab: &EditorTab) -> &'static str { pub fn tab_label(tab: &EditorTab) -> &'static str {
@ -74,7 +71,6 @@ pub fn tab_label(tab: &EditorTab) -> &'static str {
EditorTab::Hierarchy => "Hierarchy", EditorTab::Hierarchy => "Hierarchy",
EditorTab::Inspector => "Inspector", EditorTab::Inspector => "Inspector",
EditorTab::AssetBrowser => "Asset Browser", EditorTab::AssetBrowser => "Asset Browser",
EditorTab::Build => "Build",
EditorTab::Toolbar => "Toolbar (legacy)", EditorTab::Toolbar => "Toolbar (legacy)",
} }
} }

View File

@ -11,7 +11,7 @@ use sim::Player;
use crate::assets::{snapshot_for_asset, EditorAsset, EditorAssetKind}; use crate::assets::{snapshot_for_asset, EditorAsset, EditorAssetKind};
use crate::camera::EditorCamera; use crate::camera::EditorCamera;
use crate::gizmos::{EditorGizmoMode, EditorGizmoSpace}; use crate::gizmos::{EditorGizmoMode, EditorGizmoSpace};
use crate::history::{set_material_group_with_history, spawn_with_history, EditorEntitySnapshot}; use crate::history::{set_material_with_history, spawn_with_history, EditorEntitySnapshot};
use crate::play::default_player_spawn; use crate::play::default_player_spawn;
use crate::scene_io::SceneIo; use crate::scene_io::SceneIo;
use crate::viewport::{recall_camera_bookmark, save_camera_bookmark, CameraBookmarks}; use crate::viewport::{recall_camera_bookmark, save_camera_bookmark, CameraBookmarks};
@ -97,9 +97,6 @@ pub fn light_snapshot(name: &str, translation: Vec3) -> EditorEntitySnapshot {
collider: None, collider: None,
physics: None, physics: None,
light: Some(LightDesc::default()), light: Some(LightDesc::default()),
animation_controller: None,
audio_source: None,
audio_listener: None,
player_spawn: false, player_spawn: false,
model: None, model: None,
prefab: None, prefab: None,
@ -148,9 +145,6 @@ pub fn ensure_player_spawn_for_edit(world: &mut World) -> Entity {
collider: None, collider: None,
physics: None, physics: None,
light: None, light: None,
animation_controller: None,
audio_source: None,
audio_listener: None,
player_spawn: true, player_spawn: true,
model: None, model: None,
prefab: None, prefab: None,
@ -239,9 +233,6 @@ pub fn create_scene_sun_override_from_project_settings(world: &mut World) -> Ent
collider: None, collider: None,
physics: None, physics: None,
light: Some(light), light: Some(light),
animation_controller: None,
audio_source: None,
audio_listener: None,
player_spawn: false, player_spawn: false,
model: None, model: None,
prefab: None, prefab: None,
@ -265,7 +256,6 @@ pub fn asset_label(asset: &EditorAsset) -> String {
EditorAssetKind::Model => "Model", EditorAssetKind::Model => "Model",
EditorAssetKind::Texture => "Texture", EditorAssetKind::Texture => "Texture",
EditorAssetKind::Material => "Material", EditorAssetKind::Material => "Material",
EditorAssetKind::AudioClip => "Audio Clip",
EditorAssetKind::Level => "Level", EditorAssetKind::Level => "Level",
EditorAssetKind::Prefab => "Prefab", EditorAssetKind::Prefab => "Prefab",
EditorAssetKind::PostProcessVolume => "Post Process Volume", EditorAssetKind::PostProcessVolume => "Post Process Volume",
@ -280,32 +270,26 @@ pub fn apply_texture_to_selection(
world: &mut World, world: &mut World,
asset: &EditorAsset, asset: &EditorAsset,
selected: &SelectedEntities, selected: &SelectedEntities,
) -> Result<(), String> { ) {
let path = asset let Some(path) = asset.path.clone() else {
.path return;
.clone() };
.ok_or_else(|| format!("Texture has no source path: {}", asset.label))?; for entity in selected_level_entities(world, selected) {
let changes = selected_level_entities(world, selected) let mut material = world
.into_iter() .get::<MaterialDesc>(entity)
.map(|entity| { .cloned()
let mut material = world .unwrap_or_default();
.get::<MaterialDesc>(entity) material.base_color_texture = Some(path.clone());
.cloned() set_material_with_history(world, entity, material);
.unwrap_or_default(); }
material.base_color_texture = Some(path.clone());
(entity, material)
})
.collect::<Vec<_>>();
set_material_group_with_history(world, changes);
Ok(())
} }
pub fn apply_material_asset_to_selection( pub fn apply_material_asset_to_selection(
world: &mut World, world: &mut World,
asset: &EditorAsset, asset: &EditorAsset,
selected: &SelectedEntities, selected: &SelectedEntities,
) -> Result<(), String> { ) {
crate::assets::materials::apply_material_asset_to_selection(world, asset, selected) crate::assets::materials::apply_material_asset_to_selection(world, asset, selected);
} }
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]

View File

@ -5,7 +5,7 @@ use bevy_egui::egui;
use bevy_inspector_egui::bevy_inspector::hierarchy::SelectedEntities; use bevy_inspector_egui::bevy_inspector::hierarchy::SelectedEntities;
use egui::containers::scroll_area::ScrollSource; use egui::containers::scroll_area::ScrollSource;
use egui_phosphor_icons::icons; use egui_phosphor_icons::icons;
use shared::{EditorVisibility, HydratedPrefabMember}; use shared::EditorVisibility;
use crate::history::{ use crate::history::{
create_empty_child_with_history, delete_entities_with_history, duplicate_entities_with_history, create_empty_child_with_history, delete_entities_with_history, duplicate_entities_with_history,
@ -19,17 +19,11 @@ use crate::ui::helpers::{
}; };
use crate::ui::hierarchy_ops::{ use crate::ui::hierarchy_ops::{
authored_children, editor_visibility, entity_hierarchy_path, entity_matches_filter, authored_children, editor_visibility, entity_hierarchy_path, entity_matches_filter,
hierarchy_drop_violation, hierarchy_label_for_entity, is_ancestor, is_entity_locked, hierarchy_label_for_entity, is_entity_locked, level_object_roots, sort_siblings,
level_object_roots, mutable_authored_children, sort_siblings, HierarchyDropViolation, would_create_cycle, HierarchyLabel,
HierarchyLabel,
}; };
use crate::ui::hierarchy_state::{HierarchyPanelState, HierarchySort}; use crate::ui::hierarchy_state::{HierarchyPanelState, HierarchySort};
use crate::ui::selection_ops::is_linked_read_only; use crate::ui::theme::{panel_heading, TEXT, TEXT_DIM, TEXT_SELECTED};
use crate::ui::theme::{
overlay_chip_frame, panel_heading, ACCENT, ERROR, PANEL_BG_DARK, SELECTION, SELECTION_BG_MUTED,
SELECTION_SECONDARY, TEXT, TEXT_DIM, TEXT_MUTED, TEXT_SELECTED,
};
use crate::ui::widgets::phosphor_icon;
#[derive(Clone)] #[derive(Clone)]
struct HierarchyDragPayload { struct HierarchyDragPayload {
@ -43,12 +37,6 @@ enum DropTarget {
Before(Entity), Before(Entity),
} }
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum DropBlock {
Hierarchy(HierarchyDropViolation),
LockedTarget(Entity),
}
pub fn hierarchy_panel_ui( pub fn hierarchy_panel_ui(
world: &mut World, world: &mut World,
ui: &mut egui::Ui, ui: &mut egui::Ui,
@ -133,7 +121,7 @@ pub fn hierarchy_panel_ui(
if ui.input(|input| input.key_pressed(egui::Key::F2)) { if ui.input(|input| input.key_pressed(egui::Key::F2)) {
if let Some(entity) = selected_entities.as_slice().first().copied() { if let Some(entity) = selected_entities.as_slice().first().copied() {
if is_level_object(world, entity) && !is_entity_locked(&locked, entity) { if is_level_object(world, entity) {
*renaming_entity = Some(entity); *renaming_entity = Some(entity);
*rename_buffer = entity_name(world, entity); *rename_buffer = entity_name(world, entity);
} }
@ -142,10 +130,12 @@ pub fn hierarchy_panel_ui(
let filter_lower = filter.trim().to_lowercase(); let filter_lower = filter.trim().to_lowercase();
let mut drop_target = None; let mut drop_target = None;
let mut drop_block = None; let mut invalid_drop = false;
let active_drag = world.resource::<HierarchyPanelState>().active_drag.clone(); let dragging = world
let dragging = active_drag.is_some(); .resource::<HierarchyPanelState>()
.active_drag
.is_some();
egui::ScrollArea::vertical() egui::ScrollArea::vertical()
.id_salt("hierarchy_tree") .id_salt("hierarchy_tree")
@ -158,7 +148,7 @@ pub fn hierarchy_panel_ui(
ui.spacing_mut().item_spacing.y = 2.0; ui.spacing_mut().item_spacing.y = 2.0;
if dragging { if dragging {
draw_root_drop_zone(ui, active_drag.as_deref(), &mut drop_target); draw_root_drop_zone(ui, &mut drop_target);
} }
let mut roots = level_object_roots(world); let mut roots = level_object_roots(world);
@ -181,12 +171,12 @@ pub fn hierarchy_panel_ui(
&filter_lower, &filter_lower,
sort_mode, sort_mode,
show_generated, show_generated,
active_drag.as_deref(), dragging,
&expanded, &expanded,
&locked, &locked,
&prefs_paths, &prefs_paths,
&mut drop_target, &mut drop_target,
&mut drop_block, &mut invalid_drop,
); );
} }
@ -202,8 +192,6 @@ pub fn hierarchy_panel_ui(
} }
}); });
draw_hierarchy_drag_preview(world, ui, active_drag.as_deref(), drop_target, drop_block);
if ui.ctx().input(|input| input.pointer.any_released()) { if ui.ctx().input(|input| input.pointer.any_released()) {
if let Some(target) = drop_target { if let Some(target) = drop_target {
let drag_entities = world let drag_entities = world
@ -212,7 +200,7 @@ pub fn hierarchy_panel_ui(
.take(); .take();
if let Some(entities) = drag_entities { if let Some(entities) = drag_entities {
let payload = HierarchyDragPayload { entities }; let payload = HierarchyDragPayload { entities };
apply_drop(world, &payload, target, drop_block); apply_drop(world, &payload, target, invalid_drop);
} }
} else { } else {
world.resource_mut::<HierarchyPanelState>().active_drag = None; world.resource_mut::<HierarchyPanelState>().active_drag = None;
@ -232,45 +220,14 @@ fn sort_label(sort: HierarchySort) -> &'static str {
} }
} }
fn draw_root_drop_zone( fn draw_root_drop_zone(ui: &mut egui::Ui, drop_target: &mut Option<DropTarget>) {
ui: &mut egui::Ui,
drag_entities: Option<&[Entity]>,
drop_target: &mut Option<DropTarget>,
) {
let (rect, response) = let (rect, response) =
ui.allocate_exact_size(egui::vec2(ui.available_width(), 27.0), egui::Sense::hover()); ui.allocate_exact_size(egui::vec2(ui.available_width(), 2.0), egui::Sense::hover());
let hovered = drag_entities.is_some() if let Some(hover) = response.dnd_hover_payload::<HierarchyDragPayload>() {
&& (response.contains_pointer() ui.painter()
|| response .rect_filled(rect, 1.0, egui::Color32::from_rgb(80, 120, 200));
.dnd_hover_payload::<HierarchyDragPayload>()
.is_some());
ui.painter().rect(
rect,
2.0,
if hovered {
SELECTION_BG_MUTED
} else {
PANEL_BG_DARK
},
egui::Stroke::new(1.0, if hovered { SELECTION } else { TEXT_MUTED }),
egui::StrokeKind::Inside,
);
ui.painter().text(
rect.left_center() + egui::vec2(9.0, 0.0),
egui::Align2::LEFT_CENTER,
icons::TREE_STRUCTURE.as_str(),
egui::FontId::new(13.0, egui::FontFamily::Name("phosphor-regular".into())),
if hovered { SELECTION } else { TEXT_DIM },
);
ui.painter().text(
rect.left_center() + egui::vec2(29.0, 0.0),
egui::Align2::LEFT_CENTER,
"SCENE ROOT",
egui::FontId::new(11.0, egui::FontFamily::Proportional),
if hovered { TEXT_SELECTED } else { TEXT_DIM },
);
if hovered {
*drop_target = Some(DropTarget::Root); *drop_target = Some(DropTarget::Root);
let _ = hover;
} }
} }
@ -289,12 +246,12 @@ fn draw_authored_node(
filter_lower: &str, filter_lower: &str,
sort_mode: HierarchySort, sort_mode: HierarchySort,
show_generated: bool, show_generated: bool,
drag_entities: Option<&[Entity]>, drag_active: bool,
expanded: &std::collections::HashSet<Entity>, expanded: &std::collections::HashSet<Entity>,
locked: &std::collections::HashSet<Entity>, locked: &std::collections::HashSet<Entity>,
prefs_paths: &[String], prefs_paths: &[String],
drop_target: &mut Option<DropTarget>, drop_target: &mut Option<DropTarget>,
drop_block: &mut Option<DropBlock>, invalid_drop: &mut bool,
) { ) {
let path = entity_hierarchy_path(world, entity); let path = entity_hierarchy_path(world, entity);
let mut is_expanded = expanded.contains(&entity) || prefs_paths.iter().any(|p| p == &path); let mut is_expanded = expanded.contains(&entity) || prefs_paths.iter().any(|p| p == &path);
@ -308,18 +265,15 @@ fn draw_authored_node(
.any(|child| is_hierarchy_generated_child(world, child)) .any(|child| is_hierarchy_generated_child(world, child))
})); }));
if sort_mode == HierarchySort::Manual { draw_drop_before(
draw_drop_before( world,
world, ui,
ui, entity,
entity, depth,
depth, drag_active,
drag_entities, drop_target,
locked, invalid_drop,
drop_target, );
drop_block,
);
}
ui.horizontal(|ui| { ui.horizontal(|ui| {
ui.add_space(depth as f32 * 14.0); ui.add_space(depth as f32 * 14.0);
@ -364,37 +318,21 @@ fn draw_authored_node(
ui.add_space(18.0); ui.add_space(18.0);
} }
let linked_read_only = is_linked_read_only(world, entity);
let is_locked = is_entity_locked(locked, entity);
let visible = editor_visibility(world, entity).visible; let visible = editor_visibility(world, entity).visible;
let eye_icon = if visible { let eye_icon = if visible {
icons::EYE.regular() icons::EYE.regular()
} else { } else {
icons::EYE_SLASH.regular() icons::EYE_SLASH.regular()
}; };
let prefab_member = world.get::<HydratedPrefabMember>(entity).is_some(); if ui.small_button(eye_icon).clicked() {
if ui set_editor_visibility_with_history(
.add_enabled( world,
!is_locked && (!linked_read_only || prefab_member), entity,
egui::Button::new(eye_icon), EditorVisibility { visible: !visible },
) );
.clicked()
{
if prefab_member {
crate::assets::prefab_overrides::set_prefab_component_override(
world,
entity,
EditorVisibility { visible: !visible },
);
} else {
set_editor_visibility_with_history(
world,
entity,
EditorVisibility { visible: !visible },
);
}
} }
let is_locked = is_entity_locked(locked, entity);
let lock_icon = if is_locked { let lock_icon = if is_locked {
icons::LOCK.regular() icons::LOCK.regular()
} else { } else {
@ -413,33 +351,15 @@ fn draw_authored_node(
let response = ui.text_edit_singleline(rename_buffer); let response = ui.text_edit_singleline(rename_buffer);
let commit = let commit =
response.lost_focus() || ui.input(|input| input.key_pressed(egui::Key::Enter)); response.lost_focus() || ui.input(|input| input.key_pressed(egui::Key::Enter));
if commit && !is_locked && !linked_read_only { if commit {
rename_entity_with_history(world, entity, rename_buffer.clone()); rename_entity_with_history(world, entity, rename_buffer.clone());
*renaming_entity = None; *renaming_entity = None;
} }
} else { } else {
let selected = selected_entities.contains(entity); let selected = selected_entities.contains(entity);
let primary = selected_entities.as_slice().first().copied() == Some(entity);
let label = hierarchy_label_for_entity(world, entity, HierarchyNodeKind::Authored); let label = hierarchy_label_for_entity(world, entity, HierarchyNodeKind::Authored);
let color = if selected { TEXT_SELECTED } else { TEXT }; let color = if selected { TEXT_SELECTED } else { TEXT };
let response = ui.add_sized( let response = ui.selectable_label(selected, hierarchy_label_text(label, color));
egui::vec2(ui.available_width().max(72.0), 24.0),
egui::Button::selectable(selected, hierarchy_label_text(label, color))
.sense(egui::Sense::click_and_drag()),
);
if selected {
ui.painter().line_segment(
[response.rect.left_top(), response.rect.left_bottom()],
egui::Stroke::new(
if primary { 3.0 } else { 2.0 },
if primary {
SELECTION
} else {
SELECTION_SECONDARY
},
),
);
}
if response.clicked() && !is_locked { if response.clicked() && !is_locked {
let additive = ui.input(|input| { let additive = ui.input(|input| {
input.modifiers.shift || input.modifiers.command || input.modifiers.ctrl input.modifiers.shift || input.modifiers.command || input.modifiers.ctrl
@ -451,37 +371,38 @@ fn draw_authored_node(
} }
world.resource_mut::<SelectedEntity>().0 = Some(entity); world.resource_mut::<SelectedEntity>().0 = Some(entity);
} }
if response.double_clicked() if response.double_clicked() && is_level_object(world, entity) {
&& is_level_object(world, entity)
&& !is_locked
&& !linked_read_only
{
*renaming_entity = Some(entity); *renaming_entity = Some(entity);
*rename_buffer = entity_name(world, entity); *rename_buffer = entity_name(world, entity);
} }
if response.drag_started() && !is_locked && (!linked_read_only || prefab_member) { if response.drag_started() {
let candidates: Vec<Entity> = if selected_entities.contains(entity) { let drag_entities: Vec<Entity> = if selected_entities.contains(entity) {
selected_entities.iter().collect() selected_entities
.iter()
.filter(|e| is_level_object(world, *e))
.collect()
} else { } else {
vec![entity] vec![entity]
}; };
let drag_entities = draggable_selection(world, &candidates, locked);
response.dnd_set_drag_payload(HierarchyDragPayload { response.dnd_set_drag_payload(HierarchyDragPayload {
entities: drag_entities.clone(), entities: drag_entities.clone(),
}); });
world.resource_mut::<HierarchyPanelState>().active_drag = Some(drag_entities); world.resource_mut::<HierarchyPanelState>().active_drag = Some(drag_entities);
} }
let hover_payload = response.dnd_hover_payload::<HierarchyDragPayload>(); if let Some(hover) = response.dnd_hover_payload::<HierarchyDragPayload>() {
let hovered_entities = hover_payload let invalid = would_create_cycle(world, &hover.entities, Some(entity));
.as_deref() *invalid_drop = invalid;
.map(|payload| payload.entities.as_slice())
.or_else(|| drag_entities.filter(|_| response.contains_pointer()));
if let Some(entities) = hovered_entities {
*drop_block = drop_block_for_target(world, locked, entities, Some(entity), entity);
*drop_target = Some(DropTarget::On(entity)); *drop_target = Some(DropTarget::On(entity));
draw_attach_target_feedback(ui, response.rect, drop_block.is_some()); if invalid {
ui.painter().rect_stroke(
response.rect,
2.0,
egui::Stroke::new(2.0, egui::Color32::RED),
egui::StrokeKind::Outside,
);
}
} }
response.context_menu(|ui| { response.context_menu(|ui| {
@ -492,7 +413,6 @@ fn draw_authored_node(
selected_entities, selected_entities,
renaming_entity, renaming_entity,
rename_buffer, rename_buffer,
locked,
) )
}); });
} }
@ -517,12 +437,12 @@ fn draw_authored_node(
filter_lower, filter_lower,
sort_mode, sort_mode,
show_generated, show_generated,
drag_entities, drag_active,
expanded, expanded,
locked, locked,
prefs_paths, prefs_paths,
drop_target, drop_target,
drop_block, invalid_drop,
); );
} }
} }
@ -538,94 +458,43 @@ fn draw_authored_node(
} }
} }
#[expect(
clippy::too_many_arguments,
reason = "immediate-mode drop feedback threads the current drag, lock, and target state"
)]
fn draw_drop_before( fn draw_drop_before(
world: &World, world: &World,
ui: &mut egui::Ui, ui: &mut egui::Ui,
entity: Entity, entity: Entity,
depth: usize, depth: usize,
drag_entities: Option<&[Entity]>, drag_active: bool,
locked: &std::collections::HashSet<Entity>,
drop_target: &mut Option<DropTarget>, drop_target: &mut Option<DropTarget>,
drop_block: &mut Option<DropBlock>, invalid_drop: &mut bool,
) { ) {
let Some(drag_entities) = drag_entities else { if !drag_active {
return; return;
}; }
ui.horizontal(|ui| { ui.horizontal(|ui| {
ui.add_space(depth as f32 * 14.0 + 18.0); ui.add_space(depth as f32 * 14.0 + 18.0);
let (rect, response) = let (rect, response) =
ui.allocate_exact_size(egui::vec2(ui.available_width(), 7.0), egui::Sense::hover()); ui.allocate_exact_size(egui::vec2(ui.available_width(), 2.0), egui::Sense::hover());
let hovered = response.contains_pointer() if let Some(hover) = response.dnd_hover_payload::<HierarchyDragPayload>() {
|| response *invalid_drop = would_create_cycle(
.dnd_hover_payload::<HierarchyDragPayload>() world,
.is_some(); &hover.entities,
if hovered { world
let parent = world .get::<ChildOf>(entity)
.get::<ChildOf>(entity) .map(|child_of| child_of.parent()),
.map(|child_of| child_of.parent());
*drop_block = if world.get::<HydratedPrefabMember>(entity).is_some()
|| drag_entities
.iter()
.any(|dragged| world.get::<HydratedPrefabMember>(*dragged).is_some())
{
let root =
crate::assets::prefab_overrides::prefab_member_override_target(world, entity)
.map(|(root, _)| root)
.unwrap_or(entity);
Some(DropBlock::Hierarchy(
HierarchyDropViolation::PrefabBoundary { root },
))
} else {
drop_block_for_target(world, locked, drag_entities, parent, entity)
};
*drop_target = Some(DropTarget::Before(entity));
let color = if drop_block.is_some() {
ERROR
} else {
SELECTION
};
ui.painter().line_segment(
[rect.left_center(), rect.right_center()],
egui::Stroke::new(2.0, color),
); );
ui.painter().circle_filled(rect.left_center(), 3.5, color); *drop_target = Some(DropTarget::Before(entity));
let color = if *invalid_drop {
egui::Color32::RED
} else {
egui::Color32::from_rgb(80, 120, 200)
};
ui.painter().rect_filled(rect, 1.0, color);
let _ = hover;
} }
}); });
} }
fn draw_attach_target_feedback(ui: &egui::Ui, rect: egui::Rect, invalid: bool) {
let color = if invalid { ERROR } else { ACCENT };
ui.painter().rect_filled(
rect,
2.0,
if invalid {
egui::Color32::from_rgba_premultiplied(105, 25, 29, 68)
} else {
egui::Color32::from_rgba_premultiplied(92, 66, 18, 62)
},
);
ui.painter().rect_stroke(
rect,
2.0,
egui::Stroke::new(2.0, color),
egui::StrokeKind::Inside,
);
if rect.width() > 150.0 {
ui.painter().text(
rect.right_center() - egui::vec2(7.0, 0.0),
egui::Align2::RIGHT_CENTER,
if invalid { "BLOCKED" } else { "ATTACH" },
egui::FontId::new(9.0, egui::FontFamily::Proportional),
color,
);
}
}
fn draw_generated_node( fn draw_generated_node(
world: &World, world: &World,
ui: &mut egui::Ui, ui: &mut egui::Ui,
@ -689,22 +558,8 @@ fn draw_runtime_node(
ui.add_space(18.0); ui.add_space(18.0);
let label = hierarchy_label_for_entity(world, entity, HierarchyNodeKind::Runtime); let label = hierarchy_label_for_entity(world, entity, HierarchyNodeKind::Runtime);
let selected = selected_entities.contains(entity); let selected = selected_entities.contains(entity);
let primary = selected_entities.as_slice().first().copied() == Some(entity);
let color = if selected { TEXT_SELECTED } else { TEXT_DIM }; let color = if selected { TEXT_SELECTED } else { TEXT_DIM };
let response = ui.selectable_label(selected, hierarchy_label_text(label, color)); let response = ui.selectable_label(selected, hierarchy_label_text(label, color));
if selected {
ui.painter().line_segment(
[response.rect.left_top(), response.rect.left_bottom()],
egui::Stroke::new(
if primary { 3.0 } else { 2.0 },
if primary {
SELECTION
} else {
SELECTION_SECONDARY
},
),
);
}
if response.clicked() { if response.clicked() {
selected_entities.select_replace(entity); selected_entities.select_replace(entity);
} }
@ -754,62 +609,23 @@ fn authored_context_menu(
selected_entities: &mut SelectedEntities, selected_entities: &mut SelectedEntities,
renaming_entity: &mut Option<Entity>, renaming_entity: &mut Option<Entity>,
rename_buffer: &mut String, rename_buffer: &mut String,
locked: &std::collections::HashSet<Entity>,
) { ) {
let entity_locked = is_entity_locked(locked, entity); if ui.button("Rename").clicked() {
let linked_read_only = is_linked_read_only(world, entity);
let prefab_member = world.get::<HydratedPrefabMember>(entity).is_some();
let entity_mutable = !entity_locked && !linked_read_only;
let selection_mutable = selected_entities.iter().all(|selected| {
!is_entity_locked(locked, selected) && !is_linked_read_only(world, selected)
});
if entity_locked {
ui.small(egui::RichText::new("Actor locked").color(TEXT_DIM));
ui.separator();
} else if linked_read_only {
ui.small(egui::RichText::new("Linked source member").color(TEXT_DIM));
ui.separator();
}
if ui
.add_enabled(entity_mutable, egui::Button::new("Rename"))
.clicked()
{
*renaming_entity = Some(entity); *renaming_entity = Some(entity);
*rename_buffer = entity_name(world, entity); *rename_buffer = entity_name(world, entity);
ui.close(); ui.close();
} }
if ui if ui.button("Duplicate").clicked() {
.add_enabled(entity_mutable, egui::Button::new("Duplicate"))
.clicked()
{
duplicate_entities_with_history(world, &[entity]); duplicate_entities_with_history(world, &[entity]);
ui.close(); ui.close();
} }
if ui if ui.button("Delete").clicked() {
.add_enabled(entity_mutable, egui::Button::new("Delete"))
.clicked()
{
delete_entities_with_history(world, &[entity]); delete_entities_with_history(world, &[entity]);
ui.close(); ui.close();
} }
if prefab_member && !entity_locked {
if ui.button("Remove from prefab instance").clicked() {
crate::assets::prefab_overrides::remove_prefab_source_child(world, entity);
selected_entities.remove(entity);
ui.close();
return;
}
if ui.button("Unparent override").clicked() {
crate::assets::prefab_overrides::reparent_prefab_source_child(world, entity, None);
ui.close();
return;
}
}
if selected_entities.len() >= 2 if selected_entities.len() >= 2
&& selected_entities.contains(entity) && selected_entities.contains(entity)
&& ui && ui.button("Group").clicked()
.add_enabled(selection_mutable, egui::Button::new("Group"))
.clicked()
{ {
let entities: Vec<Entity> = selected_entities let entities: Vec<Entity> = selected_entities
.iter() .iter()
@ -818,17 +634,11 @@ fn authored_context_menu(
group_selection_with_history(world, &entities); group_selection_with_history(world, &entities);
ui.close(); ui.close();
} }
if ui if ui.button("Unparent").clicked() {
.add_enabled(entity_mutable, egui::Button::new("Unparent"))
.clicked()
{
reparent_with_history(world, entity, None); reparent_with_history(world, entity, None);
ui.close(); ui.close();
} }
let create_child = ui if ui.button("Create child Empty").clicked() {
.add_enabled(entity_mutable, egui::Button::new("Create child Empty"))
.on_disabled_hover_text("Locked and generated source actors cannot be modified");
if create_child.clicked() {
create_empty_child_with_history(world, entity); create_empty_child_with_history(world, entity);
ui.close(); ui.close();
} }
@ -840,167 +650,8 @@ fn authored_context_menu(
} }
} }
fn top_level_drag_entities(world: &World, candidates: &[Entity]) -> Vec<Entity> { fn apply_drop(world: &mut World, drag: &HierarchyDragPayload, target: DropTarget, invalid: bool) {
candidates if invalid || drag.entities.is_empty() {
.iter()
.copied()
.filter(|entity| {
!candidates
.iter()
.copied()
.any(|other| other != *entity && is_ancestor(world, other, *entity))
})
.collect()
}
fn draggable_selection(
world: &World,
candidates: &[Entity],
locked: &std::collections::HashSet<Entity>,
) -> Vec<Entity> {
let editable: Vec<_> = candidates
.iter()
.copied()
.filter(|entity| {
is_level_object(world, *entity)
&& !is_entity_locked(locked, *entity)
&& (!is_linked_read_only(world, *entity)
|| world.get::<HydratedPrefabMember>(*entity).is_some())
})
.collect();
top_level_drag_entities(world, &editable)
}
fn draw_hierarchy_drag_preview(
world: &World,
ui: &egui::Ui,
drag_entities: Option<&[Entity]>,
target: Option<DropTarget>,
block: Option<DropBlock>,
) {
let Some(entities) = drag_entities.filter(|entities| !entities.is_empty()) else {
return;
};
let Some(pointer) = ui.input(|input| input.pointer.hover_pos()) else {
return;
};
let title = if entities.len() == 1 {
entity_name(world, entities[0])
} else {
format!("{} ACTORS", entities.len())
};
let action = if let Some(block) = block {
match block {
DropBlock::Hierarchy(HierarchyDropViolation::Cycle) => "Cannot attach: hierarchy cycle",
DropBlock::Hierarchy(HierarchyDropViolation::PrefabBoundary { .. }) => {
"Cannot attach: prefab contents are read-only"
}
DropBlock::LockedTarget(_) => "Cannot attach: target is locked",
}
} else {
match target {
Some(DropTarget::Root) => "Move to scene root",
Some(DropTarget::On(parent)) => {
return draw_hierarchy_drag_preview_card(
ui,
pointer,
&title,
&format!("Attach to {}", entity_name(world, parent)),
false,
);
}
Some(DropTarget::Before(sibling)) => {
return draw_hierarchy_drag_preview_card(
ui,
pointer,
&title,
&format!("Insert before {}", entity_name(world, sibling)),
false,
);
}
None => "Drop on an actor to attach",
}
};
draw_hierarchy_drag_preview_card(ui, pointer, &title, action, block.is_some());
}
fn draw_hierarchy_drag_preview_card(
ui: &egui::Ui,
pointer: egui::Pos2,
title: &str,
action: &str,
invalid: bool,
) {
let content = ui.ctx().content_rect();
let pos = egui::pos2(
(pointer.x + 18.0).min(content.right() - 248.0),
(pointer.y + 18.0).min(content.bottom() - 58.0),
);
egui::Area::new(egui::Id::new("hierarchy_drag_preview"))
.fixed_pos(pos)
.default_size(egui::vec2(236.0, 56.0))
.order(egui::Order::Tooltip)
.interactable(false)
.show(ui.ctx(), |ui| {
ui.set_min_size(egui::vec2(236.0, 56.0));
ui.set_max_size(egui::vec2(236.0, 56.0));
overlay_chip_frame().show(ui, |ui| {
ui.set_width(218.0);
ui.horizontal(|ui| {
ui.label(
phosphor_icon(icons::TREE_STRUCTURE, 18.0).color(if invalid {
ERROR
} else {
ACCENT
}),
);
ui.vertical(|ui| {
ui.spacing_mut().item_spacing.y = 1.0;
ui.label(egui::RichText::new(title).strong().color(TEXT_SELECTED));
ui.label(egui::RichText::new(action).size(11.0).color(if invalid {
ERROR
} else {
TEXT_DIM
}));
});
});
});
});
}
fn apply_drop(
world: &mut World,
drag: &HierarchyDragPayload,
target: DropTarget,
block: Option<DropBlock>,
) {
if block.is_some() || drag.entities.is_empty() {
return;
}
let prefab_members = drag
.entities
.iter()
.all(|entity| world.get::<HydratedPrefabMember>(*entity).is_some());
if prefab_members {
match target {
DropTarget::Root => {
crate::assets::prefab_overrides::reparent_prefab_source_children(
world,
&drag.entities,
None,
);
}
DropTarget::On(parent) => {
crate::assets::prefab_overrides::reparent_prefab_source_children(
world,
&drag.entities,
Some(parent),
);
expand_parent_after_drop(world, parent);
}
DropTarget::Before(_) => {}
}
return; return;
} }
match target { match target {
@ -1009,18 +660,14 @@ fn apply_drop(
} }
DropTarget::On(parent) => { DropTarget::On(parent) => {
reorder_siblings_with_history(world, &drag.entities, Some(parent), i32::MAX); reorder_siblings_with_history(world, &drag.entities, Some(parent), i32::MAX);
expand_parent_after_drop(world, parent);
} }
DropTarget::Before(before) => { DropTarget::Before(before) => {
let parent = world let parent = world
.get::<ChildOf>(before) .get::<ChildOf>(before)
.map(|child_of| child_of.parent()); .map(|child_of| child_of.parent());
let siblings = match parent { let siblings = match parent {
Some(parent) => mutable_authored_children(world, parent), Some(parent) => authored_children(world, parent),
None => level_object_roots(world) None => level_object_roots(world),
.into_iter()
.filter(|entity| !is_linked_read_only(world, *entity))
.collect(),
}; };
let insert_index = siblings let insert_index = siblings
.iter() .iter()
@ -1031,53 +678,6 @@ fn apply_drop(
} }
} }
fn drop_block_for_target(
world: &World,
locked: &std::collections::HashSet<Entity>,
drag_entities: &[Entity],
new_parent: Option<Entity>,
target: Entity,
) -> Option<DropBlock> {
if is_entity_locked(locked, target)
|| new_parent.is_some_and(|parent| is_entity_locked(locked, parent))
{
return Some(DropBlock::LockedTarget(target));
}
if is_linked_read_only(world, target) {
if world.get::<HydratedPrefabMember>(target).is_some()
&& hierarchy_drop_violation(world, drag_entities, new_parent).is_none()
{
return None;
}
let root = crate::assets::prefab_overrides::prefab_member_override_target(world, target)
.map(|(root, _)| root)
.unwrap_or(target);
return Some(DropBlock::Hierarchy(
HierarchyDropViolation::PrefabBoundary { root },
));
}
hierarchy_drop_violation(world, drag_entities, new_parent).map(DropBlock::Hierarchy)
}
fn expand_parent_after_drop(world: &mut World, parent: Entity) {
let path = entity_hierarchy_path(world, parent);
{
let mut state = world.resource_mut::<HierarchyPanelState>();
if state.expanded.insert(parent) {
state.mark_dirty();
}
}
let mut prefs = world.resource_mut::<UserPreferences>();
if !prefs
.hierarchy_expanded_paths
.iter()
.any(|entry| entry == &path)
{
prefs.hierarchy_expanded_paths.push(path);
}
}
fn subtree_matches_filter( fn subtree_matches_filter(
world: &World, world: &World,
entity: Entity, entity: Entity,
@ -1144,76 +744,3 @@ pub(crate) fn is_runtime_hierarchy_entity(world: &World, entity: Entity) -> bool
|| entity_ref.contains::<ProjectSun>()) || entity_ref.contains::<ProjectSun>())
}) })
} }
#[cfg(test)]
mod tests {
use super::*;
use shared::LevelObject;
#[test]
fn drag_selection_keeps_only_top_level_actors() {
let mut world = World::new();
let parent = world.spawn(LevelObject).id();
let child = world.spawn((LevelObject, ChildOf(parent))).id();
let peer = world.spawn(LevelObject).id();
let normalized = top_level_drag_entities(&world, &[parent, child, peer]);
assert_eq!(normalized, vec![parent, peer]);
}
#[test]
fn drag_selection_excludes_locked_actors_before_subtree_normalization() {
let mut world = World::new();
let parent = world.spawn(LevelObject).id();
let child = world.spawn((LevelObject, ChildOf(parent))).id();
let peer = world.spawn(LevelObject).id();
let locked = std::collections::HashSet::from([parent]);
let normalized = draggable_selection(&world, &[parent, child, peer], &locked);
assert_eq!(normalized, vec![child, peer]);
}
#[test]
fn locked_drop_target_reports_specific_block() {
let mut world = World::new();
let actor = world.spawn(LevelObject).id();
let target = world.spawn(LevelObject).id();
let locked = std::collections::HashSet::from([target]);
assert_eq!(
drop_block_for_target(&world, &locked, &[actor], Some(target), target),
Some(DropBlock::LockedTarget(target))
);
}
#[test]
fn deep_filtered_tree_keeps_matching_descendant_discoverable() {
let mut world = World::new();
let root = world.spawn((LevelObject, Name::new("Root"))).id();
let mut parent = root;
for depth in 0..64 {
parent = world
.spawn((
LevelObject,
Name::new(if depth == 63 { "Needle" } else { "Branch" }),
ChildOf(parent),
))
.id();
}
assert!(subtree_matches_filter(
&world,
root,
"needle",
HierarchySort::Manual
));
assert!(!subtree_matches_filter(
&world,
root,
"missing",
HierarchySort::Manual
));
}
}

View File

@ -2,8 +2,8 @@
use bevy::prelude::*; use bevy::prelude::*;
use shared::{ use shared::{
ActorKind, AuthoringLightKind, EditorVisibility, HierarchySiblingIndex, HydratedPrefabMember, ActorKind, AuthoringLightKind, EditorVisibility, HierarchySiblingIndex, LevelObject, LightDesc,
LevelObject, LightDesc, ModelRef, PhysicsBody, PlayerSpawn, PrefabInstance, ModelRef, PhysicsBody, PlayerSpawn,
}; };
use super::helpers::{entity_name, is_level_object, HierarchyNodeKind}; use super::helpers::{entity_name, is_level_object, HierarchyNodeKind};
@ -44,66 +44,6 @@ pub fn would_create_cycle(world: &World, targets: &[Entity], new_parent: Option<
.any(|entity| is_ancestor(world, *entity, parent)) .any(|entity| is_ancestor(world, *entity, parent))
} }
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum HierarchyDropViolation {
Cycle,
PrefabBoundary { root: Entity },
}
/// Returns the authored prefab-instance root containing `entity`, including the root itself.
pub fn prefab_instance_boundary(world: &World, entity: Entity) -> Option<Entity> {
let mut current = Some(entity);
while let Some(candidate) = current {
if world.get::<PrefabInstance>(candidate).is_some() {
return Some(candidate);
}
current = world
.get::<ChildOf>(candidate)
.map(|child_of| child_of.parent())
.filter(|parent| is_level_object(world, *parent));
}
None
}
/// Authored local children may be attached to instances; hydrated source members remain read-only.
pub fn hierarchy_drop_violation(
world: &World,
targets: &[Entity],
new_parent: Option<Entity>,
) -> Option<HierarchyDropViolation> {
if would_create_cycle(world, targets, new_parent) {
return Some(HierarchyDropViolation::Cycle);
}
let prefab_targets: Vec<_> = targets
.iter()
.filter_map(|target| {
crate::assets::prefab_overrides::prefab_member_override_target(world, *target)
})
.collect();
if !prefab_targets.is_empty() {
let (root, path) = &prefab_targets[0];
let compatible_targets = prefab_targets.len() == targets.len()
&& prefab_targets.iter().all(|(candidate_root, candidate)| {
candidate_root == root && candidate.instance_chain == path.instance_chain
});
let compatible_parent = new_parent.is_none_or(|parent| {
crate::assets::prefab_overrides::prefab_member_override_target(world, parent)
.is_some_and(|(parent_root, parent_path)| {
parent_root == *root && parent_path.instance_chain == path.instance_chain
})
});
if compatible_targets && compatible_parent {
return None;
}
return Some(HierarchyDropViolation::PrefabBoundary { root: *root });
}
new_parent
.and_then(|parent| world.get::<HydratedPrefabMember>(parent))
.map(|owner| HierarchyDropViolation::PrefabBoundary {
root: owner.instance_root,
})
}
pub fn level_object_roots(world: &mut World) -> Vec<Entity> { pub fn level_object_roots(world: &mut World) -> Vec<Entity> {
let mut query = world.query_filtered::<(Entity, Option<&ChildOf>), With<LevelObject>>(); let mut query = world.query_filtered::<(Entity, Option<&ChildOf>), With<LevelObject>>();
query query
@ -127,30 +67,6 @@ pub fn authored_children(world: &World, parent: Entity) -> Vec<Entity> {
.unwrap_or_default() .unwrap_or_default()
} }
pub fn mutable_authored_children(world: &World, parent: Entity) -> Vec<Entity> {
authored_children(world, parent)
.into_iter()
.filter(|entity| {
world.get::<HydratedPrefabMember>(*entity).is_none()
&& world
.get::<crate::scene_io::ComposedSceneMember>(*entity)
.is_none()
})
.collect()
}
fn mutable_level_object_roots(world: &mut World) -> Vec<Entity> {
level_object_roots(world)
.into_iter()
.filter(|entity| {
world.get::<HydratedPrefabMember>(*entity).is_none()
&& world
.get::<crate::scene_io::ComposedSceneMember>(*entity)
.is_none()
})
.collect()
}
fn entity_tie_break(a: Entity, b: Entity) -> std::cmp::Ordering { fn entity_tie_break(a: Entity, b: Entity) -> std::cmp::Ordering {
a.to_bits().cmp(&b.to_bits()) a.to_bits().cmp(&b.to_bits())
} }
@ -159,9 +75,8 @@ pub fn sort_siblings(world: &World, entities: &mut [Entity], sort_mode: Hierarch
match sort_mode { match sort_mode {
HierarchySort::Manual => { HierarchySort::Manual => {
entities.sort_by(|a, b| { entities.sort_by(|a, b| {
linked_order_class(world, *a) sibling_index(world, *a)
.cmp(&linked_order_class(world, *b)) .cmp(&sibling_index(world, *b))
.then_with(|| sibling_index(world, *a).cmp(&sibling_index(world, *b)))
.then_with(|| entity_tie_break(*a, *b)) .then_with(|| entity_tie_break(*a, *b))
}); });
} }
@ -185,18 +100,6 @@ pub fn sort_siblings(world: &World, entities: &mut [Entity], sort_mode: Hierarch
} }
} }
fn linked_order_class(world: &World, entity: Entity) -> u8 {
if world.get::<HydratedPrefabMember>(entity).is_some()
|| world
.get::<crate::scene_io::ComposedSceneMember>(entity)
.is_some()
{
0
} else {
1
}
}
fn actor_kind_sort_key(world: &World, entity: Entity) -> u8 { fn actor_kind_sort_key(world: &World, entity: Entity) -> u8 {
world world
.get::<ActorKind>(entity) .get::<ActorKind>(entity)
@ -205,7 +108,6 @@ fn actor_kind_sort_key(world: &World, entity: Entity) -> u8 {
ActorKind::Empty => 0, ActorKind::Empty => 0,
ActorKind::Brush | ActorKind::StaticMesh | ActorKind::ImportedModel => 1, ActorKind::Brush | ActorKind::StaticMesh | ActorKind::ImportedModel => 1,
ActorKind::Light => 2, ActorKind::Light => 2,
ActorKind::AudioSource | ActorKind::AudioListener => 3,
ActorKind::PrefabAnchor => 3, ActorKind::PrefabAnchor => 3,
ActorKind::PlayerSpawn => 4, ActorKind::PlayerSpawn => 4,
ActorKind::WeaponSpawn => 5, ActorKind::WeaponSpawn => 5,
@ -258,7 +160,6 @@ pub fn renumber_siblings(world: &mut World, parent: Option<Entity>, ordered: &[E
} }
pub fn capture_sibling_state(world: &World, entity: Entity) -> SiblingChange { pub fn capture_sibling_state(world: &World, entity: Entity) -> SiblingChange {
let transform = world.get::<Transform>(entity).copied();
SiblingChange { SiblingChange {
entity, entity,
old_parent: world old_parent: world
@ -269,8 +170,6 @@ pub fn capture_sibling_state(world: &World, entity: Entity) -> SiblingChange {
.map(|child_of| child_of.parent()), .map(|child_of| child_of.parent()),
old_index: sibling_index(world, entity), old_index: sibling_index(world, entity),
new_index: sibling_index(world, entity), new_index: sibling_index(world, entity),
old_transform: transform,
new_transform: transform,
} }
} }
@ -282,9 +181,6 @@ pub fn apply_sibling_change(world: &mut World, change: &SiblingChange) {
entity_mut.remove::<ChildOf>(); entity_mut.remove::<ChildOf>();
} }
entity_mut.insert(HierarchySiblingIndex(change.old_index)); entity_mut.insert(HierarchySiblingIndex(change.old_index));
if let Some(transform) = change.old_transform {
entity_mut.insert(transform);
}
} }
} }
@ -296,9 +192,6 @@ pub fn apply_sibling_change_new(world: &mut World, change: &SiblingChange) {
entity_mut.remove::<ChildOf>(); entity_mut.remove::<ChildOf>();
} }
entity_mut.insert(HierarchySiblingIndex(change.new_index)); entity_mut.insert(HierarchySiblingIndex(change.new_index));
if let Some(transform) = change.new_transform {
entity_mut.insert(transform);
}
} }
} }
@ -342,8 +235,8 @@ fn renumber_inserted_siblings(
insert_index: i32, insert_index: i32,
) -> Vec<SiblingChange> { ) -> Vec<SiblingChange> {
let mut siblings = match new_parent { let mut siblings = match new_parent {
Some(parent) => mutable_authored_children(world, parent), Some(parent) => authored_children(world, parent),
None => mutable_level_object_roots(world), None => level_object_roots(world),
}; };
siblings.retain(|entity| !moving.contains(entity)); siblings.retain(|entity| !moving.contains(entity));
let insert = insert_index.clamp(0, siblings.len() as i32) as usize; let insert = insert_index.clamp(0, siblings.len() as i32) as usize;
@ -359,8 +252,8 @@ fn renumber_existing_siblings(
exclude: &[Entity], exclude: &[Entity],
) -> Vec<SiblingChange> { ) -> Vec<SiblingChange> {
let mut siblings = match parent { let mut siblings = match parent {
Some(parent) => mutable_authored_children(world, parent), Some(parent) => authored_children(world, parent),
None => mutable_level_object_roots(world), None => level_object_roots(world),
}; };
siblings.retain(|entity| !exclude.contains(entity)); siblings.retain(|entity| !exclude.contains(entity));
sort_siblings(world, &mut siblings, HierarchySort::Manual); sort_siblings(world, &mut siblings, HierarchySort::Manual);
@ -379,20 +272,6 @@ fn apply_sibling_order(
.map(|child_of| child_of.parent()); .map(|child_of| child_of.parent());
let old_index = sibling_index(world, *entity); let old_index = sibling_index(world, *entity);
let new_index = index as i32; let new_index = index as i32;
let old_transform = world.get::<Transform>(*entity).copied();
let new_transform = if old_parent != parent {
world
.get::<GlobalTransform>(*entity)
.map(|global| {
match parent.and_then(|parent| world.get::<GlobalTransform>(parent)) {
Some(parent_global) => global.reparented_to(parent_global),
None => global.compute_transform(),
}
})
.or(old_transform)
} else {
old_transform
};
if old_parent != parent || old_index != new_index { if old_parent != parent || old_index != new_index {
changes.push(SiblingChange { changes.push(SiblingChange {
entity: *entity, entity: *entity,
@ -400,8 +279,6 @@ fn apply_sibling_order(
new_parent: parent, new_parent: parent,
old_index, old_index,
new_index, new_index,
old_transform,
new_transform,
}); });
} }
if let Ok(mut entity_mut) = world.get_entity_mut(*entity) { if let Ok(mut entity_mut) = world.get_entity_mut(*entity) {
@ -411,9 +288,6 @@ fn apply_sibling_order(
entity_mut.remove::<ChildOf>(); entity_mut.remove::<ChildOf>();
} }
entity_mut.insert(HierarchySiblingIndex(new_index)); entity_mut.insert(HierarchySiblingIndex(new_index));
if let Some(transform) = new_transform {
entity_mut.insert(transform);
}
} }
} }
changes changes
@ -466,8 +340,6 @@ pub fn actor_kind_icon(kind: ActorKind) -> &'static str {
ActorKind::StaticMesh => icons::CUBE.as_str(), ActorKind::StaticMesh => icons::CUBE.as_str(),
ActorKind::ImportedModel => icons::CUBE_TRANSPARENT.as_str(), ActorKind::ImportedModel => icons::CUBE_TRANSPARENT.as_str(),
ActorKind::Light => icons::LIGHTBULB.as_str(), ActorKind::Light => icons::LIGHTBULB.as_str(),
ActorKind::AudioSource => icons::SPEAKER_HIGH.as_str(),
ActorKind::AudioListener => icons::HEADPHONES.as_str(),
ActorKind::PrefabAnchor => icons::PACKAGE.as_str(), ActorKind::PrefabAnchor => icons::PACKAGE.as_str(),
ActorKind::PlayerSpawn => icons::USER_CIRCLE.as_str(), ActorKind::PlayerSpawn => icons::USER_CIRCLE.as_str(),
ActorKind::WeaponSpawn => icons::CROSSHAIR.as_str(), ActorKind::WeaponSpawn => icons::CROSSHAIR.as_str(),
@ -562,7 +434,6 @@ pub fn is_entity_locked(locked: &std::collections::HashSet<Entity>, entity: Enti
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use shared::ActorId;
#[test] #[test]
fn sort_by_name_is_stable_for_duplicate_names() { fn sort_by_name_is_stable_for_duplicate_names() {
@ -577,99 +448,4 @@ mod tests {
sort_siblings(&world, &mut entities, HierarchySort::Name); sort_siblings(&world, &mut entities, HierarchySort::Name);
assert_eq!(entities, first, "duplicate-name sort must be deterministic"); assert_eq!(entities, first, "duplicate-name sort must be deterministic");
} }
#[test]
fn reparent_preserves_world_transform_and_round_trips_history() {
let mut world = World::new();
let parent = world
.spawn((
LevelObject,
Transform::from_xyz(10.0, 0.0, 0.0),
GlobalTransform::from_xyz(10.0, 0.0, 0.0),
HierarchySiblingIndex(0),
))
.id();
let child = world
.spawn((
LevelObject,
Transform::from_xyz(12.0, 1.0, -3.0),
GlobalTransform::from_xyz(12.0, 1.0, -3.0),
HierarchySiblingIndex(1),
))
.id();
let changes = reorder_entities_under_parent(&mut world, &[child], Some(parent), 0);
let child_change = changes
.iter()
.find(|change| change.entity == child)
.expect("reparent should record the moved actor");
assert_eq!(world.get::<ChildOf>(child).unwrap().parent(), parent);
assert_eq!(
world.get::<Transform>(child).unwrap().translation,
Vec3::new(2.0, 1.0, -3.0)
);
apply_sibling_change(&mut world, child_change);
assert!(world.get::<ChildOf>(child).is_none());
assert_eq!(
world.get::<Transform>(child).unwrap().translation,
Vec3::new(12.0, 1.0, -3.0)
);
apply_sibling_change_new(&mut world, child_change);
assert_eq!(world.get::<ChildOf>(child).unwrap().parent(), parent);
assert_eq!(
world.get::<Transform>(child).unwrap().translation,
Vec3::new(2.0, 1.0, -3.0)
);
}
#[test]
fn cycle_detection_rejects_descendant_parent() {
let mut world = World::new();
let parent = world.spawn(LevelObject).id();
let child = world.spawn((LevelObject, ChildOf(parent))).id();
assert!(would_create_cycle(&world, &[parent], Some(child)));
assert!(!would_create_cycle(&world, &[child], None));
}
#[test]
fn prefab_boundary_allows_local_and_same_layer_override_structure() {
let mut world = World::new();
let instance = world.spawn((LevelObject, PrefabInstance::default())).id();
let local_child = world.spawn((LevelObject, ChildOf(instance))).id();
let generated_child = world
.spawn((
LevelObject,
ActorId::new("generated-child"),
ChildOf(instance),
HydratedPrefabMember {
instance_root: instance,
},
))
.id();
let actor = world.spawn(LevelObject).id();
let nested_instance = world.spawn((LevelObject, PrefabInstance::default())).id();
assert_eq!(
hierarchy_drop_violation(&world, &[actor], Some(instance)),
None
);
assert_eq!(
hierarchy_drop_violation(&world, &[nested_instance], Some(local_child)),
None
);
assert_eq!(
hierarchy_drop_violation(&world, &[nested_instance], Some(generated_child)),
Some(HierarchyDropViolation::PrefabBoundary { root: instance })
);
assert_eq!(
hierarchy_drop_violation(&world, &[generated_child], None),
None
);
assert_eq!(hierarchy_drop_violation(&world, &[instance], None), None);
assert_eq!(hierarchy_drop_violation(&world, &[local_child], None), None);
}
} }

View File

@ -8,26 +8,22 @@ use bevy_egui::egui;
use egui_phosphor_icons::{icons, Icon}; use egui_phosphor_icons::{icons, Icon};
use shared::{ use shared::{
brush_math::{validate_brush, BrushDiagnosticSeverity}, brush_math::{validate_brush, BrushDiagnosticSeverity},
infer_actor_kind, inspector_component_active, ActorKind, AnimationControllerDesc, inspector_component_active, AuthoringLightKind, AuthoringRigidBody, BrushDesc, BrushKind,
AudioListenerDesc, AudioSourceDesc, AuthoringLightKind, AuthoringRigidBody, BrushDesc, ColliderDesc, ColliderShapeDesc, ColorDesc, ComponentInstanceId, EditorAssetRef,
BrushKind, ColliderDesc, ColliderShapeDesc, ColorDesc, ComponentInstanceId, EditorAssetRef,
InspectorOrder, LevelObject, LightDesc, MaterialDesc, MaterialParameter, InspectorOrder, LevelObject, LightDesc, MaterialDesc, MaterialParameter,
MaterialParameterValue, MaterialShaderKind, ModelRef, ObjectiveMarker, PhysicsBody, MaterialParameterValue, MaterialShaderKind, ObjectiveMarker, PhysicsBody, PlayerSpawn,
PlayerSpawn, PostProcessVolumeDesc, PrefabInstance, Primitive, PrimitiveShape, ProjectSun, PostProcessVolumeDesc, PrefabInstance, Primitive, PrimitiveShape, ProjectSun, RigidBodyDesc,
RigidBodyDesc, StaticMeshRenderer, StaticMeshRendererEntry, TeamSpawn, TriggerVolume, StaticMeshRenderer, StaticMeshRendererEntry, TeamSpawn, TriggerVolume, WeaponSpawn,
WeaponSpawn, AUTHORING_DIRECTIONAL_LUX_MAX, AUTHORING_POINT_SPOT_LUMENS_MAX, AUTHORING_DIRECTIONAL_LUX_MAX, AUTHORING_POINT_SPOT_LUMENS_MAX, COMPONENT_BRUSH_DESC,
COMPONENT_ANIMATION_CONTROLLER_DESC, COMPONENT_AUDIO_LISTENER_DESC, COMPONENT_COLLIDER_DESC, COMPONENT_LIGHT_DESC, COMPONENT_MATERIAL_DESC,
COMPONENT_AUDIO_SOURCE_DESC, COMPONENT_BRUSH_DESC, COMPONENT_COLLIDER_DESC, COMPONENT_OBJECTIVE_MARKER, COMPONENT_PHYSICS_BODY, COMPONENT_PLAYER_SPAWN,
COMPONENT_LIGHT_DESC, COMPONENT_MATERIAL_DESC, COMPONENT_OBJECTIVE_MARKER, COMPONENT_POST_PROCESS_VOLUME, COMPONENT_PREFAB_INSTANCE, COMPONENT_PRIMITIVE,
COMPONENT_PHYSICS_BODY, COMPONENT_PLAYER_SPAWN, COMPONENT_POST_PROCESS_VOLUME, COMPONENT_PROJECT_SUN, COMPONENT_RIGID_BODY_DESC, COMPONENT_STATIC_MESH_RENDERER,
COMPONENT_PREFAB_INSTANCE, COMPONENT_PRIMITIVE, COMPONENT_PROJECT_SUN, COMPONENT_TEAM_SPAWN, COMPONENT_TRIGGER_VOLUME, COMPONENT_WEAPON_SPAWN,
COMPONENT_RIGID_BODY_DESC, COMPONENT_STATIC_MESH_RENDERER, COMPONENT_TEAM_SPAWN,
COMPONENT_TRIGGER_VOLUME, COMPONENT_WEAPON_SPAWN,
}; };
use crate::history::{ use crate::history::{
material_eq, set_animation_controller_with_history, set_audio_listener_with_history, material_eq, set_brush_with_history, set_collider_with_history,
set_audio_source_with_history, set_brush_with_history, set_collider_with_history,
set_inspector_order_with_history, set_light_with_history, set_material_with_history, set_inspector_order_with_history, set_light_with_history, set_material_with_history,
set_physics_with_history, set_post_process_volume_with_history, set_primitive_with_history, set_physics_with_history, set_post_process_volume_with_history, set_primitive_with_history,
set_rigid_body_with_history, set_static_mesh_renderer_with_history, EditorEntitySnapshot, set_rigid_body_with_history, set_static_mesh_renderer_with_history, EditorEntitySnapshot,
@ -138,14 +134,11 @@ enum AddComponentShelfDirection {
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
enum CopiedComponent { enum CopiedComponent {
AnimationControllerDesc(AnimationControllerDesc),
Primitive(Primitive), Primitive(Primitive),
BrushDesc(BrushDesc), BrushDesc(BrushDesc),
StaticMeshRenderer(StaticMeshRenderer), StaticMeshRenderer(StaticMeshRenderer),
MaterialDesc(MaterialDesc), MaterialDesc(MaterialDesc),
LightDesc(LightDesc), LightDesc(LightDesc),
AudioSourceDesc(AudioSourceDesc),
AudioListenerDesc(AudioListenerDesc),
RigidBodyDesc(RigidBodyDesc), RigidBodyDesc(RigidBodyDesc),
ColliderDesc(ColliderDesc), ColliderDesc(ColliderDesc),
PhysicsBody(PhysicsBody), PhysicsBody(PhysicsBody),
@ -160,14 +153,11 @@ enum CopiedComponent {
impl CopiedComponent { impl CopiedComponent {
fn type_name(&self) -> &'static str { fn type_name(&self) -> &'static str {
match self { match self {
Self::AnimationControllerDesc(_) => COMPONENT_ANIMATION_CONTROLLER_DESC,
Self::Primitive(_) => COMPONENT_PRIMITIVE, Self::Primitive(_) => COMPONENT_PRIMITIVE,
Self::BrushDesc(_) => COMPONENT_BRUSH_DESC, Self::BrushDesc(_) => COMPONENT_BRUSH_DESC,
Self::StaticMeshRenderer(_) => COMPONENT_STATIC_MESH_RENDERER, Self::StaticMeshRenderer(_) => COMPONENT_STATIC_MESH_RENDERER,
Self::MaterialDesc(_) => COMPONENT_MATERIAL_DESC, Self::MaterialDesc(_) => COMPONENT_MATERIAL_DESC,
Self::LightDesc(_) => COMPONENT_LIGHT_DESC, Self::LightDesc(_) => COMPONENT_LIGHT_DESC,
Self::AudioSourceDesc(_) => COMPONENT_AUDIO_SOURCE_DESC,
Self::AudioListenerDesc(_) => COMPONENT_AUDIO_LISTENER_DESC,
Self::RigidBodyDesc(_) => COMPONENT_RIGID_BODY_DESC, Self::RigidBodyDesc(_) => COMPONENT_RIGID_BODY_DESC,
Self::ColliderDesc(_) => COMPONENT_COLLIDER_DESC, Self::ColliderDesc(_) => COMPONENT_COLLIDER_DESC,
Self::PhysicsBody(_) => COMPONENT_PHYSICS_BODY, Self::PhysicsBody(_) => COMPONENT_PHYSICS_BODY,
@ -367,9 +357,6 @@ fn component_card_key(world: &World, entity: Entity, type_name: &str) -> String
fn present_component_type_names(world: &World, entity: Entity) -> Vec<&'static str> { fn present_component_type_names(world: &World, entity: Entity) -> Vec<&'static str> {
let mut present = Vec::new(); let mut present = Vec::new();
if world.get::<AnimationControllerDesc>(entity).is_some() {
present.push(COMPONENT_ANIMATION_CONTROLLER_DESC);
}
if world.get::<StaticMeshRenderer>(entity).is_some() { if world.get::<StaticMeshRenderer>(entity).is_some() {
present.push(COMPONENT_STATIC_MESH_RENDERER); present.push(COMPONENT_STATIC_MESH_RENDERER);
} }
@ -385,12 +372,6 @@ fn present_component_type_names(world: &World, entity: Entity) -> Vec<&'static s
if world.get::<LightDesc>(entity).is_some() { if world.get::<LightDesc>(entity).is_some() {
present.push(COMPONENT_LIGHT_DESC); present.push(COMPONENT_LIGHT_DESC);
} }
if world.get::<AudioSourceDesc>(entity).is_some() {
present.push(COMPONENT_AUDIO_SOURCE_DESC);
}
if world.get::<AudioListenerDesc>(entity).is_some() {
present.push(COMPONENT_AUDIO_LISTENER_DESC);
}
if world.get::<RigidBodyDesc>(entity).is_some() { if world.get::<RigidBodyDesc>(entity).is_some() {
present.push(COMPONENT_RIGID_BODY_DESC); present.push(COMPONENT_RIGID_BODY_DESC);
} }
@ -429,10 +410,6 @@ fn present_component_type_names(world: &World, entity: Entity) -> Vec<&'static s
fn copy_component(world: &mut World, entity: Entity, type_name: &str) { fn copy_component(world: &mut World, entity: Entity, type_name: &str) {
let component = match type_name { let component = match type_name {
COMPONENT_ANIMATION_CONTROLLER_DESC => world
.get::<AnimationControllerDesc>(entity)
.cloned()
.map(CopiedComponent::AnimationControllerDesc),
COMPONENT_PRIMITIVE => world COMPONENT_PRIMITIVE => world
.get::<Primitive>(entity) .get::<Primitive>(entity)
.cloned() .cloned()
@ -453,14 +430,6 @@ fn copy_component(world: &mut World, entity: Entity, type_name: &str) {
.get::<LightDesc>(entity) .get::<LightDesc>(entity)
.cloned() .cloned()
.map(CopiedComponent::LightDesc), .map(CopiedComponent::LightDesc),
COMPONENT_AUDIO_SOURCE_DESC => world
.get::<AudioSourceDesc>(entity)
.cloned()
.map(CopiedComponent::AudioSourceDesc),
COMPONENT_AUDIO_LISTENER_DESC => world
.get::<AudioListenerDesc>(entity)
.copied()
.map(CopiedComponent::AudioListenerDesc),
COMPONENT_RIGID_BODY_DESC => world COMPONENT_RIGID_BODY_DESC => world
.get::<RigidBodyDesc>(entity) .get::<RigidBodyDesc>(entity)
.copied() .copied()
@ -509,11 +478,6 @@ fn paste_component(world: &mut World, entity: Entity, type_name: &str) {
.get_resource::<InspectorClipboard>() .get_resource::<InspectorClipboard>()
.and_then(|clipboard| clipboard.component.clone()); .and_then(|clipboard| clipboard.component.clone());
match component { match component {
Some(CopiedComponent::AnimationControllerDesc(value))
if type_name == COMPONENT_ANIMATION_CONTROLLER_DESC =>
{
set_animation_controller_with_history(world, entity, value);
}
Some(CopiedComponent::Primitive(value)) if type_name == COMPONENT_PRIMITIVE => { Some(CopiedComponent::Primitive(value)) if type_name == COMPONENT_PRIMITIVE => {
set_primitive_with_history(world, entity, value); set_primitive_with_history(world, entity, value);
} }
@ -531,16 +495,6 @@ fn paste_component(world: &mut World, entity: Entity, type_name: &str) {
Some(CopiedComponent::LightDesc(value)) if type_name == COMPONENT_LIGHT_DESC => { Some(CopiedComponent::LightDesc(value)) if type_name == COMPONENT_LIGHT_DESC => {
set_light_with_history(world, entity, value); set_light_with_history(world, entity, value);
} }
Some(CopiedComponent::AudioSourceDesc(value))
if type_name == COMPONENT_AUDIO_SOURCE_DESC =>
{
set_audio_source_with_history(world, entity, value);
}
Some(CopiedComponent::AudioListenerDesc(value))
if type_name == COMPONENT_AUDIO_LISTENER_DESC =>
{
set_audio_listener_with_history(world, entity, value);
}
Some(CopiedComponent::RigidBodyDesc(value)) if type_name == COMPONENT_RIGID_BODY_DESC => { Some(CopiedComponent::RigidBodyDesc(value)) if type_name == COMPONENT_RIGID_BODY_DESC => {
set_rigid_body_with_history(world, entity, value); set_rigid_body_with_history(world, entity, value);
} }
@ -578,11 +532,6 @@ fn paste_component(world: &mut World, entity: Entity, type_name: &str) {
fn reset_component(world: &mut World, entity: Entity, type_name: &str) { fn reset_component(world: &mut World, entity: Entity, type_name: &str) {
match type_name { match type_name {
COMPONENT_ANIMATION_CONTROLLER_DESC => {
let controller =
crate::ui::animation_inspector::default_controller_for_actor(world, entity);
set_animation_controller_with_history(world, entity, controller);
}
COMPONENT_PRIMITIVE => set_primitive_with_history(world, entity, Primitive::default()), COMPONENT_PRIMITIVE => set_primitive_with_history(world, entity, Primitive::default()),
COMPONENT_BRUSH_DESC => set_brush_with_history(world, entity, BrushDesc::default()), COMPONENT_BRUSH_DESC => set_brush_with_history(world, entity, BrushDesc::default()),
COMPONENT_STATIC_MESH_RENDERER => { COMPONENT_STATIC_MESH_RENDERER => {
@ -592,12 +541,6 @@ fn reset_component(world: &mut World, entity: Entity, type_name: &str) {
set_material_with_history(world, entity, MaterialDesc::default()) set_material_with_history(world, entity, MaterialDesc::default())
} }
COMPONENT_LIGHT_DESC => set_light_with_history(world, entity, LightDesc::default()), COMPONENT_LIGHT_DESC => set_light_with_history(world, entity, LightDesc::default()),
COMPONENT_AUDIO_SOURCE_DESC => {
set_audio_source_with_history(world, entity, AudioSourceDesc::default())
}
COMPONENT_AUDIO_LISTENER_DESC => {
set_audio_listener_with_history(world, entity, AudioListenerDesc::default())
}
COMPONENT_RIGID_BODY_DESC => { COMPONENT_RIGID_BODY_DESC => {
set_rigid_body_with_history(world, entity, RigidBodyDesc::default()); set_rigid_body_with_history(world, entity, RigidBodyDesc::default());
} }
@ -643,17 +586,8 @@ fn remove_registered_component(world: &mut World, entity: Entity, type_name: &st
let Some(before) = crate::history::snapshot_entity(world, entity) else { let Some(before) = crate::history::snapshot_entity(world, entity) else {
return; return;
}; };
let removed_dedicated_audio_kind = matches!(
(type_name, before.actor_kind),
(COMPONENT_AUDIO_SOURCE_DESC, ActorKind::AudioSource)
| (COMPONENT_AUDIO_LISTENER_DESC, ActorKind::AudioListener)
);
let removed = if let Ok(mut entity_mut) = world.get_entity_mut(entity) { let removed = if let Ok(mut entity_mut) = world.get_entity_mut(entity) {
match type_name { match type_name {
COMPONENT_ANIMATION_CONTROLLER_DESC if before.animation_controller.is_some() => {
entity_mut.remove::<AnimationControllerDesc>();
true
}
COMPONENT_PRIMITIVE if before.primitive.is_some() => { COMPONENT_PRIMITIVE if before.primitive.is_some() => {
entity_mut.remove::<Primitive>(); entity_mut.remove::<Primitive>();
true true
@ -674,14 +608,6 @@ fn remove_registered_component(world: &mut World, entity: Entity, type_name: &st
entity_mut.remove::<LightDesc>(); entity_mut.remove::<LightDesc>();
true true
} }
COMPONENT_AUDIO_SOURCE_DESC if before.audio_source.is_some() => {
entity_mut.remove::<AudioSourceDesc>();
true
}
COMPONENT_AUDIO_LISTENER_DESC if before.audio_listener.is_some() => {
entity_mut.remove::<AudioListenerDesc>();
true
}
COMPONENT_RIGID_BODY_DESC if before.rigid_body.is_some() => { COMPONENT_RIGID_BODY_DESC if before.rigid_body.is_some() => {
entity_mut.remove::<RigidBodyDesc>(); entity_mut.remove::<RigidBodyDesc>();
true true
@ -730,19 +656,6 @@ fn remove_registered_component(world: &mut World, entity: Entity, type_name: &st
if !removed { if !removed {
return; return;
} }
if type_name == COMPONENT_ANIMATION_CONTROLLER_DESC {
crate::ui::animation_inspector::stop_preview_if_actor(world, entity);
}
if removed_dedicated_audio_kind {
let fallback = world
.get_entity(entity)
.ok()
.and_then(infer_actor_kind)
.unwrap_or(ActorKind::Empty);
if let Ok(mut entity_mut) = world.get_entity_mut(entity) {
entity_mut.insert(fallback);
}
}
if let Some(after) = crate::history::snapshot_entity(world, entity) { if let Some(after) = crate::history::snapshot_entity(world, entity) {
let snapshot = diff_added(&after, &before); let snapshot = diff_added(&after, &before);
crate::history::push_command( crate::history::push_command(
@ -777,20 +690,11 @@ fn draw_authoring_component_by_type(
type_name: &'static str, type_name: &'static str,
) { ) {
match type_name { match type_name {
COMPONENT_ANIMATION_CONTROLLER_DESC => {
crate::ui::animation_inspector::animation_controller_inspector_ui(world, ui, entity);
}
COMPONENT_STATIC_MESH_RENDERER => static_mesh_renderer_ui(world, ui, entity), COMPONENT_STATIC_MESH_RENDERER => static_mesh_renderer_ui(world, ui, entity),
COMPONENT_BRUSH_DESC => brush_editor_ui(world, ui, entity), COMPONENT_BRUSH_DESC => brush_editor_ui(world, ui, entity),
COMPONENT_PRIMITIVE => primitive_editor_ui(world, ui, entity), COMPONENT_PRIMITIVE => primitive_editor_ui(world, ui, entity),
COMPONENT_MATERIAL_DESC => material_editor_ui(world, ui, entity), COMPONENT_MATERIAL_DESC => material_editor_ui(world, ui, entity),
COMPONENT_LIGHT_DESC => light_editor_ui(world, ui, entity), COMPONENT_LIGHT_DESC => light_editor_ui(world, ui, entity),
COMPONENT_AUDIO_SOURCE_DESC => {
crate::ui::audio_inspector::audio_source_inspector_ui(world, ui, entity);
}
COMPONENT_AUDIO_LISTENER_DESC => {
crate::ui::audio_inspector::audio_listener_inspector_ui(world, ui, entity);
}
COMPONENT_RIGID_BODY_DESC => rigid_body_editor_ui(world, ui, entity), COMPONENT_RIGID_BODY_DESC => rigid_body_editor_ui(world, ui, entity),
COMPONENT_COLLIDER_DESC => collider_editor_ui(world, ui, entity), COMPONENT_COLLIDER_DESC => collider_editor_ui(world, ui, entity),
COMPONENT_PHYSICS_BODY => physics_editor_ui(world, ui, entity), COMPONENT_PHYSICS_BODY => physics_editor_ui(world, ui, entity),
@ -1078,8 +982,6 @@ fn filtered_component_descriptors<'a>(
[ [
EditorComponentCategory::Authoring, EditorComponentCategory::Authoring,
EditorComponentCategory::Rendering, EditorComponentCategory::Rendering,
EditorComponentCategory::Animation,
EditorComponentCategory::Audio,
EditorComponentCategory::Physics, EditorComponentCategory::Physics,
EditorComponentCategory::Gameplay, EditorComponentCategory::Gameplay,
EditorComponentCategory::Volumes, EditorComponentCategory::Volumes,
@ -1100,8 +1002,6 @@ fn component_category_label(category: EditorComponentCategory) -> &'static str {
match category { match category {
EditorComponentCategory::Authoring => "Authoring", EditorComponentCategory::Authoring => "Authoring",
EditorComponentCategory::Rendering => "Rendering", EditorComponentCategory::Rendering => "Rendering",
EditorComponentCategory::Animation => "Animation",
EditorComponentCategory::Audio => "Audio",
EditorComponentCategory::Physics => "Physics", EditorComponentCategory::Physics => "Physics",
EditorComponentCategory::Gameplay => "Gameplay", EditorComponentCategory::Gameplay => "Gameplay",
EditorComponentCategory::Volumes => "Volumes", EditorComponentCategory::Volumes => "Volumes",
@ -1146,15 +1046,8 @@ fn component_add_state(
.copied() .copied()
.filter(|type_name| !component_present(world, entity, type_name)) .filter(|type_name| !component_present(world, entity, type_name))
.collect::<Vec<_>>(); .collect::<Vec<_>>();
let animation_target_missing = descriptor.type_name == COMPONENT_ANIMATION_CONTROLLER_DESC
&& (!world
.get::<ActorKind>(entity)
.is_some_and(|kind| *kind == ActorKind::ImportedModel)
|| world.get::<ModelRef>(entity).is_none());
let reason = if duplicate { let reason = if duplicate {
Some("Already present on this actor.".to_string()) Some("Already present on this actor.".to_string())
} else if animation_target_missing {
Some("Animation Controller requires an ImportedModel actor with ModelRef.".to_string())
} else if !conflicts.is_empty() { } else if !conflicts.is_empty() {
Some(format!( Some(format!(
"Conflicts with {}.", "Conflicts with {}.",
@ -1217,9 +1110,6 @@ fn component_display_name(
fn component_present(world: &World, entity: Entity, type_name: &str) -> bool { fn component_present(world: &World, entity: Entity, type_name: &str) -> bool {
match type_name { match type_name {
COMPONENT_ANIMATION_CONTROLLER_DESC => {
world.get::<AnimationControllerDesc>(entity).is_some()
}
"shared::components::Primitive" => world.get::<Primitive>(entity).is_some(), "shared::components::Primitive" => world.get::<Primitive>(entity).is_some(),
"shared::components::BrushDesc" => world.get::<BrushDesc>(entity).is_some(), "shared::components::BrushDesc" => world.get::<BrushDesc>(entity).is_some(),
"shared::components::StaticMeshRenderer" => { "shared::components::StaticMeshRenderer" => {
@ -1227,8 +1117,6 @@ fn component_present(world: &World, entity: Entity, type_name: &str) -> bool {
} }
"shared::components::MaterialDesc" => world.get::<MaterialDesc>(entity).is_some(), "shared::components::MaterialDesc" => world.get::<MaterialDesc>(entity).is_some(),
"shared::components::LightDesc" => world.get::<LightDesc>(entity).is_some(), "shared::components::LightDesc" => world.get::<LightDesc>(entity).is_some(),
"shared::components::AudioSourceDesc" => world.get::<AudioSourceDesc>(entity).is_some(),
"shared::components::AudioListenerDesc" => world.get::<AudioListenerDesc>(entity).is_some(),
"shared::components::RigidBodyDesc" => world.get::<RigidBodyDesc>(entity).is_some(), "shared::components::RigidBodyDesc" => world.get::<RigidBodyDesc>(entity).is_some(),
"shared::components::ColliderDesc" => world.get::<ColliderDesc>(entity).is_some(), "shared::components::ColliderDesc" => world.get::<ColliderDesc>(entity).is_some(),
"shared::components::PlayerSpawn" => world.get::<PlayerSpawn>(entity).is_some(), "shared::components::PlayerSpawn" => world.get::<PlayerSpawn>(entity).is_some(),
@ -1245,13 +1133,6 @@ fn component_present(world: &World, entity: Entity, type_name: &str) -> bool {
fn insert_registered_component(world: &mut World, entity: Entity, type_name: &str) { fn insert_registered_component(world: &mut World, entity: Entity, type_name: &str) {
match type_name { match type_name {
COMPONENT_ANIMATION_CONTROLLER_DESC => {
let controller =
crate::ui::animation_inspector::default_controller_for_actor(world, entity);
insert_component(world, entity, move |world, e| {
world.entity_mut(e).insert(controller);
});
}
"shared::components::Primitive" => insert_component(world, entity, |world, e| { "shared::components::Primitive" => insert_component(world, entity, |world, e| {
world world
.entity_mut(e) .entity_mut(e)
@ -1273,17 +1154,6 @@ fn insert_registered_component(world: &mut World, entity: Entity, type_name: &st
"shared::components::LightDesc" => insert_component(world, entity, |world, e| { "shared::components::LightDesc" => insert_component(world, entity, |world, e| {
world.entity_mut(e).insert(LightDesc::default()); world.entity_mut(e).insert(LightDesc::default());
}), }),
"shared::components::AudioSourceDesc" => insert_component(world, entity, |world, e| {
world
.entity_mut(e)
.insert((shared::ActorKind::AudioSource, AudioSourceDesc::default()));
}),
"shared::components::AudioListenerDesc" => insert_component(world, entity, |world, e| {
world.entity_mut(e).insert((
shared::ActorKind::AudioListener,
AudioListenerDesc::default(),
));
}),
"shared::components::RigidBodyDesc" => insert_component(world, entity, |world, e| { "shared::components::RigidBodyDesc" => insert_component(world, entity, |world, e| {
world.entity_mut(e).insert(RigidBodyDesc::default()); world.entity_mut(e).insert(RigidBodyDesc::default());
}), }),
@ -1364,17 +1234,6 @@ fn diff_added(before: &EditorEntitySnapshot, after: &EditorEntitySnapshot) -> Ed
collider: after.collider.clone().filter(|_| before.collider.is_none()), collider: after.collider.clone().filter(|_| before.collider.is_none()),
physics: after.physics.clone().filter(|_| before.physics.is_none()), physics: after.physics.clone().filter(|_| before.physics.is_none()),
light: after.light.clone().filter(|_| before.light.is_none()), light: after.light.clone().filter(|_| before.light.is_none()),
animation_controller: after
.animation_controller
.clone()
.filter(|_| before.animation_controller.is_none()),
audio_source: after
.audio_source
.clone()
.filter(|_| before.audio_source.is_none()),
audio_listener: after
.audio_listener
.filter(|_| before.audio_listener.is_none()),
player_spawn: after.player_spawn && !before.player_spawn, player_spawn: after.player_spawn && !before.player_spawn,
model: after.model.clone().filter(|_| before.model.is_none()), model: after.model.clone().filter(|_| before.model.is_none()),
prefab: after.prefab.clone().filter(|_| before.prefab.is_none()), prefab: after.prefab.clone().filter(|_| before.prefab.is_none()),

View File

@ -45,25 +45,6 @@ pub fn top_menu_bar(
world.resource_mut::<SceneIo>().request = Some(SceneIoRequest::SaveAs); world.resource_mut::<SceneIo>().request = Some(SceneIoRequest::SaveAs);
ui.close(); ui.close();
} }
let recovery_available = world.resource::<SceneIo>().recovery_snapshot.is_some();
if recovery_available {
ui.separator();
if menu_item(ui, "Restore Recovery Snapshot", None, true).clicked() {
world.resource_mut::<SceneIo>().request =
Some(SceneIoRequest::RestoreRecovery);
ui.close();
}
if menu_item(ui, "Save Recovery Copy As...", None, true).clicked() {
world.resource_mut::<SceneIo>().request =
Some(SceneIoRequest::SaveRecoveryCopyAs);
ui.close();
}
if menu_item(ui, "Discard Recovery Snapshot", None, true).clicked() {
world.resource_mut::<SceneIo>().request =
Some(SceneIoRequest::DiscardRecovery);
ui.close();
}
}
ui.separator(); ui.separator();
if menu_item(ui, "Import Assets...", None, true).clicked() { if menu_item(ui, "Import Assets...", None, true).clicked() {
world.resource_mut::<SceneIo>().request = Some(SceneIoRequest::ImportAssets); world.resource_mut::<SceneIo>().request = Some(SceneIoRequest::ImportAssets);
@ -78,24 +59,6 @@ pub fn top_menu_bar(
Some(SceneIoRequest::SaveSelectionAsPrefab); Some(SceneIoRequest::SaveSelectionAsPrefab);
ui.close(); ui.close();
} }
ui.separator();
if menu_item(ui, "Switch Project...", None, true).clicked() {
world.resource_mut::<SceneIo>().request = Some(SceneIoRequest::SwitchProject);
ui.close();
}
if menu_item(ui, "Reveal Project Folder", None, true).clicked() {
let root = world
.resource::<crate::project_io::ProjectWorkspace>()
.root
.clone();
let status =
match crate::launcher::reveal_project_root(std::path::Path::new(&root)) {
Ok(()) => format!("Revealed project folder {root}"),
Err(error) => format!("Reveal project folder failed: {error}"),
};
world.resource_mut::<SceneIo>().set_status(status);
ui.close();
}
let recent: Vec<(usize, String)> = world let recent: Vec<(usize, String)> = world
.resource::<SceneIo>() .resource::<SceneIo>()
.recent_paths .recent_paths
@ -160,30 +123,6 @@ pub fn top_menu_bar(
} }
}); });
ui.menu_button("Build", |ui| {
let running = world.resource::<super::build::BuildPanel>().is_running();
if menu_item(ui, "Build Package", None, !running).clicked() {
open_and_focus_tab(dock_state, EditorTab::Build, panel_nodes);
super::build::request_build(world, false);
ui.close();
}
if menu_item(ui, "Build and Run", None, !running).clicked() {
open_and_focus_tab(dock_state, EditorTab::Build, panel_nodes);
super::build::request_build(world, true);
ui.close();
}
if menu_item(ui, "Cancel Build", None, running).clicked() {
super::build::cancel_build(world);
ui.close();
}
ui.separator();
let has_output = world.resource::<super::build::BuildPanel>().has_output();
if menu_item(ui, "Reveal Output", None, has_output).clicked() {
super::build::reveal_last_output(world);
ui.close();
}
});
ui.menu_button("View", |ui| { ui.menu_button("View", |ui| {
if menu_item(ui, "Reset Layout", None, true).clicked() { if menu_item(ui, "Reset Layout", None, true).clicked() {
let layout = reset_and_save_layout(world); let layout = reset_and_save_layout(world);

View File

@ -1,8 +1,5 @@
mod actor_inspector; mod actor_inspector;
mod animation_inspector;
mod asset_browser; mod asset_browser;
mod audio_inspector;
mod build;
mod component_registry; mod component_registry;
mod diagnostics; mod diagnostics;
mod dock_tabs; mod dock_tabs;
@ -16,7 +13,6 @@ mod layout;
mod menu; mod menu;
mod play_controls; mod play_controls;
mod post_process_volume_ui; mod post_process_volume_ui;
mod scene_tabs;
pub mod selection_ops; pub mod selection_ops;
mod status_bar; mod status_bar;
pub(crate) mod theme; pub(crate) mod theme;
@ -38,7 +34,6 @@ use crate::project_io::UserPreferences;
use crate::selection::SelectedEntity; use crate::selection::SelectedEntity;
use crate::state::EditorMode; use crate::state::EditorMode;
pub use build::BuildPanel;
pub use diagnostics::{BrushDiagnosticsPanel, DiagnosticsPanel}; pub use diagnostics::{BrushDiagnosticsPanel, DiagnosticsPanel};
pub use layout::LayoutSaveTimer; pub use layout::LayoutSaveTimer;
pub use viewport_chrome::ViewportUiState; pub use viewport_chrome::ViewportUiState;
@ -129,7 +124,6 @@ impl UiState {
)] )]
fn ui(&mut self, world: &mut World, ctx: &mut egui::Context, dt: f32) { fn ui(&mut self, world: &mut World, ctx: &mut egui::Context, dt: f32) {
apply_editor_theme(ctx); apply_editor_theme(ctx);
let selected_before_ui = world.resource::<SelectedEntity>().0;
let mode = *world.resource::<State<EditorMode>>().get(); let mode = *world.resource::<State<EditorMode>>().get();
if self.last_mode_tab != Some(mode) { if self.last_mode_tab != Some(mode) {
@ -162,7 +156,7 @@ impl UiState {
&mut self.panel_nodes, &mut self.panel_nodes,
); );
editor_toolbar_panel(world, root_ui, &mut self.selected_entities); editor_toolbar_panel(world, root_ui);
status_bar_ui(world, root_ui, &self.selected_entities, mode); status_bar_ui(world, root_ui, &self.selected_entities, mode);
let mut viewer = TabViewer { let mut viewer = TabViewer {
@ -208,8 +202,6 @@ impl UiState {
.resource_mut::<crate::rendering_diagnostics::RenderingDiagnosticsPanel>() .resource_mut::<crate::rendering_diagnostics::RenderingDiagnosticsPanel>()
.open = rendering_open; .open = rendering_open;
crate::session::session_resume_window(world, ctx);
world.resource_scope(|world, mut panel: Mut<ProjectSettingsPanel>| { world.resource_scope(|world, mut panel: Mut<ProjectSettingsPanel>| {
project_settings_window(world, ctx, &mut panel); project_settings_window(world, ctx, &mut panel);
}); });
@ -218,32 +210,11 @@ impl UiState {
world.resource_mut::<ViewportUiState>().shortcuts_open = true; world.resource_mut::<ViewportUiState>().shortcuts_open = true;
} }
let requested_selection = world.resource::<SelectedEntity>().0;
reconcile_operator_selection(
&mut self.selected_entities,
selected_before_ui,
requested_selection,
);
let selected = self.selected_entities.as_slice().first().copied(); let selected = self.selected_entities.as_slice().first().copied();
world.resource_mut::<SelectedEntity>().0 = selected; world.resource_mut::<SelectedEntity>().0 = selected;
} }
} }
fn reconcile_operator_selection(
selected_entities: &mut SelectedEntities,
selected_before_ui: Option<Entity>,
requested_selection: Option<Entity>,
) {
let selected_after_ui = selected_entities.as_slice().first().copied();
if requested_selection == selected_before_ui || selected_after_ui != selected_before_ui {
return;
}
selected_entities.clear();
if let Some(entity) = requested_selection {
selected_entities.select_replace(entity);
}
}
fn tab_title(icon: egui_phosphor_icons::Icon, label: &str) -> egui::WidgetText { fn tab_title(icon: egui_phosphor_icons::Icon, label: &str) -> egui::WidgetText {
let mut job = egui::text::LayoutJob::default(); let mut job = egui::text::LayoutJob::default();
job.append( job.append(
@ -276,7 +247,6 @@ pub enum EditorTab {
Inspector, Inspector,
Toolbar, Toolbar,
AssetBrowser, AssetBrowser,
Build,
} }
pub struct EditorUiPlugin; pub struct EditorUiPlugin;
@ -286,14 +256,12 @@ impl Plugin for EditorUiPlugin {
app.insert_resource(UiState::default_layout()) app.insert_resource(UiState::default_layout())
.add_plugins(fonts::EditorFontsPlugin) .add_plugins(fonts::EditorFontsPlugin)
.init_resource::<DiagnosticsPanel>() .init_resource::<DiagnosticsPanel>()
.init_resource::<BuildPanel>()
.init_resource::<BrushDiagnosticsPanel>() .init_resource::<BrushDiagnosticsPanel>()
.init_resource::<component_registry::EditorComponentRegistry>() .init_resource::<component_registry::EditorComponentRegistry>()
.init_resource::<crate::rendering_diagnostics::RenderingDiagnosticsPanel>() .init_resource::<crate::rendering_diagnostics::RenderingDiagnosticsPanel>()
.init_resource::<asset_browser::AssetBrowserUiState>() .init_resource::<asset_browser::AssetBrowserUiState>()
.init_resource::<inspector::InspectorClipboard>() .init_resource::<inspector::InspectorClipboard>()
.init_resource::<inspector::InspectorPanelState>() .init_resource::<inspector::InspectorPanelState>()
.init_resource::<animation_inspector::AnimationInspectorState>()
.init_resource::<ViewportUiState>() .init_resource::<ViewportUiState>()
.init_resource::<LayoutSaveTimer>() .init_resource::<LayoutSaveTimer>()
.add_systems( .add_systems(
@ -307,13 +275,7 @@ impl Plugin for EditorUiPlugin {
.after(crate::project_io::load_user_preferences_on_startup), .after(crate::project_io::load_user_preferences_on_startup),
) )
.add_systems(EguiPrimaryContextPass, show_ui_system) .add_systems(EguiPrimaryContextPass, show_ui_system)
.add_systems( .add_systems(Update, hierarchy_state::tick_hierarchy_prefs_save);
Update,
(
hierarchy_state::tick_hierarchy_prefs_save,
build::tick_build_job,
),
);
} }
} }
@ -398,7 +360,6 @@ impl egui_dock::TabViewer for TabViewer<'_> {
EditorTab::AssetBrowser => { EditorTab::AssetBrowser => {
asset_browser::asset_browser_ui(self.world, ui, self.selected_entities); asset_browser::asset_browser_ui(self.world, ui, self.selected_entities);
} }
EditorTab::Build => build::build_ui(self.world, ui),
} }
} }
@ -409,7 +370,6 @@ impl egui_dock::TabViewer for TabViewer<'_> {
EditorTab::Inspector => (icons::SLIDERS, "Inspector"), EditorTab::Inspector => (icons::SLIDERS, "Inspector"),
EditorTab::Toolbar => (icons::WRENCH, "Toolbar"), EditorTab::Toolbar => (icons::WRENCH, "Toolbar"),
EditorTab::AssetBrowser => (icons::FOLDER, "Asset Browser"), EditorTab::AssetBrowser => (icons::FOLDER, "Asset Browser"),
EditorTab::Build => (icons::HAMMER, "Build"),
}; };
tab_title(icon, label) tab_title(icon, label)
} }
@ -421,33 +381,3 @@ impl egui_dock::TabViewer for TabViewer<'_> {
} }
} }
} }
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn operator_selection_wins_when_ui_selection_did_not_change() {
let before = Entity::from_bits(1);
let spawned = Entity::from_bits(2);
let mut selected = SelectedEntities::default();
selected.select_replace(before);
reconcile_operator_selection(&mut selected, Some(before), Some(spawned));
assert_eq!(selected.as_slice(), &[spawned]);
}
#[test]
fn direct_ui_selection_wins_over_stale_operator_resource() {
let before = Entity::from_bits(1);
let clicked = Entity::from_bits(2);
let requested = Entity::from_bits(3);
let mut selected = SelectedEntities::default();
selected.select_replace(clicked);
reconcile_operator_selection(&mut selected, Some(before), Some(requested));
assert_eq!(selected.as_slice(), &[clicked]);
}
}

View File

@ -13,7 +13,7 @@ pub fn toggle_play_mode(world: &mut World) {
if save_before && world.resource::<SceneIo>().dirty { if save_before && world.resource::<SceneIo>().dirty {
let status = save_active_or_prompt_world(world); let status = save_active_or_prompt_world(world);
if status.contains("failed") || status.contains("cancelled") { if status.contains("failed") || status.contains("cancelled") {
world.resource_mut::<SceneIo>().set_status(status); world.resource_mut::<SceneIo>().status = status;
return; return;
} }
} }

View File

@ -1,282 +0,0 @@
//! Top-level authored scene tabs and composition controls.
use std::path::{Path, PathBuf};
use bevy::prelude::*;
use bevy_egui::egui;
use bevy_inspector_egui::bevy_inspector::hierarchy::SelectedEntities;
use egui_phosphor_icons::icons;
use shared::{SceneComposition, SubsceneReference};
use crate::scene_io::{ComposedSceneMember, SceneIo, SceneIoRequest};
use crate::selection::SelectedEntity;
use super::selection_ops::focus_editor_camera_on_selection;
use super::theme::{BORDER, SELECTION, SELECTION_BG, TEXT, TEXT_DIM, TEXT_MUTED, WIDGET_BG};
use super::widgets::phosphor_icon;
pub fn scene_tabs_chrome(
world: &mut World,
ui: &mut egui::Ui,
selected: &mut SelectedEntities,
editing: bool,
) {
let (tabs, active_tab) = {
let io = world.resource::<SceneIo>();
(io.tabs.clone(), io.active_tab)
};
let composition_editing = editing
&& !world
.resource::<SceneIo>()
.active_path
.as_deref()
.is_some_and(crate::scene_io::is_prefab_document);
let composition_count = world.resource::<SceneComposition>().subscenes.len();
let mut request = None;
ui.spacing_mut().item_spacing.x = 3.0;
ui.menu_button(
super::tab_title(icons::STACK, &format!("Composition {composition_count}")),
|ui| composition_menu(world, ui, selected, composition_editing),
)
.response
.on_hover_text(if composition_editing {
"Scene composition"
} else {
"Composition is unavailable for prefab documents"
});
ui.separator();
if ui
.add_enabled(
editing,
egui::Button::new(phosphor_icon(icons::PLUS, 13.0))
.frame(false)
.min_size(egui::vec2(22.0, 20.0)),
)
.on_hover_text("New scene tab")
.clicked()
{
request = Some(SceneIoRequest::New);
}
for (index, tab) in tabs.iter().enumerate().rev() {
if ui
.add_enabled(
editing,
egui::Button::new(phosphor_icon(icons::X, 11.0))
.frame(false)
.min_size(egui::vec2(18.0, 20.0)),
)
.on_hover_text("Close scene tab")
.clicked()
{
request = Some(SceneIoRequest::CloseTab(index));
}
let active = index == active_tab;
let label = if tab.dirty {
format!("{} *", tab.label())
} else {
tab.label()
};
let text = egui::RichText::new(label)
.color(if active { TEXT } else { TEXT_DIM })
.strong();
let response = ui.add_enabled(
editing,
egui::Button::new(text)
.fill(if active { SELECTION_BG } else { WIDGET_BG })
.stroke(egui::Stroke::new(
1.0,
if active { SELECTION } else { BORDER },
))
.min_size(egui::vec2(128.0, 20.0)),
);
if response.clicked() && !active {
request = Some(SceneIoRequest::SwitchTab(index));
}
if response.hovered() {
let path = tab
.path
.as_deref()
.map(Path::display)
.map(|path| path.to_string())
.unwrap_or_else(|| "Unsaved scene".to_string());
response.on_hover_text(path);
}
}
if !editing {
ui.label(egui::RichText::new("Play mode").color(TEXT_MUTED));
}
if let Some(request) = request {
world.resource_mut::<SceneIo>().request = Some(request);
}
}
fn composition_menu(
world: &mut World,
ui: &mut egui::Ui,
selected: &mut SelectedEntities,
editing: bool,
) {
ui.set_min_width(480.0);
let mut composition = world.resource::<SceneComposition>().clone();
ui.label(
egui::RichText::new(format!("Scene ID {}", composition.scene_id))
.monospace()
.size(11.0)
.color(TEXT_MUTED),
);
ui.separator();
let mut changed = false;
let mut remove = None;
let mut focus = None;
if composition.subscenes.is_empty() {
ui.label(egui::RichText::new("No composed subscenes").color(TEXT_DIM));
}
for (index, reference) in composition.subscenes.iter_mut().enumerate() {
ui.horizontal(|ui| {
let file_name = Path::new(&reference.path)
.file_name()
.and_then(|name| name.to_str())
.unwrap_or(&reference.path);
ui.label(egui::RichText::new(file_name).strong().color(TEXT));
ui.label(
egui::RichText::new(&reference.path)
.monospace()
.size(10.0)
.color(TEXT_MUTED),
);
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if ui
.add_enabled(
editing,
egui::Button::new(phosphor_icon(icons::TRASH, 14.0)).frame(false),
)
.on_hover_text("Remove subscene reference")
.clicked()
{
remove = Some(index);
}
if ui
.add_enabled(reference.visible, egui::Button::new("Focus"))
.clicked()
{
focus = Some(reference.id.clone());
ui.close();
}
if ui
.add_enabled(
editing,
egui::Checkbox::new(&mut reference.locked, "Locked"),
)
.changed()
{
changed = true;
}
if ui
.add_enabled(
editing,
egui::Checkbox::new(&mut reference.visible, "Loaded"),
)
.changed()
{
changed = true;
}
});
});
ui.add_space(4.0);
}
if let Some(index) = remove {
composition.subscenes.remove(index);
changed = true;
}
ui.separator();
if ui
.add_enabled(editing, egui::Button::new("Add Subscene..."))
.clicked()
{
match choose_subscene(world, &composition) {
Ok(Some(reference)) => {
composition.subscenes.push(reference);
changed = true;
}
Ok(None) => {}
Err(error) => world
.resource_mut::<SceneIo>()
.set_status(format!("Add subscene failed: {error}")),
}
}
if changed {
crate::history::set_scene_composition_with_history(world, composition);
}
if let Some(reference_id) = focus {
focus_subscene(world, selected, &reference_id);
}
}
fn choose_subscene(
world: &World,
composition: &SceneComposition,
) -> Result<Option<SubsceneReference>, String> {
let project_root = PathBuf::from(&world.resource::<crate::project_io::ProjectWorkspace>().root)
.canonicalize()
.map_err(|error| format!("could not resolve project root: {error}"))?;
let Some(path) = rfd::FileDialog::new()
.set_directory(project_root.join("assets/levels"))
.add_filter("Bevy scene", &["scn.ron", "ron"])
.pick_file()
else {
return Ok(None);
};
let path = path
.canonicalize()
.map_err(|error| format!("could not resolve {}: {error}", path.display()))?;
let relative = path.strip_prefix(&project_root).map_err(|_| {
format!(
"{} is outside project root {}",
path.display(),
project_root.display()
)
})?;
scene::validate_level_file(&path)?;
let relative = relative.to_string_lossy().replace('\\', "/");
if composition
.subscenes
.iter()
.any(|reference| reference.path == relative)
{
return Err(format!("{relative} is already composed"));
}
Ok(Some(SubsceneReference {
id: uuid::Uuid::new_v4().to_string(),
path: relative,
visible: true,
locked: true,
}))
}
fn focus_subscene(world: &mut World, selected: &mut SelectedEntities, reference_id: &str) {
let mut query = world.query::<(Entity, &ComposedSceneMember)>();
let entity = query.iter(world).find_map(|(entity, member)| {
(member.reference_id == reference_id
|| member.reference_id.starts_with(&format!("{reference_id}/")))
.then_some(entity)
});
let Some(entity) = entity else {
world
.resource_mut::<SceneIo>()
.set_status("Subscene is not loaded or contains no authored actors");
return;
};
selected.clear();
selected.select_replace(entity);
world.resource_mut::<SelectedEntity>().0 = Some(entity);
focus_editor_camera_on_selection(world);
world
.resource_mut::<SceneIo>()
.set_status(format!("Focused composed subscene {reference_id}"));
}

View File

@ -2,7 +2,7 @@
use bevy::prelude::*; use bevy::prelude::*;
use bevy_inspector_egui::bevy_inspector::hierarchy::SelectedEntities; use bevy_inspector_egui::bevy_inspector::hierarchy::SelectedEntities;
use shared::{HydratedPrefabMember, LevelObject}; use shared::LevelObject;
use crate::camera::EditorCamera; use crate::camera::EditorCamera;
use crate::history::{ use crate::history::{
@ -11,25 +11,6 @@ use crate::history::{
use crate::selection::SelectedEntity; use crate::selection::SelectedEntity;
use crate::ui::UiState; use crate::ui::UiState;
fn is_locked(world: &World, entity: Entity) -> bool {
world
.get_resource::<crate::ui::hierarchy_state::HierarchyPanelState>()
.is_some_and(|state| state.locked.contains(&entity))
}
pub fn is_linked_read_only(world: &World, entity: Entity) -> bool {
world.get::<HydratedPrefabMember>(entity).is_some()
|| world
.get::<crate::scene_io::ComposedSceneMember>(entity)
.is_some()
}
pub fn is_mutable_level_object(world: &World, entity: Entity) -> bool {
is_level_object(world, entity)
&& !is_locked(world, entity)
&& !is_linked_read_only(world, entity)
}
pub fn focus_editor_camera_on_selection(world: &mut World) { pub fn focus_editor_camera_on_selection(world: &mut World) {
let Some(entity) = world.resource::<SelectedEntity>().0 else { let Some(entity) = world.resource::<SelectedEntity>().0 else {
return; return;
@ -72,18 +53,12 @@ pub fn is_level_object(world: &World, entity: Entity) -> bool {
} }
pub fn delete_selection(world: &mut World, selected: &SelectedEntities) { pub fn delete_selection(world: &mut World, selected: &SelectedEntities) {
let entities: Vec<_> = selected_level_entities(world, selected) let entities = selected_level_entities(world, selected);
.into_iter()
.filter(|entity| is_mutable_level_object(world, *entity))
.collect();
delete_entities_with_history(world, &entities); delete_entities_with_history(world, &entities);
} }
pub fn duplicate_selection(world: &mut World, selected: &SelectedEntities) { pub fn duplicate_selection(world: &mut World, selected: &SelectedEntities) {
let entities: Vec<_> = selected_level_entities(world, selected) let entities = selected_level_entities(world, selected);
.into_iter()
.filter(|entity| is_mutable_level_object(world, *entity))
.collect();
duplicate_entities_with_history(world, &entities); duplicate_entities_with_history(world, &entities);
} }
@ -92,7 +67,7 @@ pub fn reset_selected_transforms(world: &mut World) {
.resource::<UiState>() .resource::<UiState>()
.selected_entities .selected_entities
.iter() .iter()
.filter(|entity| is_mutable_level_object(world, *entity)) .filter(|entity| is_level_object(world, *entity))
.filter_map(|entity| { .filter_map(|entity| {
world world
.get::<Transform>(entity) .get::<Transform>(entity)

View File

@ -3,15 +3,13 @@
use bevy::prelude::*; use bevy::prelude::*;
use bevy_egui::egui; use bevy_egui::egui;
use bevy_inspector_egui::bevy_inspector::hierarchy::SelectedEntities; use bevy_inspector_egui::bevy_inspector::hierarchy::SelectedEntities;
use egui_phosphor_icons::icons;
use crate::history::EditorHistory; use crate::history::EditorHistory;
use crate::operators::ActiveOperator; use crate::operators::ActiveOperator;
use crate::scene_io::SceneIo; use crate::scene_io::SceneIo;
use crate::state::{EditorMode, PlayPaused}; use crate::state::{EditorMode, PlayPaused};
use super::theme::{status_bar_frame, ACCENT, SELECTION, SUCCESS, TEXT, TEXT_DIM, TEXT_MUTED}; use super::theme::{status_bar_frame, TEXT};
use super::widgets::phosphor_icon;
pub fn status_bar_ui( pub fn status_bar_ui(
world: &World, world: &World,
@ -20,10 +18,10 @@ pub fn status_bar_ui(
mode: EditorMode, mode: EditorMode,
) { ) {
egui::Panel::bottom("editor_status_bar") egui::Panel::bottom("editor_status_bar")
.exact_size(27.0) .exact_size(24.0)
.frame(status_bar_frame()) .frame(status_bar_frame())
.show_inside(root_ui, |ui| { .show_inside(root_ui, |ui| {
ui.spacing_mut().item_spacing.x = 10.0; ui.spacing_mut().item_spacing.x = 12.0;
egui::containers::Sides::new() egui::containers::Sides::new()
.shrink_left() .shrink_left()
.truncate() .truncate()
@ -31,41 +29,23 @@ pub fn status_bar_ui(
ui, ui,
|ui| { |ui| {
let status = primary_status_line(world); let status = primary_status_line(world);
ui.label(
egui::RichText::new("")
.color(if world.resource::<SceneIo>().dirty {
ACCENT
} else {
SUCCESS
})
.small(),
);
ui.add( ui.add(
egui::Label::new(egui::RichText::new(&status).color(TEXT_DIM)) egui::Label::new(egui::RichText::new(&status).color(TEXT)).truncate(),
.truncate(),
) )
.on_hover_text(status); .on_hover_text(status);
}, },
|ui| { |ui| {
let (mode_icon, mode_label, mode_color) = match mode { let mode_label = match mode {
EditorMode::Editing => (icons::PENCIL_SIMPLE, "EDIT", ACCENT), EditorMode::Editing => "Edit".to_string(),
EditorMode::Playing => { EditorMode::Playing => {
if world.resource::<PlayPaused>().0 { if world.resource::<PlayPaused>().0 {
(icons::PAUSE, "PAUSED", ACCENT) "Play (paused)".to_string()
} else { } else {
(icons::PLAY, "PLAYING", SUCCESS) "Play".to_string()
} }
} }
}; };
ui.label(phosphor_icon(mode_icon, 13.0).color(mode_color)); ui.label(egui::RichText::new(mode_label).color(TEXT));
ui.label(
egui::RichText::new(mode_label)
.color(mode_color)
.strong()
.small(),
);
ui.separator();
let count = selected.len(); let count = selected.len();
let selection = match count { let selection = match count {
@ -73,15 +53,11 @@ pub fn status_bar_ui(
1 => "1 selected".to_string(), 1 => "1 selected".to_string(),
_ => format!("{count} selected"), _ => format!("{count} selected"),
}; };
ui.label(egui::RichText::new(selection).color(if count == 0 { ui.label(egui::RichText::new(selection).color(TEXT));
TEXT_MUTED
} else {
SELECTION
}));
ui.label( ui.label(
egui::RichText::new(world.resource::<EditorHistory>().status.clone()) egui::RichText::new(world.resource::<EditorHistory>().status.clone())
.color(TEXT_DIM), .color(TEXT),
); );
if let Some(operator_label) = world.resource::<ActiveOperator>().label() { if let Some(operator_label) = world.resource::<ActiveOperator>().label() {
@ -100,12 +76,7 @@ pub fn status_bar_ui(
fn scene_line(scene_io: &SceneIo) -> String { fn scene_line(scene_io: &SceneIo) -> String {
let dirty = if scene_io.dirty { " *" } else { "" }; let dirty = if scene_io.dirty { " *" } else { "" };
let recovery = if scene_io.recovery_snapshot.is_some() { format!("Scene: {}{dirty}", scene_io.active_path_label())
" [recovery]"
} else {
""
};
format!("Scene: {}{dirty}{recovery}", scene_io.active_path_label())
} }
fn primary_status_line(world: &World) -> String { fn primary_status_line(world: &World) -> String {
@ -124,8 +95,5 @@ mod tests {
scene_io.mark_dirty(); scene_io.mark_dirty();
assert!(scene_line(&scene_io).ends_with('*')); assert!(scene_line(&scene_io).ends_with('*'));
scene_io.recovery_snapshot = Some("recovery.scn.ron".into());
assert!(scene_line(&scene_io).ends_with("[recovery]"));
} }
} }

View File

@ -1,103 +1,65 @@
//! Blacksite editor visual system for egui and egui_dock chrome. //! Unity-inspired dark pro theme for editor egui + egui_dock chrome.
use bevy_egui::egui::{self, Color32, CornerRadius, FontId, Stroke, Visuals}; use bevy_egui::egui::{self, Color32, CornerRadius, FontId, Stroke, Visuals};
use egui_dock::{Style as DockStyle, TabInteractionStyle, TabStyle}; use egui_dock::{Style as DockStyle, TabInteractionStyle, TabStyle};
pub const CANVAS_BG: Color32 = Color32::from_rgb(7, 8, 10); pub const PANEL_BG: Color32 = Color32::from_rgb(32, 33, 36);
pub const PANEL_BG: Color32 = Color32::from_rgb(13, 15, 18); pub const PANEL_BG_DARK: Color32 = Color32::from_rgb(24, 25, 28);
pub const PANEL_BG_DARK: Color32 = Color32::from_rgb(9, 11, 13); pub const WIDGET_BG: Color32 = Color32::from_rgb(42, 43, 47);
pub const WIDGET_BG: Color32 = Color32::from_rgb(21, 24, 28); pub const ELEVATED_BG: Color32 = Color32::from_rgb(55, 57, 63);
pub const ELEVATED_BG: Color32 = Color32::from_rgb(31, 35, 40); pub const ACCENT: Color32 = Color32::from_rgb(56, 121, 184);
pub const ACCENT: Color32 = Color32::from_rgb(229, 164, 51); pub const ACCENT_HOVER: Color32 = Color32::from_rgb(72, 143, 211);
pub const ACCENT_HOVER: Color32 = Color32::from_rgb(249, 193, 79); pub const SELECTION_BG: Color32 = Color32::from_rgb(46, 103, 158);
pub const SELECTION: Color32 = Color32::from_rgb(65, 198, 207); pub const SELECTION_BG_MUTED: Color32 = Color32::from_rgb(43, 75, 108);
pub const SELECTION_SECONDARY: Color32 = Color32::from_rgb(84, 139, 155); pub const TEXT: Color32 = Color32::from_rgb(226, 229, 234);
pub const SELECTION_BG: Color32 = Color32::from_rgb(22, 72, 78); pub const TEXT_SELECTED: Color32 = Color32::from_rgb(250, 253, 255);
pub const SELECTION_BG_MUTED: Color32 = Color32::from_rgb(23, 48, 54); pub const TEXT_DIM: Color32 = Color32::from_rgb(170, 176, 184);
pub const TEXT: Color32 = Color32::from_rgb(220, 225, 229); pub const TEXT_MUTED: Color32 = Color32::from_rgb(126, 133, 143);
pub const TEXT_SELECTED: Color32 = Color32::from_rgb(250, 251, 252); pub const BORDER: Color32 = Color32::from_rgb(69, 72, 78);
pub const TEXT_DIM: Color32 = Color32::from_rgb(155, 163, 171); pub const VIEWPORT_OVERLAY: Color32 = Color32::from_rgba_premultiplied(26, 27, 30, 220);
pub const TEXT_MUTED: Color32 = Color32::from_rgb(96, 105, 114);
pub const BORDER: Color32 = Color32::from_rgb(38, 43, 49);
pub const BORDER_STRONG: Color32 = Color32::from_rgb(64, 71, 79);
pub const SUCCESS: Color32 = Color32::from_rgb(84, 190, 127);
pub const WARNING: Color32 = Color32::from_rgb(235, 180, 72);
pub const ERROR: Color32 = Color32::from_rgb(226, 94, 94);
pub const AXIS_X: Color32 = Color32::from_rgb(232, 83, 105);
pub const AXIS_Y: Color32 = Color32::from_rgb(91, 201, 130);
pub const AXIS_Z: Color32 = Color32::from_rgb(77, 147, 235);
pub const VIEWPORT_OVERLAY: Color32 = Color32::from_rgba_premultiplied(7, 9, 11, 240);
pub fn apply_editor_theme(ctx: &egui::Context) { pub fn apply_editor_theme(ctx: &egui::Context) {
let mut visuals = Visuals::dark(); let mut visuals = Visuals::dark();
visuals.panel_fill = PANEL_BG; visuals.panel_fill = PANEL_BG;
visuals.window_fill = WIDGET_BG; visuals.window_fill = WIDGET_BG;
visuals.extreme_bg_color = CANVAS_BG; visuals.extreme_bg_color = PANEL_BG_DARK;
visuals.faint_bg_color = Color32::from_rgb(17, 19, 22); visuals.faint_bg_color = Color32::from_rgb(38, 40, 44);
visuals.code_bg_color = PANEL_BG_DARK; visuals.code_bg_color = PANEL_BG_DARK;
visuals.text_edit_bg_color = Some(CANVAS_BG); visuals.text_edit_bg_color = Some(PANEL_BG_DARK);
visuals.weak_text_color = Some(TEXT_MUTED); visuals.weak_text_color = Some(TEXT_MUTED);
visuals.widgets.noninteractive.bg_fill = PANEL_BG; visuals.widgets.noninteractive.bg_fill = WIDGET_BG;
visuals.widgets.noninteractive.bg_stroke = Stroke::new(1.0, BORDER); visuals.widgets.noninteractive.fg_stroke = Stroke::new(1.0, TEXT);
visuals.widgets.noninteractive.fg_stroke = Stroke::new(1.0, TEXT_DIM);
visuals.widgets.inactive.bg_fill = WIDGET_BG; visuals.widgets.inactive.bg_fill = WIDGET_BG;
visuals.widgets.inactive.weak_bg_fill = Color32::from_rgb(24, 27, 31); visuals.widgets.inactive.weak_bg_fill = Color32::from_rgb(47, 49, 54);
visuals.widgets.inactive.bg_stroke = Stroke::new(1.0, BORDER); visuals.widgets.inactive.bg_stroke = Stroke::new(1.0, BORDER);
visuals.widgets.inactive.fg_stroke = Stroke::new(1.0, TEXT); visuals.widgets.inactive.fg_stroke = Stroke::new(1.0, TEXT);
visuals.widgets.hovered.bg_fill = ELEVATED_BG; visuals.widgets.hovered.bg_fill = ELEVATED_BG;
visuals.widgets.hovered.weak_bg_fill = ELEVATED_BG; visuals.widgets.hovered.weak_bg_fill = ELEVATED_BG;
visuals.widgets.hovered.bg_stroke = Stroke::new(1.0, BORDER_STRONG); visuals.widgets.hovered.bg_stroke = Stroke::new(1.0, ACCENT_HOVER);
visuals.widgets.hovered.fg_stroke = Stroke::new(1.0, TEXT_SELECTED); visuals.widgets.hovered.fg_stroke = Stroke::new(1.0, TEXT_SELECTED);
visuals.widgets.active.bg_fill = Color32::from_rgb(38, 42, 47); visuals.widgets.active.bg_fill = SELECTION_BG;
visuals.widgets.active.weak_bg_fill = Color32::from_rgb(38, 42, 47); visuals.widgets.active.weak_bg_fill = SELECTION_BG;
visuals.widgets.active.bg_stroke = Stroke::new(1.0, ACCENT); visuals.widgets.active.bg_stroke = Stroke::new(1.0, ACCENT_HOVER);
visuals.widgets.active.fg_stroke = Stroke::new(1.0, TEXT_SELECTED); visuals.widgets.active.fg_stroke = Stroke::new(1.0, TEXT_SELECTED);
visuals.widgets.open.bg_fill = ELEVATED_BG; visuals.widgets.open.bg_fill = ELEVATED_BG;
visuals.widgets.open.weak_bg_fill = ELEVATED_BG; visuals.widgets.open.weak_bg_fill = ELEVATED_BG;
visuals.widgets.open.bg_stroke = Stroke::new(1.0, BORDER_STRONG);
visuals.widgets.open.fg_stroke = Stroke::new(1.0, TEXT_SELECTED); visuals.widgets.open.fg_stroke = Stroke::new(1.0, TEXT_SELECTED);
visuals.selection.bg_fill = SELECTION_BG; visuals.selection.bg_fill = SELECTION_BG;
visuals.selection.stroke = Stroke::new(1.0, SELECTION); visuals.selection.stroke = Stroke::new(1.0, TEXT_SELECTED);
visuals.hyperlink_color = SELECTION; visuals.hyperlink_color = Color32::from_rgb(132, 186, 247);
visuals.warn_fg_color = WARNING; visuals.warn_fg_color = Color32::from_rgb(255, 200, 80);
visuals.error_fg_color = ERROR; visuals.error_fg_color = Color32::from_rgb(255, 100, 100);
visuals.window_stroke = Stroke::new(1.0, BORDER); visuals.window_stroke = Stroke::new(1.0, BORDER);
visuals.window_corner_radius = CornerRadius::same(4); visuals.window_corner_radius = CornerRadius::same(4);
visuals.menu_corner_radius = CornerRadius::same(3);
visuals.window_shadow = egui::epaint::Shadow {
offset: [0, 8],
blur: 28,
spread: 2,
color: Color32::from_black_alpha(190),
};
visuals.popup_shadow = egui::epaint::Shadow {
offset: [0, 5],
blur: 18,
spread: 1,
color: Color32::from_black_alpha(205),
};
visuals.button_frame = true;
visuals.collapsing_header_frame = false;
visuals.indent_has_left_vline = true;
visuals.striped = true;
visuals.slider_trailing_fill = true;
visuals.interact_cursor = Some(egui::CursorIcon::PointingHand);
let mut style = (*ctx.global_style()).clone(); let mut style = (*ctx.global_style()).clone();
style.visuals = visuals; style.visuals = visuals;
style.spacing.item_spacing = egui::vec2(8.0, 7.0); style.spacing.item_spacing = egui::vec2(8.0, 6.0);
style.spacing.button_padding = egui::vec2(10.0, 5.0); style.spacing.button_padding = egui::vec2(8.0, 4.0);
style.spacing.interact_size = egui::vec2(40.0, 28.0); style.spacing.indent = 16.0;
style.spacing.window_margin = egui::Margin::same(10);
style.spacing.menu_margin = egui::Margin::same(8);
style.spacing.indent = 17.0;
style.interaction.tooltip_delay = 0.32;
style.interaction.tooltip_grace_time = 0.18;
style.animation_time = 0.12;
style.compact_menu_style = true;
style.text_styles.insert( style.text_styles.insert(
egui::TextStyle::Body, egui::TextStyle::Body,
FontId::new(14.0, egui::FontFamily::Proportional), FontId::new(13.0, egui::FontFamily::Proportional),
); );
style.text_styles.insert( style.text_styles.insert(
egui::TextStyle::Button, egui::TextStyle::Button,
@ -105,49 +67,42 @@ pub fn apply_editor_theme(ctx: &egui::Context) {
); );
style.text_styles.insert( style.text_styles.insert(
egui::TextStyle::Heading, egui::TextStyle::Heading,
FontId::new(16.0, egui::FontFamily::Proportional), FontId::new(15.0, egui::FontFamily::Proportional),
);
style.text_styles.insert(
egui::TextStyle::Small,
FontId::new(12.0, egui::FontFamily::Proportional),
); );
ctx.set_global_style(style); ctx.set_global_style(style);
} }
pub fn editor_dock_style(ctx: &egui::Context) -> DockStyle { pub fn editor_dock_style(ctx: &egui::Context) -> DockStyle {
let mut style = DockStyle::from_egui(ctx.global_style().as_ref()); let mut style = DockStyle::from_egui(ctx.global_style().as_ref());
style.dock_area_padding = Some(egui::Margin::same(1)); style.dock_area_padding = Some(egui::Margin::same(2));
style.main_surface_border_stroke = Stroke::new(1.0, BORDER); style.main_surface_border_stroke = Stroke::new(1.0, BORDER);
style.main_surface_border_rounding = CornerRadius::ZERO;
style.separator.width = 2.0; style.separator.width = 2.0;
style.separator.extra_interact_width = 5.0;
style.separator.color_idle = BORDER; style.separator.color_idle = BORDER;
style.separator.color_hovered = BORDER_STRONG; style.separator.color_hovered = ELEVATED_BG;
style.separator.color_dragged = ACCENT_HOVER; style.separator.color_dragged = ACCENT_HOVER;
style.tab_bar.bg_fill = PANEL_BG_DARK; style.tab_bar.bg_fill = PANEL_BG_DARK;
style.tab_bar.height = 30.0; style.tab_bar.height = 26.0;
style.tab_bar.inner_margin = egui::Margin::symmetric(2, 1); style.tab_bar.hline_color = BORDER;
style.tab_bar.hline_color = ACCENT;
style.tab_bar.corner_radius = CornerRadius::ZERO; style.tab_bar.corner_radius = CornerRadius::ZERO;
let active = TabInteractionStyle { let active = TabInteractionStyle {
outline_color: ACCENT, outline_color: BORDER,
bg_fill: PANEL_BG, bg_fill: PANEL_BG,
text_color: TEXT_SELECTED, text_color: TEXT,
corner_radius: CornerRadius::same(1), corner_radius: CornerRadius::same(3),
}; };
let inactive = TabInteractionStyle { let inactive = TabInteractionStyle {
outline_color: Color32::TRANSPARENT, outline_color: Color32::TRANSPARENT,
bg_fill: Color32::TRANSPARENT, bg_fill: WIDGET_BG,
text_color: TEXT_DIM, text_color: TEXT_DIM,
corner_radius: CornerRadius::same(1), corner_radius: CornerRadius::same(3),
}; };
let hovered = TabInteractionStyle { let hovered = TabInteractionStyle {
outline_color: BORDER_STRONG, outline_color: BORDER,
bg_fill: ELEVATED_BG, bg_fill: ELEVATED_BG,
text_color: TEXT, text_color: TEXT,
corner_radius: CornerRadius::same(1), corner_radius: CornerRadius::same(3),
}; };
style.tab = TabStyle { style.tab = TabStyle {
@ -158,16 +113,8 @@ pub fn editor_dock_style(ctx: &egui::Context) -> DockStyle {
inactive_with_kb_focus: inactive.clone(), inactive_with_kb_focus: inactive.clone(),
active_with_kb_focus: active.clone(), active_with_kb_focus: active.clone(),
focused_with_kb_focus: active, focused_with_kb_focus: active,
hline_below_active_tab_name: true,
spacing: 2.0,
..style.tab ..style.tab
}; };
style.tab.tab_body.inner_margin = egui::Margin::same(4);
style.tab.tab_body.bg_fill = PANEL_BG;
style.tab.tab_body.stroke = Stroke::new(1.0, BORDER);
style.overlay.selection_color = SELECTION_BG;
style.overlay.button_color = SELECTION;
style.overlay.button_border_stroke = Stroke::new(1.0, SELECTION);
style style
} }
@ -178,45 +125,14 @@ pub fn panel_heading(text: &str) -> egui::RichText {
pub fn status_bar_frame() -> egui::Frame { pub fn status_bar_frame() -> egui::Frame {
egui::Frame::new() egui::Frame::new()
.fill(PANEL_BG_DARK) .fill(Color32::from_rgb(0, 122, 204))
.inner_margin(egui::Margin::symmetric(10, 3)) .inner_margin(egui::Margin::symmetric(8, 2))
.stroke(Stroke::new(1.0, BORDER))
}
pub fn brand_mark_frame() -> egui::Frame {
egui::Frame::new()
.fill(CANVAS_BG)
.corner_radius(CornerRadius::same(2))
.inner_margin(egui::Margin::symmetric(8, 4))
.stroke(Stroke::new(1.0, BORDER_STRONG))
} }
pub fn viewport_toolbar_frame() -> egui::Frame { pub fn viewport_toolbar_frame() -> egui::Frame {
egui::Frame::new() egui::Frame::new()
.fill(VIEWPORT_OVERLAY) .fill(VIEWPORT_OVERLAY)
.corner_radius(CornerRadius::same(3)) .corner_radius(CornerRadius::same(4))
.inner_margin(egui::Margin::same(5)) .inner_margin(egui::Margin::same(4))
.stroke(Stroke::new(1.0, BORDER_STRONG))
.shadow(egui::epaint::Shadow {
offset: [0, 3],
blur: 12,
spread: 0,
color: Color32::from_black_alpha(110),
})
}
pub fn overlay_chip_frame() -> egui::Frame {
egui::Frame::new()
.fill(VIEWPORT_OVERLAY)
.corner_radius(CornerRadius::same(3))
.inner_margin(egui::Margin::symmetric(8, 5))
.stroke(Stroke::new(1.0, BORDER_STRONG))
}
pub fn toolbar_group_frame() -> egui::Frame {
egui::Frame::new()
.fill(PANEL_BG_DARK)
.corner_radius(CornerRadius::same(3))
.inner_margin(egui::Margin::symmetric(5, 3))
.stroke(Stroke::new(1.0, BORDER)) .stroke(Stroke::new(1.0, BORDER))
} }

View File

@ -2,7 +2,6 @@
use bevy::prelude::*; use bevy::prelude::*;
use bevy_egui::egui; use bevy_egui::egui;
use bevy_inspector_egui::bevy_inspector::hierarchy::SelectedEntities;
use egui_phosphor_icons::icons; use egui_phosphor_icons::icons;
use crate::history::spawn_with_history; use crate::history::spawn_with_history;
@ -16,46 +15,35 @@ use shared::PrimitiveShape;
use super::helpers::{ use super::helpers::{
light_snapshot, primitive_snapshot, toggle_play_mode, toggle_play_paused, toggle_possession, light_snapshot, primitive_snapshot, toggle_play_mode, toggle_play_paused, toggle_possession,
}; };
use super::theme::{brand_mark_frame, toolbar_group_frame, ACCENT, TEXT, TEXT_MUTED}; use super::widgets::{icon_button, panel_toolbar_row, toolbar_separator, transport_button_large};
use super::widgets::{
icon_button, panel_toolbar_row, phosphor_icon, toolbar_separator, transport_button_large,
};
/// Toolbar strip height (menu bar is separate). /// Toolbar strip height (menu bar is separate).
const TOOLBAR_HEIGHT: f32 = 50.0; const TOOLBAR_HEIGHT: f32 = 56.0;
const TRANSPORT_WIDTH: f32 = 150.0; const TRANSPORT_WIDTH: f32 = 152.0;
const TRANSPORT_SLOT_STEP: f32 = 50.0; const TRANSPORT_SLOT_STEP: f32 = 54.0;
pub fn editor_toolbar_panel( pub fn editor_toolbar_panel(world: &mut World, root_ui: &mut egui::Ui) {
world: &mut World,
root_ui: &mut egui::Ui,
selected: &mut SelectedEntities,
) {
egui::Panel::top("editor_toolbar") egui::Panel::top("editor_toolbar")
.exact_size(TOOLBAR_HEIGHT) .exact_size(TOOLBAR_HEIGHT)
.show_inside(root_ui, |ui| { .show_inside(root_ui, |ui| {
toolbar_ui(world, ui, selected); toolbar_ui(world, ui);
}); });
} }
pub fn toolbar_ui(world: &mut World, ui: &mut egui::Ui, selected: &mut SelectedEntities) { pub fn toolbar_ui(world: &mut World, ui: &mut egui::Ui) {
let (toolbar_rect, _) = ui.allocate_exact_size( let (toolbar_rect, _) = ui.allocate_exact_size(
egui::vec2(ui.available_width(), TOOLBAR_HEIGHT - 2.0), egui::vec2(ui.available_width(), TOOLBAR_HEIGHT - 4.0),
egui::Sense::hover(), egui::Sense::hover(),
); );
let center_y = toolbar_rect.center().y; let center_y = toolbar_rect.center().y;
let left_rect = egui::Rect::from_min_max( let left_rect = egui::Rect::from_min_max(
egui::pos2(toolbar_rect.left() + 8.0, center_y - 20.0), egui::pos2(toolbar_rect.left() + 8.0, center_y - 18.0),
egui::pos2(toolbar_rect.right(), center_y + 20.0), egui::pos2(toolbar_rect.right(), center_y + 18.0),
); );
let transport_rect = egui::Rect::from_center_size( let transport_rect = egui::Rect::from_center_size(
egui::pos2(toolbar_rect.center().x, center_y), egui::pos2(toolbar_rect.center().x, center_y),
egui::vec2(TRANSPORT_WIDTH, 46.0), egui::vec2(TRANSPORT_WIDTH, 44.0),
);
let right_rect = egui::Rect::from_min_max(
egui::pos2(transport_rect.right() + 18.0, center_y - 20.0),
egui::pos2(toolbar_rect.right() - 8.0, center_y + 20.0),
); );
let mut left_ui = ui.new_child( let mut left_ui = ui.new_child(
@ -74,89 +62,42 @@ pub fn toolbar_ui(world: &mut World, ui: &mut egui::Ui, selected: &mut SelectedE
.layout(egui::Layout::left_to_right(egui::Align::Center)), .layout(egui::Layout::left_to_right(egui::Align::Center)),
); );
transport_ui.spacing_mut().item_spacing.x = 10.0; transport_ui.spacing_mut().item_spacing.x = 10.0;
toolbar_group_frame().show(&mut transport_ui, |ui| { transport_controls(world, &mut transport_ui);
transport_controls(world, ui);
});
let mut right_ui = ui.new_child(
egui::UiBuilder::new()
.id_salt("toolbar_scene_documents")
.max_rect(right_rect)
.layout(egui::Layout::right_to_left(egui::Align::Center)),
);
let editing = *world.resource::<State<EditorMode>>().get() == EditorMode::Editing;
super::scene_tabs::scene_tabs_chrome(world, &mut right_ui, selected, editing);
ui.painter().line_segment(
[toolbar_rect.left_bottom(), toolbar_rect.right_bottom()],
egui::Stroke::new(1.0, super::theme::BORDER),
);
ui.painter().line_segment(
[
toolbar_rect.left_bottom() + egui::vec2(8.0, 0.0),
toolbar_rect.left_bottom() + egui::vec2(134.0, 0.0),
],
egui::Stroke::new(2.0, ACCENT),
);
} }
fn left_toolbar(world: &mut World, ui: &mut egui::Ui) { fn left_toolbar(world: &mut World, ui: &mut egui::Ui) {
brand_mark_frame().show(ui, |ui| { panel_toolbar_row(ui, |ui| {
ui.set_min_width(118.0); if icon_button(ui, icons::CUBE, "Spawn cube").clicked() {
ui.set_min_height(30.0); spawn_primitive_at_camera(world, PrimitiveShape::Box, "Cube");
ui.horizontal(|ui| { }
ui.label(phosphor_icon(icons::CROSSHAIR, 19.0).color(ACCENT)); if icon_button(ui, icons::MOUNTAINS, "Spawn ramp").clicked() {
ui.vertical(|ui| { spawn_primitive_at_camera(world, PrimitiveShape::Ramp, "Ramp");
ui.spacing_mut().item_spacing.y = 0.0; }
ui.label( if icon_button(ui, icons::SPHERE, "Spawn sphere").clicked() {
egui::RichText::new("BLACKSITE") spawn_primitive_at_camera(world, PrimitiveShape::Sphere, "Sphere");
.size(11.0) }
.strong() if icon_button(ui, icons::LIGHTBULB, "Spawn point light").clicked() {
.color(TEXT), let pos = camera_ground_spawn(world);
); spawn_with_history(world, light_snapshot("Point Light", pos));
ui.label( }
egui::RichText::new("EDITOR // DEV") if icon_button(ui, icons::PENCIL_SIMPLE_LINE, "Draw brush (B)").clicked() {
.size(9.0) start_draw_brush_tool(world);
.color(TEXT_MUTED), }
);
});
});
});
toolbar_group_frame().show(ui, |ui| { toolbar_separator(ui);
panel_toolbar_row(ui, |ui| {
if icon_button(ui, icons::CUBE, "Spawn cube").clicked() {
spawn_primitive_at_camera(world, PrimitiveShape::Box, "Cube");
}
if icon_button(ui, icons::MOUNTAINS, "Spawn ramp").clicked() {
spawn_primitive_at_camera(world, PrimitiveShape::Ramp, "Ramp");
}
if icon_button(ui, icons::SPHERE, "Spawn sphere").clicked() {
spawn_primitive_at_camera(world, PrimitiveShape::Sphere, "Sphere");
}
if icon_button(ui, icons::LIGHTBULB, "Spawn point light").clicked() {
let pos = camera_ground_spawn(world);
spawn_with_history(world, light_snapshot("Point Light", pos));
}
if icon_button(ui, icons::PENCIL_SIMPLE_LINE, "Draw brush (B)").clicked() {
start_draw_brush_tool(world);
}
toolbar_separator(ui); if icon_button(ui, icons::FILE, "New scene").clicked() {
world.resource_mut::<SceneIo>().request = Some(SceneIoRequest::New);
if icon_button(ui, icons::FILE, "New scene").clicked() { }
world.resource_mut::<SceneIo>().request = Some(SceneIoRequest::New); if icon_button(ui, icons::FOLDER_OPEN, "Open scene").clicked() {
} world.resource_mut::<SceneIo>().request = Some(SceneIoRequest::Open);
if icon_button(ui, icons::FOLDER_OPEN, "Open scene").clicked() { }
world.resource_mut::<SceneIo>().request = Some(SceneIoRequest::Open); if icon_button(ui, icons::FLOPPY_DISK, "Save scene").clicked() {
} world.resource_mut::<SceneIo>().request = Some(SceneIoRequest::Save);
if icon_button(ui, icons::FLOPPY_DISK, "Save scene").clicked() { }
world.resource_mut::<SceneIo>().request = Some(SceneIoRequest::Save); if icon_button(ui, icons::COPY, "Save scene as").clicked() {
} world.resource_mut::<SceneIo>().request = Some(SceneIoRequest::SaveAs);
if icon_button(ui, icons::COPY, "Save scene as").clicked() { }
world.resource_mut::<SceneIo>().request = Some(SceneIoRequest::SaveAs);
}
});
}); });
} }
@ -165,9 +106,11 @@ fn transport_controls(world: &mut World, ui: &mut egui::Ui) {
match mode { match mode {
EditorMode::Editing => { EditorMode::Editing => {
ui.add_space(TRANSPORT_SLOT_STEP); ui.add_space(TRANSPORT_SLOT_STEP);
let play_clicked = let play_clicked = ui
transport_button_large(ui, "transport_center", icons::PLAY, "Play (F5)", true) .push_id("transport_center", |ui| {
.clicked(); transport_button_large(ui, icons::PLAY, "Play (F5)", true).clicked()
})
.inner;
if play_clicked { if play_clicked {
toggle_play_mode(world); toggle_play_mode(world);
} }
@ -180,20 +123,20 @@ fn transport_controls(world: &mut World, ui: &mut egui::Ui) {
} else { } else {
"Pause simulation (F6)" "Pause simulation (F6)"
}; };
let pause_clicked = let pause_clicked = ui
transport_button_large(ui, "transport_left", pause_icon, pause_tip, false) .push_id("transport_left", |ui| {
.clicked(); transport_button_large(ui, pause_icon, pause_tip, false).clicked()
})
.inner;
if pause_clicked { if pause_clicked {
toggle_play_paused(world); toggle_play_paused(world);
} }
let stop_clicked = transport_button_large( let stop_clicked = ui
ui, .push_id("transport_center", |ui| {
"transport_center", transport_button_large(ui, icons::STOP, "Stop and return to Edit (F5)", false)
icons::STOP, .clicked()
"Stop and return to Edit (F5)", })
false, .inner;
)
.clicked();
if stop_clicked { if stop_clicked {
toggle_play_mode(world); toggle_play_mode(world);
} }
@ -203,9 +146,11 @@ fn transport_controls(world: &mut World, ui: &mut egui::Ui) {
PlayPossession::Possessed => (icons::SIGN_OUT, "Eject from player (F8)"), PlayPossession::Possessed => (icons::SIGN_OUT, "Eject from player (F8)"),
PlayPossession::Ejected => (icons::USER, "Possess player (F8)"), PlayPossession::Ejected => (icons::USER, "Possess player (F8)"),
}; };
let possession_clicked = let possession_clicked = ui
transport_button_large(ui, "transport_right", eject_icon, eject_tip, false) .push_id("transport_right", |ui| {
.clicked(); transport_button_large(ui, eject_icon, eject_tip, false).clicked()
})
.inner;
if possession_clicked { if possession_clicked {
toggle_possession(world); toggle_possession(world);
} }

View File

@ -7,15 +7,12 @@ use egui_phosphor_icons::icons;
use transform_gizmo_bevy::prelude::GizmoOptions; use transform_gizmo_bevy::prelude::GizmoOptions;
use crate::assets::operators::{ use crate::assets::operators::{
apply_material_operator, apply_texture_operator, assign_animation_clip_operator, apply_material_operator, apply_texture_operator, place_asset_operator, place_subasset_operator,
assign_audio_clip_operator, place_asset_operator, place_subasset_operator,
}; };
use crate::assets::thumbnails::kind_icon;
use crate::assets::{AssetSelection, AssetSubAssetKind, EditorAssets}; use crate::assets::{AssetSelection, AssetSubAssetKind, EditorAssets};
use crate::camera::EditorCamera;
use crate::gizmos::{EditorGizmoMode, EditorGizmoSpace}; use crate::gizmos::{EditorGizmoMode, EditorGizmoSpace};
use crate::render_target::ViewportRenderTarget; use crate::render_target::ViewportRenderTarget;
use crate::selection::{ViewportClick, ViewportPickStack}; use crate::selection::ViewportClick;
use crate::state::PlayPossession; use crate::state::PlayPossession;
use crate::viewport::actor_icons::ActorIconSettings; use crate::viewport::actor_icons::ActorIconSettings;
use crate::viewport::brush_edit::{BrushEditMode, BrushElementSelection}; use crate::viewport::brush_edit::{BrushEditMode, BrushElementSelection};
@ -28,13 +25,8 @@ use crate::viewport::{
use super::helpers::{ use super::helpers::{
focus_editor_camera_on_selection, viewport_recall_bookmark, viewport_save_bookmark, focus_editor_camera_on_selection, viewport_recall_bookmark, viewport_save_bookmark,
}; };
use super::theme::{ use super::theme::viewport_toolbar_frame;
overlay_chip_frame, viewport_toolbar_frame, ACCENT, ACCENT_HOVER, AXIS_X, AXIS_Y, AXIS_Z, use super::widgets::{icon_button_small, panel_toolbar_row, tool_button};
BORDER, BORDER_STRONG, ELEVATED_BG, SELECTION, SUCCESS, TEXT, TEXT_DIM, TEXT_MUTED, WARNING,
};
use super::widgets::{
icon_button_small, panel_toolbar_row, phosphor_icon, tool_button, tool_button_accent,
};
#[derive(Resource, Default)] #[derive(Resource, Default)]
pub struct ViewportUiState { pub struct ViewportUiState {
@ -105,19 +97,6 @@ pub fn viewport_tab_ui(
egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)), egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)),
egui::Color32::WHITE, egui::Color32::WHITE,
); );
painter.rect_stroke(
rect,
0.0,
egui::Stroke::new(
1.0,
if response.hovered() {
BORDER_STRONG
} else {
BORDER
},
),
egui::StrokeKind::Inside,
);
} else { } else {
ui.allocate_rect(rect, egui::Sense::hover()); ui.allocate_rect(rect, egui::Sense::hover());
ui.scope_builder(egui::UiBuilder::new().max_rect(rect), |ui| { ui.scope_builder(egui::UiBuilder::new().max_rect(rect), |ui| {
@ -135,49 +114,35 @@ pub fn viewport_tab_ui(
} }
scene_view_overlay_toolbar(world, ui.ctx(), rect); scene_view_overlay_toolbar(world, ui.ctx(), rect);
scene_view_selection_hud(world, ui.ctx(), rect, selected_entities);
scene_view_brush_draw_hints(world, ui.ctx(), rect); scene_view_brush_draw_hints(world, ui.ctx(), rect);
scene_view_render_badge(world, ui.ctx(), rect); scene_view_mode_badge(world, ui.ctx(), rect);
scene_view_brush_mode_badge(world, ui.ctx(), rect); scene_view_brush_mode_badge(world, ui.ctx(), rect);
scene_view_gi_badge(world, ui.ctx(), rect);
scene_view_volume_hud(world, ui.ctx(), rect); scene_view_volume_hud(world, ui.ctx(), rect);
scene_view_orientation_widget(world, ui.ctx(), rect);
viewport_asset_drop_ui(world, ui, rect, selected_entities); viewport_asset_drop_ui(world, ui, rect, selected_entities);
} }
fn scene_view_render_badge(world: &World, ctx: &egui::Context, scene_rect: egui::Rect) { fn scene_view_mode_badge(world: &World, ctx: &egui::Context, scene_rect: egui::Rect) {
let Some(mode) = world.get_resource::<EditorViewportMode>() else { let Some(mode) = world.get_resource::<EditorViewportMode>() else {
return; return;
}; };
let mode = *mode; let mode = *mode;
let (label, color) = match mode { let (label, color) = match mode {
EditorViewportMode::Lit => ("Lit", SUCCESS), EditorViewportMode::Lit => ("Lit", egui::Color32::from_rgb(120, 200, 120)),
EditorViewportMode::Unlit => ("Unlit", WARNING), EditorViewportMode::Unlit => ("Unlit", egui::Color32::from_rgb(200, 200, 120)),
EditorViewportMode::Collider => ("Collider", WARNING), EditorViewportMode::Collider => ("Collider", egui::Color32::from_rgb(220, 140, 80)),
}; };
let profile = world.get_resource::<settings::ActiveCameraRenderProfile>(); egui::Area::new(egui::Id::new("scene_view_mode_badge"))
let (gi_label, gi_color) = match profile.map(|profile| profile.gi_path) { .fixed_pos(scene_rect.right_bottom() + egui::vec2(-72.0, -22.0))
Some(settings::GiPath::SolariDeferred) => ("Solari", ACCENT),
_ => ("Forward", SELECTION),
};
egui::Area::new(egui::Id::new("scene_view_render_badge"))
.fixed_pos(scene_rect.right_bottom() + egui::vec2(-10.0, -10.0))
.pivot(egui::Align2::RIGHT_BOTTOM)
.interactable(false) .interactable(false)
.show(ctx, |ui| { .show(ctx, |ui| {
overlay_chip_frame().show(ui, |ui| { egui::Frame::NONE
ui.horizontal(|ui| { .fill(egui::Color32::from_black_alpha(160))
ui.label(egui::RichText::new(label).color(color).strong().small()); .inner_margin(egui::Margin::symmetric(8, 4))
ui.label(egui::RichText::new("/").color(TEXT_MUTED).small()); .show(ui, |ui| {
ui.label( ui.colored_label(color, label);
egui::RichText::new(gi_label)
.color(gi_color)
.strong()
.small(),
);
}); });
});
}); });
if mode == EditorViewportMode::Collider { if mode == EditorViewportMode::Collider {
@ -185,13 +150,10 @@ fn scene_view_render_badge(world: &World, ctx: &egui::Context, scene_rect: egui:
.fixed_pos(scene_rect.left_bottom() + egui::vec2(8.0, -28.0)) .fixed_pos(scene_rect.left_bottom() + egui::vec2(8.0, -28.0))
.interactable(false) .interactable(false)
.show(ctx, |ui| { .show(ctx, |ui| {
overlay_chip_frame().show(ui, |ui| { ui.colored_label(
ui.label( egui::Color32::from_rgb(255, 180, 100),
egui::RichText::new("Collider view / meshes hidden") "Meshes hidden - switch to Lit (sun icon) for shading",
.color(WARNING) );
.small(),
);
});
}); });
} }
} }
@ -211,39 +173,51 @@ fn scene_view_brush_mode_badge(world: &World, ctx: &egui::Context, scene_rect: e
.fixed_pos(scene_rect.left_top() + egui::vec2(8.0, 52.0)) .fixed_pos(scene_rect.left_top() + egui::vec2(8.0, 52.0))
.interactable(false) .interactable(false)
.show(ctx, |ui| { .show(ctx, |ui| {
overlay_chip_frame().show(ui, |ui| { egui::Frame::new()
ui.horizontal(|ui| { .fill(egui::Color32::from_rgba_unmultiplied(20, 18, 28, 230))
ui.label( .stroke(egui::Stroke::new(
egui::RichText::new(format!("Brush {}", mode.label())) 1.0,
.color(ACCENT) egui::Color32::from_rgba_unmultiplied(210, 140, 255, 210),
.strong(), ))
); .corner_radius(egui::CornerRadius::same(4))
ui.label( .inner_margin(egui::Margin::symmetric(8, 6))
egui::RichText::new(format!("{selected_count} selected")) .show(ui, |ui| {
.color(SELECTION) ui.horizontal(|ui| {
.small(), ui.label(
); egui::RichText::new(format!("Brush {}", mode.label()))
ui.label(egui::RichText::new("Element gizmo").color(SUCCESS).small()); .color(egui::Color32::from_rgb(230, 190, 255))
.strong(),
);
ui.label(
egui::RichText::new(format!("{selected_count} selected"))
.color(egui::Color32::from_rgb(205, 200, 215))
.small(),
);
ui.label(
egui::RichText::new("Element gizmo")
.color(egui::Color32::from_rgb(255, 190, 120))
.small(),
);
});
ui.add_space(4.0);
ui.horizontal_wrapped(|ui| {
match mode {
BrushEditMode::Vertex => key_hint(ui, "LMB", "Select vertex"),
BrushEditMode::Edge => key_hint(ui, "LMB", "Select edge"),
BrushEditMode::Face => key_hint(ui, "LMB", "Select face"),
BrushEditMode::Clip => key_hint(ui, "LMB", "Select face"),
BrushEditMode::Object => {}
}
if !matches!(mode, BrushEditMode::Clip) {
key_hint(ui, "W/E/R", "Move/rotate/scale");
}
if matches!(mode, BrushEditMode::Clip) {
key_hint(ui, "Enter", "Clip");
}
key_hint(ui, "Shift+LMB", "Multi");
key_hint(ui, "Esc", "Object");
});
}); });
ui.add_space(4.0);
ui.horizontal_wrapped(|ui| {
match mode {
BrushEditMode::Vertex => key_hint(ui, "LMB", "Select vertex"),
BrushEditMode::Edge => key_hint(ui, "LMB", "Select edge"),
BrushEditMode::Face => key_hint(ui, "LMB", "Select face"),
BrushEditMode::Clip => key_hint(ui, "LMB", "Select face"),
BrushEditMode::Object => {}
}
if !matches!(mode, BrushEditMode::Clip) {
key_hint(ui, "W/E/R", "Move/rotate/scale");
}
if matches!(mode, BrushEditMode::Clip) {
key_hint(ui, "Enter", "Clip");
}
key_hint(ui, "Shift+LMB", "Multi");
key_hint(ui, "Esc", "Object");
});
});
}); });
} }
@ -263,35 +237,51 @@ fn scene_view_brush_draw_hints(world: &World, ctx: &egui::Context, scene_rect: e
.fixed_pos(scene_rect.left_top() + egui::vec2(8.0, 52.0)) .fixed_pos(scene_rect.left_top() + egui::vec2(8.0, 52.0))
.interactable(false) .interactable(false)
.show(ctx, |ui| { .show(ctx, |ui| {
overlay_chip_frame().show(ui, |ui| { egui::Frame::new()
ui.horizontal(|ui| { .fill(egui::Color32::from_rgba_unmultiplied(18, 22, 26, 220))
ui.label(egui::RichText::new("Draw Brush").color(SELECTION).strong()); .stroke(egui::Stroke::new(
ui.label( 1.0,
egui::RichText::new(format!("{} pts", tool.vertices.len())) egui::Color32::from_rgba_unmultiplied(95, 150, 190, 180),
.color(TEXT_DIM) ))
.small(), .corner_radius(egui::CornerRadius::same(4))
); .inner_margin(egui::Margin::symmetric(8, 6))
ui.label(egui::RichText::new(hint).color(SUCCESS).small()); .show(ui, |ui| {
ui.horizontal(|ui| {
ui.label(
egui::RichText::new("Draw Brush")
.color(egui::Color32::from_rgb(170, 220, 255))
.strong(),
);
ui.label(
egui::RichText::new(format!("{} pts", tool.vertices.len()))
.color(egui::Color32::from_rgb(190, 200, 205))
.small(),
);
ui.label(
egui::RichText::new(hint)
.color(egui::Color32::from_rgb(150, 220, 170))
.small(),
);
});
ui.add_space(4.0);
ui.horizontal_wrapped(|ui| match tool.phase {
BrushToolPhase::Outline => {
key_hint(ui, "LMB", "Point");
key_hint(ui, "Enter", "Height");
key_hint(ui, "Backspace", "Remove");
key_hint(ui, "Esc", "Cancel");
key_hint(ui, "RMB", "Cancel");
}
BrushToolPhase::Height => {
key_hint(ui, "Mouse Up/Down", "Height");
key_hint(ui, "Enter", "Create");
key_hint(ui, "LMB", "Create");
key_hint(ui, "Backspace", "Outline");
key_hint(ui, "Esc", "Cancel");
key_hint(ui, "RMB", "Cancel");
}
});
}); });
ui.add_space(4.0);
ui.horizontal_wrapped(|ui| match tool.phase {
BrushToolPhase::Outline => {
key_hint(ui, "LMB", "Point");
key_hint(ui, "Enter", "Height");
key_hint(ui, "Backspace", "Remove");
key_hint(ui, "Esc", "Cancel");
key_hint(ui, "RMB", "Cancel");
}
BrushToolPhase::Height => {
key_hint(ui, "Mouse Up/Down", "Height");
key_hint(ui, "Enter", "Create");
key_hint(ui, "LMB", "Create");
key_hint(ui, "Backspace", "Outline");
key_hint(ui, "Esc", "Cancel");
key_hint(ui, "RMB", "Cancel");
}
});
});
}); });
} }
@ -300,149 +290,35 @@ fn key_hint(ui: &mut egui::Ui, key: &str, label: &str) {
ui.label( ui.label(
egui::RichText::new(key) egui::RichText::new(key)
.monospace() .monospace()
.color(TEXT) .color(egui::Color32::from_rgb(232, 238, 242))
.background_color(ELEVATED_BG), .background_color(egui::Color32::from_rgba_unmultiplied(45, 52, 58, 220)),
);
ui.label(
egui::RichText::new(label)
.color(egui::Color32::from_rgb(190, 200, 205))
.small(),
); );
ui.label(egui::RichText::new(label).color(TEXT_DIM).small());
}); });
} }
fn scene_view_selection_hud( fn scene_view_gi_badge(world: &World, ctx: &egui::Context, scene_rect: egui::Rect) {
world: &World, let Some(profile) = world.get_resource::<settings::ActiveCameraRenderProfile>() else {
ctx: &egui::Context,
scene_rect: egui::Rect,
selected_entities: &SelectedEntities,
) {
let Some(&primary) = selected_entities.as_slice().first() else {
return; return;
}; };
let name = world let (label, color) = match profile.gi_path {
.get::<Name>(primary) settings::GiPath::Forward => ("Forward", egui::Color32::from_rgb(140, 180, 255)),
.map(|name| name.as_str().to_string()) settings::GiPath::SolariDeferred => ("Solari", egui::Color32::from_rgb(255, 170, 90)),
.unwrap_or_else(|| "Selected entity".to_string());
let kind = world
.get::<shared::ActorKind>(primary)
.map(|kind| format!("{kind:?}"))
.unwrap_or_else(|| "Entity".to_string());
let count = selected_entities.len();
let selection_detail = if count > 1 {
format!("{kind} / {count} selected")
} else {
kind
}; };
egui::Area::new(egui::Id::new("scene_view_gi_badge"))
let pick_detail = world.get_resource::<ViewportPickStack>().and_then(|stack| { .fixed_pos(scene_rect.right_bottom() + egui::vec2(-148.0, -22.0))
(stack.entities.len() > 1 && stack.entities.contains(&primary)).then(|| {
format!(
"Pick {}/{}",
stack.index.min(stack.entities.len() - 1) + 1,
stack.entities.len()
)
})
});
let top_offset = if scene_rect.width() < 1100.0 {
50.0
} else {
10.0
};
egui::Area::new(egui::Id::new("scene_view_selection_hud"))
.fixed_pos(scene_rect.right_top() + egui::vec2(-10.0, top_offset))
.pivot(egui::Align2::RIGHT_TOP)
.interactable(false) .interactable(false)
.show(ctx, |ui| { .show(ctx, |ui| {
overlay_chip_frame().show(ui, |ui| { egui::Frame::NONE
ui.horizontal(|ui| { .fill(egui::Color32::from_black_alpha(160))
ui.label(phosphor_icon(icons::CROSSHAIR, 17.0).color(SELECTION)); .inner_margin(egui::Margin::symmetric(8, 4))
ui.vertical(|ui| { .show(ui, |ui| {
ui.set_max_width(260.0); ui.colored_label(color, label);
ui.add(
egui::Label::new(egui::RichText::new(&name).color(TEXT).strong())
.truncate(),
);
ui.label(
egui::RichText::new(&selection_detail)
.color(TEXT_DIM)
.small(),
);
});
if let Some(pick_detail) = pick_detail {
ui.separator();
ui.label(
egui::RichText::new(pick_detail)
.color(ACCENT)
.monospace()
.small(),
);
}
}); });
});
});
}
fn scene_view_orientation_widget(world: &mut World, ctx: &egui::Context, scene_rect: egui::Rect) {
let rotation = {
let mut cameras = world.query_filtered::<(&Camera, &GlobalTransform), With<EditorCamera>>();
let mut rotation = None;
for (camera, transform) in cameras.iter(world) {
rotation.get_or_insert(transform.rotation());
if camera.is_active {
rotation = Some(transform.rotation());
break;
}
}
rotation
};
let Some(rotation) = rotation else {
return;
};
egui::Area::new(egui::Id::new("scene_view_orientation_widget"))
.fixed_pos(scene_rect.right_bottom() + egui::vec2(-10.0, -48.0))
.pivot(egui::Align2::RIGHT_BOTTOM)
.interactable(false)
.show(ctx, |ui| {
overlay_chip_frame().show(ui, |ui| {
let (rect, _) =
ui.allocate_exact_size(egui::vec2(72.0, 72.0), egui::Sense::hover());
let painter = ui.painter_at(rect);
let center = rect.center();
painter.circle_filled(center, 4.0, egui::Color32::from_rgb(210, 214, 219));
let view_rotation = rotation.inverse();
let mut axes = [
("X", view_rotation * Vec3::X, AXIS_X),
("Y", view_rotation * Vec3::Y, AXIS_Y),
("Z", view_rotation * Vec3::Z, AXIS_Z),
];
axes.sort_by(|left, right| {
left.1
.z
.partial_cmp(&right.1.z)
.unwrap_or(std::cmp::Ordering::Equal)
});
for (label, axis, color) in axes {
let endpoint = center + egui::vec2(axis.x, -axis.y) * 27.0;
let facing = axis.z <= 0.0;
let color = if facing {
color
} else {
color.gamma_multiply(0.48)
};
painter.line_segment(
[center, endpoint],
egui::Stroke::new(if facing { 2.2 } else { 1.2 }, color),
);
painter.circle_filled(endpoint, if facing { 9.0 } else { 7.0 }, color);
painter.text(
endpoint,
egui::Align2::CENTER_CENTER,
label,
egui::FontId::monospace(10.0),
egui::Color32::from_rgb(18, 20, 22),
);
}
});
}); });
} }
@ -457,26 +333,17 @@ fn scene_view_volume_hud(world: &World, ctx: &egui::Context, scene_rect: egui::R
.fixed_pos(scene_rect.left_bottom() + egui::vec2(8.0, -56.0)) .fixed_pos(scene_rect.left_bottom() + egui::vec2(8.0, -56.0))
.interactable(false) .interactable(false)
.show(ctx, |ui| { .show(ctx, |ui| {
overlay_chip_frame().show(ui, |ui| { egui::Frame::NONE
ui.horizontal(|ui| { .fill(egui::Color32::from_black_alpha(150))
ui.label(phosphor_icon(icons::CUBE, 14.0).color(SELECTION)); .inner_margin(egui::Margin::symmetric(8, 4))
ui.label( .show(ui, |ui| {
egui::RichText::new(&primary.label) ui.label(format!(
.color(TEXT) "Volume: {} ({:.0}%, {} overrides)",
.strong() primary.label,
.small(), primary.weight * 100.0,
); primary.override_count
ui.label( ));
egui::RichText::new(format!(
"{:.0}% / {} overrides",
primary.weight * 100.0,
primary.override_count
))
.color(TEXT_DIM)
.small(),
);
}); });
});
}); });
} }
@ -491,7 +358,7 @@ fn scene_view_overlay_toolbar(world: &mut World, ctx: &egui::Context, scene_rect
viewport_toolbar_frame().show(ui, |ui| { viewport_toolbar_frame().show(ui, |ui| {
panel_toolbar_row(ui, |ui| { panel_toolbar_row(ui, |ui| {
let gizmo_mode = *world.resource::<crate::gizmos::EditorGizmoMode>(); let gizmo_mode = *world.resource::<crate::gizmos::EditorGizmoMode>();
if tool_button_accent( if tool_button(
ui, ui,
icons::ARROWS_OUT_CARDINAL, icons::ARROWS_OUT_CARDINAL,
gizmo_mode == EditorGizmoMode::Translate, gizmo_mode == EditorGizmoMode::Translate,
@ -501,7 +368,7 @@ fn scene_view_overlay_toolbar(world: &mut World, ctx: &egui::Context, scene_rect
{ {
super::helpers::set_gizmo_mode(world, EditorGizmoMode::Translate); super::helpers::set_gizmo_mode(world, EditorGizmoMode::Translate);
} }
if tool_button_accent( if tool_button(
ui, ui,
icons::ARROWS_CLOCKWISE, icons::ARROWS_CLOCKWISE,
gizmo_mode == EditorGizmoMode::Rotate, gizmo_mode == EditorGizmoMode::Rotate,
@ -511,7 +378,7 @@ fn scene_view_overlay_toolbar(world: &mut World, ctx: &egui::Context, scene_rect
{ {
super::helpers::set_gizmo_mode(world, EditorGizmoMode::Rotate); super::helpers::set_gizmo_mode(world, EditorGizmoMode::Rotate);
} }
if tool_button_accent( if tool_button(
ui, ui,
icons::ARROWS_IN_LINE_HORIZONTAL, icons::ARROWS_IN_LINE_HORIZONTAL,
gizmo_mode == EditorGizmoMode::Scale, gizmo_mode == EditorGizmoMode::Scale,
@ -592,14 +459,6 @@ fn scene_view_overlay_toolbar(world: &mut World, ctx: &egui::Context, scene_rect
if tool_button(ui, icons::MAGNET, snap, "Snap translation").clicked() { if tool_button(ui, icons::MAGNET, snap, "Snap translation").clicked() {
settings.snap_translate = !settings.snap_translate; settings.snap_translate = !settings.snap_translate;
} }
if settings.snap_translate {
ui.label(
egui::RichText::new(format!("{:.2}m", settings.snap_increment))
.color(SELECTION)
.monospace()
.small(),
);
}
} }
if let Some(mut visualizers) = if let Some(mut visualizers) =
@ -673,8 +532,7 @@ fn scene_options_popover(
ui.checkbox(&mut settings.snap_translate, "Snap translation"); ui.checkbox(&mut settings.snap_translate, "Snap translation");
ui.add( ui.add(
egui::Slider::new(&mut settings.snap_increment, 0.25..=4.0) egui::Slider::new(&mut settings.snap_increment, 0.25..=4.0)
.text("Snap step") .text("Snap step"),
.suffix(" m"),
); );
} }
if let Some(mut gizmo_options) = world.get_resource_mut::<GizmoOptions>() { if let Some(mut gizmo_options) = world.get_resource_mut::<GizmoOptions>() {
@ -706,7 +564,6 @@ fn scene_options_popover(
ui.checkbox(&mut visualizers.show_actor_icon_actor, "Actors"); ui.checkbox(&mut visualizers.show_actor_icon_actor, "Actors");
ui.checkbox(&mut visualizers.show_actor_icon_mesh, "Meshes"); ui.checkbox(&mut visualizers.show_actor_icon_mesh, "Meshes");
ui.checkbox(&mut visualizers.show_actor_icon_light, "Lights"); ui.checkbox(&mut visualizers.show_actor_icon_light, "Lights");
ui.checkbox(&mut visualizers.show_actor_icon_audio, "Audio");
ui.checkbox(&mut visualizers.show_actor_icon_physics, "Physics"); ui.checkbox(&mut visualizers.show_actor_icon_physics, "Physics");
ui.checkbox(&mut visualizers.show_actor_icon_gameplay, "Gameplay"); ui.checkbox(&mut visualizers.show_actor_icon_gameplay, "Gameplay");
ui.checkbox(&mut visualizers.show_actor_icon_volume, "Volumes"); ui.checkbox(&mut visualizers.show_actor_icon_volume, "Volumes");
@ -721,10 +578,7 @@ fn scene_options_popover(
ui.separator(); ui.separator();
ui.checkbox(&mut visualizers.show_colliders, "Colliders"); ui.checkbox(&mut visualizers.show_colliders, "Colliders");
ui.checkbox(&mut visualizers.show_lights, "Lights"); ui.checkbox(&mut visualizers.show_lights, "Lights");
ui.checkbox(&mut visualizers.show_audio, "Audio ranges");
ui.checkbox(&mut visualizers.show_spawns, "Player spawns"); ui.checkbox(&mut visualizers.show_spawns, "Player spawns");
ui.checkbox(&mut visualizers.show_gameplay, "Gameplay markers");
ui.checkbox(&mut visualizers.show_volumes, "Volumes");
ui.checkbox( ui.checkbox(
&mut visualizers.show_prefab_model_anchors, &mut visualizers.show_prefab_model_anchors,
"Prefab/model anchors", "Prefab/model anchors",
@ -815,74 +669,29 @@ fn viewport_asset_drop_ui(
viewport_rect: egui::Rect, viewport_rect: egui::Rect,
selected: &SelectedEntities, selected: &SelectedEntities,
) { ) {
let pointer_pos = ui.input(|input| input.pointer.hover_pos());
let drag_selection = world
.resource::<EditorAssets>()
.dragging_selection()
.cloned();
let drag_descriptor = drag_selection
.as_ref()
.and_then(|selection| asset_drag_descriptor(world, selection, selected));
let over_viewport = pointer_pos.is_some_and(|pos| viewport_rect.contains(pos));
if let (Some(pointer), Some((icon, label, action))) = (pointer_pos, drag_descriptor.as_ref()) {
draw_asset_drag_preview(
ui,
viewport_rect,
pointer,
*icon,
label,
action,
over_viewport,
);
}
if drag_selection.is_some() && over_viewport {
ui.painter().rect_stroke(
viewport_rect.shrink(2.0),
0.0,
egui::Stroke::new(2.0, ACCENT_HOVER),
egui::StrokeKind::Inside,
);
}
let pointer_released = ui.input(|input| input.pointer.any_released()); let pointer_released = ui.input(|input| input.pointer.any_released());
if !pointer_released { if !pointer_released {
return; return;
} }
let pointer_pos = ui.input(|input| input.pointer.hover_pos());
if !pointer_pos.is_some_and(|pos| viewport_rect.contains(pos)) { if !pointer_pos.is_some_and(|pos| viewport_rect.contains(pos)) {
world.resource_mut::<EditorAssets>().clear_drag(); world.resource_mut::<EditorAssets>().clear_drag();
return; return;
} }
let selection = {
let assets = world.resource::<EditorAssets>();
assets.dragging_selection().cloned()
};
world.resource_mut::<EditorAssets>().clear_drag(); world.resource_mut::<EditorAssets>().clear_drag();
let Some(selection) = drag_selection else { let Some(selection) = selection else {
return; return;
}; };
let placement = viewport_ground_position(world, pointer_pos.unwrap(), viewport_rect) let placement = viewport_ground_position(world, pointer_pos.unwrap(), viewport_rect)
.map(|pos| snap_translation(pos, world.resource::<ViewportSettings>())) .map(|pos| snap_translation(pos, world.resource::<ViewportSettings>()))
.unwrap_or(Vec3::ZERO); .unwrap_or(Vec3::ZERO);
if matches!(
selection,
AssetSelection::SubAsset {
kind: AssetSubAssetKind::AnimationClip,
..
}
) {
if let Some(entity) = selected
.as_slice()
.iter()
.copied()
.find(|entity| world.get::<shared::ModelRef>(*entity).is_some())
{
assign_animation_clip_operator(world, selection, entity);
} else {
place_subasset_operator(world, selection, placement);
}
return;
}
if matches!( if matches!(
selection, selection,
AssetSelection::SubAsset { AssetSelection::SubAsset {
@ -893,15 +702,6 @@ fn viewport_asset_drop_ui(
place_subasset_operator(world, selection, placement); place_subasset_operator(world, selection, placement);
return; return;
} }
if matches!(
selection,
AssetSelection::SubAsset {
kind: AssetSubAssetKind::Skeleton,
..
}
) {
return;
}
if let AssetSelection::SubAsset { if let AssetSelection::SubAsset {
kind: AssetSubAssetKind::Texture, kind: AssetSubAssetKind::Texture,
@ -930,19 +730,6 @@ fn viewport_asset_drop_ui(
let Some(asset) = asset else { let Some(asset) = asset else {
return; return;
}; };
if matches!(asset.kind, crate::assets::EditorAssetKind::AudioClip) {
if let Some(entity) = selected
.as_slice()
.iter()
.copied()
.find(|entity| world.get::<shared::AudioSourceDesc>(*entity).is_some())
{
assign_audio_clip_operator(world, asset, entity);
} else {
place_asset_operator(world, asset, placement);
}
return;
}
match asset.kind { match asset.kind {
crate::assets::EditorAssetKind::Texture => { crate::assets::EditorAssetKind::Texture => {
apply_texture_operator(world, asset, selected); apply_texture_operator(world, asset, selected);
@ -957,204 +744,3 @@ fn viewport_asset_drop_ui(
place_asset_operator(world, asset, placement); place_asset_operator(world, asset, placement);
} }
fn asset_drag_descriptor(
world: &World,
selection: &AssetSelection,
selected: &SelectedEntities,
) -> Option<(egui_phosphor_icons::Icon, String, String)> {
let assets = world.resource::<EditorAssets>();
let asset = assets.asset_for_selection(selection)?;
let icon = match selection {
AssetSelection::SubAsset {
kind: AssetSubAssetKind::Mesh,
..
} => icons::CUBE,
AssetSelection::SubAsset {
kind: AssetSubAssetKind::Material,
..
} => icons::PAINT_BRUSH,
AssetSelection::SubAsset {
kind: AssetSubAssetKind::Texture,
..
} => icons::IMAGE,
AssetSelection::SubAsset {
kind: AssetSubAssetKind::Skeleton,
..
} => icons::BONE,
AssetSelection::SubAsset {
kind: AssetSubAssetKind::AnimationClip,
..
} => icons::FILM_STRIP,
_ => kind_icon(&asset.kind),
};
let label = match selection {
AssetSelection::SubAsset { label, .. } => label.clone(),
_ => asset.label.clone(),
};
let action = match selection {
AssetSelection::SubAsset {
kind: AssetSubAssetKind::Texture,
..
} if !selected.is_empty() => "Apply texture to selection",
AssetSelection::SubAsset {
kind: AssetSubAssetKind::Material,
..
} if !selected.is_empty() => "Apply material to selection",
AssetSelection::SubAsset {
kind: AssetSubAssetKind::Skeleton,
..
} => "Inspect skeleton",
AssetSelection::SubAsset {
kind: AssetSubAssetKind::AnimationClip,
..
} if selected
.as_slice()
.iter()
.any(|entity| world.get::<shared::ModelRef>(*entity).is_some()) =>
{
"Assign clip to animated actor"
}
AssetSelection::SubAsset {
kind: AssetSubAssetKind::AnimationClip,
..
} => "Create animated actor",
_ => match asset.kind {
crate::assets::EditorAssetKind::Texture if !selected.is_empty() => {
"Apply texture to selection"
}
crate::assets::EditorAssetKind::Material if !selected.is_empty() => {
"Apply material to selection"
}
crate::assets::EditorAssetKind::AudioClip
if selected
.as_slice()
.iter()
.any(|entity| world.get::<shared::AudioSourceDesc>(*entity).is_some()) =>
{
"Assign clip to audio source"
}
crate::assets::EditorAssetKind::AudioClip => "Create audio source",
_ => "Place in scene",
},
};
Some((icon, label, action.to_string()))
}
fn draw_asset_drag_preview(
ui: &egui::Ui,
viewport_rect: egui::Rect,
pointer: egui::Pos2,
icon: egui_phosphor_icons::Icon,
label: &str,
action: &str,
over_viewport: bool,
) {
if over_viewport {
let painter = ui.painter();
painter.circle_filled(pointer, 3.0, ACCENT_HOVER);
painter.circle_stroke(pointer, 11.0, egui::Stroke::new(1.5, ACCENT_HOVER));
painter.line_segment(
[
pointer - egui::vec2(17.0, 0.0),
pointer - egui::vec2(7.0, 0.0),
],
egui::Stroke::new(1.5, ACCENT_HOVER),
);
painter.line_segment(
[
pointer + egui::vec2(7.0, 0.0),
pointer + egui::vec2(17.0, 0.0),
],
egui::Stroke::new(1.5, ACCENT_HOVER),
);
painter.line_segment(
[
pointer - egui::vec2(0.0, 17.0),
pointer - egui::vec2(0.0, 7.0),
],
egui::Stroke::new(1.5, ACCENT_HOVER),
);
painter.line_segment(
[
pointer + egui::vec2(0.0, 7.0),
pointer + egui::vec2(0.0, 17.0),
],
egui::Stroke::new(1.5, ACCENT_HOVER),
);
}
let content = ui.ctx().content_rect();
let pos = egui::pos2(
(pointer.x + 20.0).min(content.right() - 258.0),
(pointer.y + 20.0).min(content.bottom() - 64.0),
);
egui::Area::new(egui::Id::new("asset_drag_preview"))
.fixed_pos(pos)
.default_size(egui::vec2(246.0, 60.0))
.order(egui::Order::Tooltip)
.interactable(false)
.show(ui.ctx(), |ui| {
ui.set_min_size(egui::vec2(246.0, 60.0));
ui.set_max_size(egui::vec2(246.0, 60.0));
overlay_chip_frame().show(ui, |ui| {
ui.set_width(228.0);
ui.horizontal(|ui| {
ui.label(phosphor_icon(icon, 20.0).color(if over_viewport {
ACCENT_HOVER
} else {
TEXT_DIM
}));
ui.vertical(|ui| {
ui.spacing_mut().item_spacing.y = 1.0;
ui.label(egui::RichText::new(label).strong().color(TEXT));
ui.label(
egui::RichText::new(if over_viewport {
action
} else {
"Drag into the viewport"
})
.size(11.0)
.color(if over_viewport {
ACCENT_HOVER
} else {
TEXT_MUTED
}),
);
});
});
});
});
if over_viewport {
let corner = 16.0;
let inset = viewport_rect.shrink(8.0);
for (a, b, c) in [
(
inset.left_top(),
egui::vec2(corner, 0.0),
egui::vec2(0.0, corner),
),
(
inset.right_top(),
egui::vec2(-corner, 0.0),
egui::vec2(0.0, corner),
),
(
inset.left_bottom(),
egui::vec2(corner, 0.0),
egui::vec2(0.0, -corner),
),
(
inset.right_bottom(),
egui::vec2(-corner, 0.0),
egui::vec2(0.0, -corner),
),
] {
ui.painter()
.line_segment([a, a + b], egui::Stroke::new(3.0, ACCENT_HOVER));
ui.painter()
.line_segment([a, a + c], egui::Stroke::new(3.0, ACCENT_HOVER));
}
}
}

View File

@ -1,14 +1,9 @@
//! Reusable editor widgets: icon buttons, tool toggles, menu labels. //! Reusable editor widgets: icon buttons, tool toggles, menu labels.
use std::hash::Hash;
use bevy_egui::egui::{self, Button, FontId, RichText, Ui, WidgetText}; use bevy_egui::egui::{self, Button, FontId, RichText, Ui, WidgetText};
use egui_phosphor_icons::Icon; use egui_phosphor_icons::Icon;
use super::theme::{ use super::theme::{ACCENT, ACCENT_HOVER, ELEVATED_BG};
ACCENT, ACCENT_HOVER, BORDER, ELEVATED_BG, SELECTION, SELECTION_BG, SUCCESS, TEXT_MUTED,
TEXT_SELECTED, WIDGET_BG,
};
const PHOSPHOR: &str = "phosphor-regular"; const PHOSPHOR: &str = "phosphor-regular";
@ -20,61 +15,42 @@ pub fn phosphor_icon_text(icon: &str, size: f32) -> RichText {
RichText::new(icon).font(FontId::new(size, egui::FontFamily::Name(PHOSPHOR.into()))) RichText::new(icon).font(FontId::new(size, egui::FontFamily::Name(PHOSPHOR.into())))
} }
/// Large transport control (Play / Pause / Stop / Eject), icon-only with a tooltip. /// Large transport control (Play / Pause / Stop / Eject) — icon only, tooltip for label.
pub fn transport_button_large( pub fn transport_button_large(
ui: &mut Ui, ui: &mut Ui,
id_salt: impl Hash,
icon: Icon, icon: Icon,
tooltip: &str, tooltip: &str,
accent: bool, accent: bool,
) -> egui::Response { ) -> egui::Response {
const SIZE: f32 = 40.0; const SIZE: f32 = 44.0;
const ICON_PX: f32 = 20.0; const ICON_PX: f32 = 22.0;
let fill = if accent { let fill = if accent {
SUCCESS ACCENT
} else { } else {
ELEVATED_BG.linear_multiply(0.9) ELEVATED_BG.linear_multiply(0.85)
}; };
let (_, rect) = ui.allocate_space(egui::vec2(SIZE, SIZE)); ui.add(
let response = ui.interact(rect, ui.make_persistent_id(id_salt), egui::Sense::click()); Button::new(phosphor_icon(icon, ICON_PX))
response.widget_info(|| { .fill(fill)
egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), tooltip) .stroke(egui::Stroke::new(
}); 1.0,
let fill = if response.is_pointer_button_down_on() { if accent {
fill.linear_multiply(0.82) ACCENT_HOVER
} else if response.hovered() { } else {
fill.linear_multiply(1.15) super::theme::BORDER
} else { },
fill ))
}; .min_size(egui::vec2(SIZE, SIZE))
let stroke = egui::Stroke::new( .corner_radius(5.0),
if response.hovered() { 1.5 } else { 1.0 }, )
if accent { .on_hover_text(tooltip)
SUCCESS.linear_multiply(1.15)
} else if response.hovered() {
TEXT_MUTED
} else {
BORDER
},
);
ui.painter()
.rect(rect, 6.0, fill, stroke, egui::StrokeKind::Inside);
ui.painter().text(
rect.center(),
egui::Align2::CENTER_CENTER,
icon.as_str(),
FontId::new(ICON_PX, egui::FontFamily::Name(PHOSPHOR.into())),
TEXT_SELECTED,
);
response.on_hover_text(tooltip)
} }
pub fn icon_button(ui: &mut Ui, icon: Icon, tooltip: &str) -> egui::Response { pub fn icon_button(ui: &mut Ui, icon: Icon, tooltip: &str) -> egui::Response {
ui.add( ui.add(
Button::new(phosphor_icon(icon, 17.0)) Button::new(phosphor_icon(icon, 18.0))
.fill(WIDGET_BG.linear_multiply(0.78)) .frame(false)
.stroke(egui::Stroke::new(1.0, BORDER))
.min_size(egui::vec2(32.0, 32.0)), .min_size(egui::vec2(32.0, 32.0)),
) )
.on_hover_text(tooltip) .on_hover_text(tooltip)
@ -83,56 +59,31 @@ pub fn icon_button(ui: &mut Ui, icon: Icon, tooltip: &str) -> egui::Response {
pub fn icon_button_small(ui: &mut Ui, icon: Icon, tooltip: &str) -> egui::Response { pub fn icon_button_small(ui: &mut Ui, icon: Icon, tooltip: &str) -> egui::Response {
ui.add( ui.add(
Button::new(phosphor_icon(icon, 16.0)) Button::new(phosphor_icon(icon, 16.0))
.fill(WIDGET_BG.linear_multiply(0.72)) .frame(false)
.stroke(egui::Stroke::new(1.0, BORDER)) .min_size(egui::vec2(22.0, 22.0)),
.min_size(egui::vec2(27.0, 27.0))
.corner_radius(4.0),
) )
.on_hover_text(tooltip) .on_hover_text(tooltip)
} }
pub fn tool_button(ui: &mut Ui, icon: Icon, selected: bool, tooltip: &str) -> egui::Response { pub fn tool_button(ui: &mut Ui, icon: Icon, selected: bool, tooltip: &str) -> egui::Response {
tool_button_with_color(ui, icon, selected, tooltip, SELECTION_BG, SELECTION)
}
pub fn tool_button_accent(
ui: &mut Ui,
icon: Icon,
selected: bool,
tooltip: &str,
) -> egui::Response {
tool_button_with_color(
ui,
icon,
selected,
tooltip,
ACCENT.linear_multiply(0.84),
ACCENT_HOVER,
)
}
fn tool_button_with_color(
ui: &mut Ui,
icon: Icon,
selected: bool,
tooltip: &str,
selected_fill: egui::Color32,
selected_stroke: egui::Color32,
) -> egui::Response {
let fill = if selected { let fill = if selected {
selected_fill ACCENT
} else { } else {
WIDGET_BG.linear_multiply(0.82) ELEVATED_BG.linear_multiply(0.6)
}; };
ui.add( ui.add(
Button::new(phosphor_icon(icon, 17.0)) Button::new(phosphor_icon(icon, 16.0))
.fill(fill) .fill(fill)
.stroke(egui::Stroke::new( .stroke(egui::Stroke::new(
1.0, 1.0,
if selected { selected_stroke } else { BORDER }, if selected {
ACCENT
} else {
super::theme::BORDER
},
)) ))
.min_size(egui::vec2(30.0, 30.0)) .min_size(egui::vec2(26.0, 26.0))
.corner_radius(4.0), .corner_radius(3.0),
) )
.on_hover_text(tooltip) .on_hover_text(tooltip)
} }

View File

@ -14,15 +14,14 @@ use bevy::render::render_resource::{
}; };
use bevy::shader::ShaderRef; use bevy::shader::ShaderRef;
use shared::{ use shared::{
ActorKind, AudioListenerDesc, AudioSourceDesc, AuthoringLightKind, ColliderDesc, LevelObject, ActorKind, AuthoringLightKind, ColliderDesc, LevelObject, LightDesc, ModelRef, ObjectiveMarker,
LightDesc, ModelRef, ObjectiveMarker, PhysicsBody, PlayerSpawn, PostProcessVolumeDesc, PhysicsBody, PlayerSpawn, PostProcessVolumeDesc, PrefabInstance, PrefabRef, Primitive,
PrefabInstance, PrefabRef, Primitive, RaytracingExcluded, StaticMeshRenderer, TeamSpawn, RaytracingExcluded, StaticMeshRenderer, TeamSpawn, TriggerVolume, WeaponSpawn,
TriggerVolume, WeaponSpawn,
}; };
use crate::camera::EditorCamera; use crate::camera::EditorCamera;
use crate::infra::EditorOnly; use crate::infra::EditorOnly;
use crate::state::{EditorMode, PlayPossession}; use crate::state::scene_tools_active;
use crate::ui::UiState; use crate::ui::UiState;
use crate::viewport::ViewportDisplayMode; use crate::viewport::ViewportDisplayMode;
use crate::visualizers::EditorVisualizationSettings; use crate::visualizers::EditorVisualizationSettings;
@ -168,8 +167,6 @@ enum ActorIconImage {
LightPoint, LightPoint,
LightSpot, LightSpot,
LightDirectional, LightDirectional,
AudioSource,
AudioListener,
PhysicsCollider, PhysicsCollider,
GameSpawn, GameSpawn,
GameTrigger, GameTrigger,
@ -183,7 +180,6 @@ enum ActorIconCategory {
Actor, Actor,
Mesh, Mesh,
Light, Light,
Audio,
Physics, Physics,
Gameplay, Gameplay,
Volume, Volume,
@ -212,8 +208,6 @@ struct ActorIconComponents {
has_post_process_volume: bool, has_post_process_volume: bool,
has_team_spawn: bool, has_team_spawn: bool,
has_objective: bool, has_objective: bool,
has_audio_source: bool,
has_audio_listener: bool,
has_physics: bool, has_physics: bool,
} }
@ -238,7 +232,7 @@ impl Plugin for ActorIconsPlugin {
app.init_resource::<ActorIconSettings>() app.init_resource::<ActorIconSettings>()
.add_plugins(MaterialPlugin::<ActorIconOverlayMaterial>::default()) .add_plugins(MaterialPlugin::<ActorIconOverlayMaterial>::default())
.add_systems(Startup, init_actor_icon_assets) .add_systems(Startup, init_actor_icon_assets)
.add_systems(Update, sync_actor_icons); .add_systems(Update, sync_actor_icons.run_if(scene_tools_active));
} }
} }
@ -320,8 +314,6 @@ fn init_actor_icon_assets(
(ActorIconImage::LightPoint, ICON_09_LIGHT_POINT), (ActorIconImage::LightPoint, ICON_09_LIGHT_POINT),
(ActorIconImage::LightSpot, ICON_10_LIGHT_SPOT), (ActorIconImage::LightSpot, ICON_10_LIGHT_SPOT),
(ActorIconImage::LightDirectional, ICON_11_LIGHT_DIRECTIONAL), (ActorIconImage::LightDirectional, ICON_11_LIGHT_DIRECTIONAL),
(ActorIconImage::AudioSource, ICON_15_AUDIO_SOURCE),
(ActorIconImage::AudioListener, ICON_16_AUDIO_LISTENER),
(ActorIconImage::PhysicsCollider, ICON_17_PHYSICS_COLLIDER), (ActorIconImage::PhysicsCollider, ICON_17_PHYSICS_COLLIDER),
(ActorIconImage::GameSpawn, ICON_21_GAME_SPAWN), (ActorIconImage::GameSpawn, ICON_21_GAME_SPAWN),
(ActorIconImage::GameTrigger, ICON_22_GAME_TRIGGER), (ActorIconImage::GameTrigger, ICON_22_GAME_TRIGGER),
@ -351,8 +343,6 @@ fn init_actor_icon_assets(
#[allow(clippy::type_complexity, clippy::too_many_arguments)] #[allow(clippy::type_complexity, clippy::too_many_arguments)]
fn sync_actor_icons( fn sync_actor_icons(
mode: Res<State<EditorMode>>,
possession: Res<PlayPossession>,
display: Res<ViewportDisplayMode>, display: Res<ViewportDisplayMode>,
settings: Res<EditorVisualizationSettings>, settings: Res<EditorVisualizationSettings>,
icon_settings: Res<ActorIconSettings>, icon_settings: Res<ActorIconSettings>,
@ -386,14 +376,11 @@ fn sync_actor_icons(
), ),
(With<LevelObject>, Without<EditorOnly>), (With<LevelObject>, Without<EditorOnly>),
>, >,
audio: Query<(Has<AudioSourceDesc>, Has<AudioListenerDesc>)>,
physics: Query<(), Or<(With<PhysicsBody>, With<ColliderDesc>)>>, physics: Query<(), Or<(With<PhysicsBody>, With<ColliderDesc>)>>,
) { ) {
let camera = active_editor_camera(&camera_query); let camera = active_editor_camera(&camera_query);
let mut desired = HashMap::new(); let mut desired = HashMap::new();
let tools_active = *mode.get() == EditorMode::Editing if !display.clean_game_view && settings.enabled && settings.show_actor_icons {
|| (*mode.get() == EditorMode::Playing && *possession == PlayPossession::Ejected);
if tools_active && !display.clean_game_view && settings.enabled && settings.show_actor_icons {
for ( for (
target, target,
global, global,
@ -412,7 +399,6 @@ fn sync_actor_icons(
has_objective, has_objective,
) in &actor_roots ) in &actor_roots
{ {
let (has_audio_source, has_audio_listener) = audio.get(target).unwrap_or_default();
let components = ActorIconComponents { let components = ActorIconComponents {
kind: kind.copied(), kind: kind.copied(),
light_kind: light.map(|light| light.kind), light_kind: light.map(|light| light.kind),
@ -428,8 +414,6 @@ fn sync_actor_icons(
has_post_process_volume, has_post_process_volume,
has_team_spawn, has_team_spawn,
has_objective, has_objective,
has_audio_source,
has_audio_listener,
has_physics: physics.contains(target), has_physics: physics.contains(target),
}; };
let spec = icon_for_actor_components(components); let spec = icon_for_actor_components(components);
@ -482,7 +466,6 @@ fn sync_actor_icons(
if existing_targets.contains(&target) { if existing_targets.contains(&target) {
continue; continue;
} }
let (has_audio_source, has_audio_listener) = audio.get(target).unwrap_or_default();
let components = ActorIconComponents { let components = ActorIconComponents {
kind: kind.copied(), kind: kind.copied(),
light_kind: light.map(|light| light.kind), light_kind: light.map(|light| light.kind),
@ -498,8 +481,6 @@ fn sync_actor_icons(
has_post_process_volume, has_post_process_volume,
has_team_spawn, has_team_spawn,
has_objective, has_objective,
has_audio_source,
has_audio_listener,
has_physics: physics.contains(target), has_physics: physics.contains(target),
}; };
let spec = icon_for_actor_components(components); let spec = icon_for_actor_components(components);
@ -597,7 +578,6 @@ fn icon_category_enabled(
ActorIconCategory::Actor => settings.show_actor_icon_actor, ActorIconCategory::Actor => settings.show_actor_icon_actor,
ActorIconCategory::Mesh => settings.show_actor_icon_mesh, ActorIconCategory::Mesh => settings.show_actor_icon_mesh,
ActorIconCategory::Light => settings.show_actor_icon_light, ActorIconCategory::Light => settings.show_actor_icon_light,
ActorIconCategory::Audio => settings.show_actor_icon_audio,
ActorIconCategory::Physics => settings.show_actor_icon_physics, ActorIconCategory::Physics => settings.show_actor_icon_physics,
ActorIconCategory::Gameplay => settings.show_actor_icon_gameplay, ActorIconCategory::Gameplay => settings.show_actor_icon_gameplay,
ActorIconCategory::Volume => settings.show_actor_icon_volume, ActorIconCategory::Volume => settings.show_actor_icon_volume,
@ -732,26 +712,6 @@ fn icon_for_actor_components(components: ActorIconComponents) -> ActorIconSpec {
warning warning
} }
} }
ActorKind::AudioSource => {
if components.has_audio_source {
ActorIconSpec {
image: ActorIconImage::AudioSource,
category: ActorIconCategory::Audio,
}
} else {
warning
}
}
ActorKind::AudioListener => {
if components.has_audio_listener {
ActorIconSpec {
image: ActorIconImage::AudioListener,
category: ActorIconCategory::Audio,
}
} else {
warning
}
}
ActorKind::Empty => { ActorKind::Empty => {
if components.has_physics { if components.has_physics {
ActorIconSpec { ActorIconSpec {
@ -807,45 +767,6 @@ mod tests {
assert_eq!(spec.category, ActorIconCategory::Physics); assert_eq!(spec.category, ActorIconCategory::Physics);
} }
#[test]
fn audio_source_and_listener_use_dedicated_icons() {
let source = icon_for_actor_components(ActorIconComponents {
kind: Some(ActorKind::AudioSource),
has_audio_source: true,
..default()
});
let listener = icon_for_actor_components(ActorIconComponents {
kind: Some(ActorKind::AudioListener),
has_audio_listener: true,
..default()
});
assert_eq!(source.image, ActorIconImage::AudioSource);
assert_eq!(source.category, ActorIconCategory::Audio);
assert_eq!(listener.image, ActorIconImage::AudioListener);
assert_eq!(listener.category, ActorIconCategory::Audio);
}
#[test]
fn audio_actor_without_matching_descriptor_uses_warning_icon() {
let spec = icon_for_actor_components(ActorIconComponents {
kind: Some(ActorKind::AudioSource),
..default()
});
assert_eq!(spec.image, ActorIconImage::Warning);
assert_eq!(spec.category, ActorIconCategory::Debug);
}
#[test]
fn audio_icon_category_respects_its_viewport_toggle() {
let mut settings = EditorVisualizationSettings::default();
assert!(icon_category_enabled(&settings, ActorIconCategory::Audio));
settings.show_actor_icon_audio = false;
assert!(!icon_category_enabled(&settings, ActorIconCategory::Audio));
}
#[test] #[test]
fn perspective_icon_size_uses_view_depth() { fn perspective_icon_size_uses_view_depth() {
let camera = GlobalTransform::IDENTITY; let camera = GlobalTransform::IDENTITY;

View File

@ -8,7 +8,6 @@ use shared::{
}; };
use crate::history::apply_brush_csg_with_history; use crate::history::apply_brush_csg_with_history;
use crate::operators::{ActiveOperator, OperatorPhase, OperatorStatus};
use crate::scene_io::SceneIo; use crate::scene_io::SceneIo;
use crate::ui::UiState; use crate::ui::UiState;
@ -190,13 +189,14 @@ fn run_bounds_csg(world: &mut World, op: BrushCsgOp) {
selected, selected,
result, result,
}); });
let hint = match op { set_status(
BrushCsgOp::Intersect => "Brush intersect preview: Enter commits, Esc cancels", world,
BrushCsgOp::Merge => "Brush convex merge preview: Enter commits, Esc cancels", match op {
BrushCsgOp::Subtract => "Brush subtract preview: Enter commits, Esc cancels", BrushCsgOp::Intersect => "Brush intersect preview: Enter commits, Esc cancels",
}; BrushCsgOp::Merge => "Brush convex merge preview: Enter commits, Esc cancels",
set_status(world, hint); BrushCsgOp::Subtract => "Brush subtract preview: Enter commits, Esc cancels",
set_csg_operator_status(world, OperatorPhase::Preview, hint); },
);
} }
fn brush_csg_preview_input(world: &mut World) { fn brush_csg_preview_input(world: &mut World) {
@ -210,7 +210,14 @@ fn brush_csg_preview_input(world: &mut World) {
return; return;
} }
if escape { if escape {
cancel_csg_preview(world); if world
.resource_mut::<BrushCsgPreview>()
.pending
.take()
.is_some()
{
set_status(world, "Brush CSG preview canceled");
}
return; return;
} }
let Some(pending) = world.resource_mut::<BrushCsgPreview>().pending.take() else { let Some(pending) = world.resource_mut::<BrushCsgPreview>().pending.take() else {
@ -219,26 +226,9 @@ fn brush_csg_preview_input(world: &mut World) {
commit_csg_preview(world, pending); commit_csg_preview(world, pending);
} }
fn cancel_csg_preview(world: &mut World) {
if world
.resource_mut::<BrushCsgPreview>()
.pending
.take()
.is_some()
{
set_status(world, "Brush CSG preview canceled");
set_csg_operator_status(world, OperatorPhase::Canceled, "Canceled");
}
}
fn commit_csg_preview(world: &mut World, pending: PendingBrushCsg) { fn commit_csg_preview(world: &mut World, pending: PendingBrushCsg) {
let Some(primary) = pending.selected.first().copied() else { let Some(primary) = pending.selected.first().copied() else {
set_status(world, "Brush CSG commit failed: missing primary brush"); set_status(world, "Brush CSG commit failed: missing primary brush");
set_csg_operator_status(
world,
OperatorPhase::Blocked,
"Commit failed: missing primary brush",
);
return; return;
}; };
if world.get::<BrushDesc>(primary).is_none() { if world.get::<BrushDesc>(primary).is_none() {
@ -246,11 +236,6 @@ fn commit_csg_preview(world: &mut World, pending: PendingBrushCsg) {
world, world,
"Brush CSG commit failed: primary brush no longer exists", "Brush CSG commit failed: primary brush no longer exists",
); );
set_csg_operator_status(
world,
OperatorPhase::Blocked,
"Commit failed: primary brush no longer exists",
);
return; return;
} }
let new_brush = BrushDesc::cuboid(pending.result.size()); let new_brush = BrushDesc::cuboid(pending.result.size());
@ -266,13 +251,14 @@ fn commit_csg_preview(world: &mut World, pending: PendingBrushCsg) {
Vec::new() Vec::new()
}; };
apply_bounds_result(world, primary, pending.result, new_brush, &to_delete); apply_bounds_result(world, primary, pending.result, new_brush, &to_delete);
let hint = match pending.op { set_status(
BrushCsgOp::Intersect => "Brush intersect committed", world,
BrushCsgOp::Merge => "Brush convex merge committed", match pending.op {
BrushCsgOp::Subtract => "Brush subtract committed", BrushCsgOp::Intersect => "Brush intersect committed",
}; BrushCsgOp::Merge => "Brush convex merge committed",
set_status(world, hint); BrushCsgOp::Subtract => "Brush subtract committed",
set_csg_operator_status(world, OperatorPhase::Committed, hint); },
);
} }
fn draw_brush_csg_preview(preview: Res<BrushCsgPreview>, mut gizmos: Gizmos) { fn draw_brush_csg_preview(preview: Res<BrushCsgPreview>, mut gizmos: Gizmos) {
@ -408,24 +394,10 @@ fn set_status(world: &mut World, status: impl Into<String>) {
} }
} }
fn set_csg_operator_status(world: &mut World, phase: OperatorPhase, hint: impl Into<String>) {
if let Some(mut active) = world.get_resource_mut::<ActiveOperator>() {
active.status = Some(OperatorStatus {
id: "brush.csg".to_string(),
label: "Brush CSG".to_string(),
phase,
hint: hint.into(),
warnings: Vec::new(),
});
}
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use crate::history::{EditorCommand, EditorHistory}; use crate::history::EditorCommand;
use crate::operators::test_harness::{assert_undo_redo_round_trip, OperatorInvariantHarness};
use crate::selection::SelectedEntity;
fn bounds(min: Vec3, max: Vec3) -> BrushBounds { fn bounds(min: Vec3, max: Vec3) -> BrushBounds {
BrushBounds { min, max } BrushBounds { min, max }
@ -460,62 +432,4 @@ mod tests {
assert_eq!(command.label(), "Brush CSG"); assert_eq!(command.label(), "Brush CSG");
} }
#[test]
fn csg_preview_cancel_clears_pending_without_authored_side_effects() {
let mut world = World::new();
world.init_resource::<ActiveOperator>();
world.init_resource::<EditorHistory>();
world.init_resource::<SceneIo>();
world.init_resource::<BrushCsgPreview>();
world.resource_mut::<BrushCsgPreview>().pending = Some(PendingBrushCsg {
op: BrushCsgOp::Merge,
selected: vec![Entity::PLACEHOLDER],
result: bounds(Vec3::ZERO, Vec3::ONE),
});
set_csg_operator_status(
&mut world,
OperatorPhase::Preview,
"Enter commits, Esc cancels",
);
let harness = OperatorInvariantHarness::capture(&mut world);
cancel_csg_preview(&mut world);
harness.assert_canceled(&mut world);
assert!(world.resource::<BrushCsgPreview>().pending.is_none());
}
#[test]
fn csg_merge_commit_is_one_undo_group_and_round_trips_deleted_brush() {
let mut world = World::new();
world.init_resource::<ActiveOperator>();
world.init_resource::<EditorHistory>();
world.init_resource::<SceneIo>();
world.init_resource::<SelectedEntity>();
let primary = world
.spawn((LevelObject, BrushDesc::default(), Transform::default()))
.id();
let secondary = world
.spawn((
LevelObject,
BrushDesc::default(),
Transform::from_translation(Vec3::new(0.5, 0.0, 0.0)),
))
.id();
let harness = OperatorInvariantHarness::capture(&mut world);
let pending = PendingBrushCsg {
op: BrushCsgOp::Merge,
selected: vec![primary, secondary],
result: bounds(Vec3::new(-0.5, -0.5, -0.5), Vec3::new(1.0, 0.5, 0.5)),
};
commit_csg_preview(&mut world, pending);
harness.assert_committed(&mut world, 1, -1);
assert_undo_redo_round_trip(&mut world, 2, 1, |world| {
let mut query = world.query_filtered::<Entity, With<LevelObject>>();
query.iter(world).count()
});
}
} }

View File

@ -248,15 +248,10 @@ fn sync_brush_element_gizmo(
mode: Res<BrushEditMode>, mode: Res<BrushEditMode>,
selection: Res<BrushElementSelection>, selection: Res<BrushElementSelection>,
mut state: ResMut<BrushElementGizmoState>, mut state: ResMut<BrushElementGizmoState>,
mut brushes: Query<(&mut BrushDesc, &GlobalTransform)>, brushes: Query<(&BrushDesc, &GlobalTransform)>,
mut gizmos: Query<&mut Transform, With<BrushElementGizmo>>, mut gizmos: Query<&mut Transform, With<BrushElementGizmo>>,
) { ) {
if !mode.is_element_mode() || matches!(*mode, BrushEditMode::Clip) { if !mode.is_element_mode() || matches!(*mode, BrushEditMode::Clip) {
if let Some((brush_entity, old_brush)) = take_brush_edit_rollback(&mut state) {
if let Ok((mut brush, _)) = brushes.get_mut(brush_entity) {
*brush = old_brush;
}
}
clear_brush_element_gizmo(&mut commands, &mut state); clear_brush_element_gizmo(&mut commands, &mut state);
return; return;
} }
@ -432,19 +427,6 @@ fn apply_brush_element_gizmo(
state.changed = false; state.changed = false;
} }
fn take_brush_edit_rollback(state: &mut BrushElementGizmoState) -> Option<(Entity, BrushDesc)> {
let rollback = if state.changed {
state.brush.zip(state.old_brush.take())
} else {
None
};
state.old_brush = None;
state.old_gizmo = None;
state.last_gizmo = None;
state.changed = false;
rollback
}
fn clear_brush_element_gizmo(commands: &mut Commands, state: &mut BrushElementGizmoState) { fn clear_brush_element_gizmo(commands: &mut Commands, state: &mut BrushElementGizmoState) {
if let Some(entity) = state.entity.take() { if let Some(entity) = state.entity.take() {
if let Ok(mut entity_mut) = commands.get_entity(entity) { if let Ok(mut entity_mut) = commands.get_entity(entity) {
@ -605,14 +587,13 @@ fn draw_brush_elements(
gizmos: &mut Gizmos, gizmos: &mut Gizmos,
) { ) {
let base_color = match mode { let base_color = match mode {
BrushEditMode::Vertex => Color::srgb(0.31, 0.75, 0.88), BrushEditMode::Vertex => Color::srgb(0.2, 0.72, 1.0),
BrushEditMode::Edge => Color::srgb(0.95, 0.62, 0.2), BrushEditMode::Edge => Color::srgb(1.0, 0.68, 0.24),
BrushEditMode::Face => Color::srgb(0.32, 0.82, 0.52), BrushEditMode::Face => Color::srgb(0.24, 1.0, 0.56),
BrushEditMode::Clip => Color::srgb(0.86, 0.42, 0.92), BrushEditMode::Clip => Color::srgb(0.92, 0.36, 1.0),
BrushEditMode::Object => Color::WHITE, BrushEditMode::Object => Color::WHITE,
} };
.with_alpha(if active { 0.9 } else { 0.38 }); let selected_color = Color::srgb(1.0, 0.95, 0.2);
let selected_color = Color::srgb(1.0, 0.76, 0.34);
for face in &brush.faces { for face in &brush.faces {
let vertices = world_vertices(face, transform); let vertices = world_vertices(face, transform);
if vertices.is_empty() { if vertices.is_empty() {
@ -632,29 +613,8 @@ fn draw_brush_elements(
} else { } else {
base_color base_color
}; };
gizmos.sphere( gizmos.sphere(face_center, 0.08, color);
face_center, gizmos.line(face_center, face_center + normal * 0.55, color);
if active && selected.contains(&key) {
0.11
} else {
0.075
},
color,
);
gizmos.line(face_center, face_center + normal * 0.65, color);
if matches!(mode, BrushEditMode::Face) {
for index in 0..vertices.len() {
gizmos.line(
vertices[index],
vertices[(index + 1) % vertices.len()],
color.with_alpha(if active && selected.contains(&key) {
1.0
} else {
0.42
}),
);
}
}
} }
for index in 0..vertices.len() { for index in 0..vertices.len() {
let next = (index + 1) % vertices.len(); let next = (index + 1) % vertices.len();
@ -668,15 +628,7 @@ fn draw_brush_elements(
} else { } else {
base_color base_color
}; };
gizmos.sphere( gizmos.sphere(vertices[index], 0.06, color);
vertices[index],
if active && selected.contains(&key) {
0.1
} else {
0.055
},
color,
);
} }
if matches!(mode, BrushEditMode::Edge | BrushEditMode::Clip) { if matches!(mode, BrushEditMode::Edge | BrushEditMode::Clip) {
let key = BrushElementKey::Edge { let key = BrushElementKey::Edge {
@ -689,10 +641,6 @@ fn draw_brush_elements(
base_color base_color
}; };
gizmos.line(vertices[index], vertices[next], color); gizmos.line(vertices[index], vertices[next], color);
if active && selected.contains(&key) {
gizmos.sphere(vertices[index], 0.07, color);
gizmos.sphere(vertices[next], 0.07, color);
}
} }
} }
} }
@ -1125,11 +1073,7 @@ fn set_brush_edit_status(
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use crate::history::EditorHistory; use shared::BrushDesc;
use crate::operators::test_harness::{assert_undo_redo_round_trip, OperatorInvariantHarness};
use crate::scene_io::SceneIo;
use crate::selection::SelectedEntity;
use shared::{BrushDesc, LevelObject};
#[test] #[test]
fn point_inside_convex_polygon_is_detected() { fn point_inside_convex_polygon_is_detected() {
@ -1214,87 +1158,4 @@ mod tests {
assert_eq!(command.label(), "Clip Brush"); assert_eq!(command.label(), "Clip Brush");
} }
#[test]
fn clip_commit_is_one_undo_group_and_round_trips_geometry() {
let mut world = World::new();
world.init_resource::<ActiveOperator>();
world.init_resource::<EditorHistory>();
world.init_resource::<SceneIo>();
world.init_resource::<SelectedEntity>();
let initial_transform = Transform::default();
let initial_brush = BrushDesc::default();
let entity = world
.spawn((LevelObject, initial_transform, initial_brush.clone()))
.id();
let face = initial_brush.faces[0].id.clone();
let result = clip_preview_result(&initial_brush, &initial_transform, &face).unwrap();
let committed = (result.transform, result.brush.clone());
let harness = OperatorInvariantHarness::capture(&mut world);
set_brush_transform_with_history(&mut world, entity, result.transform, result.brush);
set_brush_edit_status(
&mut world.resource_mut::<ActiveOperator>(),
OperatorPhase::Committed,
"Brush clip committed",
);
harness.assert_committed(&mut world, 1, 0);
assert_undo_redo_round_trip(
&mut world,
(initial_transform, initial_brush),
committed,
|world| {
(
*world.get::<Transform>(entity).unwrap(),
world.get::<BrushDesc>(entity).unwrap().clone(),
)
},
);
}
#[test]
fn escape_from_element_preview_restores_brush_and_despawns_helper() {
let mut app = App::new();
app.init_resource::<ActiveOperator>()
.init_resource::<EditorHistory>()
.init_resource::<SceneIo>()
.init_resource::<BrushEditMode>()
.init_resource::<BrushElementSelection>()
.init_resource::<BrushElementGizmoState>()
.add_systems(Update, sync_brush_element_gizmo);
let initial = BrushDesc::default();
let mut preview = initial.clone();
preview.faces[0].vertices[0] += Vec3::X * 0.25;
let brush_entity = app
.world_mut()
.spawn((LevelObject, preview, GlobalTransform::default()))
.id();
let helper = app.world_mut().spawn(BrushElementGizmo).id();
*app.world_mut().resource_mut::<BrushEditMode>() = BrushEditMode::Object;
*app.world_mut().resource_mut::<BrushElementGizmoState>() = BrushElementGizmoState {
entity: Some(helper),
brush: Some(brush_entity),
old_brush: Some(initial.clone()),
old_gizmo: Some(Transform::default()),
last_gizmo: Some(Transform::from_translation(Vec3::X)),
changed: true,
};
set_brush_edit_status(
&mut app.world_mut().resource_mut::<ActiveOperator>(),
OperatorPhase::Canceled,
"Object mode",
);
let harness = OperatorInvariantHarness::capture(app.world_mut());
app.update();
harness.assert_canceled(app.world_mut());
assert_eq!(app.world().get::<BrushDesc>(brush_entity), Some(&initial));
harness.assert_no_helpers::<BrushElementGizmo>(app.world_mut());
let state = app.world().resource::<BrushElementGizmoState>();
assert!(state.entity.is_none());
assert!(state.old_brush.is_none());
assert!(!state.changed);
}
} }

View File

@ -380,9 +380,6 @@ fn brush_snapshot_from_convex_points(
collider: None, collider: None,
physics: None, physics: None,
light: None, light: None,
animation_controller: None,
audio_source: None,
audio_listener: None,
player_spawn: false, player_spawn: false,
model: None, model: None,
prefab: None, prefab: None,
@ -535,10 +532,6 @@ fn set_brush_tool_status(
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use crate::history::EditorHistory;
use crate::operators::test_harness::{assert_undo_redo_round_trip, OperatorInvariantHarness};
use crate::selection::SelectedEntity;
use shared::LevelObject;
#[test] #[test]
fn brush_snapshot_centers_polygon_points() { fn brush_snapshot_centers_polygon_points() {
@ -634,69 +627,4 @@ mod tests {
assert!(status.contains("4.2m")); assert!(status.contains("4.2m"));
assert!(status.contains("2 convex parts")); assert!(status.contains("2 convex parts"));
} }
#[test]
fn brush_tool_cancel_resets_modal_state_without_authored_side_effects() {
let mut world = World::new();
world.init_resource::<ActiveOperator>();
world.init_resource::<EditorHistory>();
world.init_resource::<SceneIo>();
let harness = OperatorInvariantHarness::capture(&mut world);
let mut tool = BrushToolState {
active: true,
phase: BrushToolPhase::Height,
vertices: vec![Vec3::ZERO, Vec3::X, Vec3::Z],
height: 7.5,
height_anchor_pointer_y: Some(200.0),
height_anchor_value: 7.5,
};
tool.cancel();
set_brush_tool_status(
&mut world.resource_mut::<ActiveOperator>(),
OperatorPhase::Canceled,
"Canceled",
);
harness.assert_canceled(&mut world);
assert!(!tool.active);
assert_eq!(tool.phase, BrushToolPhase::Outline);
assert!(tool.vertices.is_empty());
assert_eq!(tool.height, DEFAULT_BRUSH_HEIGHT);
assert!(tool.height_anchor_pointer_y.is_none());
}
#[test]
fn decomposed_brush_commit_is_one_undo_group() {
let mut world = World::new();
world.init_resource::<ActiveOperator>();
world.init_resource::<EditorHistory>();
world.init_resource::<SceneIo>();
world.init_resource::<SelectedEntity>();
let harness = OperatorInvariantHarness::capture(&mut world);
let snapshots = brush_snapshots_from_points(
&[
Vec3::new(-1.0, 0.0, -1.0),
Vec3::new(1.0, 0.0, -1.0),
Vec3::new(0.0, 0.0, 0.0),
Vec3::new(1.0, 0.0, 1.0),
Vec3::new(-1.0, 0.0, 1.0),
],
2.0,
)
.unwrap();
spawn_many_with_history(&mut world, snapshots);
set_brush_tool_status(
&mut world.resource_mut::<ActiveOperator>(),
OperatorPhase::Committed,
"Committed brush.draw (2 parts, 2.0m)",
);
harness.assert_committed(&mut world, 1, 2);
assert_undo_redo_round_trip(&mut world, 0, 2, |world| {
let mut query = world.query_filtered::<Entity, With<LevelObject>>();
query.iter(world).count()
});
}
} }

View File

@ -1,10 +1,10 @@
use bevy::math::Rect; use bevy::math::Rect;
use bevy::prelude::*; use bevy::prelude::*;
use bevy_egui::EguiContexts; use bevy_egui::EguiContexts;
use transform_gizmo_bevy::prelude::{Color32, GizmoMode, GizmoOptions, GizmoOrientation}; use transform_gizmo_bevy::prelude::{GizmoMode, GizmoOptions, GizmoOrientation};
use crate::ui::{viewport_keyboard_shortcuts_active, UiState}; use crate::ui::{viewport_keyboard_shortcuts_active, UiState};
use crate::viewport::{ViewportDisplayMode, ViewportSettings}; use crate::viewport::ViewportDisplayMode;
#[derive(Resource, Default, Debug, Clone, Copy, PartialEq, Eq)] #[derive(Resource, Default, Debug, Clone, Copy, PartialEq, Eq)]
pub enum EditorGizmoMode { pub enum EditorGizmoMode {
@ -27,7 +27,6 @@ impl Plugin for EditorGizmoPlugin {
fn build(&self, app: &mut App) { fn build(&self, app: &mut App) {
app.init_resource::<EditorGizmoMode>() app.init_resource::<EditorGizmoMode>()
.init_resource::<EditorGizmoSpace>() .init_resource::<EditorGizmoSpace>()
.add_systems(Startup, configure_gizmo_visuals)
.add_systems( .add_systems(
Update, Update,
( (
@ -40,19 +39,6 @@ impl Plugin for EditorGizmoPlugin {
} }
} }
fn configure_gizmo_visuals(mut options: ResMut<GizmoOptions>) {
options.visuals.x_color = Color32::from_rgb(232, 83, 105);
options.visuals.y_color = Color32::from_rgb(91, 201, 130);
options.visuals.z_color = Color32::from_rgb(77, 147, 235);
options.visuals.s_color = Color32::from_rgb(221, 225, 230);
options.visuals.highlight_color = Some(Color32::from_rgb(248, 206, 105));
options.visuals.inactive_alpha = 0.9;
options.visuals.highlight_alpha = 1.0;
options.visuals.stroke_width = 3.0;
options.visuals.gizmo_size = 92.0;
options.group_targets = true;
}
fn handle_gizmo_hotkeys( fn handle_gizmo_hotkeys(
keys: Res<ButtonInput<KeyCode>>, keys: Res<ButtonInput<KeyCode>>,
buttons: Res<ButtonInput<MouseButton>>, buttons: Res<ButtonInput<MouseButton>>,
@ -111,10 +97,9 @@ fn sync_gizmo_viewport_rect(
fn sync_gizmo_options( fn sync_gizmo_options(
mode: Res<EditorGizmoMode>, mode: Res<EditorGizmoMode>,
space: Res<EditorGizmoSpace>, space: Res<EditorGizmoSpace>,
settings: Res<ViewportSettings>,
mut options: ResMut<GizmoOptions>, mut options: ResMut<GizmoOptions>,
) { ) {
if !mode.is_changed() && !space.is_changed() && !settings.is_changed() { if !mode.is_changed() && !space.is_changed() {
return; return;
} }
@ -127,7 +112,5 @@ fn sync_gizmo_options(
EditorGizmoSpace::World => GizmoOrientation::Global, EditorGizmoSpace::World => GizmoOrientation::Global,
EditorGizmoSpace::Local => GizmoOrientation::Local, EditorGizmoSpace::Local => GizmoOrientation::Local,
}; };
options.snapping = settings.snap_translate;
options.snap_distance = settings.snap_increment.max(0.01);
options.mode_override = None; options.mode_override = None;
} }

View File

@ -269,42 +269,26 @@ fn draw_viewport_grid(
return; return;
} }
let step = settings.snap_increment.max(0.25); let step = settings.snap_increment.max(0.5);
let extent = 40.0; let extent = 32.0;
let divisions = (extent / step).ceil() as i32; let mut x = -extent;
for index in -divisions..=divisions { while x <= extent {
let coordinate = index as f32 * step;
if coordinate.abs() > extent || index == 0 {
continue;
}
let major = index.rem_euclid(10) == 0;
let color = if major {
Color::srgba(0.43, 0.46, 0.5, 0.38)
} else {
Color::srgba(0.36, 0.39, 0.43, 0.2)
};
gizmos.line( gizmos.line(
Vec3::new(coordinate, 0.0, -extent), Vec3::new(x, 0.0, -extent),
Vec3::new(coordinate, 0.0, extent), Vec3::new(x, 0.0, extent),
color, Color::srgba(0.35, 0.38, 0.42, 0.35),
);
gizmos.line(
Vec3::new(-extent, 0.0, coordinate),
Vec3::new(extent, 0.0, coordinate),
color,
); );
x += step;
}
let mut z = -extent;
while z <= extent {
gizmos.line(
Vec3::new(-extent, 0.0, z),
Vec3::new(extent, 0.0, z),
Color::srgba(0.35, 0.38, 0.42, 0.35),
);
z += step;
} }
gizmos.line(
Vec3::new(-extent, 0.002, 0.0),
Vec3::new(extent, 0.002, 0.0),
Color::srgba(0.91, 0.33, 0.41, 0.72),
);
gizmos.line(
Vec3::new(0.0, 0.002, -extent),
Vec3::new(0.0, 0.002, extent),
Color::srgba(0.3, 0.58, 0.92, 0.72),
);
} }
/// Stores the current editor camera as a bookmark named by scene path. /// Stores the current editor camera as a bookmark named by scene path.

View File

@ -463,9 +463,6 @@ pub fn spawn_post_process_volume_at_camera(world: &mut World) {
collider: None, collider: None,
physics: None, physics: None,
light: None, light: None,
animation_controller: None,
audio_source: None,
audio_listener: None,
player_spawn: false, player_spawn: false,
model: None, model: None,
prefab: None, prefab: None,

View File

@ -2,12 +2,11 @@ use bevy::ecs::system::SystemParam;
use bevy::picking::mesh_picking::ray_cast::{MeshRayCast, MeshRayCastSettings, RayCastVisibility}; use bevy::picking::mesh_picking::ray_cast::{MeshRayCast, MeshRayCastSettings, RayCastVisibility};
use bevy::picking::pointer::{PointerAction, PointerInput, PointerInteraction}; use bevy::picking::pointer::{PointerAction, PointerInput, PointerInteraction};
use bevy::prelude::*; use bevy::prelude::*;
use shared::{HydratedPrefabMember, LevelObject}; use shared::LevelObject;
use transform_gizmo_bevy::prelude::GizmoTarget; use transform_gizmo_bevy::prelude::GizmoTarget;
use crate::camera::EditorCamera; use crate::camera::EditorCamera;
use crate::infra::EditorOnly; use crate::infra::EditorOnly;
use crate::scene_io::ComposedSceneMember;
use crate::state::scene_tools_active; use crate::state::scene_tools_active;
use crate::state::EditorMode; use crate::state::EditorMode;
use crate::ui::helpers::ensure_player_spawn_for_edit; use crate::ui::helpers::ensure_player_spawn_for_edit;
@ -383,15 +382,7 @@ fn sync_gizmo_targets(
mut selected: ResMut<SelectedEntity>, mut selected: ResMut<SelectedEntity>,
mut commands: Commands, mut commands: Commands,
targets: Query<(Entity, Option<&BrushElementGizmo>), With<GizmoTarget>>, targets: Query<(Entity, Option<&BrushElementGizmo>), With<GizmoTarget>>,
level_objects: Query< level_objects: Query<(), (With<LevelObject>, Without<EditorOnly>)>,
(),
(
With<LevelObject>,
Without<EditorOnly>,
Without<HydratedPrefabMember>,
Without<ComposedSceneMember>,
),
>,
editor_only: Query<(), With<EditorOnly>>, editor_only: Query<(), With<EditorOnly>>,
transforms: Query<(), With<Transform>>, transforms: Query<(), With<Transform>>,
hierarchy: Option<Res<HierarchyPanelState>>, hierarchy: Option<Res<HierarchyPanelState>>,
@ -416,11 +407,11 @@ fn sync_gizmo_targets(
if brush_element_gizmo.is_some() { if brush_element_gizmo.is_some() {
continue; continue;
} }
if !ui_state.selected_entities.contains(entity) || !level_objects.contains(entity) { if !ui_state.selected_entities.contains(entity) {
commands.entity(entity).remove::<GizmoTarget>(); commands.entity(entity).remove::<GizmoTarget>();
} }
} }
for entity in ui_state.selected_entities.iter() { if let Some(entity) = ui_state.selected_entities.as_slice().first().copied() {
if level_objects.contains(entity) if level_objects.contains(entity)
&& transforms.contains(entity) && transforms.contains(entity)
&& !targets.contains(entity) && !targets.contains(entity)
@ -432,42 +423,3 @@ fn sync_gizmo_targets(
} }
} }
} }
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn multi_selection_receives_grouped_gizmo_targets() {
let mut app = App::new();
let first = app
.world_mut()
.spawn((LevelObject, Transform::default()))
.id();
let second = app
.world_mut()
.spawn((LevelObject, Transform::default()))
.id();
let mut ui_state = UiState::default_layout();
ui_state.selected_entities.select_replace(first);
ui_state.selected_entities.select_maybe_add(second, true);
app.insert_resource(ui_state)
.init_resource::<SelectedEntity>()
.init_resource::<ViewportDisplayMode>()
.init_resource::<BrushEditMode>()
.add_systems(Update, sync_gizmo_targets);
app.update();
assert!(app.world().get::<GizmoTarget>(first).is_some());
assert!(app.world().get::<GizmoTarget>(second).is_some());
app.world_mut()
.resource_mut::<UiState>()
.selected_entities
.select_replace(first);
app.update();
assert!(app.world().get::<GizmoTarget>(first).is_some());
assert!(app.world().get::<GizmoTarget>(second).is_none());
}
}

View File

@ -1,4 +1,4 @@
//! Editor selection treatment: active/secondary AABB shells, x-ray, and corner brackets. //! Editor selection outline: cheap AABB shells, dual depth pass (rim + x-ray).
use avian3d::prelude::ColliderConstructor; use avian3d::prelude::ColliderConstructor;
use bevy::asset::{load_internal_asset, uuid_handle}; use bevy::asset::{load_internal_asset, uuid_handle};
@ -21,10 +21,10 @@ use crate::viewport::ViewportDisplayMode;
const SELECTION_OUTLINE_SHADER: Handle<Shader> = const SELECTION_OUTLINE_SHADER: Handle<Shader> =
uuid_handle!("c4e8f1a2-9b3d-4e7c-a1f5-6d2b8e0c4a91"); uuid_handle!("c4e8f1a2-9b3d-4e7c-a1f5-6d2b8e0c4a91");
const PRIMARY_VISIBLE_COLOR: Color = Color::srgba(1.0, 0.72, 0.28, 0.56); /// Subtle amber rim on visible faces.
const PRIMARY_OCCLUDED_COLOR: Color = Color::srgba(1.0, 0.66, 0.22, 0.28); const VISIBLE_COLOR: Color = Color::srgba(1.0, 0.82, 0.35, 0.22);
const SECONDARY_VISIBLE_COLOR: Color = Color::srgba(0.31, 0.75, 0.88, 0.42); /// Soft cyan through occluders.
const SECONDARY_OCCLUDED_COLOR: Color = Color::srgba(0.31, 0.68, 0.82, 0.2); const OCCLUDED_COLOR: Color = Color::srgba(0.35, 0.7, 0.95, 0.15);
const BOUNDS_PADDING: f32 = 1.06; const BOUNDS_PADDING: f32 = 1.06;
#[derive(Clone, Copy, ShaderType)] #[derive(Clone, Copy, ShaderType)]
@ -50,8 +50,8 @@ pub struct SelectionOutlineOccludedMaterial {
fn outline_uniform(color: Color, pass_kind: u32) -> OutlineUniform { fn outline_uniform(color: Color, pass_kind: u32) -> OutlineUniform {
OutlineUniform { OutlineUniform {
color: color.into(), color: color.into(),
rim_power: 5.5, rim_power: 10.5,
rim_mix: 0.9, rim_mix: 0.35,
pass_kind, pass_kind,
} }
} }
@ -106,25 +106,16 @@ enum SelectionOutlinePass {
Occluded, Occluded,
} }
#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
enum SelectionOutlineRole {
Primary,
Secondary,
}
#[derive(Component)] #[derive(Component)]
pub(crate) struct SelectionOutlineShell { pub(crate) struct SelectionOutlineShell {
root: Entity, root: Entity,
pass: SelectionOutlinePass, pass: SelectionOutlinePass,
role: SelectionOutlineRole,
} }
#[derive(Resource)] #[derive(Resource)]
struct SelectionOutlineMaterials { struct SelectionOutlineMaterials {
primary_occluded: Handle<SelectionOutlineOccludedMaterial>, occluded: Handle<SelectionOutlineOccludedMaterial>,
primary_visible: Handle<SelectionOutlineVisibleMaterial>, visible: Handle<SelectionOutlineVisibleMaterial>,
secondary_occluded: Handle<SelectionOutlineOccludedMaterial>,
secondary_visible: Handle<SelectionOutlineVisibleMaterial>,
} }
#[derive(Resource)] #[derive(Resource)]
@ -161,12 +152,7 @@ impl Plugin for SelectionOutlinePlugin {
) )
.add_systems( .add_systems(
Update, Update,
( sync_selection_outline_meshes.run_if(scene_tools_active),
sync_selection_outline_meshes,
draw_selection_corner_brackets,
)
.chain()
.run_if(scene_tools_active),
); );
} }
} }
@ -183,17 +169,11 @@ fn init_selection_outline_materials(
mut commands: Commands, mut commands: Commands,
) { ) {
commands.insert_resource(SelectionOutlineMaterials { commands.insert_resource(SelectionOutlineMaterials {
primary_visible: visible.add(SelectionOutlineVisibleMaterial { visible: visible.add(SelectionOutlineVisibleMaterial {
uniform: outline_uniform(PRIMARY_VISIBLE_COLOR, 0), uniform: outline_uniform(VISIBLE_COLOR, 0),
}), }),
primary_occluded: occluded.add(SelectionOutlineOccludedMaterial { occluded: occluded.add(SelectionOutlineOccludedMaterial {
uniform: outline_uniform(PRIMARY_OCCLUDED_COLOR, 1), uniform: outline_uniform(OCCLUDED_COLOR, 1),
}),
secondary_visible: visible.add(SelectionOutlineVisibleMaterial {
uniform: outline_uniform(SECONDARY_VISIBLE_COLOR, 0),
}),
secondary_occluded: occluded.add(SelectionOutlineOccludedMaterial {
uniform: outline_uniform(SECONDARY_OCCLUDED_COLOR, 1),
}), }),
}); });
} }
@ -205,7 +185,7 @@ fn sync_selection_outline_meshes(
materials: Res<SelectionOutlineMaterials>, materials: Res<SelectionOutlineMaterials>,
unit_box: Res<SelectionOutlineUnitBox>, unit_box: Res<SelectionOutlineUnitBox>,
mut commands: Commands, mut commands: Commands,
mut shells: Query<(Entity, &SelectionOutlineShell, &mut Transform)>, shells: Query<(Entity, &SelectionOutlineShell)>,
children: Query<&Children>, children: Query<&Children>,
mesh_entities: Query<(Entity, &Mesh3d), With<Mesh3d>>, mesh_entities: Query<(Entity, &Mesh3d), With<Mesh3d>>,
globals: Query<&GlobalTransform>, globals: Query<&GlobalTransform>,
@ -215,14 +195,16 @@ fn sync_selection_outline_meshes(
colliders: Query<&ColliderDesc>, colliders: Query<&ColliderDesc>,
level_objects: Query<(), (With<LevelObject>, Without<EditorOnly>)>, level_objects: Query<(), (With<LevelObject>, Without<EditorOnly>)>,
) { ) {
let selected: Vec<Entity> = ui_state.selected_entities.iter().collect(); if !ui_state.is_changed() && !display.is_changed() {
return;
}
let mut desired: std::collections::HashMap< let selected: std::collections::HashSet<Entity> = ui_state.selected_entities.iter().collect();
(Entity, SelectionOutlinePass, SelectionOutlineRole),
SelectionBounds, let mut desired: std::collections::HashMap<(Entity, SelectionOutlinePass), SelectionBounds> =
> = std::collections::HashMap::new(); std::collections::HashMap::new();
if !display.clean_game_view { if !display.clean_game_view {
for (index, &root) in selected.iter().enumerate() { for &root in &selected {
if !level_objects.contains(root) { if !level_objects.contains(root) {
continue; continue;
} }
@ -241,30 +223,24 @@ fn sync_selection_outline_meshes(
if bounds.half_extents.max_element() <= f32::EPSILON { if bounds.half_extents.max_element() <= f32::EPSILON {
continue; continue;
} }
let role = if index == 0 { desired.insert((root, SelectionOutlinePass::Occluded), bounds);
SelectionOutlineRole::Primary desired.insert((root, SelectionOutlinePass::Visible), bounds);
} else {
SelectionOutlineRole::Secondary
};
desired.insert((root, SelectionOutlinePass::Occluded, role), bounds);
desired.insert((root, SelectionOutlinePass::Visible, role), bounds);
} }
} }
let mut existing = std::collections::HashSet::new(); for (shell_entity, shell) in &shells {
for (shell_entity, shell, mut transform) in &mut shells { if !desired.contains_key(&(shell.root, shell.pass)) {
let key = (shell.root, shell.pass, shell.role);
if let Some(bounds) = desired.get(&key) {
transform.translation = bounds.center;
transform.scale = bounds.half_extents * 2.0;
existing.insert(key);
} else {
commands.entity(shell_entity).despawn(); commands.entity(shell_entity).despawn();
} }
} }
for ((root, pass, role), bounds) in desired { let existing: std::collections::HashSet<(Entity, SelectionOutlinePass)> = shells
if existing.contains(&(root, pass, role)) { .iter()
.map(|(_, shell)| (shell.root, shell.pass))
.collect();
for ((root, pass), bounds) in desired {
if existing.contains(&(root, pass)) {
continue; continue;
} }
@ -272,7 +248,7 @@ fn sync_selection_outline_meshes(
EditorOnly, EditorOnly,
RaytracingExcluded, RaytracingExcluded,
Name::new("Selection Outline"), Name::new("Selection Outline"),
SelectionOutlineShell { root, pass, role }, SelectionOutlineShell { root, pass },
Mesh3d(unit_box.0.clone()), Mesh3d(unit_box.0.clone()),
Transform { Transform {
translation: bounds.center, translation: bounds.center,
@ -283,72 +259,13 @@ fn sync_selection_outline_meshes(
ChildOf(root), ChildOf(root),
)); ));
match (role, pass) { match pass {
(SelectionOutlineRole::Primary, SelectionOutlinePass::Occluded) => { SelectionOutlinePass::Occluded => {
entity_commands.insert(MeshMaterial3d(materials.primary_occluded.clone())); entity_commands.insert(MeshMaterial3d(materials.occluded.clone()));
} }
(SelectionOutlineRole::Primary, SelectionOutlinePass::Visible) => { SelectionOutlinePass::Visible => {
entity_commands.insert(MeshMaterial3d(materials.primary_visible.clone())); entity_commands.insert(MeshMaterial3d(materials.visible.clone()));
} }
(SelectionOutlineRole::Secondary, SelectionOutlinePass::Occluded) => {
entity_commands.insert(MeshMaterial3d(materials.secondary_occluded.clone()));
}
(SelectionOutlineRole::Secondary, SelectionOutlinePass::Visible) => {
entity_commands.insert(MeshMaterial3d(materials.secondary_visible.clone()));
}
}
}
}
fn draw_selection_corner_brackets(
display: Res<ViewportDisplayMode>,
mut gizmos: Gizmos,
shells: Query<(&SelectionOutlineShell, &GlobalTransform)>,
) {
if display.clean_game_view {
return;
}
for (shell, global) in &shells {
if shell.pass != SelectionOutlinePass::Visible {
continue;
}
let (scale, rotation, center) = global.to_scale_rotation_translation();
let half = scale.abs() * 0.5;
if half.max_element() <= f32::EPSILON {
continue;
}
let color = match shell.role {
SelectionOutlineRole::Primary => Color::srgba(1.0, 0.76, 0.34, 0.98),
SelectionOutlineRole::Secondary => Color::srgba(0.38, 0.78, 0.9, 0.78),
};
draw_corner_brackets(&mut gizmos, center, rotation, half, color);
}
}
fn draw_corner_brackets(
gizmos: &mut Gizmos,
center: Vec3,
rotation: Quat,
half: Vec3,
color: Color,
) {
let lengths = (half * 0.28).clamp(Vec3::splat(0.06), Vec3::splat(0.55));
for signs in [
Vec3::new(-1.0, -1.0, -1.0),
Vec3::new(1.0, -1.0, -1.0),
Vec3::new(-1.0, 1.0, -1.0),
Vec3::new(1.0, 1.0, -1.0),
Vec3::new(-1.0, -1.0, 1.0),
Vec3::new(1.0, -1.0, 1.0),
Vec3::new(-1.0, 1.0, 1.0),
Vec3::new(1.0, 1.0, 1.0),
] {
let corner = center + rotation * (half * signs);
for axis in [Vec3::X, Vec3::Y, Vec3::Z] {
let length = lengths.dot(axis);
let direction = rotation * (axis * -signs.dot(axis));
gizmos.line(corner, corner + direction * length, color);
} }
} }
} }

View File

@ -1,20 +1,18 @@
//! Editor-side scene visualizers for authoring/runtime entities that have no mesh. //! Editor-side scene visualizers for authoring/runtime entities that have no mesh.
use avian3d::prelude::ColliderConstructor; use avian3d::prelude::ColliderConstructor;
use bevy::ecs::system::SystemParam;
use bevy::prelude::*; use bevy::prelude::*;
use shared::{ use shared::{
AudioListenerDesc, AudioSourceDesc, AuthoringLightKind, ColliderDesc, ColliderShapeDesc, AuthoringLightKind, ColliderDesc, ColliderShapeDesc, LevelObject, LightDesc, ModelRef,
LevelObject, LightDesc, ModelRef, ObjectiveMarker, PhysicsBody, PlayerSpawn, ObjectiveMarker, PhysicsBody, PlayerSpawn, PostProcessVolumeDesc, PrefabRef, ProjectSun,
PostProcessVolumeDesc, PrefabRef, ProjectSun, RaytracingExcluded, TeamSpawn, TriggerVolume, RaytracingExcluded, TeamSpawn, TriggerVolume, WeaponSpawn,
WeaponSpawn,
}; };
use sim::tuning::{EYE_OFFSET_STAND, PLAYER_LENGTH, PLAYER_RADIUS}; use sim::tuning::{EYE_OFFSET_STAND, PLAYER_LENGTH, PLAYER_RADIUS};
use sim::{Player, PlayerCamera}; use sim::{Player, PlayerCamera};
use crate::infra::EditorOnly; use crate::infra::EditorOnly;
use crate::selection::SelectedEntity; use crate::selection::SelectedEntity;
use crate::state::{scene_tools_active, EditorMode, PlayPossession}; use crate::state::scene_tools_active;
use crate::viewport::{EditorViewportMode, ViewportDisplayMode}; use crate::viewport::{EditorViewportMode, ViewportDisplayMode};
#[derive(Resource, Debug, Clone)] #[derive(Resource, Debug, Clone)]
@ -24,7 +22,6 @@ pub struct EditorVisualizationSettings {
pub show_actor_icon_actor: bool, pub show_actor_icon_actor: bool,
pub show_actor_icon_mesh: bool, pub show_actor_icon_mesh: bool,
pub show_actor_icon_light: bool, pub show_actor_icon_light: bool,
pub show_actor_icon_audio: bool,
pub show_actor_icon_physics: bool, pub show_actor_icon_physics: bool,
pub show_actor_icon_gameplay: bool, pub show_actor_icon_gameplay: bool,
pub show_actor_icon_volume: bool, pub show_actor_icon_volume: bool,
@ -32,10 +29,7 @@ pub struct EditorVisualizationSettings {
pub show_actor_icon_debug: bool, pub show_actor_icon_debug: bool,
pub show_colliders: bool, pub show_colliders: bool,
pub show_lights: bool, pub show_lights: bool,
pub show_audio: bool,
pub show_spawns: bool, pub show_spawns: bool,
pub show_gameplay: bool,
pub show_volumes: bool,
pub show_prefab_model_anchors: bool, pub show_prefab_model_anchors: bool,
pub show_runtime: bool, pub show_runtime: bool,
pub pickable_proxies: bool, pub pickable_proxies: bool,
@ -49,7 +43,6 @@ impl Default for EditorVisualizationSettings {
show_actor_icon_actor: true, show_actor_icon_actor: true,
show_actor_icon_mesh: true, show_actor_icon_mesh: true,
show_actor_icon_light: true, show_actor_icon_light: true,
show_actor_icon_audio: true,
show_actor_icon_physics: true, show_actor_icon_physics: true,
show_actor_icon_gameplay: true, show_actor_icon_gameplay: true,
show_actor_icon_volume: true, show_actor_icon_volume: true,
@ -57,10 +50,7 @@ impl Default for EditorVisualizationSettings {
show_actor_icon_debug: true, show_actor_icon_debug: true,
show_colliders: true, show_colliders: true,
show_lights: true, show_lights: true,
show_audio: true,
show_spawns: true, show_spawns: true,
show_gameplay: true,
show_volumes: true,
show_prefab_model_anchors: true, show_prefab_model_anchors: true,
show_runtime: true, show_runtime: true,
pickable_proxies: true, pickable_proxies: true,
@ -92,39 +82,17 @@ struct VisualizerProxyAssets {
material: Handle<StandardMaterial>, material: Handle<StandardMaterial>,
} }
#[derive(SystemParam)]
struct VisualizerTargetQueries<'w, 's> {
transforms: Query<'w, 's, &'static GlobalTransform>,
colliders: Query<
'w,
's,
Entity,
(
With<LevelObject>,
Or<(With<PhysicsBody>, With<ColliderDesc>)>,
),
>,
lights: Query<'w, 's, Entity, (With<LevelObject>, With<LightDesc>)>,
spawns: Query<'w, 's, Entity, (With<LevelObject>, With<PlayerSpawn>)>,
prefabs: Query<'w, 's, Entity, (With<LevelObject>, With<PrefabRef>)>,
models: Query<'w, 's, Entity, (With<LevelObject>, With<ModelRef>)>,
players: Query<'w, 's, Entity, With<Player>>,
cameras: Query<'w, 's, Entity, With<PlayerCamera>>,
project_suns: Query<'w, 's, Entity, With<ProjectSun>>,
}
pub struct EditorVisualizersPlugin; pub struct EditorVisualizersPlugin;
impl Plugin for EditorVisualizersPlugin { impl Plugin for EditorVisualizersPlugin {
fn build(&self, app: &mut App) { fn build(&self, app: &mut App) {
app.init_resource::<EditorVisualizationSettings>() app.init_resource::<EditorVisualizationSettings>()
.add_systems(Startup, init_visualizer_proxy_assets) .add_systems(Startup, init_visualizer_proxy_assets)
.add_systems(Update, sync_visualizer_proxies) .add_systems(Update, sync_visualizer_proxies.run_if(scene_tools_active))
.add_systems( .add_systems(
Update, Update,
( (
draw_visualizers, draw_visualizers,
draw_audio_visualizers,
draw_split_collider_visualizers, draw_split_collider_visualizers,
draw_post_process_volume_gizmos, draw_post_process_volume_gizmos,
) )
@ -135,124 +103,6 @@ impl Plugin for EditorVisualizersPlugin {
} }
} }
fn draw_audio_visualizers(
viewport_mode: Res<EditorViewportMode>,
settings: Res<EditorVisualizationSettings>,
selected: Res<SelectedEntity>,
mut gizmos: Gizmos,
sources: Query<(Entity, &AudioSourceDesc, &GlobalTransform), With<LevelObject>>,
listeners: Query<(Entity, &AudioListenerDesc, &GlobalTransform), With<LevelObject>>,
) {
if *viewport_mode == EditorViewportMode::Collider || !settings.enabled || !settings.show_audio {
return;
}
let Some(selected) = selected.0 else {
return;
};
if let Ok((_, source, global)) = sources.get(selected) {
draw_audio_source(&mut gizmos, global, source);
}
if let Ok((_, listener, global)) = listeners.get(selected) {
draw_audio_listener(&mut gizmos, global, listener);
}
}
fn draw_audio_source(gizmos: &mut Gizmos, global: &GlobalTransform, source: &AudioSourceDesc) {
let center = global.translation();
let (min_distance, max_distance) = normalized_audio_radii(
source.attenuation.min_distance,
source.attenuation.max_distance,
);
let blend = source.spatial_blend.clamp(0.0, 1.0);
let inner = Color::srgba(0.28, 0.92, 1.0, 0.9);
let outer = Color::srgba(0.22, 0.68, 1.0, 0.28 + blend * 0.32);
gizmos
.sphere(
Isometry3d::new(center, global.rotation()),
min_distance,
inner,
)
.resolution(28);
gizmos
.sphere(
Isometry3d::new(center, global.rotation()),
max_distance,
outer,
)
.resolution(36);
}
fn draw_audio_listener(
gizmos: &mut Gizmos,
global: &GlobalTransform,
listener: &AudioListenerDesc,
) {
let geometry =
audio_listener_geometry(global.translation(), global.rotation(), listener.ear_gap);
let color = if listener.enabled {
Color::srgba(0.35, 1.0, 0.76, 1.0)
} else {
Color::srgba(0.55, 0.62, 0.66, 0.75)
};
gizmos.line(geometry.left_ear, geometry.right_ear, color);
for ear in [geometry.left_ear, geometry.right_ear] {
gizmos
.sphere(Isometry3d::from_translation(ear), 0.07, color)
.resolution(12);
}
gizmos.arrow(geometry.center, geometry.forward_end, color);
gizmos
.circle(
Isometry3d::new(geometry.center, global.rotation()),
geometry.head_radius,
color.with_alpha(0.55),
)
.resolution(24);
}
#[derive(Debug, Clone, Copy, PartialEq)]
struct AudioListenerGeometry {
center: Vec3,
left_ear: Vec3,
right_ear: Vec3,
forward_end: Vec3,
head_radius: f32,
}
fn normalized_audio_radii(min_distance: f32, max_distance: f32) -> (f32, f32) {
let min_distance = if min_distance.is_finite() {
min_distance.max(0.05)
} else {
0.05
};
let max_distance = if max_distance.is_finite() {
max_distance.max(min_distance)
} else {
min_distance
};
(min_distance, max_distance)
}
fn audio_listener_geometry(center: Vec3, rotation: Quat, ear_gap: f32) -> AudioListenerGeometry {
let ear_gap = if ear_gap.is_finite() {
ear_gap.max(0.02)
} else {
0.02
};
let right = rotation * Vec3::X;
let forward = rotation * Vec3::NEG_Z;
AudioListenerGeometry {
center,
left_ear: center - right * ear_gap * 0.5,
right_ear: center + right * ear_gap * 0.5,
forward_end: center + forward * 0.85,
head_radius: (ear_gap * 0.65).max(0.14),
}
}
fn viewport_editor_overlays_visible(display: Res<ViewportDisplayMode>) -> bool { fn viewport_editor_overlays_visible(display: Res<ViewportDisplayMode>) -> bool {
!display.clean_game_view !display.clean_game_view
} }
@ -275,80 +125,73 @@ fn init_visualizer_proxy_assets(
#[allow(clippy::too_many_arguments)] #[allow(clippy::too_many_arguments)]
fn sync_visualizer_proxies( fn sync_visualizer_proxies(
mode: Res<State<EditorMode>>,
possession: Res<PlayPossession>,
viewport_mode: Res<EditorViewportMode>, viewport_mode: Res<EditorViewportMode>,
display: Res<ViewportDisplayMode>, display: Res<ViewportDisplayMode>,
settings: Res<EditorVisualizationSettings>, settings: Res<EditorVisualizationSettings>,
assets: Res<VisualizerProxyAssets>, assets: Res<VisualizerProxyAssets>,
mut commands: Commands, mut commands: Commands,
mut proxies: Query<(Entity, &EditorVisualizerProxy, &mut Transform)>, mut proxies: Query<(Entity, &EditorVisualizerProxy, &mut Transform)>,
targets: VisualizerTargetQueries, transforms: Query<&GlobalTransform>,
colliders: Query<
Entity,
(
With<LevelObject>,
Or<(With<PhysicsBody>, With<ColliderDesc>)>,
),
>,
lights: Query<Entity, (With<LevelObject>, With<LightDesc>)>,
spawns: Query<Entity, (With<LevelObject>, With<PlayerSpawn>)>,
prefabs: Query<Entity, (With<LevelObject>, With<PrefabRef>)>,
models: Query<Entity, (With<LevelObject>, With<ModelRef>)>,
players: Query<Entity, With<Player>>,
cameras: Query<Entity, With<PlayerCamera>>,
project_suns: Query<Entity, With<ProjectSun>>,
) { ) {
let mut desired = std::collections::HashSet::new(); let mut desired = std::collections::HashSet::new();
let collider_mode = *viewport_mode == EditorViewportMode::Collider; let collider_mode = *viewport_mode == EditorViewportMode::Collider;
let tools_active = *mode.get() == EditorMode::Editing let proxies_active = !display.clean_game_view
|| (*mode.get() == EditorMode::Playing && *possession == PlayPossession::Ejected);
let proxies_active = tools_active
&& !display.clean_game_view
&& settings.enabled && settings.enabled
&& (settings.pickable_proxies || collider_mode); && (settings.pickable_proxies || collider_mode);
if proxies_active { if proxies_active {
if settings.show_colliders || collider_mode { if settings.show_colliders || collider_mode {
desired.extend( desired.extend(
targets colliders
.colliders
.iter() .iter()
.map(|target| (target, VisualizerKind::Collider)), .map(|target| (target, VisualizerKind::Collider)),
); );
} }
if settings.show_lights && !collider_mode { if settings.show_lights && !collider_mode {
desired.extend( desired.extend(lights.iter().map(|target| (target, VisualizerKind::Light)));
targets
.lights
.iter()
.map(|target| (target, VisualizerKind::Light)),
);
} }
if settings.show_spawns && !collider_mode { if settings.show_spawns && !collider_mode {
desired.extend( desired.extend(
targets spawns
.spawns
.iter() .iter()
.map(|target| (target, VisualizerKind::PlayerSpawn)), .map(|target| (target, VisualizerKind::PlayerSpawn)),
); );
} }
if settings.show_prefab_model_anchors && !collider_mode { if settings.show_prefab_model_anchors && !collider_mode {
desired.extend( desired.extend(
targets prefabs
.prefabs
.iter() .iter()
.map(|target| (target, VisualizerKind::Prefab)), .map(|target| (target, VisualizerKind::Prefab)),
); );
desired.extend( desired.extend(models.iter().map(|target| (target, VisualizerKind::Model)));
targets
.models
.iter()
.map(|target| (target, VisualizerKind::Model)),
);
} }
if settings.show_runtime && !collider_mode { if settings.show_runtime && !collider_mode {
desired.extend( desired.extend(
targets players
.players
.iter() .iter()
.map(|target| (target, VisualizerKind::Player)), .map(|target| (target, VisualizerKind::Player)),
); );
desired.extend( desired.extend(
targets cameras
.cameras
.iter() .iter()
.map(|target| (target, VisualizerKind::Camera)), .map(|target| (target, VisualizerKind::Camera)),
); );
desired.extend( desired.extend(
targets project_suns
.project_suns
.iter() .iter()
.map(|target| (target, VisualizerKind::ProjectSun)), .map(|target| (target, VisualizerKind::ProjectSun)),
); );
@ -361,17 +204,15 @@ fn sync_visualizer_proxies(
.collect(); .collect();
for (entity, proxy, mut transform) in &mut proxies { for (entity, proxy, mut transform) in &mut proxies {
if !desired.contains(&(proxy.target, proxy.kind)) if !desired.contains(&(proxy.target, proxy.kind)) || transforms.get(proxy.target).is_err() {
|| targets.transforms.get(proxy.target).is_err()
{
commands.entity(entity).despawn(); commands.entity(entity).despawn();
} else if let Ok(global) = targets.transforms.get(proxy.target) { } else if let Ok(global) = transforms.get(proxy.target) {
transform.translation = global.translation(); transform.translation = global.translation();
} }
} }
for (target, kind) in desired { for (target, kind) in desired {
let Ok(global) = targets.transforms.get(target) else { let Ok(global) = transforms.get(target) else {
continue; continue;
}; };
if existing.contains(&(target, kind)) { if existing.contains(&(target, kind)) {
@ -498,43 +339,41 @@ fn draw_visualizers(
} }
} }
if settings.show_gameplay && !collider_mode { for (entity, trigger, global) in &triggers {
for (entity, trigger, global) in &triggers { draw_oriented_box(
draw_oriented_box( &mut gizmos,
&mut gizmos, global,
global, trigger.half_extents,
trigger.half_extents, if selected.0 == Some(entity) {
if selected.0 == Some(entity) { Color::srgba(0.95, 0.35, 0.95, 0.85)
Color::srgba(1.0, 0.55, 0.95, 0.95) } else {
} else { Color::srgba(0.9, 0.3, 0.9, 0.45)
Color::srgba(0.9, 0.3, 0.9, 0.42) },
}, );
); }
} for (entity, global) in &weapons {
for (entity, global) in &weapons { draw_anchor(
draw_anchor( &mut gizmos,
&mut gizmos, global,
global, selected.0 == Some(entity),
selected.0 == Some(entity), Color::srgb(1.0, 0.35, 0.2),
Color::srgb(1.0, 0.35, 0.2), );
); }
} for (entity, global) in &teams {
for (entity, global) in &teams { draw_anchor(
draw_anchor( &mut gizmos,
&mut gizmos, global,
global, selected.0 == Some(entity),
selected.0 == Some(entity), Color::srgb(0.2, 0.85, 0.35),
Color::srgb(0.2, 0.85, 0.35), );
); }
} for (entity, global) in &objectives {
for (entity, global) in &objectives { draw_anchor(
draw_anchor( &mut gizmos,
&mut gizmos, global,
global, selected.0 == Some(entity),
selected.0 == Some(entity), Color::srgb(0.95, 0.85, 0.2),
Color::srgb(0.95, 0.85, 0.2), );
);
}
} }
} }
@ -574,8 +413,7 @@ fn draw_post_process_volume_gizmos(
mut gizmos: Gizmos, mut gizmos: Gizmos,
volumes: Query<(Entity, &PostProcessVolumeDesc, &GlobalTransform), With<LevelObject>>, volumes: Query<(Entity, &PostProcessVolumeDesc, &GlobalTransform), With<LevelObject>>,
) { ) {
if *viewport_mode == EditorViewportMode::Collider || !settings.enabled || !settings.show_volumes if *viewport_mode == EditorViewportMode::Collider || !settings.enabled {
{
return; return;
} }
for (entity, volume, global) in &volumes { for (entity, volume, global) in &volumes {
@ -586,7 +424,7 @@ fn draw_post_process_volume_gizmos(
global, global,
volume.half_extents, volume.half_extents,
if selected.0 == Some(entity) { if selected.0 == Some(entity) {
Color::srgba(1.0, 0.76, 0.34, 1.0) border.with_alpha(1.0)
} else { } else {
border.with_alpha(0.55) border.with_alpha(0.55)
}, },
@ -728,20 +566,12 @@ fn draw_light(
let up = global.up().as_vec3(); let up = global.up().as_vec3();
let outer_deg = light.outer_angle_deg.max(light.inner_angle_deg); let outer_deg = light.outer_angle_deg.max(light.inner_angle_deg);
let radius = range * (outer_deg.to_radians() * 0.5).tan().max(0.15); let radius = range * (outer_deg.to_radians() * 0.5).tan().max(0.15);
let inner_radius = range * (light.inner_angle_deg.to_radians() * 0.5).tan().max(0.08);
for offset in [right, -right, up, -up] { for offset in [right, -right, up, -up] {
gizmos.line(center, end + offset * radius, color); gizmos.line(center, end + offset * radius, color);
} }
gizmos gizmos
.circle(Isometry3d::new(end, global.rotation()), radius, color) .circle(Isometry3d::new(end, global.rotation()), radius, color)
.resolution(24); .resolution(24);
gizmos
.circle(
Isometry3d::new(end, global.rotation()),
inner_radius,
color.with_alpha(0.38),
)
.resolution(24);
if emphasize { if emphasize {
gizmos gizmos
.sphere( .sphere(
@ -797,7 +627,7 @@ fn draw_spawn(gizmos: &mut Gizmos, global: &GlobalTransform, color: Color) {
fn draw_anchor(gizmos: &mut Gizmos, global: &GlobalTransform, selected: bool, base: Color) { fn draw_anchor(gizmos: &mut Gizmos, global: &GlobalTransform, selected: bool, base: Color) {
let center = global.translation(); let center = global.translation();
let color = if selected { let color = if selected {
Color::srgba(1.0, 0.76, 0.34, 1.0) Color::srgba(1.0, 0.85, 1.0, 1.0)
} else { } else {
base.with_alpha(0.75) base.with_alpha(0.75)
}; };
@ -851,34 +681,19 @@ fn draw_camera_frustum(gizmos: &mut Gizmos, global: &GlobalTransform, color: Col
let forward = global.forward().as_vec3(); let forward = global.forward().as_vec3();
let right = global.right().as_vec3(); let right = global.right().as_vec3();
let up = global.up().as_vec3(); let up = global.up().as_vec3();
let near_center = origin + forward * 0.55; let near = origin + forward * 0.8;
let far_center = origin + forward * 2.4; let half_w = 0.45;
let near = frustum_corners(near_center, right, up, 0.28, 0.17); let half_h = 0.28;
let far = frustum_corners(far_center, right, up, 1.1, 0.66); let corners = [
for corner in near { near + right * half_w + up * half_h,
gizmos.line(origin, corner, color.with_alpha(0.72)); near - right * half_w + up * half_h,
near - right * half_w - up * half_h,
near + right * half_w - up * half_h,
];
for corner in corners {
gizmos.line(origin, corner, color);
} }
gizmos.lineloop(near, color.with_alpha(0.72)); gizmos.lineloop(corners, color);
gizmos.lineloop(far, color);
for index in 0..4 {
gizmos.line(near[index], far[index], color);
}
gizmos.arrow(origin, origin + forward * 1.15, color.with_alpha(0.8));
}
fn frustum_corners(
center: Vec3,
right: Vec3,
up: Vec3,
half_width: f32,
half_height: f32,
) -> [Vec3; 4] {
[
center + right * half_width + up * half_height,
center - right * half_width + up * half_height,
center - right * half_width - up * half_height,
center + right * half_width - up * half_height,
]
} }
fn draw_box(gizmos: &mut Gizmos, center: Vec3, rotation: Quat, half: Vec3, color: Color) { fn draw_box(gizmos: &mut Gizmos, center: Vec3, rotation: Quat, half: Vec3, color: Color) {
@ -983,31 +798,3 @@ fn box_edges() -> [(usize, usize); 12] {
(3, 7), (3, 7),
] ]
} }
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn audio_radii_are_finite_positive_and_ordered() {
assert_eq!(normalized_audio_radii(2.0, 8.0), (2.0, 8.0));
assert_eq!(normalized_audio_radii(4.0, 1.0), (4.0, 4.0));
assert_eq!(
normalized_audio_radii(f32::NAN, f32::INFINITY),
(0.05, 0.05)
);
}
#[test]
fn listener_geometry_respects_rotation_and_ear_gap() {
let geometry = audio_listener_geometry(
Vec3::new(2.0, 3.0, 4.0),
Quat::from_rotation_y(std::f32::consts::FRAC_PI_2),
0.4,
);
assert!((geometry.left_ear.distance(geometry.right_ear) - 0.4).abs() < 1.0e-5);
assert!((geometry.forward_end - Vec3::new(1.15, 3.0, 4.0)).length() < 1.0e-5);
assert!(geometry.head_radius >= 0.14);
}
}

View File

@ -15,13 +15,9 @@ hot-reload = ["dep:hot-lib-reloader", "game_hot/dylib"]
avian3d.workspace = true avian3d.workspace = true
bevy.workspace = true bevy.workspace = true
bevy_ufbx.workspace = true bevy_ufbx.workspace = true
cpal = "0.17.3"
game_hot = { path = "../game_hot" } game_hot = { path = "../game_hot" }
hot-lib-reloader = { version = "0.8.2", optional = true } hot-lib-reloader = { version = "0.8.2", optional = true }
protocol.workspace = true protocol.workspace = true
ron = "0.12"
scene.workspace = true
serde.workspace = true
shared.workspace = true shared.workspace = true
sim.workspace = true sim.workspace = true
settings.workspace = true settings.workspace = true

File diff suppressed because it is too large Load Diff

View File

@ -1,919 +0,0 @@
//! Runtime audio hydration, bus controls, listener resolution, and diagnostics.
use std::collections::{HashMap, HashSet};
use bevy::audio::{
AudioPlayer, AudioSink, AudioSinkPlayback, AudioSource, PlaybackMode, PlaybackSettings,
SpatialAudioSink, SpatialListener, SpatialScale, Volume,
};
use bevy::prelude::*;
use cpal::traits::{DeviceTrait, HostTrait};
use settings::{
AudioSettings, ProjectSettings, AUDIO_BUS_MASTER_ID, AUDIO_GAIN_DB_MAX, AUDIO_GAIN_DB_MIN,
};
use shared::{
asset_server_path, inspector_component_active, ActorId, AudioAttenuationDesc,
AudioListenerDesc, AudioRolloff, AudioSourceDesc, InspectorOrder, LevelObject,
COMPONENT_AUDIO_LISTENER_DESC, COMPONENT_AUDIO_SOURCE_DESC,
};
use sim::{PlayerCamera, SimEnabled};
/// Runtime override for an authored project bus. `None` preserves the project value.
#[derive(Debug, Clone, Copy, Default, PartialEq)]
pub struct AudioBusOverride {
pub gain_db: Option<f32>,
pub muted: Option<bool>,
}
/// Mutable runtime mixer state used by gameplay and options menus.
#[derive(Resource, Debug, Clone, Default)]
pub struct AudioBusRuntime {
overrides: HashMap<String, AudioBusOverride>,
}
impl AudioBusRuntime {
pub fn set_gain_db(&mut self, bus_id: impl Into<String>, gain_db: f32) -> Result<(), String> {
if !gain_db.is_finite() {
return Err("audio bus gain must be finite".into());
}
self.overrides.entry(bus_id.into()).or_default().gain_db =
Some(gain_db.clamp(AUDIO_GAIN_DB_MIN, AUDIO_GAIN_DB_MAX));
Ok(())
}
pub fn set_muted(&mut self, bus_id: impl Into<String>, muted: bool) {
self.overrides.entry(bus_id.into()).or_default().muted = Some(muted);
}
pub fn clear_bus(&mut self, bus_id: &str) {
self.overrides.remove(bus_id);
}
pub fn clear(&mut self) {
self.overrides.clear();
}
pub fn bus_override(&self, bus_id: &str) -> Option<AudioBusOverride> {
self.overrides.get(bus_id).copied()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AudioDeviceStatus {
Unknown,
Available,
Unavailable,
}
/// User-facing audio backend and codec capability snapshot.
#[derive(Resource, Debug, Clone)]
pub struct AudioRuntimeDiagnostics {
pub status: AudioDeviceStatus,
pub backend: String,
pub output_device: Option<String>,
pub message: String,
pub codecs: Vec<String>,
}
impl Default for AudioRuntimeDiagnostics {
fn default() -> Self {
Self {
status: AudioDeviceStatus::Unknown,
backend: String::new(),
output_device: None,
message: "Audio output has not been probed yet.".into(),
codecs: vec![
"Ogg Vorbis".into(),
"WAV".into(),
"MP3".into(),
"FLAC".into(),
],
}
}
}
#[derive(Resource, Debug, Clone, Copy, Default)]
pub struct ActiveAudioListener(pub Option<Entity>);
#[derive(Component, Debug, Clone, PartialEq)]
struct RuntimeAudioSourceState {
descriptor: AudioSourceDesc,
desired_playing: bool,
paused_by_sim: bool,
}
#[derive(Component, Debug, Clone, Copy, PartialEq)]
struct ManagedAudioListener {
ear_gap: f32,
}
#[derive(Component, Debug, Clone)]
pub struct AuthoredAudioVoice {
pub source: Entity,
pub bus: String,
pub source_gain_db: f32,
pub mix_weight: f32,
pub spatial: bool,
pub attenuation: AudioAttenuationDesc,
pub looping: bool,
}
/// Runtime adapter for scene-authored audio descriptors.
pub struct GameAudioPlugin;
impl Plugin for GameAudioPlugin {
fn build(&self, app: &mut App) {
app.init_resource::<AudioBusRuntime>()
.init_resource::<AudioRuntimeDiagnostics>()
.init_resource::<ActiveAudioListener>()
.add_systems(PostStartup, probe_audio_output)
.add_systems(
Update,
(
reconcile_audio_listener,
reconcile_audio_sources,
update_voice_mix,
cleanup_finished_voices,
)
.chain(),
);
}
}
fn probe_audio_output(mut diagnostics: ResMut<AudioRuntimeDiagnostics>) {
let host = cpal::default_host();
diagnostics.backend = host.id().name().to_string();
match host.default_output_device() {
Some(device) => {
let name = device
.description()
.map(|description| description.name().to_string())
.unwrap_or_else(|error| format!("Unknown device ({error})"));
diagnostics.status = AudioDeviceStatus::Available;
diagnostics.output_device = Some(name.clone());
diagnostics.message = format!("Audio output ready on {name}.");
}
None => {
diagnostics.status = AudioDeviceStatus::Unavailable;
diagnostics.output_device = None;
diagnostics.message = "No default audio output device is available.".into();
warn!("{}", diagnostics.message);
}
}
}
/// Returns the effective linear gain for a bus including parent buses and runtime overrides.
pub fn effective_bus_gain_linear(
settings: &AudioSettings,
runtime: &AudioBusRuntime,
requested_bus: &str,
) -> f32 {
let mut current = if settings.bus(requested_bus).is_some() {
requested_bus
} else {
AUDIO_BUS_MASTER_ID
};
let mut visited = HashSet::new();
let mut gain_db = 0.0;
while visited.insert(current.to_string()) {
let Some(bus) = settings.bus(current) else {
break;
};
let runtime_override = runtime.bus_override(current).unwrap_or_default();
if runtime_override.muted.unwrap_or(bus.muted) {
return 0.0;
}
gain_db += runtime_override.gain_db.unwrap_or(bus.gain_db);
let Some(parent) = bus.parent.as_deref() else {
break;
};
current = parent;
}
db_to_linear(gain_db)
}
pub fn db_to_linear(db: f32) -> f32 {
if db <= AUDIO_GAIN_DB_MIN {
0.0
} else {
10.0_f32.powf(db / 20.0)
}
}
/// Equal-power 2D/spatial voice weights for a continuous authored blend.
pub fn audio_voice_mix(spatial_blend: f32) -> Vec<(bool, f32)> {
let blend = spatial_blend.clamp(0.0, 1.0);
if blend <= f32::EPSILON {
vec![(false, 1.0)]
} else if blend >= 1.0 - f32::EPSILON {
vec![(true, 1.0)]
} else {
vec![(false, (1.0 - blend).sqrt()), (true, blend.sqrt())]
}
}
/// Authored attenuation curve, normalized to full gain at `min_distance` and silence at max.
pub fn attenuation_gain(desc: &AudioAttenuationDesc, distance: f32) -> f32 {
if !distance.is_finite() {
return 0.0;
}
let min = desc.min_distance.max(0.001);
let max = desc.max_distance.max(min + 0.001);
if distance <= min {
return 1.0;
}
if distance >= max {
return 0.0;
}
let t = ((distance - min) / (max - min)).clamp(0.0, 1.0);
let factor = desc.rolloff_factor.max(0.001);
match desc.rolloff {
AudioRolloff::Linear => (1.0 - t).powf(factor),
AudioRolloff::Inverse => ((1.0 - t) / (1.0 + factor * 4.0 * t)).clamp(0.0, 1.0),
AudioRolloff::Exponential => ((distance / min).powf(-factor) * (1.0 - t)).clamp(0.0, 1.0),
}
}
/// Coordinate scale used only for rodio's left/right spatial panning.
///
/// Rodio also applies an inverse-square distance gain. Keeping every audible point inside its
/// unity-distance zone preserves the interaural panning ratio while leaving the authored
/// attenuation curve as the sole distance-gain authority.
pub fn spatial_panning_scale(desc: &AudioAttenuationDesc, ear_gap: f32) -> f32 {
const RODIO_UNITY_ZONE_FRACTION: f32 = 0.25;
let audible_radius = if desc.max_distance.is_finite() {
desc.max_distance.max(0.001)
} else {
0.001
};
let ear_gap = if ear_gap.is_finite() {
ear_gap.max(0.001)
} else {
0.001
};
(RODIO_UNITY_ZONE_FRACTION / (audible_radius + ear_gap * 0.5)).max(f32::MIN_POSITIVE)
}
fn reconcile_audio_listener(world: &mut World) {
let enabled = world.resource::<SimEnabled>().0;
let mut candidates = Vec::new();
{
let mut query = world.query_filtered::<(
Entity,
&AudioListenerDesc,
Option<&ActorId>,
Option<&InspectorOrder>,
), With<LevelObject>>();
for (entity, desc, actor_id, order) in query.iter(world) {
if desc.enabled && inspector_component_active(order, COMPONENT_AUDIO_LISTENER_DESC) {
candidates.push((
entity,
*desc,
actor_id.map(|id| id.0.clone()).unwrap_or_default(),
));
}
}
}
candidates.sort_by(|a, b| {
b.1.priority
.cmp(&a.1.priority)
.then_with(|| a.2.cmp(&b.2))
.then_with(|| a.0.to_bits().cmp(&b.0.to_bits()))
});
let selected = if enabled {
candidates
.first()
.map(|(entity, desc, _)| (*entity, desc.ear_gap))
.or_else(|| {
let mut query = world.query_filtered::<Entity, With<PlayerCamera>>();
query
.iter(world)
.min_by_key(|entity| entity.to_bits())
.map(|entity| (entity, 0.2))
})
} else {
None
};
let mut managed_query = world.query::<(Entity, &ManagedAudioListener)>();
let managed: Vec<(Entity, ManagedAudioListener)> = managed_query
.iter(world)
.map(|(entity, listener)| (entity, *listener))
.collect();
for (entity, _) in managed {
if selected.is_none_or(|(selected, _)| selected != entity) {
if let Ok(mut entity_mut) = world.get_entity_mut(entity) {
entity_mut
.remove::<SpatialListener>()
.remove::<ManagedAudioListener>();
}
}
}
if let Some((entity, ear_gap)) = selected {
let ear_gap = ear_gap.max(0.001);
let is_current = world
.get::<ManagedAudioListener>(entity)
.is_some_and(|listener| listener.ear_gap == ear_gap)
&& world.get::<SpatialListener>(entity).is_some();
if !is_current {
if let Ok(mut entity_mut) = world.get_entity_mut(entity) {
entity_mut.insert((
SpatialListener::new(ear_gap),
ManagedAudioListener { ear_gap },
));
}
}
}
let selected_entity = selected.map(|(entity, _)| entity);
let mut active = world.resource_mut::<ActiveAudioListener>();
if active.0 != selected_entity {
active.0 = selected_entity;
}
}
fn reconcile_audio_sources(world: &mut World) {
let sim_enabled = world.resource::<SimEnabled>().0;
let mut sources = Vec::new();
{
let mut query = world.query_filtered::<(
Entity,
&AudioSourceDesc,
Option<&InspectorOrder>,
), With<LevelObject>>();
for (entity, desc, order) in query.iter(world) {
if inspector_component_active(order, COMPONENT_AUDIO_SOURCE_DESC) {
sources.push((entity, desc.clone()));
}
}
}
let source_ids: HashSet<Entity> = sources.iter().map(|(entity, _)| *entity).collect();
let mut voice_query = world.query::<&AuthoredAudioVoice>();
let mut voiced_sources: HashSet<Entity> =
voice_query.iter(world).map(|voice| voice.source).collect();
let mut state_query = world.query_filtered::<Entity, With<RuntimeAudioSourceState>>();
let stale: Vec<Entity> = state_query
.iter(world)
.filter(|entity| !source_ids.contains(entity))
.collect();
for entity in stale {
stop_voice_entities(world, entity);
if let Ok(mut entity_mut) = world.get_entity_mut(entity) {
entity_mut.remove::<RuntimeAudioSourceState>();
}
}
for (entity, descriptor) in sources {
let prior = world.get::<RuntimeAudioSourceState>(entity).cloned();
let mut state = prior.clone().unwrap_or_else(|| RuntimeAudioSourceState {
descriptor: descriptor.clone(),
desired_playing: descriptor.autoplay,
paused_by_sim: false,
});
if state.descriptor != descriptor {
let autoplay_changed = state.descriptor.autoplay != descriptor.autoplay;
stop_voice_entities(world, entity);
voiced_sources.remove(&entity);
state.descriptor = descriptor.clone();
if autoplay_changed {
state.desired_playing = descriptor.autoplay;
}
}
if !sim_enabled {
if !state.paused_by_sim {
set_source_paused(world, entity, true);
state.paused_by_sim = true;
}
} else {
if state.paused_by_sim {
set_source_paused(world, entity, false);
state.paused_by_sim = false;
}
if state.desired_playing && !voiced_sources.contains(&entity) {
if let Err(error) = spawn_source_voices(world, entity, &descriptor) {
warn!("Could not start authored audio source {entity:?}: {error}");
state.desired_playing = false;
} else {
voiced_sources.insert(entity);
}
}
}
if prior.as_ref() != Some(&state) {
if let Ok(mut entity_mut) = world.get_entity_mut(entity) {
entity_mut.insert(state);
}
}
}
}
fn spawn_source_voices(
world: &mut World,
source: Entity,
descriptor: &AudioSourceDesc,
) -> Result<(), String> {
let clip = descriptor
.clip
.as_ref()
.ok_or_else(|| "no audio clip is assigned".to_string())?;
let source_path = clip
.source_path
.as_deref()
.filter(|path| !path.trim().is_empty())
.ok_or_else(|| "audio clip has no runtime source path".to_string())?;
let asset_server = world.resource::<AssetServer>().clone();
let handle: Handle<AudioSource> = asset_server.load(asset_server_path(source_path));
let mode = if descriptor.looping {
PlaybackMode::Loop
} else {
PlaybackMode::Once
};
let ear_gap = active_listener_ear_gap(world);
let panning_scale = spatial_panning_scale(&descriptor.attenuation, ear_gap);
for (spatial, mix_weight) in audio_voice_mix(descriptor.spatial_blend) {
let child = world
.spawn((
Name::new(if spatial {
"Authored Spatial Audio Voice"
} else {
"Authored 2D Audio Voice"
}),
Transform::IDENTITY,
AudioPlayer::new(handle.clone()),
PlaybackSettings {
mode,
volume: Volume::Linear(0.0),
speed: descriptor.pitch,
paused: false,
muted: false,
spatial,
spatial_scale: spatial.then(|| SpatialScale::new(panning_scale)),
start_position: None,
duration: None,
},
AuthoredAudioVoice {
source,
bus: descriptor.bus.clone(),
source_gain_db: descriptor.gain_db,
mix_weight,
spatial,
attenuation: descriptor.attenuation,
looping: descriptor.looping,
},
ChildOf(source),
))
.id();
trace!("Spawned authored audio voice {child:?} for {source:?}");
}
Ok(())
}
fn active_listener_ear_gap(world: &World) -> f32 {
world
.resource::<ActiveAudioListener>()
.0
.and_then(|entity| world.get::<ManagedAudioListener>(entity))
.map(|listener| listener.ear_gap)
.unwrap_or(0.2)
}
fn voice_entities(world: &mut World, source: Entity) -> Vec<Entity> {
let mut query = world.query::<(Entity, &AuthoredAudioVoice)>();
query
.iter(world)
.filter_map(|(entity, voice)| (voice.source == source).then_some(entity))
.collect()
}
fn stop_voice_entities(world: &mut World, source: Entity) {
for entity in voice_entities(world, source) {
if let Some(sink) = world.get::<AudioSink>(entity) {
sink.stop();
}
if let Some(sink) = world.get::<SpatialAudioSink>(entity) {
sink.stop();
}
if let Ok(entity_mut) = world.get_entity_mut(entity) {
entity_mut.despawn();
}
}
}
fn set_source_paused(world: &mut World, source: Entity, paused: bool) {
for entity in voice_entities(world, source) {
if let Some(mut settings) = world.get_mut::<PlaybackSettings>(entity) {
settings.paused = paused;
}
if let Some(sink) = world.get::<AudioSink>(entity) {
if paused {
sink.pause();
} else {
sink.play();
}
}
if let Some(sink) = world.get::<SpatialAudioSink>(entity) {
if paused {
sink.pause();
} else {
sink.play();
}
}
}
}
/// Starts an authored source through the same runtime adapter used by autoplay.
pub fn play_authored_audio_source(world: &mut World, source: Entity) -> Result<(), String> {
let descriptor = world
.get::<AudioSourceDesc>(source)
.cloned()
.ok_or_else(|| "entity has no AudioSourceDesc".to_string())?;
stop_voice_entities(world, source);
let sim_enabled = world.resource::<SimEnabled>().0;
let state = RuntimeAudioSourceState {
descriptor: descriptor.clone(),
desired_playing: true,
paused_by_sim: !sim_enabled,
};
world.entity_mut(source).insert(state);
if sim_enabled {
spawn_source_voices(world, source, &descriptor)?;
}
Ok(())
}
/// Stops an authored source and disables replay until another explicit play or autoplay change.
pub fn stop_authored_audio_source(world: &mut World, source: Entity) {
stop_voice_entities(world, source);
if let Some(mut state) = world.get_mut::<RuntimeAudioSourceState>(source) {
state.desired_playing = false;
state.paused_by_sim = false;
}
}
/// Clears PIE-owned playback/listeners so the next session starts from authored state.
pub fn reset_authored_audio_runtime(world: &mut World) {
let mut query = world.query_filtered::<Entity, With<RuntimeAudioSourceState>>();
let sources: Vec<Entity> = query.iter(world).collect();
for source in sources {
stop_voice_entities(world, source);
if let Ok(mut entity_mut) = world.get_entity_mut(source) {
entity_mut.remove::<RuntimeAudioSourceState>();
}
}
let mut listener_query = world.query_filtered::<Entity, With<ManagedAudioListener>>();
let listeners: Vec<Entity> = listener_query.iter(world).collect();
for listener in listeners {
if let Ok(mut entity_mut) = world.get_entity_mut(listener) {
entity_mut
.remove::<SpatialListener>()
.remove::<ManagedAudioListener>();
}
}
world.resource_mut::<ActiveAudioListener>().0 = None;
}
fn update_voice_mix(world: &mut World) {
let project_audio = world.resource::<ProjectSettings>().audio.clone();
let bus_runtime = world.resource::<AudioBusRuntime>().clone();
let listener_position = world
.resource::<ActiveAudioListener>()
.0
.and_then(|entity| world.get::<GlobalTransform>(entity))
.map(GlobalTransform::translation);
let listener_ear_gap = active_listener_ear_gap(world);
let mut query = world.query::<(Entity, &AuthoredAudioVoice, Option<&GlobalTransform>)>();
let voices: Vec<(Entity, AuthoredAudioVoice, Option<Vec3>)> = query
.iter(world)
.map(|(entity, voice, transform)| {
(
entity,
voice.clone(),
transform.map(GlobalTransform::translation),
)
})
.collect();
for (entity, voice, source_position) in voices {
if voice.spatial {
let scale = spatial_panning_scale(&voice.attenuation, listener_ear_gap);
let desired = SpatialScale::new(scale);
if let Some(mut settings) = world.get_mut::<PlaybackSettings>(entity) {
if settings
.spatial_scale
.is_none_or(|current| current.0 != desired.0)
{
settings.spatial_scale = Some(desired);
}
}
}
let bus_gain = effective_bus_gain_linear(&project_audio, &bus_runtime, &voice.bus);
let attenuation = if voice.spatial {
match (listener_position, source_position) {
(Some(listener), Some(source)) => {
attenuation_gain(&voice.attenuation, listener.distance(source))
}
_ => 0.0,
}
} else {
1.0
};
let gain = db_to_linear(voice.source_gain_db) * bus_gain * voice.mix_weight * attenuation;
if let Some(mut sink) = world.get_mut::<AudioSink>(entity) {
sink.set_volume(Volume::Linear(gain));
}
if let Some(mut sink) = world.get_mut::<SpatialAudioSink>(entity) {
sink.set_volume(Volume::Linear(gain));
}
}
}
fn cleanup_finished_voices(world: &mut World) {
let mut query = world.query::<(Entity, &AuthoredAudioVoice)>();
let voices: Vec<(Entity, Entity, bool)> = query
.iter(world)
.map(|(entity, voice)| (entity, voice.source, voice.looping))
.collect();
let mut completed_sources = HashSet::new();
for (entity, source, looping) in voices {
if looping {
continue;
}
let finished = world
.get::<AudioSink>(entity)
.is_some_and(AudioSinkPlayback::empty)
|| world
.get::<SpatialAudioSink>(entity)
.is_some_and(AudioSinkPlayback::empty);
if finished {
if let Ok(entity_mut) = world.get_entity_mut(entity) {
entity_mut.despawn();
}
completed_sources.insert(source);
}
}
for source in completed_sources {
if voice_entities(world, source).is_empty() {
if let Some(mut state) = world.get_mut::<RuntimeAudioSourceState>(source) {
state.desired_playing = false;
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use bevy::asset::{AssetApp, AssetPlugin};
use settings::{AudioBusSettings, AUDIO_BUS_MUSIC_ID, AUDIO_BUS_SFX_ID};
use shared::{EditorAssetRef, AUDIO_CLIP_SUB_ASSET_ID};
#[test]
fn bus_gain_accumulates_parents_and_runtime_overrides() {
let settings = AudioSettings::default();
let mut runtime = AudioBusRuntime::default();
runtime.set_gain_db(AUDIO_BUS_MASTER_ID, -6.0).unwrap();
runtime.set_gain_db(AUDIO_BUS_MUSIC_ID, -6.0).unwrap();
let gain = effective_bus_gain_linear(&settings, &runtime, AUDIO_BUS_MUSIC_ID);
assert!((gain - db_to_linear(-12.0)).abs() < 0.0001);
runtime.set_muted(AUDIO_BUS_MASTER_ID, true);
assert_eq!(
effective_bus_gain_linear(&settings, &runtime, AUDIO_BUS_MUSIC_ID),
0.0
);
}
#[test]
fn missing_bus_falls_back_to_master() {
let mut settings = AudioSettings::default();
settings.bus_mut(AUDIO_BUS_MASTER_ID).unwrap().gain_db = -3.0;
assert!(
(effective_bus_gain_linear(&settings, &AudioBusRuntime::default(), "missing")
- db_to_linear(-3.0))
.abs()
< 0.0001
);
}
#[test]
fn spatial_blend_is_equal_power_and_keeps_endpoint_voice_counts() {
assert_eq!(audio_voice_mix(0.0), vec![(false, 1.0)]);
assert_eq!(audio_voice_mix(1.0), vec![(true, 1.0)]);
let mixed = audio_voice_mix(0.5);
assert_eq!(mixed.len(), 2);
let power: f32 = mixed.iter().map(|(_, weight)| weight * weight).sum();
assert!((power - 1.0).abs() < 0.0001);
}
#[test]
fn attenuation_is_full_inside_min_and_silent_at_max() {
for rolloff in [
AudioRolloff::Linear,
AudioRolloff::Inverse,
AudioRolloff::Exponential,
] {
let desc = AudioAttenuationDesc {
min_distance: 2.0,
max_distance: 10.0,
rolloff,
rolloff_factor: 1.0,
};
assert_eq!(attenuation_gain(&desc, 1.0), 1.0);
assert_eq!(attenuation_gain(&desc, 10.0), 0.0);
assert!(attenuation_gain(&desc, 5.0) > 0.0);
}
}
#[test]
fn panning_scale_keeps_rodio_distance_gain_neutral_in_authored_radius() {
let attenuation = AudioAttenuationDesc {
min_distance: 1.0,
max_distance: 25.0,
rolloff: AudioRolloff::Inverse,
rolloff_factor: 1.0,
};
let ear_gap = 0.2;
let scale = spatial_panning_scale(&attenuation, ear_gap);
// Rodio's distance gain remains 1.0 while each ear is at most one rodio unit away.
let furthest_audible_ear = (attenuation.max_distance + ear_gap * 0.5) * scale;
assert!(scale.is_finite() && scale > 0.0);
assert!(furthest_audible_ear <= 0.25 + f32::EPSILON);
}
#[test]
fn unchanged_reconciliation_does_not_reinsert_runtime_components() {
let mut world = World::new();
world.insert_resource(SimEnabled(true));
world.insert_resource(ActiveAudioListener::default());
let listener = world
.spawn((
LevelObject,
ActorId("audio-listener".into()),
AudioListenerDesc::default(),
))
.id();
let source = world
.spawn((
LevelObject,
AudioSourceDesc {
autoplay: false,
..Default::default()
},
))
.id();
reconcile_audio_listener(&mut world);
reconcile_audio_sources(&mut world);
world.clear_trackers();
reconcile_audio_listener(&mut world);
reconcile_audio_sources(&mut world);
assert!(!world
.entity(listener)
.get_ref::<ManagedAudioListener>()
.unwrap()
.is_changed());
assert!(!world
.entity(source)
.get_ref::<RuntimeAudioSourceState>()
.unwrap()
.is_changed());
}
#[test]
fn authored_audio_hydrates_and_reset_removes_all_runtime_state() {
let mut app = App::new();
app.add_plugins((MinimalPlugins, AssetPlugin::default()))
.init_asset::<AudioSource>()
.insert_resource(SimEnabled(true))
.init_resource::<ActiveAudioListener>();
let listener = app
.world_mut()
.spawn((
LevelObject,
ActorId("audio-listener".into()),
AudioListenerDesc::default(),
Transform::IDENTITY,
))
.id();
let source = app
.world_mut()
.spawn((
LevelObject,
Transform::from_xyz(0.0, 0.0, -2.0),
AudioSourceDesc {
clip: Some(
EditorAssetRef::new("audio-id", AUDIO_CLIP_SUB_ASSET_ID, "Tone")
.with_source_path("audio/tone.ogg"),
),
spatial_blend: 0.5,
..Default::default()
},
))
.id();
reconcile_audio_listener(app.world_mut());
reconcile_audio_sources(app.world_mut());
assert_eq!(
app.world().resource::<ActiveAudioListener>().0,
Some(listener)
);
assert!(app.world().get::<SpatialListener>(listener).is_some());
assert!(app.world().get::<RuntimeAudioSourceState>(source).is_some());
let voices = voice_entities(app.world_mut(), source);
assert_eq!(voices.len(), 2);
assert!(voices
.iter()
.all(|voice| app.world().get::<AudioPlayer>(*voice).is_some()));
reset_authored_audio_runtime(app.world_mut());
assert_eq!(app.world().resource::<ActiveAudioListener>().0, None);
assert!(app.world().get::<SpatialListener>(listener).is_none());
assert!(app.world().get::<RuntimeAudioSourceState>(source).is_none());
assert!(voice_entities(app.world_mut(), source).is_empty());
}
#[test]
fn listener_selection_prefers_priority_then_stable_actor_id() {
let mut world = World::new();
world.insert_resource(SimEnabled(true));
world.insert_resource(ActiveAudioListener::default());
let low_priority = world
.spawn((
LevelObject,
ActorId("listener-low".into()),
AudioListenerDesc {
priority: 1,
..Default::default()
},
))
.id();
let tied_later = world
.spawn((
LevelObject,
ActorId("listener-z".into()),
AudioListenerDesc {
priority: 10,
..Default::default()
},
))
.id();
let tied_first = world
.spawn((
LevelObject,
ActorId("listener-a".into()),
AudioListenerDesc {
priority: 10,
..Default::default()
},
))
.id();
reconcile_audio_listener(&mut world);
assert_eq!(world.resource::<ActiveAudioListener>().0, Some(tied_first));
assert_ne!(
world.resource::<ActiveAudioListener>().0,
Some(low_priority)
);
world
.get_mut::<AudioListenerDesc>(tied_first)
.unwrap()
.enabled = false;
reconcile_audio_listener(&mut world);
assert_eq!(world.resource::<ActiveAudioListener>().0, Some(tied_later));
}
#[test]
fn bus_cycle_is_defensively_bounded_at_runtime() {
let settings = AudioSettings {
buses: vec![
AudioBusSettings {
id: AUDIO_BUS_MASTER_ID.into(),
label: "Master".into(),
parent: Some(AUDIO_BUS_SFX_ID.into()),
gain_db: 0.0,
muted: false,
},
AudioBusSettings {
id: AUDIO_BUS_SFX_ID.into(),
label: "SFX".into(),
parent: Some(AUDIO_BUS_MASTER_ID.into()),
gain_db: 0.0,
muted: false,
},
],
};
assert_eq!(
effective_bus_gain_linear(&settings, &AudioBusRuntime::default(), AUDIO_BUS_SFX_ID),
1.0
);
}
}

View File

@ -4,7 +4,6 @@ use std::path::{Path, PathBuf};
use bevy::app::PluginGroupBuilder; use bevy::app::PluginGroupBuilder;
use bevy::asset::AssetPlugin; use bevy::asset::AssetPlugin;
use bevy::log::{LogPlugin, DEFAULT_FILTER};
use bevy::prelude::*; use bevy::prelude::*;
use bevy::window::{CompositeAlphaMode, PresentMode, WindowMode, WindowPlugin}; use bevy::window::{CompositeAlphaMode, PresentMode, WindowMode, WindowPlugin};
@ -41,13 +40,6 @@ fn find_assets_directory(start: Option<PathBuf>) -> Option<String> {
pub fn default_plugins(title: impl Into<String>) -> PluginGroupBuilder { pub fn default_plugins(title: impl Into<String>) -> PluginGroupBuilder {
DefaultPlugins DefaultPlugins
.set(LogPlugin {
// Blacksite intentionally replaces Bevy's `.scn.ron` loader with a
// schema-aware loader. Keep the expected registration warning out
// of normal startup logs; RUST_LOG still overrides this filter.
filter: format!("{DEFAULT_FILTER}bevy_asset::server::loaders=error"),
..default()
})
.set(WindowPlugin { .set(WindowPlugin {
primary_window: Some(primary_window(title)), primary_window: Some(primary_window(title)),
..default() ..default()

View File

@ -1,21 +1,8 @@
//! Runtime game crate: FPS player, rendering, and starter world. //! Runtime game crate: FPS player, rendering, and starter world.
#![allow(clippy::type_complexity)] #![allow(clippy::type_complexity)]
#![cfg_attr(
feature = "hot-reload",
allow(
clippy::too_many_arguments,
reason = "hot-lib-reloader wrappers preserve Bevy system signatures and macro spans"
)
)]
pub mod animation;
pub mod audio;
pub mod editor_ext; pub mod editor_ext;
pub mod launch; pub mod launch;
mod scene_bootstrap;
mod schema_world_loader;
pub use scene_bootstrap::ProjectDefaultLevelRoot;
pub mod player { pub mod player {
//! Player types re-exported from the stable sim crate. //! Player types re-exported from the stable sim crate.
@ -48,13 +35,6 @@ pub struct GameRenderBootstrap {
pub defer_player_camera_fx: bool, pub defer_player_camera_fx: bool,
} }
/// Scene-startup controls for a host that embeds [`GamePlugin`].
#[derive(Resource, Debug, Clone, Copy, Default)]
pub struct GameSceneBootstrap {
/// The host owns initial scene materialization instead of the standalone runtime.
pub defer_default_level: bool,
}
#[cfg(not(feature = "hot-reload"))] #[cfg(not(feature = "hot-reload"))]
mod systems { mod systems {
pub use game_hot::{ pub use game_hot::{
@ -82,8 +62,8 @@ mod systems {
mod hot { mod hot {
use avian3d::prelude::*; use avian3d::prelude::*;
use bevy::input::mouse::AccumulatedMouseMotion; use bevy::input::mouse::AccumulatedMouseMotion;
use bevy::light::atmosphere::ScatteringMedium;
use bevy::light::CascadeShadowConfig; use bevy::light::CascadeShadowConfig;
use bevy::pbr::ScatteringMedium;
use bevy::prelude::*; use bevy::prelude::*;
use bevy::time::Fixed; use bevy::time::Fixed;
use bevy::window::{CursorOptions, PrimaryWindow}; use bevy::window::{CursorOptions, PrimaryWindow};
@ -125,21 +105,7 @@ fn game_input_enabled(enabled: Res<GameInputEnabled>) -> bool {
enabled.0 enabled.0
} }
fn register_level_bootstrap(app: &mut App, defer_default_level: bool) { fn register_game_systems(app: &mut App, defer_player_camera_fx: bool) {
if defer_default_level {
// SceneIo owns editor startup and retains the generated arena as its
// failure fallback until the authored document is materialized.
app.add_systems(Startup, systems::spawn_level);
} else {
app.add_systems(Startup, scene_bootstrap::spawn_project_default_level);
}
}
fn register_game_systems(
app: &mut App,
render_bootstrap: GameRenderBootstrap,
scene_bootstrap: GameSceneBootstrap,
) {
app.add_systems( app.add_systems(
FixedUpdate, FixedUpdate,
( (
@ -170,10 +136,13 @@ fn register_game_systems(
) )
.add_systems( .add_systems(
Startup, Startup,
(systems::apply_ambient_light, systems::spawn_sun).chain(), (
systems::apply_ambient_light,
systems::spawn_sun,
systems::spawn_level,
)
.chain(),
); );
register_level_bootstrap(app, scene_bootstrap.defer_default_level);
app.add_systems( app.add_systems(
Update, Update,
( (
@ -182,7 +151,7 @@ fn register_game_systems(
), ),
); );
if !render_bootstrap.defer_player_camera_fx { if !defer_player_camera_fx {
app.add_systems( app.add_systems(
PostStartup, PostStartup,
( (
@ -199,29 +168,21 @@ pub struct GamePlugin;
impl Plugin for GamePlugin { impl Plugin for GamePlugin {
fn build(&self, app: &mut App) { fn build(&self, app: &mut App) {
let render_bootstrap = app let defer_player_camera_fx = app
.world() .world()
.get_resource::<GameRenderBootstrap>() .get_resource::<GameRenderBootstrap>()
.copied() .is_some_and(|bootstrap| bootstrap.defer_player_camera_fx);
.unwrap_or_default();
let scene_bootstrap = app
.world()
.get_resource::<GameSceneBootstrap>()
.copied()
.unwrap_or_default();
app.register_type::<GameInputEnabled>() app.register_type::<GameInputEnabled>()
.init_resource::<GameInputEnabled>() .init_resource::<GameInputEnabled>()
.init_resource::<GameInputFocused>() .init_resource::<GameInputFocused>()
.add_plugins(schema_world_loader::SchemaWorldAssetPlugin)
.add_plugins((ProjectSettingsPlugin, SimPlugin, bevy_ufbx::FbxPlugin)) .add_plugins((ProjectSettingsPlugin, SimPlugin, bevy_ufbx::FbxPlugin))
.add_plugins((audio::GameAudioPlugin, animation::GameAnimationPlugin))
.add_plugins(game_hot::SolariRenderingPlugin) .add_plugins(game_hot::SolariRenderingPlugin)
.add_plugins(game_hot::AutoExposureRenderingPlugin) .add_plugins(game_hot::AutoExposureRenderingPlugin)
.add_plugins(game_hot::FullscreenEffectsPlugin) .add_plugins(game_hot::FullscreenEffectsPlugin)
.insert_resource(ClearColor(Color::srgb(0.0, 0.0, 0.0))); .insert_resource(ClearColor(Color::srgb(0.0, 0.0, 0.0)));
register_game_systems(app, render_bootstrap, scene_bootstrap); register_game_systems(app, defer_player_camera_fx);
} }
} }
@ -229,34 +190,3 @@ impl Plugin for GamePlugin {
pub fn on_hot_reload(world: &mut World) { pub fn on_hot_reload(world: &mut World) {
game_hot::on_hot_reload(world); game_hot::on_hot_reload(world);
} }
#[cfg(test)]
mod startup_tests {
use super::*;
use bevy::asset::AssetPlugin;
use bevy::world_serialization::WorldSerializationPlugin;
#[test]
fn deferred_bootstrap_leaves_default_level_loading_to_the_host() {
let mut app = App::new();
app.add_plugins((
MinimalPlugins,
AssetPlugin::default(),
WorldSerializationPlugin,
));
app.insert_resource(settings::ProjectSettings::default());
register_level_bootstrap(&mut app, true);
app.update();
let world = app.world_mut();
let mut runtime_roots =
world.query_filtered::<Entity, With<scene_bootstrap::ProjectDefaultLevelRoot>>();
assert_eq!(runtime_roots.iter(world).count(), 0);
let mut fallback = world.query_filtered::<Entity, With<shared::LevelObject>>();
assert!(
fallback.iter(world).next().is_some(),
"the editor fallback arena remains available until SceneIo loads"
);
}
}

View File

@ -1,85 +0,0 @@
//! Standalone project-scene bootstrap.
use bevy::prelude::*;
use bevy::world_serialization::DynamicWorld;
use settings::ProjectSettings;
/// Runtime root for the standalone project's configured default level.
#[derive(Component, Debug)]
pub struct ProjectDefaultLevelRoot;
pub(crate) fn spawn_project_default_level(
mut commands: Commands,
asset_server: Res<AssetServer>,
settings: Res<ProjectSettings>,
existing: Query<(), With<ProjectDefaultLevelRoot>>,
) {
if !existing.is_empty() {
return;
}
let configured_path = settings.default_level.trim();
if configured_path.is_empty() {
error!("Project default_level is empty; standalone scene startup was skipped");
return;
}
let asset_path = shared::asset_server_path(configured_path);
let handle: Handle<DynamicWorld> = asset_server.load(asset_path.clone());
commands.spawn((
Name::new(format!("Project Default Level ({configured_path})")),
ProjectDefaultLevelRoot,
DynamicWorldRoot(handle),
));
info!("Loading project default level {configured_path} ({asset_path})");
}
#[cfg(test)]
mod tests {
use super::*;
use bevy::asset::AssetPlugin;
use bevy::world_serialization::WorldSerializationPlugin;
fn test_app(default_level: &str) -> App {
let mut app = App::new();
app.add_plugins((
MinimalPlugins,
AssetPlugin::default(),
WorldSerializationPlugin,
));
app.insert_resource(ProjectSettings {
default_level: default_level.to_string(),
..Default::default()
});
app
}
#[test]
fn standalone_bootstrap_uses_the_project_default_level() {
let mut app = test_app("assets/levels/editor_scene.scn.ron");
app.add_systems(Startup, spawn_project_default_level);
app.update();
let world = app.world_mut();
let mut roots = world.query_filtered::<&DynamicWorldRoot, With<ProjectDefaultLevelRoot>>();
let root = roots.single(world).expect("one standalone scene root");
let path = world
.resource::<AssetServer>()
.get_path(root.0.id())
.expect("default level handle retains its asset path");
assert_eq!(path.to_string(), "levels/editor_scene.scn.ron");
}
#[test]
fn empty_default_level_does_not_create_a_runtime_root() {
let mut app = test_app(" ");
app.add_systems(Startup, spawn_project_default_level);
app.update();
let world = app.world_mut();
let mut roots = world.query_filtered::<Entity, With<ProjectDefaultLevelRoot>>();
assert_eq!(roots.iter(world).count(), 0);
}
}

View File

@ -1,160 +0,0 @@
//! Runtime adapter for versioned Blacksite `.scn.ron` assets.
use std::io;
use bevy::asset::{io::Reader, AssetApp, AssetLoader, LoadContext};
use bevy::ecs::reflect::AppTypeRegistry;
use bevy::prelude::*;
use bevy::reflect::{TypePath, TypeRegistryArc};
use bevy::world_serialization::{serde::WorldDeserializer, DynamicWorld};
use serde::de::DeserializeSeed;
pub(super) struct SchemaWorldAssetPlugin;
impl Plugin for SchemaWorldAssetPlugin {
fn build(&self, app: &mut App) {
// Register after Bevy's stock loader so versioned Blacksite scenes use
// this adapter while retaining the standard DynamicWorld asset type.
app.init_asset_loader::<SchemaWorldAssetLoader>();
}
}
#[derive(Debug, TypePath)]
struct SchemaWorldAssetLoader {
type_registry: TypeRegistryArc,
}
impl FromWorld for SchemaWorldAssetLoader {
fn from_world(world: &mut World) -> Self {
Self {
type_registry: world.resource::<AppTypeRegistry>().0.clone(),
}
}
}
impl AssetLoader for SchemaWorldAssetLoader {
type Asset = DynamicWorld;
type Settings = ();
type Error = io::Error;
async fn load(
&self,
reader: &mut dyn Reader,
_settings: &Self::Settings,
load_context: &mut LoadContext<'_>,
) -> Result<Self::Asset, Self::Error> {
let mut bytes = Vec::new();
reader.read_to_end(&mut bytes).await?;
let text = std::str::from_utf8(&bytes).map_err(invalid_scene)?;
let body = normalized_world_body(text)?;
let mut deserializer = ron::de::Deserializer::from_str(&body).map_err(invalid_scene)?;
let world_deserializer = WorldDeserializer {
type_registry: &self.type_registry.read(),
load_from_path: load_context,
};
world_deserializer
.deserialize(&mut deserializer)
.map_err(|error| invalid_scene(deserializer.span_error(error)))
}
fn extensions(&self) -> &[&str] {
&["scn.ron"]
}
}
fn normalized_world_body(text: &str) -> Result<String, io::Error> {
scene::validate_level_text(text).map_err(invalid_scene)?;
let migrated = scene::migrate_scene_text(text).map_err(invalid_scene)?;
scene::strip_schema_version(&migrated).map_err(invalid_scene)
}
fn invalid_scene(error: impl std::fmt::Display) -> io::Error {
io::Error::new(io::ErrorKind::InvalidData, error.to_string())
}
#[cfg(test)]
mod tests {
use super::normalized_world_body;
use super::SchemaWorldAssetPlugin;
use bevy::asset::{AssetPlugin, AssetServer, Assets, LoadState};
use bevy::prelude::*;
use bevy::world_serialization::{DynamicWorld, WorldSerializationPlugin};
#[test]
fn versioned_world_assets_are_validated_and_unwrapped_for_bevy() {
let body = normalized_world_body(
r#"(schema_version: 2, resources: {}, entities: {
1: (components: { "shared::components::LevelObject": () }),
})"#,
)
.unwrap();
assert!(body.starts_with("(resources:"));
assert!(!body.contains("schema_version"));
}
#[test]
fn invalid_hierarchy_is_rejected_before_world_deserialization() {
let error = normalized_world_body(
r#"(schema_version: 2, resources: {}, entities: {
1: (components: { "bevy_ecs::hierarchy::ChildOf": (9) }),
})"#,
)
.unwrap_err();
assert!(error.to_string().contains("missing hierarchy parent `9`"));
}
#[test]
fn asset_server_loads_the_committed_versioned_prefab_fixture() {
let assets = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../assets");
let mut app = App::new();
app.add_plugins(MinimalPlugins)
.add_plugins(AssetPlugin {
file_path: assets.to_string_lossy().into_owned(),
..default()
})
.add_plugins(WorldSerializationPlugin)
.register_type::<Name>()
.register_type::<Transform>()
.register_type::<shared::LevelObject>()
.register_type::<shared::ActorId>()
.register_type::<shared::ActorKind>()
.register_type::<shared::EditorVisibility>()
.register_type::<shared::PrefabInstance>()
.register_type::<shared::PrefabRef>()
.add_plugins(SchemaWorldAssetPlugin);
let handle: Handle<DynamicWorld> = app
.world()
.resource::<AssetServer>()
.load("prefabs/example_variant.scn.ron");
for _ in 0..200 {
app.update();
if app
.world()
.resource::<Assets<DynamicWorld>>()
.get(&handle)
.is_some()
{
return;
}
if matches!(
app.world()
.resource::<AssetServer>()
.load_state(handle.id()),
LoadState::Failed(_)
) {
break;
}
std::thread::sleep(std::time::Duration::from_millis(5));
}
panic!(
"versioned prefab fixture did not load: {:?}",
app.world()
.resource::<AssetServer>()
.load_state(handle.id())
);
}
}

View File

@ -6,9 +6,6 @@ license.workspace = true
description = "Level scene schema versioning and validation (no Bevy dependency)" description = "Level scene schema versioning and validation (no Bevy dependency)"
[dependencies] [dependencies]
blake3 = "1"
serde = { workspace = true } serde = { workspace = true }
ron = "0.8" ron = "0.8"
ron-edit = "=0.2.0"
shared.workspace = true shared.workspace = true
settings.workspace = true

Some files were not shown because too many files have changed in this diff Show More