Terrain improvements

This commit is contained in:
2026-08-21 00:46:49 +03:00
parent 5ea7a6bde8
commit 4562db481a
34 changed files with 5251 additions and 444 deletions
+63 -14
View File
@@ -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 ABC" 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`
+10
View File
@@ -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"
)
+37
View File
@@ -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<TerrainPrefabSpawnerSystem>(
m_world, m_sceneMgr, m_cameraSystem.get(),
m_uiSystem.get(), m_physicsSystem.get());
m_terrainPrefabSpawnerSystem->initialize();
// Setup AnimationTree system
m_animationTreeSystem = std::make_unique<AnimationTreeSystem>(
m_world, m_sceneMgr);
@@ -1456,6 +1469,9 @@ void EditorApp::setupECS()
// Register PrefabInstance component
m_world.component<PrefabInstanceComponent>();
// Register TerrainPrefabSpawner component
m_world.component<TerrainPrefabSpawnerComponent>();
// Register Item and Inventory components
m_world.component<ItemComponent>();
m_world.component<InventoryComponent>();
@@ -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)
+6
View File
@@ -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<ProceduralMeshSystem> m_proceduralMeshSystem;
std::unique_ptr<CharacterSlotSystem> m_characterSlotSystem;
std::unique_ptr<CharacterSpawnerSystem> m_characterSpawnerSystem;
std::unique_ptr<TerrainPrefabSpawnerSystem> m_terrainPrefabSpawnerSystem;
std::unique_ptr<AnimationTreeSystem> m_animationTreeSystem;
std::unique_ptr<HairPhysicsSystem> m_hairPhysicsSystem;
std::unique_ptr<BehaviorTreeSystem> m_behaviorTreeSystem;
@@ -689,3 +689,95 @@ Build and run:
cmake --build <build-dir> --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.
@@ -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.1M5.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 (AB → ACB).
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 (AB → ACB)
**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 AC and
CB. 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` (`:29863038`).
### 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 AC with `edgeT' = edgeT / actualT`;
otherwise it moves to CB 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`,
`:14601518`) 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
ABC 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_<A>_<B>_<edgeT>`; 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_<A>_<B>_<slot>`.
- **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 ABC" + 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 ABC (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
AC; 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 ABC"), 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.
@@ -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 ABC 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.1M5.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] W1W4, W6W8 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.13.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/<terrainId>/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.
+258 -58
View File
@@ -640,11 +640,18 @@ Use `EShapeSubType::User1` for the shape subtype.
- **Naming**: `x<chunkX>_z<chunkZ>.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/<terrainId>/terrain_fixup/` or
the "Clear all fixups" button).
- **Storage directory**: `heightmaps/<terrainId>/terrain_fixup/` — registered as
an Ogre resource location when the scene is loaded so the definer can load them
on demand. The `<terrainId>` 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.13.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<chunkX>_z<chunkZ>.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 30270° 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.1M5.12).
**Overall M5 status**: ✅ ALL 18 sub-items complete (M5.1M5.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.
+407 -44
View File
@@ -5,6 +5,7 @@
#include <Ogre.h>
#include <algorithm>
#include <cmath>
#include <functional>
#include <string>
#include <vector>
@@ -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<RoadSidePrefab> 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<RoadWedge> &outWedges,
std::vector<RoadStraightSegment> &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<int> &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<int> *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<int> chain;
std::vector<int> 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<float> 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<int> 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<int>());
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<float>
pageBoundaryCrossings(const Ogre::Vector3 &a, const Ogre::Vector3 &b,
float worldSize)
{
std::vector<float> 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<float> 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<int> 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<int> &nodeIds) const
{
std::vector<RoadWedge> wedges;
std::vector<RoadStraightSegment> 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)
@@ -0,0 +1,42 @@
#ifndef EDITSCENE_TERRAINPREFABSPAWNER_HPP
#define EDITSCENE_TERRAINPREFABSPAWNER_HPP
#pragma once
#include <flecs.h>
#include <string>
/**
* @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
@@ -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<TerrainPrefabSpawnerComponent>(
"Terrain Prefab Spawner", "Environment",
std::make_unique<TerrainPrefabSpawnerEditor>(sceneMgr),
/* Adder */
[](flecs::entity e) {
if (!e.has<TerrainPrefabSpawnerComponent>()) {
e.set<TerrainPrefabSpawnerComponent>(
TerrainPrefabSpawnerComponent{});
}
},
/* Remover */
[](flecs::entity e) {
if (e.has<TerrainPrefabSpawnerComponent>()) {
e.remove<TerrainPrefabSpawnerComponent>();
}
});
}
@@ -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",
@@ -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<int> &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
* ---------------------------------------------------------------- */
@@ -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
@@ -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<TerrainComponent>())
return;
auto &tc = terrain.get_mut<TerrainComponent>();
std::string error;
std::vector<int> 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);
@@ -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<EntityNameComponent> m_nameQuery;
@@ -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 <OgreLogManager.h>
#include <filesystem>
#include <vector>
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<flecs::entity> children;
entity.children(
[&children](flecs::entity child) { children.push_back(child); });
for (flecs::entity child : children)
destroyInstance(child, physics, uiSystem);
if (physics && entity.has<RigidBodyComponent>()) {
auto &rigidBody = entity.get_mut<RigidBodyComponent>();
if (rigidBody.bodyCreated && !rigidBody.bodyID.IsInvalid()) {
physics->removeBody(rigidBody.bodyID);
physics->destroyBody(rigidBody.bodyID);
rigidBody.bodyCreated = false;
rigidBody.bodyID = JPH::BodyID();
}
}
if (entity.has<TransformComponent>()) {
auto &transform = entity.get_mut<TransformComponent>();
if (transform.node) {
try {
m_sceneMgr->destroySceneNode(transform.node);
} catch (...) {
}
transform.node = nullptr;
}
}
if (entity.has<RenderableComponent>()) {
auto &renderable = entity.get_mut<RenderableComponent>();
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 {
@@ -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.
*/
+502 -51
View File
@@ -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 <OgreMaterialManager.h>
#include <algorithm>
#include <cmath>
#include <fstream>
#include <set>
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<int> &nodeIds)
{
if (!m_terrainSystem || nodeIds.empty())
return;
flecs::entity terrain = getTerrainEntity();
if (!terrain.is_alive() || !terrain.has<TerrainComponent>())
return;
RoadGraph &rg =
m_world.entity(m_terrainEntityId).get_mut<TerrainComponent>().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<EditorMarkerComponent>())
inst.remove<EditorMarkerComponent>();
/* Align the prefab's -Z forward with the edge direction. */
Ogre::Quaternion yaw =
Ogre::Vector3::NEGATIVE_UNIT_Z.getRotationTo(dir);
if (inst.has<TransformComponent>()) {
auto &tc = inst.get_mut<TransformComponent>();
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<TerrainComponent>().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<uint64_t> 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);
}
}
}
}
}
+116 -7
View File
@@ -54,9 +54,6 @@ struct RoadPageGeometry {
std::vector<Ogre::Vector3> collisionVertices;
std::vector<uint32_t> collisionIndices;
/** Roadside prefab instances spawned for this page (M5.11). */
std::vector<flecs::entity> spawnedPrefabs;
/** Wedges and straight segments seeded by nodes inside this page. */
std::vector<RoadWedge> wedges;
std::vector<RoadStraightSegment> 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<int> &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<uint64_t, EdgePrefabRecord> &
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<uint64_t, RoadPageGeometry> 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<uint64_t, EdgePrefabRecord> 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;
@@ -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<TerrainPrefabSpawnerComponent>()) {
json["terrainPrefabSpawner"] =
serializeTerrainPrefabSpawner(entity);
}
if (entity.has<AnimationTreeComponent>()) {
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<CharacterSpawnerComponent>(spawner);
}
nlohmann::json
SceneSerializer::serializeTerrainPrefabSpawner(flecs::entity entity)
{
auto &spawner = entity.get<TerrainPrefabSpawnerComponent>();
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<TerrainPrefabSpawnerComponent>(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);
@@ -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);
@@ -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 <OgreCamera.h>
#include <OgreLogManager.h>
#include <cmath>
#include <vector>
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<TerrainPrefabSpawnerComponent,
TransformComponent>())
{
s_instance = this;
m_world.observer<TerrainPrefabSpawnerComponent>(
"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<TerrainPrefabSpawnerComponent>() ||
!spawnerEntity.has<TransformComponent>())
return false;
auto &transform = spawnerEntity.get_mut<TransformComponent>();
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<TransformComponent>() ||
!instance.has<TransformComponent>())
return;
const auto &spawnerTransform =
spawnerEntity.get<TransformComponent>();
auto &instTransform = instance.get_mut<TransformComponent>();
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<EditorMarkerComponent>())
inst.remove<EditorMarkerComponent>();
if (m_uiSystem)
m_uiSystem->removeEntity(inst);
spawnerEntity.get_mut<TerrainPrefabSpawnerComponent>().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<TerrainPrefabSpawnerComponent>())
spawnerEntity.get_mut<TerrainPrefabSpawnerComponent>()
.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<TerrainPrefabSpawnerComponent>() ||
!spawnerEntity.has<TransformComponent>())
return;
spawnPrefab(spawnerEntity,
spawnerEntity.get<TerrainPrefabSpawnerComponent>(),
spawnerEntity.get<TransformComponent>());
}
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<RenderableComponent>()) {
const auto &renderable = entity.get<RenderableComponent>();
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<TerrainPrefabSpawnerComponent>() ||
!spawnerEntity.has<TransformComponent>())
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<TransformComponent>();
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>(EntityNameComponent(
"PrefabSpawner_" + std::to_string(++s_spawnCounter)));
e.add<EditorMarkerComponent>();
/* 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<TransformComponent>(xform);
TerrainPrefabSpawnerComponent spawner;
spawner.prefabPath = prefabPath;
e.set<TerrainPrefabSpawnerComponent>(spawner);
if (m_uiSystem)
m_uiSystem->addEntity(e);
Ogre::LogManager::getSingleton().logMessage(
"TerrainPrefabSpawnerSystem: created spawn point '" +
prefabPath + "' at " +
Ogre::StringConverter::toString(pos));
return e;
}
@@ -0,0 +1,181 @@
#ifndef EDITSCENE_TERRAINPREFABSPAWNERSYSTEM_HPP
#define EDITSCENE_TERRAINPREFABSPAWNERSYSTEM_HPP
#pragma once
#include <flecs.h>
#include <Ogre.h>
#include <string>
#include <unordered_map>
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<struct TerrainPrefabSpawnerComponent,
struct TransformComponent>
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<flecs::entity_t, SpawnerRecord> 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
@@ -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<std::mutex> 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<std::mutex> 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<std::mutex> 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);
@@ -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<std::pair<int, int>, FixupChunk> m_fixupChunks;
std::string m_fixupDir;
File diff suppressed because it is too large Load Diff
@@ -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);
@@ -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);
@@ -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
}
}
@@ -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;
}
+387 -75
View File
@@ -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 <OgreResourceGroupManager.h>
#include <OgreTextureManager.h>
@@ -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<std::string> &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<std::string> 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() ? "<none>" : 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<flecs::entity> spawners;
world.query<TerrainPrefabSpawnerComponent>().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<EntityNameComponent>())
label = e.get<EntityNameComponent>().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<TerrainPrefabSpawnerComponent>();
}
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<std::string> &swMeshes =
getMeshList();
std::vector<const char *> 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<int> 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<std::string>
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;
@@ -0,0 +1,127 @@
#include "TerrainPrefabSpawnerEditor.hpp"
#include "../systems/PrefabSystem.hpp"
#include "../systems/TerrainPrefabSpawnerSystem.hpp"
#include <imgui.h>
#include <cmath>
#include <filesystem>
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() ?
"<none>" :
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;
}
@@ -0,0 +1,39 @@
#ifndef EDITSCENE_TERRAINPREFABSPAWNEREDITOR_HPP
#define EDITSCENE_TERRAINPREFABSPAWNEREDITOR_HPP
#pragma once
#include "ComponentEditor.hpp"
#include "../components/TerrainPrefabSpawner.hpp"
#include <OgreSceneManager.h>
#include <string>
#include <vector>
/**
* @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<TerrainPrefabSpawnerComponent> {
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<std::string> m_prefabFiles;
bool m_prefabListScanned = false;
};
#endif // EDITSCENE_TERRAINPREFABSPAWNEREDITOR_HPP