@jgengine/core 0.14.0 → 0.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +500 -1
- package/dist/ai/difficulty.d.ts +154 -0
- package/dist/ai/difficulty.js +194 -0
- package/dist/ai/driver.d.ts +107 -0
- package/dist/ai/driver.js +175 -0
- package/dist/ai/flock.js +13 -23
- package/dist/ai/laneSelect.js +2 -1
- package/dist/ai/mobBrain.js +2 -1
- package/dist/ai/waveRunner.d.ts +81 -0
- package/dist/ai/waveRunner.js +104 -0
- package/dist/audio/musicState.d.ts +47 -0
- package/dist/audio/musicState.js +38 -0
- package/dist/audio/stepCadence.d.ts +47 -0
- package/dist/audio/stepCadence.js +29 -0
- package/dist/audio/synth.d.ts +12 -0
- package/dist/authoring.d.ts +6 -4
- package/dist/authoring.js +6 -4
- package/dist/combat/castRunner.d.ts +13 -1
- package/dist/combat/castRunner.js +5 -1
- package/dist/combat/comboMeter.d.ts +140 -0
- package/dist/combat/comboMeter.js +215 -0
- package/dist/combat/comboPoints.d.ts +11 -0
- package/dist/combat/comboPoints.js +11 -2
- package/dist/combat/damageResolution.d.ts +1 -1
- package/dist/combat/defensiveWindow.d.ts +5 -0
- package/dist/combat/effects.d.ts +24 -0
- package/dist/combat/effects.js +6 -1
- package/dist/combat/projectiles.d.ts +6 -0
- package/dist/combat/projectiles.js +2 -2
- package/dist/combat/regenShield.d.ts +40 -2
- package/dist/combat/regenShield.js +66 -18
- package/dist/combat/resourcePool.d.ts +19 -0
- package/dist/combat/resourcePool.js +20 -2
- package/dist/combat/shotOrigin.d.ts +5 -0
- package/dist/combat/statusApplication.d.ts +1 -1
- package/dist/combat/statusApplication.js +2 -1
- package/dist/combat/statusEffectView.d.ts +56 -0
- package/dist/combat/statusEffectView.js +52 -0
- package/dist/combat.d.ts +4 -2
- package/dist/combat.js +2 -0
- package/dist/devtools/fallbackSeams.d.ts +3 -2
- package/dist/devtools/fallbackSeams.js +1 -1
- package/dist/devtools/textureErrors.d.ts +40 -0
- package/dist/devtools/textureErrors.js +68 -0
- package/dist/economy/shopStock.d.ts +110 -0
- package/dist/economy/shopStock.js +157 -0
- package/dist/economy/wallet.d.ts +6 -0
- package/dist/economy/wallet.js +1 -0
- package/dist/game/achievements.d.ts +80 -0
- package/dist/game/achievements.js +124 -0
- package/dist/game/cameraConfig.d.ts +7 -0
- package/dist/game/codex.d.ts +60 -0
- package/dist/game/codex.js +89 -0
- package/dist/game/credits.d.ts +36 -0
- package/dist/game/credits.js +0 -0
- package/dist/game/defineGame.d.ts +28 -3
- package/dist/game/defineGame.js +5 -0
- package/dist/game/dialogueGraph.d.ts +116 -0
- package/dist/game/dialogueGraph.js +103 -0
- package/dist/game/eventTicker.d.ts +95 -0
- package/dist/game/eventTicker.js +91 -0
- package/dist/game/events.d.ts +24 -5
- package/dist/game/events.js +18 -0
- package/dist/game/gamePhase.d.ts +1 -1
- package/dist/game/gamePhase.js +1 -1
- package/dist/game/leaderboardRank.d.ts +71 -0
- package/dist/game/leaderboardRank.js +76 -0
- package/dist/game/lootTable.d.ts +10 -1
- package/dist/game/lootTable.js +4 -2
- package/dist/game/notifications.d.ts +57 -0
- package/dist/game/notifications.js +107 -0
- package/dist/game/playableGame.d.ts +39 -2
- package/dist/game/quest.d.ts +26 -0
- package/dist/game/quest.js +31 -0
- package/dist/game/runDraft.js +2 -1
- package/dist/game/saveSlots.d.ts +102 -0
- package/dist/game/saveSlots.js +136 -0
- package/dist/game/talentTreeView.d.ts +106 -0
- package/dist/game/talentTreeView.js +114 -0
- package/dist/game/worldItem.js +2 -1
- package/dist/gameplay.d.ts +15 -4
- package/dist/gameplay.js +13 -2
- package/dist/i18n/i18n.d.ts +51 -0
- package/dist/i18n/i18n.js +82 -0
- package/dist/input/axisInput.d.ts +1 -1
- package/dist/input/axisInput.js +18 -2
- package/dist/input/rebindSession.d.ts +141 -0
- package/dist/input/rebindSession.js +235 -0
- package/dist/input/touchControlsMode.d.ts +16 -0
- package/dist/input/touchControlsMode.js +23 -0
- package/dist/input/touchScheme.d.ts +30 -1
- package/dist/input/touchScheme.js +31 -1
- package/dist/interaction/promptRegistry.d.ts +78 -0
- package/dist/interaction/promptRegistry.js +130 -0
- package/dist/inventory/storageTier.js +3 -2
- package/dist/meta/changelog.d.ts +1 -1
- package/dist/meta/changelog.js +145 -1
- package/dist/movement/movementModel.d.ts +26 -3
- package/dist/movement/movementModel.js +121 -15
- package/dist/movement/playerMovement.js +161 -25
- package/dist/physics/drivableVehicle.js +3 -1
- package/dist/physics/kinematicVehicle.d.ts +56 -1
- package/dist/physics/kinematicVehicle.js +99 -10
- package/dist/physics/vehicleObstacles.js +19 -0
- package/dist/random/resolveRng.d.ts +14 -0
- package/dist/random/resolveRng.js +31 -0
- package/dist/render/postProcessing.d.ts +12 -1
- package/dist/runtime/cameraDirector.d.ts +2 -2
- package/dist/runtime/context/combat.js +8 -0
- package/dist/runtime/gameContext.js +6 -1
- package/dist/runtime/gameContextTypes.d.ts +18 -0
- package/dist/runtime/inputSnapshot.d.ts +10 -1
- package/dist/runtime/inputSnapshot.js +13 -1
- package/dist/runtime/runtimeSave.d.ts +3 -3
- package/dist/runtime/runtimeSave.js +11 -2
- package/dist/scene/assetDiagnostics.js +2 -2
- package/dist/scene/autoTarget.js +2 -1
- package/dist/scene/captureCheck.js +3 -2
- package/dist/scene/entityStore.d.ts +4 -1
- package/dist/scene/entityStore.js +3 -0
- package/dist/scene/objectStore.d.ts +10 -0
- package/dist/scene/objectStore.js +29 -6
- package/dist/scene/sceneKinds.d.ts +18 -0
- package/dist/scene/sceneKinds.js +19 -0
- package/dist/scene/sequenceDirector.d.ts +110 -0
- package/dist/scene/sequenceDirector.js +146 -0
- package/dist/session/roles.js +3 -2
- package/dist/settings/settingsModel.d.ts +1 -0
- package/dist/settings/settingsModel.js +1 -0
- package/dist/stats/rollCheck.js +4 -2
- package/dist/time/dayNightCycle.d.ts +114 -0
- package/dist/time/dayNightCycle.js +208 -0
- package/dist/time/timerSet.d.ts +132 -0
- package/dist/time/timerSet.js +243 -0
- package/dist/ui/accessibility.d.ts +51 -0
- package/dist/ui/accessibility.js +83 -0
- package/dist/ui/coachMarks.d.ts +99 -0
- package/dist/ui/coachMarks.js +126 -0
- package/dist/ui/floatingText.d.ts +114 -0
- package/dist/ui/floatingText.js +203 -0
- package/dist/ui/modalStack.d.ts +143 -0
- package/dist/ui/modalStack.js +160 -0
- package/dist/ui/objectiveBanner.d.ts +123 -0
- package/dist/ui/objectiveBanner.js +162 -0
- package/dist/ui/photoMode.d.ts +32 -0
- package/dist/ui/photoMode.js +51 -0
- package/dist/ui/radialMenu.d.ts +47 -0
- package/dist/ui/radialMenu.js +74 -0
- package/dist/ui/screenMarkers.d.ts +128 -0
- package/dist/ui/screenMarkers.js +118 -0
- package/dist/ui.d.ts +8 -0
- package/dist/ui.js +8 -0
- package/dist/vfx/cameraShake.d.ts +97 -0
- package/dist/vfx/cameraShake.js +136 -0
- package/dist/vfx/damageDirection.d.ts +102 -0
- package/dist/vfx/damageDirection.js +188 -0
- package/dist/vfx/particles.d.ts +103 -0
- package/dist/vfx/particles.js +256 -0
- package/dist/vfx/screenEffects.d.ts +149 -0
- package/dist/vfx/screenEffects.js +191 -0
- package/dist/world/authoredObjects.d.ts +18 -0
- package/dist/world/authoredObjects.js +21 -0
- package/dist/world/authoredSpawn.js +19 -0
- package/dist/world/buildingLots.d.ts +54 -11
- package/dist/world/buildingLots.js +116 -30
- package/dist/world/cityBlocks.d.ts +45 -0
- package/dist/world/cityBlocks.js +159 -0
- package/dist/world/cityContent.d.ts +46 -2
- package/dist/world/cityContent.js +160 -2
- package/dist/world/cityGenerator.d.ts +261 -23
- package/dist/world/cityGenerator.js +643 -33
- package/dist/world/cityGeometry.d.ts +20 -0
- package/dist/world/cityGeometry.js +91 -0
- package/dist/world/cityKind.js +1 -44
- package/dist/world/fastTravel.d.ts +64 -0
- package/dist/world/fastTravel.js +108 -0
- package/dist/world/features.d.ts +16 -0
- package/dist/world/features.js +2 -0
- package/dist/world/grounded.d.ts +11 -0
- package/dist/world/grounded.js +12 -0
- package/dist/world/mapAnnotations.d.ts +75 -0
- package/dist/world/mapAnnotations.js +139 -0
- package/dist/world/markers.js +1 -0
- package/dist/world/resourceNode.d.ts +1 -1
- package/dist/world/resourceNode.js +2 -1
- package/dist/world/roads.d.ts +294 -5
- package/dist/world/roads.js +1262 -21
- package/dist/world/sharedAnnotations.d.ts +84 -0
- package/dist/world/sharedAnnotations.js +76 -0
- package/dist/world/spawnOverride.d.ts +37 -0
- package/dist/world/spawnOverride.js +50 -0
- package/dist/world/streetGenerator.d.ts +60 -11
- package/dist/world/streetGenerator.js +1786 -129
- package/dist/world/terrain.d.ts +1 -0
- package/dist/world/terrain.js +2 -0
- package/dist/world/waypoints.d.ts +63 -0
- package/dist/world/waypoints.js +137 -0
- package/dist/world.d.ts +17 -2
- package/dist/world.js +17 -2
- package/package.json +1 -1
- package/skills/ce-handoff/SKILL.md +149 -0
- package/skills/game-design/SKILL.md +2 -0
- package/skills/jgengine/SKILL.md +3 -1
- package/skills/jgengine/api.md +17 -7
- package/skills/jgengine/capabilities.md +12 -0
- package/skills/jgengine/recipes/minimal-game.md +7 -4
- package/skills/jgengine-combat/api.md +40 -4
- package/skills/jgengine-combat/capabilities.md +12 -0
- package/skills/jgengine-combat/reference.md +2 -0
- package/skills/jgengine-gameplay/api.md +212 -11
- package/skills/jgengine-gameplay/capabilities.md +66 -1
- package/skills/jgengine-world/api.md +397 -11
- package/skills/jgengine-world/capabilities.md +123 -2
- package/skills/jgengine-world/reference.md +15 -1
|
@@ -7,14 +7,63 @@
|
|
|
7
7
|
* - `gridness` — 1 lays nodes on a regular lattice (Manhattan); 0 scatters them (organic hills).
|
|
8
8
|
* - `loopiness` — 0 grows a tree that dead-ends; 1 collapses to a single closed loop (a circuit).
|
|
9
9
|
* - `connectivity` — extra chord edges between neighbors → mesh density (a dense city grid).
|
|
10
|
-
* - `branching` — spur lanes forking off the mains, ending in cul-de-sacs
|
|
10
|
+
* - `branching` — spur lanes forking off the mains, ending in cul-de-sacs (and, in a circuit, the
|
|
11
|
+
* trigger for a real pit lane that leaves and rejoins the loop).
|
|
11
12
|
* - `deadEnds` — fraction of dangling ends KEPT as cul-de-sacs vs reconnected into loops.
|
|
12
|
-
* - `winding` — sideways wander amplitude of every edge, capped so curvature never exceeds
|
|
13
|
-
* `1 / minCurveRadius`;
|
|
14
|
-
*
|
|
13
|
+
* - `winding` — for a NET, sideways wander amplitude of every edge, capped so curvature never exceeds
|
|
14
|
+
* `1 / minCurveRadius`; for a CIRCUIT, how much the control polygon folds and how few deliberate
|
|
15
|
+
* straights it keeps (a windy lap curves nearly everywhere). `minTurnAngle`/`maxTurnAngle` are HARD
|
|
16
|
+
* clamps on the corner a NET street may take at any vertex — shallow wiggles are straightened, and every
|
|
17
|
+
* real corner is replaced with a sampled circular-arc fillet of radius `minCurveRadius` so corners read
|
|
18
|
+
* as CURVES, never single bevels, sampled so no discrete turn between consecutive samples exceeds ~9°.
|
|
19
|
+
* A circuit's loop skips that fillet entirely — its centerline is a smooth curvature-floored spline whose
|
|
20
|
+
* own dense sampling keeps every per-sample turn ≤ ~6°.
|
|
15
21
|
* - `bridges` / `tunnels` (with a ground sampler) — a span that dives under `minElevation` becomes a
|
|
16
22
|
* BRIDGE deck; a span buried under a ridge becomes a TUNNEL bore. Both are path FEATURES on a
|
|
17
23
|
* continuous edge, so a circuit stays closed as it crosses water or pierces a hill.
|
|
24
|
+
* - `sidewalkWidth` — pedestrian band paved on both edges of every boulevard/avenue/street (not lanes),
|
|
25
|
+
* emitted as proper parallel offset polylines on each {@link Street} — arced on the outside of a bend,
|
|
26
|
+
* welded on the inside so the band never pinches or dips inside the road surface.
|
|
27
|
+
* - `elevation` / `maxGrade` — a seeded smooth 2-D height FIELD draped over the whole network. `elevation`
|
|
28
|
+
* (0..1) dials the relief amplitude: 0 is dead flat and BYTE-IDENTICAL to a network authored before
|
|
29
|
+
* this field existed (no `heights`, no `elevationAt`); 1 ≈ 22 m of relief across a ~520 m volume,
|
|
30
|
+
* scaled to the footprint. The field is 2–3 octaves of sine/cos mixtures with wavelengths scaled to the
|
|
31
|
+
* volume, so it is smooth by construction (crests/dips, never cliffs). Every {@link Street} gains a
|
|
32
|
+
* per-point `heights[]` grade-LIMITED so no consecutive pair exceeds `maxGrade` (iterative relaxation;
|
|
33
|
+
* a circuit loop stays continuous — first height === last — and the cap holds across the start/finish
|
|
34
|
+
* seam); {@link StreetEdge}s carry raw (uncapped) field samples for continuity, and the field itself is
|
|
35
|
+
* exposed as {@link StreetNetwork.elevationAt} so renderers drape junction welds, sidewalks, and
|
|
36
|
+
* building bases off the SAME field for weld-continuous ground.
|
|
37
|
+
*
|
|
38
|
+
* Road elevation is WHOLLY SEPARATE from the `context.heightAt` TERRAIN sampler that drives
|
|
39
|
+
* bridges/tunnels: `elevationAt` shapes the road surface itself; `heightAt` is the world ground the
|
|
40
|
+
* road bridges over or bores through. They never interact — a flat-elevation network can still span
|
|
41
|
+
* bridges, and an elevated network's `heights[]` say nothing about where terrain gaps or ridges are.
|
|
42
|
+
*
|
|
43
|
+
* Hierarchy is STRUCTURAL, not a length percentile: an approximate edge-betweenness centrality over the
|
|
44
|
+
* grown graph elects a connected arterial skeleton (avenues/boulevards) that never dead-ends at an
|
|
45
|
+
* interior junction of lesser roads NOR at an interior cul-de-sac (wide roads never stub out
|
|
46
|
+
* mid-district); everything else is a local street, spurs are lanes. Grown chords and spur lanes
|
|
47
|
+
* keep the graph planar — no edge ever crosses another without a shared junction node.
|
|
48
|
+
*
|
|
49
|
+
* Circuit mode is a CURVE-FIRST centerline, not a filleted polygon (which read as straights joined by
|
|
50
|
+
* small corner caps). The layout synthesis is unchanged — seeded points scattered, hulled, a random subset
|
|
51
|
+
* of hull-edge midpoints displaced DEEP inward (the classic random-track method, folded far enough for
|
|
52
|
+
* several concave lobes) with light hairpin/ess/chicane templates — but its polygon is treated as CONTROL
|
|
53
|
+
* POINTS, not the track: 1–3 edges are collapsed onto lines as deliberate STRAIGHTS (start/finish is the
|
|
54
|
+
* longest), a periodic centripetal Catmull-Rom spline is fitted through the points and sampled densely, and
|
|
55
|
+
* a CURVATURE CLAMP (iterative Laplacian smoothing on a coarse uniform working loop, then a re-fit) relaxes
|
|
56
|
+
* every spot tighter than `minCurveRadius` up to the floor. So the lap is MOSTLY continuous curve — long
|
|
57
|
+
* sweepers, flowing esses, parabolic entries, curvature that flows rather than corner-caps — with the
|
|
58
|
+
* straights as deliberate exceptions and a legal hairpin (~`minCurveRadius`) surviving at the floor; radii
|
|
59
|
+
* form a smooth continuum from ~1× to many × `minCurveRadius`. `minCurveRadius` is enforced by the clamp
|
|
60
|
+
* and self-clearance (the sampled spline never runs within ~1.5 track widths of a non-adjacent part of
|
|
61
|
+
* itself) via bounded deterministic reject-and-retry with decaying fold aggression, falling back to a safe
|
|
62
|
+
* simple layout only when every retry crosses. The sampled loop is distributed into many node-to-node edges
|
|
63
|
+
* (nodes exactly ON the spline, each edge carrying its sampled sub-arc); city (net) streets keep the
|
|
64
|
+
* single-radius `minCurveRadius` arc fillet. When branching is meaningful a lane-level PIT LANE offsets
|
|
65
|
+
* parallel to the start/finish straight, leaving the loop at one node and rejoining at another (two
|
|
66
|
+
* degree-3 junctions, never a dead-end stub).
|
|
18
67
|
*
|
|
19
68
|
* Output is two coupled views of the graph: atomic node-to-node {@link StreetEdge}s (fed straight into
|
|
20
69
|
* the block/parcel fabric — a race circuit is many edges between distinct nodes, never one fragile
|
|
@@ -31,6 +80,12 @@ const MAX_NODES = 900;
|
|
|
31
80
|
const MAX_STREETS = 360;
|
|
32
81
|
const MAX_EDGES = 1400;
|
|
33
82
|
const MAX_LANES = 260;
|
|
83
|
+
const MAX_CIRCUIT_TRIES = 20;
|
|
84
|
+
const BETWEENNESS_SOURCES = 40;
|
|
85
|
+
const DEFAULT_SIDEWALK_WIDTH = 2;
|
|
86
|
+
/** Hard cap on the direction change between consecutive arc-fillet samples (≈9°) so filleted corners
|
|
87
|
+
* render as smooth curves, not chunky polygons, regardless of the (looser) `maxTurnAngle` ceiling. */
|
|
88
|
+
const MAX_ARC_STEP_RAD = (9 * Math.PI) / 180;
|
|
34
89
|
const LEVEL_RANK = { boulevard: 3, avenue: 2, street: 1, lane: 0 };
|
|
35
90
|
const WIDTH_MULT = { boulevard: 2.2, avenue: 1.5, street: 1, lane: 0.65 };
|
|
36
91
|
/** Turn angle (radians, 0..π) at `b` going a→b→c; 0 = straight, π = full reversal. */
|
|
@@ -47,13 +102,21 @@ function turnAngle(a, b, c) {
|
|
|
47
102
|
return Math.acos(Math.max(-1, Math.min(1, dot)));
|
|
48
103
|
}
|
|
49
104
|
/**
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
53
|
-
*
|
|
105
|
+
* Replace every real corner of a polyline with a sampled **circular-arc fillet** so bends read as
|
|
106
|
+
* smooth curves instead of polygonal notches, while enforcing two hard limits with fixed endpoints:
|
|
107
|
+
* interior corners gentler than `minRad` are straightened away (deliberate winding only, no
|
|
108
|
+
* micro-wiggle), and each remaining corner is rounded with an arc of radius `curveRadius` (clamped to
|
|
109
|
+
* half the shorter adjacent leg) sampled finely enough that no discrete turn between consecutive
|
|
110
|
+
* samples exceeds `maxRad` — a corner sharper than the ceiling is still reduced, now to a fillet
|
|
111
|
+
* rather than a bevel. Open-chain endpoints never move (a graph node stays welded); pass `closed` for
|
|
112
|
+
* a ring (first === last) so its wrap corner is filleted too and the loop stays closed.
|
|
113
|
+
*
|
|
114
|
+
* Pass `radiusFor` to vary the fillet radius PER CORNER (returning the target radius from the corner's
|
|
115
|
+
* deflection and adjacent leg lengths) instead of the single `curveRadius` — the circuit loop uses this
|
|
116
|
+
* to mix hairpins, standard corners, and sweepers; every other caller keeps the scalar radius.
|
|
54
117
|
* @internal
|
|
55
118
|
*/
|
|
56
|
-
export function clampTurns(points, minRad, maxRad) {
|
|
119
|
+
export function clampTurns(points, minRad, maxRad, curveRadius = 0, closed = false, radiusFor) {
|
|
57
120
|
if (points.length < 3)
|
|
58
121
|
return points.map((p) => [p[0], p[1]]);
|
|
59
122
|
// 1. straighten pass — drop interior vertices whose corner is shallower than the minimum.
|
|
@@ -77,32 +140,118 @@ export function clampTurns(points, minRad, maxRad) {
|
|
|
77
140
|
pts = out;
|
|
78
141
|
}
|
|
79
142
|
}
|
|
80
|
-
// 2.
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
143
|
+
// 2. fillet pass — round every remaining corner into a sampled circular arc.
|
|
144
|
+
return filletCorners(pts, curveRadius, maxRad, closed, radiusFor);
|
|
145
|
+
}
|
|
146
|
+
/** Deflection floor above which a corner is filleted (below it the vertex is left straight). Shared by
|
|
147
|
+
* {@link filletCorners} and the per-corner radius planner so both detect the SAME corner set. */
|
|
148
|
+
function filletFmin(maxRad) {
|
|
149
|
+
return Math.min(0.14, Math.max(1e-4, maxRad));
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* Round the corners of a polyline into sampled circular arcs. Each interior corner sharper than a
|
|
153
|
+
* small threshold (or above the `maxRad` ceiling) is replaced by an arc tangent to both legs of radius
|
|
154
|
+
* `curveRadius` (clamped to half the shorter leg; a non-positive radius rounds as large as the legs
|
|
155
|
+
* allow). The arc is sampled uniformly in angle so the turn between consecutive samples is the sweep
|
|
156
|
+
* divided by the sample count — kept at or below the smaller of `maxRad` and a fixed ~9° smoothness cap
|
|
157
|
+
* ({@link MAX_ARC_STEP_RAD}) so bends read as curves, not chunky polygons. Open endpoints are preserved
|
|
158
|
+
* exactly; a `closed` ring keeps `first === last`.
|
|
159
|
+
* @internal
|
|
160
|
+
*/
|
|
161
|
+
function filletCorners(points, curveRadius, maxRad, closed, radiusFor) {
|
|
162
|
+
const n0 = points.length;
|
|
163
|
+
if (n0 < 3)
|
|
164
|
+
return points.map((p) => [p[0], p[1]]);
|
|
165
|
+
const isClosed = closed && Math.hypot(points[0][0] - points[n0 - 1][0], points[0][1] - points[n0 - 1][1]) < 1e-6;
|
|
166
|
+
const verts = isClosed ? points.slice(0, n0 - 1) : points;
|
|
167
|
+
const n = verts.length;
|
|
168
|
+
if (n < 3)
|
|
169
|
+
return points.map((p) => [p[0], p[1]]);
|
|
170
|
+
// Fillet corners above this deflection; clamp to the ceiling so anything over `maxRad` is always
|
|
171
|
+
// rounded (and anything left un-filleted is already under the ceiling).
|
|
172
|
+
const fmin = filletFmin(maxRad);
|
|
173
|
+
// Sample each arc so no discrete turn exceeds the smaller of the (looser) `maxRad` ceiling and the
|
|
174
|
+
// fixed smoothness cap — the latter is what keeps a filleted corner from reading as a hard polygon.
|
|
175
|
+
const step = Math.min(maxRad > 1e-3 ? maxRad : Math.PI, MAX_ARC_STEP_RAD);
|
|
176
|
+
const out = [];
|
|
177
|
+
for (let i = 0; i < n; i += 1) {
|
|
178
|
+
const v = verts[i];
|
|
179
|
+
const isEnd = !isClosed && (i === 0 || i === n - 1);
|
|
180
|
+
if (isEnd) {
|
|
181
|
+
out.push([v[0], v[1]]);
|
|
182
|
+
continue;
|
|
92
183
|
}
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
const
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
const
|
|
101
|
-
const
|
|
102
|
-
const
|
|
103
|
-
|
|
184
|
+
const a = verts[(i - 1 + n) % n];
|
|
185
|
+
const b = verts[(i + 1) % n];
|
|
186
|
+
const deflection = turnAngle(a, v, b);
|
|
187
|
+
if (deflection <= fmin) {
|
|
188
|
+
out.push([v[0], v[1]]);
|
|
189
|
+
continue;
|
|
190
|
+
}
|
|
191
|
+
const adx = a[0] - v[0];
|
|
192
|
+
const adz = a[1] - v[1];
|
|
193
|
+
const bdx = b[0] - v[0];
|
|
194
|
+
const bdz = b[1] - v[1];
|
|
195
|
+
const la = Math.hypot(adx, adz);
|
|
196
|
+
const lb = Math.hypot(bdx, bdz);
|
|
197
|
+
if (la < 1e-3 || lb < 1e-3) {
|
|
198
|
+
out.push([v[0], v[1]]);
|
|
199
|
+
continue;
|
|
200
|
+
}
|
|
201
|
+
const dax = adx / la;
|
|
202
|
+
const daz = adz / la;
|
|
203
|
+
const dbx = bdx / lb;
|
|
204
|
+
const dbz = bdz / lb;
|
|
205
|
+
const half = deflection / 2;
|
|
206
|
+
const tanHalf = Math.tan(half);
|
|
207
|
+
// Per-corner target radius (circuit sweeper/hairpin mix) or the single scalar for every other caller.
|
|
208
|
+
const cornerRadius = radiusFor ? radiusFor(deflection, la, lb) : curveRadius;
|
|
209
|
+
// Tangent length from the corner: `cornerRadius·tan(θ/2)`, clamped to half each leg so adjacent
|
|
210
|
+
// fillets can meet but never overlap. A non-positive radius rounds as large as the legs allow.
|
|
211
|
+
let t = cornerRadius > 0 ? cornerRadius * tanHalf : Math.min(la, lb) * 0.5;
|
|
212
|
+
t = Math.min(t, la * 0.5, lb * 0.5);
|
|
213
|
+
if (t < 1e-3) {
|
|
214
|
+
out.push([v[0], v[1]]);
|
|
215
|
+
continue;
|
|
216
|
+
}
|
|
217
|
+
const reff = t / (tanHalf || 1e-6); // effective radius after leg clamping
|
|
218
|
+
let bix = dax + dbx;
|
|
219
|
+
let biz = daz + dbz;
|
|
220
|
+
const bl = Math.hypot(bix, biz);
|
|
221
|
+
if (bl < 1e-6) {
|
|
222
|
+
// Near-reversal (θ ≈ π): no stable arc center — leave the vertex as-is.
|
|
223
|
+
out.push([v[0], v[1]]);
|
|
224
|
+
continue;
|
|
225
|
+
}
|
|
226
|
+
bix /= bl;
|
|
227
|
+
biz /= bl;
|
|
228
|
+
// Arc center sits reff/cos(θ/2) along the inward bisector (θ = deflection); cos(θ/2) = sin(γ/2)
|
|
229
|
+
// where γ = π−θ is the interior angle at the vertex.
|
|
230
|
+
const denom = Math.cos(half) || 1e-6;
|
|
231
|
+
const cx = v[0] + bix * (reff / denom);
|
|
232
|
+
const cz = v[1] + biz * (reff / denom);
|
|
233
|
+
const p0 = [v[0] + dax * t, v[1] + daz * t];
|
|
234
|
+
const p1 = [v[0] + dbx * t, v[1] + dbz * t];
|
|
235
|
+
const a0 = Math.atan2(p0[1] - cz, p0[0] - cx);
|
|
236
|
+
const a1 = Math.atan2(p1[1] - cz, p1[0] - cx);
|
|
237
|
+
let delta = a1 - a0;
|
|
238
|
+
while (delta > Math.PI)
|
|
239
|
+
delta -= TAU;
|
|
240
|
+
while (delta < -Math.PI)
|
|
241
|
+
delta += TAU;
|
|
242
|
+
// At least 3 samples so even a tiny fillet is a curve, never a bevel; capped so a full sweep can
|
|
243
|
+
// still be sampled finely (a near-U hairpin at ~9°/step needs ~20 samples).
|
|
244
|
+
const segs = Math.max(3, Math.min(96, Math.ceil(Math.abs(delta) / step)));
|
|
245
|
+
out.push([p0[0], p0[1]]);
|
|
246
|
+
for (let s = 1; s < segs; s += 1) {
|
|
247
|
+
const ang = a0 + (delta * s) / segs;
|
|
248
|
+
out.push([cx + reff * Math.cos(ang), cz + reff * Math.sin(ang)]);
|
|
249
|
+
}
|
|
250
|
+
out.push([p1[0], p1[1]]);
|
|
104
251
|
}
|
|
105
|
-
|
|
252
|
+
if (isClosed)
|
|
253
|
+
out.push([out[0][0], out[0][1]]);
|
|
254
|
+
return out;
|
|
106
255
|
}
|
|
107
256
|
/** Two-octave deterministic sideways wander, capped by a max amplitude. */
|
|
108
257
|
function makeWander(rng, amplitude, wavelength) {
|
|
@@ -260,6 +409,19 @@ function buildNet(rules, hx, hz, streams) {
|
|
|
260
409
|
};
|
|
261
410
|
for (const e of edges)
|
|
262
411
|
addAdj(e.a, e.b);
|
|
412
|
+
// A new chord/spur must keep the graph PLANAR: it may not cross or shadow any edge it does not
|
|
413
|
+
// share a node with — otherwise two roads overlap mid-segment with no junction node, and every
|
|
414
|
+
// downstream consumer (blocks, lots, renderers) sees pavement crossing pavement with no crossing.
|
|
415
|
+
const corridorClear = rules.width * 1.6;
|
|
416
|
+
const chordClear = (a, b, endA, endB) => {
|
|
417
|
+
for (const e of edges) {
|
|
418
|
+
if (e.a === endA || e.b === endA || e.a === endB || e.b === endB)
|
|
419
|
+
continue;
|
|
420
|
+
if (segSegDistance(a, b, nodes[e.a], nodes[e.b]) < corridorClear)
|
|
421
|
+
return false;
|
|
422
|
+
}
|
|
423
|
+
return true;
|
|
424
|
+
};
|
|
263
425
|
const loopRng = streams("loops");
|
|
264
426
|
for (let n = 0; n < nodes.length; n += 1) {
|
|
265
427
|
if (degree[n] !== 1)
|
|
@@ -269,14 +431,15 @@ function buildNet(rules, hx, hz, streams) {
|
|
|
269
431
|
continue; // becomes a cul-de-sac
|
|
270
432
|
if (loopRng() > rules.loopiness + 0.15)
|
|
271
433
|
continue; // otherwise mostly left as-is
|
|
272
|
-
// Reconnect to the nearest node that isn't already a neighbor, forming a loop
|
|
434
|
+
// Reconnect to the nearest node that isn't already a neighbor, forming a loop — but only via a
|
|
435
|
+
// chord that stays clear of every unrelated edge (no node-less crossings).
|
|
273
436
|
let best = -1;
|
|
274
437
|
let bestD = Infinity;
|
|
275
438
|
for (let m = 0; m < nodes.length; m += 1) {
|
|
276
439
|
if (m === n || adj.get(n)?.has(m))
|
|
277
440
|
continue;
|
|
278
441
|
const d = Math.hypot(nodes[n][0] - nodes[m][0], nodes[n][1] - nodes[m][1]);
|
|
279
|
-
if (d < bestD && d < rules.segmentLength * 1.8) {
|
|
442
|
+
if (d < bestD && d < rules.segmentLength * 1.8 && chordClear(nodes[n], nodes[m], n, m)) {
|
|
280
443
|
bestD = d;
|
|
281
444
|
best = m;
|
|
282
445
|
}
|
|
@@ -286,82 +449,1123 @@ function buildNet(rules, hx, hz, streams) {
|
|
|
286
449
|
addAdj(n, best);
|
|
287
450
|
}
|
|
288
451
|
}
|
|
289
|
-
// 4. branching — spur lanes forking
|
|
452
|
+
// 4. branching — spur lanes (alleys) forking off MID-BLOCK, ending in a fresh cul-de-sac node.
|
|
453
|
+
// An alley leaves its street at a right angle from the middle of a block face — never from a
|
|
454
|
+
// junction (where it would shadow a road) and never at a random diagonal (which reads as a
|
|
455
|
+
// glitch cutting through the grid). The host edge is split at the branch point so the graph
|
|
456
|
+
// stays planar and the crossing is a real T node.
|
|
290
457
|
const laneCount = Math.min(Math.round(rules.branching * nodes.length * 0.6), MAX_LANES);
|
|
291
458
|
const branchRng = streams("branches");
|
|
292
|
-
for (let b = 0; b < laneCount && nodes.length
|
|
293
|
-
const
|
|
294
|
-
const
|
|
295
|
-
const
|
|
296
|
-
const
|
|
297
|
-
const
|
|
298
|
-
const
|
|
299
|
-
if (
|
|
300
|
-
continue;
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
459
|
+
for (let b = 0; b < laneCount && nodes.length + 2 <= MAX_NODES && edges.length + 2 <= MAX_EDGES; b += 1) {
|
|
460
|
+
const ei = Math.floor(branchRng() * edges.length);
|
|
461
|
+
const hostEdge = edges[ei];
|
|
462
|
+
const tRoll = 0.35 + branchRng() * 0.3;
|
|
463
|
+
const sideRoll = branchRng() < 0.5 ? Math.PI / 2 : -Math.PI / 2;
|
|
464
|
+
const jitter = (branchRng() - 0.5) * (1 - rules.gridness) * (Math.PI / 2) * 0.8;
|
|
465
|
+
const baseLen = rules.segmentLength * (0.6 + branchRng() * 0.7);
|
|
466
|
+
if (hostEdge.lane)
|
|
467
|
+
continue; // alleys fork off streets, not off other alleys
|
|
468
|
+
const pa = nodes[hostEdge.a];
|
|
469
|
+
const pb = nodes[hostEdge.b];
|
|
470
|
+
const hostLen = Math.hypot(pb[0] - pa[0], pb[1] - pa[1]);
|
|
471
|
+
if (hostLen < rules.segmentLength * 0.5)
|
|
472
|
+
continue; // too short a face to host an alley
|
|
473
|
+
const mid = [pa[0] + (pb[0] - pa[0]) * tRoll, pa[1] + (pb[1] - pa[1]) * tRoll];
|
|
474
|
+
const edgeDir = Math.atan2(pb[1] - pa[1], pb[0] - pa[0]);
|
|
475
|
+
// Try both perpendicular sides and progressively shorter lanes until one candidate keeps the
|
|
476
|
+
// graph planar and clear — a blocked alley shrinks before it is abandoned.
|
|
477
|
+
let tip = null;
|
|
478
|
+
for (const flip of [0, Math.PI]) {
|
|
479
|
+
if (tip !== null)
|
|
306
480
|
break;
|
|
481
|
+
for (const lenF of [1, 0.72, 0.52]) {
|
|
482
|
+
const angle = edgeDir + sideRoll + flip + jitter;
|
|
483
|
+
const len = baseLen * lenF;
|
|
484
|
+
if (len < rules.segmentLength * 0.3)
|
|
485
|
+
break;
|
|
486
|
+
const nx = mid[0] + Math.cos(angle) * len;
|
|
487
|
+
const nz = mid[1] + Math.sin(angle) * len;
|
|
488
|
+
if (Math.abs(nx) > hx - 1 || Math.abs(nz) > hz - 1)
|
|
489
|
+
continue;
|
|
490
|
+
// Reject spurs whose tip would land on top of an existing node.
|
|
491
|
+
let clash = false;
|
|
492
|
+
for (let m = 0; m < nodes.length; m += 1) {
|
|
493
|
+
if (Math.hypot(nodes[m][0] - nx, nodes[m][1] - nz) < rules.segmentLength * 0.45) {
|
|
494
|
+
clash = true;
|
|
495
|
+
break;
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
if (clash)
|
|
499
|
+
continue;
|
|
500
|
+
// Planarity: the spur may not cross/shadow any edge other than the host it forks from.
|
|
501
|
+
const candidate = [nx, nz];
|
|
502
|
+
let blocked = false;
|
|
503
|
+
for (let k = 0; k < edges.length; k += 1) {
|
|
504
|
+
if (k === ei)
|
|
505
|
+
continue;
|
|
506
|
+
const e = edges[k];
|
|
507
|
+
if (segSegDistance(mid, candidate, nodes[e.a], nodes[e.b]) < corridorClear) {
|
|
508
|
+
blocked = true;
|
|
509
|
+
break;
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
if (!blocked) {
|
|
513
|
+
tip = candidate;
|
|
514
|
+
break;
|
|
515
|
+
}
|
|
307
516
|
}
|
|
308
517
|
}
|
|
309
|
-
if (
|
|
518
|
+
if (tip === null)
|
|
310
519
|
continue;
|
|
311
|
-
const
|
|
312
|
-
nodes.push(
|
|
313
|
-
|
|
520
|
+
const midId = nodes.length;
|
|
521
|
+
nodes.push(mid);
|
|
522
|
+
const tipId = nodes.length;
|
|
523
|
+
nodes.push(tip);
|
|
524
|
+
// Split the host edge at the branch point; both halves keep the host's street role.
|
|
525
|
+
edges[ei] = { a: hostEdge.a, b: midId, lane: false };
|
|
526
|
+
edges.push({ a: midId, b: hostEdge.b, lane: false });
|
|
527
|
+
edges.push({ a: midId, b: tipId, lane: true });
|
|
314
528
|
}
|
|
315
529
|
return { nodes, edges: edges.slice(0, MAX_EDGES) };
|
|
316
530
|
}
|
|
317
|
-
/**
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
const
|
|
321
|
-
const
|
|
322
|
-
|
|
531
|
+
/** Convex hull (Andrew's monotone chain), returns hull points CCW. */
|
|
532
|
+
function convexHull(pts) {
|
|
533
|
+
const uniq = pts.slice().sort((p, q) => (p[0] === q[0] ? p[1] - q[1] : p[0] - q[0]));
|
|
534
|
+
const dedup = [];
|
|
535
|
+
for (const p of uniq) {
|
|
536
|
+
const last = dedup[dedup.length - 1];
|
|
537
|
+
if (last === undefined || Math.hypot(last[0] - p[0], last[1] - p[1]) > 1e-6)
|
|
538
|
+
dedup.push(p);
|
|
539
|
+
}
|
|
540
|
+
const n = dedup.length;
|
|
541
|
+
if (n < 3)
|
|
542
|
+
return dedup;
|
|
543
|
+
const cross = (o, a, b) => (a[0] - o[0]) * (b[1] - o[1]) - (a[1] - o[1]) * (b[0] - o[0]);
|
|
544
|
+
const lower = [];
|
|
545
|
+
for (const p of dedup) {
|
|
546
|
+
while (lower.length >= 2 && cross(lower[lower.length - 2], lower[lower.length - 1], p) <= 0)
|
|
547
|
+
lower.pop();
|
|
548
|
+
lower.push(p);
|
|
549
|
+
}
|
|
550
|
+
const upper = [];
|
|
551
|
+
for (let i = n - 1; i >= 0; i -= 1) {
|
|
552
|
+
const p = dedup[i];
|
|
553
|
+
while (upper.length >= 2 && cross(upper[upper.length - 2], upper[upper.length - 1], p) <= 0)
|
|
554
|
+
upper.pop();
|
|
555
|
+
upper.push(p);
|
|
556
|
+
}
|
|
557
|
+
lower.pop();
|
|
558
|
+
upper.pop();
|
|
559
|
+
return lower.concat(upper);
|
|
560
|
+
}
|
|
561
|
+
/** True when segments p1–p2 and p3–p4 intersect (including touching). */
|
|
562
|
+
function segSegIntersects(p1, p2, p3, p4) {
|
|
563
|
+
const rx = p2[0] - p1[0];
|
|
564
|
+
const rz = p2[1] - p1[1];
|
|
565
|
+
const sx = p4[0] - p3[0];
|
|
566
|
+
const sz = p4[1] - p3[1];
|
|
567
|
+
const denom = rx * sz - rz * sx;
|
|
568
|
+
if (Math.abs(denom) < 1e-12)
|
|
569
|
+
return false; // parallel — the distance test covers overlap
|
|
570
|
+
const t = ((p3[0] - p1[0]) * sz - (p3[1] - p1[1]) * sx) / denom;
|
|
571
|
+
const u = ((p3[0] - p1[0]) * rz - (p3[1] - p1[1]) * rx) / denom;
|
|
572
|
+
return t >= 0 && t <= 1 && u >= 0 && u <= 1;
|
|
573
|
+
}
|
|
574
|
+
/** Minimum distance between two line segments in the XZ plane (0 when they intersect). */
|
|
575
|
+
function segSegDistance(p1, p2, p3, p4) {
|
|
576
|
+
if (segSegIntersects(p1, p2, p3, p4))
|
|
577
|
+
return 0;
|
|
578
|
+
const pointSeg = (p, a, b) => {
|
|
579
|
+
const abx = b[0] - a[0];
|
|
580
|
+
const abz = b[1] - a[1];
|
|
581
|
+
const l2 = abx * abx + abz * abz;
|
|
582
|
+
const t = l2 === 0 ? 0 : Math.max(0, Math.min(1, ((p[0] - a[0]) * abx + (p[1] - a[1]) * abz) / l2));
|
|
583
|
+
return Math.hypot(p[0] - (a[0] + abx * t), p[1] - (a[1] + abz * t));
|
|
584
|
+
};
|
|
585
|
+
return Math.min(pointSeg(p1, p3, p4), pointSeg(p2, p3, p4), pointSeg(p3, p1, p2), pointSeg(p4, p1, p2));
|
|
586
|
+
}
|
|
587
|
+
/**
|
|
588
|
+
* True when no two centerline segments that are FAR APART ALONG THE LOOP (arc-length separation
|
|
589
|
+
* `> gapMin`) run spatially closer than `clearance` — i.e. the track never folds back onto a distant
|
|
590
|
+
* part of itself. Segments within the same corner or between neighboring corners (small arc gap,
|
|
591
|
+
* including the intentional close legs of a hairpin) are excluded, so only genuine self-crossings fail.
|
|
592
|
+
*/
|
|
593
|
+
function loopSelfClearing(poly, clearance, gapMin) {
|
|
594
|
+
const n = poly.length;
|
|
595
|
+
if (n < 6)
|
|
596
|
+
return true;
|
|
597
|
+
const seg = [];
|
|
598
|
+
let perimeter = 0;
|
|
599
|
+
for (let i = 0; i < n; i += 1) {
|
|
600
|
+
const a = poly[i];
|
|
601
|
+
const b = poly[(i + 1) % n];
|
|
602
|
+
const l = Math.hypot(b[0] - a[0], b[1] - a[1]);
|
|
603
|
+
seg.push(l);
|
|
604
|
+
perimeter += l;
|
|
605
|
+
}
|
|
606
|
+
const cum = [0];
|
|
607
|
+
for (let i = 0; i < n; i += 1)
|
|
608
|
+
cum.push(cum[i] + seg[i]);
|
|
609
|
+
for (let i = 0; i < n; i += 1) {
|
|
610
|
+
const a1 = poly[i];
|
|
611
|
+
const a2 = poly[(i + 1) % n];
|
|
612
|
+
for (let j = i + 1; j < n; j += 1) {
|
|
613
|
+
// Edge-to-edge arc-length gap along the loop (0 for edges sharing a vertex, incl. the wrap).
|
|
614
|
+
const forwardGap = cum[j] - cum[i + 1];
|
|
615
|
+
const backwardGap = perimeter - cum[j + 1] + cum[i];
|
|
616
|
+
const arcGap = Math.min(forwardGap, backwardGap);
|
|
617
|
+
if (arcGap <= gapMin)
|
|
618
|
+
continue; // same corner / neighboring corners — not a fold-back
|
|
619
|
+
const b1 = poly[j];
|
|
620
|
+
const b2 = poly[(j + 1) % n];
|
|
621
|
+
if (segSegDistance(a1, a2, b1, b2) < clearance)
|
|
622
|
+
return false;
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
return true;
|
|
626
|
+
}
|
|
627
|
+
/** Signed area (>0 = CCW). */
|
|
628
|
+
function signedArea(poly) {
|
|
629
|
+
let area = 0;
|
|
630
|
+
const n = poly.length;
|
|
631
|
+
for (let i = 0; i < n; i += 1) {
|
|
632
|
+
const a = poly[i];
|
|
633
|
+
const b = poly[(i + 1) % n];
|
|
634
|
+
area += a[0] * b[1] - b[0] * a[1];
|
|
635
|
+
}
|
|
636
|
+
return area / 2;
|
|
637
|
+
}
|
|
638
|
+
/** Index of the polygon edge with the greatest length (the start/finish straight candidate). */
|
|
639
|
+
function longestEdge(poly) {
|
|
640
|
+
let index = 0;
|
|
641
|
+
let length = 0;
|
|
642
|
+
const n = poly.length;
|
|
643
|
+
for (let i = 0; i < n; i += 1) {
|
|
644
|
+
const a = poly[i];
|
|
645
|
+
const b = poly[(i + 1) % n];
|
|
646
|
+
const l = Math.hypot(b[0] - a[0], b[1] - a[1]);
|
|
647
|
+
if (l > length) {
|
|
648
|
+
length = l;
|
|
649
|
+
index = i;
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
return { index, length };
|
|
653
|
+
}
|
|
654
|
+
/**
|
|
655
|
+
* Synthesize one closed track polygon: scatter seeded points, take their convex hull, then fold it into
|
|
656
|
+
* a real circuit by displacing hull-edge midpoints INWARD toward the centroid — a random subset pushed
|
|
657
|
+
* DEEP (each deep push is a reflex vertex / concave lobe), the rest left straight so corners pop against
|
|
658
|
+
* straights — keeping the longest edge straight for a start/finish, then carving guaranteed
|
|
659
|
+
* chicane/ess/hairpin corner templates. `aggression` (1 = full depth, decaying on retries) scales fold
|
|
660
|
+
* depth and template count so a clearing layout is found before the safe fallback. Returns the polygon
|
|
661
|
+
* vertices (not duplicated), or null if the hull was degenerate.
|
|
662
|
+
*/
|
|
663
|
+
function synthTrack(rules, hx, hz, rng, aggression = 1) {
|
|
664
|
+
const rx = hx * 0.9;
|
|
665
|
+
const rz = hz * 0.9;
|
|
323
666
|
const perimeter = Math.PI * (rx + rz);
|
|
324
|
-
const
|
|
325
|
-
|
|
326
|
-
const
|
|
327
|
-
for (let i = 0; i <
|
|
328
|
-
const
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
// Smooth the radii so consecutive corners can't be sharper than the ceiling allows.
|
|
332
|
-
const smoothPasses = 2 + Math.round((1 - rules.maxTurnAngle / 180) * 4);
|
|
333
|
-
for (let pass = 0; pass < smoothPasses; pass += 1) {
|
|
334
|
-
const next = radii.slice();
|
|
335
|
-
for (let i = 0; i < k; i += 1) {
|
|
336
|
-
const prev = radii[(i - 1 + k) % k];
|
|
337
|
-
const cur = radii[i];
|
|
338
|
-
const nxt = radii[(i + 1) % k];
|
|
339
|
-
next[i] = cur * 0.5 + (prev + nxt) * 0.25;
|
|
340
|
-
}
|
|
341
|
-
for (let i = 0; i < k; i += 1)
|
|
342
|
-
radii[i] = next[i];
|
|
667
|
+
const target = Math.max(6, Math.min(Math.round(perimeter / (rules.segmentLength * 1.4)), 18));
|
|
668
|
+
const scatter = Math.max(12, Math.min(target * 2, 40));
|
|
669
|
+
const pts = [];
|
|
670
|
+
for (let i = 0; i < scatter; i += 1) {
|
|
671
|
+
const ang = rng() * TAU;
|
|
672
|
+
const rad = Math.sqrt(rng());
|
|
673
|
+
pts.push([Math.cos(ang) * rx * rad, Math.sin(ang) * rz * rad]);
|
|
343
674
|
}
|
|
344
|
-
const
|
|
675
|
+
const hull = convexHull(pts);
|
|
676
|
+
if (hull.length < 4)
|
|
677
|
+
return null;
|
|
678
|
+
let cx = 0;
|
|
679
|
+
let cz = 0;
|
|
680
|
+
for (const p of hull) {
|
|
681
|
+
cx += p[0];
|
|
682
|
+
cz += p[1];
|
|
683
|
+
}
|
|
684
|
+
cx /= hull.length;
|
|
685
|
+
cz /= hull.length;
|
|
686
|
+
const straightEdge = longestEdge(hull).index;
|
|
687
|
+
// Probability an edge gets a DEEP inward fold (a real concave lobe) vs a shallow dent vs left straight.
|
|
688
|
+
const pDeep = 0.2 + rules.winding * 0.4;
|
|
689
|
+
const pDent = pDeep + 0.3;
|
|
690
|
+
const poly = [];
|
|
691
|
+
for (let k = 0; k < hull.length; k += 1) {
|
|
692
|
+
const p = hull[k];
|
|
693
|
+
const q = hull[(k + 1) % hull.length];
|
|
694
|
+
poly.push([p[0], p[1]]);
|
|
695
|
+
if (k === straightEdge)
|
|
696
|
+
continue; // keep the start/finish edge straight — no midpoint
|
|
697
|
+
const mx = (p[0] + q[0]) / 2;
|
|
698
|
+
const mz = (p[1] + q[1]) / 2;
|
|
699
|
+
const dx = cx - mx;
|
|
700
|
+
const dz = cz - mz;
|
|
701
|
+
const d = Math.hypot(dx, dz);
|
|
702
|
+
const edgeLen = Math.hypot(q[0] - p[0], q[1] - p[1]);
|
|
703
|
+
// Fold class for this edge. DEEP pushes travel a large fraction of the way to the centroid so the
|
|
704
|
+
// loop genuinely folds (multiple reflex lobes); DENT is a shallow concavity; STRAIGHT leaves the
|
|
705
|
+
// edge alone so real straights survive. Depth scales with `aggression` so retries relax to clear.
|
|
706
|
+
const roll = rng();
|
|
707
|
+
let frac = 0;
|
|
708
|
+
if (roll < pDeep)
|
|
709
|
+
frac = (0.4 + rng() * 0.3) * aggression;
|
|
710
|
+
else if (roll < pDent)
|
|
711
|
+
frac = (0.1 + rng() * 0.16) * aggression;
|
|
712
|
+
const amt = Math.min(edgeLen * 0.7, d * Math.min(0.72, frac));
|
|
713
|
+
if (d > 1e-3 && amt > 1e-3) {
|
|
714
|
+
poly.push([mx + (dx / d) * amt, mz + (dz / d) * amt]);
|
|
715
|
+
}
|
|
716
|
+
else {
|
|
717
|
+
poly.push([mx, mz]);
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
applyCornerTemplates(poly, rules, rng, aggression);
|
|
721
|
+
// Clamp everything back inside the footprint.
|
|
722
|
+
for (let i = 0; i < poly.length; i += 1) {
|
|
723
|
+
poly[i] = [Math.max(-hx + 1, Math.min(hx - 1, poly[i][0])), Math.max(-hz + 1, Math.min(hz - 1, poly[i][1]))];
|
|
724
|
+
}
|
|
725
|
+
return poly;
|
|
726
|
+
}
|
|
727
|
+
/**
|
|
728
|
+
* Carve chicane/ess/hairpin corner templates into a track polygon in place — the corner-VARIETY quota.
|
|
729
|
+
* When `winding ≥ 0.4` the first two templates are a guaranteed HAIRPIN (a deep, narrow inward finger
|
|
730
|
+
* that reverses direction over a short arc — a genuine ~180° U-turn, not a shallow apex) and a
|
|
731
|
+
* guaranteed ESS (alternating left/right corners); further templates (count scaling with `winding` and
|
|
732
|
+
* `aggression`) are random hairpin/ess/chicane. Every template is a LOCAL edit — an inward finger or a
|
|
733
|
+
* pair of opposite perpendicular offsets — so the loop's global self-clearance survives for the
|
|
734
|
+
* reject-and-retry check. Fingers point inward (toward the centroid) so the track folds without leaving
|
|
735
|
+
* the footprint.
|
|
736
|
+
*/
|
|
737
|
+
function applyCornerTemplates(poly, rules, rng, aggression = 1) {
|
|
738
|
+
const forceVariety = rules.winding >= 0.4;
|
|
739
|
+
const count = Math.max(forceVariety ? 2 : 0, Math.round(rules.winding * 3 * aggression));
|
|
740
|
+
if (count <= 0 || poly.length < 6)
|
|
741
|
+
return;
|
|
742
|
+
const step = rules.segmentLength;
|
|
743
|
+
let cx = 0;
|
|
744
|
+
let cz = 0;
|
|
745
|
+
for (const p of poly) {
|
|
746
|
+
cx += p[0];
|
|
747
|
+
cz += p[1];
|
|
748
|
+
}
|
|
749
|
+
cx /= poly.length;
|
|
750
|
+
cz /= poly.length;
|
|
751
|
+
for (let c = 0; c < count; c += 1) {
|
|
752
|
+
const n = poly.length;
|
|
753
|
+
const i = 1 + Math.floor(rng() * (n - 2));
|
|
754
|
+
const a = poly[i];
|
|
755
|
+
const b = poly[(i + 1) % n];
|
|
756
|
+
const dx = b[0] - a[0];
|
|
757
|
+
const dz = b[1] - a[1];
|
|
758
|
+
const l = Math.hypot(dx, dz);
|
|
759
|
+
if (l < step * 0.35)
|
|
760
|
+
continue; // too little room for a legible corner
|
|
761
|
+
const ux = dx / l;
|
|
762
|
+
const uz = dz / l;
|
|
763
|
+
// Inward normal (points toward the loop centroid so fingers fold in, never out of the footprint).
|
|
764
|
+
const mx = (a[0] + b[0]) / 2;
|
|
765
|
+
const mz = (a[1] + b[1]) / 2;
|
|
766
|
+
let nx = -uz;
|
|
767
|
+
let nz = ux;
|
|
768
|
+
if ((cx - mx) * nx + (cz - mz) * nz < 0) {
|
|
769
|
+
nx = -nx;
|
|
770
|
+
nz = -nz;
|
|
771
|
+
}
|
|
772
|
+
const at = (i + 1) % n === 0 ? n : i + 1;
|
|
773
|
+
const roll = rng();
|
|
774
|
+
const kind = forceVariety && c === 0 ? "hairpin" : forceVariety && c === 1 ? "ess" : roll < 0.34 ? "hairpin" : roll < 0.67 ? "ess" : "chicane";
|
|
775
|
+
if (kind === "hairpin") {
|
|
776
|
+
// Deep, narrow inward finger: enter, cross a short tip, exit → a real U-turn over a short arc.
|
|
777
|
+
const depth = Math.min(l * 0.85, step * (0.6 + rng() * 0.35) * aggression + step * 0.28);
|
|
778
|
+
const tip = Math.min(l * 0.45, Math.max(step * 0.1, rules.width * 1.4));
|
|
779
|
+
const base = l * (0.34 + rng() * 0.12);
|
|
780
|
+
const t1 = [a[0] + ux * base + nx * depth, a[1] + uz * base + nz * depth];
|
|
781
|
+
const t2 = [a[0] + ux * (base + tip) + nx * depth, a[1] + uz * (base + tip) + nz * depth];
|
|
782
|
+
poly.splice(at, 0, t1, t2);
|
|
783
|
+
}
|
|
784
|
+
else if (kind === "ess") {
|
|
785
|
+
// Ess: two opposite offsets flanking the edge — alternating corners.
|
|
786
|
+
const off = step * (0.2 + rng() * 0.26) * aggression;
|
|
787
|
+
const t1 = [a[0] + ux * l * 0.32 + nx * off, a[1] + uz * l * 0.32 + nz * off];
|
|
788
|
+
const t2 = [a[0] + ux * l * 0.68 - nx * off, a[1] + uz * l * 0.68 - nz * off];
|
|
789
|
+
poly.splice(at, 0, t1, t2);
|
|
790
|
+
}
|
|
791
|
+
else {
|
|
792
|
+
// Chicane: a quick left-right-left kink (3 points).
|
|
793
|
+
const off = step * (0.16 + rng() * 0.2) * aggression;
|
|
794
|
+
const t1 = [a[0] + ux * l * 0.25 + nx * off, a[1] + uz * l * 0.25 + nz * off];
|
|
795
|
+
const t2 = [a[0] + ux * l * 0.5 - nx * off, a[1] + uz * l * 0.5 - nz * off];
|
|
796
|
+
const t3 = [a[0] + ux * l * 0.75 + nx * off, a[1] + uz * l * 0.75 + nz * off];
|
|
797
|
+
poly.splice(at, 0, t1, t2, t3);
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
/** A safe convex fallback track (an inset ellipse with one shallow inward dent) with a long straight. */
|
|
802
|
+
function fallbackTrack(rules, hx, hz) {
|
|
803
|
+
const rx = hx * 0.82;
|
|
804
|
+
const rz = hz * 0.82;
|
|
805
|
+
const k = Math.max(8, Math.min(Math.round((Math.PI * (rx + rz)) / rules.segmentLength), 24));
|
|
806
|
+
const poly = [];
|
|
345
807
|
for (let i = 0; i < k; i += 1) {
|
|
346
|
-
const
|
|
347
|
-
|
|
348
|
-
|
|
808
|
+
const ang = (i / k) * TAU;
|
|
809
|
+
// One gentle inward dent so the fallback is not a perfect star-convex blob.
|
|
810
|
+
const dent = i === Math.floor(k / 2) ? 0.72 : 1;
|
|
811
|
+
poly.push([Math.cos(ang) * rx * dent, Math.sin(ang) * rz * dent]);
|
|
349
812
|
}
|
|
813
|
+
return poly;
|
|
814
|
+
}
|
|
815
|
+
/** Circumradius of a point triple = the local fitted radius (∞ for a collinear/straight triple). This is
|
|
816
|
+
* the SAME metric the curvature clamp enforces and the tests measure, so a clamped loop has no triple
|
|
817
|
+
* under the floor. @internal */
|
|
818
|
+
function circumRadius(a, b, c) {
|
|
819
|
+
const ab = Math.hypot(b[0] - a[0], b[1] - a[1]);
|
|
820
|
+
const bc = Math.hypot(c[0] - b[0], c[1] - b[1]);
|
|
821
|
+
const ca = Math.hypot(a[0] - c[0], a[1] - c[1]);
|
|
822
|
+
const area = Math.abs((b[0] - a[0]) * (c[1] - a[1]) - (b[1] - a[1]) * (c[0] - a[0])) / 2;
|
|
823
|
+
if (area < 1e-9)
|
|
824
|
+
return Infinity;
|
|
825
|
+
return (ab * bc * ca) / (4 * area);
|
|
826
|
+
}
|
|
827
|
+
/** Drop consecutive (and wrap) near-coincident vertices so a control ring has well-defined tangents. */
|
|
828
|
+
function dedupeRing(pts, eps = 1e-3) {
|
|
829
|
+
const out = [];
|
|
830
|
+
for (const p of pts) {
|
|
831
|
+
const last = out[out.length - 1];
|
|
832
|
+
if (last === undefined || Math.hypot(last[0] - p[0], last[1] - p[1]) > eps)
|
|
833
|
+
out.push([p[0], p[1]]);
|
|
834
|
+
}
|
|
835
|
+
while (out.length > 3 && Math.hypot(out[0][0] - out[out.length - 1][0], out[0][1] - out[out.length - 1][1]) < eps) {
|
|
836
|
+
out.pop();
|
|
837
|
+
}
|
|
838
|
+
return out;
|
|
839
|
+
}
|
|
840
|
+
/**
|
|
841
|
+
* Designate 1–3 control-polygon edges as genuine STRAIGHTS (start/finish is always the longest edge) and
|
|
842
|
+
* collinearize each into a run of on-chord control points, so the fitted spline passes through real
|
|
843
|
+
* straight runs that CONTRAST with an otherwise continuously-curving lap. The inserted interior points are
|
|
844
|
+
* flagged `straight` (they define the dead-straight middle and are protected from the curvature clamp); the
|
|
845
|
+
* original control points stay unflagged so the spline's parabolic entry/exit at each straight end is free
|
|
846
|
+
* to curve and be floored. More straights when `winding` is low (a windy lap curves more).
|
|
847
|
+
* @internal
|
|
848
|
+
*/
|
|
849
|
+
function designateStraights(control, rules, rng) {
|
|
850
|
+
const m = control.length;
|
|
851
|
+
const lens = control.map((p, i) => {
|
|
852
|
+
const q = control[(i + 1) % m];
|
|
853
|
+
return Math.hypot(q[0] - p[0], q[1] - p[1]);
|
|
854
|
+
});
|
|
855
|
+
const order = lens.map((_, i) => i).sort((a, b) => lens[b] - lens[a]);
|
|
856
|
+
const chosen = new Set();
|
|
857
|
+
chosen.add(order[0]); // start/finish = the longest edge
|
|
858
|
+
// Fewer extra straights as `winding` rises (a windy lap should curve nearly everywhere); at winding ≥ 0.5
|
|
859
|
+
// the lap is usually just the start/finish straight so curves dominate the arc length.
|
|
860
|
+
const extra = rules.winding >= 0.5
|
|
861
|
+
? rng() < 0.35
|
|
862
|
+
? 1
|
|
863
|
+
: 0
|
|
864
|
+
: Math.max(0, Math.min(2, Math.round((1 - rules.winding) * 2)));
|
|
865
|
+
for (let r = 1; r < order.length && chosen.size < 1 + extra; r += 1) {
|
|
866
|
+
const e = order[r];
|
|
867
|
+
if (lens[e] < rules.segmentLength * 1.2)
|
|
868
|
+
break; // remaining edges too short to read as a straight
|
|
869
|
+
if (chosen.has((e + 1) % m) || chosen.has((e - 1 + m) % m))
|
|
870
|
+
continue; // keep straights apart
|
|
871
|
+
if (rng() < 0.75)
|
|
872
|
+
chosen.add(e);
|
|
873
|
+
}
|
|
874
|
+
const pts = [];
|
|
875
|
+
const straight = [];
|
|
876
|
+
for (let i = 0; i < m; i += 1) {
|
|
877
|
+
const p = control[i];
|
|
878
|
+
const q = control[(i + 1) % m];
|
|
879
|
+
pts.push([p[0], p[1]]);
|
|
880
|
+
straight.push(false); // original control points are transition anchors, never protected
|
|
881
|
+
if (chosen.has(i)) {
|
|
882
|
+
// Subdivide the edge into on-chord interior points; the CR spline through ≥3 collinear points runs
|
|
883
|
+
// dead straight between them (only the first/last sub-segment bends toward the adjacent curve).
|
|
884
|
+
const sub = Math.max(4, Math.round(lens[i] / rules.segmentLength) + 2);
|
|
885
|
+
for (let s = 1; s < sub; s += 1) {
|
|
886
|
+
const t = s / sub;
|
|
887
|
+
pts.push([p[0] + (q[0] - p[0]) * t, p[1] + (q[1] - p[1]) * t]);
|
|
888
|
+
straight.push(true);
|
|
889
|
+
}
|
|
890
|
+
}
|
|
891
|
+
}
|
|
892
|
+
return { pts, straight };
|
|
893
|
+
}
|
|
894
|
+
/** Evaluate a centripetal Catmull-Rom segment P1→P2 at parameter `u` (Barry–Goldman pyramid). */
|
|
895
|
+
function catmullPoint(p0, p1, p2, p3, t0, t1, t2, t3, u) {
|
|
896
|
+
const lerp = (a, b, ta, tb) => {
|
|
897
|
+
const d = tb - ta;
|
|
898
|
+
if (Math.abs(d) < 1e-9)
|
|
899
|
+
return [a[0], a[1]];
|
|
900
|
+
const w = (u - ta) / d;
|
|
901
|
+
return [a[0] + (b[0] - a[0]) * w, a[1] + (b[1] - a[1]) * w];
|
|
902
|
+
};
|
|
903
|
+
const a1 = lerp(p0, p1, t0, t1);
|
|
904
|
+
const a2 = lerp(p1, p2, t1, t2);
|
|
905
|
+
const a3 = lerp(p2, p3, t2, t3);
|
|
906
|
+
const b1 = lerp(a1, a2, t0, t2);
|
|
907
|
+
const b2 = lerp(a2, a3, t1, t3);
|
|
908
|
+
return lerp(b1, b2, t1, t2);
|
|
909
|
+
}
|
|
910
|
+
/**
|
|
911
|
+
* Fit a periodic CENTRIPETAL Catmull-Rom spline through the closed control points and sample it densely
|
|
912
|
+
* (≤ `sampleStep` chord spacing) so it flows through every point without cusps. Centripetal (α=0.5)
|
|
913
|
+
* parameterization avoids the self-intersections/overshoot of uniform CR. Each sample inherits the
|
|
914
|
+
* `straight` flag of its segment (a segment is straight only when BOTH its control endpoints are flagged),
|
|
915
|
+
* so the protected dead-straight middles are known downstream. @internal
|
|
916
|
+
*/
|
|
917
|
+
function fitClosedSpline(control, straightControl, sampleStep) {
|
|
918
|
+
const m = control.length;
|
|
919
|
+
const pts = [];
|
|
920
|
+
const straight = [];
|
|
921
|
+
const knot = (a, b) => Math.max(1e-4, Math.sqrt(Math.hypot(b[0] - a[0], b[1] - a[1])));
|
|
922
|
+
for (let i = 0; i < m; i += 1) {
|
|
923
|
+
const p0 = control[(i - 1 + m) % m];
|
|
924
|
+
const p1 = control[i];
|
|
925
|
+
const p2 = control[(i + 1) % m];
|
|
926
|
+
const p3 = control[(i + 2) % m];
|
|
927
|
+
const t0 = 0;
|
|
928
|
+
const t1 = t0 + knot(p0, p1);
|
|
929
|
+
const t2 = t1 + knot(p1, p2);
|
|
930
|
+
const t3 = t2 + knot(p2, p3);
|
|
931
|
+
const chord = Math.hypot(p2[0] - p1[0], p2[1] - p1[1]);
|
|
932
|
+
const k = Math.max(1, Math.ceil(chord / sampleStep));
|
|
933
|
+
const segStraight = straightControl[i] && straightControl[(i + 1) % m];
|
|
934
|
+
for (let s = 0; s < k; s += 1) {
|
|
935
|
+
const u = t1 + ((t2 - t1) * s) / k; // s=0 → u=t1 → exactly p1, so the spline hits every control point
|
|
936
|
+
pts.push(catmullPoint(p0, p1, p2, p3, t0, t1, t2, t3, u));
|
|
937
|
+
straight.push(segStraight);
|
|
938
|
+
}
|
|
939
|
+
}
|
|
940
|
+
return { pts, straight };
|
|
941
|
+
}
|
|
942
|
+
/** Resample a closed polyline to (near-)UNIFORM arc-length spacing `step`. Uniform spacing is what makes a
|
|
943
|
+
* narrow Laplacian curvature clamp both effective and STABLE: a densely-clustered cusp (where the raw
|
|
944
|
+
* spline crawls) otherwise defeats a narrow stencil (steps shrink with segment length) and a wide stencil
|
|
945
|
+
* reaches across the loop's neck and folds it. A sample is flagged straight only when the source segment
|
|
946
|
+
* it lands on is fully straight, so straight runs are preserved. @internal */
|
|
947
|
+
function resampleClosedUniform(pts, straight, step) {
|
|
948
|
+
const P = pts.length;
|
|
949
|
+
if (P < 4)
|
|
950
|
+
return { pts: pts.map((p) => [p[0], p[1]]), straight: straight.slice() };
|
|
951
|
+
const cum = [0];
|
|
952
|
+
let total = 0;
|
|
953
|
+
for (let i = 0; i < P; i += 1) {
|
|
954
|
+
const b = pts[(i + 1) % P];
|
|
955
|
+
total += Math.hypot(b[0] - pts[i][0], b[1] - pts[i][1]);
|
|
956
|
+
cum.push(total);
|
|
957
|
+
}
|
|
958
|
+
const count = Math.max(8, Math.round(total / Math.max(1e-3, step)));
|
|
959
|
+
const outP = [];
|
|
960
|
+
const outS = [];
|
|
961
|
+
let j = 0;
|
|
962
|
+
for (let s = 0; s < count; s += 1) {
|
|
963
|
+
const target = (total * s) / count;
|
|
964
|
+
while (j < P && cum[j + 1] < target)
|
|
965
|
+
j += 1;
|
|
966
|
+
const seg = cum[j + 1] - cum[j];
|
|
967
|
+
const f = seg > 1e-9 ? (target - cum[j]) / seg : 0;
|
|
968
|
+
const a = pts[j % P];
|
|
969
|
+
const b = pts[(j + 1) % P];
|
|
970
|
+
outP.push([a[0] + (b[0] - a[0]) * f, a[1] + (b[1] - a[1]) * f]);
|
|
971
|
+
outS.push(straight[j % P] && straight[(j + 1) % P]);
|
|
972
|
+
}
|
|
973
|
+
return { pts: outP, straight: outS };
|
|
974
|
+
}
|
|
975
|
+
/** One Jacobi Laplacian smoothing pass over a closed loop: each selected sample moves `lambda` toward the
|
|
976
|
+
* midpoint of its two immediate neighbors. Diffusion only lowers curvature. Straight runs are NOT specially
|
|
977
|
+
* protected — a collinear run is already a Laplacian fixpoint (midpoint of collinear neighbors is the point
|
|
978
|
+
* itself), so straight interiors never move, while the sample where a straight meets a curve is free to
|
|
979
|
+
* ease the junction (a frozen straight there would leave an un-openable kink). @internal */
|
|
980
|
+
function smoothLoopPass(pts, lambda, sel) {
|
|
981
|
+
const P = pts.length;
|
|
982
|
+
const nx = new Array(P);
|
|
983
|
+
const nz = new Array(P);
|
|
984
|
+
for (let i = 0; i < P; i += 1) {
|
|
985
|
+
if (!sel(i)) {
|
|
986
|
+
nx[i] = pts[i][0];
|
|
987
|
+
nz[i] = pts[i][1];
|
|
988
|
+
continue;
|
|
989
|
+
}
|
|
990
|
+
const a = pts[(i - 1 + P) % P];
|
|
991
|
+
const b = pts[(i + 1) % P];
|
|
992
|
+
nx[i] = pts[i][0] + lambda * ((a[0] + b[0]) * 0.5 - pts[i][0]);
|
|
993
|
+
nz[i] = pts[i][1] + lambda * ((a[1] + b[1]) * 0.5 - pts[i][1]);
|
|
994
|
+
}
|
|
995
|
+
for (let i = 0; i < P; i += 1)
|
|
996
|
+
pts[i] = [nx[i], nz[i]];
|
|
997
|
+
}
|
|
998
|
+
/**
|
|
999
|
+
* CURVATURE CLAMP (not a corner fillet), on a UNIFORMLY-sampled loop so a narrow stencil is stable: relax
|
|
1000
|
+
* the loop so every point respects the radius floor `minR`, preserving flow. Phase A runs a few light
|
|
1001
|
+
* global passes that smooth the small curvature steps a C1 Catmull-Rom leaves at its knots (the lap reads
|
|
1002
|
+
* as one continuous curve). Phase B iteratively Laplacian-smooths ONLY the samples whose fitted radius is
|
|
1003
|
+
* under the floor (and their immediate neighbors), so tight corners open toward ~minR while medium/large
|
|
1004
|
+
* curves, sweepers and protected straights are left alone — hairpins settle right at the floor. Diffusion
|
|
1005
|
+
* only lowers curvature, so it converges and stops the instant the floor holds. Phase C re-smooths the
|
|
1006
|
+
* seam of relaxed regions. Closed loop, no fixed endpoints. @internal
|
|
1007
|
+
*/
|
|
1008
|
+
function relaxCurvature(pts, minR) {
|
|
1009
|
+
const P = pts.length;
|
|
1010
|
+
if (P < 8)
|
|
1011
|
+
return;
|
|
1012
|
+
const radiusAt = (i) => circumRadius(pts[(i - 1 + P) % P], pts[i], pts[(i + 1) % P]);
|
|
1013
|
+
// Relax to a target comfortably ABOVE the floor so the smooth output spline re-fitted through these
|
|
1014
|
+
// points still clears `minR` where it dips between them, while still leaving the tightest corners as
|
|
1015
|
+
// legal hairpins near the floor.
|
|
1016
|
+
const floor = minR * 1.12;
|
|
1017
|
+
const violators = () => {
|
|
1018
|
+
const need = new Array(P).fill(false);
|
|
1019
|
+
let any = false;
|
|
1020
|
+
for (let i = 0; i < P; i += 1) {
|
|
1021
|
+
if (radiusAt(i) < floor) {
|
|
1022
|
+
need[i] = true;
|
|
1023
|
+
any = true;
|
|
1024
|
+
}
|
|
1025
|
+
}
|
|
1026
|
+
return any ? need : null;
|
|
1027
|
+
};
|
|
1028
|
+
for (let it = 0; it < 4; it += 1)
|
|
1029
|
+
smoothLoopPass(pts, 0.2, () => true);
|
|
1030
|
+
// Phase B — targeted: smooth each violator and its immediate neighbors until the floor holds. Because a
|
|
1031
|
+
// violator may land ON a straight sample at a straight↔curve junction, its curve-side neighbor (never a
|
|
1032
|
+
// fixpoint) is included so the junction actually eases.
|
|
1033
|
+
for (let it = 0; it < 1500; it += 1) {
|
|
1034
|
+
const need = violators();
|
|
1035
|
+
if (need === null)
|
|
1036
|
+
break;
|
|
1037
|
+
const sel = new Array(P).fill(false);
|
|
1038
|
+
for (let i = 0; i < P; i += 1) {
|
|
1039
|
+
if (need[i]) {
|
|
1040
|
+
sel[i] = true;
|
|
1041
|
+
sel[(i - 1 + P) % P] = true;
|
|
1042
|
+
sel[(i + 1) % P] = true;
|
|
1043
|
+
}
|
|
1044
|
+
}
|
|
1045
|
+
smoothLoopPass(pts, 0.5, (i) => sel[i]);
|
|
1046
|
+
if (it % 20 === 19)
|
|
1047
|
+
smoothLoopPass(pts, 0.1, () => true); // periodic global nudge frees cusps
|
|
1048
|
+
}
|
|
1049
|
+
// Phase B2 — escalating global erosion for any stubborn deep-finger cusp the targeted pass can't open
|
|
1050
|
+
// (a finger too thin to round to `minR` in place); runs only while a violator survives, so a legal lap
|
|
1051
|
+
// is untouched. Straight interiors are collinear Laplacian fixpoints, so they stay crisp under it.
|
|
1052
|
+
for (let guard = 0; guard < 400 && violators() !== null; guard += 1) {
|
|
1053
|
+
smoothLoopPass(pts, 0.25, () => true);
|
|
1054
|
+
}
|
|
1055
|
+
for (let it = 0; it < 2; it += 1)
|
|
1056
|
+
smoothLoopPass(pts, 0.12, () => true);
|
|
1057
|
+
}
|
|
1058
|
+
/** Rotate a sampled loop (and its straight flags) so index 0 is the start of the LONGEST straight run —
|
|
1059
|
+
* the start/finish straight — so distribution can emit it as a single edge. @internal */
|
|
1060
|
+
function rotateToStraight(pts, straight) {
|
|
1061
|
+
const P = pts.length;
|
|
1062
|
+
let bestStart = -1;
|
|
1063
|
+
let bestLen = 0;
|
|
1064
|
+
let k = 0;
|
|
1065
|
+
while (k < P) {
|
|
1066
|
+
if (!straight[k]) {
|
|
1067
|
+
k += 1;
|
|
1068
|
+
continue;
|
|
1069
|
+
}
|
|
1070
|
+
const start = k;
|
|
1071
|
+
let len = 0;
|
|
1072
|
+
while (k < P && straight[k]) {
|
|
1073
|
+
k += 1;
|
|
1074
|
+
len += 1;
|
|
1075
|
+
}
|
|
1076
|
+
if (len > bestLen) {
|
|
1077
|
+
bestLen = len;
|
|
1078
|
+
bestStart = start;
|
|
1079
|
+
}
|
|
1080
|
+
}
|
|
1081
|
+
// A run may wrap the seam (straight at both ends of the array) — stitch it and prefer if longer.
|
|
1082
|
+
if (P > 0 && straight[0] && straight[P - 1]) {
|
|
1083
|
+
let head = 0;
|
|
1084
|
+
while (head < P && straight[head])
|
|
1085
|
+
head += 1;
|
|
1086
|
+
let tail = 0;
|
|
1087
|
+
while (tail < P && straight[P - 1 - tail])
|
|
1088
|
+
tail += 1;
|
|
1089
|
+
if (head + tail > bestLen) {
|
|
1090
|
+
bestLen = head + tail;
|
|
1091
|
+
bestStart = P - tail;
|
|
1092
|
+
}
|
|
1093
|
+
}
|
|
1094
|
+
if (bestStart <= 0)
|
|
1095
|
+
return;
|
|
1096
|
+
const rp = pts.slice(bestStart).concat(pts.slice(0, bestStart));
|
|
1097
|
+
const rs = straight.slice(bestStart).concat(straight.slice(0, bestStart));
|
|
1098
|
+
for (let j = 0; j < P; j += 1) {
|
|
1099
|
+
pts[j] = rp[j];
|
|
1100
|
+
straight[j] = rs[j];
|
|
1101
|
+
}
|
|
1102
|
+
}
|
|
1103
|
+
/** Arc length of the longest straight run in a sampled loop (measures the start/finish straight). */
|
|
1104
|
+
function longestStraightRun(loop, straight) {
|
|
1105
|
+
const P = loop.length;
|
|
1106
|
+
if (P < 2)
|
|
1107
|
+
return 0;
|
|
1108
|
+
const segLen = (i) => Math.hypot(loop[(i + 1) % P][0] - loop[i][0], loop[(i + 1) % P][1] - loop[i][1]);
|
|
1109
|
+
let best = 0;
|
|
1110
|
+
let run = 0;
|
|
1111
|
+
for (let k = 0; k < 2 * P; k += 1) {
|
|
1112
|
+
const i = k % P;
|
|
1113
|
+
if (straight[i]) {
|
|
1114
|
+
run += segLen(i);
|
|
1115
|
+
best = Math.max(best, run);
|
|
1116
|
+
}
|
|
1117
|
+
else {
|
|
1118
|
+
run = 0;
|
|
1119
|
+
}
|
|
1120
|
+
}
|
|
1121
|
+
return best; // arc length; a seam-wrapping run is captured by the 2P sweep
|
|
1122
|
+
}
|
|
1123
|
+
/**
|
|
1124
|
+
* Distribute a sampled circuit loop into the `{nodes, edges}` graph contract: nodes land ON the spline at
|
|
1125
|
+
* every straight↔curve boundary and every ~`segmentLength` of arc inside curved runs; each straight run
|
|
1126
|
+
* stays a single edge (the deliberate straight). Every edge carries its exact sampled sub-arc, so the
|
|
1127
|
+
* curved centerline survives assembly instead of being rebuilt as a straight chord. @internal
|
|
1128
|
+
*/
|
|
1129
|
+
function distributeLoopEdges(loop, straight, segmentLength) {
|
|
1130
|
+
const P = loop.length;
|
|
1131
|
+
const isNode = new Array(P).fill(false);
|
|
1132
|
+
isNode[0] = true;
|
|
1133
|
+
for (let i = 0; i < P; i += 1)
|
|
1134
|
+
if (straight[i] !== straight[(i - 1 + P) % P])
|
|
1135
|
+
isNode[i] = true; // run boundary
|
|
1136
|
+
let acc = 0;
|
|
1137
|
+
for (let i = 1; i <= P; i += 1) {
|
|
1138
|
+
const cur = i % P;
|
|
1139
|
+
const prev = (i - 1 + P) % P;
|
|
1140
|
+
acc += Math.hypot(loop[cur][0] - loop[prev][0], loop[cur][1] - loop[prev][1]);
|
|
1141
|
+
if (isNode[cur]) {
|
|
1142
|
+
acc = 0;
|
|
1143
|
+
continue;
|
|
1144
|
+
}
|
|
1145
|
+
if (!straight[cur] && acc >= segmentLength) {
|
|
1146
|
+
isNode[cur] = true;
|
|
1147
|
+
acc = 0;
|
|
1148
|
+
}
|
|
1149
|
+
}
|
|
1150
|
+
const nodeIdx = [];
|
|
1151
|
+
for (let i = 0; i < P; i += 1)
|
|
1152
|
+
if (isNode[i])
|
|
1153
|
+
nodeIdx.push(i);
|
|
1154
|
+
const nodes = nodeIdx.map((i) => [loop[i][0], loop[i][1]]);
|
|
1155
|
+
const K = nodeIdx.length;
|
|
350
1156
|
const edges = [];
|
|
351
|
-
for (let
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
1157
|
+
for (let e = 0; e < K; e += 1) {
|
|
1158
|
+
const from = nodeIdx[e];
|
|
1159
|
+
const to = nodeIdx[(e + 1) % K];
|
|
1160
|
+
const points = [[loop[from][0], loop[from][1]]];
|
|
1161
|
+
let idx = from;
|
|
1162
|
+
do {
|
|
1163
|
+
idx = (idx + 1) % P;
|
|
1164
|
+
points.push([loop[idx][0], loop[idx][1]]);
|
|
1165
|
+
} while (idx !== to);
|
|
1166
|
+
edges.push({ a: e, b: (e + 1) % K, lane: false, points });
|
|
1167
|
+
}
|
|
1168
|
+
return { nodes, edges };
|
|
1169
|
+
}
|
|
1170
|
+
/**
|
|
1171
|
+
* The corridor PITCH for the grid-cycle layout: the world spacing between anti-parallel loop strands and
|
|
1172
|
+
* the diameter of a leaf hairpin. Sized so, AFTER the curvature floor bulges tight corners, (a) two
|
|
1173
|
+
* parallel corridors keep their centrelines ≥ ~1.5·trackWidth apart (well clear) and (b) a hairpin
|
|
1174
|
+
* U-turn around a tree leaf has radius pitch/2 ≥ minCurveRadius. The `1.2·minR` term is the
|
|
1175
|
+
* post-smoothing bulge budget; the `2.4·minR` floor guarantees the hairpin radius margin. @internal
|
|
1176
|
+
*/
|
|
1177
|
+
function gridCorridorPitch(rules) {
|
|
1178
|
+
const trackWidth = rules.width * WIDTH_MULT.avenue;
|
|
1179
|
+
const minR = Math.max(1, rules.minCurveRadius);
|
|
1180
|
+
return Math.max(2 * trackWidth + 1.2 * minR, 2.4 * minR);
|
|
1181
|
+
}
|
|
1182
|
+
/** Blob coverage fraction for the grid cycle: ~45% of cells at compactness 0.5, ~85% at 1, tapering to a
|
|
1183
|
+
* near-ring at low compactness so the dial's low end reads as an open GP track. @internal */
|
|
1184
|
+
function gridCoverage(compactness) {
|
|
1185
|
+
return Math.max(0.18, Math.min(0.9, 0.05 + compactness * 0.8));
|
|
1186
|
+
}
|
|
1187
|
+
/**
|
|
1188
|
+
* SPACE-FILLING grid-cycle LAYOUT — the `compactness` construction. Overlay a coarse grid on the usable
|
|
1189
|
+
* footprint (node spacing = 2·{@link gridCorridorPitch} so corridors clear after smoothing), grow a
|
|
1190
|
+
* seeded connected blob of cells from the centre (fraction `coverage`), build a seeded random spanning
|
|
1191
|
+
* tree over the blob with a DIRECTION-PERSISTENCE bias (longer straight corridors as compactness falls),
|
|
1192
|
+
* then trace the single closed wall-follower loop AROUND the tree (the boundary of the tree thickened by
|
|
1193
|
+
* half a corridor on each side). That loop is provably one self-avoiding cycle that folds back through
|
|
1194
|
+
* the interior: straight tree branches → straights, leaves → hairpins, parallel branches → switchback
|
|
1195
|
+
* esses. Its rectilinear corners (collinear runs collapsed, small seeded jitter to de-grid) are returned
|
|
1196
|
+
* as CONTROL POINTS for the same curve-first centerline pipeline the hull layout feeds. Returns null on a
|
|
1197
|
+
* degenerate footprint or blob. @internal
|
|
1198
|
+
*/
|
|
1199
|
+
function buildGridCycleControl(rules, hx, hz, rng, compactness, coverage) {
|
|
1200
|
+
const pitch = gridCorridorPitch(rules);
|
|
1201
|
+
// Keep the ENTIRE loop (its corner span plus the spline's convex overshoot) clear of the footprint rim
|
|
1202
|
+
// so the final centerline's footprint clamp never flattens an arc into a sharp corner (which would
|
|
1203
|
+
// defeat the curvature floor). The corner half-span for N node columns is (N − 0.5)·pitch, so the
|
|
1204
|
+
// largest N with (N − 0.5)·pitch ≤ margin·hx is floor(margin·hx/pitch + 0.5).
|
|
1205
|
+
const margin = 0.84;
|
|
1206
|
+
const N = Math.max(2, Math.min(12, Math.floor((margin * hx) / pitch + 0.5)));
|
|
1207
|
+
const M = Math.max(2, Math.min(12, Math.floor((margin * hz) / pitch + 0.5)));
|
|
1208
|
+
if (N < 2 || M < 2)
|
|
1209
|
+
return null;
|
|
1210
|
+
const total = N * M;
|
|
1211
|
+
const dirs = [
|
|
1212
|
+
[1, 0],
|
|
1213
|
+
[-1, 0],
|
|
1214
|
+
[0, 1],
|
|
1215
|
+
[0, -1],
|
|
1216
|
+
];
|
|
1217
|
+
const cellId = (i, j) => j * N + i;
|
|
1218
|
+
const inGrid = (i, j) => i >= 0 && i < N && j >= 0 && j < M;
|
|
1219
|
+
const ci = Math.floor(N / 2);
|
|
1220
|
+
const cj = Math.floor(M / 2);
|
|
1221
|
+
// 1. Blob growth — a connected random cell subset from the centre cell.
|
|
1222
|
+
const target = Math.max(3, Math.min(total, Math.round(Math.max(0.18, Math.min(0.9, coverage)) * total)));
|
|
1223
|
+
const selected = new Set([cellId(ci, cj)]);
|
|
1224
|
+
let guard = 0;
|
|
1225
|
+
while (selected.size < target && guard < total * 12) {
|
|
1226
|
+
guard += 1;
|
|
1227
|
+
const arr = Array.from(selected);
|
|
1228
|
+
const from = arr[Math.floor(rng() * arr.length)];
|
|
1229
|
+
const d = dirs[Math.floor(rng() * 4)];
|
|
1230
|
+
const ni = (from % N) + d[0];
|
|
1231
|
+
const nj = Math.floor(from / N) + d[1];
|
|
1232
|
+
if (!inGrid(ni, nj))
|
|
1233
|
+
continue;
|
|
1234
|
+
selected.add(cellId(ni, nj));
|
|
1235
|
+
}
|
|
1236
|
+
if (selected.size < 3)
|
|
1237
|
+
return null;
|
|
1238
|
+
// 2. Direction-biased random spanning tree (recursive-backtracker DFS). Persisting the entry direction
|
|
1239
|
+
// with probability `pPersist` (higher as compactness falls) grows long straight corridors.
|
|
1240
|
+
const start = cellId(ci, cj);
|
|
1241
|
+
const visited = new Set([start]);
|
|
1242
|
+
const enterDir = new Map();
|
|
1243
|
+
const treeEdges = [];
|
|
1244
|
+
const stack = [start];
|
|
1245
|
+
// Direction-persistence probability: high everywhere so corridors run straight for a while (real
|
|
1246
|
+
// straights survive), and higher still as compactness falls so low-compactness laps read as open GP
|
|
1247
|
+
// tracks with a couple of interior excursions rather than a dense maze.
|
|
1248
|
+
const pPersist = 0.55 + (1 - compactness) * 0.35;
|
|
1249
|
+
while (stack.length > 0) {
|
|
1250
|
+
const cur = stack[stack.length - 1];
|
|
1251
|
+
const cfi = cur % N;
|
|
1252
|
+
const cfj = Math.floor(cur / N);
|
|
1253
|
+
const opts = [];
|
|
1254
|
+
for (let d = 0; d < 4; d += 1) {
|
|
1255
|
+
const ni = cfi + dirs[d][0];
|
|
1256
|
+
const nj = cfj + dirs[d][1];
|
|
1257
|
+
if (!inGrid(ni, nj))
|
|
1258
|
+
continue;
|
|
1259
|
+
const nc = cellId(ni, nj);
|
|
1260
|
+
if (!selected.has(nc) || visited.has(nc))
|
|
1261
|
+
continue;
|
|
1262
|
+
opts.push({ cell: nc, dir: d });
|
|
1263
|
+
}
|
|
1264
|
+
if (opts.length === 0) {
|
|
1265
|
+
stack.pop();
|
|
1266
|
+
continue;
|
|
1267
|
+
}
|
|
1268
|
+
let choice = opts[Math.floor(rng() * opts.length)];
|
|
1269
|
+
const pref = enterDir.get(cur);
|
|
1270
|
+
if (pref !== undefined) {
|
|
1271
|
+
const straightOpt = opts.find((o) => o.dir === pref);
|
|
1272
|
+
if (straightOpt !== undefined && rng() < pPersist)
|
|
1273
|
+
choice = straightOpt;
|
|
1274
|
+
}
|
|
1275
|
+
visited.add(choice.cell);
|
|
1276
|
+
enterDir.set(choice.cell, choice.dir);
|
|
1277
|
+
treeEdges.push([cur, choice.cell]);
|
|
1278
|
+
stack.push(choice.cell);
|
|
1279
|
+
}
|
|
1280
|
+
// 3. Thin-tree occupancy on the DOUBLED grid: node cell (i,j) → pixel (2i,2j); a tree edge → the pixel
|
|
1281
|
+
// between its endpoints. No pixel ever lands on (odd,odd), so the set has no 2×2 block (no ambiguous
|
|
1282
|
+
// saddle) and, being a tree, no hole — its boundary is a single clean rectilinear loop.
|
|
1283
|
+
const W = 2 * N - 1;
|
|
1284
|
+
const H = 2 * M - 1;
|
|
1285
|
+
const occ = new Set();
|
|
1286
|
+
const pix = (x, y) => y * W + x;
|
|
1287
|
+
const has = (x, y) => x >= 0 && x < W && y >= 0 && y < H && occ.has(pix(x, y));
|
|
1288
|
+
for (const c of visited)
|
|
1289
|
+
occ.add(pix(2 * (c % N), 2 * Math.floor(c / N)));
|
|
1290
|
+
for (const [a, b] of treeEdges) {
|
|
1291
|
+
occ.add(pix((a % N) + (b % N), Math.floor(a / N) + Math.floor(b / N)));
|
|
1292
|
+
}
|
|
1293
|
+
// 4. Trace the boundary as directed unit edges with the occupied region on the LEFT (→ CCW), keyed by
|
|
1294
|
+
// start corner. Non-saddle thin region ⇒ the map is a permutation ⇒ chaining yields one closed loop.
|
|
1295
|
+
const nextC = new Map();
|
|
1296
|
+
const ckey = (x, y) => y * (W + 2) + x;
|
|
1297
|
+
let sx = Infinity;
|
|
1298
|
+
let sy = Infinity;
|
|
1299
|
+
for (const p of occ) {
|
|
1300
|
+
const x = p % W;
|
|
1301
|
+
const y = Math.floor(p / W);
|
|
1302
|
+
if (!has(x, y - 1))
|
|
1303
|
+
nextC.set(ckey(x, y), [x + 1, y]);
|
|
1304
|
+
if (!has(x + 1, y))
|
|
1305
|
+
nextC.set(ckey(x + 1, y), [x + 1, y + 1]);
|
|
1306
|
+
if (!has(x, y + 1))
|
|
1307
|
+
nextC.set(ckey(x + 1, y + 1), [x, y + 1]);
|
|
1308
|
+
if (!has(x - 1, y))
|
|
1309
|
+
nextC.set(ckey(x, y + 1), [x, y]);
|
|
1310
|
+
if (y < sy || (y === sy && x < sx)) {
|
|
1311
|
+
sx = x;
|
|
1312
|
+
sy = y;
|
|
1313
|
+
}
|
|
1314
|
+
}
|
|
1315
|
+
if (!Number.isFinite(sx))
|
|
1316
|
+
return null;
|
|
1317
|
+
const trace = [];
|
|
1318
|
+
let curX = sx;
|
|
1319
|
+
let curY = sy;
|
|
1320
|
+
let steps = 0;
|
|
1321
|
+
const limit = (W + 2) * (H + 2) * 4;
|
|
1322
|
+
do {
|
|
1323
|
+
trace.push([curX, curY]);
|
|
1324
|
+
const nx = nextC.get(ckey(curX, curY));
|
|
1325
|
+
if (nx === undefined)
|
|
1326
|
+
break;
|
|
1327
|
+
curX = nx[0];
|
|
1328
|
+
curY = nx[1];
|
|
1329
|
+
steps += 1;
|
|
1330
|
+
} while ((curX !== sx || curY !== sy) && steps < limit);
|
|
1331
|
+
if (trace.length < 4)
|
|
1332
|
+
return null;
|
|
1333
|
+
// 5. Keep only turn corners (collapse collinear runs so straights survive as one edge), map to world,
|
|
1334
|
+
// and add a small seeded jitter (well under a corridor pitch) to de-grid the look.
|
|
1335
|
+
const T = trace.length;
|
|
1336
|
+
const halfX = (2 * N - 1) / 2;
|
|
1337
|
+
const halfY = (2 * M - 1) / 2;
|
|
1338
|
+
const jAmp = compactness * pitch * 0.06;
|
|
1339
|
+
const out = [];
|
|
1340
|
+
for (let i = 0; i < T; i += 1) {
|
|
1341
|
+
const prev = trace[(i - 1 + T) % T];
|
|
1342
|
+
const c = trace[i];
|
|
1343
|
+
const nxt = trace[(i + 1) % T];
|
|
1344
|
+
const cross = (c[0] - prev[0]) * (nxt[1] - c[1]) - (c[1] - prev[1]) * (nxt[0] - c[0]);
|
|
1345
|
+
if (cross === 0)
|
|
1346
|
+
continue; // collinear pass-through — drop it, keeping the straight run one edge
|
|
1347
|
+
const jx = jAmp > 0 ? (rng() - 0.5) * 2 * jAmp : 0;
|
|
1348
|
+
const jz = jAmp > 0 ? (rng() - 0.5) * 2 * jAmp : 0;
|
|
1349
|
+
const wx = (c[0] - halfX) * pitch + jx;
|
|
1350
|
+
const wz = (c[1] - halfY) * pitch + jz;
|
|
1351
|
+
out.push([Math.max(-hx + 1, Math.min(hx - 1, wx)), Math.max(-hz + 1, Math.min(hz - 1, wz))]);
|
|
1352
|
+
}
|
|
1353
|
+
return out.length >= 6 ? out : null;
|
|
1354
|
+
}
|
|
1355
|
+
/**
|
|
1356
|
+
* Grow a closed race CIRCUIT as a CURVE-FIRST centerline, not a filleted polygon. The global layout
|
|
1357
|
+
* synthesis (hull + deep inward displacement + control-point corner templates) is kept, but its polygon
|
|
1358
|
+
* is treated as CONTROL POINTS: 1–3 edges are collapsed onto lines as deliberate straights, then a
|
|
1359
|
+
* periodic centripetal Catmull-Rom spline is fitted through the points and sampled densely, and a
|
|
1360
|
+
* curvature CLAMP relaxes any spot tighter than `minCurveRadius` — so the lap is MOSTLY continuous curve
|
|
1361
|
+
* (long sweepers, flowing esses, parabolic entries) with straights as exceptions, instead of straight
|
|
1362
|
+
* chords joined by small corner caps. Self-clearance is checked on the final sampled spline with the same
|
|
1363
|
+
* bounded, deterministic reject-and-retry (decaying fold aggression, safe fallback). The sampled loop is
|
|
1364
|
+
* distributed into many node-to-node edges (nodes ON the spline), then a lane-level pit spur leaves and
|
|
1365
|
+
* rejoins the loop beside the start/finish straight.
|
|
1366
|
+
*/
|
|
1367
|
+
function buildCircuit(rules, hx, hz, streams) {
|
|
1368
|
+
const trackWidth = rules.width * WIDTH_MULT.avenue;
|
|
1369
|
+
const clearance = trackWidth * 1.5;
|
|
1370
|
+
const gapMin = trackWidth * 5; // arc-length beyond which two nearby segments count as a fold-back
|
|
1371
|
+
const axisExtent = Math.max(hx, hz) * 2 * 0.9;
|
|
1372
|
+
const straightMin = axisExtent * 0.18;
|
|
1373
|
+
const minR = Math.max(1, rules.minCurveRadius);
|
|
1374
|
+
// Sample fine enough that, once the clamp guarantees radius ≥ minR, no discrete turn between samples
|
|
1375
|
+
// exceeds ~6° (step/minR ≤ 0.10 rad). The 8 m cap only lowers the ratio for large-radius tracks.
|
|
1376
|
+
const sampleStep = Math.min(minR * 0.1, 8);
|
|
1377
|
+
// Fit a smooth curve-first centerline from a folded control polygon and floor its curvature.
|
|
1378
|
+
const centerline = (control, rng) => {
|
|
1379
|
+
const ded = dedupeRing(control);
|
|
1380
|
+
if (ded.length < 4)
|
|
1381
|
+
return null;
|
|
1382
|
+
const designated = designateStraights(ded, rules, rng);
|
|
1383
|
+
// Two-stage smoothing so the curvature clamp is fast AND the output is finely, continuously sampled:
|
|
1384
|
+
// (1) fit a dense spline and resample to a COARSE uniform working loop (~minR/2 spacing) — coarse
|
|
1385
|
+
// spacing lets a stable narrow Laplacian clamp converge quickly (fine spacing makes each pass a
|
|
1386
|
+
// sagitta-sized crumb); (2) relax that loop to the radius floor; (3) re-fit a dense spline through the
|
|
1387
|
+
// relaxed points for the smooth, ≤~6°/sample output centerline.
|
|
1388
|
+
const fitted = fitClosedSpline(designated.pts, designated.straight, sampleStep * 0.4);
|
|
1389
|
+
if (fitted.pts.length < 8)
|
|
1390
|
+
return null;
|
|
1391
|
+
const coarse = resampleClosedUniform(fitted.pts, fitted.straight, Math.max(sampleStep, minR * 0.4));
|
|
1392
|
+
if (coarse.pts.length < 8)
|
|
1393
|
+
return null;
|
|
1394
|
+
relaxCurvature(coarse.pts, minR);
|
|
1395
|
+
const out = fitClosedSpline(coarse.pts, coarse.straight, sampleStep);
|
|
1396
|
+
const loop = out.pts;
|
|
1397
|
+
const straight = out.straight;
|
|
1398
|
+
if (loop.length < 8)
|
|
1399
|
+
return null;
|
|
1400
|
+
// A few light narrow passes iron out the small curvature STEP a C1 Catmull-Rom leaves at each knot, so
|
|
1401
|
+
// curvature reads continuous ("flows"), not corner-capped — at dense spacing this barely moves points
|
|
1402
|
+
// and only lowers curvature, so the radius floor still holds and straights (collinear fixpoints) stay
|
|
1403
|
+
// crisp.
|
|
1404
|
+
for (let it = 0; it < 8; it += 1)
|
|
1405
|
+
smoothLoopPass(loop, 0.1, () => true);
|
|
1406
|
+
// Safety lift: if the re-fit's C1 overshoot left any sample just under the floor, ease those samples
|
|
1407
|
+
// (and their neighbors) until the floor holds with margin. Mild + local, so it converges quickly and
|
|
1408
|
+
// leaves the flowing shape intact.
|
|
1409
|
+
const liftFloor = minR * 0.98;
|
|
1410
|
+
for (let it = 0; it < 250; it += 1) {
|
|
1411
|
+
const sel = new Array(loop.length).fill(false);
|
|
1412
|
+
let any = false;
|
|
1413
|
+
for (let i = 0; i < loop.length; i += 1) {
|
|
1414
|
+
const R = circumRadius(loop[(i - 1 + loop.length) % loop.length], loop[i], loop[(i + 1) % loop.length]);
|
|
1415
|
+
if (R < liftFloor) {
|
|
1416
|
+
sel[i] = sel[(i - 1 + loop.length) % loop.length] = sel[(i + 1) % loop.length] = true;
|
|
1417
|
+
any = true;
|
|
1418
|
+
}
|
|
1419
|
+
}
|
|
1420
|
+
if (!any)
|
|
1421
|
+
break;
|
|
1422
|
+
smoothLoopPass(loop, 0.5, (i) => sel[i]);
|
|
1423
|
+
}
|
|
1424
|
+
// Bounded global fallback for a stubborn residual dip at small minR / dense spacing (targeted passes
|
|
1425
|
+
// crawl there); runs only while a sub-floor sample survives, so a clean lap is untouched.
|
|
1426
|
+
const stillUnder = () => {
|
|
1427
|
+
for (let i = 0; i < loop.length; i += 1) {
|
|
1428
|
+
if (circumRadius(loop[(i - 1 + loop.length) % loop.length], loop[i], loop[(i + 1) % loop.length]) < liftFloor)
|
|
1429
|
+
return true;
|
|
1430
|
+
}
|
|
1431
|
+
return false;
|
|
1432
|
+
};
|
|
1433
|
+
for (let guard = 0; guard < 120 && stillUnder(); guard += 1)
|
|
1434
|
+
smoothLoopPass(loop, 0.15, () => true);
|
|
1435
|
+
// Keep the folded lobes inside the footprint (the clamp only contracts, but a fold can still touch the rim).
|
|
1436
|
+
for (let i = 0; i < loop.length; i += 1) {
|
|
1437
|
+
loop[i] = [Math.max(-hx + 1, Math.min(hx - 1, loop[i][0])), Math.max(-hz + 1, Math.min(hz - 1, loop[i][1]))];
|
|
1438
|
+
}
|
|
1439
|
+
return { loop, straight };
|
|
1440
|
+
};
|
|
1441
|
+
// The `compactness` dial swaps the LAYOUT source: 0 keeps the hull synthesis (byte-identical), rising
|
|
1442
|
+
// values switch to the space-filling grid spanning-tree cycle. Everything below (spline fit, curvature
|
|
1443
|
+
// floor, straights, clearance, edges) is shared verbatim so only the control-polygon source changes.
|
|
1444
|
+
const compactness = Math.max(0, Math.min(1, rules.compactness ?? 0));
|
|
1445
|
+
const baseCoverage = gridCoverage(compactness);
|
|
1446
|
+
// A space-filling grid lap should still carry a deliberate main straight of ~3 corridor pitches (a
|
|
1447
|
+
// straight tree branch) alongside the base axis-fraction floor.
|
|
1448
|
+
const compactStraightGate = compactness > 0 ? Math.max(straightMin, 3 * gridCorridorPitch(rules)) : straightMin;
|
|
1449
|
+
let chosen = null;
|
|
1450
|
+
// Fallback compact candidate (the FIRST clearing one, i.e. the densest / most space-filling, since
|
|
1451
|
+
// coverage only shrinks over attempts): used if no attempt also lands the preferred main straight, so a
|
|
1452
|
+
// seed that never grows a long straight still keeps a folded lap instead of collapsing to the hull.
|
|
1453
|
+
let denseCompact = null;
|
|
1454
|
+
// Each compact attempt runs the full dense curvature-floored centerline, so cap retries tighter than the
|
|
1455
|
+
// hull path; taking the densest clearing candidate with a real straight usually stops in 1–3 attempts.
|
|
1456
|
+
const maxTries = compactness > 0 ? 8 : MAX_CIRCUIT_TRIES;
|
|
1457
|
+
// A densely-folded compact candidate can clear spatially yet still hide a tight spot the bounded
|
|
1458
|
+
// curvature-floor relaxation couldn't fully open; reject those so an accepted lap always holds the floor.
|
|
1459
|
+
const floorHolds = (loop) => {
|
|
1460
|
+
const L = loop.length;
|
|
1461
|
+
for (let i = 0; i < L; i += 1) {
|
|
1462
|
+
if (circumRadius(loop[(i - 1 + L) % L], loop[i], loop[(i + 1) % L]) < minR * 0.95)
|
|
1463
|
+
return false;
|
|
1464
|
+
}
|
|
1465
|
+
return true;
|
|
1466
|
+
};
|
|
1467
|
+
for (let attempt = 0; attempt < maxTries; attempt += 1) {
|
|
1468
|
+
if (compactness > 0) {
|
|
1469
|
+
// Shrink blob coverage gently per retry so a curvature-floor clearance violation resolves
|
|
1470
|
+
// deterministically (a sparser grid folds less tightly) without sacrificing space-filling density.
|
|
1471
|
+
const coverage = Math.max(0.4, baseCoverage - attempt * 0.03);
|
|
1472
|
+
const candidate = buildGridCycleControl(rules, hx, hz, streams(`circuit:grid:${attempt}`), compactness, coverage);
|
|
1473
|
+
if (candidate === null || candidate.length < 6)
|
|
1474
|
+
continue;
|
|
1475
|
+
const cl = centerline(candidate, streams(`circuit:grid-straight:${attempt}`));
|
|
1476
|
+
if (cl === null)
|
|
1477
|
+
continue;
|
|
1478
|
+
if (!loopSelfClearing(cl.loop, clearance, gapMin))
|
|
1479
|
+
continue;
|
|
1480
|
+
if (!floorHolds(cl.loop))
|
|
1481
|
+
continue; // curvature floor must hold on the final spline
|
|
1482
|
+
const sr = longestStraightRun(cl.loop, cl.straight);
|
|
1483
|
+
if (sr < straightMin)
|
|
1484
|
+
continue; // no readable straight at all — reject
|
|
1485
|
+
if (denseCompact === null)
|
|
1486
|
+
denseCompact = cl; // first clearing candidate = densest
|
|
1487
|
+
if (sr >= compactStraightGate) {
|
|
1488
|
+
chosen = cl; // densest clearing lap that also lands the preferred main straight
|
|
1489
|
+
break;
|
|
1490
|
+
}
|
|
1491
|
+
continue;
|
|
1492
|
+
}
|
|
1493
|
+
// Fold aggression decays over retries: early attempts push deep folds (a cool, folded circuit);
|
|
1494
|
+
// if none clear, later attempts relax toward a gentler layout so the safe fallback stays rare.
|
|
1495
|
+
const aggression = Math.max(0.42, 1 - attempt * 0.075);
|
|
1496
|
+
const candidate = synthTrack(rules, hx, hz, streams(`circuit:try:${attempt}`), aggression);
|
|
1497
|
+
if (candidate === null || candidate.length < 6)
|
|
1498
|
+
continue;
|
|
1499
|
+
const cl = centerline(candidate, streams(`circuit:straight:${attempt}`));
|
|
1500
|
+
if (cl === null)
|
|
1501
|
+
continue;
|
|
1502
|
+
if (longestStraightRun(cl.loop, cl.straight) < straightMin)
|
|
1503
|
+
continue;
|
|
1504
|
+
if (!loopSelfClearing(cl.loop, clearance, gapMin))
|
|
1505
|
+
continue;
|
|
1506
|
+
chosen = cl;
|
|
1507
|
+
break;
|
|
1508
|
+
}
|
|
1509
|
+
// Keep the densest clearing space-filling lap when none also met the preferred main-straight length;
|
|
1510
|
+
// only the total absence of a clearing compact candidate drops through to the hull fallback below.
|
|
1511
|
+
if (chosen === null && denseCompact !== null)
|
|
1512
|
+
chosen = denseCompact;
|
|
1513
|
+
if (chosen === null)
|
|
1514
|
+
chosen = centerline(fallbackTrack(rules, hx, hz), streams("circuit:fallback"));
|
|
1515
|
+
if (chosen === null) {
|
|
1516
|
+
// Degenerate footprint — a bare inset ellipse keeps the loop contract alive.
|
|
1517
|
+
const fb = fallbackTrack(rules, hx, hz);
|
|
1518
|
+
chosen = { loop: fb, straight: new Array(fb.length).fill(false) };
|
|
1519
|
+
}
|
|
1520
|
+
const loop = chosen.loop;
|
|
1521
|
+
const straight = chosen.straight;
|
|
1522
|
+
// Orient CCW for a stable outward pit normal, then park index 0 on the start/finish straight.
|
|
1523
|
+
if (signedArea(loop) < 0) {
|
|
1524
|
+
loop.reverse();
|
|
1525
|
+
straight.reverse();
|
|
1526
|
+
}
|
|
1527
|
+
rotateToStraight(loop, straight);
|
|
1528
|
+
const { nodes, edges } = distributeLoopEdges(loop, straight, rules.segmentLength);
|
|
1529
|
+
const k = nodes.length;
|
|
1530
|
+
// Pit lane: a lane-level chain that leaves the loop one node before the start/finish straight and
|
|
1531
|
+
// rejoins one node after it — two degree-3 junctions on the loop, never a dead-end stub.
|
|
1532
|
+
if (rules.branching > 0.25 && k >= 4 && nodes.length + 2 <= MAX_NODES) {
|
|
1533
|
+
const pitRng = streams("circuit:pit");
|
|
1534
|
+
const si = longestEdge(nodes).index;
|
|
1535
|
+
const leaveIdx = (si - 1 + k) % k;
|
|
1536
|
+
const rejoinIdx = (si + 2) % k;
|
|
1537
|
+
if (leaveIdx !== rejoinIdx && leaveIdx !== si && rejoinIdx !== (si + 1) % k) {
|
|
1538
|
+
const s0 = nodes[si];
|
|
1539
|
+
const s1 = nodes[(si + 1) % k];
|
|
1540
|
+
const dx = s1[0] - s0[0];
|
|
1541
|
+
const dz = s1[1] - s0[1];
|
|
1542
|
+
const l = Math.hypot(dx, dz) || 1;
|
|
1543
|
+
// Outward normal (away from the loop centroid) so the pit lane sits beside the main straight.
|
|
1544
|
+
let nx = -dz / l;
|
|
1545
|
+
let nz = dx / l;
|
|
1546
|
+
const mx = (s0[0] + s1[0]) / 2;
|
|
1547
|
+
const mz = (s0[1] + s1[1]) / 2;
|
|
1548
|
+
if (mx * nx + mz * nz < 0) {
|
|
1549
|
+
nx = -nx;
|
|
1550
|
+
nz = -nz;
|
|
1551
|
+
}
|
|
1552
|
+
const off = trackWidth * (1.8 + pitRng() * 0.6);
|
|
1553
|
+
const leave = nodes[leaveIdx];
|
|
1554
|
+
const rejoin = nodes[rejoinIdx];
|
|
1555
|
+
const pitA = [leave[0] + nx * off, leave[1] + nz * off];
|
|
1556
|
+
const pitB = [rejoin[0] + nx * off, rejoin[1] + nz * off];
|
|
1557
|
+
if (Math.abs(pitA[0]) < hx - 1 &&
|
|
1558
|
+
Math.abs(pitA[1]) < hz - 1 &&
|
|
1559
|
+
Math.abs(pitB[0]) < hx - 1 &&
|
|
1560
|
+
Math.abs(pitB[1]) < hz - 1) {
|
|
1561
|
+
const idA = nodes.length;
|
|
1562
|
+
nodes.push(pitA);
|
|
1563
|
+
const idB = nodes.length;
|
|
1564
|
+
nodes.push(pitB);
|
|
1565
|
+
edges.push({ a: leaveIdx, b: idA, lane: true });
|
|
1566
|
+
edges.push({ a: idA, b: idB, lane: true });
|
|
1567
|
+
edges.push({ a: idB, b: rejoinIdx, lane: true });
|
|
1568
|
+
}
|
|
365
1569
|
}
|
|
366
1570
|
}
|
|
367
1571
|
return { nodes, edges };
|
|
@@ -373,34 +1577,421 @@ export function streetNetworkMode(rules) {
|
|
|
373
1577
|
const score = rules.loopiness * (1 - rules.branching * 0.7) * (1 - rules.connectivity * 0.7);
|
|
374
1578
|
return score >= 0.45 ? "circuit" : "net";
|
|
375
1579
|
}
|
|
376
|
-
/**
|
|
377
|
-
*
|
|
378
|
-
function
|
|
379
|
-
const
|
|
380
|
-
|
|
381
|
-
|
|
1580
|
+
/** Sampled-source shortest-path (Brandes) node betweenness over the unweighted node graph. Sources are
|
|
1581
|
+
* a deterministic even sample of at most {@link BETWEENNESS_SOURCES} nodes so the work stays bounded. */
|
|
1582
|
+
function nodeBetweenness(n, adjNodes) {
|
|
1583
|
+
const bt = new Array(n).fill(0);
|
|
1584
|
+
if (n === 0)
|
|
1585
|
+
return bt;
|
|
1586
|
+
const sources = [];
|
|
1587
|
+
const stride = Math.max(1, Math.ceil(n / BETWEENNESS_SOURCES));
|
|
1588
|
+
for (let s = 0; s < n; s += stride)
|
|
1589
|
+
sources.push(s);
|
|
1590
|
+
const dist = new Array(n);
|
|
1591
|
+
const sigma = new Array(n);
|
|
1592
|
+
const delta = new Array(n);
|
|
1593
|
+
const preds = Array.from({ length: n }, () => []);
|
|
1594
|
+
const queue = new Array(n);
|
|
1595
|
+
const stack = new Array(n);
|
|
1596
|
+
for (const src of sources) {
|
|
1597
|
+
for (let i = 0; i < n; i += 1) {
|
|
1598
|
+
dist[i] = -1;
|
|
1599
|
+
sigma[i] = 0;
|
|
1600
|
+
delta[i] = 0;
|
|
1601
|
+
preds[i].length = 0;
|
|
1602
|
+
}
|
|
1603
|
+
dist[src] = 0;
|
|
1604
|
+
sigma[src] = 1;
|
|
1605
|
+
let head = 0;
|
|
1606
|
+
let tail = 0;
|
|
1607
|
+
let top = 0;
|
|
1608
|
+
queue[tail++] = src;
|
|
1609
|
+
while (head < tail) {
|
|
1610
|
+
const v = queue[head++];
|
|
1611
|
+
stack[top++] = v;
|
|
1612
|
+
for (const w of adjNodes[v]) {
|
|
1613
|
+
if (dist[w] < 0) {
|
|
1614
|
+
dist[w] = dist[v] + 1;
|
|
1615
|
+
queue[tail++] = w;
|
|
1616
|
+
}
|
|
1617
|
+
if (dist[w] === dist[v] + 1) {
|
|
1618
|
+
sigma[w] += sigma[v];
|
|
1619
|
+
preds[w].push(v);
|
|
1620
|
+
}
|
|
1621
|
+
}
|
|
1622
|
+
}
|
|
1623
|
+
while (top > 0) {
|
|
1624
|
+
const w = stack[--top];
|
|
1625
|
+
for (const v of preds[w]) {
|
|
1626
|
+
delta[v] += (sigma[v] / sigma[w]) * (1 + delta[w]);
|
|
1627
|
+
}
|
|
1628
|
+
if (w !== src)
|
|
1629
|
+
bt[w] += delta[w];
|
|
1630
|
+
}
|
|
1631
|
+
}
|
|
1632
|
+
return bt;
|
|
1633
|
+
}
|
|
1634
|
+
/**
|
|
1635
|
+
* Assign the road hierarchy STRUCTURALLY: spurs are lanes; a high-centrality connected skeleton
|
|
1636
|
+
* (arteries) becomes avenues/boulevards; everything else is a local street. Arterial chains never
|
|
1637
|
+
* dead-end at an interior junction whose other roads are all lower level — such a chain is extended
|
|
1638
|
+
* through the most-central neighbor or demoted. A `boulevards` share of arteries upgrade to boulevard.
|
|
1639
|
+
*/
|
|
1640
|
+
function levelForStreets(chains, rules, rng, ctx) {
|
|
382
1641
|
const levels = new Array(chains.length).fill("street");
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
1642
|
+
// Centrality score per non-lane chain: sum of endpoint-node betweenness weighted by chain length.
|
|
1643
|
+
const score = chains.map((c) => {
|
|
1644
|
+
if (c.lane)
|
|
1645
|
+
return -1;
|
|
1646
|
+
let s = 0;
|
|
1647
|
+
for (const nid of c.nodes)
|
|
1648
|
+
s += ctx.bt[nid] ?? 0;
|
|
1649
|
+
return s * Math.max(1, c.length);
|
|
1650
|
+
});
|
|
1651
|
+
const nonLane = chains.map((_, i) => i).filter((i) => !chains[i].lane);
|
|
1652
|
+
const arterial = new Set();
|
|
1653
|
+
const ranked = nonLane.slice().sort((a, b) => score[b] - score[a]);
|
|
1654
|
+
const arterialCut = Math.max(1, Math.ceil(nonLane.length * 0.28));
|
|
1655
|
+
for (let r = 0; r < ranked.length && arterial.size < arterialCut; r += 1)
|
|
1656
|
+
arterial.add(ranked[r]);
|
|
1657
|
+
// A wide road must never stub out mid-district: an arterial chain whose end dangles at an
|
|
1658
|
+
// INTERIOR degree-1 node is demoted to a local street — cul-de-sacs are fine for streets and
|
|
1659
|
+
// lanes, but a boulevard/avenue that just stops mid-map reads as broken pavement. Rim endpoints
|
|
1660
|
+
// stay arterial (the road exits the district).
|
|
1661
|
+
const interiorDeadEnd = (ci) => {
|
|
1662
|
+
const chain = chains[ci];
|
|
1663
|
+
if (chain.loop)
|
|
1664
|
+
return false;
|
|
1665
|
+
for (const endNode of [chain.nodes[0], chain.nodes[chain.nodes.length - 1]]) {
|
|
1666
|
+
if ((ctx.degree[endNode] ?? 0) <= 1 && !ctx.isRim(endNode))
|
|
1667
|
+
return true;
|
|
1668
|
+
}
|
|
1669
|
+
return false;
|
|
1670
|
+
};
|
|
1671
|
+
// Connectivity repair: an arterial chain must not terminate at an interior junction whose other
|
|
1672
|
+
// chains are all non-arterial, and must not dead-end at an interior node. Extend through the
|
|
1673
|
+
// most-central non-stub neighbor, or demote when impossible.
|
|
1674
|
+
const bestNeighborAt = (node, self) => {
|
|
1675
|
+
const here = ctx.chainsAt.get(node) ?? [];
|
|
1676
|
+
let best = -1;
|
|
1677
|
+
let bestScore = -Infinity;
|
|
1678
|
+
for (const ci of here) {
|
|
1679
|
+
if (ci === self || chains[ci].lane || arterial.has(ci) || interiorDeadEnd(ci))
|
|
1680
|
+
continue;
|
|
1681
|
+
if (score[ci] > bestScore) {
|
|
1682
|
+
bestScore = score[ci];
|
|
1683
|
+
best = ci;
|
|
1684
|
+
}
|
|
389
1685
|
}
|
|
390
|
-
|
|
391
|
-
|
|
1686
|
+
return best;
|
|
1687
|
+
};
|
|
1688
|
+
let guard = 0;
|
|
1689
|
+
const maxGuard = chains.length * 4 + 8;
|
|
1690
|
+
let changed = true;
|
|
1691
|
+
while (changed && guard < maxGuard) {
|
|
1692
|
+
changed = false;
|
|
1693
|
+
guard += 1;
|
|
1694
|
+
for (const ci of Array.from(arterial)) {
|
|
1695
|
+
const chain = chains[ci];
|
|
1696
|
+
if (chain.loop)
|
|
1697
|
+
continue; // a closed ring has no dangling terminus
|
|
1698
|
+
if (interiorDeadEnd(ci)) {
|
|
1699
|
+
arterial.delete(ci);
|
|
1700
|
+
changed = true;
|
|
1701
|
+
continue;
|
|
1702
|
+
}
|
|
1703
|
+
for (const endNode of [chain.nodes[0], chain.nodes[chain.nodes.length - 1]]) {
|
|
1704
|
+
if (ctx.isRim(endNode))
|
|
1705
|
+
continue; // rim endpoints are fine
|
|
1706
|
+
if ((ctx.degree[endNode] ?? 0) < 2)
|
|
1707
|
+
continue; // unreachable for arterials (demoted above)
|
|
1708
|
+
const here = ctx.chainsAt.get(endNode) ?? [];
|
|
1709
|
+
const connected = here.some((other) => other !== ci && arterial.has(other));
|
|
1710
|
+
if (connected)
|
|
1711
|
+
continue;
|
|
1712
|
+
const ext = bestNeighborAt(endNode, ci);
|
|
1713
|
+
if (ext >= 0) {
|
|
1714
|
+
arterial.add(ext);
|
|
1715
|
+
changed = true;
|
|
1716
|
+
}
|
|
1717
|
+
else {
|
|
1718
|
+
arterial.delete(ci);
|
|
1719
|
+
changed = true;
|
|
1720
|
+
}
|
|
1721
|
+
break;
|
|
1722
|
+
}
|
|
1723
|
+
}
|
|
1724
|
+
}
|
|
1725
|
+
if (arterial.size === 0 && ranked.length > 0) {
|
|
1726
|
+
const fallback = ranked.find((ci) => !interiorDeadEnd(ci)) ?? ranked[0];
|
|
1727
|
+
arterial.add(fallback);
|
|
1728
|
+
}
|
|
1729
|
+
for (let ci = 0; ci < chains.length; ci += 1) {
|
|
1730
|
+
if (chains[ci].lane) {
|
|
1731
|
+
levels[ci] = "lane";
|
|
1732
|
+
}
|
|
1733
|
+
else if (arterial.has(ci)) {
|
|
1734
|
+
levels[ci] = rng() < rules.boulevards ? "boulevard" : "avenue";
|
|
392
1735
|
}
|
|
393
|
-
ranked += 1;
|
|
394
1736
|
}
|
|
395
1737
|
return levels;
|
|
396
1738
|
}
|
|
1739
|
+
/** Nearest distance from a point to a polyline (for keeping sidewalk vertices off the road surface). */
|
|
1740
|
+
function pointPolylineDist(px, pz, line) {
|
|
1741
|
+
let best = Infinity;
|
|
1742
|
+
for (let i = 0; i + 1 < line.length; i += 1) {
|
|
1743
|
+
const a = line[i];
|
|
1744
|
+
const b = line[i + 1];
|
|
1745
|
+
const abx = b[0] - a[0];
|
|
1746
|
+
const abz = b[1] - a[1];
|
|
1747
|
+
const l2 = abx * abx + abz * abz;
|
|
1748
|
+
const t = l2 === 0 ? 0 : Math.max(0, Math.min(1, ((px - a[0]) * abx + (pz - a[1]) * abz) / l2));
|
|
1749
|
+
const d = Math.hypot(px - (a[0] + abx * t), pz - (a[1] + abz * t));
|
|
1750
|
+
if (d < best)
|
|
1751
|
+
best = d;
|
|
1752
|
+
}
|
|
1753
|
+
return best;
|
|
1754
|
+
}
|
|
1755
|
+
/** 2D segment/segment proper-intersection test (shared endpoints don't count). */
|
|
1756
|
+
function segmentsCross(p1, p2, p3, p4) {
|
|
1757
|
+
const d = (a, b, c) => (b[0] - a[0]) * (c[1] - a[1]) - (b[1] - a[1]) * (c[0] - a[0]);
|
|
1758
|
+
const d1 = d(p3, p4, p1);
|
|
1759
|
+
const d2 = d(p3, p4, p2);
|
|
1760
|
+
const d3 = d(p1, p2, p3);
|
|
1761
|
+
const d4 = d(p1, p2, p4);
|
|
1762
|
+
return ((d1 > 0) !== (d2 > 0)) && ((d3 > 0) !== (d4 > 0));
|
|
1763
|
+
}
|
|
1764
|
+
/**
|
|
1765
|
+
* Offset a road centerline sideways into a clean, parallel SIDEWALK polyline (positive `dist` = left of
|
|
1766
|
+
* travel). This is a real polyline offset, not a naive per-vertex normal push: on the OUTSIDE of a bend
|
|
1767
|
+
* it inserts arc samples around the corner so the band stays parallel; on the INSIDE it clamps the miter
|
|
1768
|
+
* to the offset distance (never blowing out into a spike). A cleanup pass then WELDS pinched inside
|
|
1769
|
+
* corners — dropping any vertex that lands closer than `dist·0.9` to the road, and any vertex whose two
|
|
1770
|
+
* incident offset segments cross — so the sidewalk can never dip inside the road surface or self-loop
|
|
1771
|
+
* (the z-fighting/pinch defect). Endpoints are preserved.
|
|
1772
|
+
*/
|
|
1773
|
+
function offsetPolyline(points, dist) {
|
|
1774
|
+
// Deduplicate coincident vertices so segment directions are well-defined.
|
|
1775
|
+
const src = [];
|
|
1776
|
+
for (const p of points) {
|
|
1777
|
+
const last = src[src.length - 1];
|
|
1778
|
+
if (last === undefined || Math.hypot(last[0] - p[0], last[1] - p[1]) > 1e-6)
|
|
1779
|
+
src.push([p[0], p[1]]);
|
|
1780
|
+
}
|
|
1781
|
+
const m = src.length;
|
|
1782
|
+
const absd = Math.abs(dist);
|
|
1783
|
+
if (m < 2)
|
|
1784
|
+
return src.map((p) => [p[0] + dist, p[1]]);
|
|
1785
|
+
// Per-segment unit direction + left normal.
|
|
1786
|
+
const nrm = [];
|
|
1787
|
+
for (let i = 0; i + 1 < m; i += 1) {
|
|
1788
|
+
const dx = src[i + 1][0] - src[i][0];
|
|
1789
|
+
const dz = src[i + 1][1] - src[i][1];
|
|
1790
|
+
const l = Math.hypot(dx, dz) || 1;
|
|
1791
|
+
nrm.push([-dz / l, dx / l]);
|
|
1792
|
+
}
|
|
1793
|
+
const out = [];
|
|
1794
|
+
out.push([src[0][0] + dist * nrm[0][0], src[0][1] + dist * nrm[0][1]]);
|
|
1795
|
+
for (let i = 1; i + 1 < m; i += 1) {
|
|
1796
|
+
const P = src[i];
|
|
1797
|
+
const nP = nrm[i - 1];
|
|
1798
|
+
const nN = nrm[i];
|
|
1799
|
+
// Turn sign from the two segment directions (left normal rotated back gives the tangent).
|
|
1800
|
+
const dPx = nP[1];
|
|
1801
|
+
const dPz = -nP[0];
|
|
1802
|
+
const dNx = nN[1];
|
|
1803
|
+
const dNz = -nN[0];
|
|
1804
|
+
const cross = dPx * dNz - dPz * dNx; // >0 = left turn
|
|
1805
|
+
if (Math.abs(cross) < 1e-5) {
|
|
1806
|
+
out.push([P[0] + dist * nN[0], P[1] + dist * nN[1]]);
|
|
1807
|
+
continue;
|
|
1808
|
+
}
|
|
1809
|
+
const inside = cross * dist > 0; // offset side is the concave (inside) side of the bend
|
|
1810
|
+
if (inside) {
|
|
1811
|
+
// Miter clamped to the offset distance so the inside never spikes past `absd` from the corner.
|
|
1812
|
+
let bx = nP[0] + nN[0];
|
|
1813
|
+
let bz = nP[1] + nN[1];
|
|
1814
|
+
const bl = Math.hypot(bx, bz) || 1;
|
|
1815
|
+
bx /= bl;
|
|
1816
|
+
bz /= bl;
|
|
1817
|
+
out.push([P[0] + bx * dist, P[1] + bz * dist]);
|
|
1818
|
+
}
|
|
1819
|
+
else {
|
|
1820
|
+
// Outside: arc of radius `absd` around the corner from the prev band edge to the next.
|
|
1821
|
+
const aPrev = [P[0] + dist * nP[0], P[1] + dist * nP[1]];
|
|
1822
|
+
const aNext = [P[0] + dist * nN[0], P[1] + dist * nN[1]];
|
|
1823
|
+
const a0 = Math.atan2(aPrev[1] - P[1], aPrev[0] - P[0]);
|
|
1824
|
+
const a1 = Math.atan2(aNext[1] - P[1], aNext[0] - P[0]);
|
|
1825
|
+
let delta = a1 - a0;
|
|
1826
|
+
while (delta > Math.PI)
|
|
1827
|
+
delta -= TAU;
|
|
1828
|
+
while (delta < -Math.PI)
|
|
1829
|
+
delta += TAU;
|
|
1830
|
+
const segs = Math.max(1, Math.ceil(Math.abs(delta) / MAX_ARC_STEP_RAD));
|
|
1831
|
+
for (let s = 0; s <= segs; s += 1) {
|
|
1832
|
+
const ang = a0 + (delta * s) / segs;
|
|
1833
|
+
out.push([P[0] + absd * Math.cos(ang), P[1] + absd * Math.sin(ang)]);
|
|
1834
|
+
}
|
|
1835
|
+
}
|
|
1836
|
+
}
|
|
1837
|
+
out.push([src[m - 1][0] + dist * nrm[m - 2][0], src[m - 1][1] + dist * nrm[m - 2][1]]);
|
|
1838
|
+
return weldOffset(out, src, absd);
|
|
1839
|
+
}
|
|
1840
|
+
/** Cleanup pass over a raw offset polyline: WELD pinched inside corners — drop any interior vertex that
|
|
1841
|
+
* lands nearer than `absd·0.9` to the road, that spikes back on itself (a sharp consecutive backtrack),
|
|
1842
|
+
* or whose incident segments cross — so the sidewalk stays a clean parallel band. Endpoints stay. */
|
|
1843
|
+
function weldOffset(raw, centerline, absd) {
|
|
1844
|
+
let pts = raw;
|
|
1845
|
+
const floor = absd * 0.9;
|
|
1846
|
+
// Pass 1: drop road-pinching interior vertices.
|
|
1847
|
+
const kept = [];
|
|
1848
|
+
for (let i = 0; i < pts.length; i += 1) {
|
|
1849
|
+
if (i === 0 || i === pts.length - 1) {
|
|
1850
|
+
kept.push(pts[i]);
|
|
1851
|
+
continue;
|
|
1852
|
+
}
|
|
1853
|
+
if (pointPolylineDist(pts[i][0], pts[i][1], centerline) < floor)
|
|
1854
|
+
continue; // welded away
|
|
1855
|
+
kept.push(pts[i]);
|
|
1856
|
+
}
|
|
1857
|
+
pts = kept;
|
|
1858
|
+
// Pass 2: remove backtrack spikes and collapse local self-loops. A backtrack (interior turn > ~100°)
|
|
1859
|
+
// is a fold the naive offset leaves inside the road band; a windowed crossing is a small self-loop.
|
|
1860
|
+
const spikeCos = Math.cos((100 * Math.PI) / 180); // turn sharper than this folds back
|
|
1861
|
+
const WINDOW = 6; // collapse self-loops spanning up to this many segments
|
|
1862
|
+
let guard = 0;
|
|
1863
|
+
let changed = true;
|
|
1864
|
+
while (changed && guard < pts.length * 2 + 8) {
|
|
1865
|
+
changed = false;
|
|
1866
|
+
guard += 1;
|
|
1867
|
+
// Collapse the nearest self-loop (segment i crossing a segment up to WINDOW ahead).
|
|
1868
|
+
for (let i = 0; i + 1 < pts.length && !changed; i += 1) {
|
|
1869
|
+
for (let j = i + 2; j + 1 < pts.length && j <= i + WINDOW; j += 1) {
|
|
1870
|
+
if (segmentsCross(pts[i], pts[i + 1], pts[j], pts[j + 1])) {
|
|
1871
|
+
pts.splice(i + 1, j - i); // drop the looped vertices i+1..j, welding i to j+1
|
|
1872
|
+
changed = true;
|
|
1873
|
+
break;
|
|
1874
|
+
}
|
|
1875
|
+
}
|
|
1876
|
+
}
|
|
1877
|
+
if (changed)
|
|
1878
|
+
continue;
|
|
1879
|
+
// Drop backtrack spikes.
|
|
1880
|
+
for (let i = 1; i + 1 < pts.length; i += 1) {
|
|
1881
|
+
const a = pts[i - 1];
|
|
1882
|
+
const v = pts[i];
|
|
1883
|
+
const b = pts[i + 1];
|
|
1884
|
+
const ux = v[0] - a[0];
|
|
1885
|
+
const uz = v[1] - a[1];
|
|
1886
|
+
const vx = b[0] - v[0];
|
|
1887
|
+
const vz = b[1] - v[1];
|
|
1888
|
+
const lu = Math.hypot(ux, uz);
|
|
1889
|
+
const lv = Math.hypot(vx, vz);
|
|
1890
|
+
if (lu > 1e-6 && lv > 1e-6 && (ux * vx + uz * vz) / (lu * lv) < spikeCos) {
|
|
1891
|
+
pts.splice(i, 1);
|
|
1892
|
+
changed = true;
|
|
1893
|
+
break;
|
|
1894
|
+
}
|
|
1895
|
+
}
|
|
1896
|
+
}
|
|
1897
|
+
return pts;
|
|
1898
|
+
}
|
|
1899
|
+
/**
|
|
1900
|
+
* Build the seeded smooth 2-D road-surface elevation FIELD: 3 octaves of sine/cos mixtures with
|
|
1901
|
+
* wavelengths scaled to the volume `extent`, normalized so the peak-to-peak relief ≈ `reliefMeters`.
|
|
1902
|
+
* Smooth by construction (no cliffs). Pure and deterministic given `rng`. The returned sampler is the
|
|
1903
|
+
* SHARED field — streets, edges, junctions, sidewalks, and building bases all read it for weld continuity.
|
|
1904
|
+
*/
|
|
1905
|
+
function makeElevationField(rng, extent, reliefMeters) {
|
|
1906
|
+
const baseAmps = [1, 0.5, 0.28];
|
|
1907
|
+
const comps = baseAmps.map((amp, k) => {
|
|
1908
|
+
// Longest wavelength ≈ 0.9·extent (≈2 hills across the map), each octave shorter.
|
|
1909
|
+
const wl = extent * (0.9 - k * 0.26) * (0.85 + rng() * 0.3);
|
|
1910
|
+
const f = TAU / Math.max(1, wl);
|
|
1911
|
+
const dir = rng() * TAU;
|
|
1912
|
+
const dir2 = dir + Math.PI / 2 + (rng() - 0.5) * 0.8; // a second, near-perpendicular axis for real 2-D relief
|
|
1913
|
+
return {
|
|
1914
|
+
amp,
|
|
1915
|
+
f,
|
|
1916
|
+
dx: Math.cos(dir),
|
|
1917
|
+
dz: Math.sin(dir),
|
|
1918
|
+
ex: Math.cos(dir2),
|
|
1919
|
+
ez: Math.sin(dir2),
|
|
1920
|
+
ph1: rng() * TAU,
|
|
1921
|
+
ph2: rng() * TAU,
|
|
1922
|
+
};
|
|
1923
|
+
});
|
|
1924
|
+
const ampSum = comps.reduce((s, c) => s + c.amp, 0);
|
|
1925
|
+
const norm = reliefMeters / (2 * ampSum); // each component peaks at ±amp, so Σamp·norm = reliefMeters/2
|
|
1926
|
+
return (x, z) => {
|
|
1927
|
+
let h = 0;
|
|
1928
|
+
for (const c of comps) {
|
|
1929
|
+
h += c.amp * (Math.sin((x * c.dx + z * c.dz) * c.f + c.ph1) * 0.6 + Math.cos((x * c.ex + z * c.ez) * c.f + c.ph2) * 0.4);
|
|
1930
|
+
}
|
|
1931
|
+
return h * norm;
|
|
1932
|
+
};
|
|
1933
|
+
}
|
|
1934
|
+
/**
|
|
1935
|
+
* Grade-cap a height sequence so no consecutive pair's slope exceeds `maxGrade`. Guaranteed-feasible
|
|
1936
|
+
* peak-shaving: each violating edge lowers only its HIGHER endpoint to `lower + maxGrade·dist`, a monotone
|
|
1937
|
+
* (strictly-decreasing) propagation that converges within a bounded pass count (the shave travels one edge
|
|
1938
|
+
* per pass; capped at the chain length) and, at a no-change pass, leaves every edge under the cap. A final
|
|
1939
|
+
* constant shift restores the field's mean — a uniform offset changes no slope, so crests are relaxed into
|
|
1940
|
+
* grade-legal ramps without dragging the whole road toward the valleys. For a `closed` loop
|
|
1941
|
+
* (points[0] === points[last]) the ring, INCLUDING the start/finish seam edge, is capped and the shared
|
|
1942
|
+
* vertex re-welded, so the lap stays continuous. Crests/dips read smooth because the sampled field is.
|
|
1943
|
+
*/
|
|
1944
|
+
function gradeCapHeights(points, heights, maxGrade, closed) {
|
|
1945
|
+
const n = heights.length;
|
|
1946
|
+
if (n < 2)
|
|
1947
|
+
return heights.slice();
|
|
1948
|
+
const dist = (i, j) => Math.hypot(points[i][0] - points[j][0], points[i][1] - points[j][1]);
|
|
1949
|
+
// Edge list of (a, b, maxDelta). For a closed ring collapse the duplicate last vertex onto the first.
|
|
1950
|
+
const uniq = closed && dist(0, n - 1) < 1e-6 ? n - 1 : n;
|
|
1951
|
+
const edges = [];
|
|
1952
|
+
for (let i = 0; i + 1 < uniq; i += 1)
|
|
1953
|
+
edges.push({ a: i, b: i + 1, limit: maxGrade * dist(i, i + 1) });
|
|
1954
|
+
if (uniq < n)
|
|
1955
|
+
edges.push({ a: uniq - 1, b: 0, limit: maxGrade * dist(uniq - 1, n - 1) }); // seam edge
|
|
1956
|
+
const u = heights.slice(0, uniq);
|
|
1957
|
+
let meanBefore = 0;
|
|
1958
|
+
for (const v of u)
|
|
1959
|
+
meanBefore += v;
|
|
1960
|
+
meanBefore /= uniq;
|
|
1961
|
+
const maxPass = uniq * 2 + 8;
|
|
1962
|
+
let changed = true;
|
|
1963
|
+
for (let pass = 0; pass < maxPass && changed; pass += 1) {
|
|
1964
|
+
changed = false;
|
|
1965
|
+
for (const e of edges) {
|
|
1966
|
+
const diff = u[e.b] - u[e.a];
|
|
1967
|
+
if (diff > e.limit) {
|
|
1968
|
+
u[e.b] = u[e.a] + e.limit; // b too high → shave it down
|
|
1969
|
+
changed = true;
|
|
1970
|
+
}
|
|
1971
|
+
else if (diff < -e.limit) {
|
|
1972
|
+
u[e.a] = u[e.b] + e.limit; // a too high → shave it down
|
|
1973
|
+
changed = true;
|
|
1974
|
+
}
|
|
1975
|
+
}
|
|
1976
|
+
}
|
|
1977
|
+
// Restore the mean with a constant offset (slopes are shift-invariant, so the cap still holds).
|
|
1978
|
+
let meanAfter = 0;
|
|
1979
|
+
for (const v of u)
|
|
1980
|
+
meanAfter += v;
|
|
1981
|
+
meanAfter /= uniq;
|
|
1982
|
+
const shift = meanBefore - meanAfter;
|
|
1983
|
+
const out = u.map((v) => v + shift);
|
|
1984
|
+
if (uniq < n)
|
|
1985
|
+
out.push(out[0]); // re-weld the closing vertex to keep the loop continuous
|
|
1986
|
+
return out;
|
|
1987
|
+
}
|
|
397
1988
|
/**
|
|
398
1989
|
* Resolve a full path network from its rules inside a volume of half-extents `hx`×`hz`. Deterministic
|
|
399
1990
|
* per `(rules.seed, hx, hz, context)`. Pass a {@link StreetNetworkContext} with a ground sampler to turn
|
|
400
1991
|
* water gaps into bridges and ridges into tunnels. Coordinates are volume-local; the caller maps to
|
|
401
1992
|
* world space.
|
|
402
1993
|
*
|
|
403
|
-
* @capability street-generator build a deterministic street/track graph (nodes, edges, streets, junctions, bridges, tunnels) from sliders
|
|
1994
|
+
* @capability street-generator build a deterministic street/track graph (nodes, edges, streets, junctions, sidewalks, bridges, tunnels) from sliders
|
|
404
1995
|
*/
|
|
405
1996
|
export function generateStreets(rules, hx, hz, context = {}) {
|
|
406
1997
|
const streams = seededStreams(`pathnet:${rules.seed}:${Math.round(hx)}:${Math.round(hz)}`);
|
|
@@ -411,16 +2002,24 @@ export function generateStreets(rules, hx, hz, context = {}) {
|
|
|
411
2002
|
// --- degrees + adjacency over the atomic graph ---
|
|
412
2003
|
const degree = new Array(rawNodes.length).fill(0);
|
|
413
2004
|
const adj = rawNodes.map(() => []);
|
|
2005
|
+
const adjNodes = rawNodes.map(() => []);
|
|
414
2006
|
rawEdges.forEach((e, i) => {
|
|
415
2007
|
degree[e.a] += 1;
|
|
416
2008
|
degree[e.b] += 1;
|
|
417
2009
|
adj[e.a].push({ edge: i, other: e.b });
|
|
418
2010
|
adj[e.b].push({ edge: i, other: e.a });
|
|
2011
|
+
adjNodes[e.a].push(e.b);
|
|
2012
|
+
adjNodes[e.b].push(e.a);
|
|
419
2013
|
});
|
|
420
2014
|
// --- level per edge: chain length drives the hierarchy, so compute chains first (topology only). ---
|
|
2015
|
+
// A synthesized circuit loop carries its OWN pre-sampled smooth-spline centerline per edge (the
|
|
2016
|
+
// curve-first construction), so those edges are used verbatim; only the pit-lane chords (and every
|
|
2017
|
+
// city-net edge) get wandered. Circuit pit chords straighten (winding 0) — their corners come from the
|
|
2018
|
+
// fillet at the pit entry/exit, not sine wander.
|
|
2019
|
+
const wanderRules = mode === "circuit" ? { ...rules, winding: 0 } : rules;
|
|
421
2020
|
const edgeWander = rawEdges.map((e) =>
|
|
422
|
-
// Clamp
|
|
423
|
-
wanderEdge(rawNodes[e.a], rawNodes[e.b], streams(`edge:${e.a}:${e.b}`),
|
|
2021
|
+
// Clamp crests to the footprint so a curve near the rim never bulges outside the volume.
|
|
2022
|
+
(e.points ?? wanderEdge(rawNodes[e.a], rawNodes[e.b], streams(`edge:${e.a}:${e.b}`), wanderRules)).map(([x, z]) => [Math.max(-hx, Math.min(hx, x)), Math.max(-hz, Math.min(hz, z))]));
|
|
424
2023
|
const edgeLength = rawEdges.map((_, i) => {
|
|
425
2024
|
const pts = edgeWander[i];
|
|
426
2025
|
let l = 0;
|
|
@@ -505,24 +2104,56 @@ export function generateStreets(rules, hx, hz, context = {}) {
|
|
|
505
2104
|
}
|
|
506
2105
|
for (let i = 0; i < rawEdges.length; i += 1)
|
|
507
2106
|
walkFrom(i); // leftover pure loops
|
|
508
|
-
|
|
2107
|
+
// --- structural hierarchy: betweenness centrality + connectivity repair ---
|
|
2108
|
+
const bt = nodeBetweenness(rawNodes.length, adjNodes);
|
|
2109
|
+
const rimEps = 0.5;
|
|
2110
|
+
const isRim = (node) => {
|
|
2111
|
+
const p = rawNodes[node];
|
|
2112
|
+
return p !== undefined && (Math.abs(p[0]) >= hx - rimEps || Math.abs(p[1]) >= hz - rimEps);
|
|
2113
|
+
};
|
|
2114
|
+
const chainsAt = new Map();
|
|
2115
|
+
chains.forEach((chain, ci) => {
|
|
2116
|
+
if (chain.loop)
|
|
2117
|
+
return;
|
|
2118
|
+
for (const endNode of [chain.nodes[0], chain.nodes[chain.nodes.length - 1]]) {
|
|
2119
|
+
(chainsAt.get(endNode) ?? chainsAt.set(endNode, []).get(endNode)).push(ci);
|
|
2120
|
+
}
|
|
2121
|
+
});
|
|
2122
|
+
const levels = levelForStreets(chains, rules, streams("levels"), { bt, degree, isRim, chainsAt });
|
|
509
2123
|
const edgeLevel = new Array(rawEdges.length).fill("street");
|
|
510
2124
|
chains.forEach((chain, ci) => {
|
|
511
2125
|
for (const ei of chain.edges)
|
|
512
2126
|
edgeLevel[ei] = levels[ci];
|
|
513
2127
|
});
|
|
2128
|
+
// --- road-surface elevation field (opt-in; flat + byte-identical when the dial is 0) ---
|
|
2129
|
+
const elevationDial = rules.elevation ?? 0;
|
|
2130
|
+
const maxGrade = rules.maxGrade ?? 0.07;
|
|
2131
|
+
// A seeded smooth field draped over the network. Wholly separate from the `context.heightAt` TERRAIN
|
|
2132
|
+
// sampler that drives bridges/tunnels — this shapes the road surface, that is the world ground.
|
|
2133
|
+
const elevationAt = elevationDial > 0
|
|
2134
|
+
? makeElevationField(streams("elevation"), Math.max(hx, hz) * 2,
|
|
2135
|
+
// dial 1 ≈ 22 m relief across a ~520 m volume, scaled to this footprint.
|
|
2136
|
+
elevationDial * Math.max(hx, hz) * 2 * (22 / 520))
|
|
2137
|
+
: undefined;
|
|
514
2138
|
// --- assemble atomic edges ---
|
|
515
2139
|
const minRad = (rules.minTurnAngle * Math.PI) / 180;
|
|
516
2140
|
const maxRad = (rules.maxTurnAngle * Math.PI) / 180;
|
|
517
|
-
const
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
2141
|
+
const sidewalkWidth = rules.sidewalkWidth ?? DEFAULT_SIDEWALK_WIDTH;
|
|
2142
|
+
const edges = rawEdges.map((e, i) => {
|
|
2143
|
+
const edge = {
|
|
2144
|
+
id: i,
|
|
2145
|
+
a: e.a,
|
|
2146
|
+
b: e.b,
|
|
2147
|
+
points: edgeWander[i],
|
|
2148
|
+
width: rules.width * WIDTH_MULT[edgeLevel[i]],
|
|
2149
|
+
level: edgeLevel[i],
|
|
2150
|
+
loop: mode === "circuit" && !e.lane,
|
|
2151
|
+
};
|
|
2152
|
+
// Raw (uncapped) field samples for continuity; the grade-limited profile lives on the owning street.
|
|
2153
|
+
if (elevationAt)
|
|
2154
|
+
edge.heights = edge.points.map((p) => elevationAt(p[0], p[1]));
|
|
2155
|
+
return edge;
|
|
2156
|
+
});
|
|
526
2157
|
// --- assemble chained streets (smoothed geometry for rendering) ---
|
|
527
2158
|
const heightAt = context.heightAt;
|
|
528
2159
|
const streets = [];
|
|
@@ -546,7 +2177,13 @@ export function generateStreets(rules, hx, hz, context = {}) {
|
|
|
546
2177
|
pts.push(seg[s]);
|
|
547
2178
|
}
|
|
548
2179
|
});
|
|
549
|
-
|
|
2180
|
+
// The circuit LOOP is already a smooth, curvature-floored spline (curve-first construction) — its
|
|
2181
|
+
// concatenated edge samples ARE the final centerline, so it skips the polygon fillet entirely.
|
|
2182
|
+
// City nets and circuit pit lanes still fillet their corners at `minCurveRadius`.
|
|
2183
|
+
const isCircuitLoop = mode === "circuit" && chain.loop;
|
|
2184
|
+
const smooth = isCircuitLoop
|
|
2185
|
+
? pts.map((p) => [p[0], p[1]])
|
|
2186
|
+
: clampTurns(pts, minRad, maxRad, rules.minCurveRadius, chain.loop);
|
|
550
2187
|
const street = {
|
|
551
2188
|
id: streets.length,
|
|
552
2189
|
nodes: chain.nodes,
|
|
@@ -556,6 +2193,15 @@ export function generateStreets(rules, hx, hz, context = {}) {
|
|
|
556
2193
|
loop: chain.loop,
|
|
557
2194
|
features: [],
|
|
558
2195
|
};
|
|
2196
|
+
// Grade-limited road-surface heights sampled from the shared field (loops stay continuous at the seam).
|
|
2197
|
+
if (elevationAt) {
|
|
2198
|
+
street.heights = gradeCapHeights(smooth, smooth.map((p) => elevationAt(p[0], p[1])), maxGrade, chain.loop);
|
|
2199
|
+
}
|
|
2200
|
+
// Flanking pedestrian bands on paved streets (not service lanes).
|
|
2201
|
+
if (level !== "lane" && smooth.length >= 2) {
|
|
2202
|
+
const d = width / 2 + sidewalkWidth;
|
|
2203
|
+
street.sidewalks = { left: offsetPolyline(smooth, d), right: offsetPolyline(smooth, -d) };
|
|
2204
|
+
}
|
|
559
2205
|
// Dead-end cul-de-sac bulb at a dangling terminal node.
|
|
560
2206
|
const endNode = chain.nodes[chain.nodes.length - 1];
|
|
561
2207
|
const startNode = chain.nodes[0];
|
|
@@ -571,10 +2217,17 @@ export function generateStreets(rules, hx, hz, context = {}) {
|
|
|
571
2217
|
}
|
|
572
2218
|
streets.push(street);
|
|
573
2219
|
});
|
|
574
|
-
// --- junctions from degree
|
|
2220
|
+
// --- junctions from branches and hard degree-2 bends split into separate streets ---
|
|
2221
|
+
const chainEndsAt = new Uint16Array(rawNodes.length);
|
|
2222
|
+
for (const chain of chains) {
|
|
2223
|
+
if (chain.loop)
|
|
2224
|
+
continue;
|
|
2225
|
+
chainEndsAt[chain.nodes[0]] += 1;
|
|
2226
|
+
chainEndsAt[chain.nodes[chain.nodes.length - 1]] += 1;
|
|
2227
|
+
}
|
|
575
2228
|
const junctions = [];
|
|
576
2229
|
for (let n = 0; n < rawNodes.length; n += 1) {
|
|
577
|
-
if (degree[n] < 3)
|
|
2230
|
+
if (degree[n] < 3 && !(degree[n] === 2 && chainEndsAt[n] >= 2))
|
|
578
2231
|
continue;
|
|
579
2232
|
const arms = [];
|
|
580
2233
|
let radius = 0;
|
|
@@ -622,7 +2275,11 @@ export function generateStreets(rules, hx, hz, context = {}) {
|
|
|
622
2275
|
const loops = Math.max(0, rawEdges.length - rawNodes.length + components);
|
|
623
2276
|
// Compact node list with final degrees.
|
|
624
2277
|
const nodes = rawNodes.map((p, i) => ({ id: i, x: p[0], z: p[1], degree: degree[i] }));
|
|
625
|
-
|
|
2278
|
+
const network = { mode, nodes, edges, streets, junctions, deadEnds, bridges, tunnels, loops };
|
|
2279
|
+
// Expose the shared field only when elevation is on, so a flat network stays byte-identical.
|
|
2280
|
+
if (elevationAt)
|
|
2281
|
+
network.elevationAt = elevationAt;
|
|
2282
|
+
return network;
|
|
626
2283
|
}
|
|
627
2284
|
/** Connected-component count over the atomic graph (for the cycle-rank / loop count). */
|
|
628
2285
|
function countComponents(n, edges) {
|