Compare commits

..

5 Commits

Author SHA1 Message Date
3931de1f0a Fix hot reload feature matrix
Some checks are pending
CI / Format, lint, test, build (push) Waiting to run
2026-07-12 11:35:57 -04:00
f2ad88fd03 Define production readiness acceptance gate 2026-07-12 11:29:17 -04:00
5a82a9ee29 Add collaborative authored-file safety 2026-07-12 11:27:57 -04:00
b4aa61e394 Ship production navigation authoring workflow 2026-07-12 02:53:43 -04:00
0798aa5d57 Build renderer and material component foundations
Add dedicated skinned rendering, pose restoration, shared Material and Material Instance slots, registry-driven components, Surface/Solari integration, transactional schema upgrades, navigation authoring, documentation, and evaluation evidence.
2026-07-12 00:24:06 -04:00
196 changed files with 33476 additions and 3542 deletions

View File

@ -7,4 +7,6 @@ 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 --"
bake-navigation = "run -p xtask --features validate-levels --bin bake-navigation --"
package-project = "run -p xtask --features validate-levels --bin package-project --"
upgrade-project = "run -p xtask --features validate-levels --bin upgrade-project --"

View File

@ -21,9 +21,23 @@ performance, soak, regression, and first-hour workflows must pass against a repr
`#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`.
5. Completed: audio `#47`, animation `#46`, and navigation `#48` passed production acceptance.
Source-control safety `#49` has complete implementation and source/headless acceptance; live
visual acceptance remains before the readiness gate `#50`.
## Implemented Slice - Collaborative Authored-File Safety
- Exact BLAKE3 baselines for scene tabs, staged Material/Material Instance documents, and Project
Settings.
- One guarded atomic publication path for scene save/export, prefab source Apply/history, and
editable materials, including missing-file and immediate pre-rename race protection.
- Non-destructive background Git porcelain scanning with optional off-thread ownership providers;
Git/provider absence is quiet.
- Compact active-scene and selected-asset state plus Reload, metadata comparison, Save As, and
Cancel conflict recovery. No force-overwrite action exists.
- Focused race, read-only, provider-lock, parser, non-repository, modal-render, scene, and material
tests pass. Live debug-editor visual acceptance remains pending for a desktop-capable QA session;
packaged acceptance remains owner-deferred.
## M6 - Reliability, Recovery, And Project Workflow

View File

@ -0,0 +1,84 @@
# Material Library And Targeted Viewport Drop
Working implementation plan for Gitea issues
[`#16`](https://git.spacetrainclubhouse.com/Falling-Metal-Interactive/Blacksite/issues/16) and
[`#18`](https://git.spacetrainclubhouse.com/Falling-Metal-Interactive/Blacksite/issues/18).
The shared Material/Material Instance and stable renderer-slot foundation from `#51` is already
present; this slice completes the production-facing catalog and drag/drop workflow.
## Status
Source audit complete. Implementation starts on a clean branch after collaborative-safety `#49` is
published, because both slices touch the Asset Browser and should remain independently reviewable.
## Current Gaps
- Material assets are edited inside generic Asset Browser Details; no dedicated Material Library or
scene-usage view exists.
- Viewport material/texture drops act on the existing selection instead of the surface under the
pointer.
- Multi-slot renderers silently assign all slots; the hydrated draw hit already carries
`HydratedRendererMaterialBinding`, but the drop path does not use it.
- Primitive and brush-face targeting is not explicit, and unsupported authored actors can receive a
generic `MaterialDesc` instead of an actionable rejection.
- Drag UI describes an eventual action but does not preview the actual affected surface or restore a
preview on cancel/target change.
## Material Library
1. Add a dockable `Material Library` panel, opened from Window and placed in the existing bottom dock
without adding another permanent toolbar.
2. Present project Materials and Material Instances with search, Material/Instance filter, thumbnail
grid/list modes, base/dependency health, and source-control status.
3. Add a scene-usage section that counts renderer-slot, primitive, and brush-face references and can
select/locate users. Do not revive ambiguous hidden scene-local material clones.
4. Reuse the existing guarded Material/Instance drafts for Apply, Revert, Create Instance, and
texture-parameter editing. Shared-file edits remain explicit; scene assignment changes use
history.
5. Material and Instance cells are first-class drag sources and preserve the current pointer-following
visual identity.
## Targeted Drop Contract
Introduce a frame-updated `ViewportAssetDropTarget` resolved by the existing mesh-picking path:
- A hydrated static/skinned draw maps through `HydratedRendererMaterialBinding` to the authored actor
and exact stable slot ID.
- A primitive maps to its authored actor-level `MaterialDesc`.
- A brush performs authored face intersection and identifies the stable face ID.
- An authored but unsupported target remains a visible invalid target with a specific reason.
- Empty space remains placement-only for placeable assets and invalid for Material/Texture payloads.
Payload matrix:
| Payload | Renderer slot | Primitive | Brush face |
|---------|---------------|-----------|------------|
| Material / Material Instance | Assign exact hit slot; one-slot actors may assign directly | Replace actor material reference/values | Replace the hit face material reference |
| Texture | Reject with guidance to create/edit a Material Instance | Set base-color texture | Set hit-face base-color texture/material binding |
An explicit `Apply All Slots` command remains available from the renderer inspector/library usage
menu; a viewport hit never silently broadens from one slot to all slots.
## Preview, Commit, And Cancel
1. Start a persistent drop-preview session when a supported Material/Texture payload enters the
viewport. Snapshot only the target's affected authored state.
2. Apply a transient visual preview without pushing history or marking the scene dirty.
3. When the pointer changes target, restore the previous snapshot before previewing the new target.
4. On release, restore the transient state first, then commit exactly one typed history/operator
transaction to the identified slot, primitive, or face.
5. On Escape, drag cancellation, leaving the viewport, invalid target, or missing source, restore the
exact snapshot and remove every preview/helper marker.
6. The drag card and target outline name the payload, actor, slot/face, action, and invalid reason.
## Verification
- Unit-test ray-hit to authored target/slot mapping, primitive/face targeting, and invalid reasons.
- Use `OperatorInvariantHarness` for preview target changes, release commit, Escape/outside cancel,
dirty-state preservation, helper cleanup, and one-step undo/redo.
- Cover static one/multi-slot, skinned multi-slot, primitive, brush face, Texture rejection on a
renderer slot, missing material, and linked/locked prefab boundaries.
- Verify Material Library filtering, scene-usage counts, dependency diagnostics, and drag sources in
headless egui tests.
- Run full source/headless checks. Packaged testing remains deferred until the project owner requests
it again; live visual acceptance is still required before closing `#16`/`#18`.

View File

@ -0,0 +1,73 @@
# Navigation Mesh Authoring, Bake Diagnostics, And Path Preview
Working implementation plan for Gitea issue
[`#48`](https://git.spacetrainclubhouse.com/Falling-Metal-Interactive/Blacksite/issues/48).
This is the navigation production loop required by the M7 content-production milestone.
## Status
Architecture, dependency compatibility, composed-source resolution, persisted path samples,
corrective source-level QA, and live debug-editor acceptance are complete. Packaged/release
acceptance remains explicitly deferred until the project owner requests another packaged pass.
## Outcome
A level designer can author navigation bounds, obstacles, areas, links, and agent profiles; bake a
deterministic versioned artifact; inspect stale or invalid data; preview a path in the viewport; and
use the same artifact through a game-owned runtime query API and headless release validation.
## Architecture
- Shared reflected components own bounds, obstacles, area volumes, links, and scene bake settings.
- The scene crate owns deterministic composed-source resolution, source fingerprinting, bake
artifact IO, the engine-independent query core, and validation.
- Rerecast performs the 3D walkable-surface bake without coupling navigation to a Bevy plugin
release. Polyanya performs proven any-angle runtime and editor-preview path queries.
- Overlapping primitive and additive-brush triangles enter the deterministic bake source; distant
geometry and navigation authoring are excluded from each bounds fingerprint. No synthetic bounds
floor is added.
- The game crate adapts the shared query core to Bevy types and exposes renderer-free artifact
validation. Editor preview and project validation do not implement separate pathfinders.
- Generated artifacts live under `assets/navigation/generated/`, are project-relative runtime
dependencies, and never serialize viewport helpers into authored scenes.
## Implementation Sequence
1. Add reflected authoring schema, versioned bake artifact, source fingerprint, validation, and
exact agent-profile settings.
2. Add deterministic Rerecast bake input extraction for authored bounds/obstacles and headless bake
command coverage.
3. Add game-owned artifact loading and Polyanya path-query API, including authored off-mesh links.
4. Add typed inspectors, create commands, bake/stale status, viewport overlay, and start/end path
preview.
5. Add owner-attributed project validation for invalid links, missing/stale artifacts, unreachable
preview samples, and bake failures.
6. Commit a small sample scene/artifact and run automated, headless, and live debug-editor
acceptance. PIE and packaged runtime acceptance remain deferred by project-owner direction.
## Acceptance Gates
- A designer can create bounds and obstacles, bake, view the resulting mesh, and preview a valid
start-to-end path.
- Agent radius, height, climb, and slope settings deterministically affect the bake fingerprint.
- Relevant geometry or navigation-authoring changes mark the artifact stale; unrelated scene
metadata does not.
- Invalid or dangling links, samples outside the mesh, isolated regions, and bake failures identify
the owning actor or setting and provide a repair action.
- Runtime path queries and editor preview use the same game-owned API and versioned artifact.
- Helper meshes, lines, endpoints, and bake state are transient and never serialize.
- Headless bake and project validation pass for the committed sample fixture, and package dependency
collection includes the current artifact.
- Visible subscenes and nested prefab contributors resolve identically from editor snapshots and
headless tooling; unsupported structural overrides block with an apply/unpack repair action.
## Deliberate Boundaries
- V1 supports static baked navigation plus explicit links. Dynamic crowd avoidance and runtime tile
carving are separate work.
- Full partial-tile regeneration waits for upstream Rerecast tiling support. V1 records affected
source bounds and debounces a deterministic full bake while reporting the dirty region.
- Area costs are authored and validated in V1; path-cost weighting beyond walkable/blocked areas is
deferred until the runtime exposes per-polygon cost callbacks.
- Imported static meshes remain obstacle-authored in V1 because their normalized manifests do not
yet expose deterministic collision triangles to the headless scene crate.

View File

@ -0,0 +1,37 @@
# Production-Readiness Acceptance Gate
Working plan for Gitea issue
[`#50`](https://git.spacetrainclubhouse.com/Falling-Metal-Interactive/Blacksite/issues/50).
This gate is broader than the M7 feature list: it proves the complete daily authoring, content,
recovery, performance, and release workflow against one exact release-candidate commit.
## Status
Audit in progress. The versioned acceptance matrix exists, but no release candidate is nominated and
the gate is not signed off. Packaged/release testing remains explicitly deferred until the project
owner requests it again.
## Sequence
1. Keep one evidence matrix under `docs/editor/evaluations/production-readiness/`; historical H1-H6
notes remain context only.
2. Close implementation blockers before nominating a candidate: collaborative safety `#49`, the
remaining Material/drag-drop contract in `#16`, `#18`, and `#51`, terrain `#22`-`#24`, physics
placement/diagnostics `#25`-`#26`, and their regression fixtures.
3. Complete the representative regression project, mutation-invariant coverage, performance budgets,
and first-hour workflow tracked by `#32`-`#36`.
4. Nominate one exact commit, validate it from a clean checkout, and record source/headless results.
5. When packaged testing is re-enabled, run the candidate's package/build and packaged-runtime
matrix without substituting older artifacts.
6. Run the eight-hour soak and measured performance protocol, consolidate limitations by severity
and workaround, and obtain independent first-hour/recovery sign-off.
7. Post the signed Gitea milestone comment linking the exact commit and immutable evidence. Close
`#50` only when every required row passes and no P0 blocker remains.
## Gate Rules
- Feature presence is not acceptance evidence.
- A local dirty worktree is not a release candidate.
- Results from another commit or an old package do not transfer to the candidate.
- Deferred, missing, partial, and implementer-only evidence are not passes.
- Every failure records an owner ticket or a concrete rerun condition.

View File

@ -0,0 +1,43 @@
# Renderer, material, and component foundation
## Goal
Separate static and skinned renderer ownership, restore deterministic skinned edit poses, add shared
material/material-instance slots, and make authoring components registry-driven and composable.
## Component foundation milestone
- Give authoring components immutable IDs distinct from Rust type paths.
- Split persisted active state from inspector ordering with legacy-state migration.
- Route presence, persistence, add/remove/reset/copy/paste, and component history through Bevy
reflection and atomic transactions.
- Treat `ActorKind` as a derived compatibility/display hint; validate component data and explicit
requirements/conflicts.
- Provide one statically linked extension registration helper and reflection round-trip fixture.
- Verify registry integrity, transaction undo/redo, scene save inclusion, composition, and legacy
enable-state preservation.
## Related renderer/material milestone
- Keep `StaticMeshRenderer` and `SkinnedMeshRenderer` as separate authoring and hydration paths.
- Give both renderer types stable imported-source/explicit Material slots and preserve orphaned
assignments by ID across source changes.
- Add versioned Material and direct-base Material Instance assets, sparse editor overrides, shared
handle live refresh, and runtime-only property blocks.
- Capture and restore imported Transform/MorphWeights baselines; sample only the explicitly chosen
default edit clip and never reload a rig for a material-only change.
- Compose one validated Surface evaluator into raster and Solari, including normal/emissive/unlit
fields and exact cutout candidate acceptance. Safely omit skinned/morph geometry from Solari until
instance-owned deformed vertices and dynamic BLAS updates exist.
- Upgrade scenes/materials explicitly to schema v4 with dry-run, staged writes, backup, rollback,
stable slot normalization, and active-state separation.
## Acceptance
- Shared, scene, game, game-hot, editor, Surface, and maintained Solari-fork tests pass.
- `cargo --locked validate-levels` reports zero blocking findings.
- A second project-upgrade dry run reports no changes after apply.
- Editor evidence shows the stable component cards, static/skinned material slots, Material Instance
workflow, deterministic animated actor pose, and Surface/Solari diagnostics.
- Gitea implementation issue and related roadmap issues link the documentation and screenshot
evidence without claiming unsupported dynamic deformed BLAS behavior.

View File

@ -0,0 +1,58 @@
# Source-Control Status And Collaborative File Safety
Working implementation plan for Gitea issue
[`#49`](https://git.spacetrainclubhouse.com/Falling-Metal-Interactive/Blacksite/issues/49).
This is the final M7 implementation slice before the production-readiness acceptance gate.
## Status
Implementation, focused source tests, full editor tests, strict editor clippy, and headless checks
are complete. Live debug-editor visual acceptance remains pending for a desktop-capable QA session.
Packaged/release acceptance is explicitly deferred until the project owner requests another
packaged pass.
## Outcome
External edits cannot be silently overwritten by the editor, common Git/read-only/ownership states
are visible without dominating the shell, and projects without Git or an ownership provider behave
normally.
## Implementation Sequence
1. Add exact file revisions, comparison metadata, guarded atomic replacement, and deterministic
pre-rename race coverage.
2. Capture baselines for scene tabs, material/material-instance drafts, and Project Settings;
protect prefab source Apply and exact-file undo/redo with the same guard.
3. Add a non-destructive asynchronous Git scanner and optional ownership-provider registry.
4. Add compact active-scene and selected-asset status plus a recovery modal with reload, compare
metadata, save-as, and cancel.
5. Cover missing/present/read-only/external-change races, porcelain parsing, provider absence, and
document-specific conflict recovery with focused source tests.
6. Run format, strict affected-crate clippy, source tests, headless validation, and live debug-editor
QA. Do not run packaged tests until requested by the project owner.
## Acceptance Gates
- A loaded authored file changed by another process is never replaced by Save, Apply to source, or
prefab source history.
- File creation is also conditional: a file that appears after a save-as baseline is captured is
not overwritten.
- Conflict and read-only UI identifies the path and recovery choices without a force-overwrite
escape hatch.
- Active-scene and selected-asset Git status remain compact and explain themselves on hover.
- Git absence, a non-repository project, and an empty provider registry are quiet normal states.
- Git commands are read-only and never alter index, worktree, commits, branches, or locks.
- Provider state can block an authored write without coupling core editor code to a vendor.
- Source tests simulate a writer changing the target immediately before rename and prove the old or
external content survives.
## Deliberate Boundaries
- Generated registries, imports, thumbnails, navigation bakes, recovery snapshots, and packages are
regenerative/system-owned and do not open authored-file recovery UI.
- V1 compares metadata rather than rendering a text diff; external diff-tool integration is future
work.
- V1 observes ownership/locks. Acquiring, releasing, or stealing locks belongs in a provider-specific
follow-up.
- Git status is project-local and advisory. The content revision guard remains authoritative even
when Git is absent or stale.

1
.gitignore vendored
View File

@ -14,6 +14,7 @@
# Editor/runtime generated session state
/assets/levels/.pie_session.scn.ron
/assets/.trash/
/.blacksite/backups/
# Backup, temporary, and log files
**/*.rs.bk

709
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -1,5 +1,6 @@
[workspace]
members = [
"crates/blacksite_surface",
"crates/game",
"crates/game_hot",
"crates/shared",
@ -36,6 +37,7 @@ bevy = { version = "0.19", features = [
] }
bevy_core_pipeline = "0.19"
bevy_solari = "0.19"
blacksite_surface = { path = "crates/blacksite_surface" }
bevy_ufbx = "0.18.1-rc.1"
bevy_egui = "0.40"
bevy-inspector-egui = "0.37"
@ -43,6 +45,9 @@ egui_dock = { version = "0.19.1", features = ["serde"] }
egui_phosphor_icons = { version = "0.3.1", default-features = false }
transform-gizmo-bevy = "0.9"
serde = { version = "1", features = ["derive"] }
nav_glam = { package = "glam", version = "=0.30.10", features = ["serde"] }
polyanya = { version = "=0.16.1", default-features = false, features = ["recast", "serde"] }
rerecast = { version = "=0.3.2", default-features = false, features = ["std", "serialize"] }
shared = { path = "crates/shared" }
game = { path = "crates/game" }
game_hot = { path = "crates/game_hot" }
@ -52,6 +57,8 @@ settings = { path = "crates/settings" }
scene = { path = "crates/scene" }
[patch.crates-io]
# Blacksite Surface ABI and deformation-safety integration for Solari.
bevy_solari = { path = "third_party/bevy_solari" }
# 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.

View File

@ -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
cargo bake-navigation --project . --check
# Validate one artifact without opening a game window
cargo run -p game -- --validate-navigation assets/navigation/generated/navigation_showcase_humanoid.nav.ron
# Machine-readable project dependency and finding report
cargo validate-levels --json
cargo package-project --profile development
@ -149,6 +152,7 @@ deep-stale variants.
| 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/` |
| Static/Skinned Mesh Renderer material slots | Assign a shared Material/Material Instance per slot; Browse/Select/Locate the reference, or Clear the override to restore the imported source material |
| `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 |
@ -157,6 +161,8 @@ deep-stale variants.
| 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 |
| Status strip / Asset Details source-state chip | Inspect compact clean, modified, untracked, conflicted, read-only, and optional ownership status; hover for the source path and provider details |
| Authored File Not Saved dialog | Resolve an external edit, read-only target, or ownership lock with Reload, Compare Metadata, Save As, or Cancel; the editor never offers force overwrite |
| 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 |
@ -230,10 +236,22 @@ Viewport shortcut keys require the pointer to be in the viewport and are suspend
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,
dragging a clip creates a dedicated skinned renderer or assigns an exact-rig-compatible state to
the selected skinned actor. **Default animation** in Model Import Settings optionally selects the
exact clip sampled and paused as the edit-rest pose; **Imported rest pose** never guesses a clip.
Preview stop and PIE exit restore imported transforms and morph weights before resampling that
explicit default. 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).
- Navigation bounds, obstacles, areas, and links are created from **Scene > Navigation** or the path
icon in the existing horizontal toolbar. Bounds bake versioned Rerecast artifacts under
`assets/navigation/generated/`; overlapping primitive and additive-brush triangles participate
in the bake fingerprint while distant authoring is excluded. Visible composed subscenes and linked
prefab sources resolve through the same deterministic bake path used by CI. The Inspector reports
stale/current state, pins named validation paths, and provides a Polyanya-backed path test whose
mesh, links, and route render in the viewport. Use
`cargo bake-navigation --project . --check` in CI. See the
[navigation authoring guide](docs/editor/navigation-authoring.md) and [ADR 0032](docs/adr/0032-versioned-navigation-bake-and-runtime-query.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
@ -245,6 +263,13 @@ Viewport shortcut keys require the pointer to be in the viewport and are suspend
without changing the scene.
- Runtime-only handles/colliders are not serialized directly, keeping scenes stable and portable.
- Editor-only cameras and helper roots are filtered from selection, hierarchy, and scene save.
- Loaded scenes, prefab source Apply/history, Materials, Material Instances, and Project Settings
retain exact BLAKE3 disk revisions. Save verifies the revision again immediately before atomic
replacement; an external edit, create race, read-only target, or provider lock leaves the
existing file untouched and opens the collaborative recovery dialog. Git status is observational
and optional. See the
[collaborative file-safety guide](docs/editor/collaborative-file-safety.md) and
[ADR 0037](docs/adr/0037-collaborative-authored-file-safety.md).
- **PIE restores player sim only** (transform, velocity, jump state) when you stop Play; authored
`LevelObject` edits made during PIE **remain** in the scene (the level may show as dirty).
- Play mode swaps the unified viewport between the player camera (possessed) and editor fly camera
@ -307,6 +332,16 @@ identity, project and active-scene paths, dirty flags, aggregate validation coun
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.
### External file changes and read-only assets
The active scene status strip and selected asset header show compact Git/read-only/ownership state
when available. If Save or Apply detects a different disk revision, Blacksite leaves that revision
untouched and opens **Authored File Not Saved**. Use **Compare Metadata** to inspect both revisions,
**Save As** to preserve the editor copy elsewhere, **Reload** to take the disk version, or **Cancel**
to keep the local draft unsaved. Git and ownership providers are optional; the status scanner never
stages, commits, resets, checks out, restores, or discards files. See
[collaborative-file-safety.md](docs/editor/collaborative-file-safety.md).
## Cursor / VSCode Setup
The `.vscode/` folder is preconfigured:
@ -329,6 +364,8 @@ The `.vscode/` folder is preconfigured:
- [ADR 0014: Unified Viewport Model](docs/adr/0014-unified-viewport-model.md)
- [ADR 0016: Unified Rendering Contract](docs/adr/0016-unified-rendering-contract.md)
- [ADR 0017: Normalized Static Mesh Assets](docs/adr/0017-normalized-static-mesh-assets.md)
- [ADR 0034: Registry-driven Authoring Components](docs/adr/0034-registry-driven-authoring-components.md)
- [ADR 0037: Collaborative Authored-File Safety](docs/adr/0037-collaborative-authored-file-safety.md)
## Project Layout
@ -375,12 +412,14 @@ crates/
- [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] Navigation bounds/obstacles/areas/links, persisted validation samples, composed-source resolution, deterministic stale-checked bake artifacts, viewport path preview, headless bake, and shared game/runtime query API ([ADR 0032](docs/adr/0032-versioned-navigation-bake-and-runtime-query.md); [evaluation](docs/editor/evaluations/navigation-authoring/); [Gitea #48](https://git.spacetrainclubhouse.com/Falling-Metal-Interactive/Blacksite/issues/48))
- [x] Exact authored-file revision guards for scenes, prefab source Apply/history, Materials, Material Instances, and Project Settings; compact observational Git/read-only/ownership state; and explicit Reload/Compare Metadata/Save As/Cancel recovery without force overwrite ([ADR 0037](docs/adr/0037-collaborative-authored-file-safety.md), [collaboration guide](docs/editor/collaborative-file-safety.md), [Gitea #49](https://git.spacetrainclubhouse.com/Falling-Metal-Interactive/Blacksite/issues/49))
- [x] Prefab instances (`PrefabInstance`) + save-as-prefab + unpack
- [x] Independent dirty-tab close confirmation and all-tab save guard when switching projects
- [x] Transactional scene writes + bounded user-local recovery snapshots ([ADR 0023](docs/adr/0023-transactional-scene-persistence-and-recovery.md))
- [x] Versioned clean/crash session resume + privacy-bounded diagnostic bundle ([ADR 0024](docs/adr/0024-versioned-editor-session-state.md))
- [x] Hierarchy multi-select, reparent undo, multi-entity gizmo transform
- [x] Typed inspector undo (light, rigid body, collider, primitive, material, static mesh renderer) + registry-driven Add Component footer
- [x] Typed inspector undo (light, rigid body, collider, primitive, material, static mesh renderer) + registry-driven Add Component footer; skinned renderer sources are asset-owned and inspectable
- [x] Gameplay authoring markers + visualizers (`WeaponSpawn`, `TriggerVolume`, etc.)
- [x] Command palette execution; PIE sim step (F7); `xtask validate-levels`
- [x] ADRs 00050012 (prefab/registry, scene schema, EditorPlugin, editor structure, authoring/hydration, ActorKind, sun policy, zero-debt)
@ -392,16 +431,19 @@ crates/
- [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] FBX/glTF model import + normalized static/skinned renderer routing; explicit generic 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] Shared Material/Material Instance assets, stable static/skinned renderer material slots, imported-source fallback, orphan preservation, and runtime-only property blocks ([ADR 0035](docs/adr/0035-shared-material-assets-and-renderer-slots.md), [material-system guide](docs/editor/material-system.md))
- [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] Surface ABI v1 with shared raster/Solari evaluator dispatch, packed typed parameters/textures, exact cutout ray-candidate evaluation, last-good shader fallback, and explicit exclusion of skinned/morph-deformed Solari geometry ([ADR 0036](docs/adr/0036-surface-abi-and-solari-parity.md), [material-system guide](docs/editor/material-system.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] Dedicated `SkinnedMeshRenderer`/`SkinnedMesh` actor path, skinned-part exclusion from static slots, dedicated runtime hierarchy hydration, and v2-to-v3 animated-scene migration ([ADR 0033](docs/adr/0033-dedicated-skinned-mesh-renderer.md))
- [x] Componentized actor inspector, unified component cards, thumbnail static mesh slots, imported asset Browse/Locate/Clear refs with inherited source defaults, collider split, shader-aware actor material data, and texture picker/drop refs ([ADR 0018](docs/adr/0018-componentized-actor-inspector-and-materials.md))
- [x] Stable authoring component IDs, registry-built save filtering, reflected atomic add/remove/reset/copy/paste history, independent active state, static extension registration, and derived composable `ActorKind` hints ([ADR 0034](docs/adr/0034-registry-driven-authoring-components.md), [extensibility guide](docs/editor/extensibility.md))
- [x] Brush authoring schema MVP with `ActorKind::Brush`, cube `BrushDesc`, generated mesh hydration, scene migration, and inspector Add Component support ([ADR 0021](docs/adr/0021-brush-authoring-schema.md))
- [x] Brush draw, vertex/edge/face gizmo editing, face material/UV authoring, clip, and bounds-based CSG preview/commit workflow ([brush guide](docs/editor/brushes.md))
- [x] Searchable command palette with human labels/stable IDs and a status bar that exposes scene I/O, tool, history, mode, and selection feedback
@ -414,10 +456,12 @@ crates/
untextured models), material sphere thumbnails, search/filter/sort controls, expandable model
subasset shelves, and a staged details pane. **File → Import Assets**
accepts glTF/GLB and **FBX** (binary; copies sibling `.fbm` texture folders when present).
Model assets generate normalized static mesh manifests under `assets/meshes/generated/`; drag/drop
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
Model assets generate normalized model manifests under `assets/meshes/generated/`; drag/drop uses
**Renderable Asset (Auto)**. Unrigged sources use `StaticMeshRenderer` with imported asset refs
and optional separate static mesh colliders. Skin-bound or animated sources and their subasset placement use
`SkinnedMeshRenderer`, preserve the source joint hierarchy, and never create static slots or
automatic triangle colliders. Expanded mesh subassets generate independent thumbnails. Asset details can switch
placement to **Scene Instance** for generic `ModelRef`/`WorldAssetRoot` scenes, shared material assets can be
edited from the browser, and delete actions move files to `assets/.trash/`. Animation authoring
supports glTF/GLB rig and clip manifests, stable controller refs, clip drag assignment/placement,
inspector preview, PIE/runtime playback, and headless compatibility diagnostics. Animated or
@ -432,7 +476,7 @@ crates/
**Unpack Layer** preserves nested links; **Convert to Local** recursively removes them. Current
authoring UI coverage and production-acceptance gaps are tracked in
[prefab-authoring.md](docs/editor/prefab-authoring.md).
- Material assets are RON files under `assets/materials/`; shader schemas live under `assets/shaders/`. `MaterialDesc` stores shader kind, typed parameters, texture bindings, and StandardMaterial fields. The Asset Browser material details editor can apply shader schemas, edit typed parameters/textures, and regenerate sphere thumbnails. Actor inspector material edits live on the actor `MaterialDesc`; static mesh source-material refs remain imported defaults and shared material assets are edited from the Asset Browser.
- Material and direct-base Material Instance assets live under `assets/materials/`; shader schemas live under `assets/shaders/`. In the Asset Browser, select a Material and use **Create Instance** for sparse inherited variants. Static and skinned renderers own stable per-draw material slots, with explicit assignments taking precedence over imported source defaults; shared edits propagate through live-updated handles without reloading skinned geometry. Custom Surface evaluators share one constrained ABI between raster and Solari-eligible non-deformed geometry. Dynamic skinned/morph Solari geometry and a persisted property-block authoring workflow remain future work; see the [material-system guide](docs/editor/material-system.md).
- 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,
material graphs, lighting profiles) can be added without changing the scene format foundation.

View File

@ -12,6 +12,8 @@
hierarchy_mode: SingleActor,
material_policy: SourceMaterials,
static_mesh_manifest_path: None,
animation_manifest_path: None,
default_animation_clip_id: None,
),
dependencies: [],
),
@ -28,6 +30,8 @@
hierarchy_mode: SingleActor,
material_policy: SourceMaterials,
static_mesh_manifest_path: None,
animation_manifest_path: None,
default_animation_clip_id: None,
),
dependencies: [],
),
@ -44,6 +48,8 @@
hierarchy_mode: SingleActor,
material_policy: SourceMaterials,
static_mesh_manifest_path: None,
animation_manifest_path: None,
default_animation_clip_id: None,
),
dependencies: [],
),
@ -60,6 +66,8 @@
hierarchy_mode: SingleActor,
material_policy: SourceMaterials,
static_mesh_manifest_path: None,
animation_manifest_path: None,
default_animation_clip_id: None,
),
dependencies: [],
),
@ -76,6 +84,44 @@
hierarchy_mode: SingleActor,
material_policy: SourceMaterials,
static_mesh_manifest_path: Some("assets/meshes/generated/b98ef565-3500-49e7-9935-f685fa9b2594.static_mesh.ron"),
animation_manifest_path: Some("assets/animations/generated/b98ef565-3500-49e7-9935-f685fa9b2594.animation.ron"),
default_animation_clip_id: None,
),
dependencies: [],
),
(
id: ("113f74df-e39c-41d4-9b5b-e48efe541f7f"),
path: "assets/models/RobotExpressive.glb",
label: "RobotExpressive",
kind_tag: "Model",
import_settings: (
scale: 1.0,
generate_collider: true,
lod0_only: true,
placement_mode: SceneInstance,
hierarchy_mode: SourceHierarchy,
material_policy: SourceMaterials,
static_mesh_manifest_path: Some("assets/meshes/generated/113f74df-e39c-41d4-9b5b-e48efe541f7f.static_mesh.ron"),
animation_manifest_path: Some("assets/animations/generated/113f74df-e39c-41d4-9b5b-e48efe541f7f.animation.ron"),
default_animation_clip_id: Some("animation:clip:8:standing"),
),
dependencies: [],
),
(
id: ("3f63f359-45eb-4cb2-8970-71921cbd7bd0"),
path: "assets/models/robot_expressive.glb",
label: "robot_expressive",
kind_tag: "Model",
import_settings: (
scale: 1.0,
generate_collider: true,
lod0_only: true,
placement_mode: StaticAsset,
hierarchy_mode: SingleActor,
material_policy: SourceMaterials,
static_mesh_manifest_path: Some("assets/meshes/generated/3f63f359-45eb-4cb2-8970-71921cbd7bd0.static_mesh.ron"),
animation_manifest_path: Some("assets/animations/generated/3f63f359-45eb-4cb2-8970-71921cbd7bd0.animation.ron"),
default_animation_clip_id: Some("animation:clip:8:standing"),
),
dependencies: [],
),
@ -92,6 +138,8 @@
hierarchy_mode: SingleActor,
material_policy: SourceMaterials,
static_mesh_manifest_path: None,
animation_manifest_path: None,
default_animation_clip_id: None,
),
dependencies: [],
),
@ -108,6 +156,8 @@
hierarchy_mode: SingleActor,
material_policy: SourceMaterials,
static_mesh_manifest_path: None,
animation_manifest_path: None,
default_animation_clip_id: None,
),
dependencies: [],
),
@ -124,6 +174,8 @@
hierarchy_mode: SingleActor,
material_policy: SourceMaterials,
static_mesh_manifest_path: None,
animation_manifest_path: None,
default_animation_clip_id: None,
),
dependencies: [],
),
@ -140,6 +192,26 @@
hierarchy_mode: SingleActor,
material_policy: SourceMaterials,
static_mesh_manifest_path: None,
animation_manifest_path: None,
default_animation_clip_id: None,
),
dependencies: [],
),
(
id: ("706e604e-1a63-4e97-b35d-eeb1d88fe3a9"),
path: "assets/levels/navigation_authoring_showcase.scn.ron",
label: "navigation_authoring_showcase.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,
animation_manifest_path: None,
default_animation_clip_id: None,
),
dependencies: [],
),
@ -156,6 +228,8 @@
hierarchy_mode: SingleActor,
material_policy: SourceMaterials,
static_mesh_manifest_path: None,
animation_manifest_path: None,
default_animation_clip_id: None,
),
dependencies: [],
),
@ -172,6 +246,8 @@
hierarchy_mode: SingleActor,
material_policy: SourceMaterials,
static_mesh_manifest_path: None,
animation_manifest_path: None,
default_animation_clip_id: None,
),
dependencies: [],
),
@ -188,6 +264,8 @@
hierarchy_mode: SingleActor,
material_policy: SourceMaterials,
static_mesh_manifest_path: None,
animation_manifest_path: None,
default_animation_clip_id: None,
),
dependencies: [],
),
@ -204,6 +282,8 @@
hierarchy_mode: SingleActor,
material_policy: SourceMaterials,
static_mesh_manifest_path: None,
animation_manifest_path: None,
default_animation_clip_id: None,
),
dependencies: [],
),
@ -220,6 +300,8 @@
hierarchy_mode: SingleActor,
material_policy: SourceMaterials,
static_mesh_manifest_path: None,
animation_manifest_path: None,
default_animation_clip_id: None,
),
dependencies: [],
),
@ -236,6 +318,8 @@
hierarchy_mode: SingleActor,
material_policy: SourceMaterials,
static_mesh_manifest_path: None,
animation_manifest_path: None,
default_animation_clip_id: None,
),
dependencies: [],
),
@ -252,6 +336,62 @@
hierarchy_mode: SingleActor,
material_policy: SourceMaterials,
static_mesh_manifest_path: None,
animation_manifest_path: None,
default_animation_clip_id: None,
),
dependencies: [],
),
(
id: ("04a4e00e-4732-43db-8e99-9cd99b94667b"),
path: "assets/animations/generated/113f74df-e39c-41d4-9b5b-e48efe541f7f.animation.ron",
label: "113f74df-e39c-41d4-9b5b-e48efe541f7f.animation",
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,
animation_manifest_path: None,
default_animation_clip_id: None,
),
dependencies: [],
),
(
id: ("4937e63f-923b-4270-bdfc-298d63bcb814"),
path: "assets/animations/generated/3f63f359-45eb-4cb2-8970-71921cbd7bd0.animation.ron",
label: "3f63f359-45eb-4cb2-8970-71921cbd7bd0.animation",
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,
animation_manifest_path: None,
default_animation_clip_id: None,
),
dependencies: [],
),
(
id: ("28f1df44-6d24-4722-bf75-6969879995a4"),
path: "assets/animations/generated/b98ef565-3500-49e7-9935-f685fa9b2594.animation.ron",
label: "b98ef565-3500-49e7-9935-f685fa9b2594.animation",
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,
animation_manifest_path: None,
default_animation_clip_id: None,
),
dependencies: [],
),
@ -268,6 +408,8 @@
hierarchy_mode: SingleActor,
material_policy: SourceMaterials,
static_mesh_manifest_path: None,
animation_manifest_path: None,
default_animation_clip_id: None,
),
dependencies: [],
),
@ -284,6 +426,26 @@
hierarchy_mode: SingleActor,
material_policy: SourceMaterials,
static_mesh_manifest_path: None,
animation_manifest_path: None,
default_animation_clip_id: None,
),
dependencies: [],
),
(
id: ("264e2010-0693-4d74-8440-99373c2cedb3"),
path: "assets/navigation/generated/navigation_showcase_humanoid.nav.ron",
label: "navigation_showcase_humanoid.nav",
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,
animation_manifest_path: None,
default_animation_clip_id: None,
),
dependencies: [],
),
@ -300,6 +462,26 @@
hierarchy_mode: SingleActor,
material_policy: SourceMaterials,
static_mesh_manifest_path: None,
animation_manifest_path: None,
default_animation_clip_id: None,
),
dependencies: [],
),
(
id: ("cc2769da-c9a5-4cb3-866a-cc81d0688ee2"),
path: "assets/materials/surface_tint_instance.ron",
label: "surface_tint_instance",
kind_tag: "Material",
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,
animation_manifest_path: None,
default_animation_clip_id: None,
),
dependencies: [],
),
@ -316,6 +498,26 @@
hierarchy_mode: SingleActor,
material_policy: SourceMaterials,
static_mesh_manifest_path: None,
animation_manifest_path: None,
default_animation_clip_id: None,
),
dependencies: [],
),
(
id: ("d6cb4151-7124-4237-aaf9-f7f8abd5fb76"),
path: "assets/materials/surface_tint.ron",
label: "surface_tint",
kind_tag: "Material",
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,
animation_manifest_path: None,
default_animation_clip_id: None,
),
dependencies: [],
),
@ -332,6 +534,8 @@
hierarchy_mode: SingleActor,
material_policy: SourceMaterials,
static_mesh_manifest_path: None,
animation_manifest_path: None,
default_animation_clip_id: None,
),
dependencies: [],
),
@ -348,6 +552,8 @@
hierarchy_mode: SingleActor,
material_policy: SourceMaterials,
static_mesh_manifest_path: None,
animation_manifest_path: None,
default_animation_clip_id: None,
),
dependencies: [],
),
@ -364,6 +570,8 @@
hierarchy_mode: SingleActor,
material_policy: SourceMaterials,
static_mesh_manifest_path: None,
animation_manifest_path: None,
default_animation_clip_id: None,
),
dependencies: [],
),
@ -380,6 +588,8 @@
hierarchy_mode: SingleActor,
material_policy: SourceMaterials,
static_mesh_manifest_path: None,
animation_manifest_path: None,
default_animation_clip_id: None,
),
dependencies: [],
),
@ -396,6 +606,26 @@
hierarchy_mode: SingleActor,
material_policy: SourceMaterials,
static_mesh_manifest_path: None,
animation_manifest_path: None,
default_animation_clip_id: None,
),
dependencies: [],
),
(
id: ("a72943d6-3e9d-4629-8887-5c02d99d4f92"),
path: "assets/shaders/surface_tint.shader.ron",
label: "surface_tint.shader",
kind_tag: "ShaderSchema",
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,
animation_manifest_path: None,
default_animation_clip_id: None,
),
dependencies: [],
),

View File

@ -0,0 +1,236 @@
(
schema_version: 3,
asset_id: "113f74df-e39c-41d4-9b5b-e48efe541f7f",
label: "RobotExpressive",
default_animation_clip_id: Some("animation:clip:8:standing"),
source: (
path: "assets/models/RobotExpressive.glb",
format: "glb",
fingerprint: (
byte_len: 463988,
modified_unix_secs: 1783799272,
content_hash: "5869cb813e6a6093eda7af2824a9c13a582750e0443e88ce7c991a1f83415521",
),
dependencies: [],
),
runtime_supported: true,
skeletons: [
(
id: "animation:skeleton:0:skeleton_0",
label: "Skeleton 0",
source_index: 0,
signature: ("e9761f2a198d6a280afd0fc2aca6e8783dc6237bd85c90b8bc07a2e11271dc2b"),
joint_paths: [
"RootNode/RobotArmature/Bone",
"RootNode/RobotArmature/Bone/Foot.L",
"RootNode/RobotArmature/Bone/Body",
"RootNode/RobotArmature/Bone/Body/Hips",
"RootNode/RobotArmature/Bone/Body/Hips/Abdomen",
"RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso",
"RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Neck",
"RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Neck/Head",
"RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.L",
"RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.L/UpperArm.L",
"RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.L/UpperArm.L/LowerArm.L",
"RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.L/UpperArm.L/LowerArm.L/Palm2.L",
"RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.L/UpperArm.L/LowerArm.L/Palm2.L/Middle1.L",
"RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.L/UpperArm.L/LowerArm.L/Palm2.L/Middle1.L/Middle2.L",
"RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.L/UpperArm.L/LowerArm.L/Thumb.L",
"RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.L/UpperArm.L/LowerArm.L/Thumb.L/Thumb2.L",
"RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.L/UpperArm.L/LowerArm.L/Palm1.L",
"RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.L/UpperArm.L/LowerArm.L/Palm1.L/Index.L",
"RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.L/UpperArm.L/LowerArm.L/Palm1.L/Index.L/Index2.L",
"RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.L/UpperArm.L/LowerArm.L/Palm3.L",
"RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.L/UpperArm.L/LowerArm.L/Palm3.L/Ring1.L",
"RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.L/UpperArm.L/LowerArm.L/Palm3.L/Ring1.L/Ring2.L",
"RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.R",
"RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.R/UpperArm.R",
"RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.R/UpperArm.R/LowerArm.R",
"RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.R/UpperArm.R/LowerArm.R/Palm2.R",
"RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.R/UpperArm.R/LowerArm.R/Palm2.R/Middle1.R",
"RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.R/UpperArm.R/LowerArm.R/Palm2.R/Middle1.R/Middle2.R",
"RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.R/UpperArm.R/LowerArm.R/Thumb.R",
"RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.R/UpperArm.R/LowerArm.R/Thumb.R/Thumb2.R",
"RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.R/UpperArm.R/LowerArm.R/Palm1.R",
"RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.R/UpperArm.R/LowerArm.R/Palm1.R/Index.R",
"RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.R/UpperArm.R/LowerArm.R/Palm1.R/Index.R/Index2.R",
"RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.R/UpperArm.R/LowerArm.R/Palm3.R",
"RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.R/UpperArm.R/LowerArm.R/Palm3.R/Ring1.R",
"RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.R/UpperArm.R/LowerArm.R/Palm3.R/Ring1.R/Ring2.R",
"RootNode/RobotArmature/Bone/Body/UpperLeg.L",
"RootNode/RobotArmature/Bone/Body/UpperLeg.L/LowerLeg.L",
"RootNode/RobotArmature/Bone/Body/UpperLeg.R",
"RootNode/RobotArmature/Bone/Body/UpperLeg.R/LowerLeg.R",
"RootNode/RobotArmature/Bone/PoleTarget.L",
"RootNode/RobotArmature/Bone/Foot.R",
"RootNode/RobotArmature/Bone/PoleTarget.R",
],
),
(
id: "animation:skeleton:1:skeleton_1",
label: "Skeleton 1",
source_index: 1,
signature: ("e9761f2a198d6a280afd0fc2aca6e8783dc6237bd85c90b8bc07a2e11271dc2b"),
joint_paths: [
"RootNode/RobotArmature/Bone",
"RootNode/RobotArmature/Bone/Foot.L",
"RootNode/RobotArmature/Bone/Body",
"RootNode/RobotArmature/Bone/Body/Hips",
"RootNode/RobotArmature/Bone/Body/Hips/Abdomen",
"RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso",
"RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Neck",
"RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Neck/Head",
"RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.L",
"RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.L/UpperArm.L",
"RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.L/UpperArm.L/LowerArm.L",
"RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.L/UpperArm.L/LowerArm.L/Palm2.L",
"RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.L/UpperArm.L/LowerArm.L/Palm2.L/Middle1.L",
"RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.L/UpperArm.L/LowerArm.L/Palm2.L/Middle1.L/Middle2.L",
"RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.L/UpperArm.L/LowerArm.L/Thumb.L",
"RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.L/UpperArm.L/LowerArm.L/Thumb.L/Thumb2.L",
"RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.L/UpperArm.L/LowerArm.L/Palm1.L",
"RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.L/UpperArm.L/LowerArm.L/Palm1.L/Index.L",
"RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.L/UpperArm.L/LowerArm.L/Palm1.L/Index.L/Index2.L",
"RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.L/UpperArm.L/LowerArm.L/Palm3.L",
"RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.L/UpperArm.L/LowerArm.L/Palm3.L/Ring1.L",
"RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.L/UpperArm.L/LowerArm.L/Palm3.L/Ring1.L/Ring2.L",
"RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.R",
"RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.R/UpperArm.R",
"RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.R/UpperArm.R/LowerArm.R",
"RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.R/UpperArm.R/LowerArm.R/Palm2.R",
"RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.R/UpperArm.R/LowerArm.R/Palm2.R/Middle1.R",
"RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.R/UpperArm.R/LowerArm.R/Palm2.R/Middle1.R/Middle2.R",
"RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.R/UpperArm.R/LowerArm.R/Thumb.R",
"RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.R/UpperArm.R/LowerArm.R/Thumb.R/Thumb2.R",
"RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.R/UpperArm.R/LowerArm.R/Palm1.R",
"RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.R/UpperArm.R/LowerArm.R/Palm1.R/Index.R",
"RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.R/UpperArm.R/LowerArm.R/Palm1.R/Index.R/Index2.R",
"RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.R/UpperArm.R/LowerArm.R/Palm3.R",
"RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.R/UpperArm.R/LowerArm.R/Palm3.R/Ring1.R",
"RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.R/UpperArm.R/LowerArm.R/Palm3.R/Ring1.R/Ring2.R",
"RootNode/RobotArmature/Bone/Body/UpperLeg.L",
"RootNode/RobotArmature/Bone/Body/UpperLeg.L/LowerLeg.L",
"RootNode/RobotArmature/Bone/Body/UpperLeg.R",
"RootNode/RobotArmature/Bone/Body/UpperLeg.R/LowerLeg.R",
"RootNode/RobotArmature/Bone/PoleTarget.L",
"RootNode/RobotArmature/Bone/Foot.R",
"RootNode/RobotArmature/Bone/PoleTarget.R",
],
),
],
clips: [
(
id: "animation:clip:0:dance",
label: "Dance",
source_index: 0,
duration_seconds: 3.3333333,
target_skeleton_signature: Some(("e9761f2a198d6a280afd0fc2aca6e8783dc6237bd85c90b8bc07a2e11271dc2b")),
events: [],
),
(
id: "animation:clip:1:death",
label: "Death",
source_index: 1,
duration_seconds: 0.9583333,
target_skeleton_signature: Some(("e9761f2a198d6a280afd0fc2aca6e8783dc6237bd85c90b8bc07a2e11271dc2b")),
events: [],
),
(
id: "animation:clip:2:idle",
label: "Idle",
source_index: 2,
duration_seconds: 3.3333333,
target_skeleton_signature: Some(("e9761f2a198d6a280afd0fc2aca6e8783dc6237bd85c90b8bc07a2e11271dc2b")),
events: [],
),
(
id: "animation:clip:3:jump",
label: "Jump",
source_index: 3,
duration_seconds: 0.7083333,
target_skeleton_signature: Some(("e9761f2a198d6a280afd0fc2aca6e8783dc6237bd85c90b8bc07a2e11271dc2b")),
events: [],
),
(
id: "animation:clip:4:no",
label: "No",
source_index: 4,
duration_seconds: 1.6666666,
target_skeleton_signature: Some(("e9761f2a198d6a280afd0fc2aca6e8783dc6237bd85c90b8bc07a2e11271dc2b")),
events: [],
),
(
id: "animation:clip:5:punch",
label: "Punch",
source_index: 5,
duration_seconds: 0.8333333,
target_skeleton_signature: Some(("e9761f2a198d6a280afd0fc2aca6e8783dc6237bd85c90b8bc07a2e11271dc2b")),
events: [],
),
(
id: "animation:clip:6:running",
label: "Running",
source_index: 6,
duration_seconds: 0.9583333,
target_skeleton_signature: Some(("e9761f2a198d6a280afd0fc2aca6e8783dc6237bd85c90b8bc07a2e11271dc2b")),
events: [],
),
(
id: "animation:clip:7:sitting",
label: "Sitting",
source_index: 7,
duration_seconds: 0.41666666,
target_skeleton_signature: Some(("e9761f2a198d6a280afd0fc2aca6e8783dc6237bd85c90b8bc07a2e11271dc2b")),
events: [],
),
(
id: "animation:clip:8:standing",
label: "Standing",
source_index: 8,
duration_seconds: 0.41666666,
target_skeleton_signature: Some(("e9761f2a198d6a280afd0fc2aca6e8783dc6237bd85c90b8bc07a2e11271dc2b")),
events: [],
),
(
id: "animation:clip:9:thumbsup",
label: "ThumbsUp",
source_index: 9,
duration_seconds: 1.5833334,
target_skeleton_signature: Some(("e9761f2a198d6a280afd0fc2aca6e8783dc6237bd85c90b8bc07a2e11271dc2b")),
events: [],
),
(
id: "animation:clip:10:walking",
label: "Walking",
source_index: 10,
duration_seconds: 0.9583333,
target_skeleton_signature: Some(("e9761f2a198d6a280afd0fc2aca6e8783dc6237bd85c90b8bc07a2e11271dc2b")),
events: [],
),
(
id: "animation:clip:11:walkjump",
label: "WalkJump",
source_index: 11,
duration_seconds: 0.8333333,
target_skeleton_signature: Some(("e9761f2a198d6a280afd0fc2aca6e8783dc6237bd85c90b8bc07a2e11271dc2b")),
events: [],
),
(
id: "animation:clip:12:wave",
label: "Wave",
source_index: 12,
duration_seconds: 1.8333334,
target_skeleton_signature: Some(("e9761f2a198d6a280afd0fc2aca6e8783dc6237bd85c90b8bc07a2e11271dc2b")),
events: [],
),
(
id: "animation:clip:13:yes",
label: "Yes",
source_index: 13,
duration_seconds: 1.6666666,
target_skeleton_signature: Some(("e9761f2a198d6a280afd0fc2aca6e8783dc6237bd85c90b8bc07a2e11271dc2b")),
events: [],
),
],
diagnostics: [],
)

View File

@ -0,0 +1,236 @@
(
schema_version: 3,
asset_id: "3f63f359-45eb-4cb2-8970-71921cbd7bd0",
label: "robot_expressive",
default_animation_clip_id: Some("animation:clip:8:standing"),
source: (
path: "assets/models/robot_expressive.glb",
format: "glb",
fingerprint: (
byte_len: 463988,
modified_unix_secs: 1783750957,
content_hash: "5869cb813e6a6093eda7af2824a9c13a582750e0443e88ce7c991a1f83415521",
),
dependencies: [],
),
runtime_supported: true,
skeletons: [
(
id: "animation:skeleton:0:skeleton_0",
label: "Skeleton 0",
source_index: 0,
signature: ("e9761f2a198d6a280afd0fc2aca6e8783dc6237bd85c90b8bc07a2e11271dc2b"),
joint_paths: [
"RootNode/RobotArmature/Bone",
"RootNode/RobotArmature/Bone/Foot.L",
"RootNode/RobotArmature/Bone/Body",
"RootNode/RobotArmature/Bone/Body/Hips",
"RootNode/RobotArmature/Bone/Body/Hips/Abdomen",
"RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso",
"RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Neck",
"RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Neck/Head",
"RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.L",
"RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.L/UpperArm.L",
"RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.L/UpperArm.L/LowerArm.L",
"RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.L/UpperArm.L/LowerArm.L/Palm2.L",
"RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.L/UpperArm.L/LowerArm.L/Palm2.L/Middle1.L",
"RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.L/UpperArm.L/LowerArm.L/Palm2.L/Middle1.L/Middle2.L",
"RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.L/UpperArm.L/LowerArm.L/Thumb.L",
"RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.L/UpperArm.L/LowerArm.L/Thumb.L/Thumb2.L",
"RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.L/UpperArm.L/LowerArm.L/Palm1.L",
"RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.L/UpperArm.L/LowerArm.L/Palm1.L/Index.L",
"RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.L/UpperArm.L/LowerArm.L/Palm1.L/Index.L/Index2.L",
"RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.L/UpperArm.L/LowerArm.L/Palm3.L",
"RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.L/UpperArm.L/LowerArm.L/Palm3.L/Ring1.L",
"RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.L/UpperArm.L/LowerArm.L/Palm3.L/Ring1.L/Ring2.L",
"RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.R",
"RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.R/UpperArm.R",
"RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.R/UpperArm.R/LowerArm.R",
"RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.R/UpperArm.R/LowerArm.R/Palm2.R",
"RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.R/UpperArm.R/LowerArm.R/Palm2.R/Middle1.R",
"RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.R/UpperArm.R/LowerArm.R/Palm2.R/Middle1.R/Middle2.R",
"RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.R/UpperArm.R/LowerArm.R/Thumb.R",
"RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.R/UpperArm.R/LowerArm.R/Thumb.R/Thumb2.R",
"RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.R/UpperArm.R/LowerArm.R/Palm1.R",
"RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.R/UpperArm.R/LowerArm.R/Palm1.R/Index.R",
"RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.R/UpperArm.R/LowerArm.R/Palm1.R/Index.R/Index2.R",
"RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.R/UpperArm.R/LowerArm.R/Palm3.R",
"RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.R/UpperArm.R/LowerArm.R/Palm3.R/Ring1.R",
"RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.R/UpperArm.R/LowerArm.R/Palm3.R/Ring1.R/Ring2.R",
"RootNode/RobotArmature/Bone/Body/UpperLeg.L",
"RootNode/RobotArmature/Bone/Body/UpperLeg.L/LowerLeg.L",
"RootNode/RobotArmature/Bone/Body/UpperLeg.R",
"RootNode/RobotArmature/Bone/Body/UpperLeg.R/LowerLeg.R",
"RootNode/RobotArmature/Bone/PoleTarget.L",
"RootNode/RobotArmature/Bone/Foot.R",
"RootNode/RobotArmature/Bone/PoleTarget.R",
],
),
(
id: "animation:skeleton:1:skeleton_1",
label: "Skeleton 1",
source_index: 1,
signature: ("e9761f2a198d6a280afd0fc2aca6e8783dc6237bd85c90b8bc07a2e11271dc2b"),
joint_paths: [
"RootNode/RobotArmature/Bone",
"RootNode/RobotArmature/Bone/Foot.L",
"RootNode/RobotArmature/Bone/Body",
"RootNode/RobotArmature/Bone/Body/Hips",
"RootNode/RobotArmature/Bone/Body/Hips/Abdomen",
"RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso",
"RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Neck",
"RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Neck/Head",
"RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.L",
"RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.L/UpperArm.L",
"RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.L/UpperArm.L/LowerArm.L",
"RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.L/UpperArm.L/LowerArm.L/Palm2.L",
"RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.L/UpperArm.L/LowerArm.L/Palm2.L/Middle1.L",
"RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.L/UpperArm.L/LowerArm.L/Palm2.L/Middle1.L/Middle2.L",
"RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.L/UpperArm.L/LowerArm.L/Thumb.L",
"RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.L/UpperArm.L/LowerArm.L/Thumb.L/Thumb2.L",
"RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.L/UpperArm.L/LowerArm.L/Palm1.L",
"RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.L/UpperArm.L/LowerArm.L/Palm1.L/Index.L",
"RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.L/UpperArm.L/LowerArm.L/Palm1.L/Index.L/Index2.L",
"RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.L/UpperArm.L/LowerArm.L/Palm3.L",
"RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.L/UpperArm.L/LowerArm.L/Palm3.L/Ring1.L",
"RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.L/UpperArm.L/LowerArm.L/Palm3.L/Ring1.L/Ring2.L",
"RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.R",
"RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.R/UpperArm.R",
"RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.R/UpperArm.R/LowerArm.R",
"RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.R/UpperArm.R/LowerArm.R/Palm2.R",
"RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.R/UpperArm.R/LowerArm.R/Palm2.R/Middle1.R",
"RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.R/UpperArm.R/LowerArm.R/Palm2.R/Middle1.R/Middle2.R",
"RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.R/UpperArm.R/LowerArm.R/Thumb.R",
"RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.R/UpperArm.R/LowerArm.R/Thumb.R/Thumb2.R",
"RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.R/UpperArm.R/LowerArm.R/Palm1.R",
"RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.R/UpperArm.R/LowerArm.R/Palm1.R/Index.R",
"RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.R/UpperArm.R/LowerArm.R/Palm1.R/Index.R/Index2.R",
"RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.R/UpperArm.R/LowerArm.R/Palm3.R",
"RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.R/UpperArm.R/LowerArm.R/Palm3.R/Ring1.R",
"RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.R/UpperArm.R/LowerArm.R/Palm3.R/Ring1.R/Ring2.R",
"RootNode/RobotArmature/Bone/Body/UpperLeg.L",
"RootNode/RobotArmature/Bone/Body/UpperLeg.L/LowerLeg.L",
"RootNode/RobotArmature/Bone/Body/UpperLeg.R",
"RootNode/RobotArmature/Bone/Body/UpperLeg.R/LowerLeg.R",
"RootNode/RobotArmature/Bone/PoleTarget.L",
"RootNode/RobotArmature/Bone/Foot.R",
"RootNode/RobotArmature/Bone/PoleTarget.R",
],
),
],
clips: [
(
id: "animation:clip:0:dance",
label: "Dance",
source_index: 0,
duration_seconds: 3.3333333,
target_skeleton_signature: Some(("e9761f2a198d6a280afd0fc2aca6e8783dc6237bd85c90b8bc07a2e11271dc2b")),
events: [],
),
(
id: "animation:clip:1:death",
label: "Death",
source_index: 1,
duration_seconds: 0.9583333,
target_skeleton_signature: Some(("e9761f2a198d6a280afd0fc2aca6e8783dc6237bd85c90b8bc07a2e11271dc2b")),
events: [],
),
(
id: "animation:clip:2:idle",
label: "Idle",
source_index: 2,
duration_seconds: 3.3333333,
target_skeleton_signature: Some(("e9761f2a198d6a280afd0fc2aca6e8783dc6237bd85c90b8bc07a2e11271dc2b")),
events: [],
),
(
id: "animation:clip:3:jump",
label: "Jump",
source_index: 3,
duration_seconds: 0.7083333,
target_skeleton_signature: Some(("e9761f2a198d6a280afd0fc2aca6e8783dc6237bd85c90b8bc07a2e11271dc2b")),
events: [],
),
(
id: "animation:clip:4:no",
label: "No",
source_index: 4,
duration_seconds: 1.6666666,
target_skeleton_signature: Some(("e9761f2a198d6a280afd0fc2aca6e8783dc6237bd85c90b8bc07a2e11271dc2b")),
events: [],
),
(
id: "animation:clip:5:punch",
label: "Punch",
source_index: 5,
duration_seconds: 0.8333333,
target_skeleton_signature: Some(("e9761f2a198d6a280afd0fc2aca6e8783dc6237bd85c90b8bc07a2e11271dc2b")),
events: [],
),
(
id: "animation:clip:6:running",
label: "Running",
source_index: 6,
duration_seconds: 0.9583333,
target_skeleton_signature: Some(("e9761f2a198d6a280afd0fc2aca6e8783dc6237bd85c90b8bc07a2e11271dc2b")),
events: [],
),
(
id: "animation:clip:7:sitting",
label: "Sitting",
source_index: 7,
duration_seconds: 0.41666666,
target_skeleton_signature: Some(("e9761f2a198d6a280afd0fc2aca6e8783dc6237bd85c90b8bc07a2e11271dc2b")),
events: [],
),
(
id: "animation:clip:8:standing",
label: "Standing",
source_index: 8,
duration_seconds: 0.41666666,
target_skeleton_signature: Some(("e9761f2a198d6a280afd0fc2aca6e8783dc6237bd85c90b8bc07a2e11271dc2b")),
events: [],
),
(
id: "animation:clip:9:thumbsup",
label: "ThumbsUp",
source_index: 9,
duration_seconds: 1.5833334,
target_skeleton_signature: Some(("e9761f2a198d6a280afd0fc2aca6e8783dc6237bd85c90b8bc07a2e11271dc2b")),
events: [],
),
(
id: "animation:clip:10:walking",
label: "Walking",
source_index: 10,
duration_seconds: 0.9583333,
target_skeleton_signature: Some(("e9761f2a198d6a280afd0fc2aca6e8783dc6237bd85c90b8bc07a2e11271dc2b")),
events: [],
),
(
id: "animation:clip:11:walkjump",
label: "WalkJump",
source_index: 11,
duration_seconds: 0.8333333,
target_skeleton_signature: Some(("e9761f2a198d6a280afd0fc2aca6e8783dc6237bd85c90b8bc07a2e11271dc2b")),
events: [],
),
(
id: "animation:clip:12:wave",
label: "Wave",
source_index: 12,
duration_seconds: 1.8333334,
target_skeleton_signature: Some(("e9761f2a198d6a280afd0fc2aca6e8783dc6237bd85c90b8bc07a2e11271dc2b")),
events: [],
),
(
id: "animation:clip:13:yes",
label: "Yes",
source_index: 13,
duration_seconds: 1.6666666,
target_skeleton_signature: Some(("e9761f2a198d6a280afd0fc2aca6e8783dc6237bd85c90b8bc07a2e11271dc2b")),
events: [],
),
],
diagnostics: [],
)

View File

@ -0,0 +1,20 @@
(
schema_version: 3,
asset_id: "b98ef565-3500-49e7-9935-f685fa9b2594",
label: "painted_wooden_chair_02_2k",
default_animation_clip_id: None,
source: (
path: "assets/models/painted_wooden_chair_02_2k.fbx",
format: "fbx",
fingerprint: (
byte_len: 59964,
modified_unix_secs: 1780713434,
content_hash: "b12973a62dcb44589e380ea833eade726ae98a86c81084c842ee3801866d6a46",
),
dependencies: [],
),
runtime_supported: false,
skeletons: [],
clips: [],
diagnostics: [],
)

View File

@ -1,18 +1,16 @@
(schema_version: 2,
resources: {},
entities: {
4294969001: (
components: {
"bevy_ecs::name::Name": "Audition Tone Source",
"bevy_transform::components::transform::Transform": (
(schema_version: 4,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": (
"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",
@ -32,25 +30,23 @@
),
bus: "sfx",
),
},
),
4294969002: (
components: {
"bevy_ecs::name::Name": "Showcase Listener",
"bevy_transform::components::transform::Transform": (
"shared::components::LevelObject": (),
}),
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": (
"shared::components::ActorId": ("audio-showcase-listener"),
"shared::components::ActorKind": AudioListener,
"shared::components::AudioListenerDesc": (
enabled: true,
priority: 100,
ear_gap: 0.2,
),
},
),
},
)
"shared::components::LevelObject": (),
}),
},
)

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,73 @@
(schema_version: 4,resources: {
},
entities: {
1: (components: {
"bevy_ecs::name::Name": "Navigation Bounds",
"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": ("navigation-bounds-main"),
"shared::components::ActorKind": Navigation,
"shared::components::EditorVisibility": (visible: true),
"shared::components::HierarchySiblingIndex": (0),
"shared::components::LevelObject": (),
"shared::navigation::NavigationBounds": (
half_extents: (8.0, 2.0, 8.0),
agent: (
id: "humanoid",
radius: 0.35,
height: 1.8,
max_climb: 0.45,
max_slope_deg: 45.0,
cell_size_fraction: 3.0,
cell_height_fraction: 6.0,
min_region_size: 1,
merge_region_size: 2,
),
artifact_path: "assets/navigation/generated/navigation_showcase_humanoid.nav.ron",
auto_bake: false,
validation_samples: [
(id: "around-center-obstacle", start: (-6.0, 0.0, -5.0), end: (6.0, 0.0, -5.0), enabled: true),
],
),
}),
2: (components: {
"bevy_ecs::name::Name": "Center Obstacle",
"bevy_transform::components::transform::Transform": (translation: (0.0, 0.5, 0.0), rotation: (0.0, 0.0, 0.0, 1.0), scale: (1.0, 1.0, 1.0)),
"shared::components::ActorId": ("navigation-obstacle-center"),
"shared::components::ActorKind": Navigation,
"shared::components::EditorVisibility": (visible: true),
"shared::components::HierarchySiblingIndex": (1),
"shared::components::LevelObject": (),
"shared::navigation::NavigationObstacle": (half_extents: (1.0, 1.0, 2.5), dynamic: false),
}),
3: (components: {
"bevy_ecs::name::Name": "Slow Area",
"bevy_transform::components::transform::Transform": (translation: (-4.0, 0.5, -4.0), rotation: (0.0, 0.0, 0.0, 1.0), scale: (1.0, 1.0, 1.0)),
"shared::components::ActorId": ("navigation-area-slow"),
"shared::components::ActorKind": Navigation,
"shared::components::EditorVisibility": (visible: true),
"shared::components::HierarchySiblingIndex": (2),
"shared::components::LevelObject": (),
"shared::navigation::NavigationArea": (id: "slow", half_extents: (1.5, 1.0, 1.5), cost: 1.5, walkable: true),
}),
4: (components: {
"bevy_ecs::name::Name": "Obstacle 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": ("navigation-link-center"),
"shared::components::ActorKind": Navigation,
"shared::components::EditorVisibility": (visible: true),
"shared::components::HierarchySiblingIndex": (3),
"shared::components::LevelObject": (),
"shared::navigation::NavigationLink": (start: (-1.5, 0.0, 0.0), end: (1.5, 0.0, 0.0), bidirectional: true, cost: 1.2, enabled: true),
}),
5: (components: {
"bevy_ecs::name::Name": "Walkable Floor Geometry",
"bevy_transform::components::transform::Transform": (translation: (0.0, -0.25, 0.0), rotation: (0.0, 0.0, 0.0, 1.0), scale: (1.0, 1.0, 1.0)),
"shared::components::ActorId": ("navigation-floor-geometry"),
"shared::components::ActorKind": StaticMesh,
"shared::components::EditorVisibility": (visible: true),
"shared::components::HierarchySiblingIndex": (4),
"shared::components::LevelObject": (),
"shared::components::Primitive": (shape: Box, size: (16.0, 0.5, 16.0)),
}),
},
)

View File

@ -1,17 +1,16 @@
(schema_version: 2,
resources: {},
entities: {
4294968001: (
components: {
"bevy_ecs::name::Name": "Fog Volume",
"bevy_transform::components::transform::Transform": (
(schema_version: 4,resources: {
},
entities: {
4294968001: (components: {
"bevy_ecs::name::Name": "Fog Volume",
"bevy_transform::components::transform::Transform": (
translation: (0.0, 2.0, 0.0),
rotation: (0.0, 0.0, 0.0, 1.0),
scale: (1.0, 1.0, 1.0),
),
"shared::components::LevelObject": (),
"shared::components::ActorKind": PostProcessVolume,
"shared::components::PostProcessVolumeDesc": (
"shared::components::ActorKind": PostProcessVolume,
"shared::components::LevelObject": (),
"shared::components::PostProcessVolumeDesc": (
half_extents: (6.0, 3.0, 6.0),
priority: 0,
blend_distance: 2.0,
@ -23,19 +22,17 @@
profile: None,
label: Some("Foggy courtyard"),
),
},
),
4294968002: (
components: {
"bevy_ecs::name::Name": "Dark Exposure Volume",
"bevy_transform::components::transform::Transform": (
}),
4294968002: (components: {
"bevy_ecs::name::Name": "Dark Exposure Volume",
"bevy_transform::components::transform::Transform": (
translation: (14.0, 2.0, 0.0),
rotation: (0.0, 0.0, 0.0, 1.0),
scale: (1.0, 1.0, 1.0),
),
"shared::components::LevelObject": (),
"shared::components::ActorKind": PostProcessVolume,
"shared::components::PostProcessVolumeDesc": (
"shared::components::ActorKind": PostProcessVolume,
"shared::components::LevelObject": (),
"shared::components::PostProcessVolumeDesc": (
half_extents: (4.0, 2.5, 4.0),
priority: 5,
blend_distance: 1.5,
@ -47,19 +44,17 @@
profile: Some("assets/rendering_profiles/cave_dark.ron"),
label: Some("Cave mouth"),
),
},
),
4294968003: (
components: {
"bevy_ecs::name::Name": "Vignette FX Volume",
"bevy_transform::components::transform::Transform": (
}),
4294968003: (components: {
"bevy_ecs::name::Name": "Vignette FX Volume",
"bevy_transform::components::transform::Transform": (
translation: (-12.0, 2.0, 0.0),
rotation: (0.0, 0.0, 0.0, 1.0),
scale: (1.0, 1.0, 1.0),
),
"shared::components::LevelObject": (),
"shared::components::ActorKind": PostProcessVolume,
"shared::components::PostProcessVolumeDesc": (
"shared::components::ActorKind": PostProcessVolume,
"shared::components::LevelObject": (),
"shared::components::PostProcessVolumeDesc": (
half_extents: (5.0, 2.0, 5.0),
priority: 2,
blend_distance: 2.0,
@ -68,7 +63,6 @@
profile: None,
label: Some("Vignette demo"),
),
},
),
},
)
}),
},
)

View File

@ -1,12 +1,40 @@
(
schema_version: 1,
label: "Concrete",
shader: None,
shader_ref: None,
render_state: (
alpha_mode: Opaque,
alpha_cutoff: 0.5,
double_sided: false,
),
material: (
base_color: (r: 0.55, g: 0.54, b: 0.52, a: 1.0),
shader: (
kind: StandardLit,
schema_path: None,
shader_path: None,
),
base_color: (
r: 0.55,
g: 0.54,
b: 0.52,
a: 1.0,
),
metallic: 0.0,
roughness: 0.85,
emissive_color: (
r: 1.0,
g: 1.0,
b: 1.0,
a: 1.0,
),
emissive_intensity: 0.0,
base_color_texture: None,
emissive_texture: None,
normal_map_texture: None,
metallic_roughness_texture: None,
material_asset_path: None,
parameters: [],
textures: [],
),
)
)

View File

@ -1,15 +1,40 @@
(
schema_version: 1,
label: "Emissive Panel",
shader: None,
shader_ref: None,
render_state: (
alpha_mode: Opaque,
alpha_cutoff: 0.5,
double_sided: false,
),
material: (
base_color: (r: 0.15, g: 0.22, b: 0.28, a: 1.0),
shader: (
kind: StandardLit,
schema_path: None,
shader_path: None,
),
base_color: (
r: 0.15,
g: 0.22,
b: 0.28,
a: 1.0,
),
metallic: 0.0,
roughness: 0.35,
emissive_color: (r: 0.55, g: 0.85, b: 1.0, a: 1.0),
emissive_color: (
r: 0.55,
g: 0.85,
b: 1.0,
a: 1.0,
),
emissive_intensity: 2500.0,
base_color_texture: None,
emissive_texture: None,
normal_map_texture: None,
metallic_roughness_texture: None,
material_asset_path: None,
parameters: [],
textures: [],
),
)
)

View File

@ -0,0 +1,71 @@
(
schema_version: 1,
label: "Surface Tint",
shader: Some("assets/shaders/surface_tint.shader.ron"),
shader_ref: None,
render_state: (
alpha_mode: Opaque,
alpha_cutoff: 0.5,
double_sided: false,
),
material: (
shader: (
kind: Custom,
schema_path: Some("assets/shaders/surface_tint.shader.ron"),
shader_path: Some("assets/shaders/surface_tint.wgsl"),
),
base_color: (
r: 0.12,
g: 0.42,
b: 0.95,
a: 1.0,
),
metallic: 0.15,
roughness: 0.28,
emissive_color: (
r: 0.02,
g: 0.08,
b: 0.25,
a: 1.0,
),
emissive_intensity: 3.0,
base_color_texture: None,
emissive_texture: None,
normal_map_texture: None,
metallic_roughness_texture: None,
material_asset_path: None,
parameters: [
(
name: "tint",
value: Color((
r: 0.12,
g: 0.42,
b: 0.95,
a: 1.0,
)),
),
(
name: "roughness",
value: Float(0.28),
),
(
name: "metallic",
value: Float(0.15),
),
(
name: "emissive",
value: Color((
r: 0.02,
g: 0.08,
b: 0.25,
a: 1.0,
)),
),
(
name: "emissive_intensity",
value: Float(3.0),
),
],
textures: [],
),
)

View File

@ -0,0 +1,12 @@
(
schema_version: 1,
label: "surface_tint Instance",
base: ((
asset_id: "d6cb4151-7124-4237-aaf9-f7f8abd5fb76",
sub_asset_id: "material:source",
label: "surface_tint",
source_path: Some("assets/materials/surface_tint.ron"),
)),
parameters: [],
textures: [],
)

View File

@ -0,0 +1,360 @@
(
schema_version: 3,
asset_id: "113f74df-e39c-41d4-9b5b-e48efe541f7f",
label: "RobotExpressive",
source: (
path: "assets/models/RobotExpressive.glb",
format: "glb",
fingerprint: (
byte_len: 463988,
modified_unix_secs: 1783799272,
),
dependencies: [],
),
import: (
scale: 1.0,
generate_collider: true,
lod0_only: true,
placement_mode: SceneInstance,
hierarchy_mode: SourceHierarchy,
material_policy: SourceMaterials,
),
metadata: (
mesh_count: 14,
material_count: 3,
node_count: 74,
animation_count: 14,
skin_count: 2,
light_count: 0,
camera_count: 0,
),
parts: [
(
id: "draw:scene0:node4:mesh0:primitive0",
name: "Foot.L / Primitive 0",
mesh_label: "Mesh0/Primitive0",
material_id: Some("material:material0"),
material_slot_name: "Grey",
material_label: Some("Material0"),
local_transform: (
translation: (0.62405795, 0.39766362, 0.19111444),
rotation: (0.7071068, 0.00000005228401, 0.000000015504241, -0.70710665),
scale: (100.0, 99.999985, 100.0),
),
source_node: Some("RootNode/RobotArmature/Bone/Foot.L/Foot.L"),
source_mesh: Some("Mesh0"),
source_material: Some("Grey"),
skinned: false,
),
(
id: "draw:scene0:node7:mesh1:primitive0",
name: "Torso / Primitive 0",
mesh_label: "Mesh1/Primitive0",
material_id: Some("material:material0"),
material_slot_name: "Grey",
material_label: Some("Material0"),
local_transform: (
translation: (-0.0027156367, 2.1737509, -0.019581214),
rotation: (0.70710677, 0.000000000000031351603, 0.000000000000055267226, -0.7071067),
scale: (100.0, 99.99998, 99.99998),
),
source_node: Some("RootNode/RobotArmature/Bone/Body/Torso"),
source_mesh: Some("Mesh1"),
source_material: Some("Grey"),
skinned: false,
),
(
id: "draw:scene0:node7:mesh1:primitive1",
name: "Torso / Primitive 1",
mesh_label: "Mesh1/Primitive1",
material_id: Some("material:material1"),
material_slot_name: "Main",
material_label: Some("Material1"),
local_transform: (
translation: (-0.0027156367, 2.1737509, -0.019581214),
rotation: (0.70710677, 0.000000000000031351603, 0.000000000000055267226, -0.7071067),
scale: (100.0, 99.99998, 99.99998),
),
source_node: Some("RootNode/RobotArmature/Bone/Body/Torso"),
source_mesh: Some("Mesh1"),
source_material: Some("Main"),
skinned: false,
),
(
id: "draw:scene0:node61:mesh10:primitive0",
name: "Leg.R / Primitive 0",
mesh_label: "Mesh10/Primitive0",
material_id: Some("material:material1"),
material_slot_name: "Main",
material_label: Some("Material1"),
local_transform: (
translation: (-0.6746981, 1.1586236, 0.046261936),
rotation: (0.7693698, 0.080835514, 0.110868365, -0.6238942),
scale: (99.99999, 99.99886, 100.00024),
),
source_node: Some("RootNode/RobotArmature/Bone/Body/UpperLeg.R/Leg.R"),
source_mesh: Some("Mesh10"),
source_material: Some("Main"),
skinned: false,
),
(
id: "draw:scene0:node63:mesh11:primitive0",
name: "LowerLeg.R / Primitive 0",
mesh_label: "Mesh11/Primitive0",
material_id: Some("material:material1"),
material_slot_name: "Main",
material_label: Some("Material1"),
local_transform: (
translation: (-0.73626167, 0.34982973, 0.22594015),
rotation: (-0.65091395, -0.099058665, -0.09436109, 0.7467224),
scale: (99.99999, 99.998604, 100.00058),
),
source_node: Some("RootNode/RobotArmature/Bone/Body/UpperLeg.R/LowerLeg.R/LowerLeg.R"),
source_mesh: Some("Mesh11"),
source_material: Some("Main"),
skinned: false,
),
(
id: "draw:scene0:node72:mesh12:primitive0",
name: "Hand.R / Primitive 0",
mesh_label: "Mesh12/Primitive0",
material_id: Some("material:material1"),
material_slot_name: "Main",
material_label: Some("Material1"),
local_transform: (
translation: (-0.002715637, 2.3702974, -0.020994147),
rotation: (0.70710677, 0.0, 0.0, -0.7071067),
scale: (100.0, 99.99999, 99.99999),
),
source_node: Some("RootNode/Hand.R"),
source_mesh: Some("Mesh12"),
source_material: Some("Main"),
skinned: true,
),
(
id: "draw:scene0:node72:mesh12:primitive1",
name: "Hand.R / Primitive 1",
mesh_label: "Mesh12/Primitive1",
material_id: Some("material:material0"),
material_slot_name: "Grey",
material_label: Some("Material0"),
local_transform: (
translation: (-0.002715637, 2.3702974, -0.020994147),
rotation: (0.70710677, 0.0, 0.0, -0.7071067),
scale: (100.0, 99.99999, 99.99999),
),
source_node: Some("RootNode/Hand.R"),
source_mesh: Some("Mesh12"),
source_material: Some("Grey"),
skinned: true,
),
(
id: "draw:scene0:node73:mesh13:primitive0",
name: "Hand.L / Primitive 0",
mesh_label: "Mesh13/Primitive0",
material_id: Some("material:material1"),
material_slot_name: "Main",
material_label: Some("Material1"),
local_transform: (
translation: (-0.002715637, 2.3702974, -0.020994147),
rotation: (0.70710677, 0.0, 0.0, -0.7071067),
scale: (100.0, 99.99999, 99.99999),
),
source_node: Some("RootNode/Hand.L"),
source_mesh: Some("Mesh13"),
source_material: Some("Main"),
skinned: true,
),
(
id: "draw:scene0:node73:mesh13:primitive1",
name: "Hand.L / Primitive 1",
mesh_label: "Mesh13/Primitive1",
material_id: Some("material:material0"),
material_slot_name: "Grey",
material_label: Some("Material0"),
local_transform: (
translation: (-0.002715637, 2.3702974, -0.020994147),
rotation: (0.70710677, 0.0, 0.0, -0.7071067),
scale: (100.0, 99.99999, 99.99999),
),
source_node: Some("RootNode/Hand.L"),
source_mesh: Some("Mesh13"),
source_material: Some("Grey"),
skinned: true,
),
(
id: "draw:scene0:node13:mesh2:primitive0",
name: "Head / Primitive 0",
mesh_label: "Mesh2/Primitive0",
material_id: Some("material:material0"),
material_slot_name: "Grey",
material_label: Some("Material0"),
local_transform: (
translation: (-0.05334633, 3.6175368, -0.0068152454),
rotation: (0.7249889, 0.0852804, 0.05460569, -0.6812757),
scale: (99.99997, 99.99996, 99.99997),
),
source_node: Some("RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Neck/Head/Head"),
source_mesh: Some("Mesh2"),
source_material: Some("Grey"),
skinned: false,
),
(
id: "draw:scene0:node13:mesh2:primitive1",
name: "Head / Primitive 1",
mesh_label: "Mesh2/Primitive1",
material_id: Some("material:material1"),
material_slot_name: "Main",
material_label: Some("Material1"),
local_transform: (
translation: (-0.05334633, 3.6175368, -0.0068152454),
rotation: (0.7249889, 0.0852804, 0.05460569, -0.6812757),
scale: (99.99997, 99.99996, 99.99997),
),
source_node: Some("RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Neck/Head/Head"),
source_mesh: Some("Mesh2"),
source_material: Some("Main"),
skinned: false,
),
(
id: "draw:scene0:node13:mesh2:primitive2",
name: "Head / Primitive 2",
mesh_label: "Mesh2/Primitive2",
material_id: Some("material:material2"),
material_slot_name: "Black",
material_label: Some("Material2"),
local_transform: (
translation: (-0.05334633, 3.6175368, -0.0068152454),
rotation: (0.7249889, 0.0852804, 0.05460569, -0.6812757),
scale: (99.99997, 99.99996, 99.99997),
),
source_node: Some("RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Neck/Head/Head"),
source_mesh: Some("Mesh2"),
source_material: Some("Black"),
skinned: false,
),
(
id: "draw:scene0:node68:mesh3:primitive0",
name: "Foot.R / Primitive 0",
mesh_label: "Mesh3/Primitive0",
material_id: Some("material:material0"),
material_slot_name: "Grey",
material_label: Some("Material0"),
local_transform: (
translation: (-0.637997, 0.3976636, 0.19111452),
rotation: (0.70710677, 0.00000005201811, 0.000000015770217, -0.7071067),
scale: (100.0, 99.999985, 100.0),
),
source_node: Some("RootNode/RobotArmature/Bone/Foot.R/Foot.R"),
source_mesh: Some("Mesh3"),
source_material: Some("Grey"),
skinned: false,
),
(
id: "draw:scene0:node16:mesh4:primitive0",
name: "Shoulder.L / Primitive 0",
mesh_label: "Mesh4/Primitive0",
material_id: Some("material:material1"),
material_slot_name: "Main",
material_label: Some("Material1"),
local_transform: (
translation: (0.58268076, 2.3724597, -0.020994142),
rotation: (-0.59886926, 0.37597278, -0.37597266, 0.59886944),
scale: (99.99996, 99.999954, 99.99997),
),
source_node: Some("RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.L/Shoulder.L"),
source_mesh: Some("Mesh4"),
source_material: Some("Main"),
skinned: false,
),
(
id: "draw:scene0:node18:mesh5:primitive0",
name: "Arm.L / Primitive 0",
mesh_label: "Mesh5/Primitive0",
material_id: Some("material:material1"),
material_slot_name: "Main",
material_label: Some("Material1"),
local_transform: (
translation: (0.26500192, 2.8489676, 0.13069445),
rotation: (-0.5164575, 0.4218856, -0.32294926, 0.67155623),
scale: (99.99994, 99.99994, 99.99999),
),
source_node: Some("RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.L/UpperArm.L/Arm.L"),
source_mesh: Some("Mesh5"),
source_material: Some("Main"),
skinned: false,
),
(
id: "draw:scene0:node36:mesh6:primitive0",
name: "Shoulder.R / Primitive 0",
mesh_label: "Mesh6/Primitive0",
material_id: Some("material:material1"),
material_slot_name: "Main",
material_label: Some("Material1"),
local_transform: (
translation: (-0.6369861, 2.3718858, -0.020994166),
rotation: (0.6017577, 0.37133226, -0.37133226, -0.6017577),
scale: (99.999954, 99.99997, 99.99994),
),
source_node: Some("RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.R/Shoulder.R"),
source_mesh: Some("Mesh6"),
source_material: Some("Main"),
skinned: false,
),
(
id: "draw:scene0:node38:mesh7:primitive0",
name: "Arm.R / Primitive 0",
mesh_label: "Mesh7/Primitive0",
material_id: Some("material:material1"),
material_slot_name: "Main",
material_label: Some("Material1"),
local_transform: (
translation: (-0.82568955, 1.9825263, 0.040243305),
rotation: (0.6276481, 0.35463694, -0.3873087, -0.5747021),
scale: (22.70405, 22.704054, 22.704046),
),
source_node: Some("RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.R/UpperArm.R/Arm.R"),
source_mesh: Some("Mesh7"),
source_material: Some("Main"),
skinned: false,
),
(
id: "draw:scene0:node56:mesh8:primitive0",
name: "Leg.L / Primitive 0",
mesh_label: "Mesh8/Primitive0",
material_id: Some("material:material1"),
material_slot_name: "Main",
material_label: Some("Material1"),
local_transform: (
translation: (0.6488191, 1.1620699, 0.057466835),
rotation: (0.76387787, -0.05410902, -0.070370995, -0.6392268),
scale: (99.99999, 100.00079, 100.00024),
),
source_node: Some("RootNode/RobotArmature/Bone/Body/UpperLeg.L/Leg.L"),
source_mesh: Some("Mesh8"),
source_material: Some("Main"),
skinned: false,
),
(
id: "draw:scene0:node58:mesh9:primitive0",
name: "LowerLeg.L / Primitive 0",
mesh_label: "Mesh9/Primitive0",
material_id: Some("material:material1"),
material_slot_name: "Main",
material_label: Some("Material1"),
local_transform: (
translation: (0.68805873, 0.3497491, 0.23465614),
rotation: (-0.6547612, 0.06408298, 0.061960325, 0.7505613),
scale: (100.000015, 100.0006, 100.000496),
),
source_node: Some("RootNode/RobotArmature/Bone/Body/UpperLeg.L/LowerLeg.L/LowerLeg.L"),
source_mesh: Some("Mesh9"),
source_material: Some("Main"),
skinned: false,
),
],
warnings: [
"Animations are recorded as metadata; animated placement uses SkinnedMeshRenderer.",
"Skinned primitives are excluded from StaticMeshRenderer and use SkinnedMeshRenderer.",
],
)

View File

@ -0,0 +1,360 @@
(
schema_version: 3,
asset_id: "3f63f359-45eb-4cb2-8970-71921cbd7bd0",
label: "robot_expressive",
source: (
path: "assets/models/robot_expressive.glb",
format: "glb",
fingerprint: (
byte_len: 463988,
modified_unix_secs: 1783750957,
),
dependencies: [],
),
import: (
scale: 1.0,
generate_collider: true,
lod0_only: true,
placement_mode: StaticAsset,
hierarchy_mode: SingleActor,
material_policy: SourceMaterials,
),
metadata: (
mesh_count: 14,
material_count: 3,
node_count: 74,
animation_count: 14,
skin_count: 2,
light_count: 0,
camera_count: 0,
),
parts: [
(
id: "draw:scene0:node4:mesh0:primitive0",
name: "Foot.L / Primitive 0",
mesh_label: "Mesh0/Primitive0",
material_id: Some("material:material0"),
material_slot_name: "Grey",
material_label: Some("Material0"),
local_transform: (
translation: (0.62405795, 0.39766362, 0.19111444),
rotation: (0.7071068, 0.00000005228401, 0.000000015504241, -0.70710665),
scale: (100.0, 99.999985, 100.0),
),
source_node: Some("RootNode/RobotArmature/Bone/Foot.L/Foot.L"),
source_mesh: Some("Mesh0"),
source_material: Some("Grey"),
skinned: false,
),
(
id: "draw:scene0:node7:mesh1:primitive0",
name: "Torso / Primitive 0",
mesh_label: "Mesh1/Primitive0",
material_id: Some("material:material0"),
material_slot_name: "Grey",
material_label: Some("Material0"),
local_transform: (
translation: (-0.0027156367, 2.1737509, -0.019581214),
rotation: (0.70710677, 0.000000000000031351603, 0.000000000000055267226, -0.7071067),
scale: (100.0, 99.99998, 99.99998),
),
source_node: Some("RootNode/RobotArmature/Bone/Body/Torso"),
source_mesh: Some("Mesh1"),
source_material: Some("Grey"),
skinned: false,
),
(
id: "draw:scene0:node7:mesh1:primitive1",
name: "Torso / Primitive 1",
mesh_label: "Mesh1/Primitive1",
material_id: Some("material:material1"),
material_slot_name: "Main",
material_label: Some("Material1"),
local_transform: (
translation: (-0.0027156367, 2.1737509, -0.019581214),
rotation: (0.70710677, 0.000000000000031351603, 0.000000000000055267226, -0.7071067),
scale: (100.0, 99.99998, 99.99998),
),
source_node: Some("RootNode/RobotArmature/Bone/Body/Torso"),
source_mesh: Some("Mesh1"),
source_material: Some("Main"),
skinned: false,
),
(
id: "draw:scene0:node61:mesh10:primitive0",
name: "Leg.R / Primitive 0",
mesh_label: "Mesh10/Primitive0",
material_id: Some("material:material1"),
material_slot_name: "Main",
material_label: Some("Material1"),
local_transform: (
translation: (-0.6746981, 1.1586236, 0.046261936),
rotation: (0.7693698, 0.080835514, 0.110868365, -0.6238942),
scale: (99.99999, 99.99886, 100.00024),
),
source_node: Some("RootNode/RobotArmature/Bone/Body/UpperLeg.R/Leg.R"),
source_mesh: Some("Mesh10"),
source_material: Some("Main"),
skinned: false,
),
(
id: "draw:scene0:node63:mesh11:primitive0",
name: "LowerLeg.R / Primitive 0",
mesh_label: "Mesh11/Primitive0",
material_id: Some("material:material1"),
material_slot_name: "Main",
material_label: Some("Material1"),
local_transform: (
translation: (-0.73626167, 0.34982973, 0.22594015),
rotation: (-0.65091395, -0.099058665, -0.09436109, 0.7467224),
scale: (99.99999, 99.998604, 100.00058),
),
source_node: Some("RootNode/RobotArmature/Bone/Body/UpperLeg.R/LowerLeg.R/LowerLeg.R"),
source_mesh: Some("Mesh11"),
source_material: Some("Main"),
skinned: false,
),
(
id: "draw:scene0:node72:mesh12:primitive0",
name: "Hand.R / Primitive 0",
mesh_label: "Mesh12/Primitive0",
material_id: Some("material:material1"),
material_slot_name: "Main",
material_label: Some("Material1"),
local_transform: (
translation: (-0.002715637, 2.3702974, -0.020994147),
rotation: (0.70710677, 0.0, 0.0, -0.7071067),
scale: (100.0, 99.99999, 99.99999),
),
source_node: Some("RootNode/Hand.R"),
source_mesh: Some("Mesh12"),
source_material: Some("Main"),
skinned: true,
),
(
id: "draw:scene0:node72:mesh12:primitive1",
name: "Hand.R / Primitive 1",
mesh_label: "Mesh12/Primitive1",
material_id: Some("material:material0"),
material_slot_name: "Grey",
material_label: Some("Material0"),
local_transform: (
translation: (-0.002715637, 2.3702974, -0.020994147),
rotation: (0.70710677, 0.0, 0.0, -0.7071067),
scale: (100.0, 99.99999, 99.99999),
),
source_node: Some("RootNode/Hand.R"),
source_mesh: Some("Mesh12"),
source_material: Some("Grey"),
skinned: true,
),
(
id: "draw:scene0:node73:mesh13:primitive0",
name: "Hand.L / Primitive 0",
mesh_label: "Mesh13/Primitive0",
material_id: Some("material:material1"),
material_slot_name: "Main",
material_label: Some("Material1"),
local_transform: (
translation: (-0.002715637, 2.3702974, -0.020994147),
rotation: (0.70710677, 0.0, 0.0, -0.7071067),
scale: (100.0, 99.99999, 99.99999),
),
source_node: Some("RootNode/Hand.L"),
source_mesh: Some("Mesh13"),
source_material: Some("Main"),
skinned: true,
),
(
id: "draw:scene0:node73:mesh13:primitive1",
name: "Hand.L / Primitive 1",
mesh_label: "Mesh13/Primitive1",
material_id: Some("material:material0"),
material_slot_name: "Grey",
material_label: Some("Material0"),
local_transform: (
translation: (-0.002715637, 2.3702974, -0.020994147),
rotation: (0.70710677, 0.0, 0.0, -0.7071067),
scale: (100.0, 99.99999, 99.99999),
),
source_node: Some("RootNode/Hand.L"),
source_mesh: Some("Mesh13"),
source_material: Some("Grey"),
skinned: true,
),
(
id: "draw:scene0:node13:mesh2:primitive0",
name: "Head / Primitive 0",
mesh_label: "Mesh2/Primitive0",
material_id: Some("material:material0"),
material_slot_name: "Grey",
material_label: Some("Material0"),
local_transform: (
translation: (-0.05334633, 3.6175368, -0.0068152454),
rotation: (0.7249889, 0.0852804, 0.05460569, -0.6812757),
scale: (99.99997, 99.99996, 99.99997),
),
source_node: Some("RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Neck/Head/Head"),
source_mesh: Some("Mesh2"),
source_material: Some("Grey"),
skinned: false,
),
(
id: "draw:scene0:node13:mesh2:primitive1",
name: "Head / Primitive 1",
mesh_label: "Mesh2/Primitive1",
material_id: Some("material:material1"),
material_slot_name: "Main",
material_label: Some("Material1"),
local_transform: (
translation: (-0.05334633, 3.6175368, -0.0068152454),
rotation: (0.7249889, 0.0852804, 0.05460569, -0.6812757),
scale: (99.99997, 99.99996, 99.99997),
),
source_node: Some("RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Neck/Head/Head"),
source_mesh: Some("Mesh2"),
source_material: Some("Main"),
skinned: false,
),
(
id: "draw:scene0:node13:mesh2:primitive2",
name: "Head / Primitive 2",
mesh_label: "Mesh2/Primitive2",
material_id: Some("material:material2"),
material_slot_name: "Black",
material_label: Some("Material2"),
local_transform: (
translation: (-0.05334633, 3.6175368, -0.0068152454),
rotation: (0.7249889, 0.0852804, 0.05460569, -0.6812757),
scale: (99.99997, 99.99996, 99.99997),
),
source_node: Some("RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Neck/Head/Head"),
source_mesh: Some("Mesh2"),
source_material: Some("Black"),
skinned: false,
),
(
id: "draw:scene0:node68:mesh3:primitive0",
name: "Foot.R / Primitive 0",
mesh_label: "Mesh3/Primitive0",
material_id: Some("material:material0"),
material_slot_name: "Grey",
material_label: Some("Material0"),
local_transform: (
translation: (-0.637997, 0.3976636, 0.19111452),
rotation: (0.70710677, 0.00000005201811, 0.000000015770217, -0.7071067),
scale: (100.0, 99.999985, 100.0),
),
source_node: Some("RootNode/RobotArmature/Bone/Foot.R/Foot.R"),
source_mesh: Some("Mesh3"),
source_material: Some("Grey"),
skinned: false,
),
(
id: "draw:scene0:node16:mesh4:primitive0",
name: "Shoulder.L / Primitive 0",
mesh_label: "Mesh4/Primitive0",
material_id: Some("material:material1"),
material_slot_name: "Main",
material_label: Some("Material1"),
local_transform: (
translation: (0.58268076, 2.3724597, -0.020994142),
rotation: (-0.59886926, 0.37597278, -0.37597266, 0.59886944),
scale: (99.99996, 99.999954, 99.99997),
),
source_node: Some("RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.L/Shoulder.L"),
source_mesh: Some("Mesh4"),
source_material: Some("Main"),
skinned: false,
),
(
id: "draw:scene0:node18:mesh5:primitive0",
name: "Arm.L / Primitive 0",
mesh_label: "Mesh5/Primitive0",
material_id: Some("material:material1"),
material_slot_name: "Main",
material_label: Some("Material1"),
local_transform: (
translation: (0.26500192, 2.8489676, 0.13069445),
rotation: (-0.5164575, 0.4218856, -0.32294926, 0.67155623),
scale: (99.99994, 99.99994, 99.99999),
),
source_node: Some("RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.L/UpperArm.L/Arm.L"),
source_mesh: Some("Mesh5"),
source_material: Some("Main"),
skinned: false,
),
(
id: "draw:scene0:node36:mesh6:primitive0",
name: "Shoulder.R / Primitive 0",
mesh_label: "Mesh6/Primitive0",
material_id: Some("material:material1"),
material_slot_name: "Main",
material_label: Some("Material1"),
local_transform: (
translation: (-0.6369861, 2.3718858, -0.020994166),
rotation: (0.6017577, 0.37133226, -0.37133226, -0.6017577),
scale: (99.999954, 99.99997, 99.99994),
),
source_node: Some("RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.R/Shoulder.R"),
source_mesh: Some("Mesh6"),
source_material: Some("Main"),
skinned: false,
),
(
id: "draw:scene0:node38:mesh7:primitive0",
name: "Arm.R / Primitive 0",
mesh_label: "Mesh7/Primitive0",
material_id: Some("material:material1"),
material_slot_name: "Main",
material_label: Some("Material1"),
local_transform: (
translation: (-0.82568955, 1.9825263, 0.040243305),
rotation: (0.6276481, 0.35463694, -0.3873087, -0.5747021),
scale: (22.70405, 22.704054, 22.704046),
),
source_node: Some("RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.R/UpperArm.R/Arm.R"),
source_mesh: Some("Mesh7"),
source_material: Some("Main"),
skinned: false,
),
(
id: "draw:scene0:node56:mesh8:primitive0",
name: "Leg.L / Primitive 0",
mesh_label: "Mesh8/Primitive0",
material_id: Some("material:material1"),
material_slot_name: "Main",
material_label: Some("Material1"),
local_transform: (
translation: (0.6488191, 1.1620699, 0.057466835),
rotation: (0.76387787, -0.05410902, -0.070370995, -0.6392268),
scale: (99.99999, 100.00079, 100.00024),
),
source_node: Some("RootNode/RobotArmature/Bone/Body/UpperLeg.L/Leg.L"),
source_mesh: Some("Mesh8"),
source_material: Some("Main"),
skinned: false,
),
(
id: "draw:scene0:node58:mesh9:primitive0",
name: "LowerLeg.L / Primitive 0",
mesh_label: "Mesh9/Primitive0",
material_id: Some("material:material1"),
material_slot_name: "Main",
material_label: Some("Material1"),
local_transform: (
translation: (0.68805873, 0.3497491, 0.23465614),
rotation: (-0.6547612, 0.06408298, 0.061960325, 0.7505613),
scale: (100.000015, 100.0006, 100.000496),
),
source_node: Some("RootNode/RobotArmature/Bone/Body/UpperLeg.L/LowerLeg.L/LowerLeg.L"),
source_mesh: Some("Mesh9"),
source_material: Some("Main"),
skinned: false,
),
],
warnings: [
"Animations are recorded as metadata; animated placement uses SkinnedMeshRenderer.",
"Skinned primitives are excluded from StaticMeshRenderer and use SkinnedMeshRenderer.",
],
)

View File

@ -1,5 +1,5 @@
(
schema_version: 1,
schema_version: 3,
asset_id: "b98ef565-3500-49e7-9935-f685fa9b2594",
label: "painted_wooden_chair_02_2k",
source: (
@ -30,7 +30,7 @@
),
parts: [
(
id: "mesh:mesh1000",
id: "draw:scene0:node1:material0",
name: "painted_wooden_chair_02 / Material 0",
mesh_label: "Mesh1000",
material_id: Some("material:material0"),
@ -44,6 +44,7 @@
source_node: Some("Node1"),
source_mesh: Some("Mesh1"),
source_material: Some("painted_wooden_chair_02"),
skinned: false,
),
],
warnings: [],

BIN
assets/models/RobotExpressive.glb (Stored with Git LFS) Normal file

Binary file not shown.

View File

@ -0,0 +1,977 @@
(
schema_version: 2,
generator: "blacksite-nav-2/rerecast-0.3.2/polyanya-0.16.1/glam-0.30.10",
source_scene: "assets/levels/navigation_authoring_showcase.scn.ron",
source_fingerprint: "15649e3f7c8f22d642ea318ba1dafb5a3ada82e2713219cb60bbb518c5e45195",
payload_hash: "180b5a4698102e8185ae182bc30cf847466c4e9d65fe7834c315f6d1a03504c2",
bounds_actor_id: "navigation-bounds-main",
center: (0.0, 0.0, 0.0),
half_extents: (8.0, 2.0, 8.0),
agent: (
id: "humanoid",
radius: 0.35,
height: 1.8,
max_climb: 0.45,
max_slope_deg: 45.0,
cell_size_fraction: 3.0,
cell_height_fraction: 6.0,
min_region_size: 1,
merge_region_size: 2,
),
mesh: (
layers: [
(
vertices: [
(
coords: (-1.0, -2.516667),
polygons: [
0,
4294967295,
1,
3,
2,
],
is_corner: true,
),
(
coords: (-0.3000002, -2.516667),
polygons: [
4,
4294967295,
0,
],
is_corner: true,
),
(
coords: (-0.41666698, -7.3),
polygons: [
5,
4,
0,
2,
4294967295,
],
is_corner: true,
),
(
coords: (-7.3, -1.5833335),
polygons: [
3,
1,
10,
12,
4294967295,
],
is_corner: true,
),
(
coords: (-1.0, -1.5833335),
polygons: [
10,
1,
4294967295,
],
is_corner: true,
),
(
coords: (-1.0, -2.516667),
polygons: [
0,
4294967295,
1,
3,
2,
],
is_corner: true,
),
(
coords: (-1.0, -2.516667),
polygons: [
0,
4294967295,
1,
3,
2,
],
is_corner: true,
),
(
coords: (-0.41666698, -7.3),
polygons: [
5,
4,
0,
2,
4294967295,
],
is_corner: true,
),
(
coords: (-7.3, -7.3),
polygons: [
2,
3,
4294967295,
],
is_corner: true,
),
(
coords: (-7.3, -1.5833335),
polygons: [
3,
1,
10,
12,
4294967295,
],
is_corner: true,
),
(
coords: (-0.3000002, -2.516667),
polygons: [
4,
4294967295,
0,
],
is_corner: true,
),
(
coords: (0.2833333, -2.516667),
polygons: [
6,
4294967295,
4,
5,
],
is_corner: true,
),
(
coords: (0.39999962, -7.3),
polygons: [
8,
6,
5,
4294967295,
],
is_corner: true,
),
(
coords: (-0.41666698, -7.3),
polygons: [
5,
4,
0,
2,
4294967295,
],
is_corner: true,
),
(
coords: (0.39999962, -7.3),
polygons: [
8,
6,
5,
4294967295,
],
is_corner: true,
),
(
coords: (0.2833333, -2.516667),
polygons: [
6,
4294967295,
4,
5,
],
is_corner: true,
),
(
coords: (0.9833336, -2.516667),
polygons: [
8,
9,
7,
4294967295,
6,
],
is_corner: true,
),
(
coords: (0.9833336, -2.516667),
polygons: [
8,
9,
7,
4294967295,
6,
],
is_corner: true,
),
(
coords: (0.9833336, -1.5833335),
polygons: [
7,
13,
4294967295,
],
is_corner: true,
),
(
coords: (7.283333, -1.5833335),
polygons: [
15,
13,
7,
9,
4294967295,
],
is_corner: true,
),
(
coords: (0.9833336, -2.516667),
polygons: [
8,
9,
7,
4294967295,
6,
],
is_corner: true,
),
(
coords: (7.283333, -1.5833335),
polygons: [
15,
13,
7,
9,
4294967295,
],
is_corner: true,
),
(
coords: (7.283333, -7.3),
polygons: [
9,
8,
4294967295,
],
is_corner: true,
),
(
coords: (0.39999962, -7.3),
polygons: [
8,
6,
5,
4294967295,
],
is_corner: true,
),
(
coords: (-1.0, -0.0666666),
polygons: [
11,
12,
10,
4294967295,
],
is_corner: true,
),
(
coords: (-1.0, -1.5833335),
polygons: [
10,
1,
4294967295,
],
is_corner: true,
),
(
coords: (-7.3, -1.5833335),
polygons: [
3,
1,
10,
12,
4294967295,
],
is_corner: true,
),
(
coords: (-7.3, 1.5666666),
polygons: [
12,
11,
17,
19,
4294967295,
],
is_corner: true,
),
(
coords: (-1.0, 1.5666666),
polygons: [
17,
11,
4294967295,
],
is_corner: true,
),
(
coords: (-1.0, -0.0666666),
polygons: [
11,
12,
10,
4294967295,
],
is_corner: true,
),
(
coords: (-7.3, -1.5833335),
polygons: [
3,
1,
10,
12,
4294967295,
],
is_corner: true,
),
(
coords: (7.283333, -1.5833335),
polygons: [
15,
13,
7,
9,
4294967295,
],
is_corner: true,
),
(
coords: (0.9833336, -1.5833335),
polygons: [
7,
13,
4294967295,
],
is_corner: true,
),
(
coords: (0.9833336, -0.0666666),
polygons: [
13,
15,
14,
4294967295,
],
is_corner: true,
),
(
coords: (7.283333, 1.5666666),
polygons: [
23,
21,
14,
15,
4294967295,
],
is_corner: true,
),
(
coords: (7.283333, -1.5833335),
polygons: [
15,
13,
7,
9,
4294967295,
],
is_corner: true,
),
(
coords: (0.9833336, -0.0666666),
polygons: [
13,
15,
14,
4294967295,
],
is_corner: true,
),
(
coords: (0.9833336, 1.5666666),
polygons: [
14,
21,
4294967295,
],
is_corner: true,
),
(
coords: (-0.41666698, 7.283333),
polygons: [
24,
25,
4294967295,
18,
16,
],
is_corner: true,
),
(
coords: (-0.3000002, 2.5),
polygons: [
24,
16,
4294967295,
],
is_corner: true,
),
(
coords: (-1.0, 2.5),
polygons: [
16,
18,
19,
17,
4294967295,
],
is_corner: true,
),
(
coords: (-1.0, 2.5),
polygons: [
16,
18,
19,
17,
4294967295,
],
is_corner: true,
),
(
coords: (-1.0, 1.5666666),
polygons: [
17,
11,
4294967295,
],
is_corner: true,
),
(
coords: (-7.3, 1.5666666),
polygons: [
12,
11,
17,
19,
4294967295,
],
is_corner: true,
),
(
coords: (-7.3, 7.283333),
polygons: [
19,
18,
4294967295,
],
is_corner: true,
),
(
coords: (-0.41666698, 7.283333),
polygons: [
24,
25,
4294967295,
18,
16,
],
is_corner: true,
),
(
coords: (-1.0, 2.5),
polygons: [
16,
18,
19,
17,
4294967295,
],
is_corner: true,
),
(
coords: (-7.3, 1.5666666),
polygons: [
12,
11,
17,
19,
4294967295,
],
is_corner: true,
),
(
coords: (0.9833336, 2.5),
polygons: [
21,
23,
22,
20,
4294967295,
],
is_corner: true,
),
(
coords: (0.2833333, 2.5),
polygons: [
20,
25,
24,
4294967295,
],
is_corner: true,
),
(
coords: (0.39999962, 7.283333),
polygons: [
20,
22,
4294967295,
25,
],
is_corner: true,
),
(
coords: (7.283333, 1.5666666),
polygons: [
23,
21,
14,
15,
4294967295,
],
is_corner: true,
),
(
coords: (0.9833336, 1.5666666),
polygons: [
14,
21,
4294967295,
],
is_corner: true,
),
(
coords: (0.9833336, 2.5),
polygons: [
21,
23,
22,
20,
4294967295,
],
is_corner: true,
),
(
coords: (0.9833336, 2.5),
polygons: [
21,
23,
22,
20,
4294967295,
],
is_corner: true,
),
(
coords: (0.39999962, 7.283333),
polygons: [
20,
22,
4294967295,
25,
],
is_corner: true,
),
(
coords: (7.283333, 7.283333),
polygons: [
22,
23,
4294967295,
],
is_corner: true,
),
(
coords: (7.283333, 1.5666666),
polygons: [
23,
21,
14,
15,
4294967295,
],
is_corner: true,
),
(
coords: (-0.41666698, 7.283333),
polygons: [
24,
25,
4294967295,
18,
16,
],
is_corner: true,
),
(
coords: (0.39999962, 7.283333),
polygons: [
20,
22,
4294967295,
25,
],
is_corner: true,
),
(
coords: (0.2833333, 2.5),
polygons: [
20,
25,
24,
4294967295,
],
is_corner: true,
),
(
coords: (-0.3000002, 2.5),
polygons: [
24,
16,
4294967295,
],
is_corner: true,
),
],
polygons: [
(
vertices: [
2,
1,
0,
],
is_one_way: false,
),
(
vertices: [
4,
3,
5,
],
is_one_way: false,
),
(
vertices: [
6,
8,
7,
],
is_one_way: false,
),
(
vertices: [
6,
9,
8,
],
is_one_way: false,
),
(
vertices: [
13,
11,
10,
],
is_one_way: false,
),
(
vertices: [
13,
12,
11,
],
is_one_way: false,
),
(
vertices: [
15,
14,
16,
],
is_one_way: false,
),
(
vertices: [
18,
17,
19,
],
is_one_way: false,
),
(
vertices: [
22,
20,
23,
],
is_one_way: false,
),
(
vertices: [
22,
21,
20,
],
is_one_way: false,
),
(
vertices: [
25,
24,
26,
],
is_one_way: false,
),
(
vertices: [
27,
29,
28,
],
is_one_way: false,
),
(
vertices: [
27,
30,
29,
],
is_one_way: false,
),
(
vertices: [
32,
31,
33,
],
is_one_way: false,
),
(
vertices: [
36,
34,
37,
],
is_one_way: false,
),
(
vertices: [
36,
35,
34,
],
is_one_way: false,
),
(
vertices: [
40,
39,
38,
],
is_one_way: false,
),
(
vertices: [
42,
41,
43,
],
is_one_way: false,
),
(
vertices: [
44,
46,
45,
],
is_one_way: false,
),
(
vertices: [
44,
47,
46,
],
is_one_way: false,
),
(
vertices: [
49,
48,
50,
],
is_one_way: false,
),
(
vertices: [
52,
51,
53,
],
is_one_way: false,
),
(
vertices: [
54,
56,
55,
],
is_one_way: false,
),
(
vertices: [
54,
57,
56,
],
is_one_way: false,
),
(
vertices: [
60,
58,
61,
],
is_one_way: false,
),
(
vertices: [
60,
59,
58,
],
is_one_way: false,
),
],
offset: (0.0, 0.0),
baked_polygons: None,
islands: None,
height: [
0.10000008,
0.10000008,
0.10000008,
0.10000008,
0.10000008,
0.10000008,
0.10000008,
0.10000008,
0.10000008,
0.10000008,
0.10000008,
0.10000008,
0.10000008,
0.10000008,
0.10000008,
0.10000008,
0.10000008,
0.10000008,
0.10000008,
0.10000008,
0.10000008,
0.10000008,
0.10000008,
0.10000008,
0.10000008,
0.10000008,
0.10000008,
0.10000008,
0.10000008,
0.10000008,
0.10000008,
0.10000008,
0.10000008,
0.10000008,
0.10000008,
0.10000008,
0.10000008,
0.10000008,
0.10000008,
0.10000008,
0.10000008,
0.10000008,
0.10000008,
0.10000008,
0.10000008,
0.10000008,
0.10000008,
0.10000008,
0.10000008,
0.10000008,
0.10000008,
0.10000008,
0.10000008,
0.10000008,
0.10000008,
0.10000008,
0.10000008,
0.10000008,
0.10000008,
0.10000008,
0.10000008,
0.10000008,
],
),
],
search_delta: 0.1,
search_steps: 2,
),
links: [
(
actor_id: "navigation-link-center",
start: (-1.5, 0.0, 0.0),
end: (1.5, 0.0, 0.0),
bidirectional: true,
cost: 1.2,
enabled: true,
),
],
areas: [
(
actor_id: "navigation-area-slow",
id: "slow",
center: (-4.0, 0.5, -4.0),
half_extents: (1.5, 1.0, 1.5),
cost: 1.5,
walkable: true,
),
],
samples: [
(
id: "around-center-obstacle",
start: (-6.0, 0.0, -5.0),
end: (6.0, 0.0, -5.0),
enabled: true,
),
],
diagnostics: (
polygon_count: 26,
island_count: 1,
effective_component_count: 1,
),
)

View File

@ -1,19 +1,17 @@
(schema_version: 2,
resources: {},
entities: {
1: (
components: {
"bevy_ecs::name::Name": "Example Base Mesh",
"bevy_transform::components::transform::Transform": (
(schema_version: 4,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": (),
},
),
},
)
"shared::components::ActorId": ("base-mesh"),
"shared::components::ActorKind": Empty,
"shared::components::EditorVisibility": (visible: true),
"shared::components::LevelObject": (),
}),
},
)

View File

@ -1,23 +1,21 @@
(schema_version: 2,
resources: {},
entities: {
1: (
components: {
"bevy_ecs::name::Name": "Example Base Link",
"bevy_transform::components::transform::Transform": (
(schema_version: 4,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": (
"shared::components::ActorId": ("base-link"),
"shared::components::ActorKind": PrefabAnchor,
"shared::components::LevelObject": (),
"shared::components::PrefabInstance": (
asset_id: "11111111-1111-4111-8111-111111111111",
source_path: "assets/prefabs/example_base.scn.ron",
overrides_ron: None,
),
},
),
},
)
}),
},
)

View File

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

View File

@ -0,0 +1,42 @@
#import bevy_pbr::{
pbr_fragment::pbr_input_from_standard_material,
pbr_functions::alpha_discard,
}
#ifdef PREPASS_PIPELINE
#import bevy_pbr::{
prepass_io::{VertexOutput, FragmentOutput},
pbr_deferred_functions::deferred_output,
}
#else
#import bevy_pbr::{
forward_io::{VertexOutput, FragmentOutput},
pbr_functions::{apply_pbr_lighting, main_pass_post_lighting_processing},
}
#endif
struct SurfaceUniform {
shader_id: u32,
flags: u32,
alpha_cutoff: f32,
abi_version: u32,
params: array<vec4<f32>, 16>,
uv_transforms: array<vec4<f32>, 8>,
}
@group(#{MATERIAL_BIND_GROUP}) @binding(100) var<uniform> surface_uniform: SurfaceUniform;
@fragment
fn fragment(in: VertexOutput, @builtin(front_facing) is_front: bool) -> FragmentOutput {
var pbr_input = pbr_input_from_standard_material(in, is_front);
pbr_input.material.base_color = alpha_discard(pbr_input.material, pbr_input.material.base_color);
#ifdef PREPASS_PIPELINE
return deferred_output(in, pbr_input);
#else
var out: FragmentOutput;
out.color = apply_pbr_lighting(pbr_input);
out.color = main_pass_post_lighting_processing(pbr_input, out.color);
return out;
#endif
}

View File

@ -1,18 +1,73 @@
(
schema_version: 1,
label: "Standard Lit",
kind: StandardLit,
wgsl_path: None,
parameters: [
(name: "base_color", display_name: "Base Color", group: "Surface", property_type: Color),
(name: "metallic", display_name: "Metallic", group: "Surface", property_type: Float(min: Some(0.0), max: Some(1.0))),
(name: "roughness", display_name: "Roughness", group: "Surface", property_type: Float(min: Some(0.0), max: Some(1.0))),
(name: "emissive_color", display_name: "Emissive Color", group: "Emission", property_type: Color),
(name: "emissive_intensity", display_name: "Emissive Nits", group: "Emission", property_type: Float(min: Some(0.0), max: Some(20000.0))),
(name: "base_color_texture", display_name: "Base Color Texture", group: "Textures", property_type: Texture),
(name: "normal_map_texture", display_name: "Normal Map", group: "Textures", property_type: Texture),
(name: "metallic_roughness_texture", display_name: "Metallic/Roughness Texture", group: "Textures", property_type: Texture),
(name: "emissive_texture", display_name: "Emissive Texture", group: "Textures", property_type: Texture),
(
name: "base_color",
display_name: "Base Color",
group: "Surface",
property_type: Color,
),
(
name: "metallic",
display_name: "Metallic",
group: "Surface",
property_type: Float(
min: Some(0.0),
max: Some(1.0),
),
),
(
name: "roughness",
display_name: "Roughness",
group: "Surface",
property_type: Float(
min: Some(0.0),
max: Some(1.0),
),
),
(
name: "emissive_color",
display_name: "Emissive Color",
group: "Emission",
property_type: Color,
),
(
name: "emissive_intensity",
display_name: "Emissive Nits",
group: "Emission",
property_type: Float(
min: Some(0.0),
max: Some(20000.0),
),
),
(
name: "base_color_texture",
display_name: "Base Color Texture",
group: "Textures",
property_type: Texture,
),
(
name: "normal_map_texture",
display_name: "Normal Map",
group: "Textures",
property_type: Texture,
),
(
name: "metallic_roughness_texture",
display_name: "Metallic/Roughness Texture",
group: "Textures",
property_type: Texture,
),
(
name: "emissive_texture",
display_name: "Emissive Texture",
group: "Textures",
property_type: Texture,
),
],
default_values: [],
default_textures: [],
)
)

View File

@ -0,0 +1,80 @@
(
schema_version: 1,
label: "Surface Tint",
kind: Custom,
wgsl_path: Some("assets/shaders/surface_tint.wgsl"),
parameters: [
(
name: "tint",
display_name: "Tint",
group: "Surface",
property_type: Color,
),
(
name: "roughness",
display_name: "Roughness",
group: "Surface",
property_type: Float(
min: Some(0.001),
max: Some(1.0),
),
),
(
name: "metallic",
display_name: "Metallic",
group: "Surface",
property_type: Float(
min: Some(0.0),
max: Some(1.0),
),
),
(
name: "emissive",
display_name: "Emissive",
group: "Emission",
property_type: Color,
),
(
name: "emissive_intensity",
display_name: "Emissive Nits",
group: "Emission",
property_type: Float(
min: Some(0.0),
max: Some(20000.0),
),
),
],
default_values: [
(
name: "tint",
value: Color((
r: 0.12,
g: 0.42,
b: 0.95,
a: 1.0,
)),
),
(
name: "roughness",
value: Float(0.28),
),
(
name: "metallic",
value: Float(0.15),
),
(
name: "emissive",
value: Color((
r: 0.02,
g: 0.08,
b: 0.25,
a: 1.0,
)),
),
(
name: "emissive_intensity",
value: Float(3.0),
),
],
default_textures: [],
)

View File

@ -0,0 +1,14 @@
fn evaluate(
input: SurfaceInput,
params: SurfaceParams,
samples: SurfaceSamples,
) -> Surface {
var surface = surface_default();
surface.base_color = params.lanes[0];
surface.perceptual_roughness = params.lanes[1].x;
surface.metallic = params.lanes[2].x;
surface.emissive = params.lanes[3].rgb * params.lanes[4].x;
surface.reflectance = 0.5;
surface.occlusion = 1.0;
return surface;
}

View File

@ -1,13 +1,37 @@
(
schema_version: 1,
label: "Unlit",
kind: Unlit,
wgsl_path: None,
parameters: [
(name: "base_color", display_name: "Base Color", group: "Surface", property_type: Color),
(name: "base_color_texture", display_name: "Base Color Texture", group: "Textures", property_type: Texture),
(name: "emissive_color", display_name: "Emissive Color", group: "Emission", property_type: Color),
(name: "emissive_intensity", display_name: "Emissive Nits", group: "Emission", property_type: Float(min: Some(0.0), max: Some(20000.0))),
(
name: "base_color",
display_name: "Base Color",
group: "Surface",
property_type: Color,
),
(
name: "base_color_texture",
display_name: "Base Color Texture",
group: "Textures",
property_type: Texture,
),
(
name: "emissive_color",
display_name: "Emissive Color",
group: "Emission",
property_type: Color,
),
(
name: "emissive_intensity",
display_name: "Emissive Nits",
group: "Emission",
property_type: Float(
min: Some(0.0),
max: Some(20000.0),
),
),
],
default_values: [],
default_textures: [],
)
)

View File

@ -0,0 +1,13 @@
[package]
name = "blacksite_surface"
version.workspace = true
edition.workspace = true
license.workspace = true
description = "Blacksite raster and ray-tracing Surface ABI material runtime"
[dependencies]
bevy.workspace = true
naga = { version = "29", features = ["wgsl-in"] }
ron = "0.8"
shared.workspace = true
uuid = "1"

View File

@ -0,0 +1,762 @@
//! Blacksite Surface ABI v1.
//!
//! The authoring layer persists named values. This crate validates and packs them into a fixed GPU
//! contract and supplies the same surface evaluator source to raster and Solari integrations.
use std::collections::HashMap;
use std::fs;
use bevy::mesh::MeshVertexBufferLayoutRef;
use bevy::pbr::{
ExtendedMaterial, MaterialExtension, MaterialExtensionKey, MaterialExtensionPipeline,
};
use bevy::prelude::*;
use bevy::render::extract_resource::ExtractResource;
use bevy::render::render_resource::{
AsBindGroup, RenderPipelineDescriptor, ShaderType, SpecializedMeshPipelineError,
};
use bevy::shader::{Shader, ShaderRef};
use shared::{
asset_server_path, material_from_desc, HydratedRendererMaterialBinding, MaterialAsset,
MaterialInstanceAsset, MaterialParameterValue, MaterialRef, ShaderPropertyType,
ShaderSchemaAsset, MATERIAL_INSTANCE_SCHEMA_VERSION,
};
pub const SURFACE_ABI_VERSION: u32 = 1;
pub const SURFACE_PARAMETER_LANES: usize = 16;
pub const SURFACE_TEXTURE_SLOTS: usize = 8;
pub const DEFAULT_SURFACE_SHADER_PATH: &str = "shaders/blacksite_surface.wgsl";
#[derive(ShaderType, Reflect, Debug, Clone, Copy, PartialEq)]
pub struct SurfaceUniform {
pub shader_id: u32,
pub flags: u32,
pub alpha_cutoff: f32,
pub abi_version: u32,
pub params: [Vec4; SURFACE_PARAMETER_LANES],
pub uv_transforms: [Vec4; SURFACE_TEXTURE_SLOTS],
}
impl Default for SurfaceUniform {
fn default() -> Self {
Self {
shader_id: 0,
flags: 0,
alpha_cutoff: 0.5,
abi_version: SURFACE_ABI_VERSION,
params: [Vec4::ZERO; SURFACE_PARAMETER_LANES],
uv_transforms: [Vec4::new(1.0, 1.0, 0.0, 0.0); SURFACE_TEXTURE_SLOTS],
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub struct SurfaceExtensionKey {
shader: AssetId<Shader>,
}
impl From<&SurfaceExtension> for SurfaceExtensionKey {
fn from(extension: &SurfaceExtension) -> Self {
Self {
shader: extension.shader.id(),
}
}
}
#[derive(Asset, AsBindGroup, Reflect, Debug, Clone, Default)]
#[bind_group_data(SurfaceExtensionKey)]
pub struct SurfaceExtension {
#[uniform(100)]
pub uniform: SurfaceUniform,
#[texture(101)]
#[sampler(102)]
pub texture0: Option<Handle<Image>>,
#[texture(103)]
#[sampler(104)]
pub texture1: Option<Handle<Image>>,
#[texture(105)]
#[sampler(106)]
pub texture2: Option<Handle<Image>>,
#[texture(107)]
#[sampler(108)]
pub texture3: Option<Handle<Image>>,
#[texture(109)]
#[sampler(110)]
pub texture4: Option<Handle<Image>>,
#[texture(111)]
#[sampler(112)]
pub texture5: Option<Handle<Image>>,
#[texture(113)]
#[sampler(114)]
pub texture6: Option<Handle<Image>>,
#[texture(115)]
#[sampler(116)]
pub texture7: Option<Handle<Image>>,
#[reflect(ignore)]
pub shader: Handle<Shader>,
}
impl SurfaceExtension {
pub fn set_texture(&mut self, index: usize, handle: Option<Handle<Image>>) {
match index {
0 => self.texture0 = handle,
1 => self.texture1 = handle,
2 => self.texture2 = handle,
3 => self.texture3 = handle,
4 => self.texture4 = handle,
5 => self.texture5 = handle,
6 => self.texture6 = handle,
7 => self.texture7 = handle,
_ => {}
}
}
}
impl MaterialExtension for SurfaceExtension {
fn fragment_shader() -> ShaderRef {
DEFAULT_SURFACE_SHADER_PATH.into()
}
fn deferred_fragment_shader() -> ShaderRef {
DEFAULT_SURFACE_SHADER_PATH.into()
}
fn prepass_fragment_shader() -> ShaderRef {
DEFAULT_SURFACE_SHADER_PATH.into()
}
fn specialize(
_pipeline: &MaterialExtensionPipeline,
descriptor: &mut RenderPipelineDescriptor,
_layout: &MeshVertexBufferLayoutRef,
key: MaterialExtensionKey<Self>,
) -> Result<(), SpecializedMeshPipelineError> {
if let (AssetId::Uuid { uuid }, Some(fragment)) =
(key.bind_group_data.shader, descriptor.fragment.as_mut())
{
fragment.shader = Handle::Uuid(uuid, Default::default());
}
Ok(())
}
}
pub type SurfaceMaterial = ExtendedMaterial<StandardMaterial, SurfaceExtension>;
#[derive(Resource, Default)]
pub struct SurfaceMaterialCache {
handles: HashMap<MaterialRef, Handle<SurfaceMaterial>>,
standard_handles: HashMap<MaterialRef, Handle<StandardMaterial>>,
revisions: HashMap<MaterialRef, u64>,
standard_only: HashMap<MaterialRef, u64>,
dependencies: HashMap<MaterialRef, Vec<String>>,
failed_revisions: HashMap<MaterialRef, u64>,
}
enum BuiltRendererMaterial {
Standard(Box<StandardMaterial>),
Surface(Box<SurfaceMaterial>),
}
#[derive(Resource, Default, Debug, Clone)]
pub struct SurfaceDiagnostics(pub Vec<String>);
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SurfaceEvaluatorRecord {
pub shader_id: u32,
pub evaluator_source: String,
pub revision: u64,
pub alpha_mode: shared::MaterialAlphaMode,
}
/// Active, validated evaluator generation consumed by both raster and Solari render worlds.
#[derive(Resource, ExtractResource, Default, Debug, Clone)]
pub struct SurfaceEvaluatorRegistry {
pub evaluators: HashMap<u32, SurfaceEvaluatorRecord>,
}
pub struct SurfaceMaterialPlugin;
impl Plugin for SurfaceMaterialPlugin {
fn build(&self, app: &mut App) {
app.add_plugins(MaterialPlugin::<SurfaceMaterial>::default())
.init_resource::<SurfaceMaterialCache>()
.init_resource::<SurfaceDiagnostics>()
.init_resource::<SurfaceEvaluatorRegistry>()
.add_systems(Update, sync_surface_material_bindings);
}
}
#[allow(clippy::too_many_arguments, clippy::type_complexity)]
fn sync_surface_material_bindings(
mut commands: Commands,
asset_server: Res<AssetServer>,
mut shaders: ResMut<Assets<Shader>>,
mut standard_materials: ResMut<Assets<StandardMaterial>>,
mut surface_materials: ResMut<Assets<SurfaceMaterial>>,
mut cache: ResMut<SurfaceMaterialCache>,
mut diagnostics: ResMut<SurfaceDiagnostics>,
mut evaluator_registry: ResMut<SurfaceEvaluatorRegistry>,
bindings: Query<(
Entity,
&HydratedRendererMaterialBinding,
Option<&MeshMaterial3d<StandardMaterial>>,
Option<&MeshMaterial3d<SurfaceMaterial>>,
)>,
) {
for (entity, binding, standard_handle, surface_handle) in &bindings {
let Some(reference) = binding.effective_material.as_ref() else {
continue;
};
let Some(path) = reference.0.source_path.as_deref() else {
continue;
};
let dependencies = cache
.dependencies
.get(reference)
.cloned()
.unwrap_or_else(|| vec![path.to_string()]);
let revision = dependency_revision(&dependencies);
if cache.failed_revisions.get(reference) == Some(&revision) {
continue;
}
if cache.standard_only.get(reference) == Some(&revision) {
if let Some(handle) = cache.standard_handles.get(reference).cloned() {
if standard_handle.is_none_or(|current| current.0 != handle)
|| surface_handle.is_some()
{
commands
.entity(entity)
.remove::<MeshMaterial3d<SurfaceMaterial>>()
.insert(MeshMaterial3d(handle));
}
}
continue;
}
let stale = cache.revisions.get(reference) != Some(&revision);
let handle = if !stale {
cache.handles.get(reference).cloned()
} else {
None
};
let handle = match handle {
Some(handle) => handle,
None => match build_surface_material(
reference,
path,
&asset_server,
&mut shaders,
&mut standard_materials,
standard_handle,
&mut evaluator_registry,
) {
Ok(BuiltRendererMaterial::Surface(material)) => {
let material = *material;
cache.standard_only.remove(reference);
cache.standard_handles.remove(reference);
cache.failed_revisions.remove(reference);
if let Some(existing) = cache.handles.get(reference).cloned() {
if let Some(mut slot) = surface_materials.get_mut(&existing) {
*slot = material;
}
existing
} else {
let handle = surface_materials.add(material);
cache.handles.insert(reference.clone(), handle.clone());
handle
}
}
Ok(BuiltRendererMaterial::Standard(material)) => {
let material = *material;
// Plain Material assets stay on Bevy's StandardMaterial path, while still
// sharing one live-updated handle across every renderer slot.
let handle = standard_handle
.map(|handle| handle.0.clone())
.or_else(|| cache.standard_handles.get(reference).cloned())
.unwrap_or_else(|| standard_materials.add(material.clone()));
if let Some(mut slot) = standard_materials.get_mut(&handle) {
*slot = material;
}
cache.handles.remove(reference);
cache.revisions.remove(reference);
cache
.standard_handles
.insert(reference.clone(), handle.clone());
let next_dependencies =
material_dependency_paths(path).unwrap_or_else(|_| vec![path.to_string()]);
let next_revision = dependency_revision(&next_dependencies);
cache
.dependencies
.insert(reference.clone(), next_dependencies);
cache.standard_only.insert(reference.clone(), next_revision);
cache.failed_revisions.remove(reference);
evaluator_registry
.evaluators
.remove(&stable_shader_id(reference));
if standard_handle.is_none_or(|current| current.0 != handle)
|| surface_handle.is_some()
{
commands
.entity(entity)
.remove::<MeshMaterial3d<SurfaceMaterial>>()
.insert(MeshMaterial3d(handle));
}
continue;
}
Err(error) => {
cache.failed_revisions.insert(reference.clone(), revision);
diagnostics.0.push(format!(
"material {} could not build Surface ABI: {error}",
reference.0.label
));
continue;
}
},
};
let next_dependencies =
material_dependency_paths(path).unwrap_or_else(|_| vec![path.to_string()]);
let next_revision = dependency_revision(&next_dependencies);
cache
.dependencies
.insert(reference.clone(), next_dependencies);
cache.revisions.insert(reference.clone(), next_revision);
if surface_handle.is_none_or(|current| current.0 != handle) {
commands
.entity(entity)
.remove::<MeshMaterial3d<StandardMaterial>>()
.insert(MeshMaterial3d(handle));
}
}
}
fn build_surface_material(
reference: &MaterialRef,
path: &str,
asset_server: &AssetServer,
shaders: &mut Assets<Shader>,
standard_materials: &mut Assets<StandardMaterial>,
existing_standard: Option<&MeshMaterial3d<StandardMaterial>>,
evaluator_registry: &mut SurfaceEvaluatorRegistry,
) -> Result<BuiltRendererMaterial, String> {
let (mut asset, instance) = match MaterialAsset::load_from_path(path) {
Ok(asset) => (asset, None),
Err(_) => {
let instance = MaterialInstanceAsset::load_from_path(path)?;
if instance.schema_version != MATERIAL_INSTANCE_SCHEMA_VERSION {
return Err(format!(
"unsupported material-instance schema {}",
instance.schema_version
));
}
let base_path = instance
.base
.0
.source_path
.as_deref()
.ok_or_else(|| "material instance base has no loadable path".to_string())?;
(MaterialAsset::load_from_path(base_path)?, Some(instance))
}
};
if let Some(instance) = instance.as_ref() {
instance.apply_to(&mut asset.material);
}
let schema_path = asset
.shader_ref
.as_ref()
.and_then(|reference| reference.source_path.as_deref())
.or(asset.material.shader.schema_path.as_deref())
.or(asset
.shader
.as_deref()
.filter(|path| path.ends_with(".ron")));
let schema = schema_path
.map(ShaderSchemaAsset::load_from_path)
.transpose()?;
let evaluator_source = schema
.as_ref()
.and_then(|schema| schema.wgsl_path.as_deref())
.map(fs::read_to_string)
.transpose()
.map_err(|error| format!("could not read surface WGSL: {error}"))?;
let mut base = existing_standard
.and_then(|handle| standard_materials.get(&handle.0).cloned())
.unwrap_or_else(|| material_from_desc(asset_server, &asset.material));
base.alpha_mode = match asset.render_state.alpha_mode {
shared::MaterialAlphaMode::Opaque => AlphaMode::Opaque,
shared::MaterialAlphaMode::Cutout => AlphaMode::Mask(asset.render_state.alpha_cutoff),
};
base.cull_mode =
(!asset.render_state.double_sided).then_some(bevy::render::render_resource::Face::Back);
let Some(evaluator) = evaluator_source.as_deref() else {
return Ok(BuiltRendererMaterial::Standard(Box::new(base)));
};
validate_surface_evaluator(evaluator)?;
let runtime_shader_id = stable_shader_id(reference);
evaluator_registry.evaluators.insert(
runtime_shader_id,
SurfaceEvaluatorRecord {
shader_id: runtime_shader_id,
evaluator_source: evaluator.to_string(),
revision: evaluator_revision(evaluator),
alpha_mode: asset.render_state.alpha_mode,
},
);
let virtual_path = format!("generated://surface/{}.wgsl", reference.0.asset_id);
let shader_uuid = shader_uuid_for_material(reference);
let shader_id = AssetId::<Shader>::Uuid { uuid: shader_uuid };
shaders
.insert(
shader_id,
Shader::from_wgsl(compose_surface_shader(evaluator), virtual_path),
)
.map_err(|error| format!("could not replace generated surface shader: {error}"))?;
let shader = Handle::Uuid(shader_uuid, Default::default());
let mut extension = SurfaceExtension {
shader,
..default()
};
extension.uniform.alpha_cutoff = asset.render_state.alpha_cutoff;
extension.uniform.shader_id = runtime_shader_id;
if let Some(schema) = schema.as_ref() {
pack_schema_values(
&mut extension,
schema,
&asset.material,
instance.as_ref(),
asset_server,
)?;
}
Ok(BuiltRendererMaterial::Surface(Box::new(ExtendedMaterial {
base,
extension,
})))
}
fn material_dependency_paths(path: &str) -> Result<Vec<String>, String> {
let mut dependencies = vec![path.to_string()];
let asset = match MaterialAsset::load_from_path(path) {
Ok(asset) => asset,
Err(_) => {
let instance = MaterialInstanceAsset::load_from_path(path)?;
let base_path = instance
.base
.0
.source_path
.as_deref()
.ok_or_else(|| "material instance base has no loadable path".to_string())?;
dependencies.push(base_path.to_string());
MaterialAsset::load_from_path(base_path)?
}
};
let schema_path = asset
.shader_ref
.as_ref()
.and_then(|reference| reference.source_path.as_deref())
.or(asset.material.shader.schema_path.as_deref())
.or(asset
.shader
.as_deref()
.filter(|path| path.ends_with(".ron")));
if let Some(schema_path) = schema_path {
dependencies.push(schema_path.to_string());
if let Some(wgsl_path) = ShaderSchemaAsset::load_from_path(schema_path)?.wgsl_path {
dependencies.push(wgsl_path);
}
}
dependencies.sort();
dependencies.dedup();
Ok(dependencies)
}
fn dependency_revision(paths: &[String]) -> u64 {
let mut hash = 0xcbf29ce484222325u64;
for path in paths {
for byte in path.bytes() {
hash ^= u64::from(byte);
hash = hash.wrapping_mul(0x100000001b3);
}
match fs::metadata(path) {
Ok(metadata) => {
for byte in metadata.len().to_le_bytes() {
hash ^= u64::from(byte);
hash = hash.wrapping_mul(0x100000001b3);
}
if let Ok(modified) = metadata.modified() {
if let Ok(duration) = modified.duration_since(std::time::UNIX_EPOCH) {
for byte in duration.as_nanos().to_le_bytes() {
hash ^= u64::from(byte);
hash = hash.wrapping_mul(0x100000001b3);
}
}
}
}
Err(_) => {
hash ^= u64::MAX;
hash = hash.wrapping_mul(0x100000001b3);
}
}
}
hash
}
fn stable_shader_id(reference: &MaterialRef) -> u32 {
let mut hash = 0x811c9dc5u32;
for byte in reference.0.asset_id.bytes() {
hash ^= u32::from(byte);
hash = hash.wrapping_mul(0x01000193);
}
hash
}
fn evaluator_revision(source: &str) -> u64 {
let mut hash = 0xcbf29ce484222325u64;
for byte in source.bytes() {
hash ^= u64::from(byte);
hash = hash.wrapping_mul(0x100000001b3);
}
hash
}
fn shader_uuid_for_material(reference: &MaterialRef) -> uuid::Uuid {
if let Ok(source) = uuid::Uuid::parse_str(&reference.0.asset_id) {
let mixed = source.as_u128() ^ 0x7f8c_4e90_f9ab_4f28_9b44_47c1_b2d5_5ac1u128;
return uuid::Uuid::from_u128(mixed);
}
let mut bytes = [0u8; 16];
for (index, byte) in reference.0.asset_id.bytes().enumerate() {
bytes[index % 16] = bytes[index % 16].wrapping_mul(31).wrapping_add(byte);
}
uuid::Uuid::from_bytes(bytes)
}
fn pack_schema_values(
extension: &mut SurfaceExtension,
schema: &ShaderSchemaAsset,
material: &shared::MaterialDesc,
instance: Option<&MaterialInstanceAsset>,
asset_server: &AssetServer,
) -> Result<(), String> {
let mut numeric_index = 0usize;
let mut texture_index = 0usize;
for property in &schema.parameters {
if matches!(property.property_type, ShaderPropertyType::Texture) {
if texture_index >= SURFACE_TEXTURE_SLOTS {
return Err(format!(
"surface schema exceeds {SURFACE_TEXTURE_SLOTS} textures"
));
}
let binding = instance
.and_then(|instance| {
instance
.textures
.iter()
.find(|value| value.name == property.name)
})
.or_else(|| {
material
.textures
.iter()
.find(|value| value.name == property.name)
});
let handle = binding
.and_then(|binding| binding.texture.as_ref())
.and_then(|reference| reference.source_path.as_deref())
.map(asset_server_path)
.map(|path| asset_server.load(path));
extension.set_texture(texture_index, handle);
texture_index += 1;
continue;
}
if numeric_index >= SURFACE_PARAMETER_LANES {
return Err(format!(
"surface schema exceeds {SURFACE_PARAMETER_LANES} parameter lanes"
));
}
let value = instance
.and_then(|instance| {
instance
.parameters
.iter()
.find(|value| value.name == property.name)
})
.or_else(|| {
material
.parameters
.iter()
.find(|value| value.name == property.name)
})
.or_else(|| {
schema
.default_values
.iter()
.find(|value| value.name == property.name)
});
extension.uniform.params[numeric_index] = value
.map(|value| parameter_lane(&value.value, &property.property_type))
.transpose()?
.unwrap_or(Vec4::ZERO);
numeric_index += 1;
}
Ok(())
}
fn parameter_lane(
value: &MaterialParameterValue,
property_type: &ShaderPropertyType,
) -> Result<Vec4, String> {
match (value, property_type) {
(MaterialParameterValue::Bool(value), ShaderPropertyType::Bool) => {
Ok(Vec4::new(u32::from(*value) as f32, 0.0, 0.0, 0.0))
}
(MaterialParameterValue::Float(value), ShaderPropertyType::Float { .. }) => {
Ok(Vec4::new(*value, 0.0, 0.0, 0.0))
}
(MaterialParameterValue::Vec2(value), ShaderPropertyType::Vec2) => {
Ok(Vec4::new(value.x, value.y, 0.0, 0.0))
}
(MaterialParameterValue::Vec3(value), ShaderPropertyType::Vec3) => Ok(value.extend(0.0)),
(MaterialParameterValue::Color(value), ShaderPropertyType::Color) => {
Ok(Vec4::new(value.r, value.g, value.b, value.a))
}
(MaterialParameterValue::Enum(value), ShaderPropertyType::Enum { options }) => {
let index = options
.iter()
.position(|option| option == value)
.ok_or_else(|| format!("enum value `{value}` is not declared by the schema"))?;
Ok(Vec4::new(index as f32, 0.0, 0.0, 0.0))
}
_ => Err("material value does not match shader schema type".into()),
}
}
pub fn validate_surface_evaluator(source: &str) -> Result<(), String> {
if !source.contains("fn evaluate(") {
return Err("surface WGSL must define `fn evaluate(`".into());
}
for forbidden in [
"@group",
"@binding",
"@vertex",
"@fragment",
"@compute",
"var<",
"rayQuery",
"dpdx",
"dpdy",
"fwidth",
"discard",
"Barrier",
"subgroup",
"textureStore",
"atomic",
] {
if source.contains(forbidden) {
return Err(format!(
"surface WGSL uses forbidden construct `{forbidden}`"
));
}
}
let validation_source = format!("{EVALUATOR_VALIDATION_PRELUDE}\n{source}");
let module = naga::front::wgsl::parse_str(&validation_source)
.map_err(|error| format!("surface WGSL syntax error: {error}"))?;
naga::valid::Validator::new(
naga::valid::ValidationFlags::all(),
naga::valid::Capabilities::empty(),
)
.validate(&module)
.map_err(|error| format!("surface WGSL validation error: {error}"))?;
Ok(())
}
const EVALUATOR_VALIDATION_PRELUDE: &str = r#"
struct SurfaceInput {
uv0: vec2<f32>,
world_position: vec3<f32>,
world_normal: vec3<f32>,
}
struct SurfaceParams { lanes: array<vec4<f32>, 16>, }
struct SurfaceSamples { values: array<vec4<f32>, 8>, }
struct Surface {
base_color: vec4<f32>,
normal_ts: vec3<f32>,
emissive: vec3<f32>,
metallic: f32,
perceptual_roughness: f32,
reflectance: f32,
occlusion: f32,
model: u32,
}
fn surface_default() -> Surface {
var surface: Surface;
surface.base_color = vec4<f32>(1.0);
surface.normal_ts = vec3<f32>(0.0, 0.0, 1.0);
surface.emissive = vec3<f32>(0.0);
surface.metallic = 0.0;
surface.perceptual_roughness = 0.5;
surface.reflectance = 0.5;
surface.occlusion = 1.0;
surface.model = 0u;
return surface;
}
"#;
pub fn compose_surface_shader(evaluator: &str) -> String {
format!(
"{}\n{}\n{}",
include_str!("surface_header.wgsl"),
evaluator,
include_str!("surface_footer.wgsl")
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn rejects_resource_bindings_and_missing_entry() {
assert!(validate_surface_evaluator("fn nope() {}").is_err());
assert!(validate_surface_evaluator(
"@group(0) @binding(0) var t: texture_2d<f32>; fn evaluate() {}"
)
.is_err());
for compute_incompatible in [
"dpdx(input.uv0.x)",
"dpdy(input.uv0.y)",
"fwidth(input.uv0.x)",
"discard",
"workgroupBarrier()",
"subgroupAdd(1u)",
] {
let source = format!(
"fn evaluate(input: SurfaceInput, params: SurfaceParams, samples: SurfaceSamples) -> Surface {{ let invalid = {compute_incompatible}; return surface_default(); }}"
);
assert!(
validate_surface_evaluator(&source).is_err(),
"accepted compute-incompatible evaluator: {compute_incompatible}"
);
}
assert!(validate_surface_evaluator(
"fn evaluate(input: SurfaceInput, params: SurfaceParams, samples: SurfaceSamples) -> Surface { return missing_symbol; }"
)
.is_err());
}
#[test]
fn accepts_surface_only_module_and_composes_wrapper() {
let source = "fn evaluate(input: SurfaceInput, params: SurfaceParams, samples: SurfaceSamples) -> Surface { return surface_default(); }";
validate_surface_evaluator(source).unwrap();
let composed = compose_surface_shader(source);
assert!(composed.contains(source));
assert!(composed.contains("@fragment"));
}
#[test]
fn abi_limits_are_stable() {
assert_eq!(SURFACE_PARAMETER_LANES, 16);
assert_eq!(SURFACE_TEXTURE_SLOTS, 8);
assert_eq!(std::mem::size_of::<SurfaceUniform>(), 400);
}
}

View File

@ -0,0 +1,41 @@
@fragment
fn fragment(in: VertexOutput, @builtin(front_facing) is_front: bool) -> FragmentOutput {
var pbr_input = pbr_input_from_standard_material(in, is_front);
#ifdef VERTEX_UVS_A
let uv0 = in.uv;
#else
let uv0 = vec2<f32>(0.0);
#endif
let input = SurfaceInput(uv0, pbr_input.world_position.xyz, pbr_input.N);
let params = SurfaceParams(surface_uniform.params);
let surface = evaluate(input, params, surface_samples(uv0));
pbr_input.material.base_color = surface.base_color;
pbr_input.material.emissive = vec4<f32>(surface.emissive, 1.0);
pbr_input.material.metallic = clamp(surface.metallic, 0.0, 1.0);
pbr_input.material.perceptual_roughness = clamp(surface.perceptual_roughness, 0.001, 1.0);
pbr_input.material.reflectance = vec3<f32>(clamp(surface.reflectance, 0.0, 1.0));
pbr_input.diffuse_occlusion = vec3<f32>(clamp(surface.occlusion, 0.0, 1.0));
#ifdef VERTEX_TANGENTS
let surface_tbn = calculate_tbn_mikktspace(pbr_input.world_normal, in.world_tangent);
let surface_nt = normalize(surface.normal_ts);
pbr_input.N = normalize(
surface_nt.x * surface_tbn[0]
+ surface_nt.y * surface_tbn[1]
+ surface_nt.z * surface_tbn[2]
);
#endif
if surface.model == BLACKSITE_SURFACE_UNLIT {
pbr_input.material.flags |= STANDARD_MATERIAL_FLAGS_UNLIT_BIT;
} else {
pbr_input.material.flags &= ~STANDARD_MATERIAL_FLAGS_UNLIT_BIT;
}
pbr_input.material.base_color = alpha_discard(pbr_input.material, pbr_input.material.base_color);
#ifdef PREPASS_PIPELINE
return deferred_output(in, pbr_input);
#else
var out: FragmentOutput;
out.color = apply_pbr_lighting(pbr_input);
out.color = main_pass_post_lighting_processing(pbr_input, out.color);
return out;
#endif
}

View File

@ -0,0 +1,103 @@
#import bevy_pbr::{
pbr_fragment::pbr_input_from_standard_material,
pbr_functions::{alpha_discard, calculate_tbn_mikktspace},
}
#ifdef PREPASS_PIPELINE
#import bevy_pbr::{
prepass_io::{VertexOutput, FragmentOutput},
pbr_deferred_functions::deferred_output,
}
#else
#import bevy_pbr::{
forward_io::{VertexOutput, FragmentOutput},
pbr_functions::{apply_pbr_lighting, main_pass_post_lighting_processing},
}
#endif
const BLACKSITE_SURFACE_ABI_VERSION: u32 = 1u;
const BLACKSITE_SURFACE_UNLIT: u32 = 1u;
const STANDARD_MATERIAL_FLAGS_UNLIT_BIT: u32 = 1u << 5u;
struct SurfaceUniform {
shader_id: u32,
flags: u32,
alpha_cutoff: f32,
abi_version: u32,
params: array<vec4<f32>, 16>,
uv_transforms: array<vec4<f32>, 8>,
}
@group(#{MATERIAL_BIND_GROUP}) @binding(100) var<uniform> surface_uniform: SurfaceUniform;
@group(#{MATERIAL_BIND_GROUP}) @binding(101) var surface_texture0: texture_2d<f32>;
@group(#{MATERIAL_BIND_GROUP}) @binding(102) var surface_sampler0: sampler;
@group(#{MATERIAL_BIND_GROUP}) @binding(103) var surface_texture1: texture_2d<f32>;
@group(#{MATERIAL_BIND_GROUP}) @binding(104) var surface_sampler1: sampler;
@group(#{MATERIAL_BIND_GROUP}) @binding(105) var surface_texture2: texture_2d<f32>;
@group(#{MATERIAL_BIND_GROUP}) @binding(106) var surface_sampler2: sampler;
@group(#{MATERIAL_BIND_GROUP}) @binding(107) var surface_texture3: texture_2d<f32>;
@group(#{MATERIAL_BIND_GROUP}) @binding(108) var surface_sampler3: sampler;
@group(#{MATERIAL_BIND_GROUP}) @binding(109) var surface_texture4: texture_2d<f32>;
@group(#{MATERIAL_BIND_GROUP}) @binding(110) var surface_sampler4: sampler;
@group(#{MATERIAL_BIND_GROUP}) @binding(111) var surface_texture5: texture_2d<f32>;
@group(#{MATERIAL_BIND_GROUP}) @binding(112) var surface_sampler5: sampler;
@group(#{MATERIAL_BIND_GROUP}) @binding(113) var surface_texture6: texture_2d<f32>;
@group(#{MATERIAL_BIND_GROUP}) @binding(114) var surface_sampler6: sampler;
@group(#{MATERIAL_BIND_GROUP}) @binding(115) var surface_texture7: texture_2d<f32>;
@group(#{MATERIAL_BIND_GROUP}) @binding(116) var surface_sampler7: sampler;
struct SurfaceInput {
uv0: vec2<f32>,
world_position: vec3<f32>,
world_normal: vec3<f32>,
}
struct SurfaceParams {
lanes: array<vec4<f32>, 16>,
}
struct SurfaceSamples {
values: array<vec4<f32>, 8>,
}
struct Surface {
base_color: vec4<f32>,
normal_ts: vec3<f32>,
emissive: vec3<f32>,
metallic: f32,
perceptual_roughness: f32,
reflectance: f32,
occlusion: f32,
model: u32,
}
fn surface_default() -> Surface {
var surface: Surface;
surface.base_color = vec4<f32>(1.0);
surface.normal_ts = vec3<f32>(0.0, 0.0, 1.0);
surface.emissive = vec3<f32>(0.0);
surface.metallic = 0.0;
surface.perceptual_roughness = 0.5;
surface.reflectance = 0.5;
surface.occlusion = 1.0;
surface.model = 0u;
return surface;
}
fn surface_uv(index: u32, uv: vec2<f32>) -> vec2<f32> {
let transform = surface_uniform.uv_transforms[index];
return uv * transform.xy + transform.zw;
}
fn surface_samples(uv: vec2<f32>) -> SurfaceSamples {
var samples: SurfaceSamples;
samples.values[0] = textureSample(surface_texture0, surface_sampler0, surface_uv(0u, uv));
samples.values[1] = textureSample(surface_texture1, surface_sampler1, surface_uv(1u, uv));
samples.values[2] = textureSample(surface_texture2, surface_sampler2, surface_uv(2u, uv));
samples.values[3] = textureSample(surface_texture3, surface_sampler3, surface_uv(3u, uv));
samples.values[4] = textureSample(surface_texture4, surface_sampler4, surface_uv(4u, uv));
samples.values[5] = textureSample(surface_texture5, surface_sampler5, surface_uv(5u, uv));
samples.values[6] = textureSample(surface_texture6, surface_sampler6, surface_uv(6u, uv));
samples.values[7] = textureSample(surface_texture7, surface_sampler7, surface_uv(7u, uv));
return samples;
}

View File

@ -33,6 +33,7 @@ egui_phosphor_icons.workspace = true
bevy_ufbx.workspace = true
ufbx = "0.9"
game.workspace = true
polyanya.workspace = true
game_hot.workspace = true
hot-lib-reloader = { version = "0.8.2", optional = true }
notify = { version = "6.1", optional = true }

View File

@ -210,6 +210,13 @@ fn build_gltf_manifest(
});
}
let default_animation_clip_id = record.import_settings.default_animation_clip_id.clone();
validate_default_animation_clip(
default_animation_clip_id.as_deref(),
&clips,
&mut diagnostics,
);
let runtime_supported = animation_roots.len() <= 1;
if !runtime_supported {
let roots = animation_roots
@ -232,6 +239,7 @@ fn build_gltf_manifest(
schema_version: ANIMATION_MANIFEST_SCHEMA_VERSION,
asset_id: record.id.as_string(),
label: record.label.clone(),
default_animation_clip_id,
source: AnimationManifestSource {
path: record.path.clone(),
format,
@ -313,15 +321,22 @@ fn build_fbx_manifest(
})
.collect::<Vec<_>>();
let (runtime_supported, diagnostics) = fbx_runtime_support(
let (runtime_supported, mut diagnostics) = fbx_runtime_support(
scene.anim_stacks.as_ref().len(),
scene.skin_deformers.as_ref().len(),
);
let default_animation_clip_id = record.import_settings.default_animation_clip_id.clone();
validate_default_animation_clip(
default_animation_clip_id.as_deref(),
&clips,
&mut diagnostics,
);
Ok(AnimationManifest {
schema_version: ANIMATION_MANIFEST_SCHEMA_VERSION,
asset_id: record.id.as_string(),
label: record.label.clone(),
default_animation_clip_id,
source: AnimationManifestSource {
path: record.path.clone(),
format,
@ -335,6 +350,27 @@ fn build_fbx_manifest(
})
}
fn validate_default_animation_clip(
default_clip_id: Option<&str>,
clips: &[AnimationClipRecord],
diagnostics: &mut Vec<AnimationImportDiagnostic>,
) {
let Some(default_clip_id) = default_clip_id.filter(|id| !id.trim().is_empty()) else {
return;
};
if clips.iter().any(|clip| clip.id == default_clip_id) {
return;
}
diagnostics.push(AnimationImportDiagnostic {
severity: AnimationDiagnosticSeverity::Error,
code: "animation.default_clip_missing".into(),
message: format!(
"configured default animation clip `{default_clip_id}` no longer exists in the imported source"
),
repair: "Choose an existing Default animation in Model Import Settings, or select Imported rest pose, then reimport.".into(),
});
}
fn fbx_runtime_support(
animation_count: usize,
skin_count: usize,
@ -696,7 +732,8 @@ mod tests {
let second = build_animation_manifest(&record).unwrap();
assert_eq!(first, second);
assert_eq!(first.schema_version, 2);
assert_eq!(first.schema_version, ANIMATION_MANIFEST_SCHEMA_VERSION);
assert!(first.default_animation_clip_id.is_none());
assert!(first.runtime_supported);
assert_eq!(first.source.dependencies.len(), 1);
assert_eq!(first.skeletons.len(), 1);
@ -717,6 +754,32 @@ mod tests {
fs::remove_dir_all(root).unwrap();
}
#[test]
fn explicit_default_clip_is_stable_and_stale_ids_are_diagnostic() {
let root =
std::env::temp_dir().join(format!("blacksite-animation-default-{}", Uuid::new_v4()));
let path = write_animated_gltf(&root);
let mut record = fixture_record(&path);
record.import_settings.default_animation_clip_id =
Some("animation:clip:0:idle_loop".into());
let manifest = build_animation_manifest(&record).unwrap();
assert_eq!(
manifest.default_animation_clip_id.as_deref(),
Some("animation:clip:0:idle_loop")
);
assert!(manifest.diagnostics.is_empty());
record.import_settings.default_animation_clip_id = Some("animation:clip:99:removed".into());
let stale = build_animation_manifest(&record).unwrap();
assert!(stale.diagnostics.iter().any(|diagnostic| {
diagnostic.code == "animation.default_clip_missing"
&& diagnostic.severity == AnimationDiagnosticSeverity::Error
}));
fs::remove_dir_all(root).unwrap();
}
#[test]
fn exact_signature_changes_with_joint_path_or_bind_pose() {
let identity = identity_matrix_bytes();

View File

@ -27,8 +27,12 @@ impl Default for AssetId {
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
pub enum ModelPlacementMode {
/// Place renderable content through the appropriate normalized renderer. Imports containing
/// skins or animation route to `SkinnedMeshRenderer`; unrigged, non-animated content routes to
/// `StaticMeshRenderer`.
#[default]
StaticAsset,
/// Instantiate the complete source scene as a generic imported model.
SceneInstance,
}
@ -61,6 +65,10 @@ pub struct ImportSettings {
pub static_mesh_manifest_path: Option<String>,
#[serde(default)]
pub animation_manifest_path: Option<String>,
/// Stable animation clip sub-asset ID used as this model's edit-mode rest presentation.
/// `None` preserves the imported node pose and never guesses a clip.
#[serde(default)]
pub default_animation_clip_id: Option<String>,
}
impl Default for ImportSettings {
@ -74,6 +82,7 @@ impl Default for ImportSettings {
material_policy: MaterialImportPolicy::default(),
static_mesh_manifest_path: None,
animation_manifest_path: None,
default_animation_clip_id: None,
}
}
}
@ -336,6 +345,7 @@ mod tests {
schema_version: ANIMATION_MANIFEST_SCHEMA_VERSION,
asset_id: id.as_string(),
label: "Moved Model".into(),
default_animation_clip_id: None,
source: AnimationManifestSource {
path: old_path.clone(),
format: "glb".into(),

View File

@ -524,6 +524,7 @@ fn empty_snapshot_for_asset(asset: &EditorAsset, translation: Vec3) -> EditorEnt
primitive: None,
brush: None,
static_mesh_renderer: None,
skinned_mesh_renderer: None,
material: None,
material_override: None,
rigid_body: None,
@ -542,8 +543,18 @@ fn empty_snapshot_for_asset(asset: &EditorAsset, translation: Vec3) -> EditorEnt
post_process_volume: None,
team_spawn: None,
objective: None,
navigation_bounds: None,
navigation_obstacle: None,
navigation_area: None,
navigation_link: None,
hierarchy_sibling_index: 0,
editor_visibility: EditorVisibility::default(),
inspector_order: None,
component_states: None,
children: Vec::new(),
}
}
@ -672,17 +683,33 @@ pub fn spawn_asset_at(world: &mut World, asset: &EditorAsset, translation: Vec3)
record.import_settings.placement_mode,
ModelPlacementMode::StaticAsset
) {
if let Some(renderer) = static_mesh_renderer_for_asset(world, path) {
let collider = record
.import_settings
.generate_collider
.then(|| static_mesh_collider_for_renderer(&renderer));
apply_static_mesh_placement_mode(
&mut snapshot,
renderer,
collider,
record.import_settings.hierarchy_mode,
);
if let Some((renderer, requires_skinned_renderer)) =
static_mesh_renderer_for_asset(world, path)
{
if requires_skinned_renderer {
apply_skinned_mesh_placement_mode_with_materials(
&mut snapshot,
renderer.materials.clone(),
);
match default_animation_controller_for_record(&record) {
Ok(controller) => snapshot.animation_controller = controller,
Err(error) => warn!(
"Default animation was not applied while placing {}: {error}",
record.path
),
}
} else {
let collider = record
.import_settings
.generate_collider
.then(|| static_mesh_collider_for_renderer(&renderer));
apply_static_mesh_placement_mode(
&mut snapshot,
renderer,
collider,
record.import_settings.hierarchy_mode,
);
}
} else {
warn!(
"Static mesh placement fell back to SceneRoot for {} because no renderer manifest was available",
@ -760,6 +787,11 @@ pub fn spawn_subasset_at(
.parts
.iter()
.find(|part| part_effective_id_for_selection(part) == *sub_asset_id)?;
let part_requires_skinned_renderer = manifest.metadata.animation_count > 0
|| part.skinned
|| (manifest.schema_version < 2
&& crate::assets::gltf_skinned_primitive_labels(parent_path)
.contains(&part.mesh_label));
let mesh_ref = shared::EditorAssetRef::new(
manifest.asset_id.clone(),
@ -784,6 +816,10 @@ pub fn spawn_subasset_at(
id: shared::ComponentInstanceId::new("slot:0"),
name: label.clone(),
mesh: mesh_ref.clone(),
material_slot_id: shared::ComponentInstanceId::new(format!(
"slot:{}",
part_effective_id_for_selection(part)
)),
material,
local_transform: part.local_transform,
visible: true,
@ -800,6 +836,7 @@ pub fn spawn_subasset_at(
primitive: None,
brush: None,
static_mesh_renderer: Some(shared::StaticMeshRenderer::single(slot)),
skinned_mesh_renderer: None,
material: None,
material_override: None,
rigid_body: None,
@ -818,11 +855,41 @@ pub fn spawn_subasset_at(
post_process_volume: None,
team_spawn: None,
objective: None,
navigation_bounds: None,
navigation_obstacle: None,
navigation_area: None,
navigation_link: None,
hierarchy_sibling_index: 0,
editor_visibility: EditorVisibility::default(),
inspector_order: None,
component_states: None,
children: Vec::new(),
};
snapshot.transform.scale *= record.import_settings.scale;
if part_requires_skinned_renderer {
snapshot.actor_kind = ActorKind::SkinnedMesh;
snapshot.static_mesh_renderer = None;
snapshot.skinned_mesh_renderer = Some(shared::SkinnedMeshRenderer {
materials: crate::assets::static_mesh::renderer_materials_from_manifest(
&manifest,
&record.import_settings,
),
..shared::SkinnedMeshRenderer::new(parent_path.clone())
.with_asset_id(record.id.as_string())
});
match default_animation_controller_for_record(&record) {
Ok(controller) => snapshot.animation_controller = controller,
Err(error) => warn!(
"Default animation was not applied while placing {}: {error}",
record.path
),
}
return Some(spawn_with_history(world, snapshot));
}
if record.import_settings.generate_collider {
snapshot.rigid_body = Some(RigidBodyDesc::default());
snapshot.collider = Some(ColliderDesc::static_mesh(vec![mesh_ref]));
@ -840,6 +907,7 @@ pub struct AnimationClipAuthoringData {
pub skeleton: shared::EditorAssetRef,
pub skeleton_signature: shared::AnimationSkeletonSignature,
pub state: shared::AnimationStateDesc,
pub renderer_materials: shared::RendererMaterialSet,
}
pub fn animation_clip_authoring_data(
@ -905,6 +973,18 @@ pub fn animation_clip_authoring_data(
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);
let renderer_materials = record
.import_settings
.static_mesh_manifest_path
.as_deref()
.and_then(|path| load_static_mesh_manifest(path).ok())
.map(|render_manifest| {
crate::assets::static_mesh::renderer_materials_from_manifest(
&render_manifest,
&record.import_settings,
)
})
.unwrap_or_default();
Ok(AnimationClipAuthoringData {
model_asset_id: record.id.as_string(),
model_path: parent_path.clone(),
@ -920,6 +1000,7 @@ pub fn animation_clip_authoring_data(
speed: 1.0,
range: shared::AnimationPlaybackRange::default(),
},
renderer_materials,
})
}
@ -957,7 +1038,7 @@ pub fn animation_skeleton_signature_for_ref(
pub fn compatible_animation_skeleton_for_model(
world: &World,
model: &ModelRef,
model: &shared::SkinnedMeshRenderer,
signature: &shared::AnimationSkeletonSignature,
) -> Result<shared::EditorAssetRef, String> {
let record = world
@ -1017,6 +1098,82 @@ fn animation_state_id(label: &str, source_index: usize) -> String {
}
}
fn default_animation_controller_for_record(
record: &crate::asset_db::AssetRecord,
) -> Result<Option<shared::AnimationControllerDesc>, String> {
let Some(manifest_path) = record.import_settings.animation_manifest_path.as_deref() else {
return Ok(None);
};
let manifest = load_animation_manifest(manifest_path)?;
if manifest.default_animation_clip_id != record.import_settings.default_animation_clip_id {
return Err(
"model import settings and generated animation manifest disagree; reimport the model"
.into(),
);
}
let Some(default_clip_id) = manifest
.default_animation_clip_id
.as_deref()
.filter(|id| !id.trim().is_empty())
else {
return Ok(None);
};
if !manifest.runtime_supported {
return Err(format!(
"configured clip `{default_clip_id}` belongs to a model unsupported by the animation runtime"
));
}
let clip = manifest
.clips
.iter()
.find(|clip| clip.id == default_clip_id)
.ok_or_else(|| {
format!(
"configured clip `{default_clip_id}` is missing from {manifest_path}; choose an existing clip or Imported rest pose"
)
})?;
let signature = clip.target_skeleton_signature.as_ref().ok_or_else(|| {
format!(
"configured clip `{}` has no uniquely resolved target skeleton",
clip.label
)
})?;
let skeleton = manifest
.skeletons
.iter()
.find(|skeleton| &skeleton.signature == signature)
.ok_or_else(|| {
format!(
"configured clip `{}` targets skeleton `{}` but no exact match exists",
clip.label, signature.0
)
})?;
let state_id = animation_state_id(&clip.label, clip.source_index);
let source_path = manifest.source.path.clone();
let asset_id = manifest.asset_id.clone();
Ok(Some(shared::AnimationControllerDesc {
skeleton: Some(
shared::EditorAssetRef::new(
asset_id.clone(),
skeleton.id.clone(),
skeleton.label.clone(),
)
.with_source_path(&source_path),
),
states: vec![shared::AnimationStateDesc {
id: state_id.clone(),
label: clip.label.clone(),
clip: shared::EditorAssetRef::new(asset_id, clip.id.clone(), clip.label.clone())
.with_source_path(source_path),
looping: true,
speed: 1.0,
range: shared::AnimationPlaybackRange::default(),
}],
default_state: state_id,
..default()
}))
}
fn spawn_animation_clip_at(
world: &mut World,
selection: &AssetSelection,
@ -1035,7 +1192,7 @@ fn spawn_animation_clip_at(
let default_state = authored.state.id.clone();
let snapshot = EditorEntitySnapshot {
actor_id: None,
actor_kind: ActorKind::ImportedModel,
actor_kind: ActorKind::SkinnedMesh,
actor_name: None,
name: Some(authored.model_label),
transform: Transform {
@ -1046,6 +1203,11 @@ fn spawn_animation_clip_at(
primitive: None,
brush: None,
static_mesh_renderer: None,
skinned_mesh_renderer: Some(shared::SkinnedMeshRenderer {
materials: authored.renderer_materials,
..shared::SkinnedMeshRenderer::new(authored.model_path)
.with_asset_id(authored.model_asset_id)
}),
material: None,
material_override: None,
rigid_body: None,
@ -1061,7 +1223,7 @@ fn spawn_animation_clip_at(
audio_source: None,
audio_listener: None,
player_spawn: false,
model: Some(ModelRef::new(authored.model_path).with_asset_id(authored.model_asset_id)),
model: None,
prefab: None,
prefab_instance: None,
weapon_spawn: None,
@ -1069,8 +1231,18 @@ fn spawn_animation_clip_at(
post_process_volume: None,
team_spawn: None,
objective: None,
navigation_bounds: None,
navigation_obstacle: None,
navigation_area: None,
navigation_link: None,
hierarchy_sibling_index: 0,
editor_visibility: EditorVisibility::default(),
inspector_order: None,
component_states: None,
children: Vec::new(),
};
Some(spawn_with_history(world, snapshot))
@ -1118,6 +1290,7 @@ fn apply_static_mesh_placement_mode(
primitive: None,
brush: None,
static_mesh_renderer: Some(shared::StaticMeshRenderer::single(entry)),
skinned_mesh_renderer: None,
material: None,
material_override: None,
rigid_body: collider.as_ref().map(|_| RigidBodyDesc::default()),
@ -1136,8 +1309,18 @@ fn apply_static_mesh_placement_mode(
post_process_volume: None,
team_spawn: None,
objective: None,
navigation_bounds: None,
navigation_obstacle: None,
navigation_area: None,
navigation_link: None,
hierarchy_sibling_index: index as i32,
editor_visibility: EditorVisibility::default(),
inspector_order: None,
component_states: None,
children: Vec::new(),
}
})
@ -1146,6 +1329,35 @@ fn apply_static_mesh_placement_mode(
}
}
#[cfg(test)]
fn apply_skinned_mesh_placement_mode(snapshot: &mut EditorEntitySnapshot) {
apply_skinned_mesh_placement_mode_with_materials(
snapshot,
shared::RendererMaterialSet::default(),
);
}
fn apply_skinned_mesh_placement_mode_with_materials(
snapshot: &mut EditorEntitySnapshot,
materials: shared::RendererMaterialSet,
) {
let Some(model) = snapshot.model.take() else {
return;
};
snapshot.actor_kind = ActorKind::SkinnedMesh;
snapshot.static_mesh_renderer = None;
snapshot.skinned_mesh_renderer = Some(shared::SkinnedMeshRenderer {
asset_id: model.asset_id,
path: model.path,
scene_index: model.scene_index,
materials,
});
// Triangle-mesh colliders are a static-geometry concern. Animated collision must be authored
// explicitly (capsules, hitboxes, or another gameplay-specific representation).
snapshot.rigid_body = None;
snapshot.collider = None;
}
fn static_mesh_collider_for_renderer(renderer: &shared::StaticMeshRenderer) -> ColliderDesc {
ColliderDesc::static_mesh(
renderer
@ -1177,14 +1389,26 @@ fn part_effective_material_id_for_selection(
fn static_mesh_renderer_for_asset(
world: &mut World,
path: &str,
) -> Option<shared::StaticMeshRenderer> {
) -> Option<(shared::StaticMeshRenderer, bool)> {
let mut manifest_path = None;
if let Some(mut registry) = world.get_resource_mut::<AssetRegistry>() {
let mut renderer = None;
if let Some(record) = find_asset_mut_by_path(&mut registry, path) {
match super::refresh_model_artifacts(record) {
Ok(manifest) => {
renderer = Some(renderer_from_manifest(&manifest, &record.import_settings));
let requires_skinned_renderer = manifest.metadata.animation_count > 0
|| manifest.metadata.skin_count > 0
|| manifest.parts.iter().any(|part| part.skinned);
let mut authored_renderer =
renderer_from_manifest(&manifest, &record.import_settings);
if requires_skinned_renderer {
authored_renderer.materials =
crate::assets::static_mesh::renderer_materials_from_manifest(
&manifest,
&record.import_settings,
);
}
renderer = Some((authored_renderer, requires_skinned_renderer));
registry.index_dirty = true;
}
Err(error) => {
@ -1207,7 +1431,17 @@ fn static_mesh_renderer_for_asset(
let registry = world.get_resource::<AssetRegistry>()?;
let record = find_asset_by_path(registry, path)?;
let manifest = load_static_mesh_manifest(&manifest_path).ok()?;
Some(renderer_from_manifest(&manifest, &record.import_settings))
let requires_skinned_renderer = manifest.metadata.animation_count > 0
|| manifest.metadata.skin_count > 0
|| manifest.parts.iter().any(|part| part.skinned);
let mut renderer = renderer_from_manifest(&manifest, &record.import_settings);
if requires_skinned_renderer {
renderer.materials = crate::assets::static_mesh::renderer_materials_from_manifest(
&manifest,
&record.import_settings,
);
}
Some((renderer, requires_skinned_renderer))
}
pub fn import_external_assets(paths: &[PathBuf]) -> Result<usize, String> {
@ -1348,4 +1582,106 @@ mod tests {
);
assert!(world.resource::<crate::history::EditorHistory>().can_undo());
}
#[test]
fn skinned_model_placement_replaces_static_and_generic_model_renderers() {
let asset = EditorAsset {
label: "Hero".into(),
path: Some("assets/models/hero.glb".into()),
folder_path: "assets/models".into(),
kind: EditorAssetKind::Model,
};
let mut snapshot = snapshot_for_asset(&asset, Vec3::ZERO).unwrap();
snapshot.model.as_mut().unwrap().asset_id = "hero-id".into();
snapshot.static_mesh_renderer = Some(shared::StaticMeshRenderer::default());
snapshot.rigid_body = Some(RigidBodyDesc::default());
snapshot.collider = Some(ColliderDesc::static_cuboid(Vec3::ONE));
apply_skinned_mesh_placement_mode(&mut snapshot);
assert_eq!(snapshot.actor_kind, ActorKind::SkinnedMesh);
assert!(snapshot.model.is_none());
assert!(snapshot.static_mesh_renderer.is_none());
assert!(snapshot.rigid_body.is_none());
assert!(snapshot.collider.is_none());
assert_eq!(
snapshot.skinned_mesh_renderer,
Some(
shared::SkinnedMeshRenderer::new("assets/models/hero.glb").with_asset_id("hero-id")
)
);
}
#[test]
fn model_default_clip_builds_one_explicit_controller_state() {
let key = Uuid::new_v4();
let manifest_path =
std::env::temp_dir().join(format!("blacksite-default-animation-{key}.animation.ron"));
let asset_id = key.to_string();
let signature = shared::AnimationSkeletonSignature::new("rig-signature");
let clip_id = shared::animation_clip_sub_asset_id(8, "Standing");
let manifest = shared::AnimationManifest {
schema_version: shared::ANIMATION_MANIFEST_SCHEMA_VERSION,
asset_id: asset_id.clone(),
label: "Robot".into(),
default_animation_clip_id: Some(clip_id.clone()),
source: shared::AnimationManifestSource {
path: "assets/models/robot.glb".into(),
format: "glb".into(),
fingerprint: shared::AnimationSourceFingerprint {
byte_len: 1,
modified_unix_secs: 0,
content_hash: "fixture".into(),
},
dependencies: Vec::new(),
},
runtime_supported: true,
skeletons: vec![shared::AnimationSkeletonRecord {
id: shared::animation_skeleton_sub_asset_id(0, "Rig"),
label: "Rig".into(),
source_index: 0,
signature: signature.clone(),
joint_paths: vec!["Root".into()],
}],
clips: vec![shared::AnimationClipRecord {
id: clip_id.clone(),
label: "Standing".into(),
source_index: 8,
duration_seconds: 0.4,
target_skeleton_signature: Some(signature),
events: 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: AssetId(key),
path: "assets/models/robot.glb".into(),
label: "Robot".into(),
kind_tag: "Model".into(),
import_settings: crate::asset_db::ImportSettings {
animation_manifest_path: Some(manifest_path.to_string_lossy().into_owned()),
default_animation_clip_id: Some(clip_id.clone()),
..Default::default()
},
dependencies: Vec::new(),
};
let controller = default_animation_controller_for_record(&record)
.unwrap()
.expect("explicit default should produce a controller");
assert_eq!(controller.default_state, "standing");
assert_eq!(controller.states.len(), 1);
assert_eq!(controller.states[0].clip.sub_asset_id, clip_id);
assert_eq!(
controller.skeleton.unwrap().sub_asset_id,
shared::animation_skeleton_sub_asset_id(0, "Rig")
);
std::fs::remove_file(manifest_path).unwrap();
}
}

View File

@ -2,10 +2,13 @@
use bevy::prelude::*;
use bevy_egui::egui;
use shared::{LevelObject, MaterialAsset, MaterialDesc};
use shared::{
ComponentInstanceId, LevelObject, MaterialAsset, MaterialDesc, MaterialRef,
RendererMaterialSlot,
};
use crate::assets::{EditorAsset, EditorAssetKind, EditorAssets};
use crate::history::set_material_group_with_history;
use crate::history::{reflected_component_transaction, set_material_group_with_history};
use crate::ui::inspector::property_row;
pub fn load_material_asset(catalog_path: &str) -> Result<MaterialAsset, String> {
@ -28,18 +31,105 @@ pub fn apply_material_asset_to_selection(
.path
.clone()
.ok_or_else(|| format!("Material has no source path: {}", asset.label))?;
let desc = material_desc_from_asset(&path)?;
let changes = crate::ui::helpers::selected_level_entities(world, selected)
.into_iter()
.filter(|entity| world.get::<LevelObject>(*entity).is_some())
.map(|entity| (entity, desc.clone()))
.collect::<Vec<_>>();
set_material_group_with_history(world, changes);
let record = world
.get_resource::<crate::asset_db::AssetRegistry>()
.and_then(|registry| crate::asset_db::find_asset_by_path(registry, &path))
.ok_or_else(|| format!("Material is missing from the asset registry: {path}"))?;
let sub_asset_id = if shared::MaterialInstanceAsset::load_from_path(&path).is_ok() {
"material:instance"
} else {
"material:source"
};
let material_ref = MaterialRef::new(
shared::EditorAssetRef::new(record.id.as_string(), sub_asset_id, asset.label.clone())
.with_source_path(path.clone()),
);
let desc = material_desc_from_asset(&path).ok();
let entities = crate::ui::helpers::selected_level_entities(world, selected);
let mut renderer_changes = 0usize;
let mut legacy_changes = Vec::new();
for entity in entities {
if world.get::<LevelObject>(entity).is_none() {
continue;
}
if let Some(mut renderer) = world.get::<shared::StaticMeshRenderer>(entity).cloned() {
ensure_static_material_slots(&mut renderer);
for slot in &mut renderer.materials.slots {
slot.material = Some(material_ref.clone());
}
reflected_component_transaction(
world,
entity,
"Assign Material Slots",
shared::AUTHORING_COMPONENT_STATIC_MESH_RENDERER,
shared::COMPONENT_STATIC_MESH_RENDERER,
move |world, entity| {
world.entity_mut(entity).insert(renderer);
Ok(())
},
)?;
renderer_changes += 1;
continue;
}
if let Some(mut renderer) = world.get::<shared::SkinnedMeshRenderer>(entity).cloned() {
if renderer.materials.slots.is_empty() {
return Err(format!(
"Skinned renderer `{}` has no imported material slots; reimport its model",
renderer.path
));
}
for slot in &mut renderer.materials.slots {
slot.material = Some(material_ref.clone());
}
reflected_component_transaction(
world,
entity,
"Assign Material Slots",
shared::AUTHORING_COMPONENT_SKINNED_MESH_RENDERER,
shared::COMPONENT_SKINNED_MESH_RENDERER,
move |world, entity| {
world.entity_mut(entity).insert(renderer);
Ok(())
},
)?;
renderer_changes += 1;
continue;
}
if let Some(desc) = desc.as_ref() {
legacy_changes.push((entity, desc.clone()));
}
}
if !legacy_changes.is_empty() {
set_material_group_with_history(world, legacy_changes);
}
if renderer_changes == 0 && desc.is_none() {
return Err("No selected renderable actor accepts this material".into());
}
world.resource_mut::<crate::scene_io::SceneIo>().status =
format!("Applied material {}", asset.label);
format!("Applied shared material {}", asset.label);
Ok(())
}
fn ensure_static_material_slots(renderer: &mut shared::StaticMeshRenderer) {
for part in &mut renderer.slots {
if part.material_slot_id.0.trim().is_empty() {
part.material_slot_id = if part.id.0.trim().is_empty() {
ComponentInstanceId::new("slot:legacy")
} else {
ComponentInstanceId::new(format!("slot:{}", part.id.0))
};
}
if renderer.materials.slot(&part.material_slot_id).is_none() {
renderer.materials.slots.push(RendererMaterialSlot {
id: part.material_slot_id.clone(),
name: part.name.clone(),
source_material: part.material.clone().map(MaterialRef::new),
material: None,
});
}
}
}
pub fn material_asset_picker_ui(
world: &mut World,
ui: &mut egui::Ui,

View File

@ -119,10 +119,10 @@ pub fn assign_animation_clip_operator(
selection: AssetSelection,
entity: Entity,
) -> bool {
let availability = if world.get::<shared::ModelRef>(entity).is_some() {
let availability = if world.get::<shared::SkinnedMeshRenderer>(entity).is_some() {
OperatorAvailability::Ready
} else {
OperatorAvailability::Disabled("Select an authored imported-model actor".to_string())
OperatorAvailability::Disabled("Select an authored skinned-mesh actor".to_string())
};
let label = format!("Assign Animation Clip {}", selection.display_label());
run_immediate_operator(
@ -133,9 +133,9 @@ pub fn assign_animation_clip_operator(
move |world| {
let authored = animation_clip_authoring_data(world, &selection)?;
let model = world
.get::<shared::ModelRef>(entity)
.get::<shared::SkinnedMeshRenderer>(entity)
.cloned()
.ok_or_else(|| "Selected actor has no ModelRef".to_string())?;
.ok_or_else(|| "Selected actor has no SkinnedMeshRenderer".to_string())?;
let mut controller = world
.get::<shared::AnimationControllerDesc>(entity)
.cloned()
@ -197,7 +197,7 @@ mod tests {
animation_clip_sub_asset_id, animation_skeleton_sub_asset_id, ActorKind,
AnimationClipRecord, AnimationControllerDesc, AnimationManifest, AnimationManifestSource,
AnimationSkeletonRecord, AnimationSkeletonSignature, AnimationSourceFingerprint,
AudioSourceDesc, LevelObject, MaterialDesc, ModelRef, PrimitiveShape,
AudioSourceDesc, LevelObject, MaterialDesc, PrimitiveShape, SkinnedMeshRenderer,
ANIMATION_MANIFEST_SCHEMA_VERSION,
};
@ -222,6 +222,7 @@ mod tests {
schema_version: ANIMATION_MANIFEST_SCHEMA_VERSION,
asset_id: asset_id.as_string(),
label: "Operator Fixture".into(),
default_animation_clip_id: None,
source: AnimationManifestSource {
path: model_path.clone(),
format: "glb".into(),
@ -301,6 +302,43 @@ mod tests {
assert_undo_redo_round_trip(&mut world, 0, 1, authored_count);
}
#[test]
fn animation_clip_placement_creates_only_the_skinned_renderer_path() {
let mut world = World::new();
world.init_resource::<ActiveOperator>();
world.init_resource::<EditorHistory>();
world.init_resource::<SceneIo>();
world.init_resource::<SelectedEntity>();
let (manifest_path, record, selection) = animation_fixture();
world.insert_resource(AssetRegistry {
records: vec![record],
index_dirty: false,
});
let harness = OperatorInvariantHarness::capture(&mut world);
assert!(place_subasset_operator(
&mut world,
selection,
Vec3::new(1.0, 2.0, 3.0),
));
let entity = world
.query_filtered::<Entity, With<LevelObject>>()
.single(&world)
.unwrap();
assert_eq!(
world.get::<ActorKind>(entity),
Some(&ActorKind::SkinnedMesh)
);
assert!(world.get::<SkinnedMeshRenderer>(entity).is_some());
assert!(world.get::<AnimationControllerDesc>(entity).is_some());
assert!(world.get::<shared::ModelRef>(entity).is_none());
assert!(world.get::<shared::StaticMeshRenderer>(entity).is_none());
harness.assert_committed(&mut world, 1, 1);
assert_undo_redo_round_trip(&mut world, 0, 1, authored_count);
let _ = std::fs::remove_file(manifest_path);
}
#[test]
fn audio_clip_assignment_is_one_undoable_operator() {
let mut world = World::new();
@ -363,8 +401,8 @@ mod tests {
let entity = world
.spawn((
LevelObject,
ActorKind::ImportedModel,
ModelRef::new(model_path),
ActorKind::SkinnedMesh,
SkinnedMeshRenderer::new(model_path),
))
.id();
let harness = OperatorInvariantHarness::capture(&mut world);

View File

@ -1248,6 +1248,10 @@ fn apply_prefab_scope_to_source(
return;
}
};
let before_snapshot = crate::collaboration::FileSnapshot::from_loaded_bytes(
&source_path,
before_source.as_bytes(),
);
let mut document = match SceneDocument::from_ron_text(&before_source) {
Ok(document) => document,
Err(error) => {
@ -1361,7 +1365,13 @@ fn apply_prefab_scope_to_source(
}
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())?;
crate::collaboration::publish_authored_file(
world,
&source_path,
after_source.as_bytes(),
&before_snapshot,
crate::collaboration::FileWriteIntent::PrefabSource { instance_root },
)?;
Ok::<_, String>(after_source)
})();
let after_source = match apply_result {
@ -2607,6 +2617,22 @@ fn retry_prefab_source(world: &mut World, entity: Entity) {
"Prefab source queued for reload".into();
}
pub(crate) fn reload_prefab_source_after_file_conflict(
world: &mut World,
instance_root: Entity,
) -> Result<String, String> {
if world.get::<PrefabInstance>(instance_root).is_none() {
return Err("the prefab instance no longer exists".into());
}
retry_prefab_source(world, instance_root);
let status = world.resource::<crate::scene_io::SceneIo>().status.clone();
if status.starts_with("Prefab retry failed") {
Err(status)
} else {
Ok(status)
}
}
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));
@ -3258,6 +3284,38 @@ mod tests {
assert_eq!(read_overrides(&instance), PrefabOverrides::default());
}
#[test]
fn collaboration_reload_restarts_prefab_hydration_without_dropping_overrides() {
let root = std::env::temp_dir().join(format!(
"blacksite-prefab-collaboration-reload-{}",
uuid::Uuid::new_v4()
));
let source_path = root.join("assets/prefabs/base.scn.ron");
std::fs::create_dir_all(source_path.parent().unwrap()).unwrap();
std::fs::write(&source_path, prefab_source("mesh", None)).unwrap();
let mut world = prefab_test_world(&root);
let mut instance = PrefabInstance::new("base", "assets/prefabs/base.scn.ron");
instance.overrides_ron = encode_prefab_overrides(&PrefabOverrides {
source_revision: Some("previous".into()),
..Default::default()
})
.unwrap();
let instance_root = world.spawn((instance.clone(), HydratedPrefabReady)).id();
let status = reload_prefab_source_after_file_conflict(&mut world, instance_root).unwrap();
assert_eq!(status, "Prefab source queued for reload");
assert_eq!(
world
.get::<PrefabInstance>(instance_root)
.map(|current| &current.overrides_ron),
Some(&instance.overrides_ron)
);
assert!(world.get::<HydratedPrefabReady>(instance_root).is_none());
assert!(world.get::<PrefabRef>(instance_root).is_some());
std::fs::remove_dir_all(root).unwrap();
}
#[test]
fn nested_unpack_fold_is_transactional_when_a_later_link_is_invalid() {
let mut world = World::new();

View File

@ -14,9 +14,12 @@ use serde::{Deserialize, Serialize};
use crate::asset_db::{
AssetRecord, ImportSettings, MaterialImportPolicy, ModelHierarchyMode, ModelPlacementMode,
};
use shared::{ComponentInstanceId, EditorAssetRef, StaticMeshRenderer, StaticMeshRendererEntry};
use shared::{
ComponentInstanceId, EditorAssetRef, MaterialRef, RendererMaterialSet, RendererMaterialSlot,
StaticMeshRenderer, StaticMeshRendererEntry,
};
pub const STATIC_MESH_MANIFEST_SCHEMA: u32 = 1;
pub const STATIC_MESH_MANIFEST_SCHEMA: u32 = 3;
pub const STATIC_MESH_ARTIFACT_DIR: &str = "assets/meshes/generated";
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
@ -80,6 +83,10 @@ pub struct StaticMeshPart {
pub source_node: Option<String>,
pub source_mesh: Option<String>,
pub source_material: Option<String>,
/// Whether the source primitive is bound to a skin and therefore must never become a static
/// renderer slot.
#[serde(default)]
pub skinned: bool,
}
pub fn static_mesh_manifest_path(asset_id: &str) -> String {
@ -94,6 +101,17 @@ pub fn material_id_from_label(label: &str) -> String {
format!("material:{}", stable_sub_asset_slug(label))
}
fn gltf_draw_id(node_index: Option<usize>, mesh_index: usize, primitive_index: usize) -> String {
let node = node_index
.map(|index| index.to_string())
.unwrap_or_else(|| "unbound".into());
format!("draw:scene0:node{node}:mesh{mesh_index}:primitive{primitive_index}")
}
fn fbx_draw_id(node_index: usize, material_index: usize) -> String {
format!("draw:scene0:node{node_index}:material{material_index}")
}
fn stable_sub_asset_slug(label: &str) -> String {
let mut slug = String::new();
for ch in label.chars() {
@ -151,38 +169,106 @@ pub fn renderer_from_manifest(
MaterialImportPolicy::SourceMaterials
);
StaticMeshRenderer {
let parts: Vec<_> = manifest
.parts
.iter()
.filter(|part| !part.skinned && manifest.metadata.animation_count == 0)
.map(|part| StaticMeshRendererEntry {
id: ComponentInstanceId::new(part_effective_id(part)),
name: part.name.clone(),
mesh: EditorAssetRef::new(
manifest.asset_id.clone(),
part_effective_id(part),
part.name.clone(),
),
material_slot_id: ComponentInstanceId::new(material_slot_id(part)),
material: use_source_materials
.then(|| {
part_effective_material_id(part).map(|id| {
EditorAssetRef::new(
manifest.asset_id.clone(),
id,
part.material_slot_name.clone(),
)
})
})
.flatten(),
local_transform: part.local_transform,
visible: true,
cast_shadows: true,
receive_shadows: true,
})
.collect();
let materials = RendererMaterialSet {
slots: manifest
.parts
.iter()
.map(|part| StaticMeshRendererEntry {
id: ComponentInstanceId::new(part_effective_id(part)),
name: part.name.clone(),
mesh: EditorAssetRef::new(
manifest.asset_id.clone(),
part_effective_id(part),
part.name.clone(),
),
material: use_source_materials
.filter(|part| !part.skinned && manifest.metadata.animation_count == 0)
.map(|part| RendererMaterialSlot {
id: ComponentInstanceId::new(material_slot_id(part)),
name: part.material_slot_name.clone(),
source_material: use_source_materials
.then(|| {
part_effective_material_id(part).map(|id| {
EditorAssetRef::new(
MaterialRef::new(EditorAssetRef::new(
manifest.asset_id.clone(),
id,
part.material_slot_name.clone(),
)
))
})
})
.flatten(),
local_transform: part.local_transform,
visible: true,
cast_shadows: true,
receive_shadows: true,
material: None,
})
.collect(),
orphaned_assignments: Vec::new(),
};
StaticMeshRenderer {
slots: parts,
materials,
}
}
/// Builds the shared material slots for a full imported hierarchy. Unlike static renderer
/// construction this intentionally includes skin-bound and rigid animated draw bindings.
pub fn renderer_materials_from_manifest(
manifest: &StaticMeshManifest,
settings: &ImportSettings,
) -> RendererMaterialSet {
let use_source_materials = matches!(
settings.material_policy,
MaterialImportPolicy::SourceMaterials
);
RendererMaterialSet {
slots: manifest
.parts
.iter()
.map(|part| RendererMaterialSlot {
id: ComponentInstanceId::new(material_slot_id(part)),
name: part.material_slot_name.clone(),
source_material: use_source_materials
.then(|| {
part_effective_material_id(part).map(|id| {
MaterialRef::new(EditorAssetRef::new(
manifest.asset_id.clone(),
id,
part.material_slot_name.clone(),
))
})
})
.flatten(),
material: None,
})
.collect(),
orphaned_assignments: Vec::new(),
}
}
fn material_slot_id(part: &StaticMeshPart) -> String {
format!("slot:{}", part_effective_id(part))
}
fn part_effective_id(part: &StaticMeshPart) -> String {
if part.id.trim().is_empty() {
part_id_from_label(&part.mesh_label)
@ -255,23 +341,23 @@ fn build_gltf_manifest(
.or_else(|| gltf.document.scenes().next())
{
for node in scene.nodes() {
collect_gltf_node_parts(node, Mat4::IDENTITY, &mut parts);
collect_gltf_node_parts(node, Mat4::IDENTITY, "", &mut parts);
}
} else {
for mesh in gltf.document.meshes() {
collect_gltf_mesh_parts(None, None, mesh, Mat4::IDENTITY, &mut parts);
collect_gltf_mesh_parts(None, None, None, mesh, Mat4::IDENTITY, false, &mut parts);
}
}
if gltf.document.animations().count() > 0 {
warnings.push(
"Animations are recorded as metadata; use Scene Instance placement for playback."
"Animations are recorded as metadata; animated placement uses SkinnedMeshRenderer."
.into(),
);
}
if gltf.document.skins().count() > 0 {
warnings.push(
"Skins are recorded as metadata; skinned playback stays on the Scene Instance path."
"Skinned primitives are excluded from StaticMeshRenderer and use SkinnedMeshRenderer."
.into(),
);
}
@ -306,29 +392,44 @@ fn build_gltf_manifest(
fn collect_gltf_node_parts(
node: gltf::Node<'_>,
parent_transform: Mat4,
parent_path: &str,
parts: &mut Vec<StaticMeshPart>,
) {
let local = Mat4::from_cols_array_2d(&node.transform().matrix());
let world_transform = parent_transform * local;
let segment = node
.name()
.map(str::to_string)
.unwrap_or_else(|| format!("Node{}", node.index()));
let node_path = if parent_path.is_empty() {
segment
} else {
format!("{parent_path}/{segment}")
};
let skinned = node.skin().is_some();
if let Some(mesh) = node.mesh() {
collect_gltf_mesh_parts(
node.name().map(str::to_string),
Some(node.index()),
Some(node_path.clone()),
mesh,
world_transform,
skinned,
parts,
);
}
for child in node.children() {
collect_gltf_node_parts(child, world_transform, parts);
collect_gltf_node_parts(child, world_transform, &node_path, parts);
}
}
fn collect_gltf_mesh_parts(
node_name: Option<String>,
node_index: Option<usize>,
node_path: Option<String>,
mesh: gltf::Mesh<'_>,
transform: Mat4,
skinned: bool,
parts: &mut Vec<StaticMeshPart>,
) {
let mesh_index = mesh.index();
@ -359,9 +460,11 @@ fn collect_gltf_mesh_parts(
.clone()
.or_else(|| mesh_name.clone())
.unwrap_or_else(|| format!("Mesh {mesh_index}"));
let source_node = node_index.map(|index| format!("Node{index}"));
let source_node = node_path
.clone()
.or_else(|| node_index.map(|index| format!("Node{index}")));
parts.push(StaticMeshPart {
id: part_id_from_label(&mesh_label),
id: gltf_draw_id(node_index, mesh_index, primitive_index),
name: format!("{name} / Primitive {primitive_index}"),
material_id: material_label
.as_ref()
@ -373,6 +476,7 @@ fn collect_gltf_mesh_parts(
source_node,
source_mesh: Some(format!("Mesh{mesh_index}")),
source_material: Some(material_name),
skinned,
});
}
}
@ -405,6 +509,7 @@ fn build_fbx_manifest(
if mesh.num_vertices == 0 || mesh.faces.as_ref().is_empty() {
continue;
}
let skinned = !mesh.skin_deformers.as_ref().is_empty();
let mut groups: Vec<(usize, Vec<u32>)> =
group_faces_by_material(mesh).into_iter().collect();
@ -428,7 +533,7 @@ fn build_fbx_manifest(
};
let mesh_label = FbxAssetLabel::Mesh(node_index * 1000 + material_index).to_string();
parts.push(StaticMeshPart {
id: part_id_from_label(&mesh_label),
id: fbx_draw_id(node_index, material_index),
name: format!("{node_name} / Material {material_index}"),
material_id: material_label
.as_ref()
@ -440,19 +545,20 @@ fn build_fbx_manifest(
source_node: Some(format!("Node{node_index}")),
source_mesh: Some(format!("Mesh{node_index}")),
source_material: Some(material_name),
skinned,
});
}
}
if !scene.anim_stacks.as_ref().is_empty() {
warnings.push(
"Animations are recorded as metadata; use Scene Instance placement for playback."
"Animations are recorded as metadata; animated placement uses SkinnedMeshRenderer."
.into(),
);
}
if !scene.skin_deformers.as_ref().is_empty() {
warnings.push(
"Skins are recorded as metadata; skinned playback stays on the Scene Instance path."
"Skinned primitives are excluded from StaticMeshRenderer and use SkinnedMeshRenderer."
.into(),
);
}
@ -554,6 +660,7 @@ fn resolve_dependency(source_path: &str, uri: &str) -> String {
#[cfg(test)]
mod tests {
use super::*;
use crate::asset_db::AssetId;
fn test_manifest() -> StaticMeshManifest {
StaticMeshManifest {
@ -597,6 +704,7 @@ mod tests {
source_node: Some("Node0".into()),
source_mesh: Some("Mesh0".into()),
source_material: Some("Wood".into()),
skinned: false,
}],
warnings: Vec::new(),
}
@ -645,4 +753,48 @@ mod tests {
assert!(renderer.slots[0].material.is_none());
}
#[test]
fn renderer_from_manifest_excludes_skinned_primitives() {
let mut manifest = test_manifest();
manifest.parts[0].skinned = true;
let renderer = renderer_from_manifest(&manifest, &ImportSettings::default());
assert!(renderer.slots.is_empty());
}
#[test]
fn renderer_from_manifest_excludes_node_animated_geometry() {
let mut manifest = test_manifest();
manifest.metadata.animation_count = 1;
let renderer = renderer_from_manifest(&manifest, &ImportSettings::default());
assert!(renderer.slots.is_empty());
}
#[test]
fn committed_rigged_fixture_never_builds_static_renderer_slots() {
let path = Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../assets/models/robot_expressive.glb")
.to_string_lossy()
.into_owned();
let record = AssetRecord {
id: AssetId::new(),
path,
label: "Robot Expressive".into(),
kind_tag: "Model".into(),
import_settings: ImportSettings::default(),
dependencies: Vec::new(),
};
let manifest = build_static_mesh_manifest(&record).unwrap();
let renderer = renderer_from_manifest(&manifest, &record.import_settings);
assert!(manifest.metadata.skin_count > 0);
assert!(manifest.metadata.animation_count > 0);
assert!(manifest.parts.iter().any(|part| part.skinned));
assert!(renderer.slots.is_empty());
}
}

View File

@ -21,6 +21,10 @@ use crate::infra::EditorOnly;
pub(crate) const THUMB_SIZE: u32 = 128;
pub(crate) const THUMBNAIL_LAYER: usize = 31;
const THUMBNAIL_VERTICAL_FOV: f32 = 40.0_f32.to_radians();
const THUMBNAIL_HALF_VERTICAL_FOV: f32 = THUMBNAIL_VERTICAL_FOV * 0.5;
const SCENE_FRAME_PADDING: f32 = 1.35;
const MATERIAL_SPHERE_FRAME_PADDING: f32 = 1.28;
const MESH_WARMUP_FRAMES: u8 = 12;
const POST_ATTACH_FRAMES: u8 = 8;
const RENDER_FRAMES: u8 = 3;
@ -61,6 +65,25 @@ struct ActiveModelThumbnail {
framed: bool,
frames_remaining: u8,
wait_frames: u32,
framing: ThumbnailFraming,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum ThumbnailFraming {
SceneBounds,
MaterialSphere,
}
impl ThumbnailFraming {
fn for_source(source: &ThumbnailJobSource) -> Self {
match source {
ThumbnailJobSource::SourceMaterial { .. }
| ThumbnailJobSource::MaterialAsset { .. } => Self::MaterialSphere,
ThumbnailJobSource::Model { .. } | ThumbnailJobSource::MeshSubAsset { .. } => {
Self::SceneBounds
}
}
}
}
pub struct ThumbnailStudioPlugin;
@ -143,7 +166,7 @@ fn setup_thumbnail_studio(mut commands: Commands, mut images: ResMut<Assets<Imag
RenderTarget::Image(render_image.clone().into()),
Transform::from_xyz(2.0, 1.4, 2.0).looking_at(Vec3::new(0.0, 0.5, 0.0), Vec3::Y),
Projection::Perspective(PerspectiveProjection {
fov: 40.0_f32.to_radians(),
fov: THUMBNAIL_VERTICAL_FOV,
..default()
}),
))
@ -229,6 +252,7 @@ fn process_thumbnail_studio(
&mesh3d,
&mesh_storage,
&mut camera_transforms,
active.framing,
) {
active.framed = true;
active.post_attach_frames = 0;
@ -322,6 +346,7 @@ fn process_thumbnail_studio(
.id();
let label = job.source.label().to_string();
let framing = ThumbnailFraming::for_source(&job.source);
let content_spawned = spawn_thumbnail_job_content(
&mut commands,
&mut mesh_storage,
@ -350,6 +375,7 @@ fn process_thumbnail_studio(
framed: false,
frames_remaining: 0,
wait_frames: 0,
framing,
},
"no renderable meshes",
true,
@ -369,6 +395,7 @@ fn process_thumbnail_studio(
framed: false,
frames_remaining: 0,
wait_frames: 0,
framing,
});
}
@ -588,6 +615,7 @@ fn frame_thumbnail_scene(
meshes: &Query<&Mesh3d>,
mesh_assets: &Assets<Mesh>,
camera_transforms: &mut Query<&mut Transform, With<ThumbnailStudioCamera>>,
framing: ThumbnailFraming,
) -> bool {
let mut entities = Vec::new();
collect_descendants(root, children, &mut entities);
@ -633,8 +661,7 @@ fn frame_thumbnail_scene(
}
let center = (min + max) * 0.5;
let radius = (max - min).length().max(0.25) * 0.5;
let distance = radius / (20.0_f32.to_radians()).tan() * 1.35;
let distance = thumbnail_camera_distance(min, max, framing);
let eye = center + Vec3::new(1.0, 0.75, 1.0).normalize() * distance;
if let Ok(mut transform) = camera_transforms.single_mut() {
@ -644,6 +671,20 @@ fn frame_thumbnail_scene(
true
}
fn thumbnail_camera_distance(min: Vec3, max: Vec3, framing: ThumbnailFraming) -> f32 {
let bounds_size = max - min;
let (radius, padding) = match framing {
ThumbnailFraming::SceneBounds => {
(bounds_size.length().max(0.25) * 0.5, SCENE_FRAME_PADDING)
}
ThumbnailFraming::MaterialSphere => (
bounds_size.max_element().max(0.5) * 0.5,
MATERIAL_SPHERE_FRAME_PADDING,
),
};
radius / THUMBNAIL_HALF_VERTICAL_FOV.tan() * padding
}
fn collect_descendants(entity: Entity, children: &Query<&Children>, out: &mut Vec<Entity>) {
out.push(entity);
let Ok(child_list) = children.get(entity) else {
@ -673,3 +714,24 @@ fn aabb_corners(aabb: &Aabb3d) -> [Vec3; 8] {
pub fn model_file_exists(catalog_path: &str) -> bool {
Path::new(catalog_path).is_file()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn material_sphere_framing_fills_more_of_the_thumbnail_with_padding() {
let sphere_radius = 0.65;
let min = Vec3::splat(-sphere_radius);
let max = Vec3::splat(sphere_radius);
let scene_distance = thumbnail_camera_distance(min, max, ThumbnailFraming::SceneBounds);
let material_distance =
thumbnail_camera_distance(min, max, ThumbnailFraming::MaterialSphere);
let projected_radius_fraction =
sphere_radius / (material_distance * THUMBNAIL_HALF_VERTICAL_FOV.tan());
assert!(material_distance < scene_distance * 0.6);
assert!((0.75..0.82).contains(&projected_radius_fraction));
}
}

View File

@ -3,9 +3,10 @@ use std::path::PathBuf;
use bevy::prelude::*;
use shared::{
ActorId, ActorKind, ActorName, AnimationControllerDesc, AudioListenerDesc, AudioSourceDesc,
BrushDesc, ColliderDesc, EditorVisibility, InspectorOrder, LightDesc, MaterialDesc,
MaterialOverride, ModelRef, ObjectiveMarker, PhysicsBody, PostProcessVolumeDesc,
PrefabInstance, PrefabRef, Primitive, RigidBodyDesc, SceneComposition, StaticMeshRenderer,
AuthoringComponentStates, BrushDesc, ColliderDesc, EditorVisibility, InspectorOrder, LightDesc,
MaterialDesc, MaterialOverride, ModelRef, NavigationArea, NavigationBounds, NavigationLink,
NavigationObstacle, ObjectiveMarker, PhysicsBody, PostProcessVolumeDesc, PrefabInstance,
PrefabRef, Primitive, RigidBodyDesc, SceneComposition, SkinnedMeshRenderer, StaticMeshRenderer,
TeamSpawn, TriggerVolume, WeaponSpawn,
};
#[derive(Debug, Clone)]
@ -18,6 +19,7 @@ pub struct EditorEntitySnapshot {
pub primitive: Option<Primitive>,
pub brush: Option<BrushDesc>,
pub static_mesh_renderer: Option<StaticMeshRenderer>,
pub skinned_mesh_renderer: Option<SkinnedMeshRenderer>,
pub material: Option<MaterialDesc>,
pub material_override: Option<MaterialOverride>,
pub rigid_body: Option<RigidBodyDesc>,
@ -36,11 +38,52 @@ pub struct EditorEntitySnapshot {
pub post_process_volume: Option<PostProcessVolumeDesc>,
pub team_spawn: Option<TeamSpawn>,
pub objective: Option<ObjectiveMarker>,
pub navigation_bounds: Option<NavigationBounds>,
pub navigation_obstacle: Option<NavigationObstacle>,
pub navigation_area: Option<NavigationArea>,
pub navigation_link: Option<NavigationLink>,
pub hierarchy_sibling_index: i32,
pub editor_visibility: EditorVisibility,
pub inspector_order: Option<InspectorOrder>,
pub component_states: Option<AuthoringComponentStates>,
pub children: Vec<EditorEntitySnapshot>,
}
/// One reflected component value used by registry-driven editor transactions.
/// `component_id` is stable; `type_path` is retained only to resolve Bevy's
/// reflection adapter for this build.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ReflectedComponentValue {
pub component_id: String,
pub type_path: String,
pub ron: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ComponentDelta {
pub component_id: String,
pub type_path: String,
pub before: Option<ReflectedComponentValue>,
pub after: Option<ReflectedComponentValue>,
}
/// An atomic add/remove/reset/paste operation. Requirements, dependent removals,
/// and the derived ActorKind hint can be represented as additional deltas.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ComponentTransaction {
pub entity: Entity,
pub label: &'static str,
pub deltas: Vec<ComponentDelta>,
}
#[derive(Debug, Clone, Default, PartialEq)]
pub struct NavigationComponentState {
pub bounds: Option<NavigationBounds>,
pub obstacle: Option<NavigationObstacle>,
pub area: Option<NavigationArea>,
pub link: Option<NavigationLink>,
}
#[derive(Debug, Clone)]
pub struct SiblingChange {
pub entity: Entity,
@ -174,6 +217,11 @@ pub enum EditorCommand {
old: Option<PostProcessVolumeDesc>,
new: PostProcessVolumeDesc,
},
SetNavigation {
entity: Entity,
old: NavigationComponentState,
new: NavigationComponentState,
},
SetTransformGroup {
entities: Vec<Entity>,
olds: Vec<Transform>,
@ -239,6 +287,7 @@ pub enum EditorCommand {
entity: Entity,
snapshot: EditorEntitySnapshot,
},
ComponentTransaction(ComponentTransaction),
}
impl EditorCommand {
@ -266,6 +315,7 @@ impl EditorCommand {
EditorCommand::ApplyBrushCsg { .. } => "Brush CSG",
EditorCommand::SetStaticMeshRenderer { .. } => "Set Static Mesh Renderer",
EditorCommand::SetPostProcessVolume { .. } => "Set Post Process Volume",
EditorCommand::SetNavigation { .. } => "Set Navigation",
EditorCommand::SetTransformGroup { .. } => "Move Selection",
EditorCommand::Reparent { .. } => "Reparent",
EditorCommand::SetActorKind { .. } => "Set Actor Kind",
@ -283,6 +333,7 @@ impl EditorCommand {
} => "Unpack Prefab Layer",
EditorCommand::AddComponent { .. } => "Add Component",
EditorCommand::RemoveComponent { .. } => "Remove Component",
EditorCommand::ComponentTransaction(transaction) => transaction.label,
}
}
}

View File

@ -1,14 +1,20 @@
use std::collections::HashSet;
use bevy::ecs::reflect::ReflectComponent;
use bevy::prelude::*;
use bevy::reflect::serde::{TypedReflectDeserializer, TypedReflectSerializer};
use bevy::reflect::std_traits::ReflectDefault;
use bevy::world_serialization::WorldInstance;
use serde::de::DeserializeSeed;
use shared::{
infer_actor_kind, ActorId, ActorKind, ActorName, AnimationControllerDesc, AudioListenerDesc,
AudioSourceDesc, BrushDesc, ColliderDesc, EditorVisibility, HierarchySiblingIndex,
HydratedPrefabMember, HydratedPrefabReady, InspectorOrder, LevelObject, LightDesc,
MaterialDesc, MaterialOverride, ModelRef, ObjectiveMarker, PhysicsBody, PlayerSpawn,
AudioSourceDesc, AuthoringComponentStates, BrushDesc, ColliderDesc, EditorVisibility,
HierarchySiblingIndex, HydratedPrefabMember, HydratedPrefabReady, InspectorOrder, LevelObject,
LightDesc, MaterialDesc, MaterialOverride, ModelRef, NavigationArea, NavigationBounds,
NavigationLink, NavigationObstacle, ObjectiveMarker, PhysicsBody, PlayerSpawn,
PostProcessVolumeDesc, PrefabHydrationBlocked, PrefabInstance, PrefabRef, Primitive,
RigidBodyDesc, SceneComposition, StaticMeshRenderer, TeamSpawn, TriggerVolume, WeaponSpawn,
RigidBodyDesc, SceneComposition, SkinnedMeshRenderer, StaticMeshRenderer, TeamSpawn,
TriggerVolume, WeaponSpawn,
};
use crate::scene_io::{SceneIo, SceneIoRequest};
@ -22,7 +28,10 @@ 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, PrefabEditState, SiblingChange};
pub use commands::{
ComponentDelta, ComponentTransaction, EditorCommand, EditorEntitySnapshot,
NavigationComponentState, PrefabEditState, ReflectedComponentValue, SiblingChange,
};
#[derive(Resource, Debug, Default)]
pub struct EditorHistory {
@ -138,6 +147,7 @@ pub fn snapshot_entity(world: &World, entity: Entity) -> Option<EditorEntitySnap
primitive: entity_ref.get::<Primitive>().cloned(),
brush: entity_ref.get::<BrushDesc>().cloned(),
static_mesh_renderer: entity_ref.get::<StaticMeshRenderer>().cloned(),
skinned_mesh_renderer: entity_ref.get::<SkinnedMeshRenderer>().cloned(),
material: entity_ref.get::<MaterialDesc>().cloned(),
material_override: entity_ref.get::<MaterialOverride>().cloned(),
rigid_body: entity_ref.get::<RigidBodyDesc>().cloned(),
@ -156,6 +166,10 @@ pub fn snapshot_entity(world: &World, entity: Entity) -> Option<EditorEntitySnap
post_process_volume: entity_ref.get::<PostProcessVolumeDesc>().cloned(),
team_spawn: entity_ref.get::<TeamSpawn>().cloned(),
objective: entity_ref.get::<ObjectiveMarker>().cloned(),
navigation_bounds: entity_ref.get::<NavigationBounds>().cloned(),
navigation_obstacle: entity_ref.get::<NavigationObstacle>().cloned(),
navigation_area: entity_ref.get::<NavigationArea>().cloned(),
navigation_link: entity_ref.get::<NavigationLink>().cloned(),
hierarchy_sibling_index: entity_ref
.get::<HierarchySiblingIndex>()
.map(|index| index.0)
@ -164,6 +178,8 @@ pub fn snapshot_entity(world: &World, entity: Entity) -> Option<EditorEntitySnap
.get::<EditorVisibility>()
.copied()
.unwrap_or_default(),
inspector_order: entity_ref.get::<InspectorOrder>().cloned(),
component_states: entity_ref.get::<AuthoringComponentStates>().cloned(),
children,
})
}
@ -204,6 +220,9 @@ fn spawn_snapshot_with_parent(
if let Some(renderer) = &snapshot.static_mesh_renderer {
entity_mut.insert(renderer.clone());
}
if let Some(renderer) = &snapshot.skinned_mesh_renderer {
entity_mut.insert(renderer.clone());
}
if let Some(material) = &snapshot.material {
entity_mut.insert(material.clone());
}
@ -258,8 +277,26 @@ fn spawn_snapshot_with_parent(
if let Some(objective) = &snapshot.objective {
entity_mut.insert(objective.clone());
}
if let Some(bounds) = &snapshot.navigation_bounds {
entity_mut.insert(bounds.clone());
}
if let Some(obstacle) = &snapshot.navigation_obstacle {
entity_mut.insert(obstacle.clone());
}
if let Some(area) = &snapshot.navigation_area {
entity_mut.insert(area.clone());
}
if let Some(link) = &snapshot.navigation_link {
entity_mut.insert(link.clone());
}
entity_mut.insert(HierarchySiblingIndex(snapshot.hierarchy_sibling_index));
entity_mut.insert(snapshot.editor_visibility);
if let Some(order) = &snapshot.inspector_order {
entity_mut.insert(order.clone());
}
if let Some(states) = &snapshot.component_states {
entity_mut.insert(states.clone());
}
if let Some(parent) = parent {
entity_mut.insert(ChildOf(parent));
}
@ -282,6 +319,7 @@ pub fn clear_level_objects(world: &mut World) {
}
pub fn spawn_with_history(world: &mut World, mut snapshot: EditorEntitySnapshot) -> Entity {
assign_missing_actor_ids(&mut snapshot);
snapshot.hierarchy_sibling_index = next_sibling_index(world, None);
let entity = spawn_snapshot(world, &snapshot);
push_history(
@ -303,6 +341,9 @@ pub fn spawn_many_with_history(
if snapshots.is_empty() {
return Vec::new();
}
for snapshot in &mut snapshots {
assign_missing_actor_ids(snapshot);
}
for (sibling_index, snapshot) in (next_sibling_index(world, None)..).zip(snapshots.iter_mut()) {
snapshot.hierarchy_sibling_index = sibling_index;
}
@ -384,12 +425,37 @@ pub fn duplicate_entities_with_history(world: &mut World, entities: &[Entity]) {
}
fn assign_fresh_actor_ids(snapshot: &mut EditorEntitySnapshot) {
snapshot.actor_id = Some(new_actor_id());
let actor_id = new_actor_id();
if let Some(bounds) = &mut snapshot.navigation_bounds {
bounds.artifact_path = shared::navigation_artifact_path_for_actor(&actor_id.0);
}
snapshot.actor_id = Some(actor_id);
for child in &mut snapshot.children {
assign_fresh_actor_ids(child);
}
}
fn assign_missing_actor_ids(snapshot: &mut EditorEntitySnapshot) {
let existing_actor_id = snapshot
.actor_id
.clone()
.filter(|id| !id.0.trim().is_empty());
let assigned_new_id = existing_actor_id.is_none();
let actor_id = existing_actor_id.unwrap_or_else(new_actor_id);
if let Some(bounds) = &mut snapshot.navigation_bounds {
if assigned_new_id
|| bounds.artifact_path == NavigationBounds::default().artifact_path
|| shared::navigation_generated_artifact_path(&bounds.artifact_path).is_err()
{
bounds.artifact_path = shared::navigation_artifact_path_for_actor(&actor_id.0);
}
}
snapshot.actor_id = Some(actor_id);
for child in &mut snapshot.children {
assign_missing_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;
@ -561,6 +627,31 @@ pub fn set_post_process_volume_with_history(
);
}
pub fn navigation_component_state(world: &World, entity: Entity) -> NavigationComponentState {
NavigationComponentState {
bounds: world.get::<NavigationBounds>(entity).cloned(),
obstacle: world.get::<NavigationObstacle>(entity).cloned(),
area: world.get::<NavigationArea>(entity).cloned(),
link: world.get::<NavigationLink>(entity).cloned(),
}
}
pub fn set_navigation_with_history(
world: &mut World,
entity: Entity,
new: NavigationComponentState,
) {
if !is_mutable_level_object(world, entity) {
return;
}
let old = navigation_component_state(world, entity);
if old == new {
return;
}
apply_navigation_state(world, entity, &new);
push_history(world, EditorCommand::SetNavigation { entity, old, new });
}
pub fn set_physics_with_history(world: &mut World, entity: Entity, new: PhysicsBody) {
if !is_mutable_level_object(world, entity) {
return;
@ -818,6 +909,7 @@ pub fn group_selection_with_history(world: &mut World, entities: &[Entity]) {
primitive: None,
brush: None,
static_mesh_renderer: None,
skinned_mesh_renderer: None,
material: None,
material_override: None,
rigid_body: None,
@ -836,8 +928,18 @@ pub fn group_selection_with_history(world: &mut World, entities: &[Entity]) {
post_process_volume: None,
team_spawn: None,
objective: None,
navigation_bounds: None,
navigation_obstacle: None,
navigation_area: None,
navigation_link: None,
hierarchy_sibling_index: next_sibling_index(world, None),
editor_visibility: EditorVisibility::default(),
inspector_order: None,
component_states: None,
children: Vec::new(),
};
let group = spawn_snapshot(world, &snapshot);
@ -868,6 +970,7 @@ pub fn create_empty_child_with_history(world: &mut World, parent: Entity) {
primitive: None,
brush: None,
static_mesh_renderer: None,
skinned_mesh_renderer: None,
material: None,
material_override: None,
rigid_body: None,
@ -886,8 +989,18 @@ pub fn create_empty_child_with_history(world: &mut World, parent: Entity) {
post_process_volume: None,
team_spawn: None,
objective: None,
navigation_bounds: None,
navigation_obstacle: None,
navigation_area: None,
navigation_link: None,
hierarchy_sibling_index: next_sibling_index(world, Some(parent)),
editor_visibility: EditorVisibility::default(),
inspector_order: None,
component_states: None,
children: Vec::new(),
};
let entity = spawn_snapshot_with_parent(world, &snapshot, Some(parent));
@ -1235,6 +1348,215 @@ pub fn apply_actor_kind(world: &mut World, entity: Entity, kind: ActorKind) {
}
}
pub const ACTOR_KIND_COMPONENT_ID: &str = "core.actor_kind";
pub const ACTOR_KIND_TYPE_PATH: &str = "shared::components::ActorKind";
/// Captures any reflected component without adding a typed history branch.
pub fn capture_reflected_component(
world: &World,
entity: Entity,
component_id: &str,
type_path: &str,
) -> Result<Option<ReflectedComponentValue>, String> {
let entity_ref = world
.get_entity(entity)
.map_err(|_| format!("entity {entity:?} no longer exists"))?;
let app_registry = world
.get_resource::<AppTypeRegistry>()
.ok_or_else(|| "AppTypeRegistry is unavailable".to_string())?;
let type_registry = app_registry.read();
let registration = type_registry
.get_with_type_path(type_path)
.ok_or_else(|| format!("component type `{type_path}` is not registered"))?;
let reflect_component = registration
.data::<ReflectComponent>()
.ok_or_else(|| format!("component `{type_path}` has no ReflectComponent adapter"))?;
let Some(value) = reflect_component.reflect(entity_ref) else {
return Ok(None);
};
let ron = ron::ser::to_string(&TypedReflectSerializer::new(value, &type_registry))
.map_err(|error| format!("could not serialize `{type_path}`: {error}"))?;
Ok(Some(ReflectedComponentValue {
component_id: component_id.to_string(),
type_path: type_path.to_string(),
ron,
}))
}
/// Applies or removes one reflected component value.
pub fn apply_reflected_component(
world: &mut World,
entity: Entity,
type_path: &str,
value: Option<&ReflectedComponentValue>,
) -> Result<(), String> {
if world.get_entity(entity).is_err() {
return Err(format!("entity {entity:?} no longer exists"));
}
world.resource_scope(|world, app_registry: Mut<AppTypeRegistry>| {
let type_registry = app_registry.read();
let registration = type_registry
.get_with_type_path(type_path)
.ok_or_else(|| format!("component type `{type_path}` is not registered"))?;
let reflect_component = registration
.data::<ReflectComponent>()
.cloned()
.ok_or_else(|| format!("component `{type_path}` has no ReflectComponent adapter"))?;
match value {
Some(value) => {
if value.type_path != type_path {
return Err(format!(
"clipboard type `{}` does not match `{type_path}`",
value.type_path
));
}
let mut deserializer = ron::de::Deserializer::from_str(&value.ron)
.map_err(|error| error.to_string())?;
let reflected = TypedReflectDeserializer::new(registration, &type_registry)
.deserialize(&mut deserializer)
.map_err(|error| error.to_string())?;
let exists = reflect_component.reflect(world.entity(entity)).is_some();
if exists {
reflect_component.apply(world.entity_mut(entity), reflected.as_ref());
} else {
reflect_component.insert(
&mut world.entity_mut(entity),
reflected.as_ref(),
&type_registry,
);
}
}
None => reflect_component.remove(&mut world.entity_mut(entity)),
}
Ok(())
})
}
/// Inserts the reflected `Default` for a registered authoring component.
pub fn apply_reflected_default(
world: &mut World,
entity: Entity,
type_path: &str,
) -> Result<(), String> {
world.resource_scope(|world, app_registry: Mut<AppTypeRegistry>| {
let type_registry = app_registry.read();
let registration = type_registry
.get_with_type_path(type_path)
.ok_or_else(|| format!("component type `{type_path}` is not registered"))?;
let reflect_component = registration
.data::<ReflectComponent>()
.cloned()
.ok_or_else(|| format!("component `{type_path}` has no ReflectComponent adapter"))?;
let default = registration
.data::<ReflectDefault>()
.ok_or_else(|| format!("component `{type_path}` has no reflected Default"))?
.default();
let exists = reflect_component.reflect(world.entity(entity)).is_some();
if exists {
reflect_component.apply(world.entity_mut(entity), default.as_ref());
} else {
reflect_component.insert(
&mut world.entity_mut(entity),
default.as_ref(),
&type_registry,
);
}
Ok(())
})
}
pub fn sync_actor_kind_hint(world: &mut World, entity: Entity) {
let kind = 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(kind);
}
}
/// Executes one atomic reflected component edit and records all changed values,
/// including the derived ActorKind presentation hint, as a single history item.
pub fn reflected_component_transaction(
world: &mut World,
entity: Entity,
label: &'static str,
component_id: &str,
type_path: &str,
edit: impl FnOnce(&mut World, Entity) -> Result<(), String>,
) -> Result<(), String> {
if !is_mutable_level_object(world, entity) {
return Err("actor is not editable".to_string());
}
let tracked = [
(component_id, type_path),
(ACTOR_KIND_COMPONENT_ID, ACTOR_KIND_TYPE_PATH),
];
let before = tracked
.iter()
.map(|(id, path)| capture_reflected_component(world, entity, id, path))
.collect::<Result<Vec<_>, _>>()?;
if let Err(error) = edit(world, entity) {
for ((_, path), value) in tracked.iter().zip(before.iter()) {
let _ = apply_reflected_component(world, entity, path, value.as_ref());
}
return Err(error);
}
sync_actor_kind_hint(world, entity);
let after = tracked
.iter()
.map(|(id, path)| capture_reflected_component(world, entity, id, path))
.collect::<Result<Vec<_>, _>>()?;
let deltas = tracked
.iter()
.zip(before)
.zip(after)
.filter_map(|(((id, path), before), after)| {
(before != after).then(|| ComponentDelta {
component_id: (*id).to_string(),
type_path: (*path).to_string(),
before,
after,
})
})
.collect::<Vec<_>>();
if !deltas.is_empty() {
push_history(
world,
EditorCommand::ComponentTransaction(ComponentTransaction {
entity,
label,
deltas,
}),
);
}
Ok(())
}
fn apply_component_transaction(
world: &mut World,
transaction: &ComponentTransaction,
use_after: bool,
) {
for delta in &transaction.deltas {
let value = if use_after {
delta.after.as_ref()
} else {
delta.before.as_ref()
};
if let Err(error) =
apply_reflected_component(world, transaction.entity, &delta.type_path, value)
{
world.resource_mut::<EditorHistory>().status = format!(
"{} partially failed for {}: {error}",
transaction.label, delta.component_id
);
break;
}
}
}
pub fn set_scene_composition_with_history(world: &mut World, new: SceneComposition) {
let old = world.resource::<SceneComposition>().clone();
if old == new {
@ -1248,7 +1570,7 @@ pub fn set_scene_composition_with_history(world: &mut World, new: SceneCompositi
pub fn apply_command_undo(world: &mut World) {
let candidate = world.resource::<EditorHistory>().undo_stack.last().cloned();
if let Some(candidate) = candidate {
if let Err(error) = prepare_prefab_source_history(&candidate, true) {
if let Err(error) = prepare_prefab_source_history(world, &candidate, true) {
world.resource_mut::<EditorHistory>().status = format!("Undo blocked: {error}");
world
.resource_mut::<SceneIo>()
@ -1269,7 +1591,7 @@ pub fn apply_command_undo(world: &mut World) {
pub fn apply_command_redo(world: &mut World) {
let candidate = world.resource::<EditorHistory>().redo_stack.last().cloned();
if let Some(candidate) = candidate {
if let Err(error) = prepare_prefab_source_history(&candidate, false) {
if let Err(error) = prepare_prefab_source_history(world, &candidate, false) {
world.resource_mut::<EditorHistory>().status = format!("Redo blocked: {error}");
world
.resource_mut::<SceneIo>()
@ -1287,8 +1609,13 @@ pub fn apply_command_redo(world: &mut World) {
mark_dirty(world);
}
fn prepare_prefab_source_history(command: &EditorCommand, undo: bool) -> Result<(), String> {
fn prepare_prefab_source_history(
world: &mut World,
command: &EditorCommand,
undo: bool,
) -> Result<(), String> {
let EditorCommand::ApplyPrefabToSource {
entity,
source_path,
before_source,
after_source,
@ -1302,15 +1629,18 @@ fn prepare_prefab_source_history(command: &EditorCommand, undo: bool) -> Result<
} 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 &current != expected {
return Err(format!(
"{} changed outside this history command",
source_path.display()
));
}
crate::scene::recovery::atomic_write(source_path, replacement.as_bytes())
let expected =
crate::collaboration::FileSnapshot::from_loaded_bytes(source_path, expected.as_bytes());
crate::collaboration::publish_authored_file(
world,
source_path,
replacement.as_bytes(),
&expected,
crate::collaboration::FileWriteIntent::PrefabSourceHistory {
instance_root: *entity,
},
)
.map(|_| ())
}
fn undo_command(world: &mut World, command: &mut EditorCommand) {
@ -1372,6 +1702,9 @@ fn undo_command(world: &mut World, command: &mut EditorCommand) {
EditorCommand::SetPostProcessVolume { entity, old, .. } => {
apply_post_process_volume(world, *entity, old);
}
EditorCommand::SetNavigation { entity, old, .. } => {
apply_navigation_state(world, *entity, old);
}
EditorCommand::SetPhysics { entity, old, .. } => {
apply_physics(world, *entity, old);
}
@ -1485,6 +1818,9 @@ fn undo_command(world: &mut World, command: &mut EditorCommand) {
EditorCommand::RemoveComponent { entity, snapshot } => {
apply_authored_components(world, *entity, snapshot);
}
EditorCommand::ComponentTransaction(transaction) => {
apply_component_transaction(world, transaction, false);
}
}
}
@ -1573,6 +1909,9 @@ fn redo_command(world: &mut World, command: &mut EditorCommand) {
entity_mut.insert(new.clone());
}
}
EditorCommand::SetNavigation { entity, new, .. } => {
apply_navigation_state(world, *entity, new);
}
EditorCommand::SetPhysics { entity, new, .. } => {
if let Ok(mut entity_mut) = world.get_entity_mut(*entity) {
entity_mut.insert(new.clone());
@ -1706,6 +2045,9 @@ fn redo_command(world: &mut World, command: &mut EditorCommand) {
EditorCommand::RemoveComponent { entity, snapshot } => {
remove_authored_components(world, *entity, snapshot);
}
EditorCommand::ComponentTransaction(transaction) => {
apply_component_transaction(world, transaction, true);
}
}
}
@ -1862,6 +2204,45 @@ fn apply_post_process_volume(
}
}
fn apply_navigation_state(world: &mut World, entity: Entity, state: &NavigationComponentState) {
let has_navigation = state.bounds.is_some()
|| state.obstacle.is_some()
|| state.area.is_some()
|| state.link.is_some();
if let Ok(mut entity_mut) = world.get_entity_mut(entity) {
entity_mut
.remove::<NavigationBounds>()
.remove::<NavigationObstacle>()
.remove::<NavigationArea>()
.remove::<NavigationLink>();
if let Some(value) = &state.bounds {
entity_mut.insert(value.clone());
}
if let Some(value) = &state.obstacle {
entity_mut.insert(value.clone());
}
if let Some(value) = &state.area {
entity_mut.insert(value.clone());
}
if let Some(value) = &state.link {
entity_mut.insert(value.clone());
}
if has_navigation {
entity_mut.insert(ActorKind::Navigation);
}
}
if !has_navigation {
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 apply_physics(world: &mut World, entity: Entity, physics: &Option<PhysicsBody>) {
if let Ok(mut entity_mut) = world.get_entity_mut(entity) {
match physics {
@ -1986,6 +2367,9 @@ fn apply_authored_components(world: &mut World, entity: Entity, snapshot: &Edito
if let Some(v) = &snapshot.static_mesh_renderer {
entity_mut.insert(v.clone());
}
if let Some(v) = &snapshot.skinned_mesh_renderer {
entity_mut.insert(v.clone());
}
if let Some(v) = &snapshot.material {
entity_mut.insert(v.clone());
}
@ -2040,6 +2424,24 @@ fn apply_authored_components(world: &mut World, entity: Entity, snapshot: &Edito
if let Some(v) = &snapshot.objective {
entity_mut.insert(v.clone());
}
if let Some(v) = &snapshot.navigation_bounds {
entity_mut.insert(v.clone());
}
if let Some(v) = &snapshot.navigation_obstacle {
entity_mut.insert(v.clone());
}
if let Some(v) = &snapshot.navigation_area {
entity_mut.insert(v.clone());
}
if let Some(v) = &snapshot.navigation_link {
entity_mut.insert(v.clone());
}
if let Some(v) = &snapshot.inspector_order {
entity_mut.insert(v.clone());
}
if let Some(v) = &snapshot.component_states {
entity_mut.insert(v.clone());
}
}
}
@ -2061,6 +2463,9 @@ fn remove_authored_components(world: &mut World, entity: Entity, snapshot: &Edit
if snapshot.static_mesh_renderer.is_some() {
entity_mut.remove::<StaticMeshRenderer>();
}
if snapshot.skinned_mesh_renderer.is_some() {
entity_mut.remove::<SkinnedMeshRenderer>();
}
if snapshot.material.is_some() {
entity_mut.remove::<MaterialDesc>();
}
@ -2115,6 +2520,24 @@ fn remove_authored_components(world: &mut World, entity: Entity, snapshot: &Edit
if snapshot.objective.is_some() {
entity_mut.remove::<ObjectiveMarker>();
}
if snapshot.navigation_bounds.is_some() {
entity_mut.remove::<NavigationBounds>();
}
if snapshot.navigation_obstacle.is_some() {
entity_mut.remove::<NavigationObstacle>();
}
if snapshot.navigation_area.is_some() {
entity_mut.remove::<NavigationArea>();
}
if snapshot.navigation_link.is_some() {
entity_mut.remove::<NavigationLink>();
}
if snapshot.inspector_order.is_some() {
entity_mut.remove::<InspectorOrder>();
}
if snapshot.component_states.is_some() {
entity_mut.remove::<AuthoringComponentStates>();
}
}
if removed_dedicated_audio_kind {
let fallback = world
@ -2383,6 +2806,90 @@ mod tests {
use crate::operators::test_harness::{assert_undo_redo_round_trip, OperatorInvariantHarness};
use crate::operators::{ActiveOperator, OperatorPhase, OperatorStatus};
#[derive(Component, Reflect, Default, Debug, Clone, PartialEq)]
#[reflect(Component, Default)]
struct StaticExtensionFixture {
value: u32,
}
#[test]
fn reflected_extension_component_transaction_round_trips_atomically() {
let mut app = App::new();
app.register_type::<ActorKind>()
.register_type::<StaticExtensionFixture>();
let world = app.world_mut();
world.init_resource::<EditorHistory>();
world.init_resource::<SceneIo>();
let entity = world
.spawn((LevelObject, ActorKind::Light, Transform::default()))
.id();
let type_path = std::any::type_name::<StaticExtensionFixture>();
reflected_component_transaction(
world,
entity,
"Add Fixture",
"game.static_extension_fixture",
type_path,
|world, entity| {
world
.entity_mut(entity)
.insert(StaticExtensionFixture { value: 42 });
Ok(())
},
)
.unwrap();
assert_eq!(
world.get::<StaticExtensionFixture>(entity).unwrap().value,
42
);
assert_eq!(world.get::<ActorKind>(entity), Some(&ActorKind::Empty));
assert_eq!(world.resource::<EditorHistory>().undo_depth(), 1);
apply_command_undo(world);
assert!(world.get::<StaticExtensionFixture>(entity).is_none());
assert_eq!(world.get::<ActorKind>(entity), Some(&ActorKind::Light));
apply_command_redo(world);
assert_eq!(
world.get::<StaticExtensionFixture>(entity).unwrap().value,
42
);
assert_eq!(world.get::<ActorKind>(entity), Some(&ActorKind::Empty));
}
#[test]
fn snapshots_preserve_component_order_and_independent_active_state() {
let mut world = World::new();
let order = InspectorOrder {
component_ids: vec![shared::AUTHORING_COMPONENT_LIGHT.to_string()],
..Default::default()
};
let mut states = AuthoringComponentStates::default();
states.set_component_active(shared::COMPONENT_LIGHT_DESC, false);
let entity = world
.spawn((
LevelObject,
ActorKind::Light,
Transform::default(),
LightDesc::default(),
order.clone(),
states.clone(),
))
.id();
let snapshot = snapshot_entity(&world, entity).unwrap();
world.entity_mut(entity).despawn();
let restored = spawn_snapshot(&mut world, &snapshot);
assert_eq!(world.get::<InspectorOrder>(restored), Some(&order));
assert_eq!(
world.get::<AuthoringComponentStates>(restored),
Some(&states)
);
}
fn animation_controller(state_id: &str, crossfade_seconds: f32) -> AnimationControllerDesc {
AnimationControllerDesc {
skeleton: Some(
@ -2420,7 +2927,7 @@ mod tests {
let entity = world
.spawn((
LevelObject,
ActorKind::ImportedModel,
ActorKind::SkinnedMesh,
Transform::IDENTITY,
old.clone(),
))
@ -2439,6 +2946,36 @@ mod tests {
assert_eq!(world.get::<AnimationControllerDesc>(entity), Some(&new));
}
#[test]
fn navigation_component_history_round_trips_and_preserves_kind() {
let mut world = World::new();
world.init_resource::<EditorHistory>();
world.init_resource::<SceneIo>();
let entity = world
.spawn((LevelObject, ActorKind::Empty, Transform::IDENTITY))
.id();
let bounds = NavigationBounds::default();
set_navigation_with_history(
&mut world,
entity,
NavigationComponentState {
bounds: Some(bounds.clone()),
..Default::default()
},
);
assert_eq!(world.get::<NavigationBounds>(entity), Some(&bounds));
assert_eq!(world.get::<ActorKind>(entity), Some(&ActorKind::Navigation));
apply_command_undo(&mut world);
assert!(world.get::<NavigationBounds>(entity).is_none());
assert_eq!(world.get::<ActorKind>(entity), Some(&ActorKind::Empty));
apply_command_redo(&mut world);
assert_eq!(world.get::<NavigationBounds>(entity), Some(&bounds));
assert_eq!(world.get::<ActorKind>(entity), Some(&ActorKind::Navigation));
}
#[test]
fn undoing_a_new_animation_controller_removes_the_component() {
let mut world = World::new();
@ -2446,7 +2983,7 @@ mod tests {
world.init_resource::<SceneIo>();
let controller = animation_controller("idle", 0.2);
let entity = world
.spawn((LevelObject, ActorKind::ImportedModel, Transform::IDENTITY))
.spawn((LevelObject, ActorKind::SkinnedMesh, Transform::IDENTITY))
.id();
set_animation_controller_with_history(&mut world, entity, controller.clone());
@ -2467,9 +3004,10 @@ mod tests {
let source = world
.spawn((
LevelObject,
ActorKind::ImportedModel,
ActorKind::SkinnedMesh,
Name::new("Animated Robot"),
Transform::from_xyz(1.0, 2.0, 3.0),
SkinnedMeshRenderer::new("assets/models/robot.glb").with_asset_id("robot-asset"),
controller.clone(),
))
.id();
@ -2486,6 +3024,10 @@ mod tests {
world.get::<Name>(respawned).map(Name::as_str),
Some("Animated Robot")
);
assert_eq!(
world.get::<SkinnedMeshRenderer>(respawned),
Some(&SkinnedMeshRenderer::new("assets/models/robot.glb").with_asset_id("robot-asset"))
);
}
#[test]
@ -3013,6 +3555,73 @@ mod tests {
assert_eq!(unique.len(), ids.len(), "duplicated actors need fresh IDs");
}
#[test]
fn duplicate_navigation_bounds_gets_actor_owned_artifact_path() {
let mut world = World::new();
world.init_resource::<EditorHistory>();
world.init_resource::<SceneIo>();
world.init_resource::<SelectedEntity>();
let original_id = ActorId::new("navigation-bounds-original");
let original_bounds = NavigationBounds::for_actor(&original_id.0);
let original = world
.spawn((
LevelObject,
ActorKind::Navigation,
original_id,
Transform::IDENTITY,
original_bounds.clone(),
))
.id();
duplicate_entities_with_history(&mut world, &[original]);
let (duplicate_id, duplicate_bounds) = world
.query::<(Entity, &ActorId, &NavigationBounds)>()
.iter(&world)
.find_map(|(entity, actor_id, bounds)| {
(entity != original).then_some((actor_id.clone(), bounds.clone()))
})
.expect("duplicated navigation bounds should exist");
assert_ne!(
duplicate_bounds.artifact_path,
original_bounds.artifact_path
);
assert_eq!(
duplicate_bounds.artifact_path,
shared::navigation_artifact_path_for_actor(&duplicate_id.0)
);
apply_command_undo(&mut world);
apply_command_redo(&mut world);
let redone = world
.query::<(&ActorId, &NavigationBounds)>()
.iter(&world)
.find(|(actor_id, _)| **actor_id == duplicate_id)
.expect("redo should preserve the duplicate actor identity");
assert_eq!(redone.1, &duplicate_bounds);
}
#[test]
fn existing_navigation_snapshot_keeps_deliberate_generated_path() {
let mut world = World::new();
let mut bounds = NavigationBounds::for_actor("bounds-existing");
bounds.artifact_path = "assets/navigation/generated/custom-zone.nav.ron".into();
let entity = world
.spawn((
LevelObject,
ActorKind::Navigation,
ActorId::new("bounds-existing"),
Transform::IDENTITY,
bounds.clone(),
))
.id();
let mut snapshot = snapshot_entity(&world, entity).unwrap();
assign_missing_actor_ids(&mut snapshot);
assert_eq!(snapshot.navigation_bounds.as_ref(), Some(&bounds));
}
#[test]
fn linked_members_reject_direct_history_mutations() {
let mut world = World::new();

View File

@ -22,6 +22,7 @@ pub use ext::extensibility;
pub use ext::hot_reload;
pub use play::net_editor;
pub use play::state;
pub use project::collaboration;
pub use project::diagnostics_bundle;
pub use project::launcher;
pub use project::project_io;
@ -64,6 +65,7 @@ use infra::EditorInfraPlugin;
use operators::OperatorPlugin;
use play::audio_preview::AudioPreviewPlugin;
use play::PlaySessionPlugin;
use project::collaboration::CollaborationPlugin;
use project_io::ProjectIoPlugin;
use render_view::RenderViewPlugin;
use scene_io::SceneIoPlugin;
@ -111,6 +113,7 @@ impl PluginGroup for EditorPluginGroup {
.add(EditorVisualizersPlugin)
.add(EditorHistoryPlugin)
.add(SceneIoPlugin)
.add(CollaborationPlugin)
.add(EditorUiPlugin)
.add(EditorSessionPlugin)
.add(brp::BrpPlugin);

View File

@ -6,7 +6,10 @@ use bevy::window::WindowRef;
use game::player::PlayerCamera;
use protocol::PlayerInputIntent;
use settings::SimTuning;
use shared::{inspector_component_active, InspectorOrder, PlayerSpawn, COMPONENT_PLAYER_SPAWN};
use shared::{
authoring_component_active, AuthoringComponentStates, InspectorOrder, PlayerSpawn,
COMPONENT_PLAYER_SPAWN,
};
use sim::{Crouching, Grounded, JumpState, Player, PlayerVelocity};
use crate::render_target::ViewportRenderTarget;
@ -66,10 +69,16 @@ fn bootstrap_player_on_play(world: &mut World) {
.unwrap_or_else(|| SimTuning::from_physics(&settings::PhysicsSettings::default()));
let spawn = world
.query_filtered::<(&Transform, Option<&InspectorOrder>), With<PlayerSpawn>>()
.query_filtered::<(
&Transform,
Option<&AuthoringComponentStates>,
Option<&InspectorOrder>,
), With<PlayerSpawn>>()
.iter(world)
.filter(|(_, order)| inspector_component_active(*order, COMPONENT_PLAYER_SPAWN))
.map(|(transform, _)| transform)
.filter(|(_, states, legacy_order)| {
authoring_component_active(*states, *legacy_order, COMPONENT_PLAYER_SPAWN)
})
.map(|(transform, _, _)| transform)
.next()
.copied()
.unwrap_or_else(default_player_spawn);

File diff suppressed because it is too large Load Diff

View File

@ -1,5 +1,6 @@
//! Project workspace I/O, settings UI, and recent-level sync.
pub mod collaboration;
pub mod diagnostics_bundle;
pub mod launcher;
pub mod project_io;

View File

@ -1,18 +1,28 @@
//! Editor-only Project Settings panel (egui). Data lives in the `settings` crate.
use std::path::Path;
use bevy::prelude::*;
use bevy_egui::egui;
use game_hot::sync_project_sun_from_settings;
use settings::{
load_project_settings_from_path, save_project_settings, sync_sim_tuning, ExposureMode,
ProjectSettings, ProjectSettingsChanged, ProjectSettingsIo,
save_project_settings_to_string, sync_sim_tuning, ExposureMode, ProjectSettings,
ProjectSettingsChanged, ProjectSettingsIo,
};
use crate::project::collaboration::{
file_status_indicator_ui, publish_authored_file, CollaborationState, FileSnapshot,
FileWriteIntent,
};
use crate::scene_io::SceneIo;
/// Whether the Project Settings window is visible (editor UI state only).
#[derive(Resource, Debug, Default)]
pub struct ProjectSettingsPanel {
pub open: bool,
pub draft: Option<ProjectSettings>,
disk_snapshot: Option<FileSnapshot>,
save_error: Option<String>,
}
pub struct SettingsUiPlugin;
@ -39,11 +49,23 @@ pub fn project_settings_window(
) {
if !panel.open {
panel.draft = None;
panel.disk_snapshot = None;
panel.save_error = None;
return;
}
if panel.draft.is_none() {
panel.draft = Some(world.resource::<ProjectSettings>().clone());
let io = world.resource::<ProjectSettingsIo>();
panel.disk_snapshot = Some(
io.loaded_source
.as_deref()
.map(|source| {
FileSnapshot::from_loaded_bytes(Path::new(&io.path), source.as_bytes())
})
.unwrap_or_else(FileSnapshot::missing),
);
panel.save_error = None;
}
let mut open = panel.open;
@ -51,17 +73,33 @@ pub fn project_settings_window(
.open(&mut open)
.default_width(420.0)
.show(ctx, |ui| {
let Some(draft) = panel.draft.as_mut() else {
let ProjectSettingsPanel {
draft: Some(draft),
disk_snapshot,
save_error,
..
} = panel
else {
return;
};
let io_path = world.resource::<ProjectSettingsIo>().path.clone();
let mut io_dirty = world.resource::<ProjectSettingsIo>().dirty;
draw_project_settings_form(world, ui, draft, &io_path, &mut io_dirty);
draw_project_settings_form(
world,
ui,
draft,
&io_path,
&mut io_dirty,
disk_snapshot,
save_error,
);
world.resource_mut::<ProjectSettingsIo>().dirty = io_dirty;
});
panel.open = open;
if !open {
panel.draft = None;
panel.disk_snapshot = None;
panel.save_error = None;
}
}
@ -71,12 +109,26 @@ fn draw_project_settings_form(
draft: &mut ProjectSettings,
io_path: &str,
io_dirty: &mut bool,
disk_snapshot: &mut Option<FileSnapshot>,
save_error: &mut Option<String>,
) {
let caps = world
.get_resource::<settings::RenderingCapabilities>()
.cloned()
.unwrap_or_default();
ui.horizontal_wrapped(|ui| {
ui.small(egui::RichText::new(io_path).monospace());
if let Some(state) = world.get_resource::<CollaborationState>() {
let status = state.file_status(Path::new(io_path));
let _ = file_status_indicator_ui(ui, &status, Path::new(io_path));
}
});
if let Some(error) = save_error.as_deref() {
ui.colored_label(crate::ui::theme::ERROR, error);
}
ui.separator();
ui.collapsing("Global Illumination", |ui| {
egui::ComboBox::from_label("GiMode")
.selected_text(format!("{:?}", draft.rendering.gi_mode))
@ -335,25 +387,116 @@ fn draw_project_settings_form(
.clicked()
{
commit_project_settings(world, draft);
*io_dirty = true;
let settings = world.resource::<ProjectSettings>().clone();
let mut io = world.resource_mut::<ProjectSettingsIo>();
if let Err(error) = save_project_settings(&settings, &mut io) {
warn!("Save project settings failed: {error}");
} else {
*io_dirty = false;
let Some(expected) = disk_snapshot.clone() else {
let error = "Project settings disk baseline is unavailable; reload before saving";
*save_error = Some(error.into());
world.resource_mut::<SceneIo>().set_status(error);
return;
};
match persist_project_settings(world, &settings, &expected) {
Ok((snapshot, source)) => {
let mut io = world.resource_mut::<ProjectSettingsIo>();
io.dirty = false;
io.loaded_source = Some(source);
*disk_snapshot = Some(snapshot);
*save_error = None;
*io_dirty = false;
world
.resource_mut::<SceneIo>()
.set_status(format!("Saved project settings to {io_path}"));
}
Err(error) => {
warn!("Save project settings failed: {error}");
*save_error = Some(error.clone());
world
.resource_mut::<SceneIo>()
.set_status(format!("Save project settings failed: {error}"));
}
}
}
if ui.button("Revert from disk").clicked() {
*draft = load_project_settings_from_path(io_path);
commit_project_settings(world, draft);
world.resource_mut::<ProjectSettingsIo>().dirty = false;
*io_dirty = false;
match read_project_settings_document(Path::new(io_path)) {
Ok((settings, source, snapshot)) => {
*draft = settings;
commit_project_settings(world, draft);
let mut io = world.resource_mut::<ProjectSettingsIo>();
io.dirty = false;
io.loaded_source = Some(source);
*disk_snapshot = Some(snapshot);
*save_error = None;
*io_dirty = false;
}
Err(error) => {
*save_error = Some(error.clone());
world
.resource_mut::<SceneIo>()
.set_status(format!("Revert project settings failed: {error}"));
}
}
}
if *io_dirty {
ui.label(egui::RichText::new("Unsaved changes").color(egui::Color32::YELLOW));
}
}
fn persist_project_settings(
world: &mut World,
settings: &ProjectSettings,
expected: &FileSnapshot,
) -> Result<(FileSnapshot, String), String> {
let path = world.resource::<ProjectSettingsIo>().path.clone();
let source = save_project_settings_to_string(settings).map_err(|error| error.to_string())?;
let snapshot = publish_authored_file(
world,
Path::new(&path),
source.as_bytes(),
expected,
FileWriteIntent::ProjectSettings,
)?;
Ok((snapshot, source))
}
fn read_project_settings_document(
path: &Path,
) -> Result<(ProjectSettings, String, FileSnapshot), String> {
let source = std::fs::read_to_string(path)
.map_err(|error| format!("could not read {}: {error}", path.display()))?;
let settings: ProjectSettings = ron::from_str(&source)
.map_err(|error| format!("invalid project settings in {}: {error}", path.display()))?;
let snapshot = FileSnapshot::from_loaded_bytes(path, source.as_bytes());
Ok((settings, source, snapshot))
}
pub(crate) fn reload_project_settings_after_file_conflict(
world: &mut World,
path: &Path,
) -> Result<String, String> {
let active_path = world.resource::<ProjectSettingsIo>().path.clone();
if Path::new(&active_path) != path {
return Err("the project settings conflict no longer matches the active manifest".into());
}
let (settings, source, snapshot) = read_project_settings_document(path)?;
commit_project_settings(world, &settings);
{
let mut io = world.resource_mut::<ProjectSettingsIo>();
io.dirty = false;
io.loaded_source = Some(source);
}
{
let mut panel = world.resource_mut::<ProjectSettingsPanel>();
panel.draft = Some(settings);
panel.disk_snapshot = Some(snapshot);
panel.save_error = None;
}
Ok(format!("Reloaded project settings from {}", path.display()))
}
pub(crate) fn project_settings_conflict_copy_saved(world: &mut World) {
world.resource_mut::<ProjectSettingsPanel>().save_error = None;
}
fn commit_project_settings(world: &mut World, draft: &ProjectSettings) {
let previous = world.resource::<ProjectSettings>().rendering.gi_mode;
*world.resource_mut::<ProjectSettings>() = draft.clone();
@ -377,7 +520,11 @@ fn apply_project_settings_changes(
settings: Res<ProjectSettings>,
tuning: ResMut<settings::SimTuning>,
scene_suns: Query<
(&shared::LightDesc, Option<&shared::InspectorOrder>),
(
&shared::LightDesc,
Option<&shared::AuthoringComponentStates>,
Option<&shared::InspectorOrder>,
),
With<shared::LevelObject>,
>,
project_suns: Query<(&mut DirectionalLight, &mut Visibility), With<shared::ProjectSun>>,
@ -402,3 +549,35 @@ fn apply_project_settings_changes(
// Full stack refresh runs next frame via `render_view::sync_project_render_view`
// when `ProjectSettings` changes.
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn project_settings_save_refuses_an_external_revision() {
let root = std::env::temp_dir().join(format!(
"blacksite-project-settings-external-write-{}",
uuid::Uuid::new_v4()
));
let path = root.join("assets/project.ron");
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
let loaded = save_project_settings_to_string(&ProjectSettings::default()).unwrap();
std::fs::write(&path, &loaded).unwrap();
let expected = FileSnapshot::from_loaded_bytes(&path, loaded.as_bytes());
let mut world = World::new();
world.insert_resource(ProjectSettingsIo {
path: path.to_string_lossy().into_owned(),
loaded_source: Some(loaded),
..Default::default()
});
world.insert_resource(SceneIo::default());
std::fs::write(&path, b"external").unwrap();
let result = persist_project_settings(&mut world, &ProjectSettings::default(), &expected);
assert!(result.unwrap_err().contains("changed outside Blacksite"));
assert_eq!(std::fs::read(&path).unwrap(), b"external");
std::fs::remove_dir_all(root).unwrap();
}
}

View File

@ -13,17 +13,20 @@ pub(crate) fn atomic_write(path: &Path, bytes: &[u8]) -> Result<(), String> {
atomic_write_with_pre_rename(path, bytes, || Ok(()))
}
fn atomic_write_with_pre_rename(
pub(crate) fn atomic_write_with_pre_rename<E>(
path: &Path,
bytes: &[u8],
before_rename: impl FnOnce() -> Result<(), String>,
) -> Result<(), String> {
before_rename: impl FnOnce() -> Result<(), E>,
) -> Result<(), E>
where
E: From<String>,
{
let parent = path
.parent()
.filter(|parent| !parent.as_os_str().is_empty())
.unwrap_or_else(|| Path::new("."));
fs::create_dir_all(parent)
.map_err(|error| format!("could not create {}: {error}", parent.display()))?;
.map_err(|error| E::from(format!("could not create {}: {error}", parent.display())))?;
let file_name = path
.file_name()
@ -40,18 +43,21 @@ fn atomic_write_with_pre_rename(
.create_new(true)
.write(true)
.open(&temporary)
.map_err(|error| format!("could not create {}: {error}", temporary.display()))?;
file.write_all(bytes)
.map_err(|error| format!("could not write {}: {error}", temporary.display()))?;
.map_err(|error| {
E::from(format!("could not create {}: {error}", temporary.display()))
})?;
file.write_all(bytes).map_err(|error| {
E::from(format!("could not write {}: {error}", temporary.display()))
})?;
file.sync_all()
.map_err(|error| format!("could not sync {}: {error}", temporary.display()))?;
.map_err(|error| E::from(format!("could not sync {}: {error}", temporary.display())))?;
before_rename()?;
fs::rename(&temporary, path).map_err(|error| {
format!(
E::from(format!(
"could not replace {} with {}: {error}",
path.display(),
temporary.display()
)
))
})?;
sync_parent_directory(parent);
Ok(())

View File

@ -6,23 +6,26 @@ use bevy::ecs::system::SystemState;
use bevy::prelude::*;
use bevy::window::PrimaryWindow;
use bevy::world_serialization::serde::WorldDeserializer;
use bevy::world_serialization::{DynamicWorld, DynamicWorldBuilder, WorldInstance};
use bevy::world_serialization::{DynamicWorld, DynamicWorldBuilder, WorldFilter, WorldInstance};
use scene::{document::SceneDocument, strip_schema_version, validate_level_text};
use serde::de::DeserializeSeed;
use shared::{
infer_actor_kind, validate_actor, ActorId, ActorKind, ActorName, ActorValidationError,
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,
AuthoringComponentStates, EditorVisibility, HierarchySiblingIndex, HydratedPrefabMember,
HydratedPrefabReady, InspectorOrder, LevelObject, LightDesc, PrefabHydrationBlocked,
PrefabInstance, PrefabRef, SceneComposition,
};
#[cfg(test)]
use shared::{
AnimationControllerDesc, AudioSourceDesc, BrushDesc, ColliderDesc, SkinnedMeshRenderer,
};
use crate::assets::{import_external_assets, EditorAssets, IMPORTABLE_ASSET_EXTENSIONS};
use crate::history::{clear_level_objects, snapshot_entity, EditorHistory};
use crate::project::collaboration::{publish_authored_file, FileSnapshot, FileWriteIntent};
use crate::scene::recovery::{
atomic_write, default_state_root, discard_recovery_snapshots, latest_recovery_snapshot,
default_state_root, discard_recovery_snapshots, latest_recovery_snapshot,
write_recovery_snapshot,
};
use crate::selection::SelectedEntity;
@ -58,6 +61,7 @@ pub struct SceneTab {
pub dirty: bool,
snapshot: String,
recovery_snapshot: Option<PathBuf>,
disk_snapshot: Option<FileSnapshot>,
}
impl SceneTab {
@ -86,6 +90,7 @@ pub struct SceneIo {
pub events: VecDeque<SceneIoEvent>,
pub tabs: Vec<SceneTab>,
pub active_tab: usize,
change_revision: u64,
next_event_id: u64,
next_tab_id: u64,
}
@ -123,8 +128,10 @@ impl Default for SceneIo {
dirty: false,
snapshot: String::new(),
recovery_snapshot: None,
disk_snapshot: None,
}],
active_tab: 0,
change_revision: 0,
next_event_id: 1,
next_tab_id: 2,
}
@ -134,14 +141,20 @@ impl Default for SceneIo {
impl SceneIo {
pub fn mark_dirty(&mut self) {
self.dirty = true;
self.change_revision = self.change_revision.wrapping_add(1);
self.sync_active_tab_metadata();
}
pub fn mark_clean(&mut self) {
self.dirty = false;
self.change_revision = self.change_revision.wrapping_add(1);
self.sync_active_tab_metadata();
}
pub fn change_revision(&self) -> u64 {
self.change_revision
}
pub fn active_path_label(&self) -> String {
self.active_path
.as_ref()
@ -242,9 +255,14 @@ fn load_startup_scene(world: &mut World) {
let path = active_path.unwrap_or(default_path);
match load_level(world, &path) {
Ok(()) => {
world.resource_mut::<SceneIo>().active_path = Some(path.clone());
world.resource_mut::<SceneIo>().mark_clean();
Ok(disk_snapshot) => {
{
let mut io = world.resource_mut::<SceneIo>();
io.active_path = Some(path.clone());
let active_tab = io.active_tab;
io.tabs[active_tab].disk_snapshot = Some(disk_snapshot);
io.mark_clean();
}
remember_path(world, path.clone());
world.resource_mut::<EditorHistory>().clear();
refresh_recovery_notice(world, &path);
@ -386,7 +404,7 @@ fn open_path(world: &mut World, path: PathBuf) -> String {
return format!("Open failed: could not preserve active scene: {error}");
}
match load_level(world, &path) {
Ok(()) => {
Ok(disk_snapshot) => {
let id = world.resource_mut::<SceneIo>().allocate_tab_id();
{
let mut io = world.resource_mut::<SceneIo>();
@ -396,6 +414,7 @@ fn open_path(world: &mut World, path: PathBuf) -> String {
dirty: false,
snapshot: String::new(),
recovery_snapshot: None,
disk_snapshot: Some(disk_snapshot),
});
io.active_tab = io.tabs.len() - 1;
io.active_path = Some(path.clone());
@ -432,6 +451,7 @@ fn new_scene_tab(world: &mut World) -> String {
dirty: false,
snapshot: String::new(),
recovery_snapshot: None,
disk_snapshot: None,
});
io.active_tab = io.tabs.len() - 1;
io.active_path = None;
@ -540,6 +560,7 @@ fn close_scene_tab(world: &mut World, index: usize) -> String {
dirty: false,
snapshot: String::new(),
recovery_snapshot: None,
disk_snapshot: None,
}];
io.active_tab = 0;
io.active_path = None;
@ -681,12 +702,24 @@ fn save_selection_as_prefab(world: &mut World) -> String {
else {
return "Save prefab cancelled".to_string();
};
let expected = match FileSnapshot::capture(&path) {
Ok(expected) => expected,
Err(error) => return format!("Save prefab failed: {error}"),
};
if ensure_unique_actor_ids(world, &selection) > 0 {
world.resource_mut::<SceneIo>().mark_dirty();
}
match save_prefab_entities(world, &path, selection) {
match save_prefab_entities(
world,
&path,
selection,
SceneWriteContext::Standalone {
expected,
description: "prefab copy",
},
) {
Ok(count) => {
world.resource_mut::<EditorAssets>().refresh();
format!(
@ -765,6 +798,7 @@ fn save_prefab_entities(
world: &mut World,
path: &Path,
entities: Vec<Entity>,
write_context: SceneWriteContext,
) -> Result<usize, String> {
let count = entities.len();
let original_actor_ids: Vec<_> = entities
@ -781,7 +815,7 @@ fn save_prefab_entities(
.clone(),
);
scene::validate_prefab_graph_text(&text, path, &project_root)?;
atomic_write(path, text.as_bytes())?;
publish_scene_text(world, path, text.as_bytes(), count, write_context)?;
Ok(count)
})();
if result.is_err() {
@ -811,7 +845,7 @@ pub fn save_active_or_prompt_world(world: &mut World) -> String {
fn save_active_or_prompt(world: &mut World) -> String {
let path = world.resource::<SceneIo>().active_path.clone();
match path {
Some(path) => match save_level(world, &path) {
Some(path) => match save_level(world, &path, SceneWriteContext::Active) {
Ok(count) => {
world.resource_mut::<SceneIo>().mark_clean();
remember_path(world, path.clone());
@ -833,8 +867,12 @@ fn save_with_dialog(world: &mut World) -> String {
else {
return "Save cancelled".to_string();
};
let expected = match FileSnapshot::capture(&path) {
Ok(expected) => expected,
Err(error) => return format!("Save failed: {error}"),
};
match save_level(world, &path) {
match save_level(world, &path, SceneWriteContext::ActiveSaveAs { expected }) {
Ok(count) => {
world.resource_mut::<SceneIo>().active_path = Some(path.clone());
world.resource_mut::<SceneIo>().mark_clean();
@ -891,8 +929,20 @@ fn export_selection_with_dialog(world: &mut World) -> String {
else {
return "Export cancelled".to_string();
};
let expected = match FileSnapshot::capture(&path) {
Ok(expected) => expected,
Err(error) => return format!("Export failed: {error}"),
};
match save_standalone_entities(world, &path, vec![entity]) {
match save_standalone_entities(
world,
&path,
vec![entity],
SceneWriteContext::Standalone {
expected,
description: "selection export",
},
) {
Ok(count) => format!("Exported {count} selected entity to {}", path.display()),
Err(err) => format!("Export failed: {err}"),
}
@ -924,16 +974,20 @@ fn clear_scene_world(world: &mut World) {
}
}
fn save_level(world: &mut World, path: &Path) -> Result<usize, String> {
fn save_level(
world: &mut World,
path: &Path,
write_context: SceneWriteContext,
) -> Result<usize, String> {
let entities = authored_scene_entities(world);
if is_prefab_document(path) {
save_prefab_entities(world, path, entities)
save_prefab_entities(world, path, entities, write_context)
} else {
save_entities(world, path, entities)
save_entities(world, path, entities, write_context)
}
}
fn serialize_active_scene(world: &mut World) -> Result<String, String> {
pub(crate) fn serialize_active_scene(world: &mut World) -> Result<String, String> {
let entities = authored_scene_entities(world);
if world
.resource::<SceneIo>()
@ -1006,6 +1060,27 @@ fn format_actor_validation(err: ActorValidationError) -> String {
ActorValidationError::ImportedModelHasStaticMeshRenderer => {
"Save failed: ImportedModel actor cannot have StaticMeshRenderer".into()
}
ActorValidationError::SkinnedMeshMissingRenderer => {
"Save failed: SkinnedMesh actor requires SkinnedMeshRenderer".into()
}
ActorValidationError::SkinnedMeshInvalidRenderer => {
"Save failed: SkinnedMeshRenderer requires a model source path".into()
}
ActorValidationError::SkinnedMeshHasPrimitive => {
"Save failed: SkinnedMesh actor cannot have Primitive".into()
}
ActorValidationError::SkinnedMeshHasStaticMeshRenderer => {
"Save failed: SkinnedMesh actor cannot have StaticMeshRenderer".into()
}
ActorValidationError::SkinnedMeshHasModelRef => {
"Save failed: SkinnedMesh actor cannot have ModelRef".into()
}
ActorValidationError::SkinnedMeshRendererActorKindMismatch => {
"Save failed: SkinnedMeshRenderer requires ActorKind::SkinnedMesh".into()
}
ActorValidationError::ConflictingGeometrySources => {
"Save failed: actor has more than one primary geometry source component".into()
}
ActorValidationError::LightMissingLightDesc => {
"Save failed: Light actor requires LightDesc".into()
}
@ -1048,8 +1123,9 @@ fn format_actor_validation(err: ActorValidationError) -> String {
ActorValidationError::AudioListenerInvalidEarGap => {
"Save failed: audio listener ear gap must be positive and finite".into()
}
ActorValidationError::AnimationControllerMissingModelRef => {
"Save failed: animation controller requires ModelRef on the same actor".into()
ActorValidationError::AnimationControllerMissingSkinnedMeshRenderer => {
"Save failed: animation controller requires SkinnedMeshRenderer on the same actor"
.into()
}
ActorValidationError::AnimationControllerMissingSkeleton => {
"Save failed: animation controller requires an assigned skeleton".into()
@ -1093,13 +1169,21 @@ fn format_actor_validation(err: ActorValidationError) -> String {
ActorValidationError::PostProcessVolumeInvalidOverrideScalar => {
"Save failed: post-process volume override contains invalid scalar".into()
}
ActorValidationError::InvalidNavigation(message) => {
format!("Save failed: invalid navigation authoring: {message}")
}
}
}
fn save_entities(world: &mut World, path: &Path, entities: Vec<Entity>) -> Result<usize, String> {
fn save_entities(
world: &mut World,
path: &Path,
entities: Vec<Entity>,
write_context: SceneWriteContext,
) -> Result<usize, String> {
let count = entities.len();
let text = serialize_entities(world, entities)?;
atomic_write(path, text.as_bytes())?;
publish_scene_text(world, path, text.as_bytes(), count, write_context)?;
Ok(count)
}
@ -1107,13 +1191,108 @@ fn save_standalone_entities(
world: &mut World,
path: &Path,
entities: Vec<Entity>,
write_context: SceneWriteContext,
) -> Result<usize, String> {
let count = entities.len();
let text = serialize_standalone_entities(world, entities)?;
atomic_write(path, text.as_bytes())?;
publish_scene_text(world, path, text.as_bytes(), count, write_context)?;
Ok(count)
}
#[derive(Clone)]
enum SceneWriteContext {
Active,
ActiveSaveAs {
expected: FileSnapshot,
},
Standalone {
expected: FileSnapshot,
description: &'static str,
},
}
fn publish_scene_text(
world: &mut World,
path: &Path,
bytes: &[u8],
entity_count: usize,
context: SceneWriteContext,
) -> Result<(), String> {
match context {
SceneWriteContext::Active | SceneWriteContext::ActiveSaveAs { .. } => {
let (tab_id, expected) = match context {
SceneWriteContext::Active => active_scene_write_baseline(world, path)?,
SceneWriteContext::ActiveSaveAs { expected } => {
(active_scene_tab_id(world)?, expected)
}
SceneWriteContext::Standalone { .. } => unreachable!(),
};
let snapshot = publish_authored_file(
world,
path,
bytes,
&expected,
FileWriteIntent::Scene {
tab_id,
entity_count,
},
)?;
let mut io = world.resource_mut::<SceneIo>();
let Some(tab) = io.tabs.iter_mut().find(|tab| tab.id == tab_id) else {
return Err("saved scene tab no longer exists".into());
};
tab.disk_snapshot = Some(snapshot);
}
SceneWriteContext::Standalone {
expected,
description,
} => {
publish_authored_file(
world,
path,
bytes,
&expected,
FileWriteIntent::Standalone {
description: description.into(),
},
)?;
}
}
Ok(())
}
fn active_scene_write_baseline(
world: &World,
destination: &Path,
) -> Result<(u64, FileSnapshot), String> {
let io = world.resource::<SceneIo>();
let tab = io
.tabs
.get(io.active_tab)
.ok_or_else(|| "active scene tab is missing".to_string())?;
if io.active_path.as_deref() != Some(destination) || tab.path.as_deref() != Some(destination) {
return Err(format!(
"{} is not the active scene destination; use Save As",
destination.display()
));
}
let expected = tab.disk_snapshot.clone().ok_or_else(|| {
format!(
"scene disk baseline is unavailable for {}; reload it or use Save As",
destination.display()
)
})?;
Ok((tab.id, expected))
}
fn active_scene_tab_id(world: &World) -> Result<u64, String> {
let io = world.resource::<SceneIo>();
io.tabs
.get(io.active_tab)
.map(|tab| tab.id)
.ok_or_else(|| "active scene tab is missing".to_string())
}
fn serialize_entities(world: &mut World, entities: Vec<Entity>) -> Result<String, String> {
serialize_entities_inner_with_resources(world, entities, true)
}
@ -1179,39 +1358,26 @@ fn serialize_entities_inner_with_resources(
let result = (|| {
let ron = {
let registry = world.resource::<AppTypeRegistry>().read();
let mut component_filter = WorldFilter::deny_all()
.allow::<Name>()
.allow::<Transform>()
.allow::<ChildOf>()
.allow::<LevelObject>()
.allow::<ActorId>()
.allow::<ActorName>()
.allow::<HierarchySiblingIndex>()
.allow::<EditorVisibility>();
let component_registry = world
.get_resource::<crate::ui::component_registry::EditorComponentRegistry>()
.cloned()
.unwrap_or_default();
for descriptor in &component_registry.descriptors {
if let Some(registration) = registry.get_with_type_path(descriptor.type_name) {
component_filter = component_filter.allow_by_id(registration.type_id());
}
}
let builder = DynamicWorldBuilder::from_world(world, &registry)
.deny_all()
.allow_component::<Name>()
.allow_component::<Transform>()
.allow_component::<ChildOf>()
.allow_component::<LevelObject>()
.allow_component::<ActorId>()
.allow_component::<ActorName>()
.allow_component::<ActorKind>()
.allow_component::<InspectorOrder>()
.allow_component::<Primitive>()
.allow_component::<BrushDesc>()
.allow_component::<StaticMeshRenderer>()
.allow_component::<MaterialDesc>()
.allow_component::<MaterialOverride>()
.allow_component::<RigidBodyDesc>()
.allow_component::<ColliderDesc>()
.allow_component::<PhysicsBody>()
.allow_component::<LightDesc>()
.allow_component::<AnimationControllerDesc>()
.allow_component::<AudioSourceDesc>()
.allow_component::<AudioListenerDesc>()
.allow_component::<PlayerSpawn>()
.allow_component::<ModelRef>()
.allow_component::<PrefabRef>()
.allow_component::<PrefabInstance>()
.allow_component::<WeaponSpawn>()
.allow_component::<TriggerVolume>()
.allow_component::<PostProcessVolumeDesc>()
.allow_component::<TeamSpawn>()
.allow_component::<ObjectiveMarker>()
.allow_component::<HierarchySiblingIndex>()
.allow_component::<EditorVisibility>()
.with_component_filter(component_filter)
.extract_entities(entities.into_iter());
let scene = if include_scene_resources {
builder
@ -1408,7 +1574,7 @@ fn restore_recovery(world: &mut World) -> String {
return "No scene recovery snapshot is available".to_string();
};
match load_level(world, &snapshot) {
Ok(()) => {
Ok(_) => {
mark_recovery_restored(&mut world.resource_mut::<SceneIo>(), active_path);
world.resource_mut::<EditorHistory>().clear();
format!(
@ -1449,8 +1615,12 @@ fn save_recovery_copy_with_dialog(world: &mut World) -> String {
else {
return "Save recovery copy cancelled".to_string();
};
let expected = match FileSnapshot::capture(&destination) {
Ok(expected) => expected,
Err(error) => return format!("Save recovery copy failed: {error}"),
};
match write_recovery_copy(&snapshot, &destination) {
match write_recovery_copy(world, &snapshot, &destination, &expected) {
Ok(()) => format!(
"Saved recovery copy {} from {}",
destination.display(),
@ -1460,10 +1630,24 @@ fn save_recovery_copy_with_dialog(world: &mut World) -> String {
}
}
fn write_recovery_copy(snapshot: &Path, destination: &Path) -> Result<(), String> {
fn write_recovery_copy(
world: &mut World,
snapshot: &Path,
destination: &Path,
expected: &FileSnapshot,
) -> Result<(), String> {
let bytes = std::fs::read(snapshot)
.map_err(|error| format!("could not read {}: {error}", snapshot.display()))?;
atomic_write(destination, &bytes)
publish_authored_file(
world,
destination,
&bytes,
expected,
FileWriteIntent::Standalone {
description: "recovery scene copy".into(),
},
)
.map(|_| ())
}
fn mark_recovery_restored(io: &mut SceneIo, active_path: Option<PathBuf>) {
@ -1500,14 +1684,79 @@ fn discard_recovery(world: &mut World) -> String {
}
}
fn load_level(world: &mut World, path: &Path) -> Result<(), String> {
fn load_level(world: &mut World, path: &Path) -> Result<FileSnapshot, String> {
if !path.exists() {
return Err(format!("{} does not exist yet", path.display()));
}
let text = std::fs::read_to_string(path)
.map_err(|err| format!("could not read {}: {err}", path.display()))?;
load_level_text(world, Some(path), &text)
let disk_snapshot = FileSnapshot::from_loaded_bytes(path, text.as_bytes());
load_level_text(world, Some(path), &text)?;
Ok(disk_snapshot)
}
pub(crate) fn reload_scene_after_file_conflict(
world: &mut World,
tab_id: u64,
path: &Path,
) -> Result<String, String> {
let active_matches = {
let io = world.resource::<SceneIo>();
io.tabs
.get(io.active_tab)
.is_some_and(|tab| tab.id == tab_id)
};
if !active_matches {
return Err("the scene conflict no longer belongs to the active tab".into());
}
let disk_snapshot = load_level(world, path)?;
{
let mut io = world.resource_mut::<SceneIo>();
io.active_path = Some(path.to_path_buf());
io.recovery_snapshot = None;
let active_tab = io.active_tab;
io.tabs[active_tab].disk_snapshot = Some(disk_snapshot);
io.mark_clean();
}
world.resource_mut::<EditorHistory>().clear();
remember_path(world, path.to_path_buf());
refresh_recovery_notice(world, path);
capture_active_tab(world)?;
Ok(format!("Reloaded {} from disk", path.display()))
}
pub(crate) fn adopt_scene_conflict_save_as(
world: &mut World,
tab_id: u64,
path: PathBuf,
disk_snapshot: FileSnapshot,
entity_count: usize,
) -> Result<String, String> {
let active_matches = {
let io = world.resource::<SceneIo>();
io.tabs
.get(io.active_tab)
.is_some_and(|tab| tab.id == tab_id)
};
if !active_matches {
return Err("the scene conflict no longer belongs to the active tab".into());
}
{
let mut io = world.resource_mut::<SceneIo>();
io.active_path = Some(path.clone());
let active_tab = io.active_tab;
io.tabs[active_tab].disk_snapshot = Some(disk_snapshot);
io.mark_clean();
}
remember_path(world, path.clone());
retire_scene_recovery(world, &path);
Ok(format!(
"Saved {entity_count} level entities to {}",
path.display()
))
}
fn load_level_text(world: &mut World, path: Option<&Path>, text: &str) -> Result<(), String> {
@ -1781,7 +2030,14 @@ fn finalize_scene_load(world: &mut World) {
let mut state: SystemState<(
Res<settings::ProjectSettings>,
Query<(&LightDesc, Option<&InspectorOrder>), With<LevelObject>>,
Query<
(
&LightDesc,
Option<&AuthoringComponentStates>,
Option<&InspectorOrder>,
),
With<LevelObject>,
>,
Query<(&mut DirectionalLight, &mut Visibility), With<shared::ProjectSun>>,
)> = SystemState::new(world);
@ -1801,10 +2057,8 @@ fn backfill_missing_actor_kinds(world: &mut World) {
let Ok(entity_ref) = world.get_entity(entity) else {
continue;
};
if entity_ref.get::<ActorKind>().is_some() {
continue;
}
if let Some(kind) = infer_actor_kind(entity_ref) {
let current = entity_ref.get::<ActorKind>().copied();
if let Some(kind) = infer_actor_kind(entity_ref).filter(|kind| current != Some(*kind)) {
world.entity_mut(entity).insert(kind);
}
}
@ -1856,6 +2110,12 @@ mod tests {
struct NoAssetLoads;
#[derive(Component, Reflect, Default)]
#[reflect(Component, Default)]
struct RegisteredSceneExtension {
value: u32,
}
impl LoadFromPath for NoAssetLoads {
fn load_from_path_erased(
&mut self,
@ -1866,6 +2126,43 @@ mod tests {
}
}
#[test]
fn registry_registered_extension_is_included_in_scene_persistence() {
let mut app = App::new();
app.register_type::<Name>()
.register_type::<Transform>()
.register_type::<LevelObject>()
.register_type::<ActorKind>()
.register_type::<SceneComposition>()
.register_type::<RegisteredSceneExtension>();
let mut component_registry =
crate::ui::component_registry::EditorComponentRegistry::default();
let mut descriptor = component_registry.descriptors[0].clone();
descriptor.id = "game.registered_scene_extension";
descriptor.type_name = std::any::type_name::<RegisteredSceneExtension>();
descriptor.display_name = "Registered Scene Extension";
descriptor.recommended = &[];
descriptor.conflicts_with = &[];
component_registry.register(descriptor).unwrap();
app.insert_resource(component_registry);
app.insert_resource(SceneComposition::default());
let entity = app
.world_mut()
.spawn((
Name::new("Extension"),
Transform::default(),
LevelObject,
ActorKind::Empty,
RegisteredSceneExtension { value: 73 },
))
.id();
let text = serialize_entities_inner(app.world_mut(), vec![entity]).unwrap();
assert!(text.contains(std::any::type_name::<RegisteredSceneExtension>()));
assert!(text.contains("value: 73") || text.contains("value:73"));
}
#[test]
fn restored_recovery_remains_available_until_save_or_discard() {
let authored = PathBuf::from("assets/levels/authored.scn.ron");
@ -1888,11 +2185,13 @@ mod tests {
let mut io = SceneIo::default();
assert_eq!(io.tabs.len(), 1);
assert!(!io.tabs[0].dirty);
let initial_revision = io.change_revision();
io.active_path = Some(PathBuf::from("assets/levels/arena.scn.ron"));
io.mark_dirty();
assert!(io.has_unsaved_tabs());
assert!(io.change_revision() > initial_revision);
assert!(io.tabs[0].dirty);
assert_eq!(
io.tabs[0].path.as_deref(),
@ -1907,6 +2206,7 @@ mod tests {
dirty: true,
snapshot: "dirty inactive tab".to_string(),
recovery_snapshot: None,
disk_snapshot: None,
});
assert!(
io.has_unsaved_tabs(),
@ -1921,6 +2221,61 @@ mod tests {
);
}
#[test]
fn active_scene_write_refuses_an_external_revision() {
let root = std::env::temp_dir().join(format!(
"blacksite-scene-external-write-{}",
uuid::Uuid::new_v4()
));
let path = root.join("assets/levels/main.scn.ron");
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(&path, b"loaded").unwrap();
let disk_snapshot = FileSnapshot::capture(&path).unwrap();
let mut io = SceneIo {
active_path: Some(path.clone()),
..Default::default()
};
io.tabs[0].path = Some(path.clone());
io.tabs[0].disk_snapshot = Some(disk_snapshot);
let mut world = World::new();
world.insert_resource(io);
std::fs::write(&path, b"external").unwrap();
let result = publish_scene_text(&mut world, &path, b"editor", 1, SceneWriteContext::Active);
assert!(result.unwrap_err().contains("changed outside Blacksite"));
assert_eq!(std::fs::read(&path).unwrap(), b"external");
assert!(world.resource::<SceneIo>().dirty);
std::fs::remove_dir_all(root).unwrap();
}
#[test]
fn scene_save_as_refuses_a_file_created_after_destination_selection() {
let root = std::env::temp_dir().join(format!(
"blacksite-scene-save-as-race-{}",
uuid::Uuid::new_v4()
));
let path = root.join("assets/levels/new.scn.ron");
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
let expected = FileSnapshot::missing();
let mut world = World::new();
world.init_resource::<SceneIo>();
std::fs::write(&path, b"external").unwrap();
let result = publish_scene_text(
&mut world,
&path,
b"editor",
1,
SceneWriteContext::ActiveSaveAs { expected },
);
assert!(result.unwrap_err().contains("changed outside Blacksite"));
assert_eq!(std::fs::read(&path).unwrap(), b"external");
assert!(world.resource::<SceneIo>().dirty);
std::fs::remove_dir_all(root).unwrap();
}
#[test]
fn composition_edits_participate_in_undo_and_redo() {
let mut app = App::new();
@ -2152,8 +2507,8 @@ mod tests {
Transform::IDENTITY,
LevelObject,
ActorId::new("animated-model"),
ActorKind::ImportedModel,
ModelRef::new(source_path),
ActorKind::SkinnedMesh,
SkinnedMeshRenderer::new(source_path),
controller.clone(),
AnimationPlayer::default(),
AnimationGraphHandle::default(),
@ -2171,6 +2526,8 @@ mod tests {
let decoded: AnimationControllerDesc = ron::from_str(&component.ron).unwrap();
assert_eq!(decoded, controller);
assert!(text.contains("shared::animation::SkinnedMeshRenderer"));
assert!(!text.contains("shared::components::ModelRef"));
assert!(!text.contains("AnimationPlayer"));
assert!(!text.contains("AnimationGraphHandle"));
assert!(!text.contains("AnimationTransitions"));
@ -2696,8 +3053,13 @@ mod tests {
.insert_resource(crate::ui::hierarchy_state::HierarchyPanelState::default());
app.world_mut().init_resource::<SceneIo>();
load_level(app.world_mut(), &prefab_path).unwrap();
save_level(app.world_mut(), &prefab_path).unwrap();
let expected = load_level(app.world_mut(), &prefab_path).unwrap();
save_level(
app.world_mut(),
&prefab_path,
SceneWriteContext::ActiveSaveAs { expected },
)
.unwrap();
scene::validate_prefab_graph(&prefab_path, &root).unwrap();
let document =
@ -2739,8 +3101,11 @@ mod tests {
let destination = root.join("kept.scn.ron");
std::fs::create_dir_all(&root).unwrap();
std::fs::write(&snapshot, b"recovered scene bytes").unwrap();
let expected = FileSnapshot::missing();
let mut world = World::new();
world.init_resource::<SceneIo>();
write_recovery_copy(&snapshot, &destination).unwrap();
write_recovery_copy(&mut world, &snapshot, &destination, &expected).unwrap();
assert_eq!(
std::fs::read(&destination).unwrap(),

View File

@ -5,7 +5,7 @@ mod transform;
use bevy::prelude::*;
use bevy_egui::egui;
use egui_phosphor_icons::icons;
use shared::{ActorId, ActorKind, ActorName, HydratedPrefabMember, LevelObject};
use shared::{infer_actor_kind, ActorId, ActorKind, ActorName, HydratedPrefabMember, LevelObject};
use crate::ext::extensibility::ActorInspectorSectionRegistry;
use crate::ui::inspector;
@ -29,9 +29,8 @@ pub fn draw_actor_inspector(world: &mut World, ui: &mut egui::Ui, entity: Entity
return;
}
let kind = entity_ref
.get::<ActorKind>()
.copied()
let kind = infer_actor_kind(entity_ref)
.or_else(|| entity_ref.get::<ActorKind>().copied())
.unwrap_or(ActorKind::Empty);
egui::Frame::new()
@ -111,6 +110,7 @@ pub fn draw_actor_inspector(world: &mut World, ui: &mut egui::Ui, entity: Entity
match kind {
ActorKind::StaticMesh
| ActorKind::SkinnedMesh
| ActorKind::Brush
| ActorKind::ImportedModel
| ActorKind::Light
@ -123,7 +123,8 @@ pub fn draw_actor_inspector(world: &mut World, ui: &mut egui::Ui, entity: Entity
| ActorKind::TeamSpawn
| ActorKind::Objective
| ActorKind::AudioSource
| ActorKind::AudioListener => {
| ActorKind::AudioListener
| ActorKind::Navigation => {
inspector::authoring_inspector_ui(world, ui, entity);
}
}
@ -139,7 +140,10 @@ pub fn draw_actor_inspector(world: &mut World, ui: &mut egui::Ui, entity: Entity
fn actor_icon(kind: ActorKind) -> egui_phosphor_icons::Icon {
match kind {
ActorKind::StaticMesh | ActorKind::Brush | ActorKind::ImportedModel => icons::CUBE,
ActorKind::StaticMesh
| ActorKind::SkinnedMesh
| ActorKind::Brush
| ActorKind::ImportedModel => icons::CUBE,
ActorKind::Light => icons::LIGHTBULB,
ActorKind::PrefabAnchor => icons::PACKAGE,
ActorKind::PlayerSpawn => icons::PERSON_SIMPLE_RUN,
@ -149,6 +153,7 @@ fn actor_icon(kind: ActorKind) -> egui_phosphor_icons::Icon {
ActorKind::TeamSpawn | ActorKind::Objective => icons::FLAG,
ActorKind::AudioSource => icons::SPEAKER_HIGH,
ActorKind::AudioListener => icons::HEADPHONES,
ActorKind::Navigation => icons::PATH,
ActorKind::Empty => icons::CIRCLE,
}
}

View File

@ -7,7 +7,7 @@ use bevy_egui::egui;
use egui_phosphor_icons::icons;
use shared::{
AnimationClipRecord, AnimationControllerDesc, AnimationManifest, AnimationSkeletonRecord,
AnimationStateDesc, EditorAssetRef, ModelRef, COMPONENT_ANIMATION_CONTROLLER_DESC,
AnimationStateDesc, EditorAssetRef, SkinnedMeshRenderer, COMPONENT_ANIMATION_CONTROLLER_DESC,
};
use crate::assets::animation::load_animation_manifest;
@ -656,8 +656,8 @@ fn manifest_summary(ui: &mut egui::Ui, manifest: &Result<AnimationManifest, Stri
fn manifest_for_actor(world: &World, entity: Entity) -> Result<AnimationManifest, String> {
let model = world
.get::<ModelRef>(entity)
.ok_or_else(|| "actor has no ModelRef".to_string())?;
.get::<SkinnedMeshRenderer>(entity)
.ok_or_else(|| "actor has no SkinnedMeshRenderer".to_string())?;
let registry = world
.get_resource::<AssetRegistry>()
.ok_or_else(|| "asset registry is unavailable".to_string())?;
@ -878,6 +878,7 @@ mod tests {
schema_version: 1,
asset_id: "model-id".into(),
label: "Robot".into(),
default_animation_clip_id: None,
source: AnimationManifestSource {
path: "assets/models/robot.glb".into(),
format: "glb".into(),

File diff suppressed because it is too large Load Diff

View File

@ -3,10 +3,11 @@
use std::collections::HashSet;
use bevy::prelude::*;
use shared::MaterialAsset;
use shared::{MaterialAsset, MaterialInstanceAsset};
use crate::asset_db::ImportSettings;
use crate::assets::{AssetSelection, ASSETS_ROOT, BUILTINS_FOLDER};
use crate::project::collaboration::FileSnapshot;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum AssetBrowserView {
@ -48,6 +49,7 @@ pub struct AssetBrowserUiState {
pub(crate) pending_delete: Option<AssetDeleteRequest>,
pub(crate) import_draft: Option<ImportSettingsDraft>,
pub(crate) material_draft: Option<MaterialAssetDraft>,
pub(crate) material_instance_draft: Option<MaterialInstanceAssetDraft>,
}
#[derive(Debug, Clone)]
@ -68,6 +70,15 @@ pub(crate) struct ImportSettingsDraft {
pub(crate) struct MaterialAssetDraft {
pub(crate) path: String,
pub(crate) asset: MaterialAsset,
pub(crate) disk_snapshot: FileSnapshot,
pub(crate) error: Option<String>,
}
#[derive(Debug, Clone)]
pub(crate) struct MaterialInstanceAssetDraft {
pub(crate) path: String,
pub(crate) asset: MaterialInstanceAsset,
pub(crate) disk_snapshot: FileSnapshot,
pub(crate) error: Option<String>,
}
@ -89,6 +100,7 @@ impl Default for AssetBrowserUiState {
pending_delete: None,
import_draft: None,
material_draft: None,
material_instance_draft: None,
}
}
}

View File

@ -1,6 +1,12 @@
//! Editor-visible component registry for actor inspector palettes.
use std::collections::{HashMap, HashSet};
use bevy::ecs::reflect::ReflectComponent;
use bevy::prelude::*;
use bevy::reflect::std_traits::ReflectDefault;
use bevy::reflect::GetTypeRegistration;
use bevy_egui::egui;
use egui_phosphor_icons::icons;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@ -9,6 +15,7 @@ pub enum EditorComponentCategory {
Rendering,
Animation,
Audio,
Navigation,
Physics,
Gameplay,
Volumes,
@ -17,6 +24,8 @@ pub enum EditorComponentCategory {
#[derive(Debug, Clone)]
pub struct EditorComponentDescriptor {
/// Stable editor/protocol identity. This must not be a Rust type path.
pub id: &'static str,
pub type_name: &'static str,
pub display_name: &'static str,
pub category: EditorComponentCategory,
@ -35,6 +44,266 @@ pub struct EditorComponentDescriptor {
#[derive(Resource, Debug, Clone)]
pub struct EditorComponentRegistry {
pub descriptors: Vec<EditorComponentDescriptor>,
inspectors: HashMap<&'static str, ComponentInspectorFn>,
}
pub type ComponentInspectorFn = fn(&mut World, &mut egui::Ui, Entity);
impl EditorComponentRegistry {
/// Registers a statically linked component extension.
///
/// Stable ids and reflected type paths are both unique. Returning an error
/// here prevents a game/editor extension from silently shadowing a built-in.
pub fn register(&mut self, descriptor: EditorComponentDescriptor) -> Result<(), String> {
if self
.descriptors
.iter()
.any(|existing| existing.id == descriptor.id)
{
return Err(format!(
"duplicate authoring component id `{}`",
descriptor.id
));
}
if self
.descriptors
.iter()
.any(|existing| existing.type_name == descriptor.type_name)
{
return Err(format!(
"component type `{}` is already registered",
descriptor.type_name
));
}
self.descriptors.push(descriptor);
Ok(())
}
pub fn register_with_inspector(
&mut self,
descriptor: EditorComponentDescriptor,
inspector: ComponentInspectorFn,
) -> Result<(), String> {
let type_name = descriptor.type_name;
self.register(descriptor)?;
self.inspectors.insert(type_name, inspector);
Ok(())
}
pub fn inspector(&self, type_name: &str) -> Option<ComponentInspectorFn> {
self.inspectors.get(type_name).copied()
}
/// Hard lifecycle requirements keyed by stable component IDs. Keeping this
/// lookup in the registry removes requirement policy from inspector code.
pub fn required_component_ids(
&self,
descriptor: &EditorComponentDescriptor,
) -> &'static [&'static str] {
match descriptor.id {
shared::AUTHORING_COMPONENT_ANIMATION_CONTROLLER => {
&[shared::AUTHORING_COMPONENT_SKINNED_MESH_RENDERER]
}
_ => &[],
}
}
pub fn present_dependents(
&self,
world: &World,
entity: Entity,
required_id: &str,
) -> Vec<&EditorComponentDescriptor> {
self.descriptors
.iter()
.filter(|descriptor| {
self.required_component_ids(descriptor)
.contains(&required_id)
&& self.component_present(world, entity, descriptor.type_name)
})
.collect()
}
pub fn by_id(&self, id: &str) -> Option<&EditorComponentDescriptor> {
self.descriptors
.iter()
.find(|descriptor| descriptor.id == id)
}
pub fn by_type_name(&self, type_name: &str) -> Option<&EditorComponentDescriptor> {
self.descriptors
.iter()
.find(|descriptor| descriptor.type_name == type_name)
}
pub fn stable_id_for_type<'a>(&self, type_name: &'a str) -> &'a str {
self.by_type_name(type_name)
.map(|descriptor| descriptor.id)
.unwrap_or(type_name)
}
pub fn ensure_component_order(&self, order: &mut shared::InspectorOrder, present: &[&str]) {
for type_name in present {
let component_id = self.stable_id_for_type(type_name);
if !order
.component_ids
.iter()
.any(|existing| existing == component_id)
{
order.component_ids.push(component_id.to_string());
}
}
order.component_type_names.clear();
}
pub fn ordered_components<'a>(
&self,
order: &shared::InspectorOrder,
present: &[&'a str],
) -> Vec<&'a str> {
let mut ordered = Vec::new();
for component_id in &order.component_ids {
if let Some(type_name) = present
.iter()
.find(|type_name| self.stable_id_for_type(type_name) == component_id)
{
ordered.push(*type_name);
}
}
for legacy_type_name in &order.component_type_names {
if let Some(type_name) = present
.iter()
.find(|type_name| **type_name == legacy_type_name)
{
if !ordered.contains(type_name) {
ordered.push(*type_name);
}
}
}
for type_name in present {
if !ordered.contains(type_name) {
ordered.push(*type_name);
}
}
ordered
}
pub fn move_component(
&self,
order: &mut shared::InspectorOrder,
type_name: &str,
offset: isize,
present: &[&str],
) -> bool {
self.ensure_component_order(order, present);
let component_id = self.stable_id_for_type(type_name);
let Some(index) = order
.component_ids
.iter()
.position(|existing| existing == component_id)
else {
return false;
};
let target =
(index as isize + offset).clamp(0, order.component_ids.len() as isize - 1) as usize;
if index == target {
return false;
}
let item = order.component_ids.remove(index);
order.component_ids.insert(target, item);
true
}
pub fn component_present(&self, world: &World, entity: Entity, type_name: &str) -> bool {
let Ok(entity_ref) = world.get_entity(entity) else {
return false;
};
let Some(app_registry) = world.get_resource::<AppTypeRegistry>() else {
return false;
};
let type_registry = app_registry.read();
type_registry
.get_with_type_path(type_name)
.and_then(|registration| registration.data::<ReflectComponent>())
.is_some_and(|component| component.contains(entity_ref))
}
/// Validates registry metadata against Bevy reflection before the first
/// inspector frame. This is also the contract test used by extensions.
pub fn validate(&self, world: &World) -> Result<(), Vec<String>> {
let Some(app_registry) = world.get_resource::<AppTypeRegistry>() else {
return Err(vec!["AppTypeRegistry is unavailable".to_string()]);
};
let type_registry = app_registry.read();
let mut errors = Vec::new();
let mut ids = HashSet::new();
let mut type_names = HashSet::new();
for descriptor in &self.descriptors {
if descriptor.id.trim().is_empty() {
errors.push(format!(
"{} has an empty stable id",
descriptor.display_name
));
} else if !ids.insert(descriptor.id) {
errors.push(format!("duplicate stable id `{}`", descriptor.id));
}
if !type_names.insert(descriptor.type_name) {
errors.push(format!(
"duplicate reflected type `{}`",
descriptor.type_name
));
}
match type_registry.get_with_type_path(descriptor.type_name) {
Some(registration) if registration.data::<ReflectComponent>().is_none() => errors
.push(format!(
"`{}` is registered without ReflectComponent",
descriptor.type_name
)),
None => errors.push(format!(
"reflected component `{}` is not registered",
descriptor.type_name
)),
Some(registration)
if descriptor.addable && registration.data::<ReflectDefault>().is_none() =>
{
errors.push(format!(
"addable component `{}` has no reflected Default",
descriptor.type_name
));
}
Some(_) => {}
}
for relation in descriptor
.recommended
.iter()
.chain(descriptor.conflicts_with.iter())
{
if !self
.descriptors
.iter()
.any(|candidate| candidate.type_name == *relation)
{
errors.push(format!(
"{} references unregistered component `{relation}`",
descriptor.id
));
}
}
for required_id in self.required_component_ids(descriptor) {
if self.by_id(required_id).is_none() {
errors.push(format!(
"{} requires unknown stable component id `{required_id}`",
descriptor.id
));
}
}
}
if errors.is_empty() {
Ok(())
} else {
Err(errors)
}
}
}
impl Default for EditorComponentRegistry {
@ -42,6 +311,7 @@ impl Default for EditorComponentRegistry {
Self {
descriptors: vec![
EditorComponentDescriptor {
id: shared::AUTHORING_COMPONENT_STATIC_MESH_RENDERER,
type_name: "shared::components::StaticMeshRenderer",
display_name: "Static Mesh Renderer",
category: EditorComponentCategory::Rendering,
@ -56,11 +326,35 @@ impl Default for EditorComponentRegistry {
conflicts_with: &[
"shared::components::Primitive",
"shared::components::BrushDesc",
"shared::animation::SkinnedMeshRenderer",
],
hydration_effect:
"Hydrates into generated mesh children and material bindings.",
},
EditorComponentDescriptor {
id: shared::AUTHORING_COMPONENT_SKINNED_MESH_RENDERER,
type_name: "shared::animation::SkinnedMeshRenderer",
display_name: "Skinned Mesh Renderer",
category: EditorComponentCategory::Rendering,
addable: false,
removable: true,
reorderable: true,
hidden: false,
icon: icons::PERSON_SIMPLE_RUN.as_str(),
description:
"Renders rigged geometry while preserving its imported joints and skin bindings.",
search_terms: &["mesh", "renderer", "skinned", "skeletal", "rigged", "joints"],
recommended: &["shared::animation::AnimationControllerDesc"],
conflicts_with: &[
"shared::components::Primitive",
"shared::components::BrushDesc",
"shared::components::StaticMeshRenderer",
],
hydration_effect:
"Hydrates through a dedicated scene root containing Bevy skinned meshes and joints.",
},
EditorComponentDescriptor {
id: shared::AUTHORING_COMPONENT_BRUSH,
type_name: "shared::components::BrushDesc",
display_name: "Brush",
category: EditorComponentCategory::Authoring,
@ -75,10 +369,12 @@ impl Default for EditorComponentRegistry {
conflicts_with: &[
"shared::components::Primitive",
"shared::components::StaticMeshRenderer",
"shared::animation::SkinnedMeshRenderer",
],
hydration_effect: "Hydrates into generated brush mesh children.",
},
EditorComponentDescriptor {
id: shared::AUTHORING_COMPONENT_MATERIAL,
type_name: "shared::components::MaterialDesc",
display_name: "Authoring Material",
category: EditorComponentCategory::Rendering,
@ -94,6 +390,7 @@ impl Default for EditorComponentRegistry {
hydration_effect: "Hydrates into Bevy material assets for renderable actors.",
},
EditorComponentDescriptor {
id: shared::AUTHORING_COMPONENT_RIGID_BODY,
type_name: "shared::components::RigidBodyDesc",
display_name: "Rigid Body",
category: EditorComponentCategory::Physics,
@ -109,6 +406,7 @@ impl Default for EditorComponentRegistry {
hydration_effect: "Hydrates into runtime Avian rigid-body state.",
},
EditorComponentDescriptor {
id: shared::AUTHORING_COMPONENT_COLLIDER,
type_name: "shared::components::ColliderDesc",
display_name: "Collider",
category: EditorComponentCategory::Physics,
@ -124,6 +422,7 @@ impl Default for EditorComponentRegistry {
hydration_effect: "Hydrates into runtime Avian collider state.",
},
EditorComponentDescriptor {
id: shared::AUTHORING_COMPONENT_LIGHT,
type_name: "shared::components::LightDesc",
display_name: "Light",
category: EditorComponentCategory::Rendering,
@ -139,6 +438,7 @@ impl Default for EditorComponentRegistry {
hydration_effect: "Hydrates into runtime Bevy light components.",
},
EditorComponentDescriptor {
id: shared::AUTHORING_COMPONENT_ANIMATION_CONTROLLER,
type_name: "shared::animation::AnimationControllerDesc",
display_name: "Animation Controller",
category: EditorComponentCategory::Animation,
@ -149,12 +449,13 @@ impl Default for EditorComponentRegistry {
icon: icons::FILM_SLATE.as_str(),
description: "Authors named animation states for an imported model rig.",
search_terms: &["animation", "controller", "state", "clip", "skeleton", "rig"],
recommended: &[],
recommended: &["shared::animation::SkinnedMeshRenderer"],
conflicts_with: &[],
hydration_effect:
"Hydrates into a Bevy animation graph and player bound to the imported model.",
},
EditorComponentDescriptor {
id: shared::AUTHORING_COMPONENT_AUDIO_SOURCE,
type_name: "shared::components::AudioSourceDesc",
display_name: "Audio Source",
category: EditorComponentCategory::Audio,
@ -171,6 +472,7 @@ impl Default for EditorComponentRegistry {
"Hydrates into runtime Bevy audio voices with bus and attenuation control.",
},
EditorComponentDescriptor {
id: shared::AUTHORING_COMPONENT_AUDIO_LISTENER,
type_name: "shared::components::AudioListenerDesc",
display_name: "Audio Listener",
category: EditorComponentCategory::Audio,
@ -187,6 +489,7 @@ impl Default for EditorComponentRegistry {
"The highest-priority enabled listener becomes the runtime listener.",
},
EditorComponentDescriptor {
id: shared::AUTHORING_COMPONENT_PRIMITIVE,
type_name: "shared::components::Primitive",
display_name: "Primitive",
category: EditorComponentCategory::Authoring,
@ -201,11 +504,13 @@ impl Default for EditorComponentRegistry {
conflicts_with: &[
"shared::components::StaticMeshRenderer",
"shared::components::BrushDesc",
"shared::animation::SkinnedMeshRenderer",
],
hydration_effect:
"Hydrates into generated primitive render and collision data.",
},
EditorComponentDescriptor {
id: shared::AUTHORING_COMPONENT_PLAYER_SPAWN,
type_name: "shared::components::PlayerSpawn",
display_name: "Player Spawn",
category: EditorComponentCategory::Gameplay,
@ -222,6 +527,7 @@ impl Default for EditorComponentRegistry {
"Used by PIE/session startup; no saved runtime player is created.",
},
EditorComponentDescriptor {
id: shared::AUTHORING_COMPONENT_WEAPON_SPAWN,
type_name: "shared::components::WeaponSpawn",
display_name: "Weapon Spawn",
category: EditorComponentCategory::Gameplay,
@ -237,6 +543,7 @@ impl Default for EditorComponentRegistry {
hydration_effect: "Hydrates into gameplay spawn metadata.",
},
EditorComponentDescriptor {
id: shared::AUTHORING_COMPONENT_TRIGGER_VOLUME,
type_name: "shared::components::TriggerVolume",
display_name: "Trigger Volume",
category: EditorComponentCategory::Volumes,
@ -252,6 +559,7 @@ impl Default for EditorComponentRegistry {
hydration_effect: "Hydrates into gameplay trigger queries and visualizers.",
},
EditorComponentDescriptor {
id: shared::AUTHORING_COMPONENT_TEAM_SPAWN,
type_name: "shared::components::TeamSpawn",
display_name: "Team Spawn",
category: EditorComponentCategory::Gameplay,
@ -267,6 +575,7 @@ impl Default for EditorComponentRegistry {
hydration_effect: "Hydrates into gameplay spawn metadata.",
},
EditorComponentDescriptor {
id: shared::AUTHORING_COMPONENT_OBJECTIVE,
type_name: "shared::components::ObjectiveMarker",
display_name: "Objective Marker",
category: EditorComponentCategory::Gameplay,
@ -282,6 +591,7 @@ impl Default for EditorComponentRegistry {
hydration_effect: "Hydrates into gameplay objective metadata.",
},
EditorComponentDescriptor {
id: shared::AUTHORING_COMPONENT_POST_PROCESS_VOLUME,
type_name: "shared::components::PostProcessVolumeDesc",
display_name: "Post-Process Volume",
category: EditorComponentCategory::Volumes,
@ -298,6 +608,215 @@ impl Default for EditorComponentRegistry {
"Affects active camera rendering when the camera is inside the volume.",
},
EditorComponentDescriptor {
id: shared::AUTHORING_COMPONENT_NAVIGATION_BOUNDS,
type_name: "shared::navigation::NavigationBounds",
display_name: "Navigation Bounds",
category: EditorComponentCategory::Navigation,
addable: true,
removable: true,
reorderable: true,
hidden: false,
icon: icons::PATH.as_str(),
description: "Defines the world region and agent profile for a navigation bake.",
search_terms: &["navigation", "navmesh", "bounds", "agent", "bake"],
recommended: &[],
conflicts_with: &[
"shared::navigation::NavigationObstacle",
"shared::navigation::NavigationArea",
"shared::navigation::NavigationLink",
],
hydration_effect: "Feeds the generated navigation artifact; no render component is hydrated.",
},
EditorComponentDescriptor {
id: shared::AUTHORING_COMPONENT_NAVIGATION_OBSTACLE,
type_name: "shared::navigation::NavigationObstacle",
display_name: "Navigation Obstacle",
category: EditorComponentCategory::Navigation,
addable: true,
removable: true,
reorderable: true,
hidden: false,
icon: icons::SELECTION_SLASH.as_str(),
description: "Carves an axis-aligned blocked volume from overlapping bakes.",
search_terms: &["navigation", "navmesh", "obstacle", "block", "carve"],
recommended: &[],
conflicts_with: &[
"shared::navigation::NavigationBounds",
"shared::navigation::NavigationArea",
"shared::navigation::NavigationLink",
],
hydration_effect: "Contributes a deterministic unwalkable volume during bake.",
},
EditorComponentDescriptor {
id: shared::AUTHORING_COMPONENT_NAVIGATION_AREA,
type_name: "shared::navigation::NavigationArea",
display_name: "Navigation Area",
category: EditorComponentCategory::Navigation,
addable: true,
removable: true,
reorderable: true,
hidden: false,
icon: icons::POLYGON.as_str(),
description: "Labels a walkable or blocked navigation area and traversal cost.",
search_terms: &["navigation", "navmesh", "area", "cost", "walkable"],
recommended: &[],
conflicts_with: &[
"shared::navigation::NavigationBounds",
"shared::navigation::NavigationObstacle",
"shared::navigation::NavigationLink",
],
hydration_effect: "Stores area metadata and blocked-volume contribution in the bake artifact.",
},
EditorComponentDescriptor {
id: shared::AUTHORING_COMPONENT_NAVIGATION_LINK,
type_name: "shared::navigation::NavigationLink",
display_name: "Navigation Link",
category: EditorComponentCategory::Navigation,
addable: true,
removable: true,
reorderable: true,
hidden: false,
icon: icons::LINK.as_str(),
description: "Connects otherwise isolated navigation regions with an explicit traversal.",
search_terms: &["navigation", "navmesh", "link", "jump", "door", "ladder"],
recommended: &[],
conflicts_with: &[
"shared::navigation::NavigationBounds",
"shared::navigation::NavigationObstacle",
"shared::navigation::NavigationArea",
],
hydration_effect: "Adds a validated directed or bidirectional runtime path connection.",
},
EditorComponentDescriptor {
id: shared::AUTHORING_COMPONENT_LEGACY_PHYSICS_BODY,
type_name: "shared::components::PhysicsBody",
display_name: "Legacy Physics Body",
category: EditorComponentCategory::Physics,
addable: false,
removable: true,
reorderable: true,
hidden: false,
icon: icons::SPHERE.as_str(),
description: "Legacy combined physics authoring retained for scene compatibility.",
search_terms: &["legacy", "physics", "body"],
recommended: &[],
conflicts_with: &[],
hydration_effect: "Hydrates through the compatibility physics path.",
},
EditorComponentDescriptor {
id: "prefab.instance",
type_name: "shared::components::PrefabInstance",
display_name: "Prefab Instance",
category: EditorComponentCategory::Authoring,
addable: false,
removable: false,
reorderable: true,
hidden: false,
icon: icons::PACKAGE.as_str(),
description: "Stores a linked prefab instance and its overrides.",
search_terms: &["prefab", "instance", "link"],
recommended: &[],
conflicts_with: &[],
hydration_effect: "Hydrates linked prefab members beneath this anchor.",
},
EditorComponentDescriptor {
id: "prefab.reference",
type_name: "shared::components::PrefabRef",
display_name: "Prefab Reference",
category: EditorComponentCategory::Authoring,
addable: false,
removable: false,
reorderable: false,
hidden: true,
icon: icons::PACKAGE.as_str(),
description: "Stable source reference owned by a prefab anchor.",
search_terms: &["prefab", "reference"],
recommended: &[],
conflicts_with: &[],
hydration_effect: "Resolves the prefab source asset.",
},
EditorComponentDescriptor {
id: "render.imported_model",
type_name: "shared::components::ModelRef",
display_name: "Imported Model Reference",
category: EditorComponentCategory::Rendering,
addable: false,
removable: false,
reorderable: false,
hidden: true,
icon: icons::CUBE_TRANSPARENT.as_str(),
description: "Legacy full-scene imported model reference.",
search_terms: &["model", "imported", "scene"],
recommended: &[],
conflicts_with: &[],
hydration_effect: "Hydrates the referenced imported model scene.",
},
EditorComponentDescriptor {
id: "render.material_override_legacy",
type_name: "shared::components::MaterialOverride",
display_name: "Legacy Material Override",
category: EditorComponentCategory::Rendering,
addable: false,
removable: false,
reorderable: false,
hidden: true,
icon: icons::PALETTE.as_str(),
description: "Legacy renderer material slot overrides retained for migration.",
search_terms: &["material", "override", "legacy"],
recommended: &[],
conflicts_with: &[],
hydration_effect: "Applies compatibility material overrides.",
},
EditorComponentDescriptor {
id: "render.project_sun",
type_name: "shared::components::ProjectSun",
display_name: "Project Sun",
category: EditorComponentCategory::Rendering,
addable: false,
removable: false,
reorderable: true,
hidden: false,
icon: icons::SUN.as_str(),
description: "Marks the scene light that overrides the project sun.",
search_terms: &["sun", "directional", "project"],
recommended: &[],
conflicts_with: &[],
hydration_effect: "Selects the authored directional project light.",
},
EditorComponentDescriptor {
id: "core.actor_kind",
type_name: "shared::components::ActorKind",
display_name: "Actor Kind Hint",
category: EditorComponentCategory::Editor,
addable: false,
removable: false,
reorderable: false,
hidden: true,
icon: icons::TAG.as_str(),
description: "Compatibility and presentation hint derived from primary components.",
search_terms: &["actor", "kind", "hint"],
recommended: &[],
conflicts_with: &[],
hydration_effect: "No runtime effect; validation uses component composition.",
},
EditorComponentDescriptor {
id: "editor.component_states",
type_name: "shared::components::AuthoringComponentStates",
display_name: "Authoring Component States",
category: EditorComponentCategory::Editor,
addable: false,
removable: false,
reorderable: false,
hidden: true,
icon: icons::TOGGLE_LEFT.as_str(),
description: "Stores persisted component enable state independently of inspector layout.",
search_terms: &["editor", "active", "enabled"],
recommended: &[],
conflicts_with: &[],
hydration_effect: "Controls whether opted-in authoring components hydrate.",
},
EditorComponentDescriptor {
id: "editor.inspector_order",
type_name: "shared::components::InspectorOrder",
display_name: "Inspector Order",
category: EditorComponentCategory::Editor,
@ -306,14 +825,62 @@ impl Default for EditorComponentRegistry {
reorderable: false,
hidden: true,
icon: icons::LIST_BULLETS.as_str(),
description: "Stores editor-only component ordering and active-state metadata.",
description: "Stores editor-only component card ordering.",
search_terms: &["editor", "order"],
recommended: &[],
conflicts_with: &[],
hydration_effect:
"Controls editor presentation and authoring component active state.",
hydration_effect: "Controls editor presentation only; runtime active state is stored separately.",
},
],
inspectors: HashMap::new(),
}
}
}
/// Registers one statically linked game/editor component with reflection,
/// lifecycle metadata, and its editor inspector in a single call.
pub fn register_authoring_component<T>(
app: &mut App,
descriptor: EditorComponentDescriptor,
inspector: ComponentInspectorFn,
) -> Result<(), String>
where
T: Component + Reflect + GetTypeRegistration,
{
app.register_type::<T>();
if !app.world().contains_resource::<EditorComponentRegistry>() {
app.init_resource::<EditorComponentRegistry>();
}
app.world_mut()
.resource_mut::<EditorComponentRegistry>()
.register_with_inspector(descriptor, inspector)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn built_in_registry_is_unique_and_reflectable() {
let mut app = App::new();
app.add_plugins(shared::SharedTypesPlugin)
.init_resource::<EditorComponentRegistry>();
app.world()
.resource::<EditorComponentRegistry>()
.validate(app.world())
.unwrap();
}
#[test]
fn static_extensions_cannot_shadow_stable_ids_or_types() {
let mut registry = EditorComponentRegistry::default();
let mut duplicate = registry.descriptors[0].clone();
duplicate.type_name = "game::editor_ext::StaticFixture";
assert!(registry.register(duplicate).is_err());
let mut duplicate = registry.descriptors[0].clone();
duplicate.id = "game.static_fixture";
assert!(registry.register(duplicate).is_err());
}
}

View File

@ -91,6 +91,7 @@ pub fn light_snapshot(name: &str, translation: Vec3) -> EditorEntitySnapshot {
primitive: None,
brush: None,
static_mesh_renderer: None,
skinned_mesh_renderer: None,
material: None,
material_override: None,
rigid_body: None,
@ -109,8 +110,18 @@ pub fn light_snapshot(name: &str, translation: Vec3) -> EditorEntitySnapshot {
post_process_volume: None,
team_spawn: None,
objective: None,
navigation_bounds: None,
navigation_obstacle: None,
navigation_area: None,
navigation_link: None,
hierarchy_sibling_index: 0,
editor_visibility: shared::EditorVisibility::default(),
inspector_order: None,
component_states: None,
children: Vec::new(),
}
}
@ -142,6 +153,7 @@ pub fn ensure_player_spawn_for_edit(world: &mut World) -> Entity {
primitive: None,
brush: None,
static_mesh_renderer: None,
skinned_mesh_renderer: None,
material: None,
material_override: None,
rigid_body: None,
@ -160,8 +172,18 @@ pub fn ensure_player_spawn_for_edit(world: &mut World) -> Entity {
post_process_volume: None,
team_spawn: None,
objective: None,
navigation_bounds: None,
navigation_obstacle: None,
navigation_area: None,
navigation_link: None,
hierarchy_sibling_index: 0,
editor_visibility: shared::EditorVisibility::default(),
inspector_order: None,
component_states: None,
children: Vec::new(),
},
)
@ -233,6 +255,7 @@ pub fn create_scene_sun_override_from_project_settings(world: &mut World) -> Ent
primitive: None,
brush: None,
static_mesh_renderer: None,
skinned_mesh_renderer: None,
material: None,
material_override: None,
rigid_body: None,
@ -251,8 +274,18 @@ pub fn create_scene_sun_override_from_project_settings(world: &mut World) -> Ent
post_process_volume: None,
team_spawn: None,
objective: None,
navigation_bounds: None,
navigation_obstacle: None,
navigation_area: None,
navigation_link: None,
hierarchy_sibling_index: 0,
editor_visibility: shared::EditorVisibility::default(),
inspector_order: None,
component_states: None,
children: Vec::new(),
},
)

View File

@ -4,6 +4,7 @@ use bevy::prelude::*;
use shared::{
ActorKind, AuthoringLightKind, EditorVisibility, HierarchySiblingIndex, HydratedPrefabMember,
LevelObject, LightDesc, ModelRef, PhysicsBody, PlayerSpawn, PrefabInstance,
SkinnedMeshRenderer,
};
use super::helpers::{entity_name, is_level_object, HierarchyNodeKind};
@ -203,7 +204,10 @@ fn actor_kind_sort_key(world: &World, entity: Entity) -> u8 {
.copied()
.map(|kind| match kind {
ActorKind::Empty => 0,
ActorKind::Brush | ActorKind::StaticMesh | ActorKind::ImportedModel => 1,
ActorKind::Brush
| ActorKind::StaticMesh
| ActorKind::SkinnedMesh
| ActorKind::ImportedModel => 1,
ActorKind::Light => 2,
ActorKind::AudioSource | ActorKind::AudioListener => 3,
ActorKind::PrefabAnchor => 3,
@ -213,6 +217,7 @@ fn actor_kind_sort_key(world: &World, entity: Entity) -> u8 {
ActorKind::PostProcessVolume => 6,
ActorKind::TeamSpawn => 7,
ActorKind::Objective => 8,
ActorKind::Navigation => 6,
})
.unwrap_or(9)
}
@ -464,6 +469,7 @@ pub fn actor_kind_icon(kind: ActorKind) -> &'static str {
ActorKind::Empty => icons::FOLDER.as_str(),
ActorKind::Brush => icons::CUBE.as_str(),
ActorKind::StaticMesh => icons::CUBE.as_str(),
ActorKind::SkinnedMesh => icons::PERSON_SIMPLE_RUN.as_str(),
ActorKind::ImportedModel => icons::CUBE_TRANSPARENT.as_str(),
ActorKind::Light => icons::LIGHTBULB.as_str(),
ActorKind::AudioSource => icons::SPEAKER_HIGH.as_str(),
@ -475,6 +481,7 @@ pub fn actor_kind_icon(kind: ActorKind) -> &'static str {
ActorKind::PostProcessVolume => icons::CAMERA.as_str(),
ActorKind::TeamSpawn => icons::FLAG.as_str(),
ActorKind::Objective => icons::TARGET.as_str(),
ActorKind::Navigation => icons::PATH.as_str(),
}
}
@ -534,6 +541,10 @@ pub fn entity_matches_filter(world: &World, entity: Entity, filter_lower: &str)
.is_some()
.then_some("spawn"),
world.get::<ModelRef>(entity).is_some().then_some("model"),
world
.get::<SkinnedMeshRenderer>(entity)
.is_some()
.then_some("skinned model"),
world
.get::<PhysicsBody>(entity)
.is_some()

File diff suppressed because it is too large Load Diff

View File

@ -158,6 +158,44 @@ pub fn top_menu_bar(
crate::rendering_diagnostics::spawn_post_process_volume_at_camera(world);
ui.close();
}
ui.separator();
ui.menu_button("Navigation", |ui| {
use super::navigation_inspector::{
spawn_navigation_actor, NavigationActorType,
};
for (label, actor_type) in [
("Create Bounds", NavigationActorType::Bounds),
("Create Obstacle", NavigationActorType::Obstacle),
("Create Area", NavigationActorType::Area),
("Create Link", NavigationActorType::Link),
] {
if menu_item(ui, label, None, true).clicked() {
spawn_navigation_actor(world, actor_type);
ui.close();
}
}
ui.separator();
let selected_bounds =
selected.as_slice().iter().copied().find(|entity| {
world.get::<shared::NavigationBounds>(*entity).is_some()
});
if menu_item(ui, "Bake Selected Bounds", None, selected_bounds.is_some())
.clicked()
{
if let Some(entity) = selected_bounds {
super::navigation_inspector::bake_bounds(world, entity);
}
ui.close();
}
if menu_item(ui, "Test Selected Path", None, selected_bounds.is_some())
.clicked()
{
if let Some(entity) = selected_bounds {
super::navigation_inspector::query_preview(world, entity);
}
ui.close();
}
});
});
ui.menu_button("Build", |ui| {

View File

@ -3,7 +3,7 @@ mod animation_inspector;
mod asset_browser;
mod audio_inspector;
mod build;
mod component_registry;
pub mod component_registry;
mod diagnostics;
mod dock_tabs;
mod fonts;
@ -14,6 +14,7 @@ pub mod hierarchy_state;
pub(crate) mod inspector;
mod layout;
mod menu;
pub(crate) mod navigation_inspector;
mod play_controls;
mod post_process_volume_ui;
mod scene_tabs;
@ -38,6 +39,10 @@ use crate::project_io::UserPreferences;
use crate::selection::SelectedEntity;
use crate::state::EditorMode;
pub(crate) use asset_browser::{
adopt_material_conflict_save_as, reload_material_after_file_conflict,
validate_material_conflict_destination,
};
pub use build::BuildPanel;
pub use diagnostics::{BrushDiagnosticsPanel, DiagnosticsPanel};
pub use layout::LayoutSaveTimer;
@ -214,6 +219,8 @@ impl UiState {
project_settings_window(world, ctx, &mut panel);
});
crate::collaboration::file_conflict_modal(world, ctx);
if ctx.input(|input| input.key_pressed(egui::Key::F1)) {
world.resource_mut::<ViewportUiState>().shortcuts_open = true;
}
@ -294,6 +301,7 @@ impl Plugin for EditorUiPlugin {
.init_resource::<inspector::InspectorClipboard>()
.init_resource::<inspector::InspectorPanelState>()
.init_resource::<animation_inspector::AnimationInspectorState>()
.init_resource::<navigation_inspector::NavigationEditorState>()
.init_resource::<ViewportUiState>()
.init_resource::<LayoutSaveTimer>()
.add_systems(
@ -301,6 +309,7 @@ impl Plugin for EditorUiPlugin {
init_hierarchy_panel_from_prefs
.after(crate::project_io::load_user_preferences_on_startup),
)
.add_systems(Startup, validate_editor_component_registry)
.add_systems(
Startup,
apply_ui_state_from_prefs
@ -312,11 +321,25 @@ impl Plugin for EditorUiPlugin {
(
hierarchy_state::tick_hierarchy_prefs_save,
build::tick_build_job,
navigation_inspector::sync_navigation_state,
),
);
}
}
fn validate_editor_component_registry(world: &mut World) {
let errors = world
.resource::<component_registry::EditorComponentRegistry>()
.validate(world)
.err();
if let Some(errors) = errors {
panic!(
"invalid authoring component registry:\n{}",
errors.join("\n")
);
}
}
fn apply_ui_state_from_prefs(mut ui_state: ResMut<UiState>, prefs: Res<UserPreferences>) {
*ui_state = UiState::from_prefs(&prefs);
}

File diff suppressed because it is too large Load Diff

View File

@ -7,6 +7,7 @@ use egui_phosphor_icons::icons;
use crate::history::EditorHistory;
use crate::operators::ActiveOperator;
use crate::project::collaboration::{file_status_indicator_ui, CollaborationState};
use crate::scene_io::SceneIo;
use crate::state::{EditorMode, PlayPaused};
@ -47,6 +48,16 @@ pub fn status_bar_ui(
.on_hover_text(status);
},
|ui| {
if let (Some(state), Some(path)) = (
world.get_resource::<CollaborationState>(),
world.resource::<SceneIo>().active_path.as_deref(),
) {
let status = state.file_status(path);
if status.indicator().is_some() {
let _ = file_status_indicator_ui(ui, &status, path);
ui.separator();
}
}
let (mode_icon, mode_label, mode_color) = match mode {
EditorMode::Editing => (icons::PENCIL_SIMPLE, "EDIT", ACCENT),
EditorMode::Playing => {

View File

@ -141,6 +141,25 @@ fn left_toolbar(world: &mut World, ui: &mut egui::Ui) {
if icon_button(ui, icons::PENCIL_SIMPLE_LINE, "Draw brush (B)").clicked() {
start_draw_brush_tool(world);
}
let selected_navigation_bounds = world
.resource::<crate::selection::SelectedEntity>()
.0
.filter(|entity| world.get::<shared::NavigationBounds>(*entity).is_some());
let navigation_tip = if selected_navigation_bounds.is_some() {
"Bake selected navigation bounds"
} else {
"Create navigation bounds"
};
if icon_button(ui, icons::PATH, navigation_tip).clicked() {
if let Some(entity) = selected_navigation_bounds {
super::navigation_inspector::bake_bounds(world, entity);
} else {
super::navigation_inspector::spawn_navigation_actor(
world,
super::navigation_inspector::NavigationActorType::Bounds,
);
}
}
toolbar_separator(ui);

View File

@ -875,7 +875,7 @@ fn viewport_asset_drop_ui(
.as_slice()
.iter()
.copied()
.find(|entity| world.get::<shared::ModelRef>(*entity).is_some())
.find(|entity| world.get::<shared::SkinnedMeshRenderer>(*entity).is_some())
{
assign_animation_clip_operator(world, selection, entity);
} else {
@ -1011,7 +1011,7 @@ fn asset_drag_descriptor(
} if selected
.as_slice()
.iter()
.any(|entity| world.get::<shared::ModelRef>(*entity).is_some()) =>
.any(|entity| world.get::<shared::SkinnedMeshRenderer>(*entity).is_some()) =>
{
"Assign clip to animated actor"
}

View File

@ -16,8 +16,8 @@ use bevy::shader::ShaderRef;
use shared::{
ActorKind, AudioListenerDesc, AudioSourceDesc, AuthoringLightKind, ColliderDesc, LevelObject,
LightDesc, ModelRef, ObjectiveMarker, PhysicsBody, PlayerSpawn, PostProcessVolumeDesc,
PrefabInstance, PrefabRef, Primitive, RaytracingExcluded, StaticMeshRenderer, TeamSpawn,
TriggerVolume, WeaponSpawn,
PrefabInstance, PrefabRef, Primitive, RaytracingExcluded, SkinnedMeshRenderer,
StaticMeshRenderer, TeamSpawn, TriggerVolume, WeaponSpawn,
};
use crate::camera::EditorCamera;
@ -203,6 +203,7 @@ struct ActorIconComponents {
light_kind: Option<AuthoringLightKind>,
has_primitive: bool,
has_static_mesh_renderer: bool,
has_skinned_mesh_renderer: bool,
has_model_ref: bool,
has_prefab_ref: bool,
has_prefab_instance: bool,
@ -386,6 +387,7 @@ fn sync_actor_icons(
),
(With<LevelObject>, Without<EditorOnly>),
>,
skinned_meshes: Query<(), With<SkinnedMeshRenderer>>,
audio: Query<(Has<AudioSourceDesc>, Has<AudioListenerDesc>)>,
physics: Query<(), Or<(With<PhysicsBody>, With<ColliderDesc>)>>,
) {
@ -419,6 +421,7 @@ fn sync_actor_icons(
has_primitive,
has_static_mesh_renderer: static_mesh_renderer
.is_some_and(has_resolved_static_mesh_slot),
has_skinned_mesh_renderer: skinned_meshes.contains(target),
has_model_ref,
has_prefab_ref,
has_prefab_instance,
@ -489,6 +492,7 @@ fn sync_actor_icons(
has_primitive,
has_static_mesh_renderer: static_mesh_renderer
.is_some_and(has_resolved_static_mesh_slot),
has_skinned_mesh_renderer: skinned_meshes.contains(target),
has_model_ref,
has_prefab_ref,
has_prefab_instance,
@ -637,6 +641,16 @@ fn icon_for_actor_components(components: ActorIconComponents) -> ActorIconSpec {
warning
}
}
ActorKind::SkinnedMesh => {
if components.has_skinned_mesh_renderer {
ActorIconSpec {
image: ActorIconImage::Mesh,
category: ActorIconCategory::Mesh,
}
} else {
warning
}
}
ActorKind::ImportedModel => {
if components.has_model_ref {
ActorIconSpec {
@ -752,6 +766,10 @@ fn icon_for_actor_components(components: ActorIconComponents) -> ActorIconSpec {
warning
}
}
ActorKind::Navigation => ActorIconSpec {
image: ActorIconImage::GameMarker,
category: ActorIconCategory::Volume,
},
ActorKind::Empty => {
if components.has_physics {
ActorIconSpec {

View File

@ -374,6 +374,7 @@ fn brush_snapshot_from_convex_points(
primitive: None,
brush: Some(brush),
static_mesh_renderer: None,
skinned_mesh_renderer: None,
material: Some(MaterialDesc::default()),
material_override: None,
rigid_body: None,
@ -392,8 +393,18 @@ fn brush_snapshot_from_convex_points(
post_process_volume: None,
team_spawn: None,
objective: None,
navigation_bounds: None,
navigation_obstacle: None,
navigation_area: None,
navigation_link: None,
hierarchy_sibling_index: 0,
editor_visibility: EditorVisibility::default(),
inspector_order: None,
component_states: None,
children: Vec::new(),
})
}

View File

@ -290,14 +290,33 @@ fn solari_geometry_summary(world: &World, ui: &mut egui::Ui) {
ui.separator();
ui.heading("Solari raytracing scene");
ui.label(format!(
"Project meshes: {} tagged, {} eligible, {} Solari-compatible assets, {} unsupported, {} excluded helpers, {} outside level roots",
"Project meshes: {} tagged, {} eligible, {} Solari-compatible assets, {} unsupported, {} deformed safely omitted, {} excluded helpers, {} outside level roots",
stats.tagged_meshes,
stats.project_meshes,
stats.compatible_mesh_assets,
stats.unsupported_meshes,
stats.deformed_meshes_excluded,
stats.excluded_meshes,
stats.missing_level_root_meshes
));
if stats.surface_material_meshes > 0 {
ui.label(format!(
"Surface ABI materials: {} meshes, {} missing an active evaluator generation",
stats.surface_material_meshes, stats.surface_materials_missing_evaluator
));
}
if stats.deformed_meshes_excluded > 0 {
ui.colored_label(
egui::Color32::from_rgb(255, 180, 100),
"Skinned/morphed meshes are omitted from Solari until dynamic deformed BLAS is available; no bind-pose proxy is traced.",
);
}
if stats.surface_materials_missing_evaluator > 0 {
ui.colored_label(
egui::Color32::from_rgb(255, 140, 140),
"Surface meshes without a validated evaluator are omitted instead of using an approximate ray-tracing material.",
);
}
if stats.incompatible_mesh_assets > 0 {
ui.colored_label(
egui::Color32::from_rgb(255, 180, 100),
@ -457,6 +476,7 @@ pub fn spawn_post_process_volume_at_camera(world: &mut World) {
primitive: None,
brush: None,
static_mesh_renderer: None,
skinned_mesh_renderer: None,
material: None,
material_override: None,
rigid_body: None,
@ -475,8 +495,18 @@ pub fn spawn_post_process_volume_at_camera(world: &mut World) {
post_process_volume: Some(PostProcessVolumeDesc::default()),
team_spawn: None,
objective: None,
navigation_bounds: None,
navigation_obstacle: None,
navigation_area: None,
navigation_link: None,
hierarchy_sibling_index: 0,
editor_visibility: EditorVisibility::default(),
inspector_order: None,
component_states: None,
children: Vec::new(),
};
spawn_with_history(world, snapshot);

View File

@ -5,8 +5,9 @@ use bevy::ecs::system::SystemParam;
use bevy::prelude::*;
use shared::{
AudioListenerDesc, AudioSourceDesc, AuthoringLightKind, ColliderDesc, ColliderShapeDesc,
LevelObject, LightDesc, ModelRef, ObjectiveMarker, PhysicsBody, PlayerSpawn,
PostProcessVolumeDesc, PrefabRef, ProjectSun, RaytracingExcluded, TeamSpawn, TriggerVolume,
LevelObject, LightDesc, ModelRef, NavigationArea, NavigationBounds, NavigationLink,
NavigationObstacle, ObjectiveMarker, PhysicsBody, PlayerSpawn, PostProcessVolumeDesc,
PrefabRef, ProjectSun, RaytracingExcluded, SkinnedMeshRenderer, TeamSpawn, TriggerVolume,
WeaponSpawn,
};
use sim::tuning::{EYE_OFFSET_STAND, PLAYER_LENGTH, PLAYER_RADIUS};
@ -107,7 +108,15 @@ struct VisualizerTargetQueries<'w, 's> {
lights: Query<'w, 's, Entity, (With<LevelObject>, With<LightDesc>)>,
spawns: Query<'w, 's, Entity, (With<LevelObject>, With<PlayerSpawn>)>,
prefabs: Query<'w, 's, Entity, (With<LevelObject>, With<PrefabRef>)>,
models: Query<'w, 's, Entity, (With<LevelObject>, With<ModelRef>)>,
models: Query<
'w,
's,
Entity,
(
With<LevelObject>,
Or<(With<ModelRef>, With<SkinnedMeshRenderer>)>,
),
>,
players: Query<'w, 's, Entity, With<Player>>,
cameras: Query<'w, 's, Entity, With<PlayerCamera>>,
project_suns: Query<'w, 's, Entity, With<ProjectSun>>,
@ -127,6 +136,7 @@ impl Plugin for EditorVisualizersPlugin {
draw_audio_visualizers,
draw_split_collider_visualizers,
draw_post_process_volume_gizmos,
draw_navigation_visualizers,
)
.chain()
.run_if(scene_tools_active)
@ -135,6 +145,131 @@ impl Plugin for EditorVisualizersPlugin {
}
}
fn draw_navigation_visualizers(
state: Res<crate::ui::navigation_inspector::NavigationEditorState>,
mut gizmos: Gizmos,
bounds: Query<(&NavigationBounds, &GlobalTransform), With<LevelObject>>,
obstacles: Query<(&NavigationObstacle, &GlobalTransform), With<LevelObject>>,
areas: Query<(&NavigationArea, &GlobalTransform), With<LevelObject>>,
links: Query<(&NavigationLink, &GlobalTransform), With<LevelObject>>,
) {
for (value, global) in &bounds {
let half_extents = crate::ui::navigation_inspector::world_aabb_half_extents(
global.affine(),
value.half_extents,
);
let center = global.translation();
draw_box(
&mut gizmos,
center,
Quat::IDENTITY,
half_extents,
Color::srgba(0.12, 0.82, 0.78, 0.72),
);
}
for (value, global) in &obstacles {
let half_extents = crate::ui::navigation_inspector::world_aabb_half_extents(
global.affine(),
value.half_extents,
);
let center = global.translation();
draw_box(
&mut gizmos,
center,
Quat::IDENTITY,
half_extents,
Color::srgba(1.0, 0.28, 0.22, 0.78),
);
}
for (value, global) in &areas {
let half_extents = crate::ui::navigation_inspector::world_aabb_half_extents(
global.affine(),
value.half_extents,
);
let center = global.translation();
let color = if value.walkable {
Color::srgba(0.32, 0.72, 1.0, 0.62)
} else {
Color::srgba(1.0, 0.55, 0.18, 0.72)
};
draw_box(&mut gizmos, center, Quat::IDENTITY, half_extents, color);
}
for (value, global) in &links {
let start = global.transform_point(value.start);
let end = global.transform_point(value.end);
let color = if value.enabled {
Color::srgba(0.95, 0.78, 0.22, 0.95)
} else {
Color::srgba(0.48, 0.48, 0.5, 0.55)
};
gizmos.line(start, end, color);
gizmos
.sphere(Isometry3d::from_translation(start), 0.11, color)
.resolution(12);
gizmos
.sphere(Isometry3d::from_translation(end), 0.11, color)
.resolution(12);
}
if state.show_mesh {
if let Some(artifact) = &state.artifact {
for layer in &artifact.mesh.layers {
for polygon in &layer.polygons {
for index in 0..polygon.vertices.len() {
let a_index = polygon.vertices[index] as usize;
let b_index =
polygon.vertices[(index + 1) % polygon.vertices.len()] as usize;
let Some(a) = navigation_vertex(layer, a_index) else {
continue;
};
let Some(b) = navigation_vertex(layer, b_index) else {
continue;
};
gizmos.line(
a + Vec3::Y * 0.025,
b + Vec3::Y * 0.025,
Color::srgba(0.08, 0.9, 0.72, 0.7),
);
}
}
}
}
}
for edge in state.preview_path.windows(2) {
gizmos.line(
edge[0] + Vec3::Y * 0.08,
edge[1] + Vec3::Y * 0.08,
Color::srgba(0.98, 0.88, 0.2, 1.0),
);
}
if let Some(start) = state.preview_path.first().copied() {
gizmos
.sphere(
Isometry3d::from_translation(start + Vec3::Y * 0.08),
0.16,
Color::srgba(0.2, 1.0, 0.55, 1.0),
)
.resolution(14);
}
if let Some(end) = state.preview_path.last().copied() {
gizmos
.sphere(
Isometry3d::from_translation(end + Vec3::Y * 0.08),
0.16,
Color::srgba(1.0, 0.28, 0.22, 1.0),
)
.resolution(14);
}
}
fn navigation_vertex(layer: &polyanya::Layer, index: usize) -> Option<Vec3> {
let vertex = layer.vertices.get(index)?;
let height = layer.height.get(index).copied().unwrap_or_default();
let coords = vertex.coords + layer.offset;
Some(Vec3::new(coords.x, height, coords.y))
}
fn draw_audio_visualizers(
viewport_mode: Res<EditorViewportMode>,
settings: Res<EditorVisualizationSettings>,
@ -399,7 +534,13 @@ fn draw_visualizers(
lights: Query<(Entity, &LightDesc, &GlobalTransform), With<LevelObject>>,
spawns: Query<(Entity, &GlobalTransform), (With<LevelObject>, With<PlayerSpawn>)>,
prefabs: Query<(Entity, &GlobalTransform), (With<LevelObject>, With<PrefabRef>)>,
models: Query<(Entity, &GlobalTransform), (With<LevelObject>, With<ModelRef>)>,
models: Query<
(Entity, &GlobalTransform),
(
With<LevelObject>,
Or<(With<ModelRef>, With<SkinnedMeshRenderer>)>,
),
>,
players: Query<(Entity, &GlobalTransform), With<Player>>,
cameras: Query<(Entity, &GlobalTransform), With<PlayerCamera>>,
project_suns: Query<(Entity, &GlobalTransform), With<ProjectSun>>,

View File

@ -18,6 +18,8 @@ bevy_ufbx.workspace = true
cpal = "0.17.3"
game_hot = { path = "../game_hot" }
hot-lib-reloader = { version = "0.8.2", optional = true }
nav_glam.workspace = true
polyanya.workspace = true
protocol.workspace = true
ron = "0.12"
scene.workspace = true

View File

@ -1,6 +1,6 @@
//! Runtime adapter for scene-authored animation controllers.
use std::collections::{HashMap, VecDeque};
use std::collections::{HashMap, HashSet, VecDeque};
use std::path::{Path, PathBuf};
use std::time::Duration;
@ -12,11 +12,14 @@ use bevy::animation::{
RepeatAnimation,
};
use bevy::gltf::GltfAssetLabel;
use bevy::mesh::morph::MorphWeights;
use bevy::mesh::skinning::{SkinnedMesh, SkinnedMeshInverseBindposes};
use bevy::prelude::*;
use bevy::world_serialization::WorldInstanceReady;
use shared::{
animation_clip_source_index, asset_server_path, AnimationControllerDesc, AnimationEventDesc,
AnimationManifest, AnimationStateDesc, HydratedModelRoot, ANIMATION_ARTIFACT_DIR,
AnimationManifest, AnimationStateDesc, HydratedSkinnedMeshRoot, ANIMATION_ARTIFACT_DIR,
ANIMATION_MANIFEST_SCHEMA_VERSION,
};
use sim::SimEnabled;
@ -114,6 +117,29 @@ pub struct AuthoredAnimationRuntime {
last_event_time: Option<f32>,
}
#[derive(Debug, Clone)]
struct SkinnedPoseBaselineEntry {
entity: Entity,
transform: Option<Transform>,
morph_weights: Option<MorphWeights>,
}
/// Imported rest-pose state captured before Blacksite starts authored animation playback.
///
/// This component is runtime-only. It is replaced whenever a new hydrated hierarchy becomes
/// ready, so renderer reloads cannot restore values onto an older scene generation.
#[derive(Component, Debug, Clone)]
pub struct SkinnedPoseBaseline {
pub root: Entity,
entries: Vec<SkinnedPoseBaselineEntry>,
}
impl SkinnedPoseBaseline {
pub fn entry_count(&self) -> usize {
self.entries.len()
}
}
/// Editor-only transient preview request consumed by the shared runtime adapter.
#[derive(Debug, Clone, PartialEq)]
pub struct AnimationPreviewRequest {
@ -163,10 +189,14 @@ impl Plugin for GameAnimationPlugin {
fn attach_controller_when_model_ready(
ready: On<WorldInstanceReady>,
mut commands: Commands,
roots: Query<&HydratedModelRoot>,
roots: Query<&HydratedSkinnedMeshRoot>,
controllers: Query<&AnimationControllerDesc>,
children: Query<&Children>,
players: Query<Entity, With<AnimationPlayer>>,
transforms: Query<&Transform>,
morph_weights: Query<&MorphWeights>,
skinned_meshes: Query<(Entity, &SkinnedMesh)>,
inverse_bindposes: Option<Res<Assets<SkinnedMeshInverseBindposes>>>,
asset_server: Res<AssetServer>,
mut graphs: ResMut<Assets<AnimationGraph>>,
mut diagnostics: ResMut<AnimationRuntimeDiagnostics>,
@ -175,6 +205,18 @@ fn attach_controller_when_model_ready(
let Ok(root) = roots.get(ready.entity) else {
return;
};
let subtree = std::iter::once(ready.entity)
.chain(children.iter_descendants(ready.entity))
.collect::<HashSet<_>>();
let baseline = capture_pose_baseline(ready.entity, &subtree, &transforms, &morph_weights);
commands.entity(root.owner).insert(baseline);
validate_skinned_bindings(
root.owner,
&subtree,
&skinned_meshes,
inverse_bindposes.as_deref(),
&mut diagnostics,
);
let Ok(controller) = controllers.get(root.owner) else {
return;
};
@ -182,7 +224,7 @@ fn attach_controller_when_model_ready(
diagnostics.push(
Some(root.owner),
"animation.player_missing",
"The hydrated model contains no AnimationPlayer. Verify that the selected glTF scene contains an animated hierarchy.",
"The hydrated skinned mesh contains no AnimationPlayer. Verify that the selected glTF scene contains an animated hierarchy.",
);
return;
};
@ -199,6 +241,75 @@ fn attach_controller_when_model_ready(
);
}
fn capture_pose_baseline(
root: Entity,
subtree: &HashSet<Entity>,
transforms: &Query<&Transform>,
morph_weights: &Query<&MorphWeights>,
) -> SkinnedPoseBaseline {
let mut entities = subtree.iter().copied().collect::<Vec<_>>();
entities.sort_by_key(|entity| entity.to_bits());
let entries = entities
.into_iter()
.filter_map(|entity| {
let transform = transforms.get(entity).ok().copied();
let morph_weights = morph_weights.get(entity).ok().cloned();
(transform.is_some() || morph_weights.is_some()).then_some(SkinnedPoseBaselineEntry {
entity,
transform,
morph_weights,
})
})
.collect();
SkinnedPoseBaseline { root, entries }
}
fn validate_skinned_bindings(
actor: Entity,
subtree: &HashSet<Entity>,
skinned_meshes: &Query<(Entity, &SkinnedMesh)>,
inverse_bindposes: Option<&Assets<SkinnedMeshInverseBindposes>>,
diagnostics: &mut AnimationRuntimeDiagnostics,
) {
for (mesh_entity, skin) in skinned_meshes.iter() {
if !subtree.contains(&mesh_entity) {
continue;
}
if let Some(inverse_bindposes) = inverse_bindposes {
let Some(bindposes) = inverse_bindposes.get(&skin.inverse_bindposes) else {
diagnostics.push(
Some(actor),
"animation.inverse_bindposes_missing",
format!(
"Skinned mesh {mesh_entity} has no loaded inverse-bind-pose asset; the imported hierarchy was preserved but cannot be validated."
),
);
continue;
};
if skin.joints.len() != bindposes.len() {
diagnostics.push(
Some(actor),
"animation.skin_binding_count_mismatch",
format!(
"Skinned mesh {mesh_entity} has {} joints but {} inverse bind poses. Re-export the rig and reimport the model.",
skin.joints.len(),
bindposes.len()
),
);
}
}
if let Some(joint) = skin.joints.iter().find(|joint| !subtree.contains(joint)) {
diagnostics.push(
Some(actor),
"animation.skin_joint_outside_hierarchy",
format!(
"Skinned mesh {mesh_entity} references joint {joint} outside its hydrated hierarchy. Re-export the rig as one scene hierarchy."
),
);
}
}
}
#[allow(clippy::too_many_arguments)]
fn reconcile_changed_controllers(
mut commands: Commands,
@ -209,7 +320,7 @@ fn reconcile_changed_controllers(
Changed<AnimationControllerDesc>,
)>,
>,
roots: Query<(Entity, &HydratedModelRoot)>,
roots: Query<(Entity, &HydratedSkinnedMeshRoot)>,
children: Query<&Children>,
players: Query<Entity, With<AnimationPlayer>>,
asset_server: Res<AssetServer>,
@ -266,6 +377,9 @@ fn configure_runtime(
Some(controller.default_crossfade_seconds),
)
});
commands.queue(move |world: &mut World| {
restore_pose_baseline(world, actor);
});
commands.entity(player).insert((
AnimationGraphHandle(graph.clone()),
AnimationTransitions::new(),
@ -298,6 +412,7 @@ fn clear_failed_runtime_configuration(commands: &mut Commands, actor: Entity, pl
if let Some(mut animation_player) = world.get_mut::<AnimationPlayer>(player) {
animation_player.stop_all();
}
restore_pose_baseline(world, actor);
if let Ok(mut player_entity) = world.get_entity_mut(player) {
player_entity
.remove::<AnimationGraphHandle>()
@ -468,7 +583,17 @@ fn load_runtime_animation_manifest(
let path = runtime_animation_manifest_path(assets_root, asset_id);
let text = std::fs::read_to_string(&path)
.map_err(|error| format!("could not read {}: {error}", path.display()))?;
ron::from_str(&text).map_err(|error| format!("could not parse {}: {error}", path.display()))
let manifest: AnimationManifest = ron::from_str(&text)
.map_err(|error| format!("could not parse {}: {error}", path.display()))?;
if manifest.schema_version != ANIMATION_MANIFEST_SCHEMA_VERSION {
return Err(format!(
"animation manifest {} uses schema {} but runtime requires {}; reimport the source model",
path.display(),
manifest.schema_version,
ANIMATION_MANIFEST_SCHEMA_VERSION
));
}
Ok(manifest)
}
fn process_animation_state_requests(
@ -550,8 +675,18 @@ fn sync_authored_animation_playback(
.request
.as_ref()
.filter(|request| request.actor == actor);
let should_run = sim_enabled.0 || preview_request.is_some();
if !should_run {
let desired_state = preview_request
.map(|request| request.state_id.as_str())
.or(runtime.desired_state.as_deref())
.or((!controller.default_state.trim().is_empty())
.then_some(controller.default_state.as_str()))
.map(str::to_owned);
let sampling_edit_rest = !sim_enabled.0
&& preview_request.is_none()
&& runtime.active_state.is_none()
&& desired_state.is_some();
let should_evaluate = sim_enabled.0 || preview_request.is_some() || sampling_edit_rest;
if !should_evaluate {
if runtime.active_state.is_some() {
player.pause_all();
runtime.paused_by_sim = true;
@ -572,12 +707,6 @@ fn sync_authored_animation_playback(
.unwrap_or_default()
});
let desired_state = preview_request
.map(|request| request.state_id.as_str())
.or(runtime.desired_state.as_deref())
.or((!controller.default_state.trim().is_empty())
.then_some(controller.default_state.as_str()))
.map(str::to_owned);
let Some(desired_state) = desired_state else {
continue;
};
@ -600,7 +729,7 @@ fn sync_authored_animation_playback(
};
if runtime.active_state.as_deref() != Some(desired_state.as_str()) {
let crossfade = if preview_request.is_some() {
let crossfade = if preview_request.is_some() || sampling_edit_rest {
0.0
} else {
runtime
@ -830,23 +959,49 @@ fn crossed_animation_events<'a>(
crossed
}
fn cleanup_stale_animation_runtimes(
mut commands: Commands,
controllers: Query<(), With<AnimationControllerDesc>>,
runtimes: Query<(Entity, &AuthoredAnimationRuntime)>,
mut players: Query<&mut AnimationPlayer>,
) {
for (actor, runtime) in &runtimes {
if controllers.get(actor).is_ok() && players.get(runtime.player).is_ok() {
continue;
fn cleanup_stale_animation_runtimes(world: &mut World) {
let runtimes = world
.query::<(Entity, &AuthoredAnimationRuntime)>()
.iter(world)
.map(|(actor, runtime)| (actor, runtime.player))
.collect::<Vec<_>>();
let stale = runtimes
.into_iter()
.filter(|(actor, player)| {
let controller_exists = world.get::<AnimationControllerDesc>(*actor).is_some();
let player_exists = world.get::<AnimationPlayer>(*player).is_some();
!controller_exists || !player_exists
})
.collect::<Vec<_>>();
for (actor, player) in stale {
if let Some(mut animation_player) = world.get_mut::<AnimationPlayer>(player) {
animation_player.stop_all();
}
if let Ok(mut player) = players.get_mut(runtime.player) {
player.stop_all();
commands
.entity(runtime.player)
.remove::<SuspendedAnimationTransitions>();
if let Ok(mut player_entity) = world.get_entity_mut(player) {
player_entity.remove::<SuspendedAnimationTransitions>();
}
restore_pose_baseline(world, actor);
if let Ok(mut actor_entity) = world.get_entity_mut(actor) {
actor_entity.remove::<AuthoredAnimationRuntime>();
}
}
}
fn restore_pose_baseline(world: &mut World, actor: Entity) {
let Some(baseline) = world.get::<SkinnedPoseBaseline>(actor).cloned() else {
return;
};
for entry in baseline.entries {
if let Some(transform) = entry.transform {
if let Some(mut current) = world.get_mut::<Transform>(entry.entity) {
*current = transform;
}
}
if let Some(morph_weights) = entry.morph_weights {
if let Some(mut current) = world.get_mut::<MorphWeights>(entry.entity) {
*current = morph_weights;
}
}
commands.entity(actor).remove::<AuthoredAnimationRuntime>();
}
}
@ -919,8 +1074,15 @@ pub fn stop_animation_preview(world: &mut World) {
animation_player.stop_all();
}
}
restore_pose_baseline(world, actor);
let edit_rest_state = world
.get::<AnimationControllerDesc>(actor)
.map(|controller| controller.default_state.clone())
.filter(|state| !state.trim().is_empty());
if let Some(mut runtime) = world.get_mut::<AuthoredAnimationRuntime>(actor) {
runtime.active_state = None;
runtime.desired_state = edit_rest_state;
runtime.requested_crossfade_seconds = Some(0.0);
runtime.paused_by_sim = false;
runtime.applied_preview_revision = None;
runtime.last_event_time = None;
@ -943,10 +1105,15 @@ pub fn reset_authored_animation_runtime(world: &mut World) {
if let Some(mut animation_player) = world.get_mut::<AnimationPlayer>(player) {
animation_player.stop_all();
}
restore_pose_baseline(world, actor);
let edit_rest_state = world
.get::<AnimationControllerDesc>(actor)
.map(|controller| controller.default_state.clone())
.filter(|state| !state.trim().is_empty());
if let Some(mut runtime) = world.get_mut::<AuthoredAnimationRuntime>(actor) {
runtime.active_state = None;
runtime.desired_state = None;
runtime.requested_crossfade_seconds = None;
runtime.desired_state = edit_rest_state;
runtime.requested_crossfade_seconds = Some(0.0);
runtime.paused_by_sim = false;
runtime.applied_preview_revision = None;
runtime.last_event_time = None;
@ -960,7 +1127,7 @@ mod tests {
use bevy::asset::{AssetApp, AssetPlugin};
use shared::{
AnimationClipRecord, AnimationManifestSource, AnimationPlaybackRange,
AnimationSourceFingerprint, EditorAssetRef, ANIMATION_MANIFEST_SCHEMA_VERSION,
AnimationSourceFingerprint, EditorAssetRef,
};
fn state(id: &str, index: usize) -> AnimationStateDesc {
@ -1094,6 +1261,7 @@ mod tests {
schema_version: ANIMATION_MANIFEST_SCHEMA_VERSION,
asset_id: state.clip.asset_id.clone(),
label: "Moved Model".into(),
default_animation_clip_id: None,
source: AnimationManifestSource {
path: "assets/models/relocated.glb".into(),
format: "glb".into(),
@ -1144,7 +1312,7 @@ mod tests {
let actor = app.world_mut().spawn_empty().id();
let root = app
.world_mut()
.spawn((HydratedModelRoot { owner: actor }, ChildOf(actor)))
.spawn((HydratedSkinnedMeshRoot { owner: actor }, ChildOf(actor)))
.id();
let stale_node = AnimationNodeIndex::new(0);
let mut animation_player = AnimationPlayer::default();
@ -1362,6 +1530,202 @@ mod tests {
.all_paused());
}
#[test]
fn edit_mode_samples_and_pauses_the_explicit_default_state() {
let (mut app, actor, player, _) = runtime_test_app(false);
app.update();
assert_eq!(
app.world()
.get::<AuthoredAnimationRuntime>(actor)
.and_then(|runtime| runtime.active_state.as_deref()),
Some("idle")
);
assert!(app
.world()
.get::<AnimationPlayer>(player)
.is_some_and(AnimationPlayer::all_paused));
}
#[test]
fn stopping_preview_restores_transform_and_morph_baseline_before_resampling() {
let mut app = App::new();
app.init_resource::<AnimationPreviewRuntime>();
let baseline_transform = Transform::from_xyz(1.0, 2.0, 3.0);
let baseline_morph = MorphWeights::new(vec![0.1, 0.9], None).unwrap();
let target = app
.world_mut()
.spawn((
Transform::from_xyz(9.0, 8.0, 7.0),
MorphWeights::new(vec![0.8, 0.2], None).unwrap(),
))
.id();
let node = AnimationNodeIndex::new(0);
let mut animation_player = AnimationPlayer::default();
animation_player.start(node);
let player = app.world_mut().spawn(animation_player).id();
let actor = app
.world_mut()
.spawn(AnimationControllerDesc {
states: vec![state("idle", 0)],
default_state: "idle".into(),
..Default::default()
})
.id();
app.world_mut().entity_mut(actor).insert((
SkinnedPoseBaseline {
root: target,
entries: vec![SkinnedPoseBaselineEntry {
entity: target,
transform: Some(baseline_transform),
morph_weights: Some(baseline_morph),
}],
},
AuthoredAnimationRuntime {
player,
graph: Handle::default(),
state_nodes: HashMap::from([("idle".into(), node)]),
active_state: Some("idle".into()),
desired_state: Some("idle".into()),
requested_crossfade_seconds: None,
paused_by_sim: false,
applied_preview_revision: None,
metadata_by_state: HashMap::new(),
last_event_time: None,
},
));
set_animation_preview(
app.world_mut(),
AnimationPreviewRequest {
actor,
state_id: "idle".into(),
seek_seconds: Some(0.7),
playing: true,
},
)
.unwrap();
stop_animation_preview(app.world_mut());
assert_eq!(
app.world().get::<Transform>(target),
Some(&baseline_transform)
);
assert_eq!(
app.world().get::<MorphWeights>(target).unwrap().weights(),
&[0.1, 0.9]
);
assert!(app
.world()
.get::<AnimationPlayer>(player)
.unwrap()
.playing_animations()
.next()
.is_none());
let runtime = app.world().get::<AuthoredAnimationRuntime>(actor).unwrap();
assert!(runtime.active_state.is_none());
assert_eq!(runtime.desired_state.as_deref(), Some("idle"));
assert_eq!(runtime.requested_crossfade_seconds, Some(0.0));
}
#[test]
fn removing_controller_restores_baseline_and_clears_runtime() {
let mut world = World::new();
let target = world.spawn(Transform::from_xyz(9.0, 0.0, 0.0)).id();
let player = world.spawn(AnimationPlayer::default()).id();
let actor = world
.spawn((
SkinnedPoseBaseline {
root: target,
entries: vec![SkinnedPoseBaselineEntry {
entity: target,
transform: Some(Transform::from_xyz(1.0, 0.0, 0.0)),
morph_weights: None,
}],
},
AuthoredAnimationRuntime {
player,
graph: Handle::default(),
state_nodes: HashMap::new(),
active_state: Some("idle".into()),
desired_state: Some("idle".into()),
requested_crossfade_seconds: None,
paused_by_sim: false,
applied_preview_revision: None,
metadata_by_state: HashMap::new(),
last_event_time: None,
},
))
.id();
cleanup_stale_animation_runtimes(&mut world);
assert_eq!(world.get::<Transform>(target).unwrap().translation, Vec3::X);
assert!(world.get::<AuthoredAnimationRuntime>(actor).is_none());
}
#[test]
fn duplicate_skin_records_are_validated_independently_against_shared_joints() {
let mut world = World::new();
let actor = world.spawn_empty().id();
let root = world.spawn_empty().id();
let joint = world.spawn_empty().id();
let mut inverse_bindposes = Assets::<SkinnedMeshInverseBindposes>::default();
let first_bindposes =
inverse_bindposes.add(SkinnedMeshInverseBindposes::from(vec![Mat4::IDENTITY]));
let second_bindposes =
inverse_bindposes.add(SkinnedMeshInverseBindposes::from(vec![Mat4::IDENTITY]));
let first_mesh = world
.spawn(SkinnedMesh {
inverse_bindposes: first_bindposes,
joints: vec![joint],
})
.id();
let second_mesh = world
.spawn(SkinnedMesh {
inverse_bindposes: second_bindposes,
joints: vec![joint],
})
.id();
let subtree = HashSet::from([root, joint, first_mesh, second_mesh]);
let mut diagnostics = AnimationRuntimeDiagnostics::default();
let mut query_state =
bevy::ecs::system::SystemState::<Query<(Entity, &SkinnedMesh)>>::new(&mut world);
{
let skins = query_state.get(&world).unwrap();
validate_skinned_bindings(
actor,
&subtree,
&skins,
Some(&inverse_bindposes),
&mut diagnostics,
);
}
assert!(diagnostics.entries().next().is_none());
world
.get_mut::<SkinnedMesh>(second_mesh)
.unwrap()
.joints
.push(joint);
{
let skins = query_state.get(&world).unwrap();
validate_skinned_bindings(
actor,
&subtree,
&skins,
Some(&inverse_bindposes),
&mut diagnostics,
);
}
assert_eq!(
diagnostics.latest().map(|diagnostic| diagnostic.code),
Some("animation.skin_binding_count_mismatch")
);
}
#[test]
fn invalid_state_request_is_rejected_without_message_side_effect() {
let mut app = App::new();

View File

@ -12,9 +12,9 @@ use settings::{
AudioSettings, ProjectSettings, AUDIO_BUS_MASTER_ID, AUDIO_GAIN_DB_MAX, AUDIO_GAIN_DB_MIN,
};
use shared::{
asset_server_path, inspector_component_active, ActorId, AudioAttenuationDesc,
AudioListenerDesc, AudioRolloff, AudioSourceDesc, InspectorOrder, LevelObject,
COMPONENT_AUDIO_LISTENER_DESC, COMPONENT_AUDIO_SOURCE_DESC,
asset_server_path, authoring_component_active, ActorId, AudioAttenuationDesc,
AudioListenerDesc, AudioRolloff, AudioSourceDesc, AuthoringComponentStates, InspectorOrder,
LevelObject, COMPONENT_AUDIO_LISTENER_DESC, COMPONENT_AUDIO_SOURCE_DESC,
};
use sim::{PlayerCamera, SimEnabled};
@ -265,10 +265,13 @@ fn reconcile_audio_listener(world: &mut World) {
Entity,
&AudioListenerDesc,
Option<&ActorId>,
Option<&AuthoringComponentStates>,
Option<&InspectorOrder>,
), With<LevelObject>>();
for (entity, desc, actor_id, order) in query.iter(world) {
if desc.enabled && inspector_component_active(order, COMPONENT_AUDIO_LISTENER_DESC) {
for (entity, desc, actor_id, states, legacy_order) in query.iter(world) {
if desc.enabled
&& authoring_component_active(states, legacy_order, COMPONENT_AUDIO_LISTENER_DESC)
{
candidates.push((
entity,
*desc,
@ -343,10 +346,11 @@ fn reconcile_audio_sources(world: &mut World) {
let mut query = world.query_filtered::<(
Entity,
&AudioSourceDesc,
Option<&AuthoringComponentStates>,
Option<&InspectorOrder>,
), With<LevelObject>>();
for (entity, desc, order) in query.iter(world) {
if inspector_component_active(order, COMPONENT_AUDIO_SOURCE_DESC) {
for (entity, desc, states, legacy_order) in query.iter(world) {
if authoring_component_active(states, legacy_order, COMPONENT_AUDIO_SOURCE_DESC) {
sources.push((entity, desc.clone()));
}
}

View File

@ -12,6 +12,7 @@ pub mod animation;
pub mod audio;
pub mod editor_ext;
pub mod launch;
pub mod navigation;
mod scene_bootstrap;
mod schema_world_loader;
@ -93,7 +94,7 @@ mod hot {
ActiveCameraRenderProfile, ProjectRenderCamera, ProjectSettings, RenderingCapabilities,
SimTuning,
};
use shared::{InspectorOrder, LevelObject, LightDesc, ProjectSun};
use shared::{AuthoringComponentStates, InspectorOrder, LevelObject, LightDesc, ProjectSun};
use sim::{
CameraSensitivity, Crouching, GameInputEnabled, GameInputFocused, Grounded, JumpState,
Player, PlayerCamera, PlayerVelocity, PlayerYawSensitivity,

View File

@ -2,6 +2,33 @@ use bevy::prelude::*;
use game::{launch, GamePlugin};
fn main() {
let arguments = std::env::args().skip(1).collect::<Vec<_>>();
match game::navigation::navigation_validation_argument(&arguments) {
Ok(Some(path)) => match game::navigation::validate_navigation_artifact(&path) {
Ok(summary) => {
println!(
"navigation validation passed: {} sample(s), {} link(s), {}",
summary.enabled_sample_count,
summary.enabled_link_count,
summary.artifact_path.display()
);
return;
}
Err(error) => {
eprintln!(
"navigation validation failed for {}:\n{error}",
path.display()
);
std::process::exit(1);
}
},
Ok(None) => {}
Err(error) => {
eprintln!("{error}");
std::process::exit(2);
}
}
App::new()
.add_plugins(launch::default_plugins("Bevy FPS Foundation"))
.add_plugins(GamePlugin)

View File

@ -0,0 +1,294 @@
use std::path::{Path, PathBuf};
use bevy::prelude::Vec3;
use scene::navigation::{NavigationBakeArtifact, NavigationQueryPath, NavigationQueryRuntime};
/// Loaded navigation artifact and its shared Polyanya query runtime.
#[derive(Debug, Clone)]
pub struct NavigationRuntime {
query: NavigationQueryRuntime,
}
#[derive(Debug, Clone, PartialEq)]
pub struct NavigationPath {
pub points: Vec<Vec3>,
pub length: f32,
pub used_link_actor_ids: Vec<String>,
}
impl NavigationPath {
pub fn used_link_actor_id(&self) -> Option<&str> {
self.used_link_actor_ids.first().map(String::as_str)
}
}
impl From<NavigationQueryPath> for NavigationPath {
fn from(path: NavigationQueryPath) -> Self {
Self {
points: path.points.into_iter().map(Vec3::from_array).collect(),
length: path.length,
used_link_actor_ids: path.used_link_actor_ids,
}
}
}
impl NavigationRuntime {
pub fn from_artifact(artifact: NavigationBakeArtifact) -> Result<Self, String> {
Ok(Self {
query: NavigationQueryRuntime::from_artifact(artifact)?,
})
}
pub fn load(path: &Path) -> Result<Self, String> {
Ok(Self {
query: NavigationQueryRuntime::load(path)?,
})
}
pub fn artifact(&self) -> &NavigationBakeArtifact {
self.query.artifact()
}
pub fn query(&self, start: Vec3, end: Vec3) -> Result<NavigationPath, String> {
self.query
.query(start.to_array(), end.to_array())
.map(NavigationPath::from)
}
pub fn validate_links(&self) -> Vec<String> {
self.query.validate_links()
}
}
pub fn query_navigation_path(
artifact_path: &Path,
start: Vec3,
end: Vec3,
) -> Result<NavigationPath, String> {
NavigationRuntime::load(artifact_path)?.query(start, end)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct NavigationValidationSummary {
pub artifact_path: PathBuf,
pub enabled_link_count: usize,
pub enabled_sample_count: usize,
}
pub fn validate_navigation_artifact(
artifact_path: &Path,
) -> Result<NavigationValidationSummary, String> {
let runtime = NavigationQueryRuntime::load(artifact_path)?;
let mut findings = runtime.validate_links();
findings.extend(runtime.validate_samples());
if !findings.is_empty() {
return Err(findings.join("\n"));
}
Ok(NavigationValidationSummary {
artifact_path: artifact_path.to_path_buf(),
enabled_link_count: runtime
.artifact()
.links
.iter()
.filter(|link| link.enabled)
.count(),
enabled_sample_count: runtime
.artifact()
.samples
.iter()
.filter(|sample| sample.enabled)
.count(),
})
}
pub fn navigation_validation_argument(args: &[String]) -> Result<Option<PathBuf>, String> {
let Some(index) = args
.iter()
.position(|argument| argument == "--validate-navigation")
else {
return Ok(None);
};
if args.len() != 2 || index != 0 {
return Err(
"usage: game --validate-navigation <project-relative-or-absolute-artifact-path>".into(),
);
}
let path = args[1].trim();
if path.is_empty() {
return Err("navigation validation artifact path must not be empty".into());
}
Ok(Some(PathBuf::from(path)))
}
#[cfg(test)]
mod tests {
use scene::navigation::{
bake_navigation, NavigationBakeInput, NavigationGeometryInput, NavigationLinkInput,
NavigationVolumeInput,
};
use shared::NavigationAgentProfile;
use super::*;
fn floor_geometry(
actor_id: &str,
min_x: f32,
max_x: f32,
min_z: f32,
max_z: f32,
y: f32,
) -> NavigationGeometryInput {
NavigationGeometryInput {
actor_id: actor_id.into(),
triangles: vec![
[[min_x, y, min_z], [max_x, y, max_z], [max_x, y, min_z]],
[[min_x, y, min_z], [min_x, y, max_z], [max_x, y, max_z]],
],
}
}
fn input() -> NavigationBakeInput {
NavigationBakeInput {
source_scene: "assets/levels/navigation_showcase.scn.ron".into(),
bounds_actor_id: "bounds".into(),
center: [0.0, 0.0, 0.0],
half_extents: [6.0, 2.0, 6.0],
agent: NavigationAgentProfile {
min_region_size: 1,
merge_region_size: 2,
..Default::default()
},
geometry: vec![floor_geometry("floor", -6.0, 6.0, -6.0, 6.0, 0.0)],
obstacles: vec![NavigationVolumeInput {
actor_id: "wall".into(),
center: [0.0, 0.5, 0.0],
half_extents: [0.5, 1.0, 4.5],
}],
areas: Vec::new(),
links: Vec::new(),
samples: Vec::new(),
}
}
#[test]
fn runtime_query_routes_on_the_baked_mesh() {
let runtime = NavigationRuntime::from_artifact(bake_navigation(&input()).unwrap()).unwrap();
let path = runtime
.query(Vec3::new(-4.0, 0.0, -5.0), Vec3::new(4.0, 0.0, -5.0))
.unwrap();
assert!(path.length >= 8.0);
assert!(path.points.len() >= 2);
}
#[test]
fn explicit_link_can_connect_isolated_regions() {
let mut input = input();
input.obstacles[0].half_extents[2] = 6.0;
input.links.push(NavigationLinkInput {
actor_id: "door-link".into(),
start: [-1.0, 0.0, 0.0],
end: [1.0, 0.0, 0.0],
bidirectional: true,
cost: 1.0,
enabled: true,
});
let runtime = NavigationRuntime::from_artifact(bake_navigation(&input).unwrap()).unwrap();
let path = runtime
.query(Vec3::new(-4.0, 0.0, 0.0), Vec3::new(4.0, 0.0, 0.0))
.unwrap();
assert_eq!(path.used_link_actor_id(), Some("door-link"));
assert_eq!(path.used_link_actor_ids, ["door-link"]);
}
#[test]
fn route_can_traverse_multiple_explicit_links() {
let mut input = input();
input.geometry = vec![
floor_geometry("island-a", -6.0, -3.0, -2.0, 2.0, 0.0),
floor_geometry("island-b", -1.5, 1.5, -2.0, 2.0, 0.0),
floor_geometry("island-c", 3.0, 6.0, -2.0, 2.0, 0.0),
];
input.obstacles.clear();
input.links = vec![
NavigationLinkInput {
actor_id: "link-a-b".into(),
start: [-3.6, 0.0, 0.0],
end: [-1.0, 0.0, 0.0],
bidirectional: true,
cost: 1.0,
enabled: true,
},
NavigationLinkInput {
actor_id: "link-b-c".into(),
start: [1.0, 0.0, 0.0],
end: [3.6, 0.0, 0.0],
bidirectional: true,
cost: 1.0,
enabled: true,
},
];
let runtime = NavigationRuntime::from_artifact(bake_navigation(&input).unwrap()).unwrap();
let path = runtime
.query(Vec3::new(-5.0, 0.0, 0.0), Vec3::new(5.0, 0.0, 0.0))
.unwrap();
assert_eq!(path.used_link_actor_ids, ["link-a-b", "link-b-c"]);
assert!(path.length >= 10.0);
}
#[test]
fn query_selects_vertically_nearest_walkable_surface() {
let mut input = input();
input.center = [0.0, 2.0, 0.0];
input.half_extents = [6.0, 3.0, 6.0];
input.geometry = vec![
floor_geometry("lower-floor", -5.0, 5.0, -5.0, 5.0, 0.0),
floor_geometry("upper-floor", -5.0, 5.0, -5.0, 5.0, 4.0),
];
input.obstacles.clear();
let runtime = NavigationRuntime::from_artifact(bake_navigation(&input).unwrap()).unwrap();
let lower = runtime
.query(Vec3::new(-3.0, 0.1, 0.0), Vec3::new(3.0, 0.1, 0.0))
.unwrap();
let upper = runtime
.query(Vec3::new(-3.0, 3.9, 0.0), Vec3::new(3.0, 3.9, 0.0))
.unwrap();
assert!(lower.points.iter().all(|point| point.y < 1.0));
assert!(upper.points.iter().all(|point| point.y > 3.0));
}
#[test]
fn invalid_link_endpoint_is_reported_with_actor_identity() {
let mut input = input();
input.links.push(NavigationLinkInput {
actor_id: "bad-link".into(),
start: [100.0, 0.0, 100.0],
end: [0.0, 0.0, 0.0],
bidirectional: false,
cost: 1.0,
enabled: true,
});
let runtime = NavigationRuntime::from_artifact(bake_navigation(&input).unwrap()).unwrap();
assert!(runtime
.validate_links()
.iter()
.any(|finding| finding.contains("bad-link start")));
}
#[test]
fn validation_cli_argument_is_strict_and_window_free() {
assert_eq!(
navigation_validation_argument(&[
"--validate-navigation".into(),
"assets/navigation/generated/main.nav.ron".into(),
])
.unwrap(),
Some(PathBuf::from("assets/navigation/generated/main.nav.ron"))
);
assert!(navigation_validation_argument(&["--validate-navigation".into()]).is_err());
assert_eq!(
navigation_validation_argument(&["--project".into(), ".".into()]).unwrap(),
None
);
}
}

View File

@ -18,6 +18,7 @@ avian3d.workspace = true
bevy.workspace = true
bevy_core_pipeline.workspace = true
bevy_solari.workspace = true
blacksite_surface.workspace = true
protocol.workspace = true
shared.workspace = true
sim.workspace = true

View File

@ -5,7 +5,7 @@ use bevy::core_pipeline::prepass::{
DeferredPrepass, DeferredPrepassDoubleBuffer, DepthPrepass, DepthPrepassDoubleBuffer,
MotionVectorPrepass,
};
use bevy::mesh::{Indices, PrimitiveTopology};
use bevy::mesh::{morph::MeshMorphWeights, skinning::SkinnedMesh, Indices, PrimitiveTopology};
use bevy::pbr::{DefaultOpaqueRendererMethod, ExtractedDirectionalLight};
use bevy::prelude::*;
use bevy::render::render_resource::TextureUsages;
@ -14,6 +14,7 @@ use bevy::render::{Render, RenderApp, RenderSystems};
use bevy_solari::prelude::{RaytracingMesh3d, SolariLighting};
use bevy_solari::scene::RaytracingSceneBindings;
use bevy_solari::SolariPlugins;
use blacksite_surface::{SurfaceEvaluatorRegistry, SurfaceMaterial};
use settings::{GiPath, ProjectSettings, RenderingCapabilities};
use shared::{LevelObject, RaytracingExcluded};
use std::sync::{Arc, Mutex};
@ -24,6 +25,10 @@ pub struct SolariRaytracingSceneStats {
pub tagged_meshes: usize,
pub excluded_meshes: usize,
pub unsupported_meshes: usize,
/// Skinned or morphed meshes deliberately omitted until Solari consumes deformed vertices.
pub deformed_meshes_excluded: usize,
pub surface_material_meshes: usize,
pub surface_materials_missing_evaluator: usize,
pub missing_level_root_meshes: usize,
pub emissive_meshes: usize,
pub compatible_mesh_assets: usize,
@ -61,7 +66,8 @@ pub struct SolariRenderingPlugin;
impl Plugin for SolariRenderingPlugin {
fn build(&self, app: &mut App) {
app.add_plugins(SolariPlugins)
app.add_plugins(blacksite_surface::SurfaceMaterialPlugin)
.add_plugins(SolariPlugins)
.init_resource::<RenderingCapabilities>()
.init_resource::<settings::EffectiveRenderStack>()
.init_resource::<SolariRaytracingSceneStats>()
@ -213,11 +219,16 @@ fn sync_hydrated_raytracing_meshes(
mut commands: Commands,
mut stats: ResMut<SolariRaytracingSceneStats>,
materials: Res<Assets<StandardMaterial>>,
surface_materials: Res<Assets<SurfaceMaterial>>,
surface_evaluators: Res<SurfaceEvaluatorRegistry>,
mesh_assets: Res<Assets<Mesh>>,
meshes: Query<(
Entity,
&Mesh3d,
Option<&MeshMaterial3d<StandardMaterial>>,
Option<&MeshMaterial3d<SurfaceMaterial>>,
Option<&SkinnedMesh>,
Option<&MeshMorphWeights>,
Option<&RaytracingMesh3d>,
Option<&RaytracingExcluded>,
)>,
@ -230,6 +241,9 @@ fn sync_hydrated_raytracing_meshes(
next_stats.tagged_meshes = 0;
next_stats.excluded_meshes = 0;
next_stats.unsupported_meshes = 0;
next_stats.deformed_meshes_excluded = 0;
next_stats.surface_material_meshes = 0;
next_stats.surface_materials_missing_evaluator = 0;
next_stats.missing_level_root_meshes = 0;
next_stats.emissive_meshes = 0;
next_stats.compatible_mesh_assets = 0;
@ -255,7 +269,9 @@ fn sync_hydrated_raytracing_meshes(
return;
}
for (entity, mesh, material, rt_mesh, excluded) in &meshes {
for (entity, mesh, standard_material, surface_material, skin, morph, rt_mesh, excluded) in
&meshes
{
if excluded.is_some() {
next_stats.excluded_meshes += 1;
if rt_mesh.is_some() {
@ -271,19 +287,79 @@ fn sync_hydrated_raytracing_meshes(
next_stats.project_meshes += 1;
let Some(material) = material else {
// Bevy 0.19's mesh allocator exposes the source vertex slab to Solari, not the
// morph-then-skin output consumed by raster rendering. Tagging this mesh would trace the
// bind pose, so omit it until the fork owns an instance-keyed deformed vertex/BLAS path.
let mesh_has_morph_targets = mesh_assets
.get(&mesh.0)
.and_then(Mesh::get_morph_targets)
.is_some();
if !solari_deformation_is_supported(skin.is_some(), morph.is_some(), mesh_has_morph_targets)
{
next_stats.deformed_meshes_excluded += 1;
if rt_mesh.is_some() {
commands.entity(entity).remove::<RaytracingMesh3d>();
}
continue;
}
if standard_material.is_none() && surface_material.is_none() {
next_stats.unsupported_meshes += 1;
if rt_mesh.is_some() {
commands.entity(entity).remove::<RaytracingMesh3d>();
}
continue;
};
}
if materials
.get(&material.0)
.is_some_and(|material| material.emissive.to_vec3() != Vec3::ZERO)
{
if let Some(surface_handle) = surface_material {
next_stats.surface_material_meshes += 1;
let Some(material) = surface_materials.get(&surface_handle.0) else {
next_stats.unsupported_meshes += 1;
if rt_mesh.is_some() {
commands.entity(entity).remove::<RaytracingMesh3d>();
}
continue;
};
if !solari_alpha_mode_is_supported(material.base.alpha_mode) {
next_stats.unsupported_meshes += 1;
if rt_mesh.is_some() {
commands.entity(entity).remove::<RaytracingMesh3d>();
}
continue;
}
if !surface_evaluators
.evaluators
.contains_key(&material.extension.uniform.shader_id)
{
next_stats.surface_materials_missing_evaluator += 1;
next_stats.unsupported_meshes += 1;
if rt_mesh.is_some() {
commands.entity(entity).remove::<RaytracingMesh3d>();
}
continue;
}
// A custom evaluator may emit light as a function of parameters, textures, or world
// position. Conservatively register Surface instances as potential emissive sources;
// zero-emission samples contribute no energy.
next_stats.emissive_meshes += 1;
} else if let Some(standard_handle) = standard_material {
let Some(material) = materials.get(&standard_handle.0) else {
next_stats.unsupported_meshes += 1;
if rt_mesh.is_some() {
commands.entity(entity).remove::<RaytracingMesh3d>();
}
continue;
};
if !solari_alpha_mode_is_supported(material.alpha_mode) {
next_stats.unsupported_meshes += 1;
if rt_mesh.is_some() {
commands.entity(entity).remove::<RaytracingMesh3d>();
}
continue;
}
if material.emissive.to_vec3() != Vec3::ZERO {
next_stats.emissive_meshes += 1;
}
}
match mesh_assets
@ -323,7 +399,7 @@ fn sync_hydrated_raytracing_meshes(
if *stats != next_stats {
info!(
"Solari mesh eligibility: requested={:?} eligible={} tagged={} compatible_assets={} incompatible_assets={} missing_tangents={} missing_u32_indices={} unsupported={} excluded={} outside_level_roots={} emissive={}",
"Solari mesh eligibility: requested={:?} eligible={} tagged={} compatible_assets={} incompatible_assets={} missing_tangents={} missing_u32_indices={} unsupported={} deformed_excluded={} surface_materials={} surface_missing_evaluator={} excluded={} outside_level_roots={} emissive_candidates={}",
caps.requested_gi_path,
next_stats.project_meshes,
next_stats.tagged_meshes,
@ -332,6 +408,9 @@ fn sync_hydrated_raytracing_meshes(
next_stats.mesh_assets_missing_tangents,
next_stats.mesh_assets_missing_u32_indices,
next_stats.unsupported_meshes,
next_stats.deformed_meshes_excluded,
next_stats.surface_material_meshes,
next_stats.surface_materials_missing_evaluator,
next_stats.excluded_meshes,
next_stats.missing_level_root_meshes,
next_stats.emissive_meshes,
@ -340,6 +419,21 @@ fn sync_hydrated_raytracing_meshes(
}
}
fn solari_deformation_is_supported(
has_skin: bool,
has_morph_weights: bool,
mesh_has_morph_targets: bool,
) -> bool {
!has_skin && !has_morph_weights && !mesh_has_morph_targets
}
fn solari_alpha_mode_is_supported(alpha_mode: AlphaMode) -> bool {
matches!(
alpha_mode,
AlphaMode::Opaque | AlphaMode::Mask(_) | AlphaMode::AlphaToCoverage
)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum SolariMeshCompatibility {
Compatible,
@ -380,17 +474,24 @@ fn mesh_is_solari_project_geometry(
Entity,
&Mesh3d,
Option<&MeshMaterial3d<StandardMaterial>>,
Option<&MeshMaterial3d<SurfaceMaterial>>,
Option<&SkinnedMesh>,
Option<&MeshMorphWeights>,
Option<&RaytracingMesh3d>,
Option<&RaytracingExcluded>,
)>,
parents: &Query<&ChildOf>,
level_roots: &Query<(), With<LevelObject>>,
) -> bool {
let Ok((_, _, material, _, excluded)) = meshes.get(entity) else {
let Ok((_, _, standard_material, surface_material, skin, morph, _, excluded)) =
meshes.get(entity)
else {
return false;
};
excluded.is_none()
&& material.is_some()
&& skin.is_none()
&& morph.is_none()
&& (standard_material.is_some() || surface_material.is_some())
&& has_level_object_ancestor(entity, parents, level_roots)
}
@ -569,4 +670,22 @@ mod tests {
GiPath::SolariDeferred
);
}
#[test]
fn deformation_is_excluded_instead_of_tracing_the_bind_pose() {
assert!(solari_deformation_is_supported(false, false, false));
assert!(!solari_deformation_is_supported(true, false, false));
assert!(!solari_deformation_is_supported(false, true, false));
assert!(!solari_deformation_is_supported(false, false, true));
assert!(!solari_deformation_is_supported(true, true, true));
}
#[test]
fn solari_accepts_only_opaque_and_cutout_materials() {
assert!(solari_alpha_mode_is_supported(AlphaMode::Opaque));
assert!(solari_alpha_mode_is_supported(AlphaMode::Mask(0.5)));
assert!(solari_alpha_mode_is_supported(AlphaMode::AlphaToCoverage));
assert!(!solari_alpha_mode_is_supported(AlphaMode::Blend));
assert!(!solari_alpha_mode_is_supported(AlphaMode::Premultiplied));
}
}

View File

@ -7,8 +7,8 @@ use settings::{
ProjectSettings, RenderingCapabilities, RenderingSettings, VolumeContribution,
};
use shared::{
inspector_component_active, InspectorOrder, LevelObject, PostProcessVolumeDesc,
PostProcessVolumeOverrides, COMPONENT_POST_PROCESS_VOLUME,
authoring_component_active, AuthoringComponentStates, InspectorOrder, LevelObject,
PostProcessVolumeDesc, PostProcessVolumeOverrides, COMPONENT_POST_PROCESS_VOLUME,
};
use super::solari::SolariRaytracingSceneStats;
@ -264,6 +264,7 @@ pub fn sync_active_camera_render_profile(
Entity,
&PostProcessVolumeDesc,
&GlobalTransform,
Option<&AuthoringComponentStates>,
Option<&InspectorOrder>,
),
With<LevelObject>,
@ -305,10 +306,10 @@ pub fn sync_active_camera_render_profile(
let volume_list: Vec<_> = volumes
.iter()
.filter(|(_, _, _, order)| {
inspector_component_active(*order, COMPONENT_POST_PROCESS_VOLUME)
.filter(|(_, _, _, states, legacy_order)| {
authoring_component_active(*states, *legacy_order, COMPONENT_POST_PROCESS_VOLUME)
})
.map(|(entity, desc, transform, _)| (entity, desc, transform))
.map(|(entity, desc, transform, _, _)| (entity, desc, transform))
.collect();
let (resolved, entries) = resolve_active_camera_render_profile(
camera_tf.translation(),

View File

@ -4,8 +4,8 @@ use bevy::light::CascadeShadowConfig;
use bevy::prelude::*;
use settings::ProjectSettings;
use shared::{
cascade_config_from_rendering, inspector_component_active, AuthoringLightKind, InspectorOrder,
LevelObject, LightDesc, ProjectSun, COMPONENT_LIGHT_DESC,
authoring_component_active, cascade_config_from_rendering, AuthoringComponentStates,
AuthoringLightKind, InspectorOrder, LevelObject, LightDesc, ProjectSun, COMPONENT_LIGHT_DESC,
};
/// Spawns the directional "sun" from project rendering settings.
@ -14,7 +14,14 @@ pub fn spawn_sun(
mut commands: Commands,
settings: Res<ProjectSettings>,
existing: Query<(), With<DirectionalLight>>,
scene_suns: Query<(&LightDesc, Option<&InspectorOrder>), With<LevelObject>>,
scene_suns: Query<
(
&LightDesc,
Option<&AuthoringComponentStates>,
Option<&InspectorOrder>,
),
With<LevelObject>,
>,
) {
if !existing.is_empty() {
return;
@ -39,7 +46,14 @@ pub fn spawn_sun(
#[unsafe(no_mangle)]
pub fn sync_project_sun_visibility(
scene_suns: Query<(&LightDesc, Option<&InspectorOrder>), With<LevelObject>>,
scene_suns: Query<
(
&LightDesc,
Option<&AuthoringComponentStates>,
Option<&InspectorOrder>,
),
With<LevelObject>,
>,
mut project_suns: Query<(&mut DirectionalLight, &mut Visibility), With<ProjectSun>>,
) {
let scene_override = has_scene_sun_override(&scene_suns);
@ -54,10 +68,17 @@ pub fn sync_project_sun_visibility(
}
fn has_scene_sun_override(
scene_suns: &Query<(&LightDesc, Option<&InspectorOrder>), With<LevelObject>>,
scene_suns: &Query<
(
&LightDesc,
Option<&AuthoringComponentStates>,
Option<&InspectorOrder>,
),
With<LevelObject>,
>,
) -> bool {
scene_suns.iter().any(|(light, order)| {
inspector_component_active(order, COMPONENT_LIGHT_DESC)
scene_suns.iter().any(|(light, states, legacy_order)| {
authoring_component_active(states, legacy_order, COMPONENT_LIGHT_DESC)
&& matches!(light.kind, AuthoringLightKind::Directional)
})
}
@ -99,7 +120,14 @@ pub fn sync_scene_directional_shadow_cascades(
#[unsafe(no_mangle)]
pub fn sync_project_sun_from_settings(
settings: Res<ProjectSettings>,
scene_suns: Query<(&LightDesc, Option<&InspectorOrder>), With<LevelObject>>,
scene_suns: Query<
(
&LightDesc,
Option<&AuthoringComponentStates>,
Option<&InspectorOrder>,
),
With<LevelObject>,
>,
mut project_suns: Query<(&mut DirectionalLight, &mut Visibility), With<ProjectSun>>,
) {
let scene_override = has_scene_sun_override(&scene_suns);

View File

@ -10,5 +10,8 @@ blake3 = "1"
serde = { workspace = true }
ron = "0.8"
ron-edit = "=0.2.0"
nav_glam.workspace = true
polyanya.workspace = true
rerecast.workspace = true
shared.workspace = true
settings.workspace = true

View File

@ -743,7 +743,7 @@ mod tests {
let body = "(resources: {}, entities: {})";
let document = SceneDocument::from_ron_text(body).unwrap();
let text = document.to_ron_text().unwrap();
assert!(text.starts_with("(schema_version: 2,"));
assert!(text.starts_with("(schema_version: 4,"));
assert!(SceneDocument::from_ron_text(&text)
.unwrap()
.entities

View File

@ -6,15 +6,19 @@
mod composition;
pub mod document;
mod migrate;
pub mod navigation;
mod prefab;
mod project_validation;
mod upgrade;
use std::path::Path;
use serde::{Deserialize, Serialize};
pub use composition::{validate_composition_graph, validate_scene_composition};
pub use migrate::{migrate_v1_to_v2, DEFAULT_SUN_ILLUMINANCE_LUX};
pub use migrate::{
migrate_v1_to_v2, migrate_v2_to_v3, migrate_v3_to_v4, DEFAULT_SUN_ILLUMINANCE_LUX,
};
pub use prefab::{
prefab_graph_revision, validate_prefab_graph, validate_prefab_graph_text,
validate_prefab_references, validate_prefab_references_text,
@ -23,8 +27,9 @@ pub use project_validation::{
validate_project, ProjectDependency, ProjectValidationFinding, ProjectValidationReport,
ValidationSeverity,
};
pub use upgrade::{upgrade_project, ProjectUpgradeChange, ProjectUpgradeReport};
pub const CURRENT_SCENE_SCHEMA_VERSION: u32 = 2;
pub const CURRENT_SCENE_SCHEMA_VERSION: u32 = 4;
/// Whether an asset-relative path names a tree excluded from runtime packages.
pub fn is_runtime_package_excluded_tree(relative: &Path) -> bool {
@ -129,8 +134,10 @@ pub fn migrate_scene_text(text: &str) -> Result<String, String> {
};
let migrated_body = match version {
0 | 1 => migrate_v1_to_v2(&body),
2 => body,
0 | 1 => migrate_v3_to_v4(&migrate_v2_to_v3(&migrate_v1_to_v2(&body))),
2 => migrate_v3_to_v4(&migrate_v2_to_v3(&body)),
3 => migrate_v3_to_v4(&body),
4 => body,
_ => {
return Err(format!(
"scene schema version {version} is newer than supported version {CURRENT_SCENE_SCHEMA_VERSION}"
@ -220,16 +227,16 @@ mod tests {
fn stamp_and_strip_round_trip() {
let body = "(resources: {}, entities: {})";
let stamped = stamp_schema_version(body).unwrap();
assert!(stamped.starts_with("(schema_version: 2,"));
assert!(stamped.starts_with("(schema_version: 4,"));
let stripped = strip_schema_version(&stamped).unwrap();
assert_eq!(stripped, body);
}
#[test]
fn legacy_scene_migrates_to_v2() {
fn legacy_scene_migrates_to_current_schema() {
let legacy = "(resources: {}, entities: {4294967133: ()})";
let migrated = migrate_scene_text(legacy).unwrap();
assert!(migrated.contains("schema_version: 2"));
assert!(migrated.contains("schema_version: 4"));
}
#[test]

View File

@ -12,6 +12,57 @@ pub fn migrate_v1_to_v2(body: &str) -> String {
out
}
/// Migrates v2 animated imported-model actors to the dedicated skinned renderer contract.
///
/// `ModelRef` and `SkinnedMeshRenderer` intentionally share the same serialized source fields, so
/// the migration can preserve stable asset IDs and cached paths without lossy reconstruction.
pub fn migrate_v2_to_v3(body: &str) -> String {
let mut result = String::with_capacity(body.len() + 256);
let mut rest = body;
while let Some(index) = rest.find("components: {") {
result.push_str(&rest[..index]);
let after = &rest[index..];
let Some(close) = find_components_close(after) else {
result.push_str(after);
return result;
};
let block = &after[..close];
if block.contains("\"shared::animation::AnimationControllerDesc\"")
&& block.contains("\"shared::components::ModelRef\"")
{
result.push_str(
&block
.replace(
"\"shared::components::ModelRef\"",
"\"shared::animation::SkinnedMeshRenderer\"",
)
.replace(
"\"shared::components::ActorKind\": ImportedModel",
"\"shared::components::ActorKind\": SkinnedMesh",
)
.replace(
"\"shared::components::ActorKind\":ImportedModel",
"\"shared::components::ActorKind\":SkinnedMesh",
),
);
} else {
result.push_str(block);
}
rest = &after[close..];
}
result.push_str(rest);
result
}
/// Migrates schema-v3 documents into the registry-driven renderer/component foundation.
///
/// The v4 authoring types deliberately use serde defaults for material-slot sets and component
/// enable state, so normal loading can remain read-only. The explicit project upgrader later
/// materializes shared Material assets and canonical slot IDs on disk.
pub fn migrate_v3_to_v4(body: &str) -> String {
body.to_string()
}
fn backfill_actor_kinds(text: &str) -> String {
let mut result = String::with_capacity(text.len() + 1024);
let mut rest = text;
@ -89,6 +140,9 @@ fn infer_kind_from_block(block: &str) -> &'static str {
if block.contains("\"shared::components::LightDesc\"") {
return actor_kind_ron(ActorKind::Light);
}
if block.contains("\"shared::animation::SkinnedMeshRenderer\"") {
return actor_kind_ron(ActorKind::SkinnedMesh);
}
if block.contains("\"shared::components::ModelRef\"") {
return actor_kind_ron(ActorKind::ImportedModel);
}
@ -111,6 +165,9 @@ fn infer_kind_from_block(block: &str) -> &'static str {
if block.contains("\"shared::components::AudioListenerDesc\"") {
return actor_kind_ron(ActorKind::AudioListener);
}
if block.contains("\"shared::navigation::Navigation") {
return actor_kind_ron(ActorKind::Navigation);
}
actor_kind_ron(ActorKind::Empty)
}
@ -119,6 +176,7 @@ fn actor_kind_ron(kind: ActorKind) -> &'static str {
ActorKind::Empty => "Empty",
ActorKind::Brush => "Brush",
ActorKind::StaticMesh => "StaticMesh",
ActorKind::SkinnedMesh => "SkinnedMesh",
ActorKind::ImportedModel => "ImportedModel",
ActorKind::Light => "Light",
ActorKind::PrefabAnchor => "PrefabAnchor",
@ -130,6 +188,7 @@ fn actor_kind_ron(kind: ActorKind) -> &'static str {
ActorKind::Objective => "Objective",
ActorKind::AudioSource => "AudioSource",
ActorKind::AudioListener => "AudioListener",
ActorKind::Navigation => "Navigation",
}
}
@ -241,6 +300,35 @@ mod tests {
assert!(migrated.contains("Brush"));
}
#[test]
fn v2_animation_actor_moves_to_dedicated_skinned_renderer() {
let body = r#"(
resources: {},
entities: {
1: (components: {
"shared::components::LevelObject": (),
"shared::components::ActorKind": ImportedModel,
"shared::components::ModelRef": (asset_id: "hero", path: "models/hero.glb", scene_index: 0),
"shared::animation::AnimationControllerDesc": (skeleton: None, states: [], default_state: "", default_crossfade_seconds: 0.2),
}),
2: (components: {
"shared::components::ActorKind": ImportedModel,
"shared::components::ModelRef": (asset_id: "prop", path: "models/prop.glb", scene_index: 0),
}),
},
)"#;
let migrated = migrate_v2_to_v3(body);
assert!(migrated.contains("\"shared::animation::SkinnedMeshRenderer\""));
assert!(migrated.contains("\"shared::components::ActorKind\": SkinnedMesh"));
assert_eq!(
migrated.matches("\"shared::components::ModelRef\"").count(),
1,
"generic scene instance must remain a ModelRef"
);
}
#[test]
fn backfills_actor_kind_for_audio_source_entity() {
let body = r#"(

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

450
crates/scene/src/upgrade.rs Normal file
View File

@ -0,0 +1,450 @@
//! Explicit, transactional project content upgrade support.
use std::fs;
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
use serde::Serialize;
use shared::{
AuthoringComponentStates, ComponentInstanceId, InspectorOrder, MaterialAsset,
MaterialInstanceAsset, MaterialRef, RendererMaterialSlot, ShaderSchemaAsset,
StaticMeshRenderer, COMPONENT_STATIC_MESH_RENDERER,
};
use crate::document::{SceneComponentBlob, SceneDocument};
const INSPECTOR_ORDER_COMPONENT: &str = "shared::components::InspectorOrder";
const AUTHORING_COMPONENT_STATES_COMPONENT: &str = "shared::components::AuthoringComponentStates";
#[derive(Debug, Clone, Serialize)]
pub struct ProjectUpgradeChange {
pub path: String,
pub kind: String,
}
#[derive(Debug, Clone, Serialize)]
pub struct ProjectUpgradeReport {
pub applied: bool,
pub backup_path: Option<String>,
pub changes: Vec<ProjectUpgradeChange>,
pub warnings: Vec<String>,
}
struct PendingWrite {
path: PathBuf,
contents: String,
kind: &'static str,
}
pub fn upgrade_project(root: &Path, apply: bool) -> Result<ProjectUpgradeReport, String> {
let assets = root.join("assets");
let mut files = Vec::new();
collect_files(&assets, &mut files)?;
files.sort();
let mut pending = Vec::new();
let mut warnings = Vec::new();
for path in files {
if path
.file_name()
.and_then(|name| name.to_str())
.is_some_and(|name| name.starts_with('.'))
{
continue;
}
let relative = path.strip_prefix(root).unwrap_or(&path);
let normalized = relative.to_string_lossy().replace('\\', "/");
let Some(original) = fs::read_to_string(&path).ok() else {
continue;
};
let upgraded = if normalized.ends_with(".scn.ron") || normalized.ends_with(".prefab.ron") {
match canonical_scene_document(&original) {
Ok(value) => Some((value, "scene-schema")),
Err(error) => {
warnings.push(format!("{normalized}: {error}"));
None
}
}
} else if normalized.contains("/materials/") && normalized.ends_with(".ron") {
canonical_material_document(&path, &original)
} else if normalized.contains("/shaders/") && normalized.ends_with(".shader.ron") {
canonical_shader_document(&original)
} else {
None
};
let Some((contents, kind)) = upgraded else {
continue;
};
if normalize_text(&contents) != normalize_text(&original) {
pending.push(PendingWrite {
path,
contents,
kind,
});
}
}
let changes = pending
.iter()
.map(|write| ProjectUpgradeChange {
path: write
.path
.strip_prefix(root)
.unwrap_or(&write.path)
.to_string_lossy()
.replace('\\', "/"),
kind: write.kind.to_string(),
})
.collect::<Vec<_>>();
if !apply || pending.is_empty() {
return Ok(ProjectUpgradeReport {
applied: false,
backup_path: None,
changes,
warnings,
});
}
let timestamp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_err(|error| error.to_string())?
.as_secs();
let backup_root = root
.join(".blacksite")
.join("backups")
.join(format!("material-component-v4-{timestamp}"));
for write in &pending {
let relative = write
.path
.strip_prefix(root)
.map_err(|error| error.to_string())?;
let backup = backup_root.join(relative);
if let Some(parent) = backup.parent() {
fs::create_dir_all(parent).map_err(|error| error.to_string())?;
}
fs::copy(&write.path, &backup).map_err(|error| {
format!(
"could not back up {} to {}: {error}",
write.path.display(),
backup.display()
)
})?;
}
let mut staged = Vec::new();
for (index, write) in pending.iter().enumerate() {
let temporary = write
.path
.with_extension(format!("blacksite-upgrade-{index}.tmp"));
fs::write(&temporary, &write.contents)
.map_err(|error| format!("could not stage {}: {error}", write.path.display()))?;
staged.push((temporary, write.path.clone()));
}
for (temporary, destination) in &staged {
if let Err(error) = fs::rename(temporary, destination) {
for change in &changes {
let backup = backup_root.join(&change.path);
let destination = root.join(&change.path);
let _ = fs::copy(backup, destination);
}
return Err(format!(
"project upgrade failed while replacing {} and was rolled back: {error}",
destination.display()
));
}
}
Ok(ProjectUpgradeReport {
applied: true,
backup_path: Some(
backup_root
.strip_prefix(root)
.unwrap_or(&backup_root)
.to_string_lossy()
.replace('\\', "/"),
),
changes,
warnings,
})
}
fn canonical_scene_document(text: &str) -> Result<String, String> {
let mut document = SceneDocument::from_ron_text(text)?;
for entity in &mut document.entities {
let present_types = entity
.components
.iter()
.filter(|component| shared::authoring_component_id(&component.type_name).is_some())
.map(|component| component.type_name.clone())
.collect::<Vec<_>>();
if let Some(component) = entity
.components
.iter_mut()
.find(|component| component.type_name == COMPONENT_STATIC_MESH_RENDERER)
{
let mut renderer: StaticMeshRenderer =
ron::from_str(&component.ron).map_err(|error| {
format!("could not upgrade StaticMeshRenderer component: {error}")
})?;
normalize_static_renderer(&mut renderer);
*component =
SceneComponentBlob::from_serializable(COMPONENT_STATIC_MESH_RENDERER, &renderer)?;
}
let states_index = entity
.components
.iter()
.position(|component| component.type_name == AUTHORING_COMPONENT_STATES_COMPONENT);
let mut states = states_index
.and_then(|index| {
ron::from_str::<AuthoringComponentStates>(&entity.components[index].ron).ok()
})
.unwrap_or_default();
normalize_component_states(&mut states);
let mut migrated_legacy_state = false;
if let Some(order_index) = entity
.components
.iter()
.position(|component| component.type_name == INSPECTOR_ORDER_COMPONENT)
{
let mut order: InspectorOrder = ron::from_str(&entity.components[order_index].ron)
.map_err(|error| format!("could not upgrade InspectorOrder component: {error}"))?;
for legacy in std::mem::take(&mut order.component_states) {
let key = if legacy.component_id.trim().is_empty() {
legacy.type_name
} else {
legacy.component_id
};
if !key.trim().is_empty() {
states.set_component_active(key, legacy.active);
migrated_legacy_state = true;
}
}
let present = present_types.iter().map(String::as_str).collect::<Vec<_>>();
order.ensure_component_order(&present);
entity.components[order_index] =
SceneComponentBlob::from_serializable(INSPECTOR_ORDER_COMPONENT, &order)?;
}
if states_index.is_some() || migrated_legacy_state {
let component = SceneComponentBlob::from_serializable(
AUTHORING_COMPONENT_STATES_COMPONENT,
&states,
)?;
if let Some(index) = states_index {
entity.components[index] = component;
} else {
entity.components.push(component);
}
}
}
document.to_ron_text()
}
fn normalize_static_renderer(renderer: &mut StaticMeshRenderer) {
for (index, part) in renderer.slots.iter_mut().enumerate() {
if part.id.is_empty() {
let id = if part.mesh.sub_asset_id.trim().is_empty() {
format!("draw:legacy:{index}")
} else {
part.mesh.sub_asset_id.clone()
};
part.id = ComponentInstanceId::new(id);
}
if part.material_slot_id.is_empty() {
part.material_slot_id = ComponentInstanceId::new(format!("slot:{}", part.id.0));
}
let legacy_material = part.material.take().map(MaterialRef::new);
if let Some(slot) = renderer.materials.slot_mut(&part.material_slot_id) {
if slot.source_material.is_none() {
slot.source_material = legacy_material;
}
if slot.name.trim().is_empty() {
slot.name = part.name.clone();
}
} else {
renderer.materials.slots.push(RendererMaterialSlot {
id: part.material_slot_id.clone(),
name: part.name.clone(),
source_material: legacy_material,
material: None,
});
}
}
}
fn normalize_component_states(states: &mut AuthoringComponentStates) {
let old = std::mem::take(&mut states.states);
for state in old {
let key = if state.component_id.trim().is_empty() {
state.type_name
} else {
state.component_id
};
if !key.trim().is_empty() {
states.set_component_active(key, state.active);
}
}
}
fn canonical_material_document(path: &Path, text: &str) -> Option<(String, &'static str)> {
if let Ok(asset) = ron::from_str::<MaterialAsset>(text) {
return ron::ser::to_string_pretty(&asset, ron::ser::PrettyConfig::default())
.ok()
.map(|text| (text, "material-schema"));
}
if let Ok(instance) = ron::from_str::<MaterialInstanceAsset>(text) {
return ron::ser::to_string_pretty(&instance, ron::ser::PrettyConfig::default())
.ok()
.map(|text| (text, "material-instance-schema"));
}
let _ = path;
None
}
fn canonical_shader_document(text: &str) -> Option<(String, &'static str)> {
let schema = ron::from_str::<ShaderSchemaAsset>(text).ok()?;
ron::ser::to_string_pretty(&schema, ron::ser::PrettyConfig::default())
.ok()
.map(|text| (text, "surface-shader-schema"))
}
fn collect_files(path: &Path, output: &mut Vec<PathBuf>) -> Result<(), String> {
if !path.exists() {
return Ok(());
}
for entry in fs::read_dir(path).map_err(|error| error.to_string())? {
let entry = entry.map_err(|error| error.to_string())?;
let path = entry.path();
if entry
.file_type()
.map_err(|error| error.to_string())?
.is_dir()
{
collect_files(&path, output)?;
} else {
output.push(path);
}
}
Ok(())
}
fn normalize_text(text: &str) -> String {
text.trim().replace("\r\n", "\n")
}
#[cfg(test)]
mod tests {
use super::*;
use shared::{
EditorAssetRef, InspectorComponentState, MeshRenderSlot,
AUTHORING_COMPONENT_STATIC_MESH_RENDERER,
};
#[test]
fn dry_run_does_not_write_and_apply_creates_backup() {
let root = std::env::temp_dir().join(format!(
"blacksite-upgrader-{}",
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos()
));
let level = root.join("assets/levels/main.scn.ron");
fs::create_dir_all(level.parent().unwrap()).unwrap();
fs::write(&level, "(schema_version: 3, resources: {}, entities: {})").unwrap();
let dry_run = upgrade_project(&root, false).unwrap();
assert!(!dry_run.applied);
assert_eq!(dry_run.changes.len(), 1);
assert!(fs::read_to_string(&level)
.unwrap()
.contains("schema_version: 3"));
let applied = upgrade_project(&root, true).unwrap();
assert!(applied.applied);
assert!(fs::read_to_string(&level)
.unwrap()
.contains("schema_version: 4"));
assert!(root.join(applied.backup_path.unwrap()).exists());
fs::remove_dir_all(root).unwrap();
}
#[test]
fn canonical_upgrade_materializes_slots_and_separates_active_state() {
let renderer = StaticMeshRenderer {
slots: vec![MeshRenderSlot {
id: ComponentInstanceId::new("draw:body"),
name: "Body".into(),
mesh: EditorAssetRef::new("model", "mesh:body", "Body"),
material: Some(EditorAssetRef::new(
"model",
"material:body",
"Body Material",
)),
..Default::default()
}],
..Default::default()
};
let order = InspectorOrder {
component_type_names: vec![COMPONENT_STATIC_MESH_RENDERER.into()],
component_states: vec![InspectorComponentState {
type_name: COMPONENT_STATIC_MESH_RENDERER.into(),
active: false,
..Default::default()
}],
..Default::default()
};
let text = format!(
"(schema_version: 3, resources: {{}}, entities: {{1: (components: {{\"{COMPONENT_STATIC_MESH_RENDERER}\": {}, \"{INSPECTOR_ORDER_COMPONENT}\": {}}})}})",
ron::to_string(&renderer).unwrap(),
ron::to_string(&order).unwrap(),
);
let upgraded = canonical_scene_document(&text).unwrap();
let document = SceneDocument::from_ron_text(&upgraded).unwrap();
let components = &document.entities[0].components;
let renderer: StaticMeshRenderer = ron::from_str(
&components
.iter()
.find(|component| component.type_name == COMPONENT_STATIC_MESH_RENDERER)
.unwrap()
.ron,
)
.unwrap();
assert!(renderer.slots[0].material.is_none());
assert_eq!(renderer.materials.slots.len(), 1);
assert_eq!(
renderer.materials.slots[0]
.source_material
.as_ref()
.unwrap()
.0
.sub_asset_id,
"material:body"
);
let order: InspectorOrder = ron::from_str(
&components
.iter()
.find(|component| component.type_name == INSPECTOR_ORDER_COMPONENT)
.unwrap()
.ron,
)
.unwrap();
assert!(order.component_states.is_empty());
assert_eq!(
order.component_ids,
vec![AUTHORING_COMPONENT_STATIC_MESH_RENDERER]
);
let states: AuthoringComponentStates = ron::from_str(
&components
.iter()
.find(|component| component.type_name == AUTHORING_COMPONENT_STATES_COMPONENT)
.unwrap()
.ron,
)
.unwrap();
assert!(!states.is_component_active(COMPONENT_STATIC_MESH_RENDERER));
}
}

View File

@ -227,17 +227,22 @@ impl Default for InputSettings {
/// Loads settings from disk, falling back to defaults.
pub fn load_project_settings_from_path(path: &str) -> ProjectSettings {
load_project_settings_with_source(path).0
}
/// Loads settings plus the exact source text used when the file was readable.
pub fn load_project_settings_with_source(path: &str) -> (ProjectSettings, Option<String>) {
match std::fs::read_to_string(path) {
Ok(contents) => match ron::from_str(&contents) {
Ok(settings) => settings,
Ok(settings) => (settings, Some(contents)),
Err(error) => {
warn!("Failed to parse {path}: {error}; using defaults");
ProjectSettings::default()
(ProjectSettings::default(), Some(contents))
}
},
Err(error) => {
warn!("Could not read {path}: {error}; using defaults");
ProjectSettings::default()
(ProjectSettings::default(), None)
}
}
}
@ -266,6 +271,27 @@ mod tests {
assert_eq!(parsed.rendering.gi_mode, settings.rendering.gi_mode);
}
#[test]
fn settings_load_returns_the_exact_source_for_revision_guards() {
let root = std::env::temp_dir().join(format!(
"blacksite-settings-source-{}",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
let path = root.join("project.ron");
std::fs::create_dir_all(&root).unwrap();
let source = save_project_settings_to_string(&ProjectSettings::default()).unwrap();
std::fs::write(&path, &source).unwrap();
let (settings, loaded_source) = load_project_settings_with_source(path.to_str().unwrap());
assert_eq!(settings.name, ProjectSettings::default().name);
assert_eq!(loaded_source.as_deref(), Some(source.as_str()));
std::fs::remove_dir_all(root).unwrap();
}
#[test]
fn legacy_manifest_defaults_project_identity_fields() {
let legacy = r#"(

View File

@ -1,7 +1,7 @@
use bevy::prelude::*;
use crate::{
load_project_settings_from_path, save_project_settings_to_string, PhysicsSettings,
load_project_settings_with_source, save_project_settings_to_string, PhysicsSettings,
ProjectSettings, DEFAULT_PROJECT_PATH,
};
@ -56,6 +56,8 @@ impl SimTuning {
pub struct ProjectSettingsIo {
pub path: String,
pub dirty: bool,
/// Exact source loaded or last saved by this process; collaboration guards hash it.
pub loaded_source: Option<String>,
}
impl Default for ProjectSettingsIo {
@ -63,6 +65,7 @@ impl Default for ProjectSettingsIo {
Self {
path: DEFAULT_PROJECT_PATH.into(),
dirty: false,
loaded_source: None,
}
}
}
@ -91,8 +94,9 @@ impl Plugin for ProjectSettingsPlugin {
}
}
fn load_settings(mut commands: Commands, io: Res<ProjectSettingsIo>) {
let settings = load_project_settings_from_path(&io.path);
fn load_settings(mut commands: Commands, mut io: ResMut<ProjectSettingsIo>) {
let (settings, source) = load_project_settings_with_source(&io.path);
io.loaded_source = source;
let tuning = SimTuning::from_physics(&settings.physics);
commands.insert_resource(settings);
commands.insert_resource(tuning);
@ -103,13 +107,17 @@ pub fn sync_sim_tuning(settings: &ProjectSettings, mut tuning: ResMut<SimTuning>
*tuning = SimTuning::from_physics(&settings.physics);
}
/// Persists settings to the path in [`ProjectSettingsIo`].
/// Low-level unconditional persistence for non-editor callers.
///
/// The editor uses its guarded authored-file publication path instead so external revisions cannot
/// be overwritten.
pub fn save_project_settings(
settings: &ProjectSettings,
io: &mut ProjectSettingsIo,
) -> Result<(), String> {
let text = save_project_settings_to_string(settings).map_err(|error| error.to_string())?;
std::fs::write(&io.path, text).map_err(|error| error.to_string())?;
std::fs::write(&io.path, &text).map_err(|error| error.to_string())?;
io.loaded_source = Some(text);
io.dirty = false;
Ok(())
}

View File

@ -8,6 +8,7 @@ description = "Shared reflectable authoring types and scene hydration for the FP
[dependencies]
avian3d.workspace = true
bevy.workspace = true
blake3 = "1"
ron = "0.8"
serde.workspace = true
settings.workspace = true

View File

@ -9,14 +9,37 @@ use bevy::prelude::*;
use crate::{
animation_clip_source_index, animation_skeleton_source_index, brush_math::validate_brush,
ActorKind, AnimationControllerDesc, AudioListenerDesc, AudioSourceDesc, BrushDesc, LevelObject,
LightDesc, ModelRef, ObjectiveMarker, PlayerSpawn, PostProcessVolumeDesc, PrefabInstance,
PrefabRef, Primitive, StaticMeshRenderer, TeamSpawn, TriggerVolume, WeaponSpawn,
LightDesc, ModelRef, NavigationArea, NavigationBounds, NavigationLink, NavigationObstacle,
ObjectiveMarker, PlayerSpawn, PostProcessVolumeDesc, PrefabInstance, PrefabRef, Primitive,
SkinnedMeshRenderer, StaticMeshRenderer, TeamSpawn, TriggerVolume, WeaponSpawn,
AUDIO_CLIP_SUB_ASSET_ID,
};
/// One-shot deterministic kind from authoring components (scene migration only).
/// Deterministic presentation/compatibility hint derived from authoring components.
///
/// Validation is component-driven; this priority only chooses an icon/default
/// label when an actor composes several compatible behaviors (for example a
/// rendered mesh with a light).
pub fn infer_actor_kind(entity: EntityRef<'_>) -> Option<ActorKind> {
entity.get::<LevelObject>()?;
if entity.get::<PrefabRef>().is_some() || entity.get::<PrefabInstance>().is_some() {
return Some(ActorKind::PrefabAnchor);
}
if entity.get::<SkinnedMeshRenderer>().is_some() {
return Some(ActorKind::SkinnedMesh);
}
if entity.get::<BrushDesc>().is_some() {
return Some(ActorKind::Brush);
}
if entity.get::<Primitive>().is_some() || entity.get::<StaticMeshRenderer>().is_some() {
return Some(ActorKind::StaticMesh);
}
if entity.get::<ModelRef>().is_some() {
return Some(ActorKind::ImportedModel);
}
if entity.get::<LightDesc>().is_some() {
return Some(ActorKind::Light);
}
if entity.get::<PlayerSpawn>().is_some() {
return Some(ActorKind::PlayerSpawn);
}
@ -35,27 +58,19 @@ pub fn infer_actor_kind(entity: EntityRef<'_>) -> Option<ActorKind> {
if entity.get::<ObjectiveMarker>().is_some() {
return Some(ActorKind::Objective);
}
if entity.get::<LightDesc>().is_some() {
return Some(ActorKind::Light);
}
if entity.get::<BrushDesc>().is_some() {
return Some(ActorKind::Brush);
}
if entity.get::<Primitive>().is_some() || entity.get::<StaticMeshRenderer>().is_some() {
return Some(ActorKind::StaticMesh);
}
if entity.get::<ModelRef>().is_some() {
return Some(ActorKind::ImportedModel);
}
if entity.get::<PrefabRef>().is_some() || entity.get::<PrefabInstance>().is_some() {
return Some(ActorKind::PrefabAnchor);
}
if entity.get::<AudioSourceDesc>().is_some() {
return Some(ActorKind::AudioSource);
}
if entity.get::<AudioListenerDesc>().is_some() {
return Some(ActorKind::AudioListener);
}
if entity.get::<NavigationBounds>().is_some()
|| entity.get::<NavigationObstacle>().is_some()
|| entity.get::<NavigationArea>().is_some()
|| entity.get::<NavigationLink>().is_some()
{
return Some(ActorKind::Navigation);
}
Some(ActorKind::Empty)
}
@ -75,6 +90,13 @@ pub enum ActorValidationError {
ImportedModelMissingModelRef,
ImportedModelHasPrimitive,
ImportedModelHasStaticMeshRenderer,
SkinnedMeshMissingRenderer,
SkinnedMeshInvalidRenderer,
SkinnedMeshHasPrimitive,
SkinnedMeshHasStaticMeshRenderer,
SkinnedMeshHasModelRef,
SkinnedMeshRendererActorKindMismatch,
ConflictingGeometrySources,
LightMissingLightDesc,
LightHasPrimitive,
LightHasModelRef,
@ -93,7 +115,7 @@ pub enum ActorValidationError {
AudioSourceMissingBus,
AudioListenerMissingDesc,
AudioListenerInvalidEarGap,
AnimationControllerMissingModelRef,
AnimationControllerMissingSkinnedMeshRenderer,
AnimationControllerMissingSkeleton,
AnimationControllerInvalidSkeletonReference,
AnimationControllerEmptyStateId,
@ -104,6 +126,7 @@ pub enum ActorValidationError {
AnimationControllerInvalidCrossfade,
AnimationControllerMissingDefaultState,
AnimationControllerUnknownDefaultState,
InvalidNavigation(String),
}
fn is_finite_positive(v: f32) -> bool {
@ -120,9 +143,10 @@ pub fn validate_actor(entity: EntityRef<'_>) -> Result<(), ActorValidationError>
return Ok(());
}
let Some(kind) = entity.get::<ActorKind>() else {
let Some(_stored_kind) = entity.get::<ActorKind>() else {
return Err(ActorValidationError::MissingActorKind);
};
let kind = infer_actor_kind(entity).unwrap_or(ActorKind::Empty);
if entity.get::<Transform>().is_none() {
return Err(ActorValidationError::MissingTransform);
@ -135,13 +159,50 @@ pub fn validate_actor(entity: EntityRef<'_>) -> Result<(), ActorValidationError>
validate_audio_listener(listener)?;
}
if let Some(controller) = entity.get::<AnimationControllerDesc>() {
if entity.get::<ModelRef>().is_none() {
return Err(ActorValidationError::AnimationControllerMissingModelRef);
if entity.get::<SkinnedMeshRenderer>().is_none() {
return Err(ActorValidationError::AnimationControllerMissingSkinnedMeshRenderer);
}
validate_animation_controller(controller)?;
}
let geometry_source_count = usize::from(entity.get::<Primitive>().is_some())
+ usize::from(entity.get::<BrushDesc>().is_some())
+ usize::from(entity.get::<StaticMeshRenderer>().is_some())
+ usize::from(entity.get::<SkinnedMeshRenderer>().is_some())
+ usize::from(entity.get::<ModelRef>().is_some());
if geometry_source_count > 1 {
return Err(ActorValidationError::ConflictingGeometrySources);
}
if let Some(brush) = entity.get::<BrushDesc>() {
let report = validate_brush(brush);
if !report.is_valid() {
let message = report
.diagnostics
.into_iter()
.find(|diagnostic| {
diagnostic.severity == crate::brush_math::BrushDiagnosticSeverity::Error
})
.map(|diagnostic| diagnostic.message)
.unwrap_or_else(|| "Brush geometry is invalid.".to_string());
return Err(ActorValidationError::InvalidBrushGeometry(message));
}
}
if let Some(renderer) = entity.get::<SkinnedMeshRenderer>() {
if renderer.path.trim().is_empty() {
return Err(ActorValidationError::SkinnedMeshInvalidRenderer);
}
}
if let Some(desc) = entity.get::<PostProcessVolumeDesc>() {
validate_post_process_volume(desc)?;
}
if entity.get::<NavigationBounds>().is_some()
|| entity.get::<NavigationObstacle>().is_some()
|| entity.get::<NavigationArea>().is_some()
|| entity.get::<NavigationLink>().is_some()
{
validate_navigation_actor(entity)?;
}
match kind {
match &kind {
ActorKind::Brush => {
let Some(brush) = entity.get::<BrushDesc>() else {
return Err(ActorValidationError::BrushMissingDesc);
@ -152,24 +213,10 @@ pub fn validate_actor(entity: EntityRef<'_>) -> Result<(), ActorValidationError>
if entity.get::<StaticMeshRenderer>().is_some() {
return Err(ActorValidationError::BrushHasStaticMeshRenderer);
}
if entity.get::<LightDesc>().is_some() {
return Err(ActorValidationError::BrushHasLight);
}
if entity.get::<ModelRef>().is_some() {
return Err(ActorValidationError::BrushHasModelRef);
}
let report = validate_brush(brush);
if !report.is_valid() {
let message = report
.diagnostics
.into_iter()
.find(|diagnostic| {
diagnostic.severity == crate::brush_math::BrushDiagnosticSeverity::Error
})
.map(|diagnostic| diagnostic.message)
.unwrap_or_else(|| "Brush geometry is invalid.".to_string());
return Err(ActorValidationError::InvalidBrushGeometry(message));
}
let _ = brush;
}
ActorKind::StaticMesh => {
let has_static_mesh_renderer = entity
@ -178,13 +225,24 @@ pub fn validate_actor(entity: EntityRef<'_>) -> Result<(), ActorValidationError>
if entity.get::<Primitive>().is_none() && !has_static_mesh_renderer {
return Err(ActorValidationError::StaticMeshMissingPrimitive);
}
if entity.get::<LightDesc>().is_some() {
return Err(ActorValidationError::StaticMeshHasLight);
}
if entity.get::<ModelRef>().is_some() {
return Err(ActorValidationError::StaticMeshHasModelRef);
}
}
ActorKind::SkinnedMesh => {
let Some(_renderer) = entity.get::<SkinnedMeshRenderer>() else {
return Err(ActorValidationError::SkinnedMeshMissingRenderer);
};
if entity.get::<Primitive>().is_some() {
return Err(ActorValidationError::SkinnedMeshHasPrimitive);
}
if entity.get::<StaticMeshRenderer>().is_some() {
return Err(ActorValidationError::SkinnedMeshHasStaticMeshRenderer);
}
if entity.get::<ModelRef>().is_some() {
return Err(ActorValidationError::SkinnedMeshHasModelRef);
}
}
ActorKind::ImportedModel => {
if entity.get::<ModelRef>().is_none() {
return Err(ActorValidationError::ImportedModelMissingModelRef);
@ -200,40 +258,13 @@ pub fn validate_actor(entity: EntityRef<'_>) -> Result<(), ActorValidationError>
if entity.get::<LightDesc>().is_none() {
return Err(ActorValidationError::LightMissingLightDesc);
}
if entity.get::<Primitive>().is_some() {
return Err(ActorValidationError::LightHasPrimitive);
}
if entity.get::<ModelRef>().is_some() {
return Err(ActorValidationError::LightHasModelRef);
}
if entity.get::<StaticMeshRenderer>().is_some() {
return Err(ActorValidationError::LightHasStaticMeshRenderer);
}
// Other compatible authoring components may be composed with a light.
}
ActorKind::PostProcessVolume => {
let Some(desc) = entity.get::<PostProcessVolumeDesc>() else {
return Err(ActorValidationError::PostProcessVolumeMissingDesc);
};
if !is_finite_positive(desc.half_extents.x)
|| !is_finite_positive(desc.half_extents.y)
|| !is_finite_positive(desc.half_extents.z)
{
return Err(ActorValidationError::PostProcessVolumeInvalidHalfExtents);
}
if !is_finite_non_negative(desc.blend_distance) {
return Err(ActorValidationError::PostProcessVolumeInvalidBlendDistance);
}
let o = &desc.overrides;
if let Some(v) = o.exposure_ev100 {
if !v.is_finite() {
return Err(ActorValidationError::PostProcessVolumeInvalidOverrideScalar);
}
}
if let Some(v) = o.fog_density {
if !v.is_finite() || v < 0.0 {
return Err(ActorValidationError::PostProcessVolumeInvalidOverrideScalar);
}
}
let _ = desc;
}
ActorKind::AudioSource => {
if entity.get::<AudioSourceDesc>().is_none() {
@ -245,6 +276,7 @@ pub fn validate_actor(entity: EntityRef<'_>) -> Result<(), ActorValidationError>
return Err(ActorValidationError::AudioListenerMissingDesc);
}
}
ActorKind::Navigation => {}
ActorKind::Empty
| ActorKind::PrefabAnchor
| ActorKind::PlayerSpawn
@ -257,6 +289,109 @@ pub fn validate_actor(entity: EntityRef<'_>) -> Result<(), ActorValidationError>
Ok(())
}
fn validate_post_process_volume(desc: &PostProcessVolumeDesc) -> Result<(), ActorValidationError> {
if !is_finite_positive(desc.half_extents.x)
|| !is_finite_positive(desc.half_extents.y)
|| !is_finite_positive(desc.half_extents.z)
{
return Err(ActorValidationError::PostProcessVolumeInvalidHalfExtents);
}
if !is_finite_non_negative(desc.blend_distance) {
return Err(ActorValidationError::PostProcessVolumeInvalidBlendDistance);
}
let overrides = &desc.overrides;
if overrides
.exposure_ev100
.is_some_and(|value| !value.is_finite())
|| overrides
.fog_density
.is_some_and(|value| !value.is_finite() || value < 0.0)
{
return Err(ActorValidationError::PostProcessVolumeInvalidOverrideScalar);
}
Ok(())
}
fn validate_navigation_actor(entity: EntityRef<'_>) -> Result<(), ActorValidationError> {
let mut component_count = 0;
if let Some(bounds) = entity.get::<NavigationBounds>() {
component_count += 1;
validate_vec3_positive(bounds.half_extents, "navigation bounds")?;
bounds
.agent
.validate()
.map_err(|error| ActorValidationError::InvalidNavigation(error.into()))?;
crate::navigation_generated_artifact_path(&bounds.artifact_path)
.map_err(|error| ActorValidationError::InvalidNavigation(error.into()))?;
let mut sample_ids = HashSet::new();
for sample in &bounds.validation_samples {
let sample_id = sample.id.trim();
if sample_id.is_empty() {
return Err(ActorValidationError::InvalidNavigation(
"navigation validation sample ID must not be empty".into(),
));
}
if !sample_ids.insert(sample_id) {
return Err(ActorValidationError::InvalidNavigation(format!(
"navigation validation sample ID `{sample_id}` must be unique within its bounds"
)));
}
if !sample.start.is_finite() || !sample.end.is_finite() || sample.start == sample.end {
return Err(ActorValidationError::InvalidNavigation(format!(
"navigation validation sample `{sample_id}` endpoints must be finite and distinct"
)));
}
}
}
if let Some(obstacle) = entity.get::<NavigationObstacle>() {
component_count += 1;
validate_vec3_positive(obstacle.half_extents, "navigation obstacle")?;
}
if let Some(area) = entity.get::<NavigationArea>() {
component_count += 1;
validate_vec3_positive(area.half_extents, "navigation area")?;
if area.id.trim().is_empty() {
return Err(ActorValidationError::InvalidNavigation(
"navigation area ID must not be empty".into(),
));
}
if !is_finite_positive(area.cost) {
return Err(ActorValidationError::InvalidNavigation(
"navigation area cost must be finite and greater than zero".into(),
));
}
}
if let Some(link) = entity.get::<NavigationLink>() {
component_count += 1;
if !link.start.is_finite() || !link.end.is_finite() || link.start == link.end {
return Err(ActorValidationError::InvalidNavigation(
"navigation link endpoints must be finite and distinct".into(),
));
}
if !is_finite_positive(link.cost) {
return Err(ActorValidationError::InvalidNavigation(
"navigation link cost must be finite and greater than zero".into(),
));
}
}
if component_count != 1 {
return Err(ActorValidationError::InvalidNavigation(
"navigation actors must own exactly one bounds, obstacle, area, or link component"
.into(),
));
}
Ok(())
}
fn validate_vec3_positive(value: Vec3, label: &str) -> Result<(), ActorValidationError> {
if !value.is_finite() || value.min_element() <= 0.0 {
return Err(ActorValidationError::InvalidNavigation(format!(
"{label} half extents must be finite and greater than zero"
)));
}
Ok(())
}
fn validate_audio_source(source: &AudioSourceDesc) -> Result<(), ActorValidationError> {
let Some(clip) = source.clip.as_ref() else {
return Err(ActorValidationError::AudioSourceMissingClip);
@ -423,7 +558,7 @@ mod tests {
}
#[test]
fn validate_static_mesh_rejects_light() {
fn validate_static_mesh_composes_with_light_and_uses_derived_hint() {
let mut world = World::new();
let e = level_entity(
&mut world,
@ -433,9 +568,10 @@ mod tests {
LightDesc::default(),
),
);
assert!(validate_actor(world.entity(e)).is_ok());
assert_eq!(
validate_actor(world.entity(e)),
Err(ActorValidationError::StaticMeshHasLight)
infer_actor_kind(world.entity(e)),
Some(ActorKind::StaticMesh)
);
}
@ -459,7 +595,7 @@ mod tests {
);
assert_eq!(
validate_actor(world.entity(e)),
Err(ActorValidationError::BrushHasStaticMeshRenderer)
Err(ActorValidationError::ConflictingGeometrySources)
);
}
@ -587,20 +723,17 @@ mod tests {
}
#[test]
fn animation_controller_requires_model_and_resolved_skeleton() {
fn animation_controller_requires_skinned_renderer_and_resolved_skeleton() {
let mut world = World::new();
let entity = level_entity(
&mut world,
(ActorKind::ImportedModel, animation_controller()),
);
let entity = level_entity(&mut world, (ActorKind::SkinnedMesh, animation_controller()));
assert_eq!(
validate_actor(world.entity(entity)),
Err(ActorValidationError::AnimationControllerMissingModelRef)
Err(ActorValidationError::AnimationControllerMissingSkinnedMeshRenderer)
);
world
.entity_mut(entity)
.insert(ModelRef::new("assets/models/animated.glb"));
.insert(SkinnedMeshRenderer::new("assets/models/animated.glb"));
let mut controller = animation_controller();
controller.skeleton = None;
world.entity_mut(entity).insert(controller);
@ -627,8 +760,8 @@ mod tests {
let entity = level_entity(
&mut world,
(
ActorKind::ImportedModel,
ModelRef::new("assets/models/animated.glb"),
ActorKind::SkinnedMesh,
SkinnedMeshRenderer::new("assets/models/animated.glb"),
animation_controller(),
),
);
@ -666,8 +799,8 @@ mod tests {
let entity = level_entity(
&mut world,
(
ActorKind::ImportedModel,
ModelRef::new("assets/models/animated.glb"),
ActorKind::SkinnedMesh,
SkinnedMeshRenderer::new("assets/models/animated.glb"),
animation_controller(),
),
);
@ -715,4 +848,37 @@ mod tests {
Err(ActorValidationError::AnimationControllerUnknownDefaultState)
);
}
#[test]
fn navigation_bounds_validate_generated_path_and_unique_samples() {
let mut world = World::new();
let mut bounds = NavigationBounds::for_actor("bounds-main");
bounds.validation_samples.push(crate::NavigationPathSample {
id: "entry-to-exit".into(),
start: Vec3::new(-2.0, 0.0, 0.0),
end: Vec3::new(2.0, 0.0, 0.0),
enabled: true,
});
let entity = level_entity(&mut world, (ActorKind::Navigation, bounds.clone()));
assert!(validate_actor(world.entity(entity)).is_ok());
bounds
.validation_samples
.push(bounds.validation_samples[0].clone());
world.entity_mut(entity).insert(bounds.clone());
assert!(matches!(
validate_actor(world.entity(entity)),
Err(ActorValidationError::InvalidNavigation(message))
if message.contains("must be unique")
));
bounds.validation_samples.pop();
bounds.artifact_path = "assets/navigation/manual.nav.ron".into();
world.entity_mut(entity).insert(bounds);
assert!(matches!(
validate_actor(world.entity(entity)),
Err(ActorValidationError::InvalidNavigation(message))
if message.contains("assets/navigation/generated/")
));
}
}

View File

@ -5,10 +5,11 @@ use serde::{Deserialize, Serialize};
use crate::EditorAssetRef;
pub const ANIMATION_MANIFEST_SCHEMA_VERSION: u32 = 2;
pub const ANIMATION_MANIFEST_SCHEMA_VERSION: u32 = 3;
pub const ANIMATION_ARTIFACT_DIR: &str = "assets/animations/generated";
pub const ANIMATION_CLIP_SUB_ASSET_PREFIX: &str = "animation:clip:";
pub const ANIMATION_SKELETON_SUB_ASSET_PREFIX: &str = "animation:skeleton:";
pub const COMPONENT_SKINNED_MESH_RENDERER: &str = "shared::animation::SkinnedMeshRenderer";
pub const COMPONENT_ANIMATION_CONTROLLER_DESC: &str = "shared::animation::AnimationControllerDesc";
const DEFAULT_CROSSFADE_SECONDS: f32 = 0.2;
@ -75,6 +76,54 @@ impl AnimationSkeletonSignature {
}
}
/// Authoring renderer for rigged geometry that must retain its imported joint hierarchy.
///
/// Unlike [`crate::StaticMeshRenderer`], this component never flattens source primitives into
/// independent mesh slots. Hydration instantiates the selected model scene so Bevy can preserve
/// its `SkinnedMesh`, joint entities, inverse bind poses, and animation player bindings.
#[derive(Component, Reflect, Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[reflect(Component, Default, Debug, PartialEq, Serialize, Deserialize)]
pub struct SkinnedMeshRenderer {
/// Stable asset-registry UUID. Empty only for legacy or unresolved references.
#[serde(default)]
pub asset_id: String,
/// Asset path relative to `assets/`, e.g. `models/character.glb`.
pub path: String,
/// Scene index whose hierarchy owns the skinned primitives and joints.
#[serde(default)]
pub scene_index: usize,
/// Stable material slots for every draw binding in the imported animated hierarchy.
#[serde(default)]
pub materials: crate::RendererMaterialSet,
}
impl SkinnedMeshRenderer {
pub fn new(path: impl Into<String>) -> Self {
Self {
asset_id: String::new(),
path: path.into(),
scene_index: 0,
materials: crate::RendererMaterialSet::default(),
}
}
pub fn with_asset_id(mut self, asset_id: impl Into<String>) -> Self {
self.asset_id = asset_id.into();
self
}
/// Asset-server path for FBX `#SceneN` labels (see `bevy_ufbx`).
pub fn fbx_scene_asset_path(path: &str, scene_index: usize) -> String {
format!("{path}#Scene{scene_index}")
}
}
impl Default for SkinnedMeshRenderer {
fn default() -> Self {
Self::new(String::new())
}
}
/// Authored playback window inside an imported clip.
#[derive(Reflect, Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
#[reflect(Default, Debug, PartialEq, Serialize, Deserialize)]
@ -135,7 +184,7 @@ impl Default for AnimationStateDesc {
}
}
/// Scene-authored single-layer animation controller.
/// Scene-authored single-layer animation controller for a sibling [`SkinnedMeshRenderer`].
///
/// Bevy graph, player, transition, and instantiated-world state are derived at runtime.
#[derive(Component, Reflect, Debug, Clone, PartialEq, Serialize, Deserialize)]
@ -191,6 +240,12 @@ pub struct AnimationManifest {
pub schema_version: u32,
pub asset_id: String,
pub label: String,
/// Explicit imported clip shown as the asset's edit-mode rest presentation.
///
/// This is a stable clip sub-asset ID. `None` deliberately preserves the imported node pose;
/// importers and placement code must never guess the first clip.
#[serde(default)]
pub default_animation_clip_id: Option<String>,
pub source: AnimationManifestSource,
/// Whether this source format can hydrate the extracted animation data at runtime.
pub runtime_supported: bool,
@ -315,4 +370,26 @@ mod tests {
assert_eq!(animation_skeleton_source_index(&skeleton), Some(3));
assert_eq!(animation_clip_source_index(&skeleton), None);
}
#[test]
fn schema_v2_manifest_defaults_to_imported_rest_pose() {
let legacy = r#"(
schema_version: 2,
asset_id: "model-id",
label: "Legacy",
source: (
path: "assets/models/legacy.glb",
format: "glb",
fingerprint: (byte_len: 1, modified_unix_secs: 0, content_hash: "hash"),
dependencies: [],
),
runtime_supported: true,
skeletons: [],
clips: [],
diagnostics: [],
)"#;
let manifest: AnimationManifest = ron::from_str(legacy).unwrap();
assert!(manifest.default_animation_clip_id.is_none());
}
}

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