snapshot 20260729
This commit is contained in:
@@ -235,6 +235,10 @@ public:
|
||||
{
|
||||
return m_characterSpawnerSystem.get();
|
||||
}
|
||||
ProceduralMeshSystem *getProceduralMeshSystem() const
|
||||
{
|
||||
return m_proceduralMeshSystem.get();
|
||||
}
|
||||
StartupMenuSystem *getStartupMenuSystem() const
|
||||
{
|
||||
return m_startupMenuSystem.get();
|
||||
|
||||
@@ -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.4–M5.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.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).
|
||||
|
||||
### Milestone 6 — Prefab spawns and terrain compliance
|
||||
- `TerrainPrefabSpawnerComponent` + `TerrainPrefabSpawnerModule`.
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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. */
|
||||
|
||||
Reference in New Issue
Block a user