@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.
Files changed (214) hide show
  1. package/CHANGELOG.md +500 -1
  2. package/dist/ai/difficulty.d.ts +154 -0
  3. package/dist/ai/difficulty.js +194 -0
  4. package/dist/ai/driver.d.ts +107 -0
  5. package/dist/ai/driver.js +175 -0
  6. package/dist/ai/flock.js +13 -23
  7. package/dist/ai/laneSelect.js +2 -1
  8. package/dist/ai/mobBrain.js +2 -1
  9. package/dist/ai/waveRunner.d.ts +81 -0
  10. package/dist/ai/waveRunner.js +104 -0
  11. package/dist/audio/musicState.d.ts +47 -0
  12. package/dist/audio/musicState.js +38 -0
  13. package/dist/audio/stepCadence.d.ts +47 -0
  14. package/dist/audio/stepCadence.js +29 -0
  15. package/dist/audio/synth.d.ts +12 -0
  16. package/dist/authoring.d.ts +6 -4
  17. package/dist/authoring.js +6 -4
  18. package/dist/combat/castRunner.d.ts +13 -1
  19. package/dist/combat/castRunner.js +5 -1
  20. package/dist/combat/comboMeter.d.ts +140 -0
  21. package/dist/combat/comboMeter.js +215 -0
  22. package/dist/combat/comboPoints.d.ts +11 -0
  23. package/dist/combat/comboPoints.js +11 -2
  24. package/dist/combat/damageResolution.d.ts +1 -1
  25. package/dist/combat/defensiveWindow.d.ts +5 -0
  26. package/dist/combat/effects.d.ts +24 -0
  27. package/dist/combat/effects.js +6 -1
  28. package/dist/combat/projectiles.d.ts +6 -0
  29. package/dist/combat/projectiles.js +2 -2
  30. package/dist/combat/regenShield.d.ts +40 -2
  31. package/dist/combat/regenShield.js +66 -18
  32. package/dist/combat/resourcePool.d.ts +19 -0
  33. package/dist/combat/resourcePool.js +20 -2
  34. package/dist/combat/shotOrigin.d.ts +5 -0
  35. package/dist/combat/statusApplication.d.ts +1 -1
  36. package/dist/combat/statusApplication.js +2 -1
  37. package/dist/combat/statusEffectView.d.ts +56 -0
  38. package/dist/combat/statusEffectView.js +52 -0
  39. package/dist/combat.d.ts +4 -2
  40. package/dist/combat.js +2 -0
  41. package/dist/devtools/fallbackSeams.d.ts +3 -2
  42. package/dist/devtools/fallbackSeams.js +1 -1
  43. package/dist/devtools/textureErrors.d.ts +40 -0
  44. package/dist/devtools/textureErrors.js +68 -0
  45. package/dist/economy/shopStock.d.ts +110 -0
  46. package/dist/economy/shopStock.js +157 -0
  47. package/dist/economy/wallet.d.ts +6 -0
  48. package/dist/economy/wallet.js +1 -0
  49. package/dist/game/achievements.d.ts +80 -0
  50. package/dist/game/achievements.js +124 -0
  51. package/dist/game/cameraConfig.d.ts +7 -0
  52. package/dist/game/codex.d.ts +60 -0
  53. package/dist/game/codex.js +89 -0
  54. package/dist/game/credits.d.ts +36 -0
  55. package/dist/game/credits.js +0 -0
  56. package/dist/game/defineGame.d.ts +28 -3
  57. package/dist/game/defineGame.js +5 -0
  58. package/dist/game/dialogueGraph.d.ts +116 -0
  59. package/dist/game/dialogueGraph.js +103 -0
  60. package/dist/game/eventTicker.d.ts +95 -0
  61. package/dist/game/eventTicker.js +91 -0
  62. package/dist/game/events.d.ts +24 -5
  63. package/dist/game/events.js +18 -0
  64. package/dist/game/gamePhase.d.ts +1 -1
  65. package/dist/game/gamePhase.js +1 -1
  66. package/dist/game/leaderboardRank.d.ts +71 -0
  67. package/dist/game/leaderboardRank.js +76 -0
  68. package/dist/game/lootTable.d.ts +10 -1
  69. package/dist/game/lootTable.js +4 -2
  70. package/dist/game/notifications.d.ts +57 -0
  71. package/dist/game/notifications.js +107 -0
  72. package/dist/game/playableGame.d.ts +39 -2
  73. package/dist/game/quest.d.ts +26 -0
  74. package/dist/game/quest.js +31 -0
  75. package/dist/game/runDraft.js +2 -1
  76. package/dist/game/saveSlots.d.ts +102 -0
  77. package/dist/game/saveSlots.js +136 -0
  78. package/dist/game/talentTreeView.d.ts +106 -0
  79. package/dist/game/talentTreeView.js +114 -0
  80. package/dist/game/worldItem.js +2 -1
  81. package/dist/gameplay.d.ts +15 -4
  82. package/dist/gameplay.js +13 -2
  83. package/dist/i18n/i18n.d.ts +51 -0
  84. package/dist/i18n/i18n.js +82 -0
  85. package/dist/input/axisInput.d.ts +1 -1
  86. package/dist/input/axisInput.js +18 -2
  87. package/dist/input/rebindSession.d.ts +141 -0
  88. package/dist/input/rebindSession.js +235 -0
  89. package/dist/input/touchControlsMode.d.ts +16 -0
  90. package/dist/input/touchControlsMode.js +23 -0
  91. package/dist/input/touchScheme.d.ts +30 -1
  92. package/dist/input/touchScheme.js +31 -1
  93. package/dist/interaction/promptRegistry.d.ts +78 -0
  94. package/dist/interaction/promptRegistry.js +130 -0
  95. package/dist/inventory/storageTier.js +3 -2
  96. package/dist/meta/changelog.d.ts +1 -1
  97. package/dist/meta/changelog.js +145 -1
  98. package/dist/movement/movementModel.d.ts +26 -3
  99. package/dist/movement/movementModel.js +121 -15
  100. package/dist/movement/playerMovement.js +161 -25
  101. package/dist/physics/drivableVehicle.js +3 -1
  102. package/dist/physics/kinematicVehicle.d.ts +56 -1
  103. package/dist/physics/kinematicVehicle.js +99 -10
  104. package/dist/physics/vehicleObstacles.js +19 -0
  105. package/dist/random/resolveRng.d.ts +14 -0
  106. package/dist/random/resolveRng.js +31 -0
  107. package/dist/render/postProcessing.d.ts +12 -1
  108. package/dist/runtime/cameraDirector.d.ts +2 -2
  109. package/dist/runtime/context/combat.js +8 -0
  110. package/dist/runtime/gameContext.js +6 -1
  111. package/dist/runtime/gameContextTypes.d.ts +18 -0
  112. package/dist/runtime/inputSnapshot.d.ts +10 -1
  113. package/dist/runtime/inputSnapshot.js +13 -1
  114. package/dist/runtime/runtimeSave.d.ts +3 -3
  115. package/dist/runtime/runtimeSave.js +11 -2
  116. package/dist/scene/assetDiagnostics.js +2 -2
  117. package/dist/scene/autoTarget.js +2 -1
  118. package/dist/scene/captureCheck.js +3 -2
  119. package/dist/scene/entityStore.d.ts +4 -1
  120. package/dist/scene/entityStore.js +3 -0
  121. package/dist/scene/objectStore.d.ts +10 -0
  122. package/dist/scene/objectStore.js +29 -6
  123. package/dist/scene/sceneKinds.d.ts +18 -0
  124. package/dist/scene/sceneKinds.js +19 -0
  125. package/dist/scene/sequenceDirector.d.ts +110 -0
  126. package/dist/scene/sequenceDirector.js +146 -0
  127. package/dist/session/roles.js +3 -2
  128. package/dist/settings/settingsModel.d.ts +1 -0
  129. package/dist/settings/settingsModel.js +1 -0
  130. package/dist/stats/rollCheck.js +4 -2
  131. package/dist/time/dayNightCycle.d.ts +114 -0
  132. package/dist/time/dayNightCycle.js +208 -0
  133. package/dist/time/timerSet.d.ts +132 -0
  134. package/dist/time/timerSet.js +243 -0
  135. package/dist/ui/accessibility.d.ts +51 -0
  136. package/dist/ui/accessibility.js +83 -0
  137. package/dist/ui/coachMarks.d.ts +99 -0
  138. package/dist/ui/coachMarks.js +126 -0
  139. package/dist/ui/floatingText.d.ts +114 -0
  140. package/dist/ui/floatingText.js +203 -0
  141. package/dist/ui/modalStack.d.ts +143 -0
  142. package/dist/ui/modalStack.js +160 -0
  143. package/dist/ui/objectiveBanner.d.ts +123 -0
  144. package/dist/ui/objectiveBanner.js +162 -0
  145. package/dist/ui/photoMode.d.ts +32 -0
  146. package/dist/ui/photoMode.js +51 -0
  147. package/dist/ui/radialMenu.d.ts +47 -0
  148. package/dist/ui/radialMenu.js +74 -0
  149. package/dist/ui/screenMarkers.d.ts +128 -0
  150. package/dist/ui/screenMarkers.js +118 -0
  151. package/dist/ui.d.ts +8 -0
  152. package/dist/ui.js +8 -0
  153. package/dist/vfx/cameraShake.d.ts +97 -0
  154. package/dist/vfx/cameraShake.js +136 -0
  155. package/dist/vfx/damageDirection.d.ts +102 -0
  156. package/dist/vfx/damageDirection.js +188 -0
  157. package/dist/vfx/particles.d.ts +103 -0
  158. package/dist/vfx/particles.js +256 -0
  159. package/dist/vfx/screenEffects.d.ts +149 -0
  160. package/dist/vfx/screenEffects.js +191 -0
  161. package/dist/world/authoredObjects.d.ts +18 -0
  162. package/dist/world/authoredObjects.js +21 -0
  163. package/dist/world/authoredSpawn.js +19 -0
  164. package/dist/world/buildingLots.d.ts +54 -11
  165. package/dist/world/buildingLots.js +116 -30
  166. package/dist/world/cityBlocks.d.ts +45 -0
  167. package/dist/world/cityBlocks.js +159 -0
  168. package/dist/world/cityContent.d.ts +46 -2
  169. package/dist/world/cityContent.js +160 -2
  170. package/dist/world/cityGenerator.d.ts +261 -23
  171. package/dist/world/cityGenerator.js +643 -33
  172. package/dist/world/cityGeometry.d.ts +20 -0
  173. package/dist/world/cityGeometry.js +91 -0
  174. package/dist/world/cityKind.js +1 -44
  175. package/dist/world/fastTravel.d.ts +64 -0
  176. package/dist/world/fastTravel.js +108 -0
  177. package/dist/world/features.d.ts +16 -0
  178. package/dist/world/features.js +2 -0
  179. package/dist/world/grounded.d.ts +11 -0
  180. package/dist/world/grounded.js +12 -0
  181. package/dist/world/mapAnnotations.d.ts +75 -0
  182. package/dist/world/mapAnnotations.js +139 -0
  183. package/dist/world/markers.js +1 -0
  184. package/dist/world/resourceNode.d.ts +1 -1
  185. package/dist/world/resourceNode.js +2 -1
  186. package/dist/world/roads.d.ts +294 -5
  187. package/dist/world/roads.js +1262 -21
  188. package/dist/world/sharedAnnotations.d.ts +84 -0
  189. package/dist/world/sharedAnnotations.js +76 -0
  190. package/dist/world/spawnOverride.d.ts +37 -0
  191. package/dist/world/spawnOverride.js +50 -0
  192. package/dist/world/streetGenerator.d.ts +60 -11
  193. package/dist/world/streetGenerator.js +1786 -129
  194. package/dist/world/terrain.d.ts +1 -0
  195. package/dist/world/terrain.js +2 -0
  196. package/dist/world/waypoints.d.ts +63 -0
  197. package/dist/world/waypoints.js +137 -0
  198. package/dist/world.d.ts +17 -2
  199. package/dist/world.js +17 -2
  200. package/package.json +1 -1
  201. package/skills/ce-handoff/SKILL.md +149 -0
  202. package/skills/game-design/SKILL.md +2 -0
  203. package/skills/jgengine/SKILL.md +3 -1
  204. package/skills/jgengine/api.md +17 -7
  205. package/skills/jgengine/capabilities.md +12 -0
  206. package/skills/jgengine/recipes/minimal-game.md +7 -4
  207. package/skills/jgengine-combat/api.md +40 -4
  208. package/skills/jgengine-combat/capabilities.md +12 -0
  209. package/skills/jgengine-combat/reference.md +2 -0
  210. package/skills/jgengine-gameplay/api.md +212 -11
  211. package/skills/jgengine-gameplay/capabilities.md +66 -1
  212. package/skills/jgengine-world/api.md +397 -11
  213. package/skills/jgengine-world/capabilities.md +123 -2
  214. 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`; `minTurnAngle`/`maxTurnAngle` are HARD clamps on the corner a street may
14
- * take at any vertex (shallow wiggles straightened, hairpins beveled away).
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
- * Hard turn-angle clamp on a polyline with fixed endpoints: interior corners gentler than `minRad`
51
- * are straightened away (deliberate winding only, no micro-wiggle) and corners sharper than `maxRad`
52
- * are beveled — the corner vertex is replaced by two points pulled back along each leg, which
53
- * strictly reduces the angle. Endpoints never move, so a graph node stays welded.
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. bevel pass — repeatedly cut the sharpest over-max corner until all corners obey the ceiling.
81
- let guard = 0;
82
- while (guard < 200) {
83
- guard += 1;
84
- let worst = -1;
85
- let worstAngle = maxRad;
86
- for (let i = 1; i < pts.length - 1; i += 1) {
87
- const angle = turnAngle(pts[i - 1], pts[i], pts[i + 1]);
88
- if (angle > worstAngle) {
89
- worstAngle = angle;
90
- worst = i;
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
- if (worst < 0)
94
- break;
95
- const a = pts[worst - 1];
96
- const v = pts[worst];
97
- const b = pts[worst + 1];
98
- const la = Math.hypot(a[0] - v[0], a[1] - v[1]) || 1;
99
- const lb = Math.hypot(b[0] - v[0], b[1] - v[1]) || 1;
100
- const cut = 0.42;
101
- const p0 = [v[0] + ((a[0] - v[0]) / la) * la * cut, v[1] + ((a[1] - v[1]) / la) * la * cut];
102
- const p1 = [v[0] + ((b[0] - v[0]) / lb) * lb * cut, v[1] + ((b[1] - v[1]) / lb) * lb * cut];
103
- pts.splice(worst, 1, p0, p1);
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
- return pts;
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 outward off existing nodes, ending in a fresh cul-de-sac node.
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 < MAX_NODES; b += 1) {
293
- const host = Math.floor(branchRng() * nodes.length);
294
- const hp = nodes[host];
295
- const angle = branchRng() * TAU;
296
- const len = rules.segmentLength * (0.6 + branchRng() * 0.7);
297
- const nx = hp[0] + Math.cos(angle) * len;
298
- const nz = hp[1] + Math.sin(angle) * len;
299
- if (Math.abs(nx) > hx - 1 || Math.abs(nz) > hz - 1)
300
- continue;
301
- // Reject spurs that would spawn on top of an existing node.
302
- let clash = false;
303
- for (let m = 0; m < nodes.length; m += 1) {
304
- if (Math.hypot(nodes[m][0] - nx, nodes[m][1] - nz) < rules.segmentLength * 0.45) {
305
- clash = true;
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 (clash)
518
+ if (tip === null)
310
519
  continue;
311
- const id = nodes.length;
312
- nodes.push([nx, nz]);
313
- edges.push({ a: host, b: id, lane: true });
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
- /** Grow a closed CIRCUIT: nodes ordered around the volume centroid into one loop, radii smoothed so
318
- * no corner exceeds the turn ceiling, with an optional pit-lane spur when branching is up. */
319
- function buildCircuit(rules, hx, hz, streams) {
320
- const rng = streams("circuit");
321
- const rx = hx * 0.86;
322
- const rz = hz * 0.86;
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 k = Math.max(6, Math.min(Math.round(perimeter / rules.segmentLength), 40));
325
- // Per-node radius factor: winding pulls the loop in and out to carve corners and chicanes.
326
- const radii = [];
327
- for (let i = 0; i < k; i += 1) {
328
- const wobble = 1 - rules.winding * (0.12 + rng() * 0.4) * (0.5 + 0.5 * Math.sin(i * 1.7));
329
- radii.push(Math.max(0.45, wobble));
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 nodes = [];
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 angle = (i / k) * TAU;
347
- const f = radii[i];
348
- nodes.push([Math.cos(angle) * rx * f, Math.sin(angle) * rz * f]);
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 i = 0; i < k; i += 1)
352
- edges.push({ a: i, b: (i + 1) % k, lane: false });
353
- // Optional pit-lane spur: a short dead-end tap off one node (only when branching is meaningful).
354
- if (rules.branching > 0.25 && nodes.length < MAX_NODES) {
355
- const host = Math.floor(rng() * k);
356
- const hp = nodes[host];
357
- const toCenter = Math.atan2(-hp[1], -hp[0]);
358
- const len = rules.segmentLength * (0.8 + rng() * 0.6);
359
- const nx = hp[0] + Math.cos(toCenter) * len;
360
- const nz = hp[1] + Math.sin(toCenter) * len;
361
- if (Math.abs(nx) < hx - 1 && Math.abs(nz) < hz - 1) {
362
- const id = nodes.length;
363
- nodes.push([nx, nz]);
364
- edges.push({ a: host, b: id, lane: true });
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
- /** Assign a hierarchy level per chained street: spurs are lanes, the longest runs become avenues,
377
- * and a share of those upgrade to boulevards. */
378
- function levelForStreets(chains, rules, rng) {
379
- const order = chains
380
- .map((c, i) => ({ i, length: c.length, lane: c.lane }))
381
- .sort((a, b) => b.length - a.length);
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
- const avenueCut = Math.max(1, Math.ceil(order.filter((o) => !o.lane).length * 0.28));
384
- let ranked = 0;
385
- for (const o of order) {
386
- if (o.lane) {
387
- levels[o.i] = "lane";
388
- continue;
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
- if (ranked < avenueCut) {
391
- levels[o.i] = rng() < rules.boulevards ? "boulevard" : "avenue";
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 wander crests to the footprint so a curve near the rim never bulges outside the volume.
423
- wanderEdge(rawNodes[e.a], rawNodes[e.b], streams(`edge:${e.a}:${e.b}`), rules).map(([x, z]) => [Math.max(-hx, Math.min(hx, x)), Math.max(-hz, Math.min(hz, z))]));
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
- const levels = levelForStreets(chains, rules, streams("levels"));
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 edges = rawEdges.map((e, i) => ({
518
- id: i,
519
- a: e.a,
520
- b: e.b,
521
- points: edgeWander[i],
522
- width: rules.width * WIDTH_MULT[edgeLevel[i]],
523
- level: edgeLevel[i],
524
- loop: mode === "circuit" && !e.lane,
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
- const smooth = clampTurns(pts, minRad, maxRad);
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≥3 nodes; arms from incident edge tangents ---
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
- return { mode, nodes, edges, streets, junctions, deadEnds, bridges, tunnels, loops };
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) {