From 6e71f3639bc2eb119edc58b28373206d757e2131 Mon Sep 17 00:00:00 2001 From: Sergey Lapin Date: Sat, 11 Jul 2026 18:53:15 +0300 Subject: [PATCH] Updated the plan for road generation. --- src/features/editScene/TerrainRequirements.md | 1057 ++++++++++++++++- 1 file changed, 1031 insertions(+), 26 deletions(-) diff --git a/src/features/editScene/TerrainRequirements.md b/src/features/editScene/TerrainRequirements.md index dc4b039..18b0d6c 100644 --- a/src/features/editScene/TerrainRequirements.md +++ b/src/features/editScene/TerrainRequirements.md @@ -874,9 +874,11 @@ Floating-point errors at wedge boundaries can create visible gaps. Mitigation: For each wedge and end cap: 1. Create a `TriangleBufferComponent` entity as a child of the terrain entity. -2. The buffer is built by `ProceduralMeshSystem` during its update pass. -3. `ProceduralMeshSystem::convertToMesh()` creates the Ogre mesh from the buffer. -4. Apply the road material: +2. Add `TransformComponent` and `NavMeshGeometrySource` so the road surface is + included in navigation mesh generation. +3. The buffer is built by `ProceduralMeshSystem` during its update pass. +4. `ProceduralMeshSystem::convertToMesh()` creates the Ogre mesh from the buffer. +5. Apply the road material: - Set `materialEntity` reference on the `TriangleBufferComponent`. - `ProceduralMaterialSystem` resolves the material at mesh creation time. @@ -888,13 +890,15 @@ For each wedge and end cap: - Meshes beyond `roadVisibilityDistance` are culled entirely via `Ogre::Entity::setRenderingDistance()`. -**Step 7 — Physics colliders** +**Step 7 — Physics colliders and navigation mesh** - For each road mesh, generate a `JPH::MeshShape` matching the visible geometry. - The collider is attached as a static body in the NON_MOVING layer. - Colliders are created/removed in sync with mesh visibility (same distance thresholds). - Create colliders from joined geometry. +- The same generated road geometry is tagged as `NavMeshGeometrySource` so that + `NavMeshSystem` rebuilds the affected tiles when a terrain page loads/unloads. **Step 8 — Wedge joining (optimization)** @@ -1273,10 +1277,10 @@ If `m_active` but no entity carries `TerrainComponent`, `deactivate()` is called. Without this, deleting the entity or removing the component leaves terrain and colliders orphaned. -### Milestone 3 — Serialization + heightmap editing + splat painting ✅ (2026-07-05) +### Milestone 3 — Serialization + heightmap editing + splat painting ✅ (completed 2026-07-08) -**Status**: Heightmap sculpting and splat painting are two independent, mutually -exclusive modes. Enabling either mode automatically finishes the other: +**Status**: DONE. Heightmap sculpting and splat painting are two independent, +mutually exclusive modes. Enabling either mode automatically finishes the other: - **Paint → Sculpt**: `setSculptMode(true)` flips `m_paintMode = false`, then calls `beginSculptPreviews()` (unloads terrain, creates ManualObject proxies). @@ -1286,6 +1290,19 @@ exclusive modes. Enabling either mode automatically finishes the other: Each mode has its own brush state (radius, strength, tool/layer). Blend maps persist via binary save/load (`getBlendPointer`). +**Results / final state**: +- Scene save/load round-trip preserves terrain geometry, layers, and painted + blend maps. +- Splat painting is visible on the terrain surface and updates both the + blend-map texture (close-up) and the composite map (distance). +- Painting near page edges and with brushes that cross page boundaries no + longer crashes; out-of-bounds image coordinates are clamped per-page. +- Adding layers in the editor keeps the default base layer intact and adds + new textures as splat-mapped layers on top. +- Layer names are serialized and shown in the paint-layer selector. +- All terrain tests pass: create, sculpt, splat (centre + edge + GPU readback), + save/erase/reactivate/reload, verify, delete. + **Verified**: - [x] Scene serialization (save/load JSON round-trip for TerrainComponent) - [x] Binary heightmap file save/load @@ -1308,12 +1325,16 @@ persist via binary save/load (`getBlendPointer`). - [x] Splat paint: command queue uses visual world coords - [x] Splat paint: blend map save/load via `getBlendPointer()` - [x] Splat paint: save/load round-trip verification in testSplatOperations +- [x] Splat paint: save/load round-trip uses full deactivate/reactivate cycle - [x] Blend map save wired into SceneSerializer + load wired into activate() +- [x] Blend maps restored correctly when a saved scene is loaded - [x] Save/Load Blend Maps buttons in TerrainEditor UI - [x] **Multi-page splat painting**: brush radius that crosses page boundaries writes to every touched page +- [x] **Page-edge splat painting**: strokes near page boundaries stay in-bounds and update the correct pages - [x] **Erase brush**: negative paint strength subtracts blend weight (verified in tests) - [x] **Texture selection UI**: combo + browse modal for diffuse/normal textures on each layer - [x] **Layer names**: `TerrainComponent::Layer.name` serialized and shown in paint-layer selector +- [x] **Add Layer preserves base**: adding the first layer to an empty list inserts the default base first - [x] **Texture thumbnails**: layer editor attempts to load the selected texture and display a small GPU thumbnail (OpenGL `GLID`); falls back to a coloured swatch - [x] **ESC exits sculpt/paint mode** from `EditorApp::keyPressed` - [x] **Robust resource paths**: LuaScripts package/filesystem fallbacks work from build root or binary subdirectory @@ -1361,12 +1382,6 @@ cd build-vscode/src/features/editScene 12. Clicking **Add Layer** on a terrain whose `layers` list was empty replaced the implicit default base with the new texture, so the ground changed instead of gaining a splat-mapped layer. The editor now inserts the default base layer (`Ground23_col.jpg` / `Ground23_normheight.dds`) first when the list is empty, so the new texture always becomes a blendable layer on top. 13. Saved splat-paint blend maps were not restored when a scene was loaded. `TerrainSystem::activate()` set `m_active = true` *after* calling `loadSceneBlendMaps()`, but `loadBlendMaps()` early-returns when `m_active` is false. Moved `m_active = true` before the blend-map restore so saved layers load correctly. -**Not yet implemented / follow-up**: -- [ ] Headless test mode -- [ ] Procedural detail noise / aux-map editing -- [ ] Paint brush shape presets (currently hard-coded linear falloff) -- [ ] Batch composite-map updates instead of one `updateCompositeMap()` per stroke - **Files modified for M3 (2026-07-05)**: - `main.cpp` — `--run-terrain-tests=N` flag, `TerrainTestFrameListener` - `systems/TerrainSystem.hpp` — paint mode state, `setSculptMode`/`setSculptTool` simplified, blend map API, multi-page brush helpers @@ -1380,21 +1395,1008 @@ 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 — Procedural roads -- Node/edge editing UI (add, remove, split, join, select, move). -- Road configuration parameters UI (lane width, template, material). -- Wedge geometry generation using the road mesh template and - `Procedural::TriangleBuffer`. -- End cap generation for endpoints. -- Seam suppression via vertex shifting. -- LOD + visibility distance per road mesh. -- Road physics colliders (JPH::MeshShape per road mesh). -- "Comply terrain to roads" writing fixup chunks with smooth falloff. +### Milestone 4 — Miscellaneous terrain improvements -Definition of done: user can draw a road network; road geometry appears with -correct lanes; terrain flattens under roads with no visible gaps. +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 +files to touch, the data that must be serialized, and the verification steps. -### Milestone 5 — Prefab spawns and terrain compliance +#### M4.1 Headless terrain test mode + +**Goal**: the existing `--run-terrain-tests=N` suite can run as part of an +automated CMake build on a machine without a display server / GPU. + +Tests that absolutely require UI or GPU (e.g. visual blend-map readback) should +be skipped in headless mode. CPU-only variations that verify the same logic +(e.g. heightmap writes, command queue state, serialization round-trips) should +be added so the suite remains meaningful. + +**Current limitation**: `TerrainSystem::activate()` creates `Ogre::TerrainGroup`, +which uploads textures and derived data to the GPU. The tests therefore require +an active Ogre render window and a valid GL/Vulkan context. + +**Primary approach — offscreen GL context**: + +1. Add a new command-line flag `--headless` in `src/features/editScene/main.cpp`. + - When set, skip audio initialization, skip the editor UI, and request the + smallest possible offscreen render target. +2. Add helper `EditorApp::createHeadlessWindow()`. + - First try `SDL_CreateWindow` with the `SDL_WINDOW_HIDDEN` flag and a 1x1 + size, then create the GL context through Ogre's normal render window path. + - If SDL/Ogre cannot create a context without a display, fall back to an + OSMesa-backed offscreen context when OSMesa is detected at compile time + (guard with `#ifdef HAS_OSMESA` or a CMake option; do not make OSMesa a + hard dependency). + - Keep `m_window` valid because `Ogre::Root::createRenderWindow` needs a + platform window on most render systems. +3. Add CMake test target `editSceneTerrainTest` in + `src/features/editScene/CMakeLists.txt`: + ```cmake + add_test(NAME editSceneTerrainTest + COMMAND editSceneEditor --headless --run-terrain-tests=1 + WORKING_DIRECTORY ${CMAKE_CURRENT_BINARY_DIR}) + set_tests_properties(editSceneTerrainTest PROPERTIES RUN_SERIAL TRUE) + ``` +4. Make `TerrainTestRunner::run()` return a non-zero exit code on failure + (it already counts failures; wire the return value to `main()`). + +**Alternative approach — CPU-only unit tests** (use if offscreen GL is unstable): + +1. Create a separate test executable `terrain_unit_test` that links only: + - `systems/TerrainSystem.cpp` stubbed to skip Ogre/Jolt initialization, or + - a new `systems/TerrainLogicTests.cpp` that tests pure functions such as + `worldToPage`, `physicalToVisualX/Z`, brush falloff math, serialization + round-trips, and heightmap bilinear sampling. +2. Register it with `add_test(NAME editSceneTerrainUnitTest ...)`. +3. The GPU-dependent integration tests remain runnable manually via + `--run-terrain-tests=N` on a workstation. + +**Decision rule**: implement the offscreen-GL primary approach first because it +exercises the real `TerrainSystem` code path. If CI becomes flaky due to driver +issues, add the CPU-only tests as a fallback and mark the GL test as +`WILL_FAIL` or skip it when `DISPLAY` is absent. + +**Headless test mode changes inside `TerrainTests.cpp`**: + +- Add a global `bool g_headless = false;` set from `main()` via + `TerrainTestRunner::setHeadless(true)`. +- In each test that reads GPU blend-map bytes (`readGPUBlendByte`), early-out + with `return true` when `g_headless` is true and instead verify that the + command queue contains the expected paint command and that the CPU-side + blend-map cache (if any) has the correct value. +- Keep create/sculpt/serialize/delete tests running because they do not require + GPU readback. + +**Definition of done**: +- [ ] `ctest -R editSceneTerrainTest` passes on a machine with a display. +- [ ] The same command can run inside a Docker/CI container with + `LIBGL_ALWAYS_SOFTWARE=1` and no `DISPLAY` variable (software Mesa). +- [ ] The test executable exits with code 0 when all terrain tests pass and + code 1 when a test step is forced to fail. +- [ ] GPU-readback tests are skipped in headless mode instead of crashing. + +--- + +#### M4.2 Procedural detail noise + +**Goal**: add optional procedural detail noise on top of the base heightmap so +empty terrain is not perfectly flat, while keeping every parameter serializable +and editable at runtime. + +There is currently no noise support in `TerrainSystem`; this item adds it from +scratch. + +**Component changes** (`src/features/editScene/components/Terrain.hpp`): + +```cpp +struct TerrainComponent { + ... + struct DetailNoise { + bool enabled = false; + int seed = 0; + int octaves = 4; + float frequency = 0.001f; // world-space frequency + float amplitude = 10.0f; // maximum height contribution + float lacunarity = 2.0f; + float persistence = 0.5f; + }; + DetailNoise detailNoise; +}; +``` + +**Runtime integration** (`src/features/editScene/systems/TerrainSystem.cpp`): + +1. Add a private helper to `TerrainSystem`: + ```cpp + float computeDetailNoise(long worldX, long worldZ, + const TerrainComponent::DetailNoise &n) const; + ``` +2. Implement it using the `FastNoiseLite` header already vendored in + `src/FastNoiseLite/`. Initialize one `FastNoiseLite` instance per unique + `(seed, frequency)` pair (or recreate on parameter change). Evaluate + layered octaves with `lacunarity` and `persistence`: + ```cpp + float sum = 0.0f; + float amp = n.amplitude; + float freq = n.frequency; + for (int i = 0; i < n.octaves; ++i) { + noise.SetFrequency(freq); + // FastNoiseLite returns values in approximately [-1, 1]. + sum += noise.GetNoise((float)worldX, (float)worldZ) * amp; + amp *= n.persistence; + freq *= n.lacunarity; + } + return sum; + ``` +3. In `fillPageHeightData()` and `sampleHeightAtLocked()`, after the base + heightmap value is computed (and before any road fixups), add: + ```cpp + if (tc.detailNoise.enabled) + h += computeDetailNoise(wx, wz, tc.detailNoise); + ``` + Road fixups, when implemented later, must override the combined base + + noise value so roads stay flat. +4. The noise is deterministic: the same `(worldX, worldZ, seed)` always returns + the same value. +5. Noise is **not** baked into `m_heightData` / `heightmap.bin`. It is + evaluated on demand so changing parameters does not destroy sculpted data. + +**Editor changes** (`src/features/editScene/ui/TerrainEditor.hpp`): + +Add a collapsible "Detail Noise" section with: +- `Checkbox("Enabled", &tc.detailNoise.enabled)` +- `SliderInt("Seed", &tc.detailNoise.seed, 0, 100000)` +- `SliderInt("Octaves", &tc.detailNoise.octaves, 1, 8)` +- `SliderFloat("Frequency", &tc.detailNoise.frequency, 0.0001f, 0.01f)` +- `SliderFloat("Amplitude", &tc.detailNoise.amplitude, 0.0f, 200.0f)` +- `SliderFloat("Lacunarity", &tc.detailNoise.lacunarity, 1.0f, 4.0f)` +- `SliderFloat("Persistence", &tc.detailNoise.persistence, 0.0f, 1.0f)` + +When any value changes, set `tc.dirty = true` so the next `update()` rebuilds +pages. No terrain restart is required. + +**Serialization** (`src/features/editScene/systems/SceneSerializer.cpp`): + +```cpp +auto &n = tc.detailNoise; +json["detailNoise"] = { + {"enabled", n.enabled}, + {"seed", n.seed}, + {"octaves", n.octaves}, + {"frequency", n.frequency}, + {"amplitude", n.amplitude}, + {"lacunarity", n.lacunarity}, + {"persistence", n.persistence} +}; +``` + +Deserialize with `json.value(...)` defaults matching the component defaults. + +**Definition of done**: +- [ ] Enabling detail noise in the editor immediately changes the terrain + surface without resetting sculpted heights. +- [ ] Changing seed/frequency/amplitude and saving/reloading the scene restores + the identical surface. +- [ ] Disabling noise returns exactly to the base heightmap surface. +- [ ] Noise is included in the `TerrainComponent` JSON round-trip test. + +--- + +#### M4.3 Aux-map editing + +**Goal**: let the user create, remove, paint, and delete auxiliary 2D maps +(e.g. foliage density, rock mask, wetness) that are stored alongside the +heightmap and serialized with the scene. + +**Component state** (`TerrainComponent::AuxMap` already exists): + +```cpp +struct AuxMap { + std::string name; + std::string fileName; + int resolution = 256; + float defaultValue = 0.0f; +}; +std::vector auxMaps; +``` + +No component change is required, but the runtime must actually use them. + +**Storage path**: +- `heightmaps//` — raw binary + `resolution * resolution * sizeof(float)`, row-major. +- Default aux map created on first use if the file does not exist, filled with + `defaultValue`. + +**Runtime API** (`src/features/editScene/systems/TerrainSystem.hpp/.cpp`): + +```cpp +// Paint into an aux map at world position (wx, wz). Delta is signed. +void applyAuxBrush(const std::string &auxMapName, + const Ogre::Vector3 &worldPos, + float radius, float delta); + +// Sample an aux map in world space; returns defaultValue if missing. +float sampleAuxMap(const std::string &auxMapName, + long worldX, long worldZ) const; + +// Save/load all aux maps for the active terrain. +bool saveSceneAuxMaps(const TerrainComponent &tc) const; +bool loadSceneAuxMaps(const TerrainComponent &tc); +``` + +Implementation notes: +- World-to-texel conversion mirrors the base heightmap mapping: the aux map + covers the same world extents as the base heightmap (see section 4.1). +- Brush falloff reuses the configured brush shape from M4.4. +- Values are clamped to `[0, 1]` for density-style aux maps. Aux maps that + represent signed data (e.g. displacement) can opt out via a future flag; for + M4 clamp to `[0, 1]`. +- Painting marks only the aux map dirty; it does **not** mark terrain geometry + dirty unless the aux map is later consumed by geometry (not in this + milestone). + +**Editor changes** (`src/features/editScene/ui/TerrainEditor.hpp`): + +Add a new "Aux Maps" section: +- List current aux maps with name, resolution, default value, file name. +- "Add Aux Map" button: opens a small modal asking for `name`. The file name + defaults to `.bin` (sanitized to remove path separators), resolution + defaults to `tc.heightmapSize`, default value defaults to `0.0f`. +- "Remove Aux Map" button: removes the selected entry from the component and + deletes its binary file after a confirmation modal. +- "Paint Aux Map" mode: + - Add a combo listing aux map names; the selected map is the paint target. + - When active, use the existing brush radius and the paint-strength slider + as the signed delta passed to `applyAuxBrush()`. + - Aux-paint mode must be mutually exclusive with sculpt and splat paint + modes: activating aux-paint calls `setSculptMode(false)` and + `setPaintMode(false)` first. + - Dispatch from `EditorUISystem::update()` only when aux-paint mode is active + and the left mouse button is held, exactly like sculpt/splat painting. + +**Serialization** (`SceneSerializer.cpp`): + +`auxMaps` array is already part of the schema in section 8. Ensure it is read +and written. + +**Definition of done**: +- [ ] User can add a "foliage_density" aux map and paint high-density patches. +- [ ] `saveSceneAuxMaps()` writes the binary file; loading the scene restores + it via `loadSceneAuxMaps()`. +- [ ] Removing an aux map deletes the file and removes it from the component. +- [ ] Painting respects the configured brush shape (M4.4) and clamps to + `[0, 1]`. + +--- + +#### M4.4 Paint brush shape presets + +**Goal**: replace the hard-coded `1 - dist/radius` linear falloff with a +configurable brush shape used by sculpt, splat, and aux-map brushes. + +**Component / state changes**: + +No new `TerrainComponent` fields — the brush shape is a runtime editor +preference and is **not** serialized with the scene. Add a runtime-only shape +enum to `TerrainSystem`: + +```cpp +enum class BrushFalloffShape { + Linear, // 1 - t, zero at edge + Smoothstep, // 1 - (3t^2 - 2t^3) + Gaussian, // exp(-4 t^2) + Spherical, // sqrt(1 - t^2) + Cone // 1 inside radius, 0 at edge +}; +``` + +Add getters/setters: + +```cpp +BrushFalloffShape getBrushFalloffShape() const; +void setBrushFalloffShape(BrushFalloffShape s); +``` + +**Brush evaluation helper**: + +```cpp +float evalBrushFalloff(float distance, float radius, BrushFalloffShape shape); +``` + +Behaviour for `t = distance / radius`, `t` clamped to `[0, 1]`. The helper +must return `0.0f` when `t >= 1.0f` (outside the brush radius) for all shapes: +- `Linear`: `1 - t` +- `Smoothstep`: `1 - (3t^2 - 2t^3)` (full at centre, zero at edge) +- `Gaussian`: `exp(-4.0f * t * t)` (≈0.018 at edge) +- `Spherical`: `sqrt(max(0.0f, 1 - t*t))` +- `Cone`: `1.0f` for `t < 1.0f`, `0.0f` at `t >= 1.0f` + +Replace the inline falloff calculations in: +- `TerrainSystem::applySculptBrush()` (raise/lower path) +- `TerrainSystem::applySplatBrush()` +- `TerrainSystem::applyAuxBrush()` (M4.3) + +**Editor changes** (`TerrainEditor`): + +Add a "Brush Shape" combo shared by Sculpt and Paint sections: + +```cpp +const char *shapes[] = {"Linear", "Smoothstep", "Gaussian", "Spherical", "Cone"}; +int shape = (int)ts->getBrushFalloffShape(); +if (ImGui::Combo("Brush Shape", &shape, shapes, IM_ARRAYSIZE(shapes))) + ts->setBrushFalloffShape((TerrainSystem::BrushFalloffShape)shape); +``` + +Place it above the Sculpt section so it is visible in both modes. + +**Definition of done**: +- [ ] Changing the brush shape visibly changes the profile of a sculpt raise + stroke. +- [ ] Changing the brush shape visibly changes the edge softness of splat + paint strokes. +- [ ] Each shape is deterministic and produces identical results on identical + inputs. + +--- + +#### M4.5 Batched composite-map updates + +**Goal**: avoid calling `Ogre::Terrain::updateCompositeMap()` once per affected +page on every brush stroke; batch updates to one composite-map rebuild per +frame per page. + +**Current behaviour**: `applySplatBrush()` calls `terrain->updateCompositeMap()` +inside the page loop for every stroke. + +**New behaviour**: + +1. Add a new member to `TerrainSystem`: + ```cpp + std::set m_compositeDirtyPages; + ``` +2. In `applySplatBrush()`, after calling `blendMap->update()` on a page, insert + the packed page key into `m_compositeDirtyPages` using + `mTerrainGroup->packIndex(px, py)`. **Do not** call `updateCompositeMap()` + here. +3. At the end of `TerrainSystem::update()`, after `rebuildDirtyPages()` and + collider processing, call: + ```cpp + if (mTerrainGroup) { + for (uint64_t key : m_compositeDirtyPages) { + long px, py; + mTerrainGroup->unpackIndex(key, &px, &py); + Ogre::Terrain *t = mTerrainGroup->getTerrain(px, py); + if (t) + t->updateCompositeMap(); + } + } + m_compositeDirtyPages.clear(); + ``` +4. Same batching for any other code that dirties blend maps (aux-map painting + if it ever touches terrain materials; not required for M4). + +**Thread safety**: `applySplatBrush()` runs on the main thread; +`m_compositeDirtyPages` is only touched from the main thread, so no lock is +needed. + +**Definition of done**: +- [ ] Painting a large brush that touches four pages calls + `updateCompositeMap()` exactly four times in the next frame, not four + times per mouse-drag event. +- [ ] Distance rendering of splat-painted terrain is still correct after the + batched update. + +--- + +#### M4.6 Changeable heightmap resolution + +**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. + +**UI changes** (`TerrainEditor`): + +Replace the read-only "Heightmap: N x N" label with: + +```cpp +const int resolutions[] = {256, 512, 1024, 2048, 4096}; +int current = tc.heightmapSize; +int idx = 0; +while (idx < 4 && resolutions[idx] != current) ++idx; + +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"); + } +} + +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(); +} +``` + +`changeHeightmapResolution` is responsible for updating `tc.heightmapSize`; +the editor must not set it separately. + +**Implementation** (`TerrainSystem`): + +```cpp +bool changeHeightmapResolution(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`). + +**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. +- 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. + +**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 + 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. + +--- + +### Milestone 4 definition of done + +- [ ] Headless test target is registered with `add_test` and passes in CI. +- [ ] Detail noise parameters are editable in the UI, deterministic, and + serialized. +- [ ] Aux maps can be added, removed, painted, and saved/loaded. +- [ ] Brush shape presets apply to sculpt, splat, and aux-map brushes. +- [ ] 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. + +### Milestone 5 — Procedural roads + +This milestone adds a node-graph road network that is edited visually in the 3D +view, rendered by sweeping a 1-unit mesh template along the graph, and makes the +terrain conform to the road surface without intersecting it. Roads may be +asymmetric: an edge can have a different number of lanes in each direction. + +#### M5.1 Road data model + +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; +``` + +Rules: +- `RoadConfig::laneWidth` is global for the whole terrain. It is **not** + overridden per edge. +- Per-edge lane counts are resolved as: + ```cpp + int la = (edge.lanesAtoB > 0) ? edge.lanesAtoB : tc.roadConfig.lanesPerDirection; + int lb = (edge.lanesBtoA > 0) ? edge.lanesBtoA : tc.roadConfig.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`. + +--- + +#### 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. + +**Road edit mode activation**: +- Add a "Road Edit Mode" checkbox in `TerrainEditor`. +- When enabled, deactivate sculpt/paint/aux-paint modes and hide the brush decal. +- Pass the mode flag to `EditorUISystem` via a new getter + `TerrainEditor::isRoadEditMode()`. + +**Visual aids** (`RoadSystem` owns these `Ogre::ManualObject`s or scene nodes): +- A sphere or small cube marker at every `RoadNode::position`. +- A line strip along every edge from `nodeA` to `nodeB`. +- A translucent quad or disc at the road surface level showing the full road + width (both directions) along each edge. +- The selected node is highlighted in a different color and gets a translate + gizmo. +- The selected edge is highlighted and displays its A->B direction arrow. + +Visual aids are created/destroyed by `RoadSystem` and are not serialized. + +**Mouse interaction in `EditorUISystem::update()`**: +- Track `m_roadMouseDownPos` on `ImGuiMouseButton_Left` press. +- On release, if the mouse moved less than 5 pixels, treat it as a click; + otherwise it was a camera drag. +- In road edit mode, raycast against the terrain using + `TerrainSystem::raycastTerrain()`. +- Click near a node (within `laneWidth` world units): select the node and show + the translate gizmo. +- Click near an edge (within `laneWidth` world units): select the edge. +- Click on empty terrain while "Add Node" is active: create a node snapped to + the terrain surface. + +**Node manipulation**: +- Selected nodes are moved with the existing translate gizmo + (`EditorUISystem::getGizmo()`). +- During gizmo drag, the node's `(x, z)` follows the gizmo; the node's `y` is + recomputed every frame as `terrainHeightAt(x, z) + verticalOffset`. +- The node inspector shows `verticalOffset`, `roadLevel` (computed from the + connected edges), and a "Snap to Terrain" button that recomputes `position.y` + from the current terrain. + +**Toolbar buttons** (visible only in road edit mode): +- Add Node / Move Node (default). +- Remove Selected Node. +- Split Selected Edge. +- Join Selected Nodes (requires exactly two selected nodes). +- "Comply Terrain to Roads". + +**Edge inspector** (when an edge is selected): +- `lanesAtoB` and `lanesBtoA` integer inputs. +- `roadLevelA` and `roadLevelB` sliders. +- Side prefab list with add/remove. + +--- + +#### M5.3 Road mesh template + +The `roadMeshTemplate` mesh is a single Ogre mesh with these conventions (from +section 5.3): + +- It occupies exactly 1 unit along the positive X axis (`X ∈ [0, 1]`). +- Its width (Z extent) is 1 unit (`Z ∈ [0, 1]` or `Z ∈ [-1, 0]`). +- It is centered vertically on `Y = 0`. +- The top surface is at `Y = +roadThickness / 2` and the bottom surface at + `Y = -roadThickness / 2` relative to the local origin. +- UVs span `(0, 0)` to `(1, 1)` over the X/Z extents. + +Default fallback (if the template is missing or empty): generate a box with +local dimensions `1.0 x roadThickness x 1.0`, with the top face at +`Y = +roadThickness/2` and the bottom face at `Y = -roadThickness/2`. The box is +offset so that `Z ∈ [0, 1]` (one lane width in template space corresponds to one +lane width in world space after scaling). + +--- + +#### M5.4 Road geometry generation + +Road geometry is generated by the new `RoadSystem` +(`systems/RoadSystem.hpp/.cpp`). `TerrainSystem` owns the `RoadSystem` and +calls `update()` after `rebuildDirtyPages()`. + +**Generation strategy**: the road surface is built entirely from **wedges**. A +wedge is two half-edge segments that meet at a single node, forming a V-shape. +For each node with 2+ neighbors, consecutive neighbors (after angle sorting) +produce one wedge. Nodes with exactly one neighbor are a special case: they +produce a straight road segment along the single half-edge. + +This approach builds intersections automatically because the wedges that share +a node overlap at the node center; small vertex shifts remove the remaining gap. + +**Page-based output and lifecycle**: Road meshes, colliders, and navmesh +geometry are attached to terrain pages. For each loaded terrain page, generate +one `TriangleBufferComponent` entity containing all wedges that affect that page +(page bounds expanded by `roadVisibilityDistance`). When the terrain page is +unloaded, the corresponding road mesh, collider, spawned prefabs, and navmesh +contribution must be destroyed. Road geometry is regenerated only for pages +that become dirty (road graph changed) or newly loaded. + +**Navigation mesh input**: Road surfaces are walkable, so every generated road +page entity must be discoverable by `NavMeshSystem`. The `TriangleBufferComponent` +entity is tagged with `NavMeshGeometrySource` and given a `TransformComponent` +whose scene node holds the final `Ogre::Entity`, so the existing navmesh geometry +collection picks it up automatically. `RoadSystem` also marks the affected +navmesh tiles dirty whenever road geometry is created, destroyed, or changed. + +**Data structures**: + +```cpp +struct RoadPageGeometry { + long pageX, pageY; + flecs::entity bufferEntity = flecs::entity::null(); + flecs::entity colliderEntity = flecs::entity::null(); + std::vector collisionVertices; + std::vector collisionIndices; + std::vector spawnedPrefabs; +}; +std::unordered_map m_pageGeometry; +``` + +--- + +#### M5.5 Wedge enumeration + +Terminology: +- An **edge** is a connection between two road-graph nodes. Geometrically it is + the segment whose endpoints are the world positions of those two nodes. +- A **half-edge** is one of the two sub-segments that an edge is decomposed into. + For an edge connecting nodes `n1` and `n2`, the two half-edges are: + - the segment from `n1.position` to the midpoint of the edge, and + - the segment from the midpoint of the edge to `n2.position`. + Every edge is composed of exactly two half-edges. + In wedge generation, the half-edge we use starts at the node in question and + ends at the midpoint of the connecting edge. +- A **wedge** is a V-shaped region constructed from two half-edges that start at + the same node and go to two different neighbors, such that no other half-edge + to another node lies between them in angle-sorted order. For a node whose + neighbors are sorted by angle, a wedge is formed by consecutive neighbors + `n` and `n+1` (with wrap-around). +- A **node with a single connection** is a road node that is connected by an + edge to exactly one other node. +- A **node with multiple connections** is a road node that is connected by edges + to two or more other nodes. + +For every node: + +1. Collect all connected half-edges (`node -> neighbor`). +2. Sort them by angle around the world Y axis using `atan2(dz, dx)`. +3. Determine lane counts for each half-edge: + - For half-edge `node -> neighbor` belonging to edge `(node, neighbor)`: + - If the edge stores `node = nodeA, neighbor = nodeB`, the **outbound** + 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`. + +**Node with multiple connections** (a node which connects by edges to multiple +other nodes, i.e. 2+ half-edges): +- For each consecutive pair of sorted half-edges `(H_i, H_{i+1})`, with wrap + around from the last to the first, create one wedge. +- The wedge covers the V-shaped region bounded by the two half-edges. + +**Node with a single connection** (a node which is connected by edge to just one +other node, i.e. exactly one half-edge): +- Create one straight segment along the single half-edge. This is handled as a + degenerate 180° wedge or as a separate straight-segment primitive. + +**Angle validation**: +- Compute the smaller angle between the two half-edges of a wedge. +- If the angle is < 30° or > 270°, the wedge is degenerate. + - For < 30°: optionally split the angle by inserting a temporary midpoint + vertex or reject the operation in the editor (preferred: constrain the graph + so users cannot create such sharp turns). + - For > 270°: skip the wedge and log a warning. + +--- + +#### M5.6 Wedge geometry generation + +A wedge is generated by sweeping the 1-unit template mesh inside the V-shaped +region defined by its two half-edges. + +**Wedge coordinate frame**: +- Origin: the node position. +- Radial axis `R`: distance from the node along a half-edge. +- Lateral axis `S`: perpendicular to `R` in the horizontal plane, pointing + across the road. + +**Lane layout within a wedge**: +- Each half-edge has an outbound lane count `L_out` and an inbound lane count + `L_in`. +- For wedge `(H_i -> H_{i+1})`, the road width on the `H_i` side is determined + by `H_i`'s outbound lanes, and the width on the `H_{i+1}` side is determined + by `H_{i+1}`'s inbound lanes. +- The total width varies linearly from the `H_i` side to the `H_{i+1}` side. +- For asymmetric lanes, the divider line (center of the road cross-section) + varies across the wedge; interpolate its offset between the two half-edges. + +**Sweeping the template**: +- For each radial sample `r` from `0` to `halfEdgeLength` (use the shorter of the + two half-edges as the radial limit, or sample each half-edge independently and + triangulate between them): + - Compute the two boundary points of the wedge at radius `r`, one on each + half-edge. + - Between these two boundary points, place copies of the template mesh scaled + to the local width and oriented to follow the wedge surface. +- UV x repeats along the radial direction; UV y maps across the lateral width. + +**Center-gap filling**: +- Vertices near the wedge origin (`r < 0.1`) are shifted toward the node center + along their radial direction so the geometry overlaps at the node. +- This is applied per-wedge, independently. A small overlap (`~0.05` world + units) prevents visible gaps at intersections without requiring global + knowledge of all wedges. + +**Vertical placement**: +- Sample the road surface height at the node and at the neighbor ends using the + edge's `roadLevelA`/`roadLevelB` values. +- Interpolate linearly across the wedge. + +**Straight segment for a single-connection node**: +- Transform and repeat the template mesh along the single half-edge. +- No wedge blending is needed. +- Apply the same lane-count/asymmetric-lane rules as for a wedge side. + +--- + +#### M5.7 Edge length constraint + +- Prefer half-edge lengths that are integer multiples of the template length + (1 world unit). When adding or splitting a node, snap the new node position + to satisfy this for the affected half-edges. +- If snapping is impossible, scale the last template copy on each affected + half-edge non-uniformly and adjust UVs. Log a warning in the editor. + +--- + +#### M5.8 Mesh assembly per page + +For each affected terrain page: + +1. Create a `TriangleBufferComponent` entity as a child of the terrain entity. +2. Add a `TransformComponent` to the same entity so `NavMeshSystem` can discover + the generated `Ogre::Entity` via the scene node. +3. Add a `NavMeshGeometrySource` component with `include = true` so the road + surface contributes to the navigation mesh. +4. Fill its `buffer` directly with the generated wedge triangles and the + straight-segment triangles for nodes with a single connection (do **not** + create `PrimitiveComponent` children; the buffer is built procedurally). +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, + `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)`. +10. Add a `LodComponent` with a `LodSettingsComponent` that has one reduction + level at `roadLodDistance` (50% vertex reduction). + +**Navmesh dirty marking**: After creating or destroying a page's road geometry, +compute the page's world AABB (expanded by `roadVisibilityDistance`) and mark the +affected navmesh tiles dirty. If `NavMeshSystem` exposes a partial rebuild +method, use it; otherwise set `NavMeshComponent::dirty = true` on the scene's +navmesh entity for a full rebuild. + +**Lifecycle**: road meshes are tied to terrain page load/unload. +- When a terrain page loads, `RoadSystem` creates the page's + `TriangleBufferComponent` and marks it dirty. +- When a terrain page unloads, `RoadSystem` destroys the page's buffer entity, + removes its mesh from `Ogre::MeshManager`, removes the physics body, destroys + any spawned roadside prefabs, and removes the `NavMeshGeometrySource` so the + navmesh contribution is gone. +- When the road graph or config changes, mark all affected pages dirty so they + rebuild on the next update. + +--- + +#### M5.9 Road physics colliders + +Road colliders are created together with the road mesh when a terrain page +loads and destroyed together with it when the page unloads: + +1. Collect the same vertices/indices used for the render mesh. +2. Build a `JPH::MeshShape` using `JoltPhysicsWrapper::createMeshShape()`. +3. Create a static body with `JPH::EMotionType::Static` and `Layers::NON_MOVING` + via `JoltPhysicsWrapper::createBody()`. +4. Add the body with `JoltPhysicsWrapper::addBody(bodyId, + JPH::EActivation::DontActivate)`. +5. Store the body ID in a `PhysicsColliderComponent` on the page's buffer entity. +6. On page unload or rebuild, call `JoltPhysicsWrapper::removeBody()` / + `destroyBody()` for the old body first. + +The collider body is independent of the `NavMeshGeometrySource` used for +navigation; both are derived from the same generated road geometry so the +walkable surface and collision surface match. + +--- + +#### M5.10 Terrain compliance (conform, not flatten) + +The terrain must hug the underside of the road: it should not poke through the +road surface and should not leave gaps beneath it. The road surface itself is +not required to be flat — it follows the node heights and edge slopes. + +**Algorithm**: + +1. For every point on the generated road surface (edge samples + wedge samples), + compute the world position `roadSurfacePos` of the road top. +2. The desired terrain height directly below that point is: + `targetY = roadSurfacePos.y - roadThickness`. +3. Write `targetY` into the fixup chunk covering `(roadSurfacePos.x, + roadSurfacePos.z)`. +4. Apply a smooth falloff perpendicular to the road: + - Full compliance within the road width (all lanes). + - Linear fade over `laneWidth * 2` outside the outermost lane. + - Beyond the fade zone, leave the fixup entry empty (use base heightmap + + detail noise). +5. After all writes, mark affected terrain pages dirty and save the fixup + chunks. + +**Implementation note**: compliance must run on the main thread before marking +pages dirty. It should sample the already-generated road geometry, not a +simplified representation, so the terrain matches the exact road surface. + +--- + +#### M5.11 Roadside prefab spawning + +For each `RoadEdge::RoadSidePrefab`: + +1. Compute base position along the edge: + ```cpp + Ogre::Vector3 pos = lerp(nodeA.position, nodeB.position, prefab.edgeT); + ``` +2. Compute lateral offset: + ```cpp + Ogre::Vector3 dir = (nodeB.position - nodeA.position); + dir.y = 0; + dir.normalise(); + Ogre::Vector3 side = Ogre::Vector3::UNIT_Y.crossProduct(dir); + if (!prefab.leftSide) side = -side; + pos += side * prefab.sideOffset; + ``` +3. Snap Y to terrain: `pos.y = TerrainSystem::getInstance()->getHeightAt(pos)`. +4. Instantiate via `PrefabSystem::createInstance(prefabPath, terrainEntity, + pos, name, uiSystem)`. +5. Track spawned entities in `RoadPageGeometry::spawnedPrefabs` and destroy them + when the page geometry is rebuilt **or when the terrain page is unloaded**. + +Roadside prefabs are runtime-only and page-attached; they are regenerated from +edge data when the page is loaded. + +--- + +#### M5.12 Serialization and system wiring + +**Serialization**: +- Road config, nodes, edges, and side prefabs are already serialized in + `SceneSerializer::serializeTerrain()` / `deserializeTerrain()`. +- Any change to nodes/edges/config must set `tc.dirty = true` so the road + geometry rebuilds. +- Runtime meshes, colliders, visual aids, and spawned prefabs are **not** + serialized. + +**System wiring**: +1. `TerrainSystem` creates `RoadSystem` in `activate()`. +2. `TerrainSystem::update()` calls `m_roadSystem->update(deltaTime)` after + `rebuildDirtyPages()`. + - `RoadSystem` checks `TerrainGroup::getTerrainSlots()` for newly loaded + pages and creates road meshes/colliders/prefabs and navmesh geometry for + them. + - `RoadSystem` detects unloaded pages (slots with no instance) and destroys + their road meshes/colliders/prefabs and navmesh geometry. +3. After any road graph/config change, `RoadSystem` marks affected navmesh tiles + dirty so `NavMeshSystem` rebuilds them. +4. `TerrainSystem::deactivate()` destroys `RoadSystem` before tearing down the + terrain group. + +--- + +### 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 + 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 + template inside each V-shaped wedge; nodes with a single connection + generate straight segments; angle validation and center-gap filling + prevent degenerate geometry and visible seams. +- [ ] Road meshes use the configured material, LOD, and visibility distance. +- [ ] Static Jolt `MeshShape` colliders match the visible road geometry. +- [ ] Navigation mesh is rebuilt for terrain pages that contain roads; road + surfaces are walkable. +- [ ] "Comply Terrain to Roads" makes the terrain follow the road underside + (sloped/curved where the road is sloped/curved) without gaps or + 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. +- [ ] Road meshes, colliders, spawned prefabs, and navmesh geometry are created + when a terrain page loads and destroyed when it unloads. +- [ ] Removing the terrain entity destroys all road meshes, colliders, visual + aids, spawned prefabs, and navmesh geometry. + +### Milestone 6 — Prefab spawns and terrain compliance - `TerrainPrefabSpawnerComponent` + `TerrainPrefabSpawnerModule`. - `TerrainPrefabSpawnerSystem` with distance-based spawn/despawn via `PrefabSystem`. @@ -1416,6 +2418,7 @@ flush; save/load round-trips correctly. | Large heightmap build stalls | Use Ogre's background prepare; create Jolt colliders only after page is fully loaded. | | Road mesh too heavy | Join wedges, run LOD, limit visibility distance, use instancing for repeated pieces. | | Road wedge seam gaps | Shift near-seam vertices by 0.05; overlap adjacent wedge edges. | +| Navmesh rebuild storm on page load | Tag road page entities with `NavMeshGeometrySource`; mark only affected tiles dirty; avoid full rebuilds on every page load. | | Fixup chunk read/write races | `CustomTerrainDefiner::define()` runs on Ogre's WorkQueue; fixup writes must happen on the main thread before marking pages dirty. | | Terrain not visible in water reflections | Ensure terrain render queue/visibility mask matches water RTT camera setup. | | TerrainPaging/PageManager destruction order | Destroy `TerrainPaging` before `PageManager` (Paging holds reference to PageManager). | @@ -1435,6 +2438,8 @@ flush; save/load round-trips correctly. - [ ] Clear all fixups → terrain returns to base heightmap only. - [ ] Save scene, exit, reload → terrain restored with correct heightmap and layers. - [ ] Create road network → road geometry appears with correct lane count and terrain compliance. +- [ ] Road page load → Jolt `MeshShape` collider exists and navigation mesh covers the road surface. +- [ ] Road page unload → road collider and navmesh contribution are removed. - [ ] Per-edge lane override → edge with `lanesAtoB=2` renders two lanes A→B. - [ ] Save/reload scene with roads → nodes, edges, and config restored. - [ ] Place prefab spawner → prefab appears at correct distance and sits on terrain.