Add editor sample regression pack

This commit is contained in:
Rbanh 2026-07-13 01:52:58 -04:00
parent c55f34780f
commit 06010c5922
31 changed files with 3258 additions and 110 deletions

View File

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

View File

@ -0,0 +1,66 @@
# Editor Sample Regression Pack
Date: 2026-07-13
Issue: BS-JD-501 / Gitea #32
Milestone: M5 - Regression, docs, and first-hour UX
## Goal
Ship a deterministic five-scene editor regression pack that is easy to open from the editor and
that fails headless validation when its catalog, authored data, or referenced assets drift. The pack
must cover Brush, Material, Terrain, Physics Placement, and Rendering workflows.
## Decisions
- `assets/samples/editor_samples.ron` is the conventional, versioned pack manifest. This remains a
QA/editor convention and does not extend `ProjectSettings`.
- The manifest is parsed and validated in the `scene` crate. Editor UI and headless tooling consume
that one representation.
- Every entry has a stable ID, label, area, project-relative `.scn.ron` path, summary, and nonempty
operator checks. Manifest order is menu and report order.
- Exactly one entry for each required area is release-gated. Scene paths must remain regular files
inside `assets/levels/`; traversal, absolute paths, symlink escapes, duplicates, and future schemas
are blocking.
- Existing accepted terrain, physics-placement, and rendering fixtures remain canonical entries.
Add dedicated brush and material scenes under `assets/levels/samples/` and strengthen weak visual
anchors in the existing fixtures instead of duplicating them.
- `cargo validate-samples --project .` combines pack-specific structure/feature checks with the
authoritative `validate_project` report. It verifies stable actor IDs, required area components,
authoring-only scene documents, and referenced content.
- Terrain descriptors and their shared material layers become first-class project-validation input.
Actor-owned invalid descriptors or missing material references are blocking findings.
- `File > Open Sample` is backed by a cached project catalog and opens through normal scene-tab I/O.
Invalid or unavailable catalogs produce a disabled, actionable row rather than a panic.
- CI hydrates Git LFS before content validation and runs both validation commands. Packaged-runtime
tests remain deferred by project-owner request.
## Implementation
1. Add the manifest schema, deterministic report, safe path resolver, and sample feature checks to
`scene::sample_pack`, with malformed/missing/duplicate/path-escape fixture tests.
2. Add typed `TerrainDesc` validation and base/layer material dependency collection to authoritative
project validation, including owner-attributed tests.
3. Add the `validate-samples` xtask binary, Cargo alias, JSON mode, stable console summary, and CI
steps. Enable LFS hydration at checkout and verify hydrated objects.
4. Add the five-entry manifest, `brush_blockout.scn.ron`, and `material_lab.scn.ron`. Give every
sample actor a stable `ActorId`; add visible rendering anchors and distinct physics shapes where
the current fixtures are ambiguous.
5. Load the catalog after project startup, render `File > Open Sample` in manifest order, and route a
selected stable ID through scene I/O. Add unknown-entry and deterministic-row tests.
6. Add a no-GPU typed-deserialization test over every manifest scene so unknown Bevy component
registrations fail before native QA.
7. Document pack contracts, per-scene checks, release commands, and the editor workflow. Update ADR
0028, the root README checklist/controls, editor/docs indexes, feature guides, roadmap status,
debt audit, and production-readiness evidence.
8. Run source gates, open all five samples through the native menu, exercise one representative tool
interaction per area, inspect logs, and attach representative native issue images when useful.
## Acceptance
- Five manifest entries cover all required areas and open through `File > Open Sample`.
- Every entry type-deserializes and has stable actor IDs plus its required authored feature.
- `validate-levels` reports terrain material dependencies and has no blocking findings.
- `validate-samples` is deterministic, CI-backed, and exits nonzero for pack or project blockers.
- Native viewport captures are nonblank, correctly framed, and free of missing-reference, hydration,
render, or operator errors.
- Documentation gives contributors a single release-use workflow and explains every sample check.

View File

@ -17,6 +17,11 @@ jobs:
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
with:
lfs: true
- name: Verify Git LFS checkout
run: git lfs fsck
- name: Install Linux dependencies - name: Install Linux dependencies
run: | run: |
@ -55,6 +60,9 @@ jobs:
- name: Validate level scenes - name: Validate level scenes
run: cargo validate-levels run: cargo validate-levels
- name: Validate editor sample pack
run: cargo validate-samples
- name: Clippy launch feature matrix - name: Clippy launch feature matrix
run: cargo clippy -p editor -p game --bins --features dev,hot-reload -- -D warnings run: cargo clippy -p editor -p game --bins --features dev,hot-reload -- -D warnings

View File

@ -27,6 +27,7 @@ cargo clippy --workspace --all-targets -- -D warnings
cargo clippy -p editor -p game --bins --features dev,hot-reload -- -D warnings cargo clippy -p editor -p game --bins --features dev,hot-reload -- -D warnings
cargo test --workspace cargo test --workspace
cargo validate-levels cargo validate-levels
cargo validate-samples
cargo bake-navigation --project . --check cargo bake-navigation --project . --check
# Validate one artifact without opening a game window # Validate one artifact without opening a game window
cargo run -p game -- --validate-navigation assets/navigation/generated/navigation_showcase_humanoid.nav.ron cargo run -p game -- --validate-navigation assets/navigation/generated/navigation_showcase_humanoid.nav.ron
@ -164,7 +165,7 @@ deep-stale variants.
| Drag actor between rows / onto Scene Root (Hierarchy, Manual sort) | Reorder siblings / unparent to the root | | Drag actor between rows / onto Scene Root (Hierarchy, Manual sort) | Reorder siblings / unparent to the root |
| Hierarchy lock | Excludes the actor from selection, gizmos, multi-drag, structural drops, and mutating context actions | | Hierarchy 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 | | 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, non-blocking native Open/Save As, recovery restore/keep-copy/discard when available, Import Assets, Export Selection, Save Selection As Prefab (including linked-root variants), Recent Scenes | | File menu | New, non-blocking native Open/Save As, **Open Sample** for the five-area regression pack, 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 | | 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 | | 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 | | Main toolbar, right side | Switch or close independent scene tabs, create an untitled tab, and manage loaded/locked composed subscenes |
@ -417,6 +418,7 @@ crates/
- [x] Cursor/VSCode workspace tasks + launch configs - [x] Cursor/VSCode workspace tasks + launch configs
- [x] Opaque Wayland window launch defaults + SDR/HDR runtime toggle - [x] Opaque Wayland window launch defaults + SDR/HDR runtime toggle
- [x] CI workflow for format, check, clippy, tests, and binary builds - [x] CI workflow for format, check, clippy, tests, and binary builds
- [x] Deterministic five-area editor sample regression pack with stable actor IDs, **File > Open Sample**, typed scene checks, headless validation, and committed brush/material/terrain/physics/rendering fixtures ([sample guide](docs/editor/sample-regression-pack.md), [Gitea #32](https://git.spacetrainclubhouse.com/Falling-Metal-Interactive/Blacksite/issues/32))
- [x] ADRs for roadmap architecture and Bevy migration policy - [x] ADRs for roadmap architecture and Bevy migration policy
- [x] Determinism harness: same inputs over same ticks produce the same state summary/hash - [x] Determinism harness: same inputs over same ticks produce the same state summary/hash
- [x] Production operator invariants across palette dispatch, assets/material drops, brush/terrain/physics modal tools, grouping/lighting, and the transform finalizer: stable terminal status, exact cancel/failure rollback, helper cleanup, grouped history, and repeated undo/redo projections ([testing contract](docs/editor/operator-regression-testing.md), [evaluation](docs/editor/evaluations/operator-invariants/), [Gitea #33](https://git.spacetrainclubhouse.com/Falling-Metal-Interactive/Blacksite/issues/33)) - [x] Production operator invariants across palette dispatch, assets/material drops, brush/terrain/physics modal tools, grouping/lighting, and the transform finalizer: stable terminal status, exact cancel/failure rollback, helper cleanup, grouped history, and repeated undo/redo projections ([testing contract](docs/editor/operator-regression-testing.md), [evaluation](docs/editor/evaluations/operator-invariants/), [Gitea #33](https://git.spacetrainclubhouse.com/Falling-Metal-Interactive/Blacksite/issues/33))

View File

@ -35,7 +35,7 @@
), ),
}), }),
2: (components: { 2: (components: {
"bevy_ecs::name::Name": "Placement Prop A", "bevy_ecs::name::Name": "Placement Cuboid",
"bevy_transform::components::transform::Transform": ( "bevy_transform::components::transform::Transform": (
translation: (-2.0, 3.0, 0.0), translation: (-2.0, 3.0, 0.0),
rotation: (0.1305262, 0.0, 0.0, 0.9914449), rotation: (0.1305262, 0.0, 0.0, 0.9914449),
@ -70,7 +70,7 @@
), ),
}), }),
3: (components: { 3: (components: {
"bevy_ecs::name::Name": "Placement Prop B", "bevy_ecs::name::Name": "Placement Sphere",
"bevy_transform::components::transform::Transform": ( "bevy_transform::components::transform::Transform": (
translation: (0.0, 5.0, 0.0), translation: (0.0, 5.0, 0.0),
rotation: (0.0, 0.2164396, 0.0, 0.976296), rotation: (0.0, 0.2164396, 0.0, 0.976296),
@ -96,16 +96,16 @@
parameters: [], parameters: [],
textures: [], textures: [],
), ),
"shared::components::Primitive": (shape: Box, size: (1.0, 1.6, 1.0)), "shared::components::Primitive": (shape: Sphere, size: (1.6, 1.6, 1.6)),
"shared::components::RigidBodyDesc": (body: Static), "shared::components::RigidBodyDesc": (body: Static),
"shared::components::ColliderDesc": ( "shared::components::ColliderDesc": (
enabled: true, enabled: true,
is_trigger: false, is_trigger: false,
shape: Cuboid(x_length: 1.0, y_length: 1.6, z_length: 1.0), shape: Sphere(radius: 0.8),
), ),
}), }),
4: (components: { 4: (components: {
"bevy_ecs::name::Name": "Placement Prop C", "bevy_ecs::name::Name": "Placement Capsule",
"bevy_transform::components::transform::Transform": ( "bevy_transform::components::transform::Transform": (
translation: (2.0, 7.0, 0.0), translation: (2.0, 7.0, 0.0),
rotation: (0.092296, 0.092296, -0.008077, 0.991405), rotation: (0.092296, 0.092296, -0.008077, 0.991405),
@ -131,12 +131,12 @@
parameters: [], parameters: [],
textures: [], textures: [],
), ),
"shared::components::Primitive": (shape: Box, size: (1.4, 0.8, 1.0)), "shared::components::Primitive": (shape: Box, size: (0.9, 1.8, 0.9)),
"shared::components::RigidBodyDesc": (body: Static), "shared::components::RigidBodyDesc": (body: Static),
"shared::components::ColliderDesc": ( "shared::components::ColliderDesc": (
enabled: true, enabled: true,
is_trigger: false, is_trigger: false,
shape: Cuboid(x_length: 1.4, y_length: 0.8, z_length: 1.0), shape: Capsule(radius: 0.45, height: 1.8),
), ),
}), }),
5: (components: { 5: (components: {

View File

@ -1,14 +1,160 @@
(schema_version: 4,resources: { (schema_version: 4, resources: {}, entities: {
}, 1: (components: {
entities: { "bevy_ecs::name::Name": "Rendering Lab Floor",
4294968001: (components: { "bevy_transform::components::transform::Transform": (
translation: (1.0, -0.25, 0.0),
rotation: (0.0, 0.0, 0.0, 1.0),
scale: (1.0, 1.0, 1.0),
),
"shared::components::ActorId": ("rendering-showcase-floor"),
"shared::components::ActorKind": StaticMesh,
"shared::components::EditorVisibility": (visible: true),
"shared::components::HierarchySiblingIndex": (0),
"shared::components::LevelObject": (),
"shared::components::Primitive": (shape: Box, size: (38.0, 0.5, 12.0)),
"shared::components::MaterialDesc": (
shader: (kind: StandardLit, schema_path: None, shader_path: None),
base_color: (r: 0.12, g: 0.14, b: 0.16, a: 1.0),
metallic: 0.05,
roughness: 0.72,
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: [],
),
}),
2: (components: {
"bevy_ecs::name::Name": "Vignette Anchor",
"bevy_transform::components::transform::Transform": (
translation: (-12.0, 1.2, 0.0),
rotation: (0.0, 0.0, 0.0, 1.0),
scale: (1.0, 1.0, 1.0),
),
"shared::components::ActorId": ("rendering-showcase-vignette-anchor"),
"shared::components::ActorKind": StaticMesh,
"shared::components::EditorVisibility": (visible: true),
"shared::components::HierarchySiblingIndex": (1),
"shared::components::LevelObject": (),
"shared::components::Primitive": (shape: Sphere, size: (2.4, 2.4, 2.4)),
"shared::components::MaterialDesc": (
shader: (kind: StandardLit, schema_path: None, shader_path: None),
base_color: (r: 0.82, g: 0.18, b: 0.2, a: 1.0),
metallic: 0.08,
roughness: 0.38,
emissive_color: (r: 1.0, g: 0.2, b: 0.2, 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: [],
),
}),
3: (components: {
"bevy_ecs::name::Name": "Fog Anchor",
"bevy_transform::components::transform::Transform": (
translation: (0.0, 1.4, 0.0),
rotation: (0.0, 0.258819, 0.0, 0.9659258),
scale: (1.0, 1.0, 1.0),
),
"shared::components::ActorId": ("rendering-showcase-fog-anchor"),
"shared::components::ActorKind": StaticMesh,
"shared::components::EditorVisibility": (visible: true),
"shared::components::HierarchySiblingIndex": (2),
"shared::components::LevelObject": (),
"shared::components::Primitive": (shape: Box, size: (2.8, 2.8, 2.8)),
"shared::components::MaterialDesc": (
shader: (kind: StandardLit, schema_path: None, shader_path: None),
base_color: (r: 0.1, g: 0.58, b: 0.72, a: 1.0),
metallic: 0.15,
roughness: 0.35,
emissive_color: (r: 0.1, g: 0.58, b: 0.72, 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: [],
),
}),
4: (components: {
"bevy_ecs::name::Name": "Exposure Anchor",
"bevy_transform::components::transform::Transform": (
translation: (14.0, 1.3, 0.0),
rotation: (0.0, -0.1736482, 0.0, 0.9848078),
scale: (1.0, 1.0, 1.0),
),
"shared::components::ActorId": ("rendering-showcase-exposure-anchor"),
"shared::components::ActorKind": StaticMesh,
"shared::components::EditorVisibility": (visible: true),
"shared::components::HierarchySiblingIndex": (3),
"shared::components::LevelObject": (),
"shared::components::Primitive": (shape: Box, size: (3.0, 2.6, 3.0)),
"shared::components::MaterialDesc": (
shader: (kind: StandardLit, schema_path: None, shader_path: None),
base_color: (r: 0.92, g: 0.62, b: 0.12, a: 1.0),
metallic: 0.12,
roughness: 0.32,
emissive_color: (r: 1.0, g: 0.65, b: 0.12, 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: [],
),
}),
5: (components: {
"bevy_ecs::name::Name": "Emissive Comparison Panel",
"bevy_transform::components::transform::Transform": (
translation: (0.0, 2.6, -4.8),
rotation: (0.0, 0.0, 0.0, 1.0),
scale: (1.0, 1.0, 1.0),
),
"shared::components::ActorId": ("rendering-showcase-emissive-panel"),
"shared::components::ActorKind": StaticMesh,
"shared::components::EditorVisibility": (visible: true),
"shared::components::HierarchySiblingIndex": (4),
"shared::components::LevelObject": (),
"shared::components::Primitive": (shape: Box, size: (26.0, 1.2, 0.25)),
"shared::components::MaterialDesc": (
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_intensity: 2500.0,
base_color_texture: None,
emissive_texture: None,
normal_map_texture: None,
metallic_roughness_texture: None,
material_asset_path: Some("assets/materials/emissive_panel.ron"),
parameters: [],
textures: [],
),
}),
6: (components: {
"bevy_ecs::name::Name": "Fog Volume", "bevy_ecs::name::Name": "Fog Volume",
"bevy_transform::components::transform::Transform": ( "bevy_transform::components::transform::Transform": (
translation: (0.0, 2.0, 0.0), translation: (0.0, 2.0, 0.0),
rotation: (0.0, 0.0, 0.0, 1.0), rotation: (0.0, 0.0, 0.0, 1.0),
scale: (1.0, 1.0, 1.0), scale: (1.0, 1.0, 1.0),
), ),
"shared::components::ActorId": ("rendering-showcase-fog-volume"),
"shared::components::ActorKind": PostProcessVolume, "shared::components::ActorKind": PostProcessVolume,
"shared::components::EditorVisibility": (visible: true),
"shared::components::HierarchySiblingIndex": (5),
"shared::components::LevelObject": (), "shared::components::LevelObject": (),
"shared::components::PostProcessVolumeDesc": ( "shared::components::PostProcessVolumeDesc": (
half_extents: (6.0, 3.0, 6.0), half_extents: (6.0, 3.0, 6.0),
@ -23,14 +169,17 @@
label: Some("Foggy courtyard"), label: Some("Foggy courtyard"),
), ),
}), }),
4294968002: (components: { 7: (components: {
"bevy_ecs::name::Name": "Dark Exposure Volume", "bevy_ecs::name::Name": "Dark Exposure Volume",
"bevy_transform::components::transform::Transform": ( "bevy_transform::components::transform::Transform": (
translation: (14.0, 2.0, 0.0), translation: (14.0, 2.0, 0.0),
rotation: (0.0, 0.0, 0.0, 1.0), rotation: (0.0, 0.0, 0.0, 1.0),
scale: (1.0, 1.0, 1.0), scale: (1.0, 1.0, 1.0),
), ),
"shared::components::ActorId": ("rendering-showcase-exposure-volume"),
"shared::components::ActorKind": PostProcessVolume, "shared::components::ActorKind": PostProcessVolume,
"shared::components::EditorVisibility": (visible: true),
"shared::components::HierarchySiblingIndex": (6),
"shared::components::LevelObject": (), "shared::components::LevelObject": (),
"shared::components::PostProcessVolumeDesc": ( "shared::components::PostProcessVolumeDesc": (
half_extents: (4.0, 2.5, 4.0), half_extents: (4.0, 2.5, 4.0),
@ -45,14 +194,17 @@
label: Some("Cave mouth"), label: Some("Cave mouth"),
), ),
}), }),
4294968003: (components: { 8: (components: {
"bevy_ecs::name::Name": "Vignette FX Volume", "bevy_ecs::name::Name": "Vignette FX Volume",
"bevy_transform::components::transform::Transform": ( "bevy_transform::components::transform::Transform": (
translation: (-12.0, 2.0, 0.0), translation: (-12.0, 2.0, 0.0),
rotation: (0.0, 0.0, 0.0, 1.0), rotation: (0.0, 0.0, 0.0, 1.0),
scale: (1.0, 1.0, 1.0), scale: (1.0, 1.0, 1.0),
), ),
"shared::components::ActorId": ("rendering-showcase-vignette-volume"),
"shared::components::ActorKind": PostProcessVolume, "shared::components::ActorKind": PostProcessVolume,
"shared::components::EditorVisibility": (visible: true),
"shared::components::HierarchySiblingIndex": (7),
"shared::components::LevelObject": (), "shared::components::LevelObject": (),
"shared::components::PostProcessVolumeDesc": ( "shared::components::PostProcessVolumeDesc": (
half_extents: (5.0, 2.0, 5.0), half_extents: (5.0, 2.0, 5.0),
@ -64,5 +216,26 @@
label: Some("Vignette demo"), label: Some("Vignette demo"),
), ),
}), }),
}, 9: (components: {
) "bevy_ecs::name::Name": "Rendering Lab Sun",
"bevy_transform::components::transform::Transform": (
translation: (0.0, 10.0, 5.0),
rotation: (-0.3826834, 0.0, 0.0, 0.9238795),
scale: (1.0, 1.0, 1.0),
),
"shared::components::ActorId": ("rendering-showcase-sun"),
"shared::components::ActorKind": Light,
"shared::components::EditorVisibility": (visible: true),
"shared::components::HierarchySiblingIndex": (8),
"shared::components::LevelObject": (),
"shared::components::LightDesc": (
kind: Directional,
color: (r: 1.0, g: 0.95, b: 0.85, a: 1.0),
intensity: 100000.0,
range: 0.0,
shadows: true,
inner_angle_deg: 25.0,
outer_angle_deg: 35.0,
),
}),
})

View File

@ -0,0 +1,332 @@
(schema_version: 4, resources: {}, entities: {
1: (components: {
"bevy_ecs::name::Name": "Foundation Brush",
"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": ("sample-brush-foundation"),
"shared::components::ActorKind": Brush,
"shared::components::EditorVisibility": (visible: true),
"shared::components::HierarchySiblingIndex": (0),
"shared::components::LevelObject": (),
"shared::components::MaterialDesc": (
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: Some("assets/materials/concrete.ron"),
parameters: [],
textures: [],
),
"shared::components::BrushDesc": (
kind: Additive,
faces: [
(
id: ("face:+x"),
plane: (normal: (1.0, 0.0, 0.0), distance: 5.0),
vertices: [(5.0, -0.5, -4.0), (5.0, 0.5, -4.0), (5.0, 0.5, 4.0), (5.0, -0.5, 4.0)],
material: None,
texture: None,
uv_offset: (0.0, 0.0),
uv_scale: (1.0, 1.0),
uv_rotation: 0.0,
smoothing_group: 0,
),
(
id: ("face:-x"),
plane: (normal: (-1.0, 0.0, 0.0), distance: 5.0),
vertices: [(-5.0, -0.5, 4.0), (-5.0, 0.5, 4.0), (-5.0, 0.5, -4.0), (-5.0, -0.5, -4.0)],
material: None,
texture: None,
uv_offset: (0.0, 0.0),
uv_scale: (1.0, 1.0),
uv_rotation: 0.0,
smoothing_group: 0,
),
(
id: ("face:+y"),
plane: (normal: (0.0, 1.0, 0.0), distance: 0.5),
vertices: [(-5.0, 0.5, -4.0), (-5.0, 0.5, 4.0), (5.0, 0.5, 4.0), (5.0, 0.5, -4.0)],
material: None,
texture: None,
uv_offset: (0.0, 0.0),
uv_scale: (1.0, 1.0),
uv_rotation: 0.0,
smoothing_group: 0,
),
(
id: ("face:-y"),
plane: (normal: (0.0, -1.0, 0.0), distance: 0.5),
vertices: [(-5.0, -0.5, 4.0), (-5.0, -0.5, -4.0), (5.0, -0.5, -4.0), (5.0, -0.5, 4.0)],
material: None,
texture: None,
uv_offset: (0.0, 0.0),
uv_scale: (1.0, 1.0),
uv_rotation: 0.0,
smoothing_group: 0,
),
(
id: ("face:+z"),
plane: (normal: (0.0, 0.0, 1.0), distance: 4.0),
vertices: [(5.0, -0.5, 4.0), (5.0, 0.5, 4.0), (-5.0, 0.5, 4.0), (-5.0, -0.5, 4.0)],
material: None,
texture: None,
uv_offset: (0.0, 0.0),
uv_scale: (1.0, 1.0),
uv_rotation: 0.0,
smoothing_group: 0,
),
(
id: ("face:-z"),
plane: (normal: (0.0, 0.0, -1.0), distance: 4.0),
vertices: [(-5.0, -0.5, -4.0), (-5.0, 0.5, -4.0), (5.0, 0.5, -4.0), (5.0, -0.5, -4.0)],
material: None,
texture: None,
uv_offset: (0.0, 0.0),
uv_scale: (1.0, 1.0),
uv_rotation: 0.0,
smoothing_group: 0,
),
],
cast_shadows: true,
receive_shadows: true,
),
}),
2: (components: {
"bevy_ecs::name::Name": "Material Face Tower",
"bevy_transform::components::transform::Transform": (
translation: (-2.5, 3.0, 0.0),
rotation: (0.0, 0.0, 0.0, 1.0),
scale: (1.0, 1.0, 1.0),
),
"shared::components::ActorId": ("sample-brush-material-tower"),
"shared::components::ActorKind": Brush,
"shared::components::EditorVisibility": (visible: true),
"shared::components::HierarchySiblingIndex": (1),
"shared::components::LevelObject": (),
"shared::components::MaterialDesc": (
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: Some("assets/materials/concrete.ron"),
parameters: [],
textures: [],
),
"shared::components::BrushDesc": (
kind: Additive,
faces: [
(
id: ("face:+x"),
plane: (normal: (1.0, 0.0, 0.0), distance: 1.25),
vertices: [(1.25, -2.0, -1.25), (1.25, 2.0, -1.25), (1.25, 2.0, 1.25), (1.25, -2.0, 1.25)],
material: None,
texture: None,
uv_offset: (0.0, 0.0),
uv_scale: (1.0, 1.0),
uv_rotation: 0.0,
smoothing_group: 0,
),
(
id: ("face:-x"),
plane: (normal: (-1.0, 0.0, 0.0), distance: 1.25),
vertices: [(-1.25, -2.0, 1.25), (-1.25, 2.0, 1.25), (-1.25, 2.0, -1.25), (-1.25, -2.0, -1.25)],
material: None,
texture: None,
uv_offset: (0.0, 0.0),
uv_scale: (1.0, 1.0),
uv_rotation: 0.0,
smoothing_group: 0,
),
(
id: ("face:+y"),
plane: (normal: (0.0, 1.0, 0.0), distance: 2.0),
vertices: [(-1.25, 2.0, -1.25), (-1.25, 2.0, 1.25), (1.25, 2.0, 1.25), (1.25, 2.0, -1.25)],
material: Some((
asset_id: "cc2769da-c9a5-4cb3-866a-cc81d0688ee2",
sub_asset_id: "material:instance",
label: "surface_tint_instance",
source_path: Some("assets/materials/surface_tint_instance.ron"),
)),
texture: None,
uv_offset: (0.0, 0.0),
uv_scale: (1.0, 1.0),
uv_rotation: 0.0,
smoothing_group: 0,
),
(
id: ("face:-y"),
plane: (normal: (0.0, -1.0, 0.0), distance: 2.0),
vertices: [(-1.25, -2.0, 1.25), (-1.25, -2.0, -1.25), (1.25, -2.0, -1.25), (1.25, -2.0, 1.25)],
material: None,
texture: None,
uv_offset: (0.0, 0.0),
uv_scale: (1.0, 1.0),
uv_rotation: 0.0,
smoothing_group: 0,
),
(
id: ("face:+z"),
plane: (normal: (0.0, 0.0, 1.0), distance: 1.25),
vertices: [(1.25, -2.0, 1.25), (1.25, 2.0, 1.25), (-1.25, 2.0, 1.25), (-1.25, -2.0, 1.25)],
material: None,
texture: None,
uv_offset: (0.0, 0.0),
uv_scale: (1.0, 1.0),
uv_rotation: 0.0,
smoothing_group: 0,
),
(
id: ("face:-z"),
plane: (normal: (0.0, 0.0, -1.0), distance: 1.25),
vertices: [(-1.25, -2.0, -1.25), (-1.25, 2.0, -1.25), (1.25, 2.0, -1.25), (1.25, -2.0, -1.25)],
material: None,
texture: None,
uv_offset: (0.0, 0.0),
uv_scale: (1.0, 1.0),
uv_rotation: 0.0,
smoothing_group: 0,
),
],
cast_shadows: true,
receive_shadows: true,
),
}),
3: (components: {
"bevy_ecs::name::Name": "Subtractive Marker",
"bevy_transform::components::transform::Transform": (
translation: (2.5, 2.0, 0.0),
rotation: (0.0, 0.258819, 0.0, 0.9659258),
scale: (1.0, 1.0, 1.0),
),
"shared::components::ActorId": ("sample-brush-subtractive-marker"),
"shared::components::ActorKind": Brush,
"shared::components::EditorVisibility": (visible: true),
"shared::components::HierarchySiblingIndex": (2),
"shared::components::LevelObject": (),
"shared::components::MaterialDesc": (
shader: (kind: StandardLit, schema_path: None, shader_path: None),
base_color: (r: 0.72, g: 0.14, b: 0.16, a: 1.0),
metallic: 0.05,
roughness: 0.4,
emissive_color: (r: 1.0, g: 0.25, b: 0.2, 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: [],
),
"shared::components::BrushDesc": (
kind: SubtractiveMarker,
faces: [
(
id: ("face:+x"),
plane: (normal: (1.0, 0.0, 0.0), distance: 1.0),
vertices: [(1.0, -1.0, -1.0), (1.0, 1.0, -1.0), (1.0, 1.0, 1.0), (1.0, -1.0, 1.0)],
material: None,
texture: None,
uv_offset: (0.0, 0.0),
uv_scale: (1.0, 1.0),
uv_rotation: 0.0,
smoothing_group: 0,
),
(
id: ("face:-x"),
plane: (normal: (-1.0, 0.0, 0.0), distance: 1.0),
vertices: [(-1.0, -1.0, 1.0), (-1.0, 1.0, 1.0), (-1.0, 1.0, -1.0), (-1.0, -1.0, -1.0)],
material: None,
texture: None,
uv_offset: (0.0, 0.0),
uv_scale: (1.0, 1.0),
uv_rotation: 0.0,
smoothing_group: 0,
),
(
id: ("face:+y"),
plane: (normal: (0.0, 1.0, 0.0), distance: 1.0),
vertices: [(-1.0, 1.0, -1.0), (-1.0, 1.0, 1.0), (1.0, 1.0, 1.0), (1.0, 1.0, -1.0)],
material: None,
texture: None,
uv_offset: (0.0, 0.0),
uv_scale: (1.0, 1.0),
uv_rotation: 0.0,
smoothing_group: 0,
),
(
id: ("face:-y"),
plane: (normal: (0.0, -1.0, 0.0), distance: 1.0),
vertices: [(-1.0, -1.0, 1.0), (-1.0, -1.0, -1.0), (1.0, -1.0, -1.0), (1.0, -1.0, 1.0)],
material: None,
texture: None,
uv_offset: (0.0, 0.0),
uv_scale: (1.0, 1.0),
uv_rotation: 0.0,
smoothing_group: 0,
),
(
id: ("face:+z"),
plane: (normal: (0.0, 0.0, 1.0), distance: 1.0),
vertices: [(1.0, -1.0, 1.0), (1.0, 1.0, 1.0), (-1.0, 1.0, 1.0), (-1.0, -1.0, 1.0)],
material: None,
texture: None,
uv_offset: (0.0, 0.0),
uv_scale: (1.0, 1.0),
uv_rotation: 0.0,
smoothing_group: 0,
),
(
id: ("face:-z"),
plane: (normal: (0.0, 0.0, -1.0), distance: 1.0),
vertices: [(-1.0, -1.0, -1.0), (-1.0, 1.0, -1.0), (1.0, 1.0, -1.0), (1.0, -1.0, -1.0)],
material: None,
texture: None,
uv_offset: (0.0, 0.0),
uv_scale: (1.0, 1.0),
uv_rotation: 0.0,
smoothing_group: 0,
),
],
cast_shadows: true,
receive_shadows: true,
),
}),
4: (components: {
"bevy_ecs::name::Name": "Brush Lab Sun",
"bevy_transform::components::transform::Transform": (
translation: (0.0, 8.0, 4.0),
rotation: (-0.3826834, 0.0, 0.0, 0.9238795),
scale: (1.0, 1.0, 1.0),
),
"shared::components::ActorId": ("sample-brush-sun"),
"shared::components::ActorKind": Light,
"shared::components::EditorVisibility": (visible: true),
"shared::components::HierarchySiblingIndex": (3),
"shared::components::LevelObject": (),
"shared::components::LightDesc": (
kind: Directional,
color: (r: 1.0, g: 0.95, b: 0.85, a: 1.0),
intensity: 100000.0,
range: 0.0,
shadows: true,
inner_angle_deg: 25.0,
outer_angle_deg: 35.0,
),
}),
})

View File

@ -0,0 +1,189 @@
(schema_version: 4, resources: {}, entities: {
1: (components: {
"bevy_ecs::name::Name": "Material Lab Floor",
"bevy_transform::components::transform::Transform": (
translation: (0.0, -0.2, 0.0),
rotation: (0.0, 0.0, 0.0, 1.0),
scale: (1.0, 1.0, 1.0),
),
"shared::components::ActorId": ("sample-material-floor"),
"shared::components::ActorKind": StaticMesh,
"shared::components::EditorVisibility": (visible: true),
"shared::components::HierarchySiblingIndex": (0),
"shared::components::LevelObject": (),
"shared::components::Primitive": (shape: Box, size: (14.0, 0.4, 8.0)),
"shared::components::MaterialDesc": (
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: Some("assets/materials/concrete.ron"),
parameters: [],
textures: [],
),
}),
2: (components: {
"bevy_ecs::name::Name": "Concrete Reference",
"bevy_transform::components::transform::Transform": (
translation: (-4.0, 1.0, 0.0),
rotation: (0.0, 0.2164396, 0.0, 0.976296),
scale: (1.0, 1.0, 1.0),
),
"shared::components::ActorId": ("sample-material-concrete"),
"shared::components::ActorKind": StaticMesh,
"shared::components::EditorVisibility": (visible: true),
"shared::components::HierarchySiblingIndex": (1),
"shared::components::LevelObject": (),
"shared::components::Primitive": (shape: Box, size: (2.0, 2.0, 2.0)),
"shared::components::MaterialDesc": (
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: Some("assets/materials/concrete.ron"),
parameters: [],
textures: [],
),
}),
3: (components: {
"bevy_ecs::name::Name": "Custom Surface Reference",
"bevy_transform::components::transform::Transform": (
translation: (0.0, 1.2, 0.0),
rotation: (0.0, 0.0, 0.0, 1.0),
scale: (1.0, 1.0, 1.0),
),
"shared::components::ActorId": ("sample-material-custom-surface"),
"shared::components::ActorKind": StaticMesh,
"shared::components::EditorVisibility": (visible: true),
"shared::components::HierarchySiblingIndex": (2),
"shared::components::LevelObject": (),
"shared::components::Primitive": (shape: Sphere, size: (2.4, 2.4, 2.4)),
"shared::components::MaterialDesc": (
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: Some("assets/materials/surface_tint.ron"),
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: [],
),
}),
4: (components: {
"bevy_ecs::name::Name": "Material Instance Reference",
"bevy_transform::components::transform::Transform": (
translation: (4.0, 1.0, 0.0),
rotation: (0.0, -0.1305262, 0.0, 0.9914449),
scale: (1.0, 1.0, 1.0),
),
"shared::components::ActorId": ("sample-material-instance"),
"shared::components::ActorKind": StaticMesh,
"shared::components::EditorVisibility": (visible: true),
"shared::components::HierarchySiblingIndex": (3),
"shared::components::LevelObject": (),
"shared::components::Primitive": (shape: Box, size: (2.6, 2.0, 2.6)),
"shared::components::MaterialDesc": (
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: Some("assets/materials/surface_tint_instance.ron"),
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: [],
),
}),
5: (components: {
"bevy_ecs::name::Name": "Emissive Reference Panel",
"bevy_transform::components::transform::Transform": (
translation: (0.0, 2.3, -3.6),
rotation: (0.0, 0.0, 0.0, 1.0),
scale: (1.0, 1.0, 1.0),
),
"shared::components::ActorId": ("sample-material-emissive-panel"),
"shared::components::ActorKind": StaticMesh,
"shared::components::EditorVisibility": (visible: true),
"shared::components::HierarchySiblingIndex": (4),
"shared::components::LevelObject": (),
"shared::components::Primitive": (shape: Box, size: (9.0, 3.0, 0.25)),
"shared::components::MaterialDesc": (
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_intensity: 2500.0,
base_color_texture: None,
emissive_texture: None,
normal_map_texture: None,
metallic_roughness_texture: None,
material_asset_path: Some("assets/materials/emissive_panel.ron"),
parameters: [],
textures: [],
),
}),
6: (components: {
"bevy_ecs::name::Name": "Material Lab Sun",
"bevy_transform::components::transform::Transform": (
translation: (0.0, 8.0, 4.0),
rotation: (-0.3826834, 0.0, 0.0, 0.9238795),
scale: (1.0, 1.0, 1.0),
),
"shared::components::ActorId": ("sample-material-sun"),
"shared::components::ActorKind": Light,
"shared::components::EditorVisibility": (visible: true),
"shared::components::HierarchySiblingIndex": (5),
"shared::components::LevelObject": (),
"shared::components::LightDesc": (
kind: Directional,
color: (r: 1.0, g: 0.95, b: 0.85, a: 1.0),
intensity: 100000.0,
range: 0.0,
shadows: true,
inner_angle_deg: 25.0,
outer_angle_deg: 35.0,
),
}),
})

View File

@ -0,0 +1,65 @@
(
schema_version: 1,
samples: [
(
id: "brush-blockout",
label: "Brush Blockout",
area: Brush,
scene: "assets/levels/samples/brush_blockout.scn.ron",
summary: "Convex additive brushes, a subtractive marker, and a shared face material for brush editing and CSG regression checks.",
checks: [
"valid authored BrushDesc geometry",
"shared brush-face material resolution",
"object, face, clip, and CSG selection targets",
],
),
(
id: "material-lab",
label: "Material Lab",
area: Material,
scene: "assets/levels/samples/material_lab.scn.ron",
summary: "A compact look-development stage for shared materials, material instances, custom surfaces, and emissive output.",
checks: [
"Material and Material Instance dependency resolution",
"standard, custom, and emissive surface hydration",
"primitive material assignment and targeted drops",
],
),
(
id: "terrain-authoring",
label: "Terrain Authoring",
area: Terrain,
scene: "assets/levels/terrain_authoring_showcase.scn.ron",
summary: "A deterministic height grid with shared material layers, normalized paint weights, chunks, and collision.",
checks: [
"TerrainDesc schema and normalized layer weights",
"shared terrain material dependency resolution",
"sculpt, paint, chunk, and collider hydration",
],
),
(
id: "physics-placement",
label: "Physics Placement",
area: PhysicsPlacement,
scene: "assets/levels/physics_placement_showcase.scn.ron",
summary: "Three visually distinct collider cases above a static floor for transactional multi-object settling.",
checks: [
"cuboid, sphere, and capsule collider diversity",
"multi-selection settle and grouped history",
"static floor collision and cancel restoration",
],
),
(
id: "rendering-lab",
label: "Rendering Lab",
area: Rendering,
scene: "assets/levels/rendering_showcase.scn.ron",
summary: "Visible comparison anchors for fog, exposure, rendering profiles, fullscreen effects, and emissive lighting.",
checks: [
"stable post-process volume ownership",
"rendering profile and fullscreen effect dependency resolution",
"visible fog, exposure, vignette, and emissive comparison anchors",
],
),
],
)

View File

@ -27,6 +27,7 @@ pub use project::diagnostics_bundle;
pub use project::launcher; pub use project::launcher;
pub use project::native_dialog; pub use project::native_dialog;
pub use project::project_io; pub use project::project_io;
pub use project::samples;
pub use project::session; pub use project::session;
pub use project::settings_ui; pub use project::settings_ui;
pub use scene::scene_io; pub use scene::scene_io;
@ -74,6 +75,7 @@ use play::audio_preview::AudioPreviewPlugin;
use play::PlaySessionPlugin; use play::PlaySessionPlugin;
use project::collaboration::CollaborationPlugin; use project::collaboration::CollaborationPlugin;
use project::native_dialog::NativeDialogPlugin; use project::native_dialog::NativeDialogPlugin;
use project::samples::SampleCatalogPlugin;
use project_io::ProjectIoPlugin; use project_io::ProjectIoPlugin;
use render_view::RenderViewPlugin; use render_view::RenderViewPlugin;
use scene_io::SceneIoPlugin; use scene_io::SceneIoPlugin;
@ -98,6 +100,7 @@ impl PluginGroup for EditorPluginGroup {
let group = PluginGroupBuilder::start::<Self>() let group = PluginGroupBuilder::start::<Self>()
.add(EditorInfraPlugin) .add(EditorInfraPlugin)
.add(ProjectIoPlugin) .add(ProjectIoPlugin)
.add(SampleCatalogPlugin)
.add(NativeDialogPlugin) .add(NativeDialogPlugin)
.add(scene_schema::SceneSchemaPlugin) .add(scene_schema::SceneSchemaPlugin)
.add(net_editor::NetEditorPlugin) .add(net_editor::NetEditorPlugin)

View File

@ -5,5 +5,6 @@ pub mod diagnostics_bundle;
pub mod launcher; pub mod launcher;
pub mod native_dialog; pub mod native_dialog;
pub mod project_io; pub mod project_io;
pub mod samples;
pub mod session; pub mod session;
pub mod settings_ui; pub mod settings_ui;

View File

@ -0,0 +1,208 @@
//! Cached project sample catalog used by editor scene-opening UI.
use std::path::{Path, PathBuf};
use bevy::prelude::*;
use scene::sample_pack::{
validate_editor_sample_pack, EditorSampleEntry, EDITOR_SAMPLE_MANIFEST_PATH,
};
use super::project_io::ProjectWorkspace;
const VALIDATION_HINT: &str = "Run `cargo validate-samples --project .` for details.";
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SampleCatalogEntry {
pub id: String,
pub label: String,
pub scene_path: PathBuf,
pub project_relative_scene: String,
pub summary: String,
pub checks: Vec<String>,
}
impl SampleCatalogEntry {
pub fn hover_text(&self) -> String {
let mut lines = vec![self.summary.clone()];
if !self.checks.is_empty() {
lines.push(format!("Checks: {}", self.checks.join(", ")));
}
lines.push(self.project_relative_scene.clone());
lines.join("\n")
}
}
#[derive(Resource, Debug, Clone)]
pub struct SampleCatalog {
entries: Vec<SampleCatalogEntry>,
unavailable_reason: Option<String>,
}
impl Default for SampleCatalog {
fn default() -> Self {
Self::unavailable("Sample catalog is loading")
}
}
impl SampleCatalog {
pub fn entries(&self) -> &[SampleCatalogEntry] {
&self.entries
}
pub fn entry(&self, stable_id: &str) -> Option<&SampleCatalogEntry> {
self.unavailable_reason
.is_none()
.then(|| self.entries.iter().find(|entry| entry.id == stable_id))
.flatten()
}
pub fn unavailable_reason(&self) -> Option<&str> {
self.unavailable_reason.as_deref()
}
#[cfg(test)]
pub(crate) fn available_entries(entries: Vec<SampleCatalogEntry>) -> Self {
Self {
entries,
unavailable_reason: None,
}
}
fn available(samples: Vec<EditorSampleEntry>) -> Self {
Self {
entries: samples.into_iter().map(catalog_entry).collect(),
unavailable_reason: None,
}
}
fn unavailable(reason: impl Into<String>) -> Self {
Self {
entries: Vec::new(),
unavailable_reason: Some(reason.into()),
}
}
}
fn catalog_entry(entry: EditorSampleEntry) -> SampleCatalogEntry {
SampleCatalogEntry {
id: entry.id,
label: entry.label,
scene_path: PathBuf::from(&entry.scene),
project_relative_scene: entry.scene,
summary: entry.summary,
checks: entry.checks,
}
}
fn load_catalog(project_root: &Path) -> SampleCatalog {
let validation = validate_editor_sample_pack(project_root);
if !validation.is_release_ready() {
let first_error = validation
.report
.findings
.iter()
.find(|finding| finding.severity == scene::ValidationSeverity::Error)
.map(|finding| finding.message.as_str())
.unwrap_or("sample pack validation failed");
return SampleCatalog::unavailable(format!(
"Sample manifest `{EDITOR_SAMPLE_MANIFEST_PATH}` is invalid: {first_error}\n{VALIDATION_HINT}"
));
}
if validation.samples.is_empty() {
return SampleCatalog::unavailable(format!(
"Sample manifest contains no scenes.\n{VALIDATION_HINT}"
));
}
SampleCatalog::available(validation.samples)
}
fn load_sample_catalog(world: &mut World) {
let project_root = PathBuf::from(&world.resource::<ProjectWorkspace>().root);
world.insert_resource(load_catalog(&project_root));
}
pub struct SampleCatalogPlugin;
impl Plugin for SampleCatalogPlugin {
fn build(&self, app: &mut App) {
app.init_resource::<SampleCatalog>()
.add_systems(Startup, load_sample_catalog);
}
}
#[cfg(test)]
mod tests {
use super::*;
use scene::sample_pack::EditorSampleArea;
fn sample(id: &str, label: &str, scene: &str) -> EditorSampleEntry {
EditorSampleEntry {
id: id.into(),
label: label.into(),
area: EditorSampleArea::Brush,
scene: scene.into(),
summary: format!("Summary for {label}"),
checks: vec!["selection".into(), "undo".into()],
}
}
#[test]
fn available_catalog_preserves_manifest_order_and_resolves_paths() {
let catalog = SampleCatalog::available(vec![
sample(
"terrain",
"Terrain",
"assets/levels/samples/terrain.scn.ron",
),
sample("brush", "Brush", "assets/levels/samples/brush.scn.ron"),
]);
assert_eq!(
catalog
.entries()
.iter()
.map(|entry| entry.id.as_str())
.collect::<Vec<_>>(),
vec!["terrain", "brush"]
);
assert_eq!(
catalog.entry("brush").unwrap().scene_path,
PathBuf::from("assets/levels/samples/brush.scn.ron")
);
let hover = catalog.entry("brush").unwrap().hover_text();
assert!(hover.contains("Summary for Brush"));
assert!(hover.contains("Checks: selection, undo"));
assert!(hover.contains("assets/levels/samples/brush.scn.ron"));
}
#[test]
fn unavailable_catalog_never_resolves_entries() {
let catalog = SampleCatalog {
entries: vec![catalog_entry(sample(
"brush",
"Brush",
"assets/levels/samples/brush.scn.ron",
))],
unavailable_reason: Some("manifest invalid".into()),
};
assert!(catalog.entry("brush").is_none());
assert_eq!(catalog.unavailable_reason(), Some("manifest invalid"));
}
#[test]
fn missing_manifest_reports_repair_command() {
let root = std::env::temp_dir().join(format!(
"blacksite-missing-sample-catalog-{}",
uuid::Uuid::new_v4()
));
std::fs::create_dir_all(&root).unwrap();
let catalog = load_catalog(&root);
let reason = catalog.unavailable_reason().unwrap();
assert!(reason.contains(EDITOR_SAMPLE_MANIFEST_PATH));
assert!(reason.contains("cargo validate-samples --project ."));
std::fs::remove_dir_all(root).unwrap();
}
}

View File

@ -25,6 +25,7 @@ use crate::assets::{import_external_assets, EditorAssets, IMPORTABLE_ASSET_EXTEN
use crate::history::{clear_level_objects, snapshot_entity, EditorHistory}; use crate::history::{clear_level_objects, snapshot_entity, EditorHistory};
use crate::native_dialog::NativeDialogBroker; use crate::native_dialog::NativeDialogBroker;
use crate::project::collaboration::{publish_authored_file, FileSnapshot, FileWriteIntent}; use crate::project::collaboration::{publish_authored_file, FileSnapshot, FileWriteIntent};
use crate::project::samples::SampleCatalog;
use crate::scene::recovery::{ use crate::scene::recovery::{
default_state_root, discard_recovery_snapshots, latest_recovery_snapshot, default_state_root, discard_recovery_snapshots, latest_recovery_snapshot,
write_recovery_snapshot, write_recovery_snapshot,
@ -49,6 +50,7 @@ pub enum SceneIoRequest {
SaveRecoveryCopyAs, SaveRecoveryCopyAs,
DiscardRecovery, DiscardRecovery,
OpenRecent(usize), OpenRecent(usize),
OpenSample(String),
OpenPath(PathBuf), OpenPath(PathBuf),
SwitchTab(usize), SwitchTab(usize),
CloseTab(usize), CloseTab(usize),
@ -313,6 +315,7 @@ fn process_scene_io_requests(world: &mut World) {
SceneIoRequest::SaveRecoveryCopyAs => save_recovery_copy_with_dialog(world), SceneIoRequest::SaveRecoveryCopyAs => save_recovery_copy_with_dialog(world),
SceneIoRequest::DiscardRecovery => discard_recovery(world), SceneIoRequest::DiscardRecovery => discard_recovery(world),
SceneIoRequest::OpenRecent(index) => open_recent(world, index), SceneIoRequest::OpenRecent(index) => open_recent(world, index),
SceneIoRequest::OpenSample(stable_id) => open_sample(world, &stable_id),
SceneIoRequest::OpenPath(path) => open_path(world, path), SceneIoRequest::OpenPath(path) => open_path(world, path),
SceneIoRequest::SwitchTab(index) => switch_scene_tab(world, index), SceneIoRequest::SwitchTab(index) => switch_scene_tab(world, index),
SceneIoRequest::CloseTab(index) => close_scene_tab(world, index), SceneIoRequest::CloseTab(index) => close_scene_tab(world, index),
@ -377,13 +380,72 @@ fn open_recent(world: &mut World, index: usize) -> String {
open_path(world, path) open_path(world, path)
} }
fn open_sample(world: &mut World, stable_id: &str) -> String {
let catalog_state = {
let catalog = world.resource::<SampleCatalog>();
(
catalog.entry(stable_id).cloned(),
catalog.unavailable_reason().map(str::to_string),
)
};
match catalog_state {
(Some(entry), _) => open_path(world, entry.scene_path),
(None, Some(reason)) => format!("Open sample failed: {reason}"),
(None, None) => format!("Open sample failed: unknown sample ID `{stable_id}`"),
}
}
/// Comparison-only key. Stored paths remain unchanged because recovery and bookmarks use them.
fn scene_path_identity(project_root: &Path, path: &Path) -> PathBuf {
let candidate = if path.is_absolute() {
path.to_path_buf()
} else {
project_root.join(path)
};
candidate.canonicalize().unwrap_or(candidate)
}
fn scene_paths_share_identity(project_root: &Path, first: &Path, second: &Path) -> bool {
scene_path_identity(project_root, first) == scene_path_identity(project_root, second)
}
fn remember_persisted_scene_path(
preferences: &mut crate::project_io::UserPreferences,
project_root: &Path,
path: &Path,
) {
preferences
.recent_levels
.retain(|recent| !scene_paths_share_identity(project_root, Path::new(recent), path));
preferences
.recent_levels
.insert(0, path.display().to_string());
preferences.recent_levels.truncate(8);
}
fn open_path(world: &mut World, path: PathBuf) -> String { fn open_path(world: &mut World, path: PathBuf) -> String {
if let Some(index) = world let project_root = PathBuf::from(
.resource::<SceneIo>() world
.tabs .resource::<crate::project_io::ProjectWorkspace>()
.iter() .root
.position(|tab| tab.path.as_ref() == Some(&path)) .clone(),
{ );
let Ok(project_root) = project_root.canonicalize() else {
return format!(
"Open failed: could not resolve project root {}",
project_root.display()
);
};
let matching_tab = world.resource::<SceneIo>().tabs.iter().position(|tab| {
tab.path
.as_deref()
.is_some_and(|tab_path| scene_paths_share_identity(&project_root, tab_path, &path))
});
if let Some(index) = matching_tab {
let established_path = world.resource::<SceneIo>().tabs[index].path.clone();
if let Some(established_path) = established_path {
remember_path(world, established_path);
}
return switch_scene_tab(world, index); return switch_scene_tab(world, index);
} }
if let Err(error) = capture_active_tab(world) { if let Err(error) = capture_active_tab(world) {
@ -2160,15 +2222,24 @@ fn backfill_missing_actor_kinds(world: &mut World) {
} }
fn remember_path(world: &mut World, path: PathBuf) { fn remember_path(world: &mut World, path: PathBuf) {
let configured_root = PathBuf::from(
world
.resource::<crate::project_io::ProjectWorkspace>()
.root
.clone(),
);
let project_root = configured_root.canonicalize().unwrap_or(configured_root);
{
let mut io = world.resource_mut::<SceneIo>(); let mut io = world.resource_mut::<SceneIo>();
io.recent_paths.retain(|recent| recent != &path); io.recent_paths
.retain(|recent| !scene_paths_share_identity(&project_root, recent, &path));
io.recent_paths.insert(0, path.clone()); io.recent_paths.insert(0, path.clone());
io.recent_paths.truncate(8); io.recent_paths.truncate(8);
}
if world.contains_resource::<crate::project_io::UserPreferences>() { if world.contains_resource::<crate::project_io::UserPreferences>() {
let path_label = path.display().to_string();
let mut prefs = world.resource_mut::<crate::project_io::UserPreferences>(); let mut prefs = world.resource_mut::<crate::project_io::UserPreferences>();
crate::project_io::push_recent(&mut prefs.recent_levels, path_label, 8); remember_persisted_scene_path(&mut prefs, &project_root, &path);
if let Err(error) = crate::project_io::write_user_preferences(&prefs) { if let Err(error) = crate::project_io::write_user_preferences(&prefs) {
warn!("Failed to save editor preferences: {error}"); warn!("Failed to save editor preferences: {error}");
} }
@ -3168,6 +3239,258 @@ mod tests {
std::fs::remove_dir_all(root).unwrap(); std::fs::remove_dir_all(root).unwrap();
} }
#[test]
fn unknown_sample_id_does_not_change_scene_state() {
let known = crate::project::samples::SampleCatalogEntry {
id: "known".into(),
label: "Known Sample".into(),
scene_path: PathBuf::from("assets/levels/samples/known.scn.ron"),
project_relative_scene: "assets/levels/samples/known.scn.ron".into(),
summary: "Known fixture".into(),
checks: vec!["opens".into()],
};
let mut world = World::new();
world.init_resource::<SceneIo>();
world.insert_resource(SampleCatalog::available_entries(vec![known]));
let original_tab_id = world.resource::<SceneIo>().tabs[0].id;
let status = open_sample(&mut world, "missing");
assert_eq!(status, "Open sample failed: unknown sample ID `missing`");
let io = world.resource::<SceneIo>();
assert_eq!(io.tabs.len(), 1);
assert_eq!(io.tabs[0].id, original_tab_id);
assert_eq!(io.active_path, None);
assert!(!io.dirty);
}
#[test]
fn in_project_absolute_and_relative_scene_paths_share_one_canonical_key() {
let root = std::env::temp_dir().join(format!(
"blacksite-scene-path-identity-{}",
uuid::Uuid::new_v4()
));
let scene = root.join("assets/levels/samples/alias.scn.ron");
std::fs::create_dir_all(scene.parent().unwrap()).unwrap();
std::fs::write(&scene, "(schema_version: 4, resources: {}, entities: {})").unwrap();
let root = root.canonicalize().unwrap();
let expected = scene.canonicalize().unwrap();
assert_eq!(scene_path_identity(&root, &scene), expected);
assert_eq!(
scene_path_identity(&root, Path::new("assets/levels/./samples/alias.scn.ron")),
expected
);
std::fs::remove_dir_all(root).unwrap();
}
#[test]
fn relative_and_absolute_scene_aliases_do_not_create_a_second_tab() {
let root = std::env::temp_dir().join(format!(
"blacksite-scene-tab-alias-{}",
uuid::Uuid::new_v4()
));
let relative = PathBuf::from("assets/levels/samples/alias.scn.ron");
let absolute = root.join(&relative);
std::fs::create_dir_all(absolute.parent().unwrap()).unwrap();
std::fs::write(
&absolute,
"(schema_version: 4, resources: {}, entities: {})",
)
.unwrap();
let mut world = World::new();
world.init_resource::<SceneIo>();
world.insert_resource(crate::project_io::ProjectWorkspace {
root: root.display().to_string(),
..Default::default()
});
{
let mut io = world.resource_mut::<SceneIo>();
io.active_path = Some(relative.clone());
io.tabs[0].path = Some(relative.clone());
io.recent_paths = vec![absolute.clone(), relative.clone()];
}
let status = open_path(&mut world, absolute.clone());
assert_eq!(status, "Scene tab is already active");
let io = world.resource::<SceneIo>();
assert_eq!(io.tabs.len(), 1);
assert_eq!(io.tabs[0].path.as_ref(), Some(&relative));
assert_eq!(io.active_path.as_ref(), Some(&relative));
assert_eq!(io.recent_paths, vec![relative.clone()]);
let mut preferences = crate::project_io::UserPreferences {
recent_levels: vec![
absolute.display().to_string(),
relative.display().to_string(),
],
..Default::default()
};
remember_persisted_scene_path(&mut preferences, &root, &relative);
assert_eq!(
preferences.recent_levels,
vec![relative.display().to_string()]
);
let mut world = World::new();
world.init_resource::<SceneIo>();
world.insert_resource(crate::project_io::ProjectWorkspace {
root: root.display().to_string(),
..Default::default()
});
{
let mut io = world.resource_mut::<SceneIo>();
io.active_path = Some(absolute.clone());
io.tabs[0].path = Some(absolute.clone());
}
let status = open_path(&mut world, relative.clone());
assert_eq!(status, "Scene tab is already active");
let io = world.resource::<SceneIo>();
assert_eq!(io.tabs.len(), 1);
assert_eq!(io.tabs[0].path.as_ref(), Some(&absolute));
assert_eq!(io.active_path.as_ref(), Some(&absolute));
let mut preferences = crate::project_io::UserPreferences {
recent_levels: vec![
relative.display().to_string(),
absolute.display().to_string(),
],
..Default::default()
};
remember_persisted_scene_path(&mut preferences, &root, &absolute);
assert_eq!(
preferences.recent_levels,
vec![absolute.display().to_string()]
);
std::fs::remove_dir_all(root).unwrap();
}
#[test]
fn alias_open_preserves_recovery_generation_and_camera_bookmark_keys() {
let root = std::env::temp_dir().join(format!(
"blacksite-scene-alias-state-{}",
uuid::Uuid::new_v4()
));
std::fs::create_dir_all(&root).unwrap();
let root = root.canonicalize().unwrap();
let relative = PathBuf::from("assets/levels/samples/stateful.scn.ron");
let absolute = root.join(&relative);
std::fs::create_dir_all(absolute.parent().unwrap()).unwrap();
std::fs::write(
&absolute,
"(schema_version: 4, resources: {}, entities: {})",
)
.unwrap();
let state_root = root.join("editor-state");
let first_recovery =
write_recovery_snapshot(&state_root, &root, &absolute, b"first", 5).unwrap();
let bookmark = Transform::from_xyz(7.0, 11.0, 13.0);
let bookmark_key = absolute.display().to_string();
let mut bookmarks = crate::viewport::CameraBookmarks::default();
crate::viewport::save_camera_bookmark(&mut bookmarks, &bookmark_key, bookmark);
let mut world = World::new();
world.init_resource::<SceneIo>();
world.insert_resource(crate::project_io::ProjectWorkspace {
root: root.display().to_string(),
..Default::default()
});
world.insert_resource(bookmarks);
{
let mut io = world.resource_mut::<SceneIo>();
io.active_path = Some(absolute.clone());
io.recovery_snapshot = Some(first_recovery.clone());
io.tabs[0].path = Some(absolute.clone());
io.tabs[0].recovery_snapshot = Some(first_recovery.clone());
}
let status = open_path(&mut world, relative);
assert_eq!(status, "Scene tab is already active");
let active_path = world
.resource::<SceneIo>()
.active_path
.clone()
.expect("active path must remain established");
assert_eq!(active_path, absolute);
assert_eq!(
world.resource::<SceneIo>().recovery_snapshot.as_ref(),
Some(&first_recovery)
);
let second_recovery =
write_recovery_snapshot(&state_root, &root, &active_path, b"second", 5).unwrap();
assert_eq!(first_recovery.parent(), second_recovery.parent());
let active_key = active_path.display().to_string();
assert_eq!(
crate::viewport::recall_camera_bookmark(
world.resource::<crate::viewport::CameraBookmarks>(),
&active_key,
),
Some(bookmark)
);
std::fs::remove_dir_all(root).unwrap();
}
#[test]
fn outside_project_scene_keeps_its_absolute_identity() {
let root = std::env::temp_dir().join(format!(
"blacksite-scene-path-root-{}",
uuid::Uuid::new_v4()
));
let outside = std::env::temp_dir().join(format!(
"blacksite-outside-scene-{}.scn.ron",
uuid::Uuid::new_v4()
));
std::fs::create_dir_all(&root).unwrap();
std::fs::write(&outside, "(schema_version: 4, resources: {}, entities: {})").unwrap();
let root = root.canonicalize().unwrap();
let outside = outside.canonicalize().unwrap();
assert_eq!(scene_path_identity(&root, &outside), outside);
std::fs::remove_file(outside).unwrap();
std::fs::remove_dir_all(root).unwrap();
}
#[test]
fn every_manifest_sample_typed_deserializes_without_a_gpu() {
let project_root = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("../..")
.canonicalize()
.unwrap();
let manifest = scene::sample_pack::load_editor_sample_manifest(&project_root).unwrap();
assert!(!manifest.samples.is_empty());
let mut app = App::new();
app.add_plugins(MinimalPlugins)
.add_plugins(AssetPlugin::default())
.add_plugins(bevy::pbr::MaterialPlugin::<StandardMaterial>::default())
.init_asset::<Mesh>()
.add_plugins(shared::SharedTypesPlugin);
for sample in manifest.samples {
let path = project_root.join(&sample.scene);
let text = std::fs::read_to_string(&path)
.unwrap_or_else(|error| panic!("could not read sample `{}`: {error}", sample.id));
let document = SceneDocument::from_ron_text(&text).unwrap_or_else(|error| {
panic!(
"sample `{}` is not a valid scene document: {error}",
sample.id
)
});
let bevy_ron = strip_schema_version(document.normalized_ron()).unwrap();
deserialize_dynamic_scene(app.world_mut(), &bevy_ron).unwrap_or_else(|error| {
panic!(
"sample `{}` failed typed deserialization: {error}",
sample.id
)
});
}
}
#[test] #[test]
fn scene_io_event_log_is_bounded_and_classifies_failures() { fn scene_io_event_log_is_bounded_and_classifies_failures() {
let mut io = SceneIo::default(); let mut io = SceneIo::default();

View File

@ -6,6 +6,7 @@ use bevy_inspector_egui::bevy_inspector::hierarchy::SelectedEntities;
use egui_dock::DockState; use egui_dock::DockState;
use crate::history::{apply_command_redo, apply_command_undo, EditorHistory}; use crate::history::{apply_command_redo, apply_command_undo, EditorHistory};
use crate::project::samples::{SampleCatalog, SampleCatalogEntry};
use crate::scene_io::{SceneIo, SceneIoRequest}; use crate::scene_io::{SceneIo, SceneIoRequest};
use crate::settings_ui::{open_project_settings_panel, ProjectSettingsPanel}; use crate::settings_ui::{open_project_settings_panel, ProjectSettingsPanel};
use crate::state::{EditorMode, PlayPaused}; use crate::state::{EditorMode, PlayPaused};
@ -19,6 +20,19 @@ use super::layout::reset_and_save_layout;
use super::widgets::menu_item; use super::widgets::menu_item;
use super::EditorTab; use super::EditorTab;
#[derive(Debug, Clone, PartialEq, Eq)]
enum SampleMenuModel {
Available(Vec<SampleCatalogEntry>),
Unavailable(String),
}
fn sample_menu_model(catalog: &SampleCatalog) -> SampleMenuModel {
match catalog.unavailable_reason() {
Some(reason) => SampleMenuModel::Unavailable(reason.to_string()),
None => SampleMenuModel::Available(catalog.entries().to_vec()),
}
}
pub fn top_menu_bar( pub fn top_menu_bar(
world: &mut World, world: &mut World,
root_ui: &mut egui::Ui, root_ui: &mut egui::Ui,
@ -37,6 +51,25 @@ pub fn top_menu_bar(
world.resource_mut::<SceneIo>().request = Some(SceneIoRequest::Open); world.resource_mut::<SceneIo>().request = Some(SceneIoRequest::Open);
ui.close(); ui.close();
} }
let sample_menu = sample_menu_model(world.resource::<SampleCatalog>());
ui.menu_button("Open Sample", |ui| match sample_menu {
SampleMenuModel::Available(entries) => {
for entry in entries {
if menu_item(ui, &entry.label, None, true)
.on_hover_text(entry.hover_text())
.clicked()
{
world.resource_mut::<SceneIo>().request =
Some(SceneIoRequest::OpenSample(entry.id));
ui.close();
}
}
}
SampleMenuModel::Unavailable(reason) => {
menu_item(ui, "Sample pack unavailable", None, false)
.on_disabled_hover_text(reason);
}
});
if menu_item(ui, "Save", Some("Ctrl+S"), true).clicked() { if menu_item(ui, "Save", Some("Ctrl+S"), true).clicked() {
world.resource_mut::<SceneIo>().request = Some(SceneIoRequest::Save); world.resource_mut::<SceneIo>().request = Some(SceneIoRequest::Save);
ui.close(); ui.close();
@ -287,3 +320,50 @@ pub fn top_menu_bar(
}); });
}); });
} }
#[cfg(test)]
mod tests {
use std::path::PathBuf;
use super::*;
fn entry(id: &str, label: &str) -> SampleCatalogEntry {
SampleCatalogEntry {
id: id.into(),
label: label.into(),
scene_path: PathBuf::from(format!("assets/levels/samples/{id}.scn.ron")),
project_relative_scene: format!("assets/levels/samples/{id}.scn.ron"),
summary: format!("{label} summary"),
checks: vec![format!("{label} check")],
}
}
#[test]
fn sample_menu_preserves_catalog_order_and_hover_details() {
let catalog = SampleCatalog::available_entries(vec![
entry("material", "Material Lab"),
entry("terrain", "Terrain Sculpt"),
]);
let SampleMenuModel::Available(rows) = sample_menu_model(&catalog) else {
panic!("valid catalog must produce actionable menu rows");
};
assert_eq!(
rows.iter().map(|row| row.id.as_str()).collect::<Vec<_>>(),
vec!["material", "terrain"]
);
assert!(rows[0].hover_text().contains("Material Lab summary"));
assert!(rows[0].hover_text().contains("Material Lab check"));
}
#[test]
fn unavailable_catalog_produces_one_disabled_explanation() {
let catalog = SampleCatalog::default();
assert_eq!(
sample_menu_model(&catalog),
SampleMenuModel::Unavailable("Sample catalog is loading".into())
);
}
}

View File

@ -9,6 +9,7 @@ mod migrate;
pub mod navigation; pub mod navigation;
mod prefab; mod prefab;
mod project_validation; mod project_validation;
pub mod sample_pack;
mod upgrade; mod upgrade;
use std::path::Path; use std::path::Path;

View File

@ -13,7 +13,7 @@ use shared::{
AudioSourceDesc, BrushDesc, ColliderDesc, ColliderShapeDesc, EditorAssetRef, MaterialAsset, AudioSourceDesc, BrushDesc, ColliderDesc, ColliderShapeDesc, EditorAssetRef, MaterialAsset,
MaterialDesc, MaterialInstanceAsset, MaterialOverride, ModelRef, PostProcessEffectAsset, MaterialDesc, MaterialInstanceAsset, MaterialOverride, ModelRef, PostProcessEffectAsset,
PostProcessVolumeDesc, PrefabInstance, PrefabRef, RendererMaterialSet, ShaderSchemaAsset, PostProcessVolumeDesc, PrefabInstance, PrefabRef, RendererMaterialSet, ShaderSchemaAsset,
SkinnedMeshRenderer, StaticMeshRenderer, ANIMATION_MANIFEST_SCHEMA_VERSION, SkinnedMeshRenderer, StaticMeshRenderer, TerrainDesc, ANIMATION_MANIFEST_SCHEMA_VERSION,
AUDIO_CLIP_SUB_ASSET_ID, COMPONENT_ANIMATION_CONTROLLER_DESC, COMPONENT_SKINNED_MESH_RENDERER, AUDIO_CLIP_SUB_ASSET_ID, COMPONENT_ANIMATION_CONTROLLER_DESC, COMPONENT_SKINNED_MESH_RENDERER,
NAVIGATION_GENERATED_ARTIFACT_DIRECTORY, NAVIGATION_GENERATED_ARTIFACT_DIRECTORY,
}; };
@ -25,6 +25,7 @@ const MATERIAL_DESC: &str = "shared::components::MaterialDesc";
const MATERIAL_OVERRIDE: &str = "shared::components::MaterialOverride"; const MATERIAL_OVERRIDE: &str = "shared::components::MaterialOverride";
const STATIC_MESH_RENDERER: &str = "shared::components::StaticMeshRenderer"; const STATIC_MESH_RENDERER: &str = "shared::components::StaticMeshRenderer";
const BRUSH_DESC: &str = "shared::components::BrushDesc"; const BRUSH_DESC: &str = "shared::components::BrushDesc";
const TERRAIN_DESC: &str = "shared::components::TerrainDesc";
const COLLIDER_DESC: &str = "shared::components::ColliderDesc"; const COLLIDER_DESC: &str = "shared::components::ColliderDesc";
const POST_PROCESS_VOLUME: &str = "shared::components::PostProcessVolumeDesc"; const POST_PROCESS_VOLUME: &str = "shared::components::PostProcessVolumeDesc";
const PREFAB_INSTANCE: &str = "shared::components::PrefabInstance"; const PREFAB_INSTANCE: &str = "shared::components::PrefabInstance";
@ -1905,6 +1906,53 @@ fn collect_component_references(
} }
} }
} }
TERRAIN_DESC => match ron::from_str::<TerrainDesc>(&component.ron) {
Ok(terrain) => {
if let Err(error) = terrain.validate() {
report.findings.push(ProjectValidationFinding {
severity: ValidationSeverity::Error,
code: "terrain.invalid".into(),
source_path: source_path.to_string(),
owner_actor_id: actor_id.map(str::to_string),
reference: None,
message: error,
repair: "Repair the terrain dimensions, samples, layers, and chunk settings in the Inspector."
.into(),
});
}
if let Some(material) = &terrain.base_material {
collect_terrain_material_ref(
project_root,
report,
source_path,
actor_id,
"TerrainDesc.base_material",
material,
);
}
for (index, layer) in terrain.material_layers.iter().enumerate() {
if let Some(material) = &layer.material {
collect_terrain_material_ref(
project_root,
report,
source_path,
actor_id,
&format!("TerrainDesc.material_layers[{index}].material"),
material,
);
}
}
}
Err(error) => report.findings.push(ProjectValidationFinding {
severity: ValidationSeverity::Error,
code: "terrain.malformed".into(),
source_path: source_path.to_string(),
owner_actor_id: actor_id.map(str::to_string),
reference: None,
message: format!("could not parse TerrainDesc: {error}"),
repair: "Remove and recreate the Terrain component with the current editor.".into(),
}),
},
COLLIDER_DESC => validate_collider( COLLIDER_DESC => validate_collider(
project_root, project_root,
report, report,
@ -2736,6 +2784,64 @@ fn collect_material(
} }
} }
fn collect_terrain_material_ref(
project_root: &Path,
report: &mut ProjectValidationReport,
source_path: &str,
actor_id: Option<&str>,
property: &str,
reference: &EditorAssetRef,
) {
let Some(path) = reference
.source_path
.as_deref()
.filter(|path| !path.trim().is_empty())
else {
report.findings.push(ProjectValidationFinding {
severity: ValidationSeverity::Error,
code: "terrain.material_ref_unresolved".into(),
source_path: source_path.to_string(),
owner_actor_id: actor_id.map(str::to_string),
reference: Some(property.to_string()),
message: format!("{property} has no loadable project source path"),
repair:
"Assign an existing project Material or Material Instance in the Terrain inspector."
.into(),
});
return;
};
let path_value = Path::new(path);
if path_value.is_absolute()
|| path_value == Path::new("assets")
|| !path_value.starts_with("assets")
|| !path_value
.components()
.all(|component| matches!(component, Component::Normal(_)))
{
report.findings.push(ProjectValidationFinding {
severity: ValidationSeverity::Error,
code: "terrain.material_ref_invalid".into(),
source_path: source_path.to_string(),
owner_actor_id: actor_id.map(str::to_string),
reference: Some(path.to_string()),
message: format!(
"{property} source path must be a normalized project-relative path below assets/"
),
repair: "Reassign the terrain layer from a project Material or Material Instance."
.into(),
});
return;
}
add_reference(
project_root,
report,
source_path,
actor_id,
"terrain_material",
path,
);
}
fn collect_asset_ref( fn collect_asset_ref(
project_root: &Path, project_root: &Path,
report: &mut ProjectValidationReport, report: &mut ProjectValidationReport,
@ -3888,6 +3994,132 @@ mod tests {
std::fs::remove_dir_all(root).unwrap(); std::fs::remove_dir_all(root).unwrap();
} }
fn write_terrain_scene(root: &Path, terrain: &TerrainDesc) {
let terrain = ron::to_string(terrain).unwrap();
std::fs::write(
root.join("assets/levels/main.scn.ron"),
format!(
"(schema_version:4,resources:{{}},entities:{{1:(components:{{\"shared::components::ActorId\":(\"terrain-owner\"),\"{TERRAIN_DESC}\":{terrain},}})}})"
),
)
.unwrap();
}
#[test]
fn terrain_material_references_are_actor_owned_dependencies() {
let root = fixture_root();
let mut terrain = TerrainDesc::flat(3);
terrain.base_material = Some(
EditorAssetRef::new("base-id", "material:instance", "Base")
.with_source_path("assets/materials/base.ron"),
);
terrain.material_layers = vec![shared::TerrainMaterialLayer {
material: Some(
EditorAssetRef::new("layer-id", "material:source", "Layer")
.with_source_path("assets/materials/layer.ron"),
),
uv_scale: 4.0,
}];
write_terrain_scene(&root, &terrain);
let report = validate_project(&root);
let dependencies = report
.dependencies
.iter()
.filter(|dependency| dependency.kind == "terrain_material")
.collect::<Vec<_>>();
assert_eq!(dependencies.len(), 2, "{:?}", report.dependencies);
assert!(dependencies
.iter()
.all(|dependency| dependency.owner_actor_id.as_deref() == Some("terrain-owner")));
assert_eq!(
report
.findings
.iter()
.filter(|finding| finding.code == "asset.missing")
.count(),
2
);
std::fs::remove_dir_all(root).unwrap();
}
#[test]
fn terrain_material_references_require_runtime_source_paths() {
let root = fixture_root();
let mut terrain = TerrainDesc::flat(3);
terrain.base_material = Some(
EditorAssetRef::new("empty-path-id", "material:source", "Empty")
.with_source_path(" "),
);
terrain.material_layers = vec![
shared::TerrainMaterialLayer {
material: Some(EditorAssetRef::new(
"source-less-id",
"material:source",
"Source-less",
)),
uv_scale: 4.0,
},
shared::TerrainMaterialLayer {
material: Some(
EditorAssetRef::new("missing-id", "material:source", "Missing")
.with_source_path("assets/materials/missing.ron"),
),
uv_scale: 8.0,
},
];
write_terrain_scene(&root, &terrain);
let report = validate_project(&root);
let unresolved = report
.findings
.iter()
.filter(|finding| finding.code == "terrain.material_ref_unresolved")
.collect::<Vec<_>>();
assert_eq!(unresolved.len(), 2, "{:?}", report.findings);
assert!(unresolved
.iter()
.all(|finding| finding.owner_actor_id.as_deref() == Some("terrain-owner")));
assert!(report.findings.iter().any(|finding| {
finding.code == "asset.missing"
&& finding.owner_actor_id.as_deref() == Some("terrain-owner")
&& finding.reference.as_deref() == Some("assets/materials/missing.ron")
}));
assert!(report.dependencies.iter().any(|dependency| {
dependency.kind == "terrain_material"
&& dependency.reference == "assets/materials/missing.ron"
}));
assert!(!report.dependencies.iter().any(|dependency| {
dependency.kind == "generated_manifest"
&& (dependency.reference.contains("empty-path-id")
|| dependency.reference.contains("source-less-id"))
}));
assert!(!report.is_release_ready());
std::fs::remove_dir_all(root).unwrap();
}
#[test]
fn invalid_terrain_descriptor_blocks_project_validation() {
let root = fixture_root();
let mut terrain = TerrainDesc::flat(3);
terrain.heights.pop();
write_terrain_scene(&root, &terrain);
let report = validate_project(&root);
let finding = report
.findings
.iter()
.find(|finding| finding.code == "terrain.invalid")
.expect("invalid terrain should block release validation");
assert_eq!(finding.owner_actor_id.as_deref(), Some("terrain-owner"));
assert!(finding.message.contains("requires 9 height samples"));
assert!(!report.is_release_ready());
std::fs::remove_dir_all(root).unwrap();
}
#[test] #[test]
fn project_default_level_must_be_in_the_audited_levels_tree() { fn project_default_level_must_be_in_the_audited_levels_tree() {
let root = fixture_root(); let root = fixture_root();

File diff suppressed because it is too large Load Diff

View File

@ -85,6 +85,7 @@ Immutable-style log of significant decisions. Add a new numbered ADR when changi
| [editor/terrain.md](editor/terrain.md) | Terrain schema, inspector workflow, chunk hydration, collision, and follow-on boundaries | | [editor/terrain.md](editor/terrain.md) | Terrain schema, inspector workflow, chunk hydration, collision, and follow-on boundaries |
| [editor/physics-placement.md](editor/physics-placement.md) | Transactional gravity placement, diagnostics, isolation, and undo workflow | | [editor/physics-placement.md](editor/physics-placement.md) | Transactional gravity placement, diagnostics, isolation, and undo workflow |
| [editor/collider-authoring.md](editor/collider-authoring.md) | Collider shape authoring, hydration diagnostics, semantic overlays, and placement prerequisites | | [editor/collider-authoring.md](editor/collider-authoring.md) | Collider shape authoring, hydration diagnostics, semantic overlays, and placement prerequisites |
| [editor/sample-regression-pack.md](editor/sample-regression-pack.md) | Five-area sample catalog, editor workflow, validation gates, and visual QA contract |
| [editor/evaluations/material-renderer-foundation/](editor/evaluations/material-renderer-foundation/) | Live screenshots and acceptance results for the renderer/material/component foundation | | [editor/evaluations/material-renderer-foundation/](editor/evaluations/material-renderer-foundation/) | Live screenshots and acceptance results for the renderer/material/component foundation |
| [editor/evaluations/terrain-sculpt-tools/](editor/evaluations/terrain-sculpt-tools/) | Live screenshot and acceptance results for modal terrain sculpt tools | | [editor/evaluations/terrain-sculpt-tools/](editor/evaluations/terrain-sculpt-tools/) | Live screenshot and acceptance results for modal terrain sculpt tools |
| [editor/evaluations/terrain-material-layers/](editor/evaluations/terrain-material-layers/) | Live screenshot and acceptance results for terrain material assignment, blending, painting, and history | | [editor/evaluations/terrain-material-layers/](editor/evaluations/terrain-material-layers/) | Live screenshot and acceptance results for terrain material assignment, blending, painting, and history |
@ -92,6 +93,7 @@ Immutable-style log of significant decisions. Add a new numbered ADR when changi
| [editor/evaluations/collider-diagnostics/](editor/evaluations/collider-diagnostics/) | Live screenshot and acceptance results for collider overlays, diagnostics, shape history, and placement preflight | | [editor/evaluations/collider-diagnostics/](editor/evaluations/collider-diagnostics/) | Live screenshot and acceptance results for collider overlays, diagnostics, shape history, and placement preflight |
| [editor/evaluations/navigation-authoring/](editor/evaluations/navigation-authoring/) | Live screenshots and acceptance results for navigation authoring and runtime parity | | [editor/evaluations/navigation-authoring/](editor/evaluations/navigation-authoring/) | Live screenshots and acceptance results for navigation authoring and runtime parity |
| [editor/evaluations/operator-invariants/](editor/evaluations/operator-invariants/) | Source acceptance results for production operator lifecycle, rollback, cleanup, and undo/redo invariants | | [editor/evaluations/operator-invariants/](editor/evaluations/operator-invariants/) | Source acceptance results for production operator lifecycle, rollback, cleanup, and undo/redo invariants |
| [editor/evaluations/sample-regression-pack/](editor/evaluations/sample-regression-pack/) | Source evidence and native acceptance protocol for the editor sample regression pack |
| [editor/evaluations/production-readiness/](editor/evaluations/production-readiness/) | Current versioned production gate, evidence matrix, candidate commands, soak protocol, and independent sign-off requirements | | [editor/evaluations/production-readiness/](editor/evaluations/production-readiness/) | Current versioned production gate, evidence matrix, candidate commands, soak protocol, and independent sign-off requirements |
## Working plans (not canonical long-term) ## Working plans (not canonical long-term)
@ -116,6 +118,7 @@ Detailed milestone and feature plans live in [`.cursor/plans/`](../.cursor/plans
| `material_library_and_targeted_drop_*.plan.md` | Dedicated Material Library, exact viewport slot/primitive/brush targeting, hover preview, cancel, and grouped history | | `material_library_and_targeted_drop_*.plan.md` | Dedicated Material Library, exact viewport slot/primitive/brush targeting, hover preview, cancel, and grouped history |
| `terrain_material_layers_*.plan.md` | Terrain shared-material layers, normalized weights, blended hydration, and modal painting | | `terrain_material_layers_*.plan.md` | Terrain shared-material layers, normalized weights, blended hydration, and modal painting |
| `operator_invariants_completion_*.plan.md` | Production operator dispatch, interruption, rollback, cleanup, and undo/redo acceptance | | `operator_invariants_completion_*.plan.md` | Production operator dispatch, interruption, rollback, cleanup, and undo/redo acceptance |
| `editor_sample_regression_pack_*.plan.md` | Five-area sample manifest, editor catalog, deterministic validation, and native regression acceptance |
## Crate responsibilities (quick reference) ## Crate responsibilities (quick reference)

View File

@ -23,6 +23,14 @@ Both the editor Diagnostics window and the `cargo validate-levels` headless comm
entry point. The command supports `--project <path>` and `--json`; the editor can select the live entry point. The command supports `--project <path>` and `--json`; the editor can select the live
owning actor for an attributed finding. Blocking errors produce a nonzero headless exit status. owning actor for an attributed finding. Blocking errors produce a nonzero headless exit status.
The optional editor regression pack has one conventional manifest at
`assets/samples/editor_samples.ron`. Schema v1 contains exactly one Brush, Material, Terrain,
Physics Placement, and Rendering sample with stable metadata, project-relative scene paths, and
explicit manual checks. `cargo validate-samples` validates that catalog and combines its findings
with the authoritative project report. Projects without the manifest remain valid ordinary
projects; Blacksite CI invokes the sample command to make the complete pack mandatory for this
repository.
Project validation covers the project manifest and asset roots, registry IDs/sources/dependencies, Project validation covers the project manifest and asset roots, registry IDs/sources/dependencies,
generated static mesh manifests, material and shader documents, post effects, scene and prefab generated static mesh manifests, material and shader documents, post effects, scene and prefab
graphs, authored model/material/texture/brush/collider references, rendering profiles, and graphs, authored model/material/texture/brush/collider references, rendering profiles, and
@ -34,6 +42,10 @@ startup entrypoint. Unknown newer scene schemas are rejected rather than down-st
requirements are explicit findings; for example, Solari projects record the required forward-path requirements are explicit findings; for example, Solari projects record the required forward-path
fallback QA when ray tracing is unavailable. fallback QA when ray tracing is unavailable.
Terrain descriptors participate in the same actor-owned dependency pass. Their authored dimensions,
height and weight grids, chunk settings, base material, and material layers are validated before
hydration so a sample or shipping scene cannot hide an invalid or unresolved terrain reference.
## Consequences ## Consequences
- Headless and editor validation share result semantics and finding ownership. - Headless and editor validation share result semantics and finding ownership.
@ -42,3 +54,7 @@ fallback QA when ray tracing is unavailable.
Gitea #44 acceptance verifies the shared gate against real development output. Gitea #44 acceptance verifies the shared gate against real development output.
- Asset formats added later must register their dependencies with this validator and extend the - Asset formats added later must register their dependencies with this validator and extend the
valid, missing, cyclic, and incompatible project-fixture matrix. valid, missing, cyclic, and incompatible project-fixture matrix.
- The sample manifest is discovery and regression metadata, not a second project-settings schema;
editor UI and headless tooling consume the same scene ordering and validation result.
- CI must hydrate Git LFS before validation because a pointer file is intentionally treated as
missing runtime content.

View File

@ -28,6 +28,7 @@ Docs for the in-process egui editor (`crates/editor/`). Update this index when a
| [terrain.md](terrain.md) | Inline height-grid terrain, chunk hydration, collision, inspector workflow, and fixtures | | [terrain.md](terrain.md) | Inline height-grid terrain, chunk hydration, collision, inspector workflow, and fixtures |
| [physics-placement.md](physics-placement.md) | Transactional gravity placement, prerequisites, isolation, commit/cancel, and undo | | [physics-placement.md](physics-placement.md) | Transactional gravity placement, prerequisites, isolation, commit/cancel, and undo |
| [collider-authoring.md](collider-authoring.md) | Collider shape editing, shared health diagnostics, overlays, hydration status, and placement reuse | | [collider-authoring.md](collider-authoring.md) | Collider shape editing, shared health diagnostics, overlays, hydration status, and placement reuse |
| [sample-regression-pack.md](sample-regression-pack.md) | Five-area sample manifest, native workflow, visual composition, validation, and maintenance contract |
| [evaluations/](evaluations/) | Acceptance evidence records and native Gitea attachment publishing policy | | [evaluations/](evaluations/) | Acceptance evidence records and native Gitea attachment publishing policy |
| [evaluations/material-renderer-foundation/](evaluations/material-renderer-foundation/) | Live screenshots and verification record for the renderer/material/component foundation | | [evaluations/material-renderer-foundation/](evaluations/material-renderer-foundation/) | Live screenshots and verification record for the renderer/material/component foundation |
| [evaluations/material-library-targeted-drop/](evaluations/material-library-targeted-drop/) | Live screenshot and verification record for the docked Material Library and exact reversible surface drops | | [evaluations/material-library-targeted-drop/](evaluations/material-library-targeted-drop/) | Live screenshot and verification record for the docked Material Library and exact reversible surface drops |
@ -40,6 +41,7 @@ Docs for the in-process egui editor (`crates/editor/`). Update this index when a
| [evaluations/collider-diagnostics/](evaluations/collider-diagnostics/) | Live screenshot and verification record for semantic collider overlays, health linking, shape switching, and placement reuse | | [evaluations/collider-diagnostics/](evaluations/collider-diagnostics/) | Live screenshot and verification record for semantic collider overlays, health linking, shape switching, and placement reuse |
| [evaluations/navigation-authoring/](evaluations/navigation-authoring/) | Live screenshots and verification record for navigation authoring and runtime parity | | [evaluations/navigation-authoring/](evaluations/navigation-authoring/) | Live screenshots and verification record for navigation authoring and runtime parity |
| [evaluations/operator-invariants/](evaluations/operator-invariants/) | Source acceptance record for production operator lifecycle, rollback, cleanup, and undo/redo invariants | | [evaluations/operator-invariants/](evaluations/operator-invariants/) | Source acceptance record for production operator lifecycle, rollback, cleanup, and undo/redo invariants |
| [evaluations/sample-regression-pack/](evaluations/sample-regression-pack/) | Source evidence and pending native protocol for the five-area editor regression pack |
| [evaluations/production-readiness/](evaluations/production-readiness/) | Current #50 release-candidate matrix and required clean-checkout, soak, performance, limitation, and independent-signoff evidence | | [evaluations/production-readiness/](evaluations/production-readiness/) | Current #50 release-candidate matrix and required clean-checkout, soak, performance, limitation, and independent-signoff evidence |
## Subsystems (code → doc) ## Subsystems (code → doc)
@ -67,6 +69,7 @@ Docs for the in-process egui editor (`crates/editor/`). Update this index when a
| `project/session.rs` | Versioned XDG session document, clean marker, and safe resume | session-recovery.md, ADR 0024 | | `project/session.rs` | Versioned XDG session document, clean marker, and safe resume | session-recovery.md, ADR 0024 |
| `project/diagnostics_bundle.rs` | Privacy-bounded transactional support report export | session-recovery.md, ADR 0024 | | `project/diagnostics_bundle.rs` | Privacy-bounded transactional support report export | session-recovery.md, ADR 0024 |
| `project/launcher.rs` | Strict project inspection, CLI activation, recent filtering, and sandbox scaffolding | project-launcher.md, ADR 0025 | | `project/launcher.rs` | Strict project inspection, CLI activation, recent filtering, and sandbox scaffolding | project-launcher.md, ADR 0025 |
| `project/samples.rs` / `scene::sample_pack` / `xtask/src/validate_samples.rs` | Cached editor sample catalog, canonical pack contract, and deterministic headless release gate | sample-regression-pack.md, ADR 0028 |
| `ext/` | Command palette, BRP, game panel adapters | ADR 0007 | | `ext/` | Command palette, BRP, game panel adapters | ADR 0007 |
| `ui/mod.rs` | Dock layout, unified viewport, menus | README controls | | `ui/mod.rs` | Dock layout, unified viewport, menus | README controls |
| `ui/scene_tabs.rs` | Main-toolbar scene tabs and composition controls | multi-scene-composition.md, ADR 0026 | | `ui/scene_tabs.rs` | Main-toolbar scene tabs and composition controls | multi-scene-composition.md, ADR 0026 |

View File

@ -11,6 +11,7 @@ Living checklist for the production editor program ([ADR 0012](../adr/0012-zero-
| Optional / inferred `ActorKind` as long-term save path | Done | Schema v2 + migration; save validates | | Optional / inferred `ActorKind` as long-term save path | Done | Schema v2 + migration; save validates |
| Hydrated components in committed `.scn.ron` | CI | `scene::validate_scene_authoring_only` + `repo_editor_scene_has_no_hydrated_components` | | Hydrated components in committed `.scn.ron` | CI | `scene::validate_scene_authoring_only` + `repo_editor_scene_has_no_hydrated_components` |
| Production mutation without commit/cancel/failure/undo invariants | Done | Shared operator harness plus typed history projections cover palette, asset, brush, terrain, physics, grouping, lighting, material-drop, and transform paths; see [evaluation](evaluations/operator-invariants/) | | Production mutation without commit/cancel/failure/undo invariants | Done | Shared operator harness plus typed history projections cover palette, asset, brush, terrain, physics, grouping, lighting, material-drop, and transform paths; see [evaluation](evaluations/operator-invariants/) |
| Representative editor coverage can silently disappear | Done | Versioned five-area manifest, typed scene gate, `validate-samples`, and native protocol; see [sample pack](sample-regression-pack.md) |
| Dual FBX thumbnail ad-hoc path (parallel to unified pipeline) | Partial | Phase 5 `assets/thumbnails/` refactor | | Dual FBX thumbnail ad-hoc path (parallel to unified pipeline) | Partial | Phase 5 `assets/thumbnails/` refactor |
| `failed_keys` thumbnail cache without retry API | Partial | `asset_thumbnails.rs`; Phase 5 `ThumbnailState` | | `failed_keys` thumbnail cache without retry API | Partial | `asset_thumbnails.rs`; Phase 5 `ThumbnailState` |
@ -31,3 +32,4 @@ Living checklist for the production editor program ([ADR 0012](../adr/0012-zero-
- [ ] Save/load roundtrip: hierarchy, lights, materials unchanged - [ ] Save/load roundtrip: hierarchy, lights, materials unchanged
- [ ] PIE 60s stop: project lighting profile unchanged unless scene overrides edited - [ ] PIE 60s stop: project lighting profile unchanged unless scene overrides edited
- [ ] Rerun the [operator invariant matrix](operator-regression-testing.md) from the nominated release-candidate commit - [ ] Rerun the [operator invariant matrix](operator-regression-testing.md) from the nominated release-candidate commit
- [ ] Open all five [sample regression pack](sample-regression-pack.md) entries through the native File menu and record exact-candidate results

View File

@ -4,15 +4,17 @@ Date: 2026-07-12
![Three selected props settled on the committed placement floor](physics-placement-settled.png) ![Three selected props settled on the committed placement floor](physics-placement-settled.png)
The native Wayland editor is shown with the committed The native Wayland editor is shown with the 2026-07-12 revision of
`assets/levels/physics_placement_showcase.scn.ron` fixture. Placement Prop A, B, and C were `assets/levels/physics_placement_showcase.scn.ron`. At that revision, Placement Prop A, B, and C were
multi-selected and released from three different heights through the hand/down-arrow action in the multi-selected and released from three different heights through the hand/down-arrow action in the
existing horizontal viewport toolbar. existing horizontal viewport toolbar. The fixture now exposes those stable actor IDs as Placement
Cuboid, Placement Sphere, and Placement Capsule; its distinct-shape rerun is tracked by the
[sample-pack evaluation](../sample-regression-pack/).
## Acceptance ## Acceptance
- All three props used the hydrated Avian cuboid colliders and settled on Placement Floor after - In the recorded revision, all three props used hydrated Avian cuboid colliders and settled on
1.5 simulated seconds. Placement Floor after 1.5 simulated seconds.
- During preview the HUD reported selection count, simulated time, and Settled state; transform - During preview the HUD reported selection count, simulated time, and Settled state; transform
gizmos and viewport picking did not compete with the modal tool. gizmos and viewport picking did not compete with the modal tool.
- Escape restored every starting transform and left history empty. - Escape restored every starting transform and left history empty.
@ -25,4 +27,3 @@ existing horizontal viewport toolbar.
error after the stale-binding fix. error after the stale-binding fix.
Packaged-runtime tests remain intentionally deferred by project-owner request. Packaged-runtime tests remain intentionally deferred by project-owner request.

View File

@ -1,8 +1,8 @@
# Production-Readiness Acceptance Matrix # Production-Readiness Acceptance Matrix
**Matrix version:** 0.2 **Matrix version:** 0.3
**Last audit:** 2026-07-12 **Last audit:** 2026-07-13
**Release-candidate commit:** Not nominated **Release-candidate commit:** Not nominated
@ -30,21 +30,21 @@ another commit, a dirty worktree, or an older package do not transfer to the can
| ID | Requirement | State | Current evidence and gap | | ID | Requirement | State | Current evidence and gap |
|----|-------------|-------|--------------------------| |----|-------------|-------|--------------------------|
| G1 | Project create/open/resume, scene authoring, autosave/recovery, hierarchy, prefab, and asset integrity pass | Partial | Project/recovery/session/multi-scene/prefab implementations are documented in [project launcher](../../project-launcher.md), [session recovery](../../session-recovery.md), [multi-scene composition](../../multi-scene-composition.md), and [prefab authoring](../../prefab-authoring.md). Collaborative file safety `#49` and non-blocking native dialogs `#52` passed live acceptance. Candidate-specific end-to-end reruns remain. | | G1 | Project create/open/resume, scene authoring, autosave/recovery, hierarchy, prefab, and asset integrity pass | Fail | Project/recovery/session/multi-scene/prefab implementations are documented in [project launcher](../../project-launcher.md), [session recovery](../../session-recovery.md), [multi-scene composition](../../multi-scene-composition.md), and [prefab authoring](../../prefab-authoring.md). Collaborative file safety `#49` and non-blocking native dialogs `#52` passed live acceptance, but `#55` records unguarded native dirty shutdown and a missing clean-history savepoint. Candidate-specific end-to-end reruns also remain. |
| G2 | Brush, material, terrain, physics placement, animation, audio, navigation, PIE, and build/package samples pass | Fail | Animation, audio, navigation, renderer/material foundation `#51`, Material Library, targeted material drops, terrain `#22`-`#24`, physics placement `#25`, and build foundations are implemented with source/live fixtures. Brush acceptance `#37` remains incomplete. Packaged testing is owner-deferred. | | G2 | Brush, material, terrain, physics placement, animation, audio, navigation, PIE, and build/package samples pass | Partial | The [five-area sample pack](../sample-regression-pack/) now provides deterministic Brush, Material, Terrain, Physics Placement, and Rendering fixtures with source validation. Animation, audio, navigation, PIE, and build foundations also exist, but the exact-candidate pack rerun, brush acceptance `#37`, and combined workflow evidence remain incomplete. Packaged testing is owner-deferred. |
| G3 | Undo/redo/cancel invariants and helper cleanup cover every production mutation path | Partial | Source implementation and focused evidence are complete in [operator regression testing](../../operator-regression-testing.md) and the [operator-invariants evaluation](../operator-invariants/); Gitea `#33` is ready to close. A clean, exact-candidate rerun is still required for `Pass`. | | G3 | Undo/redo/cancel invariants and helper cleanup cover every production mutation path | Partial | Source implementation and focused evidence are complete in [operator regression testing](../../operator-regression-testing.md) and the [operator-invariants evaluation](../operator-invariants/); Gitea `#33` closed at `c55f347`. A clean, exact-candidate rerun is still required for `Pass`. |
| G4 | Representative project completes an eight-hour soak without unbounded memory/target growth or unrecoverable failure | Missing | No candidate soak log, resource timeline, failure ledger, or target-growth measurement exists. | | G4 | Representative project completes an eight-hour soak without unbounded memory/target growth or unrecoverable failure | Missing | No candidate soak log, resource timeline, failure ledger, or target-growth measurement exists. |
| G5 | Cold start, scene open/save, asset refresh, common manipulation, and package-build budgets are documented and measured | Missing | Gitea `#34` is open; no ratified budgets or candidate measurement record exists. | | G5 | Cold start, scene open/save, asset refresh, common manipulation, and package-build budgets are documented and measured | Missing | Gitea `#34` is open; no ratified budgets or candidate measurement record exists. |
| G6 | Headless content validation and CI are green from a clean checkout | Missing | [CI configuration](../../../../.github/workflows/ci.yml) exists and local source/headless checks have passed during feature work, but no clean-checkout candidate run is linked. The current Gitea server does not expose an Actions run endpoint for this repository. | | G6 | Headless content validation and CI are green from a clean checkout | Missing | [CI configuration](../../../../.github/workflows/ci.yml) hydrates LFS and includes both project and sample content gates. Local implementation checks have passed, but no clean-checkout candidate run is linked. The current Gitea server does not expose an Actions run endpoint for this repository. |
| G7 | First-hour UX and recovery QA are signed off by someone other than the implementer | External | No independent sign-off exists. Gitea `#36` remains open; historical H1-H6 implementation-pass notes do not count. | | G7 | First-hour UX and recovery QA are signed off by someone other than the implementer | External | No independent sign-off exists. Gitea `#36` remains open; historical H1-H6 implementation-pass notes do not count. |
| G8 | Known limitations have severity/workaround and no P0 blocker remains | Fail | Limitations are distributed across feature docs rather than one candidate ledger. The remaining open P0 implementation blocker is `#32`; `#50` cannot close while it remains. Operator invariants `#33`, renderer foundation `#51`, roadmap audit `#35`, Material Library `#16`, targeted drops `#18`, collaborative safety `#49`, and dialog responsiveness `#52` are complete. Property-block application `#53` is P1; dynamic deformed Solari geometry `#54` is a documented P2 limitation with Forward/raster fallback. | | G8 | Known limitations have severity/workaround and no P0 blocker remains | Fail | The sample-pack implementation removes the previously identified `#32` content gap, pending exact-commit native evidence and ticket closure, but guarded native shutdown and clean history savepoints remain the open P0 `#55`. A consolidated candidate limitations ledger is also missing. Deterministic imported-asset fingerprints `#56` and property-block application `#53` are P1; dynamic deformed Solari geometry `#54` is a documented P2 limitation with Forward/raster fallback. |
## Deliverables ## Deliverables
| ID | Deliverable | State | Evidence or next action | | ID | Deliverable | State | Evidence or next action |
|----|-------------|-------|-------------------------| |----|-------------|-------|-------------------------|
| D1 | Versioned acceptance matrix under `docs/editor/evaluations/` | Pass | This file, version 0.2. | | D1 | Versioned acceptance matrix under `docs/editor/evaluations/` | Pass | This file, version 0.3. |
| D2 | Release-candidate representative project and reproducible validation commands | Missing | Expand the committed project/regression pack through `#32`, including terrain and physics-placement samples, then nominate an exact commit. | | D2 | Release-candidate representative project and reproducible validation commands | Partial | The committed [sample regression pack](../sample-regression-pack/) and commands exist. Nominate an exact commit, rerun from a clean LFS-hydrated checkout, and attach native results. |
| D3 | Signed milestone comment linking evidence, limitations, and exact commit | Missing | Post only after G1-G8 pass; no candidate exists yet. | | D3 | Signed milestone comment linking evidence, limitations, and exact commit | Missing | Post only after G1-G8 pass; no candidate exists yet. |
## Workflow Coverage ## Workflow Coverage
@ -55,11 +55,13 @@ another commit, a dirty worktree, or an older package do not transfer to the can
| Scene save, autosave, recovery | Implemented | Not rerun | | Scene save, autosave, recovery | Implemented | Not rerun |
| Hierarchy parenting and prefab structure | Implemented | Not rerun | | Hierarchy parenting and prefab structure | Implemented | Not rerun |
| Multi-scene composition | Implemented | Not rerun | | Multi-scene composition | Implemented | Not rerun |
| Five-area editor sample pack | Implemented with manifest, native menu, typed scene gate, and headless validation | Source evidence recorded; exact-candidate native run pending |
| Brush blockout/edit/CSG | Implemented foundation; `#37` open | Not signed off | | Brush blockout/edit/CSG | Implemented foundation; `#37` open | Not signed off |
| Material catalog and assignment | Renderer/material foundation `#51`, Material Library, and exact targeted drops accepted; optional property-block promotion is `#53` | Not rerun as one candidate | | Material catalog and assignment | Renderer/material foundation `#51`, Material Library, and exact targeted drops accepted; optional property-block promotion is `#53` | Not rerun as one candidate |
| Terrain authoring | `#22`-`#24` source/live acceptance complete; M3 closed | Pass; packaged-runtime acceptance owner-deferred | | Rendering volumes and look development | Deterministic visible rendering fixture is in the sample pack | Not rerun as one candidate |
| Physics placement | `#25` source/live acceptance complete | Pass; packaged-runtime acceptance owner-deferred | | Terrain authoring | `#22`-`#24` source/live acceptance complete; M3 closed | Feature-level live acceptance exists; not rerun as one candidate. Packaged-runtime acceptance owner-deferred. |
| Collider authoring and diagnostics | `#26` source/live acceptance complete | Pass; packaged-runtime acceptance owner-deferred | | Physics placement | `#25` source/live acceptance complete | Feature-level live acceptance exists; not rerun as one candidate. Packaged-runtime acceptance owner-deferred. |
| Collider authoring and diagnostics | `#26` source/live acceptance complete | Feature-level live acceptance exists; not rerun as one candidate. Packaged-runtime acceptance owner-deferred. |
| Animation | Implemented | Not rerun | | Animation | Implemented | Not rerun |
| Audio | Implemented | Not rerun | | Audio | Implemented | Not rerun |
| Navigation | Implemented | Not rerun | | Navigation | Implemented | Not rerun |
@ -81,6 +83,7 @@ cargo clippy --workspace --all-targets -- -D warnings
cargo clippy -p editor -p game --bins --features dev,hot-reload -- -D warnings cargo clippy -p editor -p game --bins --features dev,hot-reload -- -D warnings
cargo test --workspace cargo test --workspace
cargo validate-levels --project . cargo validate-levels --project .
cargo validate-samples --project .
cargo bake-navigation --project . --check cargo bake-navigation --project . --check
``` ```
@ -92,8 +95,9 @@ package hash verification, and packaged-runtime smoke protocol used for the cand
The final candidate must run for eight continuous hours against the representative project. The The final candidate must run for eight continuous hours against the representative project. The
record must sample process RSS, GPU memory, target-directory size, recovery generations, background record must sample process RSS, GPU memory, target-directory size, recovery generations, background
job counts, and error/warning totals at least every five minutes. Exercise scene switching, save and job counts, and error/warning totals at least every five minutes. Exercise scene switching, save and
recovery, asset refresh, material editing, brush/terrain/physics tools, animation/audio/navigation, recovery, asset refresh, all five sample-pack scenes, material editing, brush/terrain/physics tools,
PIE transitions, and build UI cancellation without replacing the candidate during the run. animation/audio/navigation, PIE transitions, and build UI cancellation without replacing the
candidate during the run.
A pass requires no unrecoverable editor failure, no lost authored work, no unbounded upward trend in A pass requires no unrecoverable editor failure, no lost authored work, no unbounded upward trend in
steady-state resource use, bounded recovery/cache behavior, and a triaged explanation for every steady-state resource use, bounded recovery/cache behavior, and a triaged explanation for every

View File

@ -0,0 +1,55 @@
# Sample Regression Pack Evaluation
**Date:** 2026-07-13
**Issue:** [Gitea #32](https://git.spacetrainclubhouse.com/Falling-Metal-Interactive/Blacksite/issues/32)
**Implementation commit:** Pending commit
**Release-candidate commit:** Not nominated
**Implementation validation:** **Pass**
**Native visual acceptance:** **Pending**
This record separates source/fixture verification from release-candidate acceptance. The committed
five-area catalog and scenes are ready for native QA, but results from the implementation worktree
do not transfer to a later candidate commit.
## Source Evidence
| Check | Result | Evidence |
|-------|--------|----------|
| Manifest contract | Pass | Schema v1 contains one ordered entry for Brush, Material, Terrain, Physics Placement, and Rendering, with stable IDs and nonempty checks. |
| Authored scene fixtures | Pass | All five scene paths exist; actors use stable IDs and each scene contains its required authored feature. |
| Project validation | Pass | `cargo validate-levels --project .` reported 78 dependencies, five known nonblocking import/platform findings, and zero blocking errors on 2026-07-13. |
| Typed no-GPU deserialization | Pass | `cargo test -p editor every_manifest_sample_typed_deserializes_without_a_gpu --lib`: one passed, zero failed, 256 filtered out. |
| Sample-pack validation | Pass | `cargo validate-samples --project .`: five ordered samples, 83 dependencies, five known nonblocking findings, zero blocking errors. |
| Clean-checkout CI | Pending | The exact candidate must hydrate LFS and pass both content gates. Current Gitea does not expose an Actions run endpoint for this repository. |
## Native Protocol
Open every row through **File > Open Sample**, not through a typed filesystem path. Capture native
screenshots only after confirming the viewport is nonblank and correctly framed.
| Sample | Required native interaction and visual result | Result |
|--------|-----------------------------------------------|--------|
| Brush Blockout | Foundation, material-face tower, and red subtractive marker are distinct; enter brush edit and cancel one edit or clip preview without leaving helpers | Pending |
| Material Lab | Concrete cube, custom sphere, instance block, and emissive panel are visible; perform one exact primitive material drop and undo | Pending |
| Terrain Authoring | Hill chunks and two material layers render; perform and cancel one sculpt stroke and one paint stroke | Pending |
| Physics Placement | Cuboid, sphere, and capsule settle on the floor; cancel restores all three, then commit/undo behaves as one transaction | Pending |
| Rendering Lab | Red/cyan/amber anchors and emissive strip render; camera traversal visibly changes vignette, fog, and exposure ownership | Pending |
For each row, inspect the status strip, Diagnostics panel, and process log for missing references,
hydration errors, render errors, helper leaks, or operator failures. Attach native images to the Gitea
issue as ordinary uploaded attachments rather than embedding repository raw URLs. A committed
original may remain beside this record through Git LFS under the shared evaluation policy.
## Release Use
A future candidate may mark this evaluation fully passed only when the exact nominated commit has:
1. Passed typed scene deserialization, `validate-levels`, and `validate-samples` from a clean,
LFS-hydrated checkout.
2. Opened all five entries through the native menu and completed the interactions above.
3. Recorded the commit, machine profile, commands, exit codes, logs, and native attachments.

View File

@ -30,9 +30,10 @@ and placement status agree about missing, invalid, stale, disabled, or trigger c
ten-second simulated-time limit stops unstable previews while ten-second simulated-time limit stops unstable previews while
leaving explicit commit and cancel available. leaving explicit commit and cancel available.
`assets/levels/physics_placement_showcase.scn.ron` is the committed acceptance fixture. Select `assets/levels/physics_placement_showcase.scn.ron` is the Physics Placement entry in the committed
**Placement Prop A**, **B**, and **C** together, start physics placement, and verify that the three [sample regression pack](sample-regression-pack.md). Select **Placement Cuboid**, **Placement
different shapes settle on **Placement Floor** as one commit/cancel transaction. Sphere**, and **Placement Capsule** together, start physics placement, and verify that the three
distinct collider cases settle on **Placement Floor** as one commit/cancel transaction.
The clock and transaction boundary are specified by The clock and transaction boundary are specified by
[ADR 0041](../adr/0041-transactional-editor-physics-placement.md). [ADR 0041](../adr/0041-transactional-editor-physics-placement.md).

View File

@ -89,7 +89,10 @@ Shipped examples: `post_fx/vignette.ron`, `post_fx/chromatic_aberration.ron`, `r
## Example level ## Example level
Open `assets/levels/rendering_showcase.scn.ron` for fog, exposure, and vignette volume examples. Open **File > Open Sample > Rendering Lab** for the committed rendering fixture. Its red sphere,
cyan cube, amber block, and emissive horizon make the adjacent vignette, fog, and dark-exposure zones
visually comparable while the volume actors exercise stable ownership, profile, and fullscreen-effect
references. See the [sample regression pack](sample-regression-pack.md) for the native QA protocol.
## Troubleshooting ## Troubleshooting

View File

@ -124,6 +124,7 @@ Phased work for the in-process editor (`crates/editor`). Status reflects the cur
| Thumbnails | Done | Textures via asset load; glTF albedo fast-path; FBX/untextured models via offscreen studio | | Thumbnails | Done | Textures via asset load; glTF albedo fast-path; FBX/untextured models via offscreen studio |
| Import settings per asset | Done | Scale/collider/LOD in registry + details pane | | Import settings per asset | Done | Scale/collider/LOD in registry + details pane |
| Prefab workflow | Done | Shared stable nested property/component/structural overrides, recursive validation, committed variant fixtures, transactional source Apply, conflict recovery, and undoable outer unpack/recursive local conversion passed headless, packaged-runtime, and live editor acceptance in Gitea #43 | | Prefab workflow | Done | Shared stable nested property/component/structural overrides, recursive validation, committed variant fixtures, transactional source Apply, conflict recovery, and undoable outer unpack/recursive local conversion passed headless, packaged-runtime, and live editor acceptance in Gitea #43 |
| Curated editor regression samples | Done | Versioned five-area manifest, dedicated brush/material labs, strengthened terrain/physics/rendering fixtures, **File > Open Sample**, typed scene coverage, and `validate-samples` release gate in Gitea #32 |
## Phase 6 — Extensibility ## Phase 6 — Extensibility
@ -150,7 +151,7 @@ with implementation sequencing in
| Milestone | Exit condition | Status | | Milestone | Exit condition | Status |
|-----------|----------------|--------| |-----------|----------------|--------|
| M6 Reliability, recovery, and project workflow | Transactional save/recovery, stable sessions, project launcher, hardened hierarchy/prefabs, multi-scene composition | Implementation complete; all six scoped issues are closed after prefab #43 passed workspace, headless, packaged-runtime, and live editor acceptance | | M6 Reliability, recovery, and project workflow | Transactional save/recovery, stable sessions, project launcher, hardened hierarchy/prefabs, multi-scene composition | Implementation complete; all six scoped issues are closed after prefab #43 passed workspace, headless, packaged-runtime, and live editor acceptance |
| M7 Content production and shipping | Build/package profiles, content release gate, animation, audio, navigation, collaborative safety | Active; #44-#48 are complete. #49 implementation and source/headless acceptance are complete, with live visual acceptance pending; final readiness gate #50 remains. Packaged acceptance is deferred until requested by the project owner. | | M7 Content production and shipping | Build/package profiles, content release gate, animation, audio, navigation, collaborative safety | Active; #44-#49 are complete after source, headless, and native acceptance. Final readiness gate #50 remains blocked by open P0 shutdown/savepoint work #55; deterministic imported-asset fingerprints #56 are P1 follow-up work. Packaged acceptance is deferred until requested by the project owner. |
Production readiness is not inferred from feature count. Gitea Production readiness is not inferred from feature count. Gitea
[`#50`](https://git.spacetrainclubhouse.com/Falling-Metal-Interactive/Blacksite/issues/50) [`#50`](https://git.spacetrainclubhouse.com/Falling-Metal-Interactive/Blacksite/issues/50)

View File

@ -0,0 +1,57 @@
# Editor Sample Regression Pack
The editor sample regression pack is the committed, deterministic first stop for content and native
viewport QA. Its source of truth is `assets/samples/editor_samples.ron`; manifest order is also the
order shown by **File > Open Sample** and by headless reports.
## Open And Exercise A Sample
1. Open **File > Open Sample** and choose the relevant area.
2. Confirm the scene opens in a normal scene tab and the viewport contains the expected anchors.
3. Perform the checks listed in the manifest and the table below.
4. Watch the status strip and Diagnostics panel for missing references, hydration failures, or
operator errors.
5. Use **Save As** before destructive experimentation. The committed samples are regular project
scenes, not disposable or read-only copies.
An invalid or unavailable manifest leaves an actionable disabled item in the menu. It must not
silently fall back to another scene.
## Pack Contents
| Area | Scene and visual composition | Representative checks |
|------|------------------------------|-----------------------|
| Brush | `brush_blockout.scn.ron`: broad concrete foundation, tall additive brush with a contrasting shared-material top face, red rotated subtractive marker | Select object and face targets; enter brush edit; exercise draw/clip/CSG preview and cancel; verify shared face material resolution |
| Material | `material_lab.scn.ron`: neutral floor, concrete cube, blue custom-surface sphere, rotated Material Instance block, cyan emissive backdrop | Inspect shared Material/Instance paths; target a primitive with a Material drop; compare standard, custom, and emissive hydration |
| Terrain | `terrain_authoring_showcase.scn.ron`: asymmetric 5x5 hill, four deterministic chunks, two painted shared-material layers | Sculpt and paint preview/cancel; inspect normalized weights and layer refs; verify generated selection and collider state |
| Physics Placement | `physics_placement_showcase.scn.ron`: cuboid, sphere, and tall capsule above a static floor | Multi-select all three props; settle; cancel and compare exact starting transforms; repeat, commit, undo, and redo as one group |
| Rendering | `rendering_showcase.scn.ron`: red sphere, cyan cube, amber block, emissive horizon, and three adjacent vignette/fog/exposure volumes | Move the camera through all zones; inspect active-volume ownership; verify profile/fullscreen-effect refs and visible transitions |
All authored sample actors have stable `ActorId` values. Generated hydration children are deliberately
absent from the committed scene documents.
## Headless Validation
Run both gates from the project root with Git LFS content hydrated:
```bash
cargo validate-levels --project .
cargo validate-samples --project .
```
`validate-levels` remains the authoritative project dependency/finding report. `validate-samples`
adds the pack contract: schema and safe paths, exactly one entry per required area, stable actor IDs,
authoring-only scene data, required area components, and nonempty operator checks. Either command
must exit nonzero on a blocking finding. Use `--json` when attaching machine-readable evidence.
## Maintenance Contract
- Keep exactly the five areas `Brush`, `Material`, `Terrain`, `PhysicsPlacement`, and `Rendering`.
- Treat manifest IDs as persistent automation keys; rename labels instead of IDs.
- Keep scene paths project-relative, inside `assets/levels/`, and free of symlink or traversal escapes.
- When a sample changes, update its manifest summary/checks and this visual-composition table in the
same change.
- Run the typed no-GPU scene test plus both validation commands before native QA.
- Record exact-commit native results under
[evaluations/sample-regression-pack/](evaluations/sample-regression-pack/). Implementation checks
from a dirty worktree do not satisfy release-candidate acceptance.

View File

@ -38,10 +38,12 @@ weight to the remaining channels. Every sample stays normalized to 255, the terr
or amber footprint previews the brush, and release records one undo entry. Escape or right-click or amber footprint previews the brush, and release records one undo entry. Escape or right-click
restores the exact pre-stroke weights or closes the idle tool. restores the exact pre-stroke weights or closes the idle tool.
`assets/levels/terrain_authoring_showcase.scn.ron` is the deterministic foundation fixture. Its 5×5 `assets/levels/terrain_authoring_showcase.scn.ron` is the Terrain entry in the deterministic
[sample regression pack](sample-regression-pack.md). Its 5×5
grid forms an asymmetric hill split into four 2×2-quad chunks. It validates chunk boundaries, grid forms an asymmetric hill split into four 2×2-quad chunks. It validates chunk boundaries,
normals, selection through generated children, collider generation, inspector state, and save normals, selection through generated children, collider generation, inspector state, and save
stripping without requiring external assets. stripping. Height geometry is inline, while its committed shared Material and Material Instance
references deliberately validate terrain dependency resolution and layer transport.
## Data And Hydration ## Data And Hydration

View File

@ -9,6 +9,11 @@ name = "validate-levels"
path = "src/validate_levels.rs" path = "src/validate_levels.rs"
required-features = ["validate-levels"] required-features = ["validate-levels"]
[[bin]]
name = "validate-samples"
path = "src/validate_samples.rs"
required-features = ["validate-samples"]
[[bin]] [[bin]]
name = "clean-target" name = "clean-target"
path = "src/clean_target.rs" path = "src/clean_target.rs"
@ -44,3 +49,4 @@ libc = "0.2"
[features] [features]
default = [] default = []
validate-levels = ["dep:scene", "dep:settings", "dep:shared"] validate-levels = ["dep:scene", "dep:settings", "dep:shared"]
validate-samples = ["validate-levels"]

View File

@ -0,0 +1,209 @@
//! Validate the canonical editor sample pack and the complete project dependency graph.
use std::path::PathBuf;
use scene::{
sample_pack::validate_editor_sample_pack, ProjectValidationReport, ValidationSeverity,
};
use serde::Serialize;
#[derive(Serialize)]
struct ValidateSamplesOutput<'a> {
manifest_path: &'a str,
samples: &'a [scene::sample_pack::EditorSampleEntry],
dependencies: &'a [scene::ProjectDependency],
findings: &'a [scene::ProjectValidationFinding],
}
fn main() {
let mut project_root = PathBuf::from(".");
let mut json = false;
let mut args = std::env::args().skip(1);
while let Some(argument) = args.next() {
match argument.as_str() {
"--json" => json = true,
"--project" => {
let Some(path) = args.next() else {
eprintln!("validate-samples: --project requires a path");
std::process::exit(2);
};
project_root = PathBuf::from(path);
}
_ => {
eprintln!("validate-samples: unknown argument `{argument}`");
std::process::exit(2);
}
}
}
let sample_validation = validate_editor_sample_pack(&project_root);
let mut report = scene::validate_project(&project_root);
merge_reports(&mut report, &sample_validation.report);
if json {
let output = ValidateSamplesOutput {
manifest_path: &sample_validation.manifest_path,
samples: &sample_validation.samples,
dependencies: &report.dependencies,
findings: &report.findings,
};
println!(
"{}",
serde_json::to_string_pretty(&output).expect("sample validation report must serialize")
);
} else {
for sample in &sample_validation.samples {
println!(
"sample: {} [{}] {} ({})",
sample.area, sample.id, sample.label, sample.scene
);
}
for finding in &report.findings {
let actor = finding
.owner_actor_id
.as_deref()
.map(|actor| format!(" actor={actor}"))
.unwrap_or_default();
eprintln!(
"{:?} [{}] {}{}: {}",
finding.severity, finding.code, finding.source_path, actor, finding.message
);
}
println!(
"validate-samples: {} samples, {} dependencies, {} findings, {} blocking errors",
sample_validation.samples.len(),
report.dependencies.len(),
report.findings.len(),
report.blocking_error_count()
);
}
if !report.is_release_ready() {
std::process::exit(1);
}
}
fn merge_reports(target: &mut ProjectValidationReport, source: &ProjectValidationReport) {
target
.dependencies
.extend(source.dependencies.iter().cloned());
target.findings.extend(source.findings.iter().cloned());
target.dependencies.sort_by(|left, right| {
(
&left.owner_path,
&left.owner_actor_id,
&left.kind,
&left.reference,
)
.cmp(&(
&right.owner_path,
&right.owner_actor_id,
&right.kind,
&right.reference,
))
});
target.dependencies.dedup();
target.findings.sort_by(|left, right| {
(
&left.source_path,
&left.owner_actor_id,
&left.code,
&left.reference,
&left.message,
severity_rank(left.severity),
&left.repair,
)
.cmp(&(
&right.source_path,
&right.owner_actor_id,
&right.code,
&right.reference,
&right.message,
severity_rank(right.severity),
&right.repair,
))
});
target.findings.dedup();
}
const fn severity_rank(severity: ValidationSeverity) -> u8 {
match severity {
ValidationSeverity::Error => 0,
ValidationSeverity::Warning => 1,
ValidationSeverity::Info => 2,
}
}
#[cfg(test)]
mod tests {
use super::*;
use scene::{ProjectDependency, ProjectValidationFinding};
#[test]
fn merged_report_is_sorted_and_deduplicated() {
let dependency = ProjectDependency {
owner_path: "assets/samples/editor_samples.ron".into(),
owner_actor_id: None,
kind: "editor_sample_scene".into(),
reference: "assets/levels/samples/brush.scn.ron".into(),
};
let finding = ProjectValidationFinding {
severity: ValidationSeverity::Error,
code: "sample_pack.scene_missing".into(),
source_path: "assets/samples/editor_samples.ron".into(),
owner_actor_id: None,
reference: Some("assets/levels/samples/brush.scn.ron".into()),
message: "missing".into(),
repair: "restore".into(),
};
let source = ProjectValidationReport {
dependencies: vec![dependency.clone()],
findings: vec![finding.clone()],
};
let mut target = source.clone();
merge_reports(&mut target, &source);
assert_eq!(target.dependencies, vec![dependency]);
assert_eq!(target.findings, vec![finding]);
}
#[test]
fn merged_json_is_deterministic_across_input_order() {
let dependency = |reference: &str| ProjectDependency {
owner_path: "assets/samples/editor_samples.ron".into(),
owner_actor_id: None,
kind: "editor_sample_scene".into(),
reference: reference.into(),
};
let finding = |code: &str, repair: &str| ProjectValidationFinding {
severity: ValidationSeverity::Error,
code: code.into(),
source_path: "assets/samples/editor_samples.ron".into(),
owner_actor_id: None,
reference: Some(code.into()),
message: code.into(),
repair: repair.into(),
};
let first = ProjectValidationReport {
dependencies: vec![dependency("assets/levels/z.scn.ron")],
findings: vec![finding("sample_pack.same", "repair-z")],
};
let second = ProjectValidationReport {
dependencies: vec![dependency("assets/levels/a.scn.ron")],
findings: vec![finding("sample_pack.same", "repair-a")],
};
let mut left = first.clone();
merge_reports(&mut left, &second);
let mut right = second;
merge_reports(&mut right, &first);
let left_json = serde_json::to_string(&left).unwrap();
let right_json = serde_json::to_string(&right).unwrap();
assert_eq!(left, right);
assert_eq!(left_json, right_json);
assert_eq!(left.findings[0].repair, "repair-a");
assert_eq!(left.findings[1].repair, "repair-z");
}
}