Compare commits
No commits in common. "0553a85220a25bdb31b8b6737ba698b3c2a9d6c5" and "b5e561904c5f2c0f3c010d1c083736ba352b3862" have entirely different histories.
0553a85220
...
b5e561904c
@ -7,4 +7,3 @@ rustflags = ["-C", "link-arg=-fuse-ld=mold"]
|
||||
[alias]
|
||||
clean-target = "run -p xtask --bin clean-target --"
|
||||
validate-levels = "run -p xtask --features validate-levels --bin validate-levels --"
|
||||
package-project = "run -p xtask --features validate-levels --bin package-project --"
|
||||
|
||||
@ -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.
|
||||
@ -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.
|
||||
@ -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.
|
||||
28
.github/workflows/ci.yml
vendored
28
.github/workflows/ci.yml
vendored
@ -41,22 +41,34 @@ jobs:
|
||||
run: rustup show
|
||||
|
||||
- name: Check formatting
|
||||
run: cargo fmt --all --check
|
||||
run: cargo fmt --check
|
||||
|
||||
- name: Check workspace
|
||||
run: cargo check --workspace --all-targets
|
||||
run: cargo check --workspace
|
||||
|
||||
- name: Clippy workspace
|
||||
run: cargo clippy --workspace --all-targets -- -D warnings
|
||||
- name: Clippy
|
||||
run: cargo clippy --workspace
|
||||
|
||||
- name: Test workspace
|
||||
run: cargo test --workspace
|
||||
- name: Clippy foundation crates
|
||||
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
|
||||
run: cargo validate-levels
|
||||
|
||||
- name: Clippy launch feature matrix
|
||||
run: cargo clippy -p editor -p game --bins --features dev,hot-reload -- -D warnings
|
||||
- name: Test scene schema crate
|
||||
run: cargo test -p scene
|
||||
|
||||
- name: Check editor (dev)
|
||||
run: cargo check -p editor --features dev
|
||||
|
||||
- name: Build binaries
|
||||
run: |
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@ -1,6 +1,5 @@
|
||||
# Rust build output
|
||||
/target/
|
||||
/dist/
|
||||
|
||||
# Local automation and assistant state
|
||||
/.agents/
|
||||
|
||||
241
.vscode/launch.json
vendored
241
.vscode/launch.json
vendored
@ -4,251 +4,48 @@
|
||||
{
|
||||
"type": "lldb",
|
||||
"request": "launch",
|
||||
"name": "Editor: Debug",
|
||||
"cargo": {
|
||||
"args": ["build", "-p", "editor", "--bin", "editor", "--features", "dev"],
|
||||
"filter": { "name": "editor", "kind": "bin" },
|
||||
"env": { "CARGO_TARGET_DIR": "${workspaceFolder}/target" },
|
||||
"problemMatcher": "$rustc"
|
||||
},
|
||||
"name": "Debug editor",
|
||||
"preLaunchTask": "build editor (dev fast-link)",
|
||||
"program": "${workspaceFolder}/target/debug/editor",
|
||||
"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: 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",
|
||||
"WGPU_VALIDATION": "0",
|
||||
"CARGO_TARGET_DIR": "${workspaceFolder}/target",
|
||||
"LD_LIBRARY_PATH": "${workspaceFolder}/target/debug/deps:${env:LD_LIBRARY_PATH}"
|
||||
},
|
||||
"cwd": "${workspaceFolder}",
|
||||
"terminal": "integrated",
|
||||
"sourceLanguages": ["rust"],
|
||||
"gracefulShutdown": "SIGINT"
|
||||
"cwd": "${workspaceFolder}"
|
||||
},
|
||||
{
|
||||
"type": "lldb",
|
||||
"request": "launch",
|
||||
"name": "Editor: Release",
|
||||
"cargo": {
|
||||
"args": ["build", "--release", "-p", "editor", "--bin", "editor"],
|
||||
"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"
|
||||
},
|
||||
"name": "Debug game",
|
||||
"preLaunchTask": "build game (dev fast-link)",
|
||||
"program": "${workspaceFolder}/target/debug/game",
|
||||
"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: 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",
|
||||
"WGPU_VALIDATION": "0",
|
||||
"CARGO_TARGET_DIR": "${workspaceFolder}/target",
|
||||
"LD_LIBRARY_PATH": "${workspaceFolder}/target/debug/deps:${env:LD_LIBRARY_PATH}"
|
||||
},
|
||||
"cwd": "${workspaceFolder}",
|
||||
"terminal": "integrated",
|
||||
"sourceLanguages": ["rust"],
|
||||
"gracefulShutdown": "SIGINT"
|
||||
"cwd": "${workspaceFolder}"
|
||||
},
|
||||
{
|
||||
"type": "lldb",
|
||||
"request": "launch",
|
||||
"name": "Game: Release",
|
||||
"name": "Run editor (release)",
|
||||
"cargo": {
|
||||
"args": ["build", "--release", "-p", "game", "--bin", "game"],
|
||||
"filter": { "name": "game", "kind": "bin" },
|
||||
"env": { "CARGO_TARGET_DIR": "${workspaceFolder}/target" },
|
||||
"problemMatcher": "$rustc"
|
||||
"args": ["build", "--release", "-p", "editor"],
|
||||
"filter": { "name": "editor", "kind": "bin" }
|
||||
},
|
||||
"args": [],
|
||||
"env": { "RUST_BACKTRACE": "1", "WGPU_VALIDATION": "0" },
|
||||
"cwd": "${workspaceFolder}",
|
||||
"terminal": "integrated",
|
||||
"sourceLanguages": ["rust"],
|
||||
"gracefulShutdown": "SIGINT"
|
||||
}
|
||||
],
|
||||
"inputs": [
|
||||
{
|
||||
"id": "blacksiteProjectRoot",
|
||||
"type": "promptString",
|
||||
"description": "Absolute path to a Blacksite project root",
|
||||
"default": "${workspaceFolder}"
|
||||
"env": {
|
||||
"BEVY_FPS_HDR": "0",
|
||||
"WGPU_VALIDATION": "0",
|
||||
"LD_LIBRARY_PATH": "${workspaceFolder}/target/release/deps:${env:LD_LIBRARY_PATH}"
|
||||
},
|
||||
"cwd": "${workspaceFolder}"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
219
.vscode/tasks.json
vendored
219
.vscode/tasks.json
vendored
@ -2,61 +2,42 @@
|
||||
"version": "2.0.0",
|
||||
"tasks": [
|
||||
{
|
||||
"label": "cargo fmt (workspace check)",
|
||||
"label": "cargo fmt (check)",
|
||||
"type": "cargo",
|
||||
"command": "fmt",
|
||||
"args": ["--all", "--check"],
|
||||
"args": ["--check"],
|
||||
"problemMatcher": [],
|
||||
"group": "build"
|
||||
},
|
||||
{
|
||||
"label": "cargo check (workspace all targets)",
|
||||
"label": "cargo check (workspace)",
|
||||
"type": "cargo",
|
||||
"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"],
|
||||
"problemMatcher": ["$rustc"],
|
||||
"group": "test"
|
||||
"group": "build"
|
||||
},
|
||||
{
|
||||
"label": "cargo clippy (launch feature matrix)",
|
||||
"label": "cargo clippy (workspace)",
|
||||
"type": "cargo",
|
||||
"command": "clippy",
|
||||
"args": [
|
||||
"-p",
|
||||
"editor",
|
||||
"-p",
|
||||
"game",
|
||||
"--bins",
|
||||
"--features",
|
||||
"dev,hot-reload",
|
||||
"--",
|
||||
"-D",
|
||||
"warnings"
|
||||
],
|
||||
"args": ["--workspace"],
|
||||
"problemMatcher": ["$rustc"],
|
||||
"group": "build"
|
||||
},
|
||||
{
|
||||
"label": "validate level scenes",
|
||||
"label": "cargo clippy (foundation strict)",
|
||||
"type": "cargo",
|
||||
"command": "validate-levels",
|
||||
"args": [],
|
||||
"command": "clippy",
|
||||
"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"],
|
||||
"group": "test"
|
||||
},
|
||||
@ -64,7 +45,7 @@
|
||||
"label": "build game (debug)",
|
||||
"type": "cargo",
|
||||
"command": "build",
|
||||
"args": ["-p", "game", "--bin", "game"],
|
||||
"args": ["-p", "game"],
|
||||
"problemMatcher": ["$rustc"],
|
||||
"group": "build"
|
||||
},
|
||||
@ -72,50 +53,47 @@
|
||||
"label": "build editor (debug)",
|
||||
"type": "cargo",
|
||||
"command": "build",
|
||||
"args": ["-p", "editor", "--bin", "editor"],
|
||||
"args": ["-p", "editor"],
|
||||
"problemMatcher": ["$rustc"],
|
||||
"group": "build"
|
||||
"group": { "kind": "build", "isDefault": true }
|
||||
},
|
||||
{
|
||||
"label": "build editor (dev fast-link)",
|
||||
"type": "cargo",
|
||||
"command": "build",
|
||||
"args": ["-p", "editor", "--bin", "editor", "--features", "dev"],
|
||||
"args": ["-p", "editor", "--features", "dev"],
|
||||
"options": {
|
||||
"cwd": "${workspaceFolder}",
|
||||
"env": { "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" }
|
||||
"env": {
|
||||
"CARGO_TARGET_DIR": "${workspaceFolder}/target"
|
||||
}
|
||||
},
|
||||
"problemMatcher": ["$rustc"],
|
||||
"group": "build"
|
||||
},
|
||||
{
|
||||
"label": "clean dev-link crates",
|
||||
"label": "build game (dev fast-link)",
|
||||
"type": "cargo",
|
||||
"command": "clean",
|
||||
"args": ["-p", "editor", "-p", "game", "-p", "game_hot", "-p", "shared"],
|
||||
"command": "build",
|
||||
"args": ["-p", "game", "--features", "dev"],
|
||||
"options": {
|
||||
"cwd": "${workspaceFolder}",
|
||||
"env": { "CARGO_TARGET_DIR": "${workspaceFolder}/target" }
|
||||
"env": {
|
||||
"CARGO_TARGET_DIR": "${workspaceFolder}/target"
|
||||
}
|
||||
},
|
||||
"problemMatcher": []
|
||||
"problemMatcher": ["$rustc"],
|
||||
"group": "build"
|
||||
},
|
||||
{
|
||||
"label": "clean build editor (dev)",
|
||||
"dependsOrder": "sequence",
|
||||
"dependsOn": ["clean dev-link crates", "build editor (dev fast-link)"],
|
||||
"problemMatcher": [],
|
||||
"type": "shell",
|
||||
"command": "cargo clean -p editor && cargo build -p editor --features dev",
|
||||
"options": {
|
||||
"cwd": "${workspaceFolder}",
|
||||
"env": {
|
||||
"CARGO_TARGET_DIR": "${workspaceFolder}/target"
|
||||
}
|
||||
},
|
||||
"problemMatcher": ["$rustc"],
|
||||
"group": "build"
|
||||
},
|
||||
{
|
||||
@ -124,7 +102,9 @@
|
||||
"command": "cargo clean-target",
|
||||
"options": {
|
||||
"cwd": "${workspaceFolder}",
|
||||
"env": { "CARGO_TARGET_DIR": "${workspaceFolder}/target" }
|
||||
"env": {
|
||||
"CARGO_TARGET_DIR": "${workspaceFolder}/target"
|
||||
}
|
||||
},
|
||||
"problemMatcher": [],
|
||||
"group": "build"
|
||||
@ -135,7 +115,9 @@
|
||||
"command": "cargo clean-target --apply",
|
||||
"options": {
|
||||
"cwd": "${workspaceFolder}",
|
||||
"env": { "CARGO_TARGET_DIR": "${workspaceFolder}/target" }
|
||||
"env": {
|
||||
"CARGO_TARGET_DIR": "${workspaceFolder}/target"
|
||||
}
|
||||
},
|
||||
"problemMatcher": [],
|
||||
"group": "build"
|
||||
@ -146,7 +128,9 @@
|
||||
"command": "cargo clean-target --include-artifacts --days 3 --apply",
|
||||
"options": {
|
||||
"cwd": "${workspaceFolder}",
|
||||
"env": { "CARGO_TARGET_DIR": "${workspaceFolder}/target" }
|
||||
"env": {
|
||||
"CARGO_TARGET_DIR": "${workspaceFolder}/target"
|
||||
}
|
||||
},
|
||||
"problemMatcher": [],
|
||||
"group": "build"
|
||||
@ -155,11 +139,10 @@
|
||||
"label": "run editor (dev fast-link)",
|
||||
"type": "cargo",
|
||||
"command": "run",
|
||||
"args": ["-p", "editor", "--bin", "editor", "--features", "dev"],
|
||||
"args": ["-p", "editor", "--features", "dev"],
|
||||
"options": {
|
||||
"cwd": "${workspaceFolder}",
|
||||
"env": {
|
||||
"RUST_BACKTRACE": "1",
|
||||
"BEVY_FPS_HDR": "0",
|
||||
"WGPU_VALIDATION": "0",
|
||||
"CARGO_TARGET_DIR": "${workspaceFolder}/target"
|
||||
}
|
||||
@ -171,83 +154,9 @@
|
||||
"label": "run game (dev fast-link)",
|
||||
"type": "cargo",
|
||||
"command": "run",
|
||||
"args": ["-p", "game", "--bin", "game", "--features", "dev"],
|
||||
"args": ["-p", "game", "--features", "dev"],
|
||||
"options": {
|
||||
"cwd": "${workspaceFolder}",
|
||||
"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",
|
||||
"WGPU_VALIDATION": "0",
|
||||
"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\"",
|
||||
"options": {
|
||||
"cwd": "${workspaceFolder}",
|
||||
"env": { "CARGO_TARGET_DIR": "${workspaceFolder}/target" }
|
||||
"env": {
|
||||
"CARGO_TARGET_DIR": "${workspaceFolder}/target"
|
||||
}
|
||||
},
|
||||
"problemMatcher": ["$rustc"],
|
||||
"group": "build",
|
||||
"isBackground": true,
|
||||
"detail": "Requires cargo-watch: cargo install cargo-watch --locked"
|
||||
"isBackground": true
|
||||
},
|
||||
{
|
||||
"label": "verify workspace",
|
||||
"label": "verify foundation",
|
||||
"dependsOrder": "sequence",
|
||||
"dependsOn": [
|
||||
"cargo fmt (workspace check)",
|
||||
"cargo check (workspace all targets)",
|
||||
"cargo clippy (workspace strict)",
|
||||
"cargo test (workspace)",
|
||||
"cargo clippy (launch feature matrix)",
|
||||
"validate level scenes",
|
||||
"cargo fmt (check)",
|
||||
"cargo check (workspace)",
|
||||
"cargo clippy (workspace)",
|
||||
"cargo clippy (foundation strict)",
|
||||
"cargo test (foundation)",
|
||||
"build game (debug)",
|
||||
"build editor (debug)"
|
||||
],
|
||||
"problemMatcher": [],
|
||||
"group": "build"
|
||||
"group": { "kind": "build", "isDefault": true }
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
226
Cargo.lock
generated
226
Cargo.lock
generated
@ -403,7 +403,7 @@ dependencies = [
|
||||
"bevy_math",
|
||||
"bevy_transform_interpolation",
|
||||
"bitflags 2.13.0",
|
||||
"derive_more 2.1.1",
|
||||
"derive_more",
|
||||
"disqualified",
|
||||
"glam_matrix_extras",
|
||||
"itertools 0.15.0",
|
||||
@ -550,7 +550,7 @@ dependencies = [
|
||||
"bevy_transform",
|
||||
"bevy_utils",
|
||||
"blake3",
|
||||
"derive_more 2.1.1",
|
||||
"derive_more",
|
||||
"downcast-rs 2.0.2",
|
||||
"either",
|
||||
"petgraph",
|
||||
@ -642,7 +642,7 @@ dependencies = [
|
||||
"bitflags 2.13.0",
|
||||
"blake3",
|
||||
"crossbeam-channel",
|
||||
"derive_more 2.1.1",
|
||||
"derive_more",
|
||||
"disqualified",
|
||||
"downcast-rs 2.0.2",
|
||||
"either",
|
||||
@ -707,7 +707,7 @@ dependencies = [
|
||||
"bevy_transform",
|
||||
"bevy_utils",
|
||||
"bevy_window",
|
||||
"derive_more 2.1.1",
|
||||
"derive_more",
|
||||
"downcast-rs 2.0.2",
|
||||
"serde",
|
||||
"smallvec",
|
||||
@ -739,7 +739,7 @@ dependencies = [
|
||||
"bevy_math",
|
||||
"bevy_reflect",
|
||||
"bytemuck",
|
||||
"derive_more 2.1.1",
|
||||
"derive_more",
|
||||
"encase",
|
||||
"serde",
|
||||
"thiserror 2.0.18",
|
||||
@ -866,7 +866,7 @@ dependencies = [
|
||||
"bitflags 2.13.0",
|
||||
"bumpalo",
|
||||
"concurrent-queue",
|
||||
"derive_more 2.1.1",
|
||||
"derive_more",
|
||||
"fixedbitset",
|
||||
"indexmap",
|
||||
"log",
|
||||
@ -1160,7 +1160,7 @@ dependencies = [
|
||||
"bevy_math",
|
||||
"bevy_platform",
|
||||
"bevy_reflect",
|
||||
"derive_more 2.1.1",
|
||||
"derive_more",
|
||||
"log",
|
||||
"serde",
|
||||
"smol_str",
|
||||
@ -1343,7 +1343,7 @@ dependencies = [
|
||||
"approx",
|
||||
"arrayvec",
|
||||
"bevy_reflect",
|
||||
"derive_more 2.1.1",
|
||||
"derive_more",
|
||||
"glam 0.32.1",
|
||||
"itertools 0.14.0",
|
||||
"libm",
|
||||
@ -1372,7 +1372,7 @@ dependencies = [
|
||||
"bevy_transform",
|
||||
"bitflags 2.13.0",
|
||||
"bytemuck",
|
||||
"derive_more 2.1.1",
|
||||
"derive_more",
|
||||
"encase",
|
||||
"glam 0.32.1",
|
||||
"half",
|
||||
@ -1420,7 +1420,7 @@ dependencies = [
|
||||
"bevy_utils",
|
||||
"bitflags 2.13.0",
|
||||
"bytemuck",
|
||||
"derive_more 2.1.1",
|
||||
"derive_more",
|
||||
"fixedbitset",
|
||||
"indexmap",
|
||||
"nonmax",
|
||||
@ -1521,7 +1521,7 @@ dependencies = [
|
||||
"bevy_ptr",
|
||||
"bevy_reflect_derive",
|
||||
"bevy_utils",
|
||||
"derive_more 2.1.1",
|
||||
"derive_more",
|
||||
"disqualified",
|
||||
"downcast-rs 2.0.2",
|
||||
"erased-serde",
|
||||
@ -1613,7 +1613,7 @@ dependencies = [
|
||||
"bevy_window",
|
||||
"bitflags 2.13.0",
|
||||
"bytemuck",
|
||||
"derive_more 2.1.1",
|
||||
"derive_more",
|
||||
"downcast-rs 2.0.2",
|
||||
"encase",
|
||||
"glam 0.32.1",
|
||||
@ -1726,7 +1726,7 @@ dependencies = [
|
||||
"bevy_transform",
|
||||
"bevy_utils",
|
||||
"bytemuck",
|
||||
"derive_more 2.1.1",
|
||||
"derive_more",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
@ -1783,7 +1783,7 @@ dependencies = [
|
||||
"bevy_utils",
|
||||
"bitflags 2.13.0",
|
||||
"bytemuck",
|
||||
"derive_more 2.1.1",
|
||||
"derive_more",
|
||||
"fixedbitset",
|
||||
"nonmax",
|
||||
"tracing",
|
||||
@ -1829,7 +1829,7 @@ dependencies = [
|
||||
"bevy_platform",
|
||||
"concurrent-queue",
|
||||
"crossbeam-queue",
|
||||
"derive_more 2.1.1",
|
||||
"derive_more",
|
||||
"futures-lite",
|
||||
"heapless 0.9.3",
|
||||
"web-task",
|
||||
@ -1891,7 +1891,7 @@ dependencies = [
|
||||
"bevy_reflect",
|
||||
"bevy_tasks",
|
||||
"bevy_utils",
|
||||
"derive_more 2.1.1",
|
||||
"derive_more",
|
||||
"serde",
|
||||
"thiserror 2.0.18",
|
||||
]
|
||||
@ -1946,7 +1946,7 @@ dependencies = [
|
||||
"bevy_transform",
|
||||
"bevy_utils",
|
||||
"bevy_window",
|
||||
"derive_more 2.1.1",
|
||||
"derive_more",
|
||||
"parley",
|
||||
"serde",
|
||||
"smallvec",
|
||||
@ -1985,7 +1985,7 @@ dependencies = [
|
||||
"bevy_ui",
|
||||
"bevy_utils",
|
||||
"bytemuck",
|
||||
"derive_more 2.1.1",
|
||||
"derive_more",
|
||||
"indexmap",
|
||||
"tracing",
|
||||
]
|
||||
@ -2094,7 +2094,7 @@ dependencies = [
|
||||
"bevy_reflect",
|
||||
"bevy_transform",
|
||||
"bevy_utils",
|
||||
"derive_more 2.1.1",
|
||||
"derive_more",
|
||||
"ron 0.12.1",
|
||||
"serde",
|
||||
"thiserror 2.0.18",
|
||||
@ -2390,12 +2390,6 @@ version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
|
||||
|
||||
[[package]]
|
||||
name = "claxon"
|
||||
version = "0.4.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4bfbf56724aa9eca8afa4fcfadeb479e722935bb2a0900c2d37e0cc477af0688"
|
||||
|
||||
[[package]]
|
||||
name = "clipboard-win"
|
||||
version = "5.4.1"
|
||||
@ -2472,27 +2466,6 @@ version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "const_panic"
|
||||
version = "0.2.15"
|
||||
@ -2523,12 +2496,6 @@ dependencies = [
|
||||
"const_soft_float",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "convert_case"
|
||||
version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6245d59a3e82a7fc217c5828a6692dbc6dfb63a0c8c90495621f7b9d79704a0e"
|
||||
|
||||
[[package]]
|
||||
name = "convert_case"
|
||||
version = "0.10.0"
|
||||
@ -2813,19 +2780,6 @@ version = "2.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "derive_more"
|
||||
version = "2.1.1"
|
||||
@ -2841,7 +2795,7 @@ version = "2.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb"
|
||||
dependencies = [
|
||||
"convert_case 0.10.0",
|
||||
"convert_case",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"rustc_version",
|
||||
@ -2952,7 +2906,6 @@ dependencies = [
|
||||
"bevy_egui",
|
||||
"bevy_solari",
|
||||
"bevy_ufbx",
|
||||
"blake3",
|
||||
"egui_dock",
|
||||
"egui_phosphor_icons",
|
||||
"game",
|
||||
@ -2965,7 +2918,6 @@ dependencies = [
|
||||
"ron 0.8.1",
|
||||
"scene",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"settings",
|
||||
"shared",
|
||||
"sim",
|
||||
@ -3072,15 +3024,6 @@ dependencies = [
|
||||
"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]]
|
||||
name = "enum_dispatch"
|
||||
version = "0.3.13"
|
||||
@ -3455,13 +3398,9 @@ dependencies = [
|
||||
"avian3d",
|
||||
"bevy",
|
||||
"bevy_ufbx",
|
||||
"cpal",
|
||||
"game_hot",
|
||||
"hot-lib-reloader",
|
||||
"protocol",
|
||||
"ron 0.12.1",
|
||||
"scene",
|
||||
"serde",
|
||||
"settings",
|
||||
"shared",
|
||||
"sim",
|
||||
@ -3862,12 +3801,6 @@ dependencies = [
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hound"
|
||||
version = "3.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "62adaabb884c94955b19907d60019f4e145d091c75345379e70d1ee696f7854f"
|
||||
|
||||
[[package]]
|
||||
name = "http"
|
||||
version = "1.4.2"
|
||||
@ -4317,21 +4250,6 @@ version = "3.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "kqueue"
|
||||
version = "1.2.0"
|
||||
@ -4488,22 +4406,6 @@ dependencies = [
|
||||
"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]]
|
||||
name = "matchers"
|
||||
version = "0.2.0"
|
||||
@ -4528,12 +4430,6 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "minimal-lexical"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
|
||||
|
||||
[[package]]
|
||||
name = "miniz_oxide"
|
||||
version = "0.8.9"
|
||||
@ -4670,16 +4566,6 @@ version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "nonmax"
|
||||
version = "0.5.5"
|
||||
@ -5921,13 +5807,10 @@ version = "0.22.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d0a536bb79db59098ef71a4dd4246c02eb87b316deceb1b68e0cde7167ec01eb"
|
||||
dependencies = [
|
||||
"claxon",
|
||||
"cpal",
|
||||
"dasp_sample",
|
||||
"hound",
|
||||
"lewton",
|
||||
"num-rational",
|
||||
"symphonia",
|
||||
"thiserror 2.0.18",
|
||||
"tracing",
|
||||
]
|
||||
@ -5958,17 +5841,6 @@ dependencies = [
|
||||
"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]]
|
||||
name = "rstar"
|
||||
version = "0.12.2"
|
||||
@ -6058,11 +5930,8 @@ dependencies = [
|
||||
name = "scene"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"blake3",
|
||||
"ron 0.8.1",
|
||||
"ron-edit",
|
||||
"serde",
|
||||
"settings",
|
||||
"shared",
|
||||
]
|
||||
|
||||
@ -6446,55 +6315,6 @@ dependencies = [
|
||||
"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]]
|
||||
name = "syn"
|
||||
version = "2.0.118"
|
||||
@ -8061,13 +7881,7 @@ checksum = "3ae8337f8a065cfc972643663ea4279e04e7256de865aa66fe25cec5fb912d3f"
|
||||
name = "xtask"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"blake3",
|
||||
"libc",
|
||||
"ron 0.8.1",
|
||||
"scene",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"settings",
|
||||
"walkdir",
|
||||
]
|
||||
|
||||
|
||||
27
Cargo.toml
27
Cargo.toml
@ -26,14 +26,7 @@ avian3d = { version = "0.7", default-features = false, features = [
|
||||
"collider-from-mesh",
|
||||
"serialize",
|
||||
] }
|
||||
bevy = { version = "0.19", features = [
|
||||
"serialize",
|
||||
"jpeg",
|
||||
"vorbis",
|
||||
"wav",
|
||||
"mp3",
|
||||
"flac",
|
||||
] }
|
||||
bevy = { version = "0.19", features = ["serialize", "jpeg"] }
|
||||
bevy_core_pipeline = "0.19"
|
||||
bevy_solari = "0.19"
|
||||
bevy_ufbx = "0.18.1-rc.1"
|
||||
@ -54,8 +47,6 @@ scene = { path = "crates/scene" }
|
||||
[patch.crates-io]
|
||||
# Local Bevy 0.19 compatibility patch until upstream publishes a matching FBX loader.
|
||||
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).
|
||||
transform-gizmo-bevy = { path = "third_party/transform-gizmo-bevy" }
|
||||
|
||||
@ -82,19 +73,3 @@ opt-level = 3
|
||||
[profile.release]
|
||||
lto = "thin"
|
||||
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
155
README.md
@ -21,15 +21,12 @@ native Bevy scene save/load, and BRP support for external tooling.
|
||||
|
||||
```bash
|
||||
# Check everything
|
||||
cargo fmt --all --check
|
||||
cargo check --workspace --all-targets
|
||||
cargo clippy --workspace --all-targets -- -D warnings
|
||||
cargo clippy -p editor -p game --bins --features dev,hot-reload -- -D warnings
|
||||
cargo test --workspace
|
||||
cargo validate-levels
|
||||
# Machine-readable project dependency and finding report
|
||||
cargo validate-levels --json
|
||||
cargo package-project --profile development
|
||||
cargo fmt --check
|
||||
cargo check --workspace
|
||||
cargo clippy --workspace
|
||||
cargo clippy -p game -p shared -p protocol -p sim -- -D warnings
|
||||
cargo test -p game --lib
|
||||
cargo test -p sim
|
||||
|
||||
# Runtime game
|
||||
cargo run -p game
|
||||
@ -40,22 +37,6 @@ cargo run -p editor
|
||||
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)
|
||||
|
||||
`--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
|
||||
```
|
||||
|
||||
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 |
|
||||
|-------|--------|
|
||||
@ -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
|
||||
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
|
||||
`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.
|
||||
|
||||
### 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.
|
||||
- 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.
|
||||
- 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`.
|
||||
- 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.
|
||||
- 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 && 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
|
||||
|
||||
@ -134,35 +115,27 @@ deep-stale variants.
|
||||
| `Ctrl+D` | Duplicate selection |
|
||||
| `Ctrl+Z` / `Ctrl+Shift+Z` / `Ctrl+Y` | Undo / redo |
|
||||
| `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 |
|
||||
| `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 |
|
||||
| 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 |
|
||||
| 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 |
|
||||
| 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`) |
|
||||
| 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 |
|
||||
| Drag audio clip into viewport | Create an authored audio source; when an audio source is selected, assign the clip instead |
|
||||
| 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 |
|
||||
| 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 |
|
||||
| `F7` | While paused in Play: advance one sim tick |
|
||||
| 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 |
|
||||
| Drag actor between rows / onto Scene Root (Hierarchy, Manual sort) | Reorder siblings / unparent to the root |
|
||||
| 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** |
|
||||
| Hierarchy context | Reparent to other selection / Unparent |
|
||||
| File menu | New, Open, Save, Save As, Import Assets, Export Selection, Save Selection As Prefab, Recent Scenes |
|
||||
| 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 |
|
||||
| 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, audio buses, physics, and input |
|
||||
| Edit → Project Settings… | Edit `assets/project.ron` (rendering, physics, 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.
|
||||
|
||||
@ -202,18 +175,7 @@ Viewport shortcut keys require the pointer to be in the viewport and are suspend
|
||||
## Scene Workflow
|
||||
|
||||
- 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`.
|
||||
- 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
|
||||
where Play mode boots the runtime player.
|
||||
- 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
|
||||
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
|
||||
details when you need full `WorldAssetRoot` scene data instead of normalized static slots.
|
||||
Expanding a model in the Asset Browser exposes normalized mesh/material/texture plus generated
|
||||
skeleton/animation-clip subassets. Dragging a mesh places that part through the static mesh 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).
|
||||
details when you need full `WorldAssetRoot` loading for animation/skinning/scene data. Expanding a
|
||||
model in the Asset Browser exposes normalized mesh/material/texture subassets; dragging a mesh
|
||||
subasset places that part through the same static mesh renderer path.
|
||||
- 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,
|
||||
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
|
||||
|
||||
Scroll-to-drag is disabled while dragging actors. The pointer card reports the pending action:
|
||||
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.
|
||||
Scroll-to-drag is disabled while dragging actors. Release the mouse to finish or cancel the drag.
|
||||
|
||||
## 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.
|
||||
- `settings.json` runs `clippy` on save, enables proc-macro/build-script support, formats on save,
|
||||
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.
|
||||
- `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.
|
||||
- `tasks.json` provides workspace check/clippy plus game/editor build/run tasks.
|
||||
- `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.
|
||||
|
||||
## Architecture Decisions
|
||||
@ -368,17 +292,11 @@ crates/
|
||||
- [x] CI workflow for format, check, clippy, tests, and binary builds
|
||||
- [x] ADRs for roadmap architecture and Bevy migration policy
|
||||
- [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] 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] 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] Independent dirty-tab close confirmation and all-tab save guard when switching projects
|
||||
- [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] Unsaved-scene confirm on New/Open/Recent
|
||||
- [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] Gameplay authoring markers + visualizers (`WeaponSpawn`, `TriggerVolume`, etc.)
|
||||
@ -387,18 +305,14 @@ crates/
|
||||
- [x] `ActorInspectorSection` registry + game demo section (`game::editor_ext`)
|
||||
- [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] `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 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))
|
||||
- [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))
|
||||
- [ ] Project launcher/scaffolding with startup-time asset-root selection; the unsafe in-process File → Project scaffold was removed pending BS-JD-001
|
||||
- [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] 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] 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 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] Prefab instance overrides v2 (transform/material/child visibility apply-revert groups)
|
||||
- [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] 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
|
||||
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
|
||||
edited from the browser, and delete actions move files to `assets/.trash/`. Animation authoring
|
||||
supports glTF/GLB rig and clip manifests, stable controller refs, clip drag assignment/placement,
|
||||
inspector preview, PIE/runtime playback, and headless compatibility diagnostics. Animated or
|
||||
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).
|
||||
edited from the browser, and delete actions move files to `assets/.trash/`. Skeletal animation
|
||||
playback is not supported by the static mesh path yet.
|
||||
- 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.
|
||||
- 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.
|
||||
- The authoring/hydration layer is intentionally small so richer asset workflows (terrain,
|
||||
|
||||
@ -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"),
|
||||
path: "assets/post_fx/chromatic_aberration.ron",
|
||||
@ -79,6 +47,27 @@
|
||||
),
|
||||
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"),
|
||||
path: "assets/textures/metal_stool_01_nor_gl_2k.jpg",
|
||||
@ -131,7 +120,7 @@
|
||||
id: ("61802b85-fda9-4aff-94d9-fe5f0860c516"),
|
||||
path: "assets/levels/rendering_showcase.scn.ron",
|
||||
label: "rendering_showcase.scn",
|
||||
kind_tag: "Level",
|
||||
kind_tag: "Prefab",
|
||||
import_settings: (
|
||||
scale: 1.0,
|
||||
generate_collider: true,
|
||||
@ -147,23 +136,7 @@
|
||||
id: ("48bcef13-e6a6-4260-bb1b-c29787bd8c70"),
|
||||
path: "assets/levels/editor_scene 3.scn.ron",
|
||||
label: "editor_scene 3.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: ("f3dfcd11-aa5b-4fd4-872b-06832b865c99"),
|
||||
path: "assets/levels/audio_authoring_showcase.scn.ron",
|
||||
label: "audio_authoring_showcase.scn",
|
||||
kind_tag: "Level",
|
||||
kind_tag: "Prefab",
|
||||
import_settings: (
|
||||
scale: 1.0,
|
||||
generate_collider: true,
|
||||
@ -179,7 +152,7 @@
|
||||
id: ("5a2307d0-48e5-40d3-a5c9-527c0cfd30f4"),
|
||||
path: "assets/levels/editor_scene.scn.ron",
|
||||
label: "editor_scene.scn",
|
||||
kind_tag: "Level",
|
||||
kind_tag: "Prefab",
|
||||
import_settings: (
|
||||
scale: 1.0,
|
||||
generate_collider: true,
|
||||
@ -195,54 +168,6 @@
|
||||
id: ("dc13ce01-c7ce-43c5-974c-0e659ae49ab9"),
|
||||
path: "assets/levels/editor_scene 2.scn.ron",
|
||||
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",
|
||||
import_settings: (
|
||||
scale: 1.0,
|
||||
@ -319,54 +244,6 @@
|
||||
),
|
||||
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"),
|
||||
path: "assets/shaders/unlit.shader.ron",
|
||||
|
||||
BIN
assets/audio/Puzzle Lobby.mp3
(Stored with Git LFS)
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)
BIN
assets/audio/editor_audition_tone.ogg
(Stored with Git LFS)
Binary file not shown.
@ -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",
|
||||
)
|
||||
@ -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",
|
||||
)
|
||||
@ -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",
|
||||
)
|
||||
@ -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,
|
||||
),
|
||||
},
|
||||
),
|
||||
},
|
||||
)
|
||||
@ -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
BIN
assets/models/metal_stool_01_2k.gltf
(Stored with Git LFS)
Normal file
Binary file not shown.
@ -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)
BIN
assets/models/robot_expressive.glb
(Stored with Git LFS)
Binary file not shown.
@ -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.
|
||||
@ -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": (),
|
||||
},
|
||||
),
|
||||
},
|
||||
)
|
||||
@ -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,
|
||||
),
|
||||
},
|
||||
),
|
||||
},
|
||||
)
|
||||
@ -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:[])"),
|
||||
),
|
||||
},
|
||||
),
|
||||
},
|
||||
)
|
||||
@ -1,13 +1,8 @@
|
||||
(
|
||||
version: 1,
|
||||
project_id: "blacksite-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",
|
||||
asset_roots: [
|
||||
"assets/audio",
|
||||
"assets/models",
|
||||
"assets/textures",
|
||||
"assets/materials",
|
||||
@ -34,31 +29,6 @@
|
||||
shadow_first_cascade: 6.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: (
|
||||
player_radius: 0.4,
|
||||
player_length: 1.0,
|
||||
|
||||
@ -4,7 +4,6 @@ version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
description = "In-process level editor for the Bevy FPS foundation"
|
||||
default-run = "editor"
|
||||
|
||||
[lib]
|
||||
name = "editor"
|
||||
@ -14,10 +13,6 @@ path = "src/lib.rs"
|
||||
name = "editor"
|
||||
path = "src/bin/main.rs"
|
||||
|
||||
[[bin]]
|
||||
name = "project_launcher"
|
||||
path = "src/bin/project_launcher.rs"
|
||||
|
||||
[features]
|
||||
dev = ["bevy/dynamic_linking", "game/dev"]
|
||||
hot-reload = ["game/hot-reload", "dep:hot-lib-reloader", "dep:notify"]
|
||||
@ -42,11 +37,9 @@ sim.workspace = true
|
||||
settings.workspace = true
|
||||
scene.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json = "1"
|
||||
transform-gizmo-bevy.workspace = true
|
||||
rfd = "0.17.2"
|
||||
ron = "0.8"
|
||||
uuid = { version = "1", features = ["v4", "serde"] }
|
||||
walkdir = "2.5"
|
||||
gltf = { version = "1.4", features = ["extras"] }
|
||||
blake3 = "1"
|
||||
gltf = "1.4"
|
||||
|
||||
@ -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());
|
||||
}
|
||||
}
|
||||
@ -2,7 +2,7 @@
|
||||
|
||||
use bevy::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::collections::HashMap;
|
||||
use uuid::Uuid;
|
||||
|
||||
/// Stable asset identity for dependency tracking and prefab references.
|
||||
@ -59,8 +59,6 @@ pub struct ImportSettings {
|
||||
pub material_policy: MaterialImportPolicy,
|
||||
#[serde(default)]
|
||||
pub static_mesh_manifest_path: Option<String>,
|
||||
#[serde(default)]
|
||||
pub animation_manifest_path: Option<String>,
|
||||
}
|
||||
|
||||
impl Default for ImportSettings {
|
||||
@ -73,7 +71,6 @@ impl Default for ImportSettings {
|
||||
hierarchy_mode: ModelHierarchyMode::default(),
|
||||
material_policy: MaterialImportPolicy::default(),
|
||||
static_mesh_manifest_path: None,
|
||||
animation_manifest_path: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -118,11 +115,6 @@ fn sync_registry_from_browser(
|
||||
.drain(..)
|
||||
.map(|record| (record.path.clone(), record))
|
||||
.collect();
|
||||
let current_paths = assets
|
||||
.assets
|
||||
.iter()
|
||||
.filter_map(|asset| asset.path.clone())
|
||||
.collect::<HashSet<_>>();
|
||||
|
||||
let mut next_records = Vec::new();
|
||||
let mut seen_paths = HashMap::new();
|
||||
@ -137,18 +129,7 @@ fn sync_registry_from_browser(
|
||||
seen_paths.insert(path.clone(), ());
|
||||
|
||||
let kind_tag = format!("{:?}", asset.kind);
|
||||
let mut record = if let Some(mut prior) = existing.remove(&path).or_else(|| {
|
||||
take_uniquely_moved_model_record(&mut existing, ¤t_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();
|
||||
}
|
||||
let mut record = if let Some(mut prior) = existing.remove(&path) {
|
||||
prior.label = asset.label.clone();
|
||||
prior.kind_tag = kind_tag;
|
||||
prior
|
||||
@ -163,9 +144,9 @@ fn sync_registry_from_browser(
|
||||
}
|
||||
};
|
||||
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!(
|
||||
"Model artifact refresh failed for {} (asset id {}): {error}",
|
||||
"Static mesh processor failed for {} (asset id {}): {error}",
|
||||
record.path,
|
||||
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> {
|
||||
registry
|
||||
.records
|
||||
@ -243,34 +196,6 @@ pub fn find_asset_mut_by_path<'a>(
|
||||
.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(
|
||||
registry: &mut AssetRegistry,
|
||||
path: &str,
|
||||
@ -315,98 +240,3 @@ pub fn load_registry() -> AssetRegistry {
|
||||
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,
|
||||
¤t_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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -5,34 +5,28 @@ use bevy::prelude::*;
|
||||
use settings::ProjectSettings;
|
||||
use shared::{
|
||||
ActorKind, AuthoringLightKind, ColliderDesc, ColorDesc, EditorVisibility, LightDesc,
|
||||
MaterialDesc, ModelRef, PostProcessVolumeDesc, PrefabRef, Primitive, PrimitiveShape,
|
||||
RigidBodyDesc, AUDIO_CLIP_SUB_ASSET_ID,
|
||||
MaterialDesc, ModelRef, PostProcessVolumeDesc, PrefabInstance, PrefabRef, Primitive,
|
||||
PrimitiveShape, RigidBodyDesc,
|
||||
};
|
||||
use walkdir::WalkDir;
|
||||
|
||||
use crate::asset_db::{
|
||||
ensure_asset_record, find_asset_by_id, find_asset_by_path, find_asset_mut_by_path,
|
||||
AssetRegistry, MaterialImportPolicy, ModelPlacementMode,
|
||||
find_asset_by_path, find_asset_mut_by_path, AssetRegistry, MaterialImportPolicy,
|
||||
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};
|
||||
|
||||
pub const BUILTINS_FOLDER: &str = "__builtins__";
|
||||
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)]
|
||||
pub enum AssetSubAssetKind {
|
||||
Mesh,
|
||||
Material,
|
||||
Texture,
|
||||
Skeleton,
|
||||
AnimationClip,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
||||
@ -79,7 +73,6 @@ pub enum EditorAssetKind {
|
||||
Model,
|
||||
Texture,
|
||||
Material,
|
||||
AudioClip,
|
||||
Level,
|
||||
Prefab,
|
||||
PostProcessVolume,
|
||||
@ -226,15 +219,7 @@ pub struct EditorAssetsPlugin;
|
||||
impl Plugin for EditorAssetsPlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
app.init_resource::<EditorAssets>()
|
||||
.init_resource::<super::prefab_overrides::PrefabHealthCache>()
|
||||
.add_observer(super::prefab_overrides::invalidate_prefab_health_on_ready)
|
||||
.add_systems(
|
||||
Update,
|
||||
(
|
||||
refresh_assets_on_settings_change,
|
||||
super::prefab_overrides::guard_prefab_reimports,
|
||||
),
|
||||
);
|
||||
.add_systems(Update, refresh_assets_on_settings_change);
|
||||
}
|
||||
}
|
||||
|
||||
@ -367,56 +352,50 @@ fn should_skip_asset_path(path: &Path) -> bool {
|
||||
|
||||
fn asset_from_file_path(path: &Path) -> Option<EditorAsset> {
|
||||
let extension = path.extension()?.to_str()?.to_ascii_lowercase();
|
||||
let normalized_path = normalize_asset_path(path);
|
||||
let (kinds, extensions) = if is_audio_asset_extension(&extension) {
|
||||
(
|
||||
vec![EditorAssetKind::AudioClip],
|
||||
AUDIO_ASSET_EXTENSIONS.to_vec(),
|
||||
)
|
||||
} else {
|
||||
match extension.as_str() {
|
||||
let (kinds, extensions) = match extension.as_str() {
|
||||
"gltf" | "glb" | "fbx" => (vec![EditorAssetKind::Model], vec!["gltf", "glb", "fbx"]),
|
||||
"png" | "jpg" | "jpeg" | "webp" | "ktx2" => (
|
||||
vec![EditorAssetKind::Texture],
|
||||
vec!["png", "jpg", "jpeg", "webp", "ktx2"],
|
||||
),
|
||||
"ron" | "mat" | "material" => {
|
||||
if normalized_path.contains("/materials/") {
|
||||
if path.to_string_lossy().contains("/materials/") {
|
||||
(
|
||||
vec![EditorAssetKind::Material],
|
||||
vec!["ron", "mat", "material"],
|
||||
)
|
||||
} else if normalized_path.contains("/post_fx/") {
|
||||
} else if path.to_string_lossy().contains("/post_fx/") {
|
||||
(vec![EditorAssetKind::PostProcessEffect], vec!["ron"])
|
||||
} else if normalized_path.contains("/rendering_profiles/") {
|
||||
} else if path.to_string_lossy().contains("/rendering_profiles/") {
|
||||
(vec![EditorAssetKind::RenderingProfile], vec!["ron"])
|
||||
} else if normalized_path.contains("/shaders/") {
|
||||
} else if path.to_string_lossy().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"])
|
||||
(
|
||||
vec![EditorAssetKind::Level, EditorAssetKind::Prefab],
|
||||
vec!["ron"],
|
||||
)
|
||||
}
|
||||
}
|
||||
_ => return None,
|
||||
}
|
||||
};
|
||||
|
||||
if !has_extension(path, &extensions) {
|
||||
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 label = path
|
||||
.file_stem()
|
||||
.and_then(|stem| stem.to_str())
|
||||
.unwrap_or(&path_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 {
|
||||
label,
|
||||
@ -468,54 +447,8 @@ fn has_extension(path: &Path, extensions: &[&str]) -> bool {
|
||||
})
|
||||
}
|
||||
|
||||
fn is_audio_asset_extension(extension: &str) -> bool {
|
||||
AUDIO_ASSET_EXTENSIONS
|
||||
.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 {
|
||||
pub fn snapshot_for_asset(asset: &EditorAsset, translation: Vec3) -> Option<EditorEntitySnapshot> {
|
||||
let mut snapshot = EditorEntitySnapshot {
|
||||
actor_id: None,
|
||||
actor_kind: ActorKind::Empty,
|
||||
actor_name: None,
|
||||
@ -530,9 +463,6 @@ fn empty_snapshot_for_asset(asset: &EditorAsset, translation: Vec3) -> EditorEnt
|
||||
collider: None,
|
||||
physics: None,
|
||||
light: None,
|
||||
animation_controller: None,
|
||||
audio_source: None,
|
||||
audio_listener: None,
|
||||
player_spawn: false,
|
||||
model: None,
|
||||
prefab: None,
|
||||
@ -545,11 +475,7 @@ fn empty_snapshot_for_asset(asset: &EditorAsset, translation: Vec3) -> EditorEnt
|
||||
hierarchy_sibling_index: 0,
|
||||
editor_visibility: EditorVisibility::default(),
|
||||
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 {
|
||||
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.transform.translation.y += 0.1;
|
||||
}
|
||||
EditorAssetKind::Prefab => {
|
||||
EditorAssetKind::Prefab | EditorAssetKind::Level => {
|
||||
snapshot.actor_kind = ActorKind::PrefabAnchor;
|
||||
let path = asset.path.as_ref()?;
|
||||
snapshot.prefab = Some(PrefabRef::new(path.clone()));
|
||||
}
|
||||
EditorAssetKind::Level => return None,
|
||||
EditorAssetKind::Texture
|
||||
| EditorAssetKind::Material
|
||||
| EditorAssetKind::AudioClip
|
||||
| EditorAssetKind::PostProcessEffect
|
||||
| EditorAssetKind::RenderingProfile
|
||||
| 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> {
|
||||
let mut snapshot = if matches!(asset.kind, EditorAssetKind::AudioClip) {
|
||||
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)?
|
||||
};
|
||||
let mut snapshot = snapshot_for_asset(asset, translation)?;
|
||||
if let Some(light) = snapshot.light.as_mut() {
|
||||
if matches!(light.kind, AuthoringLightKind::Directional) {
|
||||
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() {
|
||||
let mut record = world
|
||||
let record = world
|
||||
.get_resource::<AssetRegistry>()
|
||||
.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 {
|
||||
match asset.kind {
|
||||
EditorAssetKind::Model => {
|
||||
if let Some(model) = snapshot.model.as_mut() {
|
||||
model.asset_id = record.id.as_string();
|
||||
}
|
||||
snapshot.transform.scale *= record.import_settings.scale;
|
||||
if matches!(
|
||||
record.import_settings.placement_mode,
|
||||
@ -693,11 +580,7 @@ pub fn spawn_asset_at(world: &mut World, asset: &EditorAsset, translation: Vec3)
|
||||
}
|
||||
EditorAssetKind::Prefab => {
|
||||
snapshot.prefab_instance =
|
||||
Some(super::prefab_overrides::new_tracked_prefab_instance(
|
||||
world,
|
||||
record.id.as_string(),
|
||||
path.clone(),
|
||||
));
|
||||
Some(PrefabInstance::new(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))
|
||||
}
|
||||
|
||||
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(
|
||||
world: &mut World,
|
||||
selection: &AssetSelection,
|
||||
translation: Vec3,
|
||||
) -> Option<Entity> {
|
||||
if matches!(
|
||||
selection,
|
||||
AssetSelection::SubAsset {
|
||||
kind: AssetSubAssetKind::AnimationClip,
|
||||
..
|
||||
}
|
||||
) {
|
||||
return spawn_animation_clip_at(world, selection, translation);
|
||||
}
|
||||
|
||||
let AssetSelection::SubAsset {
|
||||
parent_path,
|
||||
sub_asset_id,
|
||||
@ -806,9 +663,6 @@ pub fn spawn_subasset_at(
|
||||
collider: None,
|
||||
physics: None,
|
||||
light: None,
|
||||
animation_controller: None,
|
||||
audio_source: None,
|
||||
audio_listener: None,
|
||||
player_spawn: false,
|
||||
model: None,
|
||||
prefab: None,
|
||||
@ -831,251 +685,6 @@ pub fn spawn_subasset_at(
|
||||
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(
|
||||
snapshot: &mut EditorEntitySnapshot,
|
||||
renderer: shared::StaticMeshRenderer,
|
||||
@ -1124,9 +733,6 @@ fn apply_static_mesh_placement_mode(
|
||||
collider,
|
||||
physics: None,
|
||||
light: None,
|
||||
animation_controller: None,
|
||||
audio_source: None,
|
||||
audio_listener: None,
|
||||
player_spawn: false,
|
||||
model: None,
|
||||
prefab: None,
|
||||
@ -1182,13 +788,13 @@ fn static_mesh_renderer_for_asset(
|
||||
if let Some(mut registry) = world.get_resource_mut::<AssetRegistry>() {
|
||||
let mut renderer = None;
|
||||
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) => {
|
||||
renderer = Some(renderer_from_manifest(&manifest, &record.import_settings));
|
||||
registry.index_dirty = true;
|
||||
}
|
||||
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();
|
||||
}
|
||||
}
|
||||
@ -1241,9 +847,6 @@ pub fn import_external_assets(paths: &[PathBuf]) -> Result<usize, String> {
|
||||
}
|
||||
|
||||
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() {
|
||||
"gltf" | "glb" | "fbx" => "assets/models",
|
||||
"png" | "jpg" | "jpeg" | "webp" | "ktx2" => "assets/textures",
|
||||
@ -1267,85 +870,3 @@ pub fn asset_cache_key(asset: &EditorAsset) -> String {
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
||||
@ -5,7 +5,7 @@ use bevy_egui::egui;
|
||||
use shared::{LevelObject, MaterialAsset, MaterialDesc};
|
||||
|
||||
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;
|
||||
|
||||
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,
|
||||
asset: &EditorAsset,
|
||||
selected: &bevy_inspector_egui::bevy_inspector::hierarchy::SelectedEntities,
|
||||
) -> Result<(), String> {
|
||||
let path = asset
|
||||
.path
|
||||
.clone()
|
||||
.ok_or_else(|| format!("Material has no source path: {}", asset.label))?;
|
||||
let desc = material_desc_from_asset(&path)?;
|
||||
let changes = crate::ui::helpers::selected_level_entities(world, selected)
|
||||
.into_iter()
|
||||
.filter(|entity| world.get::<LevelObject>(*entity).is_some())
|
||||
.map(|entity| (entity, desc.clone()))
|
||||
.collect::<Vec<_>>();
|
||||
set_material_group_with_history(world, changes);
|
||||
) {
|
||||
let Some(path) = asset.path.clone() else {
|
||||
return;
|
||||
};
|
||||
let desc = match material_desc_from_asset(&path) {
|
||||
Ok(desc) => desc,
|
||||
Err(err) => {
|
||||
world.resource_mut::<crate::scene_io::SceneIo>().status = err;
|
||||
return;
|
||||
}
|
||||
};
|
||||
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 =
|
||||
format!("Applied material {}", asset.label);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn material_asset_picker_ui(
|
||||
|
||||
@ -1,6 +1,5 @@
|
||||
//! Asset catalog, database, thumbnails, materials, and prefab overrides.
|
||||
|
||||
pub mod animation;
|
||||
pub mod asset_db;
|
||||
mod catalog;
|
||||
mod import;
|
||||
@ -12,25 +11,6 @@ pub mod thumbnails;
|
||||
|
||||
pub use catalog::*;
|
||||
pub use thumbnails::{
|
||||
draw_asset_cell_with, gltf_skinned_primitive_labels, invalidate_on_catalog_refresh, kind_icon,
|
||||
prefetch_folder_thumbnails, AssetThumbnailCache, ThumbnailCacheSnapshot, ThumbnailState,
|
||||
ThumbnailStudio, ThumbnailsPlugin,
|
||||
draw_asset_cell_with, invalidate_on_catalog_refresh, kind_icon, prefetch_folder_thumbnails,
|
||||
AssetThumbnailCache, ThumbnailCacheSnapshot, ThumbnailState, 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}"
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
@ -8,11 +8,7 @@ use crate::ui::helpers::{
|
||||
apply_material_asset_to_selection, apply_texture_to_selection, selected_level_entities,
|
||||
};
|
||||
|
||||
use super::{
|
||||
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,
|
||||
};
|
||||
use super::{spawn_asset_at, spawn_subasset_at, AssetSelection, EditorAsset};
|
||||
|
||||
fn selection_availability(world: &World, selected: &SelectedEntities) -> OperatorAvailability {
|
||||
if selected_level_entities(world, selected).is_empty() {
|
||||
@ -30,7 +26,6 @@ pub fn place_asset_operator(world: &mut World, asset: EditorAsset, translation:
|
||||
&label,
|
||||
OperatorAvailability::Ready,
|
||||
move |world| {
|
||||
validate_prefab_asset(world, &asset)?;
|
||||
spawn_asset_at(world, &asset, translation)
|
||||
.map(|_| ())
|
||||
.ok_or_else(|| format!("Asset cannot be placed: {}", asset.label))
|
||||
@ -69,7 +64,10 @@ pub fn apply_texture_operator(
|
||||
"assets.apply_texture",
|
||||
&label,
|
||||
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",
|
||||
&label,
|
||||
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| {
|
||||
let clip = audio_clip_ref_for_asset(world, &asset)?;
|
||||
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);
|
||||
apply_material_asset_to_selection(world, &asset, selected);
|
||||
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
@ -6,7 +6,7 @@ use bevy::prelude::*;
|
||||
use bevy_egui::{egui, EguiPrimaryContextPass, EguiTextureHandle, EguiUserTextures};
|
||||
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::ThumbnailJobSource;
|
||||
use crate::assets::{
|
||||
@ -100,12 +100,13 @@ impl AssetThumbnailCache {
|
||||
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;
|
||||
}
|
||||
|
||||
if let Some(texture_path) = gltf_base_color_texture_path(&model_path) {
|
||||
self.request_texture(key, texture_path, asset_server);
|
||||
if !model_file_exists(&model_path) {
|
||||
self.mark_studio_failed(&key, "file not found", false);
|
||||
return;
|
||||
}
|
||||
|
||||
@ -120,7 +121,6 @@ impl AssetThumbnailCache {
|
||||
model_path: String,
|
||||
mesh_label: String,
|
||||
material_label: Option<String>,
|
||||
requires_scene_instance: bool,
|
||||
studio: &mut ThumbnailStudio,
|
||||
) {
|
||||
if matches!(
|
||||
@ -130,19 +130,18 @@ impl AssetThumbnailCache {
|
||||
{
|
||||
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;
|
||||
}
|
||||
let source = if requires_scene_instance {
|
||||
ThumbnailJobSource::Model { model_path }
|
||||
} else {
|
||||
if studio.enqueue_source(
|
||||
key.clone(),
|
||||
ThumbnailJobSource::MeshSubAsset {
|
||||
model_path,
|
||||
mesh_label,
|
||||
material_label,
|
||||
}
|
||||
};
|
||||
if studio.enqueue_source(key.clone(), source) {
|
||||
},
|
||||
) {
|
||||
self.studio_pending.insert(key);
|
||||
}
|
||||
}
|
||||
@ -161,7 +160,8 @@ impl AssetThumbnailCache {
|
||||
{
|
||||
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;
|
||||
}
|
||||
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(
|
||||
&mut self,
|
||||
key: &str,
|
||||
@ -295,7 +283,6 @@ pub fn kind_icon(kind: &EditorAssetKind) -> egui_phosphor_icons::Icon {
|
||||
EditorAssetKind::Model => icons::CUBE_TRANSPARENT,
|
||||
EditorAssetKind::Texture => icons::IMAGE,
|
||||
EditorAssetKind::Material => icons::PAINT_BRUSH,
|
||||
EditorAssetKind::AudioClip => icons::WAVEFORM,
|
||||
EditorAssetKind::Level => icons::MAP_TRIFOLD,
|
||||
EditorAssetKind::Prefab => icons::PACKAGE,
|
||||
EditorAssetKind::PostProcessVolume => icons::CAMERA,
|
||||
|
||||
@ -12,9 +12,7 @@ pub use cache::{
|
||||
AssetThumbnailCache, AssetThumbnailsPlugin, ThumbnailCacheSnapshot, ThumbnailState,
|
||||
};
|
||||
pub use job::{ThumbnailJob, ThumbnailJobSource};
|
||||
pub use sources::{
|
||||
gltf_skinned_primitive_labels, FbxThumbnailSource, GltfThumbnailSource, ThumbnailModelSource,
|
||||
};
|
||||
pub use sources::{FbxThumbnailSource, GltfThumbnailSource, ThumbnailModelSource};
|
||||
pub use studio::{
|
||||
model_file_exists, model_scene_asset_path, ThumbnailStudio, ThumbnailStudioPlugin,
|
||||
};
|
||||
|
||||
@ -1,6 +1,5 @@
|
||||
//! glTF/GLB fast-path texture extraction for asset browser thumbnails.
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::path::Path;
|
||||
|
||||
/// 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
|
||||
}
|
||||
|
||||
/// 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;
|
||||
|
||||
fn is_gltf_path(path: &Path) -> bool {
|
||||
@ -123,9 +61,7 @@ fn normalize_asset_path(path: &Path) -> Option<String> {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
#[test]
|
||||
fn normalize_asset_path_keeps_assets_prefix() {
|
||||
@ -143,37 +79,4 @@ mod tests {
|
||||
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"));
|
||||
}
|
||||
}
|
||||
|
||||
@ -4,7 +4,7 @@ mod fbx;
|
||||
pub(crate) mod gltf;
|
||||
|
||||
pub use fbx::FbxThumbnailSource;
|
||||
pub use gltf::{gltf_skinned_primitive_labels, GltfThumbnailSource};
|
||||
pub use gltf::GltfThumbnailSource;
|
||||
|
||||
use bevy::prelude::*;
|
||||
|
||||
|
||||
@ -11,7 +11,7 @@ use bevy::mesh::VertexAttributeValues;
|
||||
use bevy::prelude::*;
|
||||
use bevy::render::render_resource::TextureFormat;
|
||||
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::job::{ThumbnailJob, ThumbnailJobSource};
|
||||
@ -315,9 +315,6 @@ fn process_thumbnail_studio(
|
||||
ThumbnailStudioLayer,
|
||||
RenderLayers::layer(THUMBNAIL_LAYER),
|
||||
Transform::default(),
|
||||
Visibility::Visible,
|
||||
InheritedVisibility::default(),
|
||||
ViewVisibility::default(),
|
||||
))
|
||||
.id();
|
||||
|
||||
@ -413,7 +410,7 @@ fn spawn_thumbnail_job_content(
|
||||
commands,
|
||||
mesh_storage,
|
||||
root,
|
||||
asset_server.load(standard_material_asset_path(
|
||||
asset_server.load(labeled_asset_path(
|
||||
&asset_server_path(model_path),
|
||||
material_label,
|
||||
)),
|
||||
@ -437,7 +434,7 @@ fn spawn_mesh_subasset_preview(
|
||||
let source_path = asset_server_path(model_path);
|
||||
let mesh: Handle<Mesh> = asset_server.load(labeled_asset_path(&source_path, mesh_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 {
|
||||
materials.add(StandardMaterial {
|
||||
base_color: Color::srgb(0.72, 0.72, 0.75),
|
||||
|
||||
@ -1,27 +1,6 @@
|
||||
//! In-process editor binary entry point.
|
||||
|
||||
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() {
|
||||
// SAFETY: called before any threads or GPU init.
|
||||
unsafe { std::env::set_var("WGPU_VALIDATION", "0") };
|
||||
|
||||
@ -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();
|
||||
}
|
||||
@ -1,11 +1,8 @@
|
||||
use std::path::PathBuf;
|
||||
|
||||
use bevy::prelude::*;
|
||||
use shared::{
|
||||
ActorId, ActorKind, ActorName, AnimationControllerDesc, AudioListenerDesc, AudioSourceDesc,
|
||||
BrushDesc, ColliderDesc, EditorVisibility, InspectorOrder, LightDesc, MaterialDesc,
|
||||
MaterialOverride, ModelRef, ObjectiveMarker, PhysicsBody, PostProcessVolumeDesc,
|
||||
PrefabInstance, PrefabRef, Primitive, RigidBodyDesc, SceneComposition, StaticMeshRenderer,
|
||||
ActorId, ActorKind, ActorName, BrushDesc, ColliderDesc, EditorVisibility, InspectorOrder,
|
||||
LightDesc, MaterialDesc, MaterialOverride, ModelRef, ObjectiveMarker, PhysicsBody,
|
||||
PostProcessVolumeDesc, PrefabInstance, PrefabRef, Primitive, RigidBodyDesc, StaticMeshRenderer,
|
||||
TeamSpawn, TriggerVolume, WeaponSpawn,
|
||||
};
|
||||
#[derive(Debug, Clone)]
|
||||
@ -24,9 +21,6 @@ pub struct EditorEntitySnapshot {
|
||||
pub collider: Option<ColliderDesc>,
|
||||
pub physics: Option<PhysicsBody>,
|
||||
pub light: Option<LightDesc>,
|
||||
pub animation_controller: Option<AnimationControllerDesc>,
|
||||
pub audio_source: Option<AudioSourceDesc>,
|
||||
pub audio_listener: Option<AudioListenerDesc>,
|
||||
pub player_spawn: bool,
|
||||
pub model: Option<ModelRef>,
|
||||
pub prefab: Option<PrefabRef>,
|
||||
@ -48,17 +42,6 @@ pub struct SiblingChange {
|
||||
pub new_parent: Option<Entity>,
|
||||
pub old_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)]
|
||||
@ -94,11 +77,6 @@ pub enum EditorCommand {
|
||||
old: Option<MaterialDesc>,
|
||||
new: MaterialDesc,
|
||||
},
|
||||
SetMaterialGroup {
|
||||
entities: Vec<Entity>,
|
||||
olds: Vec<Option<MaterialDesc>>,
|
||||
news: Vec<MaterialDesc>,
|
||||
},
|
||||
SetMaterialOverride {
|
||||
entity: Entity,
|
||||
old: Option<MaterialOverride>,
|
||||
@ -109,21 +87,6 @@ pub enum EditorCommand {
|
||||
old: Option<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 {
|
||||
entity: Entity,
|
||||
old: Option<PhysicsBody>,
|
||||
@ -202,35 +165,6 @@ pub enum EditorCommand {
|
||||
old: Option<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 {
|
||||
entity: Entity,
|
||||
snapshot: EditorEntitySnapshot,
|
||||
@ -251,12 +185,8 @@ impl EditorCommand {
|
||||
EditorCommand::Rename { .. } => "Rename",
|
||||
EditorCommand::SetTransform { .. } => "Move",
|
||||
EditorCommand::SetMaterial { .. } => "Set Material",
|
||||
EditorCommand::SetMaterialGroup { .. } => "Apply Material to Selection",
|
||||
EditorCommand::SetMaterialOverride { .. } => "Set Material Override",
|
||||
EditorCommand::SetLight { .. } => "Set Light",
|
||||
EditorCommand::SetAnimationController { .. } => "Set Animation Controller",
|
||||
EditorCommand::SetAudioSource { .. } => "Set Audio Source",
|
||||
EditorCommand::SetAudioListener { .. } => "Set Audio Listener",
|
||||
EditorCommand::SetPhysics { .. } => "Set Physics",
|
||||
EditorCommand::SetRigidBody { .. } => "Set Rigid Body",
|
||||
EditorCommand::SetCollider { .. } => "Set Collider",
|
||||
@ -272,15 +202,6 @@ impl EditorCommand {
|
||||
EditorCommand::ReorderSibling { .. } => "Reorder Hierarchy",
|
||||
EditorCommand::SetEditorVisibility { .. } => "Set Visibility",
|
||||
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::RemoveComponent { .. } => "Remove Component",
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -22,10 +22,7 @@ pub use ext::extensibility;
|
||||
pub use ext::hot_reload;
|
||||
pub use play::net_editor;
|
||||
pub use play::state;
|
||||
pub use project::diagnostics_bundle;
|
||||
pub use project::launcher;
|
||||
pub use project::project_io;
|
||||
pub use project::session;
|
||||
pub use project::settings_ui;
|
||||
pub use scene::scene_io;
|
||||
pub use scene::scene_schema;
|
||||
@ -46,8 +43,7 @@ use bevy::app::PluginGroupBuilder;
|
||||
use bevy::prelude::*;
|
||||
use bevy_egui::EguiPlugin;
|
||||
use game::{
|
||||
launch, GameInputEnabled, GameInputFocused, GamePlugin, GameRenderBootstrap,
|
||||
GameSceneBootstrap, SimEnabled,
|
||||
launch, GameInputEnabled, GameInputFocused, GamePlugin, GameRenderBootstrap, SimEnabled,
|
||||
};
|
||||
use transform_gizmo_bevy::prelude::TransformGizmoPlugin;
|
||||
|
||||
@ -62,7 +58,6 @@ use gizmos::EditorGizmoPlugin;
|
||||
use history::EditorHistoryPlugin;
|
||||
use infra::EditorInfraPlugin;
|
||||
use operators::OperatorPlugin;
|
||||
use play::audio_preview::AudioPreviewPlugin;
|
||||
use play::PlaySessionPlugin;
|
||||
use project_io::ProjectIoPlugin;
|
||||
use render_view::RenderViewPlugin;
|
||||
@ -70,7 +65,6 @@ use scene_io::SceneIoPlugin;
|
||||
use scene_view::SceneViewPlugin;
|
||||
use selection::EditorSelectionPlugin;
|
||||
use selection_outline::SelectionOutlinePlugin;
|
||||
use session::EditorSessionPlugin;
|
||||
use settings_ui::SettingsUiPlugin;
|
||||
use state::EditorStatePlugin;
|
||||
use ui::EditorUiPlugin;
|
||||
@ -103,7 +97,6 @@ impl PluginGroup for EditorPluginGroup {
|
||||
.add(BrushEditPlugin)
|
||||
.add(BrushToolPlugin)
|
||||
.add(EditorCameraPlugin)
|
||||
.add(AudioPreviewPlugin)
|
||||
.add(ActorIconsPlugin)
|
||||
.add(EditorSelectionPlugin)
|
||||
.add(SelectionOutlinePlugin)
|
||||
@ -112,7 +105,6 @@ impl PluginGroup for EditorPluginGroup {
|
||||
.add(EditorHistoryPlugin)
|
||||
.add(SceneIoPlugin)
|
||||
.add(EditorUiPlugin)
|
||||
.add(EditorSessionPlugin)
|
||||
.add(brp::BrpPlugin);
|
||||
#[cfg(feature = "hot-reload")]
|
||||
let group = group.add(hot_reload::HotReloadPlugin);
|
||||
@ -129,9 +121,6 @@ pub fn configure_editor_app(app: &mut App) {
|
||||
.insert_resource(GameRenderBootstrap {
|
||||
defer_player_camera_fx: true,
|
||||
})
|
||||
.insert_resource(GameSceneBootstrap {
|
||||
defer_default_level: true,
|
||||
})
|
||||
.add_plugins(GamePlugin)
|
||||
.add_plugins(MeshPickingPlugin)
|
||||
.add_plugins(TransformGizmoPlugin)
|
||||
|
||||
@ -4,9 +4,6 @@ use bevy::prelude::*;
|
||||
|
||||
use crate::history::EditorHistory;
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) mod test_harness;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum OperatorPhase {
|
||||
Idle,
|
||||
@ -230,19 +227,12 @@ fn set_operator_status(world: &mut World, status: OperatorStatus) {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::history::EditorHistory;
|
||||
use crate::operators::test_harness::OperatorInvariantHarness;
|
||||
use crate::scene_io::SceneIo;
|
||||
|
||||
#[derive(Resource, Default)]
|
||||
struct Counter(i32);
|
||||
|
||||
#[derive(Component)]
|
||||
struct PreviewHelper;
|
||||
|
||||
struct CounterOperator {
|
||||
before: i32,
|
||||
helper: Option<Entity>,
|
||||
}
|
||||
|
||||
impl EditorOperator for CounterOperator {
|
||||
@ -256,7 +246,6 @@ mod tests {
|
||||
|
||||
fn begin(&mut self, world: &mut World) -> Result<(), String> {
|
||||
self.before = world.resource::<Counter>().0;
|
||||
self.helper = Some(world.spawn(PreviewHelper).id());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@ -265,50 +254,34 @@ mod tests {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn commit(&mut self, world: &mut World) -> Result<(), String> {
|
||||
self.remove_helper(world);
|
||||
fn commit(&mut self, _world: &mut World) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn cancel(&mut self, world: &mut World) {
|
||||
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]
|
||||
fn operator_commit_keeps_previewed_change() {
|
||||
let mut world = test_world();
|
||||
let harness = OperatorInvariantHarness::capture(&mut world);
|
||||
let mut world = World::new();
|
||||
world.init_resource::<ActiveOperator>();
|
||||
world.init_resource::<Counter>();
|
||||
|
||||
let mut operator = CounterOperator {
|
||||
before: 0,
|
||||
helper: None,
|
||||
};
|
||||
let mut operator = CounterOperator { before: 0 };
|
||||
|
||||
assert!(run_operator(&mut world, &mut operator));
|
||||
assert_eq!(world.resource::<Counter>().0, 1);
|
||||
harness.assert_committed(&mut world, 0, 0);
|
||||
harness.assert_no_helpers::<PreviewHelper>(&mut world);
|
||||
assert_eq!(
|
||||
world
|
||||
.resource::<ActiveOperator>()
|
||||
.status
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.phase,
|
||||
OperatorPhase::Committed
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@ -341,38 +314,22 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
let mut world = test_world();
|
||||
let harness = OperatorInvariantHarness::capture(&mut world);
|
||||
let mut world = World::new();
|
||||
world.init_resource::<ActiveOperator>();
|
||||
world.init_resource::<Counter>();
|
||||
|
||||
let mut operator = FailingCounterOperator(CounterOperator {
|
||||
before: 0,
|
||||
helper: None,
|
||||
});
|
||||
let mut operator = FailingCounterOperator(CounterOperator { before: 0 });
|
||||
|
||||
assert!(!run_operator(&mut world, &mut operator));
|
||||
assert_eq!(world.resource::<Counter>().0, 0);
|
||||
harness.assert_canceled(&mut world);
|
||||
harness.assert_no_helpers::<PreviewHelper>(&mut world);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blocked_operator_never_begins_or_mutates_world() {
|
||||
let mut world = test_world();
|
||||
let harness = OperatorInvariantHarness::capture(&mut world);
|
||||
|
||||
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_eq!(
|
||||
world
|
||||
.resource::<ActiveOperator>()
|
||||
.status
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.phase,
|
||||
OperatorPhase::Canceled
|
||||
);
|
||||
|
||||
assert!(!result);
|
||||
assert_eq!(world.resource::<Counter>().0, 0);
|
||||
harness.assert_blocked(&mut world);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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()
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -1,6 +1,5 @@
|
||||
//! Play-in-editor session, editor mode state, and net editor profiles.
|
||||
|
||||
pub mod audio_preview;
|
||||
pub mod net_editor;
|
||||
mod session;
|
||||
pub mod state;
|
||||
|
||||
@ -7,6 +7,7 @@ use protocol::PlayerInputIntent;
|
||||
use sim::{Crouching, Grounded, JumpState, Player, PlayerVelocity};
|
||||
|
||||
use crate::camera::EditorCamera;
|
||||
use crate::scene_io::SceneIo;
|
||||
|
||||
/// Editor/game mode. `Playing` runs the real game sim; use [`PlayPossession`] for camera control.
|
||||
#[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) {
|
||||
// Edit-mode scrubbing must not override authored controller playback in PIE.
|
||||
game::animation::stop_animation_preview(world);
|
||||
let snapshot = snapshot_player_sim(world);
|
||||
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() {
|
||||
restore_player_sim(world, &snapshot);
|
||||
}
|
||||
world.resource_mut::<SimEnabled>().0 = false;
|
||||
game::animation::reset_authored_animation_runtime(world);
|
||||
game::audio::reset_authored_audio_runtime(world);
|
||||
if let Some(mut scene_io) = world.get_resource_mut::<SceneIo>() {
|
||||
scene_io.dirty = true;
|
||||
}
|
||||
}
|
||||
|
||||
fn snapshot_player_sim(world: &mut World) -> PlayerSimSnapshot {
|
||||
|
||||
@ -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'));
|
||||
}
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
@ -1,7 +1,4 @@
|
||||
//! Project workspace I/O, settings UI, and recent-level sync.
|
||||
|
||||
pub mod diagnostics_bundle;
|
||||
pub mod launcher;
|
||||
pub mod project_io;
|
||||
pub mod session;
|
||||
pub mod settings_ui;
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
|
||||
use bevy::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use settings::{ProjectKind, ProjectSettings, ProjectSettingsIo, DEFAULT_PROJECT_PATH};
|
||||
use settings::{ProjectSettings, ProjectSettingsIo, DEFAULT_PROJECT_PATH};
|
||||
|
||||
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";
|
||||
|
||||
/// 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 recent_projects: Vec<String>,
|
||||
pub recent_levels: Vec<String>,
|
||||
@ -31,53 +31,16 @@ pub struct UserPreferences {
|
||||
pub hierarchy_show_runtime: bool,
|
||||
#[serde(default)]
|
||||
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 {
|
||||
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`).
|
||||
#[derive(Resource, Debug, Clone)]
|
||||
pub struct ProjectWorkspace {
|
||||
pub root: String,
|
||||
pub project_id: String,
|
||||
pub project_kind: ProjectKind,
|
||||
pub last_opened_scene: Option<String>,
|
||||
pub settings_path: String,
|
||||
pub settings_dirty: bool,
|
||||
pub layout_dirty: bool,
|
||||
@ -91,9 +54,6 @@ impl Default for ProjectWorkspace {
|
||||
.unwrap_or_else(|| ".".into());
|
||||
Self {
|
||||
root,
|
||||
project_id: String::new(),
|
||||
project_kind: ProjectKind::default(),
|
||||
last_opened_scene: None,
|
||||
settings_path: DEFAULT_PROJECT_PATH.into(),
|
||||
settings_dirty: false,
|
||||
layout_dirty: false,
|
||||
@ -113,13 +73,7 @@ impl Plugin for ProjectIoPlugin {
|
||||
}
|
||||
|
||||
fn load_user_preferences(mut commands: Commands) {
|
||||
let mut 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}");
|
||||
}
|
||||
}
|
||||
let prefs = read_user_preferences();
|
||||
commands.insert_resource(prefs);
|
||||
}
|
||||
|
||||
@ -131,13 +85,12 @@ pub(crate) fn load_user_preferences_on_startup(commands: Commands) {
|
||||
fn sync_workspace_from_settings_system(
|
||||
settings: Res<ProjectSettings>,
|
||||
settings_io: Res<ProjectSettingsIo>,
|
||||
scene_io: Res<SceneIo>,
|
||||
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;
|
||||
}
|
||||
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> {
|
||||
@ -174,26 +127,14 @@ pub fn push_recent(list: &mut Vec<String>, entry: String, max: usize) {
|
||||
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(
|
||||
workspace: &mut ProjectWorkspace,
|
||||
settings: &ProjectSettings,
|
||||
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_dirty = settings_io.dirty;
|
||||
let _ = settings;
|
||||
}
|
||||
|
||||
pub fn window_title(
|
||||
@ -219,21 +160,3 @@ pub fn window_title(
|
||||
}
|
||||
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"]);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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.heading("Physics");
|
||||
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();
|
||||
let settings_valid = draft.audio.validate().is_ok();
|
||||
if ui
|
||||
.add_enabled(settings_valid, egui::Button::new("Apply"))
|
||||
.clicked()
|
||||
{
|
||||
if ui.button("Apply").clicked() {
|
||||
commit_project_settings(world, draft);
|
||||
*io_dirty = true;
|
||||
}
|
||||
if ui
|
||||
.add_enabled(settings_valid, egui::Button::new("Save to disk"))
|
||||
.clicked()
|
||||
{
|
||||
if ui.button("Save to disk").clicked() {
|
||||
commit_project_settings(world, draft);
|
||||
let settings = world.resource::<ProjectSettings>().clone();
|
||||
let mut io = world.resource_mut::<ProjectSettingsIo>();
|
||||
|
||||
@ -1,6 +1,5 @@
|
||||
//! Scene I/O, schema validation, and viewport render-target setup.
|
||||
|
||||
pub(crate) mod recovery;
|
||||
pub mod scene_io;
|
||||
pub mod scene_schema;
|
||||
pub mod scene_view;
|
||||
|
||||
@ -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
@ -5,12 +5,11 @@ mod transform;
|
||||
use bevy::prelude::*;
|
||||
use bevy_egui::egui;
|
||||
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::ui::inspector;
|
||||
use crate::ui::selection_ops::is_linked_read_only;
|
||||
use crate::ui::theme::{panel_heading, BORDER, SELECTION, TEXT, TEXT_DIM, WIDGET_BG};
|
||||
use crate::ui::theme::{panel_heading, BORDER, TEXT_DIM, WIDGET_BG};
|
||||
use crate::ui::widgets::phosphor_icon;
|
||||
|
||||
use transform::transform_inspector_ui;
|
||||
@ -18,8 +17,6 @@ use transform::transform_inspector_ui;
|
||||
const COMPACT_ACTOR_HEADER_WIDTH: f32 = 360.0;
|
||||
|
||||
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 {
|
||||
ui.label("Entity no longer exists.");
|
||||
return;
|
||||
@ -82,28 +79,6 @@ pub fn draw_actor_inspector(world: &mut World, ui: &mut egui::Ui, entity: Entity
|
||||
});
|
||||
ui.add_space(6.0);
|
||||
|
||||
if linked_read_only {
|
||||
ui.small(
|
||||
egui::RichText::new(if prefab_member {
|
||||
"Linked prefab member"
|
||||
} else {
|
||||
"Linked scene member"
|
||||
})
|
||||
.color(TEXT_DIM),
|
||||
);
|
||||
ui.add_space(4.0);
|
||||
}
|
||||
|
||||
if prefab_member {
|
||||
egui::ScrollArea::vertical()
|
||||
.auto_shrink([false, false])
|
||||
.show(ui, |ui| {
|
||||
crate::assets::prefab_overrides::prefab_member_inspector_ui(world, ui, entity);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
ui.add_enabled_ui(!linked_read_only, |ui| {
|
||||
egui::ScrollArea::vertical()
|
||||
.auto_shrink([false, false])
|
||||
.show(ui, |ui| {
|
||||
@ -121,9 +96,7 @@ pub fn draw_actor_inspector(world: &mut World, ui: &mut egui::Ui, entity: Entity
|
||||
| ActorKind::TriggerVolume
|
||||
| ActorKind::PostProcessVolume
|
||||
| ActorKind::TeamSpawn
|
||||
| ActorKind::Objective
|
||||
| ActorKind::AudioSource
|
||||
| ActorKind::AudioListener => {
|
||||
| ActorKind::Objective => {
|
||||
inspector::authoring_inspector_ui(world, ui, entity);
|
||||
}
|
||||
}
|
||||
@ -134,7 +107,6 @@ pub fn draw_actor_inspector(world: &mut World, ui: &mut egui::Ui, entity: Entity
|
||||
|
||||
inspector::add_component_footer(world, ui, entity);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
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::PostProcessVolume => icons::APERTURE,
|
||||
ActorKind::TeamSpawn | ActorKind::Objective => icons::FLAG,
|
||||
ActorKind::AudioSource => icons::SPEAKER_HIGH,
|
||||
ActorKind::AudioListener => icons::HEADPHONES,
|
||||
ActorKind::Empty => icons::CIRCLE,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn draw_multi_actor_inspector(world: &mut World, ui: &mut egui::Ui, entities: &[Entity]) {
|
||||
if entities.is_empty() {
|
||||
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, 24.0).color(TEXT_DIM));
|
||||
ui.label(egui::RichText::new("No selection").color(TEXT_DIM).strong());
|
||||
});
|
||||
});
|
||||
return;
|
||||
}
|
||||
ui.heading(format!("{} objects selected", entities.len()));
|
||||
ui.label("Multi-edit: use the viewport gizmo for shared transform.");
|
||||
if entities.len() == 1 {
|
||||
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
@ -29,18 +29,16 @@ use crate::asset_db::{
|
||||
find_asset_by_path, find_asset_mut_by_path, save_registry, update_import_settings,
|
||||
ImportSettings, MaterialImportPolicy, ModelHierarchyMode, ModelPlacementMode,
|
||||
};
|
||||
use crate::assets::animation::load_animation_manifest;
|
||||
use crate::assets::operators::{
|
||||
apply_material_operator, apply_texture_operator, assign_animation_clip_operator,
|
||||
place_asset_operator, place_subasset_operator,
|
||||
apply_material_operator, apply_texture_operator, place_asset_operator, place_subasset_operator,
|
||||
};
|
||||
use crate::assets::static_mesh::{
|
||||
load_static_mesh_manifest, material_id_from_label, part_id_from_label,
|
||||
refresh_static_mesh_artifact,
|
||||
};
|
||||
use crate::assets::{
|
||||
asset_cache_key, draw_asset_cell_with, invalidate_on_catalog_refresh, kind_icon,
|
||||
prefetch_folder_thumbnails, refresh_model_artifacts, AssetThumbnailCache,
|
||||
ThumbnailCacheSnapshot, ThumbnailStudio,
|
||||
prefetch_folder_thumbnails, AssetThumbnailCache, ThumbnailCacheSnapshot, ThumbnailStudio,
|
||||
};
|
||||
use crate::ui::helpers::asset_label;
|
||||
use crate::ui::theme::{panel_heading, ACCENT, BORDER, ELEVATED_BG, TEXT, TEXT_DIM, WIDGET_BG};
|
||||
@ -83,7 +81,6 @@ struct EmbeddedAsset {
|
||||
thumbnail_key: String,
|
||||
mesh_label: Option<String>,
|
||||
material_label: Option<String>,
|
||||
requires_scene_instance: bool,
|
||||
}
|
||||
|
||||
fn fit_width(ui: &egui::Ui, min: f32, max: f32) -> f32 {
|
||||
@ -346,18 +343,17 @@ fn embedded_assets_for_asset(world: &World, asset: &EditorAsset) -> Vec<Embedded
|
||||
else {
|
||||
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 static_manifest = record
|
||||
.import_settings
|
||||
.static_mesh_manifest_path
|
||||
.as_deref()
|
||||
.and_then(|path| load_static_mesh_manifest(path).ok());
|
||||
let skinned_primitives = crate::assets::gltf_skinned_primitive_labels(parent_path.as_str());
|
||||
if let Some(manifest) = static_manifest.as_ref() {
|
||||
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(),
|
||||
@ -368,13 +364,10 @@ fn embedded_assets_for_asset(world: &World, asset: &EditorAsset) -> Vec<Embedded
|
||||
},
|
||||
label: part.name.clone(),
|
||||
kind: AssetSubAssetKind::Mesh,
|
||||
detail: if requires_scene_instance {
|
||||
"Skinned | place model or animation clip".to_string()
|
||||
} else {
|
||||
part.source_mesh
|
||||
detail: part
|
||||
.source_mesh
|
||||
.clone()
|
||||
.unwrap_or_else(|| part.mesh_label.clone())
|
||||
},
|
||||
.unwrap_or_else(|| part.mesh_label.clone()),
|
||||
texture_path: None,
|
||||
thumbnail_key: subasset_thumbnail_key(
|
||||
parent_path,
|
||||
@ -383,7 +376,6 @@ fn embedded_assets_for_asset(world: &World, asset: &EditorAsset) -> Vec<Embedded
|
||||
),
|
||||
mesh_label: Some(part.mesh_label.clone()),
|
||||
material_label,
|
||||
requires_scene_instance,
|
||||
});
|
||||
}
|
||||
|
||||
@ -427,7 +419,6 @@ fn embedded_assets_for_asset(world: &World, asset: &EditorAsset) -> Vec<Embedded
|
||||
),
|
||||
mesh_label: None,
|
||||
material_label: part.material_label.clone(),
|
||||
requires_scene_instance: false,
|
||||
});
|
||||
}
|
||||
|
||||
@ -460,75 +451,8 @@ fn embedded_assets_for_asset(world: &World, asset: &EditorAsset) -> Vec<Embedded
|
||||
),
|
||||
mesh_label: None,
|
||||
material_label: None,
|
||||
requires_scene_instance: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let animation_manifest = record
|
||||
.import_settings
|
||||
.animation_manifest_path
|
||||
.as_deref()
|
||||
.and_then(|path| load_animation_manifest(path).ok());
|
||||
if let Some(manifest) = animation_manifest {
|
||||
for skeleton in manifest.skeletons {
|
||||
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 event_suffix = if clip.events.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!(" | {} events", clip.events.len())
|
||||
};
|
||||
embedded.push(EmbeddedAsset {
|
||||
selection: AssetSelection::SubAsset {
|
||||
parent_path: parent_path.clone(),
|
||||
sub_asset_id: clip.id.clone(),
|
||||
label: clip.label.clone(),
|
||||
kind: AssetSubAssetKind::AnimationClip,
|
||||
source_path: Some(parent_path.clone()),
|
||||
},
|
||||
label: clip.label,
|
||||
kind: AssetSubAssetKind::AnimationClip,
|
||||
detail: format!("{:.2}s{event_suffix}", clip.duration_seconds),
|
||||
texture_path: None,
|
||||
thumbnail_key: subasset_thumbnail_key(
|
||||
parent_path,
|
||||
AssetSubAssetKind::AnimationClip,
|
||||
&clip.id,
|
||||
),
|
||||
mesh_label: None,
|
||||
material_label: None,
|
||||
requires_scene_instance: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
embedded
|
||||
}
|
||||
@ -642,7 +566,6 @@ fn asset_toolbar(world: &mut World, ui: &mut egui::Ui) {
|
||||
AssetKindFilter::Material,
|
||||
"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::Prefab, "Prefabs");
|
||||
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) {
|
||||
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() {
|
||||
ui.add(
|
||||
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 {
|
||||
AssetSubAssetKind::Mesh => {
|
||||
if selected_embedded
|
||||
.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 ui.button("Place At Origin").clicked() {
|
||||
if let Some(selection) = selection.as_ref() {
|
||||
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 => {
|
||||
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() {
|
||||
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::Light(_)
|
||||
| EditorAssetKind::Model
|
||||
| EditorAssetKind::AudioClip
|
||||
| EditorAssetKind::Prefab
|
||||
| EditorAssetKind::Level
|
||||
) && ui.button("Place At Origin").clicked()
|
||||
{
|
||||
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 {
|
||||
ui.label(egui::RichText::new("No asset selected").color(TEXT_DIM));
|
||||
@ -1257,7 +1143,6 @@ fn prefetch_embedded_thumbnails(
|
||||
parent_path.clone(),
|
||||
mesh_label,
|
||||
embedded_asset.material_label.clone(),
|
||||
embedded_asset.requires_scene_instance,
|
||||
&mut studio,
|
||||
);
|
||||
}
|
||||
@ -1282,7 +1167,6 @@ fn prefetch_embedded_thumbnails(
|
||||
&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 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 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 {
|
||||
crate::ui::theme::SELECTION_BG_MUTED
|
||||
} else if response.hovered() {
|
||||
@ -1400,10 +1277,7 @@ fn draw_embedded_asset_cell(
|
||||
.resource_mut::<EditorAssets>()
|
||||
.select(embedded.selection.clone());
|
||||
}
|
||||
if response.drag_started()
|
||||
&& embedded.kind != AssetSubAssetKind::Skeleton
|
||||
&& !embedded.requires_scene_instance
|
||||
{
|
||||
if response.drag_started() {
|
||||
world
|
||||
.resource_mut::<EditorAssets>()
|
||||
.start_drag(embedded.selection.clone());
|
||||
@ -1420,10 +1294,7 @@ fn compact_subasset_label(embedded: &EmbeddedAsset) -> String {
|
||||
.split_once(" / ")
|
||||
.map(|(head, _)| head)
|
||||
.unwrap_or(&embedded.label),
|
||||
AssetSubAssetKind::Material
|
||||
| AssetSubAssetKind::Texture
|
||||
| AssetSubAssetKind::Skeleton
|
||||
| AssetSubAssetKind::AnimationClip => embedded.label.as_str(),
|
||||
AssetSubAssetKind::Material | AssetSubAssetKind::Texture => embedded.label.as_str(),
|
||||
};
|
||||
compact_text(base, 16)
|
||||
}
|
||||
@ -1468,8 +1339,6 @@ fn subasset_icon(kind: AssetSubAssetKind) -> Icon {
|
||||
AssetSubAssetKind::Mesh => icons::CUBE,
|
||||
AssetSubAssetKind::Material => icons::PALETTE,
|
||||
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::Material => "Material",
|
||||
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"));
|
||||
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();
|
||||
@ -1970,14 +1804,7 @@ fn subasset_details_panel(
|
||||
ui.separator();
|
||||
match embedded.kind {
|
||||
AssetSubAssetKind::Mesh => {
|
||||
if embedded.requires_scene_instance {
|
||||
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() {
|
||||
if ui.button("Place At Origin").clicked() {
|
||||
place_subasset_operator(world, selection.clone(), Vec3::ZERO);
|
||||
}
|
||||
}
|
||||
@ -1998,31 +1825,6 @@ fn subasset_details_panel(
|
||||
.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() {
|
||||
world
|
||||
@ -2050,15 +1852,12 @@ fn asset_action_buttons(
|
||||
EditorAssetKind::Primitive(_)
|
||||
| EditorAssetKind::Light(_)
|
||||
| EditorAssetKind::Model
|
||||
| EditorAssetKind::AudioClip
|
||||
| EditorAssetKind::Prefab
|
||||
| EditorAssetKind::Level
|
||||
) && ui.button("Place At Origin").clicked()
|
||||
{
|
||||
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)
|
||||
&& has_embedded_assets(world, asset)
|
||||
&& 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(
|
||||
world: &mut World,
|
||||
ui: &mut egui::Ui,
|
||||
@ -2197,8 +1989,8 @@ fn apply_import_settings(
|
||||
update_import_settings(&mut registry, path, settings);
|
||||
if matches!(asset.kind, EditorAssetKind::Model) {
|
||||
if let Some(record) = find_asset_mut_by_path(&mut registry, path) {
|
||||
if let Err(error) = refresh_model_artifacts(record) {
|
||||
status = format!("Model artifact refresh failed for {}: {error}", asset.label);
|
||||
if let Err(error) = refresh_static_mesh_artifact(record) {
|
||||
status = format!("Static mesh refresh failed for {}: {error}", asset.label);
|
||||
warn!("{status}");
|
||||
}
|
||||
}
|
||||
@ -2913,7 +2705,6 @@ fn asset_matches_kind_filter(asset: &EditorAsset, filter: AssetKindFilter) -> bo
|
||||
AssetKindFilter::Model => matches!(asset.kind, EditorAssetKind::Model),
|
||||
AssetKindFilter::Texture => matches!(asset.kind, EditorAssetKind::Texture),
|
||||
AssetKindFilter::Material => matches!(asset.kind, EditorAssetKind::Material),
|
||||
AssetKindFilter::Audio => matches!(asset.kind, EditorAssetKind::AudioClip),
|
||||
AssetKindFilter::Level => matches!(asset.kind, EditorAssetKind::Level),
|
||||
AssetKindFilter::Prefab => matches!(asset.kind, EditorAssetKind::Prefab),
|
||||
AssetKindFilter::Builtin => asset.path.is_none(),
|
||||
@ -2926,7 +2717,6 @@ fn kind_filter_label(filter: AssetKindFilter) -> &'static str {
|
||||
AssetKindFilter::Model => "Models",
|
||||
AssetKindFilter::Texture => "Textures",
|
||||
AssetKindFilter::Material => "Materials",
|
||||
AssetKindFilter::Audio => "Audio",
|
||||
AssetKindFilter::Level => "Levels",
|
||||
AssetKindFilter::Prefab => "Prefabs",
|
||||
AssetKindFilter::Builtin => "Built-ins",
|
||||
@ -2949,7 +2739,6 @@ fn kind_label(kind: &EditorAssetKind) -> &'static str {
|
||||
EditorAssetKind::Model => "Model",
|
||||
EditorAssetKind::Texture => "Texture",
|
||||
EditorAssetKind::Material => "Material",
|
||||
EditorAssetKind::AudioClip => "Audio Clip",
|
||||
EditorAssetKind::Level => "Level",
|
||||
EditorAssetKind::Prefab => "Prefab",
|
||||
EditorAssetKind::PostProcessVolume => "Post Process Volume",
|
||||
@ -2963,22 +2752,6 @@ fn file_size(path: &str) -> Option<u64> {
|
||||
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> {
|
||||
fs::metadata(path)
|
||||
.ok()
|
||||
@ -3040,17 +2813,13 @@ fn asset_context_menu(
|
||||
EditorAssetKind::Primitive(_)
|
||||
| EditorAssetKind::Light(_)
|
||||
| EditorAssetKind::Model
|
||||
| EditorAssetKind::AudioClip
|
||||
| EditorAssetKind::Prefab
|
||||
| EditorAssetKind::Level
|
||||
) && ui.button("Place at Origin").clicked()
|
||||
{
|
||||
place_asset_operator(world, asset.clone(), Vec3::ZERO);
|
||||
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) {
|
||||
let expanded = asset.path.as_deref().is_some_and(|path| {
|
||||
world
|
||||
@ -3119,12 +2888,7 @@ fn subasset_context_menu(
|
||||
}
|
||||
match embedded.kind {
|
||||
AssetSubAssetKind::Mesh => {
|
||||
if embedded.requires_scene_instance {
|
||||
ui.label(
|
||||
egui::RichText::new("Skinned mesh: place parent model or animation clip")
|
||||
.color(TEXT_DIM),
|
||||
);
|
||||
} else if ui.button("Place at Origin").clicked() {
|
||||
if ui.button("Place at Origin").clicked() {
|
||||
place_subasset_operator(world, embedded.selection.clone(), Vec3::ZERO);
|
||||
ui.close();
|
||||
}
|
||||
@ -3142,35 +2906,8 @@ fn subasset_context_menu(
|
||||
AssetSubAssetKind::Material => {
|
||||
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!(
|
||||
embedded.kind,
|
||||
AssetSubAssetKind::Mesh | AssetSubAssetKind::Material | AssetSubAssetKind::Texture
|
||||
) && ui.button("Regenerate Thumbnail").clicked()
|
||||
{
|
||||
if ui.button("Regenerate Thumbnail").clicked() {
|
||||
regenerate_subasset_thumbnail(world, embedded);
|
||||
ui.close();
|
||||
}
|
||||
@ -3232,7 +2969,6 @@ fn regenerate_subasset_thumbnail(world: &mut World, embedded: &EmbeddedAsset) {
|
||||
parent_path.clone(),
|
||||
mesh_label,
|
||||
embedded.material_label.clone(),
|
||||
embedded.requires_scene_instance,
|
||||
&mut studio,
|
||||
);
|
||||
});
|
||||
@ -3255,7 +2991,6 @@ fn regenerate_subasset_thumbnail(world: &mut World, embedded: &EmbeddedAsset) {
|
||||
cache.request_texture(key.clone(), texture_path, &asset_server);
|
||||
}
|
||||
}
|
||||
AssetSubAssetKind::Skeleton | AssetSubAssetKind::AnimationClip => {}
|
||||
}
|
||||
});
|
||||
world.resource_mut::<SceneIo>().status =
|
||||
@ -3269,7 +3004,7 @@ fn reimport_asset(world: &mut World, asset: &EditorAsset) {
|
||||
let mut status = format!("Reimported {}", asset.label);
|
||||
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 Err(error) = refresh_model_artifacts(record) {
|
||||
if let Err(error) = refresh_static_mesh_artifact(record) {
|
||||
status = format!("Reimport failed for {}: {error}", asset.label);
|
||||
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 {
|
||||
files.push(manifest);
|
||||
}
|
||||
if let Some(manifest) = record.import_settings.animation_manifest_path {
|
||||
files.push(manifest);
|
||||
}
|
||||
if !record.dependencies.is_empty() {
|
||||
warnings.push(format!(
|
||||
"{} 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();
|
||||
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");
|
||||
}
|
||||
}
|
||||
|
||||
@ -28,7 +28,6 @@ pub(crate) enum AssetKindFilter {
|
||||
Model,
|
||||
Texture,
|
||||
Material,
|
||||
Audio,
|
||||
Level,
|
||||
Prefab,
|
||||
Builtin,
|
||||
|
||||
@ -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);
|
||||
}
|
||||
@ -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")
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -7,8 +7,6 @@ use egui_phosphor_icons::icons;
|
||||
pub enum EditorComponentCategory {
|
||||
Authoring,
|
||||
Rendering,
|
||||
Animation,
|
||||
Audio,
|
||||
Physics,
|
||||
Gameplay,
|
||||
Volumes,
|
||||
@ -138,54 +136,6 @@ impl Default for EditorComponentRegistry {
|
||||
conflicts_with: &[],
|
||||
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 {
|
||||
type_name: "shared::components::Primitive",
|
||||
display_name: "Primitive",
|
||||
|
||||
@ -8,19 +8,16 @@ use shared::{
|
||||
};
|
||||
|
||||
use crate::assets::EditorAssets;
|
||||
use crate::diagnostics_bundle::export_diagnostic_bundle;
|
||||
use crate::history::set_brush_with_history;
|
||||
use crate::history::EditorHistory;
|
||||
use crate::project_io::ProjectWorkspace;
|
||||
use crate::scene_io::{SceneIo, SceneIoEventSeverity};
|
||||
use crate::scene_io::SceneIo;
|
||||
use crate::selection::SelectedEntity;
|
||||
use crate::ui::UiState;
|
||||
|
||||
#[derive(Resource, Default)]
|
||||
pub struct DiagnosticsPanel {
|
||||
pub open: bool,
|
||||
pub bundle_status: Option<String>,
|
||||
pub project_validation: Option<scene::ProjectValidationReport>,
|
||||
}
|
||||
|
||||
#[derive(Resource, Default)]
|
||||
@ -29,18 +26,10 @@ pub struct BrushDiagnosticsPanel {
|
||||
}
|
||||
|
||||
pub fn diagnostics_ui(world: &mut World, ui: &mut egui::Ui) {
|
||||
let (scene_label, dirty, status, scene_events) = {
|
||||
let scene_io = world.resource::<SceneIo>();
|
||||
(
|
||||
scene_io.active_path_label(),
|
||||
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}"));
|
||||
let dirty = if scene_io.dirty { " (dirty)" } else { "" };
|
||||
ui.label(format!("Scene: {}{dirty}", scene_io.active_path_label()));
|
||||
ui.label(format!("Status: {}", scene_io.status));
|
||||
ui.label(format!(
|
||||
"History: {}",
|
||||
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();
|
||||
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.small("Native Bevy scenes are saved to assets/levels/*.scn.ron");
|
||||
ui.small("BRP is enabled on the editor for external tooling.");
|
||||
|
||||
@ -36,7 +36,7 @@ impl PanelNodes {
|
||||
EditorTab::Viewport | EditorTab::GameView => self.main_view(),
|
||||
EditorTab::Hierarchy => self.hierarchy(),
|
||||
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::Hierarchy => nodes.hierarchy = path.node.0,
|
||||
EditorTab::Inspector => nodes.inspector = path.node.0,
|
||||
EditorTab::AssetBrowser | EditorTab::Build | EditorTab::Toolbar => {
|
||||
nodes.bottom = path.node.0
|
||||
}
|
||||
EditorTab::AssetBrowser | EditorTab::Toolbar => nodes.bottom = path.node.0,
|
||||
}
|
||||
}
|
||||
nodes
|
||||
}
|
||||
}
|
||||
|
||||
pub const PANEL_TABS: [EditorTab; 5] = [
|
||||
pub const PANEL_TABS: [EditorTab; 4] = [
|
||||
EditorTab::Viewport,
|
||||
EditorTab::Hierarchy,
|
||||
EditorTab::Inspector,
|
||||
EditorTab::AssetBrowser,
|
||||
EditorTab::Build,
|
||||
];
|
||||
|
||||
pub fn tab_label(tab: &EditorTab) -> &'static str {
|
||||
@ -74,7 +71,6 @@ pub fn tab_label(tab: &EditorTab) -> &'static str {
|
||||
EditorTab::Hierarchy => "Hierarchy",
|
||||
EditorTab::Inspector => "Inspector",
|
||||
EditorTab::AssetBrowser => "Asset Browser",
|
||||
EditorTab::Build => "Build",
|
||||
EditorTab::Toolbar => "Toolbar (legacy)",
|
||||
}
|
||||
}
|
||||
|
||||
@ -11,7 +11,7 @@ use sim::Player;
|
||||
use crate::assets::{snapshot_for_asset, EditorAsset, EditorAssetKind};
|
||||
use crate::camera::EditorCamera;
|
||||
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::scene_io::SceneIo;
|
||||
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,
|
||||
physics: None,
|
||||
light: Some(LightDesc::default()),
|
||||
animation_controller: None,
|
||||
audio_source: None,
|
||||
audio_listener: None,
|
||||
player_spawn: false,
|
||||
model: None,
|
||||
prefab: None,
|
||||
@ -148,9 +145,6 @@ pub fn ensure_player_spawn_for_edit(world: &mut World) -> Entity {
|
||||
collider: None,
|
||||
physics: None,
|
||||
light: None,
|
||||
animation_controller: None,
|
||||
audio_source: None,
|
||||
audio_listener: None,
|
||||
player_spawn: true,
|
||||
model: None,
|
||||
prefab: None,
|
||||
@ -239,9 +233,6 @@ pub fn create_scene_sun_override_from_project_settings(world: &mut World) -> Ent
|
||||
collider: None,
|
||||
physics: None,
|
||||
light: Some(light),
|
||||
animation_controller: None,
|
||||
audio_source: None,
|
||||
audio_listener: None,
|
||||
player_spawn: false,
|
||||
model: None,
|
||||
prefab: None,
|
||||
@ -265,7 +256,6 @@ pub fn asset_label(asset: &EditorAsset) -> String {
|
||||
EditorAssetKind::Model => "Model",
|
||||
EditorAssetKind::Texture => "Texture",
|
||||
EditorAssetKind::Material => "Material",
|
||||
EditorAssetKind::AudioClip => "Audio Clip",
|
||||
EditorAssetKind::Level => "Level",
|
||||
EditorAssetKind::Prefab => "Prefab",
|
||||
EditorAssetKind::PostProcessVolume => "Post Process Volume",
|
||||
@ -280,32 +270,26 @@ pub fn apply_texture_to_selection(
|
||||
world: &mut World,
|
||||
asset: &EditorAsset,
|
||||
selected: &SelectedEntities,
|
||||
) -> Result<(), String> {
|
||||
let path = asset
|
||||
.path
|
||||
.clone()
|
||||
.ok_or_else(|| format!("Texture has no source path: {}", asset.label))?;
|
||||
let changes = selected_level_entities(world, selected)
|
||||
.into_iter()
|
||||
.map(|entity| {
|
||||
) {
|
||||
let Some(path) = asset.path.clone() else {
|
||||
return;
|
||||
};
|
||||
for entity in selected_level_entities(world, selected) {
|
||||
let mut material = world
|
||||
.get::<MaterialDesc>(entity)
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
material.base_color_texture = Some(path.clone());
|
||||
(entity, material)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
set_material_group_with_history(world, changes);
|
||||
Ok(())
|
||||
set_material_with_history(world, entity, material);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn apply_material_asset_to_selection(
|
||||
world: &mut World,
|
||||
asset: &EditorAsset,
|
||||
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)]
|
||||
|
||||
@ -5,7 +5,7 @@ use bevy_egui::egui;
|
||||
use bevy_inspector_egui::bevy_inspector::hierarchy::SelectedEntities;
|
||||
use egui::containers::scroll_area::ScrollSource;
|
||||
use egui_phosphor_icons::icons;
|
||||
use shared::{EditorVisibility, HydratedPrefabMember};
|
||||
use shared::EditorVisibility;
|
||||
|
||||
use crate::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::{
|
||||
authored_children, editor_visibility, entity_hierarchy_path, entity_matches_filter,
|
||||
hierarchy_drop_violation, hierarchy_label_for_entity, is_ancestor, is_entity_locked,
|
||||
level_object_roots, mutable_authored_children, sort_siblings, HierarchyDropViolation,
|
||||
HierarchyLabel,
|
||||
hierarchy_label_for_entity, is_entity_locked, level_object_roots, sort_siblings,
|
||||
would_create_cycle, HierarchyLabel,
|
||||
};
|
||||
use crate::ui::hierarchy_state::{HierarchyPanelState, HierarchySort};
|
||||
use crate::ui::selection_ops::is_linked_read_only;
|
||||
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;
|
||||
use crate::ui::theme::{panel_heading, TEXT, TEXT_DIM, TEXT_SELECTED};
|
||||
|
||||
#[derive(Clone)]
|
||||
struct HierarchyDragPayload {
|
||||
@ -43,12 +37,6 @@ enum DropTarget {
|
||||
Before(Entity),
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum DropBlock {
|
||||
Hierarchy(HierarchyDropViolation),
|
||||
LockedTarget(Entity),
|
||||
}
|
||||
|
||||
pub fn hierarchy_panel_ui(
|
||||
world: &mut World,
|
||||
ui: &mut egui::Ui,
|
||||
@ -133,7 +121,7 @@ pub fn hierarchy_panel_ui(
|
||||
|
||||
if ui.input(|input| input.key_pressed(egui::Key::F2)) {
|
||||
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);
|
||||
*rename_buffer = entity_name(world, entity);
|
||||
}
|
||||
@ -142,10 +130,12 @@ pub fn hierarchy_panel_ui(
|
||||
|
||||
let filter_lower = filter.trim().to_lowercase();
|
||||
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 = active_drag.is_some();
|
||||
let dragging = world
|
||||
.resource::<HierarchyPanelState>()
|
||||
.active_drag
|
||||
.is_some();
|
||||
|
||||
egui::ScrollArea::vertical()
|
||||
.id_salt("hierarchy_tree")
|
||||
@ -158,7 +148,7 @@ pub fn hierarchy_panel_ui(
|
||||
ui.spacing_mut().item_spacing.y = 2.0;
|
||||
|
||||
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);
|
||||
@ -181,12 +171,12 @@ pub fn hierarchy_panel_ui(
|
||||
&filter_lower,
|
||||
sort_mode,
|
||||
show_generated,
|
||||
active_drag.as_deref(),
|
||||
dragging,
|
||||
&expanded,
|
||||
&locked,
|
||||
&prefs_paths,
|
||||
&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 let Some(target) = drop_target {
|
||||
let drag_entities = world
|
||||
@ -212,7 +200,7 @@ pub fn hierarchy_panel_ui(
|
||||
.take();
|
||||
if let Some(entities) = drag_entities {
|
||||
let payload = HierarchyDragPayload { entities };
|
||||
apply_drop(world, &payload, target, drop_block);
|
||||
apply_drop(world, &payload, target, invalid_drop);
|
||||
}
|
||||
} else {
|
||||
world.resource_mut::<HierarchyPanelState>().active_drag = None;
|
||||
@ -232,45 +220,14 @@ fn sort_label(sort: HierarchySort) -> &'static str {
|
||||
}
|
||||
}
|
||||
|
||||
fn draw_root_drop_zone(
|
||||
ui: &mut egui::Ui,
|
||||
drag_entities: Option<&[Entity]>,
|
||||
drop_target: &mut Option<DropTarget>,
|
||||
) {
|
||||
fn draw_root_drop_zone(ui: &mut egui::Ui, drop_target: &mut Option<DropTarget>) {
|
||||
let (rect, response) =
|
||||
ui.allocate_exact_size(egui::vec2(ui.available_width(), 27.0), egui::Sense::hover());
|
||||
let hovered = drag_entities.is_some()
|
||||
&& (response.contains_pointer()
|
||||
|| response
|
||||
.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 {
|
||||
ui.allocate_exact_size(egui::vec2(ui.available_width(), 2.0), egui::Sense::hover());
|
||||
if let Some(hover) = response.dnd_hover_payload::<HierarchyDragPayload>() {
|
||||
ui.painter()
|
||||
.rect_filled(rect, 1.0, egui::Color32::from_rgb(80, 120, 200));
|
||||
*drop_target = Some(DropTarget::Root);
|
||||
let _ = hover;
|
||||
}
|
||||
}
|
||||
|
||||
@ -289,12 +246,12 @@ fn draw_authored_node(
|
||||
filter_lower: &str,
|
||||
sort_mode: HierarchySort,
|
||||
show_generated: bool,
|
||||
drag_entities: Option<&[Entity]>,
|
||||
drag_active: bool,
|
||||
expanded: &std::collections::HashSet<Entity>,
|
||||
locked: &std::collections::HashSet<Entity>,
|
||||
prefs_paths: &[String],
|
||||
drop_target: &mut Option<DropTarget>,
|
||||
drop_block: &mut Option<DropBlock>,
|
||||
invalid_drop: &mut bool,
|
||||
) {
|
||||
let path = entity_hierarchy_path(world, entity);
|
||||
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))
|
||||
}));
|
||||
|
||||
if sort_mode == HierarchySort::Manual {
|
||||
draw_drop_before(
|
||||
world,
|
||||
ui,
|
||||
entity,
|
||||
depth,
|
||||
drag_entities,
|
||||
locked,
|
||||
drag_active,
|
||||
drop_target,
|
||||
drop_block,
|
||||
invalid_drop,
|
||||
);
|
||||
}
|
||||
|
||||
ui.horizontal(|ui| {
|
||||
ui.add_space(depth as f32 * 14.0);
|
||||
@ -364,37 +318,21 @@ fn draw_authored_node(
|
||||
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 eye_icon = if visible {
|
||||
icons::EYE.regular()
|
||||
} else {
|
||||
icons::EYE_SLASH.regular()
|
||||
};
|
||||
let prefab_member = world.get::<HydratedPrefabMember>(entity).is_some();
|
||||
if ui
|
||||
.add_enabled(
|
||||
!is_locked && (!linked_read_only || prefab_member),
|
||||
egui::Button::new(eye_icon),
|
||||
)
|
||||
.clicked()
|
||||
{
|
||||
if prefab_member {
|
||||
crate::assets::prefab_overrides::set_prefab_component_override(
|
||||
world,
|
||||
entity,
|
||||
EditorVisibility { visible: !visible },
|
||||
);
|
||||
} else {
|
||||
if ui.small_button(eye_icon).clicked() {
|
||||
set_editor_visibility_with_history(
|
||||
world,
|
||||
entity,
|
||||
EditorVisibility { visible: !visible },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let is_locked = is_entity_locked(locked, entity);
|
||||
let lock_icon = if is_locked {
|
||||
icons::LOCK.regular()
|
||||
} else {
|
||||
@ -413,33 +351,15 @@ fn draw_authored_node(
|
||||
let response = ui.text_edit_singleline(rename_buffer);
|
||||
let commit =
|
||||
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());
|
||||
*renaming_entity = None;
|
||||
}
|
||||
} else {
|
||||
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 color = if selected { TEXT_SELECTED } else { TEXT };
|
||||
let response = ui.add_sized(
|
||||
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
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
let response = ui.selectable_label(selected, hierarchy_label_text(label, color));
|
||||
if response.clicked() && !is_locked {
|
||||
let additive = ui.input(|input| {
|
||||
input.modifiers.shift || input.modifiers.command || input.modifiers.ctrl
|
||||
@ -451,37 +371,38 @@ fn draw_authored_node(
|
||||
}
|
||||
world.resource_mut::<SelectedEntity>().0 = Some(entity);
|
||||
}
|
||||
if response.double_clicked()
|
||||
&& is_level_object(world, entity)
|
||||
&& !is_locked
|
||||
&& !linked_read_only
|
||||
{
|
||||
if response.double_clicked() && is_level_object(world, entity) {
|
||||
*renaming_entity = Some(entity);
|
||||
*rename_buffer = entity_name(world, entity);
|
||||
}
|
||||
|
||||
if response.drag_started() && !is_locked && (!linked_read_only || prefab_member) {
|
||||
let candidates: Vec<Entity> = if selected_entities.contains(entity) {
|
||||
selected_entities.iter().collect()
|
||||
if response.drag_started() {
|
||||
let drag_entities: Vec<Entity> = if selected_entities.contains(entity) {
|
||||
selected_entities
|
||||
.iter()
|
||||
.filter(|e| is_level_object(world, *e))
|
||||
.collect()
|
||||
} else {
|
||||
vec![entity]
|
||||
};
|
||||
let drag_entities = draggable_selection(world, &candidates, locked);
|
||||
response.dnd_set_drag_payload(HierarchyDragPayload {
|
||||
entities: drag_entities.clone(),
|
||||
});
|
||||
world.resource_mut::<HierarchyPanelState>().active_drag = Some(drag_entities);
|
||||
}
|
||||
|
||||
let hover_payload = response.dnd_hover_payload::<HierarchyDragPayload>();
|
||||
let hovered_entities = hover_payload
|
||||
.as_deref()
|
||||
.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);
|
||||
if let Some(hover) = response.dnd_hover_payload::<HierarchyDragPayload>() {
|
||||
let invalid = would_create_cycle(world, &hover.entities, Some(entity));
|
||||
*invalid_drop = invalid;
|
||||
*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| {
|
||||
@ -492,7 +413,6 @@ fn draw_authored_node(
|
||||
selected_entities,
|
||||
renaming_entity,
|
||||
rename_buffer,
|
||||
locked,
|
||||
)
|
||||
});
|
||||
}
|
||||
@ -517,12 +437,12 @@ fn draw_authored_node(
|
||||
filter_lower,
|
||||
sort_mode,
|
||||
show_generated,
|
||||
drag_entities,
|
||||
drag_active,
|
||||
expanded,
|
||||
locked,
|
||||
prefs_paths,
|
||||
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(
|
||||
world: &World,
|
||||
ui: &mut egui::Ui,
|
||||
entity: Entity,
|
||||
depth: usize,
|
||||
drag_entities: Option<&[Entity]>,
|
||||
locked: &std::collections::HashSet<Entity>,
|
||||
drag_active: bool,
|
||||
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;
|
||||
};
|
||||
}
|
||||
|
||||
ui.horizontal(|ui| {
|
||||
ui.add_space(depth as f32 * 14.0 + 18.0);
|
||||
let (rect, response) =
|
||||
ui.allocate_exact_size(egui::vec2(ui.available_width(), 7.0), egui::Sense::hover());
|
||||
let hovered = response.contains_pointer()
|
||||
|| response
|
||||
.dnd_hover_payload::<HierarchyDragPayload>()
|
||||
.is_some();
|
||||
if hovered {
|
||||
let parent = world
|
||||
ui.allocate_exact_size(egui::vec2(ui.available_width(), 2.0), egui::Sense::hover());
|
||||
if let Some(hover) = response.dnd_hover_payload::<HierarchyDragPayload>() {
|
||||
*invalid_drop = would_create_cycle(
|
||||
world,
|
||||
&hover.entities,
|
||||
world
|
||||
.get::<ChildOf>(entity)
|
||||
.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),
|
||||
.map(|child_of| child_of.parent()),
|
||||
);
|
||||
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(
|
||||
world: &World,
|
||||
ui: &mut egui::Ui,
|
||||
@ -689,22 +558,8 @@ fn draw_runtime_node(
|
||||
ui.add_space(18.0);
|
||||
let label = hierarchy_label_for_entity(world, entity, HierarchyNodeKind::Runtime);
|
||||
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 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() {
|
||||
selected_entities.select_replace(entity);
|
||||
}
|
||||
@ -754,62 +609,23 @@ fn authored_context_menu(
|
||||
selected_entities: &mut SelectedEntities,
|
||||
renaming_entity: &mut Option<Entity>,
|
||||
rename_buffer: &mut String,
|
||||
locked: &std::collections::HashSet<Entity>,
|
||||
) {
|
||||
let entity_locked = is_entity_locked(locked, entity);
|
||||
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()
|
||||
{
|
||||
if ui.button("Rename").clicked() {
|
||||
*renaming_entity = Some(entity);
|
||||
*rename_buffer = entity_name(world, entity);
|
||||
ui.close();
|
||||
}
|
||||
if ui
|
||||
.add_enabled(entity_mutable, egui::Button::new("Duplicate"))
|
||||
.clicked()
|
||||
{
|
||||
if ui.button("Duplicate").clicked() {
|
||||
duplicate_entities_with_history(world, &[entity]);
|
||||
ui.close();
|
||||
}
|
||||
if ui
|
||||
.add_enabled(entity_mutable, egui::Button::new("Delete"))
|
||||
.clicked()
|
||||
{
|
||||
if ui.button("Delete").clicked() {
|
||||
delete_entities_with_history(world, &[entity]);
|
||||
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
|
||||
&& selected_entities.contains(entity)
|
||||
&& ui
|
||||
.add_enabled(selection_mutable, egui::Button::new("Group"))
|
||||
.clicked()
|
||||
&& ui.button("Group").clicked()
|
||||
{
|
||||
let entities: Vec<Entity> = selected_entities
|
||||
.iter()
|
||||
@ -818,17 +634,11 @@ fn authored_context_menu(
|
||||
group_selection_with_history(world, &entities);
|
||||
ui.close();
|
||||
}
|
||||
if ui
|
||||
.add_enabled(entity_mutable, egui::Button::new("Unparent"))
|
||||
.clicked()
|
||||
{
|
||||
if ui.button("Unparent").clicked() {
|
||||
reparent_with_history(world, entity, None);
|
||||
ui.close();
|
||||
}
|
||||
let create_child = ui
|
||||
.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() {
|
||||
if ui.button("Create child Empty").clicked() {
|
||||
create_empty_child_with_history(world, entity);
|
||||
ui.close();
|
||||
}
|
||||
@ -840,167 +650,8 @@ fn authored_context_menu(
|
||||
}
|
||||
}
|
||||
|
||||
fn top_level_drag_entities(world: &World, candidates: &[Entity]) -> Vec<Entity> {
|
||||
candidates
|
||||
.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(_) => {}
|
||||
}
|
||||
fn apply_drop(world: &mut World, drag: &HierarchyDragPayload, target: DropTarget, invalid: bool) {
|
||||
if invalid || drag.entities.is_empty() {
|
||||
return;
|
||||
}
|
||||
match target {
|
||||
@ -1009,18 +660,14 @@ fn apply_drop(
|
||||
}
|
||||
DropTarget::On(parent) => {
|
||||
reorder_siblings_with_history(world, &drag.entities, Some(parent), i32::MAX);
|
||||
expand_parent_after_drop(world, parent);
|
||||
}
|
||||
DropTarget::Before(before) => {
|
||||
let parent = world
|
||||
.get::<ChildOf>(before)
|
||||
.map(|child_of| child_of.parent());
|
||||
let siblings = match parent {
|
||||
Some(parent) => mutable_authored_children(world, parent),
|
||||
None => level_object_roots(world)
|
||||
.into_iter()
|
||||
.filter(|entity| !is_linked_read_only(world, *entity))
|
||||
.collect(),
|
||||
Some(parent) => authored_children(world, parent),
|
||||
None => level_object_roots(world),
|
||||
};
|
||||
let insert_index = siblings
|
||||
.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(
|
||||
world: &World,
|
||||
entity: Entity,
|
||||
@ -1144,76 +744,3 @@ pub(crate) fn is_runtime_hierarchy_entity(world: &World, entity: Entity) -> bool
|
||||
|| 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
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@ -2,8 +2,8 @@
|
||||
|
||||
use bevy::prelude::*;
|
||||
use shared::{
|
||||
ActorKind, AuthoringLightKind, EditorVisibility, HierarchySiblingIndex, HydratedPrefabMember,
|
||||
LevelObject, LightDesc, ModelRef, PhysicsBody, PlayerSpawn, PrefabInstance,
|
||||
ActorKind, AuthoringLightKind, EditorVisibility, HierarchySiblingIndex, LevelObject, LightDesc,
|
||||
ModelRef, PhysicsBody, PlayerSpawn,
|
||||
};
|
||||
|
||||
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))
|
||||
}
|
||||
|
||||
#[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> {
|
||||
let mut query = world.query_filtered::<(Entity, Option<&ChildOf>), With<LevelObject>>();
|
||||
query
|
||||
@ -127,30 +67,6 @@ pub fn authored_children(world: &World, parent: Entity) -> Vec<Entity> {
|
||||
.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 {
|
||||
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 {
|
||||
HierarchySort::Manual => {
|
||||
entities.sort_by(|a, b| {
|
||||
linked_order_class(world, *a)
|
||||
.cmp(&linked_order_class(world, *b))
|
||||
.then_with(|| sibling_index(world, *a).cmp(&sibling_index(world, *b)))
|
||||
sibling_index(world, *a)
|
||||
.cmp(&sibling_index(world, *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 {
|
||||
world
|
||||
.get::<ActorKind>(entity)
|
||||
@ -205,7 +108,6 @@ fn actor_kind_sort_key(world: &World, entity: Entity) -> u8 {
|
||||
ActorKind::Empty => 0,
|
||||
ActorKind::Brush | ActorKind::StaticMesh | ActorKind::ImportedModel => 1,
|
||||
ActorKind::Light => 2,
|
||||
ActorKind::AudioSource | ActorKind::AudioListener => 3,
|
||||
ActorKind::PrefabAnchor => 3,
|
||||
ActorKind::PlayerSpawn => 4,
|
||||
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 {
|
||||
let transform = world.get::<Transform>(entity).copied();
|
||||
SiblingChange {
|
||||
entity,
|
||||
old_parent: world
|
||||
@ -269,8 +170,6 @@ pub fn capture_sibling_state(world: &World, entity: Entity) -> SiblingChange {
|
||||
.map(|child_of| child_of.parent()),
|
||||
old_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.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.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,
|
||||
) -> Vec<SiblingChange> {
|
||||
let mut siblings = match new_parent {
|
||||
Some(parent) => mutable_authored_children(world, parent),
|
||||
None => mutable_level_object_roots(world),
|
||||
Some(parent) => authored_children(world, parent),
|
||||
None => level_object_roots(world),
|
||||
};
|
||||
siblings.retain(|entity| !moving.contains(entity));
|
||||
let insert = insert_index.clamp(0, siblings.len() as i32) as usize;
|
||||
@ -359,8 +252,8 @@ fn renumber_existing_siblings(
|
||||
exclude: &[Entity],
|
||||
) -> Vec<SiblingChange> {
|
||||
let mut siblings = match parent {
|
||||
Some(parent) => mutable_authored_children(world, parent),
|
||||
None => mutable_level_object_roots(world),
|
||||
Some(parent) => authored_children(world, parent),
|
||||
None => level_object_roots(world),
|
||||
};
|
||||
siblings.retain(|entity| !exclude.contains(entity));
|
||||
sort_siblings(world, &mut siblings, HierarchySort::Manual);
|
||||
@ -379,20 +272,6 @@ fn apply_sibling_order(
|
||||
.map(|child_of| child_of.parent());
|
||||
let old_index = sibling_index(world, *entity);
|
||||
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 {
|
||||
changes.push(SiblingChange {
|
||||
entity: *entity,
|
||||
@ -400,8 +279,6 @@ fn apply_sibling_order(
|
||||
new_parent: parent,
|
||||
old_index,
|
||||
new_index,
|
||||
old_transform,
|
||||
new_transform,
|
||||
});
|
||||
}
|
||||
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.insert(HierarchySiblingIndex(new_index));
|
||||
if let Some(transform) = new_transform {
|
||||
entity_mut.insert(transform);
|
||||
}
|
||||
}
|
||||
}
|
||||
changes
|
||||
@ -466,8 +340,6 @@ pub fn actor_kind_icon(kind: ActorKind) -> &'static str {
|
||||
ActorKind::StaticMesh => icons::CUBE.as_str(),
|
||||
ActorKind::ImportedModel => icons::CUBE_TRANSPARENT.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::PlayerSpawn => icons::USER_CIRCLE.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)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use shared::ActorId;
|
||||
|
||||
#[test]
|
||||
fn sort_by_name_is_stable_for_duplicate_names() {
|
||||
@ -577,99 +448,4 @@ mod tests {
|
||||
sort_siblings(&world, &mut entities, HierarchySort::Name);
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -8,26 +8,22 @@ use bevy_egui::egui;
|
||||
use egui_phosphor_icons::{icons, Icon};
|
||||
use shared::{
|
||||
brush_math::{validate_brush, BrushDiagnosticSeverity},
|
||||
infer_actor_kind, inspector_component_active, ActorKind, AnimationControllerDesc,
|
||||
AudioListenerDesc, AudioSourceDesc, AuthoringLightKind, AuthoringRigidBody, BrushDesc,
|
||||
BrushKind, ColliderDesc, ColliderShapeDesc, ColorDesc, ComponentInstanceId, EditorAssetRef,
|
||||
inspector_component_active, AuthoringLightKind, AuthoringRigidBody, BrushDesc, BrushKind,
|
||||
ColliderDesc, ColliderShapeDesc, ColorDesc, ComponentInstanceId, EditorAssetRef,
|
||||
InspectorOrder, LevelObject, LightDesc, MaterialDesc, MaterialParameter,
|
||||
MaterialParameterValue, MaterialShaderKind, ModelRef, ObjectiveMarker, PhysicsBody,
|
||||
PlayerSpawn, PostProcessVolumeDesc, PrefabInstance, Primitive, PrimitiveShape, ProjectSun,
|
||||
RigidBodyDesc, StaticMeshRenderer, StaticMeshRendererEntry, TeamSpawn, TriggerVolume,
|
||||
WeaponSpawn, AUTHORING_DIRECTIONAL_LUX_MAX, AUTHORING_POINT_SPOT_LUMENS_MAX,
|
||||
COMPONENT_ANIMATION_CONTROLLER_DESC, COMPONENT_AUDIO_LISTENER_DESC,
|
||||
COMPONENT_AUDIO_SOURCE_DESC, COMPONENT_BRUSH_DESC, COMPONENT_COLLIDER_DESC,
|
||||
COMPONENT_LIGHT_DESC, COMPONENT_MATERIAL_DESC, COMPONENT_OBJECTIVE_MARKER,
|
||||
COMPONENT_PHYSICS_BODY, COMPONENT_PLAYER_SPAWN, COMPONENT_POST_PROCESS_VOLUME,
|
||||
COMPONENT_PREFAB_INSTANCE, COMPONENT_PRIMITIVE, COMPONENT_PROJECT_SUN,
|
||||
COMPONENT_RIGID_BODY_DESC, COMPONENT_STATIC_MESH_RENDERER, COMPONENT_TEAM_SPAWN,
|
||||
COMPONENT_TRIGGER_VOLUME, COMPONENT_WEAPON_SPAWN,
|
||||
MaterialParameterValue, MaterialShaderKind, ObjectiveMarker, PhysicsBody, PlayerSpawn,
|
||||
PostProcessVolumeDesc, PrefabInstance, Primitive, PrimitiveShape, ProjectSun, RigidBodyDesc,
|
||||
StaticMeshRenderer, StaticMeshRendererEntry, TeamSpawn, TriggerVolume, WeaponSpawn,
|
||||
AUTHORING_DIRECTIONAL_LUX_MAX, AUTHORING_POINT_SPOT_LUMENS_MAX, COMPONENT_BRUSH_DESC,
|
||||
COMPONENT_COLLIDER_DESC, COMPONENT_LIGHT_DESC, COMPONENT_MATERIAL_DESC,
|
||||
COMPONENT_OBJECTIVE_MARKER, COMPONENT_PHYSICS_BODY, COMPONENT_PLAYER_SPAWN,
|
||||
COMPONENT_POST_PROCESS_VOLUME, COMPONENT_PREFAB_INSTANCE, COMPONENT_PRIMITIVE,
|
||||
COMPONENT_PROJECT_SUN, COMPONENT_RIGID_BODY_DESC, COMPONENT_STATIC_MESH_RENDERER,
|
||||
COMPONENT_TEAM_SPAWN, COMPONENT_TRIGGER_VOLUME, COMPONENT_WEAPON_SPAWN,
|
||||
};
|
||||
|
||||
use crate::history::{
|
||||
material_eq, set_animation_controller_with_history, set_audio_listener_with_history,
|
||||
set_audio_source_with_history, set_brush_with_history, set_collider_with_history,
|
||||
material_eq, set_brush_with_history, set_collider_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_rigid_body_with_history, set_static_mesh_renderer_with_history, EditorEntitySnapshot,
|
||||
@ -138,14 +134,11 @@ enum AddComponentShelfDirection {
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
enum CopiedComponent {
|
||||
AnimationControllerDesc(AnimationControllerDesc),
|
||||
Primitive(Primitive),
|
||||
BrushDesc(BrushDesc),
|
||||
StaticMeshRenderer(StaticMeshRenderer),
|
||||
MaterialDesc(MaterialDesc),
|
||||
LightDesc(LightDesc),
|
||||
AudioSourceDesc(AudioSourceDesc),
|
||||
AudioListenerDesc(AudioListenerDesc),
|
||||
RigidBodyDesc(RigidBodyDesc),
|
||||
ColliderDesc(ColliderDesc),
|
||||
PhysicsBody(PhysicsBody),
|
||||
@ -160,14 +153,11 @@ enum CopiedComponent {
|
||||
impl CopiedComponent {
|
||||
fn type_name(&self) -> &'static str {
|
||||
match self {
|
||||
Self::AnimationControllerDesc(_) => COMPONENT_ANIMATION_CONTROLLER_DESC,
|
||||
Self::Primitive(_) => COMPONENT_PRIMITIVE,
|
||||
Self::BrushDesc(_) => COMPONENT_BRUSH_DESC,
|
||||
Self::StaticMeshRenderer(_) => COMPONENT_STATIC_MESH_RENDERER,
|
||||
Self::MaterialDesc(_) => COMPONENT_MATERIAL_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::ColliderDesc(_) => COMPONENT_COLLIDER_DESC,
|
||||
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> {
|
||||
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() {
|
||||
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() {
|
||||
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() {
|
||||
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) {
|
||||
let component = match type_name {
|
||||
COMPONENT_ANIMATION_CONTROLLER_DESC => world
|
||||
.get::<AnimationControllerDesc>(entity)
|
||||
.cloned()
|
||||
.map(CopiedComponent::AnimationControllerDesc),
|
||||
COMPONENT_PRIMITIVE => world
|
||||
.get::<Primitive>(entity)
|
||||
.cloned()
|
||||
@ -453,14 +430,6 @@ fn copy_component(world: &mut World, entity: Entity, type_name: &str) {
|
||||
.get::<LightDesc>(entity)
|
||||
.cloned()
|
||||
.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
|
||||
.get::<RigidBodyDesc>(entity)
|
||||
.copied()
|
||||
@ -509,11 +478,6 @@ fn paste_component(world: &mut World, entity: Entity, type_name: &str) {
|
||||
.get_resource::<InspectorClipboard>()
|
||||
.and_then(|clipboard| clipboard.component.clone());
|
||||
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 => {
|
||||
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 => {
|
||||
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 => {
|
||||
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) {
|
||||
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_BRUSH_DESC => set_brush_with_history(world, entity, BrushDesc::default()),
|
||||
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())
|
||||
}
|
||||
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 => {
|
||||
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 {
|
||||
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) {
|
||||
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() => {
|
||||
entity_mut.remove::<Primitive>();
|
||||
true
|
||||
@ -674,14 +608,6 @@ fn remove_registered_component(world: &mut World, entity: Entity, type_name: &st
|
||||
entity_mut.remove::<LightDesc>();
|
||||
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() => {
|
||||
entity_mut.remove::<RigidBodyDesc>();
|
||||
true
|
||||
@ -730,19 +656,6 @@ fn remove_registered_component(world: &mut World, entity: Entity, type_name: &st
|
||||
if !removed {
|
||||
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) {
|
||||
let snapshot = diff_added(&after, &before);
|
||||
crate::history::push_command(
|
||||
@ -777,20 +690,11 @@ fn draw_authoring_component_by_type(
|
||||
type_name: &'static str,
|
||||
) {
|
||||
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_BRUSH_DESC => brush_editor_ui(world, ui, entity),
|
||||
COMPONENT_PRIMITIVE => primitive_editor_ui(world, ui, entity),
|
||||
COMPONENT_MATERIAL_DESC => material_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_COLLIDER_DESC => collider_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::Rendering,
|
||||
EditorComponentCategory::Animation,
|
||||
EditorComponentCategory::Audio,
|
||||
EditorComponentCategory::Physics,
|
||||
EditorComponentCategory::Gameplay,
|
||||
EditorComponentCategory::Volumes,
|
||||
@ -1100,8 +1002,6 @@ fn component_category_label(category: EditorComponentCategory) -> &'static str {
|
||||
match category {
|
||||
EditorComponentCategory::Authoring => "Authoring",
|
||||
EditorComponentCategory::Rendering => "Rendering",
|
||||
EditorComponentCategory::Animation => "Animation",
|
||||
EditorComponentCategory::Audio => "Audio",
|
||||
EditorComponentCategory::Physics => "Physics",
|
||||
EditorComponentCategory::Gameplay => "Gameplay",
|
||||
EditorComponentCategory::Volumes => "Volumes",
|
||||
@ -1146,15 +1046,8 @@ fn component_add_state(
|
||||
.copied()
|
||||
.filter(|type_name| !component_present(world, entity, type_name))
|
||||
.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 {
|
||||
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() {
|
||||
Some(format!(
|
||||
"Conflicts with {}.",
|
||||
@ -1217,9 +1110,6 @@ fn component_display_name(
|
||||
|
||||
fn component_present(world: &World, entity: Entity, type_name: &str) -> bool {
|
||||
match type_name {
|
||||
COMPONENT_ANIMATION_CONTROLLER_DESC => {
|
||||
world.get::<AnimationControllerDesc>(entity).is_some()
|
||||
}
|
||||
"shared::components::Primitive" => world.get::<Primitive>(entity).is_some(),
|
||||
"shared::components::BrushDesc" => world.get::<BrushDesc>(entity).is_some(),
|
||||
"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::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::ColliderDesc" => world.get::<ColliderDesc>(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) {
|
||||
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| {
|
||||
world
|
||||
.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| {
|
||||
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| {
|
||||
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()),
|
||||
physics: after.physics.clone().filter(|_| before.physics.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,
|
||||
model: after.model.clone().filter(|_| before.model.is_none()),
|
||||
prefab: after.prefab.clone().filter(|_| before.prefab.is_none()),
|
||||
|
||||
@ -45,25 +45,6 @@ pub fn top_menu_bar(
|
||||
world.resource_mut::<SceneIo>().request = Some(SceneIoRequest::SaveAs);
|
||||
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();
|
||||
if menu_item(ui, "Import Assets...", None, true).clicked() {
|
||||
world.resource_mut::<SceneIo>().request = Some(SceneIoRequest::ImportAssets);
|
||||
@ -78,24 +59,6 @@ pub fn top_menu_bar(
|
||||
Some(SceneIoRequest::SaveSelectionAsPrefab);
|
||||
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
|
||||
.resource::<SceneIo>()
|
||||
.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| {
|
||||
if menu_item(ui, "Reset Layout", None, true).clicked() {
|
||||
let layout = reset_and_save_layout(world);
|
||||
|
||||
@ -1,8 +1,5 @@
|
||||
mod actor_inspector;
|
||||
mod animation_inspector;
|
||||
mod asset_browser;
|
||||
mod audio_inspector;
|
||||
mod build;
|
||||
mod component_registry;
|
||||
mod diagnostics;
|
||||
mod dock_tabs;
|
||||
@ -16,7 +13,6 @@ mod layout;
|
||||
mod menu;
|
||||
mod play_controls;
|
||||
mod post_process_volume_ui;
|
||||
mod scene_tabs;
|
||||
pub mod selection_ops;
|
||||
mod status_bar;
|
||||
pub(crate) mod theme;
|
||||
@ -38,7 +34,6 @@ use crate::project_io::UserPreferences;
|
||||
use crate::selection::SelectedEntity;
|
||||
use crate::state::EditorMode;
|
||||
|
||||
pub use build::BuildPanel;
|
||||
pub use diagnostics::{BrushDiagnosticsPanel, DiagnosticsPanel};
|
||||
pub use layout::LayoutSaveTimer;
|
||||
pub use viewport_chrome::ViewportUiState;
|
||||
@ -129,7 +124,6 @@ impl UiState {
|
||||
)]
|
||||
fn ui(&mut self, world: &mut World, ctx: &mut egui::Context, dt: f32) {
|
||||
apply_editor_theme(ctx);
|
||||
let selected_before_ui = world.resource::<SelectedEntity>().0;
|
||||
|
||||
let mode = *world.resource::<State<EditorMode>>().get();
|
||||
if self.last_mode_tab != Some(mode) {
|
||||
@ -162,7 +156,7 @@ impl UiState {
|
||||
&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);
|
||||
|
||||
let mut viewer = TabViewer {
|
||||
@ -208,8 +202,6 @@ impl UiState {
|
||||
.resource_mut::<crate::rendering_diagnostics::RenderingDiagnosticsPanel>()
|
||||
.open = rendering_open;
|
||||
|
||||
crate::session::session_resume_window(world, ctx);
|
||||
|
||||
world.resource_scope(|world, mut panel: Mut<ProjectSettingsPanel>| {
|
||||
project_settings_window(world, ctx, &mut panel);
|
||||
});
|
||||
@ -218,32 +210,11 @@ impl UiState {
|
||||
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();
|
||||
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 {
|
||||
let mut job = egui::text::LayoutJob::default();
|
||||
job.append(
|
||||
@ -276,7 +247,6 @@ pub enum EditorTab {
|
||||
Inspector,
|
||||
Toolbar,
|
||||
AssetBrowser,
|
||||
Build,
|
||||
}
|
||||
|
||||
pub struct EditorUiPlugin;
|
||||
@ -286,14 +256,12 @@ impl Plugin for EditorUiPlugin {
|
||||
app.insert_resource(UiState::default_layout())
|
||||
.add_plugins(fonts::EditorFontsPlugin)
|
||||
.init_resource::<DiagnosticsPanel>()
|
||||
.init_resource::<BuildPanel>()
|
||||
.init_resource::<BrushDiagnosticsPanel>()
|
||||
.init_resource::<component_registry::EditorComponentRegistry>()
|
||||
.init_resource::<crate::rendering_diagnostics::RenderingDiagnosticsPanel>()
|
||||
.init_resource::<asset_browser::AssetBrowserUiState>()
|
||||
.init_resource::<inspector::InspectorClipboard>()
|
||||
.init_resource::<inspector::InspectorPanelState>()
|
||||
.init_resource::<animation_inspector::AnimationInspectorState>()
|
||||
.init_resource::<ViewportUiState>()
|
||||
.init_resource::<LayoutSaveTimer>()
|
||||
.add_systems(
|
||||
@ -307,13 +275,7 @@ impl Plugin for EditorUiPlugin {
|
||||
.after(crate::project_io::load_user_preferences_on_startup),
|
||||
)
|
||||
.add_systems(EguiPrimaryContextPass, show_ui_system)
|
||||
.add_systems(
|
||||
Update,
|
||||
(
|
||||
hierarchy_state::tick_hierarchy_prefs_save,
|
||||
build::tick_build_job,
|
||||
),
|
||||
);
|
||||
.add_systems(Update, hierarchy_state::tick_hierarchy_prefs_save);
|
||||
}
|
||||
}
|
||||
|
||||
@ -398,7 +360,6 @@ impl egui_dock::TabViewer for TabViewer<'_> {
|
||||
EditorTab::AssetBrowser => {
|
||||
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::Toolbar => (icons::WRENCH, "Toolbar"),
|
||||
EditorTab::AssetBrowser => (icons::FOLDER, "Asset Browser"),
|
||||
EditorTab::Build => (icons::HAMMER, "Build"),
|
||||
};
|
||||
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]);
|
||||
}
|
||||
}
|
||||
|
||||
@ -13,7 +13,7 @@ pub fn toggle_play_mode(world: &mut World) {
|
||||
if save_before && world.resource::<SceneIo>().dirty {
|
||||
let status = save_active_or_prompt_world(world);
|
||||
if status.contains("failed") || status.contains("cancelled") {
|
||||
world.resource_mut::<SceneIo>().set_status(status);
|
||||
world.resource_mut::<SceneIo>().status = status;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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}"));
|
||||
}
|
||||
@ -2,7 +2,7 @@
|
||||
|
||||
use bevy::prelude::*;
|
||||
use bevy_inspector_egui::bevy_inspector::hierarchy::SelectedEntities;
|
||||
use shared::{HydratedPrefabMember, LevelObject};
|
||||
use shared::LevelObject;
|
||||
|
||||
use crate::camera::EditorCamera;
|
||||
use crate::history::{
|
||||
@ -11,25 +11,6 @@ use crate::history::{
|
||||
use crate::selection::SelectedEntity;
|
||||
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) {
|
||||
let Some(entity) = world.resource::<SelectedEntity>().0 else {
|
||||
return;
|
||||
@ -72,18 +53,12 @@ pub fn is_level_object(world: &World, entity: Entity) -> bool {
|
||||
}
|
||||
|
||||
pub fn delete_selection(world: &mut World, selected: &SelectedEntities) {
|
||||
let entities: Vec<_> = selected_level_entities(world, selected)
|
||||
.into_iter()
|
||||
.filter(|entity| is_mutable_level_object(world, *entity))
|
||||
.collect();
|
||||
let entities = selected_level_entities(world, selected);
|
||||
delete_entities_with_history(world, &entities);
|
||||
}
|
||||
|
||||
pub fn duplicate_selection(world: &mut World, selected: &SelectedEntities) {
|
||||
let entities: Vec<_> = selected_level_entities(world, selected)
|
||||
.into_iter()
|
||||
.filter(|entity| is_mutable_level_object(world, *entity))
|
||||
.collect();
|
||||
let entities = selected_level_entities(world, selected);
|
||||
duplicate_entities_with_history(world, &entities);
|
||||
}
|
||||
|
||||
@ -92,7 +67,7 @@ pub fn reset_selected_transforms(world: &mut World) {
|
||||
.resource::<UiState>()
|
||||
.selected_entities
|
||||
.iter()
|
||||
.filter(|entity| is_mutable_level_object(world, *entity))
|
||||
.filter(|entity| is_level_object(world, *entity))
|
||||
.filter_map(|entity| {
|
||||
world
|
||||
.get::<Transform>(entity)
|
||||
|
||||
@ -3,15 +3,13 @@
|
||||
use bevy::prelude::*;
|
||||
use bevy_egui::egui;
|
||||
use bevy_inspector_egui::bevy_inspector::hierarchy::SelectedEntities;
|
||||
use egui_phosphor_icons::icons;
|
||||
|
||||
use crate::history::EditorHistory;
|
||||
use crate::operators::ActiveOperator;
|
||||
use crate::scene_io::SceneIo;
|
||||
use crate::state::{EditorMode, PlayPaused};
|
||||
|
||||
use super::theme::{status_bar_frame, ACCENT, SELECTION, SUCCESS, TEXT, TEXT_DIM, TEXT_MUTED};
|
||||
use super::widgets::phosphor_icon;
|
||||
use super::theme::{status_bar_frame, TEXT};
|
||||
|
||||
pub fn status_bar_ui(
|
||||
world: &World,
|
||||
@ -20,10 +18,10 @@ pub fn status_bar_ui(
|
||||
mode: EditorMode,
|
||||
) {
|
||||
egui::Panel::bottom("editor_status_bar")
|
||||
.exact_size(27.0)
|
||||
.exact_size(24.0)
|
||||
.frame(status_bar_frame())
|
||||
.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()
|
||||
.shrink_left()
|
||||
.truncate()
|
||||
@ -31,41 +29,23 @@ pub fn status_bar_ui(
|
||||
ui,
|
||||
|ui| {
|
||||
let status = primary_status_line(world);
|
||||
ui.label(
|
||||
egui::RichText::new("●")
|
||||
.color(if world.resource::<SceneIo>().dirty {
|
||||
ACCENT
|
||||
} else {
|
||||
SUCCESS
|
||||
})
|
||||
.small(),
|
||||
);
|
||||
ui.add(
|
||||
egui::Label::new(egui::RichText::new(&status).color(TEXT_DIM))
|
||||
.truncate(),
|
||||
egui::Label::new(egui::RichText::new(&status).color(TEXT)).truncate(),
|
||||
)
|
||||
.on_hover_text(status);
|
||||
},
|
||||
|ui| {
|
||||
let (mode_icon, mode_label, mode_color) = match mode {
|
||||
EditorMode::Editing => (icons::PENCIL_SIMPLE, "EDIT", ACCENT),
|
||||
let mode_label = match mode {
|
||||
EditorMode::Editing => "Edit".to_string(),
|
||||
EditorMode::Playing => {
|
||||
if world.resource::<PlayPaused>().0 {
|
||||
(icons::PAUSE, "PAUSED", ACCENT)
|
||||
"Play (paused)".to_string()
|
||||
} 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(mode_color)
|
||||
.strong()
|
||||
.small(),
|
||||
);
|
||||
|
||||
ui.separator();
|
||||
ui.label(egui::RichText::new(mode_label).color(TEXT));
|
||||
|
||||
let count = selected.len();
|
||||
let selection = match count {
|
||||
@ -73,15 +53,11 @@ pub fn status_bar_ui(
|
||||
1 => "1 selected".to_string(),
|
||||
_ => format!("{count} selected"),
|
||||
};
|
||||
ui.label(egui::RichText::new(selection).color(if count == 0 {
|
||||
TEXT_MUTED
|
||||
} else {
|
||||
SELECTION
|
||||
}));
|
||||
ui.label(egui::RichText::new(selection).color(TEXT));
|
||||
|
||||
ui.label(
|
||||
egui::RichText::new(world.resource::<EditorHistory>().status.clone())
|
||||
.color(TEXT_DIM),
|
||||
.color(TEXT),
|
||||
);
|
||||
|
||||
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 {
|
||||
let dirty = if scene_io.dirty { " *" } else { "" };
|
||||
let recovery = if scene_io.recovery_snapshot.is_some() {
|
||||
" [recovery]"
|
||||
} else {
|
||||
""
|
||||
};
|
||||
format!("Scene: {}{dirty}{recovery}", scene_io.active_path_label())
|
||||
format!("Scene: {}{dirty}", scene_io.active_path_label())
|
||||
}
|
||||
|
||||
fn primary_status_line(world: &World) -> String {
|
||||
@ -124,8 +95,5 @@ mod tests {
|
||||
|
||||
scene_io.mark_dirty();
|
||||
assert!(scene_line(&scene_io).ends_with('*'));
|
||||
|
||||
scene_io.recovery_snapshot = Some("recovery.scn.ron".into());
|
||||
assert!(scene_line(&scene_io).ends_with("[recovery]"));
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 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(13, 15, 18);
|
||||
pub const PANEL_BG_DARK: Color32 = Color32::from_rgb(9, 11, 13);
|
||||
pub const WIDGET_BG: Color32 = Color32::from_rgb(21, 24, 28);
|
||||
pub const ELEVATED_BG: Color32 = Color32::from_rgb(31, 35, 40);
|
||||
pub const ACCENT: Color32 = Color32::from_rgb(229, 164, 51);
|
||||
pub const ACCENT_HOVER: Color32 = Color32::from_rgb(249, 193, 79);
|
||||
pub const SELECTION: Color32 = Color32::from_rgb(65, 198, 207);
|
||||
pub const SELECTION_SECONDARY: Color32 = Color32::from_rgb(84, 139, 155);
|
||||
pub const SELECTION_BG: Color32 = Color32::from_rgb(22, 72, 78);
|
||||
pub const SELECTION_BG_MUTED: Color32 = Color32::from_rgb(23, 48, 54);
|
||||
pub const TEXT: Color32 = Color32::from_rgb(220, 225, 229);
|
||||
pub const TEXT_SELECTED: Color32 = Color32::from_rgb(250, 251, 252);
|
||||
pub const TEXT_DIM: Color32 = Color32::from_rgb(155, 163, 171);
|
||||
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 const PANEL_BG: Color32 = Color32::from_rgb(32, 33, 36);
|
||||
pub const PANEL_BG_DARK: Color32 = Color32::from_rgb(24, 25, 28);
|
||||
pub const WIDGET_BG: Color32 = Color32::from_rgb(42, 43, 47);
|
||||
pub const ELEVATED_BG: Color32 = Color32::from_rgb(55, 57, 63);
|
||||
pub const ACCENT: Color32 = Color32::from_rgb(56, 121, 184);
|
||||
pub const ACCENT_HOVER: Color32 = Color32::from_rgb(72, 143, 211);
|
||||
pub const SELECTION_BG: Color32 = Color32::from_rgb(46, 103, 158);
|
||||
pub const SELECTION_BG_MUTED: Color32 = Color32::from_rgb(43, 75, 108);
|
||||
pub const TEXT: Color32 = Color32::from_rgb(226, 229, 234);
|
||||
pub const TEXT_SELECTED: Color32 = Color32::from_rgb(250, 253, 255);
|
||||
pub const TEXT_DIM: Color32 = Color32::from_rgb(170, 176, 184);
|
||||
pub const TEXT_MUTED: Color32 = Color32::from_rgb(126, 133, 143);
|
||||
pub const BORDER: Color32 = Color32::from_rgb(69, 72, 78);
|
||||
pub const VIEWPORT_OVERLAY: Color32 = Color32::from_rgba_premultiplied(26, 27, 30, 220);
|
||||
|
||||
pub fn apply_editor_theme(ctx: &egui::Context) {
|
||||
let mut visuals = Visuals::dark();
|
||||
visuals.panel_fill = PANEL_BG;
|
||||
visuals.window_fill = WIDGET_BG;
|
||||
visuals.extreme_bg_color = CANVAS_BG;
|
||||
visuals.faint_bg_color = Color32::from_rgb(17, 19, 22);
|
||||
visuals.extreme_bg_color = PANEL_BG_DARK;
|
||||
visuals.faint_bg_color = Color32::from_rgb(38, 40, 44);
|
||||
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.widgets.noninteractive.bg_fill = PANEL_BG;
|
||||
visuals.widgets.noninteractive.bg_stroke = Stroke::new(1.0, BORDER);
|
||||
visuals.widgets.noninteractive.fg_stroke = Stroke::new(1.0, TEXT_DIM);
|
||||
visuals.widgets.noninteractive.bg_fill = WIDGET_BG;
|
||||
visuals.widgets.noninteractive.fg_stroke = Stroke::new(1.0, TEXT);
|
||||
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.fg_stroke = Stroke::new(1.0, TEXT);
|
||||
visuals.widgets.hovered.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.active.bg_fill = Color32::from_rgb(38, 42, 47);
|
||||
visuals.widgets.active.weak_bg_fill = Color32::from_rgb(38, 42, 47);
|
||||
visuals.widgets.active.bg_stroke = Stroke::new(1.0, ACCENT);
|
||||
visuals.widgets.active.bg_fill = SELECTION_BG;
|
||||
visuals.widgets.active.weak_bg_fill = SELECTION_BG;
|
||||
visuals.widgets.active.bg_stroke = Stroke::new(1.0, ACCENT_HOVER);
|
||||
visuals.widgets.active.fg_stroke = Stroke::new(1.0, TEXT_SELECTED);
|
||||
visuals.widgets.open.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.selection.bg_fill = SELECTION_BG;
|
||||
visuals.selection.stroke = Stroke::new(1.0, SELECTION);
|
||||
visuals.hyperlink_color = SELECTION;
|
||||
visuals.warn_fg_color = WARNING;
|
||||
visuals.error_fg_color = ERROR;
|
||||
visuals.selection.stroke = Stroke::new(1.0, TEXT_SELECTED);
|
||||
visuals.hyperlink_color = Color32::from_rgb(132, 186, 247);
|
||||
visuals.warn_fg_color = Color32::from_rgb(255, 200, 80);
|
||||
visuals.error_fg_color = Color32::from_rgb(255, 100, 100);
|
||||
visuals.window_stroke = Stroke::new(1.0, BORDER);
|
||||
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();
|
||||
style.visuals = visuals;
|
||||
style.spacing.item_spacing = egui::vec2(8.0, 7.0);
|
||||
style.spacing.button_padding = egui::vec2(10.0, 5.0);
|
||||
style.spacing.interact_size = egui::vec2(40.0, 28.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.spacing.item_spacing = egui::vec2(8.0, 6.0);
|
||||
style.spacing.button_padding = egui::vec2(8.0, 4.0);
|
||||
style.spacing.indent = 16.0;
|
||||
style.text_styles.insert(
|
||||
egui::TextStyle::Body,
|
||||
FontId::new(14.0, egui::FontFamily::Proportional),
|
||||
FontId::new(13.0, egui::FontFamily::Proportional),
|
||||
);
|
||||
style.text_styles.insert(
|
||||
egui::TextStyle::Button,
|
||||
@ -105,49 +67,42 @@ pub fn apply_editor_theme(ctx: &egui::Context) {
|
||||
);
|
||||
style.text_styles.insert(
|
||||
egui::TextStyle::Heading,
|
||||
FontId::new(16.0, egui::FontFamily::Proportional),
|
||||
);
|
||||
style.text_styles.insert(
|
||||
egui::TextStyle::Small,
|
||||
FontId::new(12.0, egui::FontFamily::Proportional),
|
||||
FontId::new(15.0, egui::FontFamily::Proportional),
|
||||
);
|
||||
ctx.set_global_style(style);
|
||||
}
|
||||
|
||||
pub fn editor_dock_style(ctx: &egui::Context) -> DockStyle {
|
||||
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_rounding = CornerRadius::ZERO;
|
||||
style.separator.width = 2.0;
|
||||
style.separator.extra_interact_width = 5.0;
|
||||
style.separator.color_idle = BORDER;
|
||||
style.separator.color_hovered = BORDER_STRONG;
|
||||
style.separator.color_hovered = ELEVATED_BG;
|
||||
style.separator.color_dragged = ACCENT_HOVER;
|
||||
|
||||
style.tab_bar.bg_fill = PANEL_BG_DARK;
|
||||
style.tab_bar.height = 30.0;
|
||||
style.tab_bar.inner_margin = egui::Margin::symmetric(2, 1);
|
||||
style.tab_bar.hline_color = ACCENT;
|
||||
style.tab_bar.height = 26.0;
|
||||
style.tab_bar.hline_color = BORDER;
|
||||
style.tab_bar.corner_radius = CornerRadius::ZERO;
|
||||
|
||||
let active = TabInteractionStyle {
|
||||
outline_color: ACCENT,
|
||||
outline_color: BORDER,
|
||||
bg_fill: PANEL_BG,
|
||||
text_color: TEXT_SELECTED,
|
||||
corner_radius: CornerRadius::same(1),
|
||||
text_color: TEXT,
|
||||
corner_radius: CornerRadius::same(3),
|
||||
};
|
||||
let inactive = TabInteractionStyle {
|
||||
outline_color: Color32::TRANSPARENT,
|
||||
bg_fill: Color32::TRANSPARENT,
|
||||
bg_fill: WIDGET_BG,
|
||||
text_color: TEXT_DIM,
|
||||
corner_radius: CornerRadius::same(1),
|
||||
corner_radius: CornerRadius::same(3),
|
||||
};
|
||||
let hovered = TabInteractionStyle {
|
||||
outline_color: BORDER_STRONG,
|
||||
outline_color: BORDER,
|
||||
bg_fill: ELEVATED_BG,
|
||||
text_color: TEXT,
|
||||
corner_radius: CornerRadius::same(1),
|
||||
corner_radius: CornerRadius::same(3),
|
||||
};
|
||||
|
||||
style.tab = TabStyle {
|
||||
@ -158,16 +113,8 @@ pub fn editor_dock_style(ctx: &egui::Context) -> DockStyle {
|
||||
inactive_with_kb_focus: inactive.clone(),
|
||||
active_with_kb_focus: active.clone(),
|
||||
focused_with_kb_focus: active,
|
||||
hline_below_active_tab_name: true,
|
||||
spacing: 2.0,
|
||||
..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
|
||||
}
|
||||
@ -178,45 +125,14 @@ pub fn panel_heading(text: &str) -> egui::RichText {
|
||||
|
||||
pub fn status_bar_frame() -> egui::Frame {
|
||||
egui::Frame::new()
|
||||
.fill(PANEL_BG_DARK)
|
||||
.inner_margin(egui::Margin::symmetric(10, 3))
|
||||
.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))
|
||||
.fill(Color32::from_rgb(0, 122, 204))
|
||||
.inner_margin(egui::Margin::symmetric(8, 2))
|
||||
}
|
||||
|
||||
pub fn viewport_toolbar_frame() -> egui::Frame {
|
||||
egui::Frame::new()
|
||||
.fill(VIEWPORT_OVERLAY)
|
||||
.corner_radius(CornerRadius::same(3))
|
||||
.inner_margin(egui::Margin::same(5))
|
||||
.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))
|
||||
.corner_radius(CornerRadius::same(4))
|
||||
.inner_margin(egui::Margin::same(4))
|
||||
.stroke(Stroke::new(1.0, BORDER))
|
||||
}
|
||||
|
||||
@ -2,7 +2,6 @@
|
||||
|
||||
use bevy::prelude::*;
|
||||
use bevy_egui::egui;
|
||||
use bevy_inspector_egui::bevy_inspector::hierarchy::SelectedEntities;
|
||||
use egui_phosphor_icons::icons;
|
||||
|
||||
use crate::history::spawn_with_history;
|
||||
@ -16,46 +15,35 @@ use shared::PrimitiveShape;
|
||||
use super::helpers::{
|
||||
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, phosphor_icon, toolbar_separator, transport_button_large,
|
||||
};
|
||||
use super::widgets::{icon_button, panel_toolbar_row, toolbar_separator, transport_button_large};
|
||||
|
||||
/// Toolbar strip height (menu bar is separate).
|
||||
const TOOLBAR_HEIGHT: f32 = 50.0;
|
||||
const TRANSPORT_WIDTH: f32 = 150.0;
|
||||
const TRANSPORT_SLOT_STEP: f32 = 50.0;
|
||||
const TOOLBAR_HEIGHT: f32 = 56.0;
|
||||
const TRANSPORT_WIDTH: f32 = 152.0;
|
||||
const TRANSPORT_SLOT_STEP: f32 = 54.0;
|
||||
|
||||
pub fn editor_toolbar_panel(
|
||||
world: &mut World,
|
||||
root_ui: &mut egui::Ui,
|
||||
selected: &mut SelectedEntities,
|
||||
) {
|
||||
pub fn editor_toolbar_panel(world: &mut World, root_ui: &mut egui::Ui) {
|
||||
egui::Panel::top("editor_toolbar")
|
||||
.exact_size(TOOLBAR_HEIGHT)
|
||||
.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(
|
||||
egui::vec2(ui.available_width(), TOOLBAR_HEIGHT - 2.0),
|
||||
egui::vec2(ui.available_width(), TOOLBAR_HEIGHT - 4.0),
|
||||
egui::Sense::hover(),
|
||||
);
|
||||
|
||||
let center_y = toolbar_rect.center().y;
|
||||
let left_rect = egui::Rect::from_min_max(
|
||||
egui::pos2(toolbar_rect.left() + 8.0, center_y - 20.0),
|
||||
egui::pos2(toolbar_rect.right(), center_y + 20.0),
|
||||
egui::pos2(toolbar_rect.left() + 8.0, center_y - 18.0),
|
||||
egui::pos2(toolbar_rect.right(), center_y + 18.0),
|
||||
);
|
||||
let transport_rect = egui::Rect::from_center_size(
|
||||
egui::pos2(toolbar_rect.center().x, center_y),
|
||||
egui::vec2(TRANSPORT_WIDTH, 46.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),
|
||||
egui::vec2(TRANSPORT_WIDTH, 44.0),
|
||||
);
|
||||
|
||||
let mut left_ui = ui.new_child(
|
||||
@ -74,56 +62,10 @@ pub fn toolbar_ui(world: &mut World, ui: &mut egui::Ui, selected: &mut SelectedE
|
||||
.layout(egui::Layout::left_to_right(egui::Align::Center)),
|
||||
);
|
||||
transport_ui.spacing_mut().item_spacing.x = 10.0;
|
||||
toolbar_group_frame().show(&mut transport_ui, |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),
|
||||
);
|
||||
transport_controls(world, &mut transport_ui);
|
||||
}
|
||||
|
||||
fn left_toolbar(world: &mut World, ui: &mut egui::Ui) {
|
||||
brand_mark_frame().show(ui, |ui| {
|
||||
ui.set_min_width(118.0);
|
||||
ui.set_min_height(30.0);
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(phosphor_icon(icons::CROSSHAIR, 19.0).color(ACCENT));
|
||||
ui.vertical(|ui| {
|
||||
ui.spacing_mut().item_spacing.y = 0.0;
|
||||
ui.label(
|
||||
egui::RichText::new("BLACKSITE")
|
||||
.size(11.0)
|
||||
.strong()
|
||||
.color(TEXT),
|
||||
);
|
||||
ui.label(
|
||||
egui::RichText::new("EDITOR // DEV")
|
||||
.size(9.0)
|
||||
.color(TEXT_MUTED),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
toolbar_group_frame().show(ui, |ui| {
|
||||
panel_toolbar_row(ui, |ui| {
|
||||
if icon_button(ui, icons::CUBE, "Spawn cube").clicked() {
|
||||
spawn_primitive_at_camera(world, PrimitiveShape::Box, "Cube");
|
||||
@ -157,7 +99,6 @@ fn left_toolbar(world: &mut World, ui: &mut egui::Ui) {
|
||||
world.resource_mut::<SceneIo>().request = Some(SceneIoRequest::SaveAs);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
fn transport_controls(world: &mut World, ui: &mut egui::Ui) {
|
||||
@ -165,9 +106,11 @@ fn transport_controls(world: &mut World, ui: &mut egui::Ui) {
|
||||
match mode {
|
||||
EditorMode::Editing => {
|
||||
ui.add_space(TRANSPORT_SLOT_STEP);
|
||||
let play_clicked =
|
||||
transport_button_large(ui, "transport_center", icons::PLAY, "Play (F5)", true)
|
||||
.clicked();
|
||||
let play_clicked = ui
|
||||
.push_id("transport_center", |ui| {
|
||||
transport_button_large(ui, icons::PLAY, "Play (F5)", true).clicked()
|
||||
})
|
||||
.inner;
|
||||
if play_clicked {
|
||||
toggle_play_mode(world);
|
||||
}
|
||||
@ -180,20 +123,20 @@ fn transport_controls(world: &mut World, ui: &mut egui::Ui) {
|
||||
} else {
|
||||
"Pause simulation (F6)"
|
||||
};
|
||||
let pause_clicked =
|
||||
transport_button_large(ui, "transport_left", pause_icon, pause_tip, false)
|
||||
.clicked();
|
||||
let pause_clicked = ui
|
||||
.push_id("transport_left", |ui| {
|
||||
transport_button_large(ui, pause_icon, pause_tip, false).clicked()
|
||||
})
|
||||
.inner;
|
||||
if pause_clicked {
|
||||
toggle_play_paused(world);
|
||||
}
|
||||
let stop_clicked = transport_button_large(
|
||||
ui,
|
||||
"transport_center",
|
||||
icons::STOP,
|
||||
"Stop and return to Edit (F5)",
|
||||
false,
|
||||
)
|
||||
.clicked();
|
||||
let stop_clicked = ui
|
||||
.push_id("transport_center", |ui| {
|
||||
transport_button_large(ui, icons::STOP, "Stop and return to Edit (F5)", false)
|
||||
.clicked()
|
||||
})
|
||||
.inner;
|
||||
if stop_clicked {
|
||||
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::Ejected => (icons::USER, "Possess player (F8)"),
|
||||
};
|
||||
let possession_clicked =
|
||||
transport_button_large(ui, "transport_right", eject_icon, eject_tip, false)
|
||||
.clicked();
|
||||
let possession_clicked = ui
|
||||
.push_id("transport_right", |ui| {
|
||||
transport_button_large(ui, eject_icon, eject_tip, false).clicked()
|
||||
})
|
||||
.inner;
|
||||
if possession_clicked {
|
||||
toggle_possession(world);
|
||||
}
|
||||
|
||||
@ -7,15 +7,12 @@ use egui_phosphor_icons::icons;
|
||||
use transform_gizmo_bevy::prelude::GizmoOptions;
|
||||
|
||||
use crate::assets::operators::{
|
||||
apply_material_operator, apply_texture_operator, assign_animation_clip_operator,
|
||||
assign_audio_clip_operator, place_asset_operator, place_subasset_operator,
|
||||
apply_material_operator, apply_texture_operator, place_asset_operator, place_subasset_operator,
|
||||
};
|
||||
use crate::assets::thumbnails::kind_icon;
|
||||
use crate::assets::{AssetSelection, AssetSubAssetKind, EditorAssets};
|
||||
use crate::camera::EditorCamera;
|
||||
use crate::gizmos::{EditorGizmoMode, EditorGizmoSpace};
|
||||
use crate::render_target::ViewportRenderTarget;
|
||||
use crate::selection::{ViewportClick, ViewportPickStack};
|
||||
use crate::selection::ViewportClick;
|
||||
use crate::state::PlayPossession;
|
||||
use crate::viewport::actor_icons::ActorIconSettings;
|
||||
use crate::viewport::brush_edit::{BrushEditMode, BrushElementSelection};
|
||||
@ -28,13 +25,8 @@ use crate::viewport::{
|
||||
use super::helpers::{
|
||||
focus_editor_camera_on_selection, viewport_recall_bookmark, viewport_save_bookmark,
|
||||
};
|
||||
use super::theme::{
|
||||
overlay_chip_frame, viewport_toolbar_frame, ACCENT, ACCENT_HOVER, AXIS_X, AXIS_Y, AXIS_Z,
|
||||
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,
|
||||
};
|
||||
use super::theme::viewport_toolbar_frame;
|
||||
use super::widgets::{icon_button_small, panel_toolbar_row, tool_button};
|
||||
|
||||
#[derive(Resource, Default)]
|
||||
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::Color32::WHITE,
|
||||
);
|
||||
painter.rect_stroke(
|
||||
rect,
|
||||
0.0,
|
||||
egui::Stroke::new(
|
||||
1.0,
|
||||
if response.hovered() {
|
||||
BORDER_STRONG
|
||||
} else {
|
||||
BORDER
|
||||
},
|
||||
),
|
||||
egui::StrokeKind::Inside,
|
||||
);
|
||||
} else {
|
||||
ui.allocate_rect(rect, egui::Sense::hover());
|
||||
ui.scope_builder(egui::UiBuilder::new().max_rect(rect), |ui| {
|
||||
@ -135,48 +114,34 @@ pub fn viewport_tab_ui(
|
||||
}
|
||||
|
||||
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_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_gi_badge(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);
|
||||
}
|
||||
|
||||
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 {
|
||||
return;
|
||||
};
|
||||
let mode = *mode;
|
||||
let (label, color) = match mode {
|
||||
EditorViewportMode::Lit => ("Lit", SUCCESS),
|
||||
EditorViewportMode::Unlit => ("Unlit", WARNING),
|
||||
EditorViewportMode::Collider => ("Collider", WARNING),
|
||||
EditorViewportMode::Lit => ("Lit", egui::Color32::from_rgb(120, 200, 120)),
|
||||
EditorViewportMode::Unlit => ("Unlit", egui::Color32::from_rgb(200, 200, 120)),
|
||||
EditorViewportMode::Collider => ("Collider", egui::Color32::from_rgb(220, 140, 80)),
|
||||
};
|
||||
|
||||
let profile = world.get_resource::<settings::ActiveCameraRenderProfile>();
|
||||
let (gi_label, gi_color) = match profile.map(|profile| profile.gi_path) {
|
||||
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)
|
||||
egui::Area::new(egui::Id::new("scene_view_mode_badge"))
|
||||
.fixed_pos(scene_rect.right_bottom() + egui::vec2(-72.0, -22.0))
|
||||
.interactable(false)
|
||||
.show(ctx, |ui| {
|
||||
overlay_chip_frame().show(ui, |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(egui::RichText::new(label).color(color).strong().small());
|
||||
ui.label(egui::RichText::new("/").color(TEXT_MUTED).small());
|
||||
ui.label(
|
||||
egui::RichText::new(gi_label)
|
||||
.color(gi_color)
|
||||
.strong()
|
||||
.small(),
|
||||
);
|
||||
});
|
||||
egui::Frame::NONE
|
||||
.fill(egui::Color32::from_black_alpha(160))
|
||||
.inner_margin(egui::Margin::symmetric(8, 4))
|
||||
.show(ui, |ui| {
|
||||
ui.colored_label(color, label);
|
||||
});
|
||||
});
|
||||
|
||||
@ -185,14 +150,11 @@ 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))
|
||||
.interactable(false)
|
||||
.show(ctx, |ui| {
|
||||
overlay_chip_frame().show(ui, |ui| {
|
||||
ui.label(
|
||||
egui::RichText::new("Collider view / meshes hidden")
|
||||
.color(WARNING)
|
||||
.small(),
|
||||
ui.colored_label(
|
||||
egui::Color32::from_rgb(255, 180, 100),
|
||||
"Meshes hidden - switch to Lit (sun icon) for shading",
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@ -211,19 +173,31 @@ 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))
|
||||
.interactable(false)
|
||||
.show(ctx, |ui| {
|
||||
overlay_chip_frame().show(ui, |ui| {
|
||||
egui::Frame::new()
|
||||
.fill(egui::Color32::from_rgba_unmultiplied(20, 18, 28, 230))
|
||||
.stroke(egui::Stroke::new(
|
||||
1.0,
|
||||
egui::Color32::from_rgba_unmultiplied(210, 140, 255, 210),
|
||||
))
|
||||
.corner_radius(egui::CornerRadius::same(4))
|
||||
.inner_margin(egui::Margin::symmetric(8, 6))
|
||||
.show(ui, |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(
|
||||
egui::RichText::new(format!("Brush {}", mode.label()))
|
||||
.color(ACCENT)
|
||||
.color(egui::Color32::from_rgb(230, 190, 255))
|
||||
.strong(),
|
||||
);
|
||||
ui.label(
|
||||
egui::RichText::new(format!("{selected_count} selected"))
|
||||
.color(SELECTION)
|
||||
.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.label(egui::RichText::new("Element gizmo").color(SUCCESS).small());
|
||||
});
|
||||
ui.add_space(4.0);
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
@ -263,15 +237,31 @@ 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))
|
||||
.interactable(false)
|
||||
.show(ctx, |ui| {
|
||||
overlay_chip_frame().show(ui, |ui| {
|
||||
egui::Frame::new()
|
||||
.fill(egui::Color32::from_rgba_unmultiplied(18, 22, 26, 220))
|
||||
.stroke(egui::Stroke::new(
|
||||
1.0,
|
||||
egui::Color32::from_rgba_unmultiplied(95, 150, 190, 180),
|
||||
))
|
||||
.corner_radius(egui::CornerRadius::same(4))
|
||||
.inner_margin(egui::Margin::symmetric(8, 6))
|
||||
.show(ui, |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(egui::RichText::new("Draw Brush").color(SELECTION).strong());
|
||||
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(TEXT_DIM)
|
||||
.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.label(egui::RichText::new(hint).color(SUCCESS).small());
|
||||
});
|
||||
ui.add_space(4.0);
|
||||
ui.horizontal_wrapped(|ui| match tool.phase {
|
||||
@ -300,148 +290,34 @@ fn key_hint(ui: &mut egui::Ui, key: &str, label: &str) {
|
||||
ui.label(
|
||||
egui::RichText::new(key)
|
||||
.monospace()
|
||||
.color(TEXT)
|
||||
.background_color(ELEVATED_BG),
|
||||
);
|
||||
ui.label(egui::RichText::new(label).color(TEXT_DIM).small());
|
||||
});
|
||||
}
|
||||
|
||||
fn scene_view_selection_hud(
|
||||
world: &World,
|
||||
ctx: &egui::Context,
|
||||
scene_rect: egui::Rect,
|
||||
selected_entities: &SelectedEntities,
|
||||
) {
|
||||
let Some(&primary) = selected_entities.as_slice().first() else {
|
||||
return;
|
||||
};
|
||||
let name = world
|
||||
.get::<Name>(primary)
|
||||
.map(|name| name.as_str().to_string())
|
||||
.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
|
||||
};
|
||||
|
||||
let pick_detail = world.get_resource::<ViewportPickStack>().and_then(|stack| {
|
||||
(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)
|
||||
.show(ctx, |ui| {
|
||||
overlay_chip_frame().show(ui, |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(phosphor_icon(icons::CROSSHAIR, 17.0).color(SELECTION));
|
||||
ui.vertical(|ui| {
|
||||
ui.set_max_width(260.0);
|
||||
ui.add(
|
||||
egui::Label::new(egui::RichText::new(&name).color(TEXT).strong())
|
||||
.truncate(),
|
||||
.color(egui::Color32::from_rgb(232, 238, 242))
|
||||
.background_color(egui::Color32::from_rgba_unmultiplied(45, 52, 58, 220)),
|
||||
);
|
||||
ui.label(
|
||||
egui::RichText::new(&selection_detail)
|
||||
.color(TEXT_DIM)
|
||||
egui::RichText::new(label)
|
||||
.color(egui::Color32::from_rgb(190, 200, 205))
|
||||
.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 {
|
||||
fn scene_view_gi_badge(world: &World, ctx: &egui::Context, scene_rect: egui::Rect) {
|
||||
let Some(profile) = world.get_resource::<settings::ActiveCameraRenderProfile>() 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)
|
||||
let (label, color) = match profile.gi_path {
|
||||
settings::GiPath::Forward => ("Forward", egui::Color32::from_rgb(140, 180, 255)),
|
||||
settings::GiPath::SolariDeferred => ("Solari", egui::Color32::from_rgb(255, 170, 90)),
|
||||
};
|
||||
egui::Area::new(egui::Id::new("scene_view_gi_badge"))
|
||||
.fixed_pos(scene_rect.right_bottom() + egui::vec2(-148.0, -22.0))
|
||||
.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),
|
||||
);
|
||||
}
|
||||
egui::Frame::NONE
|
||||
.fill(egui::Color32::from_black_alpha(160))
|
||||
.inner_margin(egui::Margin::symmetric(8, 4))
|
||||
.show(ui, |ui| {
|
||||
ui.colored_label(color, label);
|
||||
});
|
||||
});
|
||||
}
|
||||
@ -457,25 +333,16 @@ 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))
|
||||
.interactable(false)
|
||||
.show(ctx, |ui| {
|
||||
overlay_chip_frame().show(ui, |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(phosphor_icon(icons::CUBE, 14.0).color(SELECTION));
|
||||
ui.label(
|
||||
egui::RichText::new(&primary.label)
|
||||
.color(TEXT)
|
||||
.strong()
|
||||
.small(),
|
||||
);
|
||||
ui.label(
|
||||
egui::RichText::new(format!(
|
||||
"{:.0}% / {} overrides",
|
||||
egui::Frame::NONE
|
||||
.fill(egui::Color32::from_black_alpha(150))
|
||||
.inner_margin(egui::Margin::symmetric(8, 4))
|
||||
.show(ui, |ui| {
|
||||
ui.label(format!(
|
||||
"Volume: {} ({:.0}%, {} overrides)",
|
||||
primary.label,
|
||||
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| {
|
||||
panel_toolbar_row(ui, |ui| {
|
||||
let gizmo_mode = *world.resource::<crate::gizmos::EditorGizmoMode>();
|
||||
if tool_button_accent(
|
||||
if tool_button(
|
||||
ui,
|
||||
icons::ARROWS_OUT_CARDINAL,
|
||||
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);
|
||||
}
|
||||
if tool_button_accent(
|
||||
if tool_button(
|
||||
ui,
|
||||
icons::ARROWS_CLOCKWISE,
|
||||
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);
|
||||
}
|
||||
if tool_button_accent(
|
||||
if tool_button(
|
||||
ui,
|
||||
icons::ARROWS_IN_LINE_HORIZONTAL,
|
||||
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() {
|
||||
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) =
|
||||
@ -673,8 +532,7 @@ fn scene_options_popover(
|
||||
ui.checkbox(&mut settings.snap_translate, "Snap translation");
|
||||
ui.add(
|
||||
egui::Slider::new(&mut settings.snap_increment, 0.25..=4.0)
|
||||
.text("Snap step")
|
||||
.suffix(" m"),
|
||||
.text("Snap step"),
|
||||
);
|
||||
}
|
||||
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_mesh, "Meshes");
|
||||
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_gameplay, "Gameplay");
|
||||
ui.checkbox(&mut visualizers.show_actor_icon_volume, "Volumes");
|
||||
@ -721,10 +578,7 @@ fn scene_options_popover(
|
||||
ui.separator();
|
||||
ui.checkbox(&mut visualizers.show_colliders, "Colliders");
|
||||
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_gameplay, "Gameplay markers");
|
||||
ui.checkbox(&mut visualizers.show_volumes, "Volumes");
|
||||
ui.checkbox(
|
||||
&mut visualizers.show_prefab_model_anchors,
|
||||
"Prefab/model anchors",
|
||||
@ -815,74 +669,29 @@ fn viewport_asset_drop_ui(
|
||||
viewport_rect: egui::Rect,
|
||||
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());
|
||||
if !pointer_released {
|
||||
return;
|
||||
}
|
||||
|
||||
let pointer_pos = ui.input(|input| input.pointer.hover_pos());
|
||||
if !pointer_pos.is_some_and(|pos| viewport_rect.contains(pos)) {
|
||||
world.resource_mut::<EditorAssets>().clear_drag();
|
||||
return;
|
||||
}
|
||||
|
||||
let selection = {
|
||||
let assets = world.resource::<EditorAssets>();
|
||||
assets.dragging_selection().cloned()
|
||||
};
|
||||
world.resource_mut::<EditorAssets>().clear_drag();
|
||||
|
||||
let Some(selection) = drag_selection else {
|
||||
let Some(selection) = selection else {
|
||||
return;
|
||||
};
|
||||
let placement = viewport_ground_position(world, pointer_pos.unwrap(), viewport_rect)
|
||||
.map(|pos| snap_translation(pos, world.resource::<ViewportSettings>()))
|
||||
.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!(
|
||||
selection,
|
||||
AssetSelection::SubAsset {
|
||||
@ -893,15 +702,6 @@ fn viewport_asset_drop_ui(
|
||||
place_subasset_operator(world, selection, placement);
|
||||
return;
|
||||
}
|
||||
if matches!(
|
||||
selection,
|
||||
AssetSelection::SubAsset {
|
||||
kind: AssetSubAssetKind::Skeleton,
|
||||
..
|
||||
}
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if let AssetSelection::SubAsset {
|
||||
kind: AssetSubAssetKind::Texture,
|
||||
@ -930,19 +730,6 @@ fn viewport_asset_drop_ui(
|
||||
let Some(asset) = asset else {
|
||||
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 {
|
||||
crate::assets::EditorAssetKind::Texture => {
|
||||
apply_texture_operator(world, asset, selected);
|
||||
@ -957,204 +744,3 @@ fn viewport_asset_drop_ui(
|
||||
|
||||
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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,14 +1,9 @@
|
||||
//! Reusable editor widgets: icon buttons, tool toggles, menu labels.
|
||||
|
||||
use std::hash::Hash;
|
||||
|
||||
use bevy_egui::egui::{self, Button, FontId, RichText, Ui, WidgetText};
|
||||
use egui_phosphor_icons::Icon;
|
||||
|
||||
use super::theme::{
|
||||
ACCENT, ACCENT_HOVER, BORDER, ELEVATED_BG, SELECTION, SELECTION_BG, SUCCESS, TEXT_MUTED,
|
||||
TEXT_SELECTED, WIDGET_BG,
|
||||
};
|
||||
use super::theme::{ACCENT, ACCENT_HOVER, ELEVATED_BG};
|
||||
|
||||
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())))
|
||||
}
|
||||
|
||||
/// 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(
|
||||
ui: &mut Ui,
|
||||
id_salt: impl Hash,
|
||||
icon: Icon,
|
||||
tooltip: &str,
|
||||
accent: bool,
|
||||
) -> egui::Response {
|
||||
const SIZE: f32 = 40.0;
|
||||
const ICON_PX: f32 = 20.0;
|
||||
const SIZE: f32 = 44.0;
|
||||
const ICON_PX: f32 = 22.0;
|
||||
|
||||
let fill = if accent {
|
||||
SUCCESS
|
||||
ACCENT
|
||||
} else {
|
||||
ELEVATED_BG.linear_multiply(0.9)
|
||||
ELEVATED_BG.linear_multiply(0.85)
|
||||
};
|
||||
let (_, rect) = ui.allocate_space(egui::vec2(SIZE, SIZE));
|
||||
let response = ui.interact(rect, ui.make_persistent_id(id_salt), egui::Sense::click());
|
||||
response.widget_info(|| {
|
||||
egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), tooltip)
|
||||
});
|
||||
let fill = if response.is_pointer_button_down_on() {
|
||||
fill.linear_multiply(0.82)
|
||||
} else if response.hovered() {
|
||||
fill.linear_multiply(1.15)
|
||||
} else {
|
||||
fill
|
||||
};
|
||||
let stroke = egui::Stroke::new(
|
||||
if response.hovered() { 1.5 } else { 1.0 },
|
||||
ui.add(
|
||||
Button::new(phosphor_icon(icon, ICON_PX))
|
||||
.fill(fill)
|
||||
.stroke(egui::Stroke::new(
|
||||
1.0,
|
||||
if accent {
|
||||
SUCCESS.linear_multiply(1.15)
|
||||
} else if response.hovered() {
|
||||
TEXT_MUTED
|
||||
ACCENT_HOVER
|
||||
} else {
|
||||
BORDER
|
||||
super::theme::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)
|
||||
))
|
||||
.min_size(egui::vec2(SIZE, SIZE))
|
||||
.corner_radius(5.0),
|
||||
)
|
||||
.on_hover_text(tooltip)
|
||||
}
|
||||
|
||||
pub fn icon_button(ui: &mut Ui, icon: Icon, tooltip: &str) -> egui::Response {
|
||||
ui.add(
|
||||
Button::new(phosphor_icon(icon, 17.0))
|
||||
.fill(WIDGET_BG.linear_multiply(0.78))
|
||||
.stroke(egui::Stroke::new(1.0, BORDER))
|
||||
Button::new(phosphor_icon(icon, 18.0))
|
||||
.frame(false)
|
||||
.min_size(egui::vec2(32.0, 32.0)),
|
||||
)
|
||||
.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 {
|
||||
ui.add(
|
||||
Button::new(phosphor_icon(icon, 16.0))
|
||||
.fill(WIDGET_BG.linear_multiply(0.72))
|
||||
.stroke(egui::Stroke::new(1.0, BORDER))
|
||||
.min_size(egui::vec2(27.0, 27.0))
|
||||
.corner_radius(4.0),
|
||||
.frame(false)
|
||||
.min_size(egui::vec2(22.0, 22.0)),
|
||||
)
|
||||
.on_hover_text(tooltip)
|
||||
}
|
||||
|
||||
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 {
|
||||
selected_fill
|
||||
ACCENT
|
||||
} else {
|
||||
WIDGET_BG.linear_multiply(0.82)
|
||||
ELEVATED_BG.linear_multiply(0.6)
|
||||
};
|
||||
ui.add(
|
||||
Button::new(phosphor_icon(icon, 17.0))
|
||||
Button::new(phosphor_icon(icon, 16.0))
|
||||
.fill(fill)
|
||||
.stroke(egui::Stroke::new(
|
||||
1.0,
|
||||
if selected { selected_stroke } else { BORDER },
|
||||
if selected {
|
||||
ACCENT
|
||||
} else {
|
||||
super::theme::BORDER
|
||||
},
|
||||
))
|
||||
.min_size(egui::vec2(30.0, 30.0))
|
||||
.corner_radius(4.0),
|
||||
.min_size(egui::vec2(26.0, 26.0))
|
||||
.corner_radius(3.0),
|
||||
)
|
||||
.on_hover_text(tooltip)
|
||||
}
|
||||
|
||||
@ -14,15 +14,14 @@ use bevy::render::render_resource::{
|
||||
};
|
||||
use bevy::shader::ShaderRef;
|
||||
use shared::{
|
||||
ActorKind, AudioListenerDesc, AudioSourceDesc, AuthoringLightKind, ColliderDesc, LevelObject,
|
||||
LightDesc, ModelRef, ObjectiveMarker, PhysicsBody, PlayerSpawn, PostProcessVolumeDesc,
|
||||
PrefabInstance, PrefabRef, Primitive, RaytracingExcluded, StaticMeshRenderer, TeamSpawn,
|
||||
TriggerVolume, WeaponSpawn,
|
||||
ActorKind, AuthoringLightKind, ColliderDesc, LevelObject, LightDesc, ModelRef, ObjectiveMarker,
|
||||
PhysicsBody, PlayerSpawn, PostProcessVolumeDesc, PrefabInstance, PrefabRef, Primitive,
|
||||
RaytracingExcluded, StaticMeshRenderer, TeamSpawn, TriggerVolume, WeaponSpawn,
|
||||
};
|
||||
|
||||
use crate::camera::EditorCamera;
|
||||
use crate::infra::EditorOnly;
|
||||
use crate::state::{EditorMode, PlayPossession};
|
||||
use crate::state::scene_tools_active;
|
||||
use crate::ui::UiState;
|
||||
use crate::viewport::ViewportDisplayMode;
|
||||
use crate::visualizers::EditorVisualizationSettings;
|
||||
@ -168,8 +167,6 @@ enum ActorIconImage {
|
||||
LightPoint,
|
||||
LightSpot,
|
||||
LightDirectional,
|
||||
AudioSource,
|
||||
AudioListener,
|
||||
PhysicsCollider,
|
||||
GameSpawn,
|
||||
GameTrigger,
|
||||
@ -183,7 +180,6 @@ enum ActorIconCategory {
|
||||
Actor,
|
||||
Mesh,
|
||||
Light,
|
||||
Audio,
|
||||
Physics,
|
||||
Gameplay,
|
||||
Volume,
|
||||
@ -212,8 +208,6 @@ struct ActorIconComponents {
|
||||
has_post_process_volume: bool,
|
||||
has_team_spawn: bool,
|
||||
has_objective: bool,
|
||||
has_audio_source: bool,
|
||||
has_audio_listener: bool,
|
||||
has_physics: bool,
|
||||
}
|
||||
|
||||
@ -238,7 +232,7 @@ impl Plugin for ActorIconsPlugin {
|
||||
app.init_resource::<ActorIconSettings>()
|
||||
.add_plugins(MaterialPlugin::<ActorIconOverlayMaterial>::default())
|
||||
.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::LightSpot, ICON_10_LIGHT_SPOT),
|
||||
(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::GameSpawn, ICON_21_GAME_SPAWN),
|
||||
(ActorIconImage::GameTrigger, ICON_22_GAME_TRIGGER),
|
||||
@ -351,8 +343,6 @@ fn init_actor_icon_assets(
|
||||
|
||||
#[allow(clippy::type_complexity, clippy::too_many_arguments)]
|
||||
fn sync_actor_icons(
|
||||
mode: Res<State<EditorMode>>,
|
||||
possession: Res<PlayPossession>,
|
||||
display: Res<ViewportDisplayMode>,
|
||||
settings: Res<EditorVisualizationSettings>,
|
||||
icon_settings: Res<ActorIconSettings>,
|
||||
@ -386,14 +376,11 @@ fn sync_actor_icons(
|
||||
),
|
||||
(With<LevelObject>, Without<EditorOnly>),
|
||||
>,
|
||||
audio: Query<(Has<AudioSourceDesc>, Has<AudioListenerDesc>)>,
|
||||
physics: Query<(), Or<(With<PhysicsBody>, With<ColliderDesc>)>>,
|
||||
) {
|
||||
let camera = active_editor_camera(&camera_query);
|
||||
let mut desired = HashMap::new();
|
||||
let tools_active = *mode.get() == EditorMode::Editing
|
||||
|| (*mode.get() == EditorMode::Playing && *possession == PlayPossession::Ejected);
|
||||
if tools_active && !display.clean_game_view && settings.enabled && settings.show_actor_icons {
|
||||
if !display.clean_game_view && settings.enabled && settings.show_actor_icons {
|
||||
for (
|
||||
target,
|
||||
global,
|
||||
@ -412,7 +399,6 @@ fn sync_actor_icons(
|
||||
has_objective,
|
||||
) in &actor_roots
|
||||
{
|
||||
let (has_audio_source, has_audio_listener) = audio.get(target).unwrap_or_default();
|
||||
let components = ActorIconComponents {
|
||||
kind: kind.copied(),
|
||||
light_kind: light.map(|light| light.kind),
|
||||
@ -428,8 +414,6 @@ fn sync_actor_icons(
|
||||
has_post_process_volume,
|
||||
has_team_spawn,
|
||||
has_objective,
|
||||
has_audio_source,
|
||||
has_audio_listener,
|
||||
has_physics: physics.contains(target),
|
||||
};
|
||||
let spec = icon_for_actor_components(components);
|
||||
@ -482,7 +466,6 @@ fn sync_actor_icons(
|
||||
if existing_targets.contains(&target) {
|
||||
continue;
|
||||
}
|
||||
let (has_audio_source, has_audio_listener) = audio.get(target).unwrap_or_default();
|
||||
let components = ActorIconComponents {
|
||||
kind: kind.copied(),
|
||||
light_kind: light.map(|light| light.kind),
|
||||
@ -498,8 +481,6 @@ fn sync_actor_icons(
|
||||
has_post_process_volume,
|
||||
has_team_spawn,
|
||||
has_objective,
|
||||
has_audio_source,
|
||||
has_audio_listener,
|
||||
has_physics: physics.contains(target),
|
||||
};
|
||||
let spec = icon_for_actor_components(components);
|
||||
@ -597,7 +578,6 @@ fn icon_category_enabled(
|
||||
ActorIconCategory::Actor => settings.show_actor_icon_actor,
|
||||
ActorIconCategory::Mesh => settings.show_actor_icon_mesh,
|
||||
ActorIconCategory::Light => settings.show_actor_icon_light,
|
||||
ActorIconCategory::Audio => settings.show_actor_icon_audio,
|
||||
ActorIconCategory::Physics => settings.show_actor_icon_physics,
|
||||
ActorIconCategory::Gameplay => settings.show_actor_icon_gameplay,
|
||||
ActorIconCategory::Volume => settings.show_actor_icon_volume,
|
||||
@ -732,26 +712,6 @@ fn icon_for_actor_components(components: ActorIconComponents) -> ActorIconSpec {
|
||||
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 => {
|
||||
if components.has_physics {
|
||||
ActorIconSpec {
|
||||
@ -807,45 +767,6 @@ mod tests {
|
||||
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]
|
||||
fn perspective_icon_size_uses_view_depth() {
|
||||
let camera = GlobalTransform::IDENTITY;
|
||||
|
||||
@ -8,7 +8,6 @@ use shared::{
|
||||
};
|
||||
|
||||
use crate::history::apply_brush_csg_with_history;
|
||||
use crate::operators::{ActiveOperator, OperatorPhase, OperatorStatus};
|
||||
use crate::scene_io::SceneIo;
|
||||
use crate::ui::UiState;
|
||||
|
||||
@ -190,13 +189,14 @@ fn run_bounds_csg(world: &mut World, op: BrushCsgOp) {
|
||||
selected,
|
||||
result,
|
||||
});
|
||||
let hint = match op {
|
||||
set_status(
|
||||
world,
|
||||
match op {
|
||||
BrushCsgOp::Intersect => "Brush intersect preview: Enter commits, Esc cancels",
|
||||
BrushCsgOp::Merge => "Brush convex merge preview: Enter commits, Esc cancels",
|
||||
BrushCsgOp::Subtract => "Brush subtract preview: Enter commits, Esc cancels",
|
||||
};
|
||||
set_status(world, hint);
|
||||
set_csg_operator_status(world, OperatorPhase::Preview, hint);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
fn brush_csg_preview_input(world: &mut World) {
|
||||
@ -210,7 +210,14 @@ fn brush_csg_preview_input(world: &mut World) {
|
||||
return;
|
||||
}
|
||||
if escape {
|
||||
cancel_csg_preview(world);
|
||||
if world
|
||||
.resource_mut::<BrushCsgPreview>()
|
||||
.pending
|
||||
.take()
|
||||
.is_some()
|
||||
{
|
||||
set_status(world, "Brush CSG preview canceled");
|
||||
}
|
||||
return;
|
||||
}
|
||||
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);
|
||||
}
|
||||
|
||||
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) {
|
||||
let Some(primary) = pending.selected.first().copied() else {
|
||||
set_status(world, "Brush CSG commit failed: missing primary brush");
|
||||
set_csg_operator_status(
|
||||
world,
|
||||
OperatorPhase::Blocked,
|
||||
"Commit failed: missing primary brush",
|
||||
);
|
||||
return;
|
||||
};
|
||||
if world.get::<BrushDesc>(primary).is_none() {
|
||||
@ -246,11 +236,6 @@ fn commit_csg_preview(world: &mut World, pending: PendingBrushCsg) {
|
||||
world,
|
||||
"Brush CSG commit failed: primary brush no longer exists",
|
||||
);
|
||||
set_csg_operator_status(
|
||||
world,
|
||||
OperatorPhase::Blocked,
|
||||
"Commit failed: primary brush no longer exists",
|
||||
);
|
||||
return;
|
||||
}
|
||||
let new_brush = BrushDesc::cuboid(pending.result.size());
|
||||
@ -266,13 +251,14 @@ fn commit_csg_preview(world: &mut World, pending: PendingBrushCsg) {
|
||||
Vec::new()
|
||||
};
|
||||
apply_bounds_result(world, primary, pending.result, new_brush, &to_delete);
|
||||
let hint = match pending.op {
|
||||
set_status(
|
||||
world,
|
||||
match pending.op {
|
||||
BrushCsgOp::Intersect => "Brush intersect committed",
|
||||
BrushCsgOp::Merge => "Brush convex merge committed",
|
||||
BrushCsgOp::Subtract => "Brush subtract committed",
|
||||
};
|
||||
set_status(world, hint);
|
||||
set_csg_operator_status(world, OperatorPhase::Committed, hint);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
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)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::history::{EditorCommand, EditorHistory};
|
||||
use crate::operators::test_harness::{assert_undo_redo_round_trip, OperatorInvariantHarness};
|
||||
use crate::selection::SelectedEntity;
|
||||
use crate::history::EditorCommand;
|
||||
|
||||
fn bounds(min: Vec3, max: Vec3) -> BrushBounds {
|
||||
BrushBounds { min, max }
|
||||
@ -460,62 +432,4 @@ mod tests {
|
||||
|
||||
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()
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@ -248,15 +248,10 @@ fn sync_brush_element_gizmo(
|
||||
mode: Res<BrushEditMode>,
|
||||
selection: Res<BrushElementSelection>,
|
||||
mut state: ResMut<BrushElementGizmoState>,
|
||||
mut brushes: Query<(&mut BrushDesc, &GlobalTransform)>,
|
||||
brushes: Query<(&BrushDesc, &GlobalTransform)>,
|
||||
mut gizmos: Query<&mut Transform, With<BrushElementGizmo>>,
|
||||
) {
|
||||
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);
|
||||
return;
|
||||
}
|
||||
@ -432,19 +427,6 @@ fn apply_brush_element_gizmo(
|
||||
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) {
|
||||
if let Some(entity) = state.entity.take() {
|
||||
if let Ok(mut entity_mut) = commands.get_entity(entity) {
|
||||
@ -605,14 +587,13 @@ fn draw_brush_elements(
|
||||
gizmos: &mut Gizmos,
|
||||
) {
|
||||
let base_color = match mode {
|
||||
BrushEditMode::Vertex => Color::srgb(0.31, 0.75, 0.88),
|
||||
BrushEditMode::Edge => Color::srgb(0.95, 0.62, 0.2),
|
||||
BrushEditMode::Face => Color::srgb(0.32, 0.82, 0.52),
|
||||
BrushEditMode::Clip => Color::srgb(0.86, 0.42, 0.92),
|
||||
BrushEditMode::Vertex => Color::srgb(0.2, 0.72, 1.0),
|
||||
BrushEditMode::Edge => Color::srgb(1.0, 0.68, 0.24),
|
||||
BrushEditMode::Face => Color::srgb(0.24, 1.0, 0.56),
|
||||
BrushEditMode::Clip => Color::srgb(0.92, 0.36, 1.0),
|
||||
BrushEditMode::Object => Color::WHITE,
|
||||
}
|
||||
.with_alpha(if active { 0.9 } else { 0.38 });
|
||||
let selected_color = Color::srgb(1.0, 0.76, 0.34);
|
||||
};
|
||||
let selected_color = Color::srgb(1.0, 0.95, 0.2);
|
||||
for face in &brush.faces {
|
||||
let vertices = world_vertices(face, transform);
|
||||
if vertices.is_empty() {
|
||||
@ -632,29 +613,8 @@ fn draw_brush_elements(
|
||||
} else {
|
||||
base_color
|
||||
};
|
||||
gizmos.sphere(
|
||||
face_center,
|
||||
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
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
gizmos.sphere(face_center, 0.08, color);
|
||||
gizmos.line(face_center, face_center + normal * 0.55, color);
|
||||
}
|
||||
for index in 0..vertices.len() {
|
||||
let next = (index + 1) % vertices.len();
|
||||
@ -668,15 +628,7 @@ fn draw_brush_elements(
|
||||
} else {
|
||||
base_color
|
||||
};
|
||||
gizmos.sphere(
|
||||
vertices[index],
|
||||
if active && selected.contains(&key) {
|
||||
0.1
|
||||
} else {
|
||||
0.055
|
||||
},
|
||||
color,
|
||||
);
|
||||
gizmos.sphere(vertices[index], 0.06, color);
|
||||
}
|
||||
if matches!(mode, BrushEditMode::Edge | BrushEditMode::Clip) {
|
||||
let key = BrushElementKey::Edge {
|
||||
@ -689,10 +641,6 @@ fn draw_brush_elements(
|
||||
base_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)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::history::EditorHistory;
|
||||
use crate::operators::test_harness::{assert_undo_redo_round_trip, OperatorInvariantHarness};
|
||||
use crate::scene_io::SceneIo;
|
||||
use crate::selection::SelectedEntity;
|
||||
use shared::{BrushDesc, LevelObject};
|
||||
use shared::BrushDesc;
|
||||
|
||||
#[test]
|
||||
fn point_inside_convex_polygon_is_detected() {
|
||||
@ -1214,87 +1158,4 @@ mod tests {
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -380,9 +380,6 @@ fn brush_snapshot_from_convex_points(
|
||||
collider: None,
|
||||
physics: None,
|
||||
light: None,
|
||||
animation_controller: None,
|
||||
audio_source: None,
|
||||
audio_listener: None,
|
||||
player_spawn: false,
|
||||
model: None,
|
||||
prefab: None,
|
||||
@ -535,10 +532,6 @@ fn set_brush_tool_status(
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
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]
|
||||
fn brush_snapshot_centers_polygon_points() {
|
||||
@ -634,69 +627,4 @@ mod tests {
|
||||
assert!(status.contains("4.2m"));
|
||||
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()
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,10 +1,10 @@
|
||||
use bevy::math::Rect;
|
||||
use bevy::prelude::*;
|
||||
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::viewport::{ViewportDisplayMode, ViewportSettings};
|
||||
use crate::viewport::ViewportDisplayMode;
|
||||
|
||||
#[derive(Resource, Default, Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum EditorGizmoMode {
|
||||
@ -27,7 +27,6 @@ impl Plugin for EditorGizmoPlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
app.init_resource::<EditorGizmoMode>()
|
||||
.init_resource::<EditorGizmoSpace>()
|
||||
.add_systems(Startup, configure_gizmo_visuals)
|
||||
.add_systems(
|
||||
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(
|
||||
keys: Res<ButtonInput<KeyCode>>,
|
||||
buttons: Res<ButtonInput<MouseButton>>,
|
||||
@ -111,10 +97,9 @@ fn sync_gizmo_viewport_rect(
|
||||
fn sync_gizmo_options(
|
||||
mode: Res<EditorGizmoMode>,
|
||||
space: Res<EditorGizmoSpace>,
|
||||
settings: Res<ViewportSettings>,
|
||||
mut options: ResMut<GizmoOptions>,
|
||||
) {
|
||||
if !mode.is_changed() && !space.is_changed() && !settings.is_changed() {
|
||||
if !mode.is_changed() && !space.is_changed() {
|
||||
return;
|
||||
}
|
||||
|
||||
@ -127,7 +112,5 @@ fn sync_gizmo_options(
|
||||
EditorGizmoSpace::World => GizmoOrientation::Global,
|
||||
EditorGizmoSpace::Local => GizmoOrientation::Local,
|
||||
};
|
||||
options.snapping = settings.snap_translate;
|
||||
options.snap_distance = settings.snap_increment.max(0.01);
|
||||
options.mode_override = None;
|
||||
}
|
||||
|
||||
@ -269,42 +269,26 @@ fn draw_viewport_grid(
|
||||
return;
|
||||
}
|
||||
|
||||
let step = settings.snap_increment.max(0.25);
|
||||
let extent = 40.0;
|
||||
let divisions = (extent / step).ceil() as i32;
|
||||
for index in -divisions..=divisions {
|
||||
let coordinate = index as f32 * step;
|
||||
if coordinate.abs() > extent || index == 0 {
|
||||
continue;
|
||||
let step = settings.snap_increment.max(0.5);
|
||||
let extent = 32.0;
|
||||
let mut x = -extent;
|
||||
while x <= extent {
|
||||
gizmos.line(
|
||||
Vec3::new(x, 0.0, -extent),
|
||||
Vec3::new(x, 0.0, extent),
|
||||
Color::srgba(0.35, 0.38, 0.42, 0.35),
|
||||
);
|
||||
x += step;
|
||||
}
|
||||
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)
|
||||
};
|
||||
let mut z = -extent;
|
||||
while z <= extent {
|
||||
gizmos.line(
|
||||
Vec3::new(coordinate, 0.0, -extent),
|
||||
Vec3::new(coordinate, 0.0, extent),
|
||||
color,
|
||||
);
|
||||
gizmos.line(
|
||||
Vec3::new(-extent, 0.0, coordinate),
|
||||
Vec3::new(extent, 0.0, coordinate),
|
||||
color,
|
||||
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.
|
||||
|
||||
@ -463,9 +463,6 @@ pub fn spawn_post_process_volume_at_camera(world: &mut World) {
|
||||
collider: None,
|
||||
physics: None,
|
||||
light: None,
|
||||
animation_controller: None,
|
||||
audio_source: None,
|
||||
audio_listener: None,
|
||||
player_spawn: false,
|
||||
model: None,
|
||||
prefab: None,
|
||||
|
||||
@ -2,12 +2,11 @@ use bevy::ecs::system::SystemParam;
|
||||
use bevy::picking::mesh_picking::ray_cast::{MeshRayCast, MeshRayCastSettings, RayCastVisibility};
|
||||
use bevy::picking::pointer::{PointerAction, PointerInput, PointerInteraction};
|
||||
use bevy::prelude::*;
|
||||
use shared::{HydratedPrefabMember, LevelObject};
|
||||
use shared::LevelObject;
|
||||
use transform_gizmo_bevy::prelude::GizmoTarget;
|
||||
|
||||
use crate::camera::EditorCamera;
|
||||
use crate::infra::EditorOnly;
|
||||
use crate::scene_io::ComposedSceneMember;
|
||||
use crate::state::scene_tools_active;
|
||||
use crate::state::EditorMode;
|
||||
use crate::ui::helpers::ensure_player_spawn_for_edit;
|
||||
@ -383,15 +382,7 @@ fn sync_gizmo_targets(
|
||||
mut selected: ResMut<SelectedEntity>,
|
||||
mut commands: Commands,
|
||||
targets: Query<(Entity, Option<&BrushElementGizmo>), With<GizmoTarget>>,
|
||||
level_objects: Query<
|
||||
(),
|
||||
(
|
||||
With<LevelObject>,
|
||||
Without<EditorOnly>,
|
||||
Without<HydratedPrefabMember>,
|
||||
Without<ComposedSceneMember>,
|
||||
),
|
||||
>,
|
||||
level_objects: Query<(), (With<LevelObject>, Without<EditorOnly>)>,
|
||||
editor_only: Query<(), With<EditorOnly>>,
|
||||
transforms: Query<(), With<Transform>>,
|
||||
hierarchy: Option<Res<HierarchyPanelState>>,
|
||||
@ -416,11 +407,11 @@ fn sync_gizmo_targets(
|
||||
if brush_element_gizmo.is_some() {
|
||||
continue;
|
||||
}
|
||||
if !ui_state.selected_entities.contains(entity) || !level_objects.contains(entity) {
|
||||
if !ui_state.selected_entities.contains(entity) {
|
||||
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)
|
||||
&& transforms.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());
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 bevy::asset::{load_internal_asset, uuid_handle};
|
||||
@ -21,10 +21,10 @@ use crate::viewport::ViewportDisplayMode;
|
||||
const SELECTION_OUTLINE_SHADER: Handle<Shader> =
|
||||
uuid_handle!("c4e8f1a2-9b3d-4e7c-a1f5-6d2b8e0c4a91");
|
||||
|
||||
const PRIMARY_VISIBLE_COLOR: Color = Color::srgba(1.0, 0.72, 0.28, 0.56);
|
||||
const PRIMARY_OCCLUDED_COLOR: Color = Color::srgba(1.0, 0.66, 0.22, 0.28);
|
||||
const SECONDARY_VISIBLE_COLOR: Color = Color::srgba(0.31, 0.75, 0.88, 0.42);
|
||||
const SECONDARY_OCCLUDED_COLOR: Color = Color::srgba(0.31, 0.68, 0.82, 0.2);
|
||||
/// Subtle amber rim on visible faces.
|
||||
const VISIBLE_COLOR: Color = Color::srgba(1.0, 0.82, 0.35, 0.22);
|
||||
/// Soft cyan through occluders.
|
||||
const OCCLUDED_COLOR: Color = Color::srgba(0.35, 0.7, 0.95, 0.15);
|
||||
const BOUNDS_PADDING: f32 = 1.06;
|
||||
|
||||
#[derive(Clone, Copy, ShaderType)]
|
||||
@ -50,8 +50,8 @@ pub struct SelectionOutlineOccludedMaterial {
|
||||
fn outline_uniform(color: Color, pass_kind: u32) -> OutlineUniform {
|
||||
OutlineUniform {
|
||||
color: color.into(),
|
||||
rim_power: 5.5,
|
||||
rim_mix: 0.9,
|
||||
rim_power: 10.5,
|
||||
rim_mix: 0.35,
|
||||
pass_kind,
|
||||
}
|
||||
}
|
||||
@ -106,25 +106,16 @@ enum SelectionOutlinePass {
|
||||
Occluded,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
|
||||
enum SelectionOutlineRole {
|
||||
Primary,
|
||||
Secondary,
|
||||
}
|
||||
|
||||
#[derive(Component)]
|
||||
pub(crate) struct SelectionOutlineShell {
|
||||
root: Entity,
|
||||
pass: SelectionOutlinePass,
|
||||
role: SelectionOutlineRole,
|
||||
}
|
||||
|
||||
#[derive(Resource)]
|
||||
struct SelectionOutlineMaterials {
|
||||
primary_occluded: Handle<SelectionOutlineOccludedMaterial>,
|
||||
primary_visible: Handle<SelectionOutlineVisibleMaterial>,
|
||||
secondary_occluded: Handle<SelectionOutlineOccludedMaterial>,
|
||||
secondary_visible: Handle<SelectionOutlineVisibleMaterial>,
|
||||
occluded: Handle<SelectionOutlineOccludedMaterial>,
|
||||
visible: Handle<SelectionOutlineVisibleMaterial>,
|
||||
}
|
||||
|
||||
#[derive(Resource)]
|
||||
@ -161,12 +152,7 @@ impl Plugin for SelectionOutlinePlugin {
|
||||
)
|
||||
.add_systems(
|
||||
Update,
|
||||
(
|
||||
sync_selection_outline_meshes,
|
||||
draw_selection_corner_brackets,
|
||||
)
|
||||
.chain()
|
||||
.run_if(scene_tools_active),
|
||||
sync_selection_outline_meshes.run_if(scene_tools_active),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -183,17 +169,11 @@ fn init_selection_outline_materials(
|
||||
mut commands: Commands,
|
||||
) {
|
||||
commands.insert_resource(SelectionOutlineMaterials {
|
||||
primary_visible: visible.add(SelectionOutlineVisibleMaterial {
|
||||
uniform: outline_uniform(PRIMARY_VISIBLE_COLOR, 0),
|
||||
visible: visible.add(SelectionOutlineVisibleMaterial {
|
||||
uniform: outline_uniform(VISIBLE_COLOR, 0),
|
||||
}),
|
||||
primary_occluded: occluded.add(SelectionOutlineOccludedMaterial {
|
||||
uniform: outline_uniform(PRIMARY_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),
|
||||
occluded: occluded.add(SelectionOutlineOccludedMaterial {
|
||||
uniform: outline_uniform(OCCLUDED_COLOR, 1),
|
||||
}),
|
||||
});
|
||||
}
|
||||
@ -205,7 +185,7 @@ fn sync_selection_outline_meshes(
|
||||
materials: Res<SelectionOutlineMaterials>,
|
||||
unit_box: Res<SelectionOutlineUnitBox>,
|
||||
mut commands: Commands,
|
||||
mut shells: Query<(Entity, &SelectionOutlineShell, &mut Transform)>,
|
||||
shells: Query<(Entity, &SelectionOutlineShell)>,
|
||||
children: Query<&Children>,
|
||||
mesh_entities: Query<(Entity, &Mesh3d), With<Mesh3d>>,
|
||||
globals: Query<&GlobalTransform>,
|
||||
@ -215,14 +195,16 @@ fn sync_selection_outline_meshes(
|
||||
colliders: Query<&ColliderDesc>,
|
||||
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<
|
||||
(Entity, SelectionOutlinePass, SelectionOutlineRole),
|
||||
SelectionBounds,
|
||||
> = std::collections::HashMap::new();
|
||||
let selected: std::collections::HashSet<Entity> = ui_state.selected_entities.iter().collect();
|
||||
|
||||
let mut desired: std::collections::HashMap<(Entity, SelectionOutlinePass), SelectionBounds> =
|
||||
std::collections::HashMap::new();
|
||||
if !display.clean_game_view {
|
||||
for (index, &root) in selected.iter().enumerate() {
|
||||
for &root in &selected {
|
||||
if !level_objects.contains(root) {
|
||||
continue;
|
||||
}
|
||||
@ -241,30 +223,24 @@ fn sync_selection_outline_meshes(
|
||||
if bounds.half_extents.max_element() <= f32::EPSILON {
|
||||
continue;
|
||||
}
|
||||
let role = if index == 0 {
|
||||
SelectionOutlineRole::Primary
|
||||
} else {
|
||||
SelectionOutlineRole::Secondary
|
||||
};
|
||||
desired.insert((root, SelectionOutlinePass::Occluded, role), bounds);
|
||||
desired.insert((root, SelectionOutlinePass::Visible, role), bounds);
|
||||
desired.insert((root, SelectionOutlinePass::Occluded), bounds);
|
||||
desired.insert((root, SelectionOutlinePass::Visible), bounds);
|
||||
}
|
||||
}
|
||||
|
||||
let mut existing = std::collections::HashSet::new();
|
||||
for (shell_entity, shell, mut transform) in &mut shells {
|
||||
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 {
|
||||
for (shell_entity, shell) in &shells {
|
||||
if !desired.contains_key(&(shell.root, shell.pass)) {
|
||||
commands.entity(shell_entity).despawn();
|
||||
}
|
||||
}
|
||||
|
||||
for ((root, pass, role), bounds) in desired {
|
||||
if existing.contains(&(root, pass, role)) {
|
||||
let existing: std::collections::HashSet<(Entity, SelectionOutlinePass)> = shells
|
||||
.iter()
|
||||
.map(|(_, shell)| (shell.root, shell.pass))
|
||||
.collect();
|
||||
|
||||
for ((root, pass), bounds) in desired {
|
||||
if existing.contains(&(root, pass)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@ -272,7 +248,7 @@ fn sync_selection_outline_meshes(
|
||||
EditorOnly,
|
||||
RaytracingExcluded,
|
||||
Name::new("Selection Outline"),
|
||||
SelectionOutlineShell { root, pass, role },
|
||||
SelectionOutlineShell { root, pass },
|
||||
Mesh3d(unit_box.0.clone()),
|
||||
Transform {
|
||||
translation: bounds.center,
|
||||
@ -283,72 +259,13 @@ fn sync_selection_outline_meshes(
|
||||
ChildOf(root),
|
||||
));
|
||||
|
||||
match (role, pass) {
|
||||
(SelectionOutlineRole::Primary, SelectionOutlinePass::Occluded) => {
|
||||
entity_commands.insert(MeshMaterial3d(materials.primary_occluded.clone()));
|
||||
match pass {
|
||||
SelectionOutlinePass::Occluded => {
|
||||
entity_commands.insert(MeshMaterial3d(materials.occluded.clone()));
|
||||
}
|
||||
(SelectionOutlineRole::Primary, SelectionOutlinePass::Visible) => {
|
||||
entity_commands.insert(MeshMaterial3d(materials.primary_visible.clone()));
|
||||
SelectionOutlinePass::Visible => {
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,20 +1,18 @@
|
||||
//! Editor-side scene visualizers for authoring/runtime entities that have no mesh.
|
||||
|
||||
use avian3d::prelude::ColliderConstructor;
|
||||
use bevy::ecs::system::SystemParam;
|
||||
use bevy::prelude::*;
|
||||
use shared::{
|
||||
AudioListenerDesc, AudioSourceDesc, AuthoringLightKind, ColliderDesc, ColliderShapeDesc,
|
||||
LevelObject, LightDesc, ModelRef, ObjectiveMarker, PhysicsBody, PlayerSpawn,
|
||||
PostProcessVolumeDesc, PrefabRef, ProjectSun, RaytracingExcluded, TeamSpawn, TriggerVolume,
|
||||
WeaponSpawn,
|
||||
AuthoringLightKind, ColliderDesc, ColliderShapeDesc, LevelObject, LightDesc, ModelRef,
|
||||
ObjectiveMarker, PhysicsBody, PlayerSpawn, PostProcessVolumeDesc, PrefabRef, ProjectSun,
|
||||
RaytracingExcluded, TeamSpawn, TriggerVolume, WeaponSpawn,
|
||||
};
|
||||
use sim::tuning::{EYE_OFFSET_STAND, PLAYER_LENGTH, PLAYER_RADIUS};
|
||||
use sim::{Player, PlayerCamera};
|
||||
|
||||
use crate::infra::EditorOnly;
|
||||
use crate::selection::SelectedEntity;
|
||||
use crate::state::{scene_tools_active, EditorMode, PlayPossession};
|
||||
use crate::state::scene_tools_active;
|
||||
use crate::viewport::{EditorViewportMode, ViewportDisplayMode};
|
||||
|
||||
#[derive(Resource, Debug, Clone)]
|
||||
@ -24,7 +22,6 @@ pub struct EditorVisualizationSettings {
|
||||
pub show_actor_icon_actor: bool,
|
||||
pub show_actor_icon_mesh: bool,
|
||||
pub show_actor_icon_light: bool,
|
||||
pub show_actor_icon_audio: bool,
|
||||
pub show_actor_icon_physics: bool,
|
||||
pub show_actor_icon_gameplay: bool,
|
||||
pub show_actor_icon_volume: bool,
|
||||
@ -32,10 +29,7 @@ pub struct EditorVisualizationSettings {
|
||||
pub show_actor_icon_debug: bool,
|
||||
pub show_colliders: bool,
|
||||
pub show_lights: bool,
|
||||
pub show_audio: bool,
|
||||
pub show_spawns: bool,
|
||||
pub show_gameplay: bool,
|
||||
pub show_volumes: bool,
|
||||
pub show_prefab_model_anchors: bool,
|
||||
pub show_runtime: bool,
|
||||
pub pickable_proxies: bool,
|
||||
@ -49,7 +43,6 @@ impl Default for EditorVisualizationSettings {
|
||||
show_actor_icon_actor: true,
|
||||
show_actor_icon_mesh: true,
|
||||
show_actor_icon_light: true,
|
||||
show_actor_icon_audio: true,
|
||||
show_actor_icon_physics: true,
|
||||
show_actor_icon_gameplay: true,
|
||||
show_actor_icon_volume: true,
|
||||
@ -57,10 +50,7 @@ impl Default for EditorVisualizationSettings {
|
||||
show_actor_icon_debug: true,
|
||||
show_colliders: true,
|
||||
show_lights: true,
|
||||
show_audio: true,
|
||||
show_spawns: true,
|
||||
show_gameplay: true,
|
||||
show_volumes: true,
|
||||
show_prefab_model_anchors: true,
|
||||
show_runtime: true,
|
||||
pickable_proxies: true,
|
||||
@ -92,39 +82,17 @@ struct VisualizerProxyAssets {
|
||||
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;
|
||||
|
||||
impl Plugin for EditorVisualizersPlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
app.init_resource::<EditorVisualizationSettings>()
|
||||
.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(
|
||||
Update,
|
||||
(
|
||||
draw_visualizers,
|
||||
draw_audio_visualizers,
|
||||
draw_split_collider_visualizers,
|
||||
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 {
|
||||
!display.clean_game_view
|
||||
}
|
||||
@ -275,80 +125,73 @@ fn init_visualizer_proxy_assets(
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn sync_visualizer_proxies(
|
||||
mode: Res<State<EditorMode>>,
|
||||
possession: Res<PlayPossession>,
|
||||
viewport_mode: Res<EditorViewportMode>,
|
||||
display: Res<ViewportDisplayMode>,
|
||||
settings: Res<EditorVisualizationSettings>,
|
||||
assets: Res<VisualizerProxyAssets>,
|
||||
mut commands: Commands,
|
||||
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 collider_mode = *viewport_mode == EditorViewportMode::Collider;
|
||||
let tools_active = *mode.get() == EditorMode::Editing
|
||||
|| (*mode.get() == EditorMode::Playing && *possession == PlayPossession::Ejected);
|
||||
let proxies_active = tools_active
|
||||
&& !display.clean_game_view
|
||||
let proxies_active = !display.clean_game_view
|
||||
&& settings.enabled
|
||||
&& (settings.pickable_proxies || collider_mode);
|
||||
|
||||
if proxies_active {
|
||||
if settings.show_colliders || collider_mode {
|
||||
desired.extend(
|
||||
targets
|
||||
.colliders
|
||||
colliders
|
||||
.iter()
|
||||
.map(|target| (target, VisualizerKind::Collider)),
|
||||
);
|
||||
}
|
||||
if settings.show_lights && !collider_mode {
|
||||
desired.extend(
|
||||
targets
|
||||
.lights
|
||||
.iter()
|
||||
.map(|target| (target, VisualizerKind::Light)),
|
||||
);
|
||||
desired.extend(lights.iter().map(|target| (target, VisualizerKind::Light)));
|
||||
}
|
||||
if settings.show_spawns && !collider_mode {
|
||||
desired.extend(
|
||||
targets
|
||||
.spawns
|
||||
spawns
|
||||
.iter()
|
||||
.map(|target| (target, VisualizerKind::PlayerSpawn)),
|
||||
);
|
||||
}
|
||||
if settings.show_prefab_model_anchors && !collider_mode {
|
||||
desired.extend(
|
||||
targets
|
||||
.prefabs
|
||||
prefabs
|
||||
.iter()
|
||||
.map(|target| (target, VisualizerKind::Prefab)),
|
||||
);
|
||||
desired.extend(
|
||||
targets
|
||||
.models
|
||||
.iter()
|
||||
.map(|target| (target, VisualizerKind::Model)),
|
||||
);
|
||||
desired.extend(models.iter().map(|target| (target, VisualizerKind::Model)));
|
||||
}
|
||||
if settings.show_runtime && !collider_mode {
|
||||
desired.extend(
|
||||
targets
|
||||
.players
|
||||
players
|
||||
.iter()
|
||||
.map(|target| (target, VisualizerKind::Player)),
|
||||
);
|
||||
desired.extend(
|
||||
targets
|
||||
.cameras
|
||||
cameras
|
||||
.iter()
|
||||
.map(|target| (target, VisualizerKind::Camera)),
|
||||
);
|
||||
desired.extend(
|
||||
targets
|
||||
.project_suns
|
||||
project_suns
|
||||
.iter()
|
||||
.map(|target| (target, VisualizerKind::ProjectSun)),
|
||||
);
|
||||
@ -361,17 +204,15 @@ fn sync_visualizer_proxies(
|
||||
.collect();
|
||||
|
||||
for (entity, proxy, mut transform) in &mut proxies {
|
||||
if !desired.contains(&(proxy.target, proxy.kind))
|
||||
|| targets.transforms.get(proxy.target).is_err()
|
||||
{
|
||||
if !desired.contains(&(proxy.target, proxy.kind)) || transforms.get(proxy.target).is_err() {
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
for (target, kind) in desired {
|
||||
let Ok(global) = targets.transforms.get(target) else {
|
||||
let Ok(global) = transforms.get(target) else {
|
||||
continue;
|
||||
};
|
||||
if existing.contains(&(target, kind)) {
|
||||
@ -498,16 +339,15 @@ fn draw_visualizers(
|
||||
}
|
||||
}
|
||||
|
||||
if settings.show_gameplay && !collider_mode {
|
||||
for (entity, trigger, global) in &triggers {
|
||||
draw_oriented_box(
|
||||
&mut gizmos,
|
||||
global,
|
||||
trigger.half_extents,
|
||||
if selected.0 == Some(entity) {
|
||||
Color::srgba(1.0, 0.55, 0.95, 0.95)
|
||||
Color::srgba(0.95, 0.35, 0.95, 0.85)
|
||||
} else {
|
||||
Color::srgba(0.9, 0.3, 0.9, 0.42)
|
||||
Color::srgba(0.9, 0.3, 0.9, 0.45)
|
||||
},
|
||||
);
|
||||
}
|
||||
@ -536,7 +376,6 @@ fn draw_visualizers(
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn draw_split_collider_visualizers(
|
||||
viewport_mode: Res<EditorViewportMode>,
|
||||
@ -574,8 +413,7 @@ fn draw_post_process_volume_gizmos(
|
||||
mut gizmos: Gizmos,
|
||||
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;
|
||||
}
|
||||
for (entity, volume, global) in &volumes {
|
||||
@ -586,7 +424,7 @@ fn draw_post_process_volume_gizmos(
|
||||
global,
|
||||
volume.half_extents,
|
||||
if selected.0 == Some(entity) {
|
||||
Color::srgba(1.0, 0.76, 0.34, 1.0)
|
||||
border.with_alpha(1.0)
|
||||
} else {
|
||||
border.with_alpha(0.55)
|
||||
},
|
||||
@ -728,20 +566,12 @@ fn draw_light(
|
||||
let up = global.up().as_vec3();
|
||||
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 inner_radius = range * (light.inner_angle_deg.to_radians() * 0.5).tan().max(0.08);
|
||||
for offset in [right, -right, up, -up] {
|
||||
gizmos.line(center, end + offset * radius, color);
|
||||
}
|
||||
gizmos
|
||||
.circle(Isometry3d::new(end, global.rotation()), radius, color)
|
||||
.resolution(24);
|
||||
gizmos
|
||||
.circle(
|
||||
Isometry3d::new(end, global.rotation()),
|
||||
inner_radius,
|
||||
color.with_alpha(0.38),
|
||||
)
|
||||
.resolution(24);
|
||||
if emphasize {
|
||||
gizmos
|
||||
.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) {
|
||||
let center = global.translation();
|
||||
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 {
|
||||
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 right = global.right().as_vec3();
|
||||
let up = global.up().as_vec3();
|
||||
let near_center = origin + forward * 0.55;
|
||||
let far_center = origin + forward * 2.4;
|
||||
let near = frustum_corners(near_center, right, up, 0.28, 0.17);
|
||||
let far = frustum_corners(far_center, right, up, 1.1, 0.66);
|
||||
for corner in near {
|
||||
gizmos.line(origin, corner, color.with_alpha(0.72));
|
||||
let near = origin + forward * 0.8;
|
||||
let half_w = 0.45;
|
||||
let half_h = 0.28;
|
||||
let corners = [
|
||||
near + right * half_w + up * half_h,
|
||||
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(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,
|
||||
]
|
||||
gizmos.lineloop(corners, 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),
|
||||
]
|
||||
}
|
||||
|
||||
#[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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -15,13 +15,9 @@ hot-reload = ["dep:hot-lib-reloader", "game_hot/dylib"]
|
||||
avian3d.workspace = true
|
||||
bevy.workspace = true
|
||||
bevy_ufbx.workspace = true
|
||||
cpal = "0.17.3"
|
||||
game_hot = { path = "../game_hot" }
|
||||
hot-lib-reloader = { version = "0.8.2", optional = true }
|
||||
protocol.workspace = true
|
||||
ron = "0.12"
|
||||
scene.workspace = true
|
||||
serde.workspace = true
|
||||
shared.workspace = true
|
||||
sim.workspace = true
|
||||
settings.workspace = true
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -4,7 +4,6 @@ use std::path::{Path, PathBuf};
|
||||
|
||||
use bevy::app::PluginGroupBuilder;
|
||||
use bevy::asset::AssetPlugin;
|
||||
use bevy::log::{LogPlugin, DEFAULT_FILTER};
|
||||
use bevy::prelude::*;
|
||||
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 {
|
||||
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 {
|
||||
primary_window: Some(primary_window(title)),
|
||||
..default()
|
||||
|
||||
@ -1,21 +1,8 @@
|
||||
//! Runtime game crate: FPS player, rendering, and starter world.
|
||||
#![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 launch;
|
||||
mod scene_bootstrap;
|
||||
mod schema_world_loader;
|
||||
|
||||
pub use scene_bootstrap::ProjectDefaultLevelRoot;
|
||||
|
||||
pub mod player {
|
||||
//! Player types re-exported from the stable sim crate.
|
||||
@ -48,13 +35,6 @@ pub struct GameRenderBootstrap {
|
||||
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"))]
|
||||
mod systems {
|
||||
pub use game_hot::{
|
||||
@ -82,8 +62,8 @@ mod systems {
|
||||
mod hot {
|
||||
use avian3d::prelude::*;
|
||||
use bevy::input::mouse::AccumulatedMouseMotion;
|
||||
use bevy::light::atmosphere::ScatteringMedium;
|
||||
use bevy::light::CascadeShadowConfig;
|
||||
use bevy::pbr::ScatteringMedium;
|
||||
use bevy::prelude::*;
|
||||
use bevy::time::Fixed;
|
||||
use bevy::window::{CursorOptions, PrimaryWindow};
|
||||
@ -125,21 +105,7 @@ fn game_input_enabled(enabled: Res<GameInputEnabled>) -> bool {
|
||||
enabled.0
|
||||
}
|
||||
|
||||
fn register_level_bootstrap(app: &mut App, defer_default_level: 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,
|
||||
) {
|
||||
fn register_game_systems(app: &mut App, defer_player_camera_fx: bool) {
|
||||
app.add_systems(
|
||||
FixedUpdate,
|
||||
(
|
||||
@ -170,10 +136,13 @@ fn register_game_systems(
|
||||
)
|
||||
.add_systems(
|
||||
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(
|
||||
Update,
|
||||
(
|
||||
@ -182,7 +151,7 @@ fn register_game_systems(
|
||||
),
|
||||
);
|
||||
|
||||
if !render_bootstrap.defer_player_camera_fx {
|
||||
if !defer_player_camera_fx {
|
||||
app.add_systems(
|
||||
PostStartup,
|
||||
(
|
||||
@ -199,29 +168,21 @@ pub struct GamePlugin;
|
||||
|
||||
impl Plugin for GamePlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
let render_bootstrap = app
|
||||
let defer_player_camera_fx = app
|
||||
.world()
|
||||
.get_resource::<GameRenderBootstrap>()
|
||||
.copied()
|
||||
.unwrap_or_default();
|
||||
let scene_bootstrap = app
|
||||
.world()
|
||||
.get_resource::<GameSceneBootstrap>()
|
||||
.copied()
|
||||
.unwrap_or_default();
|
||||
.is_some_and(|bootstrap| bootstrap.defer_player_camera_fx);
|
||||
|
||||
app.register_type::<GameInputEnabled>()
|
||||
.init_resource::<GameInputEnabled>()
|
||||
.init_resource::<GameInputFocused>()
|
||||
.add_plugins(schema_world_loader::SchemaWorldAssetPlugin)
|
||||
.add_plugins((ProjectSettingsPlugin, SimPlugin, bevy_ufbx::FbxPlugin))
|
||||
.add_plugins((audio::GameAudioPlugin, animation::GameAnimationPlugin))
|
||||
.add_plugins(game_hot::SolariRenderingPlugin)
|
||||
.add_plugins(game_hot::AutoExposureRenderingPlugin)
|
||||
.add_plugins(game_hot::FullscreenEffectsPlugin)
|
||||
.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) {
|
||||
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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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())
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -6,9 +6,6 @@ license.workspace = true
|
||||
description = "Level scene schema versioning and validation (no Bevy dependency)"
|
||||
|
||||
[dependencies]
|
||||
blake3 = "1"
|
||||
serde = { workspace = true }
|
||||
ron = "0.8"
|
||||
ron-edit = "=0.2.0"
|
||||
shared.workspace = true
|
||||
settings.workspace = true
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user