Terrain improvements
This commit is contained in:
@@ -86,20 +86,22 @@ to `EditorApp`.
|
||||
4. `AnimationTreeSystem` / `BehaviorTreeSystem`
|
||||
5. `PathFollowingSystem`
|
||||
6. `ProceduralMeshSystem`
|
||||
7. `RoomLayoutSystem`
|
||||
8. `CellGridSystem`
|
||||
9. `NormalDebugSystem`
|
||||
10. `NavMeshSystem`
|
||||
11. `SmartObjectSystem`
|
||||
12. `GoapPlannerSystem`
|
||||
13. `GoapRunnerSystem`
|
||||
14. `ActuatorSystem`
|
||||
15. `EventHandlerSystem`
|
||||
16. `CharacterSystem`
|
||||
17. `BuoyancySystem`
|
||||
18. `PhysicsSystem`
|
||||
19. `HairPhysicsSystem` pose read-back
|
||||
20. Rendering support systems (sun, skybox, water, light, LOD, etc.)
|
||||
7. `TerrainSystem`, then `TerrainPrefabSpawnerSystem` (spawn Y-snap and
|
||||
terrain compliance see fresh terrain data)
|
||||
8. `RoomLayoutSystem`
|
||||
9. `CellGridSystem`
|
||||
10. `NormalDebugSystem`
|
||||
11. `NavMeshSystem`
|
||||
12. `SmartObjectSystem`
|
||||
13. `GoapPlannerSystem`
|
||||
14. `GoapRunnerSystem`
|
||||
15. `ActuatorSystem`
|
||||
16. `EventHandlerSystem`
|
||||
17. `CharacterSystem`
|
||||
18. `BuoyancySystem`
|
||||
19. `PhysicsSystem`
|
||||
20. `HairPhysicsSystem` pose read-back
|
||||
21. Rendering support systems (sun, skybox, water, light, LOD, etc.)
|
||||
|
||||
Systems that write animation state or velocity should respect systems that run
|
||||
before them. In particular, `PlayerControllerSystem` runs first and is the only
|
||||
@@ -135,6 +137,53 @@ bool isSpawnerLocked(flecs::entity spawner) const;
|
||||
Spawned characters have `EditorMarkerComponent` removed and are removed from
|
||||
`EditorUISystem` caches so they don't appear in editor lists.
|
||||
|
||||
### TerrainPrefabSpawnerSystem
|
||||
|
||||
Distance-based spawn/despawn of prefab instances on terrain
|
||||
(`TerrainPrefabSpawnerComponent` holds `prefabPath` + squared distances; the
|
||||
world transform lives on `TransformComponent`). Singleton access via
|
||||
`TerrainPrefabSpawnerSystem::getInstance()` (same pattern as `TerrainSystem`).
|
||||
Spawner Y is snapped to the terrain surface at placement and on spawn;
|
||||
`complyTerrainToPrefab()` flattens the terrain under the spawned prefab's
|
||||
footprint using fixup chunks. Spawned instances are runtime-only:
|
||||
`EditorMarkerComponent` is stripped and they are never serialized. The editor
|
||||
"prefab spawn mode" (Terrain panel, ESC to exit) places spawners by clicking
|
||||
the terrain.
|
||||
|
||||
### RoadSystem
|
||||
|
||||
Per-terrain procedural roads (`RoadGraph` component, geometry built by
|
||||
`roadlib/RoadGeometryLib` — see `ProceduralRoadGeometry.md`):
|
||||
|
||||
- Wedge/segment meshes, physics colliders, terrain compliance fixups and
|
||||
navmesh dirty-marking are driven by the graph version; bump
|
||||
`RoadGraph::bumpVersion()` after every edit.
|
||||
- **Edge splitting**: each edge row in the Terrain panel edge list has a
|
||||
"Split" button (`RoadGraph::splitEdge` at t=0.5, prefab slots are
|
||||
remapped onto the two halves).
|
||||
- **Connect tool**: the road toolbar "Connect" radio pins a source node in
|
||||
the viewport; clicking another node joins them (validated by
|
||||
`RoadGraph::connectNodes` — self/duplicate and wedge-angle violations are
|
||||
rejected with feedback; cross-page connections are auto-split at the page
|
||||
boundary with an inserted node). The second node becomes the new
|
||||
source, so chains can be drawn click-by-click.
|
||||
- **Smoothing**: a selected node with exactly two neighbors shows a
|
||||
"Smooth A–B–C" action (`RoadGraph::smoothNode`, position relaxation —
|
||||
no graph mutation).
|
||||
- **Roadside prefabs**: three fixed slots per edge (`prefabLeft`,
|
||||
`prefabRight`, `prefabMid` — anchors at left curb / right curb /
|
||||
centerline). Spawning is per edge (`RoadSystem::m_edgePrefabs`, keyed by
|
||||
`RoadSystem::edgePrefabKey`) with plain-meter spawn/despawn hysteresis
|
||||
(`RoadConfig::prefabSpawnDistance`/`prefabDespawnDistance`); road edit
|
||||
mode force-spawns the selected edge's slots as a WYSIWYG preview.
|
||||
Teardown goes through the shared `PrefabSystem::destroyInstance()`.
|
||||
- **Sidewalks**: `RoadConfig::sidewalkEnabled` etc. append an elevated
|
||||
pedestrian strip per wedge outer curb (widened curb-offset chains —
|
||||
see `ProceduralRoadGeometry.md` §15); terrain compliance starts its
|
||||
falloff at the sidewalk outer edge.
|
||||
|
||||
Road edit mode exits with ESC (same as prefab spawn mode).
|
||||
|
||||
### Player Character Resolution
|
||||
|
||||
Never resolve the player by matching `PlayerControllerComponent::targetCharacterName`
|
||||
|
||||
@@ -47,6 +47,7 @@ set(EDITSCENE_SOURCES
|
||||
systems/PlayerControllerSystem.cpp
|
||||
systems/CharacterSlotSystem.cpp
|
||||
systems/CharacterSpawnerSystem.cpp
|
||||
systems/TerrainPrefabSpawnerSystem.cpp
|
||||
systems/OgreEntityHack.cpp
|
||||
systems/CharacterRegistry.cpp
|
||||
systems/MarkovNameGenerator.cpp
|
||||
@@ -103,6 +104,7 @@ set(EDITSCENE_SOURCES
|
||||
ui/TriangleBufferEditor.cpp
|
||||
ui/CharacterSlotsEditor.cpp
|
||||
ui/CharacterSpawnerEditor.cpp
|
||||
ui/TerrainPrefabSpawnerEditor.cpp
|
||||
ui/CharacterIdentityEditor.cpp
|
||||
ui/AnimationTreeEditor.cpp
|
||||
ui/AnimationTreeNodeEditor.cpp
|
||||
@@ -147,6 +149,7 @@ set(EDITSCENE_SOURCES
|
||||
components/TriangleBufferModule.cpp
|
||||
components/CharacterSlotsModule.cpp
|
||||
components/CharacterSpawnerModule.cpp
|
||||
components/TerrainPrefabSpawnerModule.cpp
|
||||
components/AnimationTreeModule.cpp
|
||||
components/AnimationTree.cpp
|
||||
components/CharacterModule.cpp
|
||||
@@ -280,7 +283,9 @@ set(EDITSCENE_HEADERS
|
||||
systems/EventHandlerSystem.hpp
|
||||
ui/EventHandlerEditor.hpp
|
||||
components/PrefabInstance.hpp
|
||||
components/TerrainPrefabSpawner.hpp
|
||||
ui/PrefabInstanceEditor.hpp
|
||||
ui/TerrainPrefabSpawnerEditor.hpp
|
||||
|
||||
systems/ItemSystem.hpp
|
||||
components/Item.hpp
|
||||
@@ -300,6 +305,7 @@ set(EDITSCENE_HEADERS
|
||||
systems/EditorWaterPlaneSystem.hpp
|
||||
systems/TerrainSystem.hpp
|
||||
systems/RoadSystem.hpp
|
||||
systems/TerrainPrefabSpawnerSystem.hpp
|
||||
systems/LightSystem.hpp
|
||||
systems/CameraSystem.hpp
|
||||
systems/LodSystem.hpp
|
||||
@@ -861,5 +867,9 @@ add_custom_command(TARGET editSceneEditor POST_BUILD
|
||||
COMMAND ${CMAKE_COMMAND} -E copy_directory
|
||||
"${CMAKE_CURRENT_SOURCE_DIR}/resources"
|
||||
"${CMAKE_CURRENT_BINARY_DIR}/resources"
|
||||
# Test fixtures (road side-prefab test, etc.)
|
||||
COMMAND ${CMAKE_COMMAND} -E copy_directory
|
||||
"${CMAKE_CURRENT_SOURCE_DIR}/tests/prefabs"
|
||||
"${CMAKE_CURRENT_BINARY_DIR}/tests/prefabs"
|
||||
COMMENT "Copying resources to editSceneEditor build directory"
|
||||
)
|
||||
|
||||
@@ -20,6 +20,8 @@
|
||||
#include "systems/CharacterSlotSystem.hpp"
|
||||
#include "systems/CharacterSpawnerSystem.hpp"
|
||||
#include "components/CharacterSpawner.hpp"
|
||||
#include "systems/TerrainPrefabSpawnerSystem.hpp"
|
||||
#include "components/TerrainPrefabSpawner.hpp"
|
||||
#include "systems/AnimationTreeSystem.hpp"
|
||||
#include "systems/HairPhysicsSystem.hpp"
|
||||
#include "systems/BehaviorTreeSystem.hpp"
|
||||
@@ -388,6 +390,8 @@ void EditorApp::destroyEditorSystems()
|
||||
m_animationTreeSystem.reset();
|
||||
m_characterSlotSystem.reset();
|
||||
m_characterSpawnerSystem.reset();
|
||||
/* Despawn prefab instances before terrain/physics teardown. */
|
||||
m_terrainPrefabSpawnerSystem.reset();
|
||||
m_proceduralMeshSystem.reset();
|
||||
m_proceduralMaterialSystem.reset();
|
||||
m_proceduralTextureSystem.reset();
|
||||
@@ -587,6 +591,15 @@ void EditorApp::setup()
|
||||
m_uiSystem.get());
|
||||
m_characterSpawnerSystem->initialize();
|
||||
|
||||
// Setup TerrainPrefabSpawner system (M6): needs the camera
|
||||
// system for distance checks and physics for body cleanup on
|
||||
// despawn; must exist before the terrain system updates.
|
||||
m_terrainPrefabSpawnerSystem =
|
||||
std::make_unique<TerrainPrefabSpawnerSystem>(
|
||||
m_world, m_sceneMgr, m_cameraSystem.get(),
|
||||
m_uiSystem.get(), m_physicsSystem.get());
|
||||
m_terrainPrefabSpawnerSystem->initialize();
|
||||
|
||||
// Setup AnimationTree system
|
||||
m_animationTreeSystem = std::make_unique<AnimationTreeSystem>(
|
||||
m_world, m_sceneMgr);
|
||||
@@ -1456,6 +1469,9 @@ void EditorApp::setupECS()
|
||||
// Register PrefabInstance component
|
||||
m_world.component<PrefabInstanceComponent>();
|
||||
|
||||
// Register TerrainPrefabSpawner component
|
||||
m_world.component<TerrainPrefabSpawnerComponent>();
|
||||
|
||||
// Register Item and Inventory components
|
||||
m_world.component<ItemComponent>();
|
||||
m_world.component<InventoryComponent>();
|
||||
@@ -1633,6 +1649,12 @@ bool EditorApp::frameRenderingQueued(const Ogre::FrameEvent &evt)
|
||||
m_terrainSystem->update(evt.timeSinceLastFrame);
|
||||
}
|
||||
|
||||
/* --- Terrain prefab spawners (after terrain so spawn Y-snap
|
||||
* and compliance see fresh terrain data) --- */
|
||||
if (m_terrainPrefabSpawnerSystem) {
|
||||
m_terrainPrefabSpawnerSystem->update();
|
||||
}
|
||||
|
||||
/* --- Static world generation (meshes + physics) --- */
|
||||
if (m_roomLayoutSystem) {
|
||||
m_roomLayoutSystem->update();
|
||||
@@ -1892,6 +1914,21 @@ bool EditorApp::keyPressed(const OgreBites::KeyboardEvent &evt)
|
||||
return true;
|
||||
}
|
||||
|
||||
/* Exit terrain prefab spawn mode with ESC. */
|
||||
if (m_terrainPrefabSpawnerSystem &&
|
||||
m_terrainPrefabSpawnerSystem->isSpawnEditing() &&
|
||||
evt.keysym.sym == OgreBites::SDLK_ESCAPE) {
|
||||
m_terrainPrefabSpawnerSystem->setSpawnEditMode(false);
|
||||
return true;
|
||||
}
|
||||
|
||||
/* Exit road edit mode with ESC. */
|
||||
if (m_terrainSystem && m_terrainSystem->getRoadEditMode() &&
|
||||
evt.keysym.sym == OgreBites::SDLK_ESCAPE) {
|
||||
m_terrainSystem->setRoadEditMode(false);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (m_gameMode == GameMode::Game) {
|
||||
if (evt.keysym.sym == OgreBites::SDLK_ESCAPE) {
|
||||
if (m_gamePlayState == GamePlayState::Playing)
|
||||
|
||||
@@ -24,6 +24,7 @@ class ProceduralMaterialSystem;
|
||||
class ProceduralMeshSystem;
|
||||
class CharacterSlotSystem;
|
||||
class CharacterSpawnerSystem;
|
||||
class TerrainPrefabSpawnerSystem;
|
||||
class AnimationTreeSystem;
|
||||
class HairPhysicsSystem;
|
||||
class BehaviorTreeSystem;
|
||||
@@ -235,6 +236,10 @@ public:
|
||||
{
|
||||
return m_characterSpawnerSystem.get();
|
||||
}
|
||||
TerrainPrefabSpawnerSystem *getTerrainPrefabSpawnerSystem() const
|
||||
{
|
||||
return m_terrainPrefabSpawnerSystem.get();
|
||||
}
|
||||
ProceduralMeshSystem *getProceduralMeshSystem() const
|
||||
{
|
||||
return m_proceduralMeshSystem.get();
|
||||
@@ -295,6 +300,7 @@ private:
|
||||
std::unique_ptr<ProceduralMeshSystem> m_proceduralMeshSystem;
|
||||
std::unique_ptr<CharacterSlotSystem> m_characterSlotSystem;
|
||||
std::unique_ptr<CharacterSpawnerSystem> m_characterSpawnerSystem;
|
||||
std::unique_ptr<TerrainPrefabSpawnerSystem> m_terrainPrefabSpawnerSystem;
|
||||
std::unique_ptr<AnimationTreeSystem> m_animationTreeSystem;
|
||||
std::unique_ptr<HairPhysicsSystem> m_hairPhysicsSystem;
|
||||
std::unique_ptr<BehaviorTreeSystem> m_behaviorTreeSystem;
|
||||
|
||||
@@ -689,3 +689,95 @@ Build and run:
|
||||
cmake --build <build-dir> --target RoadGeometryDemo
|
||||
./RoadGeometryDemo
|
||||
```
|
||||
|
||||
|
||||
## 15. Sidewalks (M5.15)
|
||||
|
||||
Sidewalks are an optional elevated pedestrian strip appended to the road
|
||||
slab along its outer curb. They are pure geometry: lane counts and the
|
||||
drivable width are unchanged, so nothing derived from the road width
|
||||
needs adjustment. Enabled per graph via `RoadConfig::sidewalkEnabled`
|
||||
with `sidewalkWidth` (lateral extent), `sidewalkHeight` (top elevation
|
||||
above the road surface), `sidewalkThickness` (fallback box thickness)
|
||||
and `sidewalkMeshTemplate` (empty = procedural box).
|
||||
|
||||
### 15.1 Template conventions
|
||||
|
||||
The sidewalk template follows the same template-space conventions as the
|
||||
road template (§2), with one change: the profile Y range is
|
||||
`[-sidewalkThickness, 0]` — the strip *top* sits at template Y = 0, so
|
||||
the phase-2 mapping `worldY = roadSurfaceY + sidewalkHeight + vy` lands
|
||||
the top exactly `sidewalkHeight` above the road surface and the body
|
||||
hangs below it. `makeSidewalkFallbackTemplate(sidewalkThickness)`
|
||||
generates the default box profile; `RoadSystem::getSidewalkTemplate()`
|
||||
mirrors `getRoadTemplate()` and caches the buffer per
|
||||
`sidewalkMeshTemplate`/`sidewalkThickness` pair.
|
||||
|
||||
### 15.2 Wedge strips — widened miter chains
|
||||
|
||||
One strip per wedge along its outer curb, built with the same
|
||||
three-phase pipeline as the road (concatenate → transform → append).
|
||||
Phase 2 differs: instead of mapping template X across `[0, sideWidth]`
|
||||
against the single curb offset, each vertex's X is mapped linearly
|
||||
between two *widened* curb offsets:
|
||||
|
||||
```
|
||||
offIn(d) = curbOffsetWidened(d, ROAD_SIDEWALK_WALL_GAP)
|
||||
offOut(d) = curbOffsetWidened(d, ROAD_SIDEWALK_WALL_GAP + sidewalkWidth)
|
||||
worldXZ = center(d) + offIn(d) + (offOut(d) - offIn(d)) * x
|
||||
worldY = roadSurfaceY(d) + sidewalkHeight + vy
|
||||
```
|
||||
|
||||
UVs: `u = halfEdgeU(...)` (phase-continuous, same as the road), `v`
|
||||
across `[0, sidewalkWidth]`.
|
||||
|
||||
`curbOffsetWidened()` is a static worker extracted from
|
||||
`computeCurbOffset()`: it scales the per-half-edge offset vectors offA /
|
||||
offB by `(len + widen) / len` *before* the miter math, then runs the
|
||||
unchanged corner-blend pipeline. The public `computeCurbOffset()` is a
|
||||
wrapper with `widen = 0`, so road geometry is bit-identical.
|
||||
|
||||
**Why not `center + offset + normalize(offset) * x`?** Extending along
|
||||
the normalized curb offset (the original plan) folds at inner corners:
|
||||
the road's cross-sections pivot around the pinned miter corner K while
|
||||
the curb chain transitions between half-edges, so consecutive sidewalk
|
||||
rows computed from the same rays cross each other near K (coplanar /
|
||||
piercing triangles and inverted normals — caught by
|
||||
`road_geometry_overlap_test`). Widening the offset chains gives the
|
||||
inner and outer sidewalk curbs their own pinned miter corners (K moved
|
||||
outward by the gap and by gap+width respectively), so each chain keeps
|
||||
the §5.4 no-fold property independently and the strip between them
|
||||
cannot self-intersect.
|
||||
|
||||
### 15.3 Z-fighting gap
|
||||
|
||||
`ROAD_SIDEWALK_WALL_GAP = 0.002f` (RoadGraph.hpp) shifts the whole strip
|
||||
slightly outward so the sidewalk's inner wall is never coplanar with the
|
||||
road curb wall. The sidewalk top still overlaps the curb horizontally,
|
||||
so the gap is invisible from above.
|
||||
|
||||
### 15.4 Straight segments
|
||||
|
||||
Dead-end segments (§7) get *two* sidewalk bands — one per curb (inbound
|
||||
and outbound) — each extruded to a slab of `sidewalkThickness` whose top
|
||||
sits at `roadSurfaceY + sidewalkHeight`.
|
||||
`computeSegmentSidewalkBand()` computes the four top corners (offset
|
||||
`width + ROAD_SIDEWALK_WALL_GAP` … `width + gap + sidewalkWidth` from
|
||||
the centerline); `buildSegmentSidewalkGeometry()` extrudes both bands
|
||||
via the shared `extrudeToSlab()`, skipping the far-end skirt so the
|
||||
bands stay open where a future wedge would connect.
|
||||
|
||||
### 15.5 Integration and terrain compliance
|
||||
|
||||
Sidewalk triangles are appended to the same page `TriangleBuffer` as the
|
||||
road, so LOD/visibility distance, the M5.9 collider soup and
|
||||
`markNavMeshDirty()` need no changes. Sidewalks share the road
|
||||
material.
|
||||
|
||||
`RoadSystem::complyTerrain()` accounts for sidewalks when enabled:
|
||||
|
||||
- the perpendicular falloff origin moves outward by `sidewalkWidth`, so
|
||||
the shoulder fade starts at the sidewalk's outer edge;
|
||||
- fixups under sidewalk vertices target the sidewalk *underside*
|
||||
(`top - sidewalkThickness`), so the terrain hugs the strip body
|
||||
instead of its elevated top.
|
||||
|
||||
@@ -0,0 +1,454 @@
|
||||
# Procedural Road Geometry — Improvement Plan
|
||||
|
||||
Procedural road geometry is specified in `ProceduralRoadGeometry.md`; the road
|
||||
milestones and their status live in `TerrainRequirements.md` (Milestone 5,
|
||||
M5.1–M5.12). This plan covers three improvements:
|
||||
|
||||
1. Edge splitting: create a node in the middle of an edge, remove the edge and
|
||||
create two edges through the new node (A–B → A–C–B).
|
||||
2. Sidewalks: an elevated extra lane where vehicles do not go, intended for
|
||||
pedestrians, enabled by checkbox, with selectable template geometry (same
|
||||
mechanism as the normal lane template) and a procedural-box fallback.
|
||||
3. Road prefabs: per-edge left/right side prefabs plus one road-midpoint
|
||||
prefab, with distance-based unload, edit-time preview, easy prefab
|
||||
selection and leak-free resource handling.
|
||||
4. It should be somehow possible to select two nodes on the graph and create
|
||||
new edge.
|
||||
5. It should be possible to select 3 neighboring nodes on the graph and smooth
|
||||
their position (position relaxation, no graph mutation — see §5).
|
||||
|
||||
Each section first states what already exists (with code references — the
|
||||
source is the only source of truth) and then defines the remaining work.
|
||||
|
||||
---
|
||||
|
||||
## 1. Edge splitting (A–B → A–C–B)
|
||||
|
||||
**Status (2026-08-18): ✅ DONE** — per-row "Split" `SmallButton` in the
|
||||
edge list (toolbar button removed), slot remap in `splitEdge`, editor-layer
|
||||
Y snap of the new node, `testRoadDataModel` extended. Verified by the
|
||||
terrain test suite (`--headless --run-terrain-tests=1`).
|
||||
|
||||
### Current state
|
||||
|
||||
The core operation already exists and is exposed in the editor:
|
||||
|
||||
- `RoadGraph::splitEdge(edgeIndex, t)` (`components/RoadGraph.hpp:523`) removes
|
||||
the edge, inserts node C at `lerp(A, B, t)` and pushes two new edges A–C and
|
||||
C–B. C is snapped to integer half-edge lengths per M5.7
|
||||
(`snapToIntegerLength`), and `verticalOffset`/`roadLevel` are interpolated so
|
||||
the road surface stays continuous.
|
||||
- UI: "Split Selected Edge" button in the Terrain panel's road-section
|
||||
toolbar (`ui/TerrainEditor.hpp:733`) — hardcoded `t = 0.5`, i.e. exactly
|
||||
the requested middle split. The interaction is counter-intuitive: the
|
||||
toolbar is overloaded, and splitting takes two steps — select the edge in
|
||||
the edge list, then find the toolbar button.
|
||||
- Tests: `testRoadDataModel` (`systems/TerrainTests.cpp:1334`) and
|
||||
`testRoadEdgeLengthConstraint` (`:2986–3038`).
|
||||
|
||||
### Remaining work
|
||||
|
||||
- **1.1 Move splitting into the edge list.** The edge list
|
||||
(`ui/TerrainEditor.hpp:864-891`) renders one `Selectable` row per edge
|
||||
(with a "Remove" `SmallButton` on the selected row), and the node list
|
||||
already shows the per-row action pattern with its "Connect"
|
||||
`SmallButton`s (`:835-860`). Give *every* edge row its own "Split"
|
||||
`SmallButton` (placed `SameLine` after the row label, like "Connect") that
|
||||
splits that edge at `t = 0.5` — one click, no prior selection, and the
|
||||
overloaded toolbar shrinks accordingly: delete the toolbar's "Split
|
||||
Selected Edge" button (`:733-738`). After the split, select the new node
|
||||
so the gizmo and node inspector follow it.
|
||||
- **1.2 Prefab carry-over on split (bug).** `splitEdge` copies the old edge
|
||||
struct into both new edges, so `sidePrefabs` (and later the §3 slots) are
|
||||
*duplicated*: both halves spawn the same prefabs. Remap instead: prefab
|
||||
`edgeT < actualT` stays on edge A–C with `edgeT' = edgeT / actualT`;
|
||||
otherwise it moves to C–B with `edgeT' = (edgeT - actualT) / (1 - actualT)`
|
||||
(`actualT` is the post-snap position already computed in `splitEdge`).
|
||||
Left/right side semantics are preserved because both halves keep the A→B
|
||||
orientation. With the §3 slot model: the left/right slots remap their
|
||||
`edgeT` this way on both halves; the mid slot transfers to the half that
|
||||
contains the original midpoint, the other half gets an empty slot.
|
||||
- **1.3 New-node Y snap.** `RoadGraph` is terrain-agnostic, so `splitEdge`
|
||||
only interpolates Y (see the comment at `RoadGraph.hpp:561`). After a UI
|
||||
split, snap C's Y to `terrainHeight(x, z) + verticalOffset` in the editor
|
||||
layer, the same way `addRoadNodeAt` does
|
||||
(`systems/EditorUISystem.cpp:2303`).
|
||||
- **1.4 Tests.** Extend `testRoadDataModel`: split an edge carrying prefab
|
||||
slots and assert remapped `edgeT`/side on both halves and that no prefab is
|
||||
duplicated; assert road-surface continuity at C (levels from both halves
|
||||
match).
|
||||
|
||||
---
|
||||
|
||||
## 2. Sidewalks
|
||||
|
||||
**Status (2026-08-18): ✅ DONE** — data model, widened-chain geometry
|
||||
(see the corrected §2.2 mapping below), template + fallback, terrain
|
||||
compliance, editor UI and tests; specified in
|
||||
`ProceduralRoadGeometry.md` §15. Verified by `testRoadWedgeGeometry`
|
||||
(new sidewalk case), `testRoadSerialization` and
|
||||
`road_geometry_overlap_test` (sidewalks enabled in all regression
|
||||
configs).
|
||||
|
||||
New feature — nothing existed before (no mentions in code or docs).
|
||||
|
||||
### Requirement mapping
|
||||
|
||||
"Extra lane where vehicles will not go": sidewalks are pure geometry appended
|
||||
to the road slab; lane counts (`resolveLaneCounts`) are unchanged, so the
|
||||
drivable width — and everything derived from it — is unaffected. Pedestrian
|
||||
routing over sidewalks is crowd/navmesh integration and is out of scope here.
|
||||
|
||||
### 2.1 Data model (`components/RoadGraph.hpp`, `RoadConfig`)
|
||||
|
||||
Global per-graph settings, next to `roadMeshTemplate`:
|
||||
|
||||
```cpp
|
||||
bool sidewalkEnabled = false; // the checkbox
|
||||
float sidewalkWidth = 1.5f; // lateral width in world units
|
||||
float sidewalkHeight = 0.15f; // top elevation above road surface
|
||||
float sidewalkThickness = 0.3f; // fallback box thickness
|
||||
std::string sidewalkMeshTemplate; // empty = procedural box
|
||||
```
|
||||
|
||||
Serialization goes into the existing `roadConfig` block
|
||||
(`systems/SceneSerializer.cpp:4233` write, `:4341` read) with the defaults
|
||||
above, so old scenes load with sidewalks disabled. Per-edge opt-out flags are
|
||||
a possible later extension and are deliberately not part of this plan.
|
||||
|
||||
### 2.2 Geometry (`roadlib/RoadGeometryLib.cpp`)
|
||||
|
||||
One sidewalk strip per wedge along its outer curb (each wedge is one lateral
|
||||
half of the road, so per-wedge strips automatically produce both sides and
|
||||
continuous corner sidewalks), built with the same three-phase pipeline:
|
||||
|
||||
- **Template** — new `RoadSystem::getSidewalkTemplate(cfg)` mirroring
|
||||
`getRoadTemplate` (`systems/RoadSystem.cpp`), same template-space
|
||||
conventions (`ProceduralRoadGeometry.md` §2), cached like
|
||||
`m_templateBuffer`. Empty/missing `sidewalkMeshTemplate` yields a
|
||||
procedural box of `sidewalkWidth × sidewalkThickness` profile.
|
||||
- **Phase 1** — identical concatenation (`N = ceil(L1 + L2)` copies).
|
||||
- **Phase 2 mapping** — the strip starts at the curb and extends outward.
|
||||
The originally planned per-vertex extension
|
||||
`worldXZ = center(d) + offset(d) + normalize(offset(d)) * (vx * sidewalkWidth + ROAD_SIDEWALK_WALL_GAP)`
|
||||
**folds at inner corners** (proven numerically during implementation):
|
||||
cross-sections pivot around the pinned miter corner K while the curb
|
||||
chain transitions between half-edges, so consecutive strip rows cross
|
||||
near K — the overlap test reported coplanar/piercing triangles and
|
||||
inverted normals at K. The shipped mapping instead interpolates
|
||||
between two *widened* curb-offset chains:
|
||||
```
|
||||
offIn(d) = curbOffsetWidened(d, ROAD_SIDEWALK_WALL_GAP)
|
||||
offOut(d) = curbOffsetWidened(d, ROAD_SIDEWALK_WALL_GAP + sidewalkWidth)
|
||||
worldXZ = center(d) + offIn(d) + (offOut(d) - offIn(d)) * vx
|
||||
worldY = roadSurfaceY(d) + sidewalkHeight + vy
|
||||
```
|
||||
`curbOffsetWidened` scales offA/offB by `(len + widen) / len` before
|
||||
the miter math, so the inner and outer sidewalk curbs get their own
|
||||
pinned miter corners and each keeps the §5.4 no-fold property
|
||||
independently (gap/fold-freedom is inherited by construction, not by
|
||||
ray-sharing). Public `computeCurbOffset` is a wrapper with
|
||||
`widen = 0` — road geometry unchanged. UVs:
|
||||
`u = halfEdgeU(...)` (phase-continuous, same as road), `v` across
|
||||
`[0, sidewalkWidth]`.
|
||||
- **Z-fighting** — the sidewalk inner wall is coplanar with the road curb
|
||||
wall over a small Y band. `ROAD_SIDEWALK_WALL_GAP` (~2 mm) shifts the whole
|
||||
sidewalk strip slightly outward so the walls never share a plane; the top
|
||||
surfaces overlap horizontally, so no hole is visible.
|
||||
- **Straight segments** (dead ends, §7 of the spec) get *two* sidewalk bands
|
||||
(inbound and outbound curb), each elevated and extruded via the existing
|
||||
`extrudeToSlab`.
|
||||
- The strips are appended to the same page `TriangleBuffer` as the road, so
|
||||
LOD/visibility distance, the M5.9 collider soup and `markNavMeshDirty()`
|
||||
need no changes. Sidewalks initially share the road material; a separate
|
||||
material (separate buffer/entity per page) is out of scope.
|
||||
|
||||
### 2.3 Terrain compliance
|
||||
|
||||
`RoadSystem::complyTerrain` writes fixups under every buffer vertex, so
|
||||
sidewalk vertices are flattened automatically. Only the falloff origin moves:
|
||||
pass `sideWidth + sidewalkWidth` (when enabled) into `writeComplianceFalloff`
|
||||
(`systems/RoadSystem.hpp:291`) so the shoulder fade starts at the sidewalk's
|
||||
outer edge.
|
||||
|
||||
### 2.4 Editor UI
|
||||
|
||||
In the Terrain panel "Road Config" tree (`ui/TerrainEditor.hpp:683`): an
|
||||
"Enabled" checkbox, width/height/thickness sliders, and a template combo
|
||||
reusing the existing mesh-scan helpers (`scanMeshFiles`/`getMeshList`,
|
||||
`:1460–1518`) with a "(Procedural Box)" default — the same UX as the road
|
||||
mesh combo at `:648`. All edits call `rg.bumpVersion()`.
|
||||
|
||||
### 2.5 Tests
|
||||
|
||||
- `testRoadWedgeGeometry` (`TerrainTests.cpp:2135`): with sidewalks enabled —
|
||||
lateral extent reaches `halfWidth + sidewalkWidth`, sidewalk top Y ≈
|
||||
`roadSurfaceY + sidewalkHeight`, vertex count grows by the sidewalk
|
||||
template count × `ceil(L)`; dead-end segment case has both bands.
|
||||
- `tests/road_geometry_overlap_test.cpp`: enable sidewalks in the existing
|
||||
A–B–C configurations (flat, corner raised/lowered) — the
|
||||
coplanar-overlap/piercing checks must stay green (miter no-fold
|
||||
regression).
|
||||
- `testRoadSerialization` (`TerrainTests.cpp:1346`): `roadConfig` round-trip
|
||||
with the new fields; loading a pre-sidewalk scene yields disabled defaults.
|
||||
|
||||
---
|
||||
|
||||
## 3. Road prefabs
|
||||
|
||||
**Status (2026-08-18): ✅ DONE** — three fixed slots per edge with legacy
|
||||
JSON migration, per-edge spawn records keyed by `edgePrefabKey`,
|
||||
plain-meter spawn/despawn hysteresis, edit-mode force preview, shared
|
||||
`PrefabSystem::destroyInstance`, yaw/Y/anchor transforms, picker UI.
|
||||
Verified by the reworked `testRoadSidePrefabs`.
|
||||
|
||||
### Current state (M5.11) and its problems
|
||||
|
||||
`RoadEdge::sidePrefabs` is an unbounded vector of
|
||||
`{prefabPath, edgeT, sideOffset, leftSide}` (`RoadGraph.hpp:185-211`),
|
||||
serialized per edge (`SceneSerializer.cpp:4254-4277` / `:4371-4396`), spawned
|
||||
by `RoadSystem::spawnSidePrefabs` (`systems/RoadSystem.cpp:985-1050`) once per
|
||||
page finalize. Known defects:
|
||||
|
||||
- **Duplicates across pages**: it iterates *all* graph edges for every
|
||||
finalizing page, so each loaded road page spawns its own copy of every
|
||||
edge's prefabs.
|
||||
- **Name collisions**: instances are named `road_prefab_<A>_<B>_<edgeT>`; two
|
||||
prefabs on one edge with equal `edgeT` collide.
|
||||
- **No yaw alignment**: road direction is never applied to the instance
|
||||
rotation.
|
||||
- **Wrong Y**: snapped to raw terrain height; road levels/elevation ignored.
|
||||
- **Leaks**: teardown is a bare `entity.destruct()` (`RoadSystem.cpp:417`,
|
||||
`:456`) — the Ogre SceneNode/Entity and Jolt bodies survive (contrast
|
||||
`TerrainPrefabSpawnerSystem::destroyInstanceRecursive`,
|
||||
`TerrainPrefabSpawnerSystem.cpp:182-225`).
|
||||
- **Silent failure**: `PrefabSystem::createInstance` returns a live empty
|
||||
entity when the file is missing (`PrefabSystem.cpp:129-141`), so the
|
||||
"failed to spawn" branch is dead and the empty proxy is tracked.
|
||||
- **No distance unload** and **no prefab picker** (raw `InputText`,
|
||||
`ui/TerrainEditor.hpp:1045-1087`).
|
||||
|
||||
### 3.1 Data model — fixed slots instead of a vector
|
||||
|
||||
Replace `sidePrefabs` with three fixed slots per edge
|
||||
(`components/RoadGraph.hpp`):
|
||||
|
||||
```cpp
|
||||
struct RoadEdgePrefabSlot {
|
||||
std::string prefabPath; // empty = slot disabled
|
||||
float edgeT = 0.5f; // normalized position along the edge
|
||||
float lateralOffset = 0.0f; // meters from the anchor, away from road
|
||||
float yOffset = 0.0f;
|
||||
};
|
||||
// RoadEdge members:
|
||||
RoadEdgePrefabSlot prefabLeft; // anchor: left curb (looking A -> B)
|
||||
RoadEdgePrefabSlot prefabRight; // anchor: right curb
|
||||
RoadEdgePrefabSlot prefabMid; // anchor: centerline, edge midpoint
|
||||
```
|
||||
|
||||
Anchors implement "zero position at the road edge, tunable along and
|
||||
perpendicular": the side-slot anchor sits on the curb at `edgeT` — lateral
|
||||
distance from the centerline equals the resolved half-width *of that side*
|
||||
(asymmetric roads have different left/right widths) — and `lateralOffset = 0`
|
||||
places the prefab exactly at the road edge. `prefabMid` anchors at the
|
||||
centerline (`edgeT`, default 0.5) and is the "one per edge, aligned to the
|
||||
road midpoint" prefab.
|
||||
|
||||
**Serialization/migration** (`SceneSerializer.cpp:4254`, `:4371`): write the
|
||||
three slots; when loading an old scene, migrate the first `leftSide == true`
|
||||
entry to `prefabLeft` and the first `false` entry to `prefabRight` (remap
|
||||
`sideOffset` to `lateralOffset = sideOffset - sideHalfWidth`), log and drop
|
||||
any extra entries.
|
||||
|
||||
### 3.2 Spawning, distance unload, lifetime (`systems/RoadSystem.cpp`)
|
||||
|
||||
- **Per-edge records, not per-page.** Track spawn state in
|
||||
`RoadSystem`, keyed by the ordered node-id pair `(min(A,B), max(A,B))` —
|
||||
stable across edge-vector reordering. Remove
|
||||
`RoadPageGeometry::spawnedPrefabs`. This fixes the cross-page duplication.
|
||||
- **Distance gating.** New `RoadConfig` fields `prefabSpawnDistance = 150`
|
||||
and `prefabDespawnDistance = 250` (serialized as plain meters, same
|
||||
convention as `TerrainPrefabSpawnerComponent`). `RoadSystem::update()`
|
||||
re-evaluates when the camera has moved beyond a threshold (the
|
||||
`SpawnerRecord`/`CAM_REEVAL_DIST_SQ` pattern in
|
||||
`TerrainPrefabSpawnerSystem.hpp:157-178`): spawn when the edge is within
|
||||
spawn distance *and* its endpoint pages are loaded; despawn beyond despawn
|
||||
distance (hysteresis), on page unload, on edge removal and on teardown.
|
||||
- **Transform.** Position: `center(edgeT)` plus the lateral anchor described
|
||||
in §3.1. Y: interpolated road surface height
|
||||
`lerp(A.y + roadLevelA, B.y + roadLevelB, edgeT) + yOffset` (terrain
|
||||
compliance already flattens the curb area to road level). Rotation: yaw
|
||||
from the edge direction, composed with the prefab's own stored root
|
||||
rotation. Unique names: `road_prefab_<A>_<B>_<slot>`.
|
||||
- **Failure handling.** Verify the prefab instantiated (file readable /
|
||||
instance flagged) before tracking it; otherwise despawn and log once.
|
||||
- **Graph changes.** On version bump, diff slot data per edge key and respawn
|
||||
only edges whose slots changed; a split (§1.2) remaps slots onto the new
|
||||
halves so prefabs survive the edit.
|
||||
- **No leaks.** Hoist `TerrainPrefabSpawnerSystem::destroyInstanceRecursive`
|
||||
into a shared `PrefabSystem::destroyInstance(entity)` and call it from both
|
||||
systems; it recursively destroys child entities, Jolt bodies, Ogre entities
|
||||
and scene nodes before `destruct()`.
|
||||
|
||||
### 3.3 Editor
|
||||
|
||||
- Rework the edge inspector prefab section (`ui/TerrainEditor.hpp:1045-1087`)
|
||||
into three slot groups (Left / Right / Mid), each with: a prefab picker
|
||||
combo fed by the existing `scanPrefabFiles` helper (`:508-523`, same pattern
|
||||
as the terrain prefab-spawn picker), and `edgeT` / `lateralOffset` /
|
||||
`yOffset` sliders.
|
||||
- **Edit-time preview.** While road edit mode is active, force-spawn the
|
||||
selected edge's prefabs regardless of distance so placement is WYSIWYG;
|
||||
leaving edit mode re-applies distance rules. (Road edit mode currently has
|
||||
no ESC handling — adding ESC-to-exit alongside this, mirroring prefab spawn
|
||||
mode at `EditorApp.cpp:1917-1923`, is a cheap consistency win.)
|
||||
- Config UI: spawn/despawn distance sliders in the "Road Config" tree.
|
||||
|
||||
### 3.4 Tests
|
||||
|
||||
Extend `testRoadSidePrefabs` (`TerrainTests.cpp:3462-3622`, fixtures in
|
||||
`tests/prefabs/`): side anchors sit at the curb (±half-width), mid slot at
|
||||
the centerline midpoint; yaw follows the edge direction; Y follows road
|
||||
level; spawn/despawn hysteresis under camera movement; force-spawn in edit
|
||||
mode; **no duplicates with two road pages loaded**; scene-graph node and
|
||||
Ogre entity counts return to baseline after despawn/teardown cycles (leak
|
||||
check); split remaps slots without duplication.
|
||||
|
||||
---
|
||||
|
||||
## 4. Connecting two nodes with a new edge
|
||||
|
||||
**Status (2026-08-18): ✅ DONE** — "Connect" radio tool with viewport
|
||||
pinning and chaining (the second node becomes the new source),
|
||||
validation with feedback (self/duplicate, cross-page via
|
||||
`edgeStaysWithinOnePage`, wedge-angle rollback), `testRoadDataModel`
|
||||
extended. The node-list "Connect" buttons remain for off-screen nodes.
|
||||
|
||||
### Current state
|
||||
|
||||
- The graph op exists: `RoadGraph::joinNodes(nodeA, nodeB)`
|
||||
(`components/RoadGraph.hpp:593`) creates the edge if absent; `addEdge`
|
||||
(`:480`) rejects self-edges, duplicates and missing nodes, and `joinNodes`
|
||||
logs a warning for sub-`ROAD_MIN_EDGE_LENGTH` edges.
|
||||
- A UI path exists but is list-only: select node A's row, then click the
|
||||
"Connect" `SmallButton` on node B's row (`ui/TerrainEditor.hpp:835-860`).
|
||||
There is no 3D-view gesture, and the selection model is single-node
|
||||
(`RoadSystem::m_selectedNodeId`).
|
||||
- `RoadGraph::edgeStaysWithinOnePage` (`RoadGraph.hpp:631`) encodes the hard
|
||||
page constraint — an edge is valid only when both endpoints lie in the
|
||||
same terrain page — but it is never called today, so cross-page edges can
|
||||
be created and silently break the page-bucketed geometry (M5.4).
|
||||
- The M5.5 wedge-angle limits (30°–270°) are only checked by
|
||||
`RoadGraph::validate()`, never enforced when an edge is created.
|
||||
|
||||
### Remaining work
|
||||
|
||||
- **4.1 Connect tool for the 3D view.** Add a third radio "Connect" next to
|
||||
"Move Node" / "Add Node" (`ui/TerrainEditor.hpp:716-725`; `RoadEditTool`
|
||||
enum at `systems/TerrainSystem.hpp:331-335`). The first click in the
|
||||
viewport pins the source node (reuse `pickRoadNode`,
|
||||
`systems/EditorUISystem.cpp:2217`) and gives it the selection highlight;
|
||||
the second click on another node calls `joinNodes` and clears the pin;
|
||||
clicking empty terrain moves or clears the pin. This is the "select two
|
||||
nodes, create edge" gesture; the node-list "Connect" buttons stay for
|
||||
off-screen nodes.
|
||||
- **4.2 Validation with feedback.** On a connect attempt: reject
|
||||
self/duplicate via `joinNodes` (exists); reject cross-page pairs by wiring
|
||||
up `edgeStaysWithinOnePage` (page world size comes from the
|
||||
`TerrainComponent`); after the edge is added, re-enumerate the wedges at
|
||||
both endpoint nodes and roll back with `removeEdge` if a new wedge
|
||||
violates the 30°–270° limits. Report the rejection reason in the road
|
||||
section, following the existing "Validate Road Graph" modal pattern
|
||||
(`ui/TerrainEditor.hpp:741-767`).
|
||||
- **4.3 Tests.** Extend `testRoadDataModel`
|
||||
(`systems/TerrainTests.cpp:1246`): duplicate/self connects rejected,
|
||||
cross-page pair rejected, angle-violating connect rolled back, successful
|
||||
connect bumps the graph version.
|
||||
|
||||
---
|
||||
|
||||
## 5. Smoothing three neighboring nodes
|
||||
|
||||
**Status (2026-08-18): ✅ DONE** — `RoadGraph::smoothNode` (position
|
||||
relaxation, no graph mutation) with min-length / page-crossing /
|
||||
wedge-angle rollback, inspector UI ("Smooth A–B–C" + strength slider)
|
||||
and neighbor highlight, editor-layer Y re-snap, `testRoadDataModel`
|
||||
extended.
|
||||
|
||||
### Current state
|
||||
|
||||
No smoothing exists. Corners are intentionally sharp — the centerline is a
|
||||
polyline (`ProceduralRoadGeometry.md` §5.3) — and selection is single-node
|
||||
only.
|
||||
|
||||
Key observation: a node with exactly two neighbors unambiguously identifies
|
||||
a 3-node chain A–B–C (itself plus its two neighbors), so the triple needs no
|
||||
new multi-selection infrastructure — clicking the middle node B selects it.
|
||||
|
||||
### Semantics (decided)
|
||||
|
||||
Position relaxation, no graph mutation: B moves toward the straight line
|
||||
A–C; A and C are anchors and never move (they connect to the rest of the
|
||||
network). A strength factor (0..1, default 0.5) controls how far B moves
|
||||
per application, so repeated applications converge to straight:
|
||||
|
||||
```
|
||||
B'.xz = lerp(B.xz, midpoint(A.xz, C.xz), strength)
|
||||
B'.verticalOffset = lerp(B.verticalOffset,
|
||||
(A.verticalOffset + C.verticalOffset) / 2, strength)
|
||||
```
|
||||
|
||||
Y is then re-snapped to `terrainHeight + verticalOffset` by the editor layer
|
||||
(the same helper used by the gizmo drag and by §1.3), keeping the road
|
||||
surface continuous.
|
||||
|
||||
### Remaining work
|
||||
|
||||
- **5.1 Graph op.** New `RoadGraph::smoothNode(int nodeId, float strength)`
|
||||
next to `splitEdge` (`components/RoadGraph.hpp:523`): pure graph function,
|
||||
returns false unless the node has exactly two neighbors; applies the lerp
|
||||
above (terrain-agnostic — no Y snap) and bumps the version.
|
||||
- **5.2 Validation.** A move is rejected and reverted when it would shorten
|
||||
an incident edge below `ROAD_MIN_EDGE_LENGTH`, push B across a terrain
|
||||
page boundary (`edgeStaysWithinOnePage` for both incident edges), or
|
||||
create a wedge outside 30°–270° (re-enumerate at A, B and C).
|
||||
- **5.3 UI.** In the selected-node inspector (`ui/TerrainEditor.hpp:894-958`),
|
||||
when the selected node has exactly two neighbors: show the derived triple
|
||||
("Smooth A–B–C"), a strength slider and a "Smooth" button; extend
|
||||
`buildSelectionHighlight` (`systems/RoadSystem.cpp`) to also mark the two
|
||||
neighbor nodes in a second color so the affected triple is visible in the
|
||||
viewport. Disabled with a hint when the degree is not 2 (junctions and
|
||||
endpoints have no unique triple).
|
||||
- **5.4 Tests.** `testRoadDataModel` additions: degree ≠ 2 rejected; B moved
|
||||
by the exact lerp; `verticalOffset` averaged; min-length and page-crossing
|
||||
moves reverted. Geometry needs no new cases — existing wedge tests
|
||||
already cover straight and angled chains.
|
||||
|
||||
---
|
||||
|
||||
## 6. Implementation order
|
||||
|
||||
1. **§3 Road prefabs** — the slot data model and lifecycle fixes are a
|
||||
prerequisite for §1.2 (split remapping operates on the slot model).
|
||||
2. **§1 Edge splitting** — small, bounded; finishes the split/prefab
|
||||
interaction.
|
||||
3. **§4 Node connecting** and **§5 Corner smoothing** — small, independent
|
||||
graph-editing additions; either order.
|
||||
4. **§2 Sidewalks** — independent of the others; the largest piece
|
||||
(geometry pipeline changes).
|
||||
|
||||
## 7. Documentation and definition of done
|
||||
|
||||
Per the project rules, every landed item updates docs and tests in the same
|
||||
change:
|
||||
|
||||
- `TerrainRequirements.md`: add sub-milestones **M5.13 Road prefab slots and
|
||||
distance unload**, **M5.14 Edge split polish**, **M5.15 Sidewalks**,
|
||||
**M5.16 Node connect tool**, **M5.17 Corner smoothing**, each with a
|
||||
status block and a definition-of-done checklist naming the verifying tests
|
||||
(`--headless --run-terrain-tests=1` suite and
|
||||
`road_geometry_overlap_test`).
|
||||
- `ProceduralRoadGeometry.md`: new section specifying the sidewalk strip
|
||||
(template conventions, per-vertex mapping, miter behavior, UV, segment
|
||||
bands) once §2 lands. **Landed as §15.**
|
||||
- This document: mark items done with dates as they land.
|
||||
@@ -36,9 +36,14 @@ others are not, and `UNCOVERED` when no automated test exists at all.
|
||||
| M5.9 | Road physics colliders| `testRoadPageMeshes` (partial) | PARTIAL | bodyId validity and PhysicsColliderComponent presence asserted at page finalize time. **Missing**: physical interaction (raycast against road collider, collider removal on rebuild). |
|
||||
| M5.9.6| Road collider debug visibility | — | UNCOVERED | New item: toggle for road colliders in physics debug draw. Not tested in headless suite. |
|
||||
| M5.9.5| Fixup chunk support | `testFixupChunks` | COVERED | writeFixup, sampleHeightAt reads override, save/load round-trip, clearAll |
|
||||
| M5.10 | Terrain compliance | — | UNCOVERED | `RoadSystem::complyTerrain()` has no automated test. Perpendicular falloff (laneWidth*2 fade) needs implementation. |
|
||||
| M5.11 | Roadside prefab spawning | — | UNCOVERED | `RoadSystem::spawnSidePrefabs()` has no automated test |
|
||||
| M5.12 | Serialization + wiring| `testRoadSerialization` | COVERED | Config/nodes/edges/sidePrefabs JSON round-trip; wiring: roadSystem lifecycle covered by testRoadPageAssignment + testRoadPageMeshes |
|
||||
| M5.10 | Terrain compliance | `testTerrainCompliance` | COVERED | Under-road / mid-fade / fade-end / beyond-fade heights vs base heightmap, save/load + clear round-trips. With sidewalks enabled (M5.15) the falloff origin moves to the sidewalk outer edge and fixups under sidewalk vertices target the strip underside. |
|
||||
| M5.11 | Roadside prefab spawning | `testRoadSidePrefabs` | COVERED (reworked by M5.13) | Per-edge slot spawn/respawn/despawn, distance gating, edit-mode preview, teardown |
|
||||
| M5.12 | Serialization + wiring| `testRoadSerialization` | COVERED | Config/nodes/edges/prefab-slot JSON round-trip (legacy `sidePrefabs` migrates); wiring: roadSystem lifecycle covered by testRoadPageAssignment + testRoadPageMeshes |
|
||||
| M5.13 | Road prefab slots + distance unload | `testRoadSidePrefabs`, `testRoadSerialization` | COVERED | Slot anchors/yaw/Y, per-edge records (`RoadSystem::getEdgePrefabs`), hysteresis, edit-mode preview, no cross-page duplicates, leak-free despawn |
|
||||
| M5.14 | Edge split polish | `testRoadDataModel` | COVERED | Per-row Split button, slot `edgeT` remap without duplication, surface continuity at the new node |
|
||||
| M5.15 | Sidewalks | `testRoadWedgeGeometry`, `testRoadSerialization`, `road_geometry_overlap_test` | COVERED | Strip extent/top Y/vertex growth, both segment bands, config round-trip; overlap test runs all A–B–C configs with sidewalks enabled |
|
||||
| M5.16 | Node connect tool | `testRoadDataModel` | COVERED | Duplicate/self rejected, cross-page auto-split at the page boundary (incl. corner dedupe), axis-crossing same-page accept, wedge-angle rollback, version bump |
|
||||
| M5.17 | Corner smoothing | `testRoadDataModel` | COVERED | Degree ≠ 2 rejected, exact lerp, `verticalOffset` averaged, min-length/page-crossing moves reverted |
|
||||
|
||||
### 1.1 M5.9 Collider Coverage Detail
|
||||
|
||||
@@ -172,8 +177,11 @@ sampling path observes, including the perpendicular falloff.
|
||||
|
||||
### 2.3 `testRoadSidePrefabs`
|
||||
|
||||
**Purpose**: verify `RoadSystem::spawnSidePrefabs()` creates and destroys
|
||||
prefab instances correctly.
|
||||
**Purpose**: verify the M5.13 slot-based roadside prefab lifecycle in
|
||||
`RoadSystem` — spawn at the correct anchor, respawn on slot edits,
|
||||
distance gating, edit-mode preview and leak-free teardown. (Originally
|
||||
written against the M5.11 `spawnSidePrefabs()`/`sidePrefabs` vector;
|
||||
fully reworked with M5.13.)
|
||||
|
||||
**Test prefab fixture**: a minimal `.prefab` JSON file placed in
|
||||
`src/features/editScene/tests/prefabs/tiny_cube.prefab`. This is a self-contained
|
||||
@@ -182,17 +190,25 @@ test resource; it must not be mixed with user-created prefabs.
|
||||
**Steps**:
|
||||
1. Ensure the test prefab is loadable (the file is registered in a resource
|
||||
group accessible during tests).
|
||||
2. Create terrain, add a road edge with one `RoadSidePrefab` pointing at
|
||||
`tiny_cube.prefab` with `edgeT=0.5, sideOffset=3, leftSide=true`.
|
||||
2. Create terrain, add a road edge, fill its `prefabLeft` slot with
|
||||
`tiny_cube.prefab` (`edgeT=0.5, lateralOffset=0`) and set huge
|
||||
spawn/despawn distances so the test camera is always in range.
|
||||
3. Pump frames until road mesh + prefabs exist.
|
||||
4. Verify `RoadPageGeometry::spawnedPrefabs` is non-empty (1 entity).
|
||||
5. Verify the spawned entity is alive, has a `TransformComponent`, and its
|
||||
position is approximately the expected world position (edge midpoint + 3
|
||||
units left).
|
||||
6. Bump graph version (add a dummy node+edge) → pump frames.
|
||||
- Verify old prefab entity is no longer alive.
|
||||
- Verify `spawnedPrefabs` now contains the new prefab instance.
|
||||
7. Deactivate terrain → verify spawned prefab entity is not alive.
|
||||
4. Verify the per-edge record (`RoadSystem::getEdgePrefabs()` keyed by
|
||||
`RoadSystem::edgePrefabKey(n1, n2)`) has a live `left` instance with a
|
||||
`TransformComponent` at the left-curb anchor (edge midpoint offset by
|
||||
the side half-width), road-level Y and yaw aligned to the edge
|
||||
direction.
|
||||
5. Add an unrelated node+edge → pump frames: the prefab must stay alive
|
||||
(spawn state is per edge, not per page — no cross-page duplicates).
|
||||
6. Edit the slot (`edgeT 0.5 → 0.25`) → pump frames: old instance
|
||||
destroyed, new instance at the remapped anchor.
|
||||
7. Shrink spawn/despawn distances to 1 m → the instance despawns and the
|
||||
root scene-node child count returns to baseline (no leak).
|
||||
8. Enable road edit mode and select the edge → the prefab force-spawns
|
||||
(edit-time preview); leaving edit mode despawns it again.
|
||||
9. Restore large distances (respawn), then destroy the terrain entity →
|
||||
the prefab entity is not alive (teardown).
|
||||
|
||||
## 3. Manual Verification Procedures
|
||||
|
||||
@@ -282,11 +298,11 @@ ordered by dependency.
|
||||
| # | Item | Files to modify | Status |
|
||||
|---|------|-----------------|--------|
|
||||
| W0 | Sweep-based wedge geometry (M5.6 gaps + overlaps) — **superseded 2026-08-02**: the radial curb sweep left node-center holes, diagonal > 180° bands, bowed through-roads and double-height segments; replaced by the mitered polyline sweep (`computeWedgeOutline`/`triangulateOutline` + `emitSlab`) per user direction | `RoadSystem.cpp`, `RoadSystem.hpp`, `TerrainTests.cpp` | ✅ DONE (2026-08-02, reworked) |
|
||||
| W1 | M5.10 perpendicular falloff in `complyTerrain()` | `RoadSystem.cpp` | ✅ DONE |
|
||||
| W1 | M5.10 perpendicular falloff in `complyTerrain()` | `RoadSystem.cpp` | ✅ DONE (2026-08-16 rework: per-sample fade writes, see below) |
|
||||
| W2 | Helper `computeComplianceHeight()` + unit test | `RoadSystem.cpp`, `TerrainTests.cpp` | ✅ DONE |
|
||||
| W3 | `testTerrainCompliance` (automated) | `TerrainTests.cpp`, `TerrainTests.hpp` | ✅ DONE |
|
||||
| W4 | `testRoadColliderInteraction` (automated) | `TerrainTests.cpp`, `TerrainTests.hpp` | ✅ DONE |
|
||||
| W5 | Road collider debug draw toggle (M5.9.6) | `RoadSystem.hpp/.cpp`, `TerrainSystem.hpp/.cpp`, `TerrainEditor.hpp` | — |
|
||||
| W5 | Road collider debug draw toggle (M5.9.6) | `RoadSystem.hpp/.cpp`, `TerrainSystem.hpp/.cpp`, `TerrainEditor.hpp` | ✅ DONE (2026-08-16) |
|
||||
| W6 | Test prefab fixture `tiny_cube.prefab` | `src/features/editScene/tests/prefabs/tiny_cube.prefab` (new) | ✅ DONE |
|
||||
| W7 | `testRoadSidePrefabs` (automated) | `TerrainTests.cpp`, `TerrainTests.hpp` | ✅ DONE |
|
||||
| W8 | Register new tests in `TerrainTestRunner::run()` | `TerrainTests.cpp` | ✅ DONE |
|
||||
@@ -298,7 +314,7 @@ ordered by dependency.
|
||||
| M5.1–M5.8 automated coverage | ✅ Adequate (8/8 sub-items have tests) |
|
||||
| M5.6 wedge geometry | ✅ Mitered polyline sweep (2026-08-02) — replaces the broken radial curb sweep (W0 rework): wedge = one mesh bent along the 2-segment centerline polyline, exact width at corners, no node holes/overlaps |
|
||||
| M5.9 automated coverage | ✅ W4 adds raycast+rebuild verification |
|
||||
| M5.9.6 road collider debug toggle | ❌ Not implemented → W5 |
|
||||
| M5.9.6 road collider debug toggle | ✅ Implemented (W5, 2026-08-16) |
|
||||
| M5.10 perpendicular falloff | ✅ Implemented → W1+W2 |
|
||||
| M5.10 automated coverage | ✅ W3 covers falloff + save/load |
|
||||
| M5.11 automated coverage | ✅ W6+W7 cover prefab spawn + teardown |
|
||||
@@ -311,7 +327,66 @@ ordered by dependency.
|
||||
(2026-08-02 rework; radial curb sweep attempt reverted).
|
||||
- [x] W1–W4, W6–W8 implemented.
|
||||
- [x] `./editSceneEditor --headless --run-terrain-tests=1` passes with all
|
||||
22 tests green per iteration (verified 2026-07-31).
|
||||
22 tests green per iteration (re-verified 2026-08-16 after the
|
||||
re-audit fixes below).
|
||||
- [ ] Manual verification walkthroughs 3.1–3.7 are executed and pass.
|
||||
- [ ] `ctest -R editSceneTerrainTest` passes in CI.
|
||||
- [ ] W5 (road collider debug draw toggle) implemented.
|
||||
- [x] `ctest -R editSceneTerrainTest` passes in CI (verified 2026-08-16,
|
||||
102 s).
|
||||
- [x] W5 (road collider debug draw toggle) implemented (2026-08-16).
|
||||
|
||||
## 6. Re-audit fixes (2026-08-16)
|
||||
|
||||
A code-level re-audit (prompted by "do not trust completion status") found
|
||||
several items marked ✅ that were not actually working; all fixed and now
|
||||
covered by the 22-test headless suite:
|
||||
|
||||
- **W1 was not implemented**: `complyTerrain()` wrote only full-compliance
|
||||
fixups under the road; the `laneWidth * 2` perpendicular fade did not
|
||||
exist. Implemented as `RoadSystem::writeComplianceFalloff()` (wedge
|
||||
outer curb + both segment sides), with `TerrainSystem::sampleBaseHeightAt()`
|
||||
added so fade targets blend toward the natural height rather than reading
|
||||
back already-written fixups.
|
||||
- **W5 was not implemented**: the `TerrainBodyDrawFilter` road-ID set
|
||||
(`RoadSystem::m_roadBodyIds`) was never populated, so the "Show Road
|
||||
Colliders" checkbox drew nothing. `createPageCollider()` /
|
||||
`destroyPageCollider()` now maintain the set.
|
||||
- **Fixup chunk grid unusable at spec density**: M5.9.5/4.2's literal
|
||||
addressing (chunk span `worldSize/256`, 256x256 samples → one sample per
|
||||
`worldSize/65536` units) cannot be filled by the compliance writer, and
|
||||
page vertices (sampling at ~`worldSize/64` spacing) never see the fixups.
|
||||
Chunks now span the full per-page `worldSize` with 256x256 samples (one
|
||||
sample per `worldSize/256` units — "same as base heightmap", section 4.2);
|
||||
chunk indices coincide with page indices. Fixup files written before this
|
||||
change used the old grid and must be deleted
|
||||
(`heightmaps/<terrainId>/terrain_fixup/` or "Clear All Fixups").
|
||||
- **Sentinel blending toward 0.0**: partially written chunk cells blended
|
||||
toward zero, digging trenches at fixup borders; unwritten corners now fall
|
||||
back to the natural (base + noise) height at the corner.
|
||||
- **Falloff writes were sparse point splats** (invisible between chunk
|
||||
cells). The fade pass now evaluates the exact fade target at every fixup
|
||||
sample inside the band (`writeFixupSample()`), so bilinear reads reproduce
|
||||
the linear ramp. The fade pass runs BEFORE the full-compliance pass so
|
||||
slab-underside values win on shared samples.
|
||||
- **Segment underside depth mismatch**: the segment pass wrote
|
||||
`centerY - roadThickness` while the wedge pass wrote `topY -
|
||||
roadThickness` (= `centerY - roadThickness/2`); unified on the half
|
||||
thickness.
|
||||
- **Roadside prefabs leaked on graph rebuild** (M5.11): `buildPageMeshes()`
|
||||
rebuilt mesh + collider but never destroyed `spawnedPrefabs`; old
|
||||
instances survived and new ones spawned on top. Old prefabs are now
|
||||
destroyed at the start of every page mesh rebuild. *(Superseded by
|
||||
M5.13: spawn state moved out of the pages into per-edge records and
|
||||
teardown goes through the shared `PrefabSystem::destroyInstance()`.)*
|
||||
- **Roadside prefabs were serialized with the scene** (M5.12): spawned
|
||||
instances kept their `EditorMarkerComponent` and, as flecs children of the
|
||||
terrain entity, were written into the scene file and duplicated on reload.
|
||||
`spawnSidePrefabs()` now strips the marker (runtime-only entities).
|
||||
*(M5.13: instances are created marker-free by the new spawn path.)*
|
||||
- **Tests strengthened**: `terrainCompliance` checks under-road / mid-fade /
|
||||
fade-end / beyond-fade heights against the natural (procedural) base
|
||||
height plus save/load and clear round-trips; `roadColliderInteraction`
|
||||
raycasts the slab top (40.15) and underside (39.85) on a Y=40 road clear
|
||||
of the terrain and verifies collider replacement across a graph rebuild;
|
||||
`roadSidePrefabs` verifies slot anchor position/yaw/Y, respawn on slot
|
||||
edit, distance gating, edit-mode preview and teardown destruction; the
|
||||
`tiny_cube.prefab` fixture is staged next to the test binary by CMake.
|
||||
|
||||
@@ -640,11 +640,18 @@ Use `EShapeSubType::User1` for the shape subtype.
|
||||
- **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));
|
||||
int chunkX = floor(worldX / worldSize);
|
||||
int chunkZ = floor(worldZ / worldSize);
|
||||
```
|
||||
So chunk `(0,0)` covers world `(0,0)` to `(worldSize/256, worldSize/256)` in
|
||||
X and Z.
|
||||
So chunk `(0,0)` covers world `(0,0)` to `(worldSize, worldSize)` in X and Z
|
||||
— one chunk per terrain page, one sample per `worldSize/256` units (the same
|
||||
density as the base heightmap, matching "same as base heightmap" above).
|
||||
**Corrected 2026-08-16**: the original formula (`worldSize / 256` per chunk,
|
||||
i.e. one sample per `worldSize/65536` units) made the fixup grid 256x denser
|
||||
than the base heightmap; road-compliance writes could not fill it and page
|
||||
meshes never saw the fixups. Fixup files written before the correction use
|
||||
the old grid and must be deleted (`heightmaps/<terrainId>/terrain_fixup/` or
|
||||
the "Clear all fixups" button).
|
||||
- **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
|
||||
@@ -966,6 +973,13 @@ struct TerrainPrefabSpawnerComponent {
|
||||
};
|
||||
```
|
||||
|
||||
> **Implementation note**: as implemented, `position`/`rotation` are NOT
|
||||
> stored in the component — they live on the entity's `TransformComponent`
|
||||
> (single transform source, so editor gizmo moves cannot desync; same
|
||||
> precedent as `CharacterSpawnerComponent`). The implemented component holds
|
||||
> only `prefabPath`, `spawnDistanceSq`, `despawnDistanceSq`, and the runtime
|
||||
> `spawnedEntity`.
|
||||
|
||||
### 6.3 TerrainPrefabSpawnerSystem
|
||||
|
||||
A lightweight system that:
|
||||
@@ -2119,14 +2133,18 @@ 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.
|
||||
|
||||
**State snapshot (2026-07-30)** — re-verified against the working tree:
|
||||
`editSceneEditor` builds cleanly. Nineteen headless tests pass. Milestone 5
|
||||
has all 13 sub-items implemented, but the verification audit
|
||||
(`TerrainML5Verification.md`) identifies gaps in automated test coverage for
|
||||
M5.9 (physics interaction), M5.10 (terrain compliance), and M5.11 (roadside
|
||||
prefab spawning), plus a spec-vs-implementation discrepancy in M5.10's
|
||||
perpendicular falloff. See `TerrainML5Verification.md` for the complete
|
||||
verification plan, manual test procedures, and open questions.
|
||||
**State snapshot (2026-08-16)** — re-verified against the working tree:
|
||||
`editSceneEditor` builds cleanly. All 22 headless tests pass and
|
||||
`ctest -R editSceneTerrainTest` is green. A code-level re-audit on
|
||||
2026-08-16 (completion statuses were not trusted) found several items marked
|
||||
done that were not actually working: M5.10's perpendicular falloff and the
|
||||
M5.9.6 road-collider debug toggle were missing, the fixup chunk grid was too
|
||||
dense to function (corrected in section 4.2), segment underside depth was
|
||||
inconsistent, and roadside prefabs leaked on rebuild and were serialized
|
||||
with the scene. All fixed and covered by tests; see
|
||||
`TerrainML5Verification.md` section 6 for the full list. The manual
|
||||
walkthroughs (`TerrainML5Verification.md` 3.1–3.7) still require a display
|
||||
and remain pending.
|
||||
|
||||
| Item | State | Notes |
|
||||
|------|-------|-------|
|
||||
@@ -2139,9 +2157,9 @@ verification plan, manual test procedures, and open questions.
|
||||
| M5.7 Edge length constraint | ✅ complete | `snapToIntegerLength()` + `ROAD_MIN_EDGE_LENGTH`; `splitEdge` snaps, `joinNodes` warns, `validate` rejects short edges; `roadEdgeLength` test green |
|
||||
| M5.8 Mesh assembly per page | ✅ complete | page entities with `TriangleBufferComponent(proceduralContent)` + `RenderableComponent` + `NavMeshGeometrySource` + `LodComponent`, `roadPageMeshes` test |
|
||||
| M5.9 Road physics colliders | ✅ complete | `createPageCollider`/`destroyPageCollider` in `RoadSystem`, asserted in `roadPageMeshes` |
|
||||
| M5.9.5 Fixup chunk support | ✅ DONE (2026-07-29) | `writeFixup`/`saveFixups`/`clearAllFixups`/`sampleFixupLocked` implemented in `TerrainSystem.cpp`; wired into `sampleHeightAtLocked`; "Clear All Fixups" UI button; `fixupChunks` test green |
|
||||
| M5.10 Terrain compliance | ✅ DONE (2026-07-29) | `RoadSystem::complyTerrain()` walks road wedges/segments, writes fixup under each top-surface vertex; "Comply Terrain to Roads" button wired; works with M5.9.5 |
|
||||
| M5.11 Roadside prefab spawning | ✅ DONE (2026-07-30) | `RoadSystem::spawnSidePrefabs()` creates instances via `PrefabSystem` at edge positions with terrain-snapped Y; tracked and destroyed on page unload/rebuild |
|
||||
| M5.9.5 Fixup chunk support | ✅ DONE (2026-07-29; grid corrected 2026-08-16) | `writeFixup`/`writeFixupSample`/`saveFixups`/`clearAllFixups`/`sampleFixupLocked` implemented in `TerrainSystem.cpp`; wired into `sampleHeightAtLocked`; "Clear All Fixups" UI button; chunk span corrected to the full per-page `worldSize` (see 4.2); `fixupChunks` test green |
|
||||
| M5.10 Terrain compliance | ✅ DONE (2026-07-29; falloff added 2026-08-16) | `RoadSystem::complyTerrain()` walks road wedges/segments, writes fixup under each top-surface vertex, then fades over `laneWidth * 2` outside each curb (`writeComplianceFalloff`, per-sample writes); "Comply Terrain to Roads" button wired; `terrainCompliance` test green |
|
||||
| M5.11 Roadside prefab spawning | ✅ DONE (2026-07-30; respawn fix 2026-08-16) | `RoadSystem::spawnSidePrefabs()` creates instances via `PrefabSystem` at edge positions with terrain-snapped Y; tracked and destroyed on page unload AND on mesh rebuild; spawned instances are stripped of `EditorMarkerComponent` so they stay runtime-only; `roadSidePrefabs` test green |
|
||||
| M5.12 Serialization + wiring | ✅ complete | serialization round-trip for roadConfig/nodes/edges/sidePrefabs; lifecycle + page detection + mesh/collider/prefab creation through M5.8/M5.9/M5.11; fixup save wired into SceneSerializer |
|
||||
|
||||
#### M5.1 Road data model
|
||||
@@ -2923,8 +2941,10 @@ writers yet.
|
||||
sentinel `-FLT_MAX` = "no fixup here" (fall through to base heightmap +
|
||||
detail noise).
|
||||
- Chunk naming/addressing: `x<chunkX>_z<chunkZ>.bin`,
|
||||
`chunkX = floor(worldX / (worldSize / 256))` (same for Z); chunk (0,0)
|
||||
covers world (0,0)..(worldSize/256, worldSize/256).
|
||||
`chunkX = floor(worldX / worldSize)` (same for Z); chunk (0,0) covers one
|
||||
full terrain page — one sample per `worldSize/256` units. **Updated
|
||||
2026-08-16**: the original `worldSize / 256` chunk span made the grid
|
||||
unfillably dense; see the correction note in section 4.2.
|
||||
- Lazy creation: a chunk object/file appears only when a writer first writes
|
||||
into it; absent chunks mean "no fixup".
|
||||
- Runtime storage lives in `TerrainSystem` (in-memory chunk map keyed by chunk
|
||||
@@ -2950,12 +2970,17 @@ writers yet.
|
||||
|
||||
#### M5.10 Terrain compliance (conform, not flatten)
|
||||
|
||||
**Status (2026-07-29): ✅ DONE.** `RoadSystem::complyTerrain()` walks every
|
||||
**Status (2026-08-16): ✅ DONE.** `RoadSystem::complyTerrain()` walks every
|
||||
wedge and straight segment, samples the top-surface vertices, writes
|
||||
`roadSurfaceY - roadThickness` into the fixup chunk at each (X,Z), applies a
|
||||
smooth perpendicular falloff over `laneWidth * 2`, then marks affected pages
|
||||
dirty and saves the fixups. The "Comply Terrain to Roads" button in
|
||||
`TerrainEditor` is wired and functional.
|
||||
smooth perpendicular falloff over `laneWidth * 2`
|
||||
(`writeComplianceFalloff()`, per-sample fade-target writes; the fade pass
|
||||
runs before the full-compliance pass so slab-underside values win on shared
|
||||
samples), then marks affected pages dirty and saves the fixups. The
|
||||
"Comply Terrain to Roads" button in `TerrainEditor` is wired and functional.
|
||||
(The 2026-07-29 ✅ was wrong — the falloff did not exist and the fixup grid
|
||||
was too dense for page meshes to see the writes; both fixed 2026-08-16,
|
||||
covered by the `terrainCompliance` headless test.)
|
||||
|
||||
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
|
||||
@@ -2985,35 +3010,41 @@ simplified representation, so the terrain matches the exact road surface.
|
||||
|
||||
#### M5.11 Roadside prefab spawning
|
||||
|
||||
**Status (2026-07-30): ✅ DONE.** `RoadSystem::spawnSidePrefabs()` iterates
|
||||
all edges' side prefabs during page finalize, computes world positions (edge
|
||||
interpolation + lateral offset), snaps Y to terrain via `TerrainSystem`, and
|
||||
calls `PrefabSystem::createInstance()`. Spawned entities are tracked in
|
||||
`RoadPageGeometry::spawnedPrefabs` and destroyed on page unload/rebuild.
|
||||
**Status (2026-08-16): ✅ DONE; reworked 2026-08-18 (see M5.13).**
|
||||
Roadside prefabs spawn per edge from the edge's three prefab slots
|
||||
(`prefabLeft`/`prefabRight`/`prefabMid`, replacing the original
|
||||
`RoadSidePrefab` vector), are distance-gated with a camera hysteresis,
|
||||
and are tracked in per-edge spawn records (`RoadSystem::m_edgePrefabs`)
|
||||
instead of per-page lists — the original page-attached tracking
|
||||
duplicated instances on edges crossing page boundaries. Spawned
|
||||
instances are stripped of `EditorMarkerComponent` so they stay
|
||||
runtime-only and are not serialized with the scene (M5.12).
|
||||
|
||||
For each `RoadEdge::RoadSidePrefab`:
|
||||
For each configured `RoadEdgePrefabSlot`:
|
||||
|
||||
1. Compute base position along the edge:
|
||||
1. Compute the anchor on the edge:
|
||||
```cpp
|
||||
Ogre::Vector3 pos = lerp(nodeA.position, nodeB.position, prefab.edgeT);
|
||||
Ogre::Vector3 pos = lerp(nodeA.position, nodeB.position, slot.edgeT);
|
||||
```
|
||||
2. Compute lateral offset:
|
||||
2. Shift to the curb and apply the lateral offset (left of the A→B
|
||||
travel direction for `prefabLeft`, right for `prefabRight`, on the
|
||||
centerline for `prefabMid`):
|
||||
```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;
|
||||
Ogre::Vector3 left = Ogre::Vector3::UNIT_Y.crossProduct(dir);
|
||||
pos += left * (sign * (halfWidth + slot.lateralOffset));
|
||||
```
|
||||
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**.
|
||||
3. Y follows the interpolated road surface height (node Y + road level),
|
||||
plus `slot.yOffset`.
|
||||
4. Instantiate via `PrefabSystem::createInstance(prefabPath,
|
||||
flecs::entity::null(), pos, name)` (root-level, so terrain teardown
|
||||
leaves no dangling scene nodes) and align the prefab's -Z forward
|
||||
with the edge direction.
|
||||
5. Destroy instances when the camera leaves the despawn radius, when
|
||||
the slot data changes, when the edge disappears, or on terrain
|
||||
teardown — via the shared `PrefabSystem::destroyInstance()`.
|
||||
|
||||
Roadside prefabs are runtime-only and page-attached; they are regenerated from
|
||||
edge data when the page is loaded.
|
||||
Roadside prefabs are runtime-only; they are regenerated from edge data
|
||||
whenever the spawn conditions hold.
|
||||
|
||||
---
|
||||
|
||||
@@ -3043,10 +3074,137 @@ edge data when the page is loaded.
|
||||
|
||||
---
|
||||
|
||||
#### M5.13 Road prefab slots and distance unload
|
||||
|
||||
**Status (2026-08-18): ✅ DONE** (improvement plan §3;
|
||||
`ProceduralRoadGeometryImprovement.md`).
|
||||
|
||||
The unbounded `RoadEdge::sidePrefabs` vector was replaced by three fixed
|
||||
`RoadEdgePrefabSlot` slots — `prefabLeft`, `prefabRight`, `prefabMid` — each
|
||||
with `prefabPath`, `edgeT`, `lateralOffset` (relative to the curb) and
|
||||
`yOffset` (relative to the road surface). Legacy `sidePrefabs` scene data
|
||||
migrates on load (first `leftSide` entry becomes `prefabLeft`, etc.).
|
||||
|
||||
1. Spawn state lives in `RoadSystem::m_edgePrefabs`, one
|
||||
`EdgePrefabRecord` per edge keyed by the ordered node-id pair — never per
|
||||
page (the old per-page tracking duplicated instances on cross-page edges).
|
||||
2. Spawn/despawn re-evaluates every frame with a camera-distance hysteresis
|
||||
(`RoadConfig::prefabSpawnDistance`/`prefabDespawnDistance`, serialized);
|
||||
distance is measured to the edge segment, and both endpoint pages must be
|
||||
loaded.
|
||||
3. Slot data changes (any field) respawn the edge's instances; a failed spawn
|
||||
(missing file) is not retried until data or conditions change.
|
||||
4. Road edit mode force-spawns the selected edge's prefabs regardless of
|
||||
distance, so slot edits preview immediately.
|
||||
5. Instances spawn at the root level with their -Z forward aligned to the
|
||||
edge direction, and are destroyed through the shared
|
||||
`PrefabSystem::destroyInstance()` (children, rigid bodies, scene nodes).
|
||||
|
||||
Verified by `testRoadSidePrefabs`, `testRoadDataModel` and
|
||||
`testRoadSerialization` (`--headless --run-terrain-tests=1`).
|
||||
|
||||
---
|
||||
|
||||
#### M5.14 Edge split polish
|
||||
|
||||
**Status (2026-08-18): ✅ DONE** (improvement plan §1).
|
||||
|
||||
1. Every row of the Edges list has its own "Split" button — splitting no
|
||||
longer requires selecting the edge first (the toolbar "Split Selected
|
||||
Edge" button was removed).
|
||||
2. The new midpoint node's Y is re-snapped to the terrain surface (split
|
||||
interpolates Y between the endpoints, which cuts across terrain).
|
||||
3. `splitEdge` remaps the prefab slots: left/right slots keep their curb side
|
||||
on both halves (`edgeT` recomputed into the half's range); the mid slot
|
||||
goes to the half containing the original anchor (t = 0.5).
|
||||
|
||||
Verified by `testRoadDataModel` (slot remap cases).
|
||||
|
||||
---
|
||||
|
||||
#### M5.15 Sidewalks
|
||||
|
||||
**Status (2026-08-18): ✅ DONE** (improvement plan §2; specified in
|
||||
`ProceduralRoadGeometry.md` §15).
|
||||
|
||||
Elevated pedestrian strips along both curbs, appended to the road's page
|
||||
geometry (inheriting its material, LOD, collider soup and navmesh dirtying).
|
||||
Lane counts — and therefore the drivable width — are unaffected.
|
||||
|
||||
1. `RoadConfig`: `sidewalkEnabled` (default off), `sidewalkWidth`,
|
||||
`sidewalkHeight`, `sidewalkThickness`, `sidewalkMeshTemplate`; serialized
|
||||
in the `roadConfig` block; UI in the terrain panel "Road Config" tree.
|
||||
2. Per wedge, one strip along the outer curb via the three-phase pipeline
|
||||
(`RoadGeometryLib::buildSidewalkGeometry`): the strip spans two widened
|
||||
mitered curb chains (inner: curb + `ROAD_SIDEWALK_WALL_GAP`, outer:
|
||||
+ `sidewalkWidth`), so inner corners miter correctly without folding.
|
||||
Top surface at `roadSurfaceY + sidewalkHeight`.
|
||||
3. Template from `RoadSystem::getSidewalkTemplate()` (cached like
|
||||
`getRoadTemplate`); empty/missing mesh yields a procedural box of
|
||||
`sidewalkWidth × sidewalkThickness` profile with its top at template Y=0.
|
||||
4. Dead-end straight segments get two bands (inbound and outbound curb) via
|
||||
`RoadGeometryLib::computeSegmentSidewalkBand()` + `extrudeToSlab()`.
|
||||
5. Terrain compliance: the perpendicular falloff starts at the sidewalk's
|
||||
outer edge, and the full-compliance pass flattens the terrain under the
|
||||
sidewalk bodies (fixup target = strip top - `sidewalkThickness`).
|
||||
|
||||
Verified by `testRoadWedgeGeometry` (case 6), `road_geometry_overlap_test`
|
||||
(sidewalks enabled in all regression configurations) and
|
||||
`testRoadSerialization` (config round-trip).
|
||||
|
||||
---
|
||||
|
||||
#### M5.16 Node connect tool
|
||||
|
||||
**Status (2026-08-18): ✅ DONE** (improvement plan §4).
|
||||
|
||||
1. `RoadGraph::connectNodes(nodeA, nodeB, worldSize, &error, &createdNodes)`
|
||||
connects two existing nodes with validation: missing nodes, self-edges,
|
||||
duplicates and wedge-angle violations are rejected and leave the graph
|
||||
unchanged (angle check rolls back the inserted edges/nodes). Page
|
||||
membership uses the origin-centred slot math of
|
||||
`TerrainGroup::convertWorldPositionToTerrainSlot` (slot (0,0) spans
|
||||
-worldSize/2 .. +worldSize/2), so edges crossing the world X/Z axes are
|
||||
not falsely reported as cross-page. A genuine cross-page connection is
|
||||
not rejected either: a node is inserted at every page-boundary crossing
|
||||
along the segment (corner crossings deduplicated) and the edge is built
|
||||
as a chain through them, keeping every edge inside one page; the
|
||||
inserted node IDs are reported through `createdNodes` and the editor
|
||||
re-snaps their Y to the terrain (`RoadSystem::snapNodesToTerrain`).
|
||||
2. New "Connect" `RoadEditTool` radio: the first click pins a source node,
|
||||
a click on a second node connects them; the second node becomes the new
|
||||
source so paths chain with repeated clicks. Rejections surface as a modal
|
||||
("Road Connect Failed").
|
||||
3. The node-list "Connect" buttons use `connectNodes` as well (previously
|
||||
the unchecked `joinNodes`).
|
||||
4. ESC exits road edit mode (same convention as sculpt/paint/prefab modes).
|
||||
|
||||
Verified by `testRoadDataModel` (connectNodes acceptance/rejection cases).
|
||||
|
||||
---
|
||||
|
||||
#### M5.17 Corner smoothing
|
||||
|
||||
**Status (2026-08-18): ✅ DONE** (improvement plan §5).
|
||||
|
||||
1. `RoadGraph::smoothNode(nodeId, strength, worldSize)` relaxes a degree-2
|
||||
node toward the midpoint of its two neighbors (position relaxation, not
|
||||
chamfering): XZ lerps by `strength`, `verticalOffset` lerps toward the
|
||||
anchors' average. Moves that would break the minimum edge length, cross
|
||||
a terrain page boundary, or create a wedge outside the 30–270° limits are
|
||||
rejected and leave the graph unchanged.
|
||||
2. Node inspector UI: "Smooth Strength" slider + "Smooth Node" button for
|
||||
degree-2 nodes (hint text otherwise); Y is re-snapped to the terrain after
|
||||
the move, same as after a gizmo drag.
|
||||
|
||||
Verified by `testRoadDataModel` (smoothNode acceptance/rejection cases).
|
||||
|
||||
---
|
||||
|
||||
### Milestone 5 definition of done
|
||||
|
||||
- [x] M5.1 — Road data model: `RoadConfig`, `RoadNode`, `RoadEdge`, and
|
||||
`RoadSidePrefab` live in `components/RoadGraph.hpp` with detailed
|
||||
`RoadEdgePrefabSlot` live in `components/RoadGraph.hpp` with detailed
|
||||
purpose annotations; `TerrainComponent` owns a `RoadGraph`; helpers for
|
||||
node lookup, edge enumeration, lane-count resolution, ID generation,
|
||||
and graph validation are available and covered by tests.
|
||||
@@ -3074,9 +3232,10 @@ edge data when the page is loaded.
|
||||
- [x] "Comply Terrain to Roads" makes the terrain follow the road underside
|
||||
(sloped/curved where the road is sloped/curved) without gaps or
|
||||
intersections (M5.10, `RoadSystem::complyTerrain()` wired).
|
||||
- [x] Roadside prefabs spawn at configured edge positions (Y snapped to terrain
|
||||
surface) and are destroyed on page unload/rebuild; scene load regenerates
|
||||
them (M5.11, `RoadSystem::spawnSidePrefabs()` via `PrefabSystem`).
|
||||
- [x] Roadside prefabs spawn at configured edge positions (Y follows the road
|
||||
surface) and are destroyed on distance unload/data change/teardown;
|
||||
scene load regenerates them (M5.11 + M5.13, per-edge records via
|
||||
`PrefabSystem::createInstance`/`destroyInstance`).
|
||||
- [x] Save/reload round-trip preserves road nodes, edges, config, and side
|
||||
prefabs (verified by `TerrainTests.cpp` headless test).
|
||||
- [x] Road meshes and colliders are created when a terrain page loads and
|
||||
@@ -3086,19 +3245,60 @@ edge data when the page is loaded.
|
||||
- [x] M5.7 — Edge length constraint: `splitEdge` snaps to integer half-lengths,
|
||||
`joinNodes` warns on short edges, `validate` rejects edges < 1 unit
|
||||
(verified by `testRoadEdgeLengthConstraint`).
|
||||
- [x] M5.13 — Road prefab slots: three fixed slots per edge, per-edge spawn
|
||||
records, camera-distance hysteresis, edit-mode preview (verified by
|
||||
`testRoadSidePrefabs`).
|
||||
- [x] M5.14 — Edge split polish: per-row Split buttons, terrain Y re-snap,
|
||||
prefab slot remapping (verified by `testRoadDataModel`).
|
||||
- [x] M5.15 — Sidewalks: elevated curb strips via widened mitered curb chains,
|
||||
segment bands, template + config UI, terrain compliance (verified by
|
||||
`testRoadWedgeGeometry` case 6 and `road_geometry_overlap_test`).
|
||||
- [x] M5.16 — Node connect tool: validated `connectNodes`, 3D Connect tool
|
||||
with chaining, error modal, ESC exits road edit mode (verified by
|
||||
`testRoadDataModel`).
|
||||
- [x] M5.17 — Corner smoothing: `smoothNode` position relaxation with
|
||||
structural guards, node inspector UI (verified by `testRoadDataModel`).
|
||||
|
||||
**Overall M5 status**: ✅ ALL 13 sub-items complete (M5.1–M5.12).
|
||||
**Overall M5 status**: ✅ ALL 18 sub-items complete (M5.1–M5.17).
|
||||
|
||||
### 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.
|
||||
- [x] `TerrainPrefabSpawnerComponent` + `TerrainPrefabSpawnerModule`
|
||||
(`components/TerrainPrefabSpawner.hpp`,
|
||||
`components/TerrainPrefabSpawnerModule.cpp`).
|
||||
**Deviation from 6.2**: world position/rotation live on the entity's
|
||||
`TransformComponent` (single transform source, same precedent as
|
||||
`CharacterSpawnerComponent`); the component only stores `prefabPath`,
|
||||
`spawnDistanceSq`, `despawnDistanceSq`, and the runtime
|
||||
`spawnedEntity` handle.
|
||||
- [x] `TerrainPrefabSpawnerSystem` with distance-based spawn/despawn via
|
||||
`PrefabSystem` (`systems/TerrainPrefabSpawnerSystem.hpp/.cpp`).
|
||||
Per-spawner camera hysteresis: distance re-evaluation is skipped while
|
||||
the camera moved < 10 units and neither the spawner transform nor its
|
||||
parameters changed (6.3 item 4). Prefab-path changes force a respawn;
|
||||
an `OnRemove` observer despawns the instance when the spawner
|
||||
component/entity is removed. Spawned instances are runtime-only:
|
||||
`EditorMarkerComponent` is stripped and the instance is removed from
|
||||
the editor UI caches, so they are never serialized.
|
||||
- [x] Terrain-snap at placement (6.4): `snapToTerrain()` sets the spawner's
|
||||
Y from `TerrainSystem::getHeightAt()` at placement time, on spawn, and
|
||||
when the spawner is moved in the editor.
|
||||
- [x] Terrain compliance tool (6.5): `complyTerrainToPrefab()` flattens the
|
||||
terrain under the spawned prefab's world-AABB footprint using fixup
|
||||
chunks (linear falloff band written first, full flatten under the
|
||||
footprint second — same ordering as road compliance), marks affected
|
||||
pages dirty, and saves the fixups.
|
||||
- [x] Editor integration (7.2): prefab spawn mode with click-to-place on the
|
||||
terrain (Terrain editor "Prefab Spawners" section, ESC to exit,
|
||||
mutually exclusive with sculpt/paint/aux/road modes),
|
||||
`TerrainPrefabSpawnerEditor` property panel with prefab picker,
|
||||
Snap-to-terrain and Flatten buttons.
|
||||
- [x] Scene serialization under the `terrainPrefabSpawner` key with plain
|
||||
(non-squared) distances, Lua binding `TerrainPrefabSpawner`.
|
||||
- [x] Headless test `testTerrainPrefabSpawners` in `TerrainTests.cpp`
|
||||
(spawn + snap + runtime-only checks, serialization round-trip,
|
||||
compliance flatten, distance despawn, respawn, observer cleanup).
|
||||
|
||||
Definition of done: towns/rocks spawn at configured locations on terrain and sit
|
||||
flush; save/load round-trips correctly.
|
||||
**Overall M6 status**: ✅ complete (verified by `--run-terrain-tests`).
|
||||
|
||||
## 11. Risks and Mitigations
|
||||
|
||||
@@ -3135,8 +3335,8 @@ flush; save/load round-trips correctly.
|
||||
- [ ] 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.
|
||||
- [x] Place prefab spawner → prefab appears at correct distance and sits on terrain.
|
||||
- [x] 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.
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
#include <Ogre.h>
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <functional>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
@@ -17,6 +18,14 @@
|
||||
*/
|
||||
static const float ROAD_MIN_EDGE_LENGTH = 1.0f;
|
||||
|
||||
/**
|
||||
* Lateral gap between the road curb wall and the sidewalk's inner wall
|
||||
* (improvement plan §2.2). Without it the two walls would be coplanar
|
||||
* and z-fight; the sidewalk top still overlaps the curb horizontally,
|
||||
* so no hole is visible.
|
||||
*/
|
||||
static const float ROAD_SIDEWALK_WALL_GAP = 0.002f;
|
||||
|
||||
/**
|
||||
* Road configuration parameters — global settings that apply to the whole
|
||||
* road network owned by a single TerrainComponent. These values are
|
||||
@@ -77,6 +86,28 @@ struct RoadConfig {
|
||||
* Ogre material applied to generated road surfaces.
|
||||
*/
|
||||
std::string roadMaterialName = "RoadMaterial";
|
||||
|
||||
/**
|
||||
* Sidewalks (improvement plan §2). When enabled, each side of the
|
||||
* road gets an elevated pedestrian strip of sidewalkWidth lateral
|
||||
* extent whose top sits sidewalkHeight above the road surface.
|
||||
* sidewalkMeshTemplate follows the same template-space conventions
|
||||
* as roadMeshTemplate; empty selects the generated procedural box
|
||||
* (sidewalkWidth x sidewalkThickness profile).
|
||||
*/
|
||||
bool sidewalkEnabled = false;
|
||||
float sidewalkWidth = 1.5f;
|
||||
float sidewalkHeight = 0.15f;
|
||||
float sidewalkThickness = 0.3f;
|
||||
std::string sidewalkMeshTemplate;
|
||||
|
||||
/**
|
||||
* Camera distances in plain meters at which road-edge prefabs spawn
|
||||
* and despawn (improvement plan §3.2). The despawn distance must
|
||||
* exceed the spawn distance so the hysteresis band is stable.
|
||||
*/
|
||||
float prefabSpawnDistance = 150.0f;
|
||||
float prefabDespawnDistance = 250.0f;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -119,6 +150,38 @@ struct RoadNode {
|
||||
int id = 0;
|
||||
};
|
||||
|
||||
/**
|
||||
* A prefab spawn slot attached to a road edge (improvement plan §3.1).
|
||||
*
|
||||
* The slot's anchor depends on which RoadEdge member it occupies: the
|
||||
* left/right slots anchor at the corresponding curb (at the resolved
|
||||
* half-width of that side of the road), prefabMid anchors at the
|
||||
* centerline. The anchor sits at normalized position edgeT along the
|
||||
* edge and is pushed lateralOffset meters further away from the road
|
||||
* (0 = exactly at the curb / centerline) and yOffset meters above the
|
||||
* interpolated road surface height.
|
||||
*
|
||||
* Side convention: looking from nodeA toward nodeB, the left side is
|
||||
* UNIT_Y x dir (bounded by the inbound lanesBtoA half-width) and the
|
||||
* right side is dir x UNIT_Y (bounded by the outbound lanesAtoB
|
||||
* half-width).
|
||||
*
|
||||
* An empty prefabPath disables the slot.
|
||||
*/
|
||||
struct RoadEdgePrefabSlot {
|
||||
/** Prefab JSON file path, relative to the prefabs directory. */
|
||||
std::string prefabPath;
|
||||
|
||||
/** Normalized position along the edge from nodeA to nodeB. */
|
||||
float edgeT = 0.5f;
|
||||
|
||||
/** Meters beyond the anchor, away from the road centerline. */
|
||||
float lateralOffset = 0.0f;
|
||||
|
||||
/** Vertical offset above the interpolated road surface height. */
|
||||
float yOffset = 0.0f;
|
||||
};
|
||||
|
||||
/**
|
||||
* A connection between two RoadNode objects.
|
||||
*
|
||||
@@ -175,42 +238,35 @@ struct RoadEdge {
|
||||
*/
|
||||
int lanesBtoA = 0;
|
||||
|
||||
/**
|
||||
* A prefab instance placed beside the road on a specific edge.
|
||||
*
|
||||
* Roadside prefabs are regenerated from edge data when the terrain
|
||||
* page that contains them is loaded; they are not serialized as
|
||||
* independent scene entities.
|
||||
*/
|
||||
struct RoadSidePrefab {
|
||||
/** Prefab JSON file path, relative to the prefabs directory. */
|
||||
std::string prefabPath;
|
||||
/** Prefab anchored at the left curb (looking from nodeA to nodeB). */
|
||||
RoadEdgePrefabSlot prefabLeft;
|
||||
|
||||
/**
|
||||
* Normalized position along the edge from nodeA to nodeB.
|
||||
*
|
||||
* 0.0 places the prefab at nodeA; 1.0 places it at nodeB.
|
||||
*/
|
||||
float edgeT = 0.5f;
|
||||
/** Prefab anchored at the right curb (looking from nodeA to nodeB). */
|
||||
RoadEdgePrefabSlot prefabRight;
|
||||
|
||||
/**
|
||||
* Lateral distance from the road center line.
|
||||
*
|
||||
* The actual side (left or right) is determined by @c leftSide.
|
||||
*/
|
||||
float sideOffset = 5.0f;
|
||||
|
||||
/**
|
||||
* true = left side of the road when looking from nodeA toward nodeB.
|
||||
* false = right side of the road when looking from nodeA toward nodeB.
|
||||
*/
|
||||
bool leftSide = true;
|
||||
};
|
||||
|
||||
/** Prefab spawn points attached to this edge. */
|
||||
std::vector<RoadSidePrefab> sidePrefabs;
|
||||
/** Prefab anchored at the centerline, at edgeT (edge midpoint). */
|
||||
RoadEdgePrefabSlot prefabMid;
|
||||
};
|
||||
|
||||
/** Minimum wedge swept angle; sharper wedges are rejected (M5.5). */
|
||||
static const float ROAD_WEDGE_MIN_ANGLE_DEG = 30.0f;
|
||||
|
||||
/** Maximum wedge swept angle; only near-360 deg wedges are degenerate. */
|
||||
static const float ROAD_WEDGE_MAX_ANGLE_DEG = 359.9f;
|
||||
|
||||
struct RoadGraph;
|
||||
struct RoadWedge;
|
||||
struct RoadStraightSegment;
|
||||
|
||||
/**
|
||||
* Enumerate all wedges and straight segments of a road graph (M5.5).
|
||||
* Defined at the bottom of this header; declared here so RoadGraph
|
||||
* editing helpers can validate the wedges their operations create.
|
||||
*/
|
||||
void enumerateWedges(const RoadGraph &graph,
|
||||
std::vector<RoadWedge> &outWedges,
|
||||
std::vector<RoadStraightSegment> &outSegments);
|
||||
|
||||
/**
|
||||
* RoadGraph — container and lightweight utility layer for a terrain's road
|
||||
* network.
|
||||
@@ -572,6 +628,51 @@ struct RoadGraph {
|
||||
edgeB.nodeA = newId;
|
||||
edgeB.roadLevelA = 0.0f;
|
||||
|
||||
/* Carry the prefab slots over to the half that contains
|
||||
* them instead of copying them onto both halves (which
|
||||
* spawned every prefab twice). edgeT is remapped into the
|
||||
* receiving half's local 0..1 range; the left/right sides
|
||||
* are preserved because both halves keep the A->B
|
||||
* orientation. The mid slot transfers to the half that
|
||||
* contains the original edge midpoint (t = 0.5). */
|
||||
edgeA.prefabLeft = RoadEdgePrefabSlot();
|
||||
edgeA.prefabRight = RoadEdgePrefabSlot();
|
||||
edgeA.prefabMid = RoadEdgePrefabSlot();
|
||||
edgeB.prefabLeft = RoadEdgePrefabSlot();
|
||||
edgeB.prefabRight = RoadEdgePrefabSlot();
|
||||
edgeB.prefabMid = RoadEdgePrefabSlot();
|
||||
if (actualT > 1e-6f && actualT < 1.0f - 1e-6f) {
|
||||
auto remapSlot = [&](const RoadEdgePrefabSlot &src,
|
||||
RoadEdgePrefabSlot &dstA,
|
||||
RoadEdgePrefabSlot &dstB) {
|
||||
if (src.prefabPath.empty())
|
||||
return;
|
||||
if (src.edgeT <= actualT) {
|
||||
dstA = src;
|
||||
dstA.edgeT = src.edgeT / actualT;
|
||||
} else {
|
||||
dstB = src;
|
||||
dstB.edgeT = (src.edgeT - actualT) /
|
||||
(1.0f - actualT);
|
||||
}
|
||||
};
|
||||
remapSlot(oldEdge.prefabLeft, edgeA.prefabLeft,
|
||||
edgeB.prefabLeft);
|
||||
remapSlot(oldEdge.prefabRight, edgeA.prefabRight,
|
||||
edgeB.prefabRight);
|
||||
if (!oldEdge.prefabMid.prefabPath.empty()) {
|
||||
RoadEdgePrefabSlot mid = oldEdge.prefabMid;
|
||||
if (0.5f <= actualT) {
|
||||
mid.edgeT = 0.5f / actualT;
|
||||
edgeA.prefabMid = mid;
|
||||
} else {
|
||||
mid.edgeT = (0.5f - actualT) /
|
||||
(1.0f - actualT);
|
||||
edgeB.prefabMid = mid;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
edges.erase(edges.begin() + edgeIndex);
|
||||
edges.push_back(edgeA);
|
||||
edges.push_back(edgeB);
|
||||
@@ -616,12 +717,258 @@ struct RoadGraph {
|
||||
return addEdge(nodeA, nodeB);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check that every wedge seeded at one of the given nodes respects
|
||||
* the M5.5 angle limits (30..270 degrees).
|
||||
*
|
||||
* Used by editing operations to validate the wedges their result
|
||||
* would create without running a full-graph validate(). Defined
|
||||
* after the wedge/segment structs at the bottom of this header.
|
||||
*/
|
||||
bool wedgeAnglesValidForNodes(const std::vector<int> &nodeIds) const;
|
||||
|
||||
/**
|
||||
* Connect two existing nodes with a new edge, with validation
|
||||
* (improvement plan §4.2).
|
||||
*
|
||||
* Unlike joinNodes this rejects structurally invalid connections:
|
||||
* missing nodes, self-edges, duplicates and connections that would
|
||||
* create a wedge outside the M5.5 angle limits. A rejected
|
||||
* connection leaves the graph unchanged.
|
||||
*
|
||||
* When the endpoints sit in different terrain pages (only possible
|
||||
* when @p worldSize > 0) the connection is not rejected: instead a
|
||||
* new node is inserted at every page-boundary crossing along the
|
||||
* straight line between the endpoints and the edge is built as a
|
||||
* chain through those nodes, so every resulting edge stays inside
|
||||
* one page. The inserted nodes get interpolated Y and
|
||||
* verticalOffset; editor callers should re-snap their Y to the
|
||||
* terrain (the IDs are reported via @p createdNodes).
|
||||
*
|
||||
* @param nodeA Stable ID of the first node.
|
||||
* @param nodeB Stable ID of the second node.
|
||||
* @param worldSize Length of one terrain page in world units, or 0
|
||||
* to skip the page check (headless tests).
|
||||
* @param error If non-null and the connection is rejected,
|
||||
* receives a human readable reason.
|
||||
* @param createdNodes If non-null, receives the IDs of the nodes
|
||||
* inserted at page-boundary crossings (empty when
|
||||
* no split was needed).
|
||||
* @return index of the first new edge, or -1 on rejection.
|
||||
*/
|
||||
int connectNodes(int nodeA, int nodeB, float worldSize = 0.0f,
|
||||
std::string *error = nullptr,
|
||||
std::vector<int> *createdNodes = nullptr)
|
||||
{
|
||||
const RoadNode *na = findNodeById(nodeA);
|
||||
const RoadNode *nb = findNodeById(nodeB);
|
||||
if (!na || !nb) {
|
||||
if (error)
|
||||
*error = "One of the nodes does not exist.";
|
||||
return -1;
|
||||
}
|
||||
if (nodeA == nodeB) {
|
||||
if (error)
|
||||
*error = "Cannot connect a node to itself.";
|
||||
return -1;
|
||||
}
|
||||
if (hasEdge(nodeA, nodeB)) {
|
||||
if (error)
|
||||
*error = "An edge already connects these nodes.";
|
||||
return -1;
|
||||
}
|
||||
|
||||
std::vector<int> chain;
|
||||
std::vector<int> inserted;
|
||||
chain.push_back(nodeA);
|
||||
if (worldSize > 0.0f &&
|
||||
!edgeStaysWithinOnePage(na->position, nb->position,
|
||||
worldSize)) {
|
||||
/* Copy the endpoint data up front: addNode may
|
||||
* reallocate the nodes vector and invalidate the
|
||||
* na/nb pointers. */
|
||||
const Ogre::Vector3 posA = na->position;
|
||||
const Ogre::Vector3 posB = nb->position;
|
||||
const float offA = na->verticalOffset;
|
||||
const float offB = nb->verticalOffset;
|
||||
std::vector<float> ts =
|
||||
pageBoundaryCrossings(posA, posB, worldSize);
|
||||
if (ts.empty()) {
|
||||
if (error)
|
||||
*error = "The nodes are in different "
|
||||
"terrain pages.";
|
||||
return -1;
|
||||
}
|
||||
for (float t : ts) {
|
||||
Ogre::Vector3 pos = posA + (posB - posA) * t;
|
||||
float off = offA + (offB - offA) * t;
|
||||
inserted.push_back(addNode(pos, off));
|
||||
}
|
||||
}
|
||||
for (int id : inserted)
|
||||
chain.push_back(id);
|
||||
chain.push_back(nodeB);
|
||||
|
||||
int firstEdge = -1;
|
||||
std::vector<int> createdEdges;
|
||||
for (size_t i = 0; i + 1 < chain.size(); ++i) {
|
||||
int idx = addEdge(chain[i], chain[i + 1]);
|
||||
if (idx < 0) {
|
||||
if (error)
|
||||
*error = "joinNodes failed.";
|
||||
break;
|
||||
}
|
||||
if (firstEdge < 0)
|
||||
firstEdge = idx;
|
||||
createdEdges.push_back(idx);
|
||||
}
|
||||
if (createdEdges.size() + 1 != chain.size() ||
|
||||
!wedgeAnglesValidForNodes(chain)) {
|
||||
/* Roll back: drop the new edges (descending index
|
||||
* order so earlier indices stay valid), then the
|
||||
* inserted nodes. */
|
||||
std::sort(createdEdges.begin(), createdEdges.end(),
|
||||
std::greater<int>());
|
||||
for (int idx : createdEdges)
|
||||
removeEdge((size_t)idx);
|
||||
for (int id : inserted)
|
||||
removeNode(id);
|
||||
if (error && createdEdges.size() + 1 == chain.size())
|
||||
*error = "The connection would create a wedge "
|
||||
"outside the 30-270 degree limits.";
|
||||
return -1;
|
||||
}
|
||||
if (createdNodes)
|
||||
*createdNodes = inserted;
|
||||
return firstEdge;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the normalized positions (0..1) along the segment a->b at
|
||||
* which it crosses a terrain page boundary.
|
||||
*
|
||||
* Page boundaries lie at (k + 0.5) * worldSize for integer k on
|
||||
* both the X and Z axes (see edgeStaysWithinOnePage). A crossing
|
||||
* through a grid corner is reported once. Crossings within 1e-4 of
|
||||
* an endpoint are dropped: the endpoint effectively sits on the
|
||||
* boundary and needs no split node.
|
||||
*
|
||||
* @return sorted, deduplicated list of crossing parameters.
|
||||
*/
|
||||
static std::vector<float>
|
||||
pageBoundaryCrossings(const Ogre::Vector3 &a, const Ogre::Vector3 &b,
|
||||
float worldSize)
|
||||
{
|
||||
std::vector<float> ts;
|
||||
if (worldSize <= 0.0f)
|
||||
return ts;
|
||||
|
||||
auto collect = [&](float pa, float pb) {
|
||||
float d = pb - pa;
|
||||
if (std::fabs(d) < 1e-6f)
|
||||
return;
|
||||
float lo = std::min(pa, pb);
|
||||
float hi = std::max(pa, pb);
|
||||
long kFirst = (long)std::floor(lo / worldSize + 0.5f);
|
||||
long kLast = (long)std::floor(hi / worldSize + 0.5f);
|
||||
for (long k = kFirst; k < kLast; ++k) {
|
||||
float boundary =
|
||||
((float)k + 0.5f) * worldSize;
|
||||
float t = (boundary - pa) / d;
|
||||
if (t > 1e-4f && t < 1.0f - 1e-4f)
|
||||
ts.push_back(t);
|
||||
}
|
||||
};
|
||||
collect(a.x, b.x);
|
||||
collect(a.z, b.z);
|
||||
|
||||
std::sort(ts.begin(), ts.end());
|
||||
std::vector<float> unique;
|
||||
for (float t : ts) {
|
||||
if (unique.empty() || t - unique.back() > 1e-4f)
|
||||
unique.push_back(t);
|
||||
}
|
||||
return unique;
|
||||
}
|
||||
|
||||
/**
|
||||
* Relax a 3-node chain A-B-C toward a straighter path (improvement
|
||||
* plan §5).
|
||||
*
|
||||
* The node must have exactly two neighbors; they act as anchors and
|
||||
* never move. The node's XZ position is lerped toward the anchors'
|
||||
* midpoint by @p strength (0..1), and its verticalOffset is lerped
|
||||
* toward the anchors' average offset by the same amount. The Y
|
||||
* coordinate itself is left untouched — the editor re-snaps it to
|
||||
* terrain_height + verticalOffset, same as after a gizmo drag.
|
||||
*
|
||||
* A move is rejected (the graph is left unchanged) when it would
|
||||
* shorten an incident edge below ROAD_MIN_EDGE_LENGTH, push the
|
||||
* node across a terrain page boundary (when @p worldSize > 0), or
|
||||
* create a wedge outside the M5.5 angle limits.
|
||||
*
|
||||
* @return true if the node moved.
|
||||
*/
|
||||
bool smoothNode(int nodeId, float strength, float worldSize = 0.0f)
|
||||
{
|
||||
RoadNode *node = findNodeById(nodeId);
|
||||
if (!node)
|
||||
return false;
|
||||
std::vector<int> nbr = getNeighborIds(nodeId);
|
||||
if (nbr.size() != 2)
|
||||
return false;
|
||||
const RoadNode *na = findNodeById(nbr[0]);
|
||||
const RoadNode *nc = findNodeById(nbr[1]);
|
||||
if (!na || !nc)
|
||||
return false;
|
||||
|
||||
float s = std::max(0.0f, std::min(1.0f, strength));
|
||||
Ogre::Vector3 mid =
|
||||
(na->position + nc->position) * 0.5f;
|
||||
Ogre::Vector3 newPos = node->position;
|
||||
newPos.x += (mid.x - newPos.x) * s;
|
||||
newPos.z += (mid.z - newPos.z) * s;
|
||||
|
||||
auto horizDist = [](const Ogre::Vector3 &p,
|
||||
const Ogre::Vector3 &q) {
|
||||
float dx = p.x - q.x, dz = p.z - q.z;
|
||||
return std::sqrt(dx * dx + dz * dz);
|
||||
};
|
||||
if (horizDist(na->position, newPos) < ROAD_MIN_EDGE_LENGTH ||
|
||||
horizDist(newPos, nc->position) < ROAD_MIN_EDGE_LENGTH)
|
||||
return false;
|
||||
if (!edgeStaysWithinOnePage(na->position, newPos,
|
||||
worldSize) ||
|
||||
!edgeStaysWithinOnePage(newPos, nc->position, worldSize))
|
||||
return false;
|
||||
|
||||
Ogre::Vector3 oldPos = node->position;
|
||||
node->position = newPos;
|
||||
if (!wedgeAnglesValidForNodes(
|
||||
{ nbr[0], nodeId, nbr[1] })) {
|
||||
node->position = oldPos;
|
||||
return false;
|
||||
}
|
||||
|
||||
float midOffset =
|
||||
(na->verticalOffset + nc->verticalOffset) * 0.5f;
|
||||
node->verticalOffset +=
|
||||
(midOffset - node->verticalOffset) * s;
|
||||
bumpVersion();
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether the straight-line edge between two world positions crosses
|
||||
* a terrain page boundary without a node at the crossing.
|
||||
*
|
||||
* Terrain pages are axis-aligned squares of side @c worldSize. An edge
|
||||
* is valid only if both endpoints lie inside the same page.
|
||||
* Terrain pages are axis-aligned squares of side @c worldSize centred on
|
||||
* the terrain-group origin: slot (0,0) spans
|
||||
* -worldSize/2 .. +worldSize/2, matching
|
||||
* TerrainGroup::convertWorldPositionToTerrainSlot with a zero group
|
||||
* origin (plain floor(pos / worldSize) would put a spurious page
|
||||
* boundary on the world X/Z axes). An edge is valid only if both
|
||||
* endpoints lie inside the same page.
|
||||
*
|
||||
* @param a Start position in world space.
|
||||
* @param b End position in world space.
|
||||
@@ -635,10 +982,10 @@ struct RoadGraph {
|
||||
if (worldSize <= 0.0f)
|
||||
return true;
|
||||
|
||||
long pageAx = (long)std::floor(a.x / worldSize);
|
||||
long pageAz = (long)std::floor(a.z / worldSize);
|
||||
long pageBx = (long)std::floor(b.x / worldSize);
|
||||
long pageBz = (long)std::floor(b.z / worldSize);
|
||||
long pageAx = (long)std::floor(a.x / worldSize + 0.5f);
|
||||
long pageAz = (long)std::floor(a.z / worldSize + 0.5f);
|
||||
long pageBx = (long)std::floor(b.x / worldSize + 0.5f);
|
||||
long pageBz = (long)std::floor(b.z / worldSize + 0.5f);
|
||||
|
||||
return pageAx == pageBx && pageAz == pageBz;
|
||||
}
|
||||
@@ -767,12 +1114,6 @@ struct RoadStraightSegment {
|
||||
RoadHalfEdge halfEdge;
|
||||
};
|
||||
|
||||
/** Minimum wedge swept angle; sharper wedges are rejected (M5.5). */
|
||||
static const float ROAD_WEDGE_MIN_ANGLE_DEG = 30.0f;
|
||||
|
||||
/** Maximum wedge swept angle; only near-360 deg wedges are degenerate. */
|
||||
static const float ROAD_WEDGE_MAX_ANGLE_DEG = 359.9f;
|
||||
|
||||
/**
|
||||
* Enumerate all wedges and straight segments of a road graph (M5.5).
|
||||
*
|
||||
@@ -871,6 +1212,28 @@ inline void enumerateWedges(const RoadGraph &graph,
|
||||
}
|
||||
}
|
||||
|
||||
inline bool
|
||||
RoadGraph::wedgeAnglesValidForNodes(const std::vector<int> &nodeIds) const
|
||||
{
|
||||
std::vector<RoadWedge> wedges;
|
||||
std::vector<RoadStraightSegment> segments;
|
||||
enumerateWedges(*this, wedges, segments);
|
||||
for (const auto &w : wedges) {
|
||||
bool relevant = false;
|
||||
for (int id : nodeIds) {
|
||||
if (w.nodeId == id) {
|
||||
relevant = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!relevant)
|
||||
continue;
|
||||
if (w.sweptAngleDeg < ROAD_WEDGE_MIN_ANGLE_DEG || w.degenerate)
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
inline Ogre::Vector3
|
||||
RoadGraph::snapToIntegerLength(const Ogre::Vector3 &anchor,
|
||||
const Ogre::Vector3 &pos)
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
#ifndef EDITSCENE_TERRAINPREFABSPAWNER_HPP
|
||||
#define EDITSCENE_TERRAINPREFABSPAWNER_HPP
|
||||
#pragma once
|
||||
|
||||
#include <flecs.h>
|
||||
#include <string>
|
||||
|
||||
/**
|
||||
* @brief Distance-based prefab spawner for terrain scenes (Milestone 6).
|
||||
*
|
||||
* Attaches to an entity with a TransformComponent. When the active camera
|
||||
* is within spawnDistanceSq of the spawner, the prefab referenced by
|
||||
* prefabPath is instantiated at the spawner's transform (Y snapped to the
|
||||
* terrain surface). When the camera moves beyond despawnDistanceSq, the
|
||||
* spawned instance is destroyed.
|
||||
*
|
||||
* The world-space position and rotation live on the entity's
|
||||
* TransformComponent (deviation from the original TerrainRequirements.md
|
||||
* 6.2 struct, which embedded position/rotation here — keeping a single
|
||||
* transform source avoids stale duplicates when the spawner is moved with
|
||||
* the editor gizmo; same precedent as CharacterSpawnerComponent).
|
||||
*
|
||||
* Spawned instances are runtime-only: they carry no EditorMarkerComponent
|
||||
* and are never serialized with the scene. Distances are stored squared
|
||||
* for fast comparison; the serializer writes them as plain distances.
|
||||
*/
|
||||
struct TerrainPrefabSpawnerComponent {
|
||||
/** Prefab JSON file path (e.g. "prefabs/test_cube.json"). */
|
||||
std::string prefabPath;
|
||||
|
||||
/** Squared distance at which the prefab should be spawned. */
|
||||
float spawnDistanceSq = 100.0f * 100.0f;
|
||||
|
||||
/** Squared distance at which the prefab should be despawned. */
|
||||
float despawnDistanceSq = 200.0f * 200.0f;
|
||||
|
||||
/** Runtime: the spawned instance entity (0 when not spawned).
|
||||
* Managed by TerrainPrefabSpawnerSystem; not serialized. */
|
||||
flecs::entity_t spawnedEntity = 0;
|
||||
};
|
||||
|
||||
#endif // EDITSCENE_TERRAINPREFABSPAWNER_HPP
|
||||
@@ -0,0 +1,26 @@
|
||||
#include "TerrainPrefabSpawner.hpp"
|
||||
#include "../ui/ComponentRegistration.hpp"
|
||||
#include "../ui/TerrainPrefabSpawnerEditor.hpp"
|
||||
|
||||
// Register TerrainPrefabSpawner component (Milestone 6)
|
||||
REGISTER_COMPONENT_GROUP("Terrain Prefab Spawner", "Environment",
|
||||
TerrainPrefabSpawnerComponent,
|
||||
TerrainPrefabSpawnerEditor)
|
||||
{
|
||||
registry.registerComponent<TerrainPrefabSpawnerComponent>(
|
||||
"Terrain Prefab Spawner", "Environment",
|
||||
std::make_unique<TerrainPrefabSpawnerEditor>(sceneMgr),
|
||||
/* Adder */
|
||||
[](flecs::entity e) {
|
||||
if (!e.has<TerrainPrefabSpawnerComponent>()) {
|
||||
e.set<TerrainPrefabSpawnerComponent>(
|
||||
TerrainPrefabSpawnerComponent{});
|
||||
}
|
||||
},
|
||||
/* Remover */
|
||||
[](flecs::entity e) {
|
||||
if (e.has<TerrainPrefabSpawnerComponent>()) {
|
||||
e.remove<TerrainPrefabSpawnerComponent>();
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -34,6 +34,7 @@
|
||||
#include "components/TriangleBuffer.hpp"
|
||||
#include "components/CharacterSlots.hpp"
|
||||
#include "components/CharacterSpawner.hpp"
|
||||
#include "components/TerrainPrefabSpawner.hpp"
|
||||
#include "components/CharacterIdentity.hpp"
|
||||
#include "systems/CharacterRegistry.hpp"
|
||||
#include "components/AnimationTree.hpp"
|
||||
@@ -591,6 +592,27 @@ static void registerAllComponents()
|
||||
c.despawnDistanceSq = (float)lua_tonumber(L, -1);
|
||||
lua_pop(L, 1););
|
||||
|
||||
// --- TerrainPrefabSpawner ---
|
||||
REGISTER_COMPONENT(
|
||||
TerrainPrefabSpawnerComponent, "TerrainPrefabSpawner",
|
||||
lua_pushstring(L, c.prefabPath.c_str());
|
||||
lua_setfield(L, -2, "prefabPath");
|
||||
lua_pushnumber(L, c.spawnDistanceSq);
|
||||
lua_setfield(L, -2, "spawnDistanceSq");
|
||||
lua_pushnumber(L, c.despawnDistanceSq);
|
||||
lua_setfield(L, -2, "despawnDistanceSq");
|
||||
, if (lua_getfield(L, idx, "prefabPath"), lua_isstring(L, -1))
|
||||
c.prefabPath = lua_tostring(L, -1);
|
||||
lua_pop(L, 1);
|
||||
if (lua_getfield(L, idx, "spawnDistanceSq"),
|
||||
lua_isnumber(L, -1))
|
||||
c.spawnDistanceSq = (float)lua_tonumber(L, -1);
|
||||
lua_pop(L, 1);
|
||||
if (lua_getfield(L, idx, "despawnDistanceSq"),
|
||||
lua_isnumber(L, -1))
|
||||
c.despawnDistanceSq = (float)lua_tonumber(L, -1);
|
||||
lua_pop(L, 1););
|
||||
|
||||
// --- AnimationTree ---
|
||||
REGISTER_COMPONENT(
|
||||
AnimationTreeComponent, "AnimationTree",
|
||||
|
||||
@@ -162,6 +162,20 @@ Procedural::TriangleBuffer makeFallbackTemplate(float roadThickness)
|
||||
return tb;
|
||||
}
|
||||
|
||||
Procedural::TriangleBuffer makeSidewalkFallbackTemplate(
|
||||
float sidewalkThickness)
|
||||
{
|
||||
Procedural::TriangleBuffer tb =
|
||||
makeFallbackTemplate(sidewalkThickness);
|
||||
/* Shift the profile from Y in [-t/2, +t/2] to [-t, 0]: the
|
||||
* sidewalk phase-2 mapping places template Y=0 at
|
||||
* roadSurfaceY + sidewalkHeight, i.e. the strip top. */
|
||||
float h = std::max(0.01f, sidewalkThickness) * 0.5f;
|
||||
for (auto &v : tb.getVertices())
|
||||
v.mPosition.y -= h;
|
||||
return tb;
|
||||
}
|
||||
|
||||
/* ----------------------------------------------------------------
|
||||
* Phase 1 — Concatenated Strip
|
||||
* ---------------------------------------------------------------- */
|
||||
@@ -192,11 +206,12 @@ void buildConcatenatedStrip(Procedural::TriangleBuffer &out,
|
||||
* those are the template caps, which would otherwise stack coplanar
|
||||
* faces at every copy join and at the edge midpoints (z-fighting).
|
||||
* The centerline wall (template X=0) is dropped as well — it is
|
||||
* interior to the joined road body.
|
||||
* interior to the joined road body — unless @p keepCenterWall is set
|
||||
* (sidewalk strips, whose X=0 wall faces the road and stays visible).
|
||||
*/
|
||||
static void appendTemplateCopy(Procedural::TriangleBuffer &out,
|
||||
const Procedural::TriangleBuffer &templ,
|
||||
float zOff, float clampD)
|
||||
float zOff, float clampD, bool keepCenterWall)
|
||||
{
|
||||
const auto &tverts = templ.getVertices();
|
||||
const auto &tidx = templ.getIndices();
|
||||
@@ -228,7 +243,7 @@ static void appendTemplateCopy(Procedural::TriangleBuffer &out,
|
||||
std::fabs(b.x) < 1e-6f &&
|
||||
std::fabs(c.x) < 1e-6f;
|
||||
const Ogre::Vector3 &n = tverts[(size_t)tidx[t]].mNormal;
|
||||
if (wall0 && std::fabs(n.x) > 0.9f)
|
||||
if (wall0 && !keepCenterWall && std::fabs(n.x) > 0.9f)
|
||||
continue;
|
||||
|
||||
out.getIndices().push_back(base + tidx[t]);
|
||||
@@ -243,20 +258,22 @@ static void appendTemplateCopy(Procedural::TriangleBuffer &out,
|
||||
* Run 1 covers d in [0, L1] with ceil(L1) uniform copies from d = 0;
|
||||
* run 2 covers [L1, L1+L2] with ceil(L2) copies from d = L1. Vertices
|
||||
* past each run's end are clamped onto it, so the miter corner at d=L1
|
||||
* is always sampled.
|
||||
* is always sampled. @p keepCenterWall keeps the template X=0 wall
|
||||
* (sidewalk strips — see appendTemplateCopy).
|
||||
*/
|
||||
static void buildWedgeStrip(Procedural::TriangleBuffer &out,
|
||||
const Procedural::TriangleBuffer &templ,
|
||||
float L1, float L2)
|
||||
float L1, float L2, bool keepCenterWall = false)
|
||||
{
|
||||
out.getVertices().clear();
|
||||
out.getIndices().clear();
|
||||
int k1 = std::max(1, (int)std::ceil(L1));
|
||||
int k2 = std::max(1, (int)std::ceil(L2));
|
||||
for (int i = 0; i < k1; ++i)
|
||||
appendTemplateCopy(out, templ, (float)i, L1);
|
||||
appendTemplateCopy(out, templ, (float)i, L1, keepCenterWall);
|
||||
for (int j = 0; j < k2; ++j)
|
||||
appendTemplateCopy(out, templ, L1 + (float)j, L1 + L2);
|
||||
appendTemplateCopy(out, templ, L1 + (float)j, L1 + L2,
|
||||
keepCenterWall);
|
||||
}
|
||||
|
||||
/* ----------------------------------------------------------------
|
||||
@@ -285,9 +302,20 @@ static Ogre::Vector3 wedgeCenterAt(const RoadWedge &wedge,
|
||||
return O + (MB - O) * ((d - L1) / L2);
|
||||
}
|
||||
|
||||
Ogre::Vector3 computeCurbOffset(const RoadWedge &wedge,
|
||||
const RoadGraph &graph,
|
||||
float d)
|
||||
/**
|
||||
* Width-parameterized curb offset worker (improvement plan §2.2).
|
||||
*
|
||||
* @p widen shifts both half-edge curb offsets outward along their own
|
||||
* direction before the miter/blend math runs, producing the curb chain
|
||||
* of a road widened by that amount (the miter corner and its pinned
|
||||
* zone scale along). Sidewalk strips sample two such chains (inner at
|
||||
* the curb + ROAD_SIDEWALK_WALL_GAP, outer + sidewalkWidth) so their
|
||||
* cross-sections cannot fold at inner corners — extending a pinned
|
||||
* miter fan past K would sweep the strip region twice.
|
||||
*/
|
||||
static Ogre::Vector3 curbOffsetWidened(const RoadWedge &wedge,
|
||||
const RoadGraph &graph, float d,
|
||||
float widen)
|
||||
{
|
||||
const RoadNode *node = graph.findNodeById(wedge.nodeId);
|
||||
if (!node)
|
||||
@@ -306,6 +334,15 @@ Ogre::Vector3 computeCurbOffset(const RoadWedge &wedge,
|
||||
Ogre::Vector3 offA = r1 * (h1.lanesOut * lw);
|
||||
Ogre::Vector3 offB = r2 * (-h2.lanesIn * lw);
|
||||
|
||||
if (widen != 0.0f) {
|
||||
float la = offA.length();
|
||||
if (la > 1e-6f)
|
||||
offA *= (la + widen) / la;
|
||||
float lb = offB.length();
|
||||
if (lb > 1e-6f)
|
||||
offB *= (lb + widen) / lb;
|
||||
}
|
||||
|
||||
/*
|
||||
* Miter corner: intersection of the two constant-width curb
|
||||
* lines, expressed as parameters t1/t2 along each direction from
|
||||
@@ -383,6 +420,13 @@ Ogre::Vector3 computeCurbOffset(const RoadWedge &wedge,
|
||||
return cornerOff + (offB - cornerOff) * t;
|
||||
}
|
||||
|
||||
Ogre::Vector3 computeCurbOffset(const RoadWedge &wedge,
|
||||
const RoadGraph &graph,
|
||||
float d)
|
||||
{
|
||||
return curbOffsetWidened(wedge, graph, d, 0.0f);
|
||||
}
|
||||
|
||||
void transformWedgeVertices(Procedural::TriangleBuffer &strip,
|
||||
const RoadWedge &wedge,
|
||||
const RoadGraph &graph)
|
||||
@@ -731,6 +775,241 @@ bool buildSegmentGeometry(const RoadStraightSegment &segment,
|
||||
return true;
|
||||
}
|
||||
|
||||
/* ----------------------------------------------------------------
|
||||
* Sidewalks (improvement plan §2.2)
|
||||
* ---------------------------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Phase 2 for sidewalk strips: like transformWedgeVertices, but the
|
||||
* template X axis maps onto the band between two widened curb chains
|
||||
* (inner: curb + ROAD_SIDEWALK_WALL_GAP, outer: + sidewalkWidth), and
|
||||
* template Y=0 sits at roadSurfaceY + sidewalkHeight.
|
||||
*/
|
||||
static void transformSidewalkVertices(Procedural::TriangleBuffer &strip,
|
||||
const RoadWedge &wedge,
|
||||
const RoadGraph &graph)
|
||||
{
|
||||
const RoadNode *node = graph.findNodeById(wedge.nodeId);
|
||||
if (!node)
|
||||
return;
|
||||
|
||||
const RoadHalfEdge &h1 = wedge.first;
|
||||
const RoadHalfEdge &h2 = wedge.second;
|
||||
const Ogre::Vector3 &O = node->position;
|
||||
Ogre::Vector3 dir1 = h1.direction;
|
||||
Ogre::Vector3 dir2 = h2.direction;
|
||||
float L1 = h1.halfLength > 1e-4f ? h1.halfLength : 1e-4f;
|
||||
float L = L1 + (h2.halfLength > 1e-4f ? h2.halfLength : 1e-4f);
|
||||
float sw = graph.config.sidewalkWidth;
|
||||
float sh = graph.config.sidewalkHeight;
|
||||
|
||||
float yO = O.y + nodeRoadLevel(graph, wedge.nodeId);
|
||||
|
||||
for (auto &v : strip.getVertices()) {
|
||||
float d = -v.mPosition.z;
|
||||
if (d < 0.0f)
|
||||
d = 0.0f;
|
||||
if (d > L)
|
||||
d = L;
|
||||
|
||||
/* Centerline (polyline MA -> O -> MB) and curb chains. */
|
||||
Ogre::Vector3 center = wedgeCenterAt(wedge, graph, d);
|
||||
|
||||
/* The strip spans between two properly mitered curb chains:
|
||||
* the inner one just outside the road curb (wall gap) and
|
||||
* the outer one a full sidewalk width further out. Each
|
||||
* chain has its own pinned miter corner, so cross-sections
|
||||
* cannot fold through inner corners (extending the road's
|
||||
* pinned fan past K would sweep the strip twice). */
|
||||
Ogre::Vector3 offIn = curbOffsetWidened(wedge, graph, d,
|
||||
ROAD_SIDEWALK_WALL_GAP);
|
||||
Ogre::Vector3 offOut = curbOffsetWidened(
|
||||
wedge, graph, d, ROAD_SIDEWALK_WALL_GAP + sw);
|
||||
|
||||
Ogre::Vector3 lateral = offOut - offIn;
|
||||
if (lateral.length() < 1e-4f)
|
||||
lateral = (d <= L1) ? roadRightVec(dir1)
|
||||
: -roadRightVec(dir2);
|
||||
else
|
||||
lateral.normalise();
|
||||
|
||||
Ogre::Vector3 worldXZ = center + offIn +
|
||||
(offOut - offIn) * v.mPosition.x;
|
||||
|
||||
/* Surface height, same sampling as the road slab. */
|
||||
float surfY;
|
||||
if (d < L1 - 1e-4f)
|
||||
surfY = halfEdgeHeightAt(h1, graph, L1 - d);
|
||||
else if (d > L1 + 1e-4f)
|
||||
surfY = halfEdgeHeightAt(h2, graph, d - L1);
|
||||
else
|
||||
surfY = yO;
|
||||
float worldY = surfY + sh + v.mPosition.y;
|
||||
|
||||
/* UVs: phase-continuous u, v across [0, sidewalkWidth]. */
|
||||
v.mUV.x = (d <= L1) ? halfEdgeU(h1, graph, L1 - d)
|
||||
: halfEdgeU(h2, graph, d - L1);
|
||||
v.mUV.y = v.mUV.y * sw;
|
||||
|
||||
/* Normals: same explicit axis mapping as the road (the
|
||||
* bend is a reflection of the template). */
|
||||
Ogre::Vector3 travel = (d <= L1) ? -dir1 : dir2;
|
||||
Ogre::Vector3 n = lateral * v.mNormal.x +
|
||||
Ogre::Vector3(0, v.mNormal.y, 0) +
|
||||
travel * (-v.mNormal.z);
|
||||
|
||||
v.mPosition = Ogre::Vector3(worldXZ.x, worldY, worldXZ.z);
|
||||
v.mNormal = n;
|
||||
}
|
||||
}
|
||||
|
||||
bool buildSidewalkGeometry(const RoadWedge &wedge,
|
||||
const RoadGraph &graph,
|
||||
Procedural::TriangleBuffer &out)
|
||||
{
|
||||
Procedural::TriangleBuffer fb =
|
||||
makeSidewalkFallbackTemplate(graph.config.sidewalkThickness);
|
||||
return buildSidewalkGeometry(wedge, graph, fb, out);
|
||||
}
|
||||
|
||||
bool buildSidewalkGeometry(const RoadWedge &wedge,
|
||||
const RoadGraph &graph,
|
||||
const Procedural::TriangleBuffer &templ,
|
||||
Procedural::TriangleBuffer &out)
|
||||
{
|
||||
if (!graph.config.sidewalkEnabled)
|
||||
return false;
|
||||
if (wedge.degenerate)
|
||||
return false;
|
||||
|
||||
/* Phase 1 — the X=0 (road-facing) wall stays: it is the visible
|
||||
* inner curb wall of the sidewalk. */
|
||||
Procedural::TriangleBuffer strip;
|
||||
buildWedgeStrip(strip, templ, wedge.first.halfLength,
|
||||
wedge.second.halfLength, true);
|
||||
|
||||
/* Phase 2. */
|
||||
transformSidewalkVertices(strip, wedge, graph);
|
||||
|
||||
/* Phase 3. */
|
||||
shiftSeamVertices(strip, wedge, graph);
|
||||
|
||||
/* Appended verbatim like the road strip (the template supplies
|
||||
* the closed cross-section). */
|
||||
int base = (int)out.getVertices().size();
|
||||
for (const auto &v : strip.getVertices())
|
||||
out.getVertices().push_back(v);
|
||||
|
||||
/* Same reflection as the road bend: reverse the winding so the
|
||||
* box faces point outward. */
|
||||
const std::vector<int> &si = strip.getIndices();
|
||||
out.getIndices().reserve(out.getIndices().size() + si.size());
|
||||
for (size_t t = 0; t + 2 < si.size(); t += 3) {
|
||||
out.getIndices().push_back(base + si[t]);
|
||||
out.getIndices().push_back(base + si[t + 2]);
|
||||
out.getIndices().push_back(base + si[t + 1]);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool computeSegmentSidewalkBand(const RoadStraightSegment &segment,
|
||||
const RoadGraph &graph, int side,
|
||||
Ogre::Vector3 c[4], Ogre::Vector2 uvc[4])
|
||||
{
|
||||
const RoadNode *node = graph.findNodeById(segment.nodeId);
|
||||
if (!node)
|
||||
return false;
|
||||
|
||||
const RoadHalfEdge &he = segment.halfEdge;
|
||||
if (he.lanesIn + he.lanesOut < 1)
|
||||
return false;
|
||||
|
||||
const Ogre::Vector3 &O = node->position;
|
||||
Ogre::Vector3 d = he.direction;
|
||||
Ogre::Vector3 r = roadRightVec(d);
|
||||
float lw = graph.config.laneWidth;
|
||||
float width = (side == 0) ? he.lanesIn * lw : he.lanesOut * lw;
|
||||
if (width <= 0.0f)
|
||||
return false;
|
||||
|
||||
/* side 0: inbound curb (left of travel, -r);
|
||||
* side 1: outbound curb (+r). */
|
||||
Ogre::Vector3 sideVec = (side == 0) ? -r : r;
|
||||
float sw = graph.config.sidewalkWidth;
|
||||
float inner = width + ROAD_SIDEWALK_WALL_GAP;
|
||||
float outer = inner + sw;
|
||||
float L = he.halfLength;
|
||||
float t0 = -SEAM_OVERLAP;
|
||||
|
||||
c[0] = O + t0 * d + sideVec * inner;
|
||||
c[1] = O + L * d + sideVec * inner;
|
||||
c[2] = O + L * d + sideVec * outer;
|
||||
c[3] = O + t0 * d + sideVec * outer;
|
||||
/* TOP surface heights: road surface + sidewalkHeight. */
|
||||
float sh = graph.config.sidewalkHeight;
|
||||
c[0].y = c[3].y = halfEdgeHeightAt(he, graph, t0) + sh;
|
||||
c[1].y = c[2].y = halfEdgeHeightAt(he, graph, L) + sh;
|
||||
|
||||
uvc[0] = Ogre::Vector2(halfEdgeU(he, graph, t0), 0.0f);
|
||||
uvc[1] = Ogre::Vector2(halfEdgeU(he, graph, L), 0.0f);
|
||||
uvc[2] = Ogre::Vector2(halfEdgeU(he, graph, L), sw);
|
||||
uvc[3] = Ogre::Vector2(halfEdgeU(he, graph, t0), sw);
|
||||
return true;
|
||||
}
|
||||
|
||||
bool buildSegmentSidewalkGeometry(const RoadStraightSegment &segment,
|
||||
const RoadGraph &graph,
|
||||
Procedural::TriangleBuffer &out)
|
||||
{
|
||||
if (!graph.config.sidewalkEnabled)
|
||||
return false;
|
||||
|
||||
float thickness = std::max(0.01f, graph.config.sidewalkThickness);
|
||||
|
||||
bool built = false;
|
||||
for (int side = 0; side < 2; ++side) {
|
||||
Ogre::Vector3 c[4];
|
||||
Ogre::Vector2 uvc[4];
|
||||
if (!computeSegmentSidewalkBand(segment, graph, side, c, uvc))
|
||||
continue;
|
||||
|
||||
/* extrudeToSlab goes +/- half thickness around the center
|
||||
* surface, so sink the band corners by half the thickness:
|
||||
* the slab top then lands on the computed top surface. */
|
||||
for (int i = 0; i < 4; ++i)
|
||||
c[i].y -= thickness * 0.5f;
|
||||
|
||||
Procedural::TriangleBuffer centerSurf;
|
||||
int base = (int)centerSurf.getVertices().size();
|
||||
for (int i = 0; i < 4; ++i) {
|
||||
Procedural::TriangleBuffer::Vertex v;
|
||||
v.mPosition = c[i];
|
||||
v.mNormal = Ogre::Vector3::UNIT_Y;
|
||||
v.mUV = uvc[i];
|
||||
centerSurf.getVertices().push_back(v);
|
||||
}
|
||||
centerSurf.getIndices().push_back(base + 0);
|
||||
centerSurf.getIndices().push_back(base + 1);
|
||||
centerSurf.getIndices().push_back(base + 2);
|
||||
centerSurf.getIndices().push_back(base + 0);
|
||||
centerSurf.getIndices().push_back(base + 2);
|
||||
centerSurf.getIndices().push_back(base + 3);
|
||||
|
||||
/* Skip the skirt at the far end (c[1]-c[2]): that plane
|
||||
* butts against the road piece from the edge's other
|
||||
* half, same as the road band. */
|
||||
auto skirtFilter = [&](const Ogre::Vector3 &p0,
|
||||
const Ogre::Vector3 &p1) -> bool {
|
||||
float d1 = p0.distance(c[1]) + p1.distance(c[2]);
|
||||
float d2 = p0.distance(c[2]) + p1.distance(c[1]);
|
||||
return (d1 > 0.001f && d2 > 0.001f);
|
||||
};
|
||||
extrudeToSlab(out, centerSurf, thickness, skirtFilter);
|
||||
built = true;
|
||||
}
|
||||
return built;
|
||||
}
|
||||
|
||||
/* ----------------------------------------------------------------
|
||||
* Template mesh loading
|
||||
* ---------------------------------------------------------------- */
|
||||
|
||||
@@ -54,6 +54,41 @@ bool buildSegmentGeometry(const RoadStraightSegment &segment,
|
||||
const RoadGraph &graph,
|
||||
Procedural::TriangleBuffer &out);
|
||||
|
||||
/**
|
||||
* Build the sidewalk strip along a wedge's outer curb and append to
|
||||
* @p out (improvement plan §2.2).
|
||||
*
|
||||
* Uses the same three-phase pipeline as the road slab; the strip
|
||||
* starts at the curb (+ ROAD_SIDEWALK_WALL_GAP) and extends
|
||||
* sidewalkWidth outward along the same rays that guarantee the
|
||||
* no-fold property, elevated by sidewalkHeight above the road
|
||||
* surface. The template profile is expected to span Y in
|
||||
* [-sidewalkThickness, 0] (top at 0 — see makeSidewalkFallbackTemplate).
|
||||
*
|
||||
* @return false when sidewalks are disabled, the wedge is degenerate,
|
||||
* or nothing was emitted.
|
||||
*/
|
||||
bool buildSidewalkGeometry(const RoadWedge &wedge,
|
||||
const RoadGraph &graph,
|
||||
Procedural::TriangleBuffer &out);
|
||||
|
||||
bool buildSidewalkGeometry(const RoadWedge &wedge,
|
||||
const RoadGraph &graph,
|
||||
const Procedural::TriangleBuffer &templ,
|
||||
Procedural::TriangleBuffer &out);
|
||||
|
||||
/**
|
||||
* Build the two sidewalk bands (inbound and outbound curb) of a
|
||||
* dead-end straight segment and append to @p out, each extruded into
|
||||
* a slab of sidewalkThickness whose top sits at
|
||||
* roadSurfaceY + sidewalkHeight.
|
||||
*
|
||||
* @return false when sidewalks are disabled or the segment is invalid.
|
||||
*/
|
||||
bool buildSegmentSidewalkGeometry(const RoadStraightSegment &segment,
|
||||
const RoadGraph &graph,
|
||||
Procedural::TriangleBuffer &out);
|
||||
|
||||
/* ----------------------------------------------------------------
|
||||
* Pipeline phases (exposed for testing)
|
||||
* ---------------------------------------------------------------- */
|
||||
@@ -107,6 +142,15 @@ void extrudeToSlab(Procedural::TriangleBuffer &out,
|
||||
*/
|
||||
Procedural::TriangleBuffer makeFallbackTemplate(float roadThickness);
|
||||
|
||||
/**
|
||||
* Sidewalk fallback template (improvement plan §2.2): same unit box,
|
||||
* but with the profile Y in [-sidewalkThickness, 0] so the strip top
|
||||
* lands exactly at roadSurfaceY + sidewalkHeight in the phase-2
|
||||
* mapping.
|
||||
*/
|
||||
Procedural::TriangleBuffer makeSidewalkFallbackTemplate(
|
||||
float sidewalkThickness);
|
||||
|
||||
/**
|
||||
* Load a road cross-section template from an OGRE mesh: the mesh
|
||||
* triangles are read verbatim and normalised into template space
|
||||
@@ -158,6 +202,19 @@ bool computeSegmentBand(const RoadStraightSegment &segment,
|
||||
const RoadGraph &graph,
|
||||
Ogre::Vector3 c[4], Ogre::Vector2 uvc[4]);
|
||||
|
||||
/**
|
||||
* Compute the four corners of one segment sidewalk band (improvement
|
||||
* plan §2.2).
|
||||
*
|
||||
* @p side: 0 = inbound curb (left of travel), 1 = outbound curb.
|
||||
* c[0..3] are the band corners of the TOP surface (at
|
||||
* roadSurfaceY + sidewalkHeight); uvc[0..3] their UVs.
|
||||
* @return false if the segment is invalid or the side has no curb.
|
||||
*/
|
||||
bool computeSegmentSidewalkBand(const RoadStraightSegment &segment,
|
||||
const RoadGraph &graph, int side,
|
||||
Ogre::Vector3 c[4], Ogre::Vector2 uvc[4]);
|
||||
|
||||
} // namespace RoadGeometryLib
|
||||
|
||||
#endif // ROAD_GEOMETRY_LIB_HPP
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
#include "../camera/EditorCamera.hpp"
|
||||
#include "../systems/TerrainSystem.hpp"
|
||||
#include "../systems/RoadSystem.hpp"
|
||||
#include "../systems/TerrainPrefabSpawnerSystem.hpp"
|
||||
#include "../components/EntityName.hpp"
|
||||
#include "../components/Transform.hpp"
|
||||
#include "../components/Renderable.hpp"
|
||||
@@ -134,6 +135,21 @@ bool EditorUISystem::onMousePressed(const Ogre::Ray &mouseRay)
|
||||
if (ImGui::GetIO().WantCaptureMouse)
|
||||
return false;
|
||||
|
||||
// Prefab spawn mode (M6): record the press; the click-vs-drag
|
||||
// decision is made on release (5 px threshold). The gizmo already
|
||||
// had priority above so spawners stay movable while placing.
|
||||
{
|
||||
TerrainPrefabSpawnerSystem *pss =
|
||||
TerrainPrefabSpawnerSystem::getInstance();
|
||||
if (pss && pss->getSpawnEditMode()) {
|
||||
m_prefabSpawnClickPending = true;
|
||||
const ImVec2 &mp = ImGui::GetIO().MousePos;
|
||||
m_prefabSpawnMouseDownPos = Ogre::Vector2(mp.x, mp.y);
|
||||
m_prefabSpawnMouseDownRay = mouseRay;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (!m_cursor3D || !m_cursor3D->isVisible())
|
||||
return false;
|
||||
|
||||
@@ -233,6 +249,26 @@ bool EditorUISystem::onMouseReleased()
|
||||
return true;
|
||||
}
|
||||
|
||||
// Prefab spawn mode (M6): resolve a pending click (below 5 px) as a
|
||||
// spawn-point placement; never consume the release so the camera
|
||||
// drag still completes.
|
||||
{
|
||||
TerrainPrefabSpawnerSystem *pss =
|
||||
TerrainPrefabSpawnerSystem::getInstance();
|
||||
if (pss && pss->getSpawnEditMode()) {
|
||||
if (m_prefabSpawnClickPending) {
|
||||
m_prefabSpawnClickPending = false;
|
||||
const ImVec2 &mp = ImGui::GetIO().MousePos;
|
||||
float dx = mp.x - m_prefabSpawnMouseDownPos.x;
|
||||
float dy = mp.y - m_prefabSpawnMouseDownPos.y;
|
||||
if (dx * dx + dy * dy < 25.0f)
|
||||
pss->handleSpawnClick(
|
||||
m_prefabSpawnMouseDownRay);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Cursor translate/rotate release
|
||||
if (m_cursor3D && (m_cursorMode == CursorInteractionMode::Translate ||
|
||||
m_cursorMode == CursorInteractionMode::Rotate)) {
|
||||
@@ -257,7 +293,12 @@ void EditorUISystem::update(float deltaTime)
|
||||
* Runs in both editor and game mode. */
|
||||
{
|
||||
TerrainSystem *ts = TerrainSystem::getInstance();
|
||||
if (ts) {
|
||||
TerrainPrefabSpawnerSystem *pss =
|
||||
TerrainPrefabSpawnerSystem::getInstance();
|
||||
/* Prefab spawn mode owns left-click placement; brushes stay
|
||||
* quiet while it is active. */
|
||||
const bool spawnEditing = pss && pss->getSpawnEditMode();
|
||||
if (ts && !spawnEditing) {
|
||||
if (ts->isSculpting() || ts->isPainting() ||
|
||||
ts->isAuxPainting()) {
|
||||
ImGuiIO &io = ImGui::GetIO();
|
||||
@@ -2146,6 +2187,47 @@ void EditorUISystem::handleRoadEditClick(const Ogre::Ray &mouseRay)
|
||||
Ogre::Vector3 hit = mouseRay.getPoint(t);
|
||||
|
||||
int nodeId = pickRoadNode(hit);
|
||||
|
||||
/* Connect tool: first click pins the source node, second click on
|
||||
* another node connects the two; the second node becomes the new
|
||||
* source so paths can be chained. */
|
||||
if (ts->getRoadEditTool() == TerrainSystem::RoadEditTool::Connect) {
|
||||
int selected = rs->getSelectedNodeId();
|
||||
if (nodeId < 0) {
|
||||
/* Empty terrain: clear the pin. */
|
||||
rs->setSelectedNodeId(-1);
|
||||
rs->setSelectedEdgeIndex(-1);
|
||||
if (m_roadGizmo)
|
||||
m_roadGizmo->setVisible(false);
|
||||
return;
|
||||
}
|
||||
if (selected < 0 || selected == nodeId) {
|
||||
rs->setSelectedNodeId(nodeId);
|
||||
rs->setSelectedEdgeIndex(-1);
|
||||
syncRoadGizmoToSelection();
|
||||
return;
|
||||
}
|
||||
flecs::entity terrain = rs->getTerrainEntity();
|
||||
if (!terrain.is_alive() || !terrain.has<TerrainComponent>())
|
||||
return;
|
||||
auto &tc = terrain.get_mut<TerrainComponent>();
|
||||
std::string error;
|
||||
std::vector<int> created;
|
||||
if (tc.roadGraph.connectNodes(selected, nodeId, tc.worldSize,
|
||||
&error, &created) >= 0) {
|
||||
/* Page-boundary split nodes start with an
|
||||
* interpolated Y; re-snap to the terrain. */
|
||||
rs->snapNodesToTerrain(created);
|
||||
rs->setSelectedNodeId(nodeId);
|
||||
rs->setSelectedEdgeIndex(-1);
|
||||
syncRoadGizmoToSelection();
|
||||
} else {
|
||||
/* Shown as a modal by the terrain editor panel. */
|
||||
rs->setConnectError(error);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (nodeId >= 0) {
|
||||
rs->setSelectedNodeId(nodeId);
|
||||
rs->setSelectedEdgeIndex(-1);
|
||||
|
||||
@@ -373,6 +373,12 @@ private:
|
||||
Ogre::Vector2 m_roadMouseDownPos = Ogre::Vector2::ZERO;
|
||||
Ogre::Ray m_roadMouseDownRay;
|
||||
|
||||
/* Pending left-click in terrain prefab spawn mode (M6); same
|
||||
* click-vs-drag disambiguation as road edit mode. */
|
||||
bool m_prefabSpawnClickPending = false;
|
||||
Ogre::Vector2 m_prefabSpawnMouseDownPos = Ogre::Vector2::ZERO;
|
||||
Ogre::Ray m_prefabSpawnMouseDownRay;
|
||||
|
||||
// Queries
|
||||
flecs::query<EntityNameComponent> m_nameQuery;
|
||||
|
||||
|
||||
@@ -5,8 +5,12 @@
|
||||
#include "../components/Transform.hpp"
|
||||
#include "../components/EntityName.hpp"
|
||||
#include "../components/EditorMarker.hpp"
|
||||
#include "../components/Renderable.hpp"
|
||||
#include "../components/RigidBody.hpp"
|
||||
#include "../physics/physics.h"
|
||||
#include <OgreLogManager.h>
|
||||
#include <filesystem>
|
||||
#include <vector>
|
||||
|
||||
std::string PrefabSystem::getPrefabsDirectory()
|
||||
{
|
||||
@@ -152,6 +156,57 @@ bool PrefabSystem::savePrefab(flecs::entity rootEntity,
|
||||
return true;
|
||||
}
|
||||
|
||||
void PrefabSystem::destroyInstance(flecs::entity entity,
|
||||
JoltPhysicsWrapper *physics,
|
||||
EditorUISystem *uiSystem)
|
||||
{
|
||||
if (!entity.is_alive())
|
||||
return;
|
||||
|
||||
std::vector<flecs::entity> children;
|
||||
entity.children(
|
||||
[&children](flecs::entity child) { children.push_back(child); });
|
||||
for (flecs::entity child : children)
|
||||
destroyInstance(child, physics, uiSystem);
|
||||
|
||||
if (physics && entity.has<RigidBodyComponent>()) {
|
||||
auto &rigidBody = entity.get_mut<RigidBodyComponent>();
|
||||
if (rigidBody.bodyCreated && !rigidBody.bodyID.IsInvalid()) {
|
||||
physics->removeBody(rigidBody.bodyID);
|
||||
physics->destroyBody(rigidBody.bodyID);
|
||||
rigidBody.bodyCreated = false;
|
||||
rigidBody.bodyID = JPH::BodyID();
|
||||
}
|
||||
}
|
||||
|
||||
if (entity.has<TransformComponent>()) {
|
||||
auto &transform = entity.get_mut<TransformComponent>();
|
||||
if (transform.node) {
|
||||
try {
|
||||
m_sceneMgr->destroySceneNode(transform.node);
|
||||
} catch (...) {
|
||||
}
|
||||
transform.node = nullptr;
|
||||
}
|
||||
}
|
||||
|
||||
if (entity.has<RenderableComponent>()) {
|
||||
auto &renderable = entity.get_mut<RenderableComponent>();
|
||||
if (renderable.entity) {
|
||||
try {
|
||||
m_sceneMgr->destroyEntity(renderable.entity);
|
||||
} catch (...) {
|
||||
}
|
||||
renderable.entity = nullptr;
|
||||
}
|
||||
}
|
||||
|
||||
if (uiSystem)
|
||||
uiSystem->removeEntity(entity);
|
||||
|
||||
entity.destruct();
|
||||
}
|
||||
|
||||
bool PrefabSystem::deletePrefab(const std::string &prefabPath)
|
||||
{
|
||||
try {
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
// Forward declarations
|
||||
class EditorUISystem;
|
||||
class SceneSerializer;
|
||||
class JoltPhysicsWrapper;
|
||||
|
||||
/**
|
||||
* @brief System for managing prefab instances.
|
||||
@@ -64,6 +65,24 @@ public:
|
||||
*/
|
||||
static std::string getPrefabsDirectory();
|
||||
|
||||
/**
|
||||
* @brief Destroy a prefab instance subtree without leaking resources.
|
||||
*
|
||||
* Recursively destroys child entities, removes Jolt bodies (when a
|
||||
* physics wrapper is given), destroys the Ogre scene nodes and
|
||||
* entities behind TransformComponent/RenderableComponent, removes
|
||||
* the entity from the UI caches (when a UI system is given) and
|
||||
* finally destructs the flecs entity. A bare entity.destruct()
|
||||
* leaks all of the above because the components only hold raw
|
||||
* pointers.
|
||||
*
|
||||
* Shared by TerrainPrefabSpawnerSystem and RoadSystem (improvement
|
||||
* plan §3.2).
|
||||
*/
|
||||
void destroyInstance(flecs::entity entity,
|
||||
JoltPhysicsWrapper *physics = nullptr,
|
||||
EditorUISystem *uiSystem = nullptr);
|
||||
|
||||
/**
|
||||
* @brief Get last error message.
|
||||
*/
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
#include "../components/Renderable.hpp"
|
||||
#include "../components/Lod.hpp"
|
||||
#include "../components/PhysicsCollider.hpp"
|
||||
#include "../components/EditorMarker.hpp"
|
||||
#include "../physics/physics.h"
|
||||
#include "../roadlib/RoadGeometryLib.hpp"
|
||||
#include "PrefabSystem.hpp"
|
||||
@@ -16,6 +17,7 @@
|
||||
#include <OgreMaterialManager.h>
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <fstream>
|
||||
#include <set>
|
||||
|
||||
static const float NODE_SIZE = 0.25f;
|
||||
@@ -143,6 +145,30 @@ void RoadSystem::setTerrainSystem(TerrainSystem *terrainSystem)
|
||||
m_terrainSystem = terrainSystem;
|
||||
}
|
||||
|
||||
void RoadSystem::snapNodesToTerrain(const std::vector<int> &nodeIds)
|
||||
{
|
||||
if (!m_terrainSystem || nodeIds.empty())
|
||||
return;
|
||||
|
||||
flecs::entity terrain = getTerrainEntity();
|
||||
if (!terrain.is_alive() || !terrain.has<TerrainComponent>())
|
||||
return;
|
||||
RoadGraph &rg =
|
||||
m_world.entity(m_terrainEntityId).get_mut<TerrainComponent>().roadGraph;
|
||||
|
||||
bool changed = false;
|
||||
for (int id : nodeIds) {
|
||||
RoadNode *n = rg.findNodeById(id);
|
||||
if (!n)
|
||||
continue;
|
||||
n->position.y = m_terrainSystem->getHeightAt(n->position) +
|
||||
n->verticalOffset;
|
||||
changed = true;
|
||||
}
|
||||
if (changed)
|
||||
rg.bumpVersion();
|
||||
}
|
||||
|
||||
flecs::entity RoadSystem::getTerrainEntity() const
|
||||
{
|
||||
return m_world.entity(m_terrainEntityId);
|
||||
@@ -248,10 +274,14 @@ void RoadSystem::update(float deltaTime)
|
||||
.markChanged();
|
||||
markNavMeshDirty();
|
||||
createPageCollider(pg, tb.meshName);
|
||||
spawnSidePrefabs(pg);
|
||||
pg.meshFinalized = true;
|
||||
}
|
||||
|
||||
/* Roadside prefabs follow camera distance and the current edge
|
||||
* data; re-evaluate every frame (spawn/despawn are transitions
|
||||
* only, so this is cheap). */
|
||||
updateEdgePrefabs();
|
||||
|
||||
/* Wedge debug: rebuild if selection or graph changed. */
|
||||
if (m_debugWedgeEnabled && m_debugWedgeObject &&
|
||||
(m_selectedNodeId != m_lastDebugNodeId ||
|
||||
@@ -413,12 +443,6 @@ void RoadSystem::destroyPageGeometry(RoadPageGeometry &pg)
|
||||
pg.colliderEntity.destruct();
|
||||
pg.colliderEntity = flecs::entity::null();
|
||||
|
||||
for (flecs::entity prefab : pg.spawnedPrefabs) {
|
||||
if (prefab.is_alive())
|
||||
prefab.destruct();
|
||||
}
|
||||
pg.spawnedPrefabs.clear();
|
||||
|
||||
pg.collisionVertices.clear();
|
||||
pg.collisionIndices.clear();
|
||||
pg.wedges.clear();
|
||||
@@ -433,6 +457,12 @@ void RoadSystem::clearPageGeometry()
|
||||
m_wedgeBuckets.clear();
|
||||
m_geometryGraphVersion = 0;
|
||||
|
||||
/* Roadside prefabs live in per-edge records now (keyed by node
|
||||
* ids, not pages) — despawn everything still alive. */
|
||||
for (auto &kv : m_edgePrefabs)
|
||||
despawnEdgePrefabs(kv.second);
|
||||
m_edgePrefabs.clear();
|
||||
|
||||
if (m_materialEntity.is_alive())
|
||||
m_materialEntity.destruct();
|
||||
m_materialEntity = flecs::entity::null();
|
||||
@@ -466,6 +496,20 @@ void RoadSystem::buildPageMeshes(RoadPageGeometry &pg)
|
||||
for (const RoadStraightSegment &s : pg.segments)
|
||||
buildSegmentGeometry(s, rg, *buffer);
|
||||
|
||||
/* Sidewalks (improvement plan §2): appended into the same page
|
||||
* buffer, so they inherit the road material, LOD/visibility
|
||||
* distance, collider soup and navmesh dirtying. */
|
||||
if (rg.config.sidewalkEnabled) {
|
||||
const Procedural::TriangleBuffer &swTempl =
|
||||
getSidewalkTemplate(rg.config);
|
||||
for (const RoadWedge &w : pg.wedges)
|
||||
RoadGeometryLib::buildSidewalkGeometry(w, rg, swTempl,
|
||||
*buffer);
|
||||
for (const RoadStraightSegment &s : pg.segments)
|
||||
RoadGeometryLib::buildSegmentSidewalkGeometry(s, rg,
|
||||
*buffer);
|
||||
}
|
||||
|
||||
if (buffer->getIndices().empty()) {
|
||||
/* No road content seeded on this page. */
|
||||
destroyPageMeshes(pg);
|
||||
@@ -678,6 +722,11 @@ void RoadSystem::createPageCollider(RoadPageGeometry &pg,
|
||||
m_physics->addBody(bodyId, JPH::EActivation::DontActivate);
|
||||
pg.bodyId = bodyId;
|
||||
|
||||
/* Track the body for the physics debug-draw filter (M5.9.6):
|
||||
* TerrainSystem syncs this set into its TerrainBodyDrawFilter so
|
||||
* road colliders are hidden unless "Show Road Colliders" is on. */
|
||||
m_roadBodyIds.insert(bodyId);
|
||||
|
||||
/* Informational component: mirrors the collider configuration on
|
||||
* the buffer entity (no RigidBodyComponent, so PhysicsSystem does
|
||||
* not build a second body from it). */
|
||||
@@ -697,6 +746,7 @@ void RoadSystem::destroyPageCollider(RoadPageGeometry &pg)
|
||||
m_physics->removeBody(pg.bodyId);
|
||||
m_physics->destroyBody(pg.bodyId);
|
||||
}
|
||||
m_roadBodyIds.erase(pg.bodyId);
|
||||
pg.bodyId = JPH::BodyID();
|
||||
|
||||
if (pg.bufferEntity.is_alive() &&
|
||||
@@ -924,6 +974,26 @@ RoadSystem::getRoadTemplate(const RoadConfig &cfg)
|
||||
return m_templateBuffer;
|
||||
}
|
||||
|
||||
const Procedural::TriangleBuffer &
|
||||
RoadSystem::getSidewalkTemplate(const RoadConfig &cfg)
|
||||
{
|
||||
if (cfg.sidewalkMeshTemplate == m_sidewalkTemplateName &&
|
||||
cfg.sidewalkThickness == m_sidewalkTemplateThickness)
|
||||
return m_sidewalkTemplateBuffer;
|
||||
|
||||
m_sidewalkTemplateName = cfg.sidewalkMeshTemplate;
|
||||
m_sidewalkTemplateThickness = cfg.sidewalkThickness;
|
||||
m_sidewalkTemplateBuffer = Procedural::TriangleBuffer();
|
||||
|
||||
if (!RoadGeometryLib::loadTemplateFromMesh(
|
||||
cfg.sidewalkMeshTemplate, m_sidewalkTemplateBuffer))
|
||||
m_sidewalkTemplateBuffer =
|
||||
RoadGeometryLib::makeSidewalkFallbackTemplate(
|
||||
cfg.sidewalkThickness);
|
||||
|
||||
return m_sidewalkTemplateBuffer;
|
||||
}
|
||||
|
||||
Procedural::TriangleBuffer
|
||||
RoadSystem::makeFallbackTemplate(float roadThickness)
|
||||
{
|
||||
@@ -966,7 +1036,125 @@ bool RoadSystem::buildSegmentGeometry(const RoadStraightSegment &segment,
|
||||
/* Roadside prefab spawning (M5.11) */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
void RoadSystem::spawnSidePrefabs(RoadPageGeometry &pg)
|
||||
bool RoadSystem::prefabSlotEquals(const RoadEdgePrefabSlot &a,
|
||||
const RoadEdgePrefabSlot &b)
|
||||
{
|
||||
return a.prefabPath == b.prefabPath && a.edgeT == b.edgeT &&
|
||||
a.lateralOffset == b.lateralOffset && a.yOffset == b.yOffset;
|
||||
}
|
||||
|
||||
void RoadSystem::despawnEdgePrefabs(EdgePrefabRecord &rec)
|
||||
{
|
||||
PrefabSystem prefabSys(m_world, m_sceneMgr);
|
||||
flecs::entity slots[3] = { rec.left, rec.right, rec.mid };
|
||||
for (flecs::entity inst : slots) {
|
||||
if (inst.is_alive())
|
||||
prefabSys.destroyInstance(inst, m_physics);
|
||||
}
|
||||
rec.left = rec.right = rec.mid = flecs::entity::null();
|
||||
rec.spawned = false;
|
||||
rec.tried = false;
|
||||
rec.lastForce = false;
|
||||
}
|
||||
|
||||
void RoadSystem::spawnEdgePrefab(const RoadGraph &rg, const RoadEdge &edge,
|
||||
const RoadEdgePrefabSlot &slot, int slotIndex,
|
||||
flecs::entity &out)
|
||||
{
|
||||
out = flecs::entity::null();
|
||||
if (slot.prefabPath.empty())
|
||||
return;
|
||||
|
||||
/* Skip quietly when the prefab file is missing; the record's
|
||||
* tried flag keeps this from being retested every frame. */
|
||||
std::ifstream f(slot.prefabPath.c_str());
|
||||
if (!f.good())
|
||||
return;
|
||||
|
||||
const RoadNode *na = rg.findNodeById(edge.nodeA);
|
||||
const RoadNode *nb = rg.findNodeById(edge.nodeB);
|
||||
if (!na || !nb)
|
||||
return;
|
||||
|
||||
Ogre::Vector3 dir = nb->position - na->position;
|
||||
dir.y = 0;
|
||||
if (dir.isZeroLength())
|
||||
return;
|
||||
dir.normalise();
|
||||
/* Left of the nodeA->nodeB travel direction. */
|
||||
Ogre::Vector3 left = Ogre::Vector3::UNIT_Y.crossProduct(dir);
|
||||
|
||||
int lanesFrom = 0, lanesTo = 0;
|
||||
rg.resolveLaneCounts(edge, edge.nodeA, lanesFrom, lanesTo);
|
||||
float lw = rg.config.laneWidth;
|
||||
/* Half-width of the road on each side, looking A->B: the outbound
|
||||
* lanes (lanesAtoB) run on the right, the inbound lanes (lanesBtoA)
|
||||
* on the left. */
|
||||
float rightHalfWidth = lanesFrom * lw;
|
||||
float leftHalfWidth = lanesTo * lw;
|
||||
|
||||
float lateralSign = 0.0f;
|
||||
float halfWidth = 0.0f;
|
||||
const char *slotName = "mid";
|
||||
if (slotIndex == 0) {
|
||||
lateralSign = 1.0f;
|
||||
halfWidth = leftHalfWidth;
|
||||
slotName = "left";
|
||||
} else if (slotIndex == 1) {
|
||||
lateralSign = -1.0f;
|
||||
halfWidth = rightHalfWidth;
|
||||
slotName = "right";
|
||||
}
|
||||
|
||||
/* Anchor: on the edge at edgeT, shifted to the curb (side slots)
|
||||
* plus lateralOffset. */
|
||||
float t = std::max(0.0f, std::min(1.0f, slot.edgeT));
|
||||
Ogre::Vector3 pos = na->position + (nb->position - na->position) * t;
|
||||
pos += left * (lateralSign * (halfWidth + slot.lateralOffset));
|
||||
|
||||
/* Y follows the interpolated road surface height (node Y + road
|
||||
* level), not the raw terrain. */
|
||||
float surfY = na->position.y + edge.roadLevelA +
|
||||
(nb->position.y + edge.roadLevelB - na->position.y -
|
||||
edge.roadLevelA) * t;
|
||||
pos.y = surfY + slot.yOffset;
|
||||
|
||||
std::string name = "road_prefab_" + std::to_string(edge.nodeA) + "_" +
|
||||
std::to_string(edge.nodeB) + "_" + slotName;
|
||||
|
||||
PrefabSystem prefabSys(m_world, m_sceneMgr);
|
||||
/* Root-level instance (no parent): parenting to the terrain
|
||||
* entity would leave dangling scene nodes on terrain teardown. */
|
||||
flecs::entity inst = prefabSys.createInstance(
|
||||
slot.prefabPath, flecs::entity::null(), pos, name);
|
||||
if (!inst.is_alive()) {
|
||||
Ogre::LogManager::getSingleton().logMessage(
|
||||
"RoadSystem: failed to spawn road prefab \"" +
|
||||
slot.prefabPath + "\" for edge " +
|
||||
std::to_string(edge.nodeA) + "-" +
|
||||
std::to_string(edge.nodeB));
|
||||
return;
|
||||
}
|
||||
|
||||
/* Runtime-only, regenerated from edge data (M5.11/M5.12): remove
|
||||
* the editor marker so instances are not serialized with the scene
|
||||
* and stay out of the editor outliner. */
|
||||
if (inst.has<EditorMarkerComponent>())
|
||||
inst.remove<EditorMarkerComponent>();
|
||||
|
||||
/* Align the prefab's -Z forward with the edge direction. */
|
||||
Ogre::Quaternion yaw =
|
||||
Ogre::Vector3::NEGATIVE_UNIT_Z.getRotationTo(dir);
|
||||
if (inst.has<TransformComponent>()) {
|
||||
auto &tc = inst.get_mut<TransformComponent>();
|
||||
tc.rotation = yaw * tc.rotation;
|
||||
tc.applyToNode();
|
||||
}
|
||||
|
||||
out = inst;
|
||||
}
|
||||
|
||||
void RoadSystem::updateEdgePrefabs()
|
||||
{
|
||||
if (!m_terrainSystem)
|
||||
return;
|
||||
@@ -976,52 +1164,98 @@ void RoadSystem::spawnSidePrefabs(RoadPageGeometry &pg)
|
||||
return;
|
||||
|
||||
const auto &rg = terrain.get<TerrainComponent>().roadGraph;
|
||||
PrefabSystem prefabSys(m_world, m_sceneMgr);
|
||||
|
||||
for (const auto &edge : rg.edges) {
|
||||
for (const auto &sp : edge.sidePrefabs) {
|
||||
const RoadNode *na = rg.findNodeById(edge.nodeA);
|
||||
const RoadNode *nb = rg.findNodeById(edge.nodeB);
|
||||
if (!na || !nb)
|
||||
continue;
|
||||
Ogre::Vector3 camPos = Ogre::Vector3::ZERO;
|
||||
Ogre::Camera *cam = m_terrainSystem->getCamera();
|
||||
if (cam)
|
||||
camPos = cam->getDerivedPosition();
|
||||
|
||||
/* Compute world position along the edge at edgeT. */
|
||||
float t = std::max(0.0f, std::min(1.0f, sp.edgeT));
|
||||
Ogre::Vector3 pos = na->position +
|
||||
(nb->position - na->position) * t;
|
||||
float spawnSq = rg.config.prefabSpawnDistance *
|
||||
rg.config.prefabSpawnDistance;
|
||||
float despawnSq = rg.config.prefabDespawnDistance *
|
||||
rg.config.prefabDespawnDistance;
|
||||
|
||||
/* Lateral offset perpendicular to edge direction. */
|
||||
Ogre::Vector3 dir = nb->position - na->position;
|
||||
dir.y = 0;
|
||||
if (!dir.isZeroLength()) {
|
||||
dir.normalise();
|
||||
Ogre::Vector3 side =
|
||||
Ogre::Vector3::UNIT_Y.crossProduct(dir);
|
||||
if (!sp.leftSide)
|
||||
side = -side;
|
||||
pos += side * sp.sideOffset;
|
||||
}
|
||||
bool editMode = m_terrainSystem->getRoadEditMode();
|
||||
|
||||
/* Snap Y to terrain surface. */
|
||||
pos.y = m_terrainSystem->getHeightAt(pos);
|
||||
std::set<uint64_t> liveKeys;
|
||||
for (size_t ei = 0; ei < rg.edges.size(); ++ei) {
|
||||
const RoadEdge &edge = rg.edges[ei];
|
||||
if (edge.prefabLeft.prefabPath.empty() &&
|
||||
edge.prefabRight.prefabPath.empty() &&
|
||||
edge.prefabMid.prefabPath.empty())
|
||||
continue;
|
||||
|
||||
/* Generate a unique name for the instance. */
|
||||
std::string name = "road_prefab_" +
|
||||
std::to_string(edge.nodeA) + "_" +
|
||||
std::to_string(edge.nodeB) + "_" +
|
||||
std::to_string(sp.edgeT);
|
||||
const RoadNode *na = rg.findNodeById(edge.nodeA);
|
||||
const RoadNode *nb = rg.findNodeById(edge.nodeB);
|
||||
if (!na || !nb)
|
||||
continue;
|
||||
|
||||
flecs::entity inst = prefabSys.createInstance(
|
||||
sp.prefabPath, terrain, pos, name);
|
||||
uint64_t key = edgePrefabKey(edge.nodeA, edge.nodeB);
|
||||
liveKeys.insert(key);
|
||||
|
||||
if (inst.is_alive())
|
||||
pg.spawnedPrefabs.push_back(inst);
|
||||
else
|
||||
Ogre::LogManager::getSingleton().logMessage(
|
||||
"RoadSystem: failed to spawn roadside prefab \"" +
|
||||
sp.prefabPath + "\" for edge " +
|
||||
std::to_string(edge.nodeA) + "-" +
|
||||
std::to_string(edge.nodeB));
|
||||
/* Distance from the camera to the edge segment. */
|
||||
Ogre::Vector3 ab = nb->position - na->position;
|
||||
float lenSq = ab.squaredLength();
|
||||
float distSq;
|
||||
if (lenSq <= 0.0f) {
|
||||
distSq = camPos.squaredDistance(na->position);
|
||||
} else {
|
||||
float s = (camPos - na->position).dotProduct(ab) /
|
||||
lenSq;
|
||||
s = std::max(0.0f, std::min(1.0f, s));
|
||||
distSq = camPos.squaredDistance(na->position + ab * s);
|
||||
}
|
||||
|
||||
/* Both endpoint pages must be loaded so prefabs do not
|
||||
* appear before the road mesh under them exists. */
|
||||
uint64_t pageA = 0, pageB = 0;
|
||||
bool pagesLoaded = pageKeyForNode(edge.nodeA, pageA) &&
|
||||
pageKeyForNode(edge.nodeB, pageB) &&
|
||||
m_pageGeometry.count(pageA) &&
|
||||
m_pageGeometry.count(pageB);
|
||||
|
||||
/* Edit mode force-spawns the selected edge's prefabs so
|
||||
* slot changes are previewed regardless of distance. */
|
||||
bool force = editMode && (int)ei == m_selectedEdgeIndex;
|
||||
|
||||
EdgePrefabRecord &rec = m_edgePrefabs[key];
|
||||
bool sameData =
|
||||
prefabSlotEquals(rec.leftSlot, edge.prefabLeft) &&
|
||||
prefabSlotEquals(rec.rightSlot, edge.prefabRight) &&
|
||||
prefabSlotEquals(rec.midSlot, edge.prefabMid);
|
||||
|
||||
if (rec.spawned &&
|
||||
(!sameData || !pagesLoaded ||
|
||||
(!force && distSq > despawnSq)))
|
||||
despawnEdgePrefabs(rec);
|
||||
|
||||
if (!rec.spawned && pagesLoaded &&
|
||||
(force || distSq <= spawnSq) &&
|
||||
(!rec.tried || !sameData || force != rec.lastForce)) {
|
||||
spawnEdgePrefab(rg, edge, edge.prefabLeft, 0,
|
||||
rec.left);
|
||||
spawnEdgePrefab(rg, edge, edge.prefabRight, 1,
|
||||
rec.right);
|
||||
spawnEdgePrefab(rg, edge, edge.prefabMid, 2, rec.mid);
|
||||
rec.leftSlot = edge.prefabLeft;
|
||||
rec.rightSlot = edge.prefabRight;
|
||||
rec.midSlot = edge.prefabMid;
|
||||
rec.tried = true;
|
||||
rec.spawned = rec.left.is_alive() ||
|
||||
rec.right.is_alive() ||
|
||||
rec.mid.is_alive();
|
||||
rec.lastForce = force;
|
||||
}
|
||||
}
|
||||
|
||||
/* Drop records for edges that no longer exist or no longer have
|
||||
* any slot configured. */
|
||||
for (auto it = m_edgePrefabs.begin(); it != m_edgePrefabs.end();) {
|
||||
if (liveKeys.find(it->first) == liveKeys.end()) {
|
||||
despawnEdgePrefabs(it->second);
|
||||
it = m_edgePrefabs.erase(it);
|
||||
} else {
|
||||
++it;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1030,6 +1264,95 @@ void RoadSystem::spawnSidePrefabs(RoadPageGeometry &pg)
|
||||
/* Terrain compliance (M5.10) */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
void RoadSystem::writeComplianceFalloff(TerrainSystem *terrainSystem,
|
||||
const RoadGraph &graph,
|
||||
const RoadHalfEdge &halfEdge,
|
||||
const Ogre::Vector3 &nodePos,
|
||||
float sideSign, float sideWidth,
|
||||
float roadThickness, float laneWidth)
|
||||
{
|
||||
if (!terrainSystem || sideWidth <= 1e-4f ||
|
||||
halfEdge.halfLength <= 1e-4f)
|
||||
return;
|
||||
|
||||
/* Fade zone: linear fade over laneWidth * 2 outside the outermost
|
||||
* lane (M5.10 step 4). */
|
||||
float fadeWidth = laneWidth * 2.0f;
|
||||
if (fadeWidth <= 1e-4f)
|
||||
return;
|
||||
|
||||
float texel = terrainSystem->getFixupTexelSize();
|
||||
if (texel <= 1e-6f)
|
||||
return;
|
||||
|
||||
Ogre::Vector3 outward =
|
||||
RoadGeometryLib::roadRightVec(halfEdge.direction) * sideSign;
|
||||
float halfThick = std::max(0.01f, roadThickness) * 0.5f;
|
||||
|
||||
/*
|
||||
* Walk the fixup samples covering the fade band (t in
|
||||
* [0, halfLength] along the half-edge, lateral in (sideWidth,
|
||||
* sideWidth + fadeWidth)) and evaluate the fade target at each
|
||||
* exact sample position, writing single samples. Because the
|
||||
* target is linear in the lateral direction and sampling is
|
||||
* bilinear, reads anywhere in the band reproduce the ramp; sparse
|
||||
* point writes would leave the coarse page-vertex sampler between
|
||||
* written cells. Samples outside the band are skipped: inside the
|
||||
* curb the full-compliance pass writes the slab underside (it runs
|
||||
* after this pass and wins on shared samples); beyond the fade the
|
||||
* terrain keeps its natural height (sentinel blends to base).
|
||||
*/
|
||||
Ogre::Vector3 far = halfEdge.direction * halfEdge.halfLength;
|
||||
Ogre::Vector3 nearOff = outward * sideWidth;
|
||||
Ogre::Vector3 farOff = outward * (sideWidth + fadeWidth);
|
||||
Ogre::Vector3 corners[4] = {
|
||||
nodePos + nearOff,
|
||||
nodePos + farOff,
|
||||
nodePos + far + nearOff,
|
||||
nodePos + far + farOff,
|
||||
};
|
||||
float minX = corners[0].x, maxX = corners[0].x;
|
||||
float minZ = corners[0].z, maxZ = corners[0].z;
|
||||
for (int i = 1; i < 4; ++i) {
|
||||
minX = std::min(minX, corners[i].x);
|
||||
maxX = std::max(maxX, corners[i].x);
|
||||
minZ = std::min(minZ, corners[i].z);
|
||||
maxZ = std::max(maxZ, corners[i].z);
|
||||
}
|
||||
|
||||
long ix0 = (long)std::floor(minX / texel);
|
||||
long ix1 = (long)std::floor(maxX / texel);
|
||||
long iz0 = (long)std::floor(minZ / texel);
|
||||
long iz1 = (long)std::floor(maxZ / texel);
|
||||
|
||||
for (long iz = iz0; iz <= iz1; ++iz) {
|
||||
for (long ix = ix0; ix <= ix1; ++ix) {
|
||||
Ogre::Vector3 p((float)ix * texel, 0.0f,
|
||||
(float)iz * texel);
|
||||
Ogre::Vector3 rel = p - nodePos;
|
||||
float t = rel.dotProduct(halfEdge.direction);
|
||||
if (t < 0.0f || t > halfEdge.halfLength)
|
||||
continue;
|
||||
float lateral = rel.dotProduct(outward);
|
||||
float d = lateral - sideWidth;
|
||||
if (d <= 0.0f || d >= fadeWidth)
|
||||
continue;
|
||||
/* Top-surface height so target = top - roadThickness
|
||||
* equals the slab underside at the curb, matching the
|
||||
* full-compliance writes. */
|
||||
float surfY = RoadGeometryLib::halfEdgeHeightAt(
|
||||
halfEdge, graph, t) +
|
||||
halfThick;
|
||||
float base = terrainSystem->sampleBaseHeightAt(
|
||||
(long)std::floor(p.x), (long)std::floor(p.z));
|
||||
float target = computeComplianceHeight(
|
||||
surfY, roadThickness, base, lateral,
|
||||
sideWidth, fadeWidth);
|
||||
terrainSystem->writeFixupSample(p.x, p.z, target);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void RoadSystem::complyTerrain(TerrainSystem *terrainSystem,
|
||||
float roadThickness, float laneWidth)
|
||||
{
|
||||
@@ -1044,11 +1367,71 @@ void RoadSystem::complyTerrain(TerrainSystem *terrainSystem,
|
||||
Procedural::TriangleBuffer fb =
|
||||
makeFallbackTemplate(rg.config.roadThickness);
|
||||
|
||||
/* Sidewalks (improvement plan §2.3): the shoulder fade starts at
|
||||
* the sidewalk's outer edge, and the full-compliance pass flattens
|
||||
* the terrain under the sidewalk bodies too. */
|
||||
bool sidewalks = rg.config.sidewalkEnabled;
|
||||
float sideExtra = sidewalks ? rg.config.sidewalkWidth : 0.0f;
|
||||
float sidewalkThick = std::max(0.01f, rg.config.sidewalkThickness);
|
||||
Procedural::TriangleBuffer fbSw =
|
||||
RoadGeometryLib::makeSidewalkFallbackTemplate(sidewalkThick);
|
||||
|
||||
/* --- Perpendicular falloff (M5.10 step 4), written FIRST ---
|
||||
* Extend a linear fade over laneWidth * 2 outside each curb. Beyond
|
||||
* the fade zone no fixup is written (base heightmap + detail noise).
|
||||
* This pass runs before the full-compliance pass below so the slab
|
||||
* underside values win wherever the coarse chunk cells overlap the
|
||||
* fade band (curb rows, node overlaps). */
|
||||
for (auto &kv : m_pageGeometry) {
|
||||
RoadPageGeometry &pg = kv.second;
|
||||
|
||||
for (const RoadWedge &wedge : pg.wedges) {
|
||||
const RoadNode *node = rg.findNodeById(wedge.nodeId);
|
||||
if (!node)
|
||||
continue;
|
||||
/* The wedge's outer curb sits on the right of the
|
||||
* first half-edge (lanesOut wide) and on the left of
|
||||
* the second half-edge (lanesIn wide). */
|
||||
writeComplianceFalloff(terrainSystem, rg, wedge.first,
|
||||
node->position, +1.0f,
|
||||
wedge.first.lanesOut * laneWidth +
|
||||
sideExtra,
|
||||
roadThickness, laneWidth);
|
||||
writeComplianceFalloff(terrainSystem, rg, wedge.second,
|
||||
node->position, -1.0f,
|
||||
wedge.second.lanesIn * laneWidth +
|
||||
sideExtra,
|
||||
roadThickness, laneWidth);
|
||||
}
|
||||
|
||||
for (const RoadStraightSegment &seg : pg.segments) {
|
||||
const RoadNode *node =
|
||||
rg.findNodeById(seg.halfEdge.nodeId);
|
||||
if (!node)
|
||||
continue;
|
||||
/* Dead-end segments span both sides of the
|
||||
* centerline: right curb at lanesOut * laneWidth,
|
||||
* left curb at lanesIn * laneWidth. */
|
||||
writeComplianceFalloff(terrainSystem, rg, seg.halfEdge,
|
||||
node->position, +1.0f,
|
||||
seg.halfEdge.lanesOut * laneWidth +
|
||||
sideExtra,
|
||||
roadThickness, laneWidth);
|
||||
writeComplianceFalloff(terrainSystem, rg, seg.halfEdge,
|
||||
node->position, -1.0f,
|
||||
seg.halfEdge.lanesIn * laneWidth +
|
||||
sideExtra,
|
||||
roadThickness, laneWidth);
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* Walk every loaded page's wedges and segments, generate road
|
||||
* geometry into a temp buffer, then write fixup values under every
|
||||
* top-surface vertex. The fixup target is the road underside:
|
||||
* surfaceY - roadThickness.
|
||||
* surfaceY - roadThickness. Runs AFTER the falloff pass so full
|
||||
* compliance under the road overrides any fade values in shared
|
||||
* chunk cells.
|
||||
*/
|
||||
for (auto &kv : m_pageGeometry) {
|
||||
RoadPageGeometry &pg = kv.second;
|
||||
@@ -1070,6 +1453,23 @@ void RoadSystem::complyTerrain(TerrainSystem *terrainSystem,
|
||||
v.mPosition.x, v.mPosition.z,
|
||||
v.mPosition.y - roadThickness);
|
||||
}
|
||||
|
||||
/* Sidewalk strip: flatten under its body too;
|
||||
* the fixup target is the sidewalk underside
|
||||
* (top - sidewalkThickness). */
|
||||
if (sidewalks) {
|
||||
Procedural::TriangleBuffer tmpSw;
|
||||
if (!RoadGeometryLib::buildSidewalkGeometry(
|
||||
wedge, rg, fbSw, tmpSw))
|
||||
continue;
|
||||
for (const auto &v : tmpSw.getVertices()) {
|
||||
if (v.mNormal.y <= 0.5f)
|
||||
continue;
|
||||
terrainSystem->writeFixup(
|
||||
v.mPosition.x, v.mPosition.z,
|
||||
v.mPosition.y - sidewalkThick);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const RoadStraightSegment &seg : pg.segments) {
|
||||
@@ -1078,11 +1478,14 @@ void RoadSystem::complyTerrain(TerrainSystem *terrainSystem,
|
||||
if (!RoadGeometryLib::computeSegmentBand(seg, rg, c, uvc))
|
||||
continue;
|
||||
|
||||
/* Write fixups at the band corners. */
|
||||
/* Band corners/interior carry the CENTER surface
|
||||
* height; the slab underside is half a thickness
|
||||
* below it, matching the wedge pass (top vertex
|
||||
* minus full thickness). */
|
||||
for (int i = 0; i < 4; ++i)
|
||||
terrainSystem->writeFixup(
|
||||
c[i].x, c[i].z,
|
||||
c[i].y - roadThickness);
|
||||
c[i].y - halfThick);
|
||||
|
||||
/* Sample intermediate points along the band edges
|
||||
* and interior for smooth compliance. */
|
||||
@@ -1102,7 +1505,55 @@ void RoadSystem::complyTerrain(TerrainSystem *terrainSystem,
|
||||
Ogre::Vector3 pos = p0 + (p1 - p0) * wt;
|
||||
terrainSystem->writeFixup(
|
||||
pos.x, pos.z,
|
||||
pos.y - roadThickness);
|
||||
pos.y - halfThick);
|
||||
}
|
||||
}
|
||||
|
||||
/* Sidewalk bands: same sampling, fixup target is
|
||||
* the sidewalk underside (top - sidewalkThickness). */
|
||||
if (sidewalks) {
|
||||
for (int side = 0; side < 2; ++side) {
|
||||
Ogre::Vector3 sc[4];
|
||||
Ogre::Vector2 suvc[4];
|
||||
if (!RoadGeometryLib::
|
||||
computeSegmentSidewalkBand(
|
||||
seg, rg, side, sc,
|
||||
suvc))
|
||||
continue;
|
||||
for (int i = 0; i < 4; ++i)
|
||||
terrainSystem->writeFixup(
|
||||
sc[i].x, sc[i].z,
|
||||
sc[i].y - sidewalkThick);
|
||||
|
||||
Ogre::Vector3 sd10 = sc[1] - sc[0];
|
||||
Ogre::Vector3 sd32 = sc[2] - sc[3];
|
||||
Ogre::Vector3 sd30 = sc[3] - sc[0];
|
||||
int sSteps = std::max(
|
||||
1, (int)std::ceil(
|
||||
sd10.length()));
|
||||
int sWidth = std::max(
|
||||
1, (int)std::ceil(
|
||||
sd30.length()));
|
||||
for (int s = 1; s < sSteps; ++s) {
|
||||
float t = (float)s /
|
||||
(float)sSteps;
|
||||
Ogre::Vector3 p0 =
|
||||
sc[0] + sd10 * t;
|
||||
Ogre::Vector3 p1 =
|
||||
sc[3] + sd32 * t;
|
||||
for (int w = 0; w <= sWidth;
|
||||
++w) {
|
||||
float wt = (float)w /
|
||||
(float)sWidth;
|
||||
Ogre::Vector3 pos =
|
||||
p0 + (p1 - p0) *
|
||||
wt;
|
||||
terrainSystem->writeFixup(
|
||||
pos.x, pos.z,
|
||||
pos.y -
|
||||
sidewalkThick);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,9 +54,6 @@ struct RoadPageGeometry {
|
||||
std::vector<Ogre::Vector3> collisionVertices;
|
||||
std::vector<uint32_t> collisionIndices;
|
||||
|
||||
/** Roadside prefab instances spawned for this page (M5.11). */
|
||||
std::vector<flecs::entity> spawnedPrefabs;
|
||||
|
||||
/** Wedges and straight segments seeded by nodes inside this page. */
|
||||
std::vector<RoadWedge> wedges;
|
||||
std::vector<RoadStraightSegment> segments;
|
||||
@@ -120,6 +117,27 @@ public:
|
||||
int getSelectedEdgeIndex() const { return m_selectedEdgeIndex; }
|
||||
void setSelectedEdgeIndex(int idx);
|
||||
|
||||
/**
|
||||
* Last connectNodes failure from the 3D Connect tool. The terrain
|
||||
* editor panel consumes it with takeConnectError() and shows it as
|
||||
* a modal; kept here because RoadSystem is the shared hub both the
|
||||
* click handler and the panel can reach.
|
||||
*/
|
||||
void setConnectError(const std::string &msg)
|
||||
{
|
||||
m_connectError = msg;
|
||||
m_connectErrorPending = true;
|
||||
}
|
||||
bool takeConnectError(std::string &out)
|
||||
{
|
||||
if (!m_connectErrorPending)
|
||||
return false;
|
||||
out = m_connectError;
|
||||
m_connectError.clear();
|
||||
m_connectErrorPending = false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Wedge-debug mode for geometry inspection. */
|
||||
bool getDebugWedgeEnabled() const { return m_debugWedgeEnabled; }
|
||||
void setDebugWedgeEnabled(bool v);
|
||||
@@ -147,6 +165,17 @@ public:
|
||||
*/
|
||||
const Procedural::TriangleBuffer &getRoadTemplate(const RoadConfig &cfg);
|
||||
|
||||
/**
|
||||
* Sidewalk cross-section template (improvement plan §2.2), same
|
||||
* template-space conventions as the road template except the
|
||||
* profile Y spans [-sidewalkThickness, 0] (top at 0). Cached and
|
||||
* rebuilt only when cfg.sidewalkMeshTemplate or
|
||||
* cfg.sidewalkThickness changes; empty/missing mesh yields the
|
||||
* procedural box.
|
||||
*/
|
||||
const Procedural::TriangleBuffer &
|
||||
getSidewalkTemplate(const RoadConfig &cfg);
|
||||
|
||||
/**
|
||||
* Geometry generation (M5.6, ProceduralRoadGeometry.md).
|
||||
*
|
||||
@@ -220,14 +249,27 @@ public:
|
||||
*/
|
||||
void setTerrainSystem(class TerrainSystem *terrainSystem);
|
||||
|
||||
/**
|
||||
* Re-snap the Y of the given road nodes to
|
||||
* terrain_height + verticalOffset.
|
||||
*
|
||||
* Used after RoadGraph::connectNodes inserts page-boundary split
|
||||
* nodes (their Y is only interpolated between the endpoints).
|
||||
* No-op without a terrain system or for unknown node IDs.
|
||||
*/
|
||||
void snapNodesToTerrain(const std::vector<int> &nodeIds);
|
||||
|
||||
/**
|
||||
* Terrain compliance (M5.10): writes fixup values under every
|
||||
* road surface vertex so the terrain matches the road underside.
|
||||
* road surface vertex so the terrain matches the road underside,
|
||||
* then extends a linear fade over laneWidth * 2 perpendicular to
|
||||
* each curb so the terrain shoulders rise smoothly from the road
|
||||
* edge back to the natural height.
|
||||
*
|
||||
* @param terrainSystem the active TerrainSystem that owns the
|
||||
* fixup layer (used to call writeFixup + markPageDirty).
|
||||
* @param roadThickness vertical thickness of the road slab.
|
||||
* @param laneWidth falloff is measured in lane widths.
|
||||
* @param laneWidth lane width; the fade zone is laneWidth * 2.
|
||||
*/
|
||||
void complyTerrain(class TerrainSystem *terrainSystem,
|
||||
float roadThickness, float laneWidth);
|
||||
@@ -256,6 +298,46 @@ public:
|
||||
return m_pageGeometry;
|
||||
}
|
||||
|
||||
/**
|
||||
* Spawn state of one edge's prefab slots (improvement plan §3.2).
|
||||
*
|
||||
* Instances are spawned/despawned per edge (never per page, which
|
||||
* used to duplicate them) with a camera-distance hysteresis from
|
||||
* RoadConfig::prefabSpawnDistance/prefabDespawnDistance. The slot
|
||||
* copies detect data changes that require a respawn; @c tried
|
||||
* suppresses per-frame spawn retries after a failed attempt until
|
||||
* the data or conditions change.
|
||||
*/
|
||||
struct EdgePrefabRecord {
|
||||
flecs::entity left = flecs::entity::null();
|
||||
flecs::entity right = flecs::entity::null();
|
||||
flecs::entity mid = flecs::entity::null();
|
||||
RoadEdgePrefabSlot leftSlot;
|
||||
RoadEdgePrefabSlot rightSlot;
|
||||
RoadEdgePrefabSlot midSlot;
|
||||
bool tried = false;
|
||||
bool spawned = false;
|
||||
bool lastForce = false;
|
||||
};
|
||||
|
||||
/**
|
||||
* Key used for per-edge prefab records: the ordered node-id pair.
|
||||
* Public for headless tests.
|
||||
*/
|
||||
static uint64_t edgePrefabKey(int nodeA, int nodeB)
|
||||
{
|
||||
int lo = std::min(nodeA, nodeB);
|
||||
int hi = std::max(nodeA, nodeB);
|
||||
return (uint64_t)(uint32_t)lo << 32 | (uint32_t)hi;
|
||||
}
|
||||
|
||||
/** Per-edge prefab spawn state; exposed for headless tests. */
|
||||
const std::unordered_map<uint64_t, EdgePrefabRecord> &
|
||||
getEdgePrefabs() const
|
||||
{
|
||||
return m_edgePrefabs;
|
||||
}
|
||||
|
||||
private:
|
||||
void createManualObjects();
|
||||
void destroyManualObjects();
|
||||
@@ -285,8 +367,26 @@ private:
|
||||
void createPageCollider(RoadPageGeometry &pg, const std::string &meshName);
|
||||
void destroyPageCollider(RoadPageGeometry &pg);
|
||||
|
||||
/* Roadside prefab spawning (M5.11). */
|
||||
void spawnSidePrefabs(RoadPageGeometry &pg);
|
||||
/* Terrain compliance falloff (M5.10 step 4): writes the linear
|
||||
* fade zone perpendicular to one half-edge, from its curb out to
|
||||
* sideWidth + laneWidth*2. sideSign = +1 for the right side of the
|
||||
* half-edge direction, -1 for the left side. */
|
||||
void writeComplianceFalloff(class TerrainSystem *terrainSystem,
|
||||
const RoadGraph &graph,
|
||||
const RoadHalfEdge &halfEdge,
|
||||
const Ogre::Vector3 &nodePos,
|
||||
float sideSign, float sideWidth,
|
||||
float roadThickness, float laneWidth);
|
||||
|
||||
/* Road prefab spawning (improvement plan §3.2): per-edge records
|
||||
* with distance hysteresis and edit-mode force spawn. */
|
||||
void updateEdgePrefabs();
|
||||
void spawnEdgePrefab(const RoadGraph &rg, const RoadEdge &edge,
|
||||
const RoadEdgePrefabSlot &slot, int slotIndex,
|
||||
flecs::entity &out);
|
||||
void despawnEdgePrefabs(EdgePrefabRecord &rec);
|
||||
static bool prefabSlotEquals(const RoadEdgePrefabSlot &a,
|
||||
const RoadEdgePrefabSlot &b);
|
||||
|
||||
Ogre::Vector3 getNodePosition(int nodeId) const;
|
||||
bool getEdgePositions(int edgeIndex, Ogre::Vector3 &outA,
|
||||
@@ -301,6 +401,8 @@ private:
|
||||
int m_selectedNodeId = -1;
|
||||
int m_selectedEdgeIndex = -1;
|
||||
uint64_t m_lastGraphVersion = 0;
|
||||
std::string m_connectError;
|
||||
bool m_connectErrorPending = false;
|
||||
|
||||
/* Page geometry state (M5.4). m_wedgeBuckets holds the enumerated
|
||||
* wedges/segments bucketed by seed-node page for ALL pages (including
|
||||
@@ -309,6 +411,10 @@ private:
|
||||
std::unordered_map<uint64_t, RoadPageGeometry> m_wedgeBuckets;
|
||||
uint64_t m_geometryGraphVersion = 0;
|
||||
|
||||
/** Per-edge road prefab spawn state (improvement plan §3.2),
|
||||
* keyed by edgePrefabKey(nodeA, nodeB). */
|
||||
std::unordered_map<uint64_t, EdgePrefabRecord> m_edgePrefabs;
|
||||
|
||||
/** Shared road material entity (M5.8), created lazily. */
|
||||
flecs::entity m_materialEntity = flecs::entity::null();
|
||||
|
||||
@@ -326,6 +432,9 @@ private:
|
||||
Procedural::TriangleBuffer m_templateBuffer;
|
||||
std::string m_templateName;
|
||||
float m_templateThickness = -1.0f;
|
||||
Procedural::TriangleBuffer m_sidewalkTemplateBuffer;
|
||||
std::string m_sidewalkTemplateName;
|
||||
float m_sidewalkTemplateThickness = -1.0f;
|
||||
|
||||
/* Wedge-debug visualization (ManualObject). */
|
||||
bool m_debugWedgeEnabled = false;
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
#include "../components/TriangleBuffer.hpp"
|
||||
#include "../components/Character.hpp"
|
||||
#include "../components/CharacterSpawner.hpp"
|
||||
#include "../components/TerrainPrefabSpawner.hpp"
|
||||
#include "../components/CharacterSlots.hpp"
|
||||
#include "../components/CharacterIdentity.hpp"
|
||||
#include "CharacterRegistry.hpp"
|
||||
@@ -262,6 +263,11 @@ nlohmann::json SceneSerializer::serializeEntity(flecs::entity entity)
|
||||
json["characterSpawner"] = serializeCharacterSpawner(entity);
|
||||
}
|
||||
|
||||
if (entity.has<TerrainPrefabSpawnerComponent>()) {
|
||||
json["terrainPrefabSpawner"] =
|
||||
serializeTerrainPrefabSpawner(entity);
|
||||
}
|
||||
|
||||
if (entity.has<AnimationTreeComponent>()) {
|
||||
json["animationTree"] = serializeAnimationTree(entity);
|
||||
}
|
||||
@@ -491,6 +497,11 @@ void SceneSerializer::deserializeEntity(const nlohmann::json &json,
|
||||
deserializeCharacterSpawner(entity, json["characterSpawner"]);
|
||||
}
|
||||
|
||||
if (json.contains("terrainPrefabSpawner")) {
|
||||
deserializeTerrainPrefabSpawner(entity,
|
||||
json["terrainPrefabSpawner"]);
|
||||
}
|
||||
|
||||
if (json.contains("animationTree")) {
|
||||
deserializeAnimationTree(entity, json["animationTree"]);
|
||||
}
|
||||
@@ -728,6 +739,11 @@ void SceneSerializer::deserializeEntityComponents(
|
||||
deserializeCharacterSpawner(entity, json["characterSpawner"]);
|
||||
}
|
||||
|
||||
if (json.contains("terrainPrefabSpawner")) {
|
||||
deserializeTerrainPrefabSpawner(entity,
|
||||
json["terrainPrefabSpawner"]);
|
||||
}
|
||||
|
||||
if (json.contains("animationTree")) {
|
||||
deserializeAnimationTree(entity, json["animationTree"]);
|
||||
}
|
||||
@@ -2292,6 +2308,29 @@ void SceneSerializer::deserializeCharacterSpawner(flecs::entity entity,
|
||||
entity.set<CharacterSpawnerComponent>(spawner);
|
||||
}
|
||||
|
||||
nlohmann::json
|
||||
SceneSerializer::serializeTerrainPrefabSpawner(flecs::entity entity)
|
||||
{
|
||||
auto &spawner = entity.get<TerrainPrefabSpawnerComponent>();
|
||||
nlohmann::json json;
|
||||
json["prefabPath"] = spawner.prefabPath;
|
||||
json["spawnDistance"] = std::sqrt(spawner.spawnDistanceSq);
|
||||
json["despawnDistance"] = std::sqrt(spawner.despawnDistanceSq);
|
||||
return json;
|
||||
}
|
||||
|
||||
void SceneSerializer::deserializeTerrainPrefabSpawner(
|
||||
flecs::entity entity, const nlohmann::json &json)
|
||||
{
|
||||
TerrainPrefabSpawnerComponent spawner;
|
||||
spawner.prefabPath = json.value("prefabPath", std::string());
|
||||
float spawnDist = json.value("spawnDistance", 100.0f);
|
||||
float despawnDist = json.value("despawnDistance", 200.0f);
|
||||
spawner.spawnDistanceSq = spawnDist * spawnDist;
|
||||
spawner.despawnDistanceSq = despawnDist * despawnDist;
|
||||
entity.set<TerrainPrefabSpawnerComponent>(spawner);
|
||||
}
|
||||
|
||||
nlohmann::json SceneSerializer::serializeCharacterSlots(flecs::entity entity)
|
||||
{
|
||||
/* CharacterSlotsComponent is deprecated and no longer saved. */
|
||||
@@ -4200,6 +4239,17 @@ nlohmann::json SceneSerializer::serializeTerrain(flecs::entity entity)
|
||||
roadConfigJson["roadVisibilityDistance"] =
|
||||
tc.roadGraph.config.roadVisibilityDistance;
|
||||
roadConfigJson["roadMaterialName"] = tc.roadGraph.config.roadMaterialName;
|
||||
roadConfigJson["sidewalkEnabled"] = tc.roadGraph.config.sidewalkEnabled;
|
||||
roadConfigJson["sidewalkWidth"] = tc.roadGraph.config.sidewalkWidth;
|
||||
roadConfigJson["sidewalkHeight"] = tc.roadGraph.config.sidewalkHeight;
|
||||
roadConfigJson["sidewalkThickness"] =
|
||||
tc.roadGraph.config.sidewalkThickness;
|
||||
roadConfigJson["sidewalkMeshTemplate"] =
|
||||
tc.roadGraph.config.sidewalkMeshTemplate;
|
||||
roadConfigJson["prefabSpawnDistance"] =
|
||||
tc.roadGraph.config.prefabSpawnDistance;
|
||||
roadConfigJson["prefabDespawnDistance"] =
|
||||
tc.roadGraph.config.prefabDespawnDistance;
|
||||
json["roadConfig"] = roadConfigJson;
|
||||
|
||||
nlohmann::json roadNodesJson = nlohmann::json::array();
|
||||
@@ -4223,16 +4273,17 @@ nlohmann::json SceneSerializer::serializeTerrain(flecs::entity entity)
|
||||
ej["lanesAtoB"] = e.lanesAtoB;
|
||||
ej["lanesBtoA"] = e.lanesBtoA;
|
||||
|
||||
nlohmann::json sidePrefabsJson = nlohmann::json::array();
|
||||
for (auto &sp : e.sidePrefabs) {
|
||||
nlohmann::json spj;
|
||||
spj["prefabPath"] = sp.prefabPath;
|
||||
spj["edgeT"] = sp.edgeT;
|
||||
spj["sideOffset"] = sp.sideOffset;
|
||||
spj["leftSide"] = sp.leftSide;
|
||||
sidePrefabsJson.push_back(spj);
|
||||
}
|
||||
ej["sidePrefabs"] = sidePrefabsJson;
|
||||
auto writeSlot = [](const RoadEdgePrefabSlot &slot) {
|
||||
nlohmann::json sj;
|
||||
sj["prefabPath"] = slot.prefabPath;
|
||||
sj["edgeT"] = slot.edgeT;
|
||||
sj["lateralOffset"] = slot.lateralOffset;
|
||||
sj["yOffset"] = slot.yOffset;
|
||||
return sj;
|
||||
};
|
||||
ej["prefabLeft"] = writeSlot(e.prefabLeft);
|
||||
ej["prefabRight"] = writeSlot(e.prefabRight);
|
||||
ej["prefabMid"] = writeSlot(e.prefabMid);
|
||||
roadEdgesJson.push_back(ej);
|
||||
}
|
||||
json["roadEdges"] = roadEdgesJson;
|
||||
@@ -4312,6 +4363,20 @@ void SceneSerializer::deserializeTerrain(flecs::entity entity,
|
||||
rcj.value("roadVisibilityDistance", 1000.0f);
|
||||
tc.roadGraph.config.roadMaterialName =
|
||||
rcj.value("roadMaterialName", "RoadMaterial");
|
||||
tc.roadGraph.config.sidewalkEnabled =
|
||||
rcj.value("sidewalkEnabled", false);
|
||||
tc.roadGraph.config.sidewalkWidth =
|
||||
rcj.value("sidewalkWidth", 1.5f);
|
||||
tc.roadGraph.config.sidewalkHeight =
|
||||
rcj.value("sidewalkHeight", 0.15f);
|
||||
tc.roadGraph.config.sidewalkThickness =
|
||||
rcj.value("sidewalkThickness", 0.3f);
|
||||
tc.roadGraph.config.sidewalkMeshTemplate =
|
||||
rcj.value("sidewalkMeshTemplate", "");
|
||||
tc.roadGraph.config.prefabSpawnDistance =
|
||||
rcj.value("prefabSpawnDistance", 150.0f);
|
||||
tc.roadGraph.config.prefabDespawnDistance =
|
||||
rcj.value("prefabDespawnDistance", 250.0f);
|
||||
}
|
||||
|
||||
if (json.contains("roadNodes") && json["roadNodes"].is_array()) {
|
||||
@@ -4341,15 +4406,58 @@ void SceneSerializer::deserializeTerrain(flecs::entity entity,
|
||||
edge.lanesAtoB = ej.value("lanesAtoB", 0);
|
||||
edge.lanesBtoA = ej.value("lanesBtoA", 0);
|
||||
|
||||
auto readSlot = [&ej](const char *key,
|
||||
RoadEdgePrefabSlot &slot) {
|
||||
if (!ej.contains(key))
|
||||
return;
|
||||
const auto &sj = ej[key];
|
||||
slot.prefabPath = sj.value("prefabPath", "");
|
||||
slot.edgeT = sj.value("edgeT", 0.5f);
|
||||
slot.lateralOffset =
|
||||
sj.value("lateralOffset", 0.0f);
|
||||
slot.yOffset = sj.value("yOffset", 0.0f);
|
||||
};
|
||||
readSlot("prefabLeft", edge.prefabLeft);
|
||||
readSlot("prefabRight", edge.prefabRight);
|
||||
readSlot("prefabMid", edge.prefabMid);
|
||||
|
||||
/* Migration: legacy sidePrefabs array -> fixed slots.
|
||||
* The first left-side entry becomes prefabLeft, the first
|
||||
* right-side entry prefabRight; sideOffset (absolute
|
||||
* distance from the centerline) is remapped to
|
||||
* lateralOffset relative to the curb. Extra entries
|
||||
* are dropped with a log warning. */
|
||||
if (ej.contains("sidePrefabs") &&
|
||||
ej["sidePrefabs"].is_array()) {
|
||||
int lanesFrom = 0, lanesTo = 0;
|
||||
tc.roadGraph.resolveLaneCounts(
|
||||
edge, edge.nodeA, lanesFrom, lanesTo);
|
||||
float lw = tc.roadGraph.config.laneWidth;
|
||||
for (auto &spj : ej["sidePrefabs"]) {
|
||||
RoadEdge::RoadSidePrefab sp;
|
||||
sp.prefabPath = spj.value("prefabPath", "");
|
||||
sp.edgeT = spj.value("edgeT", 0.5f);
|
||||
sp.sideOffset = spj.value("sideOffset", 5.0f);
|
||||
sp.leftSide = spj.value("leftSide", true);
|
||||
edge.sidePrefabs.push_back(sp);
|
||||
bool leftSide =
|
||||
spj.value("leftSide", true);
|
||||
RoadEdgePrefabSlot &slot =
|
||||
leftSide ? edge.prefabLeft :
|
||||
edge.prefabRight;
|
||||
if (!slot.prefabPath.empty()) {
|
||||
Ogre::LogManager::getSingleton()
|
||||
.logMessage(
|
||||
"SceneSerializer: dropping extra legacy road side prefab \"" +
|
||||
spj.value("prefabPath",
|
||||
std::string("")) +
|
||||
"\" during slot migration");
|
||||
continue;
|
||||
}
|
||||
slot.prefabPath =
|
||||
spj.value("prefabPath", "");
|
||||
slot.edgeT = spj.value("edgeT", 0.5f);
|
||||
float sideOffset =
|
||||
spj.value("sideOffset", 5.0f);
|
||||
float halfWidth =
|
||||
(leftSide ? lanesTo : lanesFrom) *
|
||||
lw;
|
||||
slot.lateralOffset =
|
||||
sideOffset - halfWidth;
|
||||
}
|
||||
}
|
||||
tc.roadGraph.edges.push_back(edge);
|
||||
|
||||
@@ -210,6 +210,12 @@ public:
|
||||
nlohmann::json serializeTerrain(flecs::entity entity);
|
||||
void deserializeTerrain(flecs::entity entity, const nlohmann::json &json);
|
||||
|
||||
// Terrain prefab spawner serialization (public for the same
|
||||
// round-trip tests; distances are plain, not squared, in JSON).
|
||||
nlohmann::json serializeTerrainPrefabSpawner(flecs::entity entity);
|
||||
void deserializeTerrainPrefabSpawner(flecs::entity entity,
|
||||
const nlohmann::json &json);
|
||||
|
||||
private:
|
||||
// WaterPlane serialization
|
||||
nlohmann::json serializeWaterPlane(flecs::entity entity);
|
||||
|
||||
@@ -0,0 +1,523 @@
|
||||
#include "TerrainPrefabSpawnerSystem.hpp"
|
||||
#include "PrefabSystem.hpp"
|
||||
#include "CameraSystem.hpp"
|
||||
#include "EditorUISystem.hpp"
|
||||
#include "PhysicsSystem.hpp"
|
||||
#include "TerrainSystem.hpp"
|
||||
#include "../components/TerrainPrefabSpawner.hpp"
|
||||
#include "../components/Transform.hpp"
|
||||
#include "../components/EntityName.hpp"
|
||||
#include "../components/EditorMarker.hpp"
|
||||
#include "../components/Renderable.hpp"
|
||||
#include "../components/RigidBody.hpp"
|
||||
#include <OgreCamera.h>
|
||||
#include <OgreLogManager.h>
|
||||
#include <cmath>
|
||||
#include <vector>
|
||||
|
||||
TerrainPrefabSpawnerSystem *TerrainPrefabSpawnerSystem::s_instance = nullptr;
|
||||
|
||||
TerrainPrefabSpawnerSystem::TerrainPrefabSpawnerSystem(
|
||||
flecs::world &world, Ogre::SceneManager *sceneMgr,
|
||||
EditorCameraSystem *cameraSystem, EditorUISystem *uiSystem,
|
||||
EditorPhysicsSystem *physicsSystem)
|
||||
: m_world(world)
|
||||
, m_sceneMgr(sceneMgr)
|
||||
, m_cameraSystem(cameraSystem)
|
||||
, m_uiSystem(uiSystem)
|
||||
, m_physicsSystem(physicsSystem)
|
||||
, m_query(world.query<TerrainPrefabSpawnerComponent,
|
||||
TransformComponent>())
|
||||
{
|
||||
s_instance = this;
|
||||
|
||||
m_world.observer<TerrainPrefabSpawnerComponent>(
|
||||
"TerrainPrefabSpawnerCleanup")
|
||||
.event(flecs::OnRemove)
|
||||
.each([this](flecs::entity e, TerrainPrefabSpawnerComponent &) {
|
||||
despawn(e);
|
||||
});
|
||||
}
|
||||
|
||||
TerrainPrefabSpawnerSystem::~TerrainPrefabSpawnerSystem()
|
||||
{
|
||||
for (auto &pair : m_spawners) {
|
||||
if (pair.second.spawned.is_alive())
|
||||
destroyInstanceRecursive(pair.second.spawned);
|
||||
}
|
||||
m_spawners.clear();
|
||||
|
||||
if (s_instance == this)
|
||||
s_instance = nullptr;
|
||||
}
|
||||
|
||||
void TerrainPrefabSpawnerSystem::initialize()
|
||||
{
|
||||
if (m_initialized)
|
||||
return;
|
||||
m_initialized = true;
|
||||
|
||||
Ogre::LogManager::getSingleton().logMessage(
|
||||
"TerrainPrefabSpawnerSystem initialized");
|
||||
}
|
||||
|
||||
Ogre::Vector3 TerrainPrefabSpawnerSystem::getCameraPosition() const
|
||||
{
|
||||
if (m_cameraSystem) {
|
||||
Ogre::Camera *cam = m_cameraSystem->getActiveCamera();
|
||||
if (cam)
|
||||
return cam->getDerivedPosition();
|
||||
}
|
||||
return Ogre::Vector3::ZERO;
|
||||
}
|
||||
|
||||
flecs::entity
|
||||
TerrainPrefabSpawnerSystem::getSpawnedEntity(flecs::entity spawnerEntity) const
|
||||
{
|
||||
auto it = m_spawners.find(spawnerEntity.id());
|
||||
if (it != m_spawners.end() && it->second.spawned.is_alive())
|
||||
return it->second.spawned;
|
||||
return flecs::entity::null();
|
||||
}
|
||||
|
||||
bool TerrainPrefabSpawnerSystem::snapToTerrain(flecs::entity spawnerEntity)
|
||||
{
|
||||
TerrainSystem *ts = TerrainSystem::getInstance();
|
||||
if (!ts || !ts->isActive())
|
||||
return false;
|
||||
if (!spawnerEntity.is_alive() ||
|
||||
!spawnerEntity.has<TerrainPrefabSpawnerComponent>() ||
|
||||
!spawnerEntity.has<TransformComponent>())
|
||||
return false;
|
||||
|
||||
auto &transform = spawnerEntity.get_mut<TransformComponent>();
|
||||
if (!transform.node)
|
||||
return false;
|
||||
|
||||
Ogre::Vector3 worldPos = transform.node->_getDerivedPosition();
|
||||
worldPos.y = ts->getHeightAt(worldPos);
|
||||
|
||||
Ogre::Node *parent = transform.node->getParent();
|
||||
if (parent)
|
||||
transform.node->setPosition(
|
||||
parent->convertWorldToLocalPosition(worldPos));
|
||||
else
|
||||
transform.node->setPosition(worldPos);
|
||||
transform.position = transform.node->getPosition();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
void TerrainPrefabSpawnerSystem::applySpawnerTransform(
|
||||
flecs::entity spawnerEntity, flecs::entity instance)
|
||||
{
|
||||
if (!spawnerEntity.is_alive() || !instance.is_alive())
|
||||
return;
|
||||
if (!spawnerEntity.has<TransformComponent>() ||
|
||||
!instance.has<TransformComponent>())
|
||||
return;
|
||||
|
||||
const auto &spawnerTransform =
|
||||
spawnerEntity.get<TransformComponent>();
|
||||
auto &instTransform = instance.get_mut<TransformComponent>();
|
||||
|
||||
instTransform.position = spawnerTransform.position;
|
||||
instTransform.rotation = spawnerTransform.rotation;
|
||||
instTransform.scale = spawnerTransform.scale;
|
||||
if (instTransform.node && spawnerTransform.node) {
|
||||
instTransform.node->setPosition(
|
||||
spawnerTransform.node->getPosition());
|
||||
instTransform.node->setOrientation(
|
||||
spawnerTransform.node->getOrientation());
|
||||
instTransform.node->setScale(
|
||||
spawnerTransform.node->getScale());
|
||||
}
|
||||
instTransform.applyToNode();
|
||||
}
|
||||
|
||||
void TerrainPrefabSpawnerSystem::spawnPrefab(
|
||||
flecs::entity spawnerEntity, const TerrainPrefabSpawnerComponent &spawner,
|
||||
const TransformComponent &transform)
|
||||
{
|
||||
if (spawner.prefabPath.empty() || !spawnerEntity.is_alive() ||
|
||||
!transform.node)
|
||||
return;
|
||||
|
||||
/* Make sure any previous instance is gone first. */
|
||||
despawn(spawnerEntity);
|
||||
|
||||
/* Snap the spawner to the terrain surface before instantiating so
|
||||
* the prefab sits flush (section 6.4). */
|
||||
snapToTerrain(spawnerEntity);
|
||||
const Ogre::Vector3 pos = transform.node->_getDerivedPosition();
|
||||
|
||||
PrefabSystem prefabSys(m_world, m_sceneMgr);
|
||||
const std::string name =
|
||||
"terrain_prefab_" + std::to_string(spawnerEntity.id());
|
||||
flecs::entity inst = prefabSys.createInstance(
|
||||
spawner.prefabPath, flecs::entity::null(), pos, name);
|
||||
if (!inst.is_alive()) {
|
||||
Ogre::LogManager::getSingleton().logMessage(
|
||||
"TerrainPrefabSpawnerSystem: failed to spawn prefab '" +
|
||||
spawner.prefabPath + "'");
|
||||
return;
|
||||
}
|
||||
|
||||
/* Runtime-only: keep the instance out of the scene file and the
|
||||
* editor entity lists (same treatment as roadside prefabs). */
|
||||
if (inst.has<EditorMarkerComponent>())
|
||||
inst.remove<EditorMarkerComponent>();
|
||||
if (m_uiSystem)
|
||||
m_uiSystem->removeEntity(inst);
|
||||
|
||||
spawnerEntity.get_mut<TerrainPrefabSpawnerComponent>().spawnedEntity =
|
||||
inst.id();
|
||||
m_spawners[spawnerEntity.id()].spawned = inst;
|
||||
|
||||
Ogre::LogManager::getSingleton().logMessage(
|
||||
"TerrainPrefabSpawnerSystem: spawned '" + spawner.prefabPath +
|
||||
"' for spawner " + std::to_string(spawnerEntity.id()));
|
||||
}
|
||||
|
||||
void TerrainPrefabSpawnerSystem::destroyInstanceRecursive(flecs::entity entity)
|
||||
{
|
||||
/* Shared recursive cleanup (scene nodes, Ogre entities, Jolt
|
||||
* bodies, UI caches) lives in PrefabSystem (improvement plan §3.2). */
|
||||
PrefabSystem prefabSys(m_world, m_sceneMgr);
|
||||
prefabSys.destroyInstance(
|
||||
entity,
|
||||
m_physicsSystem ? m_physicsSystem->getPhysicsWrapper() :
|
||||
nullptr,
|
||||
m_uiSystem);
|
||||
}
|
||||
|
||||
void TerrainPrefabSpawnerSystem::despawn(flecs::entity spawnerEntity)
|
||||
{
|
||||
auto it = m_spawners.find(spawnerEntity.id());
|
||||
flecs::entity inst = flecs::entity::null();
|
||||
if (it != m_spawners.end()) {
|
||||
inst = it->second.spawned;
|
||||
m_spawners.erase(it);
|
||||
}
|
||||
|
||||
if (spawnerEntity.is_alive() &&
|
||||
spawnerEntity.has<TerrainPrefabSpawnerComponent>())
|
||||
spawnerEntity.get_mut<TerrainPrefabSpawnerComponent>()
|
||||
.spawnedEntity = 0;
|
||||
|
||||
if (inst.is_alive()) {
|
||||
destroyInstanceRecursive(inst);
|
||||
Ogre::LogManager::getSingleton().logMessage(
|
||||
"TerrainPrefabSpawnerSystem: despawned prefab for "
|
||||
"spawner " +
|
||||
std::to_string(spawnerEntity.id()));
|
||||
}
|
||||
}
|
||||
|
||||
void TerrainPrefabSpawnerSystem::spawn(flecs::entity spawnerEntity)
|
||||
{
|
||||
if (!spawnerEntity.is_alive() ||
|
||||
!spawnerEntity.has<TerrainPrefabSpawnerComponent>() ||
|
||||
!spawnerEntity.has<TransformComponent>())
|
||||
return;
|
||||
spawnPrefab(spawnerEntity,
|
||||
spawnerEntity.get<TerrainPrefabSpawnerComponent>(),
|
||||
spawnerEntity.get<TransformComponent>());
|
||||
}
|
||||
|
||||
void TerrainPrefabSpawnerSystem::update()
|
||||
{
|
||||
if (!m_initialized)
|
||||
return;
|
||||
|
||||
const Ogre::Vector3 cameraPos = getCameraPosition();
|
||||
|
||||
m_query.each([&](flecs::entity e,
|
||||
TerrainPrefabSpawnerComponent &spawner,
|
||||
TransformComponent &transform) {
|
||||
if (!transform.node)
|
||||
return;
|
||||
|
||||
auto it = m_spawners.find(e.id());
|
||||
const bool hasRec = it != m_spawners.end();
|
||||
SpawnerRecord snapshot;
|
||||
if (hasRec)
|
||||
snapshot = it->second;
|
||||
|
||||
Ogre::Vector3 pos = transform.node->_getDerivedPosition();
|
||||
|
||||
const bool spawnerMoved =
|
||||
!hasRec || !snapshot.evaluated ||
|
||||
(pos - snapshot.lastSpawnerPos).squaredLength() > 1e-6f;
|
||||
const bool cameraMoved =
|
||||
!hasRec || !snapshot.evaluated ||
|
||||
(cameraPos - snapshot.camPosAtEval).squaredLength() >
|
||||
CAM_REEVAL_DIST_SQ;
|
||||
const bool paramsChanged =
|
||||
hasRec && snapshot.evaluated &&
|
||||
(snapshot.spawnDistanceSq != spawner.spawnDistanceSq ||
|
||||
snapshot.despawnDistanceSq != spawner.despawnDistanceSq ||
|
||||
snapshot.prefabPath != spawner.prefabPath);
|
||||
const bool spawnedAlive = hasRec && snapshot.spawned.is_alive();
|
||||
|
||||
/* Skip the distance re-evaluation while nothing relevant
|
||||
* changed (camera hysteresis, section 6.3 item 4). A record
|
||||
* claiming a spawn whose entity died externally always
|
||||
* re-evaluates. */
|
||||
if (hasRec && snapshot.evaluated && !spawnerMoved &&
|
||||
!cameraMoved && !paramsChanged &&
|
||||
(snapshot.spawned == flecs::entity::null() || spawnedAlive))
|
||||
return;
|
||||
|
||||
/* The spawner was moved in the editor: re-snap Y on (x, z)
|
||||
* changes and keep the spawned instance glued to it. */
|
||||
if (hasRec && snapshot.evaluated && spawnerMoved) {
|
||||
if (std::fabs(pos.x - snapshot.lastSpawnerPos.x) >
|
||||
1e-4f ||
|
||||
std::fabs(pos.z - snapshot.lastSpawnerPos.z) >
|
||||
1e-4f) {
|
||||
if (snapToTerrain(e))
|
||||
pos = transform.node
|
||||
->_getDerivedPosition();
|
||||
}
|
||||
if (spawnedAlive)
|
||||
applySpawnerTransform(e, snapshot.spawned);
|
||||
}
|
||||
|
||||
const float distSq = (pos - cameraPos).squaredLength();
|
||||
|
||||
if (spawnedAlive && paramsChanged &&
|
||||
snapshot.prefabPath != spawner.prefabPath) {
|
||||
/* Prefab asset changed: respawn to swap it. */
|
||||
despawn(e);
|
||||
spawnPrefab(e, spawner, transform);
|
||||
} else if (!spawnedAlive && !spawner.prefabPath.empty() &&
|
||||
distSq <= spawner.spawnDistanceSq) {
|
||||
spawnPrefab(e, spawner, transform);
|
||||
} else if (spawnedAlive && distSq > spawner.despawnDistanceSq) {
|
||||
despawn(e);
|
||||
}
|
||||
|
||||
/* Refresh the record (despawn() may have erased it). */
|
||||
SpawnerRecord &rec = m_spawners[e.id()];
|
||||
rec.evaluated = true;
|
||||
rec.lastSpawnerPos = pos;
|
||||
rec.camPosAtEval = cameraPos;
|
||||
rec.spawnDistanceSq = spawner.spawnDistanceSq;
|
||||
rec.despawnDistanceSq = spawner.despawnDistanceSq;
|
||||
rec.prefabPath = spawner.prefabPath;
|
||||
rec.spawned = spawner.spawnedEntity != 0 ?
|
||||
m_world.entity(spawner.spawnedEntity) :
|
||||
flecs::entity::null();
|
||||
});
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Terrain compliance tool (section 6.5) */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
static void mergeInstanceWorldAABB(flecs::entity entity,
|
||||
Ogre::AxisAlignedBox &box)
|
||||
{
|
||||
if (entity.has<RenderableComponent>()) {
|
||||
const auto &renderable = entity.get<RenderableComponent>();
|
||||
if (renderable.entity)
|
||||
box.merge(renderable.entity->getWorldBoundingBox(true));
|
||||
}
|
||||
entity.children(
|
||||
[&box](flecs::entity child) {
|
||||
mergeInstanceWorldAABB(child, box);
|
||||
});
|
||||
}
|
||||
|
||||
bool TerrainPrefabSpawnerSystem::complyTerrainToPrefab(
|
||||
flecs::entity spawnerEntity)
|
||||
{
|
||||
TerrainSystem *ts = TerrainSystem::getInstance();
|
||||
if (!ts || !ts->isActive()) {
|
||||
Ogre::LogManager::getSingleton().logMessage(
|
||||
"TerrainPrefabSpawnerSystem: comply failed - no active terrain");
|
||||
return false;
|
||||
}
|
||||
if (!spawnerEntity.is_alive() ||
|
||||
!spawnerEntity.has<TerrainPrefabSpawnerComponent>() ||
|
||||
!spawnerEntity.has<TransformComponent>())
|
||||
return false;
|
||||
|
||||
/* Make sure the instance exists so the real footprint can be
|
||||
* measured. */
|
||||
flecs::entity inst = getSpawnedEntity(spawnerEntity);
|
||||
if (!inst.is_alive()) {
|
||||
spawn(spawnerEntity);
|
||||
inst = getSpawnedEntity(spawnerEntity);
|
||||
}
|
||||
|
||||
const auto &transform = spawnerEntity.get<TransformComponent>();
|
||||
const Ogre::Vector3 center = transform.node ?
|
||||
transform.node->_getDerivedPosition() :
|
||||
transform.position;
|
||||
|
||||
/* Flatten target: the prefab base height (spawner Y is snapped to
|
||||
* the terrain surface at placement/spawn time). */
|
||||
const float targetY = center.y;
|
||||
|
||||
Ogre::AxisAlignedBox box;
|
||||
if (inst.is_alive())
|
||||
mergeInstanceWorldAABB(inst, box);
|
||||
if (box.isNull()) {
|
||||
/* No measurable geometry (prefab not instantiated yet or
|
||||
* empty): fall back to a small pad around the base point. */
|
||||
box = Ogre::AxisAlignedBox(center - Ogre::Vector3(1, 0, 1),
|
||||
center + Ogre::Vector3(1, 0, 1));
|
||||
}
|
||||
|
||||
float texel = ts->getFixupTexelSize();
|
||||
if (texel <= 1e-6f)
|
||||
texel = 1.0f;
|
||||
/* Fade margin: at least two fixup sample rings so the coarse chunk
|
||||
* grid always shows a transition next to the flattened pad. */
|
||||
const float margin = std::max(2.0f, texel * 2.0f);
|
||||
|
||||
const float minX = box.getMinimum().x;
|
||||
const float maxX = box.getMaximum().x;
|
||||
const float minZ = box.getMinimum().z;
|
||||
const float maxZ = box.getMaximum().z;
|
||||
|
||||
/* Pass 1 — falloff band around the footprint (written first so the
|
||||
* full-flatten pass wins on shared chunk cells, same ordering as
|
||||
* road compliance M5.10). */
|
||||
for (float x = minX - margin; x <= maxX + margin; x += texel) {
|
||||
for (float z = minZ - margin; z <= maxZ + margin; z += texel) {
|
||||
const float dx =
|
||||
std::max(std::max(minX - x, 0.0f), x - maxX);
|
||||
const float dz =
|
||||
std::max(std::max(minZ - z, 0.0f), z - maxZ);
|
||||
const float d = std::sqrt(dx * dx + dz * dz);
|
||||
if (d <= 1e-4f || d > margin)
|
||||
continue;
|
||||
const float t = d / margin;
|
||||
const float base = ts->sampleBaseHeightAt(
|
||||
(long)std::floor(x), (long)std::floor(z));
|
||||
ts->writeFixupSample(
|
||||
x, z, targetY * (1.0f - t) + base * t);
|
||||
}
|
||||
}
|
||||
|
||||
/* Pass 2 — full flatten under the footprint. */
|
||||
for (float x = minX; x <= maxX + texel * 0.5f; x += texel)
|
||||
for (float z = minZ; z <= maxZ + texel * 0.5f; z += texel)
|
||||
ts->writeFixup(x, z, targetY);
|
||||
|
||||
/* Mark affected pages dirty so they resample with the fixups. */
|
||||
if (Ogre::TerrainGroup *group = ts->getTerrainGroup()) {
|
||||
long pxA, pyA, pxB, pyB;
|
||||
group->convertWorldPositionToTerrainSlot(
|
||||
Ogre::Vector3(minX - margin, 0, minZ - margin), &pxA,
|
||||
&pyA);
|
||||
group->convertWorldPositionToTerrainSlot(
|
||||
Ogre::Vector3(maxX + margin, 0, maxZ + margin), &pxB,
|
||||
&pyB);
|
||||
if (pxB < pxA)
|
||||
std::swap(pxA, pxB);
|
||||
if (pyB < pyA)
|
||||
std::swap(pyA, pyB);
|
||||
for (long px = pxA; px <= pxB; ++px)
|
||||
for (long py = pyA; py <= pyB; ++py)
|
||||
ts->markPageDirty(px, py);
|
||||
}
|
||||
|
||||
/* Persist fixups to disk. */
|
||||
ts->saveFixups();
|
||||
|
||||
Ogre::LogManager::getSingleton().logMessage(
|
||||
"TerrainPrefabSpawnerSystem: terrain compliance applied for "
|
||||
"spawner " +
|
||||
std::to_string(spawnerEntity.id()));
|
||||
return true;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Editor prefab spawn mode (section 7.2) */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
void TerrainPrefabSpawnerSystem::setSpawnEditMode(bool v)
|
||||
{
|
||||
if (v == m_spawnEditMode)
|
||||
return;
|
||||
m_spawnEditMode = v;
|
||||
if (v) {
|
||||
/* Mutual exclusion with the other 3D edit modes so
|
||||
* left-click placement does not fight the brushes. */
|
||||
TerrainSystem *ts = TerrainSystem::getInstance();
|
||||
if (ts) {
|
||||
if (ts->getSculptMode())
|
||||
ts->setSculptMode(false);
|
||||
if (ts->getPaintMode())
|
||||
ts->setPaintMode(false);
|
||||
if (ts->getAuxPaintMode())
|
||||
ts->setAuxPaintMode(false);
|
||||
if (ts->getRoadEditMode())
|
||||
ts->setRoadEditMode(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
flecs::entity
|
||||
TerrainPrefabSpawnerSystem::handleSpawnClick(const Ogre::Ray &mouseRay)
|
||||
{
|
||||
if (!m_spawnEditMode || m_placementPrefabPath.empty())
|
||||
return flecs::entity::null();
|
||||
|
||||
TerrainSystem *ts = TerrainSystem::getInstance();
|
||||
if (!ts || !ts->isActive())
|
||||
return flecs::entity::null();
|
||||
|
||||
float t;
|
||||
Ogre::Vector3 normal;
|
||||
if (!ts->raycastTerrain(mouseRay, t, normal))
|
||||
return flecs::entity::null();
|
||||
|
||||
return createSpawnPoint(m_placementPrefabPath, mouseRay.getPoint(t));
|
||||
}
|
||||
|
||||
flecs::entity TerrainPrefabSpawnerSystem::createSpawnPoint(
|
||||
const std::string &prefabPath, const Ogre::Vector3 &position)
|
||||
{
|
||||
if (prefabPath.empty())
|
||||
return flecs::entity::null();
|
||||
|
||||
static int s_spawnCounter = 0;
|
||||
|
||||
flecs::entity e = m_world.entity();
|
||||
e.set<EntityNameComponent>(EntityNameComponent(
|
||||
"PrefabSpawner_" + std::to_string(++s_spawnCounter)));
|
||||
e.add<EditorMarkerComponent>();
|
||||
|
||||
/* Snap to the terrain surface at placement time (section 6.4). */
|
||||
Ogre::Vector3 pos = position;
|
||||
TerrainSystem *ts = TerrainSystem::getInstance();
|
||||
if (ts && ts->isActive())
|
||||
pos.y = ts->getHeightAt(position);
|
||||
|
||||
TransformComponent xform;
|
||||
xform.node = m_sceneMgr->getRootSceneNode()->createChildSceneNode();
|
||||
xform.position = pos;
|
||||
xform.rotation = Ogre::Quaternion::IDENTITY;
|
||||
xform.scale = Ogre::Vector3::UNIT_SCALE;
|
||||
xform.applyToNode();
|
||||
e.set<TransformComponent>(xform);
|
||||
|
||||
TerrainPrefabSpawnerComponent spawner;
|
||||
spawner.prefabPath = prefabPath;
|
||||
e.set<TerrainPrefabSpawnerComponent>(spawner);
|
||||
|
||||
if (m_uiSystem)
|
||||
m_uiSystem->addEntity(e);
|
||||
|
||||
Ogre::LogManager::getSingleton().logMessage(
|
||||
"TerrainPrefabSpawnerSystem: created spawn point '" +
|
||||
prefabPath + "' at " +
|
||||
Ogre::StringConverter::toString(pos));
|
||||
|
||||
return e;
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
#ifndef EDITSCENE_TERRAINPREFABSPAWNERSYSTEM_HPP
|
||||
#define EDITSCENE_TERRAINPREFABSPAWNERSYSTEM_HPP
|
||||
#pragma once
|
||||
|
||||
#include <flecs.h>
|
||||
#include <Ogre.h>
|
||||
#include <string>
|
||||
#include <unordered_map>
|
||||
|
||||
class EditorCameraSystem;
|
||||
class EditorUISystem;
|
||||
class EditorPhysicsSystem;
|
||||
|
||||
/**
|
||||
* @brief Distance-based spawn/despawn of prefab instances on terrain
|
||||
* (TerrainRequirements.md Milestone 6).
|
||||
*
|
||||
* Queries entities with TerrainPrefabSpawnerComponent + TransformComponent.
|
||||
* Spawns the referenced prefab through PrefabSystem when the active camera
|
||||
* is within spawnDistanceSq, despawns it beyond despawnDistanceSq.
|
||||
*
|
||||
* Distance re-evaluation is skipped while neither the camera (10 unit
|
||||
* hysteresis), the spawner transform, nor the spawner parameters changed
|
||||
* since the last evaluation (section 6.3 item 4).
|
||||
*
|
||||
* The system also owns the editor "prefab spawn mode" (section 7.2):
|
||||
* while active, a left click on the terrain places a new spawner entity
|
||||
* snapped to the surface, and the terrain compliance tool (section 6.5)
|
||||
* flattens the terrain under a spawned prefab's footprint using fixup
|
||||
* chunks.
|
||||
*
|
||||
* Spawned instances are runtime-only: EditorMarkerComponent is stripped and
|
||||
* the instance is removed from the editor UI caches, so they are never
|
||||
* serialized and never appear in the editor outliner.
|
||||
*/
|
||||
class TerrainPrefabSpawnerSystem {
|
||||
public:
|
||||
TerrainPrefabSpawnerSystem(flecs::world &world,
|
||||
Ogre::SceneManager *sceneMgr,
|
||||
EditorCameraSystem *cameraSystem,
|
||||
EditorUISystem *uiSystem = nullptr,
|
||||
EditorPhysicsSystem *physicsSystem = nullptr);
|
||||
~TerrainPrefabSpawnerSystem();
|
||||
|
||||
TerrainPrefabSpawnerSystem(const TerrainPrefabSpawnerSystem &) = delete;
|
||||
TerrainPrefabSpawnerSystem &
|
||||
operator=(const TerrainPrefabSpawnerSystem &) = delete;
|
||||
|
||||
/* Singleton access for the terrain editor panel and the UI system's
|
||||
* mouse dispatch (same pattern as TerrainSystem::getInstance()). */
|
||||
static TerrainPrefabSpawnerSystem *getInstance()
|
||||
{
|
||||
return s_instance;
|
||||
}
|
||||
|
||||
void initialize();
|
||||
void update();
|
||||
|
||||
/**
|
||||
* Force an immediate spawn for the given spawner, regardless of
|
||||
* camera distance. Snaps the spawner's Y to the terrain surface.
|
||||
* Does nothing if the spawner is missing, invalid, or has an empty
|
||||
* prefab path.
|
||||
*/
|
||||
void spawn(flecs::entity spawnerEntity);
|
||||
|
||||
/**
|
||||
* Destroy the prefab instance associated with a spawner, if any.
|
||||
*/
|
||||
void despawn(flecs::entity spawnerEntity);
|
||||
|
||||
/**
|
||||
* Return the prefab instance entity currently spawned by a spawner,
|
||||
* or null if none is alive.
|
||||
*/
|
||||
flecs::entity getSpawnedEntity(flecs::entity spawnerEntity) const;
|
||||
|
||||
/**
|
||||
* Snap the spawner's TransformComponent Y to the terrain surface at
|
||||
* its (x, z) (section 6.4). Returns false when there is no active
|
||||
* terrain or the entity is not a valid spawner.
|
||||
*/
|
||||
bool snapToTerrain(flecs::entity spawnerEntity);
|
||||
|
||||
/**
|
||||
* Terrain compliance tool (section 6.5): flatten the terrain under
|
||||
* the spawned prefab's world AABB footprint so it sits flush.
|
||||
* Spawns the prefab first when it is not currently spawned. Writes
|
||||
* fixup chunk entries (full flatten under the footprint, linear fade
|
||||
* over a margin outside it), marks the affected pages dirty, and
|
||||
* saves the fixups. Returns false when the spawner/prefab is
|
||||
* invalid or no terrain is active.
|
||||
*/
|
||||
bool complyTerrainToPrefab(flecs::entity spawnerEntity);
|
||||
|
||||
/* --- Editor prefab spawn mode (section 7.2) --- */
|
||||
|
||||
bool getSpawnEditMode() const
|
||||
{
|
||||
return m_spawnEditMode;
|
||||
}
|
||||
/* Enabling spawn mode deactivates the terrain sculpt/paint/aux/road
|
||||
* modes so left-click placement does not fight the brushes. */
|
||||
void setSpawnEditMode(bool v);
|
||||
bool isSpawnEditing() const
|
||||
{
|
||||
return m_spawnEditMode;
|
||||
}
|
||||
|
||||
/* Prefab used by click-to-place (selected in the terrain editor). */
|
||||
const std::string &getPlacementPrefabPath() const
|
||||
{
|
||||
return m_placementPrefabPath;
|
||||
}
|
||||
void setPlacementPrefabPath(const std::string &path)
|
||||
{
|
||||
m_placementPrefabPath = path;
|
||||
}
|
||||
|
||||
/**
|
||||
* Click-to-place: raycast against the terrain and create a spawner
|
||||
* entity at the hit point (Y snapped to the surface) using the
|
||||
* current placement prefab. Returns the new entity, or null when
|
||||
* the ray missed or no placement prefab is selected.
|
||||
*/
|
||||
flecs::entity handleSpawnClick(const Ogre::Ray &mouseRay);
|
||||
|
||||
/**
|
||||
* Create a spawner entity at the given world position (Y snapped to
|
||||
* the terrain when active). The entity is regular scene content:
|
||||
* it carries EditorMarkerComponent and is serialized.
|
||||
*/
|
||||
flecs::entity createSpawnPoint(const std::string &prefabPath,
|
||||
const Ogre::Vector3 &position);
|
||||
|
||||
private:
|
||||
void spawnPrefab(flecs::entity spawnerEntity,
|
||||
const struct TerrainPrefabSpawnerComponent &spawner,
|
||||
const struct TransformComponent &transform);
|
||||
void destroyInstanceRecursive(flecs::entity entity);
|
||||
void applySpawnerTransform(flecs::entity spawnerEntity,
|
||||
flecs::entity instance);
|
||||
Ogre::Vector3 getCameraPosition() const;
|
||||
|
||||
flecs::world &m_world;
|
||||
Ogre::SceneManager *m_sceneMgr;
|
||||
EditorCameraSystem *m_cameraSystem;
|
||||
EditorUISystem *m_uiSystem;
|
||||
EditorPhysicsSystem *m_physicsSystem;
|
||||
flecs::query<struct TerrainPrefabSpawnerComponent,
|
||||
struct TransformComponent>
|
||||
m_query;
|
||||
bool m_initialized = false;
|
||||
|
||||
static TerrainPrefabSpawnerSystem *s_instance;
|
||||
|
||||
struct SpawnerRecord {
|
||||
/* Spawned instance; null entity when not spawned. */
|
||||
flecs::entity spawned = flecs::entity::null();
|
||||
/* State at last distance evaluation, for change detection. */
|
||||
bool evaluated = false;
|
||||
Ogre::Vector3 lastSpawnerPos = Ogre::Vector3::ZERO;
|
||||
Ogre::Vector3 camPosAtEval = Ogre::Vector3::ZERO;
|
||||
float spawnDistanceSq = -1.0f;
|
||||
float despawnDistanceSq = -1.0f;
|
||||
std::string prefabPath;
|
||||
};
|
||||
|
||||
std::unordered_map<flecs::entity_t, SpawnerRecord> m_spawners;
|
||||
|
||||
/* Editor spawn-placement mode state (runtime only). */
|
||||
bool m_spawnEditMode = false;
|
||||
std::string m_placementPrefabPath;
|
||||
|
||||
/* Camera hysteresis: re-evaluate a spawner's distance only when the
|
||||
* camera moved more than this many units since the last evaluation
|
||||
* of that spawner (squared: 10^2, section 6.3 item 4). */
|
||||
static constexpr float CAM_REEVAL_DIST_SQ = 10.0f * 10.0f;
|
||||
};
|
||||
|
||||
#endif // EDITSCENE_TERRAINPREFABSPAWNERSYSTEM_HPP
|
||||
@@ -530,11 +530,14 @@ TerrainSystem::findFixupChunkLocked(int chunkX, int chunkZ) const
|
||||
|
||||
float TerrainSystem::sampleFixupLocked(float worldX, float worldZ) const
|
||||
{
|
||||
/* Compute chunk coordinates. Chunk (0,0) covers world
|
||||
* (0,0)..(worldSize/256, worldSize/256) in both X and Z.
|
||||
* The chunk resolution FIXUP_CHUNK_RES is always 256 regardless
|
||||
* of heightmapSize — this gives a consistent grid for fixups. */
|
||||
float chunkWorldSize = m_heightmapWorldSize / (float)FIXUP_CHUNK_RES;
|
||||
/* Compute chunk coordinates. One chunk spans the full PER-PAGE
|
||||
* world size (TerrainComponent::worldSize, section 4.2), so chunk
|
||||
* indices coincide with page indices; the 256x256 samples of a chunk
|
||||
* give one sample per worldSize/256 units — the same density as the
|
||||
* base heightmap. FIXUP_CHUNK_RES is always 256 regardless of
|
||||
* heightmapSize — this gives a consistent grid for fixups. */
|
||||
float chunkWorldSize = m_pageWorldSize;
|
||||
float cellW = chunkWorldSize / (float)FIXUP_CHUNK_RES;
|
||||
int chunkX = (int)std::floor(worldX / chunkWorldSize);
|
||||
int chunkZ = (int)std::floor(worldZ / chunkWorldSize);
|
||||
|
||||
@@ -543,10 +546,8 @@ float TerrainSystem::sampleFixupLocked(float worldX, float worldZ) const
|
||||
return FIXUP_SENTINEL;
|
||||
|
||||
/* Bilinear sample within the chunk. */
|
||||
float cellX = (worldX - (float)chunkX * chunkWorldSize) /
|
||||
chunkWorldSize * (float)FIXUP_CHUNK_RES;
|
||||
float cellZ = (worldZ - (float)chunkZ * chunkWorldSize) /
|
||||
chunkWorldSize * (float)FIXUP_CHUNK_RES;
|
||||
float cellX = (worldX - (float)chunkX * chunkWorldSize) / cellW;
|
||||
float cellZ = (worldZ - (float)chunkZ * chunkWorldSize) / cellW;
|
||||
|
||||
int x0 = (int)std::floor(cellX);
|
||||
int z0 = (int)std::floor(cellZ);
|
||||
@@ -572,25 +573,69 @@ float TerrainSystem::sampleFixupLocked(float worldX, float worldZ) const
|
||||
h01 == FIXUP_SENTINEL && h11 == FIXUP_SENTINEL)
|
||||
return FIXUP_SENTINEL;
|
||||
|
||||
/* Replace sentinel with fallback so partial cells still blend. */
|
||||
/* Replace sentinel corners with the natural (base + noise) height
|
||||
* at the corner position so partially written cells blend toward
|
||||
* the untouched terrain instead of toward zero. */
|
||||
float originX = (float)chunkX * chunkWorldSize;
|
||||
float originZ = (float)chunkZ * chunkWorldSize;
|
||||
if (h00 == FIXUP_SENTINEL)
|
||||
h00 = 0.0f;
|
||||
h00 = sampleBaseLocked((long)std::floor(originX + x0 * cellW),
|
||||
(long)std::floor(originZ + z0 * cellW));
|
||||
if (h10 == FIXUP_SENTINEL)
|
||||
h10 = 0.0f;
|
||||
h10 = sampleBaseLocked((long)std::floor(originX + x1 * cellW),
|
||||
(long)std::floor(originZ + z0 * cellW));
|
||||
if (h01 == FIXUP_SENTINEL)
|
||||
h01 = 0.0f;
|
||||
h01 = sampleBaseLocked((long)std::floor(originX + x0 * cellW),
|
||||
(long)std::floor(originZ + z1 * cellW));
|
||||
if (h11 == FIXUP_SENTINEL)
|
||||
h11 = 0.0f;
|
||||
h11 = sampleBaseLocked((long)std::floor(originX + x1 * cellW),
|
||||
(long)std::floor(originZ + z1 * cellW));
|
||||
|
||||
return (1.0f - tx) * (1.0f - tz) * h00 + tx * (1.0f - tz) * h10 +
|
||||
(1.0f - tx) * tz * h01 + tx * tz * h11;
|
||||
}
|
||||
|
||||
float TerrainSystem::getFixupTexelSize() const
|
||||
{
|
||||
return m_pageWorldSize / (float)FIXUP_CHUNK_RES;
|
||||
}
|
||||
|
||||
void TerrainSystem::writeFixupSample(float worldX, float worldZ,
|
||||
float height)
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(m_heightmapMutex);
|
||||
|
||||
float chunkWorldSize = m_pageWorldSize;
|
||||
float cellW = chunkWorldSize / (float)FIXUP_CHUNK_RES;
|
||||
int chunkX = (int)std::floor(worldX / chunkWorldSize);
|
||||
int chunkZ = (int)std::floor(worldZ / chunkWorldSize);
|
||||
|
||||
/* Create or retrieve the chunk lazily. */
|
||||
auto &chunk = m_fixupChunks[{chunkX, chunkZ}];
|
||||
if (chunk.samples.empty()) {
|
||||
chunk.samples.resize(FIXUP_CHUNK_RES * FIXUP_CHUNK_RES,
|
||||
FIXUP_SENTINEL);
|
||||
}
|
||||
|
||||
int x0 = (int)std::floor((worldX - (float)chunkX * chunkWorldSize) /
|
||||
cellW);
|
||||
int z0 = (int)std::floor((worldZ - (float)chunkZ * chunkWorldSize) /
|
||||
cellW);
|
||||
|
||||
int r = FIXUP_CHUNK_RES;
|
||||
if (x0 < 0 || x0 >= r || z0 < 0 || z0 >= r)
|
||||
return;
|
||||
|
||||
chunk.samples[z0 * r + x0] = height;
|
||||
chunk.dirty = true;
|
||||
}
|
||||
|
||||
void TerrainSystem::writeFixup(float worldX, float worldZ, float height)
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(m_heightmapMutex);
|
||||
|
||||
float chunkWorldSize = m_heightmapWorldSize / (float)FIXUP_CHUNK_RES;
|
||||
float chunkWorldSize = m_pageWorldSize;
|
||||
float cellW = chunkWorldSize / (float)FIXUP_CHUNK_RES;
|
||||
int chunkX = (int)std::floor(worldX / chunkWorldSize);
|
||||
int chunkZ = (int)std::floor(worldZ / chunkWorldSize);
|
||||
|
||||
@@ -604,10 +649,8 @@ void TerrainSystem::writeFixup(float worldX, float worldZ, float height)
|
||||
/* Write to the four samples of the chunk cell containing
|
||||
* (worldX, worldZ) so bilinear sampling returns @p height
|
||||
* at the exact write position. */
|
||||
float cellX = (worldX - (float)chunkX * chunkWorldSize) /
|
||||
chunkWorldSize * (float)FIXUP_CHUNK_RES;
|
||||
float cellZ = (worldZ - (float)chunkZ * chunkWorldSize) /
|
||||
chunkWorldSize * (float)FIXUP_CHUNK_RES;
|
||||
float cellX = (worldX - (float)chunkX * chunkWorldSize) / cellW;
|
||||
float cellZ = (worldZ - (float)chunkZ * chunkWorldSize) / cellW;
|
||||
|
||||
int x0 = (int)std::floor(cellX);
|
||||
int z0 = (int)std::floor(cellZ);
|
||||
@@ -1717,7 +1760,13 @@ float TerrainSystem::sampleHeightAt(long worldX, long worldZ) const
|
||||
return sampleHeightAtLocked(worldX, worldZ);
|
||||
}
|
||||
|
||||
float TerrainSystem::sampleHeightAtLocked(long worldX, long worldZ) const
|
||||
float TerrainSystem::sampleBaseHeightAt(long worldX, long worldZ) const
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(m_heightmapMutex);
|
||||
return sampleBaseLocked(worldX, worldZ);
|
||||
}
|
||||
|
||||
float TerrainSystem::sampleBaseLocked(long worldX, long worldZ) const
|
||||
{
|
||||
if (!m_heightmapLoaded || m_heightData.empty())
|
||||
return proceduralHeight(worldX, worldZ);
|
||||
@@ -1752,6 +1801,13 @@ float TerrainSystem::sampleHeightAtLocked(long worldX, long worldZ) const
|
||||
if (m_detailNoise.enabled)
|
||||
h += computeDetailNoise(worldX, worldZ, m_detailNoise);
|
||||
|
||||
return h;
|
||||
}
|
||||
|
||||
float TerrainSystem::sampleHeightAtLocked(long worldX, long worldZ) const
|
||||
{
|
||||
float h = sampleBaseLocked(worldX, worldZ);
|
||||
|
||||
/* Fixup chunks override the combined base height + noise (M5.9.5).
|
||||
* If a fixup value exists at this position, use it instead. */
|
||||
float fixup = sampleFixupLocked((float)worldX, (float)worldZ);
|
||||
@@ -1891,6 +1947,7 @@ void TerrainSystem::activate(TerrainComponent &tc, TransformComponent &xform,
|
||||
m_heightmapWorldMinZ = (float)m_pageMinY * tc.worldSize;
|
||||
m_heightmapWorldSize =
|
||||
(float)(m_pageMaxX - m_pageMinX + 1) * tc.worldSize;
|
||||
m_pageWorldSize = tc.worldSize;
|
||||
|
||||
m_fixupDir = getFixupDir(tc);
|
||||
|
||||
|
||||
@@ -73,6 +73,14 @@ public:
|
||||
/* --- Heightmap data (M3) --- */
|
||||
float sampleHeightAt(long worldX, long worldZ) const;
|
||||
float sampleHeightAtLocked(long worldX, long worldZ) const;
|
||||
|
||||
/* Base terrain height (base heightmap + detail noise) at integer
|
||||
* world coordinates, ignoring the fixup layer. Used by road
|
||||
* compliance falloff (M5.10) so fade targets blend toward the
|
||||
* natural terrain height even where fixups were already written
|
||||
* nearby. */
|
||||
float sampleBaseHeightAt(long worldX, long worldZ) const;
|
||||
float sampleBaseLocked(long worldX, long worldZ) const;
|
||||
void setHeightAt(long worldX, long worldZ, float value);
|
||||
bool loadHeightmap(const std::string &path);
|
||||
bool saveHeightmap(const std::string &path);
|
||||
@@ -83,15 +91,33 @@ public:
|
||||
/* Fixup chunks (M5.9.5): sparse absolute-height overrides layered
|
||||
* on top of base heightmap + detail noise during sampling.
|
||||
*
|
||||
* Chunk grid: one chunk spans the full PER-PAGE world size
|
||||
* (TerrainComponent::worldSize) and holds 256x256 samples, so one
|
||||
* sample covers worldSize/256 units — the same density as the base
|
||||
* heightmap ("same as base heightmap but always 256x256",
|
||||
* TerrainRequirements.md section 4.2). Chunk indices coincide with
|
||||
* page indices. NOTE: this deviates from the literal addressing
|
||||
* formula in M5.9.5/4.2 (chunk span worldSize/256, i.e. one sample
|
||||
* per worldSize/65536 units) — at that density the road-compliance
|
||||
* writer cannot fill the grid and the terrain mesh (sampling at
|
||||
* page-vertex spacing) never sees the fixups.
|
||||
*
|
||||
* writeFixup() splats the 4 samples of the chunk cell containing the
|
||||
* point so bilinear sampling returns @p height at the write position;
|
||||
* the chunk is created lazily and marked dirty for saveFixups().
|
||||
* writeFixupSample() writes only the single nearest sample — used by
|
||||
* the road-compliance falloff, which evaluates the fade target at the
|
||||
* exact sample position so bilinear reads reproduce the linear ramp.
|
||||
* Cells where only some samples are written blend the written values
|
||||
* with the natural (base + noise) height at the unwritten corners.
|
||||
* Callers are responsible for marking affected terrain pages dirty
|
||||
* AFTER all writes (main thread).
|
||||
*
|
||||
* clearAllFixups() deletes every fixup file and the in-memory chunks
|
||||
* and marks all loaded pages dirty. */
|
||||
void writeFixup(float worldX, float worldZ, float height);
|
||||
void writeFixupSample(float worldX, float worldZ, float height);
|
||||
float getFixupTexelSize() const;
|
||||
bool saveFixups();
|
||||
void clearAllFixups();
|
||||
size_t getFixupChunkCount() const;
|
||||
@@ -300,11 +326,14 @@ public:
|
||||
/**
|
||||
* Road editor tool modes (M5.2). Move is the default: clicks select
|
||||
* and drag existing nodes/edges. In AddNode mode a click on empty
|
||||
* terrain creates a new node.
|
||||
* terrain creates a new node. In Connect mode a click pins a source
|
||||
* node and a click on a second node connects the two (the second
|
||||
* node then becomes the source, so paths can be chained).
|
||||
*/
|
||||
enum class RoadEditTool {
|
||||
Move,
|
||||
AddNode
|
||||
AddNode,
|
||||
Connect
|
||||
};
|
||||
|
||||
RoadEditTool getRoadEditTool() const
|
||||
@@ -321,6 +350,19 @@ public:
|
||||
return m_roadSystem.get();
|
||||
}
|
||||
|
||||
/* Physics wrapper used for terrain/road colliders (may be null).
|
||||
* Exposed for headless tests that verify collider interaction. */
|
||||
JoltPhysicsWrapper *getPhysics() const
|
||||
{
|
||||
return m_physics;
|
||||
}
|
||||
|
||||
/* Camera used for terrain paging and distance checks (may be null). */
|
||||
Ogre::Camera *getCamera() const
|
||||
{
|
||||
return m_camera;
|
||||
}
|
||||
|
||||
/* --- Brush decal (M3) --- */
|
||||
void updateBrushDecal(const Ogre::Vector3 &worldPos,
|
||||
const Ogre::Vector3 &normal, float radius);
|
||||
@@ -523,6 +565,13 @@ private:
|
||||
static constexpr int FIXUP_CHUNK_RES = 256;
|
||||
static constexpr float FIXUP_SENTINEL = -FLT_MAX;
|
||||
|
||||
/* Per-page world size (TerrainComponent::worldSize of the active
|
||||
* terrain). The fixup chunk grid is derived from this — each chunk
|
||||
* covers the full per-page world size and holds 256x256 samples
|
||||
* (worldSize / FIXUP_CHUNK_RES units per sample), independent of how
|
||||
* many pages the heightmap spans. */
|
||||
float m_pageWorldSize = 2000.0f;
|
||||
|
||||
mutable std::map<std::pair<int, int>, FixupChunk> m_fixupChunks;
|
||||
std::string m_fixupDir;
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -82,6 +82,7 @@ private:
|
||||
static bool testTerrainCompliance(EditorApp &app, TerrainSystem *ts);
|
||||
static bool testRoadColliderInteraction(EditorApp &app, TerrainSystem *ts);
|
||||
static bool testRoadSidePrefabs(EditorApp &app, TerrainSystem *ts);
|
||||
static bool testTerrainPrefabSpawners(EditorApp &app, TerrainSystem *ts);
|
||||
static bool testMemoryStability(EditorApp &app);
|
||||
|
||||
static void logResult(const TerrainTestResult &r);
|
||||
|
||||
@@ -1410,9 +1410,38 @@ static int testCharacterSpawnerComponent(lua_State *L)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test 47: AnimationTree component
|
||||
// TerrainPrefabSpawner component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
static int testTerrainPrefabSpawnerComponent(lua_State *L)
|
||||
{
|
||||
TEST("TerrainPrefabSpawner component");
|
||||
|
||||
bool ok = runLua(L,
|
||||
"local id = ecs.create_entity();"
|
||||
"ecs.set_component(id, 'TerrainPrefabSpawner', {"
|
||||
" prefabPath = 'prefabs/test_cube.json',"
|
||||
" spawnDistanceSq = 10000,"
|
||||
" despawnDistanceSq = 40000"
|
||||
"});"
|
||||
"local c = ecs.get_component(id, 'TerrainPrefabSpawner');"
|
||||
"assert(c ~= nil, 'TerrainPrefabSpawner should exist');"
|
||||
"assert(c.prefabPath == 'prefabs/test_cube.json', "
|
||||
"'wrong prefabPath');"
|
||||
"assert(c.spawnDistanceSq == 10000, "
|
||||
"'wrong spawnDistanceSq');"
|
||||
"assert(c.despawnDistanceSq == 40000, "
|
||||
"'wrong despawnDistanceSq')");
|
||||
if (!ok)
|
||||
FAIL("TerrainPrefabSpawner component assertion failed");
|
||||
|
||||
PASS();
|
||||
return 0;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test 47: AnimationTree component
|
||||
// ---------------------------------------------------------------------------
|
||||
static int testAnimationTreeComponent(lua_State *L)
|
||||
{
|
||||
TEST("AnimationTree component");
|
||||
@@ -1905,6 +1934,7 @@ int main()
|
||||
failures += testPrimitiveComponent(L);
|
||||
failures += testTriangleBufferComponent(L);
|
||||
failures += testCharacterSpawnerComponent(L);
|
||||
failures += testTerrainPrefabSpawnerComponent(L);
|
||||
failures += testAnimationTreeComponent(L);
|
||||
failures += testAnimationTreeRegistryApi(L);
|
||||
failures += testBehaviorTreeComponent(L);
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
{
|
||||
"name": "TinyCube",
|
||||
"transform": {
|
||||
"position": { "x": 0, "y": 0, "z": 0 },
|
||||
"rotation": { "w": 1, "x": 0, "y": 0, "z": 0 },
|
||||
"scale": { "x": 1, "y": 1, "z": 1 }
|
||||
},
|
||||
"primitive": {
|
||||
"type": "cube",
|
||||
"size": { "x": 1, "y": 1, "z": 1 },
|
||||
"material": "RoadMaterial"
|
||||
}
|
||||
"name": "TestCube",
|
||||
"transform": {
|
||||
"position": [0, 0.5, 0],
|
||||
"rotation": [1, 0, 0, 0],
|
||||
"scale": [1, 1, 1]
|
||||
},
|
||||
"renderable": {
|
||||
"meshName": "Cube.mesh",
|
||||
"visible": true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -289,6 +289,13 @@ static bool runConfig(const Vector3 &A, const Vector3 &B, const Vector3 &C)
|
||||
graph.config.laneWidth = 3.0f;
|
||||
graph.config.lanesPerDirection = 1;
|
||||
graph.config.roadThickness = 0.3f;
|
||||
/* Sidewalks enabled (improvement plan §2.5): the sidewalk strips
|
||||
* extend along the same no-fold curb rays, so the coplanar/piercing
|
||||
* checks below must stay green with them. */
|
||||
graph.config.sidewalkEnabled = true;
|
||||
graph.config.sidewalkWidth = 1.5f;
|
||||
graph.config.sidewalkHeight = 0.15f;
|
||||
graph.config.sidewalkThickness = 0.3f;
|
||||
int idA = graph.addNode(A, 0.0f);
|
||||
int idB = graph.addNode(B, 0.0f);
|
||||
int idC = graph.addNode(C, 0.0f);
|
||||
@@ -342,6 +349,22 @@ static bool runConfig(const Vector3 &A, const Vector3 &B, const Vector3 &C)
|
||||
ok &= checkPiece(built, "larger wedge", buf, nullptr);
|
||||
}
|
||||
|
||||
/* Sidewalk strips (improvement plan §2.2): same no-fold rays,
|
||||
* elevated boxes — the overlap/piercing checks must stay green. */
|
||||
{
|
||||
Procedural::TriangleBuffer buf;
|
||||
bool built = RoadGeometryLib::buildSidewalkGeometry(*wSmall,
|
||||
graph, buf);
|
||||
ok &= checkPiece(built, "smaller wedge sidewalk", buf,
|
||||
nullptr);
|
||||
}
|
||||
{
|
||||
Procedural::TriangleBuffer buf;
|
||||
bool built = RoadGeometryLib::buildSidewalkGeometry(*wLarge,
|
||||
graph, buf);
|
||||
ok &= checkPiece(built, "larger wedge sidewalk", buf, nullptr);
|
||||
}
|
||||
|
||||
for (const auto &s : segs) {
|
||||
Procedural::TriangleBuffer buf;
|
||||
bool built = RoadGeometryLib::buildSegmentGeometry(s, graph,
|
||||
@@ -349,6 +372,14 @@ static bool runConfig(const Vector3 &A, const Vector3 &B, const Vector3 &C)
|
||||
ok &= checkPiece(built,
|
||||
s.nodeId == idA ? "segment A" : "segment C",
|
||||
buf, nullptr);
|
||||
|
||||
Procedural::TriangleBuffer swBuf;
|
||||
bool swBuilt = RoadGeometryLib::buildSegmentSidewalkGeometry(
|
||||
s, graph, swBuf);
|
||||
ok &= checkPiece(swBuilt,
|
||||
s.nodeId == idA ? "segment A sidewalk" :
|
||||
"segment C sidewalk",
|
||||
swBuf, nullptr);
|
||||
}
|
||||
return ok;
|
||||
}
|
||||
|
||||
@@ -4,8 +4,12 @@
|
||||
|
||||
#include "ComponentEditor.hpp"
|
||||
#include "../components/Terrain.hpp"
|
||||
#include "../components/TerrainPrefabSpawner.hpp"
|
||||
#include "../components/EntityName.hpp"
|
||||
#include "../systems/TerrainSystem.hpp"
|
||||
#include "../systems/RoadSystem.hpp"
|
||||
#include "../systems/TerrainPrefabSpawnerSystem.hpp"
|
||||
#include "../systems/PrefabSystem.hpp"
|
||||
|
||||
#include <OgreResourceGroupManager.h>
|
||||
#include <OgreTextureManager.h>
|
||||
@@ -222,7 +226,6 @@ public:
|
||||
static char auxNameBuf[128] = {};
|
||||
ImGui::InputText("New Aux Map Name", auxNameBuf,
|
||||
sizeof(auxNameBuf));
|
||||
ImGui::SameLine();
|
||||
if (ImGui::SmallButton("Add Aux Map")) {
|
||||
std::string name(auxNameBuf);
|
||||
/* Sanitize: no path separators or empty names. */
|
||||
@@ -290,27 +293,29 @@ public:
|
||||
|
||||
ImGui::Separator();
|
||||
|
||||
/* --- Prefab Spawners (M6) --- */
|
||||
renderPrefabSpawnerSection(entity);
|
||||
|
||||
ImGui::Separator();
|
||||
|
||||
/* --- Camera & file operations --- */
|
||||
if (ts) {
|
||||
if (ImGui::Button("Snap Camera Above Terrain"))
|
||||
ts->snapCameraAboveTerrain();
|
||||
ImGui::SameLine();
|
||||
|
||||
const std::string hmPath = ts->getHeightmapPath(tc);
|
||||
if (ImGui::Button("Save Heightmap"))
|
||||
ts->saveHeightmap(hmPath);
|
||||
ImGui::SameLine();
|
||||
if (ImGui::Button("Load Heightmap"))
|
||||
ts->loadHeightmap(hmPath);
|
||||
|
||||
if (ImGui::Button("Save Blend Maps"))
|
||||
ts->saveSceneBlendMaps(tc);
|
||||
ImGui::SameLine();
|
||||
if (ImGui::Button("Load Blend Maps"))
|
||||
ts->loadSceneBlendMaps(tc);
|
||||
|
||||
if (ImGui::Button("Save Aux Maps"))
|
||||
ts->saveSceneAuxMaps(tc);
|
||||
ImGui::SameLine();
|
||||
if (ImGui::Button("Load Aux Maps"))
|
||||
ts->loadSceneAuxMaps(tc);
|
||||
}
|
||||
@@ -392,7 +397,6 @@ public:
|
||||
|
||||
/* Layers — editable with add/remove. Max 5 (SM2Profile limit). */
|
||||
ImGui::Text("Layers: %zu / 5", tc.layers.size());
|
||||
ImGui::SameLine();
|
||||
if (ImGui::SmallButton("Add Layer") && tc.layers.size() < 5) {
|
||||
/* If the component has no explicit layers yet, the terrain is
|
||||
* using the implicit default base. Insert that base layer first
|
||||
@@ -425,7 +429,6 @@ public:
|
||||
" diffuse=" + newLayer.diffuseTexture +
|
||||
" normal=" + newLayer.normalTexture);
|
||||
}
|
||||
ImGui::SameLine();
|
||||
if (ImGui::SmallButton("Remove Layer") &&
|
||||
tc.layers.size() > 1) {
|
||||
tc.layers.pop_back();
|
||||
@@ -494,6 +497,133 @@ public:
|
||||
}
|
||||
|
||||
private:
|
||||
/* Scan the prefabs directory once (or on Refresh) for the placement
|
||||
* prefab picker. */
|
||||
static void scanPrefabFiles(std::vector<std::string> &out)
|
||||
{
|
||||
out.clear();
|
||||
const std::string dir = PrefabSystem::getPrefabsDirectory();
|
||||
if (!std::filesystem::exists(dir))
|
||||
return;
|
||||
for (const auto &entry :
|
||||
std::filesystem::directory_iterator(dir)) {
|
||||
if (!entry.is_regular_file())
|
||||
continue;
|
||||
const auto ext = entry.path().extension();
|
||||
if (ext == ".json" || ext == ".prefab")
|
||||
out.push_back(dir + "/" +
|
||||
entry.path().filename().string());
|
||||
}
|
||||
}
|
||||
|
||||
/* Prefab spawn mode + spawn point list (M6, section 7.2). */
|
||||
static void renderPrefabSpawnerSection(flecs::entity entity)
|
||||
{
|
||||
TerrainPrefabSpawnerSystem *pss =
|
||||
TerrainPrefabSpawnerSystem::getInstance();
|
||||
if (!pss)
|
||||
return;
|
||||
|
||||
ImGui::PushID("PrefabSpawners");
|
||||
ImGui::Text("Prefab Spawners");
|
||||
|
||||
bool spawnMode = pss->getSpawnEditMode();
|
||||
if (ImGui::Checkbox("Prefab Spawn Mode", &spawnMode))
|
||||
pss->setSpawnEditMode(spawnMode);
|
||||
if (spawnMode) {
|
||||
ImGui::TextColored(ImVec4(0.4f, 0.9f, 0.4f, 1),
|
||||
"PREFAB SPAWN MODE ACTIVE");
|
||||
ImGui::SameLine();
|
||||
ImGui::TextDisabled("(ESC to exit)");
|
||||
ImGui::TextWrapped(
|
||||
"Left-click the terrain in the 3D view to place a "
|
||||
"spawn point with the selected prefab. Spawn points "
|
||||
"are snapped to the terrain surface.");
|
||||
} else {
|
||||
ImGui::TextDisabled(
|
||||
"Enable to place prefab spawn points by clicking "
|
||||
"the terrain in the 3D view.");
|
||||
}
|
||||
|
||||
/* Placement prefab picker. */
|
||||
static std::vector<std::string> s_prefabFiles;
|
||||
static bool s_scanned = false;
|
||||
if (!s_scanned) {
|
||||
scanPrefabFiles(s_prefabFiles);
|
||||
s_scanned = true;
|
||||
}
|
||||
const std::string &cur = pss->getPlacementPrefabPath();
|
||||
const std::string preview = cur.empty() ? "<none>" : cur;
|
||||
if (ImGui::BeginCombo("Placement Prefab", preview.c_str())) {
|
||||
for (const auto &f : s_prefabFiles) {
|
||||
const bool selected = (f == cur);
|
||||
if (ImGui::Selectable(f.c_str(), selected))
|
||||
pss->setPlacementPrefabPath(f);
|
||||
}
|
||||
if (s_prefabFiles.empty())
|
||||
ImGui::TextDisabled("No prefabs found");
|
||||
ImGui::EndCombo();
|
||||
}
|
||||
if (ImGui::SmallButton("Refresh")) {
|
||||
scanPrefabFiles(s_prefabFiles);
|
||||
}
|
||||
|
||||
/* Existing spawn points in the scene. */
|
||||
flecs::world world = entity.world();
|
||||
std::vector<flecs::entity> spawners;
|
||||
world.query<TerrainPrefabSpawnerComponent>().each(
|
||||
[&](flecs::entity e, TerrainPrefabSpawnerComponent &) {
|
||||
spawners.push_back(e);
|
||||
});
|
||||
|
||||
ImGui::Text("Spawn points: %zu", spawners.size());
|
||||
for (flecs::entity e : spawners) {
|
||||
ImGui::PushID((int)e.id());
|
||||
|
||||
std::string label = "Entity " + std::to_string(e.id());
|
||||
if (e.has<EntityNameComponent>())
|
||||
label = e.get<EntityNameComponent>().name;
|
||||
|
||||
const bool spawned =
|
||||
pss->getSpawnedEntity(e).is_alive();
|
||||
ImGui::Bullet();
|
||||
ImGui::Text("%s%s", label.c_str(),
|
||||
spawned ? " (spawned)" : "");
|
||||
|
||||
ImGui::SameLine();
|
||||
if (ImGui::SmallButton("Flatten")) {
|
||||
/* Terrain compliance tool (6.5). */
|
||||
pss->complyTerrainToPrefab(e);
|
||||
}
|
||||
if (ImGui::IsItemHovered())
|
||||
ImGui::SetTooltip(
|
||||
"Flatten the terrain under this prefab's footprint");
|
||||
|
||||
ImGui::SameLine();
|
||||
if (ImGui::SmallButton("Remove")) {
|
||||
/* Removing the component despawns the instance
|
||||
* via the system's OnRemove observer. The (now
|
||||
* empty) marker entity stays behind for the
|
||||
* user to delete from the outliner. */
|
||||
e.remove<TerrainPrefabSpawnerComponent>();
|
||||
}
|
||||
|
||||
ImGui::PopID();
|
||||
}
|
||||
|
||||
if (!spawners.empty()) {
|
||||
if (ImGui::Button("Flatten Terrain Under All Prefabs")) {
|
||||
for (flecs::entity e : spawners)
|
||||
pss->complyTerrainToPrefab(e);
|
||||
}
|
||||
if (ImGui::IsItemHovered())
|
||||
ImGui::SetTooltip(
|
||||
"Flatten the terrain under every spawn point's prefab footprint");
|
||||
}
|
||||
|
||||
ImGui::PopID();
|
||||
}
|
||||
|
||||
static void renderRoadSection(TerrainComponent &tc, TerrainSystem *ts)
|
||||
{
|
||||
RoadSystem *rs = ts ? ts->getRoadSystem() : nullptr;
|
||||
@@ -506,6 +636,11 @@ private:
|
||||
|
||||
RoadGraph &rg = tc.roadGraph;
|
||||
|
||||
/* Last connect failure text, shared by the 3D Connect tool
|
||||
* (via RoadSystem::takeConnectError) and the node-list Connect
|
||||
* buttons; shown as a modal below the edge list. */
|
||||
static std::string s_connectError;
|
||||
|
||||
/* Road mesh template — combo with (Procedural Box) +
|
||||
* .mesh / .glb files from the Ogre resource system. */
|
||||
ImGui::SeparatorText("Road Mesh");
|
||||
@@ -569,37 +704,80 @@ private:
|
||||
cfgChanged |= ImGui::SliderFloat(
|
||||
"Visibility Distance",
|
||||
&rg.config.roadVisibilityDistance, 100.0f, 5000.0f);
|
||||
cfgChanged |= ImGui::SliderFloat(
|
||||
"Prefab Spawn Distance",
|
||||
&rg.config.prefabSpawnDistance, 10.0f, 2000.0f);
|
||||
cfgChanged |= ImGui::SliderFloat(
|
||||
"Prefab Despawn Distance",
|
||||
&rg.config.prefabDespawnDistance, 20.0f, 4000.0f);
|
||||
|
||||
ImGui::SeparatorText("Sidewalks");
|
||||
cfgChanged |= ImGui::Checkbox(
|
||||
"Sidewalks Enabled", &rg.config.sidewalkEnabled);
|
||||
if (rg.config.sidewalkEnabled) {
|
||||
cfgChanged |= ImGui::SliderFloat(
|
||||
"Sidewalk Width",
|
||||
&rg.config.sidewalkWidth, 0.5f, 5.0f);
|
||||
cfgChanged |= ImGui::SliderFloat(
|
||||
"Sidewalk Height",
|
||||
&rg.config.sidewalkHeight, 0.05f, 1.0f,
|
||||
"%.2f");
|
||||
cfgChanged |= ImGui::SliderFloat(
|
||||
"Sidewalk Thickness",
|
||||
&rg.config.sidewalkThickness, 0.05f, 1.0f,
|
||||
"%.2f");
|
||||
scanMeshFiles();
|
||||
std::vector<std::string> &swMeshes =
|
||||
getMeshList();
|
||||
std::vector<const char *> swItems;
|
||||
swItems.reserve(swMeshes.size() + 1);
|
||||
swItems.push_back("(Procedural Box)");
|
||||
int swIdx = 0;
|
||||
for (size_t mi = 0; mi < swMeshes.size(); ++mi) {
|
||||
if (swMeshes[mi] ==
|
||||
rg.config.sidewalkMeshTemplate)
|
||||
swIdx = (int)mi + 1;
|
||||
swItems.push_back(swMeshes[mi].c_str());
|
||||
}
|
||||
if (ImGui::Combo("Sidewalk Template", &swIdx,
|
||||
swItems.data(),
|
||||
(int)swItems.size())) {
|
||||
rg.config.sidewalkMeshTemplate =
|
||||
(swIdx <= 0) ?
|
||||
"" :
|
||||
swMeshes[swIdx - 1];
|
||||
cfgChanged = true;
|
||||
}
|
||||
}
|
||||
if (cfgChanged)
|
||||
rg.bumpVersion();
|
||||
ImGui::TreePop();
|
||||
}
|
||||
/* Toolbar — visible only in road edit mode. */
|
||||
if (ts->getRoadEditMode()) {
|
||||
/* Tool selector: Move Node (default) / Add Node. */
|
||||
bool addNodeTool = (ts->getRoadEditTool() ==
|
||||
TerrainSystem::RoadEditTool::AddNode);
|
||||
if (ImGui::RadioButton("Move Node", !addNodeTool))
|
||||
/* Tool selector: Move Node (default) / Add Node /
|
||||
* Connect. */
|
||||
TerrainSystem::RoadEditTool tool = ts->getRoadEditTool();
|
||||
if (ImGui::RadioButton("Move Node",
|
||||
tool == TerrainSystem::RoadEditTool::Move))
|
||||
ts->setRoadEditTool(
|
||||
TerrainSystem::RoadEditTool::Move);
|
||||
ImGui::SameLine();
|
||||
if (ImGui::RadioButton("Add Node", addNodeTool))
|
||||
if (ImGui::RadioButton("Add Node",
|
||||
tool == TerrainSystem::RoadEditTool::AddNode))
|
||||
ts->setRoadEditTool(
|
||||
TerrainSystem::RoadEditTool::AddNode);
|
||||
ImGui::SameLine();
|
||||
if (ImGui::RadioButton("Connect",
|
||||
tool == TerrainSystem::RoadEditTool::Connect))
|
||||
ts->setRoadEditTool(
|
||||
TerrainSystem::RoadEditTool::Connect);
|
||||
|
||||
if (ImGui::Button("Remove Selected Node") &&
|
||||
rs->getSelectedNodeId() >= 0) {
|
||||
rg.removeNode(rs->getSelectedNodeId());
|
||||
rs->setSelectedNodeId(-1);
|
||||
}
|
||||
ImGui::SameLine();
|
||||
if (ImGui::Button("Split Selected Edge") &&
|
||||
rs->getSelectedEdgeIndex() >= 0) {
|
||||
rs->setSelectedNodeId(rg.splitEdge(
|
||||
(size_t)rs->getSelectedEdgeIndex(), 0.5f));
|
||||
rs->setSelectedEdgeIndex(-1);
|
||||
}
|
||||
ImGui::SameLine();
|
||||
|
||||
static std::string s_roadValidationError;
|
||||
if (ImGui::Button("Validate Road Graph")) {
|
||||
@@ -628,7 +806,6 @@ private:
|
||||
ImGui::CloseCurrentPopup();
|
||||
ImGui::EndPopup();
|
||||
}
|
||||
ImGui::SameLine();
|
||||
|
||||
/* Terrain compliance: writes fixup chunks under road surfaces
|
||||
* so the terrain conforms to the road underside (M5.10). */
|
||||
@@ -674,16 +851,23 @@ private:
|
||||
"select it; drag the gizmo's X/Z axes to move it. "
|
||||
"Its height follows the terrain plus the Vertical "
|
||||
"Offset from the node inspector.\n"
|
||||
"3. To connect two nodes: select one node, then "
|
||||
"press \"Connect\" next to the other node in the "
|
||||
"Nodes list below.\n"
|
||||
"3. To connect two nodes: select the \"Connect\" "
|
||||
"tool and click one node, then the other (repeated "
|
||||
"clicks chain a path). Alternatively select a node "
|
||||
"and press \"Connect\" next to the other node in "
|
||||
"the Nodes list below.\n"
|
||||
"4. Click an edge in the 3D view or in the Edges "
|
||||
"list to select it. \"Split Selected Edge\" inserts "
|
||||
"a node at the edge midpoint; lane counts and road "
|
||||
"levels are edited in the Selected Edge inspector.\n"
|
||||
"5. \"Remove Selected Node\" deletes the node and "
|
||||
"all edges connected to it. Clicking empty terrain "
|
||||
"in Move Node mode clears the selection.\n"
|
||||
"list to select it. \"Split\" on an edge row "
|
||||
"inserts a node at the edge midpoint; lane counts "
|
||||
"and road levels are edited in the Selected Edge "
|
||||
"inspector.\n"
|
||||
"5. \"Smooth Node\" in the node inspector relaxes "
|
||||
"a node with exactly two edges toward a "
|
||||
"straighter path.\n"
|
||||
"6. \"Remove Selected Node\" deletes the node and "
|
||||
"all edges connected to it. Clicking empty "
|
||||
"terrain in Move Node mode clears the selection. "
|
||||
"ESC exits road edit mode.\n"
|
||||
"\n"
|
||||
"A click is a press+release without moving the "
|
||||
"mouse; moving the mouse while holding the button "
|
||||
@@ -716,14 +900,34 @@ private:
|
||||
}
|
||||
if (showConnect) {
|
||||
ImGui::SameLine();
|
||||
if (ImGui::SmallButton("Connect"))
|
||||
rg.joinNodes(selectedNodeId, n.id);
|
||||
if (ImGui::SmallButton("Connect")) {
|
||||
std::string error;
|
||||
std::vector<int> created;
|
||||
if (rg.connectNodes(selectedNodeId, n.id,
|
||||
tc.worldSize,
|
||||
&error,
|
||||
&created) >= 0) {
|
||||
/* Page-boundary split nodes
|
||||
* start with an interpolated
|
||||
* Y; re-snap to the terrain. */
|
||||
rs->snapNodesToTerrain(created);
|
||||
/* Chain: the clicked node
|
||||
* becomes the new source. */
|
||||
rs->setSelectedNodeId(n.id);
|
||||
} else {
|
||||
s_connectError = error;
|
||||
ImGui::OpenPopup(
|
||||
"Road Connect Failed");
|
||||
}
|
||||
}
|
||||
}
|
||||
ImGui::PopID();
|
||||
}
|
||||
|
||||
/* Edge list. Same sizing rule as the node list: keep the
|
||||
* selectable clear of the Remove button. */
|
||||
* selectable clear of the row buttons. Every row has a Split
|
||||
* button (splitting does not require selecting the edge
|
||||
* first); Remove only appears on the selected row. */
|
||||
ImGui::Text("Edges: %zu", rg.edges.size());
|
||||
for (size_t i = 0; i < rg.edges.size(); ++i) {
|
||||
const auto &e = rg.edges[i];
|
||||
@@ -732,6 +936,9 @@ private:
|
||||
std::to_string(e.nodeA) + " <-> " +
|
||||
std::to_string(e.nodeB);
|
||||
float rowWidth = ImGui::GetContentRegionAvail().x;
|
||||
rowWidth -= ImGui::CalcTextSize("Split").x +
|
||||
ImGui::GetStyle().FramePadding.x * 2.0f +
|
||||
ImGui::GetStyle().ItemSpacing.x;
|
||||
if (selected)
|
||||
rowWidth -= ImGui::CalcTextSize("Remove").x +
|
||||
ImGui::GetStyle().FramePadding.x * 2.0f +
|
||||
@@ -743,6 +950,21 @@ private:
|
||||
rs->setSelectedEdgeIndex((int)i);
|
||||
rs->setSelectedNodeId(-1);
|
||||
}
|
||||
ImGui::SameLine();
|
||||
if (ImGui::SmallButton("Split")) {
|
||||
int newId = rg.splitEdge(i, 0.5f);
|
||||
/* The midpoint Y is interpolated between the
|
||||
* endpoints; re-snap it to the terrain. */
|
||||
RoadNode *nn = rg.findNodeById(newId);
|
||||
if (nn && ts) {
|
||||
float y = ts->getHeightAt(Ogre::Vector3(
|
||||
nn->position.x, 0.0f,
|
||||
nn->position.z));
|
||||
nn->position.y = y + nn->verticalOffset;
|
||||
}
|
||||
rs->setSelectedNodeId(newId);
|
||||
rs->setSelectedEdgeIndex(-1);
|
||||
}
|
||||
if (selected) {
|
||||
ImGui::SameLine();
|
||||
if (ImGui::SmallButton("Remove")) {
|
||||
@@ -753,6 +975,26 @@ private:
|
||||
ImGui::PopID();
|
||||
}
|
||||
|
||||
/* Connect failures (from the 3D Connect tool via RoadSystem,
|
||||
* or the node-list Connect buttons above) are shown as a
|
||||
* modal. Placed after the lists so OpenPopup calls from the
|
||||
* rows are picked up in the same frame. */
|
||||
{
|
||||
std::string pendingConnectError;
|
||||
if (rs->takeConnectError(pendingConnectError)) {
|
||||
s_connectError = pendingConnectError;
|
||||
ImGui::OpenPopup("Road Connect Failed");
|
||||
}
|
||||
if (ImGui::BeginPopupModal("Road Connect Failed",
|
||||
nullptr,
|
||||
ImGuiWindowFlags_AlwaysAutoResize)) {
|
||||
ImGui::TextWrapped("%s", s_connectError.c_str());
|
||||
if (ImGui::Button("OK", ImVec2(120, 0)))
|
||||
ImGui::CloseCurrentPopup();
|
||||
ImGui::EndPopup();
|
||||
}
|
||||
}
|
||||
|
||||
/* Selected node inspector. */
|
||||
if (rs->getSelectedNodeId() >= 0) {
|
||||
RoadNode *node = rg.findNodeById(rs->getSelectedNodeId());
|
||||
@@ -815,6 +1057,46 @@ private:
|
||||
node->position.y = y + node->verticalOffset;
|
||||
nodeChanged = true;
|
||||
}
|
||||
/* Smoothing: relax the node toward the midpoint
|
||||
* of its two neighbors (only for degree-2 nodes). */
|
||||
{
|
||||
int degree = 0;
|
||||
for (const auto &e : rg.edges)
|
||||
if (e.nodeA == node->id ||
|
||||
e.nodeB == node->id)
|
||||
++degree;
|
||||
if (degree == 2) {
|
||||
static float s_smoothStrength =
|
||||
0.5f;
|
||||
ImGui::SliderFloat(
|
||||
"Smooth Strength",
|
||||
&s_smoothStrength, 0.0f,
|
||||
1.0f, "%.2f");
|
||||
if (ImGui::Button("Smooth Node") &&
|
||||
rg.smoothNode(
|
||||
node->id,
|
||||
s_smoothStrength,
|
||||
tc.worldSize) && ts) {
|
||||
/* smoothNode leaves Y
|
||||
* untouched; re-snap it at
|
||||
* the new XZ. */
|
||||
float y = ts->getHeightAt(
|
||||
Ogre::Vector3(
|
||||
node->position.x,
|
||||
0.0f,
|
||||
node->position.z));
|
||||
node->position.y =
|
||||
y + node->verticalOffset;
|
||||
}
|
||||
if (ImGui::IsItemHovered())
|
||||
ImGui::SetTooltip(
|
||||
"Relax this node toward the midpoint of its two neighbors.");
|
||||
} else {
|
||||
ImGui::TextDisabled(
|
||||
"Smooth: needs exactly 2 "
|
||||
"edges at this node");
|
||||
}
|
||||
}
|
||||
if (nodeChanged)
|
||||
rg.bumpVersion();
|
||||
}
|
||||
@@ -905,49 +1187,80 @@ private:
|
||||
edgeChanged |= ImGui::SliderFloat(
|
||||
"Road Level B", &e.roadLevelB, -5.0f, 5.0f);
|
||||
|
||||
/* Side prefabs placed along this edge. */
|
||||
ImGui::Text("Side Prefabs: %zu",
|
||||
e.sidePrefabs.size());
|
||||
for (size_t i = 0; i < e.sidePrefabs.size(); ++i) {
|
||||
auto &sp = e.sidePrefabs[i];
|
||||
ImGui::PushID((int)i);
|
||||
char pbuf[256];
|
||||
strncpy(pbuf, sp.prefabPath.c_str(),
|
||||
sizeof(pbuf) - 1);
|
||||
pbuf[sizeof(pbuf) - 1] = 0;
|
||||
if (ImGui::InputText("Prefab", pbuf,
|
||||
sizeof(pbuf))) {
|
||||
sp.prefabPath = pbuf;
|
||||
edgeChanged = true;
|
||||
}
|
||||
edgeChanged |= ImGui::SliderFloat(
|
||||
"Position T", &sp.edgeT, 0.0f, 1.0f);
|
||||
edgeChanged |= ImGui::SliderFloat(
|
||||
"Side Offset", &sp.sideOffset, 0.0f,
|
||||
50.0f);
|
||||
edgeChanged |= ImGui::Checkbox("Left Side",
|
||||
&sp.leftSide);
|
||||
if (ImGui::SmallButton("Remove")) {
|
||||
e.sidePrefabs.erase(
|
||||
e.sidePrefabs.begin() + i);
|
||||
edgeChanged = true;
|
||||
ImGui::PopID();
|
||||
break;
|
||||
/* Edge prefab slots (improvement plan §3):
|
||||
* left/right curb anchors plus one centerline
|
||||
* midpoint prefab. */
|
||||
auto slotEditor = [&](const char *label,
|
||||
RoadEdgePrefabSlot &slot) {
|
||||
ImGui::PushID(label);
|
||||
if (ImGui::TreeNode(label)) {
|
||||
static std::vector<std::string>
|
||||
s_prefabs;
|
||||
static bool s_scanned = false;
|
||||
if (!s_scanned) {
|
||||
scanPrefabFiles(
|
||||
s_prefabs);
|
||||
s_scanned = true;
|
||||
}
|
||||
const std::string preview =
|
||||
slot.prefabPath.empty() ?
|
||||
"(None)" :
|
||||
slot.prefabPath;
|
||||
if (ImGui::BeginCombo(
|
||||
"Prefab",
|
||||
preview.c_str())) {
|
||||
if (ImGui::Selectable(
|
||||
"(None)",
|
||||
slot.prefabPath
|
||||
.empty())) {
|
||||
slot.prefabPath
|
||||
.clear();
|
||||
edgeChanged =
|
||||
true;
|
||||
}
|
||||
for (const auto &f :
|
||||
s_prefabs) {
|
||||
if (ImGui::Selectable(
|
||||
f.c_str(),
|
||||
f == slot
|
||||
.prefabPath)) {
|
||||
slot.prefabPath =
|
||||
f;
|
||||
edgeChanged =
|
||||
true;
|
||||
}
|
||||
}
|
||||
if (s_prefabs.empty())
|
||||
ImGui::TextDisabled(
|
||||
"No prefabs found");
|
||||
ImGui::EndCombo();
|
||||
}
|
||||
if (ImGui::SmallButton("Refresh"))
|
||||
scanPrefabFiles(s_prefabs);
|
||||
if (!slot.prefabPath.empty()) {
|
||||
edgeChanged |= ImGui::SliderFloat(
|
||||
"Position T",
|
||||
&slot.edgeT,
|
||||
0.0f, 1.0f);
|
||||
edgeChanged |= ImGui::SliderFloat(
|
||||
"Lateral Offset",
|
||||
&slot.lateralOffset,
|
||||
-50.0f,
|
||||
50.0f);
|
||||
edgeChanged |= ImGui::SliderFloat(
|
||||
"Y Offset",
|
||||
&slot.yOffset,
|
||||
-10.0f,
|
||||
10.0f);
|
||||
}
|
||||
ImGui::TreePop();
|
||||
}
|
||||
ImGui::PopID();
|
||||
}
|
||||
static char s_prefabBuf[256] = {};
|
||||
ImGui::InputText("New Prefab Path", s_prefabBuf,
|
||||
sizeof(s_prefabBuf));
|
||||
ImGui::SameLine();
|
||||
if (ImGui::SmallButton("Add Prefab") &&
|
||||
s_prefabBuf[0] != '\0') {
|
||||
RoadEdge::RoadSidePrefab sp;
|
||||
sp.prefabPath = s_prefabBuf;
|
||||
e.sidePrefabs.push_back(sp);
|
||||
s_prefabBuf[0] = '\0';
|
||||
edgeChanged = true;
|
||||
}
|
||||
};
|
||||
ImGui::Text("Edge Prefabs:");
|
||||
slotEditor("Left Curb Prefab", e.prefabLeft);
|
||||
slotEditor("Right Curb Prefab", e.prefabRight);
|
||||
slotEditor("Midpoint Prefab", e.prefabMid);
|
||||
|
||||
if (edgeChanged)
|
||||
rg.bumpVersion();
|
||||
@@ -1128,7 +1441,6 @@ private:
|
||||
changed = true;
|
||||
}
|
||||
|
||||
ImGui::SameLine();
|
||||
if (ImGui::Button("Browse")) {
|
||||
getTexturePickerTarget() = label;
|
||||
getTexturePickerOpen() = true;
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
#include "TerrainPrefabSpawnerEditor.hpp"
|
||||
#include "../systems/PrefabSystem.hpp"
|
||||
#include "../systems/TerrainPrefabSpawnerSystem.hpp"
|
||||
#include <imgui.h>
|
||||
#include <cmath>
|
||||
#include <filesystem>
|
||||
|
||||
TerrainPrefabSpawnerEditor::TerrainPrefabSpawnerEditor(
|
||||
Ogre::SceneManager *sceneMgr)
|
||||
: m_sceneMgr(sceneMgr)
|
||||
{
|
||||
(void)m_sceneMgr;
|
||||
}
|
||||
|
||||
void TerrainPrefabSpawnerEditor::refreshPrefabList()
|
||||
{
|
||||
m_prefabFiles.clear();
|
||||
const std::string prefabDir = PrefabSystem::getPrefabsDirectory();
|
||||
if (std::filesystem::exists(prefabDir)) {
|
||||
for (const auto &entry :
|
||||
std::filesystem::directory_iterator(prefabDir)) {
|
||||
if (!entry.is_regular_file())
|
||||
continue;
|
||||
const auto ext = entry.path().extension();
|
||||
if (ext == ".json" || ext == ".prefab")
|
||||
m_prefabFiles.push_back(prefabDir + "/" +
|
||||
entry.path()
|
||||
.filename()
|
||||
.string());
|
||||
}
|
||||
}
|
||||
m_prefabListScanned = true;
|
||||
}
|
||||
|
||||
bool TerrainPrefabSpawnerEditor::renderComponent(
|
||||
flecs::entity entity, TerrainPrefabSpawnerComponent &spawner)
|
||||
{
|
||||
bool modified = false;
|
||||
|
||||
if (ImGui::CollapsingHeader("Terrain Prefab Spawner",
|
||||
ImGuiTreeNodeFlags_DefaultOpen)) {
|
||||
ImGui::Indent();
|
||||
|
||||
if (!m_prefabListScanned)
|
||||
refreshPrefabList();
|
||||
|
||||
/* Prefab picker. */
|
||||
std::string preview = spawner.prefabPath.empty() ?
|
||||
"<none>" :
|
||||
spawner.prefabPath;
|
||||
if (ImGui::BeginCombo("Prefab", preview.c_str())) {
|
||||
for (const auto &file : m_prefabFiles) {
|
||||
const bool selected =
|
||||
(file == spawner.prefabPath);
|
||||
if (ImGui::Selectable(file.c_str(), selected)) {
|
||||
spawner.prefabPath = file;
|
||||
modified = true;
|
||||
}
|
||||
}
|
||||
if (m_prefabFiles.empty())
|
||||
ImGui::TextDisabled("No prefabs found");
|
||||
ImGui::EndCombo();
|
||||
}
|
||||
if (ImGui::IsItemHovered())
|
||||
ImGui::SetTooltip("Prefab to spawn on the terrain");
|
||||
ImGui::SameLine();
|
||||
if (ImGui::SmallButton("Refresh"))
|
||||
refreshPrefabList();
|
||||
|
||||
float spawnDist = std::sqrt(spawner.spawnDistanceSq);
|
||||
if (ImGui::DragFloat("Spawn Distance", &spawnDist, 1.0f, 0.0f,
|
||||
100000.0f)) {
|
||||
spawner.spawnDistanceSq = spawnDist * spawnDist;
|
||||
modified = true;
|
||||
}
|
||||
|
||||
float despawnDist = std::sqrt(spawner.despawnDistanceSq);
|
||||
if (ImGui::DragFloat("Despawn Distance", &despawnDist, 1.0f,
|
||||
0.0f, 100000.0f)) {
|
||||
spawner.despawnDistanceSq = despawnDist * despawnDist;
|
||||
modified = true;
|
||||
}
|
||||
|
||||
if (spawner.spawnDistanceSq > spawner.despawnDistanceSq)
|
||||
ImGui::TextColored(
|
||||
ImVec4(1.0f, 0.4f, 0.4f, 1.0f),
|
||||
"Spawn distance is greater than despawn distance");
|
||||
|
||||
/* Spawn state + actions. */
|
||||
TerrainPrefabSpawnerSystem *pss =
|
||||
TerrainPrefabSpawnerSystem::getInstance();
|
||||
if (pss) {
|
||||
flecs::entity spawned = pss->getSpawnedEntity(entity);
|
||||
if (spawned.is_alive())
|
||||
ImGui::Text("Spawned: instance %llu",
|
||||
(unsigned long long)spawned.id());
|
||||
else
|
||||
ImGui::TextDisabled("Not spawned");
|
||||
|
||||
if (ImGui::Button("Snap to Terrain")) {
|
||||
if (!pss->snapToTerrain(entity))
|
||||
Ogre::LogManager::getSingleton()
|
||||
.logMessage(
|
||||
"TerrainPrefabSpawnerEditor: snap failed (no active terrain?)");
|
||||
}
|
||||
if (ImGui::IsItemHovered())
|
||||
ImGui::SetTooltip(
|
||||
"Set the spawner Y to the terrain surface at its (X, Z)");
|
||||
|
||||
ImGui::SameLine();
|
||||
|
||||
if (ImGui::Button("Flatten Terrain Under Prefab")) {
|
||||
if (!pss->complyTerrainToPrefab(entity))
|
||||
Ogre::LogManager::getSingleton()
|
||||
.logMessage(
|
||||
"TerrainPrefabSpawnerEditor: comply failed (no active terrain?)");
|
||||
}
|
||||
if (ImGui::IsItemHovered())
|
||||
ImGui::SetTooltip(
|
||||
"Flatten the terrain under the spawned prefab's footprint (writes fixup chunks)");
|
||||
}
|
||||
|
||||
ImGui::Unindent();
|
||||
}
|
||||
|
||||
return modified;
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
#ifndef EDITSCENE_TERRAINPREFABSPAWNEREDITOR_HPP
|
||||
#define EDITSCENE_TERRAINPREFABSPAWNEREDITOR_HPP
|
||||
#pragma once
|
||||
|
||||
#include "ComponentEditor.hpp"
|
||||
#include "../components/TerrainPrefabSpawner.hpp"
|
||||
#include <OgreSceneManager.h>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
/**
|
||||
* @brief Editor panel for TerrainPrefabSpawnerComponent (Milestone 6).
|
||||
*
|
||||
* Prefab picker (scans the prefabs directory), spawn/despawn distances,
|
||||
* terrain snap and terrain compliance (flatten-under-footprint) actions.
|
||||
*/
|
||||
class TerrainPrefabSpawnerEditor
|
||||
: public ComponentEditor<TerrainPrefabSpawnerComponent> {
|
||||
public:
|
||||
explicit TerrainPrefabSpawnerEditor(Ogre::SceneManager *sceneMgr);
|
||||
|
||||
const char *getName() const override
|
||||
{
|
||||
return "Terrain Prefab Spawner";
|
||||
}
|
||||
|
||||
protected:
|
||||
bool renderComponent(flecs::entity entity,
|
||||
TerrainPrefabSpawnerComponent &spawner) override;
|
||||
|
||||
private:
|
||||
void refreshPrefabList();
|
||||
|
||||
Ogre::SceneManager *m_sceneMgr;
|
||||
std::vector<std::string> m_prefabFiles;
|
||||
bool m_prefabListScanned = false;
|
||||
};
|
||||
|
||||
#endif // EDITSCENE_TERRAINPREFABSPAWNEREDITOR_HPP
|
||||
Reference in New Issue
Block a user