Data stability improved, town works much better

This commit is contained in:
2026-10-23 16:08:29 +03:00
parent e609d4e39b
commit 95b42090f9
19 changed files with 2111 additions and 278 deletions
+30 -7
View File
@@ -1240,9 +1240,18 @@ Per-terrain procedural roads (`RoadGraph` component, geometry built by
"Smooth A–B–C" action (`RoadGraph::smoothNode`, position relaxation —
no graph mutation).
- **Lane counts (0 = unset)**: `RoadEdge::lanesAtoB/lanesBtoA` of 0 mean
"unset" and resolve to `RoadConfig::lanesPerDirection` per side
(`RoadGraph::resolveLaneCounts()`) — never "no lanes". A 0+0 edge
builds as a full default road, not a placeholder path.
"unset" and resolve to `RoadConfig::defaultLanesAtoB/defaultLanesBtoA`
per direction (0 there falls back to the legacy symmetric
`RoadConfig::lanesPerDirection`; `RoadGraph::resolveLaneCounts()`) —
never "no lanes". A 0+0 edge builds as a full default road, not a
placeholder path.
- **Multi-edge selection / batch editing**: ctrl+click edges in the
list or in the 3D view toggles membership in
`RoadSystem::m_selectedEdgeIndices` (plain click single-selects and
replaces the set); "Select Connected" flood-fills the component,
node rows have "Sel. Path" (BFS shortest path to the selected node).
The Batch Edit Selection panel applies lanes A→B/B→A and sidewalk
left/right modes (each with a "keep" choice) to the whole set.
- **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
@@ -1255,7 +1264,10 @@ Per-terrain procedural roads (`RoadGraph` component, geometry built by
see `ProceduralRoadGeometry.md` §15); terrain compliance starts its
falloff at the sidewalk outer edge. Per edge side the tri-state
`RoadEdge::sidewalkLeftMode/RightMode` (Inherit/On/Off) can override
the global switch either way; the geometry builders fold modes +
the per-side config defaults
(`RoadConfig::defaultSidewalkLeftMode/defaultSidewalkRightMode`,
themselves tri-states falling back to the legacy global
`sidewalkEnabled`); the geometry builders fold modes +
global themselves (`RoadGraph::edgeSidewalkEnabled()`), while
`resolveSidewalkModes()` caches the resolved `sidewalkLeft/Right`
bools for terrain compliance and the editor UI.
@@ -1347,7 +1359,13 @@ Per-terrain procedural roads (`RoadGraph` component, geometry built by
rejection via a height sampler) stamped with `generatedGroupId` —
the editor refuses Remove/Split on grouped nodes/edges, a group is
removed wholesale (`RoadSystem::clearGenerated`) and hooks into a
manual road via `connectGroupToNode()`. Editor UI: terrain panel
manual road via `connectGroupToNode()`. Per-generation road make-up
(`GridParams::lanesAtoB/lanesBtoA`, `sidewalkLeftMode/
sidewalkRightMode`; 0/Inherit = leave unset, config defaults apply)
is stamped on every edge the generation creates — two settlements in
one map can differ without touching the RoadConfig defaults, and
`splitEdge()` copies the whole `RoadEdge` so crossing-resolution
splits preserve the values. Editor UI: terrain panel
"Road Network Generator" section (`RoadSystem::generateRoadNetwork`
— terrain heights + `WaterPhysics` water level — and
`resolveCrossingsNow`), with a "Pick Origin on Terrain" button that
@@ -1449,8 +1467,13 @@ Per-terrain procedural roads (`RoadGraph` component, geometry built by
library. **Town areas** (phase 4.5 item 4): `RoadGraph::townAreas`
(`RoadTownArea`: name, square/circle XZ scope, district, seed,
densityScale) scope generation per settlement —
`TownGen::generateTown()` filters lots by centroid containment with
first-match-wins over overlapping areas before any RNG draws;
`TownGen::generateTown()` filters lots by centroid containment before
any RNG draws, and a contested lot (inside 2+ areas) belongs to
exactly one area via `RoadNetworkGen::ownerAreaIndex()` — smallest
containing coverage, tie-break lower seed then name; stable under
`townAreas` reordering. The Town Areas block has an "Area Ownership
Preview" checkbox coloring each lot by its owning area in road edit
mode (with a per-area legend and contested-lot X markers);
`TownGenSystem::generateAreas()` builds one `"TownGen <area name>"`
container per area (the unscoped `generate()` stays for
single-settlement scenes and `--generate-town`). Areas persist
@@ -13,7 +13,9 @@
> **Lane counts — the 0-is-unset rule.** `RoadEdge::lanesAtoB` and
> `RoadEdge::lanesBtoA` are per-edge overrides; **0 means "unset"** and
> resolves to the global `RoadConfig::lanesPerDirection` (default 1) for
> resolves to `RoadConfig::defaultLanesAtoB` / `defaultLanesBtoA`
> (per-direction defaults; 0 there falls back to the legacy symmetric
> `RoadConfig::lanesPerDirection`, default 1) for
> that side — it never means "no lanes". An edge stored as 0+0 therefore
> builds as a full default road (1+1 lanes with the shipped config), not
> as a path/placeholder; there is currently no way to express a true
@@ -1091,8 +1093,11 @@ bands stay open where a future wedge would connect.
Each edge controls its sidewalk per side via the tri-state
`RoadEdge::sidewalkLeftMode` / `sidewalkRightMode` (`RoadSidewalkMode`:
**Inherit** — the default — follows the global
`RoadConfig::sidewalkEnabled`, **On** forces the side even when sidewalks
**Inherit** — the default — follows the per-side config defaults
`RoadConfig::defaultSidewalkLeftMode` / `defaultSidewalkRightMode`,
which are themselves tri-states whose Inherit falls back to the legacy
global `RoadConfig::sidewalkEnabled`; **On** forces the side even when
sidewalks
are globally off, **Off** suppresses it even when globally on).
`RoadGraph::resolveSidewalkModes()` folds modes + global switch into the
resolved `RoadEdge::sidewalkLeft` / `sidewalkRight` bools (called by
@@ -1518,11 +1523,20 @@ crossings, which is how a settlement hooks into a hand-authored road.
Editor integration: the terrain panel's "Road Network Generator"
section (origin, block counts/sizes, jitter, seed, avoid-water, max
slope, crossing resolution; Generate / Resolve Crossings Now /
per-group Remove buttons) wraps `RoadSystem::generateRoadNetwork()`
(heights from `TerrainSystem::getHeightAt`, water level from the
scene's `WaterPhysics` entity), `RoadSystem::resolveCrossingsNow()`
and `RoadSystem::clearGenerated()`. "Pick Origin on Terrain" arms a
slope, crossing resolution, per-generation road make-up; Generate /
Resolve Crossings Now / per-group Remove buttons) wraps
`RoadSystem::generateRoadNetwork()` (heights from
`TerrainSystem::getHeightAt`, water level from the scene's
`WaterPhysics` entity), `RoadSystem::resolveCrossingsNow()`
and `RoadSystem::clearGenerated()`. The per-generation make-up
(`GridParams::lanesAtoB/lanesBtoA` and
`sidewalkLeftMode/sidewalkRightMode`, phase 5.9 follow-up) is stamped on
every edge the generation creates, so two settlements in one map can
differ without touching the `RoadConfig` defaults; 0 lanes / Inherit
leave the edge fields unset (the config defaults apply at resolve
time), and `RoadGraph::splitEdge()` copies the whole `RoadEdge`, so the
crossing resolution's splits preserve the stamped values. "Pick Origin
on Terrain" arms a
one-shot terrain click (`RoadSystem::armOriginPick` /
`takePickedOrigin`; `EditorUISystem::handleRoadEditClick` consumes the
click before any road tool) that fills the origin fields. The
@@ -1539,7 +1553,8 @@ Headless coverage: `road_geometry_overlap_test` probes
untouched, `runLotProbe` 2-block grid + spur rejection,
`runStreetProbe` naming/reassignment/JSON round trip/unique addresses +
unnamed fallback, `runGeneratorProbe` determinism, counts, water and
slope rejection, two settlements in one graph, group connect/remove).
slope rejection, two settlements in one graph, group connect/remove,
per-generation lanes/sidewalk stamping + split survival).
## 20. Procedural Town Generation (taxi demo phase 4)
@@ -2079,8 +2094,11 @@ struct RoadTownArea {
`TownGen` filters lots per area by **centroid containment** (simple,
deterministic; a lot straddling the boundary belongs to the area
containing its centroid — overlapping areas resolve first-match-wins
and are flagged in the editor). Lots inside NO area generate nothing
containing its centroid — overlapping areas resolve through
`RoadNetworkGen::ownerAreaIndex`: smallest containing coverage wins,
ties break by lower seed then lexicographic name, so the owner is
stable under `townAreas` reordering; contested lots are marked in the
editor's area-ownership preview). Lots inside NO area generate nothing
(areas are the only generation scope; the old global generate becomes
a one-click "create area around origin" convenience). Each area
generates into its own `"TownGen <name>"` container. Persistence
@@ -2095,8 +2113,17 @@ the unscoped `TownGenSystem::generate()` stays as-is (area filtering
is opt-in via the trailing `areas`/`areaIndex` parameters of
`TownGen::generateTown()`, so the single-town button, the
`--generate-town` headless check and all existing probes are
unaffected), and overlapping areas are NOT flagged in the editor —
first-match-wins applies silently. `RoadTownArea` lives in
unaffected). Overlapping areas were originally first-match-wins and
NOT flagged in the editor; the phase 5.9 follow-up replaced that with
the order-independent `RoadNetworkGen::ownerAreaIndex()` rule
(smallest containing coverage, then lower seed, then name) shared by
generation and the editor, and added an "Area Ownership Preview"
checkbox in the Town Areas block: `RoadSystem::buildLotOutlines()`
then colors every `findLots()` lot by its owning area (color hashed
from the area name, stable under reordering), draws area bounds in
matching colors, marks contested lots with an X in the winner's
color, and a legend lists per-area owned/contested lot counts.
`RoadTownArea` lives in
`components/RoadGraph.hpp` with its own `RoadTownDistrict` enum
(Village..CityCenter, no Inherit, value-compatible with
`TownGen::District`, static_asserted in TownGenSystem.cpp);
@@ -2105,8 +2132,8 @@ generateAreas()` shares the terrain/water/sampler setup with
`generate()` (extracted `setupTownRun`), requires a non-empty
`townAreas`, clears once and builds one `"TownGen <area name>"`
container per area through the extracted `createTownContainer()`
tail. Filtering runs before any RNG draws (centroid inside the
area, not inside any earlier area), so per-area towns are
tail. Filtering runs before any RNG draws (owner check via
`ownerAreaIndex`), so per-area towns are
deterministic. Serialization: `RoadNetworkGen::townAreasToJson/
FromJson`, inline `townAreas` in the scene JSON next to `streets`
(non-streaming), `roads/towns.json` via
@@ -2119,10 +2146,14 @@ the two picks don't steal each other's click —, halfSize/radius,
district, seed, densityScale, Remove), with Add Area (Square/Circle)
and Generate All Areas buttons; `RoadSystem::buildLotOutlines()`
draws area outlines in dark blue (0.1,0.1,0.3) next to the dark grey
lot loops. Probes in `runTownGenProbe()`: two overlapping areas on
the 2x2-block grid (west square 4 lots, east circle 4 lots after
first-match-wins, 4 lots outside all areas build nothing), per-area
determinism, disjoint lot sets, and the townAreas json round trip.
lot loops (or per-area colors when the ownership preview is on).
Probes in `runTownGenProbe()`: two overlapping areas on
the 2x2-block grid (west square 4 lots — it is the smaller coverage
and owns the contested lots —, east circle 4 lots, 4 lots outside all
areas build nothing), per-area
determinism, disjoint lot sets, ownership identical under reversed
area order, `ownerAreaIndex` containment/coverage/seed/name
tie-break unit probes, and the townAreas json round trip.
### 21.6 Implementation order
+51 -20
View File
@@ -31,6 +31,26 @@ static const float ROAD_SIDEWALK_WALL_GAP = 0.002f;
* road network owned by a single TerrainComponent. These values are
* serialized with the scene and edited through the terrain editor.
*/
/**
* Per-edge-side sidewalk tri-state.
*
* RoadEdge::sidewalkLeft/Right used to be plain enables ANDed with the
* global RoadConfig::sidewalkEnabled, which made it impossible to give a
* single edge a sidewalk while the global switch was off. The tri-state
* replaces that: Inherit follows the global switch, On/Off force the side
* regardless of it. The resolved value is cached in the legacy bools by
* RoadGraph::resolveSidewalkModes() for the geometry builders.
*
* Declared before RoadConfig: the config's per-side default sidewalk
* modes use it as a member type.
*/
enum class RoadSidewalkMode : int {
Inherit = 0, /**< Follow RoadConfig::defaultSidewalk*Mode, then
* RoadConfig::sidewalkEnabled. */
On = 1, /**< Sidewalk on this side even if globally disabled. */
Off = 2 /**< No sidewalk on this side even if globally enabled. */
};
struct RoadConfig {
/**
* Ogre mesh template used to build each unit-length road segment.
@@ -62,6 +82,15 @@ struct RoadConfig {
*/
int lanesPerDirection = 1;
/**
* Per-direction default lane counts (phase 5.9 follow-up).
*
* An edge's lanesAtoB/lanesBtoA (0 = default) resolve against these;
* a 0 here falls back to the legacy symmetric lanesPerDirection.
*/
int defaultLanesAtoB = 0;
int defaultLanesBtoA = 0;
/**
* Vertical thickness of the road surface.
*
@@ -101,6 +130,17 @@ struct RoadConfig {
float sidewalkThickness = 0.3f;
std::string sidewalkMeshTemplate;
/**
* Per-side default sidewalk modes (phase 5.9 follow-up).
*
* An edge's sidewalkLeftMode/sidewalkRightMode Inherit resolves
* against these; Inherit here falls back to the legacy symmetric
* sidewalkEnabled switch. Sides follow the edge convention:
* looking from nodeA toward nodeB, left is UNIT_Y x dir.
*/
RoadSidewalkMode defaultSidewalkLeftMode = RoadSidewalkMode::Inherit;
RoadSidewalkMode defaultSidewalkRightMode = RoadSidewalkMode::Inherit;
/**
* Entry aprons (M5.18). When enabled, a thin sloped band is
* generated along every road outer curb: it starts at the road top
@@ -314,22 +354,6 @@ struct RoadSidewalkPass {
float width = 6.0f;
};
/**
* Per-edge-side sidewalk tri-state.
*
* RoadEdge::sidewalkLeft/Right used to be plain enables ANDed with the
* global RoadConfig::sidewalkEnabled, which made it impossible to give a
* single edge a sidewalk while the global switch was off. The tri-state
* replaces that: Inherit follows the global switch, On/Off force the side
* regardless of it. The resolved value is cached in the legacy bools by
* RoadGraph::resolveSidewalkModes() for the geometry builders.
*/
enum class RoadSidewalkMode : int {
Inherit = 0, /**< Follow RoadConfig::sidewalkEnabled. */
On = 1, /**< Sidewalk on this side even if globally disabled. */
Off = 2 /**< No sidewalk on this side even if globally enabled. */
};
/**
* Per-edge road-marking tri-state (phase 2.5).
*
@@ -564,7 +588,8 @@ struct RoadEdge {
/**
* Number of lanes traveling from nodeA to nodeB.
*
* 0 means "use RoadConfig::lanesPerDirection". Positive values
* 0 means "use RoadConfig::defaultLanesAtoB" (which itself falls
* back to RoadConfig::lanesPerDirection when 0). Positive values
* override the global default for this direction only. Asymmetric
* roads are allowed (e.g. lanesAtoB = 2, lanesBtoA = 1).
*/
@@ -573,7 +598,8 @@ struct RoadEdge {
/**
* Number of lanes traveling from nodeB to nodeA.
*
* 0 means "use RoadConfig::lanesPerDirection". See lanesAtoB for
* 0 means "use RoadConfig::defaultLanesBtoA" (falling back to
* RoadConfig::lanesPerDirection). See lanesAtoB for
* asymmetric-lane semantics.
*/
int lanesBtoA = 0;
@@ -865,6 +891,9 @@ struct RoadGraph {
{
RoadSidewalkMode m = left ? e.sidewalkLeftMode :
e.sidewalkRightMode;
if (m == RoadSidewalkMode::Inherit)
m = left ? config.defaultSidewalkLeftMode :
config.defaultSidewalkRightMode;
return m == RoadSidewalkMode::On ||
(m == RoadSidewalkMode::Inherit &&
config.sidewalkEnabled);
@@ -1015,9 +1044,11 @@ struct RoadGraph {
lb = edge.lanesPerDirectionOverride;
}
if (la == 0)
la = config.lanesPerDirection;
la = config.defaultLanesAtoB > 0 ? config.defaultLanesAtoB :
config.lanesPerDirection;
if (lb == 0)
lb = config.lanesPerDirection;
lb = config.defaultLanesBtoA > 0 ? config.defaultLanesBtoA :
config.lanesPerDirection;
if (fromNodeId == edge.nodeA) {
outLanesFrom = la;
@@ -3095,6 +3095,50 @@ control for towns, areas and layouts: moved to **Phase 8.5** (they
depend on furniture in 6.5, beds/interiors in 8 and the 4.7
population/schedule systems).
**Follow-up requirements (added 2026-10-23, implemented same day)** —
road-editing UX and town-area ownership:
1. **Road defaults + batch edge editing.** `RoadConfig` gained
per-direction defaults — `defaultLanesAtoB`/`defaultLanesBtoA`
(0 falls back to legacy `lanesPerDirection`) and tri-state
`defaultSidewalkLeftMode`/`defaultSidewalkRightMode` (Inherit falls
back to legacy `sidewalkEnabled`) — serialized in the scene
roadConfig with full backward compatibility, hashed into the road
content signature, editable in the Road Config UI; inheriting edges
(0 / Inherit) follow them automatically. Multi-edge selection:
ctrl+click in the edge list or the 3D view toggles
`RoadSystem::m_selectedEdgeIndices` (plain click single-selects),
"Select Connected" flood-fills the component, node rows have
"Sel. Path" (BFS shortest path), all selected edges highlight in
the 3D view; the Batch Edit Selection panel applies lanes A→B/B→A
and sidewalk left/right modes (with per-field "keep") to the whole
selection.
2. **Town-area preview + order-independent lot ownership.** New
`RoadNetworkGen::ownerAreaIndex()`: among areas containing the lot
centroid the smallest coverage wins (circle πr² vs square
4·halfSize²), ties break by lower seed then name — content-keyed,
stable under `townAreas` reordering. `TownGen::generateTown()`
uses it instead of first-match-wins. Editor: "Area Ownership
Preview" checkbox in the Town Areas block colors every lot by its
owning area (`areaPreviewColor`, name-hash hue), draws area bounds
in matching colors, marks contested lots with an X in the winner's
color, and shows a per-area legend with owned/contested lot counts.
New headless probes: ownership identical under reversed area order,
ownerAreaIndex containment/coverage/seed/name tie-breaks
(`road_geometry_overlap_test` PASSED).
3. **Per-generation road make-up (2026-10-23).** `GridParams` gained
`lanesAtoB`/`lanesBtoA` (0 = leave the edge field unset, the
`RoadConfig` defaults apply at resolve time) and
`sidewalkLeftMode`/`sidewalkRightMode` (Inherit = unset); the Road
Network Generator UI sets them per generation run, so two
settlements in one map can have different lane counts/sidewalks
without touching the config defaults. `generateGrid()` stamps them
on every edge it creates; `RoadGraph::splitEdge()` copies the whole
`RoadEdge`, so crossing-resolution splits preserve the values.
Headless probes: params stamped on all generated edges, default
params leave edges unset, split survival
(`runGeneratorProbe`, `road_geometry_overlap_test` PASSED).
### Phase 6 — Foot traffic NPCs
This part concerns asset pipeline in assets/blender/characters in addition to the code.
@@ -1495,15 +1495,15 @@
},
"transform": {
"position": {
"x": 3992.816650390625,
"y": 10.998796463012695,
"z": 3959.3525390625
"x": 3991.31494140625,
"y": 10.990023612976074,
"z": 3959.019775390625
},
"rotation": {
"w": 0.9888275265693665,
"x": -0.0022681253030896187,
"y": -0.14902545511722565,
"z": 0.0025473441928625107
"w": 0.9888135194778442,
"x": -0.00225599086843431,
"y": -0.1491188406944275,
"z": 0.0025476780720055103
},
"scale": {
"x": 1.0,
@@ -1900,15 +1900,15 @@
},
"transform": {
"position": {
"x": 4009.110107421875,
"y": 11.092273712158203,
"z": 3966.238525390625
"x": 4007.2353515625,
"y": 11.077539443969727,
"z": 3966.094970703125
},
"rotation": {
"w": 0.7900381684303284,
"x": -0.004521117080003023,
"y": 0.6130222082138062,
"z": 0.004806817974895239
"w": 0.8054168224334717,
"x": -0.004673945717513561,
"y": 0.5926746129989624,
"z": 0.004342386499047279
},
"scale": {
"x": 1.0,
@@ -2122,6 +2122,10 @@
"crossingHeightThreshold": 10.0,
"crossingSnapDistance": 5.0,
"decalMaterialName": "RoadDecalMaterial",
"defaultLanesAtoB": 0,
"defaultLanesBtoA": 0,
"defaultSidewalkLeftMode": 0,
"defaultSidewalkRightMode": 0,
"laneWidth": 5.0,
"lanesPerDirection": 1,
"markingDashGap": 3.0,
@@ -2254,7 +2258,7 @@
0.800000011920929
],
"sunSphereSize": 5.0,
"timeOfDay": 10.760608673095703,
"timeOfDay": 20.96595573425293,
"timeSpeed": 0.12999999523162842
},
"transform": {
@@ -1,33 +1,12 @@
{
"edges": [
{
"lanesAtoB": 2,
"lanesBtoA": 2,
"lanesPerDirectionOverride": 0,
"nodeA": 61,
"nodeB": 62,
"roadLevelA": 0.0,
"roadLevelB": 0.0,
"sidewalkLeft": true,
"sidewalkLeftMode": 1,
"sidewalkRight": true,
"sidewalkRightMode": 1
}
],
"edges": [],
"nodes": [
{
"id": 61,
"id": 1,
"verticalOffset": 0.1762075424194336,
"x": 3910.80126953125,
"y": 7.148712158203125,
"z": 2962.83984375
},
{
"id": 62,
"verticalOffset": 0.22691726684570313,
"x": 3951.0302734375,
"y": 9.944311141967773,
"z": 3000.0
}
],
"page": [
File diff suppressed because it is too large Load Diff
@@ -3,7 +3,7 @@
"nodes": [
{
"generatedGroupId": 2,
"id": 42,
"id": 57,
"verticalOffset": 0.0,
"x": 4048.19775390625,
"y": 7.354270935058594,
@@ -16,6 +16,51 @@
"village"
],
"radius": 500.0,
"seed": 1,
"town": "Pensville"
},
{
"center": [
4664.90185546875,
11.98272705078125,
3714.66748046875
],
"circular": false,
"densityScale": 1.0,
"district": 0,
"halfSize": 250.0,
"name": "Area 2",
"preferredTypes": [
"house",
"village"
],
"radius": 500.0,
"seed": 1,
"town": "Pensville"
},
{
"center": [
3401.02001953125,
21.564054489135742,
4221.458984375
],
"circular": false,
"densityScale": 1.0,
"district": 0,
"halfSize": 250.0,
"name": "Area 3",
"preferredTypes": [
"shop",
"commercial"
],
"radius": 500.0,
"seed": 1,
"town": "Pensville"
}
],
"towns": [
{
"name": "Pensville",
"seed": 1
}
]
File diff suppressed because one or more lines are too long
@@ -924,7 +924,17 @@ uint32_t generateGrid(RoadGraph &graph, const GridParams &params,
if (run < 1e-4f || rise / run > params.maxSlope)
return; /* too steep */
}
graph.addEdge(a, b);
int ei = graph.addEdge(a, b);
if (ei >= 0) {
/* Stamp this generation's road make-up (phase 5.9
* follow-up); 0/Inherit leave the edge fields unset so
* the RoadConfig defaults apply. */
RoadEdge &e = graph.edges[(size_t)ei];
e.lanesAtoB = params.lanesAtoB;
e.lanesBtoA = params.lanesBtoA;
e.sidewalkLeftMode = params.sidewalkLeftMode;
e.sidewalkRightMode = params.sidewalkRightMode;
}
};
for (int i = 0; i <= params.blocksX; ++i)
for (int j = 0; j <= params.blocksZ; ++j) {
@@ -1193,4 +1203,37 @@ uint32_t effectiveAreaSeed(const RoadGraph &graph, const RoadTownArea &area)
return area.seed;
}
int ownerAreaIndex(const std::vector<RoadTownArea> &areas,
float x, float z)
{
int best = -1;
double bestCoverage = 0.0;
for (size_t i = 0; i < areas.size(); ++i) {
const RoadTownArea &a = areas[i];
if (!a.contains(x, z))
continue;
double coverage = a.circular ?
3.14159265358979323846 * (double)a.radius *
(double)a.radius :
4.0 * (double)a.halfSize * (double)a.halfSize;
if (best < 0 || coverage < bestCoverage) {
best = (int)i;
bestCoverage = coverage;
continue;
}
if (coverage > bestCoverage)
continue;
/* Exact coverage tie: lower seed, then name. */
const RoadTownArea &b = areas[best];
if (a.seed != b.seed) {
if (a.seed < b.seed)
best = (int)i;
continue;
}
if (a.name < b.name)
best = (int)i;
}
return best;
}
} /* namespace RoadNetworkGen */
@@ -200,6 +200,21 @@ struct GridParams {
float maxSlope = 0.35f;
/** Run resolveAllCrossings after generating. */
bool runCrossingResolution = true;
/**
* Per-generation road make-up (phase 5.9 follow-up): the lanes and
* sidewalk modes stamped on every edge THIS generation creates, so
* two grids in one map can differ without touching the RoadConfig
* defaults. A lane count of 0 leaves the edge field unset (it
* inherits RoadConfig::defaultLanesAtoB/lanesPerDirection at resolve
* time); a sidewalk mode of Inherit leaves the edge mode unset.
* RoadGraph::splitEdge copies the whole RoadEdge, so the crossing
* resolution's splits preserve the stamped values.
*/
int lanesAtoB = 0;
int lanesBtoA = 0;
RoadSidewalkMode sidewalkLeftMode = RoadSidewalkMode::Inherit;
RoadSidewalkMode sidewalkRightMode = RoadSidewalkMode::Inherit;
};
/** Terrain height sampler: render-space (x, z) -> surface Y. */
@@ -274,6 +289,40 @@ void townAreasFromJson(RoadGraph &graph, const nlohmann::json &j);
uint32_t effectiveAreaSeed(const RoadGraph &graph,
const RoadTownArea &area);
/**
* Owning area of a lot centroid (phase 5.9 follow-up): a lot belongs
* to exactly ONE area. Among the areas containing (x, z) the owner
* is the one with the smallest coverage (circle pi*r^2, square
* 4*halfSize^2 — the most specific area wins); ties break by lower
* seed, then by lexicographic name. All keys are area content, so
* the result is stable under townAreas list reordering
* (order-independent). Note: a full (coverage, seed) tie makes the
* choice name-dependent, so give areas distinct seeds.
*
* Returns the index into areas, or -1 when no area contains the
* point. TownGen::generateTown() and the editor area-ownership
* preview both go through this so they never disagree.
*/
int ownerAreaIndex(const std::vector<RoadTownArea> &areas,
float x, float z);
/**
* Deterministic preview color for an area (editor area-ownership
* preview): FNV-1a hash of the area name mapped to an HSB hue, so the
* color is stable under townAreas list reordering.
*/
inline Ogre::ColourValue areaPreviewColor(const std::string &name)
{
uint32_t h = 2166136261u;
for (char c : name) {
h ^= (uint8_t)c;
h *= 16777619u;
}
Ogre::ColourValue col;
col.setHSB((float)(h % 256) / 255.0f, 0.75f, 1.0f);
return col;
}
} /* namespace RoadNetworkGen */
#endif /* EDITSCENE_ROADNETWORKGEN_HPP */
+10 -17
View File
@@ -368,25 +368,18 @@ TownResult generateTown(const RoadGraph &graph, const TownParams &params,
streetNumbers.clear();
}
/* Phase 4.5 item 4: town-area scoping. The lot belongs
* to the area containing its centroid (first-match-wins
* over overlapping areas); only that area's run builds
* it. No RNG draws happen before this point, so
* filtering does not disturb determinism. */
/* Phase 4.5 item 4 / phase 5.9 follow-up: town-area
* scoping. The lot belongs to exactly one area — the
* deterministic ownerAreaIndex() winner (smallest
* containing coverage, then lower seed, then name;
* order-independent). Only the owner's run builds it.
* No RNG draws happen before this point, so filtering
* does not disturb determinism. */
if (areas && areaIndex >= 0 &&
areaIndex < (int)areas->size()) {
if (!(*areas)[areaIndex].contains(lot.centroid.x,
lot.centroid.z))
continue;
bool ownedEarlier = false;
for (int k = 0; k < areaIndex; ++k) {
if ((*areas)[k].contains(lot.centroid.x,
lot.centroid.z)) {
ownedEarlier = true;
break;
}
}
if (ownedEarlier)
if (RoadNetworkGen::ownerAreaIndex(
*areas, lot.centroid.x,
lot.centroid.z) != areaIndex)
continue;
}
@@ -2528,8 +2528,15 @@ void EditorUISystem::handleRoadEditClick(const Ogre::Ray &mouseRay)
int edgeIdx = pickRoadEdge(hit);
if (edgeIdx >= 0) {
rs->setSelectedEdgeIndex(edgeIdx);
rs->setSelectedNodeId(-1);
/* Ctrl+click toggles the edge in the batch multi-selection
* (phase 5.9 follow-up); plain click single-selects. */
if (ImGui::GetIO().KeyCtrl) {
rs->setSelectedNodeId(-1);
rs->toggleEdgeSelected(edgeIdx);
} else {
rs->setSelectedEdgeIndex(edgeIdx);
rs->setSelectedNodeId(-1);
}
m_roadGizmo->setVisible(false);
return;
}
+169 -6
View File
@@ -218,12 +218,130 @@ void RoadSystem::setSelectedNodeId(int id)
void RoadSystem::setSelectedEdgeIndex(int idx)
{
if (m_selectedEdgeIndex != idx) {
bool setChanged = false;
if (idx >= 0)
setChanged = m_selectedEdgeIndices != std::set<int>{ idx };
else
setChanged = !m_selectedEdgeIndices.empty();
if (m_selectedEdgeIndex != idx || setChanged) {
m_selectedEdgeIndex = idx;
m_selectedEdgeIndices.clear();
if (idx >= 0)
m_selectedEdgeIndices.insert(idx);
rebuildVisualAids();
}
}
void RoadSystem::toggleEdgeSelected(int idx)
{
if (idx < 0)
return;
if (m_selectedEdgeIndices.count(idx)) {
m_selectedEdgeIndices.erase(idx);
if (m_selectedEdgeIndex == idx)
m_selectedEdgeIndex = m_selectedEdgeIndices.empty() ?
-1 : *m_selectedEdgeIndices.rbegin();
} else {
m_selectedEdgeIndices.insert(idx);
m_selectedEdgeIndex = idx;
}
rebuildVisualAids();
}
void RoadSystem::clearEdgeSelection()
{
if (m_selectedEdgeIndices.empty() && m_selectedEdgeIndex < 0)
return;
m_selectedEdgeIndices.clear();
m_selectedEdgeIndex = -1;
rebuildVisualAids();
}
int RoadSystem::selectConnectedComponent(int edgeIdx)
{
flecs::entity terrain = getTerrainEntity();
if (!terrain.is_alive() || !terrain.has<TerrainComponent>())
return 0;
const RoadGraph &rg = terrain.get<TerrainComponent>().roadGraph;
if (edgeIdx < 0 || edgeIdx >= (int)rg.edges.size())
return 0;
std::unordered_map<int, std::vector<int>> byNode;
for (size_t i = 0; i < rg.edges.size(); ++i) {
byNode[rg.edges[i].nodeA].push_back((int)i);
byNode[rg.edges[i].nodeB].push_back((int)i);
}
std::set<int> visited;
std::vector<int> queue;
queue.push_back(edgeIdx);
visited.insert(edgeIdx);
while (!queue.empty()) {
int ei = queue.back();
queue.pop_back();
const RoadEdge &e = rg.edges[ei];
const int endNodes[2] = { e.nodeA, e.nodeB };
for (int n : endNodes) {
for (int next : byNode[n]) {
if (visited.insert(next).second)
queue.push_back(next);
}
}
}
m_selectedEdgeIndices = visited;
m_selectedEdgeIndex = *visited.rbegin();
rebuildVisualAids();
return (int)visited.size();
}
int RoadSystem::selectPathBetweenNodes(int nodeA, int nodeB)
{
flecs::entity terrain = getTerrainEntity();
if (!terrain.is_alive() || !terrain.has<TerrainComponent>())
return 0;
const RoadGraph &rg = terrain.get<TerrainComponent>().roadGraph;
if (nodeA == nodeB || !rg.findNodeById(nodeA) ||
!rg.findNodeById(nodeB))
return 0;
/* node -> (neighbor node, edge index) adjacency */
std::unordered_map<int, std::vector<std::pair<int, int>>> adj;
for (size_t i = 0; i < rg.edges.size(); ++i) {
adj[rg.edges[i].nodeA].push_back(
std::make_pair(rg.edges[i].nodeB, (int)i));
adj[rg.edges[i].nodeB].push_back(
std::make_pair(rg.edges[i].nodeA, (int)i));
}
/* BFS shortest path by edge count, parent map per node. */
std::unordered_map<int, std::pair<int, int>> parent;
std::vector<int> queue;
queue.push_back(nodeA);
parent[nodeA] = std::make_pair(-1, -1);
size_t head = 0;
while (head < queue.size() && parent.find(nodeB) == parent.end()) {
int cur = queue[head++];
for (const auto &next : adj[cur]) {
if (parent.find(next.first) != parent.end())
continue;
parent[next.first] = std::make_pair(cur, next.second);
queue.push_back(next.first);
}
}
if (parent.find(nodeB) == parent.end())
return 0;
std::set<int> path;
for (int n = nodeB; parent[n].first >= 0; n = parent[n].first)
path.insert(parent[n].second);
m_selectedEdgeIndices = path;
m_selectedEdgeIndex = *path.rbegin();
rebuildVisualAids();
return (int)path.size();
}
void RoadSystem::setDebugWedgeEnabled(bool v)
{
if (m_debugWedgeEnabled != v) {
@@ -1274,8 +1392,12 @@ uint64_t RoadSystem::computePageSignature(uint64_t key) const
const RoadConfig &cfg = graph.config;
fnvHashPod(h, cfg.laneWidth);
fnvHashPod(h, cfg.lanesPerDirection);
fnvHashPod(h, cfg.defaultLanesAtoB);
fnvHashPod(h, cfg.defaultLanesBtoA);
fnvHashPod(h, cfg.roadThickness);
fnvHashPod(h, cfg.sidewalkEnabled);
fnvHashPod(h, cfg.defaultSidewalkLeftMode);
fnvHashPod(h, cfg.defaultSidewalkRightMode);
fnvHashPod(h, cfg.sidewalkWidth);
fnvHashPod(h, cfg.sidewalkHeight);
fnvHashPod(h, cfg.sidewalkThickness);
@@ -2326,7 +2448,7 @@ void RoadSystem::buildEdgeLines()
if (!na || !nb)
continue;
bool selected = ((int)i == m_selectedEdgeIndex);
bool selected = m_selectedEdgeIndices.count((int)i) != 0;
Ogre::ColourValue color = selected ?
Ogre::ColourValue(1.0f, 0.0f, 1.0f) :
Ogre::ColourValue(0.0f, 1.0f, 1.0f);
@@ -2350,15 +2472,32 @@ void RoadSystem::buildLotOutlines()
/* Potential building lots (RoadNetworkGen::findLots with the
* default 45 m / 8-edge filter and edge-fallback squares) drawn as
* dark grey outline loops, lifted slightly above the road surface
* like the other helper geometry. */
* like the other helper geometry. With the area-ownership preview
* enabled (phase 5.9 follow-up) each lot instead takes its owning
* area's color (ownerAreaIndex: smallest containing coverage, then
* seed, then name), and contested lots (inside >= 2 areas) get an
* X marker at the centroid in the winner's color. */
std::vector<RoadNetworkGen::RoadLot> lots =
RoadNetworkGen::findLots(rg);
m_lotOutlines->begin("Ogre/AxisGizmo",
Ogre::RenderOperation::OT_LINE_LIST);
m_lotOutlines->colour(Ogre::ColourValue(0.1f, 0.1f, 0.1f));
for (const RoadNetworkGen::RoadLot &lot : lots) {
int owner = -1;
int containers = 0;
if (m_areaOwnershipPreview && !rg.townAreas.empty()) {
owner = RoadNetworkGen::ownerAreaIndex(
rg.townAreas, lot.centroid.x, lot.centroid.z);
for (const RoadTownArea &area : rg.townAreas)
if (area.contains(lot.centroid.x,
lot.centroid.z))
++containers;
}
m_lotOutlines->colour(owner >= 0 ?
RoadNetworkGen::areaPreviewColor(
rg.townAreas[owner].name) :
Ogre::ColourValue(0.1f, 0.1f, 0.1f));
size_t n = lot.polygon.size();
for (size_t i = 0; i < n; ++i) {
Ogre::Vector3 a = lot.polygon[i];
@@ -2368,12 +2507,25 @@ void RoadSystem::buildLotOutlines()
m_lotOutlines->position(a);
m_lotOutlines->position(b);
}
if (containers >= 2 && owner >= 0) {
/* Contested lot: X marker at the centroid in the
* winner's color. */
Ogre::Vector3 c = lot.centroid + Ogre::Vector3(0, 0.4f, 0);
const float r = 1.5f;
m_lotOutlines->position(c + Ogre::Vector3(-r, 0, -r));
m_lotOutlines->position(c + Ogre::Vector3(r, 0, r));
m_lotOutlines->position(c + Ogre::Vector3(-r, 0, r));
m_lotOutlines->position(c + Ogre::Vector3(r, 0, -r));
}
}
/* Town generation areas (phase 4.5 item 4): dark blue loops,
* squares as 4 corners, circles as 64-segment polylines. */
m_lotOutlines->colour(Ogre::ColourValue(0.1f, 0.1f, 0.3f));
* squares as 4 corners, circles as 64-segment polylines — or the
* area's preview color when the ownership preview is on. */
for (const RoadTownArea &area : rg.townAreas) {
m_lotOutlines->colour(m_areaOwnershipPreview ?
RoadNetworkGen::areaPreviewColor(area.name) :
Ogre::ColourValue(0.1f, 0.1f, 0.3f));
if (area.circular) {
const int segs = 64;
for (int i = 0; i < segs; ++i) {
@@ -2467,6 +2619,17 @@ void RoadSystem::buildSelectionHighlight()
m_selectionHighlight->position(pos + Ogre::Vector3(0, 2.0f, 0));
}
/* Multi-edge selection (phase 5.9 follow-up): full lines over every
* selected edge, plus the direction arrow on the primary edge. */
m_selectionHighlight->colour(Ogre::ColourValue(1.0f, 0.0f, 1.0f));
for (int ei : m_selectedEdgeIndices) {
Ogre::Vector3 a, b;
if (!getEdgePositions(ei, a, b))
continue;
m_selectionHighlight->position(a + Ogre::Vector3(0, 0.4f, 0));
m_selectionHighlight->position(b + Ogre::Vector3(0, 0.4f, 0));
}
if (m_selectedEdgeIndex >= 0) {
Ogre::Vector3 a, b;
if (getEdgePositions(m_selectedEdgeIndex, a, b)) {
@@ -141,6 +141,56 @@ public:
int getSelectedEdgeIndex() const { return m_selectedEdgeIndex; }
void setSelectedEdgeIndex(int idx);
/**
* Area-ownership preview (phase 5.9 follow-up): when enabled the
* lot-outline overlay colors every findLots() lot by its owning
* town area (RoadNetworkGen::ownerAreaIndex), draws area bounds in
* matching colors and marks contested lots (inside >= 2 areas)
* with an X in the winner's color.
*/
bool getAreaOwnershipPreview() const
{
return m_areaOwnershipPreview;
}
void setAreaOwnershipPreview(bool v)
{
if (m_areaOwnershipPreview != v) {
m_areaOwnershipPreview = v;
rebuildVisualAids();
}
}
/**
* Multi-edge selection for batch editing (phase 5.9 follow-up).
*
* selectedEdgeIndex remains the "primary" edge whose inspector the
* panel shows; the set is what batch operations apply to. Plain
* setSelectedEdgeIndex() replaces the set with the single edge (or
* clears it for -1); toggleEdgeSelected() ctrl-click style adds or
* removes one edge and makes it the primary when added.
*/
const std::set<int> &getSelectedEdgeIndices() const
{
return m_selectedEdgeIndices;
}
bool isEdgeSelected(int idx) const
{
return m_selectedEdgeIndices.count(idx) != 0;
}
void toggleEdgeSelected(int idx);
void clearEdgeSelection();
/**
* Select every edge reachable from edgeIdx via shared nodes (BFS).
* Returns the number of edges selected.
*/
int selectConnectedComponent(int edgeIdx);
/**
* Select the edges of the shortest path (by edge count, BFS)
* between two nodes. Returns the number of edges selected, 0 when
* no path exists.
*/
int selectPathBetweenNodes(int nodeA, int nodeB);
/**
* Last connectNodes failure from the 3D Connect tool. The terrain
* editor panel consumes it with takeConnectError() and shows it as
@@ -627,6 +677,11 @@ private:
bool m_visualAidsVisible = true;
int m_selectedNodeId = -1;
int m_selectedEdgeIndex = -1;
/* Multi-edge selection (phase 5.9 follow-up); always contains
* m_selectedEdgeIndex when that is >= 0. */
std::set<int> m_selectedEdgeIndices;
/* Area-ownership preview toggle (phase 5.9 follow-up). */
bool m_areaOwnershipPreview = false;
uint64_t m_lastGraphVersion = 0;
std::string m_connectError;
bool m_connectErrorPending = false;
@@ -4838,6 +4838,8 @@ nlohmann::json SceneSerializer::serializeTerrain(flecs::entity entity)
roadConfigJson["roadMeshTemplate"] = tc.roadGraph.config.roadMeshTemplate;
roadConfigJson["laneWidth"] = tc.roadGraph.config.laneWidth;
roadConfigJson["lanesPerDirection"] = tc.roadGraph.config.lanesPerDirection;
roadConfigJson["defaultLanesAtoB"] = tc.roadGraph.config.defaultLanesAtoB;
roadConfigJson["defaultLanesBtoA"] = tc.roadGraph.config.defaultLanesBtoA;
roadConfigJson["roadThickness"] = tc.roadGraph.config.roadThickness;
roadConfigJson["roadLodDistance"] = tc.roadGraph.config.roadLodDistance;
roadConfigJson["roadVisibilityDistance"] =
@@ -4850,6 +4852,10 @@ nlohmann::json SceneSerializer::serializeTerrain(flecs::entity entity)
tc.roadGraph.config.sidewalkThickness;
roadConfigJson["sidewalkMeshTemplate"] =
tc.roadGraph.config.sidewalkMeshTemplate;
roadConfigJson["defaultSidewalkLeftMode"] =
(int)tc.roadGraph.config.defaultSidewalkLeftMode;
roadConfigJson["defaultSidewalkRightMode"] =
(int)tc.roadGraph.config.defaultSidewalkRightMode;
roadConfigJson["apronEnabled"] = tc.roadGraph.config.apronEnabled;
roadConfigJson["apronWidth"] = tc.roadGraph.config.apronWidth;
roadConfigJson["apronDrop"] = tc.roadGraph.config.apronDrop;
@@ -5085,6 +5091,10 @@ void SceneSerializer::deserializeTerrain(flecs::entity entity,
tc.roadGraph.config.laneWidth = rcj.value("laneWidth", 3.0f);
tc.roadGraph.config.lanesPerDirection =
rcj.value("lanesPerDirection", 1);
tc.roadGraph.config.defaultLanesAtoB =
rcj.value("defaultLanesAtoB", 0);
tc.roadGraph.config.defaultLanesBtoA =
rcj.value("defaultLanesBtoA", 0);
tc.roadGraph.config.roadThickness = rcj.value("roadThickness", 0.3f);
tc.roadGraph.config.roadLodDistance = rcj.value("roadLodDistance", 200.0f);
tc.roadGraph.config.roadVisibilityDistance =
@@ -5101,6 +5111,10 @@ void SceneSerializer::deserializeTerrain(flecs::entity entity,
rcj.value("sidewalkThickness", 0.3f);
tc.roadGraph.config.sidewalkMeshTemplate =
rcj.value("sidewalkMeshTemplate", "");
tc.roadGraph.config.defaultSidewalkLeftMode =
(RoadSidewalkMode)rcj.value("defaultSidewalkLeftMode", 0);
tc.roadGraph.config.defaultSidewalkRightMode =
(RoadSidewalkMode)rcj.value("defaultSidewalkRightMode", 0);
tc.roadGraph.config.apronEnabled =
rcj.value("apronEnabled", false);
tc.roadGraph.config.apronWidth = rcj.value("apronWidth", 1.0f);
@@ -4785,7 +4785,9 @@ static bool runTownGenProbe()
}
/* Phase 4.5 item 4: town areas scope generation by lot centroid,
* first-match-wins over overlapping areas. */
* deterministic owner over overlapping areas (phase 5.9
* follow-up: RoadNetworkGen::ownerAreaIndex — smallest containing
* coverage, then lower seed, then name; order-independent). */
{
RoadGraph graph;
buildTown(graph);
@@ -4815,8 +4817,10 @@ static bool runTownGenProbe()
/* A: left enclosed pair + left fallback pair = 4 lots.
* B's circle overlaps A on the (50,*) enclosed lots but
* loses them first-match-wins: right enclosed pair +
* right fallback pair = 4 lots. The top/bottom fallback
* loses them — A's square (coverage 4*60^2 = 14400) is
* smaller than B's circle (pi*120^2 ~= 45239), so A owns
* the contested lots: right enclosed pair + right
* fallback pair = 4 lots. The top/bottom fallback
* squares lie outside both areas and build nothing. */
if (resA.buildings.size() != 4 || resB.buildings.size() != 4) {
printf(" *** town areas: A=%zu B=%zu, want 4/4 ***\n",
@@ -4839,8 +4843,9 @@ static bool runTownGenProbe()
}
}
/* First-match-wins: the two area runs own disjoint lot
* sets (the overlap lots belong to A). */
/* Disjoint ownership: the two area runs own disjoint lot
* sets (the contested lots belong to A, the smaller
* containing area). */
std::set<int> lotsA, lotsB;
for (const TownGen::TownBuilding &b : resA.buildings)
lotsA.insert(b.lotIndex);
@@ -4857,6 +4862,87 @@ static bool runTownGenProbe()
ok = false;
}
/* Phase 5.9 follow-up: ownership is order-independent.
* Reversing townAreas must swap the per-area indices but
* keep each area's lot set identical. */
{
RoadGraph graphRev = graph;
std::reverse(graphRev.townAreas.begin(),
graphRev.townAreas.end());
TownGen::TownResult resAr =
TownGen::generateTown(graphRev, cc, flat,
nullptr,
&graphRev.townAreas, 1);
TownGen::TownResult resBr =
TownGen::generateTown(graphRev, cc, flat,
nullptr,
&graphRev.townAreas, 0);
std::set<int> lotsAr, lotsBr;
for (const TownGen::TownBuilding &b :
resAr.buildings)
lotsAr.insert(b.lotIndex);
for (const TownGen::TownBuilding &b :
resBr.buildings)
lotsBr.insert(b.lotIndex);
if (lotsAr != lotsA || lotsBr != lotsB) {
printf(" *** town areas: ownership changed "
"under reorder (A %zu->%zu, B %zu->%zu) "
"***\n",
lotsA.size(), lotsAr.size(),
lotsB.size(), lotsBr.size());
ok = false;
}
}
/* ownerAreaIndex unit probes: containment, smallest-
* coverage win, seed and name tie-breaks — all
* independent of list order. */
{
const std::vector<RoadTownArea> &areas =
graph.townAreas;
using RoadNetworkGen::ownerAreaIndex;
/* (50,100): inside A (square) and B (circle) —
* A is smaller. (-30,100): A only.
* (250,100): B only. (500,500): none. */
if (ownerAreaIndex(areas, 50.0f, 100.0f) != 0 ||
ownerAreaIndex(areas, -30.0f, 100.0f) != 0 ||
ownerAreaIndex(areas, 250.0f, 100.0f) != 1 ||
ownerAreaIndex(areas, 500.0f, 500.0f) != -1) {
printf(" *** ownerAreaIndex: containment/"
"coverage ***\n");
ok = false;
}
/* Seed tie-break: identical squares, lower seed
* wins regardless of order. */
RoadTownArea s1, s2;
s1.name = "s1";
s2.name = "s2";
s1.center = s2.center = Vector3(0, 0, 0);
s1.halfSize = s2.halfSize = 50.0f;
s1.seed = 7;
s2.seed = 3;
std::vector<RoadTownArea> v1 = { s1, s2 };
std::vector<RoadTownArea> v2 = { s2, s1 };
int o1 = ownerAreaIndex(v1, 0.0f, 0.0f);
int o2 = ownerAreaIndex(v2, 0.0f, 0.0f);
if (o1 != 1 || o2 != 0 ||
v1[o1].name != "s2" || v2[o2].name != "s2") {
printf(" *** ownerAreaIndex: seed "
"tie-break ***\n");
ok = false;
}
/* Name tie-break: identical coverage and seed,
* lexicographically smaller name wins. */
s2.seed = s1.seed;
std::vector<RoadTownArea> v3 = { s2, s1 };
int o3 = ownerAreaIndex(v3, 0.0f, 0.0f);
if (o3 < 0 || v3[o3].name != "s1") {
printf(" *** ownerAreaIndex: name "
"tie-break ***\n");
ok = false;
}
}
/* townAreas json round trip (phase 5.9: towns + preferred
* types included). */
{
@@ -6434,6 +6520,67 @@ static bool runGeneratorProbe()
}
}
/* Per-generation lanes/sidewalk stamping (phase 5.9 follow-up):
* the params land on every edge this generation creates, and
* crossing-resolution splits preserve them (splitEdge copies the
* whole RoadEdge). */
{
GridParams ps = p;
ps.jitter = 0.0f;
ps.lanesAtoB = 2;
ps.lanesBtoA = 1;
ps.sidewalkLeftMode = RoadSidewalkMode::On;
ps.sidewalkRightMode = RoadSidewalkMode::Off;
RoadGraph gStamp;
generateGrid(gStamp, ps, HeightSampler(), nullptr);
bool stamped = !gStamp.edges.empty();
for (const RoadEdge &e : gStamp.edges) {
if (e.lanesAtoB != 2 || e.lanesBtoA != 1 ||
e.sidewalkLeftMode != RoadSidewalkMode::On ||
e.sidewalkRightMode != RoadSidewalkMode::Off) {
stamped = false;
break;
}
}
if (!stamped) {
printf(" *** stamping: generated edges lack the "
"params ***\n");
ok = false;
}
/* Defaults (0/Inherit) leave the edge fields unset so the
* RoadConfig defaults apply. */
bool unset = true;
for (const RoadEdge &e : g1.edges) {
if (e.lanesAtoB != 0 || e.lanesBtoA != 0 ||
e.sidewalkLeftMode != RoadSidewalkMode::Inherit ||
e.sidewalkRightMode != RoadSidewalkMode::Inherit) {
unset = false;
break;
}
}
if (!unset) {
printf(" *** stamping: default params dirtied the "
"edges ***\n");
ok = false;
}
/* Split survival: split a stamped edge, both halves must
* keep the stamped values. */
int splitId = gStamp.splitEdge(0, 0.5f);
bool splitOk = splitId > 0;
for (const RoadEdge &e : gStamp.edges) {
if (e.lanesAtoB != 2 || e.lanesBtoA != 1 ||
e.sidewalkLeftMode != RoadSidewalkMode::On ||
e.sidewalkRightMode != RoadSidewalkMode::Off) {
splitOk = false;
break;
}
}
if (!splitOk) {
printf(" *** stamping: split lost the params ***\n");
ok = false;
}
}
printf(" %s\n", ok ? "passed" : "FAILURES above");
return ok;
}
+309 -12
View File
@@ -1319,6 +1319,24 @@ private:
cfgChanged |= ImGui::SliderInt(
"Lanes Per Direction",
&rg.config.lanesPerDirection, 1, 8);
cfgChanged |= ImGui::SliderInt(
"Default Lanes A->B",
&rg.config.defaultLanesAtoB, 0, 8);
if (ImGui::IsItemHovered())
ImGui::SetTooltip(
"Default lane count for the edge A->B "
"direction (0 = use Lanes Per "
"Direction). Edges with Lanes A->B = 0 "
"inherit this.");
cfgChanged |= ImGui::SliderInt(
"Default Lanes B->A",
&rg.config.defaultLanesBtoA, 0, 8);
if (ImGui::IsItemHovered())
ImGui::SetTooltip(
"Default lane count for the edge B->A "
"direction (0 = use Lanes Per "
"Direction). Edges with Lanes B->A = 0 "
"inherit this.");
cfgChanged |= ImGui::SliderFloat(
"Road Thickness", &rg.config.roadThickness, 0.05f,
1.0f, "%.2f");
@@ -1342,7 +1360,45 @@ private:
ImGui::SeparatorText("Sidewalks");
cfgChanged |= ImGui::Checkbox(
"Sidewalks Enabled", &rg.config.sidewalkEnabled);
if (rg.config.sidewalkEnabled) {
{
/* Per-side defaults (phase 5.9 follow-up): edges
* whose sidewalk mode is Inherit resolve against
* these; Inherit here falls back to the legacy
* "Sidewalks Enabled" switch. */
const char *swModes[3] = { "Inherit", "On",
"Off" };
int dl = (int)rg.config.defaultSidewalkLeftMode;
if (ImGui::Combo("Default Sidewalk Left", &dl,
swModes, 3)) {
rg.config.defaultSidewalkLeftMode =
(RoadSidewalkMode)dl;
cfgChanged = true;
}
if (ImGui::IsItemHovered())
ImGui::SetTooltip(
"Default for the left side (looking "
"from node A to node B) of edges in "
"Inherit mode; Inherit here uses "
"Sidewalks Enabled.");
int dr = (int)rg.config.defaultSidewalkRightMode;
if (ImGui::Combo("Default Sidewalk Right", &dr,
swModes, 3)) {
rg.config.defaultSidewalkRightMode =
(RoadSidewalkMode)dr;
cfgChanged = true;
}
if (ImGui::IsItemHovered())
ImGui::SetTooltip(
"Default for the right side (looking "
"from node A to node B) of edges in "
"Inherit mode; Inherit here uses "
"Sidewalks Enabled.");
}
if (rg.config.sidewalkEnabled ||
rg.config.defaultSidewalkLeftMode ==
RoadSidewalkMode::On ||
rg.config.defaultSidewalkRightMode ==
RoadSidewalkMode::On) {
cfgChanged |= ImGui::SliderFloat(
"Sidewalk Width",
&rg.config.sidewalkWidth, 0.5f, 5.0f);
@@ -1453,6 +1509,11 @@ private:
static bool s_pickArmed = false;
static bool s_preview = false;
static std::string s_genResult;
/* Per-generation road make-up (phase 5.9 follow-up):
* stamped on the edges this generation creates, not
* the RoadConfig defaults. 0 lanes = inherit config. */
static int s_genLanesAtoB = 0, s_genLanesBtoA = 0;
static int s_genSidewalkLeft = 0, s_genSidewalkRight = 0;
bool paramsChanged =
ImGui::InputFloat2("Origin XZ", s_origin);
@@ -1510,6 +1571,28 @@ private:
1.0f, "%.2f");
ImGui::Checkbox("Resolve Crossings", &s_resolve);
/* Lanes/sidewalks stamped on THIS generation's edges
* (0/Inherit leave the edge fields unset so the
* RoadConfig defaults apply at resolve time). */
paramsChanged |= ImGui::SliderInt("Lanes A->B##gen",
&s_genLanesAtoB, 0, 8);
paramsChanged |= ImGui::SliderInt("Lanes B->A##gen",
&s_genLanesBtoA, 0, 8);
const char *genSideItems[3] = { "Inherit", "On", "Off" };
paramsChanged |= ImGui::Combo("Sidewalk Left##gen",
&s_genSidewalkLeft,
genSideItems, 3);
paramsChanged |= ImGui::Combo("Sidewalk Right##gen",
&s_genSidewalkRight,
genSideItems, 3);
if (ImGui::IsItemHovered())
ImGui::SetTooltip(
"Road make-up for the edges this generation "
"creates. 0 lanes / Inherit sidewalk leave the "
"edge fields unset so the RoadConfig defaults "
"apply; nonzero/On/Off stamp every generated "
"edge (survives crossing-resolution splits).");
auto buildParams = [&]() {
RoadNetworkGen::GridParams p;
p.origin = Ogre::Vector3(s_origin[0], 0.0f,
@@ -1523,6 +1606,12 @@ private:
p.avoidWater = s_avoidWater;
p.maxSlope = s_maxSlope;
p.runCrossingResolution = s_resolve;
p.lanesAtoB = s_genLanesAtoB;
p.lanesBtoA = s_genLanesBtoA;
p.sidewalkLeftMode =
(RoadSidewalkMode)s_genSidewalkLeft;
p.sidewalkRightMode =
(RoadSidewalkMode)s_genSidewalkRight;
return p;
};
@@ -1831,11 +1920,101 @@ private:
ImGui::Separator();
/* Phase 4.5 item 4: town areas — named square/circular
* generation scopes carried by the road graph. A lot is
* built by the area containing its centroid
* (first-match-wins over overlapping areas).
* built by the area owning its centroid
* (RoadNetworkGen::ownerAreaIndex — smallest
* containing coverage, then seed, then name;
* order-independent).
* "Generate All Areas" builds one "TownGen <area>"
* container per area. */
ImGui::TextUnformatted("Town Areas");
/* Area-ownership preview (phase 5.9 follow-up):
* viewport overlay coloring each lot by its owning
* area, plus a legend with per-area lot counts. */
{
bool ownPreview = rs->getAreaOwnershipPreview();
if (ImGui::Checkbox("Area Ownership Preview",
&ownPreview))
rs->setAreaOwnershipPreview(ownPreview);
if (ImGui::IsItemHovered())
ImGui::SetTooltip(
"Color every lot by the area that "
"owns it and draw area bounds in "
"matching colors. Lots inside 2+ "
"areas get an X marker in the "
"winning area's color.");
if (ownPreview && !rg.townAreas.empty()) {
static uint64_t s_ownVersion = ~0ull;
static size_t s_ownAreaCount = 0;
static std::vector<int> s_ownLots;
static std::vector<int> s_ownContested;
if (s_ownVersion != rg.version ||
s_ownAreaCount !=
rg.townAreas.size()) {
s_ownVersion = rg.version;
s_ownAreaCount =
rg.townAreas.size();
s_ownLots.assign(
rg.townAreas.size(), 0);
s_ownContested.assign(
rg.townAreas.size(), 0);
std::vector<RoadNetworkGen::
RoadLot>
lots = RoadNetworkGen::
findLots(rg);
for (const auto &lot : lots) {
int own = RoadNetworkGen::
ownerAreaIndex(
rg.townAreas,
lot.centroid
.x,
lot.centroid
.z);
if (own < 0)
continue;
s_ownLots[own]++;
int cont = 0;
for (const auto &a :
rg.townAreas)
if (a.contains(
lot.centroid
.x,
lot.centroid
.z))
++cont;
if (cont >= 2)
s_ownContested
[own]++;
}
}
for (size_t ai = 0;
ai < rg.townAreas.size(); ++ai) {
const RoadTownArea &a =
rg.townAreas[ai];
Ogre::ColourValue c =
RoadNetworkGen::
areaPreviewColor(
a.name);
/* Unique ID scope per legend row:
* without it every row's color
* button shares "##owncolor" and
* ImGui flags an ID conflict. */
ImGui::PushID((int)ai);
ImGui::ColorButton(
"##owncolor",
ImVec4(c.r, c.g, c.b,
1.0f),
ImGuiColorEditFlags_NoTooltip,
ImVec2(16, 16));
ImGui::SameLine();
ImGui::Text(
"%s: %d lots (%d contested)",
a.name.c_str(),
s_ownLots[ai],
s_ownContested[ai]);
ImGui::PopID();
}
}
}
static int s_areaPick = -1;
const char *shapes[] = { "Square", "Circle" };
int removeArea = -1;
@@ -2680,8 +2859,9 @@ private:
float rowWidth = ImGui::GetContentRegionAvail().x;
if (showConnect)
rowWidth -= ImGui::CalcTextSize("Connect").x +
ImGui::GetStyle().FramePadding.x * 2.0f +
ImGui::GetStyle().ItemSpacing.x;
ImGui::CalcTextSize("Sel. Path").x +
(ImGui::GetStyle().FramePadding.x * 2.0f +
ImGui::GetStyle().ItemSpacing.x) * 2.0f;
ImGui::PushID(n.id);
if (ImGui::Selectable(label.c_str(), selected, 0,
ImVec2(std::max(20.0f, rowWidth),
@@ -2711,6 +2891,16 @@ private:
"Road Connect Failed");
}
}
ImGui::SameLine();
if (ImGui::SmallButton("Sel. Path"))
rs->selectPathBetweenNodes(selectedNodeId,
n.id);
if (ImGui::IsItemHovered())
ImGui::SetTooltip(
"Select the edges of the shortest "
"path between node %d and this "
"node (batch editing).",
selectedNodeId);
}
ImGui::PopID();
}
@@ -2718,11 +2908,32 @@ private:
/* Edge list. Same sizing rule as the node list: keep the
* 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. */
* first); Remove only appears on the primary selected row.
* Ctrl+click toggles the edge in the multi-selection used by
* the batch panel below; plain click keeps single-select. */
ImGui::Text("Edges: %zu", rg.edges.size());
{
const std::set<int> &sel = rs->getSelectedEdgeIndices();
if (!sel.empty()) {
ImGui::Text("Selected: %zu", sel.size());
ImGui::SameLine();
if (ImGui::SmallButton("Select Connected"))
rs->selectConnectedComponent(
rs->getSelectedEdgeIndex());
if (ImGui::IsItemHovered())
ImGui::SetTooltip(
"Extend the selection to every edge "
"reachable from the primary edge via "
"shared nodes.");
ImGui::SameLine();
if (ImGui::SmallButton("Clear Selection"))
rs->clearEdgeSelection();
}
}
for (size_t i = 0; i < rg.edges.size(); ++i) {
const auto &e = rg.edges[i];
bool selected = ((int)i == rs->getSelectedEdgeIndex());
bool inSelection = rs->isEdgeSelected((int)i);
bool primary = ((int)i == rs->getSelectedEdgeIndex());
std::string label = "Edge " + std::to_string(i) + ": " +
std::to_string(e.nodeA) + " <-> " +
std::to_string(e.nodeB);
@@ -2736,16 +2947,21 @@ private:
rowWidth -= ImGui::CalcTextSize("Split").x +
ImGui::GetStyle().FramePadding.x * 2.0f +
ImGui::GetStyle().ItemSpacing.x;
if (selected)
if (primary)
rowWidth -= ImGui::CalcTextSize("Remove").x +
ImGui::GetStyle().FramePadding.x * 2.0f +
ImGui::GetStyle().ItemSpacing.x;
ImGui::PushID((int)i);
if (ImGui::Selectable(label.c_str(), selected, 0,
if (ImGui::Selectable(label.c_str(), inSelection, 0,
ImVec2(std::max(20.0f, rowWidth),
0))) {
rs->setSelectedEdgeIndex((int)i);
rs->setSelectedNodeId(-1);
if (ImGui::GetIO().KeyCtrl) {
rs->setSelectedNodeId(-1);
rs->toggleEdgeSelected((int)i);
} else {
rs->setSelectedEdgeIndex((int)i);
rs->setSelectedNodeId(-1);
}
}
ImGui::SameLine();
ImGui::BeginDisabled(eGrouped);
@@ -2763,7 +2979,7 @@ private:
rs->setSelectedNodeId(newId);
rs->setSelectedEdgeIndex(-1);
}
if (selected) {
if (primary) {
ImGui::SameLine();
if (ImGui::SmallButton("Remove")) {
rg.removeEdge(i);
@@ -2781,6 +2997,87 @@ private:
ImGui::PopID();
}
/* Batch edit (phase 5.9 follow-up): applies lanes/sidewalk
* modes to every edge in the multi-selection. Each field
* has a "keep" choice that leaves that property untouched. */
if (!rs->getSelectedEdgeIndices().empty()) {
ImGui::SeparatorText("Batch Edit Selection");
static int s_batchLanesAtoB = -1; /* -1 = keep */
static int s_batchLanesBtoA = -1;
static int s_batchSidewalkLeft = 0; /* 0 keep,1 inherit,
2 on, 3 off */
static int s_batchSidewalkRight = 0;
/* Field edits only stage the batch; nothing touches the
* graph until Apply. */
ImGui::SliderInt(
"Lanes A->B##batch", &s_batchLanesAtoB, -1, 8,
s_batchLanesAtoB < 0 ? "keep" : "%d");
ImGui::SliderInt(
"Lanes B->A##batch", &s_batchLanesBtoA, -1, 8,
s_batchLanesBtoA < 0 ? "keep" : "%d");
const char *keepModes[4] = { "keep", "Inherit", "On",
"Off" };
ImGui::Combo("Sidewalk Left##batch",
&s_batchSidewalkLeft,
keepModes, 4);
ImGui::Combo("Sidewalk Right##batch",
&s_batchSidewalkRight,
keepModes, 4);
bool canApply = s_batchLanesAtoB >= 0 ||
s_batchLanesBtoA >= 0 ||
s_batchSidewalkLeft > 0 ||
s_batchSidewalkRight > 0;
ImGui::BeginDisabled(!canApply);
if (ImGui::Button("Apply to Selected Edges")) {
bool any = false;
for (int ei : rs->getSelectedEdgeIndices()) {
if (ei < 0 ||
(size_t)ei >= rg.edges.size())
continue;
RoadEdge &e = rg.edges[(size_t)ei];
if (s_batchLanesAtoB >= 0) {
e.lanesAtoB = s_batchLanesAtoB;
any = true;
}
if (s_batchLanesBtoA >= 0) {
e.lanesBtoA = s_batchLanesBtoA;
any = true;
}
if (s_batchSidewalkLeft > 0) {
e.sidewalkLeftMode =
(RoadSidewalkMode)(
s_batchSidewalkLeft -
1);
any = true;
}
if (s_batchSidewalkRight > 0) {
e.sidewalkRightMode =
(RoadSidewalkMode)(
s_batchSidewalkRight -
1);
any = true;
}
}
if (any)
rg.bumpVersion();
}
ImGui::EndDisabled();
if (ImGui::IsItemHovered(
ImGuiHoveredFlags_AllowWhenDisabled))
ImGui::SetTooltip(
"Apply the non-\"keep\" fields to all "
"%zu selected edges. Ctrl+click edges in "
"the list or in the 3D view to extend "
"the selection.",
rs->getSelectedEdgeIndices().size());
if (ImGui::SmallButton("Reset Batch Fields")) {
s_batchLanesAtoB = -1;
s_batchLanesBtoA = -1;
s_batchSidewalkLeft = 0;
s_batchSidewalkRight = 0;
}
}
/* 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