Updating mid-implementation of M5.2
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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.1–M4.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 ¤t,
|
||||
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
|
||||
@@ -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
|
||||
|
||||
@@ -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 ¢er,
|
||||
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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 ¤t,
|
||||
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;
|
||||
|
||||
Reference in New Issue
Block a user