Phase 6.6 complete checkpoint

This commit is contained in:
2026-10-28 07:35:18 +03:00
parent 3dfd6c12bd
commit 72851b442f
31 changed files with 4281 additions and 207 deletions
+165 -19
View File
@@ -333,6 +333,12 @@ cd demos/demo-taxi
# and drives when observed, symbolic advance when not, parked at the
# destination parking afterwards, evening leg drives home:
./demoTaxi --headless --test-traffic-schedule
# ...or headless parking acceptance probe (phase 6.6.8): sweeps a
# scripted day asserting exclusive (area, slot, chain) occupancy and
# bit-exact re-probes, then forces a high-detail despawn/respawn,
# checks the live drop-off against the claimed park pose and carjacks
# the car to verify fleet/claim release:
./demoTaxi --headless --test-parking
# ...or probe natural (non-forced) high-detail spawns from a given player
# position; prints how many population NPCs are spawned plus per-NPC
# entity/master-mesh/age/sex diagnostics at frame N:
@@ -770,8 +776,8 @@ selected NPC's full 24h schedule + home/work/interest bindings, plus a "Path"
pane (the active taxi-goal path first, else the motion path, as a waypoint
list) and a "Locations" pane (the area's `TownLocations::LocationSet`). NPCs
with an active taxi goal are marked `[TAXI state]`, traffic drivers get a
`[CAR]` marker with a per-car section (parked-at, phase, active leg,
high-detail state). While the
`[CAR]` marker with a per-car section (claimed slot + chain occupancy,
phase, active leg, high-detail state, park watchdog/fallback). While the
overlay is open the mouse is ungrabbed so its widgets are reachable; **F7**
or **ESC** closes it and grabs the mouse again — ESC never opens the pause
menu while the overlay is up (overlay closes first, next ESC pauses). The
@@ -1195,16 +1201,24 @@ layer must see the fresh flag in the SAME frame, otherwise the frame a
drive leg ends leaves the registry position at the last symbolic car
position for one frame; `postPhysicsUpdate` right after NpcTaxiSystem's)
owns the population's car fleet. When an area's population and
`TownLocations::LocationSet` exist, each Parking location rolls a
deterministic hash (fnv1a of the location name, the TownLocations
per-lot discipline) against `carOwnershipRatio` for one car, capped at
`maxCarsPerArea`; areas without parking rects (only the layout DSL
`TownLocations::LocationSet` exist, each ADDRESS with at least one
Parking (or curb-fallback) spot rolls ONE deterministic hash (fnv1a of
the lowest-index slot's name — curb-only towns keep their legacy
per-location rolls bit-identically) against `carOwnershipRatio` for one
car, capped at `maxCarsPerArea`, parked at the address's lowest-index
free slot; areas without parking rects (only the layout DSL
`parking` verb creates Parking POIs) fall back to the Curb locations as
on-street parking. The driver is the first adult (ageYears >= 18)
whose `npcHome` string column matches the parking spot's address.
Parked cars sit nose along the nearest road edge
(`RoadPath::nearestEdgeInfo`, deterministic direction bit) with the
handbrake on and no driver.
Parked cars sit with the handbrake on and no driver; the pose comes
from `parkedPlacement()`: a bay-slot POI (phase 6.6.1) parks at its
own position with its recorded nose-to-street heading, while a curb
POI is projected onto the curb line (`RoadPath::curbChainPose`,
phase 6.6.4 — `nearestEdgeInfo` halfW + `curbLateralOffset` past the
carriageway edge on the POI's side so the body clears the outer lane,
nose along the edge with the fnv1a(name) direction bit, spot k shifted
k * (`curbChainCarLength` + `curbChainGap`) backwards along the nose —
the chain slots are picked by the 6.6.3 occupancy registry).
Schedule-adherent driving (6.A, replaced the 5.4 endless cruising
loop): `evalSymbolic` re-derives each car's state from
@@ -1228,6 +1242,36 @@ advances along the route at `cruiseSpeed`). **Purity rule:
it back** (no live walker/car positions, no alight-position persist);
live entities are visual approximations only.
**Slot occupancy (6.6.3)** is pure too: `rebuildClaims()` runs every
frame before the per-car evaluation and replays the whole day's claim
events from day start — per-car chains with occupancy-blind default
endpoints give pure event times, then a global (time, driver-id,
leg)-ordered replay claims the destination slot at the WALK departure
(the live tier needs it to drive to) and releases a visitor origin at
the DRIVE departure (the car physically leaves then). Home spots stay
permanently claimed. The same clock always reproduces the same
assignments bit-exactly, so clock scrubs, pauses and scene reloads need
no reconciliation code; the per-leg claimed endpoints are recorded in
`TrafficCar::legClaims` and the route geometry uses them while the
timing always comes from the default route. `TrafficInfo` exposes the
held claim (`area`, `parkedAtSlot`, `parkedAtChain`) plus the live park
watchdog/fallback state (`parkBlockedSeconds`, `fallbackActive`) for F7
and headless probes.
**Pad reachability (6.6.6, helper-geometry pivot):** `slotReachable()`
always returns true — raised pads are one solid frustum whose top
face touches the road at `Pad::topY` and whose sides are walkable
30-degree slopes, non-fitting pads park parallel at the curb line,
so there is no natural-terrain seam left to probe (the old
`padMaxGrade` terrain-slope probe died with the runtime pad
compliance). `TownGenSystem::createParkingPad()` builds the actual
pads as helper geometry (see RoadSystem automatic compliance below).
Every drive leg also inserts a park-APPROACH spine waypoint
(`parkApproachSpineDist` 5 m, on the approach side of the park pose)
so the final segment runs ALONG the slot's nose axis — a diagonal
approach stops at the AI's 2.5 m arriveRadius with LATERAL error,
which the alight gate (lateral 1.5 m) rejects.
Two tiers, symbolic ground truth (same design as NpcDetailSystem): the
`TrafficCar` record survives spawn/despawn and scene switches; the
high-detail tier (camera within 150 m, hysteresis 220 m — the taxi demo
@@ -1249,9 +1293,22 @@ setState no-ops before AnimationTreeSystem has per-entity state; the
re-assert is an early-out once latched). Observed physics is
best-effort: the AI can park AFTER the symbolic clock already chained
into the next leg (late board, junction yield), so the alight check
lives outside the Driving phase and fires whenever the AI reports
arrived; when the symbolic layer is driving another leg by then the
car despawns to resync. On despawn nothing is persisted back — the
lives outside the Driving phase and fires on the drop-off gate
regardless of phase; when the symbolic layer is driving another leg by
then the car despawns to resync. Drop-off discipline (6.6.5): the
leg route's FINAL waypoint is the park pose (not its lane projection),
so the approach steers into the slot, and the driver alights only when
the live car sits inside the park pose's box — `parkArriveM` (1.5 m)
laterally off the slot's nose axis, `parkLongitudinalM` (3.0 m) along
it — and slower than `parkAlightSpeedMps` (0.6 m/s), never mid-lane on
bare `AiDriverComponent::arrived` (the AI's 2.5 m arriveRadius only
ever undershoots longitudinally). An arrived-but-short car gets one
creep retarget onto the pose; an approach that stops improving inside
`parkEngageM` (9 m) escalates after `parkFallbackSeconds` (5 s) to
`retargetToFallback()` — the nearest free curb-chain spot, warned
once. The claim does NOT move on fallback (the symbolic layer stays
untouched; the deviation self-heals on the next leg or despawn). On
despawn nothing is persisted back — the
next spawn reads the clock-pure symbolic state.
`NpcDetailSystem::isSpawnEligible` consults
`NpcTrafficSystem::isDriving`/`isSeated` so a suspended or seated
@@ -1262,12 +1319,16 @@ carjacking) leaves the fleet: the record is erased, the driver alights
in place and returns to its schedule. Symbolic cars are invisible and
cannot collide — junction priority only exists in the high-detail
tier, so a transient overlap at the tier boundary is accepted.
Headless probe: `--test-traffic-schedule` (exterior scene): scrubs the
Headless probes: `--test-traffic-schedule` (exterior scene): scrubs the
clock to a driver's work leg — car parked at home before departure,
driver boards and the car drives when observed, symbolic tier advances
when not, car parked at the destination parking after arrival with the
driver back on schedule, evening return leg drives home (replaces the
5.4 `--test-traffic-town` cruising probe).
5.4 `--test-traffic-town` cruising probe), and the final parked-at name
is validated against the area's `LocationSet`. `--test-parking` is the
6.6 acceptance gate: exclusive occupancy and exact snapshots over a
scripted day, reachable slot names, despawn/respawn with an unchanged
claim, live park-box/mid-lane validation and carjack claim release.
### EditorSkyboxSystem / EditorSunSystem
@@ -1561,9 +1622,20 @@ Per-terrain procedural roads (`RoadGraph` component, geometry built by
Evaluation failures
warn and fall back to the district default (`evaluate()` resets the
output plan on entry, so the fallback retry starts clean). Parking rects become
extra POIs (`"<address> parking N"` — buildings are no longer 1:1
with POIs, `TownGenSystem` gathers via `TownPoi::building`); an
optional `town_layouts.txt` in the project directory extends the
one POI per enumerated bay slot (phase 6.6.1,
`roadlib/TownParking::enumerateSlots` — 2.6 x 5.2 m bays, 2 rows +
center aisle when deep enough, cap 12/rect, too-small rects degrade
to one centered slot), named `"<address> parking [i]-s<k>"` and
carrying the nose-to-street heading (`TownPoi::hasHeading`/
`headingYaw` = the building yaw) — buildings are no longer 1:1 with
POIs, `TownGenSystem` gathers via `TownPoi::building`; slots blocking
the front-door->road walk corridor or the doorstep clearance are
dropped deterministically with a `TownResult::warnings` entry (phase
6.6.2, `TownParking::pruneSlots`). Each rect also emits a
`TownResult::Pad` (building/address/center/yaw/w/d plus `edge`, the
frontage-edge pin used by terrain compliance); frontage length
ranking is XZ-only so road-height compliance cannot flip near ties.
An optional `town_layouts.txt` in the project directory extends the
library. **Town areas** (phase 4.5 item 4): `RoadGraph::townAreas`
(`RoadTownArea`: name, square/circle XZ scope, district, seed,
densityScale) scope generation per settlement —
@@ -1717,7 +1789,10 @@ Per-terrain procedural roads (`RoadGraph` component, geometry built by
collider and LOD set (`RoadPageGeometry::bufferEntities`/`bodyIds`;
`_c<N>` mesh-name suffix, see `ProceduralRoadGeometry.md` §16). This
keeps every mesh in the 16-bit index range and frustum-culls tighter
than one page-sized mesh. Note: an upstream Ogre `MeshLodGenerator`
than one page-sized mesh. `ensureRoadMaterial()` marks the ensured
material resource ready with its pointer so chunk conversion can bind
it immediately; `ProceduralMaterialSystem` still restyles it on the
next update. Note: an upstream Ogre `MeshLodGenerator`
bug (LOD index buffer typed by index count instead of vertex count,
overrunning 16-bit LOD buffers of 32-bit-index meshes) is fixed in the
vendored Ogre fork — chunking still applies regardless.
@@ -1820,10 +1895,81 @@ Per-terrain procedural roads (`RoadGraph` component, geometry built by
terrain to fit. The standalone "Comply Roads to Terrain" button
keeps the full `roadThickness + 0.05` lift with no cut budget for a
raised roadbed look.
**Parking pads are helper geometry (6.6.6, pivot):** town layouts'
parking rects become runtime pad entities, NOT terrain compliance —
runtime terrain edits were unreliable (fixup writes racing page
colliders, junction-wedge exclusion zones swallowing whole pads) and
slowed startup. `TownGen.cpp` decides purely whether each
`TownResult::Pad` gets a frustum at all; a rect that cannot host a
clean driveway instead parks PARALLEL at the roadside (user-directed:
a perpendicular bay without a driveway never has enough room). The
fit rules: `topY` = the frontage road's top − 0.2 (NEVER above it,
so the pad never pokes through the road); `apronReach` = the
street-ward extension of the top face to the carriageway edge
(half-width + 0.25 curb backoff, no overlap into the road body), so
the top face bridges the road's entry-apron band. Raised requires
road top − lot ground > 0.35, no terrain poking through the parking
surface and `drop` <= 4 m. House contact is allowed only when
`topY <= floorY + 0.15` (a door-threshold step) — the frustum then
meets the foundation and can never cut a wall, the stairs or the
door; a HIGHER platform must clear the house footprint and the
doorstep clearance entirely (one street-ward shift of the rect,
bays included, may buy the room; the top face's street edge stays
pinned to the carriageway edge). `Pad::drop` is settled in the
pure layer (bottom face 0.4 m below the LOWEST ground over the
footprint, so no slope foot floats). Failing pads get no geometry
and ONE parallel roadside spot per address on the curb line
(`RoadPath::curbChainPose`, nose along the street) — a tight
multi-spot chain was rejected because the AI stops in the lane
behind the car in front and never reaches the curb; extra capacity
comes from the ordinary curb chain.
`TownGenSystem::createParkingPad()` (called from `createBuilding`
with the building's pads) builds ONE SOLID FRUSTUM per raised pad
(user-directed design): a single 12-triangle mesh (TriangleBuffer
filled directly — top, bottom and four side faces) whose top face
is the parking surface sized `pad.w x (pad.d + apronReach)` at
`topY`, and whose bottom face is dropped by `Pad::drop` and
expanded by drop * tan(30deg) per side, so all four sides form
walkable 30-degree slopes whose bottom edges sit below the
terrain — no seams, no gaps, no loose parts, driveways never block
building exits. The collider is the mesh itself
(`PhysicsColliderComponent::ShapeType::Mesh`); because the mesh is
procedural, `EditorPhysicsSystem::updateRigidBody()` DEFERS body
creation while a Mesh collider references a still-dirty
TriangleBufferComponent (retried next sync) — without that the
body used to be built before ProceduralMeshSystem converted the
buffer and silently fell back to a 0.5 m default box (a ghost
pad). The terrain is never written. Bay-slot parked
poses inherit `topY` through the parking POI positions (TownGen sets
the POI's y to the pad top; `TownLocations`/`parkedPlacement` copy
it), so cars spawn on the parking surface. The pad
geometry regenerates with its building (per-page streaming and
per-building regeneration both pass the pads through); the
"parking-pad" entity is the stable per-address anchor for future
parking infrastructure (garages). Raised pads whose
driveway reach is deep enough (apronReach >= 2.6 m) also emit ONE
overflow parking POI ("<address> parking-ovf", nose along the
street) at the driveway mouth — a car parks there only when every
designated slot is taken (the pick tiers: free bays -> free
overflow -> curb chain), never stacked (one exclusively-claimed
pose) and never in front of the door (it sits at the road end of
the driveway). Narrow lots with neither driveway room nor a
side-wall strip are a PLAN PROBLEM / TODO for the later, more
general placement system (6.6.2's corridor/pruning rules keep such
lots car-free rather than blocking exits).
Commercial layouts size their rects for at least 4 bays
(town_layouts.txt: the corner-shop's 13x5.2 rect = 5 bays);
residential keep vehicles+1 headroom (the curb chain supplements
the single roadside spot).
Covered by the `autoCompliance` and `autoComplianceAprons` terrain
tests (converge, no terrain-over-road violations, bounded hover,
idempotent — a second `complyAll()` reports 0 changes, with and
without entry aprons).
without entry aprons) and by `TERRAIN_TEST_FILTER=townParkingPads`
(sculpted fixture: the rect beside the house, the road 1 m above
the local terrain — at least one raised frustum MUST fit; raised
pads = exactly one frustum entity with a mesh collider at `topY`,
flat pads build no geometry, terrain bit-identical under the
pads).
- **Region streaming (M5)**: when the terrain has `streamingEnabled`, the
road graph persists per terrain page in
`heightmaps/<terrainId>/roads/<px>_<py>.json` (`RoadRegionStore`,
+2
View File
@@ -892,6 +892,8 @@ add_library(RoadGeometryLib STATIC
roadlib/RoadPath.hpp
roadlib/TownGen.cpp
roadlib/TownGen.hpp
roadlib/TownParking.cpp
roadlib/TownParking.hpp
roadlib/TownPopulation.cpp
roadlib/TownPopulation.hpp
roadlib/TownLocations.cpp
+4
View File
@@ -496,6 +496,10 @@ void EditorApp::destroyEditorSystems()
m_zoneSystem.reset();
m_aiDriverSystem.reset();
m_vehicleSystem.reset();
/* Hair ragdolls hold Jolt constraints; the destructor removes them
* from the JPH::PhysicsSystem, so the hair system must go before
* the physics system. */
m_hairPhysicsSystem.reset();
m_physicsSystem.reset();
/* Flush the RTShader generator cache before clearing materials. This
+3
View File
@@ -74,6 +74,9 @@ Sculpting never touches the base layer: `setHeightAt`/brushes write into the spa
per-page **fixup chunk** store (`heightmaps/<terrainId>/terrain_fixup/x{cX}_z{cZ}.bin`),
which is lazily disk-loaded, LRU-capped, and saved on eviction when dirty. Blend maps
and aux maps are likewise per-page files under `heightmaps/<terrainId>/`.
When a page is (re)built, `fillPageHeightData()` evaluates the base layer at its legacy
integer sample positions but overlays the fixup layer at the exact double-precision
vertex coordinates, so road/pad planes are not shifted by position truncation.
Height queries (`getHeightAt`, `raycastTerrain`) hit loaded pages when possible and fall
back to the analytic base+fixup evaluation for far/unloaded terrain, so tools (teleport
+584 -72
View File
@@ -3814,67 +3814,569 @@ and schedule/job behavior. Vehicle damage/economy simulation is likewise
gameplay scope; Phase 6.5 supplies and proves the repair-bay furniture, POI,
path and action wiring it will use.
### Phase 6.6 — Parking spots & vehicle drop-off (problem statement)
### Phase 6.6 — Parking spots & vehicle drop-off
Observed in the demo (2026-10-24), all four stem from the same gap:
there is no first-class parking concept, only `Parking`/`Curb` POI
points that a single hash places a car on:
**Status: DONE (2026-10-26; hardened 2026-10-28) — 6.6.1 through 6.6.8
complete: named bay slots, pruning, curb chains, pure occupancy claims,
drop-off discipline, helper-geometry parking pads (frustum fit rules +
parallel roadside spots + park-approach spine), determinism and the
acceptance probe battery.**
1. **Cars block building exits.** Parking/curb spots are placed
without regard to door positions or the walking paths between a
building's entrance and the road, so parked cars end up blocking
the way out (visually blocking exit from the doorway).
2. **Drivers abandon the car mid-road.** When a drive leg finishes,
the driver alights wherever the AI managed to stop — frequently in
the traffic lane — instead of reaching a parking spot or at least
pulling to the road side/shoulder. Vehicles must never be dropped
in a lane.
3. **Stacked cars.** Spot assignment has no occupancy tracking: a
visiting car's `parkingFor()` destination pick can land on another
car's home spot (and curb POIs can coincide), so two vehicles
spawn/park exactly on top of each other.
4. **Cars can't reach the road.** Spots beside the road sit on
un-flattened, sometimes sloped ground; a spawned vehicle cannot
climb from its spot onto the road surface. Parking needs terrain
compliance (flattened pad) and a drivable ramp/connection to the
road.
#### The idea (plain words)
Design considerations to solve for (not yet formulated into an
implementation — the DSL side especially needs thought):
Parking becomes a first-class SLOT model, end to end:
- **Parking lots in the layout DSL.** A lot should be definable per
building lot (most important later for commercial buildings, which
NEED proper lots; residential can start minimal). Placement rule of
thumb: between the road and the building, but leaving enough clear
space for foot travel between the parked cars and the building
entrance — the building<->road walking path must stay passable and
door clearance zones must stay car-free.
- **Discrete slots, not points.** A parking lot/rect should enumerate
individual slots (position + orientation, capacity > 1) so multiple
cars are handled gracefully: occupancy is tracked per slot, no two
cars ever receive the same slot (home spots, destination picks and
spawn placement all draw from the same occupancy registry).
- **Drop-off discipline.** The symbolic layer already chains cars
through `destParkPos`; the high-detail AI must actually ARRIVE there
(or at the nearest free fallback slot, or at least the road
shoulder) before the driver alights — never mid-lane. Blocked or
unreachable slots need a defined fallback.
- **Access ramps & compliance.** Parking pads get terrain flattening
and a shallow, drivable connection ramp to the road edge so NPC
vehicles can reliably drive in/out; spawn-time validation that the
car can physically reach the road from its slot.
- **Curb fallback stays** for layouts without parking rects, but curb
placement must be at the road side, oriented along the edge, out of
the lane, and door-clear.
1. The DSL parking rect enumerates discrete named bay slots (a pose +
capacity > 1) instead of collapsing to one center point — task
6.6.1, with door/corridor placement rules in 6.6.2.
2. Every spot pick (home, visitor, spawn) draws from a per-area
occupancy registry, so no two cars ever share a slot — task 6.6.3;
curb-only areas get a deterministic roadside slot chain in 6.6.4.
3. A drive leg ends when the car ACTUALLY reaches its slot (or a
roadside fallback) before the driver steps out, never mid-lane —
task 6.6.5.
4. Pads become ONE SOLID FRUSTUM each where a driveway fits (a
parking platform whose top face touches the road and whose
30-degree slopes walk down to the terrain); every other rect
parks PARALLEL at the roadside, and the terrain is never
touched — task 6.6.6.
### Phase 6.7 - DSL, schedule and POIs.
Determinism rules (6.6.7) and the probe battery (6.6.8) are
cross-cutting; defects observed in the demo (2026-10-24) are tagged
P1-P4 and cited per task.
#### Why: parking today (verified symbols)
- DSL `(parking x z w d)` / `(parking (rect-front-left w d))` records
a lot-frame rect `PlanParking{x,z,w,d}` (LayoutEval.cpp ~1803;
struct at BuildingPlan.hpp:76).
- TownGen turns each rect into ONE POI — `TownPoi::Parking` at the
rect center, `"<address> parking [i]"` (TownGen.cpp ~841-871).
- `TownLocations::buildLocations()` maps every POI to one point
`NpcLocation` (Parking; everything else Curb, TownLocations.cpp
~166) — that bare point is all the traffic stack ever sees.
- `NpcTrafficSystem`: `parkedPlacement()` (:162) parks AT the POI
point and only orients the nose along the nearest road edge
(`nearestEdgeInfo` + a fnv1a name direction bit); `parkingFor()`
(:188) picks the destination as the nearest Parking-or-Curb in the
whole town (tie = lowest location index) with NO occupancy; the leg
route shifts EVERY waypoint — the destination included — into the
right lane (`RoadPath::offsetToRightLane`, RoadPath.cpp:323); the
live tier alights on bare `AiDriverComponent::arrived` (:835) and
drops the driver at `doorWorld()` (:688) wherever the car stopped.
Only the symbolic layer ever "reaches" the park pose
(`lr->destParkPos`, :464, clock-pure).
#### Defects observed in the demo (2026-10-24)
- **P1 — cars block building exits:** spots are placed with no
`TownBuilding::doorstep` / walk-corridor constraint anywhere.
- **P2 — drivers abandon the car mid-lane:** the AI stops at the
lane-shifted final route waypoint; no park-pose arrival gate exists.
- **P3 — stacked cars:** nearest-by-distance with no occupancy hands
one spot to several cars; a curb POI is a bare point, so two cars
coincide exactly.
- **P4 — cars can't reach the road:** the POI point sits on
un-flattened terrain with no pad, no apron and no reachability
check.
#### Tasks
**Task 6.6.1 — Discrete bay slots (pure town model; enables all) —
DONE (2026-10-26).**
- What: a small pure helper (`roadlib/TownParking.{hpp,cpp}`) expands
each `PlanParking` rect into N bay slots from the rect + the lot's
frontage axes: a double row + center aisle when the depth allows,
else one row; bay size = traffic-sedan chassis footprint + a door
margin; per-rect cap (~12, config). Names `"<address> parking
<i>-s<k>"` stay address-stable. Slots carry a heading (nose to the
road) via a new optional pose on `TownPoi`/`NpcLocation`;
`parkedPlacement()` prefers it over the road-edge projection (which
would park a bay car diagonally across its bay).
Implemented as specced: `TownParking::enumerateSlots` (bay 2.6 x
5.2 m from the sedan collider half extents 1.0107/2.4834 -> 2.02 x
4.97 m + margins; 2 rows + 6 m aisle when d >= 2*5.2+6, else 1
road-side row; cap 12; a too-small rect degrades to one centered
slot); `TownPoi`/`NpcLocation` gained `hasHeading` + `headingYaw`;
`parkedPlacement()` prefers the heading.
- Done when: the road_geometry_overlap_test town probe asserts the
bay math — count, inside-rect, spacing >= bay width, seed-identical
sets, road-facing headings.
(`runTownParkingProbe`, registered in the probe battery: pure
single/double-row/cap/degraded cases plus the TownGen integration —
48 slot POIs over 12 buildings with names `"<address> parking-s<k>"`
(single rect: no rect index), `headingYaw == TownBuilding::yaw` and
row-geometry/determinism assertions.)
**Task 6.6.2 — Door & corridor rules (pure; fixes P1) — DONE
(2026-10-26).**
- What: slot expansion/pruning keeps a ~1.5 m walk corridor from the
front door (`doorCellX` / `doorstep`) to the frontage road and a
doorstep clearance zone car-free; violators drop deterministically
with a `TownResult::warnings` entry. The placement rule of thumb
goes into the DSL help (`LayoutLibrary::examples()`).
Implemented: `TownParking::WalkCorridor{doorX, doorZ, halfWidth
0.75, doorClear 1.5}` + `TownParking::pruneSlots()` (AABB overlap
of each bay against the road-edge->doorstep strip plus the
doorstep clearance square, pure and deterministic); TownGen prunes
in the rect frame (doorstep projected onto the frontage axes) and
warns `"lot <li> strip <si> (<addr>) parking rect <i>: dropped K
bay slot(s) blocking the door walk corridor"`. The DSL help text
(TerrainEditor.hpp — where the parking verb is documented; the
examples list itself was left untouched) now states the corridor
rule.
- Done when: the probe finds every doorstep->frontage corridor clear
of bays, with stable per-seed drops and warnings.
(`runTownParkingProbe`: three pure prune cases plus the wide-rect
`lot-w` integration — 9..11 survivors per building, no survivor
inside the doorstep square, the drop warning, and
regenerate-identical determinism.)
**Task 6.6.3 — Occupancy registry & assignment (fixes P3) — DONE
(2026-10-26).**
- What: a per-area occupancy map slot-name -> car key. Home cars:
the fnv1a ownership roll (`carOwnershipRatio`, `maxCarsPerArea`)
moves from per-LOCATION to per-ADDRESS — with slot POIs a
per-location roll would multiply car counts — and takes the
address's lowest-index free slot. Visitors: `parkingFor()` filters
FREE Parking slots (nearest, tie = lowest location index as today),
then free Curbs — never an occupied spot; curb-chain k picks
(6.6.4) draw the same way. Release on despawn / carjack / scene
switch; rebuilt deterministically on load.
- **Implementation deviation — the map is PURE, not incremental
runtime state.** The first (incremental latch + reconcile)
version broke `--test-traffic-schedule`'s bit-exact re-probe:
claim order followed wall evaluation order, so a clock scrub
re-resolved shared destinations in driver-id order instead of
departure-time order. `NpcTrafficSystem::rebuildClaims()` instead
replays the whole day's claim events from day start every frame —
per-car chains with occupancy-blind default endpoints give pure
event times (phase A), then a global (time, driver-id)-ordered
replay (phase B) claims the destination slot at the WALK departure
(the live tier needs it to drive to) and releases the visitor
origin at the DRIVE departure (the car physically leaves then).
Same clock -> same assignments, bit-exact under scrubs, so
"release/rebuild on load" needs no code at all. Home spots stay
permanently claimed (a visitor never takes someone's home slot;
the owner always re-parks exactly at home). Seed rolls ride on
the lowest-index slot's NAME per address, so curb-only towns keep
their legacy fleet bit-identically.
- Done when: `--test-parking` runs a scripted day with N cars sharing
no slot, and the same seed reproduces the same assignments.
(Interim: `--test-traffic-schedule` sweep + re-probe bit-exactness
with 18 cars covers the purity; the dedicated parking battery
lands with 6.6.8.)
**Task 6.6.4 — Curb fallback chain (pure geometry; fixes P3;
selection in 6.6.3) — DONE (2026-10-26).**
- What: a curb location becomes a chain of parallel street spots: the
k-th spot shifted k * (carLen + gap) along the edge from the POI
projection (starting side from the existing fnv1a(name) direction
bit), centered on the curb line (`nearestEdgeInfo` halfW + a small
outward margin so the body clears the outer lane), nose along the
edge. Pure `RoadPath` geometry; k = 0 keeps today's pose, so
curb-only towns change only where cars would have stacked.
Implemented as `RoadPath::curbChainPose(graph, poiPos, k, carLen,
gap, curbMargin, flipDir, pos, rot)` with `parkedPlacement()`
gaining a `chainIndex` parameter and NpcTrafficSystem the config
`curbChainCarLength` 5.0 / `curbChainGap` 0.8 /
`curbLateralOffset` 1.2; the chain grows BEHIND the nose and the
lateral side is the POI's side of the road (centerline POIs fall
back to right-of-direction). **Deviation from the "k = 0 keeps
today's pose" line:** ALL curb spots — k = 0 included — move to the
curb line. Keeping k = 0 at the raw POI point would have left P1
unfixed in curb-only towns (the curb POI sits ~3 m in front of the
door — on the walkway, not at the curb — and every current town is
curb-only until layouts gain parking rects); the old "park at the
bare point" behavior has no remaining consumer.
- Done when: the probe asserts chain spacing >= carLen, poses at or
outside the curb line (never inside the outer lane) and no
coincident pairs.
(`runTownParkingProbe` curb-chain section: k=0 curb-line pose with
the sedan body clear of the outer lane, k=1/2 spacing behind the
nose, flipped-direction chain growth, far-side/centerline lateral
picks, empty-graph false; `--test-traffic-schedule`, `--test-taxi`,
`--test-vehicle-drive` and `--test-determinism` all re-run green
after the global curb-pose change.)
**Task 6.6.5 — Drop-off discipline (traffic + AI; fixes P2) — DONE
(2026-10-26).**
- What: keep the lane offsets along the route body but REPLACE the
final waypoint with the park pose (`lr.destParkPos` / `destParkRot`)
so the approach steers into the slot. Gate alighting (:835) on the
live pose being within `parkArriveM` (~1.5 m, config) of the park
pose — or of the fallback pose — instead of bare `arrived`.
Fallback after ~5 s blocked: drive to the nearest curb-chain pose
(6.6.4), warn once — never mid-lane. The symbolic layer stays
untouched (it already parks at `lr->destParkPos`, :464).
Implemented as specced, with the alight gate as a BOX instead of a
radius: `parkArriveM` (1.5 m) laterally off the slot's nose axis and
`parkLongitudinalM` (3.0 m) along it, plus `parkAlightSpeedMps`
(0.6) — a radius gate would demand 2.5x the AI's arriveRadius
precision longitudinally, while only the lateral part keeps the body
out of the carriageway (a parallel-parked car a meter back along
the curb is parked fine). The gate evaluates against the
`liveDestPos`/`liveDestRot` captured at AI attach (survives the
symbolic clock chaining into the next leg). An arrived-but-short
car gets one creep retarget onto the pose; the blocked watchdog
(inside `parkEngageM` 9 m without improving the approach minimum)
escalates after `parkFallbackSeconds` (5 s) to
`retargetToFallback()` = nearest curb with a free chain spot, warn
once; **the claim does NOT move on fallback** — the symbolic layer
stays clock-pure and the deviation self-heals on the next leg or
despawn. `TrafficInfo::parkedAtSlot` exposes the held claim.
Teardown fix piggy-backed here (found by this task's reruns):
`EditorApp::destroyEditorSystems()` now resets
`m_hairPhysicsSystem` before `m_physicsSystem` — hair ragdolls
remove their Jolt constraints in the system destructor, which needs
a live JPH::PhysicsSystem (previously a hair-wearing NPC alive at
shutdown crashed teardown in Jolt's ConstraintManager).
- Done when: `--test-parking` shows every drop outside the
carriageway (lateral >= resolved halfW) and the final pose within
tolerance unless the fallback fired; `--test-traffic-schedule`
passes unchanged.
(`--test-traffic-schedule`, `--test-taxi`, `--test-vehicle-drive`,
`--test-determinism` and `--test-population-switch` all re-run PASS
with exit 0; the dedicated `--test-parking` acceptance gate also
passes.)
**Task 6.6.6 — Pad helper geometry & drivable connection (fixes P4) —
DONE.**
- What: each parking rect becomes RUNTIME HELPER GEOMETRY instead of a
terrain edit. The terrain is never modified at runtime: the
original comply-based design (fixups written inside every
`complyAll()` pass) was unreliable in game mode — pad fixups
raced page-collider rebuilds, the junction-wedge exclusion could
swallow whole pads (a 2*halfWidth disc reached 21 m while a pad sat
13.8 m from a T-junction node, leaving it 1.1 m below the raised
roadbed and beaching the car on the bench edge), and runtime
compliance slowed startup. Editorial "Auto Comply Roads & Terrain"
keeps working for roads; pads are pure geometry.
- Design (user-directed): ONE SOLID FRUSTUM per pad — start from a
box whose top face is the parking surface (the pad rect extended
street-ward so it touches the road) sitting 0.2 below the road's
surface, then drop and expand the bottom face so all four sides
form 30-degree slopes whose bottom edges sit below the terrain.
A single 12-triangle mesh, exact mesh collider, no seams, no gaps,
no loose parts; the slopes are walkable, so driveways never block
building exits. (An earlier multi-part variant — slab + pitched
mouth + segmented skirts — was rejected as overcomplication and
replaced by this.)
- Grading is pure and lives in TownGen. A pad gets a frustum ONLY
where it fits cleanly; every other parking rect parks PARALLEL at
the roadside (user-directed: a perpendicular bay without a
driveway never has enough room). The fit rules:
`TownResult::Pad::topY` = the frontage road's top − 0.2, NEVER
above it (a higher platform would poke through the road where the
road level drops along the frontage); `Pad::apronReach` = the
street-ward extension of the top face to the carriageway edge
(half-width + 0.25 curb backoff, NO overlap into the road body),
so the top face bridges the road's entry-apron band without
intersecting the road. Raised requires road top − lot ground >
0.35, no terrain poking through the parking surface, and a drop
(top face to bottom face) of at most 4 m. House contact is
allowed only when the platform is at/below the house floor plus a
0.15 m door-threshold step (`topY <= floorY + 0.15`) — then the
frustum meets the foundation and can never cut a wall, the stairs
or the door; a HIGHER platform must clear the house footprint and
the doorstep clearance entirely (one street-ward shift of the
rect, bays included, may buy the room; the top face's street edge
stays pinned to the carriageway edge). Failing pads get no
geometry and ONE parallel roadside spot per address on the curb
line (`RoadPath::curbChainPose`, nose along the street) — a tight
multi-spot chain was tried and rejected: it demands real
parallel-parking skill the AI does not have (it stops in the lane
behind the car in front and never reaches the curb). Extra
capacity comes from the ordinary curb chain. The bay-slot POI
positions of raised pads carry `topY`, so `TownLocations`,
`parkedPlacement()` and the F7 poses all inherit the parking
surface height.
- Implementation: `TownGenSystem::createParkingPad()` (called from
`createBuilding` with the building's pads, so per-page streaming
and per-building regeneration both build them) parents a
"parking-pad" entity to the building: a static "parking"-textured
12-triangle frustum (TriangleBuffer filled directly — top,
bottom and four side faces; UVs pinned to the texture center)
sized `pad.w x (pad.d + apronReach)` on top, with the bottom face
dropped by the pure-layer-settled `Pad::drop` (0.4 m below the
LOWEST ground over the footprint, so no slope foot floats) and
expanded by drop * tan(30deg) per side. The node sits at the top
face's center (`topY`); child transforms are building-local (the
world->local conversion bit me once: slabs built from world coords
landed ~46 m north of their pads). The collider is the mesh
itself (`ShapeType::Mesh`).
Physics ordering fix piggy-backed here (found by this task's
reruns): `EditorPhysicsSystem::updateRigidBody()` now DEFERS body
creation while a `ShapeType::Mesh` collider references a
`TriangleBufferComponent` on the same entity that is still dirty
(retried next sync) — previously the body was built before
`ProceduralMeshSystem` converted the buffer, the mesh load threw,
and `buildCompoundShape()` silently fell back to a 0.5 m default
box, leaving pads as ghost colliders cars drove straight through.
`slotReachable()` now always returns true (the only slopes are the
walkable 30-degree frustum sides); AiDriverSystem gained a
stuck-escape (full throttle + no movement for 2 s -> one
zero-input frame to release the brake-before-reverse latch, then
1.2 s reverse with a one-waypoint route rewind) so a wedged car
backs out instead of beaching forever, a grazing-hit filter in the
whisker loop (hits whose normal is near-perpendicular to the ray
— an adjacent bay's flank — no longer clamp to a stop) and a
final-approach creep floor so the last waypoint is always driven,
not crept to a stall. Leg routes leaving a bay slot insert a
straight-out spine waypoint (`bayExitSpineDist` 8 m) so
pure-pursuit exits the bay dead straight, and every drive leg
inserts a park-APPROACH spine (`parkApproachSpineDist` 5 m, on
the approach side of the pose) so the final segment runs ALONG
the slot's nose axis — without it a diagonal final segment stops
at the AI's 2.5 m arriveRadius with the error landing LATERALLY
(outside the alight gate), which is exactly why the parallel
roadside spots initially never got reached. Raised pads whose
driveway reach is deep enough (apronReach >= 2.6 m) emit one
overflow POI ("`<address>` parking-ovf", nose along the street)
picked after the free bays and before the curb chain — the
between-road-and-house fallback, never stacked and never in front
of the door.
- Narrow spots (user-directed rules): a parking rect that cannot
host a clean frustum (road at lot level, terrain poking through,
too big a drop, or a high platform that would bury the exit) gets
NO geometry and parks parallel at the roadside. Parking spots
near a house's side walls are placed when the lot's space permits;
layouts with neither the room for a driveway nor a side-wall strip
are marked as a PLAN PROBLEM / TODO for the later, more general
placement system (the corridor/pruning rules of 6.6.2 already keep
such lots car-free rather than blocking exits).
- Verified: `TERRAIN_TEST_FILTER=townParkingPads` (sculpted fixture:
the rect beside the house, the road 1 m above the local terrain —
at least one raised frustum MUST fit; raised pads = exactly one
frustum entity with a mesh collider whose node sits at `topY`,
flat pads build no geometry, the terrain bit-identical under the
pads), the full terrain suite PASS, `road_geometry_overlap_test`
(raised-town bay contract + wide-rect corridor pruning + the new
at-grade PARALLEL-spot contract: one curb-line spot per building,
nose along the street, deterministic), and the whole demo battery
including `--test-parking` and `--test-traffic-schedule` PASS.
**Task 6.6.7 — Determinism rules (cross-cutting) — DONE.**
- What: every new decision stays a clock- and RNG-free pure function:
slots = f(rect, frontage axes, bay dims); assignment = f(slot
names, live occupancy, fixed order); curb chain = f(name hash, edge
geometry). Slot names derive per address, so editing one lot never
shifts another lot's slots (household id band principle,
`popSeed ^ fnv1a(address)`). Occupancy is the ONLY runtime state.
- Done when: `--test-determinism` stays bit-exact across a regen with
one lot's parking layout edited.
- Verified: pure town/pad regeneration is identical, the parking
probe's revisited (phase, position, area, slot, chain) snapshots are
bit-exact (including backward clock scrubs), and the existing
`--test-determinism` / `--test-traffic-schedule` probes stay green.
**Task 6.6.8 — Tests & probes — DONE.**
- Pure: road_geometry_overlap_test gains slot-enumeration, corridor
and curb-chain probes (6.6.1 / 6.6.2 / 6.6.4), plus pad metadata,
determinism and pinned-frontage projection checks (6.6.6).
- Runtime: new `demoTaxi --headless --test-parking` — occupancy
exclusivity over a scripted day (incl. despawn and carjack), no
mid-lane drops, spot->road reachability, bit-exact determinism
snapshot. `--test-traffic-schedule` parked-at assertions switch to
slot names; the F7 overlay's per-car section shows slot +
occupancy + fallback state.
- Verified: `road_geometry_overlap_test` exits 0; `--test-parking`
PASS (18 cars, exclusive claims over 64 day probes, three exact
re-probes, forced high-detail despawn/respawn with an unchanged
claim, park-box + mid-lane checks, carjack releases the fleet
record); `--test-traffic-schedule` validates the final slot name
against the area `LocationSet`. `TrafficInfo` exposes area, chain,
watchdog and fallback fields, and F7 shows them.
#### Delivery order
1. 6.6.1 -> 6.6.2 -> 6.6.4 — pure town model; nothing behavioral.
2. 6.6.3 + 6.6.5 together — one coherent traffic-behavior change.
3. 6.6.6 — helper pad geometry, after the roads comply (editor-side;
the runtime never touches terrain).
4. Tests land per task; `--test-parking` is the acceptance gate.
6.6.4 and the curbside half of 6.6.5 change curb poses globally —
re-run `--test-traffic-schedule`, `--test-taxi` and
`--test-vehicle-drive` after each.
### Phase 6.7 — Animation state machine travel
**Status: NOT STARTED (design decomposed 2026-10-28).** Scope: the
animation tree (registry, runtime, editor) and every controller that
switches states. Robustness and tests are explicit requirements, not
afterthoughts.
#### The idea (plain words)
Today a state change is a hard switch: `setState()` cross-fades
immediately to whatever state the controller names, mid-animation if
needed. **Travel** is a *guided* change: the tree walks from the
current state to the requested one along declared **connect links**,
playing each intermediate state to the END of its animation before
moving on. `idle -> walking -> running` stops being a pop and becomes
a smooth accelerate/decelerate chain, which is what kills the visible
feet sliding.
#### Vocabulary
- **State** — a named child of a `stateMachine` node (existing).
- **Connect link** — a NEW directed `A -> B` declaration on a
`stateMachine` node meaning "B may follow A". Sibling of the
existing `endTransitions` map (which stays: it is the automatic
"when A ends, go to B" rule).
- **Travel** — a NEW request `travelTo(e, sm, target)`: follow the
shortest connect path current -> target, one state at a time.
- **Start state / end state** — NEW optional markers on the children
of a nested `stateMachine` node (a state machine used as a state of
an upper machine).
#### Semantics (the contract, in order)
1. `travelTo(e, sm, target)` computes the shortest connect path from
the current state to `target` (BFS; ties broken by state NAME, so
results are deterministic).
2. **No path = old behavior.** If no connect path exists (no links,
target unreachable, current state unknown), travel degrades to the
plain `setState()` switch. Never an error, never a hang.
3. Each hop completes the CURRENT state's animation before the next
state starts: a one-shot clip advances at its end; a LOOPING clip
advances at the loop boundary (end of the current iteration), never
mid-cycle. The hop itself is the usual cross-fade.
4. A new `travelTo` while a travel is running REPLACES the pending
path: the in-flight hop finishes, then the machine travels from
there to the new target (recomputed). No queue of stale commands.
5. Arrival = the last state is reached and fading in. If that state
loops, it simply keeps looping; travel never force-stops it.
6. Nested machines: when the upper machine ACTIVATES a nested
`stateMachine` node, the nested machine enters its **start state**
(only if it was not already active). When the upper machine wants
to leave that node, the nested machine first TRAVELS to its **end
state**; only when that travel completes does the node deactivate
(fade out). With no end state marked, deactivation is immediate
(today's behavior).
7. Robustness: deleted states/links, a missing target, a disabled
tree or a despawned entity cancel the travel and fall back to the
plain switch (one log line per entity, not per frame). Pending
travel is runtime-only: scene load / spawn / teleport re-issues
from the controller's logical state, nothing is serialized.
#### Why: what exists today (verified symbols)
- Node model: `AnimationTreeNode` (components/AnimationTree.hpp) with
types `output` / `stateMachine` / `state` / `speed` / `animation`,
`endTransitions` on `stateMachine`.
- Runtime: `AnimationTreeSystem::setState(e, sm, state, reset)`
cross-fades immediately; `checkEndTransitions()` fires the automatic
transition at 99% of the clip (AnimationTreeSystem.cpp:739-818).
- Definitions live in `AnimationTreeRegistry` (animation_tree.json),
edited in Tools -> Animation Tree Registry
(ui/AnimationTreeRegistryEditor.cpp).
- Hard-switch call sites to migrate:
`PlayerControllerSystem.cpp:615` (idle/walking/running/swimming via
`pc.locomotionStateMachine`),
`VehicleControllerSystem.cpp:257,524`,
`NpcTrafficSystem.cpp:1110,1202,1978` (`locomotion: driving/idle`),
`NpcTaxiSystem.cpp:520,634`,
`SmartObjectSystem.cpp:151,160`,
`BehaviorTreeSystem.cpp:449`,
`PathFollowingSystem.cpp:146`.
#### Tasks
**Task 6.7.1 — Connect links & markers (data model + editor).**
- Add `connectLinks` (directed pairs) and the optional `startState` /
`endState` markers to `AnimationTreeNode`; serialize them in
animation_tree.json with backward-compatible loading (old files keep
working, empty links = switch-only).
- Registry editor: create/remove links between states of one machine,
mark start/end on nested machines; validate on save (no dangling
state names, warn on unreachable states, no duplicate links).
- Done when: a hand-built tree round-trips links+markers through
save/load, invalid entries are rejected with a clear message, and
the demo's existing trees load unchanged.
**Task 6.7.2 — Travel runtime (`travelTo`).**
- Implement path search + the per-entity/per-machine pending-travel
state and the hop advancement rules 1-5 above, next to `setState`
(which stays for hard resets). Reuse the existing cross-fade and
end-of-clip detection (share code with `checkEndTransitions`).
- Done when: a headless probe walks A->B->C and asserts the order, the
end-of-clip timing (one-shot AND looping), the no-path fallback and
command replacement mid-travel; no per-frame allocations in the
steady state.
**Task 6.7.3 — Nested start/end states.**
- Implement activation/deactivation gating for nested machines
(rule 6), including "already active" re-entry and aborting a pending
exit when the node is reactivated.
- Done when: the probe shows enter->start, exit->travel-to-end->fade-
out, reactivation during the exit aborting the exit, and immediate
deactivation when no end state is marked.
**Task 6.7.4 — Controller migration.**
- Switch the call sites listed above to `travelTo` for LOCOMOTION
(idle/walk/run/swim), vehicle enter/exit and smart-object actions;
keep plain `setState` for spawn/teleport/scene-load resets.
- Give the stock locomotion tree sensible connect links
(idle<->walking<->running, plus the swim chain) so migration is
visible on day one.
- Done when: the full demo battery passes unchanged and a visual
walk/run/stop cycle shows no state popping (screenshot/video check).
**Task 6.7.5 — Test harness & regression coverage.**
- Pure: path search (shortest, ties, unreachable, cycles), JSON round-
trip, editor validation.
- Runtime: travel timing, nested start/end, controller-level
enter/exit-vehicle travel; register the probes in the existing test
executables (no new framework).
- Done when: the new probes plus the terrain suite,
road_geometry_overlap_test and all demoTaxi tests exit 0, and the
docs (this file + editScene/AGENTS.md) describe travel semantics.
#### Delivery order
1. 6.7.1 data model (runtime can already read links).
2. 6.7.2 travel core + probes.
3. 6.7.3 nested start/end + probes.
4. 6.7.4 controller migration, one system at a time, re-running the
demo battery after each.
5. 6.7.5 final coverage + docs.
#### Explicit non-goals (for later phases)
- Foot IK / ground alignment, root-motion warping, animation blending
beyond the existing cross-fade, and any AI/schedule behavior change:
travel is an ANIMATION-layer feature only.
### Phase 6.8 - DSL, schedule and POIs.
- it should be possible to declare macro blocks in DSL similar to Lisp macros to reuse parametrized DSL fragments in many places.
- this is relevant to exterior scene
- Documentation and help window should be updated with new syntax.
- exterior scene:
- per-NPC schedule uses symbolic names, like home, job, leisure, etc.
- per-NPC schedule uses blocks of 1 in-game hour to set schedule, not random time pereiods. The scheduler should try to do realistic schedules and prefer shorter distances between locations, but not overuse same locations making some of them crowded and some empty.
- per NPC daily schedule should start and end at home if no special scripted circumstances occur. In later case the script creates the schedule for the NPC until gives up back to the system.
- these symbolic names are resolved to actual POIs at schedule build and update time, symbolic name should not be forgotten, but association of symbolic names with POIs is kept until re-schedule occurs.
- the schedule is built for in-game week
- once out of schedule, re-scheduling occurs.
- POIs should count their usage in the schedules of NPCs
- if POI is not home, when schedule is built, prefer POIs with less usage and less distance to NPC "home". That serves a purpose of making the town more interesting and spreading NPCs around town.
- POIs are declared with tags with DSL or node set up for interiors and node-based for exterior. Should easily handle large number of POIs in editor UX and performance.
- For job POIs, the specification of the job in tags is kept, so when re-scheduling, they keep their "profeession" but job location can be different.
- Currently NPCs do not prefer any job type or leisure type, but that will be added at one point. However they prefer to keep the same job type, using the same tags in search as last one.
- symbolic names from schedule are searched as POI tags
- home is declared as house where NPC sleeps in and starts game in, should be set from the start for that NPC.
- all other symbolic names are searched among POIs as tags and best ones are selected.
@@ -3883,28 +4385,8 @@ implementation — the DSL side especially needs thought):
- for interior scene
- POIS declared in DSL can fully declare behavior tree for action and action itself. that we can declare behavior tree, action, actuator/POI in DSL the same way as with nodes/components directly, completely with GOAP/HTN data. Lua events and calls should be easily supported too. All these blocks will be heavily reused from lots of places, so it should be easy to reference them from other parts of DSL.
- for both interior and exterior - POIs which are have job tag, can also have job specification tags, like "shop, salesman", or "office, secretary" and these should be preferred when looking for other job. Also special kinds of job like "police", "ambulance",... should make NPCs listed there into special lists used by special game systems implemented later.
### Phase 6.8 - Initial game story fragment
The purpose of this phase is to check we have enough narration tools to implement fluent short story fragment.
This phase should be moved to a position where we think we have enough narration tools to implement this.
- First we need to point-out missing modelling/animation pieces.
- Second we need to check and finish our dialogue system based on ImGui as usual and Ink interpreter based on Lua (src/features/editScene/lua-scripts/narrator)
- Third we need to create and polish a framework to create stories and narrative missions like that without major development work with minimal efforts to keep ourselves productive. Probably another DSL on top of Lua or Ink-based narrative solution, which should be very clear to use and implement, without writing tons of scripts, C++ code and .ink files everywhere.
- Note that not whole narration is text, often it won't start with dialogue option or actuator, it might be time event or occurance at special place or near NPC matching some parameters..
pd
- The story: player character wakes up in startup exterior scene. He gets his own thought message that he needs to visit student dormitory to put out ad about rent for bed vacancy for a girl as he has heavy comfort issues he can't solve himself buf is fully capable of paying the rent. He arrives to the place and sees how one girl is rudely evicted from the dormitory by three other girls. The player character protects that girl or confronts her and brings her home:
- alternatives:
- she agrees to go with player character in the car as passenger and enters his initial house as permanent resident.
- the player character forces her into the car trunk and brings her home, which she enters as permanent resident.
- the alternative defines the outcome of initial relationship
- after the above events girl serves game sandbox functions:
- player character can call her from stationary phone locations (will be added) to ask her to deliver him a car
- getting back home and talking to the girl will restore character health (will be added as system)
- talking to the girl will level up player character if enough XP is generated (develop as level up and stats will be added)
- the player character can ask girl to prepare food for him.
- special taxi missions depending on game events.
- options unlock in relationship progress of the girl to the player character.
- for quicker search for POIs interior POIs should be indexed per building/per lot to be found quicker.
- something for research: it would be nice if i.e. shop employees would enter back door, but shop cutomers will enter front door; the POI for employee would be "shop, salesman" and for customer "shop, shopping". Some simple idea is needed...
### Phase 7 — CellGrid stair generation - before interiors
@@ -3960,6 +4442,13 @@ requirements moved from Phase 5.9). Builds on the 4.7
population/schedule systems, the 5.9 layout metadata (tags/score) and
the Phase 6.5/8 furniture/interior work — layouts already carry
`:type`-tagged rooms for this.
- this needs to be cleaned up and reformulated, need more ideas, and now it is a mess.
- the residents specoified in DSL are initial ones for new game. They should be stored and loaded in game saves. People should be able to die, give birth, transition from baby to a child, from child to teenager, from teenager to adult, from adult to elder.
- for cultural/censorship reasons it should be possible to prevent babies, children and teenagers from spawning, with them still simulated but assumed-only. However whole mechanics should remain working.
- the whole relationship thing should be implemented in Lua completely and behaviors should query relationship system with dedicated queries, when game AI needs to decide which action to perform.
- each NPC has associated GOAP action/HTN task which affects NPC states, groups, etc. These should be created in Lua script for each NPC.
- stats for each NPCs also should be created by Lua script.
- all these numbers and parameters should be persistently stored in save games.
- **Population control at three levels**: town, area and layout must
each be able to define and control the population in numbers
@@ -4046,6 +4535,29 @@ the Phase 6.5/8 furniture/interior work — layouts already carry
with distance, a timeout resets the chain to fare 1, completion
events fire, HUD state updates.
### Phase 10.1 - Initial game story fragment
The purpose of this phase is to check we have enough narration tools to implement fluent short story fragment.
This phase should be moved to a position where we think we have enough narration tools to implement this.
- First we need to point-out missing modelling/animation pieces.
- Second we need to check and finish our dialogue system based on ImGui as usual and Ink interpreter based on Lua (src/features/editScene/lua-scripts/narrator)
- Third we need to create and polish a framework to create stories and narrative missions like that without major development work with minimal efforts to keep ourselves productive. Probably another DSL on top of Lua or Ink-based narrative solution, which should be very clear to use and implement, without writing tons of scripts, C++ code and .ink files everywhere.
- Note that not whole narration is text, often it won't start with dialogue option or actuator, it might be time event or occurance at special place or near NPC matching some parameters..
pd
- The story: player character wakes up in startup exterior scene. He gets his own thought message that he needs to visit student dormitory to put out ad about rent for bed vacancy for a girl as he has heavy comfort issues he can't solve himself buf is fully capable of paying the rent. He arrives to the place and sees how one girl is rudely evicted from the dormitory by three other girls. The player character protects that girl or confronts her and brings her home:
- alternatives:
- she agrees to go with player character in the car as passenger and enters his initial house as permanent resident.
- the player character forces her into the car trunk and brings her home, which she enters as permanent resident.
- the alternative defines the outcome of initial relationship
- after the above events girl serves game sandbox functions:
- player character can call her from stationary phone locations (will be added) to ask her to deliver him a car
- getting back home and talking to the girl will restore character health (will be added as system)
- talking to the girl will level up player character if enough XP is generated (develop as level up and stats will be added)
- the player character can ask girl to prepare food for him.
- special taxi missions depending on game events.
- options unlock in relationship progress of the girl to the player character.
### Phase 10.5 — NPC looks: all ages, retargeted animations, more clothes
Today every townsperson renders with adult catalog parts
@@ -2,6 +2,7 @@
#include <fstream>
#include <cstdlib>
#include "EditorApp.hpp"
#include "GameMode.hpp"
#include "ProjectConfig.hpp"
#include "camera/EditorCamera.hpp"
#include "systems/CharacterRegistry.hpp"
@@ -12,7 +13,9 @@
#include "systems/VehicleSystem.hpp"
#include "systems/CharacterSystem.hpp"
#include "systems/PhysicsSystem.hpp"
#include "physics/physics.h"
#include "systems/TerrainSystem.hpp"
#include "systems/TownGenSystem.hpp"
#include "systems/RoadSystem.hpp"
#include "systems/RenderOriginSystem.hpp"
#include "systems/EventBus.hpp"
@@ -47,6 +50,9 @@
#include <map>
#include <set>
#include <Jolt/Physics/PhysicsSystem.h>
#include <Jolt/Physics/Vehicle/VehicleConstraint.h>
#include <Jolt/Physics/Vehicle/WheeledVehicleController.h>
#include <Jolt/Physics/Vehicle/Wheel.h>
#include <Ogre.h>
#include <OgreRoot.h>
#include <filesystem>
@@ -4796,7 +4802,7 @@ struct TrafficYieldTestListener : public Ogre::FrameListener {
* Part A (symbolic layer): with the pedestrian detail range untouched
* but the vehicle high-detail tier suppressed (maxActiveVehicles = 0)
* and maxWalkDistanceM forced low so drive legs exist, the paused clock
* is swept over the working day in float-exact 15-minute steps and every
* is swept over the working day in float-exact 5-minute steps and every
* car's (phase, position) is recorded. Asserts at least one car DRIVES
* a leg at some probe (phase 2, position off its home spot) and that
* re-visiting three probe times reproduces every car position bit-
@@ -4824,11 +4830,12 @@ struct TrafficScheduleTestListener : public Ogre::FrameListener {
Ogre::Vector3 teleportTarget = Ogre::Vector3::ZERO;
static const uint32_t kDay = 5;
static const int kProbes = 64; /* 6:00 .. 22:00 in 15 min steps */
static const int kProbes = 192; /* 6:00 .. 22:00 in 5 min steps */
int probe = 0;
int settleFrames = 0;
std::vector<std::map<uint64_t, std::pair<int, Ogre::Vector3>>> snaps;
int driveProbes = 0;
Ogre::String probeError;
/* Shortest driving leg seen (part B target). */
uint64_t driveCar = 0;
float driveHour = 0.0f;
@@ -4853,7 +4860,7 @@ struct TrafficScheduleTestListener : public Ogre::FrameListener {
float probeHour(int p) const
{
return 6.0f + (float)p * 0.25f;
return 6.0f + (float)p * (16.0f / (float)(kProbes - 1));
}
/* Re-teleport for a few frames: streaming terrain colliders may
@@ -4887,6 +4894,8 @@ struct TrafficScheduleTestListener : public Ogre::FrameListener {
NpcTrafficSystem::TrafficInfo info;
if (!traffic->trafficInfoOf(id, info))
continue;
if (info.parkedAtSlot.empty())
probeError = "empty parked-at slot during sweep";
Ogre::Vector3 pos;
if (!traffic->carWorldPosition(id, pos))
continue;
@@ -4923,7 +4932,7 @@ struct TrafficScheduleTestListener : public Ogre::FrameListener {
return true;
/* Force drive legs to exist and keep them short. */
traffic->maxWalkDistanceM = 50.0f;
traffic->cruiseSpeed = 22.0f;
traffic->cruiseSpeed = 11.0f;
/* No high-detail spawns during the sweep: part A is
* the pure symbolic layer. */
traffic->maxActiveVehicles = 0;
@@ -4946,6 +4955,8 @@ struct TrafficScheduleTestListener : public Ogre::FrameListener {
return true;
settleFrames = 0;
recordProbe(traffic, probeHour(probe));
if (!probeError.empty())
return fail(probeError);
if (++probe < kProbes)
return true;
/* Count probes with any driving car. */
@@ -4971,7 +4982,7 @@ struct TrafficScheduleTestListener : public Ogre::FrameListener {
if (phase == 2) {
/* Determinism: re-visit probes 8/24/40, positions
* must reproduce bit-exactly. */
static const int revisit[3] = { 8, 24, 40 };
static const int revisit[3] = { 24, 72, 120 };
if (settleFrames == 0)
clock->setTime(kDay,
probeHour(revisit[probe]));
@@ -5002,7 +5013,7 @@ struct TrafficScheduleTestListener : public Ogre::FrameListener {
* at real time so the AI driver and the schedule
* advance together. */
traffic->maxActiveVehicles = 8;
clock->setTime(kDay, driveHour);
clock->setTime(kDay, std::max(6.0f, driveHour - 0.05f));
clock->setSpeed(1.0f);
clock->setPaused(false);
Ogre::Vector3 carPos;
@@ -5073,7 +5084,15 @@ struct TrafficScheduleTestListener : public Ogre::FrameListener {
if (moved && info.phase == 0 &&
!traffic->isDriving(driveCar) &&
!traffic->isSeated(driveCar)) {
std::cout << "[test] PASS: traffic schedule "
const TownLocations::LocationSet *locs =
pop->areaLocations(info.area);
if (info.parkedAtSlot.empty() || !locs ||
!locs->findByName(info.parkedAtSlot))
return fail("parked-at is not a slot name");
std::cout << "[test] traffic schedule: parked at "
<< info.parkedAtSlot << " (chain "
<< info.parkedAtChain << ")\n"
<< "[test] PASS: traffic schedule "
"(drove, parked, driver alighted, "
"schedule resumed)"
<< std::endl;
@@ -5090,6 +5109,542 @@ struct TrafficScheduleTestListener : public Ogre::FrameListener {
}
};
/*
* --test-parking (phase 6.6.8): parking-slot acceptance gate.
* Symbolic day sweep: per-(area, slot, chain) occupancy is exclusive,
* claims name real reachable locations and re-probes are bit-exact.
* Observed leg: high-detail despawn/respawn preserves the claim, the
* driver only alights at the claimed park pose (not mid-lane), and a
* player carjack removes the fleet record and releases its claim.
*/
struct ParkingTestListener : public Ogre::FrameListener {
EditorApp *app;
int frame = 0;
int phase = 0;
int phaseFrame = 0;
int settleFrames = 0;
int deadline = 45000;
bool failed = false;
Ogre::String failReason;
static const uint32_t kDay = 5;
static const int kProbes = 192; /* 5-minute drive-window coverage */
int probe = 0;
struct Snap {
int phase = 0;
Ogre::Vector3 pos = Ogre::Vector3::ZERO;
std::string slot;
int chain = 0;
int area = -1;
};
std::vector<std::map<uint64_t, Snap> > snaps;
uint64_t driveCar = 0;
int driveProbes = 0;
float driveHour = 0.0f;
float driveLegLen = FLT_MAX;
Ogre::Vector3 driveStartPos = Ogre::Vector3::ZERO;
bool moved = false;
size_t fleetBefore = 0;
std::string despawnSlot;
int despawnChain = 0;
Ogre::Vector3 arrivalPos = Ogre::Vector3::ZERO;
Ogre::Vector3 teleportTarget = Ogre::Vector3::ZERO;
int teleportFrames = 0;
ParkingTestListener(EditorApp *a)
: app(a)
{
snaps.resize(kProbes);
}
bool fail(const Ogre::String &reason)
{
failed = true;
failReason = reason;
app->getRoot()->queueEndRendering();
return true;
}
float probeHour(int p) const
{
return 6.0f + (float)p * (16.0f / (float)(kProbes - 1));
}
void startTeleport(const Ogre::Vector3 &worldPos)
{
teleportTarget = worldPos;
teleportFrames = 90;
}
void teleportTick()
{
if (teleportFrames <= 0)
return;
--teleportFrames;
flecs::entity player = app->getPlayerCharacterEntity();
if (!player.is_alive() ||
!player.has<TransformComponent>())
return;
TransformComponent &t = player.get_mut<TransformComponent>();
if (!t.node)
return;
t.node->setPosition(teleportTarget);
t.position = teleportTarget;
}
static uint32_t hashName(const std::string &s)
{
uint32_t h = 2166136261u;
for (char c : s) {
h ^= (uint8_t)c;
h *= 16777619u;
}
return h;
}
const RoadGraph *roadGraphNow() const
{
TerrainSystem *ts = TerrainSystem::getInstance();
if (!ts || !ts->getRoadSystem())
return nullptr;
flecs::entity terrain = ts->getRoadSystem()->getTerrainEntity();
if (!terrain.is_alive() || !terrain.has<TerrainComponent>())
return nullptr;
return &terrain.get<TerrainComponent>().roadGraph;
}
Ogre::String claimError(NpcPopulationSystem *pop,
NpcTrafficSystem *traffic,
const NpcTrafficSystem::TrafficInfo &info) const
{
if (info.area < 0 || info.parkedAtSlot.empty())
return "car has no area/slot claim";
const TownLocations::LocationSet *locs =
pop->areaLocations(info.area);
const TownLocations::NpcLocation *loc =
locs ? locs->findByName(info.parkedAtSlot) : nullptr;
if (!loc)
return "claim is not a location slot name";
const RoadGraph *graph = roadGraphNow();
if (!traffic->slotReachable(info.area, *loc, graph))
return "claimed slot failed the road reachability probe";
return "";
}
Ogre::String occupancyError(NpcTrafficSystem *traffic) const
{
std::map<std::string, uint64_t> used;
for (uint64_t id : traffic->carDriverIds()) {
NpcTrafficSystem::TrafficInfo info;
if (!traffic->trafficInfoOf(id, info))
continue;
if (info.parkedAtSlot.empty())
return "empty slot in occupancy registry";
std::string key = std::to_string(info.area) + "|" +
info.parkedAtSlot + "|" +
std::to_string(info.parkedAtChain);
auto ins = used.insert(std::make_pair(key, id));
if (!ins.second)
return "slot occupied by drivers " +
std::to_string(ins.first->second) +
" and " + std::to_string(id);
}
return "";
}
Ogre::String recordProbe(NpcTrafficSystem *traffic,
NpcPopulationSystem *pop, float hour)
{
for (uint64_t id : traffic->carDriverIds()) {
NpcTrafficSystem::TrafficInfo info;
if (!traffic->trafficInfoOf(id, info))
return "fleet record vanished during sweep";
Ogre::String err = claimError(pop, traffic, info);
if (!err.empty())
return err;
Ogre::Vector3 pos;
if (!traffic->carWorldPosition(id, pos))
return "car position vanished during sweep";
Snap &s = snaps[probe][id];
s.phase = info.phase;
s.pos = pos;
s.slot = info.parkedAtSlot;
s.chain = info.parkedAtChain;
s.area = info.area;
if (info.phase == 2 && info.routeLength > 1.0f &&
info.routeLength < driveLegLen) {
driveLegLen = info.routeLength;
driveCar = id;
driveHour = hour;
}
}
return occupancyError(traffic);
}
bool expectedParkPose(NpcPopulationSystem *pop,
NpcTrafficSystem *traffic,
const NpcTrafficSystem::TrafficInfo &info,
Ogre::Vector3 &pos, Ogre::Quaternion &rot) const
{
const TownLocations::LocationSet *locs =
pop->areaLocations(info.area);
const TownLocations::NpcLocation *loc =
locs ? locs->findByName(info.parkedAtSlot) : nullptr;
const RoadGraph *graph = roadGraphNow();
if (!loc || !graph)
return false;
if (loc->hasHeading) {
pos = loc->position;
rot = Ogre::Quaternion(
Ogre::Radian(loc->headingYaw),
Ogre::Vector3::UNIT_Y);
return true;
}
return RoadPath::curbChainPose(
*graph, loc->position, info.parkedAtChain,
traffic->curbChainCarLength, traffic->curbChainGap,
traffic->curbLateralOffset,
(hashName(loc->name) & 0x10000u) == 0, pos, rot);
}
flecs::entity nearestVehicle(const Ogre::Vector3 &worldPos) const
{
flecs::entity best;
float bestDist = 36.0f;
app->getWorld()
->query<VehicleComponent, TransformComponent>()
.each([&](flecs::entity e, VehicleComponent &,
TransformComponent &t) {
if (!t.node)
return;
Ogre::Vector3 p = t.node->_getDerivedPosition();
RenderOriginSystem *ro =
RenderOriginSystem::getInstance();
if (ro) {
JPH::DVec3 w = ro->renderToWorld(p);
p = Ogre::Vector3((float)w.GetX(),
(float)w.GetY(),
(float)w.GetZ());
}
float d = (p - worldPos).squaredLength();
if (d < bestDist) {
bestDist = d;
best = e;
}
});
return best;
}
bool frameRenderingQueued(const Ogre::FrameEvent &) override
{
++frame;
if (failed)
return true;
if (frame > deadline)
return fail("watchdog timeout");
teleportTick();
NpcTrafficSystem *traffic = NpcTrafficSystem::getInstance();
NpcPopulationSystem *pop = NpcPopulationSystem::getInstance();
NpcMotionSystem *motion = NpcMotionSystem::getInstance();
GameClockSystem *clock = GameClockSystem::getInstance();
if (!traffic || !pop || !motion || !clock)
return true;
if (phase == 0) {
if (traffic->carCount() == 0)
return true;
traffic->maxWalkDistanceM = 50.0f;
traffic->cruiseSpeed = 11.0f;
traffic->maxActiveVehicles = 0;
clock->setPaused(true);
fleetBefore = traffic->carCount();
std::cout << "[test] parking: fleet " << fleetBefore
<< " car(s), occupancy day sweep" << std::endl;
phase = 1;
return true;
}
if (phase == 1) {
if (settleFrames == 0)
clock->setTime(kDay, probeHour(probe));
if (++settleFrames < 3)
return true;
settleFrames = 0;
Ogre::String err = recordProbe(traffic, pop, probeHour(probe));
if (!err.empty())
return fail(err);
if (++probe < kProbes)
return true;
for (int p = 0; p < kProbes; ++p)
for (const auto &kv : snaps[p])
if (kv.second.phase == 2) {
++driveProbes;
break;
}
if (driveProbes == 0 || !driveCar)
return fail("no drive leg in parking day sweep");
std::cout << "[test] parking: " << driveProbes
<< " driving probes; observed leg "
<< driveLegLen << " m" << std::endl;
probe = 0;
phase = 2;
return true;
}
if (phase == 2) {
static const int revisit[3] = { 24, 72, 120 };
if (settleFrames == 0)
clock->setTime(kDay,
probeHour(revisit[probe]));
if (++settleFrames < 3)
return true;
settleFrames = 0;
for (const auto &kv : snaps[revisit[probe]]) {
NpcTrafficSystem::TrafficInfo info;
if (!traffic->trafficInfoOf(kv.first, info))
return fail("car vanished on determinism probe");
Ogre::Vector3 pos;
traffic->carWorldPosition(kv.first, pos);
const Snap &s = kv.second;
if (info.phase != s.phase || pos != s.pos ||
info.parkedAtSlot != s.slot ||
info.parkedAtChain != s.chain ||
info.area != s.area)
return fail("parking snapshot not bit-exact");
}
Ogre::String err = occupancyError(traffic);
if (!err.empty())
return fail(err);
if (++probe < 3)
return true;
std::cout << "[test] parking: re-probes bit-exact"
<< std::endl;
phase = 3;
return true;
}
if (phase == 3) {
traffic->maxActiveVehicles = 8;
clock->setTime(kDay, std::max(6.0f, driveHour - 0.05f));
clock->setSpeed(1.0f);
clock->setPaused(false);
Ogre::Vector3 carPos;
if (!traffic->carWorldPosition(driveCar, carPos))
return fail("observed car vanished");
startTeleport(carPos +
Ogre::Vector3(20.0f, 3.0f, 20.0f));
phaseFrame = 0;
phase = 4;
return true;
}
if (phase == 4) {
if (teleportFrames > 0)
return true;
++phaseFrame;
NpcTrafficSystem::TrafficInfo info;
if (!traffic->trafficInfoOf(driveCar, info))
return fail("observed car record lost");
if (!info.highDetail || !traffic->isSeated(driveCar)) {
if (phaseFrame > 2400)
return fail("observed car never seated");
return true;
}
if (!traffic->carWorldPosition(driveCar, driveStartPos))
return fail("observed car position lost");
phaseFrame = 0;
phase = 5;
return true;
}
if (phase == 5) {
++phaseFrame;
Ogre::Vector3 carPos;
if (!traffic->carWorldPosition(driveCar, carPos))
return fail("car vanished before despawn");
if ((carPos - driveStartPos).length() < 5.0f) {
if (phaseFrame > 4000)
return fail("car never drove for despawn test");
return true;
}
NpcTrafficSystem::TrafficInfo info;
traffic->trafficInfoOf(driveCar, info);
despawnSlot = info.parkedAtSlot;
despawnChain = info.parkedAtChain;
clock->setPaused(true);
startTeleport(carPos +
Ogre::Vector3(1000.0f, 20.0f, 1000.0f));
std::cout << "[test] parking: forcing high-detail despawn"
<< std::endl;
phaseFrame = 0;
phase = 6;
return true;
}
if (phase == 6) {
++phaseFrame;
if (phaseFrame < 330)
return true;
NpcTrafficSystem::TrafficInfo info;
if (!traffic->trafficInfoOf(driveCar, info))
return fail("despawn erased the symbolic car");
if (info.highDetail)
return fail("car did not despawn out of range");
if (info.parkedAtSlot != despawnSlot ||
info.parkedAtChain != despawnChain)
return fail("despawn changed the slot claim");
Ogre::String err = occupancyError(traffic);
if (!err.empty())
return fail(err);
Ogre::Vector3 carPos;
traffic->carWorldPosition(driveCar, carPos);
startTeleport(carPos +
Ogre::Vector3(20.0f, 3.0f, 20.0f));
phaseFrame = 0;
phase = 7;
return true;
}
if (phase == 7) {
if (teleportFrames > 0)
return true;
++phaseFrame;
NpcTrafficSystem::TrafficInfo info;
if (!traffic->trafficInfoOf(driveCar, info))
return fail("car vanished before respawn");
if (!info.highDetail) {
if (phaseFrame > 3000)
return fail("car never respawned");
return true;
}
/* A live arrival may have completed while the clock
* was paused: a parked respawn has no seated driver,
* and that is a valid drop-off, not a stall. */
if (info.phase != 0 && !traffic->isSeated(driveCar)) {
if (phaseFrame > 3000)
return fail("driver never respawned");
return true;
}
clock->setPaused(false);
phaseFrame = 0;
phase = 8;
return true;
}
if (phase == 8) {
++phaseFrame;
NpcTrafficSystem::TrafficInfo info;
if (!traffic->trafficInfoOf(driveCar, info))
return fail("car vanished before drop-off");
if (info.phase != 0 || traffic->isDriving(driveCar) ||
traffic->isSeated(driveCar)) {
if (phaseFrame > 20000)
return fail("driver never alighted at slot");
return true;
}
Ogre::String err = claimError(pop, traffic, info);
if (!err.empty())
return fail(err);
if (!traffic->carWorldPosition(driveCar, arrivalPos))
return fail("parked car position lost");
std::cout << "[test] parking: dropped at "
<< info.parkedAtSlot << "; carjacking"
<< std::endl;
phaseFrame = 0;
phase = 9;
return true;
}
if (phase == 9) {
++phaseFrame;
NpcTrafficSystem::TrafficInfo info;
if (!traffic->trafficInfoOf(driveCar, info))
return fail("parked car vanished before carjack");
if (!info.highDetail) {
if (teleportFrames == 0 && phaseFrame == 1)
startTeleport(arrivalPos +
Ogre::Vector3(12.0f, 3.0f,
12.0f));
if (teleportFrames > 0)
return true;
if (phaseFrame > 3000)
return fail("parked car never respawned");
return true;
}
if (!traffic->carWorldPosition(driveCar, arrivalPos))
return fail("live parked car position lost");
{
Ogre::Vector3 want;
Ogre::Quaternion rot;
if (!expectedParkPose(pop, traffic, info,
want, rot))
return fail("cannot resolve park pose");
Ogre::Vector3 delta = arrivalPos - want;
delta.y = 0.0f;
Ogre::Vector3 nose = rot * Ogre::Vector3::UNIT_Z;
nose.y = 0.0f;
nose.normalise();
Ogre::Vector3 side(nose.z, 0.0f, -nose.x);
if (std::fabs(delta.dotProduct(side)) >
traffic->parkArriveM + 0.05f ||
std::fabs(delta.dotProduct(nose)) >
traffic->parkLongitudinalM + 0.05f)
return fail("drop-off outside the park box");
const RoadGraph *graph = roadGraphNow();
Ogre::Vector3 proj, dir;
float halfW = 0.0f;
if (graph &&
RoadPath::nearestEdgeInfo(*graph, arrivalPos,
proj, dir, halfW)) {
Ogre::Vector3 lateral = arrivalPos - proj;
lateral.y = 0.0f;
if (lateral.length() < halfW - 0.35f)
return fail("driver alighted mid-lane");
}
}
flecs::entity vehicle = nearestVehicle(arrivalPos);
if (!vehicle.is_alive())
return fail("cannot find carjack vehicle");
VehicleControllerSystem *vcs =
app->getVehicleControllerSystem();
if (!vcs || !vcs->enterVehicle(vehicle))
return fail("player could not carjack traffic car");
phaseFrame = 0;
phase = 10;
return true;
}
if (phase == 10) {
++phaseFrame;
NpcTrafficSystem::TrafficInfo info;
if (traffic->trafficInfoOf(driveCar, info)) {
if (phaseFrame > 300)
return fail("carjack kept the fleet record");
return true;
}
if (traffic->isSeated(driveCar) ||
traffic->isDriving(driveCar))
return fail("carjacked driver stayed traffic-owned");
if (traffic->carCount() + 1 != fleetBefore)
return fail("carjack changed the wrong fleet count");
Ogre::String err = occupancyError(traffic);
if (!err.empty())
return fail(err);
std::cout << "[test] PASS: parking (exclusive claims, "
"reachable slots, deterministic snapshots, "
"despawn, no mid-lane drop, carjack)"
<< std::endl;
phase = 11;
app->getRoot()->queueEndRendering();
return true;
}
return true;
}
};
/*
* --test-furniture (phase 6.5.I): the furnished demo scene
* (demo_scene_furniture.json) end-to-end. Three full-mode CellGrids:
@@ -7083,6 +7638,7 @@ int main(int argc, char *argv[])
bool testTrafficFollow = false;
bool testTrafficYield = false;
bool testTrafficSchedule = false;
bool testParking = false;
bool testFurniture = false;
bool benchmark = false;
bool benchmarkDrive = false;
@@ -7148,6 +7704,8 @@ int main(int argc, char *argv[])
testTrafficYield = true;
} else if (arg == "--test-traffic-schedule") {
testTrafficSchedule = true;
} else if (arg == "--test-parking") {
testParking = true;
} else if (arg == "--test-furniture") {
testFurniture = true;
} else if (arg == "--benchmark") {
@@ -7270,6 +7828,9 @@ int main(int argc, char *argv[])
/* The schedule traffic test needs the generated town. */
if (testTrafficSchedule && !sceneArgGiven)
sceneFile = "demo_scene_exterior.json";
/* The parking acceptance probe needs it too. */
if (testParking && !sceneArgGiven)
sceneFile = "demo_scene_exterior.json";
/* The furniture test loads the furnished demo scene. */
if (testFurniture && !sceneArgGiven)
sceneFile = "demo_scene_furniture.json";
@@ -7415,6 +7976,10 @@ int main(int argc, char *argv[])
if (testTrafficSchedule)
app.getRoot()->addFrameListener(&trafficScheduleListener);
ParkingTestListener parkingListener(&app);
if (testParking)
app.getRoot()->addFrameListener(&parkingListener);
FurnitureDemoTestListener furnitureTestListener(&app);
if (testFurniture)
app.getRoot()->addFrameListener(&furnitureTestListener);
@@ -7797,6 +8362,26 @@ int main(int argc, char *argv[])
}
}
if (testParking) {
if (parkingListener.failed) {
std::cerr << "[test] FAIL: "
<< parkingListener.failReason
<< std::endl;
app.clearScene();
app.closeApp();
return 1;
}
if (parkingListener.phase != 11) {
std::cerr << "[test] FAIL: incomplete (parking "
"phase "
<< parkingListener.phase << ")"
<< std::endl;
app.clearScene();
app.closeApp();
return 1;
}
}
if (testFurniture) {
if (furnitureTestListener.failed) {
std::cerr << "[test] FAIL: "
@@ -8,7 +8,9 @@
; tag are candidates for a lot.
; :score — weight of the random pick between candidates (default 1).
; Rooms carry :type so the interior generator knows where beds and
; living-room furniture go (phase 4.7.3).
; living-room furniture go (phase 4.7.3). Front-right parking rects
; keep the bays beside the shell and clear of the front-door corridor
; (phase 6.6).
; The 3-room C-shape house authored on edge 0 of road page 2_2
; (corridor spine with the living room and bedroom branching off the
@@ -24,7 +26,8 @@
(connect corridor1 bedroom1)
(door front -8)
(windows all))
(roof normal 0 0 w d)))
(roof normal 0 0 w d)
(parking (rect-front-center 7.8 5.2))))
; Small one-floor cottage: living room + bedroom side by side.
(layout cottage :tags (house village) :score 2
@@ -36,7 +39,8 @@
(connect living1 bedroom1)
(door front center)
(windows all))
(roof gable 0 0 w d)))
(roof gable 0 0 w d)
(parking (rect-front-center 5.2 5.2))))
; Two-floor family house: living+kitchen downstairs, bedrooms up.
(layout two-floor-house :tags (house village town) :score 1
@@ -53,9 +57,11 @@
(room bedroom2 (rect 0.5 0 1 1) :type bedroom)
(connect bedroom1 bedroom2)
(windows all))
(roof gable 0 0 w d)))
(roof gable 0 0 w d)
(parking (rect-front-center 5.2 5.2))))
; Single-floor corner shop with a flat roof.
; Single-floor corner shop with a flat roof. Commercial lots get
; at least 4 bays along the road (13 m = 5 bays here, 6.6.6).
(layout corner-shop :tags (commercial shop village town) :score 1
(let ((w 16) (d 12))
(shell w d :floors 1 :empty)
@@ -65,4 +71,5 @@
(connect shop1 storage1)
(door front center)
(windows front))
(roof flat 0 0 w d)))
(roof flat 0 0 w d)
(parking (rect-front-center 13 5.2))))
@@ -1801,7 +1801,8 @@ bool LayoutEval::verbRoof(const std::vector<Value> &pos,
bool LayoutEval::verbParking(const SExpr &form)
{
/* (parking x z w d) or (parking (rect-front-left w d)) /
* (parking (rect-front-right w d)) — LOT frame meters (origin
* (parking (rect-front-right w d)) / (parking
* (rect-front-center w d)) — LOT frame meters (origin
* at the front-left of the inset buildable rect, +x along the
* frontage, +z into the lot). */
if (form.list.size() < 2)
@@ -1820,6 +1821,11 @@ bool LayoutEval::verbParking(const SExpr &form)
} else if (a1.list[0].isSymbol("rect-front-right")) {
p.x = m_b.lotW - (float)w;
p.z = 0.0f;
} else if (a1.list[0].isSymbol("rect-front-center")) {
/* Mid-frontage: driveways grade and connect best
* away from lot corners (junction wedges). */
p.x = (m_b.lotW - (float)w) * 0.5f;
p.z = 0.0f;
} else {
return fail(a1, "unknown parking rect '" +
a1.list[0].text + "'");
+88 -1
View File
@@ -409,9 +409,50 @@ void offsetToRightLane(const RoadGraph &graph,
}
}
bool projectOnEdge(const RoadGraph &graph, int edgeIndex,
const Ogre::Vector3 &pos, Ogre::Vector3 &projPoint,
Ogre::Vector3 &edgeDir, float &carriagewayHalf,
float *surfaceTopY)
{
if (edgeIndex < 0 || edgeIndex >= (int)graph.edges.size())
return false;
const RoadEdge &e = graph.edges[edgeIndex];
const RoadNode *na = graph.findNodeById(e.nodeA);
const RoadNode *nb = graph.findNodeById(e.nodeB);
if (!na || !nb)
return false;
const Ogre::Vector3 &a = na->position;
const Ogre::Vector3 &b = nb->position;
const float abx = b.x - a.x;
const float abz = b.z - a.z;
const float len2 = abx * abx + abz * abz;
float t = 0.0f;
if (len2 > 1.0e-12f) {
t = ((pos.x - a.x) * abx + (pos.z - a.z) * abz) / len2;
t = std::max(0.0f, std::min(1.0f, t));
}
projPoint = a + (b - a) * t;
edgeDir = b - a;
edgeDir.y = 0.0f;
if (edgeDir.squaredLength() < 1.0e-10f)
return false;
edgeDir.normalise();
int la = 0, lb = 0;
graph.resolveLaneCounts(e, e.nodeA, la, lb);
carriagewayHalf = (float)(la + lb) * graph.config.laneWidth * 0.5f;
if (surfaceTopY) {
const float surfA = a.y + e.roadLevelA;
const float surfB = b.y + e.roadLevelB;
*surfaceTopY = surfA + (surfB - surfA) * t +
graph.config.roadThickness * 0.5f;
}
return true;
}
bool nearestEdgeInfo(const RoadGraph &graph, const Ogre::Vector3 &pos,
Ogre::Vector3 &projPoint, Ogre::Vector3 &edgeDir,
float &carriagewayHalf)
float &carriagewayHalf, float *surfaceTopY,
const RoadEdge **edgeOut)
{
if (graph.edges.empty())
return false;
@@ -423,6 +464,7 @@ bool nearestEdgeInfo(const RoadGraph &graph, const Ogre::Vector3 &pos,
const RoadEdge *best = nullptr;
float bestD = std::numeric_limits<float>::max();
float bestT = 0.0f;
Ogre::Vector3 bestProj = Ogre::Vector3::ZERO;
Ogre::Vector3 bestA = Ogre::Vector3::ZERO;
Ogre::Vector3 bestB = Ogre::Vector3::ZERO;
@@ -449,6 +491,7 @@ bool nearestEdgeInfo(const RoadGraph &graph, const Ogre::Vector3 &pos,
if (d < bestD) {
bestD = d;
best = &e;
bestT = t;
bestProj = proj;
bestA = a;
bestB = b;
@@ -458,6 +501,14 @@ bool nearestEdgeInfo(const RoadGraph &graph, const Ogre::Vector3 &pos,
return false;
projPoint = bestProj;
if (surfaceTopY) {
const float surfA = bestA.y + best->roadLevelA;
const float surfB = bestB.y + best->roadLevelB;
*surfaceTopY = surfA + (surfB - surfA) * bestT +
graph.config.roadThickness * 0.5f;
}
if (edgeOut)
*edgeOut = best;
edgeDir = bestB - bestA;
edgeDir.y = 0.0f;
if (edgeDir.squaredLength() > 1.0e-8f)
@@ -473,4 +524,40 @@ bool nearestEdgeInfo(const RoadGraph &graph, const Ogre::Vector3 &pos,
return true;
}
bool curbChainPose(const RoadGraph &graph, const Ogre::Vector3 &poiPos,
int k, float carLen, float gap, float curbMargin,
bool flipDir, Ogre::Vector3 &pos, Ogre::Quaternion &rot)
{
Ogre::Vector3 proj, edgeDir;
float halfW = 0.0f;
if (!nearestEdgeInfo(graph, poiPos, proj, edgeDir, halfW))
return false;
/* Lateral: onto the curb line on the POI's side of the road,
* curbMargin past the carriageway edge so the car body clears
* the outer lane. */
Ogre::Vector3 off = poiPos - proj;
off.y = 0.0f;
Ogre::Vector3 side = off - edgeDir * off.dotProduct(edgeDir);
if (side.squaredLength() <= 1.0e-8f) {
/* POI on the centerline: park right of the edge
* direction. */
side = Ogre::Vector3(edgeDir.z, 0.0f, -edgeDir.x);
}
side.normalise();
pos = proj + side * (halfW + curbMargin);
pos.y = poiPos.y;
/* Nose along the edge; the caller's deterministic bit picks
* the direction. */
Ogre::Vector3 nose = flipDir ? -edgeDir : edgeDir;
rot = Ogre::Vector3::UNIT_Z.getRotationTo(nose);
/* The chain grows behind the nose: spot k sits
* k * (carLen + gap) behind spot 0. */
if (k > 0)
pos -= nose * ((float)k * (carLen + gap));
return true;
}
} /* namespace RoadPath */
+40 -1
View File
@@ -3,6 +3,7 @@
#include <vector>
#include <OgreVector3.h>
#include <OgreQuaternion.h>
#include "../components/RoadGraph.hpp"
@@ -75,15 +76,53 @@ float polylineLength(const std::vector<Ogre::Vector3> &path);
Ogre::Vector3 polylineAt(const std::vector<Ogre::Vector3> &path,
float dist);
/**
* Project @p pos onto one specific road edge (demo-taxi phase 6.6.6):
* the same outputs as nearestEdgeInfo() but pinned to
* graph.edges[edgeIndex] — near corners a nearest-edge query snaps to
* the perpendicular street, which must not grade a parking pad.
* Returns false when the index is invalid or the edge's nodes are
* missing.
*/
bool projectOnEdge(const RoadGraph &graph, int edgeIndex,
const Ogre::Vector3 &pos, Ogre::Vector3 &projPoint,
Ogre::Vector3 &edgeDir, float &carriagewayHalf,
float *surfaceTopY = nullptr);
/**
* Nearest point on the nearest road EDGE to @p pos in XZ (demo-taxi
* phase 4.9.4 road clearing). Outputs the projected point, the edge's
* unit direction (A->B, XZ) and the carriageway half width (resolved
* lane counts x laneWidth / 2, no sidewalk/apron). Returns false for
* an empty/edgeless graph.
*
* When @p surfaceTopY is given (demo-taxi phase 6.6.6), it receives the
* road TOP height at the projection: the per-end road surface levels
* (node.position.y + RoadEdge::roadLevelA/B) lerped by the projection
* parameter plus half the road thickness — projPoint.y alone is the raw
* node lerp and misses the per-end level offsets. When @p edgeOut is
* given it receives the nearest edge (for sidewalk/apron queries).
*/
bool nearestEdgeInfo(const RoadGraph &graph, const Ogre::Vector3 &pos,
Ogre::Vector3 &projPoint, Ogre::Vector3 &edgeDir,
float &carriagewayHalf);
float &carriagewayHalf, float *surfaceTopY = nullptr,
const RoadEdge **edgeOut = nullptr);
/**
* Parallel-parking curb chain (demo-taxi phase 6.6.4): spot @p k of a
* curb POI is the POI's nearest-edge projection pushed out to the curb
* line — @p curbMargin past the carriageway half width on the POI's
* side of the road, so the car body stays clear of the outer lane —
* then shifted @p k * (@p carLen + @p gap) backwards along the nose
* direction, so chained cars line up front-to-back. The nose runs
* along the edge; @p flipDir picks the direction (the traffic system's
* deterministic name-hash bit). k = 0 is the curb-line pose itself.
*
* Pure RoadGraph geometry — deterministic, no RNG. Returns false for
* an empty/edgeless graph.
*/
bool curbChainPose(const RoadGraph &graph, const Ogre::Vector3 &poiPos,
int k, float carLen, float gap, float curbMargin,
bool flipDir, Ogre::Vector3 &pos, Ogre::Quaternion &rot);
} /* namespace RoadPath */
+308 -20
View File
@@ -1,9 +1,12 @@
#include "TownGen.hpp"
#include "TownParking.hpp"
#include "RoadPath.hpp"
#include "../layoutlib/LayoutEval.hpp"
#include <algorithm>
#include <cmath>
#include <cfloat>
#include <map>
#include <random>
@@ -35,6 +38,17 @@ const DistrictParams &districtParams(District d)
* floor (meters). */
static const float STAIRS_THRESHOLD = 0.35f;
/** Deterministic name hash (same FNV-1a as the traffic stack). */
static uint32_t fnv1a(const std::string &s)
{
uint32_t h = 2166136261u;
for (unsigned char c : s) {
h ^= c;
h *= 16777619u;
}
return h;
}
/** Extra gap kept between a building wall and the road body on top
* of the road half-width (meters). */
static const float ROAD_CLEARANCE_MARGIN = 0.3f;
@@ -405,7 +419,15 @@ TownResult generateTown(const RoadGraph &graph, const TownParams &params,
int bestTier = -1;
for (size_t i = 0; i < n; ++i) {
size_t j = (i + 1) % n;
float len = (lot.polygon[j] - lot.polygon[i]).length();
/* XZ length: the road nodes' Y moves when the
* roads settle against the terrain, and a 3D
* length would let that flip near-tie frontage
* picks (town generation must be a pure
* function of the XZ topology). */
Ogre::Vector3 edgeVec =
lot.polygon[j] - lot.polygon[i];
edgeVec.y = 0.0f;
float len = edgeVec.length();
int tier = 0;
if (!lot.nodeIds.empty() &&
RoadNetworkGen::findStreetForEdge(
@@ -837,28 +859,294 @@ TownResult generateTown(const RoadGraph &graph, const TownParams &params,
result.pois.push_back(poi);
/* Parking pads (layout DSL lot features) become
* extra POIs: strip-frame rect center -> world,
* "<address> parking [i]". */
* one POI per enumerated bay slot (phase
* 6.6.1): strip-frame rect -> world,
* "<address> parking [i]-s<k>", nose toward
* the street (the building's yaw maps local
* +Z to the street side). */
const TownBuilding &built = result.buildings.back();
for (size_t pi = 0; pi < built.plan.parkings.size();
++pi) {
const LayoutLib::PlanParking &pp =
built.plan.parkings[pi];
TownPoi ppPoi;
ppPoi.name = built.address + " parking";
ppPoi.kind = TownPoi::Parking;
if (built.plan.parkings.size() > 1)
ppPoi.name += " " + std::to_string(pi + 1);
ppPoi.position = frontMid +
u * (su0 + pp.x + pp.w * 0.5f) +
v * (vMin + pp.z + pp.d * 0.5f);
ppPoi.position.y =
sampleY(sampleHeight, ppPoi.position.x,
ppPoi.position.z,
lot.centroid.y);
ppPoi.building =
(int)result.buildings.size() - 1;
result.pois.push_back(ppPoi);
/* Phase 6.6.6 (final): a parking rect becomes a frustum
* DRIVEWAY only where it fits cleanly - top below the road,
* clear of the house walls and of the doorstep, no terrain
* poking through. Every other rect parks PARALLEL at the
* roadside (a perpendicular bay needs a driveway; without
* one there is never enough room). The rect is a local
* copy: a candidate may shift street-ward to clear the
* house/doorstep, and the bay slots follow it. */
LayoutLib::PlanParking pp = built.plan.parkings[pi];
const float ppZ0 = pp.z;
TownResult::Pad pad;
pad.building = (int)result.buildings.size() - 1;
pad.address = built.address;
pad.edge = built.paramsEdge;
pad.yaw = built.yaw;
pad.w = pp.w;
pad.d = pp.d;
const Ogre::Vector3 vPad(-std::sin(pad.yaw), 0.0f,
-std::cos(pad.yaw));
auto centerOf = [&]() {
return frontMid + u * (su0 + pp.x + pp.w * 0.5f) +
v * (vMin + pp.z + pp.d * 0.5f);
};
pad.center = centerOf();
pad.center.y = sampleY(sampleHeight, pad.center.x,
pad.center.z, lot.centroid.y);
const float groundCenter = pad.center.y;
/* Road data: the pinned frontage edge (a corner lot cannot
* grade from the perpendicular street), nearest-edge
* fallback. */
Ogre::Vector3 proj, dir;
float halfW = 0.0f;
float roadTop = 0.0f;
const RoadEdge *edgeOut =
pad.edge >= 0 && pad.edge < (int)graph.edges.size()
? &graph.edges[pad.edge]
: nullptr;
Ogre::Vector3 frontCenter = pad.center - vPad * (pad.d * 0.5f);
bool haveEdge =
edgeOut &&
RoadPath::projectOnEdge(graph, pad.edge, frontCenter, proj,
dir, halfW, &roadTop);
if (!haveEdge)
haveEdge = RoadPath::nearestEdgeInfo(graph, frontCenter,
proj, dir, halfW, &roadTop,
&edgeOut);
/* The parking surface: 0.2 below the road top, NEVER above
* it (a higher platform would poke through the road where
* the road level drops along the pad frontage). The top
* face reaches the carriageway edge (half-width + 0.25 curb
* backoff), bridging the entry-apron band - no overlap into
* the road body. */
const float topY = roadTop - 0.2f;
const float bench = halfW + 0.25f;
Ogre::Vector3 lat = frontCenter - proj;
lat.y = 0.0f;
float reach = std::max(0.0f, lat.length() - bench);
/* The lot frame: rect + house + doorstep, all in the strip
* axes (u along the street, v into the lot). */
float x0 = su0 + pp.x;
float x1 = x0 + pp.w;
float z0 = vMin + pp.z;
float z1 = z0 + pp.d;
const float bx = (built.center - frontMid).dotProduct(u);
const float bz = (built.center - frontMid).dotProduct(v);
const float bm = 0.3f; /* keep the frustum off the walls */
const float bX0 = bx - built.width * 0.5f - bm;
const float bX1 = bx + built.width * 0.5f + bm;
const float bZ0 = bz - built.depth * 0.5f - bm;
const float bZ1 = bz + built.depth * 0.5f + bm;
const float dX = (built.doorstep - frontMid).dotProduct(u);
const float dZ = (built.doorstep - frontMid).dotProduct(v);
const float dc = 1.5f; /* == TownParking doorClear */
auto groundAt = [&](float fx, float fz) {
const Ogre::Vector3 wp = frontMid + u * fx + v * fz;
return sampleY(sampleHeight, wp.x, wp.z, lot.centroid.y);
};
float drop = 0.35f;
float expand = 0.0f;
/* Settle the 30-degree slope expansion against the terrain:
* the bottom face must end up below the LOWEST ground over
* the whole footprint, or the slope feet float. */
auto settle = [&]() {
for (int it = 0; it < 2; ++it) {
const float fx0 = x0 - expand;
const float fx1 = x1 + expand;
const float fz0 = z0 - reach - expand;
const float fz1 = z1 + expand;
const float mx = (fx0 + fx1) * 0.5f;
const float mz = (fz0 + fz1) * 0.5f;
float minG = groundAt(mx, mz);
const float rx[4] = { fx0, fx1, mx, mx };
const float rz[4] = { mz, mz, fz0, fz1 };
for (int r = 0; r < 4; ++r) {
minG = std::min(minG, groundAt(rx[r], fz0));
minG = std::min(minG, groundAt(rx[r], fz1));
minG = std::min(minG, groundAt(fx0, rz[r]));
minG = std::min(minG, groundAt(fx1, rz[r]));
}
drop = topY - minG + 0.4f;
expand = drop * 1.7320508f;
}
};
/* The whole frustum footprint (top face + slope runs) must
* stay clear of the house and of the doorstep clearance -
* that is what keeps the building exits unblocked and the
* geometry out of the walls. */
auto hitsHouse = [&]() {
const float fx0 = x0 - expand;
const float fx1 = x1 + expand;
const float fz0 = z0 - reach - expand;
const float fz1 = z1 + expand;
const bool hitB = fx1 > bX0 && fx0 < bX1 &&
fz1 > bZ0 && fz0 < bZ1;
const bool hitD = fx1 > dX - dc && fx0 < dX + dc &&
fz1 > dZ - dc && fz0 < dZ + dc;
return hitB || hitD;
};
bool raised = haveEdge && (roadTop - groundCenter) > 0.35f;
if (raised) {
settle();
/* A platform AT/BELOW the house floor (+ a 0.15 m door
* threshold step) may tuck against
* (and under) the house: it meets the foundation and
* can never cut a wall, the stairs or the door. A
* platform ABOVE the floor would slice the shell and
* bury the exit, so its whole footprint (slopes
* included) must clear the house and the doorstep -
* one street-ward shift may buy the room. */
const bool lowPlatform = topY <= built.floorY + 0.15f;
if (!lowPlatform && hitsHouse()) {
float limit = FLT_MAX;
const float fx0 = x0 - expand;
const float fx1 = x1 + expand;
if (fx1 > bX0 && fx0 < bX1)
limit = std::min(limit, bZ0 - 0.1f);
if (fx1 > dX - dc && fx0 < dX + dc)
limit = std::min(limit, dZ - dc - 0.1f);
const float shift = (z1 + expand) - limit;
if (shift <= 0.0f || shift > 8.0f ||
reach - shift < 0.0f) {
raised = false;
} else {
pp.z -= shift;
z0 -= shift;
z1 -= shift;
reach -= shift;
settle();
if (hitsHouse())
raised = false;
}
}
/* No terrain may poke through the parking surface
* (samples under the house are the foundation's
* business; the 0.3 m nearest the roadbed is the
* road's). */
if (raised) {
const float zs = z0 - reach + std::min(0.3f, reach);
float maxG = -FLT_MAX;
for (int gx = 0; gx < 3; ++gx) {
for (int gz = 0; gz < 3; ++gz) {
const float sx = x0 + (x1 - x0) * gx * 0.5f;
const float sz = zs + (z1 - zs) * gz * 0.5f;
if (sx > bX0 && sx < bX1 &&
sz > bZ0 && sz < bZ1)
continue;
maxG = std::max(maxG, groundAt(sx, sz));
}
}
if (maxG > topY - 0.02f || drop > 4.0f)
raised = false;
}
}
pad.raised = raised;
if (raised) {
pad.center = centerOf();
pad.center.y = groundCenter;
pad.topY = topY;
pad.apronReach = reach;
pad.drop = drop;
} else {
pp.z = ppZ0;
pad.center = centerOf();
pad.center.y = groundCenter;
pad.topY = groundCenter;
pad.apronReach = 0.0f;
}
result.pads.push_back(pad);
if (haveEdge && !raised) {
/* No driveway: PARALLEL roadside spots on the curb line
* (the car would never fit perpendicular here). Poses
* come from the curb-chain geometry, names stay
* address-stable so occupancy/home claims work
* unchanged. */
const float carLen = 5.0f;
const float gap = 0.8f;
const float curbMargin = 1.2f;
/* ONE spot per address: a tight chain
* would demand real parallel-parking
* skill the AI does not have (it stops
* in the lane behind the car in front
* and never reaches the curb). Extra
* capacity comes from the curb chain. */
int n = 1;
Ogre::Vector3 fc = pad.center - vPad * (pad.d * 0.5f);
fc.y = roadTop;
const bool flip = (fnv1a(built.address) & 0x10000u) == 0;
for (int k = 0; k < n; ++k) {
Ogre::Vector3 pos;
Ogre::Quaternion rot;
if (!RoadPath::curbChainPose(graph, fc, k, carLen, gap,
curbMargin, flip, pos, rot))
break;
TownPoi sp;
sp.name = built.address + " parking";
if (built.plan.parkings.size() > 1)
sp.name += " " + std::to_string(pi + 1);
sp.name += "-s" + std::to_string(k + 1);
sp.kind = TownPoi::Parking;
sp.position = pos;
sp.building = (int)result.buildings.size() - 1;
sp.hasHeading = true;
const Ogre::Vector3 nose = rot * Ogre::Vector3::UNIT_Z;
sp.headingYaw = std::atan2(nose.x, nose.z);
result.pois.push_back(sp);
}
continue;
}
/* Raised pads (and edge-less lots, legacy): discrete bay
* slots on the parking surface. */
std::vector<TownParking::Slot> slots =
TownParking::enumerateSlots(pp.w, pp.d);
/* Phase 6.6.2: keep the front-door walk corridor (doorstep
* -> road) and the doorstep clearance car-free; violators
* drop deterministically with a warning. */
Ogre::Vector3 rectO = frontMid + u * (su0 + pp.x) +
v * (vMin + pp.z);
TownParking::WalkCorridor wc;
wc.doorX = (built.doorstep - rectO).dotProduct(u);
wc.doorZ = (built.doorstep - rectO).dotProduct(v);
int dropped = 0;
slots = TownParking::pruneSlots(slots, wc, &dropped);
if (dropped > 0)
result.warnings.push_back(
"lot " + std::to_string(li) + " strip " +
std::to_string(si) + " (" + built.address +
") parking rect " + std::to_string(pi + 1) +
": dropped " + std::to_string(dropped) +
" bay slot(s) blocking the door walk corridor");
for (size_t sk = 0; sk < slots.size(); ++sk) {
TownPoi ppPoi;
ppPoi.name = built.address + " parking";
ppPoi.kind = TownPoi::Parking;
if (built.plan.parkings.size() > 1)
ppPoi.name += " " + std::to_string(pi + 1);
ppPoi.name += "-s" + std::to_string(sk + 1);
ppPoi.position = frontMid +
u * (su0 + pp.x + slots[sk].x) +
v * (vMin + pp.z + slots[sk].z);
ppPoi.position.y = pad.topY;
ppPoi.building = (int)result.buildings.size() - 1;
ppPoi.hasHeading = true;
ppPoi.headingYaw = built.yaw;
result.pois.push_back(ppPoi);
}
/* Residential overflow: ONE ad-hoc spot on the driveway
* mouth, used only when every designated slot is taken. */
if (raised && pad.apronReach >= 2.6f) {
TownPoi ovf;
ovf.name = built.address + " parking-ovf";
ovf.kind = TownPoi::Parking;
ovf.position = frontMid +
u * (su0 + pp.x + pp.w * 0.5f) +
v * (vMin + pp.z - pad.apronReach * 0.5f);
ovf.position.y = pad.topY;
ovf.building = (int)result.buildings.size() - 1;
ovf.hasHeading = true;
ovf.headingYaw = built.yaw + 1.5707964f;
result.pois.push_back(ovf);
}
}
}
}
+69 -2
View File
@@ -165,11 +165,78 @@ struct TownPoi {
Ogre::Vector3 position = Ogre::Vector3::ZERO;
int building = -1; /* index into buildings */
Kind kind = Pickup;
/**
* Optional parked-car heading (phase 6.6.1): a Parking POI is
* one enumerated bay SLOT and carries its nose yaw (radians
* around +Y, same convention as TownBuilding::yaw — slots nose
* toward the street). hasHeading = false keeps the legacy
* behavior (the traffic system derives the pose from the
* nearest road edge).
*/
bool hasHeading = false;
float headingYaw = 0.0f;
};
struct TownResult {
std::vector<TownBuilding> buildings;
std::vector<TownPoi> pois;
/** World-space parking pad rects (phase 6.6.6), one per layout
* parking rect — the 6.6.1 bay slots enumerate from these.
* TownGenSystem builds each pad as helper geometry: an
* asphalt-textured slab flush with the frontage road's top
* (Pad::topY) plus a ramp down to the natural terrain on the
* lot side — the terrain itself stays untouched. */
struct Pad {
int building = -1; /* index into buildings */
std::string address; /* owning building's address */
/** Graph edge index (into RoadGraph::edges) of the
* frontage edge the pad faces — the pad helper
* geometry grades to THIS edge (a nearest-edge query
* near a corner would snap to the perpendicular
* street). -1 = unknown. */
int edge = -1;
/** The parking surface's height (phase 6.6.6): raised
* pads = the frustum platform's top face, always
* 0.2 below the frontage road's top (never above
* it, so the pad never pokes through the road);
* flat pads = the ground sample (no geometry, the
* address parks parallel at the roadside). The
* helper geometry, the parked poses and the
* location heights all use this value. */
float topY = 0.0f;
/** True when the pad got a frustum platform: the
* road sits more than 0.35 m above the lot's
* ground AND the frustum fits — its footprint
* (top face + 30-degree slope runs) clears the
* house walls and the doorstep clearance, no
* terrain pokes through the top face and the
* drop stays within 4 m. False = no geometry,
* parallel roadside spots. */
bool raised = false;
/** Street-ward extension (m) of the frustum's TOP
* face past the pad rect's front edge, reaching
* the carriageway edge (carriageway half-width +
* 0.25 curb backoff) so the top face bridges the
* road's entry-apron band without overlapping the
* road body. 0 for flat pads. */
float apronReach = 0.0f;
/** Vertical distance (m) from the top face down to
* the bottom face (pure-layer settled so the
* bottom sits 0.4 m below the lowest terrain over
* the expanding footprint). The bottom face is
* expanded by drop * tan(30deg) per side. */
float drop = 0.35f;
/** Rect center in the caller's coordinate space; y = the
* ground sample at the center. */
Ogre::Vector3 center = Ogre::Vector3::ZERO;
/** Same convention as TownBuilding::yaw: the rect's
* local +Z faces the street, +X runs along the
* frontage, w along +X and d into the lot. */
float yaw = 0.0f;
float w = 0.0f;
float d = 0.0f;
};
std::vector<Pad> pads;
/** Lots whose density roll failed and whose DEFAULT layout was
* clamped to the modest variant (explicit layouts react to the
* density-pass binding themselves). The roll never drops a
@@ -220,8 +287,8 @@ struct TownResult {
* order, so an all-default town is bit-identical to the pre-DSL
* generator. A failing layout falls back to the district default
* (noted in TownResult::warnings). The evaluated BuildingPlan rides
* on TownBuilding::plan; parking rects become extra "<address>
* parking" POIs.
* on TownBuilding::plan; parking rects become per-slot "<address>
* parking [i]-s<k>" POIs (phase 6.6.1, TownParking::enumerateSlots).
*
* Phase 4.5 item 3: an edge-side `maxFrontage` > 0 splits a wide lot
* into independent strips along the frontage (each strip its own
@@ -174,6 +174,8 @@ LocationSet buildLocations(const TownGen::TownResult &town, uint32_t seed,
LocationKind::Parking : LocationKind::Curb;
loc.name = poi.name;
loc.building = poi.building;
loc.hasHeading = poi.hasHeading;
loc.headingYaw = poi.headingYaw;
if (poi.building >= 0 &&
(size_t)poi.building < town.buildings.size())
loc.address = town.buildings[poi.building].address;
@@ -60,6 +60,13 @@ struct NpcLocation {
Ogre::Vector3 position = Ogre::Vector3::ZERO;
/** Index into TownResult::buildings, -1 when standalone. */
int building = -1;
/**
* Optional parked-car heading copied from the POI (phase 6.6.1):
* yaw around +Y of a parking slot's nose. false = the traffic
* system derives the pose from the nearest road edge (legacy).
*/
bool hasHeading = false;
float headingYaw = 0.0f;
};
struct LocationSet {
@@ -0,0 +1,85 @@
#include "TownParking.hpp"
#include <algorithm>
#include <cmath>
namespace TownParking {
std::vector<Slot> enumerateSlots(float rectW, float rectD,
const Config &cfg)
{
std::vector<Slot> out;
if (rectW <= 0.0f || rectD <= 0.0f)
return out;
/* Degraded single slot for a rect too small for one bay (the
* pre-6.6 "one POI per rect" granularity). */
if (rectW < cfg.bayWidth || rectD < cfg.bayDepth) {
Slot s;
s.x = rectW * 0.5f;
s.z = rectD * 0.5f;
s.w = rectW;
s.d = rectD;
out.push_back(s);
return out;
}
int rows = rectD >= 2.0f * cfg.bayDepth + cfg.aisleWidth ? 2 : 1;
int perRowCap = std::max(1, cfg.maxSlotsPerRect / rows);
int perRow = std::min((int)std::floor(rectW / cfg.bayWidth),
perRowCap);
if (perRow < 1)
perRow = 1;
/* Center the row horizontally inside the rect. */
float x0 = (rectW - perRow * cfg.bayWidth) * 0.5f;
for (int r = 0; r < rows; ++r) {
float z0 = r == 0 ? 0.0f :
cfg.bayDepth + cfg.aisleWidth;
for (int k = 0; k < perRow; ++k) {
Slot s;
s.x = x0 + (k + 0.5f) * cfg.bayWidth;
s.z = z0 + cfg.bayDepth * 0.5f;
s.w = cfg.bayWidth;
s.d = cfg.bayDepth;
out.push_back(s);
}
}
return out;
}
std::vector<Slot> pruneSlots(std::vector<Slot> slots,
const WalkCorridor &corridor,
int *dropped)
{
std::vector<Slot> kept;
kept.reserve(slots.size());
int n = 0;
for (const Slot &s : slots) {
float x0 = s.x - s.w * 0.5f;
float x1 = s.x + s.w * 0.5f;
float z0 = s.z - s.d * 0.5f;
float z1 = s.z + s.d * 0.5f;
/* Corridor strip: road edge -> doorstep. */
bool hitCorridor =
x1 > corridor.doorX - corridor.halfWidth &&
x0 < corridor.doorX + corridor.halfWidth &&
z1 > 0.0f && z0 < corridor.doorZ;
/* Doorstep clearance square. */
bool hitDoor =
x1 > corridor.doorX - corridor.doorClear &&
x0 < corridor.doorX + corridor.doorClear &&
z1 > corridor.doorZ - corridor.doorClear &&
z0 < corridor.doorZ + corridor.doorClear;
if (hitCorridor || hitDoor) {
++n;
continue;
}
kept.push_back(s);
}
if (dropped)
*dropped = n;
return kept;
}
} /* namespace TownParking */
@@ -0,0 +1,80 @@
#pragma once
#include <vector>
/**
* Parking bay slot enumeration (demo-taxi phase 6.6.1).
*
* Pure geometry, no ECS/OGRE: a layout-DSL `(parking ...)` rect (LOT
* frame meters, origin at the front-left of the inset buildable rect,
* +x along the frontage, +z into the lot, z=0 the road side) is
* expanded into discrete named bay slots so the town model and the
* traffic systems see slots, not one bare center point.
*
* Layout rules: a double row with a center aisle when the depth fits
* both, else a single row on the road side; bay size from the traffic
* sedan's chassis footprint plus door margins. Every slot's nose
* faces the road (-z in lot frame); the world-space yaw is derived by
* the caller (it equals the building's yaw). Slot order is row-major
* from the rect's road side — stable slot numbering.
*/
namespace TownParking {
struct Config {
/** Sedan footprint 2.02 x 4.97 m + door/bumper margins. */
float bayWidth = 2.6f;
float bayDepth = 5.2f;
/** Drive aisle between the two rows of a deep rect. */
float aisleWidth = 6.0f;
/** Hard cap per rect so a huge rect stays a sane lot. */
int maxSlotsPerRect = 12;
};
struct Slot {
/** Center in the rect's frame (x along the frontage). */
float x = 0.0f;
float z = 0.0f;
/** Bay extents (w along the frontage, d into the lot). */
float w = 0.0f;
float d = 0.0f;
};
/**
* Enumerate the bay slots of a w x d parking rect.
*
* Row count: 2 when d >= 2*bayDepth + aisleWidth (row 0 at z=0, row 1
* behind the aisle), else 1. Slots per row: floor(w / bayWidth),
* capped so rows * perRow <= maxSlotsPerRect; the row is centered
* horizontally in the rect. A rect too small for even one bay
* degrades to a single centered slot spanning the rect (the pre-6.6
* granularity); a zero/negative rect yields no slots.
*/
std::vector<Slot> enumerateSlots(float rectW, float rectD,
const Config &cfg = Config());
/**
* Door walk-corridor constraint (phase 6.6.2), all in the rect's lot
* frame. The corridor is the strip of width 2*halfWidth centered on
* the door's x running from the rect's road edge (z = 0) to the
* doorstep (z = doorZ); the doorstep additionally keeps a square of
* half-size doorClear car-free. Both default to a ~1.5 m walk lane.
*/
struct WalkCorridor {
/** Doorstep position, rect frame. */
float doorX = 0.0f;
float doorZ = 0.0f;
float halfWidth = 0.75f;
float doorClear = 1.5f;
};
/**
* Drop the slots that intersect the corridor or the doorstep
* clearance square; returns the kept slots in their original order
* (deterministic — no RNG) and stores the drop count in *dropped
* when non-null.
*/
std::vector<Slot> pruneSlots(std::vector<Slot> slots,
const WalkCorridor &corridor,
int *dropped = nullptr);
} /* namespace TownParking */
@@ -188,6 +188,20 @@ void AiDriverSystem::update(float deltaTime)
hitPos, hitNormal, hitBody,
rigidBody.bodyID,
walkableNormalY)) {
/* A grazing hit — the
* surface runs parallel to
* the ray (a parked car's
* flank in the adjacent bay,
* a wall beside the road) —
* is not blocking the path;
* only hits facing the ray
* count. */
float facing =
std::fabs(
dir.dotProduct(
hitNormal));
if (facing < 0.4f)
continue;
float dist =
(hitPos - from).length();
if (dist < best)
@@ -197,6 +211,53 @@ void AiDriverSystem::update(float deltaTime)
st.obstacleDistance = best;
}
/* Stuck-escape (phase 6.6.6): reverse out of a
* wedge the whiskers cannot see, then let the
* normal controller try again (see the config
* comment in the header). */
if (st.unstickTime > 0.0f) {
st.unstickTime -= deltaTime;
if (st.unstickRelease) {
/* One frame of zero input so
* VehicleSystem's brake-before-
* reverse latch releases; a held
* negative throttle never clears
* it and the reverse-out would
* just brake forever. */
st.unstickRelease = false;
vehicle.inputForward = 0.0f;
vehicle.inputRight = 0.0f;
vehicle.inputBrake = 1.0f;
return;
}
vehicle.inputForward = -unstickThrottle;
vehicle.inputRight = 0.0f;
vehicle.inputBrake = 0.0f;
vehicle.inputHandBrake = 0.0f;
ai.arrived = false;
return;
}
if (!ai.arrived && vehicle.inputForward > 0.9f &&
speed < stuckSpeedMps) {
st.stuckTime += deltaTime;
if (st.stuckTime >= stuckSeconds) {
st.unstickTime = unstickSeconds;
st.unstickRelease = true;
st.stuckTime = 0.0f;
/* Rewind one waypoint: the car
* backs up and must re-approach
* the point it was aiming at,
* not the one after it (the
* cursor otherwise ratchets
* forward while the unstick
* cycles drag the car back). */
if (ai.routeIndex > 0)
--ai.routeIndex;
}
} else {
st.stuckTime = 0.0f;
}
/* Advance the waypoint cursor. The radius scales
* with speed so a fast car cuts the corner instead
* of orbiting the exact waypoint. */
@@ -475,6 +536,18 @@ void AiDriverSystem::update(float deltaTime)
targetSpeed = std::min(targetSpeed, allowed);
}
/* Final-approach creep (phase 6.6.6): parking into
* a tight bay, the turn-in transiently faces the
* side whiskers at the neighbors' parked cars and
* the clamp above would hold the car outside the
* slot forever (the park watchdog then falls back
* to a curb). On the last waypoint keep a slow
* creep until the AI's own arrival radius takes
* over and parks. */
if (!ai.loop && ai.routeIndex == n - 1 &&
!ai.arrived)
targetSpeed = std::max(targetSpeed, 1.0f);
if (targetSpeed <= 0.0f && speed < 0.5f) {
/* Holding at a standstill (obstacle, a
* stopped car ahead or a junction yield):
@@ -110,6 +110,22 @@ public:
* aprons), not obstacles. */
float walkableNormalY = 0.7f;
/** Stuck-escape (phase 6.6.6): a car holding ~full throttle with
* its speed still under stuckSpeedMps for stuckSeconds seconds
* is beached on terrain or wedged against something the
* whiskers cannot see (their rays start whiskerForwardOffset
* past the origin, so an adjacent-bay car one meter to the side
* is invisible — and a one-wheel-airborne perch hides from
* every forward ray). The driver then reverses for
* unstickSeconds at unstickThrottle and lets the normal
* controller try again. Whisker-braked cars never accumulate
* stuck time: their speed controller has the throttle near
* zero. */
float stuckSpeedMps = 0.3f;
float stuckSeconds = 2.0f;
float unstickSeconds = 1.2f;
float unstickThrottle = 0.6f;
/** Junction priority (phase 5.3): a car slows to junctionSpeed
* once its next junction waypoint is within junctionApproachDist,
* and glides to a stop at a stop line junctionStopDist before the
@@ -167,6 +183,14 @@ private:
bool junctionReserved = false;
/* Seconds the reservation has been held (timeout). */
float junctionHoldTime = 0.0f;
/* Stuck-escape: seconds at full throttle without
* moving, seconds left in the reverse-out, and the
* one-frame zero-input pulse that releases
* VehicleSystem's brake-before-reverse latch (reverse
* only engages after the "driver releases the key"). */
float stuckTime = 0.0f;
float unstickTime = 0.0f;
bool unstickRelease = false;
};
std::map<flecs::entity_t, DriveState> m_states;
@@ -288,6 +288,20 @@ void NpcScheduleDebugOverlay::render()
tri.highDetail ?
" [HIGH]" :
"");
if (!tri.parkedAtSlot.empty()) {
ImGui::Text(" slot: %s",
tri.parkedAtSlot.c_str());
ImGui::Text(
" occupancy: claimed (chain %d)",
tri.parkedAtChain);
}
if (tri.fallbackActive)
ImGui::Text(
" fallback: ACTIVE (claim unchanged)");
else if (tri.parkBlockedSeconds > 0.0f)
ImGui::Text(
" park watchdog: %.1f s",
tri.parkBlockedSeconds);
if (tri.phase != 0) {
ImGui::Text(" leg: %s -> %s",
tri.legFrom.c_str(),
@@ -49,6 +49,30 @@ static uint32_t fnv1a(const std::string &s)
return h;
}
/* Phase 6.6.6 (parallel roadside spots): approach the park pose
* ALONG its nose axis. A diagonal final segment stops at the AI's
* arriveRadius with the error landing LATERALLY — outside the
* alight gate — so the car never actually reaches the curb. The
* spine waypoint sits a few meters before the pose on the approach
* side, straightening the last leg into a parallel-parking run. */
static void insertParkApproachSpine(std::vector<Ogre::Vector3> &out,
const Ogre::Vector3 &destPos,
const Ogre::Quaternion &destRot,
float dist)
{
if (out.size() < 2)
return;
Ogre::Vector3 nose = destRot * Ogre::Vector3::UNIT_Z;
nose.y = 0.0f;
if (nose.squaredLength() < 1e-8f)
return;
nose.normalise();
const float along =
(destPos - out[out.size() - 2]).dotProduct(nose);
out.insert(out.end() - 1,
destPos + nose * (along >= 0.0f ? -dist : dist));
}
NpcTrafficSystem *NpcTrafficSystem::ms_instance = nullptr;
NpcTrafficSystem::NpcTrafficSystem(flecs::world &world,
@@ -104,6 +128,11 @@ bool NpcTrafficSystem::trafficInfoOf(uint64_t driverId,
out.legFrom = car.legFrom;
out.legTo = car.legTo;
out.arrivalAbs = car.arrivalAbs;
out.area = car.areaIdx;
out.parkedAtSlot = car.claimedSlot;
out.parkedAtChain = car.claimedK;
out.parkBlockedSeconds = car.parkBlockedSec;
out.fallbackActive = car.parkFallbackFired;
return true;
}
@@ -162,65 +191,430 @@ Ogre::Vector3 NpcTrafficSystem::cameraPosition() const
bool NpcTrafficSystem::parkedPlacement(const TownLocations::NpcLocation &park,
const RoadGraph *graph,
Ogre::Vector3 &pos,
Ogre::Quaternion &rot) const
Ogre::Quaternion &rot,
int chainIndex) const
{
pos = park.position;
rot = Ogre::Quaternion::IDENTITY;
/* Phase 6.6.1: a bay-slot POI carries its own heading (nose
* toward the street) — the road-edge projection would park a
* bay car diagonally across its bay. */
if (park.hasHeading) {
rot = Ogre::Quaternion(Ogre::Radian(park.headingYaw),
Ogre::Vector3::UNIT_Y);
return true;
}
if (!graph)
return true;
Ogre::Vector3 proj, edgeDir;
float halfW = 0.0f;
if (!RoadPath::nearestEdgeInfo(*graph, park.position, proj, edgeDir,
halfW))
return true;
/* Deterministic direction along the edge. */
if ((fnv1a(park.name) & 0x10000u) == 0)
edgeDir = -edgeDir;
edgeDir.y = 0.0f;
if (edgeDir.squaredLength() <= 1e-8f)
return true;
edgeDir.normalise();
/* Vehicle nose is +Z. */
rot = Ogre::Vector3::UNIT_Z.getRotationTo(edgeDir);
/* Phase 6.6.4: a curb spot lives on the curb line (body clear
* of the outer lane), chained along the edge by the
* occupancy-chosen index (6.6.3); the name-hash bit keeps the
* legacy nose direction. */
return RoadPath::curbChainPose(*graph, park.position, chainIndex,
curbChainCarLength, curbChainGap,
curbLateralOffset,
(fnv1a(park.name) & 0x10000u) == 0,
pos, rot);
}
std::string NpcTrafficSystem::claimKey(int areaIdx,
const std::string &slotName, int k)
{
return std::to_string(areaIdx) + "|" + slotName + "#" +
std::to_string(k);
}
bool NpcTrafficSystem::isSlotFree(int areaIdx, const std::string &slotName,
int k, uint64_t forDriver) const
{
auto it = m_slotClaims.find(claimKey(areaIdx, slotName, k));
return it == m_slotClaims.end() || it->second == forDriver;
}
int NpcTrafficSystem::lowestFreeChainK(int areaIdx,
const std::string &curbName,
uint64_t forDriver) const
{
for (int k = 0; k < curbChainMaxSpots; ++k)
if (isSlotFree(areaIdx, curbName, k, forDriver))
return k;
return curbChainMaxSpots;
}
bool NpcTrafficSystem::slotReachable(
int areaIdx, const TownLocations::NpcLocation &loc,
const RoadGraph *graph) const
{
/* Phase 6.6.6 (helper-geometry pivot): bay slots are always
* reachable. The pad is a flat slab flush with the frontage
* road (Pad::topY) and the only slope a car crosses is the
* pad's own 0.25-grade ramp — there is no natural-terrain
* seam left to probe. The original terrain-slope probe
* (padMaxGrade/padProbeStepM) died with the runtime pad
* compliance. */
(void)areaIdx;
(void)loc;
(void)graph;
return true;
}
const TownLocations::NpcLocation *NpcTrafficSystem::parkingFor(
NpcTrafficSystem::SlotPick NpcTrafficSystem::parkingFor(
const TownLocations::LocationSet &locs, const TrafficCar &car,
const std::string &locName) const
const std::string &locName, bool respectClaims,
const RoadGraph *graph) const
{
SlotPick out;
/* Going home parks at the driver's own spot. */
if (locName == car.homeLocation)
return locs.findByName(car.homeParking);
if (locName == car.homeLocation) {
out.loc = locs.findByName(car.homeParking);
out.k = car.homeChainK;
return out;
}
const TownLocations::NpcLocation *loc = locs.findByName(locName);
if (!loc)
return nullptr;
/* Nearest Parking-or-Curb to the destination; ties resolve to
* the lowest location index (stable, order-independent). */
std::vector<int> cand =
locs.indicesOfKind(TownLocations::LocationKind::Parking);
std::vector<int> curbs =
locs.indicesOfKind(TownLocations::LocationKind::Curb);
cand.insert(cand.end(), curbs.begin(), curbs.end());
const TownLocations::NpcLocation *best = nullptr;
return out;
if (!respectClaims) {
/* Legacy occupancy-blind pick: nearest designated
* Parking, then the driveway-mouth overflow, then the
* nearest Curb; ties resolve to the lowest location
* index (stable, order-independent). Used for
* not-yet-active legs' timing so the chain never
* flickers with claims. */
std::vector<int> bays;
std::vector<int> ovfs;
for (int i : locs.indicesOfKind(
TownLocations::LocationKind::Parking)) {
const TownLocations::NpcLocation &p =
locs.locations[i];
bool ovf = p.name.size() > 4 &&
p.name.compare(p.name.size() - 4, 4,
"-ovf") == 0;
(ovf ? ovfs : bays).push_back(i);
}
std::vector<int> curbs =
locs.indicesOfKind(TownLocations::LocationKind::Curb);
float bestD = FLT_MAX;
for (int i : bays) {
const TownLocations::NpcLocation &p =
locs.locations[i];
float d = (p.position - loc->position).squaredLength();
if (d < bestD - 1e-4f) {
bestD = d;
out.loc = &p;
out.k = 0;
}
}
if (!out.loc) {
for (int i : ovfs) {
const TownLocations::NpcLocation &p =
locs.locations[i];
float d = (p.position - loc->position)
.squaredLength();
if (d < bestD - 1e-4f) {
bestD = d;
out.loc = &p;
out.k = 0;
}
}
}
if (!out.loc) {
for (int i : curbs) {
const TownLocations::NpcLocation &p =
locs.locations[i];
float d = (p.position -
loc->position)
.squaredLength();
if (d < bestD - 1e-4f) {
bestD = d;
out.loc = &p;
out.k = 0;
}
}
}
return out;
}
/* 6.6.3: nearest FREE Parking slot first ... overflow spots
* ("parking-ovf") come later: they are the driveway-mouth
* reserve, not designated spaces (6.6.6). */
float bestD = FLT_MAX;
for (int i : cand) {
for (int i :
locs.indicesOfKind(TownLocations::LocationKind::Parking)) {
const TownLocations::NpcLocation &p = locs.locations[i];
if (p.name.size() > 4 &&
p.name.compare(p.name.size() - 4, 4, "-ovf") == 0)
continue;
if (!isSlotFree(car.areaIdx, p.name, 0, car.driverId))
continue;
if (!slotReachable(car.areaIdx, p, graph))
continue;
float d = (p.position - loc->position).squaredLength();
if (d < bestD - 1e-4f) {
bestD = d;
best = &p;
out.loc = &p;
out.k = 0;
}
}
return best;
if (out.loc)
return out;
/* ... then the driveway-mouth overflow (never stacked: one
* exclusively-claimed pose per pad), ... */
bestD = FLT_MAX;
for (int i :
locs.indicesOfKind(TownLocations::LocationKind::Parking)) {
const TownLocations::NpcLocation &p = locs.locations[i];
if (p.name.size() <= 4 ||
p.name.compare(p.name.size() - 4, 4, "-ovf") != 0)
continue;
if (!isSlotFree(car.areaIdx, p.name, 0, car.driverId))
continue;
if (!slotReachable(car.areaIdx, p, graph))
continue;
float d = (p.position - loc->position).squaredLength();
if (d < bestD - 1e-4f) {
bestD = d;
out.loc = &p;
out.k = 0;
}
}
if (out.loc)
return out;
/* ... then the nearest curb with a free chain spot (never an
* occupied spot). */
bestD = FLT_MAX;
for (int i : locs.indicesOfKind(TownLocations::LocationKind::Curb)) {
const TownLocations::NpcLocation &p = locs.locations[i];
int k = lowestFreeChainK(car.areaIdx, p.name, car.driverId);
if (k >= curbChainMaxSpots)
continue;
float d = (p.position - loc->position).squaredLength();
if (d < bestD - 1e-4f) {
bestD = d;
out.loc = &p;
out.k = k;
}
}
return out;
}
void NpcTrafficSystem::rebuildClaims(double tabs, float tod,
const RoadGraph *graph)
{
m_slotClaims.clear();
NpcPopulationSystem *pop = NpcPopulationSystem::getInstance();
NpcMotionSystem *motion = NpcMotionSystem::getInstance();
const float walkSpeed = motion ? motion->walkSpeedMps : 1.4f;
const double dayStart = tabs - (double)tod;
/* Home claims are permanent: a car's own spot stays reserved
* while it is away (visitors never take it — it reads as
* occupied in m_slotClaims — and the owner always re-parks
* exactly at home). */
for (auto &kv : m_cars) {
TrafficCar &car = kv.second;
car.legClaims.clear();
car.claimedSlotLoc.clear();
car.claimedSlot.clear();
car.claimedK = 0;
if (car.homeLocation.empty() || car.homeParking.empty())
continue;
m_slotClaims[claimKey(car.areaIdx, car.homeParking,
car.homeChainK)] = car.driverId;
car.claimedSlotLoc = car.homeLocation;
car.claimedSlot = car.homeParking;
car.claimedK = car.homeChainK;
}
if (!graph || !pop)
return;
/* Phase A: every car's driven-leg event times, chained per car
* with occupancy-blind default endpoints — the same walk/drive
* gating as evalSymbolic, so event times are a pure function of
* the clock. Only legs whose walk departure already passed
* claim anything. */
struct ClaimEv {
double time = 0.0;
uint64_t driver = 0;
uint32_t seq = 0;
/** true: claim the destination slot of the leg
* fromLoc>toLoc; false: release the visitor origin. */
bool claim = false;
std::string fromLoc, toLoc;
};
std::vector<ClaimEv> evs;
for (auto &kv : m_cars) {
TrafficCar &car = kv.second;
const TownSchedule::NpcSchedule *sched =
pop->scheduleOf(car.driverId);
const TownLocations::LocationSet *locs =
pop->areaLocations(car.areaIdx);
if (!sched || !locs || car.homeLocation.empty() ||
sched->entries.empty())
continue;
std::string parkedAtLoc = car.homeLocation;
const TownSchedule::ScheduleEntry *first =
TownSchedule::entryAt(*sched, 0.0f);
std::string curLoc =
first ? first->location : sched->entries[0].location;
double curSince = dayStart;
uint32_t seq = 0;
for (const TownSchedule::ScheduleEntry &e : sched->entries) {
if (e.location == curLoc)
continue;
const double legArrival =
dayStart + (double)e.startHour;
const std::string fromLoc = curLoc;
const double prevSince = curSince;
curLoc = e.location;
curSince = legArrival;
const TownLocations::NpcLocation *from =
locs->findByName(fromLoc);
const TownLocations::NpcLocation *to =
locs->findByName(e.location);
if (!from || !to)
continue;
const float walkM =
from->position.distance(to->position);
const double walkH = (double)walkM /
((double)walkSpeed * 3600.0);
const double span = legArrival - prevSince;
if (parkedAtLoc != fromLoc ||
(walkM <= maxWalkDistanceM && walkH <= span))
continue; /* walked leg: the car stays */
SlotPick orgT = parkingFor(*locs, car, fromLoc,
false);
SlotPick dstT = parkingFor(*locs, car, e.location,
false);
const LegRoute *lr = legRoute(car, fromLoc, e.location,
orgT, dstT, *graph);
if (!lr)
continue; /* route failed: walked */
const double driveH = (double)lr->length /
((double)cruiseSpeed * 3600.0) +
(double)parkOverheadSeconds / 3600.0;
const double boardH = (double)lr->boardWalkM /
((double)walkSpeed * 3600.0);
const double driveDep = legArrival - driveH;
const double walkDep = driveDep - boardH;
if (walkDep > tabs)
break; /* future legs claim nothing yet */
ClaimEv cev;
cev.time = walkDep;
cev.driver = car.driverId;
cev.seq = seq;
cev.claim = true;
cev.fromLoc = fromLoc;
cev.toLoc = e.location;
evs.push_back(cev);
ClaimEv rev;
rev.time = driveDep;
rev.driver = car.driverId;
rev.seq = seq;
rev.claim = false;
evs.push_back(rev);
parkedAtLoc = e.location;
++seq;
}
}
/* Phase B: replay the events in (time, driver id, leg) order —
* deterministic, so the same clock always reproduces the same
* assignments. The claim fires at the WALK departure (the live
* tier needs the destination to drive to) while the visitor
* origin frees only at the DRIVE departure (the car physically
* stands there while the driver walks over). */
std::sort(evs.begin(), evs.end(),
[](const ClaimEv &a, const ClaimEv &b) {
if (a.time != b.time)
return a.time < b.time;
if (a.driver != b.driver)
return a.driver < b.driver;
if (a.seq != b.seq)
return a.seq < b.seq;
return a.claim > b.claim;
});
/* Per car: the visitor slot currently held, and the previous
* one (the origin still occupied during the walk-to-car
* window). */
std::map<uint64_t, std::pair<std::string, int>> visitor;
std::map<uint64_t, std::pair<std::string, int>> prevVisitor;
for (const ClaimEv &ev : evs) {
TrafficCar &car = m_cars[ev.driver];
if (!ev.claim) {
auto pv = prevVisitor.find(ev.driver);
if (pv != prevVisitor.end()) {
auto it = m_slotClaims.find(
claimKey(car.areaIdx,
pv->second.first,
pv->second.second));
if (it != m_slotClaims.end() &&
it->second == ev.driver)
m_slotClaims.erase(it);
prevVisitor.erase(pv);
}
continue;
}
const TownLocations::LocationSet *locs =
pop->areaLocations(car.areaIdx);
if (!locs)
continue;
std::string originSlot = car.homeParking;
int originK = car.homeChainK;
auto v = visitor.find(ev.driver);
if (v != visitor.end()) {
originSlot = v->second.first;
originK = v->second.second;
}
SlotPick dst = parkingFor(*locs, car, ev.toLoc, true,
graph);
if (!dst.loc)
/* Nothing free anywhere: overlap accepted
* (pre-6.6 behavior), deterministic via the
* occupancy-blind pick. */
dst = parkingFor(*locs, car, ev.toLoc, false);
if (!dst.loc)
continue;
TrafficCar::LegClaim lc;
lc.originSlot = originSlot;
lc.originK = originK;
lc.destSlot = dst.loc->name;
lc.destK = dst.k;
car.legClaims[ev.fromLoc + ">" + ev.toLoc] = lc;
prevVisitor[ev.driver] = visitor.count(ev.driver) ?
visitor[ev.driver] :
std::make_pair(std::string(), 0);
if (dst.loc->name == car.homeParking &&
dst.k == car.homeChainK) {
/* Going home: the home claim is permanent;
* the visitor origin frees at driveDep. */
visitor.erase(ev.driver);
} else {
m_slotClaims[claimKey(car.areaIdx, dst.loc->name,
dst.k)] = ev.driver;
visitor[ev.driver] =
std::make_pair(dst.loc->name, dst.k);
}
car.claimedSlotLoc = ev.toLoc;
car.claimedSlot = dst.loc->name;
car.claimedK = dst.k;
}
}
const NpcTrafficSystem::LegRoute *NpcTrafficSystem::legRoute(
TrafficCar &car, const std::string &fromLoc,
const std::string &toLoc, const RoadGraph &graph)
const std::string &toLoc, const SlotPick &origin,
const SlotPick &dest, const RoadGraph &graph)
{
/* The endpoints are part of the key: two cars driving the same
* location pair to different claimed slots must not share the
* route (6.6.3). */
std::string key = std::to_string(car.areaIdx) + "|" + fromLoc + ">" +
toLoc;
toLoc + "|" + (origin.loc ? origin.loc->name : "-") + "#" +
std::to_string(origin.k) + ">" +
(dest.loc ? dest.loc->name : "-") + "#" +
std::to_string(dest.k);
auto cached = m_legRoutes.find(key);
if (cached != m_legRoutes.end())
return cached->second.unusable ? nullptr : &cached->second;
@@ -230,18 +624,14 @@ const NpcTrafficSystem::LegRoute *NpcTrafficSystem::legRoute(
pop ? pop->areaLocations(car.areaIdx) : nullptr;
LegRoute lr;
if (locs) {
if (locs && origin.loc && dest.loc) {
const TownLocations::NpcLocation *from =
locs->findByName(fromLoc);
const TownLocations::NpcLocation *opark =
parkingFor(*locs, car, fromLoc);
const TownLocations::NpcLocation *dpark =
parkingFor(*locs, car, toLoc);
if (from && opark && dpark) {
parkedPlacement(*opark, &graph, lr.originParkPos,
lr.originParkRot);
parkedPlacement(*dpark, &graph, lr.destParkPos,
lr.destParkRot);
if (from) {
parkedPlacement(*origin.loc, &graph, lr.originParkPos,
lr.originParkRot, origin.k);
parkedPlacement(*dest.loc, &graph, lr.destParkPos,
lr.destParkRot, dest.k);
lr.boardWalkM =
from->position.distance(lr.originParkPos);
std::vector<Ogre::Vector3> out;
@@ -249,6 +639,31 @@ const NpcTrafficSystem::LegRoute *NpcTrafficSystem::legRoute(
lr.destParkPos, out) &&
out.size() >= 2) {
RoadPath::offsetToRightLane(graph, out);
/* 6.6.6: leave a bay slot straight out
* first — the road stitch bends toward
* the lane and pure-pursuit cutting that
* corner from a standing start grazes the
* car in the adjacent bay. */
if (origin.loc->hasHeading &&
out.size() >= 2) {
Ogre::Vector3 nose =
lr.originParkRot *
Ogre::Vector3::UNIT_Z;
out.insert(out.begin() + 1,
lr.originParkPos +
nose * bayExitSpineDist);
}
/* 6.6.5: keep the lane offsets along
* the route body but REPLACE the final
* waypoint with the park pose so the
* approach steers into the slot instead
* of stopping in the lane (the symbolic
* layer then also ends the route AT the
* park pose). */
out.back() = lr.destParkPos;
insertParkApproachSpine(out, lr.destParkPos,
lr.destParkRot,
parkApproachSpineDist);
for (const Ogre::Vector3 &p : out) {
AiRoutePoint rp;
rp.position = p;
@@ -349,6 +764,7 @@ void NpcTrafficSystem::evalSymbolic(TrafficCar &car, double tabs, float tod,
* longer than the span the driver had at the origin). */
const LegRoute *lr = nullptr;
double walkDep = 0.0, driveDep = 0.0;
const std::string legKey = fromLoc + ">" + e.location;
if (graph && parkedAtLoc == fromLoc) {
const float walkM =
from->position.distance(to->position);
@@ -356,8 +772,17 @@ void NpcTrafficSystem::evalSymbolic(TrafficCar &car, double tabs, float tod,
((double)walkSpeed * 3600.0);
const double span = legArrival - prevSince;
if (walkM > maxWalkDistanceM || walkH > span) {
lr = legRoute(car, fromLoc, e.location,
*graph);
/* Timing endpoints are the deterministic
* occupancy-blind defaults (pure event
* times); the GEOMETRY route uses the
* leg's claimed endpoints recorded by
* rebuildClaims (6.6.3). */
SlotPick orgT = parkingFor(*locs, car, fromLoc,
false);
SlotPick dstT = parkingFor(*locs, car,
e.location, false);
lr = legRoute(car, fromLoc, e.location, orgT,
dstT, *graph);
if (lr) {
const double driveH =
(double)lr->length /
@@ -371,6 +796,26 @@ void NpcTrafficSystem::evalSymbolic(TrafficCar &car, double tabs, float tod,
3600.0);
driveDep = legArrival - driveH;
walkDep = driveDep - boardH;
auto lc = car.legClaims.find(legKey);
if (lc != car.legClaims.end()) {
SlotPick orgG, dstG;
orgG.loc = locs->findByName(
lc->second.originSlot);
orgG.k = lc->second.originK;
dstG.loc = locs->findByName(
lc->second.destSlot);
dstG.k = lc->second.destK;
if (orgG.loc && dstG.loc) {
const LegRoute *lrG =
legRoute(car,
fromLoc,
e.location,
orgG, dstG,
*graph);
if (lrG)
lr = lrG;
}
}
}
}
}
@@ -382,8 +827,6 @@ void NpcTrafficSystem::evalSymbolic(TrafficCar &car, double tabs, float tod,
continue;
}
const std::string legKey = fromLoc + ">" + e.location;
if (tabs < walkDep) {
/* Not yet leaving by car. The walk layer's own
* departure for this leg (arrival - walk travel
@@ -409,7 +852,9 @@ void NpcTrafficSystem::evalSymbolic(TrafficCar &car, double tabs, float tod,
}
if (tabs < legArrival) {
/* The active drive leg. */
/* The active drive leg. The claimed endpoint
* slots were recorded by rebuildClaims (pure per
* clock), so no per-car latch is needed. */
car.leg = lr;
car.legFrom = fromLoc;
car.legTo = e.location;
@@ -465,6 +910,18 @@ void NpcTrafficSystem::evalSymbolic(TrafficCar &car, double tabs, float tod,
curParkRot = lr->destParkRot;
}
/* 6.6.3: the symbolic parked pose is the CLAIMED slot's pose
* (claims are rebuilt purely by rebuildClaims before this
* per-car pass, so the pose is clock-pure too; the live tier
* parks at the same pose). */
if (car.claimedSlotLoc == parkedAtLoc && !car.claimedSlot.empty()) {
const TownLocations::NpcLocation *cl =
locs->findByName(car.claimedSlot);
if (cl)
parkedPlacement(*cl, graph, curParkPos, curParkRot,
car.claimedK);
}
car.symbolPos = curParkPos;
car.symbolRot = curParkRot;
setSuspended(car, false);
@@ -683,6 +1140,18 @@ void NpcTrafficSystem::attachAi(TrafficCar &car)
car.vehicle.set<AiDriverComponent>(ai);
car.vehicle.get_mut<VehicleComponent>().inputHandBrake = 0.0f;
car.aiAttached = true;
/* 6.6.5: the drop-off goal is the leg's park pose, captured
* now so it survives the symbolic clock chaining into the next
* leg (car.leg goes null) while the live car is still
* approaching. */
car.liveDestPos = car.leg->destParkPos;
car.liveDestRot = car.leg->destParkRot;
car.parkMinDist = FLT_MAX;
car.parkBlockedSec = 0.0f;
car.parkCreepFired = false;
car.parkFallbackFired = false;
car.parkFallbackWarned = false;
car.haveLastLivePos = false;
}
void NpcTrafficSystem::alightDriver(TrafficCar &car)
@@ -748,7 +1217,8 @@ void NpcTrafficSystem::alightDriver(TrafficCar &car)
setSuspended(car, false);
}
void NpcTrafficSystem::stepHighDetail(TrafficCar &car)
void NpcTrafficSystem::stepHighDetail(TrafficCar &car, float dt,
const RoadGraph *graph)
{
if (!car.vehicle.is_alive())
return;
@@ -831,12 +1301,195 @@ void NpcTrafficSystem::stepHighDetail(TrafficCar &car)
* junction yield), so the AI can park after the symbolic clock
* already chained into the next leg — alight anyway. When the
* symbolic layer is driving another leg by then, despawn so the
* next spawn resyncs to the symbolic state. */
if (car.aiAttached && car.vehicle.has<AiDriverComponent>() &&
car.vehicle.get<AiDriverComponent>().arrived) {
alightDriver(car);
if (car.phase == Phase::Driving)
despawnHighDetail(car);
* next spawn resyncs to the symbolic state.
*
* 6.6.5 drop-off discipline: the driver alights only when the
* live car sits inside the park pose's box (parkArriveM
* laterally, parkLongitudinalM along the nose — the claimed
* slot, or the fallback curb spot once fired) and nearly
* stopped — never mid-lane on bare AiDriverComponent::arrived.
* The AI's 2.5 m arriveRadius is coarser than the gate, so an
* "arrived but short" car first gets a creep retarget onto the
* pose; an approach that stops improving inside parkEngageM
* (whisker stop on a car already sitting in the slot, etc.)
* escalates to the curb-chain fallback after
* parkFallbackSeconds. */
if (car.aiAttached && car.driverSeated &&
car.vehicle.has<AiDriverComponent>()) {
Ogre::Vector3 pos;
if (carWorldPosition(car.driverId, pos)) {
auto &ai = car.vehicle.get_mut<AiDriverComponent>();
/* Lateral/longitudinal error against the park
* pose's nose axis: the lateral part is what keeps
* the body out of the carriageway; the AI's 2.5 m
* arriveRadius only ever undershoots
* longitudinally (a parallel-parked car a meter
* back along the curb is parked fine). */
Ogre::Vector3 d = pos - car.liveDestPos;
d.y = 0.0f;
Ogre::Vector3 nose =
car.liveDestRot * Ogre::Vector3::UNIT_Z;
nose.y = 0.0f;
if (nose.squaredLength() > 1e-8f)
nose.normalise();
else
nose = Ogre::Vector3::UNIT_Z;
const float lon = d.dotProduct(nose);
const float lat = (d - nose * lon).length();
const float dist = d.length();
float speed = 0.0f;
if (car.haveLastLivePos && dt > 1e-6f)
speed = (pos - car.lastLivePos).length() / dt;
car.lastLivePos = pos;
car.haveLastLivePos = true;
if (lat <= parkArriveM &&
std::fabs(lon) <= parkLongitudinalM &&
speed <= parkAlightSpeedMps) {
alightDriver(car);
if (car.phase == Phase::Driving)
despawnHighDetail(car);
return;
}
/* Progress watchdog: any approach improvement
* resets the blocked timer. */
if (dist < car.parkMinDist - 0.1f) {
car.parkMinDist = dist;
car.parkBlockedSec = 0.0f;
} else if (dist <= parkEngageM || ai.arrived) {
car.parkBlockedSec += dt;
}
if (ai.arrived &&
(lat > parkArriveM ||
std::fabs(lon) > parkLongitudinalM) &&
!car.parkCreepFired && !car.parkFallbackFired) {
/* Stopped inside the AI tolerance but
* outside the alight gate: creep the
* remaining meters straight onto the
* pose. */
car.parkCreepFired = true;
AiRoutePoint rp0, rp1;
rp0.position = pos;
rp1.position = car.liveDestPos;
ai.route.clear();
ai.route.push_back(rp0);
ai.route.push_back(rp1);
ai.routeIndex = 0;
ai.arrived = false;
ai.loop = false;
ai.junctionsClassified = true;
car.vehicle.get_mut<VehicleComponent>()
.inputHandBrake = 0.0f;
} else if (car.parkBlockedSec >= parkFallbackSeconds &&
!car.parkFallbackFired) {
retargetToFallback(car, graph);
}
}
}
}
void NpcTrafficSystem::retargetToFallback(TrafficCar &car,
const RoadGraph *graph)
{
if (!car.vehicle.is_alive() ||
!car.vehicle.has<AiDriverComponent>())
return;
NpcPopulationSystem *pop = NpcPopulationSystem::getInstance();
const TownLocations::LocationSet *locs =
pop ? pop->areaLocations(car.areaIdx) : nullptr;
Ogre::Vector3 pos;
if (!locs || !carWorldPosition(car.driverId, pos))
return;
/* Nearest curb with a free chain spot to the live pose (the
* blocked slot stays claimed by this car until the fallback
* lands, so nobody else takes it mid-retarget). */
const TownLocations::NpcLocation *best = nullptr;
int bestK = 0;
float bestD = FLT_MAX;
for (int i : locs->indicesOfKind(TownLocations::LocationKind::Curb)) {
const TownLocations::NpcLocation &p = locs->locations[i];
int k = lowestFreeChainK(car.areaIdx, p.name, car.driverId);
if (k >= curbChainMaxSpots)
continue;
float d = (p.position - pos).squaredLength();
if (d < bestD - 1e-4f) {
bestD = d;
best = &p;
bestK = k;
}
}
if (!best) {
if (!car.parkFallbackWarned) {
car.parkFallbackWarned = true;
Ogre::LogManager::getSingleton().logMessage(
"NpcTrafficSystem: driver " +
std::to_string((unsigned long long)
car.driverId) +
" blocked at its slot and no curb "
"fallback is free");
}
return;
}
/* The claim does NOT move: the symbolic layer stays a pure
* function of the clock (the plan's "symbolic layer stays
* untouched" rule) and keeps the car at its assigned slot; the
* fallback is a live-tier-only deviation that self-heals on the
* next leg or despawn. */
car.parkFallbackFired = true;
car.parkBlockedSec = 0.0f;
car.parkMinDist = FLT_MAX;
if (graph) {
parkedPlacement(*best, graph, car.liveDestPos,
car.liveDestRot, bestK);
} else {
car.liveDestPos = best->position;
car.liveDestRot = Ogre::Quaternion::IDENTITY;
}
/* Re-route from the live pose to the fallback spot. */
std::vector<Ogre::Vector3> out;
if (graph &&
(!RoadPath::findPath(*graph, pos, car.liveDestPos, out) ||
out.size() < 2))
out.clear();
if (out.empty()) {
out.push_back(pos);
out.push_back(car.liveDestPos);
} else {
RoadPath::offsetToRightLane(*graph, out);
out.back() = car.liveDestPos;
insertParkApproachSpine(out, car.liveDestPos,
car.liveDestRot,
parkApproachSpineDist);
}
AiDriverComponent ai;
for (const Ogre::Vector3 &p : out) {
AiRoutePoint rp;
rp.position = p;
ai.route.push_back(rp);
}
ai.routeIndex = 0;
ai.loop = false;
ai.cruiseSpeed = cruiseSpeed;
if (graph) {
AiDriverSystem::classifyJunctions(ai, *graph, 6.0f);
ai.junctionsClassified = true;
}
car.vehicle.set<AiDriverComponent>(ai);
car.vehicle.get_mut<VehicleComponent>().inputHandBrake = 0.0f;
if (!car.parkFallbackWarned) {
car.parkFallbackWarned = true;
Ogre::LogManager::getSingleton().logMessage(
"NpcTrafficSystem: driver " +
std::to_string((unsigned long long)car.driverId) +
" blocked at its slot — falling back to curb '" +
best->name + "' k" + std::to_string(bestK));
}
}
@@ -1002,11 +1655,29 @@ void NpcTrafficSystem::seedArea(int areaIdx)
}
int seeded = 0;
/* Phase 6.6.3: the ownership roll is per ADDRESS — all slot
* POIs of an address share one roll (a per-location roll would
* multiply the car count by the slot count) and the car takes
* the address's lowest-index free slot. The roll hash rides on
* the lowest-index slot's NAME so curb-only towns (one curb POI
* per address) keep their legacy rolls bit-identically.
* Addresses are visited in first-appearance order in the
* location set (stable, deterministic). */
std::map<std::string, std::vector<int>> byAddress;
std::vector<std::string> addressOrder;
for (int li : parkingIdx) {
const std::string &addr = locs->locations[li].address;
if (byAddress.emplace(addr, std::vector<int>()).second)
addressOrder.push_back(addr);
byAddress[addr].push_back(li);
}
for (const std::string &addr : addressOrder) {
if (seeded >= maxCarsPerArea)
break;
const TownLocations::NpcLocation &park = locs->locations[li];
uint32_t h = fnv1a(park.name);
const std::vector<int> &slots = byAddress[addr];
const TownLocations::NpcLocation &first =
locs->locations[slots.front()];
uint32_t h = fnv1a(first.name);
if ((double)(h % 10000) / 10000.0 >= carOwnershipRatio)
continue;
@@ -1019,7 +1690,7 @@ void NpcTrafficSystem::seedArea(int areaIdx)
reg.findCharacter(id);
auto it = rec->stringColumns.find("npcHome");
if (it != rec->stringColumns.end() &&
it->second == park.address) {
it->second == addr) {
driver = id;
break;
}
@@ -1030,10 +1701,39 @@ void NpcTrafficSystem::seedArea(int areaIdx)
if (m_cars.count(driver))
continue;
/* The address's lowest-index free slot (curb chains:
* the lowest free chain index). 6.6.6: slots that fail
* the slope probe (the car could not climb onto the
* pad) are skipped. */
const TownLocations::NpcLocation *park = nullptr;
int parkK = 0;
for (int li : slots) {
const TownLocations::NpcLocation &p =
locs->locations[li];
if (!slotReachable(areaIdx, p, graph))
continue;
if (p.kind == TownLocations::LocationKind::Curb) {
int k = lowestFreeChainK(areaIdx, p.name, 0);
if (k >= curbChainMaxSpots)
continue;
park = &p;
parkK = k;
break;
}
if (!isSlotFree(areaIdx, p.name, 0, 0))
continue;
park = &p;
parkK = 0;
break;
}
if (!park)
continue;
TrafficCar car;
car.driverId = driver;
car.areaIdx = areaIdx;
car.homeParking = park.name;
car.homeParking = park->name;
car.homeChainK = parkK;
CharacterRegistry::CharacterRecord *rec =
reg.findCharacter(driver);
if (rec) {
@@ -1042,7 +1742,8 @@ void NpcTrafficSystem::seedArea(int areaIdx)
!it->second.empty())
car.homeLocation = "home:" + it->second;
}
parkedPlacement(park, graph, car.parkedPos, car.parkedRot);
parkedPlacement(*park, graph, car.parkedPos, car.parkedRot,
parkK);
car.symbolPos = car.parkedPos;
car.symbolRot = car.parkedRot;
m_cars[driver] = car;
@@ -1132,6 +1833,10 @@ void NpcTrafficSystem::update(float dt)
m_lastTabs = tabs;
m_haveTabs = true;
/* 6.6.3: rebuild the slot-claim map from the clock (pure
* replay) before the per-car evaluation reads it. */
rebuildClaims(tabs, tod, graph);
const Ogre::Vector3 camPos = cameraPosition();
int spawnBudget = spawnBudgetPerFrame;
int activeCount = 0;
@@ -1191,7 +1896,7 @@ void NpcTrafficSystem::update(float dt)
/* Observed-tier choreography: boarding, AI attach,
* arrival, alighting. */
if (car.highDetail)
stepHighDetail(car);
stepHighDetail(car, dt, graph);
car.dwell += dt;
@@ -3,6 +3,7 @@
#pragma once
#include <cstdint>
#include <cfloat>
#include <map>
#include <set>
#include <string>
@@ -115,6 +116,19 @@ public:
/** Absolute hours when the leg must arrive (schedule
* start of the destination entry). */
double arrivalAbs = 0.0;
/** Owning town-area index (for occupancy/reachability
* probes). */
int area = -1;
/** Claimed parking slot (6.6.3): the slot location name
* the car currently holds, "" before seeding finishes. */
std::string parkedAtSlot;
/** Curb-chain index within parkedAtSlot (0 for bays). */
int parkedAtChain = 0;
/** Live park watchdog/fallback state (6.6.5). The
* symbolic claim stays on the original slot even when
* the live car takes a curb fallback. */
float parkBlockedSeconds = 0.0f;
bool fallbackActive = false;
};
/** True while the NPC is in a drive-leg window (suspended from
@@ -156,6 +170,10 @@ public:
out.push_back(kv.first);
return out;
}
/** 6.6.6: probe a bay slot's slot->road terrain profile.
* Memoized per area; curb spots always return true. */
bool slotReachable(int areaIdx, const TownLocations::NpcLocation &loc,
const RoadGraph *graph) const;
/** Per-frame update; game mode only. Runs after the NPC detail
* tier so the population and locations are fresh. */
@@ -169,6 +187,48 @@ public:
float carOwnershipRatio = 0.5f;
/** Fleet size cap per town area. */
int maxCarsPerArea = 6;
/** Curb chain (phase 6.6.4): parallel-parking spots along the
* road edge — the sedan's length, the gap between chained cars
* and the lateral offset of the car CENTER past the
* carriageway edge (keeps the ~1.0 m half-width body clear of
* the outer lane). */
float curbChainCarLength = 5.0f;
float curbChainGap = 0.8f;
float curbLateralOffset = 1.2f;
/** Occupancy (6.6.3): a curb location's chain is considered
* full past this many spots (a full curb is skipped). */
int curbChainMaxSpots = 8;
/** Bay exit (6.6.6): every leg route departing a bay slot
* inserts a spine waypoint this far straight out along the
* slot's nose before the road stitch, so pure-pursuit leaves
* the bay dead straight instead of cutting the corner into
* the car parked in the adjacent bay (2.6 m bay pitch leaves
* well under a meter of side clearance). */
float bayExitSpineDist = 8.0f;
/** Park approach (6.6.6): every leg route inserts a spine
* waypoint this far BEFORE the park pose (on the approach
* side) so the final segment runs along the slot's nose
* axis — a diagonal approach stops at the AI's 2.5 m
* arriveRadius with the error landing laterally, outside
* the alight gate, which is why parallel roadside spots
* were never reached without it. */
float parkApproachSpineDist = 5.0f;
/** Drop-off discipline (6.6.5): the live car must park within
* the park pose's box — parkArriveM laterally off the slot's
* nose axis (what keeps the body out of the carriageway) and
* parkLongitudinalM along it (the AI's 2.5 m arriveRadius
* only ever undershoots longitudinally) — and slower than
* parkAlightSpeedMps before the driver alights; never
* mid-lane on bare AiDriverComponent::arrived. The blocked
* watchdog runs while the car is within parkEngageM of the
* pose without improving its approach; after
* parkFallbackSeconds the car retargets to the nearest free
* curb-chain spot. */
float parkArriveM = 1.5f;
float parkLongitudinalM = 3.0f;
float parkAlightSpeedMps = 0.6f;
float parkEngageM = 9.0f;
float parkFallbackSeconds = 5.0f;
/** Spawn/despawn distance (squared, world units) camera<->car;
* larger than the pedestrian tier — cars are fast and big. */
float spawnDistanceSq = 150.0f * 150.0f;
@@ -221,6 +281,8 @@ private:
int areaIdx = -1;
/** Home Parking/Curb location name (TownLocations). */
std::string homeParking;
/** Home curb-chain index (6.6.3; 0 for bay slots). */
int homeChainK = 0;
/** Driver's home SCHEDULE location name ("home:<addr>") —
* the car's parked-at chain starts here every day. */
std::string homeLocation;
@@ -229,6 +291,27 @@ private:
Ogre::Vector3 parkedPos = Ogre::Vector3::ZERO;
Ogre::Quaternion parkedRot = Ogre::Quaternion::IDENTITY;
/* Occupancy (6.6.3): slot claims are a PURE function of
* the clock, rebuilt every frame by rebuildClaims() —
* the day's drive-leg events (dest claim at walk
* departure, origin release at drive departure) are
* replayed in (time, driver-id) order from the day
* start, so clock scrubs reproduce assignments
* bit-exactly. claimedSlotLoc is the SCHEDULE location
* the claim serves; claimedSlot/claimedK identify the
* parking slot (chain index for curb spots); legClaims
* records each driven leg's claimed endpoints. */
std::string claimedSlotLoc;
std::string claimedSlot;
int claimedK = 0;
struct LegClaim {
std::string originSlot;
int originK = 0;
std::string destSlot;
int destK = 0;
};
std::map<std::string, LegClaim> legClaims; /* by legKey */
/* Symbolic state — re-derived from the clock every
* frame by evalSymbolic(). */
Phase phase = Phase::Parked;
@@ -257,24 +340,77 @@ private:
int seatIdx = -1;
Ogre::Vector3 savedCharScale = Ogre::Vector3::UNIT_SCALE;
float dwell = 0.0f;
/* Live drop-off state (6.6.5): the park pose captured
* when the AI attached (survives the symbolic clock
* chaining into the next leg), the approach watchdog
* and the curb-fallback retarget. */
Ogre::Vector3 liveDestPos = Ogre::Vector3::ZERO;
Ogre::Quaternion liveDestRot = Ogre::Quaternion::IDENTITY;
Ogre::Vector3 lastLivePos = Ogre::Vector3::ZERO;
bool haveLastLivePos = false;
float parkMinDist = FLT_MAX;
float parkBlockedSec = 0.0f;
bool parkCreepFired = false;
bool parkFallbackFired = false;
bool parkFallbackWarned = false;
};
void seedArea(int areaIdx);
bool parkedPlacement(const TownLocations::NpcLocation &park,
const RoadGraph *graph, Ogre::Vector3 &pos,
Ogre::Quaternion &rot) const;
const TownLocations::NpcLocation *parkingFor(
const TownLocations::LocationSet &locs, const TrafficCar &car,
const std::string &locName) const;
Ogre::Quaternion &rot, int chainIndex = 0) const;
/** A parking slot pick: the location + the curb-chain index
* (0 for bay slots). */
struct SlotPick {
const TownLocations::NpcLocation *loc = nullptr;
int k = 0;
};
/* Occupancy registry (6.6.3) — a per-frame pure snapshot
* rebuilt by rebuildClaims(). */
static std::string claimKey(int areaIdx, const std::string &slotName,
int k);
bool isSlotFree(int areaIdx, const std::string &slotName, int k,
uint64_t forDriver) const;
/** Lowest unclaimed chain index of a curb location,
* curbChainMaxSpots when full. */
int lowestFreeChainK(int areaIdx, const std::string &curbName,
uint64_t forDriver) const;
/** Slot for a trip to locName: the car's own home spot when
* going home; with respectClaims the nearest FREE Parking slot
* (ties = lowest location index), then the nearest curb with a
* free chain spot; without it the legacy occupancy-blind
* nearest Parking-or-Curb at k=0 (the stable default pick used
* for the pure event timing). The graph is only consulted by
* the respectClaims variant (6.6.6 reachability). */
SlotPick parkingFor(const TownLocations::LocationSet &locs,
const TrafficCar &car, const std::string &locName,
bool respectClaims,
const RoadGraph *graph = nullptr) const;
/** 6.6.3: rebuild the whole claim map from the clock — replays
* every car's day chain, claiming the destination slot at the
* walk departure and releasing the visitor origin at the drive
* departure, events ordered by (time, driver id). Pure: the
* same clock always reproduces the same assignments. */
void rebuildClaims(double tabs, float tod, const RoadGraph *graph);
const LegRoute *legRoute(TrafficCar &car, const std::string &fromLoc,
const std::string &toLoc,
const SlotPick &origin, const SlotPick &dest,
const RoadGraph &graph);
void evalSymbolic(TrafficCar &car, double tabs, float tod,
const RoadGraph *graph);
void setSuspended(TrafficCar &car, bool susp);
void stepHighDetail(TrafficCar &car);
void stepHighDetail(TrafficCar &car, float dt, const RoadGraph *graph);
void attachAi(TrafficCar &car);
void alightDriver(TrafficCar &car);
/** 6.6.5: retarget the live car to the nearest free curb-chain
* spot. The symbolic claim deliberately stays on the original
* destination slot; the deviation self-heals on the next leg or
* despawn. */
void retargetToFallback(TrafficCar &car, const RoadGraph *graph);
Ogre::Vector3 doorWorld(const TrafficCar &car) const;
flecs::entity driverEntity(const TrafficCar &car) const;
bool ownDriver(const TrafficCar &car, flecs::entity ch) const;
@@ -290,6 +426,10 @@ private:
EditorApp *m_editorApp;
std::map<uint64_t, TrafficCar> m_cars; /* key: driver registry id */
std::set<int> m_seededAreas;
/** Slot claims (6.6.3): claimKey -> driver registry id. A
* per-frame pure snapshot rebuilt by rebuildClaims() — never
* mutated incrementally. */
std::map<std::string, uint64_t> m_slotClaims;
/* Leg route cache (clock-independent geometry), flushed whole on
* a graph version change or overflow. */
std::map<std::string, LegRoute> m_legRoutes;
@@ -1,5 +1,6 @@
#include "PhysicsSystem.hpp"
#include "../components/Transform.hpp"
#include "../components/TriangleBuffer.hpp"
#include <OgreLogManager.h>
#include <OgreTimer.h>
@@ -351,6 +352,32 @@ void EditorPhysicsSystem::updateRigidBody(flecs::entity entity,
return;
}
// A Mesh collider backed by a procedural TriangleBufferComponent
// that is still dirty cannot load yet (ProceduralMeshSystem
// converts it later in the frame). Defer body creation so the
// next sync builds the collider from the finished mesh instead
// of silently falling back to the default box.
bool meshPending = false;
auto checkMeshPending = [&](flecs::entity e) {
if (!e.has<PhysicsColliderComponent>() ||
!e.has<TriangleBufferComponent>())
return;
const auto &c = e.get<PhysicsColliderComponent>();
const auto &tb = e.get<TriangleBufferComponent>();
if (c.shapeType ==
PhysicsColliderComponent::ShapeType::Mesh &&
tb.dirty && tb.meshName == c.meshName)
meshPending = true;
};
checkMeshPending(entity);
entity.children(
[&](flecs::entity child) { checkMeshPending(child); });
if (meshPending) {
rigidBody.bodyCreated = false;
rigidBody.bodyDirty = true;
return;
}
// Build collision shape
JPH::ShapeRefC shape = buildCompoundShape(entity);
if (!shape)
@@ -15,6 +15,7 @@
#include "../roadlib/RoadGeometryLib.hpp"
#include "../roadlib/RoadNetworkGen.hpp"
#include "PrefabSystem.hpp"
#include "TownGenSystem.hpp"
#include <OgreTerrainGroup.h>
#include <OgreMaterialManager.h>
#include <nlohmann/json.hpp>
@@ -1982,6 +1983,12 @@ flecs::entity RoadSystem::ensureRoadMaterial(const RoadConfig &cfg)
pm.diffuse[0] = 0.35f;
pm.diffuse[1] = 0.35f;
pm.diffuse[2] = 0.35f;
/* The plain resource already exists (above), so mesh conversion
* may bind it immediately; the material system's next update
* still restyles it because dirty defaults to true. */
pm.ogreMaterial = Ogre::MaterialManager::getSingleton().getByName(
cfg.roadMaterialName);
pm.created = true;
m_materialEntity = m_world.entity();
m_materialEntity.set<ProceduralMaterialComponent>(pm);
return m_materialEntity;
@@ -2277,12 +2277,22 @@ void TerrainSystem::fillPageHeightData(Ogre::TerrainGroup *group, long x,
for (int j = 0; j < terrainSize; ++j) {
for (int i = 0; i < terrainSize; ++i) {
const long wx = (long)(baseX + (double)i * step);
const long wz = (long)(baseZ + (double)j * step);
const double wxd = baseX + (double)i * step;
const double wzd = baseZ + (double)j * step;
const long wx = (long)wxd;
const long wz = (long)wzd;
/* sampleHeightAt() already layers detail noise on top of the
* base heightmap, so the page data matches raycasts and
* sculpt previews. */
heightMap[j * terrainSize + i] = sampleHeightAt(wx, wz);
* sculpt previews. The fixup layer is then re-sampled
* at the EXACT vertex position: the truncated long
* coords shift a sloped fixup field (road benches,
* parking pads) by up to a metre, which the rendered
* surface inherited. */
float h = sampleHeightAt(wx, wz);
const float fixup = sampleFixupAt(wxd, wzd);
if (fixup != FIXUP_SENTINEL)
h = fixup;
heightMap[j * terrainSize + i] = h;
}
}
}
@@ -2299,6 +2309,12 @@ float TerrainSystem::sampleBaseHeightAt(long worldX, long worldZ) const
return sampleBaseLocked(worldX, worldZ);
}
float TerrainSystem::sampleFixupAt(double worldX, double worldZ) const
{
std::lock_guard<std::mutex> lock(m_heightmapMutex);
return sampleFixupLocked(worldX, worldZ);
}
float TerrainSystem::sampleBaseLocked(long worldX, long worldZ) const
{
float h;
@@ -83,6 +83,12 @@ public:
* before calling. */
float sampleBaseHeightAt(long worldX, long worldZ) const;
float sampleBaseLocked(long worldX, long worldZ) const;
/* Fixup layer only, bilinear at EXACT double PHYSICAL coordinates
* (FIXUP_SENTINEL when no fixup covers the position). Used by
* fillPageHeightData so fixup-shaped content (road benches,
* parking pads, prefab footprints) lands on the rendered page
* vertices without the legacy (long) truncation shift. */
float sampleFixupAt(double worldX, double worldZ) const;
void setHeightAt(long worldX, long worldZ, float value);
bool loadHeightmap(const std::string &path);
bool saveHeightmap(const std::string &path);
@@ -18,6 +18,8 @@
#include "../components/NavMesh.hpp"
#include "../components/Door.hpp"
#include "../components/CellGrid.hpp"
#include "../components/Primitive.hpp"
#include "../components/PhysicsCollider.hpp"
#include "../components/Actuator.hpp"
#include "../components/SmartObject.hpp"
#include "../components/RigidBody.hpp"
@@ -40,6 +42,7 @@
#include "../ui/NavigationPanel.hpp"
#include "../physics/physics.h"
#include "../roadlib/RoadGeometryLib.hpp"
#include "../roadlib/RoadPath.hpp"
#include <ProceduralBoxGenerator.h>
#include <OgreMeshManager.h>
@@ -7487,6 +7490,7 @@ int TerrainTestRunner::run(EditorApp &app, int iterations)
testTerrainComplianceClearance },
{ "autoCompliance", testAutoCompliance },
{ "autoComplianceAprons", testAutoComplianceAprons },
{ "townParkingPads", testTownParkingPads },
{ "complyRoadsToTerrain", testComplyRoadsToTerrain },
{ "roadColliderInteraction", testRoadColliderInteraction },
{ "roadSidePrefabs", testRoadSidePrefabs },
@@ -8837,6 +8841,293 @@ bool TerrainTestRunner::testAutoComplianceAprons(EditorApp &app,
return true;
}
/* ------------------------------------------------------------------ */
/* testTownParkingPads (demo-taxi phase 6.6.6: parking pad */
/* helper geometry) */
/* ------------------------------------------------------------------ */
bool TerrainTestRunner::testTownParkingPads(EditorApp &app,
TerrainSystem *ts)
{
if (ts->isActive())
ts->deactivate();
/* Isolated terrain id: the town generation may materialize
* runtime data under heightmaps/<terrainId>/, which must
* never touch user data. */
const uint64_t testTerrainId = 42424245ULL;
std::string testDir = "heightmaps/" +
Ogre::StringConverter::toString(testTerrainId);
auto cleanupFiles = [&]() {
std::error_code ec;
std::filesystem::remove_all(testDir, ec);
};
cleanupFiles();
flecs::entity e = createTerrainEntity(app);
{
auto &tc = e.get_mut<TerrainComponent>();
tc.terrainId = testTerrainId;
/* Flat-lot / raised-road fixture: the ground is
* sculpted below, so the detail noise (which would
* swamp the sculpting) stays OFF. */
tc.worldSize = 500.0f;
tc.terrainSize = 257;
tc.detailNoise.enabled = false;
tc.detailNoise.seed = 26368;
tc.detailNoise.amplitude = 10.0f;
tc.detailNoise.frequency = 0.006f;
tc.detailNoise.octaves = 4;
tc.detailNoise.persistence = 0.5f;
tc.detailNoise.lacunarity = 2.0f;
}
pumpFrames(app, ts, 10);
auto fail = [&](const Ogre::String &msg) {
Ogre::LogManager::getSingleton().logMessage(
"TerrainTests: FAIL - " + msg);
destroyTerrainEntity(app, e);
pumpFrames(app, ts, 1);
cleanupFiles();
return false;
};
if (!ts->isActive())
return fail("terrain not active (parking pads)");
RoadSystem *rs = ts->getRoadSystem();
TownGenSystem *tgs = ts->getTownGenSystem();
if (!rs || !tgs)
return fail("no road/town-gen system (parking pads)");
(void)rs;
/* The rect sits BESIDE the house (x 16..28, clear of the
* walls and the door corridor) so the raised-frustum fit
* rules can actually pass on the sculpted ground. */
const char *layout =
"(shell 10 8 :floors 1)\n"
"(floor 0 (door front center))\n"
"(parking 16 0 12 12)\n";
/* One 120x120 ring -> one face lot plus four edge-fallback
* lots -> five buildings, each with a 12x12 parking rect.
* Candidate centers are probed in order and the first one
* producing pads wins. */
static const float candC[][2] = {
{ 0.0f, 0.0f },
{ 120.0f, 0.0f },
{ 0.0f, 120.0f },
{ -120.0f, 120.0f },
{ 150.0f, -90.0f },
};
static const float cornerOff[4][2] = {
{ -60.0f, -60.0f }, { 60.0f, -60.0f },
{ 60.0f, 60.0f }, { -60.0f, 60.0f },
};
/* Pad frame helper: origin + local axes from the pad record
* (TownGen convention: +Z faces the street, +X along the
* frontage, v into the lot). */
struct PadFrame {
Ogre::Vector3 origin, u, v;
};
auto frameOf = [](const TownGen::TownResult::Pad &pad) {
PadFrame f;
f.u = Ogre::Vector3(std::cos(pad.yaw), 0.0f,
-std::sin(pad.yaw));
f.v = Ogre::Vector3(-std::sin(pad.yaw), 0.0f,
-std::cos(pad.yaw));
f.origin = pad.center - f.u * (pad.w * 0.5f) -
f.v * (pad.d * 0.5f);
return f;
};
TownGen::TownResult pre;
bool placed = false;
/* The graph version must visibly change across candidate
* resets (RoadSystem keys rebuilds off it). */
uint64_t graphVersion = 1000;
for (const auto &cc : candC) {
{
auto &tc = e.get_mut<TerrainComponent>();
tc.roadGraph = RoadGraph();
tc.roadGraph.config.laneWidth = 3.0f;
const float cx = cc[0];
const float cz = cc[1];
int ids[4];
for (int i = 0; i < 4; ++i) {
float nx = cx + cornerOff[i][0];
float nz = cz + cornerOff[i][1];
/* The road rides 1 m above the LOCAL
* terrain: every candidate pad is raised
* with a small, fittable drop. */
float ny = ts->getHeightAt(
Ogre::Vector3(nx, 0.0f, nz)) +
1.0f;
ids[i] = tc.roadGraph.addNode(
Ogre::Vector3(nx, ny, nz), 0.0f);
}
for (int i = 0; i < 4; ++i)
tc.roadGraph.addEdge(ids[i],
ids[(i + 1) % 4]);
for (RoadEdge &ed : tc.roadGraph.edges) {
ed.lotLeft.layout = layout;
ed.lotRight.layout = layout;
}
RoadTownArea area;
area.name = "padtest";
area.district = RoadTownDistrict::Village;
area.center = Ogre::Vector3(cx, 0.0f, cz);
area.halfSize = 120.0f;
area.seed = 5;
tc.roadGraph.townAreas.push_back(area);
tc.roadGraph.version = graphVersion++;
}
std::string genErr;
if (!tgs->runAreaGeneration(0, pre, &genErr) ||
pre.pads.empty()) {
Ogre::LogManager::getSingleton().logMessage(
"TerrainTests: pad candidate (" +
Ogre::StringConverter::toString(cc[0]) +
"," +
Ogre::StringConverter::toString(cc[1]) +
") skipped: buildings " +
Ogre::StringConverter::toString(
pre.buildings.size()) + " pads " +
Ogre::StringConverter::toString(
pre.pads.size()) + " err " + genErr);
continue;
}
placed = true;
break;
}
if (!placed)
return fail("no candidate ring produced a parking pad");
pumpFrames(app, ts, 10);
/* Pre-generation heights at the per-pad sample points: the
* pad helper geometry must leave the terrain untouched
* (the phase 6.6.6 pivot — no fixups under pads). */
static const float sFrac[] = { 0.2f, 0.5f, 0.8f };
static const float tFrac[] = { 0.2f, 0.5f, 0.8f };
std::vector<Ogre::Vector3> samples;
std::vector<float> preH;
for (const TownGen::TownResult::Pad &pad : pre.pads) {
PadFrame f = frameOf(pad);
for (float sf : sFrac)
for (float tf : tFrac) {
Ogre::Vector3 p =
f.origin + f.u * (pad.w * sf) +
f.v * (pad.d * tf);
samples.push_back(p);
preH.push_back(ts->getHeightAt(p));
}
}
std::string genErr;
if (tgs->generateAreas(&genErr) == 0)
return fail("town generation with pads failed: " +
genErr);
pumpFrames(app, ts, 5);
/* The pads re-generate identically (the XZ frame + the
* graded top are pure). */
TownGen::TownResult post;
if (!tgs->runAreaGeneration(0, post, &genErr) ||
post.pads.size() != pre.pads.size())
return fail("pad re-generation after town build failed: " +
genErr);
for (size_t pi = 0; pi < pre.pads.size(); ++pi) {
const TownGen::TownResult::Pad &a = pre.pads[pi];
const TownGen::TownResult::Pad &b = post.pads[pi];
if (std::fabs(b.center.x - a.center.x) > 1e-4f ||
std::fabs(b.center.z - a.center.z) > 1e-4f ||
std::fabs(b.yaw - a.yaw) > 1e-6f ||
std::fabs(b.w - a.w) > 1e-4f ||
std::fabs(b.d - a.d) > 1e-4f ||
std::fabs(b.topY - a.topY) > 1e-4f)
return fail("pad frame changed across the town build");
}
/* Terrain untouched: every sample bit-identical to the
* pre-generation heights. */
for (size_t i = 0; i < samples.size(); ++i)
if (ts->getHeightAt(samples[i]) != preH[i])
return fail("terrain moved under a parking pad");
/* Per pad: raised pads are ONE frustum entity (the node at
* the parking surface's height, a Mesh collider); flat pads
* have no geometry at all. */
int raisedPads = 0;
int flatPads = 0;
for (const TownGen::TownResult::Pad &pad : post.pads) {
flecs::entity building;
app.getWorld()
->query<EntityNameComponent, CellGridComponent>()
.each([&](flecs::entity ent, EntityNameComponent &n,
CellGridComponent &) {
if (n.name == pad.address)
building = ent;
});
if (!building.is_alive())
return fail("no building entity for " + pad.address);
flecs::entity padEnt;
int colliderParts = 0;
building.children([&](flecs::entity child) {
if (!child.has<EntityNameComponent>() ||
child.get<EntityNameComponent>().name !=
"parking-pad")
return;
padEnt = child;
if (child.has<PhysicsColliderComponent>() &&
child.get<PhysicsColliderComponent>()
.shapeType ==
PhysicsColliderComponent::ShapeType::Mesh)
++colliderParts;
});
if (pad.raised) {
++raisedPads;
if (!padEnt.is_alive())
return fail("raised pad has no frustum entity: " +
pad.address);
if (colliderParts != 1)
return fail("pad frustum has no mesh collider: " +
pad.address);
float topY = padEnt.get<TransformComponent>()
.node->_getDerivedPosition()
.y;
if (std::fabs(topY - pad.topY) > 0.03f)
return fail("pad frustum top off the parking "
"surface height");
} else {
++flatPads;
if (padEnt.is_alive())
return fail("flat pad built geometry: " +
pad.address);
}
}
tgs->clear();
destroyTerrainEntity(app, e);
pumpFrames(app, ts, 3);
cleanupFiles();
if (raisedPads < 1)
return fail("no raised frustum pad fitted the sculpted "
"fixture");
Ogre::LogManager::getSingleton().logMessage(
"TerrainTests: town parking pads test passed (" +
Ogre::StringConverter::toString(raisedPads) +
" frustum pads, " +
Ogre::StringConverter::toString(flatPads) +
" flat roadside pads, terrain untouched)");
return true;
}
/* ------------------------------------------------------------------ */
/* testRoadColliderInteraction (W4) */
/* ------------------------------------------------------------------ */
@@ -91,6 +91,7 @@ private:
static bool testAutoCompliance(EditorApp &app, TerrainSystem *ts);
static bool testAutoComplianceAprons(EditorApp &app,
TerrainSystem *ts);
static bool testTownParkingPads(EditorApp &app, TerrainSystem *ts);
static bool testComplyRoadsToTerrain(EditorApp &app,
TerrainSystem *ts);
static bool testRoadColliderInteraction(EditorApp &app, TerrainSystem *ts);
@@ -19,6 +19,7 @@
#include "../components/RoadGraph.hpp"
#include "../physics/physics.h"
#include "../roadlib/RoadNetworkGen.hpp"
#include "../roadlib/RoadPath.hpp"
#include <OgreRTShaderSystem.h>
@@ -347,7 +348,9 @@ bool TownGenSystem::previewLayout(const std::string &layoutSource,
b.stairs = false;
std::vector<const TownGen::TownPoi *> noPois;
createBuilding(container, material, texture, b, noPois, genMode);
std::vector<const TownGen::TownResult::Pad *> noPads;
createBuilding(container, material, texture, b, noPois, genMode,
noPads);
return true;
}
@@ -678,7 +681,6 @@ int TownGenSystem::regenerateBuilding(const std::string &address,
"regenerate the whole town",
error);
}
int TownGenSystem::regenerateByLotParams(int edgeIndex, int side,
std::string *error)
{
@@ -802,6 +804,11 @@ int TownGenSystem::regenerateMatching(
for (const TownGen::TownPoi &poi : result.pois)
if (poi.building == (int)i)
pois.push_back(&poi);
std::vector<const TownGen::TownResult::Pad *> pads;
for (const TownGen::TownResult::Pad &pad :
result.pads)
if (pad.building == (int)i)
pads.push_back(&pad);
flecs::entity material;
if (run.entity.has<TownComponent>())
@@ -822,7 +829,7 @@ int TownGenSystem::regenerateMatching(
destroyEntityDeep(oldBuilding);
createBuilding(run.entity, material, texture, tb, pois,
genMode);
genMode, pads);
++rebuilt;
}
}
@@ -882,13 +889,20 @@ TownGenSystem::createTownContainer(const std::string &containerName,
for (size_t i = 0; i < result.buildings.size(); ++i) {
/* POIs are not 1:1 with buildings (parking POIs share
* their building's slot), gather them by index. */
* their building's slot), gather them by index — and
* the building's parking pads for the helper
* geometry. */
std::vector<const TownGen::TownPoi *> pois;
for (const TownGen::TownPoi &poi : result.pois)
if (poi.building == (int)i)
pois.push_back(&poi);
std::vector<const TownGen::TownResult::Pad *> pads;
for (const TownGen::TownResult::Pad &pad : result.pads)
if (pad.building == (int)i)
pads.push_back(&pad);
createBuilding(container, material, texture,
result.buildings[i], pois);
result.buildings[i], pois, "exteriorOnly",
pads);
}
return container;
@@ -1063,6 +1077,7 @@ TownGenSystem::createSettlementResources(flecs::entity container,
tex.setColor(4, Ogre::ColourValue(0.90f, 0.90f, 0.88f)); /* winframe */
tex.setColor(5, Ogre::ColourValue(0.75f, 0.70f, 0.62f)); /* floor */
tex.setColor(6, Ogre::ColourValue(0.92f, 0.92f, 0.92f)); /* ceiling */
tex.setColor(7, Ogre::ColourValue(0.18f, 0.18f, 0.19f)); /* asphalt */
tex.addNamedRect("wall", 0);
tex.addNamedRect("roof", 1);
tex.addNamedRect("foundation", 2);
@@ -1070,6 +1085,13 @@ TownGenSystem::createSettlementResources(flecs::entity container,
tex.addNamedRect("winframe", 4);
tex.addNamedRect("floor", 5);
tex.addNamedRect("ceiling", 6);
tex.addNamedRect("parking", 7);
/* Wider UV inset: the palette rects are flat colors, so a 4%
* border costs nothing visually and keeps linear sampling
* (and the wrap at the texture's edge) out of neighboring
* rects — parking slabs used to grow tan/cream edge lines
* from the wall/ceiling palette cells. */
tex.uvMargin = 0.04f;
texture.set<ProceduralTextureComponent>(tex);
return texture;
@@ -1224,7 +1246,10 @@ void TownGenSystem::createBuilding(flecs::entity container,
flecs::entity material, flecs::entity texture,
const TownGen::TownBuilding &b,
const std::vector<const TownGen::TownPoi *> &pois,
const std::string &genMode)
const std::string &genMode,
const std::vector<
const TownGen::TownResult::Pad *>
&pads)
{
TerrainSystem *ts = m_terrainSystem ?
m_terrainSystem :
@@ -1363,6 +1388,12 @@ void TownGenSystem::createBuilding(flecs::entity container,
poiXform.applyToNode();
poiEnt.set<TransformComponent>(poiXform);
}
/* Parking-pad helper geometry (phase 6.6.6): asphalt slabs
* flush with the frontage road plus lot-side ramps — the
* terrain stays untouched. */
for (const TownGen::TownResult::Pad *pad : pads)
createParkingPad(building, *pad, material, texture);
}
void TownGenSystem::createFoundation(flecs::entity building,
@@ -1583,3 +1614,139 @@ void TownGenSystem::createStairs(flecs::entity building,
step.set<PhysicsColliderComponent>(collider);
}
}
/* Parking-pad helper geometry (phase 6.6.6): ONE SOLID FRUSTUM.
* A box is placed so its top face is the parking surface (the pad
* rect plus its street-ward reach, touching the road body) at 0.2
* below the road's surface; the bottom face is then dropped and
* expanded so all four sides form 30-degree slopes whose bottom
* edges sit below the terrain. The frustum is a single 12-triangle
* mesh with an exact mesh collider — no seams, no gaps, no loose
* parts — and its slopes are walkable, so building exits are never
* blocked. Flat pads (the road less than 0.35 m above the lot's
* ground) get no geometry at all: their bays are plain roadside
* spots on the natural terrain. The terrain is never modified. */
void TownGenSystem::createParkingPad(flecs::entity building,
const TownGen::TownResult::Pad &pad,
flecs::entity material,
flecs::entity texture)
{
/* Flat pads: no geometry (the address parks parallel at
* the roadside). */
if (!pad.raised)
return;
const Ogre::Vector3 u(std::cos(pad.yaw), 0.0f, -std::sin(pad.yaw));
const Ogre::Vector3 v(-std::sin(pad.yaw), 0.0f, -std::cos(pad.yaw));
/* The top face's span: the parking rect extended street-ward
* to the carriageway edge (no overlap into the road body). */
const float reach = std::max(0.0f, pad.apronReach);
const float topD = pad.d + reach;
const Ogre::Vector3 topCenter = pad.center - v * (reach * 0.5f);
/* The pure layer already settled the drop against the
* terrain (bottom 0.4 m below the lowest ground over the
* footprint) and checked the footprint clears the house
* and the doorstep — just build it. */
const float tan30 = 1.7320508f; /* 1 / tan(30 deg) */
const float drop = pad.drop;
const float expand = drop * tan30;
/* The frustum's 8 corners, LOCAL to the pad entity's node
* (the node sits at the top face's center, topY). */
const float hw = pad.w * 0.5f;
const float hd = topD * 0.5f;
const Ogre::Vector3 tl(-hw, 0.0f, hd);
const Ogre::Vector3 tr(hw, 0.0f, hd);
const Ogre::Vector3 bl(-hw, 0.0f, -hd);
const Ogre::Vector3 br(hw, 0.0f, -hd);
const float bhw = hw + expand;
const float bhd = hd + expand;
const float bY = -drop;
const Ogre::Vector3 Btl(-bhw, bY, bhd);
const Ogre::Vector3 Btr(bhw, bY, bhd);
const Ogre::Vector3 Bbl(-bhw, bY, -bhd);
const Ogre::Vector3 Bbr(bhw, bY, -bhd);
Procedural::TriangleBuffer buf;
auto addTri = [&buf](const Ogre::Vector3 &p0,
const Ogre::Vector3 &p1,
const Ogre::Vector3 &p2) {
Ogre::Vector3 n = (p1 - p0).crossProduct(p2 - p0);
if (n.squaredLength() < 1e-10f)
return;
n.normalise();
int base = (int)buf.getVertices().size();
const Ogre::Vector3 *pp[3] = { &p0, &p1, &p2 };
for (int i = 0; i < 3; ++i) {
Procedural::TriangleBuffer::Vertex vtx;
vtx.mPosition = *pp[i];
vtx.mNormal = n;
vtx.mUV = Ogre::Vector2(0.5f, 0.5f);
buf.getVertices().push_back(vtx);
buf.getIndices().push_back(base + i);
}
};
/* top (+Y) */
addTri(tl, tr, br);
addTri(tl, br, bl);
/* bottom (-Y) */
addTri(Btl, Bbr, Btr);
addTri(Btl, Bbl, Bbr);
/* street side (+Z) */
addTri(tl, Btl, Btr);
addTri(tl, Btr, tr);
/* lot side (-Z) */
addTri(bl, Bbr, Bbl);
addTri(bl, br, Bbr);
/* left (-X) */
addTri(tl, Bbl, Btl);
addTri(tl, bl, Bbl);
/* right (+X) */
addTri(tr, Btr, Bbr);
addTri(tr, Bbr, br);
flecs::entity padEnt = m_world.entity();
padEnt.child_of(building);
padEnt.set<EntityNameComponent>(
EntityNameComponent("parking-pad"));
padEnt.add<EditorMarkerComponent>();
/* The node at the top face's center, topY high — building-
* local transform (world rotated into the node's frame). */
const TransformComponent &bt = building.get<TransformComponent>();
Ogre::Vector3 nodeWorld(topCenter.x, pad.topY, topCenter.z);
Ogre::Vector3 nodeLocal =
bt.rotation.Inverse() * (nodeWorld - bt.position);
TransformComponent padXform;
padXform.node = building.get<TransformComponent>()
.node->createChildSceneNode();
padXform.position = nodeLocal;
padXform.rotation = Ogre::Quaternion::IDENTITY;
padXform.scale = Ogre::Vector3::UNIT_SCALE;
padXform.applyToNode();
padEnt.set<TransformComponent>(padXform);
TriangleBufferComponent tb;
tb.buffer = std::make_shared<Procedural::TriangleBuffer>(buf);
tb.proceduralContent = true;
tb.textureEntity = texture;
tb.textureRectName = "parking";
tb.materialEntity = material;
tb.meshName = pad.address + "_parking-pad";
tb.dirty = true;
padEnt.set<TriangleBufferComponent>(tb);
RigidBodyComponent rb;
rb.bodyType = RigidBodyComponent::BodyType::Static;
padEnt.set<RigidBodyComponent>(rb);
/* Exact collider: the mesh itself (static body, 12 triangles).
* The mesh is converted by ProceduralMeshSystem earlier in the
* frame, before the physics pass builds this body. */
PhysicsColliderComponent col;
col.shapeType = PhysicsColliderComponent::ShapeType::Mesh;
col.meshName = tb.meshName;
padEnt.set<PhysicsColliderComponent>(col);
}
@@ -77,6 +77,7 @@ public:
bool runAreaGeneration(int areaIndex, TownGen::TownResult &out,
std::string *error);
/** Per-frame driver (phase 4.5 item 5, called from
* TerrainSystem::update). Game mode + streaming terrain + town
* areas in the graph: regenerates each loaded page's buildings
@@ -202,7 +203,12 @@ private:
void createBuilding(flecs::entity container, flecs::entity material,
flecs::entity texture, const TownGen::TownBuilding &b,
const std::vector<const TownGen::TownPoi *> &pois,
const std::string &genMode = "exteriorOnly");
const std::string &genMode = "exteriorOnly",
const std::vector<const TownGen::TownResult::Pad *>
&pads = {});
void createParkingPad(flecs::entity building,
const TownGen::TownResult::Pad &pad,
flecs::entity material, flecs::entity texture);
void createFoundation(flecs::entity building,
const TownGen::TownBuilding &b,
const CellGridComponent &grid,
@@ -38,6 +38,7 @@
#include "roadlib/RoadNetworkGen.hpp"
#include "roadlib/RoadPath.hpp"
#include "roadlib/TownGen.hpp"
#include "roadlib/TownParking.hpp"
#include "roadlib/TownPopulation.hpp"
#include "roadlib/TownLocations.hpp"
#include "roadlib/TownSchedule.hpp"
@@ -5582,6 +5583,35 @@ static bool runRoadPathProbe()
"***\n");
ok = false;
}
/* projectOnEdge (6.6.6): same geometry, pinned to one
* frontage edge; surfaceTopY includes the slab top. */
float nearestTop = 0.0f;
const RoadEdge *nearest = nullptr;
if (RoadPath::nearestEdgeInfo(graph, Vector3(50, 0, 2),
proj, dir, halfW, &nearestTop,
&nearest) && nearest) {
const int edgeIndex = (int)(nearest - graph.edges.data());
Vector3 pinnedProj, pinnedDir;
float pinnedHalf = 0.0f;
float pinnedTop = 0.0f;
if (!RoadPath::projectOnEdge(
graph, edgeIndex, Vector3(25, 0, 2),
pinnedProj, pinnedDir, pinnedHalf,
&pinnedTop) ||
pinnedProj.distance(Vector3(25, 0, 0)) > 0.01f ||
std::fabs(pinnedHalf - 3.0f) > 0.01f ||
std::fabs(pinnedTop - nearestTop) > 0.01f) {
printf(" *** projectOnEdge mismatch ***\n");
ok = false;
}
if (RoadPath::projectOnEdge(graph, -1, Vector3::ZERO,
pinnedProj, pinnedDir,
pinnedHalf)) {
printf(" *** projectOnEdge accepted edge -1 "
"***\n");
ok = false;
}
}
}
/* A separate edge component with no connection to the grid ->
@@ -6335,6 +6365,649 @@ static bool runTownLocationsProbe()
return ok;
}
/**
* Town parking slots probe (phase 6.6.1): the pure bay enumeration
* (TownParking::enumerateSlots) and the TownGen integration — each
* layout parking rect must emit one POI per slot with a stable
* address-keyed name and a nose-to-street heading.
*/
static bool runTownParkingProbe()
{
printf("town parking slots probe\n");
bool ok = true;
/* --- pure enumeration math --- */
const TownParking::Config cfg; /* 2.6 x 5.2 bays, 6 m aisle,
* cap 12. */
/* Single row: 12x8 -> floor(12/2.6) = 4 bays at z = 2.6. */
std::vector<TownParking::Slot> s1 =
TownParking::enumerateSlots(12.0f, 8.0f, cfg);
if (s1.size() != 4) {
printf(" *** 12x8: %zu slots, want 4 ***\n", s1.size());
ok = false;
} else {
for (size_t i = 0; i < s1.size(); ++i) {
const TownParking::Slot &s = s1[i];
if (std::fabs(s.w - 2.6f) > 1e-4f ||
std::fabs(s.d - 5.2f) > 1e-4f ||
std::fabs(s.z - 2.6f) > 1e-4f ||
s.x - s.w * 0.5f < -1e-4f ||
s.x + s.w * 0.5f > 12.0f + 1e-4f) {
printf(" *** 12x8 slot %zu out of "
"rect/bay spec ***\n", i);
ok = false;
}
if (i > 0 &&
std::fabs(s.x - s1[i - 1].x - 2.6f) > 1e-4f) {
printf(" *** 12x8 slot %zu spacing %.3f "
"!= 2.6 ***\n", i, s.x - s1[i - 1].x);
ok = false;
}
}
}
/* Double row: 16x17 -> 2 rows of min(floor(16/2.6), 6) = 6. */
std::vector<TownParking::Slot> s2 =
TownParking::enumerateSlots(16.0f, 17.0f, cfg);
if (s2.size() != 12) {
printf(" *** 16x17: %zu slots, want 12 ***\n", s2.size());
ok = false;
} else {
for (size_t i = 0; i < s2.size(); ++i) {
float wantZ = i < 6 ? 2.6f : 5.2f + 6.0f + 2.6f;
if (std::fabs(s2[i].z - wantZ) > 1e-4f) {
printf(" *** 16x17 slot %zu z %.3f, "
"want %.3f ***\n", i, s2[i].z, wantZ);
ok = false;
}
}
}
/* Cap: 100x17 -> 2 rows but only 6+6 = 12 slots. */
if (TownParking::enumerateSlots(100.0f, 17.0f, cfg).size() != 12) {
printf(" *** 100x17: cap failed ***\n");
ok = false;
}
/* Degraded: 4x5 fits no bay -> one centered rect-sized slot. */
std::vector<TownParking::Slot> s4 =
TownParking::enumerateSlots(4.0f, 5.0f, cfg);
if (s4.size() != 1 || std::fabs(s4[0].x - 2.0f) > 1e-4f ||
std::fabs(s4[0].z - 2.5f) > 1e-4f) {
printf(" *** 4x5 degraded slot wrong ***\n");
ok = false;
}
/* Empty rect -> no slots. */
if (!TownParking::enumerateSlots(0.0f, 5.0f, cfg).empty()) {
printf(" *** 0x5: want no slots ***\n");
ok = false;
}
/* --- 6.6.2: walk-corridor pruning (pure) --- */
/* s1 centers: 2.1 / 4.7 / 7.3 / 9.9 (ranges [.8,3.4] ...
* [8.6,11.2]). A 1.5 m corridor at x=6 cuts the two middle
* bays; the doorstep square at z=12 adds nothing (bays end at
* z=5.2). */
{
TownParking::WalkCorridor wc;
wc.doorX = 6.0f;
wc.doorZ = 12.0f;
int dropped = -1;
std::vector<TownParking::Slot> pr =
TownParking::pruneSlots(s1, wc, &dropped);
if (dropped != 2 || pr.size() != 2 ||
std::fabs(pr[0].x - 2.1f) > 1e-4f ||
std::fabs(pr[1].x - 9.9f) > 1e-4f) {
printf(" *** corridor prune: kept %zu dropped "
"%d, want 2/2 at x 2.1/9.9 ***\n",
pr.size(), dropped);
ok = false;
}
}
/* Doorstep square only (corridor inactive at doorZ = 0):
* doorX = 8.6 -> the two right bays dropped. */
{
TownParking::WalkCorridor wc;
wc.doorX = 8.6f;
wc.doorZ = 0.0f;
int dropped = -1;
std::vector<TownParking::Slot> pr =
TownParking::pruneSlots(s1, wc, &dropped);
if (dropped != 2 || pr.size() != 2 ||
std::fabs(pr[0].x - 2.1f) > 1e-4f ||
std::fabs(pr[1].x - 4.7f) > 1e-4f) {
printf(" *** doorstep prune: kept %zu dropped "
"%d, want 2/2 at x 2.1/4.7 ***\n",
pr.size(), dropped);
ok = false;
}
}
/* Corridor outside the rect: nothing dropped. */
{
TownParking::WalkCorridor wc;
wc.doorX = 60.0f;
wc.doorZ = 12.0f;
int dropped = -1;
TownParking::pruneSlots(s1, wc, &dropped);
if (dropped != 0) {
printf(" *** far corridor dropped %d ***\n",
dropped);
ok = false;
}
}
/* --- 6.6.4: curb chain poses (pure, RoadPath) --- */
{
RoadGraph cg;
int na = cg.addNode(Vector3(0, 0, 0), 0.0f);
int nb = cg.addNode(Vector3(100, 0, 0), 0.0f);
cg.addEdge(na, nb); /* 1+1 lanes x 3 m -> halfW 3.0 */
Ogre::Vector3 pos;
Ogre::Quaternion rot;
/* k = 0: curb-line pose on the POI's side, nose +X. */
if (!RoadPath::curbChainPose(cg, Vector3(50, 7, 10), 0,
5.0f, 0.8f, 1.2f, false,
pos, rot)) {
printf(" *** curb chain: no edge ***\n");
ok = false;
} else {
if (std::fabs(pos.x - 50.0f) > 1e-4f ||
std::fabs(pos.z - 4.2f) > 1e-4f ||
std::fabs(pos.y - 7.0f) > 1e-4f) {
printf(" *** curb k=0 pose (%.3f, %.3f, "
"%.3f), want (50, 7, 4.2) ***\n",
pos.x, pos.y, pos.z);
ok = false;
}
/* Out of the lane: the sedan half width (~1.01)
* inside the curb offset stays past the
* carriageway edge. */
if (std::fabs(pos.z) - 1.01f < 3.0f - 1e-3f) {
printf(" *** curb k=0 body reaches "
"into the lane ***\n");
ok = false;
}
Ogre::Vector3 nose = rot * Ogre::Vector3::UNIT_Z;
if (std::fabs(nose.x - 1.0f) > 1e-4f ||
std::fabs(nose.z) > 1e-4f) {
printf(" *** curb k=0 nose not +X ***\n");
ok = false;
}
}
/* Chain: k grows behind the nose; spacing carLen+gap. */
Ogre::Vector3 p1, p2;
Ogre::Quaternion r1, r2;
RoadPath::curbChainPose(cg, Vector3(50, 0, 10), 1,
5.0f, 0.8f, 1.2f, false, p1, r1);
RoadPath::curbChainPose(cg, Vector3(50, 0, 10), 2,
5.0f, 0.8f, 1.2f, false, p2, r2);
if (std::fabs(p1.x - (50.0f - 5.8f)) > 1e-4f ||
std::fabs(p2.x - (50.0f - 11.6f)) > 1e-4f ||
std::fabs(p1.z - 4.2f) > 1e-4f) {
printf(" *** curb chain spacing wrong: k1 "
"(%.2f, %.2f) k2 (%.2f, %.2f) ***\n",
p1.x, p1.z, p2.x, p2.z);
ok = false;
}
/* Flipped direction: nose -X, chain grows +X. */
Ogre::Vector3 pf;
Ogre::Quaternion rf;
RoadPath::curbChainPose(cg, Vector3(50, 0, 10), 1,
5.0f, 0.8f, 1.2f, true, pf, rf);
Ogre::Vector3 noseF = rf * Ogre::Vector3::UNIT_Z;
if (std::fabs(pf.x - (50.0f + 5.8f)) > 1e-4f ||
std::fabs(noseF.x + 1.0f) > 1e-4f) {
printf(" *** curb chain flip wrong ***\n");
ok = false;
}
/* POI on the far side flips the lateral side. */
Ogre::Vector3 pn;
Ogre::Quaternion rn;
RoadPath::curbChainPose(cg, Vector3(50, 0, -10), 0,
5.0f, 0.8f, 1.2f, false, pn, rn);
if (std::fabs(pn.z + 4.2f) > 1e-4f) {
printf(" *** curb far-side z %.3f, want -4.2 "
"***\n", pn.z);
ok = false;
}
/* Centerline POI: right-of-direction fallback. */
Ogre::Vector3 pc;
Ogre::Quaternion rc;
RoadPath::curbChainPose(cg, Vector3(50, 0, 0), 0,
5.0f, 0.8f, 1.2f, false, pc, rc);
if (std::fabs(pc.z + 4.2f) > 1e-4f) {
printf(" *** curb centerline z %.3f, want "
"-4.2 ***\n", pc.z);
ok = false;
}
/* Empty graph: false. */
RoadGraph empty;
if (RoadPath::curbChainPose(empty, Vector3::ZERO, 0,
5.0f, 0.8f, 1.2f, false,
pos, rot)) {
printf(" *** curb chain on an empty graph "
"***\n");
ok = false;
}
}
/* --- TownGen integration: per-slot POIs with headings --- */
RoadGraph graph;
{
int ids[3][3];
for (int i = 0; i < 3; ++i)
for (int j = 0; j < 3; ++j)
ids[i][j] = graph.addNode(
Vector3(i * 100.0f, 0, j * 100.0f),
0.0f);
for (int i = 0; i < 3; ++i)
for (int j = 0; j < 3; ++j) {
if (i < 2)
graph.addEdge(ids[i][j],
ids[i + 1][j]);
if (j < 2)
graph.addEdge(ids[i][j],
ids[i][j + 1]);
}
}
/* One inline layout with a 12x12 road-side parking rect on
* every lot: 4 single-row bays per building. */
for (RoadEdge &e : graph.edges) {
e.lotLeft.layout = "(shell 10 8 :floors 1)\n"
"(floor 0 (door front center))\n"
"(parking 0 0 12 12)\n";
e.lotRight.layout = e.lotLeft.layout;
}
/* Terraced ground: flat -0.5 near the roads (so every pad
* candidate sees the road > 0.35 above its lot and no
* terrain pokes through the platform), rising steeply
* beyond 20 m so the house floors sit ABOVE the parking
* surface (the low-platform rule lets the frustum tuck
* against the house). */
RoadNetworkGen::HeightSampler ground = [](float x, float z) {
float dx = std::min(
std::min(std::fabs(x), std::fabs(x - 100.0f)),
std::fabs(x - 200.0f));
float dz = std::min(
std::min(std::fabs(z), std::fabs(z - 100.0f)),
std::fabs(z - 200.0f));
float d = std::min(dx, dz);
return -0.5f + 0.2f * std::max(0.0f, d - 13.6f);
};
TownGen::TownParams tp;
tp.district = TownGen::District::Village;
tp.seed = 11;
TownGen::TownResult town = TownGen::generateTown(graph, tp, ground);
if (town.buildings.size() != 12) {
printf(" *** setup: %zu buildings, want 12 ***\n",
town.buildings.size());
return false;
}
size_t parkingPois = 0;
for (const TownGen::TownPoi &poi : town.pois)
if (poi.kind == TownGen::TownPoi::Parking &&
poi.name.find("-ovf") == std::string::npos)
++parkingPois;
if (parkingPois != 48) {
printf(" *** %zu parking POIs, want 48 (4 x 12) ***\n",
parkingPois);
ok = false;
}
for (size_t bi = 0; bi < town.buildings.size(); ++bi) {
const TownGen::TownBuilding &b = town.buildings[bi];
std::vector<const TownGen::TownPoi *> slots;
for (const TownGen::TownPoi &poi : town.pois)
if (poi.kind == TownGen::TownPoi::Parking &&
poi.building == (int)bi &&
poi.name.find("-ovf") == std::string::npos)
slots.push_back(&poi);
if (slots.size() != 4) {
printf(" *** building %zu: %zu slots, want 4 "
"***\n", bi, slots.size());
ok = false;
continue;
}
/* Names: "<address> parking-s1".."-s4" (single rect ->
* no per-rect index suffix), headings = the building's
* yaw (nose to the street). */
for (size_t k = 0; k < 4; ++k) {
std::string want = b.address + " parking-s" +
std::to_string(k + 1);
if (slots[k]->name != want) {
printf(" *** slot name '%s', want "
"'%s' ***\n", slots[k]->name.c_str(),
want.c_str());
ok = false;
}
if (!slots[k]->hasHeading ||
std::fabs(slots[k]->headingYaw - b.yaw) >
1e-6f) {
printf(" *** slot '%s' heading "
"missing/mismatched ***\n",
slots[k]->name.c_str());
ok = false;
}
}
/* Geometry: one row — equal depth along the inward
* axis, 2.6 m spacing along the frontage axis. */
Ogre::Vector3 fwd(std::sin(b.yaw), 0.0f, std::cos(b.yaw));
Ogre::Vector3 uAxis(fwd.z, 0.0f, -fwd.x); /* local +X */
Ogre::Vector3 inward = -fwd;
float sv0 = 0.0f;
std::vector<float> sus;
for (size_t k = 0; k < 4; ++k) {
Ogre::Vector3 d = slots[k]->position - b.center;
d.y = 0.0f;
float sv = d.dotProduct(inward);
if (k == 0)
sv0 = sv;
else if (std::fabs(sv - sv0) > 1e-3f) {
printf(" *** building %zu slot %zu "
"row depth drift %.4f ***\n",
bi, k, sv - sv0);
ok = false;
}
sus.push_back(d.dotProduct(uAxis));
}
std::sort(sus.begin(), sus.end());
for (size_t k = 1; k < sus.size(); ++k)
if (std::fabs(sus[k] - sus[k - 1] - 2.6f) > 1e-3f) {
printf(" *** building %zu slot spacing "
"%.4f != 2.6 ***\n", bi,
sus[k] - sus[k - 1]);
ok = false;
}
}
/* 6.6.6: one pad record per parking rect (12 buildings x 1),
* in the building's yaw frame; every surviving slot POI must
* sit inside its pad's rect. */
if (town.pads.size() != 12) {
printf(" *** %zu pads, want 12 ***\n", town.pads.size());
ok = false;
}
for (size_t pi = 0; pi < town.pads.size(); ++pi) {
const TownGen::TownResult::Pad &pad = town.pads[pi];
if (pad.building < 0 ||
pad.building >= (int)town.buildings.size()) {
printf(" *** pad %zu: bad building index %d ***\n",
pi, pad.building);
ok = false;
continue;
}
const TownGen::TownBuilding &pb =
town.buildings[pad.building];
if (pad.address != pb.address ||
std::fabs(pad.yaw - pb.yaw) > 1e-6f ||
std::fabs(pad.w - 12.0f) > 1e-4f ||
std::fabs(pad.d - 12.0f) > 1e-4f) {
printf(" *** pad %zu: meta mismatch "
"(addr/yaw/size) ***\n", pi);
ok = false;
}
/* The pad's frontage edge pin (6.6.6) must be a real
* edge — the terrain pad compliance projects onto it. */
if (pad.edge < 0 || pad.edge >= (int)graph.edges.size()) {
printf(" *** pad %zu: bad edge pin %d ***\n",
pi, pad.edge);
ok = false;
}
/* Pad frame (TownGen convention): u = local +X along
* the frontage, v into the lot, origin at the
* front-left corner. */
Ogre::Vector3 pu(std::cos(pad.yaw), 0.0f,
-std::sin(pad.yaw));
Ogre::Vector3 pv(-std::sin(pad.yaw), 0.0f,
-std::cos(pad.yaw));
Ogre::Vector3 po = pad.center - pu * (pad.w * 0.5f) -
pv * (pad.d * 0.5f);
for (const TownGen::TownPoi &poi : town.pois) {
if (poi.kind != TownGen::TownPoi::Parking ||
poi.building != pad.building)
continue;
Ogre::Vector3 rel = poi.position - po;
rel.y = 0.0f;
float ps = rel.dotProduct(pu);
float pt = rel.dotProduct(pv);
if (ps < -1e-3f || ps > pad.w + 1e-3f ||
pt < -1e-3f || pt > pad.d + 1e-3f) {
printf(" *** slot '%s' outside its pad "
"(s=%.3f t=%.3f) ***\n",
poi.name.c_str(), ps, pt);
ok = false;
}
}
/* The terraced sampler puts every pad on a raised
* frustum whose top sits above its ground sample. */
if (!pad.raised || pad.topY <= pad.center.y ||
pad.drop <= 0.0f) {
printf(" *** pad %zu: not a raised frustum "
"(raised %d topY %.3f ground %.3f) ***\n",
pi, (int)pad.raised, pad.topY, pad.center.y);
ok = false;
}
}
/* Determinism: identical params -> identical POI list. */
TownGen::TownResult town2 = TownGen::generateTown(graph, tp, ground);
if (town2.pois.size() != town.pois.size()) {
printf(" *** determinism: poi count mismatch ***\n");
ok = false;
} else {
for (size_t i = 0; i < town.pois.size(); ++i) {
const TownGen::TownPoi &x = town.pois[i];
const TownGen::TownPoi &y = town2.pois[i];
if (x.name != y.name || x.position != y.position ||
x.kind != y.kind ||
x.hasHeading != y.hasHeading ||
x.headingYaw != y.headingYaw) {
printf(" *** determinism: poi %zu "
"differs ***\n", i);
ok = false;
break;
}
}
}
if (town2.pads.size() != town.pads.size()) {
printf(" *** determinism: pad count mismatch ***\n");
ok = false;
} else {
for (size_t i = 0; i < town.pads.size(); ++i) {
const TownGen::TownResult::Pad &px = town.pads[i];
const TownGen::TownResult::Pad &py = town2.pads[i];
if (px.building != py.building ||
px.address != py.address ||
px.edge != py.edge ||
px.center != py.center || px.yaw != py.yaw ||
px.w != py.w || px.d != py.d) {
printf(" *** determinism: pad %zu "
"differs ***\n", i);
ok = false;
break;
}
}
}
/* 6.6.2 integration: a rect spanning the whole frontage loses
* the bays blocking the door corridor, deterministically. */
for (RoadEdge &e : graph.edges) {
e.lotLeft.layout = "(shell 10 8 :floors 1)\n"
"(floor 0 (door front center))\n"
"(parking 0 0 lot-w 12)\n";
e.lotRight.layout = e.lotLeft.layout;
}
TownGen::TownResult wide = TownGen::generateTown(graph, tp, ground);
TownGen::TownResult wide2 = TownGen::generateTown(graph, tp, ground);
if (wide.buildings.size() != 12 ||
wide.pois.size() != wide2.pois.size()) {
printf(" *** wide-rect setup/determinism failed ***\n");
ok = false;
} else if (wide.pads.size() != 12) {
printf(" *** wide rect: %zu pads, want 12 ***\n",
wide.pads.size());
ok = false;
} else {
bool warned = false;
for (const std::string &w : wide.warnings)
if (w.find("door walk corridor") !=
std::string::npos)
warned = true;
if (!warned) {
printf(" *** wide rect: no corridor drop "
"warning ***\n");
ok = false;
}
for (size_t bi = 0; bi < wide.buildings.size(); ++bi) {
const TownGen::TownBuilding &b =
wide.buildings[bi];
std::vector<const TownGen::TownPoi *> slots;
for (const TownGen::TownPoi &poi : wide.pois)
if (poi.kind == TownGen::TownPoi::Parking &&
poi.building == (int)bi)
slots.push_back(&poi);
/* 12 enumerated per building, the corridor +
* doorstep square (x-window 3.0 over bays pitched
* 2.6) always drops 1..3. */
if (slots.size() < 9 || slots.size() > 11) {
printf(" *** wide rect building %zu: "
"%zu slots, want 9..11 ***\n",
bi, slots.size());
ok = false;
}
/* No survivor may touch the doorstep square
* (prune margins: 1.5 + half bay 1.3 / 2.6). */
Ogre::Vector3 fwd(std::sin(b.yaw), 0.0f,
std::cos(b.yaw));
Ogre::Vector3 uAxis(fwd.z, 0.0f, -fwd.x);
for (const TownGen::TownPoi *sp : slots) {
Ogre::Vector3 d = sp->position - b.doorstep;
d.y = 0.0f;
float du = std::fabs(d.dotProduct(uAxis));
float dv = std::fabs(d.dotProduct(fwd));
if (du < 2.7f && dv < 4.0f) {
printf(" *** '%s' survives inside "
"the doorstep square ***\n",
sp->name.c_str());
ok = false;
}
}
}
}
/* 6.6.6 (final): at-grade lots get NO frustum and no
* perpendicular bays — the address parks PARALLEL at the
* roadside. Reuse the 12-wide rect town on flat ground:
* two curb-line spots per building, nose along the edge,
* chained 5.8 m apart, deterministic. */
for (RoadEdge &e : graph.edges) {
e.lotLeft.layout = "(shell 10 8 :floors 1)\n"
"(floor 0 (door front center))\n"
"(parking 0 0 12 12)\n";
e.lotRight.layout = e.lotLeft.layout;
}
RoadNetworkGen::HeightSampler flat = [](float, float) {
return 0.0f;
};
TownGen::TownResult atg = TownGen::generateTown(graph, tp, flat);
if (atg.buildings.size() != 12) {
printf(" *** at-grade setup: %zu buildings ***\n",
atg.buildings.size());
ok = false;
} else {
for (size_t pi = 0; pi < atg.pads.size(); ++pi)
if (atg.pads[pi].raised) {
printf(" *** at-grade pad %zu is raised "
"***\n", pi);
ok = false;
}
for (size_t bi = 0; bi < atg.buildings.size(); ++bi) {
const TownGen::TownBuilding &b =
atg.buildings[bi];
std::vector<const TownGen::TownPoi *> spots;
for (const TownGen::TownPoi &poi : atg.pois)
if (poi.kind == TownGen::TownPoi::Parking &&
poi.building == (int)bi &&
poi.name.find("-ovf") ==
std::string::npos)
spots.push_back(&poi);
if (spots.size() != 1) {
printf(" *** at-grade building %zu: "
"%zu spots, want 1 ***\n", bi,
spots.size());
ok = false;
continue;
}
Ogre::Vector3 fwd(std::sin(b.yaw), 0.0f,
std::cos(b.yaw));
for (size_t k = 0; k < 1; ++k) {
std::string want = b.address + " parking-s" +
std::to_string(k + 1);
if (spots[k]->name != want) {
printf(" *** at-grade slot '%s', "
"want '%s' ***\n",
spots[k]->name.c_str(),
want.c_str());
ok = false;
}
if (!spots[k]->hasHeading) {
printf(" *** at-grade '%s': no "
"heading ***\n",
spots[k]->name.c_str());
ok = false;
continue;
}
/* Nose ALONG the street: perpendicular
* to the building's street-facing axis. */
Ogre::Vector3 nose(
std::sin(spots[k]->headingYaw), 0.0f,
std::cos(spots[k]->headingYaw));
if (std::fabs(nose.dotProduct(fwd)) > 0.1f) {
printf(" *** at-grade '%s': not "
"parallel to the road ***\n",
spots[k]->name.c_str());
ok = false;
}
/* On the curb line, clear of the outer
* lane (halfW + 1.2 from the centerline). */
Ogre::Vector3 proj, dir;
float halfW = 0.0f;
if (RoadPath::nearestEdgeInfo(
graph, spots[k]->position, proj,
dir, halfW)) {
Ogre::Vector3 lat =
spots[k]->position - proj;
lat.y = 0.0f;
if (std::fabs(lat.length() -
(halfW + 1.2f)) > 0.05f) {
printf(" *** at-grade '%s': "
"off the curb line "
"(%.3f vs %.3f) ***\n",
spots[k]->name.c_str(),
lat.length(), halfW + 1.2f);
ok = false;
}
}
}
}
}
printf(" %s (%zu parking POIs over %zu buildings)\n",
ok ? "passed" : "FAILURES above", parkingPois,
town.buildings.size());
return ok;
}
static bool runTownScheduleProbe()
{
printf("town schedule probe\n");
@@ -8249,6 +8922,7 @@ int main(int argc, char **argv)
ok &= runRoadPathProbe();
ok &= runTownPopulationProbe();
ok &= runTownLocationsProbe();
ok &= runTownParkingProbe();
ok &= runTownScheduleProbe();
ok &= runLaneTransitionTaper();
ok &= runApronSkirt();
+4 -1
View File
@@ -770,7 +770,10 @@ private:
"cell row (default above the top floor; 1 roofs a "
"lower wing under a smaller upper floor)\n"
" (parking x z w d) or (parking (rect-front-left "
"w d)) or (rect-front-right w d)\n"
"w d)) or (rect-front-right w d) lot-frame pad, "
"enumerated into bay slots (nose to the street); "
"slots blocking the door->road walk corridor or the "
"doorstep are dropped (warning)\n"
" (place \"<prefab>\" (at x z) [:yaw deg]) "
"recorded only");
ImGui::Spacing();