Compare commits

..

4 Commits

Author SHA1 Message Date
slapin a97efbe237 Road geometry is actually generated 2026-07-31 13:25:56 +03:00
slapin 9759c06d5f Implemented Milestone 5 as AI says 2026-07-30 21:23:12 +03:00
slapin 4acad712b0 snapshot 20260729 2026-07-29 13:20:06 +03:00
slapin 50c2395581 Updating mid-implementation of M5.2 2026-07-19 22:44:52 +03:00
25 changed files with 8266 additions and 262 deletions
+5
View File
@@ -24,6 +24,7 @@ set(EDITSCENE_SOURCES
systems/EditorSkyboxSystem.cpp
systems/EditorWaterPlaneSystem.cpp
systems/TerrainSystem.cpp
systems/RoadSystem.cpp
systems/LightSystem.cpp
systems/CameraSystem.cpp
systems/LodSystem.cpp
@@ -170,6 +171,7 @@ set(EDITSCENE_SOURCES
camera/EditorCamera.cpp
gizmo/Gizmo.cpp
gizmo/Cursor3D.cpp
gizmo/RoadGizmo.cpp
physics/physics.cpp
lua/LuaState.cpp
lua/LuaEntityApi.cpp
@@ -198,6 +200,7 @@ set(EDITSCENE_HEADERS
components/WaterPhysics.hpp
components/WaterPlane.hpp
components/Terrain.hpp
components/RoadGraph.hpp
components/Sun.hpp
components/Skybox.hpp
components/Light.hpp
@@ -290,6 +293,7 @@ set(EDITSCENE_HEADERS
systems/EditorSkyboxSystem.hpp
systems/EditorWaterPlaneSystem.hpp
systems/TerrainSystem.hpp
systems/RoadSystem.hpp
systems/LightSystem.hpp
systems/CameraSystem.hpp
systems/LodSystem.hpp
@@ -350,6 +354,7 @@ set(EDITSCENE_HEADERS
camera/EditorCamera.hpp
gizmo/Gizmo.hpp
gizmo/Cursor3D.hpp
gizmo/RoadGizmo.hpp
physics/physics.h
lua/LuaState.hpp
lua/LuaEntityApi.hpp
+4
View File
@@ -235,6 +235,10 @@ public:
{
return m_characterSpawnerSystem.get();
}
ProceduralMeshSystem *getProceduralMeshSystem() const
{
return m_proceduralMeshSystem.get();
}
StartupMenuSystem *getStartupMenuSystem() const
{
return m_startupMenuSystem.get();
@@ -0,0 +1,312 @@
# Milestone 5 Verification Plan — Procedural Roads
This document enumerates all verification steps needed to confirm the
correctness of Milestone 5 (Procedural Roads) as specified in
`TerrainRequirements.md`. It does **not** trust the plan's self-reported
completion status; each item is independently assessed against the current
working tree (2026-07-30).
## 0. Resolved Decisions
Decisions made by the plan author (2026-07-30) during the verification audit:
| Decision | Resolution |
|----------|------------|
| M5.10 perpendicular falloff | **Implement now.** Add the `laneWidth * 2` linear fade to `complyTerrain()` before M5 closure. |
| Road collider debug visibility | **Add toggle.** Include road colliders in the physics debug draw with a checkbox (or filter them alongside terrain bodies). |
| Roadside prefab test fixture | **Real prefab, separate directory.** Create a minimal `.prefab` JSON in a `tests/` directory (not mixed with user prefabs). The test verifies successful spawn + entity destruction on teardown. |
## 1. Automated Test Coverage Audit
The table below maps every M5 sub-item to the headless tests in
`TerrainTests.cpp`. Status is `COVERED` when at least one test exists that
exercises the core logic, `PARTIAL` when some behaviours are verified but
others are not, and `UNCOVERED` when no automated test exists at all.
| Item | Spec section | Test function | Coverage | Notes |
|-------|-------------|---------------|----------|-------|
| M5.1 | Road data model | `testRoadDataModel` | COVERED | addNode, addEdge, removeNode, removeEdge, splitEdge, joinNodes, resolveLaneCounts, validate |
| M5.2 | Visual road editor | — | UNCOVERED (manual) | Interactive 3D tool; cannot be headlessly tested. See manual section below. |
| M5.3 | Road mesh template | `testRoadTemplate` | COVERED | Fallback box bounds, UV range, missing-template fallback |
| M5.4 | Road geometry gen | `testRoadPageAssignment` | COVERED | Page bucketing, rebucketing after graph change, unloaded-page exclusion, teardown |
| M5.5 | Wedge enumeration | `testRoadWedgeEnumeration` | COVERED | Endpoints, asymmetric lanes, 90°, 270°, T-junction sort, degenerate 300° rejection |
| M5.6 | Wedge geometry | `testRoadWedgeGeometry` | COVERED | Segment extents, asymmetric lanes, 90° fan, 270° fallback, degenerate rejection |
| M5.7 | Edge length constraint| `testRoadEdgeLengthConstraint` | COVERED | snapToIntegerLength, splitEdge snapping, joinNodes warning, validate rejects short edges |
| M5.8 | Mesh assembly per page| `testRoadPageMeshes` | COVERED | Components, buffer fill, mesh creation, finalize, mesh resource, teardown |
| M5.9 | Road physics colliders| `testRoadPageMeshes` (partial) | PARTIAL | bodyId validity and PhysicsColliderComponent presence asserted at page finalize time. **Missing**: physical interaction (raycast against road collider, collider removal on rebuild). |
| M5.9.6| Road collider debug visibility | — | UNCOVERED | New item: toggle for road colliders in physics debug draw. Not tested in headless suite. |
| M5.9.5| Fixup chunk support | `testFixupChunks` | COVERED | writeFixup, sampleHeightAt reads override, save/load round-trip, clearAll |
| M5.10 | Terrain compliance | — | UNCOVERED | `RoadSystem::complyTerrain()` has no automated test. Perpendicular falloff (laneWidth*2 fade) needs implementation. |
| M5.11 | Roadside prefab spawning | — | UNCOVERED | `RoadSystem::spawnSidePrefabs()` has no automated test |
| M5.12 | Serialization + wiring| `testRoadSerialization` | COVERED | Config/nodes/edges/sidePrefabs JSON round-trip; wiring: roadSystem lifecycle covered by testRoadPageAssignment + testRoadPageMeshes |
### 1.1 M5.9 Collider Coverage Detail
The current `testRoadPageMeshes` test checks:
- bodyId is valid (not invalid) after page finalize
- buffer entity has PhysicsColliderComponent
These are **static existence checks** — no physics simulation step, no collision
query, no body-vs-body contact is verified. The following concerns are
untested:
- **Raycast accuracy**: a vertical ray from above the road should hit the road
body at the road top surface Y. A ray from below should hit the underside
(the road slab is a closed solid, not a one-sided surface).
- **Collider removal on page rebuild**: when a road page is marked dirty and
rebuilt, the old collider body must be removed **before** the new mesh is
created (otherwise the old body references a freed mesh).
- **Collider removal on page unload**: the body must be removed from the Jolt
world when the terrain page unloads.
- **Multi-page colliders**: a road that spans two terrain pages should have two
independent static collider bodies, one per page, and the seam between them
must not trap small bodies.
### 1.2 M5.10 Spec-vs-implementation Gap
The spec (TerrainRequirements.md, M5.10) describes a **perpendicular falloff**:
> 4. Apply a smooth falloff perpendicular to the road:
> - Full compliance within the road width (all lanes).
> - Linear fade over `laneWidth * 2` outside the outermost lane.
> - Beyond the fade zone, leave the fixup entry empty (use base heightmap + detail noise).
The current `RoadSystem::complyTerrain()` (RoadSystem.cpp:15641629)
**writes fixups only at the vertices of the road surface geometry** — the
top-surface vertices of each wedge and segment. It does **not** iterate beyond
the road surface or apply the `laneWidth * 2` fade zone.
**Resolved**: implement falloff now (see section 1.2a).
#### 1.2a Required Implementation — Perpendicular Falloff
In `RoadSystem::complyTerrain()`, after writing the road-underside fixups,
add a second pass that extends laterally from the road centre line:
```
For each road surface point P (world pos of a top vertex):
1. Compute the perpendicular (horizontal) direction away from the road.
2. The road width at P = totalLanes(P) * laneWidth.
3. Full compliance zone: fixup already written by existing code.
4. Fade zone: for lateral offset d in [roadWidth/2, roadWidth/2 + laneWidth*2]:
targetY = interpolate(P.y - roadThickness, baseHeight, fadeFactor)
where fadeFactor = linear from 1.0 to 0.0 over the fade zone
Write targetY into fixup chunk at (P.x + d * perp.x, P.z + d * perp.z)
and (P.x - d * perp.x, P.z - d * perp.z).
5. Beyond roadWidth/2 + laneWidth*2: no write (fall through to base height).
```
To keep the implementation testable, expose a private helper:
```cpp
static float computeComplianceHeight(float roadSurfaceY, float roadThickness,
float baseHeight, float lateralDistance,
float halfRoadWidth, float fadeWidth);
```
This helper is a pure function and can be exercised directly in the test.
### 1.3 Road Collider Debug Draw (New Item M5.9.6)
Currently `TerrainSystem::TerrainBodyDrawFilter` only tracks terrain body IDs.
Road colliders are created by `RoadSystem::createPageCollider()` as static
`NON_MOVING` bodies but are not registered with any draw filter.
**Implementation**:
- `RoadSystem` maintains a `std::set<JPH::BodyID> m_roadBodyIds` (populated
in `createPageCollider`, cleared in `destroyPageCollider`).
- A `RoadBodyDrawFilter` (or a combined filter) is wired into
`JoltPhysicsWrapper::setBodyDrawFilter()`.
- The `TerrainEditor` gains a "Show Road Colliders" checkbox
(`getShowRoadColliders()` / `setShowRoadColliders()` on `TerrainSystem`
forwarded to `RoadSystem`).
- Default: off (same behaviour as terrain colliders).
## 2. Missing Automated Tests (Implementation Targets)
These tests must be added to `TerrainTests.cpp`. The test runner already
registers the existing 9 M5 test functions; these 3 new ones (or 4, if
`testRoadColliderRebuild` is separate) will be appended.
### 2.1 `testRoadColliderInteraction`
**Purpose**: verify that the generated road `MeshShape` collider produces
correct physical contact (headless-compatible — Jolt CPU queries).
**Steps**:
1. Create a terrain entity with a two-node road edge (known positions).
2. Pump frames until page activation + road mesh + collider exist.
3. Retrieve `RoadPageGeometry::bodyId`.
4. **Raycast from above**: `JPH::RRayCast` from `(roadCenter.x, roadSurfaceY + 50, roadCenter.z)` straight down.
- Verify hit: body ID matches road body, hit Y ≈ `roadSurfaceY` (± 0.05).
5. **Raycast from below**: cast from `(roadCenter.x, roadSurfaceY - 50, roadCenter.z)` upward.
- Verify hit: body ID matches road body, hit Y ≈ `roadSurfaceY - roadThickness` (underside of slab).
6. **Rebuild**: bump graph version, pump frames, verify old body ID is now invalid (removed) and a new body exists.
7. **Teardown**: deactivate terrain, verify body ID is invalid (removed from physics world).
### 2.2 `testTerrainCompliance`
**Purpose**: verify `RoadSystem::complyTerrain()` writes fixups that the terrain
sampling path observes, including the perpendicular falloff.
**Steps**:
1. Create terrain, add a two-node straight road edge at known positions
(e.g. nodes at (100,0,100) and (500,0,100)).
2. Pump frames until road mesh exists.
3. Call `rs->complyTerrain(ts, roadThickness, laneWidth)`.
4. Sample at a point directly under the road centre:
- Verify `ts->sampleHeightAt(wx, wz) ≈ roadSurfaceY - roadThickness` (± 0.1).
5. Sample at a point far from the road (e.g. (2000, 2000)):
- Verify height matches base heightmap (no fixup).
6. Verify `ts->getFixupChunkCount()` > 0.
7. Test the falloff:
- Compute the total half-width at the sample point: `(lanesAtoB + lanesBtoA) * laneWidth / 2`.
- Sample at `halfWidth + laneWidth` outside (mid-fade): height should be between the full-compliance value and the base height.
- Sample at `halfWidth + laneWidth * 2` outside (fade end): height ≈ base height (± 0.1).
- Sample at `halfWidth + laneWidth * 3` outside (beyond fade): height == base height.
8. `ts->saveFixups()`, deactivate/reactivate, re-sample same points → same values.
9. `ts->clearAllFixups()` → samples revert to base height.
**Test helper**: `computeComplianceHeight()` unit-test assertions on known inputs (pure math, no scene needed).
### 2.3 `testRoadSidePrefabs`
**Purpose**: verify `RoadSystem::spawnSidePrefabs()` creates and destroys
prefab instances correctly.
**Test prefab fixture**: a minimal `.prefab` JSON file placed in
`src/features/editScene/tests/prefabs/tiny_cube.prefab`. This is a self-contained
test resource; it must not be mixed with user-created prefabs.
**Steps**:
1. Ensure the test prefab is loadable (the file is registered in a resource
group accessible during tests).
2. Create terrain, add a road edge with one `RoadSidePrefab` pointing at
`tiny_cube.prefab` with `edgeT=0.5, sideOffset=3, leftSide=true`.
3. Pump frames until road mesh + prefabs exist.
4. Verify `RoadPageGeometry::spawnedPrefabs` is non-empty (1 entity).
5. Verify the spawned entity is alive, has a `TransformComponent`, and its
position is approximately the expected world position (edge midpoint + 3
units left).
6. Bump graph version (add a dummy node+edge) → pump frames.
- Verify old prefab entity is no longer alive.
- Verify `spawnedPrefabs` now contains the new prefab instance.
7. Deactivate terrain → verify spawned prefab entity is not alive.
## 3. Manual Verification Procedures
Tests not possible in headless/automated mode. Run `./editSceneEditor` in
interactive mode (with display).
### 3.1 Road Editor (M5.2) — Interactive Walkthrough
| Step | Action | Expected Result |
|------|--------|-----------------|
| 1 | Create terrain entity (Add Entity → Terrain) | Terrain renders, Road Edit Mode checkbox appears in Terrain panel |
| 2 | Enable "Road Edit Mode" | Sculpt/Paint mode deactivates, brush decal hides, toolbar appears |
| 3 | Switch to "Add Node" tool, click on terrain surface | A cube marker appears at the clicked position, node is added |
| 4 | Click a second location | Second cube marker appears |
| 5 | Switch to "Move" tool, click first node → click "Connect" on second node in node list | Edge line appears between the two nodes, road-width indicator renders |
| 6 | Select the edge in the edge list | Edge line turns magenta, A→B direction arrow appears |
| 7 | In edge inspector, set `lanesAtoB = 2, lanesBtoA = 1` | Road-width indicator shows 2 lanes on A→B side, 1 lane on B→A side |
| 8 | Drag a node with gizmo | Node follows mouse, Y auto-snaps to terrain surface |
| 9 | Split edge (click "Split") | New node appears at midpoint, two edges replace the one |
| 10 | "Validate Road Graph" button | Modal shows "Graph is valid" (or errors if intentional bad state) |
| 11 | Remove selected node | Node cube disappears, connected edges disappear |
| 12 | Disable "Road Edit Mode" | All visual aids vanish, brush decal reappears if paint/sculpt mode enabled |
| 13 | Save scene, reload | Road nodes, edges, and config are restored |
### 3.2 Road Mesh Rendering — Visual Check
| Step | Action | Expected Result |
|------|--------|-----------------|
| 1 | After step 7 of 3.1, look at the terrain surface | Road mesh renders on top of terrain with correct material |
| 2 | Move camera far away (> roadVisibilityDistance) | Road meshes disappear (distance culling) |
| 3 | Move camera to distance between lodDistance and visibilityDistance | Road meshes visibly reduce detail (LOD 50%) |
| 4 | Move camera very close | Road surface shows full detail, top face visible, no z-fighting with terrain |
| 5 | Check road-material appearance | Material matches `RoadMaterial` (or config's roadMaterialName) |
### 3.3 Physics Colliders — Interactive
| Step | Action | Expected Result |
|------|--------|-----------------|
| 1 | Enable physics debug draw in editor | Body wireframes appear |
| 2 | Enable "Show Terrain Colliders" | Terrain collider wireframes appear |
| 3 | Enable "Show Road Colliders" (new checkbox) | Road collider wireframes appear, matching the road mesh exactly |
| 4 | Spawn a dynamic rigid body (e.g. a sphere via editor) above the road | Body falls and rests on the road surface, does not fall through |
| 5 | Push the body along the road | Body slides along the road surface, does not sink into seams between pages |
| 6 | Walk a character (if player controller exists) onto the road | Character stands on road surface, walks along it without falling through |
### 3.4 Terrain Compliance — Visual + Sampling
| Step | Action | Expected Result |
|------|--------|-----------------|
| 1 | After 3.1, click "Comply Terrain to Roads" | Message logged: "RoadSystem: terrain compliance applied for N pages" |
| 2 | Look closely at the road from the side | Terrain is flush with the underside of the road (no gap, terrain does not poke through) |
| 3 | Look at the road edge | **Terrain slopes smoothly down from the road edge over ~laneWidth*2 distance** (falloff visible). No abrupt cliff. |
| 4 | Disable road rendering (toggle visibility) | The terrain under the road shows a depression matching the road footprint, with smooth shoulders at edges |
| 5 | Save scene, reload | Fixup data persists; terrain still conforms to roads after reload |
| 6 | "Clear All Fixups" button | Terrain returns to its original surface (no road depression) |
### 3.5 Roadside Prefabs — Visual
| Step | Action | Expected Result |
|------|--------|-----------------|
| 1 | In edge inspector, add a side prefab with a known valid prefab path, `edgeT=0.5`, `sideOffset=3`, `leftSide=true` | A prefab instance appears beside the road at the midpoint, offset 3 units to the left |
| 2 | Move camera far away and back | Prefab visible when close, absent when far (handled by PrefabSystem's own distance logic, not RoadSystem) |
| 3 | Delete the edge or remove the terrain | Prefab instance is destroyed |
| 4 | Save scene, reload | Side prefabs are re-spawned from edge data |
### 3.6 Integration — NavMesh
| Step | Action | Expected Result |
|------|--------|-----------------|
| 1 | After 3.1, build navigation mesh (if NavMeshSystem UI exists) | Navigation mesh covers the road surface (walkable area) |
| 2 | Visualize navmesh (if supported) | Road area shows as walkable, connected to terrain walkable areas |
### 3.7 Memory / Leak Check
| Step | Action | Expected Result |
|------|--------|-----------------|
| 1 | Create terrain, add roads, enable compliance, add prefabs | Terrain + roads render |
| 2 | Delete terrain entity | No crash, all road visual aids/meshes/colliders/prefabs destroyed |
| 3 | Repeat steps 12 five times | No growing memory (watch process RSS) |
| 4 | Exit editor | Clean shutdown, no Jolt/OGRE assertions in console |
## 4. Implementation Work Items
These are the concrete code changes needed to close all gaps. Each item is
ordered by dependency.
| # | Item | Files to modify | Depends on |
|---|------|-----------------|------------|
| W1 | M5.10 perpendicular falloff in `complyTerrain()` | `RoadSystem.cpp` | — |
| W2 | Helper `computeComplianceHeight()` + unit test | `RoadSystem.cpp`, `TerrainTests.cpp` | W1 |
| W3 | `testTerrainCompliance` (automated) | `TerrainTests.cpp`, `TerrainTests.hpp` | W1 |
| W4 | `testRoadColliderInteraction` (automated) | `TerrainTests.cpp`, `TerrainTests.hpp` | — |
| W5 | Road collider debug draw toggle (M5.9.6) | `RoadSystem.hpp/.cpp`, `TerrainSystem.hpp/.cpp`, `TerrainEditor.hpp` | — |
| W6 | Test prefab fixture `tiny_cube.prefab` | `src/features/editScene/tests/prefabs/tiny_cube.prefab` (new) | — |
| W7 | `testRoadSidePrefabs` (automated) | `TerrainTests.cpp`, `TerrainTests.hpp` | W6 |
| W8 | Register new tests in `TerrainTestRunner::run()` | `TerrainTests.cpp` | W3, W4, W7 |
## 5. Summary
| Area | Status |
|------|--------|
| M5.1M5.8 automated coverage | ✅ Adequate (8/8 sub-items have tests) |
| M5.9 automated coverage | ⚠️ Partial → W4 adds raycast+rebuild verification |
| M5.9.6 road collider debug toggle | ❌ Not implemented → W5 |
| M5.10 perpendicular falloff | ❌ Missing from implementation → W1+W2 |
| M5.10 automated coverage | ❌ None → W3 |
| M5.11 automated coverage | ❌ None → W6+W7 |
| M5.12 automated coverage | ✅ Adequate |
| Manual verification steps | 📋 Defined (sections 3.13.7) |
| Open questions | ✅ All resolved (section 0) |
**Exit criteria** — Milestone 5 is fully verified when:
- [ ] All 8 work items (W1W8) are implemented.
- [ ] `./editSceneEditor --headless --run-terrain-tests=1` passes with all
existing + new M5 tests green (expect 2223 tests per iteration).
- [ ] Manual verification walkthroughs 3.13.7 are executed and pass.
- [ ] `ctest -R editSceneTerrainTest` passes in CI.
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,989 @@
#ifndef EDITSCENE_ROADGRAPH_HPP
#define EDITSCENE_ROADGRAPH_HPP
#pragma once
#include <Ogre.h>
#include <algorithm>
#include <cmath>
#include <string>
#include <vector>
/**
* Minimum edge length in world units (M5.7). The road mesh template is
* exactly 1 unit along X; edges shorter than this will produce visibly
* compressed UVs. Operations that would create shorter half-edges snap
* to integer multiples of this value; if snapping is impossible the
* geometry system's UV scaling handles the fractional remainder.
*/
static const float ROAD_MIN_EDGE_LENGTH = 1.0f;
/**
* Road configuration parameters — global settings that apply to the whole
* road network owned by a single TerrainComponent. These values are
* serialized with the scene and edited through the terrain editor.
*/
struct RoadConfig {
/**
* Ogre mesh template used to build each unit-length road segment.
*
* The mesh must occupy exactly 1 unit along the positive X axis
* (X in [0, 1]) and 1 unit in Z (Z in [0, 1] or [-1, 0]). The top
* surface is at Y = +roadThickness/2 and the bottom at
* Y = -roadThickness/2. The template is repeated and scaled along
* each road edge/wedge during geometry generation.
*/
std::string roadMeshTemplate = "road_segment.mesh";
/**
* World-space width of one traffic lane.
*
* This is a global constant for the whole terrain; it cannot be
* overridden per edge. Total road width at any point is derived
* from this value and the resolved lane counts of the incident
* edges.
*/
float laneWidth = 3.0f;
/**
* Default number of lanes in each direction.
*
* An edge can override this with RoadEdge::lanesAtoB and
* RoadEdge::lanesBtoA. A value of 0 in an edge field means "use
* this global default".
*/
int lanesPerDirection = 1;
/**
* Vertical thickness of the road surface.
*
* Used both to size the generated/fallback road mesh and to compute
* terrain compliance: the terrain is flattened to
* roadSurfaceY - roadThickness so the road sits flush on top without
* clipping or floating.
*/
float roadThickness = 0.3f;
/**
* Distance from the camera at which a lower LOD level is selected.
*/
float roadLodDistance = 200.0f;
/**
* Maximum distance from the camera at which road meshes are rendered.
*/
float roadVisibilityDistance = 1000.0f;
/**
* Ogre material applied to generated road surfaces.
*/
std::string roadMaterialName = "RoadMaterial";
};
/**
* A single point in the road network graph.
*
* RoadNode stores the world position of a graph vertex plus a small vertical
* offset above the terrain surface. The Y component of @c position is always
* kept consistent with terrain_height(x, z) + verticalOffset by the editor
* tools; the serializable source of truth is the pair
* (position.x, position.z, verticalOffset).
*/
struct RoadNode {
/**
* World-space position of the node.
*
* The X and Z coordinates are chosen by the user and stored exactly.
* The Y coordinate is derived from the terrain surface height plus
* @c verticalOffset. Runtime systems that sample the node should
* recompute Y from the current terrain when precise placement is
* required.
*/
Ogre::Vector3 position;
/**
* Vertical offset above the terrain surface at (position.x, position.z).
*
* Positive values raise the road above the terrain; negative values
* sink it. Editable in the node inspector.
*/
float verticalOffset = 0.0f;
/**
* Stable identifier for the node.
*
* IDs are assigned monotonically and are never reused after a node is
* deleted. RoadEdge endpoints reference nodes by this ID, not by
* index into a vector, so reordering or deleting nodes does not break
* existing edges.
*/
int id = 0;
};
/**
* A connection between two RoadNode objects.
*
* The edge stores its endpoint node IDs, the road surface height at each
* end relative to the corresponding node, and per-direction lane counts.
* Lane counts are stored as written; a value of 0 means "fall back to the
* global RoadConfig::lanesPerDirection".
*/
struct RoadEdge {
/** Stable ID of the first endpoint node. */
int nodeA = -1;
/** Stable ID of the second endpoint node. */
int nodeB = -1;
/**
* Road surface height at the @c nodeA end, relative to nodeA.position.y.
*
* The absolute road surface Y at nodeA is nodeA.position.y + roadLevelA.
*/
float roadLevelA = 0.0f;
/**
* Road surface height at the @c nodeB end, relative to nodeB.position.y.
*
* The absolute road surface Y at nodeB is nodeB.position.y + roadLevelB.
*/
float roadLevelB = 0.0f;
/**
* Deprecated per-edge lane-count override.
*
* Kept for backward compatibility with older serialized scenes. New
* code should prefer RoadEdge::lanesAtoB and RoadEdge::lanesBtoA.
* When non-zero this value behaves like lanesAtoB == lanesBtoA ==
* lanesPerDirectionOverride.
*/
int lanesPerDirectionOverride = 0;
/**
* Number of lanes traveling from nodeA to nodeB.
*
* 0 means "use RoadConfig::lanesPerDirection". Positive values
* override the global default for this direction only. Asymmetric
* roads are allowed (e.g. lanesAtoB = 2, lanesBtoA = 1).
*/
int lanesAtoB = 0;
/**
* Number of lanes traveling from nodeB to nodeA.
*
* 0 means "use RoadConfig::lanesPerDirection". See lanesAtoB for
* asymmetric-lane semantics.
*/
int lanesBtoA = 0;
/**
* A prefab instance placed beside the road on a specific edge.
*
* Roadside prefabs are regenerated from edge data when the terrain
* page that contains them is loaded; they are not serialized as
* independent scene entities.
*/
struct RoadSidePrefab {
/** Prefab JSON file path, relative to the prefabs directory. */
std::string prefabPath;
/**
* Normalized position along the edge from nodeA to nodeB.
*
* 0.0 places the prefab at nodeA; 1.0 places it at nodeB.
*/
float edgeT = 0.5f;
/**
* Lateral distance from the road center line.
*
* The actual side (left or right) is determined by @c leftSide.
*/
float sideOffset = 5.0f;
/**
* true = left side of the road when looking from nodeA toward nodeB.
* false = right side of the road when looking from nodeA toward nodeB.
*/
bool leftSide = true;
};
/** Prefab spawn points attached to this edge. */
std::vector<RoadSidePrefab> sidePrefabs;
};
/**
* RoadGraph — container and lightweight utility layer for a terrain's road
* network.
*
* The graph is an indexed edge list: nodes are stored in @c nodes and edges
* in @c edges. Edges reference nodes by stable ID, so the graph supports
* deletion and reordering without invalidating references. The helper
* methods below are intentionally simple; they are meant to be used by the
* road editor UI and by RoadSystem during geometry generation.
*/
struct RoadGraph {
/** Global configuration for every road in this graph. */
RoadConfig config;
/** All road nodes in the graph. Ordering is not significant. */
std::vector<RoadNode> nodes;
/** All road edges in the graph. Ordering is not significant. */
std::vector<RoadEdge> edges;
/**
* Monotonic version counter incremented by every mutating operation.
*
* Systems that cache derived data (visual aids, geometry, colliders) can
* compare this value to detect changes without deep-copying the graph.
*/
uint64_t version = 0;
/** Increment @c version. Called by all editing helpers. */
void bumpVersion()
{
++version;
}
/**
* Find a node by its stable ID.
*
* @return pointer to the node, or nullptr if no node with @c id exists.
*/
const RoadNode *findNodeById(int id) const
{
for (const auto &n : nodes)
if (n.id == id)
return &n;
return nullptr;
}
/**
* Find the vector index of a node by its stable ID.
*
* @return index into @c nodes, or -1 if not found.
*/
int findNodeIndexById(int id) const
{
for (size_t i = 0; i < nodes.size(); ++i)
if (nodes[i].id == id)
return (int)i;
return -1;
}
/**
* Return the indices of all edges connected to @c nodeId.
*
* The returned indices are stable only until the edge vector is
* modified.
*/
std::vector<size_t> findEdgeIndicesForNode(int nodeId) const
{
std::vector<size_t> result;
for (size_t i = 0; i < edges.size(); ++i) {
if (edges[i].nodeA == nodeId ||
edges[i].nodeB == nodeId)
result.push_back(i);
}
return result;
}
/**
* Return the IDs of all neighbor nodes connected to @c nodeId.
*
* Neighbors are returned in insertion order, not angle-sorted order.
* Callers that need angular ordering (e.g. wedge generation) must sort
* the returned IDs themselves.
*/
std::vector<int> getNeighborIds(int nodeId) const
{
std::vector<int> result;
for (const auto &e : edges) {
if (e.nodeA == nodeId)
result.push_back(e.nodeB);
else if (e.nodeB == nodeId)
result.push_back(e.nodeA);
}
return result;
}
/**
* Determine whether @c nodeId is an endpoint (degree == 1).
*/
bool isEndpoint(int nodeId) const
{
int degree = 0;
for (const auto &e : edges) {
if (e.nodeA == nodeId || e.nodeB == nodeId) {
++degree;
if (degree > 1)
return false;
}
}
return degree == 1;
}
/**
* Compute the next stable node ID.
*
* Returns one greater than the highest existing ID, or 1 if the graph
* contains no nodes. The returned value is guaranteed not to collide
* with any existing node.
*/
int getNextNodeId() const
{
int maxId = 0;
for (const auto &n : nodes)
if (n.id > maxId)
maxId = n.id;
return maxId + 1;
}
/**
* Resolve the effective lane counts for a directed traversal of @c edge.
*
* @param edge The edge to evaluate.
* @param fromNodeId The node we are traveling away from.
* @param outLanesFrom Receives the number of lanes on the side of the
* road that is outbound from @c fromNodeId.
* @param outLanesTo Receives the number of lanes on the side of the
* road that is inbound toward @c fromNodeId.
*
* The legacy lanesPerDirectionOverride field is honored only when both
* per-direction fields are zero.
*/
void resolveLaneCounts(const RoadEdge &edge, int fromNodeId,
int &outLanesFrom, int &outLanesTo) const
{
int la = edge.lanesAtoB;
int lb = edge.lanesBtoA;
if (la == 0 && lb == 0 && edge.lanesPerDirectionOverride > 0) {
la = edge.lanesPerDirectionOverride;
lb = edge.lanesPerDirectionOverride;
}
if (la == 0)
la = config.lanesPerDirection;
if (lb == 0)
lb = config.lanesPerDirection;
if (fromNodeId == edge.nodeA) {
outLanesFrom = la;
outLanesTo = lb;
} else {
outLanesFrom = lb;
outLanesTo = la;
}
}
/**
* Validate the graph and report the first problem found.
*
* @param error If non-null and the graph is invalid, receives a human
* readable description of the problem.
* @return true if the graph is structurally consistent.
*
* Checks performed:
* - Edge endpoints reference existing node IDs.
* - Node IDs are unique.
* - No edge connects a node to itself.
*/
/**
* Non-const node lookup for editing operations.
*
* @return pointer to the node, or nullptr if not found.
*/
RoadNode *findNodeById(int id)
{
for (auto &n : nodes)
if (n.id == id)
return &n;
return nullptr;
}
/**
* Find the index of an undirected edge connecting @c nodeA and @c nodeB.
*
* @return index into @c edges, or -1 if no such edge exists.
*/
int findEdgeIndex(int nodeA, int nodeB) const
{
for (size_t i = 0; i < edges.size(); ++i) {
if ((edges[i].nodeA == nodeA && edges[i].nodeB == nodeB) ||
(edges[i].nodeA == nodeB && edges[i].nodeB == nodeA))
return (int)i;
}
return -1;
}
/**
* Return true if an edge (in either direction) connects the two nodes.
*/
bool hasEdge(int nodeA, int nodeB) const
{
return findEdgeIndex(nodeA, nodeB) >= 0;
}
/**
* Add a new node to the graph.
*
* @param position World-space position. The caller is responsible
* for snapping X/Z to the desired location and Y to
* terrain_height + verticalOffset.
* @param verticalOffset Offset above the terrain surface.
* @return the stable ID assigned to the new node.
*/
int addNode(const Ogre::Vector3 &position, float verticalOffset = 0.0f)
{
RoadNode node;
node.id = getNextNodeId();
node.position = position;
node.verticalOffset = verticalOffset;
nodes.push_back(node);
bumpVersion();
return node.id;
}
/**
* Remove a node and all edges connected to it.
*
* @return true if the node existed and was removed.
*/
bool removeNode(int nodeId)
{
bool removedAny = false;
for (auto it = edges.begin(); it != edges.end();) {
if (it->nodeA == nodeId || it->nodeB == nodeId) {
it = edges.erase(it);
removedAny = true;
} else {
++it;
}
}
for (auto it = nodes.begin(); it != nodes.end(); ++it) {
if (it->id == nodeId) {
nodes.erase(it);
bumpVersion();
return true;
}
}
return removedAny;
}
/**
* Add an undirected edge between two existing nodes.
*
* @return index of the new edge, or -1 if the edge would be invalid
* (missing node, self-edge, or duplicate).
*/
int addEdge(int nodeA, int nodeB)
{
if (nodeA == nodeB)
return -1;
if (findNodeById(nodeA) == nullptr || findNodeById(nodeB) == nullptr)
return -1;
if (hasEdge(nodeA, nodeB))
return -1;
RoadEdge edge;
edge.nodeA = nodeA;
edge.nodeB = nodeB;
edges.push_back(edge);
bumpVersion();
return (int)edges.size() - 1;
}
/**
* Remove the edge at @c edgeIndex.
*
* @return true if the index was valid and the edge was removed.
*/
bool removeEdge(size_t edgeIndex)
{
if (edgeIndex >= edges.size())
return false;
edges.erase(edges.begin() + edgeIndex);
bumpVersion();
return true;
}
/**
* Split an edge at a normalized position and insert a new node.
*
* The new node is placed at lerp(nodeA.position, nodeB.position, t) with
* verticalOffset chosen so that its Y matches the interpolated road
* surface height along the edge. The original edge is replaced by two
* edges connecting the new node to the original endpoints.
*
* @param edgeIndex Index of the edge to split.
* @param t Normalized position along the edge (0..1).
* @return ID of the newly created node, or -1 on failure.
*/
int splitEdge(size_t edgeIndex, float t)
{
if (edgeIndex >= edges.size())
return -1;
const RoadEdge &oldEdge = edges[edgeIndex];
const RoadNode *na = findNodeById(oldEdge.nodeA);
const RoadNode *nb = findNodeById(oldEdge.nodeB);
if (!na || !nb)
return -1;
float clampedT = std::max(0.0f, std::min(1.0f, t));
Ogre::Vector3 rawPos = na->position +
(nb->position - na->position) * clampedT;
/* M5.7: snap the split point to an integer number of
* ROAD_MIN_EDGE_LENGTH units from nodeA along the edge so both
* resulting half-edges have integer lengths. If the total edge
* length is less than 2*ROAD_MIN_EDGE_LENGTH we snap to the
* midpoint and let the geometry system's UV scaling compensate. */
Ogre::Vector3 dir = nb->position - na->position;
dir.y = 0.0f;
float edgeLen = dir.length();
Ogre::Vector3 newPos;
if (edgeLen >= 2.0f * ROAD_MIN_EDGE_LENGTH)
newPos = snapToIntegerLength(na->position, rawPos);
else
newPos = na->position + dir * 0.5f;
float actualT = (edgeLen > 0.001f) ?
(newPos - na->position).length() / edgeLen :
clampedT;
float newYOffset = na->verticalOffset +
(nb->verticalOffset - na->verticalOffset) *
actualT +
oldEdge.roadLevelA +
(oldEdge.roadLevelB - oldEdge.roadLevelA) *
actualT;
/* roadLevel values are relative to the node Y, so the new node's
* verticalOffset must keep the road surface continuous. Subtract
* the terrain height at the new XZ if it is known; otherwise keep
* the interpolated offset and let the editor re-snap it. */
int newId = addNode(newPos, newYOffset);
RoadEdge edgeA = oldEdge;
edgeA.nodeB = newId;
edgeA.roadLevelB = 0.0f;
RoadEdge edgeB = oldEdge;
edgeB.nodeA = newId;
edgeB.roadLevelA = 0.0f;
edges.erase(edges.begin() + edgeIndex);
edges.push_back(edgeA);
edges.push_back(edgeB);
bumpVersion();
return newId;
}
/**
* Create an edge between two existing nodes if one does not already
* exist (M5.7).
*
* If the horizontal distance between the two nodes is less than
* ROAD_MIN_EDGE_LENGTH a warning is logged via Ogre::LogManager
* but the edge is still created — the geometry system's UV scaling
* handles fractional and sub-unit lengths.
*
* @return index of the edge, or -1 on failure.
*/
int joinNodes(int nodeA, int nodeB)
{
int existing = findEdgeIndex(nodeA, nodeB);
if (existing >= 0)
return existing;
const RoadNode *na = findNodeById(nodeA);
const RoadNode *nb = findNodeById(nodeB);
if (na && nb) {
float dx = nb->position.x - na->position.x;
float dz = nb->position.z - na->position.z;
float dist = std::sqrt(dx * dx + dz * dz);
if (dist < ROAD_MIN_EDGE_LENGTH)
Ogre::LogManager::getSingleton().logMessage(
"RoadGraph: edge between nodes " +
std::to_string(nodeA) + " and " +
std::to_string(nodeB) +
" is " + std::to_string(dist) +
" units (< " +
std::to_string(ROAD_MIN_EDGE_LENGTH) +
"); UV scaling will compensate");
}
return addEdge(nodeA, nodeB);
}
/**
* Check whether the straight-line edge between two world positions crosses
* a terrain page boundary without a node at the crossing.
*
* Terrain pages are axis-aligned squares of side @c worldSize. An edge
* is valid only if both endpoints lie inside the same page.
*
* @param a Start position in world space.
* @param b End position in world space.
* @param worldSize Length of one terrain page in world units.
* @return true if the edge stays within a single page.
*/
static bool edgeStaysWithinOnePage(const Ogre::Vector3 &a,
const Ogre::Vector3 &b,
float worldSize)
{
if (worldSize <= 0.0f)
return true;
long pageAx = (long)std::floor(a.x / worldSize);
long pageAz = (long)std::floor(a.z / worldSize);
long pageBx = (long)std::floor(b.x / worldSize);
long pageBz = (long)std::floor(b.z / worldSize);
return pageAx == pageBx && pageAz == pageBz;
}
/**
* Validate the graph and report the first problem found.
*
* @param error If non-null and the graph is invalid, receives a human
* readable description of the problem.
* @return true if the graph is structurally consistent.
*
* Checks performed:
* - Edge endpoints reference existing node IDs.
* - Node IDs are unique.
* - No edge connects a node to itself.
* - No edge is shorter than ROAD_MIN_EDGE_LENGTH (M5.7).
* - No wedge is sharper than 30 degrees or wider than 270 degrees
* (M5.5 angle constraint; editor operations reject such wedges,
* this catches hand-edited or legacy scenes).
*/
bool validate(std::string *error = nullptr) const;
/**
* Snap a world-space position to an integer number of
* ROAD_MIN_EDGE_LENGTH units from @c anchor along the horizontal
* line connecting them (M5.7).
*
* If the distance is less than ROAD_MIN_EDGE_LENGTH the position is
* returned unchanged. Only the X and Z components are adjusted;
* Y is left as-is (the caller should snap Y to terrain separately).
*/
static Ogre::Vector3
snapToIntegerLength(const Ogre::Vector3 &anchor,
const Ogre::Vector3 &pos);
};
/**
* 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 near 360 degrees, i.e.
* both half-edges pointing in the same direction) must be skipped.
*/
struct RoadWedge {
/** Stable ID of the seed node both half-edges start at. */
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 is near 360 (wedge angle ~ 0). */
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; only near-360 deg wedges are degenerate. */
static const float ROAD_WEDGE_MAX_ANGLE_DEG = 359.9f;
/**
* Enumerate all wedges and straight segments of a road graph (M5.5).
*
* For every node the connected half-edges are sorted by angle around the
* world Y axis; each consecutive pair (with wrap-around) yields one wedge.
* Nodes with exactly one connection yield one straight segment instead.
* Zero-length edges (endpoints sharing the same XZ) are skipped.
*
* This is a pure function of the graph data — no scene or terrain state is
* required — so it can be exercised by headless tests.
*/
inline void enumerateWedges(const RoadGraph &graph,
std::vector<RoadWedge> &outWedges,
std::vector<RoadStraightSegment> &outSegments)
{
outWedges.clear();
outSegments.clear();
for (const auto &node : graph.nodes) {
std::vector<RoadHalfEdge> halfEdges;
for (size_t ei = 0; ei < graph.edges.size(); ++ei) {
const RoadEdge &e = graph.edges[ei];
int neighborId = -1;
if (e.nodeA == node.id)
neighborId = e.nodeB;
else if (e.nodeB == node.id)
neighborId = e.nodeA;
else
continue;
const RoadNode *neighbor = graph.findNodeById(neighborId);
if (!neighbor)
continue;
Ogre::Vector3 dir = neighbor->position - node.position;
dir.y = 0.0f;
float fullLength = dir.normalise();
if (fullLength < 1e-4f)
continue;
RoadHalfEdge he;
he.edgeIndex = (int)ei;
he.nodeId = node.id;
he.neighborId = neighborId;
he.direction = dir;
he.angleRad = std::atan2(dir.z, dir.x);
he.halfLength = fullLength * 0.5f;
graph.resolveLaneCounts(e, node.id, he.lanesOut,
he.lanesIn);
if (e.nodeA == node.id) {
he.roadLevelAtNode = e.roadLevelA;
he.roadLevelAtNeighbor = e.roadLevelB;
} else {
he.roadLevelAtNode = e.roadLevelB;
he.roadLevelAtNeighbor = e.roadLevelA;
}
halfEdges.push_back(he);
}
if (halfEdges.empty())
continue;
std::sort(halfEdges.begin(), halfEdges.end(),
[](const RoadHalfEdge &a, const RoadHalfEdge &b) {
return a.angleRad < b.angleRad;
});
if (halfEdges.size() == 1) {
RoadStraightSegment seg;
seg.nodeId = node.id;
seg.halfEdge = halfEdges[0];
outSegments.push_back(seg);
continue;
}
for (size_t i = 0; i < halfEdges.size(); ++i) {
const RoadHalfEdge &h1 = halfEdges[i];
const RoadHalfEdge &h2 =
halfEdges[(i + 1) % halfEdges.size()];
float swept = h2.angleRad - h1.angleRad;
if (swept <= 0.0f)
swept += (float)(2.0 * M_PI);
RoadWedge w;
w.nodeId = node.id;
w.first = h1;
w.second = h2;
w.sweptAngleDeg =
swept * (180.0f / (float)M_PI);
w.degenerate =
w.sweptAngleDeg > ROAD_WEDGE_MAX_ANGLE_DEG;
outWedges.push_back(w);
}
}
}
inline Ogre::Vector3
RoadGraph::snapToIntegerLength(const Ogre::Vector3 &anchor,
const Ogre::Vector3 &pos)
{
float dx = pos.x - anchor.x;
float dz = pos.z - anchor.z;
float dist = std::sqrt(dx * dx + dz * dz);
if (dist < ROAD_MIN_EDGE_LENGTH)
return pos;
/* Round to the nearest integer multiple of ROAD_MIN_EDGE_LENGTH. */
float snappedDist =
std::round(dist / ROAD_MIN_EDGE_LENGTH) * ROAD_MIN_EDGE_LENGTH;
if (snappedDist < ROAD_MIN_EDGE_LENGTH)
snappedDist = ROAD_MIN_EDGE_LENGTH;
float scale = snappedDist / dist;
Ogre::Vector3 result = pos;
result.x = anchor.x + dx * scale;
result.z = anchor.z + dz * scale;
return result;
}
inline bool RoadGraph::validate(std::string *error) const
{
for (const auto &e : edges) {
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;
}
/* M5.7: check minimum edge length. */
const RoadNode *na = findNodeById(e.nodeA);
const RoadNode *nb = findNodeById(e.nodeB);
if (na && nb) {
float dx = nb->position.x - na->position.x;
float dz = nb->position.z - na->position.z;
float dist = std::sqrt(dx * dx + dz * dz);
if (dist < ROAD_MIN_EDGE_LENGTH) {
if (error)
*error =
"Edge between nodes " +
std::to_string(e.nodeA) +
" and " +
std::to_string(e.nodeB) +
" is too short (" +
std::to_string(dist) +
" < " +
std::to_string(
ROAD_MIN_EDGE_LENGTH) +
")";
return false;
}
}
}
for (size_t i = 0; i < nodes.size(); ++i) {
for (size_t j = i + 1; j < nodes.size(); ++j) {
if (nodes[i].id == nodes[j].id) {
if (error)
*error = "Duplicate node ID: " +
std::to_string(
nodes[i].id);
return false;
}
}
}
/* Wedge angle sanity (M5.5): wedges that are too sharp or too wide
* cannot be generated cleanly. Editor operations reject them, so a
* failure here means the scene was hand-edited or comes from an older
* build. */
std::vector<RoadWedge> wedges;
std::vector<RoadStraightSegment> segments;
enumerateWedges(*this, wedges, segments);
for (const auto &w : wedges) {
if (w.sweptAngleDeg < ROAD_WEDGE_MIN_ANGLE_DEG) {
if (error)
*error = "Sharp wedge at node " +
std::to_string(w.nodeId) + " (" +
std::to_string(w.sweptAngleDeg) +
" deg < 30)";
return false;
}
if (w.degenerate) {
if (error)
*error = "Near-zero-angle wedge at node " +
std::to_string(w.nodeId) + " (" +
std::to_string(w.sweptAngleDeg) +
" deg ~ 360)";
return false;
}
}
return true;
}
#endif // EDITSCENE_ROADGRAPH_HPP
+11 -37
View File
@@ -2,6 +2,7 @@
#define EDITSCENE_TERRAIN_HPP
#pragma once
#include "RoadGraph.hpp"
#include <Ogre.h>
#include <string>
#include <vector>
@@ -27,6 +28,9 @@ struct TerrainComponent {
// Base binary heightmap resolution. Default 256x256.
int heightmapSize = 256;
// Blend-map texture resolution. Default 256x256; independent of heightmapSize.
int blendMapSize = 256;
// World-space size of one Ogre terrain page in units.
float worldSize = 2000.0f;
@@ -52,42 +56,9 @@ struct TerrainComponent {
};
std::vector<Layer> layers;
// Road configuration parameters.
struct RoadConfig {
std::string roadMeshTemplate = "road_segment.mesh";
float laneWidth = 3.0f;
int lanesPerDirection = 1;
float roadThickness = 0.3f;
float roadLodDistance = 200.0f;
float roadVisibilityDistance = 1000.0f;
std::string roadMaterialName = "RoadMaterial";
};
RoadConfig roadConfig;
// Road network data (node/edge graph).
struct RoadNode {
Ogre::Vector3 position;
float verticalOffset = 0.0f;
int id = 0;
};
struct RoadEdge {
int nodeA = -1;
int nodeB = -1;
float roadLevelA = 0.0f;
float roadLevelB = 0.0f;
int lanesPerDirectionOverride = 0;
int lanesAtoB = 0;
int lanesBtoA = 0;
struct RoadSidePrefab {
std::string prefabPath;
float edgeT = 0.5f;
float sideOffset = 5.0f;
bool leftSide = true;
};
std::vector<RoadSidePrefab> sidePrefabs;
};
std::vector<RoadNode> roadNodes;
std::vector<RoadEdge> roadEdges;
// Road network data model: configuration, nodes, edges, and side prefabs.
// See RoadGraph.hpp for detailed field documentation.
RoadGraph roadGraph;
// Procedural detail noise layered on top of the base heightmap.
// Not baked into heightmap.bin; evaluated on demand.
@@ -115,7 +86,10 @@ struct TerrainComponent {
bool dirty = true;
bool rebuildInProgress = false;
void markDirty() { dirty = true; }
void markDirty()
{
dirty = true;
}
};
#endif // EDITSCENE_TERRAIN_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;
+262
View File
@@ -0,0 +1,262 @@
#include "RoadGizmo.hpp"
#include <cmath>
// Project ray onto axis line and return the parameter t along the axis.
static bool projectRayOntoAxis(const Ogre::Ray &ray,
const Ogre::Vector3 &axisOrigin,
const Ogre::Vector3 &axisDir, float &outT)
{
Ogre::Vector3 rayOrigin = ray.getOrigin();
Ogre::Vector3 rayDir = ray.getDirection();
Ogre::Vector3 w0 = rayOrigin - axisOrigin;
float a = rayDir.dotProduct(rayDir);
float b = rayDir.dotProduct(axisDir);
float c = axisDir.dotProduct(axisDir);
float d = rayDir.dotProduct(w0);
float e = axisDir.dotProduct(w0);
float denom = a * c - b * b;
if (std::abs(denom) < 0.0001f)
return false;
outT = (a * e - b * d) / denom;
return true;
}
RoadGizmo::RoadGizmo(Ogre::SceneManager *sceneMgr)
: m_sceneMgr(sceneMgr)
{
m_gizmoNode = m_sceneMgr->getRootSceneNode()->createChildSceneNode(
"RoadGizmoNode");
m_axisX = m_sceneMgr->createManualObject("RoadGizmoAxisX");
m_axisZ = m_sceneMgr->createManualObject("RoadGizmoAxisZ");
m_gizmoNode->attachObject(m_axisX);
m_gizmoNode->attachObject(m_axisZ);
m_axisX->setRenderQueueGroup(Ogre::RENDER_QUEUE_OVERLAY);
m_axisZ->setRenderQueueGroup(Ogre::RENDER_QUEUE_OVERLAY);
m_gizmoNode->setVisible(false);
createGeometry();
}
RoadGizmo::~RoadGizmo()
{
if (m_sceneMgr)
shutdown();
}
void RoadGizmo::shutdown()
{
if (!m_sceneMgr)
return;
if (m_gizmoNode && m_axisX) {
try {
m_gizmoNode->detachObject(m_axisX);
} catch (...) {
}
}
if (m_gizmoNode && m_axisZ) {
try {
m_gizmoNode->detachObject(m_axisZ);
} catch (...) {
}
}
if (m_axisX) {
try {
m_sceneMgr->destroyManualObject(m_axisX);
} catch (...) {
}
m_axisX = nullptr;
}
if (m_axisZ) {
try {
m_sceneMgr->destroyManualObject(m_axisZ);
} catch (...) {
}
m_axisZ = nullptr;
}
if (m_gizmoNode) {
try {
m_sceneMgr->destroySceneNode(m_gizmoNode);
} catch (...) {
}
m_gizmoNode = nullptr;
}
m_sceneMgr = nullptr;
}
void RoadGizmo::createGeometry()
{
float len = m_axisLength * m_size;
float arrowSize = 0.3f * m_size;
bool xSelected = (m_selectedAxis == Axis::X || m_hoveredAxis == Axis::X);
m_axisX->clear();
m_axisX->begin("Ogre/AxisGizmo", Ogre::RenderOperation::OT_LINE_LIST);
if (xSelected)
m_axisX->colour(1.0f, 1.0f, 0.0f);
else
m_axisX->colour(1.0f, 0.0f, 0.0f);
m_axisX->position(0, 0, 0);
m_axisX->position(len, 0, 0);
m_axisX->position(len, 0, 0);
m_axisX->position(len - arrowSize, arrowSize, 0);
m_axisX->position(len, 0, 0);
m_axisX->position(len - arrowSize, -arrowSize, 0);
m_axisX->end();
bool zSelected = (m_selectedAxis == Axis::Z || m_hoveredAxis == Axis::Z);
m_axisZ->clear();
m_axisZ->begin("Ogre/AxisGizmo", Ogre::RenderOperation::OT_LINE_LIST);
if (zSelected)
m_axisZ->colour(1.0f, 1.0f, 0.0f);
else
m_axisZ->colour(0.0f, 0.0f, 1.0f);
m_axisZ->position(0, 0, 0);
m_axisZ->position(0, 0, len);
m_axisZ->position(0, 0, len);
m_axisZ->position(arrowSize, 0, len - arrowSize);
m_axisZ->position(0, 0, len);
m_axisZ->position(-arrowSize, 0, len - arrowSize);
m_axisZ->end();
}
void RoadGizmo::setPosition(const Ogre::Vector3 &position)
{
if (m_gizmoNode)
m_gizmoNode->setPosition(position);
}
Ogre::Vector3 RoadGizmo::getPosition() const
{
if (m_gizmoNode)
return m_gizmoNode->getPosition();
return Ogre::Vector3::ZERO;
}
void RoadGizmo::setVisible(bool visible)
{
m_visible = visible;
if (m_gizmoNode)
m_gizmoNode->setVisible(visible);
}
bool RoadGizmo::isVisible() const
{
return m_visible;
}
bool RoadGizmo::onMousePressed(const Ogre::Ray &mouseRay)
{
if (!m_axisX || !m_axisX->isVisible())
return false;
m_selectedAxis = hitTest(mouseRay);
if (m_selectedAxis != Axis::None) {
m_isDragging = true;
m_dragStartPosition = getPosition();
switch (m_selectedAxis) {
case Axis::X:
m_dragAxisDir = Ogre::Vector3::UNIT_X;
break;
case Axis::Z:
m_dragAxisDir = Ogre::Vector3::UNIT_Z;
break;
default:
m_dragAxisDir = Ogre::Vector3::UNIT_X;
break;
}
projectRayOntoAxis(mouseRay, m_dragStartPosition, m_dragAxisDir,
m_dragStartT);
createGeometry();
return true;
}
return false;
}
bool RoadGizmo::onMouseMoved(const Ogre::Ray &mouseRay)
{
if (m_isDragging && m_selectedAxis != Axis::None) {
float currentT;
if (!projectRayOntoAxis(mouseRay, m_dragStartPosition, m_dragAxisDir,
currentT))
return true;
float deltaT = currentT - m_dragStartT;
Ogre::Vector3 newPos = m_dragStartPosition + m_dragAxisDir * deltaT;
setPosition(newPos);
return true;
} else if (m_axisX && m_axisX->isVisible()) {
Axis prevHover = m_hoveredAxis;
m_hoveredAxis = hitTest(mouseRay);
if (prevHover != m_hoveredAxis)
createGeometry();
}
return false;
}
bool RoadGizmo::onMouseReleased()
{
if (m_isDragging) {
m_isDragging = false;
m_selectedAxis = Axis::None;
m_dragStartPosition = Ogre::Vector3::ZERO;
createGeometry();
return true;
}
return false;
}
RoadGizmo::Axis RoadGizmo::hitTest(const Ogre::Ray &mouseRay)
{
if (!m_gizmoNode || !m_axisX || !m_axisX->isVisible())
return Axis::None;
Ogre::Vector3 gizmoPos = m_gizmoNode->getPosition();
float len = m_axisLength * m_size;
float threshold = 0.5f * m_size;
float bestDist = 1000000.0f;
Axis bestAxis = Axis::None;
for (int i = 0; i < 2; ++i) {
Ogre::Vector3 axisDir = (i == 0) ?
Ogre::Vector3::UNIT_X :
Ogre::Vector3::UNIT_Z;
for (int j = 0; j <= 8; ++j) {
float t = len * j / 8.0f;
Ogre::Vector3 pointOnAxis = gizmoPos + axisDir * t;
Ogre::Vector3 L = pointOnAxis - mouseRay.getOrigin();
float tca = L.dotProduct(mouseRay.getDirection());
if (tca < 0.01f)
continue;
float d2 = L.dotProduct(L) - tca * tca;
if (d2 <= threshold * threshold) {
if (tca < bestDist) {
bestDist = tca;
bestAxis = (i == 0) ? Axis::X : Axis::Z;
}
}
}
}
return bestAxis;
}
@@ -0,0 +1,92 @@
#ifndef EDITSCENE_ROADGIZMO_HPP
#define EDITSCENE_ROADGIZMO_HPP
#pragma once
#include <Ogre.h>
#include <OgreManualObject.h>
/**
* Simple 2-axis (X/Z) translation gizmo used by the road editor.
*
* Unlike the general entity transform gizmo, RoadGizmo is designed to sit at a
* road node position and drag the node along the horizontal plane. It is
* self-contained and does not require a Flecs entity.
*/
class RoadGizmo {
public:
enum class Axis {
None,
X,
Z
};
RoadGizmo(Ogre::SceneManager *sceneMgr);
~RoadGizmo();
/**
* Shutdown and cleanup - must be called before SceneManager destruction.
*/
void shutdown();
/**
* Move the gizmo to a world-space position.
*/
void setPosition(const Ogre::Vector3 &position);
/**
* Get the current world-space position (valid while dragging).
*/
Ogre::Vector3 getPosition() const;
/**
* Show or hide the gizmo geometry.
*/
void setVisible(bool visible);
bool isVisible() const;
/**
* Mouse interaction. Returns true when the gizmo consumed the event.
*/
bool onMousePressed(const Ogre::Ray &mouseRay);
bool onMouseMoved(const Ogre::Ray &mouseRay);
bool onMouseReleased();
/**
* Current drag state.
*/
bool isDragging() const { return m_isDragging; }
Axis getSelectedAxis() const { return m_selectedAxis; }
/**
* Set gizmo size/scale.
*/
void setSize(float size)
{
m_size = size;
createGeometry();
}
float getSize() const { return m_size; }
private:
void createGeometry();
Axis hitTest(const Ogre::Ray &mouseRay);
Ogre::SceneManager *m_sceneMgr;
Ogre::SceneNode *m_gizmoNode = nullptr;
Ogre::ManualObject *m_axisX = nullptr;
Ogre::ManualObject *m_axisZ = nullptr;
float m_size = 1.0f;
float m_axisLength = 2.0f;
bool m_visible = false;
Axis m_selectedAxis = Axis::None;
Axis m_hoveredAxis = Axis::None;
bool m_isDragging = false;
Ogre::Vector3 m_dragStartPosition = Ogre::Vector3::ZERO;
Ogre::Vector3 m_dragAxisDir = Ogre::Vector3::UNIT_X;
float m_dragStartT = 0.0f;
};
#endif // EDITSCENE_ROADGIZMO_HPP
@@ -328,7 +328,7 @@ void DialogueSystem::renderDialogueBox()
// Speaker name
if (!m_speaker.empty()) {
if (m_speakerFont)
ImGui::PushFont(m_speakerFont);
ImGui::PushFont(m_speakerFont, m_speakerFont->LegacySize);
ImGui::TextColored(ImVec4(0.8f, 0.8f, 1.0f, 1.0f), "%s",
m_speaker.c_str());
if (m_speakerFont)
@@ -338,7 +338,7 @@ void DialogueSystem::renderDialogueBox()
// Narration text
if (m_dialogueFont)
ImGui::PushFont(m_dialogueFont);
ImGui::PushFont(m_dialogueFont, m_dialogueFont->LegacySize);
ImGui::TextWrapped("%s", m_text.c_str());
@@ -5,6 +5,7 @@
#include "ItemRegistry.hpp"
#include "../camera/EditorCamera.hpp"
#include "../systems/TerrainSystem.hpp"
#include "../systems/RoadSystem.hpp"
#include "../components/EntityName.hpp"
#include "../components/Transform.hpp"
#include "../components/Renderable.hpp"
@@ -80,6 +81,7 @@ EditorUISystem::EditorUISystem(flecs::world &world,
{
registerComponentEditors();
m_gizmo = std::make_unique<Gizmo>(m_sceneMgr);
m_roadGizmo = std::make_unique<RoadGizmo>(m_sceneMgr);
m_cursor3D = std::make_unique<Cursor3D>(m_sceneMgr);
m_serializer = std::make_unique<SceneSerializer>(m_world, m_sceneMgr);
@@ -93,10 +95,13 @@ EditorUISystem::~EditorUISystem() = default;
void EditorUISystem::shutdown()
{
// Shutdown gizmo and cursor before SceneManager is destroyed
// Shutdown gizmos and cursor before SceneManager is destroyed
if (m_gizmo) {
m_gizmo->shutdown();
}
if (m_roadGizmo) {
m_roadGizmo->shutdown();
}
if (m_cursor3D) {
m_cursor3D->shutdown();
}
@@ -104,6 +109,28 @@ void EditorUISystem::shutdown()
bool EditorUISystem::onMousePressed(const Ogre::Ray &mouseRay)
{
TerrainSystem *ts = TerrainSystem::getInstance();
const bool roadEdit = ts && ts->getRoadEditMode();
if (roadEdit) {
if (m_roadGizmo && m_roadGizmo->onMousePressed(mouseRay)) {
m_roadGizmoDragged = true;
return true;
}
if (ImGui::GetIO().WantCaptureMouse)
return false;
/* Record the press; the click-vs-drag decision is made on
* release (5 px threshold). The event is not consumed so a
* drag still reaches the camera. */
m_roadClickPending = true;
const ImVec2 &mp = ImGui::GetIO().MousePos;
m_roadMouseDownPos = Ogre::Vector2(mp.x, mp.y);
m_roadMouseDownRay = mouseRay;
return false;
}
// Try gizmo first
if (m_gizmo && m_gizmo->onMousePressed(mouseRay)) {
return true;
@@ -157,6 +184,15 @@ bool EditorUISystem::onMousePressed(const Ogre::Ray &mouseRay)
bool EditorUISystem::onMouseMoved(const Ogre::Ray &mouseRay,
const Ogre::Vector2 &mouseDelta)
{
TerrainSystem *ts = TerrainSystem::getInstance();
const bool roadEdit = ts && ts->getRoadEditMode();
if (roadEdit) {
if (m_roadGizmo && m_roadGizmo->onMouseMoved(mouseRay))
return true;
return false;
}
// Gizmo gets first shot
if (m_gizmo && m_gizmo->onMouseMoved(mouseRay, mouseDelta)) {
return true;
@@ -174,6 +210,30 @@ bool EditorUISystem::onMouseMoved(const Ogre::Ray &mouseRay,
bool EditorUISystem::onMouseReleased()
{
TerrainSystem *ts = TerrainSystem::getInstance();
const bool roadEdit = ts && ts->getRoadEditMode();
if (roadEdit) {
if (m_roadGizmo && m_roadGizmo->onMouseReleased()) {
m_roadGizmoDragged = false;
applyRoadGizmoDrag(true);
return true;
}
if (m_roadClickPending) {
m_roadClickPending = false;
const ImVec2 &mp = ImGui::GetIO().MousePos;
float dx = mp.x - m_roadMouseDownPos.x;
float dy = mp.y - m_roadMouseDownPos.y;
/* Less than 5 pixels: a click. More: a camera drag. */
if (dx * dx + dy * dy < 25.0f)
handleRoadEditClick(m_roadMouseDownRay);
/* Never consume the release: the camera saw the press. */
return false;
}
return false;
}
// Gizmo gets first shot
if (m_gizmo && m_gizmo->onMouseReleased()) {
return true;
@@ -409,6 +469,9 @@ void EditorUISystem::update(float deltaTime)
}
}
// Road edit mode state transitions and per-frame gizmo sync.
updateRoadEditMode();
if (!m_editorUIEnabled) {
renderFPSOverlay(deltaTime);
return;
@@ -2434,3 +2497,276 @@ void EditorUISystem::renderDialogueSettingsWindow()
ImGui::End();
}
/* --- Road editing helpers (M5.2) --- */
void EditorUISystem::updateRoadEditMode()
{
TerrainSystem *ts = TerrainSystem::getInstance();
const bool roadEdit = ts && ts->getRoadEditMode();
if (roadEdit == m_lastRoadEditMode) {
if (roadEdit) {
/* While the gizmo is dragged, move the node every frame
* so the graph follows live; otherwise keep the gizmo
* glued to the current selection. */
if (m_roadGizmo && m_roadGizmo->isDragging())
applyRoadGizmoDrag(false);
else
syncRoadGizmoToSelection();
}
return;
}
m_lastRoadEditMode = roadEdit;
m_roadClickPending = false;
if (roadEdit) {
// Disable the regular entity gizmo while editing roads.
if (m_gizmo)
m_gizmo->detach();
if (m_roadGizmo)
m_roadGizmo->setVisible(true);
syncRoadGizmoToSelection();
} else {
if (m_roadGizmo)
m_roadGizmo->setVisible(false);
// Restore regular gizmo for the selected entity.
if (m_gizmo) {
if (m_selectedEntity.is_alive() &&
m_selectedEntity.has<TransformComponent>())
m_gizmo->attachTo(m_selectedEntity);
else
m_gizmo->detach();
}
}
}
void EditorUISystem::syncRoadGizmoToSelection()
{
if (!m_roadGizmo)
return;
TerrainSystem *ts = TerrainSystem::getInstance();
if (!ts || !ts->getRoadEditMode())
return;
RoadSystem *rs = ts->getRoadSystem();
if (!rs) {
m_roadGizmo->setVisible(false);
return;
}
int nodeId = rs->getSelectedNodeId();
if (nodeId < 0) {
m_roadGizmo->setVisible(false);
return;
}
flecs::entity terrain = rs->getTerrainEntity();
if (!terrain.is_alive() || !terrain.has<TerrainComponent>()) {
m_roadGizmo->setVisible(false);
return;
}
const TerrainComponent &tc = terrain.get<TerrainComponent>();
const RoadNode *node = tc.roadGraph.findNodeById(nodeId);
if (!node) {
m_roadGizmo->setVisible(false);
return;
}
m_roadGizmo->setPosition(node->position);
m_roadGizmo->setVisible(true);
}
void EditorUISystem::applyRoadGizmoDrag(bool snapGizmoBack)
{
TerrainSystem *ts = TerrainSystem::getInstance();
if (!ts || !ts->getRoadEditMode() || !m_roadGizmo)
return;
RoadSystem *rs = ts->getRoadSystem();
if (!rs)
return;
int nodeId = rs->getSelectedNodeId();
if (nodeId < 0)
return;
flecs::entity terrain = rs->getTerrainEntity();
if (!terrain.is_alive() || !terrain.has<TerrainComponent>())
return;
auto &tc = terrain.get_mut<TerrainComponent>();
RoadNode *node = tc.roadGraph.findNodeById(nodeId);
if (!node)
return;
Ogre::Vector3 newPos = m_roadGizmo->getPosition();
float terrainY = ts->getHeightAt(newPos);
node->position.x = newPos.x;
node->position.z = newPos.z;
node->position.y = terrainY + node->verticalOffset;
if (snapGizmoBack) {
// Snap the gizmo back to the snapped node surface position.
m_roadGizmo->setPosition(node->position);
}
tc.roadGraph.bumpVersion();
}
void EditorUISystem::handleRoadEditClick(const Ogre::Ray &mouseRay)
{
TerrainSystem *ts = TerrainSystem::getInstance();
if (!ts || !ts->getRoadEditMode())
return;
RoadSystem *rs = ts->getRoadSystem();
if (!rs)
return;
float t;
Ogre::Vector3 normal;
if (!ts->raycastTerrain(mouseRay, t, normal))
return;
Ogre::Vector3 hit = mouseRay.getPoint(t);
int nodeId = pickRoadNode(hit);
if (nodeId >= 0) {
rs->setSelectedNodeId(nodeId);
rs->setSelectedEdgeIndex(-1);
syncRoadGizmoToSelection();
return;
}
int edgeIdx = pickRoadEdge(hit);
if (edgeIdx >= 0) {
rs->setSelectedEdgeIndex(edgeIdx);
rs->setSelectedNodeId(-1);
m_roadGizmo->setVisible(false);
return;
}
// Click on empty terrain: create a node when the Add Node tool is
// active; in Move mode just clear the selection.
if (ts->getRoadEditTool() == TerrainSystem::RoadEditTool::AddNode) {
addRoadNodeAt(hit);
} else {
rs->setSelectedNodeId(-1);
rs->setSelectedEdgeIndex(-1);
if (m_roadGizmo)
m_roadGizmo->setVisible(false);
}
}
int EditorUISystem::pickRoadNode(const Ogre::Vector3 &hit) const
{
TerrainSystem *ts = TerrainSystem::getInstance();
if (!ts)
return -1;
RoadSystem *rs = ts->getRoadSystem();
if (!rs)
return -1;
flecs::entity terrain = rs->getTerrainEntity();
if (!terrain.is_alive() || !terrain.has<TerrainComponent>())
return -1;
const TerrainComponent &tc = terrain.get<TerrainComponent>();
float threshold = std::max(tc.roadGraph.config.laneWidth, 2.0f);
float bestDist = threshold * threshold;
int bestId = -1;
for (const auto &n : tc.roadGraph.nodes) {
Ogre::Vector3 d = n.position - hit;
float distSq = d.squaredLength();
if (distSq < bestDist) {
bestDist = distSq;
bestId = n.id;
}
}
return bestId;
}
int EditorUISystem::pickRoadEdge(const Ogre::Vector3 &hit) const
{
TerrainSystem *ts = TerrainSystem::getInstance();
if (!ts)
return -1;
RoadSystem *rs = ts->getRoadSystem();
if (!rs)
return -1;
flecs::entity terrain = rs->getTerrainEntity();
if (!terrain.is_alive() || !terrain.has<TerrainComponent>())
return -1;
const TerrainComponent &tc = terrain.get<TerrainComponent>();
float threshold = std::max(tc.roadGraph.config.laneWidth, 2.0f);
float bestDist = threshold * threshold;
int bestIdx = -1;
for (size_t i = 0; i < tc.roadGraph.edges.size(); ++i) {
const RoadEdge &e = tc.roadGraph.edges[i];
const RoadNode *na = tc.roadGraph.findNodeById(e.nodeA);
const RoadNode *nb = tc.roadGraph.findNodeById(e.nodeB);
if (!na || !nb)
continue;
Ogre::Vector3 ab = nb->position - na->position;
Ogre::Vector3 ah = hit - na->position;
float abLenSq = ab.squaredLength();
if (abLenSq < 0.0001f)
continue;
float t = std::max(0.0f, std::min(1.0f,
ah.dotProduct(ab) / abLenSq));
Ogre::Vector3 closest = na->position + ab * t;
float distSq = (closest - hit).squaredLength();
if (distSq < bestDist) {
bestDist = distSq;
bestIdx = (int)i;
}
}
return bestIdx;
}
Ogre::Vector3 EditorUISystem::snapRoadNodePosition(const Ogre::Vector3 &pos) const
{
TerrainSystem *ts = TerrainSystem::getInstance();
if (!ts)
return pos;
float y = ts->getHeightAt(pos);
return Ogre::Vector3(pos.x, y, pos.z);
}
void EditorUISystem::addRoadNodeAt(const Ogre::Vector3 &pos)
{
TerrainSystem *ts = TerrainSystem::getInstance();
if (!ts || !ts->getRoadEditMode())
return;
RoadSystem *rs = ts->getRoadSystem();
if (!rs)
return;
flecs::entity terrain = rs->getTerrainEntity();
if (!terrain.is_alive() || !terrain.has<TerrainComponent>())
return;
auto &tc = terrain.get_mut<TerrainComponent>();
Ogre::Vector3 snapped = snapRoadNodePosition(pos);
int newId = tc.roadGraph.addNode(snapped, 0.0f);
rs->setSelectedNodeId(newId);
rs->setSelectedEdgeIndex(-1);
syncRoadGizmoToSelection();
}
@@ -13,6 +13,7 @@
#include "../ui/AnimationTreeRegistryEditor.hpp"
#include "../components/EntityName.hpp"
#include "../gizmo/Gizmo.hpp"
#include "../gizmo/RoadGizmo.hpp"
#include "../gizmo/Cursor3D.hpp"
#include "SceneSerializer.hpp"
#include "CharacterRegistry.hpp"
@@ -258,6 +259,7 @@ private:
ComponentRegistry m_componentRegistry;
std::vector<flecs::entity> m_allEntities;
std::unique_ptr<Gizmo> m_gizmo;
std::unique_ptr<RoadGizmo> m_roadGizmo;
std::unique_ptr<Cursor3D> m_cursor3D;
std::unique_ptr<SceneSerializer> m_serializer;
@@ -352,6 +354,26 @@ private:
bool m_showAnimationTreeRegistry = false;
AnimationTreeRegistryEditor m_animationTreeRegistryEditor;
// Road editing (M5.2)
void updateRoadEditMode();
void syncRoadGizmoToSelection();
void applyRoadGizmoDrag(bool snapGizmoBack);
void handleRoadEditClick(const Ogre::Ray &mouseRay);
int pickRoadNode(const Ogre::Vector3 &hit) const;
int pickRoadEdge(const Ogre::Vector3 &hit) const;
Ogre::Vector3 snapRoadNodePosition(const Ogre::Vector3 &pos) const;
void addRoadNodeAt(const Ogre::Vector3 &pos);
bool m_lastRoadEditMode = false;
bool m_roadGizmoDragged = false;
/* Pending left-click in road edit mode. The press is recorded and
* resolved on release: movement below 5 pixels is a click, anything
* more is treated as a camera drag (M5.2). */
bool m_roadClickPending = false;
Ogre::Vector2 m_roadMouseDownPos = Ogre::Vector2::ZERO;
Ogre::Ray m_roadMouseDownRay;
// Queries
flecs::query<EntityNameComponent> m_nameQuery;
@@ -126,7 +126,7 @@ void PauseMenuSystem::renderMenu()
ImGuiWindowFlags_NoFocusOnAppearing);
if (m_menuFont)
ImGui::PushFont(m_menuFont);
ImGui::PushFont(m_menuFont, m_menuFont->LegacySize);
struct ButtonData {
const char *label;
@@ -85,6 +85,13 @@ bool ProceduralMeshSystem::arePrimitivesDirty(flecs::entity parent)
void ProceduralMeshSystem::buildTriangleBuffer(flecs::entity entity, TriangleBufferComponent& tb)
{
// Directly-filled buffers (e.g. road geometry) have no Primitive
// children; keep their contents and just clear the dirty flag.
if (tb.proceduralContent) {
tb.dirty = false;
return;
}
// Create new triangle buffer
tb.buffer = std::make_shared<Procedural::TriangleBuffer>();
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,317 @@
#ifndef EDITSCENE_ROADSYSTEM_HPP
#define EDITSCENE_ROADSYSTEM_HPP
#pragma once
#include <Ogre.h>
#include <OgreManualObject.h>
#include <ProceduralTriangleBuffer.h>
#include <flecs.h>
#include <Jolt/Jolt.h>
#include <Jolt/Physics/Body/BodyID.h>
#include <cstdint>
#include <memory>
#include <string>
#include <unordered_map>
#include <vector>
#include "../components/RoadGraph.hpp"
namespace Ogre {
class TerrainGroup;
}
struct RoadConfig;
class JoltPhysicsWrapper;
/**
* Per-page road geometry state (M5.4).
*
* One entry exists for every loaded terrain page. The page generates the
* wedges and straight segments seeded by road nodes inside its bounds (see
* the page-assignment decision in TerrainRequirements.md, M5.4). Runtime
* objects (mesh entity, collider, roadside prefabs) are created in later
* milestones and destroyed together when the page unloads.
*/
struct RoadPageGeometry {
long pageX = 0;
long pageY = 0;
/** Entity holding this page's road TriangleBufferComponent (M5.8). */
flecs::entity bufferEntity = flecs::entity::null();
/** Entity holding this page's road collider body (M5.9). */
flecs::entity colliderEntity = flecs::entity::null();
/**
* Jolt body of this page's static road collider (M5.9), created from
* the page's render mesh once it exists. Invalid when no collider
* has been created.
*/
JPH::BodyID bodyId;
/** Collision soup mirroring the generated road triangles (M5.9). */
std::vector<Ogre::Vector3> collisionVertices;
std::vector<uint32_t> collisionIndices;
/** Roadside prefab instances spawned for this page (M5.11). */
std::vector<flecs::entity> spawnedPrefabs;
/** Wedges and straight segments seeded by nodes inside this page. */
std::vector<RoadWedge> wedges;
std::vector<RoadStraightSegment> segments;
/** Geometry needs (re)generation. */
bool dirty = true;
/**
* Rendering distance, navmesh contribution and RenderableComponent
* pointer have been applied to the created Ogre mesh (M5.8). Reset
* whenever the page mesh is (re)built.
*/
bool meshFinalized = false;
};
/**
* RoadSystem runtime owner of road visual aids, per-page road geometry
* state, and (future) road mesh generation.
*
* The class builds and updates ManualObject overlays for the road graph:
* node markers, edge lines, road-width indicators, and selection
* highlights. It also tracks terrain page load/unload and buckets the
* enumerated wedges/straight segments by the page containing their seed
* node (M5.4). It is created by TerrainSystem when a terrain is activated
* and destroyed when the terrain deactivates.
*/
class RoadSystem {
public:
RoadSystem(flecs::world &world, Ogre::SceneManager *sceneMgr);
~RoadSystem();
/**
* Associate the system with the active terrain entity.
*
* The entity must have a TerrainComponent. Passing a null entity clears
* the association and destroys any visual aids.
*/
void setTerrainEntity(flecs::entity entity);
/**
* Clear the associated terrain entity and remove visual aids.
*/
void clear();
/**
* Rebuild visual aids if the road graph has changed.
*
* Should be called from TerrainSystem::update() each frame while the
* terrain is active.
*/
void update(float deltaTime);
/** Show or hide all road visual aids. */
void setVisualAidsVisible(bool visible);
bool getVisualAidsVisible() const;
/** Selection state used by the road editor UI. */
int getSelectedNodeId() const { return m_selectedNodeId; }
void setSelectedNodeId(int id);
int getSelectedEdgeIndex() const { return m_selectedEdgeIndex; }
void setSelectedEdgeIndex(int idx);
/** Wedge-debug mode for geometry inspection. */
bool getDebugWedgeEnabled() const { return m_debugWedgeEnabled; }
void setDebugWedgeEnabled(bool v);
int getDebugWedgeIndex() const { return m_debugWedgeIndex; }
void setDebugWedgeIndex(int idx);
/** The terrain entity this system is bound to. */
flecs::entity getTerrainEntity() const;
/**
* 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);
/**
* Provide the terrain system for height queries (M5.11).
*
* Called by TerrainSystem right after construction. Passing nullptr
* disables roadside prefab spawning.
*/
void setTerrainSystem(class TerrainSystem *terrainSystem);
/**
* Terrain compliance (M5.10): writes fixup values under every
* road surface vertex so the terrain matches the road underside.
*
* @param terrainSystem the active TerrainSystem that owns the
* fixup layer (used to call writeFixup + markPageDirty).
* @param roadThickness vertical thickness of the road slab.
* @param laneWidth falloff is measured in lane widths.
*/
void complyTerrain(class TerrainSystem *terrainSystem,
float roadThickness, float laneWidth);
/** Road physics body IDs for debug-draw filtering. */
const std::set<JPH::BodyID> &getRoadBodyIds() const
{
return m_roadBodyIds;
}
/** Compute target terrain height for road compliance falloff. */
static float computeComplianceHeight(
float roadSurfaceY, float roadThickness,
float baseHeight, float lateralDistance,
float halfRoadWidth, float fadeWidth);
/**
* Per-page road geometry state, keyed by
* TerrainGroup::packIndex(pageX, pageY) (M5.4).
*
* Exposed for headless tests and for the M5.8 mesh assembly.
*/
const std::unordered_map<uint64_t, RoadPageGeometry> &
getPageGeometry() const
{
return m_pageGeometry;
}
private:
void createManualObjects();
void destroyManualObjects();
void rebuildVisualAids();
void buildNodeMarkers();
void buildEdgeLines();
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);
/* Roadside prefab spawning (M5.11). */
void spawnSidePrefabs(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;
flecs::world &m_world;
Ogre::SceneManager *m_sceneMgr;
flecs::entity_t m_terrainEntityId = 0;
Ogre::TerrainGroup *m_terrainGroup = nullptr;
bool m_visualAidsVisible = true;
int m_selectedNodeId = -1;
int m_selectedEdgeIndex = -1;
uint64_t m_lastGraphVersion = 0;
/* Page geometry state (M5.4). m_wedgeBuckets holds the enumerated
* wedges/segments bucketed by seed-node page for ALL pages (including
* unloaded ones); m_pageGeometry only tracks loaded pages. */
std::unordered_map<uint64_t, RoadPageGeometry> m_pageGeometry;
std::unordered_map<uint64_t, RoadPageGeometry> m_wedgeBuckets;
uint64_t m_geometryGraphVersion = 0;
/** Shared road material entity (M5.8), created lazily. */
flecs::entity m_materialEntity = flecs::entity::null();
/** Shared road LOD settings entity (M5.8), created lazily. */
flecs::entity m_lodSettingsEntity = flecs::entity::null();
/** Physics wrapper for road colliders (M5.9), may be null. */
std::set<JPH::BodyID> m_roadBodyIds;
JoltPhysicsWrapper *m_physics = nullptr;
/** Terrain system for height queries (M5.11), may be null. */
class TerrainSystem *m_terrainSystem = nullptr;
Procedural::TriangleBuffer m_templateBuffer;
std::string m_templateName;
float m_templateThickness = -1.0f;
/* Wedge-debug visualization (ManualObject). */
bool m_debugWedgeEnabled = false;
int m_debugWedgeIndex = -1;
int m_lastDebugNodeId = -1;
int m_lastDebugWedgeIndex = -2;
uint64_t m_lastDebugGraphVersion = 0;
Ogre::ManualObject *m_debugWedgeObject = nullptr;
void rebuildDebugWedge();
Ogre::SceneNode *m_visualNode = nullptr;
Ogre::ManualObject *m_nodeMarkers = nullptr;
Ogre::ManualObject *m_edgeLines = nullptr;
Ogre::ManualObject *m_widthIndicators = nullptr;
Ogre::ManualObject *m_selectionHighlight = nullptr;
};
#endif // EDITSCENE_ROADSYSTEM_HPP
@@ -4151,6 +4151,7 @@ nlohmann::json SceneSerializer::serializeTerrain(flecs::entity entity)
json["terrainSize"] = tc.terrainSize;
json["terrainId"] = tc.terrainId;
json["heightmapSize"] = tc.heightmapSize;
json["blendMapSize"] = tc.blendMapSize;
json["worldSize"] = tc.worldSize;
json["maxPixelError"] = tc.maxPixelError;
json["compositeMapDistance"] = tc.compositeMapDistance;
@@ -4191,18 +4192,18 @@ nlohmann::json SceneSerializer::serializeTerrain(flecs::entity entity)
json["detailNoise"] = detailNoiseJson;
nlohmann::json roadConfigJson;
roadConfigJson["roadMeshTemplate"] = tc.roadConfig.roadMeshTemplate;
roadConfigJson["laneWidth"] = tc.roadConfig.laneWidth;
roadConfigJson["lanesPerDirection"] = tc.roadConfig.lanesPerDirection;
roadConfigJson["roadThickness"] = tc.roadConfig.roadThickness;
roadConfigJson["roadLodDistance"] = tc.roadConfig.roadLodDistance;
roadConfigJson["roadMeshTemplate"] = tc.roadGraph.config.roadMeshTemplate;
roadConfigJson["laneWidth"] = tc.roadGraph.config.laneWidth;
roadConfigJson["lanesPerDirection"] = tc.roadGraph.config.lanesPerDirection;
roadConfigJson["roadThickness"] = tc.roadGraph.config.roadThickness;
roadConfigJson["roadLodDistance"] = tc.roadGraph.config.roadLodDistance;
roadConfigJson["roadVisibilityDistance"] =
tc.roadConfig.roadVisibilityDistance;
roadConfigJson["roadMaterialName"] = tc.roadConfig.roadMaterialName;
tc.roadGraph.config.roadVisibilityDistance;
roadConfigJson["roadMaterialName"] = tc.roadGraph.config.roadMaterialName;
json["roadConfig"] = roadConfigJson;
nlohmann::json roadNodesJson = nlohmann::json::array();
for (auto &n : tc.roadNodes) {
for (auto &n : tc.roadGraph.nodes) {
nlohmann::json nj;
nj["id"] = n.id;
nj["position"] = { n.position.x, n.position.y, n.position.z };
@@ -4212,7 +4213,7 @@ nlohmann::json SceneSerializer::serializeTerrain(flecs::entity entity)
json["roadNodes"] = roadNodesJson;
nlohmann::json roadEdgesJson = nlohmann::json::array();
for (auto &e : tc.roadEdges) {
for (auto &e : tc.roadGraph.edges) {
nlohmann::json ej;
ej["nodeA"] = e.nodeA;
ej["nodeB"] = e.nodeB;
@@ -4242,6 +4243,7 @@ nlohmann::json SceneSerializer::serializeTerrain(flecs::entity entity)
ts->saveSceneHeightmap(tc);
ts->saveSceneBlendMaps(tc);
ts->saveSceneAuxMaps(tc);
ts->saveFixups();
}
return json;
@@ -4256,6 +4258,7 @@ void SceneSerializer::deserializeTerrain(flecs::entity entity,
tc.terrainSize = json.value("terrainSize", 65);
tc.terrainId = json.value("terrainId", (uint64_t)0);
tc.heightmapSize = json.value("heightmapSize", 256);
tc.blendMapSize = json.value("blendMapSize", 256);
tc.worldSize = json.value("worldSize", 2000.0f);
tc.maxPixelError = json.value("maxPixelError", 1.0f);
tc.compositeMapDistance = json.value("compositeMapDistance", 300.0f);
@@ -4298,22 +4301,22 @@ void SceneSerializer::deserializeTerrain(flecs::entity entity,
if (json.contains("roadConfig")) {
auto &rcj = json["roadConfig"];
tc.roadConfig.roadMeshTemplate =
tc.roadGraph.config.roadMeshTemplate =
rcj.value("roadMeshTemplate", "road_segment.mesh");
tc.roadConfig.laneWidth = rcj.value("laneWidth", 3.0f);
tc.roadConfig.lanesPerDirection =
tc.roadGraph.config.laneWidth = rcj.value("laneWidth", 3.0f);
tc.roadGraph.config.lanesPerDirection =
rcj.value("lanesPerDirection", 1);
tc.roadConfig.roadThickness = rcj.value("roadThickness", 0.3f);
tc.roadConfig.roadLodDistance = rcj.value("roadLodDistance", 200.0f);
tc.roadConfig.roadVisibilityDistance =
tc.roadGraph.config.roadThickness = rcj.value("roadThickness", 0.3f);
tc.roadGraph.config.roadLodDistance = rcj.value("roadLodDistance", 200.0f);
tc.roadGraph.config.roadVisibilityDistance =
rcj.value("roadVisibilityDistance", 1000.0f);
tc.roadConfig.roadMaterialName =
tc.roadGraph.config.roadMaterialName =
rcj.value("roadMaterialName", "RoadMaterial");
}
if (json.contains("roadNodes") && json["roadNodes"].is_array()) {
for (auto &nj : json["roadNodes"]) {
TerrainComponent::RoadNode node;
RoadNode node;
node.id = nj.value("id", 0);
node.verticalOffset = nj.value("verticalOffset", 0.0f);
if (nj.contains("position") && nj["position"].is_array() &&
@@ -4322,13 +4325,13 @@ void SceneSerializer::deserializeTerrain(flecs::entity entity,
node.position.y = nj["position"][1];
node.position.z = nj["position"][2];
}
tc.roadNodes.push_back(node);
tc.roadGraph.nodes.push_back(node);
}
}
if (json.contains("roadEdges") && json["roadEdges"].is_array()) {
for (auto &ej : json["roadEdges"]) {
TerrainComponent::RoadEdge edge;
RoadEdge edge;
edge.nodeA = ej.value("nodeA", -1);
edge.nodeB = ej.value("nodeB", -1);
edge.roadLevelA = ej.value("roadLevelA", 0.0f);
@@ -4341,7 +4344,7 @@ void SceneSerializer::deserializeTerrain(flecs::entity entity,
if (ej.contains("sidePrefabs") &&
ej["sidePrefabs"].is_array()) {
for (auto &spj : ej["sidePrefabs"]) {
TerrainComponent::RoadEdge::RoadSidePrefab sp;
RoadEdge::RoadSidePrefab sp;
sp.prefabPath = spj.value("prefabPath", "");
sp.edgeT = spj.value("edgeT", 0.5f);
sp.sideOffset = spj.value("sideOffset", 5.0f);
@@ -4349,10 +4352,12 @@ void SceneSerializer::deserializeTerrain(flecs::entity entity,
edge.sidePrefabs.push_back(sp);
}
}
tc.roadEdges.push_back(edge);
tc.roadGraph.edges.push_back(edge);
}
}
tc.roadGraph.bumpVersion();
entity.set<TerrainComponent>(tc);
TerrainSystem *ts = TerrainSystem::getInstance();
@@ -204,10 +204,15 @@ private:
void deserializeWaterPhysics(flecs::entity entity,
const nlohmann::json &json);
// WaterPlane serialization
nlohmann::json serializeWaterPlane(flecs::entity entity);
// Terrain serialization (public so tests and tools can round-trip a
// single TerrainComponent without saving the whole scene).
public:
nlohmann::json serializeTerrain(flecs::entity entity);
void deserializeTerrain(flecs::entity entity, const nlohmann::json &json);
private:
// WaterPlane serialization
nlohmann::json serializeWaterPlane(flecs::entity entity);
void deserializeWaterPlane(flecs::entity entity,
const nlohmann::json &json);
@@ -135,7 +135,7 @@ void StartupMenuSystem::renderMenu(StartupMenuComponent &sm)
ImGuiWindowFlags_NoFocusOnAppearing);
if (m_menuFont)
ImGui::PushFont(m_menuFont);
ImGui::PushFont(m_menuFont, m_menuFont->LegacySize);
struct ButtonData {
const char *label;
@@ -1,4 +1,5 @@
#include "TerrainSystem.hpp"
#include "RoadSystem.hpp"
#include "../components/Terrain.hpp"
#include "../components/Transform.hpp"
#include "../physics/physics.h"
@@ -60,8 +61,11 @@ TerrainSystem::~TerrainSystem()
bool TerrainSystem::TerrainBodyDrawFilter::ShouldDraw(
const JPH::Body &inBody) const
{
if (!showTerrain)
return m_terrainIds.find(inBody.GetID()) == m_terrainIds.end();
JPH::BodyID id = inBody.GetID();
if (!showTerrain && m_terrainIds.find(id) != m_terrainIds.end())
return false;
if (!showRoad && m_roadIds.find(id) != m_roadIds.end())
return false;
return true;
}
@@ -473,6 +477,626 @@ bool TerrainSystem::saveSceneHeightmap(const TerrainComponent &tc)
return saveHeightmap(getHeightmapPath(tc));
}
/* ------------------------------------------------------------------ */
/* Fixup chunks (M5.9.5) */
/* ------------------------------------------------------------------ */
std::string TerrainSystem::getFixupDir(const TerrainComponent &tc) const
{
return "heightmaps/" + Ogre::StringConverter::toString(tc.terrainId) +
"/terrain_fixup";
}
std::string TerrainSystem::fixupChunkPath(int chunkX, int chunkZ) const
{
return m_fixupDir + "/x" + Ogre::StringConverter::toString(chunkX) +
"_z" + Ogre::StringConverter::toString(chunkZ) + ".bin";
}
bool TerrainSystem::loadFixupChunk(int chunkX, int chunkZ,
std::vector<float> &out) const
{
std::string path = fixupChunkPath(chunkX, chunkZ);
std::ifstream file(path, std::ios::binary);
if (!file)
return false;
out.resize(FIXUP_CHUNK_RES * FIXUP_CHUNK_RES);
file.read(reinterpret_cast<char *>(out.data()),
FIXUP_CHUNK_RES * FIXUP_CHUNK_RES * sizeof(float));
bool ok = file.good();
if (!ok)
out.clear();
return ok;
}
const TerrainSystem::FixupChunk *
TerrainSystem::findFixupChunkLocked(int chunkX, int chunkZ) const
{
auto it = m_fixupChunks.find({chunkX, chunkZ});
if (it != m_fixupChunks.end())
return &it->second;
/* Lazy-load from disk. m_heightmapMutex must be held by the caller. */
std::vector<float> samples;
if (loadFixupChunk(chunkX, chunkZ, samples)) {
auto &chunk = m_fixupChunks[{chunkX, chunkZ}];
chunk.samples = std::move(samples);
chunk.dirty = false;
return &chunk;
}
return nullptr;
}
float TerrainSystem::sampleFixupLocked(float worldX, float worldZ) const
{
/* Compute chunk coordinates. Chunk (0,0) covers world
* (0,0)..(worldSize/256, worldSize/256) in both X and Z.
* The chunk resolution FIXUP_CHUNK_RES is always 256 regardless
* of heightmapSize this gives a consistent grid for fixups. */
float chunkWorldSize = m_heightmapWorldSize / (float)FIXUP_CHUNK_RES;
int chunkX = (int)std::floor(worldX / chunkWorldSize);
int chunkZ = (int)std::floor(worldZ / chunkWorldSize);
const FixupChunk *chunk = findFixupChunkLocked(chunkX, chunkZ);
if (!chunk || chunk->samples.empty())
return FIXUP_SENTINEL;
/* Bilinear sample within the chunk. */
float cellX = (worldX - (float)chunkX * chunkWorldSize) /
chunkWorldSize * (float)FIXUP_CHUNK_RES;
float cellZ = (worldZ - (float)chunkZ * chunkWorldSize) /
chunkWorldSize * (float)FIXUP_CHUNK_RES;
int x0 = (int)std::floor(cellX);
int z0 = (int)std::floor(cellZ);
int x1 = x0 + 1;
int z1 = z0 + 1;
int r = FIXUP_CHUNK_RES;
x0 = std::max(0, std::min(x0, r - 1));
x1 = std::max(0, std::min(x1, r - 1));
z0 = std::max(0, std::min(z0, r - 1));
z1 = std::max(0, std::min(z1, r - 1));
float tx = cellX - (float)x0;
float tz = cellZ - (float)z0;
float h00 = chunk->samples[z0 * r + x0];
float h10 = chunk->samples[z0 * r + x1];
float h01 = chunk->samples[z1 * r + x0];
float h11 = chunk->samples[z1 * r + x1];
/* If all four samples are sentinel, no fixup at this cell. */
if (h00 == FIXUP_SENTINEL && h10 == FIXUP_SENTINEL &&
h01 == FIXUP_SENTINEL && h11 == FIXUP_SENTINEL)
return FIXUP_SENTINEL;
/* Replace sentinel with fallback so partial cells still blend. */
if (h00 == FIXUP_SENTINEL)
h00 = 0.0f;
if (h10 == FIXUP_SENTINEL)
h10 = 0.0f;
if (h01 == FIXUP_SENTINEL)
h01 = 0.0f;
if (h11 == FIXUP_SENTINEL)
h11 = 0.0f;
return (1.0f - tx) * (1.0f - tz) * h00 + tx * (1.0f - tz) * h10 +
(1.0f - tx) * tz * h01 + tx * tz * h11;
}
void TerrainSystem::writeFixup(float worldX, float worldZ, float height)
{
std::lock_guard<std::mutex> lock(m_heightmapMutex);
float chunkWorldSize = m_heightmapWorldSize / (float)FIXUP_CHUNK_RES;
int chunkX = (int)std::floor(worldX / chunkWorldSize);
int chunkZ = (int)std::floor(worldZ / chunkWorldSize);
/* Create or retrieve the chunk lazily. */
auto &chunk = m_fixupChunks[{chunkX, chunkZ}];
if (chunk.samples.empty()) {
chunk.samples.resize(FIXUP_CHUNK_RES * FIXUP_CHUNK_RES,
FIXUP_SENTINEL);
}
/* Write to the four samples of the chunk cell containing
* (worldX, worldZ) so bilinear sampling returns @p height
* at the exact write position. */
float cellX = (worldX - (float)chunkX * chunkWorldSize) /
chunkWorldSize * (float)FIXUP_CHUNK_RES;
float cellZ = (worldZ - (float)chunkZ * chunkWorldSize) /
chunkWorldSize * (float)FIXUP_CHUNK_RES;
int x0 = (int)std::floor(cellX);
int z0 = (int)std::floor(cellZ);
int x1 = x0 + 1;
int z1 = z0 + 1;
int r = FIXUP_CHUNK_RES;
if (x0 < 0 || x1 >= r || z0 < 0 || z1 >= r)
return;
chunk.samples[z0 * r + x0] = height;
chunk.samples[z0 * r + x1] = height;
chunk.samples[z1 * r + x0] = height;
chunk.samples[z1 * r + x1] = height;
chunk.dirty = true;
}
bool TerrainSystem::saveFixups()
{
std::lock_guard<std::mutex> lock(m_heightmapMutex);
for (auto &kv : m_fixupChunks) {
if (!kv.second.dirty || kv.second.samples.empty())
continue;
int chunkX = kv.first.first;
int chunkZ = kv.first.second;
std::string path = fixupChunkPath(chunkX, chunkZ);
std::filesystem::path p(path);
std::filesystem::create_directories(p.parent_path());
std::ofstream file(path, std::ios::binary);
if (!file) {
Ogre::LogManager::getSingleton().logMessage(
"Terrain: failed to write fixup chunk: " +
path);
continue;
}
file.write(
reinterpret_cast<const char *>(kv.second.samples.data()),
kv.second.samples.size() * sizeof(float));
kv.second.dirty = false;
Ogre::LogManager::getSingleton().logMessage(
"Terrain: saved fixup chunk x" +
Ogre::StringConverter::toString(chunkX) + "_z" +
Ogre::StringConverter::toString(chunkZ));
}
return true;
}
void TerrainSystem::clearAllFixups()
{
std::lock_guard<std::mutex> lock(m_heightmapMutex);
/* Delete all fixup files. */
for (auto &kv : m_fixupChunks) {
std::string path =
fixupChunkPath(kv.first.first, kv.first.second);
std::remove(path.c_str());
}
m_fixupChunks.clear();
/* Mark all loaded pages dirty so they re-sample without fixups. */
if (mTerrainGroup) {
for (long py = m_pageMinY; py <= m_pageMaxY; ++py) {
for (long px = m_pageMinX; px <= m_pageMaxX; ++px) {
Ogre::Terrain *terrain =
mTerrainGroup->getTerrain(px, py);
if (terrain && terrain->isLoaded())
markPageDirty(px, py);
}
}
}
Ogre::LogManager::getSingleton().logMessage(
"Terrain: cleared all fixup chunks");
}
size_t TerrainSystem::getFixupChunkCount() const
{
std::lock_guard<std::mutex> lock(m_heightmapMutex);
return m_fixupChunks.size();
}
/* ------------------------------------------------------------------ */
/* Resolution change helpers (M4.6) */
/* ------------------------------------------------------------------ */
static bool isValidTerrainResolution(int resolution)
{
static const int resolutions[] = { 256, 512, 1024, 2048, 4096 };
for (int r : resolutions) {
if (r == resolution)
return true;
}
return false;
}
static std::vector<float> resampleFloatMap(const std::vector<float> &src,
int srcRes, int dstRes)
{
if (srcRes <= 1 || dstRes <= 1 || (int)src.size() != srcRes * srcRes)
return std::vector<float>(dstRes * dstRes, 0.0f);
if (srcRes == dstRes)
return src;
std::vector<float> dst(dstRes * dstRes);
const float srcMax = (float)(srcRes - 1);
const float dstMax = (float)(dstRes - 1);
for (int z = 0; z < dstRes; ++z) {
float v = (float)z / dstMax * srcMax;
int z0 = (int)floorf(v);
int z1 = std::min(srcRes - 1, z0 + 1);
float tz = v - (float)z0;
for (int x = 0; x < dstRes; ++x) {
float u = (float)x / dstMax * srcMax;
int x0 = (int)floorf(u);
int x1 = std::min(srcRes - 1, x0 + 1);
float tx = u - (float)x0;
float h00 = src[z0 * srcRes + x0];
float h10 = src[z0 * srcRes + x1];
float h01 = src[z1 * srcRes + x0];
float h11 = src[z1 * srcRes + x1];
dst[z * dstRes + x] = (1.0f - tx) * (1.0f - tz) * h00 +
tx * (1.0f - tz) * h10 +
(1.0f - tx) * tz * h01 +
tx * tz * h11;
}
}
return dst;
}
bool TerrainSystem::changeHeightmapResolution(TerrainComponent &tc,
int newResolution)
{
Ogre::LogManager::getSingleton().logMessage(
"Terrain: changeHeightmapResolution requested old=" +
Ogre::StringConverter::toString(tc.heightmapSize) +
" new=" + Ogre::StringConverter::toString(newResolution));
if (!isValidTerrainResolution(newResolution)) {
Ogre::LogManager::getSingleton().logMessage(
"Terrain: invalid heightmap resolution " +
Ogre::StringConverter::toString(newResolution));
return false;
}
if (newResolution == tc.heightmapSize)
return true;
if (m_sculptMode || m_paintMode || m_auxPaintMode) {
Ogre::LogManager::getSingleton().logMessage(
"Terrain: cannot change heightmap resolution while "
"sculpt/paint/aux mode is active");
return false;
}
/* Snapshot heightmap data before deactivate() clears it. */
std::vector<float> oldHeightData;
int oldRes = tc.heightmapSize;
{
std::lock_guard<std::mutex> lock(m_heightmapMutex);
if (m_heightmapLoaded && m_heightmapRes == oldRes) {
oldHeightData = m_heightData;
}
}
/* Fall back to loading from disk if the runtime buffer is not available. */
if (oldHeightData.empty()) {
std::string path = getHeightmapPath(tc);
std::ifstream file(path, std::ios::binary);
if (file) {
uint32_t fileRes = 0;
file.read(reinterpret_cast<char *>(&fileRes),
sizeof(fileRes));
if (fileRes >= 2 && fileRes <= 8192) {
oldRes = (int)fileRes;
oldHeightData.resize(oldRes * oldRes);
file.read(reinterpret_cast<char *>(
oldHeightData.data()),
oldRes * oldRes * sizeof(float));
}
}
}
if (oldHeightData.empty()) {
Ogre::LogManager::getSingleton().logMessage(
"Terrain: no heightmap data available to resample");
return false;
}
deactivate();
std::string basePath = getHeightmapPath(tc);
std::filesystem::path baseFs(basePath);
std::string stem = baseFs.stem().string();
std::string ext = baseFs.extension().string();
std::string dir = baseFs.parent_path().string();
std::string backupHm = dir + "/" + stem + "_" +
Ogre::StringConverter::toString(oldRes) + ext;
std::string blendDir = dir + "/" + stem + "_blendmaps";
std::string backupBlendDir = dir + "/" + stem + "_blendmaps_backup_" +
Ogre::StringConverter::toString(oldRes);
try {
std::filesystem::create_directories(dir);
/* Write the backup heightmap from the in-memory snapshot so the
* backup always exists even when the source file has not been
* saved yet (e.g. procedural terrain). */
std::ofstream backupHmFile(backupHm, std::ios::binary);
if (backupHmFile) {
uint32_t oldResU = (uint32_t)oldRes;
backupHmFile.write(
reinterpret_cast<const char *>(&oldResU),
sizeof(oldResU));
backupHmFile.write(reinterpret_cast<const char *>(
oldHeightData.data()),
oldHeightData.size() *
sizeof(float));
}
if (std::filesystem::exists(blendDir)) {
std::filesystem::remove_all(backupBlendDir);
std::filesystem::copy(
blendDir, backupBlendDir,
std::filesystem::copy_options::recursive);
}
} catch (const std::exception &e) {
Ogre::LogManager::getSingleton().logMessage(
"Terrain: failed to back up terrain data: " +
std::string(e.what()));
return false;
}
/* Resample and save the heightmap. */
std::vector<float> newHeightData =
resampleFloatMap(oldHeightData, oldRes, newResolution);
{
std::lock_guard<std::mutex> lock(m_heightmapMutex);
m_heightData = std::move(newHeightData);
m_heightmapRes = newResolution;
m_heightmapLoaded = true;
}
if (!saveHeightmap(basePath)) {
Ogre::LogManager::getSingleton().logMessage(
"Terrain: failed to save resampled heightmap to " +
basePath);
return false;
}
/* Sanity-check the written file. */
{
std::ifstream check(basePath, std::ios::binary | std::ios::ate);
if (check) {
std::streamsize size = check.tellg();
std::streamsize expected =
sizeof(uint32_t) +
(std::streamsize)newResolution * newResolution *
sizeof(float);
if (size != expected) {
Ogre::LogManager::getSingleton().logMessage(
"Terrain: resampled heightmap file size mismatch, expected " +
Ogre::StringConverter::toString(
(long)expected) +
" got " +
Ogre::StringConverter::toString(
(long)size));
return false;
}
}
}
/* Resample aux maps to the new heightmap resolution. */
for (auto &aux : tc.auxMaps) {
std::string auxPath = getAuxMapPath(tc, aux);
std::vector<float> auxData(aux.resolution * aux.resolution,
aux.defaultValue);
std::ifstream auxFile(auxPath, std::ios::binary);
if (auxFile) {
auxFile.read(reinterpret_cast<char *>(auxData.data()),
auxData.size() * sizeof(float));
if (!auxFile)
std::fill(auxData.begin(), auxData.end(),
aux.defaultValue);
}
std::vector<float> newAuxData = resampleFloatMap(
auxData, aux.resolution, newResolution);
aux.resolution = newResolution;
std::ofstream auxOut(auxPath, std::ios::binary);
if (auxOut) {
auxOut.write(reinterpret_cast<const char *>(
newAuxData.data()),
newAuxData.size() * sizeof(float));
}
}
/* Handle blend maps. If the on-disk blend-map size differs from the
* configured blendMapSize, resample; otherwise the existing backup is
* already valid and can stay in place. */
int oldBlendMapSize = -1;
if (std::filesystem::exists(backupBlendDir)) {
for (const auto &entry :
std::filesystem::directory_iterator(backupBlendDir)) {
std::ifstream f(entry.path(), std::ios::binary);
if (!f)
continue;
uint32_t sz = 0;
f.read(reinterpret_cast<char *>(&sz), sizeof(sz));
if (sz >= 2 && sz <= 8192) {
oldBlendMapSize = (int)sz;
break;
}
}
}
if (oldBlendMapSize > 0 && oldBlendMapSize != tc.blendMapSize) {
std::filesystem::remove_all(blendDir);
std::filesystem::create_directories(blendDir);
for (const auto &entry :
std::filesystem::directory_iterator(backupBlendDir)) {
std::ifstream f(entry.path(), std::ios::binary);
if (!f)
continue;
uint32_t sz = 0;
uint8_t layer = 0;
f.read(reinterpret_cast<char *>(&sz), sizeof(sz));
f.read(reinterpret_cast<char *>(&layer), sizeof(layer));
if ((int)sz != oldBlendMapSize)
continue;
std::vector<float> bmData(oldBlendMapSize *
oldBlendMapSize);
f.read(reinterpret_cast<char *>(bmData.data()),
bmData.size() * sizeof(float));
std::vector<float> newBmData = resampleFloatMap(
bmData, oldBlendMapSize, tc.blendMapSize);
std::ofstream out(
blendDir + "/" +
entry.path().filename().string(),
std::ios::binary);
if (out) {
uint32_t newSz = (uint32_t)tc.blendMapSize;
out.write(
reinterpret_cast<const char *>(&newSz),
sizeof(newSz));
out.write(
reinterpret_cast<const char *>(&layer),
sizeof(layer));
out.write(reinterpret_cast<const char *>(
newBmData.data()),
newBmData.size() * sizeof(float));
}
}
} else if (!std::filesystem::exists(blendDir) &&
std::filesystem::exists(backupBlendDir)) {
std::filesystem::copy(backupBlendDir, blendDir,
std::filesystem::copy_options::recursive);
}
tc.heightmapSize = newResolution;
tc.dirty = true;
Ogre::LogManager::getSingleton().logMessage(
"Terrain: changed heightmap resolution from " +
Ogre::StringConverter::toString(oldRes) + " to " +
Ogre::StringConverter::toString(newResolution) +
" (tc.heightmapSize=" +
Ogre::StringConverter::toString(tc.heightmapSize) + ")");
return true;
}
bool TerrainSystem::changeBlendMapResolution(TerrainComponent &tc,
int newResolution)
{
if (!isValidTerrainResolution(newResolution)) {
Ogre::LogManager::getSingleton().logMessage(
"Terrain: invalid blend-map resolution " +
Ogre::StringConverter::toString(newResolution));
return false;
}
if (newResolution == tc.blendMapSize)
return true;
if (m_sculptMode || m_paintMode || m_auxPaintMode) {
Ogre::LogManager::getSingleton().logMessage(
"Terrain: cannot change blend-map resolution while "
"sculpt/paint/aux mode is active");
return false;
}
std::string basePath = getHeightmapPath(tc);
std::filesystem::path baseFs(basePath);
std::string stem = baseFs.stem().string();
std::string dir = baseFs.parent_path().string();
std::string blendDir = dir + "/" + stem + "_blendmaps";
std::string backupBlendDir =
dir + "/" + stem + "_blendmaps_backup_" +
Ogre::StringConverter::toString(tc.blendMapSize);
int oldBlendMapSize = -1;
struct BlendMapFile {
std::string name;
std::vector<float> data;
uint8_t layer = 0;
};
std::vector<BlendMapFile> savedBms;
if (std::filesystem::exists(blendDir)) {
for (const auto &entry :
std::filesystem::directory_iterator(blendDir)) {
std::ifstream f(entry.path(), std::ios::binary);
if (!f)
continue;
uint32_t sz = 0;
uint8_t layer = 0;
f.read(reinterpret_cast<char *>(&sz), sizeof(sz));
f.read(reinterpret_cast<char *>(&layer), sizeof(layer));
if (oldBlendMapSize < 0 && sz >= 2 && sz <= 8192)
oldBlendMapSize = (int)sz;
if ((int)sz != oldBlendMapSize)
continue;
std::vector<float> bmData(oldBlendMapSize *
oldBlendMapSize);
f.read(reinterpret_cast<char *>(bmData.data()),
bmData.size() * sizeof(float));
savedBms.push_back({ entry.path().filename().string(),
std::move(bmData), layer });
}
try {
std::filesystem::remove_all(backupBlendDir);
std::filesystem::copy(
blendDir, backupBlendDir,
std::filesystem::copy_options::recursive);
} catch (const std::exception &e) {
Ogre::LogManager::getSingleton().logMessage(
"Terrain: failed to back up blend maps: " +
std::string(e.what()));
return false;
}
}
deactivate();
if (oldBlendMapSize > 0) {
std::filesystem::remove_all(blendDir);
std::filesystem::create_directories(blendDir);
for (const auto &bm : savedBms) {
std::vector<float> newBmData = resampleFloatMap(
bm.data, oldBlendMapSize, newResolution);
std::ofstream out(blendDir + "/" + bm.name,
std::ios::binary);
if (out) {
uint32_t newSz = (uint32_t)newResolution;
out.write(
reinterpret_cast<const char *>(&newSz),
sizeof(newSz));
out.write(reinterpret_cast<const char *>(
&bm.layer),
sizeof(bm.layer));
out.write(reinterpret_cast<const char *>(
newBmData.data()),
newBmData.size() * sizeof(float));
}
}
}
tc.blendMapSize = newResolution;
tc.dirty = true;
Ogre::LogManager::getSingleton().logMessage(
"Terrain: changed blend-map resolution from " +
Ogre::StringConverter::toString(oldBlendMapSize) + " to " +
Ogre::StringConverter::toString(newResolution));
return true;
}
/* ------------------------------------------------------------------ */
/* Blend map save/load (M3) */
/* ------------------------------------------------------------------ */
@@ -888,6 +1512,32 @@ void TerrainSystem::setAuxMapVisualizationName(const std::string &name)
destroyAuxMapVisualization();
}
/* ------------------------------------------------------------------ */
/* Road editing (M5.2) */
/* ------------------------------------------------------------------ */
void TerrainSystem::setRoadEditMode(bool v)
{
if (v == m_roadEditMode)
return;
m_roadEditMode = v;
if (v) {
/* Road edit mode is mutually exclusive with terrain brush modes. */
if (m_sculptMode)
setSculptMode(false);
if (m_paintMode)
setPaintMode(false);
if (m_auxPaintMode)
setAuxPaintMode(false);
hideBrushDecal();
}
if (m_roadSystem)
m_roadSystem->setVisualAidsVisible(v);
}
void TerrainSystem::destroyAuxMapVisualization()
{
if (m_auxVisNode) {
@@ -957,8 +1607,7 @@ void TerrainSystem::buildAuxMapVisualization()
if (!m_auxVisObj) {
m_auxVisObj = m_sceneMgr->createManualObject(
"AuxMapVis_" + m_auxMapVisualizationName);
m_auxVisObj->setRenderQueueGroup(
Ogre::RENDER_QUEUE_OVERLAY);
m_auxVisObj->setRenderQueueGroup(Ogre::RENDER_QUEUE_OVERLAY);
m_auxVisNode =
m_sceneMgr->getRootSceneNode()->createChildSceneNode();
m_auxVisNode->attachObject(m_auxVisObj);
@@ -1103,6 +1752,12 @@ float TerrainSystem::sampleHeightAtLocked(long worldX, long worldZ) const
if (m_detailNoise.enabled)
h += computeDetailNoise(worldX, worldZ, m_detailNoise);
/* Fixup chunks override the combined base height + noise (M5.9.5).
* If a fixup value exists at this position, use it instead. */
float fixup = sampleFixupLocked((float)worldX, (float)worldZ);
if (fixup != FIXUP_SENTINEL)
return fixup;
return h;
}
@@ -1214,13 +1869,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;
@@ -1232,6 +1892,8 @@ void TerrainSystem::activate(TerrainComponent &tc, TransformComponent &xform)
m_heightmapWorldSize =
(float)(m_pageMaxX - m_pageMinX + 1) * tc.worldSize;
m_fixupDir = getFixupDir(tc);
ensureHeightmapLoaded(tc);
/* Reuse TerrainGlobalOptions if it survived a previous
@@ -1244,6 +1906,8 @@ void TerrainSystem::activate(TerrainComponent &tc, TransformComponent &xform)
mTerrainGlobals->setMaxPixelError(tc.maxPixelError);
mTerrainGlobals->setCompositeMapDistance(tc.compositeMapDistance);
mTerrainGlobals->setCompositeMapAmbient(m_sceneMgr->getAmbientLight());
mTerrainGlobals->setLayerBlendMapSize(
(Ogre::uint16)std::max(2, tc.blendMapSize));
Ogre::TerrainMaterialGeneratorPtr matGen(
new Ogre::TerrainMaterialGeneratorA());
@@ -1330,6 +1994,14 @@ void TerrainSystem::activate(TerrainComponent &tc, TransformComponent &xform)
loadSceneBlendMaps(tc);
loadSceneAuxMaps(tc);
/* Create the road system after the terrain is fully set up. */
m_roadSystem = std::make_unique<RoadSystem>(m_world, m_sceneMgr);
m_roadSystem->setTerrainGroup(mTerrainGroup);
m_roadSystem->setPhysics(m_physics);
m_roadSystem->setTerrainSystem(this);
m_roadSystem->setTerrainEntity(m_world.entity(m_terrainEntityId));
m_roadSystem->setVisualAidsVisible(m_roadEditMode);
if (m_physics)
m_physics->setBodyDrawFilter(&mBodyDrawFilter);
@@ -1356,12 +2028,15 @@ void TerrainSystem::deactivate()
m_compositeDirtyPages.clear();
m_sculptMode = false;
m_paintMode = false;
m_roadEditMode = false;
m_detailNoise = TerrainComponent::DetailNoise();
m_heightmapLoaded = false;
m_heightData.clear();
m_heightmapRes = 0;
m_auxMapData.clear();
m_dirtyAuxMaps.clear();
m_fixupChunks.clear();
m_fixupDir.clear();
/* Clean up sculpt previews without trying to reload terrain
* pages (we're shutting down the whole terrain). */
@@ -1395,6 +2070,9 @@ void TerrainSystem::deactivate()
/* Destroy aux-map visualization. */
destroyAuxMapVisualization();
/* Destroy road system visual aids before terrain teardown. */
m_roadSystem.reset();
removeAllColliders();
if (m_physics)
m_physics->setBodyDrawFilter(nullptr);
@@ -1476,10 +2154,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. */
@@ -1532,7 +2208,13 @@ void TerrainSystem::update(float /*deltaTime*/)
if (m_auxVisDirty)
buildAuxMapVisualization();
if (m_roadSystem)
m_roadSystem->update(0.0f);
mBodyDrawFilter.showTerrain = m_showTerrainColliders;
mBodyDrawFilter.showRoad = m_showRoadColliders;
if (m_roadSystem)
mBodyDrawFilter.syncRoadIds(m_roadSystem->getRoadBodyIds());
}
/* ------------------------------------------------------------------ */
@@ -1676,6 +2358,11 @@ void TerrainSystem::setShowTerrainColliders(bool show)
m_showTerrainColliders = show;
}
void TerrainSystem::setShowRoadColliders(bool show)
{
m_showRoadColliders = show;
}
/* ------------------------------------------------------------------ */
/* snapCameraAboveTerrain */
/* ------------------------------------------------------------------ */
@@ -31,6 +31,7 @@
#include <FastNoiseLite.h>
class JoltPhysicsWrapper;
class RoadSystem;
class TerrainSystem {
public:
@@ -63,6 +64,12 @@ public:
return m_showTerrainColliders;
}
void setShowRoadColliders(bool show);
bool getShowRoadColliders() const
{
return m_showRoadColliders;
}
/* --- Heightmap data (M3) --- */
float sampleHeightAt(long worldX, long worldZ) const;
float sampleHeightAtLocked(long worldX, long worldZ) const;
@@ -73,6 +80,29 @@ 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);
bool changeBlendMapResolution(struct TerrainComponent &tc,
int newResolution);
/* Blend map save/load (M3) */
bool saveBlendMaps(const std::string &dirPath) const;
bool loadBlendMaps(const std::string &dirPath);
@@ -256,6 +286,41 @@ public:
}
void setAuxMapVisualizationName(const std::string &name);
/* --- Road editing (M5.2) --- */
bool getRoadEditMode() const
{
return m_roadEditMode;
}
void setRoadEditMode(bool v);
bool isRoadEditing() const
{
return m_roadEditMode;
}
/**
* Road editor tool modes (M5.2). Move is the default: clicks select
* and drag existing nodes/edges. In AddNode mode a click on empty
* terrain creates a new node.
*/
enum class RoadEditTool {
Move,
AddNode
};
RoadEditTool getRoadEditTool() const
{
return m_roadEditTool;
}
void setRoadEditTool(RoadEditTool tool)
{
m_roadEditTool = tool;
}
RoadSystem *getRoadSystem() const
{
return m_roadSystem.get();
}
/* --- Brush decal (M3) --- */
void updateBrushDecal(const Ogre::Vector3 &worldPos,
const Ogre::Vector3 &normal, float radius);
@@ -295,8 +360,10 @@ public:
friend class TerrainCommandQueue;
private:
std::unique_ptr<RoadSystem> m_roadSystem;
void activate(struct TerrainComponent &tc,
struct TransformComponent &xform);
struct TransformComponent &xform,
flecs::entity_t terrainEntityId);
void ensureHeightmapLoaded(struct TerrainComponent &tc);
void updatePageGeometry(long pageX, long pageY);
void fillPageHeightData(Ogre::TerrainGroup *group, long x, long y,
@@ -361,11 +428,18 @@ private:
void clear()
{
m_terrainIds.clear();
m_roadIds.clear();
}
void syncRoadIds(const std::set<JPH::BodyID> &ids)
{
m_roadIds = ids;
}
bool showTerrain = false;
bool showRoad = false;
private:
std::set<JPH::BodyID> m_terrainIds;
std::set<JPH::BodyID> m_roadIds;
};
JPH::ShapeRefC buildPageCollider(Ogre::Terrain *terrain);
@@ -382,6 +456,7 @@ private:
JoltPhysicsWrapper *m_physics = nullptr;
bool m_active = false;
bool m_showTerrainColliders = false;
bool m_showRoadColliders = false;
static TerrainSystem *s_instance;
@@ -437,6 +512,26 @@ private:
m_auxMapData;
std::unordered_set<std::string> m_dirtyAuxMaps;
/* Fixup chunks (M5.9.5): sparse absolute-height overrides stored as
* 256x256 float chunks under heightmaps/<terrainId>/terrain_fixup/.
* Guarded by m_heightmapMutex; loaded lazily from disk on first
* access, saved alongside the heightmap. */
struct FixupChunk {
std::vector<float> samples;
bool dirty = false;
};
static constexpr int FIXUP_CHUNK_RES = 256;
static constexpr float FIXUP_SENTINEL = -FLT_MAX;
mutable std::map<std::pair<int, int>, FixupChunk> m_fixupChunks;
std::string m_fixupDir;
float sampleFixupLocked(float worldX, float worldZ) const;
const FixupChunk *findFixupChunkLocked(int chunkX, int chunkZ) const;
bool loadFixupChunk(int chunkX, int chunkZ,
std::vector<float> &out) const;
std::string fixupChunkPath(int chunkX, int chunkZ) const;
const std::vector<float> *
ensureAuxMapLoaded(const struct TerrainComponent::AuxMap &aux,
const std::string &path) const;
@@ -469,6 +564,10 @@ private:
void destroyAuxMapVisualization();
static Ogre::ColourValue auxValueToColour(float v);
/* Road editing state (M5.2) */
bool m_roadEditMode = false;
RoadEditTool m_roadEditTool = RoadEditTool::Move;
/* --- Sculpt preview (M3) --- */
struct SculptPreview {
Ogre::ManualObject *manualObj = nullptr;
File diff suppressed because it is too large Load Diff
@@ -67,8 +67,21 @@ private:
static bool testBrushShapes(EditorApp &app, TerrainSystem *ts);
static bool testAuxMapOperations(EditorApp &app, TerrainSystem *ts);
static bool testCompositeMapBatching(EditorApp &app, TerrainSystem *ts);
static bool testChangeResolution(EditorApp &app, TerrainSystem *ts);
static bool testVerifyGeometry(EditorApp &app, TerrainSystem *ts);
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 testFixupChunks(EditorApp &app, TerrainSystem *ts);
static bool testRoadEdgeLengthConstraint(EditorApp &app, TerrainSystem *ts);
static bool testTerrainCompliance(EditorApp &app, TerrainSystem *ts);
static bool testRoadColliderInteraction(EditorApp &app, TerrainSystem *ts);
static bool testRoadSidePrefabs(EditorApp &app, TerrainSystem *ts);
static bool testMemoryStability(EditorApp &app);
static void logResult(const TerrainTestResult &r);
@@ -0,0 +1,13 @@
{
"name": "TinyCube",
"transform": {
"position": { "x": 0, "y": 0, "z": 0 },
"rotation": { "w": 1, "x": 0, "y": 0, "z": 0 },
"scale": { "x": 1, "y": 1, "z": 1 }
},
"primitive": {
"type": "cube",
"size": { "x": 1, "y": 1, "z": 1 },
"material": "RoadMaterial"
}
}
+643 -3
View File
@@ -5,6 +5,7 @@
#include "ComponentEditor.hpp"
#include "../components/Terrain.hpp"
#include "../systems/TerrainSystem.hpp"
#include "../systems/RoadSystem.hpp"
#include <OgreResourceGroupManager.h>
#include <OgreTextureManager.h>
@@ -14,7 +15,9 @@
#include <algorithm>
#include <cstring>
#include <filesystem>
#include <functional>
#include <string>
#include <unordered_map>
#include <vector>
/**
@@ -49,6 +52,10 @@ public:
bool show = ts->getShowTerrainColliders();
if (ImGui::Checkbox("Show Terrain Colliders", &show))
ts->setShowTerrainColliders(show);
bool showRoad = ts->getShowRoadColliders();
if (ImGui::Checkbox("Show Road Colliders", &showRoad))
ts->setShowRoadColliders(showRoad);
}
ImGui::Separator();
@@ -253,6 +260,36 @@ public:
ImGui::Separator();
/* --- Road Network (M5.2) --- */
if (ts) {
ImGui::PushID("Road");
ImGui::Text("Road Network");
bool roadEdit = ts->getRoadEditMode();
if (ImGui::Checkbox("Road Edit Mode", &roadEdit))
ts->setRoadEditMode(roadEdit);
if (roadEdit) {
ImGui::TextColored(ImVec4(1, 0.7f, 0.2f, 1),
"ROAD EDIT MODE ACTIVE");
ImGui::TextWrapped(
"Quick start: pick the \"Add Node\" tool "
"below and click the terrain in the 3D view "
"to place nodes. Expand \"How to edit "
"roads\" in the toolbar for the full "
"workflow.");
} else {
ImGui::TextDisabled(
"Enable to place and connect road nodes "
"directly on the terrain in the 3D view.");
}
renderRoadSection(tc, ts);
ImGui::PopID();
}
ImGui::Separator();
/* --- Camera & file operations --- */
if (ts) {
if (ImGui::Button("Snap Camera Above Terrain"))
@@ -280,14 +317,33 @@ public:
ImGui::Separator();
/* Read-only display of sizing params */
/* Resolution selectors (M4.6). */
if (ts) {
if (renderResolutionCombo(
"Heightmap Resolution",
"Confirm Heightmap Resolution Change",
tc.heightmapSize, [&](int newRes) {
ts->changeHeightmapResolution(
tc, newRes);
}))
changed = true;
if (renderResolutionCombo(
"Blend Map Resolution",
"Confirm Blend Map Resolution Change",
tc.blendMapSize, [&](int newRes) {
ts->changeBlendMapResolution(
tc, newRes);
}))
changed = true;
}
/* Read-only display of remaining sizing params */
ImGui::Text("Terrain ID: %llu",
(unsigned long long)tc.terrainId);
ImGui::Text("Page size: %d x %d vertices", tc.terrainSize,
tc.terrainSize);
ImGui::Text("World size: %.0f units", tc.worldSize);
ImGui::Text("Heightmap: %d x %d", tc.heightmapSize,
tc.heightmapSize);
ImGui::Text("Batch: %d - %d", tc.minBatchSize,
tc.maxBatchSize);
@@ -438,6 +494,528 @@ public:
}
private:
static void renderRoadSection(TerrainComponent &tc, TerrainSystem *ts)
{
RoadSystem *rs = ts ? ts->getRoadSystem() : nullptr;
if (!rs) {
ImGui::TextDisabled(
"Road editing is unavailable because the terrain "
"is not active. Enable the terrain above first.");
return;
}
RoadGraph &rg = tc.roadGraph;
/* Road mesh template — combo with (Procedural Box) +
* .mesh / .glb files from the Ogre resource system. */
ImGui::SeparatorText("Road Mesh");
bool cfgChanged = false;
{
scanMeshFiles();
std::vector<std::string> &meshes = getMeshList();
std::vector<const char *> items;
items.reserve(meshes.size() + 1);
items.push_back("(Procedural Box)");
int currentIdx = 0;
for (size_t mi = 0; mi < meshes.size(); ++mi) {
if (meshes[mi] == rg.config.roadMeshTemplate)
currentIdx = (int)mi + 1;
items.push_back(meshes[mi].c_str());
}
if (ImGui::Combo("Template", &currentIdx,
items.data(), (int)items.size())) {
rg.config.roadMeshTemplate =
(currentIdx <= 0) ? "" :
meshes[currentIdx - 1];
cfgChanged = true;
}
}
{
if (rg.config.roadMeshTemplate.empty())
ImGui::TextColored(
ImVec4(0.5f, 1.0f, 0.5f, 1.0f),
"Active: procedural box (%.2f x %.2f x 1.0)",
(double)rg.config.roadThickness,
(double)rg.config.roadThickness);
else
ImGui::TextDisabled("Active: %s",
rg.config.roadMeshTemplate.c_str());
}
/* Other road config settings. */
if (ImGui::TreeNode("Road Config")) {
{
char matBuf[256] = {};
std::strncpy(matBuf,
rg.config.roadMaterialName.c_str(),
sizeof(matBuf) - 1);
if (ImGui::InputText("Material", matBuf,
sizeof(matBuf))) {
rg.config.roadMaterialName = matBuf;
cfgChanged = true;
}
}
cfgChanged |= ImGui::SliderFloat(
"Lane Width", &rg.config.laneWidth, 1.0f, 10.0f);
cfgChanged |= ImGui::SliderInt(
"Lanes Per Direction",
&rg.config.lanesPerDirection, 1, 8);
cfgChanged |= ImGui::SliderFloat(
"Road Thickness", &rg.config.roadThickness, 0.05f,
1.0f, "%.2f");
cfgChanged |= ImGui::SliderFloat(
"LOD Distance", &rg.config.roadLodDistance, 50.0f,
2000.0f);
cfgChanged |= ImGui::SliderFloat(
"Visibility Distance",
&rg.config.roadVisibilityDistance, 100.0f, 5000.0f);
if (cfgChanged)
rg.bumpVersion();
ImGui::TreePop();
}
/* Toolbar — visible only in road edit mode. */
if (ts->getRoadEditMode()) {
/* Tool selector: Move Node (default) / Add Node. */
bool addNodeTool = (ts->getRoadEditTool() ==
TerrainSystem::RoadEditTool::AddNode);
if (ImGui::RadioButton("Move Node", !addNodeTool))
ts->setRoadEditTool(
TerrainSystem::RoadEditTool::Move);
ImGui::SameLine();
if (ImGui::RadioButton("Add Node", addNodeTool))
ts->setRoadEditTool(
TerrainSystem::RoadEditTool::AddNode);
ImGui::SameLine();
if (ImGui::Button("Remove Selected Node") &&
rs->getSelectedNodeId() >= 0) {
rg.removeNode(rs->getSelectedNodeId());
rs->setSelectedNodeId(-1);
}
ImGui::SameLine();
if (ImGui::Button("Split Selected Edge") &&
rs->getSelectedEdgeIndex() >= 0) {
rs->setSelectedNodeId(rg.splitEdge(
(size_t)rs->getSelectedEdgeIndex(), 0.5f));
rs->setSelectedEdgeIndex(-1);
}
ImGui::SameLine();
static std::string s_roadValidationError;
if (ImGui::Button("Validate Road Graph")) {
std::string error;
if (rg.validate(&error)) {
s_roadValidationError.clear();
ImGui::OpenPopup("Road Graph Valid");
} else {
s_roadValidationError = error;
ImGui::OpenPopup("Road Graph Invalid");
}
}
if (ImGui::BeginPopupModal("Road Graph Valid", nullptr,
ImGuiWindowFlags_AlwaysAutoResize)) {
ImGui::Text("Road graph is valid.");
if (ImGui::Button("OK", ImVec2(120, 0)))
ImGui::CloseCurrentPopup();
ImGui::EndPopup();
}
if (ImGui::BeginPopupModal("Road Graph Invalid", nullptr,
ImGuiWindowFlags_AlwaysAutoResize)) {
ImGui::Text("Validation error:");
ImGui::TextWrapped("%s",
s_roadValidationError.c_str());
if (ImGui::Button("OK", ImVec2(120, 0)))
ImGui::CloseCurrentPopup();
ImGui::EndPopup();
}
ImGui::SameLine();
/* Terrain compliance: writes fixup chunks under road surfaces
* so the terrain conforms to the road underside (M5.10). */
if (ImGui::Button("Comply Terrain to Roads")) {
RoadSystem *rs = ts->getRoadSystem();
if (rs)
rs->complyTerrain(
ts,
tc.roadGraph.config.roadThickness,
tc.roadGraph.config.laneWidth);
}
if (ImGui::IsItemHovered())
ImGui::SetTooltip(
"Makes the terrain surface follow the road underside "
"through fixup height-override chunks.");
/* Clear all fixup chunks (M5.9.5). */
if (ImGui::Button("Clear All Fixups")) {
ts->clearAllFixups();
}
if (ImGui::IsItemHovered())
ImGui::SetTooltip(
"Deletes all terrain fixup chunk files and "
"restores the base heightmap surface.");
/* Usage instructions — road editing is not discoverable
* without them. */
if (rg.nodes.empty() &&
ts->getRoadEditTool() ==
TerrainSystem::RoadEditTool::Move) {
ImGui::TextColored(
ImVec4(1, 0.7f, 0.2f, 1),
"No road nodes yet - select \"Add Node\" above\n"
"and click on the terrain to place the first one.");
}
if (ImGui::TreeNode("How to edit roads")) {
ImGui::TextWrapped(
"1. Select the \"Add Node\" tool, then click on the "
"terrain in the 3D view to place nodes. Nodes snap "
"to the terrain surface.\n"
"2. Switch to \"Move Node\" (the default) and click "
"a node in the 3D view or in the Nodes list to "
"select it; drag the gizmo's X/Z axes to move it. "
"Its height follows the terrain plus the Vertical "
"Offset from the node inspector.\n"
"3. To connect two nodes: select one node, then "
"press \"Connect\" next to the other node in the "
"Nodes list below.\n"
"4. Click an edge in the 3D view or in the Edges "
"list to select it. \"Split Selected Edge\" inserts "
"a node at the edge midpoint; lane counts and road "
"levels are edited in the Selected Edge inspector.\n"
"5. \"Remove Selected Node\" deletes the node and "
"all edges connected to it. Clicking empty terrain "
"in Move Node mode clears the selection.\n"
"\n"
"A click is a press+release without moving the "
"mouse; moving the mouse while holding the button "
"drags the camera instead.");
ImGui::TreePop();
}
}
/* 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);
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 (showConnect) {
ImGui::SameLine();
if (ImGui::SmallButton("Connect"))
rg.joinNodes(selectedNodeId, n.id);
}
ImGui::PopID();
}
/* 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];
bool selected = ((int)i == rs->getSelectedEdgeIndex());
std::string label = "Edge " + std::to_string(i) + ": " +
std::to_string(e.nodeA) + " <-> " +
std::to_string(e.nodeB);
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();
if (ImGui::SmallButton("Remove")) {
rg.removeEdge(i);
rs->setSelectedEdgeIndex(-1);
}
}
ImGui::PopID();
}
/* Selected node inspector. */
if (rs->getSelectedNodeId() >= 0) {
RoadNode *node = rg.findNodeById(rs->getSelectedNodeId());
if (node) {
ImGui::Separator();
ImGui::Text("Selected Node %d", node->id);
bool nodeChanged = false;
float xz[2] = { node->position.x, node->position.z };
if (ImGui::InputFloat2("Position XZ", xz)) {
node->position.x = xz[0];
node->position.z = xz[1];
if (ts) {
float y = ts->getHeightAt(Ogre::Vector3(
node->position.x, 0.0f,
node->position.z));
node->position.y = y + node->verticalOffset;
}
nodeChanged = true;
}
if (ImGui::SliderFloat("Vertical Offset",
&node->verticalOffset, -5.0f,
5.0f)) {
if (ts) {
float y = ts->getHeightAt(Ogre::Vector3(
node->position.x, 0.0f,
node->position.z));
node->position.y = y + node->verticalOffset;
}
nodeChanged = true;
}
/* Road level at this node, computed from the
* connected edges (roadLevelA/B of each edge end
* touching this node). */
{
float levelSum = 0.0f;
int levelCount = 0;
for (const auto &e : rg.edges) {
if (e.nodeA == node->id) {
levelSum += e.roadLevelA;
++levelCount;
}
if (e.nodeB == node->id) {
levelSum += e.roadLevelB;
++levelCount;
}
}
if (levelCount > 0)
ImGui::Text(
"Road Level: %.2f (avg of %d edge%s)",
levelSum / levelCount, levelCount,
levelCount == 1 ? "" : "s");
else
ImGui::TextDisabled(
"Road Level: n/a (no edges)");
}
if (ImGui::Button("Snap to Terrain") && ts) {
float y = ts->getHeightAt(Ogre::Vector3(
node->position.x, 0.0f,
node->position.z));
node->position.y = y + node->verticalOffset;
nodeChanged = true;
}
if (nodeChanged)
rg.bumpVersion();
}
}
/* --- Wedge debug --- */
ImGui::SeparatorText("Wedge Debug");
{
bool wedgeDbg = rs->getDebugWedgeEnabled();
if (ImGui::Checkbox("Show Wedge Geometry", &wedgeDbg))
rs->setDebugWedgeEnabled(wedgeDbg);
if (wedgeDbg && rs->getSelectedNodeId() >= 0) {
std::vector<RoadWedge> wedges;
std::vector<RoadStraightSegment> segments;
enumerateWedges(rg, wedges, segments);
std::vector<RoadWedge> nodeWedges;
std::vector<RoadStraightSegment> nodeSegs;
for (const auto &w : wedges) {
if (w.nodeId == rs->getSelectedNodeId())
nodeWedges.push_back(w);
}
for (const auto &s : segments) {
if (s.nodeId == rs->getSelectedNodeId())
nodeSegs.push_back(s);
}
int total = (int)nodeWedges.size() +
(int)nodeSegs.size();
if (total == 0) {
ImGui::TextDisabled(
"No wedges for selected node");
} else {
std::vector<const char *> items;
items.push_back("(All)");
int comboIdx = 0;
int selIdx = rs->getDebugWedgeIndex();
for (size_t wi = 0; wi < nodeWedges.size(); ++wi) {
if (selIdx >= 0 &&
(size_t)selIdx < nodeWedges.size() &&
wi == (size_t)selIdx)
comboIdx = (int)wi + 1;
const auto &w = nodeWedges[wi];
static char wbuf[80];
snprintf(wbuf, sizeof(wbuf),
"Wedge %zu: %.0f deg",
wi, (double)w.sweptAngleDeg);
items.push_back(wbuf);
}
for (size_t si = 0; si < nodeSegs.size(); ++si) {
int sii = (int)nodeWedges.size() + (int)si;
if (selIdx == sii)
comboIdx = sii + 1;
const auto &s = nodeSegs[si];
static char sbuf[64];
snprintf(sbuf, sizeof(sbuf),
"Segment %zu", si);
items.push_back(sbuf);
}
if (ImGui::Combo("Primitive", &comboIdx,
items.data(),
(int)items.size()))
rs->setDebugWedgeIndex(
comboIdx - 1);
}
} else if (wedgeDbg) {
ImGui::TextDisabled(
"Select a road node first");
}
}
/* Selected edge inspector. */
if (rs->getSelectedEdgeIndex() >= 0) {
int idx = rs->getSelectedEdgeIndex();
if (idx >= 0 && (size_t)idx < rg.edges.size()) {
RoadEdge &e = rg.edges[(size_t)idx];
ImGui::Separator();
ImGui::Text("Selected Edge %d", idx);
bool edgeChanged = false;
edgeChanged |= ImGui::SliderInt(
"Lanes A->B", &e.lanesAtoB, 0, 8);
edgeChanged |= ImGui::SliderInt(
"Lanes B->A", &e.lanesBtoA, 0, 8);
edgeChanged |= ImGui::SliderFloat(
"Road Level A", &e.roadLevelA, -5.0f, 5.0f);
edgeChanged |= ImGui::SliderFloat(
"Road Level B", &e.roadLevelB, -5.0f, 5.0f);
/* Side prefabs placed along this edge. */
ImGui::Text("Side Prefabs: %zu",
e.sidePrefabs.size());
for (size_t i = 0; i < e.sidePrefabs.size(); ++i) {
auto &sp = e.sidePrefabs[i];
ImGui::PushID((int)i);
char pbuf[256];
strncpy(pbuf, sp.prefabPath.c_str(),
sizeof(pbuf) - 1);
pbuf[sizeof(pbuf) - 1] = 0;
if (ImGui::InputText("Prefab", pbuf,
sizeof(pbuf))) {
sp.prefabPath = pbuf;
edgeChanged = true;
}
edgeChanged |= ImGui::SliderFloat(
"Position T", &sp.edgeT, 0.0f, 1.0f);
edgeChanged |= ImGui::SliderFloat(
"Side Offset", &sp.sideOffset, 0.0f,
50.0f);
edgeChanged |= ImGui::Checkbox("Left Side",
&sp.leftSide);
if (ImGui::SmallButton("Remove")) {
e.sidePrefabs.erase(
e.sidePrefabs.begin() + i);
edgeChanged = true;
ImGui::PopID();
break;
}
ImGui::PopID();
}
static char s_prefabBuf[256] = {};
ImGui::InputText("New Prefab Path", s_prefabBuf,
sizeof(s_prefabBuf));
ImGui::SameLine();
if (ImGui::SmallButton("Add Prefab") &&
s_prefabBuf[0] != '\0') {
RoadEdge::RoadSidePrefab sp;
sp.prefabPath = s_prefabBuf;
e.sidePrefabs.push_back(sp);
s_prefabBuf[0] = '\0';
edgeChanged = true;
}
if (edgeChanged)
rg.bumpVersion();
}
}
}
static bool
renderResolutionCombo(const char *label, const char *popupId,
int &current,
const std::function<void(int)> &onConfirm)
{
static std::unordered_map<std::string, std::pair<int, bool> >
s_confirmState;
auto &st = s_confirmState[popupId];
int &pendingRes = st.first;
bool &openConfirm = st.second;
const int resolutions[] = { 256, 512, 1024, 2048, 4096 };
int displayRes = openConfirm ? pendingRes : current;
int idx = 0;
while (idx < 5 && resolutions[idx] != displayRes)
++idx;
if (idx >= 5)
idx = 0;
const char *labels[] = { "256", "512", "1024", "2048", "4096" };
bool confirmed = false;
if (ImGui::Combo(label, &idx, labels, IM_ARRAYSIZE(labels))) {
int newRes = resolutions[idx];
if (newRes != current) {
pendingRes = newRes;
openConfirm = true;
}
}
if (openConfirm)
ImGui::OpenPopup(popupId);
if (ImGui::BeginPopupModal(popupId, nullptr,
ImGuiWindowFlags_AlwaysAutoResize)) {
ImGui::Text("This will resample stored terrain data.");
ImGui::Text("The old binary files will be backed up.");
ImGui::Text("Pending resolution: %d", pendingRes);
if (ImGui::Button("OK", ImVec2(120, 0))) {
if (pendingRes != current) {
Ogre::LogManager::getSingleton().logMessage(
std::string(
"TerrainEditor: confirming ") +
label + " change to " +
Ogre::StringConverter::toString(
pendingRes));
onConfirm(pendingRes);
confirmed = true;
}
openConfirm = false;
ImGui::CloseCurrentPopup();
}
ImGui::SameLine();
if (ImGui::Button("Cancel", ImVec2(120, 0))) {
openConfirm = false;
ImGui::CloseCurrentPopup();
}
ImGui::EndPopup();
}
return confirmed;
}
static std::vector<std::string> &getTextureList()
{
static std::vector<std::string> s_textures;
@@ -739,6 +1317,68 @@ private:
ts->setPaintLayerIndex(idx);
}
}
/* ── Road mesh template picker ────────────────────────── */
static std::vector<std::string> &getMeshList()
{
static std::vector<std::string> s_meshes;
return s_meshes;
}
static bool &getMeshScanned()
{
static bool s_scanned = false;
return s_scanned;
}
static bool isMeshExtension(const std::string &ext)
{
static const char *meshExts[] = {"mesh", "glb", "gltf"};
std::string lower = ext;
std::transform(lower.begin(), lower.end(), lower.begin(),
::tolower);
for (const char *e : meshExts) {
if (lower == e)
return true;
}
return false;
}
static void scanMeshFiles()
{
if (getMeshScanned())
return;
std::vector<std::string> &meshes = getMeshList();
meshes.clear();
Ogre::ResourceGroupManager &rgm =
Ogre::ResourceGroupManager::getSingleton();
Ogre::StringVector groups = rgm.getResourceGroups();
for (const auto &group : groups) {
Ogre::FileInfoListPtr fileList =
rgm.findResourceFileInfo(group, "*");
if (!fileList)
continue;
for (const auto &fi : *fileList) {
size_t dot = fi.filename.find_last_of('.');
if (dot == std::string::npos)
continue;
std::string ext = fi.filename.substr(dot + 1);
if (isMeshExtension(ext))
meshes.push_back(fi.filename);
}
}
std::sort(meshes.begin(), meshes.end());
meshes.erase(std::unique(meshes.begin(), meshes.end()),
meshes.end());
getMeshScanned() = true;
}
};
#endif // EDITSCENE_TERRAINEDITOR_HPP