diff --git a/src/features/editScene/EditorApp.hpp b/src/features/editScene/EditorApp.hpp index 7c25b3d..6ccc0ba 100644 --- a/src/features/editScene/EditorApp.hpp +++ b/src/features/editScene/EditorApp.hpp @@ -235,6 +235,10 @@ public: { return m_characterSpawnerSystem.get(); } + ProceduralMeshSystem *getProceduralMeshSystem() const + { + return m_proceduralMeshSystem.get(); + } StartupMenuSystem *getStartupMenuSystem() const { return m_startupMenuSystem.get(); diff --git a/src/features/editScene/TerrainRequirements.md b/src/features/editScene/TerrainRequirements.md index 2bdeccf..728822a 100644 --- a/src/features/editScene/TerrainRequirements.md +++ b/src/features/editScene/TerrainRequirements.md @@ -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 collisionVertices; std::vector collisionIndices; std::vector spawnedPrefabs; + + /* Wedges and straight segments seeded by nodes inside this page. */ + std::vector wedges; + std::vector segments; + + bool dirty = true; /* geometry needs (re)generation */ }; std::unordered_map m_pageGeometry; ``` @@ -2391,6 +2521,17 @@ std::unordered_map 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 samples; bool dirty; }; + static constexpr int FIXUP_CHUNK_RES = 256; + static constexpr float FIXUP_SENTINEL = -FLT_MAX; + std::map, 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 &out) const; + std::string fixupChunkPath(int chunkX, int chunkZ) const; +``` + +Section 4.2 specifies sparse fixup chunks (absolute-height overrides stored in +`heightmaps//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_z.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//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`. diff --git a/src/features/editScene/components/RoadGraph.hpp b/src/features/editScene/components/RoadGraph.hpp index cd8f177..6f786c6 100644 --- a/src/features/editScene/components/RoadGraph.hpp +++ b/src/features/editScene/components/RoadGraph.hpp @@ -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 &outWedges, + std::vector &outSegments) +{ + outWedges.clear(); + outSegments.clear(); + + for (const auto &node : graph.nodes) { + std::vector 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 wedges; + std::vector 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 diff --git a/src/features/editScene/components/TriangleBuffer.hpp b/src/features/editScene/components/TriangleBuffer.hpp index 45b8dd1..94d34be 100644 --- a/src/features/editScene/components/TriangleBuffer.hpp +++ b/src/features/editScene/components/TriangleBuffer.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; diff --git a/src/features/editScene/systems/ProceduralMeshSystem.cpp b/src/features/editScene/systems/ProceduralMeshSystem.cpp index 56686ec..13156bf 100644 --- a/src/features/editScene/systems/ProceduralMeshSystem.cpp +++ b/src/features/editScene/systems/ProceduralMeshSystem.cpp @@ -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(); diff --git a/src/features/editScene/systems/RoadSystem.cpp b/src/features/editScene/systems/RoadSystem.cpp index efbd4aa..0784a2f 100644 --- a/src/features/editScene/systems/RoadSystem.cpp +++ b/src/features/editScene/systems/RoadSystem.cpp @@ -1,8 +1,19 @@ #include "RoadSystem.hpp" #include "../components/Terrain.hpp" #include "../components/Transform.hpp" +#include "../components/RoadGraph.hpp" +#include "../components/TriangleBuffer.hpp" +#include "../components/ProceduralMaterial.hpp" +#include "../components/NavMesh.hpp" +#include "../components/Renderable.hpp" +#include "../components/Lod.hpp" +#include "../components/PhysicsCollider.hpp" +#include "../physics/physics.h" +#include +#include #include #include +#include static const float NODE_SIZE = 0.25f; static const float SELECTED_NODE_SIZE = 0.45f; @@ -17,6 +28,7 @@ RoadSystem::RoadSystem(flecs::world &world, Ogre::SceneManager *sceneMgr) RoadSystem::~RoadSystem() { + clearPageGeometry(); destroyManualObjects(); } @@ -84,6 +96,7 @@ void RoadSystem::setTerrainEntity(flecs::entity entity) m_terrainEntityId = 0; } m_lastGraphVersion = 0; + clearPageGeometry(); rebuildVisualAids(); } @@ -93,9 +106,27 @@ void RoadSystem::clear() m_selectedNodeId = -1; m_selectedEdgeIndex = -1; m_lastGraphVersion = 0; + clearPageGeometry(); rebuildVisualAids(); } +void RoadSystem::setTerrainGroup(Ogre::TerrainGroup *group) +{ + if (m_terrainGroup == group) + return; + m_terrainGroup = group; + clearPageGeometry(); +} + +void RoadSystem::setPhysics(JoltPhysicsWrapper *physics) +{ + if (m_physics == physics) + return; + for (auto &kv : m_pageGeometry) + destroyPageCollider(kv.second); + m_physics = physics; +} + flecs::entity RoadSystem::getTerrainEntity() const { return m_world.entity(m_terrainEntityId); @@ -139,6 +170,7 @@ void RoadSystem::update(float deltaTime) m_lastGraphVersion = 0; rebuildVisualAids(); } + clearPageGeometry(); return; } @@ -147,6 +179,43 @@ void RoadSystem::update(float deltaTime) m_lastGraphVersion = tc.roadGraph.version; rebuildVisualAids(); } + + /* Geometry bucketing follows the graph version independently of the + * visual aids. */ + if (tc.roadGraph.version != m_geometryGraphVersion) { + m_geometryGraphVersion = tc.roadGraph.version; + reassignWedges(); + } + syncPages(); + + /* Mesh assembly (M5.8): rebuild dirty pages, then finalize pages + * whose Ogre mesh ProceduralMeshSystem has since created. */ + for (auto &kv : m_pageGeometry) { + RoadPageGeometry &pg = kv.second; + if (pg.dirty) { + buildPageMeshes(pg); + pg.dirty = false; + } + if (pg.meshFinalized || !pg.bufferEntity.is_alive() || + !pg.bufferEntity.has()) + continue; + + auto &tb = pg.bufferEntity.get_mut(); + if (!tb.meshCreated || !tb.ogreEntity) + continue; + + tb.ogreEntity->setRenderingDistance( + tc.roadGraph.config.roadVisibilityDistance); + if (pg.bufferEntity.has()) + pg.bufferEntity.get_mut().entity = + tb.ogreEntity; + if (pg.bufferEntity.has()) + pg.bufferEntity.get_mut() + .markChanged(); + markNavMeshDirty(); + createPageCollider(pg, tb.meshName); + pg.meshFinalized = true; + } } Ogre::Vector3 RoadSystem::getNodePosition(int nodeId) const @@ -184,6 +253,411 @@ bool RoadSystem::getEdgePositions(int edgeIndex, Ogre::Vector3 &outA, return true; } +/* ------------------------------------------------------------------ */ +/* Page tracking (M5.4) */ +/* ------------------------------------------------------------------ */ + +bool RoadSystem::pageKeyForNode(int nodeId, uint64_t &outKey) const +{ + if (!m_terrainGroup) + return false; + + flecs::entity terrain = getTerrainEntity(); + if (!terrain.is_alive() || !terrain.has()) + return false; + + const RoadNode *n = + terrain.get().roadGraph.findNodeById(nodeId); + if (!n) + return false; + + long px = 0, py = 0; + m_terrainGroup->convertWorldPositionToTerrainSlot(n->position, &px, + &py); + outKey = m_terrainGroup->packIndex(px, py); + return true; +} + +void RoadSystem::applyPageBucket(uint64_t key, RoadPageGeometry &pg) +{ + pg.wedges.clear(); + pg.segments.clear(); + auto it = m_wedgeBuckets.find(key); + if (it != m_wedgeBuckets.end()) { + pg.wedges = it->second.wedges; + pg.segments = it->second.segments; + } + pg.dirty = true; +} + +void RoadSystem::reassignWedges() +{ + m_wedgeBuckets.clear(); + + flecs::entity terrain = getTerrainEntity(); + if (!terrain.is_alive() || !terrain.has()) + return; + + const RoadGraph &rg = terrain.get().roadGraph; + + std::vector wedges; + std::vector segs; + enumerateWedges(rg, wedges, segs); + + for (const auto &w : wedges) { + uint64_t key = 0; + if (pageKeyForNode(w.nodeId, key)) + m_wedgeBuckets[key].wedges.push_back(w); + } + for (const auto &s : segs) { + uint64_t key = 0; + if (pageKeyForNode(s.nodeId, key)) + m_wedgeBuckets[key].segments.push_back(s); + } + + /* Push the fresh buckets into the tracked pages. */ + for (auto &kv : m_pageGeometry) + applyPageBucket(kv.first, kv.second); +} + +void RoadSystem::syncPages() +{ + if (!m_terrainGroup) { + clearPageGeometry(); + return; + } + + /* Collect the currently loaded pages and create entries for new + * ones. */ + std::set loaded; + for (const auto &kv : m_terrainGroup->getTerrainSlots()) { + Ogre::TerrainGroup::TerrainSlot *slot = kv.second; + if (!slot || !slot->instance || !slot->instance->isLoaded()) + continue; + + uint64_t key = m_terrainGroup->packIndex(slot->x, slot->y); + loaded.insert(key); + + if (m_pageGeometry.find(key) != m_pageGeometry.end()) + continue; + + RoadPageGeometry pg; + pg.pageX = slot->x; + pg.pageY = slot->y; + applyPageBucket(key, pg); + m_pageGeometry.emplace(key, std::move(pg)); + } + + /* Destroy entries whose page unloaded or was removed. */ + for (auto it = m_pageGeometry.begin(); it != m_pageGeometry.end();) { + if (loaded.find(it->first) == loaded.end()) { + destroyPageGeometry(it->second); + it = m_pageGeometry.erase(it); + } else { + ++it; + } + } +} + +void RoadSystem::destroyPageGeometry(RoadPageGeometry &pg) +{ + destroyPageMeshes(pg); + + if (pg.colliderEntity.is_alive()) + pg.colliderEntity.destruct(); + pg.colliderEntity = flecs::entity::null(); + + for (flecs::entity prefab : pg.spawnedPrefabs) { + if (prefab.is_alive()) + prefab.destruct(); + } + pg.spawnedPrefabs.clear(); + + pg.collisionVertices.clear(); + pg.collisionIndices.clear(); + pg.wedges.clear(); + pg.segments.clear(); +} + +void RoadSystem::clearPageGeometry() +{ + for (auto &kv : m_pageGeometry) + destroyPageGeometry(kv.second); + m_pageGeometry.clear(); + m_wedgeBuckets.clear(); + m_geometryGraphVersion = 0; + + if (m_materialEntity.is_alive()) + m_materialEntity.destruct(); + m_materialEntity = flecs::entity::null(); + if (m_lodSettingsEntity.is_alive()) + m_lodSettingsEntity.destruct(); + m_lodSettingsEntity = flecs::entity::null(); +} + +/* ------------------------------------------------------------------ */ +/* Mesh assembly (M5.8) */ +/* ------------------------------------------------------------------ */ + +void RoadSystem::buildPageMeshes(RoadPageGeometry &pg) +{ + pg.meshFinalized = false; + /* The mesh is about to be recreated; the collider built from the + * old mesh must go first. */ + destroyPageCollider(pg); + + flecs::entity terrain = getTerrainEntity(); + if (!terrain.is_alive() || !terrain.has()) { + destroyPageMeshes(pg); + return; + } + const TerrainComponent &tc = terrain.get(); + const RoadGraph &rg = tc.roadGraph; + + auto buffer = std::make_shared(); + for (const RoadWedge &w : pg.wedges) + buildWedgeGeometry(w, rg, *buffer); + for (const RoadStraightSegment &s : pg.segments) + buildSegmentGeometry(s, rg, *buffer); + + if (buffer->getIndices().empty()) { + /* No road content seeded on this page. */ + destroyPageMeshes(pg); + return; + } + + std::string meshName = "Road_" + std::to_string(tc.terrainId) + "_" + + std::to_string(pg.pageX) + "_" + + std::to_string(pg.pageY); + + flecs::entity e = pg.bufferEntity; + if (!e.is_alive()) { + e = m_world.entity(); + + /* + * Deliberately NOT a flecs child of the terrain entity and + * NOT a child of the terrain scene node: flecs cascade + * deletion and Ogre node destruction would bypass the + * explicit Ogre cleanup in destroyPageMeshes() (there is no + * OnRemove hook for TriangleBufferComponent). No + * EditorMarkerComponent either, so the entity stays out of + * the editor scene outliner. + */ + TransformComponent xf; + xf.node = m_sceneMgr->getRootSceneNode()->createChildSceneNode( + meshName + "_node"); + e.set(xf); + + NavMeshGeometrySource nmg; + nmg.include = true; + e.set(nmg); + + RenderableComponent rend; + rend.meshName = meshName; + e.set(rend); + + pg.bufferEntity = e; + } + + TriangleBufferComponent tb; + if (e.has()) + tb = e.get(); + tb.buffer = buffer; + tb.proceduralContent = true; + tb.meshName = meshName; + tb.materialEntity = ensureRoadMaterial(rg.config); + tb.dirty = true; + e.set(tb); + + /* LOD rebuilds the Mesh resource in place; a rebuilt road mesh + * needs a fresh application, so mark dirty on every build. */ + if (!e.has()) { + LodComponent lod; + lod.settingsEntity = ensureRoadLodSettings(rg.config); + e.set(lod); + } else { + e.get_mut().markDirty(); + } +} + +void RoadSystem::destroyPageMeshes(RoadPageGeometry &pg) +{ + bool contributedToNavMesh = false; + + destroyPageCollider(pg); + + if (pg.bufferEntity.is_alive()) { + flecs::entity e = pg.bufferEntity; + + if (e.has()) { + auto &tb = e.get_mut(); + contributedToNavMesh = tb.meshCreated; + if (tb.ogreEntity) { + try { + m_sceneMgr->destroyEntity(tb.ogreEntity); + } catch (...) { + } + tb.ogreEntity = nullptr; + } + if (!tb.meshName.empty()) { + try { + Ogre::MeshManager::getSingleton() + .remove(tb.meshName); + } catch (...) { + } + } + tb.meshCreated = false; + } + + if (e.has()) { + auto &xf = e.get_mut(); + if (xf.node) { + m_sceneMgr->destroySceneNode(xf.node); + xf.node = nullptr; + } + } + + e.destruct(); + } + pg.bufferEntity = flecs::entity::null(); + pg.meshFinalized = false; + + if (contributedToNavMesh) + markNavMeshDirty(); +} + +flecs::entity RoadSystem::ensureRoadMaterial(const RoadConfig &cfg) +{ + if (m_materialEntity.is_alive() && + m_materialEntity.has()) { + const auto &pm = + m_materialEntity.get(); + if (pm.materialName == cfg.roadMaterialName) + return m_materialEntity; + m_materialEntity.destruct(); + m_materialEntity = flecs::entity::null(); + } + + /* + * Make sure a plain material with the configured name exists before + * ProceduralMaterialSystem processes the component, so mesh + * conversion never races material creation. The material system + * reuses the existing resource and restyles it in place. + */ + if (!Ogre::MaterialManager::getSingleton().resourceExists( + cfg.roadMaterialName)) { + Ogre::MaterialPtr mat = + Ogre::MaterialManager::getSingleton().create( + cfg.roadMaterialName, + Ogre::ResourceGroupManager:: + DEFAULT_RESOURCE_GROUP_NAME); + Ogre::Technique *tech = mat->getTechnique(0); + Ogre::Pass *pass = tech->getNumPasses() > 0 + ? tech->getPass(0) + : tech->createPass(); + pass->setAmbient(0.2f, 0.2f, 0.2f); + pass->setDiffuse(0.35f, 0.35f, 0.35f, 1.0f); + } + + ProceduralMaterialComponent pm; + pm.materialName = cfg.roadMaterialName; + pm.diffuse[0] = 0.35f; + pm.diffuse[1] = 0.35f; + pm.diffuse[2] = 0.35f; + m_materialEntity = m_world.entity(); + m_materialEntity.set(pm); + return m_materialEntity; +} + +flecs::entity RoadSystem::ensureRoadLodSettings(const RoadConfig &cfg) +{ + if (m_lodSettingsEntity.is_alive() && + m_lodSettingsEntity.has()) + return m_lodSettingsEntity; + + LodSettingsComponent ls; + ls.settingsId = "RoadLodSettings"; + ls.addProportionalLevel(cfg.roadLodDistance, 0.5f); + m_lodSettingsEntity = m_world.entity(); + m_lodSettingsEntity.set(ls); + return m_lodSettingsEntity; +} + +void RoadSystem::markNavMeshDirty() +{ + /* + * TileCacheNavMesh bakes the input geometry soup at build time, so + * partial tile rebuilds cannot pick up newly appeared (or remove + * disappeared) road meshes; request a full rebuild instead. + */ + m_world.query().each( + [](flecs::entity, NavMeshComponent &nm) { + if (nm.enabled) + nm.dirty = true; + }); +} + +/* ------------------------------------------------------------------ */ +/* Physics colliders (M5.9) */ +/* ------------------------------------------------------------------ */ + +void RoadSystem::createPageCollider(RoadPageGeometry &pg, + const std::string &meshName) +{ + if (!m_physics || !pg.bodyId.IsInvalid()) + return; + + JPH::ShapeRefC shape; + try { + shape = m_physics->createMeshShape(meshName); + } catch (...) { + shape = nullptr; + } + if (!shape) { + Ogre::LogManager::getSingleton().logMessage( + "RoadSystem: mesh shape creation failed for " + meshName); + return; + } + + JPH::BodyCreationSettings settings( + shape.GetPtr(), JPH::RVec3::sZero(), JPH::Quat::sIdentity(), + JPH::EMotionType::Static, Layers::NON_MOVING); + JPH::BodyID bodyId = m_physics->createBody(settings); + if (bodyId.IsInvalid()) { + Ogre::LogManager::getSingleton().logMessage( + "RoadSystem: collider body creation failed for " + + meshName); + return; + } + m_physics->addBody(bodyId, JPH::EActivation::DontActivate); + pg.bodyId = bodyId; + + /* Informational component: mirrors the collider configuration on + * the buffer entity (no RigidBodyComponent, so PhysicsSystem does + * not build a second body from it). */ + if (pg.bufferEntity.is_alive()) { + PhysicsColliderComponent col; + col.shapeType = PhysicsColliderComponent::ShapeType::Mesh; + col.meshName = meshName; + col.shape = shape; + col.shapeDirty = false; + pg.bufferEntity.set(col); + } +} + +void RoadSystem::destroyPageCollider(RoadPageGeometry &pg) +{ + if (m_physics && !pg.bodyId.IsInvalid()) { + m_physics->removeBody(pg.bodyId); + m_physics->destroyBody(pg.bodyId); + } + pg.bodyId = JPH::BodyID(); + + if (pg.bufferEntity.is_alive() && + pg.bufferEntity.has()) + pg.bufferEntity.remove(); +} + static void addBox(Ogre::ManualObject *mo, const Ogre::Vector3 ¢er, float halfSize, const Ogre::ColourValue &color) { @@ -380,3 +854,633 @@ void RoadSystem::buildSelectionHighlight() m_selectionHighlight->end(); } + +/* ------------------------------------------------------------------ */ +/* Road mesh template (M5.3) */ +/* ------------------------------------------------------------------ */ + +const Procedural::TriangleBuffer & +RoadSystem::getRoadTemplate(const RoadConfig &cfg) +{ + if (cfg.roadMeshTemplate == m_templateName && + cfg.roadThickness == m_templateThickness) + return m_templateBuffer; + + m_templateName = cfg.roadMeshTemplate; + m_templateThickness = cfg.roadThickness; + m_templateBuffer = Procedural::TriangleBuffer(); + + if (!loadTemplateFromMesh(cfg.roadMeshTemplate)) + buildFallbackTemplate(cfg.roadThickness); + + return m_templateBuffer; +} + +bool RoadSystem::loadTemplateFromMesh(const std::string &meshName) +{ + if (meshName.empty()) + return false; + + Ogre::MeshPtr mesh; + try { + mesh = Ogre::MeshManager::getSingleton().load( + meshName, + Ogre::ResourceGroupManager::DEFAULT_RESOURCE_GROUP_NAME); + } catch (const std::exception &e) { + Ogre::LogManager::getSingleton().logMessage( + "RoadSystem: road mesh template '" + meshName + + "' unavailable (" + e.what() + + "), using fallback box"); + return false; + } + if (!mesh || mesh->getNumSubMeshes() == 0) + return false; + + Procedural::TriangleBuffer tb; + + for (unsigned si = 0; si < mesh->getNumSubMeshes(); ++si) { + Ogre::SubMesh *sub = mesh->getSubMesh(si); + Ogre::VertexData *vd = sub->useSharedVertices ? + mesh->sharedVertexData : + sub->vertexData; + if (!vd || !sub->indexData || !sub->indexData->indexBuffer) + continue; + + const Ogre::VertexElement *posElem = + vd->vertexDeclaration->findElementBySemantic( + Ogre::VES_POSITION); + if (!posElem) + continue; + const Ogre::VertexElement *normElem = + vd->vertexDeclaration->findElementBySemantic( + Ogre::VES_NORMAL); + const Ogre::VertexElement *uvElem = + vd->vertexDeclaration->findElementBySemantic( + Ogre::VES_TEXTURE_COORDINATES, 0); + + int base = (int)tb.getVertices().size(); + tb.getVertices().reserve(base + vd->vertexCount); + + /* Positions (required). */ + { + Ogre::HardwareVertexBufferSharedPtr vbuf = + vd->vertexBufferBinding->getBuffer( + posElem->getSource()); + unsigned char *data = static_cast( + vbuf->lock(Ogre::HardwareBuffer::HBL_READ_ONLY)); + for (size_t v = 0; v < vd->vertexCount; ++v) { + float *p; + posElem->baseVertexPointerToElement( + data + v * vbuf->getVertexSize(), &p); + Procedural::TriangleBuffer::Vertex tv; + tv.mPosition = + Ogre::Vector3(p[0], p[1], p[2]); + tv.mNormal = Ogre::Vector3::UNIT_Y; + /* Convention fallback: UVs span the X/Z + * extents. */ + tv.mUV = Ogre::Vector2(p[0], p[2]); + tb.getVertices().push_back(tv); + } + vbuf->unlock(); + } + + /* Normals (optional). */ + if (normElem) { + Ogre::HardwareVertexBufferSharedPtr vbuf = + vd->vertexBufferBinding->getBuffer( + normElem->getSource()); + unsigned char *data = static_cast( + vbuf->lock(Ogre::HardwareBuffer::HBL_READ_ONLY)); + for (size_t v = 0; v < vd->vertexCount; ++v) { + float *p; + normElem->baseVertexPointerToElement( + data + v * vbuf->getVertexSize(), &p); + tb.getVertices()[base + v].mNormal = + Ogre::Vector3(p[0], p[1], p[2]); + } + vbuf->unlock(); + } + + /* UVs (optional). */ + if (uvElem) { + Ogre::HardwareVertexBufferSharedPtr vbuf = + vd->vertexBufferBinding->getBuffer( + uvElem->getSource()); + unsigned char *data = static_cast( + vbuf->lock(Ogre::HardwareBuffer::HBL_READ_ONLY)); + for (size_t v = 0; v < vd->vertexCount; ++v) { + float *p; + uvElem->baseVertexPointerToElement( + data + v * vbuf->getVertexSize(), &p); + tb.getVertices()[base + v].mUV = + Ogre::Vector2(p[0], p[1]); + } + vbuf->unlock(); + } + + /* Indices (16- or 32-bit). */ + Ogre::HardwareIndexBufferSharedPtr ibuf = + sub->indexData->indexBuffer; + size_t start = sub->indexData->indexStart; + size_t count = sub->indexData->indexCount; + tb.getIndices().reserve(tb.getIndices().size() + count); + if (ibuf->getType() == Ogre::HardwareIndexBuffer::IT_16BIT) { + const uint16_t *p = static_cast( + ibuf->lock(Ogre::HardwareBuffer::HBL_READ_ONLY)); + for (size_t i = start; i < start + count; ++i) + tb.getIndices().push_back(base + (int)p[i]); + ibuf->unlock(); + } else { + const uint32_t *p = static_cast( + ibuf->lock(Ogre::HardwareBuffer::HBL_READ_ONLY)); + for (size_t i = start; i < start + count; ++i) + tb.getIndices().push_back(base + (int)p[i]); + ibuf->unlock(); + } + } + + if (tb.getVertices().empty() || tb.getIndices().empty()) + return false; + + /* Convention check: X within [0,1] and roughly 1 unit long, + * Z within [-1,1] and roughly 1 unit wide. Violations are warned + * about but the mesh is still used as-is. */ + Ogre::Vector3 mn = tb.getVertices()[0].mPosition; + Ogre::Vector3 mx = mn; + for (const auto &v : tb.getVertices()) { + mn.makeFloor(v.mPosition); + mx.makeCeil(v.mPosition); + } + if (mn.x < -0.001f || mx.x > 1.001f || (mx.x - mn.x) < 0.5f || + mn.z < -1.001f || mx.z > 1.001f || (mx.z - mn.z) < 0.5f) { + Ogre::LogManager::getSingleton().logMessage( + "RoadSystem: road mesh template '" + meshName + + "' violates the template conventions " + "(X [0,1], Z [-1,1], unit extents); bounds are (" + + Ogre::StringConverter::toString(mn) + ") .. (" + + Ogre::StringConverter::toString(mx) + + "), using it anyway"); + } + + m_templateBuffer = tb; + return true; +} + +void RoadSystem::buildFallbackTemplate(float roadThickness) +{ + float h = std::max(0.01f, roadThickness) * 0.5f; + + m_templateBuffer = Procedural::TriangleBuffer(); + auto &verts = m_templateBuffer.getVertices(); + auto &indices = m_templateBuffer.getIndices(); + verts.reserve(24); + indices.reserve(36); + + struct Corner { + Ogre::Vector3 p; + Ogre::Vector2 uv; + }; + + /* One quad face: 4 vertices, 2 triangles, counter-clockwise + * seen from outside (Ogre front face). Top/bottom faces map UV to + * (x, z); side faces map u along their horizontal extent and v + * along Y so every face spans (0,0)-(1,1). */ + auto addFace = [&](const Corner &a, const Corner &b, const Corner &c, + const Corner &d, const Ogre::Vector3 &normal) { + int base = (int)verts.size(); + for (const Corner *q : { &a, &b, &c, &d }) { + Procedural::TriangleBuffer::Vertex v; + v.mPosition = q->p; + v.mNormal = normal; + v.mUV = q->uv; + verts.push_back(v); + } + indices.push_back(base + 0); + indices.push_back(base + 1); + indices.push_back(base + 2); + indices.push_back(base + 0); + indices.push_back(base + 2); + indices.push_back(base + 3); + }; + + /* Top (+Y): Z in [0,1], X in [0,1]. */ + addFace({ { 0, h, 0 }, { 0, 0 } }, { { 0, h, 1 }, { 0, 1 } }, + { { 1, h, 1 }, { 1, 1 } }, { { 1, h, 0 }, { 1, 0 } }, + Ogre::Vector3::UNIT_Y); + /* Bottom (-Y). */ + addFace({ { 0, -h, 0 }, { 0, 0 } }, { { 1, -h, 0 }, { 1, 0 } }, + { { 1, -h, 1 }, { 1, 1 } }, { { 0, -h, 1 }, { 0, 1 } }, + Ogre::Vector3::NEGATIVE_UNIT_Y); + /* Front (+Z). */ + addFace({ { 0, -h, 1 }, { 0, 0 } }, { { 1, -h, 1 }, { 1, 0 } }, + { { 1, h, 1 }, { 1, 1 } }, { { 0, h, 1 }, { 0, 1 } }, + Ogre::Vector3::UNIT_Z); + /* Back (-Z). */ + addFace({ { 1, -h, 0 }, { 0, 0 } }, { { 0, -h, 0 }, { 1, 0 } }, + { { 0, h, 0 }, { 1, 1 } }, { { 1, h, 0 }, { 0, 1 } }, + Ogre::Vector3::NEGATIVE_UNIT_Z); + /* Right (+X). */ + addFace({ { 1, -h, 1 }, { 0, 0 } }, { { 1, -h, 0 }, { 1, 0 } }, + { { 1, h, 0 }, { 1, 1 } }, { { 1, h, 1 }, { 0, 1 } }, + Ogre::Vector3::UNIT_X); + /* Left (-X). */ + addFace({ { 0, -h, 0 }, { 0, 0 } }, { { 0, -h, 1 }, { 1, 0 } }, + { { 0, h, 1 }, { 1, 1 } }, { { 0, h, 0 }, { 0, 1 } }, + Ogre::Vector3::NEGATIVE_UNIT_X); +} + + +/* ------------------------------------------------------------------ */ +/* Wedge / segment geometry generation (M5.6) */ +/* ------------------------------------------------------------------ */ + +/** + * Right-of-travel direction for a horizontal road direction @p d. + * + * Template convention: +X forward x +Y up = +Z right, so for any + * normalized horizontal direction the right side is d x UNIT_Y. This is + * also the direction of increasing atan2(z, x) angle, i.e. the side a + * wedge sweeps toward from its first half-edge. + */ +static Ogre::Vector3 roadRightVec(const Ogre::Vector3 &d) +{ + return d.crossProduct(Ogre::Vector3::UNIT_Y); +} + +/** + * Absolute road surface heights at both ends of a half-edge. + * + * yNode is the surface height at the seed node; yMid is the surface + * height at the edge midpoint, averaged between the linearly + * interpolated heights of both edge ends. + */ +static void halfEdgeHeights(const RoadHalfEdge &he, const RoadGraph &graph, + float &yNode, float &yMid) +{ + const RoadNode *node = graph.findNodeById(he.nodeId); + const RoadNode *neighbor = graph.findNodeById(he.neighborId); + float nodeY = node ? node->position.y : 0.0f; + float neighborY = neighbor ? neighbor->position.y : nodeY; + + yNode = nodeY + he.roadLevelAtNode; + yMid = 0.5f * (yNode + neighborY + he.roadLevelAtNeighbor); +} + +/** Road surface height at distance @p t along a half-edge. */ +static float halfEdgeHeightAt(const RoadHalfEdge &he, const RoadGraph &graph, + float t) +{ + float yNode, yMid; + halfEdgeHeights(he, graph, yNode, yMid); + float l = he.halfLength > 1e-4f ? he.halfLength : 1e-4f; + return yNode + (yMid - yNode) * (t / l); +} + +/** + * Along-road texture coordinate at distance @p t from the seed node. + * + * For the nodeA half of an edge this is simply t; for the nodeB half it + * is 2*halfLength - t, so u stays phase-continuous across the edge + * midpoint where the two halves meet. + */ +static float halfEdgeU(const RoadHalfEdge &he, const RoadGraph &graph, + float t) +{ + if (he.edgeIndex >= 0 && he.edgeIndex < (int)graph.edges.size() && + graph.edges[he.edgeIndex].nodeB == he.nodeId) + return 2.0f * he.halfLength - t; + return t; +} + +/** One center-surface triangle with UVs, input to emitSlab(). */ +struct RoadSurfTri { + Ogre::Vector3 p[3]; + Ogre::Vector2 uv[3]; +}; + +/** One exposed center-surface boundary edge that gets a side skirt. */ +struct RoadSkirtEdge { + Ogre::Vector3 p0, p1; + Ogre::Vector2 uv0, uv1; +}; + +/** Append one triangle; degenerate (zero-area) triangles are skipped. */ +static void emitTri(Procedural::TriangleBuffer &out, const Ogre::Vector3 &p0, + const Ogre::Vector3 &p1, const Ogre::Vector3 &p2, + const Ogre::Vector2 &uv0, const Ogre::Vector2 &uv1, + const Ogre::Vector2 &uv2) +{ + Ogre::Vector3 n = (p1 - p0).crossProduct(p2 - p0); + if (n.squaredLength() < 1e-10f) + return; + n.normalise(); + + int base = (int)out.getVertices().size(); + const Ogre::Vector3 *pp[3] = { &p0, &p1, &p2 }; + const Ogre::Vector2 *uu[3] = { &uv0, &uv1, &uv2 }; + for (int i = 0; i < 3; ++i) { + Procedural::TriangleBuffer::Vertex v; + v.mPosition = *pp[i]; + v.mNormal = n; + v.mUV = *uu[i]; + out.getVertices().push_back(v); + out.getIndices().push_back(base + i); + } +} + +/** + * Turn a set of center-surface triangles into a solid slab. + * + * Every triangle is emitted twice: offset by +halfThick along Y with the + * winding chosen so the normal points up, and offset by -halfThick with + * the opposite winding. Each listed boundary edge grows a vertical + * skirt quad whose normal points away from @p refPoint (an interior + * reference, e.g. the primitive's centroid). + */ +static void emitSlab(Procedural::TriangleBuffer &out, + const std::vector &tris, + const std::vector &skirts, float halfThick, + const Ogre::Vector3 &refPoint) +{ + Ogre::Vector3 up(0.0f, halfThick, 0.0f); + + for (const RoadSurfTri &t : tris) { + Ogre::Vector3 n = + (t.p[1] - t.p[0]).crossProduct(t.p[2] - t.p[0]); + if (n.squaredLength() < 1e-10f) + continue; + int i1 = n.y >= 0.0f ? 1 : 2; + int i2 = n.y >= 0.0f ? 2 : 1; + + /* Top surface. */ + emitTri(out, t.p[0] + up, t.p[i1] + up, t.p[i2] + up, + t.uv[0], t.uv[i1], t.uv[i2]); + /* Bottom surface, flipped. */ + emitTri(out, t.p[0] - up, t.p[i2] - up, t.p[i1] - up, + t.uv[0], t.uv[i2], t.uv[i1]); + } + + float thickness = 2.0f * halfThick; + for (const RoadSkirtEdge &e : skirts) { + Ogre::Vector3 t0 = e.p0 + up; + Ogre::Vector3 t1 = e.p1 + up; + Ogre::Vector3 b0 = e.p0 - up; + Ogre::Vector3 b1 = e.p1 - up; + Ogre::Vector2 uvB0(e.uv0.x, e.uv0.y - thickness); + Ogre::Vector2 uvB1(e.uv1.x, e.uv1.y - thickness); + + Ogre::Vector3 n = (t1 - t0).crossProduct(b0 - t0); + if (n.squaredLength() < 1e-10f) + continue; + Ogre::Vector3 mid = 0.5f * (t0 + t1); + bool outward = n.dotProduct(mid - refPoint) >= 0.0f; + if (outward) { + emitTri(out, t0, t1, b1, e.uv0, e.uv1, uvB1); + emitTri(out, t0, b1, b0, e.uv0, uvB1, uvB0); + } else { + emitTri(out, t0, b1, t1, e.uv0, uvB1, e.uv1); + emitTri(out, t0, b0, b1, e.uv0, uvB0, uvB1); + } + } +} + +/** Overlap of strip quads behind the node so no seam gap shows. */ +static const float ROAD_SEAM_OVERLAP = 0.05f; + +bool RoadSystem::buildWedgeGeometry(const RoadWedge &wedge, + const RoadGraph &graph, + Procedural::TriangleBuffer &out) +{ + if (wedge.degenerate) { + Ogre::LogManager::getSingleton().logMessage( + "RoadSystem: skipping degenerate road wedge at node " + + Ogre::StringConverter::toString(wedge.nodeId)); + return false; + } + + const RoadNode *node = graph.findNodeById(wedge.nodeId); + if (!node) + return false; + + const RoadHalfEdge &h1 = wedge.first; + const RoadHalfEdge &h2 = wedge.second; + const Ogre::Vector3 &O = node->position; + Ogre::Vector3 d1 = h1.direction; + Ogre::Vector3 d2 = h2.direction; + Ogre::Vector3 r1 = roadRightVec(d1); + Ogre::Vector3 r2 = roadRightVec(d2); + float lw = graph.config.laneWidth; + float halfThick = + std::max(0.01f, graph.config.roadThickness) * 0.5f; + float out1 = h1.lanesOut * lw; + float in1 = h1.lanesIn * lw; + float in2 = h2.lanesIn * lw; + float L1 = h1.halfLength; + float L2 = h2.halfLength; + + /* + * The wedge region is the union of two one-sided band strips: + * strip1 = O + t*d1 + s*r1, t in [0, L1], s in [0, out1] (h1's + * outbound side) and strip2 = O + t*d2 + s*r2, t in [0, L2], + * s in [-in2, 0] (h2's inbound side). For swept angles up to + * 180 degrees the union is an L-shaped hexagon with a single + * outer corner X where the two outer curbs intersect; emit it as + * a triangle fan around X. Otherwise fall back to two + * independent strip quads with their own curb and cap skirts. + */ + + /* Outer corner: X = O + t1*d1 + out1*r1 = O + t2*d2 - in2*r2. */ + Ogre::Vector3 rhs = -in2 * r2 - out1 * r1; + float det = d1.z * d2.x - d1.x * d2.z; + float t1 = 0.0f, t2 = 0.0f; + bool cornerOk = false; + if (std::fabs(det) >= 0.05f) { + t1 = (-rhs.x * d2.z + d2.x * rhs.z) / det; + t2 = (d1.x * rhs.z - rhs.x * d1.z) / det; + cornerOk = t1 >= 0.0f && t1 <= L1 && t2 >= 0.0f && + t2 <= L2; + } + + if (cornerOk && wedge.sweptAngleDeg <= 180.0f) { + float yNode1, yMid1, yNode2, yMid2; + halfEdgeHeights(h1, graph, yNode1, yMid1); + halfEdgeHeights(h2, graph, yNode2, yMid2); + + Ogre::Vector3 vX = O + t1 * d1 + out1 * r1; + float yO = 0.5f * (yNode1 + yNode2); + float yX = 0.5f * (halfEdgeHeightAt(h1, graph, t1) + + halfEdgeHeightAt(h2, graph, t2)); + + Ogre::Vector3 poly[6] = { + O, O + L1 * d1, O + L1 * d1 + out1 * r1, + vX, O + L2 * d2 - in2 * r2, O + L2 * d2 + }; + float polyY[6] = { yO, yMid1, yMid1, yX, yMid2, yMid2 }; + + /* Planar UVs in the (d1, r1) frame. */ + Ogre::Vector2 polyUV[6]; + for (int i = 0; i < 6; ++i) { + Ogre::Vector3 rel = poly[i] - O; + polyUV[i] = Ogre::Vector2(rel.dotProduct(d1), + rel.dotProduct(r1) + in1); + } + + std::vector tris; + for (int i = 0; i < 6; ++i) { + int j = (i + 1) % 6; + RoadSurfTri tri; + tri.p[0] = Ogre::Vector3(vX.x, yX, vX.z); + tri.p[1] = + Ogre::Vector3(poly[i].x, polyY[i], poly[i].z); + tri.p[2] = + Ogre::Vector3(poly[j].x, polyY[j], poly[j].z); + tri.uv[0] = polyUV[3]; + tri.uv[1] = polyUV[i]; + tri.uv[2] = polyUV[j]; + tris.push_back(tri); + } + + /* + * The fan closes the whole L-shape; the only exposed + * boundary is the two outer curbs meeting at X. + */ + std::vector skirts; + RoadSkirtEdge curb1; + curb1.p0 = Ogre::Vector3(poly[2].x, polyY[2], poly[2].z); + curb1.p1 = Ogre::Vector3(vX.x, yX, vX.z); + curb1.uv0 = polyUV[2]; + curb1.uv1 = polyUV[3]; + skirts.push_back(curb1); + RoadSkirtEdge curb2; + curb2.p0 = Ogre::Vector3(vX.x, yX, vX.z); + curb2.p1 = Ogre::Vector3(poly[4].x, polyY[4], poly[4].z); + curb2.uv0 = polyUV[3]; + curb2.uv1 = polyUV[4]; + skirts.push_back(curb2); + + emitSlab(out, tris, skirts, halfThick, O); + return true; + } + + /* Fallback: two independent one-sided strip quads. */ + float t0 = -ROAD_SEAM_OVERLAP; + + struct StripDef { + const RoadHalfEdge &he; + const Ogre::Vector3 &d; + const Ogre::Vector3 &r; + float s0, s1; /* lateral range along r */ + float L; + }; + StripDef strips[2] = { { h1, d1, r1, 0.0f, out1, L1 }, + { h2, d2, r2, -in2, 0.0f, L2 } }; + + for (const StripDef &sd : strips) { + Ogre::Vector3 c00 = O + t0 * sd.d + sd.s0 * sd.r; + Ogre::Vector3 c10 = O + sd.L * sd.d + sd.s0 * sd.r; + Ogre::Vector3 c11 = O + sd.L * sd.d + sd.s1 * sd.r; + Ogre::Vector3 c01 = O + t0 * sd.d + sd.s1 * sd.r; + float y0 = halfEdgeHeightAt(sd.he, graph, t0); + float yL = halfEdgeHeightAt(sd.he, graph, sd.L); + c00.y = c01.y = y0; + c10.y = c11.y = yL; + + float inW = sd.he.lanesIn * lw; + Ogre::Vector2 uv00(halfEdgeU(sd.he, graph, t0), sd.s0 + inW); + Ogre::Vector2 uv10(halfEdgeU(sd.he, graph, sd.L), + sd.s0 + inW); + Ogre::Vector2 uv11(halfEdgeU(sd.he, graph, sd.L), + sd.s1 + inW); + Ogre::Vector2 uv01(halfEdgeU(sd.he, graph, t0), sd.s1 + inW); + + std::vector tris; + RoadSurfTri tA; + tA.p[0] = c00; tA.p[1] = c10; tA.p[2] = c11; + tA.uv[0] = uv00; tA.uv[1] = uv10; tA.uv[2] = uv11; + tris.push_back(tA); + RoadSurfTri tB; + tB.p[0] = c00; tB.p[1] = c11; tB.p[2] = c01; + tB.uv[0] = uv00; tB.uv[1] = uv11; tB.uv[2] = uv01; + tris.push_back(tB); + + std::vector skirts; + RoadSkirtEdge curb; + curb.p0 = c01; curb.p1 = c11; + curb.uv0 = uv01; curb.uv1 = uv11; + skirts.push_back(curb); + RoadSkirtEdge cap; + cap.p0 = c00; cap.p1 = c01; + cap.uv0 = uv00; cap.uv1 = uv01; + skirts.push_back(cap); + + Ogre::Vector3 ref = 0.25f * (c00 + c10 + c11 + c01); + emitSlab(out, tris, skirts, halfThick, ref); + } + + return true; +} + +bool RoadSystem::buildSegmentGeometry(const RoadStraightSegment &segment, + const RoadGraph &graph, + Procedural::TriangleBuffer &out) +{ + const RoadNode *node = graph.findNodeById(segment.nodeId); + if (!node) + return false; + + const RoadHalfEdge &he = segment.halfEdge; + const Ogre::Vector3 &O = node->position; + Ogre::Vector3 d = he.direction; + Ogre::Vector3 r = roadRightVec(d); + float lw = graph.config.laneWidth; + float halfThick = + std::max(0.01f, graph.config.roadThickness) * 0.5f; + float inW = he.lanesIn * lw; + float outW = he.lanesOut * lw; + float L = he.halfLength; + float t0 = -ROAD_SEAM_OVERLAP; + + /* Full-width band: s in [-inW, +outW], t in [t0, L]. */ + Ogre::Vector3 c00 = O + t0 * d - inW * r; + Ogre::Vector3 c10 = O + L * d - inW * r; + Ogre::Vector3 c11 = O + L * d + outW * r; + Ogre::Vector3 c01 = O + t0 * d + outW * r; + float y0 = halfEdgeHeightAt(he, graph, t0); + float yL = halfEdgeHeightAt(he, graph, L); + c00.y = c01.y = y0; + c10.y = c11.y = yL; + + Ogre::Vector2 uv00(halfEdgeU(he, graph, t0), 0.0f); + Ogre::Vector2 uv10(halfEdgeU(he, graph, L), 0.0f); + Ogre::Vector2 uv11(halfEdgeU(he, graph, L), inW + outW); + Ogre::Vector2 uv01(halfEdgeU(he, graph, t0), inW + outW); + + std::vector tris; + RoadSurfTri tA; + tA.p[0] = c00; tA.p[1] = c10; tA.p[2] = c11; + tA.uv[0] = uv00; tA.uv[1] = uv10; tA.uv[2] = uv11; + tris.push_back(tA); + RoadSurfTri tB; + tB.p[0] = c00; tB.p[1] = c11; tB.p[2] = c01; + tB.uv[0] = uv00; tB.uv[1] = uv11; tB.uv[2] = uv01; + tris.push_back(tB); + + /* Skirts: node-end cap plus both curbs (not the far end). */ + std::vector skirts; + RoadSkirtEdge cap; + cap.p0 = c00; cap.p1 = c01; + cap.uv0 = uv00; cap.uv1 = uv01; + skirts.push_back(cap); + RoadSkirtEdge curbIn; + curbIn.p0 = c00; curbIn.p1 = c10; + curbIn.uv0 = uv00; curbIn.uv1 = uv10; + skirts.push_back(curbIn); + RoadSkirtEdge curbOut; + curbOut.p0 = c01; curbOut.p1 = c11; + curbOut.uv0 = uv01; curbOut.uv1 = uv11; + skirts.push_back(curbOut); + + Ogre::Vector3 ref = 0.25f * (c00 + c10 + c11 + c01); + emitSlab(out, tris, skirts, halfThick, ref); + return true; +} diff --git a/src/features/editScene/systems/RoadSystem.hpp b/src/features/editScene/systems/RoadSystem.hpp index b61f45e..3767326 100644 --- a/src/features/editScene/systems/RoadSystem.hpp +++ b/src/features/editScene/systems/RoadSystem.hpp @@ -4,18 +4,83 @@ #include #include +#include #include +#include +#include #include #include +#include +#include +#include + +#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 collisionVertices; + std::vector collisionIndices; + + /** Roadside prefab instances spawned for this page (M5.11). */ + std::vector spawnedPrefabs; + + /** Wedges and straight segments seeded by nodes inside this page. */ + std::vector wedges; + std::vector 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 & + 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 m_pageGeometry; + std::unordered_map 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; diff --git a/src/features/editScene/systems/TerrainSystem.cpp b/src/features/editScene/systems/TerrainSystem.cpp index 367d7fc..7b5d5ee 100644 --- a/src/features/editScene/systems/TerrainSystem.cpp +++ b/src/features/editScene/systems/TerrainSystem.cpp @@ -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(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. */ diff --git a/src/features/editScene/systems/TerrainSystem.hpp b/src/features/editScene/systems/TerrainSystem.hpp index e9f9ccc..347f693 100644 --- a/src/features/editScene/systems/TerrainSystem.hpp +++ b/src/features/editScene/systems/TerrainSystem.hpp @@ -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 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 m_dirtyAuxMaps; + /* Fixup chunks (M5.9.5): sparse absolute-height overrides stored as + * 256x256 float chunks under heightmaps//terrain_fixup/. + * Guarded by m_heightmapMutex; loaded lazily from disk on first + * access, saved alongside the heightmap. */ + struct FixupChunk { + std::vector samples; + bool dirty = false; + }; + static constexpr int FIXUP_CHUNK_RES = 256; + static constexpr float FIXUP_SENTINEL = -FLT_MAX; + + mutable std::map, 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 &out) const; + std::string fixupChunkPath(int chunkX, int chunkZ) const; + const std::vector * ensureAuxMapLoaded(const struct TerrainComponent::AuxMap &aux, const std::string &path) const; diff --git a/src/features/editScene/systems/TerrainTests.cpp b/src/features/editScene/systems/TerrainTests.cpp index 9c8623d..74f7158 100644 --- a/src/features/editScene/systems/TerrainTests.cpp +++ b/src/features/editScene/systems/TerrainTests.cpp @@ -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 @@ -14,6 +22,7 @@ #include #include #include +#include #include #include #include @@ -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 wedges; + std::vector 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 wedges; + std::vector 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 wedges; + std::vector 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 wedges; + std::vector 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 wedges; + std::vector 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(); + 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(); + 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 wedges; + std::vector 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 wedges; + std::vector 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 wedges; + std::vector 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 wedges; + std::vector 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(); + 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() || + !be.has() || + !be.has() || + !be.has() || !be.has()) { + 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(); + 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().meshName; + pms->update(); + pumpFrames(app, ts, 2); + + { + const auto &tb = be.get(); + const auto &rend = be.get(); + 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()) { + 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 }, }; diff --git a/src/features/editScene/systems/TerrainTests.hpp b/src/features/editScene/systems/TerrainTests.hpp index 214f30e..fb122c0 100644 --- a/src/features/editScene/systems/TerrainTests.hpp +++ b/src/features/editScene/systems/TerrainTests.hpp @@ -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); diff --git a/src/features/editScene/ui/TerrainEditor.hpp b/src/features/editScene/ui/TerrainEditor.hpp index c06f0c7..9a9885d 100644 --- a/src/features/editScene/ui/TerrainEditor.hpp +++ b/src/features/editScene/ui/TerrainEditor.hpp @@ -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. */