snapshot 20260729

This commit is contained in:
2026-07-29 13:20:06 +03:00
parent 50c2395581
commit 4acad712b0
12 changed files with 2894 additions and 83 deletions
+4
View File
@@ -235,6 +235,10 @@ public:
{
return m_characterSpawnerSystem.get();
}
ProceduralMeshSystem *getProceduralMeshSystem() const
{
return m_proceduralMeshSystem.get();
}
StartupMenuSystem *getStartupMenuSystem() const
{
return m_startupMenuSystem.get();
+357 -19
View File
@@ -2119,6 +2119,29 @@ 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.
| 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.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.1 Road data model
**Status**: complete. The annotated road types and a small utility layer live
@@ -2203,6 +2226,26 @@ confirmed every gap from the first audit is implemented, the
(`./editSceneEditor --headless --run-terrain-tests=1`) passes with
`roadDataModel` and `roadSerialization` green.
Interactive verification (2026-07-20) confirmed the editor works end to
end (add/select/move/connect/split/remove nodes, visual aids, gizmo) and
fixed three issues found along the way:
- `RoadSystem` was bound to entity 0: `TerrainSystem::update()` assigned
`m_terrainEntityId` only after `activate()`, and `activate()` re-zeroed
the field through its internal `deactivate()` call, so every road
click handler early-outed on a dead entity and no visual aids were
built. `TerrainSystem::activate()` now takes the terrain entity id as
a parameter and restores the field right after `deactivate()`.
- The per-row "Connect"/"Remove" buttons in the node/edge lists were
overlapped by the full-width row `ImGui::Selectable`, which swallowed
their clicks (pressing "Connect" selected the node instead of
connecting). The selectables are now sized to leave room for the
buttons (`TerrainEditor.hpp::renderRoadSection()`).
- `blendMapSize` persistence was verified working; the reported loss
came from a scene file saved before the field was serialized (the key
was absent from the JSON, so load fell back to the 256 default).
Scenes saved without the key should be re-saved once.
Audit findings (2026-07-18, first audit) — all now implemented:
- ~~Missing: "Comply Terrain to Roads" placeholder button~~ — present as a
@@ -2329,7 +2372,10 @@ The `roadMeshTemplate` mesh is a single Ogre mesh with these conventions (from
section 5.3):
- It occupies exactly 1 unit along the positive X axis (`X ∈ [0, 1]`).
- Its width (Z extent) is 1 unit (`Z ∈ [0, 1]` or `Z ∈ [-1, 0]`).
- Its width (Z extent) is 1 unit (`Z ∈ [0, 1]` for the fallback; a template
file may use `Z ∈ [-1, 0]` instead). Template-space `+Z` is defined as the
**right** side of the edge A→B travel direction (`+X` forward × `+Y` up =
`+Z` right); M5.5/M5.6 lane math uses this sign.
- It is centered vertically on `Y = 0`.
- The top surface is at `Y = +roadThickness / 2` and the bottom surface at
`Y = -roadThickness / 2` relative to the local origin.
@@ -2341,10 +2387,76 @@ local dimensions `1.0 x roadThickness x 1.0`, with the top face at
offset so that `Z ∈ [0, 1]` (one lane width in template space corresponds to one
lane width in world space after scaling).
**Implementation notes (2026-07-20, pre-implementation review)**:
- **Status**: ✅ complete (2026-07-20). Scope confirmed with the user:
provider code only — no `road_segment.mesh` asset is authored in this
milestone; dropping a conforming mesh file into a `General` resource
location later works without code changes. Implemented
`RoadSystem::getRoadTemplate()` (file loader + generated fallback box
with cache); the `editSceneEditor` target builds cleanly and the
headless suite passes with `roadTemplate` green alongside
`roadDataModel` and `roadSerialization`.
- The provider lives in `RoadSystem` (`systems/RoadSystem.hpp/.cpp`) and
returns the template as a `Procedural::TriangleBuffer` (OgreProcedural;
position/normal/uv vertices plus `append`/`applyTransform`/`scale` cover
everything the M5.6 sweep needs):
```cpp
const Procedural::TriangleBuffer &getRoadTemplate(const RoadConfig &cfg);
```
The buffer is cached and rebuilt only when `cfg.roadMeshTemplate` or
`cfg.roadThickness` changes (the fallback box embeds the thickness).
- Loading: resolve `roadMeshTemplate` via `Ogre::MeshManager` in the `General`
resource group; copy POSITION/NORMAL/UV elements and indices from all
submeshes into the buffer (same buffer-locking pattern as
`NormalDebugSystem.cpp`). Missing or empty mesh → log and use the fallback
box. A loaded mesh whose bounds violate the conventions logs a warning but
is used as-is.
- The fallback box is emitted with explicit vertices (not
`Procedural::BoxGenerator`) so its UVs span exactly `(0,0)``(1,1)` across
X/Z on every face.
- **Substitute box confirmed feasible (2026-07-20)** and fully supported as
the prototyping path: `Procedural::TriangleBuffer` accepts explicit
vertex/index data (`getVertices()`/`getIndices()`), so the unit box is
generated in code with no mesh asset, no Blender export, and no resource
file. All later milestones (M5.4M5.10) can be developed and tested
against the generated box; dropping in a conforming
`road_segment.mesh` later only changes the loaded result, never the
interface.
- The start-cap overlap shift (`X < 0.05` → `-0.05` from section 5.3) is a
**generation-time** operation owned by M5.6 (implemented as the
`ROAD_SEAM_OVERLAP` strip extension); nothing is baked into the template.
- Testing: add a headless `TerrainTests` case asserting the fallback template
bounds (`X ∈ [0,1]`, `Y ∈ ±roadThickness/2`, `Z ∈ [0,1]`) and UV range, and
that a missing template file selects the fallback.
---
#### M5.4 Road geometry generation
**Status**: ✅ complete (2026-07-26) — page tracking and wedge bucketing.
`RoadSystem` owns `m_pageGeometry` (one `RoadPageGeometry` entry per loaded
terrain page, keyed by `TerrainGroup::packIndex`), `syncPages()` creates and
destroys entries as `TerrainSlot`s load/unload, and `reassignWedges()` runs
on every road-graph version change and buckets the enumerated wedges and
straight segments by the page containing their seed node. Runtime objects
(mesh/collider/prefab entities) land in M5.8/M5.9/M5.11;
`destroyPageGeometry()` already tears them down. Headless coverage:
`roadPageAssignment` in `TerrainTests.cpp` (initial bucketing, rebucketing
after a graph change, no entry for an unloaded page, `RoadSystem` destroyed
with the terrain).
**Slot-coordinate convention (found 2026-07-26)**: Ogre terrain slots are
**centered** on `slot*worldSize` (`mBase = -worldSize*0.5`), and for
`ALIGN_X_Z` the terrain Y axis **negates** world Z
(`Terrain::convertWorldToTerrainAxes`). Page `(px,py)` therefore covers
X ∈ `[px*ws - ws/2, px*ws + ws/2)` and
Z ∈ `[-py*ws - ws/2, -py*ws + ws/2)`. `pageKeyForNode()` uses
`TerrainGroup::convertWorldPositionToTerrainSlot()`, which already applies
both conventions; any future manual page math must do the same.
Road geometry is generated by the new `RoadSystem`
(`systems/RoadSystem.hpp/.cpp`). `TerrainSystem` owns the `RoadSystem` and
calls `update()` after `rebuildDirtyPages()`.
@@ -2366,6 +2478,18 @@ unloaded, the corresponding road mesh, collider, spawned prefabs, and navmesh
contribution must be destroyed. Road geometry is regenerated only for pages
that become dirty (road graph changed) or newly loaded.
**Page assignment decision (2026-07-20)**: each wedge/straight segment is
generated by **exactly one page** — the page containing its seed node — instead
of every page whose bounds + `roadVisibilityDistance` margin contain the node.
Rationale: the margin rule makes neighboring pages emit identical geometry for
shared wedges; the duplicates are visually harmless (equal depth fails the
default `LESS` depth test) but double the mesh memory, draw calls, collider
bodies, and navmesh input. The `roadVisibilityDistance` expansion is kept only
for the page world AABB used in navmesh dirty marking (M5.8). Trade-off
accepted: a wedge disappears if its seed page unloads while a neighboring page
stays loaded — in the editor all pages are loaded synchronously, so this only
matters for future runtime paging.
**Navigation mesh input**: Road surfaces are walkable, so every generated road
page entity must be discoverable by `NavMeshSystem`. The `TriangleBufferComponent`
entity is tagged with `NavMeshGeometrySource` and given a `TransformComponent`
@@ -2373,16 +2497,22 @@ whose scene node holds the final `Ogre::Entity`, so the existing navmesh geometr
collection picks it up automatically. `RoadSystem` also marks the affected
navmesh tiles dirty whenever road geometry is created, destroyed, or changed.
**Data structures**:
**Data structures** (as implemented in `systems/RoadSystem.hpp`):
```cpp
struct RoadPageGeometry {
long pageX, pageY;
long pageX = 0, pageY = 0;
flecs::entity bufferEntity = flecs::entity::null();
flecs::entity colliderEntity = flecs::entity::null();
std::vector<Ogre::Vector3> collisionVertices;
std::vector<uint32_t> collisionIndices;
std::vector<flecs::entity> spawnedPrefabs;
/* Wedges and straight segments seeded by nodes inside this page. */
std::vector<RoadWedge> wedges;
std::vector<RoadStraightSegment> segments;
bool dirty = true; /* geometry needs (re)generation */
};
std::unordered_map<uint64_t, RoadPageGeometry> m_pageGeometry;
```
@@ -2391,6 +2521,17 @@ std::unordered_map<uint64_t, RoadPageGeometry> m_pageGeometry;
#### M5.5 Wedge enumeration
**Status**: ✅ complete (2026-07-26). Implemented as the pure function
`enumerateWedges(graph, outWedges, outSegments)` in
`components/RoadGraph.hpp` together with the `RoadHalfEdge`, `RoadWedge`,
and `RoadStraightSegment` types (half-edge direction/length, resolved
per-direction lane counts, per-end road levels, swept angle, degenerate
flag). `RoadGraph::validate()` moved out-of-line and additionally reports
sharp (< 30°) and degenerate (> 270°) wedges. Headless coverage:
`roadWedgeEnumeration` in `TerrainTests.cpp` (endpoint segments, asymmetric
lanes, L-shape 90°/270° wedges, T-junction angle sorting, degenerate 300°
wedge, sharp-wedge validation failure).
Terminology:
- An **edge** is a connection between two road-graph nodes. Geometrically it is
the segment whose endpoints are the world positions of those two nodes.
@@ -2442,6 +2583,15 @@ other node, i.e. exactly one half-edge):
so users cannot create such sharp turns).
- For > 270°: skip the wedge and log a warning.
**Angle-constraint decision (2026-07-20)**: the < 30° case is handled as an
editor-side rejection — operations that would create a wedge sharper than 30°
(join/add-node producing one) are refused with a warning message, so the graph
stays clean by construction. `RoadGraph::validate()` additionally reports any
existing sharp/degenerate wedges so hand-edited or legacy scenes are caught.
The `RoadGraph` data structure itself stays permissive (no logic changes);
geometry generation still skips > 270° wedges with a log warning as a safety
net.
---
#### M5.6 Wedge geometry generation
@@ -2494,6 +2644,43 @@ region defined by its two half-edges.
- No wedge blending is needed.
- Apply the same lane-count/asymmetric-lane rules as for a wedge side.
**Status (2026-07-26): ✅ complete**, with a deliberate deviation from the
literal template-copy sweep described above. The naive chord sweep was
rejected: it overshoots non-road corner triangles and ignores per-side lane
widths. What is implemented in `RoadSystem::buildWedgeGeometry()` /
`buildSegmentGeometry()` instead:
- The wedge region is the exact union of two one-sided lane band strips
(H1's outbound side `s ∈ [0, L_out·laneWidth]`, H2's inbound side
`s ∈ [-L_in·laneWidth, 0]`; `roadRightVec(d) = d × UNIT_Y`).
- Swept ≤ 180° with a valid outer corner `X` (curb intersection, Cramer
solve with `|det| ≥ 0.05`, `t` inside both half-lengths): single L-shaped
hexagon emitted as a fan around `X` (4 triangles; degenerate ones skipped).
Only the two outer curbs get skirts.
- Otherwise (swept > 180°, near-parallel, or corner outside the half-edges):
two independent strip quads with outer curb + node-end cap skirts and a
0.05 seam overlap at the node (replaces the "center-gap filling" above).
- Straight segments emit the full band `s ∈ [-L_in·w, +L_out·w]` with cap +
both curb skirts; the far end has no skirt (meets the neighbor's geometry).
- All primitives go through `emitSlab()`: center-surface triangles are
duplicated at `±roadThickness/2` with auto-flipped winding (normals from
cross products), boundary edges grow vertical skirts oriented away from an
interior reference point. This gives closed solids suitable for physics
mesh shapes (M5.9) instead of open surfaces.
- Heights interpolate `roadLevelA/B` from node to edge midpoint; lateral
direction stays flat. UVs: `u` along the road (phase-continuous across
the edge midpoint), `v` across the band (continuous at the center line);
wedge fans use a planar projection in the first half-edge's frame. UV
scaling replaces the physical 1-unit template repeat of M5.7's snapping
scheme.
- Degenerate wedges (> 270°) are logged and skipped (`false`).
`getRoadTemplate()` (M5.3) is retained: its space conventions define the
sign math above, and custom mesh templates may still be honored later.
Headless coverage: `roadWedgeGeometry` test (segment extents incl. top and
bottom surfaces, asymmetric lanes, 90° fan without overshoot + outer corner
vertex, 270° fallback path, degenerate wedge rejection).
---
#### M5.7 Edge length constraint
@@ -2518,6 +2705,16 @@ For each affected terrain page:
4. Fill its `buffer` directly with the generated wedge triangles and the
straight-segment triangles for nodes with a single connection (do **not**
create `PrimitiveComponent` children; the buffer is built procedurally).
**ProceduralMeshSystem conflict (found 2026-07-26)**: when
`TriangleBufferComponent::dirty` is set, `ProceduralMeshSystem::update()`
calls `buildTriangleBuffer()`, which **recreates `tb.buffer` from scratch**
and only appends `PrimitiveComponent` children — directly-filled road
triangles would be wiped before mesh conversion. Resolution: a new
`TriangleBufferComponent::proceduralContent` flag; when set,
`buildTriangleBuffer()` keeps the existing buffer contents (only clears the
dirty flag) instead of rebuilding from primitives. Road page entities set
this flag.
5. Set `tb.meshName = "Road_" + terrainId + "_" + pageX + "_" + pageY`.
6. Create a `ProceduralMaterialComponent` entity whose `materialName` is
`tc.roadGraph.config.roadMaterialName`. If the material resource does not exist,
@@ -2536,6 +2733,46 @@ affected navmesh tiles dirty. If `NavMeshSystem` exposes a partial rebuild
method, use it; otherwise set `NavMeshComponent::dirty = true` on the scene's
navmesh entity for a full rebuild.
**Status (2026-07-26): ✅ complete**, with these refinements found during
implementation:
- `TriangleBufferComponent::proceduralContent` added as planned;
`ProceduralMeshSystem::buildTriangleBuffer()` early-returns (keeps the
directly-filled buffer) when it is set.
- Page entities are **standalone** — deliberately not flecs children of the
terrain entity and not children of the terrain scene node. flecs cascade
deletion and Ogre node destruction would bypass the explicit Ogre cleanup
(no `OnRemove` hook exists for `TriangleBufferComponent`). They carry no
`EditorMarkerComponent`, so they stay out of the editor outliner.
- A `RenderableComponent` rides along (`meshName` at creation, `entity`
mirrored from `tb.ogreEntity` at finalize): `NavMeshSystem` collects
`NavMeshGeometrySource` geometry via `RenderableComponent`/
`StaticGeometryMemberComponent`, not via `TriangleBufferComponent`.
- Navmesh marking uses the full-rebuild fallback (`NavMeshComponent::dirty`)
because `TileCacheNavMesh` bakes the input triangle soup at build time —
partial tile rebuilds re-rasterize the old soup and can neither pick up new
road meshes nor remove destroyed ones. Marked when a page mesh is
finalized and when a previously-contributing page mesh is destroyed.
- Material: one shared `ProceduralMaterialComponent` entity per terrain
(`ensureRoadMaterial`), plus eager creation of a plain diffuse Ogre
material so mesh conversion never races material creation (the material
system reuses and restyles the existing resource in place).
- LOD: one shared `LodSettingsComponent` (`RoadLodSettings`, 50% at
`roadLodDistance`) per terrain; each page entity gets a `LodComponent`
re-marked dirty on every rebuild (LOD levels live on the Mesh resource and
vanish when the mesh is recreated).
- Finalize pass in `RoadSystem::update()`: once `tb.meshCreated`, applies
`setRenderingDistance(roadVisibilityDistance)`, mirrors
`RenderableComponent::entity`, bumps the transform version and marks the
navmesh dirty (`RoadPageGeometry::meshFinalized`).
- `destroyPageMeshes()` destroys the Ogre entity, removes the mesh from
`MeshManager`, destroys the scene node, then destructs the flecs entity;
`~RoadSystem` now runs `clearPageGeometry()` so terrain deactivation tears
everything down.
- Headless coverage: `roadPageMeshes` test (components present, buffer
filled directly, mesh created + finalized after `ProceduralMeshSystem`
pump, mesh resource exists, full teardown on terrain destruction).
**Lifecycle**: road meshes are tied to terrain page load/unload.
- When a terrain page loads, `RoadSystem` creates the page's
`TriangleBufferComponent` and marks it dirty.
@@ -2567,10 +2804,99 @@ The collider body is independent of the `NavMeshGeometrySource` used for
navigation; both are derived from the same generated road geometry so the
walkable surface and collision surface match.
**Status (2026-07-26): ✅ complete**, implemented the same way
`TerrainSystem` creates page colliders (direct wrapper calls, not the
`RigidBodyComponent` automation):
- `RoadSystem::setPhysics()` receives the `JoltPhysicsWrapper` from
`TerrainSystem` right after construction; changing it drops all colliders.
- `createPageCollider()` runs at page finalize time (render mesh must exist
for `createMeshShape(meshName)`): static `JPH::BodyCreationSettings` at the
origin with `Layers::NON_MOVING`, `createBody()`, `addBody(DontActivate)`,
body ID stored in `RoadPageGeometry::bodyId`. The mesh vertices are
world-space, so the body sits at the origin.
- The page entity additionally gets an informational
`PhysicsColliderComponent` (shapeType Mesh, meshName, shape ref) — it has
no `RigidBodyComponent`, so `EditorPhysicsSystem` never builds a second
body from it.
- `destroyPageCollider()` (`removeBody()` + `destroyBody()`) runs on page
unload, on every rebuild (before the mesh is recreated), and on
`RoadSystem` teardown.
- Headless coverage: `roadPageMeshes` asserts the body ID is valid and the
collider component is present after finalize.
---
#### 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`.
Declared API (not yet implemented):
```cpp
void writeFixup(float worldX, float worldZ, float height);
bool saveFixups();
void clearAllFixups();
size_t getFixupChunkCount() const;
std::string getFixupDir(const TerrainComponent &tc) const;
private:
struct FixupChunk { std::vector<float> samples; bool dirty; };
static constexpr int FIXUP_CHUNK_RES = 256;
static constexpr float FIXUP_SENTINEL = -FLT_MAX;
std::map<std::pair<int,int>, FixupChunk> m_fixupChunks;
std::string m_fixupDir;
float sampleFixupLocked(float worldX, float worldZ) const;
const FixupChunk *findFixupChunkLocked(int chunkX, int chunkZ) const;
bool loadFixupChunk(int chunkX, int chunkZ, std::vector<float> &out) const;
std::string fixupChunkPath(int chunkX, int chunkZ) const;
```
Section 4.2 specifies sparse fixup chunks (absolute-height overrides stored in
`heightmaps/<terrainId>/terrain_fixup/`), but no milestone ever implemented
them; M5.10 (road compliance) and Milestone 6 (prefab footprint flattening)
both write into them. **Decision (2026-07-20, confirmed with the user)**:
deliver the storage + sampling layer here as its own item, before M5.10 — no
writers yet.
**Scope** (unchanged from original):
- Chunk format: raw binary 256x256 floats; a sample is an **absolute** height,
sentinel `-FLT_MAX` = "no fixup here" (fall through to base heightmap +
detail noise).
- Chunk naming/addressing: `x<chunkX>_z<chunkZ>.bin`,
`chunkX = floor(worldX / (worldSize / 256))` (same for Z); chunk (0,0)
covers world (0,0)..(worldSize/256, worldSize/256).
- Lazy creation: a chunk object/file appears only when a writer first writes
into it; absent chunks mean "no fixup".
- Runtime storage lives in `TerrainSystem` (in-memory chunk map keyed by chunk
coordinates, loaded on demand, dirty-tracked for saving).
- Height sampling: `getHeightAt`/`sampleHeightAt`/`fillPageHeightData` consult
the fixup layer first (bilinear within the chunk) and fall through to base +
noise on sentinel or missing chunk. Road fixups added by M5.10 must
override the combined base + noise value (see the M4.2 layering note).
- Editor: a "Clear all fixups" button in `TerrainEditor` deletes all files
under `heightmaps/<terrainId>/terrain_fixup/`, clears the in-memory map, and
marks all pages dirty. (Per-chunk right-click deletion from section 4.2 is
deferred — no writer needs it yet.)
- Threading: fixup writes and chunk saves happen on the main thread before
marking pages dirty (see Risks, section 11).
- Persistence: chunks are saved alongside `saveSceneHeightmap` on scene save
and loaded on activation/on demand.
**Testing**: headless `TerrainTests` case — write a fixup value, verify
`getHeightAt` returns it (overriding base + noise), save/reload round-trip,
"clear all" restores the base height.
---
#### M5.10 Terrain compliance (conform, not flatten)
**Depends on M5.9.5** (fixup chunk storage + sampling must exist first).
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
not required to be flat — it follows the node heights and edge slopes.
@@ -2664,27 +2990,39 @@ edge data when the page is loaded.
nodes visually. A "Validate Road Graph" button reports graph errors.
- [x] New nodes are placed on the terrain surface and stay snapped to it when
moved with the gizmo.
- [ ] Per-edge asymmetric lane counts (`lanesAtoB` != `lanesBtoA`) render
correctly.
- [ ] Road mesh is generated from angle-sorted wedges by sweeping the 1-unit
template inside each V-shaped wedge; nodes with a single connection
generate straight segments; angle validation and center-gap filling
prevent degenerate geometry and visible seams.
- [ ] Road meshes use the configured material, LOD, and visibility distance.
- [ ] Static Jolt `MeshShape` colliders match the visible road geometry.
- [ ] Navigation mesh is rebuilt for terrain pages that contain roads; road
surfaces are walkable.
- [x] Per-edge asymmetric lane counts (`lanesAtoB` != `lanesBtoA`) render
correctly (verified in `testRoadWedgeGeometry`).
- [x] Road mesh is generated from angle-sorted wedges; nodes with a single
connection generate straight segments; angle validation and center-gap
filling prevent degenerate geometry and visible seams.
- [x] Road meshes use the configured material, LOD, and visibility distance
(shared `ProceduralMaterialComponent` + `LodSettingsComponent` per
terrain, `setRenderingDistance` on finalize; M5.8).
- [x] Static Jolt `MeshShape` colliders match the visible road geometry
(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
(sloped/curved where the road is sloped/curved) without gaps or
intersections.
intersections (M5.10, blocked by M5.9.5).
- [ ] Roadside prefabs spawn at configured edge positions and are recreated on
scene load.
scene load (M5.11, not started).
- [x] Save/reload round-trip preserves road nodes, edges, config, and side
prefabs (verified by `TerrainTests.cpp` headless test).
- [ ] Road meshes, colliders, spawned prefabs, and navmesh geometry are created
when a terrain page loads and destroyed when it unloads.
- [ ] Removing the terrain entity destroys all road meshes, colliders, visual
aids, spawned prefabs, and navmesh geometry.
- [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.
**Overall M5 status**: 10 of 11 sub-items complete (M5.1M5.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).
### Milestone 6 — Prefab spawns and terrain compliance
- `TerrainPrefabSpawnerComponent` + `TerrainPrefabSpawnerModule`.
+268 -40
View File
@@ -601,47 +601,275 @@ struct RoadGraph {
* - Edge endpoints reference existing node IDs.
* - Node IDs are unique.
* - No edge connects a node to itself.
* - 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
{
for (const auto &e : edges) {
if (findNodeById(e.nodeA) == nullptr) {
if (error)
*error =
"Edge references missing node A: " +
std::to_string(e.nodeA);
return false;
}
if (findNodeById(e.nodeB) == nullptr) {
if (error)
*error =
"Edge references missing node B: " +
std::to_string(e.nodeB);
return false;
}
if (e.nodeA == e.nodeB) {
if (error)
*error =
"Edge connects node to itself: " +
std::to_string(e.nodeA);
return false;
}
}
for (size_t i = 0; i < nodes.size(); ++i) {
for (size_t j = i + 1; j < nodes.size(); ++j) {
if (nodes[i].id == nodes[j].id) {
if (error)
*error = "Duplicate node ID: " +
std::to_string(
nodes[i].id);
return false;
}
}
}
return true;
}
bool validate(std::string *error = nullptr) const;
};
/**
* One half of an edge as seen from one of its endpoint nodes (M5.5).
*
* A half-edge starts at @c nodeId and ends at the midpoint of the edge
* connecting @c nodeId and @c neighborId. Geometry generation sweeps the
* road mesh template along half-edges; wedge generation pairs two half-edges
* that share the same seed node.
*/
struct RoadHalfEdge {
/** Index into RoadGraph::edges for the edge this half belongs to. */
int edgeIndex = -1;
/** Stable ID of the seed node this half-edge starts at. */
int nodeId = -1;
/** Stable ID of the node at the other end of the edge. */
int neighborId = -1;
/**
* Normalized horizontal (XZ) direction from the seed node toward the
* neighbor. Y is always 0; vertical placement is handled separately
* through the roadLevel fields.
*/
Ogre::Vector3 direction = Ogre::Vector3::UNIT_X;
/**
* Angle of @c direction around the world Y axis, atan2(z, x).
* Used as the sort key for wedge enumeration.
*/
float angleRad = 0.0f;
/**
* Horizontal distance from the seed node to the edge midpoint — the
* length of this half-edge in world units.
*/
float halfLength = 0.0f;
/** Number of lanes traveling away from the seed node. */
int lanesOut = 0;
/** Number of lanes traveling toward the seed node. */
int lanesIn = 0;
/**
* Road surface height offset at the seed-node end of the edge
* (RoadEdge::roadLevelA/B mapped to this half-edge's orientation).
*/
float roadLevelAtNode = 0.0f;
/** Road surface height offset at the neighbor end of the edge. */
float roadLevelAtNeighbor = 0.0f;
};
/**
* 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.
*/
struct RoadWedge {
/** Stable ID of the seed node both half-edges start at. */
int nodeId = -1;
/** First bounding half-edge (lower sort angle). */
RoadHalfEdge first;
/** Second bounding half-edge (next in angle-sorted order). */
RoadHalfEdge second;
/**
* Counter-clockwise swept angle from @c first to @c second around the
* seed node, in degrees, in the range (0, 360].
*/
float sweptAngleDeg = 0.0f;
/** true when @c sweptAngleDeg exceeds 270 degrees. */
bool degenerate = false;
};
/**
* Geometry primitive for a node with exactly one connection (M5.5): a
* straight road segment along the single half-edge.
*/
struct RoadStraightSegment {
/** Stable ID of the endpoint node. */
int nodeId = -1;
/** The node's single half-edge. */
RoadHalfEdge halfEdge;
};
/** Minimum wedge swept angle; sharper wedges are rejected (M5.5). */
static const float ROAD_WEDGE_MIN_ANGLE_DEG = 30.0f;
/** Maximum wedge swept angle; wider wedges are skipped (M5.5). */
static const float ROAD_WEDGE_MAX_ANGLE_DEG = 270.0f;
/**
* Enumerate all wedges and straight segments of a road graph (M5.5).
*
* For every node the connected half-edges are sorted by angle around the
* world Y axis; each consecutive pair (with wrap-around) yields one wedge.
* Nodes with exactly one connection yield one straight segment instead.
* Zero-length edges (endpoints sharing the same XZ) are skipped.
*
* This is a pure function of the graph data — no scene or terrain state is
* required — so it can be exercised by headless tests.
*/
inline void enumerateWedges(const RoadGraph &graph,
std::vector<RoadWedge> &outWedges,
std::vector<RoadStraightSegment> &outSegments)
{
outWedges.clear();
outSegments.clear();
for (const auto &node : graph.nodes) {
std::vector<RoadHalfEdge> halfEdges;
for (size_t ei = 0; ei < graph.edges.size(); ++ei) {
const RoadEdge &e = graph.edges[ei];
int neighborId = -1;
if (e.nodeA == node.id)
neighborId = e.nodeB;
else if (e.nodeB == node.id)
neighborId = e.nodeA;
else
continue;
const RoadNode *neighbor = graph.findNodeById(neighborId);
if (!neighbor)
continue;
Ogre::Vector3 dir = neighbor->position - node.position;
dir.y = 0.0f;
float fullLength = dir.normalise();
if (fullLength < 1e-4f)
continue;
RoadHalfEdge he;
he.edgeIndex = (int)ei;
he.nodeId = node.id;
he.neighborId = neighborId;
he.direction = dir;
he.angleRad = std::atan2(dir.z, dir.x);
he.halfLength = fullLength * 0.5f;
graph.resolveLaneCounts(e, node.id, he.lanesOut,
he.lanesIn);
if (e.nodeA == node.id) {
he.roadLevelAtNode = e.roadLevelA;
he.roadLevelAtNeighbor = e.roadLevelB;
} else {
he.roadLevelAtNode = e.roadLevelB;
he.roadLevelAtNeighbor = e.roadLevelA;
}
halfEdges.push_back(he);
}
if (halfEdges.empty())
continue;
std::sort(halfEdges.begin(), halfEdges.end(),
[](const RoadHalfEdge &a, const RoadHalfEdge &b) {
return a.angleRad < b.angleRad;
});
if (halfEdges.size() == 1) {
RoadStraightSegment seg;
seg.nodeId = node.id;
seg.halfEdge = halfEdges[0];
outSegments.push_back(seg);
continue;
}
for (size_t i = 0; i < halfEdges.size(); ++i) {
const RoadHalfEdge &h1 = halfEdges[i];
const RoadHalfEdge &h2 =
halfEdges[(i + 1) % halfEdges.size()];
float swept = h2.angleRad - h1.angleRad;
if (swept <= 0.0f)
swept += (float)(2.0 * M_PI);
RoadWedge w;
w.nodeId = node.id;
w.first = h1;
w.second = h2;
w.sweptAngleDeg =
swept * (180.0f / (float)M_PI);
w.degenerate =
w.sweptAngleDeg > ROAD_WEDGE_MAX_ANGLE_DEG;
outWedges.push_back(w);
}
}
}
inline bool RoadGraph::validate(std::string *error) const
{
for (const auto &e : edges) {
if (findNodeById(e.nodeA) == nullptr) {
if (error)
*error =
"Edge references missing node A: " +
std::to_string(e.nodeA);
return false;
}
if (findNodeById(e.nodeB) == nullptr) {
if (error)
*error =
"Edge references missing node B: " +
std::to_string(e.nodeB);
return false;
}
if (e.nodeA == e.nodeB) {
if (error)
*error =
"Edge connects node to itself: " +
std::to_string(e.nodeA);
return false;
}
}
for (size_t i = 0; i < nodes.size(); ++i) {
for (size_t j = i + 1; j < nodes.size(); ++j) {
if (nodes[i].id == nodes[j].id) {
if (error)
*error = "Duplicate node ID: " +
std::to_string(
nodes[i].id);
return false;
}
}
}
/* Wedge angle sanity (M5.5): wedges that are too sharp or too wide
* cannot be generated cleanly. Editor operations reject them, so a
* failure here means the scene was hand-edited or comes from an older
* build. */
std::vector<RoadWedge> wedges;
std::vector<RoadStraightSegment> segments;
enumerateWedges(*this, wedges, segments);
for (const auto &w : wedges) {
if (w.sweptAngleDeg < ROAD_WEDGE_MIN_ANGLE_DEG) {
if (error)
*error = "Sharp wedge at node " +
std::to_string(w.nodeId) + " (" +
std::to_string(w.sweptAngleDeg) +
" deg < 30)";
return false;
}
if (w.degenerate) {
if (error)
*error = "Degenerate wedge at node " +
std::to_string(w.nodeId) + " (" +
std::to_string(w.sweptAngleDeg) +
" deg > 270)";
return false;
}
}
return true;
}
#endif // EDITSCENE_ROADGRAPH_HPP
@@ -38,6 +38,11 @@ struct TriangleBufferComponent {
// Whether the buffer needs rebuilding
bool dirty = true;
// Buffer content is filled directly by the owning system (e.g. roads);
// ProceduralMeshSystem keeps the existing contents instead of rebuilding
// from Primitive children when clearing the dirty flag.
bool proceduralContent = false;
// Whether the buffer has been converted to a mesh
bool meshCreated = false;
@@ -85,6 +85,13 @@ bool ProceduralMeshSystem::arePrimitivesDirty(flecs::entity parent)
void ProceduralMeshSystem::buildTriangleBuffer(flecs::entity entity, TriangleBufferComponent& tb)
{
// Directly-filled buffers (e.g. road geometry) have no Primitive
// children; keep their contents and just clear the dirty flag.
if (tb.proceduralContent) {
tb.dirty = false;
return;
}
// Create new triangle buffer
tb.buffer = std::make_shared<Procedural::TriangleBuffer>();
File diff suppressed because it is too large Load Diff
+178 -6
View File
@@ -4,18 +4,83 @@
#include <Ogre.h>
#include <OgreManualObject.h>
#include <ProceduralTriangleBuffer.h>
#include <flecs.h>
#include <Jolt/Jolt.h>
#include <Jolt/Physics/Body/BodyID.h>
#include <cstdint>
#include <memory>
#include <string>
#include <unordered_map>
#include <vector>
#include "../components/RoadGraph.hpp"
namespace Ogre {
class TerrainGroup;
}
struct RoadConfig;
class JoltPhysicsWrapper;
/**
* RoadSystem — runtime owner of road visual aids and (future) road mesh
* generation.
* Per-page road geometry state (M5.4).
*
* In the current milestone this class builds and updates ManualObject
* overlays for the road graph: node markers, edge lines, road-width
* indicators, and selection highlights. It is created by TerrainSystem when
* a terrain is activated and destroyed when the terrain deactivates.
* One entry exists for every loaded terrain page. The page generates the
* wedges and straight segments seeded by road nodes inside its bounds (see
* the page-assignment decision in TerrainRequirements.md, M5.4). Runtime
* objects (mesh entity, collider, roadside prefabs) are created in later
* milestones and destroyed together when the page unloads.
*/
struct RoadPageGeometry {
long pageX = 0;
long pageY = 0;
/** Entity holding this page's road TriangleBufferComponent (M5.8). */
flecs::entity bufferEntity = flecs::entity::null();
/** Entity holding this page's road collider body (M5.9). */
flecs::entity colliderEntity = flecs::entity::null();
/**
* Jolt body of this page's static road collider (M5.9), created from
* the page's render mesh once it exists. Invalid when no collider
* has been created.
*/
JPH::BodyID bodyId;
/** Collision soup mirroring the generated road triangles (M5.9). */
std::vector<Ogre::Vector3> collisionVertices;
std::vector<uint32_t> collisionIndices;
/** Roadside prefab instances spawned for this page (M5.11). */
std::vector<flecs::entity> spawnedPrefabs;
/** Wedges and straight segments seeded by nodes inside this page. */
std::vector<RoadWedge> wedges;
std::vector<RoadStraightSegment> segments;
/** Geometry needs (re)generation. */
bool dirty = true;
/**
* Rendering distance, navmesh contribution and RenderableComponent
* pointer have been applied to the created Ogre mesh (M5.8). Reset
* whenever the page mesh is (re)built.
*/
bool meshFinalized = false;
};
/**
* RoadSystem — runtime owner of road visual aids, per-page road geometry
* state, and (future) road mesh generation.
*
* The class builds and updates ManualObject overlays for the road graph:
* node markers, edge lines, road-width indicators, and selection
* highlights. It also tracks terrain page load/unload and buckets the
* enumerated wedges/straight segments by the page containing their seed
* node (M5.4). It is created by TerrainSystem when a terrain is activated
* and destroyed when the terrain deactivates.
*/
class RoadSystem {
public:
@@ -57,6 +122,70 @@ public:
/** The terrain entity this system is bound to. */
flecs::entity getTerrainEntity() const;
/**
* Road mesh template (M5.3).
*
* Returns the template road segment as a Procedural::TriangleBuffer in
* template space: X in [0, 1] along the edge, Y in
* [-roadThickness/2, +roadThickness/2], Z in [0, 1] across the road
* (+Z = right of the A->B travel direction), UVs spanning (0,0)-(1,1)
* over the X/Z extents.
*
* The template is loaded from cfg.roadMeshTemplate (General resource
* group). A missing or empty mesh falls back to a generated unit box
* (the supported prototyping path — no asset required). The buffer is
* cached and rebuilt only when cfg.roadMeshTemplate or
* cfg.roadThickness changes.
*/
const Procedural::TriangleBuffer &getRoadTemplate(const RoadConfig &cfg);
/**
* Geometry generation (M5.6).
*
* Builds the world-space road slab for one wedge or one straight
* segment and appends it to @ out. The slab has top and bottom
* surfaces at +/- roadThickness/2 around the interpolated road level
* and side skirts along exposed edges (curbs and endpoint caps).
*
* Static so headless tests can call them without a scene. Returns
* false when the primitive is degenerate and nothing was emitted
* (e.g. a wedge wider than 270 degrees).
*/
static bool buildWedgeGeometry(const RoadWedge &wedge,
const RoadGraph &graph,
Procedural::TriangleBuffer &out);
static bool buildSegmentGeometry(const RoadStraightSegment &segment,
const RoadGraph &graph,
Procedural::TriangleBuffer &out);
/**
* Bind the terrain group used for page tracking (M5.4).
*
* Called by TerrainSystem right after construction. Passing nullptr
* detaches and drops all per-page state.
*/
void setTerrainGroup(Ogre::TerrainGroup *group);
/**
* Provide the physics wrapper used for road colliders (M5.9).
*
* Called by TerrainSystem right after construction. Passing nullptr
* drops all colliders and disables collider creation.
*/
void setPhysics(JoltPhysicsWrapper *physics);
/**
* Per-page road geometry state, keyed by
* TerrainGroup::packIndex(pageX, pageY) (M5.4).
*
* Exposed for headless tests and for the M5.8 mesh assembly.
*/
const std::unordered_map<uint64_t, RoadPageGeometry> &
getPageGeometry() const
{
return m_pageGeometry;
}
private:
void createManualObjects();
void destroyManualObjects();
@@ -67,6 +196,28 @@ private:
void buildWidthIndicators();
void buildSelectionHighlight();
/* Page tracking (M5.4). */
void syncPages();
void reassignWedges();
void applyPageBucket(uint64_t key, RoadPageGeometry &pg);
void destroyPageGeometry(RoadPageGeometry &pg);
void clearPageGeometry();
bool pageKeyForNode(int nodeId, uint64_t &outKey) const;
/* Mesh assembly (M5.8). */
void buildPageMeshes(RoadPageGeometry &pg);
void destroyPageMeshes(RoadPageGeometry &pg);
flecs::entity ensureRoadMaterial(const RoadConfig &cfg);
flecs::entity ensureRoadLodSettings(const RoadConfig &cfg);
void markNavMeshDirty();
/* Physics colliders (M5.9). */
void createPageCollider(RoadPageGeometry &pg, const std::string &meshName);
void destroyPageCollider(RoadPageGeometry &pg);
bool loadTemplateFromMesh(const std::string &meshName);
void buildFallbackTemplate(float roadThickness);
Ogre::Vector3 getNodePosition(int nodeId) const;
bool getEdgePositions(int edgeIndex, Ogre::Vector3 &outA,
Ogre::Vector3 &outB) const;
@@ -74,12 +225,33 @@ private:
flecs::world &m_world;
Ogre::SceneManager *m_sceneMgr;
flecs::entity_t m_terrainEntityId = 0;
Ogre::TerrainGroup *m_terrainGroup = nullptr;
bool m_visualAidsVisible = true;
int m_selectedNodeId = -1;
int m_selectedEdgeIndex = -1;
uint64_t m_lastGraphVersion = 0;
/* Page geometry state (M5.4). m_wedgeBuckets holds the enumerated
* wedges/segments bucketed by seed-node page for ALL pages (including
* unloaded ones); m_pageGeometry only tracks loaded pages. */
std::unordered_map<uint64_t, RoadPageGeometry> m_pageGeometry;
std::unordered_map<uint64_t, RoadPageGeometry> m_wedgeBuckets;
uint64_t m_geometryGraphVersion = 0;
/** Shared road material entity (M5.8), created lazily. */
flecs::entity m_materialEntity = flecs::entity::null();
/** Shared road LOD settings entity (M5.8), created lazily. */
flecs::entity m_lodSettingsEntity = flecs::entity::null();
/** Physics wrapper for road colliders (M5.9), may be null. */
JoltPhysicsWrapper *m_physics = nullptr;
Procedural::TriangleBuffer m_templateBuffer;
std::string m_templateName;
float m_templateThickness = -1.0f;
Ogre::SceneNode *m_visualNode = nullptr;
Ogre::ManualObject *m_nodeMarkers = nullptr;
Ogre::ManualObject *m_edgeLines = nullptr;
@@ -1641,13 +1641,18 @@ void TerrainSystem::processDeferredReloads()
/* activate */
/* ------------------------------------------------------------------ */
void TerrainSystem::activate(TerrainComponent &tc, TransformComponent &xform)
void TerrainSystem::activate(TerrainComponent &tc, TransformComponent &xform,
flecs::entity_t terrainEntityId)
{
Ogre::LogManager::getSingleton().logMessage(
"TerrainSystem: activating terrain...");
deactivate();
/* deactivate() clears m_terrainEntityId; restore it now so the
* RoadSystem created below binds to the live terrain entity. */
m_terrainEntityId = terrainEntityId;
/* Paging range and heightmap world area must be known before the
* heightmap is generated or sampled. */
m_pageMinX = -1;
@@ -1761,6 +1766,8 @@ void TerrainSystem::activate(TerrainComponent &tc, TransformComponent &xform)
/* Create the road system after the terrain is fully set up. */
m_roadSystem = std::make_unique<RoadSystem>(m_world, m_sceneMgr);
m_roadSystem->setTerrainGroup(mTerrainGroup);
m_roadSystem->setPhysics(m_physics);
m_roadSystem->setTerrainEntity(m_world.entity(m_terrainEntityId));
m_roadSystem->setVisualAidsVisible(m_roadEditMode);
@@ -1914,10 +1921,8 @@ void TerrainSystem::update(float /*deltaTime*/)
deactivate();
return;
}
if (tc.enabled && !m_active) {
activate(tc, xform);
m_terrainEntityId = e.id();
}
if (tc.enabled && !m_active)
activate(tc, xform, e.id());
/* Editor-flagged dirty: rebuild all loaded pages so parameter
* changes (e.g. detail noise) take effect. */
@@ -74,6 +74,23 @@ public:
bool saveSceneHeightmap(const struct TerrainComponent &tc);
std::string getHeightmapPath(const struct TerrainComponent &tc) const;
/* Fixup chunks (M5.9.5): sparse absolute-height overrides layered
* on top of base heightmap + detail noise during sampling.
*
* writeFixup() splats the 4 samples of the chunk cell containing the
* point so bilinear sampling returns @p height at the write position;
* the chunk is created lazily and marked dirty for saveFixups().
* Callers are responsible for marking affected terrain pages dirty
* AFTER all writes (main thread).
*
* clearAllFixups() deletes every fixup file and the in-memory chunks
* and marks all loaded pages dirty. */
void writeFixup(float worldX, float worldZ, float height);
bool saveFixups();
void clearAllFixups();
size_t getFixupChunkCount() const;
std::string getFixupDir(const struct TerrainComponent &tc) const;
/* Resolution change (M4.6) */
bool changeHeightmapResolution(struct TerrainComponent &tc,
int newResolution);
@@ -339,7 +356,8 @@ public:
private:
std::unique_ptr<RoadSystem> m_roadSystem;
void activate(struct TerrainComponent &tc,
struct TransformComponent &xform);
struct TransformComponent &xform,
flecs::entity_t terrainEntityId);
void ensureHeightmapLoaded(struct TerrainComponent &tc);
void updatePageGeometry(long pageX, long pageY);
void fillPageHeightData(Ogre::TerrainGroup *group, long x, long y,
@@ -480,6 +498,26 @@ private:
m_auxMapData;
std::unordered_set<std::string> m_dirtyAuxMaps;
/* Fixup chunks (M5.9.5): sparse absolute-height overrides stored as
* 256x256 float chunks under heightmaps/<terrainId>/terrain_fixup/.
* Guarded by m_heightmapMutex; loaded lazily from disk on first
* access, saved alongside the heightmap. */
struct FixupChunk {
std::vector<float> samples;
bool dirty = false;
};
static constexpr int FIXUP_CHUNK_RES = 256;
static constexpr float FIXUP_SENTINEL = -FLT_MAX;
mutable std::map<std::pair<int, int>, FixupChunk> m_fixupChunks;
std::string m_fixupDir;
float sampleFixupLocked(float worldX, float worldZ) const;
const FixupChunk *findFixupChunkLocked(int chunkX, int chunkZ) const;
bool loadFixupChunk(int chunkX, int chunkZ,
std::vector<float> &out) const;
std::string fixupChunkPath(int chunkX, int chunkZ) const;
const std::vector<float> *
ensureAuxMapLoaded(const struct TerrainComponent::AuxMap &aux,
const std::string &path) const;
@@ -1,10 +1,18 @@
#include "TerrainTests.hpp"
#include "TerrainSystem.hpp"
#include "TerrainCommands.hpp"
#include "RoadSystem.hpp"
#include "ProceduralMeshSystem.hpp"
#include "../EditorApp.hpp"
#include "../components/Terrain.hpp"
#include "../components/Transform.hpp"
#include "../components/EntityName.hpp"
#include "../components/RoadGraph.hpp"
#include "../components/TriangleBuffer.hpp"
#include "../components/Renderable.hpp"
#include "../components/NavMesh.hpp"
#include "../components/Lod.hpp"
#include "../components/PhysicsCollider.hpp"
#include "../systems/SceneSerializer.hpp"
#include <OgreLogManager.h>
@@ -14,6 +22,7 @@
#include <OgreTexture.h>
#include <iostream>
#include <cmath>
#include <cfloat>
#include <chrono>
#include <OgreSceneNode.h>
#include <filesystem>
@@ -1425,6 +1434,879 @@ bool TerrainTestRunner::testRoadSerialization(EditorApp &app, TerrainSystem *ts)
return true;
}
bool TerrainTestRunner::testRoadTemplate(EditorApp &app, TerrainSystem *ts)
{
(void)ts;
flecs::world *w = app.getWorld();
RoadSystem rs(*w, app.getSceneManager());
/* A missing template file must select the generated fallback box. */
RoadConfig cfg;
cfg.roadMeshTemplate = "missing_road_template.mesh";
cfg.roadThickness = 0.3f;
const Procedural::TriangleBuffer &tb = rs.getRoadTemplate(cfg);
const auto &verts = tb.getVertices();
const auto &indices = tb.getIndices();
if (verts.size() != 24 || indices.size() != 36) {
Ogre::LogManager::getSingleton().logMessage(
"TerrainTests: FAIL - fallback template is not a box "
"(verts=" +
Ogre::StringConverter::toString(verts.size()) +
", indices=" +
Ogre::StringConverter::toString(indices.size()) + ")");
return false;
}
const float h = cfg.roadThickness * 0.5f;
for (const auto &v : verts) {
bool ok = v.mPosition.x >= -1e-4f &&
v.mPosition.x <= 1.0f + 1e-4f &&
v.mPosition.y >= -h - 1e-4f &&
v.mPosition.y <= h + 1e-4f &&
v.mPosition.z >= -1e-4f &&
v.mPosition.z <= 1.0f + 1e-4f &&
v.mUV.x >= -1e-4f && v.mUV.x <= 1.0f + 1e-4f &&
v.mUV.y >= -1e-4f && v.mUV.y <= 1.0f + 1e-4f;
if (!ok) {
Ogre::LogManager::getSingleton().logMessage(
"TerrainTests: FAIL - fallback template vertex "
"out of bounds: pos=" +
Ogre::StringConverter::toString(v.mPosition) +
" uv=" + Ogre::StringConverter::toString(v.mUV));
return false;
}
}
/* Indices must reference valid vertices. */
for (int i : indices) {
if (i < 0 || (size_t)i >= verts.size()) {
Ogre::LogManager::getSingleton().logMessage(
"TerrainTests: FAIL - fallback template index "
"out of range");
return false;
}
}
/* Cache: an unchanged config returns the same buffer object. */
const Procedural::TriangleBuffer &tbCached = rs.getRoadTemplate(cfg);
if (&tbCached != &tb) {
Ogre::LogManager::getSingleton().logMessage(
"TerrainTests: FAIL - template cache rebuilds on "
"unchanged config");
return false;
}
/* A roadThickness change rebuilds with matching vertical bounds. */
cfg.roadThickness = 0.5f;
const Procedural::TriangleBuffer &tbThick = rs.getRoadTemplate(cfg);
float hThick = 0.25f;
bool sawNewHeight = false;
for (const auto &v : tbThick.getVertices()) {
if (fabsf(fabsf(v.mPosition.y) - hThick) < 1e-4f)
sawNewHeight = true;
}
if (!sawNewHeight) {
Ogre::LogManager::getSingleton().logMessage(
"TerrainTests: FAIL - template not rebuilt after "
"roadThickness change");
return false;
}
/* An empty template name also selects the fallback box. */
RoadConfig cfg2;
cfg2.roadMeshTemplate = "";
if (rs.getRoadTemplate(cfg2).getVertices().size() != 24) {
Ogre::LogManager::getSingleton().logMessage(
"TerrainTests: FAIL - empty template name did not "
"select the fallback box");
return false;
}
Ogre::LogManager::getSingleton().logMessage(
"TerrainTests: road mesh template test passed");
return true;
}
bool TerrainTestRunner::testRoadWedgeEnumeration(EditorApp &app,
TerrainSystem *ts)
{
(void)app;
(void)ts;
const float eps = 1e-3f;
/* Single edge: both nodes are endpoints -> two straight segments,
* no wedges. */
{
RoadGraph rg;
int a = rg.addNode(Ogre::Vector3(0, 0, 0));
int b = rg.addNode(Ogre::Vector3(10, 0, 0));
rg.addEdge(a, b);
std::vector<RoadWedge> wedges;
std::vector<RoadStraightSegment> segs;
enumerateWedges(rg, wedges, segs);
if (!wedges.empty() || segs.size() != 2) {
Ogre::LogManager::getSingleton().logMessage(
"TerrainTests: FAIL - single edge should produce "
"2 straight segments and 0 wedges");
return false;
}
/* Half-edge from node a points +X and is 5 units long. */
bool foundA = false;
for (const auto &s : segs) {
if (s.nodeId != a)
continue;
foundA = true;
const RoadHalfEdge &he = s.halfEdge;
if (fabsf(he.direction.x - 1.0f) > eps ||
fabsf(he.direction.z) > eps ||
fabsf(he.halfLength - 5.0f) > eps) {
Ogre::LogManager::getSingleton().logMessage(
"TerrainTests: FAIL - half-edge from A "
"has wrong direction or length");
return false;
}
}
if (!foundA) {
Ogre::LogManager::getSingleton().logMessage(
"TerrainTests: FAIL - no straight segment for "
"node A");
return false;
}
}
/* Asymmetric lane counts land on the correct half-edges. */
{
RoadGraph rg;
int a = rg.addNode(Ogre::Vector3(0, 0, 0));
int b = rg.addNode(Ogre::Vector3(10, 0, 0));
int e = rg.addEdge(a, b);
rg.edges[(size_t)e].lanesAtoB = 2;
rg.edges[(size_t)e].lanesBtoA = 1;
std::vector<RoadWedge> wedges;
std::vector<RoadStraightSegment> segs;
enumerateWedges(rg, wedges, segs);
bool okA = false, okB = false;
for (const auto &s : segs) {
if (s.nodeId == a)
okA = s.halfEdge.lanesOut == 2 &&
s.halfEdge.lanesIn == 1;
if (s.nodeId == b)
okB = s.halfEdge.lanesOut == 1 &&
s.halfEdge.lanesIn == 2;
}
if (!okA || !okB) {
Ogre::LogManager::getSingleton().logMessage(
"TerrainTests: FAIL - asymmetric lanes not "
"resolved on half-edges");
return false;
}
}
/* L-shape: corner node with neighbors at +X and +Z -> two wedges
* (90 and 270 degrees, both valid); the two other nodes are
* endpoints. */
{
RoadGraph rg;
int corner = rg.addNode(Ogre::Vector3(0, 0, 0));
int px = rg.addNode(Ogre::Vector3(10, 0, 0));
int pz = rg.addNode(Ogre::Vector3(0, 0, 10));
rg.addEdge(corner, px);
rg.addEdge(corner, pz);
std::vector<RoadWedge> wedges;
std::vector<RoadStraightSegment> segs;
enumerateWedges(rg, wedges, segs);
if (wedges.size() != 2 || segs.size() != 2) {
Ogre::LogManager::getSingleton().logMessage(
"TerrainTests: FAIL - L-shape should produce "
"2 wedges and 2 segments");
return false;
}
bool saw90 = false, saw270 = false;
for (const auto &w : wedges) {
if (w.nodeId != corner) {
Ogre::LogManager::getSingleton().logMessage(
"TerrainTests: FAIL - wedge seeded at "
"wrong node");
return false;
}
if (w.degenerate) {
Ogre::LogManager::getSingleton().logMessage(
"TerrainTests: FAIL - L-shape wedge "
"wrongly marked degenerate");
return false;
}
if (fabsf(w.sweptAngleDeg - 90.0f) < 0.1f)
saw90 = true;
if (fabsf(w.sweptAngleDeg - 270.0f) < 0.1f)
saw270 = true;
}
if (!saw90 || !saw270) {
Ogre::LogManager::getSingleton().logMessage(
"TerrainTests: FAIL - L-shape wedge angles "
"are not 90/270 degrees");
return false;
}
std::string error;
if (!rg.validate(&error)) {
Ogre::LogManager::getSingleton().logMessage(
"TerrainTests: FAIL - L-shape rejected by "
"validate(): " + error);
return false;
}
}
/* T-junction: center node with three neighbors -> three wedges.
* Edges are added in scrambled order to exercise angle sorting. */
{
RoadGraph rg;
int c = rg.addNode(Ogre::Vector3(0, 0, 0));
int e1 = rg.addNode(Ogre::Vector3(10, 0, 0)); /* 0 deg */
int e2 = rg.addNode(Ogre::Vector3(-10, 0, 10)); /* 135 deg */
int e3 = rg.addNode(Ogre::Vector3(-10, 0, -10)); /* -135 deg */
rg.addEdge(c, e1);
rg.addEdge(c, e3);
rg.addEdge(c, e2);
std::vector<RoadWedge> wedges;
std::vector<RoadStraightSegment> segs;
enumerateWedges(rg, wedges, segs);
if (wedges.size() != 3 || segs.size() != 3) {
Ogre::LogManager::getSingleton().logMessage(
"TerrainTests: FAIL - T-junction should "
"produce 3 wedges and 3 segments");
return false;
}
/* Sorted angles: -135, 0, +135 -> sweeps 135, 135, 90. */
float expected[3] = { 135.0f, 135.0f, 90.0f };
for (int i = 0; i < 3; ++i) {
bool matched = false;
for (const auto &w : wedges) {
if (fabsf(w.sweptAngleDeg - expected[i]) <
0.1f &&
!w.degenerate) {
matched = true;
break;
}
}
if (!matched) {
Ogre::LogManager::getSingleton().logMessage(
"TerrainTests: FAIL - T-junction wedge "
"angle mismatch");
return false;
}
}
}
/* Degenerate wedge: two neighbors seen under a 300 degree sweep
* (60 degrees apart) -> one valid 60 degree wedge and one skipped
* 300 degree wedge; validate() must report the graph. */
{
RoadGraph rg;
int c = rg.addNode(Ogre::Vector3(0, 0, 0));
int n1 = rg.addNode(Ogre::Vector3(10, 0, 0)); /* 0 deg */
int n2 = rg.addNode(Ogre::Vector3(5, 0, 8.660254f)); /* 60 deg */
rg.addEdge(c, n1);
rg.addEdge(c, n2);
std::vector<RoadWedge> wedges;
std::vector<RoadStraightSegment> segs;
enumerateWedges(rg, wedges, segs);
if (wedges.size() != 2) {
Ogre::LogManager::getSingleton().logMessage(
"TerrainTests: FAIL - expected 2 wedges for "
"degenerate case");
return false;
}
int degenerateCount = 0;
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;
}
std::string error;
if (rg.validate(&error)) {
Ogre::LogManager::getSingleton().logMessage(
"TerrainTests: FAIL - validate() accepted a "
"degenerate wedge");
return false;
}
}
/* Sharp wedge (< 30 degrees) must also fail validate(). */
{
RoadGraph rg;
int c = rg.addNode(Ogre::Vector3(0, 0, 0));
int n1 = rg.addNode(Ogre::Vector3(10, 0, 0)); /* 0 deg */
int n2 = rg.addNode(Ogre::Vector3(9.848078f, 0,
1.736482f)); /* 10 deg */
rg.addEdge(c, n1);
rg.addEdge(c, n2);
std::string error;
if (rg.validate(&error)) {
Ogre::LogManager::getSingleton().logMessage(
"TerrainTests: FAIL - validate() accepted a "
"sharp wedge");
return false;
}
}
Ogre::LogManager::getSingleton().logMessage(
"TerrainTests: road wedge enumeration test passed");
return true;
}
bool TerrainTestRunner::testRoadPageAssignment(EditorApp &app,
TerrainSystem *ts)
{
if (ts->isActive())
ts->deactivate();
flecs::entity e = createTerrainEntity(app);
/* Build the road graph before activation. The test terrain has
* worldSize=2000 and pages in [-1..1] x [-1..1]. Ogre terrain slots
* are CENTERED on slot*worldSize, and for ALIGN_X_Z the terrain Y
* axis NEGATES world Z, so page (px,py) covers:
* X: [px*2000 - 1000, px*2000 + 1000)
* Z: [-py*2000 - 1000, -py*2000 + 1000)
*
* - page (0,0): n1-n2 edge -> 2 straight segments
* - page (-1,-1): n3 corner to n4,n5 -> 2 wedges + 2 segments
* - page (0,-2): n6-n7 edge -> bucket only, page unloaded
*/
int n1, n2;
{
auto &tc = e.get_mut<TerrainComponent>();
n1 = tc.roadGraph.addNode(Ogre::Vector3(100, 0, 100));
n2 = tc.roadGraph.addNode(Ogre::Vector3(500, 0, 100));
tc.roadGraph.addEdge(n1, n2);
int n3 = tc.roadGraph.addNode(Ogre::Vector3(-1500, 0, 1500));
int n4 = tc.roadGraph.addNode(Ogre::Vector3(-1800, 0, 1500));
int n5 = tc.roadGraph.addNode(Ogre::Vector3(-1500, 0, 1800));
tc.roadGraph.addEdge(n3, n4);
tc.roadGraph.addEdge(n3, n5);
int n6 = tc.roadGraph.addNode(Ogre::Vector3(100, 0, 3500));
int n7 = tc.roadGraph.addNode(Ogre::Vector3(500, 0, 3500));
tc.roadGraph.addEdge(n6, n7);
}
pumpFrames(app, ts, 5);
RoadSystem *rs = ts->getRoadSystem();
if (!rs) {
Ogre::LogManager::getSingleton().logMessage(
"TerrainTests: FAIL - no RoadSystem after activation");
destroyTerrainEntity(app, e);
pumpFrames(app, ts, 1);
return false;
}
Ogre::TerrainGroup *group = ts->getTerrainGroup();
const auto &pages = rs->getPageGeometry();
if (pages.size() != 9) {
Ogre::LogManager::getSingleton().logMessage(
"TerrainTests: FAIL - expected 9 tracked pages, got " +
Ogre::StringConverter::toString(pages.size()));
destroyTerrainEntity(app, e);
pumpFrames(app, ts, 1);
return false;
}
auto countAt = [&](long px, long py, size_t &wedges,
size_t &segs) -> bool {
uint64_t key = group->packIndex(px, py);
auto it = pages.find(key);
if (it == pages.end())
return false;
wedges = it->second.wedges.size();
segs = it->second.segments.size();
return true;
};
size_t wedges = 0, segs = 0;
if (!countAt(0, 0, wedges, segs) || wedges != 0 || segs != 2) {
Ogre::LogManager::getSingleton().logMessage(
"TerrainTests: FAIL - page (0,0) should hold 0 wedges "
"and 2 segments");
destroyTerrainEntity(app, e);
pumpFrames(app, ts, 1);
return false;
}
if (!countAt(-1, -1, wedges, segs) || wedges != 2 || segs != 2) {
Ogre::LogManager::getSingleton().logMessage(
"TerrainTests: FAIL - page (-1,-1) should hold 2 "
"wedges and 2 segments, got wedges=" +
Ogre::StringConverter::toString(wedges) + " segs=" +
Ogre::StringConverter::toString(segs));
for (const auto &kv : pages) {
Ogre::LogManager::getSingleton().logMessage(
"TerrainTests: page (" +
Ogre::StringConverter::toString(kv.second.pageX) +
"," +
Ogre::StringConverter::toString(kv.second.pageY) +
") wedges=" +
Ogre::StringConverter::toString(
kv.second.wedges.size()) +
" segs=" +
Ogre::StringConverter::toString(
kv.second.segments.size()));
}
destroyTerrainEntity(app, e);
pumpFrames(app, ts, 1);
return false;
}
/* Page (0,-2) is outside the loaded range: its bucket must not
* produce a page entry. */
{
uint64_t key = group->packIndex(0, -2);
if (pages.find(key) != pages.end()) {
Ogre::LogManager::getSingleton().logMessage(
"TerrainTests: FAIL - unloaded page (0,-2) "
"got a geometry entry");
destroyTerrainEntity(app, e);
pumpFrames(app, ts, 1);
return false;
}
}
/* Graph mutation: extend the road from n2 into page (1,0). n2
* becomes a 2-neighbor node (2 wedges), n1 keeps the only segment
* on page (0,0), and the new endpoint appears on page (1,0). */
{
auto &tc = e.get_mut<TerrainComponent>();
int n8 = tc.roadGraph.addNode(Ogre::Vector3(2500, 0, 100));
tc.roadGraph.addEdge(n2, n8);
}
pumpFrames(app, ts, 3);
if (!countAt(0, 0, wedges, segs) || wedges != 2 || segs != 1) {
Ogre::LogManager::getSingleton().logMessage(
"TerrainTests: FAIL - page (0,0) should rebucket to 2 "
"wedges and 1 segment after graph change");
destroyTerrainEntity(app, e);
pumpFrames(app, ts, 1);
return false;
}
if (!countAt(1, 0, wedges, segs) || wedges != 0 || segs != 1) {
Ogre::LogManager::getSingleton().logMessage(
"TerrainTests: FAIL - page (1,0) should gain 1 "
"segment after graph change");
destroyTerrainEntity(app, e);
pumpFrames(app, ts, 1);
return false;
}
/* Destroying the terrain entity must tear down the road system. */
destroyTerrainEntity(app, e);
pumpFrames(app, ts, 3);
if (ts->getRoadSystem() != nullptr) {
Ogre::LogManager::getSingleton().logMessage(
"TerrainTests: FAIL - RoadSystem survived terrain "
"destruction");
return false;
}
Ogre::LogManager::getSingleton().logMessage(
"TerrainTests: road page assignment test passed");
return true;
}
bool TerrainTestRunner::testRoadWedgeGeometry(EditorApp &app,
TerrainSystem *ts)
{
(void)app;
(void)ts;
auto fail = [](const char *msg) {
Ogre::LogManager::getSingleton().logMessage(
std::string("TerrainTests: FAIL - ") + msg);
return false;
};
/* Scan a buffer: bounds, NaN check, index range check. */
struct Scan {
Ogre::Vector3 min, max;
bool ok;
};
auto scan = [](const Procedural::TriangleBuffer &buf) {
Scan s;
s.ok = true;
s.min = Ogre::Vector3(FLT_MAX, FLT_MAX, FLT_MAX);
s.max = Ogre::Vector3(-FLT_MAX, -FLT_MAX, -FLT_MAX);
const auto &verts = buf.getVertices();
const auto &indices = buf.getIndices();
if (verts.empty() || indices.empty() ||
indices.size() % 3 != 0) {
s.ok = false;
return s;
}
for (const auto &v : verts) {
const Ogre::Vector3 &p = v.mPosition;
if (std::isnan(p.x) || std::isnan(p.y) ||
std::isnan(p.z) || std::isnan(v.mNormal.x) ||
std::isnan(v.mUV.x) || std::isnan(v.mUV.y)) {
s.ok = false;
return s;
}
s.min.x = std::min(s.min.x, p.x);
s.min.y = std::min(s.min.y, p.y);
s.min.z = std::min(s.min.z, p.z);
s.max.x = std::max(s.max.x, p.x);
s.max.y = std::max(s.max.y, p.y);
s.max.z = std::max(s.max.z, p.z);
}
for (int idx : indices) {
if (idx < 0 || idx >= (int)verts.size()) {
s.ok = false;
return s;
}
}
return s;
};
/*
* Case 1: straight segment slab from a 2-node graph.
* Nodes (0,0,0)-(20,0,0) -> half length 10, default 1+1 lanes of
* width 3, thickness 0.3. Expect x in [-0.05, 10], z in [-3, 3],
* top at +0.15 and bottom at -0.15.
*/
{
RoadGraph rg;
int a = rg.addNode(Ogre::Vector3(0, 0, 0));
int b = rg.addNode(Ogre::Vector3(20, 0, 0));
rg.addEdge(a, b);
std::vector<RoadWedge> wedges;
std::vector<RoadStraightSegment> segs;
enumerateWedges(rg, wedges, segs);
const RoadStraightSegment *segA = nullptr;
for (const auto &s : segs)
if (s.nodeId == a)
segA = &s;
if (!segA)
return fail("no straight segment for node A");
Procedural::TriangleBuffer buf;
if (!RoadSystem::buildSegmentGeometry(*segA, rg, buf))
return fail("buildSegmentGeometry returned false");
Scan s = scan(buf);
if (!s.ok)
return fail("segment buffer has NaN or bad indices");
if (s.min.x < -0.1f || s.max.x > 10.05f ||
s.min.z < -3.05f || s.max.z > 3.05f)
return fail("segment slab extents wrong");
if (s.min.y > -0.14f || s.max.y < 0.14f ||
s.min.y < -0.16f || s.max.y > 0.16f)
return fail("segment slab missing top/bottom surface");
}
/*
* Case 2: asymmetric lanes (2 out, 1 in) shift the band to
* z in [-3, +6] on the +right side of the A->B direction.
*/
{
RoadGraph rg;
int a = rg.addNode(Ogre::Vector3(0, 0, 0));
int b = rg.addNode(Ogre::Vector3(20, 0, 0));
int e = rg.addEdge(a, b);
rg.edges[(size_t)e].lanesAtoB = 2;
rg.edges[(size_t)e].lanesBtoA = 1;
std::vector<RoadWedge> wedges;
std::vector<RoadStraightSegment> segs;
enumerateWedges(rg, wedges, segs);
const RoadStraightSegment *segA = nullptr;
for (const auto &s : segs)
if (s.nodeId == a)
segA = &s;
if (!segA)
return fail("no straight segment for node A (asym)");
Procedural::TriangleBuffer buf;
if (!RoadSystem::buildSegmentGeometry(*segA, rg, buf))
return fail("buildSegmentGeometry failed (asym)");
Scan s = scan(buf);
if (!s.ok)
return fail("asym segment buffer has NaN or bad indices");
if (s.min.z < -3.05f || s.min.z > -2.9f ||
s.max.z < 5.9f || s.max.z > 6.05f)
return fail("asym segment band extents wrong");
}
/*
* Case 3: 90-degree wedge fans around the outer corner without
* overshooting the L-shape. Corner at origin, neighbors at +X
* and +Z, default 1+1 lanes -> outer corner at (3, y, 3), all
* vertices inside [0, 10]^2 in XZ.
*/
{
RoadGraph rg;
int c = rg.addNode(Ogre::Vector3(0, 0, 0));
int px = rg.addNode(Ogre::Vector3(20, 0, 0));
int pz = rg.addNode(Ogre::Vector3(0, 0, 20));
rg.addEdge(c, px);
rg.addEdge(c, pz);
std::vector<RoadWedge> wedges;
std::vector<RoadStraightSegment> segs;
enumerateWedges(rg, wedges, segs);
const RoadWedge *w90 = nullptr, *w270 = nullptr;
for (const auto &w : wedges) {
if (fabsf(w.sweptAngleDeg - 90.0f) < 0.1f)
w90 = &w;
if (fabsf(w.sweptAngleDeg - 270.0f) < 0.1f)
w270 = &w;
}
if (!w90 || !w270)
return fail("L-corner wedges not found");
Procedural::TriangleBuffer buf;
if (!RoadSystem::buildWedgeGeometry(*w90, rg, buf))
return fail("buildWedgeGeometry failed for 90 deg");
Scan s = scan(buf);
if (!s.ok)
return fail("wedge buffer has NaN or bad indices");
if (s.min.x < -0.06f || s.min.z < -0.06f ||
s.max.x > 10.05f || s.max.z > 10.05f)
return fail("90 deg wedge overshoots the L-shape");
bool sawCorner = false;
for (const auto &v : buf.getVertices()) {
if (fabsf(v.mPosition.x - 3.0f) < 0.05f &&
fabsf(v.mPosition.z - 3.0f) < 0.05f) {
sawCorner = true;
break;
}
}
if (!sawCorner)
return fail("90 deg wedge missing outer corner (3,3)");
/* The 270-degree wedge takes the two-strip fallback path. */
Procedural::TriangleBuffer buf270;
if (!RoadSystem::buildWedgeGeometry(*w270, rg, buf270))
return fail("buildWedgeGeometry failed for 270 deg");
Scan s270 = scan(buf270);
if (!s270.ok)
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);
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");
}
Ogre::LogManager::getSingleton().logMessage(
"TerrainTests: road wedge geometry test passed");
return true;
}
bool TerrainTestRunner::testRoadPageMeshes(EditorApp &app, TerrainSystem *ts)
{
if (ts->isActive())
ts->deactivate();
flecs::entity e = createTerrainEntity(app);
/* One edge seeded on page (0,0). */
{
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 system or terrain "
"group");
destroyTerrainEntity(app, e);
pumpFrames(app, ts, 1);
return false;
}
const auto &pages = rs->getPageGeometry();
uint64_t key = group->packIndex(0, 0);
auto it = pages.find(key);
if (it == pages.end() || it->second.segments.empty()) {
Ogre::LogManager::getSingleton().logMessage(
"TerrainTests: FAIL - page (0,0) has no road segments");
destroyTerrainEntity(app, e);
pumpFrames(app, ts, 1);
return false;
}
flecs::entity be = it->second.bufferEntity;
if (!be.is_alive() || !be.has<TriangleBufferComponent>() ||
!be.has<TransformComponent>() ||
!be.has<NavMeshGeometrySource>() ||
!be.has<RenderableComponent>() || !be.has<LodComponent>()) {
Ogre::LogManager::getSingleton().logMessage(
"TerrainTests: FAIL - road page entity missing components");
destroyTerrainEntity(app, e);
pumpFrames(app, ts, 1);
return false;
}
{
const auto &tb = be.get<TriangleBufferComponent>();
if (!tb.proceduralContent || !tb.buffer ||
tb.buffer->getIndices().empty() || tb.meshName.empty()) {
Ogre::LogManager::getSingleton().logMessage(
"TerrainTests: FAIL - road buffer not filled "
"directly");
destroyTerrainEntity(app, e);
pumpFrames(app, ts, 1);
return false;
}
}
/* Convert to an Ogre mesh, then let RoadSystem finalize the page. */
std::string meshName = be.get<TriangleBufferComponent>().meshName;
pms->update();
pumpFrames(app, ts, 2);
{
const auto &tb = be.get<TriangleBufferComponent>();
const auto &rend = be.get<RenderableComponent>();
if (!tb.meshCreated || !tb.ogreEntity ||
rend.entity != tb.ogreEntity) {
Ogre::LogManager::getSingleton().logMessage(
"TerrainTests: FAIL - road mesh not created or "
"not mirrored to RenderableComponent");
destroyTerrainEntity(app, e);
pumpFrames(app, ts, 1);
return false;
}
if (!it->second.meshFinalized) {
Ogre::LogManager::getSingleton().logMessage(
"TerrainTests: FAIL - road page not finalized");
destroyTerrainEntity(app, e);
pumpFrames(app, ts, 1);
return false;
}
if (Ogre::MeshManager::getSingleton().getByName(meshName)
.isNull()) {
Ogre::LogManager::getSingleton().logMessage(
"TerrainTests: FAIL - road mesh resource missing");
destroyTerrainEntity(app, e);
pumpFrames(app, ts, 1);
return false;
}
/* M5.9: static mesh collider created from the render mesh. */
if (it->second.bodyId.IsInvalid()) {
Ogre::LogManager::getSingleton().logMessage(
"TerrainTests: FAIL - road collider body not "
"created");
destroyTerrainEntity(app, e);
pumpFrames(app, ts, 1);
return false;
}
if (!be.has<PhysicsColliderComponent>()) {
Ogre::LogManager::getSingleton().logMessage(
"TerrainTests: FAIL - road entity missing "
"PhysicsColliderComponent");
destroyTerrainEntity(app, e);
pumpFrames(app, ts, 1);
return false;
}
}
/* Destroying the terrain tears down road meshes and entities. */
destroyTerrainEntity(app, e);
pumpFrames(app, ts, 3);
if (ts->getRoadSystem() != nullptr) {
Ogre::LogManager::getSingleton().logMessage(
"TerrainTests: FAIL - RoadSystem survived terrain "
"destruction");
return false;
}
if (be.is_alive()) {
Ogre::LogManager::getSingleton().logMessage(
"TerrainTests: FAIL - road page entity survived terrain "
"destruction");
return false;
}
if (!Ogre::MeshManager::getSingleton().getByName(meshName).isNull()) {
Ogre::LogManager::getSingleton().logMessage(
"TerrainTests: FAIL - road mesh resource leaked");
return false;
}
Ogre::LogManager::getSingleton().logMessage(
"TerrainTests: road page meshes test passed");
return true;
}
bool TerrainTestRunner::testMemoryStability(EditorApp &app)
{
/* Pump remaining frames to flush any pending work. */
@@ -1499,6 +2381,11 @@ int TerrainTestRunner::run(EditorApp &app, int iterations)
{ "delete", testDeleteTerrain },
{ "roadDataModel", testRoadDataModel },
{ "roadSerialization", testRoadSerialization },
{ "roadTemplate", testRoadTemplate },
{ "roadWedgeEnumeration", testRoadWedgeEnumeration },
{ "roadPageAssignment", testRoadPageAssignment },
{ "roadWedgeGeometry", testRoadWedgeGeometry },
{ "roadPageMeshes", testRoadPageMeshes },
};
@@ -72,6 +72,11 @@ private:
static bool testDeleteTerrain(EditorApp &app, TerrainSystem *ts);
static bool testRoadDataModel(EditorApp &app, TerrainSystem *ts);
static bool testRoadSerialization(EditorApp &app, TerrainSystem *ts);
static bool testRoadTemplate(EditorApp &app, TerrainSystem *ts);
static bool testRoadWedgeEnumeration(EditorApp &app, TerrainSystem *ts);
static bool testRoadPageAssignment(EditorApp &app, TerrainSystem *ts);
static bool testRoadWedgeGeometry(EditorApp &app, TerrainSystem *ts);
static bool testRoadPageMeshes(EditorApp &app, TerrainSystem *ts);
static bool testMemoryStability(EditorApp &app);
static void logResult(const TerrainTestResult &r);
+30 -12
View File
@@ -630,27 +630,38 @@ private:
}
}
/* Node list. */
/* Node list. The row selectable is sized to leave room for
* the Connect button; a full-width selectable would underlap
* the button and swallow its click. */
ImGui::Text("Nodes: %zu", rg.nodes.size());
int selectedNodeId = rs->getSelectedNodeId();
for (const auto &n : rg.nodes) {
bool selected = (n.id == selectedNodeId);
std::string label = "Node " + std::to_string(n.id);
if (ImGui::Selectable(label.c_str(), selected)) {
bool showConnect =
selectedNodeId >= 0 && selectedNodeId != n.id;
float rowWidth = ImGui::GetContentRegionAvail().x;
if (showConnect)
rowWidth -= ImGui::CalcTextSize("Connect").x +
ImGui::GetStyle().FramePadding.x * 2.0f +
ImGui::GetStyle().ItemSpacing.x;
ImGui::PushID(n.id);
if (ImGui::Selectable(label.c_str(), selected, 0,
ImVec2(std::max(20.0f, rowWidth),
0))) {
rs->setSelectedNodeId(n.id);
rs->setSelectedEdgeIndex(-1);
}
if (selectedNodeId >= 0 && selectedNodeId != n.id) {
if (showConnect) {
ImGui::SameLine();
std::string connectLabel = "Connect##" +
std::to_string(n.id);
if (ImGui::SmallButton(connectLabel.c_str())) {
if (ImGui::SmallButton("Connect"))
rg.joinNodes(selectedNodeId, n.id);
}
}
ImGui::PopID();
}
/* Edge list. */
/* Edge list. Same sizing rule as the node list: keep the
* selectable clear of the Remove button. */
ImGui::Text("Edges: %zu", rg.edges.size());
for (size_t i = 0; i < rg.edges.size(); ++i) {
const auto &e = rg.edges[i];
@@ -658,19 +669,26 @@ private:
std::string label = "Edge " + std::to_string(i) + ": " +
std::to_string(e.nodeA) + " <-> " +
std::to_string(e.nodeB);
if (ImGui::Selectable(label.c_str(), selected)) {
float rowWidth = ImGui::GetContentRegionAvail().x;
if (selected)
rowWidth -= ImGui::CalcTextSize("Remove").x +
ImGui::GetStyle().FramePadding.x * 2.0f +
ImGui::GetStyle().ItemSpacing.x;
ImGui::PushID((int)i);
if (ImGui::Selectable(label.c_str(), selected, 0,
ImVec2(std::max(20.0f, rowWidth),
0))) {
rs->setSelectedEdgeIndex((int)i);
rs->setSelectedNodeId(-1);
}
if (selected) {
ImGui::SameLine();
std::string removeLabel = "Remove##edge" +
std::to_string(i);
if (ImGui::SmallButton(removeLabel.c_str())) {
if (ImGui::SmallButton("Remove")) {
rg.removeEdge(i);
rs->setSelectedEdgeIndex(-1);
}
}
ImGui::PopID();
}
/* Selected node inspector. */