diff --git a/.cargo/config.toml b/.cargo/config.toml index 9c57a99..100f9f5 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -7,3 +7,4 @@ 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 --" diff --git a/.cursor/plans/animation_authoring_2026-07-11.plan.md b/.cursor/plans/animation_authoring_2026-07-11.plan.md new file mode 100644 index 0000000..74f0834 --- /dev/null +++ b/.cursor/plans/animation_authoring_2026-07-11.plan.md @@ -0,0 +1,105 @@ +# 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. diff --git a/.cursor/plans/audio_authoring_2026-07-10.plan.md b/.cursor/plans/audio_authoring_2026-07-10.plan.md new file mode 100644 index 0000000..69807ab --- /dev/null +++ b/.cursor/plans/audio_authoring_2026-07-10.plan.md @@ -0,0 +1,72 @@ +# 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. diff --git a/.cursor/plans/blacksite_production_readiness_2026-07-10.plan.md b/.cursor/plans/blacksite_production_readiness_2026-07-10.plan.md new file mode 100644 index 0000000..fc1005c --- /dev/null +++ b/.cursor/plans/blacksite_production_readiness_2026-07-10.plan.md @@ -0,0 +1,101 @@ +# 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. diff --git a/.gitignore b/.gitignore index 4cbe9e4..83d8053 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ # Rust build output /target/ +/dist/ # Local automation and assistant state /.agents/ diff --git a/.vscode/launch.json b/.vscode/launch.json index ac2b63f..bd081d5 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -111,6 +111,27 @@ "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", @@ -221,5 +242,13 @@ "sourceLanguages": ["rust"], "gracefulShutdown": "SIGINT" } + ], + "inputs": [ + { + "id": "blacksiteProjectRoot", + "type": "promptString", + "description": "Absolute path to a Blacksite project root", + "default": "${workspaceFolder}" + } ] } diff --git a/Cargo.lock b/Cargo.lock index e58c263..57c8a65 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -403,7 +403,7 @@ dependencies = [ "bevy_math", "bevy_transform_interpolation", "bitflags 2.13.0", - "derive_more", + "derive_more 2.1.1", "disqualified", "glam_matrix_extras", "itertools 0.15.0", @@ -550,7 +550,7 @@ dependencies = [ "bevy_transform", "bevy_utils", "blake3", - "derive_more", + "derive_more 2.1.1", "downcast-rs 2.0.2", "either", "petgraph", @@ -642,7 +642,7 @@ dependencies = [ "bitflags 2.13.0", "blake3", "crossbeam-channel", - "derive_more", + "derive_more 2.1.1", "disqualified", "downcast-rs 2.0.2", "either", @@ -707,7 +707,7 @@ dependencies = [ "bevy_transform", "bevy_utils", "bevy_window", - "derive_more", + "derive_more 2.1.1", "downcast-rs 2.0.2", "serde", "smallvec", @@ -739,7 +739,7 @@ dependencies = [ "bevy_math", "bevy_reflect", "bytemuck", - "derive_more", + "derive_more 2.1.1", "encase", "serde", "thiserror 2.0.18", @@ -866,7 +866,7 @@ dependencies = [ "bitflags 2.13.0", "bumpalo", "concurrent-queue", - "derive_more", + "derive_more 2.1.1", "fixedbitset", "indexmap", "log", @@ -1160,7 +1160,7 @@ dependencies = [ "bevy_math", "bevy_platform", "bevy_reflect", - "derive_more", + "derive_more 2.1.1", "log", "serde", "smol_str", @@ -1343,7 +1343,7 @@ dependencies = [ "approx", "arrayvec", "bevy_reflect", - "derive_more", + "derive_more 2.1.1", "glam 0.32.1", "itertools 0.14.0", "libm", @@ -1372,7 +1372,7 @@ dependencies = [ "bevy_transform", "bitflags 2.13.0", "bytemuck", - "derive_more", + "derive_more 2.1.1", "encase", "glam 0.32.1", "half", @@ -1420,7 +1420,7 @@ dependencies = [ "bevy_utils", "bitflags 2.13.0", "bytemuck", - "derive_more", + "derive_more 2.1.1", "fixedbitset", "indexmap", "nonmax", @@ -1521,7 +1521,7 @@ dependencies = [ "bevy_ptr", "bevy_reflect_derive", "bevy_utils", - "derive_more", + "derive_more 2.1.1", "disqualified", "downcast-rs 2.0.2", "erased-serde", @@ -1613,7 +1613,7 @@ dependencies = [ "bevy_window", "bitflags 2.13.0", "bytemuck", - "derive_more", + "derive_more 2.1.1", "downcast-rs 2.0.2", "encase", "glam 0.32.1", @@ -1726,7 +1726,7 @@ dependencies = [ "bevy_transform", "bevy_utils", "bytemuck", - "derive_more", + "derive_more 2.1.1", "tracing", ] @@ -1783,7 +1783,7 @@ dependencies = [ "bevy_utils", "bitflags 2.13.0", "bytemuck", - "derive_more", + "derive_more 2.1.1", "fixedbitset", "nonmax", "tracing", @@ -1829,7 +1829,7 @@ dependencies = [ "bevy_platform", "concurrent-queue", "crossbeam-queue", - "derive_more", + "derive_more 2.1.1", "futures-lite", "heapless 0.9.3", "web-task", @@ -1891,7 +1891,7 @@ dependencies = [ "bevy_reflect", "bevy_tasks", "bevy_utils", - "derive_more", + "derive_more 2.1.1", "serde", "thiserror 2.0.18", ] @@ -1946,7 +1946,7 @@ dependencies = [ "bevy_transform", "bevy_utils", "bevy_window", - "derive_more", + "derive_more 2.1.1", "parley", "serde", "smallvec", @@ -1985,7 +1985,7 @@ dependencies = [ "bevy_ui", "bevy_utils", "bytemuck", - "derive_more", + "derive_more 2.1.1", "indexmap", "tracing", ] @@ -2094,7 +2094,7 @@ dependencies = [ "bevy_reflect", "bevy_transform", "bevy_utils", - "derive_more", + "derive_more 2.1.1", "ron 0.12.1", "serde", "thiserror 2.0.18", @@ -2390,6 +2390,12 @@ 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" @@ -2466,6 +2472,27 @@ 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" @@ -2496,6 +2523,12 @@ 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" @@ -2780,6 +2813,19 @@ 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" @@ -2795,7 +2841,7 @@ version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" dependencies = [ - "convert_case", + "convert_case 0.10.0", "proc-macro2", "quote", "rustc_version", @@ -2906,6 +2952,7 @@ dependencies = [ "bevy_egui", "bevy_solari", "bevy_ufbx", + "blake3", "egui_dock", "egui_phosphor_icons", "game", @@ -2918,6 +2965,7 @@ dependencies = [ "ron 0.8.1", "scene", "serde", + "serde_json", "settings", "shared", "sim", @@ -3024,6 +3072,15 @@ 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" @@ -3398,9 +3455,13 @@ dependencies = [ "avian3d", "bevy", "bevy_ufbx", + "cpal", "game_hot", "hot-lib-reloader", "protocol", + "ron 0.12.1", + "scene", + "serde", "settings", "shared", "sim", @@ -3801,6 +3862,12 @@ 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" @@ -4250,6 +4317,21 @@ 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" @@ -4406,6 +4488,22 @@ 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" @@ -4430,6 +4528,12 @@ 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" @@ -4566,6 +4670,16 @@ 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" @@ -5807,10 +5921,13 @@ 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", ] @@ -5841,6 +5958,17 @@ 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" @@ -5930,8 +6058,11 @@ dependencies = [ name = "scene" version = "0.1.0" dependencies = [ + "blake3", "ron 0.8.1", + "ron-edit", "serde", + "settings", "shared", ] @@ -6315,6 +6446,55 @@ 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" @@ -7881,7 +8061,13 @@ checksum = "3ae8337f8a065cfc972643663ea4279e04e7256de865aa66fe25cec5fb912d3f" name = "xtask" version = "0.1.0" dependencies = [ + "blake3", + "libc", + "ron 0.8.1", "scene", + "serde", + "serde_json", + "settings", "walkdir", ] diff --git a/Cargo.toml b/Cargo.toml index 1f2960a..72a7607 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -26,7 +26,14 @@ avian3d = { version = "0.7", default-features = false, features = [ "collider-from-mesh", "serialize", ] } -bevy = { version = "0.19", features = ["serialize", "jpeg"] } +bevy = { version = "0.19", features = [ + "serialize", + "jpeg", + "vorbis", + "wav", + "mp3", + "flac", +] } bevy_core_pipeline = "0.19" bevy_solari = "0.19" bevy_ufbx = "0.18.1-rc.1" @@ -47,6 +54,8 @@ 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" } @@ -73,3 +82,19 @@ 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" diff --git a/README.md b/README.md index 63b6f8f..5b98f53 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,9 @@ 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 # Runtime game cargo run -p game @@ -37,6 +40,22 @@ 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). @@ -82,7 +101,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 scene validation is required. VS Code tasks expose dry-run, safe, and +`cargo validate-levels` when level and prefab-graph validation is required. VS Code tasks expose dry-run, safe, and deep-stale variants. ### Launch Troubleshooting @@ -115,27 +134,35 @@ 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 | +| `W` / `E` / `R` | Translate / rotate / scale gizmo; multi-selection uses one grouped gizmo and undo step | | `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, 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, gameplay markers, volumes, 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, switch grid/list, expand model subassets with generated thumbnails, inspect staged import/material settings, drag assets/submeshes into the viewport | +| 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 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 | -| Hierarchy context | Reparent to other selection / Unparent | -| File menu | New, Open, Save, Save As, Import Assets, Export Selection, Save Selection As Prefab, Recent Scenes | +| 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** | | 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 | -| Edit → Project Settings… | Edit `assets/project.ron` (rendering, physics, input) | +| 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 | 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. @@ -175,7 +202,18 @@ 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` @@ -189,9 +227,13 @@ 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` 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. + 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). - 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 @@ -229,7 +271,41 @@ 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. Release the mouse to finish or cancel the 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=` overrides the interval for +the current process without changing preferences. + +### Session recovery + +Clean editor exits restore the last authored scene, panel visibility, and viewport bookmarks from +the versioned machine-local session document. After an abnormal exit, Blacksite opens the safe +startup scene and asks whether to **Resume Last Scene** or **Continue Safe**; modal tools and dirty +preview state are never restored. Session metadata lives under +`$XDG_STATE_HOME/blacksite-editor/session.ron` or `~/.local/state/blacksite-editor/session.ron` and +contains no scene contents or credentials. See [ADR 0024](docs/adr/0024-versioned-editor-session-state.md). + +Open **Window → Diagnostics** and choose **Export Diagnostic Bundle** to write a transactional +support report under `$XDG_STATE_HOME/blacksite-editor/diagnostics` or +`~/.local/state/blacksite-editor/diagnostics`. The report includes build/platform and renderer +identity, project and active-scene paths, dirty flags, aggregate validation counts, prior-crash +state, and the bounded Scene I/O log. It excludes scene and asset contents, environment values, +host/user identity, credentials, access tokens, and modal tool state. ## Cursor / VSCode Setup @@ -292,11 +368,17 @@ 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] Unsaved-scene confirm on New/Open/Recent +- [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] 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.) @@ -305,14 +387,18 @@ 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` +- [x] `scene` crate schema stamp/migrate/validate on save/load + CI `validate-levels` for levels and prefab graphs - [x] Project Settings draft + Apply (HDR/swapchain safe) -- [ ] Project launcher/scaffolding with startup-time asset-root selection; the unsafe in-process File → Project scaffold was removed pending BS-JD-001 +- [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)) - [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 instance overrides v2 (transform/material/child visibility apply-revert groups) +- [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] 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)) @@ -332,9 +418,20 @@ 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/`. 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. + 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). - 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, diff --git a/assets/.index/registry.ron b/assets/.index/registry.ron index f62413c..0b2bfe2 100644 --- a/assets/.index/registry.ron +++ b/assets/.index/registry.ron @@ -1,4 +1,36 @@ [ + ( + 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", @@ -47,27 +79,6 @@ ), 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", @@ -120,7 +131,7 @@ id: ("61802b85-fda9-4aff-94d9-fe5f0860c516"), path: "assets/levels/rendering_showcase.scn.ron", label: "rendering_showcase.scn", - kind_tag: "Prefab", + kind_tag: "Level", import_settings: ( scale: 1.0, generate_collider: true, @@ -136,7 +147,23 @@ id: ("48bcef13-e6a6-4260-bb1b-c29787bd8c70"), path: "assets/levels/editor_scene 3.scn.ron", label: "editor_scene 3.scn", - kind_tag: "Prefab", + 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", import_settings: ( scale: 1.0, generate_collider: true, @@ -152,7 +179,7 @@ id: ("5a2307d0-48e5-40d3-a5c9-527c0cfd30f4"), path: "assets/levels/editor_scene.scn.ron", label: "editor_scene.scn", - kind_tag: "Prefab", + kind_tag: "Level", import_settings: ( scale: 1.0, generate_collider: true, @@ -168,6 +195,54 @@ 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, @@ -244,6 +319,54 @@ ), 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", diff --git a/assets/audio/Puzzle Lobby.mp3 b/assets/audio/Puzzle Lobby.mp3 new file mode 100644 index 0000000..291d2c6 --- /dev/null +++ b/assets/audio/Puzzle Lobby.mp3 @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6054c9d586a489deea452b966068883825d164e3ce25dafd00b8501154631c56 +size 1680820 diff --git a/assets/audio/editor_audition_tone.ogg b/assets/audio/editor_audition_tone.ogg new file mode 100644 index 0000000..dcd71d8 --- /dev/null +++ b/assets/audio/editor_audition_tone.ogg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:fc92e160ccd3d82ca86848f9db1ea3f1bd1b4f901060f3e004bc300681037390 +size 4450 diff --git a/assets/build_profiles/development.ron b/assets/build_profiles/development.ron new file mode 100644 index 0000000..fde855f --- /dev/null +++ b/assets/build_profiles/development.ron @@ -0,0 +1,10 @@ +( + 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", +) diff --git a/assets/build_profiles/qa.ron b/assets/build_profiles/qa.ron new file mode 100644 index 0000000..99ddb71 --- /dev/null +++ b/assets/build_profiles/qa.ron @@ -0,0 +1,10 @@ +( + 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", +) diff --git a/assets/build_profiles/release.ron b/assets/build_profiles/release.ron new file mode 100644 index 0000000..0ee5edb --- /dev/null +++ b/assets/build_profiles/release.ron @@ -0,0 +1,10 @@ +( + 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", +) diff --git a/assets/levels/audio_authoring_showcase.scn.ron b/assets/levels/audio_authoring_showcase.scn.ron new file mode 100644 index 0000000..d0d48dc --- /dev/null +++ b/assets/levels/audio_authoring_showcase.scn.ron @@ -0,0 +1,56 @@ +(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, + ), + }, + ), + }, +) diff --git a/assets/meshes/generated/71071fc3-7a7f-454d-b9ab-93ebf0feccbe.static_mesh.ron b/assets/meshes/generated/71071fc3-7a7f-454d-b9ab-93ebf0feccbe.static_mesh.ron deleted file mode 100644 index 33bee9d..0000000 --- a/assets/meshes/generated/71071fc3-7a7f-454d-b9ab-93ebf0feccbe.static_mesh.ron +++ /dev/null @@ -1,55 +0,0 @@ -( - 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: [], -) \ No newline at end of file diff --git a/assets/models/metal_stool_01_2k.gltf b/assets/models/metal_stool_01_2k.gltf deleted file mode 100644 index 88d9e41..0000000 --- a/assets/models/metal_stool_01_2k.gltf +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:c5886418d98b7ec4e932f54a2ba44896dd9e61276bf62d21697b9136f2b0803e -size 2790 diff --git a/assets/models/robot_expressive.LICENSE.md b/assets/models/robot_expressive.LICENSE.md new file mode 100644 index 0000000..eb1d131 --- /dev/null +++ b/assets/models/robot_expressive.LICENSE.md @@ -0,0 +1,12 @@ +# 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. diff --git a/assets/models/robot_expressive.glb b/assets/models/robot_expressive.glb new file mode 100644 index 0000000..7cb7550 --- /dev/null +++ b/assets/models/robot_expressive.glb @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:047f5e5fb3bb6d378bd1df16ca6137f2a596c99b3a1b5690b4020c05aaf6f319 +size 463988 diff --git a/assets/prefabs/README.md b/assets/prefabs/README.md new file mode 100644 index 0000000..dbd6a11 --- /dev/null +++ b/assets/prefabs/README.md @@ -0,0 +1,10 @@ +# 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. diff --git a/assets/prefabs/example_base.scn.ron b/assets/prefabs/example_base.scn.ron new file mode 100644 index 0000000..23ca1dc --- /dev/null +++ b/assets/prefabs/example_base.scn.ron @@ -0,0 +1,19 @@ +(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": (), + }, + ), + }, +) diff --git a/assets/prefabs/example_nested.scn.ron b/assets/prefabs/example_nested.scn.ron new file mode 100644 index 0000000..8039fbd --- /dev/null +++ b/assets/prefabs/example_nested.scn.ron @@ -0,0 +1,23 @@ +(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, + ), + }, + ), + }, +) diff --git a/assets/prefabs/example_variant.scn.ron b/assets/prefabs/example_variant.scn.ron new file mode 100644 index 0000000..852daf7 --- /dev/null +++ b/assets/prefabs/example_variant.scn.ron @@ -0,0 +1,23 @@ +(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:[])"), + ), + }, + ), + }, +) diff --git a/assets/project.ron b/assets/project.ron index d2ba3c8..ca7e10d 100644 --- a/assets/project.ron +++ b/assets/project.ron @@ -1,8 +1,13 @@ ( 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", @@ -29,6 +34,31 @@ 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, diff --git a/crates/editor/Cargo.toml b/crates/editor/Cargo.toml index 6c8935a..f5a06b9 100644 --- a/crates/editor/Cargo.toml +++ b/crates/editor/Cargo.toml @@ -4,6 +4,7 @@ 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" @@ -13,6 +14,10 @@ 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"] @@ -37,9 +42,11 @@ 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 = "1.4" +gltf = { version = "1.4", features = ["extras"] } +blake3 = "1" diff --git a/crates/editor/src/assets/animation.rs b/crates/editor/src/assets/animation.rs new file mode 100644 index 0000000..7e5d38d --- /dev/null +++ b/crates/editor/src/assets/animation.rs @@ -0,0 +1,833 @@ +//! 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 { + 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 { + 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 { + 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, + signature: AnimationSkeletonSignature, +} + +fn build_gltf_manifest( + record: &AssetRecord, + format: String, + fingerprint: AnimationSourceFingerprint, + bytes: &[u8], +) -> Result { + 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::>(); + dependencies.sort(); + dependencies.dedup(); + + let node_identity_names = gltf + .document + .nodes() + .map(|node| bevy_node_segment(node.name(), node.index())) + .collect::>(); + let readable_node_names = gltf + .document + .nodes() + .map(|node| normalized_node_segment(node.name(), node.index())) + .collect::>(); + 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::>(); + let joint_identity_paths = joint_indices + .iter() + .map(|index| node_path_segments(*index, &node_identity_names, &parents)) + .collect::>(); + let joint_paths = joint_indices + .iter() + .map(|index| node_path(*index, &readable_node_names, &parents)) + .collect::>(); + 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::>()) + .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::>(); + 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::>() + .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 { + 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::>(); + + 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) { + 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, +} + +#[derive(Debug, Deserialize)] +struct GltfAnimationEvent { + id: String, + time_seconds: f32, + #[serde(default)] + payload: Option, +} + +fn gltf_animation_events( + animation: &gltf::Animation<'_>, + duration_seconds: f32, + clip_label: &str, + diagnostics: &mut Vec, +) -> Vec { + let Some(raw) = animation.extras().as_ref() else { + return Vec::new(); + }; + let extras = match serde_json::from_str::(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, + candidates: &[SkeletonCandidate], +) -> Option { + let scored = candidates + .iter() + .map(|candidate| { + ( + candidate + .compatible_nodes + .intersection(target_nodes) + .count(), + candidate, + ) + }) + .filter(|(score, _)| *score > 0) + .collect::>(); + 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]) -> String { + node_path_segments(index, names, parents).join("/") +} + +fn node_path_segments(index: usize, names: &[String], parents: &[Option]) -> Vec { + 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 { + 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 { + 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], + bind_pose_bytes: &[Vec], +) -> 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 { + matrix + .into_iter() + .flatten() + .flat_map(f32::to_le_bytes) + .collect() +} + +fn identity_matrix_bytes() -> Vec { + 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 { + [ + 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 { + 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 { + 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()); + } +} diff --git a/crates/editor/src/assets/asset_db.rs b/crates/editor/src/assets/asset_db.rs index 847d2f9..3f75e7d 100644 --- a/crates/editor/src/assets/asset_db.rs +++ b/crates/editor/src/assets/asset_db.rs @@ -2,7 +2,7 @@ use bevy::prelude::*; use serde::{Deserialize, Serialize}; -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use uuid::Uuid; /// Stable asset identity for dependency tracking and prefab references. @@ -59,6 +59,8 @@ pub struct ImportSettings { pub material_policy: MaterialImportPolicy, #[serde(default)] pub static_mesh_manifest_path: Option, + #[serde(default)] + pub animation_manifest_path: Option, } impl Default for ImportSettings { @@ -71,6 +73,7 @@ impl Default for ImportSettings { hierarchy_mode: ModelHierarchyMode::default(), material_policy: MaterialImportPolicy::default(), static_mesh_manifest_path: None, + animation_manifest_path: None, } } } @@ -115,6 +118,11 @@ 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::>(); let mut next_records = Vec::new(); let mut seen_paths = HashMap::new(); @@ -129,7 +137,18 @@ 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) { + 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(); + } prior.label = asset.label.clone(); prior.kind_tag = kind_tag; prior @@ -144,9 +163,9 @@ fn sync_registry_from_browser( } }; if record.kind_tag == "Model" { - if let Err(error) = super::static_mesh::refresh_static_mesh_artifact(&mut record) { + if let Err(error) = super::refresh_model_artifacts(&mut record) { warn!( - "Static mesh processor failed for {} (asset id {}): {error}", + "Model artifact refresh failed for {} (asset id {}): {error}", record.path, record.id.as_string() ); @@ -170,6 +189,34 @@ fn sync_registry_from_browser( } } +fn take_uniquely_moved_model_record( + existing: &mut HashMap, + current_paths: &HashSet, + new_path: &str, + kind_tag: &str, +) -> Option { + 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::>(); + if candidates.len() != 1 { + return None; + } + existing.remove(&candidates[0]) +} + pub fn find_asset_by_path(registry: &AssetRegistry, path: &str) -> Option { registry .records @@ -196,6 +243,34 @@ pub fn find_asset_mut_by_path<'a>( .find(|record| record.path == path) } +pub fn ensure_asset_record( + registry: &mut AssetRegistry, + path: impl Into, + label: impl Into, + kind_tag: impl Into, +) -> Result { + 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, @@ -240,3 +315,98 @@ 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); + } +} diff --git a/crates/editor/src/assets/catalog.rs b/crates/editor/src/assets/catalog.rs index fa0433e..f49e961 100644 --- a/crates/editor/src/assets/catalog.rs +++ b/crates/editor/src/assets/catalog.rs @@ -5,28 +5,34 @@ use bevy::prelude::*; use settings::ProjectSettings; use shared::{ ActorKind, AuthoringLightKind, ColliderDesc, ColorDesc, EditorVisibility, LightDesc, - MaterialDesc, ModelRef, PostProcessVolumeDesc, PrefabInstance, PrefabRef, Primitive, - PrimitiveShape, RigidBodyDesc, + MaterialDesc, ModelRef, PostProcessVolumeDesc, PrefabRef, Primitive, PrimitiveShape, + RigidBodyDesc, AUDIO_CLIP_SUB_ASSET_ID, }; use walkdir::WalkDir; use crate::asset_db::{ - 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, + ensure_asset_record, find_asset_by_id, find_asset_by_path, find_asset_mut_by_path, + AssetRegistry, MaterialImportPolicy, ModelPlacementMode, }; +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)] @@ -73,6 +79,7 @@ pub enum EditorAssetKind { Model, Texture, Material, + AudioClip, Level, Prefab, PostProcessVolume, @@ -219,7 +226,15 @@ pub struct EditorAssetsPlugin; impl Plugin for EditorAssetsPlugin { fn build(&self, app: &mut App) { app.init_resource::() - .add_systems(Update, refresh_assets_on_settings_change); + .init_resource::() + .add_observer(super::prefab_overrides::invalidate_prefab_health_on_ready) + .add_systems( + Update, + ( + refresh_assets_on_settings_change, + super::prefab_overrides::guard_prefab_reimports, + ), + ); } } @@ -352,50 +367,56 @@ fn should_skip_asset_path(path: &Path) -> bool { fn asset_from_file_path(path: &Path) -> Option { let extension = path.extension()?.to_str()?.to_ascii_lowercase(); - 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 path.to_string_lossy().contains("/materials/") { - ( - vec![EditorAssetKind::Material], - vec!["ron", "mat", "material"], - ) - } else if path.to_string_lossy().contains("/post_fx/") { - (vec![EditorAssetKind::PostProcessEffect], vec!["ron"]) - } else if path.to_string_lossy().contains("/rendering_profiles/") { - (vec![EditorAssetKind::RenderingProfile], vec!["ron"]) - } else if path.to_string_lossy().contains("/shaders/") { - (vec![EditorAssetKind::ShaderSchema], vec!["ron"]) - } else { - ( - vec![EditorAssetKind::Level, EditorAssetKind::Prefab], - vec!["ron"], - ) + 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() { + "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/") { + ( + vec![EditorAssetKind::Material], + vec!["ron", "mat", "material"], + ) + } else if normalized_path.contains("/post_fx/") { + (vec![EditorAssetKind::PostProcessEffect], vec!["ron"]) + } else if normalized_path.contains("/rendering_profiles/") { + (vec![EditorAssetKind::RenderingProfile], vec!["ron"]) + } else if normalized_path.contains("/shaders/") { + (vec![EditorAssetKind::ShaderSchema], vec!["ron"]) + } else if normalized_path.ends_with(".scn.ron") + && (normalized_path.starts_with("assets/prefabs/") + || normalized_path.contains("/assets/prefabs/")) + { + (vec![EditorAssetKind::Prefab], vec!["ron"]) + } else { + (vec![EditorAssetKind::Level], vec!["ron"]) + } } + _ => return None, } - _ => return None, }; if !has_extension(path, &extensions) { return None; } - let path_string = normalize_asset_path(path); + let path_string = normalized_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 = if kinds.len() == 2 && path_string.ends_with(".scn.ron") { - kinds[1].clone() - } else { - kinds[0].clone() - }; + let kind = kinds[0].clone(); Some(EditorAsset { label, @@ -447,8 +468,54 @@ fn has_extension(path: &Path, extensions: &[&str]) -> bool { }) } -pub fn snapshot_for_asset(asset: &EditorAsset, translation: Vec3) -> Option { - let mut snapshot = EditorEntitySnapshot { +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 { + 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::() + .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 { + Ok(shared::AudioSourceDesc { + clip: Some(audio_clip_ref_for_asset(world, asset)?), + ..Default::default() + }) +} + +fn empty_snapshot_for_asset(asset: &EditorAsset, translation: Vec3) -> EditorEntitySnapshot { + EditorEntitySnapshot { actor_id: None, actor_kind: ActorKind::Empty, actor_name: None, @@ -463,6 +530,9 @@ pub fn snapshot_for_asset(asset: &EditorAsset, translation: Vec3) -> Option Option Option { + let mut snapshot = empty_snapshot_for_asset(asset, translation); match &asset.kind { EditorAssetKind::Primitive(shape) => { @@ -522,13 +596,15 @@ pub fn snapshot_for_asset(asset: &EditorAsset, translation: Vec3) -> Option { + EditorAssetKind::Prefab => { 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, @@ -538,7 +614,24 @@ pub fn snapshot_for_asset(asset: &EditorAsset, translation: Vec3) -> Option Option { - let mut snapshot = snapshot_for_asset(asset, translation)?; + 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::() { + scene_io.status = format!("Audio placement failed: {error}"); + } + return None; + } + }; + let mut snapshot = empty_snapshot_for_asset(asset, translation); + snapshot.actor_kind = ActorKind::AudioSource; + snapshot.audio_source = Some(descriptor); + snapshot + } else { + snapshot_for_asset(asset, translation)? + }; if let Some(light) = snapshot.light.as_mut() { if matches!(light.kind, AuthoringLightKind::Directional) { light.intensity = world @@ -548,12 +641,32 @@ pub fn spawn_asset_at(world: &mut World, asset: &EditorAsset, translation: Vec3) } } if let Some(path) = asset.path.as_ref() { - let record = world + let mut record = world .get_resource::() .and_then(|registry| find_asset_by_path(registry, path)); + if matches!(asset.kind, EditorAssetKind::Prefab) && record.is_none() { + record = world + .get_resource_mut::() + .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::() { + 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, @@ -580,7 +693,11 @@ pub fn spawn_asset_at(world: &mut World, asset: &EditorAsset, translation: Vec3) } EditorAssetKind::Prefab => { snapshot.prefab_instance = - Some(PrefabInstance::new(record.id.as_string(), path.clone())); + Some(super::prefab_overrides::new_tracked_prefab_instance( + world, + record.id.as_string(), + path.clone(), + )); } _ => {} } @@ -589,11 +706,37 @@ 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::() + .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 { + if matches!( + selection, + AssetSelection::SubAsset { + kind: AssetSubAssetKind::AnimationClip, + .. + } + ) { + return spawn_animation_clip_at(world, selection, translation); + } + let AssetSelection::SubAsset { parent_path, sub_asset_id, @@ -663,6 +806,9 @@ 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, @@ -685,6 +831,251 @@ 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 { + 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::() + .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 { + let record = world + .get_resource::() + .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 { + let record = world + .get_resource::() + .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 { + 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::() { + 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, @@ -733,6 +1124,9 @@ 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, @@ -788,13 +1182,13 @@ fn static_mesh_renderer_for_asset( if let Some(mut registry) = world.get_resource_mut::() { let mut renderer = None; if let Some(record) = find_asset_mut_by_path(&mut registry, path) { - match refresh_static_mesh_artifact(record) { + match super::refresh_model_artifacts(record) { Ok(manifest) => { renderer = Some(renderer_from_manifest(&manifest, &record.import_settings)); registry.index_dirty = true; } Err(error) => { - warn!("Static mesh manifest refresh failed for {path}: {error}"); + warn!("Model artifact refresh failed for {path}: {error}"); manifest_path = record.import_settings.static_mesh_manifest_path.clone(); } } @@ -847,6 +1241,9 @@ pub fn import_external_assets(paths: &[PathBuf]) -> Result { } 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", @@ -870,3 +1267,85 @@ 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::(); + world.init_resource::(); + + 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::(entity), + Some(&ActorKind::AudioSource) + ); + let placed = world.get::(entity).unwrap(); + assert_eq!(placed.clip.as_ref(), Some(&reference)); + assert_eq!( + world.get::(entity).unwrap().translation, + Vec3::new(1.0, 2.0, 3.0) + ); + assert!(world.resource::().can_undo()); + } +} diff --git a/crates/editor/src/assets/materials.rs b/crates/editor/src/assets/materials.rs index b5eefa9..fffb3a5 100644 --- a/crates/editor/src/assets/materials.rs +++ b/crates/editor/src/assets/materials.rs @@ -5,7 +5,7 @@ use bevy_egui::egui; use shared::{LevelObject, MaterialAsset, MaterialDesc}; use crate::assets::{EditorAsset, EditorAssetKind, EditorAssets}; -use crate::history::set_material_with_history; +use crate::history::set_material_group_with_history; use crate::ui::inspector::property_row; pub fn load_material_asset(catalog_path: &str) -> Result { @@ -23,28 +23,21 @@ pub fn apply_material_asset_to_selection( world: &mut World, asset: &EditorAsset, selected: &bevy_inspector_egui::bevy_inspector::hierarchy::SelectedEntities, -) { - let Some(path) = asset.path.clone() else { - return; - }; - let desc = match material_desc_from_asset(&path) { - Ok(desc) => desc, - Err(err) => { - world.resource_mut::().status = err; - return; - } - }; - for entity in crate::ui::helpers::selected_level_entities(world, selected) { - if world.get::(entity).is_none() { - continue; - } - if world.get::(entity).is_none() { - world.entity_mut(entity).insert(desc.clone()); - } - set_material_with_history(world, entity, desc.clone()); - } +) -> 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::(*entity).is_some()) + .map(|entity| (entity, desc.clone())) + .collect::>(); + set_material_group_with_history(world, changes); world.resource_mut::().status = format!("Applied material {}", asset.label); + Ok(()) } pub fn material_asset_picker_ui( diff --git a/crates/editor/src/assets/mod.rs b/crates/editor/src/assets/mod.rs index c1d3d39..cf406bd 100644 --- a/crates/editor/src/assets/mod.rs +++ b/crates/editor/src/assets/mod.rs @@ -1,5 +1,6 @@ //! Asset catalog, database, thumbnails, materials, and prefab overrides. +pub mod animation; pub mod asset_db; mod catalog; mod import; @@ -11,6 +12,25 @@ pub mod thumbnails; pub use catalog::*; pub use thumbnails::{ - draw_asset_cell_with, invalidate_on_catalog_refresh, kind_icon, prefetch_folder_thumbnails, - AssetThumbnailCache, ThumbnailCacheSnapshot, ThumbnailState, ThumbnailStudio, ThumbnailsPlugin, + draw_asset_cell_with, gltf_skinned_primitive_labels, 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 { + 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}" + )), + } +} diff --git a/crates/editor/src/assets/operators.rs b/crates/editor/src/assets/operators.rs index fae0062..0ff8253 100644 --- a/crates/editor/src/assets/operators.rs +++ b/crates/editor/src/assets/operators.rs @@ -8,7 +8,11 @@ use crate::ui::helpers::{ apply_material_asset_to_selection, apply_texture_to_selection, selected_level_entities, }; -use super::{spawn_asset_at, spawn_subasset_at, AssetSelection, EditorAsset}; +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, +}; fn selection_availability(world: &World, selected: &SelectedEntities) -> OperatorAvailability { if selected_level_entities(world, selected).is_empty() { @@ -26,6 +30,7 @@ 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)) @@ -64,10 +69,7 @@ pub fn apply_texture_operator( "assets.apply_texture", &label, availability, - move |world| { - apply_texture_to_selection(world, &asset, selected); - Ok(()) - }, + move |world| apply_texture_to_selection(world, &asset, selected), ) } @@ -83,9 +85,414 @@ 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::(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| { - apply_material_asset_to_selection(world, &asset, selected); + let clip = audio_clip_ref_for_asset(world, &asset)?; + let mut source = world + .get::(entity) + .cloned() + .ok_or_else(|| "Selected actor is not an audio source".to_string())?; + source.clip = Some(clip); + crate::history::set_audio_source_with_history(world, entity, source); Ok(()) }, ) } + +pub fn assign_animation_clip_operator( + world: &mut World, + selection: AssetSelection, + entity: Entity, +) -> bool { + let availability = if world.get::(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::(entity) + .cloned() + .ok_or_else(|| "Selected actor has no ModelRef".to_string())?; + let mut controller = world + .get::(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::>(); + 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::(); + world.init_resource::(); + world.init_resource::(); + world.init_resource::(); + 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::(); + world.init_resource::(); + world.init_resource::(); + 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::(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::(); + world.init_resource::(); + world.init_resource::(); + 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::(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::(); + world.init_resource::(); + world.init_resource::(); + 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::(entity) + .and_then(|material| material.base_color_texture.clone()) + }) + .collect::>() + }; + + 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::(); + world.init_resource::(); + world.init_resource::(); + 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::(); + world.init_resource::(); + world.init_resource::(); + 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::(); + world.init_resource::(); + world.init_resource::(); + 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); + } +} diff --git a/crates/editor/src/assets/prefab_overrides.rs b/crates/editor/src/assets/prefab_overrides.rs index b37a764..7fe03f0 100644 --- a/crates/editor/src/assets/prefab_overrides.rs +++ b/crates/editor/src/assets/prefab_overrides.rs @@ -1,20 +1,32 @@ //! Prefab instance overrides — apply/revert per field group (v2). -use std::collections::HashMap; -use std::path::Path; +use std::collections::{HashMap, HashSet}; +use std::path::{Path, PathBuf}; +use std::time::{Duration, Instant}; use bevy::prelude::*; +use bevy::world_serialization::{ + DynamicWorld, DynamicWorldRoot, WorldInstance, WorldInstanceReady, +}; use bevy_egui::egui; use egui_phosphor_icons::icons; -use scene::{migrate_scene_text, strip_schema_version, validate_level_text}; -use serde::{Deserialize, Serialize}; -use shared::{EditorVisibility, MaterialDesc, PrefabInstance}; +use scene::document::{SceneComponentBlob, SceneDocument, SceneEntityId, ScenePatch}; +use shared::{ + decode_prefab_overrides, encode_prefab_overrides, ActorId, EditorVisibility, + HydratedPrefabMember, HydratedPrefabReady, MaterialDesc, PrefabActorPath, + PrefabComponentOverride, PrefabInstance, PrefabOverrides, PrefabPropertyOverride, PrefabRef, + PrefabStructuralOverride, +}; -use crate::history::{set_material_with_history, set_transform_with_history}; +use crate::asset_db::{ensure_asset_record, AssetRegistry}; +use crate::history::{ + capture_prefab_edit_state, record_prefab_edit_state, record_prefab_source_apply, +}; use crate::ui::inspector::{ apply_component_card_response, component_card, component_card_context, property_row, ComponentCardOptions, }; +use crate::ui::theme::{ERROR, SUCCESS, TEXT_MUTED, WARNING}; #[derive(Resource, Default)] pub struct PrefabOverrideClipboard { @@ -22,170 +34,2411 @@ pub struct PrefabOverrideClipboard { pub value_ron: String, } -#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)] -pub struct PrefabOverrides { - #[serde(default)] - pub transform: Option, - #[serde(default)] - pub material: Option, - #[serde(default)] - pub child_visibility: HashMap, +#[derive(Resource, Default)] +pub struct PrefabHealthCache { + entries: HashMap, } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum PrefabOverrideField { - Transform, - Material, - ChildVisibility, +pub fn prefab_source_revision(project_root: &Path, source_path: &str) -> Result { + scene::prefab_graph_revision(&project_root.join(source_path), project_root) +} + +fn current_source_revision(world: &World, instance: &PrefabInstance) -> Option { + let root = world + .get_resource::() + .map(|workspace| PathBuf::from(&workspace.root))?; + prefab_source_revision(&root, &instance.source_path).ok() +} + +fn track_current_source_revision( + world: &World, + instance: &PrefabInstance, + overrides: &mut PrefabOverrides, +) { + overrides.source_revision = current_source_revision(world, instance); +} + +#[derive(Debug, Clone)] +enum PrefabHealth { + Ready, + Loading, + Untracked, + ChangedBase, + Conflict(Vec), + Broken(String), + InvalidOverrides(String), +} + +fn prefab_health( + world: &mut World, + entity: Entity, + instance: &PrefabInstance, + overrides: &PrefabOverrides, +) -> PrefabHealth { + if let Some(health) = world + .get_resource::() + .and_then(|cache| cache.entries.get(&entity)) + .filter(|(checked, _)| checked.elapsed() < Duration::from_millis(500)) + .map(|(_, health)| health.clone()) + { + return health; + } + let health = compute_prefab_health(world, entity, instance, overrides); + if let Some(mut cache) = world.get_resource_mut::() { + cache + .entries + .insert(entity, (Instant::now(), health.clone())); + } + health +} + +fn compute_prefab_health( + world: &World, + entity: Entity, + instance: &PrefabInstance, + overrides: &PrefabOverrides, +) -> PrefabHealth { + let Some(project_root) = world + .get_resource::() + .map(|workspace| PathBuf::from(&workspace.root)) + else { + return PrefabHealth::Broken("Project workspace is unavailable".to_string()); + }; + if let Some(blocked) = world.get::(entity) { + return PrefabHealth::Broken(blocked.reason.clone()); + } + if let Err(error) = + scene::validate_prefab_graph(&project_root.join(&instance.source_path), &project_root) + { + return PrefabHealth::Broken(error); + } + if world.get::(entity).is_none() { + return PrefabHealth::Loading; + } + let current = prefab_source_revision(&project_root, &instance.source_path).ok(); + let known_keys = prefab_legacy_target_keys(&project_root, instance); + let mut stale: Vec = overrides + .child_visibility + .keys() + .filter(|key| !known_keys.contains(*key)) + .cloned() + .collect(); + stale.extend(prefab_semantic_conflicts( + &project_root, + instance, + overrides, + )); + stale.sort(); + stale.dedup(); + if !stale.is_empty() { + return PrefabHealth::Conflict(stale); + } + match (&overrides.source_revision, current) { + (None, _) => PrefabHealth::Untracked, + (Some(recorded), Some(current)) if recorded != ¤t => PrefabHealth::ChangedBase, + _ => PrefabHealth::Ready, + } +} + +fn prefab_legacy_target_keys(project_root: &Path, instance: &PrefabInstance) -> HashSet { + let direct = PrefabActorPath::direct(""); + source_document_for_target(project_root, instance, &direct) + .map(|document| { + document + .entities + .into_iter() + .flat_map(|entity| [entity.actor_id, entity.name]) + .flatten() + .collect() + }) + .unwrap_or_default() +} + +fn prefab_semantic_conflicts( + project_root: &Path, + instance: &PrefabInstance, + overrides: &PrefabOverrides, +) -> Vec { + let mut conflicts = Vec::new(); + for property in &overrides.properties { + let source = source_component_for_target( + project_root, + instance, + &property.target, + &property.component_type, + ) + .and_then(|source| { + apply_layer_prerequisites( + source, + overrides, + &property.target, + &property.component_type, + ) + }); + let conflict = match source { + Ok(Some(source)) if property.component_type == std::any::type_name::() => { + apply_transform_property(&source.ron, property).is_err() + } + Ok(Some(source)) + if property.component_type == std::any::type_name::() => + { + apply_material_property(&source.ron, property).is_err() + } + Ok(Some(_)) => false, + _ => true, + }; + if conflict { + conflicts.push(property_conflict_key(property)); + } + } + for component in &overrides.components { + let current = source_component_for_target( + project_root, + instance, + &component.target, + &component.component_type, + ) + .ok() + .flatten(); + let conflict = match (&component.base_component_ron, current) { + (None, Some(_)) | (Some(_), None) => true, + (Some(base), Some(current)) => { + !component_values_equal(&component.component_type, ¤t.ron, base) + } + (None, None) => false, + }; + if conflict { + conflicts.push(component_conflict_key(component)); + } + } + for structural in &overrides.structural { + let target = structural_target(structural); + let document = source_document_for_target(project_root, instance, target); + let source_entity = document.as_ref().ok().and_then(|document| { + document.find_entity(&SceneEntityId::ActorId(target.actor_id.clone())) + }); + let conflict = match structural { + PrefabStructuralOverride::RemoveChild { + base_subtree_revision, + .. + } => { + source_entity.is_none() + || match prefab_source_revision(project_root, &instance.source_path) { + Ok(current) => current != *base_subtree_revision, + Err(_) => true, + } + } + PrefabStructuralOverride::ReparentChild { + base_parent, + new_parent, + base_transform_ron, + .. + } => { + let parent_changed = match source_parent_for_target(project_root, instance, target) + { + Ok(current) => current != *base_parent, + Err(_) => true, + }; + let transform_changed = base_transform_ron.as_ref().is_some_and(|base| { + source_component_for_target( + project_root, + instance, + target, + std::any::type_name::(), + ) + .ok() + .flatten() + .is_none_or(|current| { + !component_values_equal( + std::any::type_name::(), + ¤t.ron, + base, + ) + }) + }); + let destination_missing = new_parent.as_ref().is_some_and(|parent| { + parent.instance_chain != target.instance_chain + || source_parent_for_target(project_root, instance, parent).is_err() + }); + parent_changed || transform_changed || destination_missing + } + PrefabStructuralOverride::AttachLocalChild { .. } => false, + }; + if conflict { + conflicts.push(structural_conflict_key(structural)); + } + } + conflicts +} + +fn actor_path_key(target: &PrefabActorPath) -> String { + if target.instance_chain.is_empty() { + target.actor_id.clone() + } else { + format!("{}/{}", target.instance_chain.join("/"), target.actor_id) + } +} + +fn property_conflict_key(property: &PrefabPropertyOverride) -> String { + format!( + "{}.{}.{}", + actor_path_key(&property.target), + property.component_type, + property.property_path + ) +} + +fn component_conflict_key(component: &PrefabComponentOverride) -> String { + format!( + "{}.{}", + actor_path_key(&component.target), + component.component_type + ) +} + +fn structural_conflict_key(structural: &PrefabStructuralOverride) -> String { + format!( + "{}.structure", + actor_path_key(structural_target(structural)) + ) +} + +fn source_document_for_target( + project_root: &Path, + instance: &PrefabInstance, + target: &PrefabActorPath, +) -> Result { + let mut source_path = instance.source_path.clone(); + for anchor_actor_id in &target.instance_chain { + let document = SceneDocument::from_ron_text( + &std::fs::read_to_string(project_root.join(&source_path)) + .map_err(|error| error.to_string())?, + )?; + let component = document + .find_entity(&SceneEntityId::ActorId(anchor_actor_id.clone())) + .and_then(|entity| { + entity.components.iter().find(|component| { + component.type_name == std::any::type_name::() + }) + }) + .ok_or_else(|| format!("nested prefab anchor `{anchor_actor_id}` is missing"))?; + let nested: PrefabInstance = + ron::from_str(&component.ron).map_err(|error| error.to_string())?; + source_path = nested.source_path; + } + SceneDocument::from_ron_text( + &std::fs::read_to_string(project_root.join(source_path)) + .map_err(|error| error.to_string())?, + ) +} + +fn source_component_for_target( + project_root: &Path, + instance: &PrefabInstance, + target: &PrefabActorPath, + component_type: &str, +) -> Result, String> { + let mut source_path = instance.source_path.clone(); + let mut remaining_target = target.clone(); + let mut layers = Vec::new(); + for anchor_actor_id in &target.instance_chain { + let document = SceneDocument::from_ron_text( + &std::fs::read_to_string(project_root.join(&source_path)) + .map_err(|error| error.to_string())?, + )?; + let nested_component = document + .find_entity(&SceneEntityId::ActorId(anchor_actor_id.clone())) + .and_then(|entity| { + entity.components.iter().find(|component| { + component.type_name == std::any::type_name::() + }) + }) + .ok_or_else(|| format!("nested prefab anchor `{anchor_actor_id}` is missing"))?; + let nested: PrefabInstance = + ron::from_str(&nested_component.ron).map_err(|error| error.to_string())?; + remaining_target.instance_chain.remove(0); + layers.push((decode_prefab_overrides(&nested)?, remaining_target.clone())); + source_path = nested.source_path; + } + let document = SceneDocument::from_ron_text( + &std::fs::read_to_string(project_root.join(source_path)) + .map_err(|error| error.to_string())?, + )?; + let mut current = document + .find_entity(&SceneEntityId::ActorId(target.actor_id.clone())) + .and_then(|entity| { + entity + .components + .iter() + .find(|component| component.type_name == component_type) + }) + .cloned(); + for (overrides, layer_target) in layers.into_iter().rev() { + current = apply_layer_to_component(current, &overrides, &layer_target, component_type)?; + } + Ok(current) +} + +fn source_parent_for_target( + project_root: &Path, + instance: &PrefabInstance, + target: &PrefabActorPath, +) -> Result, String> { + let mut source_path = instance.source_path.clone(); + let mut remaining_target = target.clone(); + let mut layers = Vec::new(); + for anchor_actor_id in &target.instance_chain { + let document = SceneDocument::from_ron_text( + &std::fs::read_to_string(project_root.join(&source_path)) + .map_err(|error| error.to_string())?, + )?; + let component = document + .find_entity(&SceneEntityId::ActorId(anchor_actor_id.clone())) + .and_then(|entity| { + entity.components.iter().find(|component| { + component.type_name == std::any::type_name::() + }) + }) + .ok_or_else(|| format!("nested prefab anchor `{anchor_actor_id}` is missing"))?; + let nested: PrefabInstance = + ron::from_str(&component.ron).map_err(|error| error.to_string())?; + remaining_target.instance_chain.remove(0); + layers.push((decode_prefab_overrides(&nested)?, remaining_target.clone())); + source_path = nested.source_path; + } + let document = SceneDocument::from_ron_text( + &std::fs::read_to_string(project_root.join(source_path)) + .map_err(|error| error.to_string())?, + )?; + let mut parent_actor_id = document + .find_entity(&SceneEntityId::ActorId(target.actor_id.clone())) + .ok_or_else(|| format!("source actor `{}` is missing", actor_path_key(target)))? + .parent_actor_id + .clone(); + for (overrides, layer_target) in layers.into_iter().rev() { + for structural in &overrides.structural { + match structural { + PrefabStructuralOverride::RemoveChild { target, .. } if target == &layer_target => { + return Err(format!( + "source actor `{}` was removed by a nested layer", + actor_path_key(target) + )); + } + PrefabStructuralOverride::ReparentChild { + target, + base_parent, + new_parent, + .. + } if target == &layer_target => { + let expected = base_parent.as_ref().map(|parent| parent.actor_id.as_str()); + if parent_actor_id.as_deref() != expected { + return Err(format!( + "nested parent override for `{}` conflicts with its source", + actor_path_key(target) + )); + } + parent_actor_id = new_parent.as_ref().map(|parent| parent.actor_id.clone()); + } + _ => {} + } + } + } + Ok(parent_actor_id.map(|actor_id| PrefabActorPath { + instance_chain: target.instance_chain.clone(), + actor_id, + })) +} + +fn apply_layer_to_component( + current: Option, + overrides: &PrefabOverrides, + target: &PrefabActorPath, + component_type: &str, +) -> Result, String> { + let mut current = apply_layer_prerequisites(current, overrides, target, component_type)?; + for property in &overrides.properties { + if property.target != *target || property.component_type != component_type { + continue; + } + let source = current.as_ref().ok_or_else(|| { + format!( + "nested property `{}` target is missing component `{component_type}`", + property.property_path + ) + })?; + let ron = if component_type == std::any::type_name::() { + apply_transform_property(&source.ron, property)? + } else if component_type == std::any::type_name::() { + apply_material_property(&source.ron, property)? + } else { + return Err(format!( + "nested property resolution does not support component `{component_type}`" + )); + }; + current = Some(SceneComponentBlob::from_ron(component_type, ron)?); + } + if overrides.structural.iter().any(|structural| { + matches!(structural, PrefabStructuralOverride::RemoveChild { target: candidate, .. } if candidate == target) + }) { + return Ok(None); + } + Ok(current) +} + +fn apply_layer_prerequisites( + mut current: Option, + overrides: &PrefabOverrides, + target: &PrefabActorPath, + component_type: &str, +) -> Result, String> { + if component_type == std::any::type_name::() { + for structural in &overrides.structural { + if let PrefabStructuralOverride::ReparentChild { + target: candidate, + base_transform_ron, + local_transform_ron, + .. + } = structural + { + if candidate != target { + continue; + } + if let Some(base) = base_transform_ron { + let current_ron = current.as_ref().map(|component| component.ron.as_str()); + if current_ron + .is_none_or(|value| !component_values_equal(component_type, value, base)) + { + return Err(format!( + "nested reparent transform for `{}` conflicts with its source", + actor_path_key(target) + )); + } + } + if let Some(value) = local_transform_ron { + current = Some(SceneComponentBlob::from_ron(component_type, value.clone())?); + } + } + } + } + for component in &overrides.components { + if component.target != *target || component.component_type != component_type { + continue; + } + let current_ron = current.as_ref().map(|value| value.ron.as_str()); + let base_matches = match (&component.base_component_ron, current_ron) { + (None, None) => true, + (Some(base), Some(current)) => component_values_equal(component_type, current, base), + _ => false, + }; + if !base_matches { + return Err(format!( + "nested component `{component_type}` for `{}` conflicts with its source", + actor_path_key(target) + )); + } + current = component + .value_component_ron + .as_ref() + .map(|value| SceneComponentBlob::from_ron(component_type, value.clone())) + .transpose()?; + } + Ok(current) +} + +fn source_target_exists( + project_root: &Path, + instance: &PrefabInstance, + target: &PrefabActorPath, +) -> bool { + source_document_for_target(project_root, instance, target) + .ok() + .and_then(|document| { + document + .find_entity(&SceneEntityId::ActorId(target.actor_id.clone())) + .map(|_| ()) + }) + .is_some() +} + +fn accept_current_source(world: &mut World, entity: Entity) { + let old = capture_prefab_edit_state(world, entity); + let Some(instance) = world.get::(entity).cloned() else { + return; + }; + let Some(mut overrides) = read_overrides_for_edit(world, &instance) else { + return; + }; + track_current_source_revision(world, &instance, &mut overrides); + write_overrides(world, entity, &overrides); + record_prefab_edit_state(world, entity, old); +} + +fn keep_prefab_overrides(world: &mut World, entity: Entity) { + let old = capture_prefab_edit_state(world, entity); + let Some(instance) = world.get::(entity).cloned() else { + return; + }; + let Some(project_root) = world + .get_resource::() + .map(|workspace| PathBuf::from(&workspace.root)) + else { + return; + }; + let Some(mut overrides) = read_overrides_for_edit(world, &instance) else { + return; + }; + let prerequisite_layer = overrides.clone(); + for property in &mut overrides.properties { + if let Ok(Some(source)) = source_component_for_target( + &project_root, + &instance, + &property.target, + &property.component_type, + ) + .and_then(|source| { + apply_layer_prerequisites( + source, + &prerequisite_layer, + &property.target, + &property.component_type, + ) + }) { + property.base_component_ron = source.ron; + } + } + for component in &mut overrides.components { + component.base_component_ron = source_component_for_target( + &project_root, + &instance, + &component.target, + &component.component_type, + ) + .ok() + .flatten() + .map(|source| source.ron); + } + for structural in &mut overrides.structural { + match structural { + PrefabStructuralOverride::RemoveChild { + base_subtree_revision, + .. + } => { + *base_subtree_revision = + prefab_source_revision(&project_root, &instance.source_path) + .unwrap_or_default(); + } + PrefabStructuralOverride::ReparentChild { + target, + base_parent, + base_transform_ron, + .. + } => { + if let Ok(parent) = source_parent_for_target(&project_root, &instance, target) { + *base_parent = parent; + } + *base_transform_ron = source_component_for_target( + &project_root, + &instance, + target, + std::any::type_name::(), + ) + .ok() + .flatten() + .map(|component| component.ron); + } + PrefabStructuralOverride::AttachLocalChild { .. } => {} + } + } + track_current_source_revision(world, &instance, &mut overrides); + write_overrides(world, entity, &overrides); + record_prefab_edit_state(world, entity, old); +} + +fn take_source_for_conflicts(world: &mut World, entity: Entity) { + let old = capture_prefab_edit_state(world, entity); + let Some(instance) = world.get::(entity).cloned() else { + return; + }; + let Some(project_root) = world + .get_resource::() + .map(|workspace| PathBuf::from(&workspace.root)) + else { + return; + }; + let Some(mut overrides) = read_overrides_for_edit(world, &instance) else { + return; + }; + let conflicts: HashSet<_> = prefab_semantic_conflicts(&project_root, &instance, &overrides) + .into_iter() + .collect(); + overrides + .properties + .retain(|property| !conflicts.contains(&property_conflict_key(property))); + overrides + .components + .retain(|component| !conflicts.contains(&component_conflict_key(component))); + overrides + .structural + .retain(|structural| !conflicts.contains(&structural_conflict_key(structural))); + track_current_source_revision(world, &instance, &mut overrides); + write_overrides(world, entity, &overrides); + record_prefab_edit_state(world, entity, old); +} + +pub fn revert_prefab_instance_overrides(world: &mut World, entity: Entity) { + let old = capture_prefab_edit_state(world, entity); + let Some(instance) = world.get::(entity).cloned() else { + return; + }; + let mut overrides = PrefabOverrides::default(); + track_current_source_revision(world, &instance, &mut overrides); + write_overrides(world, entity, &overrides); + record_prefab_edit_state(world, entity, old); +} + +fn remove_stale_visibility_overrides(world: &mut World, entity: Entity) { + let old = capture_prefab_edit_state(world, entity); + let Some(instance) = world.get::(entity).cloned() else { + return; + }; + let Some(project_root) = world + .get_resource::() + .map(|workspace| PathBuf::from(&workspace.root)) + else { + return; + }; + let known_keys = prefab_legacy_target_keys(&project_root, &instance); + let Some(mut overrides) = read_overrides_for_edit(world, &instance) else { + return; + }; + overrides + .child_visibility + .retain(|key, _| known_keys.contains(key)); + overrides + .properties + .retain(|property| source_target_exists(&project_root, &instance, &property.target)); + overrides + .components + .retain(|component| source_target_exists(&project_root, &instance, &component.target)); + overrides.structural.retain(|structural| match structural { + PrefabStructuralOverride::RemoveChild { target, .. } + | PrefabStructuralOverride::ReparentChild { target, .. } => { + source_target_exists(&project_root, &instance, target) + } + PrefabStructuralOverride::AttachLocalChild { parent, .. } => { + source_target_exists(&project_root, &instance, parent) + } + }); + track_current_source_revision(world, &instance, &mut overrides); + write_overrides(world, entity, &overrides); + record_prefab_edit_state(world, entity, old); +} + +fn discard_invalid_overrides(world: &mut World, entity: Entity) { + let old = capture_prefab_edit_state(world, entity); + let Some(mut instance) = world.get::(entity).cloned() else { + return; + }; + instance.overrides_ron = None; + world.entity_mut(entity).insert(instance); + record_prefab_edit_state(world, entity, old); +} + +fn relink_prefab_source(world: &mut World, entity: Entity) { + let Some(project_root) = world + .get_resource::() + .map(|workspace| PathBuf::from(&workspace.root)) + else { + return; + }; + let Some(path) = rfd::FileDialog::new() + .set_directory(project_root.join("assets/prefabs")) + .add_filter("Bevy prefab", &["scn.ron", "ron"]) + .pick_file() + else { + return; + }; + let Ok(path) = path.canonicalize() else { + return; + }; + let Ok(root) = project_root.canonicalize() else { + return; + }; + let Ok(relative) = path.strip_prefix(&root) else { + world.resource_mut::().status = + "Prefab source must remain inside the project".into(); + return; + }; + let source_path = relative.to_string_lossy().replace('\\', "/"); + if let Err(error) = scene::validate_prefab_graph(&path, &root) { + world.resource_mut::().status = + format!("Prefab relink failed: {error}"); + return; + } + let old = capture_prefab_edit_state(world, entity); + let Some(mut instance) = world.get::(entity).cloned() else { + return; + }; + instance.source_path = source_path.clone(); + let label = path + .file_stem() + .and_then(|value| value.to_str()) + .unwrap_or("Prefab") + .to_string(); + let registration = world + .get_resource_mut::() + .ok_or_else(|| "asset registry is unavailable".to_string()) + .and_then(|mut registry| { + ensure_asset_record(&mut registry, source_path.clone(), label, "Prefab") + }); + let record = match registration { + Ok(record) => record, + Err(error) => { + world.resource_mut::().status = + format!("Prefab relink failed: could not register source: {error}"); + return; + } + }; + instance.asset_id = record.id.as_string(); + let Some(mut overrides) = read_overrides_for_edit(world, &instance) else { + return; + }; + track_current_source_revision(world, &instance, &mut overrides); + instance.overrides_ron = encode_prefab_overrides(&overrides).ok().flatten(); + world + .entity_mut(entity) + .remove::() + .insert((instance, PrefabRef::new(source_path))); + record_prefab_edit_state(world, entity, old); + world.resource_mut::().status = "Prefab source relinked".into(); } pub fn read_overrides(instance: &PrefabInstance) -> PrefabOverrides { + try_read_overrides(instance).unwrap_or_default() +} + +pub fn try_read_overrides(instance: &PrefabInstance) -> Result { + decode_prefab_overrides(instance) +} + +fn read_overrides_for_edit( + world: &mut World, + instance: &PrefabInstance, +) -> Option { + match try_read_overrides(instance) { + Ok(overrides) => Some(overrides), + Err(error) => { + world.resource_mut::().status = + format!("Prefab edit blocked: {error}. Use Discard invalid data to recover."); + None + } + } +} + +pub fn new_tracked_prefab_instance( + world: &World, + asset_id: impl Into, + source_path: impl Into, +) -> PrefabInstance { + let mut instance = PrefabInstance::new(asset_id, source_path); + let mut overrides = PrefabOverrides::default(); + track_current_source_revision(world, &instance, &mut overrides); + if overrides != PrefabOverrides::default() { + instance.overrides_ron = encode_prefab_overrides(&overrides).ok().flatten(); + } instance - .overrides_ron - .as_ref() - .and_then(|ron| ron::from_str(ron).ok()) - .unwrap_or_default() } pub fn write_overrides(world: &mut World, entity: Entity, overrides: &PrefabOverrides) { let Some(mut instance) = world.get::(entity).cloned() else { return; }; - instance.overrides_ron = if overrides == &PrefabOverrides::default() { - None - } else { - Some(ron::to_string(overrides).unwrap_or_default()) - }; + instance.overrides_ron = encode_prefab_overrides(overrides).unwrap_or_default(); if let Ok(mut entity_mut) = world.get_entity_mut(entity) { entity_mut.insert(instance); } } -pub fn has_override(overrides: &PrefabOverrides, field: PrefabOverrideField) -> bool { - match field { - PrefabOverrideField::Transform => overrides.transform.is_some(), - PrefabOverrideField::Material => overrides.material.is_some(), - PrefabOverrideField::ChildVisibility => !overrides.child_visibility.is_empty(), +pub fn set_child_visibility_override( + world: &mut World, + entity: Entity, + child: Entity, + visible: bool, +) { + let Some((instance_root, target)) = prefab_member_override_target(world, child) else { + world.resource_mut::().status = + "Cannot store prefab visibility: target has no stable prefab path".into(); + return; + }; + if instance_root != entity { + return; } + let Some(instance) = world.get::(instance_root).cloned() else { + return; + }; + let component_type = std::any::type_name::().to_string(); + let value = EditorVisibility { visible }; + let value_component_ron = match ron::to_string(&value) { + Ok(value) => value, + Err(_) => return, + }; + let source_base = world + .get_resource::() + .map(|workspace| PathBuf::from(&workspace.root)) + .and_then(|project_root| { + source_component_for_target(&project_root, &instance, &target, &component_type) + .ok() + .flatten() + .map(|component| component.ron) + }); + + let old = capture_prefab_edit_state(world, instance_root); + let Some(mut overrides) = read_overrides_for_edit(world, &instance) else { + return; + }; + overrides.child_visibility.remove(&target.actor_id); + let base_component_ron = overrides + .components + .iter() + .find(|candidate| candidate.target == target && candidate.component_type == component_type) + .and_then(|candidate| candidate.base_component_ron.clone()) + .or(source_base); + overrides.components.retain(|candidate| { + !(candidate.target == target && candidate.component_type == component_type) + }); + overrides.components.push(PrefabComponentOverride { + target, + component_type, + base_component_ron, + value_component_ron: Some(value_component_ron), + }); + track_current_source_revision(world, &instance, &mut overrides); + write_overrides(world, instance_root, &overrides); + world.entity_mut(child).insert(value); + record_prefab_edit_state(world, instance_root, old); } -pub fn capture_transform_override(world: &mut World, entity: Entity) { - let Some(transform) = world.get::(entity).copied() else { - return; - }; - let Some(instance) = world.get::(entity) else { - return; - }; - let mut overrides = read_overrides(instance); - overrides.transform = Some(transform); - write_overrides(world, entity, &overrides); +pub fn prefab_member_override_target( + world: &World, + member: Entity, +) -> Option<(Entity, PrefabActorPath)> { + let actor_id = world.get::(member)?.0.clone(); + let mut instance_root = world.get::(member)?.instance_root; + let mut instance_chain = Vec::new(); + let mut seen = HashSet::new(); + while seen.insert(instance_root) { + let Some(owner) = world.get::(instance_root) else { + break; + }; + instance_chain.insert(0, world.get::(instance_root)?.0.clone()); + instance_root = owner.instance_root; + } + world.get::(instance_root)?; + Some(( + instance_root, + PrefabActorPath { + instance_chain, + actor_id, + }, + )) } -pub fn revert_transform_override(world: &mut World, entity: Entity) { - let Some(instance) = world.get::(entity).cloned() else { +pub fn set_prefab_material_property_override( + world: &mut World, + member: Entity, + property_path: &str, + value: MaterialDesc, +) { + record_prefab_property_override(world, member, property_path, value); +} + +pub fn set_prefab_transform_property_override( + world: &mut World, + member: Entity, + property_path: &str, + value: Transform, +) { + record_prefab_property_override(world, member, property_path, value); +} + +fn record_prefab_property_override( + world: &mut World, + member: Entity, + property_path: &str, + value: T, +) where + T: Component + Clone + serde::Serialize, +{ + let Some((instance_root, target)) = prefab_member_override_target(world, member) else { return; }; - let Some(old) = world.get::(entity).copied() else { + let Some(instance) = world.get::(instance_root).cloned() else { return; }; - let Some(source_transform) = prefab_root_transform(&instance.source_path) else { + let Some(current) = world.get::(member).cloned() else { + return; + }; + let component_type = std::any::type_name::().to_string(); + let current_ron = match ron::to_string(¤t) { + Ok(value) => value, + Err(error) => { + world.resource_mut::().status = + format!("Could not capture prefab property: {error}"); + return; + } + }; + let value_ron = match ron::to_string(&value) { + Ok(value) => value, + Err(error) => { + world.resource_mut::().status = + format!("Could not encode prefab property: {error}"); + return; + } + }; + + let old = capture_prefab_edit_state(world, instance_root); + let Some(mut overrides) = read_overrides_for_edit(world, &instance) else { + return; + }; + let base_component_ron = overrides + .properties + .iter() + .find(|candidate| candidate.target == target && candidate.component_type == component_type) + .map(|candidate| candidate.base_component_ron.clone()) + .unwrap_or(current_ron); + overrides.properties.retain(|candidate| { + !(candidate.target == target + && candidate.component_type == component_type + && candidate.property_path == property_path) + }); + overrides.properties.push(PrefabPropertyOverride { + target, + component_type, + property_path: property_path.to_string(), + base_component_ron, + value_component_ron: value_ron, + }); + track_current_source_revision(world, &instance, &mut overrides); + write_overrides(world, instance_root, &overrides); + world.entity_mut(member).insert(value); + record_prefab_edit_state(world, instance_root, old); +} + +pub fn remove_prefab_component_override(world: &mut World, member: Entity) +where + T: Component + Clone + serde::Serialize, +{ + let Some((instance_root, target)) = prefab_member_override_target(world, member) else { + return; + }; + let Some(instance) = world.get::(instance_root).cloned() else { + return; + }; + let Some(current) = world.get::(member).cloned() else { + return; + }; + let Ok(current_component_ron) = ron::to_string(¤t) else { + return; + }; + let component_type = std::any::type_name::().to_string(); + let old = capture_prefab_edit_state(world, instance_root); + let Some(mut overrides) = read_overrides_for_edit(world, &instance) else { + return; + }; + let existing_component = overrides + .components + .iter() + .find(|candidate| candidate.target == target && candidate.component_type == component_type); + let cancels_local_add = existing_component.is_some_and(|component| { + component.base_component_ron.is_none() && component.value_component_ron.is_some() + }); + let base_component_ron = existing_component + .and_then(|component| component.base_component_ron.clone()) + .or_else(|| { + overrides + .properties + .iter() + .find(|candidate| { + candidate.target == target && candidate.component_type == component_type + }) + .map(|property| property.base_component_ron.clone()) + }) + .or_else(|| { + world + .get_resource::() + .map(|workspace| PathBuf::from(&workspace.root)) + .and_then(|project_root| { + source_component_for_target(&project_root, &instance, &target, &component_type) + .ok() + .flatten() + .map(|component| component.ron) + }) + }) + .or(Some(current_component_ron)); + overrides.properties.retain(|candidate| { + !(candidate.target == target && candidate.component_type == component_type) + }); + overrides.components.retain(|candidate| { + !(candidate.target == target && candidate.component_type == component_type) + }); + if !cancels_local_add { + overrides.components.push(PrefabComponentOverride { + target, + component_type, + base_component_ron, + value_component_ron: None, + }); + } + track_current_source_revision(world, &instance, &mut overrides); + write_overrides(world, instance_root, &overrides); + world.entity_mut(member).remove::(); + record_prefab_edit_state(world, instance_root, old); +} + +pub fn set_prefab_component_override(world: &mut World, member: Entity, value: T) +where + T: Component + Clone + serde::Serialize, +{ + let Some((instance_root, target)) = prefab_member_override_target(world, member) else { + return; + }; + let Some(instance) = world.get::(instance_root).cloned() else { + return; + }; + let value_component_ron = match ron::to_string(&value) { + Ok(value) => value, + Err(_) => return, + }; + let component_type = std::any::type_name::().to_string(); + let base_component_ron = world + .get::(member) + .and_then(|current| ron::to_string(current).ok()); + let old = capture_prefab_edit_state(world, instance_root); + let Some(mut overrides) = read_overrides_for_edit(world, &instance) else { + return; + }; + let base_component_ron = overrides + .components + .iter() + .find(|candidate| candidate.target == target && candidate.component_type == component_type) + .and_then(|candidate| candidate.base_component_ron.clone()) + .or(base_component_ron); + overrides.components.retain(|candidate| { + !(candidate.target == target && candidate.component_type == component_type) + }); + overrides.components.push(PrefabComponentOverride { + target, + component_type, + base_component_ron, + value_component_ron: Some(value_component_ron), + }); + track_current_source_revision(world, &instance, &mut overrides); + write_overrides(world, instance_root, &overrides); + world.entity_mut(member).insert(value); + record_prefab_edit_state(world, instance_root, old); +} + +pub fn revert_prefab_override_scope( + world: &mut World, + member: Entity, + component_type: Option<&str>, + property_path: Option<&str>, +) { + let Some((instance_root, target)) = prefab_member_override_target(world, member) else { + return; + }; + revert_prefab_target_scope(world, instance_root, &target, component_type, property_path); +} + +fn revert_prefab_target_scope( + world: &mut World, + instance_root: Entity, + target: &PrefabActorPath, + component_type: Option<&str>, + property_path: Option<&str>, +) { + let Some(instance) = world.get::(instance_root).cloned() else { + return; + }; + let old = capture_prefab_edit_state(world, instance_root); + let Some(mut overrides) = read_overrides_for_edit(world, &instance) else { + return; + }; + overrides.properties.retain(|candidate| { + candidate.target != *target + || component_type.is_some_and(|value| candidate.component_type != value) + || property_path.is_some_and(|value| candidate.property_path != value) + }); + if property_path.is_none() { + overrides.components.retain(|candidate| { + candidate.target != *target + || component_type.is_some_and(|value| candidate.component_type != value) + }); + } + if component_type.is_none() && property_path.is_none() { + overrides + .structural + .retain(|candidate| structural_target(candidate) != target); + } + track_current_source_revision(world, &instance, &mut overrides); + write_overrides(world, instance_root, &overrides); + record_prefab_edit_state(world, instance_root, old); +} + +pub fn revert_prefab_actor_path( + world: &mut World, + instance_root: Entity, + target: &PrefabActorPath, +) { + revert_prefab_target_scope(world, instance_root, target, None, None); +} + +pub fn apply_prefab_member_scope_to_source( + world: &mut World, + member: Entity, + component_type: Option<&str>, + property_path: Option<&str>, +) { + let Some((instance_root, target)) = prefab_member_override_target(world, member) else { + return; + }; + apply_prefab_scope_to_source( + world, + instance_root, + Some(&target), + component_type, + property_path, + ); +} + +pub fn apply_prefab_instance_overrides_to_source(world: &mut World, instance_root: Entity) { + apply_prefab_scope_to_source(world, instance_root, None, None, None); +} + +pub fn apply_prefab_actor_path_to_source( + world: &mut World, + instance_root: Entity, + target: &PrefabActorPath, +) { + apply_prefab_scope_to_source(world, instance_root, Some(target), None, None); +} + +fn apply_prefab_scope_to_source( + world: &mut World, + instance_root: Entity, + target_filter: Option<&PrefabActorPath>, + component_type: Option<&str>, + property_path: Option<&str>, +) { + let Some(instance) = world.get::(instance_root).cloned() else { + return; + }; + let Some(project_root) = world + .get_resource::() + .map(|workspace| PathBuf::from(&workspace.root)) + else { + return; + }; + let source_path = project_root.join(&instance.source_path); + let before_source = match std::fs::read_to_string(&source_path) { + Ok(source) => source, + Err(error) => { + world.resource_mut::().status = + format!("Apply to source failed: {error}"); + return; + } + }; + let mut document = match SceneDocument::from_ron_text(&before_source) { + Ok(document) => document, + Err(error) => { + world.resource_mut::().status = + format!("Apply to source failed: {error}"); + return; + } + }; + let old = capture_prefab_edit_state(world, instance_root); + let Some(mut overrides) = read_overrides_for_edit(world, &instance) else { + return; + }; + if let Err(error) = + promote_legacy_visibility_overrides(&project_root, &instance, &mut overrides) + { world.resource_mut::().status = - "Could not read prefab source for revert".into(); + format!("Apply to source failed: {error}"); return; - }; - set_transform_with_history(world, entity, old, source_transform); - let mut overrides = read_overrides(&instance); - overrides.transform = None; - write_overrides(world, entity, &overrides); -} - -pub fn apply_material_override(world: &mut World, entity: Entity) { - let Some(instance) = world.get::(entity).cloned() else { - return; - }; - let Some(material) = world.get::(entity).cloned() else { - world.resource_mut::().status = - "Prefab instance has no MaterialDesc to capture".into(); - return; - }; - let mut overrides = read_overrides(&instance); - overrides.material = Some(material); - write_overrides(world, entity, &overrides); - world.resource_mut::().status = "Material override applied".into(); -} - -pub fn revert_material_override(world: &mut World, entity: Entity) { - let Some(instance) = world.get::(entity).cloned() else { - return; - }; - let Some(source_material) = prefab_root_material(&instance.source_path) else { - world.resource_mut::().status = - "Could not read prefab source material".into(); - return; - }; - let old = world.get::(entity).cloned(); - if old.is_some() { - set_material_with_history(world, entity, source_material.clone()); + } + let selected_properties: Vec<_> = overrides + .properties + .iter() + .filter(|candidate| { + target_filter.is_none_or(|target| candidate.target == *target) + && component_type.is_none_or(|value| candidate.component_type == value) + && property_path.is_none_or(|value| candidate.property_path == value) + }) + .cloned() + .collect(); + let selected_components: Vec<_> = overrides + .components + .iter() + .filter(|candidate| { + target_filter.is_none_or(|target| candidate.target == *target) + && component_type.is_none_or(|value| candidate.component_type == value) + && (property_path.is_none() + || (candidate.base_component_ron.is_none() + && candidate.value_component_ron.is_some())) + }) + .cloned() + .collect(); + let selected_structural: Vec<_> = if component_type.is_none() && property_path.is_none() { + overrides + .structural + .iter() + .filter(|candidate| { + target_filter.is_none_or(|target| structural_target(candidate) == target) + }) + .cloned() + .collect() } else { - world.entity_mut(entity).insert(source_material.clone()); + Vec::new() + }; + if selected_properties.is_empty() + && selected_components.is_empty() + && selected_structural.is_empty() + { + world.resource_mut::().status = + "No prefab overrides in this scope".into(); + return; } - let mut overrides = read_overrides(&instance); - overrides.material = None; - write_overrides(world, entity, &overrides); + + let apply_result = (|| { + for structural in &selected_structural { + validate_structural_apply_base(&project_root, &instance, structural)?; + } + for component in &selected_components { + validate_component_apply_base( + &project_root, + &instance, + component, + &selected_structural, + )?; + } + for property in &selected_properties { + validate_property_apply_base( + &project_root, + &instance, + property, + &selected_components, + &selected_structural, + )?; + } + for structural in &selected_structural { + if matches!(structural, PrefabStructuralOverride::ReparentChild { .. }) { + apply_or_transfer_structural(&mut document, structural, &project_root)?; + } + } + for component in &selected_components { + if component.target.instance_chain.is_empty() { + apply_component_to_document(&mut document, component)?; + } else { + transfer_component_to_nested_layer( + &mut document, + component.clone(), + &project_root, + )?; + } + } + for property in &selected_properties { + if property.target.instance_chain.is_empty() { + apply_property_to_document(&mut document, property)?; + } else { + transfer_property_to_nested_layer(&mut document, property.clone(), &project_root)?; + } + } + for structural in &selected_structural { + if !matches!(structural, PrefabStructuralOverride::ReparentChild { .. }) { + apply_or_transfer_structural(&mut document, structural, &project_root)?; + } + } + let after_source = document.to_ron_text()?; + scene::validate_prefab_graph_text(&after_source, &source_path, &project_root)?; + crate::scene::recovery::atomic_write(&source_path, after_source.as_bytes())?; + Ok::<_, String>(after_source) + })(); + let after_source = match apply_result { + Ok(source) => source, + Err(error) => { + world.resource_mut::().status = + format!("Apply to source failed: {error}"); + return; + } + }; + + overrides + .properties + .retain(|candidate| !selected_properties.contains(candidate)); + overrides + .components + .retain(|candidate| !selected_components.contains(candidate)); + overrides + .structural + .retain(|candidate| !selected_structural.contains(candidate)); + track_current_source_revision(world, &instance, &mut overrides); + write_overrides(world, instance_root, &overrides); + record_prefab_source_apply( + world, + instance_root, + source_path, + before_source, + after_source, + old, + ); + world.resource_mut::().status = + "Prefab overrides applied to source".into(); } -pub fn apply_child_visibility_override(world: &mut World, entity: Entity, child: Entity) { - let Some(instance) = world.get::(entity).cloned() else { - return; - }; - let Some(name) = world.get::(child).map(|n| n.to_string()) else { - return; - }; - let visible = world - .get::(child) - .map(|v| v.visible) - .unwrap_or(true); +fn apply_or_transfer_structural( + document: &mut SceneDocument, + structural: &PrefabStructuralOverride, + project_root: &Path, +) -> Result<(), String> { + if structural_target(structural).instance_chain.is_empty() { + apply_structural_to_document(document, structural) + } else { + transfer_structural_to_nested_layer(document, structural.clone(), project_root) + } +} - let mut overrides = read_overrides(&instance); - overrides.child_visibility.insert(name, visible); - write_overrides(world, entity, &overrides); +fn validate_property_apply_base( + project_root: &Path, + instance: &PrefabInstance, + property: &PrefabPropertyOverride, + selected_components: &[PrefabComponentOverride], + selected_structural: &[PrefabStructuralOverride], +) -> Result<(), String> { + let mut current = source_component_for_target( + project_root, + instance, + &property.target, + &property.component_type, + )?; + if property.component_type == std::any::type_name::() { + if let Some(local_transform_ron) = + selected_structural + .iter() + .find_map(|structural| match structural { + PrefabStructuralOverride::ReparentChild { + target, + local_transform_ron, + .. + } if target == &property.target => local_transform_ron.clone(), + _ => None, + }) + { + current = Some(SceneComponentBlob::from_ron( + std::any::type_name::(), + local_transform_ron, + )?); + } + } + for component in selected_components.iter().filter(|component| { + component.target == property.target && component.component_type == property.component_type + }) { + let current_ron = current.as_ref().map(|value| value.ron.as_str()); + let base_matches = match (&component.base_component_ron, current_ron) { + (None, None) => true, + (Some(base), Some(current)) => { + component_values_equal(&component.component_type, current, base) + } + _ => false, + }; + if !base_matches { + return Err(format!( + "conflict: source component `{}` changed", + component.component_type + )); + } + current = component + .value_component_ron + .as_ref() + .map(|value| { + SceneComponentBlob::from_ron(component.component_type.clone(), value.clone()) + }) + .transpose()?; + } + let source = current.ok_or_else(|| { + format!( + "source actor `{}` is missing component `{}`", + actor_path_key(&property.target), + property.component_type + ) + })?; + if property.component_type == std::any::type_name::() { + apply_transform_property(&source.ron, property).map(|_| ()) + } else if property.component_type == std::any::type_name::() { + apply_material_property(&source.ron, property).map(|_| ()) + } else { + Err(format!( + "property apply does not support component `{}`", + property.component_type + )) + } +} + +fn validate_component_apply_base( + project_root: &Path, + instance: &PrefabInstance, + component: &PrefabComponentOverride, + selected_structural: &[PrefabStructuralOverride], +) -> Result<(), String> { + let mut current = source_component_for_target( + project_root, + instance, + &component.target, + &component.component_type, + )?; + if component.component_type == std::any::type_name::() { + if let Some(local_transform_ron) = + selected_structural + .iter() + .find_map(|structural| match structural { + PrefabStructuralOverride::ReparentChild { + target, + local_transform_ron, + .. + } if target == &component.target => local_transform_ron.clone(), + _ => None, + }) + { + current = Some(SceneComponentBlob::from_ron( + std::any::type_name::(), + local_transform_ron, + )?); + } + } + let current_ron = current.as_ref().map(|value| value.ron.as_str()); + let base_matches = match (&component.base_component_ron, current_ron) { + (None, None) => true, + (Some(base), Some(current)) => { + component_values_equal(&component.component_type, current, base) + } + _ => false, + }; + if base_matches { + Ok(()) + } else { + Err(format!( + "conflict: source component `{}` changed", + component.component_type + )) + } +} + +fn structural_target(structural: &PrefabStructuralOverride) -> &PrefabActorPath { + match structural { + PrefabStructuralOverride::RemoveChild { target, .. } + | PrefabStructuralOverride::ReparentChild { target, .. } => target, + PrefabStructuralOverride::AttachLocalChild { parent, .. } => parent, + } +} + +fn promote_legacy_visibility_overrides( + project_root: &Path, + instance: &PrefabInstance, + overrides: &mut PrefabOverrides, +) -> Result<(), String> { + if overrides.child_visibility.is_empty() { + return Ok(()); + } + let direct = PrefabActorPath::direct(""); + let document = source_document_for_target(project_root, instance, &direct)?; + let component_type = std::any::type_name::().to_string(); + let legacy = std::mem::take(&mut overrides.child_visibility); + for (key, visible) in legacy { + let source = document + .entities + .iter() + .find(|entity| { + entity.actor_id.as_deref() == Some(key.as_str()) + || entity.name.as_deref() == Some(key.as_str()) + }) + .ok_or_else(|| format!("legacy visibility target `{key}` no longer exists"))?; + let actor_id = source + .actor_id + .clone() + .ok_or_else(|| format!("legacy visibility target `{key}` has no ActorId"))?; + let target = PrefabActorPath::direct(actor_id); + let base_component_ron = source + .components + .iter() + .find(|component| component.type_name == component_type) + .map(|component| component.ron.clone()); + overrides.components.retain(|candidate| { + !(candidate.target == target && candidate.component_type == component_type) + }); + overrides.components.push(PrefabComponentOverride { + target, + component_type: component_type.clone(), + base_component_ron, + value_component_ron: Some( + ron::to_string(&EditorVisibility { visible }).map_err(|error| error.to_string())?, + ), + }); + } + Ok(()) +} + +fn apply_property_to_document( + document: &mut SceneDocument, + property: &PrefabPropertyOverride, +) -> Result<(), String> { + let target = SceneEntityId::ActorId(property.target.actor_id.clone()); + let source = document + .find_entity(&target) + .and_then(|entity| { + entity + .components + .iter() + .find(|component| component.type_name == property.component_type) + }) + .cloned() + .ok_or_else(|| { + format!( + "source actor `{}` is missing component `{}`", + property.target.actor_id, property.component_type + ) + })?; + let ron = if property.component_type == std::any::type_name::() { + apply_transform_property(&source.ron, property)? + } else if property.component_type == std::any::type_name::() { + apply_material_property(&source.ron, property)? + } else { + return Err(format!( + "property apply does not support component `{}`", + property.component_type + )); + }; + document.apply_patch(ScenePatch::SetComponent { + target, + component: SceneComponentBlob::from_ron(property.component_type.clone(), ron)?, + }) +} + +fn apply_transform_property( + source_ron: &str, + property: &PrefabPropertyOverride, +) -> Result { + let mut source: Transform = ron::from_str(source_ron).map_err(|error| error.to_string())?; + let base: Transform = + ron::from_str(&property.base_component_ron).map_err(|error| error.to_string())?; + let value: Transform = + ron::from_str(&property.value_component_ron).map_err(|error| error.to_string())?; + let conflict = match property.property_path.as_str() { + "translation" => { + let conflict = source.translation != base.translation; + source.translation = value.translation; + conflict + } + "rotation" => { + let conflict = source.rotation != base.rotation; + source.rotation = value.rotation; + conflict + } + "scale" => { + let conflict = source.scale != base.scale; + source.scale = value.scale; + conflict + } + path => return Err(format!("unsupported Transform property `{path}`")), + }; + if conflict { + return Err(format!( + "conflict: source Transform.{} changed since the override was created", + property.property_path + )); + } + ron::to_string(&source).map_err(|error| error.to_string()) +} + +fn apply_material_property( + source_ron: &str, + property: &PrefabPropertyOverride, +) -> Result { + let mut source: MaterialDesc = ron::from_str(source_ron).map_err(|error| error.to_string())?; + let base: MaterialDesc = + ron::from_str(&property.base_component_ron).map_err(|error| error.to_string())?; + let value: MaterialDesc = + ron::from_str(&property.value_component_ron).map_err(|error| error.to_string())?; + let conflict = match property.property_path.as_str() { + "base_color" => { + let conflict = source.base_color != base.base_color; + source.base_color = value.base_color; + conflict + } + "metallic" => { + let conflict = source.metallic != base.metallic; + source.metallic = value.metallic; + conflict + } + "roughness" => { + let conflict = source.roughness != base.roughness; + source.roughness = value.roughness; + conflict + } + path => return Err(format!("unsupported MaterialDesc property `{path}`")), + }; + if conflict { + return Err(format!( + "conflict: source MaterialDesc.{} changed since the override was created", + property.property_path + )); + } + ron::to_string(&source).map_err(|error| error.to_string()) +} + +fn apply_component_to_document( + document: &mut SceneDocument, + component: &PrefabComponentOverride, +) -> Result<(), String> { + let target = SceneEntityId::ActorId(component.target.actor_id.clone()); + let current = document.find_entity(&target).and_then(|entity| { + entity + .components + .iter() + .find(|candidate| candidate.type_name == component.component_type) + }); + match (&component.base_component_ron, current) { + (None, Some(_)) => { + return Err(format!( + "conflict: source actor `{}` gained component `{}`", + component.target.actor_id, component.component_type + )); + } + (Some(base), Some(current)) + if !component_values_equal(&component.component_type, ¤t.ron, base) => + { + return Err(format!( + "conflict: source component `{}` changed", + component.component_type + )); + } + (Some(_), None) => { + return Err(format!( + "conflict: source component `{}` was removed", + component.component_type + )); + } + _ => {} + } + match &component.value_component_ron { + Some(value) => document.apply_patch(ScenePatch::SetComponent { + target, + component: SceneComponentBlob::from_ron( + component.component_type.clone(), + value.clone(), + )?, + }), + None => document.apply_patch(ScenePatch::RemoveComponent { + target, + component_type: component.component_type.clone(), + }), + } +} + +fn component_values_equal(component_type: &str, left: &str, right: &str) -> bool { + if component_type == std::any::type_name::() { + return ron::from_str::(left).ok() == ron::from_str::(right).ok(); + } + if component_type == std::any::type_name::() { + return ron::from_str::(left).ok() + == ron::from_str::(right).ok(); + } + ron::from_str::(left).ok() == ron::from_str::(right).ok() +} + +fn apply_structural_to_document( + document: &mut SceneDocument, + structural: &PrefabStructuralOverride, +) -> Result<(), String> { + match structural { + PrefabStructuralOverride::RemoveChild { target, .. } => { + document.apply_patch(ScenePatch::DeleteEntity { + target: SceneEntityId::ActorId(target.actor_id.clone()), + }) + } + PrefabStructuralOverride::ReparentChild { + target, + new_parent, + local_transform_ron, + .. + } => { + if new_parent + .as_ref() + .is_some_and(|parent| !parent.instance_chain.is_empty()) + { + return Err("cannot apply a cross-layer parent to this source".to_string()); + } + if document_reparent_would_cycle(document, target, new_parent.as_ref()) { + return Err(format!( + "reparenting `{}` would create a hierarchy cycle", + actor_path_key(target) + )); + } + document.apply_patch(ScenePatch::Reparent { + target: SceneEntityId::ActorId(target.actor_id.clone()), + parent_actor_id: new_parent.as_ref().map(|parent| parent.actor_id.clone()), + })?; + if let Some(transform_ron) = local_transform_ron { + document.apply_patch(ScenePatch::SetComponent { + target: SceneEntityId::ActorId(target.actor_id.clone()), + component: SceneComponentBlob::from_ron( + std::any::type_name::(), + transform_ron.clone(), + )?, + })?; + } + Ok(()) + } + PrefabStructuralOverride::AttachLocalChild { .. } => { + Err("local child attachment must be converted before source apply".to_string()) + } + } +} + +fn document_reparent_would_cycle( + document: &SceneDocument, + target: &PrefabActorPath, + new_parent: Option<&PrefabActorPath>, +) -> bool { + let Some(new_parent) = new_parent else { + return false; + }; + let mut current = Some(new_parent.actor_id.as_str()); + let mut seen = HashSet::new(); + while let Some(actor_id) = current { + if actor_id == target.actor_id || !seen.insert(actor_id.to_string()) { + return true; + } + current = document + .find_entity(&SceneEntityId::ActorId(actor_id.to_string())) + .and_then(|entity| entity.parent_actor_id.as_deref()); + } + false +} + +fn validate_structural_apply_base( + project_root: &Path, + instance: &PrefabInstance, + structural: &PrefabStructuralOverride, +) -> Result<(), String> { + let target = structural_target(structural); + let document = source_document_for_target(project_root, instance, target)?; + let _source = document + .find_entity(&SceneEntityId::ActorId(target.actor_id.clone())) + .ok_or_else(|| { + format!( + "conflict: source actor `{}` no longer exists", + actor_path_key(target) + ) + })?; + match structural { + PrefabStructuralOverride::RemoveChild { + base_subtree_revision, + .. + } => { + let current = prefab_source_revision(project_root, &instance.source_path)?; + if current != *base_subtree_revision { + return Err(format!( + "conflict: source subtree `{}` changed since removal was authored", + actor_path_key(target) + )); + } + } + PrefabStructuralOverride::ReparentChild { + base_parent, + new_parent, + base_transform_ron, + .. + } => { + let current_parent = source_parent_for_target(project_root, instance, target)?; + if current_parent != *base_parent { + return Err(format!( + "conflict: source parent for `{}` changed", + actor_path_key(target) + )); + } + if let Some(base) = base_transform_ron { + let current = source_component_for_target( + project_root, + instance, + target, + std::any::type_name::(), + )? + .ok_or_else(|| { + format!( + "conflict: source transform for `{}` was removed", + actor_path_key(target) + ) + })?; + if !component_values_equal(std::any::type_name::(), ¤t.ron, base) { + return Err(format!( + "conflict: source transform for `{}` changed", + actor_path_key(target) + )); + } + } + if let Some(parent) = new_parent { + if parent.instance_chain != target.instance_chain + || source_parent_for_target(project_root, instance, parent).is_err() + { + return Err(format!( + "conflict: destination parent `{}` is unavailable", + actor_path_key(parent) + )); + } + } + } + PrefabStructuralOverride::AttachLocalChild { .. } => {} + } + Ok(()) +} + +fn transfer_property_to_nested_layer( + document: &mut SceneDocument, + mut property: PrefabPropertyOverride, + project_root: &Path, +) -> Result<(), String> { + let anchor = property.target.instance_chain.remove(0); + update_nested_instance(document, &anchor, project_root, move |overrides, _| { + merge_property_override(overrides, property); + }) +} + +fn transfer_component_to_nested_layer( + document: &mut SceneDocument, + mut component: PrefabComponentOverride, + project_root: &Path, +) -> Result<(), String> { + let anchor = component.target.instance_chain.remove(0); + update_nested_instance(document, &anchor, project_root, move |overrides, _| { + merge_component_override(overrides, component); + }) +} + +fn transfer_structural_to_nested_layer( + document: &mut SceneDocument, + mut structural: PrefabStructuralOverride, + project_root: &Path, +) -> Result<(), String> { + let anchor = structural_target(&structural) + .instance_chain + .first() + .cloned() + .ok_or_else(|| "nested structural override has no instance path".to_string())?; + trim_structural_instance_prefix(&mut structural, &anchor)?; + update_nested_instance( + document, + &anchor, + project_root, + move |overrides, nested_instance| { + let base_revision = + prefab_source_revision(project_root, &nested_instance.source_path).ok(); + merge_structural_override(overrides, structural, base_revision); + }, + ) +} + +fn merge_property_override(overrides: &mut PrefabOverrides, mut property: PrefabPropertyOverride) { + if let Some(existing) = overrides.properties.iter().find(|candidate| { + candidate.target == property.target + && candidate.component_type == property.component_type + && candidate.property_path == property.property_path + }) { + property.base_component_ron = existing.base_component_ron.clone(); + } + overrides.properties.retain(|candidate| { + !(candidate.target == property.target + && candidate.component_type == property.component_type + && candidate.property_path == property.property_path) + }); + overrides.properties.push(property); +} + +fn merge_component_override( + overrides: &mut PrefabOverrides, + mut component: PrefabComponentOverride, +) { + if let Some(existing) = overrides.components.iter().find(|candidate| { + candidate.target == component.target && candidate.component_type == component.component_type + }) { + component.base_component_ron = existing.base_component_ron.clone(); + } + overrides.components.retain(|candidate| { + !(candidate.target == component.target + && candidate.component_type == component.component_type) + }); + overrides.components.push(component); +} + +fn merge_structural_override( + overrides: &mut PrefabOverrides, + mut structural: PrefabStructuralOverride, + base_revision: Option, +) { + let target = structural_target(&structural).clone(); + let existing = overrides + .structural + .iter() + .find(|candidate| { + std::mem::discriminant(*candidate) == std::mem::discriminant(&structural) + && structural_target(candidate) == &target + }) + .cloned(); + match (&mut structural, existing) { + ( + PrefabStructuralOverride::RemoveChild { + base_subtree_revision, + .. + }, + Some(PrefabStructuralOverride::RemoveChild { + base_subtree_revision: existing, + .. + }), + ) => *base_subtree_revision = existing, + ( + PrefabStructuralOverride::ReparentChild { + base_parent, + base_transform_ron, + .. + }, + Some(PrefabStructuralOverride::ReparentChild { + base_parent: existing_parent, + base_transform_ron: existing_transform, + .. + }), + ) => { + *base_parent = existing_parent; + *base_transform_ron = existing_transform; + } + ( + PrefabStructuralOverride::RemoveChild { + base_subtree_revision, + .. + }, + None, + ) => { + if let Some(revision) = base_revision { + *base_subtree_revision = revision; + } + } + _ => {} + } + overrides.structural.retain(|candidate| { + std::mem::discriminant(candidate) != std::mem::discriminant(&structural) + || structural_target(candidate) != &target + }); + overrides.structural.push(structural); +} + +pub fn fold_nested_overrides_for_unpack( + world: &mut World, + instance_root: Entity, +) -> Result<(), String> { + let instance = world + .get::(instance_root) + .cloned() + .ok_or_else(|| "prefab instance is missing".to_string())?; + let outer = try_read_overrides(&instance)?; + let nested_entities: Vec<(Entity, String, PrefabInstance)> = world + .query::<(Entity, &HydratedPrefabMember, &ActorId, &PrefabInstance)>() + .iter(world) + .filter(|(_, owner, _, _)| owner.instance_root == instance_root) + .map(|(entity, _, actor_id, nested)| (entity, actor_id.0.clone(), nested.clone())) + .collect(); + let project_root = world + .get_resource::() + .map(|workspace| PathBuf::from(&workspace.root)); + let mut updates = Vec::new(); + for (entity, anchor_actor_id, mut nested_instance) in nested_entities { + let mut nested_overrides = try_read_overrides(&nested_instance)?; + let mut changed = false; + for mut property in outer + .properties + .iter() + .filter(|property| property.target.instance_chain.first() == Some(&anchor_actor_id)) + .cloned() + { + property.target.instance_chain.remove(0); + merge_property_override(&mut nested_overrides, property); + changed = true; + } + for mut component in outer + .components + .iter() + .filter(|component| component.target.instance_chain.first() == Some(&anchor_actor_id)) + .cloned() + { + component.target.instance_chain.remove(0); + merge_component_override(&mut nested_overrides, component); + changed = true; + } + for mut structural in outer + .structural + .iter() + .filter(|structural| { + structural_target(structural).instance_chain.first() == Some(&anchor_actor_id) + }) + .cloned() + { + trim_structural_instance_prefix(&mut structural, &anchor_actor_id)?; + let base_revision = project_root + .as_ref() + .and_then(|root| prefab_source_revision(root, &nested_instance.source_path).ok()); + merge_structural_override(&mut nested_overrides, structural, base_revision); + changed = true; + } + if !changed { + continue; + } + if let Some(root) = project_root.as_ref() { + nested_overrides.source_revision = + prefab_source_revision(root, &nested_instance.source_path).ok(); + } + nested_instance.overrides_ron = encode_prefab_overrides(&nested_overrides)?; + updates.push((entity, nested_instance)); + } + for (entity, nested_instance) in updates { + world.entity_mut(entity).insert(nested_instance); + } + Ok(()) +} + +fn trim_structural_instance_prefix( + structural: &mut PrefabStructuralOverride, + prefix: &str, +) -> Result<(), String> { + let trim = |path: &mut PrefabActorPath| -> Result<(), String> { + if path.instance_chain.first().map(String::as_str) != Some(prefix) { + return Err("structural override crosses an instance layer".to_string()); + } + path.instance_chain.remove(0); + Ok(()) + }; + match structural { + PrefabStructuralOverride::RemoveChild { target, .. } => trim(target), + PrefabStructuralOverride::ReparentChild { + target, + base_parent, + new_parent, + .. + } => { + trim(target)?; + if let Some(parent) = base_parent { + trim(parent)?; + } + if let Some(parent) = new_parent { + trim(parent)?; + } + Ok(()) + } + PrefabStructuralOverride::AttachLocalChild { parent, .. } => trim(parent), + } +} + +fn update_nested_instance( + document: &mut SceneDocument, + anchor_actor_id: &str, + project_root: &Path, + update: impl FnOnce(&mut PrefabOverrides, &PrefabInstance), +) -> Result<(), String> { + let target = SceneEntityId::ActorId(anchor_actor_id.to_string()); + let component = document + .find_entity(&target) + .and_then(|entity| { + entity + .components + .iter() + .find(|component| component.type_name == std::any::type_name::()) + }) + .cloned() + .ok_or_else(|| format!("nested prefab anchor `{anchor_actor_id}` is missing"))?; + let mut instance: PrefabInstance = + ron::from_str(&component.ron).map_err(|error| error.to_string())?; + let mut overrides = decode_prefab_overrides(&instance)?; + update(&mut overrides, &instance); + overrides.source_revision = prefab_source_revision(project_root, &instance.source_path).ok(); + instance.overrides_ron = encode_prefab_overrides(&overrides)?; + document.apply_patch(ScenePatch::SetComponent { + target, + component: SceneComponentBlob::from_ron( + std::any::type_name::(), + ron::to_string(&instance).map_err(|error| error.to_string())?, + )?, + }) +} + +pub fn remove_prefab_source_child(world: &mut World, member: Entity) -> bool { + let Some((instance_root, target)) = prefab_member_override_target(world, member) else { + return false; + }; + let Some(instance) = world.get::(instance_root).cloned() else { + return false; + }; + let old = capture_prefab_edit_state(world, instance_root); + let Some(mut overrides) = read_overrides_for_edit(world, &instance) else { + return false; + }; + overrides + .properties + .retain(|candidate| candidate.target != target); + overrides + .components + .retain(|candidate| candidate.target != target); + overrides + .structural + .retain(|candidate| structural_target(candidate) != &target); + let base_subtree_revision = current_source_revision(world, &instance).unwrap_or_default(); + overrides + .structural + .push(PrefabStructuralOverride::RemoveChild { + target, + base_subtree_revision, + }); + track_current_source_revision(world, &instance, &mut overrides); + write_overrides(world, instance_root, &overrides); + if let Ok(entity_mut) = world.get_entity_mut(member) { + entity_mut.despawn(); + } + record_prefab_edit_state(world, instance_root, old); + true +} + +pub fn reparent_prefab_source_child( + world: &mut World, + member: Entity, + new_parent: Option, +) -> bool { + reparent_prefab_source_children(world, &[member], new_parent) +} + +pub fn reparent_prefab_source_children( + world: &mut World, + members: &[Entity], + new_parent: Option, +) -> bool { + let Some(first) = members.first().copied() else { + return false; + }; + let Some((instance_root, first_target)) = prefab_member_override_target(world, first) else { + return false; + }; + let new_parent_target = match new_parent { + Some(parent) => { + let Some((parent_root, parent_target)) = prefab_member_override_target(world, parent) + else { + return false; + }; + if parent_root != instance_root + || parent_target.instance_chain != first_target.instance_chain + { + return false; + } + Some(parent_target) + } + None => None, + }; + let runtime_parent = new_parent.unwrap_or(instance_root); + let mut plans = Vec::with_capacity(members.len()); + for member in members.iter().copied() { + let Some((owner, target)) = prefab_member_override_target(world, member) else { + return false; + }; + if owner != instance_root || target.instance_chain != first_target.instance_chain { + return false; + } + if hierarchy_would_cycle(world, member, runtime_parent) { + world.resource_mut::().status = + "Prefab reparent blocked: hierarchy cycle".into(); + return false; + } + let new_transform = world + .get::(member) + .map( + |global| match world.get::(runtime_parent) { + Some(parent_global) => global.reparented_to(parent_global), + None => global.compute_transform(), + }, + ) + .or_else(|| world.get::(member).copied()); + let local_transform_ron = new_transform + .as_ref() + .and_then(|transform| ron::to_string(transform).ok()); + plans.push((member, target, new_transform, local_transform_ron)); + } + let Some(instance) = world.get::(instance_root).cloned() else { + return false; + }; + let old = capture_prefab_edit_state(world, instance_root); + let Some(mut overrides) = read_overrides_for_edit(world, &instance) else { + return false; + }; + let project_root = world + .get_resource::() + .map(|workspace| PathBuf::from(&workspace.root)); + for (member, target, _, local_transform_ron) in &plans { + let existing_base = overrides + .structural + .iter() + .find_map(|candidate| match candidate { + PrefabStructuralOverride::ReparentChild { + target: existing, + base_parent, + base_transform_ron, + .. + } if existing == target => Some((base_parent.clone(), base_transform_ron.clone())), + _ => None, + }); + let (base_parent, base_transform_ron) = existing_base.unwrap_or_else(|| { + let source_parent = project_root + .as_ref() + .and_then(|root| source_parent_for_target(root, &instance, target).ok()); + let base_parent = source_parent.unwrap_or_else(|| { + world + .get::(*member) + .map(ChildOf::parent) + .and_then(|parent| prefab_member_override_target(world, parent)) + .filter(|(root, path)| { + *root == instance_root && path.instance_chain == target.instance_chain + }) + .map(|(_, path)| path) + }); + let base_transform = project_root + .as_ref() + .and_then(|root| { + source_component_for_target( + root, + &instance, + target, + std::any::type_name::(), + ) + .ok() + .flatten() + .map(|component| component.ron) + }) + .or_else(|| { + world + .get::(*member) + .and_then(|transform| ron::to_string(transform).ok()) + }); + (base_parent, base_transform) + }); + overrides.structural.retain(|candidate| { + !matches!(candidate, PrefabStructuralOverride::ReparentChild { target: existing, .. } if existing == target) + }); + if new_parent_target != base_parent { + overrides + .structural + .push(PrefabStructuralOverride::ReparentChild { + target: target.clone(), + base_parent, + new_parent: new_parent_target.clone(), + base_transform_ron, + local_transform_ron: local_transform_ron.clone(), + }); + } + } + track_current_source_revision(world, &instance, &mut overrides); + write_overrides(world, instance_root, &overrides); + for (member, _, new_transform, _) in plans { + if let Ok(mut entity_mut) = world.get_entity_mut(member) { + entity_mut.insert(ChildOf(runtime_parent)); + if let Some(transform) = new_transform { + entity_mut.insert(transform); + } + } + } + record_prefab_edit_state(world, instance_root, old); + true +} + +fn hierarchy_would_cycle(world: &World, entity: Entity, parent: Entity) -> bool { + let mut current = Some(parent); + let mut seen = HashSet::new(); + while let Some(candidate) = current { + if candidate == entity || !seen.insert(candidate) { + return true; + } + current = world.get::(candidate).map(ChildOf::parent); + } + false } pub fn revert_child_visibility_overrides(world: &mut World, entity: Entity) { + let old = capture_prefab_edit_state(world, entity); let Some(instance) = world.get::(entity).cloned() else { return; }; - for child in prefab_instance_children(world, entity) { - if let Ok(mut entity_mut) = world.get_entity_mut(child) { - entity_mut.remove::(); + let Some(mut overrides) = read_overrides_for_edit(world, &instance) else { + return; + }; + if let Some(project_root) = world + .get_resource::() + .map(|workspace| PathBuf::from(&workspace.root)) + { + let _ = promote_legacy_visibility_overrides(&project_root, &instance, &mut overrides); + } + let component_type = std::any::type_name::(); + let restorations: Vec<_> = overrides + .components + .iter() + .filter(|component| component.component_type == component_type) + .map(|component| { + ( + component.target.clone(), + component.base_component_ron.clone(), + ) + }) + .collect(); + for (target, base) in restorations { + let Some(member) = prefab_member_for_target(world, entity, &target) else { + continue; + }; + if let Some(value) = base.and_then(|ron| ron::from_str::(&ron).ok()) { + world.entity_mut(member).insert(value); + } else { + world.entity_mut(member).remove::(); } } - let mut overrides = read_overrides(&instance); overrides.child_visibility.clear(); + overrides + .components + .retain(|component| component.component_type != component_type); + track_current_source_revision(world, &instance, &mut overrides); write_overrides(world, entity, &overrides); + record_prefab_edit_state(world, entity, old); } pub fn apply_stored_overrides(world: &mut World, entity: Entity) { let Some(instance) = world.get::(entity).cloned() else { return; }; - let overrides = read_overrides(&instance); - - if let Some(transform) = overrides.transform { - if let Ok(mut entity_mut) = world.get_entity_mut(entity) { - entity_mut.insert(transform); + let Ok(overrides) = try_read_overrides(&instance) else { + return; + }; + for child in prefab_instance_members(world, entity) { + let is_direct = prefab_member_override_target(world, child) + .is_some_and(|(root, target)| root == entity && target.instance_chain.is_empty()); + if !is_direct { + continue; } - } - if let Some(material) = overrides.material { - if let Ok(mut entity_mut) = world.get_entity_mut(entity) { - entity_mut.insert(material); - } - } - for child in prefab_instance_children(world, entity) { - let Some(name) = world.get::(child).map(|n| n.to_string()) else { + let Some(actor_id) = world.get::(child).map(|id| id.0.as_str()) else { continue; }; - if let Some(&visible) = overrides.child_visibility.get(&name) { + let legacy_name = world.get::(child).map(|name| name.as_str()); + if let Some(&visible) = overrides + .child_visibility + .get(actor_id) + .or_else(|| legacy_name.and_then(|name| overrides.child_visibility.get(name))) + { if let Ok(mut entity_mut) = world.get_entity_mut(child) { entity_mut.insert(EditorVisibility { visible }); } @@ -193,21 +2446,486 @@ pub fn apply_stored_overrides(world: &mut World, entity: Entity) { } } +fn prefab_member_for_target( + world: &mut World, + instance_root: Entity, + target: &PrefabActorPath, +) -> Option { + let candidates: Vec = world + .query_filtered::>() + .iter(world) + .collect(); + candidates.into_iter().find(|candidate| { + prefab_member_override_target(world, *candidate).is_some_and(|(root, candidate_target)| { + root == instance_root && candidate_target == *target + }) + }) +} + +pub fn invalidate_prefab_health_on_ready( + ready: On, + mut health_cache: Option>, +) { + if let Some(cache) = health_cache.as_deref_mut() { + cache.entries.remove(&ready.entity); + } +} + +pub fn guard_prefab_reimports( + mut events: MessageReader>, + mut failures: MessageReader>, + roots: Query<(Entity, &DynamicWorldRoot, &PrefabInstance)>, + workspace: Res, + mut commands: Commands, +) { + let mut changed: std::collections::HashSet<_> = events + .read() + .filter_map(|event| match event { + AssetEvent::Modified { id } + | AssetEvent::Removed { id } + | AssetEvent::LoadedWithDependencies { id } => Some(*id), + _ => None, + }) + .collect(); + let failed: HashMap<_, _> = failures + .read() + .map(|failure| { + ( + failure.id, + format!("could not load {}: {}", failure.path, failure.error), + ) + }) + .collect(); + changed.extend(failed.keys().copied()); + if changed.is_empty() { + return; + } + let project_root = Path::new(&workspace.root); + for (entity, dynamic_root, instance) in &roots { + if !changed.contains(&dynamic_root.id()) { + continue; + } + if let Some(reason) = failed.get(&dynamic_root.id()) { + let reason = reason.clone(); + commands.queue(move |world: &mut World| { + block_prefab_hydration(world, entity, reason); + }); + continue; + } + match scene::validate_prefab_graph(&project_root.join(&instance.source_path), project_root) + { + Ok(()) => { + commands + .entity(entity) + .remove::(); + } + Err(reason) => { + commands.queue(move |world: &mut World| { + block_prefab_hydration(world, entity, reason); + }); + } + } + } +} + +fn block_prefab_hydration(world: &mut World, entity: Entity, reason: String) { + let mut entity = entity; + let mut seen = HashSet::new(); + while seen.insert(entity) { + let Some(owner) = world + .get::(entity) + .map(|member| member.instance_root) + else { + break; + }; + entity = owner; + } + + // Nested instances are owned by generated members of their parent instance. Walk that + // ownership graph so an invalid reload cannot leave an unregistered nested tree behind. + let ownership: Vec<(Entity, Entity)> = world + .query::<(Entity, &HydratedPrefabMember)>() + .iter(world) + .map(|(member, owner)| (member, owner.instance_root)) + .collect(); + let mut owned_roots = HashSet::from([entity]); + let mut changed = true; + while changed { + changed = false; + for (member, owner) in &ownership { + if owned_roots.contains(owner) && owned_roots.insert(*member) { + changed = true; + } + } + } + owned_roots.remove(&entity); + for member in owned_roots { + if let Ok(entity_mut) = world.get_entity_mut(member) { + entity_mut.despawn(); + } + } + + if let Ok(mut root) = world.get_entity_mut(entity) { + // Remove the asset root while WorldInstance is still present so Bevy unregisters the + // instance, then remove the stale ID before a repaired source is hydrated. + root.insert(shared::PrefabHydrationBlocked { reason }) + .remove::() + .remove::() + .remove::(); + } + if let Some(mut cache) = world.get_resource_mut::() { + cache.entries.remove(&entity); + } +} + +fn retry_prefab_source(world: &mut World, entity: Entity) { + let Some(instance) = world.get::(entity).cloned() else { + return; + }; + let Some(project_root) = world + .get_resource::() + .map(|workspace| PathBuf::from(&workspace.root)) + else { + return; + }; + if let Err(error) = + scene::validate_prefab_graph(&project_root.join(&instance.source_path), &project_root) + { + world.resource_mut::().status = + format!("Prefab retry failed: {error}"); + return; + } + + block_prefab_hydration(world, entity, "restarting prefab hydration".to_string()); + if let Ok(mut root) = world.get_entity_mut(entity) { + root.remove::().insert(( + instance.clone(), + PrefabRef::new(instance.source_path.clone()), + )); + } + world.resource_mut::().status = + "Prefab source queued for reload".into(); +} + +pub fn prefab_member_inspector_ui(world: &mut World, ui: &mut egui::Ui, entity: Entity) { + let Some((instance_root, target)) = prefab_member_override_target(world, entity) else { + ui.label(egui::RichText::new("Prefab target unavailable").color(ERROR)); + return; + }; + let Some(instance) = world.get::(instance_root).cloned() else { + return; + }; + let overrides = match try_read_overrides(&instance) { + Ok(overrides) => overrides, + Err(error) => { + ui.label(egui::RichText::new(error).color(ERROR)); + ui.label("Repair the prefab instance from its root inspector before editing members."); + return; + } + }; + let transform_type = std::any::type_name::(); + let material_type = std::any::type_name::(); + let property_active = |component_type: &str, property_path: &str| { + overrides.properties.iter().any(|candidate| { + candidate.target == target + && candidate.component_type == component_type + && candidate.property_path == property_path + }) + }; + let component_active = |component_type: &str| { + overrides.components.iter().any(|candidate| { + candidate.target == target && candidate.component_type == component_type + }) + }; + + ui.horizontal_wrapped(|ui| { + ui.label( + egui::RichText::new("Override layer") + .strong() + .color(WARNING), + ); + ui.label( + egui::RichText::new(format!("{} / {}", instance.source_path, target.actor_id)) + .color(TEXT_MUTED), + ); + }); + ui.add_space(4.0); + + if let Some(mut transform) = world.get::(entity).copied() { + ui.collapsing("Transform", |ui| { + let old_translation = transform.translation; + ui.horizontal_wrapped(|ui| { + ui.label("Position"); + ui.add(egui::DragValue::new(&mut transform.translation.x).speed(0.1)); + ui.add(egui::DragValue::new(&mut transform.translation.y).speed(0.1)); + ui.add(egui::DragValue::new(&mut transform.translation.z).speed(0.1)); + if property_active(transform_type, "translation") + && ui.small_button("Revert").clicked() + { + revert_prefab_override_scope( + world, + entity, + Some(transform_type), + Some("translation"), + ); + } + if property_active(transform_type, "translation") + && ui.small_button("Apply to source").clicked() + { + apply_prefab_member_scope_to_source( + world, + entity, + Some(transform_type), + Some("translation"), + ); + } + }); + if transform.translation != old_translation { + set_prefab_transform_property_override(world, entity, "translation", transform); + } + + let old_rotation = transform.rotation; + let (yaw, pitch, roll) = transform.rotation.to_euler(EulerRot::YXZ); + let mut degrees = Vec3::new(pitch.to_degrees(), yaw.to_degrees(), roll.to_degrees()); + ui.horizontal_wrapped(|ui| { + ui.label("Rotation"); + ui.add(egui::DragValue::new(&mut degrees.x).speed(0.5)); + ui.add(egui::DragValue::new(&mut degrees.y).speed(0.5)); + ui.add(egui::DragValue::new(&mut degrees.z).speed(0.5)); + if property_active(transform_type, "rotation") + && ui.small_button("Revert").clicked() + { + revert_prefab_override_scope( + world, + entity, + Some(transform_type), + Some("rotation"), + ); + } + if property_active(transform_type, "rotation") + && ui.small_button("Apply to source").clicked() + { + apply_prefab_member_scope_to_source( + world, + entity, + Some(transform_type), + Some("rotation"), + ); + } + }); + transform.rotation = Quat::from_euler( + EulerRot::YXZ, + degrees.y.to_radians(), + degrees.x.to_radians(), + degrees.z.to_radians(), + ); + if transform.rotation != old_rotation { + set_prefab_transform_property_override(world, entity, "rotation", transform); + } + + let old_scale = transform.scale; + ui.horizontal_wrapped(|ui| { + ui.label("Scale"); + ui.add(egui::DragValue::new(&mut transform.scale.x).speed(0.05)); + ui.add(egui::DragValue::new(&mut transform.scale.y).speed(0.05)); + ui.add(egui::DragValue::new(&mut transform.scale.z).speed(0.05)); + if property_active(transform_type, "scale") && ui.small_button("Revert").clicked() { + revert_prefab_override_scope( + world, + entity, + Some(transform_type), + Some("scale"), + ); + } + if property_active(transform_type, "scale") + && ui.small_button("Apply to source").clicked() + { + apply_prefab_member_scope_to_source( + world, + entity, + Some(transform_type), + Some("scale"), + ); + } + }); + if transform.scale != old_scale { + set_prefab_transform_property_override(world, entity, "scale", transform); + } + }); + } + + ui.collapsing("Material", |ui| { + if let Some(mut material) = world.get::(entity).cloned() { + let mut color = [ + material.base_color.r, + material.base_color.g, + material.base_color.b, + material.base_color.a, + ]; + ui.horizontal_wrapped(|ui| { + ui.label("Base color"); + if ui.color_edit_button_rgba_unmultiplied(&mut color).changed() { + material.base_color.r = color[0]; + material.base_color.g = color[1]; + material.base_color.b = color[2]; + material.base_color.a = color[3]; + set_prefab_material_property_override( + world, + entity, + "base_color", + material.clone(), + ); + } + if property_active(material_type, "base_color") + && ui.small_button("Revert").clicked() + { + revert_prefab_override_scope( + world, + entity, + Some(material_type), + Some("base_color"), + ); + } + if property_active(material_type, "base_color") + && ui.small_button("Apply to source").clicked() + { + apply_prefab_member_scope_to_source( + world, + entity, + Some(material_type), + Some("base_color"), + ); + } + }); + + let old_metallic = material.metallic; + ui.horizontal_wrapped(|ui| { + ui.label("Metallic"); + ui.add(egui::Slider::new(&mut material.metallic, 0.0..=1.0)); + if property_active(material_type, "metallic") && ui.small_button("Revert").clicked() + { + revert_prefab_override_scope( + world, + entity, + Some(material_type), + Some("metallic"), + ); + } + if property_active(material_type, "metallic") + && ui.small_button("Apply to source").clicked() + { + apply_prefab_member_scope_to_source( + world, + entity, + Some(material_type), + Some("metallic"), + ); + } + }); + if material.metallic != old_metallic { + set_prefab_material_property_override(world, entity, "metallic", material.clone()); + } + + let old_roughness = material.roughness; + ui.horizontal_wrapped(|ui| { + ui.label("Roughness"); + ui.add(egui::Slider::new(&mut material.roughness, 0.0..=1.0)); + if property_active(material_type, "roughness") + && ui.small_button("Revert").clicked() + { + revert_prefab_override_scope( + world, + entity, + Some(material_type), + Some("roughness"), + ); + } + if property_active(material_type, "roughness") + && ui.small_button("Apply to source").clicked() + { + apply_prefab_member_scope_to_source( + world, + entity, + Some(material_type), + Some("roughness"), + ); + } + }); + if material.roughness != old_roughness { + set_prefab_material_property_override(world, entity, "roughness", material); + } + + if ui.small_button("Remove component").clicked() { + remove_prefab_component_override::(world, entity); + } + if overrides.properties.iter().any(|candidate| { + candidate.target == target && candidate.component_type == material_type + }) || component_active(material_type) + { + ui.horizontal_wrapped(|ui| { + if ui.small_button("Apply component to source").clicked() { + apply_prefab_member_scope_to_source( + world, + entity, + Some(material_type), + None, + ); + } + if ui.small_button("Revert component overrides").clicked() { + revert_prefab_override_scope(world, entity, Some(material_type), None); + } + }); + } + } else if component_active(material_type) { + ui.horizontal_wrapped(|ui| { + if ui.button("Apply removal to source").clicked() { + apply_prefab_member_scope_to_source(world, entity, Some(material_type), None); + } + if ui.button("Restore source component").clicked() { + revert_prefab_override_scope(world, entity, Some(material_type), None); + } + }); + } else if ui.button("Add material component").clicked() { + set_prefab_component_override(world, entity, MaterialDesc::default()); + } + }); + + ui.separator(); + ui.horizontal_wrapped(|ui| { + if ui.button("Apply actor to source").clicked() { + apply_prefab_member_scope_to_source(world, entity, None, None); + } + if ui.button("Revert actor overrides").clicked() { + revert_prefab_override_scope(world, entity, None, None); + } + if ui + .add(egui::Button::new("Remove source actor")) + .on_hover_text("Creates an undoable structural override") + .clicked() + { + remove_prefab_source_child(world, entity); + } + }); +} + pub fn prefab_instance_inspector_ui(world: &mut World, ui: &mut egui::Ui, entity: Entity) { let Some(instance) = world.get::(entity).cloned() else { return; }; - let overrides = read_overrides(&instance); + let parsed_overrides = try_read_overrides(&instance); + let overrides_valid = parsed_overrides.is_ok(); + let overrides = parsed_overrides.clone().unwrap_or_default(); + let health = parsed_overrides.map_or_else(PrefabHealth::InvalidOverrides, |_| { + prefab_health(world, entity, &instance, &overrides) + }); - let card = component_card_context( - world, - entity, - ComponentCardOptions::removable( - shared::COMPONENT_PREFAB_INSTANCE, - "Prefab Instance", - icons::PACKAGE, - ), + let mut options = ComponentCardOptions::fixed( + shared::COMPONENT_PREFAB_INSTANCE, + "Prefab Instance", + icons::PACKAGE, ); + options.resettable = false; + let card = component_card_context(world, entity, options); let card_response = component_card(ui, &card, |ui| { property_row(ui, "Asset ID", |ui| { ui.add(egui::Label::new(instance.asset_id.as_str()).truncate()); @@ -215,163 +2933,274 @@ pub fn prefab_instance_inspector_ui(world: &mut World, ui: &mut egui::Ui, entity property_row(ui, "Source", |ui| { ui.add(egui::Label::new(instance.source_path.as_str()).truncate()); }); - - ui.collapsing("Transform override", |ui| { - let active = has_override(&overrides, PrefabOverrideField::Transform); - if active { - ui.label(egui::RichText::new("Override active").weak()); + property_row(ui, "Status", |ui| { + let (label, color, detail) = match &health { + PrefabHealth::Ready => ("Ready", SUCCESS, None), + PrefabHealth::Loading => ( + "Loading source", + TEXT_MUTED, + Some("Override targets will be available when the prefab finishes loading"), + ), + PrefabHealth::Untracked => ( + "Base not tracked", + WARNING, + Some("Record the current source revision before production use"), + ), + PrefabHealth::ChangedBase => ( + "Base changed", + WARNING, + Some("The source file changed after these overrides were recorded"), + ), + PrefabHealth::Conflict(stale) => ( + "Override conflict", + ERROR, + Some(if stale.len() == 1 { + "One override conflicts with the current source value, hierarchy, or target" + } else { + "Multiple overrides conflict with current source values, hierarchy, or targets" + }), + ), + PrefabHealth::Broken(error) => ("Broken source", ERROR, Some(error.as_str())), + PrefabHealth::InvalidOverrides(error) => { + ("Invalid overrides", ERROR, Some(error.as_str())) + } + }; + let response = ui.label(egui::RichText::new(label).strong().color(color)); + if let Some(detail) = detail { + response.on_hover_text(detail); } - ui.horizontal_wrapped(|ui| { - if ui.button("Apply").clicked() { - capture_transform_override(world, entity); - world.resource_mut::().status = - "Transform override saved".into(); - } - if ui - .add_enabled(active, egui::Button::new("Revert")) - .clicked() - { - revert_transform_override(world, entity); - } - }); }); - ui.collapsing("Material override", |ui| { - let active = has_override(&overrides, PrefabOverrideField::Material); - if active { - ui.label(egui::RichText::new("Override active").weak()); + ui.horizontal_wrapped(|ui| match &health { + PrefabHealth::Ready => { + if ui.small_button("Reload source").clicked() { + world.entity_mut(entity).insert(( + instance.clone(), + PrefabRef::new(instance.source_path.clone()), + )); + } + if ui.small_button("Relink...").clicked() { + relink_prefab_source(world, entity); + } } - ui.horizontal_wrapped(|ui| { - if ui.button("Apply").clicked() { - apply_material_override(world, entity); + PrefabHealth::Untracked | PrefabHealth::ChangedBase => { + if ui.small_button("Accept current base").clicked() { + accept_current_source(world, entity); } - if ui - .add_enabled(active, egui::Button::new("Revert")) - .clicked() - { - revert_material_override(world, entity); + if ui.small_button("Relink...").clicked() { + relink_prefab_source(world, entity); } - }); + } + PrefabHealth::Conflict(stale) => { + ui.label( + egui::RichText::new(format!("{} conflicts", stale.len())).color(TEXT_MUTED), + ); + if ui.small_button("Keep overrides").clicked() { + keep_prefab_overrides(world, entity); + } + if ui.small_button("Take source").clicked() { + take_source_for_conflicts(world, entity); + } + if ui.small_button("Remove missing targets").clicked() { + remove_stale_visibility_overrides(world, entity); + } + if ui.small_button("Relink...").clicked() { + relink_prefab_source(world, entity); + } + } + PrefabHealth::Broken(_) => { + if ui.small_button("Retry source").clicked() { + retry_prefab_source(world, entity); + } + if ui.small_button("Relink source...").clicked() { + relink_prefab_source(world, entity); + } + } + PrefabHealth::Loading => { + ui.spinner(); + } + PrefabHealth::InvalidOverrides(_) => { + if ui.small_button("Discard invalid data").clicked() { + discard_invalid_overrides(world, entity); + } + } }); - ui.collapsing("Child visibility overrides", |ui| { - let active = has_override(&overrides, PrefabOverrideField::ChildVisibility); - if active { - ui.label(egui::RichText::new("Overrides stored by child name").weak()); - } - for child in prefab_instance_children(world, entity) { - let name = world - .get::(child) - .map(|n| n.to_string()) - .unwrap_or_else(|| format!("{child:?}")); - ui.horizontal_wrapped(|ui| { - ui.add(egui::Label::new(name).truncate()); - if ui.small_button("Apply").clicked() { - apply_child_visibility_override(world, entity, child); + ui.add_enabled_ui(overrides_valid, |ui| { + let operation_count = overrides.properties.len() + + overrides.components.len() + + overrides.structural.len() + + overrides.child_visibility.len(); + ui.horizontal_wrapped(|ui| { + ui.label( + egui::RichText::new(format!("{operation_count} override operations")) + .color(TEXT_MUTED), + ); + if ui + .add_enabled( + operation_count > 0, + egui::Button::new("Apply all to source"), + ) + .clicked() + { + apply_prefab_instance_overrides_to_source(world, entity); + } + if ui + .add_enabled(operation_count > 0, egui::Button::new("Revert all")) + .clicked() + { + revert_prefab_instance_overrides(world, entity); + } + }); + if !overrides.structural.is_empty() { + let mut structural_action = None; + ui.collapsing("Structural overrides", |ui| { + for structural in &overrides.structural { + let target = structural_target(structural).clone(); + let operation = match structural { + PrefabStructuralOverride::RemoveChild { .. } => "Removed", + PrefabStructuralOverride::ReparentChild { .. } => "Reparented", + PrefabStructuralOverride::AttachLocalChild { .. } => "Attached", + }; + ui.horizontal_wrapped(|ui| { + ui.label( + egui::RichText::new(format!( + "{operation} {}", + actor_path_key(&target) + )) + .color(TEXT_MUTED), + ); + if ui.small_button("Apply actor").clicked() { + structural_action = Some((true, target.clone())); + } + if ui.small_button("Revert actor").clicked() { + structural_action = Some((false, target)); + } + }); } }); - } - ui.horizontal_wrapped(|ui| { - if ui - .add_enabled(active, egui::Button::new("Revert all")) - .clicked() - { - revert_child_visibility_overrides(world, entity); + if let Some((apply, target)) = structural_action { + if apply { + apply_prefab_actor_path_to_source(world, entity, &target); + } else { + revert_prefab_actor_path(world, entity, &target); + } } + } + ui.collapsing("Visibility overrides", |ui| { + let visibility_type = std::any::type_name::(); + let active = !overrides.child_visibility.is_empty() + || overrides + .components + .iter() + .any(|component| component.component_type == visibility_type); + if active { + ui.label(egui::RichText::new("Overrides use stable nested actor paths").weak()); + } + for child in prefab_instance_members(world, entity) { + let Some((_, target)) = prefab_member_override_target(world, child) else { + continue; + }; + let name = world + .get::(child) + .map(|n| n.to_string()) + .unwrap_or_else(|| format!("{child:?}")); + let mut visible = overrides + .components + .iter() + .find(|component| { + component.target == target + && component.component_type == visibility_type + }) + .and_then(|component| component.value_component_ron.as_deref()) + .and_then(|ron| ron::from_str::(ron).ok()) + .map(|visibility| visibility.visible) + .or_else(|| { + target + .instance_chain + .is_empty() + .then(|| overrides.child_visibility.get(&target.actor_id).copied()) + .flatten() + }) + .unwrap_or_else(|| { + world + .get::(child) + .map(|visibility| visibility.visible) + .unwrap_or(true) + }); + ui.horizontal_wrapped(|ui| { + ui.add( + egui::Label::new(format!("{name} ({})", actor_path_key(&target))) + .truncate(), + ); + if ui.checkbox(&mut visible, "Visible").changed() { + set_child_visibility_override(world, entity, child, visible); + } + }); + } + ui.horizontal_wrapped(|ui| { + if ui + .add_enabled(active, egui::Button::new("Revert all")) + .clicked() + { + revert_child_visibility_overrides(world, entity); + } + }); }); }); ui.horizontal_wrapped(|ui| { - if ui.button("Unpack Instance").clicked() { + if ui.button("Create Variant...").clicked() { + world.resource_mut::().request = + Some(crate::scene_io::SceneIoRequest::SaveSelectionAsPrefab); + } + if ui + .add_enabled( + world.get::(entity).is_some(), + egui::Button::new("Unpack Layer"), + ) + .on_disabled_hover_text("Prefab source has not finished loading") + .on_hover_text("Make this prefab layer local while preserving nested prefab links") + .clicked() + { crate::history::unpack_prefab_instance(world, entity); } + if ui + .add_enabled( + world.get::(entity).is_some(), + egui::Button::new("Convert to Local"), + ) + .on_disabled_hover_text("Prefab source has not finished loading") + .on_hover_text( + "Recursively replace this instance and all nested links with local actors", + ) + .clicked() + { + crate::history::convert_prefab_completely_to_local(world, entity); + } }); }); apply_component_card_response(world, entity, card_response); } -fn prefab_instance_children(world: &World, root: Entity) -> Vec { - let mut children = Vec::new(); - let mut stack = Vec::new(); - if let Some(kids) = world.get::(root) { - stack.extend(kids.iter()); - } - while let Some(entity) = stack.pop() { - children.push(entity); - if let Some(kids) = world.get::(entity) { - stack.extend(kids.iter()); - } - } - children -} - -fn prefab_root_transform(source_path: &str) -> Option { - parse_prefab_root_component::(source_path, "Transform") -} - -fn prefab_root_material(source_path: &str) -> Option { - parse_prefab_root_component::(source_path, "MaterialDesc") -} - -fn parse_prefab_root_component( - source_path: &str, - type_name: &str, -) -> Option { - let path = Path::new(source_path); - let text = std::fs::read_to_string(path).ok()?; - validate_level_text(&text).ok()?; - let body = strip_schema_version(&migrate_scene_text(&text).ok()?).ok()?; - let root_tuple = body.trim().trim_start_matches('(').trim_end_matches(')'); - for segment in split_top_level_fields(root_tuple) { - let segment = segment.trim(); - if segment.starts_with("entities:") { - let entities_body = segment.trim_start_matches("entities:").trim(); - return parse_first_entity_component(entities_body, type_name); - } - } - None -} - -fn split_top_level_fields(input: &str) -> Vec { - let mut fields = Vec::new(); - let mut depth: i32 = 0; - let mut start = 0; - for (index, ch) in input.char_indices() { - match ch { - '(' | '{' => depth += 1, - ')' | '}' => depth = depth.saturating_sub(1), - ',' if depth == 0 => { - fields.push(input[start..index].to_string()); - start = index + 1; - } - _ => {} - } - } - if start < input.len() { - fields.push(input[start..].to_string()); - } - fields -} - -fn parse_first_entity_component( - entities_body: &str, - type_name: &str, -) -> Option { - let trimmed = entities_body.trim(); - let inner = trimmed.strip_prefix('{')?.strip_suffix('}')?.trim(); - let first_entity = inner.split(',').next()?.trim(); - let components = first_entity - .split(':') - .nth(1)? - .trim() - .strip_prefix('(')? - .strip_suffix(')')?; - for component in split_top_level_fields(components) { - if component.contains(type_name) { - return ron::from_str::(&format!("({component})")).ok(); - } - } - None +fn prefab_instance_members(world: &mut World, root: Entity) -> Vec { + let candidates: Vec = world + .query_filtered::>() + .iter(world) + .collect(); + let mut members: Vec<_> = candidates + .into_iter() + .filter_map(|candidate| { + prefab_member_override_target(world, candidate) + .filter(|(owner, _)| *owner == root) + .map(|(_, target)| (actor_path_key(&target), candidate)) + }) + .collect(); + members.sort_by(|(left, _), (right, _)| left.cmp(right)); + members + .into_iter() + .map(|(_, candidate)| candidate) + .collect() } pub fn list_prefab_dependencies(path: &str) -> Vec { @@ -381,10 +3210,1125 @@ pub fn list_prefab_dependencies(path: &str) -> Vec { #[cfg(test)] mod tests { use super::*; + use bevy::asset::AssetPlugin; + use bevy::prelude::DynamicWorldRoot; + use bevy::world_serialization::{DynamicWorld, WorldInstanceSpawner, WorldSerializationPlugin}; + use shared::LevelObject; + + fn prefab_source(actor_id: &str, material: Option<&MaterialDesc>) -> String { + let material = material.map(|material| { + format!( + ",\"{}\":{}", + std::any::type_name::(), + ron::to_string(material).unwrap() + ) + }); + format!( + "(schema_version:2,resources:{{}},entities:{{1:(components:{{\"shared::components::LevelObject\":(),\"shared::components::ActorId\":(\"{actor_id}\"),\"shared::components::ActorKind\":Empty,\"{}\":{}{} }})}})", + std::any::type_name::(), + ron::to_string(&Transform::default()).unwrap(), + material.unwrap_or_default(), + ) + } + + fn hierarchy_prefab_source(child_transform: Transform) -> String { + format!( + "(schema_version:2,resources:{{}},entities:{{1:(components:{{\"shared::components::LevelObject\":(),\"shared::components::ActorId\":(\"parent\"),\"shared::components::ActorKind\":Empty,\"{}\":{}}}),2:(components:{{\"shared::components::LevelObject\":(),\"shared::components::ActorId\":(\"child\"),\"shared::components::ActorKind\":Empty,\"{}\":{},\"bevy_ecs::hierarchy::ChildOf\":(1)}})}})", + std::any::type_name::(), + ron::to_string(&Transform::default()).unwrap(), + std::any::type_name::(), + ron::to_string(&child_transform).unwrap(), + ) + } + + fn prefab_test_world(project_root: &Path) -> World { + let mut world = World::new(); + world.init_resource::(); + world.init_resource::(); + world.insert_resource(crate::project_io::ProjectWorkspace { + root: project_root.display().to_string(), + ..Default::default() + }); + world + } #[test] fn empty_overrides_roundtrip() { let instance = PrefabInstance::new("id", "assets/prefabs/test.scn.ron"); assert_eq!(read_overrides(&instance), PrefabOverrides::default()); } + + #[test] + fn nested_unpack_fold_is_transactional_when_a_later_link_is_invalid() { + let mut world = World::new(); + world.init_resource::(); + let material = MaterialDesc::default(); + let mut outer = PrefabInstance::new("outer", "assets/prefabs/outer.scn.ron"); + outer.overrides_ron = encode_prefab_overrides(&PrefabOverrides { + properties: ["first", "second"] + .into_iter() + .map(|anchor| PrefabPropertyOverride { + target: PrefabActorPath { + instance_chain: vec![anchor.to_string()], + actor_id: "mesh".to_string(), + }, + component_type: std::any::type_name::().to_string(), + property_path: "roughness".to_string(), + base_component_ron: ron::to_string(&material).unwrap(), + value_component_ron: ron::to_string(&material).unwrap(), + }) + .collect(), + ..default() + }) + .unwrap(); + let root = world.spawn(outer).id(); + let first = world + .spawn(( + ActorId::new("first"), + PrefabInstance::new("first", "assets/prefabs/first.scn.ron"), + HydratedPrefabMember { + instance_root: root, + }, + )) + .id(); + let mut invalid = PrefabInstance::new("second", "assets/prefabs/second.scn.ron"); + invalid.overrides_ron = Some("invalid".to_string()); + world.spawn(( + ActorId::new("second"), + invalid, + HydratedPrefabMember { + instance_root: root, + }, + )); + + assert!(fold_nested_overrides_for_unpack(&mut world, root).is_err()); + assert!(world + .get::(first) + .unwrap() + .overrides_ron + .is_none()); + } + + #[test] + fn property_apply_to_source_is_transactional_and_history_guarded() { + let root = std::env::temp_dir().join(format!( + "blacksite-prefab-source-apply-{}", + uuid::Uuid::new_v4() + )); + let prefabs = root.join("assets/prefabs"); + std::fs::create_dir_all(&prefabs).unwrap(); + std::fs::write(root.join("assets/project.ron"), "()").unwrap(); + let source_path = prefabs.join("base.scn.ron"); + let base_material = MaterialDesc::default(); + let before_source = prefab_source("mesh", Some(&base_material)); + std::fs::write(&source_path, &before_source).unwrap(); + + let mut world = prefab_test_world(&root); + let instance_root = world + .spawn(PrefabInstance::new("base", "assets/prefabs/base.scn.ron")) + .id(); + let member = world + .spawn(( + ActorId::new("mesh"), + base_material.clone(), + HydratedPrefabMember { instance_root }, + ChildOf(instance_root), + )) + .id(); + let mut overridden = base_material.clone(); + overridden.roughness = 0.2; + set_prefab_material_property_override(&mut world, member, "roughness", overridden); + + apply_prefab_member_scope_to_source( + &mut world, + member, + Some(std::any::type_name::()), + Some("roughness"), + ); + + let applied_source = std::fs::read_to_string(&source_path).unwrap(); + let document = SceneDocument::from_ron_text(&applied_source).unwrap(); + let material: MaterialDesc = ron::from_str( + &document.entities[0] + .components + .iter() + .find(|component| component.type_name == std::any::type_name::()) + .unwrap() + .ron, + ) + .unwrap(); + assert_eq!(material.roughness, 0.2); + assert!( + read_overrides(world.get::(instance_root).unwrap()) + .properties + .is_empty() + ); + assert_eq!( + world + .resource::() + .undo_depth(), + 2 + ); + + crate::history::apply_command_undo(&mut world); + assert_eq!( + std::fs::read_to_string(&source_path).unwrap(), + before_source + ); + assert_eq!( + read_overrides(world.get::(instance_root).unwrap()) + .properties + .len(), + 1 + ); + crate::history::apply_command_redo(&mut world); + assert_eq!( + std::fs::read_to_string(&source_path).unwrap(), + applied_source + ); + + std::fs::write(&source_path, format!("{applied_source}\n")).unwrap(); + let undo_depth = world + .resource::() + .undo_depth(); + crate::history::apply_command_undo(&mut world); + assert_eq!( + world + .resource::() + .undo_depth(), + undo_depth + ); + assert!(world + .resource::() + .status + .contains("changed outside")); + std::fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn added_component_field_apply_commits_prerequisite_and_round_trips() { + let root = std::env::temp_dir().join(format!( + "blacksite-prefab-component-apply-{}", + uuid::Uuid::new_v4() + )); + let prefabs = root.join("assets/prefabs"); + std::fs::create_dir_all(&prefabs).unwrap(); + std::fs::write(root.join("assets/project.ron"), "()").unwrap(); + let source_path = prefabs.join("base.scn.ron"); + let before_source = prefab_source("mesh", None); + std::fs::write(&source_path, &before_source).unwrap(); + + let mut world = prefab_test_world(&root); + let instance_root = world + .spawn(PrefabInstance::new("base", "assets/prefabs/base.scn.ron")) + .id(); + let member = world + .spawn(( + ActorId::new("mesh"), + HydratedPrefabMember { instance_root }, + ChildOf(instance_root), + )) + .id(); + let base = MaterialDesc::default(); + set_prefab_component_override(&mut world, member, base.clone()); + let mut edited = base; + edited.roughness = 0.17; + set_prefab_material_property_override(&mut world, member, "roughness", edited); + + apply_prefab_member_scope_to_source( + &mut world, + member, + Some(std::any::type_name::()), + Some("roughness"), + ); + + let after_source = std::fs::read_to_string(&source_path).unwrap(); + let document = SceneDocument::from_ron_text(&after_source).unwrap(); + let material: MaterialDesc = ron::from_str( + &document + .find_entity(&SceneEntityId::ActorId("mesh".into())) + .unwrap() + .components + .iter() + .find(|component| component.type_name == std::any::type_name::()) + .unwrap() + .ron, + ) + .unwrap(); + assert_eq!(material.roughness, 0.17); + let applied = read_overrides(world.get::(instance_root).unwrap()); + assert!(applied.components.is_empty()); + assert!(applied.properties.is_empty()); + + crate::history::apply_command_undo(&mut world); + assert_eq!( + std::fs::read_to_string(&source_path).unwrap(), + before_source + ); + let restored = read_overrides(world.get::(instance_root).unwrap()); + assert_eq!(restored.components.len(), 1); + assert_eq!(restored.properties.len(), 1); + + crate::history::apply_command_redo(&mut world); + assert_eq!(std::fs::read_to_string(&source_path).unwrap(), after_source); + std::fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn removed_component_apply_and_undo_preserve_source_bytes() { + let root = std::env::temp_dir().join(format!( + "blacksite-prefab-component-remove-{}", + uuid::Uuid::new_v4() + )); + let prefabs = root.join("assets/prefabs"); + std::fs::create_dir_all(&prefabs).unwrap(); + std::fs::write(root.join("assets/project.ron"), "()").unwrap(); + let source_path = prefabs.join("base.scn.ron"); + let material = MaterialDesc::default(); + let before_source = prefab_source("mesh", Some(&material)); + std::fs::write(&source_path, &before_source).unwrap(); + + let mut world = prefab_test_world(&root); + let instance_root = world + .spawn(PrefabInstance::new("base", "assets/prefabs/base.scn.ron")) + .id(); + let member = world + .spawn(( + ActorId::new("mesh"), + material, + HydratedPrefabMember { instance_root }, + ChildOf(instance_root), + )) + .id(); + remove_prefab_component_override::(&mut world, member); + + apply_prefab_member_scope_to_source( + &mut world, + member, + Some(std::any::type_name::()), + None, + ); + + let after_source = std::fs::read_to_string(&source_path).unwrap(); + let document = SceneDocument::from_ron_text(&after_source).unwrap(); + assert!(document.entities[0] + .components + .iter() + .all(|component| component.type_name != std::any::type_name::())); + assert!( + read_overrides(world.get::(instance_root).unwrap()) + .components + .is_empty() + ); + + crate::history::apply_command_undo(&mut world); + assert_eq!( + std::fs::read_to_string(&source_path).unwrap(), + before_source + ); + let restored = read_overrides(world.get::(instance_root).unwrap()); + assert_eq!(restored.components.len(), 1); + assert!(restored.components[0].value_component_ron.is_none()); + std::fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn structural_remove_apply_is_exact_byte_undoable() { + let root = std::env::temp_dir().join(format!( + "blacksite-prefab-structural-apply-{}", + uuid::Uuid::new_v4() + )); + let prefabs = root.join("assets/prefabs"); + std::fs::create_dir_all(&prefabs).unwrap(); + std::fs::write(root.join("assets/project.ron"), "()").unwrap(); + let source_path = prefabs.join("hierarchy.scn.ron"); + let before_source = hierarchy_prefab_source(Transform::from_xyz(1.0, 2.0, 3.0)); + std::fs::write(&source_path, &before_source).unwrap(); + + let mut world = prefab_test_world(&root); + let instance = PrefabInstance::new("hierarchy", "assets/prefabs/hierarchy.scn.ron"); + let instance_root = world.spawn(instance.clone()).id(); + let revision = prefab_source_revision(&root, &instance.source_path).unwrap(); + write_overrides( + &mut world, + instance_root, + &PrefabOverrides { + source_revision: Some(revision.clone()), + structural: vec![PrefabStructuralOverride::RemoveChild { + target: PrefabActorPath::direct("child"), + base_subtree_revision: revision, + }], + ..default() + }, + ); + + apply_prefab_instance_overrides_to_source(&mut world, instance_root); + + let after_source = std::fs::read_to_string(&source_path).unwrap(); + let document = SceneDocument::from_ron_text(&after_source).unwrap(); + assert!(document + .find_entity(&SceneEntityId::ActorId("child".into())) + .is_none()); + assert!( + read_overrides(world.get::(instance_root).unwrap()) + .structural + .is_empty() + ); + + crate::history::apply_command_undo(&mut world); + assert_eq!( + std::fs::read_to_string(&source_path).unwrap(), + before_source + ); + assert_eq!( + read_overrides(world.get::(instance_root).unwrap()) + .structural + .len(), + 1 + ); + crate::history::apply_command_redo(&mut world); + assert_eq!(std::fs::read_to_string(&source_path).unwrap(), after_source); + std::fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn reparent_then_transform_edit_applies_in_layer_order_and_undoes() { + let root = std::env::temp_dir().join(format!( + "blacksite-prefab-reparent-apply-{}", + uuid::Uuid::new_v4() + )); + let prefabs = root.join("assets/prefabs"); + std::fs::create_dir_all(&prefabs).unwrap(); + std::fs::write(root.join("assets/project.ron"), "()").unwrap(); + let source_path = prefabs.join("hierarchy.scn.ron"); + let before_source = hierarchy_prefab_source(Transform::from_xyz(1.0, 0.0, 0.0)); + std::fs::write(&source_path, &before_source).unwrap(); + + let mut world = prefab_test_world(&root); + let instance_root = world + .spawn(PrefabInstance::new( + "hierarchy", + "assets/prefabs/hierarchy.scn.ron", + )) + .id(); + let parent = world + .spawn(( + ActorId::new("parent"), + Transform::default(), + GlobalTransform::from_xyz(10.0, 0.0, 0.0), + HydratedPrefabMember { instance_root }, + ChildOf(instance_root), + )) + .id(); + let child = world + .spawn(( + ActorId::new("child"), + Transform::from_xyz(1.0, 0.0, 0.0), + GlobalTransform::from_xyz(11.0, 0.0, 0.0), + HydratedPrefabMember { instance_root }, + ChildOf(parent), + )) + .id(); + + assert!(reparent_prefab_source_child(&mut world, child, None)); + assert_eq!(world.get::(child).unwrap().translation.x, 11.0); + set_prefab_transform_property_override( + &mut world, + child, + "translation", + Transform::from_xyz(12.0, 0.0, 0.0), + ); + let instance = world.get::(instance_root).unwrap().clone(); + let overrides = read_overrides(&instance); + assert!(prefab_semantic_conflicts(&root, &instance, &overrides).is_empty()); + + apply_prefab_actor_path_to_source( + &mut world, + instance_root, + &PrefabActorPath::direct("child"), + ); + + let after_source = std::fs::read_to_string(&source_path).unwrap(); + let document = SceneDocument::from_ron_text(&after_source).unwrap(); + let child_source = document + .find_entity(&SceneEntityId::ActorId("child".into())) + .unwrap(); + assert!(child_source.parent_actor_id.is_none()); + let transform: Transform = ron::from_str( + &child_source + .components + .iter() + .find(|component| component.type_name == std::any::type_name::()) + .unwrap() + .ron, + ) + .unwrap(); + assert_eq!(transform.translation.x, 12.0); + + crate::history::apply_command_undo(&mut world); + assert_eq!( + std::fs::read_to_string(&source_path).unwrap(), + before_source + ); + let restored = read_overrides(world.get::(instance_root).unwrap()); + assert_eq!(restored.structural.len(), 1); + assert_eq!(restored.properties.len(), 1); + std::fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn multi_member_reparent_is_atomic_and_records_one_history_entry() { + let mut world = World::new(); + world.init_resource::(); + world.init_resource::(); + let root = world + .spawn(PrefabInstance::new("prefab", "assets/prefabs/test.scn.ron")) + .id(); + let parent = world + .spawn(( + ActorId::new("parent"), + GlobalTransform::from_xyz(10.0, 0.0, 0.0), + HydratedPrefabMember { + instance_root: root, + }, + ChildOf(root), + )) + .id(); + let first = world + .spawn(( + ActorId::new("first"), + Transform::default(), + GlobalTransform::from_xyz(1.0, 0.0, 0.0), + HydratedPrefabMember { + instance_root: root, + }, + ChildOf(root), + )) + .id(); + let second = world + .spawn(( + ActorId::new("second"), + Transform::default(), + GlobalTransform::from_xyz(2.0, 0.0, 0.0), + HydratedPrefabMember { + instance_root: root, + }, + ChildOf(root), + )) + .id(); + + assert!(reparent_prefab_source_children( + &mut world, + &[first, second], + Some(parent), + )); + assert_eq!( + world.get::(first).map(ChildOf::parent), + Some(parent) + ); + assert_eq!( + world.get::(second).map(ChildOf::parent), + Some(parent) + ); + assert_eq!( + read_overrides(world.get::(root).unwrap()) + .structural + .len(), + 2 + ); + assert_eq!( + world + .resource::() + .undo_depth(), + 1 + ); + + let invalid = world.spawn_empty().id(); + let before = world.get::(first).map(ChildOf::parent); + assert!(!reparent_prefab_source_children( + &mut world, + &[first, invalid], + None, + )); + assert_eq!(world.get::(first).map(ChildOf::parent), before); + assert_eq!( + world + .resource::() + .undo_depth(), + 1 + ); + } + + #[test] + fn nested_apply_updates_immediate_source_layer() { + let root = std::env::temp_dir().join(format!( + "blacksite-prefab-nested-apply-{}", + uuid::Uuid::new_v4() + )); + let prefabs = root.join("assets/prefabs"); + std::fs::create_dir_all(&prefabs).unwrap(); + std::fs::write(root.join("assets/project.ron"), "()").unwrap(); + let base_path = prefabs.join("base.scn.ron"); + let variant_path = prefabs.join("variant.scn.ron"); + let base_material = MaterialDesc::default(); + let base_source = prefab_source("mesh", Some(&base_material)); + std::fs::write(&base_path, &base_source).unwrap(); + let mut variant_material = base_material.clone(); + variant_material.roughness = 0.2; + let mut nested_instance = PrefabInstance::new("base", "assets/prefabs/base.scn.ron"); + nested_instance.overrides_ron = encode_prefab_overrides(&PrefabOverrides { + properties: vec![PrefabPropertyOverride { + target: PrefabActorPath::direct("mesh"), + component_type: std::any::type_name::().to_string(), + property_path: "roughness".to_string(), + base_component_ron: ron::to_string(&base_material).unwrap(), + value_component_ron: ron::to_string(&variant_material).unwrap(), + }], + ..default() + }) + .unwrap(); + let variant_source = format!( + "(schema_version:2,resources:{{}},entities:{{1:(components:{{\"shared::components::LevelObject\":(),\"shared::components::ActorId\":(\"nested\"),\"shared::components::ActorKind\":PrefabAnchor,\"{}\":{},\"{}\":{} }})}})", + std::any::type_name::(), + ron::to_string(&Transform::default()).unwrap(), + std::any::type_name::(), + ron::to_string(&nested_instance).unwrap(), + ); + std::fs::write(&variant_path, &variant_source).unwrap(); + + let mut world = prefab_test_world(&root); + let outer = world + .spawn(PrefabInstance::new( + "variant", + "assets/prefabs/variant.scn.ron", + )) + .id(); + let nested = world + .spawn(( + ActorId::new("nested"), + nested_instance.clone(), + HydratedPrefabMember { + instance_root: outer, + }, + ChildOf(outer), + )) + .id(); + let member = world + .spawn(( + ActorId::new("mesh"), + variant_material.clone(), + HydratedPrefabMember { + instance_root: nested, + }, + ChildOf(nested), + )) + .id(); + let mut overridden = variant_material; + overridden.roughness = 0.1; + set_prefab_material_property_override(&mut world, member, "roughness", overridden); + let outer_instance = world.get::(outer).unwrap().clone(); + let outer_overrides = read_overrides(&outer_instance); + assert!(prefab_semantic_conflicts(&root, &outer_instance, &outer_overrides).is_empty()); + + apply_prefab_member_scope_to_source( + &mut world, + member, + Some(std::any::type_name::()), + Some("roughness"), + ); + + assert_eq!(std::fs::read_to_string(&base_path).unwrap(), base_source); + let document = + SceneDocument::from_ron_text(&std::fs::read_to_string(&variant_path).unwrap()).unwrap(); + let nested_overrides = decode_prefab_overrides(&document.prefab_instances[0]).unwrap(); + assert_eq!(nested_overrides.properties.len(), 1); + assert!(nested_overrides.properties[0] + .target + .instance_chain + .is_empty()); + assert_eq!(nested_overrides.properties[0].target.actor_id, "mesh"); + let preserved_base: MaterialDesc = + ron::from_str(&nested_overrides.properties[0].base_component_ron).unwrap(); + let committed_value: MaterialDesc = + ron::from_str(&nested_overrides.properties[0].value_component_ron).unwrap(); + assert_eq!(preserved_base.roughness, base_material.roughness); + assert_eq!(committed_value.roughness, 0.1); + let effective = source_component_for_target( + &root, + world.get::(outer).unwrap(), + &PrefabActorPath { + instance_chain: vec!["nested".to_string()], + actor_id: "mesh".to_string(), + }, + std::any::type_name::(), + ) + .unwrap() + .unwrap(); + assert_eq!( + ron::from_str::(&effective.ron) + .unwrap() + .roughness, + 0.1 + ); + assert!(read_overrides(world.get::(outer).unwrap()) + .properties + .is_empty()); + + crate::history::apply_command_undo(&mut world); + assert_eq!( + std::fs::read_to_string(&variant_path).unwrap(), + variant_source + ); + assert_eq!( + read_overrides(world.get::(outer).unwrap()) + .properties + .len(), + 1 + ); + std::fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn source_field_conflict_keeps_override_recoverable() { + let root = std::env::temp_dir().join(format!( + "blacksite-prefab-source-conflict-{}", + uuid::Uuid::new_v4() + )); + let prefabs = root.join("assets/prefabs"); + std::fs::create_dir_all(&prefabs).unwrap(); + std::fs::write(root.join("assets/project.ron"), "()").unwrap(); + let source_path = prefabs.join("base.scn.ron"); + let base = MaterialDesc::default(); + std::fs::write(&source_path, prefab_source("mesh", Some(&base))).unwrap(); + + let mut world = prefab_test_world(&root); + let instance_root = world + .spawn(PrefabInstance::new("base", "assets/prefabs/base.scn.ron")) + .id(); + let member = world + .spawn(( + ActorId::new("mesh"), + base.clone(), + HydratedPrefabMember { instance_root }, + ChildOf(instance_root), + )) + .id(); + let mut overridden = base.clone(); + overridden.roughness = 0.1; + set_prefab_material_property_override(&mut world, member, "roughness", overridden); + let mut externally_changed = base; + externally_changed.roughness = 0.4; + let changed_source = prefab_source("mesh", Some(&externally_changed)); + std::fs::write(&source_path, &changed_source).unwrap(); + + apply_prefab_member_scope_to_source( + &mut world, + member, + Some(std::any::type_name::()), + Some("roughness"), + ); + + assert_eq!( + std::fs::read_to_string(&source_path).unwrap(), + changed_source + ); + assert_eq!( + read_overrides(world.get::(instance_root).unwrap()) + .properties + .len(), + 1 + ); + assert!(world + .resource::() + .status + .contains("conflict")); + assert_eq!( + world + .resource::() + .undo_depth(), + 1 + ); + std::fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn visibility_component_override_survives_rename_by_actor_path() { + let mut world = World::new(); + world.init_resource::(); + world.init_resource::(); + let root = world + .spawn(( + LevelObject, + PrefabInstance::new("prefab-id", "assets/prefabs/test.scn.ron"), + )) + .id(); + let child = world + .spawn(( + LevelObject, + ActorId::new("stable-child"), + Name::new("Old Name"), + EditorVisibility { visible: false }, + ChildOf(root), + HydratedPrefabMember { + instance_root: root, + }, + )) + .id(); + + set_child_visibility_override(&mut world, root, child, false); + let overrides = read_overrides(world.get::(root).unwrap()); + assert!(overrides.child_visibility.is_empty()); + assert_eq!(overrides.components.len(), 1); + assert_eq!( + overrides.components[0].target, + PrefabActorPath::direct("stable-child") + ); + assert_eq!( + overrides.components[0].component_type, + std::any::type_name::() + ); + assert_eq!( + world + .resource::() + .undo_depth(), + 1 + ); + + crate::history::apply_command_undo(&mut world); + assert!(read_overrides(world.get::(root).unwrap()) + .components + .is_empty()); + crate::history::apply_command_redo(&mut world); + assert_eq!( + read_overrides(world.get::(root).unwrap()).components[0].target, + PrefabActorPath::direct("stable-child") + ); + + world.entity_mut(child).insert(Name::new("Renamed Child")); + assert_eq!( + read_overrides(world.get::(root).unwrap()).components[0].target, + PrefabActorPath::direct("stable-child") + ); + + revert_child_visibility_overrides(&mut world, root); + assert_eq!( + world + .resource::() + .undo_depth(), + 2 + ); + assert!(world.get::(child).is_none()); + assert!(read_overrides(world.get::(root).unwrap()) + .components + .is_empty()); + + crate::history::apply_command_undo(&mut world); + assert_eq!( + read_overrides(world.get::(root).unwrap()).components[0].target, + PrefabActorPath::direct("stable-child") + ); + crate::history::apply_command_redo(&mut world); + assert!(read_overrides(world.get::(root).unwrap()) + .components + .is_empty()); + } + + #[test] + fn nested_visibility_overrides_distinguish_repeated_leaf_actor_ids() { + let mut world = World::new(); + world.init_resource::(); + world.init_resource::(); + let root = world + .spawn(PrefabInstance::new("outer", "assets/prefabs/outer.scn.ron")) + .id(); + let nested_a = world + .spawn(( + ActorId::new("nested-a"), + PrefabInstance::new("leaf", "assets/prefabs/leaf.scn.ron"), + HydratedPrefabMember { + instance_root: root, + }, + ChildOf(root), + )) + .id(); + let nested_b = world + .spawn(( + ActorId::new("nested-b"), + PrefabInstance::new("leaf", "assets/prefabs/leaf.scn.ron"), + HydratedPrefabMember { + instance_root: root, + }, + ChildOf(root), + )) + .id(); + let leaf_a = world + .spawn(( + ActorId::new("shared-leaf"), + EditorVisibility::default(), + HydratedPrefabMember { + instance_root: nested_a, + }, + ChildOf(nested_a), + )) + .id(); + let leaf_b = world + .spawn(( + ActorId::new("shared-leaf"), + EditorVisibility::default(), + HydratedPrefabMember { + instance_root: nested_b, + }, + ChildOf(nested_b), + )) + .id(); + + set_child_visibility_override(&mut world, root, leaf_a, false); + set_child_visibility_override(&mut world, root, leaf_b, false); + + let overrides = read_overrides(world.get::(root).unwrap()); + let targets: HashSet<_> = overrides + .components + .iter() + .map(|component| component.target.clone()) + .collect(); + assert_eq!(targets.len(), 2); + assert!(targets.contains(&PrefabActorPath { + instance_chain: vec!["nested-a".to_string()], + actor_id: "shared-leaf".to_string(), + })); + assert!(targets.contains(&PrefabActorPath { + instance_chain: vec!["nested-b".to_string()], + actor_id: "shared-leaf".to_string(), + })); + + crate::history::apply_command_undo(&mut world); + assert_eq!( + read_overrides(world.get::(root).unwrap()) + .components + .len(), + 1 + ); + crate::history::apply_command_redo(&mut world); + assert_eq!( + read_overrides(world.get::(root).unwrap()) + .components + .len(), + 2 + ); + } + + #[test] + fn ready_instance_reapplies_child_visibility_by_actor_id() { + let mut app = App::new(); + app.add_plugins((AssetPlugin::default(), WorldSerializationPlugin)) + .register_type::() + .add_observer(shared::apply_prefab_overrides_on_ready); + + let mut source = World::new(); + source.spawn(ActorId::new("stable-child")); + let dynamic_world = DynamicWorld::from_world_with( + &source, + &app.world().resource::().read(), + ); + let handle = app + .world_mut() + .resource_mut::>() + .add(dynamic_world); + let root = app + .world_mut() + .spawn(( + PrefabInstance::new("prefab-id", "assets/prefabs/test.scn.ron"), + DynamicWorldRoot(handle), + )) + .id(); + write_overrides( + app.world_mut(), + root, + &PrefabOverrides { + child_visibility: HashMap::from([("stable-child".to_string(), false)]), + ..default() + }, + ); + + app.update(); + + let world = app.world_mut(); + let (_, visibility) = world + .query::<(&ActorId, &EditorVisibility)>() + .single(world) + .expect("ready observer should apply the stable child override"); + assert!(!visibility.visible); + } + + #[test] + fn invalid_reimport_cleanup_allows_single_clean_retry() { + let mut app = App::new(); + app.add_plugins((AssetPlugin::default(), WorldSerializationPlugin)) + .register_type::(); + + let mut source = World::new(); + source.spawn(ActorId::new("source-child")); + let dynamic_world = DynamicWorld::from_world_with( + &source, + &app.world().resource::().read(), + ); + let handle = app + .world_mut() + .resource_mut::>() + .add(dynamic_world); + let root = app + .world_mut() + .spawn(( + PrefabInstance::new("prefab-id", "assets/prefabs/test.scn.ron"), + DynamicWorldRoot(handle.clone()), + )) + .id(); + app.update(); + + let first_instance = **app + .world() + .get::(root) + .expect("initial prefab should be registered"); + let first_members: Vec<_> = app + .world() + .resource::() + .iter_instance_entities(first_instance) + .collect(); + assert_eq!(first_members.len(), 1); + for member in first_members { + app.world_mut() + .entity_mut(member) + .insert(HydratedPrefabMember { + instance_root: root, + }); + } + + block_prefab_hydration(app.world_mut(), root, "invalid source".to_string()); + + assert!(app.world().get::(root).is_none()); + assert!(app.world().get::(root).is_none()); + assert!(app + .world() + .get::(root) + .is_some()); + assert_eq!( + app.world_mut() + .query::<&HydratedPrefabMember>() + .iter(app.world()) + .count(), + 0 + ); + assert!(!app + .world() + .resource::() + .instance_is_ready(first_instance)); + + app.world_mut() + .entity_mut(root) + .remove::() + .insert(DynamicWorldRoot(handle)); + app.update(); + + let repaired_instance = **app + .world() + .get::(root) + .expect("repaired prefab should register a fresh instance"); + let repaired_members: Vec<_> = app + .world() + .resource::() + .iter_instance_entities(repaired_instance) + .collect(); + assert_ne!(repaired_instance, first_instance); + assert_eq!(repaired_members.len(), 1); + assert_eq!( + app.world_mut() + .query::<&ActorId>() + .iter(app.world()) + .count(), + 1, + "retry must not duplicate generated source state" + ); + } + + #[test] + fn removed_asset_event_blocks_prefab_and_cleans_generated_state() { + let project_root = std::env::temp_dir().join(format!( + "blacksite-prefab-removed-event-{}", + uuid::Uuid::new_v4() + )); + std::fs::create_dir_all(project_root.join("assets/prefabs")).unwrap(); + std::fs::write(project_root.join("assets/project.ron"), "()").unwrap(); + + let mut app = App::new(); + app.add_plugins((AssetPlugin::default(), WorldSerializationPlugin)) + .init_resource::() + .insert_resource(crate::project_io::ProjectWorkspace { + root: project_root.display().to_string(), + ..Default::default() + }) + .add_systems(Update, guard_prefab_reimports); + let handle = app + .world_mut() + .resource_mut::>() + .add(DynamicWorld::default()); + let root = app + .world_mut() + .spawn(( + DynamicWorldRoot(handle.clone()), + PrefabInstance::new("missing", "assets/prefabs/missing.scn.ron"), + )) + .id(); + let generated = app + .world_mut() + .spawn(HydratedPrefabMember { + instance_root: root, + }) + .id(); + app.world_mut() + .resource_mut::>>() + .write(AssetEvent::Removed { id: handle.id() }); + + app.update(); + + assert!(app.world().get_entity(generated).is_err()); + assert!(app + .world() + .get::(root) + .is_some()); + assert!(app.world().get::(root).is_none()); + std::fs::remove_dir_all(project_root).unwrap(); + } + + #[test] + fn nested_invalid_reimport_blocks_top_authored_owner() { + let mut world = World::new(); + let root = world + .spawn(( + PrefabInstance::new("outer", "assets/prefabs/outer.scn.ron"), + DynamicWorldRoot::default(), + )) + .id(); + let nested = world + .spawn(( + PrefabInstance::new("nested", "assets/prefabs/nested.scn.ron"), + HydratedPrefabMember { + instance_root: root, + }, + )) + .id(); + let nested_child = world + .spawn(HydratedPrefabMember { + instance_root: nested, + }) + .id(); + + block_prefab_hydration(&mut world, nested, "nested source invalid".to_string()); + + assert!(world + .get::(root) + .is_some_and(|blocked| blocked.reason == "nested source invalid")); + assert!(world.get::(root).is_none()); + assert!(world.get_entity(nested).is_err()); + assert!(world.get_entity(nested_child).is_err()); + } } diff --git a/crates/editor/src/assets/thumbnails/cache.rs b/crates/editor/src/assets/thumbnails/cache.rs index e50eaac..3de9eff 100644 --- a/crates/editor/src/assets/thumbnails/cache.rs +++ b/crates/editor/src/assets/thumbnails/cache.rs @@ -120,6 +120,7 @@ impl AssetThumbnailCache { model_path: String, mesh_label: String, material_label: Option, + requires_scene_instance: bool, studio: &mut ThumbnailStudio, ) { if matches!( @@ -132,14 +133,16 @@ impl AssetThumbnailCache { if !self.model_source_ready(&key, &model_path) { return; } - if studio.enqueue_source( - key.clone(), + let source = if requires_scene_instance { + ThumbnailJobSource::Model { model_path } + } else { ThumbnailJobSource::MeshSubAsset { model_path, mesh_label, material_label, - }, - ) { + } + }; + if studio.enqueue_source(key.clone(), source) { self.studio_pending.insert(key); } } @@ -292,6 +295,7 @@ 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, diff --git a/crates/editor/src/assets/thumbnails/mod.rs b/crates/editor/src/assets/thumbnails/mod.rs index 988e388..c28f54c 100644 --- a/crates/editor/src/assets/thumbnails/mod.rs +++ b/crates/editor/src/assets/thumbnails/mod.rs @@ -12,7 +12,9 @@ pub use cache::{ AssetThumbnailCache, AssetThumbnailsPlugin, ThumbnailCacheSnapshot, ThumbnailState, }; pub use job::{ThumbnailJob, ThumbnailJobSource}; -pub use sources::{FbxThumbnailSource, GltfThumbnailSource, ThumbnailModelSource}; +pub use sources::{ + gltf_skinned_primitive_labels, FbxThumbnailSource, GltfThumbnailSource, ThumbnailModelSource, +}; pub use studio::{ model_file_exists, model_scene_asset_path, ThumbnailStudio, ThumbnailStudioPlugin, }; diff --git a/crates/editor/src/assets/thumbnails/sources/gltf.rs b/crates/editor/src/assets/thumbnails/sources/gltf.rs index 088c48a..9c36594 100644 --- a/crates/editor/src/assets/thumbnails/sources/gltf.rs +++ b/crates/editor/src/assets/thumbnails/sources/gltf.rs @@ -1,5 +1,6 @@ //! 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. @@ -64,6 +65,32 @@ pub fn validate_gltf_dependencies(model_asset_path: &str) -> Result<(), String> Ok(()) } +/// Returns primitive labels that cannot be rendered without their source skin hierarchy. +pub fn gltf_skinned_primitive_labels(model_asset_path: &str) -> HashSet { + 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 { @@ -137,4 +164,16 @@ mod tests { 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")); + } } diff --git a/crates/editor/src/assets/thumbnails/sources/mod.rs b/crates/editor/src/assets/thumbnails/sources/mod.rs index b83e999..470792f 100644 --- a/crates/editor/src/assets/thumbnails/sources/mod.rs +++ b/crates/editor/src/assets/thumbnails/sources/mod.rs @@ -4,7 +4,7 @@ mod fbx; pub(crate) mod gltf; pub use fbx::FbxThumbnailSource; -pub use gltf::GltfThumbnailSource; +pub use gltf::{gltf_skinned_primitive_labels, GltfThumbnailSource}; use bevy::prelude::*; diff --git a/crates/editor/src/assets/thumbnails/studio.rs b/crates/editor/src/assets/thumbnails/studio.rs index 108df7f..6792577 100644 --- a/crates/editor/src/assets/thumbnails/studio.rs +++ b/crates/editor/src/assets/thumbnails/studio.rs @@ -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, ModelRef}; +use shared::{material_from_desc, standard_material_asset_path, ModelRef}; use super::cache::AssetThumbnailCache; use super::job::{ThumbnailJob, ThumbnailJobSource}; @@ -413,7 +413,7 @@ fn spawn_thumbnail_job_content( commands, mesh_storage, root, - asset_server.load(labeled_asset_path( + asset_server.load(standard_material_asset_path( &asset_server_path(model_path), material_label, )), @@ -437,7 +437,7 @@ fn spawn_mesh_subasset_preview( let source_path = asset_server_path(model_path); let mesh: Handle = asset_server.load(labeled_asset_path(&source_path, mesh_label)); let material = if let Some(material_label) = material_label { - asset_server.load(labeled_asset_path(&source_path, material_label)) + asset_server.load(standard_material_asset_path(&source_path, material_label)) } else { materials.add(StandardMaterial { base_color: Color::srgb(0.72, 0.72, 0.75), diff --git a/crates/editor/src/bin/main.rs b/crates/editor/src/bin/main.rs index bd3fce6..9d7609e 100644 --- a/crates/editor/src/bin/main.rs +++ b/crates/editor/src/bin/main.rs @@ -1,6 +1,27 @@ //! 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") }; diff --git a/crates/editor/src/bin/project_launcher.rs b/crates/editor/src/bin/project_launcher.rs new file mode 100644 index 0000000..d5d536d --- /dev/null +++ b/crates/editor/src/bin/project_launcher.rs @@ -0,0 +1,9 @@ +//! 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(); +} diff --git a/crates/editor/src/history/commands.rs b/crates/editor/src/history/commands.rs index 1ce3c42..392859f 100644 --- a/crates/editor/src/history/commands.rs +++ b/crates/editor/src/history/commands.rs @@ -1,8 +1,11 @@ +use std::path::PathBuf; + use bevy::prelude::*; use shared::{ - ActorId, ActorKind, ActorName, BrushDesc, ColliderDesc, EditorVisibility, InspectorOrder, - LightDesc, MaterialDesc, MaterialOverride, ModelRef, ObjectiveMarker, PhysicsBody, - PostProcessVolumeDesc, PrefabInstance, PrefabRef, Primitive, RigidBodyDesc, StaticMeshRenderer, + ActorId, ActorKind, ActorName, AnimationControllerDesc, AudioListenerDesc, AudioSourceDesc, + BrushDesc, ColliderDesc, EditorVisibility, InspectorOrder, LightDesc, MaterialDesc, + MaterialOverride, ModelRef, ObjectiveMarker, PhysicsBody, PostProcessVolumeDesc, + PrefabInstance, PrefabRef, Primitive, RigidBodyDesc, SceneComposition, StaticMeshRenderer, TeamSpawn, TriggerVolume, WeaponSpawn, }; #[derive(Debug, Clone)] @@ -21,6 +24,9 @@ pub struct EditorEntitySnapshot { pub collider: Option, pub physics: Option, pub light: Option, + pub animation_controller: Option, + pub audio_source: Option, + pub audio_listener: Option, pub player_spawn: bool, pub model: Option, pub prefab: Option, @@ -42,6 +48,17 @@ pub struct SiblingChange { pub new_parent: Option, pub old_index: i32, pub new_index: i32, + pub old_transform: Option, + pub new_transform: Option, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct PrefabEditState { + pub instance: Option, + pub prefab_ref: Option, + pub blocked_reason: Option, + pub transform: Option, + pub material: Option, } #[derive(Debug, Clone)] @@ -77,6 +94,11 @@ pub enum EditorCommand { old: Option, new: MaterialDesc, }, + SetMaterialGroup { + entities: Vec, + olds: Vec>, + news: Vec, + }, SetMaterialOverride { entity: Entity, old: Option, @@ -87,6 +109,21 @@ pub enum EditorCommand { old: Option, new: LightDesc, }, + SetAnimationController { + entity: Entity, + old: Option, + new: AnimationControllerDesc, + }, + SetAudioSource { + entity: Entity, + old: Option, + new: AudioSourceDesc, + }, + SetAudioListener { + entity: Entity, + old: Option, + new: AudioListenerDesc, + }, SetPhysics { entity: Entity, old: Option, @@ -165,6 +202,35 @@ pub enum EditorCommand { old: Option, 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, + linked_local_roots: Vec, + unpacked_snapshots: Vec, + unpacked_parented_to_anchor: Vec, + unpacked_roots: Vec, + }, AddComponent { entity: Entity, snapshot: EditorEntitySnapshot, @@ -185,8 +251,12 @@ 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", @@ -202,6 +272,15 @@ 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", } diff --git a/crates/editor/src/history/mod.rs b/crates/editor/src/history/mod.rs index d60de83..43a0901 100644 --- a/crates/editor/src/history/mod.rs +++ b/crates/editor/src/history/mod.rs @@ -1,22 +1,28 @@ +use std::collections::HashSet; + use bevy::prelude::*; +use bevy::world_serialization::WorldInstance; use shared::{ - infer_actor_kind, ActorId, ActorKind, ActorName, BrushDesc, ColliderDesc, EditorVisibility, - HierarchySiblingIndex, InspectorOrder, LevelObject, LightDesc, MaterialDesc, MaterialOverride, - ModelRef, ObjectiveMarker, PhysicsBody, PlayerSpawn, PostProcessVolumeDesc, PrefabInstance, - PrefabRef, Primitive, RigidBodyDesc, StaticMeshRenderer, TeamSpawn, TriggerVolume, WeaponSpawn, + infer_actor_kind, ActorId, ActorKind, ActorName, AnimationControllerDesc, AudioListenerDesc, + AudioSourceDesc, BrushDesc, ColliderDesc, EditorVisibility, HierarchySiblingIndex, + HydratedPrefabMember, HydratedPrefabReady, InspectorOrder, LevelObject, LightDesc, + MaterialDesc, MaterialOverride, ModelRef, ObjectiveMarker, PhysicsBody, PlayerSpawn, + PostProcessVolumeDesc, PrefabHydrationBlocked, PrefabInstance, PrefabRef, Primitive, + RigidBodyDesc, SceneComposition, StaticMeshRenderer, TeamSpawn, TriggerVolume, WeaponSpawn, }; -use crate::scene_io::SceneIo; +use crate::scene_io::{SceneIo, SceneIoRequest}; use crate::selection::SelectedEntity; use crate::state::scene_tools_active; use crate::ui::hierarchy_ops::{ - apply_sibling_change, apply_sibling_change_new, next_sibling_index, - reorder_entities_under_parent, would_create_cycle, + apply_sibling_change, apply_sibling_change_new, hierarchy_drop_violation, next_sibling_index, + reorder_entities_under_parent, }; +use crate::ui::selection_ops::is_mutable_level_object; use crate::ui::{egui_captures_keyboard_from_world, UiState}; mod commands; -pub use commands::{EditorCommand, EditorEntitySnapshot, SiblingChange}; +pub use commands::{EditorCommand, EditorEntitySnapshot, PrefabEditState, SiblingChange}; #[derive(Resource, Debug, Default)] pub struct EditorHistory { @@ -98,7 +104,10 @@ impl Plugin for EditorHistoryPlugin { pub fn snapshot_entity(world: &World, entity: Entity) -> Option { let entity_ref = world.get_entity(entity).ok()?; - if !entity_ref.contains::() { + if !entity_ref.contains::() + || entity_ref.contains::() + || entity_ref.contains::() + { return None; } @@ -135,6 +144,9 @@ pub fn snapshot_entity(world: &World, entity: Entity) -> Option().cloned(), physics: entity_ref.get::().cloned(), light: entity_ref.get::().cloned(), + animation_controller: entity_ref.get::().cloned(), + audio_source: entity_ref.get::().cloned(), + audio_listener: entity_ref.get::().copied(), player_spawn: entity_ref.contains::(), model: entity_ref.get::().cloned(), prefab: entity_ref.get::().cloned(), @@ -210,6 +222,15 @@ fn spawn_snapshot_with_parent( if let Some(light) = &snapshot.light { entity_mut.insert(light.clone()); } + if let Some(controller) = &snapshot.animation_controller { + entity_mut.insert(controller.clone()); + } + if let Some(audio_source) = &snapshot.audio_source { + entity_mut.insert(audio_source.clone()); + } + if let Some(audio_listener) = snapshot.audio_listener { + entity_mut.insert(audio_listener); + } if snapshot.player_spawn { entity_mut.insert(PlayerSpawn); } @@ -303,6 +324,9 @@ pub fn spawn_many_with_history( pub fn delete_entities_with_history(world: &mut World, entities: &[Entity]) { let mut deleted = Vec::new(); for entity in entities { + if !is_mutable_level_object(world, *entity) { + continue; + } if let Some(snapshot) = snapshot_entity(world, *entity) { deleted.push((snapshot, *entity)); } @@ -329,8 +353,10 @@ pub fn delete_entities_with_history(world: &mut World, entities: &[Entity]) { pub fn duplicate_entities_with_history(world: &mut World, entities: &[Entity]) { let snapshots: Vec = entities .iter() + .filter(|entity| is_mutable_level_object(world, **entity)) .filter_map(|entity| snapshot_entity(world, *entity)) .map(|mut snapshot| { + assign_fresh_actor_ids(&mut snapshot); snapshot.transform.translation += Vec3::new(1.0, 0.0, 1.0); if let Some(name) = &mut snapshot.name { name.push_str(" Copy"); @@ -357,7 +383,17 @@ pub fn duplicate_entities_with_history(world: &mut World, entities: &[Entity]) { select_many(world, &entities); } +fn assign_fresh_actor_ids(snapshot: &mut EditorEntitySnapshot) { + snapshot.actor_id = Some(new_actor_id()); + for child in &mut snapshot.children { + assign_fresh_actor_ids(child); + } +} + pub fn rename_entity_with_history(world: &mut World, entity: Entity, new_name: String) { + if !is_mutable_level_object(world, entity) { + return; + } let old = world .get::(entity) .map(|name| name.as_str().to_string()) @@ -379,7 +415,7 @@ pub fn rename_entity_with_history(world: &mut World, entity: Entity, new_name: S } pub fn set_material_with_history(world: &mut World, entity: Entity, new: MaterialDesc) { - if !is_level_object(world, entity) { + if !is_mutable_level_object(world, entity) { return; } let old = world.get::(entity).cloned(); @@ -392,11 +428,48 @@ pub fn set_material_with_history(world: &mut World, entity: Entity, new: Materia push_history(world, EditorCommand::SetMaterial { entity, old, new }); } +pub fn set_material_group_with_history( + world: &mut World, + changes: impl IntoIterator, +) { + let mut entities = Vec::new(); + let mut olds = Vec::new(); + let mut news = Vec::new(); + for (entity, new) in changes { + if !is_mutable_level_object(world, entity) { + continue; + } + let old = world.get::(entity).cloned(); + if old.as_ref().is_some_and(|old| material_eq(old, &new)) { + continue; + } + if let Ok(mut entity_mut) = world.get_entity_mut(entity) { + entity_mut.insert(new.clone()); + entities.push(entity); + olds.push(old); + news.push(new); + } + } + if !entities.is_empty() { + push_history( + world, + EditorCommand::SetMaterialGroup { + entities, + olds, + news, + }, + ); + } +} + pub fn set_material_override_with_history( world: &mut World, entity: Entity, new: MaterialOverride, ) { + if !is_mutable_level_object(world, entity) { + return; + } let old = world.get::(entity).cloned(); if old.as_ref() == Some(&new) { return; @@ -411,7 +484,7 @@ pub fn set_material_override_with_history( } pub fn set_light_with_history(world: &mut World, entity: Entity, new: LightDesc) { - if !is_level_object(world, entity) { + if !is_mutable_level_object(world, entity) { return; } let old = world.get::(entity).cloned(); @@ -421,12 +494,61 @@ pub fn set_light_with_history(world: &mut World, entity: Entity, new: LightDesc) push_history(world, EditorCommand::SetLight { entity, old, new }); } +pub fn set_audio_source_with_history(world: &mut World, entity: Entity, new: AudioSourceDesc) { + if !is_mutable_level_object(world, entity) { + return; + } + let old = world.get::(entity).cloned(); + if old.as_ref() == Some(&new) { + return; + } + if let Ok(mut entity_mut) = world.get_entity_mut(entity) { + entity_mut.insert(new.clone()); + } + push_history(world, EditorCommand::SetAudioSource { entity, old, new }); +} + +pub fn set_animation_controller_with_history( + world: &mut World, + entity: Entity, + new: AnimationControllerDesc, +) { + if !is_mutable_level_object(world, entity) { + return; + } + let old = world.get::(entity).cloned(); + if old.as_ref() == Some(&new) { + return; + } + if let Ok(mut entity_mut) = world.get_entity_mut(entity) { + entity_mut.insert(new.clone()); + } + push_history( + world, + EditorCommand::SetAnimationController { entity, old, new }, + ); +} + +pub fn set_audio_listener_with_history(world: &mut World, entity: Entity, new: AudioListenerDesc) { + if !is_mutable_level_object(world, entity) { + return; + } + let old = world.get::(entity).copied(); + if old == Some(new) { + return; + } + if let Ok(mut entity_mut) = world.get_entity_mut(entity) { + entity_mut.insert(new); + } + push_history(world, EditorCommand::SetAudioListener { entity, old, new }); +} + pub fn set_post_process_volume_with_history( world: &mut World, entity: Entity, new: PostProcessVolumeDesc, ) { - if !is_level_object(world, entity) { + if !is_mutable_level_object(world, entity) { return; } let old = world.get::(entity).cloned(); @@ -440,7 +562,7 @@ pub fn set_post_process_volume_with_history( } pub fn set_physics_with_history(world: &mut World, entity: Entity, new: PhysicsBody) { - if !is_level_object(world, entity) { + if !is_mutable_level_object(world, entity) { return; } let old = world.get::(entity).cloned(); @@ -451,6 +573,9 @@ pub fn set_physics_with_history(world: &mut World, entity: Entity, new: PhysicsB } pub fn set_rigid_body_with_history(world: &mut World, entity: Entity, new: RigidBodyDesc) { + if !is_mutable_level_object(world, entity) { + return; + } let old = world.get::(entity).copied(); if old == Some(new) { return; @@ -462,6 +587,9 @@ pub fn set_rigid_body_with_history(world: &mut World, entity: Entity, new: Rigid } pub fn set_collider_with_history(world: &mut World, entity: Entity, new: ColliderDesc) { + if !is_mutable_level_object(world, entity) { + return; + } let old = world.get::(entity).cloned(); if old.as_ref() == Some(&new) { return; @@ -473,7 +601,7 @@ pub fn set_collider_with_history(world: &mut World, entity: Entity, new: Collide } pub fn set_primitive_with_history(world: &mut World, entity: Entity, new: Primitive) { - if !is_level_object(world, entity) { + if !is_mutable_level_object(world, entity) { return; } let old = world.get::(entity).cloned(); @@ -484,7 +612,7 @@ pub fn set_primitive_with_history(world: &mut World, entity: Entity, new: Primit } pub fn set_brush_with_history(world: &mut World, entity: Entity, new: BrushDesc) { - if !is_level_object(world, entity) { + if !is_mutable_level_object(world, entity) { return; } let old = world.get::(entity).cloned(); @@ -503,7 +631,7 @@ pub fn set_brush_transform_with_history( new_transform: Transform, new_brush: BrushDesc, ) { - if !is_level_object(world, entity) { + if !is_mutable_level_object(world, entity) { return; } let old_transform = world.get::(entity).copied().unwrap_or_default(); @@ -578,7 +706,7 @@ pub fn set_static_mesh_renderer_with_history( entity: Entity, new: StaticMeshRenderer, ) { - if !is_level_object(world, entity) { + if !is_mutable_level_object(world, entity) { return; } let old = world.get::(entity).cloned(); @@ -607,12 +735,22 @@ pub fn reorder_siblings_with_history( let entities: Vec = entities .iter() .copied() - .filter(|entity| is_level_object(world, *entity)) + .filter(|entity| is_mutable_level_object(world, *entity)) .collect(); if entities.is_empty() { return; } - if would_create_cycle(world, &entities, new_parent) { + if let Some(violation) = hierarchy_drop_violation(world, &entities, new_parent) { + if let Some(mut scene_io) = world.get_resource_mut::() { + scene_io.set_status(match violation { + crate::ui::hierarchy_ops::HierarchyDropViolation::Cycle => { + "Hierarchy change blocked: it would create a cycle".to_string() + } + crate::ui::hierarchy_ops::HierarchyDropViolation::PrefabBoundary { .. } => + "Hierarchy change blocked: prefab instance contents are read-only; unpack the instance first" + .to_string(), + }); + } return; } @@ -628,7 +766,7 @@ pub fn set_editor_visibility_with_history( entity: Entity, new: EditorVisibility, ) { - if !is_level_object(world, entity) { + if !is_mutable_level_object(world, entity) { return; } let old = world @@ -648,7 +786,7 @@ pub fn set_editor_visibility_with_history( } pub fn set_inspector_order_with_history(world: &mut World, entity: Entity, new: InspectorOrder) { - if !is_level_object(world, entity) { + if !is_mutable_level_object(world, entity) { return; } let old = world.get::(entity).cloned(); @@ -665,7 +803,7 @@ pub fn group_selection_with_history(world: &mut World, entities: &[Entity]) { let entities: Vec = entities .iter() .copied() - .filter(|entity| is_level_object(world, *entity)) + .filter(|entity| is_mutable_level_object(world, *entity)) .collect(); if entities.is_empty() { return; @@ -686,6 +824,9 @@ pub fn group_selection_with_history(world: &mut World, entities: &[Entity]) { collider: None, physics: None, light: None, + animation_controller: None, + audio_source: None, + audio_listener: None, player_spawn: false, model: None, prefab: None, @@ -711,7 +852,11 @@ pub fn group_selection_with_history(world: &mut World, entities: &[Entity]) { } pub fn create_empty_child_with_history(world: &mut World, parent: Entity) { - if !is_level_object(world, parent) { + if !is_level_object(world, parent) || world.get::(parent).is_some() { + if let Some(mut scene_io) = world.get_resource_mut::() { + scene_io + .set_status("Create child blocked: generated prefab source content is read-only"); + } return; } let snapshot = EditorEntitySnapshot { @@ -729,6 +874,9 @@ pub fn create_empty_child_with_history(world: &mut World, parent: Entity) { collider: None, physics: None, light: None, + animation_controller: None, + audio_source: None, + audio_listener: None, player_spawn: false, model: None, prefab: None, @@ -754,13 +902,268 @@ pub fn create_empty_child_with_history(world: &mut World, parent: Entity) { } pub fn unpack_prefab_instance(world: &mut World, entity: Entity) { - if world.get::(entity).is_none() { + let Some(instance) = world.get::(entity).cloned() else { + return; + }; + let prefab_ref = world + .get::(entity) + .cloned() + .unwrap_or_else(|| PrefabRef::new(instance.source_path.clone())); + if world.get::(entity).is_none() { + if let Some(mut scene_io) = world.get_resource_mut::() { + scene_io.set_status("Unpack blocked: prefab source is still loading or unavailable"); + } return; } - if let Ok(mut entity_mut) = world.get_entity_mut(entity) { - entity_mut.remove::(); + let linked_actor_kind = world + .get::(entity) + .copied() + .unwrap_or(ActorKind::PrefabAnchor); + if let Err(error) = + crate::assets::prefab_overrides::fold_nested_overrides_for_unpack(world, entity) + { + if let Some(mut scene_io) = world.get_resource_mut::() { + scene_io.set_status(format!("Unpack blocked: {error}")); + } + return; } - mark_dirty(world); + let unpacked_roots = perform_prefab_unpack(world, entity); + let unpacked_parented_to_anchor = unpacked_roots + .iter() + .map(|root| world.get::(*root).map(ChildOf::parent) == Some(entity)) + .collect(); + let unpacked_snapshots = unpacked_roots + .iter() + .filter_map(|root| snapshot_entity(world, *root)) + .collect(); + push_history( + world, + EditorCommand::UnpackPrefab { + entity, + instance, + prefab_ref, + linked_actor_kind, + fully_local: false, + linked_local_snapshots: Vec::new(), + linked_local_roots: Vec::new(), + unpacked_snapshots, + unpacked_parented_to_anchor, + unpacked_roots, + }, + ); +} + +pub fn convert_prefab_completely_to_local(world: &mut World, entity: Entity) { + let Some(instance) = world.get::(entity).cloned() else { + return; + }; + let prefab_ref = world + .get::(entity) + .cloned() + .unwrap_or_else(|| PrefabRef::new(instance.source_path.clone())); + let linked_local: Vec<(Entity, EditorEntitySnapshot)> = world + .get::(entity) + .map(|children| { + children + .iter() + .filter(|child| world.get::(*child).is_none()) + .filter_map(|child| snapshot_entity(world, child).map(|snapshot| (child, snapshot))) + .collect() + }) + .unwrap_or_default(); + let linked_local_roots: Vec = linked_local.iter().map(|(root, _)| *root).collect(); + let linked_local_snapshots = linked_local + .into_iter() + .map(|(_, snapshot)| snapshot) + .collect(); + + let mut all_instances: Vec<(usize, Entity)> = world + .query_filtered::>() + .iter(world) + .filter_map(|candidate| { + prefab_conversion_depth(world, candidate, entity).map(|depth| (depth, candidate)) + }) + .collect(); + all_instances.sort_by_key(|(depth, _)| *depth); + if all_instances + .iter() + .any(|(_, instance_root)| world.get::(*instance_root).is_none()) + { + if let Some(mut scene_io) = world.get_resource_mut::() { + scene_io + .set_status("Complete conversion blocked: every nested prefab must finish loading"); + } + return; + } + + let linked_actor_kind = world + .get::(entity) + .copied() + .unwrap_or(ActorKind::PrefabAnchor); + let mut candidate_roots = linked_local_roots.clone(); + for (_, instance_root) in all_instances { + candidate_roots.extend(perform_prefab_unpack(world, instance_root)); + } + let candidate_set: HashSet<_> = candidate_roots.iter().copied().collect(); + candidate_roots.retain(|root| !has_candidate_ancestor(world, *root, &candidate_set)); + let mut unpacked_roots = Vec::new(); + let mut unpacked_snapshots = Vec::new(); + let mut unpacked_parented_to_anchor = Vec::new(); + for root in candidate_roots { + if let Some(snapshot) = snapshot_entity(world, root) { + unpacked_parented_to_anchor + .push(world.get::(root).map(ChildOf::parent) == Some(entity)); + unpacked_roots.push(root); + unpacked_snapshots.push(snapshot); + } + } + push_history( + world, + EditorCommand::UnpackPrefab { + entity, + instance, + prefab_ref, + linked_actor_kind, + fully_local: true, + linked_local_snapshots, + linked_local_roots: Vec::new(), + unpacked_snapshots, + unpacked_parented_to_anchor, + unpacked_roots, + }, + ); +} + +fn prefab_conversion_depth(world: &World, entity: Entity, root: Entity) -> Option { + let mut current = entity; + let mut depth = 0; + let mut seen = HashSet::new(); + while seen.insert(current) { + if current == root { + return Some(depth); + } + current = world + .get::(current) + .map(|owner| owner.instance_root) + .or_else(|| world.get::(current).map(ChildOf::parent))?; + depth += 1; + } + None +} + +fn has_candidate_ancestor(world: &World, entity: Entity, candidates: &HashSet) -> bool { + let mut current = world.get::(entity).map(ChildOf::parent); + let mut seen = HashSet::new(); + while let Some(parent) = current { + if candidates.contains(&parent) { + return true; + } + if !seen.insert(parent) { + break; + } + current = world.get::(parent).map(ChildOf::parent); + } + false +} + +fn perform_prefab_unpack(world: &mut World, entity: Entity) -> Vec { + let members: Vec = world + .query::<(Entity, &HydratedPrefabMember)>() + .iter(world) + .filter_map(|(member, owner)| (owner.instance_root == entity).then_some(member)) + .collect(); + let member_set: HashSet = members.iter().copied().collect(); + let roots = members + .iter() + .copied() + .filter(|member| { + world + .get::(*member) + .map(ChildOf::parent) + .is_none_or(|parent| !member_set.contains(&parent)) + }) + .collect(); + for member in members { + if let Ok(mut member_mut) = world.get_entity_mut(member) { + member_mut.remove::(); + if member_mut.contains::() { + member_mut.insert(new_actor_id()); + } + } + } + if let Ok(mut entity_mut) = world.get_entity_mut(entity) { + entity_mut + .insert(ActorKind::Empty) + .remove::() + .remove::() + .remove::() + .remove::() + .remove::() + .remove::(); + } + roots +} + +fn despawn_hydrated_prefab_members(world: &mut World, entity: Entity) { + let members: Vec = world + .query::<(Entity, &HydratedPrefabMember)>() + .iter(world) + .filter_map(|(member, owner)| (owner.instance_root == entity).then_some(member)) + .collect(); + let member_set: HashSet = members.iter().copied().collect(); + let roots: Vec = members + .into_iter() + .filter(|member| { + world + .get::(*member) + .map(ChildOf::parent) + .is_none_or(|parent| !member_set.contains(&parent)) + }) + .collect(); + for root in roots { + despawn_entity(world, Some(root)); + } +} + +pub fn capture_prefab_edit_state(world: &World, entity: Entity) -> PrefabEditState { + PrefabEditState { + instance: world.get::(entity).cloned(), + prefab_ref: world.get::(entity).cloned(), + blocked_reason: world + .get::(entity) + .map(|blocked| blocked.reason.clone()), + transform: world.get::(entity).copied(), + material: world.get::(entity).cloned(), + } +} + +pub fn record_prefab_edit_state(world: &mut World, entity: Entity, old: PrefabEditState) { + let new = capture_prefab_edit_state(world, entity); + if old != new { + push_history(world, EditorCommand::SetPrefabState { entity, old, new }); + } +} + +pub fn record_prefab_source_apply( + world: &mut World, + entity: Entity, + source_path: std::path::PathBuf, + before_source: String, + after_source: String, + old: PrefabEditState, +) { + let new = capture_prefab_edit_state(world, entity); + push_history( + world, + EditorCommand::ApplyPrefabToSource { + entity, + source_path, + before_source, + after_source, + old, + new, + }, + ); } pub fn set_transform_with_history( @@ -769,6 +1172,9 @@ pub fn set_transform_with_history( old: Transform, new: Transform, ) { + if !is_mutable_level_object(world, entity) { + return; + } if transform_nearly_eq(&old, &new) { return; } @@ -786,6 +1192,9 @@ pub fn set_transform_group_with_history( let mut olds = Vec::new(); let mut news = Vec::new(); for (entity, old, new) in changes { + if !is_mutable_level_object(world, entity) { + continue; + } if transform_nearly_eq(&old, &new) { continue; } @@ -814,7 +1223,7 @@ pub fn set_actor_kind_with_history( old: ActorKind, new: ActorKind, ) { - if old == new { + if !is_mutable_level_object(world, entity) || old == new { return; } push_history(world, EditorCommand::SetActorKind { entity, old, new }); @@ -826,7 +1235,27 @@ pub fn apply_actor_kind(world: &mut World, entity: Entity, kind: ActorKind) { } } +pub fn set_scene_composition_with_history(world: &mut World, new: SceneComposition) { + let old = world.resource::().clone(); + if old == new { + return; + } + world.insert_resource(new.clone()); + push_history(world, EditorCommand::SetSceneComposition { old, new }); + world.resource_mut::().request = Some(SceneIoRequest::ReloadComposition); +} + pub fn apply_command_undo(world: &mut World) { + let candidate = world.resource::().undo_stack.last().cloned(); + if let Some(candidate) = candidate { + if let Err(error) = prepare_prefab_source_history(&candidate, true) { + world.resource_mut::().status = format!("Undo blocked: {error}"); + world + .resource_mut::() + .set_status(format!("Undo blocked: {error}")); + return; + } + } let Some(mut command) = world.resource_mut::().pop_undo() else { return; }; @@ -838,6 +1267,16 @@ pub fn apply_command_undo(world: &mut World) { } pub fn apply_command_redo(world: &mut World) { + let candidate = world.resource::().redo_stack.last().cloned(); + if let Some(candidate) = candidate { + if let Err(error) = prepare_prefab_source_history(&candidate, false) { + world.resource_mut::().status = format!("Redo blocked: {error}"); + world + .resource_mut::() + .set_status(format!("Redo blocked: {error}")); + return; + } + } let Some(mut command) = world.resource_mut::().pop_redo() else { return; }; @@ -848,6 +1287,32 @@ pub fn apply_command_redo(world: &mut World) { mark_dirty(world); } +fn prepare_prefab_source_history(command: &EditorCommand, undo: bool) -> Result<(), String> { + let EditorCommand::ApplyPrefabToSource { + source_path, + before_source, + after_source, + .. + } = command + else { + return Ok(()); + }; + let (expected, replacement) = if undo { + (after_source, before_source) + } else { + (before_source, after_source) + }; + let current = std::fs::read_to_string(source_path) + .map_err(|error| format!("could not read {}: {error}", source_path.display()))?; + if ¤t != expected { + return Err(format!( + "{} changed outside this history command", + source_path.display() + )); + } + crate::scene::recovery::atomic_write(source_path, replacement.as_bytes()) +} + fn undo_command(world: &mut World, command: &mut EditorCommand) { match command { EditorCommand::Spawn { entity, .. } => { @@ -884,12 +1349,26 @@ fn undo_command(world: &mut World, command: &mut EditorCommand) { EditorCommand::SetMaterial { entity, old, .. } => { apply_material(world, *entity, old); } + EditorCommand::SetMaterialGroup { entities, olds, .. } => { + for (entity, old) in entities.iter().zip(olds.iter()) { + apply_material(world, *entity, old); + } + } EditorCommand::SetMaterialOverride { entity, old, .. } => { apply_material_override(world, *entity, old); } EditorCommand::SetLight { entity, old, .. } => { apply_light(world, *entity, old); } + EditorCommand::SetAnimationController { entity, old, .. } => { + apply_animation_controller(world, *entity, old); + } + EditorCommand::SetAudioSource { entity, old, .. } => { + apply_audio_source(world, *entity, old); + } + EditorCommand::SetAudioListener { entity, old, .. } => { + apply_audio_listener(world, *entity, old); + } EditorCommand::SetPostProcessVolume { entity, old, .. } => { apply_post_process_volume(world, *entity, old); } @@ -969,6 +1448,37 @@ fn undo_command(world: &mut World, command: &mut EditorCommand) { EditorCommand::SetInspectorOrder { entity, old, .. } => { apply_inspector_order(world, *entity, old); } + EditorCommand::SetSceneComposition { old, .. } => { + world.insert_resource(old.clone()); + world.resource_mut::().request = Some(SceneIoRequest::ReloadComposition); + } + EditorCommand::SetPrefabState { entity, old, .. } => { + apply_prefab_edit_state(world, *entity, old); + } + EditorCommand::ApplyPrefabToSource { entity, old, .. } => { + apply_prefab_edit_state(world, *entity, old); + } + EditorCommand::UnpackPrefab { + entity, + instance, + prefab_ref, + linked_actor_kind, + linked_local_snapshots, + linked_local_roots, + unpacked_roots, + .. + } => { + for root in std::mem::take(unpacked_roots) { + despawn_entity(world, Some(root)); + } + if let Ok(mut entity_mut) = world.get_entity_mut(*entity) { + entity_mut.insert((*linked_actor_kind, instance.clone(), prefab_ref.clone())); + } + *linked_local_roots = linked_local_snapshots + .iter() + .map(|snapshot| spawn_snapshot_with_parent(world, snapshot, Some(*entity))) + .collect(); + } EditorCommand::AddComponent { entity, snapshot } => { remove_authored_components(world, *entity, snapshot); } @@ -1026,6 +1536,13 @@ fn redo_command(world: &mut World, command: &mut EditorCommand) { entity_mut.insert(new.clone()); } } + EditorCommand::SetMaterialGroup { entities, news, .. } => { + for (entity, new) in entities.iter().zip(news.iter()) { + if let Ok(mut entity_mut) = world.get_entity_mut(*entity) { + entity_mut.insert(new.clone()); + } + } + } EditorCommand::SetMaterialOverride { entity, new, .. } => { if let Ok(mut entity_mut) = world.get_entity_mut(*entity) { entity_mut.insert(new.clone()); @@ -1036,6 +1553,21 @@ fn redo_command(world: &mut World, command: &mut EditorCommand) { entity_mut.insert(new.clone()); } } + EditorCommand::SetAnimationController { entity, new, .. } => { + if let Ok(mut entity_mut) = world.get_entity_mut(*entity) { + entity_mut.insert(new.clone()); + } + } + EditorCommand::SetAudioSource { entity, new, .. } => { + if let Ok(mut entity_mut) = world.get_entity_mut(*entity) { + entity_mut.insert(new.clone()); + } + } + EditorCommand::SetAudioListener { entity, new, .. } => { + if let Ok(mut entity_mut) = world.get_entity_mut(*entity) { + entity_mut.insert(*new); + } + } EditorCommand::SetPostProcessVolume { entity, new, .. } => { if let Ok(mut entity_mut) = world.get_entity_mut(*entity) { entity_mut.insert(new.clone()); @@ -1128,6 +1660,46 @@ fn redo_command(world: &mut World, command: &mut EditorCommand) { entity_mut.insert(new.clone()); } } + EditorCommand::SetSceneComposition { new, .. } => { + world.insert_resource(new.clone()); + world.resource_mut::().request = Some(SceneIoRequest::ReloadComposition); + } + EditorCommand::SetPrefabState { entity, new, .. } => { + apply_prefab_edit_state(world, *entity, new); + } + EditorCommand::ApplyPrefabToSource { entity, new, .. } => { + apply_prefab_edit_state(world, *entity, new); + } + EditorCommand::UnpackPrefab { + entity, + linked_local_roots, + unpacked_snapshots, + unpacked_parented_to_anchor, + unpacked_roots, + .. + } => { + for root in std::mem::take(linked_local_roots) { + despawn_entity(world, Some(root)); + } + despawn_hydrated_prefab_members(world, *entity); + if let Ok(mut entity_mut) = world.get_entity_mut(*entity) { + entity_mut + .insert(ActorKind::Empty) + .remove::() + .remove::() + .remove::() + .remove::() + .remove::() + .remove::(); + } + *unpacked_roots = unpacked_snapshots + .iter() + .zip(unpacked_parented_to_anchor.iter()) + .map(|(snapshot, parented)| { + spawn_snapshot_with_parent(world, snapshot, parented.then_some(*entity)) + }) + .collect(); + } EditorCommand::AddComponent { entity, snapshot } => { apply_authored_components(world, *entity, snapshot); } @@ -1150,6 +1722,56 @@ fn apply_material(world: &mut World, entity: Entity, material: &Option { + entity_mut.insert(instance.clone()); + } + None => { + entity_mut.remove::(); + } + } + match &state.prefab_ref { + Some(prefab_ref) => { + entity_mut.insert(prefab_ref.clone()); + } + None => { + entity_mut.remove::(); + } + } + match &state.blocked_reason { + Some(reason) => { + entity_mut + .insert(PrefabHydrationBlocked { + reason: reason.clone(), + }) + .remove::() + .remove::() + .remove::(); + } + None => { + entity_mut.remove::(); + } + } + if let Some(transform) = state.transform { + entity_mut.insert(transform); + } + match &state.material { + Some(material) => { + entity_mut.insert(material.clone()); + } + None => { + entity_mut.remove::(); + } + } + } + crate::assets::prefab_overrides::apply_stored_overrides(world, entity); +} + fn apply_material_override( world: &mut World, entity: Entity, @@ -1180,6 +1802,49 @@ fn apply_light(world: &mut World, entity: Entity, light: &Option) { } } +fn apply_animation_controller( + world: &mut World, + entity: Entity, + controller: &Option, +) { + if let Ok(mut entity_mut) = world.get_entity_mut(entity) { + match controller { + Some(value) => { + entity_mut.insert(value.clone()); + } + None => { + entity_mut.remove::(); + } + } + } +} + +fn apply_audio_source(world: &mut World, entity: Entity, source: &Option) { + if let Ok(mut entity_mut) = world.get_entity_mut(entity) { + match source { + Some(value) => { + entity_mut.insert(value.clone()); + } + None => { + entity_mut.remove::(); + } + } + } +} + +fn apply_audio_listener(world: &mut World, entity: Entity, listener: &Option) { + if let Ok(mut entity_mut) = world.get_entity_mut(entity) { + match listener { + Some(value) => { + entity_mut.insert(*value); + } + None => { + entity_mut.remove::(); + } + } + } +} + fn apply_post_process_volume( world: &mut World, entity: Entity, @@ -1339,6 +2004,15 @@ fn apply_authored_components(world: &mut World, entity: Entity, snapshot: &Edito if let Some(v) = &snapshot.light { entity_mut.insert(v.clone()); } + if let Some(v) = &snapshot.animation_controller { + entity_mut.insert(v.clone()); + } + if let Some(v) = &snapshot.audio_source { + entity_mut.insert(v.clone()); + } + if let Some(v) = snapshot.audio_listener { + entity_mut.insert(v); + } if snapshot.player_spawn { entity_mut.insert(PlayerSpawn); } @@ -1370,6 +2044,13 @@ fn apply_authored_components(world: &mut World, entity: Entity, snapshot: &Edito } fn remove_authored_components(world: &mut World, entity: Entity, snapshot: &EditorEntitySnapshot) { + let removed_dedicated_audio_kind = matches!( + world.get::(entity).copied(), + Some(ActorKind::AudioSource) if snapshot.audio_source.is_some() + ) || matches!( + world.get::(entity).copied(), + Some(ActorKind::AudioListener) if snapshot.audio_listener.is_some() + ); if let Ok(mut entity_mut) = world.get_entity_mut(entity) { if snapshot.primitive.is_some() { entity_mut.remove::(); @@ -1398,6 +2079,15 @@ fn remove_authored_components(world: &mut World, entity: Entity, snapshot: &Edit if snapshot.light.is_some() { entity_mut.remove::(); } + if snapshot.animation_controller.is_some() { + entity_mut.remove::(); + } + if snapshot.audio_source.is_some() { + entity_mut.remove::(); + } + if snapshot.audio_listener.is_some() { + entity_mut.remove::(); + } if snapshot.player_spawn { entity_mut.remove::(); } @@ -1426,6 +2116,16 @@ fn remove_authored_components(world: &mut World, entity: Entity, snapshot: &Edit entity_mut.remove::(); } } + 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); + } + } } fn handle_history_hotkeys(world: &mut World) { @@ -1461,7 +2161,7 @@ fn handle_history_hotkeys(world: &mut World) { ui_state .selected_entities .iter() - .filter(|entity| is_level_object(world, *entity)) + .filter(|entity| is_mutable_level_object(world, *entity)) .collect() }) .unwrap_or_default(); @@ -1478,7 +2178,7 @@ fn capture_gizmo_transform_edits(world: &mut World) { .map(|ui| { ui.selected_entities .iter() - .filter(|entity| is_level_object(world, *entity)) + .filter(|entity| is_mutable_level_object(world, *entity)) .collect() }) .unwrap_or_default(); @@ -1676,3 +2376,711 @@ pub(crate) fn material_eq(a: &MaterialDesc, b: &MaterialDesc) -> bool { && a.parameters == b.parameters && a.textures == b.textures } + +#[cfg(test)] +mod tests { + use super::*; + use crate::operators::test_harness::{assert_undo_redo_round_trip, OperatorInvariantHarness}; + use crate::operators::{ActiveOperator, OperatorPhase, OperatorStatus}; + + fn animation_controller(state_id: &str, crossfade_seconds: f32) -> AnimationControllerDesc { + AnimationControllerDesc { + skeleton: Some( + shared::EditorAssetRef::new("robot-asset", "animation:skeleton:0:robot", "Robot") + .with_source_path("assets/models/robot.glb"), + ), + states: vec![shared::AnimationStateDesc { + id: state_id.to_string(), + label: state_id.to_ascii_uppercase(), + clip: shared::EditorAssetRef::new( + "robot-asset", + format!("animation:clip:0:{state_id}"), + state_id, + ) + .with_source_path("assets/models/robot.glb"), + looping: true, + speed: 1.25, + range: shared::AnimationPlaybackRange { + start_seconds: 0.1, + end_seconds: Some(1.8), + }, + }], + default_state: state_id.to_string(), + default_crossfade_seconds: crossfade_seconds, + } + } + + #[test] + fn animation_controller_history_round_trips_and_marks_scene_dirty() { + let mut world = World::new(); + world.init_resource::(); + world.init_resource::(); + let old = animation_controller("idle", 0.15); + let new = animation_controller("run", 0.3); + let entity = world + .spawn(( + LevelObject, + ActorKind::ImportedModel, + Transform::IDENTITY, + old.clone(), + )) + .id(); + + set_animation_controller_with_history(&mut world, entity, new.clone()); + + assert_eq!(world.get::(entity), Some(&new)); + assert_eq!(world.resource::().undo_depth(), 1); + assert!(world.resource::().dirty); + + apply_command_undo(&mut world); + assert_eq!(world.get::(entity), Some(&old)); + + apply_command_redo(&mut world); + assert_eq!(world.get::(entity), Some(&new)); + } + + #[test] + fn undoing_a_new_animation_controller_removes_the_component() { + let mut world = World::new(); + world.init_resource::(); + world.init_resource::(); + let controller = animation_controller("idle", 0.2); + let entity = world + .spawn((LevelObject, ActorKind::ImportedModel, Transform::IDENTITY)) + .id(); + + set_animation_controller_with_history(&mut world, entity, controller.clone()); + apply_command_undo(&mut world); + assert!(world.get::(entity).is_none()); + + apply_command_redo(&mut world); + assert_eq!( + world.get::(entity), + Some(&controller) + ); + } + + #[test] + fn animation_controller_survives_snapshot_respawn() { + let mut world = World::new(); + let controller = animation_controller("idle", 0.2); + let source = world + .spawn(( + LevelObject, + ActorKind::ImportedModel, + Name::new("Animated Robot"), + Transform::from_xyz(1.0, 2.0, 3.0), + controller.clone(), + )) + .id(); + let snapshot = snapshot_entity(&world, source).expect("level object is snapshotable"); + + world.entity_mut(source).despawn(); + let respawned = spawn_snapshot(&mut world, &snapshot); + + assert_eq!( + world.get::(respawned), + Some(&controller) + ); + assert_eq!( + world.get::(respawned).map(Name::as_str), + Some("Animated Robot") + ); + } + + #[test] + fn removing_dedicated_audio_components_repairs_and_restores_actor_kind() { + let mut world = World::new(); + let source = world + .spawn(( + LevelObject, + ActorKind::AudioSource, + Transform::IDENTITY, + AudioSourceDesc::default(), + )) + .id(); + let source_snapshot = snapshot_entity(&world, source).unwrap(); + + remove_authored_components(&mut world, source, &source_snapshot); + assert!(world.get::(source).is_none()); + assert_eq!(world.get::(source), Some(&ActorKind::Empty)); + + apply_authored_components(&mut world, source, &source_snapshot); + assert!(world.get::(source).is_some()); + assert_eq!( + world.get::(source), + Some(&ActorKind::AudioSource) + ); + + let listener = world + .spawn(( + LevelObject, + ActorKind::AudioListener, + Transform::IDENTITY, + AudioListenerDesc::default(), + )) + .id(); + let listener_snapshot = snapshot_entity(&world, listener).unwrap(); + + remove_authored_components(&mut world, listener, &listener_snapshot); + assert!(world.get::(listener).is_none()); + assert_eq!(world.get::(listener), Some(&ActorKind::Empty)); + + apply_authored_components(&mut world, listener, &listener_snapshot); + assert!(world.get::(listener).is_some()); + assert_eq!( + world.get::(listener), + Some(&ActorKind::AudioListener) + ); + } + + #[test] + fn continuous_multi_transform_finalizes_as_one_undo_group() { + let mut world = World::new(); + world.init_resource::(); + world.init_resource::(); + world.init_resource::(); + let first = world.spawn((LevelObject, Transform::default())).id(); + let second = world + .spawn(( + LevelObject, + Transform::from_translation(Vec3::new(2.0, 0.0, 0.0)), + )) + .id(); + let initial = vec![Vec3::ZERO, Vec3::new(2.0, 0.0, 0.0)]; + let committed = vec![Vec3::Z, Vec3::new(2.0, 0.0, 1.0)]; + let harness = OperatorInvariantHarness::capture(&mut world); + + set_transform_group_with_history( + &mut world, + [ + ( + first, + Transform::default(), + Transform::from_translation(committed[0]), + ), + ( + second, + Transform::from_translation(initial[1]), + Transform::from_translation(committed[1]), + ), + ], + ); + world.resource_mut::().status = Some(OperatorStatus { + id: "transform.gizmo".to_string(), + label: "Transform Gizmo".to_string(), + phase: OperatorPhase::Committed, + hint: "Committed multi-selection drag".to_string(), + warnings: Vec::new(), + }); + + harness.assert_committed(&mut world, 1, 0); + assert_undo_redo_round_trip(&mut world, initial, committed, |world| { + [first, second] + .into_iter() + .map(|entity| world.get::(entity).unwrap().translation) + .collect::>() + }); + } + + #[test] + fn unpack_prefab_is_one_undoable_history_command() { + let mut world = World::new(); + world.init_resource::(); + world.init_resource::(); + let instance = PrefabInstance::new("asset", "assets/prefabs/crate.scn.ron"); + let root = world + .spawn(( + LevelObject, + ActorKind::PrefabAnchor, + instance.clone(), + PrefabRef::new(instance.source_path.clone()), + HydratedPrefabReady, + )) + .id(); + let generated = world + .spawn(( + LevelObject, + ActorId::new("generated-child"), + ChildOf(root), + HydratedPrefabMember { + instance_root: root, + }, + )) + .id(); + + unpack_prefab_instance(&mut world, root); + + assert_eq!(world.resource::().undo_depth(), 1); + assert!(world.get::(root).is_none()); + assert!(world.get::(root).is_none()); + assert_eq!(world.get::(root), Some(&ActorKind::Empty)); + assert!(world.get::(generated).is_none()); + let unpacked_actor_id = world.get::(generated).unwrap().0.clone(); + assert_ne!(unpacked_actor_id, "generated-child"); + + apply_command_undo(&mut world); + assert_eq!(world.get::(root), Some(&instance)); + assert!(world.get::(root).is_some()); + assert_eq!(world.get::(root), Some(&ActorKind::PrefabAnchor)); + assert!(world.get_entity(generated).is_err()); + + world + .entity_mut(root) + .insert(PrefabHydrationBlocked { + reason: "source removed after undo".to_string(), + }) + .remove::(); + apply_command_redo(&mut world); + assert!(world.get::(root).is_none()); + assert!(world.get::(root).is_none()); + assert!(world.get::(root).is_none()); + assert_eq!(world.get::(root), Some(&ActorKind::Empty)); + assert!(!world.resource::().can_redo()); + let restored = world + .query::<(Entity, &ActorId)>() + .iter(&world) + .find_map(|(entity, actor_id)| (actor_id.0 == unpacked_actor_id).then_some(entity)) + .expect("redo should restore the committed local actor without source readiness"); + assert_eq!( + world.get::(restored).map(ChildOf::parent), + Some(root) + ); + assert_eq!(world.get::(restored).unwrap().0, unpacked_actor_id); + } + + #[test] + fn unpack_redo_uses_committed_snapshot_instead_of_mutated_source() { + let mut world = World::new(); + world.init_resource::(); + world.init_resource::(); + let instance = PrefabInstance::new("asset", "assets/prefabs/crate.scn.ron"); + let root = world + .spawn(( + LevelObject, + ActorKind::PrefabAnchor, + instance.clone(), + PrefabRef::new(instance.source_path.clone()), + HydratedPrefabReady, + )) + .id(); + let generated = world + .spawn(( + LevelObject, + ActorKind::StaticMesh, + ActorId::new("source-child"), + Name::new("Committed Source Actor"), + Transform::from_xyz(1.0, 2.0, 3.0), + ChildOf(root), + HydratedPrefabMember { + instance_root: root, + }, + )) + .id(); + + unpack_prefab_instance(&mut world, root); + let committed_actor_id = world.get::(generated).unwrap().0.clone(); + apply_command_undo(&mut world); + + let mutated_source = world + .spawn(( + LevelObject, + ActorKind::Light, + ActorId::new("source-child"), + Name::new("Mutated Source Actor"), + Transform::from_xyz(90.0, 80.0, 70.0), + ChildOf(root), + HydratedPrefabMember { + instance_root: root, + }, + )) + .id(); + world.entity_mut(root).insert(HydratedPrefabReady); + + apply_command_redo(&mut world); + + assert!(world.get_entity(mutated_source).is_err()); + let restored = world + .query::<(Entity, &ActorId)>() + .iter(&world) + .find_map(|(entity, actor_id)| (actor_id.0 == committed_actor_id).then_some(entity)) + .expect("redo should recreate the originally committed local actor"); + assert_eq!( + world.get::(restored).map(Name::as_str), + Some("Committed Source Actor") + ); + assert_eq!( + world.get::(restored).unwrap().translation, + Vec3::new(1.0, 2.0, 3.0) + ); + assert_eq!( + world.get::(restored), + Some(&ActorKind::StaticMesh) + ); + assert!(world.get::(restored).is_none()); + } + + #[test] + fn unpack_redo_preserves_structurally_unparented_roots() { + let mut world = World::new(); + world.init_resource::(); + world.init_resource::(); + let root = world + .spawn(( + LevelObject, + ActorKind::PrefabAnchor, + PrefabInstance::new("asset", "assets/prefabs/test.scn.ron"), + PrefabRef::new("assets/prefabs/test.scn.ron"), + HydratedPrefabReady, + )) + .id(); + let generated = world + .spawn(( + LevelObject, + ActorId::new("unparented-source-child"), + HydratedPrefabMember { + instance_root: root, + }, + )) + .id(); + + unpack_prefab_instance(&mut world, root); + let local_actor_id = world.get::(generated).unwrap().0.clone(); + assert!(world.get::(generated).is_none()); + + apply_command_undo(&mut world); + apply_command_redo(&mut world); + + let restored = world + .query::<(Entity, &ActorId)>() + .iter(&world) + .find_map(|(entity, actor_id)| (actor_id.0 == local_actor_id).then_some(entity)) + .expect("redo should restore the committed unparented root"); + assert!(world.get::(restored).is_none()); + } + + #[test] + fn unpack_layer_preserves_nested_prefab_links() { + let mut world = World::new(); + world.init_resource::(); + world.init_resource::(); + let base_material = MaterialDesc::default(); + let mut overridden_material = base_material.clone(); + overridden_material.roughness = 0.21; + let mut outer_instance = PrefabInstance::new("outer", "assets/prefabs/outer.scn.ron"); + outer_instance.overrides_ron = shared::encode_prefab_overrides(&shared::PrefabOverrides { + properties: vec![shared::PrefabPropertyOverride { + target: shared::PrefabActorPath { + instance_chain: vec!["nested-anchor".to_string()], + actor_id: "nested-child".to_string(), + }, + component_type: std::any::type_name::().to_string(), + property_path: "roughness".to_string(), + base_component_ron: ron::to_string(&base_material).unwrap(), + value_component_ron: ron::to_string(&overridden_material).unwrap(), + }], + ..default() + }) + .unwrap(); + let root = world + .spawn(( + LevelObject, + ActorKind::PrefabAnchor, + outer_instance, + PrefabRef::new("assets/prefabs/outer.scn.ron"), + HydratedPrefabReady, + )) + .id(); + let nested = world + .spawn(( + LevelObject, + ActorKind::PrefabAnchor, + ActorId::new("nested-anchor"), + PrefabInstance::new("nested", "assets/prefabs/nested.scn.ron"), + PrefabRef::new("assets/prefabs/nested.scn.ron"), + HydratedPrefabReady, + ChildOf(root), + HydratedPrefabMember { + instance_root: root, + }, + )) + .id(); + let nested_child = world + .spawn(( + LevelObject, + ActorId::new("nested-child"), + overridden_material, + ChildOf(nested), + HydratedPrefabMember { + instance_root: nested, + }, + )) + .id(); + + unpack_prefab_instance(&mut world, root); + + assert!(world.get::(root).is_none()); + assert!(world.get::(nested).is_some()); + assert!(world.get::(nested).is_none()); + assert_eq!( + world + .get::(nested_child) + .map(|owner| owner.instance_root), + Some(nested) + ); + let nested_overrides = + shared::decode_prefab_overrides(world.get::(nested).unwrap()).unwrap(); + assert_eq!(nested_overrides.properties.len(), 1); + assert!(nested_overrides.properties[0] + .target + .instance_chain + .is_empty()); + assert_eq!( + ron::from_str::(&nested_overrides.properties[0].value_component_ron) + .unwrap() + .roughness, + 0.21 + ); + } + + #[test] + fn complete_prefab_conversion_is_recursive_unique_and_undoable() { + let mut world = World::new(); + world.init_resource::(); + world.init_resource::(); + let outer_instance = PrefabInstance::new("outer", "assets/prefabs/outer.scn.ron"); + let root = world + .spawn(( + LevelObject, + ActorKind::PrefabAnchor, + outer_instance.clone(), + PrefabRef::new(outer_instance.source_path.clone()), + HydratedPrefabReady, + )) + .id(); + world.spawn(( + LevelObject, + ActorId::new("repeated-source-id"), + Name::new("Outer Child"), + ChildOf(root), + HydratedPrefabMember { + instance_root: root, + }, + )); + let nested = world + .spawn(( + LevelObject, + ActorKind::PrefabAnchor, + ActorId::new("nested-anchor"), + Name::new("Nested Anchor"), + PrefabInstance::new("nested", "assets/prefabs/nested.scn.ron"), + PrefabRef::new("assets/prefabs/nested.scn.ron"), + HydratedPrefabReady, + ChildOf(root), + HydratedPrefabMember { + instance_root: root, + }, + )) + .id(); + world.spawn(( + LevelObject, + ActorId::new("repeated-source-id"), + Name::new("Nested Child"), + ChildOf(nested), + HydratedPrefabMember { + instance_root: nested, + }, + )); + let local_nested = world + .spawn(( + LevelObject, + ActorKind::PrefabAnchor, + ActorId::new("authored-local-anchor"), + Name::new("Authored Local Nested"), + PrefabInstance::new("local-nested", "assets/prefabs/local.scn.ron"), + PrefabRef::new("assets/prefabs/local.scn.ron"), + HydratedPrefabReady, + ChildOf(root), + )) + .id(); + world.spawn(( + LevelObject, + ActorId::new("repeated-source-id"), + Name::new("Authored Local Child"), + ChildOf(local_nested), + HydratedPrefabMember { + instance_root: local_nested, + }, + )); + + convert_prefab_completely_to_local(&mut world, root); + + let mut prefab_instances = world.query::<&PrefabInstance>(); + assert_eq!(prefab_instances.iter(&world).count(), 0); + let mut generated_members = world.query::<&HydratedPrefabMember>(); + assert_eq!(generated_members.iter(&world).count(), 0); + let ids: Vec = world + .query_filtered::<&ActorId, With>() + .iter(&world) + .map(|actor_id| actor_id.0.clone()) + .collect(); + let unique: HashSet<_> = ids.iter().collect(); + assert_eq!(ids.len(), unique.len()); + assert_eq!(world.resource::().undo_depth(), 1); + + apply_command_undo(&mut world); + assert_eq!(world.get::(root), Some(&outer_instance)); + let mut prefab_instances = world.query::<(Entity, &PrefabInstance)>(); + let restored_instances: Vec<_> = prefab_instances.iter(&world).collect(); + assert_eq!(restored_instances.len(), 2); + let restored_local = world + .query::<(Entity, &Name, &PrefabInstance)>() + .iter(&world) + .find_map(|(entity, name, _)| { + (name.as_str() == "Authored Local Nested").then_some(entity) + }) + .expect("undo should restore authored local nested links"); + assert_eq!( + world.get::(restored_local).map(ChildOf::parent), + Some(root) + ); + + apply_command_redo(&mut world); + assert!(world.get::(root).is_none()); + let mut prefab_instances = world.query::<&PrefabInstance>(); + assert_eq!(prefab_instances.iter(&world).count(), 0); + let ids: Vec = world + .query_filtered::<&ActorId, With>() + .iter(&world) + .map(|actor_id| actor_id.0.clone()) + .collect(); + let unique: HashSet<_> = ids.iter().collect(); + assert_eq!(ids.len(), unique.len()); + } + + #[test] + fn unpack_waits_for_source_readiness() { + let mut world = World::new(); + world.init_resource::(); + world.init_resource::(); + let root = world + .spawn(( + LevelObject, + PrefabInstance::new("asset", "assets/prefabs/missing.scn.ron"), + PrefabRef::new("assets/prefabs/missing.scn.ron"), + )) + .id(); + + unpack_prefab_instance(&mut world, root); + + assert!(world.get::(root).is_some()); + assert_eq!(world.resource::().undo_depth(), 0); + assert!(world.resource::().status.contains("still loading")); + } + + #[test] + fn duplicate_remaps_actor_ids_and_excludes_generated_members() { + let mut world = World::new(); + world.init_resource::(); + world.init_resource::(); + world.init_resource::(); + let root = world + .spawn(( + LevelObject, + ActorId::new("root"), + PrefabInstance::new("asset", "assets/prefabs/test.scn.ron"), + )) + .id(); + world.spawn(( + LevelObject, + ActorId::new("generated"), + ChildOf(root), + HydratedPrefabMember { + instance_root: root, + }, + )); + world.spawn((LevelObject, ActorId::new("local"), ChildOf(root))); + + duplicate_entities_with_history(&mut world, &[root]); + + let ids: Vec = world + .query_filtered::<&ActorId, With>() + .iter(&world) + .map(|id| id.0.clone()) + .collect(); + let unique: HashSet<_> = ids.iter().collect(); + assert_eq!(ids.len(), 5, "generated source content must not be copied"); + assert_eq!(unique.len(), ids.len(), "duplicated actors need fresh IDs"); + } + + #[test] + fn linked_members_reject_direct_history_mutations() { + let mut world = World::new(); + world.init_resource::(); + world.init_resource::(); + world.init_resource::(); + let root = world.spawn(LevelObject).id(); + let member = world + .spawn(( + LevelObject, + Name::new("Source Actor"), + Transform::default(), + HydratedPrefabMember { + instance_root: root, + }, + )) + .id(); + + rename_entity_with_history(&mut world, member, "Mutated".to_string()); + set_transform_with_history( + &mut world, + member, + Transform::default(), + Transform::from_xyz(9.0, 0.0, 0.0), + ); + set_editor_visibility_with_history(&mut world, member, EditorVisibility { visible: false }); + delete_entities_with_history(&mut world, &[member]); + + assert_eq!(world.get::(member).unwrap().as_str(), "Source Actor"); + assert_eq!( + world.get::(member).unwrap(), + &Transform::default() + ); + assert!(world.get_entity(member).is_ok()); + assert_eq!(world.resource::().undo_depth(), 0); + } + + #[test] + fn undoing_prefab_relink_restores_broken_hydration_guard() { + let mut world = World::new(); + world.init_resource::(); + world.init_resource::(); + let broken = PrefabInstance::new("broken", "assets/prefabs/missing.scn.ron"); + let root = world + .spawn(( + LevelObject, + broken.clone(), + PrefabHydrationBlocked { + reason: "missing prefab source".to_string(), + }, + )) + .id(); + let old = capture_prefab_edit_state(&world, root); + let valid = PrefabInstance::new("valid", "assets/prefabs/valid.scn.ron"); + world + .entity_mut(root) + .remove::() + .insert((valid.clone(), PrefabRef::new(valid.source_path.clone()))); + record_prefab_edit_state(&mut world, root, old); + + apply_command_undo(&mut world); + + assert_eq!(world.get::(root), Some(&broken)); + assert!(world.get::(root).is_none()); + assert!(world + .get::(root) + .is_some_and(|blocked| blocked.reason == "missing prefab source")); + assert!(world.get::(root).is_none()); + assert!(world.get::(root).is_none()); + } +} diff --git a/crates/editor/src/lib.rs b/crates/editor/src/lib.rs index b0763da..3470647 100644 --- a/crates/editor/src/lib.rs +++ b/crates/editor/src/lib.rs @@ -22,7 +22,10 @@ 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; @@ -43,7 +46,8 @@ use bevy::app::PluginGroupBuilder; use bevy::prelude::*; use bevy_egui::EguiPlugin; use game::{ - launch, GameInputEnabled, GameInputFocused, GamePlugin, GameRenderBootstrap, SimEnabled, + launch, GameInputEnabled, GameInputFocused, GamePlugin, GameRenderBootstrap, + GameSceneBootstrap, SimEnabled, }; use transform_gizmo_bevy::prelude::TransformGizmoPlugin; @@ -58,6 +62,7 @@ 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; @@ -65,6 +70,7 @@ 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; @@ -97,6 +103,7 @@ impl PluginGroup for EditorPluginGroup { .add(BrushEditPlugin) .add(BrushToolPlugin) .add(EditorCameraPlugin) + .add(AudioPreviewPlugin) .add(ActorIconsPlugin) .add(EditorSelectionPlugin) .add(SelectionOutlinePlugin) @@ -105,6 +112,7 @@ 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); @@ -121,6 +129,9 @@ 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) diff --git a/crates/editor/src/operators.rs b/crates/editor/src/operators.rs index 4f033cb..c5ed841 100644 --- a/crates/editor/src/operators.rs +++ b/crates/editor/src/operators.rs @@ -4,6 +4,9 @@ use bevy::prelude::*; use crate::history::EditorHistory; +#[cfg(test)] +pub(crate) mod test_harness; + #[derive(Debug, Clone, PartialEq, Eq)] pub enum OperatorPhase { Idle, @@ -227,12 +230,19 @@ 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, } impl EditorOperator for CounterOperator { @@ -246,6 +256,7 @@ mod tests { fn begin(&mut self, world: &mut World) -> Result<(), String> { self.before = world.resource::().0; + self.helper = Some(world.spawn(PreviewHelper).id()); Ok(()) } @@ -254,34 +265,50 @@ mod tests { Ok(()) } - fn commit(&mut self, _world: &mut World) -> Result<(), String> { + fn commit(&mut self, world: &mut World) -> Result<(), String> { + self.remove_helper(world); Ok(()) } fn cancel(&mut self, world: &mut World) { world.resource_mut::().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::(); + world.init_resource::(); + world.init_resource::(); + world.init_resource::(); + world + } + #[test] fn operator_commit_keeps_previewed_change() { - let mut world = World::new(); - world.init_resource::(); - world.init_resource::(); + let mut world = test_world(); + let harness = OperatorInvariantHarness::capture(&mut world); - let mut operator = CounterOperator { before: 0 }; + let mut operator = CounterOperator { + before: 0, + helper: None, + }; assert!(run_operator(&mut world, &mut operator)); assert_eq!(world.resource::().0, 1); - assert_eq!( - world - .resource::() - .status - .as_ref() - .unwrap() - .phase, - OperatorPhase::Committed - ); + harness.assert_committed(&mut world, 0, 0); + harness.assert_no_helpers::(&mut world); } #[test] @@ -314,22 +341,38 @@ mod tests { } } - let mut world = World::new(); - world.init_resource::(); - world.init_resource::(); + let mut world = test_world(); + let harness = OperatorInvariantHarness::capture(&mut world); - let mut operator = FailingCounterOperator(CounterOperator { before: 0 }); + let mut operator = FailingCounterOperator(CounterOperator { + before: 0, + helper: None, + }); assert!(!run_operator(&mut world, &mut operator)); assert_eq!(world.resource::().0, 0); - assert_eq!( - world - .resource::() - .status - .as_ref() - .unwrap() - .phase, - OperatorPhase::Canceled + harness.assert_canceled(&mut world); + harness.assert_no_helpers::(&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::().0 += 1; + Ok(()) + }, ); + + assert!(!result); + assert_eq!(world.resource::().0, 0); + harness.assert_blocked(&mut world); } } diff --git a/crates/editor/src/operators/test_harness.rs b/crates/editor/src/operators/test_harness.rs new file mode 100644 index 0000000..ec63567 --- /dev/null +++ b/crates/editor/src/operators/test_harness.rs @@ -0,0 +1,136 @@ +//! 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::() + .map(EditorHistory::undo_depth) + .unwrap_or_default(), + dirty: world + .get_resource::() + .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::().undo_depth(), + self.undo_depth + undo_delta, + "committed operator must create the expected number of undo groups" + ); + if undo_delta > 0 { + assert!( + world.resource::().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::().undo_depth(), + self.undo_depth, + "canceled operator must not create undo history" + ); + assert_eq!( + world.resource::().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::().undo_depth(), + self.undo_depth + ); + assert_eq!(world.resource::().dirty, self.dirty); + assert_eq!(authored_count(world), self.authored_count); + } + + pub(crate) fn assert_no_helpers(&self, world: &mut World) { + let mut query = world.query_filtered::>(); + 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::() + .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( + 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::>(); + query.iter(world).count() +} diff --git a/crates/editor/src/play/audio_preview.rs b/crates/editor/src/play/audio_preview.rs new file mode 100644 index 0000000..593ac10 --- /dev/null +++ b/crates/editor/src/play/audio_preview.rs @@ -0,0 +1,468 @@ +//! 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, + pub source: Option, + pub clip_path: Option, + pub status: String, + clip_handle: Option>, + listener: Option, +} + +#[derive(Component, Debug, Clone)] +struct AudioPreviewVoice { + source: Option, + 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::() + .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::(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::>().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), With>( + ); + 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::().clone(); + let handle: Handle = 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 { + 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) { + let voices = std::mem::take(&mut world.resource_mut::().voices); + for voice in voices { + if let Some(sink) = world.get::(voice) { + sink.stop(); + } + if let Some(sink) = world.get::(voice) { + sink.stop(); + } + if let Ok(entity_mut) = world.get_entity_mut(voice) { + entity_mut.despawn(); + } + } + let mut query = world.query_filtered::>(); + let listeners: Vec = 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::().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::().clone(); + if state.voices.is_empty() { + return; + } + if *world.resource::>().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::(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::(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::().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::() { + scene_io.set_status(status); + } + return; + } + + let listener_position = state + .listener + .and_then(|listener| world.get::(listener)) + .map(GlobalTransform::translation); + let project_audio = world.resource::().audio.clone(); + let bus_runtime = world.resource::().clone(); + let mut finished = 0usize; + for voice_entity in &state.voices { + let Some(voice) = world.get::(*voice_entity).cloned() else { + finished += 1; + continue; + }; + let source_position = voice + .source + .and_then(|source| world.get::(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::(*voice_entity) { + sink.set_volume(Volume::Linear(gain)); + if sink.empty() { + finished += 1; + } + } else if let Some(mut sink) = world.get_mut::(*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::() + .init_state::() + .add_plugins(AudioPreviewPlugin) + .init_resource::() + .init_resource::() + .init_resource::() + .insert_resource(settings::ProjectSettings::default()); + app + } + + fn spawn_camera(app: &mut App, listener: Option) -> 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::() + .voices + .is_empty()); + assert!(!app.world().resource::().dirty); + assert_eq!( + app.world() + .resource::() + .undo_depth(), + 0 + ); + + stop_audio_preview(app.world_mut()); + + assert!(app + .world() + .resource::() + .voices + .is_empty()); + assert!(!app.world().resource::().dirty); + assert_eq!( + app.world() + .resource::() + .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::(camera).unwrap().clone(); + + audition_audio_source(app.world_mut(), source).unwrap(); + + let actual = app.world().get::(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::().listener, + Some(camera) + ); + let mut preview_listeners = app + .world_mut() + .query_filtered::>(); + assert_eq!(preview_listeners.iter(app.world()).count(), 0); + + stop_audio_preview(app.world_mut()); + + let actual = app.world().get::(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::(); + update_audio_preview(app.world_mut()); + + let state = app.world().resource::(); + 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::>() + .set(EditorMode::Playing); + app.update(); + + let state = app.world().resource::(); + assert!(state.voices.is_empty()); + assert!(state.source.is_none()); + let mut preview_listeners = app + .world_mut() + .query_filtered::>(); + assert_eq!(preview_listeners.iter(app.world()).count(), 0); + } +} diff --git a/crates/editor/src/play/mod.rs b/crates/editor/src/play/mod.rs index 517f11c..13759b1 100644 --- a/crates/editor/src/play/mod.rs +++ b/crates/editor/src/play/mod.rs @@ -1,5 +1,6 @@ //! Play-in-editor session, editor mode state, and net editor profiles. +pub mod audio_preview; pub mod net_editor; mod session; pub mod state; diff --git a/crates/editor/src/play/state.rs b/crates/editor/src/play/state.rs index d5262d5..aaf9161 100644 --- a/crates/editor/src/play/state.rs +++ b/crates/editor/src/play/state.rs @@ -7,7 +7,6 @@ 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)] @@ -252,6 +251,8 @@ 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::().player = Some(snapshot); } @@ -260,9 +261,9 @@ fn exit_play_mode(world: &mut World) { if let Some(snapshot) = world.resource_mut::().player.take() { restore_player_sim(world, &snapshot); } - if let Some(mut scene_io) = world.get_resource_mut::() { - scene_io.dirty = true; - } + world.resource_mut::().0 = false; + game::animation::reset_authored_animation_runtime(world); + game::audio::reset_authored_audio_runtime(world); } fn snapshot_player_sim(world: &mut World) -> PlayerSimSnapshot { diff --git a/crates/editor/src/project/diagnostics_bundle.rs b/crates/editor/src/project/diagnostics_bundle.rs new file mode 100644 index 0000000..de64632 --- /dev/null +++ b/crates/editor/src/project/diagnostics_bundle.rs @@ -0,0 +1,278 @@ +//! 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, + 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, + pub project: DiagnosticProjectInfo, + pub validation: DiagnosticValidationSummary, + pub prior_abnormal_shutdown: bool, + pub scene_io_events: Vec, +} + +/// Writes an allowlisted support report without scene data, environment values, or credentials. +pub fn export_diagnostic_bundle(world: &mut World) -> Result { + 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::() + .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::(); + let workspace = world.resource::(); + 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::() + .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::>(); + let level_object_count = level_query.iter(world).count(); + let mut brush_query = world.query_filtered::<&BrushDesc, With>(); + 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 { + 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')); + } +} diff --git a/crates/editor/src/project/launcher.rs b/crates/editor/src/project/launcher.rs new file mode 100644 index 0000000..65a6bcb --- /dev/null +++ b/crates/editor/src/project/launcher.rs @@ -0,0 +1,847 @@ +//! 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, + pub default_level: PathBuf, + pub last_scene: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct StartupArguments { + pub project: Option, + pub help: bool, +} + +#[derive(Resource)] +struct ProjectLauncherUi { + recents: Vec, + sandbox_name: String, + sandbox_destination: Option, + status: Option, +} + +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::() + .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>() + .single(world) + else { + return; + }; + let mut context = context.clone(); + world.resource_scope(|world, mut state: Mut| { + 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 { + 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, +) -> Result { + 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 ]\n\nOPTIONS:\n --project Open a validated Blacksite project before engine startup\n -h, --help Print this help" +} + +pub fn activate_project_root(root: &Path) -> Result { + 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 { + 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 { + 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 { + 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, field: &str) -> Result { + 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(); + } +} diff --git a/crates/editor/src/project/mod.rs b/crates/editor/src/project/mod.rs index e1fbc7a..373c2e4 100644 --- a/crates/editor/src/project/mod.rs +++ b/crates/editor/src/project/mod.rs @@ -1,4 +1,7 @@ //! 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; diff --git a/crates/editor/src/project/project_io.rs b/crates/editor/src/project/project_io.rs index c59b7fe..88af911 100644 --- a/crates/editor/src/project/project_io.rs +++ b/crates/editor/src/project/project_io.rs @@ -2,7 +2,7 @@ use bevy::prelude::*; use serde::{Deserialize, Serialize}; -use settings::{ProjectSettings, ProjectSettingsIo, DEFAULT_PROJECT_PATH}; +use settings::{ProjectKind, 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, Default, Serialize, Deserialize, Resource)] +#[derive(Debug, Clone, Serialize, Deserialize, Resource)] pub struct UserPreferences { pub recent_projects: Vec, pub recent_levels: Vec, @@ -31,16 +31,53 @@ 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, pub settings_path: String, pub settings_dirty: bool, pub layout_dirty: bool, @@ -54,6 +91,9 @@ 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, @@ -73,7 +113,13 @@ impl Plugin for ProjectIoPlugin { } fn load_user_preferences(mut commands: Commands) { - let prefs = read_user_preferences(); + 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}"); + } + } commands.insert_resource(prefs); } @@ -85,12 +131,13 @@ pub(crate) fn load_user_preferences_on_startup(commands: Commands) { fn sync_workspace_from_settings_system( settings: Res, settings_io: Res, + scene_io: Res, mut workspace: ResMut, ) { - if !settings_io.is_changed() && !settings.is_changed() { + if !settings_io.is_changed() && !settings.is_changed() && !scene_io.is_changed() { return; } - sync_workspace_from_settings(&mut workspace, &settings, &settings_io); + sync_workspace_from_settings(&mut workspace, &settings, &settings_io, &scene_io); } fn user_prefs_path() -> Option { @@ -127,14 +174,26 @@ pub fn push_recent(list: &mut Vec, 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( @@ -160,3 +219,21 @@ 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"]); + } +} diff --git a/crates/editor/src/project/session.rs b/crates/editor/src/project/session.rs new file mode 100644 index 0000000..d3dd400 --- /dev/null +++ b/crates/editor/src/project/session.rs @@ -0,0 +1,419 @@ +//! 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, + #[serde(default)] + pub dock_layout: Option, + #[serde(default)] + pub hierarchy_expanded_paths: Vec, + #[serde(default)] + pub panels: SessionPanelState, + #[serde(default)] + pub camera_bookmarks: Vec, +} + +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, + 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::() + .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::(); + (runtime.resume_prompt_open, runtime.resume_scene.clone()) + }; + if !open { + return; + } + let Some(scene) = scene else { + world + .resource_mut::() + .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::().request = + Some(SceneIoRequest::OpenPath(scene.clone())); + world + .resource_mut::() + .resume_prompt_open = false; + } + if ui.button("Continue Safe").clicked() { + world + .resource_mut::() + .resume_prompt_open = false; + world + .resource_mut::() + .set_status("Continued with safe startup after abnormal shutdown"); + } + }); + }); +} + +fn session_path() -> Option { + default_state_root().map(|root| root.join(SESSION_FILE)) +} + +fn read_session(path: &Path) -> Result { + 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 { + 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::().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::().poses = poses; + world.resource_mut::().open = document.panels.diagnostics; + world.resource_mut::().open = document.panels.brush_diagnostics; + world.resource_mut::().open = document.panels.rendering; + world.resource_mut::().open = document.panels.project_settings; +} + +fn capture_session(world: &World, clean_shutdown: bool) -> EditorSessionDocument { + let prefs = world.resource::(); + EditorSessionDocument { + schema_version: SESSION_SCHEMA_VERSION, + clean_shutdown, + project_root: world.resource::().root.clone(), + active_scene: world + .resource::() + .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::().open, + brush_diagnostics: world.resource::().open, + rendering: world.resource::().open, + project_settings: world.resource::().open, + }, + camera_bookmarks: world + .resource::() + .poses + .iter() + .map(|(scene, transform)| { + CameraBookmarkRecord::from_transform(scene.clone(), *transform) + }) + .collect(), + } +} + +fn persist_running_session(world: &mut World) { + let delta = world.resource::