diff --git a/src/features/editScene/AGENTS.md b/src/features/editScene/AGENTS.md index 77ab565..597995b 100644 --- a/src/features/editScene/AGENTS.md +++ b/src/features/editScene/AGENTS.md @@ -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 (`"
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 `"
parking [i]-s"` 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` 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 ("
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//roads/_.json` (`RoadRegionStore`, diff --git a/src/features/editScene/CMakeLists.txt b/src/features/editScene/CMakeLists.txt index b3a763e..67a9f17 100644 --- a/src/features/editScene/CMakeLists.txt +++ b/src/features/editScene/CMakeLists.txt @@ -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 diff --git a/src/features/editScene/EditorApp.cpp b/src/features/editScene/EditorApp.cpp index 305dea8..d96b56f 100644 --- a/src/features/editScene/EditorApp.cpp +++ b/src/features/editScene/EditorApp.cpp @@ -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 diff --git a/src/features/editScene/TerrainDoc.md b/src/features/editScene/TerrainDoc.md index 2178ee2..10d24b0 100644 --- a/src/features/editScene/TerrainDoc.md +++ b/src/features/editScene/TerrainDoc.md @@ -74,6 +74,9 @@ Sculpting never touches the base layer: `setHeightAt`/brushes write into the spa per-page **fixup chunk** store (`heightmaps//terrain_fixup/x{cX}_z{cZ}.bin`), which is lazily disk-loaded, LRU-capped, and saved on eviction when dirty. Blend maps and aux maps are likewise per-page files under `heightmaps//`. +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 diff --git a/src/features/editScene/demos/demo-taxi/PLAN.md b/src/features/editScene/demos/demo-taxi/PLAN.md index 2391d6d..8136cf8 100644 --- a/src/features/editScene/demos/demo-taxi/PLAN.md +++ b/src/features/editScene/demos/demo-taxi/PLAN.md @@ -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, `"
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 `"
parking + -s"` 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 `"
parking-s"` + (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
  • strip () parking rect : 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 ("`
    ` 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 diff --git a/src/features/editScene/demos/demo-taxi/demo_main.cpp b/src/features/editScene/demos/demo-taxi/demo_main.cpp index 12c6499..a4761de 100644 --- a/src/features/editScene/demos/demo-taxi/demo_main.cpp +++ b/src/features/editScene/demos/demo-taxi/demo_main.cpp @@ -2,6 +2,7 @@ #include #include #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 #include #include +#include +#include +#include #include #include #include @@ -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>> 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 > 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()) + return; + TransformComponent &t = player.get_mut(); + 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()) + return nullptr; + return &terrain.get().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 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() + .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: " diff --git a/src/features/editScene/demos/demo-taxi/town_layouts.txt b/src/features/editScene/demos/demo-taxi/town_layouts.txt index b3eba8a..9fb8261 100644 --- a/src/features/editScene/demos/demo-taxi/town_layouts.txt +++ b/src/features/editScene/demos/demo-taxi/town_layouts.txt @@ -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)))) diff --git a/src/features/editScene/layoutlib/LayoutEval.cpp b/src/features/editScene/layoutlib/LayoutEval.cpp index a767cf5..b67b045 100644 --- a/src/features/editScene/layoutlib/LayoutEval.cpp +++ b/src/features/editScene/layoutlib/LayoutEval.cpp @@ -1801,7 +1801,8 @@ bool LayoutEval::verbRoof(const std::vector &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 + "'"); diff --git a/src/features/editScene/roadlib/RoadPath.cpp b/src/features/editScene/roadlib/RoadPath.cpp index 86d9025..61856af 100644 --- a/src/features/editScene/roadlib/RoadPath.cpp +++ b/src/features/editScene/roadlib/RoadPath.cpp @@ -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::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 */ diff --git a/src/features/editScene/roadlib/RoadPath.hpp b/src/features/editScene/roadlib/RoadPath.hpp index 4979d39..82621c0 100644 --- a/src/features/editScene/roadlib/RoadPath.hpp +++ b/src/features/editScene/roadlib/RoadPath.hpp @@ -3,6 +3,7 @@ #include #include +#include #include "../components/RoadGraph.hpp" @@ -75,15 +76,53 @@ float polylineLength(const std::vector &path); Ogre::Vector3 polylineAt(const std::vector &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 */ diff --git a/src/features/editScene/roadlib/TownGen.cpp b/src/features/editScene/roadlib/TownGen.cpp index 0dd63a7..2bfb88b 100644 --- a/src/features/editScene/roadlib/TownGen.cpp +++ b/src/features/editScene/roadlib/TownGen.cpp @@ -1,9 +1,12 @@ #include "TownGen.hpp" +#include "TownParking.hpp" +#include "RoadPath.hpp" #include "../layoutlib/LayoutEval.hpp" #include #include +#include #include #include @@ -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 ¶ms, 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 ¶ms, result.pois.push_back(poi); /* Parking pads (layout DSL lot features) become - * extra POIs: strip-frame rect center -> world, - * "
    parking [i]". */ + * one POI per enumerated bay slot (phase + * 6.6.1): strip-frame rect -> world, + * "
    parking [i]-s", 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 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); + } } } } diff --git a/src/features/editScene/roadlib/TownGen.hpp b/src/features/editScene/roadlib/TownGen.hpp index d142c64..9793865 100644 --- a/src/features/editScene/roadlib/TownGen.hpp +++ b/src/features/editScene/roadlib/TownGen.hpp @@ -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 buildings; std::vector 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 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 "
    - * parking" POIs. + * on TownBuilding::plan; parking rects become per-slot "
    + * parking [i]-s" 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 diff --git a/src/features/editScene/roadlib/TownLocations.cpp b/src/features/editScene/roadlib/TownLocations.cpp index 14d2a51..a71e041 100644 --- a/src/features/editScene/roadlib/TownLocations.cpp +++ b/src/features/editScene/roadlib/TownLocations.cpp @@ -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; diff --git a/src/features/editScene/roadlib/TownLocations.hpp b/src/features/editScene/roadlib/TownLocations.hpp index 1904dcb..0b1788c 100644 --- a/src/features/editScene/roadlib/TownLocations.hpp +++ b/src/features/editScene/roadlib/TownLocations.hpp @@ -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 { diff --git a/src/features/editScene/roadlib/TownParking.cpp b/src/features/editScene/roadlib/TownParking.cpp new file mode 100644 index 0000000..6a15988 --- /dev/null +++ b/src/features/editScene/roadlib/TownParking.cpp @@ -0,0 +1,85 @@ +#include "TownParking.hpp" + +#include +#include + +namespace TownParking { + +std::vector enumerateSlots(float rectW, float rectD, + const Config &cfg) +{ + std::vector 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 pruneSlots(std::vector slots, + const WalkCorridor &corridor, + int *dropped) +{ + std::vector 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 */ diff --git a/src/features/editScene/roadlib/TownParking.hpp b/src/features/editScene/roadlib/TownParking.hpp new file mode 100644 index 0000000..c2c54f2 --- /dev/null +++ b/src/features/editScene/roadlib/TownParking.hpp @@ -0,0 +1,80 @@ +#pragma once + +#include + +/** + * 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 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 pruneSlots(std::vector slots, + const WalkCorridor &corridor, + int *dropped = nullptr); + +} /* namespace TownParking */ diff --git a/src/features/editScene/systems/AiDriverSystem.cpp b/src/features/editScene/systems/AiDriverSystem.cpp index 90da74c..917dd3a 100644 --- a/src/features/editScene/systems/AiDriverSystem.cpp +++ b/src/features/editScene/systems/AiDriverSystem.cpp @@ -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): diff --git a/src/features/editScene/systems/AiDriverSystem.hpp b/src/features/editScene/systems/AiDriverSystem.hpp index 670c22f..d45daff 100644 --- a/src/features/editScene/systems/AiDriverSystem.hpp +++ b/src/features/editScene/systems/AiDriverSystem.hpp @@ -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 m_states; diff --git a/src/features/editScene/systems/NpcScheduleDebugOverlay.cpp b/src/features/editScene/systems/NpcScheduleDebugOverlay.cpp index d78068f..7e7d48a 100644 --- a/src/features/editScene/systems/NpcScheduleDebugOverlay.cpp +++ b/src/features/editScene/systems/NpcScheduleDebugOverlay.cpp @@ -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(), diff --git a/src/features/editScene/systems/NpcTrafficSystem.cpp b/src/features/editScene/systems/NpcTrafficSystem.cpp index 85e612d..b95459f 100644 --- a/src/features/editScene/systems/NpcTrafficSystem.cpp +++ b/src/features/editScene/systems/NpcTrafficSystem.cpp @@ -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 &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 cand = - locs.indicesOfKind(TownLocations::LocationKind::Parking); - std::vector 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 bays; + std::vector 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 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 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> visitor; + std::map> 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 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(ai); car.vehicle.get_mut().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() && - car.vehicle.get().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()) { + Ogre::Vector3 pos; + if (carWorldPosition(car.driverId, pos)) { + auto &ai = car.vehicle.get_mut(); + /* 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() + .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()) + 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 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(ai); + car.vehicle.get_mut().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> byAddress; + std::vector addressOrder; for (int li : parkingIdx) { + const std::string &addr = locs->locations[li].address; + if (byAddress.emplace(addr, std::vector()).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 &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; diff --git a/src/features/editScene/systems/NpcTrafficSystem.hpp b/src/features/editScene/systems/NpcTrafficSystem.hpp index e086b0c..50afc97 100644 --- a/src/features/editScene/systems/NpcTrafficSystem.hpp +++ b/src/features/editScene/systems/NpcTrafficSystem.hpp @@ -3,6 +3,7 @@ #pragma once #include +#include #include #include #include @@ -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:") — * 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 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 m_cars; /* key: driver registry id */ std::set m_seededAreas; + /** Slot claims (6.6.3): claimKey -> driver registry id. A + * per-frame pure snapshot rebuilt by rebuildClaims() — never + * mutated incrementally. */ + std::map m_slotClaims; /* Leg route cache (clock-independent geometry), flushed whole on * a graph version change or overflow. */ std::map m_legRoutes; diff --git a/src/features/editScene/systems/PhysicsSystem.cpp b/src/features/editScene/systems/PhysicsSystem.cpp index 42a80d4..72a7e55 100644 --- a/src/features/editScene/systems/PhysicsSystem.cpp +++ b/src/features/editScene/systems/PhysicsSystem.cpp @@ -1,5 +1,6 @@ #include "PhysicsSystem.hpp" #include "../components/Transform.hpp" +#include "../components/TriangleBuffer.hpp" #include #include @@ -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() || + !e.has()) + return; + const auto &c = e.get(); + const auto &tb = e.get(); + 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) diff --git a/src/features/editScene/systems/RoadSystem.cpp b/src/features/editScene/systems/RoadSystem.cpp index 05fa665..78ae136 100644 --- a/src/features/editScene/systems/RoadSystem.cpp +++ b/src/features/editScene/systems/RoadSystem.cpp @@ -15,6 +15,7 @@ #include "../roadlib/RoadGeometryLib.hpp" #include "../roadlib/RoadNetworkGen.hpp" #include "PrefabSystem.hpp" +#include "TownGenSystem.hpp" #include #include #include @@ -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(pm); return m_materialEntity; diff --git a/src/features/editScene/systems/TerrainSystem.cpp b/src/features/editScene/systems/TerrainSystem.cpp index 5fd4ad2..634612d 100644 --- a/src/features/editScene/systems/TerrainSystem.cpp +++ b/src/features/editScene/systems/TerrainSystem.cpp @@ -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 lock(m_heightmapMutex); + return sampleFixupLocked(worldX, worldZ); +} + float TerrainSystem::sampleBaseLocked(long worldX, long worldZ) const { float h; diff --git a/src/features/editScene/systems/TerrainSystem.hpp b/src/features/editScene/systems/TerrainSystem.hpp index 140a7b0..5ced95b 100644 --- a/src/features/editScene/systems/TerrainSystem.hpp +++ b/src/features/editScene/systems/TerrainSystem.hpp @@ -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); diff --git a/src/features/editScene/systems/TerrainTests.cpp b/src/features/editScene/systems/TerrainTests.cpp index 5f770fb..ccf6221 100644 --- a/src/features/editScene/systems/TerrainTests.cpp +++ b/src/features/editScene/systems/TerrainTests.cpp @@ -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 #include @@ -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//, 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(); + 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(); + 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 samples; + std::vector 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() + .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() || + child.get().name != + "parking-pad") + return; + padEnt = child; + if (child.has() && + child.get() + .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() + .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) */ /* ------------------------------------------------------------------ */ diff --git a/src/features/editScene/systems/TerrainTests.hpp b/src/features/editScene/systems/TerrainTests.hpp index b23fb45..0504fa5 100644 --- a/src/features/editScene/systems/TerrainTests.hpp +++ b/src/features/editScene/systems/TerrainTests.hpp @@ -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); diff --git a/src/features/editScene/systems/TownGenSystem.cpp b/src/features/editScene/systems/TownGenSystem.cpp index 3735de1..2a47f1f 100644 --- a/src/features/editScene/systems/TownGenSystem.cpp +++ b/src/features/editScene/systems/TownGenSystem.cpp @@ -19,6 +19,7 @@ #include "../components/RoadGraph.hpp" #include "../physics/physics.h" #include "../roadlib/RoadNetworkGen.hpp" +#include "../roadlib/RoadPath.hpp" #include @@ -347,7 +348,9 @@ bool TownGenSystem::previewLayout(const std::string &layoutSource, b.stairs = false; std::vector noPois; - createBuilding(container, material, texture, b, noPois, genMode); + std::vector 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 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()) @@ -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 pois; for (const TownGen::TownPoi &poi : result.pois) if (poi.building == (int)i) pois.push_back(&poi); + std::vector 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(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 &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(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(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("parking-pad")); + padEnt.add(); + + /* 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(); + Ogre::Vector3 nodeWorld(topCenter.x, pad.topY, topCenter.z); + Ogre::Vector3 nodeLocal = + bt.rotation.Inverse() * (nodeWorld - bt.position); + TransformComponent padXform; + padXform.node = building.get() + .node->createChildSceneNode(); + padXform.position = nodeLocal; + padXform.rotation = Ogre::Quaternion::IDENTITY; + padXform.scale = Ogre::Vector3::UNIT_SCALE; + padXform.applyToNode(); + padEnt.set(padXform); + + TriangleBufferComponent tb; + tb.buffer = std::make_shared(buf); + tb.proceduralContent = true; + tb.textureEntity = texture; + tb.textureRectName = "parking"; + tb.materialEntity = material; + tb.meshName = pad.address + "_parking-pad"; + tb.dirty = true; + padEnt.set(tb); + + RigidBodyComponent rb; + rb.bodyType = RigidBodyComponent::BodyType::Static; + padEnt.set(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(col); +} \ No newline at end of file diff --git a/src/features/editScene/systems/TownGenSystem.hpp b/src/features/editScene/systems/TownGenSystem.hpp index d310c62..5bc2db6 100644 --- a/src/features/editScene/systems/TownGenSystem.hpp +++ b/src/features/editScene/systems/TownGenSystem.hpp @@ -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 &pois, - const std::string &genMode = "exteriorOnly"); + const std::string &genMode = "exteriorOnly", + const std::vector + &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, diff --git a/src/features/editScene/tests/road_geometry_overlap_test.cpp b/src/features/editScene/tests/road_geometry_overlap_test.cpp index 3658931..4c266d3 100644 --- a/src/features/editScene/tests/road_geometry_overlap_test.cpp +++ b/src/features/editScene/tests/road_geometry_overlap_test.cpp @@ -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 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 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 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 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 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 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: "
    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 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 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 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(); diff --git a/src/features/editScene/ui/TerrainEditor.hpp b/src/features/editScene/ui/TerrainEditor.hpp index bef761c..80d689c 100644 --- a/src/features/editScene/ui/TerrainEditor.hpp +++ b/src/features/editScene/ui/TerrainEditor.hpp @@ -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 \"\" (at x z) [:yaw deg]) " "recorded only"); ImGui::Spacing();