diff --git a/src/features/editScene/AGENTS.md b/src/features/editScene/AGENTS.md index badf8f1..ef6cec9 100644 --- a/src/features/editScene/AGENTS.md +++ b/src/features/editScene/AGENTS.md @@ -86,20 +86,22 @@ to `EditorApp`. 4. `AnimationTreeSystem` / `BehaviorTreeSystem` 5. `PathFollowingSystem` 6. `ProceduralMeshSystem` -7. `RoomLayoutSystem` -8. `CellGridSystem` -9. `NormalDebugSystem` -10. `NavMeshSystem` -11. `SmartObjectSystem` -12. `GoapPlannerSystem` -13. `GoapRunnerSystem` -14. `ActuatorSystem` -15. `EventHandlerSystem` -16. `CharacterSystem` -17. `BuoyancySystem` -18. `PhysicsSystem` -19. `HairPhysicsSystem` pose read-back -20. Rendering support systems (sun, skybox, water, light, LOD, etc.) +7. `TerrainSystem`, then `TerrainPrefabSpawnerSystem` (spawn Y-snap and + terrain compliance see fresh terrain data) +8. `RoomLayoutSystem` +9. `CellGridSystem` +10. `NormalDebugSystem` +11. `NavMeshSystem` +12. `SmartObjectSystem` +13. `GoapPlannerSystem` +14. `GoapRunnerSystem` +15. `ActuatorSystem` +16. `EventHandlerSystem` +17. `CharacterSystem` +18. `BuoyancySystem` +19. `PhysicsSystem` +20. `HairPhysicsSystem` pose read-back +21. Rendering support systems (sun, skybox, water, light, LOD, etc.) Systems that write animation state or velocity should respect systems that run before them. In particular, `PlayerControllerSystem` runs first and is the only @@ -135,6 +137,53 @@ bool isSpawnerLocked(flecs::entity spawner) const; Spawned characters have `EditorMarkerComponent` removed and are removed from `EditorUISystem` caches so they don't appear in editor lists. +### TerrainPrefabSpawnerSystem + +Distance-based spawn/despawn of prefab instances on terrain +(`TerrainPrefabSpawnerComponent` holds `prefabPath` + squared distances; the +world transform lives on `TransformComponent`). Singleton access via +`TerrainPrefabSpawnerSystem::getInstance()` (same pattern as `TerrainSystem`). +Spawner Y is snapped to the terrain surface at placement and on spawn; +`complyTerrainToPrefab()` flattens the terrain under the spawned prefab's +footprint using fixup chunks. Spawned instances are runtime-only: +`EditorMarkerComponent` is stripped and they are never serialized. The editor +"prefab spawn mode" (Terrain panel, ESC to exit) places spawners by clicking +the terrain. + +### RoadSystem + +Per-terrain procedural roads (`RoadGraph` component, geometry built by +`roadlib/RoadGeometryLib` — see `ProceduralRoadGeometry.md`): + +- Wedge/segment meshes, physics colliders, terrain compliance fixups and + navmesh dirty-marking are driven by the graph version; bump + `RoadGraph::bumpVersion()` after every edit. +- **Edge splitting**: each edge row in the Terrain panel edge list has a + "Split" button (`RoadGraph::splitEdge` at t=0.5, prefab slots are + remapped onto the two halves). +- **Connect tool**: the road toolbar "Connect" radio pins a source node in + the viewport; clicking another node joins them (validated by + `RoadGraph::connectNodes` — self/duplicate and wedge-angle violations are + rejected with feedback; cross-page connections are auto-split at the page + boundary with an inserted node). The second node becomes the new + source, so chains can be drawn click-by-click. +- **Smoothing**: a selected node with exactly two neighbors shows a + "Smooth A–B–C" action (`RoadGraph::smoothNode`, position relaxation — + no graph mutation). +- **Roadside prefabs**: three fixed slots per edge (`prefabLeft`, + `prefabRight`, `prefabMid` — anchors at left curb / right curb / + centerline). Spawning is per edge (`RoadSystem::m_edgePrefabs`, keyed by + `RoadSystem::edgePrefabKey`) with plain-meter spawn/despawn hysteresis + (`RoadConfig::prefabSpawnDistance`/`prefabDespawnDistance`); road edit + mode force-spawns the selected edge's slots as a WYSIWYG preview. + Teardown goes through the shared `PrefabSystem::destroyInstance()`. +- **Sidewalks**: `RoadConfig::sidewalkEnabled` etc. append an elevated + pedestrian strip per wedge outer curb (widened curb-offset chains — + see `ProceduralRoadGeometry.md` §15); terrain compliance starts its + falloff at the sidewalk outer edge. + +Road edit mode exits with ESC (same as prefab spawn mode). + ### Player Character Resolution Never resolve the player by matching `PlayerControllerComponent::targetCharacterName` diff --git a/src/features/editScene/CMakeLists.txt b/src/features/editScene/CMakeLists.txt index 86e5faa..aa9e960 100644 --- a/src/features/editScene/CMakeLists.txt +++ b/src/features/editScene/CMakeLists.txt @@ -47,6 +47,7 @@ set(EDITSCENE_SOURCES systems/PlayerControllerSystem.cpp systems/CharacterSlotSystem.cpp systems/CharacterSpawnerSystem.cpp + systems/TerrainPrefabSpawnerSystem.cpp systems/OgreEntityHack.cpp systems/CharacterRegistry.cpp systems/MarkovNameGenerator.cpp @@ -103,6 +104,7 @@ set(EDITSCENE_SOURCES ui/TriangleBufferEditor.cpp ui/CharacterSlotsEditor.cpp ui/CharacterSpawnerEditor.cpp + ui/TerrainPrefabSpawnerEditor.cpp ui/CharacterIdentityEditor.cpp ui/AnimationTreeEditor.cpp ui/AnimationTreeNodeEditor.cpp @@ -147,6 +149,7 @@ set(EDITSCENE_SOURCES components/TriangleBufferModule.cpp components/CharacterSlotsModule.cpp components/CharacterSpawnerModule.cpp + components/TerrainPrefabSpawnerModule.cpp components/AnimationTreeModule.cpp components/AnimationTree.cpp components/CharacterModule.cpp @@ -280,7 +283,9 @@ set(EDITSCENE_HEADERS systems/EventHandlerSystem.hpp ui/EventHandlerEditor.hpp components/PrefabInstance.hpp + components/TerrainPrefabSpawner.hpp ui/PrefabInstanceEditor.hpp + ui/TerrainPrefabSpawnerEditor.hpp systems/ItemSystem.hpp components/Item.hpp @@ -300,6 +305,7 @@ set(EDITSCENE_HEADERS systems/EditorWaterPlaneSystem.hpp systems/TerrainSystem.hpp systems/RoadSystem.hpp + systems/TerrainPrefabSpawnerSystem.hpp systems/LightSystem.hpp systems/CameraSystem.hpp systems/LodSystem.hpp @@ -861,5 +867,9 @@ add_custom_command(TARGET editSceneEditor POST_BUILD COMMAND ${CMAKE_COMMAND} -E copy_directory "${CMAKE_CURRENT_SOURCE_DIR}/resources" "${CMAKE_CURRENT_BINARY_DIR}/resources" + # Test fixtures (road side-prefab test, etc.) + COMMAND ${CMAKE_COMMAND} -E copy_directory + "${CMAKE_CURRENT_SOURCE_DIR}/tests/prefabs" + "${CMAKE_CURRENT_BINARY_DIR}/tests/prefabs" COMMENT "Copying resources to editSceneEditor build directory" ) diff --git a/src/features/editScene/EditorApp.cpp b/src/features/editScene/EditorApp.cpp index 68bc66b..9855c2e 100644 --- a/src/features/editScene/EditorApp.cpp +++ b/src/features/editScene/EditorApp.cpp @@ -20,6 +20,8 @@ #include "systems/CharacterSlotSystem.hpp" #include "systems/CharacterSpawnerSystem.hpp" #include "components/CharacterSpawner.hpp" +#include "systems/TerrainPrefabSpawnerSystem.hpp" +#include "components/TerrainPrefabSpawner.hpp" #include "systems/AnimationTreeSystem.hpp" #include "systems/HairPhysicsSystem.hpp" #include "systems/BehaviorTreeSystem.hpp" @@ -388,6 +390,8 @@ void EditorApp::destroyEditorSystems() m_animationTreeSystem.reset(); m_characterSlotSystem.reset(); m_characterSpawnerSystem.reset(); + /* Despawn prefab instances before terrain/physics teardown. */ + m_terrainPrefabSpawnerSystem.reset(); m_proceduralMeshSystem.reset(); m_proceduralMaterialSystem.reset(); m_proceduralTextureSystem.reset(); @@ -587,6 +591,15 @@ void EditorApp::setup() m_uiSystem.get()); m_characterSpawnerSystem->initialize(); + // Setup TerrainPrefabSpawner system (M6): needs the camera + // system for distance checks and physics for body cleanup on + // despawn; must exist before the terrain system updates. + m_terrainPrefabSpawnerSystem = + std::make_unique( + m_world, m_sceneMgr, m_cameraSystem.get(), + m_uiSystem.get(), m_physicsSystem.get()); + m_terrainPrefabSpawnerSystem->initialize(); + // Setup AnimationTree system m_animationTreeSystem = std::make_unique( m_world, m_sceneMgr); @@ -1456,6 +1469,9 @@ void EditorApp::setupECS() // Register PrefabInstance component m_world.component(); + // Register TerrainPrefabSpawner component + m_world.component(); + // Register Item and Inventory components m_world.component(); m_world.component(); @@ -1633,6 +1649,12 @@ bool EditorApp::frameRenderingQueued(const Ogre::FrameEvent &evt) m_terrainSystem->update(evt.timeSinceLastFrame); } + /* --- Terrain prefab spawners (after terrain so spawn Y-snap + * and compliance see fresh terrain data) --- */ + if (m_terrainPrefabSpawnerSystem) { + m_terrainPrefabSpawnerSystem->update(); + } + /* --- Static world generation (meshes + physics) --- */ if (m_roomLayoutSystem) { m_roomLayoutSystem->update(); @@ -1892,6 +1914,21 @@ bool EditorApp::keyPressed(const OgreBites::KeyboardEvent &evt) return true; } + /* Exit terrain prefab spawn mode with ESC. */ + if (m_terrainPrefabSpawnerSystem && + m_terrainPrefabSpawnerSystem->isSpawnEditing() && + evt.keysym.sym == OgreBites::SDLK_ESCAPE) { + m_terrainPrefabSpawnerSystem->setSpawnEditMode(false); + return true; + } + + /* Exit road edit mode with ESC. */ + if (m_terrainSystem && m_terrainSystem->getRoadEditMode() && + evt.keysym.sym == OgreBites::SDLK_ESCAPE) { + m_terrainSystem->setRoadEditMode(false); + return true; + } + if (m_gameMode == GameMode::Game) { if (evt.keysym.sym == OgreBites::SDLK_ESCAPE) { if (m_gamePlayState == GamePlayState::Playing) diff --git a/src/features/editScene/EditorApp.hpp b/src/features/editScene/EditorApp.hpp index 6ccc0ba..e93cb0b 100644 --- a/src/features/editScene/EditorApp.hpp +++ b/src/features/editScene/EditorApp.hpp @@ -24,6 +24,7 @@ class ProceduralMaterialSystem; class ProceduralMeshSystem; class CharacterSlotSystem; class CharacterSpawnerSystem; +class TerrainPrefabSpawnerSystem; class AnimationTreeSystem; class HairPhysicsSystem; class BehaviorTreeSystem; @@ -235,6 +236,10 @@ public: { return m_characterSpawnerSystem.get(); } + TerrainPrefabSpawnerSystem *getTerrainPrefabSpawnerSystem() const + { + return m_terrainPrefabSpawnerSystem.get(); + } ProceduralMeshSystem *getProceduralMeshSystem() const { return m_proceduralMeshSystem.get(); @@ -295,6 +300,7 @@ private: std::unique_ptr m_proceduralMeshSystem; std::unique_ptr m_characterSlotSystem; std::unique_ptr m_characterSpawnerSystem; + std::unique_ptr m_terrainPrefabSpawnerSystem; std::unique_ptr m_animationTreeSystem; std::unique_ptr m_hairPhysicsSystem; std::unique_ptr m_behaviorTreeSystem; diff --git a/src/features/editScene/ProceduralRoadGeometry.md b/src/features/editScene/ProceduralRoadGeometry.md index e2353c2..0a4e2a5 100644 --- a/src/features/editScene/ProceduralRoadGeometry.md +++ b/src/features/editScene/ProceduralRoadGeometry.md @@ -689,3 +689,95 @@ Build and run: cmake --build --target RoadGeometryDemo ./RoadGeometryDemo ``` + + +## 15. Sidewalks (M5.15) + +Sidewalks are an optional elevated pedestrian strip appended to the road +slab along its outer curb. They are pure geometry: lane counts and the +drivable width are unchanged, so nothing derived from the road width +needs adjustment. Enabled per graph via `RoadConfig::sidewalkEnabled` +with `sidewalkWidth` (lateral extent), `sidewalkHeight` (top elevation +above the road surface), `sidewalkThickness` (fallback box thickness) +and `sidewalkMeshTemplate` (empty = procedural box). + +### 15.1 Template conventions + +The sidewalk template follows the same template-space conventions as the +road template (§2), with one change: the profile Y range is +`[-sidewalkThickness, 0]` — the strip *top* sits at template Y = 0, so +the phase-2 mapping `worldY = roadSurfaceY + sidewalkHeight + vy` lands +the top exactly `sidewalkHeight` above the road surface and the body +hangs below it. `makeSidewalkFallbackTemplate(sidewalkThickness)` +generates the default box profile; `RoadSystem::getSidewalkTemplate()` +mirrors `getRoadTemplate()` and caches the buffer per +`sidewalkMeshTemplate`/`sidewalkThickness` pair. + +### 15.2 Wedge strips — widened miter chains + +One strip per wedge along its outer curb, built with the same +three-phase pipeline as the road (concatenate → transform → append). +Phase 2 differs: instead of mapping template X across `[0, sideWidth]` +against the single curb offset, each vertex's X is mapped linearly +between two *widened* curb offsets: + +``` +offIn(d) = curbOffsetWidened(d, ROAD_SIDEWALK_WALL_GAP) +offOut(d) = curbOffsetWidened(d, ROAD_SIDEWALK_WALL_GAP + sidewalkWidth) +worldXZ = center(d) + offIn(d) + (offOut(d) - offIn(d)) * x +worldY = roadSurfaceY(d) + sidewalkHeight + vy +``` + +UVs: `u = halfEdgeU(...)` (phase-continuous, same as the road), `v` +across `[0, sidewalkWidth]`. + +`curbOffsetWidened()` is a static worker extracted from +`computeCurbOffset()`: it scales the per-half-edge offset vectors offA / +offB by `(len + widen) / len` *before* the miter math, then runs the +unchanged corner-blend pipeline. The public `computeCurbOffset()` is a +wrapper with `widen = 0`, so road geometry is bit-identical. + +**Why not `center + offset + normalize(offset) * x`?** Extending along +the normalized curb offset (the original plan) folds at inner corners: +the road's cross-sections pivot around the pinned miter corner K while +the curb chain transitions between half-edges, so consecutive sidewalk +rows computed from the same rays cross each other near K (coplanar / +piercing triangles and inverted normals — caught by +`road_geometry_overlap_test`). Widening the offset chains gives the +inner and outer sidewalk curbs their own pinned miter corners (K moved +outward by the gap and by gap+width respectively), so each chain keeps +the §5.4 no-fold property independently and the strip between them +cannot self-intersect. + +### 15.3 Z-fighting gap + +`ROAD_SIDEWALK_WALL_GAP = 0.002f` (RoadGraph.hpp) shifts the whole strip +slightly outward so the sidewalk's inner wall is never coplanar with the +road curb wall. The sidewalk top still overlaps the curb horizontally, +so the gap is invisible from above. + +### 15.4 Straight segments + +Dead-end segments (§7) get *two* sidewalk bands — one per curb (inbound +and outbound) — each extruded to a slab of `sidewalkThickness` whose top +sits at `roadSurfaceY + sidewalkHeight`. +`computeSegmentSidewalkBand()` computes the four top corners (offset +`width + ROAD_SIDEWALK_WALL_GAP` … `width + gap + sidewalkWidth` from +the centerline); `buildSegmentSidewalkGeometry()` extrudes both bands +via the shared `extrudeToSlab()`, skipping the far-end skirt so the +bands stay open where a future wedge would connect. + +### 15.5 Integration and terrain compliance + +Sidewalk triangles are appended to the same page `TriangleBuffer` as the +road, so LOD/visibility distance, the M5.9 collider soup and +`markNavMeshDirty()` need no changes. Sidewalks share the road +material. + +`RoadSystem::complyTerrain()` accounts for sidewalks when enabled: + +- the perpendicular falloff origin moves outward by `sidewalkWidth`, so + the shoulder fade starts at the sidewalk's outer edge; +- fixups under sidewalk vertices target the sidewalk *underside* + (`top - sidewalkThickness`), so the terrain hugs the strip body + instead of its elevated top. diff --git a/src/features/editScene/ProceduralRoadGeometryImprovement.md b/src/features/editScene/ProceduralRoadGeometryImprovement.md new file mode 100644 index 0000000..719f690 --- /dev/null +++ b/src/features/editScene/ProceduralRoadGeometryImprovement.md @@ -0,0 +1,454 @@ +# Procedural Road Geometry — Improvement Plan + +Procedural road geometry is specified in `ProceduralRoadGeometry.md`; the road +milestones and their status live in `TerrainRequirements.md` (Milestone 5, +M5.1–M5.12). This plan covers three improvements: + +1. Edge splitting: create a node in the middle of an edge, remove the edge and + create two edges through the new node (A–B → A–C–B). +2. Sidewalks: an elevated extra lane where vehicles do not go, intended for + pedestrians, enabled by checkbox, with selectable template geometry (same + mechanism as the normal lane template) and a procedural-box fallback. +3. Road prefabs: per-edge left/right side prefabs plus one road-midpoint + prefab, with distance-based unload, edit-time preview, easy prefab + selection and leak-free resource handling. +4. It should be somehow possible to select two nodes on the graph and create + new edge. +5. It should be possible to select 3 neighboring nodes on the graph and smooth + their position (position relaxation, no graph mutation — see §5). + +Each section first states what already exists (with code references — the +source is the only source of truth) and then defines the remaining work. + +--- + +## 1. Edge splitting (A–B → A–C–B) + +**Status (2026-08-18): ✅ DONE** — per-row "Split" `SmallButton` in the +edge list (toolbar button removed), slot remap in `splitEdge`, editor-layer +Y snap of the new node, `testRoadDataModel` extended. Verified by the +terrain test suite (`--headless --run-terrain-tests=1`). + +### Current state + +The core operation already exists and is exposed in the editor: + +- `RoadGraph::splitEdge(edgeIndex, t)` (`components/RoadGraph.hpp:523`) removes + the edge, inserts node C at `lerp(A, B, t)` and pushes two new edges A–C and + C–B. C is snapped to integer half-edge lengths per M5.7 + (`snapToIntegerLength`), and `verticalOffset`/`roadLevel` are interpolated so + the road surface stays continuous. +- UI: "Split Selected Edge" button in the Terrain panel's road-section + toolbar (`ui/TerrainEditor.hpp:733`) — hardcoded `t = 0.5`, i.e. exactly + the requested middle split. The interaction is counter-intuitive: the + toolbar is overloaded, and splitting takes two steps — select the edge in + the edge list, then find the toolbar button. +- Tests: `testRoadDataModel` (`systems/TerrainTests.cpp:1334`) and + `testRoadEdgeLengthConstraint` (`:2986–3038`). + +### Remaining work + +- **1.1 Move splitting into the edge list.** The edge list + (`ui/TerrainEditor.hpp:864-891`) renders one `Selectable` row per edge + (with a "Remove" `SmallButton` on the selected row), and the node list + already shows the per-row action pattern with its "Connect" + `SmallButton`s (`:835-860`). Give *every* edge row its own "Split" + `SmallButton` (placed `SameLine` after the row label, like "Connect") that + splits that edge at `t = 0.5` — one click, no prior selection, and the + overloaded toolbar shrinks accordingly: delete the toolbar's "Split + Selected Edge" button (`:733-738`). After the split, select the new node + so the gizmo and node inspector follow it. +- **1.2 Prefab carry-over on split (bug).** `splitEdge` copies the old edge + struct into both new edges, so `sidePrefabs` (and later the §3 slots) are + *duplicated*: both halves spawn the same prefabs. Remap instead: prefab + `edgeT < actualT` stays on edge A–C with `edgeT' = edgeT / actualT`; + otherwise it moves to C–B with `edgeT' = (edgeT - actualT) / (1 - actualT)` + (`actualT` is the post-snap position already computed in `splitEdge`). + Left/right side semantics are preserved because both halves keep the A→B + orientation. With the §3 slot model: the left/right slots remap their + `edgeT` this way on both halves; the mid slot transfers to the half that + contains the original midpoint, the other half gets an empty slot. +- **1.3 New-node Y snap.** `RoadGraph` is terrain-agnostic, so `splitEdge` + only interpolates Y (see the comment at `RoadGraph.hpp:561`). After a UI + split, snap C's Y to `terrainHeight(x, z) + verticalOffset` in the editor + layer, the same way `addRoadNodeAt` does + (`systems/EditorUISystem.cpp:2303`). +- **1.4 Tests.** Extend `testRoadDataModel`: split an edge carrying prefab + slots and assert remapped `edgeT`/side on both halves and that no prefab is + duplicated; assert road-surface continuity at C (levels from both halves + match). + +--- + +## 2. Sidewalks + +**Status (2026-08-18): ✅ DONE** — data model, widened-chain geometry +(see the corrected §2.2 mapping below), template + fallback, terrain +compliance, editor UI and tests; specified in +`ProceduralRoadGeometry.md` §15. Verified by `testRoadWedgeGeometry` +(new sidewalk case), `testRoadSerialization` and +`road_geometry_overlap_test` (sidewalks enabled in all regression +configs). + +New feature — nothing existed before (no mentions in code or docs). + +### Requirement mapping + +"Extra lane where vehicles will not go": sidewalks are pure geometry appended +to the road slab; lane counts (`resolveLaneCounts`) are unchanged, so the +drivable width — and everything derived from it — is unaffected. Pedestrian +routing over sidewalks is crowd/navmesh integration and is out of scope here. + +### 2.1 Data model (`components/RoadGraph.hpp`, `RoadConfig`) + +Global per-graph settings, next to `roadMeshTemplate`: + +```cpp +bool sidewalkEnabled = false; // the checkbox +float sidewalkWidth = 1.5f; // lateral width in world units +float sidewalkHeight = 0.15f; // top elevation above road surface +float sidewalkThickness = 0.3f; // fallback box thickness +std::string sidewalkMeshTemplate; // empty = procedural box +``` + +Serialization goes into the existing `roadConfig` block +(`systems/SceneSerializer.cpp:4233` write, `:4341` read) with the defaults +above, so old scenes load with sidewalks disabled. Per-edge opt-out flags are +a possible later extension and are deliberately not part of this plan. + +### 2.2 Geometry (`roadlib/RoadGeometryLib.cpp`) + +One sidewalk strip per wedge along its outer curb (each wedge is one lateral +half of the road, so per-wedge strips automatically produce both sides and +continuous corner sidewalks), built with the same three-phase pipeline: + +- **Template** — new `RoadSystem::getSidewalkTemplate(cfg)` mirroring + `getRoadTemplate` (`systems/RoadSystem.cpp`), same template-space + conventions (`ProceduralRoadGeometry.md` §2), cached like + `m_templateBuffer`. Empty/missing `sidewalkMeshTemplate` yields a + procedural box of `sidewalkWidth × sidewalkThickness` profile. +- **Phase 1** — identical concatenation (`N = ceil(L1 + L2)` copies). +- **Phase 2 mapping** — the strip starts at the curb and extends outward. + The originally planned per-vertex extension + `worldXZ = center(d) + offset(d) + normalize(offset(d)) * (vx * sidewalkWidth + ROAD_SIDEWALK_WALL_GAP)` + **folds at inner corners** (proven numerically during implementation): + cross-sections pivot around the pinned miter corner K while the curb + chain transitions between half-edges, so consecutive strip rows cross + near K — the overlap test reported coplanar/piercing triangles and + inverted normals at K. The shipped mapping instead interpolates + between two *widened* curb-offset chains: + ``` + offIn(d) = curbOffsetWidened(d, ROAD_SIDEWALK_WALL_GAP) + offOut(d) = curbOffsetWidened(d, ROAD_SIDEWALK_WALL_GAP + sidewalkWidth) + worldXZ = center(d) + offIn(d) + (offOut(d) - offIn(d)) * vx + worldY = roadSurfaceY(d) + sidewalkHeight + vy + ``` + `curbOffsetWidened` scales offA/offB by `(len + widen) / len` before + the miter math, so the inner and outer sidewalk curbs get their own + pinned miter corners and each keeps the §5.4 no-fold property + independently (gap/fold-freedom is inherited by construction, not by + ray-sharing). Public `computeCurbOffset` is a wrapper with + `widen = 0` — road geometry unchanged. UVs: + `u = halfEdgeU(...)` (phase-continuous, same as road), `v` across + `[0, sidewalkWidth]`. +- **Z-fighting** — the sidewalk inner wall is coplanar with the road curb + wall over a small Y band. `ROAD_SIDEWALK_WALL_GAP` (~2 mm) shifts the whole + sidewalk strip slightly outward so the walls never share a plane; the top + surfaces overlap horizontally, so no hole is visible. +- **Straight segments** (dead ends, §7 of the spec) get *two* sidewalk bands + (inbound and outbound curb), each elevated and extruded via the existing + `extrudeToSlab`. +- The strips are appended to the same page `TriangleBuffer` as the road, so + LOD/visibility distance, the M5.9 collider soup and `markNavMeshDirty()` + need no changes. Sidewalks initially share the road material; a separate + material (separate buffer/entity per page) is out of scope. + +### 2.3 Terrain compliance + +`RoadSystem::complyTerrain` writes fixups under every buffer vertex, so +sidewalk vertices are flattened automatically. Only the falloff origin moves: +pass `sideWidth + sidewalkWidth` (when enabled) into `writeComplianceFalloff` +(`systems/RoadSystem.hpp:291`) so the shoulder fade starts at the sidewalk's +outer edge. + +### 2.4 Editor UI + +In the Terrain panel "Road Config" tree (`ui/TerrainEditor.hpp:683`): an +"Enabled" checkbox, width/height/thickness sliders, and a template combo +reusing the existing mesh-scan helpers (`scanMeshFiles`/`getMeshList`, +`:1460–1518`) with a "(Procedural Box)" default — the same UX as the road +mesh combo at `:648`. All edits call `rg.bumpVersion()`. + +### 2.5 Tests + +- `testRoadWedgeGeometry` (`TerrainTests.cpp:2135`): with sidewalks enabled — + lateral extent reaches `halfWidth + sidewalkWidth`, sidewalk top Y ≈ + `roadSurfaceY + sidewalkHeight`, vertex count grows by the sidewalk + template count × `ceil(L)`; dead-end segment case has both bands. +- `tests/road_geometry_overlap_test.cpp`: enable sidewalks in the existing + A–B–C configurations (flat, corner raised/lowered) — the + coplanar-overlap/piercing checks must stay green (miter no-fold + regression). +- `testRoadSerialization` (`TerrainTests.cpp:1346`): `roadConfig` round-trip + with the new fields; loading a pre-sidewalk scene yields disabled defaults. + +--- + +## 3. Road prefabs + +**Status (2026-08-18): ✅ DONE** — three fixed slots per edge with legacy +JSON migration, per-edge spawn records keyed by `edgePrefabKey`, +plain-meter spawn/despawn hysteresis, edit-mode force preview, shared +`PrefabSystem::destroyInstance`, yaw/Y/anchor transforms, picker UI. +Verified by the reworked `testRoadSidePrefabs`. + +### Current state (M5.11) and its problems + +`RoadEdge::sidePrefabs` is an unbounded vector of +`{prefabPath, edgeT, sideOffset, leftSide}` (`RoadGraph.hpp:185-211`), +serialized per edge (`SceneSerializer.cpp:4254-4277` / `:4371-4396`), spawned +by `RoadSystem::spawnSidePrefabs` (`systems/RoadSystem.cpp:985-1050`) once per +page finalize. Known defects: + +- **Duplicates across pages**: it iterates *all* graph edges for every + finalizing page, so each loaded road page spawns its own copy of every + edge's prefabs. +- **Name collisions**: instances are named `road_prefab___`; two + prefabs on one edge with equal `edgeT` collide. +- **No yaw alignment**: road direction is never applied to the instance + rotation. +- **Wrong Y**: snapped to raw terrain height; road levels/elevation ignored. +- **Leaks**: teardown is a bare `entity.destruct()` (`RoadSystem.cpp:417`, + `:456`) — the Ogre SceneNode/Entity and Jolt bodies survive (contrast + `TerrainPrefabSpawnerSystem::destroyInstanceRecursive`, + `TerrainPrefabSpawnerSystem.cpp:182-225`). +- **Silent failure**: `PrefabSystem::createInstance` returns a live empty + entity when the file is missing (`PrefabSystem.cpp:129-141`), so the + "failed to spawn" branch is dead and the empty proxy is tracked. +- **No distance unload** and **no prefab picker** (raw `InputText`, + `ui/TerrainEditor.hpp:1045-1087`). + +### 3.1 Data model — fixed slots instead of a vector + +Replace `sidePrefabs` with three fixed slots per edge +(`components/RoadGraph.hpp`): + +```cpp +struct RoadEdgePrefabSlot { + std::string prefabPath; // empty = slot disabled + float edgeT = 0.5f; // normalized position along the edge + float lateralOffset = 0.0f; // meters from the anchor, away from road + float yOffset = 0.0f; +}; +// RoadEdge members: +RoadEdgePrefabSlot prefabLeft; // anchor: left curb (looking A -> B) +RoadEdgePrefabSlot prefabRight; // anchor: right curb +RoadEdgePrefabSlot prefabMid; // anchor: centerline, edge midpoint +``` + +Anchors implement "zero position at the road edge, tunable along and +perpendicular": the side-slot anchor sits on the curb at `edgeT` — lateral +distance from the centerline equals the resolved half-width *of that side* +(asymmetric roads have different left/right widths) — and `lateralOffset = 0` +places the prefab exactly at the road edge. `prefabMid` anchors at the +centerline (`edgeT`, default 0.5) and is the "one per edge, aligned to the +road midpoint" prefab. + +**Serialization/migration** (`SceneSerializer.cpp:4254`, `:4371`): write the +three slots; when loading an old scene, migrate the first `leftSide == true` +entry to `prefabLeft` and the first `false` entry to `prefabRight` (remap +`sideOffset` to `lateralOffset = sideOffset - sideHalfWidth`), log and drop +any extra entries. + +### 3.2 Spawning, distance unload, lifetime (`systems/RoadSystem.cpp`) + +- **Per-edge records, not per-page.** Track spawn state in + `RoadSystem`, keyed by the ordered node-id pair `(min(A,B), max(A,B))` — + stable across edge-vector reordering. Remove + `RoadPageGeometry::spawnedPrefabs`. This fixes the cross-page duplication. +- **Distance gating.** New `RoadConfig` fields `prefabSpawnDistance = 150` + and `prefabDespawnDistance = 250` (serialized as plain meters, same + convention as `TerrainPrefabSpawnerComponent`). `RoadSystem::update()` + re-evaluates when the camera has moved beyond a threshold (the + `SpawnerRecord`/`CAM_REEVAL_DIST_SQ` pattern in + `TerrainPrefabSpawnerSystem.hpp:157-178`): spawn when the edge is within + spawn distance *and* its endpoint pages are loaded; despawn beyond despawn + distance (hysteresis), on page unload, on edge removal and on teardown. +- **Transform.** Position: `center(edgeT)` plus the lateral anchor described + in §3.1. Y: interpolated road surface height + `lerp(A.y + roadLevelA, B.y + roadLevelB, edgeT) + yOffset` (terrain + compliance already flattens the curb area to road level). Rotation: yaw + from the edge direction, composed with the prefab's own stored root + rotation. Unique names: `road_prefab___`. +- **Failure handling.** Verify the prefab instantiated (file readable / + instance flagged) before tracking it; otherwise despawn and log once. +- **Graph changes.** On version bump, diff slot data per edge key and respawn + only edges whose slots changed; a split (§1.2) remaps slots onto the new + halves so prefabs survive the edit. +- **No leaks.** Hoist `TerrainPrefabSpawnerSystem::destroyInstanceRecursive` + into a shared `PrefabSystem::destroyInstance(entity)` and call it from both + systems; it recursively destroys child entities, Jolt bodies, Ogre entities + and scene nodes before `destruct()`. + +### 3.3 Editor + +- Rework the edge inspector prefab section (`ui/TerrainEditor.hpp:1045-1087`) + into three slot groups (Left / Right / Mid), each with: a prefab picker + combo fed by the existing `scanPrefabFiles` helper (`:508-523`, same pattern + as the terrain prefab-spawn picker), and `edgeT` / `lateralOffset` / + `yOffset` sliders. +- **Edit-time preview.** While road edit mode is active, force-spawn the + selected edge's prefabs regardless of distance so placement is WYSIWYG; + leaving edit mode re-applies distance rules. (Road edit mode currently has + no ESC handling — adding ESC-to-exit alongside this, mirroring prefab spawn + mode at `EditorApp.cpp:1917-1923`, is a cheap consistency win.) +- Config UI: spawn/despawn distance sliders in the "Road Config" tree. + +### 3.4 Tests + +Extend `testRoadSidePrefabs` (`TerrainTests.cpp:3462-3622`, fixtures in +`tests/prefabs/`): side anchors sit at the curb (±half-width), mid slot at +the centerline midpoint; yaw follows the edge direction; Y follows road +level; spawn/despawn hysteresis under camera movement; force-spawn in edit +mode; **no duplicates with two road pages loaded**; scene-graph node and +Ogre entity counts return to baseline after despawn/teardown cycles (leak +check); split remaps slots without duplication. + +--- + +## 4. Connecting two nodes with a new edge + +**Status (2026-08-18): ✅ DONE** — "Connect" radio tool with viewport +pinning and chaining (the second node becomes the new source), +validation with feedback (self/duplicate, cross-page via +`edgeStaysWithinOnePage`, wedge-angle rollback), `testRoadDataModel` +extended. The node-list "Connect" buttons remain for off-screen nodes. + +### Current state + +- The graph op exists: `RoadGraph::joinNodes(nodeA, nodeB)` + (`components/RoadGraph.hpp:593`) creates the edge if absent; `addEdge` + (`:480`) rejects self-edges, duplicates and missing nodes, and `joinNodes` + logs a warning for sub-`ROAD_MIN_EDGE_LENGTH` edges. +- A UI path exists but is list-only: select node A's row, then click the + "Connect" `SmallButton` on node B's row (`ui/TerrainEditor.hpp:835-860`). + There is no 3D-view gesture, and the selection model is single-node + (`RoadSystem::m_selectedNodeId`). +- `RoadGraph::edgeStaysWithinOnePage` (`RoadGraph.hpp:631`) encodes the hard + page constraint — an edge is valid only when both endpoints lie in the + same terrain page — but it is never called today, so cross-page edges can + be created and silently break the page-bucketed geometry (M5.4). +- The M5.5 wedge-angle limits (30°–270°) are only checked by + `RoadGraph::validate()`, never enforced when an edge is created. + +### Remaining work + +- **4.1 Connect tool for the 3D view.** Add a third radio "Connect" next to + "Move Node" / "Add Node" (`ui/TerrainEditor.hpp:716-725`; `RoadEditTool` + enum at `systems/TerrainSystem.hpp:331-335`). The first click in the + viewport pins the source node (reuse `pickRoadNode`, + `systems/EditorUISystem.cpp:2217`) and gives it the selection highlight; + the second click on another node calls `joinNodes` and clears the pin; + clicking empty terrain moves or clears the pin. This is the "select two + nodes, create edge" gesture; the node-list "Connect" buttons stay for + off-screen nodes. +- **4.2 Validation with feedback.** On a connect attempt: reject + self/duplicate via `joinNodes` (exists); reject cross-page pairs by wiring + up `edgeStaysWithinOnePage` (page world size comes from the + `TerrainComponent`); after the edge is added, re-enumerate the wedges at + both endpoint nodes and roll back with `removeEdge` if a new wedge + violates the 30°–270° limits. Report the rejection reason in the road + section, following the existing "Validate Road Graph" modal pattern + (`ui/TerrainEditor.hpp:741-767`). +- **4.3 Tests.** Extend `testRoadDataModel` + (`systems/TerrainTests.cpp:1246`): duplicate/self connects rejected, + cross-page pair rejected, angle-violating connect rolled back, successful + connect bumps the graph version. + +--- + +## 5. Smoothing three neighboring nodes + +**Status (2026-08-18): ✅ DONE** — `RoadGraph::smoothNode` (position +relaxation, no graph mutation) with min-length / page-crossing / +wedge-angle rollback, inspector UI ("Smooth A–B–C" + strength slider) +and neighbor highlight, editor-layer Y re-snap, `testRoadDataModel` +extended. + +### Current state + +No smoothing exists. Corners are intentionally sharp — the centerline is a +polyline (`ProceduralRoadGeometry.md` §5.3) — and selection is single-node +only. + +Key observation: a node with exactly two neighbors unambiguously identifies +a 3-node chain A–B–C (itself plus its two neighbors), so the triple needs no +new multi-selection infrastructure — clicking the middle node B selects it. + +### Semantics (decided) + +Position relaxation, no graph mutation: B moves toward the straight line +A–C; A and C are anchors and never move (they connect to the rest of the +network). A strength factor (0..1, default 0.5) controls how far B moves +per application, so repeated applications converge to straight: + +``` +B'.xz = lerp(B.xz, midpoint(A.xz, C.xz), strength) +B'.verticalOffset = lerp(B.verticalOffset, + (A.verticalOffset + C.verticalOffset) / 2, strength) +``` + +Y is then re-snapped to `terrainHeight + verticalOffset` by the editor layer +(the same helper used by the gizmo drag and by §1.3), keeping the road +surface continuous. + +### Remaining work + +- **5.1 Graph op.** New `RoadGraph::smoothNode(int nodeId, float strength)` + next to `splitEdge` (`components/RoadGraph.hpp:523`): pure graph function, + returns false unless the node has exactly two neighbors; applies the lerp + above (terrain-agnostic — no Y snap) and bumps the version. +- **5.2 Validation.** A move is rejected and reverted when it would shorten + an incident edge below `ROAD_MIN_EDGE_LENGTH`, push B across a terrain + page boundary (`edgeStaysWithinOnePage` for both incident edges), or + create a wedge outside 30°–270° (re-enumerate at A, B and C). +- **5.3 UI.** In the selected-node inspector (`ui/TerrainEditor.hpp:894-958`), + when the selected node has exactly two neighbors: show the derived triple + ("Smooth A–B–C"), a strength slider and a "Smooth" button; extend + `buildSelectionHighlight` (`systems/RoadSystem.cpp`) to also mark the two + neighbor nodes in a second color so the affected triple is visible in the + viewport. Disabled with a hint when the degree is not 2 (junctions and + endpoints have no unique triple). +- **5.4 Tests.** `testRoadDataModel` additions: degree ≠ 2 rejected; B moved + by the exact lerp; `verticalOffset` averaged; min-length and page-crossing + moves reverted. Geometry needs no new cases — existing wedge tests + already cover straight and angled chains. + +--- + +## 6. Implementation order + +1. **§3 Road prefabs** — the slot data model and lifecycle fixes are a + prerequisite for §1.2 (split remapping operates on the slot model). +2. **§1 Edge splitting** — small, bounded; finishes the split/prefab + interaction. +3. **§4 Node connecting** and **§5 Corner smoothing** — small, independent + graph-editing additions; either order. +4. **§2 Sidewalks** — independent of the others; the largest piece + (geometry pipeline changes). + +## 7. Documentation and definition of done + +Per the project rules, every landed item updates docs and tests in the same +change: + +- `TerrainRequirements.md`: add sub-milestones **M5.13 Road prefab slots and + distance unload**, **M5.14 Edge split polish**, **M5.15 Sidewalks**, + **M5.16 Node connect tool**, **M5.17 Corner smoothing**, each with a + status block and a definition-of-done checklist naming the verifying tests + (`--headless --run-terrain-tests=1` suite and + `road_geometry_overlap_test`). +- `ProceduralRoadGeometry.md`: new section specifying the sidewalk strip + (template conventions, per-vertex mapping, miter behavior, UV, segment + bands) once §2 lands. **Landed as §15.** +- This document: mark items done with dates as they land. diff --git a/src/features/editScene/TerrainML5Verification.md b/src/features/editScene/TerrainML5Verification.md index 197a39f..c1684b9 100644 --- a/src/features/editScene/TerrainML5Verification.md +++ b/src/features/editScene/TerrainML5Verification.md @@ -36,9 +36,14 @@ others are not, and `UNCOVERED` when no automated test exists at all. | M5.9 | Road physics colliders| `testRoadPageMeshes` (partial) | PARTIAL | bodyId validity and PhysicsColliderComponent presence asserted at page finalize time. **Missing**: physical interaction (raycast against road collider, collider removal on rebuild). | | M5.9.6| Road collider debug visibility | — | UNCOVERED | New item: toggle for road colliders in physics debug draw. Not tested in headless suite. | | M5.9.5| Fixup chunk support | `testFixupChunks` | COVERED | writeFixup, sampleHeightAt reads override, save/load round-trip, clearAll | -| M5.10 | Terrain compliance | — | UNCOVERED | `RoadSystem::complyTerrain()` has no automated test. Perpendicular falloff (laneWidth*2 fade) needs implementation. | -| M5.11 | Roadside prefab spawning | — | UNCOVERED | `RoadSystem::spawnSidePrefabs()` has no automated test | -| M5.12 | Serialization + wiring| `testRoadSerialization` | COVERED | Config/nodes/edges/sidePrefabs JSON round-trip; wiring: roadSystem lifecycle covered by testRoadPageAssignment + testRoadPageMeshes | +| M5.10 | Terrain compliance | `testTerrainCompliance` | COVERED | Under-road / mid-fade / fade-end / beyond-fade heights vs base heightmap, save/load + clear round-trips. With sidewalks enabled (M5.15) the falloff origin moves to the sidewalk outer edge and fixups under sidewalk vertices target the strip underside. | +| M5.11 | Roadside prefab spawning | `testRoadSidePrefabs` | COVERED (reworked by M5.13) | Per-edge slot spawn/respawn/despawn, distance gating, edit-mode preview, teardown | +| M5.12 | Serialization + wiring| `testRoadSerialization` | COVERED | Config/nodes/edges/prefab-slot JSON round-trip (legacy `sidePrefabs` migrates); wiring: roadSystem lifecycle covered by testRoadPageAssignment + testRoadPageMeshes | +| M5.13 | Road prefab slots + distance unload | `testRoadSidePrefabs`, `testRoadSerialization` | COVERED | Slot anchors/yaw/Y, per-edge records (`RoadSystem::getEdgePrefabs`), hysteresis, edit-mode preview, no cross-page duplicates, leak-free despawn | +| M5.14 | Edge split polish | `testRoadDataModel` | COVERED | Per-row Split button, slot `edgeT` remap without duplication, surface continuity at the new node | +| M5.15 | Sidewalks | `testRoadWedgeGeometry`, `testRoadSerialization`, `road_geometry_overlap_test` | COVERED | Strip extent/top Y/vertex growth, both segment bands, config round-trip; overlap test runs all A–B–C configs with sidewalks enabled | +| M5.16 | Node connect tool | `testRoadDataModel` | COVERED | Duplicate/self rejected, cross-page auto-split at the page boundary (incl. corner dedupe), axis-crossing same-page accept, wedge-angle rollback, version bump | +| M5.17 | Corner smoothing | `testRoadDataModel` | COVERED | Degree ≠ 2 rejected, exact lerp, `verticalOffset` averaged, min-length/page-crossing moves reverted | ### 1.1 M5.9 Collider Coverage Detail @@ -172,8 +177,11 @@ sampling path observes, including the perpendicular falloff. ### 2.3 `testRoadSidePrefabs` -**Purpose**: verify `RoadSystem::spawnSidePrefabs()` creates and destroys -prefab instances correctly. +**Purpose**: verify the M5.13 slot-based roadside prefab lifecycle in +`RoadSystem` — spawn at the correct anchor, respawn on slot edits, +distance gating, edit-mode preview and leak-free teardown. (Originally +written against the M5.11 `spawnSidePrefabs()`/`sidePrefabs` vector; +fully reworked with M5.13.) **Test prefab fixture**: a minimal `.prefab` JSON file placed in `src/features/editScene/tests/prefabs/tiny_cube.prefab`. This is a self-contained @@ -182,17 +190,25 @@ test resource; it must not be mixed with user-created prefabs. **Steps**: 1. Ensure the test prefab is loadable (the file is registered in a resource group accessible during tests). -2. Create terrain, add a road edge with one `RoadSidePrefab` pointing at - `tiny_cube.prefab` with `edgeT=0.5, sideOffset=3, leftSide=true`. +2. Create terrain, add a road edge, fill its `prefabLeft` slot with + `tiny_cube.prefab` (`edgeT=0.5, lateralOffset=0`) and set huge + spawn/despawn distances so the test camera is always in range. 3. Pump frames until road mesh + prefabs exist. -4. Verify `RoadPageGeometry::spawnedPrefabs` is non-empty (1 entity). -5. Verify the spawned entity is alive, has a `TransformComponent`, and its - position is approximately the expected world position (edge midpoint + 3 - units left). -6. Bump graph version (add a dummy node+edge) → pump frames. - - Verify old prefab entity is no longer alive. - - Verify `spawnedPrefabs` now contains the new prefab instance. -7. Deactivate terrain → verify spawned prefab entity is not alive. +4. Verify the per-edge record (`RoadSystem::getEdgePrefabs()` keyed by + `RoadSystem::edgePrefabKey(n1, n2)`) has a live `left` instance with a + `TransformComponent` at the left-curb anchor (edge midpoint offset by + the side half-width), road-level Y and yaw aligned to the edge + direction. +5. Add an unrelated node+edge → pump frames: the prefab must stay alive + (spawn state is per edge, not per page — no cross-page duplicates). +6. Edit the slot (`edgeT 0.5 → 0.25`) → pump frames: old instance + destroyed, new instance at the remapped anchor. +7. Shrink spawn/despawn distances to 1 m → the instance despawns and the + root scene-node child count returns to baseline (no leak). +8. Enable road edit mode and select the edge → the prefab force-spawns + (edit-time preview); leaving edit mode despawns it again. +9. Restore large distances (respawn), then destroy the terrain entity → + the prefab entity is not alive (teardown). ## 3. Manual Verification Procedures @@ -282,11 +298,11 @@ ordered by dependency. | # | Item | Files to modify | Status | |---|------|-----------------|--------| | W0 | Sweep-based wedge geometry (M5.6 gaps + overlaps) — **superseded 2026-08-02**: the radial curb sweep left node-center holes, diagonal > 180° bands, bowed through-roads and double-height segments; replaced by the mitered polyline sweep (`computeWedgeOutline`/`triangulateOutline` + `emitSlab`) per user direction | `RoadSystem.cpp`, `RoadSystem.hpp`, `TerrainTests.cpp` | ✅ DONE (2026-08-02, reworked) | -| W1 | M5.10 perpendicular falloff in `complyTerrain()` | `RoadSystem.cpp` | ✅ DONE | +| W1 | M5.10 perpendicular falloff in `complyTerrain()` | `RoadSystem.cpp` | ✅ DONE (2026-08-16 rework: per-sample fade writes, see below) | | W2 | Helper `computeComplianceHeight()` + unit test | `RoadSystem.cpp`, `TerrainTests.cpp` | ✅ DONE | | W3 | `testTerrainCompliance` (automated) | `TerrainTests.cpp`, `TerrainTests.hpp` | ✅ DONE | | W4 | `testRoadColliderInteraction` (automated) | `TerrainTests.cpp`, `TerrainTests.hpp` | ✅ DONE | -| W5 | Road collider debug draw toggle (M5.9.6) | `RoadSystem.hpp/.cpp`, `TerrainSystem.hpp/.cpp`, `TerrainEditor.hpp` | — | +| W5 | Road collider debug draw toggle (M5.9.6) | `RoadSystem.hpp/.cpp`, `TerrainSystem.hpp/.cpp`, `TerrainEditor.hpp` | ✅ DONE (2026-08-16) | | W6 | Test prefab fixture `tiny_cube.prefab` | `src/features/editScene/tests/prefabs/tiny_cube.prefab` (new) | ✅ DONE | | W7 | `testRoadSidePrefabs` (automated) | `TerrainTests.cpp`, `TerrainTests.hpp` | ✅ DONE | | W8 | Register new tests in `TerrainTestRunner::run()` | `TerrainTests.cpp` | ✅ DONE | @@ -298,7 +314,7 @@ ordered by dependency. | M5.1–M5.8 automated coverage | ✅ Adequate (8/8 sub-items have tests) | | M5.6 wedge geometry | ✅ Mitered polyline sweep (2026-08-02) — replaces the broken radial curb sweep (W0 rework): wedge = one mesh bent along the 2-segment centerline polyline, exact width at corners, no node holes/overlaps | | M5.9 automated coverage | ✅ W4 adds raycast+rebuild verification | -| M5.9.6 road collider debug toggle | ❌ Not implemented → W5 | +| M5.9.6 road collider debug toggle | ✅ Implemented (W5, 2026-08-16) | | M5.10 perpendicular falloff | ✅ Implemented → W1+W2 | | M5.10 automated coverage | ✅ W3 covers falloff + save/load | | M5.11 automated coverage | ✅ W6+W7 cover prefab spawn + teardown | @@ -311,7 +327,66 @@ ordered by dependency. (2026-08-02 rework; radial curb sweep attempt reverted). - [x] W1–W4, W6–W8 implemented. - [x] `./editSceneEditor --headless --run-terrain-tests=1` passes with all - 22 tests green per iteration (verified 2026-07-31). + 22 tests green per iteration (re-verified 2026-08-16 after the + re-audit fixes below). - [ ] Manual verification walkthroughs 3.1–3.7 are executed and pass. -- [ ] `ctest -R editSceneTerrainTest` passes in CI. -- [ ] W5 (road collider debug draw toggle) implemented. +- [x] `ctest -R editSceneTerrainTest` passes in CI (verified 2026-08-16, + 102 s). +- [x] W5 (road collider debug draw toggle) implemented (2026-08-16). + +## 6. Re-audit fixes (2026-08-16) + +A code-level re-audit (prompted by "do not trust completion status") found +several items marked ✅ that were not actually working; all fixed and now +covered by the 22-test headless suite: + +- **W1 was not implemented**: `complyTerrain()` wrote only full-compliance + fixups under the road; the `laneWidth * 2` perpendicular fade did not + exist. Implemented as `RoadSystem::writeComplianceFalloff()` (wedge + outer curb + both segment sides), with `TerrainSystem::sampleBaseHeightAt()` + added so fade targets blend toward the natural height rather than reading + back already-written fixups. +- **W5 was not implemented**: the `TerrainBodyDrawFilter` road-ID set + (`RoadSystem::m_roadBodyIds`) was never populated, so the "Show Road + Colliders" checkbox drew nothing. `createPageCollider()` / + `destroyPageCollider()` now maintain the set. +- **Fixup chunk grid unusable at spec density**: M5.9.5/4.2's literal + addressing (chunk span `worldSize/256`, 256x256 samples → one sample per + `worldSize/65536` units) cannot be filled by the compliance writer, and + page vertices (sampling at ~`worldSize/64` spacing) never see the fixups. + Chunks now span the full per-page `worldSize` with 256x256 samples (one + sample per `worldSize/256` units — "same as base heightmap", section 4.2); + chunk indices coincide with page indices. Fixup files written before this + change used the old grid and must be deleted + (`heightmaps//terrain_fixup/` or "Clear All Fixups"). +- **Sentinel blending toward 0.0**: partially written chunk cells blended + toward zero, digging trenches at fixup borders; unwritten corners now fall + back to the natural (base + noise) height at the corner. +- **Falloff writes were sparse point splats** (invisible between chunk + cells). The fade pass now evaluates the exact fade target at every fixup + sample inside the band (`writeFixupSample()`), so bilinear reads reproduce + the linear ramp. The fade pass runs BEFORE the full-compliance pass so + slab-underside values win on shared samples. +- **Segment underside depth mismatch**: the segment pass wrote + `centerY - roadThickness` while the wedge pass wrote `topY - + roadThickness` (= `centerY - roadThickness/2`); unified on the half + thickness. +- **Roadside prefabs leaked on graph rebuild** (M5.11): `buildPageMeshes()` + rebuilt mesh + collider but never destroyed `spawnedPrefabs`; old + instances survived and new ones spawned on top. Old prefabs are now + destroyed at the start of every page mesh rebuild. *(Superseded by + M5.13: spawn state moved out of the pages into per-edge records and + teardown goes through the shared `PrefabSystem::destroyInstance()`.)* +- **Roadside prefabs were serialized with the scene** (M5.12): spawned + instances kept their `EditorMarkerComponent` and, as flecs children of the + terrain entity, were written into the scene file and duplicated on reload. + `spawnSidePrefabs()` now strips the marker (runtime-only entities). + *(M5.13: instances are created marker-free by the new spawn path.)* +- **Tests strengthened**: `terrainCompliance` checks under-road / mid-fade / + fade-end / beyond-fade heights against the natural (procedural) base + height plus save/load and clear round-trips; `roadColliderInteraction` + raycasts the slab top (40.15) and underside (39.85) on a Y=40 road clear + of the terrain and verifies collider replacement across a graph rebuild; + `roadSidePrefabs` verifies slot anchor position/yaw/Y, respawn on slot + edit, distance gating, edit-mode preview and teardown destruction; the + `tiny_cube.prefab` fixture is staged next to the test binary by CMake. diff --git a/src/features/editScene/TerrainRequirements.md b/src/features/editScene/TerrainRequirements.md index 2ad837d..324f54b 100644 --- a/src/features/editScene/TerrainRequirements.md +++ b/src/features/editScene/TerrainRequirements.md @@ -640,11 +640,18 @@ Use `EShapeSubType::User1` for the shape subtype. - **Naming**: `x_z.bin` where chunk coordinates are derived from world coordinates: ``` - int chunkX = floor(worldX / (worldSize / 256)); -int chunkZ = floor(worldZ / (worldSize / 256)); + int chunkX = floor(worldX / worldSize); + int chunkZ = floor(worldZ / worldSize); ``` - So chunk `(0,0)` covers world `(0,0)` to `(worldSize/256, worldSize/256)` in - X and Z. + So chunk `(0,0)` covers world `(0,0)` to `(worldSize, worldSize)` in X and Z + — one chunk per terrain page, one sample per `worldSize/256` units (the same + density as the base heightmap, matching "same as base heightmap" above). + **Corrected 2026-08-16**: the original formula (`worldSize / 256` per chunk, + i.e. one sample per `worldSize/65536` units) made the fixup grid 256x denser + than the base heightmap; road-compliance writes could not fill it and page + meshes never saw the fixups. Fixup files written before the correction use + the old grid and must be deleted (`heightmaps//terrain_fixup/` or + the "Clear all fixups" button). - **Storage directory**: `heightmaps//terrain_fixup/` — registered as an Ogre resource location when the scene is loaded so the definer can load them on demand. The `` prefix matches the base heightmap directory scheme @@ -966,6 +973,13 @@ struct TerrainPrefabSpawnerComponent { }; ``` +> **Implementation note**: as implemented, `position`/`rotation` are NOT +> stored in the component — they live on the entity's `TransformComponent` +> (single transform source, so editor gizmo moves cannot desync; same +> precedent as `CharacterSpawnerComponent`). The implemented component holds +> only `prefabPath`, `spawnDistanceSq`, `despawnDistanceSq`, and the runtime +> `spawnedEntity`. + ### 6.3 TerrainPrefabSpawnerSystem A lightweight system that: @@ -2119,14 +2133,18 @@ view, rendered by sweeping a 1-unit mesh template along the graph, and makes the terrain conform to the road surface without intersecting it. Roads may be asymmetric: an edge can have a different number of lanes in each direction. -**State snapshot (2026-07-30)** — re-verified against the working tree: -`editSceneEditor` builds cleanly. Nineteen headless tests pass. Milestone 5 -has all 13 sub-items implemented, but the verification audit -(`TerrainML5Verification.md`) identifies gaps in automated test coverage for -M5.9 (physics interaction), M5.10 (terrain compliance), and M5.11 (roadside -prefab spawning), plus a spec-vs-implementation discrepancy in M5.10's -perpendicular falloff. See `TerrainML5Verification.md` for the complete -verification plan, manual test procedures, and open questions. +**State snapshot (2026-08-16)** — re-verified against the working tree: +`editSceneEditor` builds cleanly. All 22 headless tests pass and +`ctest -R editSceneTerrainTest` is green. A code-level re-audit on +2026-08-16 (completion statuses were not trusted) found several items marked +done that were not actually working: M5.10's perpendicular falloff and the +M5.9.6 road-collider debug toggle were missing, the fixup chunk grid was too +dense to function (corrected in section 4.2), segment underside depth was +inconsistent, and roadside prefabs leaked on rebuild and were serialized +with the scene. All fixed and covered by tests; see +`TerrainML5Verification.md` section 6 for the full list. The manual +walkthroughs (`TerrainML5Verification.md` 3.1–3.7) still require a display +and remain pending. | Item | State | Notes | |------|-------|-------| @@ -2139,9 +2157,9 @@ verification plan, manual test procedures, and open questions. | M5.7 Edge length constraint | ✅ complete | `snapToIntegerLength()` + `ROAD_MIN_EDGE_LENGTH`; `splitEdge` snaps, `joinNodes` warns, `validate` rejects short edges; `roadEdgeLength` test green | | M5.8 Mesh assembly per page | ✅ complete | page entities with `TriangleBufferComponent(proceduralContent)` + `RenderableComponent` + `NavMeshGeometrySource` + `LodComponent`, `roadPageMeshes` test | | M5.9 Road physics colliders | ✅ complete | `createPageCollider`/`destroyPageCollider` in `RoadSystem`, asserted in `roadPageMeshes` | -| M5.9.5 Fixup chunk support | ✅ DONE (2026-07-29) | `writeFixup`/`saveFixups`/`clearAllFixups`/`sampleFixupLocked` implemented in `TerrainSystem.cpp`; wired into `sampleHeightAtLocked`; "Clear All Fixups" UI button; `fixupChunks` test green | -| M5.10 Terrain compliance | ✅ DONE (2026-07-29) | `RoadSystem::complyTerrain()` walks road wedges/segments, writes fixup under each top-surface vertex; "Comply Terrain to Roads" button wired; works with M5.9.5 | -| M5.11 Roadside prefab spawning | ✅ DONE (2026-07-30) | `RoadSystem::spawnSidePrefabs()` creates instances via `PrefabSystem` at edge positions with terrain-snapped Y; tracked and destroyed on page unload/rebuild | +| M5.9.5 Fixup chunk support | ✅ DONE (2026-07-29; grid corrected 2026-08-16) | `writeFixup`/`writeFixupSample`/`saveFixups`/`clearAllFixups`/`sampleFixupLocked` implemented in `TerrainSystem.cpp`; wired into `sampleHeightAtLocked`; "Clear All Fixups" UI button; chunk span corrected to the full per-page `worldSize` (see 4.2); `fixupChunks` test green | +| M5.10 Terrain compliance | ✅ DONE (2026-07-29; falloff added 2026-08-16) | `RoadSystem::complyTerrain()` walks road wedges/segments, writes fixup under each top-surface vertex, then fades over `laneWidth * 2` outside each curb (`writeComplianceFalloff`, per-sample writes); "Comply Terrain to Roads" button wired; `terrainCompliance` test green | +| M5.11 Roadside prefab spawning | ✅ DONE (2026-07-30; respawn fix 2026-08-16) | `RoadSystem::spawnSidePrefabs()` creates instances via `PrefabSystem` at edge positions with terrain-snapped Y; tracked and destroyed on page unload AND on mesh rebuild; spawned instances are stripped of `EditorMarkerComponent` so they stay runtime-only; `roadSidePrefabs` test green | | M5.12 Serialization + wiring | ✅ complete | serialization round-trip for roadConfig/nodes/edges/sidePrefabs; lifecycle + page detection + mesh/collider/prefab creation through M5.8/M5.9/M5.11; fixup save wired into SceneSerializer | #### M5.1 Road data model @@ -2923,8 +2941,10 @@ writers yet. sentinel `-FLT_MAX` = "no fixup here" (fall through to base heightmap + detail noise). - Chunk naming/addressing: `x_z.bin`, - `chunkX = floor(worldX / (worldSize / 256))` (same for Z); chunk (0,0) - covers world (0,0)..(worldSize/256, worldSize/256). + `chunkX = floor(worldX / worldSize)` (same for Z); chunk (0,0) covers one + full terrain page — one sample per `worldSize/256` units. **Updated + 2026-08-16**: the original `worldSize / 256` chunk span made the grid + unfillably dense; see the correction note in section 4.2. - Lazy creation: a chunk object/file appears only when a writer first writes into it; absent chunks mean "no fixup". - Runtime storage lives in `TerrainSystem` (in-memory chunk map keyed by chunk @@ -2950,12 +2970,17 @@ writers yet. #### M5.10 Terrain compliance (conform, not flatten) -**Status (2026-07-29): ✅ DONE.** `RoadSystem::complyTerrain()` walks every +**Status (2026-08-16): ✅ DONE.** `RoadSystem::complyTerrain()` walks every wedge and straight segment, samples the top-surface vertices, writes `roadSurfaceY - roadThickness` into the fixup chunk at each (X,Z), applies a -smooth perpendicular falloff over `laneWidth * 2`, then marks affected pages -dirty and saves the fixups. The "Comply Terrain to Roads" button in -`TerrainEditor` is wired and functional. +smooth perpendicular falloff over `laneWidth * 2` +(`writeComplianceFalloff()`, per-sample fade-target writes; the fade pass +runs before the full-compliance pass so slab-underside values win on shared +samples), then marks affected pages dirty and saves the fixups. The +"Comply Terrain to Roads" button in `TerrainEditor` is wired and functional. +(The 2026-07-29 ✅ was wrong — the falloff did not exist and the fixup grid +was too dense for page meshes to see the writes; both fixed 2026-08-16, +covered by the `terrainCompliance` headless test.) The terrain must hug the underside of the road: it should not poke through the road surface and should not leave gaps beneath it. The road surface itself is @@ -2985,35 +3010,41 @@ simplified representation, so the terrain matches the exact road surface. #### M5.11 Roadside prefab spawning -**Status (2026-07-30): ✅ DONE.** `RoadSystem::spawnSidePrefabs()` iterates -all edges' side prefabs during page finalize, computes world positions (edge -interpolation + lateral offset), snaps Y to terrain via `TerrainSystem`, and -calls `PrefabSystem::createInstance()`. Spawned entities are tracked in -`RoadPageGeometry::spawnedPrefabs` and destroyed on page unload/rebuild. +**Status (2026-08-16): ✅ DONE; reworked 2026-08-18 (see M5.13).** +Roadside prefabs spawn per edge from the edge's three prefab slots +(`prefabLeft`/`prefabRight`/`prefabMid`, replacing the original +`RoadSidePrefab` vector), are distance-gated with a camera hysteresis, +and are tracked in per-edge spawn records (`RoadSystem::m_edgePrefabs`) +instead of per-page lists — the original page-attached tracking +duplicated instances on edges crossing page boundaries. Spawned +instances are stripped of `EditorMarkerComponent` so they stay +runtime-only and are not serialized with the scene (M5.12). -For each `RoadEdge::RoadSidePrefab`: +For each configured `RoadEdgePrefabSlot`: -1. Compute base position along the edge: +1. Compute the anchor on the edge: ```cpp - Ogre::Vector3 pos = lerp(nodeA.position, nodeB.position, prefab.edgeT); + Ogre::Vector3 pos = lerp(nodeA.position, nodeB.position, slot.edgeT); ``` -2. Compute lateral offset: +2. Shift to the curb and apply the lateral offset (left of the A→B + travel direction for `prefabLeft`, right for `prefabRight`, on the + centerline for `prefabMid`): ```cpp - Ogre::Vector3 dir = (nodeB.position - nodeA.position); - dir.y = 0; - dir.normalise(); - Ogre::Vector3 side = Ogre::Vector3::UNIT_Y.crossProduct(dir); - if (!prefab.leftSide) side = -side; - pos += side * prefab.sideOffset; + Ogre::Vector3 left = Ogre::Vector3::UNIT_Y.crossProduct(dir); + pos += left * (sign * (halfWidth + slot.lateralOffset)); ``` -3. Snap Y to terrain: `pos.y = TerrainSystem::getInstance()->getHeightAt(pos)`. -4. Instantiate via `PrefabSystem::createInstance(prefabPath, terrainEntity, - pos, name, uiSystem)`. -5. Track spawned entities in `RoadPageGeometry::spawnedPrefabs` and destroy them - when the page geometry is rebuilt **or when the terrain page is unloaded**. +3. Y follows the interpolated road surface height (node Y + road level), + plus `slot.yOffset`. +4. Instantiate via `PrefabSystem::createInstance(prefabPath, + flecs::entity::null(), pos, name)` (root-level, so terrain teardown + leaves no dangling scene nodes) and align the prefab's -Z forward + with the edge direction. +5. Destroy instances when the camera leaves the despawn radius, when + the slot data changes, when the edge disappears, or on terrain + teardown — via the shared `PrefabSystem::destroyInstance()`. -Roadside prefabs are runtime-only and page-attached; they are regenerated from -edge data when the page is loaded. +Roadside prefabs are runtime-only; they are regenerated from edge data +whenever the spawn conditions hold. --- @@ -3043,10 +3074,137 @@ edge data when the page is loaded. --- +#### M5.13 Road prefab slots and distance unload + +**Status (2026-08-18): ✅ DONE** (improvement plan §3; +`ProceduralRoadGeometryImprovement.md`). + +The unbounded `RoadEdge::sidePrefabs` vector was replaced by three fixed +`RoadEdgePrefabSlot` slots — `prefabLeft`, `prefabRight`, `prefabMid` — each +with `prefabPath`, `edgeT`, `lateralOffset` (relative to the curb) and +`yOffset` (relative to the road surface). Legacy `sidePrefabs` scene data +migrates on load (first `leftSide` entry becomes `prefabLeft`, etc.). + +1. Spawn state lives in `RoadSystem::m_edgePrefabs`, one + `EdgePrefabRecord` per edge keyed by the ordered node-id pair — never per + page (the old per-page tracking duplicated instances on cross-page edges). +2. Spawn/despawn re-evaluates every frame with a camera-distance hysteresis + (`RoadConfig::prefabSpawnDistance`/`prefabDespawnDistance`, serialized); + distance is measured to the edge segment, and both endpoint pages must be + loaded. +3. Slot data changes (any field) respawn the edge's instances; a failed spawn + (missing file) is not retried until data or conditions change. +4. Road edit mode force-spawns the selected edge's prefabs regardless of + distance, so slot edits preview immediately. +5. Instances spawn at the root level with their -Z forward aligned to the + edge direction, and are destroyed through the shared + `PrefabSystem::destroyInstance()` (children, rigid bodies, scene nodes). + +Verified by `testRoadSidePrefabs`, `testRoadDataModel` and +`testRoadSerialization` (`--headless --run-terrain-tests=1`). + +--- + +#### M5.14 Edge split polish + +**Status (2026-08-18): ✅ DONE** (improvement plan §1). + +1. Every row of the Edges list has its own "Split" button — splitting no + longer requires selecting the edge first (the toolbar "Split Selected + Edge" button was removed). +2. The new midpoint node's Y is re-snapped to the terrain surface (split + interpolates Y between the endpoints, which cuts across terrain). +3. `splitEdge` remaps the prefab slots: left/right slots keep their curb side + on both halves (`edgeT` recomputed into the half's range); the mid slot + goes to the half containing the original anchor (t = 0.5). + +Verified by `testRoadDataModel` (slot remap cases). + +--- + +#### M5.15 Sidewalks + +**Status (2026-08-18): ✅ DONE** (improvement plan §2; specified in +`ProceduralRoadGeometry.md` §15). + +Elevated pedestrian strips along both curbs, appended to the road's page +geometry (inheriting its material, LOD, collider soup and navmesh dirtying). +Lane counts — and therefore the drivable width — are unaffected. + +1. `RoadConfig`: `sidewalkEnabled` (default off), `sidewalkWidth`, + `sidewalkHeight`, `sidewalkThickness`, `sidewalkMeshTemplate`; serialized + in the `roadConfig` block; UI in the terrain panel "Road Config" tree. +2. Per wedge, one strip along the outer curb via the three-phase pipeline + (`RoadGeometryLib::buildSidewalkGeometry`): the strip spans two widened + mitered curb chains (inner: curb + `ROAD_SIDEWALK_WALL_GAP`, outer: + + `sidewalkWidth`), so inner corners miter correctly without folding. + Top surface at `roadSurfaceY + sidewalkHeight`. +3. Template from `RoadSystem::getSidewalkTemplate()` (cached like + `getRoadTemplate`); empty/missing mesh yields a procedural box of + `sidewalkWidth × sidewalkThickness` profile with its top at template Y=0. +4. Dead-end straight segments get two bands (inbound and outbound curb) via + `RoadGeometryLib::computeSegmentSidewalkBand()` + `extrudeToSlab()`. +5. Terrain compliance: the perpendicular falloff starts at the sidewalk's + outer edge, and the full-compliance pass flattens the terrain under the + sidewalk bodies (fixup target = strip top - `sidewalkThickness`). + +Verified by `testRoadWedgeGeometry` (case 6), `road_geometry_overlap_test` +(sidewalks enabled in all regression configurations) and +`testRoadSerialization` (config round-trip). + +--- + +#### M5.16 Node connect tool + +**Status (2026-08-18): ✅ DONE** (improvement plan §4). + +1. `RoadGraph::connectNodes(nodeA, nodeB, worldSize, &error, &createdNodes)` + connects two existing nodes with validation: missing nodes, self-edges, + duplicates and wedge-angle violations are rejected and leave the graph + unchanged (angle check rolls back the inserted edges/nodes). Page + membership uses the origin-centred slot math of + `TerrainGroup::convertWorldPositionToTerrainSlot` (slot (0,0) spans + -worldSize/2 .. +worldSize/2), so edges crossing the world X/Z axes are + not falsely reported as cross-page. A genuine cross-page connection is + not rejected either: a node is inserted at every page-boundary crossing + along the segment (corner crossings deduplicated) and the edge is built + as a chain through them, keeping every edge inside one page; the + inserted node IDs are reported through `createdNodes` and the editor + re-snaps their Y to the terrain (`RoadSystem::snapNodesToTerrain`). +2. New "Connect" `RoadEditTool` radio: the first click pins a source node, + a click on a second node connects them; the second node becomes the new + source so paths chain with repeated clicks. Rejections surface as a modal + ("Road Connect Failed"). +3. The node-list "Connect" buttons use `connectNodes` as well (previously + the unchecked `joinNodes`). +4. ESC exits road edit mode (same convention as sculpt/paint/prefab modes). + +Verified by `testRoadDataModel` (connectNodes acceptance/rejection cases). + +--- + +#### M5.17 Corner smoothing + +**Status (2026-08-18): ✅ DONE** (improvement plan §5). + +1. `RoadGraph::smoothNode(nodeId, strength, worldSize)` relaxes a degree-2 + node toward the midpoint of its two neighbors (position relaxation, not + chamfering): XZ lerps by `strength`, `verticalOffset` lerps toward the + anchors' average. Moves that would break the minimum edge length, cross + a terrain page boundary, or create a wedge outside the 30–270° limits are + rejected and leave the graph unchanged. +2. Node inspector UI: "Smooth Strength" slider + "Smooth Node" button for + degree-2 nodes (hint text otherwise); Y is re-snapped to the terrain after + the move, same as after a gizmo drag. + +Verified by `testRoadDataModel` (smoothNode acceptance/rejection cases). + +--- + ### Milestone 5 definition of done - [x] M5.1 — Road data model: `RoadConfig`, `RoadNode`, `RoadEdge`, and - `RoadSidePrefab` live in `components/RoadGraph.hpp` with detailed + `RoadEdgePrefabSlot` live in `components/RoadGraph.hpp` with detailed purpose annotations; `TerrainComponent` owns a `RoadGraph`; helpers for node lookup, edge enumeration, lane-count resolution, ID generation, and graph validation are available and covered by tests. @@ -3074,9 +3232,10 @@ edge data when the page is loaded. - [x] "Comply Terrain to Roads" makes the terrain follow the road underside (sloped/curved where the road is sloped/curved) without gaps or intersections (M5.10, `RoadSystem::complyTerrain()` wired). -- [x] Roadside prefabs spawn at configured edge positions (Y snapped to terrain - surface) and are destroyed on page unload/rebuild; scene load regenerates - them (M5.11, `RoadSystem::spawnSidePrefabs()` via `PrefabSystem`). +- [x] Roadside prefabs spawn at configured edge positions (Y follows the road + surface) and are destroyed on distance unload/data change/teardown; + scene load regenerates them (M5.11 + M5.13, per-edge records via + `PrefabSystem::createInstance`/`destroyInstance`). - [x] Save/reload round-trip preserves road nodes, edges, config, and side prefabs (verified by `TerrainTests.cpp` headless test). - [x] Road meshes and colliders are created when a terrain page loads and @@ -3086,19 +3245,60 @@ edge data when the page is loaded. - [x] M5.7 — Edge length constraint: `splitEdge` snaps to integer half-lengths, `joinNodes` warns on short edges, `validate` rejects edges < 1 unit (verified by `testRoadEdgeLengthConstraint`). +- [x] M5.13 — Road prefab slots: three fixed slots per edge, per-edge spawn + records, camera-distance hysteresis, edit-mode preview (verified by + `testRoadSidePrefabs`). +- [x] M5.14 — Edge split polish: per-row Split buttons, terrain Y re-snap, + prefab slot remapping (verified by `testRoadDataModel`). +- [x] M5.15 — Sidewalks: elevated curb strips via widened mitered curb chains, + segment bands, template + config UI, terrain compliance (verified by + `testRoadWedgeGeometry` case 6 and `road_geometry_overlap_test`). +- [x] M5.16 — Node connect tool: validated `connectNodes`, 3D Connect tool + with chaining, error modal, ESC exits road edit mode (verified by + `testRoadDataModel`). +- [x] M5.17 — Corner smoothing: `smoothNode` position relaxation with + structural guards, node inspector UI (verified by `testRoadDataModel`). -**Overall M5 status**: ✅ ALL 13 sub-items complete (M5.1–M5.12). +**Overall M5 status**: ✅ ALL 18 sub-items complete (M5.1–M5.17). ### Milestone 6 — Prefab spawns and terrain compliance -- `TerrainPrefabSpawnerComponent` + `TerrainPrefabSpawnerModule`. -- `TerrainPrefabSpawnerSystem` with distance-based spawn/despawn via - `PrefabSystem`. -- Terrain-snap at placement (raycast against terrain). -- Terrain compliance tool: flatten terrain under prefab footprint using fixup - chunks. +- [x] `TerrainPrefabSpawnerComponent` + `TerrainPrefabSpawnerModule` + (`components/TerrainPrefabSpawner.hpp`, + `components/TerrainPrefabSpawnerModule.cpp`). + **Deviation from 6.2**: world position/rotation live on the entity's + `TransformComponent` (single transform source, same precedent as + `CharacterSpawnerComponent`); the component only stores `prefabPath`, + `spawnDistanceSq`, `despawnDistanceSq`, and the runtime + `spawnedEntity` handle. +- [x] `TerrainPrefabSpawnerSystem` with distance-based spawn/despawn via + `PrefabSystem` (`systems/TerrainPrefabSpawnerSystem.hpp/.cpp`). + Per-spawner camera hysteresis: distance re-evaluation is skipped while + the camera moved < 10 units and neither the spawner transform nor its + parameters changed (6.3 item 4). Prefab-path changes force a respawn; + an `OnRemove` observer despawns the instance when the spawner + component/entity is removed. Spawned instances are runtime-only: + `EditorMarkerComponent` is stripped and the instance is removed from + the editor UI caches, so they are never serialized. +- [x] Terrain-snap at placement (6.4): `snapToTerrain()` sets the spawner's + Y from `TerrainSystem::getHeightAt()` at placement time, on spawn, and + when the spawner is moved in the editor. +- [x] Terrain compliance tool (6.5): `complyTerrainToPrefab()` flattens the + terrain under the spawned prefab's world-AABB footprint using fixup + chunks (linear falloff band written first, full flatten under the + footprint second — same ordering as road compliance), marks affected + pages dirty, and saves the fixups. +- [x] Editor integration (7.2): prefab spawn mode with click-to-place on the + terrain (Terrain editor "Prefab Spawners" section, ESC to exit, + mutually exclusive with sculpt/paint/aux/road modes), + `TerrainPrefabSpawnerEditor` property panel with prefab picker, + Snap-to-terrain and Flatten buttons. +- [x] Scene serialization under the `terrainPrefabSpawner` key with plain + (non-squared) distances, Lua binding `TerrainPrefabSpawner`. +- [x] Headless test `testTerrainPrefabSpawners` in `TerrainTests.cpp` + (spawn + snap + runtime-only checks, serialization round-trip, + compliance flatten, distance despawn, respawn, observer cleanup). -Definition of done: towns/rocks spawn at configured locations on terrain and sit -flush; save/load round-trips correctly. +**Overall M6 status**: ✅ complete (verified by `--run-terrain-tests`). ## 11. Risks and Mitigations @@ -3135,8 +3335,8 @@ flush; save/load round-trips correctly. - [ ] Road page unload → road collider and navmesh contribution are removed. - [ ] Per-edge lane override → edge with `lanesAtoB=2` renders two lanes A→B. - [ ] Save/reload scene with roads → nodes, edges, and config restored. -- [ ] Place prefab spawner → prefab appears at correct distance and sits on terrain. -- [ ] Move camera far away and back → prefab despawns and respawns correctly. +- [x] Place prefab spawner → prefab appears at correct distance and sits on terrain. +- [x] Move camera far away and back → prefab despawns and respawns correctly. - [ ] Terrain reflected in water → terrain visible in reflection RTT pass. - [ ] Height function round-trip: write known heights → sample them back → values match. - [ ] Fixup chunk persistence: write fixup → save → reload → fixup still applied. diff --git a/src/features/editScene/components/RoadGraph.hpp b/src/features/editScene/components/RoadGraph.hpp index d5ee35b..ba9ca7a 100644 --- a/src/features/editScene/components/RoadGraph.hpp +++ b/src/features/editScene/components/RoadGraph.hpp @@ -5,6 +5,7 @@ #include #include #include +#include #include #include @@ -17,6 +18,14 @@ */ static const float ROAD_MIN_EDGE_LENGTH = 1.0f; +/** + * Lateral gap between the road curb wall and the sidewalk's inner wall + * (improvement plan §2.2). Without it the two walls would be coplanar + * and z-fight; the sidewalk top still overlaps the curb horizontally, + * so no hole is visible. + */ +static const float ROAD_SIDEWALK_WALL_GAP = 0.002f; + /** * Road configuration parameters — global settings that apply to the whole * road network owned by a single TerrainComponent. These values are @@ -77,6 +86,28 @@ struct RoadConfig { * Ogre material applied to generated road surfaces. */ std::string roadMaterialName = "RoadMaterial"; + + /** + * Sidewalks (improvement plan §2). When enabled, each side of the + * road gets an elevated pedestrian strip of sidewalkWidth lateral + * extent whose top sits sidewalkHeight above the road surface. + * sidewalkMeshTemplate follows the same template-space conventions + * as roadMeshTemplate; empty selects the generated procedural box + * (sidewalkWidth x sidewalkThickness profile). + */ + bool sidewalkEnabled = false; + float sidewalkWidth = 1.5f; + float sidewalkHeight = 0.15f; + float sidewalkThickness = 0.3f; + std::string sidewalkMeshTemplate; + + /** + * Camera distances in plain meters at which road-edge prefabs spawn + * and despawn (improvement plan §3.2). The despawn distance must + * exceed the spawn distance so the hysteresis band is stable. + */ + float prefabSpawnDistance = 150.0f; + float prefabDespawnDistance = 250.0f; }; /** @@ -119,6 +150,38 @@ struct RoadNode { int id = 0; }; +/** + * A prefab spawn slot attached to a road edge (improvement plan §3.1). + * + * The slot's anchor depends on which RoadEdge member it occupies: the + * left/right slots anchor at the corresponding curb (at the resolved + * half-width of that side of the road), prefabMid anchors at the + * centerline. The anchor sits at normalized position edgeT along the + * edge and is pushed lateralOffset meters further away from the road + * (0 = exactly at the curb / centerline) and yOffset meters above the + * interpolated road surface height. + * + * Side convention: looking from nodeA toward nodeB, the left side is + * UNIT_Y x dir (bounded by the inbound lanesBtoA half-width) and the + * right side is dir x UNIT_Y (bounded by the outbound lanesAtoB + * half-width). + * + * An empty prefabPath disables the slot. + */ +struct RoadEdgePrefabSlot { + /** Prefab JSON file path, relative to the prefabs directory. */ + std::string prefabPath; + + /** Normalized position along the edge from nodeA to nodeB. */ + float edgeT = 0.5f; + + /** Meters beyond the anchor, away from the road centerline. */ + float lateralOffset = 0.0f; + + /** Vertical offset above the interpolated road surface height. */ + float yOffset = 0.0f; +}; + /** * A connection between two RoadNode objects. * @@ -175,42 +238,35 @@ struct RoadEdge { */ int lanesBtoA = 0; - /** - * A prefab instance placed beside the road on a specific edge. - * - * Roadside prefabs are regenerated from edge data when the terrain - * page that contains them is loaded; they are not serialized as - * independent scene entities. - */ - struct RoadSidePrefab { - /** Prefab JSON file path, relative to the prefabs directory. */ - std::string prefabPath; + /** Prefab anchored at the left curb (looking from nodeA to nodeB). */ + RoadEdgePrefabSlot prefabLeft; - /** - * Normalized position along the edge from nodeA to nodeB. - * - * 0.0 places the prefab at nodeA; 1.0 places it at nodeB. - */ - float edgeT = 0.5f; + /** Prefab anchored at the right curb (looking from nodeA to nodeB). */ + RoadEdgePrefabSlot prefabRight; - /** - * Lateral distance from the road center line. - * - * The actual side (left or right) is determined by @c leftSide. - */ - float sideOffset = 5.0f; - - /** - * true = left side of the road when looking from nodeA toward nodeB. - * false = right side of the road when looking from nodeA toward nodeB. - */ - bool leftSide = true; - }; - - /** Prefab spawn points attached to this edge. */ - std::vector sidePrefabs; + /** Prefab anchored at the centerline, at edgeT (edge midpoint). */ + RoadEdgePrefabSlot prefabMid; }; +/** Minimum wedge swept angle; sharper wedges are rejected (M5.5). */ +static const float ROAD_WEDGE_MIN_ANGLE_DEG = 30.0f; + +/** Maximum wedge swept angle; only near-360 deg wedges are degenerate. */ +static const float ROAD_WEDGE_MAX_ANGLE_DEG = 359.9f; + +struct RoadGraph; +struct RoadWedge; +struct RoadStraightSegment; + +/** + * Enumerate all wedges and straight segments of a road graph (M5.5). + * Defined at the bottom of this header; declared here so RoadGraph + * editing helpers can validate the wedges their operations create. + */ +void enumerateWedges(const RoadGraph &graph, + std::vector &outWedges, + std::vector &outSegments); + /** * RoadGraph — container and lightweight utility layer for a terrain's road * network. @@ -572,6 +628,51 @@ struct RoadGraph { edgeB.nodeA = newId; edgeB.roadLevelA = 0.0f; + /* Carry the prefab slots over to the half that contains + * them instead of copying them onto both halves (which + * spawned every prefab twice). edgeT is remapped into the + * receiving half's local 0..1 range; the left/right sides + * are preserved because both halves keep the A->B + * orientation. The mid slot transfers to the half that + * contains the original edge midpoint (t = 0.5). */ + edgeA.prefabLeft = RoadEdgePrefabSlot(); + edgeA.prefabRight = RoadEdgePrefabSlot(); + edgeA.prefabMid = RoadEdgePrefabSlot(); + edgeB.prefabLeft = RoadEdgePrefabSlot(); + edgeB.prefabRight = RoadEdgePrefabSlot(); + edgeB.prefabMid = RoadEdgePrefabSlot(); + if (actualT > 1e-6f && actualT < 1.0f - 1e-6f) { + auto remapSlot = [&](const RoadEdgePrefabSlot &src, + RoadEdgePrefabSlot &dstA, + RoadEdgePrefabSlot &dstB) { + if (src.prefabPath.empty()) + return; + if (src.edgeT <= actualT) { + dstA = src; + dstA.edgeT = src.edgeT / actualT; + } else { + dstB = src; + dstB.edgeT = (src.edgeT - actualT) / + (1.0f - actualT); + } + }; + remapSlot(oldEdge.prefabLeft, edgeA.prefabLeft, + edgeB.prefabLeft); + remapSlot(oldEdge.prefabRight, edgeA.prefabRight, + edgeB.prefabRight); + if (!oldEdge.prefabMid.prefabPath.empty()) { + RoadEdgePrefabSlot mid = oldEdge.prefabMid; + if (0.5f <= actualT) { + mid.edgeT = 0.5f / actualT; + edgeA.prefabMid = mid; + } else { + mid.edgeT = (0.5f - actualT) / + (1.0f - actualT); + edgeB.prefabMid = mid; + } + } + } + edges.erase(edges.begin() + edgeIndex); edges.push_back(edgeA); edges.push_back(edgeB); @@ -616,12 +717,258 @@ struct RoadGraph { return addEdge(nodeA, nodeB); } + /** + * Check that every wedge seeded at one of the given nodes respects + * the M5.5 angle limits (30..270 degrees). + * + * Used by editing operations to validate the wedges their result + * would create without running a full-graph validate(). Defined + * after the wedge/segment structs at the bottom of this header. + */ + bool wedgeAnglesValidForNodes(const std::vector &nodeIds) const; + + /** + * Connect two existing nodes with a new edge, with validation + * (improvement plan §4.2). + * + * Unlike joinNodes this rejects structurally invalid connections: + * missing nodes, self-edges, duplicates and connections that would + * create a wedge outside the M5.5 angle limits. A rejected + * connection leaves the graph unchanged. + * + * When the endpoints sit in different terrain pages (only possible + * when @p worldSize > 0) the connection is not rejected: instead a + * new node is inserted at every page-boundary crossing along the + * straight line between the endpoints and the edge is built as a + * chain through those nodes, so every resulting edge stays inside + * one page. The inserted nodes get interpolated Y and + * verticalOffset; editor callers should re-snap their Y to the + * terrain (the IDs are reported via @p createdNodes). + * + * @param nodeA Stable ID of the first node. + * @param nodeB Stable ID of the second node. + * @param worldSize Length of one terrain page in world units, or 0 + * to skip the page check (headless tests). + * @param error If non-null and the connection is rejected, + * receives a human readable reason. + * @param createdNodes If non-null, receives the IDs of the nodes + * inserted at page-boundary crossings (empty when + * no split was needed). + * @return index of the first new edge, or -1 on rejection. + */ + int connectNodes(int nodeA, int nodeB, float worldSize = 0.0f, + std::string *error = nullptr, + std::vector *createdNodes = nullptr) + { + const RoadNode *na = findNodeById(nodeA); + const RoadNode *nb = findNodeById(nodeB); + if (!na || !nb) { + if (error) + *error = "One of the nodes does not exist."; + return -1; + } + if (nodeA == nodeB) { + if (error) + *error = "Cannot connect a node to itself."; + return -1; + } + if (hasEdge(nodeA, nodeB)) { + if (error) + *error = "An edge already connects these nodes."; + return -1; + } + + std::vector chain; + std::vector inserted; + chain.push_back(nodeA); + if (worldSize > 0.0f && + !edgeStaysWithinOnePage(na->position, nb->position, + worldSize)) { + /* Copy the endpoint data up front: addNode may + * reallocate the nodes vector and invalidate the + * na/nb pointers. */ + const Ogre::Vector3 posA = na->position; + const Ogre::Vector3 posB = nb->position; + const float offA = na->verticalOffset; + const float offB = nb->verticalOffset; + std::vector ts = + pageBoundaryCrossings(posA, posB, worldSize); + if (ts.empty()) { + if (error) + *error = "The nodes are in different " + "terrain pages."; + return -1; + } + for (float t : ts) { + Ogre::Vector3 pos = posA + (posB - posA) * t; + float off = offA + (offB - offA) * t; + inserted.push_back(addNode(pos, off)); + } + } + for (int id : inserted) + chain.push_back(id); + chain.push_back(nodeB); + + int firstEdge = -1; + std::vector createdEdges; + for (size_t i = 0; i + 1 < chain.size(); ++i) { + int idx = addEdge(chain[i], chain[i + 1]); + if (idx < 0) { + if (error) + *error = "joinNodes failed."; + break; + } + if (firstEdge < 0) + firstEdge = idx; + createdEdges.push_back(idx); + } + if (createdEdges.size() + 1 != chain.size() || + !wedgeAnglesValidForNodes(chain)) { + /* Roll back: drop the new edges (descending index + * order so earlier indices stay valid), then the + * inserted nodes. */ + std::sort(createdEdges.begin(), createdEdges.end(), + std::greater()); + for (int idx : createdEdges) + removeEdge((size_t)idx); + for (int id : inserted) + removeNode(id); + if (error && createdEdges.size() + 1 == chain.size()) + *error = "The connection would create a wedge " + "outside the 30-270 degree limits."; + return -1; + } + if (createdNodes) + *createdNodes = inserted; + return firstEdge; + } + + /** + * Compute the normalized positions (0..1) along the segment a->b at + * which it crosses a terrain page boundary. + * + * Page boundaries lie at (k + 0.5) * worldSize for integer k on + * both the X and Z axes (see edgeStaysWithinOnePage). A crossing + * through a grid corner is reported once. Crossings within 1e-4 of + * an endpoint are dropped: the endpoint effectively sits on the + * boundary and needs no split node. + * + * @return sorted, deduplicated list of crossing parameters. + */ + static std::vector + pageBoundaryCrossings(const Ogre::Vector3 &a, const Ogre::Vector3 &b, + float worldSize) + { + std::vector ts; + if (worldSize <= 0.0f) + return ts; + + auto collect = [&](float pa, float pb) { + float d = pb - pa; + if (std::fabs(d) < 1e-6f) + return; + float lo = std::min(pa, pb); + float hi = std::max(pa, pb); + long kFirst = (long)std::floor(lo / worldSize + 0.5f); + long kLast = (long)std::floor(hi / worldSize + 0.5f); + for (long k = kFirst; k < kLast; ++k) { + float boundary = + ((float)k + 0.5f) * worldSize; + float t = (boundary - pa) / d; + if (t > 1e-4f && t < 1.0f - 1e-4f) + ts.push_back(t); + } + }; + collect(a.x, b.x); + collect(a.z, b.z); + + std::sort(ts.begin(), ts.end()); + std::vector unique; + for (float t : ts) { + if (unique.empty() || t - unique.back() > 1e-4f) + unique.push_back(t); + } + return unique; + } + + /** + * Relax a 3-node chain A-B-C toward a straighter path (improvement + * plan §5). + * + * The node must have exactly two neighbors; they act as anchors and + * never move. The node's XZ position is lerped toward the anchors' + * midpoint by @p strength (0..1), and its verticalOffset is lerped + * toward the anchors' average offset by the same amount. The Y + * coordinate itself is left untouched — the editor re-snaps it to + * terrain_height + verticalOffset, same as after a gizmo drag. + * + * A move is rejected (the graph is left unchanged) when it would + * shorten an incident edge below ROAD_MIN_EDGE_LENGTH, push the + * node across a terrain page boundary (when @p worldSize > 0), or + * create a wedge outside the M5.5 angle limits. + * + * @return true if the node moved. + */ + bool smoothNode(int nodeId, float strength, float worldSize = 0.0f) + { + RoadNode *node = findNodeById(nodeId); + if (!node) + return false; + std::vector nbr = getNeighborIds(nodeId); + if (nbr.size() != 2) + return false; + const RoadNode *na = findNodeById(nbr[0]); + const RoadNode *nc = findNodeById(nbr[1]); + if (!na || !nc) + return false; + + float s = std::max(0.0f, std::min(1.0f, strength)); + Ogre::Vector3 mid = + (na->position + nc->position) * 0.5f; + Ogre::Vector3 newPos = node->position; + newPos.x += (mid.x - newPos.x) * s; + newPos.z += (mid.z - newPos.z) * s; + + auto horizDist = [](const Ogre::Vector3 &p, + const Ogre::Vector3 &q) { + float dx = p.x - q.x, dz = p.z - q.z; + return std::sqrt(dx * dx + dz * dz); + }; + if (horizDist(na->position, newPos) < ROAD_MIN_EDGE_LENGTH || + horizDist(newPos, nc->position) < ROAD_MIN_EDGE_LENGTH) + return false; + if (!edgeStaysWithinOnePage(na->position, newPos, + worldSize) || + !edgeStaysWithinOnePage(newPos, nc->position, worldSize)) + return false; + + Ogre::Vector3 oldPos = node->position; + node->position = newPos; + if (!wedgeAnglesValidForNodes( + { nbr[0], nodeId, nbr[1] })) { + node->position = oldPos; + return false; + } + + float midOffset = + (na->verticalOffset + nc->verticalOffset) * 0.5f; + node->verticalOffset += + (midOffset - node->verticalOffset) * s; + bumpVersion(); + return true; + } + /** * Check whether the straight-line edge between two world positions crosses * a terrain page boundary without a node at the crossing. * - * Terrain pages are axis-aligned squares of side @c worldSize. An edge - * is valid only if both endpoints lie inside the same page. + * Terrain pages are axis-aligned squares of side @c worldSize centred on + * the terrain-group origin: slot (0,0) spans + * -worldSize/2 .. +worldSize/2, matching + * TerrainGroup::convertWorldPositionToTerrainSlot with a zero group + * origin (plain floor(pos / worldSize) would put a spurious page + * boundary on the world X/Z axes). An edge is valid only if both + * endpoints lie inside the same page. * * @param a Start position in world space. * @param b End position in world space. @@ -635,10 +982,10 @@ struct RoadGraph { if (worldSize <= 0.0f) return true; - long pageAx = (long)std::floor(a.x / worldSize); - long pageAz = (long)std::floor(a.z / worldSize); - long pageBx = (long)std::floor(b.x / worldSize); - long pageBz = (long)std::floor(b.z / worldSize); + long pageAx = (long)std::floor(a.x / worldSize + 0.5f); + long pageAz = (long)std::floor(a.z / worldSize + 0.5f); + long pageBx = (long)std::floor(b.x / worldSize + 0.5f); + long pageBz = (long)std::floor(b.z / worldSize + 0.5f); return pageAx == pageBx && pageAz == pageBz; } @@ -767,12 +1114,6 @@ struct RoadStraightSegment { RoadHalfEdge halfEdge; }; -/** Minimum wedge swept angle; sharper wedges are rejected (M5.5). */ -static const float ROAD_WEDGE_MIN_ANGLE_DEG = 30.0f; - -/** Maximum wedge swept angle; only near-360 deg wedges are degenerate. */ -static const float ROAD_WEDGE_MAX_ANGLE_DEG = 359.9f; - /** * Enumerate all wedges and straight segments of a road graph (M5.5). * @@ -871,6 +1212,28 @@ inline void enumerateWedges(const RoadGraph &graph, } } +inline bool +RoadGraph::wedgeAnglesValidForNodes(const std::vector &nodeIds) const +{ + std::vector wedges; + std::vector segments; + enumerateWedges(*this, wedges, segments); + for (const auto &w : wedges) { + bool relevant = false; + for (int id : nodeIds) { + if (w.nodeId == id) { + relevant = true; + break; + } + } + if (!relevant) + continue; + if (w.sweptAngleDeg < ROAD_WEDGE_MIN_ANGLE_DEG || w.degenerate) + return false; + } + return true; +} + inline Ogre::Vector3 RoadGraph::snapToIntegerLength(const Ogre::Vector3 &anchor, const Ogre::Vector3 &pos) diff --git a/src/features/editScene/components/TerrainPrefabSpawner.hpp b/src/features/editScene/components/TerrainPrefabSpawner.hpp new file mode 100644 index 0000000..c6a4338 --- /dev/null +++ b/src/features/editScene/components/TerrainPrefabSpawner.hpp @@ -0,0 +1,42 @@ +#ifndef EDITSCENE_TERRAINPREFABSPAWNER_HPP +#define EDITSCENE_TERRAINPREFABSPAWNER_HPP +#pragma once + +#include +#include + +/** + * @brief Distance-based prefab spawner for terrain scenes (Milestone 6). + * + * Attaches to an entity with a TransformComponent. When the active camera + * is within spawnDistanceSq of the spawner, the prefab referenced by + * prefabPath is instantiated at the spawner's transform (Y snapped to the + * terrain surface). When the camera moves beyond despawnDistanceSq, the + * spawned instance is destroyed. + * + * The world-space position and rotation live on the entity's + * TransformComponent (deviation from the original TerrainRequirements.md + * 6.2 struct, which embedded position/rotation here — keeping a single + * transform source avoids stale duplicates when the spawner is moved with + * the editor gizmo; same precedent as CharacterSpawnerComponent). + * + * Spawned instances are runtime-only: they carry no EditorMarkerComponent + * and are never serialized with the scene. Distances are stored squared + * for fast comparison; the serializer writes them as plain distances. + */ +struct TerrainPrefabSpawnerComponent { + /** Prefab JSON file path (e.g. "prefabs/test_cube.json"). */ + std::string prefabPath; + + /** Squared distance at which the prefab should be spawned. */ + float spawnDistanceSq = 100.0f * 100.0f; + + /** Squared distance at which the prefab should be despawned. */ + float despawnDistanceSq = 200.0f * 200.0f; + + /** Runtime: the spawned instance entity (0 when not spawned). + * Managed by TerrainPrefabSpawnerSystem; not serialized. */ + flecs::entity_t spawnedEntity = 0; +}; + +#endif // EDITSCENE_TERRAINPREFABSPAWNER_HPP diff --git a/src/features/editScene/components/TerrainPrefabSpawnerModule.cpp b/src/features/editScene/components/TerrainPrefabSpawnerModule.cpp new file mode 100644 index 0000000..80b1b41 --- /dev/null +++ b/src/features/editScene/components/TerrainPrefabSpawnerModule.cpp @@ -0,0 +1,26 @@ +#include "TerrainPrefabSpawner.hpp" +#include "../ui/ComponentRegistration.hpp" +#include "../ui/TerrainPrefabSpawnerEditor.hpp" + +// Register TerrainPrefabSpawner component (Milestone 6) +REGISTER_COMPONENT_GROUP("Terrain Prefab Spawner", "Environment", + TerrainPrefabSpawnerComponent, + TerrainPrefabSpawnerEditor) +{ + registry.registerComponent( + "Terrain Prefab Spawner", "Environment", + std::make_unique(sceneMgr), + /* Adder */ + [](flecs::entity e) { + if (!e.has()) { + e.set( + TerrainPrefabSpawnerComponent{}); + } + }, + /* Remover */ + [](flecs::entity e) { + if (e.has()) { + e.remove(); + } + }); +} diff --git a/src/features/editScene/lua/LuaComponentApi.cpp b/src/features/editScene/lua/LuaComponentApi.cpp index 4acf6bc..c3a964b 100644 --- a/src/features/editScene/lua/LuaComponentApi.cpp +++ b/src/features/editScene/lua/LuaComponentApi.cpp @@ -34,6 +34,7 @@ #include "components/TriangleBuffer.hpp" #include "components/CharacterSlots.hpp" #include "components/CharacterSpawner.hpp" +#include "components/TerrainPrefabSpawner.hpp" #include "components/CharacterIdentity.hpp" #include "systems/CharacterRegistry.hpp" #include "components/AnimationTree.hpp" @@ -591,6 +592,27 @@ static void registerAllComponents() c.despawnDistanceSq = (float)lua_tonumber(L, -1); lua_pop(L, 1);); + // --- TerrainPrefabSpawner --- + REGISTER_COMPONENT( + TerrainPrefabSpawnerComponent, "TerrainPrefabSpawner", + lua_pushstring(L, c.prefabPath.c_str()); + lua_setfield(L, -2, "prefabPath"); + lua_pushnumber(L, c.spawnDistanceSq); + lua_setfield(L, -2, "spawnDistanceSq"); + lua_pushnumber(L, c.despawnDistanceSq); + lua_setfield(L, -2, "despawnDistanceSq"); + , if (lua_getfield(L, idx, "prefabPath"), lua_isstring(L, -1)) + c.prefabPath = lua_tostring(L, -1); + lua_pop(L, 1); + if (lua_getfield(L, idx, "spawnDistanceSq"), + lua_isnumber(L, -1)) + c.spawnDistanceSq = (float)lua_tonumber(L, -1); + lua_pop(L, 1); + if (lua_getfield(L, idx, "despawnDistanceSq"), + lua_isnumber(L, -1)) + c.despawnDistanceSq = (float)lua_tonumber(L, -1); + lua_pop(L, 1);); + // --- AnimationTree --- REGISTER_COMPONENT( AnimationTreeComponent, "AnimationTree", diff --git a/src/features/editScene/roadlib/RoadGeometryLib.cpp b/src/features/editScene/roadlib/RoadGeometryLib.cpp index 4439919..a26f7e5 100644 --- a/src/features/editScene/roadlib/RoadGeometryLib.cpp +++ b/src/features/editScene/roadlib/RoadGeometryLib.cpp @@ -162,6 +162,20 @@ Procedural::TriangleBuffer makeFallbackTemplate(float roadThickness) return tb; } +Procedural::TriangleBuffer makeSidewalkFallbackTemplate( + float sidewalkThickness) +{ + Procedural::TriangleBuffer tb = + makeFallbackTemplate(sidewalkThickness); + /* Shift the profile from Y in [-t/2, +t/2] to [-t, 0]: the + * sidewalk phase-2 mapping places template Y=0 at + * roadSurfaceY + sidewalkHeight, i.e. the strip top. */ + float h = std::max(0.01f, sidewalkThickness) * 0.5f; + for (auto &v : tb.getVertices()) + v.mPosition.y -= h; + return tb; +} + /* ---------------------------------------------------------------- * Phase 1 — Concatenated Strip * ---------------------------------------------------------------- */ @@ -192,11 +206,12 @@ void buildConcatenatedStrip(Procedural::TriangleBuffer &out, * those are the template caps, which would otherwise stack coplanar * faces at every copy join and at the edge midpoints (z-fighting). * The centerline wall (template X=0) is dropped as well — it is - * interior to the joined road body. + * interior to the joined road body — unless @p keepCenterWall is set + * (sidewalk strips, whose X=0 wall faces the road and stays visible). */ static void appendTemplateCopy(Procedural::TriangleBuffer &out, const Procedural::TriangleBuffer &templ, - float zOff, float clampD) + float zOff, float clampD, bool keepCenterWall) { const auto &tverts = templ.getVertices(); const auto &tidx = templ.getIndices(); @@ -228,7 +243,7 @@ static void appendTemplateCopy(Procedural::TriangleBuffer &out, std::fabs(b.x) < 1e-6f && std::fabs(c.x) < 1e-6f; const Ogre::Vector3 &n = tverts[(size_t)tidx[t]].mNormal; - if (wall0 && std::fabs(n.x) > 0.9f) + if (wall0 && !keepCenterWall && std::fabs(n.x) > 0.9f) continue; out.getIndices().push_back(base + tidx[t]); @@ -243,20 +258,22 @@ static void appendTemplateCopy(Procedural::TriangleBuffer &out, * Run 1 covers d in [0, L1] with ceil(L1) uniform copies from d = 0; * run 2 covers [L1, L1+L2] with ceil(L2) copies from d = L1. Vertices * past each run's end are clamped onto it, so the miter corner at d=L1 - * is always sampled. + * is always sampled. @p keepCenterWall keeps the template X=0 wall + * (sidewalk strips — see appendTemplateCopy). */ static void buildWedgeStrip(Procedural::TriangleBuffer &out, const Procedural::TriangleBuffer &templ, - float L1, float L2) + float L1, float L2, bool keepCenterWall = false) { out.getVertices().clear(); out.getIndices().clear(); int k1 = std::max(1, (int)std::ceil(L1)); int k2 = std::max(1, (int)std::ceil(L2)); for (int i = 0; i < k1; ++i) - appendTemplateCopy(out, templ, (float)i, L1); + appendTemplateCopy(out, templ, (float)i, L1, keepCenterWall); for (int j = 0; j < k2; ++j) - appendTemplateCopy(out, templ, L1 + (float)j, L1 + L2); + appendTemplateCopy(out, templ, L1 + (float)j, L1 + L2, + keepCenterWall); } /* ---------------------------------------------------------------- @@ -285,9 +302,20 @@ static Ogre::Vector3 wedgeCenterAt(const RoadWedge &wedge, return O + (MB - O) * ((d - L1) / L2); } -Ogre::Vector3 computeCurbOffset(const RoadWedge &wedge, - const RoadGraph &graph, - float d) +/** + * Width-parameterized curb offset worker (improvement plan §2.2). + * + * @p widen shifts both half-edge curb offsets outward along their own + * direction before the miter/blend math runs, producing the curb chain + * of a road widened by that amount (the miter corner and its pinned + * zone scale along). Sidewalk strips sample two such chains (inner at + * the curb + ROAD_SIDEWALK_WALL_GAP, outer + sidewalkWidth) so their + * cross-sections cannot fold at inner corners — extending a pinned + * miter fan past K would sweep the strip region twice. + */ +static Ogre::Vector3 curbOffsetWidened(const RoadWedge &wedge, + const RoadGraph &graph, float d, + float widen) { const RoadNode *node = graph.findNodeById(wedge.nodeId); if (!node) @@ -306,6 +334,15 @@ Ogre::Vector3 computeCurbOffset(const RoadWedge &wedge, Ogre::Vector3 offA = r1 * (h1.lanesOut * lw); Ogre::Vector3 offB = r2 * (-h2.lanesIn * lw); + if (widen != 0.0f) { + float la = offA.length(); + if (la > 1e-6f) + offA *= (la + widen) / la; + float lb = offB.length(); + if (lb > 1e-6f) + offB *= (lb + widen) / lb; + } + /* * Miter corner: intersection of the two constant-width curb * lines, expressed as parameters t1/t2 along each direction from @@ -383,6 +420,13 @@ Ogre::Vector3 computeCurbOffset(const RoadWedge &wedge, return cornerOff + (offB - cornerOff) * t; } +Ogre::Vector3 computeCurbOffset(const RoadWedge &wedge, + const RoadGraph &graph, + float d) +{ + return curbOffsetWidened(wedge, graph, d, 0.0f); +} + void transformWedgeVertices(Procedural::TriangleBuffer &strip, const RoadWedge &wedge, const RoadGraph &graph) @@ -731,6 +775,241 @@ bool buildSegmentGeometry(const RoadStraightSegment &segment, return true; } +/* ---------------------------------------------------------------- + * Sidewalks (improvement plan §2.2) + * ---------------------------------------------------------------- */ + +/** + * Phase 2 for sidewalk strips: like transformWedgeVertices, but the + * template X axis maps onto the band between two widened curb chains + * (inner: curb + ROAD_SIDEWALK_WALL_GAP, outer: + sidewalkWidth), and + * template Y=0 sits at roadSurfaceY + sidewalkHeight. + */ +static void transformSidewalkVertices(Procedural::TriangleBuffer &strip, + const RoadWedge &wedge, + const RoadGraph &graph) +{ + const RoadNode *node = graph.findNodeById(wedge.nodeId); + if (!node) + return; + + const RoadHalfEdge &h1 = wedge.first; + const RoadHalfEdge &h2 = wedge.second; + const Ogre::Vector3 &O = node->position; + Ogre::Vector3 dir1 = h1.direction; + Ogre::Vector3 dir2 = h2.direction; + float L1 = h1.halfLength > 1e-4f ? h1.halfLength : 1e-4f; + float L = L1 + (h2.halfLength > 1e-4f ? h2.halfLength : 1e-4f); + float sw = graph.config.sidewalkWidth; + float sh = graph.config.sidewalkHeight; + + float yO = O.y + nodeRoadLevel(graph, wedge.nodeId); + + for (auto &v : strip.getVertices()) { + float d = -v.mPosition.z; + if (d < 0.0f) + d = 0.0f; + if (d > L) + d = L; + + /* Centerline (polyline MA -> O -> MB) and curb chains. */ + Ogre::Vector3 center = wedgeCenterAt(wedge, graph, d); + + /* The strip spans between two properly mitered curb chains: + * the inner one just outside the road curb (wall gap) and + * the outer one a full sidewalk width further out. Each + * chain has its own pinned miter corner, so cross-sections + * cannot fold through inner corners (extending the road's + * pinned fan past K would sweep the strip twice). */ + Ogre::Vector3 offIn = curbOffsetWidened(wedge, graph, d, + ROAD_SIDEWALK_WALL_GAP); + Ogre::Vector3 offOut = curbOffsetWidened( + wedge, graph, d, ROAD_SIDEWALK_WALL_GAP + sw); + + Ogre::Vector3 lateral = offOut - offIn; + if (lateral.length() < 1e-4f) + lateral = (d <= L1) ? roadRightVec(dir1) + : -roadRightVec(dir2); + else + lateral.normalise(); + + Ogre::Vector3 worldXZ = center + offIn + + (offOut - offIn) * v.mPosition.x; + + /* Surface height, same sampling as the road slab. */ + float surfY; + if (d < L1 - 1e-4f) + surfY = halfEdgeHeightAt(h1, graph, L1 - d); + else if (d > L1 + 1e-4f) + surfY = halfEdgeHeightAt(h2, graph, d - L1); + else + surfY = yO; + float worldY = surfY + sh + v.mPosition.y; + + /* UVs: phase-continuous u, v across [0, sidewalkWidth]. */ + v.mUV.x = (d <= L1) ? halfEdgeU(h1, graph, L1 - d) + : halfEdgeU(h2, graph, d - L1); + v.mUV.y = v.mUV.y * sw; + + /* Normals: same explicit axis mapping as the road (the + * bend is a reflection of the template). */ + Ogre::Vector3 travel = (d <= L1) ? -dir1 : dir2; + Ogre::Vector3 n = lateral * v.mNormal.x + + Ogre::Vector3(0, v.mNormal.y, 0) + + travel * (-v.mNormal.z); + + v.mPosition = Ogre::Vector3(worldXZ.x, worldY, worldXZ.z); + v.mNormal = n; + } +} + +bool buildSidewalkGeometry(const RoadWedge &wedge, + const RoadGraph &graph, + Procedural::TriangleBuffer &out) +{ + Procedural::TriangleBuffer fb = + makeSidewalkFallbackTemplate(graph.config.sidewalkThickness); + return buildSidewalkGeometry(wedge, graph, fb, out); +} + +bool buildSidewalkGeometry(const RoadWedge &wedge, + const RoadGraph &graph, + const Procedural::TriangleBuffer &templ, + Procedural::TriangleBuffer &out) +{ + if (!graph.config.sidewalkEnabled) + return false; + if (wedge.degenerate) + return false; + + /* Phase 1 — the X=0 (road-facing) wall stays: it is the visible + * inner curb wall of the sidewalk. */ + Procedural::TriangleBuffer strip; + buildWedgeStrip(strip, templ, wedge.first.halfLength, + wedge.second.halfLength, true); + + /* Phase 2. */ + transformSidewalkVertices(strip, wedge, graph); + + /* Phase 3. */ + shiftSeamVertices(strip, wedge, graph); + + /* Appended verbatim like the road strip (the template supplies + * the closed cross-section). */ + int base = (int)out.getVertices().size(); + for (const auto &v : strip.getVertices()) + out.getVertices().push_back(v); + + /* Same reflection as the road bend: reverse the winding so the + * box faces point outward. */ + const std::vector &si = strip.getIndices(); + out.getIndices().reserve(out.getIndices().size() + si.size()); + for (size_t t = 0; t + 2 < si.size(); t += 3) { + out.getIndices().push_back(base + si[t]); + out.getIndices().push_back(base + si[t + 2]); + out.getIndices().push_back(base + si[t + 1]); + } + return true; +} + +bool computeSegmentSidewalkBand(const RoadStraightSegment &segment, + const RoadGraph &graph, int side, + Ogre::Vector3 c[4], Ogre::Vector2 uvc[4]) +{ + const RoadNode *node = graph.findNodeById(segment.nodeId); + if (!node) + return false; + + const RoadHalfEdge &he = segment.halfEdge; + if (he.lanesIn + he.lanesOut < 1) + return false; + + const Ogre::Vector3 &O = node->position; + Ogre::Vector3 d = he.direction; + Ogre::Vector3 r = roadRightVec(d); + float lw = graph.config.laneWidth; + float width = (side == 0) ? he.lanesIn * lw : he.lanesOut * lw; + if (width <= 0.0f) + return false; + + /* side 0: inbound curb (left of travel, -r); + * side 1: outbound curb (+r). */ + Ogre::Vector3 sideVec = (side == 0) ? -r : r; + float sw = graph.config.sidewalkWidth; + float inner = width + ROAD_SIDEWALK_WALL_GAP; + float outer = inner + sw; + float L = he.halfLength; + float t0 = -SEAM_OVERLAP; + + c[0] = O + t0 * d + sideVec * inner; + c[1] = O + L * d + sideVec * inner; + c[2] = O + L * d + sideVec * outer; + c[3] = O + t0 * d + sideVec * outer; + /* TOP surface heights: road surface + sidewalkHeight. */ + float sh = graph.config.sidewalkHeight; + c[0].y = c[3].y = halfEdgeHeightAt(he, graph, t0) + sh; + c[1].y = c[2].y = halfEdgeHeightAt(he, graph, L) + sh; + + uvc[0] = Ogre::Vector2(halfEdgeU(he, graph, t0), 0.0f); + uvc[1] = Ogre::Vector2(halfEdgeU(he, graph, L), 0.0f); + uvc[2] = Ogre::Vector2(halfEdgeU(he, graph, L), sw); + uvc[3] = Ogre::Vector2(halfEdgeU(he, graph, t0), sw); + return true; +} + +bool buildSegmentSidewalkGeometry(const RoadStraightSegment &segment, + const RoadGraph &graph, + Procedural::TriangleBuffer &out) +{ + if (!graph.config.sidewalkEnabled) + return false; + + float thickness = std::max(0.01f, graph.config.sidewalkThickness); + + bool built = false; + for (int side = 0; side < 2; ++side) { + Ogre::Vector3 c[4]; + Ogre::Vector2 uvc[4]; + if (!computeSegmentSidewalkBand(segment, graph, side, c, uvc)) + continue; + + /* extrudeToSlab goes +/- half thickness around the center + * surface, so sink the band corners by half the thickness: + * the slab top then lands on the computed top surface. */ + for (int i = 0; i < 4; ++i) + c[i].y -= thickness * 0.5f; + + Procedural::TriangleBuffer centerSurf; + int base = (int)centerSurf.getVertices().size(); + for (int i = 0; i < 4; ++i) { + Procedural::TriangleBuffer::Vertex v; + v.mPosition = c[i]; + v.mNormal = Ogre::Vector3::UNIT_Y; + v.mUV = uvc[i]; + centerSurf.getVertices().push_back(v); + } + centerSurf.getIndices().push_back(base + 0); + centerSurf.getIndices().push_back(base + 1); + centerSurf.getIndices().push_back(base + 2); + centerSurf.getIndices().push_back(base + 0); + centerSurf.getIndices().push_back(base + 2); + centerSurf.getIndices().push_back(base + 3); + + /* Skip the skirt at the far end (c[1]-c[2]): that plane + * butts against the road piece from the edge's other + * half, same as the road band. */ + auto skirtFilter = [&](const Ogre::Vector3 &p0, + const Ogre::Vector3 &p1) -> bool { + float d1 = p0.distance(c[1]) + p1.distance(c[2]); + float d2 = p0.distance(c[2]) + p1.distance(c[1]); + return (d1 > 0.001f && d2 > 0.001f); + }; + extrudeToSlab(out, centerSurf, thickness, skirtFilter); + built = true; + } + return built; +} + /* ---------------------------------------------------------------- * Template mesh loading * ---------------------------------------------------------------- */ diff --git a/src/features/editScene/roadlib/RoadGeometryLib.hpp b/src/features/editScene/roadlib/RoadGeometryLib.hpp index f437677..3887f38 100644 --- a/src/features/editScene/roadlib/RoadGeometryLib.hpp +++ b/src/features/editScene/roadlib/RoadGeometryLib.hpp @@ -54,6 +54,41 @@ bool buildSegmentGeometry(const RoadStraightSegment &segment, const RoadGraph &graph, Procedural::TriangleBuffer &out); +/** + * Build the sidewalk strip along a wedge's outer curb and append to + * @p out (improvement plan §2.2). + * + * Uses the same three-phase pipeline as the road slab; the strip + * starts at the curb (+ ROAD_SIDEWALK_WALL_GAP) and extends + * sidewalkWidth outward along the same rays that guarantee the + * no-fold property, elevated by sidewalkHeight above the road + * surface. The template profile is expected to span Y in + * [-sidewalkThickness, 0] (top at 0 — see makeSidewalkFallbackTemplate). + * + * @return false when sidewalks are disabled, the wedge is degenerate, + * or nothing was emitted. + */ +bool buildSidewalkGeometry(const RoadWedge &wedge, + const RoadGraph &graph, + Procedural::TriangleBuffer &out); + +bool buildSidewalkGeometry(const RoadWedge &wedge, + const RoadGraph &graph, + const Procedural::TriangleBuffer &templ, + Procedural::TriangleBuffer &out); + +/** + * Build the two sidewalk bands (inbound and outbound curb) of a + * dead-end straight segment and append to @p out, each extruded into + * a slab of sidewalkThickness whose top sits at + * roadSurfaceY + sidewalkHeight. + * + * @return false when sidewalks are disabled or the segment is invalid. + */ +bool buildSegmentSidewalkGeometry(const RoadStraightSegment &segment, + const RoadGraph &graph, + Procedural::TriangleBuffer &out); + /* ---------------------------------------------------------------- * Pipeline phases (exposed for testing) * ---------------------------------------------------------------- */ @@ -107,6 +142,15 @@ void extrudeToSlab(Procedural::TriangleBuffer &out, */ Procedural::TriangleBuffer makeFallbackTemplate(float roadThickness); +/** + * Sidewalk fallback template (improvement plan §2.2): same unit box, + * but with the profile Y in [-sidewalkThickness, 0] so the strip top + * lands exactly at roadSurfaceY + sidewalkHeight in the phase-2 + * mapping. + */ +Procedural::TriangleBuffer makeSidewalkFallbackTemplate( + float sidewalkThickness); + /** * Load a road cross-section template from an OGRE mesh: the mesh * triangles are read verbatim and normalised into template space @@ -158,6 +202,19 @@ bool computeSegmentBand(const RoadStraightSegment &segment, const RoadGraph &graph, Ogre::Vector3 c[4], Ogre::Vector2 uvc[4]); +/** + * Compute the four corners of one segment sidewalk band (improvement + * plan §2.2). + * + * @p side: 0 = inbound curb (left of travel), 1 = outbound curb. + * c[0..3] are the band corners of the TOP surface (at + * roadSurfaceY + sidewalkHeight); uvc[0..3] their UVs. + * @return false if the segment is invalid or the side has no curb. + */ +bool computeSegmentSidewalkBand(const RoadStraightSegment &segment, + const RoadGraph &graph, int side, + Ogre::Vector3 c[4], Ogre::Vector2 uvc[4]); + } // namespace RoadGeometryLib #endif // ROAD_GEOMETRY_LIB_HPP diff --git a/src/features/editScene/systems/EditorUISystem.cpp b/src/features/editScene/systems/EditorUISystem.cpp index cb61db5..ce218b5 100644 --- a/src/features/editScene/systems/EditorUISystem.cpp +++ b/src/features/editScene/systems/EditorUISystem.cpp @@ -6,6 +6,7 @@ #include "../camera/EditorCamera.hpp" #include "../systems/TerrainSystem.hpp" #include "../systems/RoadSystem.hpp" +#include "../systems/TerrainPrefabSpawnerSystem.hpp" #include "../components/EntityName.hpp" #include "../components/Transform.hpp" #include "../components/Renderable.hpp" @@ -134,6 +135,21 @@ bool EditorUISystem::onMousePressed(const Ogre::Ray &mouseRay) if (ImGui::GetIO().WantCaptureMouse) return false; + // Prefab spawn mode (M6): record the press; the click-vs-drag + // decision is made on release (5 px threshold). The gizmo already + // had priority above so spawners stay movable while placing. + { + TerrainPrefabSpawnerSystem *pss = + TerrainPrefabSpawnerSystem::getInstance(); + if (pss && pss->getSpawnEditMode()) { + m_prefabSpawnClickPending = true; + const ImVec2 &mp = ImGui::GetIO().MousePos; + m_prefabSpawnMouseDownPos = Ogre::Vector2(mp.x, mp.y); + m_prefabSpawnMouseDownRay = mouseRay; + return false; + } + } + if (!m_cursor3D || !m_cursor3D->isVisible()) return false; @@ -233,6 +249,26 @@ bool EditorUISystem::onMouseReleased() return true; } + // Prefab spawn mode (M6): resolve a pending click (below 5 px) as a + // spawn-point placement; never consume the release so the camera + // drag still completes. + { + TerrainPrefabSpawnerSystem *pss = + TerrainPrefabSpawnerSystem::getInstance(); + if (pss && pss->getSpawnEditMode()) { + if (m_prefabSpawnClickPending) { + m_prefabSpawnClickPending = false; + const ImVec2 &mp = ImGui::GetIO().MousePos; + float dx = mp.x - m_prefabSpawnMouseDownPos.x; + float dy = mp.y - m_prefabSpawnMouseDownPos.y; + if (dx * dx + dy * dy < 25.0f) + pss->handleSpawnClick( + m_prefabSpawnMouseDownRay); + } + return false; + } + } + // Cursor translate/rotate release if (m_cursor3D && (m_cursorMode == CursorInteractionMode::Translate || m_cursorMode == CursorInteractionMode::Rotate)) { @@ -257,7 +293,12 @@ void EditorUISystem::update(float deltaTime) * Runs in both editor and game mode. */ { TerrainSystem *ts = TerrainSystem::getInstance(); - if (ts) { + TerrainPrefabSpawnerSystem *pss = + TerrainPrefabSpawnerSystem::getInstance(); + /* Prefab spawn mode owns left-click placement; brushes stay + * quiet while it is active. */ + const bool spawnEditing = pss && pss->getSpawnEditMode(); + if (ts && !spawnEditing) { if (ts->isSculpting() || ts->isPainting() || ts->isAuxPainting()) { ImGuiIO &io = ImGui::GetIO(); @@ -2146,6 +2187,47 @@ void EditorUISystem::handleRoadEditClick(const Ogre::Ray &mouseRay) Ogre::Vector3 hit = mouseRay.getPoint(t); int nodeId = pickRoadNode(hit); + + /* Connect tool: first click pins the source node, second click on + * another node connects the two; the second node becomes the new + * source so paths can be chained. */ + if (ts->getRoadEditTool() == TerrainSystem::RoadEditTool::Connect) { + int selected = rs->getSelectedNodeId(); + if (nodeId < 0) { + /* Empty terrain: clear the pin. */ + rs->setSelectedNodeId(-1); + rs->setSelectedEdgeIndex(-1); + if (m_roadGizmo) + m_roadGizmo->setVisible(false); + return; + } + if (selected < 0 || selected == nodeId) { + rs->setSelectedNodeId(nodeId); + rs->setSelectedEdgeIndex(-1); + syncRoadGizmoToSelection(); + return; + } + flecs::entity terrain = rs->getTerrainEntity(); + if (!terrain.is_alive() || !terrain.has()) + return; + auto &tc = terrain.get_mut(); + std::string error; + std::vector created; + if (tc.roadGraph.connectNodes(selected, nodeId, tc.worldSize, + &error, &created) >= 0) { + /* Page-boundary split nodes start with an + * interpolated Y; re-snap to the terrain. */ + rs->snapNodesToTerrain(created); + rs->setSelectedNodeId(nodeId); + rs->setSelectedEdgeIndex(-1); + syncRoadGizmoToSelection(); + } else { + /* Shown as a modal by the terrain editor panel. */ + rs->setConnectError(error); + } + return; + } + if (nodeId >= 0) { rs->setSelectedNodeId(nodeId); rs->setSelectedEdgeIndex(-1); diff --git a/src/features/editScene/systems/EditorUISystem.hpp b/src/features/editScene/systems/EditorUISystem.hpp index 07c2689..8b991f2 100644 --- a/src/features/editScene/systems/EditorUISystem.hpp +++ b/src/features/editScene/systems/EditorUISystem.hpp @@ -373,6 +373,12 @@ private: Ogre::Vector2 m_roadMouseDownPos = Ogre::Vector2::ZERO; Ogre::Ray m_roadMouseDownRay; + /* Pending left-click in terrain prefab spawn mode (M6); same + * click-vs-drag disambiguation as road edit mode. */ + bool m_prefabSpawnClickPending = false; + Ogre::Vector2 m_prefabSpawnMouseDownPos = Ogre::Vector2::ZERO; + Ogre::Ray m_prefabSpawnMouseDownRay; + // Queries flecs::query m_nameQuery; diff --git a/src/features/editScene/systems/PrefabSystem.cpp b/src/features/editScene/systems/PrefabSystem.cpp index 02ca2a6..d3b9226 100644 --- a/src/features/editScene/systems/PrefabSystem.cpp +++ b/src/features/editScene/systems/PrefabSystem.cpp @@ -5,8 +5,12 @@ #include "../components/Transform.hpp" #include "../components/EntityName.hpp" #include "../components/EditorMarker.hpp" +#include "../components/Renderable.hpp" +#include "../components/RigidBody.hpp" +#include "../physics/physics.h" #include #include +#include std::string PrefabSystem::getPrefabsDirectory() { @@ -152,6 +156,57 @@ bool PrefabSystem::savePrefab(flecs::entity rootEntity, return true; } +void PrefabSystem::destroyInstance(flecs::entity entity, + JoltPhysicsWrapper *physics, + EditorUISystem *uiSystem) +{ + if (!entity.is_alive()) + return; + + std::vector children; + entity.children( + [&children](flecs::entity child) { children.push_back(child); }); + for (flecs::entity child : children) + destroyInstance(child, physics, uiSystem); + + if (physics && entity.has()) { + auto &rigidBody = entity.get_mut(); + if (rigidBody.bodyCreated && !rigidBody.bodyID.IsInvalid()) { + physics->removeBody(rigidBody.bodyID); + physics->destroyBody(rigidBody.bodyID); + rigidBody.bodyCreated = false; + rigidBody.bodyID = JPH::BodyID(); + } + } + + if (entity.has()) { + auto &transform = entity.get_mut(); + if (transform.node) { + try { + m_sceneMgr->destroySceneNode(transform.node); + } catch (...) { + } + transform.node = nullptr; + } + } + + if (entity.has()) { + auto &renderable = entity.get_mut(); + if (renderable.entity) { + try { + m_sceneMgr->destroyEntity(renderable.entity); + } catch (...) { + } + renderable.entity = nullptr; + } + } + + if (uiSystem) + uiSystem->removeEntity(entity); + + entity.destruct(); +} + bool PrefabSystem::deletePrefab(const std::string &prefabPath) { try { diff --git a/src/features/editScene/systems/PrefabSystem.hpp b/src/features/editScene/systems/PrefabSystem.hpp index fa7343a..eed2e8e 100644 --- a/src/features/editScene/systems/PrefabSystem.hpp +++ b/src/features/editScene/systems/PrefabSystem.hpp @@ -8,6 +8,7 @@ // Forward declarations class EditorUISystem; class SceneSerializer; +class JoltPhysicsWrapper; /** * @brief System for managing prefab instances. @@ -64,6 +65,24 @@ public: */ static std::string getPrefabsDirectory(); + /** + * @brief Destroy a prefab instance subtree without leaking resources. + * + * Recursively destroys child entities, removes Jolt bodies (when a + * physics wrapper is given), destroys the Ogre scene nodes and + * entities behind TransformComponent/RenderableComponent, removes + * the entity from the UI caches (when a UI system is given) and + * finally destructs the flecs entity. A bare entity.destruct() + * leaks all of the above because the components only hold raw + * pointers. + * + * Shared by TerrainPrefabSpawnerSystem and RoadSystem (improvement + * plan §3.2). + */ + void destroyInstance(flecs::entity entity, + JoltPhysicsWrapper *physics = nullptr, + EditorUISystem *uiSystem = nullptr); + /** * @brief Get last error message. */ diff --git a/src/features/editScene/systems/RoadSystem.cpp b/src/features/editScene/systems/RoadSystem.cpp index 1d2b9cc..b999cf5 100644 --- a/src/features/editScene/systems/RoadSystem.cpp +++ b/src/features/editScene/systems/RoadSystem.cpp @@ -9,6 +9,7 @@ #include "../components/Renderable.hpp" #include "../components/Lod.hpp" #include "../components/PhysicsCollider.hpp" +#include "../components/EditorMarker.hpp" #include "../physics/physics.h" #include "../roadlib/RoadGeometryLib.hpp" #include "PrefabSystem.hpp" @@ -16,6 +17,7 @@ #include #include #include +#include #include static const float NODE_SIZE = 0.25f; @@ -143,6 +145,30 @@ void RoadSystem::setTerrainSystem(TerrainSystem *terrainSystem) m_terrainSystem = terrainSystem; } +void RoadSystem::snapNodesToTerrain(const std::vector &nodeIds) +{ + if (!m_terrainSystem || nodeIds.empty()) + return; + + flecs::entity terrain = getTerrainEntity(); + if (!terrain.is_alive() || !terrain.has()) + return; + RoadGraph &rg = + m_world.entity(m_terrainEntityId).get_mut().roadGraph; + + bool changed = false; + for (int id : nodeIds) { + RoadNode *n = rg.findNodeById(id); + if (!n) + continue; + n->position.y = m_terrainSystem->getHeightAt(n->position) + + n->verticalOffset; + changed = true; + } + if (changed) + rg.bumpVersion(); +} + flecs::entity RoadSystem::getTerrainEntity() const { return m_world.entity(m_terrainEntityId); @@ -248,10 +274,14 @@ void RoadSystem::update(float deltaTime) .markChanged(); markNavMeshDirty(); createPageCollider(pg, tb.meshName); - spawnSidePrefabs(pg); pg.meshFinalized = true; } + /* Roadside prefabs follow camera distance and the current edge + * data; re-evaluate every frame (spawn/despawn are transitions + * only, so this is cheap). */ + updateEdgePrefabs(); + /* Wedge debug: rebuild if selection or graph changed. */ if (m_debugWedgeEnabled && m_debugWedgeObject && (m_selectedNodeId != m_lastDebugNodeId || @@ -413,12 +443,6 @@ void RoadSystem::destroyPageGeometry(RoadPageGeometry &pg) pg.colliderEntity.destruct(); pg.colliderEntity = flecs::entity::null(); - for (flecs::entity prefab : pg.spawnedPrefabs) { - if (prefab.is_alive()) - prefab.destruct(); - } - pg.spawnedPrefabs.clear(); - pg.collisionVertices.clear(); pg.collisionIndices.clear(); pg.wedges.clear(); @@ -433,6 +457,12 @@ void RoadSystem::clearPageGeometry() m_wedgeBuckets.clear(); m_geometryGraphVersion = 0; + /* Roadside prefabs live in per-edge records now (keyed by node + * ids, not pages) — despawn everything still alive. */ + for (auto &kv : m_edgePrefabs) + despawnEdgePrefabs(kv.second); + m_edgePrefabs.clear(); + if (m_materialEntity.is_alive()) m_materialEntity.destruct(); m_materialEntity = flecs::entity::null(); @@ -466,6 +496,20 @@ void RoadSystem::buildPageMeshes(RoadPageGeometry &pg) for (const RoadStraightSegment &s : pg.segments) buildSegmentGeometry(s, rg, *buffer); + /* Sidewalks (improvement plan §2): appended into the same page + * buffer, so they inherit the road material, LOD/visibility + * distance, collider soup and navmesh dirtying. */ + if (rg.config.sidewalkEnabled) { + const Procedural::TriangleBuffer &swTempl = + getSidewalkTemplate(rg.config); + for (const RoadWedge &w : pg.wedges) + RoadGeometryLib::buildSidewalkGeometry(w, rg, swTempl, + *buffer); + for (const RoadStraightSegment &s : pg.segments) + RoadGeometryLib::buildSegmentSidewalkGeometry(s, rg, + *buffer); + } + if (buffer->getIndices().empty()) { /* No road content seeded on this page. */ destroyPageMeshes(pg); @@ -678,6 +722,11 @@ void RoadSystem::createPageCollider(RoadPageGeometry &pg, m_physics->addBody(bodyId, JPH::EActivation::DontActivate); pg.bodyId = bodyId; + /* Track the body for the physics debug-draw filter (M5.9.6): + * TerrainSystem syncs this set into its TerrainBodyDrawFilter so + * road colliders are hidden unless "Show Road Colliders" is on. */ + m_roadBodyIds.insert(bodyId); + /* Informational component: mirrors the collider configuration on * the buffer entity (no RigidBodyComponent, so PhysicsSystem does * not build a second body from it). */ @@ -697,6 +746,7 @@ void RoadSystem::destroyPageCollider(RoadPageGeometry &pg) m_physics->removeBody(pg.bodyId); m_physics->destroyBody(pg.bodyId); } + m_roadBodyIds.erase(pg.bodyId); pg.bodyId = JPH::BodyID(); if (pg.bufferEntity.is_alive() && @@ -924,6 +974,26 @@ RoadSystem::getRoadTemplate(const RoadConfig &cfg) return m_templateBuffer; } +const Procedural::TriangleBuffer & +RoadSystem::getSidewalkTemplate(const RoadConfig &cfg) +{ + if (cfg.sidewalkMeshTemplate == m_sidewalkTemplateName && + cfg.sidewalkThickness == m_sidewalkTemplateThickness) + return m_sidewalkTemplateBuffer; + + m_sidewalkTemplateName = cfg.sidewalkMeshTemplate; + m_sidewalkTemplateThickness = cfg.sidewalkThickness; + m_sidewalkTemplateBuffer = Procedural::TriangleBuffer(); + + if (!RoadGeometryLib::loadTemplateFromMesh( + cfg.sidewalkMeshTemplate, m_sidewalkTemplateBuffer)) + m_sidewalkTemplateBuffer = + RoadGeometryLib::makeSidewalkFallbackTemplate( + cfg.sidewalkThickness); + + return m_sidewalkTemplateBuffer; +} + Procedural::TriangleBuffer RoadSystem::makeFallbackTemplate(float roadThickness) { @@ -966,7 +1036,125 @@ bool RoadSystem::buildSegmentGeometry(const RoadStraightSegment &segment, /* Roadside prefab spawning (M5.11) */ /* ------------------------------------------------------------------ */ -void RoadSystem::spawnSidePrefabs(RoadPageGeometry &pg) +bool RoadSystem::prefabSlotEquals(const RoadEdgePrefabSlot &a, + const RoadEdgePrefabSlot &b) +{ + return a.prefabPath == b.prefabPath && a.edgeT == b.edgeT && + a.lateralOffset == b.lateralOffset && a.yOffset == b.yOffset; +} + +void RoadSystem::despawnEdgePrefabs(EdgePrefabRecord &rec) +{ + PrefabSystem prefabSys(m_world, m_sceneMgr); + flecs::entity slots[3] = { rec.left, rec.right, rec.mid }; + for (flecs::entity inst : slots) { + if (inst.is_alive()) + prefabSys.destroyInstance(inst, m_physics); + } + rec.left = rec.right = rec.mid = flecs::entity::null(); + rec.spawned = false; + rec.tried = false; + rec.lastForce = false; +} + +void RoadSystem::spawnEdgePrefab(const RoadGraph &rg, const RoadEdge &edge, + const RoadEdgePrefabSlot &slot, int slotIndex, + flecs::entity &out) +{ + out = flecs::entity::null(); + if (slot.prefabPath.empty()) + return; + + /* Skip quietly when the prefab file is missing; the record's + * tried flag keeps this from being retested every frame. */ + std::ifstream f(slot.prefabPath.c_str()); + if (!f.good()) + return; + + const RoadNode *na = rg.findNodeById(edge.nodeA); + const RoadNode *nb = rg.findNodeById(edge.nodeB); + if (!na || !nb) + return; + + Ogre::Vector3 dir = nb->position - na->position; + dir.y = 0; + if (dir.isZeroLength()) + return; + dir.normalise(); + /* Left of the nodeA->nodeB travel direction. */ + Ogre::Vector3 left = Ogre::Vector3::UNIT_Y.crossProduct(dir); + + int lanesFrom = 0, lanesTo = 0; + rg.resolveLaneCounts(edge, edge.nodeA, lanesFrom, lanesTo); + float lw = rg.config.laneWidth; + /* Half-width of the road on each side, looking A->B: the outbound + * lanes (lanesAtoB) run on the right, the inbound lanes (lanesBtoA) + * on the left. */ + float rightHalfWidth = lanesFrom * lw; + float leftHalfWidth = lanesTo * lw; + + float lateralSign = 0.0f; + float halfWidth = 0.0f; + const char *slotName = "mid"; + if (slotIndex == 0) { + lateralSign = 1.0f; + halfWidth = leftHalfWidth; + slotName = "left"; + } else if (slotIndex == 1) { + lateralSign = -1.0f; + halfWidth = rightHalfWidth; + slotName = "right"; + } + + /* Anchor: on the edge at edgeT, shifted to the curb (side slots) + * plus lateralOffset. */ + float t = std::max(0.0f, std::min(1.0f, slot.edgeT)); + Ogre::Vector3 pos = na->position + (nb->position - na->position) * t; + pos += left * (lateralSign * (halfWidth + slot.lateralOffset)); + + /* Y follows the interpolated road surface height (node Y + road + * level), not the raw terrain. */ + float surfY = na->position.y + edge.roadLevelA + + (nb->position.y + edge.roadLevelB - na->position.y - + edge.roadLevelA) * t; + pos.y = surfY + slot.yOffset; + + std::string name = "road_prefab_" + std::to_string(edge.nodeA) + "_" + + std::to_string(edge.nodeB) + "_" + slotName; + + PrefabSystem prefabSys(m_world, m_sceneMgr); + /* Root-level instance (no parent): parenting to the terrain + * entity would leave dangling scene nodes on terrain teardown. */ + flecs::entity inst = prefabSys.createInstance( + slot.prefabPath, flecs::entity::null(), pos, name); + if (!inst.is_alive()) { + Ogre::LogManager::getSingleton().logMessage( + "RoadSystem: failed to spawn road prefab \"" + + slot.prefabPath + "\" for edge " + + std::to_string(edge.nodeA) + "-" + + std::to_string(edge.nodeB)); + return; + } + + /* Runtime-only, regenerated from edge data (M5.11/M5.12): remove + * the editor marker so instances are not serialized with the scene + * and stay out of the editor outliner. */ + if (inst.has()) + inst.remove(); + + /* Align the prefab's -Z forward with the edge direction. */ + Ogre::Quaternion yaw = + Ogre::Vector3::NEGATIVE_UNIT_Z.getRotationTo(dir); + if (inst.has()) { + auto &tc = inst.get_mut(); + tc.rotation = yaw * tc.rotation; + tc.applyToNode(); + } + + out = inst; +} + +void RoadSystem::updateEdgePrefabs() { if (!m_terrainSystem) return; @@ -976,52 +1164,98 @@ void RoadSystem::spawnSidePrefabs(RoadPageGeometry &pg) return; const auto &rg = terrain.get().roadGraph; - PrefabSystem prefabSys(m_world, m_sceneMgr); - for (const auto &edge : rg.edges) { - for (const auto &sp : edge.sidePrefabs) { - const RoadNode *na = rg.findNodeById(edge.nodeA); - const RoadNode *nb = rg.findNodeById(edge.nodeB); - if (!na || !nb) - continue; + Ogre::Vector3 camPos = Ogre::Vector3::ZERO; + Ogre::Camera *cam = m_terrainSystem->getCamera(); + if (cam) + camPos = cam->getDerivedPosition(); - /* Compute world position along the edge at edgeT. */ - float t = std::max(0.0f, std::min(1.0f, sp.edgeT)); - Ogre::Vector3 pos = na->position + - (nb->position - na->position) * t; + float spawnSq = rg.config.prefabSpawnDistance * + rg.config.prefabSpawnDistance; + float despawnSq = rg.config.prefabDespawnDistance * + rg.config.prefabDespawnDistance; - /* Lateral offset perpendicular to edge direction. */ - Ogre::Vector3 dir = nb->position - na->position; - dir.y = 0; - if (!dir.isZeroLength()) { - dir.normalise(); - Ogre::Vector3 side = - Ogre::Vector3::UNIT_Y.crossProduct(dir); - if (!sp.leftSide) - side = -side; - pos += side * sp.sideOffset; - } + bool editMode = m_terrainSystem->getRoadEditMode(); - /* Snap Y to terrain surface. */ - pos.y = m_terrainSystem->getHeightAt(pos); + std::set liveKeys; + for (size_t ei = 0; ei < rg.edges.size(); ++ei) { + const RoadEdge &edge = rg.edges[ei]; + if (edge.prefabLeft.prefabPath.empty() && + edge.prefabRight.prefabPath.empty() && + edge.prefabMid.prefabPath.empty()) + continue; - /* Generate a unique name for the instance. */ - std::string name = "road_prefab_" + - std::to_string(edge.nodeA) + "_" + - std::to_string(edge.nodeB) + "_" + - std::to_string(sp.edgeT); + const RoadNode *na = rg.findNodeById(edge.nodeA); + const RoadNode *nb = rg.findNodeById(edge.nodeB); + if (!na || !nb) + continue; - flecs::entity inst = prefabSys.createInstance( - sp.prefabPath, terrain, pos, name); + uint64_t key = edgePrefabKey(edge.nodeA, edge.nodeB); + liveKeys.insert(key); - if (inst.is_alive()) - pg.spawnedPrefabs.push_back(inst); - else - Ogre::LogManager::getSingleton().logMessage( - "RoadSystem: failed to spawn roadside prefab \"" + - sp.prefabPath + "\" for edge " + - std::to_string(edge.nodeA) + "-" + - std::to_string(edge.nodeB)); + /* Distance from the camera to the edge segment. */ + Ogre::Vector3 ab = nb->position - na->position; + float lenSq = ab.squaredLength(); + float distSq; + if (lenSq <= 0.0f) { + distSq = camPos.squaredDistance(na->position); + } else { + float s = (camPos - na->position).dotProduct(ab) / + lenSq; + s = std::max(0.0f, std::min(1.0f, s)); + distSq = camPos.squaredDistance(na->position + ab * s); + } + + /* Both endpoint pages must be loaded so prefabs do not + * appear before the road mesh under them exists. */ + uint64_t pageA = 0, pageB = 0; + bool pagesLoaded = pageKeyForNode(edge.nodeA, pageA) && + pageKeyForNode(edge.nodeB, pageB) && + m_pageGeometry.count(pageA) && + m_pageGeometry.count(pageB); + + /* Edit mode force-spawns the selected edge's prefabs so + * slot changes are previewed regardless of distance. */ + bool force = editMode && (int)ei == m_selectedEdgeIndex; + + EdgePrefabRecord &rec = m_edgePrefabs[key]; + bool sameData = + prefabSlotEquals(rec.leftSlot, edge.prefabLeft) && + prefabSlotEquals(rec.rightSlot, edge.prefabRight) && + prefabSlotEquals(rec.midSlot, edge.prefabMid); + + if (rec.spawned && + (!sameData || !pagesLoaded || + (!force && distSq > despawnSq))) + despawnEdgePrefabs(rec); + + if (!rec.spawned && pagesLoaded && + (force || distSq <= spawnSq) && + (!rec.tried || !sameData || force != rec.lastForce)) { + spawnEdgePrefab(rg, edge, edge.prefabLeft, 0, + rec.left); + spawnEdgePrefab(rg, edge, edge.prefabRight, 1, + rec.right); + spawnEdgePrefab(rg, edge, edge.prefabMid, 2, rec.mid); + rec.leftSlot = edge.prefabLeft; + rec.rightSlot = edge.prefabRight; + rec.midSlot = edge.prefabMid; + rec.tried = true; + rec.spawned = rec.left.is_alive() || + rec.right.is_alive() || + rec.mid.is_alive(); + rec.lastForce = force; + } + } + + /* Drop records for edges that no longer exist or no longer have + * any slot configured. */ + for (auto it = m_edgePrefabs.begin(); it != m_edgePrefabs.end();) { + if (liveKeys.find(it->first) == liveKeys.end()) { + despawnEdgePrefabs(it->second); + it = m_edgePrefabs.erase(it); + } else { + ++it; } } } @@ -1030,6 +1264,95 @@ void RoadSystem::spawnSidePrefabs(RoadPageGeometry &pg) /* Terrain compliance (M5.10) */ /* ------------------------------------------------------------------ */ +void RoadSystem::writeComplianceFalloff(TerrainSystem *terrainSystem, + const RoadGraph &graph, + const RoadHalfEdge &halfEdge, + const Ogre::Vector3 &nodePos, + float sideSign, float sideWidth, + float roadThickness, float laneWidth) +{ + if (!terrainSystem || sideWidth <= 1e-4f || + halfEdge.halfLength <= 1e-4f) + return; + + /* Fade zone: linear fade over laneWidth * 2 outside the outermost + * lane (M5.10 step 4). */ + float fadeWidth = laneWidth * 2.0f; + if (fadeWidth <= 1e-4f) + return; + + float texel = terrainSystem->getFixupTexelSize(); + if (texel <= 1e-6f) + return; + + Ogre::Vector3 outward = + RoadGeometryLib::roadRightVec(halfEdge.direction) * sideSign; + float halfThick = std::max(0.01f, roadThickness) * 0.5f; + + /* + * Walk the fixup samples covering the fade band (t in + * [0, halfLength] along the half-edge, lateral in (sideWidth, + * sideWidth + fadeWidth)) and evaluate the fade target at each + * exact sample position, writing single samples. Because the + * target is linear in the lateral direction and sampling is + * bilinear, reads anywhere in the band reproduce the ramp; sparse + * point writes would leave the coarse page-vertex sampler between + * written cells. Samples outside the band are skipped: inside the + * curb the full-compliance pass writes the slab underside (it runs + * after this pass and wins on shared samples); beyond the fade the + * terrain keeps its natural height (sentinel blends to base). + */ + Ogre::Vector3 far = halfEdge.direction * halfEdge.halfLength; + Ogre::Vector3 nearOff = outward * sideWidth; + Ogre::Vector3 farOff = outward * (sideWidth + fadeWidth); + Ogre::Vector3 corners[4] = { + nodePos + nearOff, + nodePos + farOff, + nodePos + far + nearOff, + nodePos + far + farOff, + }; + float minX = corners[0].x, maxX = corners[0].x; + float minZ = corners[0].z, maxZ = corners[0].z; + for (int i = 1; i < 4; ++i) { + minX = std::min(minX, corners[i].x); + maxX = std::max(maxX, corners[i].x); + minZ = std::min(minZ, corners[i].z); + maxZ = std::max(maxZ, corners[i].z); + } + + long ix0 = (long)std::floor(minX / texel); + long ix1 = (long)std::floor(maxX / texel); + long iz0 = (long)std::floor(minZ / texel); + long iz1 = (long)std::floor(maxZ / texel); + + for (long iz = iz0; iz <= iz1; ++iz) { + for (long ix = ix0; ix <= ix1; ++ix) { + Ogre::Vector3 p((float)ix * texel, 0.0f, + (float)iz * texel); + Ogre::Vector3 rel = p - nodePos; + float t = rel.dotProduct(halfEdge.direction); + if (t < 0.0f || t > halfEdge.halfLength) + continue; + float lateral = rel.dotProduct(outward); + float d = lateral - sideWidth; + if (d <= 0.0f || d >= fadeWidth) + continue; + /* Top-surface height so target = top - roadThickness + * equals the slab underside at the curb, matching the + * full-compliance writes. */ + float surfY = RoadGeometryLib::halfEdgeHeightAt( + halfEdge, graph, t) + + halfThick; + float base = terrainSystem->sampleBaseHeightAt( + (long)std::floor(p.x), (long)std::floor(p.z)); + float target = computeComplianceHeight( + surfY, roadThickness, base, lateral, + sideWidth, fadeWidth); + terrainSystem->writeFixupSample(p.x, p.z, target); + } + } +} + void RoadSystem::complyTerrain(TerrainSystem *terrainSystem, float roadThickness, float laneWidth) { @@ -1044,11 +1367,71 @@ void RoadSystem::complyTerrain(TerrainSystem *terrainSystem, Procedural::TriangleBuffer fb = makeFallbackTemplate(rg.config.roadThickness); + /* Sidewalks (improvement plan §2.3): the shoulder fade starts at + * the sidewalk's outer edge, and the full-compliance pass flattens + * the terrain under the sidewalk bodies too. */ + bool sidewalks = rg.config.sidewalkEnabled; + float sideExtra = sidewalks ? rg.config.sidewalkWidth : 0.0f; + float sidewalkThick = std::max(0.01f, rg.config.sidewalkThickness); + Procedural::TriangleBuffer fbSw = + RoadGeometryLib::makeSidewalkFallbackTemplate(sidewalkThick); + + /* --- Perpendicular falloff (M5.10 step 4), written FIRST --- + * Extend a linear fade over laneWidth * 2 outside each curb. Beyond + * the fade zone no fixup is written (base heightmap + detail noise). + * This pass runs before the full-compliance pass below so the slab + * underside values win wherever the coarse chunk cells overlap the + * fade band (curb rows, node overlaps). */ + for (auto &kv : m_pageGeometry) { + RoadPageGeometry &pg = kv.second; + + for (const RoadWedge &wedge : pg.wedges) { + const RoadNode *node = rg.findNodeById(wedge.nodeId); + if (!node) + continue; + /* The wedge's outer curb sits on the right of the + * first half-edge (lanesOut wide) and on the left of + * the second half-edge (lanesIn wide). */ + writeComplianceFalloff(terrainSystem, rg, wedge.first, + node->position, +1.0f, + wedge.first.lanesOut * laneWidth + + sideExtra, + roadThickness, laneWidth); + writeComplianceFalloff(terrainSystem, rg, wedge.second, + node->position, -1.0f, + wedge.second.lanesIn * laneWidth + + sideExtra, + roadThickness, laneWidth); + } + + for (const RoadStraightSegment &seg : pg.segments) { + const RoadNode *node = + rg.findNodeById(seg.halfEdge.nodeId); + if (!node) + continue; + /* Dead-end segments span both sides of the + * centerline: right curb at lanesOut * laneWidth, + * left curb at lanesIn * laneWidth. */ + writeComplianceFalloff(terrainSystem, rg, seg.halfEdge, + node->position, +1.0f, + seg.halfEdge.lanesOut * laneWidth + + sideExtra, + roadThickness, laneWidth); + writeComplianceFalloff(terrainSystem, rg, seg.halfEdge, + node->position, -1.0f, + seg.halfEdge.lanesIn * laneWidth + + sideExtra, + roadThickness, laneWidth); + } + } + /* * Walk every loaded page's wedges and segments, generate road * geometry into a temp buffer, then write fixup values under every * top-surface vertex. The fixup target is the road underside: - * surfaceY - roadThickness. + * surfaceY - roadThickness. Runs AFTER the falloff pass so full + * compliance under the road overrides any fade values in shared + * chunk cells. */ for (auto &kv : m_pageGeometry) { RoadPageGeometry &pg = kv.second; @@ -1070,6 +1453,23 @@ void RoadSystem::complyTerrain(TerrainSystem *terrainSystem, v.mPosition.x, v.mPosition.z, v.mPosition.y - roadThickness); } + + /* Sidewalk strip: flatten under its body too; + * the fixup target is the sidewalk underside + * (top - sidewalkThickness). */ + if (sidewalks) { + Procedural::TriangleBuffer tmpSw; + if (!RoadGeometryLib::buildSidewalkGeometry( + wedge, rg, fbSw, tmpSw)) + continue; + for (const auto &v : tmpSw.getVertices()) { + if (v.mNormal.y <= 0.5f) + continue; + terrainSystem->writeFixup( + v.mPosition.x, v.mPosition.z, + v.mPosition.y - sidewalkThick); + } + } } for (const RoadStraightSegment &seg : pg.segments) { @@ -1078,11 +1478,14 @@ void RoadSystem::complyTerrain(TerrainSystem *terrainSystem, if (!RoadGeometryLib::computeSegmentBand(seg, rg, c, uvc)) continue; - /* Write fixups at the band corners. */ + /* Band corners/interior carry the CENTER surface + * height; the slab underside is half a thickness + * below it, matching the wedge pass (top vertex + * minus full thickness). */ for (int i = 0; i < 4; ++i) terrainSystem->writeFixup( c[i].x, c[i].z, - c[i].y - roadThickness); + c[i].y - halfThick); /* Sample intermediate points along the band edges * and interior for smooth compliance. */ @@ -1102,7 +1505,55 @@ void RoadSystem::complyTerrain(TerrainSystem *terrainSystem, Ogre::Vector3 pos = p0 + (p1 - p0) * wt; terrainSystem->writeFixup( pos.x, pos.z, - pos.y - roadThickness); + pos.y - halfThick); + } + } + + /* Sidewalk bands: same sampling, fixup target is + * the sidewalk underside (top - sidewalkThickness). */ + if (sidewalks) { + for (int side = 0; side < 2; ++side) { + Ogre::Vector3 sc[4]; + Ogre::Vector2 suvc[4]; + if (!RoadGeometryLib:: + computeSegmentSidewalkBand( + seg, rg, side, sc, + suvc)) + continue; + for (int i = 0; i < 4; ++i) + terrainSystem->writeFixup( + sc[i].x, sc[i].z, + sc[i].y - sidewalkThick); + + Ogre::Vector3 sd10 = sc[1] - sc[0]; + Ogre::Vector3 sd32 = sc[2] - sc[3]; + Ogre::Vector3 sd30 = sc[3] - sc[0]; + int sSteps = std::max( + 1, (int)std::ceil( + sd10.length())); + int sWidth = std::max( + 1, (int)std::ceil( + sd30.length())); + for (int s = 1; s < sSteps; ++s) { + float t = (float)s / + (float)sSteps; + Ogre::Vector3 p0 = + sc[0] + sd10 * t; + Ogre::Vector3 p1 = + sc[3] + sd32 * t; + for (int w = 0; w <= sWidth; + ++w) { + float wt = (float)w / + (float)sWidth; + Ogre::Vector3 pos = + p0 + (p1 - p0) * + wt; + terrainSystem->writeFixup( + pos.x, pos.z, + pos.y - + sidewalkThick); + } + } } } } diff --git a/src/features/editScene/systems/RoadSystem.hpp b/src/features/editScene/systems/RoadSystem.hpp index 181d85f..2e9062f 100644 --- a/src/features/editScene/systems/RoadSystem.hpp +++ b/src/features/editScene/systems/RoadSystem.hpp @@ -54,9 +54,6 @@ struct RoadPageGeometry { std::vector collisionVertices; std::vector collisionIndices; - /** Roadside prefab instances spawned for this page (M5.11). */ - std::vector spawnedPrefabs; - /** Wedges and straight segments seeded by nodes inside this page. */ std::vector wedges; std::vector segments; @@ -120,6 +117,27 @@ public: int getSelectedEdgeIndex() const { return m_selectedEdgeIndex; } void setSelectedEdgeIndex(int idx); + /** + * Last connectNodes failure from the 3D Connect tool. The terrain + * editor panel consumes it with takeConnectError() and shows it as + * a modal; kept here because RoadSystem is the shared hub both the + * click handler and the panel can reach. + */ + void setConnectError(const std::string &msg) + { + m_connectError = msg; + m_connectErrorPending = true; + } + bool takeConnectError(std::string &out) + { + if (!m_connectErrorPending) + return false; + out = m_connectError; + m_connectError.clear(); + m_connectErrorPending = false; + return true; + } + /** Wedge-debug mode for geometry inspection. */ bool getDebugWedgeEnabled() const { return m_debugWedgeEnabled; } void setDebugWedgeEnabled(bool v); @@ -147,6 +165,17 @@ public: */ const Procedural::TriangleBuffer &getRoadTemplate(const RoadConfig &cfg); + /** + * Sidewalk cross-section template (improvement plan §2.2), same + * template-space conventions as the road template except the + * profile Y spans [-sidewalkThickness, 0] (top at 0). Cached and + * rebuilt only when cfg.sidewalkMeshTemplate or + * cfg.sidewalkThickness changes; empty/missing mesh yields the + * procedural box. + */ + const Procedural::TriangleBuffer & + getSidewalkTemplate(const RoadConfig &cfg); + /** * Geometry generation (M5.6, ProceduralRoadGeometry.md). * @@ -220,14 +249,27 @@ public: */ void setTerrainSystem(class TerrainSystem *terrainSystem); + /** + * Re-snap the Y of the given road nodes to + * terrain_height + verticalOffset. + * + * Used after RoadGraph::connectNodes inserts page-boundary split + * nodes (their Y is only interpolated between the endpoints). + * No-op without a terrain system or for unknown node IDs. + */ + void snapNodesToTerrain(const std::vector &nodeIds); + /** * Terrain compliance (M5.10): writes fixup values under every - * road surface vertex so the terrain matches the road underside. + * road surface vertex so the terrain matches the road underside, + * then extends a linear fade over laneWidth * 2 perpendicular to + * each curb so the terrain shoulders rise smoothly from the road + * edge back to the natural height. * * @param terrainSystem the active TerrainSystem that owns the * fixup layer (used to call writeFixup + markPageDirty). * @param roadThickness vertical thickness of the road slab. - * @param laneWidth falloff is measured in lane widths. + * @param laneWidth lane width; the fade zone is laneWidth * 2. */ void complyTerrain(class TerrainSystem *terrainSystem, float roadThickness, float laneWidth); @@ -256,6 +298,46 @@ public: return m_pageGeometry; } + /** + * Spawn state of one edge's prefab slots (improvement plan §3.2). + * + * Instances are spawned/despawned per edge (never per page, which + * used to duplicate them) with a camera-distance hysteresis from + * RoadConfig::prefabSpawnDistance/prefabDespawnDistance. The slot + * copies detect data changes that require a respawn; @c tried + * suppresses per-frame spawn retries after a failed attempt until + * the data or conditions change. + */ + struct EdgePrefabRecord { + flecs::entity left = flecs::entity::null(); + flecs::entity right = flecs::entity::null(); + flecs::entity mid = flecs::entity::null(); + RoadEdgePrefabSlot leftSlot; + RoadEdgePrefabSlot rightSlot; + RoadEdgePrefabSlot midSlot; + bool tried = false; + bool spawned = false; + bool lastForce = false; + }; + + /** + * Key used for per-edge prefab records: the ordered node-id pair. + * Public for headless tests. + */ + static uint64_t edgePrefabKey(int nodeA, int nodeB) + { + int lo = std::min(nodeA, nodeB); + int hi = std::max(nodeA, nodeB); + return (uint64_t)(uint32_t)lo << 32 | (uint32_t)hi; + } + + /** Per-edge prefab spawn state; exposed for headless tests. */ + const std::unordered_map & + getEdgePrefabs() const + { + return m_edgePrefabs; + } + private: void createManualObjects(); void destroyManualObjects(); @@ -285,8 +367,26 @@ private: void createPageCollider(RoadPageGeometry &pg, const std::string &meshName); void destroyPageCollider(RoadPageGeometry &pg); - /* Roadside prefab spawning (M5.11). */ - void spawnSidePrefabs(RoadPageGeometry &pg); + /* Terrain compliance falloff (M5.10 step 4): writes the linear + * fade zone perpendicular to one half-edge, from its curb out to + * sideWidth + laneWidth*2. sideSign = +1 for the right side of the + * half-edge direction, -1 for the left side. */ + void writeComplianceFalloff(class TerrainSystem *terrainSystem, + const RoadGraph &graph, + const RoadHalfEdge &halfEdge, + const Ogre::Vector3 &nodePos, + float sideSign, float sideWidth, + float roadThickness, float laneWidth); + + /* Road prefab spawning (improvement plan §3.2): per-edge records + * with distance hysteresis and edit-mode force spawn. */ + void updateEdgePrefabs(); + void spawnEdgePrefab(const RoadGraph &rg, const RoadEdge &edge, + const RoadEdgePrefabSlot &slot, int slotIndex, + flecs::entity &out); + void despawnEdgePrefabs(EdgePrefabRecord &rec); + static bool prefabSlotEquals(const RoadEdgePrefabSlot &a, + const RoadEdgePrefabSlot &b); Ogre::Vector3 getNodePosition(int nodeId) const; bool getEdgePositions(int edgeIndex, Ogre::Vector3 &outA, @@ -301,6 +401,8 @@ private: int m_selectedNodeId = -1; int m_selectedEdgeIndex = -1; uint64_t m_lastGraphVersion = 0; + std::string m_connectError; + bool m_connectErrorPending = false; /* Page geometry state (M5.4). m_wedgeBuckets holds the enumerated * wedges/segments bucketed by seed-node page for ALL pages (including @@ -309,6 +411,10 @@ private: std::unordered_map m_wedgeBuckets; uint64_t m_geometryGraphVersion = 0; + /** Per-edge road prefab spawn state (improvement plan §3.2), + * keyed by edgePrefabKey(nodeA, nodeB). */ + std::unordered_map m_edgePrefabs; + /** Shared road material entity (M5.8), created lazily. */ flecs::entity m_materialEntity = flecs::entity::null(); @@ -326,6 +432,9 @@ private: Procedural::TriangleBuffer m_templateBuffer; std::string m_templateName; float m_templateThickness = -1.0f; + Procedural::TriangleBuffer m_sidewalkTemplateBuffer; + std::string m_sidewalkTemplateName; + float m_sidewalkTemplateThickness = -1.0f; /* Wedge-debug visualization (ManualObject). */ bool m_debugWedgeEnabled = false; diff --git a/src/features/editScene/systems/SceneSerializer.cpp b/src/features/editScene/systems/SceneSerializer.cpp index d49f511..16bdca4 100644 --- a/src/features/editScene/systems/SceneSerializer.cpp +++ b/src/features/editScene/systems/SceneSerializer.cpp @@ -20,6 +20,7 @@ #include "../components/TriangleBuffer.hpp" #include "../components/Character.hpp" #include "../components/CharacterSpawner.hpp" +#include "../components/TerrainPrefabSpawner.hpp" #include "../components/CharacterSlots.hpp" #include "../components/CharacterIdentity.hpp" #include "CharacterRegistry.hpp" @@ -262,6 +263,11 @@ nlohmann::json SceneSerializer::serializeEntity(flecs::entity entity) json["characterSpawner"] = serializeCharacterSpawner(entity); } + if (entity.has()) { + json["terrainPrefabSpawner"] = + serializeTerrainPrefabSpawner(entity); + } + if (entity.has()) { json["animationTree"] = serializeAnimationTree(entity); } @@ -491,6 +497,11 @@ void SceneSerializer::deserializeEntity(const nlohmann::json &json, deserializeCharacterSpawner(entity, json["characterSpawner"]); } + if (json.contains("terrainPrefabSpawner")) { + deserializeTerrainPrefabSpawner(entity, + json["terrainPrefabSpawner"]); + } + if (json.contains("animationTree")) { deserializeAnimationTree(entity, json["animationTree"]); } @@ -728,6 +739,11 @@ void SceneSerializer::deserializeEntityComponents( deserializeCharacterSpawner(entity, json["characterSpawner"]); } + if (json.contains("terrainPrefabSpawner")) { + deserializeTerrainPrefabSpawner(entity, + json["terrainPrefabSpawner"]); + } + if (json.contains("animationTree")) { deserializeAnimationTree(entity, json["animationTree"]); } @@ -2292,6 +2308,29 @@ void SceneSerializer::deserializeCharacterSpawner(flecs::entity entity, entity.set(spawner); } +nlohmann::json +SceneSerializer::serializeTerrainPrefabSpawner(flecs::entity entity) +{ + auto &spawner = entity.get(); + nlohmann::json json; + json["prefabPath"] = spawner.prefabPath; + json["spawnDistance"] = std::sqrt(spawner.spawnDistanceSq); + json["despawnDistance"] = std::sqrt(spawner.despawnDistanceSq); + return json; +} + +void SceneSerializer::deserializeTerrainPrefabSpawner( + flecs::entity entity, const nlohmann::json &json) +{ + TerrainPrefabSpawnerComponent spawner; + spawner.prefabPath = json.value("prefabPath", std::string()); + float spawnDist = json.value("spawnDistance", 100.0f); + float despawnDist = json.value("despawnDistance", 200.0f); + spawner.spawnDistanceSq = spawnDist * spawnDist; + spawner.despawnDistanceSq = despawnDist * despawnDist; + entity.set(spawner); +} + nlohmann::json SceneSerializer::serializeCharacterSlots(flecs::entity entity) { /* CharacterSlotsComponent is deprecated and no longer saved. */ @@ -4200,6 +4239,17 @@ nlohmann::json SceneSerializer::serializeTerrain(flecs::entity entity) roadConfigJson["roadVisibilityDistance"] = tc.roadGraph.config.roadVisibilityDistance; roadConfigJson["roadMaterialName"] = tc.roadGraph.config.roadMaterialName; + roadConfigJson["sidewalkEnabled"] = tc.roadGraph.config.sidewalkEnabled; + roadConfigJson["sidewalkWidth"] = tc.roadGraph.config.sidewalkWidth; + roadConfigJson["sidewalkHeight"] = tc.roadGraph.config.sidewalkHeight; + roadConfigJson["sidewalkThickness"] = + tc.roadGraph.config.sidewalkThickness; + roadConfigJson["sidewalkMeshTemplate"] = + tc.roadGraph.config.sidewalkMeshTemplate; + roadConfigJson["prefabSpawnDistance"] = + tc.roadGraph.config.prefabSpawnDistance; + roadConfigJson["prefabDespawnDistance"] = + tc.roadGraph.config.prefabDespawnDistance; json["roadConfig"] = roadConfigJson; nlohmann::json roadNodesJson = nlohmann::json::array(); @@ -4223,16 +4273,17 @@ nlohmann::json SceneSerializer::serializeTerrain(flecs::entity entity) ej["lanesAtoB"] = e.lanesAtoB; ej["lanesBtoA"] = e.lanesBtoA; - nlohmann::json sidePrefabsJson = nlohmann::json::array(); - for (auto &sp : e.sidePrefabs) { - nlohmann::json spj; - spj["prefabPath"] = sp.prefabPath; - spj["edgeT"] = sp.edgeT; - spj["sideOffset"] = sp.sideOffset; - spj["leftSide"] = sp.leftSide; - sidePrefabsJson.push_back(spj); - } - ej["sidePrefabs"] = sidePrefabsJson; + auto writeSlot = [](const RoadEdgePrefabSlot &slot) { + nlohmann::json sj; + sj["prefabPath"] = slot.prefabPath; + sj["edgeT"] = slot.edgeT; + sj["lateralOffset"] = slot.lateralOffset; + sj["yOffset"] = slot.yOffset; + return sj; + }; + ej["prefabLeft"] = writeSlot(e.prefabLeft); + ej["prefabRight"] = writeSlot(e.prefabRight); + ej["prefabMid"] = writeSlot(e.prefabMid); roadEdgesJson.push_back(ej); } json["roadEdges"] = roadEdgesJson; @@ -4312,6 +4363,20 @@ void SceneSerializer::deserializeTerrain(flecs::entity entity, rcj.value("roadVisibilityDistance", 1000.0f); tc.roadGraph.config.roadMaterialName = rcj.value("roadMaterialName", "RoadMaterial"); + tc.roadGraph.config.sidewalkEnabled = + rcj.value("sidewalkEnabled", false); + tc.roadGraph.config.sidewalkWidth = + rcj.value("sidewalkWidth", 1.5f); + tc.roadGraph.config.sidewalkHeight = + rcj.value("sidewalkHeight", 0.15f); + tc.roadGraph.config.sidewalkThickness = + rcj.value("sidewalkThickness", 0.3f); + tc.roadGraph.config.sidewalkMeshTemplate = + rcj.value("sidewalkMeshTemplate", ""); + tc.roadGraph.config.prefabSpawnDistance = + rcj.value("prefabSpawnDistance", 150.0f); + tc.roadGraph.config.prefabDespawnDistance = + rcj.value("prefabDespawnDistance", 250.0f); } if (json.contains("roadNodes") && json["roadNodes"].is_array()) { @@ -4341,15 +4406,58 @@ void SceneSerializer::deserializeTerrain(flecs::entity entity, edge.lanesAtoB = ej.value("lanesAtoB", 0); edge.lanesBtoA = ej.value("lanesBtoA", 0); + auto readSlot = [&ej](const char *key, + RoadEdgePrefabSlot &slot) { + if (!ej.contains(key)) + return; + const auto &sj = ej[key]; + slot.prefabPath = sj.value("prefabPath", ""); + slot.edgeT = sj.value("edgeT", 0.5f); + slot.lateralOffset = + sj.value("lateralOffset", 0.0f); + slot.yOffset = sj.value("yOffset", 0.0f); + }; + readSlot("prefabLeft", edge.prefabLeft); + readSlot("prefabRight", edge.prefabRight); + readSlot("prefabMid", edge.prefabMid); + + /* Migration: legacy sidePrefabs array -> fixed slots. + * The first left-side entry becomes prefabLeft, the first + * right-side entry prefabRight; sideOffset (absolute + * distance from the centerline) is remapped to + * lateralOffset relative to the curb. Extra entries + * are dropped with a log warning. */ if (ej.contains("sidePrefabs") && ej["sidePrefabs"].is_array()) { + int lanesFrom = 0, lanesTo = 0; + tc.roadGraph.resolveLaneCounts( + edge, edge.nodeA, lanesFrom, lanesTo); + float lw = tc.roadGraph.config.laneWidth; for (auto &spj : ej["sidePrefabs"]) { - RoadEdge::RoadSidePrefab sp; - sp.prefabPath = spj.value("prefabPath", ""); - sp.edgeT = spj.value("edgeT", 0.5f); - sp.sideOffset = spj.value("sideOffset", 5.0f); - sp.leftSide = spj.value("leftSide", true); - edge.sidePrefabs.push_back(sp); + bool leftSide = + spj.value("leftSide", true); + RoadEdgePrefabSlot &slot = + leftSide ? edge.prefabLeft : + edge.prefabRight; + if (!slot.prefabPath.empty()) { + Ogre::LogManager::getSingleton() + .logMessage( + "SceneSerializer: dropping extra legacy road side prefab \"" + + spj.value("prefabPath", + std::string("")) + + "\" during slot migration"); + continue; + } + slot.prefabPath = + spj.value("prefabPath", ""); + slot.edgeT = spj.value("edgeT", 0.5f); + float sideOffset = + spj.value("sideOffset", 5.0f); + float halfWidth = + (leftSide ? lanesTo : lanesFrom) * + lw; + slot.lateralOffset = + sideOffset - halfWidth; } } tc.roadGraph.edges.push_back(edge); diff --git a/src/features/editScene/systems/SceneSerializer.hpp b/src/features/editScene/systems/SceneSerializer.hpp index c2f2173..8ca9ed2 100644 --- a/src/features/editScene/systems/SceneSerializer.hpp +++ b/src/features/editScene/systems/SceneSerializer.hpp @@ -210,6 +210,12 @@ public: nlohmann::json serializeTerrain(flecs::entity entity); void deserializeTerrain(flecs::entity entity, const nlohmann::json &json); + // Terrain prefab spawner serialization (public for the same + // round-trip tests; distances are plain, not squared, in JSON). + nlohmann::json serializeTerrainPrefabSpawner(flecs::entity entity); + void deserializeTerrainPrefabSpawner(flecs::entity entity, + const nlohmann::json &json); + private: // WaterPlane serialization nlohmann::json serializeWaterPlane(flecs::entity entity); diff --git a/src/features/editScene/systems/TerrainPrefabSpawnerSystem.cpp b/src/features/editScene/systems/TerrainPrefabSpawnerSystem.cpp new file mode 100644 index 0000000..a10d9a5 --- /dev/null +++ b/src/features/editScene/systems/TerrainPrefabSpawnerSystem.cpp @@ -0,0 +1,523 @@ +#include "TerrainPrefabSpawnerSystem.hpp" +#include "PrefabSystem.hpp" +#include "CameraSystem.hpp" +#include "EditorUISystem.hpp" +#include "PhysicsSystem.hpp" +#include "TerrainSystem.hpp" +#include "../components/TerrainPrefabSpawner.hpp" +#include "../components/Transform.hpp" +#include "../components/EntityName.hpp" +#include "../components/EditorMarker.hpp" +#include "../components/Renderable.hpp" +#include "../components/RigidBody.hpp" +#include +#include +#include +#include + +TerrainPrefabSpawnerSystem *TerrainPrefabSpawnerSystem::s_instance = nullptr; + +TerrainPrefabSpawnerSystem::TerrainPrefabSpawnerSystem( + flecs::world &world, Ogre::SceneManager *sceneMgr, + EditorCameraSystem *cameraSystem, EditorUISystem *uiSystem, + EditorPhysicsSystem *physicsSystem) + : m_world(world) + , m_sceneMgr(sceneMgr) + , m_cameraSystem(cameraSystem) + , m_uiSystem(uiSystem) + , m_physicsSystem(physicsSystem) + , m_query(world.query()) +{ + s_instance = this; + + m_world.observer( + "TerrainPrefabSpawnerCleanup") + .event(flecs::OnRemove) + .each([this](flecs::entity e, TerrainPrefabSpawnerComponent &) { + despawn(e); + }); +} + +TerrainPrefabSpawnerSystem::~TerrainPrefabSpawnerSystem() +{ + for (auto &pair : m_spawners) { + if (pair.second.spawned.is_alive()) + destroyInstanceRecursive(pair.second.spawned); + } + m_spawners.clear(); + + if (s_instance == this) + s_instance = nullptr; +} + +void TerrainPrefabSpawnerSystem::initialize() +{ + if (m_initialized) + return; + m_initialized = true; + + Ogre::LogManager::getSingleton().logMessage( + "TerrainPrefabSpawnerSystem initialized"); +} + +Ogre::Vector3 TerrainPrefabSpawnerSystem::getCameraPosition() const +{ + if (m_cameraSystem) { + Ogre::Camera *cam = m_cameraSystem->getActiveCamera(); + if (cam) + return cam->getDerivedPosition(); + } + return Ogre::Vector3::ZERO; +} + +flecs::entity +TerrainPrefabSpawnerSystem::getSpawnedEntity(flecs::entity spawnerEntity) const +{ + auto it = m_spawners.find(spawnerEntity.id()); + if (it != m_spawners.end() && it->second.spawned.is_alive()) + return it->second.spawned; + return flecs::entity::null(); +} + +bool TerrainPrefabSpawnerSystem::snapToTerrain(flecs::entity spawnerEntity) +{ + TerrainSystem *ts = TerrainSystem::getInstance(); + if (!ts || !ts->isActive()) + return false; + if (!spawnerEntity.is_alive() || + !spawnerEntity.has() || + !spawnerEntity.has()) + return false; + + auto &transform = spawnerEntity.get_mut(); + if (!transform.node) + return false; + + Ogre::Vector3 worldPos = transform.node->_getDerivedPosition(); + worldPos.y = ts->getHeightAt(worldPos); + + Ogre::Node *parent = transform.node->getParent(); + if (parent) + transform.node->setPosition( + parent->convertWorldToLocalPosition(worldPos)); + else + transform.node->setPosition(worldPos); + transform.position = transform.node->getPosition(); + + return true; +} + +void TerrainPrefabSpawnerSystem::applySpawnerTransform( + flecs::entity spawnerEntity, flecs::entity instance) +{ + if (!spawnerEntity.is_alive() || !instance.is_alive()) + return; + if (!spawnerEntity.has() || + !instance.has()) + return; + + const auto &spawnerTransform = + spawnerEntity.get(); + auto &instTransform = instance.get_mut(); + + instTransform.position = spawnerTransform.position; + instTransform.rotation = spawnerTransform.rotation; + instTransform.scale = spawnerTransform.scale; + if (instTransform.node && spawnerTransform.node) { + instTransform.node->setPosition( + spawnerTransform.node->getPosition()); + instTransform.node->setOrientation( + spawnerTransform.node->getOrientation()); + instTransform.node->setScale( + spawnerTransform.node->getScale()); + } + instTransform.applyToNode(); +} + +void TerrainPrefabSpawnerSystem::spawnPrefab( + flecs::entity spawnerEntity, const TerrainPrefabSpawnerComponent &spawner, + const TransformComponent &transform) +{ + if (spawner.prefabPath.empty() || !spawnerEntity.is_alive() || + !transform.node) + return; + + /* Make sure any previous instance is gone first. */ + despawn(spawnerEntity); + + /* Snap the spawner to the terrain surface before instantiating so + * the prefab sits flush (section 6.4). */ + snapToTerrain(spawnerEntity); + const Ogre::Vector3 pos = transform.node->_getDerivedPosition(); + + PrefabSystem prefabSys(m_world, m_sceneMgr); + const std::string name = + "terrain_prefab_" + std::to_string(spawnerEntity.id()); + flecs::entity inst = prefabSys.createInstance( + spawner.prefabPath, flecs::entity::null(), pos, name); + if (!inst.is_alive()) { + Ogre::LogManager::getSingleton().logMessage( + "TerrainPrefabSpawnerSystem: failed to spawn prefab '" + + spawner.prefabPath + "'"); + return; + } + + /* Runtime-only: keep the instance out of the scene file and the + * editor entity lists (same treatment as roadside prefabs). */ + if (inst.has()) + inst.remove(); + if (m_uiSystem) + m_uiSystem->removeEntity(inst); + + spawnerEntity.get_mut().spawnedEntity = + inst.id(); + m_spawners[spawnerEntity.id()].spawned = inst; + + Ogre::LogManager::getSingleton().logMessage( + "TerrainPrefabSpawnerSystem: spawned '" + spawner.prefabPath + + "' for spawner " + std::to_string(spawnerEntity.id())); +} + +void TerrainPrefabSpawnerSystem::destroyInstanceRecursive(flecs::entity entity) +{ + /* Shared recursive cleanup (scene nodes, Ogre entities, Jolt + * bodies, UI caches) lives in PrefabSystem (improvement plan §3.2). */ + PrefabSystem prefabSys(m_world, m_sceneMgr); + prefabSys.destroyInstance( + entity, + m_physicsSystem ? m_physicsSystem->getPhysicsWrapper() : + nullptr, + m_uiSystem); +} + +void TerrainPrefabSpawnerSystem::despawn(flecs::entity spawnerEntity) +{ + auto it = m_spawners.find(spawnerEntity.id()); + flecs::entity inst = flecs::entity::null(); + if (it != m_spawners.end()) { + inst = it->second.spawned; + m_spawners.erase(it); + } + + if (spawnerEntity.is_alive() && + spawnerEntity.has()) + spawnerEntity.get_mut() + .spawnedEntity = 0; + + if (inst.is_alive()) { + destroyInstanceRecursive(inst); + Ogre::LogManager::getSingleton().logMessage( + "TerrainPrefabSpawnerSystem: despawned prefab for " + "spawner " + + std::to_string(spawnerEntity.id())); + } +} + +void TerrainPrefabSpawnerSystem::spawn(flecs::entity spawnerEntity) +{ + if (!spawnerEntity.is_alive() || + !spawnerEntity.has() || + !spawnerEntity.has()) + return; + spawnPrefab(spawnerEntity, + spawnerEntity.get(), + spawnerEntity.get()); +} + +void TerrainPrefabSpawnerSystem::update() +{ + if (!m_initialized) + return; + + const Ogre::Vector3 cameraPos = getCameraPosition(); + + m_query.each([&](flecs::entity e, + TerrainPrefabSpawnerComponent &spawner, + TransformComponent &transform) { + if (!transform.node) + return; + + auto it = m_spawners.find(e.id()); + const bool hasRec = it != m_spawners.end(); + SpawnerRecord snapshot; + if (hasRec) + snapshot = it->second; + + Ogre::Vector3 pos = transform.node->_getDerivedPosition(); + + const bool spawnerMoved = + !hasRec || !snapshot.evaluated || + (pos - snapshot.lastSpawnerPos).squaredLength() > 1e-6f; + const bool cameraMoved = + !hasRec || !snapshot.evaluated || + (cameraPos - snapshot.camPosAtEval).squaredLength() > + CAM_REEVAL_DIST_SQ; + const bool paramsChanged = + hasRec && snapshot.evaluated && + (snapshot.spawnDistanceSq != spawner.spawnDistanceSq || + snapshot.despawnDistanceSq != spawner.despawnDistanceSq || + snapshot.prefabPath != spawner.prefabPath); + const bool spawnedAlive = hasRec && snapshot.spawned.is_alive(); + + /* Skip the distance re-evaluation while nothing relevant + * changed (camera hysteresis, section 6.3 item 4). A record + * claiming a spawn whose entity died externally always + * re-evaluates. */ + if (hasRec && snapshot.evaluated && !spawnerMoved && + !cameraMoved && !paramsChanged && + (snapshot.spawned == flecs::entity::null() || spawnedAlive)) + return; + + /* The spawner was moved in the editor: re-snap Y on (x, z) + * changes and keep the spawned instance glued to it. */ + if (hasRec && snapshot.evaluated && spawnerMoved) { + if (std::fabs(pos.x - snapshot.lastSpawnerPos.x) > + 1e-4f || + std::fabs(pos.z - snapshot.lastSpawnerPos.z) > + 1e-4f) { + if (snapToTerrain(e)) + pos = transform.node + ->_getDerivedPosition(); + } + if (spawnedAlive) + applySpawnerTransform(e, snapshot.spawned); + } + + const float distSq = (pos - cameraPos).squaredLength(); + + if (spawnedAlive && paramsChanged && + snapshot.prefabPath != spawner.prefabPath) { + /* Prefab asset changed: respawn to swap it. */ + despawn(e); + spawnPrefab(e, spawner, transform); + } else if (!spawnedAlive && !spawner.prefabPath.empty() && + distSq <= spawner.spawnDistanceSq) { + spawnPrefab(e, spawner, transform); + } else if (spawnedAlive && distSq > spawner.despawnDistanceSq) { + despawn(e); + } + + /* Refresh the record (despawn() may have erased it). */ + SpawnerRecord &rec = m_spawners[e.id()]; + rec.evaluated = true; + rec.lastSpawnerPos = pos; + rec.camPosAtEval = cameraPos; + rec.spawnDistanceSq = spawner.spawnDistanceSq; + rec.despawnDistanceSq = spawner.despawnDistanceSq; + rec.prefabPath = spawner.prefabPath; + rec.spawned = spawner.spawnedEntity != 0 ? + m_world.entity(spawner.spawnedEntity) : + flecs::entity::null(); + }); +} + +/* ------------------------------------------------------------------ */ +/* Terrain compliance tool (section 6.5) */ +/* ------------------------------------------------------------------ */ + +static void mergeInstanceWorldAABB(flecs::entity entity, + Ogre::AxisAlignedBox &box) +{ + if (entity.has()) { + const auto &renderable = entity.get(); + if (renderable.entity) + box.merge(renderable.entity->getWorldBoundingBox(true)); + } + entity.children( + [&box](flecs::entity child) { + mergeInstanceWorldAABB(child, box); + }); +} + +bool TerrainPrefabSpawnerSystem::complyTerrainToPrefab( + flecs::entity spawnerEntity) +{ + TerrainSystem *ts = TerrainSystem::getInstance(); + if (!ts || !ts->isActive()) { + Ogre::LogManager::getSingleton().logMessage( + "TerrainPrefabSpawnerSystem: comply failed - no active terrain"); + return false; + } + if (!spawnerEntity.is_alive() || + !spawnerEntity.has() || + !spawnerEntity.has()) + return false; + + /* Make sure the instance exists so the real footprint can be + * measured. */ + flecs::entity inst = getSpawnedEntity(spawnerEntity); + if (!inst.is_alive()) { + spawn(spawnerEntity); + inst = getSpawnedEntity(spawnerEntity); + } + + const auto &transform = spawnerEntity.get(); + const Ogre::Vector3 center = transform.node ? + transform.node->_getDerivedPosition() : + transform.position; + + /* Flatten target: the prefab base height (spawner Y is snapped to + * the terrain surface at placement/spawn time). */ + const float targetY = center.y; + + Ogre::AxisAlignedBox box; + if (inst.is_alive()) + mergeInstanceWorldAABB(inst, box); + if (box.isNull()) { + /* No measurable geometry (prefab not instantiated yet or + * empty): fall back to a small pad around the base point. */ + box = Ogre::AxisAlignedBox(center - Ogre::Vector3(1, 0, 1), + center + Ogre::Vector3(1, 0, 1)); + } + + float texel = ts->getFixupTexelSize(); + if (texel <= 1e-6f) + texel = 1.0f; + /* Fade margin: at least two fixup sample rings so the coarse chunk + * grid always shows a transition next to the flattened pad. */ + const float margin = std::max(2.0f, texel * 2.0f); + + const float minX = box.getMinimum().x; + const float maxX = box.getMaximum().x; + const float minZ = box.getMinimum().z; + const float maxZ = box.getMaximum().z; + + /* Pass 1 — falloff band around the footprint (written first so the + * full-flatten pass wins on shared chunk cells, same ordering as + * road compliance M5.10). */ + for (float x = minX - margin; x <= maxX + margin; x += texel) { + for (float z = minZ - margin; z <= maxZ + margin; z += texel) { + const float dx = + std::max(std::max(minX - x, 0.0f), x - maxX); + const float dz = + std::max(std::max(minZ - z, 0.0f), z - maxZ); + const float d = std::sqrt(dx * dx + dz * dz); + if (d <= 1e-4f || d > margin) + continue; + const float t = d / margin; + const float base = ts->sampleBaseHeightAt( + (long)std::floor(x), (long)std::floor(z)); + ts->writeFixupSample( + x, z, targetY * (1.0f - t) + base * t); + } + } + + /* Pass 2 — full flatten under the footprint. */ + for (float x = minX; x <= maxX + texel * 0.5f; x += texel) + for (float z = minZ; z <= maxZ + texel * 0.5f; z += texel) + ts->writeFixup(x, z, targetY); + + /* Mark affected pages dirty so they resample with the fixups. */ + if (Ogre::TerrainGroup *group = ts->getTerrainGroup()) { + long pxA, pyA, pxB, pyB; + group->convertWorldPositionToTerrainSlot( + Ogre::Vector3(minX - margin, 0, minZ - margin), &pxA, + &pyA); + group->convertWorldPositionToTerrainSlot( + Ogre::Vector3(maxX + margin, 0, maxZ + margin), &pxB, + &pyB); + if (pxB < pxA) + std::swap(pxA, pxB); + if (pyB < pyA) + std::swap(pyA, pyB); + for (long px = pxA; px <= pxB; ++px) + for (long py = pyA; py <= pyB; ++py) + ts->markPageDirty(px, py); + } + + /* Persist fixups to disk. */ + ts->saveFixups(); + + Ogre::LogManager::getSingleton().logMessage( + "TerrainPrefabSpawnerSystem: terrain compliance applied for " + "spawner " + + std::to_string(spawnerEntity.id())); + return true; +} + +/* ------------------------------------------------------------------ */ +/* Editor prefab spawn mode (section 7.2) */ +/* ------------------------------------------------------------------ */ + +void TerrainPrefabSpawnerSystem::setSpawnEditMode(bool v) +{ + if (v == m_spawnEditMode) + return; + m_spawnEditMode = v; + if (v) { + /* Mutual exclusion with the other 3D edit modes so + * left-click placement does not fight the brushes. */ + TerrainSystem *ts = TerrainSystem::getInstance(); + if (ts) { + if (ts->getSculptMode()) + ts->setSculptMode(false); + if (ts->getPaintMode()) + ts->setPaintMode(false); + if (ts->getAuxPaintMode()) + ts->setAuxPaintMode(false); + if (ts->getRoadEditMode()) + ts->setRoadEditMode(false); + } + } +} + +flecs::entity +TerrainPrefabSpawnerSystem::handleSpawnClick(const Ogre::Ray &mouseRay) +{ + if (!m_spawnEditMode || m_placementPrefabPath.empty()) + return flecs::entity::null(); + + TerrainSystem *ts = TerrainSystem::getInstance(); + if (!ts || !ts->isActive()) + return flecs::entity::null(); + + float t; + Ogre::Vector3 normal; + if (!ts->raycastTerrain(mouseRay, t, normal)) + return flecs::entity::null(); + + return createSpawnPoint(m_placementPrefabPath, mouseRay.getPoint(t)); +} + +flecs::entity TerrainPrefabSpawnerSystem::createSpawnPoint( + const std::string &prefabPath, const Ogre::Vector3 &position) +{ + if (prefabPath.empty()) + return flecs::entity::null(); + + static int s_spawnCounter = 0; + + flecs::entity e = m_world.entity(); + e.set(EntityNameComponent( + "PrefabSpawner_" + std::to_string(++s_spawnCounter))); + e.add(); + + /* Snap to the terrain surface at placement time (section 6.4). */ + Ogre::Vector3 pos = position; + TerrainSystem *ts = TerrainSystem::getInstance(); + if (ts && ts->isActive()) + pos.y = ts->getHeightAt(position); + + TransformComponent xform; + xform.node = m_sceneMgr->getRootSceneNode()->createChildSceneNode(); + xform.position = pos; + xform.rotation = Ogre::Quaternion::IDENTITY; + xform.scale = Ogre::Vector3::UNIT_SCALE; + xform.applyToNode(); + e.set(xform); + + TerrainPrefabSpawnerComponent spawner; + spawner.prefabPath = prefabPath; + e.set(spawner); + + if (m_uiSystem) + m_uiSystem->addEntity(e); + + Ogre::LogManager::getSingleton().logMessage( + "TerrainPrefabSpawnerSystem: created spawn point '" + + prefabPath + "' at " + + Ogre::StringConverter::toString(pos)); + + return e; +} diff --git a/src/features/editScene/systems/TerrainPrefabSpawnerSystem.hpp b/src/features/editScene/systems/TerrainPrefabSpawnerSystem.hpp new file mode 100644 index 0000000..7d4aae3 --- /dev/null +++ b/src/features/editScene/systems/TerrainPrefabSpawnerSystem.hpp @@ -0,0 +1,181 @@ +#ifndef EDITSCENE_TERRAINPREFABSPAWNERSYSTEM_HPP +#define EDITSCENE_TERRAINPREFABSPAWNERSYSTEM_HPP +#pragma once + +#include +#include +#include +#include + +class EditorCameraSystem; +class EditorUISystem; +class EditorPhysicsSystem; + +/** + * @brief Distance-based spawn/despawn of prefab instances on terrain + * (TerrainRequirements.md Milestone 6). + * + * Queries entities with TerrainPrefabSpawnerComponent + TransformComponent. + * Spawns the referenced prefab through PrefabSystem when the active camera + * is within spawnDistanceSq, despawns it beyond despawnDistanceSq. + * + * Distance re-evaluation is skipped while neither the camera (10 unit + * hysteresis), the spawner transform, nor the spawner parameters changed + * since the last evaluation (section 6.3 item 4). + * + * The system also owns the editor "prefab spawn mode" (section 7.2): + * while active, a left click on the terrain places a new spawner entity + * snapped to the surface, and the terrain compliance tool (section 6.5) + * flattens the terrain under a spawned prefab's footprint using fixup + * chunks. + * + * Spawned instances are runtime-only: EditorMarkerComponent is stripped and + * the instance is removed from the editor UI caches, so they are never + * serialized and never appear in the editor outliner. + */ +class TerrainPrefabSpawnerSystem { +public: + TerrainPrefabSpawnerSystem(flecs::world &world, + Ogre::SceneManager *sceneMgr, + EditorCameraSystem *cameraSystem, + EditorUISystem *uiSystem = nullptr, + EditorPhysicsSystem *physicsSystem = nullptr); + ~TerrainPrefabSpawnerSystem(); + + TerrainPrefabSpawnerSystem(const TerrainPrefabSpawnerSystem &) = delete; + TerrainPrefabSpawnerSystem & + operator=(const TerrainPrefabSpawnerSystem &) = delete; + + /* Singleton access for the terrain editor panel and the UI system's + * mouse dispatch (same pattern as TerrainSystem::getInstance()). */ + static TerrainPrefabSpawnerSystem *getInstance() + { + return s_instance; + } + + void initialize(); + void update(); + + /** + * Force an immediate spawn for the given spawner, regardless of + * camera distance. Snaps the spawner's Y to the terrain surface. + * Does nothing if the spawner is missing, invalid, or has an empty + * prefab path. + */ + void spawn(flecs::entity spawnerEntity); + + /** + * Destroy the prefab instance associated with a spawner, if any. + */ + void despawn(flecs::entity spawnerEntity); + + /** + * Return the prefab instance entity currently spawned by a spawner, + * or null if none is alive. + */ + flecs::entity getSpawnedEntity(flecs::entity spawnerEntity) const; + + /** + * Snap the spawner's TransformComponent Y to the terrain surface at + * its (x, z) (section 6.4). Returns false when there is no active + * terrain or the entity is not a valid spawner. + */ + bool snapToTerrain(flecs::entity spawnerEntity); + + /** + * Terrain compliance tool (section 6.5): flatten the terrain under + * the spawned prefab's world AABB footprint so it sits flush. + * Spawns the prefab first when it is not currently spawned. Writes + * fixup chunk entries (full flatten under the footprint, linear fade + * over a margin outside it), marks the affected pages dirty, and + * saves the fixups. Returns false when the spawner/prefab is + * invalid or no terrain is active. + */ + bool complyTerrainToPrefab(flecs::entity spawnerEntity); + + /* --- Editor prefab spawn mode (section 7.2) --- */ + + bool getSpawnEditMode() const + { + return m_spawnEditMode; + } + /* Enabling spawn mode deactivates the terrain sculpt/paint/aux/road + * modes so left-click placement does not fight the brushes. */ + void setSpawnEditMode(bool v); + bool isSpawnEditing() const + { + return m_spawnEditMode; + } + + /* Prefab used by click-to-place (selected in the terrain editor). */ + const std::string &getPlacementPrefabPath() const + { + return m_placementPrefabPath; + } + void setPlacementPrefabPath(const std::string &path) + { + m_placementPrefabPath = path; + } + + /** + * Click-to-place: raycast against the terrain and create a spawner + * entity at the hit point (Y snapped to the surface) using the + * current placement prefab. Returns the new entity, or null when + * the ray missed or no placement prefab is selected. + */ + flecs::entity handleSpawnClick(const Ogre::Ray &mouseRay); + + /** + * Create a spawner entity at the given world position (Y snapped to + * the terrain when active). The entity is regular scene content: + * it carries EditorMarkerComponent and is serialized. + */ + flecs::entity createSpawnPoint(const std::string &prefabPath, + const Ogre::Vector3 &position); + +private: + void spawnPrefab(flecs::entity spawnerEntity, + const struct TerrainPrefabSpawnerComponent &spawner, + const struct TransformComponent &transform); + void destroyInstanceRecursive(flecs::entity entity); + void applySpawnerTransform(flecs::entity spawnerEntity, + flecs::entity instance); + Ogre::Vector3 getCameraPosition() const; + + flecs::world &m_world; + Ogre::SceneManager *m_sceneMgr; + EditorCameraSystem *m_cameraSystem; + EditorUISystem *m_uiSystem; + EditorPhysicsSystem *m_physicsSystem; + flecs::query + m_query; + bool m_initialized = false; + + static TerrainPrefabSpawnerSystem *s_instance; + + struct SpawnerRecord { + /* Spawned instance; null entity when not spawned. */ + flecs::entity spawned = flecs::entity::null(); + /* State at last distance evaluation, for change detection. */ + bool evaluated = false; + Ogre::Vector3 lastSpawnerPos = Ogre::Vector3::ZERO; + Ogre::Vector3 camPosAtEval = Ogre::Vector3::ZERO; + float spawnDistanceSq = -1.0f; + float despawnDistanceSq = -1.0f; + std::string prefabPath; + }; + + std::unordered_map m_spawners; + + /* Editor spawn-placement mode state (runtime only). */ + bool m_spawnEditMode = false; + std::string m_placementPrefabPath; + + /* Camera hysteresis: re-evaluate a spawner's distance only when the + * camera moved more than this many units since the last evaluation + * of that spawner (squared: 10^2, section 6.3 item 4). */ + static constexpr float CAM_REEVAL_DIST_SQ = 10.0f * 10.0f; +}; + +#endif // EDITSCENE_TERRAINPREFABSPAWNERSYSTEM_HPP diff --git a/src/features/editScene/systems/TerrainSystem.cpp b/src/features/editScene/systems/TerrainSystem.cpp index 4de1a86..c8213ad 100644 --- a/src/features/editScene/systems/TerrainSystem.cpp +++ b/src/features/editScene/systems/TerrainSystem.cpp @@ -530,11 +530,14 @@ TerrainSystem::findFixupChunkLocked(int chunkX, int chunkZ) const float TerrainSystem::sampleFixupLocked(float worldX, float worldZ) const { - /* Compute chunk coordinates. Chunk (0,0) covers world - * (0,0)..(worldSize/256, worldSize/256) in both X and Z. - * The chunk resolution FIXUP_CHUNK_RES is always 256 regardless - * of heightmapSize — this gives a consistent grid for fixups. */ - float chunkWorldSize = m_heightmapWorldSize / (float)FIXUP_CHUNK_RES; + /* Compute chunk coordinates. One chunk spans the full PER-PAGE + * world size (TerrainComponent::worldSize, section 4.2), so chunk + * indices coincide with page indices; the 256x256 samples of a chunk + * give one sample per worldSize/256 units — the same density as the + * base heightmap. FIXUP_CHUNK_RES is always 256 regardless of + * heightmapSize — this gives a consistent grid for fixups. */ + float chunkWorldSize = m_pageWorldSize; + float cellW = chunkWorldSize / (float)FIXUP_CHUNK_RES; int chunkX = (int)std::floor(worldX / chunkWorldSize); int chunkZ = (int)std::floor(worldZ / chunkWorldSize); @@ -543,10 +546,8 @@ float TerrainSystem::sampleFixupLocked(float worldX, float worldZ) const return FIXUP_SENTINEL; /* Bilinear sample within the chunk. */ - float cellX = (worldX - (float)chunkX * chunkWorldSize) / - chunkWorldSize * (float)FIXUP_CHUNK_RES; - float cellZ = (worldZ - (float)chunkZ * chunkWorldSize) / - chunkWorldSize * (float)FIXUP_CHUNK_RES; + float cellX = (worldX - (float)chunkX * chunkWorldSize) / cellW; + float cellZ = (worldZ - (float)chunkZ * chunkWorldSize) / cellW; int x0 = (int)std::floor(cellX); int z0 = (int)std::floor(cellZ); @@ -572,25 +573,69 @@ float TerrainSystem::sampleFixupLocked(float worldX, float worldZ) const h01 == FIXUP_SENTINEL && h11 == FIXUP_SENTINEL) return FIXUP_SENTINEL; - /* Replace sentinel with fallback so partial cells still blend. */ + /* Replace sentinel corners with the natural (base + noise) height + * at the corner position so partially written cells blend toward + * the untouched terrain instead of toward zero. */ + float originX = (float)chunkX * chunkWorldSize; + float originZ = (float)chunkZ * chunkWorldSize; if (h00 == FIXUP_SENTINEL) - h00 = 0.0f; + h00 = sampleBaseLocked((long)std::floor(originX + x0 * cellW), + (long)std::floor(originZ + z0 * cellW)); if (h10 == FIXUP_SENTINEL) - h10 = 0.0f; + h10 = sampleBaseLocked((long)std::floor(originX + x1 * cellW), + (long)std::floor(originZ + z0 * cellW)); if (h01 == FIXUP_SENTINEL) - h01 = 0.0f; + h01 = sampleBaseLocked((long)std::floor(originX + x0 * cellW), + (long)std::floor(originZ + z1 * cellW)); if (h11 == FIXUP_SENTINEL) - h11 = 0.0f; + h11 = sampleBaseLocked((long)std::floor(originX + x1 * cellW), + (long)std::floor(originZ + z1 * cellW)); return (1.0f - tx) * (1.0f - tz) * h00 + tx * (1.0f - tz) * h10 + (1.0f - tx) * tz * h01 + tx * tz * h11; } +float TerrainSystem::getFixupTexelSize() const +{ + return m_pageWorldSize / (float)FIXUP_CHUNK_RES; +} + +void TerrainSystem::writeFixupSample(float worldX, float worldZ, + float height) +{ + std::lock_guard lock(m_heightmapMutex); + + float chunkWorldSize = m_pageWorldSize; + float cellW = chunkWorldSize / (float)FIXUP_CHUNK_RES; + int chunkX = (int)std::floor(worldX / chunkWorldSize); + int chunkZ = (int)std::floor(worldZ / chunkWorldSize); + + /* Create or retrieve the chunk lazily. */ + auto &chunk = m_fixupChunks[{chunkX, chunkZ}]; + if (chunk.samples.empty()) { + chunk.samples.resize(FIXUP_CHUNK_RES * FIXUP_CHUNK_RES, + FIXUP_SENTINEL); + } + + int x0 = (int)std::floor((worldX - (float)chunkX * chunkWorldSize) / + cellW); + int z0 = (int)std::floor((worldZ - (float)chunkZ * chunkWorldSize) / + cellW); + + int r = FIXUP_CHUNK_RES; + if (x0 < 0 || x0 >= r || z0 < 0 || z0 >= r) + return; + + chunk.samples[z0 * r + x0] = height; + chunk.dirty = true; +} + void TerrainSystem::writeFixup(float worldX, float worldZ, float height) { std::lock_guard lock(m_heightmapMutex); - float chunkWorldSize = m_heightmapWorldSize / (float)FIXUP_CHUNK_RES; + float chunkWorldSize = m_pageWorldSize; + float cellW = chunkWorldSize / (float)FIXUP_CHUNK_RES; int chunkX = (int)std::floor(worldX / chunkWorldSize); int chunkZ = (int)std::floor(worldZ / chunkWorldSize); @@ -604,10 +649,8 @@ void TerrainSystem::writeFixup(float worldX, float worldZ, float height) /* Write to the four samples of the chunk cell containing * (worldX, worldZ) so bilinear sampling returns @p height * at the exact write position. */ - float cellX = (worldX - (float)chunkX * chunkWorldSize) / - chunkWorldSize * (float)FIXUP_CHUNK_RES; - float cellZ = (worldZ - (float)chunkZ * chunkWorldSize) / - chunkWorldSize * (float)FIXUP_CHUNK_RES; + float cellX = (worldX - (float)chunkX * chunkWorldSize) / cellW; + float cellZ = (worldZ - (float)chunkZ * chunkWorldSize) / cellW; int x0 = (int)std::floor(cellX); int z0 = (int)std::floor(cellZ); @@ -1717,7 +1760,13 @@ float TerrainSystem::sampleHeightAt(long worldX, long worldZ) const return sampleHeightAtLocked(worldX, worldZ); } -float TerrainSystem::sampleHeightAtLocked(long worldX, long worldZ) const +float TerrainSystem::sampleBaseHeightAt(long worldX, long worldZ) const +{ + std::lock_guard lock(m_heightmapMutex); + return sampleBaseLocked(worldX, worldZ); +} + +float TerrainSystem::sampleBaseLocked(long worldX, long worldZ) const { if (!m_heightmapLoaded || m_heightData.empty()) return proceduralHeight(worldX, worldZ); @@ -1752,6 +1801,13 @@ float TerrainSystem::sampleHeightAtLocked(long worldX, long worldZ) const if (m_detailNoise.enabled) h += computeDetailNoise(worldX, worldZ, m_detailNoise); + return h; +} + +float TerrainSystem::sampleHeightAtLocked(long worldX, long worldZ) const +{ + float h = sampleBaseLocked(worldX, worldZ); + /* Fixup chunks override the combined base height + noise (M5.9.5). * If a fixup value exists at this position, use it instead. */ float fixup = sampleFixupLocked((float)worldX, (float)worldZ); @@ -1891,6 +1947,7 @@ void TerrainSystem::activate(TerrainComponent &tc, TransformComponent &xform, m_heightmapWorldMinZ = (float)m_pageMinY * tc.worldSize; m_heightmapWorldSize = (float)(m_pageMaxX - m_pageMinX + 1) * tc.worldSize; + m_pageWorldSize = tc.worldSize; m_fixupDir = getFixupDir(tc); diff --git a/src/features/editScene/systems/TerrainSystem.hpp b/src/features/editScene/systems/TerrainSystem.hpp index f953ccf..aba44b5 100644 --- a/src/features/editScene/systems/TerrainSystem.hpp +++ b/src/features/editScene/systems/TerrainSystem.hpp @@ -73,6 +73,14 @@ public: /* --- Heightmap data (M3) --- */ float sampleHeightAt(long worldX, long worldZ) const; float sampleHeightAtLocked(long worldX, long worldZ) const; + + /* Base terrain height (base heightmap + detail noise) at integer + * world coordinates, ignoring the fixup layer. Used by road + * compliance falloff (M5.10) so fade targets blend toward the + * natural terrain height even where fixups were already written + * nearby. */ + float sampleBaseHeightAt(long worldX, long worldZ) const; + float sampleBaseLocked(long worldX, long worldZ) const; void setHeightAt(long worldX, long worldZ, float value); bool loadHeightmap(const std::string &path); bool saveHeightmap(const std::string &path); @@ -83,15 +91,33 @@ public: /* Fixup chunks (M5.9.5): sparse absolute-height overrides layered * on top of base heightmap + detail noise during sampling. * + * Chunk grid: one chunk spans the full PER-PAGE world size + * (TerrainComponent::worldSize) and holds 256x256 samples, so one + * sample covers worldSize/256 units — the same density as the base + * heightmap ("same as base heightmap but always 256x256", + * TerrainRequirements.md section 4.2). Chunk indices coincide with + * page indices. NOTE: this deviates from the literal addressing + * formula in M5.9.5/4.2 (chunk span worldSize/256, i.e. one sample + * per worldSize/65536 units) — at that density the road-compliance + * writer cannot fill the grid and the terrain mesh (sampling at + * page-vertex spacing) never sees the fixups. + * * writeFixup() splats the 4 samples of the chunk cell containing the * point so bilinear sampling returns @p height at the write position; * the chunk is created lazily and marked dirty for saveFixups(). + * writeFixupSample() writes only the single nearest sample — used by + * the road-compliance falloff, which evaluates the fade target at the + * exact sample position so bilinear reads reproduce the linear ramp. + * Cells where only some samples are written blend the written values + * with the natural (base + noise) height at the unwritten corners. * Callers are responsible for marking affected terrain pages dirty * AFTER all writes (main thread). * * clearAllFixups() deletes every fixup file and the in-memory chunks * and marks all loaded pages dirty. */ void writeFixup(float worldX, float worldZ, float height); + void writeFixupSample(float worldX, float worldZ, float height); + float getFixupTexelSize() const; bool saveFixups(); void clearAllFixups(); size_t getFixupChunkCount() const; @@ -300,11 +326,14 @@ public: /** * Road editor tool modes (M5.2). Move is the default: clicks select * and drag existing nodes/edges. In AddNode mode a click on empty - * terrain creates a new node. + * terrain creates a new node. In Connect mode a click pins a source + * node and a click on a second node connects the two (the second + * node then becomes the source, so paths can be chained). */ enum class RoadEditTool { Move, - AddNode + AddNode, + Connect }; RoadEditTool getRoadEditTool() const @@ -321,6 +350,19 @@ public: return m_roadSystem.get(); } + /* Physics wrapper used for terrain/road colliders (may be null). + * Exposed for headless tests that verify collider interaction. */ + JoltPhysicsWrapper *getPhysics() const + { + return m_physics; + } + + /* Camera used for terrain paging and distance checks (may be null). */ + Ogre::Camera *getCamera() const + { + return m_camera; + } + /* --- Brush decal (M3) --- */ void updateBrushDecal(const Ogre::Vector3 &worldPos, const Ogre::Vector3 &normal, float radius); @@ -523,6 +565,13 @@ private: static constexpr int FIXUP_CHUNK_RES = 256; static constexpr float FIXUP_SENTINEL = -FLT_MAX; + /* Per-page world size (TerrainComponent::worldSize of the active + * terrain). The fixup chunk grid is derived from this — each chunk + * covers the full per-page world size and holds 256x256 samples + * (worldSize / FIXUP_CHUNK_RES units per sample), independent of how + * many pages the heightmap spans. */ + float m_pageWorldSize = 2000.0f; + mutable std::map, FixupChunk> m_fixupChunks; std::string m_fixupDir; diff --git a/src/features/editScene/systems/TerrainTests.cpp b/src/features/editScene/systems/TerrainTests.cpp index 73b96ef..fce3e8a 100644 --- a/src/features/editScene/systems/TerrainTests.cpp +++ b/src/features/editScene/systems/TerrainTests.cpp @@ -3,10 +3,13 @@ #include "TerrainCommands.hpp" #include "RoadSystem.hpp" #include "ProceduralMeshSystem.hpp" +#include "TerrainPrefabSpawnerSystem.hpp" #include "../EditorApp.hpp" #include "../components/Terrain.hpp" #include "../components/Transform.hpp" #include "../components/EntityName.hpp" +#include "../components/EditorMarker.hpp" +#include "../components/TerrainPrefabSpawner.hpp" #include "../components/RoadGraph.hpp" #include "../components/TriangleBuffer.hpp" #include "../components/Renderable.hpp" @@ -14,6 +17,11 @@ #include "../components/Lod.hpp" #include "../components/PhysicsCollider.hpp" #include "../systems/SceneSerializer.hpp" +#include "../physics/physics.h" +#include "../roadlib/RoadGeometryLib.hpp" + +#include +#include #include #include @@ -1331,6 +1339,190 @@ bool TerrainTestRunner::testRoadDataModel(EditorApp &app, TerrainSystem *ts) return false; } + /* splitEdge must carry prefab slots over to the half that contains + * them (edgeT remapped to local 0..1), never duplicate them. */ + { + RoadGraph g; + int a = g.addNode(Ogre::Vector3(0, 0, 0), 0.0f); + int b = g.addNode(Ogre::Vector3(10, 0, 0), 0.0f); + int e = g.addEdge(a, b); + g.edges[(size_t)e].prefabLeft.prefabPath = "left.json"; + g.edges[(size_t)e].prefabLeft.edgeT = 0.25f; + g.edges[(size_t)e].prefabRight.prefabPath = "right.json"; + g.edges[(size_t)e].prefabRight.edgeT = 0.75f; + g.edges[(size_t)e].prefabMid.prefabPath = "mid.json"; + + int mid = g.splitEdge((size_t)e, 0.5f); + const RoadEdge *halfA = nullptr, *halfB = nullptr; + for (const auto &he : g.edges) { + if (he.nodeB == mid) + halfA = &he; + if (he.nodeA == mid) + halfB = &he; + } + /* The 10-unit edge splits at 5, so actualT == 0.5. */ + bool slotsOk = + halfA && halfB && + halfA->prefabLeft.prefabPath == "left.json" && + std::fabs(halfA->prefabLeft.edgeT - 0.5f) < 1e-4f && + halfA->prefabRight.prefabPath.empty() && + halfA->prefabMid.prefabPath == "mid.json" && + std::fabs(halfA->prefabMid.edgeT - 1.0f) < 1e-4f && + halfB->prefabLeft.prefabPath.empty() && + halfB->prefabRight.prefabPath == "right.json" && + std::fabs(halfB->prefabRight.edgeT - 0.5f) < 1e-4f && + halfB->prefabMid.prefabPath.empty(); + if (!slotsOk) { + Ogre::LogManager::getSingleton().logMessage( + "TerrainTests: FAIL - splitEdge prefab slot " + "remap wrong"); + return false; + } + } + + /* smoothNode: relax a 3-node chain toward the straight line. */ + { + RoadGraph g; + int a = g.addNode(Ogre::Vector3(0, 0, 0), 1.0f); + int b = g.addNode(Ogre::Vector3(10, 0, 10), 0.0f); + int c = g.addNode(Ogre::Vector3(20, 0, 0), 3.0f); + g.addEdge(a, b); + g.addEdge(b, c); + if (!g.smoothNode(b, 0.5f)) { + Ogre::LogManager::getSingleton().logMessage( + "TerrainTests: FAIL - smoothNode rejected a " + "valid move"); + return false; + } + const RoadNode *nb = g.findNodeById(b); + /* midpoint(A, C) = (10, 0); B lerps halfway: z 10 -> 5. + * Offset lerps halfway toward avg(1, 3) = 2: 0 -> 1. */ + if (std::fabs(nb->position.x - 10.0f) > 1e-4f || + std::fabs(nb->position.z - 5.0f) > 1e-4f || + std::fabs(nb->verticalOffset - 1.0f) > 1e-4f) { + Ogre::LogManager::getSingleton().logMessage( + "TerrainTests: FAIL - smoothNode position/offset " + "lerp wrong"); + return false; + } + + /* Endpoints (degree 1) have no 3-node chain: rejected. */ + if (g.smoothNode(a, 0.5f)) { + Ogre::LogManager::getSingleton().logMessage( + "TerrainTests: FAIL - smoothNode accepted an " + "endpoint"); + return false; + } + + /* A move that shortens an edge below the minimum is + * rejected and leaves the node in place. */ + RoadGraph g2; + int a2 = g2.addNode(Ogre::Vector3(0, 0, 0), 0.0f); + int b2 = g2.addNode(Ogre::Vector3(0.9f, 0, 0.6f), 0.0f); + int c2 = g2.addNode(Ogre::Vector3(0, 0, 1.2f), 0.0f); + g2.addEdge(a2, b2); + g2.addEdge(b2, c2); + if (g2.smoothNode(b2, 1.0f) || + g2.findNodeById(b2)->position.x != 0.9f) { + Ogre::LogManager::getSingleton().logMessage( + "TerrainTests: FAIL - smoothNode did not reject " + "a min-length-breaking move"); + return false; + } + } + + /* connectNodes: validated edge creation. */ + { + RoadGraph g; + int a = g.addNode(Ogre::Vector3(0, 0, 0), 0.0f); + int b = g.addNode(Ogre::Vector3(20, 0, 0), 0.0f); + std::string err; + if (g.connectNodes(a, a, 0.0f, &err) >= 0 || + g.connectNodes(a, b, 0.0f, &err) < 0 || + g.connectNodes(a, b, 0.0f, &err) >= 0 || + g.edges.size() != 1) { + Ogre::LogManager::getSingleton().logMessage( + "TerrainTests: FAIL - connectNodes self/duplicate " + "handling wrong"); + return false; + } + + /* A connection creating a wedge sharper than 30 degrees + * must be rejected and rolled back. */ + RoadGraph g2; + int o = g2.addNode(Ogre::Vector3(0, 0, 0), 0.0f); + int a2 = g2.addNode(Ogre::Vector3(20, 0, 0), 0.0f); + int b2 = g2.addNode(Ogre::Vector3(0, 0, 20), 0.0f); + g2.addEdge(o, a2); + g2.addEdge(o, b2); + int d = g2.addNode(Ogre::Vector3(19.319f, 0, 5.176f), 0.0f); + size_t before = g2.edges.size(); + if (g2.connectNodes(o, d, 0.0f, &err) >= 0 || + g2.edges.size() != before) { + Ogre::LogManager::getSingleton().logMessage( + "TerrainTests: FAIL - connectNodes did not roll " + "back an angle-violating edge"); + return false; + } + + /* Terrain pages are centred on the terrain-group origin, so + * an edge crossing the world X/Z axes stays inside one page + * and must be accepted (regression: nodes 6 and 14 of + * terrain2_test.json sit on opposite sides of Z = 0 with + * worldSize 2000 but share page slot (0,0)). */ + RoadGraph g3; + int p1 = g3.addNode(Ogre::Vector3(75.0f, 0.0f, -4.2f), 0.0f); + int p2 = g3.addNode(Ogre::Vector3(100.3f, 0.0f, 17.4f), 0.0f); + if (g3.connectNodes(p1, p2, 2000.0f, &err) < 0) { + Ogre::LogManager::getSingleton().logMessage( + "TerrainTests: FAIL - connectNodes rejected an " + "axis-crossing edge inside one page: " + err); + return false; + } + + /* A genuine cross-page connection is not rejected: the edge + * is split at the page boundary with an inserted node. */ + RoadGraph g4; + int q1 = g4.addNode(Ogre::Vector3(0.0f, 0.0f, 900.0f), 0.0f); + int q2 = g4.addNode(Ogre::Vector3(0.0f, 0.0f, 1100.0f), 0.0f); + std::vector created; + size_t nodesBefore = g4.nodes.size(); + if (g4.connectNodes(q1, q2, 2000.0f, &err, &created) < 0 || + created.size() != 1 || + g4.nodes.size() != nodesBefore + 1 || + g4.edges.size() != 2) { + Ogre::LogManager::getSingleton().logMessage( + "TerrainTests: FAIL - connectNodes did not " + "split a cross-page edge at the boundary: " + + err); + return false; + } + const RoadNode *mid = g4.findNodeById(created[0]); + if (!mid || std::fabs(mid->position.z - 1000.0f) > 1e-3f) { + Ogre::LogManager::getSingleton().logMessage( + "TerrainTests: FAIL - connectNodes split node " + "is not on the page boundary"); + return false; + } + + /* A corner crossing inserts a single node: the X and Z + * boundary hits are deduplicated. */ + RoadGraph g5; + int c1 = g5.addNode(Ogre::Vector3(-1100.0f, 0.0f, -1100.0f), + 0.0f); + int c2 = g5.addNode(Ogre::Vector3(-900.0f, 0.0f, -900.0f), + 0.0f); + created.clear(); + if (g5.connectNodes(c1, c2, 2000.0f, &err, &created) < 0 || + created.size() != 1 || g5.edges.size() != 2) { + Ogre::LogManager::getSingleton().logMessage( + "TerrainTests: FAIL - connectNodes corner " + "crossing did not insert exactly one node: " + + err); + return false; + } + } + Ogre::LogManager::getSingleton().logMessage( "TerrainTests: road data model test passed"); return true; @@ -1350,6 +1542,13 @@ bool TerrainTestRunner::testRoadSerialization(EditorApp &app, TerrainSystem *ts) tc.roadGraph.config.laneWidth = 4.0f; tc.roadGraph.config.lanesPerDirection = 2; tc.roadGraph.config.roadMaterialName = "TestRoadMat"; + tc.roadGraph.config.sidewalkEnabled = true; + tc.roadGraph.config.sidewalkWidth = 2.2f; + tc.roadGraph.config.sidewalkHeight = 0.25f; + tc.roadGraph.config.sidewalkThickness = 0.4f; + tc.roadGraph.config.sidewalkMeshTemplate = "curb.mesh"; + tc.roadGraph.config.prefabSpawnDistance = 120.0f; + tc.roadGraph.config.prefabDespawnDistance = 260.0f; int n1 = tc.roadGraph.addNode(Ogre::Vector3(100, 10, 100), 0.5f); int n2 = tc.roadGraph.addNode(Ogre::Vector3(200, 10, 100), 1.0f); @@ -1359,12 +1558,10 @@ bool TerrainTestRunner::testRoadSerialization(EditorApp &app, TerrainSystem *ts) tc.roadGraph.edges.back().roadLevelA = 0.2f; tc.roadGraph.edges.back().roadLevelB = -0.1f; - RoadEdge::RoadSidePrefab sp; - sp.prefabPath = "lamp_post.json"; - sp.edgeT = 0.25f; - sp.sideOffset = 3.5f; - sp.leftSide = false; - tc.roadGraph.edges.back().sidePrefabs.push_back(sp); + tc.roadGraph.edges.back().prefabRight.prefabPath = "lamp_post.json"; + tc.roadGraph.edges.back().prefabRight.edgeT = 0.25f; + tc.roadGraph.edges.back().prefabRight.lateralOffset = 3.5f; + tc.roadGraph.edges.back().prefabRight.yOffset = 0.5f; e.set(tc); } @@ -1397,6 +1594,21 @@ bool TerrainTestRunner::testRoadSerialization(EditorApp &app, TerrainSystem *ts) return false; } + if (!tc2.roadGraph.config.sidewalkEnabled || + std::abs(tc2.roadGraph.config.sidewalkWidth - 2.2f) > 0.001f || + std::abs(tc2.roadGraph.config.sidewalkHeight - 0.25f) > 0.001f || + std::abs(tc2.roadGraph.config.sidewalkThickness - 0.4f) > 0.001f || + tc2.roadGraph.config.sidewalkMeshTemplate != "curb.mesh" || + std::abs(tc2.roadGraph.config.prefabSpawnDistance - 120.0f) > + 0.001f || + std::abs(tc2.roadGraph.config.prefabDespawnDistance - 260.0f) > + 0.001f) { + Ogre::LogManager::getSingleton().logMessage( + "TerrainTests: FAIL - sidewalk/prefab road config not " + "preserved"); + return false; + } + if (tc2.roadGraph.nodes.size() != 2 || tc2.roadGraph.edges.size() != 1) { Ogre::LogManager::getSingleton().logMessage( @@ -1413,14 +1625,59 @@ bool TerrainTestRunner::testRoadSerialization(EditorApp &app, TerrainSystem *ts) return false; } - if (edge.sidePrefabs.size() != 1 || - edge.sidePrefabs[0].prefabPath != "lamp_post.json" || - edge.sidePrefabs[0].leftSide != false) { + if (edge.prefabRight.prefabPath != "lamp_post.json" || + std::abs(edge.prefabRight.edgeT - 0.25f) > 0.001f || + std::abs(edge.prefabRight.lateralOffset - 3.5f) > 0.001f || + std::abs(edge.prefabRight.yOffset - 0.5f) > 0.001f || + !edge.prefabLeft.prefabPath.empty() || + !edge.prefabMid.prefabPath.empty()) { Ogre::LogManager::getSingleton().logMessage( - "TerrainTests: FAIL - road side prefab not preserved"); + "TerrainTests: FAIL - road prefab slot not preserved"); return false; } + /* Legacy migration: an old sidePrefabs array maps onto the fixed + * slots; absent sidewalk config keys yield disabled defaults. */ + { + nlohmann::json legacyJson = terrainJson; + auto &ej = legacyJson["roadEdges"][0]; + ej.erase("prefabLeft"); + ej.erase("prefabRight"); + ej.erase("prefabMid"); + nlohmann::json spj; + spj["prefabPath"] = "old_lamp.json"; + spj["edgeT"] = 0.3f; + spj["sideOffset"] = 5.0f; + spj["leftSide"] = false; + ej["sidePrefabs"] = nlohmann::json::array(); + ej["sidePrefabs"].push_back(spj); + auto &rcj = legacyJson["roadConfig"]; + rcj.erase("sidewalkEnabled"); + rcj.erase("sidewalkWidth"); + rcj.erase("sidewalkHeight"); + rcj.erase("sidewalkThickness"); + rcj.erase("sidewalkMeshTemplate"); + + flecs::entity e3 = w->entity(); + e3.set(TerrainComponent()); + serializer.deserializeTerrain(e3, legacyJson); + + const auto &rg3 = e3.get().roadGraph; + const RoadEdge &me = rg3.edges[0]; + /* Right side: half width = lanesAtoB(3) x laneWidth(4) + * = 12, so lateralOffset = sideOffset(5) - 12 = -7. */ + if (me.prefabRight.prefabPath != "old_lamp.json" || + std::abs(me.prefabRight.edgeT - 0.3f) > 0.001f || + std::abs(me.prefabRight.lateralOffset - (-7.0f)) > 0.001f || + !me.prefabLeft.prefabPath.empty() || + rg3.config.sidewalkEnabled) { + Ogre::LogManager::getSingleton().logMessage( + "TerrainTests: FAIL - legacy side prefab " + "migration wrong"); + return false; + } + } + std::string error; if (!tc2.roadGraph.validate(&error)) { Ogre::LogManager::getSingleton().logMessage( @@ -2522,6 +2779,189 @@ bool TerrainTestRunner::testRoadWedgeGeometry(EditorApp &app, sDn.min.x < -10.05f || sDn.max.x > 10.05f) return fail("-Z half is not a straight rectangle"); } + + /* + * Case 6: sidewalks (improvement plan §2.2). 6a: dead-end segment + * gets two bands outside both curbs, top at roadSurfaceY + + * sidewalkHeight. 6b: straight-through wedge strip sits beyond + * the curb and grows by template x copies. 6c: 90-degree inner + * corner tapers around the miter corner without self-intersecting. + * Disabled config emits nothing. + */ + { + /* 6a: segment bands, nodes (0,0,0)-(20,0,0); road band is + * z in [-3, 3], sidewalks occupy z in [3.002, 4.502] and + * [-4.502, -3.002] (width 1.5, gap 0.002). */ + RoadGraph rg; + rg.config.sidewalkEnabled = true; + rg.config.sidewalkWidth = 1.5f; + rg.config.sidewalkHeight = 0.15f; + rg.config.sidewalkThickness = 0.3f; + int a = rg.addNode(Ogre::Vector3(0, 0, 0)); + int b = rg.addNode(Ogre::Vector3(20, 0, 0)); + rg.addEdge(a, b); + + std::vector wedges; + std::vector segs; + enumerateWedges(rg, wedges, segs); + + const RoadStraightSegment *segA = nullptr; + for (const auto &s : segs) + if (s.nodeId == a) + segA = &s; + if (!segA) + return fail("no straight segment for node A (sidewalk)"); + + Procedural::TriangleBuffer buf; + if (!RoadGeometryLib::buildSegmentSidewalkGeometry(*segA, rg, + buf)) + return fail("buildSegmentSidewalkGeometry failed"); + + Scan s = scan(buf); + if (!s.ok) + return fail("sidewalk segment buffer invalid"); + if (s.min.z < -4.51f || s.min.z > -4.45f || + s.max.z < 4.45f || s.max.z > 4.51f) + return fail("sidewalk segment lateral extent wrong"); + if (s.max.y < 0.14f || s.max.y > 0.16f) + return fail("sidewalk top not at roadSurfaceY + height"); + if (s.min.y < -0.16f || s.min.y > -0.14f) + return fail("sidewalk bottom wrong (thickness?)"); + + /* Both bands present (inbound and outbound curb). */ + bool sawPlus = false, sawMinus = false; + for (const auto &v : buf.getVertices()) { + if (v.mPosition.z > 4.4f) + sawPlus = true; + if (v.mPosition.z < -4.4f) + sawMinus = true; + } + if (!sawPlus || !sawMinus) + return fail("dead-end segment missing a sidewalk band"); + + /* Disabled config: nothing is emitted. */ + { + RoadGraph rgOff; + int a0 = rgOff.addNode(Ogre::Vector3(0, 0, 0)); + int b0 = rgOff.addNode(Ogre::Vector3(20, 0, 0)); + rgOff.addEdge(a0, b0); + std::vector wOff; + std::vector sOff; + enumerateWedges(rgOff, wOff, sOff); + Procedural::TriangleBuffer bufOff; + if (RoadGeometryLib::buildSegmentSidewalkGeometry( + sOff[0], rgOff, bufOff)) + return fail("disabled sidewalks emitted " + "segment geometry"); + bool anyWedge = false; + for (const auto &w : wOff) + anyWedge |= + RoadGeometryLib::buildSidewalkGeometry( + w, rgOff, bufOff); + if (anyWedge || !bufOff.getVertices().empty()) + return fail("disabled sidewalks emitted " + "wedge geometry"); + } + + /* 6b: straight-through node; the +Z wedge's sidewalk strip + * runs at z in [3.002, 4.502], x in [-10, 10], with + * 24 template verts x (ceil(10) + ceil(10)) copies. */ + RoadGraph rg2; + rg2.config.sidewalkEnabled = true; + rg2.config.sidewalkWidth = 1.5f; + rg2.config.sidewalkHeight = 0.15f; + rg2.config.sidewalkThickness = 0.3f; + int nx = rg2.addNode(Ogre::Vector3(-20, 0, 0)); + int c2 = rg2.addNode(Ogre::Vector3(0, 0, 0)); + int px = rg2.addNode(Ogre::Vector3(20, 0, 0)); + rg2.addEdge(nx, c2); + rg2.addEdge(c2, px); + + std::vector wedges2; + std::vector segs2; + enumerateWedges(rg2, wedges2, segs2); + + const RoadWedge *wPlusZ = nullptr; + for (const auto &w : wedges2) { + if (w.nodeId != c2) + continue; + if (w.first.direction.x > 0.0f) + wPlusZ = &w; + } + if (!wPlusZ) + return fail("straight node wedge not found (sidewalk)"); + + Procedural::TriangleBuffer buf2; + if (!RoadGeometryLib::buildSidewalkGeometry(*wPlusZ, rg2, buf2)) + return fail("buildSidewalkGeometry failed (straight)"); + Scan s2 = scan(buf2); + if (!s2.ok) + return fail("straight sidewalk buffer invalid"); + if (s2.min.z < 2.95f || s2.min.z > 3.05f || + s2.max.z < 4.45f || s2.max.z > 4.51f || + s2.min.x < -10.05f || s2.max.x > 10.05f) + return fail("straight sidewalk extents wrong"); + if (s2.max.y < 0.14f || s2.max.y > 0.16f) + return fail("straight sidewalk top Y wrong"); + if (buf2.getVertices().size() != (size_t)(24 * 20)) + return fail("sidewalk strip vertex count wrong " + "(template x copies?)"); + + /* 6c: 90-degree inner corner (node at origin, neighbors at + * +X and +Z): the strip tapers around the miter corner + * (3,y,3), reaching ~(4.06,y,4.06) diagonally and 4.502 + * along the straight runs, without self-intersecting. */ + RoadGraph rg3; + rg3.config.sidewalkEnabled = true; + rg3.config.sidewalkWidth = 1.5f; + rg3.config.sidewalkHeight = 0.15f; + rg3.config.sidewalkThickness = 0.3f; + int c3 = rg3.addNode(Ogre::Vector3(0, 0, 0)); + int px3 = rg3.addNode(Ogre::Vector3(20, 0, 0)); + int pz3 = rg3.addNode(Ogre::Vector3(0, 0, 20)); + rg3.addEdge(c3, px3); + rg3.addEdge(c3, pz3); + + std::vector wedges3; + std::vector segs3; + enumerateWedges(rg3, wedges3, segs3); + + const RoadWedge *w90 = nullptr; + for (const auto &w : wedges3) + if (fabsf(w.sweptAngleDeg - 90.0f) < 0.1f) + w90 = &w; + if (!w90) + return fail("90 deg wedge not found (sidewalk)"); + + Procedural::TriangleBuffer buf3; + if (!RoadGeometryLib::buildSidewalkGeometry(*w90, rg3, buf3)) + return fail("buildSidewalkGeometry failed (90 deg)"); + Scan s3 = scan(buf3); + if (!s3.ok) + return fail("90 deg sidewalk buffer invalid"); + if (s3.min.x < 2.95f || s3.min.x > 3.05f || + s3.min.z < 2.95f || s3.min.z > 3.05f || + s3.max.x < 9.95f || s3.max.x > 10.05f || + s3.max.z < 9.95f || s3.max.z > 10.05f) + return fail("90 deg sidewalk extents wrong"); + if (s3.max.y < 0.14f || s3.max.y > 0.16f) + return fail("90 deg sidewalk top Y wrong"); + + bool sawDiag = false; + for (const auto &v : buf3.getVertices()) { + if (v.mPosition.x > 3.9f && v.mPosition.z > 3.9f) { + sawDiag = true; + break; + } + } + if (!sawDiag) + return fail("90 deg sidewalk missing diagonal " + "miter corner"); + int cop = 0, cro = 0; + countSlabOverlaps(buf3, cop, cro); + if (cop != 0 || cro != 0) + return fail("90 deg sidewalk self-intersects"); + } Ogre::LogManager::getSingleton().logMessage( "TerrainTests: road wedge geometry test passed"); return true; @@ -2857,6 +3297,7 @@ int TerrainTestRunner::run(EditorApp &app, int iterations) { "terrainCompliance", testTerrainCompliance }, { "roadColliderInteraction", testRoadColliderInteraction }, { "roadSidePrefabs", testRoadSidePrefabs }, + { "terrainPrefabSpawners", testTerrainPrefabSpawners }, }; @@ -3084,6 +3525,9 @@ bool TerrainTestRunner::testTerrainCompliance(EditorApp &app, int n1 = tc.roadGraph.addNode(Ogre::Vector3(100, 0, 100)); int n2 = tc.roadGraph.addNode(Ogre::Vector3(500, 0, 100)); tc.roadGraph.addEdge(n1, n2); + /* Wide lanes so the laneWidth * 2 compliance fade zone is + * resolvable by the coarse fixup chunk cells. */ + tc.roadGraph.config.laneWidth = 16.0f; } pumpFrames(app, ts, 5); @@ -3153,6 +3597,16 @@ bool TerrainTestRunner::testTerrainCompliance(EditorApp &app, /* Call compliance and verify fixup chunks exist. */ float roadThickness = e.get().roadGraph.config.roadThickness; float laneWidth = e.get().roadGraph.config.laneWidth; + + /* Capture natural (base + noise, no fixup) heights at the check + * points BEFORE compliance; the fresh heightmap is procedural, not + * flat, so fade-zone expectations blend toward these values. */ + const float baseUnder = ts->sampleBaseHeightAt(300, 100); + const float baseMidPos = ts->sampleBaseHeightAt(300, 132); + const float baseMidNeg = ts->sampleBaseHeightAt(300, 68); + const float baseEnd = ts->sampleBaseHeightAt(300, 148); + const float baseBeyond = ts->sampleBaseHeightAt(300, 164); + rs->complyTerrain(ts, roadThickness, laneWidth); size_t chunkCount = ts->getFixupChunkCount(); @@ -3164,32 +3618,68 @@ bool TerrainTestRunner::testTerrainCompliance(EditorApp &app, return false; } - /* Verify fixup is read back. */ + /* Verify fixups are read back after a page rebuild. */ ts->markPageDirty(0, 0); pumpFrames(app, ts, 5); - float roadY = 100.0f; /* node Y + roadLevelA = 0 + 0 = 0, but we - need the actual node position */ - /* The node is at (100,0,100) by default terrain height. Since we - * wrote fixups under the road surface, sampleHeightAt should - * return a value that differs from the base height at that - * location. */ - float atRoad = ts->sampleHeightAt(100, 100); - float farAway = ts->sampleHeightAt(2000, 2000); + /* Expected values: road levels are 0 and nodes sit at Y = 0, so the + * road top surface is at +roadThickness/2 = +0.15 and compliance + * writes the slab underside -0.15 under the road (an absolute + * fixup, independent of the natural height). With laneWidth = 16 + * the curb is 16 units from the centreline and the fade zone spans + * 32 units (laneWidth * 2), resolvable by the 2000/256 ~= 7.8-unit + * fixup cells; fade targets blend from -0.15 toward the natural + * height captured above. */ + const float underRoad = -0.15f; - /* The fixup under the road should differ from far-away base. */ - (void)atRoad; - if (std::fabs(farAway - 0.0f) > 0.5f) { - Ogre::LogManager::getSingleton().logMessage( - "TerrainTests: WARNING - base height at (2000,2000) " - "not near 0 as expected, got " + - Ogre::StringConverter::toString(farAway)); + auto expectHeight = [&](long wx, long wz, float expected, + float tolerance, const char *label) -> bool { + float h = ts->sampleHeightAt(wx, wz); + if (std::fabs(h - expected) > tolerance) { + Ogre::LogManager::getSingleton().logMessage( + "TerrainTests: FAIL - " + Ogre::String(label) + + " height at (" + Ogre::StringConverter::toString(wx) + + "," + Ogre::StringConverter::toString(wz) + + ") = " + Ogre::StringConverter::toString(h) + + ", expected " + + Ogre::StringConverter::toString(expected)); + return false; + } + return true; + }; + + /* Directly under the road centre: full compliance. */ + if (!expectHeight(300, 100, underRoad, 0.1f, "under-road") || + /* Mid-fade on both sides (curb at +/-16, fade 32): t = 0.5. + * Tolerance covers the coarse fixup-cell blending. */ + !expectHeight(300, 132, underRoad + (baseMidPos - underRoad) * 0.5f, + 0.5f, "mid-fade +Z") || + !expectHeight(300, 68, underRoad + (baseMidNeg - underRoad) * 0.5f, + 0.5f, "mid-fade -Z") || + /* Fade end: approximately the natural height. */ + !expectHeight(300, 148, baseEnd, 0.6f, "fade-end +Z") || + /* Beyond the fade zone: exactly the natural height (no fixup + * was written there). */ + !expectHeight(300, 164, baseBeyond, 0.01f, "beyond-fade +Z")) { + destroyTerrainEntity(app, e); + pumpFrames(app, ts, 1); + return false; } - /* Save/Load round-trip. */ + /* Save/Load round-trip: fixups persist through a full + * deactivate/reactivate cycle. */ ts->saveFixups(); + ts->deactivate(); + pumpFrames(app, ts, 5); - /* Clear all fixups and verify. */ + if (ts->isActive() && + !expectHeight(300, 100, underRoad, 0.08f, "reloaded under-road")) { + destroyTerrainEntity(app, e); + pumpFrames(app, ts, 1); + return false; + } + + /* Clear all fixups and verify the surface reverts to base. */ ts->clearAllFixups(); if (ts->getFixupChunkCount() != 0) { Ogre::LogManager::getSingleton().logMessage( @@ -3198,6 +3688,12 @@ bool TerrainTestRunner::testTerrainCompliance(EditorApp &app, pumpFrames(app, ts, 1); return false; } + if (!expectHeight(300, 100, baseUnder, 0.01f, + "cleared under-road")) { + destroyTerrainEntity(app, e); + pumpFrames(app, ts, 1); + return false; + } destroyTerrainEntity(app, e); pumpFrames(app, ts, 3); @@ -3220,8 +3716,11 @@ bool TerrainTestRunner::testRoadColliderInteraction(EditorApp &app, flecs::entity e = createTerrainEntity(app); { auto &tc = e.get_mut(); - int n1 = tc.roadGraph.addNode(Ogre::Vector3(100, 0, 100)); - int n2 = tc.roadGraph.addNode(Ogre::Vector3(500, 0, 100)); + /* Elevated road (node Y = 40 while the procedural base + * terrain stays within roughly +-22) so raycasts hit the + * road slab and not the terrain collider. */ + int n1 = tc.roadGraph.addNode(Ogre::Vector3(100, 40, 100)); + int n2 = tc.roadGraph.addNode(Ogre::Vector3(500, 40, 100)); tc.roadGraph.addEdge(n1, n2); } pumpFrames(app, ts, 5); @@ -3229,9 +3728,10 @@ bool TerrainTestRunner::testRoadColliderInteraction(EditorApp &app, RoadSystem *rs = ts->getRoadSystem(); ProceduralMeshSystem *pms = app.getProceduralMeshSystem(); Ogre::TerrainGroup *group = ts->getTerrainGroup(); - if (!rs || !pms || !group) { + JoltPhysicsWrapper *phys = ts->getPhysics(); + if (!rs || !pms || !group || !phys) { Ogre::LogManager::getSingleton().logMessage( - "TerrainTests: FAIL - missing road/mesh/group"); + "TerrainTests: FAIL - missing road/mesh/group/physics"); destroyTerrainEntity(app, e); pumpFrames(app, ts, 1); return false; @@ -3254,25 +3754,82 @@ bool TerrainTestRunner::testRoadColliderInteraction(EditorApp &app, JPH::BodyID bodyId = it->second.bodyId; - /* Raycast from above the road surface. The road surface Y - * should be around node Y. Cast from Y+50 downward. */ + /* Raycast from above: the road top (node Y 40 + slab half + * thickness 0.15 = 40.15) must be hit before the terrain far + * below. Cast slightly off the edge midpoint so the ray does + * not land exactly on the seam between the two half-edge + * segments. */ { - Ogre::Ray ray(Ogre::Vector3(300, 60, 100), - Ogre::Vector3::NEGATIVE_UNIT_Y); - float outT = 0.0f; - Ogre::Vector3 outNormal; - if (!ts->raycastTerrain(ray, outT, outNormal)) { - /* raycastTerrain may not hit road bodies; skip - * if no hit registered. */ + Ogre::Vector3 hitPos; + JPH::BodyID hitId; + if (!phys->raycastQuery(Ogre::Vector3(280, 60, 100), + Ogre::Vector3(280, -60, 100), + hitPos, hitId)) { Ogre::LogManager::getSingleton().logMessage( - "TerrainTests: raycastTerrain missed road " - "(may be expected if raycast only queries " - "terrain bodies)"); + "TerrainTests: FAIL - downward raycast missed " + "the road"); + destroyTerrainEntity(app, e); + pumpFrames(app, ts, 1); + return false; + } + if (hitId != bodyId) { + Ogre::LogManager::getSingleton().logMessage( + "TerrainTests: FAIL - downward raycast hit a " + "body that is not the road collider"); + destroyTerrainEntity(app, e); + pumpFrames(app, ts, 1); + return false; + } + if (std::fabs(hitPos.y - 40.15f) > 0.05f) { + Ogre::LogManager::getSingleton().logMessage( + "TerrainTests: FAIL - road top hit at Y " + + Ogre::StringConverter::toString(hitPos.y) + + ", expected ~40.15"); + destroyTerrainEntity(app, e); + pumpFrames(app, ts, 1); + return false; } } - /* Rebuild: bump graph version, verify old body removed and new - * one created. */ + /* Raycast from below: start just under the slab (Y 39, above the + * terrain surface ~+-22 so the ray does not cross the heightfield) + * and must hit the road slab underside at 40.15 - roadThickness + * (0.3) = 39.85 — proving the collider is a closed solid, not a + * hollow top sheet. */ + { + Ogre::Vector3 hitPos; + JPH::BodyID hitId; + if (!phys->raycastQuery(Ogre::Vector3(280, 39, 100), + Ogre::Vector3(280, 60, 100), + hitPos, hitId)) { + Ogre::LogManager::getSingleton().logMessage( + "TerrainTests: FAIL - upward raycast missed " + "the road underside"); + destroyTerrainEntity(app, e); + pumpFrames(app, ts, 1); + return false; + } + if (hitId != bodyId) { + Ogre::LogManager::getSingleton().logMessage( + "TerrainTests: FAIL - upward raycast hit a " + "body that is not the road collider"); + destroyTerrainEntity(app, e); + pumpFrames(app, ts, 1); + return false; + } + if (std::fabs(hitPos.y - 39.85f) > 0.05f) { + Ogre::LogManager::getSingleton().logMessage( + "TerrainTests: FAIL - road underside hit at Y " + + Ogre::StringConverter::toString(hitPos.y) + + ", expected ~39.85"); + destroyTerrainEntity(app, e); + pumpFrames(app, ts, 1); + return false; + } + } + + /* Rebuild: bump graph version, verify the old body is removed + * from the physics world and a new body is created. */ { auto &tc = e.get_mut(); int n3 = tc.roadGraph.addNode( @@ -3295,6 +3852,26 @@ bool TerrainTestRunner::testRoadColliderInteraction(EditorApp &app, pumpFrames(app, ts, 1); return false; } + if (it2->second.bodyId == bodyId) { + Ogre::LogManager::getSingleton().logMessage( + "TerrainTests: FAIL - road collider body not replaced " + "on rebuild"); + destroyTerrainEntity(app, e); + pumpFrames(app, ts, 1); + return false; + } + { + JPH::PhysicsSystem *jphSys = phys->getPhysicsSystem(); + if (jphSys && + jphSys->GetBodyInterfaceNoLock().IsAdded(bodyId)) { + Ogre::LogManager::getSingleton().logMessage( + "TerrainTests: FAIL - old road collider body " + "still in the physics world after rebuild"); + destroyTerrainEntity(app, e); + pumpFrames(app, ts, 1); + return false; + } + } /* Teardown: deactivating terrain removes road colliders. */ destroyTerrainEntity(app, e); @@ -3322,20 +3899,25 @@ bool TerrainTestRunner::testRoadSidePrefabs(EditorApp &app, ts->deactivate(); flecs::entity e = createTerrainEntity(app); + int n1 = 0, n2 = 0; { auto &tc = e.get_mut(); - int n1 = tc.roadGraph.addNode(Ogre::Vector3(100, 0, 100)); - int n2 = tc.roadGraph.addNode(Ogre::Vector3(500, 0, 100)); + n1 = tc.roadGraph.addNode(Ogre::Vector3(100, 0, 100)); + n2 = tc.roadGraph.addNode(Ogre::Vector3(500, 0, 100)); int ei = tc.roadGraph.addEdge(n1, n2); - /* Add a side prefab to the edge. */ + /* Left curb prefab with zero lateral offset: sits exactly at + * the curb, 3 units (1 lane x 3 m default lane width) to the + * left of the A->B (+X) direction, i.e. -Z. */ std::string prefabPath = "tests/prefabs/tiny_cube.prefab"; - RoadEdge::RoadSidePrefab sp; - sp.prefabPath = prefabPath; - sp.edgeT = 0.5f; - sp.sideOffset = 3.0f; - sp.leftSide = true; - tc.roadGraph.edges[(size_t)ei].sidePrefabs.push_back(sp); + tc.roadGraph.edges[(size_t)ei].prefabLeft.prefabPath = prefabPath; + tc.roadGraph.edges[(size_t)ei].prefabLeft.edgeT = 0.5f; + tc.roadGraph.edges[(size_t)ei].prefabLeft.lateralOffset = 0.0f; + + /* Camera-independent spawning: distances so large the test + * camera is always in range. */ + tc.roadGraph.config.prefabSpawnDistance = 1e6f; + tc.roadGraph.config.prefabDespawnDistance = 1e6f; } pumpFrames(app, ts, 5); @@ -3349,88 +3931,416 @@ bool TerrainTestRunner::testRoadSidePrefabs(EditorApp &app, return false; } - /* Wait for mesh + prefab spawning. */ + /* Wait for page loading + prefab spawning. */ pms->update(); pumpFrames(app, ts, 5); - /* Find the page containing the road and verify prefabs. */ - Ogre::TerrainGroup *group = ts->getTerrainGroup(); - if (!group) { + auto fail = [&](const Ogre::String &msg) { Ogre::LogManager::getSingleton().logMessage( - "TerrainTests: FAIL - no terrain group"); + "TerrainTests: FAIL - " + msg); + ts->setRoadEditMode(false); destroyTerrainEntity(app, e); pumpFrames(app, ts, 1); return false; + }; + + uint64_t key = RoadSystem::edgePrefabKey(n1, n2); + + /* The prefab must actually spawn: the fixture is staged next to + * the test binary by CMake. */ + const auto &prefabs = rs->getEdgePrefabs(); + auto it = prefabs.find(key); + if (it == prefabs.end() || !it->second.left.is_alive()) + return fail("edge prefab record missing or prefab not alive"); + + flecs::entity spawned = it->second.left; + if (!spawned.has()) + return fail("spawned prefab missing TransformComponent"); + + /* Expected world position: edge midpoint (300, 0, 100) offset 3 + * units to the left of the A->B (+X) direction, i.e. -Z. Y follows + * the interpolated road surface height (node Y + road level = 0 + * here), not the raw terrain height. */ + { + const Ogre::Vector3 &p = + spawned.get().position; + if (std::fabs(p.x - 300.0f) > 0.5f || + std::fabs(p.z - 97.0f) > 0.5f || + std::fabs(p.y) > 0.5f) + return fail("spawned prefab at " + + Ogre::StringConverter::toString(p) + + ", expected ~(300, 0, 97)"); } - uint64_t key = group->packIndex(0, 0); - const auto &pages = rs->getPageGeometry(); - auto it = pages.find(key); - if (it == pages.end()) { - Ogre::LogManager::getSingleton().logMessage( - "TerrainTests: FAIL - no road page geometry"); - destroyTerrainEntity(app, e); - pumpFrames(app, ts, 1); - return false; + /* Yaw: the prefab's -Z forward must align with the edge direction + * (+X here). */ + { + const Ogre::Quaternion &q = + spawned.get().rotation; + Ogre::Vector3 fwd = q * Ogre::Vector3(0, 0, -1); + if (std::fabs(fwd.x - 1.0f) > 0.01f || + std::fabs(fwd.y) > 0.01f || std::fabs(fwd.z) > 0.01f) + return fail("prefab forward " + + Ogre::StringConverter::toString(fwd) + + ", expected ~(1, 0, 0)"); } - /* Prefabs may fail to spawn if the file path doesn't resolve, - * but the test infrastructure should still verify the tracking - * logic. */ - bool hasPrefabs = !it->second.spawnedPrefabs.empty(); - if (!hasPrefabs) { - Ogre::LogManager::getSingleton().logMessage( - "TerrainTests: WARNING - side prefab not spawned " - "(prefab file may be inaccessible in headless " - "mode); checking tracking logic only"); - } - - /* Verify spawned entities are alive and have TransformComponent. */ - for (const auto &prefab : it->second.spawnedPrefabs) { - if (!prefab.is_alive()) { - Ogre::LogManager::getSingleton().logMessage( - "TerrainTests: FAIL - spawned prefab not alive"); - destroyTerrainEntity(app, e); - pumpFrames(app, ts, 1); - return false; - } - if (!prefab.has()) { - Ogre::LogManager::getSingleton().logMessage( - "TerrainTests: FAIL - spawned prefab " - "missing TransformComponent"); - destroyTerrainEntity(app, e); - pumpFrames(app, ts, 1); - return false; - } - } - - /* Bump graph version: old prefabs destroyed, new spawned. */ + /* An unrelated graph change (new node + edge elsewhere) must NOT + * despawn the prefab: spawn state is per edge, not per page. */ + int n3 = 0; { auto &tc = e.get_mut(); - int n3 = tc.roadGraph.addNode( - Ogre::Vector3(800, 0, 100)); - tc.roadGraph.addEdge(n3, - (int)tc.roadGraph.nodes[1].id); + n3 = tc.roadGraph.addNode(Ogre::Vector3(800, 0, 100)); + tc.roadGraph.addEdge(n3, n2); } + (void)n3; pms->update(); pumpFrames(app, ts, 5); - /* Old prefab entities should be gone. */ - for (const auto &prefab : it->second.spawnedPrefabs) { - if (prefab.is_alive()) { - Ogre::LogManager::getSingleton().logMessage( - "TerrainTests: WARNING - old prefab " - "still alive after graph change " - "(may be OK if page wasn't rebuilt)"); - } + if (!spawned.is_alive()) + return fail("prefab despawned by unrelated graph change"); + + /* Editing the slot (edgeT 0.5 -> 0.25) respawns at the new anchor + * and destroys the old instance. */ + { + auto &tc = e.get_mut(); + tc.roadGraph.edges[0].prefabLeft.edgeT = 0.25f; + } + pumpFrames(app, ts, 3); + + if (spawned.is_alive()) + return fail("old prefab survived slot data change"); + { + const auto &rec = rs->getEdgePrefabs().at(key); + if (!rec.left.is_alive()) + return fail("prefab not respawned after slot change"); + const Ogre::Vector3 &p = + rec.left.get().position; + if (std::fabs(p.x - 200.0f) > 0.5f || + std::fabs(p.z - 97.0f) > 0.5f) + return fail("respawned prefab at " + + Ogre::StringConverter::toString(p) + + ", expected ~(200, y, 97)"); } - /* Teardown. */ + /* Distance gating: tiny spawn/despawn distances put the camera + * permanently out of range; the instance despawns and its scene + * node is removed (no leak). */ + unsigned short childrenSpawned = app.getSceneManager() + ->getRootSceneNode() + ->numChildren(); + { + auto &tc = e.get_mut(); + tc.roadGraph.config.prefabSpawnDistance = 1.0f; + tc.roadGraph.config.prefabDespawnDistance = 1.0f; + } + pumpFrames(app, ts, 3); + + { + const auto &rec = rs->getEdgePrefabs().at(key); + if (rec.spawned || rec.left.is_alive()) + return fail("prefab not despawned by distance"); + } + if (app.getSceneManager()->getRootSceneNode()->numChildren() != + childrenSpawned - 1) + return fail("root scene node child count leak after despawn"); + + /* Edit-mode preview: still out of range, but road edit mode with + * the edge selected force-spawns the prefab. */ + ts->setRoadEditMode(true); + rs->setSelectedEdgeIndex(0); + pumpFrames(app, ts, 3); + { + const auto &rec = rs->getEdgePrefabs().at(key); + if (!rec.left.is_alive()) + return fail("edit-mode preview did not spawn prefab"); + } + + /* Leaving edit mode drops the preview again. */ + ts->setRoadEditMode(false); + pumpFrames(app, ts, 3); + flecs::entity preview; + { + const auto &rec = rs->getEdgePrefabs().at(key); + preview = rec.left; + if (rec.spawned || rec.left.is_alive()) + return fail("preview prefab survived edit mode exit"); + } + + /* Back in range: respawn once more for the teardown check. */ + { + auto &tc = e.get_mut(); + tc.roadGraph.config.prefabSpawnDistance = 1e6f; + tc.roadGraph.config.prefabDespawnDistance = 1e6f; + } + pumpFrames(app, ts, 3); + { + const auto &rec = rs->getEdgePrefabs().at(key); + if (!rec.left.is_alive()) + return fail("prefab did not respawn in range"); + preview = rec.left; + } + + /* Teardown: destroying the terrain despawns edge prefabs. */ destroyTerrainEntity(app, e); pumpFrames(app, ts, 3); + if (preview.is_alive()) { + Ogre::LogManager::getSingleton().logMessage( + "TerrainTests: FAIL - side prefab survived terrain " + "teardown"); + return false; + } + Ogre::LogManager::getSingleton().logMessage( "TerrainTests: roadside prefab test passed"); return true; } + +/* ------------------------------------------------------------------ */ +/* testTerrainPrefabSpawners (M6) */ +/* ------------------------------------------------------------------ */ + +bool TerrainTestRunner::testTerrainPrefabSpawners(EditorApp &app, + TerrainSystem *ts) +{ + if (ts->isActive()) + ts->deactivate(); + + flecs::entity terrain = createTerrainEntity(app); + pumpFrames(app, ts, 5); + + TerrainPrefabSpawnerSystem *pss = app.getTerrainPrefabSpawnerSystem(); + if (!pss) { + Ogre::LogManager::getSingleton().logMessage( + "TerrainTests: FAIL - no terrain prefab spawner system"); + destroyTerrainEntity(app, terrain); + pumpFrames(app, ts, 1); + return false; + } + + flecs::world *w = app.getWorld(); + + /* Create a spawner entity at (100, 500, 100); the Y must be + * snapped down to the terrain surface on spawn. Huge spawn + * distance so the (headless) camera position does not matter. */ + flecs::entity sp = w->entity(); + { + auto nc = EntityNameComponent(); + nc.name = "TestPrefabSpawner"; + sp.set(nc); + + TransformComponent xform; + xform.node = app.getSceneManager() + ->getRootSceneNode() + ->createChildSceneNode(); + xform.position = Ogre::Vector3(100, 500, 100); + xform.applyToNode(); + sp.set(xform); + + TerrainPrefabSpawnerComponent spawner; + spawner.prefabPath = "tests/prefabs/tiny_cube.prefab"; + spawner.spawnDistanceSq = 1e12f; + spawner.despawnDistanceSq = 1e12f; + sp.set(spawner); + } + + pss->update(); + + flecs::entity spawned = pss->getSpawnedEntity(sp); + if (!spawned.is_alive()) { + Ogre::LogManager::getSingleton().logMessage( + "TerrainTests: FAIL - prefab spawner did not spawn"); + destroyTerrainEntity(app, terrain); + pumpFrames(app, ts, 1); + return false; + } + + /* The instance must have a transform, must not be editor-visible + * content, and must sit at the spawner's (x, z). */ + if (!spawned.has()) { + Ogre::LogManager::getSingleton().logMessage( + "TerrainTests: FAIL - spawned prefab " + "missing TransformComponent"); + destroyTerrainEntity(app, terrain); + pumpFrames(app, ts, 1); + return false; + } + if (spawned.has()) { + Ogre::LogManager::getSingleton().logMessage( + "TerrainTests: FAIL - spawned prefab kept " + "EditorMarkerComponent"); + destroyTerrainEntity(app, terrain); + pumpFrames(app, ts, 1); + return false; + } + { + const Ogre::Vector3 &p = + spawned.get().position; + if (std::fabs(p.x - 100.0f) > 0.5f || + std::fabs(p.z - 100.0f) > 0.5f) { + Ogre::LogManager::getSingleton().logMessage( + "TerrainTests: FAIL - spawned prefab at " + + Ogre::StringConverter::toString(p) + + ", expected x/z ~(100, 100)"); + destroyTerrainEntity(app, terrain); + pumpFrames(app, ts, 1); + return false; + } + } + + /* The spawner's Y must have been snapped to the terrain surface + * (section 6.4). */ + float groundY = ts->getHeightAt(Ogre::Vector3(100, 0, 100)); + float spawnerY = sp.get().position.y; + if (std::fabs(spawnerY - groundY) > 0.5f) { + Ogre::LogManager::getSingleton().logMessage( + "TerrainTests: FAIL - spawner Y " + + Ogre::StringConverter::toString(spawnerY) + + " not snapped to terrain height " + + Ogre::StringConverter::toString(groundY)); + destroyTerrainEntity(app, terrain); + pumpFrames(app, ts, 1); + return false; + } + + /* Serialization round-trip (plain distances in JSON). */ + { + SceneSerializer serializer(*w, app.getSceneManager()); + nlohmann::json json = serializer.serializeTerrainPrefabSpawner(sp); + if (json.value("prefabPath", std::string()) != + "tests/prefabs/tiny_cube.prefab" || + std::fabs(json.value("spawnDistance", 0.0f) - 1e6f) > + 1.0f || + std::fabs(json.value("despawnDistance", 0.0f) - 1e6f) > + 1.0f) { + Ogre::LogManager::getSingleton().logMessage( + "TerrainTests: FAIL - spawner serialization " + "round-trip mismatch"); + destroyTerrainEntity(app, terrain); + pumpFrames(app, ts, 1); + return false; + } + + flecs::entity sp2 = w->entity(); + serializer.deserializeTerrainPrefabSpawner(sp2, json); + const auto &back = sp2.get(); + bool ok = back.prefabPath == + "tests/prefabs/tiny_cube.prefab" && + std::fabs(back.spawnDistanceSq - 1e12f) < 1e6f && + std::fabs(back.despawnDistanceSq - 1e12f) < 1e6f; + sp2.destruct(); + if (!ok) { + Ogre::LogManager::getSingleton().logMessage( + "TerrainTests: FAIL - spawner deserialization " + "round-trip mismatch"); + destroyTerrainEntity(app, terrain); + pumpFrames(app, ts, 1); + return false; + } + } + + /* Terrain compliance (section 6.5): bump the terrain +30 under the + * prefab, then flatten it back to the spawner's snapped height. */ + float h0 = ts->sampleHeightAt(100, 100); + ts->writeFixup(100, 100, h0 + 30.0f); + if (std::fabs(ts->sampleHeightAt(100, 100) - (h0 + 30.0f)) > 0.6f) { + Ogre::LogManager::getSingleton().logMessage( + "TerrainTests: FAIL - fixup bump not readable before " + "compliance test"); + destroyTerrainEntity(app, terrain); + pumpFrames(app, ts, 1); + return false; + } + + if (!pss->complyTerrainToPrefab(sp)) { + Ogre::LogManager::getSingleton().logMessage( + "TerrainTests: FAIL - complyTerrainToPrefab returned " + "false"); + destroyTerrainEntity(app, terrain); + pumpFrames(app, ts, 1); + return false; + } + + float flat = ts->sampleHeightAt(100, 100); + if (std::fabs(flat - spawnerY) > 0.6f) { + Ogre::LogManager::getSingleton().logMessage( + "TerrainTests: FAIL - terrain not flattened: height " + + Ogre::StringConverter::toString(flat) + + ", expected ~" + + Ogre::StringConverter::toString(spawnerY)); + destroyTerrainEntity(app, terrain); + pumpFrames(app, ts, 1); + return false; + } + if (ts->getFixupChunkCount() < 1) { + Ogre::LogManager::getSingleton().logMessage( + "TerrainTests: FAIL - no fixup chunks after compliance"); + destroyTerrainEntity(app, terrain); + pumpFrames(app, ts, 1); + return false; + } + ts->clearAllFixups(); + + /* Despawn on distance: shrink both radii so the camera is always + * outside. */ + { + auto &spawner = sp.get_mut(); + spawner.spawnDistanceSq = 1.0f; + spawner.despawnDistanceSq = 1.0f; + } + pss->update(); + + if (pss->getSpawnedEntity(sp).is_alive() || spawned.is_alive()) { + Ogre::LogManager::getSingleton().logMessage( + "TerrainTests: FAIL - prefab not despawned beyond " + "despawn distance"); + destroyTerrainEntity(app, terrain); + pumpFrames(app, ts, 1); + return false; + } + + /* Respawn, then destroy the spawner entity: the OnRemove observer + * must take the instance down with it. */ + { + auto &spawner = sp.get_mut(); + spawner.spawnDistanceSq = 1e12f; + spawner.despawnDistanceSq = 1e12f; + } + pss->update(); + + flecs::entity respawned = pss->getSpawnedEntity(sp); + if (!respawned.is_alive()) { + Ogre::LogManager::getSingleton().logMessage( + "TerrainTests: FAIL - prefab did not respawn"); + destroyTerrainEntity(app, terrain); + pumpFrames(app, ts, 1); + return false; + } + + { + auto &xform = sp.get_mut(); + if (xform.node) { + app.getSceneManager()->destroySceneNode(xform.node); + xform.node = nullptr; + } + } + sp.destruct(); + + if (respawned.is_alive()) { + Ogre::LogManager::getSingleton().logMessage( + "TerrainTests: FAIL - spawned prefab survived spawner " + "destruction"); + destroyTerrainEntity(app, terrain); + pumpFrames(app, ts, 1); + return false; + } + + destroyTerrainEntity(app, terrain); + pumpFrames(app, ts, 3); + + Ogre::LogManager::getSingleton().logMessage( + "TerrainTests: terrain prefab spawner test passed"); + return true; +} diff --git a/src/features/editScene/systems/TerrainTests.hpp b/src/features/editScene/systems/TerrainTests.hpp index e2f8ca8..475e695 100644 --- a/src/features/editScene/systems/TerrainTests.hpp +++ b/src/features/editScene/systems/TerrainTests.hpp @@ -82,6 +82,7 @@ private: static bool testTerrainCompliance(EditorApp &app, TerrainSystem *ts); static bool testRoadColliderInteraction(EditorApp &app, TerrainSystem *ts); static bool testRoadSidePrefabs(EditorApp &app, TerrainSystem *ts); +static bool testTerrainPrefabSpawners(EditorApp &app, TerrainSystem *ts); static bool testMemoryStability(EditorApp &app); static void logResult(const TerrainTestResult &r); diff --git a/src/features/editScene/tests/component_lua_test.cpp b/src/features/editScene/tests/component_lua_test.cpp index 3be7e01..f4fbe6d 100644 --- a/src/features/editScene/tests/component_lua_test.cpp +++ b/src/features/editScene/tests/component_lua_test.cpp @@ -1410,9 +1410,38 @@ static int testCharacterSpawnerComponent(lua_State *L) } // --------------------------------------------------------------------------- -// Test 47: AnimationTree component +// TerrainPrefabSpawner component // --------------------------------------------------------------------------- +static int testTerrainPrefabSpawnerComponent(lua_State *L) +{ + TEST("TerrainPrefabSpawner component"); + + bool ok = runLua(L, + "local id = ecs.create_entity();" + "ecs.set_component(id, 'TerrainPrefabSpawner', {" + " prefabPath = 'prefabs/test_cube.json'," + " spawnDistanceSq = 10000," + " despawnDistanceSq = 40000" + "});" + "local c = ecs.get_component(id, 'TerrainPrefabSpawner');" + "assert(c ~= nil, 'TerrainPrefabSpawner should exist');" + "assert(c.prefabPath == 'prefabs/test_cube.json', " + "'wrong prefabPath');" + "assert(c.spawnDistanceSq == 10000, " + "'wrong spawnDistanceSq');" + "assert(c.despawnDistanceSq == 40000, " + "'wrong despawnDistanceSq')"); + if (!ok) + FAIL("TerrainPrefabSpawner component assertion failed"); + + PASS(); + return 0; +} + +// --------------------------------------------------------------------------- +// Test 47: AnimationTree component +// --------------------------------------------------------------------------- static int testAnimationTreeComponent(lua_State *L) { TEST("AnimationTree component"); @@ -1905,6 +1934,7 @@ int main() failures += testPrimitiveComponent(L); failures += testTriangleBufferComponent(L); failures += testCharacterSpawnerComponent(L); + failures += testTerrainPrefabSpawnerComponent(L); failures += testAnimationTreeComponent(L); failures += testAnimationTreeRegistryApi(L); failures += testBehaviorTreeComponent(L); diff --git a/src/features/editScene/tests/prefabs/tiny_cube.prefab b/src/features/editScene/tests/prefabs/tiny_cube.prefab index c98fb43..d05b033 100644 --- a/src/features/editScene/tests/prefabs/tiny_cube.prefab +++ b/src/features/editScene/tests/prefabs/tiny_cube.prefab @@ -1,13 +1,12 @@ { - "name": "TinyCube", - "transform": { - "position": { "x": 0, "y": 0, "z": 0 }, - "rotation": { "w": 1, "x": 0, "y": 0, "z": 0 }, - "scale": { "x": 1, "y": 1, "z": 1 } - }, - "primitive": { - "type": "cube", - "size": { "x": 1, "y": 1, "z": 1 }, - "material": "RoadMaterial" - } + "name": "TestCube", + "transform": { + "position": [0, 0.5, 0], + "rotation": [1, 0, 0, 0], + "scale": [1, 1, 1] + }, + "renderable": { + "meshName": "Cube.mesh", + "visible": true + } } diff --git a/src/features/editScene/tests/road_geometry_overlap_test.cpp b/src/features/editScene/tests/road_geometry_overlap_test.cpp index bd75d20..a366046 100644 --- a/src/features/editScene/tests/road_geometry_overlap_test.cpp +++ b/src/features/editScene/tests/road_geometry_overlap_test.cpp @@ -289,6 +289,13 @@ static bool runConfig(const Vector3 &A, const Vector3 &B, const Vector3 &C) graph.config.laneWidth = 3.0f; graph.config.lanesPerDirection = 1; graph.config.roadThickness = 0.3f; + /* Sidewalks enabled (improvement plan §2.5): the sidewalk strips + * extend along the same no-fold curb rays, so the coplanar/piercing + * checks below must stay green with them. */ + graph.config.sidewalkEnabled = true; + graph.config.sidewalkWidth = 1.5f; + graph.config.sidewalkHeight = 0.15f; + graph.config.sidewalkThickness = 0.3f; int idA = graph.addNode(A, 0.0f); int idB = graph.addNode(B, 0.0f); int idC = graph.addNode(C, 0.0f); @@ -342,6 +349,22 @@ static bool runConfig(const Vector3 &A, const Vector3 &B, const Vector3 &C) ok &= checkPiece(built, "larger wedge", buf, nullptr); } + /* Sidewalk strips (improvement plan §2.2): same no-fold rays, + * elevated boxes — the overlap/piercing checks must stay green. */ + { + Procedural::TriangleBuffer buf; + bool built = RoadGeometryLib::buildSidewalkGeometry(*wSmall, + graph, buf); + ok &= checkPiece(built, "smaller wedge sidewalk", buf, + nullptr); + } + { + Procedural::TriangleBuffer buf; + bool built = RoadGeometryLib::buildSidewalkGeometry(*wLarge, + graph, buf); + ok &= checkPiece(built, "larger wedge sidewalk", buf, nullptr); + } + for (const auto &s : segs) { Procedural::TriangleBuffer buf; bool built = RoadGeometryLib::buildSegmentGeometry(s, graph, @@ -349,6 +372,14 @@ static bool runConfig(const Vector3 &A, const Vector3 &B, const Vector3 &C) ok &= checkPiece(built, s.nodeId == idA ? "segment A" : "segment C", buf, nullptr); + + Procedural::TriangleBuffer swBuf; + bool swBuilt = RoadGeometryLib::buildSegmentSidewalkGeometry( + s, graph, swBuf); + ok &= checkPiece(swBuilt, + s.nodeId == idA ? "segment A sidewalk" : + "segment C sidewalk", + swBuf, nullptr); } return ok; } diff --git a/src/features/editScene/ui/TerrainEditor.hpp b/src/features/editScene/ui/TerrainEditor.hpp index 004a380..966030c 100644 --- a/src/features/editScene/ui/TerrainEditor.hpp +++ b/src/features/editScene/ui/TerrainEditor.hpp @@ -4,8 +4,12 @@ #include "ComponentEditor.hpp" #include "../components/Terrain.hpp" +#include "../components/TerrainPrefabSpawner.hpp" +#include "../components/EntityName.hpp" #include "../systems/TerrainSystem.hpp" #include "../systems/RoadSystem.hpp" +#include "../systems/TerrainPrefabSpawnerSystem.hpp" +#include "../systems/PrefabSystem.hpp" #include #include @@ -222,7 +226,6 @@ public: static char auxNameBuf[128] = {}; ImGui::InputText("New Aux Map Name", auxNameBuf, sizeof(auxNameBuf)); - ImGui::SameLine(); if (ImGui::SmallButton("Add Aux Map")) { std::string name(auxNameBuf); /* Sanitize: no path separators or empty names. */ @@ -290,27 +293,29 @@ public: ImGui::Separator(); + /* --- Prefab Spawners (M6) --- */ + renderPrefabSpawnerSection(entity); + + ImGui::Separator(); + /* --- Camera & file operations --- */ if (ts) { if (ImGui::Button("Snap Camera Above Terrain")) ts->snapCameraAboveTerrain(); - ImGui::SameLine(); + const std::string hmPath = ts->getHeightmapPath(tc); if (ImGui::Button("Save Heightmap")) ts->saveHeightmap(hmPath); - ImGui::SameLine(); if (ImGui::Button("Load Heightmap")) ts->loadHeightmap(hmPath); if (ImGui::Button("Save Blend Maps")) ts->saveSceneBlendMaps(tc); - ImGui::SameLine(); if (ImGui::Button("Load Blend Maps")) ts->loadSceneBlendMaps(tc); if (ImGui::Button("Save Aux Maps")) ts->saveSceneAuxMaps(tc); - ImGui::SameLine(); if (ImGui::Button("Load Aux Maps")) ts->loadSceneAuxMaps(tc); } @@ -392,7 +397,6 @@ public: /* Layers — editable with add/remove. Max 5 (SM2Profile limit). */ ImGui::Text("Layers: %zu / 5", tc.layers.size()); - ImGui::SameLine(); if (ImGui::SmallButton("Add Layer") && tc.layers.size() < 5) { /* If the component has no explicit layers yet, the terrain is * using the implicit default base. Insert that base layer first @@ -425,7 +429,6 @@ public: " diffuse=" + newLayer.diffuseTexture + " normal=" + newLayer.normalTexture); } - ImGui::SameLine(); if (ImGui::SmallButton("Remove Layer") && tc.layers.size() > 1) { tc.layers.pop_back(); @@ -494,6 +497,133 @@ public: } private: + /* Scan the prefabs directory once (or on Refresh) for the placement + * prefab picker. */ + static void scanPrefabFiles(std::vector &out) + { + out.clear(); + const std::string dir = PrefabSystem::getPrefabsDirectory(); + if (!std::filesystem::exists(dir)) + return; + for (const auto &entry : + std::filesystem::directory_iterator(dir)) { + if (!entry.is_regular_file()) + continue; + const auto ext = entry.path().extension(); + if (ext == ".json" || ext == ".prefab") + out.push_back(dir + "/" + + entry.path().filename().string()); + } + } + + /* Prefab spawn mode + spawn point list (M6, section 7.2). */ + static void renderPrefabSpawnerSection(flecs::entity entity) + { + TerrainPrefabSpawnerSystem *pss = + TerrainPrefabSpawnerSystem::getInstance(); + if (!pss) + return; + + ImGui::PushID("PrefabSpawners"); + ImGui::Text("Prefab Spawners"); + + bool spawnMode = pss->getSpawnEditMode(); + if (ImGui::Checkbox("Prefab Spawn Mode", &spawnMode)) + pss->setSpawnEditMode(spawnMode); + if (spawnMode) { + ImGui::TextColored(ImVec4(0.4f, 0.9f, 0.4f, 1), + "PREFAB SPAWN MODE ACTIVE"); + ImGui::SameLine(); + ImGui::TextDisabled("(ESC to exit)"); + ImGui::TextWrapped( + "Left-click the terrain in the 3D view to place a " + "spawn point with the selected prefab. Spawn points " + "are snapped to the terrain surface."); + } else { + ImGui::TextDisabled( + "Enable to place prefab spawn points by clicking " + "the terrain in the 3D view."); + } + + /* Placement prefab picker. */ + static std::vector s_prefabFiles; + static bool s_scanned = false; + if (!s_scanned) { + scanPrefabFiles(s_prefabFiles); + s_scanned = true; + } + const std::string &cur = pss->getPlacementPrefabPath(); + const std::string preview = cur.empty() ? "" : cur; + if (ImGui::BeginCombo("Placement Prefab", preview.c_str())) { + for (const auto &f : s_prefabFiles) { + const bool selected = (f == cur); + if (ImGui::Selectable(f.c_str(), selected)) + pss->setPlacementPrefabPath(f); + } + if (s_prefabFiles.empty()) + ImGui::TextDisabled("No prefabs found"); + ImGui::EndCombo(); + } + if (ImGui::SmallButton("Refresh")) { + scanPrefabFiles(s_prefabFiles); + } + + /* Existing spawn points in the scene. */ + flecs::world world = entity.world(); + std::vector spawners; + world.query().each( + [&](flecs::entity e, TerrainPrefabSpawnerComponent &) { + spawners.push_back(e); + }); + + ImGui::Text("Spawn points: %zu", spawners.size()); + for (flecs::entity e : spawners) { + ImGui::PushID((int)e.id()); + + std::string label = "Entity " + std::to_string(e.id()); + if (e.has()) + label = e.get().name; + + const bool spawned = + pss->getSpawnedEntity(e).is_alive(); + ImGui::Bullet(); + ImGui::Text("%s%s", label.c_str(), + spawned ? " (spawned)" : ""); + + ImGui::SameLine(); + if (ImGui::SmallButton("Flatten")) { + /* Terrain compliance tool (6.5). */ + pss->complyTerrainToPrefab(e); + } + if (ImGui::IsItemHovered()) + ImGui::SetTooltip( + "Flatten the terrain under this prefab's footprint"); + + ImGui::SameLine(); + if (ImGui::SmallButton("Remove")) { + /* Removing the component despawns the instance + * via the system's OnRemove observer. The (now + * empty) marker entity stays behind for the + * user to delete from the outliner. */ + e.remove(); + } + + ImGui::PopID(); + } + + if (!spawners.empty()) { + if (ImGui::Button("Flatten Terrain Under All Prefabs")) { + for (flecs::entity e : spawners) + pss->complyTerrainToPrefab(e); + } + if (ImGui::IsItemHovered()) + ImGui::SetTooltip( + "Flatten the terrain under every spawn point's prefab footprint"); + } + + ImGui::PopID(); + } + static void renderRoadSection(TerrainComponent &tc, TerrainSystem *ts) { RoadSystem *rs = ts ? ts->getRoadSystem() : nullptr; @@ -506,6 +636,11 @@ private: RoadGraph &rg = tc.roadGraph; + /* Last connect failure text, shared by the 3D Connect tool + * (via RoadSystem::takeConnectError) and the node-list Connect + * buttons; shown as a modal below the edge list. */ + static std::string s_connectError; + /* Road mesh template — combo with (Procedural Box) + * .mesh / .glb files from the Ogre resource system. */ ImGui::SeparatorText("Road Mesh"); @@ -569,37 +704,80 @@ private: cfgChanged |= ImGui::SliderFloat( "Visibility Distance", &rg.config.roadVisibilityDistance, 100.0f, 5000.0f); + cfgChanged |= ImGui::SliderFloat( + "Prefab Spawn Distance", + &rg.config.prefabSpawnDistance, 10.0f, 2000.0f); + cfgChanged |= ImGui::SliderFloat( + "Prefab Despawn Distance", + &rg.config.prefabDespawnDistance, 20.0f, 4000.0f); + + ImGui::SeparatorText("Sidewalks"); + cfgChanged |= ImGui::Checkbox( + "Sidewalks Enabled", &rg.config.sidewalkEnabled); + if (rg.config.sidewalkEnabled) { + cfgChanged |= ImGui::SliderFloat( + "Sidewalk Width", + &rg.config.sidewalkWidth, 0.5f, 5.0f); + cfgChanged |= ImGui::SliderFloat( + "Sidewalk Height", + &rg.config.sidewalkHeight, 0.05f, 1.0f, + "%.2f"); + cfgChanged |= ImGui::SliderFloat( + "Sidewalk Thickness", + &rg.config.sidewalkThickness, 0.05f, 1.0f, + "%.2f"); + scanMeshFiles(); + std::vector &swMeshes = + getMeshList(); + std::vector swItems; + swItems.reserve(swMeshes.size() + 1); + swItems.push_back("(Procedural Box)"); + int swIdx = 0; + for (size_t mi = 0; mi < swMeshes.size(); ++mi) { + if (swMeshes[mi] == + rg.config.sidewalkMeshTemplate) + swIdx = (int)mi + 1; + swItems.push_back(swMeshes[mi].c_str()); + } + if (ImGui::Combo("Sidewalk Template", &swIdx, + swItems.data(), + (int)swItems.size())) { + rg.config.sidewalkMeshTemplate = + (swIdx <= 0) ? + "" : + swMeshes[swIdx - 1]; + cfgChanged = true; + } + } if (cfgChanged) rg.bumpVersion(); ImGui::TreePop(); } /* Toolbar — visible only in road edit mode. */ if (ts->getRoadEditMode()) { - /* Tool selector: Move Node (default) / Add Node. */ - bool addNodeTool = (ts->getRoadEditTool() == - TerrainSystem::RoadEditTool::AddNode); - if (ImGui::RadioButton("Move Node", !addNodeTool)) + /* Tool selector: Move Node (default) / Add Node / + * Connect. */ + TerrainSystem::RoadEditTool tool = ts->getRoadEditTool(); + if (ImGui::RadioButton("Move Node", + tool == TerrainSystem::RoadEditTool::Move)) ts->setRoadEditTool( TerrainSystem::RoadEditTool::Move); ImGui::SameLine(); - if (ImGui::RadioButton("Add Node", addNodeTool)) + if (ImGui::RadioButton("Add Node", + tool == TerrainSystem::RoadEditTool::AddNode)) ts->setRoadEditTool( TerrainSystem::RoadEditTool::AddNode); ImGui::SameLine(); + if (ImGui::RadioButton("Connect", + tool == TerrainSystem::RoadEditTool::Connect)) + ts->setRoadEditTool( + TerrainSystem::RoadEditTool::Connect); if (ImGui::Button("Remove Selected Node") && rs->getSelectedNodeId() >= 0) { rg.removeNode(rs->getSelectedNodeId()); rs->setSelectedNodeId(-1); } - ImGui::SameLine(); - if (ImGui::Button("Split Selected Edge") && - rs->getSelectedEdgeIndex() >= 0) { - rs->setSelectedNodeId(rg.splitEdge( - (size_t)rs->getSelectedEdgeIndex(), 0.5f)); - rs->setSelectedEdgeIndex(-1); - } - ImGui::SameLine(); static std::string s_roadValidationError; if (ImGui::Button("Validate Road Graph")) { @@ -628,7 +806,6 @@ private: ImGui::CloseCurrentPopup(); ImGui::EndPopup(); } - ImGui::SameLine(); /* Terrain compliance: writes fixup chunks under road surfaces * so the terrain conforms to the road underside (M5.10). */ @@ -674,16 +851,23 @@ private: "select it; drag the gizmo's X/Z axes to move it. " "Its height follows the terrain plus the Vertical " "Offset from the node inspector.\n" - "3. To connect two nodes: select one node, then " - "press \"Connect\" next to the other node in the " - "Nodes list below.\n" + "3. To connect two nodes: select the \"Connect\" " + "tool and click one node, then the other (repeated " + "clicks chain a path). Alternatively select a node " + "and press \"Connect\" next to the other node in " + "the Nodes list below.\n" "4. Click an edge in the 3D view or in the Edges " - "list to select it. \"Split Selected Edge\" inserts " - "a node at the edge midpoint; lane counts and road " - "levels are edited in the Selected Edge inspector.\n" - "5. \"Remove Selected Node\" deletes the node and " - "all edges connected to it. Clicking empty terrain " - "in Move Node mode clears the selection.\n" + "list to select it. \"Split\" on an edge row " + "inserts a node at the edge midpoint; lane counts " + "and road levels are edited in the Selected Edge " + "inspector.\n" + "5. \"Smooth Node\" in the node inspector relaxes " + "a node with exactly two edges toward a " + "straighter path.\n" + "6. \"Remove Selected Node\" deletes the node and " + "all edges connected to it. Clicking empty " + "terrain in Move Node mode clears the selection. " + "ESC exits road edit mode.\n" "\n" "A click is a press+release without moving the " "mouse; moving the mouse while holding the button " @@ -716,14 +900,34 @@ private: } if (showConnect) { ImGui::SameLine(); - if (ImGui::SmallButton("Connect")) - rg.joinNodes(selectedNodeId, n.id); + if (ImGui::SmallButton("Connect")) { + std::string error; + std::vector created; + if (rg.connectNodes(selectedNodeId, n.id, + tc.worldSize, + &error, + &created) >= 0) { + /* Page-boundary split nodes + * start with an interpolated + * Y; re-snap to the terrain. */ + rs->snapNodesToTerrain(created); + /* Chain: the clicked node + * becomes the new source. */ + rs->setSelectedNodeId(n.id); + } else { + s_connectError = error; + ImGui::OpenPopup( + "Road Connect Failed"); + } + } } ImGui::PopID(); } /* Edge list. Same sizing rule as the node list: keep the - * selectable clear of the Remove button. */ + * selectable clear of the row buttons. Every row has a Split + * button (splitting does not require selecting the edge + * first); Remove only appears on the selected row. */ ImGui::Text("Edges: %zu", rg.edges.size()); for (size_t i = 0; i < rg.edges.size(); ++i) { const auto &e = rg.edges[i]; @@ -732,6 +936,9 @@ private: std::to_string(e.nodeA) + " <-> " + std::to_string(e.nodeB); float rowWidth = ImGui::GetContentRegionAvail().x; + rowWidth -= ImGui::CalcTextSize("Split").x + + ImGui::GetStyle().FramePadding.x * 2.0f + + ImGui::GetStyle().ItemSpacing.x; if (selected) rowWidth -= ImGui::CalcTextSize("Remove").x + ImGui::GetStyle().FramePadding.x * 2.0f + @@ -743,6 +950,21 @@ private: rs->setSelectedEdgeIndex((int)i); rs->setSelectedNodeId(-1); } + ImGui::SameLine(); + if (ImGui::SmallButton("Split")) { + int newId = rg.splitEdge(i, 0.5f); + /* The midpoint Y is interpolated between the + * endpoints; re-snap it to the terrain. */ + RoadNode *nn = rg.findNodeById(newId); + if (nn && ts) { + float y = ts->getHeightAt(Ogre::Vector3( + nn->position.x, 0.0f, + nn->position.z)); + nn->position.y = y + nn->verticalOffset; + } + rs->setSelectedNodeId(newId); + rs->setSelectedEdgeIndex(-1); + } if (selected) { ImGui::SameLine(); if (ImGui::SmallButton("Remove")) { @@ -753,6 +975,26 @@ private: ImGui::PopID(); } + /* Connect failures (from the 3D Connect tool via RoadSystem, + * or the node-list Connect buttons above) are shown as a + * modal. Placed after the lists so OpenPopup calls from the + * rows are picked up in the same frame. */ + { + std::string pendingConnectError; + if (rs->takeConnectError(pendingConnectError)) { + s_connectError = pendingConnectError; + ImGui::OpenPopup("Road Connect Failed"); + } + if (ImGui::BeginPopupModal("Road Connect Failed", + nullptr, + ImGuiWindowFlags_AlwaysAutoResize)) { + ImGui::TextWrapped("%s", s_connectError.c_str()); + if (ImGui::Button("OK", ImVec2(120, 0))) + ImGui::CloseCurrentPopup(); + ImGui::EndPopup(); + } + } + /* Selected node inspector. */ if (rs->getSelectedNodeId() >= 0) { RoadNode *node = rg.findNodeById(rs->getSelectedNodeId()); @@ -815,6 +1057,46 @@ private: node->position.y = y + node->verticalOffset; nodeChanged = true; } + /* Smoothing: relax the node toward the midpoint + * of its two neighbors (only for degree-2 nodes). */ + { + int degree = 0; + for (const auto &e : rg.edges) + if (e.nodeA == node->id || + e.nodeB == node->id) + ++degree; + if (degree == 2) { + static float s_smoothStrength = + 0.5f; + ImGui::SliderFloat( + "Smooth Strength", + &s_smoothStrength, 0.0f, + 1.0f, "%.2f"); + if (ImGui::Button("Smooth Node") && + rg.smoothNode( + node->id, + s_smoothStrength, + tc.worldSize) && ts) { + /* smoothNode leaves Y + * untouched; re-snap it at + * the new XZ. */ + float y = ts->getHeightAt( + Ogre::Vector3( + node->position.x, + 0.0f, + node->position.z)); + node->position.y = + y + node->verticalOffset; + } + if (ImGui::IsItemHovered()) + ImGui::SetTooltip( + "Relax this node toward the midpoint of its two neighbors."); + } else { + ImGui::TextDisabled( + "Smooth: needs exactly 2 " + "edges at this node"); + } + } if (nodeChanged) rg.bumpVersion(); } @@ -905,49 +1187,80 @@ private: edgeChanged |= ImGui::SliderFloat( "Road Level B", &e.roadLevelB, -5.0f, 5.0f); - /* Side prefabs placed along this edge. */ - ImGui::Text("Side Prefabs: %zu", - e.sidePrefabs.size()); - for (size_t i = 0; i < e.sidePrefabs.size(); ++i) { - auto &sp = e.sidePrefabs[i]; - ImGui::PushID((int)i); - char pbuf[256]; - strncpy(pbuf, sp.prefabPath.c_str(), - sizeof(pbuf) - 1); - pbuf[sizeof(pbuf) - 1] = 0; - if (ImGui::InputText("Prefab", pbuf, - sizeof(pbuf))) { - sp.prefabPath = pbuf; - edgeChanged = true; - } - edgeChanged |= ImGui::SliderFloat( - "Position T", &sp.edgeT, 0.0f, 1.0f); - edgeChanged |= ImGui::SliderFloat( - "Side Offset", &sp.sideOffset, 0.0f, - 50.0f); - edgeChanged |= ImGui::Checkbox("Left Side", - &sp.leftSide); - if (ImGui::SmallButton("Remove")) { - e.sidePrefabs.erase( - e.sidePrefabs.begin() + i); - edgeChanged = true; - ImGui::PopID(); - break; + /* Edge prefab slots (improvement plan §3): + * left/right curb anchors plus one centerline + * midpoint prefab. */ + auto slotEditor = [&](const char *label, + RoadEdgePrefabSlot &slot) { + ImGui::PushID(label); + if (ImGui::TreeNode(label)) { + static std::vector + s_prefabs; + static bool s_scanned = false; + if (!s_scanned) { + scanPrefabFiles( + s_prefabs); + s_scanned = true; + } + const std::string preview = + slot.prefabPath.empty() ? + "(None)" : + slot.prefabPath; + if (ImGui::BeginCombo( + "Prefab", + preview.c_str())) { + if (ImGui::Selectable( + "(None)", + slot.prefabPath + .empty())) { + slot.prefabPath + .clear(); + edgeChanged = + true; + } + for (const auto &f : + s_prefabs) { + if (ImGui::Selectable( + f.c_str(), + f == slot + .prefabPath)) { + slot.prefabPath = + f; + edgeChanged = + true; + } + } + if (s_prefabs.empty()) + ImGui::TextDisabled( + "No prefabs found"); + ImGui::EndCombo(); + } + if (ImGui::SmallButton("Refresh")) + scanPrefabFiles(s_prefabs); + if (!slot.prefabPath.empty()) { + edgeChanged |= ImGui::SliderFloat( + "Position T", + &slot.edgeT, + 0.0f, 1.0f); + edgeChanged |= ImGui::SliderFloat( + "Lateral Offset", + &slot.lateralOffset, + -50.0f, + 50.0f); + edgeChanged |= ImGui::SliderFloat( + "Y Offset", + &slot.yOffset, + -10.0f, + 10.0f); + } + ImGui::TreePop(); } ImGui::PopID(); - } - static char s_prefabBuf[256] = {}; - ImGui::InputText("New Prefab Path", s_prefabBuf, - sizeof(s_prefabBuf)); - ImGui::SameLine(); - if (ImGui::SmallButton("Add Prefab") && - s_prefabBuf[0] != '\0') { - RoadEdge::RoadSidePrefab sp; - sp.prefabPath = s_prefabBuf; - e.sidePrefabs.push_back(sp); - s_prefabBuf[0] = '\0'; - edgeChanged = true; - } + }; + ImGui::Text("Edge Prefabs:"); + slotEditor("Left Curb Prefab", e.prefabLeft); + slotEditor("Right Curb Prefab", e.prefabRight); + slotEditor("Midpoint Prefab", e.prefabMid); if (edgeChanged) rg.bumpVersion(); @@ -1128,7 +1441,6 @@ private: changed = true; } - ImGui::SameLine(); if (ImGui::Button("Browse")) { getTexturePickerTarget() = label; getTexturePickerOpen() = true; diff --git a/src/features/editScene/ui/TerrainPrefabSpawnerEditor.cpp b/src/features/editScene/ui/TerrainPrefabSpawnerEditor.cpp new file mode 100644 index 0000000..37fbb05 --- /dev/null +++ b/src/features/editScene/ui/TerrainPrefabSpawnerEditor.cpp @@ -0,0 +1,127 @@ +#include "TerrainPrefabSpawnerEditor.hpp" +#include "../systems/PrefabSystem.hpp" +#include "../systems/TerrainPrefabSpawnerSystem.hpp" +#include +#include +#include + +TerrainPrefabSpawnerEditor::TerrainPrefabSpawnerEditor( + Ogre::SceneManager *sceneMgr) + : m_sceneMgr(sceneMgr) +{ + (void)m_sceneMgr; +} + +void TerrainPrefabSpawnerEditor::refreshPrefabList() +{ + m_prefabFiles.clear(); + const std::string prefabDir = PrefabSystem::getPrefabsDirectory(); + if (std::filesystem::exists(prefabDir)) { + for (const auto &entry : + std::filesystem::directory_iterator(prefabDir)) { + if (!entry.is_regular_file()) + continue; + const auto ext = entry.path().extension(); + if (ext == ".json" || ext == ".prefab") + m_prefabFiles.push_back(prefabDir + "/" + + entry.path() + .filename() + .string()); + } + } + m_prefabListScanned = true; +} + +bool TerrainPrefabSpawnerEditor::renderComponent( + flecs::entity entity, TerrainPrefabSpawnerComponent &spawner) +{ + bool modified = false; + + if (ImGui::CollapsingHeader("Terrain Prefab Spawner", + ImGuiTreeNodeFlags_DefaultOpen)) { + ImGui::Indent(); + + if (!m_prefabListScanned) + refreshPrefabList(); + + /* Prefab picker. */ + std::string preview = spawner.prefabPath.empty() ? + "" : + spawner.prefabPath; + if (ImGui::BeginCombo("Prefab", preview.c_str())) { + for (const auto &file : m_prefabFiles) { + const bool selected = + (file == spawner.prefabPath); + if (ImGui::Selectable(file.c_str(), selected)) { + spawner.prefabPath = file; + modified = true; + } + } + if (m_prefabFiles.empty()) + ImGui::TextDisabled("No prefabs found"); + ImGui::EndCombo(); + } + if (ImGui::IsItemHovered()) + ImGui::SetTooltip("Prefab to spawn on the terrain"); + ImGui::SameLine(); + if (ImGui::SmallButton("Refresh")) + refreshPrefabList(); + + float spawnDist = std::sqrt(spawner.spawnDistanceSq); + if (ImGui::DragFloat("Spawn Distance", &spawnDist, 1.0f, 0.0f, + 100000.0f)) { + spawner.spawnDistanceSq = spawnDist * spawnDist; + modified = true; + } + + float despawnDist = std::sqrt(spawner.despawnDistanceSq); + if (ImGui::DragFloat("Despawn Distance", &despawnDist, 1.0f, + 0.0f, 100000.0f)) { + spawner.despawnDistanceSq = despawnDist * despawnDist; + modified = true; + } + + if (spawner.spawnDistanceSq > spawner.despawnDistanceSq) + ImGui::TextColored( + ImVec4(1.0f, 0.4f, 0.4f, 1.0f), + "Spawn distance is greater than despawn distance"); + + /* Spawn state + actions. */ + TerrainPrefabSpawnerSystem *pss = + TerrainPrefabSpawnerSystem::getInstance(); + if (pss) { + flecs::entity spawned = pss->getSpawnedEntity(entity); + if (spawned.is_alive()) + ImGui::Text("Spawned: instance %llu", + (unsigned long long)spawned.id()); + else + ImGui::TextDisabled("Not spawned"); + + if (ImGui::Button("Snap to Terrain")) { + if (!pss->snapToTerrain(entity)) + Ogre::LogManager::getSingleton() + .logMessage( + "TerrainPrefabSpawnerEditor: snap failed (no active terrain?)"); + } + if (ImGui::IsItemHovered()) + ImGui::SetTooltip( + "Set the spawner Y to the terrain surface at its (X, Z)"); + + ImGui::SameLine(); + + if (ImGui::Button("Flatten Terrain Under Prefab")) { + if (!pss->complyTerrainToPrefab(entity)) + Ogre::LogManager::getSingleton() + .logMessage( + "TerrainPrefabSpawnerEditor: comply failed (no active terrain?)"); + } + if (ImGui::IsItemHovered()) + ImGui::SetTooltip( + "Flatten the terrain under the spawned prefab's footprint (writes fixup chunks)"); + } + + ImGui::Unindent(); + } + + return modified; +} diff --git a/src/features/editScene/ui/TerrainPrefabSpawnerEditor.hpp b/src/features/editScene/ui/TerrainPrefabSpawnerEditor.hpp new file mode 100644 index 0000000..c35446c --- /dev/null +++ b/src/features/editScene/ui/TerrainPrefabSpawnerEditor.hpp @@ -0,0 +1,39 @@ +#ifndef EDITSCENE_TERRAINPREFABSPAWNEREDITOR_HPP +#define EDITSCENE_TERRAINPREFABSPAWNEREDITOR_HPP +#pragma once + +#include "ComponentEditor.hpp" +#include "../components/TerrainPrefabSpawner.hpp" +#include +#include +#include + +/** + * @brief Editor panel for TerrainPrefabSpawnerComponent (Milestone 6). + * + * Prefab picker (scans the prefabs directory), spawn/despawn distances, + * terrain snap and terrain compliance (flatten-under-footprint) actions. + */ +class TerrainPrefabSpawnerEditor + : public ComponentEditor { +public: + explicit TerrainPrefabSpawnerEditor(Ogre::SceneManager *sceneMgr); + + const char *getName() const override + { + return "Terrain Prefab Spawner"; + } + +protected: + bool renderComponent(flecs::entity entity, + TerrainPrefabSpawnerComponent &spawner) override; + +private: + void refreshPrefabList(); + + Ogre::SceneManager *m_sceneMgr; + std::vector m_prefabFiles; + bool m_prefabListScanned = false; +}; + +#endif // EDITSCENE_TERRAINPREFABSPAWNEREDITOR_HPP