Updating mid-implementation of M5.2

This commit is contained in:
2026-07-19 22:44:52 +03:00
parent 32471f0db6
commit 50c2395581
17 changed files with 3515 additions and 229 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
+324 -159
View File
@@ -118,20 +118,9 @@ struct TerrainComponent {
};
std::vector<Layer> layers;
// Road network data (node/edge graph).
struct RoadNode {
Ogre::Vector3 position; // default: terrain surface + offset
float verticalOffset = 0.0f;
int id = 0;
};
struct RoadEdge {
int nodeA = -1;
int nodeB = -1;
float roadLevelA = 0.0f;
float roadLevelB = 0.0f;
};
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;
// Prefab spawn points on the terrain managed by TerrainPrefabSpawnerComponent
// (not embedded here — see section 6).
@@ -734,7 +723,7 @@ infrastructure is in scope.
### 5.1 Road node graph
- Stored in `TerrainComponent::roadNodes` / `roadEdges`.
- Stored in `TerrainComponent::roadGraph.nodes` / `roadGraph.edges`.
- Editable in the terrain editor:
- Add node (default Y = terrain height + offset).
- Remove node (removes connected edges).
@@ -782,7 +771,7 @@ struct RoadEdge {
## #5.2 Road configuration parameters
These live in `TerrainComponent` alongside the node graph :
These live in `TerrainComponent::roadGraph.config`:
```cpp
// Road surface mesh template name (file in General resource group).
@@ -1094,6 +1083,7 @@ JSON schema:
"terrainId" : 17345678901234567890,
"terrainSize" : 65,
"heightmapSize" : 256,
"blendMapSize" : 256,
"worldSize" : 2000.0,
"maxPixelError" : 1.0,
"compositeMapDistance" : 300.0,
@@ -1396,7 +1386,10 @@ cd build-vscode/src/features/editScene
- `EditorApp.cpp` — LuaScripts filesystem fallback, ESC exits sculpt/paint mode
- `lua-scripts/data2.lua` — minimal placeholder for app init (new file)
### Milestone 4 — Miscellaneous terrain improvements
### Milestone 4 — Miscellaneous terrain improvements ✅ COMPLETE (2026-07-12)
**Overall status**: all items (M4.1M4.6) implemented, verified by the headless
terrain test suite, and signed off by user.
This milestone polishes the terrain editing pipeline and makes the test suite
runnable in CI. Each item below states the concrete user-visible behaviour, the
@@ -1910,105 +1903,199 @@ needed.
---
#### M4.6 Changeable heightmap resolution
#### M4.6 Changeable heightmap resolution ✅ DONE — verified
**Status**: completed and verified by user.
**Goal**: allow the user to change `TerrainComponent::heightmapSize` between
256, 512, 1024, 2048, and 4096 through the editor. Changing the resolution is
a destructive operation: it resamples the existing heightmap and blend maps to
the new resolution.
a destructive operation: it resamples the existing heightmap and aux maps to
the new resolution and backs up the old heightmap and blend-map files.
Blend-map resolution is controlled by a separate `TerrainComponent::blendMapSize`
field (default 256). It is independent of `heightmapSize` and is also
changeable through the editor using the same backup/resample workflow.
**Component changes** (`TerrainComponent`):
```cpp
struct TerrainComponent {
...
// Base binary heightmap resolution. Default 256x256.
int heightmapSize = 256;
// Blend-map texture resolution. Default 256x256; independent of heightmapSize.
int blendMapSize = 256;
...
};
```
**UI changes** (`TerrainEditor`):
Replace the read-only "Heightmap: N x N" label with:
Replace the read-only "Heightmap: N x N" label with two combos. Each combo
shares a confirmation modal and persists pending state across frames so the
selected resolution does not flicker back to the old value while the modal is
open:
```cpp
const int resolutions[] = {256, 512, 1024, 2048, 4096};
int current = tc.heightmapSize;
int idx = 0;
while (idx < 4 && resolutions[idx] != current) ++idx;
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 char *labels[] = {"256", "512", "1024", "2048", "4096"};
if (ImGui::Combo("Heightmap Resolution", &idx, labels, IM_ARRAYSIZE(labels))) {
int newRes = resolutions[idx];
if (newRes != tc.heightmapSize) {
ImGui::OpenPopup("Confirm Resolution Change");
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) {
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;
}
if (ImGui::BeginPopupModal("Confirm Resolution Change", nullptr,
ImGuiWindowFlags_AlwaysAutoResize)) {
ImGui::Text("This will resample the heightmap and blend maps.");
ImGui::Text("The old binary files will be backed up.");
if (ImGui::Button("OK", ImVec2(120, 0))) {
ts->changeHeightmapResolution(tc, newRes);
ImGui::CloseCurrentPopup();
}
ImGui::SameLine();
if (ImGui::Button("Cancel", ImVec2(120, 0)))
ImGui::CloseCurrentPopup();
ImGui::EndPopup();
}
if (renderResolutionCombo(
"Heightmap Resolution",
"Confirm Heightmap Resolution Change",
tc.heightmapSize, [&](int newRes) {
TerrainSystem *ts = TerrainSystem::getInstance();
if (ts)
ts->changeHeightmapResolution(tc, newRes);
}))
changed = true;
if (renderResolutionCombo(
"Blend Map Resolution",
"Confirm Blend Map Resolution Change",
tc.blendMapSize, [&](int newRes) {
TerrainSystem *ts = TerrainSystem::getInstance();
if (ts)
ts->changeBlendMapResolution(tc, newRes);
}))
changed = true;
```
`changeHeightmapResolution` is responsible for updating `tc.heightmapSize`;
the editor must not set it separately.
`changeHeightmapResolution` and `changeBlendMapResolution` are responsible for
updating their respective component fields; the editor must not set them
separately.
**Implementation** (`TerrainSystem`):
```cpp
bool changeHeightmapResolution(TerrainComponent &tc, int newResolution);
bool changeBlendMapResolution(TerrainComponent &tc, int newResolution);
```
Steps:
1. Validate `newResolution` is one of `{
256, 512, 1024, 2048, 4096}`.
2. If `newResolution == tc.heightmapSize`, return true.
3. Ensure sculpt and paint modes are inactive; if either is active, log an
error and return false.
4. Deactivate terrain (`deactivate()`).
5. Back up existing files:
- `heightmaps/<terrainId>/heightmap.bin` →
`heightmaps/<terrainId>/heightmap_<oldRes>.bin`
- `heightmaps/<terrainId>/heightmap.bin_blendmaps/` →
`heightmaps/<terrainId>/heightmap.bin_blendmaps_backup_<oldRes>/`
6. Resample the heightmap:
- Load old `m_heightData` at old resolution (`oldRes`).
- Allocate new `m_heightData` at `newResolution * newResolution`.
- For each new texel `(nx, nz)`, compute normalized UV over the new map:
```cpp
float u = (float)nx / (float)(newResolution - 1);
float v = (float)nz / (float)(newResolution - 1);
```
Then sample the old heightmap at `(u * (oldRes - 1), v * (oldRes - 1))`
using bilinear interpolation.
- Save as `heightmaps/<terrainId>/heightmap.bin`.
7. Resample blend maps (if the backup directory from step 5 exists):
- For each saved blend-map binary file, load it at the old texel density.
- Resample to the new texel density using bilinear filtering.
- Store the resampled data back to
`heightmaps/<terrainId>/heightmap.bin_blendmaps/`.
8. Update `tc.heightmapSize = newResolution` inside this function.
9. Reactivate terrain (`tc.dirty = true`).
Common prerequisites for both functions:
1. Validate `newResolution` is one of `{256, 512, 1024, 2048, 4096}`.
2. If `newResolution == current`, return true.
3. Ensure sculpt, paint, and aux-paint modes are inactive. If any is active,
log an error and return false.
4. Snapshot the active heightmap data into a local buffer before deactivating,
because `deactivate()` clears `m_heightData`.
`changeHeightmapResolution` steps:
1. Deactivate terrain (`deactivate()`).
2. Back up existing files using `tc.heightmapFile` as the base name:
- `heightmaps/<terrainId>/<heightmapFile>` →
`heightmaps/<terrainId>/<stem>_<oldRes>.bin`
- `heightmaps/<terrainId>/<heightmapFile>_blendmaps/` →
`heightmaps/<terrainId>/<heightmapFile>_blendmaps_backup_<oldRes>/`
3. Resample the heightmap from the snapshot to `newResolution * newResolution`
using bilinear interpolation over normalized UV `[0,1]x[0,1]`.
4. Save the new `heightmap.bin`. The save is checked: if the file cannot be
written or its size does not match `sizeof(uint32_t) + newResolution *
newResolution * sizeof(float)`, the function logs an error and returns
`false` without updating `tc.heightmapSize`.
5. Resample every `TerrainComponent::AuxMap` from its current resolution to
`newResolution`, update `aux.resolution`, and write the new binary file.
6. If `tc.blendMapSize` differs from the blend-map data currently on disk,
resample each backed-up blend-map file to `tc.blendMapSize`; otherwise copy
the backup back to the active blend-map directory.
7. Update `tc.heightmapSize = newResolution`.
8. Set `tc.dirty = true` so the next `TerrainSystem::update()` reactivates the
terrain with the new resolution.
Both resolution-change functions log the requested old/new resolution and the
final component value to simplify debugging.
`changeBlendMapResolution` steps:
1. Deactivate terrain.
2. Back up the blend-map directory to
`heightmaps/<terrainId>/<heightmapFile>_blendmaps_backup_<oldBlendMapSize>/`.
3. Resample every backed-up blend-map file from the old blend-map size to
`newResolution`.
4. Update `tc.blendMapSize = newResolution`.
5. Set `tc.dirty = true`.
`TerrainSystem::activate()` now calls
`mTerrainGlobals->setLayerBlendMapSize(tc.blendMapSize)` before creating pages
so that new and reactivated terrains use the configured blend-map resolution.
**Important constraints**:
- Do **not** change resolution while sculpt or paint mode is active. The
editor must call `setSculptMode(false)` and `setPaintMode(false)` first and
wait for `endSculptPreviews()` to finish.
- Do **not** change resolution while sculpt, paint, or aux-paint mode is active.
The editor calls `setSculptMode(false)`, `setPaintMode(false)`, and
`setAuxPaintMode(false)` first and waits for preview teardown to finish.
- Changing `terrainSize` or `worldSize` is **not** part of this item; those
still require a full terrain restart via the existing "Restart Terrain"
workflow.
- The backup files are kept forever; a future "Restore Backup" button is
out of scope for M4.
- Backup files are kept forever; a future "Restore Backup" button is out of
scope for M4.
**Definition of done**:
- [ ] Changing resolution from 256 to 512 and saving the scene produces a
`heightmap.bin` of `512 * 512 * sizeof(float)` bytes.
- [ ] Sculpted features are visually preserved after upsampling (some smoothing
- [x] Changing heightmap resolution from 256 to 512 produces a
`heightmap.bin` of `512 * 512 * sizeof(float)` bytes (plus the uint32
header).
- [x] Sculpted features are visually preserved after upsampling (some smoothing
is expected from bilinear filtering).
- [ ] Painted splat layers are preserved after upsampling.
- [ ] The old `heightmap_256.bin` backup exists next to the new file.
- [ ] A scene saved at 512 loads correctly and renders the same terrain as
before the save.
- [x] Painted splat layers are preserved after resolution changes.
- [x] The old `<heightmapFile>_<oldRes>.bin` backup exists next to the new file.
- [x] Aux maps are resampled to the new `heightmapSize` and their
`AuxMap::resolution` is updated.
- [x] `blendMapSize` is serialized and honored by `TerrainSystem::activate()`.
- [x] A scene saved after a resolution change loads correctly and renders the
same terrain as before the save.
- [x] The headless terrain test suite includes a resolution-change step that
verifies both 128→256 and 256→512, including correct file sizes,
backups, and aux-map resampling.
---
@@ -2020,8 +2107,9 @@ Steps:
- [x] Brush shape presets apply to sculpt, splat, and aux-map brushes.
- [x] Aux maps can be added, removed, painted, and saved/loaded.
- [x] Composite-map updates are batched to one per dirty page per frame.
- [ ] Heightmap resolution can be changed between supported sizes with a
warning, backup, and resampling.
- [x] Heightmap resolution can be changed between supported sizes with a
warning, backup, and resampling. `blendMapSize` is exposed as an
independent serializable resolution.
### Milestone 5 — Procedural roads
@@ -2033,84 +2121,155 @@ asymmetric: an edge can have a different number of lanes in each direction.
#### M5.1 Road data model
**Status**: complete. The annotated road types and a small utility layer live
in `components/RoadGraph.hpp`; `TerrainComponent` owns a single `RoadGraph`
instance. Automated tests in `TerrainTests.cpp` exercise the helper layer
(`addNode`, `addEdge`, `removeNode`, `removeEdge`, `splitEdge`, `joinNodes`,
`resolveLaneCounts`, and `validate`).
The data lives in `TerrainComponent` (`components/Terrain.hpp`):
```cpp
struct RoadConfig {
// Road surface mesh template. See M5.3 for conventions.
std::string roadMeshTemplate = "road_segment.mesh";
// Width of one lane in world units. This is global and immutable per road.
float laneWidth = 3.0f;
// Default lane count for each direction when an edge does not override it.
int lanesPerDirection = 1;
// Vertical thickness of the road surface.
float roadThickness = 0.3f;
float roadLodDistance = 200.0f;
float roadVisibilityDistance = 1000.0f;
std::string roadMaterialName = "RoadMaterial";
};
RoadConfig roadConfig;
struct RoadNode {
// World position. Y is always terrain_height_at(x,z) + verticalOffset.
Ogre::Vector3 position;
// Offset above the terrain surface. Editable in the node inspector.
float verticalOffset = 0.0f;
// Stable ID. Never reused after deletion.
int id = 0;
};
struct RoadEdge {
int nodeA = -1;
int nodeB = -1;
// Height of the road surface at each end, relative to the node position.
// roadSurfaceY(nodeA) = nodeA.position.y + roadLevelA.
float roadLevelA = 0.0f;
float roadLevelB = 0.0f;
// Lane counts for the A->B and B->A directions. 0 means "use global default".
// Asymmetric roads are allowed (e.g. lanesAtoB=2, lanesBtoA=1).
int lanesAtoB = 0;
int lanesBtoA = 0;
struct RoadSidePrefab {
std::string prefabPath;
float edgeT = 0.5f; // 0..1 along the edge from nodeA to nodeB
float sideOffset = 5.0f;
bool leftSide = true; // left side relative to A->B direction
};
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;
```
`RoadGraph` (`components/RoadGraph.hpp`) groups the graph data and provides
lightweight, self-documenting helpers that the editor and `RoadSystem` can use:
```cpp
struct RoadGraph {
RoadConfig config;
std::vector<RoadNode> nodes;
std::vector<RoadEdge> edges;
const RoadNode *findNodeById(int id) const;
int findNodeIndexById(int id) const;
std::vector<size_t> findEdgeIndicesForNode(int nodeId) const;
std::vector<int> getNeighborIds(int nodeId) const;
bool isEndpoint(int nodeId) const;
int getNextNodeId() const;
void resolveLaneCounts(const RoadEdge &edge, int fromNodeId,
int &outLanesFrom, int &outLanesTo) const;
bool validate(std::string *error = nullptr) const;
};
```
Each road data structure carries detailed comment annotations describing its
purpose, units, and usage rules:
- `RoadConfig` — global settings (lane width, thickness, material, LOD/distances,
mesh template). `laneWidth` is global and immutable per terrain.
- `RoadNode` — world position plus a vertical offset above the terrain surface.
`position.y` is derived from `terrain_height(x,z) + verticalOffset`. `id` is
stable and never reused.
- `RoadEdge` — endpoint node IDs, per-end road surface levels, and per-direction
lane counts (`lanesAtoB`, `lanesBtoA`). Zero lane counts fall back to
`RoadConfig::lanesPerDirection`. `lanesPerDirectionOverride` is kept for
backward compatibility.
- `RoadEdge::RoadSidePrefab` — a prefab instance placed beside the road at a
normalized position along the edge.
Rules:
- `RoadConfig::laneWidth` is global for the whole terrain. It is **not**
overridden per edge.
- Per-edge lane counts are resolved as:
- Per-edge lane counts are resolved through `RoadGraph::resolveLaneCounts()`:
```cpp
int la = (edge.lanesAtoB > 0) ? edge.lanesAtoB : tc.roadConfig.lanesPerDirection;
int lb = (edge.lanesBtoA > 0) ? edge.lanesBtoA : tc.roadConfig.lanesPerDirection;
int fromLanes, toLanes;
roadGraph.resolveLaneCounts(edge, nodeId, fromLanes, toLanes);
```
Internally this resolves `lanesAtoB`/`lanesBtoA` (with the legacy
`lanesPerDirectionOverride` fallback) against `config.lanesPerDirection`.
- Node IDs are stable integers. When a node is deleted, all edges referencing it
are deleted.
- Edge endpoints are stored as node IDs, not indices into `roadNodes`.
- Edge endpoints are stored as node IDs, not indices into `roadGraph.nodes`.
- `RoadGraph::validate()` checks for dangling edge references, duplicate node
IDs, and self-edges. Call it after deserialization and before expensive
geometry generation.
**Migration note**: older serialized scenes store `roadConfig`, `roadNodes`, and
`roadEdges` at the top level of the terrain JSON object. `SceneSerializer`
continues to read and write those keys; internally it populates the
`RoadGraph` fields.
---
#### M5.2 Visual road editor
Road editing is a dedicated 3D tool mode, not a set of inspector buttons. The
user sees the graph overlaid on the terrain and manipulates it directly.
**Status**: ✅ complete (2026-07-18). A second-audit verification pass
confirmed every gap from the first audit is implemented, the
`editSceneEditor` target builds cleanly, and the headless suite
(`./editSceneEditor --headless --run-terrain-tests=1`) passes with
`roadDataModel` and `roadSerialization` green.
Audit findings (2026-07-18, first audit) — all now implemented:
- ~~Missing: "Comply Terrain to Roads" placeholder button~~ — present as a
no-op with tooltip in `TerrainEditor.hpp::renderRoadSection()`.
- ~~Missing: side prefab list with add/remove in the edge inspector~~ —
present in `TerrainEditor.hpp::renderRoadSection()` (per-prefab path,
`edgeT`, `sideOffset`, `leftSide`, Remove; "Add Prefab" row).
- ~~Missing: `roadLevel` display (computed from connected edges) in the node
inspector~~ — present in `TerrainEditor.hpp::renderRoadSection()` (average
of `roadLevelA`/`roadLevelB` over connected edges).
- ~~Missing: click-vs-drag disambiguation~~ — `m_roadMouseDownPos` with a
5 px threshold in `EditorUISystem::onMousePressed()` /
`onMouseReleased()`; clicks fire only on release within the threshold.
- ~~Missing: live node follow during gizmo drag~~ —
`EditorUISystem::updateRoadEditMode()` calls `applyRoadGizmoDrag(false)`
every frame while `RoadGizmo::isDragging()`; the node Y is recomputed as
`terrainHeight(x,z) + verticalOffset` per frame.
- ~~Missing: Add Node / Move Node tool-mode split~~ —
`TerrainSystem::RoadEditTool` (default `Move`) with toolbar radio buttons;
an empty-terrain click creates a node only when "Add Node" is active,
otherwise it clears the selection.
- ~~Missing: toolbar gating~~ — the whole toolbar block in
`TerrainEditor.hpp::renderRoadSection()` is inside
`if (ts->getRoadEditMode())`.
Verified as already correct during the second audit:
- "Road Edit Mode" checkbox deactivates sculpt/paint/aux-paint modes and
hides the brush decal (`TerrainSystem::setRoadEditMode()`).
- Visual aids owned by `RoadSystem` and not serialized: cube node markers
(selected = orange/larger), edge line strips (selected = magenta),
per-edge road-width line outlines interpolating resolved lane counts,
selected-node vertical highlight, selected-edge A→B direction arrow.
- `RoadSystem` lifecycle: created in `TerrainSystem::activate()`, visual
aids initialized to `m_roadEditMode`, updated every frame from
`TerrainSystem::update()`, destroyed in `deactivate()`. Graph changes are
detected via `RoadGraph::version` (all mutating helpers bump it).
- Node/edge picking within `max(laneWidth, 2)` world units against a
`TerrainSystem::raycastTerrain()` hit; new nodes snap to the terrain
surface (`addRoadNodeAt()` → `snapRoadNodePosition()`).
- "Validate Road Graph" button runs `RoadGraph::validate()` and reports via
modal popups.
- Instruction text: a one-line hint under the "Road Edit Mode" checkbox when
the mode is off, a "Quick start" line under the active-mode banner, and the
detailed "How to edit roads" tree inside the toolbar. When the terrain is
not active the road section explains why it is unavailable instead of
disappearing silently.
- Headless tests `roadDataModel` and `roadSerialization` are registered in
`TerrainTests.cpp` and run under `--headless --run-terrain-tests=1`.
- Accepted deviations from the spec text (kept as-is): the mode flag lives in
`TerrainSystem::getRoadEditMode()` rather than a
`TerrainEditor::isRoadEditMode()` getter; the road width aid is a line
outline instead of a filled translucent quad; join is done via per-node
"Connect" buttons instead of a two-selection "Join Selected Nodes" button;
a dedicated `RoadGizmo` is used instead of the entity gizmo.
Road editing is a dedicated 3D tool mode. The user sees the graph overlaid on
terrain and manipulates it directly. The polish items required for M5.2
completion (both done) are:
- `TerrainEditor` "Validate Road Graph" button that runs `RoadGraph::validate()`
and reports dangling references, duplicate IDs, or self-edges.
- Headless automated tests for road data model helpers and road-network
serialization round-trip in `TerrainTests.cpp`.
The "Comply Terrain to Roads" toolbar button is intentionally left as a
no-op/visual placeholder until M5.10 (terrain compliance) is implemented.
**Road edit mode activation**:
- Add a "Road Edit Mode" checkbox in `TerrainEditor`.
@@ -2262,7 +2421,7 @@ For every node:
lane count is `lanesAtoB` and the **inbound** lane count is `lanesBtoA`.
- If stored as `node = nodeB, neighbor = nodeA`, the outbound lane count is
`lanesBtoA` and the inbound lane count is `lanesAtoB`.
- Resolve 0 values to `tc.roadConfig.lanesPerDirection`.
- Resolve 0 values to `tc.roadGraph.config.lanesPerDirection`.
**Node with multiple connections** (a node which connects by edges to multiple
other nodes, i.e. 2+ half-edges):
@@ -2361,13 +2520,13 @@ For each affected terrain page:
create `PrimitiveComponent` children; the buffer is built procedurally).
5. Set `tb.meshName = "Road_" + terrainId + "_" + pageX + "_" + pageY`.
6. Create a `ProceduralMaterialComponent` entity whose `materialName` is
`tc.roadConfig.roadMaterialName`. If the material resource does not exist,
`tc.roadGraph.config.roadMaterialName`. If the material resource does not exist,
`RoadSystem` creates a simple diffuse material with that name.
7. Set `tb.materialEntity` to the material entity.
8. Mark `tb.dirty = true` so `ProceduralMeshSystem::update()` converts the buffer
to an Ogre mesh.
9. After mesh creation, retrieve `tb.ogreEntity` and call
`entity->setRenderingDistance(tc.roadConfig.roadVisibilityDistance)`.
`entity->setRenderingDistance(tc.roadGraph.config.roadVisibilityDistance)`.
10. Add a `LodComponent` with a `LodSettingsComponent` that has one reduction
level at `roadLodDistance` (50% vertex reduction).
@@ -2494,11 +2653,17 @@ edge data when the page is loaded.
### Milestone 5 definition of done
- [ ] Road Edit Mode activates a visual 3D editing state with node markers,
edge lines, and road-width visual aids.
- [ ] New nodes are placed on the terrain surface and stay snapped to it when
- [x] M5.1 — Road data model: `RoadConfig`, `RoadNode`, `RoadEdge`, and
`RoadSidePrefab` live in `components/RoadGraph.hpp` with detailed
purpose annotations; `TerrainComponent` owns a `RoadGraph`; helpers for
node lookup, edge enumeration, lane-count resolution, ID generation,
and graph validation are available and covered by tests.
- [x] M5.2 — Visual road editor: Road Edit Mode activates a 3D editing state
with node markers, edge lines, road-width visual aids, and a translate
gizmo. The user can add, select, move, split, join, and delete road
nodes visually. A "Validate Road Graph" button reports graph errors.
- [x] New nodes are placed on the terrain surface and stay snapped to it when
moved with the gizmo.
- [ ] User can add, select, move, split, join, and delete road nodes visually.
- [ ] Per-edge asymmetric lane counts (`lanesAtoB` != `lanesBtoA`) render
correctly.
- [ ] Road mesh is generated from angle-sorted wedges by sweeping the 1-unit
@@ -2514,8 +2679,8 @@ edge data when the page is loaded.
intersections.
- [ ] Roadside prefabs spawn at configured edge positions and are recreated on
scene load.
- [ ] Save/reload round-trip preserves road nodes, edges, config, and side
prefabs.
- [x] Save/reload round-trip preserves road nodes, edges, config, and side
prefabs (verified by `TerrainTests.cpp` headless test).
- [ ] Road meshes, colliders, spawned prefabs, and navmesh geometry are created
when a terrain page loads and destroyed when it unloads.
- [ ] Removing the terrain entity destroys all road meshes, colliders, visual
@@ -0,0 +1,647 @@
#ifndef EDITSCENE_ROADGRAPH_HPP
#define EDITSCENE_ROADGRAPH_HPP
#pragma once
#include <Ogre.h>
#include <algorithm>
#include <cmath>
#include <string>
#include <vector>
/**
* 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 newPos = na->position +
(nb->position - na->position) * clampedT;
float newYOffset = na->verticalOffset +
(nb->verticalOffset - na->verticalOffset) * clampedT +
oldEdge.roadLevelA +
(oldEdge.roadLevelB - oldEdge.roadLevelA) * clampedT;
/* 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.
*
* @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;
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.
*/
bool validate(std::string *error = nullptr) const
{
for (const auto &e : edges) {
if (findNodeById(e.nodeA) == nullptr) {
if (error)
*error =
"Edge references missing node A: " +
std::to_string(e.nodeA);
return false;
}
if (findNodeById(e.nodeB) == nullptr) {
if (error)
*error =
"Edge references missing node B: " +
std::to_string(e.nodeB);
return false;
}
if (e.nodeA == e.nodeB) {
if (error)
*error =
"Edge connects node to itself: " +
std::to_string(e.nodeA);
return false;
}
}
for (size_t i = 0; i < nodes.size(); ++i) {
for (size_t j = i + 1; j < nodes.size(); ++j) {
if (nodes[i].id == nodes[j].id) {
if (error)
*error = "Duplicate node ID: " +
std::to_string(
nodes[i].id);
return false;
}
}
}
return true;
}
};
#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
+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
@@ -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;
@@ -0,0 +1,382 @@
#include "RoadSystem.hpp"
#include "../components/Terrain.hpp"
#include "../components/Transform.hpp"
#include <algorithm>
#include <cmath>
static const float NODE_SIZE = 0.25f;
static const float SELECTED_NODE_SIZE = 0.45f;
static const float WIDTH_SAMPLE_STEP = 5.0f;
RoadSystem::RoadSystem(flecs::world &world, Ogre::SceneManager *sceneMgr)
: m_world(world)
, m_sceneMgr(sceneMgr)
{
createManualObjects();
}
RoadSystem::~RoadSystem()
{
destroyManualObjects();
}
void RoadSystem::createManualObjects()
{
if (!m_sceneMgr)
return;
m_visualNode = m_sceneMgr->getRootSceneNode()->createChildSceneNode(
"RoadVisualNode");
m_nodeMarkers = m_sceneMgr->createManualObject("RoadNodeMarkers");
m_edgeLines = m_sceneMgr->createManualObject("RoadEdgeLines");
m_widthIndicators = m_sceneMgr->createManualObject("RoadWidthIndicators");
m_selectionHighlight = m_sceneMgr->createManualObject(
"RoadSelectionHighlight");
m_visualNode->attachObject(m_nodeMarkers);
m_visualNode->attachObject(m_edgeLines);
m_visualNode->attachObject(m_widthIndicators);
m_visualNode->attachObject(m_selectionHighlight);
/* Draw in the overlay queue so the aids are never hidden by terrain. */
m_nodeMarkers->setRenderQueueGroup(Ogre::RENDER_QUEUE_OVERLAY);
m_edgeLines->setRenderQueueGroup(Ogre::RENDER_QUEUE_OVERLAY);
m_widthIndicators->setRenderQueueGroup(Ogre::RENDER_QUEUE_OVERLAY);
m_selectionHighlight->setRenderQueueGroup(Ogre::RENDER_QUEUE_OVERLAY);
}
void RoadSystem::destroyManualObjects()
{
if (!m_sceneMgr)
return;
if (m_visualNode) {
m_visualNode->detachAllObjects();
try {
m_sceneMgr->destroySceneNode(m_visualNode);
} catch (...) {
}
m_visualNode = nullptr;
}
auto destroyManual = [&](Ogre::ManualObject *&obj) {
if (obj) {
try {
m_sceneMgr->destroyManualObject(obj);
} catch (...) {
}
obj = nullptr;
}
};
destroyManual(m_nodeMarkers);
destroyManual(m_edgeLines);
destroyManual(m_widthIndicators);
destroyManual(m_selectionHighlight);
}
void RoadSystem::setTerrainEntity(flecs::entity entity)
{
if (entity.is_alive() && entity.has<TerrainComponent>()) {
m_terrainEntityId = entity.id();
} else {
m_terrainEntityId = 0;
}
m_lastGraphVersion = 0;
rebuildVisualAids();
}
void RoadSystem::clear()
{
m_terrainEntityId = 0;
m_selectedNodeId = -1;
m_selectedEdgeIndex = -1;
m_lastGraphVersion = 0;
rebuildVisualAids();
}
flecs::entity RoadSystem::getTerrainEntity() const
{
return m_world.entity(m_terrainEntityId);
}
void RoadSystem::setVisualAidsVisible(bool visible)
{
m_visualAidsVisible = visible;
if (m_visualNode)
m_visualNode->setVisible(visible);
}
bool RoadSystem::getVisualAidsVisible() const
{
return m_visualAidsVisible;
}
void RoadSystem::setSelectedNodeId(int id)
{
if (m_selectedNodeId != id) {
m_selectedNodeId = id;
rebuildVisualAids();
}
}
void RoadSystem::setSelectedEdgeIndex(int idx)
{
if (m_selectedEdgeIndex != idx) {
m_selectedEdgeIndex = idx;
rebuildVisualAids();
}
}
void RoadSystem::update(float deltaTime)
{
(void)deltaTime;
flecs::entity terrain = getTerrainEntity();
if (!terrain.is_alive() || !terrain.has<TerrainComponent>()) {
if (m_lastGraphVersion != 0) {
m_lastGraphVersion = 0;
rebuildVisualAids();
}
return;
}
const TerrainComponent &tc = terrain.get<TerrainComponent>();
if (tc.roadGraph.version != m_lastGraphVersion) {
m_lastGraphVersion = tc.roadGraph.version;
rebuildVisualAids();
}
}
Ogre::Vector3 RoadSystem::getNodePosition(int nodeId) const
{
flecs::entity terrain = getTerrainEntity();
if (!terrain.is_alive() || !terrain.has<TerrainComponent>())
return Ogre::Vector3::ZERO;
const auto &rg = terrain.get<TerrainComponent>().roadGraph;
const RoadNode *n = rg.findNodeById(nodeId);
if (!n)
return Ogre::Vector3::ZERO;
return n->position;
}
bool RoadSystem::getEdgePositions(int edgeIndex, Ogre::Vector3 &outA,
Ogre::Vector3 &outB) const
{
flecs::entity terrain = getTerrainEntity();
if (!terrain.is_alive() || !terrain.has<TerrainComponent>())
return false;
const auto &rg = terrain.get<TerrainComponent>().roadGraph;
if (edgeIndex < 0 || (size_t)edgeIndex >= rg.edges.size())
return false;
const RoadEdge &e = rg.edges[(size_t)edgeIndex];
const RoadNode *na = rg.findNodeById(e.nodeA);
const RoadNode *nb = rg.findNodeById(e.nodeB);
if (!na || !nb)
return false;
outA = na->position;
outB = nb->position;
return true;
}
static void addBox(Ogre::ManualObject *mo, const Ogre::Vector3 &center,
float halfSize, const Ogre::ColourValue &color)
{
mo->colour(color);
/* 12 edges of a cube centered at @c center. */
for (int axis = 0; axis < 3; ++axis) {
int a1 = (axis + 1) % 3;
int a2 = (axis + 2) % 3;
for (int s1 = -1; s1 <= 1; s1 += 2) {
for (int s2 = -1; s2 <= 1; s2 += 2) {
Ogre::Vector3 p = center;
p[a1] += s1 * halfSize;
p[a2] += s2 * halfSize;
Ogre::Vector3 q = p;
q[axis] += 2.0f * halfSize;
mo->position(p);
mo->position(q);
}
}
}
}
void RoadSystem::rebuildVisualAids()
{
if (!m_nodeMarkers || !m_edgeLines || !m_widthIndicators ||
!m_selectionHighlight)
return;
m_nodeMarkers->clear();
m_edgeLines->clear();
m_widthIndicators->clear();
m_selectionHighlight->clear();
flecs::entity terrain = getTerrainEntity();
if (!terrain.is_alive() || !terrain.has<TerrainComponent>()) {
setVisualAidsVisible(m_visualAidsVisible);
return;
}
const TerrainComponent &tc = terrain.get<TerrainComponent>();
const RoadGraph &rg = tc.roadGraph;
if (rg.nodes.empty()) {
setVisualAidsVisible(m_visualAidsVisible);
return;
}
buildNodeMarkers();
buildEdgeLines();
buildWidthIndicators();
buildSelectionHighlight();
setVisualAidsVisible(m_visualAidsVisible);
}
void RoadSystem::buildNodeMarkers()
{
flecs::entity terrain = getTerrainEntity();
if (!terrain.is_alive() || !terrain.has<TerrainComponent>())
return;
const RoadGraph &rg = terrain.get<TerrainComponent>().roadGraph;
m_nodeMarkers->begin("Ogre/AxisGizmo",
Ogre::RenderOperation::OT_LINE_LIST);
for (const auto &n : rg.nodes) {
bool selected = (n.id == m_selectedNodeId);
Ogre::ColourValue color = selected ?
Ogre::ColourValue(1.0f, 0.5f, 0.0f) :
Ogre::ColourValue(1.0f, 1.0f, 0.0f);
float halfSize = selected ? SELECTED_NODE_SIZE * 0.5f :
NODE_SIZE * 0.5f;
addBox(m_nodeMarkers, n.position, halfSize, color);
}
m_nodeMarkers->end();
}
void RoadSystem::buildEdgeLines()
{
flecs::entity terrain = getTerrainEntity();
if (!terrain.is_alive() || !terrain.has<TerrainComponent>())
return;
const RoadGraph &rg = terrain.get<TerrainComponent>().roadGraph;
m_edgeLines->begin("Ogre/AxisGizmo",
Ogre::RenderOperation::OT_LINE_LIST);
for (size_t i = 0; i < rg.edges.size(); ++i) {
const RoadEdge &e = rg.edges[i];
const RoadNode *na = rg.findNodeById(e.nodeA);
const RoadNode *nb = rg.findNodeById(e.nodeB);
if (!na || !nb)
continue;
bool selected = ((int)i == m_selectedEdgeIndex);
Ogre::ColourValue color = selected ?
Ogre::ColourValue(1.0f, 0.0f, 1.0f) :
Ogre::ColourValue(0.0f, 1.0f, 1.0f);
m_edgeLines->colour(color);
m_edgeLines->position(na->position);
m_edgeLines->position(nb->position);
}
m_edgeLines->end();
}
void RoadSystem::buildWidthIndicators()
{
flecs::entity terrain = getTerrainEntity();
if (!terrain.is_alive() || !terrain.has<TerrainComponent>())
return;
const RoadGraph &rg = terrain.get<TerrainComponent>().roadGraph;
float laneWidth = rg.config.laneWidth;
m_widthIndicators->begin("Ogre/AxisGizmo",
Ogre::RenderOperation::OT_LINE_LIST);
m_widthIndicators->colour(Ogre::ColourValue(0.0f, 1.0f, 0.0f, 0.5f));
for (const auto &e : rg.edges) {
const RoadNode *na = rg.findNodeById(e.nodeA);
const RoadNode *nb = rg.findNodeById(e.nodeB);
if (!na || !nb)
continue;
Ogre::Vector3 dir = nb->position - na->position;
dir.y = 0.0f;
float length = dir.normalise();
if (length < 0.0001f)
continue;
Ogre::Vector3 side = Ogre::Vector3::UNIT_Y.crossProduct(dir);
side.normalise();
int lanesA, lanesB;
rg.resolveLaneCounts(e, e.nodeA, lanesA, lanesB);
float halfWidthA = lanesA * laneWidth * 0.5f;
float halfWidthB = lanesB * laneWidth * 0.5f;
int samples = std::max(2, (int)std::ceil(length / WIDTH_SAMPLE_STEP));
for (int s = 0; s <= samples; ++s) {
float t = (float)s / (float)samples;
Ogre::Vector3 center = na->position + (nb->position - na->position) * t;
float halfWidth = halfWidthA + (halfWidthB - halfWidthA) * t;
m_widthIndicators->position(center - side * halfWidth);
m_widthIndicators->position(center + side * halfWidth);
}
}
m_widthIndicators->end();
}
void RoadSystem::buildSelectionHighlight()
{
m_selectionHighlight->begin("Ogre/AxisGizmo",
Ogre::RenderOperation::OT_LINE_LIST);
if (m_selectedNodeId >= 0) {
Ogre::Vector3 pos = getNodePosition(m_selectedNodeId);
m_selectionHighlight->colour(Ogre::ColourValue(1.0f, 0.5f, 0.0f));
m_selectionHighlight->position(pos + Ogre::Vector3(0, 0.6f, 0));
m_selectionHighlight->position(pos + Ogre::Vector3(0, 2.0f, 0));
}
if (m_selectedEdgeIndex >= 0) {
Ogre::Vector3 a, b;
if (getEdgePositions(m_selectedEdgeIndex, a, b)) {
Ogre::Vector3 dir = b - a;
dir.y = 0.0f;
float len = dir.normalise();
if (len > 0.0001f) {
Ogre::Vector3 mid = (a + b) * 0.5f;
Ogre::Vector3 arrowTip = mid + dir * 1.5f;
Ogre::Vector3 side = Ogre::Vector3::UNIT_Y.crossProduct(dir);
side.normalise();
m_selectionHighlight->colour(
Ogre::ColourValue(1.0f, 0.0f, 1.0f));
m_selectionHighlight->position(mid);
m_selectionHighlight->position(arrowTip);
m_selectionHighlight->position(arrowTip);
m_selectionHighlight->position(
arrowTip - dir * 0.4f + side * 0.2f);
m_selectionHighlight->position(arrowTip);
m_selectionHighlight->position(
arrowTip - dir * 0.4f - side * 0.2f);
}
}
}
m_selectionHighlight->end();
}
@@ -0,0 +1,90 @@
#ifndef EDITSCENE_ROADSYSTEM_HPP
#define EDITSCENE_ROADSYSTEM_HPP
#pragma once
#include <Ogre.h>
#include <OgreManualObject.h>
#include <flecs.h>
#include <cstdint>
#include <memory>
/**
* RoadSystem runtime owner of road visual aids and (future) road mesh
* generation.
*
* In the current milestone this class builds and updates ManualObject
* overlays for the road graph: node markers, edge lines, road-width
* indicators, and selection highlights. It is created by TerrainSystem when
* a terrain is activated and destroyed when the terrain deactivates.
*/
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);
/** The terrain entity this system is bound to. */
flecs::entity getTerrainEntity() const;
private:
void createManualObjects();
void destroyManualObjects();
void rebuildVisualAids();
void buildNodeMarkers();
void buildEdgeLines();
void buildWidthIndicators();
void buildSelectionHighlight();
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;
bool m_visualAidsVisible = true;
int m_selectedNodeId = -1;
int m_selectedEdgeIndex = -1;
uint64_t m_lastGraphVersion = 0;
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;
@@ -4256,6 +4257,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 +4300,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 +4324,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 +4343,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,7 +4351,7 @@ void SceneSerializer::deserializeTerrain(flecs::entity entity,
edge.sidePrefabs.push_back(sp);
}
}
tc.roadEdges.push_back(edge);
tc.roadGraph.edges.push_back(edge);
}
}
@@ -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);
@@ -1,4 +1,5 @@
#include "TerrainSystem.hpp"
#include "RoadSystem.hpp"
#include "../components/Terrain.hpp"
#include "../components/Transform.hpp"
#include "../physics/physics.h"
@@ -473,6 +474,407 @@ bool TerrainSystem::saveSceneHeightmap(const TerrainComponent &tc)
return saveHeightmap(getHeightmapPath(tc));
}
/* ------------------------------------------------------------------ */
/* 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 +1290,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 +1385,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);
@@ -1244,6 +1671,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 +1759,11 @@ 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->setTerrainEntity(m_world.entity(m_terrainEntityId));
m_roadSystem->setVisualAidsVisible(m_roadEditMode);
if (m_physics)
m_physics->setBodyDrawFilter(&mBodyDrawFilter);
@@ -1356,6 +1790,7 @@ 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();
@@ -1395,6 +1830,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);
@@ -1532,6 +1970,9 @@ void TerrainSystem::update(float /*deltaTime*/)
if (m_auxVisDirty)
buildAuxMapVisualization();
if (m_roadSystem)
m_roadSystem->update(0.0f);
mBodyDrawFilter.showTerrain = m_showTerrainColliders;
}
@@ -31,6 +31,7 @@
#include <FastNoiseLite.h>
class JoltPhysicsWrapper;
class RoadSystem;
class TerrainSystem {
public:
@@ -73,6 +74,12 @@ public:
bool saveSceneHeightmap(const struct TerrainComponent &tc);
std::string getHeightmapPath(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 +263,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,6 +337,7 @@ public:
friend class TerrainCommandQueue;
private:
std::unique_ptr<RoadSystem> m_roadSystem;
void activate(struct TerrainComponent &tc,
struct TransformComponent &xform);
void ensureHeightmapLoaded(struct TerrainComponent &tc);
@@ -469,6 +512,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;
+387 -4
View File
@@ -697,7 +697,12 @@ bool TerrainTestRunner::testDetailNoise(EditorApp &app, TerrainSystem *ts)
Ogre::LogManager::getSingleton().logMessage(
"TerrainTests: detail noise deterministic height + serialization test passed");
/* Explicitly deactivate before destroying the entity so the next
* test starts from a clean terrain state. Pump frames to flush
* any deferred Flecs deletion. */
ts->deactivate();
destroyTerrainEntity(app, e);
pumpFrames(app, ts, 3);
std::filesystem::remove(flatPath);
return true;
}
@@ -765,9 +770,6 @@ bool TerrainTestRunner::testBrushShapes(EditorApp &app, TerrainSystem *ts)
bool TerrainTestRunner::testAuxMapOperations(EditorApp &app, TerrainSystem *ts)
{
if (!ts->isActive())
return false;
/* Find the test terrain entity and add an aux map for testing. */
flecs::world *w = app.getWorld();
flecs::entity terrain = flecs::entity::null();
@@ -891,7 +893,7 @@ bool TerrainTestRunner::testAuxMapOperations(EditorApp &app, TerrainSystem *ts)
}
bool TerrainTestRunner::testCompositeMapBatching(EditorApp &app,
TerrainSystem *ts)
TerrainSystem *ts)
{
(void)app;
@@ -960,6 +962,186 @@ bool TerrainTestRunner::testCompositeMapBatching(EditorApp &app,
return true;
}
bool TerrainTestRunner::testChangeResolution(EditorApp &app, TerrainSystem *ts)
{
(void)app;
if (!ts->isActive())
return false;
flecs::world *w = app.getWorld();
flecs::entity terrainEntity = flecs::entity::null();
bool foundEntity = false;
w->query<TerrainComponent, TransformComponent>().each(
[&](flecs::entity e, TerrainComponent &, TransformComponent &) {
if (!foundEntity) {
terrainEntity = e;
foundEntity = true;
}
});
if (!foundEntity || !terrainEntity.is_alive() ||
!terrainEntity.has<TerrainComponent>()) {
Ogre::LogManager::getSingleton().logMessage(
"TerrainTests: FAIL - no terrain entity for resolution change");
return false;
}
auto &tc = terrainEntity.get_mut<TerrainComponent>();
int oldRes = tc.heightmapSize;
/* Add and paint an aux map so we can verify it is resampled. */
TerrainComponent::AuxMap aux;
aux.name = "test_density";
aux.fileName = "test_density.bin";
aux.resolution = oldRes;
aux.defaultValue = 0.0f;
tc.auxMaps.push_back(aux);
ts->applyAuxBrush(aux.name, Ogre::Vector3(0.0f, 0.0f, 0.0f), 500.0f,
1.0f);
ts->saveSceneAuxMaps(tc);
auto verifyHeightmapFileSize = [&](int expectedRes,
const char *context) -> bool {
std::string hmPath = ts->getHeightmapPath(tc);
std::ifstream hmFile(hmPath, std::ios::binary | std::ios::ate);
if (!hmFile) {
Ogre::LogManager::getSingleton().logMessage(
std::string(
"TerrainTests: FAIL - could not open resampled heightmap (") +
context + ")");
return false;
}
std::streamsize size = hmFile.tellg();
std::streamsize expectedSize =
sizeof(uint32_t) + (std::streamsize)expectedRes *
expectedRes * sizeof(float);
if (size != expectedSize) {
Ogre::LogManager::getSingleton().logMessage(
std::string(
"TerrainTests: FAIL - resampled heightmap size mismatch (") +
context + "), expected " +
Ogre::StringConverter::toString(
(long)expectedSize) +
" got " +
Ogre::StringConverter::toString((long)size));
return false;
}
return true;
};
auto verifyBackupExists = [&](int res, const char *context) -> bool {
std::string hmPath = ts->getHeightmapPath(tc);
std::filesystem::path hmFs(hmPath);
std::string backupHm = hmFs.parent_path().string() + "/" +
hmFs.stem().string() + "_" +
Ogre::StringConverter::toString(res) +
hmFs.extension().string();
if (!std::filesystem::exists(backupHm)) {
Ogre::LogManager::getSingleton().logMessage(
std::string(
"TerrainTests: FAIL - heightmap backup missing (") +
context + "): " + backupHm);
return false;
}
return true;
};
auto verifyAuxResolution = [&](int expectedRes,
const char *context) -> bool {
for (auto &a : tc.auxMaps) {
if (a.name == aux.name) {
if (a.resolution != expectedRes) {
Ogre::LogManager::getSingleton().logMessage(
std::string(
"TerrainTests: FAIL - aux map resolution not updated (") +
context + ")");
return false;
}
return true;
}
}
Ogre::LogManager::getSingleton().logMessage(
std::string(
"TerrainTests: FAIL - aux map missing after resolution change (") +
context + ")");
return false;
};
/* Change heightmap resolution 128 -> 256. */
if (!ts->changeHeightmapResolution(tc, 256)) {
Ogre::LogManager::getSingleton().logMessage(
"TerrainTests: FAIL - changeHeightmapResolution(256) returned false");
return false;
}
/* Pump frames for reactivation. */
pumpFrames(app, ts, 5);
if (!ts->isActive()) {
Ogre::LogManager::getSingleton().logMessage(
"TerrainTests: FAIL - terrain not active after 256 change");
return false;
}
if (tc.heightmapSize != 256) {
Ogre::LogManager::getSingleton().logMessage(
"TerrainTests: FAIL - heightmapSize not updated to 256");
return false;
}
if (!verifyHeightmapFileSize(256, "256"))
return false;
if (!verifyBackupExists(oldRes, "256"))
return false;
if (!verifyAuxResolution(256, "256"))
return false;
/* Change heightmap resolution 256 -> 512 to cover the common editor case. */
if (!ts->changeHeightmapResolution(tc, 512)) {
Ogre::LogManager::getSingleton().logMessage(
"TerrainTests: FAIL - changeHeightmapResolution(512) returned false");
return false;
}
pumpFrames(app, ts, 5);
if (!ts->isActive()) {
Ogre::LogManager::getSingleton().logMessage(
"TerrainTests: FAIL - terrain not active after 512 change");
return false;
}
if (tc.heightmapSize != 512) {
Ogre::LogManager::getSingleton().logMessage(
"TerrainTests: FAIL - heightmapSize not updated to 512, got " +
Ogre::StringConverter::toString(tc.heightmapSize));
return false;
}
if (!verifyHeightmapFileSize(512, "512"))
return false;
if (!verifyBackupExists(256, "512"))
return false;
if (!verifyAuxResolution(512, "512"))
return false;
/* Verify layers survived the reactivation. */
Ogre::TerrainGroup *group = ts->getTerrainGroup();
if (group) {
Ogre::Terrain *t = group->getTerrain(0, 0);
if (t && t->isLoaded() && t->getLayerCount() < 2) {
Ogre::LogManager::getSingleton().logMessage(
"TerrainTests: FAIL - layers lost after resolution change");
return false;
}
}
Ogre::LogManager::getSingleton().logMessage(
"TerrainTests: resolution change test passed");
return true;
}
bool TerrainTestRunner::testVerifyGeometry(EditorApp &app, TerrainSystem *ts)
{
if (!ts->isActive())
@@ -1045,6 +1227,204 @@ bool TerrainTestRunner::testDeleteTerrain(EditorApp &app, TerrainSystem *ts)
return true;
}
bool TerrainTestRunner::testRoadDataModel(EditorApp &app, TerrainSystem *ts)
{
(void)app;
(void)ts;
RoadGraph rg;
/* addNode / addEdge / lookup. */
int idA = rg.addNode(Ogre::Vector3(10, 5, 20), 1.0f);
int idB = rg.addNode(Ogre::Vector3(30, 5, 20), 2.0f);
int idC = rg.addNode(Ogre::Vector3(30, 5, 40), 0.5f);
if (idA <= 0 || idB <= 0 || idC <= 0) {
Ogre::LogManager::getSingleton().logMessage(
"TerrainTests: FAIL - addNode returned invalid id");
return false;
}
int edgeAB = rg.addEdge(idA, idB);
int edgeBC = rg.addEdge(idB, idC);
if (edgeAB < 0 || edgeBC < 0) {
Ogre::LogManager::getSingleton().logMessage(
"TerrainTests: FAIL - addEdge failed");
return false;
}
if (rg.findNodeById(idA) == nullptr ||
rg.findNodeIndexById(idB) < 0) {
Ogre::LogManager::getSingleton().logMessage(
"TerrainTests: FAIL - node lookup failed");
return false;
}
auto neighbors = rg.getNeighborIds(idB);
if (neighbors.size() != 2) {
Ogre::LogManager::getSingleton().logMessage(
"TerrainTests: FAIL - expected 2 neighbors for node B");
return false;
}
/* Lane count resolution with asymmetric edge. */
rg.edges[(size_t)edgeAB].lanesAtoB = 2;
rg.edges[(size_t)edgeAB].lanesBtoA = 1;
int fromA, toA;
rg.resolveLaneCounts(rg.edges[(size_t)edgeAB], idA, fromA, toA);
if (fromA != 2 || toA != 1) {
Ogre::LogManager::getSingleton().logMessage(
"TerrainTests: FAIL - resolveLaneCounts asymmetric mismatch");
return false;
}
/* Validation of good graph. */
std::string error;
if (!rg.validate(&error)) {
Ogre::LogManager::getSingleton().logMessage(
"TerrainTests: FAIL - valid graph rejected: " + error);
return false;
}
/* Validation must reject an edge referencing a missing node. */
RoadGraph bad;
int id = bad.addNode(Ogre::Vector3::ZERO, 0.0f);
RoadEdge dangling;
dangling.nodeA = id;
dangling.nodeB = 999; /* non-existent */
bad.edges.push_back(dangling);
if (bad.validate(&error)) {
Ogre::LogManager::getSingleton().logMessage(
"TerrainTests: FAIL - dangling edge accepted as valid");
return false;
}
/* removeNode must delete incident edges. */
if (!rg.removeNode(idB)) {
Ogre::LogManager::getSingleton().logMessage(
"TerrainTests: FAIL - removeNode returned false");
return false;
}
if (rg.edges.size() != 0) {
Ogre::LogManager::getSingleton().logMessage(
"TerrainTests: FAIL - edges not removed with node");
return false;
}
/* splitEdge. */
RoadGraph rg2;
int n1 = rg2.addNode(Ogre::Vector3(0, 0, 0), 0.0f);
int n2 = rg2.addNode(Ogre::Vector3(10, 0, 0), 0.0f);
int e1 = rg2.addEdge(n1, n2);
int nMid = rg2.splitEdge((size_t)e1, 0.5f);
if (nMid < 0 || rg2.edges.size() != 2) {
Ogre::LogManager::getSingleton().logMessage(
"TerrainTests: FAIL - splitEdge did not produce two edges");
return false;
}
Ogre::LogManager::getSingleton().logMessage(
"TerrainTests: road data model test passed");
return true;
}
bool TerrainTestRunner::testRoadSerialization(EditorApp &app, TerrainSystem *ts)
{
(void)ts;
flecs::world *w = app.getWorld();
flecs::entity e = w->entity();
{
TerrainComponent tc;
tc.terrainId = 42424242;
tc.blendMapSize = 512;
tc.roadGraph.config.laneWidth = 4.0f;
tc.roadGraph.config.lanesPerDirection = 2;
tc.roadGraph.config.roadMaterialName = "TestRoadMat";
int n1 = tc.roadGraph.addNode(Ogre::Vector3(100, 10, 100), 0.5f);
int n2 = tc.roadGraph.addNode(Ogre::Vector3(200, 10, 100), 1.0f);
tc.roadGraph.addEdge(n1, n2);
tc.roadGraph.edges.back().lanesAtoB = 3;
tc.roadGraph.edges.back().lanesBtoA = 1;
tc.roadGraph.edges.back().roadLevelA = 0.2f;
tc.roadGraph.edges.back().roadLevelB = -0.1f;
RoadEdge::RoadSidePrefab sp;
sp.prefabPath = "lamp_post.json";
sp.edgeT = 0.25f;
sp.sideOffset = 3.5f;
sp.leftSide = false;
tc.roadGraph.edges.back().sidePrefabs.push_back(sp);
e.set<TerrainComponent>(tc);
}
SceneSerializer serializer(*w, app.getSceneManager());
nlohmann::json terrainJson = serializer.serializeTerrain(e);
flecs::entity e2 = w->entity();
e2.set<TerrainComponent>(TerrainComponent());
serializer.deserializeTerrain(e2, terrainJson);
if (!e2.has<TerrainComponent>()) {
Ogre::LogManager::getSingleton().logMessage(
"TerrainTests: FAIL - deserializeTerrain removed TerrainComponent");
return false;
}
const TerrainComponent &tc2 = e2.get<TerrainComponent>();
if (tc2.blendMapSize != 512) {
Ogre::LogManager::getSingleton().logMessage(
"TerrainTests: FAIL - blendMapSize not preserved");
return false;
}
if (tc2.roadGraph.config.laneWidth != 4.0f ||
tc2.roadGraph.config.lanesPerDirection != 2 ||
tc2.roadGraph.config.roadMaterialName != "TestRoadMat") {
Ogre::LogManager::getSingleton().logMessage(
"TerrainTests: FAIL - road config not preserved");
return false;
}
if (tc2.roadGraph.nodes.size() != 2 ||
tc2.roadGraph.edges.size() != 1) {
Ogre::LogManager::getSingleton().logMessage(
"TerrainTests: FAIL - road nodes/edges not preserved");
return false;
}
const RoadEdge &edge = tc2.roadGraph.edges[0];
if (edge.lanesAtoB != 3 || edge.lanesBtoA != 1 ||
std::abs(edge.roadLevelA - 0.2f) > 0.001f ||
std::abs(edge.roadLevelB - (-0.1f)) > 0.001f) {
Ogre::LogManager::getSingleton().logMessage(
"TerrainTests: FAIL - road edge fields not preserved");
return false;
}
if (edge.sidePrefabs.size() != 1 ||
edge.sidePrefabs[0].prefabPath != "lamp_post.json" ||
edge.sidePrefabs[0].leftSide != false) {
Ogre::LogManager::getSingleton().logMessage(
"TerrainTests: FAIL - road side prefab not preserved");
return false;
}
std::string error;
if (!tc2.roadGraph.validate(&error)) {
Ogre::LogManager::getSingleton().logMessage(
"TerrainTests: FAIL - deserialized road graph invalid: " +
error);
return false;
}
Ogre::LogManager::getSingleton().logMessage(
"TerrainTests: road serialization round-trip passed");
return true;
}
bool TerrainTestRunner::testMemoryStability(EditorApp &app)
{
/* Pump remaining frames to flush any pending work. */
@@ -1114,8 +1494,11 @@ int TerrainTestRunner::run(EditorApp &app, int iterations)
{ "brushShapes", testBrushShapes },
{ "auxMapOps", testAuxMapOperations },
{ "compositeBatching", testCompositeMapBatching },
{ "changeResolution", testChangeResolution },
{ "verify", testVerifyGeometry },
{ "delete", testDeleteTerrain },
{ "roadDataModel", testRoadDataModel },
{ "roadSerialization", testRoadSerialization },
};
@@ -67,8 +67,11 @@ 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 testMemoryStability(EditorApp &app);
static void logResult(const TerrainTestResult &r);
+433 -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>
/**
@@ -253,6 +256,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 +313,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 +490,384 @@ 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;
/* Global road config. */
if (ImGui::TreeNode("Road Config")) {
bool cfgChanged = false;
ImGui::Text("Mesh: %s", rg.config.roadMeshTemplate.c_str());
ImGui::Text("Material: %s",
rg.config.roadMaterialName.c_str());
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();
/* No-op placeholder until M5.10 (terrain compliance). */
if (ImGui::Button("Comply Terrain to Roads")) {
/* intentionally a no-op until M5.10 */
}
if (ImGui::IsItemHovered())
ImGui::SetTooltip(
"Placeholder - implemented in M5.10");
/* 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. */
ImGui::Text("Nodes: %zu", rg.nodes.size());
int selectedNodeId = rs->getSelectedNodeId();
for (const auto &n : rg.nodes) {
bool selected = (n.id == selectedNodeId);
std::string label = "Node " + std::to_string(n.id);
if (ImGui::Selectable(label.c_str(), selected)) {
rs->setSelectedNodeId(n.id);
rs->setSelectedEdgeIndex(-1);
}
if (selectedNodeId >= 0 && selectedNodeId != n.id) {
ImGui::SameLine();
std::string connectLabel = "Connect##" +
std::to_string(n.id);
if (ImGui::SmallButton(connectLabel.c_str())) {
rg.joinNodes(selectedNodeId, n.id);
}
}
}
/* Edge list. */
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);
if (ImGui::Selectable(label.c_str(), selected)) {
rs->setSelectedEdgeIndex((int)i);
rs->setSelectedNodeId(-1);
}
if (selected) {
ImGui::SameLine();
std::string removeLabel = "Remove##edge" +
std::to_string(i);
if (ImGui::SmallButton(removeLabel.c_str())) {
rg.removeEdge(i);
rs->setSelectedEdgeIndex(-1);
}
}
}
/* 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();
}
}
/* 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;