@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
@@ -36,6 +36,36 @@ export function roundPathCorners(points, radius, cornerSegments = 5) {
36
36
  out.push([points[points.length - 1][0], points[points.length - 1][1]]);
37
37
  return out;
38
38
  }
39
+ /**
40
+ * The single owning table of ground-decal Y offsets, in world units above the sampled terrain.
41
+ *
42
+ * Contract — the vertical stacking order is `terrain < road < junction < marking < glow`, and every
43
+ * ground overlay must draw from THIS table rather than picking a local magic epsilon (the old
44
+ * scatter of `0.08` ribbons, `+0.02` discs, `0.05` markings, `0.055–0.1` playground fudge factors
45
+ * is exactly the z-fighting this removes).
46
+ *
47
+ * - `road` and `junction` deliberately share one layer: {@link buildJunctionSurface} welds its
48
+ * boundary vertices onto the exact corner vertices where the trimmed ribbon ends
49
+ * ({@link trimPathAtJunctions}), so the two surfaces meet at a shared seam and never stack — they
50
+ * must sit at the same height or the seam would tear. Do NOT bump `junction` above `road`.
51
+ * - `marking` is the ONLY permitted coplanar overlay (lane paint sits on top of asphalt at the same
52
+ * XZ). Its Y separation alone is not enough to beat depth precision on sloped ground: consumers
53
+ * rendering markings MUST ALSO set a negative `polygonOffset` and/or a higher `renderOrder` on the
54
+ * marking material (renderer-side — this table only owns the Y term).
55
+ * - `glow` sits above markings for emissive decals (crosswalk glow, selection halos).
56
+ *
57
+ * Values are chosen with real separation so a small terrain slope can't invert the order.
58
+ */
59
+ export const GROUND_DECAL_LAYERS = {
60
+ /** Road ribbons. */
61
+ road: 0.06,
62
+ /** Junction surfaces — SAME layer as `road` because they are seam-welded, never stacked. */
63
+ junction: 0.06,
64
+ /** Lane markings / crosswalks — the only coplanar overlay; also set polygonOffset/renderOrder. */
65
+ marking: 0.11,
66
+ /** Emissive decals above markings (glow, halos). */
67
+ glow: 0.14,
68
+ };
39
69
  function subdividePath(path, maxLength) {
40
70
  const out = [];
41
71
  for (let i = 0; i < path.length - 1; i += 1) {
@@ -68,30 +98,136 @@ function normalAt(points, index) {
68
98
  * into a mesh, and tests can assert on it directly.
69
99
  */
70
100
  export function buildRoadRibbon(path, width, sampleHeight, options = {}) {
71
- const elevation = options.elevation ?? 0.08;
101
+ const elevation = options.elevation ?? GROUND_DECAL_LAYERS.road;
72
102
  const maxSegmentLength = options.maxSegmentLength ?? 4;
73
103
  if (path.length < 2 || width <= 0) {
74
104
  return { positions: new Float32Array(0), indices: new Uint32Array(0) };
75
105
  }
76
106
  const points = subdividePath(path, maxSegmentLength);
107
+ const n = points.length;
77
108
  const half = width / 2;
78
- const positions = new Float32Array(points.length * 6);
79
- for (let i = 0; i < points.length; i += 1) {
109
+ // Offset boundaries as 2-D points, then draped. The offsetting has to survive bends: a naive
110
+ // per-vertex perpendicular offset makes the INNER edge of a corner fold back on itself (a bowtie)
111
+ // whenever the local turn radius drops below the half-width — visible as doubled/overlapping road.
112
+ //
113
+ // Two invariants keep downstream welds and the regression suite intact while fixing the fold:
114
+ // • Endpoints (i=0, i=n−1) and straight interior vertices use the EXACT naive formula
115
+ // (`centre ± normalAt·half`). buildTrimmedIntersections reads terminal cross-sections back
116
+ // bitwise, so those floats must never move; straight ribbons stay byte-identical to before.
117
+ // • Only genuinely BENT interior vertices change: the inner edge gets a real miter join (the
118
+ // intersection of the two adjacent inner offset lines) and a forward-progress weld that
119
+ // collapses any residual fold, while the outer edge keeps the smooth naive offset.
120
+ const left = new Array(n);
121
+ const right = new Array(n);
122
+ const turnSign = new Float64Array(n); // >0 left turn (left edge inner), <0 right turn, 0 straight
123
+ const normX = new Float64Array(n);
124
+ const normZ = new Float64Array(n);
125
+ for (let i = 0; i < n; i += 1) {
80
126
  const [x, z] = points[i];
81
127
  const [nx, nz] = normalAt(points, i);
82
- const lx = x + nx * half;
83
- const lz = z + nz * half;
84
- const rx = x - nx * half;
85
- const rz = z - nz * half;
86
- positions[i * 6] = lx;
87
- positions[i * 6 + 1] = sampleHeight(lx, lz) + elevation;
88
- positions[i * 6 + 2] = lz;
89
- positions[i * 6 + 3] = rx;
90
- positions[i * 6 + 4] = sampleHeight(rx, rz) + elevation;
91
- positions[i * 6 + 5] = rz;
92
- }
93
- const indices = new Uint32Array((points.length - 1) * 6);
94
- for (let i = 0; i < points.length - 1; i += 1) {
128
+ normX[i] = nx;
129
+ normZ[i] = nz;
130
+ let lx = x + nx * half;
131
+ let lz = z + nz * half;
132
+ let rx = x - nx * half;
133
+ let rz = z - nz * half;
134
+ if (i > 0 && i < n - 1) {
135
+ const p = points[i - 1];
136
+ const q = points[i + 1];
137
+ const inx = x - p[0];
138
+ const inz = z - p[1];
139
+ const il = Math.hypot(inx, inz);
140
+ const oux = q[0] - x;
141
+ const ouz = q[1] - z;
142
+ const ol = Math.hypot(oux, ouz);
143
+ if (il > 1e-12 && ol > 1e-12) {
144
+ const dix = inx / il;
145
+ const diz = inz / il;
146
+ const dox = oux / ol;
147
+ const doz = ouz / ol;
148
+ const cross = dix * doz - diz * dox; // >0 left turn (left edge is inner), <0 right turn
149
+ if (Math.abs(cross) > 1e-9) {
150
+ turnSign[i] = cross;
151
+ // Left normals of the incoming/outgoing segments; their offset lines intersect at the miter.
152
+ const nix = -diz;
153
+ const niz = dix;
154
+ const nox = -doz;
155
+ const noz = dox;
156
+ const denom = 1 + (nix * nox + niz * noz); // 1 + cosθ; → 0 only at a ~180° reversal
157
+ if (denom > 1e-6) {
158
+ const mvx = (nix + nox) / denom;
159
+ const mvz = (niz + noz) / denom;
160
+ if (cross > 0) {
161
+ // Left edge is inner → miter it; right (outer) stays naive.
162
+ lx = x + mvx * half;
163
+ lz = z + mvz * half;
164
+ }
165
+ else {
166
+ // Right edge is inner → miter it; left (outer) stays naive.
167
+ rx = x - mvx * half;
168
+ rz = z - mvz * half;
169
+ }
170
+ }
171
+ }
172
+ }
173
+ }
174
+ left[i] = [lx, lz];
175
+ right[i] = [rx, rz];
176
+ }
177
+ // Collapse any inner-edge fold. At interior vertices only, weld an INNER-side vertex onto its kept
178
+ // predecessor when it either (a) fails to advance along the centreline tangent, or (b) has crossed
179
+ // to the far side of the centreline — both signatures of a fold when the turn radius drops below
180
+ // the half-width (the offset curve otherwise inverts and self-intersects). Straight runs and the
181
+ // outer edge of a bend are never inner, so they — and every straight ribbon — stay byte-identical.
182
+ const weldFolds = (edge, sideSign) => {
183
+ let kept = edge[0];
184
+ for (let i = 1; i < n - 1; i += 1) {
185
+ const inner = sideSign * turnSign[i] > 0;
186
+ if (!inner) {
187
+ kept = edge[i];
188
+ continue;
189
+ }
190
+ const p = points[i - 1];
191
+ const q = points[i + 1];
192
+ let tx = q[0] - p[0];
193
+ let tz = q[1] - p[1];
194
+ const tl = Math.hypot(tx, tz);
195
+ if (tl <= 1e-12) {
196
+ kept = edge[i];
197
+ continue;
198
+ }
199
+ tx /= tl;
200
+ tz /= tl;
201
+ const cx = points[i][0];
202
+ const cz = points[i][1];
203
+ const qx = edge[i][0];
204
+ const qz = edge[i][1];
205
+ const forward = (qx - kept[0]) * tx + (qz - kept[1]) * tz;
206
+ // Signed distance onto this edge's own outward normal — negative means it crossed the centreline.
207
+ const sideDist = (qx - cx) * (sideSign * normX[i]) + (qz - cz) * (sideSign * normZ[i]);
208
+ if (forward <= 1e-9 || sideDist <= 1e-9) {
209
+ edge[i] = [kept[0], kept[1]]; // fold → collapse onto the kept frontier
210
+ }
211
+ else {
212
+ kept = edge[i];
213
+ }
214
+ }
215
+ };
216
+ weldFolds(left, 1);
217
+ weldFolds(right, -1);
218
+ const positions = new Float32Array(n * 6);
219
+ for (let i = 0; i < n; i += 1) {
220
+ const l = left[i];
221
+ const r = right[i];
222
+ positions[i * 6] = l[0];
223
+ positions[i * 6 + 1] = sampleHeight(l[0], l[1]) + elevation;
224
+ positions[i * 6 + 2] = l[1];
225
+ positions[i * 6 + 3] = r[0];
226
+ positions[i * 6 + 4] = sampleHeight(r[0], r[1]) + elevation;
227
+ positions[i * 6 + 5] = r[1];
228
+ }
229
+ const indices = new Uint32Array((n - 1) * 6);
230
+ for (let i = 0; i < n - 1; i += 1) {
95
231
  const a = i * 2;
96
232
  indices[i * 6] = a;
97
233
  indices[i * 6 + 1] = a + 1;
@@ -103,13 +239,17 @@ export function buildRoadRibbon(path, width, sampleHeight, options = {}) {
103
239
  return { positions, indices };
104
240
  }
105
241
  /**
106
- * Build a flat, ground-draped disc patch centered on `center` — the welded junction surface that
107
- * covers crossing road ribbons so they read as one intersection instead of stacking/z-fighting.
108
- * A triangle fan of `segments` sides (default 16), every vertex draped at `sampleHeight + elevation`.
109
- * Pure geometry; the shell meshes the result with the asphalt material.
242
+ * Build a flat, ground-draped disc patch centered on `center` — a triangle fan of `segments` sides
243
+ * (default 16), every vertex draped at `sampleHeight + elevation`.
244
+ *
245
+ * @deprecated This floats a disc slightly above the crossing ribbons, which still overlaps and
246
+ * z-fights them. Prefer {@link buildJunctionSurface} (with {@link trimPathAtJunctions}, or the
247
+ * all-in-one {@link buildTrimmedIntersections}): it trims the ribbons back to the junction boundary
248
+ * and welds one seam-shared surface, with no overlap and no floating disc. Kept so existing callers
249
+ * don't break this round.
110
250
  */
111
251
  export function buildJunctionPatch(center, radius, sampleHeight, options = {}) {
112
- const elevation = options.elevation ?? 0.08;
252
+ const elevation = options.elevation ?? GROUND_DECAL_LAYERS.junction;
113
253
  const segments = Math.max(3, Math.floor(options.segments ?? 16));
114
254
  if (radius <= 0)
115
255
  return { positions: new Float32Array(0), indices: new Uint32Array(0) };
@@ -239,3 +379,1104 @@ export function pathLength(path) {
239
379
  }
240
380
  return total;
241
381
  }
382
+ /** Outward unit direction of a junction arm under the `atan2(dx, dz)` convention. */
383
+ function armDirection(angle) {
384
+ return [Math.sin(angle), Math.cos(angle)];
385
+ }
386
+ /**
387
+ * Apron pull-back for one approach: how far the ribbon must retreat so its mouth sits on the
388
+ * carriageway-union boundary of the *crossing* arms.
389
+ *
390
+ * For each arm that is neither this approach (dot ≈ +1) nor its through-continuation (dot ≈ −1),
391
+ * the clearance along this centerline is `(arm.width / 2) / sin(φ)` where φ is the angle between
392
+ * the two directions. Orthogonal equal-width roads therefore trim at half-width; a narrow stub
393
+ * into a wide boulevard trims farther; the wide boulevard only trims by the stub's half-width.
394
+ *
395
+ * The curb-return radius is deliberately **not** part of the apron — returns are exterior corner
396
+ * arcs only. Including them here was the oversized-plaza failure mode (mouths pushed out by R,
397
+ * then filleted again).
398
+ */
399
+ function apronDistance(junction, outward, margin,
400
+ /** Half-width of the approach being trimmed (for acute-angle mouth clearance). */
401
+ selfHalf = 0) {
402
+ const arms = junction.arms;
403
+ if (arms.length === 0)
404
+ return margin;
405
+ let crossMax = 0;
406
+ let anyCrosser = false;
407
+ let widestHalf = 0;
408
+ for (let j = 0; j < arms.length; j += 1) {
409
+ const half = arms[j].width / 2;
410
+ widestHalf = Math.max(widestHalf, half);
411
+ const [ax, az] = armDirection(arms[j].angle);
412
+ const dot = outward[0] * ax + outward[1] * az;
413
+ // Same-direction arm or opposite through-continuation — not a crosser.
414
+ if (dot > 0.85 || dot < -0.85)
415
+ continue;
416
+ // |sin φ| from the 2-D cross magnitude of unit directions.
417
+ const sinPhi = Math.abs(outward[0] * az - outward[1] * ax);
418
+ if (sinPhi < 1e-6)
419
+ continue;
420
+ anyCrosser = true;
421
+ // Clear the crossing carriageway projected along this centerline.
422
+ let needed = half / sinPhi;
423
+ // Degree-2 acute turns also need enough pull-back that the two mouth end-caps no longer
424
+ // properly cross (a 90° cross is fine at half-width; a 45° fork is not). Multi-arm nodes
425
+ // keep the pure projection so unequal five-ways stay compact.
426
+ if (arms.length === 2) {
427
+ const phi = Math.atan2(sinPhi, Math.max(-1, Math.min(1, Math.abs(dot))));
428
+ if (phi < Math.PI / 2 - 1e-6) {
429
+ const tanHalf = Math.tan(Math.max(phi / 2, 1e-3));
430
+ needed = Math.max(needed, (selfHalf + half) / (2 * tanHalf));
431
+ }
432
+ }
433
+ crossMax = Math.max(crossMax, needed);
434
+ }
435
+ if (!anyCrosser)
436
+ crossMax = widestHalf;
437
+ return crossMax + margin;
438
+ }
439
+ /** Clamp a requested curb-return radius by the two adjacent road widths and the corner chord. */
440
+ function clampCurbReturnRadius(requested, widthA, widthB, chord) {
441
+ if (requested <= 0 || chord <= 1e-9)
442
+ return 0;
443
+ // Ordinary street corners stay well below half the narrower carriageway.
444
+ const byWidth = 0.35 * Math.min(widthA, widthB);
445
+ // A circular return cannot exceed half the gap chord (degenerate semicircle).
446
+ const byChord = chord * 0.45;
447
+ return Math.max(0, Math.min(requested, byWidth, byChord));
448
+ }
449
+ /** Point + forward tangent at arc-length `dist` from the start of `sub`; used to place a cut. */
450
+ function pointAtArcLength(sub, dist) {
451
+ let acc = 0;
452
+ for (let i = 0; i < sub.length - 1; i += 1) {
453
+ const a = sub[i];
454
+ const b = sub[i + 1];
455
+ const dx = b[0] - a[0];
456
+ const dz = b[1] - a[1];
457
+ const segLen = Math.hypot(dx, dz);
458
+ if (acc + segLen >= dist || i === sub.length - 2) {
459
+ const t = segLen <= 0 ? 0 : Math.max(0, Math.min(1, (dist - acc) / segLen));
460
+ const len = segLen || 1;
461
+ return { point: [a[0] + dx * t, a[1] + dz * t], segment: i, tangent: [dx / len, dz / len] };
462
+ }
463
+ acc += segLen;
464
+ }
465
+ const last = sub[sub.length - 1];
466
+ return { point: [last[0], last[1]], segment: sub.length - 2, tangent: [1, 0] };
467
+ }
468
+ /** Left/right corners exactly as {@link buildRoadRibbon} would emit them at a cut (center ± half along ⟂tangent). */
469
+ function cornerPair(center, tangent, half) {
470
+ // Matches buildRoadRibbon's normalAt: normal = [-tangent.z, tangent.x]; left = +normal, right = −normal.
471
+ const nx = -tangent[1];
472
+ const nz = tangent[0];
473
+ return {
474
+ left: [center[0] + nx * half, center[1] + nz * half],
475
+ right: [center[0] - nx * half, center[1] - nz * half],
476
+ };
477
+ }
478
+ /**
479
+ * Cut a street centerline back from the junctions it passes through so its ribbon ENDS at the
480
+ * junction boundary instead of ploughing through the node. Junctions are matched to path vertices
481
+ * within `nodeEpsilon` (endpoints and interior vertices alike); a junction sitting on an interior
482
+ * vertex SPLITS the path into two independently-trimmed sub-paths. Each cut records the exact
483
+ * boundary point and the road's left/right edge corners there, so {@link buildJunctionSurface} can
484
+ * weld its polygon onto the identical vertices with no overlap.
485
+ *
486
+ * Pure geometry, allocation-aware, bounded by the vertex and junction counts. Returns one
487
+ * {@link TrimmedRoad} per surviving sub-path (a mid-polyline junction yields two).
488
+ */
489
+ export function trimPathAtJunctions(path, width, junctions, options = {}) {
490
+ if (path.length < 2 || width <= 0)
491
+ return [];
492
+ const margin = options.apronMargin ?? 0.25;
493
+ const eps = options.nodeEpsilon ?? 1e-6;
494
+ const half = width / 2;
495
+ const n = path.length;
496
+ // Map each path vertex to a coincident junction (or -1).
497
+ const vertJunction = new Array(n).fill(-1);
498
+ for (let vi = 0; vi < n; vi += 1) {
499
+ const p = path[vi];
500
+ for (let ji = 0; ji < junctions.length; ji += 1) {
501
+ const j = junctions[ji];
502
+ if (Math.hypot(p[0] - j.x, p[1] - j.z) <= eps) {
503
+ vertJunction[vi] = ji;
504
+ break;
505
+ }
506
+ }
507
+ }
508
+ // Boundaries split the polyline: start, every interior junction vertex, end.
509
+ const boundaries = [0];
510
+ for (let vi = 1; vi < n - 1; vi += 1)
511
+ if (vertJunction[vi] !== -1)
512
+ boundaries.push(vi);
513
+ boundaries.push(n - 1);
514
+ const result = [];
515
+ for (let r = 0; r < boundaries.length - 1; r += 1) {
516
+ const a = boundaries[r];
517
+ const b = boundaries[r + 1];
518
+ let sub = [];
519
+ for (let i = a; i <= b; i += 1)
520
+ sub.push([path[i][0], path[i][1]]);
521
+ if (sub.length < 2)
522
+ continue;
523
+ const startJ = vertJunction[a];
524
+ const endJ = vertJunction[b];
525
+ let startApron = 0;
526
+ let endApron = 0;
527
+ if (startJ !== -1) {
528
+ const dx = sub[1][0] - sub[0][0];
529
+ const dz = sub[1][1] - sub[0][1];
530
+ const l = Math.hypot(dx, dz) || 1;
531
+ startApron = apronDistance(junctions[startJ], [dx / l, dz / l], margin, half);
532
+ }
533
+ if (endJ !== -1) {
534
+ const last = sub.length - 1;
535
+ const dx = sub[last - 1][0] - sub[last][0];
536
+ const dz = sub[last - 1][1] - sub[last][1];
537
+ const l = Math.hypot(dx, dz) || 1;
538
+ endApron = apronDistance(junctions[endJ], [dx / l, dz / l], margin, half);
539
+ }
540
+ // Clamp so the two aprons never cross: leave a sliver of ribbon between adjacent junctions.
541
+ const runLen = pathLength(sub);
542
+ const avail = Math.max(0, runLen - 1e-4);
543
+ if (startApron + endApron > avail) {
544
+ const total = startApron + endApron || 1;
545
+ startApron = (startApron / total) * avail;
546
+ endApron = (endApron / total) * avail;
547
+ }
548
+ const cuts = [];
549
+ if (startApron > 1e-9) {
550
+ const { point, segment, tangent } = pointAtArcLength(sub, startApron);
551
+ const { left, right } = cornerPair(point, tangent, half);
552
+ cuts.push({ junctionIndex: startJ, at: "start", center: point, direction: tangent, left, right, apron: startApron });
553
+ const kept = [point];
554
+ for (let i = segment + 1; i < sub.length; i += 1)
555
+ kept.push(sub[i]);
556
+ sub = kept;
557
+ }
558
+ if (endApron > 1e-9 && sub.length >= 2) {
559
+ const target = pathLength(sub) - endApron;
560
+ const { point, segment, tangent } = pointAtArcLength(sub, target);
561
+ const { left, right } = cornerPair(point, tangent, half);
562
+ cuts.push({
563
+ junctionIndex: endJ,
564
+ at: "end",
565
+ center: point,
566
+ direction: [-tangent[0], -tangent[1]],
567
+ left,
568
+ right,
569
+ apron: endApron,
570
+ });
571
+ const kept = [];
572
+ for (let i = 0; i <= segment; i += 1)
573
+ kept.push(sub[i]);
574
+ kept.push(point);
575
+ sub = kept;
576
+ }
577
+ if (sub.length >= 2 && pathLength(sub) > 1e-6)
578
+ result.push({ path: sub, cuts });
579
+ }
580
+ return result;
581
+ }
582
+ /**
583
+ * Cut an offset band (a polyline running PARALLEL to a street — e.g. a sidewalk from
584
+ * `Street.sidewalks.{left,right}`) out of every junction's apron, so the band stops at the crossing
585
+ * instead of sailing straight through it. Unlike {@link trimPathAtJunctions} the band never passes
586
+ * through a node, so it is clipped by DISTANCE: each junction contributes a circular apron of radius
587
+ * `max arm half-width + apronMargin + bandWidth/2 + clearance` (plus a small curb-return allowance),
588
+ * and any part of the band inside any apron circle is removed, splitting the band into the sub-paths
589
+ * that survive outside. Entry/exit points land exactly on the apron boundary. A band that never
590
+ * enters an apron passes through as a single untouched copy.
591
+ *
592
+ * Pure geometry, deterministic, bounded by the band-vertex and junction counts.
593
+ *
594
+ * @example
595
+ * ```ts
596
+ * // Same `junctions` you fed the roads; trim both sidewalks of a street around every node.
597
+ * const left = trimBandAtJunctions(street.sidewalks.left, street.sidewalkWidth, junctions);
598
+ * const right = trimBandAtJunctions(street.sidewalks.right, street.sidewalkWidth, junctions);
599
+ * for (const sub of [...left, ...right]) meshes.push(buildRoadRibbon(sub, street.sidewalkWidth, sampleHeight));
600
+ * ```
601
+ *
602
+ * @capability world-intersections trim offset sidewalk/parallel bands around junction aprons
603
+ */
604
+ export function trimBandAtJunctions(bandPath, bandWidth, junctions, options = {}) {
605
+ if (bandPath.length < 2)
606
+ return [];
607
+ const curbReturnRadius = options.curbReturnRadius ?? 2;
608
+ const margin = options.apronMargin ?? 0.25;
609
+ const clearance = options.clearance ?? 0;
610
+ const half = Math.max(0, bandWidth) / 2;
611
+ const circles = [];
612
+ for (let ji = 0; ji < junctions.length; ji += 1) {
613
+ const j = junctions[ji];
614
+ let maxHalf = 0;
615
+ let minWidth = Infinity;
616
+ for (let a = 0; a < j.arms.length; a += 1) {
617
+ maxHalf = Math.max(maxHalf, j.arms[a].width / 2);
618
+ minWidth = Math.min(minWidth, j.arms[a].width);
619
+ }
620
+ // Small curb allowance (not the full return) so parallel bands clear exterior corner arcs.
621
+ const curbAllow = Math.min(curbReturnRadius, 0.35 * (Number.isFinite(minWidth) ? minWidth : 0));
622
+ const r = maxHalf + margin + half + clearance + curbAllow;
623
+ if (r > 0)
624
+ circles.push({ x: j.x, z: j.z, r2: r * r });
625
+ }
626
+ const copy = () => bandPath.map((p) => [p[0], p[1]]);
627
+ if (circles.length === 0)
628
+ return [copy()];
629
+ const insideAny = (x, z) => {
630
+ for (let c = 0; c < circles.length; c += 1) {
631
+ const dx = x - circles[c].x;
632
+ const dz = z - circles[c].z;
633
+ if (dx * dx + dz * dz < circles[c].r2)
634
+ return true;
635
+ }
636
+ return false;
637
+ };
638
+ const result = [];
639
+ let cur = [];
640
+ const eps = 1e-9;
641
+ const pushPoint = (p) => {
642
+ const last = cur[cur.length - 1];
643
+ if (last === undefined || Math.hypot(last[0] - p[0], last[1] - p[1]) > eps)
644
+ cur.push(p);
645
+ };
646
+ const closeSub = () => {
647
+ if (cur.length >= 2 && pathLength(cur) > eps)
648
+ result.push(cur);
649
+ cur = [];
650
+ };
651
+ for (let i = 0; i < bandPath.length - 1; i += 1) {
652
+ const a = bandPath[i];
653
+ const b = bandPath[i + 1];
654
+ const dx = b[0] - a[0];
655
+ const dz = b[1] - a[1];
656
+ // Every boundary crossing t in (0,1) where the segment meets a circle edge.
657
+ const ts = [0, 1];
658
+ for (let c = 0; c < circles.length; c += 1) {
659
+ const circle = circles[c];
660
+ const fx = a[0] - circle.x;
661
+ const fz = a[1] - circle.z;
662
+ const A = dx * dx + dz * dz;
663
+ if (A < eps)
664
+ continue;
665
+ const B = 2 * (fx * dx + fz * dz);
666
+ const C = fx * fx + fz * fz - circle.r2;
667
+ const disc = B * B - 4 * A * C;
668
+ if (disc <= 0)
669
+ continue;
670
+ const sq = Math.sqrt(disc);
671
+ const t0 = (-B - sq) / (2 * A);
672
+ const t1 = (-B + sq) / (2 * A);
673
+ if (t0 > eps && t0 < 1 - eps)
674
+ ts.push(t0);
675
+ if (t1 > eps && t1 < 1 - eps)
676
+ ts.push(t1);
677
+ }
678
+ ts.sort((p, q) => p - q);
679
+ for (let s = 0; s < ts.length - 1; s += 1) {
680
+ const t0 = ts[s];
681
+ const t1 = ts[s + 1];
682
+ if (t1 - t0 < eps)
683
+ continue;
684
+ const tm = (t0 + t1) / 2;
685
+ const mx = a[0] + dx * tm;
686
+ const mz = a[1] + dz * tm;
687
+ if (insideAny(mx, mz)) {
688
+ closeSub(); // this piece is inside an apron — drop it and break continuity
689
+ continue;
690
+ }
691
+ const p0 = [a[0] + dx * t0, a[1] + dz * t0];
692
+ const p1 = [a[0] + dx * t1, a[1] + dz * t1];
693
+ pushPoint(p0);
694
+ pushPoint(p1);
695
+ }
696
+ }
697
+ closeSub();
698
+ return result;
699
+ }
700
+ /**
701
+ * Build a tangent-continuous centerline through a two-arm junction. This is the shared path for
702
+ * paint, medians, curbs, or other linear dressing that should follow a bend but stop at crossings.
703
+ * Returns `null` for anything other than exactly two approaches.
704
+ * @capability world-intersections connect linear road dressing through two-arm bends
705
+ */
706
+ export function buildJunctionConnector(junction, approaches, segments = 8) {
707
+ if (approaches.length !== 2)
708
+ return null;
709
+ const a = approaches[0].center;
710
+ const b = approaches[1].center;
711
+ const aRadius = Math.hypot(a[0] - junction.x, a[1] - junction.z);
712
+ const bRadius = Math.hypot(b[0] - junction.x, b[1] - junction.z);
713
+ let adx = approaches[0].direction?.[0] ?? a[0] - junction.x;
714
+ let adz = approaches[0].direction?.[1] ?? a[1] - junction.z;
715
+ let bdx = approaches[1].direction?.[0] ?? b[0] - junction.x;
716
+ let bdz = approaches[1].direction?.[1] ?? b[1] - junction.z;
717
+ const al = Math.hypot(adx, adz);
718
+ const bl = Math.hypot(bdx, bdz);
719
+ if (al < 1e-6 || bl < 1e-6)
720
+ return null;
721
+ adx /= al;
722
+ adz /= al;
723
+ bdx /= bl;
724
+ bdz /= bl;
725
+ const chord = Math.hypot(b[0] - a[0], b[1] - a[1]);
726
+ const handle = Math.min(aRadius, bRadius, chord * 0.45);
727
+ const c1x = a[0] - adx * handle;
728
+ const c1z = a[1] - adz * handle;
729
+ const c2x = b[0] - bdx * handle;
730
+ const c2z = b[1] - bdz * handle;
731
+ const count = Math.max(2, Math.min(64, Math.floor(Number.isFinite(segments) ? segments : 8)));
732
+ const path = [];
733
+ for (let i = 0; i <= count; i += 1) {
734
+ const t = i / count;
735
+ const u = 1 - t;
736
+ path.push([
737
+ u * u * u * a[0] + 3 * u * u * t * c1x + 3 * u * t * t * c2x + t * t * t * b[0],
738
+ u * u * u * a[1] + 3 * u * u * t * c1z + 3 * u * t * t * c2z + t * t * t * b[1],
739
+ ]);
740
+ }
741
+ return path;
742
+ }
743
+ /**
744
+ * Weld one triangulated junction surface onto the corner vertices its incident ribbons END at
745
+ * (from {@link trimPathAtJunctions}). Corners are grouped by approach (so unequal widths cannot
746
+ * interleave a neighbour between a mouth pair), ordered around the node, and bridged as follows:
747
+ *
748
+ * - **Road mouth** — the straight end-edge of the approach (shared seam with the ribbon).
749
+ * - **Through edge (~180°)** — straight chord along the continuing curb (T-junction far side).
750
+ * - **Exterior corner** — a compact circular curb-return of radius ≤ `curbReturnRadius`, clamped by
751
+ * adjacent widths and chord length; bows *outward* only, never a plaza disc.
752
+ * - **Two-arm turn** — both edges get the same compact returns (no diagonal cap, no remote tangent).
753
+ *
754
+ * The boundary is ear-clipped so every triangle faces +Y. Draped height matches
755
+ * {@link buildRoadRibbon}. Mouth corners are the approach corners verbatim.
756
+ */
757
+ export function buildJunctionSurface(junction, approaches, sampleHeight, options = {}) {
758
+ if (approaches.length === 0)
759
+ return { positions: new Float32Array(0), indices: new Uint32Array(0) };
760
+ const elevation = options.elevation ?? GROUND_DECAL_LAYERS.junction;
761
+ const curbReturnRadius = options.curbReturnRadius ?? 2;
762
+ const filletSegments = Math.max(1, Math.floor(options.filletSegments ?? 4));
763
+ const cx = junction.x;
764
+ const cz = junction.z;
765
+ // Wrap an angle difference into (−π, π].
766
+ const wrapAngle = (a) => {
767
+ let v = a;
768
+ while (v <= -Math.PI)
769
+ v += Math.PI * 2;
770
+ while (v > Math.PI)
771
+ v -= Math.PI * 2;
772
+ return v;
773
+ };
774
+ const ordered = [];
775
+ for (let i = 0; i < approaches.length; i += 1) {
776
+ const ap = approaches[i];
777
+ const outAngle = ap.direction === undefined
778
+ ? Math.atan2(ap.center[1] - cz, ap.center[0] - cx)
779
+ : Math.atan2(ap.direction[1], ap.direction[0]);
780
+ const leftC = { x: ap.left[0], y: ap.left[1], z: ap.left[2] };
781
+ const rightC = { x: ap.right[0], y: ap.right[1], z: ap.right[2] };
782
+ const leftOff = wrapAngle(Math.atan2(leftC.z - cz, leftC.x - cx) - outAngle);
783
+ const rightOff = wrapAngle(Math.atan2(rightC.z - cz, rightC.x - cx) - outAngle);
784
+ // `first` is the CW-most corner, `second` the CCW-most for a CCW walk around the node.
785
+ const firstIsLeft = leftOff <= rightOff;
786
+ ordered.push({
787
+ first: firstIsLeft ? leftC : rightC,
788
+ second: firstIsLeft ? rightC : leftC,
789
+ firstAngle: Math.atan2((firstIsLeft ? leftC : rightC).z - cz, (firstIsLeft ? leftC : rightC).x - cx),
790
+ secondAngle: Math.atan2((firstIsLeft ? rightC : leftC).z - cz, (firstIsLeft ? rightC : leftC).x - cx),
791
+ outAngle,
792
+ width: ap.width ?? Math.hypot(ap.left[0] - ap.right[0], ap.left[2] - ap.right[2]),
793
+ });
794
+ }
795
+ ordered.sort((p, q) => p.outAngle - q.outAngle);
796
+ /**
797
+ * Compact cubic curb between two mouth corners. Handles follow each approach's edge direction
798
+ * (parallel to the centerline) and are capped so remote edge intersections cannot recreate the
799
+ * plaza-sized blob. Samples stay on the outward side of the chord.
800
+ */
801
+ const pushCurbCubic = (from, to, outAngleA, outAngleB, handle, into) => {
802
+ const L = Math.hypot(to.x - from.x, to.z - from.z);
803
+ if (L < 1e-6 || handle < 1e-6)
804
+ return;
805
+ const adx = Math.cos(outAngleA);
806
+ const adz = Math.sin(outAngleA);
807
+ const bdx = Math.cos(outAngleB);
808
+ const bdz = Math.sin(outAngleB);
809
+ const den = adx * bdz - adz * bdx;
810
+ let hA = handle;
811
+ let hB = handle;
812
+ // Prefer the edge-tangent intersection when it lies a short, finite distance away; otherwise
813
+ // fall back to equal capped handles along each edge.
814
+ if (Math.abs(den) > 1e-6) {
815
+ const qx = to.x - from.x;
816
+ const qz = to.z - from.z;
817
+ const alongA = (qx * bdz - qz * bdx) / den;
818
+ const alongB = (qx * adz - qz * adx) / den;
819
+ const cap = (v) => Math.max(-handle, Math.min(handle, v));
820
+ hA = cap(alongA);
821
+ hB = cap(alongB);
822
+ }
823
+ const c1x = from.x + adx * hA;
824
+ const c1z = from.z + adz * hA;
825
+ const c2x = to.x + bdx * hB;
826
+ const c2z = to.z + bdz * hB;
827
+ // Reject cubics that dive through the junction interior (would look like a diagonal cut).
828
+ const midT = 0.5;
829
+ const u = 1 - midT;
830
+ const mx = u * u * u * from.x + 3 * u * u * midT * c1x + 3 * u * midT * midT * c2x + midT * midT * midT * to.x;
831
+ const mz = u * u * u * from.z + 3 * u * u * midT * c1z + 3 * u * midT * midT * c2z + midT * midT * midT * to.z;
832
+ const chordMx = (from.x + to.x) / 2;
833
+ const chordMz = (from.z + to.z) / 2;
834
+ // Outward = away from node relative to the chord midpoint.
835
+ const away = (chordMx - cx) * (mx - chordMx) + (chordMz - cz) * (mz - chordMz);
836
+ if (away < -1e-6) {
837
+ // Flip handle signs so the bulge faces outward.
838
+ const f1x = from.x - adx * hA;
839
+ const f1z = from.z - adz * hA;
840
+ const f2x = to.x - bdx * hB;
841
+ const f2z = to.z - bdz * hB;
842
+ for (let s = 1; s < filletSegments; s += 1) {
843
+ const t = s / filletSegments;
844
+ const uu = 1 - t;
845
+ const x = uu * uu * uu * from.x + 3 * uu * uu * t * f1x + 3 * uu * t * t * f2x + t * t * t * to.x;
846
+ const z = uu * uu * uu * from.z + 3 * uu * uu * t * f1z + 3 * uu * t * t * f2z + t * t * t * to.z;
847
+ into.push({ x, y: sampleHeight(x, z) + elevation, z });
848
+ }
849
+ return;
850
+ }
851
+ for (let s = 1; s < filletSegments; s += 1) {
852
+ const t = s / filletSegments;
853
+ const uu = 1 - t;
854
+ const x = uu * uu * uu * from.x + 3 * uu * uu * t * c1x + 3 * uu * t * t * c2x + t * t * t * to.x;
855
+ const z = uu * uu * uu * from.z + 3 * uu * uu * t * c1z + 3 * uu * t * t * c2z + t * t * t * to.z;
856
+ into.push({ x, y: sampleHeight(x, z) + elevation, z });
857
+ }
858
+ };
859
+ const boundary = [];
860
+ let angularFallback = false;
861
+ for (let k = 0; k < ordered.length; k += 1) {
862
+ const ap = ordered[k];
863
+ boundary.push(ap.first);
864
+ boundary.push(ap.second);
865
+ if (ordered.length < 2)
866
+ continue;
867
+ const nxt = ordered[(k + 1) % ordered.length];
868
+ const cur = ap.second;
869
+ const to = nxt.first;
870
+ const L = Math.hypot(to.x - cur.x, to.z - cur.z);
871
+ if (L < 1e-6)
872
+ continue;
873
+ let armGap = wrapAngle(nxt.outAngle - ap.outAngle);
874
+ if (armGap < 0)
875
+ armGap += Math.PI * 2;
876
+ // Near-parallel two-arm fork: keep the four mouth corners as a compact star-shaped polygon.
877
+ if (ordered.length === 2 && Math.min(armGap, Math.PI * 2 - armGap) < (35 * Math.PI) / 180) {
878
+ angularFallback = true;
879
+ continue;
880
+ }
881
+ if (ordered.length === 2) {
882
+ // Degree-2 bend: the pavement is the L-union of the two strips. The short gap is the inside
883
+ // curb return; the long gap must pass through the outer edge-intersection so the surface is
884
+ // an L, not a diagonal chord (which under-covers) or an interior cubic (which self-crosses).
885
+ const minW = Math.min(ap.width, nxt.width);
886
+ const isOuter = L > minW * 0.75;
887
+ if (!isOuter) {
888
+ const handle = Math.min(curbReturnRadius * 0.55, L * 0.4, minW * 0.35);
889
+ if (handle > 1e-6)
890
+ pushCurbCubic(cur, to, ap.outAngle, nxt.outAngle, handle, boundary);
891
+ continue;
892
+ }
893
+ const adx = Math.cos(ap.outAngle);
894
+ const adz = Math.sin(ap.outAngle);
895
+ const bdx = Math.cos(nxt.outAngle);
896
+ const bdz = Math.sin(nxt.outAngle);
897
+ const den = adx * bdz - adz * bdx;
898
+ if (Math.abs(den) <= 1e-6)
899
+ continue;
900
+ const qx = to.x - cur.x;
901
+ const qz = to.z - cur.z;
902
+ const alongA = (qx * bdz - qz * bdx) / den;
903
+ const alongB = (qx * adz - qz * adx) / den;
904
+ // Finite outer corner of the two edge lines; reject near-parallel runaway intersections.
905
+ const maxReach = Math.max(minW * 3, L * 1.5);
906
+ if (!Number.isFinite(alongA) || !Number.isFinite(alongB))
907
+ continue;
908
+ if (Math.abs(alongA) > maxReach || Math.abs(alongB) > maxReach)
909
+ continue;
910
+ const ix = cur.x + adx * alongA;
911
+ const iz = cur.z + adz * alongA;
912
+ // Must lie on the far side of the chord from the node (true outer corner).
913
+ const chordMx = (cur.x + to.x) / 2;
914
+ const chordMz = (cur.z + to.z) / 2;
915
+ if ((ix - chordMx) * (chordMx - cx) + (iz - chordMz) * (chordMz - cz) > 1e-6)
916
+ continue;
917
+ // Sharp L corner (optionally softened by a few samples pulled slightly toward the corner from
918
+ // each edge so the outer curb is not a single needle vertex on very acute angles).
919
+ const soft = Math.min(curbReturnRadius, minW * 0.25, Math.abs(alongA) * 0.35, Math.abs(alongB) * 0.35);
920
+ if (soft > 1e-3 && filletSegments > 1) {
921
+ for (let s = 1; s < filletSegments; s += 1) {
922
+ const t = s / filletSegments;
923
+ // Quadratic Bezier cur → outer-corner → to: always simple, always covers the L.
924
+ const u = 1 - t;
925
+ const x = u * u * cur.x + 2 * u * t * ix + t * t * to.x;
926
+ const z = u * u * cur.z + 2 * u * t * iz + t * t * to.z;
927
+ boundary.push({ x, y: sampleHeight(x, z) + elevation, z });
928
+ }
929
+ }
930
+ else {
931
+ boundary.push({ x: ix, y: sampleHeight(ix, iz) + elevation, z: iz });
932
+ }
933
+ continue;
934
+ }
935
+ // Multi-arm through-road far side (~180°): straight curb along the continuing carriageway.
936
+ if (armGap > (150 * Math.PI) / 180)
937
+ continue;
938
+ // Skip if the next mouth angularly overlaps (would sweep backward across a corner).
939
+ if (wrapAngle(nxt.firstAngle - ap.secondAngle) <= 1e-9)
940
+ continue;
941
+ const R = clampCurbReturnRadius(curbReturnRadius, ap.width, nxt.width, L);
942
+ if (R < 1e-6)
943
+ continue;
944
+ // Cubic quarter-circle uses ~0.55R handles; also never exceed the chord budget.
945
+ pushCurbCubic(cur, to, ap.outAngle, nxt.outAngle, Math.min(R * 0.55228475, L * 0.4), boundary);
946
+ }
947
+ if (angularFallback) {
948
+ boundary.sort((p, q) => Math.atan2(p.z - cz, p.x - cx) - Math.atan2(q.z - cz, q.x - cx));
949
+ }
950
+ const m = boundary.length;
951
+ const positions = new Float32Array((m + 1) * 3);
952
+ positions[0] = cx;
953
+ positions[1] = sampleHeight(cx, cz) + elevation;
954
+ positions[2] = cz;
955
+ for (let k = 0; k < m; k += 1) {
956
+ const p = boundary[k];
957
+ const base = (k + 1) * 3;
958
+ positions[base] = p.x;
959
+ positions[base + 1] = p.y;
960
+ positions[base + 2] = p.z;
961
+ }
962
+ // Ear-clip the grouped ring. A center fan requires a star-shaped polygon and the old global angle
963
+ // sort made one by separating unequal-width approach corners, destroying the exact mouth seam and
964
+ // leaving visible triangular holes. Ear clipping keeps every grouped boundary edge intact.
965
+ const idx = [];
966
+ const signedCross = (a, b, c) => {
967
+ const pa = boundary[a];
968
+ const pb = boundary[b];
969
+ const pc = boundary[c];
970
+ return (pb.x - pa.x) * (pc.z - pa.z) - (pb.z - pa.z) * (pc.x - pa.x);
971
+ };
972
+ let area = 0;
973
+ for (let i = 0; i < m; i += 1) {
974
+ const a = boundary[i];
975
+ const b = boundary[(i + 1) % m];
976
+ area += a.x * b.z - b.x * a.z;
977
+ }
978
+ const orientation = area >= 0 ? 1 : -1;
979
+ const pointInTriangle = (p, a, b, c) => {
980
+ const ab = signedCross(a, b, p) * orientation;
981
+ const bc = signedCross(b, c, p) * orientation;
982
+ const ca = signedCross(c, a, p) * orientation;
983
+ return ab >= -1e-9 && bc >= -1e-9 && ca >= -1e-9;
984
+ };
985
+ const remaining = Array.from({ length: m }, (_, i) => i);
986
+ let guard = 0;
987
+ while (remaining.length > 3 && guard < m * m) {
988
+ guard += 1;
989
+ let clipped = false;
990
+ for (let i = 0; i < remaining.length; i += 1) {
991
+ const prev = remaining[(i - 1 + remaining.length) % remaining.length];
992
+ const ear = remaining[i];
993
+ const next = remaining[(i + 1) % remaining.length];
994
+ if (signedCross(prev, ear, next) * orientation <= 1e-9)
995
+ continue;
996
+ let contains = false;
997
+ for (let j = 0; j < remaining.length; j += 1) {
998
+ const point = remaining[j];
999
+ if (point === prev || point === ear || point === next)
1000
+ continue;
1001
+ if (pointInTriangle(point, prev, ear, next)) {
1002
+ contains = true;
1003
+ break;
1004
+ }
1005
+ }
1006
+ if (contains)
1007
+ continue;
1008
+ // Standard XZ CCW points down in Three's X/Y/Z winding, so reverse CCW rings for +Y.
1009
+ if (orientation > 0)
1010
+ idx.push(prev + 1, next + 1, ear + 1);
1011
+ else
1012
+ idx.push(prev + 1, ear + 1, next + 1);
1013
+ remaining.splice(i, 1);
1014
+ clipped = true;
1015
+ break;
1016
+ }
1017
+ if (!clipped)
1018
+ break;
1019
+ }
1020
+ if (remaining.length === 3) {
1021
+ if (orientation > 0)
1022
+ idx.push(remaining[0] + 1, remaining[2] + 1, remaining[1] + 1);
1023
+ else
1024
+ idx.push(remaining[0] + 1, remaining[1] + 1, remaining[2] + 1);
1025
+ }
1026
+ return { positions, indices: Uint32Array.from(idx) };
1027
+ }
1028
+ function offsetPath(path, offset) {
1029
+ const out = new Array(path.length);
1030
+ for (let i = 0; i < path.length; i += 1) {
1031
+ const [nx, nz] = normalAt(path, i);
1032
+ out[i] = [path[i][0] + nx * offset, path[i][1] + nz * offset];
1033
+ }
1034
+ return out;
1035
+ }
1036
+ function buildRoadSidewalk(road, side, width, sampleHeight, elevation) {
1037
+ const count = road.positions.length / 6;
1038
+ if (count < 2 || width <= 0)
1039
+ return { positions: new Float32Array(0), indices: new Uint32Array(0) };
1040
+ const positions = new Float32Array(count * 6);
1041
+ for (let i = 0; i < count; i += 1) {
1042
+ const leftX = road.positions[i * 6];
1043
+ const leftZ = road.positions[i * 6 + 2];
1044
+ const rightX = road.positions[i * 6 + 3];
1045
+ const rightZ = road.positions[i * 6 + 5];
1046
+ const innerX = side === "left" ? leftX : rightX;
1047
+ const innerZ = side === "left" ? leftZ : rightZ;
1048
+ const otherX = side === "left" ? rightX : leftX;
1049
+ const otherZ = side === "left" ? rightZ : leftZ;
1050
+ const dx = innerX - otherX;
1051
+ const dz = innerZ - otherZ;
1052
+ const length = Math.hypot(dx, dz) || 1;
1053
+ const outerX = innerX + (dx / length) * width;
1054
+ const outerZ = innerZ + (dz / length) * width;
1055
+ positions[i * 6] = innerX;
1056
+ positions[i * 6 + 1] = sampleHeight(innerX, innerZ) + elevation;
1057
+ positions[i * 6 + 2] = innerZ;
1058
+ positions[i * 6 + 3] = outerX;
1059
+ positions[i * 6 + 4] = sampleHeight(outerX, outerZ) + elevation;
1060
+ positions[i * 6 + 5] = outerZ;
1061
+ }
1062
+ const indices = new Uint32Array((count - 1) * 6);
1063
+ const writeUpTriangle = (at, i0, i1, i2) => {
1064
+ const ax = positions[i1 * 3] - positions[i0 * 3];
1065
+ const az = positions[i1 * 3 + 2] - positions[i0 * 3 + 2];
1066
+ const bx = positions[i2 * 3] - positions[i0 * 3];
1067
+ const bz = positions[i2 * 3 + 2] - positions[i0 * 3 + 2];
1068
+ indices[at] = i0;
1069
+ if (az * bx - ax * bz >= 0) {
1070
+ indices[at + 1] = i1;
1071
+ indices[at + 2] = i2;
1072
+ }
1073
+ else {
1074
+ indices[at + 1] = i2;
1075
+ indices[at + 2] = i1;
1076
+ }
1077
+ };
1078
+ for (let i = 0; i < count - 1; i += 1) {
1079
+ const a = i * 2;
1080
+ writeUpTriangle(i * 6, a, a + 1, a + 2);
1081
+ writeUpTriangle(i * 6 + 3, a + 1, a + 3, a + 2);
1082
+ }
1083
+ return { positions, indices };
1084
+ }
1085
+ function buildSidewalkApron(junction, surface, approaches, sampleHeight, elevation) {
1086
+ const ring = [];
1087
+ for (let v = 1; v * 3 + 2 < surface.positions.length; v += 1) {
1088
+ ring.push([surface.positions[v * 3], surface.positions[v * 3 + 2]]);
1089
+ }
1090
+ if (ring.length < 2)
1091
+ return { positions: new Float32Array(0), indices: new Uint32Array(0) };
1092
+ const corners = [];
1093
+ for (let i = 0; i < approaches.length; i += 1) {
1094
+ const ap = approaches[i];
1095
+ const dx = ap.direction?.[0] ?? ap.center[0] - junction.x;
1096
+ const dz = ap.direction?.[1] ?? ap.center[1] - junction.z;
1097
+ const length = Math.hypot(dx, dz) || 1;
1098
+ const ux = dx / length;
1099
+ const uz = dz / length;
1100
+ // Left normal of the outward approach direction (matches ribbon left/right).
1101
+ const nx = -uz;
1102
+ const nz = ux;
1103
+ const leftWidth = Math.max(0, ap.sidewalkWidths?.left ?? 0);
1104
+ const rightWidth = Math.max(0, ap.sidewalkWidths?.right ?? 0);
1105
+ const sourceDot = (ap.sourceTangent?.[0] ?? ux) * ux + (ap.sourceTangent?.[1] ?? uz) * uz;
1106
+ const left = [ap.left[0], ap.left[2]];
1107
+ const right = [ap.right[0], ap.right[2]];
1108
+ const sourceSign = sourceDot >= 0 ? 1 : -1;
1109
+ corners.push({
1110
+ point: left,
1111
+ outer: [left[0] + nx * leftWidth * sourceSign, left[1] + nz * leftWidth * sourceSign],
1112
+ approach: i,
1113
+ width: leftWidth,
1114
+ });
1115
+ corners.push({
1116
+ point: right,
1117
+ outer: [right[0] - nx * rightWidth * sourceSign, right[1] - nz * rightWidth * sourceSign],
1118
+ approach: i,
1119
+ width: rightWidth,
1120
+ });
1121
+ }
1122
+ if (corners.length === 0)
1123
+ return { positions: new Float32Array(0), indices: new Uint32Array(0) };
1124
+ const nearestCorner = (p) => {
1125
+ let corner = corners[0];
1126
+ let distance = Infinity;
1127
+ for (let i = 0; i < corners.length; i += 1) {
1128
+ const candidate = corners[i];
1129
+ const d = Math.hypot(candidate.point[0] - p[0], candidate.point[1] - p[1]);
1130
+ if (d < distance) {
1131
+ corner = candidate;
1132
+ distance = d;
1133
+ }
1134
+ }
1135
+ return { corner, distance };
1136
+ };
1137
+ /**
1138
+ * Offset a pavement-boundary point outward along the local ring normal by the sidewalk width.
1139
+ * Using the ring normal (not a radial vector from the junction) keeps the apron a constant-width
1140
+ * band around the curb — no giant annular/octagonal pads.
1141
+ */
1142
+ const outerAt = (p, index) => {
1143
+ const nearest = nearestCorner(p);
1144
+ if (nearest.distance < 1e-3)
1145
+ return nearest.corner.outer;
1146
+ const prev = ring[(index - 1 + ring.length) % ring.length];
1147
+ const next = ring[(index + 1) % ring.length];
1148
+ let tx = next[0] - prev[0];
1149
+ let tz = next[1] - prev[1];
1150
+ const tl = Math.hypot(tx, tz);
1151
+ if (tl < 1e-9) {
1152
+ tx = next[0] - p[0];
1153
+ tz = next[1] - p[1];
1154
+ }
1155
+ const tlen = Math.hypot(tx, tz) || 1;
1156
+ tx /= tlen;
1157
+ tz /= tlen;
1158
+ // Outward normal: perpendicular of the tangent pointing away from the node.
1159
+ let nx = -tz;
1160
+ let nz = tx;
1161
+ if ((p[0] - junction.x) * nx + (p[1] - junction.z) * nz < 0) {
1162
+ nx = -nx;
1163
+ nz = -nz;
1164
+ }
1165
+ // Interpolate width from the two nearest mouth corners so unequal sidewalks blend around the arc.
1166
+ let w0 = nearest.corner.width;
1167
+ let w1 = w0;
1168
+ let d1 = Infinity;
1169
+ for (const candidate of corners) {
1170
+ if (candidate === nearest.corner)
1171
+ continue;
1172
+ const d = Math.hypot(candidate.point[0] - p[0], candidate.point[1] - p[1]);
1173
+ if (d < d1) {
1174
+ d1 = d;
1175
+ w1 = candidate.width;
1176
+ }
1177
+ }
1178
+ const t = nearest.distance + d1 > 1e-9 ? d1 / (nearest.distance + d1) : 0.5;
1179
+ const width = Math.max(0, w0 * t + w1 * (1 - t));
1180
+ if (width < 1e-9)
1181
+ return p;
1182
+ return [p[0] + nx * width, p[1] + nz * width];
1183
+ };
1184
+ const positions = [];
1185
+ const indices = [];
1186
+ const vertices = new Map();
1187
+ const vertex = (p) => {
1188
+ const key = `${p[0]},${p[1]}`;
1189
+ const found = vertices.get(key);
1190
+ if (found !== undefined)
1191
+ return found;
1192
+ const index = positions.length / 3;
1193
+ positions.push(p[0], sampleHeight(p[0], p[1]) + elevation, p[1]);
1194
+ vertices.set(key, index);
1195
+ return index;
1196
+ };
1197
+ const pushUpTriangle = (i0, i1, i2) => {
1198
+ const ax = positions[i1 * 3] - positions[i0 * 3];
1199
+ const az = positions[i1 * 3 + 2] - positions[i0 * 3 + 2];
1200
+ const bx = positions[i2 * 3] - positions[i0 * 3];
1201
+ const bz = positions[i2 * 3 + 2] - positions[i0 * 3 + 2];
1202
+ if (az * bx - ax * bz >= 0)
1203
+ indices.push(i0, i1, i2);
1204
+ else
1205
+ indices.push(i0, i2, i1);
1206
+ };
1207
+ for (let i = 0; i < ring.length; i += 1) {
1208
+ const a = ring[i];
1209
+ const b = ring[(i + 1) % ring.length];
1210
+ const ca = nearestCorner(a);
1211
+ const cb = nearestCorner(b);
1212
+ // The paired corners of one approach are a road mouth, not sidewalk: leave it open.
1213
+ if (ca.distance < 1e-3 && cb.distance < 1e-3 && ca.corner.approach === cb.corner.approach)
1214
+ continue;
1215
+ const ao = outerAt(a, i);
1216
+ const bo = outerAt(b, (i + 1) % ring.length);
1217
+ if (Math.hypot(ao[0] - a[0], ao[1] - a[1]) < 1e-9 && Math.hypot(bo[0] - b[0], bo[1] - b[1]) < 1e-9)
1218
+ continue;
1219
+ const ai = vertex(a);
1220
+ const bi = vertex(b);
1221
+ const aoi = vertex(ao);
1222
+ const boi = vertex(bo);
1223
+ pushUpTriangle(ai, bi, aoi);
1224
+ pushUpTriangle(bi, boi, aoi);
1225
+ }
1226
+ return { positions: Float32Array.from(positions), indices: Uint32Array.from(indices) };
1227
+ }
1228
+ /**
1229
+ * Trim a set of streets against a set of junctions and weld the crossing surfaces in one call — the
1230
+ * ergonomic entry the shell/playground consume for meshing.
1231
+ *
1232
+ * Each street is trimmed by {@link trimPathAtJunctions} (through-streets split in two), each surviving
1233
+ * sub-path is meshed with {@link buildRoadRibbon}, and every junction that any ribbon actually ends
1234
+ * at gets one {@link buildJunctionSurface}. The junction's boundary vertices are read back BITWISE
1235
+ * from the ribbons' terminal cross-sections, so ribbon and surface share the exact same floats at
1236
+ * the seam — no overlap, no z-fighting, no floating disc. Ribbons and surfaces both live on the
1237
+ * seam-shared {@link GROUND_DECAL_LAYERS}.road/.junction layer. Deterministic (junctions emitted in
1238
+ * ascending index order) and bounded by the street/junction counts.
1239
+ *
1240
+ * @capability world-intersections trim streets at junctions and weld curb-return crossing surfaces in one call
1241
+ */
1242
+ export function buildTrimmedIntersections(streets, junctions, sampleHeight, options = {}) {
1243
+ const ribbonElevation = options.elevation ?? GROUND_DECAL_LAYERS.road;
1244
+ const ribbons = [];
1245
+ const trimmed = [];
1246
+ const trimmedStreetIndices = [];
1247
+ const trimmedWidths = [];
1248
+ const trimmedMarkings = [];
1249
+ const sidewalks = [];
1250
+ const approachesByJunction = new Map();
1251
+ for (let s = 0; s < streets.length; s += 1) {
1252
+ const street = streets[s];
1253
+ const subs = trimPathAtJunctions(street.path, street.width, junctions, options);
1254
+ for (let r = 0; r < subs.length; r += 1) {
1255
+ const tr = subs[r];
1256
+ const ribbon = buildRoadRibbon(tr.path, street.width, sampleHeight, {
1257
+ elevation: ribbonElevation,
1258
+ maxSegmentLength: options.maxSegmentLength,
1259
+ });
1260
+ if (ribbon.positions.length < 12)
1261
+ continue; // degenerate — no terminal cross-section to weld
1262
+ ribbons.push(ribbon);
1263
+ trimmed.push(tr);
1264
+ const trimmedIndex = trimmed.length - 1;
1265
+ trimmedStreetIndices.push(s);
1266
+ trimmedWidths.push(street.width);
1267
+ trimmedMarkings.push(street.markings);
1268
+ const leftSidewalk = Math.max(0, street.sidewalks?.left ?? 0);
1269
+ const rightSidewalk = Math.max(0, street.sidewalks?.right ?? 0);
1270
+ if (leftSidewalk > 0) {
1271
+ sidewalks.push(buildRoadSidewalk(ribbon, "left", leftSidewalk, sampleHeight, ribbonElevation));
1272
+ }
1273
+ if (rightSidewalk > 0) {
1274
+ sidewalks.push(buildRoadSidewalk(ribbon, "right", rightSidewalk, sampleHeight, ribbonElevation));
1275
+ }
1276
+ const numPoints = ribbon.positions.length / 6;
1277
+ for (let c = 0; c < tr.cuts.length; c += 1) {
1278
+ const cut = tr.cuts[c];
1279
+ const leftVert = cut.at === "start" ? 0 : (numPoints - 1) * 2;
1280
+ const rightVert = leftVert + 1;
1281
+ const approach = {
1282
+ center: cut.center,
1283
+ left: [ribbon.positions[leftVert * 3], ribbon.positions[leftVert * 3 + 1], ribbon.positions[leftVert * 3 + 2]],
1284
+ right: [ribbon.positions[rightVert * 3], ribbon.positions[rightVert * 3 + 1], ribbon.positions[rightVert * 3 + 2]],
1285
+ direction: cut.direction,
1286
+ sourceTangent: cut.at === "start" ? cut.direction : [-cut.direction[0], -cut.direction[1]],
1287
+ streetIndex: s,
1288
+ trimmedIndex,
1289
+ cutAt: cut.at,
1290
+ width: street.width,
1291
+ sidewalkWidths: street.sidewalks,
1292
+ markings: street.markings,
1293
+ };
1294
+ const list = approachesByJunction.get(cut.junctionIndex);
1295
+ if (list)
1296
+ list.push(approach);
1297
+ else
1298
+ approachesByJunction.set(cut.junctionIndex, [approach]);
1299
+ }
1300
+ }
1301
+ }
1302
+ const junctionSurfaces = [];
1303
+ const junctionIndices = [];
1304
+ const junctionApproaches = [];
1305
+ const sidewalkAprons = [];
1306
+ const sidewalkApronJunctionIndices = [];
1307
+ const sortedIndices = [...approachesByJunction.keys()].sort((p, q) => p - q);
1308
+ for (let i = 0; i < sortedIndices.length; i += 1) {
1309
+ const ji = sortedIndices[i];
1310
+ const j = junctions[ji];
1311
+ const approaches = approachesByJunction.get(ji);
1312
+ const surface = buildJunctionSurface(j, approaches, sampleHeight, options);
1313
+ junctionSurfaces.push(surface);
1314
+ const apron = buildSidewalkApron(j, surface, approaches, sampleHeight, ribbonElevation);
1315
+ if (apron.indices.length > 0) {
1316
+ sidewalkAprons.push(apron);
1317
+ sidewalkApronJunctionIndices.push(ji);
1318
+ }
1319
+ junctionIndices.push(ji);
1320
+ junctionApproaches.push(approaches);
1321
+ }
1322
+ return {
1323
+ ribbons,
1324
+ trimmed,
1325
+ junctions: junctionSurfaces,
1326
+ junctionIndices,
1327
+ junctionApproaches,
1328
+ trimmedStreetIndices,
1329
+ trimmedWidths,
1330
+ trimmedMarkings,
1331
+ sidewalks,
1332
+ sidewalkAprons,
1333
+ sidewalkApronJunctionIndices,
1334
+ };
1335
+ }
1336
+ function markingPoint(ap, line) {
1337
+ const tangent = ap.sourceTangent ?? ap.direction ?? [1, 0];
1338
+ const offset = Number.isFinite(line.offset) ? line.offset : 0;
1339
+ return [ap.center[0] - tangent[1] * offset, ap.center[1] + tangent[0] * offset];
1340
+ }
1341
+ function buildOffsetConnector(a, b, aLine, bLine, segments) {
1342
+ const start = markingPoint(a, aLine);
1343
+ const end = markingPoint(b, bLine);
1344
+ const ad = a.direction;
1345
+ const bd = b.direction;
1346
+ if (ad === undefined || bd === undefined)
1347
+ return null;
1348
+ const chord = Math.hypot(end[0] - start[0], end[1] - start[1]);
1349
+ const handle = chord * 0.45;
1350
+ const c1 = [start[0] - ad[0] * handle, start[1] - ad[1] * handle];
1351
+ const c2 = [end[0] - bd[0] * handle, end[1] - bd[1] * handle];
1352
+ const count = Math.max(2, Math.min(64, Math.floor(Number.isFinite(segments) ? segments : 8)));
1353
+ const path = [];
1354
+ for (let i = 0; i <= count; i += 1) {
1355
+ const t = i / count;
1356
+ const u = 1 - t;
1357
+ path.push([
1358
+ u * u * u * start[0] + 3 * u * u * t * c1[0] + 3 * u * t * t * c2[0] + t * t * t * end[0],
1359
+ u * u * u * start[1] + 3 * u * u * t * c1[1] + 3 * u * t * t * c2[1] + t * t * t * end[1],
1360
+ ]);
1361
+ }
1362
+ return path;
1363
+ }
1364
+ /**
1365
+ * Mesh the paint declared on streets passed to {@link buildTrimmedIntersections}. Longitudinal
1366
+ * ribbons follow the trimmed road paths. At degree-2 nodes corresponding lines are joined by a
1367
+ * tangent-continuous cubic (signed offsets are preserved); at multi-arm nodes lines deliberately
1368
+ * stop short of the mouth and optional transverse stop lines make that termination readable.
1369
+ * @capability world-intersections connect sidewalks and lane guidance through generated intersections
1370
+ */
1371
+ export function buildIntersectionMarkings(intersections, sampleHeight, options = {}) {
1372
+ const mouthClearance = Math.max(0, options.mouthClearance ?? 1);
1373
+ const lineWidth = options.lineWidth ?? 0.15;
1374
+ const stopLineWidth = options.stopLineWidth ?? 0.35;
1375
+ const edgeClearance = Math.max(0, options.stopLineEdgeClearance ?? 0.5);
1376
+ const elevation = options.elevation ?? GROUND_DECAL_LAYERS.marking;
1377
+ const requestedStep = options.maxSegmentLength ?? 4;
1378
+ const maxSegmentLength = Number.isFinite(requestedStep)
1379
+ ? Math.max(0.25, Math.min(100, requestedStep))
1380
+ : 4;
1381
+ const ribbons = [];
1382
+ const appendLongitudinal = (path, width) => {
1383
+ const dashLength = options.dashLength ?? 0;
1384
+ const pieces = dashLength > 0 ? dashSegments(path, dashLength, Math.max(0, options.dashGap ?? 3)) : [path];
1385
+ for (const piece of pieces) {
1386
+ const ribbon = buildRoadRibbon(piece, width, sampleHeight, { elevation, maxSegmentLength });
1387
+ if (ribbon.indices.length > 0)
1388
+ ribbons.push(ribbon);
1389
+ }
1390
+ };
1391
+ const approachByTrim = new Map();
1392
+ for (let j = 0; j < intersections.junctionApproaches.length; j += 1) {
1393
+ const approaches = intersections.junctionApproaches[j];
1394
+ for (const ap of approaches) {
1395
+ if (ap.trimmedIndex !== undefined && ap.cutAt !== undefined) {
1396
+ approachByTrim.set(`${ap.trimmedIndex}:${ap.cutAt}`, { approach: ap, degree: approaches.length });
1397
+ }
1398
+ }
1399
+ }
1400
+ for (let i = 0; i < intersections.trimmed.length; i += 1) {
1401
+ const intent = intersections.trimmedMarkings[i];
1402
+ if (intent === undefined)
1403
+ continue;
1404
+ const trimmed = intersections.trimmed[i];
1405
+ for (const line of intent.lines ?? []) {
1406
+ let path = offsetPath(trimmed.path, line.offset ?? 0);
1407
+ let startClearance = 0;
1408
+ let endClearance = 0;
1409
+ for (const cut of trimmed.cuts) {
1410
+ const match = approachByTrim.get(`${i}:${cut.at}`);
1411
+ if (match?.degree !== undefined && match.degree > 2) {
1412
+ if (cut.at === "start")
1413
+ startClearance = mouthClearance;
1414
+ else
1415
+ endClearance = mouthClearance;
1416
+ }
1417
+ }
1418
+ const length = pathLength(path);
1419
+ if (startClearance + endClearance >= length - 1e-6)
1420
+ continue;
1421
+ if (startClearance > 0) {
1422
+ const cut = pointAtArcLength(path, startClearance);
1423
+ path = [cut.point, ...path.slice(cut.segment + 1)];
1424
+ }
1425
+ if (endClearance > 0) {
1426
+ const cut = pointAtArcLength(path, pathLength(path) - endClearance);
1427
+ path = [...path.slice(0, cut.segment + 1), cut.point];
1428
+ }
1429
+ appendLongitudinal(path, line.width ?? lineWidth);
1430
+ }
1431
+ }
1432
+ for (let j = 0; j < intersections.junctionApproaches.length; j += 1) {
1433
+ const approaches = intersections.junctionApproaches[j];
1434
+ if (approaches.length === 2) {
1435
+ const aLines = approaches[0].markings?.lines ?? [];
1436
+ const bLines = approaches[1].markings?.lines ?? [];
1437
+ const candidates = [];
1438
+ for (let a = 0; a < Math.min(aLines.length, 16); a += 1) {
1439
+ for (let b = 0; b < Math.min(bLines.length, 16); b += 1) {
1440
+ const ap = markingPoint(approaches[0], aLines[a]);
1441
+ const bp = markingPoint(approaches[1], bLines[b]);
1442
+ candidates.push({ a, b, distance: Math.hypot(ap[0] - bp[0], ap[1] - bp[1]) });
1443
+ }
1444
+ }
1445
+ candidates.sort((p, q) => p.distance - q.distance || p.a - q.a || p.b - q.b);
1446
+ const usedA = new Set();
1447
+ const usedB = new Set();
1448
+ for (const pair of candidates) {
1449
+ if (usedA.has(pair.a) || usedB.has(pair.b))
1450
+ continue;
1451
+ usedA.add(pair.a);
1452
+ usedB.add(pair.b);
1453
+ const aLine = aLines[pair.a];
1454
+ const bLine = bLines[pair.b];
1455
+ const path = buildOffsetConnector(approaches[0], approaches[1], aLine, bLine, options.connectorSegments ?? 8);
1456
+ if (path !== null) {
1457
+ const aWidth = Number.isFinite(aLine.width) ? aLine.width : lineWidth;
1458
+ const bWidth = Number.isFinite(bLine.width) ? bLine.width : lineWidth;
1459
+ const width = Math.max(0.01, Math.min(aWidth, bWidth));
1460
+ appendLongitudinal(path, width);
1461
+ }
1462
+ }
1463
+ continue;
1464
+ }
1465
+ if (approaches.length < 3)
1466
+ continue;
1467
+ for (const ap of approaches) {
1468
+ if (!ap.markings?.stopLine || ap.direction === undefined || ap.width === undefined)
1469
+ continue;
1470
+ const half = Math.max(0, ap.width / 2 - edgeClearance);
1471
+ if (half <= 0 || stopLineWidth <= 0)
1472
+ continue;
1473
+ const cx = ap.center[0] + ap.direction[0] * mouthClearance;
1474
+ const cz = ap.center[1] + ap.direction[1] * mouthClearance;
1475
+ const nx = -ap.direction[1];
1476
+ const nz = ap.direction[0];
1477
+ const path = [[cx - nx * half, cz - nz * half], [cx + nx * half, cz + nz * half]];
1478
+ ribbons.push(buildRoadRibbon(path, stopLineWidth, sampleHeight, { elevation, maxSegmentLength }));
1479
+ }
1480
+ }
1481
+ return ribbons;
1482
+ }