@jgengine/core 0.14.0 → 0.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +500 -1
- package/dist/ai/difficulty.d.ts +154 -0
- package/dist/ai/difficulty.js +194 -0
- package/dist/ai/driver.d.ts +107 -0
- package/dist/ai/driver.js +175 -0
- package/dist/ai/flock.js +13 -23
- package/dist/ai/laneSelect.js +2 -1
- package/dist/ai/mobBrain.js +2 -1
- package/dist/ai/waveRunner.d.ts +81 -0
- package/dist/ai/waveRunner.js +104 -0
- package/dist/audio/musicState.d.ts +47 -0
- package/dist/audio/musicState.js +38 -0
- package/dist/audio/stepCadence.d.ts +47 -0
- package/dist/audio/stepCadence.js +29 -0
- package/dist/audio/synth.d.ts +12 -0
- package/dist/authoring.d.ts +6 -4
- package/dist/authoring.js +6 -4
- package/dist/combat/castRunner.d.ts +13 -1
- package/dist/combat/castRunner.js +5 -1
- package/dist/combat/comboMeter.d.ts +140 -0
- package/dist/combat/comboMeter.js +215 -0
- package/dist/combat/comboPoints.d.ts +11 -0
- package/dist/combat/comboPoints.js +11 -2
- package/dist/combat/damageResolution.d.ts +1 -1
- package/dist/combat/defensiveWindow.d.ts +5 -0
- package/dist/combat/effects.d.ts +24 -0
- package/dist/combat/effects.js +6 -1
- package/dist/combat/projectiles.d.ts +6 -0
- package/dist/combat/projectiles.js +2 -2
- package/dist/combat/regenShield.d.ts +40 -2
- package/dist/combat/regenShield.js +66 -18
- package/dist/combat/resourcePool.d.ts +19 -0
- package/dist/combat/resourcePool.js +20 -2
- package/dist/combat/shotOrigin.d.ts +5 -0
- package/dist/combat/statusApplication.d.ts +1 -1
- package/dist/combat/statusApplication.js +2 -1
- package/dist/combat/statusEffectView.d.ts +56 -0
- package/dist/combat/statusEffectView.js +52 -0
- package/dist/combat.d.ts +4 -2
- package/dist/combat.js +2 -0
- package/dist/devtools/fallbackSeams.d.ts +3 -2
- package/dist/devtools/fallbackSeams.js +1 -1
- package/dist/devtools/textureErrors.d.ts +40 -0
- package/dist/devtools/textureErrors.js +68 -0
- package/dist/economy/shopStock.d.ts +110 -0
- package/dist/economy/shopStock.js +157 -0
- package/dist/economy/wallet.d.ts +6 -0
- package/dist/economy/wallet.js +1 -0
- package/dist/game/achievements.d.ts +80 -0
- package/dist/game/achievements.js +124 -0
- package/dist/game/cameraConfig.d.ts +7 -0
- package/dist/game/codex.d.ts +60 -0
- package/dist/game/codex.js +89 -0
- package/dist/game/credits.d.ts +36 -0
- package/dist/game/credits.js +0 -0
- package/dist/game/defineGame.d.ts +28 -3
- package/dist/game/defineGame.js +5 -0
- package/dist/game/dialogueGraph.d.ts +116 -0
- package/dist/game/dialogueGraph.js +103 -0
- package/dist/game/eventTicker.d.ts +95 -0
- package/dist/game/eventTicker.js +91 -0
- package/dist/game/events.d.ts +24 -5
- package/dist/game/events.js +18 -0
- package/dist/game/gamePhase.d.ts +1 -1
- package/dist/game/gamePhase.js +1 -1
- package/dist/game/leaderboardRank.d.ts +71 -0
- package/dist/game/leaderboardRank.js +76 -0
- package/dist/game/lootTable.d.ts +10 -1
- package/dist/game/lootTable.js +4 -2
- package/dist/game/notifications.d.ts +57 -0
- package/dist/game/notifications.js +107 -0
- package/dist/game/playableGame.d.ts +39 -2
- package/dist/game/quest.d.ts +26 -0
- package/dist/game/quest.js +31 -0
- package/dist/game/runDraft.js +2 -1
- package/dist/game/saveSlots.d.ts +102 -0
- package/dist/game/saveSlots.js +136 -0
- package/dist/game/talentTreeView.d.ts +106 -0
- package/dist/game/talentTreeView.js +114 -0
- package/dist/game/worldItem.js +2 -1
- package/dist/gameplay.d.ts +15 -4
- package/dist/gameplay.js +13 -2
- package/dist/i18n/i18n.d.ts +51 -0
- package/dist/i18n/i18n.js +82 -0
- package/dist/input/axisInput.d.ts +1 -1
- package/dist/input/axisInput.js +18 -2
- package/dist/input/rebindSession.d.ts +141 -0
- package/dist/input/rebindSession.js +235 -0
- package/dist/input/touchControlsMode.d.ts +16 -0
- package/dist/input/touchControlsMode.js +23 -0
- package/dist/input/touchScheme.d.ts +30 -1
- package/dist/input/touchScheme.js +31 -1
- package/dist/interaction/promptRegistry.d.ts +78 -0
- package/dist/interaction/promptRegistry.js +130 -0
- package/dist/inventory/storageTier.js +3 -2
- package/dist/meta/changelog.d.ts +1 -1
- package/dist/meta/changelog.js +145 -1
- package/dist/movement/movementModel.d.ts +26 -3
- package/dist/movement/movementModel.js +121 -15
- package/dist/movement/playerMovement.js +161 -25
- package/dist/physics/drivableVehicle.js +3 -1
- package/dist/physics/kinematicVehicle.d.ts +56 -1
- package/dist/physics/kinematicVehicle.js +99 -10
- package/dist/physics/vehicleObstacles.js +19 -0
- package/dist/random/resolveRng.d.ts +14 -0
- package/dist/random/resolveRng.js +31 -0
- package/dist/render/postProcessing.d.ts +12 -1
- package/dist/runtime/cameraDirector.d.ts +2 -2
- package/dist/runtime/context/combat.js +8 -0
- package/dist/runtime/gameContext.js +6 -1
- package/dist/runtime/gameContextTypes.d.ts +18 -0
- package/dist/runtime/inputSnapshot.d.ts +10 -1
- package/dist/runtime/inputSnapshot.js +13 -1
- package/dist/runtime/runtimeSave.d.ts +3 -3
- package/dist/runtime/runtimeSave.js +11 -2
- package/dist/scene/assetDiagnostics.js +2 -2
- package/dist/scene/autoTarget.js +2 -1
- package/dist/scene/captureCheck.js +3 -2
- package/dist/scene/entityStore.d.ts +4 -1
- package/dist/scene/entityStore.js +3 -0
- package/dist/scene/objectStore.d.ts +10 -0
- package/dist/scene/objectStore.js +29 -6
- package/dist/scene/sceneKinds.d.ts +18 -0
- package/dist/scene/sceneKinds.js +19 -0
- package/dist/scene/sequenceDirector.d.ts +110 -0
- package/dist/scene/sequenceDirector.js +146 -0
- package/dist/session/roles.js +3 -2
- package/dist/settings/settingsModel.d.ts +1 -0
- package/dist/settings/settingsModel.js +1 -0
- package/dist/stats/rollCheck.js +4 -2
- package/dist/time/dayNightCycle.d.ts +114 -0
- package/dist/time/dayNightCycle.js +208 -0
- package/dist/time/timerSet.d.ts +132 -0
- package/dist/time/timerSet.js +243 -0
- package/dist/ui/accessibility.d.ts +51 -0
- package/dist/ui/accessibility.js +83 -0
- package/dist/ui/coachMarks.d.ts +99 -0
- package/dist/ui/coachMarks.js +126 -0
- package/dist/ui/floatingText.d.ts +114 -0
- package/dist/ui/floatingText.js +203 -0
- package/dist/ui/modalStack.d.ts +143 -0
- package/dist/ui/modalStack.js +160 -0
- package/dist/ui/objectiveBanner.d.ts +123 -0
- package/dist/ui/objectiveBanner.js +162 -0
- package/dist/ui/photoMode.d.ts +32 -0
- package/dist/ui/photoMode.js +51 -0
- package/dist/ui/radialMenu.d.ts +47 -0
- package/dist/ui/radialMenu.js +74 -0
- package/dist/ui/screenMarkers.d.ts +128 -0
- package/dist/ui/screenMarkers.js +118 -0
- package/dist/ui.d.ts +8 -0
- package/dist/ui.js +8 -0
- package/dist/vfx/cameraShake.d.ts +97 -0
- package/dist/vfx/cameraShake.js +136 -0
- package/dist/vfx/damageDirection.d.ts +102 -0
- package/dist/vfx/damageDirection.js +188 -0
- package/dist/vfx/particles.d.ts +103 -0
- package/dist/vfx/particles.js +256 -0
- package/dist/vfx/screenEffects.d.ts +149 -0
- package/dist/vfx/screenEffects.js +191 -0
- package/dist/world/authoredObjects.d.ts +18 -0
- package/dist/world/authoredObjects.js +21 -0
- package/dist/world/authoredSpawn.js +19 -0
- package/dist/world/buildingLots.d.ts +54 -11
- package/dist/world/buildingLots.js +116 -30
- package/dist/world/cityBlocks.d.ts +45 -0
- package/dist/world/cityBlocks.js +159 -0
- package/dist/world/cityContent.d.ts +46 -2
- package/dist/world/cityContent.js +160 -2
- package/dist/world/cityGenerator.d.ts +261 -23
- package/dist/world/cityGenerator.js +643 -33
- package/dist/world/cityGeometry.d.ts +20 -0
- package/dist/world/cityGeometry.js +91 -0
- package/dist/world/cityKind.js +1 -44
- package/dist/world/fastTravel.d.ts +64 -0
- package/dist/world/fastTravel.js +108 -0
- package/dist/world/features.d.ts +16 -0
- package/dist/world/features.js +2 -0
- package/dist/world/grounded.d.ts +11 -0
- package/dist/world/grounded.js +12 -0
- package/dist/world/mapAnnotations.d.ts +75 -0
- package/dist/world/mapAnnotations.js +139 -0
- package/dist/world/markers.js +1 -0
- package/dist/world/resourceNode.d.ts +1 -1
- package/dist/world/resourceNode.js +2 -1
- package/dist/world/roads.d.ts +294 -5
- package/dist/world/roads.js +1262 -21
- package/dist/world/sharedAnnotations.d.ts +84 -0
- package/dist/world/sharedAnnotations.js +76 -0
- package/dist/world/spawnOverride.d.ts +37 -0
- package/dist/world/spawnOverride.js +50 -0
- package/dist/world/streetGenerator.d.ts +60 -11
- package/dist/world/streetGenerator.js +1786 -129
- package/dist/world/terrain.d.ts +1 -0
- package/dist/world/terrain.js +2 -0
- package/dist/world/waypoints.d.ts +63 -0
- package/dist/world/waypoints.js +137 -0
- package/dist/world.d.ts +17 -2
- package/dist/world.js +17 -2
- package/package.json +1 -1
- package/skills/ce-handoff/SKILL.md +149 -0
- package/skills/game-design/SKILL.md +2 -0
- package/skills/jgengine/SKILL.md +3 -1
- package/skills/jgengine/api.md +17 -7
- package/skills/jgengine/capabilities.md +12 -0
- package/skills/jgengine/recipes/minimal-game.md +7 -4
- package/skills/jgengine-combat/api.md +40 -4
- package/skills/jgengine-combat/capabilities.md +12 -0
- package/skills/jgengine-combat/reference.md +2 -0
- package/skills/jgengine-gameplay/api.md +212 -11
- package/skills/jgengine-gameplay/capabilities.md +66 -1
- package/skills/jgengine-world/api.md +397 -11
- package/skills/jgengine-world/capabilities.md +123 -2
- package/skills/jgengine-world/reference.md +15 -1
package/dist/world/roads.js
CHANGED
|
@@ -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 ??
|
|
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
|
-
|
|
79
|
-
|
|
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
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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` —
|
|
107
|
-
*
|
|
108
|
-
*
|
|
109
|
-
*
|
|
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 ??
|
|
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
|
+
}
|