#Terrain System Implementation Plan

## Goal
Add a robust, editor-friendly `Ogre::Terrain`-based terrain feature to the
`editScene` editor/game executable using the current ECS architecture in
`src/features/editScene`.

## Non-Goals (for this plan)
- Replacing the legacy `src/gamedata/TerrainModule.cpp` (it is kept as
  reference only).
- Multi-player networking.
- Runtime terrain streaming on a separate thread outside Ogre's work queue.

## High-Level Architecture

```
┌─────────────────────────────────────────────────────────────────────────────┐
│  editScene executable                                                       │
│                                                                             │
│  ┌────────────────────┐     ┌────────────────────┐     ┌─────────────────┐ │
│  │ TerrainComponent   │────▶│ TerrainSystem      │────▶│ Ogre::Terrain   │ │
│  │ (parameters only)  │     │ (singleton owner)  │     │ Ogre::TerrainGroup│
│  └────────────────────┘     └────────────────────┘     │ Ogre::TerrainPaging│
│           │                           │                │ Ogre::PageManager │
│           │                           ▼                └─────────────────┘ │
│           │                  ┌────────────────────┐                        │
│           │                  │ CustomTerrainDefiner                        │ │
│           │                  │  - height function f(int XZ) -> float       │ │
│           │                  │  - 256x256 base heightmap                   │ │
│           │                  │  - sparse 256x256 fixup chunks              │ │
│           │                  └────────────────────┘                        │
│           │                           │                                     │
│           │                           ▼                                     │
│           │                  ┌────────────────────┐                        │
│           │                  │ ZigzagHeightfieldShape                      │ │
│           │                  │  - Jolt custom collision shape              │ │
│           │                  │  - matches Ogre::Terrain row zigzag         │ │
│           │                  └────────────────────┘                        │
│           │                                                                │
│           ▼                                                                │
│  ┌────────────────────┐                                                   │
│  │ TerrainEditor      │  - heightmap brushes                              │
│  │ (ImGui panel)      │  - road node graph add/split/join/select          │
│  │                    │  - terrain restart for size changes               │
│  └────────────────────┘                                                   │
└─────────────────────────────────────────────────────────────────────────────┘
```

## Files to Create / Modify

| Type | Path | Purpose |
|------|------|---------|
| Component | `src/features/editScene/components/Terrain.hpp` | Parameters-only component |
| Module | `src/features/editScene/components/TerrainModule.cpp` | ECS component registration |
| Editor | `src/features/editScene/ui/TerrainEditor.hpp` | ImGui property/editor panel |
| Editor | `src/features/editScene/ui/TerrainEditor.cpp` | ImGui property/editor panel impl |
| System | `src/features/editScene/systems/TerrainSystem.hpp` | System declaration |
| System | `src/features/editScene/systems/TerrainSystem.cpp` | System implementation |
| Physics | `src/features/editScene/physics/ZigzagHeightfieldShape.hpp` | Custom Jolt shape header |
| Physics | `src/features/editScene/physics/ZigzagHeightfieldShape.cpp` | Custom Jolt shape implementation |
| App wiring | `src/features/editScene/EditorApp.hpp` | Forward declaration + unique_ptr member |
| App wiring | `src/features/editScene/EditorApp.cpp` | setupECS, setup, frameRenderingQueued, destructor |
| Serialization | `src/features/editScene/systems/SceneSerializer.hpp` | Declare serializeTerrain/deserializeTerrain |
| Serialization | `src/features/editScene/systems/SceneSerializer.cpp` | Save/load terrain data + include |
| Build | `src/features/editScene/CMakeLists.txt` | Add files + `OgrePaging`/`OgreTerrain` links |

## 1. TerrainComponent (parameters only)

**Design**: This is a **singleton component** — exactly one entity in the scene
should carry it. Ogre only supports one `TerrainGroup` per `SceneManager`, so
multi-instance terrain makes no sense. The component holds only serializable
parameters and a transient `dirty` flag. All runtime Ogre/Jolt objects live in
`TerrainSystem`.

```cpp
struct TerrainComponent {
	bool enabled = true;

	// Page/tile vertex resolution. Must be 2^n+1 (e.g. 33, 65, 129, 257).
	// Ogre supports up to 4097, but practical limits depend on GPU memory.
	int terrainSize = 65;

	// Stable unique ID for this terrain — generated once at component creation,
	// serialized, and never changed.  Used as the directory key for all binary
	// terrain data (heightmap, fixups, aux maps).  Default 0 means "not yet
	// assigned" — the editor generates one on first save or creation.
	uint64_t terrainId = 0;

	// Base binary heightmap resolution. Default 256x256.
	int heightmapSize = 256;

	// World-space size of one Ogre terrain page in units.
	// For a single-page test scene, 2000-4000 is practical (terrainSize=65
	// gives ~31-62 units per vertex).  The 40M legacy value assumes enormous
	// world coordinates; scale to your scene's coordinate system.
	float worldSize = 2000.0f;

	// Maximum geometric error in pixels before a LOD tile splits.
	float maxPixelError = 1.0f;

	// Distance at which the cheap composite map is used.
	float compositeMapDistance = 300.0f;

	// Minimum and maximum batch LOD sizes. Must be 2^n+1 and <= terrainSize.
	int minBatchSize = 17;
	int maxBatchSize = 65;

	// Base heightmap binary file path (project-relative or absolute).
	std::string heightmapFile = "heightmap.bin";

	// Layer texture settings (diffuse+normal pairs).
	// These are loaded via Ogre resource groups and fed into
	// TerrainMaterialGeneratorA (RTShader terrain materials).
	struct Layer {
		std::string diffuseTexture;
		std::string normalTexture;
		float worldSize = 100.0f;
	};
	std::vector<Layer> layers;

	// Road network data (node/edge graph).
	struct RoadNode {
		Ogre::Vector3 position; // default: terrain surface + offset
		float verticalOffset = 0.0f;
		int id = 0;
	};
	struct RoadEdge {
		int nodeA = -1;
		int nodeB = -1;
		float roadLevelA = 0.0f;
		float roadLevelB = 0.0f;
	};
	std::vector<RoadNode> roadNodes;
	std::vector<RoadEdge> roadEdges;

	// Prefab spawn points on the terrain managed by TerrainPrefabSpawnerComponent
	// (not embedded here — see section 6).

	// Runtime-only: managed by TerrainSystem. Not serialized.
	bool dirty = true;
	bool rebuildInProgress = false;

	void markDirty()
	{
		dirty = true;
	}
};
```

Rules:
- Only serializable parameters and a transient `dirty` flag live here.
- `terrainSize`, `heightmapSize`, `worldSize` changes require a full terrain
  restart in editor mode.
- Runtime objects (`Ogre::Terrain*`, Jolt bodies, scene nodes) must live in
  `TerrainSystem`, not in the component.
- The component must be registered in `EditorApp::setupECS()` with
  `m_world.component<TerrainComponent>()` so the module macro and
  serializer can use it. The `REGISTER_COMPONENT_GROUP` macro in
  `TerrainModule.cpp` only handles the editor UI registration.
- This is a **singleton component**: exactly one entity per scene.

## 2. TerrainSystem (singleton owner)

One `TerrainSystem` instance is created by `EditorApp`. It owns all Ogre
terrain singletons and the Jolt collision state for the active scene.

Owned objects:

```cpp
Ogre::TerrainGlobalOptions* mTerrainGlobals = nullptr;
Ogre::TerrainGroup *mTerrainGroup = nullptr;
Ogre::PageManager *mPageManager = nullptr;
Ogre::TerrainPaging *mTerrainPaging = nullptr;
Ogre::PagedWorld *mPagedWorld = nullptr;
Ogre::TerrainPagedWorldSection *mTerrainPagedWorldSection = nullptr;
std::unique_ptr<CustomTerrainDefiner> mTerrainDefiner;
```

	Per -
	page physics state :

```cpp struct TerrainCollider {
	long x, y;
	JPH::BodyID bodyId;
	bool pendingRemove = false;
};
std::unordered_map<uint64_t, TerrainCollider> mColliders;
```

### 2.1 Lifecycle

1. **Construction** (`EditorApp::setup()`)
   - Create `TerrainSystem` after `PhysicsSystem` but before static geometry
     systems so raycasts and navmesh generation can use terrain.

2. **Activation** (when an entity with `TerrainComponent` + `TransformComponent`
   is found)
   - Build `TerrainGlobalOptions`, `TerrainGroup`, `PageManager`,
     `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.

3. **Deactivation / scene clear**
   - Disable paging operations (`PageManager::setPagingOperationsEnabled(false)`)
     so no new page loads/unloads start while tearing down.
   - Remove all Jolt bodies first.
   - Release RTShader `SubRenderState` objects held by the terrain material
     generator and erase per-pass `TargetRenderState` user-object bindings
     before RTSS teardown.
   - Destroy `TerrainPaging` → `PageManager`.  `PageManager::~PageManager` does
     **not** destroy the `PagedWorld`s it owns, so the `TerrainPagedWorldSection`
     and its `TerrainGroup` are intentionally leaked.  This avoids GL driver
     hangs/crashes observed when destroying the group during live scene reloads.
   - Destroy `TerrainGlobalOptions` and drop the `mTerrainGroup` pointer.

4. **Destruction** (`EditorApp::~EditorApp()`)
   - Destroy `TerrainSystem` **before** `PhysicsSystem` and the
     `Ogre::SceneManager`.

### 2.2 Per-frame update

Call `TerrainSystem::update(dt)` from `EditorApp::frameRenderingQueued()`
**before** static-world generation:

```cpp
if (m_terrainSystem)
{
	m_terrainSystem->update(evt.timeSinceLastFrame);
}
```

Inside `update()`:

1. `rebuildDirtyPages()` and `processDeferredReloads()` handle editor edits.
2. `ensurePageColliders()` scans every loaded `TerrainSlot` and queues collider
   creation for pages that do not have one yet.
3. `processColliderCreates()` drains the queue.  It skips pages whose terrain
   instance is not yet loaded and re-queues them; it does **not** block on
   `isDerivedDataUpdateInProgress()` because the heightfield data is stable once
   `isLoaded()` is true.
4. `processColliderRemoves()` destroys bodies marked by `DummyPageProvider`.

### 2.3 Safety rules for background jobs

`Ogre::Terrain` performs `prepare()`, LOD streaming, and derived-data updates on
Ogre's `WorkQueue`. The following rules prevent crashes, leaks, and use-after-free:

- **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
  `CustomTerrainDefiner::define()` from being called on a
		background thread and touching GL
		/ ECS state unsafely.-
	**Never call `defineTerrain()` on a slot that already has a live
		instance from a background thread
		.**If paging is active at runtime,
	only unload / reload pages through the paging system so the instance is
			freed before `define()` is called again.-
		**Keep editing and paging reload separate.*
			*Sculpting writes the heightmap and marks pages dirty; `rebuildDirtyPages()` updates the live terrain geometry
  directly.  The paging system may call `CustomTerrainDefiner` later for a true
  reload, but the two paths do not interleave during a frame.
- `DummyPageProvider::unloadProceduralPage` must only mark colliders for removal;
it must not synchronously destroy Jolt bodies or
	touch ECS state.-
		Colliders can be built as soon as `terrain->isLoaded()` is true;
the heightmap data is stable even while derived normal /
			composite maps are still being generated.-
		The `TerrainGroup` destructor can hang or
	crash on some GL drivers,
	so the active group is intentionally leaked at
		shutdown.  `PageManager::~PageManager` does not destroy worlds,
	which makes this leak safe.

	## #2.4 Height function

		The height function is implemented
			inside `CustomTerrainDefiner::define()`:

```cpp float getHeightAtWorld(int worldX, int worldZ);
```

Input:
- Integer XZ world coordinates (not normalized, not page-local).

Output:
- World-space float Y height.

Sources (in order of priority):
1. **Road fixup heightmap** — sparse 256x256 chunks loaded on demand. If a chunk
   covers `(worldX, worldZ)` and contains a value, return it (possibly blended
   with base height).
2. **Base heightmap** — 256x256 binary file bilinearly sampled to the requested
   world size. If out of bounds, return a configurable fallback (e.g., `0.0f`).
3. **Procedural detail noise** (optional) — layered Perlin/Simplex noise for
   fine detail, matching legacy `TerrainModule` behavior.

Page-local sampling in `define()`:

```cpp
// convertTerrainSlotToWorldPosition returns the MINIMUM CORNER of page (x,y),
// NOT the center.  Vertex (i,j) maps to:
//   world.x = corner.x + i * worldSize / (terrainSize-1)
//   world.z = corner.z + j * worldSize / (terrainSize-1)
Ogre::Real worldSize = terrainGroup->getTerrainWorldSize();
Ogre::Real step = worldSize / (Ogre::Real)(terrainSize - 1);
Ogre::Vector3 worldPos;
terrainGroup->convertTerrainSlotToWorldPosition(x, y, &worldPos);
for (int z = 0; z < terrainSize; ++z) {
	for (int x = 0; x < terrainSize; ++x) {
		long wx = (long)(worldPos.x + (Ogre::Real)x * step);
		long wz = (long)(worldPos.z + (Ogre::Real)z * step);
		heightMap[z * terrainSize + x] = getHeightAtWorld(wx, wz);
	}
}
terrainGroup->defineTerrain(x, y, heightMap);
```

	Note
	: Ogre's internal `getHeightData(x, y)` uses `index = y * size + x`, so row index `z` in
		  the buffer corresponds to world
			  Z.row index in the buffer corresponds to world Z.

	  ## #2.5 RTShader and material configuration

		  Before creating the `TerrainGroup`,
	configure the terrain material generator :

```cpp mTerrainGlobals->setDefaultMaterialGenerator(
		Ogre::TerrainMaterialGeneratorPtr(
			new Ogre::TerrainMaterialGeneratorA()));
```

	This must happen while `Ogre::RTShader::ShaderGenerator` is
	active(it is, via
`EditorApp::setup()`)
		.

	## #2.5a Layer declaration(texture sampler layout)

`TerrainMaterialGeneratorA` defines a **layer
	declaration ** — the set of texture samplers each layer expects.The
	default `SM2Profile` declaration provides :

	| Sampler | Semantic | Purpose | | -- -- -- -- - | -- -- -- -- -- |
	-- -- -- -- - | | `diffusespecular` | diffuse + specular |
	Albedo(RGB) + specular(A) | | `normalheight` | normal + parallax
	| Normal map(RGB) + displacement(A) |

	Each `Layer` in `TerrainComponent::layers` produces an
`Ogre::Terrain::LayerInstance` whose `textureNames` list must match the sampler
	count and order of the declaration :

```cpp Ogre::Terrain::LayerInstance layerInstance;
layerInstance.worldSize = componentLayer.worldSize;
// Order must match the LayerDeclaration samplers:
layerInstance.textureNames.push_back(componentLayer.diffuseTexture);
layerInstance.textureNames.push_back(componentLayer.normalTexture);
importData.layerList.push_back(layerInstance);
```

	The `ImportData::layerDeclaration` is obtained from the material
		generator profile :

```cpp importData.layerDeclaration = generatorProfile->getLayerDeclaration();
```

	## #2.5b Blend
	maps(splatting)

Ogre terrain **auto-creates** a packed blend map texture for all pages.
How blend maps work:

- **Layer 0** is the base layer — always 100% opaque everywhere.  It needs no
  blend map.
- **Layers 1–4** each get one RGBA channel (R, G, B, A)
in the packed blend
		texture.Each texel is 0..255(mapped to 0..1)controlling the
	visibility of that layer blended on top of the layers below.-
	With the default `SM2Profile`,
	**up to 5 layers **are supported per terrain
		page(layer 0 + 4 blend channels)
			.-
		After `terrain->prepare(importData)`,
	blend maps exist but are initialized to zero
	everywhere(only layer 0 visible)
		.Use
  `TerrainLayerBlendMap::setBlendValue()` to paint splat weights.

```cpp Ogre::TerrainLayerBlendMap *blendMap =
		terrain->getLayerBlendMap(layerIdx);
blendMap->setBlendValue(tx, ty, 1.0f); // full opacity for this layer here
blendMap->dirty();
blendMap->update();
```

**Composite map**: Ogre auto-generates a low-res "composite map" from the blend
maps for distant rendering (`compositeMapDistance` controls when it switches).

### 2.5c Default blend map initialization

When a terrain page is first created, a sensible default paint is to show layer
0 everywhere.  Later milestones (M3)
add brush tools to paint splat weights in the editor.For M1,
	just leave blend maps at their zero defaults or
		apply a simple slope - based heuristic :

```
	// Pseudocode: paint layer 1 (grass) on flat areas, layer 2 (rock) on steep
	float slope = computeSlope(x, z);
if (slope < 30°)
	blendMap1->setBlendValue(x, z, 1.0f);
else
	blendMap2->setBlendValue(x, z, 1.0f);
```

### 2.5d Editor splat paint tools (future milestone)

Splat painting uses the same brush infrastructure as heightmap editing:
- Brush type selector (which layer to paint, or "erase" to reduce weight).
- Brush radius and opacity curve.
- `TerrainLayerBlendMap::setBlendValue()` for each affected texel.
- `dirtyRect()` to limit the GPU upload to the modified region.
- The blend map image size is configurable via
  `TerrainGlobalOptions::setLayerBlendMapSize()` (default 1024x1024).

### 2.6 Resource locations for terrain data

Terrain textures and heightmaps need `Ogre::ResourceGroupManager` locations:

- `heightmapFile` path: `heightmaps/<terrainId>_<heightmapFile>` where
  `<terrainId>` is `TerrainComponent::terrainId`.  Not scene-relative — the
  terrain ID is the stable key, independent of scene file name or save path.
- Fixup chunks: stored in `heightmaps/<terrainId>/terrain_fixup/` subdirectory,
  registered as a resource location when the scene is loaded.
- Layer textures: expected in the `General` resource group (same as all other
  textures), so no additional resource location is needed.

### 2.7 Interaction with water and skybox systems

- **Water reflection/refraction**: The terrain's render queue group and
  visibility mask must include the water RTT cameras. If the water system hides
  entities during RTT passes via `RenderTargetListener`, the terrain must be
  visible to those passes or water won't reflect the terrain.
- **Skybox**: No special handling needed — sky renders behind terrain.
- **Sun shadows**: If terrain uses shadow casting, ensure the sun light's shadow
  far distance covers the terrain pages.

### 2.8 EditorApp wiring

Follow the existing pattern from `EditorWaterPlaneSystem` and
`EditorPhysicsSystem`:

**In `EditorApp.hpp`**:
```cpp
// Forward declaration
class TerrainSystem;

// In private section:
std::unique_ptr<TerrainSystem> m_terrainSystem;
```

	**In `EditorApp::setupECS()`** — register the component :
```cpp m_world.component<TerrainComponent>();
```

		**In `EditorApp::setup()`** — construct after physics but before
	water
	/ sun /
	skybox(terrain must exist before those systems try to interact with it)
	: Ogre::Camera *cam = m_camera ? m_camera->getCamera() : nullptr;
m_terrainSystem = std::make_unique<TerrainSystem>(
	m_world, m_sceneMgr, cam, m_physicsSystem->getPhysicsWrapper());

**In `EditorApp::frameRenderingQueued()`** — call update **before **static -
	world generation and**after **visual mesh setup
		.Insert right after
`m_proceduralMeshSystem->update()`:
```cpp if (m_terrainSystem)
{
	m_terrainSystem->update(evt.timeSinceLastFrame);
}
```

**In `EditorApp::~EditorApp()`** — destroy **before** `m_physicsSystem` and
before Ogre `SceneManager` cleanup. Insert `m_terrainSystem.reset()` before
`m_physicsSystem.reset()`. Also ensure `m_waterPlaneSystem`, `m_skyboxSystem`,
and `m_sunSystem` are reset before terrain (the current destructor omits these
resets — add them):
```cpp
m_waterPlaneSystem.reset();
m_skyboxSystem.reset();
m_sunSystem.reset();
m_terrainSystem.reset();
m_buoyancySystem.reset();
m_physicsSystem.reset();
```

	## #2.9 EditorUISystem wiring



`EditorUISystem::renderComponentList()` hardcodes each component type with

	explicit `entity.has<ComponentType>()` checks.Add the following after the

	WaterPlane block in `src
	/ features / editScene / systems /
	EditorUISystem.cpp`:



```cpp

#include "../components/Terrain.hpp" // at top with other includes

	// In renderComponentList(), after WaterPlane:

	// Render Terrain if present

	if (entity.has<TerrainComponent>())
{
	auto &tc = entity.get_mut<TerrainComponent>();

	m_componentRegistry.render<TerrainComponent>(entity, tc);

	componentCount++;
}

```

## 3. ZigzagHeightfieldShape (custom Jolt collision shape)

`JPH::HeightFieldShape` is plain row-major and always splits quads along the
same diagonal. `Ogre::Terrain` alternates the diagonal direction per row
(zigzag). Bullet solved this with `btHeightfieldTerrainShape::setUseZigzagSubdivision(true)`
(issue #625). We must implement an equivalent custom Jolt shape because the
Jolt author does not want zigzag behavior in the core heightfield shape.

### 3.1 Why it matters

If the physics triangulation does not match the visual triangulation, small
rigid bodies and characters can sink into or float above the terrain at the
edges of quads, especially on slopes.

### 3.2 Ogre::Terrain triangulation

From `OgreTerrain.cpp` (row `z`):

- Even `z`: diagonal connects `(x, z)` → `(x+1, z+1)` (`\`).
- Odd `z`: diagonal connects `(x+1, z)` → `(x, z+1)` (`/`).

Equivalent to Bullet's `m_useZigzagSubdivision && !(j & 1)`.

### 3.3 Implementation approach

Because implementing a full custom `JPH::Shape` is large, the first milestone
uses a practical, verifiable approach:

**MeshShape approach (Milestone 2)**:

1. Build a `JPH::MeshShape` per terrain page whose triangles use the exact same
   zigzag triangulation as `Ogre::Terrain`.
2. The mesh vertices are `(x, height[y*size+x], z)` in row-major order.
3. Each quad `(x, z)` emits two triangles according to the row parity:
   - Even row: `(x,z), (x,z+1), (x+1,z+1)` and `(x,z), (x+1,z+1), (x+1,z)`.
   - Odd row: `(x,z), (x,z+1), (x+1,z)` and `(x,z+1), (x+1,z+1), (x+1,z)`.
4. Place the `MeshShape` at the page world position using a
   `JPH::RotatedTranslatedShape` or by offsetting vertex coordinates.

**Performance note**: `JPH::MeshShape` stores the full triangle soup and uses
a bounding-volume tree internally, while `JPH::HeightFieldShape` exploits the
regular grid structure for O(log n) collision queries with minimal memory.
For a terrain page of 65x65 vertices (8192 triangles), the `MeshShape` overhead
is acceptable (a few KB extra memory, similar collision speed for typical
contacts).  For pages above 129x129, the custom heightfield shape should be
prioritized.  **Mitigation**: Test with `terrainSize=65` first;
profile with Jolt's stats overlay before scaling up.

	If runtime collision performance becomes a problem,
	a second milestone can replace the `MeshShape` with
		a true custom `ZigzagHeightfieldShape` derived from
`JPH::Shape`,
	reusing Jolt's triangle-vs-convex helpers (`CollideConvexVsTriangles`, `CastConvexVsTriangles`)
		.

	## #3.4 Custom shape skeleton(future milestone)

```cpp class ZigzagHeightfieldShapeSettings : public JPH::ShapeSettings {
public:
	uint32_t sampleCount = 0;
	JPH::Vec3 offset = JPH::Vec3::sZero();
	JPH::Vec3 scale = JPH::Vec3::sReplicate(1.0f);
	std::vector<float> heightSamples;

	virtual JPH::ShapeResult Create() const override;
};

class ZigzagHeightfieldShape : public JPH::Shape {
public:
	ZigzagHeightfieldShape(const ZigzagHeightfieldShapeSettings &settings,
			       JPH::ShapeResult &outResult);

	// Required JPH::Shape overrides:
	// GetLocalBounds, GetSubShapeIDBitsRecursive, GetInnerRadius,
	// GetMassProperties, GetMaterial, GetSurfaceNormal, CastRay,
	// CollidePoint, CollideSoftBodyVertices, GetTrianglesStart/Next,
	// GetStats, GetVolume, GetSubmergedVolume.

	// Zigzag-aware triangle iteration used by collide/cast functions.
	void
	VisitTriangles(const JPH::AABB &localBox,
		       const std::function<void(const JPH::Triangle &t)> &fn);
};
```

	Collision handlers are registered with :

```cpp CollisionDispatch::sRegisterCollideShape(typeA, typeB,
						 MyCollideFunction);
CollisionDispatch::sRegisterCastShape(typeA, typeB, MyCastFunction);
```

Use `EShapeSubType::User1` for the shape subtype.

## 4. Heightmap Data

### 4.1 Base heightmap

- Format: raw binary, `heightmapSize * heightmapSize * sizeof(float)`.
- Row-major, same layout as Ogre: `index = y * heightmapSize + x`.
- **Storage path**: Directory keyed by the stable `TerrainComponent::terrainId`:
  `heightmaps/<terrainId>_<heightmapFile>` where `<terrainId>` is the
  decimal value of `TerrainComponent::terrainId`.  This ID is generated once
  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.
- Editable in the terrain editor with elevation and curve brushes.
- A fresh empty heightmap initializes all samples to `0.0f`.

### 4.2 Fixup chunks

- **Purpose**: Override base heightmap values in localized regions so terrain
  complies to roads, buildings, and other placed geometry.
- **Format**: raw binary, 256x256 floats (same as base heightmap but always
  256x256).
- **Naming**: `x<chunkX>_z<chunkZ>.bin` where chunk coordinates are derived from
  world coordinates:
  ```
  int chunkX = floor(worldX / (worldSize / 256));
int chunkZ = floor(worldZ / (worldSize / 256));
  ```
  So chunk `(0,0)` covers world `(0,0)` to `(worldSize/256, worldSize/256)` in
  X and Z.
- **Storage directory**: `heightmaps/<terrainId>/terrain_fixup/` — registered as
  an Ogre resource location when the scene is loaded so the definer can load them
  on demand. The `<terrainId>` prefix matches the base heightmap directory scheme
  from 4.1.
- **Semantics**: Each fixup sample is an **absolute** height value (not an
  offset). If `getHeightAtWorld()` finds a fixup chunk covering the queried
  world coordinate, it bilinearly samples the fixup chunk and **replaces** the
  base heightmap value at that point. A sentinel value (e.g., `NaN` or
  `-FLT_MAX`) indicates "no fixup at this sample" — in that case fall through
  to the base heightmap.
- **Blending at chunk boundaries**: Use bilinear interpolation within each chunk
  (natural for floating-point heightmaps). When a queried point falls in a
  chunk that hasn't been created yet, there's no fixup — fall through to base.
- **Sparsity**: Chunks are created lazily — only when a road brush writes to
  them or when explicitly created by editor tools. Absent chunks mean "no fixup
  data for this area."
- **Cleanup**: Provide a "Clear all fixups" button in the terrain editor that
  deletes all files under `heightmaps/<terrainId>/terrain_fixup/` and marks all
  terrain pages dirty for re-sampling.  Additionally, individual fixup chunks
  can be deleted by right-clicking a world position in the editor — the chunk
  file covering that position is removed and the affected pages rebuild.

### 4.3 Non-heightmap maps (foliage density, material masks, etc.)

Terrain systems often need auxiliary 2D maps beyond the heightfield.  These are
stored the same way as the base heightmap but with independent resolution and
without fixup chunk support.

**Data structure** — add to `TerrainComponent`:

```cpp
struct AuxMap {
	  std::string name; // e.g. "foliage_density", "rock_mask"
	  std::string fileName; // raw binary file, e.g. "foliage_density.bin"
	  int resolution = 256; // independent from heightmapSize
	  float defaultValue = 0.0f; // initial fill value for new maps
  };
  std::vector<AuxMap> auxMaps;
```

**Storage**: `heightmaps/<terrainId>/<auxMap.fileName>` — same directory as the
base heightmap.

**Editing**: Use the same brush system as heightmap editing (raise/lower/smooth)
but interpreted as adding/subtracting density values.  Values are clamped to
[0, 1] for density maps.

**Visualization**: For density maps, the foliage placement itself serves as
indirect visualization (more foliage where density is high).  For debugging,
render a heatmap overlay on the terrain surface using a simple colored quad or
compositor pass.

**Foliage integration**: After terrain implementation, `Ogre::PagedGeometry`
will sample these density maps during placement.  The `AuxMap` data must be
accessible from the foliage system (via `TerrainSystem` API or shared file
paths).  This is tracked as a follow-up task; for now, the storage and editing
infrastructure is in scope.

### 4.4 Packaging

- **Goal**: Bundle all terrain data files into a single `.package` archive
  (using the existing Package archive format from `package/OgrePackageArchive`)
  to avoid filesystem clutter after heavy terrain editing.
- **Trigger**: A "Package Terrain" button in the terrain editor or a CLI tool.
- **Contents**: All files under `heightmaps/<terrainId>/` — base heightmap,
  fixup chunks, and aux maps.
- **Package path**: `heightmaps/<terrainId>.package`
- **Loading**: When the package file exists, register it as an Ogre resource
  location before the loose file directory so the packaged version takes
  precedence.  Loose files under `heightmaps/<terrainId>/` are treated as
  overrides (editor writes to loose files, packaging snapshots them).
- **Unpack**: An "Unpack Terrain" button extracts the package back to loose
  files for editing.

## 5. Procedural Roads

### 5.1 Road node graph

- Stored in `TerrainComponent::roadNodes` / `roadEdges`.
- Editable in the terrain editor:
  - Add node (default Y = terrain height + offset).
  - Remove node (removes connected edges).
  - Split edge (inserts a node at midpoint).
  - Select and move nodes.
  - Join two selected nodes with an edge.
  - Set per-node vertical offset and per-edge road level for each direction.

**Per-edge data** — `RoadEdge` is extended beyond the simple A/B link to carry
local overrides for road configuration:

```cpp
struct RoadEdge {
		int nodeA = -1;
		int nodeB = -1;
		float roadLevelA = 0.0f; // road surface height at nodeA end
		float roadLevelB = 0.0f; // road surface height at nodeB end

		// Per-edge lane count override (0 = use global lanesPerDirection).
		int lanesPerDirectionOverride = 0;

		// Per-direction lane counts for asymmetric roads.
		// Positive = A→B, negative = B→A.  If 0, uses lanesPerDirectionOverride
		// or global lanesPerDirection.
		int lanesAtoB = 0;
		int lanesBtoA = 0;

		// Prefab spawn points along this edge.
		struct RoadSidePrefab {
			std::string prefabPath;
			float edgeT = 0.5f; // 0..1 position along edge
			float sideOffset =
				5.0f; // lateral offset from road center
			bool leftSide =
				true; // left vs right relative to A→B direction
		};
		std::vector<RoadSidePrefab> sidePrefabs;
	};
```

	This allows individual edges to be
	wider(more lanes) than the default and to spawn lamp posts,
	guardrails,
	or other roadside objects at configurable offsets.

		## #5.2 Road configuration parameters

		These live in `TerrainComponent` alongside the node graph :

```cpp
	// Road surface mesh template name (file in General resource group).
	// Default: a unit-length flat plane mesh at Y=0, extending +X.
	std::string roadMeshTemplate = "road_segment.mesh";

// Width of a single lane in world units.
float laneWidth = 3.0f;

// Number of lanes in each road direction (1 = single-lane each way). default value, should be possible to set per edge per direction.
int lanesPerDirection = 1;

// Vertical thickness of the road surface (for terrain compliance depth).
float roadThickness = 0.3f;

// Maximum edge length beyond which roads use lower LOD.
float roadLodDistance = 200.0f;

// Distance at which road meshes fade out (Ogre visibility distance).
float roadVisibilityDistance = 1000.0f;

// Material name for road surfaces.
std::string roadMaterialName = "RoadMaterial";
```

### 5.3 Road mesh template

The `roadMeshTemplate` is an Ogre mesh file with these conventions:

- The mesh occupies **exactly 1 unit** along the positive X axis (from X=0 to
  X=1), Z=0 to Z=1 or Z=-1 to Z=0, centered on Y=0 (width spans 0 to
  laneWidth for each lane).
- The mesh is repeated along the edge direction for the actual road length.
- Vertices with `X < 0.05` (near the start cap) are shifted slightly negative
  (`-0.05`) to create overlap and prevent seams at wedge boundaries.
- UVs span (0,0) to (1,1) along the X/Z extents and are scaled to the actual
  road length at mesh generation time.
- If no template mesh is provided, fall back to a generated box primitive with
  dimensions `roadSegmentLength x roadThickness x laneWidth`. It should be offset right and towards front for easier calculation and have Z size of 1.0

### 5.4 Geometry generation

For each loaded terrain page, collect all road nodes whose world position falls
within the page plus a margin equal to `roadVisibilityDistance`.

**Step 1 — Node sorting**

For each node:
1. Collect all neighbors connected by edges.
2. Sort neighbors by angle around the vertical (Y) axis.
3. If only one neighbor, the node is an endpoint → generate an **end cap**.

**Step 2 — Wedge generation**

For nodes with multiple neighbors, iterate sorted neighbors and for each
consecutive pair `(neighborI, neighborI+1)` create a **wedge** bounded by:
- Line from node to `neighborI` midpoint.
- Line from node to `neighborI+1` midpoint.

For each wedge:
1. Compute the two boundary direction vectors from the node.
2. Generate wedge geometry into a `Procedural::TriangleBuffer`:
   - The wedge spans the angular region between the two directions.
   - The road mesh template is repeated along each radial direction and the
     triangles between adjacent samples form the wedge surface.
   - Each vertex's model-space X coordinate maps to distance from the node;
     Z maps to lateral offset.
3. For each lane `L` from `0` to `lanesPerDirection - 1`:
   - Offset the Z coordinate by `L * laneWidth` for the outward lane and
     `-L * laneWidth` for the inward lane.
   - Generate separate wedge geometry for each lane (so lanes can be
     individually LOD'd and culled).

**Step 3 — End cap generation**

For endpoints (nodes with exactly one neighbor):
1. Generate a half-circle or rectangular cap mesh from the node extending away
   from the neighbor direction.
2. Same lane multiplication as wedge generation.

**Step 4 — Seam suppression**

Floating-point errors at wedge boundaries can create visible gaps. Mitigation:
- Vertices with model-space `X < 0.05` (near zero) are shifted by `-0.05` in
  the negative X direction (toward the node center).
- Vertices at the outer radial edge (model-space `X` near the edge midpoint
  distance) are shifted slightly outward along the edge direction.

**Step 5 — Mesh assembly**

For each wedge and end cap:
1. Create a `TriangleBufferComponent` entity as a child of the terrain entity.
2. Add `TransformComponent` and `NavMeshGeometrySource` so the road surface is
   included in navigation mesh generation.
3. The buffer is built by `ProceduralMeshSystem` during its update pass.
4. `ProceduralMeshSystem::convertToMesh()` creates the Ogre mesh from the buffer.
5. Apply the road material:
   - Set `materialEntity` reference on the `TriangleBufferComponent`.
   - `ProceduralMaterialSystem` resolves the material at mesh creation time.

**Step 6 — LOD and visibility**

- Each road mesh entity gets a `LodComponent` with `LodStrategy::Distance`.
- Meshes beyond `roadLodDistance` from the camera use a lower-poly LOD level
  (generated by `MeshLodGenerator` if available, or a simple reduction).
- Meshes beyond `roadVisibilityDistance` are culled entirely via
  `Ogre::Entity::setRenderingDistance()`.

**Step 7 — Physics colliders and navigation mesh**

- For each road mesh, generate a `JPH::MeshShape` matching the visible geometry.
- The collider is attached as a static body in the NON_MOVING layer.
- Colliders are created/removed in sync with mesh visibility (same distance
  thresholds).
- Create colliders from joined geometry.
- The same generated road geometry is tagged as `NavMeshGeometrySource` so that
  `NavMeshSystem` rebuilds the affected tiles when a terrain page loads/unloads.

**Step 8 — Wedge joining (optimization)**

Adjacent wedges that share an edge direction and have the same material can be
merged into a single `TriangleBuffer` to reduce draw calls:
1. After generating all wedges for a page, group wedges by material and seed
   node.
2. Merge triangle buffers by appending triangles and adjusting indices.
3. Merge only if the total triangle count stays below a threshold (e.g. 65535
   vertices for 16-bit index buffers).

### 5.5 Terrain compliance (road flattening)

For every road vertex/sample:
1. Compute the desired terrain height at that world position:
   `targetY = roadY - roadThickness`.
2. Write `targetY` into the fixup chunk that covers that world position.
3. Mark affected terrain pages dirty so `CustomTerrainDefiner` re-samples them
   on the next `update()` call.
4. Apply a smooth falloff perpendicular to the road direction:
   - Full compliance within the road width.
   - Linear fade over `laneWidth * 2` units outside the road edge.
   - Beyond the fade zone, no fixup (use base heightmap).

### 5.6 Edge length constraint

Each road edge must have an **integer** length in the road mesh template
coordinate system. The road mesh template is exactly 1 unit long, so edges
whose world-space length is not a whole number of road-segment units will
have a fractional segment at the end. Round edge lengths to the nearest
integer number of road-mesh repetitions. For very short edges (length < 1
unit), scale the road mesh down or emit a warning/immediate LOD reduction.
When operating on edges, like splitting and joining, it should be checked and applied. If enforcing of constraint (by moving new nodes) is impossible, the operation is cancelled with warning message. Other option would be scaling elemental mesh on one of midpoint edges together with scaling UVs so road surface will still look fine. If that would be robust enough I'd go with it, but only if fixing by moving the node is impossible. That should reduce probability of problems.

### 5.7 Dependencies on existing systems

- **ProceduralMeshSystem**: Converts `TriangleBufferComponent` → Ogre mesh.
- **ProceduralMaterialSystem**: Resolves `ProceduralMaterialComponent` → Ogre
  material for road surfaces.
- **LodSystem**: Manages `LodComponent` for distance-based LOD switching.
- **EditorPhysicsSystem**: Provides `JoltPhysicsWrapper` for creating static
  mesh colliders.
- **StaticGeometrySystem**: Optional — road meshes could be batched into
  static geometry for large road networks, but this complicates per-segment
  LOD and visibility culling.

## 6. Prefab Spawning on Terrain

### 6.1 Design

Terrain-attached prefabs use the existing `PrefabSystem` for instantiation and
a new ECS component for distance-based spawn/despawn, similar to
`CharacterSpawnerComponent`.

### 6.2 TerrainPrefabSpawnerComponent

A new component (not embedded in `TerrainComponent`, to keep the component
single-responsibility):

```cpp
struct TerrainPrefabSpawnerComponent {
	// Prefab JSON file path (selectable from prefabs directory in UI).
	std::string prefabPath;

	// World position (snapped to terrain surface at placement time).
	Ogre::Vector3 position;
	Ogre::Quaternion rotation;

	// Distance-based spawn/despawn (squared for fast comparison).
	float spawnDistanceSq = 100.0f * 100.0f;
	float despawnDistanceSq = 200.0f * 200.0f;

	// Runtime: the spawned instance entity (null when not spawned).
	flecs::entity_t spawnedEntity = 0;
};
```

### 6.3 TerrainPrefabSpawnerSystem

A lightweight system that:
1. Queries all `TerrainPrefabSpawnerComponent` entities each frame.
2. Compares camera distance against `spawnDistanceSq` / `despawnDistanceSq`.
3. Creates/destroys prefab instances via `PrefabSystem::createInstance()`.
4. Tracks camera position changes; only re-evaluates when the camera moves
   significantly (e.g., more than 10 units since last check).

This is registered and updated alongside other systems in `EditorApp`, not
inside `TerrainSystem`.

### 6.4 Terrain snap at placement

When the user places a prefab spawn point in the editor:
1. Raycast against the terrain to find the surface Y at `(position.x, position.z)`.
2. Set `position.y` to the terrain height.
3. The spawner entity's `TransformComponent.position` matches this snapped
   position so it's visible in the editor.

### 6.5 Terrain compliance tool

A terrain editing tool mode can temporarily raise/lower terrain around a
spawned prefab to make it sit flush:
1. Sample the prefab's bounding box footprint on the terrain.
2. Write fixup chunk entries that flatten the terrain under the footprint.
3. Optionally add a slope/blend around the footprint edges.
4. Mark affected terrain pages dirty.

## 7. Editor Integration

### 7.1 Component registration

Create `src/features/editScene/components/TerrainModule.cpp`:

```cpp
#include "Terrain.hpp"
#include "Transform.hpp"
#include "EditorMarker.hpp"
#include "EntityName.hpp"
#include "../ui/ComponentRegistration.hpp"
#include "../ui/TerrainEditor.hpp"

REGISTER_COMPONENT_GROUP("Terrain", "Environment", TerrainComponent, TerrainEditor)
{
	registry.registerComponent<TerrainComponent>(
		"Terrain", "Environment", std::make_unique<TerrainEditor>(),
		[](flecs::entity e) {
			if (!e.has<TerrainComponent>()) {
				e.set<TerrainComponent>({});
			}
		},
		[](flecs::entity e) {
			if (e.has<TerrainComponent>()) {
				e.remove<TerrainComponent>();
			}
		});
}
```

### 7.2 TerrainEditor panel

Minimum controls:
- Enable/disable.
- `terrainSize`, `heightmapSize`, `worldSize` with a **Restart Terrain** button
  (these cannot change at runtime without full rebuild).
- `maxPixelError`, `compositeMapDistance`, `minBatchSize`, `maxBatchSize`.
- Heightmap file path and **Load / Save** buttons.
- Brush mode:
  - Brush type: raise, lower, smooth, flatten, **layer paint** (selects which
    terrain layer to apply, with opacity).
  - Brush radius and curve (stored as simple 1D curve).
  - Apply to base heightmap and affected fixup chunks.
  - For layer paint: writes to `TerrainLayerBlendMap` via `setBlendValue()` and
    `dirtyRect()`, then `update()`.
- Road mode:
  - Road config: `roadMeshTemplate`, `laneWidth`, `lanesPerDirection`,
    `roadThickness`, `roadLodDistance`, `roadVisibilityDistance`,
    `roadMaterialName`.
  - Add/remove/split/join/select nodes.
  - Inspector for node position, offset, edge levels.
  - "Comply terrain to roads" button.
	- Physics debug: "Show Terrain Colliders" checkbox (off by default,
	  toggles `TerrainBodyDrawFilter`.  Runtime-only, not serialized with scene).
- Prefab spawn mode (uses `TerrainPrefabSpawnerComponent`):
  - Add/remove spawn points (snapped to terrain surface).
  - Pick prefab from existing prefab list.
  - Configure spawn/despawn distances.

## 8. Serialization

Add to `SceneSerializer::serializeEntity()`:

```cpp
if (entity.has<TerrainComponent>()) {
	json["terrain"] = serializeTerrain(entity);
}
```

Add to both deserialize paths:

```cpp
if (json.contains("terrain")) {
	deserializeTerrain(entity, json["terrain"]);
}
```

JSON schema:

```json
{
	"terrain":
	{
		"enabled" : true,
			    "terrainId" : 17345678901234567890,
			    "terrainSize" : 65,
			    "heightmapSize" : 256,
			    "worldSize" : 2000.0,
			    "maxPixelError" : 1.0,
			    "compositeMapDistance" : 300.0,
			    "minBatchSize" : 17,
			    "maxBatchSize" : 65,
			    "heightmapFile" : "heightmap.bin",
					      "layers"
			: [{
				"diffuseTexture": "Ground23_col.jpg",
				"normalTexture": "Ground23_normheight.dds",
				"worldSize": 100.0
			}],
			  "auxMaps" : [{
				  "name": "foliage_density",
				  "fileName": "foliage_density.bin",
				  "resolution": 256,
				  "defaultValue": 0.0
			  }],
				      "roadConfig"
			: {
				"roadMeshTemplate": "road_segment.mesh",
				"laneWidth": 3.0,
				"lanesPerDirection": 1,
				"roadThickness": 0.3,
				"roadLodDistance": 200.0,
				"roadVisibilityDistance": 1000.0,
				"roadMaterialName": "RoadMaterial"
			},
			  "roadNodes" : [{
				  "id": 0,
				  "position": [0, 10, 0],
				  "verticalOffset": 0.5
			  }],
					"roadEdges" : [{
						"nodeA": 0,
						"nodeB": 1,
						"roadLevelA": 0.0,
						"roadLevelB": 0.0,
						"lanesPerDirectionOverride": 0,
						"lanesAtoB": 0,
						"lanesBtoA": 0,
						"sidePrefabs": []
					}]
	}
}
```

**Note**: Prefab spawners are serialized separately via
`TerrainPrefabSpawnerComponent`, not embedded in the terrain JSON.

**Binary file storage**: All terrain binary data lives under
`heightmaps/<terrainId>/` where `<terrainId>` is the decimal value of
`TerrainComponent::terrainId`:
- Base heightmap: `heightmaps/<terrainId>/<heightmapFile>`
- Fixup chunks: `heightmaps/<terrainId>/terrain_fixup/x<chunkX>_z<chunkZ>.bin`
- Aux maps: `heightmaps/<terrainId>/<auxMap.fileName>`
- Package: `heightmaps/<terrainId>.package`

The terrainId is generated once at component creation, serialized, and survives save/reload unchanged.
time, then serialized.  This avoids the chicken-and-egg problem of time-based
IDs and keeps all data for one terrain in one directory.

## 9. Build Changes

In `src/features/editScene/CMakeLists.txt`:

1. Add `Paging` and `Terrain` to the OGRE component search:

```cmake
find_package(OGRE REQUIRED COMPONENTS Bites Overlay MeshLodGenerator Paging Terrain CONFIG)
```

2. Add new source files to the target sources.

3. Link `OgrePaging` and `OgreTerrain`:

```cmake
target_link_libraries(editSceneEditor PUBLIC
    OgreMain
    OgreBites
    OgreOverlay
    OgreMeshLodGenerator
    OgrePaging
    OgreTerrain
    ...)
```

## 10. Implementation Milestones

### Milestone 1 — Minimal rendering terrain
- `TerrainComponent`, `TerrainModule`, `TerrainEditor`.
- `TerrainSystem` with `Ogre::TerrainGroup`, `PageManager`, `TerrainPaging`,
  `PagedWorld`, `TerrainPagedWorldSection`.
- `CustomTerrainDefiner` sampling a flat or procedural 256x256 base heightmap.
- RTShader material generator setup (TerrainMaterialGeneratorA).
- 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.

### Milestone 2 — Physics integration  ✅ DONE

**Changes to TerrainSystem constructor**: Now takes `JoltPhysicsWrapper*` so
it can create/destroy static terrain collider bodies directly in the Jolt world.

```cpp
TerrainSystem(flecs::world &world, Ogre::SceneManager *sceneMgr,
              Ogre::Camera *camera, JoltPhysicsWrapper *physics);
```

**EditorApp wiring change**: Update `setup()` to pass `m_physicsSystem->getPhysicsWrapper()`.

**Collider creation queue**: `ensurePageColliders()` scans every loaded
`TerrainSlot` each frame and queues missing pages.  `processColliderCreates()`
drains the queue in `update()` when the terrain instance is loaded.  Each page
gets a zigzag `JPH::MeshShape` as a static body in `Layers::NON_MOVING`.

**mColliders map**: `std::unordered_map<uint64_t, TerrainCollider>` keyed by
`packIndex(x, y)`.  `TerrainCollider` holds `{
	pageX, pageY, JPH::BodyID, pendingRemove}`.

**Collider removal queue**: `DummyPageProvider::unloadProceduralPage` marks the
collider `pendingRemove`.  `processColliderRemoves()` destroys bodies in
`update()` when the corresponding terrain page is no longer busy.

**Zigzag triangulation**: Build `JPH::MeshShape` from `terrain->getHeightData()`
using the zigzag quad split matching Ogre's odd/even row parity.  Uses
`JPH::TriangleList` (embedded-vertex triangles).

**Body positioning** — use terrain scene node, not computed center:
`terrain->_getRootSceneNode()->_getDerivedPosition()` is the ground truth.
Computed `convertTerrainSlotToWorldPosition + worldSize/2` differs by up to
1000 units for some origin setups.

**Z-axis flip**: Ogre terrain and Jolt MeshShape use opposite Z conventions.
Mesh vertices must have Z negated: `vz = worldSize*0.5 - z*scale` (instead of
`z*scale - worldSize*0.5`).  Triangle winding must flip to compensate: swap
vertices 2 and 3 (v01↔v11) so the face normal points +Y after Z inversion.

**Body activation — ADD_BODY required**:
`JoltPhysicsWrapper::createBody()` calls `body_interface.CreateBody()` which
only allocates the body; it does NOT add it to the broad phase / simulation.
After `createBody()`, call `m_physics->addBody(bodyId,
JPH::EActivation::DontActivate)`.

**Debug drawing — TerrainBodyDrawFilter**:
- `class TerrainBodyDrawFilter : public JPH::BodyDrawFilter` in `TerrainSystem`
  tracks terrain `BodyID`s in a `std::set`.
- `ShouldDraw()` returns `false` for terrain bodies when
  `m_showTerrainColliders == false` (default off).
- **Wiring**: call `m_physics->setBodyDrawFilter(&mBodyDrawFilter)` in
  `activate()` and `setBodyDrawFilter(nullptr)` in `deactivate()`.  Without
  this the physics debug draw never consults the filter.
- Editor gets "Show Terrain Colliders" checkbox (runtime-only, not saved).

**Raycast helper**:
- `getHeightAt()` uses Ogre terrain for height queries.
- `raycastTerrain()` casts `JPH::RRayCast` via `NarrowPhaseQuery::CastRay`
  against all loaded terrain page bodies.

**Testing**:
- [x] `editSceneEditor test_terrain.json --exit-after-first-frame` exits cleanly
  (EXIT:0) with paging objects created and RTSS teardown successful.
- [x] Running for several seconds creates colliders for all loaded pages.
- [ ] Drop a rigid body on terrain → rests flush.
- [ ] Toggle "Show Terrain Colliders" → wireframe matches terrain.
- [ ] Fly away and back → colliders for unloaded pages removed, new pages get colliders.
- [ ] Remove terrain entity → all collider bodies removed, no dangling Jolt bodies.
- [ ] Add/remove terrain repeated → stable memory (valgrind/ASan).
- [ ] Raycast from above hits terrain at correct height.
- [x] Colliders are NOT visible in debug draw by default.

Definition of done: ✅ rigid bodies collide flush with visible terrain; debug
draw off by default with toggle; removing and re-adding terrain is leak-free.

**Terrain cleanup on entity/component removal** (post-M2 fix):
`TerrainSystem::update()` tracks whether any matching terrain entity exists.
If `m_active` but no entity carries `TerrainComponent`, `deactivate()` is
called.  Without this, deleting the entity or removing the component leaves
terrain and colliders orphaned.

### Milestone 3 — Serialization + heightmap editing + splat painting ✅ (completed 2026-07-08)

**Status**: DONE. Heightmap sculpting and splat painting are two independent,
mutually exclusive modes.  Enabling either mode automatically finishes the other:

- **Paint → Sculpt**: `setSculptMode(true)` flips `m_paintMode = false`, then
  calls `beginSculptPreviews()` (unloads terrain, creates ManualObject proxies).
- **Sculpt → Paint**: `setPaintMode(true)` calls `setSculptMode(false)` which
  triggers `endSculptPreviews()` (reloads full terrain pages with blend maps).

Each mode has its own brush state (radius, strength, tool/layer).  Blend maps
persist via binary save/load (`getBlendPointer`).

**Results / final state**:
- Scene save/load round-trip preserves terrain geometry, layers, and painted
  blend maps.
- Splat painting is visible on the terrain surface and updates both the
  blend-map texture (close-up) and the composite map (distance).
- Painting near page edges and with brushes that cross page boundaries no
  longer crashes; out-of-bounds image coordinates are clamped per-page.
- Adding layers in the editor keeps the default base layer intact and adds
  new textures as splat-mapped layers on top.
- Layer names are serialized and shown in the paint-layer selector.
- All terrain tests pass: create, sculpt, splat (centre + edge + GPU readback),
  save/erase/reactivate/reload, verify, delete.

**Verified**:
- [x] Scene serialization (save/load JSON round-trip for TerrainComponent)
- [x] Binary heightmap file save/load
- [x] Sculpt mode checkbox + brush controls in TerrainEditor panel
- [x] Brush tools: Raise, Lower, Smooth, Flatten — produce correct visible terrain changes
- [x] Brush decal: visible green circle + white cross at correct location
- [x] `markPageDirty()` / `rebuildDirtyPages()` infrastructure
- [x] Sculpt works in both editor and game modes
- [x] Iterative ray-heightfield intersection (t=0.01 seed, 100km max)
- [x] ManualObject sculpt preview — GPU update via rebuild, matches terrain exactly
- [x] `getHeightAt()` uses visual→physical conversion in sculpt mode
- [x] `snapCameraAboveTerrain()` samples 5 points for safety
- [x] `TerrainCommandQueue` wired to `applySculptBrush`/`applySplatBrush`
- [x] TerrainTests.cpp compiles and links
- [x] Sculpt Mode and Paint Mode as separate, mutually exclusive checkboxes
- [x] Paint mode: layer index, radius, strength controls in TerrainEditor
- [x] Paint mode: EditorUISystem dispatches `isPainting()` → `applySplatBrush`
- [x] Paint mode: `setPaintMode(true)` calls `setSculptMode(false)` → `endSculptPreviews()` (terrain reloaded)
- [x] Sculpt mode: `setSculptMode(true)` silences paint, calls `beginSculptPreviews()`
- [x] Splat paint: command queue uses visual world coords
- [x] Splat paint: blend map save/load via `getBlendPointer()`
- [x] Splat paint: save/load round-trip verification in testSplatOperations
- [x] Splat paint: save/load round-trip uses full deactivate/reactivate cycle
- [x] Blend map save wired into SceneSerializer + load wired into activate()
- [x] Blend maps restored correctly when a saved scene is loaded
- [x] Save/Load Blend Maps buttons in TerrainEditor UI
- [x] **Multi-page splat painting**: brush radius that crosses page boundaries writes to every touched page
- [x] **Page-edge splat painting**: strokes near page boundaries stay in-bounds and update the correct pages
- [x] **Erase brush**: negative paint strength subtracts blend weight (verified in tests)
- [x] **Texture selection UI**: combo + browse modal for diffuse/normal textures on each layer
- [x] **Layer names**: `TerrainComponent::Layer.name` serialized and shown in paint-layer selector
- [x] **Add Layer preserves base**: adding the first layer to an empty list inserts the default base first
- [x] **Texture thumbnails**: layer editor attempts to load the selected texture and display a small GPU thumbnail (OpenGL `GLID`); falls back to a coloured swatch
- [x] **ESC exits sculpt/paint mode** from `EditorApp::keyPressed`
- [x] **Robust resource paths**: LuaScripts package/filesystem fallbacks work from build root or binary subdirectory

**Z-mapping solution**:
- Decal at raw physical hit (visible world position)
- Sculpt brush converts via `visualToPhysicalX(hit.x)` + `visualToPhysicalZ(hit.z)`
- Splat brush uses visual world coords directly; page selection uses
  `TerrainGroup::convertWorldPositionToTerrainSlot` and texel mapping uses
  `TerrainLayerBlendMap::convertWorldToUVSpace` / `convertUVToImageSpace`
- `visualToPhysicalZ` uses visual page indexing (works for Z < 0)

**Helpers added**: `visualToPhysicalX`, `visualToPhysicalZ`, `getTerrainWorldHalfSize`, `getTerrainGroup`

**Test infrastructure**:

| Component | File | Purpose |
|-----------|------|---------|
| CLI flag | `main.cpp` | `--run-terrain-tests=N` |
| Frame listener | `main.cpp` | `TerrainTestFrameListener` - runs suite on first frame |
| Test runner | `systems/TerrainTests.cpp` | 5-step cycle: create - sculpt - splat - verify - delete |
| Frame pump | `systems/TerrainTests.cpp` | `pumpFrames()` drives `ts->update()` inside render loop |

**Verification command**:

```bash
cd build-vscode/src/features/editScene
./editSceneEditor --run-terrain-tests=1
```

> Run from the binary directory so relative LuaScripts and resource paths resolve correctly.

**Bugs found & fixed during M3**:
1. `getLayerBlendMap()` expects the **layer index** (1-based for blendable layers), not the internal array index.  Passing `layerIdx - 1` caused `InvalidParametersException: Invalid layer index` as soon as painting started.
2. Blend-map coordinate conversion in `applySplatBrush` originally mixed page-centre and page-corner math; fixed to use the page centre returned by `convertTerrainSlotToWorldPosition` and `bmSize - 1` for texel mapping.  Subsequent fix: page iteration used `floor((pos ± radius) / worldSize)`, which placed brush strokes on the wrong terrain slot for any hit point not near a page centre.  Replaced with `TerrainGroup::convertWorldPositionToTerrainSlot` for the brush centre and a slot-radius loop, and replaced the manual UV math with `TerrainLayerBlendMap::convertWorldToUVSpace` / `convertUVToImageSpace`.
3. `saveBlendMaps`/`saveHeightmap` used single-level `mkdir`, failing when the parent `heightmaps/<terrainId>` directory had not been created yet.  Replaced with `std::filesystem::create_directories`.
4. Erase via `TerrainCommandQueue` ignored `cmd.strength` and always used positive `cmd.splatWeight`.  `SplatPaint` now uses `cmd.strength` as the brush delta (negative = erase).
5. LuaScripts were only resolved from `./lua-scripts.package` / `./lua-scripts`, which fails when the executable is launched from the build root.  Added multiple relative-path fallbacks.
6. `renderTexturePreview()` passed arguments to `Ogre::TextureManager::resourceExists()` in the wrong order (`group, name` instead of `name, group`), causing an `ItemIdentityException` when a new layer defaulted to `Ground23_col.jpg`.
7. Brush decal stayed visible after paint/sculpt mode was turned off because `EditorUISystem::update()` only hid it while inside the mode.  Added an `else` branch to hide the decal whenever neither mode is active, and `setPaintMode(false)` now also hides the decal.
8. Paint-layer selector clamped only the local combo index, not the actual `m_paintLayerIndex`.  Selecting the base layer (index 0) left `m_paintLayerIndex = 0`, so `applySplatBrush` silently returned.  The selector now writes the clamped value back to `TerrainSystem`.
9. New layers used the same diffuse/normal texture as the base layer, making splat painting effectively invisible.  New layers now default to `Ground37_diffspec.dds` / `Ground37_normheight.dds` so the painted area is visually distinct.
10. Interactive paint near terrain page edges or with large brushes that cross page boundaries crashed with out-of-bounds image coordinates (`Ogre::Image::getData` assertion).  `applySplatBrush` now derives the actual GPU blend-map dimensions, keeps unclamped float image coordinates for accurate cross-page distance falloff, clamps the affected pixel range to each page, and passes half-open bounds to `dirtyRect`.  Redundant `terrain->update(true)` was removed; `blendMap->update()` + `terrain->updateCompositeMap()` remain for immediate GPU upload and distance rendering.
11. After adding a new terrain layer the old terrain pages were not removed before reactivation; the leaked pages stayed in the scene manager and rendered on top of the new terrain, hiding painted splat changes.  `TerrainSystem::deactivate()` now calls `mTerrainGroup->removeAllTerrains()` to destroy the old page instances while still leaking the group object to avoid driver teardown crashes.
12. Clicking **Add Layer** on a terrain whose `layers` list was empty replaced the implicit default base with the new texture, so the ground changed instead of gaining a splat-mapped layer.  The editor now inserts the default base layer (`Ground23_col.jpg` / `Ground23_normheight.dds`) first when the list is empty, so the new texture always becomes a blendable layer on top.
13. Saved splat-paint blend maps were not restored when a scene was loaded.  `TerrainSystem::activate()` set `m_active = true` *after* calling `loadSceneBlendMaps()`, but `loadBlendMaps()` early-returns when `m_active` is false.  Moved `m_active = true` before the blend-map restore so saved layers load correctly.

**Files modified for M3 (2026-07-05)**:
- `main.cpp` — `--run-terrain-tests=N` flag, `TerrainTestFrameListener`
- `systems/TerrainSystem.hpp` — paint mode state, `setSculptMode`/`setSculptTool` simplified, blend map API, multi-page brush helpers
- `systems/TerrainSystem.cpp` — `setSculptMode`, `setSculptTool`, `setPaintMode`, `applySplatBrush` (multi-page + correct texel mapping), `saveBlendMaps`, `loadBlendMaps`, `saveHeightmap`
- `systems/EditorUISystem.cpp` — gate on `isSculpting() || isPainting()`, dispatch `applySplatBrush`/`applySculptBrush`
- `systems/TerrainTests.cpp` — splat round-trip, frame-driven `pumpFrames`, try-catch steps, fixed layer index/coordinate checks; test now paints off-centre on page (1,0) to exercise the corrected world-to-slot mapping; added edge/cross-page stroke at the crash position and GPU blend-texture readback verification; save/load round-trip now uses a full deactivate/reactivate cycle to exercise the activation-time blend-map restore
- `systems/SceneSerializer.cpp` — serialize/deserialize `Layer.name`, blend map save during serialization
- `systems/TerrainCommands.hpp` — `SplatPaint` command uses paint-mode API
- `ui/TerrainEditor.hpp` — separate Sculpting / Splat Painting sections, Save/Load Blend Maps buttons, layer name editing, texture combo + browse modal + thumbnail preview, paint-layer selector with names; "Add Layer" now ensures the default base layer exists first
- `components/Terrain.hpp` — added `Layer.name`
- `EditorApp.cpp` — LuaScripts filesystem fallback, ESC exits sculpt/paint mode
- `lua-scripts/data2.lua` — minimal placeholder for app init (new file)

### Milestone 4 — Miscellaneous terrain improvements

This milestone polishes the terrain editing pipeline and makes the test suite
runnable in CI.  Each item below states the concrete user-visible behaviour, the
files to touch, the data that must be serialized, and the verification steps.

#### M4.1 Headless terrain test mode ✅ DONE — verified

**Status**: completed and verified by user.

**Goal**: the existing `--run-terrain-tests=N` suite can run as part of an
automated CMake build on a machine without a display server / GPU.

Tests that absolutely require UI or GPU (e.g. visual blend-map readback) should
be skipped in headless mode.  CPU-only variations that verify the same logic
(e.g. heightmap writes, command queue state, serialization round-trips) should
be added so the suite remains meaningful.

**Current limitation**: `TerrainSystem::activate()` creates `Ogre::TerrainGroup`,
which uploads textures and derived data to the GPU.  The tests therefore require
an active Ogre render window and a valid GL/Vulkan context.

**Chosen approach — hidden SDL window**:

The Ogre GL3Plus render system on Linux already accepts an `sdlwin` misc
parameter pointing at an `SDL_Window*`.  We therefore create a 1×1
`SDL_WINDOW_HIDDEN | SDL_WINDOW_OPENGL` window and hand it to Ogre through the
normal `Ogre::Root::createRenderWindow` path.  This exercises the real
`TerrainSystem` code path while avoiding a visible window.

For CI containers without a display, run the test under `xvfb-run` or a similar
software-GL display shim; full OSMesa integration is **not** implemented in this
milestone because getting Ogre's GL3Plus RenderSystem to render through OSMesa
reliably requires substantial platform-specific plumbing.

Implementation steps:

1. Add a new command-line flag `--headless` in `src/features/editScene/main.cpp`.
   - When set, call `EditorApp::setHeadless(true)` before `initApp()`.
   - Wire the return value of `TerrainTestRunner::run()` to the process exit
     code so CI can detect failures.
2. Add `EditorApp::setHeadless()` / `isHeadless()` and override
   `ApplicationContext::createWindow()`.
   - In headless mode create a 1×1 hidden SDL window and pass `sdlwin=<ptr>`
     in the render-window misc parameters.
3. In `EditorApp::setup()` skip ImGui/EditorUI initialization and input-listener
   registration when headless.
4. Add `TerrainTestRunner::setHeadless(true)` and skip `readGPUBlendByte` GPU
   readback in headless mode.
5. Add CMake test target `editSceneTerrainTest` in
   `src/features/editScene/CMakeLists.txt` and `enable_testing()` in the root
   `CMakeLists.txt`:
   ```cmake
   add_test(NAME editSceneTerrainTest
       COMMAND editSceneEditor --headless --run-terrain-tests=1
       WORKING_DIRECTORY ${CMAKE_CURRENT_BINARY_DIR})
   set_tests_properties(editSceneTerrainTest PROPERTIES RUN_SERIAL TRUE)
   ```

**Definition of done**:
- [x] `cmake --build build-vscode --target editSceneEditor` succeeds.
- [x] `./editSceneEditor --headless --run-terrain-tests=1` exits cleanly with
      code 0 when tests pass and code 1 when a step is forced to fail.
- [x] `ctest -R editSceneTerrainTest` discovers and can run the test.
- [x] GPU-readback tests are skipped in headless mode instead of crashing.

**Notes from implementation**:
- `EditorApp::createWindow()` is overridden to create a 1×1 hidden SDL window
  and push the resulting `NativeWindowPair` into `mWindows`; without this the
  base class `initialiseRTShaderSystem()` crashes because `getRenderWindow()`
  returns null.
- `SDL2::SDL2` is linked explicitly now that `EditorApp` calls SDL directly.
- `readGPUBlendByte()` sets `*outByte = 255` in headless mode so the combined
  CPU+GPU verification in the edge/cross-page paint test still passes.

---

#### M4.2 Procedural detail noise

**Goal**: add optional procedural detail noise on top of the base heightmap so
empty terrain is not perfectly flat, while keeping every parameter serializable
and editable at runtime.

There is currently no noise support in `TerrainSystem`; this item adds it from
scratch.

**Component changes** (`src/features/editScene/components/Terrain.hpp`):

```cpp
struct TerrainComponent {
    ...
    struct DetailNoise {
        bool enabled = false;
        int seed = 0;
        int octaves = 4;
        float frequency = 0.001f;  // world-space frequency
        float amplitude = 10.0f;   // maximum height contribution
        float lacunarity = 2.0f;
        float persistence = 0.5f;
    };
    DetailNoise detailNoise;
};
```

**Runtime integration** (`src/features/editScene/systems/TerrainSystem.cpp`):

1. Add a private helper to `TerrainSystem`:
   ```cpp
   float computeDetailNoise(long worldX, long worldZ,
                            const TerrainComponent::DetailNoise &n) const;
   ```
2. Implement it using the `FastNoiseLite` header already vendored in
   `src/FastNoiseLite/`.  Initialize one `FastNoiseLite` instance per unique
   `(seed, frequency)` pair (or recreate on parameter change).  Evaluate
   layered octaves with `lacunarity` and `persistence`:
   ```cpp
   float sum = 0.0f;
   float amp = n.amplitude;
   float freq = n.frequency;
   for (int i = 0; i < n.octaves; ++i) {
       noise.SetFrequency(freq);
       // FastNoiseLite returns values in approximately [-1, 1].
       sum += noise.GetNoise((float)worldX, (float)worldZ) * amp;
       amp *= n.persistence;
       freq *= n.lacunarity;
   }
   return sum;
   ```
3. In `fillPageHeightData()` and `sampleHeightAtLocked()`, after the base
   heightmap value is computed (and before any road fixups), add:
   ```cpp
   if (tc.detailNoise.enabled)
       h += computeDetailNoise(wx, wz, tc.detailNoise);
   ```
   Road fixups, when implemented later, must override the combined base +
   noise value so roads stay flat.
4. The noise is deterministic: the same `(worldX, worldZ, seed)` always returns
   the same value.
5. Noise is **not** baked into `m_heightData` / `heightmap.bin`.  It is
   evaluated on demand so changing parameters does not destroy sculpted data.

**Editor changes** (`src/features/editScene/ui/TerrainEditor.hpp`):

Add a collapsible "Detail Noise" section with:
- `Checkbox("Enabled", &tc.detailNoise.enabled)`
- `SliderInt("Seed", &tc.detailNoise.seed, 0, 100000)`
- `SliderInt("Octaves", &tc.detailNoise.octaves, 1, 8)`
- `SliderFloat("Frequency", &tc.detailNoise.frequency, 0.0001f, 0.01f)`
- `SliderFloat("Amplitude", &tc.detailNoise.amplitude, 0.0f, 200.0f)`
- `SliderFloat("Lacunarity", &tc.detailNoise.lacunarity, 1.0f, 4.0f)`
- `SliderFloat("Persistence", &tc.detailNoise.persistence, 0.0f, 1.0f)`

When any value changes, set `tc.dirty = true` so the next `update()` rebuilds
pages.  No terrain restart is required.

**Serialization** (`src/features/editScene/systems/SceneSerializer.cpp`):

```cpp
auto &n = tc.detailNoise;
json["detailNoise"] = {
    {"enabled", n.enabled},
    {"seed", n.seed},
    {"octaves", n.octaves},
    {"frequency", n.frequency},
    {"amplitude", n.amplitude},
    {"lacunarity", n.lacunarity},
    {"persistence", n.persistence}
};
```

Deserialize with `json.value(...)` defaults matching the component defaults.

**Definition of done**:
- [ ] Enabling detail noise in the editor immediately changes the terrain
      surface without resetting sculpted heights.
- [ ] Changing seed/frequency/amplitude and saving/reloading the scene restores
      the identical surface.
- [ ] Disabling noise returns exactly to the base heightmap surface.
- [ ] Noise is included in the `TerrainComponent` JSON round-trip test.

---

#### M4.3 Aux-map editing

**Goal**: let the user create, remove, paint, and delete auxiliary 2D maps
(e.g. foliage density, rock mask, wetness) that are stored alongside the
heightmap and serialized with the scene.

**Component state** (`TerrainComponent::AuxMap` already exists):

```cpp
struct AuxMap {
    std::string name;
    std::string fileName;
    int resolution = 256;
    float defaultValue = 0.0f;
};
std::vector<AuxMap> auxMaps;
```

No component change is required, but the runtime must actually use them.

**Storage path**:
- `heightmaps/<terrainId>/<auxMap.fileName>` — raw binary
  `resolution * resolution * sizeof(float)`, row-major.
- Default aux map created on first use if the file does not exist, filled with
  `defaultValue`.

**Runtime API** (`src/features/editScene/systems/TerrainSystem.hpp/.cpp`):

```cpp
// Paint into an aux map at world position (wx, wz).  Delta is signed.
void applyAuxBrush(const std::string &auxMapName,
                   const Ogre::Vector3 &worldPos,
                   float radius, float delta);

// Sample an aux map in world space; returns defaultValue if missing.
float sampleAuxMap(const std::string &auxMapName,
                   long worldX, long worldZ) const;

// Save/load all aux maps for the active terrain.
bool saveSceneAuxMaps(const TerrainComponent &tc) const;
bool loadSceneAuxMaps(const TerrainComponent &tc);
```

Implementation notes:
- World-to-texel conversion mirrors the base heightmap mapping: the aux map
  covers the same world extents as the base heightmap (see section 4.1).
- Brush falloff reuses the configured brush shape from M4.4.
- Values are clamped to `[0, 1]` for density-style aux maps.  Aux maps that
  represent signed data (e.g. displacement) can opt out via a future flag; for
  M4 clamp to `[0, 1]`.
- Painting marks only the aux map dirty; it does **not** mark terrain geometry
  dirty unless the aux map is later consumed by geometry (not in this
  milestone).

**Editor changes** (`src/features/editScene/ui/TerrainEditor.hpp`):

Add a new "Aux Maps" section:
- List current aux maps with name, resolution, default value, file name.
- "Add Aux Map" button: opens a small modal asking for `name`.  The file name
  defaults to `<name>.bin` (sanitized to remove path separators), resolution
  defaults to `tc.heightmapSize`, default value defaults to `0.0f`.
- "Remove Aux Map" button: removes the selected entry from the component and
  deletes its binary file after a confirmation modal.
- "Paint Aux Map" mode:
  - Add a combo listing aux map names; the selected map is the paint target.
  - When active, use the existing brush radius and the paint-strength slider
    as the signed delta passed to `applyAuxBrush()`.
  - Aux-paint mode must be mutually exclusive with sculpt and splat paint
    modes: activating aux-paint calls `setSculptMode(false)` and
    `setPaintMode(false)` first.
  - Dispatch from `EditorUISystem::update()` only when aux-paint mode is active
    and the left mouse button is held, exactly like sculpt/splat painting.

**Serialization** (`SceneSerializer.cpp`):

`auxMaps` array is already part of the schema in section 8.  Ensure it is read
and written.

**Definition of done**:
- [ ] User can add a "foliage_density" aux map and paint high-density patches.
- [ ] `saveSceneAuxMaps()` writes the binary file; loading the scene restores
      it via `loadSceneAuxMaps()`.
- [ ] Removing an aux map deletes the file and removes it from the component.
- [ ] Painting respects the configured brush shape (M4.4) and clamps to
      `[0, 1]`.

---

#### M4.4 Paint brush shape presets

**Goal**: replace the hard-coded `1 - dist/radius` linear falloff with a
configurable brush shape used by sculpt, splat, and aux-map brushes.

**Component / state changes**:

No new `TerrainComponent` fields — the brush shape is a runtime editor
preference and is **not** serialized with the scene.  Add a runtime-only shape
enum to `TerrainSystem`:

```cpp
enum class BrushFalloffShape {
    Linear,      // 1 - t, zero at edge
    Smoothstep,  // 1 - (3t^2 - 2t^3)
    Gaussian,    // exp(-4 t^2)
    Spherical,   // sqrt(1 - t^2)
    Cone         // 1 inside radius, 0 at edge
};
```

Add getters/setters:

```cpp
BrushFalloffShape getBrushFalloffShape() const;
void setBrushFalloffShape(BrushFalloffShape s);
```

**Brush evaluation helper**:

```cpp
float evalBrushFalloff(float distance, float radius, BrushFalloffShape shape);
```

Behaviour for `t = distance / radius`, `t` clamped to `[0, 1]`.  The helper
must return `0.0f` when `t >= 1.0f` (outside the brush radius) for all shapes:
- `Linear`: `1 - t`
- `Smoothstep`: `1 - (3t^2 - 2t^3)` (full at centre, zero at edge)
- `Gaussian`: `exp(-4.0f * t * t)` (≈0.018 at edge)
- `Spherical`: `sqrt(max(0.0f, 1 - t*t))`
- `Cone`: `1.0f` for `t < 1.0f`, `0.0f` at `t >= 1.0f`

Replace the inline falloff calculations in:
- `TerrainSystem::applySculptBrush()` (raise/lower path)
- `TerrainSystem::applySplatBrush()`
- `TerrainSystem::applyAuxBrush()` (M4.3)

**Editor changes** (`TerrainEditor`):

Add a "Brush Shape" combo shared by Sculpt and Paint sections:

```cpp
const char *shapes[] = {"Linear", "Smoothstep", "Gaussian", "Spherical", "Cone"};
int shape = (int)ts->getBrushFalloffShape();
if (ImGui::Combo("Brush Shape", &shape, shapes, IM_ARRAYSIZE(shapes)))
    ts->setBrushFalloffShape((TerrainSystem::BrushFalloffShape)shape);
```

Place it above the Sculpt section so it is visible in both modes.

**Definition of done**:
- [ ] Changing the brush shape visibly changes the profile of a sculpt raise
      stroke.
- [ ] Changing the brush shape visibly changes the edge softness of splat
      paint strokes.
- [ ] Each shape is deterministic and produces identical results on identical
      inputs.

---

#### M4.5 Batched composite-map updates

**Goal**: avoid calling `Ogre::Terrain::updateCompositeMap()` once per affected
page on every brush stroke; batch updates to one composite-map rebuild per
frame per page.

**Current behaviour**: `applySplatBrush()` calls `terrain->updateCompositeMap()`
inside the page loop for every stroke.

**New behaviour**:

1. Add a new member to `TerrainSystem`:
   ```cpp
   std::set<uint64_t> m_compositeDirtyPages;
   ```
2. In `applySplatBrush()`, after calling `blendMap->update()` on a page, insert
   the packed page key into `m_compositeDirtyPages` using
   `mTerrainGroup->packIndex(px, py)`.  **Do not** call `updateCompositeMap()`
   here.
3. At the end of `TerrainSystem::update()`, after `rebuildDirtyPages()` and
   collider processing, call:
   ```cpp
   if (mTerrainGroup) {
       for (uint64_t key : m_compositeDirtyPages) {
           long px, py;
           mTerrainGroup->unpackIndex(key, &px, &py);
           Ogre::Terrain *t = mTerrainGroup->getTerrain(px, py);
           if (t)
               t->updateCompositeMap();
       }
   }
   m_compositeDirtyPages.clear();
   ```
4. Same batching for any other code that dirties blend maps (aux-map painting
   if it ever touches terrain materials; not required for M4).

**Thread safety**: `applySplatBrush()` runs on the main thread;
`m_compositeDirtyPages` is only touched from the main thread, so no lock is
needed.

**Definition of done**:
- [ ] Painting a large brush that touches four pages calls
      `updateCompositeMap()` exactly four times in the next frame, not four
      times per mouse-drag event.
- [ ] Distance rendering of splat-painted terrain is still correct after the
      batched update.

---

#### M4.6 Changeable heightmap resolution

**Goal**: allow the user to change `TerrainComponent::heightmapSize` between
256, 512, 1024, 2048, and 4096 through the editor.  Changing the resolution is
a destructive operation: it resamples the existing heightmap and blend maps to
the new resolution.

**UI changes** (`TerrainEditor`):

Replace the read-only "Heightmap: N x N" label with:

```cpp
const int resolutions[] = {256, 512, 1024, 2048, 4096};
int current = tc.heightmapSize;
int idx = 0;
while (idx < 4 && resolutions[idx] != current) ++idx;

const char *labels[] = {"256", "512", "1024", "2048", "4096"};
if (ImGui::Combo("Heightmap Resolution", &idx, labels, IM_ARRAYSIZE(labels))) {
    int newRes = resolutions[idx];
    if (newRes != tc.heightmapSize) {
        ImGui::OpenPopup("Confirm Resolution Change");
    }
}

if (ImGui::BeginPopupModal("Confirm Resolution Change", nullptr,
                           ImGuiWindowFlags_AlwaysAutoResize)) {
    ImGui::Text("This will resample the heightmap and blend maps.");
    ImGui::Text("The old binary files will be backed up.");
    if (ImGui::Button("OK", ImVec2(120, 0))) {
        ts->changeHeightmapResolution(tc, newRes);
        ImGui::CloseCurrentPopup();
    }
    ImGui::SameLine();
    if (ImGui::Button("Cancel", ImVec2(120, 0)))
        ImGui::CloseCurrentPopup();
    ImGui::EndPopup();
}
```

`changeHeightmapResolution` is responsible for updating `tc.heightmapSize`;
the editor must not set it separately.

**Implementation** (`TerrainSystem`):

```cpp
bool changeHeightmapResolution(TerrainComponent &tc, int newResolution);
```

Steps:
1. Validate `newResolution` is one of `{256, 512, 1024, 2048, 4096}`.
2. If `newResolution == tc.heightmapSize`, return true.
3. Ensure sculpt and paint modes are inactive; if either is active, log an
   error and return false.
4. Deactivate terrain (`deactivate()`).
5. Back up existing files:
   - `heightmaps/<terrainId>/heightmap.bin` →
     `heightmaps/<terrainId>/heightmap_<oldRes>.bin`
   - `heightmaps/<terrainId>/heightmap.bin_blendmaps/` →
     `heightmaps/<terrainId>/heightmap.bin_blendmaps_backup_<oldRes>/`
6. Resample the heightmap:
   - Load old `m_heightData` at old resolution (`oldRes`).
   - Allocate new `m_heightData` at `newResolution * newResolution`.
   - For each new texel `(nx, nz)`, compute normalized UV over the new map:
     ```cpp
     float u = (float)nx / (float)(newResolution - 1);
     float v = (float)nz / (float)(newResolution - 1);
     ```
     Then sample the old heightmap at `(u * (oldRes - 1), v * (oldRes - 1))`
     using bilinear interpolation.
   - Save as `heightmaps/<terrainId>/heightmap.bin`.
7. Resample blend maps (if the backup directory from step 5 exists):
   - For each saved blend-map binary file, load it at the old texel density.
   - Resample to the new texel density using bilinear filtering.
   - Store the resampled data back to
     `heightmaps/<terrainId>/heightmap.bin_blendmaps/`.
8. Update `tc.heightmapSize = newResolution` inside this function.
9. Reactivate terrain (`tc.dirty = true`).

**Important constraints**:
- Do **not** change resolution while sculpt or paint mode is active.  The
  editor must call `setSculptMode(false)` and `setPaintMode(false)` first and
  wait for `endSculptPreviews()` to finish.
- Changing `terrainSize` or `worldSize` is **not** part of this item; those
  still require a full terrain restart via the existing "Restart Terrain"
  workflow.
- The backup files are kept forever; a future "Restore Backup" button is
  out of scope for M4.

**Definition of done**:
- [ ] Changing resolution from 256 to 512 and saving the scene produces a
      `heightmap.bin` of `512 * 512 * sizeof(float)` bytes.
- [ ] Sculpted features are visually preserved after upsampling (some smoothing
      is expected from bilinear filtering).
- [ ] Painted splat layers are preserved after upsampling.
- [ ] The old `heightmap_256.bin` backup exists next to the new file.
- [ ] A scene saved at 512 loads correctly and renders the same terrain as
      before the save.

---

### Milestone 4 definition of done

- [ ] Headless test target is registered with `add_test` and passes in CI.
- [ ] Detail noise parameters are editable in the UI, deterministic, and
      serialized.
- [ ] Aux maps can be added, removed, painted, and saved/loaded.
- [ ] Brush shape presets apply to sculpt, splat, and aux-map brushes.
- [ ] Composite-map updates are batched to one per dirty page per frame.
- [ ] Heightmap resolution can be changed between supported sizes with a
      warning, backup, and resampling.

### Milestone 5 — Procedural roads

This milestone adds a node-graph road network that is edited visually in the 3D
view, rendered by sweeping a 1-unit mesh template along the graph, and makes the
terrain conform to the road surface without intersecting it.  Roads may be
asymmetric: an edge can have a different number of lanes in each direction.

#### M5.1 Road data model

The data lives in `TerrainComponent` (`components/Terrain.hpp`):

```cpp
struct RoadConfig {
    // Road surface mesh template.  See M5.3 for conventions.
    std::string roadMeshTemplate = "road_segment.mesh";

    // Width of one lane in world units.  This is global and immutable per road.
    float laneWidth = 3.0f;

    // Default lane count for each direction when an edge does not override it.
    int lanesPerDirection = 1;

    // Vertical thickness of the road surface.
    float roadThickness = 0.3f;

    float roadLodDistance = 200.0f;
    float roadVisibilityDistance = 1000.0f;
    std::string roadMaterialName = "RoadMaterial";
};
RoadConfig roadConfig;

struct RoadNode {
    // World position.  Y is always terrain_height_at(x,z) + verticalOffset.
    Ogre::Vector3 position;

    // Offset above the terrain surface.  Editable in the node inspector.
    float verticalOffset = 0.0f;

    // Stable ID.  Never reused after deletion.
    int id = 0;
};

struct RoadEdge {
    int nodeA = -1;
    int nodeB = -1;

    // Height of the road surface at each end, relative to the node position.
    // roadSurfaceY(nodeA) = nodeA.position.y + roadLevelA.
    float roadLevelA = 0.0f;
    float roadLevelB = 0.0f;

    // Lane counts for the A->B and B->A directions.  0 means "use global default".
    // Asymmetric roads are allowed (e.g. lanesAtoB=2, lanesBtoA=1).
    int lanesAtoB = 0;
    int lanesBtoA = 0;

    struct RoadSidePrefab {
        std::string prefabPath;
        float edgeT = 0.5f;   // 0..1 along the edge from nodeA to nodeB
        float sideOffset = 5.0f;
        bool leftSide = true; // left side relative to A->B direction
    };
    std::vector<RoadSidePrefab> sidePrefabs;
};

std::vector<RoadNode> roadNodes;
std::vector<RoadEdge> roadEdges;
```

Rules:
- `RoadConfig::laneWidth` is global for the whole terrain.  It is **not**
  overridden per edge.
- Per-edge lane counts are resolved as:
  ```cpp
  int la = (edge.lanesAtoB > 0) ? edge.lanesAtoB : tc.roadConfig.lanesPerDirection;
  int lb = (edge.lanesBtoA > 0) ? edge.lanesBtoA : tc.roadConfig.lanesPerDirection;
  ```
- Node IDs are stable integers.  When a node is deleted, all edges referencing it
  are deleted.
- Edge endpoints are stored as node IDs, not indices into `roadNodes`.

---

#### M5.2 Visual road editor

Road editing is a dedicated 3D tool mode, not a set of inspector buttons.  The
user sees the graph overlaid on the terrain and manipulates it directly.

**Road edit mode activation**:
- Add a "Road Edit Mode" checkbox in `TerrainEditor`.
- When enabled, deactivate sculpt/paint/aux-paint modes and hide the brush decal.
- Pass the mode flag to `EditorUISystem` via a new getter
  `TerrainEditor::isRoadEditMode()`.

**Visual aids** (`RoadSystem` owns these `Ogre::ManualObject`s or scene nodes):
- A sphere or small cube marker at every `RoadNode::position`.
- A line strip along every edge from `nodeA` to `nodeB`.
- A translucent quad or disc at the road surface level showing the full road
  width (both directions) along each edge.
- The selected node is highlighted in a different color and gets a translate
  gizmo.
- The selected edge is highlighted and displays its A->B direction arrow.

Visual aids are created/destroyed by `RoadSystem` and are not serialized.

**Mouse interaction in `EditorUISystem::update()`**:
- Track `m_roadMouseDownPos` on `ImGuiMouseButton_Left` press.
- On release, if the mouse moved less than 5 pixels, treat it as a click;
  otherwise it was a camera drag.
- In road edit mode, raycast against the terrain using
  `TerrainSystem::raycastTerrain()`.
- Click near a node (within `laneWidth` world units): select the node and show
  the translate gizmo.
- Click near an edge (within `laneWidth` world units): select the edge.
- Click on empty terrain while "Add Node" is active: create a node snapped to
  the terrain surface.

**Node manipulation**:
- Selected nodes are moved with the existing translate gizmo
  (`EditorUISystem::getGizmo()`).
- During gizmo drag, the node's `(x, z)` follows the gizmo; the node's `y` is
  recomputed every frame as `terrainHeightAt(x, z) + verticalOffset`.
- The node inspector shows `verticalOffset`, `roadLevel` (computed from the
  connected edges), and a "Snap to Terrain" button that recomputes `position.y`
  from the current terrain.

**Toolbar buttons** (visible only in road edit mode):
- Add Node / Move Node (default).
- Remove Selected Node.
- Split Selected Edge.
- Join Selected Nodes (requires exactly two selected nodes).
- "Comply Terrain to Roads".

**Edge inspector** (when an edge is selected):
- `lanesAtoB` and `lanesBtoA` integer inputs.
- `roadLevelA` and `roadLevelB` sliders.
- Side prefab list with add/remove.

---

#### M5.3 Road mesh template

The `roadMeshTemplate` mesh is a single Ogre mesh with these conventions (from
section 5.3):

- It occupies exactly 1 unit along the positive X axis (`X ∈ [0, 1]`).
- Its width (Z extent) is 1 unit (`Z ∈ [0, 1]` or `Z ∈ [-1, 0]`).
- It is centered vertically on `Y = 0`.
- The top surface is at `Y = +roadThickness / 2` and the bottom surface at
  `Y = -roadThickness / 2` relative to the local origin.
- UVs span `(0, 0)` to `(1, 1)` over the X/Z extents.

Default fallback (if the template is missing or empty): generate a box with
local dimensions `1.0 x roadThickness x 1.0`, with the top face at
`Y = +roadThickness/2` and the bottom face at `Y = -roadThickness/2`.  The box is
offset so that `Z ∈ [0, 1]` (one lane width in template space corresponds to one
lane width in world space after scaling).

---

#### M5.4 Road geometry generation

Road geometry is generated by the new `RoadSystem`
(`systems/RoadSystem.hpp/.cpp`).  `TerrainSystem` owns the `RoadSystem` and
calls `update()` after `rebuildDirtyPages()`.

**Generation strategy**: the road surface is built entirely from **wedges**.  A
wedge is two half-edge segments that meet at a single node, forming a V-shape.
For each node with 2+ neighbors, consecutive neighbors (after angle sorting)
produce one wedge.  Nodes with exactly one neighbor are a special case: they
produce a straight road segment along the single half-edge.

This approach builds intersections automatically because the wedges that share
a node overlap at the node center; small vertex shifts remove the remaining gap.

**Page-based output and lifecycle**: Road meshes, colliders, and navmesh
geometry are attached to terrain pages.  For each loaded terrain page, generate
one `TriangleBufferComponent` entity containing all wedges that affect that page
(page bounds expanded by `roadVisibilityDistance`).  When the terrain page is
unloaded, the corresponding road mesh, collider, spawned prefabs, and navmesh
contribution must be destroyed.  Road geometry is regenerated only for pages
that become dirty (road graph changed) or newly loaded.

**Navigation mesh input**: Road surfaces are walkable, so every generated road
page entity must be discoverable by `NavMeshSystem`.  The `TriangleBufferComponent`
entity is tagged with `NavMeshGeometrySource` and given a `TransformComponent`
whose scene node holds the final `Ogre::Entity`, so the existing navmesh geometry
collection picks it up automatically.  `RoadSystem` also marks the affected
navmesh tiles dirty whenever road geometry is created, destroyed, or changed.

**Data structures**:

```cpp
struct RoadPageGeometry {
    long pageX, pageY;
    flecs::entity bufferEntity = flecs::entity::null();
    flecs::entity colliderEntity = flecs::entity::null();
    std::vector<Ogre::Vector3> collisionVertices;
    std::vector<uint32_t> collisionIndices;
    std::vector<flecs::entity> spawnedPrefabs;
};
std::unordered_map<uint64_t, RoadPageGeometry> m_pageGeometry;
```

---

#### M5.5 Wedge enumeration

Terminology:
- An **edge** is a connection between two road-graph nodes.  Geometrically it is
  the segment whose endpoints are the world positions of those two nodes.
- A **half-edge** is one of the two sub-segments that an edge is decomposed into.
  For an edge connecting nodes `n1` and `n2`, the two half-edges are:
  - the segment from `n1.position` to the midpoint of the edge, and
  - the segment from the midpoint of the edge to `n2.position`.
  Every edge is composed of exactly two half-edges.
  In wedge generation, the half-edge we use starts at the node in question and
  ends at the midpoint of the connecting edge.
- A **wedge** is a V-shaped region constructed from two half-edges that start at
  the same node and go to two different neighbors, such that no other half-edge
  to another node lies between them in angle-sorted order.  For a node whose
  neighbors are sorted by angle, a wedge is formed by consecutive neighbors
  `n` and `n+1` (with wrap-around).
- A **node with a single connection** is a road node that is connected by an
  edge to exactly one other node.
- A **node with multiple connections** is a road node that is connected by edges
  to two or more other nodes.

For every node:

1. Collect all connected half-edges (`node -> neighbor`).
2. Sort them by angle around the world Y axis using `atan2(dz, dx)`.
3. Determine lane counts for each half-edge:
   - For half-edge `node -> neighbor` belonging to edge `(node, neighbor)`:
     - If the edge stores `node = nodeA, neighbor = nodeB`, the **outbound**
       lane count is `lanesAtoB` and the **inbound** lane count is `lanesBtoA`.
     - If stored as `node = nodeB, neighbor = nodeA`, the outbound lane count is
       `lanesBtoA` and the inbound lane count is `lanesAtoB`.
   - Resolve 0 values to `tc.roadConfig.lanesPerDirection`.

**Node with multiple connections** (a node which connects by edges to multiple
other nodes, i.e. 2+ half-edges):
- For each consecutive pair of sorted half-edges `(H_i, H_{i+1})`, with wrap
  around from the last to the first, create one wedge.
- The wedge covers the V-shaped region bounded by the two half-edges.

**Node with a single connection** (a node which is connected by edge to just one
other node, i.e. exactly one half-edge):
- Create one straight segment along the single half-edge.  This is handled as a
  degenerate 180° wedge or as a separate straight-segment primitive.

**Angle validation**:
- Compute the smaller angle between the two half-edges of a wedge.
- If the angle is < 30° or > 270°, the wedge is degenerate.
  - For < 30°: optionally split the angle by inserting a temporary midpoint
    vertex or reject the operation in the editor (preferred: constrain the graph
    so users cannot create such sharp turns).
  - For > 270°: skip the wedge and log a warning.

---

#### M5.6 Wedge geometry generation

A wedge is generated by sweeping the 1-unit template mesh inside the V-shaped
region defined by its two half-edges.

**Wedge coordinate frame**:
- Origin: the node position.
- Radial axis `R`: distance from the node along a half-edge.
- Lateral axis `S`: perpendicular to `R` in the horizontal plane, pointing
  across the road.

**Lane layout within a wedge**:
- Each half-edge has an outbound lane count `L_out` and an inbound lane count
  `L_in`.
- For wedge `(H_i -> H_{i+1})`, the road width on the `H_i` side is determined
  by `H_i`'s outbound lanes, and the width on the `H_{i+1}` side is determined
  by `H_{i+1}`'s inbound lanes.
- The total width varies linearly from the `H_i` side to the `H_{i+1}` side.
- For asymmetric lanes, the divider line (center of the road cross-section)
  varies across the wedge; interpolate its offset between the two half-edges.

**Sweeping the template**:
- For each radial sample `r` from `0` to `halfEdgeLength` (use the shorter of the
  two half-edges as the radial limit, or sample each half-edge independently and
  triangulate between them):
  - Compute the two boundary points of the wedge at radius `r`, one on each
    half-edge.
  - Between these two boundary points, place copies of the template mesh scaled
    to the local width and oriented to follow the wedge surface.
- UV x repeats along the radial direction; UV y maps across the lateral width.

**Center-gap filling**:
- Vertices near the wedge origin (`r < 0.1`) are shifted toward the node center
  along their radial direction so the geometry overlaps at the node.
- This is applied per-wedge, independently.  A small overlap (`~0.05` world
  units) prevents visible gaps at intersections without requiring global
  knowledge of all wedges.

**Vertical placement**:
- Sample the road surface height at the node and at the neighbor ends using the
  edge's `roadLevelA`/`roadLevelB` values.
- Interpolate linearly across the wedge.

**Straight segment for a single-connection node**:
- Transform and repeat the template mesh along the single half-edge.
- No wedge blending is needed.
- Apply the same lane-count/asymmetric-lane rules as for a wedge side.

---

#### M5.7 Edge length constraint

- Prefer half-edge lengths that are integer multiples of the template length
  (1 world unit).  When adding or splitting a node, snap the new node position
  to satisfy this for the affected half-edges.
- If snapping is impossible, scale the last template copy on each affected
  half-edge non-uniformly and adjust UVs.  Log a warning in the editor.

---

#### M5.8 Mesh assembly per page

For each affected terrain page:

1. Create a `TriangleBufferComponent` entity as a child of the terrain entity.
2. Add a `TransformComponent` to the same entity so `NavMeshSystem` can discover
   the generated `Ogre::Entity` via the scene node.
3. Add a `NavMeshGeometrySource` component with `include = true` so the road
   surface contributes to the navigation mesh.
4. Fill its `buffer` directly with the generated wedge triangles and the
   straight-segment triangles for nodes with a single connection (do **not**
   create `PrimitiveComponent` children; the buffer is built procedurally).
5. Set `tb.meshName = "Road_" + terrainId + "_" + pageX + "_" + pageY`.
6. Create a `ProceduralMaterialComponent` entity whose `materialName` is
   `tc.roadConfig.roadMaterialName`.  If the material resource does not exist,
   `RoadSystem` creates a simple diffuse material with that name.
7. Set `tb.materialEntity` to the material entity.
8. Mark `tb.dirty = true` so `ProceduralMeshSystem::update()` converts the buffer
   to an Ogre mesh.
9. After mesh creation, retrieve `tb.ogreEntity` and call
   `entity->setRenderingDistance(tc.roadConfig.roadVisibilityDistance)`.
10. Add a `LodComponent` with a `LodSettingsComponent` that has one reduction
    level at `roadLodDistance` (50% vertex reduction).

**Navmesh dirty marking**: After creating or destroying a page's road geometry,
compute the page's world AABB (expanded by `roadVisibilityDistance`) and mark the
affected navmesh tiles dirty.  If `NavMeshSystem` exposes a partial rebuild
method, use it; otherwise set `NavMeshComponent::dirty = true` on the scene's
navmesh entity for a full rebuild.

**Lifecycle**: road meshes are tied to terrain page load/unload.
- When a terrain page loads, `RoadSystem` creates the page's
  `TriangleBufferComponent` and marks it dirty.
- When a terrain page unloads, `RoadSystem` destroys the page's buffer entity,
  removes its mesh from `Ogre::MeshManager`, removes the physics body, destroys
  any spawned roadside prefabs, and removes the `NavMeshGeometrySource` so the
  navmesh contribution is gone.
- When the road graph or config changes, mark all affected pages dirty so they
  rebuild on the next update.

---

#### M5.9 Road physics colliders

Road colliders are created together with the road mesh when a terrain page
loads and destroyed together with it when the page unloads:

1. Collect the same vertices/indices used for the render mesh.
2. Build a `JPH::MeshShape` using `JoltPhysicsWrapper::createMeshShape()`.
3. Create a static body with `JPH::EMotionType::Static` and `Layers::NON_MOVING`
   via `JoltPhysicsWrapper::createBody()`.
4. Add the body with `JoltPhysicsWrapper::addBody(bodyId,
   JPH::EActivation::DontActivate)`.
5. Store the body ID in a `PhysicsColliderComponent` on the page's buffer entity.
6. On page unload or rebuild, call `JoltPhysicsWrapper::removeBody()` /
   `destroyBody()` for the old body first.

The collider body is independent of the `NavMeshGeometrySource` used for
navigation; both are derived from the same generated road geometry so the
walkable surface and collision surface match.

---

#### M5.10 Terrain compliance (conform, not flatten)

The terrain must hug the underside of the road: it should not poke through the
road surface and should not leave gaps beneath it.  The road surface itself is
not required to be flat — it follows the node heights and edge slopes.

**Algorithm**:

1. For every point on the generated road surface (edge samples + wedge samples),
   compute the world position `roadSurfacePos` of the road top.
2. The desired terrain height directly below that point is:
   `targetY = roadSurfacePos.y - roadThickness`.
3. Write `targetY` into the fixup chunk covering `(roadSurfacePos.x,
   roadSurfacePos.z)`.
4. Apply a smooth falloff perpendicular to the road:
   - Full compliance within the road width (all lanes).
   - Linear fade over `laneWidth * 2` outside the outermost lane.
   - Beyond the fade zone, leave the fixup entry empty (use base heightmap +
     detail noise).
5. After all writes, mark affected terrain pages dirty and save the fixup
   chunks.

**Implementation note**: compliance must run on the main thread before marking
pages dirty.  It should sample the already-generated road geometry, not a
simplified representation, so the terrain matches the exact road surface.

---

#### M5.11 Roadside prefab spawning

For each `RoadEdge::RoadSidePrefab`:

1. Compute base position along the edge:
   ```cpp
   Ogre::Vector3 pos = lerp(nodeA.position, nodeB.position, prefab.edgeT);
   ```
2. Compute lateral offset:
   ```cpp
   Ogre::Vector3 dir = (nodeB.position - nodeA.position);
   dir.y = 0;
   dir.normalise();
   Ogre::Vector3 side = Ogre::Vector3::UNIT_Y.crossProduct(dir);
   if (!prefab.leftSide) side = -side;
   pos += side * prefab.sideOffset;
   ```
3. Snap Y to terrain: `pos.y = TerrainSystem::getInstance()->getHeightAt(pos)`.
4. Instantiate via `PrefabSystem::createInstance(prefabPath, terrainEntity,
   pos, name, uiSystem)`.
5. Track spawned entities in `RoadPageGeometry::spawnedPrefabs` and destroy them
   when the page geometry is rebuilt **or when the terrain page is unloaded**.

Roadside prefabs are runtime-only and page-attached; they are regenerated from
edge data when the page is loaded.

---

#### M5.12 Serialization and system wiring

**Serialization**:
- Road config, nodes, edges, and side prefabs are already serialized in
  `SceneSerializer::serializeTerrain()` / `deserializeTerrain()`.
- Any change to nodes/edges/config must set `tc.dirty = true` so the road
  geometry rebuilds.
- Runtime meshes, colliders, visual aids, and spawned prefabs are **not**
  serialized.

**System wiring**:
1. `TerrainSystem` creates `RoadSystem` in `activate()`.
2. `TerrainSystem::update()` calls `m_roadSystem->update(deltaTime)` after
   `rebuildDirtyPages()`.
   - `RoadSystem` checks `TerrainGroup::getTerrainSlots()` for newly loaded
     pages and creates road meshes/colliders/prefabs and navmesh geometry for
     them.
   - `RoadSystem` detects unloaded pages (slots with no instance) and destroys
     their road meshes/colliders/prefabs and navmesh geometry.
3. After any road graph/config change, `RoadSystem` marks affected navmesh tiles
   dirty so `NavMeshSystem` rebuilds them.
4. `TerrainSystem::deactivate()` destroys `RoadSystem` before tearing down the
   terrain group.

---

### Milestone 5 definition of done

- [ ] Road Edit Mode activates a visual 3D editing state with node markers,
      edge lines, and road-width visual aids.
- [ ] New nodes are placed on the terrain surface and stay snapped to it when
      moved with the gizmo.
- [ ] User can add, select, move, split, join, and delete road nodes visually.
- [ ] Per-edge asymmetric lane counts (`lanesAtoB` != `lanesBtoA`) render
      correctly.
- [ ] Road mesh is generated from angle-sorted wedges by sweeping the 1-unit
      template inside each V-shaped wedge; nodes with a single connection
      generate straight segments; angle validation and center-gap filling
      prevent degenerate geometry and visible seams.
- [ ] Road meshes use the configured material, LOD, and visibility distance.
- [ ] Static Jolt `MeshShape` colliders match the visible road geometry.
- [ ] Navigation mesh is rebuilt for terrain pages that contain roads; road
      surfaces are walkable.
- [ ] "Comply Terrain to Roads" makes the terrain follow the road underside
      (sloped/curved where the road is sloped/curved) without gaps or
      intersections.
- [ ] Roadside prefabs spawn at configured edge positions and are recreated on
      scene load.
- [ ] Save/reload round-trip preserves road nodes, edges, config, and side
      prefabs.
- [ ] Road meshes, colliders, spawned prefabs, and navmesh geometry are created
      when a terrain page loads and destroyed when it unloads.
- [ ] Removing the terrain entity destroys all road meshes, colliders, visual
      aids, spawned prefabs, and navmesh geometry.

### Milestone 6 — Prefab spawns and terrain compliance
- `TerrainPrefabSpawnerComponent` + `TerrainPrefabSpawnerModule`.
- `TerrainPrefabSpawnerSystem` with distance-based spawn/despawn via
  `PrefabSystem`.
- Terrain-snap at placement (raycast against terrain).
- Terrain compliance tool: flatten terrain under prefab footprint using fixup
  chunks.

Definition of done: towns/rocks spawn at configured locations on terrain and sit
flush; save/load round-trips correctly.

## 11. Risks and Mitigations

| Risk | Mitigation |
|------|------------|
| Background job crash on terrain removal | Always check `isDerivedDataUpdateInProgress()`; defer removal to next frame. |
| Memory leak on repeated add/remove | `TerrainSystem` owns all runtime objects; verify destruction order in tests. |
| Physics/visual mismatch | Use zigzag triangulation; compare debug draw of Jolt body against Ogre terrain wireframe. |
| Large world size precision loss | Project already uses `JPH_DOUBLE_PRECISION`; keep world coordinates as `double`/`long` where possible. Scale `worldSize` to realistic scene dimensions. |
| Large heightmap build stalls | Use Ogre's background prepare; create Jolt colliders only after page is fully loaded. |
| Road mesh too heavy | Join wedges, run LOD, limit visibility distance, use instancing for repeated pieces. |
| Road wedge seam gaps | Shift near-seam vertices by 0.05; overlap adjacent wedge edges. |
| Navmesh rebuild storm on page load | Tag road page entities with `NavMeshGeometrySource`; mark only affected tiles dirty; avoid full rebuilds on every page load. |
| Fixup chunk read/write races | `CustomTerrainDefiner::define()` runs on Ogre's WorkQueue; fixup writes must happen on the main thread before marking pages dirty. |
| Terrain not visible in water reflections | Ensure terrain render queue/visibility mask matches water RTT camera setup. |
| TerrainPaging/PageManager destruction order | Destroy `TerrainPaging` before `PageManager` (Paging holds reference to PageManager). |
| EditorApp destructor leaves Ogre objects alive | Explicitly `reset()` water/skybox/sun/terrain systems before physics and SceneManager shutdown. |

## 12. Testing Checklist

- [ ] Add terrain entity → paged terrain renders.
- [ ] Remove terrain entity → no crash, no leaks, no dangling Jolt bodies.
- [ ] Add, remove, add again repeatedly → stable memory (valgrind/ASan).
- [ ] Camera fly-through → pages load/unload correctly.
- [ ] Place a rigid body on a slope → it rests flush with terrain (no sinking/floating).
- [ ] Sculpt heightmap → visual and collision update (brush raise/lower/smooth/flatten).
- [ ] Paint splat layer → terrain surface changes appearance at painted locations.
- [ ] Paint splat, save, reload → blend map changes persist.
- [ ] Create/edit/delete AuxMap → values stored and loaded correctly.
- [ ] Clear all fixups → terrain returns to base heightmap only.
- [ ] Save scene, exit, reload → terrain restored with correct heightmap and layers.
- [ ] Create road network → road geometry appears with correct lane count and terrain compliance.
- [ ] Road page load → Jolt `MeshShape` collider exists and navigation mesh covers the road surface.
- [ ] Road page unload → road collider and navmesh contribution are removed.
- [ ] Per-edge lane override → edge with `lanesAtoB=2` renders two lanes A→B.
- [ ] Save/reload scene with roads → nodes, edges, and config restored.
- [ ] Place prefab spawner → prefab appears at correct distance and sits on terrain.
- [ ] Move camera far away and back → prefab despawns and respawns correctly.
- [ ] Terrain reflected in water → terrain visible in reflection RTT pass.
- [ ] Height function round-trip: write known heights → sample them back → values match.
- [ ] Fixup chunk persistence: write fixup → save → reload → fixup still applied.
- [ ] Build with ASan/UBSan if available → no reports.

## 13. Reference Code

- Legacy terrain module: `src/gamedata/TerrainModule.cpp` and
  `src/gamedata/TerrainModule.h`.
- Ogre EndlessWorld sample: `../../ogre/Samples/EndlessWorld`.
- Ogre::Terrain core: `../../ogre/Components/Terrain`.
- Bullet zigzag heightfield: `../../bullet3/src/BulletCollision/CollisionShapes/btHeightfieldTerrainShape.cpp`
  (search for `m_useZigzagSubdivision`).
- Jolt heightfield shape: `../../jolt/Jolt/Physics/Collision/Shape/HeightFieldShape.cpp`.
- Current Jolt wrapper: `src/features/editScene/physics/physics.h/.cpp`.
- Existing editScene component patterns:
  - `src/features/editScene/components/WaterPlane.hpp`
  - `src/features/editScene/components/WaterPlaneModule.cpp`
  - `src/features/editScene/systems/EditorWaterPlaneSystem.hpp`
  - `src/features/editScene/systems/SceneSerializer.cpp`
