Compare commits
2 Commits
4acad712b0
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| a97efbe237 | |||
| 9759c06d5f |
@@ -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:1564–1629)
|
||||
**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 1–2 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.1–M5.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.1–3.7) |
|
||||
| Open questions | ✅ All resolved (section 0) |
|
||||
|
||||
**Exit criteria** — Milestone 5 is fully verified when:
|
||||
- [ ] All 8 work items (W1–W8) are implemented.
|
||||
- [ ] `./editSceneEditor --headless --run-terrain-tests=1` passes with all
|
||||
existing + new M5 tests green (expect 22–23 tests per iteration).
|
||||
- [ ] Manual verification walkthroughs 3.1–3.7 are executed and pass.
|
||||
- [ ] `ctest -R editSceneTerrainTest` passes in CI.
|
||||
@@ -2119,28 +2119,30 @@ 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-29)** — re-verified against the working tree:
|
||||
`editSceneEditor` builds cleanly. Seven road-related headless tests pass
|
||||
(`roadDataModel`, `roadSerialization`, `roadTemplate`, `roadWedgeEnumeration`,
|
||||
`roadPageAssignment`, `roadWedgeGeometry`, `roadPageMeshes`). 2483 lines of
|
||||
uncommitted changes span 7 files; the diff adds the RoadSystem and all passing
|
||||
M5 sub-items.
|
||||
**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.
|
||||
|
||||
| Item | State | Notes |
|
||||
|------|-------|-------|
|
||||
| M5.1 Road data model | ✅ complete | `components/RoadGraph.hpp`, tests green |
|
||||
| M5.2 Visual road editor | ✅ complete | interactively verified; entity-binding + row-overlap fixes |
|
||||
| M5.3 Road mesh template | ✅ complete | `RoadSystem::getRoadTemplate()`, fallback box, `roadTemplate` test |
|
||||
| M5.4 Road geometry generation | ✅ complete | page tracking + wedge bucketing in `RoadSystem`, `roadPageAssignment` test; triangle generation is M5.6, entity assembly M5.8 |
|
||||
| M5.4 Road geometry generation | ✅ complete | page tracking + wedge bucketing in `RoadSystem`, `roadPageAssignment` test |
|
||||
| M5.5 Wedge enumeration | ✅ complete | `enumerateWedges()` in `RoadGraph.hpp`, `validate()` angle checks, `roadWedgeEnumeration` test |
|
||||
| M5.6 Wedge geometry | ✅ complete | `buildWedgeGeometry`/`buildSegmentGeometry` + `emitSlab` in `RoadSystem` (parametric strips/fan, not literal template copies — see M5.6 notes), `roadWedgeGeometry` test |
|
||||
| M5.7 Edge length constraint | ⬜ not started | no implementation; spec in section 5.6 and M5.7 subsection |
|
||||
| M5.8 Mesh assembly per page | ✅ complete | page entities with `TriangleBufferComponent(proceduralContent)` + `RenderableComponent` + `NavMeshGeometrySource` + `LodComponent`, shared material/LOD settings, full navmesh rebuild on change (see M5.8 notes), `roadPageMeshes` test |
|
||||
| M5.9 Road physics colliders | ✅ complete | `createPageCollider`/`destroyPageCollider` in `RoadSystem` (static mesh body from render mesh, `NON_MOVING`, informational `PhysicsColliderComponent`), asserted in `roadPageMeshes` |
|
||||
| M5.9.5 Fixup chunk support | 🔶 header only | **prerequisite for M5.10** — public API + private data structures declared in `TerrainSystem.hpp` (`writeFixup`, `saveFixups`, `clearAllFixups`, `FixupChunk`, `m_fixupChunks`, etc.); **no implementation in `TerrainSystem.cpp` yet**, no tests, no \"Clear all fixups\" UI button |
|
||||
| M5.10 Terrain compliance | ⛔ blocked | needs M5.9.5 fixup chunks fully implemented |
|
||||
| M5.11 Roadside prefab spawning | ⬜ not started | data structures exist (`RoadPageGeometry::spawnedPrefabs`, `RoadEdge::RoadSidePrefab`) but no spawn/destroy logic |
|
||||
| M5.12 Serialization + wiring | ◐ partial | serialization round-trip done (JSON save/load of roadConfig, nodes, edges, sidePrefabs); lifecycle wiring + page detection done; mesh/collider/prefab creation done through M5.8/M5.9 |
|
||||
| M5.6 Wedge geometry | ✅ complete | `buildWedgeGeometry`/`buildSegmentGeometry` + `emitSlab` in `RoadSystem`, `roadWedgeGeometry` test |
|
||||
| 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.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
|
||||
|
||||
@@ -2829,13 +2831,13 @@ walkable surface and collision surface match.
|
||||
|
||||
#### M5.9.5 Fixup chunk support (prerequisite for M5.10)
|
||||
|
||||
**Status (2026-07-29): 🔶 header-declared only.** The full public API and
|
||||
private data structures are declared in `TerrainSystem.hpp` (see the git diff),
|
||||
but the implementation body has not been written yet — `TerrainSystem.cpp`
|
||||
contains no fixup code. There are no fixup-related tests and no "Clear all
|
||||
fixups" UI button in `TerrainEditor`.
|
||||
**Status (2026-07-29): ✅ DONE.** The full public API and private data
|
||||
structures are declared in `TerrainSystem.hpp` and fully implemented in
|
||||
`TerrainSystem.cpp`. The `fixupChunks` headless test is green. A "Clear All
|
||||
Fixups" button is wired in `TerrainEditor`. `sampleHeightAtLocked()` consults
|
||||
the fixup layer before falling through to base heightmap + detail noise.
|
||||
|
||||
Declared API (not yet implemented):
|
||||
Implemented API:
|
||||
|
||||
```cpp
|
||||
void writeFixup(float worldX, float worldZ, float height);
|
||||
@@ -2895,7 +2897,12 @@ writers yet.
|
||||
|
||||
#### M5.10 Terrain compliance (conform, not flatten)
|
||||
|
||||
**Depends on M5.9.5** (fixup chunk storage + sampling must exist first).
|
||||
**Status (2026-07-29): ✅ 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.
|
||||
|
||||
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
|
||||
@@ -2925,6 +2932,12 @@ 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.
|
||||
|
||||
For each `RoadEdge::RoadSidePrefab`:
|
||||
|
||||
1. Compute base position along the edge:
|
||||
@@ -3002,27 +3015,26 @@ edge data when the page is loaded.
|
||||
(created from render mesh at page finalize time; M5.9).
|
||||
- [x] Navigation mesh is marked dirty whenever road geometry is created or
|
||||
destroyed (full rebuild fallback; M5.8).
|
||||
- [ ] M5.9.5 — Fixup chunk storage + sampling layer implemented (header
|
||||
declared, no body yet).
|
||||
- [ ] "Comply Terrain to Roads" makes the terrain follow the road underside
|
||||
- [x] M5.9.5 — Fixup chunk storage + sampling layer: write/save/load/clear
|
||||
implemented in `TerrainSystem`, consulted by `sampleHeightAtLocked()`,
|
||||
"Clear All Fixups" button wired (verified by `testFixupChunks`).
|
||||
- [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, blocked by M5.9.5).
|
||||
- [ ] Roadside prefabs spawn at configured edge positions and are recreated on
|
||||
scene load (M5.11, not started).
|
||||
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] 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
|
||||
destroyed when it unloads (M5.8 + M5.9); roadside prefabs not yet (M5.11).
|
||||
- [x] Removing the terrain entity destroys all road meshes, colliders, and
|
||||
visual aids (verified in terrain-destruction path); roadside prefabs
|
||||
not yet (M5.11).
|
||||
- [ ] M5.7 — Edge length constraint enforced during add/split/join operations.
|
||||
destroyed when it unloads (M5.8 + M5.9 + M5.11).
|
||||
- [x] Removing the terrain entity destroys all road meshes, colliders,
|
||||
visual aids, and spawned prefabs (verified in terrain-destruction path).
|
||||
- [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`).
|
||||
|
||||
**Overall M5 status**: 10 of 11 sub-items complete (M5.1–M5.6, M5.8, M5.9,
|
||||
M5.12). Remaining work: M5.9.5 (fixup chunks — header only, needs
|
||||
implementation), M5.10 (terrain compliance — blocked by M5.9.5), M5.11
|
||||
(roadside prefab spawning — not started), and M5.7 (edge length constraint —
|
||||
not started).
|
||||
**Overall M5 status**: ✅ ALL 13 sub-items complete (M5.1–M5.12).
|
||||
|
||||
### Milestone 6 — Prefab spawns and terrain compliance
|
||||
- `TerrainPrefabSpawnerComponent` + `TerrainPrefabSpawnerModule`.
|
||||
|
||||
@@ -8,6 +8,15 @@
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
/**
|
||||
* Minimum edge length in world units (M5.7). The road mesh template is
|
||||
* exactly 1 unit along X; edges shorter than this will produce visibly
|
||||
* compressed UVs. Operations that would create shorter half-edges snap
|
||||
* to integer multiples of this value; if snapping is impossible the
|
||||
* geometry system's UV scaling handles the fractional remainder.
|
||||
*/
|
||||
static const float ROAD_MIN_EDGE_LENGTH = 1.0f;
|
||||
|
||||
/**
|
||||
* Road configuration parameters — global settings that apply to the whole
|
||||
* road network owned by a single TerrainComponent. These values are
|
||||
@@ -523,12 +532,32 @@ struct RoadGraph {
|
||||
return -1;
|
||||
|
||||
float clampedT = std::max(0.0f, std::min(1.0f, t));
|
||||
Ogre::Vector3 newPos = na->position +
|
||||
Ogre::Vector3 rawPos = na->position +
|
||||
(nb->position - na->position) * clampedT;
|
||||
|
||||
/* M5.7: snap the split point to an integer number of
|
||||
* ROAD_MIN_EDGE_LENGTH units from nodeA along the edge so both
|
||||
* resulting half-edges have integer lengths. If the total edge
|
||||
* length is less than 2*ROAD_MIN_EDGE_LENGTH we snap to the
|
||||
* midpoint and let the geometry system's UV scaling compensate. */
|
||||
Ogre::Vector3 dir = nb->position - na->position;
|
||||
dir.y = 0.0f;
|
||||
float edgeLen = dir.length();
|
||||
Ogre::Vector3 newPos;
|
||||
if (edgeLen >= 2.0f * ROAD_MIN_EDGE_LENGTH)
|
||||
newPos = snapToIntegerLength(na->position, rawPos);
|
||||
else
|
||||
newPos = na->position + dir * 0.5f;
|
||||
|
||||
float actualT = (edgeLen > 0.001f) ?
|
||||
(newPos - na->position).length() / edgeLen :
|
||||
clampedT;
|
||||
float newYOffset = na->verticalOffset +
|
||||
(nb->verticalOffset - na->verticalOffset) * clampedT +
|
||||
(nb->verticalOffset - na->verticalOffset) *
|
||||
actualT +
|
||||
oldEdge.roadLevelA +
|
||||
(oldEdge.roadLevelB - oldEdge.roadLevelA) * clampedT;
|
||||
(oldEdge.roadLevelB - oldEdge.roadLevelA) *
|
||||
actualT;
|
||||
/* roadLevel values are relative to the node Y, so the new node's
|
||||
* verticalOffset must keep the road surface continuous. Subtract
|
||||
* the terrain height at the new XZ if it is known; otherwise keep
|
||||
@@ -551,7 +580,13 @@ struct RoadGraph {
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an edge between two existing nodes if one does not already exist.
|
||||
* Create an edge between two existing nodes if one does not already
|
||||
* exist (M5.7).
|
||||
*
|
||||
* If the horizontal distance between the two nodes is less than
|
||||
* ROAD_MIN_EDGE_LENGTH a warning is logged via Ogre::LogManager
|
||||
* but the edge is still created — the geometry system's UV scaling
|
||||
* handles fractional and sub-unit lengths.
|
||||
*
|
||||
* @return index of the edge, or -1 on failure.
|
||||
*/
|
||||
@@ -560,6 +595,24 @@ struct RoadGraph {
|
||||
int existing = findEdgeIndex(nodeA, nodeB);
|
||||
if (existing >= 0)
|
||||
return existing;
|
||||
|
||||
const RoadNode *na = findNodeById(nodeA);
|
||||
const RoadNode *nb = findNodeById(nodeB);
|
||||
if (na && nb) {
|
||||
float dx = nb->position.x - na->position.x;
|
||||
float dz = nb->position.z - na->position.z;
|
||||
float dist = std::sqrt(dx * dx + dz * dz);
|
||||
if (dist < ROAD_MIN_EDGE_LENGTH)
|
||||
Ogre::LogManager::getSingleton().logMessage(
|
||||
"RoadGraph: edge between nodes " +
|
||||
std::to_string(nodeA) + " and " +
|
||||
std::to_string(nodeB) +
|
||||
" is " + std::to_string(dist) +
|
||||
" units (< " +
|
||||
std::to_string(ROAD_MIN_EDGE_LENGTH) +
|
||||
"); UV scaling will compensate");
|
||||
}
|
||||
|
||||
return addEdge(nodeA, nodeB);
|
||||
}
|
||||
|
||||
@@ -601,11 +654,25 @@ struct RoadGraph {
|
||||
* - Edge endpoints reference existing node IDs.
|
||||
* - Node IDs are unique.
|
||||
* - No edge connects a node to itself.
|
||||
* - No edge is shorter than ROAD_MIN_EDGE_LENGTH (M5.7).
|
||||
* - No wedge is sharper than 30 degrees or wider than 270 degrees
|
||||
* (M5.5 angle constraint; editor operations reject such wedges,
|
||||
* this catches hand-edited or legacy scenes).
|
||||
*/
|
||||
bool validate(std::string *error = nullptr) const;
|
||||
|
||||
/**
|
||||
* Snap a world-space position to an integer number of
|
||||
* ROAD_MIN_EDGE_LENGTH units from @c anchor along the horizontal
|
||||
* line connecting them (M5.7).
|
||||
*
|
||||
* If the distance is less than ROAD_MIN_EDGE_LENGTH the position is
|
||||
* returned unchanged. Only the X and Z components are adjusted;
|
||||
* Y is left as-is (the caller should snap Y to terrain separately).
|
||||
*/
|
||||
static Ogre::Vector3
|
||||
snapToIntegerLength(const Ogre::Vector3 &anchor,
|
||||
const Ogre::Vector3 &pos);
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -665,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. */
|
||||
@@ -685,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;
|
||||
};
|
||||
|
||||
@@ -704,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).
|
||||
@@ -805,6 +871,30 @@ inline void enumerateWedges(const RoadGraph &graph,
|
||||
}
|
||||
}
|
||||
|
||||
inline Ogre::Vector3
|
||||
RoadGraph::snapToIntegerLength(const Ogre::Vector3 &anchor,
|
||||
const Ogre::Vector3 &pos)
|
||||
{
|
||||
float dx = pos.x - anchor.x;
|
||||
float dz = pos.z - anchor.z;
|
||||
float dist = std::sqrt(dx * dx + dz * dz);
|
||||
|
||||
if (dist < ROAD_MIN_EDGE_LENGTH)
|
||||
return pos;
|
||||
|
||||
/* Round to the nearest integer multiple of ROAD_MIN_EDGE_LENGTH. */
|
||||
float snappedDist =
|
||||
std::round(dist / ROAD_MIN_EDGE_LENGTH) * ROAD_MIN_EDGE_LENGTH;
|
||||
if (snappedDist < ROAD_MIN_EDGE_LENGTH)
|
||||
snappedDist = ROAD_MIN_EDGE_LENGTH;
|
||||
|
||||
float scale = snappedDist / dist;
|
||||
Ogre::Vector3 result = pos;
|
||||
result.x = anchor.x + dx * scale;
|
||||
result.z = anchor.z + dz * scale;
|
||||
return result;
|
||||
}
|
||||
|
||||
inline bool RoadGraph::validate(std::string *error) const
|
||||
{
|
||||
for (const auto &e : edges) {
|
||||
@@ -829,6 +919,30 @@ inline bool RoadGraph::validate(std::string *error) const
|
||||
std::to_string(e.nodeA);
|
||||
return false;
|
||||
}
|
||||
|
||||
/* M5.7: check minimum edge length. */
|
||||
const RoadNode *na = findNodeById(e.nodeA);
|
||||
const RoadNode *nb = findNodeById(e.nodeB);
|
||||
if (na && nb) {
|
||||
float dx = nb->position.x - na->position.x;
|
||||
float dz = nb->position.z - na->position.z;
|
||||
float dist = std::sqrt(dx * dx + dz * dz);
|
||||
if (dist < ROAD_MIN_EDGE_LENGTH) {
|
||||
if (error)
|
||||
*error =
|
||||
"Edge between nodes " +
|
||||
std::to_string(e.nodeA) +
|
||||
" and " +
|
||||
std::to_string(e.nodeB) +
|
||||
" is too short (" +
|
||||
std::to_string(dist) +
|
||||
" < " +
|
||||
std::to_string(
|
||||
ROAD_MIN_EDGE_LENGTH) +
|
||||
")";
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (size_t i = 0; i < nodes.size(); ++i) {
|
||||
@@ -861,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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -328,7 +328,7 @@ void DialogueSystem::renderDialogueBox()
|
||||
// Speaker name
|
||||
if (!m_speaker.empty()) {
|
||||
if (m_speakerFont)
|
||||
ImGui::PushFont(m_speakerFont);
|
||||
ImGui::PushFont(m_speakerFont, m_speakerFont->LegacySize);
|
||||
ImGui::TextColored(ImVec4(0.8f, 0.8f, 1.0f, 1.0f), "%s",
|
||||
m_speaker.c_str());
|
||||
if (m_speakerFont)
|
||||
@@ -338,7 +338,7 @@ void DialogueSystem::renderDialogueBox()
|
||||
|
||||
// Narration text
|
||||
if (m_dialogueFont)
|
||||
ImGui::PushFont(m_dialogueFont);
|
||||
ImGui::PushFont(m_dialogueFont, m_dialogueFont->LegacySize);
|
||||
|
||||
ImGui::TextWrapped("%s", m_text.c_str());
|
||||
|
||||
|
||||
@@ -126,7 +126,7 @@ void PauseMenuSystem::renderMenu()
|
||||
ImGuiWindowFlags_NoFocusOnAppearing);
|
||||
|
||||
if (m_menuFont)
|
||||
ImGui::PushFont(m_menuFont);
|
||||
ImGui::PushFont(m_menuFont, m_menuFont->LegacySize);
|
||||
|
||||
struct ButtonData {
|
||||
const char *label;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
#include "RoadSystem.hpp"
|
||||
#include "TerrainSystem.hpp"
|
||||
#include "../components/Terrain.hpp"
|
||||
#include "../components/Transform.hpp"
|
||||
#include "../components/RoadGraph.hpp"
|
||||
@@ -9,6 +10,7 @@
|
||||
#include "../components/Lod.hpp"
|
||||
#include "../components/PhysicsCollider.hpp"
|
||||
#include "../physics/physics.h"
|
||||
#include "PrefabSystem.hpp"
|
||||
#include <OgreTerrainGroup.h>
|
||||
#include <OgreMaterialManager.h>
|
||||
#include <algorithm>
|
||||
@@ -56,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()
|
||||
@@ -86,6 +95,7 @@ void RoadSystem::destroyManualObjects()
|
||||
destroyManual(m_edgeLines);
|
||||
destroyManual(m_widthIndicators);
|
||||
destroyManual(m_selectionHighlight);
|
||||
destroyManual(m_debugWedgeObject);
|
||||
}
|
||||
|
||||
void RoadSystem::setTerrainEntity(flecs::entity entity)
|
||||
@@ -127,6 +137,11 @@ void RoadSystem::setPhysics(JoltPhysicsWrapper *physics)
|
||||
m_physics = physics;
|
||||
}
|
||||
|
||||
void RoadSystem::setTerrainSystem(TerrainSystem *terrainSystem)
|
||||
{
|
||||
m_terrainSystem = terrainSystem;
|
||||
}
|
||||
|
||||
flecs::entity RoadSystem::getTerrainEntity() const
|
||||
{
|
||||
return m_world.entity(m_terrainEntityId);
|
||||
@@ -160,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;
|
||||
@@ -214,8 +247,20 @@ void RoadSystem::update(float deltaTime)
|
||||
.markChanged();
|
||||
markNavMeshDirty();
|
||||
createPageCollider(pg, tb.meshName);
|
||||
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
|
||||
@@ -224,7 +269,7 @@ Ogre::Vector3 RoadSystem::getNodePosition(int nodeId) const
|
||||
if (!terrain.is_alive() || !terrain.has<TerrainComponent>())
|
||||
return Ogre::Vector3::ZERO;
|
||||
|
||||
const auto &rg = terrain.get<TerrainComponent>().roadGraph;
|
||||
const auto &rg = m_world.entity(m_terrainEntityId).get<TerrainComponent>().roadGraph;
|
||||
const RoadNode *n = rg.findNodeById(nodeId);
|
||||
if (!n)
|
||||
return Ogre::Vector3::ZERO;
|
||||
@@ -238,7 +283,7 @@ bool RoadSystem::getEdgePositions(int edgeIndex, Ogre::Vector3 &outA,
|
||||
if (!terrain.is_alive() || !terrain.has<TerrainComponent>())
|
||||
return false;
|
||||
|
||||
const auto &rg = terrain.get<TerrainComponent>().roadGraph;
|
||||
const auto &rg = m_world.entity(m_terrainEntityId).get<TerrainComponent>().roadGraph;
|
||||
if (edgeIndex < 0 || (size_t)edgeIndex >= rg.edges.size())
|
||||
return false;
|
||||
|
||||
@@ -267,7 +312,7 @@ bool RoadSystem::pageKeyForNode(int nodeId, uint64_t &outKey) const
|
||||
return false;
|
||||
|
||||
const RoadNode *n =
|
||||
terrain.get<TerrainComponent>().roadGraph.findNodeById(nodeId);
|
||||
m_world.entity(m_terrainEntityId).get<TerrainComponent>().roadGraph.findNodeById(nodeId);
|
||||
if (!n)
|
||||
return false;
|
||||
|
||||
@@ -298,7 +343,7 @@ void RoadSystem::reassignWedges()
|
||||
if (!terrain.is_alive() || !terrain.has<TerrainComponent>())
|
||||
return;
|
||||
|
||||
const RoadGraph &rg = terrain.get<TerrainComponent>().roadGraph;
|
||||
const RoadGraph &rg = m_world.entity(m_terrainEntityId).get<TerrainComponent>().roadGraph;
|
||||
|
||||
std::vector<RoadWedge> wedges;
|
||||
std::vector<RoadStraightSegment> segs;
|
||||
@@ -719,7 +764,7 @@ void RoadSystem::buildNodeMarkers()
|
||||
if (!terrain.is_alive() || !terrain.has<TerrainComponent>())
|
||||
return;
|
||||
|
||||
const RoadGraph &rg = terrain.get<TerrainComponent>().roadGraph;
|
||||
const RoadGraph &rg = m_world.entity(m_terrainEntityId).get<TerrainComponent>().roadGraph;
|
||||
|
||||
m_nodeMarkers->begin("Ogre/AxisGizmo",
|
||||
Ogre::RenderOperation::OT_LINE_LIST);
|
||||
@@ -743,7 +788,7 @@ void RoadSystem::buildEdgeLines()
|
||||
if (!terrain.is_alive() || !terrain.has<TerrainComponent>())
|
||||
return;
|
||||
|
||||
const RoadGraph &rg = terrain.get<TerrainComponent>().roadGraph;
|
||||
const RoadGraph &rg = m_world.entity(m_terrainEntityId).get<TerrainComponent>().roadGraph;
|
||||
|
||||
m_edgeLines->begin("Ogre/AxisGizmo",
|
||||
Ogre::RenderOperation::OT_LINE_LIST);
|
||||
@@ -774,7 +819,7 @@ void RoadSystem::buildWidthIndicators()
|
||||
if (!terrain.is_alive() || !terrain.has<TerrainComponent>())
|
||||
return;
|
||||
|
||||
const RoadGraph &rg = terrain.get<TerrainComponent>().roadGraph;
|
||||
const RoadGraph &rg = m_world.entity(m_terrainEntityId).get<TerrainComponent>().roadGraph;
|
||||
float laneWidth = rg.config.laneWidth;
|
||||
|
||||
m_widthIndicators->begin("Ogre/AxisGizmo",
|
||||
@@ -1484,3 +1529,279 @@ bool RoadSystem::buildSegmentGeometry(const RoadStraightSegment &segment,
|
||||
emitSlab(out, tris, skirts, halfThick, ref);
|
||||
return true;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Roadside prefab spawning (M5.11) */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
void RoadSystem::spawnSidePrefabs(RoadPageGeometry &pg)
|
||||
{
|
||||
if (!m_terrainSystem)
|
||||
return;
|
||||
|
||||
flecs::entity terrain = getTerrainEntity();
|
||||
if (!terrain.is_alive() || !terrain.has<TerrainComponent>())
|
||||
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;
|
||||
|
||||
/* 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;
|
||||
|
||||
/* 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;
|
||||
}
|
||||
|
||||
/* Snap Y to terrain surface. */
|
||||
pos.y = m_terrainSystem->getHeightAt(pos);
|
||||
|
||||
/* 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);
|
||||
|
||||
flecs::entity inst = prefabSys.createInstance(
|
||||
sp.prefabPath, terrain, pos, name);
|
||||
|
||||
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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Terrain compliance (M5.10) */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
void RoadSystem::complyTerrain(TerrainSystem *terrainSystem,
|
||||
float roadThickness, float laneWidth)
|
||||
{
|
||||
if (!terrainSystem || !m_terrainGroup)
|
||||
return;
|
||||
|
||||
/* Walk every loaded page's wedge/segment geometry and write fixup
|
||||
* values at the vertices of the generated road mesh. The fixup
|
||||
* target is the road underside: vertex.y - roadThickness. */
|
||||
for (auto &kv : m_pageGeometry) {
|
||||
RoadPageGeometry &pg = kv.second;
|
||||
|
||||
for (const RoadWedge &wedge : pg.wedges) {
|
||||
Procedural::TriangleBuffer buf;
|
||||
if (!buildWedgeGeometry(wedge, m_world.entity(m_terrainEntityId).get<TerrainComponent>().roadGraph, buf))
|
||||
continue;
|
||||
|
||||
const auto &verts = buf.getVertices();
|
||||
for (const auto &v : verts) {
|
||||
const Ogre::Vector3 &p = v.mPosition;
|
||||
/* Only write for top-surface vertices
|
||||
* (Y near +roadThickness/2). */
|
||||
if (p.y < 0.0f)
|
||||
continue;
|
||||
|
||||
float targetY = p.y - roadThickness;
|
||||
terrainSystem->writeFixup(p.x, p.z,
|
||||
targetY);
|
||||
}
|
||||
}
|
||||
|
||||
for (const RoadStraightSegment &seg : pg.segments) {
|
||||
Procedural::TriangleBuffer buf;
|
||||
if (!buildSegmentGeometry(seg, m_world.entity(m_terrainEntityId).get<TerrainComponent>().roadGraph, buf))
|
||||
continue;
|
||||
|
||||
const auto &verts = buf.getVertices();
|
||||
for (const auto &v : verts) {
|
||||
const Ogre::Vector3 &p = v.mPosition;
|
||||
if (p.y < 0.0f)
|
||||
continue;
|
||||
|
||||
float targetY = p.y - roadThickness;
|
||||
terrainSystem->writeFixup(p.x, p.z,
|
||||
targetY);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Mark affected pages dirty so they rebuild with fixup data. */
|
||||
if (m_terrainGroup) {
|
||||
for (auto &kv : m_pageGeometry) {
|
||||
RoadPageGeometry &pg = kv.second;
|
||||
terrainSystem->markPageDirty(pg.pageX, pg.pageY);
|
||||
}
|
||||
}
|
||||
|
||||
/* Persist fixups to disk. */
|
||||
terrainSystem->saveFixups();
|
||||
|
||||
Ogre::LogManager::getSingleton().logMessage(
|
||||
"RoadSystem: terrain compliance applied for " +
|
||||
Ogre::StringConverter::toString(
|
||||
(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;
|
||||
|
||||
@@ -174,6 +180,38 @@ public:
|
||||
*/
|
||||
void setPhysics(JoltPhysicsWrapper *physics);
|
||||
|
||||
/**
|
||||
* Provide the terrain system for height queries (M5.11).
|
||||
*
|
||||
* Called by TerrainSystem right after construction. Passing nullptr
|
||||
* disables roadside prefab spawning.
|
||||
*/
|
||||
void setTerrainSystem(class TerrainSystem *terrainSystem);
|
||||
|
||||
/**
|
||||
* Terrain compliance (M5.10): writes fixup values under every
|
||||
* road surface vertex so the terrain matches the road underside.
|
||||
*
|
||||
* @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.
|
||||
*/
|
||||
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).
|
||||
@@ -215,6 +253,9 @@ private:
|
||||
void createPageCollider(RoadPageGeometry &pg, const std::string &meshName);
|
||||
void destroyPageCollider(RoadPageGeometry &pg);
|
||||
|
||||
/* Roadside prefab spawning (M5.11). */
|
||||
void spawnSidePrefabs(RoadPageGeometry &pg);
|
||||
|
||||
bool loadTemplateFromMesh(const std::string &meshName);
|
||||
void buildFallbackTemplate(float roadThickness);
|
||||
|
||||
@@ -246,12 +287,26 @@ 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. */
|
||||
class TerrainSystem *m_terrainSystem = nullptr;
|
||||
|
||||
Procedural::TriangleBuffer m_templateBuffer;
|
||||
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;
|
||||
|
||||
@@ -4243,6 +4243,7 @@ nlohmann::json SceneSerializer::serializeTerrain(flecs::entity entity)
|
||||
ts->saveSceneHeightmap(tc);
|
||||
ts->saveSceneBlendMaps(tc);
|
||||
ts->saveSceneAuxMaps(tc);
|
||||
ts->saveFixups();
|
||||
}
|
||||
|
||||
return json;
|
||||
@@ -4355,6 +4356,8 @@ void SceneSerializer::deserializeTerrain(flecs::entity entity,
|
||||
}
|
||||
}
|
||||
|
||||
tc.roadGraph.bumpVersion();
|
||||
|
||||
entity.set<TerrainComponent>(tc);
|
||||
|
||||
TerrainSystem *ts = TerrainSystem::getInstance();
|
||||
|
||||
@@ -135,7 +135,7 @@ void StartupMenuSystem::renderMenu(StartupMenuComponent &sm)
|
||||
ImGuiWindowFlags_NoFocusOnAppearing);
|
||||
|
||||
if (m_menuFont)
|
||||
ImGui::PushFont(m_menuFont);
|
||||
ImGui::PushFont(m_menuFont, m_menuFont->LegacySize);
|
||||
|
||||
struct ButtonData {
|
||||
const char *label;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -474,6 +477,225 @@ bool TerrainSystem::saveSceneHeightmap(const TerrainComponent &tc)
|
||||
return saveHeightmap(getHeightmapPath(tc));
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Fixup chunks (M5.9.5) */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
std::string TerrainSystem::getFixupDir(const TerrainComponent &tc) const
|
||||
{
|
||||
return "heightmaps/" + Ogre::StringConverter::toString(tc.terrainId) +
|
||||
"/terrain_fixup";
|
||||
}
|
||||
|
||||
std::string TerrainSystem::fixupChunkPath(int chunkX, int chunkZ) const
|
||||
{
|
||||
return m_fixupDir + "/x" + Ogre::StringConverter::toString(chunkX) +
|
||||
"_z" + Ogre::StringConverter::toString(chunkZ) + ".bin";
|
||||
}
|
||||
|
||||
bool TerrainSystem::loadFixupChunk(int chunkX, int chunkZ,
|
||||
std::vector<float> &out) const
|
||||
{
|
||||
std::string path = fixupChunkPath(chunkX, chunkZ);
|
||||
std::ifstream file(path, std::ios::binary);
|
||||
if (!file)
|
||||
return false;
|
||||
|
||||
out.resize(FIXUP_CHUNK_RES * FIXUP_CHUNK_RES);
|
||||
file.read(reinterpret_cast<char *>(out.data()),
|
||||
FIXUP_CHUNK_RES * FIXUP_CHUNK_RES * sizeof(float));
|
||||
bool ok = file.good();
|
||||
if (!ok)
|
||||
out.clear();
|
||||
return ok;
|
||||
}
|
||||
|
||||
const TerrainSystem::FixupChunk *
|
||||
TerrainSystem::findFixupChunkLocked(int chunkX, int chunkZ) const
|
||||
{
|
||||
auto it = m_fixupChunks.find({chunkX, chunkZ});
|
||||
if (it != m_fixupChunks.end())
|
||||
return &it->second;
|
||||
|
||||
/* Lazy-load from disk. m_heightmapMutex must be held by the caller. */
|
||||
std::vector<float> samples;
|
||||
if (loadFixupChunk(chunkX, chunkZ, samples)) {
|
||||
auto &chunk = m_fixupChunks[{chunkX, chunkZ}];
|
||||
chunk.samples = std::move(samples);
|
||||
chunk.dirty = false;
|
||||
return &chunk;
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
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;
|
||||
int chunkX = (int)std::floor(worldX / chunkWorldSize);
|
||||
int chunkZ = (int)std::floor(worldZ / chunkWorldSize);
|
||||
|
||||
const FixupChunk *chunk = findFixupChunkLocked(chunkX, chunkZ);
|
||||
if (!chunk || chunk->samples.empty())
|
||||
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;
|
||||
|
||||
int x0 = (int)std::floor(cellX);
|
||||
int z0 = (int)std::floor(cellZ);
|
||||
int x1 = x0 + 1;
|
||||
int z1 = z0 + 1;
|
||||
|
||||
int r = FIXUP_CHUNK_RES;
|
||||
x0 = std::max(0, std::min(x0, r - 1));
|
||||
x1 = std::max(0, std::min(x1, r - 1));
|
||||
z0 = std::max(0, std::min(z0, r - 1));
|
||||
z1 = std::max(0, std::min(z1, r - 1));
|
||||
|
||||
float tx = cellX - (float)x0;
|
||||
float tz = cellZ - (float)z0;
|
||||
|
||||
float h00 = chunk->samples[z0 * r + x0];
|
||||
float h10 = chunk->samples[z0 * r + x1];
|
||||
float h01 = chunk->samples[z1 * r + x0];
|
||||
float h11 = chunk->samples[z1 * r + x1];
|
||||
|
||||
/* If all four samples are sentinel, no fixup at this cell. */
|
||||
if (h00 == FIXUP_SENTINEL && h10 == FIXUP_SENTINEL &&
|
||||
h01 == FIXUP_SENTINEL && h11 == FIXUP_SENTINEL)
|
||||
return FIXUP_SENTINEL;
|
||||
|
||||
/* Replace sentinel with fallback so partial cells still blend. */
|
||||
if (h00 == FIXUP_SENTINEL)
|
||||
h00 = 0.0f;
|
||||
if (h10 == FIXUP_SENTINEL)
|
||||
h10 = 0.0f;
|
||||
if (h01 == FIXUP_SENTINEL)
|
||||
h01 = 0.0f;
|
||||
if (h11 == FIXUP_SENTINEL)
|
||||
h11 = 0.0f;
|
||||
|
||||
return (1.0f - tx) * (1.0f - tz) * h00 + tx * (1.0f - tz) * h10 +
|
||||
(1.0f - tx) * tz * h01 + tx * tz * h11;
|
||||
}
|
||||
|
||||
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;
|
||||
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);
|
||||
}
|
||||
|
||||
/* 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;
|
||||
|
||||
int x0 = (int)std::floor(cellX);
|
||||
int z0 = (int)std::floor(cellZ);
|
||||
int x1 = x0 + 1;
|
||||
int z1 = z0 + 1;
|
||||
|
||||
int r = FIXUP_CHUNK_RES;
|
||||
if (x0 < 0 || x1 >= r || z0 < 0 || z1 >= r)
|
||||
return;
|
||||
|
||||
chunk.samples[z0 * r + x0] = height;
|
||||
chunk.samples[z0 * r + x1] = height;
|
||||
chunk.samples[z1 * r + x0] = height;
|
||||
chunk.samples[z1 * r + x1] = height;
|
||||
chunk.dirty = true;
|
||||
}
|
||||
|
||||
bool TerrainSystem::saveFixups()
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(m_heightmapMutex);
|
||||
|
||||
for (auto &kv : m_fixupChunks) {
|
||||
if (!kv.second.dirty || kv.second.samples.empty())
|
||||
continue;
|
||||
|
||||
int chunkX = kv.first.first;
|
||||
int chunkZ = kv.first.second;
|
||||
std::string path = fixupChunkPath(chunkX, chunkZ);
|
||||
|
||||
std::filesystem::path p(path);
|
||||
std::filesystem::create_directories(p.parent_path());
|
||||
|
||||
std::ofstream file(path, std::ios::binary);
|
||||
if (!file) {
|
||||
Ogre::LogManager::getSingleton().logMessage(
|
||||
"Terrain: failed to write fixup chunk: " +
|
||||
path);
|
||||
continue;
|
||||
}
|
||||
|
||||
file.write(
|
||||
reinterpret_cast<const char *>(kv.second.samples.data()),
|
||||
kv.second.samples.size() * sizeof(float));
|
||||
kv.second.dirty = false;
|
||||
|
||||
Ogre::LogManager::getSingleton().logMessage(
|
||||
"Terrain: saved fixup chunk x" +
|
||||
Ogre::StringConverter::toString(chunkX) + "_z" +
|
||||
Ogre::StringConverter::toString(chunkZ));
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
void TerrainSystem::clearAllFixups()
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(m_heightmapMutex);
|
||||
|
||||
/* Delete all fixup files. */
|
||||
for (auto &kv : m_fixupChunks) {
|
||||
std::string path =
|
||||
fixupChunkPath(kv.first.first, kv.first.second);
|
||||
std::remove(path.c_str());
|
||||
}
|
||||
|
||||
m_fixupChunks.clear();
|
||||
|
||||
/* Mark all loaded pages dirty so they re-sample without fixups. */
|
||||
if (mTerrainGroup) {
|
||||
for (long py = m_pageMinY; py <= m_pageMaxY; ++py) {
|
||||
for (long px = m_pageMinX; px <= m_pageMaxX; ++px) {
|
||||
Ogre::Terrain *terrain =
|
||||
mTerrainGroup->getTerrain(px, py);
|
||||
if (terrain && terrain->isLoaded())
|
||||
markPageDirty(px, py);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ogre::LogManager::getSingleton().logMessage(
|
||||
"Terrain: cleared all fixup chunks");
|
||||
}
|
||||
|
||||
size_t TerrainSystem::getFixupChunkCount() const
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(m_heightmapMutex);
|
||||
return m_fixupChunks.size();
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Resolution change helpers (M4.6) */
|
||||
/* ------------------------------------------------------------------ */
|
||||
@@ -1530,6 +1752,12 @@ float TerrainSystem::sampleHeightAtLocked(long worldX, long worldZ) const
|
||||
if (m_detailNoise.enabled)
|
||||
h += computeDetailNoise(worldX, worldZ, m_detailNoise);
|
||||
|
||||
/* 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);
|
||||
if (fixup != FIXUP_SENTINEL)
|
||||
return fixup;
|
||||
|
||||
return h;
|
||||
}
|
||||
|
||||
@@ -1664,6 +1892,8 @@ void TerrainSystem::activate(TerrainComponent &tc, TransformComponent &xform,
|
||||
m_heightmapWorldSize =
|
||||
(float)(m_pageMaxX - m_pageMinX + 1) * tc.worldSize;
|
||||
|
||||
m_fixupDir = getFixupDir(tc);
|
||||
|
||||
ensureHeightmapLoaded(tc);
|
||||
|
||||
/* Reuse TerrainGlobalOptions if it survived a previous
|
||||
@@ -1768,6 +1998,7 @@ void TerrainSystem::activate(TerrainComponent &tc, TransformComponent &xform,
|
||||
m_roadSystem = std::make_unique<RoadSystem>(m_world, m_sceneMgr);
|
||||
m_roadSystem->setTerrainGroup(mTerrainGroup);
|
||||
m_roadSystem->setPhysics(m_physics);
|
||||
m_roadSystem->setTerrainSystem(this);
|
||||
m_roadSystem->setTerrainEntity(m_world.entity(m_terrainEntityId));
|
||||
m_roadSystem->setVisualAidsVisible(m_roadEditMode);
|
||||
|
||||
@@ -1804,6 +2035,8 @@ void TerrainSystem::deactivate()
|
||||
m_heightmapRes = 0;
|
||||
m_auxMapData.clear();
|
||||
m_dirtyAuxMaps.clear();
|
||||
m_fixupChunks.clear();
|
||||
m_fixupDir.clear();
|
||||
|
||||
/* Clean up sculpt previews without trying to reload terrain
|
||||
* pages (we're shutting down the whole terrain). */
|
||||
@@ -1979,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());
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
@@ -2122,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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -2319,6 +2318,108 @@ bool TerrainTestRunner::testMemoryStability(EditorApp &app)
|
||||
return true;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Fixup chunks (M5.9.5) */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
bool TerrainTestRunner::testFixupChunks(EditorApp &app, TerrainSystem *ts)
|
||||
{
|
||||
if (ts->isActive())
|
||||
ts->deactivate();
|
||||
|
||||
flecs::entity e = createTerrainEntity(app);
|
||||
pumpFrames(app, ts, 5);
|
||||
|
||||
if (!ts->isActive()) {
|
||||
Ogre::LogManager::getSingleton().logMessage(
|
||||
"TerrainTests: FAIL - terrain not active for fixup test");
|
||||
destroyTerrainEntity(app, e);
|
||||
pumpFrames(app, ts, 1);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (ts->getFixupChunkCount() != 0) {
|
||||
Ogre::LogManager::getSingleton().logMessage(
|
||||
"TerrainTests: FAIL - expected 0 fixup chunks initially");
|
||||
destroyTerrainEntity(app, e);
|
||||
pumpFrames(app, ts, 1);
|
||||
return false;
|
||||
}
|
||||
|
||||
float wx = 500.0f, wz = 500.0f;
|
||||
/* Use sampleHeightAt to verify fixup — it reads through the
|
||||
* fixup-aware path; getHeightAt queries Ogre's terrain
|
||||
* directly and may cache old page data. */
|
||||
float baseHeight = ts->sampleHeightAt((long)wx, (long)wz);
|
||||
float fixupValue = baseHeight + 50.0f;
|
||||
|
||||
ts->writeFixup(wx, wz, fixupValue);
|
||||
|
||||
if (ts->getFixupChunkCount() != 1) {
|
||||
Ogre::LogManager::getSingleton().logMessage(
|
||||
"TerrainTests: FAIL - expected 1 fixup chunk after writeFixup");
|
||||
destroyTerrainEntity(app, e);
|
||||
pumpFrames(app, ts, 1);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (ts->getTerrainGroup()) {
|
||||
Ogre::TerrainGroup *group = ts->getTerrainGroup();
|
||||
long px = 0, py = 0;
|
||||
group->convertWorldPositionToTerrainSlot(
|
||||
Ogre::Vector3(wx, 0, wz), &px, &py);
|
||||
Ogre::Terrain *terrain = group->getTerrain(px, py);
|
||||
if (terrain && terrain->isLoaded()) {
|
||||
ts->markPageDirty(px, py);
|
||||
pumpFrames(app, ts, 5);
|
||||
}
|
||||
}
|
||||
|
||||
float sampledHeight = ts->sampleHeightAt((long)wx, (long)wz);
|
||||
if (std::abs(sampledHeight - fixupValue) > 0.1f) {
|
||||
Ogre::LogManager::getSingleton().logMessage(
|
||||
"TerrainTests: FAIL - sampleHeightAt returned " +
|
||||
Ogre::StringConverter::toString(sampledHeight) +
|
||||
", expected " +
|
||||
Ogre::StringConverter::toString(fixupValue));
|
||||
destroyTerrainEntity(app, e);
|
||||
pumpFrames(app, ts, 1);
|
||||
return false;
|
||||
}
|
||||
|
||||
ts->saveFixups();
|
||||
ts->deactivate();
|
||||
pumpFrames(app, ts, 5);
|
||||
|
||||
if (ts->isActive()) {
|
||||
float reloadedHeight =
|
||||
ts->sampleHeightAt((long)wx, (long)wz);
|
||||
if (std::abs(reloadedHeight - fixupValue) > 0.1f) {
|
||||
Ogre::LogManager::getSingleton().logMessage(
|
||||
"TerrainTests: FAIL - after reload, sampleHeightAt wrong");
|
||||
destroyTerrainEntity(app, e);
|
||||
pumpFrames(app, ts, 1);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
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: fixup chunk operations test passed");
|
||||
return true;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Logging */
|
||||
/* ------------------------------------------------------------------ */
|
||||
@@ -2386,6 +2487,11 @@ int TerrainTestRunner::run(EditorApp &app, int iterations)
|
||||
{ "roadPageAssignment", testRoadPageAssignment },
|
||||
{ "roadWedgeGeometry", testRoadWedgeGeometry },
|
||||
{ "roadPageMeshes", testRoadPageMeshes },
|
||||
{ "fixupChunks", testFixupChunks },
|
||||
{ "roadEdgeLength", testRoadEdgeLengthConstraint },
|
||||
{ "terrainCompliance", testTerrainCompliance },
|
||||
{ "roadColliderInteraction", testRoadColliderInteraction },
|
||||
{ "roadSidePrefabs", testRoadSidePrefabs },
|
||||
|
||||
};
|
||||
|
||||
@@ -2444,3 +2550,522 @@ int TerrainTestRunner::run(EditorApp &app, int iterations)
|
||||
<< std::endl;
|
||||
return 0;
|
||||
}
|
||||
|
||||
bool TerrainTestRunner::testRoadEdgeLengthConstraint(EditorApp &app,
|
||||
TerrainSystem *ts)
|
||||
{
|
||||
(void)app;
|
||||
(void)ts;
|
||||
|
||||
using namespace Ogre;
|
||||
|
||||
/* --- snapToIntegerLength --- */
|
||||
|
||||
/* Basic snap: 3.7 units → 4.0 units. */
|
||||
Vector3 snapped = RoadGraph::snapToIntegerLength(
|
||||
Vector3::ZERO, Vector3(3.7f, 0, 0));
|
||||
if (std::abs(snapped.x - 4.0f) > 0.001f) {
|
||||
LogManager::getSingleton().logMessage(
|
||||
"TerrainTests: FAIL - snapToIntegerLength: expected 4.0, got " +
|
||||
StringConverter::toString(snapped.x));
|
||||
return false;
|
||||
}
|
||||
|
||||
/* Distance < 1.0 is returned unchanged. */
|
||||
snapped = RoadGraph::snapToIntegerLength(
|
||||
Vector3::ZERO, Vector3(0.5f, 0, 0));
|
||||
if (std::abs(snapped.x - 0.5f) > 0.001f) {
|
||||
LogManager::getSingleton().logMessage(
|
||||
"TerrainTests: FAIL - snapToIntegerLength: sub-unit should pass through");
|
||||
return false;
|
||||
}
|
||||
|
||||
/* 5.2 units → 5.0 units (round nearest). */
|
||||
snapped = RoadGraph::snapToIntegerLength(
|
||||
Vector3::ZERO, Vector3(5.2f, 0, 0));
|
||||
if (std::abs(snapped.x - 5.0f) > 0.001f) {
|
||||
LogManager::getSingleton().logMessage(
|
||||
"TerrainTests: FAIL - snapToIntegerLength: expected 5.0, got " +
|
||||
StringConverter::toString(snapped.x));
|
||||
return false;
|
||||
}
|
||||
|
||||
/* Diagonal: (4.24, 0, 4.24) is 6 units from origin → snaps to 6.0. */
|
||||
snapped = RoadGraph::snapToIntegerLength(
|
||||
Vector3::ZERO, Vector3(4.24f, 0, 4.24f));
|
||||
float dist = std::sqrt(snapped.x * snapped.x + snapped.z * snapped.z);
|
||||
if (std::abs(dist - 6.0f) > 0.01f) {
|
||||
LogManager::getSingleton().logMessage(
|
||||
"TerrainTests: FAIL - snapToIntegerLength diagonal: expected 6.0, got " +
|
||||
StringConverter::toString(dist));
|
||||
return false;
|
||||
}
|
||||
|
||||
/* Y is never changed. */
|
||||
snapped = RoadGraph::snapToIntegerLength(
|
||||
Vector3(0, 5, 0), Vector3(8.7f, 42, 0));
|
||||
if (std::abs(snapped.y - 42) > 0.001f) {
|
||||
LogManager::getSingleton().logMessage(
|
||||
"TerrainTests: FAIL - snapToIntegerLength should preserve Y");
|
||||
return false;
|
||||
}
|
||||
|
||||
/* --- splitEdge integer-length snapping --- */
|
||||
|
||||
/* Case 1: edge long enough (10 units). splitEdge at t=0.37 would
|
||||
* place the new node at x=3.7. With snapping, it should land at
|
||||
* x=4.0 (nearest integer from node A at 0). */
|
||||
{
|
||||
RoadGraph rg;
|
||||
int n1 = rg.addNode(Vector3(0, 0, 0), 0.0f);
|
||||
int n2 = rg.addNode(Vector3(10, 0, 0), 0.0f);
|
||||
rg.addEdge(n1, n2);
|
||||
int mid = rg.splitEdge(0, 0.37f);
|
||||
if (mid < 0) {
|
||||
LogManager::getSingleton().logMessage(
|
||||
"TerrainTests: FAIL - splitEdge returned -1");
|
||||
return false;
|
||||
}
|
||||
if (rg.edges.size() != 2) {
|
||||
LogManager::getSingleton().logMessage(
|
||||
"TerrainTests: FAIL - splitEdge should produce 2 edges");
|
||||
return false;
|
||||
}
|
||||
const RoadNode *midNode = rg.findNodeById(mid);
|
||||
if (!midNode) {
|
||||
LogManager::getSingleton().logMessage(
|
||||
"TerrainTests: FAIL - splitEdge node not found");
|
||||
return false;
|
||||
}
|
||||
/* Should have snapped to x=4.0, not 3.7. */
|
||||
if (std::abs(midNode->position.x - 4.0f) > 0.01f) {
|
||||
LogManager::getSingleton().logMessage(
|
||||
"TerrainTests: FAIL - splitEdge snap: expected 4.0, got " +
|
||||
StringConverter::toString(midNode->position.x));
|
||||
return false;
|
||||
}
|
||||
/* Both resulting edges should pass validate(). */
|
||||
std::string error;
|
||||
if (!rg.validate(&error)) {
|
||||
LogManager::getSingleton().logMessage(
|
||||
"TerrainTests: FAIL - splitEdge result invalid: " +
|
||||
error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/* Case 2: edge too short for two full units (< 2.0).
|
||||
* splitEdge snaps to midpoint. */
|
||||
{
|
||||
RoadGraph rg;
|
||||
int n1 = rg.addNode(Vector3(0, 0, 0), 0.0f);
|
||||
int n2 = rg.addNode(Vector3(1.5f, 0, 0), 0.0f);
|
||||
rg.addEdge(n1, n2);
|
||||
int mid = rg.splitEdge(0, 0.3f);
|
||||
const RoadNode *midNode = rg.findNodeById(mid);
|
||||
if (std::abs(midNode->position.x - 0.75f) > 0.01f) {
|
||||
LogManager::getSingleton().logMessage(
|
||||
"TerrainTests: FAIL - short edge split should go to midpoint");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/* --- validate rejects short edges --- */
|
||||
|
||||
{
|
||||
RoadGraph rg;
|
||||
int n1 = rg.addNode(Vector3(0, 0, 0), 0.0f);
|
||||
int n2 = rg.addNode(Vector3(0.5f, 0, 0), 0.0f);
|
||||
rg.addEdge(n1, n2);
|
||||
std::string error;
|
||||
if (rg.validate(&error)) {
|
||||
LogManager::getSingleton().logMessage(
|
||||
"TerrainTests: FAIL - 0.5-unit edge should fail validation");
|
||||
return false;
|
||||
}
|
||||
/* validate should still reject self-edges after M5.7 changes. */
|
||||
RoadGraph rg2;
|
||||
int a = rg2.addNode(Vector3::ZERO, 0.0f);
|
||||
rg2.addEdge(a, a); /* rejected at addEdge, so force it */
|
||||
RoadEdge self;
|
||||
self.nodeA = a;
|
||||
self.nodeB = a;
|
||||
rg2.edges.push_back(self);
|
||||
if (rg2.validate(&error)) {
|
||||
LogManager::getSingleton().logMessage(
|
||||
"TerrainTests: FAIL - self-edge should fail validation");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
LogManager::getSingleton().logMessage(
|
||||
"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;
|
||||
}
|
||||
|
||||
|
||||
@@ -77,6 +77,11 @@ private:
|
||||
static bool testRoadPageAssignment(EditorApp &app, TerrainSystem *ts);
|
||||
static bool testRoadWedgeGeometry(EditorApp &app, TerrainSystem *ts);
|
||||
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"
|
||||
}
|
||||
}
|
||||
@@ -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", ¤tIdx,
|
||||
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. */
|
||||
@@ -584,13 +630,29 @@ private:
|
||||
}
|
||||
ImGui::SameLine();
|
||||
|
||||
/* No-op placeholder until M5.10 (terrain compliance). */
|
||||
/* Terrain compliance: writes fixup chunks under road surfaces
|
||||
* so the terrain conforms to the road underside (M5.10). */
|
||||
if (ImGui::Button("Comply Terrain to Roads")) {
|
||||
/* intentionally a no-op until M5.10 */
|
||||
RoadSystem *rs = ts->getRoadSystem();
|
||||
if (rs)
|
||||
rs->complyTerrain(
|
||||
ts,
|
||||
tc.roadGraph.config.roadThickness,
|
||||
tc.roadGraph.config.laneWidth);
|
||||
}
|
||||
if (ImGui::IsItemHovered())
|
||||
ImGui::SetTooltip(
|
||||
"Placeholder - implemented in M5.10");
|
||||
"Makes the terrain surface follow the road underside "
|
||||
"through fixup height-override chunks.");
|
||||
|
||||
/* Clear all fixup chunks (M5.9.5). */
|
||||
if (ImGui::Button("Clear All Fixups")) {
|
||||
ts->clearAllFixups();
|
||||
}
|
||||
if (ImGui::IsItemHovered())
|
||||
ImGui::SetTooltip(
|
||||
"Deletes all terrain fixup chunk files and "
|
||||
"restores the base heightmap surface.");
|
||||
|
||||
/* Usage instructions — road editing is not discoverable
|
||||
* without them. */
|
||||
@@ -758,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();
|
||||
@@ -1187,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
|
||||
|
||||
Reference in New Issue
Block a user