From ed21bcc52dc3c0aea48ec25738e1b97459662f0e Mon Sep 17 00:00:00 2001 From: Rbanh Date: Sun, 12 Jul 2026 18:43:42 -0400 Subject: [PATCH] Add chunked terrain authoring foundation --- ...in_authoring_foundation_2026-07-12.plan.md | 46 +++ README.md | 2 + assets/.index/registry.ron | 18 + .../levels/terrain_authoring_showcase.scn.ron | 60 ++++ crates/editor/src/scene/scene_io.rs | 3 + crates/editor/src/ui/actor_inspector/mod.rs | 2 + crates/editor/src/ui/component_registry.rs | 21 ++ crates/editor/src/ui/hierarchy_ops.rs | 2 + crates/editor/src/ui/inspector.rs | 131 +++++++- crates/editor/src/viewport/actor_icons.rs | 4 + crates/scene/src/migrate.rs | 1 + crates/shared/src/actor.rs | 19 +- crates/shared/src/components.rs | 122 +++++++ crates/shared/src/hydration/mod.rs | 5 + crates/shared/src/hydration/terrain.rs | 314 ++++++++++++++++++ crates/shared/src/lib.rs | 4 +- docs/README.md | 2 + ...9-inline-height-grid-terrain-foundation.md | 38 +++ docs/editor/README.md | 3 + .../evaluations/terrain-foundation/README.md | 42 +++ .../terrain-showcase-forward.png | 3 + docs/editor/terrain.md | 38 +++ 22 files changed, 874 insertions(+), 6 deletions(-) create mode 100644 .cursor/plans/terrain_authoring_foundation_2026-07-12.plan.md create mode 100644 assets/levels/terrain_authoring_showcase.scn.ron create mode 100644 crates/shared/src/hydration/terrain.rs create mode 100644 docs/adr/0039-inline-height-grid-terrain-foundation.md create mode 100644 docs/editor/evaluations/terrain-foundation/README.md create mode 100644 docs/editor/evaluations/terrain-foundation/terrain-showcase-forward.png create mode 100644 docs/editor/terrain.md diff --git a/.cursor/plans/terrain_authoring_foundation_2026-07-12.plan.md b/.cursor/plans/terrain_authoring_foundation_2026-07-12.plan.md new file mode 100644 index 0000000..ea13d30 --- /dev/null +++ b/.cursor/plans/terrain_authoring_foundation_2026-07-12.plan.md @@ -0,0 +1,46 @@ +# Terrain Authoring Foundation + +Working implementation plan for Gitea +[`#22`](https://git.spacetrainclubhouse.com/Falling-Metal-Interactive/Blacksite/issues/22). +This establishes the persistent and hydrated terrain contract required by sculpting `#23`, layer +painting `#24`, physics placement `#25`, and the regression pack `#32`. + +## Authored Contract + +- Add reflected `TerrainDesc` authoring data with a version, square height-grid resolution, inline + finite heights, horizontal sample spacing, vertical scale, chunk quad size, optional shared + Material/Material Instance reference, collider generation, and shadow flags. +- V1 stores the compact height grid in the scene so save/recovery/history/prefab behavior is exact. + A later external heightmap asset migration must be explicit and versioned rather than silently + changing ownership. +- Validate resolution, exact sample count, finite values, positive scale, and chunk bounds before + hydration or publication. + +## Hydration + +- Partition the grid into deterministic non-overlapping quad chunks with shared boundary samples. +- Generate triangle-list meshes with normals, UVs, U32 indices, and tangents; each generated child + carries owner/chunk coordinates and remains runtime-only. +- Use the authored shared material when resolvable and an explicit terrain fallback otherwise. +- Optionally attach `ColliderConstructor::TrimeshFromMesh` per chunk. +- Rebuild only the owning terrain's generated chunks when descriptor/material/active state changes; + disabling/removing terrain cleans every generated child. + +## Editor Integration + +- Register Terrain as a stable authoring component and ActorKind with conflicts against primitive, + brush, static renderer, and skinned renderer geometry sources. +- Add an inspector for resolution, world scale, chunk size, collider/shadow state, material summary, + validation, and a guarded flat-grid resize action. All mutations are one typed history step. +- Include terrain bounds in selection framing and generated chunks in authored-parent picking. +- Expose Add Terrain through the existing component shelf; no new permanent toolbar is added. + +## Verification + +- Tests cover descriptor validation, deterministic chunk coverage/mesh topology, hydration and + cleanup, generated-child stripping, serialization, and history round-trip. +- Source-only workspace format/check/strict-Clippy/tests and level validation run. Packaged tests + remain deferred by project-owner direction. +- Live native-Wayland acceptance creates or loads a terrain actor, verifies chunk rendering and + selection/inspection, changes scale/chunking, exercises undo/redo, and inspects a saved scene to + confirm generated chunks are absent. diff --git a/README.md b/README.md index c17107d..479d27d 100644 --- a/README.md +++ b/README.md @@ -170,6 +170,7 @@ deep-stale variants. | Prefab Instance inspector | Inspect/recover source health, Apply/Revert overrides by scope, Apply overrides to source, create a variant, **Unpack Layer**, or recursively **Convert to Local** | | Inspector component card | Collapse with caret, toggle active with status dot, or use triple-dot menu for reset/copy/paste/move/remove actions | | Inspector footer → Add Component | Expands an inline search shelf for registered authoring, rendering, physics, gameplay, and volume components with descriptions, availability hints, and undo | +| Inspector footer → Add Component → Terrain | Add a height-grid terrain actor; configure grid scale/chunking/collision in its component card and use Resize Flat only for deliberate grid replacement | | Audio Source / Listener inspector | Assign and audition clips; edit gain, pitch, loop/autoplay, spatial blend, attenuation, bus, listener priority, and ear gap | | Edit → Project Settings… | Edit `assets/project.ron` rendering, audio buses, physics, and input | @@ -398,6 +399,7 @@ crates/ - [x] Filtered hierarchy, inspector, viewport, toolbar, asset browser, and status panels - [x] Delete, duplicate, rename, and structural/material undo-redo - [x] Native Bevy scene New/Open/Save/Save As with dirty title tracking +- [x] First-class height-grid Terrain actor with deterministic generated render/collider chunks, reflected history, validation, and a committed showcase fixture ([ADR 0039](docs/adr/0039-inline-height-grid-terrain-foundation.md), [terrain guide](docs/editor/terrain.md), [Gitea #22](https://git.spacetrainclubhouse.com/Falling-Metal-Interactive/Blacksite/issues/22)) - [x] Non-blocking native file/folder/confirmation broker across scene, asset, prefab, composition, collaboration, and Project Browser workflows ([ADR 0038](docs/adr/0038-non-blocking-native-dialog-broker.md), [workflow guide](docs/editor/native-dialogs.md), [Gitea #52](https://git.spacetrainclubhouse.com/Falling-Metal-Interactive/Blacksite/issues/52)) - [x] Asset import, static mesh/prefab placement, texture assignment, and selection export - [x] PIE player-only snapshot/restore (authored `LevelObject` edits persist on stop) diff --git a/assets/.index/registry.ron b/assets/.index/registry.ron index cf83459..f28d7b3 100644 --- a/assets/.index/registry.ron +++ b/assets/.index/registry.ron @@ -269,6 +269,24 @@ ), dependencies: [], ), + ( + id: ("1b25d451-7b54-41d6-af00-d2e6950f502d"), + path: "assets/levels/terrain_authoring_showcase.scn.ron", + label: "terrain_authoring_showcase.scn", + kind_tag: "Level", + import_settings: ( + scale: 1.0, + generate_collider: true, + lod0_only: true, + placement_mode: StaticAsset, + hierarchy_mode: SingleActor, + material_policy: SourceMaterials, + static_mesh_manifest_path: None, + animation_manifest_path: None, + default_animation_clip_id: None, + ), + dependencies: [], + ), ( id: ("dc13ce01-c7ce-43c5-974c-0e659ae49ab9"), path: "assets/levels/editor_scene 2.scn.ron", diff --git a/assets/levels/terrain_authoring_showcase.scn.ron b/assets/levels/terrain_authoring_showcase.scn.ron new file mode 100644 index 0000000..2dd35a6 --- /dev/null +++ b/assets/levels/terrain_authoring_showcase.scn.ron @@ -0,0 +1,60 @@ +(schema_version: 4, resources: {}, entities: { + 1: (components: { + "bevy_ecs::name::Name": "Terrain Showcase", + "bevy_transform::components::transform::Transform": ( + translation: (0.0, 0.0, 0.0), + rotation: (0.0, 0.0, 0.0, 1.0), + scale: (1.0, 1.0, 1.0), + ), + "shared::components::ActorId": ("terrain-showcase-main"), + "shared::components::ActorKind": Terrain, + "shared::components::EditorVisibility": (visible: true), + "shared::components::HierarchySiblingIndex": (0), + "shared::components::LevelObject": (), + "shared::components::TerrainDesc": ( + schema_version: 1, + resolution: 5, + heights: [ + 0.00, 0.00, 0.05, 0.00, 0.00, + 0.00, 0.15, 0.30, 0.10, 0.00, + 0.05, 0.35, 0.80, 0.25, 0.05, + 0.00, 0.20, 0.40, 0.15, 0.00, + 0.00, 0.00, 0.05, 0.00, 0.00, + ], + sample_spacing: 2.0, + height_scale: 3.0, + chunk_quads: 2, + base_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"), + )), + generate_colliders: true, + cast_shadows: true, + receive_shadows: true, + ), + }), + 2: (components: { + "bevy_ecs::name::Name": "Terrain Sun", + "bevy_transform::components::transform::Transform": ( + translation: (0.0, 8.0, 0.0), + rotation: (0.1691, -0.0670, 0.7244, 0.6648), + scale: (1.0, 1.0, 1.0), + ), + "shared::components::ActorId": ("terrain-showcase-sun"), + "shared::components::ActorKind": Light, + "shared::components::EditorVisibility": (visible: true), + "shared::components::HierarchySiblingIndex": (1), + "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/crates/editor/src/scene/scene_io.rs b/crates/editor/src/scene/scene_io.rs index 1904728..a44c88d 100644 --- a/crates/editor/src/scene/scene_io.rs +++ b/crates/editor/src/scene/scene_io.rs @@ -1113,6 +1113,9 @@ fn format_actor_validation(err: ActorValidationError) -> String { ActorValidationError::InvalidBrushGeometry(message) => { format!("Save failed: invalid brush geometry: {message}") } + ActorValidationError::InvalidTerrain(message) => { + format!("Save failed: invalid terrain: {message}") + } ActorValidationError::StaticMeshMissingPrimitive => { "Save failed: StaticMesh actor requires Primitive or StaticMeshRenderer with a mesh slot" .into() diff --git a/crates/editor/src/ui/actor_inspector/mod.rs b/crates/editor/src/ui/actor_inspector/mod.rs index c9b1e86..3d17092 100644 --- a/crates/editor/src/ui/actor_inspector/mod.rs +++ b/crates/editor/src/ui/actor_inspector/mod.rs @@ -112,6 +112,7 @@ pub fn draw_actor_inspector(world: &mut World, ui: &mut egui::Ui, entity: Entity ActorKind::StaticMesh | ActorKind::SkinnedMesh | ActorKind::Brush + | ActorKind::Terrain | ActorKind::ImportedModel | ActorKind::Light | ActorKind::Empty @@ -143,6 +144,7 @@ fn actor_icon(kind: ActorKind) -> egui_phosphor_icons::Icon { ActorKind::StaticMesh | ActorKind::SkinnedMesh | ActorKind::Brush + | ActorKind::Terrain | ActorKind::ImportedModel => icons::CUBE, ActorKind::Light => icons::LIGHTBULB, ActorKind::PrefabAnchor => icons::PACKAGE, diff --git a/crates/editor/src/ui/component_registry.rs b/crates/editor/src/ui/component_registry.rs index e7ea2f9..ac08921 100644 --- a/crates/editor/src/ui/component_registry.rs +++ b/crates/editor/src/ui/component_registry.rs @@ -373,6 +373,27 @@ impl Default for EditorComponentRegistry { ], hydration_effect: "Hydrates into generated brush mesh children.", }, + EditorComponentDescriptor { + id: shared::AUTHORING_COMPONENT_TERRAIN, + type_name: "shared::components::TerrainDesc", + display_name: "Terrain", + category: EditorComponentCategory::Authoring, + addable: true, + removable: true, + reorderable: true, + hidden: false, + icon: icons::MOUNTAINS.as_str(), + description: "Creates chunked height-grid terrain with optional collision.", + search_terms: &["terrain", "landscape", "heightmap", "ground", "chunk"], + recommended: &[], + conflicts_with: &[ + "shared::components::Primitive", + "shared::components::BrushDesc", + "shared::components::StaticMeshRenderer", + "shared::animation::SkinnedMeshRenderer", + ], + hydration_effect: "Hydrates into generated render and collider chunks.", + }, EditorComponentDescriptor { id: shared::AUTHORING_COMPONENT_MATERIAL, type_name: "shared::components::MaterialDesc", diff --git a/crates/editor/src/ui/hierarchy_ops.rs b/crates/editor/src/ui/hierarchy_ops.rs index 3337b3c..ddebc96 100644 --- a/crates/editor/src/ui/hierarchy_ops.rs +++ b/crates/editor/src/ui/hierarchy_ops.rs @@ -205,6 +205,7 @@ fn actor_kind_sort_key(world: &World, entity: Entity) -> u8 { .map(|kind| match kind { ActorKind::Empty => 0, ActorKind::Brush + | ActorKind::Terrain | ActorKind::StaticMesh | ActorKind::SkinnedMesh | ActorKind::ImportedModel => 1, @@ -468,6 +469,7 @@ pub fn actor_kind_icon(kind: ActorKind) -> &'static str { match kind { ActorKind::Empty => icons::FOLDER.as_str(), ActorKind::Brush => icons::CUBE.as_str(), + ActorKind::Terrain => icons::MOUNTAINS.as_str(), ActorKind::StaticMesh => icons::CUBE.as_str(), ActorKind::SkinnedMesh => icons::PERSON_SIMPLE_RUN.as_str(), ActorKind::ImportedModel => icons::CUBE_TRANSPARENT.as_str(), diff --git a/crates/editor/src/ui/inspector.rs b/crates/editor/src/ui/inspector.rs index 9ac0f26..bd03ec6 100644 --- a/crates/editor/src/ui/inspector.rs +++ b/crates/editor/src/ui/inspector.rs @@ -16,8 +16,8 @@ use shared::{ MaterialParameterValue, MaterialRef, MaterialShaderKind, NavigationArea, NavigationBounds, NavigationLink, NavigationObstacle, ObjectiveMarker, PhysicsBody, PlayerSpawn, PostProcessVolumeDesc, PrefabInstance, Primitive, PrimitiveShape, ProjectSun, RigidBodyDesc, - SkinnedMeshRenderer, StaticMeshRenderer, StaticMeshRendererEntry, TeamSpawn, TriggerVolume, - WeaponSpawn, AUTHORING_DIRECTIONAL_LUX_MAX, AUTHORING_POINT_SPOT_LUMENS_MAX, + SkinnedMeshRenderer, StaticMeshRenderer, StaticMeshRendererEntry, TeamSpawn, TerrainDesc, + TriggerVolume, WeaponSpawn, AUTHORING_DIRECTIONAL_LUX_MAX, AUTHORING_POINT_SPOT_LUMENS_MAX, COMPONENT_ANIMATION_CONTROLLER_DESC, COMPONENT_AUDIO_LISTENER_DESC, COMPONENT_AUDIO_SOURCE_DESC, COMPONENT_BRUSH_DESC, COMPONENT_COLLIDER_DESC, COMPONENT_LIGHT_DESC, COMPONENT_MATERIAL_DESC, COMPONENT_NAVIGATION_AREA, @@ -25,8 +25,8 @@ use shared::{ COMPONENT_OBJECTIVE_MARKER, COMPONENT_PHYSICS_BODY, COMPONENT_PLAYER_SPAWN, COMPONENT_POST_PROCESS_VOLUME, COMPONENT_PREFAB_INSTANCE, COMPONENT_PRIMITIVE, COMPONENT_PROJECT_SUN, COMPONENT_RIGID_BODY_DESC, COMPONENT_SKINNED_MESH_RENDERER, - COMPONENT_STATIC_MESH_RENDERER, COMPONENT_TEAM_SPAWN, COMPONENT_TRIGGER_VOLUME, - COMPONENT_WEAPON_SPAWN, + COMPONENT_STATIC_MESH_RENDERER, COMPONENT_TEAM_SPAWN, COMPONENT_TERRAIN_DESC, + COMPONENT_TRIGGER_VOLUME, COMPONENT_WEAPON_SPAWN, }; use crate::history::{ @@ -1110,6 +1110,7 @@ fn draw_authoring_component_by_type( COMPONENT_STATIC_MESH_RENDERER => static_mesh_renderer_ui(world, ui, entity), COMPONENT_SKINNED_MESH_RENDERER => skinned_mesh_renderer_ui(world, ui, entity), COMPONENT_BRUSH_DESC => brush_editor_ui(world, ui, entity), + COMPONENT_TERRAIN_DESC => terrain_editor_ui(world, ui, entity), COMPONENT_PRIMITIVE => primitive_editor_ui(world, ui, entity), COMPONENT_MATERIAL_DESC => material_editor_ui(world, ui, entity), COMPONENT_LIGHT_DESC => light_editor_ui(world, ui, entity), @@ -3494,6 +3495,128 @@ fn brush_editor_ui(world: &mut World, ui: &mut egui::Ui, entity: Entity) { } } +fn terrain_editor_ui(world: &mut World, ui: &mut egui::Ui, entity: Entity) { + let Some(mut terrain) = world.get::(entity).cloned() else { + return; + }; + let original = terrain.clone(); + let mut changed = false; + let mut requested_resolution = terrain.resolution; + let card = component_card_context( + world, + entity, + ComponentCardOptions::removable(COMPONENT_TERRAIN_DESC, "Terrain", icons::MOUNTAINS), + ); + let card_response = component_card(ui, &card, |ui| { + property_row(ui, "Grid", |ui| { + ui.horizontal(|ui| { + ui.add( + egui::DragValue::new(&mut requested_resolution) + .range(2..=1025) + .suffix(" samples"), + ); + if ui + .add_enabled( + requested_resolution != terrain.resolution, + egui::Button::new("Resize Flat"), + ) + .on_hover_text("Replaces the current height grid with a flat grid") + .clicked() + { + let replacement = TerrainDesc::flat(requested_resolution); + terrain.resolution = replacement.resolution; + terrain.heights = replacement.heights; + terrain.chunk_quads = terrain.chunk_quads.min(terrain.resolution - 1).max(1); + changed = true; + } + }); + }); + property_row(ui, "Sample Spacing", |ui| { + changed |= ui + .add( + egui::DragValue::new(&mut terrain.sample_spacing) + .range(0.01..=1000.0) + .speed(0.1) + .suffix(" m"), + ) + .changed(); + }); + property_row(ui, "Height Scale", |ui| { + changed |= ui + .add( + egui::DragValue::new(&mut terrain.height_scale) + .range(0.01..=10000.0) + .speed(0.1) + .suffix(" m"), + ) + .changed(); + }); + property_row(ui, "Chunk Size", |ui| { + changed |= ui + .add( + egui::DragValue::new(&mut terrain.chunk_quads) + .range(1..=terrain.resolution.saturating_sub(1)) + .suffix(" quads"), + ) + .changed(); + }); + property_row(ui, "Collision", |ui| { + changed |= ui + .checkbox(&mut terrain.generate_colliders, "Generate") + .changed(); + }); + property_row(ui, "Shadows", |ui| { + ui.horizontal_wrapped(|ui| { + changed |= ui.checkbox(&mut terrain.cast_shadows, "Cast").changed(); + changed |= ui + .checkbox(&mut terrain.receive_shadows, "Receive") + .changed(); + }); + }); + property_row(ui, "Base Material", |ui| { + ui.label( + terrain + .base_material + .as_ref() + .map(|material| material.label.as_str()) + .filter(|label| !label.is_empty()) + .unwrap_or("Terrain fallback"), + ); + }); + match terrain.validate() { + Ok(()) => { + ui.label( + egui::RichText::new(format!( + "{} heights • {}×{} chunks", + terrain.heights.len(), + (terrain.resolution - 1).div_ceil(terrain.chunk_quads), + (terrain.resolution - 1).div_ceil(terrain.chunk_quads) + )) + .color(super::theme::SUCCESS), + ); + } + Err(error) => { + ui.label(egui::RichText::new(error).color(super::theme::ERROR)); + } + } + }); + apply_component_card_response(world, entity, card_response); + + if changed && terrain != original { + let _ = reflected_component_transaction( + world, + entity, + "Edit Terrain", + shared::AUTHORING_COMPONENT_TERRAIN, + COMPONENT_TERRAIN_DESC, + move |world, entity| { + world.entity_mut(entity).insert(terrain); + Ok(()) + }, + ); + } +} + fn brush_validation_ui(ui: &mut egui::Ui, brush: &BrushDesc) { let report = validate_brush(brush); if report.diagnostics.is_empty() { diff --git a/crates/editor/src/viewport/actor_icons.rs b/crates/editor/src/viewport/actor_icons.rs index 610a250..f433dd3 100644 --- a/crates/editor/src/viewport/actor_icons.rs +++ b/crates/editor/src/viewport/actor_icons.rs @@ -631,6 +631,10 @@ fn icon_for_actor_components(components: ActorIconComponents) -> ActorIconSpec { image: ActorIconImage::Mesh, category: ActorIconCategory::Mesh, }, + ActorKind::Terrain => ActorIconSpec { + image: ActorIconImage::Mesh, + category: ActorIconCategory::Mesh, + }, ActorKind::StaticMesh => { if components.has_primitive || components.has_static_mesh_renderer { ActorIconSpec { diff --git a/crates/scene/src/migrate.rs b/crates/scene/src/migrate.rs index df04651..5ade779 100644 --- a/crates/scene/src/migrate.rs +++ b/crates/scene/src/migrate.rs @@ -175,6 +175,7 @@ fn actor_kind_ron(kind: ActorKind) -> &'static str { match kind { ActorKind::Empty => "Empty", ActorKind::Brush => "Brush", + ActorKind::Terrain => "Terrain", ActorKind::StaticMesh => "StaticMesh", ActorKind::SkinnedMesh => "SkinnedMesh", ActorKind::ImportedModel => "ImportedModel", diff --git a/crates/shared/src/actor.rs b/crates/shared/src/actor.rs index d5140ce..15601b3 100644 --- a/crates/shared/src/actor.rs +++ b/crates/shared/src/actor.rs @@ -11,7 +11,7 @@ use crate::{ ActorKind, AnimationControllerDesc, AudioListenerDesc, AudioSourceDesc, BrushDesc, LevelObject, LightDesc, ModelRef, NavigationArea, NavigationBounds, NavigationLink, NavigationObstacle, ObjectiveMarker, PlayerSpawn, PostProcessVolumeDesc, PrefabInstance, PrefabRef, Primitive, - SkinnedMeshRenderer, StaticMeshRenderer, TeamSpawn, TriggerVolume, WeaponSpawn, + SkinnedMeshRenderer, StaticMeshRenderer, TeamSpawn, TerrainDesc, TriggerVolume, WeaponSpawn, AUDIO_CLIP_SUB_ASSET_ID, }; @@ -31,6 +31,9 @@ pub fn infer_actor_kind(entity: EntityRef<'_>) -> Option { if entity.get::().is_some() { return Some(ActorKind::Brush); } + if entity.get::().is_some() { + return Some(ActorKind::Terrain); + } if entity.get::().is_some() || entity.get::().is_some() { return Some(ActorKind::StaticMesh); } @@ -84,6 +87,7 @@ pub enum ActorValidationError { BrushHasLight, BrushHasModelRef, InvalidBrushGeometry(String), + InvalidTerrain(String), StaticMeshMissingPrimitive, StaticMeshHasLight, StaticMeshHasModelRef, @@ -166,6 +170,7 @@ pub fn validate_actor(entity: EntityRef<'_>) -> Result<(), ActorValidationError> } let geometry_source_count = usize::from(entity.get::().is_some()) + usize::from(entity.get::().is_some()) + + usize::from(entity.get::().is_some()) + usize::from(entity.get::().is_some()) + usize::from(entity.get::().is_some()) + usize::from(entity.get::().is_some()); @@ -186,6 +191,11 @@ pub fn validate_actor(entity: EntityRef<'_>) -> Result<(), ActorValidationError> return Err(ActorValidationError::InvalidBrushGeometry(message)); } } + if let Some(terrain) = entity.get::() { + terrain + .validate() + .map_err(ActorValidationError::InvalidTerrain)?; + } if let Some(renderer) = entity.get::() { if renderer.path.trim().is_empty() { return Err(ActorValidationError::SkinnedMeshInvalidRenderer); @@ -218,6 +228,13 @@ pub fn validate_actor(entity: EntityRef<'_>) -> Result<(), ActorValidationError> } let _ = brush; } + ActorKind::Terrain => { + if entity.get::().is_none() { + return Err(ActorValidationError::InvalidTerrain( + "terrain actor is missing TerrainDesc".into(), + )); + } + } ActorKind::StaticMesh => { let has_static_mesh_renderer = entity .get::() diff --git a/crates/shared/src/components.rs b/crates/shared/src/components.rs index 9993e61..f226621 100644 --- a/crates/shared/src/components.rs +++ b/crates/shared/src/components.rs @@ -290,6 +290,7 @@ pub fn inspector_component_active(order: Option<&InspectorOrder>, type_name: &st // Rust type paths remain serialization details and may change during refactors. pub const AUTHORING_COMPONENT_PRIMITIVE: &str = "render.primitive"; pub const AUTHORING_COMPONENT_BRUSH: &str = "render.brush"; +pub const AUTHORING_COMPONENT_TERRAIN: &str = "render.terrain"; pub const AUTHORING_COMPONENT_STATIC_MESH_RENDERER: &str = "render.static_mesh_renderer"; pub const AUTHORING_COMPONENT_SKINNED_MESH_RENDERER: &str = "render.skinned_mesh_renderer"; pub const AUTHORING_COMPONENT_MATERIAL: &str = "render.material"; @@ -318,6 +319,7 @@ pub fn authoring_component_id(key: &str) -> Option<&'static str> { match key { AUTHORING_COMPONENT_PRIMITIVE | COMPONENT_PRIMITIVE => Some(AUTHORING_COMPONENT_PRIMITIVE), AUTHORING_COMPONENT_BRUSH | COMPONENT_BRUSH_DESC => Some(AUTHORING_COMPONENT_BRUSH), + AUTHORING_COMPONENT_TERRAIN | COMPONENT_TERRAIN_DESC => Some(AUTHORING_COMPONENT_TERRAIN), AUTHORING_COMPONENT_STATIC_MESH_RENDERER | COMPONENT_STATIC_MESH_RENDERER => { Some(AUTHORING_COMPONENT_STATIC_MESH_RENDERER) } @@ -386,6 +388,7 @@ pub fn authoring_component_key(key: &str) -> &str { pub const COMPONENT_PRIMITIVE: &str = "shared::components::Primitive"; pub const COMPONENT_BRUSH_DESC: &str = "shared::components::BrushDesc"; +pub const COMPONENT_TERRAIN_DESC: &str = "shared::components::TerrainDesc"; pub const COMPONENT_STATIC_MESH_RENDERER: &str = "shared::components::StaticMeshRenderer"; pub const COMPONENT_MATERIAL_DESC: &str = "shared::components::MaterialDesc"; pub const COMPONENT_MATERIAL_OVERRIDE: &str = "shared::components::MaterialOverride"; @@ -852,6 +855,124 @@ impl BrushDesc { } } +pub const TERRAIN_SCHEMA_VERSION: u32 = 1; + +/// Persistent height-grid terrain authoring data. Hydration partitions the grid into generated +/// render/collider chunks; saved scenes retain only this descriptor. +#[derive(Component, Reflect, Debug, Clone, PartialEq, Serialize, Deserialize)] +#[reflect(Component, Default, Debug, PartialEq, Serialize, Deserialize)] +pub struct TerrainDesc { + #[serde(default = "default_terrain_schema_version")] + pub schema_version: u32, + #[serde(default = "default_terrain_resolution")] + pub resolution: u32, + #[serde(default = "default_terrain_heights")] + pub heights: Vec, + #[serde(default = "default_terrain_sample_spacing")] + pub sample_spacing: f32, + #[serde(default = "default_terrain_height_scale")] + pub height_scale: f32, + #[serde(default = "default_terrain_chunk_quads")] + pub chunk_quads: u32, + #[serde(default)] + pub base_material: Option, + #[serde(default = "default_true")] + pub generate_colliders: bool, + #[serde(default = "default_true")] + pub cast_shadows: bool, + #[serde(default = "default_true")] + pub receive_shadows: bool, +} + +impl Default for TerrainDesc { + fn default() -> Self { + Self { + schema_version: TERRAIN_SCHEMA_VERSION, + resolution: default_terrain_resolution(), + heights: default_terrain_heights(), + sample_spacing: default_terrain_sample_spacing(), + height_scale: default_terrain_height_scale(), + chunk_quads: default_terrain_chunk_quads(), + base_material: None, + generate_colliders: true, + cast_shadows: true, + receive_shadows: true, + } + } +} + +impl TerrainDesc { + pub fn flat(resolution: u32) -> Self { + let resolution = resolution.max(2); + Self { + resolution, + heights: vec![0.0; resolution as usize * resolution as usize], + chunk_quads: (resolution - 1).min(default_terrain_chunk_quads()), + ..Default::default() + } + } + + pub fn validate(&self) -> Result<(), String> { + if self.schema_version != TERRAIN_SCHEMA_VERSION { + return Err(format!( + "terrain schema {} is unsupported; expected {}", + self.schema_version, TERRAIN_SCHEMA_VERSION + )); + } + if !(2..=1025).contains(&self.resolution) { + return Err("terrain resolution must be between 2 and 1025 samples".into()); + } + let expected = self.resolution as usize * self.resolution as usize; + if self.heights.len() != expected { + return Err(format!( + "terrain resolution {} requires {expected} height samples, found {}", + self.resolution, + self.heights.len() + )); + } + if self.heights.iter().any(|height| !height.is_finite()) { + return Err("terrain height samples must all be finite".into()); + } + if !self.sample_spacing.is_finite() || self.sample_spacing <= 0.0 { + return Err("terrain sample spacing must be finite and positive".into()); + } + if !self.height_scale.is_finite() || self.height_scale <= 0.0 { + return Err("terrain height scale must be finite and positive".into()); + } + if self.chunk_quads == 0 || self.chunk_quads > self.resolution - 1 { + return Err(format!( + "terrain chunk size must be between 1 and {} quads", + self.resolution - 1 + )); + } + Ok(()) + } +} + +fn default_terrain_schema_version() -> u32 { + TERRAIN_SCHEMA_VERSION +} + +fn default_terrain_resolution() -> u32 { + 33 +} + +fn default_terrain_heights() -> Vec { + vec![0.0; default_terrain_resolution() as usize * default_terrain_resolution() as usize] +} + +fn default_terrain_sample_spacing() -> f32 { + 1.0 +} + +fn default_terrain_height_scale() -> f32 { + 10.0 +} + +fn default_terrain_chunk_quads() -> u32 { + 16 +} + impl Default for BrushDesc { fn default() -> Self { Self::cuboid(Vec3::ONE) @@ -1392,6 +1513,7 @@ impl Default for EditorVisibility { pub enum ActorKind { Empty, Brush, + Terrain, StaticMesh, SkinnedMesh, ImportedModel, diff --git a/crates/shared/src/hydration/mod.rs b/crates/shared/src/hydration/mod.rs index 630c51e..b6a2d9f 100644 --- a/crates/shared/src/hydration/mod.rs +++ b/crates/shared/src/hydration/mod.rs @@ -10,6 +10,7 @@ mod primitives; mod skinned_meshes; mod static_meshes; pub mod strip; +mod terrain; mod visibility; pub use lights::cascade_config_from_rendering; @@ -37,6 +38,8 @@ use static_meshes::{ hydrate_static_mesh_renderers, spawn_static_mesh_parts, HydratedStaticMeshPart, StaticMeshArtifactCache, }; +pub use terrain::HydratedTerrainChunk; +use terrain::{cleanup_removed_terrain, hydrate_terrain}; use visibility::{ ensure_level_object_visibility_hierarchy, init_editor_visibility_on_spawn, sync_editor_visibility, visibility_from_editor, @@ -73,6 +76,7 @@ impl Plugin for HydrationPlugin { ( hydrate_primitives, hydrate_brushes, + hydrate_terrain, hydrate_materials, hydrate_lights, reconcile_missing_runtime_lights, @@ -83,6 +87,7 @@ impl Plugin for HydrationPlugin { hydrate_models, hydrate_prefabs, hydrate_physics, + cleanup_removed_terrain, ) .chain() .in_set(HydrationSet::Content), diff --git a/crates/shared/src/hydration/terrain.rs b/crates/shared/src/hydration/terrain.rs new file mode 100644 index 0000000..bf3e023 --- /dev/null +++ b/crates/shared/src/hydration/terrain.rs @@ -0,0 +1,314 @@ +//! Chunked render and collider hydration for authored height-grid terrain. + +use avian3d::prelude::ColliderConstructor; +use bevy::asset::RenderAssetUsages; +use bevy::light::{NotShadowCaster, NotShadowReceiver}; +use bevy::mesh::{Indices, PrimitiveTopology}; +use bevy::prelude::*; + +use crate::{ + authoring_component_active, load_resolved_material_from_path, AuthoringComponentStates, + InspectorOrder, LevelObject, RaytracingExcluded, TerrainDesc, COMPONENT_TERRAIN_DESC, +}; + +use super::materials::material_from_desc; + +#[derive(Component, Reflect, Debug, Clone, Copy, PartialEq, Eq)] +#[reflect(Component, Default, Debug)] +pub struct HydratedTerrainChunk { + pub owner: Entity, + pub chunk_x: u32, + pub chunk_z: u32, +} + +impl Default for HydratedTerrainChunk { + fn default() -> Self { + Self { + owner: Entity::PLACEHOLDER, + chunk_x: 0, + chunk_z: 0, + } + } +} + +#[allow(clippy::type_complexity)] +pub fn hydrate_terrain( + mut commands: Commands, + asset_server: Res, + mut meshes: ResMut>, + mut materials: ResMut>, + terrains: Query< + ( + Entity, + &TerrainDesc, + Option<&AuthoringComponentStates>, + Option<&InspectorOrder>, + ), + ( + With, + Or<( + Added, + Changed, + Changed, + Changed, + )>, + ), + >, + children: Query<&Children>, + chunks: Query<(), With>, +) { + for (entity, terrain, states, order) in &terrains { + despawn_terrain_chunks(&mut commands, entity, &children, &chunks); + if authoring_component_active(states, order, COMPONENT_TERRAIN_DESC) { + spawn_terrain_chunks( + &mut commands, + &asset_server, + &mut meshes, + &mut materials, + entity, + terrain, + ); + } + } +} + +pub fn cleanup_removed_terrain( + mut commands: Commands, + mut removed: RemovedComponents, + children: Query<&Children>, + chunks: Query<(), With>, +) { + for entity in removed.read() { + despawn_terrain_chunks(&mut commands, entity, &children, &chunks); + } +} + +pub fn spawn_terrain_chunks( + commands: &mut Commands, + asset_server: &AssetServer, + meshes: &mut Assets, + materials: &mut Assets, + owner: Entity, + terrain: &TerrainDesc, +) { + if let Err(error) = terrain.validate() { + warn!("Terrain hydration skipped invalid actor {owner:?}: {error}"); + return; + } + + let material = terrain + .base_material + .as_ref() + .and_then(|reference| reference.source_path.as_deref()) + .and_then(|path| match load_resolved_material_from_path(path) { + Ok((desc, _)) => Some(material_from_desc(asset_server, &desc)), + Err(error) => { + warn!("Terrain {owner:?} material could not resolve: {error}"); + None + } + }) + .unwrap_or_else(|| StandardMaterial { + base_color: Color::srgb(0.24, 0.29, 0.25), + perceptual_roughness: 0.92, + ..default() + }); + let material = materials.add(material); + let quad_count = terrain.resolution - 1; + let chunks_per_axis = quad_count.div_ceil(terrain.chunk_quads); + + for chunk_z in 0..chunks_per_axis { + for chunk_x in 0..chunks_per_axis { + let start_x = chunk_x * terrain.chunk_quads; + let start_z = chunk_z * terrain.chunk_quads; + let end_x = (start_x + terrain.chunk_quads).min(quad_count); + let end_z = (start_z + terrain.chunk_quads).min(quad_count); + let Some(mesh) = terrain_chunk_mesh(terrain, start_x, start_z, end_x, end_z) else { + continue; + }; + let mesh = meshes.add(mesh); + let mut chunk = commands.spawn(( + HydratedTerrainChunk { + owner, + chunk_x, + chunk_z, + }, + Name::new(format!("Terrain Chunk {chunk_x},{chunk_z}")), + Mesh3d(mesh), + MeshMaterial3d(material.clone()), + Transform::default(), + Visibility::Visible, + RaytracingExcluded, + ChildOf(owner), + )); + if terrain.generate_colliders { + chunk.insert(ColliderConstructor::TrimeshFromMesh); + } + if !terrain.cast_shadows { + chunk.insert(NotShadowCaster); + } + if !terrain.receive_shadows { + chunk.insert(NotShadowReceiver); + } + } + } +} + +fn despawn_terrain_chunks( + commands: &mut Commands, + entity: Entity, + children: &Query<&Children>, + chunks: &Query<(), With>, +) { + let Ok(children) = children.get(entity) else { + return; + }; + for child in children.iter() { + if chunks.get(child).is_ok() { + commands.entity(child).despawn(); + } + } +} + +pub fn terrain_chunk_mesh( + terrain: &TerrainDesc, + start_x: u32, + start_z: u32, + end_x: u32, + end_z: u32, +) -> Option { + if start_x >= end_x + || start_z >= end_z + || end_x >= terrain.resolution + || end_z >= terrain.resolution + { + return None; + } + let width = end_x - start_x + 1; + let depth = end_z - start_z + 1; + let mut positions = Vec::with_capacity((width * depth) as usize); + let mut normals = Vec::with_capacity((width * depth) as usize); + let mut uvs = Vec::with_capacity((width * depth) as usize); + let quad_count = (terrain.resolution - 1) as f32; + let half_extent = quad_count * terrain.sample_spacing * 0.5; + + let sample = |x: u32, z: u32| { + terrain.heights[(z * terrain.resolution + x) as usize] * terrain.height_scale + }; + for z in start_z..=end_z { + for x in start_x..=end_x { + let left = sample(x.saturating_sub(1), z); + let right = sample((x + 1).min(terrain.resolution - 1), z); + let down = sample(x, z.saturating_sub(1)); + let up = sample(x, (z + 1).min(terrain.resolution - 1)); + let normal = Vec3::new(left - right, 2.0 * terrain.sample_spacing, down - up) + .normalize_or_zero(); + positions.push([ + x as f32 * terrain.sample_spacing - half_extent, + sample(x, z), + z as f32 * terrain.sample_spacing - half_extent, + ]); + normals.push(normal.to_array()); + uvs.push([x as f32 / quad_count, z as f32 / quad_count]); + } + } + + let mut indices = Vec::with_capacity(((width - 1) * (depth - 1) * 6) as usize); + for z in 0..depth - 1 { + for x in 0..width - 1 { + let a = z * width + x; + let b = a + 1; + let c = a + width; + let d = c + 1; + indices.extend_from_slice(&[a, c, b, b, c, d]); + } + } + + let mut mesh = Mesh::new( + PrimitiveTopology::TriangleList, + RenderAssetUsages::default(), + ); + mesh.insert_attribute(Mesh::ATTRIBUTE_POSITION, positions); + mesh.insert_attribute(Mesh::ATTRIBUTE_NORMAL, normals); + mesh.insert_attribute(Mesh::ATTRIBUTE_UV_0, uvs); + mesh.insert_indices(Indices::U32(indices)); + if let Err(error) = mesh.generate_tangents() { + warn!("Terrain chunk tangent generation failed: {error:?}"); + } + Some(mesh) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::ActorKind; + + #[test] + fn chunk_mesh_has_expected_topology() { + let terrain = TerrainDesc::flat(5); + let mesh = terrain_chunk_mesh(&terrain, 0, 0, 4, 4).unwrap(); + assert_eq!(mesh.count_vertices(), 25); + assert_eq!(mesh.indices().unwrap().len(), 96); + } + + #[test] + fn descriptor_rejects_mismatched_and_non_finite_heights() { + let mut terrain = TerrainDesc::flat(3); + terrain.heights.pop(); + assert!(terrain.validate().unwrap_err().contains("requires 9")); + terrain.heights.push(f32::NAN); + assert!(terrain.validate().unwrap_err().contains("finite")); + } + + #[test] + fn descriptor_round_trips_through_ron() { + let mut terrain = TerrainDesc::flat(5); + terrain.heights[12] = 0.75; + terrain.sample_spacing = 2.0; + let text = ron::to_string(&terrain).unwrap(); + let restored: TerrainDesc = ron::from_str(&text).unwrap(); + assert_eq!(restored, terrain); + assert!(restored.validate().is_ok()); + } + + #[test] + fn hydration_generates_runtime_only_chunks_and_cleans_up() { + let mut app = App::new(); + app.add_plugins((MinimalPlugins, AssetPlugin::default())) + .init_asset::() + .init_asset::() + .add_systems(Update, (hydrate_terrain, cleanup_removed_terrain).chain()); + let owner = app + .world_mut() + .spawn(( + LevelObject, + ActorKind::Terrain, + TerrainDesc { + resolution: 5, + heights: vec![0.0; 25], + chunk_quads: 2, + ..Default::default() + }, + Transform::default(), + )) + .id(); + + app.update(); + app.update(); + let children = app.world().get::(owner).unwrap(); + assert_eq!(children.len(), 4); + for child in children.iter() { + assert!(app.world().get::(child).is_some()); + assert!(app.world().get::(child).is_none()); + assert!(app.world().get::(child).is_some()); + assert!(app.world().get::(child).is_some()); + } + + app.world_mut().entity_mut(owner).remove::(); + app.update(); + app.update(); + assert!(app + .world() + .get::(owner) + .is_none_or(Children::is_empty)); + } +} diff --git a/crates/shared/src/lib.rs b/crates/shared/src/lib.rs index f2c667d..88392a5 100644 --- a/crates/shared/src/lib.rs +++ b/crates/shared/src/lib.rs @@ -22,7 +22,8 @@ pub use components::*; pub use hydration::{ cascade_config_from_rendering, flush_level_object_hydration, material_from_desc, strip_hydrated, strip_hydrated_entity, HydratedModelRoot, HydratedPrefabMember, - HydratedPrefabReady, HydratedSkinnedMeshRoot, HydrationPlugin, PrefabHydrationBlocked, + HydratedPrefabReady, HydratedSkinnedMeshRoot, HydratedTerrainChunk, HydrationPlugin, + PrefabHydrationBlocked, }; pub use material_asset::{ load_resolved_material_from_path, MaterialAlphaMode, MaterialAsset, MaterialInstanceAsset, @@ -112,6 +113,7 @@ impl Plugin for SharedTypesPlugin { .register_type::() .register_type::() .register_type::() + .register_type::() .register_type::() .register_type::() .register_type::() diff --git a/docs/README.md b/docs/README.md index 4ebb35c..9683e02 100644 --- a/docs/README.md +++ b/docs/README.md @@ -53,6 +53,7 @@ Immutable-style log of significant decisions. Add a new numbered ADR when changi | [0036](adr/0036-surface-abi-and-solari-parity.md) | Constrained Surface ABI v1, raster/Solari evaluator parity, and deformed-geometry boundary | | [0037](adr/0037-collaborative-authored-file-safety.md) | Exact authored-file revisions, observational Git status, and optional ownership providers | | [0038](adr/0038-non-blocking-native-dialog-broker.md) | Worker-owned native waits with one-shot main-thread workflow completion | +| [0039](adr/0039-inline-height-grid-terrain-foundation.md) | Inline authored height grids with deterministic runtime-only chunk hydration | ## Editor framework @@ -79,6 +80,7 @@ Immutable-style log of significant decisions. Add a new numbered ADR when changi | [editor/material-system.md](editor/material-system.md) | Shared material assets and instances, renderer-slot assignment, Surface evaluators, migration, and diagnostics | | [editor/collaborative-file-safety.md](editor/collaborative-file-safety.md) | Guarded authored writes, compact Git/read-only status, conflict recovery, and ownership providers | | [editor/native-dialogs.md](editor/native-dialogs.md) | Non-blocking native dialog acquisition and main-thread result application | +| [editor/terrain.md](editor/terrain.md) | Terrain schema, inspector workflow, chunk hydration, collision, and follow-on boundaries | | [editor/evaluations/material-renderer-foundation/](editor/evaluations/material-renderer-foundation/) | Live screenshots and acceptance results for the renderer/material/component foundation | | [editor/evaluations/navigation-authoring/](editor/evaluations/navigation-authoring/) | Live screenshots and acceptance results for navigation authoring and runtime parity | | [editor/evaluations/production-readiness/](editor/evaluations/production-readiness/) | Current versioned production gate, evidence matrix, candidate commands, soak protocol, and independent sign-off requirements | diff --git a/docs/adr/0039-inline-height-grid-terrain-foundation.md b/docs/adr/0039-inline-height-grid-terrain-foundation.md new file mode 100644 index 0000000..7437455 --- /dev/null +++ b/docs/adr/0039-inline-height-grid-terrain-foundation.md @@ -0,0 +1,38 @@ +# ADR 0039: Inline Height-Grid Terrain Foundation + +## Status + +Accepted + +## Context + +Terrain sculpting, material painting, physics placement, recovery, and prefab workflows need one +stable owner for height data. Generated meshes and colliders cannot be authoritative because they +are runtime artifacts, while introducing an external binary heightmap before editing semantics exist +would add file-move, revision, import, and multi-document transaction complexity prematurely. + +## Decision + +`TerrainDesc` schema v1 stores a bounded square grid of normalized finite `f32` heights directly in +the authored scene component. It also stores sample spacing, height scale, chunk quad size, an +optional shared base Material/Material Instance reference, collider generation, and shadow flags. + +Hydration deterministically partitions grid quads into generated children. Boundary samples are +duplicated between neighboring render chunks so each chunk has complete normals and independent +triangle geometry. Chunk meshes/colliders are never `LevelObject` entities and are therefore absent +from saved scenes, recovery documents, prefabs, and history snapshots. + +Resolution changes are explicit destructive flat-grid replacements in the foundation inspector. +Sculpt tools mutate the existing grid through grouped history transactions. A future external +heightmap representation requires an explicit schema migration and authored-file ownership ADR; it +must not silently rewrite an inline terrain during normal load. + +## Consequences + +- Scene, prefab, undo, recovery, collaboration, and validation use one exact terrain document. +- V1 grids are bounded to 1025×1025 samples to prevent accidental unbounded scene payloads. +- Large production terrains may eventually need tiled external storage and streaming. +- Foundation chunks remain raster-only in Solari until terrain layer materials have an exact Surface + evaluator in `#24`; explicit exclusion is preferred to a black or mismatched ray-traced proxy. +- Layer weights and sculpt strokes extend this descriptor contract in `#23`/`#24`; generated chunks + remain disposable regardless of the editing representation. diff --git a/docs/editor/README.md b/docs/editor/README.md index 5577c02..131833e 100644 --- a/docs/editor/README.md +++ b/docs/editor/README.md @@ -25,10 +25,12 @@ Docs for the in-process egui editor (`crates/editor/`). Update this index when a | [material-system.md](material-system.md) | Shared Material/Material Instance assets, static/skinned renderer slots, Surface ABI, Solari scope, and migration | | [collaborative-file-safety.md](collaborative-file-safety.md) | Exact authored-file revisions, Git/read-only status, conflict recovery, and optional ownership providers | | [native-dialogs.md](native-dialogs.md) | Non-blocking file/folder/confirmation acquisition and main-thread result application | +| [terrain.md](terrain.md) | Inline height-grid terrain, chunk hydration, collision, inspector workflow, and fixtures | | [evaluations/material-renderer-foundation/](evaluations/material-renderer-foundation/) | Live screenshots and verification record for the renderer/material/component foundation | | [evaluations/material-library-targeted-drop/](evaluations/material-library-targeted-drop/) | Live screenshot and verification record for the docked Material Library and exact reversible surface drops | | [evaluations/collaborative-file-safety/](evaluations/collaborative-file-safety/) | Live screenshot and verification record for source-control status and guarded external-change recovery | | [evaluations/native-dialog-responsiveness/](evaluations/native-dialog-responsiveness/) | Live native-Wayland screenshot and verification record for non-blocking picker responsiveness | +| [evaluations/terrain-foundation/](evaluations/terrain-foundation/) | Live screenshot and verification record for height-grid terrain schema and chunk hydration | | [evaluations/navigation-authoring/](evaluations/navigation-authoring/) | Live screenshots and verification record for navigation authoring and runtime parity | | [evaluations/production-readiness/](evaluations/production-readiness/) | Current #50 release-candidate matrix and required clean-checkout, soak, performance, limitation, and independent-signoff evidence | @@ -38,6 +40,7 @@ Docs for the in-process egui editor (`crates/editor/`). Update this index when a |-------------|----------------|-----| | `lib.rs` / `EditorPluginGroup` | Ordered plugin bundle, lib/bin split | ADR 0008, architecture.md | | `shared::components::BrushDesc` / `shared::hydration::brushes` | Persisted brush authoring data and generated runtime mesh hydration | brushes.md, ADR 0021 | +| `shared::components::TerrainDesc` / `shared::hydration::terrain` | Persisted height grid and generated render/collider chunks | terrain.md, ADR 0039 | | `scene/` | Level I/O, tabs, composition materialization, schema, viewport render-target setup | architecture.md, multi-scene-composition.md, ADR 0026 | | `viewport/` | Camera, selection, gizmos, render views | architecture.md | | `play/` | PIE session, editor mode state | architecture.md | diff --git a/docs/editor/evaluations/terrain-foundation/README.md b/docs/editor/evaluations/terrain-foundation/README.md new file mode 100644 index 0000000..45de66c --- /dev/null +++ b/docs/editor/evaluations/terrain-foundation/README.md @@ -0,0 +1,42 @@ +# Terrain Foundation Evaluation + +Date: 2026-07-12 +Branch: `codex/terrain-foundation` +Gitea issue: `#22` + +This record captures acceptance for the persistent height-grid terrain schema and deterministic +runtime chunk hydration. The permanent workflow lives in the [terrain guide](../../terrain.md). + +## Live Editor Evidence + +The native Wayland debug editor loaded the committed +`assets/levels/terrain_authoring_showcase.scn.ron` fixture. The screenshot uses Forward rendering to +isolate mesh/material/light correctness while foundation terrain remains explicitly excluded from +Solari until exact terrain layer/Surface parity lands in `#24`. + +![Asymmetric shared-material terrain hill generated from four runtime chunks](terrain-showcase-forward.png) + +The 5×5 authored grid renders as the expected asymmetric hill, resolves the committed shared +Material Instance, and is split into four 2×2-quad chunks with generated trimesh colliders. The +authored scene contains no `HydratedTerrainChunk`, `Mesh3d`, or collider-constructor components. + +## Acceptance Results + +| Area | Result | Evidence | +|------|--------|----------| +| Schema and serialization | Pass | Versioned bounded descriptor, exact height count, finite-value and scale/chunk validation, RON round-trip test | +| Chunk hydration | Pass | Deterministic four-chunk runtime fixture; topology test verifies 25 vertices and 96 indices for a 4×4-quad mesh | +| Runtime-only ownership | Pass | Hydration test verifies children have mesh/collider/owner markers but no `LevelObject`, then verifies removal cleanup | +| Actor/editor integration | Pass | First-class Terrain kind, registry descriptor, hierarchy/viewport icon, reflected Add Component and inspector transaction path | +| Material behavior | Pass with boundary | Shared Material/Instance source resolves for raster; missing source uses visible fallback; Solari submission is explicitly excluded pending `#24` | +| Project validation | Pass | 60 level dependencies, existing 5 non-blocking findings, 0 blocking errors | +| Packaged acceptance | Deferred | Explicitly deferred by project-owner direction; no packaged result is claimed here | + +## Automated Verification + +| Command/suite | Result | +|---------------|--------| +| Focused terrain descriptor/topology/hydration tests | 4 passed | +| Editor component registry reflection contract | Pass | +| `cargo check --workspace --all-targets` | Pass | +| `cargo validate-levels --project .` | 60 dependencies, 5 findings, 0 blockers | diff --git a/docs/editor/evaluations/terrain-foundation/terrain-showcase-forward.png b/docs/editor/evaluations/terrain-foundation/terrain-showcase-forward.png new file mode 100644 index 0000000..e06eb1d --- /dev/null +++ b/docs/editor/evaluations/terrain-foundation/terrain-showcase-forward.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e9fc17370a6ca92765979162fd4cec46965dac68a0eb7cfa2f9dfe6061bf677a +size 1025434 diff --git a/docs/editor/terrain.md b/docs/editor/terrain.md new file mode 100644 index 0000000..2b9f931 --- /dev/null +++ b/docs/editor/terrain.md @@ -0,0 +1,38 @@ +# Terrain Authoring + +Terrain is a first-class authored actor backed by `TerrainDesc`. Add it from the Inspector's +**Add Component** shelf by searching for Terrain. It conflicts with other geometry sources on the +same actor: Primitive, Brush, Static Mesh Renderer, and Skinned Mesh Renderer. + +## Foundation Workflow + +The Terrain card exposes grid resolution, sample spacing, height scale, chunk size, generated +collision, shadows, base material status, and validation. **Resize Flat** deliberately replaces the +height array with a flat grid; normal numeric edits retain all height samples. Every accepted edit is +one reflected history transaction and rebuilds only that terrain's generated chunks. + +`assets/levels/terrain_authoring_showcase.scn.ron` is the deterministic foundation fixture. 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. + +## Data And Hydration + +- Heights are finite normalized values multiplied by Height Scale at hydration time. +- Sample Spacing controls X/Z distance and the grid remains centered on the actor origin. +- Chunk Size is measured in quads. Neighboring chunks share boundary samples but not runtime mesh + assets, allowing later stroke updates to rebuild only affected chunks. +- A resolvable shared Material/Material Instance source supplies the chunk material. Missing or + invalid references produce a warning and visible terrain fallback rather than missing geometry. +- Generated `HydratedTerrainChunk` children own mesh, optional trimesh collider, and shadow state. + They are runtime-only and never serialized as authored actors. +- Foundation chunks are explicitly excluded from Solari submission and remain raster-visible while + Auto/Solari is active. Terrain Surface/layer parity enters with the material-layer work in `#24`; + the editor never substitutes a black or semantically different ray-traced terrain proxy. + +Sculpt brushes and grouped stroke undo are tracked by Gitea `#23`; material layers and weight +painting are tracked by `#24`. The persistence decision is recorded in +[ADR 0039](../adr/0039-inline-height-grid-terrain-foundation.md). + +Live native-Wayland evidence and focused verification are recorded in the +[terrain foundation evaluation](evaluations/terrain-foundation/).