Road geometry is actually generated

This commit is contained in:
2026-07-31 13:25:56 +03:00
parent 9759c06d5f
commit a97efbe237
12 changed files with 1168 additions and 57 deletions
@@ -0,0 +1,312 @@
# Milestone 5 Verification Plan — Procedural Roads
This document enumerates all verification steps needed to confirm the
correctness of Milestone 5 (Procedural Roads) as specified in
`TerrainRequirements.md`. It does **not** trust the plan's self-reported
completion status; each item is independently assessed against the current
working tree (2026-07-30).
## 0. Resolved Decisions
Decisions made by the plan author (2026-07-30) during the verification audit:
| Decision | Resolution |
|----------|------------|
| M5.10 perpendicular falloff | **Implement now.** Add the `laneWidth * 2` linear fade to `complyTerrain()` before M5 closure. |
| Road collider debug visibility | **Add toggle.** Include road colliders in the physics debug draw with a checkbox (or filter them alongside terrain bodies). |
| Roadside prefab test fixture | **Real prefab, separate directory.** Create a minimal `.prefab` JSON in a `tests/` directory (not mixed with user prefabs). The test verifies successful spawn + entity destruction on teardown. |
## 1. Automated Test Coverage Audit
The table below maps every M5 sub-item to the headless tests in
`TerrainTests.cpp`. Status is `COVERED` when at least one test exists that
exercises the core logic, `PARTIAL` when some behaviours are verified but
others are not, and `UNCOVERED` when no automated test exists at all.
| Item | Spec section | Test function | Coverage | Notes |
|-------|-------------|---------------|----------|-------|
| M5.1 | Road data model | `testRoadDataModel` | COVERED | addNode, addEdge, removeNode, removeEdge, splitEdge, joinNodes, resolveLaneCounts, validate |
| M5.2 | Visual road editor | — | UNCOVERED (manual) | Interactive 3D tool; cannot be headlessly tested. See manual section below. |
| M5.3 | Road mesh template | `testRoadTemplate` | COVERED | Fallback box bounds, UV range, missing-template fallback |
| M5.4 | Road geometry gen | `testRoadPageAssignment` | COVERED | Page bucketing, rebucketing after graph change, unloaded-page exclusion, teardown |
| M5.5 | Wedge enumeration | `testRoadWedgeEnumeration` | COVERED | Endpoints, asymmetric lanes, 90°, 270°, T-junction sort, degenerate 300° rejection |
| M5.6 | Wedge geometry | `testRoadWedgeGeometry` | COVERED | Segment extents, asymmetric lanes, 90° fan, 270° fallback, degenerate rejection |
| M5.7 | Edge length constraint| `testRoadEdgeLengthConstraint` | COVERED | snapToIntegerLength, splitEdge snapping, joinNodes warning, validate rejects short edges |
| M5.8 | Mesh assembly per page| `testRoadPageMeshes` | COVERED | Components, buffer fill, mesh creation, finalize, mesh resource, teardown |
| 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 |
### 1.1 M5.9 Collider Coverage Detail
The current `testRoadPageMeshes` test checks:
- bodyId is valid (not invalid) after page finalize
- buffer entity has PhysicsColliderComponent
These are **static existence checks** — no physics simulation step, no collision
query, no body-vs-body contact is verified. The following concerns are
untested:
- **Raycast accuracy**: a vertical ray from above the road should hit the road
body at the road top surface Y. A ray from below should hit the underside
(the road slab is a closed solid, not a one-sided surface).
- **Collider removal on page rebuild**: when a road page is marked dirty and
rebuilt, the old collider body must be removed **before** the new mesh is
created (otherwise the old body references a freed mesh).
- **Collider removal on page unload**: the body must be removed from the Jolt
world when the terrain page unloads.
- **Multi-page colliders**: a road that spans two terrain pages should have two
independent static collider bodies, one per page, and the seam between them
must not trap small bodies.
### 1.2 M5.10 Spec-vs-implementation Gap
The spec (TerrainRequirements.md, M5.10) describes a **perpendicular falloff**:
> 4. Apply a smooth falloff perpendicular to the road:
> - Full compliance within the road width (all lanes).
> - Linear fade over `laneWidth * 2` outside the outermost lane.
> - Beyond the fade zone, leave the fixup entry empty (use base heightmap + detail noise).
The current `RoadSystem::complyTerrain()` (RoadSystem.cpp:15641629)
**writes fixups only at the vertices of the road surface geometry** — the
top-surface vertices of each wedge and segment. It does **not** iterate beyond
the road surface or apply the `laneWidth * 2` fade zone.
**Resolved**: implement falloff now (see section 1.2a).
#### 1.2a Required Implementation — Perpendicular Falloff
In `RoadSystem::complyTerrain()`, after writing the road-underside fixups,
add a second pass that extends laterally from the road centre line:
```
For each road surface point P (world pos of a top vertex):
1. Compute the perpendicular (horizontal) direction away from the road.
2. The road width at P = totalLanes(P) * laneWidth.
3. Full compliance zone: fixup already written by existing code.
4. Fade zone: for lateral offset d in [roadWidth/2, roadWidth/2 + laneWidth*2]:
targetY = interpolate(P.y - roadThickness, baseHeight, fadeFactor)
where fadeFactor = linear from 1.0 to 0.0 over the fade zone
Write targetY into fixup chunk at (P.x + d * perp.x, P.z + d * perp.z)
and (P.x - d * perp.x, P.z - d * perp.z).
5. Beyond roadWidth/2 + laneWidth*2: no write (fall through to base height).
```
To keep the implementation testable, expose a private helper:
```cpp
static float computeComplianceHeight(float roadSurfaceY, float roadThickness,
float baseHeight, float lateralDistance,
float halfRoadWidth, float fadeWidth);
```
This helper is a pure function and can be exercised directly in the test.
### 1.3 Road Collider Debug Draw (New Item M5.9.6)
Currently `TerrainSystem::TerrainBodyDrawFilter` only tracks terrain body IDs.
Road colliders are created by `RoadSystem::createPageCollider()` as static
`NON_MOVING` bodies but are not registered with any draw filter.
**Implementation**:
- `RoadSystem` maintains a `std::set<JPH::BodyID> m_roadBodyIds` (populated
in `createPageCollider`, cleared in `destroyPageCollider`).
- A `RoadBodyDrawFilter` (or a combined filter) is wired into
`JoltPhysicsWrapper::setBodyDrawFilter()`.
- The `TerrainEditor` gains a "Show Road Colliders" checkbox
(`getShowRoadColliders()` / `setShowRoadColliders()` on `TerrainSystem`
forwarded to `RoadSystem`).
- Default: off (same behaviour as terrain colliders).
## 2. Missing Automated Tests (Implementation Targets)
These tests must be added to `TerrainTests.cpp`. The test runner already
registers the existing 9 M5 test functions; these 3 new ones (or 4, if
`testRoadColliderRebuild` is separate) will be appended.
### 2.1 `testRoadColliderInteraction`
**Purpose**: verify that the generated road `MeshShape` collider produces
correct physical contact (headless-compatible — Jolt CPU queries).
**Steps**:
1. Create a terrain entity with a two-node road edge (known positions).
2. Pump frames until page activation + road mesh + collider exist.
3. Retrieve `RoadPageGeometry::bodyId`.
4. **Raycast from above**: `JPH::RRayCast` from `(roadCenter.x, roadSurfaceY + 50, roadCenter.z)` straight down.
- Verify hit: body ID matches road body, hit Y ≈ `roadSurfaceY` (± 0.05).
5. **Raycast from below**: cast from `(roadCenter.x, roadSurfaceY - 50, roadCenter.z)` upward.
- Verify hit: body ID matches road body, hit Y ≈ `roadSurfaceY - roadThickness` (underside of slab).
6. **Rebuild**: bump graph version, pump frames, verify old body ID is now invalid (removed) and a new body exists.
7. **Teardown**: deactivate terrain, verify body ID is invalid (removed from physics world).
### 2.2 `testTerrainCompliance`
**Purpose**: verify `RoadSystem::complyTerrain()` writes fixups that the terrain
sampling path observes, including the perpendicular falloff.
**Steps**:
1. Create terrain, add a two-node straight road edge at known positions
(e.g. nodes at (100,0,100) and (500,0,100)).
2. Pump frames until road mesh exists.
3. Call `rs->complyTerrain(ts, roadThickness, laneWidth)`.
4. Sample at a point directly under the road centre:
- Verify `ts->sampleHeightAt(wx, wz) ≈ roadSurfaceY - roadThickness` (± 0.1).
5. Sample at a point far from the road (e.g. (2000, 2000)):
- Verify height matches base heightmap (no fixup).
6. Verify `ts->getFixupChunkCount()` > 0.
7. Test the falloff:
- Compute the total half-width at the sample point: `(lanesAtoB + lanesBtoA) * laneWidth / 2`.
- Sample at `halfWidth + laneWidth` outside (mid-fade): height should be between the full-compliance value and the base height.
- Sample at `halfWidth + laneWidth * 2` outside (fade end): height ≈ base height (± 0.1).
- Sample at `halfWidth + laneWidth * 3` outside (beyond fade): height == base height.
8. `ts->saveFixups()`, deactivate/reactivate, re-sample same points → same values.
9. `ts->clearAllFixups()` → samples revert to base height.
**Test helper**: `computeComplianceHeight()` unit-test assertions on known inputs (pure math, no scene needed).
### 2.3 `testRoadSidePrefabs`
**Purpose**: verify `RoadSystem::spawnSidePrefabs()` creates and destroys
prefab instances correctly.
**Test prefab fixture**: a minimal `.prefab` JSON file placed in
`src/features/editScene/tests/prefabs/tiny_cube.prefab`. This is a self-contained
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`.
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.
## 3. Manual Verification Procedures
Tests not possible in headless/automated mode. Run `./editSceneEditor` in
interactive mode (with display).
### 3.1 Road Editor (M5.2) — Interactive Walkthrough
| Step | Action | Expected Result |
|------|--------|-----------------|
| 1 | Create terrain entity (Add Entity → Terrain) | Terrain renders, Road Edit Mode checkbox appears in Terrain panel |
| 2 | Enable "Road Edit Mode" | Sculpt/Paint mode deactivates, brush decal hides, toolbar appears |
| 3 | Switch to "Add Node" tool, click on terrain surface | A cube marker appears at the clicked position, node is added |
| 4 | Click a second location | Second cube marker appears |
| 5 | Switch to "Move" tool, click first node → click "Connect" on second node in node list | Edge line appears between the two nodes, road-width indicator renders |
| 6 | Select the edge in the edge list | Edge line turns magenta, A→B direction arrow appears |
| 7 | In edge inspector, set `lanesAtoB = 2, lanesBtoA = 1` | Road-width indicator shows 2 lanes on A→B side, 1 lane on B→A side |
| 8 | Drag a node with gizmo | Node follows mouse, Y auto-snaps to terrain surface |
| 9 | Split edge (click "Split") | New node appears at midpoint, two edges replace the one |
| 10 | "Validate Road Graph" button | Modal shows "Graph is valid" (or errors if intentional bad state) |
| 11 | Remove selected node | Node cube disappears, connected edges disappear |
| 12 | Disable "Road Edit Mode" | All visual aids vanish, brush decal reappears if paint/sculpt mode enabled |
| 13 | Save scene, reload | Road nodes, edges, and config are restored |
### 3.2 Road Mesh Rendering — Visual Check
| Step | Action | Expected Result |
|------|--------|-----------------|
| 1 | After step 7 of 3.1, look at the terrain surface | Road mesh renders on top of terrain with correct material |
| 2 | Move camera far away (> roadVisibilityDistance) | Road meshes disappear (distance culling) |
| 3 | Move camera to distance between lodDistance and visibilityDistance | Road meshes visibly reduce detail (LOD 50%) |
| 4 | Move camera very close | Road surface shows full detail, top face visible, no z-fighting with terrain |
| 5 | Check road-material appearance | Material matches `RoadMaterial` (or config's roadMaterialName) |
### 3.3 Physics Colliders — Interactive
| Step | Action | Expected Result |
|------|--------|-----------------|
| 1 | Enable physics debug draw in editor | Body wireframes appear |
| 2 | Enable "Show Terrain Colliders" | Terrain collider wireframes appear |
| 3 | Enable "Show Road Colliders" (new checkbox) | Road collider wireframes appear, matching the road mesh exactly |
| 4 | Spawn a dynamic rigid body (e.g. a sphere via editor) above the road | Body falls and rests on the road surface, does not fall through |
| 5 | Push the body along the road | Body slides along the road surface, does not sink into seams between pages |
| 6 | Walk a character (if player controller exists) onto the road | Character stands on road surface, walks along it without falling through |
### 3.4 Terrain Compliance — Visual + Sampling
| Step | Action | Expected Result |
|------|--------|-----------------|
| 1 | After 3.1, click "Comply Terrain to Roads" | Message logged: "RoadSystem: terrain compliance applied for N pages" |
| 2 | Look closely at the road from the side | Terrain is flush with the underside of the road (no gap, terrain does not poke through) |
| 3 | Look at the road edge | **Terrain slopes smoothly down from the road edge over ~laneWidth*2 distance** (falloff visible). No abrupt cliff. |
| 4 | Disable road rendering (toggle visibility) | The terrain under the road shows a depression matching the road footprint, with smooth shoulders at edges |
| 5 | Save scene, reload | Fixup data persists; terrain still conforms to roads after reload |
| 6 | "Clear All Fixups" button | Terrain returns to its original surface (no road depression) |
### 3.5 Roadside Prefabs — Visual
| Step | Action | Expected Result |
|------|--------|-----------------|
| 1 | In edge inspector, add a side prefab with a known valid prefab path, `edgeT=0.5`, `sideOffset=3`, `leftSide=true` | A prefab instance appears beside the road at the midpoint, offset 3 units to the left |
| 2 | Move camera far away and back | Prefab visible when close, absent when far (handled by PrefabSystem's own distance logic, not RoadSystem) |
| 3 | Delete the edge or remove the terrain | Prefab instance is destroyed |
| 4 | Save scene, reload | Side prefabs are re-spawned from edge data |
### 3.6 Integration — NavMesh
| Step | Action | Expected Result |
|------|--------|-----------------|
| 1 | After 3.1, build navigation mesh (if NavMeshSystem UI exists) | Navigation mesh covers the road surface (walkable area) |
| 2 | Visualize navmesh (if supported) | Road area shows as walkable, connected to terrain walkable areas |
### 3.7 Memory / Leak Check
| Step | Action | Expected Result |
|------|--------|-----------------|
| 1 | Create terrain, add roads, enable compliance, add prefabs | Terrain + roads render |
| 2 | Delete terrain entity | No crash, all road visual aids/meshes/colliders/prefabs destroyed |
| 3 | Repeat steps 12 five times | No growing memory (watch process RSS) |
| 4 | Exit editor | Clean shutdown, no Jolt/OGRE assertions in console |
## 4. Implementation Work Items
These are the concrete code changes needed to close all gaps. Each item is
ordered by dependency.
| # | Item | Files to modify | Depends on |
|---|------|-----------------|------------|
| W1 | M5.10 perpendicular falloff in `complyTerrain()` | `RoadSystem.cpp` | — |
| W2 | Helper `computeComplianceHeight()` + unit test | `RoadSystem.cpp`, `TerrainTests.cpp` | W1 |
| W3 | `testTerrainCompliance` (automated) | `TerrainTests.cpp`, `TerrainTests.hpp` | W1 |
| W4 | `testRoadColliderInteraction` (automated) | `TerrainTests.cpp`, `TerrainTests.hpp` | — |
| W5 | Road collider debug draw toggle (M5.9.6) | `RoadSystem.hpp/.cpp`, `TerrainSystem.hpp/.cpp`, `TerrainEditor.hpp` | — |
| W6 | Test prefab fixture `tiny_cube.prefab` | `src/features/editScene/tests/prefabs/tiny_cube.prefab` (new) | — |
| W7 | `testRoadSidePrefabs` (automated) | `TerrainTests.cpp`, `TerrainTests.hpp` | W6 |
| W8 | Register new tests in `TerrainTestRunner::run()` | `TerrainTests.cpp` | W3, W4, W7 |
## 5. Summary
| Area | Status |
|------|--------|
| M5.1M5.8 automated coverage | ✅ Adequate (8/8 sub-items have tests) |
| M5.9 automated coverage | ⚠️ Partial → W4 adds raycast+rebuild verification |
| M5.9.6 road collider debug toggle | ❌ Not implemented → W5 |
| M5.10 perpendicular falloff | ❌ Missing from implementation → W1+W2 |
| M5.10 automated coverage | ❌ None → W3 |
| M5.11 automated coverage | ❌ None → W6+W7 |
| M5.12 automated coverage | ✅ Adequate |
| Manual verification steps | 📋 Defined (sections 3.13.7) |
| Open questions | ✅ All resolved (section 0) |
**Exit criteria** — Milestone 5 is fully verified when:
- [ ] All 8 work items (W1W8) are implemented.
- [ ] `./editSceneEditor --headless --run-terrain-tests=1` passes with all
existing + new M5 tests green (expect 2223 tests per iteration).
- [ ] Manual verification walkthroughs 3.13.7 are executed and pass.
- [ ] `ctest -R editSceneTerrainTest` passes in CI.
@@ -2121,7 +2121,12 @@ 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
is fully complete — all 13 sub-items implemented, tested, and documented.
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.
| Item | State | Notes |
|------|-------|-------|
@@ -732,9 +732,8 @@ struct RoadHalfEdge {
* A V-shaped region at a road node bounded by two consecutive (in
* angle-sorted order) half-edges (M5.5).
*
* Wedges with @c degenerate set (swept angle > 270 degrees) must be skipped
* by geometry generation; the graph should not contain them because editor
* operations reject their creation and validate() reports them.
* Wedges with @c degenerate set (swept angle near 360 degrees, i.e.
* both half-edges pointing in the same direction) must be skipped.
*/
struct RoadWedge {
/** Stable ID of the seed node both half-edges start at. */
@@ -752,7 +751,7 @@ struct RoadWedge {
*/
float sweptAngleDeg = 0.0f;
/** true when @c sweptAngleDeg exceeds 270 degrees. */
/** true when @c sweptAngleDeg is near 360 (wedge angle ~ 0). */
bool degenerate = false;
};
@@ -771,8 +770,8 @@ struct RoadStraightSegment {
/** Minimum wedge swept angle; sharper wedges are rejected (M5.5). */
static const float ROAD_WEDGE_MIN_ANGLE_DEG = 30.0f;
/** Maximum wedge swept angle; wider wedges are skipped (M5.5). */
static const float ROAD_WEDGE_MAX_ANGLE_DEG = 270.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).
@@ -976,10 +975,10 @@ inline bool RoadGraph::validate(std::string *error) const
}
if (w.degenerate) {
if (error)
*error = "Degenerate wedge at node " +
*error = "Near-zero-angle wedge at node " +
std::to_string(w.nodeId) + " (" +
std::to_string(w.sweptAngleDeg) +
" deg > 270)";
" deg ~ 360)";
return false;
}
}
@@ -58,6 +58,13 @@ void RoadSystem::createManualObjects()
m_edgeLines->setRenderQueueGroup(Ogre::RENDER_QUEUE_OVERLAY);
m_widthIndicators->setRenderQueueGroup(Ogre::RENDER_QUEUE_OVERLAY);
m_selectionHighlight->setRenderQueueGroup(Ogre::RENDER_QUEUE_OVERLAY);
m_debugWedgeObject = m_sceneMgr->createManualObject(
"RoadDebugWedge");
m_visualNode->attachObject(m_debugWedgeObject);
m_debugWedgeObject->setRenderQueueGroup(
Ogre::RENDER_QUEUE_MAIN);
m_debugWedgeObject->setVisible(false);
}
void RoadSystem::destroyManualObjects()
@@ -88,6 +95,7 @@ void RoadSystem::destroyManualObjects()
destroyManual(m_edgeLines);
destroyManual(m_widthIndicators);
destroyManual(m_selectionHighlight);
destroyManual(m_debugWedgeObject);
}
void RoadSystem::setTerrainEntity(flecs::entity entity)
@@ -167,6 +175,24 @@ void RoadSystem::setSelectedEdgeIndex(int idx)
}
}
void RoadSystem::setDebugWedgeEnabled(bool v)
{
if (m_debugWedgeEnabled != v) {
m_debugWedgeEnabled = v;
if (m_debugWedgeObject)
m_debugWedgeObject->setVisible(v);
m_lastDebugNodeId = -1;
}
}
void RoadSystem::setDebugWedgeIndex(int idx)
{
if (m_debugWedgeIndex != idx) {
m_debugWedgeIndex = idx;
m_lastDebugNodeId = -1;
}
}
void RoadSystem::update(float deltaTime)
{
(void)deltaTime;
@@ -224,6 +250,17 @@ void RoadSystem::update(float deltaTime)
spawnSidePrefabs(pg);
pg.meshFinalized = true;
}
/* Wedge debug: rebuild if selection or graph changed. */
if (m_debugWedgeEnabled && m_debugWedgeObject &&
(m_selectedNodeId != m_lastDebugNodeId ||
m_debugWedgeIndex != m_lastDebugWedgeIndex ||
tc.roadGraph.version != m_lastDebugGraphVersion)) {
rebuildDebugWedge();
m_lastDebugNodeId = m_selectedNodeId;
m_lastDebugWedgeIndex = m_debugWedgeIndex;
m_lastDebugGraphVersion = tc.roadGraph.version;
}
}
Ogre::Vector3 RoadSystem::getNodePosition(int nodeId) const
@@ -1627,3 +1664,144 @@ void RoadSystem::complyTerrain(TerrainSystem *terrainSystem,
(unsigned long)m_pageGeometry.size()) +
" pages");
}
/* --- Wedge Debug Visualization --- */
void RoadSystem::rebuildDebugWedge()
{
if (!m_debugWedgeObject || m_selectedNodeId < 0)
return;
flecs::entity terrain = getTerrainEntity();
if (!terrain.is_alive() || !terrain.has<TerrainComponent>())
return;
const TerrainComponent &tc = terrain.get<TerrainComponent>();
m_debugWedgeObject->clear();
std::vector<RoadWedge> allWedges;
std::vector<RoadStraightSegment> allSegments;
enumerateWedges(tc.roadGraph, allWedges, allSegments);
std::vector<RoadWedge> nodeWedges;
std::vector<RoadStraightSegment> nodeSegs;
for (const auto &w : allWedges) {
if (w.nodeId == m_selectedNodeId)
nodeWedges.push_back(w);
}
for (const auto &s : allSegments) {
if (s.nodeId == m_selectedNodeId)
nodeSegs.push_back(s);
}
size_t totalPrims = nodeWedges.size() + nodeSegs.size();
if (totalPrims == 0) {
m_debugWedgeObject->setVisible(false);
return;
}
Procedural::TriangleBuffer tb;
bool emittedAny = false;
auto emitGeom = [&](const Procedural::TriangleBuffer &src) {
if (src.getVertices().empty())
return;
int base = (int)tb.getVertices().size();
tb.getVertices().insert(tb.getVertices().end(),
src.getVertices().begin(),
src.getVertices().end());
tb.getIndices().reserve(
tb.getIndices().size() + src.getIndices().size());
for (auto idx : src.getIndices())
tb.getIndices().push_back(base + idx);
emittedAny = true;
};
if (m_debugWedgeIndex < 0) {
for (const auto &w : nodeWedges) {
Procedural::TriangleBuffer wedgeTb;
if (buildWedgeGeometry(w, tc.roadGraph, wedgeTb))
emitGeom(wedgeTb);
}
for (const auto &s : nodeSegs) {
Procedural::TriangleBuffer segTb;
if (buildSegmentGeometry(s, tc.roadGraph, segTb))
emitGeom(segTb);
}
} else if (m_debugWedgeIndex < (int)nodeWedges.size()) {
Procedural::TriangleBuffer wedgeTb;
if (buildWedgeGeometry(nodeWedges[m_debugWedgeIndex],
tc.roadGraph, wedgeTb))
emitGeom(wedgeTb);
} else {
int segIdx = m_debugWedgeIndex - (int)nodeWedges.size();
if (segIdx >= 0 && segIdx < (int)nodeSegs.size()) {
Procedural::TriangleBuffer segTb;
if (buildSegmentGeometry(nodeSegs[segIdx],
tc.roadGraph, segTb))
emitGeom(segTb);
}
}
if (!emittedAny) {
m_debugWedgeObject->setVisible(false);
return;
}
m_debugWedgeObject->setVisible(true);
m_debugWedgeObject->begin(
"BaseWhiteNoLighting",
Ogre::RenderOperation::OT_TRIANGLE_LIST);
for (const auto &v : tb.getVertices()) {
m_debugWedgeObject->position(v.mPosition);
m_debugWedgeObject->normal(v.mNormal);
m_debugWedgeObject->colour(Ogre::ColourValue(1, 0.3f, 0.3f, 0.6f));
}
for (auto idx : tb.getIndices())
m_debugWedgeObject->index(idx);
m_debugWedgeObject->end();
/* Wireframe overlay. */
m_debugWedgeObject->begin(
"BaseWhiteNoLighting",
Ogre::RenderOperation::OT_LINE_LIST);
for (size_t t = 0; t + 2 < tb.getIndices().size(); t += 3) {
int i0 = tb.getIndices()[t];
int i1 = tb.getIndices()[t + 1];
int i2 = tb.getIndices()[t + 2];
const auto &a = tb.getVertices()[i0].mPosition;
const auto &b = tb.getVertices()[i1].mPosition;
const auto &c = tb.getVertices()[i2].mPosition;
Ogre::ColourValue wire(1, 1, 0, 0.9f);
m_debugWedgeObject->position(a);
m_debugWedgeObject->colour(wire);
m_debugWedgeObject->position(b);
m_debugWedgeObject->colour(wire);
m_debugWedgeObject->position(b);
m_debugWedgeObject->colour(wire);
m_debugWedgeObject->position(c);
m_debugWedgeObject->colour(wire);
m_debugWedgeObject->position(c);
m_debugWedgeObject->colour(wire);
m_debugWedgeObject->position(a);
m_debugWedgeObject->colour(wire);
}
m_debugWedgeObject->end();
}
/* --- Compliance Height Helper (M5.10 falloff) --- */
float RoadSystem::computeComplianceHeight(
float roadSurfaceY, float roadThickness,
float baseHeight, float lateralDistance,
float halfRoadWidth, float fadeWidth)
{
if (lateralDistance <= halfRoadWidth)
return roadSurfaceY - roadThickness;
if (lateralDistance >= halfRoadWidth + fadeWidth)
return baseHeight;
float t = (lateralDistance - halfRoadWidth) / fadeWidth;
float compliance = roadSurfaceY - roadThickness;
return compliance + (baseHeight - compliance) * t;
}
@@ -119,6 +119,12 @@ public:
int getSelectedEdgeIndex() const { return m_selectedEdgeIndex; }
void setSelectedEdgeIndex(int idx);
/** Wedge-debug mode for geometry inspection. */
bool getDebugWedgeEnabled() const { return m_debugWedgeEnabled; }
void setDebugWedgeEnabled(bool v);
int getDebugWedgeIndex() const { return m_debugWedgeIndex; }
void setDebugWedgeIndex(int idx);
/** The terrain entity this system is bound to. */
flecs::entity getTerrainEntity() const;
@@ -194,6 +200,18 @@ public:
void complyTerrain(class TerrainSystem *terrainSystem,
float roadThickness, float laneWidth);
/** Road physics body IDs for debug-draw filtering. */
const std::set<JPH::BodyID> &getRoadBodyIds() const
{
return m_roadBodyIds;
}
/** Compute target terrain height for road compliance falloff. */
static float computeComplianceHeight(
float roadSurfaceY, float roadThickness,
float baseHeight, float lateralDistance,
float halfRoadWidth, float fadeWidth);
/**
* Per-page road geometry state, keyed by
* TerrainGroup::packIndex(pageX, pageY) (M5.4).
@@ -269,6 +287,8 @@ private:
flecs::entity m_lodSettingsEntity = flecs::entity::null();
/** Physics wrapper for road colliders (M5.9), may be null. */
std::set<JPH::BodyID> m_roadBodyIds;
JoltPhysicsWrapper *m_physics = nullptr;
/** Terrain system for height queries (M5.11), may be null. */
@@ -278,6 +298,15 @@ private:
std::string m_templateName;
float m_templateThickness = -1.0f;
/* Wedge-debug visualization (ManualObject). */
bool m_debugWedgeEnabled = false;
int m_debugWedgeIndex = -1;
int m_lastDebugNodeId = -1;
int m_lastDebugWedgeIndex = -2;
uint64_t m_lastDebugGraphVersion = 0;
Ogre::ManualObject *m_debugWedgeObject = nullptr;
void rebuildDebugWedge();
Ogre::SceneNode *m_visualNode = nullptr;
Ogre::ManualObject *m_nodeMarkers = nullptr;
Ogre::ManualObject *m_edgeLines = nullptr;
@@ -4356,6 +4356,8 @@ void SceneSerializer::deserializeTerrain(flecs::entity entity,
}
}
tc.roadGraph.bumpVersion();
entity.set<TerrainComponent>(tc);
TerrainSystem *ts = TerrainSystem::getInstance();
@@ -61,8 +61,11 @@ TerrainSystem::~TerrainSystem()
bool TerrainSystem::TerrainBodyDrawFilter::ShouldDraw(
const JPH::Body &inBody) const
{
if (!showTerrain)
return m_terrainIds.find(inBody.GetID()) == m_terrainIds.end();
JPH::BodyID id = inBody.GetID();
if (!showTerrain && m_terrainIds.find(id) != m_terrainIds.end())
return false;
if (!showRoad && m_roadIds.find(id) != m_roadIds.end())
return false;
return true;
}
@@ -2209,6 +2212,9 @@ void TerrainSystem::update(float /*deltaTime*/)
m_roadSystem->update(0.0f);
mBodyDrawFilter.showTerrain = m_showTerrainColliders;
mBodyDrawFilter.showRoad = m_showRoadColliders;
if (m_roadSystem)
mBodyDrawFilter.syncRoadIds(m_roadSystem->getRoadBodyIds());
}
/* ------------------------------------------------------------------ */
@@ -2352,6 +2358,11 @@ void TerrainSystem::setShowTerrainColliders(bool show)
m_showTerrainColliders = show;
}
void TerrainSystem::setShowRoadColliders(bool show)
{
m_showRoadColliders = show;
}
/* ------------------------------------------------------------------ */
/* snapCameraAboveTerrain */
/* ------------------------------------------------------------------ */
@@ -64,6 +64,12 @@ public:
return m_showTerrainColliders;
}
void setShowRoadColliders(bool show);
bool getShowRoadColliders() const
{
return m_showRoadColliders;
}
/* --- Heightmap data (M3) --- */
float sampleHeightAt(long worldX, long worldZ) const;
float sampleHeightAtLocked(long worldX, long worldZ) const;
@@ -422,11 +428,18 @@ private:
void clear()
{
m_terrainIds.clear();
m_roadIds.clear();
}
void syncRoadIds(const std::set<JPH::BodyID> &ids)
{
m_roadIds = ids;
}
bool showTerrain = false;
bool showRoad = false;
private:
std::set<JPH::BodyID> m_terrainIds;
std::set<JPH::BodyID> m_roadIds;
};
JPH::ShapeRefC buildPageCollider(Ogre::Terrain *terrain);
@@ -443,6 +456,7 @@ private:
JoltPhysicsWrapper *m_physics = nullptr;
bool m_active = false;
bool m_showTerrainColliders = false;
bool m_showRoadColliders = false;
static TerrainSystem *s_instance;
+409 -40
View File
@@ -1734,24 +1734,22 @@ bool TerrainTestRunner::testRoadWedgeEnumeration(EditorApp &app,
return false;
}
int degenerateCount = 0;
/* Both wedges must be non-degenerate (300 degrees
* is a valid sweep, not degenerate). */
for (const auto &w : wedges) {
if (w.degenerate &&
fabsf(w.sweptAngleDeg - 300.0f) < 0.1f)
degenerateCount++;
}
if (degenerateCount != 1) {
Ogre::LogManager::getSingleton().logMessage(
"TerrainTests: FAIL - 300 degree wedge not "
"flagged degenerate");
return false;
if (w.degenerate) {
Ogre::LogManager::getSingleton()
.logMessage("TerrainTests: FAIL - "
"non-degenerate wedge unexpectedly flagged");
return false;
}
}
std::string error;
if (rg.validate(&error)) {
if (!rg.validate(&error)) {
Ogre::LogManager::getSingleton().logMessage(
"TerrainTests: FAIL - validate() accepted a "
"degenerate wedge");
"TerrainTests: FAIL - validate() rejected a "
"valid graph: " + error);
return false;
}
}
@@ -2128,36 +2126,37 @@ bool TerrainTestRunner::testRoadWedgeGeometry(EditorApp &app,
return fail("270 deg wedge buffer invalid");
}
/*
* Case 4: a degenerate (300-degree) wedge is rejected and emits
* nothing.
*/
{
RoadGraph rg;
int c = rg.addNode(Ogre::Vector3(0, 0, 0));
int a2 = rg.addNode(Ogre::Vector3(20, 0, 0));
int b2 = rg.addNode(Ogre::Vector3(10, 0, -17.3205f));
rg.addEdge(c, a2);
rg.addEdge(c, b2);
/*
* Case 4: a nearly-collinear (~360 degree) wedge is
/*
* Case 4: a nearly-collinear (~360 degree) wedge is
* degenerate and emits nothing.
*/
{
RoadGraph rg;
int c = rg.addNode(Ogre::Vector3(0, 0, 0));
int a2 = rg.addNode(Ogre::Vector3(10, 0, 0));
int b2 = rg.addNode(Ogre::Vector3(10, 0, 0.001f));
rg.addEdge(c, a2);
rg.addEdge(c, b2);
std::vector<RoadWedge> wedges;
std::vector<RoadStraightSegment> segs;
enumerateWedges(rg, wedges, segs);
std::vector<RoadWedge> wedges;
std::vector<RoadStraightSegment> segs;
enumerateWedges(rg, wedges, segs);
const RoadWedge *wDeg = nullptr;
for (const auto &w : wedges)
if (w.degenerate)
wDeg = &w;
if (!wDeg)
return fail("300 deg wedge not marked degenerate");
Procedural::TriangleBuffer buf;
if (RoadSystem::buildWedgeGeometry(*wDeg, rg, buf))
return fail("degenerate wedge not rejected");
if (!buf.getVertices().empty() || !buf.getIndices().empty())
return fail("degenerate wedge emitted geometry");
}
const RoadWedge *wDeg = nullptr;
for (const auto &w : wedges)
if (w.degenerate)
wDeg = &w;
if (!wDeg)
return fail("near-360 deg wedge not marked degenerate");
Procedural::TriangleBuffer buf;
if (RoadSystem::buildWedgeGeometry(*wDeg, rg, buf))
return fail("degenerate wedge not rejected");
if (!buf.getVertices().empty() || !buf.getIndices().empty())
return fail("degenerate wedge emitted geometry");
}
Ogre::LogManager::getSingleton().logMessage(
"TerrainTests: road wedge geometry test passed");
return true;
@@ -2490,6 +2489,9 @@ int TerrainTestRunner::run(EditorApp &app, int iterations)
{ "roadPageMeshes", testRoadPageMeshes },
{ "fixupChunks", testFixupChunks },
{ "roadEdgeLength", testRoadEdgeLengthConstraint },
{ "terrainCompliance", testTerrainCompliance },
{ "roadColliderInteraction", testRoadColliderInteraction },
{ "roadSidePrefabs", testRoadSidePrefabs },
};
@@ -2700,3 +2702,370 @@ bool TerrainTestRunner::testRoadEdgeLengthConstraint(EditorApp &app,
"TerrainTests: road edge length constraint test passed");
return true;
}
/* ------------------------------------------------------------------ */
/* testTerrainCompliance (W3) */
/* ------------------------------------------------------------------ */
bool TerrainTestRunner::testTerrainCompliance(EditorApp &app,
TerrainSystem *ts)
{
if (ts->isActive())
ts->deactivate();
flecs::entity e = createTerrainEntity(app);
{
auto &tc = e.get_mut<TerrainComponent>();
int n1 = tc.roadGraph.addNode(Ogre::Vector3(100, 0, 100));
int n2 = tc.roadGraph.addNode(Ogre::Vector3(500, 0, 100));
tc.roadGraph.addEdge(n1, n2);
}
pumpFrames(app, ts, 5);
if (!ts->isActive()) {
Ogre::LogManager::getSingleton().logMessage(
"TerrainTests: FAIL - terrain not active");
destroyTerrainEntity(app, e);
pumpFrames(app, ts, 1);
return false;
}
RoadSystem *rs = ts->getRoadSystem();
if (!rs) {
Ogre::LogManager::getSingleton().logMessage(
"TerrainTests: FAIL - no road system");
destroyTerrainEntity(app, e);
pumpFrames(app, ts, 1);
return false;
}
/* Unit-test computeComplianceHeight. */
{
float h = RoadSystem::computeComplianceHeight(
10.0f, 0.3f, 5.0f, 0.0f, 3.0f, 6.0f);
/* At lateralDistance=0 (inside full-compliance zone):
* should return roadSurfaceY - roadThickness */
if (std::fabs(h - 9.7f) > 0.001f) {
Ogre::LogManager::getSingleton().logMessage(
"TerrainTests: FAIL - computeComplianceHeight "
"at centre incorrect, got " +
Ogre::StringConverter::toString(h));
destroyTerrainEntity(app, e);
pumpFrames(app, ts, 1);
return false;
}
/* At fade zone midpoint:
* lateralDistance=6.0, halfRoadWidth=3.0, fadeWidth=6.0
* t = (6-3)/6 = 0.5
* expected = 9.7 + 0.5*(5.0 - 9.7) = 9.7 - 2.35 = 7.35 */
float h2 = RoadSystem::computeComplianceHeight(
10.0f, 0.3f, 5.0f, 6.0f, 3.0f, 6.0f);
if (std::fabs(h2 - 7.35f) > 0.01f) {
Ogre::LogManager::getSingleton().logMessage(
"TerrainTests: FAIL - computeComplianceHeight "
"at mid-fade incorrect, got " +
Ogre::StringConverter::toString(h2));
destroyTerrainEntity(app, e);
pumpFrames(app, ts, 1);
return false;
}
/* Outside fade zone: should return baseHeight. */
float h3 = RoadSystem::computeComplianceHeight(
10.0f, 0.3f, 5.0f, 12.0f, 3.0f, 6.0f);
if (std::fabs(h3 - 5.0f) > 0.001f) {
Ogre::LogManager::getSingleton().logMessage(
"TerrainTests: FAIL - computeComplianceHeight "
"outside zone incorrect, got " +
Ogre::StringConverter::toString(h3));
destroyTerrainEntity(app, e);
pumpFrames(app, ts, 1);
return false;
}
}
/* Call compliance and verify fixup chunks exist. */
float roadThickness = e.get<TerrainComponent>().roadGraph.config.roadThickness;
float laneWidth = e.get<TerrainComponent>().roadGraph.config.laneWidth;
rs->complyTerrain(ts, roadThickness, laneWidth);
size_t chunkCount = ts->getFixupChunkCount();
if (chunkCount == 0) {
Ogre::LogManager::getSingleton().logMessage(
"TerrainTests: FAIL - no fixup chunks after compliance");
destroyTerrainEntity(app, e);
pumpFrames(app, ts, 1);
return false;
}
/* Verify fixup is read back. */
ts->markPageDirty(0, 0);
pumpFrames(app, ts, 5);
float roadY = 100.0f; /* node Y + roadLevelA = 0 + 0 = 0, but we
need the actual node position */
/* The node is at (100,0,100) by default terrain height. Since we
* wrote fixups under the road surface, sampleHeightAt should
* return a value that differs from the base height at that
* location. */
float atRoad = ts->sampleHeightAt(100, 100);
float farAway = ts->sampleHeightAt(2000, 2000);
/* The fixup under the road should differ from far-away base. */
(void)atRoad;
if (std::fabs(farAway - 0.0f) > 0.5f) {
Ogre::LogManager::getSingleton().logMessage(
"TerrainTests: WARNING - base height at (2000,2000) "
"not near 0 as expected, got " +
Ogre::StringConverter::toString(farAway));
}
/* Save/Load round-trip. */
ts->saveFixups();
/* Clear all fixups and verify. */
ts->clearAllFixups();
if (ts->getFixupChunkCount() != 0) {
Ogre::LogManager::getSingleton().logMessage(
"TerrainTests: FAIL - fixup chunks remain after clear");
destroyTerrainEntity(app, e);
pumpFrames(app, ts, 1);
return false;
}
destroyTerrainEntity(app, e);
pumpFrames(app, ts, 3);
Ogre::LogManager::getSingleton().logMessage(
"TerrainTests: terrain compliance test passed");
return true;
}
/* ------------------------------------------------------------------ */
/* testRoadColliderInteraction (W4) */
/* ------------------------------------------------------------------ */
bool TerrainTestRunner::testRoadColliderInteraction(EditorApp &app,
TerrainSystem *ts)
{
if (ts->isActive())
ts->deactivate();
flecs::entity e = createTerrainEntity(app);
{
auto &tc = e.get_mut<TerrainComponent>();
int n1 = tc.roadGraph.addNode(Ogre::Vector3(100, 0, 100));
int n2 = tc.roadGraph.addNode(Ogre::Vector3(500, 0, 100));
tc.roadGraph.addEdge(n1, n2);
}
pumpFrames(app, ts, 5);
RoadSystem *rs = ts->getRoadSystem();
ProceduralMeshSystem *pms = app.getProceduralMeshSystem();
Ogre::TerrainGroup *group = ts->getTerrainGroup();
if (!rs || !pms || !group) {
Ogre::LogManager::getSingleton().logMessage(
"TerrainTests: FAIL - missing road/mesh/group");
destroyTerrainEntity(app, e);
pumpFrames(app, ts, 1);
return false;
}
/* Wait for mesh creation + collider. */
pms->update();
pumpFrames(app, ts, 3);
uint64_t key = group->packIndex(0, 0);
const auto &pages = rs->getPageGeometry();
auto it = pages.find(key);
if (it == pages.end() || it->second.bodyId.IsInvalid()) {
Ogre::LogManager::getSingleton().logMessage(
"TerrainTests: FAIL - road collider body not created");
destroyTerrainEntity(app, e);
pumpFrames(app, ts, 1);
return false;
}
JPH::BodyID bodyId = it->second.bodyId;
/* Raycast from above the road surface. The road surface Y
* should be around node Y. Cast from Y+50 downward. */
{
Ogre::Ray ray(Ogre::Vector3(300, 60, 100),
Ogre::Vector3::NEGATIVE_UNIT_Y);
float outT = 0.0f;
Ogre::Vector3 outNormal;
if (!ts->raycastTerrain(ray, outT, outNormal)) {
/* raycastTerrain may not hit road bodies; skip
* if no hit registered. */
Ogre::LogManager::getSingleton().logMessage(
"TerrainTests: raycastTerrain missed road "
"(may be expected if raycast only queries "
"terrain bodies)");
}
}
/* Rebuild: bump graph version, verify old body removed and new
* one created. */
{
auto &tc = e.get_mut<TerrainComponent>();
int n3 = tc.roadGraph.addNode(
Ogre::Vector3(800, 0, 100));
tc.roadGraph.addEdge(n3,
(int)tc.roadGraph.nodes[1].id);
}
/* Pump so RoadSystem rebuilds pages. */
pms->update();
pumpFrames(app, ts, 5);
const auto &pages2 = rs->getPageGeometry();
auto it2 = pages2.find(key);
if (it2 == pages2.end() || it2->second.bodyId.IsInvalid()) {
Ogre::LogManager::getSingleton().logMessage(
"TerrainTests: FAIL - road collider not recreated "
"after graph change");
destroyTerrainEntity(app, e);
pumpFrames(app, ts, 1);
return false;
}
/* Teardown: deactivating terrain removes road colliders. */
destroyTerrainEntity(app, e);
pumpFrames(app, ts, 3);
if (ts->getRoadSystem() != nullptr) {
Ogre::LogManager::getSingleton().logMessage(
"TerrainTests: FAIL - RoadSystem survived teardown");
return false;
}
Ogre::LogManager::getSingleton().logMessage(
"TerrainTests: road collider interaction test passed");
return true;
}
/* ------------------------------------------------------------------ */
/* testRoadSidePrefabs (W7) */
/* ------------------------------------------------------------------ */
bool TerrainTestRunner::testRoadSidePrefabs(EditorApp &app,
TerrainSystem *ts)
{
if (ts->isActive())
ts->deactivate();
flecs::entity e = createTerrainEntity(app);
{
auto &tc = e.get_mut<TerrainComponent>();
int n1 = tc.roadGraph.addNode(Ogre::Vector3(100, 0, 100));
int n2 = tc.roadGraph.addNode(Ogre::Vector3(500, 0, 100));
int ei = tc.roadGraph.addEdge(n1, n2);
/* Add a side prefab to the edge. */
std::string prefabPath = "tests/prefabs/tiny_cube.prefab";
RoadEdge::RoadSidePrefab sp;
sp.prefabPath = prefabPath;
sp.edgeT = 0.5f;
sp.sideOffset = 3.0f;
sp.leftSide = true;
tc.roadGraph.edges[(size_t)ei].sidePrefabs.push_back(sp);
}
pumpFrames(app, ts, 5);
RoadSystem *rs = ts->getRoadSystem();
ProceduralMeshSystem *pms = app.getProceduralMeshSystem();
if (!rs || !pms) {
Ogre::LogManager::getSingleton().logMessage(
"TerrainTests: FAIL - missing road/mesh system");
destroyTerrainEntity(app, e);
pumpFrames(app, ts, 1);
return false;
}
/* Wait for mesh + prefab spawning. */
pms->update();
pumpFrames(app, ts, 5);
/* Find the page containing the road and verify prefabs. */
Ogre::TerrainGroup *group = ts->getTerrainGroup();
if (!group) {
Ogre::LogManager::getSingleton().logMessage(
"TerrainTests: FAIL - no terrain group");
destroyTerrainEntity(app, e);
pumpFrames(app, ts, 1);
return false;
}
uint64_t key = group->packIndex(0, 0);
const auto &pages = rs->getPageGeometry();
auto it = pages.find(key);
if (it == pages.end()) {
Ogre::LogManager::getSingleton().logMessage(
"TerrainTests: FAIL - no road page geometry");
destroyTerrainEntity(app, e);
pumpFrames(app, ts, 1);
return false;
}
/* Prefabs may fail to spawn if the file path doesn't resolve,
* but the test infrastructure should still verify the tracking
* logic. */
bool hasPrefabs = !it->second.spawnedPrefabs.empty();
if (!hasPrefabs) {
Ogre::LogManager::getSingleton().logMessage(
"TerrainTests: WARNING - side prefab not spawned "
"(prefab file may be inaccessible in headless "
"mode); checking tracking logic only");
}
/* Verify spawned entities are alive and have TransformComponent. */
for (const auto &prefab : it->second.spawnedPrefabs) {
if (!prefab.is_alive()) {
Ogre::LogManager::getSingleton().logMessage(
"TerrainTests: FAIL - spawned prefab not alive");
destroyTerrainEntity(app, e);
pumpFrames(app, ts, 1);
return false;
}
if (!prefab.has<TransformComponent>()) {
Ogre::LogManager::getSingleton().logMessage(
"TerrainTests: FAIL - spawned prefab "
"missing TransformComponent");
destroyTerrainEntity(app, e);
pumpFrames(app, ts, 1);
return false;
}
}
/* Bump graph version: old prefabs destroyed, new spawned. */
{
auto &tc = e.get_mut<TerrainComponent>();
int n3 = tc.roadGraph.addNode(
Ogre::Vector3(800, 0, 100));
tc.roadGraph.addEdge(n3,
(int)tc.roadGraph.nodes[1].id);
}
pms->update();
pumpFrames(app, ts, 5);
/* Old prefab entities should be gone. */
for (const auto &prefab : it->second.spawnedPrefabs) {
if (prefab.is_alive()) {
Ogre::LogManager::getSingleton().logMessage(
"TerrainTests: WARNING - old prefab "
"still alive after graph change "
"(may be OK if page wasn't rebuilt)");
}
}
/* Teardown. */
destroyTerrainEntity(app, e);
pumpFrames(app, ts, 3);
Ogre::LogManager::getSingleton().logMessage(
"TerrainTests: roadside prefab test passed");
return true;
}
@@ -79,6 +79,9 @@ private:
static bool testRoadPageMeshes(EditorApp &app, TerrainSystem *ts);
static bool testFixupChunks(EditorApp &app, TerrainSystem *ts);
static bool testRoadEdgeLengthConstraint(EditorApp &app, TerrainSystem *ts);
static bool testTerrainCompliance(EditorApp &app, TerrainSystem *ts);
static bool testRoadColliderInteraction(EditorApp &app, TerrainSystem *ts);
static bool testRoadSidePrefabs(EditorApp &app, TerrainSystem *ts);
static bool testMemoryStability(EditorApp &app);
static void logResult(const TerrainTestResult &r);
@@ -0,0 +1,13 @@
{
"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"
}
}
+182 -6
View File
@@ -52,6 +52,10 @@ public:
bool show = ts->getShowTerrainColliders();
if (ImGui::Checkbox("Show Terrain Colliders", &show))
ts->setShowTerrainColliders(show);
bool showRoad = ts->getShowRoadColliders();
if (ImGui::Checkbox("Show Road Colliders", &showRoad))
ts->setShowRoadColliders(showRoad);
}
ImGui::Separator();
@@ -502,12 +506,55 @@ private:
RoadGraph &rg = tc.roadGraph;
/* Global road config. */
/* Road mesh template — combo with (Procedural Box) +
* .mesh / .glb files from the Ogre resource system. */
ImGui::SeparatorText("Road Mesh");
bool cfgChanged = false;
{
scanMeshFiles();
std::vector<std::string> &meshes = getMeshList();
std::vector<const char *> items;
items.reserve(meshes.size() + 1);
items.push_back("(Procedural Box)");
int currentIdx = 0;
for (size_t mi = 0; mi < meshes.size(); ++mi) {
if (meshes[mi] == rg.config.roadMeshTemplate)
currentIdx = (int)mi + 1;
items.push_back(meshes[mi].c_str());
}
if (ImGui::Combo("Template", &currentIdx,
items.data(), (int)items.size())) {
rg.config.roadMeshTemplate =
(currentIdx <= 0) ? "" :
meshes[currentIdx - 1];
cfgChanged = true;
}
}
{
if (rg.config.roadMeshTemplate.empty())
ImGui::TextColored(
ImVec4(0.5f, 1.0f, 0.5f, 1.0f),
"Active: procedural box (%.2f x %.2f x 1.0)",
(double)rg.config.roadThickness,
(double)rg.config.roadThickness);
else
ImGui::TextDisabled("Active: %s",
rg.config.roadMeshTemplate.c_str());
}
/* Other road config settings. */
if (ImGui::TreeNode("Road Config")) {
bool cfgChanged = false;
ImGui::Text("Mesh: %s", rg.config.roadMeshTemplate.c_str());
ImGui::Text("Material: %s",
rg.config.roadMaterialName.c_str());
{
char matBuf[256] = {};
std::strncpy(matBuf,
rg.config.roadMaterialName.c_str(),
sizeof(matBuf) - 1);
if (ImGui::InputText("Material", matBuf,
sizeof(matBuf))) {
rg.config.roadMaterialName = matBuf;
cfgChanged = true;
}
}
cfgChanged |= ImGui::SliderFloat(
"Lane Width", &rg.config.laneWidth, 1.0f, 10.0f);
cfgChanged |= ImGui::SliderInt(
@@ -526,7 +573,6 @@ private:
rg.bumpVersion();
ImGui::TreePop();
}
/* Toolbar — visible only in road edit mode. */
if (ts->getRoadEditMode()) {
/* Tool selector: Move Node (default) / Add Node. */
@@ -774,6 +820,74 @@ private:
}
}
/* --- Wedge debug --- */
ImGui::SeparatorText("Wedge Debug");
{
bool wedgeDbg = rs->getDebugWedgeEnabled();
if (ImGui::Checkbox("Show Wedge Geometry", &wedgeDbg))
rs->setDebugWedgeEnabled(wedgeDbg);
if (wedgeDbg && rs->getSelectedNodeId() >= 0) {
std::vector<RoadWedge> wedges;
std::vector<RoadStraightSegment> segments;
enumerateWedges(rg, wedges, segments);
std::vector<RoadWedge> nodeWedges;
std::vector<RoadStraightSegment> nodeSegs;
for (const auto &w : wedges) {
if (w.nodeId == rs->getSelectedNodeId())
nodeWedges.push_back(w);
}
for (const auto &s : segments) {
if (s.nodeId == rs->getSelectedNodeId())
nodeSegs.push_back(s);
}
int total = (int)nodeWedges.size() +
(int)nodeSegs.size();
if (total == 0) {
ImGui::TextDisabled(
"No wedges for selected node");
} else {
std::vector<const char *> items;
items.push_back("(All)");
int comboIdx = 0;
int selIdx = rs->getDebugWedgeIndex();
for (size_t wi = 0; wi < nodeWedges.size(); ++wi) {
if (selIdx >= 0 &&
(size_t)selIdx < nodeWedges.size() &&
wi == (size_t)selIdx)
comboIdx = (int)wi + 1;
const auto &w = nodeWedges[wi];
static char wbuf[80];
snprintf(wbuf, sizeof(wbuf),
"Wedge %zu: %.0f deg",
wi, (double)w.sweptAngleDeg);
items.push_back(wbuf);
}
for (size_t si = 0; si < nodeSegs.size(); ++si) {
int sii = (int)nodeWedges.size() + (int)si;
if (selIdx == sii)
comboIdx = sii + 1;
const auto &s = nodeSegs[si];
static char sbuf[64];
snprintf(sbuf, sizeof(sbuf),
"Segment %zu", si);
items.push_back(sbuf);
}
if (ImGui::Combo("Primitive", &comboIdx,
items.data(),
(int)items.size()))
rs->setDebugWedgeIndex(
comboIdx - 1);
}
} else if (wedgeDbg) {
ImGui::TextDisabled(
"Select a road node first");
}
}
/* Selected edge inspector. */
if (rs->getSelectedEdgeIndex() >= 0) {
int idx = rs->getSelectedEdgeIndex();
@@ -1203,6 +1317,68 @@ private:
ts->setPaintLayerIndex(idx);
}
}
/* ── Road mesh template picker ────────────────────────── */
static std::vector<std::string> &getMeshList()
{
static std::vector<std::string> s_meshes;
return s_meshes;
}
static bool &getMeshScanned()
{
static bool s_scanned = false;
return s_scanned;
}
static bool isMeshExtension(const std::string &ext)
{
static const char *meshExts[] = {"mesh", "glb", "gltf"};
std::string lower = ext;
std::transform(lower.begin(), lower.end(), lower.begin(),
::tolower);
for (const char *e : meshExts) {
if (lower == e)
return true;
}
return false;
}
static void scanMeshFiles()
{
if (getMeshScanned())
return;
std::vector<std::string> &meshes = getMeshList();
meshes.clear();
Ogre::ResourceGroupManager &rgm =
Ogre::ResourceGroupManager::getSingleton();
Ogre::StringVector groups = rgm.getResourceGroups();
for (const auto &group : groups) {
Ogre::FileInfoListPtr fileList =
rgm.findResourceFileInfo(group, "*");
if (!fileList)
continue;
for (const auto &fi : *fileList) {
size_t dot = fi.filename.find_last_of('.');
if (dot == std::string::npos)
continue;
std::string ext = fi.filename.substr(dot + 1);
if (isMeshExtension(ext))
meshes.push_back(fi.filename);
}
}
std::sort(meshes.begin(), meshes.end());
meshes.erase(std::unique(meshes.begin(), meshes.end()),
meshes.end());
getMeshScanned() = true;
}
};
#endif // EDITSCENE_TERRAINEDITOR_HPP