From 50c2395581d1cf6d05e46e1e3fc294c1e3671ce2 Mon Sep 17 00:00:00 2001 From: Sergey Lapin Date: Sun, 19 Jul 2026 22:44:52 +0300 Subject: [PATCH] Updating mid-implementation of M5.2 --- src/features/editScene/CMakeLists.txt | 5 + src/features/editScene/TerrainRequirements.md | 483 ++++++++----- .../editScene/components/RoadGraph.hpp | 647 ++++++++++++++++++ src/features/editScene/components/Terrain.hpp | 48 +- src/features/editScene/gizmo/RoadGizmo.cpp | 262 +++++++ src/features/editScene/gizmo/RoadGizmo.hpp | 92 +++ .../editScene/systems/EditorUISystem.cpp | 338 ++++++++- .../editScene/systems/EditorUISystem.hpp | 22 + src/features/editScene/systems/RoadSystem.cpp | 382 +++++++++++ src/features/editScene/systems/RoadSystem.hpp | 90 +++ .../editScene/systems/SceneSerializer.cpp | 44 +- .../editScene/systems/SceneSerializer.hpp | 9 +- .../editScene/systems/TerrainSystem.cpp | 445 +++++++++++- .../editScene/systems/TerrainSystem.hpp | 47 ++ .../editScene/systems/TerrainTests.cpp | 391 ++++++++++- .../editScene/systems/TerrainTests.hpp | 3 + src/features/editScene/ui/TerrainEditor.hpp | 436 +++++++++++- 17 files changed, 3515 insertions(+), 229 deletions(-) create mode 100644 src/features/editScene/components/RoadGraph.hpp create mode 100644 src/features/editScene/gizmo/RoadGizmo.cpp create mode 100644 src/features/editScene/gizmo/RoadGizmo.hpp create mode 100644 src/features/editScene/systems/RoadSystem.cpp create mode 100644 src/features/editScene/systems/RoadSystem.hpp diff --git a/src/features/editScene/CMakeLists.txt b/src/features/editScene/CMakeLists.txt index 5551dcf..f6b56b8 100644 --- a/src/features/editScene/CMakeLists.txt +++ b/src/features/editScene/CMakeLists.txt @@ -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 diff --git a/src/features/editScene/TerrainRequirements.md b/src/features/editScene/TerrainRequirements.md index 7a93961..2bdeccf 100644 --- a/src/features/editScene/TerrainRequirements.md +++ b/src/features/editScene/TerrainRequirements.md @@ -118,20 +118,9 @@ struct TerrainComponent { }; std::vector 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 roadNodes; - std::vector 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 &onConfirm) +{ + static std::unordered_map > 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//heightmap.bin` → - `heightmaps//heightmap_.bin` - - `heightmaps//heightmap.bin_blendmaps/` → - `heightmaps//heightmap.bin_blendmaps_backup_/` -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//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//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//` → + `heightmaps//_.bin` + - `heightmaps//_blendmaps/` → + `heightmaps//_blendmaps_backup_/` +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//_blendmaps_backup_/`. +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 `_.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 sidePrefabs; -}; - -std::vector roadNodes; -std::vector 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 nodes; + std::vector edges; + + const RoadNode *findNodeById(int id) const; + int findNodeIndexById(int id) const; + std::vector findEdgeIndicesForNode(int nodeId) const; + std::vector 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 diff --git a/src/features/editScene/components/RoadGraph.hpp b/src/features/editScene/components/RoadGraph.hpp new file mode 100644 index 0000000..cd8f177 --- /dev/null +++ b/src/features/editScene/components/RoadGraph.hpp @@ -0,0 +1,647 @@ +#ifndef EDITSCENE_ROADGRAPH_HPP +#define EDITSCENE_ROADGRAPH_HPP +#pragma once + +#include +#include +#include +#include +#include + +/** + * 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 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 nodes; + + /** All road edges in the graph. Ordering is not significant. */ + std::vector 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 findEdgeIndicesForNode(int nodeId) const + { + std::vector 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 getNeighborIds(int nodeId) const + { + std::vector 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 diff --git a/src/features/editScene/components/Terrain.hpp b/src/features/editScene/components/Terrain.hpp index 3dea548..191a511 100644 --- a/src/features/editScene/components/Terrain.hpp +++ b/src/features/editScene/components/Terrain.hpp @@ -2,6 +2,7 @@ #define EDITSCENE_TERRAIN_HPP #pragma once +#include "RoadGraph.hpp" #include #include #include @@ -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 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 sidePrefabs; - }; - std::vector roadNodes; - std::vector 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 diff --git a/src/features/editScene/gizmo/RoadGizmo.cpp b/src/features/editScene/gizmo/RoadGizmo.cpp new file mode 100644 index 0000000..6b2b66a --- /dev/null +++ b/src/features/editScene/gizmo/RoadGizmo.cpp @@ -0,0 +1,262 @@ +#include "RoadGizmo.hpp" +#include + +// 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; +} diff --git a/src/features/editScene/gizmo/RoadGizmo.hpp b/src/features/editScene/gizmo/RoadGizmo.hpp new file mode 100644 index 0000000..4decf6b --- /dev/null +++ b/src/features/editScene/gizmo/RoadGizmo.hpp @@ -0,0 +1,92 @@ +#ifndef EDITSCENE_ROADGIZMO_HPP +#define EDITSCENE_ROADGIZMO_HPP +#pragma once + +#include +#include + +/** + * 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 diff --git a/src/features/editScene/systems/EditorUISystem.cpp b/src/features/editScene/systems/EditorUISystem.cpp index 0781554..75835f3 100644 --- a/src/features/editScene/systems/EditorUISystem.cpp +++ b/src/features/editScene/systems/EditorUISystem.cpp @@ -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(m_sceneMgr); + m_roadGizmo = std::make_unique(m_sceneMgr); m_cursor3D = std::make_unique(m_sceneMgr); m_serializer = std::make_unique(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()) + 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()) { + m_roadGizmo->setVisible(false); + return; + } + + const TerrainComponent &tc = terrain.get(); + 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()) + return; + + auto &tc = terrain.get_mut(); + 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()) + return -1; + + const TerrainComponent &tc = terrain.get(); + 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()) + return -1; + + const TerrainComponent &tc = terrain.get(); + 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()) + return; + + auto &tc = terrain.get_mut(); + Ogre::Vector3 snapped = snapRoadNodePosition(pos); + int newId = tc.roadGraph.addNode(snapped, 0.0f); + + rs->setSelectedNodeId(newId); + rs->setSelectedEdgeIndex(-1); + syncRoadGizmoToSelection(); +} diff --git a/src/features/editScene/systems/EditorUISystem.hpp b/src/features/editScene/systems/EditorUISystem.hpp index f213691..eeff804 100644 --- a/src/features/editScene/systems/EditorUISystem.hpp +++ b/src/features/editScene/systems/EditorUISystem.hpp @@ -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 m_allEntities; std::unique_ptr m_gizmo; + std::unique_ptr m_roadGizmo; std::unique_ptr m_cursor3D; std::unique_ptr 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 m_nameQuery; diff --git a/src/features/editScene/systems/RoadSystem.cpp b/src/features/editScene/systems/RoadSystem.cpp new file mode 100644 index 0000000..efbd4aa --- /dev/null +++ b/src/features/editScene/systems/RoadSystem.cpp @@ -0,0 +1,382 @@ +#include "RoadSystem.hpp" +#include "../components/Terrain.hpp" +#include "../components/Transform.hpp" +#include +#include + +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()) { + 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()) { + if (m_lastGraphVersion != 0) { + m_lastGraphVersion = 0; + rebuildVisualAids(); + } + return; + } + + const TerrainComponent &tc = terrain.get(); + 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()) + return Ogre::Vector3::ZERO; + + const auto &rg = terrain.get().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()) + return false; + + const auto &rg = terrain.get().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()) { + setVisualAidsVisible(m_visualAidsVisible); + return; + } + + const TerrainComponent &tc = terrain.get(); + 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()) + return; + + const RoadGraph &rg = terrain.get().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()) + return; + + const RoadGraph &rg = terrain.get().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()) + return; + + const RoadGraph &rg = terrain.get().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(); +} diff --git a/src/features/editScene/systems/RoadSystem.hpp b/src/features/editScene/systems/RoadSystem.hpp new file mode 100644 index 0000000..b61f45e --- /dev/null +++ b/src/features/editScene/systems/RoadSystem.hpp @@ -0,0 +1,90 @@ +#ifndef EDITSCENE_ROADSYSTEM_HPP +#define EDITSCENE_ROADSYSTEM_HPP +#pragma once + +#include +#include +#include +#include +#include + +/** + * 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 diff --git a/src/features/editScene/systems/SceneSerializer.cpp b/src/features/editScene/systems/SceneSerializer.cpp index 7dc512f..f675bf0 100644 --- a/src/features/editScene/systems/SceneSerializer.cpp +++ b/src/features/editScene/systems/SceneSerializer.cpp @@ -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); } } diff --git a/src/features/editScene/systems/SceneSerializer.hpp b/src/features/editScene/systems/SceneSerializer.hpp index 70d4940..c2f2173 100644 --- a/src/features/editScene/systems/SceneSerializer.hpp +++ b/src/features/editScene/systems/SceneSerializer.hpp @@ -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); diff --git a/src/features/editScene/systems/TerrainSystem.cpp b/src/features/editScene/systems/TerrainSystem.cpp index 8d54ad4..367d7fc 100644 --- a/src/features/editScene/systems/TerrainSystem.cpp +++ b/src/features/editScene/systems/TerrainSystem.cpp @@ -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 resampleFloatMap(const std::vector &src, + int srcRes, int dstRes) +{ + if (srcRes <= 1 || dstRes <= 1 || (int)src.size() != srcRes * srcRes) + return std::vector(dstRes * dstRes, 0.0f); + + if (srcRes == dstRes) + return src; + + std::vector 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 oldHeightData; + int oldRes = tc.heightmapSize; + { + std::lock_guard 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(&fileRes), + sizeof(fileRes)); + if (fileRes >= 2 && fileRes <= 8192) { + oldRes = (int)fileRes; + oldHeightData.resize(oldRes * oldRes); + file.read(reinterpret_cast( + 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(&oldResU), + sizeof(oldResU)); + backupHmFile.write(reinterpret_cast( + 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 newHeightData = + resampleFloatMap(oldHeightData, oldRes, newResolution); + { + std::lock_guard 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 auxData(aux.resolution * aux.resolution, + aux.defaultValue); + std::ifstream auxFile(auxPath, std::ios::binary); + if (auxFile) { + auxFile.read(reinterpret_cast(auxData.data()), + auxData.size() * sizeof(float)); + if (!auxFile) + std::fill(auxData.begin(), auxData.end(), + aux.defaultValue); + } + + std::vector newAuxData = resampleFloatMap( + auxData, aux.resolution, newResolution); + aux.resolution = newResolution; + + std::ofstream auxOut(auxPath, std::ios::binary); + if (auxOut) { + auxOut.write(reinterpret_cast( + 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(&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(&sz), sizeof(sz)); + f.read(reinterpret_cast(&layer), sizeof(layer)); + if ((int)sz != oldBlendMapSize) + continue; + std::vector bmData(oldBlendMapSize * + oldBlendMapSize); + f.read(reinterpret_cast(bmData.data()), + bmData.size() * sizeof(float)); + std::vector 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(&newSz), + sizeof(newSz)); + out.write( + reinterpret_cast(&layer), + sizeof(layer)); + out.write(reinterpret_cast( + 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 data; + uint8_t layer = 0; + }; + std::vector 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(&sz), sizeof(sz)); + f.read(reinterpret_cast(&layer), sizeof(layer)); + if (oldBlendMapSize < 0 && sz >= 2 && sz <= 8192) + oldBlendMapSize = (int)sz; + if ((int)sz != oldBlendMapSize) + continue; + std::vector bmData(oldBlendMapSize * + oldBlendMapSize); + f.read(reinterpret_cast(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 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(&newSz), + sizeof(newSz)); + out.write(reinterpret_cast( + &bm.layer), + sizeof(bm.layer)); + out.write(reinterpret_cast( + 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(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; } diff --git a/src/features/editScene/systems/TerrainSystem.hpp b/src/features/editScene/systems/TerrainSystem.hpp index f098eff..e9f9ccc 100644 --- a/src/features/editScene/systems/TerrainSystem.hpp +++ b/src/features/editScene/systems/TerrainSystem.hpp @@ -31,6 +31,7 @@ #include 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 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; diff --git a/src/features/editScene/systems/TerrainTests.cpp b/src/features/editScene/systems/TerrainTests.cpp index 227e0b0..9c8623d 100644 --- a/src/features/editScene/systems/TerrainTests.cpp +++ b/src/features/editScene/systems/TerrainTests.cpp @@ -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().each( + [&](flecs::entity e, TerrainComponent &, TransformComponent &) { + if (!foundEntity) { + terrainEntity = e; + foundEntity = true; + } + }); + + if (!foundEntity || !terrainEntity.is_alive() || + !terrainEntity.has()) { + Ogre::LogManager::getSingleton().logMessage( + "TerrainTests: FAIL - no terrain entity for resolution change"); + return false; + } + + auto &tc = terrainEntity.get_mut(); + 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(tc); + } + + SceneSerializer serializer(*w, app.getSceneManager()); + nlohmann::json terrainJson = serializer.serializeTerrain(e); + + flecs::entity e2 = w->entity(); + e2.set(TerrainComponent()); + serializer.deserializeTerrain(e2, terrainJson); + + if (!e2.has()) { + Ogre::LogManager::getSingleton().logMessage( + "TerrainTests: FAIL - deserializeTerrain removed TerrainComponent"); + return false; + } + + const TerrainComponent &tc2 = e2.get(); + 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 }, }; diff --git a/src/features/editScene/systems/TerrainTests.hpp b/src/features/editScene/systems/TerrainTests.hpp index f9cc9e3..214f30e 100644 --- a/src/features/editScene/systems/TerrainTests.hpp +++ b/src/features/editScene/systems/TerrainTests.hpp @@ -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); diff --git a/src/features/editScene/ui/TerrainEditor.hpp b/src/features/editScene/ui/TerrainEditor.hpp index 20010b1..c06f0c7 100644 --- a/src/features/editScene/ui/TerrainEditor.hpp +++ b/src/features/editScene/ui/TerrainEditor.hpp @@ -5,6 +5,7 @@ #include "ComponentEditor.hpp" #include "../components/Terrain.hpp" #include "../systems/TerrainSystem.hpp" +#include "../systems/RoadSystem.hpp" #include #include @@ -14,7 +15,9 @@ #include #include #include +#include #include +#include #include /** @@ -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 &onConfirm) + { + static std::unordered_map > + 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 &getTextureList() { static std::vector s_textures;