From 06010c59228655e2d3de6be283b1af0fd6ba9aae Mon Sep 17 00:00:00 2001 From: Rbanh Date: Mon, 13 Jul 2026 01:52:58 -0400 Subject: [PATCH] Add editor sample regression pack --- .cargo/config.toml | 1 + ..._sample_regression_pack_2026-07-13.plan.md | 66 + .github/workflows/ci.yml | 8 + README.md | 4 +- .../levels/physics_placement_showcase.scn.ron | 14 +- assets/levels/rendering_showcase.scn.ron | 297 ++++- assets/levels/samples/brush_blockout.scn.ron | 332 +++++ assets/levels/samples/material_lab.scn.ron | 189 +++ assets/samples/editor_samples.ron | 65 + crates/editor/src/lib.rs | 3 + crates/editor/src/project/mod.rs | 1 + crates/editor/src/project/samples.rs | 208 ++++ crates/editor/src/scene/scene_io.rs | 347 +++++- crates/editor/src/ui/menu.rs | 80 ++ crates/scene/src/lib.rs | 1 + crates/scene/src/project_validation.rs | 234 +++- crates/scene/src/sample_pack.rs | 1101 +++++++++++++++++ docs/README.md | 3 + ...uthoritative-project-content-validation.md | 16 + docs/editor/README.md | 3 + docs/editor/debt-audit.md | 2 + .../evaluations/physics-placement/README.md | 13 +- .../production-readiness/README.md | 32 +- .../sample-regression-pack/README.md | 55 + docs/editor/physics-placement.md | 7 +- docs/editor/rendering.md | 5 +- docs/editor/roadmap.md | 3 +- docs/editor/sample-regression-pack.md | 57 + docs/editor/terrain.md | 6 +- xtask/Cargo.toml | 6 + xtask/src/validate_samples.rs | 209 ++++ 31 files changed, 3258 insertions(+), 110 deletions(-) create mode 100644 .cursor/plans/editor_sample_regression_pack_2026-07-13.plan.md create mode 100644 assets/levels/samples/brush_blockout.scn.ron create mode 100644 assets/levels/samples/material_lab.scn.ron create mode 100644 assets/samples/editor_samples.ron create mode 100644 crates/editor/src/project/samples.rs create mode 100644 crates/scene/src/sample_pack.rs create mode 100644 docs/editor/evaluations/sample-regression-pack/README.md create mode 100644 docs/editor/sample-regression-pack.md create mode 100644 xtask/src/validate_samples.rs diff --git a/.cargo/config.toml b/.cargo/config.toml index d6822a4..1750233 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -7,6 +7,7 @@ rustflags = ["-C", "link-arg=-fuse-ld=mold"] [alias] clean-target = "run -p xtask --bin clean-target --" validate-levels = "run -p xtask --features validate-levels --bin validate-levels --" +validate-samples = "run -p xtask --features validate-samples --bin validate-samples --" bake-navigation = "run -p xtask --features validate-levels --bin bake-navigation --" package-project = "run -p xtask --features validate-levels --bin package-project --" upgrade-project = "run -p xtask --features validate-levels --bin upgrade-project --" diff --git a/.cursor/plans/editor_sample_regression_pack_2026-07-13.plan.md b/.cursor/plans/editor_sample_regression_pack_2026-07-13.plan.md new file mode 100644 index 0000000..505e937 --- /dev/null +++ b/.cursor/plans/editor_sample_regression_pack_2026-07-13.plan.md @@ -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. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 93bb078..0c46e15 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,6 +17,11 @@ jobs: steps: - name: Checkout uses: actions/checkout@v4 + with: + lfs: true + + - name: Verify Git LFS checkout + run: git lfs fsck - name: Install Linux dependencies run: | @@ -55,6 +60,9 @@ jobs: - name: Validate level scenes run: cargo validate-levels + - name: Validate editor sample pack + run: cargo validate-samples + - name: Clippy launch feature matrix run: cargo clippy -p editor -p game --bins --features dev,hot-reload -- -D warnings diff --git a/README.md b/README.md index 85a4a5d..342ca7e 100644 --- a/README.md +++ b/README.md @@ -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 test --workspace cargo validate-levels +cargo validate-samples cargo bake-navigation --project . --check # Validate one artifact without opening a game window cargo run -p game -- --validate-navigation assets/navigation/generated/navigation_showcase_humanoid.nav.ron @@ -164,7 +165,7 @@ deep-stale variants. | Drag actor between rows / onto Scene Root (Hierarchy, Manual sort) | Reorder siblings / unparent to the root | | Hierarchy lock | Excludes the actor from selection, gizmos, multi-drag, structural drops, and mutating context actions | | Hierarchy context | Group selection, create authored local children below linked prefab roots, remove/reparent generated members through same-layer structural overrides, or unparent | -| File menu | New, 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 | | 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 | @@ -417,6 +418,7 @@ crates/ - [x] Cursor/VSCode workspace tasks + launch configs - [x] Opaque Wayland window launch defaults + SDR/HDR runtime toggle - [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] 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)) diff --git a/assets/levels/physics_placement_showcase.scn.ron b/assets/levels/physics_placement_showcase.scn.ron index 375648d..6803934 100644 --- a/assets/levels/physics_placement_showcase.scn.ron +++ b/assets/levels/physics_placement_showcase.scn.ron @@ -35,7 +35,7 @@ ), }), 2: (components: { - "bevy_ecs::name::Name": "Placement Prop A", + "bevy_ecs::name::Name": "Placement Cuboid", "bevy_transform::components::transform::Transform": ( translation: (-2.0, 3.0, 0.0), rotation: (0.1305262, 0.0, 0.0, 0.9914449), @@ -70,7 +70,7 @@ ), }), 3: (components: { - "bevy_ecs::name::Name": "Placement Prop B", + "bevy_ecs::name::Name": "Placement Sphere", "bevy_transform::components::transform::Transform": ( translation: (0.0, 5.0, 0.0), rotation: (0.0, 0.2164396, 0.0, 0.976296), @@ -96,16 +96,16 @@ parameters: [], 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::ColliderDesc": ( enabled: true, is_trigger: false, - shape: Cuboid(x_length: 1.0, y_length: 1.6, z_length: 1.0), + shape: Sphere(radius: 0.8), ), }), 4: (components: { - "bevy_ecs::name::Name": "Placement Prop C", + "bevy_ecs::name::Name": "Placement Capsule", "bevy_transform::components::transform::Transform": ( translation: (2.0, 7.0, 0.0), rotation: (0.092296, 0.092296, -0.008077, 0.991405), @@ -131,12 +131,12 @@ parameters: [], 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::ColliderDesc": ( enabled: true, 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: { diff --git a/assets/levels/rendering_showcase.scn.ron b/assets/levels/rendering_showcase.scn.ron index 54be998..1e0194a 100644 --- a/assets/levels/rendering_showcase.scn.ron +++ b/assets/levels/rendering_showcase.scn.ron @@ -1,68 +1,241 @@ -(schema_version: 4,resources: { - }, - entities: { - 4294968001: (components: { - "bevy_ecs::name::Name": "Fog Volume", - "bevy_transform::components::transform::Transform": ( - translation: (0.0, 2.0, 0.0), - rotation: (0.0, 0.0, 0.0, 1.0), - scale: (1.0, 1.0, 1.0), +(schema_version: 4, resources: {}, entities: { + 1: (components: { + "bevy_ecs::name::Name": "Rendering Lab Floor", + "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::ActorKind": PostProcessVolume, - "shared::components::LevelObject": (), - "shared::components::PostProcessVolumeDesc": ( - half_extents: (6.0, 3.0, 6.0), - priority: 0, - blend_distance: 2.0, - overrides: ( - fog_density: Some(0.004), - fog_color: Some((r: 0.5, g: 0.55, b: 0.65, a: 1.0)), - ), - fullscreen_effect: None, - profile: None, - label: Some("Foggy courtyard"), + "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: [], ), - }), - 4294968002: (components: { - "bevy_ecs::name::Name": "Dark Exposure Volume", - "bevy_transform::components::transform::Transform": ( - translation: (14.0, 2.0, 0.0), - rotation: (0.0, 0.0, 0.0, 1.0), - scale: (1.0, 1.0, 1.0), + }), + 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::ActorKind": PostProcessVolume, - "shared::components::LevelObject": (), - "shared::components::PostProcessVolumeDesc": ( - half_extents: (4.0, 2.5, 4.0), - priority: 5, - blend_distance: 1.5, - overrides: ( - exposure_ev100: Some(9.5), - bloom: Some(false), - ), - fullscreen_effect: None, - profile: Some("assets/rendering_profiles/cave_dark.ron"), - label: Some("Cave mouth"), + "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: [], ), - }), - 4294968003: (components: { - "bevy_ecs::name::Name": "Vignette FX Volume", - "bevy_transform::components::transform::Transform": ( - translation: (-12.0, 2.0, 0.0), - rotation: (0.0, 0.0, 0.0, 1.0), - scale: (1.0, 1.0, 1.0), + }), + 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::ActorKind": PostProcessVolume, - "shared::components::LevelObject": (), - "shared::components::PostProcessVolumeDesc": ( - half_extents: (5.0, 2.0, 5.0), - priority: 2, - blend_distance: 2.0, - overrides: (), - fullscreen_effect: Some("assets/post_fx/vignette.ron"), - profile: None, - label: Some("Vignette demo"), + "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: [], ), - }), - }, -) \ No newline at end of file + }), + 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_transform::components::transform::Transform": ( + translation: (0.0, 2.0, 0.0), + rotation: (0.0, 0.0, 0.0, 1.0), + scale: (1.0, 1.0, 1.0), + ), + "shared::components::ActorId": ("rendering-showcase-fog-volume"), + "shared::components::ActorKind": PostProcessVolume, + "shared::components::EditorVisibility": (visible: true), + "shared::components::HierarchySiblingIndex": (5), + "shared::components::LevelObject": (), + "shared::components::PostProcessVolumeDesc": ( + half_extents: (6.0, 3.0, 6.0), + priority: 0, + blend_distance: 2.0, + overrides: ( + fog_density: Some(0.004), + fog_color: Some((r: 0.5, g: 0.55, b: 0.65, a: 1.0)), + ), + fullscreen_effect: None, + profile: None, + label: Some("Foggy courtyard"), + ), + }), + 7: (components: { + "bevy_ecs::name::Name": "Dark Exposure Volume", + "bevy_transform::components::transform::Transform": ( + translation: (14.0, 2.0, 0.0), + rotation: (0.0, 0.0, 0.0, 1.0), + scale: (1.0, 1.0, 1.0), + ), + "shared::components::ActorId": ("rendering-showcase-exposure-volume"), + "shared::components::ActorKind": PostProcessVolume, + "shared::components::EditorVisibility": (visible: true), + "shared::components::HierarchySiblingIndex": (6), + "shared::components::LevelObject": (), + "shared::components::PostProcessVolumeDesc": ( + half_extents: (4.0, 2.5, 4.0), + priority: 5, + blend_distance: 1.5, + overrides: ( + exposure_ev100: Some(9.5), + bloom: Some(false), + ), + fullscreen_effect: None, + profile: Some("assets/rendering_profiles/cave_dark.ron"), + label: Some("Cave mouth"), + ), + }), + 8: (components: { + "bevy_ecs::name::Name": "Vignette FX Volume", + "bevy_transform::components::transform::Transform": ( + translation: (-12.0, 2.0, 0.0), + rotation: (0.0, 0.0, 0.0, 1.0), + scale: (1.0, 1.0, 1.0), + ), + "shared::components::ActorId": ("rendering-showcase-vignette-volume"), + "shared::components::ActorKind": PostProcessVolume, + "shared::components::EditorVisibility": (visible: true), + "shared::components::HierarchySiblingIndex": (7), + "shared::components::LevelObject": (), + "shared::components::PostProcessVolumeDesc": ( + half_extents: (5.0, 2.0, 5.0), + priority: 2, + blend_distance: 2.0, + overrides: (), + fullscreen_effect: Some("assets/post_fx/vignette.ron"), + profile: None, + 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, + ), + }), +}) diff --git a/assets/levels/samples/brush_blockout.scn.ron b/assets/levels/samples/brush_blockout.scn.ron new file mode 100644 index 0000000..da24239 --- /dev/null +++ b/assets/levels/samples/brush_blockout.scn.ron @@ -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, + ), + }), +}) diff --git a/assets/levels/samples/material_lab.scn.ron b/assets/levels/samples/material_lab.scn.ron new file mode 100644 index 0000000..7911031 --- /dev/null +++ b/assets/levels/samples/material_lab.scn.ron @@ -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, + ), + }), +}) diff --git a/assets/samples/editor_samples.ron b/assets/samples/editor_samples.ron new file mode 100644 index 0000000..4ab8ae6 --- /dev/null +++ b/assets/samples/editor_samples.ron @@ -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", + ], + ), + ], +) diff --git a/crates/editor/src/lib.rs b/crates/editor/src/lib.rs index 46adde2..c3ca58c 100644 --- a/crates/editor/src/lib.rs +++ b/crates/editor/src/lib.rs @@ -27,6 +27,7 @@ pub use project::diagnostics_bundle; pub use project::launcher; pub use project::native_dialog; pub use project::project_io; +pub use project::samples; pub use project::session; pub use project::settings_ui; pub use scene::scene_io; @@ -74,6 +75,7 @@ use play::audio_preview::AudioPreviewPlugin; use play::PlaySessionPlugin; use project::collaboration::CollaborationPlugin; use project::native_dialog::NativeDialogPlugin; +use project::samples::SampleCatalogPlugin; use project_io::ProjectIoPlugin; use render_view::RenderViewPlugin; use scene_io::SceneIoPlugin; @@ -98,6 +100,7 @@ impl PluginGroup for EditorPluginGroup { let group = PluginGroupBuilder::start::() .add(EditorInfraPlugin) .add(ProjectIoPlugin) + .add(SampleCatalogPlugin) .add(NativeDialogPlugin) .add(scene_schema::SceneSchemaPlugin) .add(net_editor::NetEditorPlugin) diff --git a/crates/editor/src/project/mod.rs b/crates/editor/src/project/mod.rs index f95e14c..07ca68e 100644 --- a/crates/editor/src/project/mod.rs +++ b/crates/editor/src/project/mod.rs @@ -5,5 +5,6 @@ pub mod diagnostics_bundle; pub mod launcher; pub mod native_dialog; pub mod project_io; +pub mod samples; pub mod session; pub mod settings_ui; diff --git a/crates/editor/src/project/samples.rs b/crates/editor/src/project/samples.rs new file mode 100644 index 0000000..c6698c7 --- /dev/null +++ b/crates/editor/src/project/samples.rs @@ -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, +} + +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, + unavailable_reason: Option, +} + +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) -> Self { + Self { + entries, + unavailable_reason: None, + } + } + + fn available(samples: Vec) -> Self { + Self { + entries: samples.into_iter().map(catalog_entry).collect(), + unavailable_reason: None, + } + } + + fn unavailable(reason: impl Into) -> 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::().root); + world.insert_resource(load_catalog(&project_root)); +} + +pub struct SampleCatalogPlugin; + +impl Plugin for SampleCatalogPlugin { + fn build(&self, app: &mut App) { + app.init_resource::() + .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!["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(); + } +} diff --git a/crates/editor/src/scene/scene_io.rs b/crates/editor/src/scene/scene_io.rs index a44c88d..a52c586 100644 --- a/crates/editor/src/scene/scene_io.rs +++ b/crates/editor/src/scene/scene_io.rs @@ -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::native_dialog::NativeDialogBroker; use crate::project::collaboration::{publish_authored_file, FileSnapshot, FileWriteIntent}; +use crate::project::samples::SampleCatalog; use crate::scene::recovery::{ default_state_root, discard_recovery_snapshots, latest_recovery_snapshot, write_recovery_snapshot, @@ -49,6 +50,7 @@ pub enum SceneIoRequest { SaveRecoveryCopyAs, DiscardRecovery, OpenRecent(usize), + OpenSample(String), OpenPath(PathBuf), SwitchTab(usize), CloseTab(usize), @@ -313,6 +315,7 @@ fn process_scene_io_requests(world: &mut World) { SceneIoRequest::SaveRecoveryCopyAs => save_recovery_copy_with_dialog(world), SceneIoRequest::DiscardRecovery => discard_recovery(world), SceneIoRequest::OpenRecent(index) => open_recent(world, index), + SceneIoRequest::OpenSample(stable_id) => open_sample(world, &stable_id), SceneIoRequest::OpenPath(path) => open_path(world, path), SceneIoRequest::SwitchTab(index) => switch_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) } +fn open_sample(world: &mut World, stable_id: &str) -> String { + let catalog_state = { + let catalog = world.resource::(); + ( + 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 { - if let Some(index) = world - .resource::() - .tabs - .iter() - .position(|tab| tab.path.as_ref() == Some(&path)) - { + let project_root = PathBuf::from( + world + .resource::() + .root + .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::().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::().tabs[index].path.clone(); + if let Some(established_path) = established_path { + remember_path(world, established_path); + } return switch_scene_tab(world, index); } 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) { - let mut io = world.resource_mut::(); - io.recent_paths.retain(|recent| recent != &path); - io.recent_paths.insert(0, path.clone()); - io.recent_paths.truncate(8); + let configured_root = PathBuf::from( + world + .resource::() + .root + .clone(), + ); + let project_root = configured_root.canonicalize().unwrap_or(configured_root); + { + let mut io = world.resource_mut::(); + io.recent_paths + .retain(|recent| !scene_paths_share_identity(&project_root, recent, &path)); + io.recent_paths.insert(0, path.clone()); + io.recent_paths.truncate(8); + } if world.contains_resource::() { - let path_label = path.display().to_string(); let mut prefs = world.resource_mut::(); - 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) { warn!("Failed to save editor preferences: {error}"); } @@ -3168,6 +3239,258 @@ mod tests { 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::(); + world.insert_resource(SampleCatalog::available_entries(vec![known])); + let original_tab_id = world.resource::().tabs[0].id; + + let status = open_sample(&mut world, "missing"); + + assert_eq!(status, "Open sample failed: unknown sample ID `missing`"); + let io = world.resource::(); + 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::(); + world.insert_resource(crate::project_io::ProjectWorkspace { + root: root.display().to_string(), + ..Default::default() + }); + { + let mut io = world.resource_mut::(); + 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::(); + 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::(); + world.insert_resource(crate::project_io::ProjectWorkspace { + root: root.display().to_string(), + ..Default::default() + }); + { + let mut io = world.resource_mut::(); + 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::(); + 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::(); + world.insert_resource(crate::project_io::ProjectWorkspace { + root: root.display().to_string(), + ..Default::default() + }); + world.insert_resource(bookmarks); + { + let mut io = world.resource_mut::(); + 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::() + .active_path + .clone() + .expect("active path must remain established"); + assert_eq!(active_path, absolute); + assert_eq!( + world.resource::().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::(), + &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::::default()) + .init_asset::() + .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] fn scene_io_event_log_is_bounded_and_classifies_failures() { let mut io = SceneIo::default(); diff --git a/crates/editor/src/ui/menu.rs b/crates/editor/src/ui/menu.rs index 822f039..efa409d 100644 --- a/crates/editor/src/ui/menu.rs +++ b/crates/editor/src/ui/menu.rs @@ -6,6 +6,7 @@ use bevy_inspector_egui::bevy_inspector::hierarchy::SelectedEntities; use egui_dock::DockState; use crate::history::{apply_command_redo, apply_command_undo, EditorHistory}; +use crate::project::samples::{SampleCatalog, SampleCatalogEntry}; use crate::scene_io::{SceneIo, SceneIoRequest}; use crate::settings_ui::{open_project_settings_panel, ProjectSettingsPanel}; use crate::state::{EditorMode, PlayPaused}; @@ -19,6 +20,19 @@ use super::layout::reset_and_save_layout; use super::widgets::menu_item; use super::EditorTab; +#[derive(Debug, Clone, PartialEq, Eq)] +enum SampleMenuModel { + Available(Vec), + 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( world: &mut World, root_ui: &mut egui::Ui, @@ -37,6 +51,25 @@ pub fn top_menu_bar( world.resource_mut::().request = Some(SceneIoRequest::Open); ui.close(); } + let sample_menu = sample_menu_model(world.resource::()); + 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::().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() { world.resource_mut::().request = Some(SceneIoRequest::Save); 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!["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()) + ); + } +} diff --git a/crates/scene/src/lib.rs b/crates/scene/src/lib.rs index c19850f..022f39f 100644 --- a/crates/scene/src/lib.rs +++ b/crates/scene/src/lib.rs @@ -9,6 +9,7 @@ mod migrate; pub mod navigation; mod prefab; mod project_validation; +pub mod sample_pack; mod upgrade; use std::path::Path; diff --git a/crates/scene/src/project_validation.rs b/crates/scene/src/project_validation.rs index 012ef83..cd1c86f 100644 --- a/crates/scene/src/project_validation.rs +++ b/crates/scene/src/project_validation.rs @@ -13,7 +13,7 @@ use shared::{ AudioSourceDesc, BrushDesc, ColliderDesc, ColliderShapeDesc, EditorAssetRef, MaterialAsset, MaterialDesc, MaterialInstanceAsset, MaterialOverride, ModelRef, PostProcessEffectAsset, 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, NAVIGATION_GENERATED_ARTIFACT_DIRECTORY, }; @@ -25,6 +25,7 @@ const MATERIAL_DESC: &str = "shared::components::MaterialDesc"; const MATERIAL_OVERRIDE: &str = "shared::components::MaterialOverride"; const STATIC_MESH_RENDERER: &str = "shared::components::StaticMeshRenderer"; const BRUSH_DESC: &str = "shared::components::BrushDesc"; +const TERRAIN_DESC: &str = "shared::components::TerrainDesc"; const COLLIDER_DESC: &str = "shared::components::ColliderDesc"; const POST_PROCESS_VOLUME: &str = "shared::components::PostProcessVolumeDesc"; const PREFAB_INSTANCE: &str = "shared::components::PrefabInstance"; @@ -1905,6 +1906,53 @@ fn collect_component_references( } } } + TERRAIN_DESC => match ron::from_str::(&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( project_root, 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( project_root: &Path, report: &mut ProjectValidationReport, @@ -3888,6 +3994,132 @@ mod tests { 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::>(); + + 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::>(); + + 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] fn project_default_level_must_be_in_the_audited_levels_tree() { let root = fixture_root(); diff --git a/crates/scene/src/sample_pack.rs b/crates/scene/src/sample_pack.rs new file mode 100644 index 0000000..d2375af --- /dev/null +++ b/crates/scene/src/sample_pack.rs @@ -0,0 +1,1101 @@ +//! Canonical editor sample-pack manifest and validation. + +use std::collections::{BTreeMap, BTreeSet}; +use std::fmt; +use std::path::{Component, Path, PathBuf}; + +use serde::{Deserialize, Serialize}; +use shared::{ + ActorKind, BrushDesc, ColliderDesc, EditorVisibility, HierarchySiblingIndex, LevelObject, + LightDesc, MaterialDesc, PostProcessVolumeDesc, Primitive, RigidBodyDesc, TerrainDesc, + COMPONENT_BRUSH_DESC, COMPONENT_COLLIDER_DESC, COMPONENT_LIGHT_DESC, COMPONENT_MATERIAL_DESC, + COMPONENT_POST_PROCESS_VOLUME, COMPONENT_PRIMITIVE, COMPONENT_RIGID_BODY_DESC, + COMPONENT_TERRAIN_DESC, +}; + +use crate::{ + document::{SceneDocument, TRANSFORM_COMPONENT}, + ProjectDependency, ProjectValidationFinding, ProjectValidationReport, ValidationSeverity, +}; + +pub const EDITOR_SAMPLE_MANIFEST_PATH: &str = "assets/samples/editor_samples.ron"; +pub const EDITOR_SAMPLE_SCHEMA_VERSION: u32 = 1; + +const LEVEL_OBJECT_COMPONENT: &str = "shared::components::LevelObject"; +const ACTOR_KIND_COMPONENT: &str = "shared::components::ActorKind"; +const EDITOR_VISIBILITY_COMPONENT: &str = "shared::components::EditorVisibility"; +const HIERARCHY_SIBLING_INDEX_COMPONENT: &str = "shared::components::HierarchySiblingIndex"; +const NAME_COMPONENT: &str = "bevy_ecs::name::Name"; +const REQUIRED_ACTOR_BASELINE: [(&str, &str); 3] = [ + (LEVEL_OBJECT_COMPONENT, "LevelObject"), + (TRANSFORM_COMPONENT, "Transform"), + (ACTOR_KIND_COMPONENT, "ActorKind"), +]; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +pub enum EditorSampleArea { + Brush, + Material, + Terrain, + PhysicsPlacement, + Rendering, +} + +impl EditorSampleArea { + pub const ALL: [Self; 5] = [ + Self::Brush, + Self::Material, + Self::Terrain, + Self::PhysicsPlacement, + Self::Rendering, + ]; + + pub const fn as_str(self) -> &'static str { + match self { + Self::Brush => "Brush", + Self::Material => "Material", + Self::Terrain => "Terrain", + Self::PhysicsPlacement => "Physics Placement", + Self::Rendering => "Rendering", + } + } + + fn required_components(self) -> &'static [&'static str] { + match self { + Self::Brush => &[COMPONENT_BRUSH_DESC], + Self::Material => &[COMPONENT_MATERIAL_DESC], + Self::Terrain => &[COMPONENT_TERRAIN_DESC], + Self::PhysicsPlacement => &[COMPONENT_RIGID_BODY_DESC, COMPONENT_COLLIDER_DESC], + Self::Rendering => &[COMPONENT_POST_PROCESS_VOLUME], + } + } +} + +impl fmt::Display for EditorSampleArea { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(self.as_str()) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct EditorSampleEntry { + pub id: String, + pub label: String, + pub area: EditorSampleArea, + pub scene: String, + pub summary: String, + pub checks: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct EditorSampleManifest { + pub schema_version: u32, + pub samples: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct EditorSamplePackValidation { + pub manifest_path: String, + pub samples: Vec, + pub report: ProjectValidationReport, +} + +impl EditorSamplePackValidation { + pub fn is_release_ready(&self) -> bool { + self.report.is_release_ready() + } +} + +pub fn load_editor_sample_manifest(project_root: &Path) -> Result { + let relative_path = Path::new(EDITOR_SAMPLE_MANIFEST_PATH); + let path = project_root.join(relative_path); + if path_contains_symlink(project_root, relative_path) { + return Err(format!( + "could not read {}: manifest path contains a symlink", + path.display() + )); + } + let metadata = std::fs::symlink_metadata(&path) + .map_err(|error| format!("could not read {}: {error}", path.display()))?; + if metadata.file_type().is_symlink() || !metadata.is_file() { + return Err(format!( + "could not read {}: manifest is not a regular file", + path.display() + )); + } + let text = std::fs::read_to_string(&path) + .map_err(|error| format!("could not read {}: {error}", path.display()))?; + ron::from_str(&text).map_err(|error| format!("could not parse {}: {error}", path.display())) +} + +pub fn validate_editor_sample_pack(project_root: &Path) -> EditorSamplePackValidation { + let mut validation = EditorSamplePackValidation { + manifest_path: EDITOR_SAMPLE_MANIFEST_PATH.into(), + samples: Vec::new(), + report: ProjectValidationReport::default(), + }; + let manifest_relative_path = Path::new(EDITOR_SAMPLE_MANIFEST_PATH); + let manifest_path = project_root.join(manifest_relative_path); + if path_contains_symlink(project_root, manifest_relative_path) { + push_finding( + &mut validation.report, + "sample_pack.manifest_not_regular", + None, + Some(EDITOR_SAMPLE_MANIFEST_PATH), + "editor sample manifest or one of its parent directories is a symlink", + "Replace the manifest path with regular project directories and a regular RON file.", + ); + return validation; + } + match std::fs::symlink_metadata(&manifest_path) { + Ok(metadata) if metadata.file_type().is_symlink() || !metadata.is_file() => { + push_finding( + &mut validation.report, + "sample_pack.manifest_not_regular", + None, + Some(EDITOR_SAMPLE_MANIFEST_PATH), + "editor sample manifest is not a regular project file", + "Replace it with a regular RON file at the conventional manifest path.", + ); + return validation; + } + Err(error) => { + push_finding( + &mut validation.report, + "sample_pack.manifest_missing", + None, + Some(EDITOR_SAMPLE_MANIFEST_PATH), + &format!("editor sample manifest could not be read: {error}"), + "Create the schema-v1 editor sample manifest at the conventional path.", + ); + return validation; + } + Ok(_) => {} + } + + let manifest = match load_editor_sample_manifest(project_root) { + Ok(manifest) => manifest, + Err(error) => { + push_finding( + &mut validation.report, + "sample_pack.manifest_invalid", + None, + Some(EDITOR_SAMPLE_MANIFEST_PATH), + &error, + "Repair the manifest RON and retain every required schema-v1 field.", + ); + return validation; + } + }; + validation.samples = manifest.samples; + + if manifest.schema_version != EDITOR_SAMPLE_SCHEMA_VERSION { + push_finding( + &mut validation.report, + "sample_pack.incompatible_schema", + None, + Some(&manifest.schema_version.to_string()), + &format!( + "editor sample schema {} is unsupported; expected {}", + manifest.schema_version, EDITOR_SAMPLE_SCHEMA_VERSION + ), + "Migrate the sample manifest with the current editor before release validation.", + ); + } + + let mut ids = BTreeSet::new(); + let mut scenes = BTreeSet::new(); + let mut areas: BTreeMap> = BTreeMap::new(); + for sample in &validation.samples { + areas + .entry(sample.area) + .or_default() + .push(sample.id.clone()); + validate_entry_fields(sample, &mut ids, &mut scenes, &mut validation.report); + validate_entry_scene(project_root, sample, &mut validation.report); + } + for area in EditorSampleArea::ALL { + match areas.get(&area).map(Vec::as_slice) { + Some([_]) => {} + Some(ids) => push_finding( + &mut validation.report, + "sample_pack.duplicate_area", + None, + Some(area.as_str()), + &format!( + "sample area {area} must have exactly one entry; found {} ({})", + ids.len(), + ids.join(", ") + ), + "Keep one stable manifest entry for this sample area.", + ), + None => push_finding( + &mut validation.report, + "sample_pack.missing_area", + None, + Some(area.as_str()), + &format!("sample area {area} has no manifest entry"), + "Add one documented sample scene for this required area.", + ), + } + } + + normalize_report(&mut validation.report); + validation +} + +fn validate_entry_fields( + sample: &EditorSampleEntry, + ids: &mut BTreeSet, + scenes: &mut BTreeSet, + report: &mut ProjectValidationReport, +) { + if !stable_id(&sample.id) { + push_finding( + report, + "sample_pack.invalid_id", + Some(&sample.id), + Some(&sample.id), + "sample ID must start with a lowercase ASCII letter or digit and contain only lowercase ASCII letters, digits, '.', '-', or '_'", + "Choose one stable lowercase identifier and do not derive it from a display label.", + ); + } else if !ids.insert(sample.id.clone()) { + push_finding( + report, + "sample_pack.duplicate_id", + Some(&sample.id), + Some(&sample.id), + "sample ID appears more than once", + "Give every sample one unique stable ID.", + ); + } + validate_required_text(report, sample, "label", &sample.label); + validate_required_text(report, sample, "summary", &sample.summary); + if sample.checks.is_empty() || sample.checks.iter().any(|check| !clean_text(check)) { + push_finding( + report, + "sample_pack.invalid_checks", + Some(&sample.id), + Some(&sample.scene), + "sample checks must contain at least one nonempty, whitespace-normalized instruction", + "Record concise deterministic checks for the sample's manual regression workflow.", + ); + } + if !scenes.insert(sample.scene.clone()) { + push_finding( + report, + "sample_pack.duplicate_scene", + Some(&sample.id), + Some(&sample.scene), + "sample scene appears in more than one manifest entry", + "Use one canonical area and manifest entry for each sample scene.", + ); + } +} + +fn validate_required_text( + report: &mut ProjectValidationReport, + sample: &EditorSampleEntry, + field: &str, + value: &str, +) { + if !clean_text(value) { + push_finding( + report, + &format!("sample_pack.invalid_{field}"), + Some(&sample.id), + Some(&sample.scene), + &format!("sample {field} must be nonempty and have no surrounding whitespace"), + &format!("Provide one stable, human-readable sample {field}."), + ); + } +} + +fn validate_entry_scene( + project_root: &Path, + sample: &EditorSampleEntry, + report: &mut ProjectValidationReport, +) { + if !safe_scene_reference(&sample.scene) { + push_finding( + report, + "sample_pack.unsafe_scene_path", + Some(&sample.id), + Some(&sample.scene), + "sample scene must be a project-relative .scn.ron file below assets/levels/", + "Move the scene below assets/levels/ and use a normalized project-relative path.", + ); + return; + } + report.dependencies.push(ProjectDependency { + owner_path: EDITOR_SAMPLE_MANIFEST_PATH.into(), + owner_actor_id: None, + kind: "editor_sample_scene".into(), + reference: sample.scene.clone(), + }); + + let path = project_root.join(&sample.scene); + let metadata = match std::fs::symlink_metadata(&path) { + Ok(metadata) => metadata, + Err(error) => { + push_finding( + report, + "sample_pack.scene_missing", + Some(&sample.id), + Some(&sample.scene), + &format!("sample scene is missing or unreadable: {error}"), + "Restore the regular scene file or update the manifest entry.", + ); + return; + } + }; + if metadata.file_type().is_symlink() || !metadata.is_file() { + push_finding( + report, + "sample_pack.scene_not_regular", + Some(&sample.id), + Some(&sample.scene), + "sample scene is not a regular file", + "Replace symlinks and special files with regular project scene content.", + ); + return; + } + if !path_stays_within_project(project_root, &path) + || path_contains_symlink(project_root, Path::new(&sample.scene)) + { + push_finding( + report, + "sample_pack.unsafe_scene_path", + Some(&sample.id), + Some(&sample.scene), + "sample scene resolves through a symlink or outside the project root", + "Keep every sample and parent directory as regular project content.", + ); + return; + } + + let text = match std::fs::read_to_string(&path) { + Ok(text) => text, + Err(error) => { + push_finding( + report, + "sample_pack.scene_unreadable", + Some(&sample.id), + Some(&sample.scene), + &format!("sample scene could not be read: {error}"), + "Restore readable UTF-8 scene content.", + ); + return; + } + }; + let document = match SceneDocument::from_ron_text(&text) { + Ok(document) => document, + Err(error) => { + push_finding( + report, + "sample_pack.scene_invalid", + Some(&sample.id), + Some(&sample.scene), + &format!("sample scene failed document validation: {error}"), + "Open the sample with the current editor and repair its authored scene data.", + ); + return; + } + }; + validate_actor_ids(sample, &document, report); + let required = sample.area.required_components(); + let mut present = BTreeSet::new(); + for entity in &document.entities { + let actor_id = entity.actor_id.as_deref().unwrap_or(""); + for component in &entity.components { + let required_for_area = required.contains(&component.type_name.as_str()); + if required_for_area { + present.insert(component.type_name.as_str()); + } + if let Some(Err(error)) = + parse_recognized_component(&component.type_name, &component.ron) + { + push_finding( + report, + if required_for_area { + "sample_pack.area_contract_malformed" + } else { + "sample_pack.authored_component_malformed" + }, + Some(&sample.id), + Some(&sample.scene), + &format!( + "{} payload on actor `{actor_id}` is malformed: {error}", + component.type_name + ), + "Remove and recreate the malformed component with the current editor.", + ); + } + } + let missing_baseline = REQUIRED_ACTOR_BASELINE + .iter() + .filter(|(type_name, _)| { + !entity + .components + .iter() + .any(|component| component.type_name == *type_name) + }) + .map(|(_, label)| *label) + .collect::>(); + if !missing_baseline.is_empty() { + push_finding( + report, + "sample_pack.actor_baseline_missing", + Some(&sample.id), + Some(&sample.scene), + &format!( + "actor `{actor_id}` lacks required baseline component(s): {}", + missing_baseline.join(", ") + ), + "Restore LevelObject, Transform, and ActorKind on every authored sample actor.", + ); + } + } + let missing: Vec<_> = sample + .area + .required_components() + .iter() + .copied() + .filter(|required| !present.contains(required)) + .collect(); + if !missing.is_empty() { + push_finding( + report, + "sample_pack.area_contract_missing", + Some(&sample.id), + Some(&sample.scene), + &format!( + "{} sample lacks required authored component(s): {}", + sample.area, + missing.join(", ") + ), + "Add the required authored workflow content to the sample scene.", + ); + } +} + +fn parse_recognized_component(type_name: &str, value: &str) -> Option> { + macro_rules! parse { + ($ty:ty) => { + Some( + ron::from_str::<$ty>(value) + .map(|_| ()) + .map_err(|error| error.to_string()), + ) + }; + } + match type_name { + NAME_COMPONENT => parse!(String), + TRANSFORM_COMPONENT => parse!(SerializedTransform), + LEVEL_OBJECT_COMPONENT => parse!(LevelObject), + ACTOR_KIND_COMPONENT => parse!(ActorKind), + COMPONENT_PRIMITIVE => parse!(Primitive), + COMPONENT_LIGHT_DESC => parse!(LightDesc), + EDITOR_VISIBILITY_COMPONENT => parse!(EditorVisibility), + HIERARCHY_SIBLING_INDEX_COMPONENT => parse!(HierarchySiblingIndex), + COMPONENT_BRUSH_DESC => parse!(BrushDesc), + COMPONENT_MATERIAL_DESC => parse!(MaterialDesc), + COMPONENT_TERRAIN_DESC => parse!(TerrainDesc), + COMPONENT_RIGID_BODY_DESC => parse!(RigidBodyDesc), + COMPONENT_COLLIDER_DESC => parse!(ColliderDesc), + COMPONENT_POST_PROCESS_VOLUME => parse!(PostProcessVolumeDesc), + _ => None, + } +} + +#[derive(Debug, Deserialize)] +struct SerializedTransform { + #[serde(rename = "translation")] + _translation: [f32; 3], + #[serde(rename = "rotation")] + _rotation: [f32; 4], + #[serde(rename = "scale")] + _scale: [f32; 3], +} + +fn validate_actor_ids( + sample: &EditorSampleEntry, + document: &SceneDocument, + report: &mut ProjectValidationReport, +) { + let mut actor_ids = BTreeSet::new(); + for entity in &document.entities { + let Some(actor_id) = entity + .actor_id + .as_deref() + .filter(|actor_id| !actor_id.trim().is_empty()) + else { + push_finding( + report, + "sample_pack.actor_id_missing", + Some(&sample.id), + Some(&sample.scene), + "every sample-scene entity must have a nonempty stable ActorId", + "Assign and save stable ActorId components for every authored sample entity.", + ); + continue; + }; + if !actor_ids.insert(actor_id) { + push_finding( + report, + "sample_pack.actor_id_duplicate", + Some(&sample.id), + Some(actor_id), + "sample scene contains a duplicate ActorId", + "Assign one unique stable ActorId to every authored sample entity.", + ); + } + } +} + +fn stable_id(value: &str) -> bool { + let mut bytes = value.bytes(); + let Some(first) = bytes.next() else { + return false; + }; + (first.is_ascii_lowercase() || first.is_ascii_digit()) + && bytes.all(|byte| { + byte.is_ascii_lowercase() || byte.is_ascii_digit() || matches!(byte, b'.' | b'-' | b'_') + }) +} + +fn clean_text(value: &str) -> bool { + !value.is_empty() && value.trim() == value +} + +fn safe_scene_reference(reference: &str) -> bool { + let path = Path::new(reference); + !path.is_absolute() + && path + .components() + .all(|component| matches!(component, Component::Normal(_))) + && path.starts_with("assets/levels") + && path != Path::new("assets/levels") + && reference.ends_with(".scn.ron") +} + +fn path_stays_within_project(project_root: &Path, path: &Path) -> bool { + let Ok(root) = project_root.canonicalize() else { + return false; + }; + path.canonicalize() + .is_ok_and(|resolved| resolved.starts_with(root)) +} + +fn path_contains_symlink(project_root: &Path, relative: &Path) -> bool { + let mut current = PathBuf::from(project_root); + relative.components().any(|component| { + current.push(component.as_os_str()); + std::fs::symlink_metadata(¤t).is_ok_and(|metadata| metadata.file_type().is_symlink()) + }) +} + +fn push_finding( + report: &mut ProjectValidationReport, + code: &str, + sample_id: Option<&str>, + reference: Option<&str>, + message: &str, + repair: &str, +) { + let message = sample_id.map_or_else( + || message.to_string(), + |sample_id| format!("sample `{sample_id}`: {message}"), + ); + report.findings.push(ProjectValidationFinding { + severity: ValidationSeverity::Error, + code: code.into(), + source_path: EDITOR_SAMPLE_MANIFEST_PATH.into(), + owner_actor_id: None, + reference: reference.map(str::to_string), + message, + repair: repair.into(), + }); +} + +fn normalize_report(report: &mut ProjectValidationReport) { + report.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, + )) + }); + report.dependencies.dedup(); + report.findings.sort_by(|left, right| { + ( + &left.source_path, + &left.owner_actor_id, + &left.code, + &left.reference, + &left.message, + &left.repair, + ) + .cmp(&( + &right.source_path, + &right.owner_actor_id, + &right.code, + &right.reference, + &right.message, + &right.repair, + )) + }); + report.findings.dedup(); +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::atomic::{AtomicU64, Ordering}; + + static NEXT_FIXTURE: AtomicU64 = AtomicU64::new(1); + + fn fixture_root() -> PathBuf { + let root = std::env::temp_dir().join(format!( + "blacksite-editor-samples-{}-{}", + std::process::id(), + NEXT_FIXTURE.fetch_add(1, Ordering::Relaxed) + )); + std::fs::create_dir_all(root.join("assets/samples")).unwrap(); + std::fs::create_dir_all(root.join("assets/levels/samples")).unwrap(); + root + } + + fn sample(area: EditorSampleArea, id: &str, scene: &str) -> EditorSampleEntry { + EditorSampleEntry { + id: id.into(), + label: format!("{area} Sample"), + area, + scene: scene.into(), + summary: format!("Validates the {area} workflow."), + checks: vec![format!("Open and inspect the {area} sample.")], + } + } + + fn valid_samples() -> Vec { + vec![ + sample( + EditorSampleArea::Brush, + "brush-blockout", + "assets/levels/samples/brush_blockout.scn.ron", + ), + sample( + EditorSampleArea::Material, + "material-lab", + "assets/levels/samples/material_lab.scn.ron", + ), + sample( + EditorSampleArea::Terrain, + "terrain-sculpt", + "assets/levels/samples/terrain_sculpt.scn.ron", + ), + sample( + EditorSampleArea::PhysicsPlacement, + "physics-drop", + "assets/levels/samples/physics_drop.scn.ron", + ), + sample( + EditorSampleArea::Rendering, + "rendering-lab", + "assets/levels/samples/rendering_lab.scn.ron", + ), + ] + } + + fn valid_component_ron(type_name: &str) -> String { + match type_name { + COMPONENT_BRUSH_DESC => ron::to_string(&BrushDesc::default()).unwrap(), + COMPONENT_MATERIAL_DESC => ron::to_string(&MaterialDesc::default()).unwrap(), + COMPONENT_TERRAIN_DESC => ron::to_string(&TerrainDesc::default()).unwrap(), + COMPONENT_RIGID_BODY_DESC => ron::to_string(&RigidBodyDesc::default()).unwrap(), + COMPONENT_COLLIDER_DESC => ron::to_string(&ColliderDesc::default()).unwrap(), + COMPONENT_POST_PROCESS_VOLUME => { + ron::to_string(&PostProcessVolumeDesc::default()).unwrap() + } + _ => panic!("no typed fixture for {type_name}"), + } + } + + fn scene_components(sample: &EditorSampleEntry, malformed: Option<&str>) -> String { + sample + .area + .required_components() + .iter() + .map(|type_name| { + let value = if malformed == Some(*type_name) { + "\"malformed-required-component\"".to_string() + } else { + valid_component_ron(type_name) + }; + format!("\"{type_name}\":{value}") + }) + .collect::>() + .join(",") + } + + fn write_sample_scene(root: &Path, sample: &EditorSampleEntry, malformed: Option<&str>) { + let components = scene_components(sample, malformed); + let path = root.join(&sample.scene); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write( + path, + format!( + "(schema_version:4,resources:{{}},entities:{{1:(components:{{\"shared::components::ActorId\":(\"sample-{}\"),\"{LEVEL_OBJECT_COMPONENT}\":(),\"{TRANSFORM_COMPONENT}\":(translation:(0.0,0.0,0.0),rotation:(0.0,0.0,0.0,1.0),scale:(1.0,1.0,1.0)),\"{ACTOR_KIND_COMPONENT}\":Empty,{components}}})}})", + sample.id + ), + ) + .unwrap(); + } + + fn write_fixture(root: &Path, samples: &[EditorSampleEntry]) { + let manifest = EditorSampleManifest { + schema_version: EDITOR_SAMPLE_SCHEMA_VERSION, + samples: samples.to_vec(), + }; + std::fs::write( + root.join(EDITOR_SAMPLE_MANIFEST_PATH), + ron::ser::to_string_pretty(&manifest, ron::ser::PrettyConfig::default()).unwrap(), + ) + .unwrap(); + for sample in samples { + write_sample_scene(root, sample, None); + } + } + + #[test] + fn valid_pack_preserves_manifest_order_and_is_release_ready() { + let root = fixture_root(); + let mut samples = valid_samples(); + samples.reverse(); + write_fixture(&root, &samples); + + let validation = validate_editor_sample_pack(&root); + + assert!( + validation.is_release_ready(), + "{:?}", + validation.report.findings + ); + assert_eq!(validation.samples.len(), EditorSampleArea::ALL.len()); + assert_eq!(validation.samples[0].area, EditorSampleArea::Rendering); + assert_eq!( + validation.report.dependencies.len(), + EditorSampleArea::ALL.len() + ); + std::fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn missing_or_malformed_manifest_is_blocking() { + let missing_root = fixture_root(); + let missing = validate_editor_sample_pack(&missing_root); + assert_eq!(missing.report.findings.len(), 1); + assert_eq!( + missing.report.findings[0].code, + "sample_pack.manifest_missing" + ); + std::fs::remove_dir_all(missing_root).unwrap(); + + let malformed_root = fixture_root(); + std::fs::write( + malformed_root.join(EDITOR_SAMPLE_MANIFEST_PATH), + "(schema_version:1,samples:[", + ) + .unwrap(); + let malformed = validate_editor_sample_pack(&malformed_root); + assert_eq!(malformed.report.findings.len(), 1); + assert_eq!( + malformed.report.findings[0].code, + "sample_pack.manifest_invalid" + ); + std::fs::remove_dir_all(malformed_root).unwrap(); + } + + #[test] + fn missing_scene_and_duplicate_manifest_keys_are_blocking() { + let missing_root = fixture_root(); + let samples = valid_samples(); + write_fixture(&missing_root, &samples); + std::fs::remove_file(missing_root.join(&samples[0].scene)).unwrap(); + let missing = validate_editor_sample_pack(&missing_root); + assert!(missing + .report + .findings + .iter() + .any(|finding| finding.code == "sample_pack.scene_missing")); + std::fs::remove_dir_all(missing_root).unwrap(); + + let duplicate_root = fixture_root(); + let mut duplicates = valid_samples(); + duplicates[1].id = duplicates[0].id.clone(); + duplicates[1].scene = duplicates[0].scene.clone(); + write_fixture(&duplicate_root, &duplicates); + let duplicate = validate_editor_sample_pack(&duplicate_root); + let codes = duplicate + .report + .findings + .iter() + .map(|finding| finding.code.as_str()) + .collect::>(); + assert!(codes.contains("sample_pack.duplicate_id")); + assert!(codes.contains("sample_pack.duplicate_scene")); + std::fs::remove_dir_all(duplicate_root).unwrap(); + } + + #[test] + fn every_required_area_payload_is_type_checked() { + for malformed_type in [ + COMPONENT_BRUSH_DESC, + COMPONENT_MATERIAL_DESC, + COMPONENT_TERRAIN_DESC, + COMPONENT_RIGID_BODY_DESC, + COMPONENT_COLLIDER_DESC, + COMPONENT_POST_PROCESS_VOLUME, + ] { + let root = fixture_root(); + let samples = valid_samples(); + write_fixture(&root, &samples); + let sample = samples + .iter() + .find(|sample| sample.area.required_components().contains(&malformed_type)) + .unwrap(); + write_sample_scene(&root, sample, Some(malformed_type)); + + let validation = validate_editor_sample_pack(&root); + + assert!( + validation.report.findings.iter().any(|finding| { + finding.code == "sample_pack.area_contract_malformed" + && finding.message.contains(malformed_type) + }), + "{malformed_type} was not type-checked: {:?}", + validation.report.findings + ); + assert!(!validation.is_release_ready()); + std::fs::remove_dir_all(root).unwrap(); + } + } + + #[test] + fn malformed_secondary_recognized_payload_blocks_the_pack() { + let root = fixture_root(); + let samples = valid_samples(); + write_fixture(&root, &samples); + let sample = &samples[0]; + let path = root.join(&sample.scene); + let text = std::fs::read_to_string(&path).unwrap(); + let brush = valid_component_ron(COMPONENT_BRUSH_DESC); + let required = format!("\"{COMPONENT_BRUSH_DESC}\":{brush}"); + let with_secondary = + format!("{required},\"{COMPONENT_MATERIAL_DESC}\":\"malformed-secondary-material\""); + assert!(text.contains(&required)); + std::fs::write(&path, text.replacen(&required, &with_secondary, 1)).unwrap(); + + let validation = validate_editor_sample_pack(&root); + + assert!(validation.report.findings.iter().any(|finding| { + finding.code == "sample_pack.authored_component_malformed" + && finding.message.contains(COMPONENT_MATERIAL_DESC) + && finding.message.contains("sample-brush-blockout") + })); + assert!(!validation.is_release_ready()); + std::fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn missing_actor_baseline_component_blocks_the_pack() { + let root = fixture_root(); + let samples = valid_samples(); + write_fixture(&root, &samples); + let sample = &samples[0]; + let path = root.join(&sample.scene); + let text = std::fs::read_to_string(&path).unwrap(); + let actor_kind = format!("\"{ACTOR_KIND_COMPONENT}\":Empty,"); + assert!(text.contains(&actor_kind)); + std::fs::write(&path, text.replacen(&actor_kind, "", 1)).unwrap(); + + let validation = validate_editor_sample_pack(&root); + + assert!(validation.report.findings.iter().any(|finding| { + finding.code == "sample_pack.actor_baseline_missing" + && finding.message.contains("ActorKind") + && finding.message.contains("sample-brush-blockout") + })); + assert!(!validation.is_release_ready()); + std::fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn malformed_actor_kind_payload_blocks_the_pack() { + let root = fixture_root(); + let samples = valid_samples(); + write_fixture(&root, &samples); + let sample = &samples[0]; + let path = root.join(&sample.scene); + let text = std::fs::read_to_string(&path).unwrap(); + let actor_kind = format!("\"{ACTOR_KIND_COMPONENT}\":Empty"); + let malformed_actor_kind = format!("\"{ACTOR_KIND_COMPONENT}\":(unexpected_field:true)"); + assert!(text.contains(&actor_kind)); + std::fs::write(&path, text.replacen(&actor_kind, &malformed_actor_kind, 1)).unwrap(); + + let validation = validate_editor_sample_pack(&root); + + assert!(validation.report.findings.iter().any(|finding| { + finding.code == "sample_pack.authored_component_malformed" + && finding.message.contains(ACTOR_KIND_COMPONENT) + && finding.message.contains("sample-brush-blockout") + })); + assert!(!validation.is_release_ready()); + std::fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn unknown_extension_component_remains_allowed() { + let root = fixture_root(); + let samples = valid_samples(); + write_fixture(&root, &samples); + let sample = &samples[0]; + let path = root.join(&sample.scene); + let text = std::fs::read_to_string(&path).unwrap(); + let brush = valid_component_ron(COMPONENT_BRUSH_DESC); + let required = format!("\"{COMPONENT_BRUSH_DESC}\":{brush}"); + let with_extension = + format!("{required},\"game_extension::CustomSampleData\":(enabled:true)"); + assert!(text.contains(&required)); + std::fs::write(&path, text.replacen(&required, &with_extension, 1)).unwrap(); + + let validation = validate_editor_sample_pack(&root); + + assert!( + validation.is_release_ready(), + "unknown extension should remain allowed: {:?}", + validation.report.findings + ); + std::fs::remove_dir_all(root).unwrap(); + } + + #[cfg(unix)] + #[test] + fn manifest_parent_symlink_is_rejected() { + use std::os::unix::fs::symlink; + + let root = fixture_root(); + let samples = valid_samples(); + write_fixture(&root, &samples); + let manifest = std::fs::read(root.join(EDITOR_SAMPLE_MANIFEST_PATH)).unwrap(); + std::fs::remove_file(root.join(EDITOR_SAMPLE_MANIFEST_PATH)).unwrap(); + std::fs::remove_dir(root.join("assets/samples")).unwrap(); + let outside = std::env::temp_dir().join(format!( + "blacksite-editor-samples-outside-{}-{}", + std::process::id(), + NEXT_FIXTURE.fetch_add(1, Ordering::Relaxed) + )); + std::fs::create_dir_all(&outside).unwrap(); + std::fs::write(outside.join("editor_samples.ron"), manifest).unwrap(); + symlink(&outside, root.join("assets/samples")).unwrap(); + + let load_error = load_editor_sample_manifest(&root).unwrap_err(); + let validation = validate_editor_sample_pack(&root); + + assert!(load_error.contains("symlink"), "{load_error}"); + assert_eq!(validation.report.findings.len(), 1); + assert_eq!( + validation.report.findings[0].code, + "sample_pack.manifest_not_regular" + ); + std::fs::remove_file(root.join("assets/samples")).unwrap(); + std::fs::remove_dir_all(root).unwrap(); + std::fs::remove_dir_all(outside).unwrap(); + } + + #[test] + fn missing_and_duplicate_actor_ids_block_the_pack() { + let root = fixture_root(); + let samples = valid_samples(); + write_fixture(&root, &samples); + let brush = valid_component_ron(COMPONENT_BRUSH_DESC); + std::fs::write( + root.join(&samples[0].scene), + format!( + "(schema_version:4,resources:{{}},entities:{{1:(components:{{\"shared::components::ActorId\":(\"duplicate\"),\"{COMPONENT_BRUSH_DESC}\":{brush}}}),2:(components:{{\"shared::components::ActorId\":(\"duplicate\")}}),3:(components:{{}})}})" + ), + ) + .unwrap(); + + let validation = validate_editor_sample_pack(&root); + let codes = validation + .report + .findings + .iter() + .map(|finding| finding.code.as_str()) + .collect::>(); + + assert!(codes.contains("sample_pack.actor_id_missing")); + assert!(codes.contains("sample_pack.actor_id_duplicate")); + assert!(!validation.is_release_ready()); + std::fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn duplicate_and_missing_areas_are_deterministic_blockers() { + let root = fixture_root(); + let mut samples = valid_samples(); + samples.pop(); + samples.push(sample( + EditorSampleArea::Brush, + "brush-second", + "assets/levels/samples/brush_second.scn.ron", + )); + write_fixture(&root, &samples); + + let validation = validate_editor_sample_pack(&root); + let codes = validation + .report + .findings + .iter() + .map(|finding| finding.code.as_str()) + .collect::>(); + + assert!(codes.contains(&"sample_pack.duplicate_area")); + assert!(codes.contains(&"sample_pack.missing_area")); + assert!(!validation.is_release_ready()); + std::fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn unsafe_or_semantically_empty_scene_is_rejected() { + let root = fixture_root(); + let mut samples = valid_samples(); + samples[0].scene = "../outside.scn.ron".into(); + write_fixture(&root, &samples); + std::fs::write( + root.join(&samples[1].scene), + "(schema_version:4,resources:{},entities:{})", + ) + .unwrap(); + + let validation = validate_editor_sample_pack(&root); + let codes = validation + .report + .findings + .iter() + .map(|finding| finding.code.as_str()) + .collect::>(); + + assert!(codes.contains("sample_pack.unsafe_scene_path")); + assert!(codes.contains("sample_pack.area_contract_missing")); + std::fs::remove_dir_all(root).unwrap(); + } +} diff --git a/docs/README.md b/docs/README.md index ad59d4a..c7aed21 100644 --- a/docs/README.md +++ b/docs/README.md @@ -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/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/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/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 | @@ -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/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/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 | ## 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 | | `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 | +| `editor_sample_regression_pack_*.plan.md` | Five-area sample manifest, editor catalog, deterministic validation, and native regression acceptance | ## Crate responsibilities (quick reference) diff --git a/docs/adr/0028-authoritative-project-content-validation.md b/docs/adr/0028-authoritative-project-content-validation.md index 6229ab3..f554b11 100644 --- a/docs/adr/0028-authoritative-project-content-validation.md +++ b/docs/adr/0028-authoritative-project-content-validation.md @@ -23,6 +23,14 @@ Both the editor Diagnostics window and the `cargo validate-levels` headless comm entry point. The command supports `--project ` and `--json`; the editor can select the live 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, generated static mesh manifests, material and shader documents, post effects, scene and prefab 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 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 - 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. - Asset formats added later must register their dependencies with this validator and extend the 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. diff --git a/docs/editor/README.md b/docs/editor/README.md index 8bbc626..9db488a 100644 --- a/docs/editor/README.md +++ b/docs/editor/README.md @@ -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 | | [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 | +| [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/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 | @@ -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/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/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 | ## 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/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/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 | | `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 | diff --git a/docs/editor/debt-audit.md b/docs/editor/debt-audit.md index 7c7c842..0696445 100644 --- a/docs/editor/debt-audit.md +++ b/docs/editor/debt-audit.md @@ -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 | | 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/) | +| 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 | | `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 - [ ] 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 +- [ ] Open all five [sample regression pack](sample-regression-pack.md) entries through the native File menu and record exact-candidate results diff --git a/docs/editor/evaluations/physics-placement/README.md b/docs/editor/evaluations/physics-placement/README.md index a993ae5..5f7d1c5 100644 --- a/docs/editor/evaluations/physics-placement/README.md +++ b/docs/editor/evaluations/physics-placement/README.md @@ -4,15 +4,17 @@ Date: 2026-07-12 ![Three selected props settled on the committed placement floor](physics-placement-settled.png) -The native Wayland editor is shown with the committed -`assets/levels/physics_placement_showcase.scn.ron` fixture. Placement Prop A, B, and C were +The native Wayland editor is shown with the 2026-07-12 revision of +`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 -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 -- All three props used the hydrated Avian cuboid colliders and settled on Placement Floor after - 1.5 simulated seconds. +- In the recorded revision, all three props used hydrated Avian cuboid colliders and settled on + Placement Floor after 1.5 simulated seconds. - During preview the HUD reported selection count, simulated time, and Settled state; transform gizmos and viewport picking did not compete with the modal tool. - Escape restored every starting transform and left history empty. @@ -25,4 +27,3 @@ existing horizontal viewport toolbar. error after the stale-binding fix. Packaged-runtime tests remain intentionally deferred by project-owner request. - diff --git a/docs/editor/evaluations/production-readiness/README.md b/docs/editor/evaluations/production-readiness/README.md index 4e77a9f..9c2de91 100644 --- a/docs/editor/evaluations/production-readiness/README.md +++ b/docs/editor/evaluations/production-readiness/README.md @@ -1,8 +1,8 @@ # 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 @@ -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 | |----|-------------|-------|--------------------------| -| 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. | -| 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. | -| 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`. | +| 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 | 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` 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. | | 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. | -| 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 | ID | Deliverable | State | Evidence or next action | |----|-------------|-------|-------------------------| -| D1 | Versioned acceptance matrix under `docs/editor/evaluations/` | Pass | This file, version 0.2. | -| 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. | +| D1 | Versioned acceptance matrix under `docs/editor/evaluations/` | Pass | This file, version 0.3. | +| 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. | ## 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 | | Hierarchy parenting and prefab structure | 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 | | 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 | -| Physics placement | `#25` source/live acceptance complete | Pass; packaged-runtime acceptance owner-deferred | -| Collider authoring and diagnostics | `#26` source/live acceptance complete | 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 | +| 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. | +| 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 | | Audio | 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 test --workspace cargo validate-levels --project . +cargo validate-samples --project . 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 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 -recovery, asset refresh, material editing, brush/terrain/physics tools, animation/audio/navigation, -PIE transitions, and build UI cancellation without replacing the candidate during the run. +recovery, asset refresh, all five sample-pack scenes, material editing, brush/terrain/physics tools, +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 steady-state resource use, bounded recovery/cache behavior, and a triaged explanation for every diff --git a/docs/editor/evaluations/sample-regression-pack/README.md b/docs/editor/evaluations/sample-regression-pack/README.md new file mode 100644 index 0000000..47d0b63 --- /dev/null +++ b/docs/editor/evaluations/sample-regression-pack/README.md @@ -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. diff --git a/docs/editor/physics-placement.md b/docs/editor/physics-placement.md index 7129c73..244004d 100644 --- a/docs/editor/physics-placement.md +++ b/docs/editor/physics-placement.md @@ -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 leaving explicit commit and cancel available. -`assets/levels/physics_placement_showcase.scn.ron` is the committed acceptance fixture. Select -**Placement Prop A**, **B**, and **C** together, start physics placement, and verify that the three -different shapes settle on **Placement Floor** as one commit/cancel transaction. +`assets/levels/physics_placement_showcase.scn.ron` is the Physics Placement entry in the committed +[sample regression pack](sample-regression-pack.md). Select **Placement Cuboid**, **Placement +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 [ADR 0041](../adr/0041-transactional-editor-physics-placement.md). diff --git a/docs/editor/rendering.md b/docs/editor/rendering.md index 8615746..0a28b75 100644 --- a/docs/editor/rendering.md +++ b/docs/editor/rendering.md @@ -89,7 +89,10 @@ Shipped examples: `post_fx/vignette.ron`, `post_fx/chromatic_aberration.ron`, `r ## 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 diff --git a/docs/editor/roadmap.md b/docs/editor/roadmap.md index 545f9ab..5d12019 100644 --- a/docs/editor/roadmap.md +++ b/docs/editor/roadmap.md @@ -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 | | 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 | +| 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 @@ -150,7 +151,7 @@ with implementation sequencing in | 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 | -| 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 [`#50`](https://git.spacetrainclubhouse.com/Falling-Metal-Interactive/Blacksite/issues/50) diff --git a/docs/editor/sample-regression-pack.md b/docs/editor/sample-regression-pack.md new file mode 100644 index 0000000..5c462af --- /dev/null +++ b/docs/editor/sample-regression-pack.md @@ -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. diff --git a/docs/editor/terrain.md b/docs/editor/terrain.md index c9ceb5b..028c79c 100644 --- a/docs/editor/terrain.md +++ b/docs/editor/terrain.md @@ -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 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, 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 diff --git a/xtask/Cargo.toml b/xtask/Cargo.toml index 39b8e01..fcb40df 100644 --- a/xtask/Cargo.toml +++ b/xtask/Cargo.toml @@ -9,6 +9,11 @@ name = "validate-levels" path = "src/validate_levels.rs" required-features = ["validate-levels"] +[[bin]] +name = "validate-samples" +path = "src/validate_samples.rs" +required-features = ["validate-samples"] + [[bin]] name = "clean-target" path = "src/clean_target.rs" @@ -44,3 +49,4 @@ libc = "0.2" [features] default = [] validate-levels = ["dep:scene", "dep:settings", "dep:shared"] +validate-samples = ["validate-levels"] diff --git a/xtask/src/validate_samples.rs b/xtask/src/validate_samples.rs new file mode 100644 index 0000000..1c88816 --- /dev/null +++ b/xtask/src/validate_samples.rs @@ -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"); + } +}