From b062d8331dce7f0fbad94ce833bfc40c64708418 Mon Sep 17 00:00:00 2001 From: Sergey Lapin Date: Fri, 4 Sep 2026 21:52:47 +0300 Subject: [PATCH] Terrain improvement --- src/features/editScene/AGENTS.md | 173 + src/features/editScene/CMakeLists.txt | 8 + src/features/editScene/EditorApp.cpp | 31 + src/features/editScene/EditorApp.hpp | 7 + src/features/editScene/TerrainDoc.md | 187 + src/features/editScene/TerrainImprovment2.md | 204 + src/features/editScene/TerrainRequirements.md | 149 +- .../editScene/camera/EditorCamera.cpp | 34 +- .../editScene/camera/EditorCamera.hpp | 41 +- src/features/editScene/components/Terrain.hpp | 39 + .../components/TerrainPrefabSpawner.hpp | 16 + .../editScene/components/Transform.hpp | 16 +- src/features/editScene/lua/LuaTerrainApi.cpp | 6 +- src/features/editScene/lua/LuaTerrainApi.hpp | 9 + src/features/editScene/physics/physics.cpp | 62 +- src/features/editScene/physics/physics.h | 15 + .../editScene/systems/EditorUISystem.cpp | 62 +- .../editScene/systems/EditorUISystem.hpp | 12 + .../editScene/systems/PrefabSystem.cpp | 53 +- .../editScene/systems/RenderOriginSystem.cpp | 92 + .../editScene/systems/RenderOriginSystem.hpp | 87 + .../editScene/systems/RoadRegionStore.cpp | 207 + .../editScene/systems/RoadRegionStore.hpp | 96 + src/features/editScene/systems/RoadSystem.cpp | 2046 ++++++++-- src/features/editScene/systems/RoadSystem.hpp | 134 +- .../editScene/systems/SceneSerializer.cpp | 211 +- .../editScene/systems/SceneSerializer.hpp | 29 + .../editScene/systems/SpawnerRegionStore.cpp | 190 + .../editScene/systems/SpawnerRegionStore.hpp | 93 + .../systems/TerrainPrefabSpawnerSystem.cpp | 362 +- .../systems/TerrainPrefabSpawnerSystem.hpp | 56 + .../editScene/systems/TerrainSystem.cpp | 1849 +++++++-- .../editScene/systems/TerrainSystem.hpp | 288 +- .../editScene/systems/TerrainTests.cpp | 3392 ++++++++++++++++- .../editScene/systems/TerrainTests.hpp | 32 + .../editScene/systems/WorldMapData.hpp | 171 + src/features/editScene/ui/NavigationPanel.hpp | 223 ++ src/features/editScene/ui/TerrainEditor.hpp | 52 + src/features/editScene/ui/WorldBookmark.hpp | 22 + src/features/editScene/ui/WorldMapPanel.hpp | 418 ++ 40 files changed, 10367 insertions(+), 807 deletions(-) create mode 100644 src/features/editScene/TerrainDoc.md create mode 100644 src/features/editScene/TerrainImprovment2.md create mode 100644 src/features/editScene/systems/RenderOriginSystem.cpp create mode 100644 src/features/editScene/systems/RenderOriginSystem.hpp create mode 100644 src/features/editScene/systems/RoadRegionStore.cpp create mode 100644 src/features/editScene/systems/RoadRegionStore.hpp create mode 100644 src/features/editScene/systems/SpawnerRegionStore.cpp create mode 100644 src/features/editScene/systems/SpawnerRegionStore.hpp create mode 100644 src/features/editScene/systems/WorldMapData.hpp create mode 100644 src/features/editScene/ui/NavigationPanel.hpp create mode 100644 src/features/editScene/ui/WorldBookmark.hpp create mode 100644 src/features/editScene/ui/WorldMapPanel.hpp diff --git a/src/features/editScene/AGENTS.md b/src/features/editScene/AGENTS.md index ef6cec9..b97ff00 100644 --- a/src/features/editScene/AGENTS.md +++ b/src/features/editScene/AGENTS.md @@ -41,6 +41,9 @@ cd build-vscode/src/features/editScene # Run terrain integration tests headless (1x1 hidden SDL window, no UI) ./editSceneEditor --headless --run-terrain-tests=1 +# Run only matching test steps (substring match on step name) +TERRAIN_TEST_FILTER=terrainCompliance ./editSceneEditor --headless --run-terrain-tests=1 + # Road wedge/segment self-intersection regression test (no scene needed, # also registered as CTest roadGeometryOverlapTest) ./road_geometry_overlap_test @@ -150,6 +153,19 @@ footprint using fixup chunks. Spawned instances are runtime-only: "prefab spawn mode" (Terrain panel, ESC to exit) places spawners by clicking the terrain. +**Streaming (M4):** when the active terrain has `streamingEnabled`, spawner +definitions persist per terrain page in +`heightmaps//spawners/_.json` (`SpawnerRegionStore`, +absolute world-space doubles). `syncStreamedSpawners()` polls +`TerrainGroup::getTerrainSlots()` each frame: it creates a spawner entity per +def when its page loads and destroys the entity (writing any edits back to the +store, including defs whose position crossed a page boundary) when the page +unloads. Streamed entities carry `StreamedSpawnerTag{pageX, pageY, defId}` so +`SceneSerializer` excludes them from the scene JSON and editor edits can be +written back. `createSpawnPoint()` routes to the region store while streaming. +Scene-embedded `terrainPrefabSpawner` entities keep working as always-loaded +spawners for small or non-streaming scenes. + ### RoadSystem Per-terrain procedural roads (`RoadGraph` component, geometry built by @@ -181,9 +197,108 @@ Per-terrain procedural roads (`RoadGraph` component, geometry built by pedestrian strip per wedge outer curb (widened curb-offset chains — see `ProceduralRoadGeometry.md` §15); terrain compliance starts its falloff at the sidewalk outer edge. +- **Terrain compliance** ("Comply Terrain to Roads", + `RoadSystem::complyTerrain`): collects the top surface of every road + piece as world-space triangles, then treats "terrain must stay below + the slab underside" as a constraint-solving problem on the *rendered + page-lattice* vertices (one vertex per ~31 m, the only heights the + renderer interpolates between). `collectComplianceConstraints()` + clips each triangle against every lattice cell it overlaps and emits + one linear constraint per candidate point (cell corners, kink points + on the actual per-row triangulation diagonal — matching the + renderer/collider zigzag — and triangle vertices), with barycentric + weights on the 4 cell corners and target `roadTop - + ROAD_COMPLIANCE_SAG` (0.05). The + system is solved in two phases over cached vertex heights: (1) damped + Kaczmarz lower-only sweeps push violated constraints to feasibility, + each corner sinking by its barycentric-weight share of the excess; + (2) a raise-only relaxation lifts every vertex back to the highest + value its constraints allow (capped at the natural height), which + recovers the hysteresis overshoot of phase 1 so the roadbed follows + the road curvature instead of collapsing to the lowest road nearby. + The resulting per-vertex lowering is applied via + `TerrainSystem::lowerFixupCorners()`, which lowers only the 4 fixup + samples blending into that vertex and materializes sentinel corners + at the *current* surface so the post-write blend equals + `natural - delta` exactly. The footprint stays within the cells + touching the road instead of flattening the whole corridor. +- **Roads-to-terrain compliance** ("Comply Roads to Terrain", + `RoadSystem::complyRoadsToTerrain`): the inverse operation — shifts + road nodes vertically (both directions) so every point of every edge + stays at least `roadThickness + 0.05` above the current terrain. + Constraints are sampled along each half-edge (dense 0.5 m centreline + and curb samples extended backwards over the node wedge, every + lattice-line crossing of those lines, and every page vertex inside + the road footprint — lanes plus sidewalk only when enabled) against + `renderedHeightAt()` (the rendered page-lattice surface with the + actual per-row triangulation diagonal), and every sample is filtered + against the actual XZ footprint of the road top-surface triangles + (the sampling rectangle's corners stick out past the wedge fan / + end cap behind a node; bumps there must not lift the road). The + system is solved iteratively in three phases: (1) Kaczmarz projection + sweeps raise nodes to feasibility, distributing each violated + constraint's deficit between the node and its neighbour along the + constraint normal — a per-node "raise to the max lower bound" update + instead corners the solution onto whichever node the update order + hits first, leaving metres of hover over the other end; (2) + alternating damped lower-only relaxation and pairwise rebalancing — + lowering the small-coefficient side of a binding constraint while + raising the large-coefficient side to compensate keeps it satisfied + and strictly reduces the total node height, escaping the LP corners + plain relaxation sticks at; (3) a few raise-only passes clear the + residual violations phase 2 can introduce. Node + `verticalOffset` values are re-derived and the graph version is + bumped. +- **Region streaming (M5)**: when the terrain has `streamingEnabled`, the + road graph persists per terrain page in + `heightmaps//roads/_.json` (`RoadRegionStore`, + absolute world-space doubles) instead of inline in the scene JSON — + `SceneSerializer::serializeTerrain` then writes only `roadConfig`. A + cross-page edge is stored in the region file of *each* endpoint page + with the foreign endpoint repeated inline; merges match nodes by world + XZ (0.01 eps) so reloaded pages never duplicate nodes, and edges are + only created once both endpoints are in the active graph. A non-empty + legacy inline graph is migrated to region files on the first sync. + `EditorUISystem::saveScene` calls `RoadSystem::flushRegionStore()` + before writing the scene. Page mesh rebuilds are gated by a + rebase-invariant FNV-1a content signature (world positions quantized + to cm), so a render-origin rebase (which shifts node render positions + via `RoadSystem::onRenderOriginChanged`, forwarded from + `TerrainSystem::onRenderOriginChanged`) does not re-dirty pages; + navmesh dirtying is debounced ~15 quiet frames while streaming. Road edit mode exits with ESC (same as prefab spawn mode). +### Terrain Streaming & Navigation + +- `RenderOriginSystem` (singleton, `RenderOriginSystem::getInstance()`, + created in `EditorApp`) owns the floating render origin: render = + world - renderOrigin. When the camera moves further than + `REBASE_THRESHOLD` (8192 render units) from the origin, the origin + snaps to integer world coordinates near the camera and every + render-space position shifts by the exact negated delta. Convert with + `worldToRender(double,double,double)` / `renderToWorld(Ogre::Vector3)` + (returns `JPH::DVec3`). +- With `TerrainComponent::streamingEnabled`, the world spans up to + `worldSizeUnits` (default 40,000,000) units per axis — 20000 x 20000 + pages at the default 2000-unit page size, clamped to 32768 for + TerrainGroup's signed 16-bit slot packing. `updateStreamingWindow()` + loads `pageLoadRadius` pages around the camera (2 loads/frame) and + unloads beyond `pageHoldRadius` (4 unloads/frame), all synchronously. + Base heights come from `TerrainComponent::baseNoise` (FastNoiseLite, + double precision); edits write through the LRU-capped fixup layer. +- Editor navigation (M3): `EditorCamera` has wheel zoom + (`handleMouseWheel`), Shift boost (x10) and configurable fly speed + (`setFlySpeed`, 1..100000 units/s). **Tools -> Navigation** opens + `NavigationPanel` (teleport by world coords or page index via the + static `teleportCamera`/`teleportToPage` helpers; named world + bookmarks persisted as a top-level `bookmarks` array in the scene + JSON, tolerated when missing). **Tools -> World Map** opens + `WorldMapPanel` (ImGui canvas over the ImGui-free `WorldMapData`: + heights/pages/roads/spawners/bookmarks/camera overlays, drag pan, + wheel zoom, double-click teleport). The Terrain panel has a compact + "Navigation" section sharing the same backend. + ### Player Character Resolution Never resolve the player by matching `PlayerControllerComponent::targetCharacterName` @@ -283,6 +398,61 @@ Make sure documentation, tests and examples are always in sync. - Use `editScene::isGameMode()` / `editScene::isGamePlaying()` for mode checks when you don't have an `EditorApp` pointer. +- Terrain code has THREE coordinate spaces (M2): **world** space (absolute, + double precision, up to 40,000,000 x 40,000,000 units — authoritative), + **render** space (float, `render = world - renderOrigin`, what scene nodes, + mouse rays and the editor camera live in) and **physical** heightmap space + (what `fillPageHeightData`, fixup chunks, `sampleHeightAt`/ + `sampleBaseHeightAt` and `writeFixup`/`writeFixupSample` use; physical X is + shifted by half a page, physical Z is mirrored within each page). Convert + render->world with `TerrainSystem::renderToWorldX/Y/Z`, world->physical with + `TerrainSystem::visualToPhysicalX/Z` (doubles). `RenderOriginSystem` + (singleton, `EditorApp::getRenderOriginSystem()`) owns the render origin and + rebases when the editor camera moves past `REBASE_THRESHOLD` (8192) units: + root-level entity nodes shift by the exact negated delta (recomputed from + `TransformComponent::worldX/Y/Z` when `hasWorldPosition` is set — serialized + as `"worldPosition"` in scene JSON), and `TerrainSystem::onRenderOriginChanged` + repositions terrain pages precisely from doubles (Ogre's own slot position + math is single-precision and loses metres at page indices near 20000). + Terrain Jolt colliders live in absolute world space (`JPH::RVec3` overloads + in `physics/physics.h`); `TerrainSystem::raycastTerrain` converts its ray to + world space for the collider query. +- Terrain streaming groundwork: `TerrainComponent::streamingEnabled` + (default false) switches the base-height source from the legacy + `m_heightData` buffer to on-demand procedural evaluation of + `TerrainComponent::baseNoise` (`TerrainSystem::computeBaseNoise`, + FastNoiseLite OpenSimplex2, sampled in double precision). Page streaming IS + active in streaming mode: a window of `pageLoadRadius`/`pageHoldRadius` + pages around the camera loads/unloads each frame (2 loads per frame cap), + physical pages are indexed [0, N-1] with N = worldSizeUnits/worldSize + (clamped to 32768 for TerrainGroup's signed 16-bit slot packing), and blend + maps/aux maps are stored per page under + `heightmaps//`. `setHeightAt` writes through the fixup layer, + and analytic fallbacks serve unloaded pages (`getHeightAt`, + `raycastTerrain`). Fixup chunks are + LRU-capped (`TerrainSystem::setFixupChunkCap`, default 64); evicted + dirty chunks are flushed to disk. While a terrain is active the editor + camera far clip and linear fog follow `TerrainComponent::farClipDistance` + / `fogEnabled` / `fogStart` / `fogEnd` and are restored on deactivate. +- Physical terrain page `(x, y)` lives in Ogre TerrainGroup slot `(x, -y)` + because `ALIGN_X_Z` negates Z — `TerrainSystem::isPageLoaded(x, y)` and the + region stores take PHYSICAL page coords and convert internally. Getting + this mapping wrong places loads/saves on the mirrored page. +- Never store world positions in floats for anything that must stay exact at + the far end of the world: floats lose ~4 units at 40,000,000. Use + `TransformComponent::worldX/Y/Z` doubles (or region files) as the + authoritative position and derive render-space floats from them. +- Streamed content is NOT in the scene JSON: `StreamedSpawnerTag` spawner + entities and (when streaming) `roadNodes`/`roadEdges` live in the + `heightmaps//{spawners,roads}/_.json` region stores. + Spawner store writes are immediate per mutation; road regions flush on + page unload and via `RoadSystem::flushRegionStore()` on scene save; + dirty fixup chunks flush on LRU eviction. Expect edits to survive only + through those paths. +- The `ecs.terrain` Lua brush functions (`sculpt`, `paint`, `paintAux`) + take absolute visual WORLD coordinates (render-origin independent) and + convert internally; only `terrain.sampleAux` takes PHYSICAL heightmap + coordinates (see `lua/LuaTerrainApi.hpp`). - Systems that set animation states must not affect player-controlled entities; check `e.has()` early. - Spawner-targeted characters are transient instances; persistent player data @@ -294,5 +464,8 @@ Make sure documentation, tests and examples are always in sync. ## Additional Docs +- `TerrainDoc.md` – terrain system functional description (streaming, coordinate + spaces, region storage, editor tools) +- `TerrainImprovment2.md` – streamed 40M-world plan with per-milestone status - `docs/SaveLoadSystem.md` – save file format and API details - Root `AGENTS.md` – project-wide build, style and architecture notes diff --git a/src/features/editScene/CMakeLists.txt b/src/features/editScene/CMakeLists.txt index aa9e960..3f900d7 100644 --- a/src/features/editScene/CMakeLists.txt +++ b/src/features/editScene/CMakeLists.txt @@ -24,6 +24,9 @@ set(EDITSCENE_SOURCES systems/EditorSkyboxSystem.cpp systems/EditorWaterPlaneSystem.cpp systems/TerrainSystem.cpp + systems/RenderOriginSystem.cpp + systems/SpawnerRegionStore.cpp + systems/RoadRegionStore.cpp systems/RoadSystem.cpp systems/LightSystem.cpp systems/CameraSystem.cpp @@ -871,5 +874,10 @@ add_custom_command(TARGET editSceneEditor POST_BUILD COMMAND ${CMAKE_COMMAND} -E copy_directory "${CMAKE_CURRENT_SOURCE_DIR}/tests/prefabs" "${CMAKE_CURRENT_BINARY_DIR}/tests/prefabs" + # Project runtime prefabs (PrefabSystem resolves "prefabs/..." + # relative to the executable directory) + COMMAND ${CMAKE_COMMAND} -E copy_directory + "${CMAKE_SOURCE_DIR}/prefabs" + "${CMAKE_CURRENT_BINARY_DIR}/prefabs" COMMENT "Copying resources to editSceneEditor build directory" ) diff --git a/src/features/editScene/EditorApp.cpp b/src/features/editScene/EditorApp.cpp index 9855c2e..2f959ee 100644 --- a/src/features/editScene/EditorApp.cpp +++ b/src/features/editScene/EditorApp.cpp @@ -10,6 +10,7 @@ #include "systems/EditorSkyboxSystem.hpp" #include "systems/EditorWaterPlaneSystem.hpp" #include "systems/TerrainSystem.hpp" +#include "systems/RenderOriginSystem.hpp" #include "systems/LightSystem.hpp" #include "systems/CameraSystem.hpp" #include "systems/LodSystem.hpp" @@ -530,6 +531,16 @@ void EditorApp::setup() m_physicsSystem->getPhysicsWrapper()); } + /* Floating render origin (M2): rebases render space when the + * camera wanders far from the origin so the world can span + * 40,000,000 x 40,000,000 units without float artifacts. */ + m_renderOriginSystem = + std::make_unique(m_world, + m_sceneMgr); + m_renderOriginSystem->setTerrainSystem(m_terrainSystem.get()); + m_renderOriginSystem->setEditorCamera(m_camera.get()); + m_renderOriginSystem->setAuxNodes(m_gridNode, m_axisNode); + // Apply debug setting if it was set before system creation if (m_debugBuoyancy) { m_buoyancySystem->setDebugEnabled(true); @@ -1606,6 +1617,11 @@ bool EditorApp::frameRenderingQueued(const Ogre::FrameEvent &evt) { bool paused = (m_gamePlayState == GamePlayState::Paused); + /* Render origin rebase runs first so every system below sees the + * post-rebase render space. */ + if (m_renderOriginSystem) + m_renderOriginSystem->update(); + if (m_gameMode == GameMode::Editor) { // Update camera if (m_camera) { @@ -1900,6 +1916,21 @@ bool EditorApp::mouseReleased(const OgreBites::MouseButtonEvent &evt) return true; } +bool EditorApp::mouseWheelRolled(const OgreBites::MouseWheelEvent &evt) +{ + if (m_gameMode == GameMode::Game) { + return true; + } + + /* Only zoom the editor camera when ImGui is not capturing the + * wheel (scrolling a panel must not zoom the viewport). */ + ImGuiIO &io = ImGui::GetIO(); + if (!io.WantCaptureMouse && m_camera) { + m_camera->handleMouseWheel(evt.y); + } + return true; +} + bool EditorApp::keyPressed(const OgreBites::KeyboardEvent &evt) { m_currentModifiers = evt.keysym.mod; diff --git a/src/features/editScene/EditorApp.hpp b/src/features/editScene/EditorApp.hpp index e93cb0b..ebe9eab 100644 --- a/src/features/editScene/EditorApp.hpp +++ b/src/features/editScene/EditorApp.hpp @@ -42,6 +42,7 @@ class EditorSkyboxSystem; class EditorWaterPlaneSystem; class NormalDebugSystem; class TerrainSystem; +class RenderOriginSystem; class SmartObjectSystem; class GoapRunnerSystem; class PathFollowingSystem; @@ -151,6 +152,7 @@ public: bool mouseMoved(const OgreBites::MouseMotionEvent &evt) override; bool mousePressed(const OgreBites::MouseButtonEvent &evt) override; bool mouseReleased(const OgreBites::MouseButtonEvent &evt) override; + bool mouseWheelRolled(const OgreBites::MouseWheelEvent &evt) override; bool keyPressed(const OgreBites::KeyboardEvent &evt) override; bool keyReleased(const OgreBites::KeyboardEvent &evt) override; @@ -224,6 +226,10 @@ public: { return m_camera.get(); } + RenderOriginSystem *getRenderOriginSystem() const + { + return m_renderOriginSystem.get(); + } AnimationTreeSystem *getAnimationTreeSystem() const { return m_animationTreeSystem.get(); @@ -291,6 +297,7 @@ private: std::unique_ptr m_skyboxSystem; std::unique_ptr m_waterPlaneSystem; std::unique_ptr m_terrainSystem; + std::unique_ptr m_renderOriginSystem; std::unique_ptr m_lightSystem; std::unique_ptr m_cameraSystem; std::unique_ptr m_lodSystem; diff --git a/src/features/editScene/TerrainDoc.md b/src/features/editScene/TerrainDoc.md new file mode 100644 index 0000000..2178ee2 --- /dev/null +++ b/src/features/editScene/TerrainDoc.md @@ -0,0 +1,187 @@ +# Terrain System — Functional Description + +This document describes how the terrain in `src/features/editScene` works: what it can +do, how the data is organised, and how the editor and runtime systems interact with it. +For the implementation history see `TerrainImprovment2.md`; for contributor rules see +`AGENTS.md`. + +## Overview + +The terrain is a single `TerrainComponent`-driven Ogre `TerrainGroup` owned by +`TerrainSystem`. Two modes exist: + +- **Legacy mode** (`streamingEnabled = false`): a small fixed grid, all pages defined + and loaded up front, base heights from a single in-memory heightmap buffer. Used by + old scenes and most headless tests. +- **Streaming mode** (`streamingEnabled = true`): a bounded world of up to + 40,000,000 × 40,000,000 units (20,000 × 20,000 pages at the default 2000-unit page + size). Pages stream in around the camera; base heights are procedural; edits, paint + layers, spawners and roads persist in per-page sidecar files. + +## Coordinate spaces + +Four coordinate spaces coexist; converting at system boundaries is every caller's job: + +- **WORLD** — absolute double-precision coordinates, authoritative. Region files, + bookmarks, the Navigation panel, `TransformComponent::worldX/Y/Z` and physics use it. +- **RENDER** — float, relative to the floating render origin: + `render = world - renderOrigin`. Scene nodes, mouse rays and cameras live here. + `RenderOriginSystem` (singleton) owns the origin and rebases when the camera moves + more than 8192 render units away from it, shifting every render-space position by the + exact negated delta. Convert with `RenderOriginSystem::worldToRender()` / + `renderToWorld()`. +- **PHYSICS** — Jolt `DVec3`/`RVec3` in absolute world coordinates + (`physics/physics.h`). Terrain colliders live here. +- **PHYSICAL HEIGHTMAP** — per-page vertex indices: physical X is shifted by half a + page, physical Z is mirrored within each page. Convert with + `TerrainSystem::visualToPhysicalX/Z` / `physicalToVisualX/Z`. + +Page indices are PHYSICAL page coordinates `[0, N-1]`. The Ogre TerrainGroup slot for +physical page `(x, y)` is `(x, -y)` because `ALIGN_X_Z` negates Z — use +`TerrainSystem::isPageLoaded(x, y)` rather than raw slot math. + +Never store a world position in a float; floats cannot represent 40,000,000 with +sub-metre precision. + +## Configuration (`TerrainComponent`) + +| Field | Default | Meaning | +|---|---|---| +| `streamingEnabled` | false | Opt in to the streamed world | +| `worldSizeUnits` | 40,000,000 | World extent per axis (units) | +| `pageLoadRadius` | 2 | Pages loaded around the camera page | +| `pageHoldRadius` | 3 | Pages kept before unloading (hysteresis) | +| `farClipDistance` | 6000 | Camera far clip while terrain is active | +| `fogEnabled` / `fogStart` / `fogEnd` | true / 2500 / 5500 | Linear fog hiding pop-in | +| `baseNoise` | seeded FastNoiseLite | Procedural base height source | + +All fields serialize with the scene (`SceneSerializer::serializeTerrain`). + +## Streaming window + +Each frame `TerrainSystem::updateStreamingWindow()` computes the camera's page, then: + +- defines and loads pages entering the load window (at most 2 per frame), and +- unloads pages beyond the hold radius (at most 4 per frame). + +Loading is synchronous and amortized — Ogre's background WorkQueue is deliberately not +used (it caused shutdown hangs). Base heights for a page are evaluated on demand from +`baseNoise` (double precision, deterministic for a given seed); a legacy +`heightmaps//heightmap.bin`, when present, is sampled as a "base patch" +inside its bounds for backward compatibility. + +Sculpting never touches the base layer: `setHeightAt`/brushes write into the sparse +per-page **fixup chunk** store (`heightmaps//terrain_fixup/x{cX}_z{cZ}.bin`), +which is lazily disk-loaded, LRU-capped, and saved on eviction when dirty. Blend maps +and aux maps are likewise per-page files under `heightmaps//`. + +Height queries (`getHeightAt`, `raycastTerrain`) hit loaded pages when possible and fall +back to the analytic base+fixup evaluation for far/unloaded terrain, so tools (teleport +snapping, world map) work anywhere in the world. + +Each loaded page gets a static Jolt collider created at its page-index-computed world +position (double precision, no float round-trip); colliders are queued/removed with the +page lifecycle. + +## Region storage layout + +All per-terrain streamed data lives under `heightmaps//`: + +``` +heightmaps// +├── terrain_fixup/x{cX}_z{cZ}.bin # sparse sculpt edits (chunk = page) +├── blend/... aux/... # per-page paint layers +├── spawners/_.json # prefab spawner definitions (M4) +└── roads/_.json # road graph partition (M5) +``` + +`_` are physical page indices. Positions inside the JSON files are absolute +world-space doubles, so the files are immune to render-origin rebases. + +## Prefab spawner streaming + +Two kinds of prefab spawners exist: + +- **Always-loaded**: scene-embedded `TerrainPrefabSpawnerComponent` entities — the + classic behaviour, unchanged, right for small scenes. +- **Streamed**: when the terrain streams, spawner definitions live in the region store. + `TerrainPrefabSpawnerSystem::syncStreamedSpawners()` watches the loaded page set, + creates a region spawner entity (tagged `StreamedSpawnerTag`, excluded from scene + JSON) when a page loads, and destroys it — writing position/distance edits back, + including moves across page boundaries — when it unloads. The distance-based + spawn/despawn of the actual prefab instances is unchanged. + +The editor's click-to-place prefab spawn mode writes straight to the region store when +streaming. + +## Road streaming + +With streaming enabled, the road graph (`RoadGraph`: nodes + edges + `roadConfig`) is +partitioned per page: + +- A node belongs to the page containing its position; a cross-page edge is written to + **both** endpoint pages' files (foreign endpoint repeated inline), so each file is + self-contained. +- On page load, `RoadSystem::syncRegions()` merges the file into the active graph, + matching nodes by world XZ (0.01 eps) and creating an edge only when both endpoints + exist — an edge into an unloaded neighbour appears when that neighbour loads. +- On page unload, the page's nodes and their edges are extracted back to the file. +- Legacy scenes with an inline `roadGraph` migrate to region files automatically on + first sync; the scene JSON then keeps only `roadConfig`. +- Saving the scene flushes the region store first + (`EditorUISystem::saveScene` → `RoadSystem::flushRegionStore()`). + +Per-page road meshes, colliders and roadside prefabs were already page-scoped; they are +rebuilt only when a rebase-invariant content signature changes, and navmesh dirtying is +debounced (~15 quiet frames) so mass page transitions do not trigger rebuild storms. + +## Editor navigation tools + +- **Fly camera**: mouse-wheel zoom, Shift boost (×10), configurable speed + (`EditorCamera::setFlySpeed`, 1..100,000 units/s). +- **Tools → Navigation**: teleport to world X/Y/Z or to a page index (clamped to world + bounds, camera snapped above the terrain); named **bookmarks** stored in the scene + JSON `bookmarks` array. +- **Tools → World Map**: data-driven ImGui canvas (no RTT) showing coarse cached height + shading, loaded/unloaded pages, road polylines, prefab spawners, bookmarks and the + camera marker; drag to pan, wheel to zoom, double-click to teleport. +- `TerrainEditor` hosts a compact "Navigation" section with the same teleport backend. + +## Serialization behaviour + +- Scene JSON: terrain component settings, `roadConfig`, bookmarks, and all + non-streamed entities. When streaming, `roadNodes`/`roadEdges` and + `StreamedSpawnerTag` entities are **not** written — they live in the region stores. +- Region stores are written immediately on edit (spawners), on page unload, and on + scene save (roads). +- Loading an old scene with inline roads/spawners migrates them into region files on + first sync/save. + +## Lua API coordinates + +`lua/LuaTerrainApi.hpp` documents the coordinate space per function: +`terrain.sculpt` / `terrain.paintAux` take visual world coordinates, +`terrain.paint` converts world → render internally (rebase-safe), and +`terrain.sampleAux` takes physical heightmap coordinates. + +## Testing + +Headless integration suite (no display needed): + +```bash +cd build-vscode/src/features/editScene +./editSceneEditor --headless --run-terrain-tests=1 # full suite +TERRAIN_TEST_FILTER=roadRegion ./editSceneEditor --headless --run-terrain-tests=1 +``` + +Covers: streaming window follow/unload, procedural determinism, fixup persistence, +rebase stability and round-trip, bookmarks, world-map transforms, spawner region +round-trip and window lifecycle, road region store/streaming/migration. Success line: +`TERRAIN TESTS: ALL 1 ITERATIONS PASSED`. + +## Known limitations + +- The world is **bounded**, not looped: page indices and teleports clamp to + `[0, N-1]`. Wrap-around was explicitly scoped out (see `TerrainImprovment2.md`). +- Synchronous amortized loading can briefly lag behind very fast camera movement. +- Roads into an unloaded neighbour page render only once that page loads (by design). diff --git a/src/features/editScene/TerrainImprovment2.md b/src/features/editScene/TerrainImprovment2.md new file mode 100644 index 0000000..63c495a --- /dev/null +++ b/src/features/editScene/TerrainImprovment2.md @@ -0,0 +1,204 @@ +# Terrain Improvement Plan 2 — Streamed 40M×40M World (with status) + +Status legend: **[DONE]** implemented and verified, **[PARTIAL]** implemented with +deviations, **[OPEN]** not implemented. + +Overall status: **all milestones complete** (verified 2026-09: build clean, +`TERRAIN TESTS: ALL 1 ITERATIONS PASSED`, `component_lua_test` 63/63). + +## Goal + +Convert `src/features/editScene` terrain from a fixed 3×3-page, always-loaded grid into a +camera-streamed, bounded 40,000,000 × 40,000,000-unit world with: + +- Terrain page streaming — pages load/unload around the camera. +- Procedural base terrain + sparse persisted edits. +- Rendering origin rebasing — world coordinates stay `double`; rendering floats never see + huge values. Jolt runs with `JPH_DOUBLE_PRECISION`, so physics stays in world space. +- Editor navigation tools — teleport, world map with info layers, bookmarks, speed-scaled + fly camera. +- Terrain-attached prefab streaming — spawner definitions stored per region. +- Road streaming — road graph stored per region, loaded around the camera. + +## Confirmed decisions + +- Bounded world, **no wrap-around**. (The original request mentioned a looped world; this + was consciously scoped out. See "Open items" below.) +- Base terrain = procedural + sparse edits (no giant heightmap file). +- Precision = origin rebasing for rendering only; physics uses doubles already. +- All four editor tools wanted; world map must show info layers (prefabs, roads, pages). + +## Architecture + +### Coordinate model + +- **World space**: `double`, authoritative. World bounds `[0, 40,000,000]` both axes → + pages `[0, 19999]` at the default 2000-unit page size (clamped to 32768 for + TerrainGroup's signed 16-bit slot packing). +- **Render space**: float, camera-centric. `RenderOriginSystem` owns a `JPH::DVec3` + origin; `render = world - origin`. Rebase when the camera moves further than 8192 + render units from the origin: the origin snaps to integer world coordinates near the + camera and every render-space position shifts by the exact negated delta. +- **Physics space**: Jolt `DVec3`/`RVec3` in absolute world coordinates. +- **Physical heightmap space**: physical X shifted by half a page, physical Z mirrored + within each page; convert with `TerrainSystem::visualToPhysicalX/Z` / + `physicalToVisualX/Z`. Note: the Ogre TerrainGroup slot for physical page `(x, y)` is + `(x, -y)` because `ALIGN_X_Z` negates Z. + +### Region/window model for content streaming + +- Terrain pages stream in a window around the camera's page (`pageLoadRadius` = 2, + `pageHoldRadius` = 3 by default, configurable in `TerrainComponent`), synchronously, + amortized (2 loads + 4 unloads per frame) to avoid hitches and the WorkQueue shutdown + hangs seen with Ogre background loading. +- Sidecar content (prefab spawners, road graph) is stored **per page** under + `heightmaps//spawners/_.json` and + `heightmaps//roads/_.json` (the plan originally considered coarser + 8×8-page regions; per-page files proved simpler and match the terrain page lifecycle). + +--- + +## Milestones + +### M1 — Terrain page streaming core — [DONE] + +- `TerrainComponent` gained `streamingEnabled` (default false; opt-in per terrain), + `worldSizeUnits` (default 40,000,000), `pageLoadRadius`/`pageHoldRadius` (2/3), + `farClipDistance` (6000), linear fog (`fogEnabled`, 2500→5500) and `baseNoise` + (FastNoiseLite config). All serialized in `SceneSerializer`. +- Base heights are procedural-on-demand from `baseNoise` (double precision); + `setHeightAt`/sculpting write through the sparse per-page fixup chunk layer + (LRU-capped, save-on-evict). A legacy `heightmap.bin` keeps working as an optional + base patch for old scenes. +- `TerrainSystem::updateStreamingWindow()` loads/unloads pages around the camera page + each frame; old `m_pageMin/Max` full-grid loops were converted to iterate loaded + slots; blend/aux maps are per-page files under `heightmaps//`. +- `getHeightAt`/`raycastTerrain` fall back to the analytic height path for unloaded + (far) pages. +- Camera far clip + fog come from `TerrainComponent` so pop-in is hidden at the hold + radius. +- Tests: page window follows camera, unload frees slots, procedural determinism across + reload, fixup persistence across unload/reload; legacy assumptions adapted. + +### M2 — Rendering origin rebasing + double world positions — [DONE] + +- New `systems/RenderOriginSystem.{hpp,cpp}` (singleton): `worldToRender(x,y,z)`, + `renderToWorld(Ogre::Vector3) -> JPH::DVec3`, 8192-unit rebase threshold; runs before + all other systems each frame. +- `TransformComponent` carries authoritative double `worldX/Y/Z` alongside the float + node-local position; serialization writes the doubles (float kept for legacy load). +- Physics wrapper gained `JPH::RVec3` overloads so world-space doubles flow without a + float round-trip; terrain page colliders are created at page-index-computed world + positions. +- `TerrainSystem` exposes `worldToRender`, `renderToWorldX/Y/Z`, `isPageLoaded(x,y)` + (physical page coords), `streamingCameraPage`, `getTerrainEntityId`, `getStreamingActive`, + `getStreamingPageCount`, `snapCameraAboveTerrain`. +- Tests: rebase stability (geometry shifts by exactly the origin delta), + world→render→world round-trip, far-corner teleport renders correct terrain. + +### M3 — Editor navigation tools — [DONE] + +- `EditorCamera`: mouse-wheel zoom (`handleMouseWheel`), Shift boost (×10), configurable + fly speed (`setFlySpeed`, 1..100,000 units/s). +- **Tools → Navigation** (`ui/NavigationPanel.hpp`, header-only): teleport by world + X/Y/Z or by page index (clamped to world bounds); named bookmarks + (`ui/WorldBookmark.hpp`) persisted in the scene JSON top-level `bookmarks` array + (tolerated when missing on load). +- **Tools → World Map** (`ui/WorldMapPanel.hpp` + `systems/WorldMapData.hpp`): ImGui + 2D canvas (data-driven, not RTT) with coarse cached height shading, loaded/unloaded + page overlay, road polylines, prefab-spawner points, bookmarks, camera marker; + drag pan, wheel zoom, double-click teleport. +- `TerrainEditor` has a compact "Navigation" section sharing the same teleport backend. +- Tests: bookmark round-trip, world↔map transforms at extremes, navigation teleport. + +### M4 — Terrain-attached prefab streaming — [DONE] + +- New `systems/SpawnerRegionStore.{hpp,cpp}`: `SpawnerRegionDef` (prefabPath, world-space + double position, spawn/despawn distances, per-page id) in + `heightmaps//spawners/_.json`, saved immediately on edit. +- `TerrainPrefabSpawnerSystem::syncStreamedSpawners()` polls + `TerrainGroup::getTerrainSlots()`: creates region spawner entities on page load + (tagged `StreamedSpawnerTag{pageX,pageY,defId}`), destroys them and writes edits back + (including page-boundary crossings) on unload. Existing distance-based spawn/despawn + logic is unchanged. +- Editor click-to-place (`createSpawnPoint`) writes to the region store when streaming; + scene-embedded spawner entities remain as "always-loaded" spawners for + small/non-streaming scenes. +- `SceneSerializer` excludes `StreamedSpawnerTag` entities from scene JSON and caches + parsed prefab JSON (`loadPrefabJsonCached`/`invalidatePrefabJsonCache`, invalidated on + prefab save/delete). +- Tests: region store round-trip, streamed spawner window lifecycle, prefab JSON cache. + +### M5 — Road streaming — [DONE] + +- New `systems/RoadRegionStore.{hpp,cpp}`: per-page files + `heightmaps//roads/_.json` with nodes as absolute world-space + doubles and edges referencing per-file node ids (full lane/level/prefab-slot data). +- Membership rule: a node belongs to the page containing its position; an edge is stored + in the region file of **each** endpoint page (foreign endpoint repeated inline), so a + page file is self-contained. Merge matches nodes by world XZ (0.01 eps); an edge is + created only when both endpoints exist in the active graph; `hasEdge` prevents + duplicates. +- `RoadSystem::syncRegions()` runs each frame: merges region files of newly loaded pages, + extracts departing pages back to disk (two-phase: all departing pages are written + before any node is removed, because `removeNode` cascades incident edges). +- One-time migration: a non-empty inline `roadGraph` (legacy scene JSON) is partitioned + into region files on first sync; `SceneSerializer::serializeTerrain` then skips + `roadNodes`/`roadEdges` when `streamingEnabled` (keeps `roadConfig`). +- `EditorUISystem::saveScene` calls `RoadSystem::flushRegionStore()` before writing. +- Page mesh rebuilds are gated by a rebase-invariant FNV-1a content signature (world + positions quantized to cm) instead of re-dirtying all pages on every graph change; + navmesh dirtying is debounced (~15 quiet frames) while streaming. +- `TerrainSystem::onRenderOriginChanged` forwards to `RoadSystem::onRenderOriginChanged` + (shifts render-space node positions, bumps version; region files untouched). +- Notable fix found by tests: physical page assignment on the mirrored Z axis needs a + small epsilon (`floor((visualToPhysicalZ(wz) - 1e-6) / ws)`) so boundary nodes land in + the same page `connectNodes`' round-half rule picks. +- Tests: `roadRegionStore`, `roadRegionStreaming` (incl. cross-page `connectNodes` split, + rebase invariance, unload/reload remerge), `roadRegionMigration`. + +### M6 — Docs, examples, cleanup — [DONE] + +- Root `AGENTS.md`: "Road Region Streaming (M5)" + "Terrain Streaming Architecture" + sections (coordinate spaces, streaming window, region storage layout, navigation + tools, streamed vs always-loaded spawners). +- `src/features/editScene/AGENTS.md`: streaming paragraphs for + `TerrainPrefabSpawnerSystem` and `RoadSystem`, a "Terrain Streaming & Navigation" + section, and new caveats (physical page vs Ogre slot `(x,-y)`; never store world + positions in floats; streamed content lives in region stores; Lua coordinate + expectations). +- `TerrainRequirements.md`: stale "all pages loaded" / fixed-grid statements corrected. +- `lua/LuaTerrainApi`: per-function coordinate spaces documented in the header; + `terrain.paint` fixed to convert world→render (it painted at the wrong spot after a + rebase); `terrain.sculpt`/`paintAux` take visual world coords, `sampleAux` takes + physical coords. +- CMake: `prefabs/` is now copied next to the runtime resources (mirrors the existing + `tests/prefabs` copy), fixing the missing runtime prefab directory. +- Stale comments in `RoadSystem.hpp` / `TerrainSystem.hpp` / `RenderOriginSystem.hpp` + brought in line with the final behaviour. + +--- + +## Verification + +After every milestone and at completion: + +- `cmake --build build-vscode --target editSceneEditor -j4` — clean. +- `cd build-vscode/src/features/editScene && ./editSceneEditor --headless --run-terrain-tests=1` + — `TERRAIN TESTS: ALL 1 ITERATIONS PASSED` (single steps via `TERRAIN_TEST_FILTER=`). +- `./component_lua_test` — 63/63 passed. + +## Open items / known limitations + +- **Looped (wrap-around) world**: requested but explicitly scoped out; the world is + bounded and page indices/teleports clamp to `[0, N-1]`. Implementing a toroidal world + (wrapping streaming window, render origin, region stores, minimap) is a separate + feature. +- Page loads/unloads are synchronous and amortized (2+4 per frame); very fast camera + movement can outrun the window briefly. Ogre's background WorkQueue was deliberately + avoided (shutdown hangs). +- `connectNodes` boundary splitting uses render-space round-half math and can misplace + splits after a non-page-aligned rebase (nodes re-merge by position, so this is + cosmetic at worst). +- Roads crossing into an unloaded neighbour page appear only when the second page loads + (by design; both-files rule). diff --git a/src/features/editScene/TerrainRequirements.md b/src/features/editScene/TerrainRequirements.md index 324f54b..6e626a9 100644 --- a/src/features/editScene/TerrainRequirements.md +++ b/src/features/editScene/TerrainRequirements.md @@ -188,8 +188,15 @@ std::unordered_map mColliders; `TerrainPaging`, `PagedWorld`, `TerrainPagedWorldSection`. - Configure default `Ogre::Terrain::ImportData` from component values. - Set the custom `CustomTerrainDefiner`. - - Call `loadAllTerrains(true)` for editor-mode synchronous setup, or rely on - paging for game mode. + - In legacy (non-streaming) mode: define every page in the configured + range and call `loadAllTerrains(true)` for editor-mode synchronous + setup (the camera is not attached to `PageManager`; the paging + objects are present so runtime game mode can attach one). With + `TerrainComponent::streamingEnabled` only the initial load window + around the camera is defined/loaded; `updateStreamingWindow()` + streams further pages in/out as the camera moves (see + `TerrainSystem::updateStreamingWindow` and the streaming section of + `AGENTS.md`). 3. **Deactivation / scene clear** - Disable paging operations (`PageManager::setPagingOperationsEnabled(false)`) @@ -238,8 +245,8 @@ Ogre's `WorkQueue`. The following rules prevent crashes, leaks, and use-after-fr - **Do not drive paging from a worker thread in the editor.** In editor mode the camera is intentionally **not** added to `PageManager`; -pages are loaded synchronously through `TerrainGroup::loadAllTerrains( - true)`.This prevents +pages are loaded synchronously — the initial set through `TerrainGroup::loadAllTerrains( + true)` (non-streaming) or the load window around the camera plus `loadTerrain(x, y, true)` per streamed page (streaming mode). This prevents `CustomTerrainDefiner::define()` from being called on a background thread and touching GL / ECS state unsafely.- @@ -628,6 +635,12 @@ Use `EShapeSubType::User1` for the shape subtype. at component creation (e.g. `std::random_device{}() | (uint64_t)time(0) << 32`), serialized with the component, and survives save/reload cycles unchanged. - **Default size**: 256x256. +- **Streaming mode**: with `TerrainComponent::streamingEnabled` the legacy + heightmap buffer is not the base-height source; base heights come from + on-demand procedural evaluation of `TerrainComponent::baseNoise` + (FastNoiseLite OpenSimplex2, double precision) and pages stream in/out in a + `pageLoadRadius`/`pageHoldRadius` window around the camera. Edits write + through the fixup chunks (4.2). - Editable in the terrain editor with elevation and curve brushes. - A fresh empty heightmap initializes all samples to `0.0f`. @@ -1196,10 +1209,13 @@ target_link_libraries(editSceneEditor PUBLIC - CMake (`OgrePaging`/`OgreTerrain` links) and `EditorApp` wiring. - Component registration in `setupECS()` + editor module. -Definition of done: a paged terrain renders in the editor. In the current -editor mode the camera is not attached to `PageManager`, so all pages in the -configured range are loaded synchronously; the paging objects are present and -ready for runtime game mode where a camera can be attached. +Definition of done: a paged terrain renders in the editor. In legacy +(non-streaming) mode the camera is not attached to `PageManager`, so all pages +in the configured range are loaded synchronously; the paging objects are +present and ready for runtime game mode where a camera can be attached. With +`streamingEnabled` (added later) only the load window around the camera is +defined up front and `updateStreamingWindow()` streams pages in/out as the +camera moves. ### Milestone 2 — Physics integration ✅ DONE @@ -2158,7 +2174,7 @@ and remain pending. | M5.8 Mesh assembly per page | ✅ complete | page entities with `TriangleBufferComponent(proceduralContent)` + `RenderableComponent` + `NavMeshGeometrySource` + `LodComponent`, `roadPageMeshes` test | | M5.9 Road physics colliders | ✅ complete | `createPageCollider`/`destroyPageCollider` in `RoadSystem`, asserted in `roadPageMeshes` | | M5.9.5 Fixup chunk support | ✅ DONE (2026-07-29; grid corrected 2026-08-16) | `writeFixup`/`writeFixupSample`/`saveFixups`/`clearAllFixups`/`sampleFixupLocked` implemented in `TerrainSystem.cpp`; wired into `sampleHeightAtLocked`; "Clear All Fixups" UI button; chunk span corrected to the full per-page `worldSize` (see 4.2); `fixupChunks` test green | -| M5.10 Terrain compliance | ✅ DONE (2026-07-29; falloff added 2026-08-16) | `RoadSystem::complyTerrain()` walks road wedges/segments, writes fixup under each top-surface vertex, then fades over `laneWidth * 2` outside each curb (`writeComplianceFalloff`, per-sample writes); "Comply Terrain to Roads" button wired; `terrainCompliance` test green | +| M5.10 Terrain compliance | ✅ DONE (2026-07-29; falloff added 2026-08-16; clearance-hardened 2026-08-22; constraint-solver rework 2026-08-30) | `RoadSystem::complyTerrain()` collects road top-surface triangles and solves "rendered terrain stays 0.05 below the road top" as linear constraints on the rendered page-lattice vertices (actual per-row triangulation diagonal), with damped Kaczmarz lower-only sweeps plus a raise-only relaxation so the roadbed follows curved roads without phantom-diagonal over-excavation; per-vertex lowering is written via `TerrainSystem::lowerFixupCorners()` (sentinel corners materialize at the current surface, preserving the untouched surface exactly); "Comply Terrain to Roads" button wired; inverse `RoadSystem::complyRoadsToTerrain()` ("Comply Roads to Terrain") lifts/sinks nodes so edges stay `roadThickness + 0.05` above the terrain (constraints sampled against the rendered lattice surface, filtered to the actual road XZ footprint, solved with Kaczmarz deficit distribution + alternating lower relaxation and pairwise rebalancing); `terrainCompliance`, `terrainComplianceClearance` and `complyRoadsToTerrain` tests green | | M5.11 Roadside prefab spawning | ✅ DONE (2026-07-30; respawn fix 2026-08-16) | `RoadSystem::spawnSidePrefabs()` creates instances via `PrefabSystem` at edge positions with terrain-snapped Y; tracked and destroyed on page unload AND on mesh rebuild; spawned instances are stripped of `EditorMarkerComponent` so they stay runtime-only; `roadSidePrefabs` test green | | M5.12 Serialization + wiring | ✅ complete | serialization round-trip for roadConfig/nodes/edges/sidePrefabs; lifecycle + page detection + mesh/collider/prefab creation through M5.8/M5.9/M5.11; fixup save wired into SceneSerializer | @@ -2507,8 +2523,10 @@ default `LESS` depth test) but double the mesh memory, draw calls, collider bodies, and navmesh input. The `roadVisibilityDistance` expansion is kept only for the page world AABB used in navmesh dirty marking (M5.8). Trade-off accepted: a wedge disappears if its seed page unloads while a neighboring page -stays loaded — in the editor all pages are loaded synchronously, so this only -matters for future runtime paging. +stays loaded — with `streamingEnabled` pages genuinely stream in/out around +the camera, so this trade-off is live (the hold radius keeps a margin of +loaded pages beyond the load radius to limit it); in legacy mode all pages +are loaded synchronously and it never triggers. **Navigation mesh input**: Road surfaces are walkable, so every generated road page entity must be discoverable by `NavMeshSystem`. The `TriangleBufferComponent` @@ -2970,23 +2988,108 @@ writers yet. #### M5.10 Terrain compliance (conform, not flatten) -**Status (2026-08-16): ✅ DONE.** `RoadSystem::complyTerrain()` walks every -wedge and straight segment, samples the top-surface vertices, writes -`roadSurfaceY - roadThickness` into the fixup chunk at each (X,Z), applies a -smooth perpendicular falloff over `laneWidth * 2` -(`writeComplianceFalloff()`, per-sample fade-target writes; the fade pass -runs before the full-compliance pass so slab-underside values win on shared -samples), then marks affected pages dirty and saves the fixups. The -"Comply Terrain to Roads" button in `TerrainEditor` is wired and functional. -(The 2026-07-29 ✅ was wrong — the falloff did not exist and the fixup grid -was too dense for page meshes to see the writes; both fixed 2026-08-16, -covered by the `terrainCompliance` headless test.) +**Status (2026-08-30): ✅ DONE, constraint-solver rework.** +`RoadSystem::complyTerrain()` collects the road top-surface triangles +and solves "rendered terrain stays `ROAD_COMPLIANCE_SAG` (0.05 m) below +the road top" as linear constraints on the rendered page-lattice vertex +heights (see the 2026-08-30 note below), then writes the per-vertex +lowering as fixups via `TerrainSystem::lowerFixupCorners()`, marks +affected pages dirty and saves the fixups. The "Comply Terrain to +Roads" button in `TerrainEditor` is wired and functional. +(Supersedes the 2026-08-16 per-texel slab-underside writes with +perpendicular falloff and the 2026-08-22 clamp/cap passes; the original +2026-07-29 ✅ was wrong — the falloff did not exist and the fixup grid +was too dense for page meshes to see the writes.) + +**2026-08-22 clearance hardening** (covered by the +`terrainComplianceClearance` headless test, which replicates +`terrain2_test.json`): guarantees the rendered terrain never +rises above the road top. + +**2026-08-30 constraint-solver rework** (supersedes the earlier +clamp/cap passes): compliance is now handled as an iterative +constraint-solving problem on the *rendered page-lattice* vertices — +one vertex per `worldSize/(terrainSize-1)` (e.g. 31.25 m), the only +heights the renderer interpolates between: + +- *Constraint collection* (`collectComplianceConstraints`): every road + top-surface triangle (wedges, sidewalk wedges, segment and sidewalk + band quads) is clipped against each lattice cell it overlaps; each + candidate maximum point (cell-corner polygon vertices plus the + polygon-edge intersections with the cell's *actual* triangulation + diagonal — the renderer/collider zigzag by row parity, not both + diagonals) contributes one linear constraint on the 4 cell-corner + heights with barycentric weights and target `roadTop - + ROAD_COMPLIANCE_SAG` (0.05 m). Constraining the phantom second + diagonal fabricated violations up to metres high and caused massive + over-excavation far from the road. +- *Phase 1 — damped Kaczmarz lower-only sweeps*: each violated + constraint lowers its corners by the minimal-norm share of the excess + (shares proportional to the barycentric weights). Lowering can never + violate a constraint, so the sweeps converge to feasibility. +- *Phase 2 — raise-only relaxation*: every vertex is repeatedly set to + the highest value its constraints allow given the current neighbours + (capped at the natural height), recovering the hysteresis overshoot of + phase 1 (a vertex lowered while its neighbours were still high would + otherwise stay low) so the roadbed follows the road curvature instead + of collapsing flat to the lowest road nearby. +- *Fixup application* (`TerrainSystem::lowerFixupCorners`): the + per-vertex lowering is written as fixups on the 4 samples blending + into that lattice vertex; sentinel (unwritten) corners materialize at + the *current* surface height, so the post-write blend at the vertex + equals `natural - delta` exactly instead of snapping to texel-corner + naturals (which could even raise the terrain). + +The footprint stays within the lattice cells touching the road (only +lowering past the natural height never happens elsewhere) instead of +flattening the whole corridor. + +**Comply Roads to Terrain** (`RoadSystem::complyRoadsToTerrain()`, button +next to "Comply Terrain to Roads", covered by the `complyRoadsToTerrain` +headless test): the inverse operation — shifts road nodes vertically (down +or up) so every edge stays at least `roadThickness + 0.05` above the current +terrain. The road surface along a half-edge is linear between the node +surface and the edge midpoint, so every sampled terrain point yields one +linear constraint `coef*Yn + w*Ynb >= required` on the two endpoint node +heights. Constraints are sampled against `renderedHeightAt()` — the +rendered page-lattice surface evaluated with the actual per-row cell +triangulation diagonal the renderer/collider use — at dense 0.5 m +intervals along the centreline and both curb lines (extended backwards +over node wedges/end caps), at every lattice-line crossing of those +lines, and at every page vertex inside the road footprint (lanes plus +sidewalk only when enabled). Because the sampling rectangle around a +half-edge sticks out past the wedge fan / end cap behind a node (by up +to `halfWidth * sqrt(2)` at the corners), every sample is then filtered +against the actual XZ footprint of the road top surface (the same +wedge/band triangles `complyTerrain` collects, in a coarse lookup grid) +— terrain bumps outside the road mesh would otherwise lift nodes over +ground the road never covers. Connected nodes are initialized to +terrain + elevation and the constraint system is solved iteratively in +three phases: (1) Kaczmarz projection sweeps raise nodes to feasibility, +distributing each violated constraint's deficit between the node and its +neighbour along the constraint normal (minimum-norm correction) — a +per-node "raise to the max lower bound" update instead corners the +solution onto whichever node the update order hits first (a constraint +with a small neighbour coefficient is most cheaply satisfied by raising +the *other* node), leaving metres of hover over the far end; (2) +alternating damped lower-only relaxation (tightens nodes against the +terrain, clearing the slack the projection phase leaves on constraints +processed before their neighbours rose) and pairwise rebalancing (a +binding constraint with unequal coefficients is rebalanced by lowering +its small-coefficient node while raising the large-coefficient node to +compensate, which keeps the constraint satisfied and strictly reduces +the total node height — plain lowering alone sticks at such LP corners); +(3) a few raise-only passes clear the residual violations the +simultaneous damping of phase 2 can introduce. Node +`verticalOffset` values are re-derived from the terrain and the graph +version is bumped on change. 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**: +**Algorithm** (original per-texel design, superseded 2026-08-30 by the +lattice constraint solver described in the rework note above): 1. For every point on the generated road surface (edge samples + wedge samples), compute the world position `roadSurfacePos` of the road top. diff --git a/src/features/editScene/camera/EditorCamera.cpp b/src/features/editScene/camera/EditorCamera.cpp index c793e54..9d4c0ed 100644 --- a/src/features/editScene/camera/EditorCamera.cpp +++ b/src/features/editScene/camera/EditorCamera.cpp @@ -1,5 +1,7 @@ #include "EditorCamera.hpp" #include +#include +#include EditorCamera::EditorCamera(Ogre::SceneManager *sceneMgr, Ogre::RenderWindow *window) @@ -102,7 +104,8 @@ void EditorCamera::updateFPSMovement(float deltaTime) // Apply movement if (movement.squaredLength() > 0.0001f) { movement.normalise(); - m_target += movement * FPS_SPEED * deltaTime; + float speed = m_flySpeed * (m_keyShift ? SHIFT_BOOST : 1.0f); + m_target += movement * speed * deltaTime; m_targetNode->setPosition(m_target); m_cameraMan->setTarget(m_targetNode); } @@ -163,6 +166,7 @@ void EditorCamera::handleMouseRelease(const OgreBites::MouseButtonEvent &evt) false; // Disable FPS mode when right mouse is released // Reset key states m_keyW = m_keyS = m_keyA = m_keyD = m_keyQ = m_keyE = false; + m_keyShift = false; } else if (evt.button == OgreBites::BUTTON_MIDDLE) { m_panning = false; } @@ -198,9 +202,28 @@ void EditorCamera::handleKeyboard(const OgreBites::KeyboardEvent &evt) case 'E': m_keyE = pressed; break; + case OgreBites::SDLK_LSHIFT: + m_keyShift = pressed; + break; } } +void EditorCamera::handleMouseWheel(float y) +{ + if (y == 0.0f) + return; + m_distance *= std::pow(0.85f, y); + if (m_distance < 0.05f) + m_distance = 0.05f; + if (m_distance > 500000.0f) + m_distance = 500000.0f; +} + +void EditorCamera::setFlySpeed(float s) +{ + m_flySpeed = std::max(1.0f, std::min(100000.0f, s)); +} + void EditorCamera::focusOn(const Ogre::Vector3 &point) { m_target = point; @@ -215,6 +238,15 @@ void EditorCamera::setPosition(const Ogre::Vector3 &pos) m_cameraMan->setTarget(m_targetNode); } +void EditorCamera::shiftPosition(const Ogre::Vector3 &offset) +{ + m_position += offset; + m_target += offset; + m_targetNode->setPosition(m_target); + m_cameraNode->setPosition(m_cameraNode->getPosition() + offset); + m_cameraMan->setTarget(m_targetNode); +} + Ogre::Ray EditorCamera::getMouseRay(float screenX, float screenY) const { // Convert pixel coordinates to normalized viewport coordinates (0-1) diff --git a/src/features/editScene/camera/EditorCamera.hpp b/src/features/editScene/camera/EditorCamera.hpp index 70edcc2..828916d 100644 --- a/src/features/editScene/camera/EditorCamera.hpp +++ b/src/features/editScene/camera/EditorCamera.hpp @@ -49,6 +49,43 @@ public: */ void setPosition(const Ogre::Vector3 &pos); + /** + * Shift camera and target by a render-space offset (used by + * RenderOriginSystem on rebase) + */ + void shiftPosition(const Ogre::Vector3 &offset); + + /** + * Orbit target (render space) + */ + Ogre::Vector3 getTarget() const + { + return m_target; + } + + /** + * Orbit distance + */ + float getDistance() const + { + return m_distance; + } + + /** + * Mouse wheel: zoom the orbit distance (multiplicative). + */ + void handleMouseWheel(float y); + + /** + * Fly (FPS-mode) speed in units/second; Shift boosts x10. + * Range 1 .. 100000 (100 km/s) for streamed-world navigation. + */ + float getFlySpeed() const + { + return m_flySpeed; + } + void setFlySpeed(float s); + /** * Get camera position */ @@ -103,11 +140,13 @@ private: bool m_keyD; bool m_keyQ; bool m_keyE; + bool m_keyShift = false; // Movement speeds static constexpr float ROTATION_SPEED = 0.3f; static constexpr float PAN_SPEED = 0.01f; - static constexpr float FPS_SPEED = 10.0f; + float m_flySpeed = 10.0f; // units/second, see setFlySpeed + static constexpr float SHIFT_BOOST = 10.0f; }; #endif // EDITSCENE_EDITORCAMERA_HPP diff --git a/src/features/editScene/components/Terrain.hpp b/src/features/editScene/components/Terrain.hpp index 191a511..0871f92 100644 --- a/src/features/editScene/components/Terrain.hpp +++ b/src/features/editScene/components/Terrain.hpp @@ -73,6 +73,45 @@ struct TerrainComponent { }; DetailNoise detailNoise; + /* --- Streaming / far-distance rendering (world streaming + * groundwork) --- + * + * streamingEnabled switches the base-height source from the single + * legacy heightmap buffer to on-demand procedural evaluation of + * baseNoise over the whole bounded world. Page streaming itself is + * NOT implemented yet: the fixed 3x3 page grid is still loaded; only + * the height source and the far-fallback paths change. */ + bool streamingEnabled = false; + + // Total bounded world extent per axis, in world units. + double worldSizeUnits = 40000000.0; + + // Page streaming window radii (pages). Reserved for the streaming + // window task; currently unused. + int pageLoadRadius = 2; + int pageHoldRadius = 3; + + // Camera far clip applied while the terrain is active. + float farClipDistance = 6000.0f; + + // Linear fog applied while the terrain is active. + bool fogEnabled = true; + float fogStart = 2500.0f; + float fogEnd = 5500.0f; + + // Procedural base terrain shape (streaming mode only). Evaluated + // on demand at any world coordinate via FastNoiseLite; replaces the + // legacy heightmap buffer when streamingEnabled is true. + struct BaseNoise { + int seed = 1337; + int octaves = 4; + float frequency = 0.0005f; + float amplitude = 40.0f; + float lacunarity = 2.0f; + float persistence = 0.5f; + }; + BaseNoise baseNoise; + // Auxiliary maps (foliage density, material masks, etc.). struct AuxMap { std::string name; diff --git a/src/features/editScene/components/TerrainPrefabSpawner.hpp b/src/features/editScene/components/TerrainPrefabSpawner.hpp index c6a4338..af2d938 100644 --- a/src/features/editScene/components/TerrainPrefabSpawner.hpp +++ b/src/features/editScene/components/TerrainPrefabSpawner.hpp @@ -39,4 +39,20 @@ struct TerrainPrefabSpawnerComponent { flecs::entity_t spawnedEntity = 0; }; +/** + * Marks a spawner entity as owned by the streamed-region store (M4). + * + * Such entities are created/destroyed by TerrainPrefabSpawnerSystem as + * terrain pages stream in/out; their authoritative data lives in the + * SpawnerRegionStore page files (heightmaps//spawners/), so + * SceneSerializer must NOT write them into the scene JSON. The tag + * also identifies the def for edit write-back (move/delete/property + * edits in the editor update the region store). + */ +struct StreamedSpawnerTag { + long pageX = 0; + long pageY = 0; + uint64_t defId = 0; +}; + #endif // EDITSCENE_TERRAINPREFABSPAWNER_HPP diff --git a/src/features/editScene/components/Transform.hpp b/src/features/editScene/components/Transform.hpp index 75e591e..34b9d0f 100644 --- a/src/features/editScene/components/Transform.hpp +++ b/src/features/editScene/components/Transform.hpp @@ -12,7 +12,21 @@ struct TransformComponent { Ogre::Vector3 position = Ogre::Vector3::ZERO; Ogre::Quaternion rotation = Ogre::Quaternion::IDENTITY; Ogre::Vector3 scale = Ogre::Vector3::UNIT_SCALE; - + + /* Authoritative double-precision world position (absolute world + * space, independent of the render origin — see + * systems/RenderOriginSystem). Only meaningful when + * hasWorldPosition is true; legacy scenes leave it false and rely + * on the float `position` (which then doubles as the world + * position because the render origin starts at 0). For root-level + * entities `position` stays the render-space node-local value + * (world - renderOrigin); on rebase RenderOriginSystem recomputes + * it exactly from these doubles. */ + double worldX = 0.0; + double worldY = 0.0; + double worldZ = 0.0; + bool hasWorldPosition = false; + // Version tracking for change detection unsigned int version = 0; diff --git a/src/features/editScene/lua/LuaTerrainApi.cpp b/src/features/editScene/lua/LuaTerrainApi.cpp index dc37f56..02e22d0 100644 --- a/src/features/editScene/lua/LuaTerrainApi.cpp +++ b/src/features/editScene/lua/LuaTerrainApi.cpp @@ -111,7 +111,11 @@ static int luaTerrainPaint(lua_State *L) if (lua_isinteger(L, 5)) ts->setPaintLayerIndex((int)lua_tointeger(L, 5)); - ts->applySplatBrush(Ogre::Vector3(x, 0.0f, z)); + /* applySplatBrush works in RENDER space (page-local blend-map + * mapping through the TerrainGroup origin), so convert the visual + * world coordinates the API advertises. */ + Ogre::Vector3 rpos = ts->worldToRender((double)x, 0.0, (double)z); + ts->applySplatBrush(rpos); lua_pushboolean(L, 1); return 1; } diff --git a/src/features/editScene/lua/LuaTerrainApi.hpp b/src/features/editScene/lua/LuaTerrainApi.hpp index eb2a6dd..bfcba8c 100644 --- a/src/features/editScene/lua/LuaTerrainApi.hpp +++ b/src/features/editScene/lua/LuaTerrainApi.hpp @@ -25,6 +25,15 @@ namespace editScene * terrain.setAuxPaintMap(name) * terrain.saveHeightmap(), terrain.saveBlendMaps(), terrain.saveAuxMaps() * terrain.loadHeightmap(), terrain.loadBlendMaps(), terrain.loadAuxMaps() + * + * COORDINATE SPACES (M2): sculpt/paint/paintAux take absolute visual + * WORLD coordinates (double-precision-capable, render-origin + * independent — the same space NavigationPanel teleports and world + * bookmarks use); each binding converts internally + * (world->physical for sculpt/paintAux via visualToPhysicalX/Z, + * world->render for paint via worldToRender). sampleAux takes + * PHYSICAL heightmap coordinates instead (what sampleAuxMap expects); + * convert a world position with visualToPhysicalX/Z first if needed. */ void registerLuaTerrainApi(lua_State *L); diff --git a/src/features/editScene/physics/physics.cpp b/src/features/editScene/physics/physics.cpp index c5251ca..35a7d5a 100644 --- a/src/features/editScene/physics/physics.cpp +++ b/src/features/editScene/physics/physics.cpp @@ -962,10 +962,21 @@ public: const Ogre::Quaternion &rotation, JPH::EMotionType motion, JPH::ObjectLayer layer, ActivationListener *listener = nullptr) + { + return createBody(shape, mass, JoltPhysics::convert(position), + rotation, motion, layer, listener); + } + /* World-space (double precision) overload — absolute world + * positions must not round-trip through float (M2). */ + JPH::BodyID createBody(const JPH::Shape *shape, float mass, + const JPH::RVec3 &position, + const Ogre::Quaternion &rotation, + JPH::EMotionType motion, JPH::ObjectLayer layer, + ActivationListener *listener = nullptr) { JPH::BodyCreationSettings bodySettings( - shape, JoltPhysics::convert(position), - JoltPhysics::convert(rotation), motion, layer); + shape, position, JoltPhysics::convert(rotation), + motion, layer); if (mass > 0.001f) { JPH::MassProperties msp; msp.ScaleToMass(mass); @@ -1562,9 +1573,15 @@ public: } void setPosition(JPH::BodyID id, const Ogre::Vector3 &position, bool activate = true) + { + setPosition(id, JoltPhysics::convert(position), activate); + } + /* World-space (double precision) overload, see createBody. */ + void setPosition(JPH::BodyID id, const JPH::RVec3 &position, + bool activate = true) { physics_system.GetBodyInterface().SetPosition( - id, JoltPhysics::convert(position), + id, position, activate ? JPH::EActivation::Activate : JPH::EActivation::DontActivate); } @@ -1665,6 +1682,24 @@ public: } return hadHit; } + /* World-space (double precision) overload, see createBody. The + * hit position is returned in world space as well. */ + bool raycastQuery(const JPH::RVec3 &startPoint, + const JPH::RVec3 &endPoint, JPH::RVec3 &position, + JPH::BodyID &id) + { + JPH::Vec3 direction = JPH::Vec3(endPoint - startPoint); + JPH::RRayCast ray{ startPoint, direction }; + JPH::RayCastResult hit; + bool hadHit = physics_system.GetNarrowPhaseQuery().CastRay( + ray, hit, {}, + JPH::SpecifiedObjectLayerFilter(Layers::NON_MOVING)); + if (hadHit) { + position = ray.GetPointOnRay(hit.mFraction); + id = hit.mBodyID; + } + return hadHit; + } bool bodyIsCharacter(JPH::BodyID id) const { return characterBodies.find(id) != characterBodies.end(); @@ -1848,6 +1883,14 @@ JPH::BodyID JoltPhysicsWrapper::createBody(const JPH::Shape *shape, float mass, { return phys->createBody(shape, mass, position, rotation, motion, layer); } +JPH::BodyID JoltPhysicsWrapper::createBody(const JPH::Shape *shape, float mass, + const JPH::RVec3 &position, + const Ogre::Quaternion &rotation, + JPH::EMotionType motion, + JPH::ObjectLayer layer) +{ + return phys->createBody(shape, mass, position, rotation, motion, layer); +} JPH::BodyID JoltPhysicsWrapper::createBody(const JPH::Shape *shape, float mass, Ogre::SceneNode *node, JPH::EMotionType motion, @@ -1956,6 +1999,12 @@ void JoltPhysicsWrapper::setPosition(JPH::BodyID id, { return phys->setPosition(id, position, activate); } +void JoltPhysicsWrapper::setPosition(JPH::BodyID id, + const JPH::RVec3 &position, + bool activate) +{ + return phys->setPosition(id, position, activate); +} Ogre::Quaternion JoltPhysicsWrapper::getRotation(JPH::BodyID id) { return phys->getRotation(id); @@ -2033,6 +2082,13 @@ bool JoltPhysicsWrapper::raycastQuery(Ogre::Vector3 startPoint, return phys->raycastQuery(startPoint, endPoint, position, id); } +bool JoltPhysicsWrapper::raycastQuery(const JPH::RVec3 &startPoint, + const JPH::RVec3 &endPoint, + JPH::RVec3 &position, JPH::BodyID &id) +{ + return phys->raycastQuery(startPoint, endPoint, position, id); +} + bool JoltPhysicsWrapper::bodyIsCharacter(JPH::BodyID id) const { return phys->bodyIsCharacter(id); diff --git a/src/features/editScene/physics/physics.h b/src/features/editScene/physics/physics.h index 517204e..d1ebd65 100644 --- a/src/features/editScene/physics/physics.h +++ b/src/features/editScene/physics/physics.h @@ -163,6 +163,13 @@ public: const Ogre::Vector3 &position, const Ogre::Quaternion &rotation, JPH::EMotionType motion, JPH::ObjectLayer layer); + /* World-space (double precision) overload — use when the position + * is an absolute world coordinate that must not round-trip + * through float (render origin rebasing, M2). */ + JPH::BodyID createBody(const JPH::Shape *shape, float mass, + const JPH::RVec3 &position, + const Ogre::Quaternion &rotation, + JPH::EMotionType motion, JPH::ObjectLayer layer); JPH::BodyID createBody(const JPH::Shape *shape, float mass, Ogre::SceneNode *node, JPH::EMotionType motion, JPH::ObjectLayer layer); @@ -209,6 +216,9 @@ public: Ogre::Vector3 getPosition(JPH::BodyID id); void setPosition(JPH::BodyID id, const Ogre::Vector3 &position, bool activate = true); + /* World-space (double precision) overload, see createBody. */ + void setPosition(JPH::BodyID id, const JPH::RVec3 &position, + bool activate = true); Ogre::Quaternion getRotation(JPH::BodyID id); void setRotation(JPH::BodyID id, const Ogre::Quaternion &rotation, bool activate = true); @@ -237,6 +247,11 @@ public: void removeContactListener(const JPH::BodyID &id); bool raycastQuery(Ogre::Vector3 startPoint, Ogre::Vector3 endPoint, Ogre::Vector3 &position, JPH::BodyID &id); + /* World-space (double precision) overload, see createBody. The + * hit position is returned in world space as well. */ + bool raycastQuery(const JPH::RVec3 &startPoint, + const JPH::RVec3 &endPoint, JPH::RVec3 &position, + JPH::BodyID &id); bool bodyIsCharacter(JPH::BodyID id) const; void destroyCharacter(std::shared_ptr ch); Ogre::SceneNode *getSceneNodeFromBodyID(JPH::BodyID id) const; diff --git a/src/features/editScene/systems/EditorUISystem.cpp b/src/features/editScene/systems/EditorUISystem.cpp index ce218b5..93ef14f 100644 --- a/src/features/editScene/systems/EditorUISystem.cpp +++ b/src/features/editScene/systems/EditorUISystem.cpp @@ -84,6 +84,9 @@ EditorUISystem::EditorUISystem(flecs::world &world, m_characterRegistry.setSceneManager(m_sceneMgr); m_characterRegistry.setEditorUISystem(this); m_characterRegistry.initialize(); + + m_worldMapPanel.setWorld(&m_world); + m_worldMapPanel.setBookmarks(&m_navigationPanel.getBookmarks()); } EditorUISystem::~EditorUISystem() = default; @@ -341,28 +344,24 @@ void EditorUISystem::update(float deltaTime) hit); } else if ( ts->isAuxPainting()) { - Ogre::Vector3 - brushPos = - hit; - brushPos.x = ts->visualToPhysicalX( - hit.x); - brushPos.z = ts->visualToPhysicalZ( - hit.z); - ts->applyAuxBrush( + ts->applyAuxBrushPhysical( ts->getAuxPaintMapName(), - brushPos, + ts->visualToPhysicalX( + ts->renderToWorldX( + hit.x)), + ts->visualToPhysicalZ( + ts->renderToWorldZ( + hit.z)), ts->getPaintRadius(), ts->getPaintStrength()); } else { - Ogre::Vector3 - brushPos = - hit; - brushPos.x = ts->visualToPhysicalX( - hit.x); - brushPos.z = ts->visualToPhysicalZ( - hit.z); - ts->applySculptBrush( - brushPos); + ts->applySculptBrushPhysical( + ts->visualToPhysicalX( + ts->renderToWorldX( + hit.x)), + ts->visualToPhysicalZ( + ts->renderToWorldZ( + hit.z))); } } } else { @@ -406,6 +405,16 @@ void EditorUISystem::update(float deltaTime) renderPrefabBrowser(); renderCursorPanel(); + // Render Navigation panel (teleport + world bookmarks) + if (m_showNavigation) { + m_navigationPanel.render(&m_showNavigation); + } + + // Render World Map panel + if (m_showWorldMap) { + m_worldMapPanel.render(&m_showWorldMap); + } + // Render Action Database singleton editor window if (m_showActionDatabaseSingleton) { m_actionDatabaseSingletonEditor.render( @@ -527,6 +536,12 @@ void EditorUISystem::renderHierarchyWindow() if (ImGui::MenuItem("3D Cursor")) { m_showCursorPanel = true; } + if (ImGui::MenuItem("Navigation")) { + m_showNavigation = true; + } + if (ImGui::MenuItem("World Map")) { + m_showWorldMap = true; + } ImGui::Separator(); if (ImGui::MenuItem( "Action Database (Singleton)")) { @@ -1306,6 +1321,16 @@ void EditorUISystem::saveScene(const std::string &filepath) if (!m_serializer) return; + // Sync navigation bookmarks into the scene file + m_serializer->setBookmarks(m_navigationPanel.getBookmarks()); + + // Streamed road networks persist to per-page region files, not the + // scene JSON (M5); flush the loaded window so the latest edits hit + // disk together with the rest of the scene. + TerrainSystem *ts = TerrainSystem::getInstance(); + if (ts && ts->getStreamingActive() && ts->getRoadSystem()) + ts->getRoadSystem()->flushRegionStore(); + if (m_serializer->saveToFile(filepath)) { Ogre::LogManager::getSingleton().logMessage("Scene saved to: " + filepath); @@ -1322,6 +1347,7 @@ void EditorUISystem::loadScene(const std::string &filepath) return; if (m_serializer->loadFromFile(filepath, this)) { + m_navigationPanel.setBookmarks(m_serializer->getBookmarks()); Ogre::LogManager::getSingleton().logMessage( "Scene loaded from: " + filepath); } else { diff --git a/src/features/editScene/systems/EditorUISystem.hpp b/src/features/editScene/systems/EditorUISystem.hpp index 8b991f2..f957ca8 100644 --- a/src/features/editScene/systems/EditorUISystem.hpp +++ b/src/features/editScene/systems/EditorUISystem.hpp @@ -11,6 +11,8 @@ #include "../ui/ActionDatabaseSingletonEditor.hpp" #include "../ui/CharacterClassDatabaseEditor.hpp" #include "../ui/AnimationTreeRegistryEditor.hpp" +#include "../ui/NavigationPanel.hpp" +#include "../ui/WorldMapPanel.hpp" #include "../components/EntityName.hpp" #include "../gizmo/Gizmo.hpp" #include "../gizmo/RoadGizmo.hpp" @@ -174,6 +176,8 @@ public: void setEditorCamera(EditorCamera *camera) { m_editorCamera = camera; + m_navigationPanel.setEditorCamera(camera); + m_worldMapPanel.setEditorCamera(camera); } /** @@ -328,6 +332,14 @@ private: // Camera reference for cursor placement/rotation EditorCamera *m_editorCamera = nullptr; + // Navigation panel (teleport + world bookmarks) + bool m_showNavigation = false; + NavigationPanel m_navigationPanel; + + // World map panel (2D top-down map of the streamed world) + bool m_showWorldMap = false; + WorldMapPanel m_worldMapPanel; + // Action Database singleton editor state bool m_showActionDatabaseSingleton = false; ActionDatabaseSingletonEditor m_actionDatabaseSingletonEditor; diff --git a/src/features/editScene/systems/PrefabSystem.cpp b/src/features/editScene/systems/PrefabSystem.cpp index d3b9226..febb9e6 100644 --- a/src/features/editScene/systems/PrefabSystem.cpp +++ b/src/features/editScene/systems/PrefabSystem.cpp @@ -51,39 +51,33 @@ flecs::entity PrefabSystem::createInstance(const std::string &prefabPath, EditorUISystem *uiSystem) { // Read prefab root transform so we can preserve it as the base + // (cached parse, M4.4 — spawners instantiate repeatedly) Ogre::Vector3 prefabPos(0, 0, 0); Ogre::Quaternion prefabRot(Ogre::Quaternion::IDENTITY); Ogre::Vector3 prefabScale(1, 1, 1); try { - std::ifstream file(prefabPath); - if (file.is_open()) { - nlohmann::json prefabJson; - file >> prefabJson; - file.close(); - if (prefabJson.contains("transform")) { - auto &t = prefabJson["transform"]; - if (t.contains("position")) { - auto &p = t["position"]; - prefabPos = Ogre::Vector3( - p.value("x", 0.0f), - p.value("y", 0.0f), - p.value("z", 0.0f)); - } - if (t.contains("rotation")) { - auto &r = t["rotation"]; - prefabRot = Ogre::Quaternion( - r.value("w", 1.0f), - r.value("x", 0.0f), - r.value("y", 0.0f), - r.value("z", 0.0f)); - } - if (t.contains("scale")) { - auto &s = t["scale"]; - prefabScale = Ogre::Vector3( - s.value("x", 1.0f), - s.value("y", 1.0f), - s.value("z", 1.0f)); - } + const nlohmann::json *cached = + SceneSerializer::loadPrefabJsonCached(prefabPath); + if (cached && cached->contains("transform")) { + const auto &t = (*cached)["transform"]; + if (t.contains("position")) { + const auto &p = t["position"]; + prefabPos = Ogre::Vector3(p.value("x", 0.0f), + p.value("y", 0.0f), + p.value("z", 0.0f)); + } + if (t.contains("rotation")) { + const auto &r = t["rotation"]; + prefabRot = Ogre::Quaternion(r.value("w", 1.0f), + r.value("x", 0.0f), + r.value("y", 0.0f), + r.value("z", 0.0f)); + } + if (t.contains("scale")) { + const auto &s = t["scale"]; + prefabScale = Ogre::Vector3(s.value("x", 1.0f), + s.value("y", 1.0f), + s.value("z", 1.0f)); } } } catch (...) { @@ -218,6 +212,7 @@ bool PrefabSystem::deletePrefab(const std::string &prefabPath) m_lastError = "Failed to delete prefab: " + prefabPath; return false; } + SceneSerializer::invalidatePrefabJsonCache(prefabPath); Ogre::LogManager::getSingleton().logMessage( "PrefabSystem: Deleted prefab '" + prefabPath + "'"); return true; diff --git a/src/features/editScene/systems/RenderOriginSystem.cpp b/src/features/editScene/systems/RenderOriginSystem.cpp new file mode 100644 index 0000000..aabe0d1 --- /dev/null +++ b/src/features/editScene/systems/RenderOriginSystem.cpp @@ -0,0 +1,92 @@ +#include "RenderOriginSystem.hpp" +#include "TerrainSystem.hpp" +#include "../camera/EditorCamera.hpp" +#include "../components/Transform.hpp" +#include + +RenderOriginSystem *RenderOriginSystem::s_instance = nullptr; + +RenderOriginSystem::RenderOriginSystem(flecs::world &world, + Ogre::SceneManager *sceneMgr) + : m_world(world), m_sceneMgr(sceneMgr) +{ + s_instance = this; +} + +RenderOriginSystem::~RenderOriginSystem() +{ + if (s_instance == this) + s_instance = nullptr; +} + +Ogre::Vector3 RenderOriginSystem::worldToRender(double x, double y, + double z) const +{ + return Ogre::Vector3((float)(x - m_origin.GetX()), + (float)(y - m_origin.GetY()), + (float)(z - m_origin.GetZ())); +} + +JPH::DVec3 RenderOriginSystem::renderToWorld(const Ogre::Vector3 &p) const +{ + return JPH::DVec3(m_origin.GetX() + p.x, m_origin.GetY() + p.y, + m_origin.GetZ() + p.z); +} + +void RenderOriginSystem::update() +{ + if (!m_editorCamera || !m_editorCamera->getCamera()) + return; + + Ogre::Vector3 p = m_editorCamera->getCamera()->getDerivedPosition(); + if (fabsf(p.x) < REBASE_THRESHOLD && + fabsf(p.y) < REBASE_THRESHOLD && fabsf(p.z) < REBASE_THRESHOLD) + return; + + /* Snap the new origin to integer world coordinates so render + * deltas stay exactly representable in float. */ + JPH::DVec3 newOrigin(m_origin.GetX() + floor(p.x), + m_origin.GetY() + floor(p.y), + m_origin.GetZ() + floor(p.z)); + rebase(newOrigin); +} + +void RenderOriginSystem::rebase(const JPH::DVec3 &newOrigin) +{ + JPH::DVec3 delta = newOrigin - m_origin; + if (delta == JPH::DVec3(0.0, 0.0, 0.0)) + return; + + Ogre::Vector3 deltaF((float)delta.GetX(), (float)delta.GetY(), + (float)delta.GetZ()); + m_origin = newOrigin; + + Ogre::SceneNode *root = m_sceneMgr->getRootSceneNode(); + + /* Shift root-level entity nodes. Entities with an authoritative + * double world position are recomputed exactly; legacy entities + * are shifted by the render delta. */ + m_world.query().each( + [&](flecs::entity /*e*/, TransformComponent &tc) { + if (!tc.node || + tc.node->getParentSceneNode() != root) + return; + if (tc.hasWorldPosition) + tc.position = worldToRender(tc.worldX, + tc.worldY, + tc.worldZ); + else + tc.position -= deltaF; + tc.node->setPosition(tc.position); + }); + + if (m_gridNode) + m_gridNode->setPosition(m_gridNode->getPosition() - deltaF); + if (m_axisNode) + m_axisNode->setPosition(m_axisNode->getPosition() - deltaF); + + if (m_editorCamera) + m_editorCamera->shiftPosition(-deltaF); + if (m_terrainSystem) + m_terrainSystem->onRenderOriginChanged(deltaF); +} diff --git a/src/features/editScene/systems/RenderOriginSystem.hpp b/src/features/editScene/systems/RenderOriginSystem.hpp new file mode 100644 index 0000000..012b942 --- /dev/null +++ b/src/features/editScene/systems/RenderOriginSystem.hpp @@ -0,0 +1,87 @@ +#ifndef EDITSCENE_RENDERORIGINSYSTEM_HPP +#define EDITSCENE_RENDERORIGINSYSTEM_HPP +#pragma once +#include +#include +#include +#include + +class EditorCamera; +class TerrainSystem; + +/** + * RenderOriginSystem - floating render origin for huge streamed worlds. + * + * The world spans up to 40,000,000 x 40,000,000 units, far beyond + * what 32-bit float coordinates can represent. Rendering therefore + * happens in "render space": render = world - renderOrigin, keeping + * everything near the float origin around the camera. Authoritative + * absolute positions live in double precision (TransformComponent + * worldX/Y/Z, the TerrainSystem world origin); this system tracks the + * current render origin and rebases when the camera moves further + * than REBASE_THRESHOLD from it: the origin snaps to integer world + * coordinates near the camera and every render-space position is + * shifted by the exact negated delta. + * + * TerrainSystem keeps most of its public position APIs in render + * space; it converts to absolute world coordinates internally wherever + * precision matters and is notified of rebases via + * onRenderOriginChanged(). (Exception: the sculpt/aux brush apply + * APIs take physical heightmap coords — see TerrainSystem.hpp.) + */ +class RenderOriginSystem { +public: + RenderOriginSystem(flecs::world &world, Ogre::SceneManager *sceneMgr); + ~RenderOriginSystem(); + + static RenderOriginSystem *getInstance() + { + return s_instance; + } + + void setTerrainSystem(TerrainSystem *ts) + { + m_terrainSystem = ts; + } + void setEditorCamera(EditorCamera *cam) + { + m_editorCamera = cam; + } + void setAuxNodes(Ogre::SceneNode *gridNode, Ogre::SceneNode *axisNode) + { + m_gridNode = gridNode; + m_axisNode = axisNode; + } + + const JPH::DVec3 &getOrigin() const + { + return m_origin; + } + + Ogre::Vector3 worldToRender(double x, double y, double z) const; + JPH::DVec3 renderToWorld(const Ogre::Vector3 &p) const; + + /* Poll the editor camera and rebase once it has moved further + * than REBASE_THRESHOLD from the current origin. Call once per + * frame, before TerrainSystem::update(). */ + void update(); + + /* Move the render origin to @p newOrigin (absolute world space) + * and shift the whole render scene by the negated delta. */ + void rebase(const JPH::DVec3 &newOrigin); + + static constexpr float REBASE_THRESHOLD = 8192.0f; + +private: + static RenderOriginSystem *s_instance; + + flecs::world &m_world; + Ogre::SceneManager *m_sceneMgr; + TerrainSystem *m_terrainSystem = nullptr; + EditorCamera *m_editorCamera = nullptr; + Ogre::SceneNode *m_gridNode = nullptr; + Ogre::SceneNode *m_axisNode = nullptr; + JPH::DVec3 m_origin = JPH::DVec3(0.0, 0.0, 0.0); +}; + +#endif // EDITSCENE_RENDERORIGINSYSTEM_HPP diff --git a/src/features/editScene/systems/RoadRegionStore.cpp b/src/features/editScene/systems/RoadRegionStore.cpp new file mode 100644 index 0000000..c25a090 --- /dev/null +++ b/src/features/editScene/systems/RoadRegionStore.cpp @@ -0,0 +1,207 @@ +#include "RoadRegionStore.hpp" + +#include +#include + +#include +#include + +void RoadRegionStore::setRootDirectory(const std::string &dir) +{ + if (dir == m_rootDir) + return; + m_rootDir = dir; + clearCache(); +} + +void RoadRegionStore::clearCache() +{ + m_pages.clear(); +} + +std::string RoadRegionStore::pageFilePath(const std::string &rootDir, + long pageX, long pageY) +{ + return rootDir + "/roads/" + std::to_string(pageX) + "_" + + std::to_string(pageY) + ".json"; +} + +static void prefabToJson(const RoadRegionEdgePrefab &src, nlohmann::json &out) +{ + out["prefabPath"] = src.prefabPath; + out["edgeT"] = src.edgeT; + out["lateralOffset"] = src.lateralOffset; + out["yOffset"] = src.yOffset; +} + +static void prefabFromJson(const nlohmann::json &js, + RoadRegionEdgePrefab &out) +{ + out.prefabPath = js.value("prefabPath", std::string()); + out.edgeT = js.value("edgeT", 0.5f); + out.lateralOffset = js.value("lateralOffset", 0.0f); + out.yOffset = js.value("yOffset", 0.0f); +} + +RoadRegionStore::PageData &RoadRegionStore::loadPage(long pageX, long pageY) +{ + const uint64_t key = packPageKey(pageX, pageY); + auto it = m_pages.find(key); + if (it != m_pages.end()) + return it->second; + + PageData page; + if (!m_rootDir.empty()) { + const std::string path = pageFilePath(m_rootDir, pageX, pageY); + try { + std::ifstream file(path); + if (file.is_open()) { + nlohmann::json j; + file >> j; + if (j.contains("nodes") && j["nodes"].is_array()) { + for (const nlohmann::json &jn : + j["nodes"]) { + RoadRegionNode n; + n.id = jn.value("id", + (uint64_t)0); + n.x = jn.value("x", 0.0); + n.y = jn.value("y", 0.0); + n.z = jn.value("z", 0.0); + n.verticalOffset = jn.value( + "verticalOffset", 0.0f); + page.data.nodes.push_back(n); + } + } + if (j.contains("edges") && j["edges"].is_array()) { + for (const nlohmann::json &je : + j["edges"]) { + RoadRegionEdge e; + e.nodeAId = je.value( + "nodeA", (uint64_t)0); + e.nodeBId = je.value( + "nodeB", (uint64_t)0); + e.roadLevelA = je.value( + "roadLevelA", 0.0f); + e.roadLevelB = je.value( + "roadLevelB", 0.0f); + e.lanesPerDirectionOverride = + je.value("lanesPerDirectionOverride", + 0); + e.lanesAtoB = je.value( + "lanesAtoB", (uint32_t)0); + e.lanesBtoA = je.value( + "lanesBtoA", (uint32_t)0); + if (je.contains("prefabLeft")) + prefabFromJson( + je["prefabLeft"], + e.prefabLeft); + if (je.contains("prefabRight")) + prefabFromJson( + je["prefabRight"], + e.prefabRight); + if (je.contains("prefabMid")) + prefabFromJson( + je["prefabMid"], + e.prefabMid); + page.data.edges.push_back(e); + } + } + page.loaded = true; + } + } catch (const std::exception &e) { + Ogre::LogManager::getSingleton().logMessage( + "RoadRegionStore: failed to load " + path + + ": " + e.what()); + } + } + + return m_pages.emplace(key, std::move(page)).first->second; +} + +bool RoadRegionStore::getRegion(long px, long py, RoadRegionData *out) +{ + if (m_rootDir.empty() || !out) + return false; + PageData &page = loadPage(px, py); + if (!page.loaded) + return false; + *out = page.data; + return true; +} + +bool RoadRegionStore::saveRegion(long px, long py, const RoadRegionData &data) +{ + if (m_rootDir.empty()) + return false; + + try { + const std::string dir = m_rootDir + "/roads"; + std::filesystem::create_directories(dir); + + nlohmann::json j; + j["version"] = "1.0"; + j["page"] = { px, py }; + j["nodes"] = nlohmann::json::array(); + for (const RoadRegionNode &n : data.nodes) { + nlohmann::json jn; + jn["id"] = n.id; + jn["x"] = n.x; + jn["y"] = n.y; + jn["z"] = n.z; + jn["verticalOffset"] = n.verticalOffset; + j["nodes"].push_back(jn); + } + j["edges"] = nlohmann::json::array(); + for (const RoadRegionEdge &e : data.edges) { + nlohmann::json je; + je["nodeA"] = e.nodeAId; + je["nodeB"] = e.nodeBId; + je["roadLevelA"] = e.roadLevelA; + je["roadLevelB"] = e.roadLevelB; + je["lanesPerDirectionOverride"] = + e.lanesPerDirectionOverride; + je["lanesAtoB"] = e.lanesAtoB; + je["lanesBtoA"] = e.lanesBtoA; + if (!e.prefabLeft.prefabPath.empty()) + prefabToJson(e.prefabLeft, je["prefabLeft"]); + if (!e.prefabRight.prefabPath.empty()) + prefabToJson(e.prefabRight, je["prefabRight"]); + if (!e.prefabMid.prefabPath.empty()) + prefabToJson(e.prefabMid, je["prefabMid"]); + j["edges"].push_back(je); + } + + const std::string path = pageFilePath(m_rootDir, px, py); + std::ofstream file(path); + if (!file.is_open()) { + Ogre::LogManager::getSingleton().logMessage( + "RoadRegionStore: failed to write " + path); + return false; + } + file << j.dump(4); + + PageData &page = loadPage(px, py); + page.data = data; + page.loaded = true; + return true; + } catch (const std::exception &e) { + Ogre::LogManager::getSingleton().logMessage( + "RoadRegionStore: save error: " + + std::string(e.what())); + return false; + } +} + +void RoadRegionStore::removeRegionFile(long px, long py) +{ + m_pages.erase(packPageKey(px, py)); + if (m_rootDir.empty()) + return; + try { + std::filesystem::remove(pageFilePath(m_rootDir, px, py)); + } catch (const std::exception &e) { + Ogre::LogManager::getSingleton().logMessage( + "RoadRegionStore: remove error: " + + std::string(e.what())); + } +} diff --git a/src/features/editScene/systems/RoadRegionStore.hpp b/src/features/editScene/systems/RoadRegionStore.hpp new file mode 100644 index 0000000..eacd04e --- /dev/null +++ b/src/features/editScene/systems/RoadRegionStore.hpp @@ -0,0 +1,96 @@ +#pragma once + +#include +#include +#include +#include + +/* + * One road node stored in a region file. Positions are absolute world + * space doubles so regions work at any distance from the origin + * (RoadNode.position is only float and cannot represent those). + */ +struct RoadRegionNode { + uint64_t id = 0; + double x = 0.0; + double y = 0.0; + double z = 0.0; + float verticalOffset = 0.0f; +}; + +struct RoadRegionEdgePrefab { + std::string prefabPath; + float edgeT = 0.5f; + float lateralOffset = 0.0f; + float yOffset = 0.0f; +}; + +/* + * One road edge stored in a region file. Endpoints are referenced by + * the region-local node ids of this file. Field semantics match + * RoadEdge (0 lane count = "use RoadConfig::lanesPerDirection"). + */ +struct RoadRegionEdge { + uint64_t nodeAId = 0; + uint64_t nodeBId = 0; + float roadLevelA = 0.0f; + float roadLevelB = 0.0f; + int lanesPerDirectionOverride = 0; + uint32_t lanesAtoB = 0; + uint32_t lanesBtoA = 0; + RoadRegionEdgePrefab prefabLeft; + RoadRegionEdgePrefab prefabRight; + RoadRegionEdgePrefab prefabMid; +}; + +/* + * Contents of one road region file. Every edge lists the node ids it + * references; nodes that belong to a different page are repeated inline + * (foreign endpoints) so the file is self contained. + */ +struct RoadRegionData { + std::vector nodes; + std::vector edges; +}; + +/* + * Owns the on-disk storage for road network region files. Files live + * under "/roads/_.json" where px/py are PHYSICAL page + * indices derived from world positions, matching the terrain page that + * contains the node (round-half convention, same as + * RoadGraph::edgeStaysWithinOnePage). + */ +class RoadRegionStore +{ +public: + void setRootDirectory(const std::string &dir); + const std::string &getRootDirectory() const + { + return m_rootDir; + } + + static uint64_t packPageKey(long x, long y) + { + return ((uint64_t)(uint32_t)(int32_t)x << 32) | + (uint32_t)(int32_t)y; + } + + static std::string pageFilePath(const std::string &rootDir, + long pageX, long pageY); + + bool getRegion(long px, long py, RoadRegionData *out); + bool saveRegion(long px, long py, const RoadRegionData &data); + void removeRegionFile(long px, long py); + void clearCache(); + +private: + struct PageData { + RoadRegionData data; + bool loaded = false; + }; + + PageData &loadPage(long px, long py); + + std::string m_rootDir; + std::map m_pages; +}; diff --git a/src/features/editScene/systems/RoadSystem.cpp b/src/features/editScene/systems/RoadSystem.cpp index b999cf5..cd25aff 100644 --- a/src/features/editScene/systems/RoadSystem.cpp +++ b/src/features/editScene/systems/RoadSystem.cpp @@ -16,8 +16,11 @@ #include #include #include +#include #include #include +#include +#include #include static const float NODE_SIZE = 0.25f; @@ -109,6 +112,7 @@ void RoadSystem::setTerrainEntity(flecs::entity entity) m_terrainEntityId = 0; } m_lastGraphVersion = 0; + resetRegionState(); clearPageGeometry(); rebuildVisualAids(); } @@ -119,6 +123,7 @@ void RoadSystem::clear() m_selectedNodeId = -1; m_selectedEdgeIndex = -1; m_lastGraphVersion = 0; + resetRegionState(); clearPageGeometry(); rebuildVisualAids(); } @@ -230,10 +235,17 @@ void RoadSystem::update(float deltaTime) m_lastGraphVersion = 0; rebuildVisualAids(); } + resetRegionState(); clearPageGeometry(); return; } + /* Region streaming (M5) runs first: it merges/extracts graph + * nodes for pages that loaded/unloaded since the last frame, which + * bumps the graph version for the checks below. */ + m_streamActivity = false; + syncRegions(); + const TerrainComponent &tc = terrain.get(); if (tc.roadGraph.version != m_lastGraphVersion) { m_lastGraphVersion = tc.roadGraph.version; @@ -255,6 +267,7 @@ void RoadSystem::update(float deltaTime) if (pg.dirty) { buildPageMeshes(pg); pg.dirty = false; + m_streamActivity = true; } if (pg.meshFinalized || !pg.bufferEntity.is_alive() || !pg.bufferEntity.has()) @@ -292,6 +305,20 @@ void RoadSystem::update(float deltaTime) m_lastDebugWedgeIndex = m_debugWedgeIndex; m_lastDebugGraphVersion = tc.roadGraph.version; } + + /* Streamed navmesh dirty-marking is debounced: page churn during + * streaming would otherwise request a full navmesh rebuild every + * frame. Apply the pending mark only after 15 consecutive frames + * without region or page geometry transitions. */ + if (m_streamActivity) + m_navMeshQuietFrames = 0; + else if (m_navMeshQuietFrames < 15) + ++m_navMeshQuietFrames; + if (m_navMeshDirtyPending && m_navMeshQuietFrames >= 15) { + m_navMeshDirtyPending = false; + m_navMeshQuietFrames = 0; + applyNavMeshDirty(); + } } Ogre::Vector3 RoadSystem::getNodePosition(int nodeId) const @@ -347,6 +374,20 @@ bool RoadSystem::pageKeyForNode(int nodeId, uint64_t &outKey) const if (!n) return false; + if (roadStreamingActive()) { + /* Derive the page from the absolute world position so a + * render-origin rebase cannot corrupt the bucketing. The + * physical page (px, py) lives in slot (px, -py). */ + long px = 0, py = 0; + if (!pageForWorldPos( + m_terrainSystem->renderToWorldX(n->position.x), + m_terrainSystem->renderToWorldZ(n->position.z), + px, py)) + return false; + outKey = m_terrainGroup->packIndex(px, -py); + return true; + } + long px = 0, py = 0; m_terrainGroup->convertWorldPositionToTerrainSlot(n->position, &px, &py); @@ -356,6 +397,10 @@ bool RoadSystem::pageKeyForNode(int nodeId, uint64_t &outKey) const void RoadSystem::applyPageBucket(uint64_t key, RoadPageGeometry &pg) { + /* Always copy the fresh buckets (they embed render-space positions + * that change on rebase), but only re-dirty the page when the + * rebase-invariant content signature actually changed — a plain + * graph version bump used to rebuild EVERY page's mesh. */ pg.wedges.clear(); pg.segments.clear(); auto it = m_wedgeBuckets.find(key); @@ -363,7 +408,11 @@ void RoadSystem::applyPageBucket(uint64_t key, RoadPageGeometry &pg) pg.wedges = it->second.wedges; pg.segments = it->second.segments; } - pg.dirty = true; + const uint64_t signature = computePageSignature(key); + if (signature != pg.signature) { + pg.signature = signature; + pg.dirty = true; + } } void RoadSystem::reassignWedges() @@ -422,6 +471,7 @@ void RoadSystem::syncPages() pg.pageY = slot->y; applyPageBucket(key, pg); m_pageGeometry.emplace(key, std::move(pg)); + m_streamActivity = true; } /* Destroy entries whose page unloaded or was removed. */ @@ -429,6 +479,7 @@ void RoadSystem::syncPages() if (loaded.find(it->first) == loaded.end()) { destroyPageGeometry(it->second); it = m_pageGeometry.erase(it); + m_streamActivity = true; } else { ++it; } @@ -471,6 +522,614 @@ void RoadSystem::clearPageGeometry() m_lodSettingsEntity = flecs::entity::null(); } +/* ------------------------------------------------------------------ */ +/* Road region streaming (M5) */ +/* */ +/* The road graph only holds the merged node/edge data of the loaded */ +/* terrain pages; every page's data persists in a region file under */ +/* heightmaps//roads/_.json with absolute world- */ +/* space double positions. A node belongs to the page containing its */ +/* position; an edge is stored in the region file of EACH endpoint */ +/* node's page, with a foreign endpoint repeated inline. */ +/* ------------------------------------------------------------------ */ + +void RoadSystem::resetRegionState() +{ + m_loadedRegions.clear(); + m_regionRoot.clear(); + m_regionStore.clearCache(); + m_roadMigrated = false; + m_navMeshDirtyPending = false; + m_navMeshQuietFrames = 0; +} + +bool RoadSystem::roadStreamingActive() const +{ + TerrainSystem *ts = TerrainSystem::getInstance(); + return ts && ts->getStreamingActive() && ts->getTerrainGroup() && + m_terrainEntityId != 0; +} + +bool RoadSystem::pageForWorldPos(double wx, double wz, long &outX, + long &outY) const +{ + TerrainSystem *ts = TerrainSystem::getInstance(); + if (!ts || !ts->getStreamingActive() || !ts->getTerrainGroup()) + return false; + /* Same convention as TerrainPrefabSpawnerSystem::pageForWorld: + * physical page indices, clamped to the world bounds. The Z + * mapping mirrors the axis (visualToPhysicalZ), so a point + * exactly on a page border maps to the top edge of the physical + * page BELOW it and a plain floor() would pick the wrong page; + * nudge the coordinate down by a hair so the border goes to the + * page the connectNodes round-half rule picks. */ + const double ws = ts->getTerrainGroup()->getTerrainWorldSize(); + const long pages = ts->getStreamingPageCount(); + long px = (long)std::floor(ts->visualToPhysicalX(wx) / ws); + long py = (long)std::floor((ts->visualToPhysicalZ(wz) - 1e-6) / ws); + if (px < 0) + px = 0; + if (py < 0) + py = 0; + if (px > pages - 1) + px = pages - 1; + if (py > pages - 1) + py = pages - 1; + outX = px; + outY = py; + return true; +} + +int RoadSystem::findNodeByWorldPos(const RoadGraph &graph, double wx, + double wz) const +{ + TerrainSystem *ts = TerrainSystem::getInstance(); + if (!ts) + return -1; + /* XZ match only: the Y of a node is terrain_height + offset and + * may legitimately change (sculpting) between the write and the + * merge of a region file. */ + for (const RoadNode &n : graph.nodes) { + const double nx = ts->renderToWorldX(n.position.x); + const double nz = ts->renderToWorldZ(n.position.z); + if (std::fabs(nx - wx) <= 0.01 && std::fabs(nz - wz) <= 0.01) + return n.id; + } + return -1; +} + +void RoadSystem::syncRegions() +{ + TerrainSystem *ts = TerrainSystem::getInstance(); + if (!ts || !ts->getStreamingActive() || !ts->getTerrainGroup()) { + /* Streaming off: drop the tracking state so a mode switch + * starts clean (the graph itself is the inline data). */ + if (!m_loadedRegions.empty() || !m_regionRoot.empty()) + resetRegionState(); + return; + } + + flecs::entity terrain = getTerrainEntity(); + if (!terrain.is_alive() || !terrain.has()) + return; + RoadGraph &graph = + terrain.get_mut().roadGraph; + + const std::string root = + "heightmaps/" + + std::to_string(terrain.get().terrainId); + if (root != m_regionRoot) { + m_regionRoot = root; + m_regionStore.setRootDirectory(root); + } + + Ogre::TerrainGroup *group = ts->getTerrainGroup(); + + /* One-time migration of legacy inline scene data (deserialized + * roadNodes/roadEdges) into per-page region files; the normal + * page-load merge below repopulates the loaded window. */ + if (!m_roadMigrated) { + m_roadMigrated = true; + if (!graph.nodes.empty() || !graph.edges.empty()) { + migrateGraphToRegions(graph); + m_streamActivity = true; + } + } + + /* Loaded physical pages (slot (x, y) is physical page (x, -y)). */ + std::set loaded; + for (const auto &kv : group->getTerrainSlots()) { + Ogre::TerrainGroup::TerrainSlot *slot = kv.second; + if (!slot || !slot->instance || !slot->instance->isLoaded()) + continue; + loaded.insert( + RoadRegionStore::packPageKey(slot->x, -slot->y)); + } + + /* Merge the region files of newly loaded pages. */ + for (uint64_t key : loaded) { + if (m_loadedRegions.count(key)) + continue; + const long px = (long)(int32_t)(key >> 32); + const long py = (long)(int32_t)(key & 0xffffffffu); + /* Register even when the page has no region file so it is + * not re-read from disk every frame. */ + m_loadedRegions.insert(key); + loadRegion(px, py, graph); + m_streamActivity = true; + } + + /* Extract and drop the pages that left the window. Two phases: + * write every departing page's region file BEFORE removing any + * nodes, so a cross-page edge whose endpoint pages leave in the + * same frame is still in the graph when the second file is + * written (removeNode cascades incident edges). */ + std::vector > gone; + for (auto it = m_loadedRegions.begin(); + it != m_loadedRegions.end();) { + if (loaded.find(*it) == loaded.end()) { + gone.push_back( + { (long)(int32_t)(*it >> 32), + (long)(int32_t)(*it & 0xffffffffu) }); + it = m_loadedRegions.erase(it); + } else { + ++it; + } + } + for (const auto &g : gone) { + RoadRegionData data; + buildRegionData(g.first, g.second, graph, data); + m_regionStore.saveRegion(g.first, g.second, data); + } + for (const auto &g : gone) { + std::vector localIds; + for (const RoadNode &n : graph.nodes) { + long npx = 0, npy = 0; + pageForWorldPos(ts->renderToWorldX(n.position.x), + ts->renderToWorldZ(n.position.z), + npx, npy); + if (npx == g.first && npy == g.second) + localIds.push_back(n.id); + } + for (int id : localIds) + graph.removeNode(id); + } + if (!gone.empty()) + m_streamActivity = true; +} + +void RoadSystem::migrateGraphToRegions(RoadGraph &graph) +{ + TerrainSystem *ts = TerrainSystem::getInstance(); + if (!ts) + return; + + /* Node world positions and page membership. */ + struct NodeInfo { + double wx, wy, wz; + uint64_t pageKey; + }; + std::map nodeInfo; + std::map pages; + for (const RoadNode &n : graph.nodes) { + NodeInfo info; + info.wx = ts->renderToWorldX(n.position.x); + info.wy = ts->renderToWorldY(n.position.y); + info.wz = ts->renderToWorldZ(n.position.z); + long px = 0, py = 0; + pageForWorldPos(info.wx, info.wz, px, py); + info.pageKey = RoadRegionStore::packPageKey(px, py); + nodeInfo[n.id] = info; + + RoadRegionNode rn; + rn.id = (uint64_t)(uint32_t)n.id; + rn.x = info.wx; + rn.y = info.wy; + rn.z = info.wz; + rn.verticalOffset = n.verticalOffset; + pages[info.pageKey].nodes.push_back(rn); + } + + /* An edge is stored in the region file of each endpoint page, + * with a foreign endpoint repeated inline. */ + for (const RoadEdge &e : graph.edges) { + auto ia = nodeInfo.find(e.nodeA); + auto ib = nodeInfo.find(e.nodeB); + if (ia == nodeInfo.end() || ib == nodeInfo.end()) + continue; + uint64_t keys[2] = { ia->second.pageKey, ib->second.pageKey }; + for (int k = 0; k < 2; ++k) { + if (k == 1 && keys[1] == keys[0]) + break; + RoadRegionData &data = pages[keys[k]]; + for (int endpoint = 0; endpoint < 2; ++endpoint) { + const NodeInfo &ni = endpoint == 0 ? + ia->second : ib->second; + const int nid = endpoint == 0 ? + e.nodeA : e.nodeB; + if (ni.pageKey == keys[k]) + continue; /* local: already listed */ + bool listed = false; + for (const RoadRegionNode &rn : data.nodes) + if (rn.id == + (uint64_t)(uint32_t)nid) { + listed = true; + break; + } + if (listed) + continue; + const RoadNode *fn = graph.findNodeById(nid); + RoadRegionNode rn; + rn.id = (uint64_t)(uint32_t)nid; + rn.x = ni.wx; + rn.y = ni.wy; + rn.z = ni.wz; + rn.verticalOffset = + fn ? fn->verticalOffset : 0.0f; + data.nodes.push_back(rn); + } + RoadRegionEdge re; + re.nodeAId = (uint64_t)(uint32_t)e.nodeA; + re.nodeBId = (uint64_t)(uint32_t)e.nodeB; + re.roadLevelA = e.roadLevelA; + re.roadLevelB = e.roadLevelB; + re.lanesPerDirectionOverride = + e.lanesPerDirectionOverride; + re.lanesAtoB = (uint32_t)e.lanesAtoB; + re.lanesBtoA = (uint32_t)e.lanesBtoA; + re.prefabLeft.prefabPath = e.prefabLeft.prefabPath; + re.prefabLeft.edgeT = e.prefabLeft.edgeT; + re.prefabLeft.lateralOffset = + e.prefabLeft.lateralOffset; + re.prefabLeft.yOffset = e.prefabLeft.yOffset; + re.prefabRight.prefabPath = e.prefabRight.prefabPath; + re.prefabRight.edgeT = e.prefabRight.edgeT; + re.prefabRight.lateralOffset = + e.prefabRight.lateralOffset; + re.prefabRight.yOffset = e.prefabRight.yOffset; + re.prefabMid.prefabPath = e.prefabMid.prefabPath; + re.prefabMid.edgeT = e.prefabMid.edgeT; + re.prefabMid.lateralOffset = e.prefabMid.lateralOffset; + re.prefabMid.yOffset = e.prefabMid.yOffset; + data.edges.push_back(re); + } + } + + for (auto &kv : pages) { + const long px = (long)(int32_t)(kv.first >> 32); + const long py = (long)(int32_t)(kv.first & 0xffffffffu); + m_regionStore.saveRegion(px, py, kv.second); + } + + /* The loaded window repopulates from the region files through + * the normal merge path. The config is global and stays. */ + graph.nodes.clear(); + graph.edges.clear(); + graph.bumpVersion(); + + Ogre::LogManager::getSingleton().logMessage( + "RoadSystem: migrated inline road data to " + + std::to_string(pages.size()) + " region file(s)"); +} + +void RoadSystem::loadRegion(long px, long py, RoadGraph &graph) +{ + TerrainSystem *ts = TerrainSystem::getInstance(); + if (!ts) + return; + + RoadRegionData data; + if (!m_regionStore.getRegion(px, py, &data)) + return; + + /* First pass over ALL nodes in the file (foreign endpoints + * included): build the file-id -> graph-id remap. Only nodes + * whose position page is THIS page are added to the graph; + * foreign nodes resolve only against existing graph nodes. */ + std::map remap; + for (const RoadRegionNode &rn : data.nodes) { + int existing = findNodeByWorldPos(graph, rn.x, rn.z); + if (existing >= 0) { + remap[rn.id] = existing; + continue; + } + long npx = 0, npy = 0; + pageForWorldPos(rn.x, rn.z, npx, npy); + if (npx != px || npy != py) + continue; + remap[rn.id] = graph.addNode( + ts->worldToRender(rn.x, rn.y, rn.z), + rn.verticalOffset); + } + + /* Second pass: edges whose endpoints both resolved. */ + bool changed = false; + for (const RoadRegionEdge &re : data.edges) { + auto ia = remap.find(re.nodeAId); + auto ib = remap.find(re.nodeBId); + if (ia == remap.end() || ib == remap.end()) + continue; + if (graph.hasEdge(ia->second, ib->second)) + continue; + int idx = graph.addEdge(ia->second, ib->second); + if (idx < 0) + continue; + RoadEdge &e = graph.edges[(size_t)idx]; + e.roadLevelA = re.roadLevelA; + e.roadLevelB = re.roadLevelB; + e.lanesPerDirectionOverride = re.lanesPerDirectionOverride; + e.lanesAtoB = (int)re.lanesAtoB; + e.lanesBtoA = (int)re.lanesBtoA; + e.prefabLeft.prefabPath = re.prefabLeft.prefabPath; + e.prefabLeft.edgeT = re.prefabLeft.edgeT; + e.prefabLeft.lateralOffset = re.prefabLeft.lateralOffset; + e.prefabLeft.yOffset = re.prefabLeft.yOffset; + e.prefabRight.prefabPath = re.prefabRight.prefabPath; + e.prefabRight.edgeT = re.prefabRight.edgeT; + e.prefabRight.lateralOffset = re.prefabRight.lateralOffset; + e.prefabRight.yOffset = re.prefabRight.yOffset; + e.prefabMid.prefabPath = re.prefabMid.prefabPath; + e.prefabMid.edgeT = re.prefabMid.edgeT; + e.prefabMid.lateralOffset = re.prefabMid.lateralOffset; + e.prefabMid.yOffset = re.prefabMid.yOffset; + changed = true; + } + if (changed || !remap.empty()) + graph.bumpVersion(); +} + +void RoadSystem::buildRegionData(long px, long py, const RoadGraph &graph, + RoadRegionData &out) const +{ + TerrainSystem *ts = TerrainSystem::getInstance(); + out.nodes.clear(); + out.edges.clear(); + if (!ts) + return; + + /* Local nodes: graph nodes whose world position page is this + * page. */ + std::set localIds; + for (const RoadNode &n : graph.nodes) { + const double wx = ts->renderToWorldX(n.position.x); + const double wz = ts->renderToWorldZ(n.position.z); + long npx = 0, npy = 0; + pageForWorldPos(wx, wz, npx, npy); + if (npx != px || npy != py) + continue; + localIds.insert(n.id); + RoadRegionNode rn; + rn.id = (uint64_t)(uint32_t)n.id; + rn.x = wx; + rn.y = ts->renderToWorldY(n.position.y); + rn.z = wz; + rn.verticalOffset = n.verticalOffset; + out.nodes.push_back(rn); + } + + /* Every edge touching a local node, with the foreign endpoint + * repeated inline (even when that endpoint's page is currently + * loaded — the file must be self contained). */ + auto appendNode = [&](const RoadNode *n) { + for (const RoadRegionNode &rn : out.nodes) + if (rn.id == (uint64_t)(uint32_t)n->id) + return; + RoadRegionNode rn; + rn.id = (uint64_t)(uint32_t)n->id; + rn.x = ts->renderToWorldX(n->position.x); + rn.y = ts->renderToWorldY(n->position.y); + rn.z = ts->renderToWorldZ(n->position.z); + rn.verticalOffset = n->verticalOffset; + out.nodes.push_back(rn); + }; + + for (const RoadEdge &e : graph.edges) { + if (!localIds.count(e.nodeA) && !localIds.count(e.nodeB)) + continue; + const RoadNode *na = graph.findNodeById(e.nodeA); + const RoadNode *nb = graph.findNodeById(e.nodeB); + if (!na || !nb) + continue; + appendNode(na); + appendNode(nb); + RoadRegionEdge re; + re.nodeAId = (uint64_t)(uint32_t)e.nodeA; + re.nodeBId = (uint64_t)(uint32_t)e.nodeB; + re.roadLevelA = e.roadLevelA; + re.roadLevelB = e.roadLevelB; + re.lanesPerDirectionOverride = e.lanesPerDirectionOverride; + re.lanesAtoB = (uint32_t)e.lanesAtoB; + re.lanesBtoA = (uint32_t)e.lanesBtoA; + re.prefabLeft.prefabPath = e.prefabLeft.prefabPath; + re.prefabLeft.edgeT = e.prefabLeft.edgeT; + re.prefabLeft.lateralOffset = e.prefabLeft.lateralOffset; + re.prefabLeft.yOffset = e.prefabLeft.yOffset; + re.prefabRight.prefabPath = e.prefabRight.prefabPath; + re.prefabRight.edgeT = e.prefabRight.edgeT; + re.prefabRight.lateralOffset = e.prefabRight.lateralOffset; + re.prefabRight.yOffset = e.prefabRight.yOffset; + re.prefabMid.prefabPath = e.prefabMid.prefabPath; + re.prefabMid.edgeT = e.prefabMid.edgeT; + re.prefabMid.lateralOffset = e.prefabMid.lateralOffset; + re.prefabMid.yOffset = e.prefabMid.yOffset; + out.edges.push_back(re); + } +} + +void RoadSystem::flushRegionStore() +{ + if (!roadStreamingActive()) + return; + TerrainSystem *ts = TerrainSystem::getInstance(); + flecs::entity terrain = getTerrainEntity(); + if (!terrain.is_alive() || !terrain.has()) + return; + const RoadGraph &graph = terrain.get().roadGraph; + + /* Group the active graph by node page and rewrite each region + * file with the same extraction as the page-unload path. */ + std::map > pageSet; + for (const RoadNode &n : graph.nodes) { + long px = 0, py = 0; + pageForWorldPos(ts->renderToWorldX(n.position.x), + ts->renderToWorldZ(n.position.z), px, py); + pageSet[RoadRegionStore::packPageKey(px, py)] = { px, py }; + } + for (const auto &kv : pageSet) { + RoadRegionData data; + buildRegionData(kv.second.first, kv.second.second, graph, + data); + m_regionStore.saveRegion(kv.second.first, kv.second.second, + data); + } +} + +void RoadSystem::onRenderOriginChanged(const Ogre::Vector3 &delta) +{ + flecs::entity terrain = getTerrainEntity(); + if (!terrain.is_alive() || !terrain.has()) + return; + RoadGraph &graph = + terrain.get_mut().roadGraph; + if (graph.nodes.empty()) + return; + + /* Node positions are render-space floats; shift them into the new + * render space. Region files store absolute world doubles and + * need no update. The version bump refreshes the visual aids and + * the wedge buckets; the page content signatures are computed in + * world space, so the rebase alone does not rebuild any mesh. */ + for (RoadNode &n : graph.nodes) + n.position -= delta; + graph.bumpVersion(); +} + +/* ------------------------------------------------------------------ */ +/* Page content signatures (M5 dirty scope) */ +/* ------------------------------------------------------------------ */ + +static void fnvHash(uint64_t &h, const void *data, size_t len) +{ + const uint8_t *p = (const uint8_t *)data; + for (size_t i = 0; i < len; ++i) { + h ^= p[i]; + h *= 1099511628211ull; + } +} + +template +static void fnvHashPod(uint64_t &h, const T &v) +{ + fnvHash(h, &v, sizeof(v)); +} + +static void fnvHashString(uint64_t &h, const std::string &s) +{ + fnvHash(h, s.data(), s.size()); +} + +void RoadSystem::hashNodeWorldPos(uint64_t &h, const RoadNode &n) const +{ + /* Quantize the WORLD position to centimetres so the hash is + * rebase-invariant (render-space floats shift on every rebase). */ + double wx = n.position.x, wy = n.position.y, wz = n.position.z; + if (m_terrainSystem) { + wx = m_terrainSystem->renderToWorldX(n.position.x); + wy = m_terrainSystem->renderToWorldY(n.position.y); + wz = m_terrainSystem->renderToWorldZ(n.position.z); + } + int64_t qx = (int64_t)llround(wx * 100.0); + int64_t qy = (int64_t)llround(wy * 100.0); + int64_t qz = (int64_t)llround(wz * 100.0); + fnvHashPod(h, qx); + fnvHashPod(h, qy); + fnvHashPod(h, qz); + fnvHashPod(h, n.verticalOffset); +} + +uint64_t RoadSystem::computePageSignature(uint64_t key) const +{ + flecs::entity terrain = getTerrainEntity(); + if (!terrain.is_alive() || !terrain.has()) + return 0; + const RoadGraph &graph = terrain.get().roadGraph; + + uint64_t h = 14695981039346656037ull; + + /* Geometry-relevant config: a config change must rebuild every + * page (the resolved lane counts of unoverridden edges follow the + * global defaults). */ + const RoadConfig &cfg = graph.config; + fnvHashPod(h, cfg.laneWidth); + fnvHashPod(h, cfg.lanesPerDirection); + fnvHashPod(h, cfg.roadThickness); + fnvHashPod(h, cfg.sidewalkEnabled); + fnvHashPod(h, cfg.sidewalkWidth); + fnvHashPod(h, cfg.sidewalkHeight); + fnvHashPod(h, cfg.sidewalkThickness); + fnvHashString(h, cfg.roadMeshTemplate); + fnvHashString(h, cfg.sidewalkMeshTemplate); + + /* Seed nodes of this page's wedges/segments. */ + std::set nodeIds; + auto bit = m_wedgeBuckets.find(key); + if (bit != m_wedgeBuckets.end()) { + for (const RoadWedge &w : bit->second.wedges) + nodeIds.insert(w.nodeId); + for (const RoadStraightSegment &s : bit->second.segments) + nodeIds.insert(s.nodeId); + } + + /* Edges incident to the seed nodes, deduplicated by the ordered + * node pair. Both endpoint positions feed the hash: a foreign + * endpoint move changes this page's half-edge geometry too. */ + std::set > edgeKeys; + for (int id : nodeIds) + for (size_t ei : graph.findEdgeIndicesForNode(id)) { + const RoadEdge &e = graph.edges[ei]; + edgeKeys.insert( + std::make_pair(std::min(e.nodeA, e.nodeB), + std::max(e.nodeA, e.nodeB))); + } + + std::set allIds = nodeIds; + for (const auto &ek : edgeKeys) { + allIds.insert(ek.first); + allIds.insert(ek.second); + } + for (int id : allIds) { + const RoadNode *n = graph.findNodeById(id); + if (!n) + continue; + fnvHashPod(h, id); + hashNodeWorldPos(h, *n); + } + + for (const auto &ek : edgeKeys) { + int idx = graph.findEdgeIndex(ek.first, ek.second); + if (idx < 0) + continue; + const RoadEdge &e = graph.edges[(size_t)idx]; + fnvHashPod(h, e.nodeA); + fnvHashPod(h, e.nodeB); + fnvHashPod(h, e.roadLevelA); + fnvHashPod(h, e.roadLevelB); + fnvHashPod(h, e.lanesPerDirectionOverride); + fnvHashPod(h, e.lanesAtoB); + fnvHashPod(h, e.lanesBtoA); + const RoadEdgePrefabSlot *slots[3] = { + &e.prefabLeft, &e.prefabRight, &e.prefabMid + }; + for (const RoadEdgePrefabSlot *slot : slots) { + fnvHashString(h, slot->prefabPath); + fnvHashPod(h, slot->edgeT); + fnvHashPod(h, slot->lateralOffset); + fnvHashPod(h, slot->yOffset); + } + } + return h; +} + /* ------------------------------------------------------------------ */ /* Mesh assembly (M5.8) */ /* ------------------------------------------------------------------ */ @@ -674,6 +1333,17 @@ flecs::entity RoadSystem::ensureRoadLodSettings(const RoadConfig &cfg) } void RoadSystem::markNavMeshDirty() +{ + /* While streaming, page churn dirties the navmesh repeatedly; + * defer the actual marking to the debounce in update(). */ + if (roadStreamingActive()) { + m_navMeshDirtyPending = true; + return; + } + applyNavMeshDirty(); +} + +void RoadSystem::applyNavMeshDirty() { /* * TileCacheNavMesh bakes the input geometry soup at build time, so @@ -709,8 +1379,21 @@ void RoadSystem::createPageCollider(RoadPageGeometry &pg, return; } + /* + * The mesh shape is baked in the CURRENT render space; placing the + * body at the render origin (absolute world space, double + * precision) puts the collider at the right world position and + * keeps it valid across render-origin rebases (the mesh vertices + * and this body both stay in the build-time render frame). The + * body is recreated whenever the page mesh is rebuilt. + */ + JPH::RVec3 bodyPos = JPH::RVec3::sZero(); + if (m_terrainSystem) + bodyPos = JPH::RVec3(m_terrainSystem->renderToWorldX(0.0f), + m_terrainSystem->renderToWorldY(0.0f), + m_terrainSystem->renderToWorldZ(0.0f)); JPH::BodyCreationSettings settings( - shape.GetPtr(), JPH::RVec3::sZero(), JPH::Quat::sIdentity(), + shape.GetPtr(), bodyPos, JPH::Quat::sIdentity(), JPH::EMotionType::Static, Layers::NON_MOVING); JPH::BodyID bodyId = m_physics->createBody(settings); if (bodyId.IsInvalid()) { @@ -1264,99 +1947,346 @@ void RoadSystem::updateEdgePrefabs() /* Terrain compliance (M5.10) */ /* ------------------------------------------------------------------ */ -void RoadSystem::writeComplianceFalloff(TerrainSystem *terrainSystem, - const RoadGraph &graph, - const RoadHalfEdge &halfEdge, - const Ogre::Vector3 &nodePos, - float sideSign, float sideWidth, - float roadThickness, float laneWidth) +/* --- Rendered-surface helpers for compliance --------------------------- + * fillPageHeightData() builds each page from sampleHeightAt() at the + * page-vertex lattice, one vertex every worldSize/(terrainSize-1) + * (e.g. 31.25 m) — far wider than a road. The rendered surface between + * vertices is interpolated from the four surrounding lattice vertices + * (triangulated along a quad diagonal; the physics collider even + * alternates the diagonal per row), so only those corner values matter + * — neither the fixup texel grid nor the base heightmap resolution does. + * + * latticeVertexHeight() reads a lattice vertex exactly the way + * fillPageHeightData samples it (same coordinates and truncation), so it + * reflects in-memory fixup writes BEFORE the affected pages are rebuilt + * — getHeightAtWorldPosition() would still return stale page data. */ +static float latticeVertexHeight(TerrainSystem *terrainSystem, float vx, + float vz) { - if (!terrainSystem || sideWidth <= 1e-4f || - halfEdge.halfLength <= 1e-4f) - return; + long wx = (long)terrainSystem->visualToPhysicalX(vx); + long wz = (long)terrainSystem->visualToPhysicalZ(vz); + return terrainSystem->sampleHeightAt(wx, wz); +} - /* Fade zone: linear fade over laneWidth * 2 outside the outermost - * lane (M5.10 step 4). */ - float fadeWidth = laneWidth * 2.0f; - if (fadeWidth <= 1e-4f) - return; - - float texel = terrainSystem->getFixupTexelSize(); - if (texel <= 1e-6f) - return; - - Ogre::Vector3 outward = - RoadGeometryLib::roadRightVec(halfEdge.direction) * sideSign; - float halfThick = std::max(0.01f, roadThickness) * 0.5f; - - /* - * Walk the fixup samples covering the fade band (t in - * [0, halfLength] along the half-edge, lateral in (sideWidth, - * sideWidth + fadeWidth)) and evaluate the fade target at each - * exact sample position, writing single samples. Because the - * target is linear in the lateral direction and sampling is - * bilinear, reads anywhere in the band reproduce the ramp; sparse - * point writes would leave the coarse page-vertex sampler between - * written cells. Samples outside the band are skipped: inside the - * curb the full-compliance pass writes the slab underside (it runs - * after this pass and wins on shared samples); beyond the fade the - * terrain keeps its natural height (sentinel blends to base). - */ - Ogre::Vector3 far = halfEdge.direction * halfEdge.halfLength; - Ogre::Vector3 nearOff = outward * sideWidth; - Ogre::Vector3 farOff = outward * (sideWidth + fadeWidth); - Ogre::Vector3 corners[4] = { - nodePos + nearOff, - nodePos + farOff, - nodePos + far + nearOff, - nodePos + far + farOff, - }; - float minX = corners[0].x, maxX = corners[0].x; - float minZ = corners[0].z, maxZ = corners[0].z; - for (int i = 1; i < 4; ++i) { - minX = std::min(minX, corners[i].x); - maxX = std::max(maxX, corners[i].x); - minZ = std::min(minZ, corners[i].z); - maxZ = std::max(maxZ, corners[i].z); - } - - long ix0 = (long)std::floor(minX / texel); - long ix1 = (long)std::floor(maxX / texel); - long iz0 = (long)std::floor(minZ / texel); - long iz1 = (long)std::floor(maxZ / texel); - - for (long iz = iz0; iz <= iz1; ++iz) { - for (long ix = ix0; ix <= ix1; ++ix) { - Ogre::Vector3 p((float)ix * texel, 0.0f, - (float)iz * texel); - Ogre::Vector3 rel = p - nodePos; - float t = rel.dotProduct(halfEdge.direction); - if (t < 0.0f || t > halfEdge.halfLength) - continue; - float lateral = rel.dotProduct(outward); - float d = lateral - sideWidth; - if (d <= 0.0f || d >= fadeWidth) - continue; - /* Top-surface height so target = top - roadThickness - * equals the slab underside at the curb, matching the - * full-compliance writes. */ - float surfY = RoadGeometryLib::halfEdgeHeightAt( - halfEdge, graph, t) + - halfThick; - float base = terrainSystem->sampleBaseHeightAt( - (long)std::floor(p.x), (long)std::floor(p.z)); - float target = computeComplianceHeight( - surfY, roadThickness, base, lateral, - sideWidth, fadeWidth); - terrainSystem->writeFixupSample(p.x, p.z, target); +/* Clip an XZ polygon against one lattice half-plane: axis 0 keeps + * x <= val (keepBelow) or x >= val, axis 1 the same for z. */ +static void clipPolyAxis(std::vector &poly, int axis, + float val, bool keepBelow) +{ + std::vector out; + size_t n = poly.size(); + for (size_t i = 0; i < n; ++i) { + const Ogre::Vector2 &p = poly[i]; + const Ogre::Vector2 &q = poly[(i + 1) % n]; + float dp = (axis == 0 ? p.x : p.y) - val; + float dq = (axis == 0 ? q.x : q.y) - val; + if (!keepBelow) { + dp = -dp; + dq = -dq; + } + bool inP = dp <= 0.0f; + bool inQ = dq <= 0.0f; + if (inP) + out.push_back(p); + if (inP != inQ) { + float t = dp / (dp - dq); + out.push_back(p + (q - p) * t); } } + poly.swap(out); +} + +/* Append all top-surface triangles of a generated road geometry buffer + * (wedge or sidewalk strip) with their Y shifted by yOffset, for the + * relaxation pass. */ +static void collectTopTriangles(const Procedural::TriangleBuffer &buf, + float yOffset, + std::vector> &out) +{ + const auto &verts = buf.getVertices(); + const auto &idx = buf.getIndices(); + Ogre::Vector3 up(0.0f, yOffset, 0.0f); + for (size_t t = 0; t + 2 < idx.size(); t += 3) { + const auto &a = verts[idx[t]]; + const auto &b = verts[idx[t + 1]]; + const auto &c = verts[idx[t + 2]]; + if (a.mNormal.y <= 0.5f || b.mNormal.y <= 0.5f || + c.mNormal.y <= 0.5f) + continue; + out.push_back({ a.mPosition + up, b.mPosition + up, + c.mPosition + up }); + } +} + +/* --- Compliance constraint solving ----------------------------------- + * The rendered terrain surface is the interpolation of the page-vertex + * lattice (one vertex every worldSize/(terrainSize-1), e.g. 31.25 m), + * triangulated per cell along one of the two diagonals (the renderer + * and the physics collider alternate them per row). The fixup layer + * can only move these lattice vertices (via lowerFixupCorners), so + * compliance is a constraint problem on vertex heights: for every + * point q of a road top surface, rendered(q) <= roadTop(q) - SAG. + * + * The maximum of (rendered - roadTop) over the convex intersection of + * a road triangle with a lattice cell is attained at a polygon vertex + * or at an intersection of a polygon edge with the cell's + * triangulation diagonal (both surfaces are piecewise linear with + * kinks only there), so those points are the only candidates. Each + * candidate yields one linear constraint; constraints already + * satisfied are dropped (lowering can never violate them). The rest + * are solved in two monotone phases: damped Kaczmarz sweeps lower the + * vertices until every constraint holds, then raise-only relaxation + * lifts each vertex back to the highest value its constraints allow + * (capped at the natural height). The fixed point is locally maximal + * — no vertex can be raised without poking through a road — so every + * vertex drops exactly as much as the road above it justifies and the + * roadbed follows the road curvature instead of collapsing to the + * lowest road nearby. */ +#define ROAD_COMPLIANCE_SAG 0.05f + +/* One linear constraint: the weighted sum of the four corner heights + * of lattice cell (ci, cj) must not exceed target. Corner order: + * (ci,cj), (ci+1,cj), (ci,cj+1), (ci+1,cj+1). */ +struct ComplianceConstraint { + long ci, cj; + float w[4]; + float target; +}; + +/* Bilinear weights of the four cell corners for BOTH possible + * triangulation diagonals at cell-local coordinates (tx, tz). wA is + * the v10-v01 (anti-)diagonal, wB the v00-v11 (main) diagonal; corner + * order matches ComplianceConstraint. */ +static void cellWeights(float tx, float tz, float wA[4], float wB[4]) +{ + if (tx + tz <= 1.0f) { + wA[0] = 1.0f - tx - tz; + wA[1] = tx; + wA[2] = tz; + wA[3] = 0.0f; + } else { + wA[0] = 0.0f; + wA[1] = 1.0f - tz; + wA[2] = 1.0f - tx; + wA[3] = tx + tz - 1.0f; + } + if (tz <= tx) { + wB[0] = 1.0f - tx; + wB[1] = tx - tz; + wB[2] = 0.0f; + wB[3] = tz; + } else { + wB[0] = 1.0f - tz; + wB[1] = 0.0f; + wB[2] = tz - tx; + wB[3] = tx; + } +} + +/* Cached lattice-vertex heights keyed by packed (i, j); the cache lets + * the solver lower vertices in memory and apply the fixups once at the + * end. */ +static float cachedVertexHeight(TerrainSystem *terrainSystem, + std::unordered_map &cache, + long i, long j, float step, float baseX, + float baseZ) +{ + uint64_t key = (uint64_t)(uint32_t)i << 32 | (uint32_t)j; + auto it = cache.find(key); + if (it != cache.end()) + return it->second; + float h = latticeVertexHeight(terrainSystem, + baseX + (float)i * step, + baseZ + (float)j * step); + cache.emplace(key, h); + return h; +} + +/* The rendered terrain and the Jolt collider triangulate every + * lattice cell along ONE diagonal, zigzagging by row (see + * TerrainSystem::buildPageCollider): cells in odd rows use the + * anti-diagonal (orientation A), cells in even rows the main diagonal + * (orientation B). 64 cells per page is even, so the rule is a pure + * function of the global cell row. */ +static int cellDiagonalOrientation(long cj) +{ + return (cj & 1) != 0 ? 0 : 1; /* 0 = A (anti), 1 = B (main) */ +} + +/* Collect the constraints one road top-surface triangle imposes on the + * lattice cells it overlaps: clip the triangle against each cell, + * evaluate the road top plane at every candidate maximum point, and + * emit one constraint for the cell's actual triangulation orientation + * for the candidates whose rendered height currently violates + * roadTop - SAG. */ +static void collectComplianceConstraints( + TerrainSystem *terrainSystem, const Ogre::Vector3 &v0, + const Ogre::Vector3 &v1, const Ogre::Vector3 &v2, float step, + float baseX, float baseZ, + std::unordered_map &heights, + std::vector &out) +{ + /* Road-top plane from the triangle normal; skip triangles that + * are degenerate (near-vertical) in XZ. */ + Ogre::Vector3 n = (v1 - v0).crossProduct(v2 - v0); + if (std::fabs(n.y) < 1e-6f) + return; + + float minX = std::min(v0.x, std::min(v1.x, v2.x)); + float maxX = std::max(v0.x, std::max(v1.x, v2.x)); + float minZ = std::min(v0.z, std::min(v1.z, v2.z)); + float maxZ = std::max(v0.z, std::max(v1.z, v2.z)); + + long i0 = (long)std::floor((minX - baseX) / step); + long i1 = (long)std::floor((maxX - baseX) / step); + long j0 = (long)std::floor((minZ - baseZ) / step); + long j1 = (long)std::floor((maxZ - baseZ) / step); + + for (long j = j0; j <= j1; ++j) { + for (long i = i0; i <= i1; ++i) { + float cx = baseX + (float)i * step; + float cz = baseZ + (float)j * step; + + std::vector poly; + poly.push_back(Ogre::Vector2(v0.x, v0.z)); + poly.push_back(Ogre::Vector2(v1.x, v1.z)); + poly.push_back(Ogre::Vector2(v2.x, v2.z)); + clipPolyAxis(poly, 0, cx, false); + clipPolyAxis(poly, 0, cx + step, true); + clipPolyAxis(poly, 1, cz, false); + clipPolyAxis(poly, 1, cz + step, true); + if (poly.empty()) + continue; + + /* Candidate maximum points: the polygon vertices + * plus the intersections of the polygon edges + * with the cell's actual triangulation diagonal + * (the only kink line of the rendered patch). */ + int orient = cellDiagonalOrientation(j); + std::vector pts = poly; + for (size_t e = 0; e < poly.size(); ++e) { + const Ogre::Vector2 &p = poly[e]; + const Ogre::Vector2 &q = + poly[(e + 1) % poly.size()]; + float pu = (p.x - cx) / step; + float pv = (p.y - cz) / step; + float qu = (q.x - cx) / step; + float qv = (q.y - cz) / step; + float dp, dq; + if (orient == 0) { + /* Anti-diagonal tx + tz = 1. */ + dp = pu + pv - 1.0f; + dq = qu + qv - 1.0f; + } else { + /* Main diagonal tx = tz. */ + dp = pu - pv; + dq = qu - qv; + } + if ((dp < 0.0f) != (dq < 0.0f)) { + float t = dp / (dp - dq); + pts.push_back(p + (q - p) * t); + } + } + + for (const Ogre::Vector2 &q : pts) { + float tx = (q.x - cx) / step; + float tz = (q.y - cz) / step; + float top = v0.y - + (n.x * (q.x - v0.x) + + n.z * (q.y - v0.z)) / n.y; + float target = top - ROAD_COMPLIANCE_SAG; + float wA[4], wB[4]; + cellWeights(tx, tz, wA, wB); + const float *w = orient == 0 ? wA : wB; + float rendered = 0.0f; + for (int c = 0; c < 4; ++c) + rendered += + w[c] * + cachedVertexHeight( + terrainSystem, heights, + i + (c & 1), + j + (c >> 1), step, + baseX, baseZ); + if (rendered <= target) + continue; + ComplianceConstraint cc; + cc.ci = i; + cc.cj = j; + for (int c = 0; c < 4; ++c) + cc.w[c] = w[c]; + cc.target = target; + out.push_back(cc); + } + } + } +} + +/* Rendered terrain surface height at visual (vx, vz): the lattice + * cell patch evaluated with the cell's actual triangulation diagonal + * (the same zigzag the renderer and the physics collider use). */ +static float renderedHeightAt(TerrainSystem *terrainSystem, float vx, + float vz, float step, float baseX, float baseZ) +{ + float gx = (vx - baseX) / step; + float gz = (vz - baseZ) / step; + long i = (long)std::floor(gx); + long j = (long)std::floor(gz); + float tx = gx - (float)i; + float tz = gz - (float)j; + if (tx < 0.0f) + tx = 0.0f; + else if (tx > 1.0f) + tx = 1.0f; + if (tz < 0.0f) + tz = 0.0f; + else if (tz > 1.0f) + tz = 1.0f; + float h[4] = { + latticeVertexHeight(terrainSystem, baseX + (float)i * step, + baseZ + (float)j * step), + latticeVertexHeight(terrainSystem, + baseX + (float)(i + 1) * step, + baseZ + (float)j * step), + latticeVertexHeight(terrainSystem, baseX + (float)i * step, + baseZ + (float)(j + 1) * step), + latticeVertexHeight(terrainSystem, + baseX + (float)(i + 1) * step, + baseZ + (float)(j + 1) * step), + }; + float wA[4], wB[4]; + cellWeights(tx, tz, wA, wB); + const float *w = cellDiagonalOrientation(j) == 0 ? wA : wB; + float r = 0.0f; + for (int c = 0; c < 4; ++c) + r += w[c] * h[c]; + return r; +} + +/* Append every crossing of the line p0 + dir*t, t in [t0, t1], with the + * lattice lines coordinate == base + i*step (axis 0 = x, 1 = z). */ +static void addLatticeCrossings(std::vector &ts, + const Ogre::Vector3 &p0, + const Ogre::Vector3 &dir, float t0, float t1, + float base, float step, int axis) +{ + float d = axis == 0 ? dir.x : dir.z; + if (std::fabs(d) < 1e-6f) + return; + float c = axis == 0 ? p0.x : p0.z; + float cA = c + d * t0; + float cB = c + d * t1; + long i0 = (long)std::ceil((std::min(cA, cB) - base) / step + 1e-4f); + long i1 = (long)std::floor((std::max(cA, cB) - base) / step - 1e-4f); + for (long i = i0; i <= i1; ++i) + ts.push_back((base + (float)i * step - c) / d); } void RoadSystem::complyTerrain(TerrainSystem *terrainSystem, float roadThickness, float laneWidth) { - if (!terrainSystem || !m_terrainGroup) + (void)laneWidth; + if (!terrainSystem || !m_terrainGroup || + m_terrainGroup->getTerrainSize() < 2) return; flecs::entity terrain = getTerrainEntity(); @@ -1367,72 +2297,24 @@ void RoadSystem::complyTerrain(TerrainSystem *terrainSystem, Procedural::TriangleBuffer fb = makeFallbackTemplate(rg.config.roadThickness); - /* Sidewalks (improvement plan §2.3): the shoulder fade starts at - * the sidewalk's outer edge, and the full-compliance pass flattens - * the terrain under the sidewalk bodies too. */ + /* Rendered page-vertex lattice (visual space): pages are centred + * on the terrain group origin, vertices one step apart. */ + float ws = m_terrainGroup->getTerrainWorldSize(); + float pageStep = ws / (float)(m_terrainGroup->getTerrainSize() - 1); + float pageBaseX = m_terrainGroup->getOrigin().x - ws * 0.5f; + float pageBaseZ = m_terrainGroup->getOrigin().z - ws * 0.5f; + + /* Collect the top surface of every road piece of every loaded + * page as world-space triangles. Wedge and sidewalk top + * vertices carry the actual top height; segment band corners + * carry the centre surface height, so the slab top is halfThick + * above them. */ bool sidewalks = rg.config.sidewalkEnabled; - float sideExtra = sidewalks ? rg.config.sidewalkWidth : 0.0f; float sidewalkThick = std::max(0.01f, rg.config.sidewalkThickness); Procedural::TriangleBuffer fbSw = RoadGeometryLib::makeSidewalkFallbackTemplate(sidewalkThick); - /* --- Perpendicular falloff (M5.10 step 4), written FIRST --- - * Extend a linear fade over laneWidth * 2 outside each curb. Beyond - * the fade zone no fixup is written (base heightmap + detail noise). - * This pass runs before the full-compliance pass below so the slab - * underside values win wherever the coarse chunk cells overlap the - * fade band (curb rows, node overlaps). */ - for (auto &kv : m_pageGeometry) { - RoadPageGeometry &pg = kv.second; - - for (const RoadWedge &wedge : pg.wedges) { - const RoadNode *node = rg.findNodeById(wedge.nodeId); - if (!node) - continue; - /* The wedge's outer curb sits on the right of the - * first half-edge (lanesOut wide) and on the left of - * the second half-edge (lanesIn wide). */ - writeComplianceFalloff(terrainSystem, rg, wedge.first, - node->position, +1.0f, - wedge.first.lanesOut * laneWidth + - sideExtra, - roadThickness, laneWidth); - writeComplianceFalloff(terrainSystem, rg, wedge.second, - node->position, -1.0f, - wedge.second.lanesIn * laneWidth + - sideExtra, - roadThickness, laneWidth); - } - - for (const RoadStraightSegment &seg : pg.segments) { - const RoadNode *node = - rg.findNodeById(seg.halfEdge.nodeId); - if (!node) - continue; - /* Dead-end segments span both sides of the - * centerline: right curb at lanesOut * laneWidth, - * left curb at lanesIn * laneWidth. */ - writeComplianceFalloff(terrainSystem, rg, seg.halfEdge, - node->position, +1.0f, - seg.halfEdge.lanesOut * laneWidth + - sideExtra, - roadThickness, laneWidth); - writeComplianceFalloff(terrainSystem, rg, seg.halfEdge, - node->position, -1.0f, - seg.halfEdge.lanesIn * laneWidth + - sideExtra, - roadThickness, laneWidth); - } - } - - /* - * Walk every loaded page's wedges and segments, generate road - * geometry into a temp buffer, then write fixup values under every - * top-surface vertex. The fixup target is the road underside: - * surfaceY - roadThickness. Runs AFTER the falloff pass so full - * compliance under the road overrides any fade values in shared - * chunk cells. - */ + std::vector> topTris; for (auto &kv : m_pageGeometry) { RoadPageGeometry &pg = kv.second; @@ -1440,78 +2322,321 @@ void RoadSystem::complyTerrain(TerrainSystem *terrainSystem, Procedural::TriangleBuffer tmp; if (!buildWedgeGeometry(wedge, rg, fb, tmp)) continue; - - /* Write fixups from the generated top-surface - * vertices: sample the Y of vertices whose normal - * points up and write target = Y - roadThickness - * underneath them (the slab bottom). */ - const auto &verts = tmp.getVertices(); - for (const auto &v : verts) { - if (v.mNormal.y <= 0.5f) - continue; - terrainSystem->writeFixup( - v.mPosition.x, v.mPosition.z, - v.mPosition.y - roadThickness); - } - - /* Sidewalk strip: flatten under its body too; - * the fixup target is the sidewalk underside - * (top - sidewalkThickness). */ + collectTopTriangles(tmp, 0.0f, topTris); if (sidewalks) { Procedural::TriangleBuffer tmpSw; - if (!RoadGeometryLib::buildSidewalkGeometry( + if (RoadGeometryLib::buildSidewalkGeometry( wedge, rg, fbSw, tmpSw)) - continue; - for (const auto &v : tmpSw.getVertices()) { - if (v.mNormal.y <= 0.5f) - continue; - terrainSystem->writeFixup( - v.mPosition.x, v.mPosition.z, - v.mPosition.y - sidewalkThick); - } + collectTopTriangles(tmpSw, 0.0f, + topTris); } } for (const RoadStraightSegment &seg : pg.segments) { Ogre::Vector3 c[4]; Ogre::Vector2 uvc[4]; - if (!RoadGeometryLib::computeSegmentBand(seg, rg, c, uvc)) + if (!RoadGeometryLib::computeSegmentBand(seg, rg, c, + uvc)) continue; + Ogre::Vector3 up(0.0f, halfThick, 0.0f); + topTris.push_back({ c[0] + up, c[1] + up, + c[2] + up }); + topTris.push_back({ c[0] + up, c[2] + up, + c[3] + up }); + if (!sidewalks) + continue; + for (int side = 0; side < 2; ++side) { + Ogre::Vector3 sc[4]; + Ogre::Vector2 suvc[4]; + if (!RoadGeometryLib:: + computeSegmentSidewalkBand( + seg, rg, side, sc, suvc)) + continue; + topTris.push_back({ sc[0], sc[1], sc[2] }); + topTris.push_back({ sc[0], sc[2], sc[3] }); + } + } + } - /* Band corners/interior carry the CENTER surface - * height; the slab underside is half a thickness - * below it, matching the wedge pass (top vertex - * minus full thickness). */ - for (int i = 0; i < 4; ++i) - terrainSystem->writeFixup( - c[i].x, c[i].z, - c[i].y - halfThick); + /* Gather the constraints the road tops impose on the lattice + * (only the ones currently violated; lowering can never violate + * the rest) and solve them with damped Kaczmarz sweeps over the + * cached vertex heights. */ + std::unordered_map heights; + std::vector constraints; + for (const auto &tri : topTris) + collectComplianceConstraints(terrainSystem, tri[0], tri[1], + tri[2], pageStep, pageBaseX, + pageBaseZ, heights, + constraints); - /* Sample intermediate points along the band edges - * and interior for smooth compliance. */ - Ogre::Vector3 d10 = c[1] - c[0]; - Ogre::Vector3 d32 = c[2] - c[3]; - Ogre::Vector3 d30 = c[3] - c[0]; - float edgeLen = d10.length(); - float widthLen = d30.length(); - int nSteps = std::max(1, (int)std::ceil(edgeLen)); - int nWidth = std::max(1, (int)std::ceil(widthLen)); - for (int s = 1; s < nSteps; ++s) { - float t = (float)s / (float)nSteps; - Ogre::Vector3 p0 = c[0] + d10 * t; - Ogre::Vector3 p1 = c[3] + d32 * t; - for (int w = 0; w <= nWidth; ++w) { - float wt = (float)w / (float)nWidth; - Ogre::Vector3 pos = p0 + (p1 - p0) * wt; - terrainSystem->writeFixup( - pos.x, pos.z, - pos.y - halfThick); + /* Phase 1 - damped Kaczmarz sweeps: each violated constraint lowers + * its corners by the minimal-norm share of the excess (shares + * proportional to the squared weights). Lowering can never + * violate a constraint, so the sweeps converge to a feasible + * solution. The result overshoots: a vertex lowered while its + * neighbours were still high stays low even after the neighbours + * catch up, because lowering is never given back. */ + for (int sweep = 0; sweep < 400 && !constraints.empty(); ++sweep) { + float maxViol = 0.0f; + for (const ComplianceConstraint &cc : constraints) { + float rendered = 0.0f, sumW2 = 0.0f; + uint64_t keys[4]; + for (int c = 0; c < 4; ++c) { + long vi = cc.ci + (c & 1); + long vj = cc.cj + (c >> 1); + keys[c] = (uint64_t)(uint32_t)vi << 32 | + (uint32_t)vj; + rendered += cc.w[c] * heights[keys[c]]; + sumW2 += cc.w[c] * cc.w[c]; + } + float viol = rendered - cc.target; + if (viol > maxViol) + maxViol = viol; + if (viol <= 0.0f || sumW2 < 1e-12f) + continue; + for (int c = 0; c < 4; ++c) { + if (cc.w[c] <= 0.0f) + continue; + heights[keys[c]] -= + 0.5f * cc.w[c] * viol / sumW2; + } + } + if (maxViol < 1e-3f) + break; + } + + /* Phase 2 - raise-only relaxation: repeatedly set every vertex to + * the highest value its constraints allow given the current + * neighbour heights (capped at the natural height), i.e. + * h_V = min_c (target_c - sum of the weighted other corners) / + * w_V. Raising along these bounds can never violate a + * constraint, the iteration is monotone and bounded above, so it + * converges to a locally maximal feasible solution: no vertex + * sits lower than its own constraints require, which recovers + * the hysteresis overshoot of phase 1 and lets the roadbed + * follow the road curvature instead of collapsing to the lowest + * road nearby. A small epsilon keeps the solution strictly + * feasible against float rounding. */ + std::unordered_map> vertConstraints; + for (size_t k = 0; k < constraints.size(); ++k) { + const ComplianceConstraint &cc = constraints[k]; + for (int c = 0; c < 4; ++c) { + if (cc.w[c] <= 0.0f) + continue; + long vi = cc.ci + (c & 1); + long vj = cc.cj + (c >> 1); + uint64_t key = (uint64_t)(uint32_t)vi << 32 | + (uint32_t)vj; + vertConstraints[key].push_back(k); + } + } + for (int sweep = 0; sweep < 200 && !vertConstraints.empty(); + ++sweep) { + float maxRaise = 0.0f; + for (const auto &vc : vertConstraints) { + float h = heights[vc.first]; + float bound = latticeVertexHeight( + terrainSystem, + pageBaseX + + (float)(long)(int32_t)(vc.first >> 32) * + pageStep, + pageBaseZ + + (float)(long)(int32_t)(vc.first & + 0xffffffffu) * + pageStep); + for (size_t k : vc.second) { + const ComplianceConstraint &cc = + constraints[k]; + float wV = 0.0f, others = 0.0f; + for (int c = 0; c < 4; ++c) { + long vi = cc.ci + (c & 1); + long vj = cc.cj + (c >> 1); + uint64_t key = + (uint64_t)(uint32_t)vi << 32 | + (uint32_t)vj; + if (key == vc.first) + wV += cc.w[c]; + else + others += + cc.w[c] * heights[key]; + } + if (wV <= 0.0f) + continue; + bound = std::min(bound, + (cc.target - others) / wV - + 1e-4f); + } + if (bound > h) { + maxRaise = std::max(maxRaise, bound - h); + heights[vc.first] = bound; + } + } + if (maxRaise < 1e-3f) + break; + } + + /* Apply the per-vertex lowering as fixups and dirty every page + * touching a lowered vertex (a lattice vertex on a page border + * belongs to both pages). */ + std::set> dirtyPages; + for (const auto &kv : heights) { + long vi = (long)(int32_t)(kv.first >> 32); + long vj = (long)(int32_t)(kv.first & 0xffffffffu); + float vx = pageBaseX + (float)vi * pageStep; + float vz = pageBaseZ + (float)vj * pageStep; + float natural = latticeVertexHeight(terrainSystem, vx, vz); + float delta = natural - kv.second; + if (delta <= 1e-4f) + continue; + long wx = (long)terrainSystem->visualToPhysicalX(vx); + long wz = (long)terrainSystem->visualToPhysicalZ(vz); + terrainSystem->lowerFixupCorners((float)wx, (float)wz, + delta); + for (int sx = -1; sx <= 1; sx += 2) { + for (int sz = -1; sz <= 1; sz += 2) { + Ogre::Vector3 p(vx + (float)sx * pageStep * + 0.25f, + 0.0f, + vz + (float)sz * pageStep * + 0.25f); + long px = 0, py = 0; + m_terrainGroup-> + convertWorldPositionToTerrainSlot( + p, &px, &py); + dirtyPages.insert(std::make_pair(px, py)); + } + } + } + for (const auto &dp : dirtyPages) + terrainSystem->markPageDirty(dp.first, dp.second); + + /* Persist fixups to disk. */ + terrainSystem->saveFixups(); + + Ogre::LogManager::getSingleton().logMessage( + "RoadSystem: terrain compliance lowered " + + Ogre::StringConverter::toString( + (unsigned long)dirtyPages.size()) + + " pages for " + + Ogre::StringConverter::toString( + (unsigned long)constraints.size()) + + " constraints"); +} + +void RoadSystem::complyRoadsToTerrain(TerrainSystem *terrainSystem, + float elevation) +{ + if (!terrainSystem || !m_terrainGroup || + m_terrainGroup->getTerrainSize() < 2) + return; + + flecs::entity terrain = getTerrainEntity(); + if (!terrain.is_alive() || !terrain.has()) + return; + RoadGraph &rg = m_world.entity(m_terrainEntityId) + .get_mut() + .roadGraph; + if (rg.nodes.empty() || rg.edges.empty()) + return; + + const float step = m_terrainGroup->getTerrainWorldSize() / + (float)(m_terrainGroup->getTerrainSize() - 1); + + /* Collect both half-edges of every edge (same construction as + * enumerateWedges). */ + struct HalfEdgeRef { + int nodeId; + int neighborId; + Ogre::Vector3 direction; + float halfLength; + float halfWidth; + float roadLevelAtNode; + float roadLevelAtNeighbor; + }; + std::vector halfEdges; + for (size_t ei = 0; ei < rg.edges.size(); ++ei) { + const RoadEdge &e = rg.edges[ei]; + for (int end = 0; end < 2; ++end) { + int nodeId = end == 0 ? e.nodeA : e.nodeB; + int neighborId = end == 0 ? e.nodeB : e.nodeA; + const RoadNode *node = rg.findNodeById(nodeId); + const RoadNode *neighbor = rg.findNodeById(neighborId); + if (!node || !neighbor) + continue; + Ogre::Vector3 dir = neighbor->position - node->position; + dir.y = 0.0f; + float fullLength = dir.normalise(); + if (fullLength < 1e-4f) + continue; + HalfEdgeRef he; + he.nodeId = nodeId; + he.neighborId = neighborId; + he.direction = dir; + he.halfLength = fullLength * 0.5f; + int lanesOut = 0, lanesIn = 0; + rg.resolveLaneCounts(e, nodeId, lanesOut, lanesIn); + /* Constrain the road's actual footprint: lanes plus + * the sidewalk only when sidewalks are enabled — + * terrain past the structure edge is a roadside + * verge and must not lift the road on slopes. */ + he.halfWidth = (float)std::max(lanesIn, lanesOut) * + rg.config.laneWidth; + if (rg.config.sidewalkEnabled) + he.halfWidth += rg.config.sidewalkWidth; + he.roadLevelAtNode = end == 0 ? e.roadLevelA : + e.roadLevelB; + he.roadLevelAtNeighbor = end == 0 ? e.roadLevelB : + e.roadLevelA; + halfEdges.push_back(he); + } + } + if (halfEdges.empty()) + return; + + /* XZ footprint of the road top surface (wedge fans, segment bands, + * sidewalks — the same geometry complyTerrain uses) in a coarse + * lookup grid. Constraint samples are generated on a rectangle + * around each half-edge, whose corners behind a node stick out past + * the wedge fan / end cap by up to halfWidth*sqrt(2); terrain bumps + * there are not under the road and must not lift it, so samples + * outside the actual footprint are dropped. */ + std::vector > footprint; + { + Procedural::TriangleBuffer fb = + makeFallbackTemplate(rg.config.roadThickness); + bool sidewalksOn = rg.config.sidewalkEnabled; + float sidewalkThick = + std::max(0.01f, rg.config.sidewalkThickness); + Procedural::TriangleBuffer fbSw = + RoadGeometryLib::makeSidewalkFallbackTemplate( + sidewalkThick); + std::vector > topTris; + for (auto &kv : m_pageGeometry) { + RoadPageGeometry &pg = kv.second; + for (const RoadWedge &wedge : pg.wedges) { + Procedural::TriangleBuffer tmp; + if (!buildWedgeGeometry(wedge, rg, fb, tmp)) + continue; + collectTopTriangles(tmp, 0.0f, topTris); + if (sidewalksOn) { + Procedural::TriangleBuffer tmpSw; + if (RoadGeometryLib::buildSidewalkGeometry( + wedge, rg, fbSw, tmpSw)) + collectTopTriangles(tmpSw, 0.0f, + topTris); } } - - /* Sidewalk bands: same sampling, fixup target is - * the sidewalk underside (top - sidewalkThickness). */ - if (sidewalks) { + for (const RoadStraightSegment &seg : pg.segments) { + Ogre::Vector3 c[4]; + Ogre::Vector2 uvc[4]; + if (!RoadGeometryLib::computeSegmentBand( + seg, rg, c, uvc)) + continue; + topTris.push_back({ c[0], c[1], c[2] }); + topTris.push_back({ c[0], c[2], c[3] }); + if (!sidewalksOn) + continue; for (int side = 0; side < 2; ++side) { Ogre::Vector3 sc[4]; Ogre::Vector2 suvc[4]; @@ -1520,61 +2645,464 @@ void RoadSystem::complyTerrain(TerrainSystem *terrainSystem, seg, rg, side, sc, suvc)) continue; - for (int i = 0; i < 4; ++i) - terrainSystem->writeFixup( - sc[i].x, sc[i].z, - sc[i].y - sidewalkThick); - - Ogre::Vector3 sd10 = sc[1] - sc[0]; - Ogre::Vector3 sd32 = sc[2] - sc[3]; - Ogre::Vector3 sd30 = sc[3] - sc[0]; - int sSteps = std::max( - 1, (int)std::ceil( - sd10.length())); - int sWidth = std::max( - 1, (int)std::ceil( - sd30.length())); - for (int s = 1; s < sSteps; ++s) { - float t = (float)s / - (float)sSteps; - Ogre::Vector3 p0 = - sc[0] + sd10 * t; - Ogre::Vector3 p1 = - sc[3] + sd32 * t; - for (int w = 0; w <= sWidth; - ++w) { - float wt = (float)w / - (float)sWidth; - Ogre::Vector3 pos = - p0 + (p1 - p0) * - wt; - terrainSystem->writeFixup( - pos.x, pos.z, - pos.y - - sidewalkThick); - } - } + topTris.push_back( + { sc[0], sc[1], sc[2] }); + topTris.push_back( + { sc[0], sc[2], sc[3] }); } } } + footprint.reserve(topTris.size()); + for (const auto &tri : topTris) + footprint.push_back( + { Ogre::Vector2(tri[0].x, tri[0].z), + Ogre::Vector2(tri[1].x, tri[1].z), + Ogre::Vector2(tri[2].x, tri[2].z) }); + } + const float gridCell = 8.0f; + std::unordered_map > fpGrid; + auto fpKey = [](long gx, long gz) { + return (uint64_t)(uint32_t)gx << 32 | (uint32_t)gz; + }; + for (uint32_t ti = 0; ti < footprint.size(); ++ti) { + const auto &tri = footprint[ti]; + float mnx = std::min(tri[0].x, std::min(tri[1].x, tri[2].x)); + float mxx = std::max(tri[0].x, std::max(tri[1].x, tri[2].x)); + float mnz = std::min(tri[0].y, std::min(tri[1].y, tri[2].y)); + float mxz = std::max(tri[0].y, std::max(tri[1].y, tri[2].y)); + long gx0 = (long)std::floor(mnx / gridCell); + long gx1 = (long)std::floor(mxx / gridCell); + long gz0 = (long)std::floor(mnz / gridCell); + long gz1 = (long)std::floor(mxz / gridCell); + for (long gz = gz0; gz <= gz1; ++gz) + for (long gx = gx0; gx <= gx1; ++gx) + fpGrid[fpKey(gx, gz)].push_back(ti); + } + auto coveredByRoad = [&](float x, float z) { + if (footprint.empty()) + return true; /* no geometry: keep old behaviour */ + auto it = fpGrid.find(fpKey((long)std::floor(x / gridCell), + (long)std::floor(z / gridCell))); + if (it == fpGrid.end()) + return false; + for (uint32_t ti : it->second) { + const auto &tri = footprint[ti]; + float c0 = (tri[1].x - tri[0].x) * (z - tri[0].y) - + (tri[1].y - tri[0].y) * (x - tri[0].x); + float c1 = (tri[2].x - tri[1].x) * (z - tri[1].y) - + (tri[2].y - tri[1].y) * (x - tri[1].x); + float c2 = (tri[0].x - tri[2].x) * (z - tri[2].y) - + (tri[0].y - tri[2].y) * (x - tri[2].x); + const float eps = 0.02f; /* boundary spill */ + if ((c0 >= -eps && c1 >= -eps && c2 >= -eps) || + (c0 <= eps && c1 <= eps && c2 <= eps)) + return true; + } + return false; + }; + + /* Constraints on node heights. + * + * The road surface along a half-edge is linear between the node + * surface (Yn + levelAtNode) and the edge midpoint + * (0.5 * (Yn + levelAtNode + Ynb + levelAtNeighbor)), so at + * s = t / halfLength: + * surface = Yn * (1 - s/2) + levelAtNode * (1 - s/2) + + * (s/2) * (Ynb + levelAtNeighbor) + * and the minimum Yn satisfying surface >= required is + * (required - levelAtNode * coef - (s/2) * (Ynb + levelAtNeighbor)) + * / coef, coef = 1 - s/2. + * + * Constraint sampling uses renderedHeightAt() — the rendered + * page-lattice surface evaluated with the actual per-row cell + * triangulation diagonal the renderer/collider use — instead of + * the fine heightmap, so roads are not lifted over sub-lattice + * bumps that never render. Sampling the corridor at dense 0.5 m + * intervals plus every lattice-line crossing of the centreline, + * both curb lines and both end lines, plus every lattice vertex + * inside the band constrains the maxima of that surface over the + * corridor. */ + struct Constraint { + int nodeId; + int neighborId; + float s; + float levelAtNode; + float levelAtNeighbor; + float required; /* terrain height + elevation */ + }; + std::vector constraints; + + auto addConstraint = [&](const HalfEdgeRef &he, float t, float req, + float px, float pz) { + if (!coveredByRoad(px, pz)) + return; + float L = he.halfLength > 1e-4f ? he.halfLength : 1e-4f; + Constraint c; + c.nodeId = he.nodeId; + c.neighborId = he.neighborId; + /* Samples behind the node (t < 0) sit on the node wedge + * fan whose surface is Yn + levelAtNode; clamp to s = 0 + * so they constrain the node directly instead of + * extrapolating the half-edge line backwards. */ + c.s = std::max(0.0f, std::min(1.0f, t / L)); + c.levelAtNode = he.roadLevelAtNode; + c.levelAtNeighbor = he.roadLevelAtNeighbor; + c.required = req; + constraints.push_back(c); + }; + + /* Visual page-vertex lattice: pages are centred on the terrain + * group origin, vertices one step apart. */ + const Ogre::Vector3 &origin = m_terrainGroup->getOrigin(); + const float baseX = + origin.x - m_terrainGroup->getTerrainWorldSize() * 0.5f; + const float baseZ = + origin.z - m_terrainGroup->getTerrainWorldSize() * 0.5f; + + for (const HalfEdgeRef &he : halfEdges) { + const RoadNode *node = rg.findNodeById(he.nodeId); + float L = he.halfLength; + float t0 = -he.halfWidth; + Ogre::Vector3 right = RoadGeometryLib::roadRightVec(he.direction); + const Ogre::Vector3 &a = node->position; + + /* Sample parameters along the half-edge: uniform every + * 0.5 m (dense enough that the triangulation-diagonal + * kinks of the rendered terrain between lattice-line + * crossings cannot hide more than a few centimetres of + * poke-through), extended backwards over the node wedge / + * end cap, plus every crossing of the centreline or a + * curb line with a lattice line. */ + std::vector ts; + int n = std::max(4, (int)std::ceil((L - t0) / 0.5f)); + for (int k = 0; k <= n; ++k) + ts.push_back(t0 + (L - t0) * (float)k / (float)n); + for (int line = -1; line <= 1; ++line) { + Ogre::Vector3 p0 = + a + right * ((float)line * he.halfWidth); + addLatticeCrossings(ts, p0, he.direction, t0, L, + baseX, step, 0); + addLatticeCrossings(ts, p0, he.direction, t0, L, + baseZ, step, 1); + } + std::sort(ts.begin(), ts.end()); + ts.erase(std::unique(ts.begin(), ts.end(), + [](float x, float y) { + return std::fabs(x - y) < 1e-3f; + }), + ts.end()); + + static const float fracs[5] = { -1.0f, -0.5f, 0.0f, 0.5f, + 1.0f }; + for (float t : ts) { + for (float f : fracs) { + Ogre::Vector3 p = a + he.direction * t + + right * (f * he.halfWidth); + addConstraint(he, t, + renderedHeightAt(terrainSystem, + p.x, p.z, step, + baseX, baseZ) + + elevation, + p.x, p.z); + } + } + + /* Crossings of the two end lines (perpendicular to the + * edge at t0 and at L) with the lattice: the remaining + * (band x cell) polygon vertices. */ + for (int end = 0; end < 2; ++end) { + float tE = end == 0 ? t0 : L; + Ogre::Vector3 q0 = a + he.direction * tE; + for (int axis = 0; axis < 2; ++axis) { + float r = axis == 0 ? right.x : right.z; + if (std::fabs(r) < 1e-6f) + continue; + float c = axis == 0 ? q0.x : q0.z; + float base = axis == 0 ? baseX : baseZ; + float uLo = std::min(c - r * he.halfWidth, + c + r * he.halfWidth); + float uHi = std::max(c - r * he.halfWidth, + c + r * he.halfWidth); + long k0 = (long)std::ceil((uLo - base) / step + + 1e-4f); + long k1 = (long)std::floor((uHi - base) / step - + 1e-4f); + for (long k = k0; k <= k1; ++k) { + float u = (base + (float)k * step - c) / + r; + Ogre::Vector3 p = q0 + right * u; + addConstraint( + he, tE, + renderedHeightAt(terrainSystem, + p.x, p.z, step, + baseX, baseZ) + + elevation, + p.x, p.z); + } + } + } + + /* Lattice vertices inside the band: corners of the + * (band x cell) polygons. */ + Ogre::Vector3 b0 = a + he.direction * t0; + Ogre::Vector3 b1 = a + he.direction * L; + float minX = std::min(b0.x, b1.x) - he.halfWidth; + float maxX = std::max(b0.x, b1.x) + he.halfWidth; + float minZ = std::min(b0.z, b1.z) - he.halfWidth; + float maxZ = std::max(b0.z, b1.z) + he.halfWidth; + long i0 = (long)std::floor((minX - baseX) / step); + long i1 = (long)std::ceil((maxX - baseX) / step); + long j0 = (long)std::floor((minZ - baseZ) / step); + long j1 = (long)std::ceil((maxZ - baseZ) / step); + for (long j = j0; j <= j1; ++j) { + for (long i = i0; i <= i1; ++i) { + float vx = baseX + (float)i * step; + float vz = baseZ + (float)j * step; + float t = (vx - a.x) * he.direction.x + + (vz - a.z) * he.direction.z; + if (t < t0 || t > L) + continue; + float perp = (vx - a.x) * right.x + + (vz - a.z) * right.z; + if (std::fabs(perp) > he.halfWidth) + continue; + Ogre::Vector3 v(vx, 0.0f, vz); + addConstraint(he, t, + renderedHeightAt(terrainSystem, + v.x, v.z, step, + baseX, baseZ) + + elevation, + v.x, v.z); + } + } } - /* Mark affected pages dirty so they rebuild with fixup data. */ - if (m_terrainGroup) { - for (auto &kv : m_pageGeometry) { - RoadPageGeometry &pg = kv.second; - terrainSystem->markPageDirty(pg.pageX, pg.pageY); - } + /* Solve for LOW node heights satisfying every constraint, as an + * iterative constraint solver with small steps: + * 1. Kaczmarz projection sweeps raise nodes to feasibility, + * distributing each violated constraint's deficit between + * the node and its neighbour along the constraint normal — + * a per-node "raise to the max lower bound" update corners + * the solution onto whichever node the update order hits + * first (metres of hover over the other end), because a + * constraint coef*Yn + w*Ynb >= req with small w is most + * cheaply satisfied by raising Yn, not Ynb. + * 2. Alternating damped lower-only relaxation (tightens nodes + * against the terrain) and pairwise rebalancing (escapes LP + * corners by lowering the small-coefficient side of a binding + * constraint while raising the large-coefficient side to + * compensate, which strictly reduces the total node height). + * 3. A few raise-only passes clear the residual violations the + * simultaneous damping of phase 2 can introduce. */ + std::unordered_map nodeY; + for (const HalfEdgeRef &he : halfEdges) { + if (nodeY.count(he.nodeId)) + continue; + const RoadNode *n = rg.findNodeById(he.nodeId); + nodeY[he.nodeId] = + renderedHeightAt(terrainSystem, n->position.x, + n->position.z, step, baseX, baseZ) + + elevation; } - /* Persist fixups to disk. */ - terrainSystem->saveFixups(); + std::unordered_map > byNode; + for (size_t ci = 0; ci < constraints.size(); ++ci) { + byNode[constraints[ci].nodeId].push_back(ci); + if (constraints[ci].neighborId != constraints[ci].nodeId) + byNode[constraints[ci].neighborId].push_back(ci); + } + + auto lowerBound = [&](int id) { + float lb = -FLT_MAX; + for (size_t ci : byNode[id]) { + const Constraint &c = constraints[ci]; + float coef = 1.0f - 0.5f * c.s; + if (c.nodeId == id) { + if (std::fabs(coef) < 1e-6f) + continue; + float need = + (c.required - c.levelAtNode * coef - + 0.5f * c.s * + (nodeY[c.neighborId] + + c.levelAtNeighbor)) / + coef; + if (need > lb) + lb = need; + } else { + /* The node is the neighbour: only a + * positive coefficient bounds it from + * below. */ + float w = 0.5f * c.s; + if (w < 1e-6f) + continue; + float need = + (c.required - coef * (nodeY[c.nodeId] + + c.levelAtNode)) / + w - + c.levelAtNeighbor; + if (need > lb) + lb = need; + } + } + return lb; + }; + + /* Kaczmarz sweeps: clear every violated constraint by + * distributing its deficit between the node and the neighbour + * along the constraint normal (minimum-norm correction), so a + * mid-edge bump lifts both endpoints in proportion to their + * coefficients instead of cornering onto whichever node the + * update order reaches first. Corrections only raise and a + * raise never violates another constraint (all coefficients + * are non-negative), so each cleared constraint stays cleared + * and the sweep converges to feasibility. */ + for (int sweep = 0; sweep < 64; ++sweep) { + float maxDeficit = 0.0f; + for (const Constraint &c : constraints) { + float coef = 1.0f - 0.5f * c.s; + float w = 0.5f * c.s; + float val = + coef * (nodeY[c.nodeId] + c.levelAtNode) + + w * (nodeY[c.neighborId] + c.levelAtNeighbor); + float deficit = c.required - val; + if (deficit <= 0.0f) + continue; + maxDeficit = std::max(maxDeficit, deficit); + float norm = coef * coef + w * w; + if (norm < 1e-12f) + continue; + nodeY[c.nodeId] += deficit * coef / norm; + if (c.neighborId != c.nodeId) + nodeY[c.neighborId] += deficit * w / norm; + } + if (maxDeficit < 1e-4f) + break; + } + + auto lowerBoundEx = [&](int id, long exclude) { + float lb = -FLT_MAX; + for (size_t ci : byNode[id]) { + if ((long)ci == exclude) + continue; + const Constraint &c = constraints[ci]; + float coef = 1.0f - 0.5f * c.s; + if (c.nodeId == id) { + if (std::fabs(coef) < 1e-6f) + continue; + float need = + (c.required - c.levelAtNode * coef - + 0.5f * c.s * + (nodeY[c.neighborId] + + c.levelAtNeighbor)) / + coef; + if (need > lb) + lb = need; + } else { + float w = 0.5f * c.s; + if (w < 1e-6f) + continue; + float need = + (c.required - coef * (nodeY[c.nodeId] + + c.levelAtNode)) / + w - + c.levelAtNeighbor; + if (need > lb) + lb = need; + } + } + return lb; + }; + + /* Relax DOWN, alternating with pairwise rebalancing. Plain + * lower-only relaxation sticks at LP corners: a constraint sampled + * a hair past a node (s small) blends a few percent of the + * neighbour's height in, so once the near node just clears its + * local bump the FAR node is held metres above its own terrain. + * Lowering the far (small-coefficient) node while raising the near + * (large-coefficient) node to compensate keeps the constraint + * satisfied and strictly reduces the total height, so alternate + * both until neither moves. */ + auto relaxDown = [&]() { + bool any = false; + for (int pass = 0; pass < 200; ++pass) { + float maxChange = 0.0f; + for (auto &kv : nodeY) { + float lb = lowerBound(kv.first); + if (lb == -FLT_MAX || lb >= kv.second) + continue; + float newY = + kv.second + 0.5f * (lb - kv.second); + float change = std::fabs(newY - kv.second); + if (change > maxChange) + maxChange = change; + kv.second = newY; + } + if (maxChange < 1e-4f) + break; + any = true; + } + return any; + }; + + for (int cycle = 0; cycle < 16; ++cycle) { + bool lowered = relaxDown(); + bool shifted = false; + for (size_t ci = 0; ci < constraints.size(); ++ci) { + const Constraint &c = constraints[ci]; + float coef = 1.0f - 0.5f * c.s; + float w = 0.5f * c.s; + if (w < 1e-6f || w >= coef) + continue; + float val = + coef * (nodeY[c.nodeId] + c.levelAtNode) + + w * (nodeY[c.neighborId] + c.levelAtNeighbor); + if (val - c.required > 1e-3f) + continue; /* slack: plain lowering handles it */ + float lbN = lowerBoundEx(c.neighborId, (long)ci); + if (lbN == -FLT_MAX) + continue; + float delta = nodeY[c.neighborId] - lbN; + if (delta < 1e-4f) + continue; + nodeY[c.nodeId] += delta * w / coef; + nodeY[c.neighborId] = lbN; + shifted = true; + } + if (!lowered && !shifted) + break; + } + for (int pass = 0; pass < 64; ++pass) { + float maxRaise = 0.0f; + for (auto &kv : nodeY) { + float lb = lowerBound(kv.first); + if (lb == -FLT_MAX || lb <= kv.second) + continue; + maxRaise = std::max(maxRaise, lb - kv.second); + kv.second = lb; + } + if (maxRaise < 1e-4f) + break; + } + + bool changed = false; + for (const auto &kv : nodeY) { + RoadNode *n = rg.findNodeById(kv.first); + if (!n) + continue; + if (std::fabs(kv.second - n->position.y) < 1e-4f) + continue; + n->position.y = kv.second; + n->verticalOffset = + kv.second - terrainSystem->getHeightAt(n->position); + changed = true; + } + if (changed) + rg.bumpVersion(); Ogre::LogManager::getSingleton().logMessage( - "RoadSystem: terrain compliance applied for " + - Ogre::StringConverter::toString( - (unsigned long)m_pageGeometry.size()) + - " pages"); + "RoadSystem: roads complied to terrain for " + + Ogre::StringConverter::toString((unsigned long)nodeY.size()) + + " nodes"); } /* --- Wedge Debug Visualization --- */ @@ -1701,19 +3229,3 @@ void RoadSystem::rebuildDebugWedge() } m_debugWedgeObject->end(); } - -/* --- Compliance Height Helper (M5.10 falloff) --- */ - -float RoadSystem::computeComplianceHeight( - float roadSurfaceY, float roadThickness, - float baseHeight, float lateralDistance, - float halfRoadWidth, float fadeWidth) -{ - if (lateralDistance <= halfRoadWidth) - return roadSurfaceY - roadThickness; - if (lateralDistance >= halfRoadWidth + fadeWidth) - return baseHeight; - float t = (lateralDistance - halfRoadWidth) / fadeWidth; - float compliance = roadSurfaceY - roadThickness; - return compliance + (baseHeight - compliance) * t; -} diff --git a/src/features/editScene/systems/RoadSystem.hpp b/src/features/editScene/systems/RoadSystem.hpp index 2e9062f..46ccb94 100644 --- a/src/features/editScene/systems/RoadSystem.hpp +++ b/src/features/editScene/systems/RoadSystem.hpp @@ -11,11 +11,13 @@ #include #include #include +#include #include #include #include #include "../components/RoadGraph.hpp" +#include "RoadRegionStore.hpp" namespace Ogre { class TerrainGroup; @@ -30,8 +32,8 @@ class JoltPhysicsWrapper; * One entry exists for every loaded terrain page. The page generates the * wedges and straight segments seeded by road nodes inside its bounds (see * the page-assignment decision in TerrainRequirements.md, M5.4). Runtime - * objects (mesh entity, collider, roadside prefabs) are created in later - * milestones and destroyed together when the page unloads. + * objects (mesh entity, collider, roadside prefabs) are created by the + * M5.8+ passes and destroyed together when the page unloads. */ struct RoadPageGeometry { long pageX = 0; @@ -61,6 +63,15 @@ struct RoadPageGeometry { /** Geometry needs (re)generation. */ bool dirty = true; + /** + * Content signature of the last bucket assignment (M5): an FNV-1a + * hash over the page's seed nodes (id + world position quantized to + * centimetres, so it survives render-origin rebases), their incident + * edges and the geometry-relevant RoadConfig fields. A graph change + * only re-dirties the page when this value changed. + */ + uint64_t signature = 0; + /** * Rendering distance, navmesh contribution and RenderableComponent * pointer have been applied to the created Ogre mesh (M5.8). Reset @@ -71,7 +82,7 @@ struct RoadPageGeometry { /** * RoadSystem — runtime owner of road visual aids, per-page road geometry - * state, and (future) road mesh generation. + * state, and road mesh generation. * * The class builds and updates ManualObject overlays for the road graph: * node markers, edge lines, road-width indicators, and selection @@ -260,32 +271,62 @@ public: void snapNodesToTerrain(const std::vector &nodeIds); /** - * Terrain compliance (M5.10): writes fixup values under every - * road surface vertex so the terrain matches the road underside, - * then extends a linear fade over laneWidth * 2 perpendicular to - * each curb so the terrain shoulders rise smoothly from the road - * edge back to the natural height. + * Terrain compliance (M5.10): lowers the terrain under every + * road so the rendered surface stays below the road top minus a + * small sag. The rendered surface is the interpolation of the + * page-vertex lattice and the fixup layer can only move lattice + * vertices (TerrainSystem::lowerFixupCorners), so compliance is + * solved as a constraint problem: each road top-surface + * triangle imposes rendered(q) <= roadTop(q) - sag on the + * lattice cells it overlaps (for both triangulation + * diagonals), currently satisfied constraints are dropped, and + * the rest are solved with damped Kaczmarz sweeps over cached + * vertex heights (lowering is monotone, so the sweeps + * converge). The per-vertex deltas are then applied as fixups + * and every page touching a lowered vertex is marked dirty. + * Terrain beyond the road corridor keeps its natural height. * * @param terrainSystem the active TerrainSystem that owns the - * fixup layer (used to call writeFixup + markPageDirty). + * fixup layer (lowerFixupCorners + markPageDirty). * @param roadThickness vertical thickness of the road slab. - * @param laneWidth lane width; the fade zone is laneWidth * 2. + * @param laneWidth unused (kept for API compatibility). */ void complyTerrain(class TerrainSystem *terrainSystem, float roadThickness, float laneWidth); + /** + * Inverse of complyTerrain: shifts road nodes vertically (both + * directions) so every point of every road edge stays at least + * @p elevation above the RENDERED terrain surface (the + * page-lattice interpolation, conservatively the max over both + * cell triangulation diagonals) — not the fine heightmap, so + * roads are not lifted over sub-lattice bumps that never + * render. Connected nodes are initialized to rendered terrain + * + elevation, a damped relaxation moves every node toward the + * maximum lower bound its constraints impose given current + * neighbour heights, and a few raise-only passes clear the + * damping residual so no constraint is left violated. + * Constraints sample the corridor at dense 0.5 m intervals + * along each half-edge plus every crossing of the centreline, + * curb and end lines with the page-vertex lattice, plus every + * lattice vertex inside the road band. Samples behind a node + * (the wedge fan) clamp to s = 0 so they constrain the node + * height directly instead of extrapolating the half-edge line + * backwards. Pass roadThickness + a small margin for + * @p elevation so the whole slab (not just the top) stays + * above the terrain. Node verticalOffset values are + * re-derived from the terrain and the graph version is bumped + * on change. + */ + void complyRoadsToTerrain(class TerrainSystem *terrainSystem, + float elevation); + /** Road physics body IDs for debug-draw filtering. */ const std::set &getRoadBodyIds() const { return m_roadBodyIds; } - /** Compute target terrain height for road compliance falloff. */ - static float computeComplianceHeight( - float roadSurfaceY, float roadThickness, - float baseHeight, float lateralDistance, - float halfRoadWidth, float fadeWidth); - /** * Per-page road geometry state, keyed by * TerrainGroup::packIndex(pageX, pageY) (M5.4). @@ -338,6 +379,23 @@ public: return m_edgePrefabs; } + /** + * Render-origin rebase (M5): shift every graph node position by + * -delta (node positions are render-space floats) and rebuild the + * derived buckets. Region files store absolute world doubles and + * are rebase-invariant, so they are not touched. + */ + void onRenderOriginChanged(const Ogre::Vector3 &delta); + + /** + * Persist the streamed road network (M5): every page that currently + * has nodes in the active graph is extracted to its region file + * (same extraction as the page-unload path). Called on scene save + * and at the end of the legacy inline-data migration. No-op when + * terrain streaming is not active. + */ + void flushRegionStore(); + private: void createManualObjects(); void destroyManualObjects(); @@ -355,6 +413,23 @@ private: void destroyPageGeometry(RoadPageGeometry &pg); void clearPageGeometry(); bool pageKeyForNode(int nodeId, uint64_t &outKey) const; + uint64_t computePageSignature(uint64_t key) const; + void hashNodeWorldPos(uint64_t &h, const RoadNode &n) const; + + /* Road region streaming (M5): per-page region files under + * heightmaps//roads/, merged/extracted as terrain pages + * load/unload. */ + void syncRegions(); + void resetRegionState(); + bool roadStreamingActive() const; + bool pageForWorldPos(double wx, double wz, long &outX, + long &outY) const; + void migrateGraphToRegions(RoadGraph &graph); + void loadRegion(long px, long py, RoadGraph &graph); + void buildRegionData(long px, long py, const RoadGraph &graph, + RoadRegionData &out) const; + int findNodeByWorldPos(const RoadGraph &graph, double wx, + double wz) const; /* Mesh assembly (M5.8). */ void buildPageMeshes(RoadPageGeometry &pg); @@ -362,22 +437,12 @@ private: flecs::entity ensureRoadMaterial(const RoadConfig &cfg); flecs::entity ensureRoadLodSettings(const RoadConfig &cfg); void markNavMeshDirty(); + void applyNavMeshDirty(); /* Physics colliders (M5.9). */ void createPageCollider(RoadPageGeometry &pg, const std::string &meshName); void destroyPageCollider(RoadPageGeometry &pg); - /* Terrain compliance falloff (M5.10 step 4): writes the linear - * fade zone perpendicular to one half-edge, from its curb out to - * sideWidth + laneWidth*2. sideSign = +1 for the right side of the - * half-edge direction, -1 for the left side. */ - void writeComplianceFalloff(class TerrainSystem *terrainSystem, - const RoadGraph &graph, - const RoadHalfEdge &halfEdge, - const Ogre::Vector3 &nodePos, - float sideSign, float sideWidth, - float roadThickness, float laneWidth); - /* Road prefab spawning (improvement plan §3.2): per-edge records * with distance hysteresis and edit-mode force spawn. */ void updateEdgePrefabs(); @@ -411,6 +476,21 @@ private: std::unordered_map m_wedgeBuckets; uint64_t m_geometryGraphVersion = 0; + /* Road region streaming state (M5). m_loadedRegions holds the + * RoadRegionStore::packPageKey of every physical page whose region + * data has been merged into the graph. */ + RoadRegionStore m_regionStore; + std::string m_regionRoot; + std::set m_loadedRegions; + bool m_roadMigrated = false; + + /* Navmesh dirty-marking debounce while streaming: set by + * markNavMeshDirty, applied after 15 frames without region or page + * geometry churn. */ + bool m_navMeshDirtyPending = false; + int m_navMeshQuietFrames = 0; + bool m_streamActivity = false; + /** Per-edge road prefab spawn state (improvement plan §3.2), * keyed by edgePrefabKey(nodeA, nodeB). */ std::unordered_map m_edgePrefabs; diff --git a/src/features/editScene/systems/SceneSerializer.cpp b/src/features/editScene/systems/SceneSerializer.cpp index 16bdca4..9afcff8 100644 --- a/src/features/editScene/systems/SceneSerializer.cpp +++ b/src/features/editScene/systems/SceneSerializer.cpp @@ -1,4 +1,5 @@ #include "SceneSerializer.hpp" +#include "RenderOriginSystem.hpp" #include "ItemRegistry.hpp" #include "ContainerStateRegistry.hpp" #include "ItemStateRegistry.hpp" @@ -70,6 +71,35 @@ SceneSerializer::SceneSerializer(flecs::world &world, { } +/* Prefab JSON cache (M4.4). */ +static std::unordered_map s_prefabJsonCache; + +const nlohmann::json * +SceneSerializer::loadPrefabJsonCached(const std::string &filepath) +{ + auto it = s_prefabJsonCache.find(filepath); + if (it != s_prefabJsonCache.end()) + return &it->second; + + try { + std::ifstream file(filepath); + if (!file.is_open()) + return nullptr; + nlohmann::json j; + file >> j; + file.close(); + return &s_prefabJsonCache.emplace(filepath, std::move(j)) + .first->second; + } catch (const std::exception &) { + return nullptr; + } +} + +void SceneSerializer::invalidatePrefabJsonCache(const std::string &filepath) +{ + s_prefabJsonCache.erase(filepath); +} + bool SceneSerializer::saveToFile(const std::string &filepath) { try { @@ -77,9 +107,12 @@ bool SceneSerializer::saveToFile(const std::string &filepath) scene["version"] = "1.0"; scene["entities"] = nlohmann::json::array(); - // Collect all entities with EditorMarkerComponent + // Collect all entities with EditorMarkerComponent; region-store + // spawners (M4) are persisted per terrain page instead and + // must not land in the scene JSON. m_world.query_builder<>() .with() + .without() .build() .each([&](flecs::entity entity) { // Only save root entities (children will be saved recursively) @@ -93,6 +126,17 @@ bool SceneSerializer::saveToFile(const std::string &filepath) // Save ActionDatabase singleton at scene level scene["actionDatabase"] = serializeActionDatabase(); + // Save editor world bookmarks at scene level + scene["bookmarks"] = nlohmann::json::array(); + for (const WorldBookmark &b : m_bookmarks) { + nlohmann::json jb; + jb["name"] = b.name; + jb["x"] = b.x; + jb["y"] = b.y; + jb["z"] = b.z; + scene["bookmarks"].push_back(jb); + } + // Write to file std::ofstream file(filepath); if (!file.is_open()) { @@ -145,6 +189,19 @@ bool SceneSerializer::loadFromFile(const std::string &filepath, deserializeActionDatabase(scene["actionDatabase"]); } + // Load editor world bookmarks (tolerate old scenes without them) + m_bookmarks.clear(); + if (scene.contains("bookmarks") && scene["bookmarks"].is_array()) { + for (const nlohmann::json &jb : scene["bookmarks"]) { + WorldBookmark b; + b.name = jb.value("name", std::string("Bookmark")); + b.x = jb.value("x", 0.0); + b.y = jb.value("y", 0.0); + b.z = jb.value("z", 0.0); + m_bookmarks.push_back(b); + } + } + // Clear entity map for new load m_entityMap.clear(); @@ -984,6 +1041,9 @@ bool SceneSerializer::savePrefab(flecs::entity rootEntity, } file << prefab.dump(4); file.close(); + + /* The cached parse (if any) is now stale. */ + invalidatePrefabJsonCache(filepath); return true; } catch (const std::exception &e) { m_lastError = std::string("Prefab save error: ") + e.what(); @@ -1032,15 +1092,14 @@ bool SceneSerializer::instantiatePrefab(flecs::entity instanceEntity, EditorUISystem *uiSystem) { try { - std::ifstream file(filepath); - if (!file.is_open()) { + /* Cached parse (M4.4): the same prefab may be instantiated + * by hundreds of streamed spawners. */ + const nlohmann::json *cached = loadPrefabJsonCached(filepath); + if (!cached) { m_lastError = "Failed to open prefab: " + filepath; return false; } - - nlohmann::json prefabJson; - file >> prefabJson; - file.close(); + const nlohmann::json &prefabJson = *cached; // Save entity map state — prefabs use their own local IDs auto savedMap = m_entityMap; @@ -1217,6 +1276,15 @@ nlohmann::json SceneSerializer::serializeTransform(flecs::entity entity) { "y", transform.position.y }, { "z", transform.position.z } }; + /* Authoritative double-precision world position (M2). Written + * only when set; legacy scenes have no render origin and the + * float position doubles as world position. */ + if (transform.hasWorldPosition) { + json["worldPosition"] = { { "x", transform.worldX }, + { "y", transform.worldY }, + { "z", transform.worldZ } }; + } + json["rotation"] = { { "w", transform.rotation.w }, { "x", transform.rotation.x }, { "y", transform.rotation.y }, @@ -1405,6 +1473,23 @@ void SceneSerializer::deserializeTransform(flecs::entity entity, pos.value("z", 0.0f)); } + /* Authoritative world position (M2). When present it wins: the + * render-space node position is recomputed through the current + * render origin so scenes load correctly regardless of where the + * origin happens to be. */ + if (json.contains("worldPosition")) { + auto &wp = json["worldPosition"]; + transform.worldX = wp.value("x", 0.0); + transform.worldY = wp.value("y", 0.0); + transform.worldZ = wp.value("z", 0.0); + transform.hasWorldPosition = true; + RenderOriginSystem *ro = RenderOriginSystem::getInstance(); + if (ro) + transform.position = ro->worldToRender( + transform.worldX, transform.worldY, + transform.worldZ); + } + // Read rotation if (json.contains("rotation")) { auto &rot = json["rotation"]; @@ -4230,6 +4315,26 @@ nlohmann::json SceneSerializer::serializeTerrain(flecs::entity entity) detailNoiseJson["persistence"] = tc.detailNoise.persistence; json["detailNoise"] = detailNoiseJson; + /* Streaming / far-distance rendering (additive; old scenes keep the + * defaults when these keys are missing). */ + json["streamingEnabled"] = tc.streamingEnabled; + json["worldSizeUnits"] = tc.worldSizeUnits; + json["pageLoadRadius"] = tc.pageLoadRadius; + json["pageHoldRadius"] = tc.pageHoldRadius; + json["farClipDistance"] = tc.farClipDistance; + json["fogEnabled"] = tc.fogEnabled; + json["fogStart"] = tc.fogStart; + json["fogEnd"] = tc.fogEnd; + + nlohmann::json baseNoiseJson; + baseNoiseJson["seed"] = tc.baseNoise.seed; + baseNoiseJson["octaves"] = tc.baseNoise.octaves; + baseNoiseJson["frequency"] = tc.baseNoise.frequency; + baseNoiseJson["amplitude"] = tc.baseNoise.amplitude; + baseNoiseJson["lacunarity"] = tc.baseNoise.lacunarity; + baseNoiseJson["persistence"] = tc.baseNoise.persistence; + json["baseNoise"] = baseNoiseJson; + nlohmann::json roadConfigJson; roadConfigJson["roadMeshTemplate"] = tc.roadGraph.config.roadMeshTemplate; roadConfigJson["laneWidth"] = tc.roadGraph.config.laneWidth; @@ -4252,41 +4357,48 @@ nlohmann::json SceneSerializer::serializeTerrain(flecs::entity entity) tc.roadGraph.config.prefabDespawnDistance; json["roadConfig"] = roadConfigJson; - nlohmann::json roadNodesJson = nlohmann::json::array(); - for (auto &n : tc.roadGraph.nodes) { - nlohmann::json nj; - nj["id"] = n.id; - nj["position"] = { n.position.x, n.position.y, n.position.z }; - nj["verticalOffset"] = n.verticalOffset; - roadNodesJson.push_back(nj); - } - json["roadNodes"] = roadNodesJson; + /* Streaming terrains persist the road network in per-page region + * files (heightmaps//roads/) via RoadSystem; the inline + * data would only cover the currently loaded window and go stale. + * roadConfig stays inline — it is global, not per page. */ + if (!tc.streamingEnabled) { + nlohmann::json roadNodesJson = nlohmann::json::array(); + for (auto &n : tc.roadGraph.nodes) { + nlohmann::json nj; + nj["id"] = n.id; + nj["position"] = { n.position.x, n.position.y, + n.position.z }; + nj["verticalOffset"] = n.verticalOffset; + roadNodesJson.push_back(nj); + } + json["roadNodes"] = roadNodesJson; - nlohmann::json roadEdgesJson = nlohmann::json::array(); - for (auto &e : tc.roadGraph.edges) { - nlohmann::json ej; - ej["nodeA"] = e.nodeA; - ej["nodeB"] = e.nodeB; - ej["roadLevelA"] = e.roadLevelA; - ej["roadLevelB"] = e.roadLevelB; - ej["lanesPerDirectionOverride"] = e.lanesPerDirectionOverride; - ej["lanesAtoB"] = e.lanesAtoB; - ej["lanesBtoA"] = e.lanesBtoA; + nlohmann::json roadEdgesJson = nlohmann::json::array(); + for (auto &e : tc.roadGraph.edges) { + nlohmann::json ej; + ej["nodeA"] = e.nodeA; + ej["nodeB"] = e.nodeB; + ej["roadLevelA"] = e.roadLevelA; + ej["roadLevelB"] = e.roadLevelB; + ej["lanesPerDirectionOverride"] = e.lanesPerDirectionOverride; + ej["lanesAtoB"] = e.lanesAtoB; + ej["lanesBtoA"] = e.lanesBtoA; - auto writeSlot = [](const RoadEdgePrefabSlot &slot) { - nlohmann::json sj; - sj["prefabPath"] = slot.prefabPath; - sj["edgeT"] = slot.edgeT; - sj["lateralOffset"] = slot.lateralOffset; - sj["yOffset"] = slot.yOffset; - return sj; - }; - ej["prefabLeft"] = writeSlot(e.prefabLeft); - ej["prefabRight"] = writeSlot(e.prefabRight); - ej["prefabMid"] = writeSlot(e.prefabMid); - roadEdgesJson.push_back(ej); + auto writeSlot = [](const RoadEdgePrefabSlot &slot) { + nlohmann::json sj; + sj["prefabPath"] = slot.prefabPath; + sj["edgeT"] = slot.edgeT; + sj["lateralOffset"] = slot.lateralOffset; + sj["yOffset"] = slot.yOffset; + return sj; + }; + ej["prefabLeft"] = writeSlot(e.prefabLeft); + ej["prefabRight"] = writeSlot(e.prefabRight); + ej["prefabMid"] = writeSlot(e.prefabMid); + roadEdgesJson.push_back(ej); + } + json["roadEdges"] = roadEdgesJson; } - json["roadEdges"] = roadEdgesJson; /* Persist binary heightmap, blend maps, and aux maps alongside the JSON. */ TerrainSystem *ts = TerrainSystem::getInstance(); @@ -4350,6 +4462,27 @@ void SceneSerializer::deserializeTerrain(flecs::entity entity, tc.detailNoise.persistence = dnj.value("persistence", 0.5f); } + /* Streaming / far-distance rendering. Missing keys keep the + * TerrainComponent defaults so old scenes load unchanged. */ + tc.streamingEnabled = json.value("streamingEnabled", false); + tc.worldSizeUnits = json.value("worldSizeUnits", 40000000.0); + tc.pageLoadRadius = json.value("pageLoadRadius", 2); + tc.pageHoldRadius = json.value("pageHoldRadius", 3); + tc.farClipDistance = json.value("farClipDistance", 6000.0f); + tc.fogEnabled = json.value("fogEnabled", true); + tc.fogStart = json.value("fogStart", 2500.0f); + tc.fogEnd = json.value("fogEnd", 5500.0f); + + if (json.contains("baseNoise")) { + auto &bnj = json["baseNoise"]; + tc.baseNoise.seed = bnj.value("seed", 1337); + tc.baseNoise.octaves = bnj.value("octaves", 4); + tc.baseNoise.frequency = bnj.value("frequency", 0.0005f); + tc.baseNoise.amplitude = bnj.value("amplitude", 40.0f); + tc.baseNoise.lacunarity = bnj.value("lacunarity", 2.0f); + tc.baseNoise.persistence = bnj.value("persistence", 0.5f); + } + if (json.contains("roadConfig")) { auto &rcj = json["roadConfig"]; tc.roadGraph.config.roadMeshTemplate = diff --git a/src/features/editScene/systems/SceneSerializer.hpp b/src/features/editScene/systems/SceneSerializer.hpp index 8ca9ed2..1ea3f5a 100644 --- a/src/features/editScene/systems/SceneSerializer.hpp +++ b/src/features/editScene/systems/SceneSerializer.hpp @@ -7,6 +7,7 @@ #include #include #include +#include "../ui/WorldBookmark.hpp" // Forward declarations class EditorUISystem; @@ -29,6 +30,31 @@ public: bool loadFromFile(const std::string &filepath, EditorUISystem *uiSystem = nullptr); + /** + * Editor world bookmarks (world-space camera positions), saved + * in the scene JSON as a top-level "bookmarks" array. + */ + void setBookmarks(const std::vector &bookmarks) + { + m_bookmarks = bookmarks; + } + const std::vector &getBookmarks() const + { + return m_bookmarks; + } + + /** + * Prefab JSON cache (M4.4): prefab files are read and parsed + * once, then served from memory (spawner-heavy streamed worlds + * instantiate the same prefabs repeatedly). Entries are + * invalidated by savePrefab(); callers that overwrite or delete + * a prefab file must call invalidatePrefabJsonCache(). + * Returns nullptr when the file cannot be read or parsed. + */ + static const nlohmann::json *loadPrefabJsonCached( + const std::string &filepath); + static void invalidatePrefabJsonCache(const std::string &filepath); + /** * Get last error message */ @@ -313,6 +339,9 @@ private: Ogre::SceneManager *m_sceneMgr; std::string m_lastError; + // Editor world bookmarks (saved at scene level) + std::vector m_bookmarks; + // Track entity ID mapping for parent/child relationships std::unordered_map m_entityMap; diff --git a/src/features/editScene/systems/SpawnerRegionStore.cpp b/src/features/editScene/systems/SpawnerRegionStore.cpp new file mode 100644 index 0000000..34f428e --- /dev/null +++ b/src/features/editScene/systems/SpawnerRegionStore.cpp @@ -0,0 +1,190 @@ +#include "SpawnerRegionStore.hpp" + +#include +#include + +#include +#include + +void SpawnerRegionStore::setRootDirectory(const std::string &dir) +{ + if (dir == m_rootDir) + return; + m_rootDir = dir; + clearCache(); +} + +void SpawnerRegionStore::clearCache() +{ + m_pages.clear(); +} + +std::string SpawnerRegionStore::pageFilePath(const std::string &rootDir, + long pageX, long pageY) +{ + return rootDir + "/spawners/" + std::to_string(pageX) + "_" + + std::to_string(pageY) + ".json"; +} + +SpawnerRegionStore::PageData &SpawnerRegionStore::loadPage(long pageX, + long pageY) +{ + const uint64_t key = packPageKey(pageX, pageY); + auto it = m_pages.find(key); + if (it != m_pages.end()) + return it->second; + + PageData data; + if (!m_rootDir.empty()) { + const std::string path = pageFilePath(m_rootDir, pageX, pageY); + try { + std::ifstream file(path); + if (file.is_open()) { + nlohmann::json j; + file >> j; + data.nextId = j.value("nextId", 1); + if (j.contains("spawners") && + j["spawners"].is_array()) { + for (const nlohmann::json &js : + j["spawners"]) { + SpawnerRegionDef def; + def.id = js.value("id", 0); + def.prefabPath = js.value( + "prefabPath", + std::string()); + def.worldX = js.value("x", 0.0); + def.worldY = js.value("y", 0.0); + def.worldZ = js.value("z", 0.0); + def.spawnDistance = js.value( + "spawnDistance", 100.0f); + def.despawnDistance = js.value( + "despawnDistance", 200.0f); + data.defs.push_back(def); + if (def.id >= data.nextId) + data.nextId = + def.id + 1; + } + } + } + } catch (const std::exception &e) { + Ogre::LogManager::getSingleton().logMessage( + "SpawnerRegionStore: failed to load " + path + + ": " + e.what()); + } + } + + return m_pages.emplace(key, std::move(data)).first->second; +} + +bool SpawnerRegionStore::savePage(long pageX, long pageY, + const PageData &data) const +{ + if (m_rootDir.empty()) + return false; + + try { + const std::string dir = m_rootDir + "/spawners"; + std::filesystem::create_directories(dir); + + nlohmann::json j; + j["version"] = "1.0"; + j["page"] = { pageX, pageY }; + j["nextId"] = data.nextId; + j["spawners"] = nlohmann::json::array(); + for (const SpawnerRegionDef &def : data.defs) { + nlohmann::json js; + js["id"] = def.id; + js["prefabPath"] = def.prefabPath; + js["x"] = def.worldX; + js["y"] = def.worldY; + js["z"] = def.worldZ; + js["spawnDistance"] = def.spawnDistance; + js["despawnDistance"] = def.despawnDistance; + j["spawners"].push_back(js); + } + + const std::string path = + pageFilePath(m_rootDir, pageX, pageY); + std::ofstream file(path); + if (!file.is_open()) { + Ogre::LogManager::getSingleton().logMessage( + "SpawnerRegionStore: failed to write " + path); + return false; + } + file << j.dump(4); + return true; + } catch (const std::exception &e) { + Ogre::LogManager::getSingleton().logMessage( + "SpawnerRegionStore: save error: " + + std::string(e.what())); + return false; + } +} + +const std::vector & +SpawnerRegionStore::getSpawners(long pageX, long pageY) +{ + if (m_rootDir.empty()) { + static const std::vector s_empty; + return s_empty; + } + return loadPage(pageX, pageY).defs; +} + +const SpawnerRegionDef *SpawnerRegionStore::findById(long pageX, long pageY, + uint64_t id) +{ + if (m_rootDir.empty()) + return nullptr; + const PageData &data = loadPage(pageX, pageY); + for (const SpawnerRegionDef &def : data.defs) { + if (def.id == id) + return &def; + } + return nullptr; +} + +uint64_t SpawnerRegionStore::addSpawner(long pageX, long pageY, + SpawnerRegionDef def) +{ + if (m_rootDir.empty()) + return 0; + PageData &data = loadPage(pageX, pageY); + def.id = data.nextId++; + data.defs.push_back(def); + if (!savePage(pageX, pageY, data)) + return 0; + return def.id; +} + +bool SpawnerRegionStore::removeSpawnerById(long pageX, long pageY, + uint64_t id) +{ + if (m_rootDir.empty()) + return false; + PageData &data = loadPage(pageX, pageY); + for (size_t i = 0; i < data.defs.size(); ++i) { + if (data.defs[i].id != id) + continue; + data.defs.erase(data.defs.begin() + i); + return savePage(pageX, pageY, data); + } + return false; +} + +bool SpawnerRegionStore::updateSpawnerById(long pageX, long pageY, + uint64_t id, + const SpawnerRegionDef &def) +{ + if (m_rootDir.empty()) + return false; + PageData &data = loadPage(pageX, pageY); + for (SpawnerRegionDef &d : data.defs) { + if (d.id != id) + continue; + d = def; + d.id = id; + return savePage(pageX, pageY, data); + } + return false; +} diff --git a/src/features/editScene/systems/SpawnerRegionStore.hpp b/src/features/editScene/systems/SpawnerRegionStore.hpp new file mode 100644 index 0000000..117aaec --- /dev/null +++ b/src/features/editScene/systems/SpawnerRegionStore.hpp @@ -0,0 +1,93 @@ +#ifndef EDITSCENE_SPAWNERREGIONSTORE_HPP +#define EDITSCENE_SPAWNERREGIONSTORE_HPP +#pragma once + +#include +#include +#include +#include + +/** + * One terrain-attached prefab spawner definition, persisted per + * region (region == one physical terrain page). + * + * Positions are absolute double WORLD coordinates (see + * RenderOriginSystem), so spawners survive render-origin rebases and + * stay exact at the far corners of a 40,000,000 x 40,000,000 world. + */ +struct SpawnerRegionDef { + std::string prefabPath; + double worldX = 0.0; + double worldY = 0.0; + double worldZ = 0.0; + float spawnDistance = 100.0f; + float despawnDistance = 200.0f; + + /* Stable id, unique within the page file. Assigned by + * addSpawner(); streamed spawner entities reference their def by + * (pageX, pageY, id) via StreamedSpawnerTag. */ + uint64_t id = 0; +}; + +/** + * SpawnerRegionStore (M4): persists terrain-attached prefab spawner + * definitions per terrain page in + * /spawners/_.json, with rootDir conventionally + * "heightmaps/". Pages are lazily loaded and cached in + * memory; every mutation rewrites the affected page file immediately + * (editor-paced writes, small files). + * + * The store is file/json only — it knows nothing about entities, + * scene nodes, or the terrain; TerrainPrefabSpawnerSystem turns defs + * into entities for loaded pages and back. + */ +class SpawnerRegionStore { +public: + void setRootDirectory(const std::string &dir); + const std::string &getRootDirectory() const + { + return m_rootDir; + } + + /* Drop all cached pages (terrain switch). */ + void clearCache(); + + static std::string pageFilePath(const std::string &rootDir, + long pageX, long pageY); + static uint64_t packPageKey(long pageX, long pageY) + { + return ((uint64_t)(uint32_t)pageX << 32) | + (uint32_t)pageY; + } + + /* All defs of a page (empty when none / no store configured). + * The returned reference is invalidated by any mutation of the + * same page. */ + const std::vector &getSpawners(long pageX, + long pageY); + + const SpawnerRegionDef *findById(long pageX, long pageY, + uint64_t id); + + /* Assigns a fresh id, appends the def and rewrites the page + * file. Returns the assigned id (0 on failure). */ + uint64_t addSpawner(long pageX, long pageY, SpawnerRegionDef def); + + bool removeSpawnerById(long pageX, long pageY, uint64_t id); + bool updateSpawnerById(long pageX, long pageY, uint64_t id, + const SpawnerRegionDef &def); + +private: + struct PageData { + std::vector defs; + uint64_t nextId = 1; + }; + + PageData &loadPage(long pageX, long pageY); + bool savePage(long pageX, long pageY, const PageData &data) const; + + std::string m_rootDir; + std::unordered_map m_pages; +}; + +#endif // EDITSCENE_SPAWNERREGIONSTORE_HPP diff --git a/src/features/editScene/systems/TerrainPrefabSpawnerSystem.cpp b/src/features/editScene/systems/TerrainPrefabSpawnerSystem.cpp index a10d9a5..af2ada6 100644 --- a/src/features/editScene/systems/TerrainPrefabSpawnerSystem.cpp +++ b/src/features/editScene/systems/TerrainPrefabSpawnerSystem.cpp @@ -5,6 +5,7 @@ #include "PhysicsSystem.hpp" #include "TerrainSystem.hpp" #include "../components/TerrainPrefabSpawner.hpp" +#include "../components/Terrain.hpp" #include "../components/Transform.hpp" #include "../components/EntityName.hpp" #include "../components/EditorMarker.hpp" @@ -12,7 +13,9 @@ #include "../components/RigidBody.hpp" #include #include +#include #include +#include #include TerrainPrefabSpawnerSystem *TerrainPrefabSpawnerSystem::s_instance = nullptr; @@ -230,6 +233,10 @@ void TerrainPrefabSpawnerSystem::update() if (!m_initialized) return; + /* Region store sync (M4): create/remove spawner entities as + * terrain pages stream in/out. */ + syncStreamedSpawners(); + const Ogre::Vector3 cameraPos = getCameraPosition(); m_query.each([&](flecs::entity e, @@ -309,6 +316,10 @@ void TerrainPrefabSpawnerSystem::update() rec.spawned = spawner.spawnedEntity != 0 ? m_world.entity(spawner.spawnedEntity) : flecs::entity::null(); + + /* Streamed spawners (M4): persist editor edits (moves, + * distance changes) back to the region store. */ + writeBackStreamedSpawner(e, spawner, pos); }); } @@ -385,7 +396,10 @@ bool TerrainPrefabSpawnerSystem::complyTerrainToPrefab( /* Pass 1 — falloff band around the footprint (written first so the * full-flatten pass wins on shared chunk cells, same ordering as - * road compliance M5.10). */ + * road compliance M5.10). The box is in visual world space but + * fixup chunks are sampled in physical heightmap space (X shifted + * by half a page, Z mirrored — TerrainSystem::visualToPhysicalX/Z), + * so every read/write goes through the conversion. */ for (float x = minX - margin; x <= maxX + margin; x += texel) { for (float z = minZ - margin; z <= maxZ + margin; z += texel) { const float dx = @@ -396,17 +410,20 @@ bool TerrainPrefabSpawnerSystem::complyTerrainToPrefab( if (d <= 1e-4f || d > margin) continue; const float t = d / margin; + const float physX = ts->visualToPhysicalX(x); + const float physZ = ts->visualToPhysicalZ(z); const float base = ts->sampleBaseHeightAt( - (long)std::floor(x), (long)std::floor(z)); - ts->writeFixupSample( - x, z, targetY * (1.0f - t) + base * t); + (long)std::floor(physX), (long)std::floor(physZ)); + ts->writeFixupSample(physX, physZ, + targetY * (1.0f - t) + base * t); } } /* Pass 2 — full flatten under the footprint. */ for (float x = minX; x <= maxX + texel * 0.5f; x += texel) for (float z = minZ; z <= maxZ + texel * 0.5f; z += texel) - ts->writeFixup(x, z, targetY); + ts->writeFixup(ts->visualToPhysicalX(x), + ts->visualToPhysicalZ(z), targetY); /* Mark affected pages dirty so they resample with the fixups. */ if (Ogre::TerrainGroup *group = ts->getTerrainGroup()) { @@ -486,6 +503,41 @@ flecs::entity TerrainPrefabSpawnerSystem::createSpawnPoint( if (prefabPath.empty()) return flecs::entity::null(); + TerrainSystem *ts = TerrainSystem::getInstance(); + + /* Streaming terrain (M4): the spawner def goes to the region + * store; the entity is (re)created by the page sync. */ + if (ts && ts->getStreamingActive()) { + Ogre::Vector3 pos = position; + pos.y = ts->getHeightAt(position); + + const double wx = ts->renderToWorldX(pos.x); + const double wy = ts->renderToWorldY(pos.y); + const double wz = ts->renderToWorldZ(pos.z); + + long px, py; + if (!pageForWorld(wx, wz, px, py)) + return flecs::entity::null(); + + ensureRegionStoreRoot(); + SpawnerRegionDef def; + def.prefabPath = prefabPath; + def.worldX = wx; + def.worldY = wy; + def.worldZ = wz; + const uint64_t id = m_regionStore.addSpawner(px, py, def); + if (id == 0) + return flecs::entity::null(); + def.id = id; + + Ogre::LogManager::getSingleton().logMessage( + "TerrainPrefabSpawnerSystem: created streamed spawn point '" + + prefabPath + "' on page " + std::to_string(px) + "," + + std::to_string(py)); + + return createRegionEntity(def, px, py); + } + static int s_spawnCounter = 0; flecs::entity e = m_world.entity(); @@ -495,7 +547,6 @@ flecs::entity TerrainPrefabSpawnerSystem::createSpawnPoint( /* Snap to the terrain surface at placement time (section 6.4). */ Ogre::Vector3 pos = position; - TerrainSystem *ts = TerrainSystem::getInstance(); if (ts && ts->isActive()) pos.y = ts->getHeightAt(position); @@ -521,3 +572,302 @@ flecs::entity TerrainPrefabSpawnerSystem::createSpawnPoint( return e; } + +/* ------------------------------------------------------------------ */ +/* Streamed region spawners (M4) */ +/* ------------------------------------------------------------------ */ + +void TerrainPrefabSpawnerSystem::ensureRegionStoreRoot() +{ + TerrainSystem *ts = TerrainSystem::getInstance(); + if (!ts || !ts->isActive()) + return; + flecs::entity te = m_world.entity(ts->getTerrainEntityId()); + if (!te.is_alive() || !te.has()) + return; + const TerrainComponent &tc = te.get(); + const std::string root = + "heightmaps/" + std::to_string(tc.terrainId); + if (root != m_regionRoot) { + m_regionRoot = root; + m_regionStore.setRootDirectory(root); + } +} + +bool TerrainPrefabSpawnerSystem::pageForWorld(double wx, double wz, + long &outX, long &outY) const +{ + TerrainSystem *ts = TerrainSystem::getInstance(); + if (!ts || !ts->getStreamingActive() || !ts->getTerrainGroup()) + return false; + const double ws = ts->getTerrainGroup()->getTerrainWorldSize(); + const long pages = ts->getStreamingPageCount(); + long px = (long)std::floor(ts->visualToPhysicalX(wx) / ws); + long py = (long)std::floor(ts->visualToPhysicalZ(wz) / ws); + if (px < 0) + px = 0; + if (py < 0) + py = 0; + if (px > pages - 1) + px = pages - 1; + if (py > pages - 1) + py = pages - 1; + outX = px; + outY = py; + return true; +} + +flecs::entity TerrainPrefabSpawnerSystem::createRegionEntity( + const SpawnerRegionDef &def, long pageX, long pageY) +{ + TerrainSystem *ts = TerrainSystem::getInstance(); + if (!ts || def.prefabPath.empty()) + return flecs::entity::null(); + + flecs::entity e = m_world.entity(); + e.set(EntityNameComponent( + "RegionSpawner_" + std::to_string(pageX) + "_" + + std::to_string(pageY) + "_" + std::to_string(def.id))); + e.add(); + + StreamedSpawnerTag tag; + tag.pageX = pageX; + tag.pageY = pageY; + tag.defId = def.id; + e.set(tag); + + TransformComponent xform; + xform.node = m_sceneMgr->getRootSceneNode()->createChildSceneNode(); + xform.position = + ts->worldToRender(def.worldX, def.worldY, def.worldZ); + xform.rotation = Ogre::Quaternion::IDENTITY; + xform.scale = Ogre::Vector3::UNIT_SCALE; + /* Authoritative world position: the render origin rebases + * recompute the node exactly (M2). */ + xform.worldX = def.worldX; + xform.worldY = def.worldY; + xform.worldZ = def.worldZ; + xform.hasWorldPosition = true; + xform.applyToNode(); + e.set(xform); + + TerrainPrefabSpawnerComponent spawner; + spawner.prefabPath = def.prefabPath; + spawner.spawnDistanceSq = def.spawnDistance * def.spawnDistance; + spawner.despawnDistanceSq = + def.despawnDistance * def.despawnDistance; + e.set(spawner); + + if (m_uiSystem) + m_uiSystem->addEntity(e); + + m_regionEntities[SpawnerRegionStore::packPageKey(pageX, pageY)] + .push_back(e); + m_regionEntityDefIds[e.id()] = def.id; + return e; +} + +void TerrainPrefabSpawnerSystem::unloadRegionEntities(uint64_t pageKey) +{ + auto it = m_regionEntities.find(pageKey); + if (it == m_regionEntities.end()) + return; + + m_unloadingRegion = true; + for (flecs::entity e : it->second) { + if (!e.is_alive()) + continue; + despawn(e); + m_regionEntityDefIds.erase(e.id()); + if (m_uiSystem) + m_uiSystem->removeEntity(e); + if (e.has()) { + auto &xform = e.get_mut(); + if (xform.node) { + try { + m_sceneMgr->destroySceneNode( + xform.node); + } catch (...) { + } + xform.node = nullptr; + } + } + e.destruct(); + } + m_unloadingRegion = false; + + m_regionEntities.erase(it); +} + +void TerrainPrefabSpawnerSystem::unloadAllRegionEntities() +{ + while (!m_regionEntities.empty()) + unloadRegionEntities(m_regionEntities.begin()->first); +} + +void TerrainPrefabSpawnerSystem::syncStreamedSpawners() +{ + TerrainSystem *ts = TerrainSystem::getInstance(); + if (!ts || !ts->getStreamingActive() || !ts->getTerrainGroup()) { + /* Streaming off: drop all region entities and the store + * root so a scene switch starts clean. */ + if (!m_regionEntities.empty() || !m_regionRoot.empty()) { + unloadAllRegionEntities(); + m_regionRoot.clear(); + m_regionStore.clearCache(); + } + return; + } + + ensureRegionStoreRoot(); + if (m_regionRoot.empty()) + return; + + /* Drop defs whose entity was deleted through the editor while + * the page was loaded. */ + for (auto &kv : m_regionEntities) { + std::vector &vec = kv.second; + const long px = (long)(uint32_t)(kv.first >> 32); + const long py = (long)(uint32_t)(kv.first & 0xffffffffu); + for (size_t i = 0; i < vec.size();) { + flecs::entity e = vec[i]; + if (e.is_alive()) { + ++i; + continue; + } + uint64_t defId = 0; + /* The tag is gone with the entity; find the def by + * elimination is impossible, so the tag data is + * mirrored in the bookkeeping below. */ + auto bt = m_regionEntityDefIds.find(e.id()); + if (bt != m_regionEntityDefIds.end()) { + defId = bt->second; + m_regionEntityDefIds.erase(bt); + } + if (defId != 0) + m_regionStore.removeSpawnerById(px, py, defId); + vec.erase(vec.begin() + i); + } + } + + /* Loaded physical pages (slot (x, y) is physical page (x, -y)). */ + Ogre::TerrainGroup *group = ts->getTerrainGroup(); + std::set loaded; + for (const auto &kv : group->getTerrainSlots()) { + Ogre::TerrainGroup::TerrainSlot *slot = kv.second; + if (!slot || !slot->instance || !slot->instance->isLoaded()) + continue; + loaded.insert( + SpawnerRegionStore::packPageKey(slot->x, -slot->y)); + } + + /* Create entities for newly loaded pages. */ + for (uint64_t key : loaded) { + if (m_regionEntities.find(key) != m_regionEntities.end()) + continue; + const long px = (long)(uint32_t)(key >> 32); + const long py = (long)(uint32_t)(key & 0xffffffffu); + /* Register even when the page has no defs so the page is + * not re-read from disk every frame. */ + m_regionEntities[key] = std::vector(); + for (const SpawnerRegionDef &def : + m_regionStore.getSpawners(px, py)) + createRegionEntity(def, px, py); + } + + /* Remove entities whose page unloaded. */ + for (auto it = m_regionEntities.begin(); + it != m_regionEntities.end();) { + if (loaded.find(it->first) == loaded.end()) { + const uint64_t key = it->first; + ++it; + unloadRegionEntities(key); + } else { + ++it; + } + } +} + +void TerrainPrefabSpawnerSystem::writeBackStreamedSpawner( + flecs::entity e, const TerrainPrefabSpawnerComponent &spawner, + const Ogre::Vector3 &renderPos) +{ + if (!e.has()) + return; + StreamedSpawnerTag &tag = e.get_mut(); + TerrainSystem *ts = TerrainSystem::getInstance(); + if (!ts) + return; + + const double wx = ts->renderToWorldX(renderPos.x); + const double wy = ts->renderToWorldY(renderPos.y); + const double wz = ts->renderToWorldZ(renderPos.z); + const float sd = std::sqrt(std::max(0.0f, spawner.spawnDistanceSq)); + const float dd = + std::sqrt(std::max(0.0f, spawner.despawnDistanceSq)); + + const SpawnerRegionDef *def = + m_regionStore.findById(tag.pageX, tag.pageY, tag.defId); + if (!def) + return; + + /* Rebase guard: compare in WORLD space; a pure render-origin + * shift leaves these unchanged. */ + const bool moved = std::fabs(def->worldX - wx) > 0.5 || + std::fabs(def->worldY - wy) > 0.5 || + std::fabs(def->worldZ - wz) > 0.5; + const bool changed = moved || + def->prefabPath != spawner.prefabPath || + std::fabs(def->spawnDistance - sd) > 0.5f || + std::fabs(def->despawnDistance - dd) > 0.5f; + if (!changed) + return; + + SpawnerRegionDef nd = *def; + nd.prefabPath = spawner.prefabPath; + nd.worldX = wx; + nd.worldY = wy; + nd.worldZ = wz; + nd.spawnDistance = sd; + nd.despawnDistance = dd; + + long npx, npy; + if (moved && pageForWorld(wx, wz, npx, npy) && + (npx != tag.pageX || npy != tag.pageY)) { + /* Crossed a page boundary: move the def to the new + * page's file and re-tag the entity. */ + m_regionStore.removeSpawnerById(tag.pageX, tag.pageY, + tag.defId); + nd.id = 0; + const uint64_t newId = m_regionStore.addSpawner(npx, npy, nd); + if (newId == 0) + return; + + const uint64_t oldKey = SpawnerRegionStore::packPageKey( + tag.pageX, tag.pageY); + auto it = m_regionEntities.find(oldKey); + if (it != m_regionEntities.end()) { + std::vector &vec = it->second; + vec.erase(std::remove(vec.begin(), vec.end(), e), + vec.end()); + } + m_regionEntities[SpawnerRegionStore::packPageKey(npx, npy)] + .push_back(e); + m_regionEntityDefIds[e.id()] = newId; + tag.pageX = npx; + tag.pageY = npy; + tag.defId = newId; + } else { + m_regionStore.updateSpawnerById(tag.pageX, tag.pageY, + tag.defId, nd); + } + + /* Keep the authoritative world position in sync. */ + if (e.has()) { + TransformComponent &xform = e.get_mut(); + xform.worldX = wx; + xform.worldY = wy; + xform.worldZ = wz; + xform.hasWorldPosition = true; + } +} diff --git a/src/features/editScene/systems/TerrainPrefabSpawnerSystem.hpp b/src/features/editScene/systems/TerrainPrefabSpawnerSystem.hpp index 7d4aae3..ca75d24 100644 --- a/src/features/editScene/systems/TerrainPrefabSpawnerSystem.hpp +++ b/src/features/editScene/systems/TerrainPrefabSpawnerSystem.hpp @@ -6,6 +6,9 @@ #include #include #include +#include + +#include "SpawnerRegionStore.hpp" class EditorCameraSystem; class EditorUISystem; @@ -133,6 +136,23 @@ public: flecs::entity createSpawnPoint(const std::string &prefabPath, const Ogre::Vector3 &position); + /* --- Streamed region spawners (M4) --- */ + + SpawnerRegionStore &getRegionStore() + { + return m_regionStore; + } + + /* Total number of region-store-owned spawner entities currently + * alive (for tests/diagnostics). */ + size_t getStreamedSpawnerCount() const + { + size_t n = 0; + for (const auto &kv : m_regionEntities) + n += kv.second.size(); + return n; + } + private: void spawnPrefab(flecs::entity spawnerEntity, const struct TerrainPrefabSpawnerComponent &spawner, @@ -176,6 +196,42 @@ private: * camera moved more than this many units since the last evaluation * of that spawner (squared: 10^2, section 6.3 item 4). */ static constexpr float CAM_REEVAL_DIST_SQ = 10.0f * 10.0f; + + /* --- Streamed region spawners (M4) --- + * + * When the active terrain streams (TerrainComponent:: + * streamingEnabled), spawner definitions live in the region store + * (heightmaps//spawners/_.json) and only + * spawners on loaded pages exist as entities (tagged + * StreamedSpawnerTag, excluded from scene serialization). + * Scene-embedded spawner entities keep working as + * "always-loaded" spawners for small scenes. */ + SpawnerRegionStore m_regionStore; + std::string m_regionRoot; + /* Page key (SpawnerRegionStore::packPageKey) -> entities created + * for that page. */ + std::unordered_map > + m_regionEntities; + /* Entity id -> region def id, so defs of entities deleted through + * the editor (tag unreadable once dead) can be removed from the + * store. */ + std::unordered_map m_regionEntityDefIds; + /* Guard so the OnRemove observer does not write to the store + * while WE tear down region entities on page unload. */ + bool m_unloadingRegion = false; + + void syncStreamedSpawners(); + void ensureRegionStoreRoot(); + flecs::entity createRegionEntity(const SpawnerRegionDef &def, + long pageX, long pageY); + void unloadRegionEntities(uint64_t pageKey); + void unloadAllRegionEntities(); + /* Physical page containing world (wx, wz); returns false when no + * streaming terrain is active. */ + bool pageForWorld(double wx, double wz, long &outX, long &outY) const; + void writeBackStreamedSpawner(flecs::entity e, + const struct TerrainPrefabSpawnerComponent &spawner, + const Ogre::Vector3 &renderPos); }; #endif // EDITSCENE_TERRAINPREFABSPAWNERSYSTEM_HPP diff --git a/src/features/editScene/systems/TerrainSystem.cpp b/src/features/editScene/systems/TerrainSystem.cpp index c8213ad..af4dc35 100644 --- a/src/features/editScene/systems/TerrainSystem.cpp +++ b/src/features/editScene/systems/TerrainSystem.cpp @@ -28,6 +28,8 @@ #include #include +#include +#include #include #include #include @@ -104,7 +106,35 @@ float TerrainSystem::computeDetailNoise( float freq = 1.0f; for (int i = 0; i < n.octaves; ++i) { noise.SetFrequency(n.frequency * freq); - sum += noise.GetNoise((float)worldX, (float)worldZ) * amp; + /* Sample in double precision: world coords reach 4e7 in the + * streaming world, where float quantization (~4 units) + * would visibly alias the noise. */ + sum += noise.GetNoise((double)worldX, (double)worldZ) * amp; + amp *= n.persistence; + freq *= n.lacunarity; + } + return sum; +} + +float TerrainSystem::computeBaseNoise( + long worldX, long worldZ, const TerrainComponent::BaseNoise &n) const +{ + if (n.octaves <= 0) + return 0.0f; + + /* Create a fresh noise object per call, same as computeDetailNoise: + * FastNoiseLite is cheap and this avoids threading/cached-state + * issues with Ogre's paging WorkQueue. */ + FastNoiseLite noise(n.seed); + noise.SetNoiseType(FastNoiseLite::NoiseType_OpenSimplex2); + + float sum = 0.0f; + float amp = n.amplitude; + float freq = 1.0f; + for (int i = 0; i < n.octaves; ++i) { + noise.SetFrequency(n.frequency * freq); + /* Double precision sampling, see computeDetailNoise. */ + sum += noise.GetNoise((double)worldX, (double)worldZ) * amp; amp *= n.persistence; freq *= n.lacunarity; } @@ -282,6 +312,14 @@ void TerrainSystem::processColliderCreates() continue; } + /* Streaming: the page may have been unloaded after its + * collider was queued; drop entries whose slot is gone. */ + if (mTerrainGroup->getTerrainSlots().find(key) == + mTerrainGroup->getTerrainSlots().end()) { + mPendingColliderPages.erase(key); + continue; + } + Ogre::Terrain *terrain = mTerrainGroup->getTerrain(x, y); if (!terrain || !terrain->isLoaded()) { mColliderCreateQueue.push_back({ x, y }); @@ -297,12 +335,17 @@ void TerrainSystem::processColliderCreates() continue; } - Ogre::Vector3 pagePos = - terrain->_getRootSceneNode()->_getDerivedPosition(); + /* Collider position in absolute world space, computed from + * the slot indices in double precision — the scene-node + * derived position is render space and single-precision, + * so it is wrong after a render-origin rebase. */ + const double ws = mTerrainGroup->getTerrainWorldSize(); + JPH::RVec3 pagePos(m_worldOriginX + (double)x * ws, + m_worldOriginY, + m_worldOriginZ - (double)y * ws); JPH::BodyCreationSettings bodySettings( - shape.GetPtr(), - JPH::RVec3(pagePos.x, pagePos.y, pagePos.z), + shape.GetPtr(), pagePos, JPH::Quat::sIdentity(), JPH::EMotionType::Static, Layers::NON_MOVING); JPH::BodyID bodyId = m_physics->createBody(bodySettings); @@ -510,12 +553,70 @@ bool TerrainSystem::loadFixupChunk(int chunkX, int chunkZ, return ok; } +void TerrainSystem::touchFixupChunkLocked(const FixupChunk &chunk) const +{ + chunk.lastAccess = ++m_fixupAccessCounter; +} + +void TerrainSystem::saveFixupChunkLocked(int chunkX, int chunkZ, + FixupChunk &chunk) const +{ + if (!chunk.dirty || chunk.samples.empty()) + return; + + std::string path = fixupChunkPath(chunkX, chunkZ); + + std::filesystem::path p(path); + std::filesystem::create_directories(p.parent_path()); + + std::ofstream file(path, std::ios::binary); + if (!file) { + Ogre::LogManager::getSingleton().logMessage( + "Terrain: failed to write fixup chunk: " + path); + return; + } + + file.write(reinterpret_cast(chunk.samples.data()), + chunk.samples.size() * sizeof(float)); + chunk.dirty = false; + + Ogre::LogManager::getSingleton().logMessage( + "Terrain: saved fixup chunk x" + + Ogre::StringConverter::toString(chunkX) + "_z" + + Ogre::StringConverter::toString(chunkZ)); +} + +void TerrainSystem::evictFixupChunksLocked() const +{ + /* m_heightmapMutex must be held by the caller. std::map is + * node-based, so erasing the victim does not invalidate the chunk + * references held by concurrent samplers. */ + while (m_fixupChunks.size() > m_fixupChunkCap) { + auto victim = m_fixupChunks.begin(); + for (auto it = m_fixupChunks.begin(); it != m_fixupChunks.end(); + ++it) { + if (it->second.lastAccess < victim->second.lastAccess) + victim = it; + } + saveFixupChunkLocked(victim->first.first, victim->first.second, + victim->second); + Ogre::LogManager::getSingleton().logMessage( + "Terrain: evicted fixup chunk x" + + Ogre::StringConverter::toString(victim->first.first) + + "_z" + + Ogre::StringConverter::toString(victim->first.second)); + m_fixupChunks.erase(victim); + } +} + const TerrainSystem::FixupChunk * TerrainSystem::findFixupChunkLocked(int chunkX, int chunkZ) const { auto it = m_fixupChunks.find({chunkX, chunkZ}); - if (it != m_fixupChunks.end()) + if (it != m_fixupChunks.end()) { + touchFixupChunkLocked(it->second); return &it->second; + } /* Lazy-load from disk. m_heightmapMutex must be held by the caller. */ std::vector samples; @@ -523,12 +624,14 @@ TerrainSystem::findFixupChunkLocked(int chunkX, int chunkZ) const auto &chunk = m_fixupChunks[{chunkX, chunkZ}]; chunk.samples = std::move(samples); chunk.dirty = false; - return &chunk; + touchFixupChunkLocked(chunk); + evictFixupChunksLocked(); + return &m_fixupChunks[{chunkX, chunkZ}]; } return nullptr; } -float TerrainSystem::sampleFixupLocked(float worldX, float worldZ) const +float TerrainSystem::sampleFixupLocked(double worldX, double worldZ) const { /* Compute chunk coordinates. One chunk spans the full PER-PAGE * world size (TerrainComponent::worldSize, section 4.2), so chunk @@ -536,8 +639,8 @@ float TerrainSystem::sampleFixupLocked(float worldX, float worldZ) const * give one sample per worldSize/256 units — the same density as the * base heightmap. FIXUP_CHUNK_RES is always 256 regardless of * heightmapSize — this gives a consistent grid for fixups. */ - float chunkWorldSize = m_pageWorldSize; - float cellW = chunkWorldSize / (float)FIXUP_CHUNK_RES; + double chunkWorldSize = m_pageWorldSize; + double cellW = chunkWorldSize / (double)FIXUP_CHUNK_RES; int chunkX = (int)std::floor(worldX / chunkWorldSize); int chunkZ = (int)std::floor(worldZ / chunkWorldSize); @@ -546,8 +649,8 @@ float TerrainSystem::sampleFixupLocked(float worldX, float worldZ) const return FIXUP_SENTINEL; /* Bilinear sample within the chunk. */ - float cellX = (worldX - (float)chunkX * chunkWorldSize) / cellW; - float cellZ = (worldZ - (float)chunkZ * chunkWorldSize) / cellW; + double cellX = (worldX - (double)chunkX * chunkWorldSize) / cellW; + double cellZ = (worldZ - (double)chunkZ * chunkWorldSize) / cellW; int x0 = (int)std::floor(cellX); int z0 = (int)std::floor(cellZ); @@ -560,8 +663,8 @@ float TerrainSystem::sampleFixupLocked(float worldX, float worldZ) const z0 = std::max(0, std::min(z0, r - 1)); z1 = std::max(0, std::min(z1, r - 1)); - float tx = cellX - (float)x0; - float tz = cellZ - (float)z0; + float tx = (float)(cellX - (double)x0); + float tz = (float)(cellZ - (double)z0); float h00 = chunk->samples[z0 * r + x0]; float h10 = chunk->samples[z0 * r + x1]; @@ -576,8 +679,8 @@ float TerrainSystem::sampleFixupLocked(float worldX, float worldZ) const /* Replace sentinel corners with the natural (base + noise) height * at the corner position so partially written cells blend toward * the untouched terrain instead of toward zero. */ - float originX = (float)chunkX * chunkWorldSize; - float originZ = (float)chunkZ * chunkWorldSize; + double originX = (double)chunkX * chunkWorldSize; + double originZ = (double)chunkZ * chunkWorldSize; if (h00 == FIXUP_SENTINEL) h00 = sampleBaseLocked((long)std::floor(originX + x0 * cellW), (long)std::floor(originZ + z0 * cellW)); @@ -600,13 +703,13 @@ float TerrainSystem::getFixupTexelSize() const return m_pageWorldSize / (float)FIXUP_CHUNK_RES; } -void TerrainSystem::writeFixupSample(float worldX, float worldZ, +void TerrainSystem::writeFixupSample(double worldX, double worldZ, float height) { std::lock_guard lock(m_heightmapMutex); - float chunkWorldSize = m_pageWorldSize; - float cellW = chunkWorldSize / (float)FIXUP_CHUNK_RES; + double chunkWorldSize = m_pageWorldSize; + double cellW = chunkWorldSize / (double)FIXUP_CHUNK_RES; int chunkX = (int)std::floor(worldX / chunkWorldSize); int chunkZ = (int)std::floor(worldZ / chunkWorldSize); @@ -616,10 +719,12 @@ void TerrainSystem::writeFixupSample(float worldX, float worldZ, chunk.samples.resize(FIXUP_CHUNK_RES * FIXUP_CHUNK_RES, FIXUP_SENTINEL); } + touchFixupChunkLocked(chunk); + evictFixupChunksLocked(); - int x0 = (int)std::floor((worldX - (float)chunkX * chunkWorldSize) / + int x0 = (int)std::floor((worldX - (double)chunkX * chunkWorldSize) / cellW); - int z0 = (int)std::floor((worldZ - (float)chunkZ * chunkWorldSize) / + int z0 = (int)std::floor((worldZ - (double)chunkZ * chunkWorldSize) / cellW); int r = FIXUP_CHUNK_RES; @@ -630,27 +735,80 @@ void TerrainSystem::writeFixupSample(float worldX, float worldZ, chunk.dirty = true; } -void TerrainSystem::writeFixup(float worldX, float worldZ, float height) +void TerrainSystem::lowerFixupCorners(double worldX, double worldZ, + float delta) +{ + if (delta <= 0.0f) + return; + + std::lock_guard lock(m_heightmapMutex); + + double chunkWorldSize = m_pageWorldSize; + double cellW = chunkWorldSize / (double)FIXUP_CHUNK_RES; + int chunkX = (int)std::floor(worldX / chunkWorldSize); + int chunkZ = (int)std::floor(worldZ / chunkWorldSize); + + /* Corner indices exactly as sampleFixupLocked computes them. */ + double cellX = (worldX - (double)chunkX * chunkWorldSize) / cellW; + double cellZ = (worldZ - (double)chunkZ * chunkWorldSize) / cellW; + int r = FIXUP_CHUNK_RES; + int x0 = std::max(0, std::min((int)std::floor(cellX), r - 1)); + int z0 = std::max(0, std::min((int)std::floor(cellZ), r - 1)); + int x1 = std::max(0, std::min(x0 + 1, r - 1)); + int z1 = std::max(0, std::min(z0 + 1, r - 1)); + + auto &chunk = m_fixupChunks[{ chunkX, chunkZ }]; + if (chunk.samples.empty()) + chunk.samples.resize(r * r, FIXUP_SENTINEL); + touchFixupChunkLocked(chunk); + evictFixupChunksLocked(); + + for (int tz = z0; tz <= z1; ++tz) { + for (int tx = x0; tx <= x1; ++tx) { + float &s = chunk.samples[tz * r + tx]; + if (s == FIXUP_SENTINEL) { + /* Materialize the surface height currently + * rendered at the write position (NOT the + * natural height at the texel corner) so the + * bilinear blend at (worldX, worldZ) after + * the lowering is exactly current - delta; + * materializing corner naturals would shift + * the surface by the corner-vs-position + * height difference (decimetres on slopes). */ + s = sampleHeightAtLocked((long)worldX, + (long)worldZ); + } + s -= delta; + } + } + chunk.dirty = true; +} + +void TerrainSystem::writeFixup(double worldX, double worldZ, float height) { std::lock_guard lock(m_heightmapMutex); - float chunkWorldSize = m_pageWorldSize; - float cellW = chunkWorldSize / (float)FIXUP_CHUNK_RES; + double chunkWorldSize = m_pageWorldSize; + double cellW = chunkWorldSize / (double)FIXUP_CHUNK_RES; int chunkX = (int)std::floor(worldX / chunkWorldSize); int chunkZ = (int)std::floor(worldZ / chunkWorldSize); /* Create or retrieve the chunk lazily. */ - auto &chunk = m_fixupChunks[{chunkX, chunkZ}]; + auto &chunk = m_fixupChunks[{ chunkX, chunkZ }]; if (chunk.samples.empty()) { chunk.samples.resize(FIXUP_CHUNK_RES * FIXUP_CHUNK_RES, FIXUP_SENTINEL); } + touchFixupChunkLocked(chunk); + evictFixupChunksLocked(); /* Write to the four samples of the chunk cell containing * (worldX, worldZ) so bilinear sampling returns @p height * at the exact write position. */ - float cellX = (worldX - (float)chunkX * chunkWorldSize) / cellW; - float cellZ = (worldZ - (float)chunkZ * chunkWorldSize) / cellW; + float cellX = (float)((worldX - (double)chunkX * chunkWorldSize) / + cellW); + float cellZ = (float)((worldZ - (double)chunkZ * chunkWorldSize) / + cellW); int x0 = (int)std::floor(cellX); int z0 = (int)std::floor(cellZ); @@ -672,35 +830,9 @@ bool TerrainSystem::saveFixups() { std::lock_guard lock(m_heightmapMutex); - for (auto &kv : m_fixupChunks) { - if (!kv.second.dirty || kv.second.samples.empty()) - continue; - - int chunkX = kv.first.first; - int chunkZ = kv.first.second; - std::string path = fixupChunkPath(chunkX, chunkZ); - - std::filesystem::path p(path); - std::filesystem::create_directories(p.parent_path()); - - std::ofstream file(path, std::ios::binary); - if (!file) { - Ogre::LogManager::getSingleton().logMessage( - "Terrain: failed to write fixup chunk: " + - path); - continue; - } - - file.write( - reinterpret_cast(kv.second.samples.data()), - kv.second.samples.size() * sizeof(float)); - kv.second.dirty = false; - - Ogre::LogManager::getSingleton().logMessage( - "Terrain: saved fixup chunk x" + - Ogre::StringConverter::toString(chunkX) + "_z" + - Ogre::StringConverter::toString(chunkZ)); - } + for (auto &kv : m_fixupChunks) + saveFixupChunkLocked(kv.first.first, kv.first.second, + kv.second); return true; } @@ -708,24 +840,36 @@ void TerrainSystem::clearAllFixups() { std::lock_guard lock(m_heightmapMutex); - /* Delete all fixup files. */ + /* Delete all fixup files. Sweep the fixup directory as well: + * chunks evicted from the LRU cache are no longer in + * m_fixupChunks but their files exist on disk. */ for (auto &kv : m_fixupChunks) { std::string path = fixupChunkPath(kv.first.first, kv.first.second); std::remove(path.c_str()); } + if (!m_fixupDir.empty()) { + std::error_code ec; + if (std::filesystem::exists(m_fixupDir, ec)) { + for (const auto &de : + std::filesystem::directory_iterator(m_fixupDir, + ec)) { + if (de.path().extension() == ".bin") + std::filesystem::remove(de.path(), ec); + } + } + } m_fixupChunks.clear(); - /* Mark all loaded pages dirty so they re-sample without fixups. */ + /* Mark all loaded pages dirty so they re-sample without fixups. + * Iterate the actual terrain slots: in streaming mode the page range + * is far larger than the loaded window. */ if (mTerrainGroup) { - for (long py = m_pageMinY; py <= m_pageMaxY; ++py) { - for (long px = m_pageMinX; px <= m_pageMaxX; ++px) { - Ogre::Terrain *terrain = - mTerrainGroup->getTerrain(px, py); - if (terrain && terrain->isLoaded()) - markPageDirty(px, py); - } + for (const auto &kv : mTerrainGroup->getTerrainSlots()) { + Ogre::TerrainGroup::TerrainSlot *slot = kv.second; + if (slot && slot->instance && slot->instance->isLoaded()) + markPageDirty(slot->x, slot->y); } } @@ -739,6 +883,119 @@ size_t TerrainSystem::getFixupChunkCount() const return m_fixupChunks.size(); } +/* ------------------------------------------------------------------ */ +/* Streaming aux-map chunks (M1c) */ +/* ------------------------------------------------------------------ */ + +std::string TerrainSystem::auxChunkPath(const std::string &auxName, long px, + long pz) const +{ + return m_auxChunkDir + "/" + auxName + "/x" + + Ogre::StringConverter::toString(px) + "_z" + + Ogre::StringConverter::toString(pz) + ".bin"; +} + +void TerrainSystem::evictAuxChunksLocked() const +{ + /* m_heightmapMutex must be held by the caller. Shares the fixup + * LRU stamp counter so a single clock orders both caches. */ + while (m_auxChunks.size() > m_auxChunkCap) { + auto victim = m_auxChunks.begin(); + for (auto it = m_auxChunks.begin(); it != m_auxChunks.end(); + ++it) { + if (it->second.lastAccess < victim->second.lastAccess) + victim = it; + } + saveAuxChunkLocked(victim->first, victim->second); + m_auxChunks.erase(victim); + } +} + +void TerrainSystem::saveAuxChunkLocked( + const std::pair &key, AuxChunk &chunk) const +{ + if (!chunk.dirty || chunk.data.empty()) + return; + + long px = (long)(int32_t)(key.second >> 32); + long pz = (long)(int32_t)(key.second & 0xffffffffu); + std::string path = auxChunkPath(key.first, px, pz); + + std::filesystem::path p(path); + std::filesystem::create_directories(p.parent_path()); + + std::ofstream file(path, std::ios::binary); + if (!file) { + Ogre::LogManager::getSingleton().logMessage( + "Terrain: failed to write aux chunk: " + path); + return; + } + file.write(reinterpret_cast(chunk.data.data()), + chunk.data.size() * sizeof(float)); + chunk.dirty = false; +} + +const TerrainSystem::AuxChunk * +TerrainSystem::findAuxChunkLocked(const TerrainComponent::AuxMap &aux, + long px, long pz) const +{ + /* m_heightmapMutex must be held by the caller. */ + auto key = std::make_pair(aux.name, ((uint64_t)(uint32_t)px << 32) | + (uint32_t)pz); + auto it = m_auxChunks.find(key); + if (it != m_auxChunks.end()) { + it->second.lastAccess = ++m_fixupAccessCounter; + return &it->second; + } + + std::ifstream file(auxChunkPath(aux.name, px, pz), std::ios::binary); + if (!file) + return nullptr; + + AuxChunk chunk; + chunk.data.resize((size_t)aux.resolution * aux.resolution); + file.read(reinterpret_cast(chunk.data.data()), + chunk.data.size() * sizeof(float)); + if (!file) + return nullptr; + + auto inserted = m_auxChunks.emplace(key, std::move(chunk)); + inserted.first->second.lastAccess = ++m_fixupAccessCounter; + evictAuxChunksLocked(); + return &inserted.first->second; +} + +TerrainSystem::AuxChunk * +TerrainSystem::getAuxChunkForWriteLocked(const TerrainComponent::AuxMap &aux, + long px, long pz) +{ + /* m_heightmapMutex must be held by the caller. */ + auto key = std::make_pair(aux.name, ((uint64_t)(uint32_t)px << 32) | + (uint32_t)pz); + auto it = m_auxChunks.find(key); + if (it == m_auxChunks.end()) { + AuxChunk chunk; + std::ifstream file(auxChunkPath(aux.name, px, pz), + std::ios::binary); + if (file) { + chunk.data.resize((size_t)aux.resolution * + aux.resolution); + file.read(reinterpret_cast(chunk.data.data()), + chunk.data.size() * sizeof(float)); + if (!file) + chunk.data.clear(); + } + if (chunk.data.empty()) + chunk.data.assign((size_t)aux.resolution * + aux.resolution, + aux.defaultValue); + it = m_auxChunks.emplace(key, std::move(chunk)).first; + } + it->second.lastAccess = ++m_fixupAccessCounter; + evictAuxChunksLocked(); + return &it->second; +} + /* ------------------------------------------------------------------ */ /* Resolution change helpers (M4.6) */ /* ------------------------------------------------------------------ */ @@ -809,6 +1066,14 @@ bool TerrainSystem::changeHeightmapResolution(TerrainComponent &tc, if (newResolution == tc.heightmapSize) return true; + /* Streaming mode has no whole-terrain heightmap (or whole-terrain + * aux maps) to resample; per-page data is resolution-independent. */ + if (m_streamingActive) { + Ogre::LogManager::getSingleton().logMessage( + "Terrain: cannot change heightmap resolution in streaming mode"); + return false; + } + if (m_sculptMode || m_paintMode || m_auxPaintMode) { Ogre::LogManager::getSingleton().logMessage( "Terrain: cannot change heightmap resolution while " @@ -1164,44 +1429,13 @@ bool TerrainSystem::saveBlendMaps(const std::string &dirPath) const if (numLayers < 2) continue; /* No blend maps (only base layer). */ - int bmSize = (int)t->getLayerBlendMapSize(); - - /* Each blendable layer (1..numLayers-1) gets its own file. */ - for (int l = 1; l < numLayers; ++l) { - Ogre::TerrainLayerBlendMap *bm = - t->getLayerBlendMap((Ogre::uint8)l); - if (!bm) - continue; - - float *data = bm->getBlendPointer(); - if (!data) - continue; - - std::string fname = - dirPath + "/page_" + - Ogre::StringConverter::toString(slot->x) + "_" + - Ogre::StringConverter::toString(slot->y) + - "_l" + Ogre::StringConverter::toString(l) + - ".bin"; - - std::ofstream file(fname, std::ios::binary); - if (!file) - continue; - - /* Header: blendMapSize (uint32), layerIndex (uint8). */ - uint32_t sz = (uint32_t)bmSize; - uint8_t li = (uint8_t)l; - file.write(reinterpret_cast(&sz), - sizeof(sz)); - file.write(reinterpret_cast(&li), - sizeof(li)); - - file.write(reinterpret_cast(data), - bmSize * bmSize * sizeof(float)); - savedPages++; - } + savePageBlendMaps(dirPath, slot->x, slot->y); + savedPages += numLayers - 1; } + /* Everything loaded is now on disk; nothing is dirty anymore. */ + m_dirtyBlendPages.clear(); + Ogre::LogManager::getSingleton().logMessage( "Terrain: saved " + Ogre::StringConverter::toString(savedPages) + @@ -1209,6 +1443,111 @@ bool TerrainSystem::saveBlendMaps(const std::string &dirPath) const return true; } +void TerrainSystem::savePageBlendMaps(const std::string &dirPath, long slotX, + long slotY) const +{ + if (!mTerrainGroup) + return; + + Ogre::Terrain *t = mTerrainGroup->getTerrain(slotX, slotY); + if (!t || !t->isLoaded()) + return; + + int numLayers = (int)t->getLayerCount(); + if (numLayers < 2) + return; + + std::filesystem::create_directories(dirPath); + + int bmSize = (int)t->getLayerBlendMapSize(); + + /* Each blendable layer (1..numLayers-1) gets its own file. */ + for (int l = 1; l < numLayers; ++l) { + Ogre::TerrainLayerBlendMap *bm = + t->getLayerBlendMap((Ogre::uint8)l); + if (!bm) + continue; + + float *data = bm->getBlendPointer(); + if (!data) + continue; + + std::string fname = + dirPath + "/page_" + + Ogre::StringConverter::toString(slotX) + "_" + + Ogre::StringConverter::toString(slotY) + "_l" + + Ogre::StringConverter::toString(l) + ".bin"; + + std::ofstream file(fname, std::ios::binary); + if (!file) + continue; + + /* Header: blendMapSize (uint32), layerIndex (uint8). */ + uint32_t sz = (uint32_t)bmSize; + uint8_t li = (uint8_t)l; + file.write(reinterpret_cast(&sz), sizeof(sz)); + file.write(reinterpret_cast(&li), sizeof(li)); + + file.write(reinterpret_cast(data), + bmSize * bmSize * sizeof(float)); + } +} + +void TerrainSystem::loadPageBlendMaps(const std::string &dirPath, long slotX, + long slotY) +{ + if (!mTerrainGroup) + return; + + Ogre::Terrain *t = mTerrainGroup->getTerrain(slotX, slotY); + if (!t || !t->isLoaded()) + return; + + int numLayers = (int)t->getLayerCount(); + if (numLayers < 2) + return; + + int bmSize = (int)t->getLayerBlendMapSize(); + + for (int l = 1; l < numLayers; ++l) { + std::string fname = + dirPath + "/page_" + + Ogre::StringConverter::toString(slotX) + "_" + + Ogre::StringConverter::toString(slotY) + "_l" + + Ogre::StringConverter::toString(l) + ".bin"; + + std::ifstream file(fname, std::ios::binary); + if (!file) + continue; + + uint32_t fileSz = 0; + uint8_t fileLayer = 0; + file.read(reinterpret_cast(&fileSz), sizeof(fileSz)); + file.read(reinterpret_cast(&fileLayer), + sizeof(fileLayer)); + + if ((int)fileSz != bmSize || (int)fileLayer != l) { + Ogre::LogManager::getSingleton().logMessage( + "Terrain: blend map mismatch in " + fname); + continue; + } + + Ogre::TerrainLayerBlendMap *bm = + t->getLayerBlendMap((Ogre::uint8)l); + if (!bm) + continue; + + float *data = bm->getBlendPointer(); + if (!data) + continue; + + file.read(reinterpret_cast(data), + bmSize * bmSize * sizeof(float)); + bm->dirty(); + bm->update(); + } +} + bool TerrainSystem::loadBlendMaps(const std::string &dirPath) { if (!mTerrainGroup || !m_active) @@ -1227,8 +1566,7 @@ bool TerrainSystem::loadBlendMaps(const std::string &dirPath) if (numLayers < 2) continue; - int bmSize = (int)t->getLayerBlendMapSize(); - + /* Count the layers that actually had a file on disk. */ for (int l = 1; l < numLayers; ++l) { std::string fname = dirPath + "/page_" + @@ -1236,48 +1574,11 @@ bool TerrainSystem::loadBlendMaps(const std::string &dirPath) Ogre::StringConverter::toString(slot->y) + "_l" + Ogre::StringConverter::toString(l) + ".bin"; - - std::ifstream file(fname, std::ios::binary); - if (!file) - continue; - - uint32_t fileSz = 0; - uint8_t fileLayer = 0; - file.read(reinterpret_cast(&fileSz), - sizeof(fileSz)); - file.read(reinterpret_cast(&fileLayer), - sizeof(fileLayer)); - - if ((int)fileSz != bmSize || (int)fileLayer != l) { - Ogre::LogManager::getSingleton().logMessage( - "Terrain: blend map mismatch in " + - fname + " (expected " + - Ogre::StringConverter::toString( - bmSize) + - "x" + - Ogre::StringConverter::toString( - bmSize) + - " layer " + - Ogre::StringConverter::toString(l) + - ")"); - continue; - } - - Ogre::TerrainLayerBlendMap *bm = - t->getLayerBlendMap((Ogre::uint8)l); - if (!bm) - continue; - - float *data = bm->getBlendPointer(); - if (!data) - continue; - - file.read(reinterpret_cast(data), - bmSize * bmSize * sizeof(float)); - bm->dirty(); - bm->update(); - loadedPages++; + std::ifstream probe(fname, std::ios::binary); + if (probe) + loadedPages++; } + loadPageBlendMaps(dirPath, slot->x, slot->y); } if (loadedPages > 0) { @@ -1343,6 +1644,14 @@ TerrainSystem::ensureAuxMapLoaded(const TerrainComponent::AuxMap &aux, void TerrainSystem::applyAuxBrush(const std::string &auxMapName, const Ogre::Vector3 &worldPos, float radius, float delta) +{ + applyAuxBrushPhysical(auxMapName, worldPos.x, worldPos.z, radius, + delta); +} + +void TerrainSystem::applyAuxBrushPhysical(const std::string &auxMapName, + double physX, double physZ, + float radius, float delta) { if (!m_active) return; @@ -1368,6 +1677,109 @@ void TerrainSystem::applyAuxBrush(const std::string &auxMapName, return; } + /* Streaming mode: per-page chunks, physical page coords. The brush + * position arrives in physical heightmap coords (same convention + * as sampleAuxMap). */ + if (m_streamingActive) { + std::lock_guard lock(m_heightmapMutex); + + const int res = aux->resolution; + if (res < 2) + return; + const double ws = m_pageWorldSize; + const double spacing = ws / (double)(res - 1); + const int rSamples = (int)(radius / spacing) + 1; + const int cx = (int)floor(physX / spacing); + const int cz = (int)floor(physZ / spacing); + const float r2 = radius * radius; + + for (int tz = cz - rSamples; tz <= cz + rSamples; ++tz) { + for (int tx = cx - rSamples; tx <= cx + rSamples; + ++tx) { + double wx = (double)tx * spacing; + double wz = (double)tz * spacing; + double dx = wx - physX; + double dz = wz - physZ; + if (dx * dx + dz * dz > r2) + continue; + + /* Page texel space: each page owns texels + * [0, res-1) locally; the boundary texel + * (res-1) mirrors the neighbour's texel 0 + * and is kept in sync below. */ + long px = tx / (res - 1); + long pz = tz / (res - 1); + int lx = tx % (res - 1); + int lz = tz % (res - 1); + if (tx < 0) { /* floor division for negatives */ + px = (tx + 1) / (res - 1) - 1; + lx = (int)(tx - px * (res - 1)); + } + if (tz < 0) { + pz = (tz + 1) / (res - 1) - 1; + lz = (int)(tz - pz * (res - 1)); + } + if (px < 0 || pz < 0 || + px >= m_streamWorldPages || + pz >= m_streamWorldPages) + continue; + + float falloff = evaluateBrushFalloff( + (float)sqrt(dx * dx + dz * dz), radius, + m_brushFalloffShape); + + for (int pass = 0; pass < 4; ++pass) { + /* pass 0: owning page; passes 1..3: + * mirror boundary texels into the + * neighbour pages so bilinear reads + * across the seam stay consistent. */ + long wpx = px; + long wpz = pz; + int wlx = lx; + int wlz = lz; + if (pass == 1) { + if (lx != 0 || px == 0) + continue; + wpx = px - 1; + wlx = res - 1; + } else if (pass == 2) { + if (lz != 0 || pz == 0) + continue; + wpz = pz - 1; + wlz = res - 1; + } else if (pass == 3) { + if (lx != 0 || lz != 0 || + px == 0 || pz == 0) + continue; + wpx = px - 1; + wpz = pz - 1; + wlx = res - 1; + wlz = res - 1; + } + + AuxChunk *chunk = + getAuxChunkForWriteLocked( + *aux, wpx, wpz); + if (!chunk) + continue; + float &v = + chunk->data[(size_t)wlz * res + + wlx]; + v = std::max(0.0f, + std::min(1.0f, + v + delta * + falloff)); + chunk->dirty = true; + } + } + } + + if (m_showAuxMapVisualization && + m_auxMapVisualizationName == auxMapName) + m_auxVisDirty = true; + return; + } + std::string path; m_terrainQuery.each([&](flecs::entity e, TerrainComponent &tc, TransformComponent &) { @@ -1384,9 +1796,9 @@ void TerrainSystem::applyAuxBrush(const std::string &auxMapName, float spacing = m_heightmapWorldSize / (float)(res - 1); int rSamples = (int)(radius / spacing) + 1; - int cx = (int)((worldPos.x - m_heightmapWorldMinX) / + int cx = (int)((physX - m_heightmapWorldMinX) / m_heightmapWorldSize * (float)res); - int cz = (int)((worldPos.z - m_heightmapWorldMinZ) / + int cz = (int)((physZ - m_heightmapWorldMinZ) / m_heightmapWorldSize * (float)res); int x0 = std::max(0, cx - rSamples); int x1 = std::min(res - 1, cx + rSamples); @@ -1437,6 +1849,57 @@ float TerrainSystem::sampleAuxMap(const std::string &auxMapName, long worldX, if (!aux) return 0.0f; + /* Streaming mode: sample the per-page chunk, physical coords. */ + if (m_streamingActive) { + std::lock_guard lock(m_heightmapMutex); + + const int res = aux->resolution; + if (res < 2) + return aux->defaultValue; + const float ws = m_pageWorldSize; + + long px = (long)floor((double)worldX / ws); + long pz = (long)floor((double)worldZ / ws); + if (px < 0 || pz < 0 || px >= m_streamWorldPages || + pz >= m_streamWorldPages) + return aux->defaultValue; + + const AuxChunk *chunk = findAuxChunkLocked(*aux, px, pz); + if (!chunk || chunk->data.empty()) + return aux->defaultValue; + + /* Bilinear within the page chunk; texel grid spacing is + * ws/(res-1) so the page boundary texel matches the + * neighbour's first texel (kept in sync by the brush). */ + float fx = ((float)worldX - (float)px * ws) / ws * + (float)(res - 1); + float fz = ((float)worldZ - (float)pz * ws) / ws * + (float)(res - 1); + + int x0 = (int)floorf(fx); + int z0 = (int)floorf(fz); + int x1 = x0 + 1; + int z1 = z0 + 1; + + x0 = std::max(0, std::min(x0, res - 1)); + x1 = std::max(0, std::min(x1, res - 1)); + z0 = std::max(0, std::min(z0, res - 1)); + z1 = std::max(0, std::min(z1, res - 1)); + + float tx = fx - (float)x0; + float tz = fz - (float)z0; + + const std::vector &data = chunk->data; + float v00 = data[(size_t)z0 * res + x0]; + float v10 = data[(size_t)z0 * res + x1]; + float v01 = data[(size_t)z1 * res + x0]; + float v11 = data[(size_t)z1 * res + x1]; + + return (1.0f - tx) * (1.0f - tz) * v00 + + tx * (1.0f - tz) * v10 + (1.0f - tx) * tz * v01 + + tx * tz * v11; + } + const std::vector *dataPtr = ensureAuxMapLoaded(*aux, path); if (!dataPtr || dataPtr->empty()) return aux->defaultValue; @@ -1473,6 +1936,14 @@ float TerrainSystem::sampleAuxMap(const std::string &auxMapName, long worldX, bool TerrainSystem::saveSceneAuxMaps(const TerrainComponent &tc) { + /* Streaming mode: flush all dirty per-page chunks. */ + if (m_streamingActive) { + std::lock_guard lock(m_heightmapMutex); + for (auto &kv : m_auxChunks) + saveAuxChunkLocked(kv.first, kv.second); + return true; + } + if (m_dirtyAuxMaps.empty()) return true; @@ -1513,6 +1984,14 @@ bool TerrainSystem::loadSceneAuxMaps(const TerrainComponent &tc) m_auxMapData.clear(); m_dirtyAuxMaps.clear(); + /* Streaming mode: chunks are lazy-loaded from disk on first + * access; just drop the cache. */ + if (m_streamingActive) { + std::lock_guard lock(m_heightmapMutex); + m_auxChunks.clear(); + return !tc.auxMaps.empty(); + } + for (const auto &aux : tc.auxMaps) { std::string path = getAuxMapPath(tc, aux); ensureAuxMapLoaded(aux, path); @@ -1626,10 +2105,15 @@ void TerrainSystem::buildAuxMapVisualization() return; } - const std::vector *dataPtr = ensureAuxMapLoaded(*aux, path); - if (!dataPtr || dataPtr->empty()) { - destroyAuxMapVisualization(); - return; + /* In streaming mode the data lives in per-page chunks sampled via + * sampleAuxMap; the whole-terrain buffer does not exist. */ + if (!m_streamingActive) { + const std::vector *dataPtr = + ensureAuxMapLoaded(*aux, path); + if (!dataPtr || dataPtr->empty()) { + destroyAuxMapVisualization(); + return; + } } if (!m_auxVisMaterial) { @@ -1661,10 +2145,43 @@ void TerrainSystem::buildAuxMapVisualization() const int gridRes = std::min(128, aux->resolution); const Ogre::Real ws = mTerrainGroup->getTerrainWorldSize(); const float halfSize = ws * 0.5f; - Ogre::Vector3 minPos((float)m_pageMinX * ws - halfSize, 0.0f, - (float)m_pageMinY * ws - halfSize); - Ogre::Vector3 maxPos((float)(m_pageMaxX + 1) * ws - halfSize, 0.0f, - (float)(m_pageMaxY + 1) * ws - halfSize); + + /* Bounds from the currently loaded slots. In legacy mode this is + * exactly the m_pageMin..m_pageMax grid; in streaming mode the page + * range is the whole bounded world, so only loaded pages can be + * visualized. */ + long slotMinX = 0, slotMinY = 0, slotMaxX = 0, slotMaxY = 0; + bool anyLoaded = false; + for (const auto &kv : mTerrainGroup->getTerrainSlots()) { + Ogre::TerrainGroup::TerrainSlot *slot = kv.second; + if (!slot || !slot->instance || !slot->instance->isLoaded()) + continue; + if (!anyLoaded) { + slotMinX = slotMaxX = slot->x; + slotMinY = slotMaxY = slot->y; + anyLoaded = true; + continue; + } + slotMinX = std::min(slotMinX, slot->x); + slotMaxX = std::max(slotMaxX, slot->x); + slotMinY = std::min(slotMinY, slot->y); + slotMaxY = std::max(slotMaxY, slot->y); + } + if (!anyLoaded) { + destroyAuxMapVisualization(); + return; + } + + /* ALIGN_X_Z negates Z: slot y covers visual z in + * [-y*ws - half, -y*ws + half], so the min z comes from the + * maximum slot y and vice versa. Positions are render-space, + * computed in double precision from the world origin. */ + Ogre::Vector3 minPos = worldToRender( + m_worldOriginX + (double)slotMinX * ws - halfSize, 0.0, + m_worldOriginZ - (double)slotMaxY * ws - halfSize); + Ogre::Vector3 maxPos = worldToRender( + m_worldOriginX + (double)(slotMaxX + 1) * ws - halfSize, 0.0, + m_worldOriginZ - (double)(slotMinY - 1) * ws - halfSize); const float stepX = (maxPos.x - minPos.x) / (float)gridRes; const float stepZ = (maxPos.z - minPos.z) / (float)gridRes; @@ -1679,8 +2196,10 @@ void TerrainSystem::buildAuxMapVisualization() minPos.z + (float)z * stepZ); pos.y = getHeightAt(pos) + 1.0f; - long px = (long)visualToPhysicalX(pos.x); - long pz = (long)visualToPhysicalZ(pos.z); + long px = (long)visualToPhysicalX( + renderToWorldX(pos.x)); + long pz = (long)visualToPhysicalZ( + renderToWorldZ(pos.z)); float v = sampleAuxMap(m_auxMapVisualizationName, px, pz); @@ -1709,6 +2228,12 @@ void TerrainSystem::ensureHeightmapLoaded(TerrainComponent &tc) if (m_heightmapLoaded) return; + /* Streaming mode: base heights come from baseNoise evaluated on + * demand (sampleBaseLocked); the single legacy heightmap buffer is + * never needed. */ + if (m_streamingEnabled) + return; + if (loadSceneHeightmap(tc)) return; @@ -1736,16 +2261,22 @@ void TerrainSystem::fillPageHeightData(Ogre::TerrainGroup *group, long x, const Ogre::uint16 terrainSize = group->getTerrainSize(); const Ogre::Real worldSize = group->getTerrainWorldSize(); - Ogre::Vector3 worldPos; - group->convertTerrainSlotToWorldPosition(x, y, &worldPos); - const Ogre::Real step = worldSize / Ogre::Real(terrainSize - 1); + /* Compute the page sample origin in double precision from the + * absolute world origin: slot (x, y) is centred at + * worldOrigin + (x*ws, 0, -y*ws) and fillPageHeightData samples + * the physical rect [x*ws, (x+1)*ws] x ... (the +half-page shift + * is the visual->physical offset). Ogre's own + * convertTerrainSlotToWorldPosition() is single-precision and + * loses metres at page indices near 20000. */ + const double ws = (double)worldSize; + const double baseX = m_worldOriginX + (double)x * ws; + const double baseZ = m_worldOriginZ - (double)y * ws; + const double step = ws / (double)(terrainSize - 1); for (int j = 0; j < terrainSize; ++j) { for (int i = 0; i < terrainSize; ++i) { - const long wx = - (long)(worldPos.x + (Ogre::Real)i * step); - const long wz = - (long)(worldPos.z + (Ogre::Real)j * step); + const long wx = (long)(baseX + (double)i * step); + const long wz = (long)(baseZ + (double)j * step); /* sampleHeightAt() already layers detail noise on top of the * base heightmap, so the page data matches raycasts and * sculpt previews. */ @@ -1768,35 +2299,42 @@ float TerrainSystem::sampleBaseHeightAt(long worldX, long worldZ) const float TerrainSystem::sampleBaseLocked(long worldX, long worldZ) const { - if (!m_heightmapLoaded || m_heightData.empty()) - return proceduralHeight(worldX, worldZ); + float h; - float fx = ((float)worldX - m_heightmapWorldMinX) / - m_heightmapWorldSize * (float)m_heightmapRes; - float fz = ((float)worldZ - m_heightmapWorldMinZ) / - m_heightmapWorldSize * (float)m_heightmapRes; + if (m_streamingEnabled) { + /* Streaming mode: the base terrain is procedural over the + * whole bounded world; no heightmap buffer involved. */ + h = computeBaseNoise(worldX, worldZ, m_baseNoise); + } else if (!m_heightmapLoaded || m_heightData.empty()) { + h = proceduralHeight(worldX, worldZ); + } else { + float fx = ((float)worldX - m_heightmapWorldMinX) / + m_heightmapWorldSize * (float)m_heightmapRes; + float fz = ((float)worldZ - m_heightmapWorldMinZ) / + m_heightmapWorldSize * (float)m_heightmapRes; - int x0 = (int)floorf(fx); - int z0 = (int)floorf(fz); - int x1 = x0 + 1; - int z1 = z0 + 1; + int x0 = (int)floorf(fx); + int z0 = (int)floorf(fz); + int x1 = x0 + 1; + int z1 = z0 + 1; - int r = m_heightmapRes; - x0 = std::max(0, std::min(x0, r - 1)); - x1 = std::max(0, std::min(x1, r - 1)); - z0 = std::max(0, std::min(z0, r - 1)); - z1 = std::max(0, std::min(z1, r - 1)); + int r = m_heightmapRes; + x0 = std::max(0, std::min(x0, r - 1)); + x1 = std::max(0, std::min(x1, r - 1)); + z0 = std::max(0, std::min(z0, r - 1)); + z1 = std::max(0, std::min(z1, r - 1)); - float tx = fx - (float)x0; - float tz = fz - (float)z0; + float tx = fx - (float)x0; + float tz = fz - (float)z0; - float h00 = m_heightData[z0 * r + x0]; - float h10 = m_heightData[z0 * r + x1]; - float h01 = m_heightData[z1 * r + x0]; - float h11 = m_heightData[z1 * r + x1]; + float h00 = m_heightData[z0 * r + x0]; + float h10 = m_heightData[z0 * r + x1]; + float h01 = m_heightData[z1 * r + x0]; + float h11 = m_heightData[z1 * r + x1]; - float h = (1.0f - tx) * (1.0f - tz) * h00 + tx * (1.0f - tz) * h10 + - (1.0f - tx) * tz * h01 + tx * tz * h11; + h = (1.0f - tx) * (1.0f - tz) * h00 + tx * (1.0f - tz) * h10 + + (1.0f - tx) * tz * h01 + tx * tz * h11; + } if (m_detailNoise.enabled) h += computeDetailNoise(worldX, worldZ, m_detailNoise); @@ -1810,7 +2348,7 @@ float TerrainSystem::sampleHeightAtLocked(long worldX, long worldZ) const /* Fixup chunks override the combined base height + noise (M5.9.5). * If a fixup value exists at this position, use it instead. */ - float fixup = sampleFixupLocked((float)worldX, (float)worldZ); + float fixup = sampleFixupLocked((double)worldX, (double)worldZ); if (fixup != FIXUP_SENTINEL) return fixup; @@ -1819,6 +2357,14 @@ float TerrainSystem::sampleHeightAtLocked(long worldX, long worldZ) const void TerrainSystem::setHeightAt(long worldX, long worldZ, float value) { + /* Streaming mode: there is no heightmap buffer; write through the + * fixup layer instead. writeFixup() takes its own lock, so this + * branch must run before locking m_heightmapMutex. */ + if (m_streamingEnabled) { + writeFixup((float)worldX, (float)worldZ, value); + return; + } + std::lock_guard lock(m_heightmapMutex); if (!m_heightmapLoaded) return; @@ -1832,9 +2378,9 @@ void TerrainSystem::setHeightAt(long worldX, long worldZ, float value) m_heightData[z * m_heightmapRes + x] = value; } -long TerrainSystem::worldToPage(float worldCoord, float worldSize) const +long TerrainSystem::worldToPage(double worldCoord, double worldSize) const { - return (long)floorf(worldCoord / worldSize); + return (long)floor(worldCoord / worldSize); } void TerrainSystem::markPageDirty(long pageX, long pageY) @@ -1921,6 +2467,317 @@ void TerrainSystem::processDeferredReloads() m_reloadQueue.clear(); } +/* ------------------------------------------------------------------ */ +/* Streaming page window (M1b) */ +/* ------------------------------------------------------------------ */ + +void TerrainSystem::streamingCameraPage(long *outX, long *outY) const +{ + *outX = 0; + *outY = 0; + if (!mTerrainGroup) + return; + + Ogre::Vector3 camPos = m_groupOrigin; + if (m_camera) { + /* getDerivedPosition() is stale for a detached camera; use + * the parent node (the editor attaches the camera to one) + * and fall back to the local position otherwise. */ + Ogre::Node *parent = m_camera->getParentNode(); + camPos = parent ? parent->_getDerivedPosition() : + m_camera->getRealPosition(); + } + + /* Convert the camera position to physical heightmap coords + * relative to the world origin (render -> world via the tracked + * render origin, then world -> origin-relative) and then to the + * physical page index. The Ogre slot for physical page (px, pz) + * is (px, -pz) because ALIGN_X_Z negates Z (see the streaming + * window comment in the header). */ + const double ws = mTerrainGroup->getTerrainWorldSize(); + const double relX = + (double)camPos.x + m_renderOriginX - m_worldOriginX; + const double relZ = + (double)camPos.z + m_renderOriginZ - m_worldOriginZ; + const double physX = relX + ws * 0.5; + const double physZ = 2.0 * floor((relZ + ws * 0.5) / ws) * ws + + ws * 0.5 - relZ; + + long px = (long)floor(physX / ws); + long pz = (long)floor(physZ / ws); + + if (px < 0) + px = 0; + if (pz < 0) + pz = 0; + if (px > m_streamWorldPages - 1) + px = m_streamWorldPages - 1; + if (pz > m_streamWorldPages - 1) + pz = m_streamWorldPages - 1; + + *outX = px; + *outY = pz; +} + +void TerrainSystem::streamLoadPage(long x, long y) +{ + if (!mTerrainGroup) + return; + if (x < 0 || y < 0 || x >= m_streamWorldPages || + y >= m_streamWorldPages) + return; + + /* Physical page (x, y) lives in TerrainGroup slot (x, -y). */ + const long slotY = -y; + + if (!mTerrainGroup->getTerrain(x, slotY)) { + const Ogre::uint16 terrainSize = mTerrainGroup->getTerrainSize(); + float *heightMap = + OGRE_ALLOC_T(float, terrainSize * terrainSize, + Ogre::MEMCATEGORY_GEOMETRY); + fillPageHeightData(mTerrainGroup, x, slotY, heightMap); + mTerrainGroup->defineTerrain(x, slotY, heightMap); + OGRE_FREE(heightMap, Ogre::MEMCATEGORY_GEOMETRY); + } + + /* Synchronous load: the WorkQueue path is known to hang shutdown. */ + mTerrainGroup->loadTerrain(x, slotY, true); + + /* TerrainGroup positions the instance in single precision from + * its float origin, which loses metres at far page indices; + * re-set the exact render-space position from doubles. */ + { + Ogre::Terrain *t = mTerrainGroup->getTerrain(x, slotY); + if (t) { + const double ws = + mTerrainGroup->getTerrainWorldSize(); + t->setPosition(worldToRender( + m_worldOriginX + (double)x * ws, + m_worldOriginY, + m_worldOriginZ - (double)slotY * ws)); + } + } + + /* Restore painted blend maps for this page, if any. */ + if (!m_blendMapDir.empty()) + loadPageBlendMaps(m_blendMapDir, x, slotY); + + queueColliderCreate(x, slotY); +} + +void TerrainSystem::updateStreamingWindow() +{ + if (!m_streamingActive || !mTerrainGroup) + return; + + /* Sculpt mode unloads all pages and drives ManualObjects instead; + * leave the slots alone until endSculptPreviews() restores them. */ + if (m_sculptPreviewsActive) + return; + + long camX = 0, camY = 0; + streamingCameraPage(&camX, &camY); + + /* --- Unload pages outside the hold radius, farthest first --- */ + std::vector > toUnload; + for (const auto &kv : mTerrainGroup->getTerrainSlots()) { + Ogre::TerrainGroup::TerrainSlot *slot = kv.second; + if (!slot || !slot->instance) + continue; + /* Slot (x, y) is physical page (x, -y). */ + long dx = slot->x - camX; + long dy = -slot->y - camY; + if (dx < 0) + dx = -dx; + if (dy < 0) + dy = -dy; + if (std::max(dx, dy) > (long)m_pageHoldRadius) + toUnload.push_back({ slot->x, slot->y }); + } + std::sort(toUnload.begin(), toUnload.end(), + [camX, camY](const std::pair &a, + const std::pair &b) { + long da = std::max(std::labs(a.first - camX), + std::labs(-a.second - camY)); + long db = std::max(std::labs(b.first - camX), + std::labs(-b.second - camY)); + return da > db; + }); + int unloaded = 0; + for (auto &p : toUnload) { + if (unloaded >= STREAM_UNLOADS_PER_FRAME) + break; + /* removeTerrain() deletes the slot entirely; the DummyPage- + * Provider unload hook never fires because we bypass the + * PagedWorld, so drop the collider here directly. */ + uint64_t key = mTerrainGroup->packIndex(p.first, p.second); + if (m_dirtyBlendPages.erase(key) > 0) + savePageBlendMaps(m_blendMapDir, p.first, p.second); + queueColliderRemove(p.first, p.second); + mTerrainGroup->removeTerrain(p.first, p.second); + ++unloaded; + } + + /* --- Load pages inside the load radius, nearest first. The loop + * runs in physical page coords; the slot for (x, y) is (x, -y). --- */ + long minX = std::max(0L, camX - m_pageLoadRadius); + long maxX = std::min(m_streamWorldPages - 1, + camX + m_pageLoadRadius); + long minY = std::max(0L, camY - m_pageLoadRadius); + long maxY = std::min(m_streamWorldPages - 1, + camY + m_pageLoadRadius); + + std::vector > toLoad; + for (long y = minY; y <= maxY; ++y) { + for (long x = minX; x <= maxX; ++x) { + if (mTerrainGroup->getTerrain(x, -y)) + continue; /* already defined */ + toLoad.push_back({ x, y }); + } + } + std::sort(toLoad.begin(), toLoad.end(), + [camX, camY](const std::pair &a, + const std::pair &b) { + long da = std::max(std::labs(a.first - camX), + std::labs(a.second - camY)); + long db = std::max(std::labs(b.first - camX), + std::labs(b.second - camY)); + return da < db; + }); + int loaded = 0; + for (auto &p : toLoad) { + if (loaded >= STREAM_LOADS_PER_FRAME) + break; + streamLoadPage(p.first, p.second); + ++loaded; + } +} + +void TerrainSystem::applySculptBrushStreaming(double physX, double physZ) +{ + if (!m_active || !mTerrainGroup) + return; + + Ogre::LogManager::getSingleton().logMessage( + "Terrain: applySculptBrush (streaming) at (" + + Ogre::StringConverter::toString(physX) + ", " + + Ogre::StringConverter::toString(physZ) + + ") radius=" + Ogre::StringConverter::toString(m_sculptRadius)); + + /* (physX, physZ) are physical heightmap coords in double + * precision (the command queue converts render->world->physical + * before calling). Edits land on the fixup texel grid so + * sampleFixupLocked() reads back exactly the written values at + * the texel corners. */ + const double texel = getFixupTexelSize(); + if (texel <= 0.0) + return; + + const int rSamples = (int)(m_sculptRadius / texel) + 1; + const int cx = (int)floor(physX / texel); + const int cz = (int)floor(physZ / texel); + const int x0 = cx - rSamples, x1 = cx + rSamples; + const int z0 = cz - rSamples, z1 = cz + rSamples; + const float r2 = m_sculptRadius * m_sculptRadius; + + struct Edit { + double wx, wz; + float h; + }; + std::vector edits; + + if (m_sculptTool == SculptTool::Smooth) { + float avg = 0.0f; + int count = 0; + for (int z = z0; z <= z1; ++z) { + for (int x = x0; x <= x1; ++x) { + double wx = (double)x * texel; + double wz = (double)z * texel; + double dx = wx - physX; + double dz = wz - physZ; + if (dx * dx + dz * dz > r2) + continue; + avg += sampleHeightAt((long)wx, (long)wz); + ++count; + } + } + if (!count) + return; + avg /= (float)count; + for (int z = z0; z <= z1; ++z) { + for (int x = x0; x <= x1; ++x) { + double wx = (double)x * texel; + double wz = (double)z * texel; + double dx = wx - physX; + double dz = wz - physZ; + if (dx * dx + dz * dz > r2) + continue; + float cur = sampleHeightAt((long)wx, (long)wz); + edits.push_back( + { wx, wz, + avg * m_sculptStrength + + cur * (1.0f - m_sculptStrength) }); + } + } + } else if (m_sculptTool == SculptTool::Flatten) { + float ch = sampleHeightAt((long)physX, (long)physZ); + for (int z = z0; z <= z1; ++z) { + for (int x = x0; x <= x1; ++x) { + double wx = (double)x * texel; + double wz = (double)z * texel; + double dx = wx - physX; + double dz = wz - physZ; + if (dx * dx + dz * dz > r2) + continue; + float cur = sampleHeightAt((long)wx, (long)wz); + edits.push_back( + { wx, wz, + cur * (1.0f - m_sculptStrength) + + ch * m_sculptStrength }); + } + } + } else { + float sign = (m_sculptTool == SculptTool::Raise) ? 1.0f : -1.0f; + for (int z = z0; z <= z1; ++z) { + for (int x = x0; x <= x1; ++x) { + double wx = (double)x * texel; + double wz = (double)z * texel; + double dx = wx - physX; + double dz = wz - physZ; + double d2 = dx * dx + dz * dz; + if (d2 > r2) + continue; + float falloff = evaluateBrushFalloff( + (float)sqrt(d2), m_sculptRadius, + m_brushFalloffShape); + float cur = sampleHeightAt((long)wx, (long)wz); + edits.push_back( + { wx, wz, + cur + sign * m_sculptStrength * + falloff }); + } + } + } + + /* writeFixupSample() takes m_heightmapMutex itself; the sampling + * above is done through the same lock one call at a time, so the + * two phases never nest. */ + for (auto &edit : edits) + writeFixupSample(edit.wx, edit.wz, edit.h); + + /* Rebuild any loaded pages the brush touched. The range is in + * physical page coords; the slot for physical page (px, pz) is + * (px, -pz). */ + double ws = m_pageWorldSize; + long px0 = worldToPage(physX - m_sculptRadius, ws); + long px1 = worldToPage(physX + m_sculptRadius, ws); + long pz0 = worldToPage(physZ - m_sculptRadius, ws); + long pz1 = worldToPage(physZ + m_sculptRadius, ws); + for (long pz = pz0; pz <= pz1; ++pz) + for (long px = px0; px <= px1; ++px) + markPageDirty(px, -pz); +} + /* ------------------------------------------------------------------ */ /* activate */ /* ------------------------------------------------------------------ */ @@ -1950,6 +2807,65 @@ void TerrainSystem::activate(TerrainComponent &tc, TransformComponent &xform, m_pageWorldSize = tc.worldSize; m_fixupDir = getFixupDir(tc); + m_blendMapDir = getHeightmapPath(tc) + "_blendmaps"; + m_auxChunkDir = "heightmaps/" + + Ogre::StringConverter::toString(tc.terrainId) + "/aux"; + + /* Streaming state must be set before ensureHeightmapLoaded(): in + * streaming mode the legacy heightmap buffer is never loaded and + * base heights come from baseNoise. */ + m_streamingEnabled = tc.streamingEnabled; + m_baseNoise = tc.baseNoise; + m_streamingActive = tc.streamingEnabled; + + /* Latch the absolute double-precision world origin. Prefer the + * authoritative world fields; fall back to the render-space node + * position converted through the current render origin. Never + * recomputed from the node afterwards. */ + if (xform.hasWorldPosition) { + m_worldOriginX = xform.worldX; + m_worldOriginY = xform.worldY; + m_worldOriginZ = xform.worldZ; + } else { + m_worldOriginX = m_renderOriginX + xform.position.x; + m_worldOriginY = m_renderOriginY + xform.position.y; + m_worldOriginZ = m_renderOriginZ + xform.position.z; + } + m_groupOrigin = worldToRender(m_worldOriginX, m_worldOriginY, + m_worldOriginZ); + + if (m_streamingActive) { + /* Streaming mode (M1b): the world is absolute and bounded, + * physical pages are indexed [0, N-1] per axis with + * N = floor(worldSizeUnits / worldSize). Physical page (x,z) + * covers the physical heightmap rect [x*worldSize, + * (x+1)*worldSize) x [z*worldSize, (z+1)*worldSize) relative + * to the terrain group origin. Because ALIGN_X_Z negates Z, + * the TerrainGroup slot for physical page (x, z) is + * (x, -z), so the slot range is x in [0, N-1], y in + * [-(N-1), 0]. N is clamped to 32768 so TerrainGroup slot + * indices stay inside the signed 16-bit range packIndex + * supports. */ + double n = std::floor(tc.worldSizeUnits / + (double)tc.worldSize); + if (n < 1.0) + n = 1.0; + if (n > 32768.0) + n = 32768.0; + m_streamWorldPages = (long)n; + m_pageLoadRadius = std::max(0, tc.pageLoadRadius); + m_pageHoldRadius = std::max(m_pageLoadRadius, + tc.pageHoldRadius); + + m_pageMinX = 0; + m_pageMinY = -(m_streamWorldPages - 1); + m_pageMaxX = m_streamWorldPages - 1; + m_pageMaxY = 0; + m_heightmapWorldMinX = 0.0f; + m_heightmapWorldMinZ = 0.0f; + m_heightmapWorldSize = (float)m_streamWorldPages * + tc.worldSize; + } ensureHeightmapLoaded(tc); @@ -1974,7 +2890,7 @@ void TerrainSystem::activate(TerrainComponent &tc, TransformComponent &xform, Ogre::Terrain::ALIGN_X_Z, (uint16_t)tc.terrainSize, tc.worldSize); - mTerrainGroup->setOrigin(xform.position); + mTerrainGroup->setOrigin(m_groupOrigin); Ogre::Terrain::ImportData &defaultImp = mTerrainGroup->getDefaultImportSettings(); @@ -2030,20 +2946,50 @@ void TerrainSystem::activate(TerrainComponent &tc, TransformComponent &xform, /* Cache the detail noise settings used by the height sampling helpers. */ m_detailNoise = tc.detailNoise; - for (long py = m_pageMinY; py <= m_pageMaxY; ++py) { - for (long px = m_pageMinX; px <= m_pageMaxX; ++px) { + long loadMinX = m_pageMinX, loadMaxX = m_pageMaxX; + long loadMinY = m_pageMinY, loadMaxY = m_pageMaxY; + if (m_streamingActive) { + /* Streaming mode: define only the initial load window around + * the camera. The full page range is up to 32768^2 slots and + * can never be defined up front; updateStreamingWindow() + * streams the rest in as the camera moves. The window is + * computed in physical page coords [0, N-1]; the slot for + * physical page (x, z) is (x, -z). */ + long camX = 0, camY = 0; + /* streamingCameraPage needs mTerrainGroup; it is created + * above, so this is safe. */ + streamingCameraPage(&camX, &camY); + loadMinX = std::max(0L, camX - m_pageLoadRadius); + loadMaxX = std::min(m_streamWorldPages - 1, + camX + m_pageLoadRadius); + loadMinY = std::max(0L, camY - m_pageLoadRadius); + loadMaxY = std::min(m_streamWorldPages - 1, + camY + m_pageLoadRadius); + } + + for (long py = loadMinY; py <= loadMaxY; ++py) { + for (long px = loadMinX; px <= loadMaxX; ++px) { + /* In streaming mode py is a physical page index and + * the slot Y is negated; in legacy mode the loop + * range already holds slot indices. */ + long slotY = m_streamingActive ? -py : py; Ogre::uint16 terrainSize = mTerrainGroup->getTerrainSize(); float *heightMap = OGRE_ALLOC_T(float, terrainSize *terrainSize, Ogre::MEMCATEGORY_GEOMETRY); - fillPageHeightData(mTerrainGroup, px, py, heightMap); - mTerrainGroup->defineTerrain(px, py, heightMap); + fillPageHeightData(mTerrainGroup, px, slotY, heightMap); + mTerrainGroup->defineTerrain(px, slotY, heightMap); OGRE_FREE(heightMap, Ogre::MEMCATEGORY_GEOMETRY); } } mTerrainGroup->loadAllTerrains(true); + /* loadAllTerrains positions pages in single precision via the + * group origin; re-set exact render-space positions from the + * double world origin. */ + repositionLoadedPages(); + m_active = true; /* Restore saved blend maps if they exist. This must happen after @@ -2062,6 +3008,9 @@ void TerrainSystem::activate(TerrainComponent &tc, TransformComponent &xform, if (m_physics) m_physics->setBodyDrawFilter(&mBodyDrawFilter); + /* Terrain-driven view settings (far clip + fog). */ + applyViewSettings(tc); + Ogre::LogManager::getSingleton().logMessage( "TerrainSystem: activation complete"); } @@ -2087,14 +3036,34 @@ void TerrainSystem::deactivate() m_paintMode = false; m_roadEditMode = false; m_detailNoise = TerrainComponent::DetailNoise(); + m_streamingEnabled = false; + m_baseNoise = TerrainComponent::BaseNoise(); + m_streamingActive = false; + m_streamWorldPages = 1; + m_pageLoadRadius = 2; + m_pageHoldRadius = 3; + m_groupOrigin = Ogre::Vector3::ZERO; + m_worldOriginX = 0.0; + m_worldOriginY = 0.0; + m_worldOriginZ = 0.0; + /* m_renderOrigin* intentionally NOT reset: the render origin is + * global and survives terrain de/reactivation. */ m_heightmapLoaded = false; m_heightData.clear(); m_heightmapRes = 0; m_auxMapData.clear(); m_dirtyAuxMaps.clear(); + m_auxChunks.clear(); + m_auxChunkDir.clear(); + m_dirtyBlendPages.clear(); + m_blendMapDir.clear(); m_fixupChunks.clear(); + m_fixupAccessCounter = 0; m_fixupDir.clear(); + /* Give the camera/scene back their pre-terrain far clip and fog. */ + restoreViewSettings(); + /* Clean up sculpt previews without trying to reload terrain * pages (we're shutting down the whole terrain). */ if (m_sculptPreviewsActive) { @@ -2195,6 +3164,72 @@ void TerrainSystem::deactivate() "TerrainSystem: deactivated"); } +/* ------------------------------------------------------------------ */ +/* View settings (far clip + fog) */ +/* ------------------------------------------------------------------ */ + +void TerrainSystem::applyViewSettings(const TerrainComponent &tc) +{ + if (!m_savedViewValid) { + if (m_camera) + m_savedFarClip = m_camera->getFarClipDistance(); + if (m_sceneMgr) { + m_savedFogMode = m_sceneMgr->getFogMode(); + m_savedFogColour = m_sceneMgr->getFogColour(); + m_savedFogDensity = m_sceneMgr->getFogDensity(); + m_savedFogStart = m_sceneMgr->getFogStart(); + m_savedFogEnd = m_sceneMgr->getFogEnd(); + } + m_savedViewValid = true; + } + + if (m_camera && tc.farClipDistance > 0.0f && + tc.farClipDistance != m_appliedFarClip) { + m_camera->setFarClipDistance(tc.farClipDistance); + m_appliedFarClip = tc.farClipDistance; + } + + if (m_sceneMgr && (tc.fogEnabled != m_appliedFog || + tc.fogStart != m_appliedFogStart || + tc.fogEnd != m_appliedFogEnd)) { + if (tc.fogEnabled) { + /* The skybox is shader-driven, so no sky colour is + * trivially available; use a light blue-grey that + * reads as distant haze. */ + m_sceneMgr->setFog(Ogre::FOG_LINEAR, + Ogre::ColourValue(0.62f, 0.72f, + 0.82f), + 0.0f, tc.fogStart, tc.fogEnd); + } else { + m_sceneMgr->setFog(m_savedFogMode, m_savedFogColour, + m_savedFogDensity, m_savedFogStart, + m_savedFogEnd); + } + m_appliedFog = tc.fogEnabled; + m_appliedFogStart = tc.fogStart; + m_appliedFogEnd = tc.fogEnd; + } +} + +void TerrainSystem::restoreViewSettings() +{ + if (!m_savedViewValid) + return; + + if (m_camera && m_appliedFarClip > 0.0f) + m_camera->setFarClipDistance(m_savedFarClip); + if (m_sceneMgr && m_appliedFog) + m_sceneMgr->setFog(m_savedFogMode, m_savedFogColour, + m_savedFogDensity, m_savedFogStart, + m_savedFogEnd); + + m_savedViewValid = false; + m_appliedFarClip = -1.0f; + m_appliedFog = false; + m_appliedFogStart = -1.0f; + m_appliedFogEnd = -1.0f; +} + /* ------------------------------------------------------------------ */ /* update */ /* ------------------------------------------------------------------ */ @@ -2238,10 +3273,28 @@ void TerrainSystem::update(float /*deltaTime*/) * rebuilding dirty pages so the new noise values take effect. */ if (m_active && foundEntity != 0) { flecs::entity e = m_world.entity(foundEntity); - if (e.is_valid() && e.has()) - m_detailNoise = e.get().detailNoise; + if (e.is_valid() && e.has()) { + const TerrainComponent &tc = e.get(); + m_detailNoise = tc.detailNoise; + m_streamingEnabled = tc.streamingEnabled; + m_baseNoise = tc.baseNoise; + if (m_streamingActive) { + /* Re-sync the window radii every frame so editor + * tweaks apply live. */ + m_pageLoadRadius = std::max(0, tc.pageLoadRadius); + m_pageHoldRadius = + std::max(m_pageLoadRadius, + tc.pageHoldRadius); + } + applyViewSettings(tc); + } } + /* Stream terrain pages in/out around the camera before collider + * polling so newly loaded pages get their colliders this frame. */ + if (m_streamingActive) + updateStreamingWindow(); + rebuildDirtyPages(); processDeferredReloads(); @@ -2283,13 +3336,31 @@ float TerrainSystem::getHeightAt(const Ogre::Vector3 &worldPos) const /* In sculpt mode the Ogre terrain instances are unloaded; * sample directly from the in-memory heightmap. Convert - * visual world position to physical heightmap coords. */ + * render-space input to world, then to physical heightmap + * coords. */ if (m_sculptPreviewsActive && m_heightmapLoaded) - return sampleHeightAt((long)visualToPhysicalX(worldPos.x), - (long)visualToPhysicalZ(worldPos.z)); + return sampleHeightAt( + (long)visualToPhysicalX(renderToWorldX(worldPos.x)), + (long)visualToPhysicalZ(renderToWorldZ(worldPos.z))); - if (mTerrainGroup) - return mTerrainGroup->getHeightAtWorldPosition(worldPos); + if (mTerrainGroup) { + /* If the page covering worldPos is loaded, use the + * authoritative render geometry (render space — geometry + * is always near the render origin post-rebase). */ + long pageX = 0, pageY = 0; + mTerrainGroup->convertWorldPositionToTerrainSlot( + worldPos, &pageX, &pageY); + Ogre::Terrain *terrain = + mTerrainGroup->getTerrain(pageX, pageY); + if (terrain && terrain->isLoaded()) + return mTerrainGroup->getHeightAtWorldPosition(worldPos); + + /* Far fallback: the page is not loaded (streaming world); + * sample analytically at the physical heightmap coords. */ + return sampleHeightAt( + (long)visualToPhysicalX(renderToWorldX(worldPos.x)), + (long)visualToPhysicalZ(renderToWorldZ(worldPos.z))); + } return 0.0f; } @@ -2307,6 +3378,8 @@ bool TerrainSystem::raycastTerrain(const Ogre::Ray &ray, float &outT, * plane at that height, and repeat. Converges in 2–4 * iterations even on steep slopes. --- */ if (m_sculptPreviewsActive && m_heightmapLoaded) { + /* Ray is render-space; convert sample positions to world + * (render origin offset) before hitting the heightmap. */ const Ogre::Vector3 &origin = ray.getOrigin(); const Ogre::Vector3 &dir = ray.getDirection(); @@ -2323,7 +3396,9 @@ bool TerrainSystem::raycastTerrain(const Ogre::Ray &ray, float &outT, /* Safety: don't march past 100 km. */ const float maxDist = 100000.0f; for (int iter = 0; iter < 16; ++iter) { - float h = sampleHeightAt((long)pt.x, (long)pt.z); + float h = sampleHeightAt( + (long)(renderToWorldX(pt.x)), + (long)(renderToWorldZ(pt.z))); float tNew = (h - origin.y) / dir.y; if (tNew < 0.0f || tNew > maxDist) return false; @@ -2335,13 +3410,18 @@ bool TerrainSystem::raycastTerrain(const Ogre::Ray &ray, float &outT, outT = tNew; /* Normal from heightmap gradient. */ float eps = 1.0f; - float dx = sampleHeightAt((long)(ptNew.x + eps), - (long)ptNew.z) - + float dx = sampleHeightAt( + (long)renderToWorldX( + ptNew.x + eps), + (long)renderToWorldZ( + ptNew.z)) - + h; + float dz = sampleHeightAt( + (long)renderToWorldX( + ptNew.x), + (long)renderToWorldZ( + ptNew.z + eps)) - h; - float dz = - sampleHeightAt((long)ptNew.x, - (long)(ptNew.z + eps)) - - h; outNormal = Ogre::Vector3(-dx, eps * 2.0f, -dz); outNormal.normalise(); return true; @@ -2355,10 +3435,14 @@ bool TerrainSystem::raycastTerrain(const Ogre::Ray &ray, float &outT, return true; } - /* --- Primary: Jolt physics raycast against colliders --- */ + /* --- Primary: Jolt physics raycast against colliders --- + * Terrain colliders live in absolute world space (their positions + * are computed from page indices in double precision), so the + * render-space ray origin is converted to world space. */ if (m_physics && !mColliders.empty()) { - const JPH::RVec3 origin(ray.getOrigin().x, ray.getOrigin().y, - ray.getOrigin().z); + const JPH::RVec3 origin(renderToWorldX(ray.getOrigin().x), + renderToWorldY(ray.getOrigin().y), + renderToWorldZ(ray.getOrigin().z)); JPH::Vec3 dir(ray.getDirection().x, ray.getDirection().y, ray.getDirection().z); dir = dir.Normalized(); @@ -2384,6 +3468,65 @@ bool TerrainSystem::raycastTerrain(const Ogre::Ray &ray, float &outT, } } + /* --- Streaming fallback: no Jolt collider exists for far/unloaded + * pages. March the ray against the analytic height function (same + * iteration as the sculpt-mode path, with render->world and + * visual->physical conversion for the height samples). --- */ + if (m_streamingEnabled) { + const Ogre::Vector3 &origin = ray.getOrigin(); + const Ogre::Vector3 &dir = ray.getDirection(); + + if (fabsf(dir.y) < 1e-6f) + return false; + + float t = 0.01f; + Ogre::Vector3 pt = origin + dir * t; + + /* Don't march past 100 km. */ + const float maxDist = 100000.0f; + for (int iter = 0; iter < 16; ++iter) { + float h = sampleHeightAt( + (long)visualToPhysicalX(renderToWorldX(pt.x)), + (long)visualToPhysicalZ(renderToWorldZ(pt.z))); + float tNew = (h - origin.y) / dir.y; + if (tNew < 0.0f || tNew > maxDist) + return false; + Ogre::Vector3 ptNew = origin + dir * tNew; + + if (fabsf(ptNew.x - pt.x) < 0.2f && + fabsf(ptNew.z - pt.z) < 0.2f) { + outT = tNew; + float eps = 1.0f; + float dx = sampleHeightAt( + (long)visualToPhysicalX( + renderToWorldX( + ptNew.x + + eps)), + (long)visualToPhysicalZ( + renderToWorldZ( + ptNew.z))) - + h; + float dz = sampleHeightAt( + (long)visualToPhysicalX( + renderToWorldX( + ptNew.x)), + (long)visualToPhysicalZ( + renderToWorldZ( + ptNew.z + + eps))) - + h; + outNormal = Ogre::Vector3(-dx, eps * 2.0f, -dz); + outNormal.normalise(); + return true; + } + pt = ptNew; + t = tNew; + } + outT = t; + outNormal = Ogre::Vector3::UNIT_Y; + return true; + } + /* --- Fallback: Y=0 plane intersection for when Jolt * colliders miss (camera far above/below or shallow * angle). Not used in sculpt mode which has its own @@ -2478,23 +3621,34 @@ void TerrainSystem::setSculptTool(SculptTool t) void TerrainSystem::applySculptBrush(const Ogre::Vector3 &worldPos) { + applySculptBrushPhysical(worldPos.x, worldPos.z); +} + +void TerrainSystem::applySculptBrushPhysical(double physX, double physZ) +{ + /* Streaming mode: there is no heightmap buffer; write through the + * fixup layer instead. */ + if (m_streamingActive) { + applySculptBrushStreaming(physX, physZ); + return; + } + if (!m_heightmapLoaded || !m_active || !mTerrainGroup) return; Ogre::LogManager::getSingleton().logMessage( "Terrain: applySculptBrush at (" + - Ogre::StringConverter::toString(worldPos.x) + ", " + - Ogre::StringConverter::toString(worldPos.y) + ", " + - Ogre::StringConverter::toString(worldPos.z) + + Ogre::StringConverter::toString(physX) + ", " + + Ogre::StringConverter::toString(physZ) + ") radius=" + Ogre::StringConverter::toString(m_sculptRadius)); std::lock_guard lock(m_heightmapMutex); float spacing = m_heightmapWorldSize / (float)(m_heightmapRes - 1); int rSamples = (int)(m_sculptRadius / spacing) + 1; - int cx = (int)((worldPos.x - m_heightmapWorldMinX) / + int cx = (int)((physX - m_heightmapWorldMinX) / m_heightmapWorldSize * (float)m_heightmapRes); - int cz = (int)((worldPos.z - m_heightmapWorldMinZ) / + int cz = (int)((physZ - m_heightmapWorldMinZ) / m_heightmapWorldSize * (float)m_heightmapRes); int x0 = std::max(0, cx - rSamples); int x1 = std::min(m_heightmapRes - 1, cx + rSamples); @@ -2556,13 +3710,15 @@ void TerrainSystem::applySculptBrush(const Ogre::Vector3 &worldPos) } float ws = mTerrainGroup->getTerrainWorldSize(); - long px0 = worldToPage(worldPos.x - m_sculptRadius, ws); - long px1 = worldToPage(worldPos.x + m_sculptRadius, ws); - long pz0 = worldToPage(worldPos.z - m_sculptRadius, ws); - long pz1 = worldToPage(worldPos.z + m_sculptRadius, ws); + long px0 = worldToPage(physX - m_sculptRadius, ws); + long px1 = worldToPage(physX + m_sculptRadius, ws); + long pz0 = worldToPage(physZ - m_sculptRadius, ws); + long pz1 = worldToPage(physZ + m_sculptRadius, ws); + /* worldToPage() yields physical page indices; ALIGN_X_Z negates Z, + * so the TerrainGroup slot for physical page (px, pz) is (px, -pz). */ for (long pz = pz0; pz <= pz1; ++pz) for (long px = px0; px <= px1; ++px) - markPageDirty(px, pz); + markPageDirty(px, -pz); } /* ------------------------------------------------------------------ */ @@ -2742,6 +3898,10 @@ void TerrainSystem::applySplatBrush(const Ogre::Vector3 &worldPos) * composite map once per frame (M4.5). */ m_compositeDirtyPages.insert( mTerrainGroup->packIndex(px, py)); + /* Remember the paint so a streamed-out page is + * saved before unload (M1c). */ + m_dirtyBlendPages.insert( + mTerrainGroup->packIndex(px, py)); /* Debug: read back the GPU blend value at the centre pixel * to verify the upload actually reached the GPU. */ @@ -2919,11 +4079,14 @@ void TerrainSystem::beginSculptPreviews() for (auto [x, y] : loadedPages) { uint64_t key = mTerrainGroup->packIndex(x, y); - /* SceneNode at page minimum corner (match terrain - * root node position). */ - Ogre::Vector3 worldPos; - mTerrainGroup->convertTerrainSlotToWorldPosition(x, y, - &worldPos); + /* SceneNode at the page centre (matching the terrain + * root node position), computed in double precision + * from the world origin. */ + const double ws = mTerrainGroup->getTerrainWorldSize(); + Ogre::Vector3 worldPos = + worldToRender(m_worldOriginX + (double)x * ws, + m_worldOriginY, + m_worldOriginZ - (double)y * ws); Ogre::SceneNode *node = m_sceneMgr->getRootSceneNode()->createChildSceneNode( @@ -2970,18 +4133,19 @@ void TerrainSystem::buildSculptPreview(long pageX, long pageY) const Ogre::Real worldSize = mTerrainGroup->getTerrainWorldSize(); const Ogre::Real step = worldSize / (Ogre::Real)(size - 1); - /* Sample heights. */ + /* Sample heights (double precision page origin, see + * fillPageHeightData). */ std::vector heights(size * size); { - Ogre::Vector3 worldPos; - mTerrainGroup->convertTerrainSlotToWorldPosition(pageX, pageY, - &worldPos); + const double baseX = m_worldOriginX + (double)pageX * + (double)worldSize; + const double baseZ = m_worldOriginZ - (double)pageY * + (double)worldSize; + const double dstep = (double)worldSize / (double)(size - 1); for (int j = 0; j < size; ++j) { for (int i = 0; i < size; ++i) { - long wx = (long)(worldPos.x + - (Ogre::Real)i * step); - long wz = (long)(worldPos.z + - (Ogre::Real)j * step); + long wx = (long)(baseX + (double)i * dstep); + long wz = (long)(baseZ + (double)j * dstep); heights[j * size + i] = sampleHeightAt(wx, wz); } } @@ -3073,6 +4237,20 @@ void TerrainSystem::endSculptPreviews() hideBrushDecal(); + /* Capture the previewed page list before clearing: in streaming + * mode the full page range is far too large to re-define, only + * the pages that were unloaded for previewing may be restored. */ + std::vector > pagesToReload; + if (m_streamingActive) { + for (auto &kv : m_sculptPreviews) + pagesToReload.push_back( + { kv.second.pageX, kv.second.pageY }); + } else { + for (long py = m_pageMinY; py <= m_pageMaxY; ++py) + for (long px = m_pageMinX; px <= m_pageMaxX; ++px) + pagesToReload.push_back({ px, py }); + } + /* 1. Destroy all ManualObjects and SceneNodes. */ for (auto &kv : m_sculptPreviews) { auto &sp = kv.second; @@ -3088,18 +4266,20 @@ void TerrainSystem::endSculptPreviews() /* 2. Reload terrain pages with updated heightmap data. */ const Ogre::uint16 terrainSize = mTerrainGroup->getTerrainSize(); - for (long py = m_pageMinY; py <= m_pageMaxY; ++py) { - for (long px = m_pageMinX; px <= m_pageMaxX; ++px) { - float *heightMap = - OGRE_ALLOC_T(float, terrainSize *terrainSize, - Ogre::MEMCATEGORY_GEOMETRY); - fillPageHeightData(mTerrainGroup, px, py, heightMap); - mTerrainGroup->defineTerrain(px, py, heightMap); - OGRE_FREE(heightMap, Ogre::MEMCATEGORY_GEOMETRY); - } + for (auto &page : pagesToReload) { + float *heightMap = + OGRE_ALLOC_T(float, terrainSize * terrainSize, + Ogre::MEMCATEGORY_GEOMETRY); + fillPageHeightData(mTerrainGroup, page.first, page.second, + heightMap); + mTerrainGroup->defineTerrain(page.first, page.second, heightMap); + OGRE_FREE(heightMap, Ogre::MEMCATEGORY_GEOMETRY); } mTerrainGroup->loadAllTerrains(true); + /* Re-set exact render-space page positions (see activate()). */ + repositionLoadedPages(); + /* 3. Re-enable paging. */ if (mPageManager) mPageManager->setPagingOperationsEnabled(true); @@ -3234,60 +4414,149 @@ float TerrainSystem::getTerrainWorldHalfSize() const /* destroyCollider */ /* ------------------------------------------------------------------ */ +/* ------------------------------------------------------------------ */ +/* Coordinate space helpers (M2) */ +/* ------------------------------------------------------------------ */ + +double TerrainSystem::renderToWorldX(float renderX) const +{ + return m_renderOriginX + (double)renderX; +} + +double TerrainSystem::renderToWorldY(float renderY) const +{ + return m_renderOriginY + (double)renderY; +} + +double TerrainSystem::renderToWorldZ(float renderZ) const +{ + return m_renderOriginZ + (double)renderZ; +} + +Ogre::Vector3 TerrainSystem::worldToRender(double worldX, double worldY, + double worldZ) const +{ + return Ogre::Vector3((float)(worldX - m_renderOriginX), + (float)(worldY - m_renderOriginY), + (float)(worldZ - m_renderOriginZ)); +} + +void TerrainSystem::onRenderOriginChanged(const Ogre::Vector3 &delta) +{ + m_renderOriginX += delta.x; + m_renderOriginY += delta.y; + m_renderOriginZ += delta.z; + + m_groupOrigin = worldToRender(m_worldOriginX, m_worldOriginY, + m_worldOriginZ); + + if (mTerrainGroup) { + mTerrainGroup->setOrigin(m_groupOrigin); + repositionLoadedPages(); + } + + /* Recompute terrain-owned scene node positions exactly from the + * double world coordinates (a plain -delta shift would accumulate + * float error over many rebases). */ + if (mTerrainGroup) { + const double ws = mTerrainGroup->getTerrainWorldSize(); + for (auto &kv : m_sculptPreviews) { + if (!kv.second.sceneNode) + continue; + kv.second.sceneNode->setPosition(worldToRender( + m_worldOriginX + (double)kv.second.pageX * ws, + m_worldOriginY, + m_worldOriginZ - + (double)kv.second.pageY * ws)); + } + } + if (m_auxVisNode) + m_auxVisNode->setPosition(m_auxVisNode->getPosition() - delta); + if (m_brushDecalNode) + m_brushDecalNode->setPosition( + m_brushDecalNode->getPosition() - delta); + + /* Road graph nodes are render-space floats (M5); the road system + * shifts them and rebuilds its derived buckets. */ + if (m_roadSystem) + m_roadSystem->onRenderOriginChanged(delta); +} + +void TerrainSystem::repositionLoadedPages() +{ + if (!mTerrainGroup) + return; + + const double ws = mTerrainGroup->getTerrainWorldSize(); + for (const auto &kv : mTerrainGroup->getTerrainSlots()) { + Ogre::TerrainGroup::TerrainSlot *slot = kv.second; + if (!slot || !slot->instance) + continue; + slot->instance->setPosition( + worldToRender(m_worldOriginX + (double)slot->x * ws, + m_worldOriginY, + m_worldOriginZ - + (double)slot->y * ws)); + } +} + /* ------------------------------------------------------------------ */ /* physicalToVisualX */ /* ------------------------------------------------------------------ */ -float TerrainSystem::physicalToVisualX(float physicalX) const +double TerrainSystem::physicalToVisualX(double physicalX) const { if (!mTerrainGroup) return physicalX; /* For ALIGN_X_Z: Ogre terrain local X = i*step - halfSize. - * The heightmap physical X = pageMinX + i*step. - * Visual world X = pageMinX + i*step - halfSize = physicalX - halfSize. - * Same shift for all pages. */ - return physicalX - mTerrainGroup->getTerrainWorldSize() * 0.5f; + * The heightmap physical X = pageMinX + i*step (relative to the + * world origin). + * Visual world X = worldOriginX + physicalX - halfSize. */ + return m_worldOriginX + physicalX - + (double)mTerrainGroup->getTerrainWorldSize() * 0.5; } /* ------------------------------------------------------------------ */ /* physicalToVisualZ */ /* ------------------------------------------------------------------ */ -float TerrainSystem::physicalToVisualZ(float physicalZ) const +double TerrainSystem::physicalToVisualZ(double physicalZ) const { if (!mTerrainGroup) return physicalZ; - Ogre::Real ws = mTerrainGroup->getTerrainWorldSize(); - float halfSize = ws * 0.5f; - float pageZ = floorf(physicalZ / ws) * ws; - return 2.0f * pageZ + halfSize - physicalZ; + double ws = mTerrainGroup->getTerrainWorldSize(); + double halfSize = ws * 0.5; + double pageZ = floor(physicalZ / ws) * ws; + return m_worldOriginZ + 2.0 * pageZ + halfSize - physicalZ; } /* ------------------------------------------------------------------ */ /* visualToPhysicalX */ /* ------------------------------------------------------------------ */ -float TerrainSystem::visualToPhysicalX(float visualX) const +double TerrainSystem::visualToPhysicalX(double visualX) const { if (!mTerrainGroup) return visualX; - return visualX + mTerrainGroup->getTerrainWorldSize() * 0.5f; + return (visualX - m_worldOriginX) + + (double)mTerrainGroup->getTerrainWorldSize() * 0.5; } /* ------------------------------------------------------------------ */ /* visualToPhysicalZ */ /* ------------------------------------------------------------------ */ -float TerrainSystem::visualToPhysicalZ(float visualZ) const +double TerrainSystem::visualToPhysicalZ(double visualZ) const { if (!mTerrainGroup) return visualZ; - Ogre::Real ws = mTerrainGroup->getTerrainWorldSize(); - float halfSize = ws * 0.5f; + double ws = mTerrainGroup->getTerrainWorldSize(); + double halfSize = ws * 0.5; + double rel = visualZ - m_worldOriginZ; /* Visual page: page 0 covers [-halfSize, halfSize). */ - float pageV = floorf((visualZ + halfSize) / ws) * ws; - /* Physical Z such that the ManualObject displays it at visual Z. */ - return 2.0f * pageV + halfSize - visualZ; + double pageV = floor((rel + halfSize) / ws) * ws; + /* Physical Z such that the terrain displays it at visual Z. */ + return 2.0 * pageV + halfSize - rel; } void TerrainSystem::destroyCollider(uint64_t /*key*/, @@ -3305,50 +4574,54 @@ void TerrainCommandQueue::executeCommandInternal(const TerrainCommand &cmd) if (!m_terrainSystem || !m_terrainSystem->isActive()) return; - /* Command worldPos is in visual world space. - * Brush methods expect physical heightmap coords. */ - Ogre::Vector3 physPos = cmd.worldPos; - physPos.x = m_terrainSystem->visualToPhysicalX(cmd.worldPos.x); - physPos.z = m_terrainSystem->visualToPhysicalZ(cmd.worldPos.z); + /* Command worldPos is in render space. Brush methods expect + * physical heightmap coords — convert render -> world -> physical + * in double precision (world coords reach 4e7 in the streaming + * world). */ + TerrainSystem *ts = m_terrainSystem; + const double physX = ts->visualToPhysicalX( + ts->renderToWorldX(cmd.worldPos.x)); + const double physZ = ts->visualToPhysicalZ( + ts->renderToWorldZ(cmd.worldPos.z)); switch (cmd.type) { case TerrainCommandType::Raise: - m_terrainSystem->setSculptTool( + ts->setSculptTool( TerrainSystem::SculptTool::Raise); - m_terrainSystem->setSculptRadius(cmd.radius); - m_terrainSystem->setSculptStrength(cmd.strength); - m_terrainSystem->applySculptBrush(physPos); + ts->setSculptRadius(cmd.radius); + ts->setSculptStrength(cmd.strength); + ts->applySculptBrushPhysical(physX, physZ); break; case TerrainCommandType::Lower: - m_terrainSystem->setSculptTool( + ts->setSculptTool( TerrainSystem::SculptTool::Lower); - m_terrainSystem->setSculptRadius(cmd.radius); - m_terrainSystem->setSculptStrength(cmd.strength); - m_terrainSystem->applySculptBrush(physPos); + ts->setSculptRadius(cmd.radius); + ts->setSculptStrength(cmd.strength); + ts->applySculptBrushPhysical(physX, physZ); break; case TerrainCommandType::Smooth: - m_terrainSystem->setSculptTool( + ts->setSculptTool( TerrainSystem::SculptTool::Smooth); - m_terrainSystem->setSculptRadius(cmd.radius); - m_terrainSystem->setSculptStrength(cmd.strength); - m_terrainSystem->applySculptBrush(physPos); + ts->setSculptRadius(cmd.radius); + ts->setSculptStrength(cmd.strength); + ts->applySculptBrushPhysical(physX, physZ); break; case TerrainCommandType::Flatten: - m_terrainSystem->setSculptTool( + ts->setSculptTool( TerrainSystem::SculptTool::Flatten); - m_terrainSystem->setSculptRadius(cmd.radius); - m_terrainSystem->setSculptStrength(cmd.strength); - m_terrainSystem->applySculptBrush(physPos); + ts->setSculptRadius(cmd.radius); + ts->setSculptStrength(cmd.strength); + ts->applySculptBrushPhysical(physX, physZ); break; case TerrainCommandType::SplatPaint: - m_terrainSystem->setPaintRadius(cmd.radius); + ts->setPaintRadius(cmd.radius); /* Use command strength as the brush delta; negative values * erase, positive values paint. */ - m_terrainSystem->setPaintStrength(cmd.strength); - m_terrainSystem->setPaintLayerIndex(cmd.layerIndex); - /* SplatPaint uses visual world coords directly - * (blend-map texel mapping is visual-space). */ - m_terrainSystem->applySplatBrush(cmd.worldPos); + ts->setPaintStrength(cmd.strength); + ts->setPaintLayerIndex(cmd.layerIndex); + /* SplatPaint uses render-space visual coords directly + * (blend-map texel mapping is page-local). */ + ts->applySplatBrush(cmd.worldPos); break; } } diff --git a/src/features/editScene/systems/TerrainSystem.hpp b/src/features/editScene/systems/TerrainSystem.hpp index aba44b5..8f82790 100644 --- a/src/features/editScene/systems/TerrainSystem.hpp +++ b/src/features/editScene/systems/TerrainSystem.hpp @@ -75,10 +75,11 @@ public: float sampleHeightAtLocked(long worldX, long worldZ) const; /* Base terrain height (base heightmap + detail noise) at integer - * world coordinates, ignoring the fixup layer. Used by road - * compliance falloff (M5.10) so fade targets blend toward the - * natural terrain height even where fixups were already written - * nearby. */ + * PHYSICAL heightmap coordinates, ignoring the fixup layer. Used + * by road compliance falloff (M5.10) so fade targets blend toward + * the natural terrain height even where fixups were already written + * nearby. Convert visual world positions with visualToPhysicalX/Z + * before calling. */ float sampleBaseHeightAt(long worldX, long worldZ) const; float sampleBaseLocked(long worldX, long worldZ) const; void setHeightAt(long worldX, long worldZ, float value); @@ -91,6 +92,13 @@ public: /* Fixup chunks (M5.9.5): sparse absolute-height overrides layered * on top of base heightmap + detail noise during sampling. * + * COORDINATE SPACE: all fixup/sampling APIs here take PHYSICAL + * heightmap coordinates (the space fillPageHeightData samples in). + * Visual world positions (editor clicks, road nodes, prefab + * bounds) must be converted with visualToPhysicalX/Z first — + * physical X is shifted by half a page, physical Z is mirrored + * within each page. + * * Chunk grid: one chunk spans the full PER-PAGE world size * (TerrainComponent::worldSize) and holds 256x256 samples, so one * sample covers worldSize/256 units — the same density as the base @@ -115,9 +123,19 @@ public: * * clearAllFixups() deletes every fixup file and the in-memory chunks * and marks all loaded pages dirty. */ - void writeFixup(float worldX, float worldZ, float height); - void writeFixupSample(float worldX, float worldZ, float height); + void writeFixup(double worldX, double worldZ, float height); + void writeFixupSample(double worldX, double worldZ, float height); float getFixupTexelSize() const; + + /* Lowers the four fixup sample nodes that bilinearly drive the + * height sampled at (worldX, worldZ) by @p delta, so the sampled + * height anywhere inside that chunk cell drops by exactly @p delta. + * Unwritten (sentinel) nodes are first materialized at the natural + * (base + noise) height the bilinear reader would substitute. + * Used by the road-compliance relaxation pass to sink rendered + * page vertices that poke through the road surface. PHYSICAL + * coords. */ + void lowerFixupCorners(double worldX, double worldZ, float delta); bool saveFixups(); void clearAllFixups(); size_t getFixupChunkCount() const; @@ -144,6 +162,27 @@ public: computeDetailNoise(long worldX, long worldZ, const struct TerrainComponent::DetailNoise &n) const; + /* --- Procedural base noise (world streaming groundwork) --- + * Evaluates TerrainComponent::BaseNoise at any PHYSICAL world + * coordinate via FastNoiseLite (OpenSimplex2, seeded, same + * octave/persistence/lacunarity pattern as computeDetailNoise). + * Used as the base height source when streamingEnabled is true. */ + float + computeBaseNoise(long worldX, long worldZ, + const struct TerrainComponent::BaseNoise &n) const; + + /* Fixup chunk cache cap (LRU). Chunks beyond the cap are evicted + * least-recently-used first; dirty chunks are flushed to disk before + * eviction. Exposed for tests. */ + void setFixupChunkCap(size_t cap) + { + m_fixupChunkCap = cap < 1 ? 1 : cap; + } + size_t getFixupChunkCap() const + { + return m_fixupChunkCap; + } + /* --- Sculpting (M3) --- */ enum class SculptTool { Raise, Lower, Smooth, Flatten }; bool getSculptMode() const @@ -177,7 +216,14 @@ public: m_sculptStrength = s; } void snapCameraAboveTerrain(); + /* Despite the parameter name, the Vector3 overload forwards x/z + * straight to the Physical variant: PHYSICAL heightmap coords + * (legacy convention — prefer applySculptBrushPhysical). */ void applySculptBrush(const Ogre::Vector3 &worldPos); + /* Double-precision physical-coords variant for the streaming + * world; the Vector3 overload loses ~4 units of precision at the + * far end of the 40,000,000-unit world. */ + void applySculptBrushPhysical(double physX, double physZ); /* --- Brush falloff shape (M4.3) --- */ enum class BrushFalloffShape { @@ -200,10 +246,17 @@ public: static float evaluateBrushFalloff(float distance, float radius, BrushFalloffShape shape); - /* --- Aux-map editing (M4.4) --- */ + /* --- Aux-map editing (M4.4) --- + * The Vector3 overload takes PHYSICAL heightmap coords (same legacy + * convention as applySculptBrush); sampleAuxMap takes PHYSICAL + * coords too. */ void applyAuxBrush(const std::string &auxMapName, const Ogre::Vector3 &worldPos, float radius, float delta); + /* Double-precision physical-coords variant (streaming world). */ + void applyAuxBrushPhysical(const std::string &auxMapName, + double physX, double physZ, float radius, + float delta); float sampleAuxMap(const std::string &auxMapName, long worldX, long worldZ) const; bool saveSceneAuxMaps(const struct TerrainComponent &tc); @@ -260,6 +313,9 @@ public: { m_paintStrength = s; } + /* Takes RENDER-space coords (blend-map texel mapping is page-local + * via TerrainGroup::convertWorldPositionToTerrainSlot against the + * render-space group origin). */ void applySplatBrush(const Ogre::Vector3 &worldPos); /* --- Composite-map batching (M4.5) --- */ @@ -363,19 +419,76 @@ public: return m_camera; } + /* --- Streaming window introspection (M1b; tests) --- */ + size_t getColliderCount() const + { + return mColliders.size(); + } + bool getStreamingActive() const + { + return m_streamingActive; + } + long getStreamingPageCount() const + { + return m_streamWorldPages; + } + + /* Page slot containing the streaming camera, clamped to + * [0, m_streamWorldPages-1]. Falls back to the group origin when + * no camera is available. */ + void streamingCameraPage(long *outX, long *outY) const; + /* Physical page (x, y) lives in TerrainGroup slot (x, -y) because + * ALIGN_X_Z negates Z. */ + bool isPageLoaded(long x, long y) const + { + if (!mTerrainGroup) + return false; + Ogre::Terrain *t = mTerrainGroup->getTerrain(x, -y); + return t && t->isLoaded(); + } + /* --- Brush decal (M3) --- */ void updateBrushDecal(const Ogre::Vector3 &worldPos, const Ogre::Vector3 &normal, float radius); void hideBrushDecal(); - /* Convert physical heightmap X to visual display X. + /* --- Coordinate spaces (M2) --- + * + * WORLD space: absolute double-precision world coordinates (up to + * 40,000,000 x 40,000,000 units). Terrain heightmap/physical + * coordinates are world coords relative to the terrain origin, + * i.e. physical = world - worldOrigin (+ the half-page shift and + * Z mirror below). + * + * RENDER space: float scene-graph coordinates around the floating + * render origin (RenderOriginSystem); render = world - + * renderOrigin. Most public position APIs of TerrainSystem + * (getHeightAt, raycastTerrain, brush decal, command queue, + * applySplatBrush) take RENDER space; conversion to world happens + * internally. Exception: the sculpt/aux brush apply APIs take + * PHYSICAL heightmap coords (see their declarations). */ + + /* Convert a render-space position to absolute world space. */ + double renderToWorldX(float renderX) const; + double renderToWorldY(float renderY) const; + double renderToWorldZ(float renderZ) const; + Ogre::Vector3 worldToRender(double worldX, double worldY, + double worldZ) const; + + /* Called by RenderOriginSystem after a rebase: @p delta is the + * world-space delta of the render origin (newOrigin - oldOrigin). + * Repositions the terrain group and all terrain-owned scene + * nodes into the new render space. */ + void onRenderOriginChanged(const Ogre::Vector3 &delta); + + /* Convert physical heightmap X to visual WORLD X. * Terrain pages render column i at X = i*step - halfSize * but the height data lives at X = pageMinX + i*step. * Same for Z but Z-inverted (see physicalToVisualZ). */ - float physicalToVisualX(float physicalX) const; + double physicalToVisualX(double physicalX) const; /* Inverse: physical X corresponding to a given visual world X. */ - float visualToPhysicalX(float visualX) const; + double visualToPhysicalX(double visualX) const; /* Half the terrain world size (for coordinate math). */ float getTerrainWorldHalfSize() const; @@ -385,14 +498,25 @@ public: return mTerrainGroup; } - /* Convert physical heightmap Z to visual display Z. + Ogre::SceneManager *getSceneManager() const + { + return m_sceneMgr; + } + + /* Entity id of the active terrain (0 when none). */ + flecs::entity_t getTerrainEntityId() const + { + return m_terrainEntityId; + } + + /* Convert physical heightmap Z to visual WORLD Z. * Terrain pages render row j at Z = pageMinZ + halfSize - j*step * but the height data lives at Z = pageMinZ + j*step. */ - float physicalToVisualZ(float physicalZ) const; + double physicalToVisualZ(double physicalZ) const; /* Inverse: physical Z that maps to the given visual world Z. * Uses visual page indexing, unlike physicalToVisualZ. */ - float visualToPhysicalZ(float visualZ) const; + double visualToPhysicalZ(double visualZ) const; TerrainCommandQueue &getCommandQueue() { @@ -410,9 +534,29 @@ private: void updatePageGeometry(long pageX, long pageY); void fillPageHeightData(Ogre::TerrainGroup *group, long x, long y, float *heightMap); - long worldToPage(float worldCoord, float worldSize) const; + long worldToPage(double worldCoord, double worldSize) const; void ensurePageColliders(); + /* Streaming page window (M1b). Active only when the terrain was + * activated with TerrainComponent::streamingEnabled = true + * (m_streamingActive). The world is absolute and bounded, indexed + * in PHYSICAL page coords: page (x,z) covers the physical heightmap + * rect [x*worldSize, (x+1)*worldSize) x [z*worldSize, + * (z+1)*worldSize) with x,z in [0, m_streamWorldPages-1]; nothing + * outside that range is ever defined. Because TerrainGroup uses + * ALIGN_X_Z (which negates Z), the Ogre slot for physical page + * (x, z) is (x, -z). The TerrainGroup origin stays at the terrain + * entity's TransformComponent position, so the existing + * visualToPhysicalX/Z conversions apply unchanged. */ + void updateStreamingWindow(); + /* Both take PHYSICAL page coords in [0, m_streamWorldPages-1]. */ + void streamLoadPage(long x, long y); + void applySculptBrushStreaming(double physX, double physZ); + + /* Per-frame amortization for page streaming. */ + static constexpr int STREAM_LOADS_PER_FRAME = 2; + static constexpr int STREAM_UNLOADS_PER_FRAME = 4; + struct TerrainCollider { long pageX, pageY; JPH::BodyID bodyId; @@ -546,14 +690,93 @@ private: * helpers do not touch ECS state while the heightmap mutex is held. */ struct TerrainComponent::DetailNoise m_detailNoise; + /* Streaming state (world streaming groundwork). Local copies of the + * TerrainComponent streaming fields, synced in activate()/update(). + * When m_streamingEnabled is true the base height comes from + * m_baseNoise evaluated on demand instead of the m_heightData + * buffer. */ + bool m_streamingEnabled = false; + struct TerrainComponent::BaseNoise m_baseNoise; + + /* Streaming page window runtime state (M1b). m_streamingActive is + * latched in activate() from tc.streamingEnabled — the update-time + * sync of m_streamingEnabled alone must not start the window on a + * legacy-activated terrain. m_streamWorldPages is the number of + * pages per axis (N), clamped so TerrainGroup::packIndex stays + * valid (slots are signed 16-bit). m_pageLoadRadius / + * m_pageHoldRadius mirror the component and are re-synced every + * frame so editor tweaks apply live. */ + bool m_streamingActive = false; + long m_streamWorldPages = 1; + int m_pageLoadRadius = 2; + int m_pageHoldRadius = 3; + /* Render-space origin of the TerrainGroup (world origin - render + * origin). Refreshed from the double origins below on every + * rebase; after TerrainGroup::setOrigin() the loaded page + * positions are re-set precisely from doubles by + * repositionLoadedPages() because Ogre's slot position math is + * single-precision. */ + Ogre::Vector3 m_groupOrigin = Ogre::Vector3::ZERO; + + /* Absolute double-precision world-space origin of the terrain + * (latched in activate() from the terrain entity's transform) and + * the current render origin (tracked via onRenderOriginChanged). + * All heightmap/physical coordinate math is done relative to + * m_worldOrigin* in double precision. */ + double m_worldOriginX = 0.0; + double m_worldOriginY = 0.0; + double m_worldOriginZ = 0.0; + double m_renderOriginX = 0.0; + double m_renderOriginY = 0.0; + double m_renderOriginZ = 0.0; + void repositionLoadedPages(); + /* Brush falloff shape (M4.3). Runtime editor preference, not serialized. */ BrushFalloffShape m_brushFalloffShape = BrushFalloffShape::Linear; - /* Aux-map state (M4.4). Cached in memory while terrain is active. */ + /* Aux-map state (M4.4). Cached in memory while terrain is active. + * Legacy mode (streamingEnabled=false) only: one whole-terrain map + * per aux map name. */ mutable std::unordered_map > m_auxMapData; std::unordered_set m_dirtyAuxMaps; + /* Streaming aux maps (M1c): per-page chunks of resolution^2 floats + * covering one page each, stored under + * heightmaps//aux//x_z.bin using PHYSICAL + * page coords. Guarded by m_heightmapMutex; lazy-loaded from disk, + * LRU-evicted with save-if-dirty (same policy as fixup chunks). */ + struct AuxChunk { + std::vector data; + bool dirty = false; + mutable uint64_t lastAccess = 0; + }; + mutable std::map, AuxChunk> + m_auxChunks; + std::string m_auxChunkDir; + size_t m_auxChunkCap = 64; + + std::string auxChunkPath(const std::string &auxName, long px, + long pz) const; + const AuxChunk * + findAuxChunkLocked(const struct TerrainComponent::AuxMap &aux, long px, + long pz) const; + AuxChunk * + getAuxChunkForWriteLocked(const struct TerrainComponent::AuxMap &aux, + long px, long pz); + void evictAuxChunksLocked() const; + void saveAuxChunkLocked(const std::pair &key, + AuxChunk &chunk) const; + + /* Streaming blend maps (M1c): slot keys (packIndex) painted since + * the last save. Pages are saved on unload and on saveBlendMaps. */ + mutable std::set m_dirtyBlendPages; + std::string m_blendMapDir; + void savePageBlendMaps(const std::string &dirPath, long slotX, + long slotY) const; + void loadPageBlendMaps(const std::string &dirPath, long slotX, + long slotY); + /* Fixup chunks (M5.9.5): sparse absolute-height overrides stored as * 256x256 float chunks under heightmaps//terrain_fixup/. * Guarded by m_heightmapMutex; loaded lazily from disk on first @@ -561,6 +784,9 @@ private: struct FixupChunk { std::vector samples; bool dirty = false; + /* LRU stamp; bumped on every access while the heightmap + * mutex is held. */ + mutable uint64_t lastAccess = 0; }; static constexpr int FIXUP_CHUNK_RES = 256; static constexpr float FIXUP_SENTINEL = -FLT_MAX; @@ -575,12 +801,42 @@ private: mutable std::map, FixupChunk> m_fixupChunks; std::string m_fixupDir; - float sampleFixupLocked(float worldX, float worldZ) const; + /* Fixup chunk LRU eviction. m_fixupAccessCounter is a monotone + * stamp assigned on every chunk access; evictFixupChunksLocked() + * drops the lowest-stamp chunks (flushing dirty ones to disk) once + * the cache exceeds m_fixupChunkCap. */ + mutable uint64_t m_fixupAccessCounter = 0; + size_t m_fixupChunkCap = 64; + + float sampleFixupLocked(double worldX, double worldZ) const; const FixupChunk *findFixupChunkLocked(int chunkX, int chunkZ) const; + void touchFixupChunkLocked(const FixupChunk &chunk) const; + void evictFixupChunksLocked() const; + void saveFixupChunkLocked(int chunkX, int chunkZ, + FixupChunk &chunk) const; bool loadFixupChunk(int chunkX, int chunkZ, std::vector &out) const; std::string fixupChunkPath(int chunkX, int chunkZ) const; + /* View settings driven by the active terrain (far clip + fog). + * Applied in activate() and re-checked in update() when the + * component values change; the previous camera/scene state is + * restored on deactivate(). */ + bool m_savedViewValid = false; + float m_savedFarClip = 0.0f; + Ogre::FogMode m_savedFogMode = Ogre::FOG_NONE; + Ogre::ColourValue m_savedFogColour = Ogre::ColourValue::Black; + float m_savedFogDensity = 0.0f; + float m_savedFogStart = 0.0f; + float m_savedFogEnd = 0.0f; + float m_appliedFarClip = -1.0f; + bool m_appliedFog = false; + float m_appliedFogStart = -1.0f; + float m_appliedFogEnd = -1.0f; + + void applyViewSettings(const struct TerrainComponent &tc); + void restoreViewSettings(); + const std::vector * ensureAuxMapLoaded(const struct TerrainComponent::AuxMap &aux, const std::string &path) const; diff --git a/src/features/editScene/systems/TerrainTests.cpp b/src/features/editScene/systems/TerrainTests.cpp index fce3e8a..9c1dc8b 100644 --- a/src/features/editScene/systems/TerrainTests.cpp +++ b/src/features/editScene/systems/TerrainTests.cpp @@ -1,5 +1,6 @@ #include "TerrainTests.hpp" #include "TerrainSystem.hpp" +#include "RenderOriginSystem.hpp" #include "TerrainCommands.hpp" #include "RoadSystem.hpp" #include "ProceduralMeshSystem.hpp" @@ -17,6 +18,10 @@ #include "../components/Lod.hpp" #include "../components/PhysicsCollider.hpp" #include "../systems/SceneSerializer.hpp" +#include "../systems/SpawnerRegionStore.hpp" +#include "../systems/RoadRegionStore.hpp" +#include "../systems/WorldMapData.hpp" +#include "../ui/NavigationPanel.hpp" #include "../physics/physics.h" #include "../roadlib/RoadGeometryLib.hpp" @@ -29,9 +34,12 @@ #include #include #include +#include +#include #include #include #include +#include #include #include @@ -83,10 +91,14 @@ static void pumpFrames(EditorApp &app, TerrainSystem *ts, int count) evt.timeSinceLastFrame = 0.016f; for (int i = 0; i < count; ++i) { - /* Run terrain system update to process activation, - * colliders, and dirty page rebuilds. + /* Rebase the render origin first (same order as + * EditorApp::frameRenderingQueued), then run the terrain + * system update to process activation, colliders, and + * dirty page rebuilds. * Called from within the render loop (frame listener), * so GPU context / render targets are valid. */ + if (RenderOriginSystem *ro = app.getRenderOriginSystem()) + ro->update(); if (ts) ts->update(evt.timeSinceLastFrame); } @@ -3225,6 +3237,2470 @@ bool TerrainTestRunner::testMemoryStability(EditorApp &app) return true; } +/* ------------------------------------------------------------------ */ +/* Streaming groundwork tests */ +/* ------------------------------------------------------------------ */ + +/* Destroy any terrain entities left over from previous tests so the + * next entity is the only one the system can activate. */ +static void destroyLeftoverTerrainEntities(EditorApp &app) +{ + std::vector oldEntities; + app.getWorld() + ->query() + .each([&](flecs::entity oldE, TerrainComponent &, + TransformComponent &) { + oldEntities.push_back(oldE); + }); + for (flecs::entity oldE : oldEntities) + destroyTerrainEntity(app, oldE); +} + +bool TerrainTestRunner::testStreamingProceduralBase(EditorApp &app, + TerrainSystem *ts) +{ + if (ts->isActive()) + ts->deactivate(); + destroyLeftoverTerrainEntities(app); + + flecs::entity e = createTerrainEntity(app); + { + auto &tc = e.get_mut(); + tc.streamingEnabled = true; + tc.baseNoise.seed = 98765; + tc.baseNoise.octaves = 4; + tc.baseNoise.frequency = 0.0005f; + tc.baseNoise.amplitude = 40.0f; + tc.baseNoise.lacunarity = 2.0f; + tc.baseNoise.persistence = 0.5f; + } + pumpFrames(app, ts, 5); + + if (!ts->isActive()) { + Ogre::LogManager::getSingleton().logMessage( + "TerrainTests: FAIL - streaming terrain did not activate"); + destroyTerrainEntity(app, e); + pumpFrames(app, ts, 1); + return false; + } + + /* The camera sits at the origin, so the initial streaming window + * includes page (0,0). */ + if (!ts->getTerrainGroup() || + !ts->getTerrainGroup()->getTerrain(0, 0) || + !ts->getTerrainGroup()->getTerrain(0, 0)->isLoaded()) { + Ogre::LogManager::getSingleton().logMessage( + "TerrainTests: FAIL - page (0,0) not loaded in streaming mode"); + destroyTerrainEntity(app, e); + pumpFrames(app, ts, 1); + return false; + } + + static const long coords[][2] = { + { 1234567, -7654321 }, + { 100, 100 }, + { -1500, 2500 }, + { 3000000, 12345 }, + { -42, 987654 }, + }; + const int numCoords = (int)(sizeof(coords) / sizeof(coords[0])); + + float streamingHeights[numCoords]; + for (int i = 0; i < numCoords; ++i) { + float h1 = ts->sampleHeightAt(coords[i][0], coords[i][1]); + float h2 = ts->sampleHeightAt(coords[i][0], coords[i][1]); + if (!std::isfinite(h1) || h1 != h2) { + Ogre::LogManager::getSingleton().logMessage( + "TerrainTests: FAIL - streaming base not deterministic"); + destroyTerrainEntity(app, e); + pumpFrames(app, ts, 1); + return false; + } + streamingHeights[i] = h1; + } + + /* Determinism across deactivate/activate. */ + ts->deactivate(); + pumpFrames(app, ts, 5); + if (!ts->isActive()) { + Ogre::LogManager::getSingleton().logMessage( + "TerrainTests: FAIL - streaming terrain did not re-activate"); + destroyTerrainEntity(app, e); + pumpFrames(app, ts, 1); + return false; + } + for (int i = 0; i < numCoords; ++i) { + float h = ts->sampleHeightAt(coords[i][0], coords[i][1]); + if (h != streamingHeights[i]) { + Ogre::LogManager::getSingleton().logMessage( + "TerrainTests: FAIL - streaming base changed across re-activation"); + destroyTerrainEntity(app, e); + pumpFrames(app, ts, 1); + return false; + } + } + + /* Legacy mode (proceduralHeight fallback) must produce a different + * base shape at the same coordinates. */ + destroyTerrainEntity(app, e); + pumpFrames(app, ts, 3); + + flecs::entity e2 = createTerrainEntity(app); /* streaming off */ + pumpFrames(app, ts, 5); + if (!ts->isActive()) { + Ogre::LogManager::getSingleton().logMessage( + "TerrainTests: FAIL - legacy terrain did not activate"); + destroyTerrainEntity(app, e2); + pumpFrames(app, ts, 1); + return false; + } + + int different = 0; + for (int i = 0; i < numCoords; ++i) { + float hl = ts->sampleHeightAt(coords[i][0], coords[i][1]); + if (std::abs(hl - streamingHeights[i]) > 0.5f) + ++different; + } + if (different < 3) { + Ogre::LogManager::getSingleton().logMessage( + "TerrainTests: FAIL - streaming base too similar to legacy " + "proceduralHeight"); + destroyTerrainEntity(app, e2); + pumpFrames(app, ts, 1); + return false; + } + + destroyTerrainEntity(app, e2); + pumpFrames(app, ts, 3); + + Ogre::LogManager::getSingleton().logMessage( + "TerrainTests: streaming procedural base test passed"); + return true; +} + +bool TerrainTestRunner::testFixupChunkLRU(EditorApp &app, TerrainSystem *ts) +{ + if (ts->isActive()) + ts->deactivate(); + destroyLeftoverTerrainEntities(app); + + flecs::entity e = createTerrainEntity(app); + pumpFrames(app, ts, 5); + + if (!ts->isActive()) { + Ogre::LogManager::getSingleton().logMessage( + "TerrainTests: FAIL - terrain not active for LRU test"); + destroyTerrainEntity(app, e); + pumpFrames(app, ts, 1); + return false; + } + + ts->clearAllFixups(); + ts->setFixupChunkCap(2); + + /* Three positions in three distinct fixup chunks (chunk span == + * page world size, 2000 units for the test terrain). */ + const float posX[3] = { 100.0f, 2100.0f, 4100.0f }; + const float posZ = 100.0f; + float target[3]; + for (int i = 0; i < 3; ++i) { + target[i] = + ts->sampleHeightAt((long)posX[i], (long)posZ) + 50.0f; + ts->writeFixup(posX[i], posZ, target[i]); + } + + /* The third write must have evicted the first chunk. */ + if (ts->getFixupChunkCount() != 2) { + Ogre::LogManager::getSingleton().logMessage( + "TerrainTests: FAIL - expected 2 cached fixup chunks, got " + + Ogre::StringConverter::toString( + (int)ts->getFixupChunkCount())); + ts->setFixupChunkCap(64); + ts->clearAllFixups(); + destroyTerrainEntity(app, e); + pumpFrames(app, ts, 1); + return false; + } + + /* The evicted chunk was dirty, so it must have been flushed to + * disk before eviction. */ + std::string evictedPath = + ts->getFixupDir(e.get()) + "/x0_z0.bin"; + if (!std::filesystem::exists(evictedPath)) { + Ogre::LogManager::getSingleton().logMessage( + "TerrainTests: FAIL - evicted dirty chunk not flushed to disk"); + ts->setFixupChunkCap(64); + ts->clearAllFixups(); + destroyTerrainEntity(app, e); + pumpFrames(app, ts, 1); + return false; + } + + /* Sampling the evicted chunk lazily reloads it from disk. */ + float h = ts->sampleHeightAt((long)posX[0], (long)posZ); + if (std::abs(h - target[0]) > 0.1f) { + Ogre::LogManager::getSingleton().logMessage( + "TerrainTests: FAIL - evicted fixup chunk reload returned " + + Ogre::StringConverter::toString(h) + ", expected " + + Ogre::StringConverter::toString(target[0])); + ts->setFixupChunkCap(64); + ts->clearAllFixups(); + destroyTerrainEntity(app, e); + pumpFrames(app, ts, 1); + return false; + } + + ts->setFixupChunkCap(64); + ts->clearAllFixups(); + if (ts->getFixupChunkCount() != 0) { + Ogre::LogManager::getSingleton().logMessage( + "TerrainTests: FAIL - fixup chunks remain after clear (LRU)"); + destroyTerrainEntity(app, e); + pumpFrames(app, ts, 1); + return false; + } + + destroyTerrainEntity(app, e); + pumpFrames(app, ts, 3); + + Ogre::LogManager::getSingleton().logMessage( + "TerrainTests: fixup chunk LRU test passed"); + return true; +} + +bool TerrainTestRunner::testGetHeightAtFarFallback(EditorApp &app, + TerrainSystem *ts) +{ + if (ts->isActive()) + ts->deactivate(); + destroyLeftoverTerrainEntities(app); + + flecs::entity e = createTerrainEntity(app); + { + auto &tc = e.get_mut(); + tc.streamingEnabled = true; + } + pumpFrames(app, ts, 5); + + if (!ts->isActive()) { + Ogre::LogManager::getSingleton().logMessage( + "TerrainTests: FAIL - streaming terrain not active for far fallback test"); + destroyTerrainEntity(app, e); + pumpFrames(app, ts, 1); + return false; + } + + /* Far from any loaded page: getHeightAt must fall back to the + * analytic sample at the converted physical coordinates. */ + const Ogre::Vector3 farPos(100000.0f, 0.0f, 100000.0f); + float expected = ts->sampleHeightAt( + (long)ts->visualToPhysicalX(farPos.x), + (long)ts->visualToPhysicalZ(farPos.z)); + float actual = ts->getHeightAt(farPos); + if (std::abs(actual - expected) > 1e-3f) { + Ogre::LogManager::getSingleton().logMessage( + "TerrainTests: FAIL - getHeightAt far fallback " + + Ogre::StringConverter::toString(actual) + + " != analytic " + + Ogre::StringConverter::toString(expected)); + destroyTerrainEntity(app, e); + pumpFrames(app, ts, 1); + return false; + } + + /* Loaded pages still answer from render geometry. */ + float near = ts->getHeightAt(Ogre::Vector3(0.0f, 0.0f, 0.0f)); + if (!std::isfinite(near)) { + Ogre::LogManager::getSingleton().logMessage( + "TerrainTests: FAIL - getHeightAt on loaded page not finite"); + destroyTerrainEntity(app, e); + pumpFrames(app, ts, 1); + return false; + } + + destroyTerrainEntity(app, e); + pumpFrames(app, ts, 3); + + Ogre::LogManager::getSingleton().logMessage( + "TerrainTests: getHeightAt far fallback test passed"); + return true; +} + +bool TerrainTestRunner::testStreamingSerialization(EditorApp &app, + TerrainSystem *ts) +{ + (void)ts; + + flecs::world *w = app.getWorld(); + flecs::entity e = w->entity(); + + { + TerrainComponent tc; + tc.enabled = false; /* never activates, no transform */ + tc.terrainId = (uint64_t)std::chrono::system_clock::now() + .time_since_epoch() + .count(); + tc.streamingEnabled = true; + tc.worldSizeUnits = 12345678.0; + tc.pageLoadRadius = 4; + tc.pageHoldRadius = 7; + tc.farClipDistance = 9000.0f; + tc.fogEnabled = false; + tc.fogStart = 111.0f; + tc.fogEnd = 2222.0f; + tc.baseNoise.seed = 4242; + tc.baseNoise.octaves = 6; + tc.baseNoise.frequency = 0.01f; + tc.baseNoise.amplitude = 99.0f; + tc.baseNoise.lacunarity = 2.5f; + tc.baseNoise.persistence = 0.4f; + e.set(tc); + } + + SceneSerializer serializer(*w, app.getSceneManager()); + nlohmann::json terrainJson = serializer.serializeTerrain(e); + + flecs::entity e2 = w->entity(); + serializer.deserializeTerrain(e2, terrainJson); + + bool ok = true; + const TerrainComponent &tc2 = e2.get(); + const TerrainComponent &tc1 = e.get(); + if (tc2.streamingEnabled != tc1.streamingEnabled || + tc2.worldSizeUnits != tc1.worldSizeUnits || + tc2.pageLoadRadius != tc1.pageLoadRadius || + tc2.pageHoldRadius != tc1.pageHoldRadius || + tc2.farClipDistance != tc1.farClipDistance || + tc2.fogEnabled != tc1.fogEnabled || + tc2.fogStart != tc1.fogStart || tc2.fogEnd != tc1.fogEnd || + tc2.baseNoise.seed != tc1.baseNoise.seed || + tc2.baseNoise.octaves != tc1.baseNoise.octaves || + tc2.baseNoise.frequency != tc1.baseNoise.frequency || + tc2.baseNoise.amplitude != tc1.baseNoise.amplitude || + tc2.baseNoise.lacunarity != tc1.baseNoise.lacunarity || + tc2.baseNoise.persistence != tc1.baseNoise.persistence) { + Ogre::LogManager::getSingleton().logMessage( + "TerrainTests: FAIL - streaming fields not preserved by serialization"); + ok = false; + } + + /* Old scenes (missing keys) must load with the defaults. */ + nlohmann::json legacyJson; + legacyJson["enabled"] = true; + legacyJson["terrainId"] = (uint64_t)5; + flecs::entity e3 = w->entity(); + serializer.deserializeTerrain(e3, legacyJson); + + const TerrainComponent &tc3 = e3.get(); + TerrainComponent defaults; + if (tc3.streamingEnabled != defaults.streamingEnabled || + tc3.worldSizeUnits != defaults.worldSizeUnits || + tc3.pageLoadRadius != defaults.pageLoadRadius || + tc3.pageHoldRadius != defaults.pageHoldRadius || + tc3.farClipDistance != defaults.farClipDistance || + tc3.fogEnabled != defaults.fogEnabled || + tc3.fogStart != defaults.fogStart || + tc3.fogEnd != defaults.fogEnd || + tc3.baseNoise.seed != defaults.baseNoise.seed || + tc3.baseNoise.octaves != defaults.baseNoise.octaves || + tc3.baseNoise.frequency != defaults.baseNoise.frequency || + tc3.baseNoise.amplitude != defaults.baseNoise.amplitude || + tc3.baseNoise.lacunarity != defaults.baseNoise.lacunarity || + tc3.baseNoise.persistence != defaults.baseNoise.persistence) { + Ogre::LogManager::getSingleton().logMessage( + "TerrainTests: FAIL - legacy scene did not get streaming defaults"); + ok = false; + } + + e.destruct(); + e2.destruct(); + e3.destruct(); + + if (!ok) + return false; + + Ogre::LogManager::getSingleton().logMessage( + "TerrainTests: streaming serialization test passed"); + return true; +} + +/* ------------------------------------------------------------------ */ +/* Streaming window tests (M1b) */ +/* ------------------------------------------------------------------ */ + +/* Move the streaming camera to a visual world position. Returns false + * when the camera is not attached to a scene node. */ +/* Move the streaming camera to an absolute WORLD position (double + * precision); the node gets the render-space equivalent. The render + * origin rebase (if the position is far out) happens in pumpFrames. */ +static bool moveStreamCamera(TerrainSystem *ts, double x, double y, double z) +{ + Ogre::Camera *cam = ts->getCamera(); + if (!cam || !cam->getParentSceneNode()) + return false; + RenderOriginSystem *ro = RenderOriginSystem::getInstance(); + if (ro) + cam->getParentSceneNode()->setPosition( + ro->worldToRender(x, y, z)); + else + cam->getParentSceneNode()->setPosition((float)x, (float)y, + (float)z); + return true; +} + +bool TerrainTestRunner::testStreamingWindowFollowsCamera(EditorApp &app, + TerrainSystem *ts) +{ + if (ts->isActive()) + ts->deactivate(); + destroyLeftoverTerrainEntities(app); + + flecs::entity e = createTerrainEntity(app); + { + auto &tc = e.get_mut(); + tc.streamingEnabled = true; + /* 6x6 pages of 2000 units: small enough to assert the whole + * window layout. */ + tc.worldSizeUnits = 12000.0; + tc.pageLoadRadius = 1; + tc.pageHoldRadius = 2; + } + pumpFrames(app, ts, 10); + + if (!ts->isActive() || !ts->getStreamingActive()) { + Ogre::LogManager::getSingleton().logMessage( + "TerrainTests: FAIL - streaming terrain did not activate"); + destroyTerrainEntity(app, e); + pumpFrames(app, ts, 1); + return false; + } + if (ts->getStreamingPageCount() != 6) { + Ogre::LogManager::getSingleton().logMessage( + "TerrainTests: FAIL - expected 6 streaming pages per axis, got " + + Ogre::StringConverter::toString( + ts->getStreamingPageCount())); + destroyTerrainEntity(app, e); + pumpFrames(app, ts, 1); + return false; + } + + /* The camera sits at the origin: the initial window is pages + * [0..1]^2 (clamped at the world corner). */ + for (long y = 0; y <= 1; ++y) { + for (long x = 0; x <= 1; ++x) { + if (!ts->isPageLoaded(x, y)) { + Ogre::LogManager::getSingleton().logMessage( + "TerrainTests: FAIL - initial window page (" + + Ogre::StringConverter::toString(x) + "," + + Ogre::StringConverter::toString(y) + + ") not loaded"); + destroyTerrainEntity(app, e); + pumpFrames(app, ts, 1); + return false; + } + } + } + if (ts->isPageLoaded(3, 3)) { + Ogre::LogManager::getSingleton().logMessage( + "TerrainTests: FAIL - page (3,3) loaded outside initial window"); + destroyTerrainEntity(app, e); + pumpFrames(app, ts, 1); + return false; + } + + /* Move the camera to page (3,3) and let the window catch up. + * The window is 3x3 pages loaded 2 per frame, so 20 frames is + * ample. */ + Ogre::Vector3 oldCamPos(0, 0, 0); + if (ts->getCamera() && ts->getCamera()->getParentSceneNode()) + oldCamPos = ts->getCamera() + ->getParentSceneNode() + ->_getDerivedPosition(); + if (!moveStreamCamera(ts, 6000.0f, 200.0f, 6000.0f)) { + Ogre::LogManager::getSingleton().logMessage( + "TerrainTests: FAIL - camera not attached, cannot move it"); + destroyTerrainEntity(app, e); + pumpFrames(app, ts, 1); + return false; + } + pumpFrames(app, ts, 20); + + for (long y = 2; y <= 4; ++y) { + for (long x = 2; x <= 4; ++x) { + if (!ts->isPageLoaded(x, y)) { + Ogre::LogManager::getSingleton().logMessage( + "TerrainTests: FAIL - window page (" + + Ogre::StringConverter::toString(x) + "," + + Ogre::StringConverter::toString(y) + + ") not loaded after camera move"); + moveStreamCamera(ts, oldCamPos.x, oldCamPos.y, + oldCamPos.z); + destroyTerrainEntity(app, e); + pumpFrames(app, ts, 1); + return false; + } + } + } + /* Pages beyond the hold radius must have been unloaded. */ + if (ts->isPageLoaded(0, 0)) { + Ogre::LogManager::getSingleton().logMessage( + "TerrainTests: FAIL - page (0,0) not unloaded after camera move"); + moveStreamCamera(ts, oldCamPos.x, oldCamPos.y, oldCamPos.z); + destroyTerrainEntity(app, e); + pumpFrames(app, ts, 1); + return false; + } + /* Pages between load and hold radius are kept (hysteresis). */ + if (!ts->isPageLoaded(1, 1)) { + Ogre::LogManager::getSingleton().logMessage( + "TerrainTests: FAIL - hold-radius page (1,1) was unloaded"); + moveStreamCamera(ts, oldCamPos.x, oldCamPos.y, oldCamPos.z); + destroyTerrainEntity(app, e); + pumpFrames(app, ts, 1); + return false; + } + /* Colliders were created for the streamed pages. */ + if (ts->getColliderCount() == 0) { + Ogre::LogManager::getSingleton().logMessage( + "TerrainTests: FAIL - no colliders for streamed pages"); + moveStreamCamera(ts, oldCamPos.x, oldCamPos.y, oldCamPos.z); + destroyTerrainEntity(app, e); + pumpFrames(app, ts, 1); + return false; + } + + moveStreamCamera(ts, oldCamPos.x, oldCamPos.y, oldCamPos.z); + destroyTerrainEntity(app, e); + pumpFrames(app, ts, 3); + + Ogre::LogManager::getSingleton().logMessage( + "TerrainTests: streaming window follows camera test passed"); + return true; +} + +bool TerrainTestRunner::testStreamingWorldBounds(EditorApp &app, + TerrainSystem *ts) +{ + if (ts->isActive()) + ts->deactivate(); + destroyLeftoverTerrainEntities(app); + + flecs::entity e = createTerrainEntity(app); + { + auto &tc = e.get_mut(); + tc.streamingEnabled = true; + tc.worldSizeUnits = 12000.0; /* 6x6 pages */ + tc.pageLoadRadius = 1; + tc.pageHoldRadius = 2; + } + pumpFrames(app, ts, 10); + + if (!ts->isActive() || !ts->getStreamingActive()) { + Ogre::LogManager::getSingleton().logMessage( + "TerrainTests: FAIL - streaming terrain did not activate (bounds)"); + destroyTerrainEntity(app, e); + pumpFrames(app, ts, 1); + return false; + } + + /* Far past the far corner: the camera page must clamp to the last + * page and nothing outside [0,5] may ever load. */ + if (!moveStreamCamera(ts, 200000.0f, 200.0f, 200000.0f)) { + Ogre::LogManager::getSingleton().logMessage( + "TerrainTests: FAIL - camera not attached (bounds)"); + destroyTerrainEntity(app, e); + pumpFrames(app, ts, 1); + return false; + } + pumpFrames(app, ts, 30); + + if (!ts->isPageLoaded(5, 5) || !ts->isPageLoaded(4, 5) || + !ts->isPageLoaded(5, 4)) { + Ogre::LogManager::getSingleton().logMessage( + "TerrainTests: FAIL - far-corner window pages not loaded"); + moveStreamCamera(ts, 0.0f, 200.0f, 0.0f); + destroyTerrainEntity(app, e); + pumpFrames(app, ts, 1); + return false; + } + + /* Far past the near corner: clamp back to page (0,0). */ + moveStreamCamera(ts, -200000.0f, 200.0f, -200000.0f); + pumpFrames(app, ts, 30); + + if (!ts->isPageLoaded(0, 0) || !ts->isPageLoaded(1, 0) || + !ts->isPageLoaded(0, 1)) { + Ogre::LogManager::getSingleton().logMessage( + "TerrainTests: FAIL - near-corner window pages not loaded"); + moveStreamCamera(ts, 0.0f, 200.0f, 0.0f); + destroyTerrainEntity(app, e); + pumpFrames(app, ts, 1); + return false; + } + if (ts->isPageLoaded(5, 5)) { + Ogre::LogManager::getSingleton().logMessage( + "TerrainTests: FAIL - page (5,5) still loaded at near corner"); + moveStreamCamera(ts, 0.0f, 200.0f, 0.0f); + destroyTerrainEntity(app, e); + pumpFrames(app, ts, 1); + return false; + } + + moveStreamCamera(ts, 0.0f, 200.0f, 0.0f); + destroyTerrainEntity(app, e); + pumpFrames(app, ts, 3); + + Ogre::LogManager::getSingleton().logMessage( + "TerrainTests: streaming world bounds test passed"); + return true; +} + +bool TerrainTestRunner::testStreamingSculptWritesFixups(EditorApp &app, + TerrainSystem *ts) +{ + if (ts->isActive()) + ts->deactivate(); + destroyLeftoverTerrainEntities(app); + + flecs::entity e = createTerrainEntity(app); + { + auto &tc = e.get_mut(); + tc.streamingEnabled = true; + tc.worldSizeUnits = 12000.0; + tc.pageLoadRadius = 1; + tc.pageHoldRadius = 2; + } + pumpFrames(app, ts, 10); + + if (!ts->isActive() || !ts->getStreamingActive()) { + Ogre::LogManager::getSingleton().logMessage( + "TerrainTests: FAIL - streaming terrain did not activate (sculpt)"); + destroyTerrainEntity(app, e); + pumpFrames(app, ts, 1); + return false; + } + + ts->clearAllFixups(); + + /* Brush in physical heightmap coords (callers convert visual to + * physical before calling applySculptBrush). Page (0,0) is + * loaded at the default camera position. */ + float px = ts->visualToPhysicalX(0.0f); + float pz = ts->visualToPhysicalZ(0.0f); + + float before = ts->sampleHeightAt((long)px, (long)pz); + ts->setSculptTool(TerrainSystem::SculptTool::Raise); + ts->setSculptRadius(20.0f); + ts->setSculptStrength(5.0f); + ts->applySculptBrush(Ogre::Vector3(px, 0.0f, pz)); + float after = ts->sampleHeightAt((long)px, (long)pz); + + if (!(after > before)) { + Ogre::LogManager::getSingleton().logMessage( + "TerrainTests: FAIL - streaming sculpt did not raise the surface (" + + Ogre::StringConverter::toString(before) + " -> " + + Ogre::StringConverter::toString(after) + ")"); + ts->clearAllFixups(); + destroyTerrainEntity(app, e); + pumpFrames(app, ts, 1); + return false; + } + + /* The dirty-page rebuild must not crash and the loaded page must + * report the sculpted height through the render geometry. */ + pumpFrames(app, ts, 3); + float rendered = ts->getHeightAt(Ogre::Vector3(0.0f, 0.0f, 0.0f)); + if (std::abs(rendered - after) > 0.5f) { + Ogre::LogManager::getSingleton().logMessage( + "TerrainTests: FAIL - loaded page does not reflect sculpt (" + + Ogre::StringConverter::toString(rendered) + " vs " + + Ogre::StringConverter::toString(after) + ")"); + ts->clearAllFixups(); + destroyTerrainEntity(app, e); + pumpFrames(app, ts, 1); + return false; + } + + /* Persistence: flush, cycle the terrain, expect the fixup back + * from disk. */ + ts->saveFixups(); + ts->deactivate(); + pumpFrames(app, ts, 5); + if (!ts->isActive()) { + Ogre::LogManager::getSingleton().logMessage( + "TerrainTests: FAIL - streaming terrain did not re-activate (sculpt)"); + ts->clearAllFixups(); + destroyTerrainEntity(app, e); + pumpFrames(app, ts, 1); + return false; + } + float restored = ts->sampleHeightAt((long)px, (long)pz); + if (std::abs(restored - after) > 0.01f) { + Ogre::LogManager::getSingleton().logMessage( + "TerrainTests: FAIL - sculpt fixup not persisted (" + + Ogre::StringConverter::toString(restored) + " vs " + + Ogre::StringConverter::toString(after) + ")"); + ts->clearAllFixups(); + destroyTerrainEntity(app, e); + pumpFrames(app, ts, 1); + return false; + } + + ts->clearAllFixups(); + destroyTerrainEntity(app, e); + pumpFrames(app, ts, 3); + + Ogre::LogManager::getSingleton().logMessage( + "TerrainTests: streaming sculpt writes fixups test passed"); + return true; +} + +bool TerrainTestRunner::testStreamingBlendMapsSurviveUnload(EditorApp &app, + TerrainSystem *ts) +{ + if (ts->isActive()) + ts->deactivate(); + destroyLeftoverTerrainEntities(app); + + flecs::entity e = createTerrainEntity(app); + { + auto &tc = e.get_mut(); + tc.streamingEnabled = true; + tc.worldSizeUnits = 12000.0; /* 6x6 pages */ + tc.pageLoadRadius = 1; + tc.pageHoldRadius = 2; + } + pumpFrames(app, ts, 10); + + if (!ts->isActive() || !ts->getStreamingActive()) { + Ogre::LogManager::getSingleton().logMessage( + "TerrainTests: FAIL - streaming terrain did not activate (blend)"); + destroyTerrainEntity(app, e); + pumpFrames(app, ts, 1); + return false; + } + + /* Paint layer 1 at the origin (page (0,0), loaded). */ + ts->setPaintLayerIndex(1); + ts->setPaintRadius(100.0f); + ts->setPaintStrength(1.0f); + ts->applySplatBrush(Ogre::Vector3(0.0f, 0.0f, 0.0f)); + pumpFrames(app, ts, 2); + + /* Move the camera far away so page (0,0) leaves the hold window + * and unloads; the dirty blend map must be flushed to disk. */ + if (!moveStreamCamera(ts, 9000.0f, 200.0f, 9000.0f)) { + Ogre::LogManager::getSingleton().logMessage( + "TerrainTests: FAIL - camera not attached (blend)"); + destroyTerrainEntity(app, e); + pumpFrames(app, ts, 1); + return false; + } + pumpFrames(app, ts, 30); + if (ts->isPageLoaded(0, 0)) { + Ogre::LogManager::getSingleton().logMessage( + "TerrainTests: FAIL - page (0,0) not unloaded (blend)"); + moveStreamCamera(ts, 0.0f, 200.0f, 0.0f); + destroyTerrainEntity(app, e); + pumpFrames(app, ts, 1); + return false; + } + + /* Move back; the page reloads and must restore the paint. */ + moveStreamCamera(ts, 0.0f, 200.0f, 0.0f); + pumpFrames(app, ts, 30); + if (!ts->isPageLoaded(0, 0)) { + Ogre::LogManager::getSingleton().logMessage( + "TerrainTests: FAIL - page (0,0) not reloaded (blend)"); + destroyTerrainEntity(app, e); + pumpFrames(app, ts, 1); + return false; + } + + Ogre::Terrain *t = ts->getTerrainGroup()->getTerrain(0, 0); + bool hasBlend = false; + if (t && t->isLoaded() && t->getLayerCount() > 1) { + Ogre::TerrainLayerBlendMap *bm = t->getLayerBlendMap(1); + if (bm) { + int bms = (int)t->getLayerBlendMapSize(); + for (int y = 0; y < bms && !hasBlend; ++y) + for (int x = 0; x < bms && !hasBlend; ++x) + if (bm->getBlendValue((Ogre::uint32)x, + (Ogre::uint32)y) > + 0.01f) + hasBlend = true; + } + } + if (!hasBlend) { + Ogre::LogManager::getSingleton().logMessage( + "TerrainTests: FAIL - blend map lost across page unload/reload"); + destroyTerrainEntity(app, e); + pumpFrames(app, ts, 1); + return false; + } + + destroyTerrainEntity(app, e); + pumpFrames(app, ts, 3); + + Ogre::LogManager::getSingleton().logMessage( + "TerrainTests: streaming blend maps survive unload test passed"); + return true; +} + +bool TerrainTestRunner::testStreamingAuxMapPerPage(EditorApp &app, + TerrainSystem *ts) +{ + if (ts->isActive()) + ts->deactivate(); + destroyLeftoverTerrainEntities(app); + + flecs::entity e = createTerrainEntity(app); + uint64_t terrainId; + { + auto &tc = e.get_mut(); + tc.streamingEnabled = true; + tc.worldSizeUnits = 12000.0; + tc.pageLoadRadius = 1; + tc.pageHoldRadius = 2; + + TerrainComponent::AuxMap am; + am.name = "streamAux"; + am.fileName = "streamAux.bin"; + am.resolution = 64; /* per-page in streaming mode */ + am.defaultValue = 0.0f; + tc.auxMaps.push_back(am); + + terrainId = tc.terrainId; + } + pumpFrames(app, ts, 10); + + if (!ts->isActive() || !ts->getStreamingActive()) { + Ogre::LogManager::getSingleton().logMessage( + "TerrainTests: FAIL - streaming terrain did not activate (aux)"); + destroyTerrainEntity(app, e); + pumpFrames(app, ts, 1); + return false; + } + + /* Cone falloff: full delta everywhere inside the radius. */ + ts->setBrushFalloffShape(TerrainSystem::BrushFalloffShape::Cone); + + /* Physical coords of the visual origin (page (0,0)). */ + long physX = (long)ts->visualToPhysicalX(0.0f); + long physZ = (long)ts->visualToPhysicalZ(0.0f); + + float v0 = ts->sampleAuxMap("streamAux", physX, physZ); + if (v0 != 0.0f) { + Ogre::LogManager::getSingleton().logMessage( + "TerrainTests: FAIL - streaming aux default not zero"); + destroyTerrainEntity(app, e); + pumpFrames(app, ts, 1); + return false; + } + + ts->applyAuxBrush("streamAux", + Ogre::Vector3((float)physX, 0.0f, (float)physZ), + 50.0f, 0.5f); + float v1 = ts->sampleAuxMap("streamAux", physX, physZ); + if (std::fabs(v1 - 0.5f) > 0.05f) { + Ogre::LogManager::getSingleton().logMessage( + "TerrainTests: FAIL - streaming aux paint expected ~0.5 got " + + Ogre::StringConverter::toString(v1)); + destroyTerrainEntity(app, e); + pumpFrames(app, ts, 1); + return false; + } + + /* A far page with no edits must still read the default. */ + float vFar = ts->sampleAuxMap("streamAux", 9000, 9000); + if (vFar != 0.0f) { + Ogre::LogManager::getSingleton().logMessage( + "TerrainTests: FAIL - streaming aux far page not default"); + destroyTerrainEntity(app, e); + pumpFrames(app, ts, 1); + return false; + } + + /* Persist and cycle the terrain: the chunk must come back from + * disk after the in-memory cache is dropped. */ + const TerrainComponent &tc = e.get(); + if (!ts->saveSceneAuxMaps(tc)) { + Ogre::LogManager::getSingleton().logMessage( + "TerrainTests: FAIL - could not save streaming aux maps"); + destroyTerrainEntity(app, e); + pumpFrames(app, ts, 1); + return false; + } + + std::string chunkPath = "heightmaps/" + + Ogre::StringConverter::toString(terrainId) + + "/aux/streamAux/x0_z0.bin"; + if (!std::filesystem::exists(chunkPath)) { + Ogre::LogManager::getSingleton().logMessage( + "TerrainTests: FAIL - streaming aux chunk file missing: " + + chunkPath); + destroyTerrainEntity(app, e); + pumpFrames(app, ts, 1); + return false; + } + + ts->deactivate(); + pumpFrames(app, ts, 5); + if (!ts->isActive()) { + Ogre::LogManager::getSingleton().logMessage( + "TerrainTests: FAIL - streaming terrain did not re-activate (aux)"); + destroyTerrainEntity(app, e); + pumpFrames(app, ts, 1); + return false; + } + + float v2 = ts->sampleAuxMap("streamAux", physX, physZ); + if (std::fabs(v2 - 0.5f) > 0.05f) { + Ogre::LogManager::getSingleton().logMessage( + "TerrainTests: FAIL - streaming aux not restored from disk, got " + + Ogre::StringConverter::toString(v2)); + destroyTerrainEntity(app, e); + pumpFrames(app, ts, 1); + return false; + } + + destroyTerrainEntity(app, e); + pumpFrames(app, ts, 3); + + Ogre::LogManager::getSingleton().logMessage( + "TerrainTests: streaming per-page aux map test passed"); + return true; +} + +/* ------------------------------------------------------------------ */ +/* Render origin (M2) */ +/* ------------------------------------------------------------------ */ + +bool TerrainTestRunner::testRenderOriginBasics(EditorApp &app, + TerrainSystem *ts) +{ + RenderOriginSystem *ro = app.getRenderOriginSystem(); + if (!ro) { + Ogre::LogManager::getSingleton().logMessage( + "TerrainTests: FAIL - no RenderOriginSystem in app"); + return false; + } + + /* world->render->world round-trip near the far corner of the + * 40,000,000-unit world. */ + ro->rebase(JPH::DVec3(39999000.0, 0.0, 39999000.0)); + + const double wx = 39999123.5, wy = 42.25, wz = 39998777.75; + Ogre::Vector3 r = ro->worldToRender(wx, wy, wz); + JPH::DVec3 back = ro->renderToWorld(r); + const double errX = std::fabs(back.GetX() - wx); + const double errY = std::fabs(back.GetY() - wy); + const double errZ = std::fabs(back.GetZ() - wz); + if (errX > 0.01 || errY > 0.01 || errZ > 0.01) { + Ogre::LogManager::getSingleton().logMessage( + "TerrainTests: FAIL - render origin round-trip error (" + + Ogre::StringConverter::toString(errX) + ", " + + Ogre::StringConverter::toString(errY) + ", " + + Ogre::StringConverter::toString(errZ) + ")"); + ro->rebase(JPH::DVec3(0.0, 0.0, 0.0)); + return false; + } + + /* Rebase shifts root-level entity nodes by exactly the origin + * delta; entities with an authoritative world position keep it + * and get their node recomputed exactly. */ + flecs::world *w = app.getWorld(); + flecs::entity tracked = w->entity(); + { + TransformComponent tc2; + tc2.node = app.getSceneManager() + ->getRootSceneNode() + ->createChildSceneNode(); + tc2.worldX = 39999500.0; + tc2.worldY = 10.0; + tc2.worldZ = 39999600.0; + tc2.hasWorldPosition = true; + tc2.position = ro->worldToRender(tc2.worldX, tc2.worldY, + tc2.worldZ); + tc2.applyToNode(); + tracked.set(tc2); + } + flecs::entity legacy = w->entity(); + { + TransformComponent tc2; + tc2.node = app.getSceneManager() + ->getRootSceneNode() + ->createChildSceneNode(); + tc2.position = Ogre::Vector3(100.0f, 5.0f, -50.0f); + tc2.applyToNode(); + legacy.set(tc2); + } + const Ogre::Vector3 legacyBefore = + legacy.get().position; + + ro->rebase(JPH::DVec3(39999000.0 + 4096.0, 0.0, 39999000.0)); + + const TransformComponent &tt = tracked.get(); + if (!tt.hasWorldPosition || tt.worldX != 39999500.0 || + tt.worldZ != 39999600.0) { + Ogre::LogManager::getSingleton().logMessage( + "TerrainTests: FAIL - rebase clobbered world position"); + tracked.destruct(); + legacy.destruct(); + ro->rebase(JPH::DVec3(0.0, 0.0, 0.0)); + return false; + } + Ogre::Vector3 expect = ro->worldToRender(39999500.0, 10.0, + 39999600.0); + if ((tt.position - expect).length() > 0.01f || + (tt.node->getPosition() - expect).length() > 0.01f) { + Ogre::LogManager::getSingleton().logMessage( + "TerrainTests: FAIL - tracked entity node not at " + "recomputed render position"); + tracked.destruct(); + legacy.destruct(); + ro->rebase(JPH::DVec3(0.0, 0.0, 0.0)); + return false; + } + + const TransformComponent < = legacy.get(); + Ogre::Vector3 legacyExpect = + legacyBefore - Ogre::Vector3(4096.0f, 0.0f, 0.0f); + if ((lt.position - legacyExpect).length() > 0.01f) { + Ogre::LogManager::getSingleton().logMessage( + "TerrainTests: FAIL - legacy entity node not shifted " + "by the origin delta"); + tracked.destruct(); + legacy.destruct(); + ro->rebase(JPH::DVec3(0.0, 0.0, 0.0)); + return false; + } + + tracked.destruct(); + legacy.destruct(); + ro->rebase(JPH::DVec3(0.0, 0.0, 0.0)); + pumpFrames(app, ts, 1); + + Ogre::LogManager::getSingleton().logMessage( + "TerrainTests: render origin basics test passed"); + return true; +} + +bool TerrainTestRunner::testStreamingFarCorner(EditorApp &app, + TerrainSystem *ts) +{ + if (ts->isActive()) + ts->deactivate(); + destroyLeftoverTerrainEntities(app); + + RenderOriginSystem *ro = app.getRenderOriginSystem(); + if (!ro) { + Ogre::LogManager::getSingleton().logMessage( + "TerrainTests: FAIL - no RenderOriginSystem (far corner)"); + return false; + } + + flecs::entity e = createTerrainEntity(app); + { + auto &tc = e.get_mut(); + tc.streamingEnabled = true; + /* The full world: 20000 x 20000 pages of 2000 units. */ + tc.worldSizeUnits = 40000000.0; + tc.pageLoadRadius = 1; + tc.pageHoldRadius = 2; + } + pumpFrames(app, ts, 10); + + if (!ts->isActive() || !ts->getStreamingActive()) { + Ogre::LogManager::getSingleton().logMessage( + "TerrainTests: FAIL - streaming terrain did not activate (far corner)"); + destroyTerrainEntity(app, e); + pumpFrames(app, ts, 1); + return false; + } + if (ts->getStreamingPageCount() != 20000) { + Ogre::LogManager::getSingleton().logMessage( + "TerrainTests: FAIL - expected 20000 pages per axis, got " + + Ogre::StringConverter::toString( + ts->getStreamingPageCount())); + destroyTerrainEntity(app, e); + pumpFrames(app, ts, 1); + return false; + } + + /* Teleport to the centre of the last page (19999, 19999); the + * render origin must rebase and the far page must stream in. + * (The last page's visual span is [39997000, 39999000), so its + * centre is 39998000.) */ + if (!moveStreamCamera(ts, 39998000.0, 200.0, 39998000.0)) { + Ogre::LogManager::getSingleton().logMessage( + "TerrainTests: FAIL - camera not attached (far corner)"); + destroyTerrainEntity(app, e); + pumpFrames(app, ts, 1); + return false; + } + pumpFrames(app, ts, 30); + + const JPH::DVec3 &origin = ro->getOrigin(); + if (std::fabs(origin.GetX() - 39998000.0) > 1.0 || + std::fabs(origin.GetZ() - 39998000.0) > 1.0) { + Ogre::LogManager::getSingleton().logMessage( + "TerrainTests: FAIL - render origin did not rebase to the far corner, got (" + + Ogre::StringConverter::toString(origin.GetX()) + ", " + + Ogre::StringConverter::toString(origin.GetZ()) + ")"); + moveStreamCamera(ts, 0.0, 200.0, 0.0); + destroyTerrainEntity(app, e); + pumpFrames(app, ts, 3); + return false; + } + + /* The camera sits at physical page (19999, 19999), clamped at + * the world corner; the window is 2x2 pages there. */ + if (!ts->isPageLoaded(19999, 19999) || + !ts->isPageLoaded(19998, 19999) || + !ts->isPageLoaded(19999, 19998)) { + Ogre::LogManager::getSingleton().logMessage( + "TerrainTests: FAIL - far-corner pages not loaded"); + moveStreamCamera(ts, 0.0, 200.0, 0.0); + destroyTerrainEntity(app, e); + pumpFrames(app, ts, 3); + return false; + } + if (ts->isPageLoaded(0, 0)) { + Ogre::LogManager::getSingleton().logMessage( + "TerrainTests: FAIL - page (0,0) still loaded at the far corner"); + moveStreamCamera(ts, 0.0, 200.0, 0.0); + destroyTerrainEntity(app, e); + pumpFrames(app, ts, 3); + return false; + } + + /* The loaded page geometry height at the (near-origin) render + * position must match the analytic sample at the physical + * coordinates of the camera's world position — this validates + * the whole double-precision fillPageHeightData path. */ + Ogre::Vector3 camRender = + ts->getCamera()->getParentSceneNode()->_getDerivedPosition(); + float rendered = ts->getHeightAt(camRender); + float analytic = ts->sampleHeightAt( + (long)ts->visualToPhysicalX(39998000.0), + (long)ts->visualToPhysicalZ(39998000.0)); + if (!std::isfinite(rendered) || + std::fabs(rendered - analytic) > 2.0f) { + Ogre::LogManager::getSingleton().logMessage( + "TerrainTests: FAIL - far corner height mismatch, rendered " + + Ogre::StringConverter::toString(rendered) + + " vs analytic " + + Ogre::StringConverter::toString(analytic)); + moveStreamCamera(ts, 0.0, 0.0, 0.0); + destroyTerrainEntity(app, e); + pumpFrames(app, ts, 3); + return false; + } + + /* Teleporting back to (0,0,0) must rebase the origin to zero + * exactly. */ + moveStreamCamera(ts, 0.0, 0.0, 0.0); + pumpFrames(app, ts, 30); + const JPH::DVec3 &origin2 = ro->getOrigin(); + if (origin2.GetX() != 0.0 || origin2.GetY() != 0.0 || + origin2.GetZ() != 0.0) { + Ogre::LogManager::getSingleton().logMessage( + "TerrainTests: FAIL - render origin did not return to zero, got (" + + Ogre::StringConverter::toString(origin2.GetX()) + ", " + + Ogre::StringConverter::toString(origin2.GetY()) + ", " + + Ogre::StringConverter::toString(origin2.GetZ()) + ")"); + destroyTerrainEntity(app, e); + pumpFrames(app, ts, 3); + return false; + } + if (!ts->isPageLoaded(0, 0)) { + Ogre::LogManager::getSingleton().logMessage( + "TerrainTests: FAIL - page (0,0) not loaded after returning"); + destroyTerrainEntity(app, e); + pumpFrames(app, ts, 3); + return false; + } + + destroyTerrainEntity(app, e); + pumpFrames(app, ts, 3); + + Ogre::LogManager::getSingleton().logMessage( + "TerrainTests: streaming far corner test passed"); + return true; +} + +/* ------------------------------------------------------------------ */ +/* testBookmarkRoundTrip (M3.3) */ +/* ------------------------------------------------------------------ */ + +bool TerrainTestRunner::testBookmarkRoundTrip(EditorApp &app, + TerrainSystem *ts) +{ + (void)ts; + const std::string path = "test_bookmarks_scene.json"; + + /* Use a scratch world so save/load never touches the app's + * scene entities. */ + flecs::world scratchWorld; + SceneSerializer saver(scratchWorld, app.getSceneManager()); + + std::vector in; + { + WorldBookmark a; + a.name = "Origin"; + a.x = 0.0; + a.y = 50.0; + a.z = 0.0; + in.push_back(a); + } + { + WorldBookmark b; + b.name = "Far Corner"; + b.x = 39998000.5; + b.y = 123.25; + b.z = 39997500.75; + in.push_back(b); + } + saver.setBookmarks(in); + + if (!saver.saveToFile(path)) { + Ogre::LogManager::getSingleton().logMessage( + "TerrainTests: FAIL - bookmark scene save failed: " + + saver.getLastError()); + return false; + } + + flecs::world scratchWorld2; + SceneSerializer loader(scratchWorld2, app.getSceneManager()); + if (!loader.loadFromFile(path, nullptr)) { + Ogre::LogManager::getSingleton().logMessage( + "TerrainTests: FAIL - bookmark scene load failed: " + + loader.getLastError()); + std::remove(path.c_str()); + return false; + } + + const std::vector &out = loader.getBookmarks(); + bool ok = out.size() == in.size(); + if (ok) { + for (size_t i = 0; i < in.size(); ++i) { + if (out[i].name != in[i].name || + std::fabs(out[i].x - in[i].x) > 1e-6 || + std::fabs(out[i].y - in[i].y) > 1e-6 || + std::fabs(out[i].z - in[i].z) > 1e-6) { + ok = false; + break; + } + } + } + std::remove(path.c_str()); + + if (!ok) { + Ogre::LogManager::getSingleton().logMessage( + "TerrainTests: FAIL - bookmark round-trip mismatch"); + return false; + } + + Ogre::LogManager::getSingleton().logMessage( + "TerrainTests: bookmark round-trip test passed"); + return true; +} + +/* ------------------------------------------------------------------ */ +/* testWorldMapTransforms (M3.4) */ +/* ------------------------------------------------------------------ */ + +bool TerrainTestRunner::testWorldMapTransforms(EditorApp &app, + TerrainSystem *ts) +{ + /* Pure-math round trips, including the far corner of the + * 40,000,000-unit world. The world->map step quantizes to float + * pixels, so the round-trip tolerance is a tiny fraction of a + * pixel in world units (sub-pixel at any zoom). */ + { + WorldMapData d; + d.centerX = 19999000.0; + d.centerZ = 19999000.0; + d.metersPerPixel = 78125.0; + const double tol = d.metersPerPixel * 1e-4; + + const double pts[][2] = { { -1000.0, -1000.0 }, + { 39999000.0, 39999000.0 }, + { 12345678.5, 39999999.0 }, + { 0.0, 0.0 } }; + for (const auto &p : pts) { + float mx, my; + d.worldToMap(p[0], p[1], 512, 512, mx, my); + double wx, wz; + d.mapToWorld(mx, my, 512, 512, wx, wz); + if (std::fabs(wx - p[0]) > tol || + std::fabs(wz - p[1]) > tol) { + Ogre::LogManager::getSingleton().logMessage( + "TerrainTests: FAIL - world map round-trip error at (" + + Ogre::StringConverter::toString(p[0]) + + ", " + + Ogre::StringConverter::toString(p[1]) + + ")"); + return false; + } + } + + /* The inverse direction (pixel -> world -> pixel) must be + * exact to float precision: map clicks are stable. */ + double wx0, wz0; + d.mapToWorld(37.0, 491.0, 512, 512, wx0, wz0); + float bx, by; + d.worldToMap(wx0, wz0, 512, 512, bx, by); + if (std::fabs(bx - 37.0f) > 0.001f || + std::fabs(by - 491.0f) > 0.001f) { + Ogre::LogManager::getSingleton().logMessage( + "TerrainTests: FAIL - pixel->world->pixel not stable"); + return false; + } + } + + /* zoomAt keeps the world point under the cursor fixed and + * clamps the zoom range. */ + { + WorldMapData d; + d.centerX = 20000000.0; + d.centerZ = 5000000.0; + d.metersPerPixel = 5000.0; + + double wx0, wz0; + d.mapToWorld(100.0, 400.0, 512, 512, wx0, wz0); + d.zoomAt(100.0, 400.0, 512, 512, 2.0); + float mx, my; + d.worldToMap(wx0, wz0, 512, 512, mx, my); + if (std::fabs(mx - 100.0f) > 0.01f || + std::fabs(my - 400.0f) > 0.01f) { + Ogre::LogManager::getSingleton().logMessage( + "TerrainTests: FAIL - zoomAt moved the cursor point"); + return false; + } + + d.zoomAt(0.0, 0.0, 512, 512, -1000.0); + if (d.metersPerPixel > WorldMapData::MAX_METERS_PER_PIXEL) { + Ogre::LogManager::getSingleton().logMessage( + "TerrainTests: FAIL - zoom not clamped"); + return false; + } + } + + /* fitWorld centres a full streamed world. */ + if (ts->isActive()) + ts->deactivate(); + destroyLeftoverTerrainEntities(app); + + flecs::entity e = createTerrainEntity(app); + { + auto &tc = e.get_mut(); + tc.streamingEnabled = true; + tc.worldSizeUnits = 40000000.0; + tc.pageLoadRadius = 1; + tc.pageHoldRadius = 2; + } + pumpFrames(app, ts, 10); + + if (!ts->isActive() || !ts->getStreamingActive()) { + Ogre::LogManager::getSingleton().logMessage( + "TerrainTests: FAIL - streaming terrain did not activate (world map)"); + destroyTerrainEntity(app, e); + pumpFrames(app, ts, 1); + return false; + } + + /* Terrain entity sits at the origin, so page 0 is centred on + * (0, 0); the last page centre is 19999*2000 further out. */ + const double expectCentre = 19999.0 * 1000.0; + const double expectMpp = 40000000.0 / 512.0; + + WorldMapData f; + f.fitWorld(ts, 512.0, 512.0); + bool ok = std::fabs(f.centerX - expectCentre) < 1.0 && + std::fabs(f.centerZ - expectCentre) < 1.0 && + std::fabs(f.metersPerPixel - expectMpp) < 1.0; + + /* Page (0,0) centre (world origin) must map to the top-left + * canvas corner, half a page in. */ + if (ok) { + float mx, my; + f.worldToMap(0.0, 0.0, 512, 512, mx, my); + if (std::fabs(mx - 1000.0 / expectMpp) > 0.01f || + std::fabs(my - 1000.0 / expectMpp) > 0.01f) + ok = false; + } + + /* The height cache samples the procedural base at the far + * corner without precision loss. */ + if (ok) { + f.rebuildHeightCache(ts, 512.0, 512.0, 32); + if (!f.hasCache() || f.getGridRes() != 32) + ok = false; + else { + const float corner = f.getCachedHeight(31, 31); + const float analytic = ts->sampleBaseHeightAt( + (long)ts->visualToPhysicalX(39999000.0), + (long)ts->visualToPhysicalZ(39999000.0)); + if (!std::isfinite(corner) || + std::fabs(corner - analytic) > 500.0f) + ok = false; + } + } + + destroyTerrainEntity(app, e); + pumpFrames(app, ts, 3); + + if (!ok) { + Ogre::LogManager::getSingleton().logMessage( + "TerrainTests: FAIL - world map fitWorld/cache wrong"); + return false; + } + + Ogre::LogManager::getSingleton().logMessage( + "TerrainTests: world map transforms test passed"); + return true; +} + +/* ------------------------------------------------------------------ */ +/* testNavigationTeleport (M3.2) */ +/* ------------------------------------------------------------------ */ + +bool TerrainTestRunner::testNavigationTeleport(EditorApp &app, + TerrainSystem *ts) +{ + if (ts->isActive()) + ts->deactivate(); + destroyLeftoverTerrainEntities(app); + + flecs::entity e = createTerrainEntity(app); + { + auto &tc = e.get_mut(); + tc.streamingEnabled = true; + tc.worldSizeUnits = 40000000.0; + tc.pageLoadRadius = 1; + tc.pageHoldRadius = 2; + } + pumpFrames(app, ts, 10); + + if (!ts->isActive() || !ts->getStreamingActive()) { + Ogre::LogManager::getSingleton().logMessage( + "TerrainTests: FAIL - streaming terrain did not activate (nav teleport)"); + destroyTerrainEntity(app, e); + pumpFrames(app, ts, 1); + return false; + } + + /* The no-camera teleport path moves the "EditorCameraTarget" + * node directly; make sure it exists (headless has no + * EditorCamera). */ + Ogre::SceneManager *sm = app.getSceneManager(); + if (!sm->hasSceneNode("EditorCameraTarget")) + sm->getRootSceneNode()->createChildSceneNode( + "EditorCameraTarget"); + Ogre::SceneNode *node = sm->getSceneNode("EditorCameraTarget"); + + bool ok = true; + + /* Page teleport: last page centre is world 39998000 on both + * axes. Assertions are origin-independent (render->world + * round trip), so no rebase is needed first. */ + NavigationPanel::teleportToPage(19999, 19999, nullptr); + { + const Ogre::Vector3 p = node->getPosition(); + const double wx = ts->renderToWorldX(p.x); + const double wz = ts->renderToWorldZ(p.z); + if (std::fabs(wx - 39998000.0) > 1.0 || + std::fabs(wz - 39998000.0) > 1.0) { + Ogre::LogManager::getSingleton().logMessage( + "TerrainTests: FAIL - page teleport landed at (" + + Ogre::StringConverter::toString(wx) + ", " + + Ogre::StringConverter::toString(wz) + ")"); + ok = false; + } + /* Y snapped above the terrain surface. */ + const float h = ts->getHeightAt(p); + if (std::isfinite(h) && p.y < h) { + Ogre::LogManager::getSingleton().logMessage( + "TerrainTests: FAIL - teleport left camera under terrain"); + ok = false; + } + } + + /* Out-of-range page indices clamp to the world bounds: + * (99999999, -5) -> page (19999, 0), centre (39998000, 0). */ + if (ok) { + NavigationPanel::teleportToPage(99999999, -5, nullptr); + const Ogre::Vector3 p = node->getPosition(); + if (std::fabs(ts->renderToWorldX(p.x) - 39998000.0) > 1.0 || + std::fabs(ts->renderToWorldZ(p.z) - 0.0) > 1.0) { + Ogre::LogManager::getSingleton().logMessage( + "TerrainTests: FAIL - page teleport clamping wrong"); + ok = false; + } + } + + /* Explicit world-coordinate teleport. */ + if (ok) { + NavigationPanel::teleportCamera(12345.0, 0.0, -6789.0, + nullptr); + const Ogre::Vector3 p = node->getPosition(); + if (std::fabs(ts->renderToWorldX(p.x) - 12345.0) > 1.0 || + std::fabs(ts->renderToWorldZ(p.z) - (-6789.0)) > 1.0) { + Ogre::LogManager::getSingleton().logMessage( + "TerrainTests: FAIL - coordinate teleport wrong"); + ok = false; + } + } + + /* Back to the origin and clean up. */ + NavigationPanel::teleportCamera(0.0, 200.0, 0.0, nullptr); + pumpFrames(app, ts, 5); + destroyTerrainEntity(app, e); + pumpFrames(app, ts, 3); + + if (!ok) + return false; + + Ogre::LogManager::getSingleton().logMessage( + "TerrainTests: navigation teleport test passed"); + return true; +} + +/* ------------------------------------------------------------------ */ +/* testSpawnerRegionStore (M4.1) */ +/* ------------------------------------------------------------------ */ + +bool TerrainTestRunner::testSpawnerRegionStore(EditorApp &app, + TerrainSystem *ts) +{ + (void)app; + (void)ts; + const std::string root = "test_spawner_regions"; + std::filesystem::remove_all(root); + + bool ok = true; + + { + SpawnerRegionStore store; + store.setRootDirectory(root); + + SpawnerRegionDef a; + a.prefabPath = "prefabs/test_cube.json"; + a.worldX = 39998000.5; + a.worldY = 123.25; + a.worldZ = 39997500.75; + a.spawnDistance = 150.0f; + a.despawnDistance = 300.0f; + const uint64_t idA = store.addSpawner(19999, 19999, a); + + SpawnerRegionDef b; + b.prefabPath = "prefabs/other.json"; + b.worldX = 6500.0; + b.worldY = 12.0; + b.worldZ = 7500.0; + const uint64_t idB = store.addSpawner(3, 4, b); + + /* Ids are unique within a page (the tag references defs by + * page + id), not globally. */ + if (idA == 0 || idB == 0) { + Ogre::LogManager::getSingleton().logMessage( + "TerrainTests: FAIL - spawner region store ids"); + ok = false; + } + + if (!std::filesystem::exists(SpawnerRegionStore::pageFilePath( + root, 19999, 19999)) || + !std::filesystem::exists( + SpawnerRegionStore::pageFilePath(root, 3, 4))) { + Ogre::LogManager::getSingleton().logMessage( + "TerrainTests: FAIL - spawner region files not written"); + ok = false; + } + + /* A fresh store instance must read the same defs from + * disk (double-precision positions survive JSON). */ + SpawnerRegionStore store2; + store2.setRootDirectory(root); + const std::vector &defs = + store2.getSpawners(19999, 19999); + if (defs.size() != 1 || defs[0].id != idA || + defs[0].prefabPath != a.prefabPath || + std::fabs(defs[0].worldX - a.worldX) > 1e-6 || + std::fabs(defs[0].worldY - a.worldY) > 1e-6 || + std::fabs(defs[0].worldZ - a.worldZ) > 1e-6 || + std::fabs(defs[0].spawnDistance - 150.0f) > 1e-3f) { + Ogre::LogManager::getSingleton().logMessage( + "TerrainTests: FAIL - spawner region round-trip mismatch"); + ok = false; + } + + /* Empty pages report no spawners. */ + if (!store2.getSpawners(0, 0).empty()) { + Ogre::LogManager::getSingleton().logMessage( + "TerrainTests: FAIL - empty spawner page not empty"); + ok = false; + } + + /* updateSpawnerById / findById / removeSpawnerById. */ + SpawnerRegionDef b2 = b; + b2.id = idB; + b2.spawnDistance = 500.0f; + if (!store2.updateSpawnerById(3, 4, idB, b2)) { + Ogre::LogManager::getSingleton().logMessage( + "TerrainTests: FAIL - updateSpawnerById"); + ok = false; + } + const SpawnerRegionDef *found = + store2.findById(3, 4, idB); + if (!found || + std::fabs(found->spawnDistance - 500.0f) > 1e-3f) { + Ogre::LogManager::getSingleton().logMessage( + "TerrainTests: FAIL - findById after update"); + ok = false; + } + if (!store2.removeSpawnerById(3, 4, idB) || + !store2.getSpawners(3, 4).empty()) { + Ogre::LogManager::getSingleton().logMessage( + "TerrainTests: FAIL - removeSpawnerById"); + ok = false; + } + /* The removal must be on disk too. */ + SpawnerRegionStore store3; + store3.setRootDirectory(root); + if (!store3.getSpawners(3, 4).empty() || + store3.getSpawners(19999, 19999).size() != 1) { + Ogre::LogManager::getSingleton().logMessage( + "TerrainTests: FAIL - spawner removal not persisted"); + ok = false; + } + } + + std::filesystem::remove_all(root); + + if (!ok) + return false; + + Ogre::LogManager::getSingleton().logMessage( + "TerrainTests: spawner region store test passed"); + return true; +} + +/* ------------------------------------------------------------------ */ +/* testStreamedSpawnerWindow (M4.2) */ +/* ------------------------------------------------------------------ */ + +bool TerrainTestRunner::testStreamedSpawnerWindow(EditorApp &app, + TerrainSystem *ts) +{ + if (ts->isActive()) + ts->deactivate(); + destroyLeftoverTerrainEntities(app); + + TerrainPrefabSpawnerSystem *pss = app.getTerrainPrefabSpawnerSystem(); + if (!pss) { + Ogre::LogManager::getSingleton().logMessage( + "TerrainTests: FAIL - no terrain prefab spawner system (streamed window)"); + return false; + } + + flecs::entity e = createTerrainEntity(app); + uint64_t terrainId = 0; + { + auto &tc = e.get_mut(); + tc.streamingEnabled = true; + /* 5 x 5 pages of 2000 units. */ + tc.worldSizeUnits = 10000.0f; + tc.pageLoadRadius = 1; + tc.pageHoldRadius = 2; + terrainId = tc.terrainId; + } + pumpFrames(app, ts, 10); + + if (!ts->isActive() || !ts->getStreamingActive()) { + Ogre::LogManager::getSingleton().logMessage( + "TerrainTests: FAIL - streaming terrain did not activate (spawner window)"); + destroyTerrainEntity(app, e); + pumpFrames(app, ts, 1); + return false; + } + + bool ok = true; + + /* One pss update to pick up the region store root. */ + pss->update(); + + /* A spawner def on far page (4, 4), world centre (8000, 8000) + * with the terrain entity at the origin. */ + SpawnerRegionDef def; + def.prefabPath = "tests/prefabs/tiny_cube.prefab"; + def.worldX = 8000.0; + def.worldY = 0.0; + def.worldZ = 8000.0; + /* Huge distances: the headless camera position is unreliable + * (same workaround as testTerrainPrefabSpawners). */ + def.spawnDistance = 100000.0f; + def.despawnDistance = 200000.0f; + if (pss->getRegionStore().addSpawner(4, 4, def) == 0) { + Ogre::LogManager::getSingleton().logMessage( + "TerrainTests: FAIL - could not add region spawner"); + ok = false; + } + + /* Camera at the origin: page (4,4) is not loaded, so no + * spawner entity must exist. */ + pumpFrames(app, ts, 5); + pss->update(); + if (ok && pss->getStreamedSpawnerCount() != 0) { + Ogre::LogManager::getSingleton().logMessage( + "TerrainTests: FAIL - streamed spawner exists for unloaded page"); + ok = false; + } + + /* Move to the far page: the entity must appear and (camera + * within 500 units) the prefab instance must spawn. */ + flecs::entity spawnerEntity; + if (ok) { + if (!moveStreamCamera(ts, 8000.0, 200.0, 8000.0)) { + Ogre::LogManager::getSingleton().logMessage( + "TerrainTests: FAIL - camera not attached (spawner window)"); + ok = false; + } + pumpFrames(app, ts, 30); + pss->update(); + } + if (ok && pss->getStreamedSpawnerCount() != 1) { + Ogre::LogManager::getSingleton().logMessage( + "TerrainTests: FAIL - streamed spawner did not appear, count " + + Ogre::StringConverter::toString( + (int)pss->getStreamedSpawnerCount())); + ok = false; + } + + if (ok) { + flecs::world *w = app.getWorld(); + w->query_builder() + .build() + .each([&](flecs::entity se, StreamedSpawnerTag &tag, + TransformComponent &xform) { + spawnerEntity = se; + if (tag.pageX != 4 || tag.pageY != 4 || + !xform.hasWorldPosition || + std::fabs(xform.worldX - 8000.0) > 1.0 || + std::fabs(xform.worldZ - 8000.0) > 1.0) + ok = false; + }); + if (!spawnerEntity.is_alive()) { + Ogre::LogManager::getSingleton().logMessage( + "TerrainTests: FAIL - streamed spawner entity not found"); + ok = false; + } else if (!ok) { + Ogre::LogManager::getSingleton().logMessage( + "TerrainTests: FAIL - streamed spawner at wrong world position"); + } + } + + if (ok && !pss->getSpawnedEntity(spawnerEntity).is_alive()) { + Ogre::LogManager::getSingleton().logMessage( + "TerrainTests: FAIL - streamed spawner did not spawn prefab"); + ok = false; + } + + /* The scene serializer must skip streamed spawners. */ + if (ok) { + const std::string scenePath = "test_streamed_spawner_scene.json"; + SceneSerializer saver(*app.getWorld(), app.getSceneManager()); + if (!saver.saveToFile(scenePath)) { + Ogre::LogManager::getSingleton().logMessage( + "TerrainTests: FAIL - scene save (spawner window)"); + ok = false; + } else { + std::ifstream f(scenePath); + std::string content((std::istreambuf_iterator(f)), + std::istreambuf_iterator()); + if (content.find("RegionSpawner_") != std::string::npos || + content.find("tiny_cube") != std::string::npos) { + Ogre::LogManager::getSingleton().logMessage( + "TerrainTests: FAIL - streamed spawner leaked into scene JSON"); + ok = false; + } + } + std::remove(scenePath.c_str()); + } + + /* Back to the origin: page (4,4) unloads, the entity and the + * prefab instance must be destroyed. */ + moveStreamCamera(ts, 0.0, 200.0, 0.0); + pumpFrames(app, ts, 30); + pss->update(); + if (pss->getStreamedSpawnerCount() != 0) { + Ogre::LogManager::getSingleton().logMessage( + "TerrainTests: FAIL - streamed spawner not removed on page unload"); + ok = false; + } + if (spawnerEntity.is_alive()) { + Ogre::LogManager::getSingleton().logMessage( + "TerrainTests: FAIL - streamed spawner entity still alive"); + ok = false; + } + + destroyTerrainEntity(app, e); + pumpFrames(app, ts, 3); + pss->update(); + std::filesystem::remove_all("heightmaps/" + + std::to_string(terrainId)); + + if (!ok) + return false; + + Ogre::LogManager::getSingleton().logMessage( + "TerrainTests: streamed spawner window test passed"); + return true; +} + +/* ------------------------------------------------------------------ */ +/* testRoadRegionStore (M5) */ +/* ------------------------------------------------------------------ */ + +bool TerrainTestRunner::testRoadRegionStore(EditorApp &app, TerrainSystem *ts) +{ + (void)app; + (void)ts; + const std::string root = "test_road_regions"; + std::filesystem::remove_all(root); + + bool ok = true; + + { + RoadRegionStore store; + store.setRootDirectory(root); + + RoadRegionData data; + RoadRegionNode n1; + n1.id = 1; + n1.x = 39998000.25; + n1.y = 12.5; + n1.z = 39997500.75; + n1.verticalOffset = 0.5f; + RoadRegionNode n2; + n2.id = 2; + n2.x = 39998100.5; + n2.y = 13.0; + n2.z = 39997600.25; + n2.verticalOffset = -0.25f; + data.nodes.push_back(n1); + data.nodes.push_back(n2); + + RoadRegionEdge re; + re.nodeAId = 1; + re.nodeBId = 2; + re.roadLevelA = 0.1f; + re.roadLevelB = -0.2f; + re.lanesPerDirectionOverride = 3; + re.lanesAtoB = 2; + re.lanesBtoA = 1; + re.prefabLeft.prefabPath = "prefabs/lamp.json"; + re.prefabLeft.edgeT = 0.25f; + re.prefabLeft.lateralOffset = 1.5f; + re.prefabLeft.yOffset = 0.05f; + re.prefabMid.prefabPath = "prefabs/sign.json"; + re.prefabMid.edgeT = 0.75f; + re.prefabMid.lateralOffset = -0.5f; + re.prefabMid.yOffset = 0.1f; + data.edges.push_back(re); + + if (!store.saveRegion(19999, 19999, data)) { + Ogre::LogManager::getSingleton().logMessage( + "TerrainTests: FAIL - road region save failed"); + ok = false; + } + + if (!std::filesystem::exists(RoadRegionStore::pageFilePath( + root, 19999, 19999))) { + Ogre::LogManager::getSingleton().logMessage( + "TerrainTests: FAIL - road region file not written"); + ok = false; + } + + /* A fresh store reads the same data back from disk; the + * far-corner double positions must survive exactly. */ + RoadRegionStore store2; + store2.setRootDirectory(root); + RoadRegionData out; + if (!store2.getRegion(19999, 19999, &out) || + out.nodes.size() != 2 || out.edges.size() != 1) { + Ogre::LogManager::getSingleton().logMessage( + "TerrainTests: FAIL - road region round-trip size"); + ok = false; + } else { + const RoadRegionNode &m1 = out.nodes[0]; + const RoadRegionNode &m2 = out.nodes[1]; + if (m1.id != 1 || m1.x != 39998000.25 || + m1.y != 12.5 || m1.z != 39997500.75 || + m1.verticalOffset != 0.5f || + m2.id != 2 || m2.x != 39998100.5 || + m2.verticalOffset != -0.25f) { + Ogre::LogManager::getSingleton().logMessage( + "TerrainTests: FAIL - road region node round-trip mismatch"); + ok = false; + } + const RoadRegionEdge &me = out.edges[0]; + if (me.nodeAId != 1 || me.nodeBId != 2 || + me.roadLevelA != 0.1f || me.roadLevelB != -0.2f || + me.lanesPerDirectionOverride != 3 || + me.lanesAtoB != 2 || me.lanesBtoA != 1 || + me.prefabLeft.prefabPath != "prefabs/lamp.json" || + me.prefabLeft.edgeT != 0.25f || + me.prefabLeft.lateralOffset != 1.5f || + me.prefabLeft.yOffset != 0.05f || + me.prefabMid.prefabPath != "prefabs/sign.json" || + me.prefabMid.edgeT != 0.75f || + !me.prefabRight.prefabPath.empty()) { + Ogre::LogManager::getSingleton().logMessage( + "TerrainTests: FAIL - road region edge round-trip mismatch"); + ok = false; + } + } + + /* A page without a file reports false. */ + RoadRegionData empty; + if (store2.getRegion(3, 3, &empty)) { + Ogre::LogManager::getSingleton().logMessage( + "TerrainTests: FAIL - missing road region reported as present"); + ok = false; + } + + /* removeRegionFile deletes from disk. */ + store2.removeRegionFile(19999, 19999); + if (std::filesystem::exists(RoadRegionStore::pageFilePath( + root, 19999, 19999))) { + Ogre::LogManager::getSingleton().logMessage( + "TerrainTests: FAIL - road region file not removed"); + ok = false; + } + RoadRegionStore store3; + store3.setRootDirectory(root); + if (store3.getRegion(19999, 19999, &empty)) { + Ogre::LogManager::getSingleton().logMessage( + "TerrainTests: FAIL - removed road region still loads"); + ok = false; + } + } + + std::filesystem::remove_all(root); + + if (!ok) + return false; + + Ogre::LogManager::getSingleton().logMessage( + "TerrainTests: road region store test passed"); + return true; +} + +/* ------------------------------------------------------------------ */ +/* testRoadRegionStreaming (M5) */ +/* ------------------------------------------------------------------ */ + +/* Find a graph node by its absolute world XZ position. */ +static int findRoadNodeAtWorld(TerrainSystem *ts, const RoadGraph &g, + double wx, double wz, double eps) +{ + for (const RoadNode &n : g.nodes) { + if (std::fabs(ts->renderToWorldX(n.position.x) - wx) <= eps && + std::fabs(ts->renderToWorldZ(n.position.z) - wz) <= eps) + return n.id; + } + return -1; +} + +bool TerrainTestRunner::testRoadRegionStreaming(EditorApp &app, + TerrainSystem *ts) +{ + if (ts->isActive()) + ts->deactivate(); + destroyLeftoverTerrainEntities(app); + + flecs::entity e = createTerrainEntity(app); + uint64_t terrainId = 0; + { + auto &tc = e.get_mut(); + tc.streamingEnabled = true; + /* 5 x 5 pages of 2000 units. */ + tc.worldSizeUnits = 10000.0; + tc.pageLoadRadius = 1; + tc.pageHoldRadius = 2; + terrainId = tc.terrainId; + } + pumpFrames(app, ts, 10); + + if (!ts->isActive() || !ts->getStreamingActive() || + !ts->getRoadSystem()) { + Ogre::LogManager::getSingleton().logMessage( + "TerrainTests: FAIL - streaming terrain did not activate (road regions)"); + destroyTerrainEntity(app, e); + pumpFrames(app, ts, 1); + return false; + } + RoadSystem *rs = ts->getRoadSystem(); + const std::string root = "heightmaps/" + std::to_string(terrainId); + + bool ok = true; + + /* A road within page (0,0): two nodes plus one edge. */ + { + auto &tc = e.get_mut(); + int a = tc.roadGraph.addNode(Ogre::Vector3(200, 0, 200)); + int b = tc.roadGraph.addNode(Ogre::Vector3(800, 0, 600)); + tc.roadGraph.addEdge(a, b); + } + + /* flushRegionStore persists the active graph per page. */ + rs->flushRegionStore(); + { + RoadRegionStore check; + check.setRootDirectory(root); + RoadRegionData rd; + if (!check.getRegion(0, 0, &rd) || rd.nodes.size() != 2 || + rd.edges.size() != 1) { + Ogre::LogManager::getSingleton().logMessage( + "TerrainTests: FAIL - road region file 0_0 wrong after flush"); + ok = false; + } + } + + /* Let the merge path re-read the file: the existing nodes match + * by world position, so nothing duplicates. */ + pumpFrames(app, ts, 3); + { + const auto &g = e.get().roadGraph; + if (g.nodes.size() != 2 || g.edges.size() != 1) { + Ogre::LogManager::getSingleton().logMessage( + "TerrainTests: FAIL - road merge duplicated nodes, got " + + Ogre::StringConverter::toString( + (int)g.nodes.size()) + " nodes " + + Ogre::StringConverter::toString( + (int)g.edges.size()) + " edges"); + ok = false; + } + } + + /* A cross-page edge: D (page 0,0) to E (page 0,1); connectNodes + * splits at the z = 1000 page boundary with an inserted node + * (which belongs to page (0,1) by the round-half rule). */ + if (ok) { + auto &tc = e.get_mut(); + int d = tc.roadGraph.addNode(Ogre::Vector3(500, 0, 500)); + int f = tc.roadGraph.addNode(Ogre::Vector3(500, 0, 1500)); + std::string err; + std::vector created; + if (tc.roadGraph.connectNodes(d, f, tc.worldSize, &err, + &created) < 0 || + created.size() != 1) { + Ogre::LogManager::getSingleton().logMessage( + "TerrainTests: FAIL - cross-page connectNodes: " + + err); + ok = false; + } + } + + /* After the flush, both endpoint pages carry the crossing edge + * and repeat the boundary node. */ + if (ok) { + rs->flushRegionStore(); + RoadRegionStore check; + check.setRootDirectory(root); + RoadRegionData rd0, rd1; + if (!check.getRegion(0, 0, &rd0) || + !check.getRegion(0, 1, &rd1)) { + Ogre::LogManager::getSingleton().logMessage( + "TerrainTests: FAIL - cross-page region files missing"); + ok = false; + } else { + /* Page (0,0): 3 local nodes + the boundary node + * inline, edges A-B and D-BD. */ + if (rd0.nodes.size() != 4 || rd0.edges.size() != 2) { + Ogre::LogManager::getSingleton().logMessage( + "TerrainTests: FAIL - region 0_0 contents, got " + + Ogre::StringConverter::toString( + (int)rd0.nodes.size()) + + " nodes " + + Ogre::StringConverter::toString( + (int)rd0.edges.size()) + + " edges"); + ok = false; + } + /* Page (0,1): boundary node + E local, D inline, + * edges D-BD and BD-E. */ + if (rd1.nodes.size() != 3 || rd1.edges.size() != 2) { + Ogre::LogManager::getSingleton().logMessage( + "TerrainTests: FAIL - region 0_1 contents, got " + + Ogre::StringConverter::toString( + (int)rd1.nodes.size()) + + " nodes " + + Ogre::StringConverter::toString( + (int)rd1.edges.size()) + + " edges"); + ok = false; + } + bool sawBoundary0 = false, sawBoundary1 = false; + for (const RoadRegionNode &rn : rd0.nodes) + if (std::fabs(rn.x - 500.0) < 0.01 && + std::fabs(rn.z - 1000.0) < 0.01) + sawBoundary0 = true; + for (const RoadRegionNode &rn : rd1.nodes) + if (std::fabs(rn.x - 500.0) < 0.01 && + std::fabs(rn.z - 1000.0) < 0.01) + sawBoundary1 = true; + if (!sawBoundary0 || !sawBoundary1) { + Ogre::LogManager::getSingleton().logMessage( + "TerrainTests: FAIL - boundary node not in both region files"); + ok = false; + } + } + } + + /* The scene JSON must not carry inline road data when streaming + * (roadConfig stays). */ + if (ok) { + SceneSerializer serializer(*app.getWorld(), + app.getSceneManager()); + nlohmann::json tj = serializer.serializeTerrain(e); + if (tj.contains("roadNodes") || tj.contains("roadEdges") || + !tj.contains("roadConfig")) { + Ogre::LogManager::getSingleton().logMessage( + "TerrainTests: FAIL - streaming terrain serialization kept inline roads"); + ok = false; + } + } + + /* Rebase the render origin: node world positions must not move + * and no page mesh may be rebuilt (the dirty-scope signature is + * rebase-invariant). */ + RenderOriginSystem *ro = app.getRenderOriginSystem(); + ProceduralMeshSystem *pms = app.getProceduralMeshSystem(); + if (ok && ro && pms) { + /* Let RoadSystem process the pending connectNodes version + * bump, then drain the resulting mesh rebuilds, so any + * dirty flag seen after the rebase is rebase-caused. */ + pumpFrames(app, ts, 2); + pms->update(); + ro->rebase(JPH::DVec3(4000.0, 0.0, 4000.0)); + pumpFrames(app, ts, 2); + + const auto &g = e.get().roadGraph; + if (findRoadNodeAtWorld(ts, g, 200.0, 200.0, 0.01) < 0 || + findRoadNodeAtWorld(ts, g, 500.0, 1000.0, 0.01) < 0) { + Ogre::LogManager::getSingleton().logMessage( + "TerrainTests: FAIL - road nodes moved by rebase"); + ok = false; + } + /* A rebuilt page would leave its TriangleBufferComponent + * dirty (the mesh system is not pumped here). */ + for (const auto &kv : rs->getPageGeometry()) { + flecs::entity be = kv.second.bufferEntity; + if (!be.is_alive() || + !be.has()) + continue; + if (be.get().dirty) { + Ogre::LogManager::getSingleton().logMessage( + "TerrainTests: FAIL - rebase rebuilt a road page mesh"); + ok = false; + } + } + ro->rebase(JPH::DVec3(0.0, 0.0, 0.0)); + pumpFrames(app, ts, 2); + } + + /* Move far away: pages (0,0) and (0,1) unload, their nodes are + * extracted to the region files and leave the graph. */ + if (ok) { + if (!moveStreamCamera(ts, 8000.0, 200.0, 8000.0)) { + Ogre::LogManager::getSingleton().logMessage( + "TerrainTests: FAIL - camera not attached (road regions)"); + ok = false; + } + pumpFrames(app, ts, 30); + } + if (ok) { + const auto &g = e.get().roadGraph; + if (!g.nodes.empty() || !g.edges.empty()) { + Ogre::LogManager::getSingleton().logMessage( + "TerrainTests: FAIL - road nodes not extracted on page unload, " + + Ogre::StringConverter::toString( + (int)g.nodes.size()) + " nodes left"); + ok = false; + } + RoadRegionStore check; + check.setRootDirectory(root); + RoadRegionData rd; + if (!check.getRegion(0, 0, &rd) || rd.nodes.size() != 4 || + rd.edges.size() != 2) { + Ogre::LogManager::getSingleton().logMessage( + "TerrainTests: FAIL - region 0_0 lost data on unload"); + ok = false; + } + } + + /* Move back: the pages reload and the full road network remerges + * at the same world positions, without duplicates. */ + if (ok) { + moveStreamCamera(ts, 0.0, 200.0, 0.0); + pumpFrames(app, ts, 30); + + const auto &g = e.get().roadGraph; + if (g.nodes.size() != 5 || g.edges.size() != 3) { + Ogre::LogManager::getSingleton().logMessage( + "TerrainTests: FAIL - road remerge wrong, got " + + Ogre::StringConverter::toString( + (int)g.nodes.size()) + " nodes " + + Ogre::StringConverter::toString( + (int)g.edges.size()) + " edges"); + ok = false; + } else { + int a = findRoadNodeAtWorld(ts, g, 200.0, 200.0, 0.01); + int b = findRoadNodeAtWorld(ts, g, 800.0, 600.0, 0.01); + int d = findRoadNodeAtWorld(ts, g, 500.0, 500.0, 0.01); + int bd = findRoadNodeAtWorld(ts, g, 500.0, 1000.0, + 0.01); + int f = findRoadNodeAtWorld(ts, g, 500.0, 1500.0, 0.01); + if (a < 0 || b < 0 || d < 0 || bd < 0 || f < 0 || + !g.hasEdge(a, b) || !g.hasEdge(d, bd) || + !g.hasEdge(bd, f)) { + Ogre::LogManager::getSingleton().logMessage( + "TerrainTests: FAIL - remerged road chain incomplete"); + ok = false; + } + } + } + + moveStreamCamera(ts, 0.0, 200.0, 0.0); + if (ro) + ro->rebase(JPH::DVec3(0.0, 0.0, 0.0)); + destroyTerrainEntity(app, e); + pumpFrames(app, ts, 3); + std::filesystem::remove_all(root); + + if (!ok) + return false; + + Ogre::LogManager::getSingleton().logMessage( + "TerrainTests: road region streaming test passed"); + return true; +} + +/* ------------------------------------------------------------------ */ +/* testRoadRegionMigration (M5) */ +/* ------------------------------------------------------------------ */ + +bool TerrainTestRunner::testRoadRegionMigration(EditorApp &app, + TerrainSystem *ts) +{ + if (ts->isActive()) + ts->deactivate(); + destroyLeftoverTerrainEntities(app); + + flecs::entity e = createTerrainEntity(app); + uint64_t terrainId = 0; + { + auto &tc = e.get_mut(); + tc.streamingEnabled = true; + tc.worldSizeUnits = 10000.0; + tc.pageLoadRadius = 1; + tc.pageHoldRadius = 2; + /* Legacy inline road data, as deserializeTerrain produces. + * The second edge crosses a page boundary (x = 7000). */ + tc.roadGraph.config.laneWidth = 4.5f; + int a = tc.roadGraph.addNode(Ogre::Vector3(200, 0, 200)); + int b = tc.roadGraph.addNode(Ogre::Vector3(800, 0, 600)); + tc.roadGraph.addEdge(a, b); + tc.roadGraph.edges.back().lanesAtoB = 2; + int c = tc.roadGraph.addNode(Ogre::Vector3(6500, 0, 6500)); + int d = tc.roadGraph.addNode(Ogre::Vector3(7000, 0, 6500)); + tc.roadGraph.addEdge(c, d); + terrainId = tc.terrainId; + } + pumpFrames(app, ts, 10); + + if (!ts->isActive() || !ts->getStreamingActive()) { + Ogre::LogManager::getSingleton().logMessage( + "TerrainTests: FAIL - streaming terrain did not activate (road migration)"); + destroyTerrainEntity(app, e); + pumpFrames(app, ts, 1); + return false; + } + const std::string root = "heightmaps/" + std::to_string(terrainId); + + bool ok = true; + + /* The migration partitioned the inline data into region files: + * page (0,0), page (3,3) and page (4,3) (node d sits on the + * x = 7000 boundary, round-half assigns it to page 4). */ + { + RoadRegionStore check; + check.setRootDirectory(root); + RoadRegionData rd; + if (!check.getRegion(0, 0, &rd) || rd.nodes.size() != 2 || + rd.edges.size() != 1) { + Ogre::LogManager::getSingleton().logMessage( + "TerrainTests: FAIL - migration did not write region 0_0"); + ok = false; + } + if (!check.getRegion(3, 3, &rd) || rd.nodes.size() != 2 || + rd.edges.size() != 1) { + Ogre::LogManager::getSingleton().logMessage( + "TerrainTests: FAIL - migration did not write region 3_3 (with foreign endpoint)"); + ok = false; + } + if (!check.getRegion(4, 3, &rd) || rd.nodes.size() != 2 || + rd.edges.size() != 1) { + Ogre::LogManager::getSingleton().logMessage( + "TerrainTests: FAIL - migration did not write region 4_3 (with foreign endpoint)"); + ok = false; + } + /* Edge data (the lanesAtoB = 2 override) survives. */ + if (check.getRegion(0, 0, &rd) && + (rd.edges.empty() || rd.edges[0].lanesAtoB != 2)) { + Ogre::LogManager::getSingleton().logMessage( + "TerrainTests: FAIL - migration lost edge lane data"); + ok = false; + } + } + + /* The graph was cleared and remerged for the loaded window only: + * nodes a/b (page 0,0), not c/d. */ + if (ok) { + const auto &g = e.get().roadGraph; + if (g.nodes.size() != 2 || g.edges.size() != 1) { + Ogre::LogManager::getSingleton().logMessage( + "TerrainTests: FAIL - migration left wrong graph window, " + + Ogre::StringConverter::toString( + (int)g.nodes.size()) + " nodes"); + ok = false; + } + if (g.config.laneWidth != 4.5f) { + Ogre::LogManager::getSingleton().logMessage( + "TerrainTests: FAIL - migration lost the road config"); + ok = false; + } + if (findRoadNodeAtWorld(ts, g, 200.0, 200.0, 0.01) < 0 || + findRoadNodeAtWorld(ts, g, 800.0, 600.0, 0.01) < 0 || + findRoadNodeAtWorld(ts, g, 6500.0, 6500.0, 0.01) >= 0) { + Ogre::LogManager::getSingleton().logMessage( + "TerrainTests: FAIL - migration merged the wrong nodes"); + ok = false; + } + } + + /* Move to the far edge: pages (3,3)/(4,3) load and the c-d edge + * reappears, while a/b are extracted back to their file. */ + if (ok) { + moveStreamCamera(ts, 6500.0, 200.0, 6500.0); + pumpFrames(app, ts, 30); + + const auto &g = e.get().roadGraph; + int c = findRoadNodeAtWorld(ts, g, 6500.0, 6500.0, 0.01); + int d = findRoadNodeAtWorld(ts, g, 7000.0, 6500.0, 0.01); + if (g.nodes.size() != 2 || g.edges.size() != 1 || c < 0 || + d < 0 || !g.hasEdge(c, d) || + findRoadNodeAtWorld(ts, g, 200.0, 200.0, 0.01) >= 0) { + Ogre::LogManager::getSingleton().logMessage( + "TerrainTests: FAIL - far road edge did not remerge, " + + Ogre::StringConverter::toString( + (int)g.nodes.size()) + " nodes " + + Ogre::StringConverter::toString( + (int)g.edges.size()) + " edges"); + ok = false; + } + } + + moveStreamCamera(ts, 0.0, 200.0, 0.0); + pumpFrames(app, ts, 3); + destroyTerrainEntity(app, e); + pumpFrames(app, ts, 3); + std::filesystem::remove_all(root); + + if (!ok) + return false; + + Ogre::LogManager::getSingleton().logMessage( + "TerrainTests: road region migration test passed"); + return true; +} + +/* ------------------------------------------------------------------ */ +/* testPrefabJsonCache (M4.4) */ +/* ------------------------------------------------------------------ */ + +bool TerrainTestRunner::testPrefabJsonCache(EditorApp &app, + TerrainSystem *ts) +{ + (void)app; + (void)ts; + const std::string path = "test_cache_prefab.json"; + + bool ok = true; + + { + std::ofstream f(path); + f << "{ \"transform\": { \"position\": { \"x\": 1.0 } } }"; + } + + const nlohmann::json *j1 = SceneSerializer::loadPrefabJsonCached(path); + const nlohmann::json *j2 = SceneSerializer::loadPrefabJsonCached(path); + if (!j1 || j1 != j2) { + Ogre::LogManager::getSingleton().logMessage( + "TerrainTests: FAIL - prefab cache did not return the cached entry"); + ok = false; + } else if (std::fabs((*j1)["transform"]["position"]["x"].get() - + 1.0) > 1e-9) { + Ogre::LogManager::getSingleton().logMessage( + "TerrainTests: FAIL - prefab cache content wrong"); + ok = false; + } + + /* Overwrite: the cache serves the stale entry until + * invalidated. */ + { + std::ofstream f(path); + f << "{ \"transform\": { \"position\": { \"x\": 2.0 } } }"; + } + const nlohmann::json *j3 = SceneSerializer::loadPrefabJsonCached(path); + if (!j3 || j3 != j1 || + std::fabs((*j3)["transform"]["position"]["x"].get() - + 1.0) > 1e-9) { + Ogre::LogManager::getSingleton().logMessage( + "TerrainTests: FAIL - prefab cache should serve stale entry"); + ok = false; + } + + SceneSerializer::invalidatePrefabJsonCache(path); + const nlohmann::json *j4 = SceneSerializer::loadPrefabJsonCached(path); + if (!j4 || + std::fabs((*j4)["transform"]["position"]["x"].get() - + 2.0) > 1e-9) { + Ogre::LogManager::getSingleton().logMessage( + "TerrainTests: FAIL - prefab cache invalidation"); + ok = false; + } + + if (SceneSerializer::loadPrefabJsonCached( + "definitely_missing_prefab.json") != nullptr) { + Ogre::LogManager::getSingleton().logMessage( + "TerrainTests: FAIL - prefab cache should miss"); + ok = false; + } + + std::remove(path.c_str()); + + if (!ok) + return false; + + Ogre::LogManager::getSingleton().logMessage( + "TerrainTests: prefab JSON cache test passed"); + return true; +} + /* ------------------------------------------------------------------ */ /* Logging */ /* ------------------------------------------------------------------ */ @@ -3295,14 +5771,52 @@ int TerrainTestRunner::run(EditorApp &app, int iterations) { "fixupChunks", testFixupChunks }, { "roadEdgeLength", testRoadEdgeLengthConstraint }, { "terrainCompliance", testTerrainCompliance }, + { "terrainComplianceClearance", + testTerrainComplianceClearance }, + { "complyRoadsToTerrain", testComplyRoadsToTerrain }, { "roadColliderInteraction", testRoadColliderInteraction }, { "roadSidePrefabs", testRoadSidePrefabs }, { "terrainPrefabSpawners", testTerrainPrefabSpawners }, + { "streamingProceduralBase", + testStreamingProceduralBase }, + { "fixupChunkLRU", testFixupChunkLRU }, + { "getHeightAtFarFallback", + testGetHeightAtFarFallback }, + { "streamingSerialization", + testStreamingSerialization }, + { "streamingWindowFollowsCamera", + testStreamingWindowFollowsCamera }, + { "streamingWorldBounds", + testStreamingWorldBounds }, + { "streamingSculptWritesFixups", + testStreamingSculptWritesFixups }, + { "streamingBlendMapsSurviveUnload", + testStreamingBlendMapsSurviveUnload }, + { "streamingAuxMapPerPage", + testStreamingAuxMapPerPage }, + { "renderOriginBasics", testRenderOriginBasics }, + { "streamingFarCorner", testStreamingFarCorner }, + { "bookmarkRoundTrip", testBookmarkRoundTrip }, + { "worldMapTransforms", testWorldMapTransforms }, + { "navigationTeleport", testNavigationTeleport }, + { "spawnerRegionStore", testSpawnerRegionStore }, + { "streamedSpawnerWindow", testStreamedSpawnerWindow }, + { "prefabJsonCache", testPrefabJsonCache }, + { "roadRegionStore", testRoadRegionStore }, + { "roadRegionStreaming", testRoadRegionStreaming }, + { "roadRegionMigration", testRoadRegionMigration }, }; bool iterPassed = true; + /* Optional substring filter (TERRAIN_TEST_FILTER env var) to + * run only matching steps during development. */ + const char *stepFilter = std::getenv("TERRAIN_TEST_FILTER"); for (auto &step : steps) { + if (stepFilter && *stepFilter && + Ogre::String(step.name).find(stepFilter) == + Ogre::String::npos) + continue; if (!step.fn(app, ts)) { m_failures++; iterPassed = false; @@ -3520,13 +6034,14 @@ bool TerrainTestRunner::testTerrainCompliance(EditorApp &app, ts->deactivate(); flecs::entity e = createTerrainEntity(app); + int n1 = 0, n2 = 0; { auto &tc = e.get_mut(); - int n1 = tc.roadGraph.addNode(Ogre::Vector3(100, 0, 100)); - int n2 = tc.roadGraph.addNode(Ogre::Vector3(500, 0, 100)); + n1 = tc.roadGraph.addNode(Ogre::Vector3(100, 0, 100)); + n2 = tc.roadGraph.addNode(Ogre::Vector3(500, 0, 100)); tc.roadGraph.addEdge(n1, n2); - /* Wide lanes so the laneWidth * 2 compliance fade zone is - * resolvable by the coarse fixup chunk cells. */ + /* Wide lanes so the road corridor covers several lattice + * cells. */ tc.roadGraph.config.laneWidth = 16.0f; } pumpFrames(app, ts, 5); @@ -3548,64 +6063,59 @@ bool TerrainTestRunner::testTerrainCompliance(EditorApp &app, return false; } - /* Unit-test computeComplianceHeight. */ - { - float h = RoadSystem::computeComplianceHeight( - 10.0f, 0.3f, 5.0f, 0.0f, 3.0f, 6.0f); - /* At lateralDistance=0 (inside full-compliance zone): - * should return roadSurfaceY - roadThickness */ - if (std::fabs(h - 9.7f) > 0.001f) { - Ogre::LogManager::getSingleton().logMessage( - "TerrainTests: FAIL - computeComplianceHeight " - "at centre incorrect, got " + - Ogre::StringConverter::toString(h)); - destroyTerrainEntity(app, e); - pumpFrames(app, ts, 1); - return false; - } - - /* At fade zone midpoint: - * lateralDistance=6.0, halfRoadWidth=3.0, fadeWidth=6.0 - * t = (6-3)/6 = 0.5 - * expected = 9.7 + 0.5*(5.0 - 9.7) = 9.7 - 2.35 = 7.35 */ - float h2 = RoadSystem::computeComplianceHeight( - 10.0f, 0.3f, 5.0f, 6.0f, 3.0f, 6.0f); - if (std::fabs(h2 - 7.35f) > 0.01f) { - Ogre::LogManager::getSingleton().logMessage( - "TerrainTests: FAIL - computeComplianceHeight " - "at mid-fade incorrect, got " + - Ogre::StringConverter::toString(h2)); - destroyTerrainEntity(app, e); - pumpFrames(app, ts, 1); - return false; - } - - /* Outside fade zone: should return baseHeight. */ - float h3 = RoadSystem::computeComplianceHeight( - 10.0f, 0.3f, 5.0f, 12.0f, 3.0f, 6.0f); - if (std::fabs(h3 - 5.0f) > 0.001f) { - Ogre::LogManager::getSingleton().logMessage( - "TerrainTests: FAIL - computeComplianceHeight " - "outside zone incorrect, got " + - Ogre::StringConverter::toString(h3)); - destroyTerrainEntity(app, e); - pumpFrames(app, ts, 1); - return false; + /* Sink the road below the terrain so compliance has violations to + * fix deterministically; measure the corridor's natural height + * range for the clearance bound below. */ + float minH = 1e30f, maxH = -1e30f; + for (float x = 100.0f; x <= 500.0f; x += 20.0f) { + for (float z = 92.0f; z <= 108.0f; z += 8.0f) { + float h = ts->getHeightAt(Ogre::Vector3(x, 0.0f, z)); + minH = std::min(minH, h); + maxH = std::max(maxH, h); } } + { + auto &tc = e.get_mut(); + RoadNode *a = tc.roadGraph.findNodeById(n1); + RoadNode *b = tc.roadGraph.findNodeById(n2); + if (!a || !b) { + Ogre::LogManager::getSingleton().logMessage( + "TerrainTests: FAIL - road nodes missing"); + destroyTerrainEntity(app, e); + pumpFrames(app, ts, 1); + return false; + } + a->position.y = minH - 1.0f; + b->position.y = minH - 1.0f; + tc.roadGraph.bumpVersion(); + } + pumpFrames(app, ts, 2); /* Call compliance and verify fixup chunks exist. */ - float roadThickness = e.get().roadGraph.config.roadThickness; - float laneWidth = e.get().roadGraph.config.laneWidth; + float roadThickness = + e.get().roadGraph.config.roadThickness; + float laneWidth = + e.get().roadGraph.config.laneWidth; /* Capture natural (base + noise, no fixup) heights at the check - * points BEFORE compliance; the fresh heightmap is procedural, not - * flat, so fade-zone expectations blend toward these values. */ - const float baseUnder = ts->sampleBaseHeightAt(300, 100); - const float baseMidPos = ts->sampleBaseHeightAt(300, 132); - const float baseMidNeg = ts->sampleBaseHeightAt(300, 68); - const float baseEnd = ts->sampleBaseHeightAt(300, 148); - const float baseBeyond = ts->sampleBaseHeightAt(300, 164); + * points BEFORE compliance. Road nodes live in VISUAL world + * space, while sampleBaseHeightAt / sampleHeightAt take PHYSICAL + * heightmap coordinates — convert with visualToPhysicalX/Z (X + * +half page, Z mirrored per page). */ + auto physX = [&](float visX) { return ts->visualToPhysicalX(visX); }; + auto physZ = [&](float visZ) { return ts->visualToPhysicalZ(visZ); }; + const float baseUnder = + ts->sampleBaseHeightAt((long)physX(300), (long)physZ(100)); + const float baseBeyond = + ts->sampleBaseHeightAt((long)physX(300), (long)physZ(190)); + const float baseBeyondNeg = + ts->sampleBaseHeightAt((long)physX(300), (long)physZ(10)); + + Ogre::TerrainGroup *tg = ts->getTerrainGroup(); + float mirrorBefore = 0.0f; + if (tg) + mirrorBefore = tg->getHeightAtWorldPosition( + Ogre::Vector3(-700.0f, 1000.0f, 900.0f)); rs->complyTerrain(ts, roadThickness, laneWidth); @@ -3622,15 +6132,14 @@ bool TerrainTestRunner::testTerrainCompliance(EditorApp &app, ts->markPageDirty(0, 0); pumpFrames(app, ts, 5); - /* Expected values: road levels are 0 and nodes sit at Y = 0, so the - * road top surface is at +roadThickness/2 = +0.15 and compliance - * writes the slab underside -0.15 under the road (an absolute - * fixup, independent of the natural height). With laneWidth = 16 - * the curb is 16 units from the centreline and the fade zone spans - * 32 units (laneWidth * 2), resolvable by the 2000/256 ~= 7.8-unit - * fixup cells; fade targets blend from -0.15 toward the natural - * height captured above. */ - const float underRoad = -0.15f; + /* No-poke and clearance bounds: the RENDERED terrain (checked + * through the Ogre terrain instances, i.e. what the user actually + * sees) must stay below the road top minus the compliance sag + * everywhere under the corridor, and must not trench deeper than + * the corridor's natural height variation justifies. */ + const float roadTop = minH - 1.0f + roadThickness * 0.5f; + const float clearanceBound = + roadTop - 0.05f - (maxH - minH) - 1.5f; auto expectHeight = [&](long wx, long wz, float expected, float tolerance, const char *label) -> bool { @@ -3648,32 +6157,103 @@ bool TerrainTestRunner::testTerrainCompliance(EditorApp &app, return true; }; - /* Directly under the road centre: full compliance. */ - if (!expectHeight(300, 100, underRoad, 0.1f, "under-road") || - /* Mid-fade on both sides (curb at +/-16, fade 32): t = 0.5. - * Tolerance covers the coarse fixup-cell blending. */ - !expectHeight(300, 132, underRoad + (baseMidPos - underRoad) * 0.5f, - 0.5f, "mid-fade +Z") || - !expectHeight(300, 68, underRoad + (baseMidNeg - underRoad) * 0.5f, - 0.5f, "mid-fade -Z") || - /* Fade end: approximately the natural height. */ - !expectHeight(300, 148, baseEnd, 0.6f, "fade-end +Z") || - /* Beyond the fade zone: exactly the natural height (no fixup - * was written there). */ - !expectHeight(300, 164, baseBeyond, 0.01f, "beyond-fade +Z")) { + if (!tg) { + Ogre::LogManager::getSingleton().logMessage( + "TerrainTests: FAIL - no terrain group"); destroyTerrainEntity(app, e); pumpFrames(app, ts, 1); return false; } + bool poked = false; + float worstPoke = 0.0f; + Ogre::Vector3 worstPos = Ogre::Vector3::ZERO; + for (float x = 110.0f; x <= 490.0f; x += 20.0f) { + for (float z = 92.0f; z <= 108.0f; z += 8.0f) { + float th = tg->getHeightAtWorldPosition( + Ogre::Vector3(x, 1000.0f, z)); + float over = th - (roadTop - 0.05f); + if (over > 0.05f) { + poked = true; + if (over > worstPoke) { + worstPoke = over; + worstPos = Ogre::Vector3(x, th, z); + } + } + if (th < clearanceBound) { + Ogre::LogManager::getSingleton().logMessage( + "TerrainTests: FAIL - clearance " + "excessive at (" + + Ogre::StringConverter::toString(x) + + "," + + Ogre::StringConverter::toString(z) + + "): terrain " + + Ogre::StringConverter::toString(th) + + ", bound " + + Ogre::StringConverter::toString( + clearanceBound)); + destroyTerrainEntity(app, e); + pumpFrames(app, ts, 1); + return false; + } + } + } + if (poked) { + Ogre::LogManager::getSingleton().logMessage( + "TerrainTests: FAIL - terrain covers road; worst at (" + + Ogre::StringConverter::toString(worstPos.x) + "," + + Ogre::StringConverter::toString(worstPos.z) + + "): terrain " + + Ogre::StringConverter::toString(worstPos.y) + + " vs road top " + + Ogre::StringConverter::toString(roadTop)); + destroyTerrainEntity(app, e); + pumpFrames(app, ts, 1); + return false; + } + + /* Beyond the corridor: exactly the natural height (no fixup was + * written there). */ + if (!expectHeight((long)physX(300), (long)physZ(190), baseBeyond, + 0.01f, "beyond-corridor +Z") || + !expectHeight((long)physX(300), (long)physZ(10), baseBeyondNeg, + 0.01f, "beyond-corridor -Z")) { + destroyTerrainEntity(app, e); + pumpFrames(app, ts, 1); + return false; + } + + /* Visual-space regression: road nodes live in visual world + * coordinates, while fixup chunks are sampled in physical + * heightmap coordinates (offset by half a page in X, mirrored in + * Z — see TerrainSystem::visualToPhysicalX/Z). The mirrored + * physical position (visual -700, 900) must NOT be lowered: no + * road passes there. */ + { + float visMirror = tg->getHeightAtWorldPosition( + Ogre::Vector3(-700.0f, 1000.0f, 900.0f)); + if (std::fabs(visMirror - mirrorBefore) > 0.05f) { + Ogre::LogManager::getSingleton().logMessage( + "TerrainTests: FAIL - fixup leaked to mirrored " + "visual position (-700,900), height = " + + Ogre::StringConverter::toString(visMirror)); + destroyTerrainEntity(app, e); + pumpFrames(app, ts, 1); + return false; + } + } + /* Save/Load round-trip: fixups persist through a full * deactivate/reactivate cycle. */ + const float sampleUnder = + ts->sampleHeightAt((long)physX(300), (long)physZ(100)); ts->saveFixups(); ts->deactivate(); pumpFrames(app, ts, 5); if (ts->isActive() && - !expectHeight(300, 100, underRoad, 0.08f, "reloaded under-road")) { + !expectHeight((long)physX(300), (long)physZ(100), sampleUnder, + 0.08f, "reloaded under-road")) { destroyTerrainEntity(app, e); pumpFrames(app, ts, 1); return false; @@ -3688,7 +6268,7 @@ bool TerrainTestRunner::testTerrainCompliance(EditorApp &app, pumpFrames(app, ts, 1); return false; } - if (!expectHeight(300, 100, baseUnder, 0.01f, + if (!expectHeight((long)physX(300), (long)physZ(100), baseUnder, 0.01f, "cleared under-road")) { destroyTerrainEntity(app, e); pumpFrames(app, ts, 1); @@ -3703,6 +6283,618 @@ bool TerrainTestRunner::testTerrainCompliance(EditorApp &app, return true; } +/* ------------------------------------------------------------------ */ +/* testTerrainComplianceClearance */ +/* */ +/* Replicates the terrain2_test.json scenario: hilly terrain (detail */ +/* noise amplitude 10) with a road network snapped to the surface plus */ +/* small vertical offsets and laneWidth 3 (fade band narrower than one */ +/* fixup texel). After "Comply Terrain to Roads" the terrain must */ +/* never rise above the road surface anywhere under the road. */ +/* ------------------------------------------------------------------ */ + +bool TerrainTestRunner::testTerrainComplianceClearance(EditorApp &app, + TerrainSystem *ts) +{ + if (ts->isActive()) + ts->deactivate(); + + /* Replicate terrain2_test.json faithfully: the saved 1024x1024 + * base heightmap plus detail noise, and the stored road node + * positions (NOT recomputed from the fine terrain surface, which + * can deviate by metres from the rendered lattice surface). + * The heightmap is copied to an isolated test terrain id so the + * fixups saved by complyTerrain() never touch the user's data. */ + const uint64_t srcTerrainId = 1783541614808045650ULL; + const uint64_t testTerrainId = 42424243ULL; + std::string srcHm = "heightmaps/" + + Ogre::StringConverter::toString(srcTerrainId) + + "/heightmap.bin"; + std::string testDir = "heightmaps/" + + Ogre::StringConverter::toString(testTerrainId); + std::string dstHm = testDir + "/heightmap.bin"; + auto cleanupFiles = [&]() { + std::error_code ec; + std::filesystem::remove_all(testDir, ec); + }; + cleanupFiles(); + { + std::error_code ec; + std::filesystem::create_directories(testDir, ec); + std::filesystem::copy_file(srcHm, dstHm, ec); + if (ec) { + Ogre::LogManager::getSingleton().logMessage( + "TerrainTests: FAIL - could not copy scene heightmap: " + + ec.message()); + return false; + } + } + + flecs::entity e = createTerrainEntity(app); + { + auto &tc = e.get_mut(); + tc.terrainId = testTerrainId; + tc.heightmapSize = 1024; + tc.heightmapFile = "heightmap.bin"; + tc.detailNoise.enabled = true; + tc.detailNoise.seed = 26368; + tc.detailNoise.amplitude = 10.0f; + tc.detailNoise.frequency = 0.006f; + tc.detailNoise.octaves = 4; + tc.detailNoise.persistence = 0.5f; + tc.detailNoise.lacunarity = 2.0f; + tc.roadGraph.config.laneWidth = 3.0f; + } + pumpFrames(app, ts, 10); + if (!ts->isActive()) { + Ogre::LogManager::getSingleton().logMessage( + "TerrainTests: FAIL - terrain not active (clearance)"); + destroyTerrainEntity(app, e); + pumpFrames(app, ts, 1); + cleanupFiles(); + return false; + } + + /* Stored node positions + vertical offsets from + * terrain2_test.json (ids 1-15). */ + struct NodeSpec { + float x, y, z, voff; + }; + static const NodeSpec nodeSpecs[] = { + { 87.508f, 3.287f, -10.456f, 0.975f }, /* 1 */ + { 78.170f, 3.940f, -26.520f, 0.498f }, /* 2 */ + { 53.109f, 6.212f, -23.880f, 1.300f }, /* 3 */ + { 44.950f, 5.934f, -7.475f, 0.584f }, /* 4 */ + { 61.722f, 11.112f, 6.399f, 0.403f }, /* 5 */ + { 75.005f, 6.517f, -4.224f, 0.597f }, /* 6 */ + { 86.119f, 4.529f, -18.289f, 1.511f }, /* 7 */ + { 52.541f, 8.384f, 2.301f, 0.0f }, /* 8 */ + { 47.947f, 6.925f, -1.559f, 0.028f }, /* 9 */ + { 64.170f, 5.168f, -26.468f, 0.405f }, /* 10 */ + { 46.681f, 6.133f, -16.216f, 1.611f }, /* 11 */ + { 69.133f, 8.661f, 3.185f, 0.712f }, /* 12 */ + { 57.900f, 10.603f, 6.804f, 0.454f }, /* 13 */ + { 100.285f, 1.979f, 17.430f, 0.0f }, /* 14 */ + { 87.374f, 3.346f, 6.371f, 0.606f }, /* 15 */ + }; + static const int edgeSpecs[][2] = { + { 6, 1 }, { 1, 7 }, { 7, 2 }, { 4, 9 }, { 9, 8 }, + { 2, 10 }, { 10, 3 }, { 3, 11 }, { 11, 4 }, { 5, 12 }, + { 12, 6 }, { 8, 13 }, { 13, 5 }, { 14, 15 }, { 15, 6 }, + }; + { + auto &tc = e.get_mut(); + for (const NodeSpec &ns : nodeSpecs) + tc.roadGraph.addNode( + Ogre::Vector3(ns.x, ns.y, ns.z), ns.voff); + for (const auto &es : edgeSpecs) + tc.roadGraph.addEdge(es[0], es[1]); + } + pumpFrames(app, ts, 5); + + RoadSystem *rs = ts->getRoadSystem(); + if (!rs) { + Ogre::LogManager::getSingleton().logMessage( + "TerrainTests: FAIL - no road system (clearance)"); + destroyTerrainEntity(app, e); + pumpFrames(app, ts, 1); + cleanupFiles(); + return false; + } + + const float roadThickness = 0.3f; + const float laneWidth = 3.0f; + + const RoadGraph &rg = e.get().roadGraph; + Ogre::TerrainGroup *tg = ts->getTerrainGroup(); + if (!tg) { + Ogre::LogManager::getSingleton().logMessage( + "TerrainTests: FAIL - no terrain group (clearance)"); + destroyTerrainEntity(app, e); + pumpFrames(app, ts, 1); + cleanupFiles(); + return false; + } + + /* Half-edge lookup by (node, neighbor) for surface sampling. */ + std::vector wedges; + std::vector segs; + enumerateWedges(rg, wedges, segs); + auto findHE = [&](int nodeId, int neighborId) -> const RoadHalfEdge * { + for (const auto &w : wedges) { + if (w.first.nodeId == nodeId && + w.first.neighborId == neighborId) + return &w.first; + if (w.second.nodeId == nodeId && + w.second.neighborId == neighborId) + return &w.second; + } + for (const auto &s : segs) { + if (s.halfEdge.nodeId == nodeId && + s.halfEdge.neighborId == neighborId) + return &s.halfEdge; + } + return nullptr; + }; + + const float halfThick = roadThickness * 0.5f; + const float halfWidth = laneWidth; /* 1 lane per direction */ + + /* Scan the rendered terrain along every edge corridor: returns the + * worst (terrain - (roadTop - 0.02)) excess and optionally records + * every sample height. */ + struct CorridorScan { + int violations = 0; + float worstExcess = 0.0f; + Ogre::Vector3 worstPos = Ogre::Vector3::ZERO; + float worstTop = 0.0f; + float worstTerrain = 0.0f; + /* Road surface clearance above the terrain: + * (roadTop - halfThick) - terrain. */ + float maxClear = 0.0f; + Ogre::Vector3 maxClearPos = Ogre::Vector3::ZERO; + float maxClearSurf = 0.0f; + float maxClearTerrain = 0.0f; + }; + auto scanCorridor = [&](std::vector *heightsOut, + std::vector *posOut) { + CorridorScan r; + for (const RoadEdge &edge : rg.edges) { + const RoadNode *na = rg.findNodeById(edge.nodeA); + const RoadNode *nb = rg.findNodeById(edge.nodeB); + const RoadHalfEdge *heA = findHE(edge.nodeA, + edge.nodeB); + const RoadHalfEdge *heB = findHE(edge.nodeB, + edge.nodeA); + if (!na || !nb || !heA || !heB) + continue; + + const float fullLen = heA->halfLength * 2.0f; + if (fullLen < 1e-3f) + continue; + Ogre::Vector3 dir = nb->position - na->position; + dir.y = 0.0f; + dir.normalise(); + Ogre::Vector3 right = + RoadGeometryLib::roadRightVec(dir); + + const int nSteps = + std::max(2, (int)std::ceil(fullLen / 0.5f)); + for (int s = 0; s <= nSteps; ++s) { + float t = (float)s / (float)nSteps * fullLen; + float surf = + (t <= heA->halfLength) ? + RoadGeometryLib:: + halfEdgeHeightAt(*heA, + rg, t) : + RoadGeometryLib:: + halfEdgeHeightAt( + *heB, rg, + fullLen - t); + const float top = surf + halfThick; + /* Stay inside the actual road footprint: + * the band/cap coverage ends exactly at + * halfWidth, and float rounding at the + * boundary would sample bare terrain next + * to the road edge. */ + static const float latFrac[] = { + -0.95f, -0.5f, 0.0f, 0.5f, 0.95f + }; + for (float lf : latFrac) { + Ogre::Vector3 p = + na->position + dir * t + + right * (lf * halfWidth); + float th = + tg->getHeightAtWorldPosition( + p.x, 1000.0f, p.z); + if (heightsOut) + heightsOut->push_back(th); + if (posOut) + posOut->push_back(p); + float excess = th - (top - 0.02f); + if (excess > 0.0f) { + ++r.violations; + if (excess > r.worstExcess) { + r.worstExcess = excess; + r.worstPos = p; + r.worstTop = top; + r.worstTerrain = th; + } + } + float clear = (top - halfThick) - th; + if (clear > r.maxClear) { + r.maxClear = clear; + r.maxClearPos = p; + r.maxClearSurf = + top - halfThick; + r.maxClearTerrain = th; + } + } + } + } + return r; + }; + + /* Measure the natural state first so the terrain drop caused by + * compliance can be bounded by what the violations required. */ + std::vector natural; + std::vector naturalPos; + CorridorScan before = scanCorridor(&natural, &naturalPos); + + rs->complyTerrain(ts, roadThickness, laneWidth); + pumpFrames(app, ts, 5); + + std::vector after; + CorridorScan res = scanCorridor(&after, nullptr); + + float maxDrop = 0.0f; + Ogre::Vector3 maxDropPos = Ogre::Vector3::ZERO; + for (size_t i = 0; i < natural.size() && i < after.size(); ++i) { + float d = natural[i] - after[i]; + if (d > maxDrop) { + maxDrop = d; + maxDropPos = naturalPos[i]; + } + } + + Ogre::LogManager::getSingleton().logMessage( + "TerrainTests: clearance scan initial excess " + + Ogre::StringConverter::toString(before.worstExcess) + + ", max terrain drop " + + Ogre::StringConverter::toString(maxDrop) + " at (" + + Ogre::StringConverter::toString(maxDropPos.x) + "," + + Ogre::StringConverter::toString(maxDropPos.z) + ")"); + + int violations = res.violations; + Ogre::Vector3 worstPos = res.worstPos; + float worstTop = res.worstTop, worstTerrain = res.worstTerrain; + + if (violations > 0) { + Ogre::LogManager::getSingleton().logMessage( + "TerrainTests: FAIL - terrain covers road at " + + Ogre::StringConverter::toString(violations) + + " sample points; worst at (" + + Ogre::StringConverter::toString(worstPos.x) + "," + + Ogre::StringConverter::toString(worstPos.z) + + "): terrain " + + Ogre::StringConverter::toString(worstTerrain) + + " vs road top " + + Ogre::StringConverter::toString(worstTop)); + destroyTerrainEntity(app, e); + pumpFrames(app, ts, 1); + cleanupFiles(); + return false; + } + + /* The terrain must not drop by more than the violations required + * (plus the sag and a small slack): the roadbed should follow the + * road surface, not collapse to the lowest road nearby. */ + float allowedDrop = std::max(0.0f, before.worstExcess) + 0.75f; + if (maxDrop > allowedDrop) { + Ogre::LogManager::getSingleton().logMessage( + "TerrainTests: FAIL - excessive terrain drop " + + Ogre::StringConverter::toString(maxDrop) + + " under the road corridor (allowed " + + Ogre::StringConverter::toString(allowedDrop) + ")"); + destroyTerrainEntity(app, e); + pumpFrames(app, ts, 1); + cleanupFiles(); + return false; + } + + Ogre::LogManager::getSingleton().logMessage( + "TerrainTests: after complyTerrain max roadbed clearance " + + Ogre::StringConverter::toString(res.maxClear) + " at (" + + Ogre::StringConverter::toString(res.maxClearPos.x) + "," + + Ogre::StringConverter::toString(res.maxClearPos.z) + ")"); + + /* Phase 2: "Comply Roads to Terrain" on the complied terrain must + * settle the road close to the surface without any poke. */ + rs->complyRoadsToTerrain(ts, roadThickness + 0.05f); + pumpFrames(app, ts, 5); + + wedges.clear(); + segs.clear(); + enumerateWedges(rg, wedges, segs); + CorridorScan res2 = scanCorridor(nullptr, nullptr); + + Ogre::LogManager::getSingleton().logMessage( + "TerrainTests: after complyRoadsToTerrain violations " + + Ogre::StringConverter::toString(res2.violations) + + ", max road clearance " + + Ogre::StringConverter::toString(res2.maxClear) + " at (" + + Ogre::StringConverter::toString(res2.maxClearPos.x) + "," + + Ogre::StringConverter::toString(res2.maxClearPos.z) + + "): road surface " + + Ogre::StringConverter::toString(res2.maxClearSurf) + + " vs terrain " + + Ogre::StringConverter::toString(res2.maxClearTerrain)); + + if (res2.violations > 0) { + Ogre::LogManager::getSingleton().logMessage( + "TerrainTests: FAIL - terrain covers road after " + "complyRoadsToTerrain at " + + Ogre::StringConverter::toString(res2.violations) + + " sample points; worst at (" + + Ogre::StringConverter::toString(res2.worstPos.x) + "," + + Ogre::StringConverter::toString(res2.worstPos.z) + + "): terrain " + + Ogre::StringConverter::toString(res2.worstTerrain) + + " vs road top " + + Ogre::StringConverter::toString(res2.worstTop)); + destroyTerrainEntity(app, e); + pumpFrames(app, ts, 1); + cleanupFiles(); + return false; + } + + /* The remaining clearance peaks where the road crosses a trench + * complyTerrain had to cut for a lower road sharing the same coarse + * lattice vertices — the road cannot descend into it because the + * corridor points in between are already tight. Bound it loosely + * to catch solver regressions. */ + if (res2.maxClear > 4.0f) { + Ogre::LogManager::getSingleton().logMessage( + "TerrainTests: FAIL - road floats " + + Ogre::StringConverter::toString(res2.maxClear) + + " above the terrain after complyRoadsToTerrain"); + destroyTerrainEntity(app, e); + pumpFrames(app, ts, 1); + cleanupFiles(); + return false; + } + + destroyTerrainEntity(app, e); + pumpFrames(app, ts, 3); + cleanupFiles(); + + Ogre::LogManager::getSingleton().logMessage( + "TerrainTests: terrain compliance clearance test passed"); + return true; +} + +/* ------------------------------------------------------------------ */ +/* testComplyRoadsToTerrain */ +/* */ +/* Hilly terrain (detail noise amplitude 10) with a road sunk 2 m */ +/* below the surface. "Comply Roads to Terrain" must lift the nodes */ +/* so every point of every edge stays at least elevation above the */ +/* rendered terrain. */ +/* ------------------------------------------------------------------ */ + +bool TerrainTestRunner::testComplyRoadsToTerrain(EditorApp &app, + TerrainSystem *ts) +{ + if (ts->isActive()) + ts->deactivate(); + + flecs::entity e = createTerrainEntity(app); + { + auto &tc = e.get_mut(); + tc.detailNoise.enabled = true; + tc.detailNoise.seed = 26368; + tc.detailNoise.amplitude = 10.0f; + tc.detailNoise.frequency = 0.006f; + tc.detailNoise.octaves = 4; + tc.detailNoise.persistence = 0.5f; + tc.detailNoise.lacunarity = 2.0f; + tc.roadGraph.config.laneWidth = 3.0f; + } + pumpFrames(app, ts, 5); + if (!ts->isActive()) { + Ogre::LogManager::getSingleton().logMessage( + "TerrainTests: FAIL - terrain not active (roads-to-terrain)"); + destroyTerrainEntity(app, e); + pumpFrames(app, ts, 1); + return false; + } + + static const float nodeXZ[][2] = { + { 60.0f, -20.0f }, /* 1 */ + { 80.0f, -5.0f }, /* 2 */ + { 65.0f, 10.0f }, /* 3 */ + { 90.0f, 15.0f }, /* 4 */ + }; + static const int edgeSpecs[][2] = { + { 1, 2 }, + { 2, 3 }, + { 3, 4 }, + }; + std::vector sunkY; + { + auto &tc = e.get_mut(); + for (const auto &np : nodeXZ) { + float y = ts->getHeightAt( + Ogre::Vector3(np[0], 0.0f, np[1])); + sunkY.push_back(y - 2.0f); + tc.roadGraph.addNode( + Ogre::Vector3(np[0], y - 2.0f, np[1]), -2.0f); + } + for (const auto &es : edgeSpecs) + tc.roadGraph.addEdge(es[0], es[1]); + } + pumpFrames(app, ts, 5); + + RoadSystem *rs = ts->getRoadSystem(); + Ogre::TerrainGroup *tg = ts->getTerrainGroup(); + if (!rs || !tg) { + Ogre::LogManager::getSingleton().logMessage( + "TerrainTests: FAIL - no road system/group (roads-to-terrain)"); + destroyTerrainEntity(app, e); + pumpFrames(app, ts, 1); + return false; + } + + const float roadThickness = 0.3f; + const float elevation = roadThickness + 0.05f; + + rs->complyRoadsToTerrain(ts, elevation); + pumpFrames(app, ts, 5); + + const RoadGraph &rg = e.get().roadGraph; + + auto fail = [&](const Ogre::String &msg) { + Ogre::LogManager::getSingleton().logMessage( + "TerrainTests: FAIL - " + msg); + destroyTerrainEntity(app, e); + pumpFrames(app, ts, 1); + return false; + }; + + /* Every node was lifted well off its sunk position, and its stored + * verticalOffset stays consistent with position.y = terrain + + * offset. */ + for (size_t i = 0; i < sunkY.size(); ++i) { + const RoadNode *n = rg.findNodeById((int)i + 1); + if (!n) + return fail("node missing after complyRoadsToTerrain"); + if (n->position.y < sunkY[i] + 1.0f) + return fail("node " + + Ogre::StringConverter::toString(i + 1) + + " not lifted by complyRoadsToTerrain"); + float ground = tg->getHeightAtWorldPosition( + n->position.x, 1000.0f, n->position.z); + if (std::fabs(n->position.y - (ground + n->verticalOffset)) > + 0.05f) + return fail("node verticalOffset inconsistent after " + "complyRoadsToTerrain"); + } + + /* Clearance: every edge sample must stay at least elevation above + * the rendered terrain. */ + std::vector wedges; + std::vector segs; + enumerateWedges(rg, wedges, segs); + auto findHE = [&](int nodeId, int neighborId) -> const RoadHalfEdge * { + for (const auto &w : wedges) { + if (w.first.nodeId == nodeId && + w.first.neighborId == neighborId) + return &w.first; + if (w.second.nodeId == nodeId && + w.second.neighborId == neighborId) + return &w.second; + } + for (const auto &s : segs) { + if (s.halfEdge.nodeId == nodeId && + s.halfEdge.neighborId == neighborId) + return &s.halfEdge; + } + return nullptr; + }; + + int violations = 0; + float worstShort = 0.0f; + float worstHover = 0.0f; + Ogre::Vector3 worstPos = Ogre::Vector3::ZERO; + Ogre::Vector3 worstHoverPos = Ogre::Vector3::ZERO; + float worstSurf = 0.0f, worstTerrain = 0.0f; + + for (const RoadEdge &edge : rg.edges) { + const RoadNode *na = rg.findNodeById(edge.nodeA); + const RoadNode *nb = rg.findNodeById(edge.nodeB); + const RoadHalfEdge *heA = findHE(edge.nodeA, edge.nodeB); + const RoadHalfEdge *heB = findHE(edge.nodeB, edge.nodeA); + if (!na || !nb || !heA || !heB) + continue; + + const float fullLen = heA->halfLength * 2.0f; + if (fullLen < 1e-3f) + continue; + Ogre::Vector3 dir = nb->position - na->position; + dir.y = 0.0f; + dir.normalise(); + Ogre::Vector3 right = RoadGeometryLib::roadRightVec(dir); + + const int nSteps = std::max(2, (int)std::ceil(fullLen / 0.5f)); + for (int s = 0; s <= nSteps; ++s) { + float t = (float)s / (float)nSteps * fullLen; + float surf = (t <= heA->halfLength) ? + RoadGeometryLib::halfEdgeHeightAt( + *heA, rg, t) : + RoadGeometryLib::halfEdgeHeightAt( + *heB, rg, fullLen - t); + static const float latFrac[] = { -0.85f, 0.0f, 0.85f }; + for (float lf : latFrac) { + Ogre::Vector3 p = na->position + dir * t + + right * (lf * 3.0f); + float th = tg->getHeightAtWorldPosition( + p.x, 1000.0f, p.z); + float shortfall = + (th + elevation - 0.05f) - surf; + if (shortfall > 0.0f) { + ++violations; + if (shortfall > worstShort) { + worstShort = shortfall; + worstPos = p; + worstSurf = surf; + worstTerrain = th; + } + } + /* Hover: the road should hug the terrain, + * not float metres above it. */ + float hover = surf - (th + elevation); + if (hover > worstHover) { + worstHover = hover; + worstHoverPos = p; + } + } + } + } + + if (violations > 0) + return fail("road below terrain at " + + Ogre::StringConverter::toString(violations) + + " sample points; worst at (" + + Ogre::StringConverter::toString(worstPos.x) + "," + + Ogre::StringConverter::toString(worstPos.z) + + "): terrain " + + Ogre::StringConverter::toString(worstTerrain) + + " vs road surface " + + Ogre::StringConverter::toString(worstSurf)); + + /* The road should sit close to the terrain: the constraint + * solver only raises nodes as far as the rendered surface under + * the corridor requires, so a small hover over dips between + * constraint samples is expected, but not metres of it. */ + Ogre::LogManager::getSingleton().logMessage( + "TerrainTests: complyRoadsToTerrain worst hover " + + Ogre::StringConverter::toString(worstHover) + " at (" + + Ogre::StringConverter::toString(worstHoverPos.x) + "," + + Ogre::StringConverter::toString(worstHoverPos.z) + ")"); + if (worstHover > 2.0f) + return fail("road floats " + + Ogre::StringConverter::toString(worstHover) + + " above terrain + elevation after " + "complyRoadsToTerrain"); + + destroyTerrainEntity(app, e); + pumpFrames(app, ts, 3); + + Ogre::LogManager::getSingleton().logMessage( + "TerrainTests: comply roads to terrain test passed"); + return true; +} + /* ------------------------------------------------------------------ */ /* testRoadColliderInteraction (W4) */ /* ------------------------------------------------------------------ */ @@ -4242,10 +7434,17 @@ bool TerrainTestRunner::testTerrainPrefabSpawners(EditorApp &app, } /* Terrain compliance (section 6.5): bump the terrain +30 under the - * prefab, then flatten it back to the spawner's snapped height. */ - float h0 = ts->sampleHeightAt(100, 100); - ts->writeFixup(100, 100, h0 + 30.0f); - if (std::fabs(ts->sampleHeightAt(100, 100) - (h0 + 30.0f)) > 0.6f) { + * prefab, then flatten it back to the spawner's snapped height. + * The spawner position is in visual world space; fixup chunks are + * sampled in physical heightmap space, so the bump and the checks + * go through visualToPhysicalX/Z (see TerrainSystem). */ + const float physSpawnerX = ts->visualToPhysicalX(100.0f); + const float physSpawnerZ = ts->visualToPhysicalZ(100.0f); + float h0 = ts->sampleHeightAt((long)physSpawnerX, (long)physSpawnerZ); + ts->writeFixup(physSpawnerX, physSpawnerZ, h0 + 30.0f); + if (std::fabs(ts->sampleHeightAt((long)physSpawnerX, + (long)physSpawnerZ) - + (h0 + 30.0f)) > 0.6f) { Ogre::LogManager::getSingleton().logMessage( "TerrainTests: FAIL - fixup bump not readable before " "compliance test"); @@ -4263,7 +7462,8 @@ bool TerrainTestRunner::testTerrainPrefabSpawners(EditorApp &app, return false; } - float flat = ts->sampleHeightAt(100, 100); + float flat = ts->sampleHeightAt((long)physSpawnerX, + (long)physSpawnerZ); if (std::fabs(flat - spawnerY) > 0.6f) { Ogre::LogManager::getSingleton().logMessage( "TerrainTests: FAIL - terrain not flattened: height " + @@ -4274,6 +7474,24 @@ bool TerrainTestRunner::testTerrainPrefabSpawners(EditorApp &app, pumpFrames(app, ts, 1); return false; } + + /* Visual-space check: the rendered terrain at the spawner's + * visual position must be flat too (rebuild dirty pages first). */ + pumpFrames(app, ts, 3); + if (Ogre::TerrainGroup *tg = ts->getTerrainGroup()) { + float visFlat = tg->getHeightAtWorldPosition( + Ogre::Vector3(100.0f, 1000.0f, 100.0f)); + if (std::fabs(visFlat - spawnerY) > 0.6f) { + Ogre::LogManager::getSingleton().logMessage( + "TerrainTests: FAIL - visual terrain not flattened: " + + Ogre::StringConverter::toString(visFlat) + + ", expected ~" + + Ogre::StringConverter::toString(spawnerY)); + destroyTerrainEntity(app, terrain); + pumpFrames(app, ts, 1); + return false; + } + } if (ts->getFixupChunkCount() < 1) { Ogre::LogManager::getSingleton().logMessage( "TerrainTests: FAIL - no fixup chunks after compliance"); diff --git a/src/features/editScene/systems/TerrainTests.hpp b/src/features/editScene/systems/TerrainTests.hpp index 475e695..8cf82d6 100644 --- a/src/features/editScene/systems/TerrainTests.hpp +++ b/src/features/editScene/systems/TerrainTests.hpp @@ -80,9 +80,41 @@ private: static bool testFixupChunks(EditorApp &app, TerrainSystem *ts); static bool testRoadEdgeLengthConstraint(EditorApp &app, TerrainSystem *ts); static bool testTerrainCompliance(EditorApp &app, TerrainSystem *ts); + static bool testTerrainComplianceClearance(EditorApp &app, + TerrainSystem *ts); + static bool testComplyRoadsToTerrain(EditorApp &app, + TerrainSystem *ts); static bool testRoadColliderInteraction(EditorApp &app, TerrainSystem *ts); static bool testRoadSidePrefabs(EditorApp &app, TerrainSystem *ts); static bool testTerrainPrefabSpawners(EditorApp &app, TerrainSystem *ts); + static bool testStreamingProceduralBase(EditorApp &app, + TerrainSystem *ts); + static bool testFixupChunkLRU(EditorApp &app, TerrainSystem *ts); + static bool testGetHeightAtFarFallback(EditorApp &app, + TerrainSystem *ts); + static bool testStreamingSerialization(EditorApp &app, + TerrainSystem *ts); + static bool testStreamingWindowFollowsCamera(EditorApp &app, + TerrainSystem *ts); + static bool testStreamingWorldBounds(EditorApp &app, + TerrainSystem *ts); + static bool testStreamingSculptWritesFixups(EditorApp &app, + TerrainSystem *ts); + static bool testStreamingBlendMapsSurviveUnload(EditorApp &app, + TerrainSystem *ts); + static bool testStreamingAuxMapPerPage(EditorApp &app, + TerrainSystem *ts); + static bool testRenderOriginBasics(EditorApp &app, TerrainSystem *ts); + static bool testStreamingFarCorner(EditorApp &app, TerrainSystem *ts); + static bool testBookmarkRoundTrip(EditorApp &app, TerrainSystem *ts); + static bool testWorldMapTransforms(EditorApp &app, TerrainSystem *ts); + static bool testNavigationTeleport(EditorApp &app, TerrainSystem *ts); + static bool testSpawnerRegionStore(EditorApp &app, TerrainSystem *ts); + static bool testStreamedSpawnerWindow(EditorApp &app, TerrainSystem *ts); + static bool testPrefabJsonCache(EditorApp &app, TerrainSystem *ts); + static bool testRoadRegionStore(EditorApp &app, TerrainSystem *ts); + static bool testRoadRegionStreaming(EditorApp &app, TerrainSystem *ts); + static bool testRoadRegionMigration(EditorApp &app, TerrainSystem *ts); static bool testMemoryStability(EditorApp &app); static void logResult(const TerrainTestResult &r); diff --git a/src/features/editScene/systems/WorldMapData.hpp b/src/features/editScene/systems/WorldMapData.hpp new file mode 100644 index 0000000..316a8e2 --- /dev/null +++ b/src/features/editScene/systems/WorldMapData.hpp @@ -0,0 +1,171 @@ +#ifndef EDITSCENE_WORLDMAPDATA_HPP +#define EDITSCENE_WORLDMAPDATA_HPP +#pragma once + +#include "TerrainSystem.hpp" + +#include +#include +#include + +/** + * View state and base-height cache for the world map panel (M3.4). + * + * ImGui-free so headless terrain tests can verify the transforms. + * All coordinates are double WORLD space; the panel converts to + * canvas pixels through worldToMap()/mapToWorld(). + * + * Screen mapping: canvas +x = world +x, canvas +y (down) = world +z. + */ +class WorldMapData { +public: + /* World position shown at the canvas centre. */ + double centerX = 0.0; + double centerZ = 0.0; + /* Zoom level in world units per canvas pixel. */ + double metersPerPixel = 1000.0; + + static constexpr double MIN_METERS_PER_PIXEL = 0.25; + static constexpr double MAX_METERS_PER_PIXEL = 1000000.0; + + void worldToMap(double wx, double wz, double canvasW, double canvasH, + float &outX, float &outY) const + { + outX = (float)(canvasW * 0.5 + + (wx - centerX) / metersPerPixel); + outY = (float)(canvasH * 0.5 + + (wz - centerZ) / metersPerPixel); + } + + void mapToWorld(double mx, double my, double canvasW, double canvasH, + double &outX, double &outZ) const + { + outX = centerX + (mx - canvasW * 0.5) * metersPerPixel; + outZ = centerZ + (my - canvasH * 0.5) * metersPerPixel; + } + + void pan(double dxPixels, double dyPixels) + { + centerX -= dxPixels * metersPerPixel; + centerZ -= dyPixels * metersPerPixel; + } + + /* Zoom keeping the world point under canvas pixel (mx, my) + * fixed. Positive wheelY zooms in. */ + void zoomAt(double mx, double my, double canvasW, double canvasH, + double wheelY) + { + if (wheelY == 0.0) + return; + double wx, wz; + mapToWorld(mx, my, canvasW, canvasH, wx, wz); + metersPerPixel *= std::pow(0.8, wheelY); + metersPerPixel = std::max(MIN_METERS_PER_PIXEL, + std::min(MAX_METERS_PER_PIXEL, + metersPerPixel)); + centerX = wx - (mx - canvasW * 0.5) * metersPerPixel; + centerZ = wz - (my - canvasH * 0.5) * metersPerPixel; + } + + /* Fit the whole streamed world into the canvas. */ + void fitWorld(TerrainSystem *ts, double canvasW, double canvasH) + { + if (!ts || !ts->getTerrainGroup()) + return; + const double ws = + ts->getTerrainGroup()->getTerrainWorldSize(); + const long pages = ts->getStreamingPageCount(); + /* World centre of page 0 sits at the world origin (see + * physicalToVisualX/Z); the last page centre is + * (pages-1)*ws further out on both axes. */ + const double x0 = ts->physicalToVisualX(0.5 * ws); + const double z0 = ts->physicalToVisualZ(0.5 * ws); + centerX = x0 + (double)(pages - 1) * 0.5 * ws; + centerZ = z0 + (double)(pages - 1) * 0.5 * ws; + const double span = ws * (double)pages; + const double dim = std::min(canvasW, canvasH); + if (dim > 0.0) + metersPerPixel = span / dim; + metersPerPixel = std::max(MIN_METERS_PER_PIXEL, + std::min(MAX_METERS_PER_PIXEL, + metersPerPixel)); + } + + /* Coarse base-height cache over the currently visible rect, + * rebuilt on demand (view change or explicit refresh). Row-major + * m_gridRes^2 entries; cell (ix, iy) maps back to canvas pixels + * as rect [ix*canvasW/res, (ix+1)*canvasW/res) x [iy*canvasH/res, + * (iy+1)*canvasH/res). */ + void rebuildHeightCache(TerrainSystem *ts, double canvasW, + double canvasH, int gridRes) + { + m_gridRes = std::max(8, std::min(256, gridRes)); + m_heights.clear(); + m_minHeight = 0.0f; + m_maxHeight = 0.0f; + if (!ts || !ts->getTerrainGroup() || canvasW <= 0.0 || + canvasH <= 0.0) + return; + + const double spanX = canvasW * metersPerPixel; + const double spanZ = canvasH * metersPerPixel; + const double minX = centerX - spanX * 0.5; + const double minZ = centerZ - spanZ * 0.5; + const int res = m_gridRes; + m_heights.resize((size_t)res * res); + + float lo = 0.0f, hi = 0.0f; + for (int iy = 0; iy < res; ++iy) { + const double wz = + minZ + ((double)iy + 0.5) * spanZ / res; + const long pz = + (long)std::floor(ts->visualToPhysicalZ(wz)); + for (int ix = 0; ix < res; ++ix) { + const double wx = minX + ((double)ix + 0.5) * + spanX / res; + const long px = (long)std::floor( + ts->visualToPhysicalX(wx)); + const float h = ts->sampleBaseHeightAt(px, pz); + m_heights[(size_t)iy * res + ix] = h; + if (ix == 0 && iy == 0) { + lo = h; + hi = h; + } else { + lo = std::min(lo, h); + hi = std::max(hi, h); + } + } + } + m_minHeight = lo; + m_maxHeight = hi; + } + + bool hasCache() const + { + return !m_heights.empty(); + } + int getGridRes() const + { + return m_gridRes; + } + float getCachedHeight(int ix, int iy) const + { + return m_heights[(size_t)iy * m_gridRes + ix]; + } + float getCacheMinHeight() const + { + return m_minHeight; + } + float getCacheMaxHeight() const + { + return m_maxHeight; + } + +private: + int m_gridRes = 64; + std::vector m_heights; + float m_minHeight = 0.0f; + float m_maxHeight = 0.0f; +}; + +#endif // EDITSCENE_WORLDMAPDATA_HPP diff --git a/src/features/editScene/ui/NavigationPanel.hpp b/src/features/editScene/ui/NavigationPanel.hpp new file mode 100644 index 0000000..47fd1be --- /dev/null +++ b/src/features/editScene/ui/NavigationPanel.hpp @@ -0,0 +1,223 @@ +#ifndef EDITSCENE_NAVIGATIONPANEL_HPP +#define EDITSCENE_NAVIGATIONPANEL_HPP +#pragma once + +#include "WorldBookmark.hpp" +#include "../camera/EditorCamera.hpp" +#include "../systems/RenderOriginSystem.hpp" +#include "../systems/TerrainSystem.hpp" + +#include +#include +#include + +/** + * Navigation panel (M3): teleport the editor camera anywhere in the + * streamed world by world coordinates or terrain page index, and + * manage named world bookmarks. + * + * All teleport math is done in double WORLD space and converted + * through RenderOriginSystem, so jumps to the far corner of a + * 40,000,000 x 40,000,000 world stay exact. + * + * The teleport helpers are static and ImGui-free so headless tests + * and the TerrainEditor "Navigation" section can share the same + * backend. When no EditorCamera is supplied they move the + * "EditorCameraTarget" scene node directly. + */ +class NavigationPanel { +public: + void setEditorCamera(EditorCamera *cam) + { + m_editorCamera = cam; + } + + const std::vector &getBookmarks() const + { + return m_bookmarks; + } + + void setBookmarks(const std::vector &bookmarks) + { + m_bookmarks = bookmarks; + } + + void addBookmark(const std::string &name, double x, double y, + double z) + { + WorldBookmark b; + b.name = name.empty() ? "Bookmark" : name; + b.x = x; + b.y = y; + b.z = z; + m_bookmarks.push_back(b); + } + + /* Move the editor camera (or, headless / no camera, the + * "EditorCameraTarget" node) so its orbit target sits at world + * (wx, wy, wz), then snap it above the terrain. */ + static void teleportCamera(double wx, double wy, double wz, + EditorCamera *cam) + { + TerrainSystem *ts = TerrainSystem::getInstance(); + RenderOriginSystem *ro = RenderOriginSystem::getInstance(); + if (!ts) + return; + + Ogre::Vector3 rp; + if (ro) + rp = ro->worldToRender(wx, wy, wz); + else + rp = ts->worldToRender(wx, wy, wz); + + if (cam) { + cam->setPosition(rp); + } else if (ts->getSceneManager()) { + Ogre::SceneManager *sm = ts->getSceneManager(); + Ogre::SceneNode *node = nullptr; + if (sm->hasSceneNode("EditorCameraTarget")) + node = sm->getSceneNode("EditorCameraTarget"); + else if (sm->hasSceneNode("EditorCameraTargetNode")) + node = sm->getSceneNode( + "EditorCameraTargetNode"); + if (node) + node->setPosition(rp); + } + ts->snapCameraAboveTerrain(); + } + + /* Teleport to the centre of physical terrain page (px, pz), + * clamped to [0, pages-1]. Y is snapped above the terrain. */ + static void teleportToPage(long px, long pz, EditorCamera *cam) + { + TerrainSystem *ts = TerrainSystem::getInstance(); + if (!ts || !ts->getTerrainGroup()) + return; + long pages = ts->getStreamingPageCount(); + if (pages < 1) + pages = 1; + if (px < 0) + px = 0; + if (pz < 0) + pz = 0; + if (px > pages - 1) + px = pages - 1; + if (pz > pages - 1) + pz = pages - 1; + const double ws = ts->getTerrainGroup()->getTerrainWorldSize(); + const double wx = + ts->physicalToVisualX(((double)px + 0.5) * ws); + const double wz = + ts->physicalToVisualZ(((double)pz + 0.5) * ws); + teleportCamera(wx, 0.0, wz, cam); + } + + void render(bool *open) + { + TerrainSystem *ts = TerrainSystem::getInstance(); + + if (!ImGui::Begin("Navigation", open)) { + ImGui::End(); + return; + } + + if (ImGui::CollapsingHeader("Teleport", + ImGuiTreeNodeFlags_DefaultOpen)) { + renderTeleportSection(ts); + } + + if (ImGui::CollapsingHeader("Bookmarks", + ImGuiTreeNodeFlags_DefaultOpen)) { + renderBookmarkSection(); + } + + ImGui::End(); + } + +private: + void readCameraPosition() + { + TerrainSystem *ts = TerrainSystem::getInstance(); + if (!ts) + return; + Ogre::Vector3 p(0, 0, 0); + if (m_editorCamera) { + p = m_editorCamera->getTarget(); + } else if (ts->getSceneManager()) { + Ogre::SceneManager *sm = ts->getSceneManager(); + if (sm->hasSceneNode("EditorCameraTarget")) + p = sm->getSceneNode("EditorCameraTarget") + ->getPosition(); + } + m_teleX = ts->renderToWorldX(p.x); + m_teleY = ts->renderToWorldY(p.y); + m_teleZ = ts->renderToWorldZ(p.z); + } + + void renderTeleportSection(TerrainSystem *ts) + { + ImGui::InputDouble("X", &m_teleX, 100.0, 10000.0, "%.1f"); + ImGui::InputDouble("Y", &m_teleY, 10.0, 1000.0, "%.1f"); + ImGui::InputDouble("Z", &m_teleZ, 100.0, 10000.0, "%.1f"); + if (ImGui::Button("Teleport")) + teleportCamera(m_teleX, m_teleY, m_teleZ, + m_editorCamera); + ImGui::SameLine(); + if (ImGui::Button("Current Position")) + readCameraPosition(); + + if (ts && ts->getStreamingActive()) { + ImGui::Separator(); + long cpx, cpz; + ts->streamingCameraPage(&cpx, &cpz); + ImGui::Text("Camera page: %ld, %ld (of %ld x %ld)", + cpx, cpz, ts->getStreamingPageCount(), + ts->getStreamingPageCount()); + ImGui::InputInt("Page X", &m_pageX); + ImGui::InputInt("Page Z", &m_pageZ); + if (ImGui::Button("Teleport To Page")) + teleportToPage(m_pageX, m_pageZ, + m_editorCamera); + } + } + + void renderBookmarkSection() + { + ImGui::InputText("Name", m_bookmarkName, + sizeof(m_bookmarkName)); + ImGui::SameLine(); + if (ImGui::Button("Add##bookmark")) { + readCameraPosition(); + addBookmark(m_bookmarkName, m_teleX, m_teleY, m_teleZ); + m_bookmarkName[0] = '\0'; + } + + for (size_t i = 0; i < m_bookmarks.size(); ++i) { + const WorldBookmark &b = m_bookmarks[i]; + ImGui::PushID((int)i); + if (ImGui::Button("Go")) + teleportCamera(b.x, b.y, b.z, m_editorCamera); + ImGui::SameLine(); + if (ImGui::Button("Del")) { + m_bookmarks.erase(m_bookmarks.begin() + i); + ImGui::PopID(); + break; + } + ImGui::SameLine(); + ImGui::Text("%s (%.0f, %.0f, %.0f)", b.name.c_str(), + b.x, b.y, b.z); + ImGui::PopID(); + } + } + + EditorCamera *m_editorCamera = nullptr; + double m_teleX = 0.0; + double m_teleY = 100.0; + double m_teleZ = 0.0; + int m_pageX = 0; + int m_pageZ = 0; + char m_bookmarkName[128] = ""; + std::vector m_bookmarks; +}; + +#endif // EDITSCENE_NAVIGATIONPANEL_HPP diff --git a/src/features/editScene/ui/TerrainEditor.hpp b/src/features/editScene/ui/TerrainEditor.hpp index 966030c..ccadb89 100644 --- a/src/features/editScene/ui/TerrainEditor.hpp +++ b/src/features/editScene/ui/TerrainEditor.hpp @@ -10,6 +10,7 @@ #include "../systems/RoadSystem.hpp" #include "../systems/TerrainPrefabSpawnerSystem.hpp" #include "../systems/PrefabSystem.hpp" +#include "NavigationPanel.hpp" #include #include @@ -298,6 +299,35 @@ public: ImGui::Separator(); + /* --- Navigation (M3): teleport by world coords or page, + * sharing the NavigationPanel backend. The full panel + * (with bookmarks) lives under Tools -> Navigation. --- */ + if (ts && ImGui::CollapsingHeader("Navigation")) { + ImGui::InputDouble("Nav X", &m_navX, 100.0, 10000.0, + "%.1f"); + ImGui::InputDouble("Nav Z", &m_navZ, 100.0, 10000.0, + "%.1f"); + if (ImGui::Button("Teleport Here (Y snapped)")) + NavigationPanel::teleportCamera(m_navX, 0.0, + m_navZ, + nullptr); + if (ts->getStreamingActive()) { + long cpx, cpz; + ts->streamingCameraPage(&cpx, &cpz); + ImGui::Text("Camera page: %ld, %ld (of %ld)", + cpx, cpz, + ts->getStreamingPageCount()); + ImGui::InputInt("Page X", &m_navPageX); + ImGui::InputInt("Page Z", &m_navPageZ); + if (ImGui::Button("Teleport To Page")) + NavigationPanel::teleportToPage( + m_navPageX, m_navPageZ, + nullptr); + } + } + + ImGui::Separator(); + /* --- Camera & file operations --- */ if (ts) { if (ImGui::Button("Snap Camera Above Terrain")) @@ -497,6 +527,12 @@ public: } private: + /* Navigation section state (M3). */ + double m_navX = 0.0; + double m_navZ = 0.0; + int m_navPageX = 0; + int m_navPageZ = 0; + /* Scan the prefabs directory once (or on Refresh) for the placement * prefab picker. */ static void scanPrefabFiles(std::vector &out) @@ -822,6 +858,22 @@ private: "Makes the terrain surface follow the road underside " "through fixup height-override chunks."); + /* Inverse operation: move road nodes vertically so the + * road edges stay above the terrain plus the slab + * thickness and a small margin. */ + if (ImGui::Button("Comply Roads to Terrain")) { + RoadSystem *rs = ts->getRoadSystem(); + if (rs) + rs->complyRoadsToTerrain( + ts, + tc.roadGraph.config.roadThickness + + 0.05f); + } + if (ImGui::IsItemHovered()) + ImGui::SetTooltip( + "Shifts road nodes up/down so road edges stay above " + "the terrain surface plus the road thickness."); + /* Clear all fixup chunks (M5.9.5). */ if (ImGui::Button("Clear All Fixups")) { ts->clearAllFixups(); diff --git a/src/features/editScene/ui/WorldBookmark.hpp b/src/features/editScene/ui/WorldBookmark.hpp new file mode 100644 index 0000000..810f9e8 --- /dev/null +++ b/src/features/editScene/ui/WorldBookmark.hpp @@ -0,0 +1,22 @@ +#ifndef EDITSCENE_WORLDBOOKMARK_HPP +#define EDITSCENE_WORLDBOOKMARK_HPP +#pragma once + +#include + +/** + * World bookmark: a named absolute WORLD-space camera position. + * + * Persisted in the scene JSON ("bookmarks" array) so editors can + * jump back to points of interest across sessions. Kept in its own + * header (no ImGui) so SceneSerializer can store them without + * pulling in UI dependencies. + */ +struct WorldBookmark { + std::string name; + double x = 0.0; + double y = 0.0; + double z = 0.0; +}; + +#endif // EDITSCENE_WORLDBOOKMARK_HPP diff --git a/src/features/editScene/ui/WorldMapPanel.hpp b/src/features/editScene/ui/WorldMapPanel.hpp new file mode 100644 index 0000000..c1218be --- /dev/null +++ b/src/features/editScene/ui/WorldMapPanel.hpp @@ -0,0 +1,418 @@ +#ifndef EDITSCENE_WORLDMAPPANEL_HPP +#define EDITSCENE_WORLDMAPPANEL_HPP +#pragma once + +#include "NavigationPanel.hpp" +#include "../systems/WorldMapData.hpp" +#include "../systems/RoadSystem.hpp" +#include "../components/Terrain.hpp" +#include "../components/TerrainPrefabSpawner.hpp" +#include "../components/Transform.hpp" + +#include +#include +#include + +/** + * World map panel (M3.4): a 2D top-down map of the streamed world. + * + * Data-driven (no render-to-texture): coarse base-height shading is + * sampled from TerrainSystem into WorldMapData's cache, and overlays + * (loaded pages, roads, prefab spawners, bookmarks, camera marker) + * are drawn as ImGui primitives. Interactions: left-drag pans, + * mouse wheel zooms about the cursor, double-click teleports the + * editor camera through the NavigationPanel backend. + * + * All math is double WORLD space via WorldMapData, so the map stays + * exact at the far corners of a 40,000,000 x 40,000,000 world. + */ +class WorldMapPanel { +public: + void setEditorCamera(EditorCamera *cam) + { + m_editorCamera = cam; + } + void setWorld(flecs::world *world) + { + m_world = world; + } + /* Points at NavigationPanel's bookmark list (stays valid; the + * list is a stable member of EditorUISystem's panel). */ + void setBookmarks(const std::vector *bookmarks) + { + m_bookmarks = bookmarks; + } + + WorldMapData &getData() + { + return m_data; + } + + void render(bool *open) + { + TerrainSystem *ts = TerrainSystem::getInstance(); + + if (!ImGui::Begin("World Map", open)) { + ImGui::End(); + return; + } + + renderToolbar(ts); + + float canvasW = std::max(200.0f, ImGui::GetContentRegionAvail().x); + float canvasH = std::max(200.0f, std::min(canvasW, 1024.0f)); + canvasW = canvasH; + ImVec2 origin = ImGui::GetCursorScreenPos(); + ImGui::InvisibleButton("##worldmapcanvas", + ImVec2(canvasW, canvasH)); + const bool hovered = ImGui::IsItemHovered(); + ImDrawList *dl = ImGui::GetWindowDrawList(); + + if (m_heightsDirty && m_showHeights) { + m_data.rebuildHeightCache(ts, canvasW, canvasH, + m_gridRes); + m_heightsDirty = false; + } + + dl->AddRectFilled(origin, + ImVec2(origin.x + canvasW, origin.y + canvasH), + IM_COL32(12, 12, 16, 255)); + if (m_showHeights) + drawHeights(dl, origin, canvasW, canvasH); + if (m_showPages) + drawLoadedPages(dl, ts, origin, canvasW, canvasH); + if (m_showRoads) + drawRoads(dl, ts, origin, canvasW, canvasH); + if (m_showSpawners) + drawSpawners(dl, ts, origin, canvasW, canvasH); + if (m_showBookmarks) + drawBookmarks(dl, origin, canvasW, canvasH); + drawCameraMarker(dl, ts, origin, canvasW, canvasH); + + handleInput(origin, canvasW, canvasH, hovered); + + ImGui::End(); + } + +private: + void renderToolbar(TerrainSystem *ts) + { + if (ImGui::Button("Center On Camera")) + centerOnCamera(ts); + ImGui::SameLine(); + if (ImGui::Button("Fit World")) { + m_data.fitWorld(ts, 512.0, 512.0); + m_heightsDirty = true; + } + ImGui::SameLine(); + if (ImGui::Button("Refresh Heights")) + m_heightsDirty = true; + + ImGui::Checkbox("Heights", &m_showHeights); + ImGui::SameLine(); + ImGui::Checkbox("Pages", &m_showPages); + ImGui::SameLine(); + ImGui::Checkbox("Roads", &m_showRoads); + ImGui::SameLine(); + ImGui::Checkbox("Spawners", &m_showSpawners); + ImGui::SameLine(); + ImGui::Checkbox("Bookmarks", &m_showBookmarks); + + if (ImGui::SliderInt("Height Detail", &m_gridRes, 16, 128)) + m_heightsDirty = true; + + ImGui::Text("Zoom: %.1f u/px Centre: %.0f, %.0f", + m_data.metersPerPixel, m_data.centerX, + m_data.centerZ); + ImGui::TextDisabled( + "Drag: pan Wheel: zoom Double-click: teleport"); + } + + void handleInput(const ImVec2 &origin, float canvasW, float canvasH, + bool hovered) + { + ImGuiIO &io = ImGui::GetIO(); + + if (ImGui::IsItemActive() && + ImGui::IsMouseDragging(ImGuiMouseButton_Left)) { + m_data.pan(io.MouseDelta.x, io.MouseDelta.y); + m_heightsDirty = true; + } + if (hovered && io.MouseWheel != 0.0f) { + m_data.zoomAt(io.MousePos.x - origin.x, + io.MousePos.y - origin.y, canvasW, + canvasH, io.MouseWheel); + m_heightsDirty = true; + } + if (hovered && + ImGui::IsMouseDoubleClicked(ImGuiMouseButton_Left)) { + double wx, wz; + m_data.mapToWorld(io.MousePos.x - origin.x, + io.MousePos.y - origin.y, canvasW, + canvasH, wx, wz); + NavigationPanel::teleportCamera(wx, 0.0, wz, + m_editorCamera); + } + } + + bool getCameraWorld(double &wx, double &wy, double &wz) const + { + TerrainSystem *ts = TerrainSystem::getInstance(); + if (!ts) + return false; + Ogre::Vector3 p(0, 0, 0); + if (m_editorCamera) { + p = m_editorCamera->getTarget(); + } else if (ts->getSceneManager() && + ts->getSceneManager()->hasSceneNode( + "EditorCameraTarget")) { + p = ts->getSceneManager() + ->getSceneNode("EditorCameraTarget") + ->getPosition(); + } + wx = ts->renderToWorldX(p.x); + wy = ts->renderToWorldY(p.y); + wz = ts->renderToWorldZ(p.z); + return true; + } + + void centerOnCamera(TerrainSystem *ts) + { + (void)ts; + double wx, wy, wz; + if (!getCameraWorld(wx, wy, wz)) + return; + m_data.centerX = wx; + m_data.centerZ = wz; + m_heightsDirty = true; + } + + static ImU32 heightColor(float t) + { + /* deep water -> water -> grass -> rock -> snow */ + struct Stop { + float t; + int r, g, b; + }; + static const Stop stops[] = { + { 0.0f, 20, 40, 90 }, { 0.25f, 50, 90, 140 }, + { 0.4f, 80, 140, 70 }, { 0.7f, 120, 110, 90 }, + { 1.0f, 240, 240, 245 }, + }; + t = std::max(0.0f, std::min(1.0f, t)); + for (size_t i = 1; i < sizeof(stops) / sizeof(stops[0]); ++i) { + if (t > stops[i].t) + continue; + const Stop &a = stops[i - 1]; + const Stop &b = stops[i]; + const float f = (t - a.t) / (b.t - a.t); + const int r = (int)(a.r + f * (b.r - a.r)); + const int g = (int)(a.g + f * (b.g - a.g)); + const int bl = (int)(a.b + f * (b.b - a.b)); + return IM_COL32(r, g, bl, 255); + } + return IM_COL32(240, 240, 245, 255); + } + + void drawHeights(ImDrawList *dl, const ImVec2 &origin, float canvasW, + float canvasH) const + { + if (!m_data.hasCache()) + return; + const int res = m_data.getGridRes(); + const float lo = m_data.getCacheMinHeight(); + const float hi = m_data.getCacheMaxHeight(); + const float span = (hi > lo) ? (hi - lo) : 1.0f; + const float cellW = canvasW / res; + const float cellH = canvasH / res; + for (int iy = 0; iy < res; ++iy) { + for (int ix = 0; ix < res; ++ix) { + const float t = + (m_data.getCachedHeight(ix, iy) - lo) / + span; + dl->AddRectFilled( + ImVec2(origin.x + ix * cellW, + origin.y + iy * cellH), + ImVec2(origin.x + (ix + 1) * cellW + 1, + origin.y + (iy + 1) * cellH + 1), + heightColor(t)); + } + } + } + + void drawLoadedPages(ImDrawList *dl, TerrainSystem *ts, + const ImVec2 &origin, float canvasW, + float canvasH) const + { + if (!ts || !ts->getStreamingActive() || !ts->getTerrainGroup()) + return; + const double ws = ts->getTerrainGroup()->getTerrainWorldSize(); + const long pages = ts->getStreamingPageCount(); + + double minWx, minWz, maxWx, maxWz; + m_data.mapToWorld(0.0, 0.0, canvasW, canvasH, minWx, minWz); + m_data.mapToWorld(canvasW, canvasH, canvasW, canvasH, maxWx, + maxWz); + + auto clampPage = [pages](long p) { + return std::max(0L, std::min(pages - 1, p)); + }; + const long px0 = clampPage((long)std::floor( + ts->visualToPhysicalX(minWx) / ws)); + const long px1 = clampPage((long)std::floor( + ts->visualToPhysicalX(maxWx) / ws)); + const long pz0 = clampPage((long)std::floor( + ts->visualToPhysicalZ(minWz) / ws)); + const long pz1 = clampPage((long)std::floor( + ts->visualToPhysicalZ(maxWz) / ws)); + + /* Too many pages on screen: the outlines would be noise. */ + if (px1 - px0 > 256 || pz1 - pz0 > 256) + return; + + for (long pz = pz0; pz <= pz1; ++pz) { + for (long px = px0; px <= px1; ++px) { + if (!ts->isPageLoaded(px, pz)) + continue; + const double wxa = + ts->physicalToVisualX((double)px * ws); + const double wxb = ts->physicalToVisualX( + (double)(px + 1) * ws); + const double wza = + ts->physicalToVisualZ((double)pz * ws); + const double wzb = ts->physicalToVisualZ( + (double)(pz + 1) * ws); + float ax, ay, bx, by; + m_data.worldToMap(std::min(wxa, wxb), + std::min(wza, wzb), canvasW, + canvasH, ax, ay); + m_data.worldToMap(std::max(wxa, wxb), + std::max(wza, wzb), canvasW, + canvasH, bx, by); + dl->AddRect(ImVec2(origin.x + ax, origin.y + ay), + ImVec2(origin.x + bx, origin.y + by), + IM_COL32(60, 220, 60, 160)); + } + } + } + + void drawRoads(ImDrawList *dl, TerrainSystem *ts, const ImVec2 &origin, + float canvasW, float canvasH) const + { + if (!ts) + return; + RoadSystem *rs = ts->getRoadSystem(); + if (!rs) + return; + flecs::entity te = rs->getTerrainEntity(); + if (!te.is_alive() || !te.has()) + return; + const TerrainComponent &tc = te.get(); + const RoadGraph &g = tc.roadGraph; + + /* Road node positions are RENDER space (terrain raycast + * hits); convert to world for the map. */ + for (const RoadEdge &e : g.edges) { + const RoadNode *na = g.findNodeById(e.nodeA); + const RoadNode *nb = g.findNodeById(e.nodeB); + if (!na || !nb) + continue; + float ax, ay, bx, by; + m_data.worldToMap(ts->renderToWorldX(na->position.x), + ts->renderToWorldZ(na->position.z), + canvasW, canvasH, ax, ay); + m_data.worldToMap(ts->renderToWorldX(nb->position.x), + ts->renderToWorldZ(nb->position.z), + canvasW, canvasH, bx, by); + dl->AddLine(ImVec2(origin.x + ax, origin.y + ay), + ImVec2(origin.x + bx, origin.y + by), + IM_COL32(220, 220, 230, 255), 2.0f); + } + for (const RoadNode &n : g.nodes) { + float mx, my; + m_data.worldToMap(ts->renderToWorldX(n.position.x), + ts->renderToWorldZ(n.position.z), + canvasW, canvasH, mx, my); + dl->AddCircleFilled(ImVec2(origin.x + mx, origin.y + my), + 3.0f, IM_COL32(255, 120, 120, 255)); + } + } + + void drawSpawners(ImDrawList *dl, TerrainSystem *ts, + const ImVec2 &origin, float canvasW, + float canvasH) const + { + if (!m_world || !ts) + return; + m_world->query_builder() + .build() + .each([&](TerrainPrefabSpawnerComponent &sp, + TransformComponent &t) { + (void)sp; + const double wx = + t.hasWorldPosition ? + t.worldX : + ts->renderToWorldX(t.position.x); + const double wz = + t.hasWorldPosition ? + t.worldZ : + ts->renderToWorldZ(t.position.z); + float mx, my; + m_data.worldToMap(wx, wz, canvasW, canvasH, mx, + my); + dl->AddCircleFilled( + ImVec2(origin.x + mx, origin.y + my), + 4.0f, IM_COL32(255, 170, 40, 255)); + }); + } + + void drawBookmarks(ImDrawList *dl, const ImVec2 &origin, float canvasW, + float canvasH) const + { + if (!m_bookmarks) + return; + for (const WorldBookmark &b : *m_bookmarks) { + float mx, my; + m_data.worldToMap(b.x, b.z, canvasW, canvasH, mx, my); + const ImVec2 c(origin.x + mx, origin.y + my); + dl->AddCircleFilled(c, 4.0f, + IM_COL32(255, 230, 60, 255)); + dl->AddText(ImVec2(c.x + 6.0f, c.y - 6.0f), + IM_COL32(255, 230, 60, 255), + b.name.c_str()); + } + } + + void drawCameraMarker(ImDrawList *dl, TerrainSystem *ts, + const ImVec2 &origin, float canvasW, + float canvasH) const + { + (void)ts; + double wx, wy, wz; + if (!getCameraWorld(wx, wy, wz)) + return; + float mx, my; + m_data.worldToMap(wx, wz, canvasW, canvasH, mx, my); + const ImVec2 c(origin.x + mx, origin.y + my); + dl->AddCircle(c, 7.0f, IM_COL32(255, 60, 60, 255), 0, 2.0f); + dl->AddLine(ImVec2(c.x - 10.0f, c.y), ImVec2(c.x + 10.0f, c.y), + IM_COL32(255, 60, 60, 255), 1.5f); + dl->AddLine(ImVec2(c.x, c.y - 10.0f), ImVec2(c.x, c.y + 10.0f), + IM_COL32(255, 60, 60, 255), 1.5f); + } + + EditorCamera *m_editorCamera = nullptr; + flecs::world *m_world = nullptr; + const std::vector *m_bookmarks = nullptr; + WorldMapData m_data; + bool m_heightsDirty = true; + bool m_showHeights = true; + bool m_showPages = true; + bool m_showRoads = true; + bool m_showSpawners = true; + bool m_showBookmarks = true; + int m_gridRes = 64; +}; + +#endif // EDITSCENE_WORLDMAPPANEL_HPP