@drawtonomy/sdk 0.8.0 → 0.10.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 (46) hide show
  1. package/dist/exporter/index.d.ts +5 -2
  2. package/dist/exporter/index.d.ts.map +1 -1
  3. package/dist/exporter/index.js +3 -0
  4. package/dist/exporter/index.js.map +1 -1
  5. package/dist/exporter/lanelet2.d.ts.map +1 -1
  6. package/dist/exporter/lanelet2.js +434 -2
  7. package/dist/exporter/lanelet2.js.map +1 -1
  8. package/dist/exporter/odrCarryThrough.d.ts +89 -0
  9. package/dist/exporter/odrCarryThrough.d.ts.map +1 -0
  10. package/dist/exporter/odrCarryThrough.js +186 -0
  11. package/dist/exporter/odrCarryThrough.js.map +1 -0
  12. package/dist/exporter/odrGeometry.d.ts +64 -0
  13. package/dist/exporter/odrGeometry.d.ts.map +1 -0
  14. package/dist/exporter/odrGeometry.js +324 -0
  15. package/dist/exporter/odrGeometry.js.map +1 -0
  16. package/dist/exporter/odrGeometryFit.d.ts +37 -0
  17. package/dist/exporter/odrGeometryFit.d.ts.map +1 -0
  18. package/dist/exporter/odrGeometryFit.js +476 -0
  19. package/dist/exporter/odrGeometryFit.js.map +1 -0
  20. package/dist/exporter/odrToShapes.d.ts +53 -0
  21. package/dist/exporter/odrToShapes.d.ts.map +1 -0
  22. package/dist/exporter/odrToShapes.js +1696 -0
  23. package/dist/exporter/odrToShapes.js.map +1 -0
  24. package/dist/exporter/opendrive.d.ts +14 -1
  25. package/dist/exporter/opendrive.d.ts.map +1 -1
  26. package/dist/exporter/opendrive.js +1813 -163
  27. package/dist/exporter/opendrive.js.map +1 -1
  28. package/dist/exporter/opendriveParser.d.ts +194 -0
  29. package/dist/exporter/opendriveParser.d.ts.map +1 -0
  30. package/dist/exporter/opendriveParser.js +429 -0
  31. package/dist/exporter/opendriveParser.js.map +1 -0
  32. package/dist/exporter/osmToShapes.d.ts +52 -3
  33. package/dist/exporter/osmToShapes.d.ts.map +1 -1
  34. package/dist/exporter/osmToShapes.js +208 -5
  35. package/dist/exporter/osmToShapes.js.map +1 -1
  36. package/dist/exporter/projection.d.ts +1 -1
  37. package/dist/exporter/projection.d.ts.map +1 -1
  38. package/dist/exporter/projection.js +2 -3
  39. package/dist/exporter/projection.js.map +1 -1
  40. package/dist/exporter/units.d.ts +7 -0
  41. package/dist/exporter/units.d.ts.map +1 -1
  42. package/dist/exporter/units.js +11 -0
  43. package/dist/exporter/units.js.map +1 -1
  44. package/dist/types.d.ts +27 -1
  45. package/dist/types.d.ts.map +1 -1
  46. package/package.json +1 -1
@@ -0,0 +1,1696 @@
1
+ // Convert a parsed OpenDRIVE model (`OdrMap`) into the shape primitives that
2
+ // the drawtonomy editor consumes (points, linestrings, lanes, traffic lights).
3
+ //
4
+ // Mirrors the Lanelet2 OSM import path (`osmToShapes`): the output is the
5
+ // same intermediate `ImportedShapes` structure, extended with a sidecar (the
6
+ // original XML plus the derived geographic origin) and a list of warnings for
7
+ // features that were parsed but flattened or ignored.
8
+ //
9
+ // Geometry pipeline per road:
10
+ // 1. Adaptively sample the reference line (line/arc/spiral/paramPoly3).
11
+ // 2. At each station, shift by the laneOffset polynomial along the normal
12
+ // to obtain the lane reference ("center") polyline.
13
+ // 3. Accumulate lane widths outward (left lanes +t, right lanes -t) to get
14
+ // every lane boundary polyline in meters.
15
+ // 4. Convert meters -> pixels and ENU (y up) -> canvas (y down), the exact
16
+ // inverse of the OpenDRIVE exporter's conversion, so that
17
+ // import -> export is near-identity.
18
+ //
19
+ // Boundary sharing: adjacent lanes within one road/section share a single
20
+ // boundary linestring (lane -1's outer boundary IS lane -2's inner boundary),
21
+ // and the center boundary is shared between lane +1 and lane -1.
22
+ //
23
+ // Direction semantics: in OpenDRIVE, left lanes (positive ids) run opposite
24
+ // to the reference line. Boundary polylines are stored in reference-line
25
+ // order, so left lanes set invertLeft/invertRight = true — the editor then
26
+ // reads the boundaries reversed, consistent with how `osmToShapes` encodes
27
+ // direction via boundary inversion.
28
+ import { evalPoly3, sampleReferenceLine } from './odrGeometry';
29
+ import { sampleAtParam } from './laneCenterline';
30
+ import { createShapeIdAllocator, } from './osmToShapes';
31
+ import { PIXELS_PER_METER } from './units';
32
+ import { hashRoadState, } from './odrCarryThrough';
33
+ /** Lanes narrower than this (m) carry no usable area and are skipped. */
34
+ const WIDTH_EPS = 1e-3;
35
+ const S_EPS = 1e-6;
36
+ /**
37
+ * Lane sections shorter than this (m) are skipped as transition slivers.
38
+ * Generated maps (e.g. CARLA towns) often encode lane-count transitions as
39
+ * chains of centimeter-scale lane sections; materializing those as lanes
40
+ * produces degenerate slivers that no exporter can represent. A lane section
41
+ * below vehicle scale carries no usable lane area, so connectivity is
42
+ * bridged across skipped sections via their lane-level links instead.
43
+ */
44
+ const MIN_SECTION_LEN_M = 0.3;
45
+ /**
46
+ * Derive a WGS84 origin from an OpenDRIVE <geoReference> PROJ string.
47
+ *
48
+ * Supports `+proj=tmerc +lat_0=.. +lon_0=..` (exact: the projection origin is
49
+ * the local (0, 0)) and `+proj=utm +zone=..` (approximate: the zone's central
50
+ * meridian on the equator; UTM's false easting/northing is not compensated).
51
+ * Returns null when the origin cannot be derived.
52
+ */
53
+ export function parseGeoReferenceOrigin(geoReference) {
54
+ if (!geoReference)
55
+ return null;
56
+ if (/\+proj=tmerc\b/.test(geoReference)) {
57
+ const latMatch = geoReference.match(/\+lat_0=(-?[\d.]+(?:[eE][-+]?\d+)?)/);
58
+ const lonMatch = geoReference.match(/\+lon_0=(-?[\d.]+(?:[eE][-+]?\d+)?)/);
59
+ if (latMatch && lonMatch) {
60
+ const lat = parseFloat(latMatch[1]);
61
+ const lon = parseFloat(lonMatch[1]);
62
+ if (Number.isFinite(lat) && Number.isFinite(lon)) {
63
+ return { lat, lon, approximate: false };
64
+ }
65
+ }
66
+ return null;
67
+ }
68
+ if (/\+proj=utm\b/.test(geoReference)) {
69
+ const zoneMatch = geoReference.match(/\+zone=(\d+)/);
70
+ if (zoneMatch) {
71
+ const zone = parseInt(zoneMatch[1], 10);
72
+ if (zone >= 1 && zone <= 60) {
73
+ return { lat: 0, lon: zone * 6 - 183, approximate: true };
74
+ }
75
+ }
76
+ return null;
77
+ }
78
+ return null;
79
+ }
80
+ /** ENU meters -> canvas pixels (inverse of the exporter's pxToEnuX). */
81
+ function enuToCanvasX(m) {
82
+ return m * PIXELS_PER_METER;
83
+ }
84
+ /** ENU meters -> canvas pixels with the y-axis flip (inverse of pxToEnuY). */
85
+ function enuToCanvasY(m) {
86
+ return -m * PIXELS_PER_METER;
87
+ }
88
+ function laneOffsetAt(road, s) {
89
+ let active = null;
90
+ for (const rec of road.laneOffsets) {
91
+ if (rec.s <= s + S_EPS)
92
+ active = rec;
93
+ else
94
+ break;
95
+ }
96
+ return active ? evalPoly3(active, s - active.s) : 0;
97
+ }
98
+ /** Lane width at `ds` meters past the lane-section start. */
99
+ function laneWidthAt(lane, ds) {
100
+ let active = null;
101
+ for (const rec of lane.widths) {
102
+ if (rec.sOffset <= ds + S_EPS)
103
+ active = rec;
104
+ else
105
+ break;
106
+ }
107
+ if (!active)
108
+ return 0;
109
+ const w = evalPoly3(active, ds - active.sOffset);
110
+ return w > 0 ? w : 0;
111
+ }
112
+ function roadMarkToSubtype(rm) {
113
+ if (!rm)
114
+ return 'solid';
115
+ if (rm.type.includes('broken'))
116
+ return 'dashed';
117
+ return 'solid';
118
+ }
119
+ /**
120
+ * OpenDRIVE lane type -> lanelet-style lane subtype. Lane shapes follow the
121
+ * lanelet vocabulary (`attributes.type = 'lanelet'`, kind in `subtype`) so
122
+ * that the Lanelet2 exporter emits valid `type=lanelet` relations; the exact
123
+ * OpenDRIVE type is preserved separately in `odr_type` for re-export.
124
+ */
125
+ const ODR_TYPE_TO_LANELET_SUBTYPE = {
126
+ driving: 'road',
127
+ sidewalk: 'walkway',
128
+ walking: 'walkway',
129
+ biking: 'bicycle_lane',
130
+ exit: 'exit',
131
+ entry: 'road',
132
+ onRamp: 'road',
133
+ offRamp: 'road',
134
+ bus: 'bus_lane',
135
+ taxi: 'bus_lane',
136
+ crosswalk: 'crosswalk',
137
+ };
138
+ function laneletSubtypeFor(odrType) {
139
+ return ODR_TYPE_TO_LANELET_SUBTYPE[odrType] ?? odrType;
140
+ }
141
+ function shouldKeepLane(lane, maxWidth) {
142
+ if (lane.type === 'none')
143
+ return false;
144
+ return maxWidth > WIDTH_EPS;
145
+ }
146
+ function emptyBounds() {
147
+ return {
148
+ minX: Infinity,
149
+ maxX: -Infinity,
150
+ minY: Infinity,
151
+ maxY: -Infinity,
152
+ centerX: 0,
153
+ centerY: 0,
154
+ width: 0,
155
+ height: 0,
156
+ };
157
+ }
158
+ /**
159
+ * Classify a junction lane's turning direction from the accumulated signed
160
+ * heading change along its reference samples (left lanes travel against the
161
+ * reference direction, flipping the sign). Consumers such as Autoware require
162
+ * a turn_direction tag on every lanelet inside an intersection.
163
+ */
164
+ function turnDirectionFor(stations, isLeftLane) {
165
+ let total = 0;
166
+ for (let i = 1; i < stations.length; i++) {
167
+ let d = stations[i].hdg - stations[i - 1].hdg;
168
+ while (d > Math.PI)
169
+ d -= 2 * Math.PI;
170
+ while (d < -Math.PI)
171
+ d += 2 * Math.PI;
172
+ total += d;
173
+ }
174
+ if (isLeftLane)
175
+ total = -total;
176
+ const TURN_THRESHOLD = (20 * Math.PI) / 180;
177
+ if (total > TURN_THRESHOLD)
178
+ return 'left';
179
+ if (total < -TURN_THRESHOLD)
180
+ return 'right';
181
+ return 'straight';
182
+ }
183
+ /** OpenDRIVE signal types converted to traffic light shapes (vehicle / pedestrian). */
184
+ const TRAFFIC_LIGHT_SIGNAL_TYPES = new Set(['1000001', '1000002']);
185
+ /** Interpolated pose on the sampled reference line at station `s` (clamped). */
186
+ function poseAt(samples, s) {
187
+ const first = samples[0];
188
+ if (s <= first.s)
189
+ return first;
190
+ const last = samples[samples.length - 1];
191
+ if (s >= last.s)
192
+ return last;
193
+ for (let i = 0; i < samples.length - 1; i++) {
194
+ const a = samples[i];
195
+ const b = samples[i + 1];
196
+ if (s > b.s)
197
+ continue;
198
+ const span = b.s - a.s;
199
+ const f = span > S_EPS ? (s - a.s) / span : 0;
200
+ return { x: a.x + (b.x - a.x) * f, y: a.y + (b.y - a.y) * f, hdg: a.hdg };
201
+ }
202
+ return last;
203
+ }
204
+ /**
205
+ * Convert a parsed OpenDRIVE map into editor-ready point/linestring/lane
206
+ * records plus a sidecar for round-trip export.
207
+ *
208
+ * Pass `selectedRoadIds` to restrict the conversion to a subset of roads
209
+ * (selective import); leave it `undefined` to import every road.
210
+ */
211
+ export function odrToShapes(map, options = {}) {
212
+ const idAllocator = options.idAllocator ?? createShapeIdAllocator();
213
+ const warnings = [];
214
+ const origin = parseGeoReferenceOrigin(map.header.geoReference);
215
+ if (origin?.approximate) {
216
+ warnings.push('geoReference uses a UTM projection; the derived origin is the zone central meridian on the equator (false easting/northing not compensated).');
217
+ }
218
+ const result = {
219
+ points: [],
220
+ linestrings: [],
221
+ lanes: [],
222
+ trafficLights: [],
223
+ crosswalks: [],
224
+ bounds: emptyBounds(),
225
+ sidecar: {
226
+ rawXml: map.rawXml,
227
+ originLat: origin ? origin.lat : null,
228
+ originLon: origin ? origin.lon : null,
229
+ },
230
+ warnings,
231
+ };
232
+ if (origin) {
233
+ result.originLatLon = { lat: origin.lat, lon: origin.lon };
234
+ }
235
+ let roads = map.roads;
236
+ if (options.selectedRoadIds) {
237
+ const selected = new Set(options.selectedRoadIds);
238
+ roads = roads.filter(r => selected.has(r.id));
239
+ }
240
+ // ---- Statistics for aggregated warnings ----
241
+ let elevationRoads = 0;
242
+ let superelevationRoads = 0;
243
+ let poly3Roads = 0;
244
+ let signalCount = 0;
245
+ let convertedSignalCount = 0;
246
+ let objectCount = 0;
247
+ let convertedObjectCount = 0;
248
+ let microSectionRoads = 0;
249
+ // ---- Shape materialization ----
250
+ const laneRegistry = new Map();
251
+ const lanesByRoad = new Map();
252
+ const laneShapeById = new Map();
253
+ const sectionCount = new Map();
254
+ /** Section indices skipped per road (micro sections / unsampleable). */
255
+ const skippedSections = new Map();
256
+ const roadById = new Map();
257
+ /** Roads (with their reference samples) whose signals are converted after all lanes exist. */
258
+ const signalRoads = [];
259
+ const restoredAttrCache = new Map();
260
+ const pickStringAttrs = (obj) => {
261
+ const out = {};
262
+ for (const [k, v] of Object.entries(obj)) {
263
+ // `type` is fixed to 'lanelet' and odr_* meta is regenerated.
264
+ if (typeof v !== 'string' || k === 'type' || k.startsWith('odr_'))
265
+ continue;
266
+ out[k] = v;
267
+ }
268
+ return Object.keys(out).length > 0 ? out : null;
269
+ };
270
+ const restoredLaneAttributes = (road, odrLaneId) => {
271
+ let entry = restoredAttrCache.get(road.id);
272
+ if (entry === undefined) {
273
+ entry = { flat: null, perLane: null };
274
+ const raw = road.userData['laneAttributes'];
275
+ if (raw) {
276
+ try {
277
+ const obj = JSON.parse(raw);
278
+ if (obj && typeof obj === 'object' && !Array.isArray(obj)) {
279
+ const flat = {};
280
+ for (const [k, v] of Object.entries(obj)) {
281
+ if (v && typeof v === 'object' && !Array.isArray(v) && /^-?\d+$/.test(k)) {
282
+ const laneAttrs = pickStringAttrs(v);
283
+ if (laneAttrs) {
284
+ entry.perLane = entry.perLane ?? new Map();
285
+ entry.perLane.set(parseInt(k, 10), laneAttrs);
286
+ }
287
+ }
288
+ else {
289
+ flat[k] = v;
290
+ }
291
+ }
292
+ entry.flat = pickStringAttrs(flat);
293
+ }
294
+ }
295
+ catch {
296
+ // Malformed userData JSON is ignored (third-party files).
297
+ }
298
+ }
299
+ restoredAttrCache.set(road.id, entry);
300
+ }
301
+ const perLane = entry.perLane?.get(odrLaneId) ?? null;
302
+ if (!entry.flat && !perLane)
303
+ return null;
304
+ return { ...(entry.flat ?? {}), ...(perLane ?? {}) };
305
+ };
306
+ const registryKey = (roadId, sectionIdx, odrLaneId) => `${roadId}|${sectionIdx}|${odrLaneId}`;
307
+ for (const road of roads) {
308
+ roadById.set(road.id, road);
309
+ sectionCount.set(road.id, road.laneSections.length);
310
+ if (road.hasElevation)
311
+ elevationRoads++;
312
+ if (road.hasSuperelevation)
313
+ superelevationRoads++;
314
+ if (road.planView.some(g => g.kind === 'poly3'))
315
+ poly3Roads++;
316
+ signalCount += road.signals.length;
317
+ objectCount += road.objects.length;
318
+ if (road.planView.length === 0 || road.laneSections.length === 0) {
319
+ warnings.push(`Road ${road.id}: no plan view geometry or lane sections; skipped.`);
320
+ continue;
321
+ }
322
+ // Stations that must be present: laneSection starts, laneOffset
323
+ // breakpoints, and lane width record breakpoints.
324
+ const extraStations = [];
325
+ for (const sec of road.laneSections) {
326
+ extraStations.push(sec.s);
327
+ for (const lane of [...sec.left, ...sec.right]) {
328
+ for (const w of lane.widths)
329
+ extraStations.push(sec.s + w.sOffset);
330
+ }
331
+ }
332
+ for (const lo of road.laneOffsets)
333
+ extraStations.push(lo.s);
334
+ const samples = sampleReferenceLine(road, {
335
+ maxChordErrorMeters: options.maxChordErrorMeters,
336
+ maxStepMeters: options.maxStepMeters,
337
+ extraStations,
338
+ });
339
+ if (samples.length < 2) {
340
+ warnings.push(`Road ${road.id}: reference line could not be sampled; skipped.`);
341
+ continue;
342
+ }
343
+ const skipped = new Set();
344
+ let microSections = 0;
345
+ for (let secIdx = 0; secIdx < road.laneSections.length; secIdx++) {
346
+ const sec = road.laneSections[secIdx];
347
+ const secEnd = secIdx + 1 < road.laneSections.length ? road.laneSections[secIdx + 1].s : road.length;
348
+ // Micro sections (a few centimeters or less — common in generated maps
349
+ // as lane-count transition slivers) carry no usable lane area; they are
350
+ // skipped and lane-level connectivity is bridged across them below.
351
+ if (secEnd - sec.s < MIN_SECTION_LEN_M) {
352
+ skipped.add(secIdx);
353
+ microSections++;
354
+ continue;
355
+ }
356
+ const stations = samples.filter(st => st.s >= sec.s - S_EPS && st.s <= secEnd + S_EPS);
357
+ if (stations.length < 2) {
358
+ skipped.add(secIdx);
359
+ continue;
360
+ }
361
+ materializeSection(road, sec, secIdx, stations);
362
+ }
363
+ skippedSections.set(road.id, skipped);
364
+ if (microSections > 0)
365
+ microSectionRoads++;
366
+ // Signals are materialized after every road's lanes exist, because a
367
+ // <signalReference> may point at lanes of a road processed later.
368
+ signalRoads.push({ road, samples });
369
+ }
370
+ /**
371
+ * Resolve lane references that point into skipped (micro) sections to the
372
+ * nearest materialized section, following lane-level links in `dir`
373
+ * (+1 = toward larger section indices, -1 = toward smaller). Returns the
374
+ * (sectionIdx, laneId) pairs in the first materialized section reached.
375
+ */
376
+ function resolveThroughSkipped(road, secIdx, laneIds, dir) {
377
+ const out = [];
378
+ const skipped = skippedSections.get(road.id);
379
+ const visit = (si, ids, depth) => {
380
+ if (si < 0 || si >= road.laneSections.length || ids.length === 0 || depth > road.laneSections.length) {
381
+ return;
382
+ }
383
+ if (!skipped?.has(si)) {
384
+ for (const id of ids)
385
+ out.push({ secIdx: si, laneId: id });
386
+ return;
387
+ }
388
+ const sec = road.laneSections[si];
389
+ const nextIds = [];
390
+ for (const id of ids) {
391
+ const lane = [...sec.left, ...sec.right].find(l => l.id === id);
392
+ if (!lane)
393
+ continue;
394
+ for (const linked of dir === 1 ? lane.successorIds : lane.predecessorIds) {
395
+ if (!nextIds.includes(linked))
396
+ nextIds.push(linked);
397
+ }
398
+ }
399
+ visit(si + dir, nextIds, depth + 1);
400
+ };
401
+ visit(secIdx, laneIds, 0);
402
+ return out;
403
+ }
404
+ /**
405
+ * Resolve the lane shapes a <validity> lane range applies to on `road` at
406
+ * station `s`, falling back to every driving lane of the road when the
407
+ * validity list is empty. Resolved shape ids are appended to `into`.
408
+ */
409
+ function resolveAffectedLanes(road, s, validity, into) {
410
+ let secIdx = 0;
411
+ for (let i = 0; i < road.laneSections.length; i++) {
412
+ if (road.laneSections[i].s <= s + S_EPS)
413
+ secIdx = i;
414
+ else
415
+ break;
416
+ }
417
+ if (validity.length > 0) {
418
+ for (const v of validity) {
419
+ const lo = Math.min(v.fromLane, v.toLane);
420
+ const hi = Math.max(v.fromLane, v.toLane);
421
+ for (let odrLaneId = lo; odrLaneId <= hi; odrLaneId++) {
422
+ if (odrLaneId === 0)
423
+ continue;
424
+ const reg = laneRegistry.get(registryKey(road.id, secIdx, odrLaneId));
425
+ if (reg && !into.includes(reg.shapeId))
426
+ into.push(reg.shapeId);
427
+ }
428
+ }
429
+ }
430
+ else {
431
+ for (const reg of lanesByRoad.get(road.id) ?? []) {
432
+ if (reg.laneType === 'driving' && !into.includes(reg.shapeId)) {
433
+ into.push(reg.shapeId);
434
+ }
435
+ }
436
+ }
437
+ }
438
+ /**
439
+ * Rebuild a stop-line linestring from a signal's
440
+ * <userData code="stopLine" value="[[x,y],...]"> record (ENU meters).
441
+ * Returns the linestring shape id, or null when the record is absent or
442
+ * malformed.
443
+ */
444
+ function materializeStopLine(stopLineJson) {
445
+ if (!stopLineJson)
446
+ return null;
447
+ let coords;
448
+ try {
449
+ coords = JSON.parse(stopLineJson);
450
+ }
451
+ catch {
452
+ return null;
453
+ }
454
+ if (!Array.isArray(coords) || coords.length < 2)
455
+ return null;
456
+ const pts = [];
457
+ for (const entry of coords) {
458
+ if (!Array.isArray(entry) || entry.length < 2)
459
+ return null;
460
+ const [x, y] = entry;
461
+ if (typeof x !== 'number' || typeof y !== 'number')
462
+ return null;
463
+ pts.push({ x, y });
464
+ }
465
+ const pointIds = [];
466
+ let firstX = 0;
467
+ let firstY = 0;
468
+ pts.forEach((p, i) => {
469
+ const x = enuToCanvasX(p.x);
470
+ const y = enuToCanvasY(p.y);
471
+ if (i === 0) {
472
+ firstX = x;
473
+ firstY = y;
474
+ }
475
+ const pointId = idAllocator.next('point');
476
+ result.points.push({ id: pointId, x, y, osmId: '' });
477
+ pointIds.push(pointId);
478
+ });
479
+ const ls = {
480
+ id: idAllocator.next('linestring'),
481
+ x: firstX,
482
+ y: firstY,
483
+ pointIds,
484
+ osmId: '',
485
+ attributes: { type: 'stop_line', subtype: 'solid', width: '0.2' },
486
+ };
487
+ result.linestrings.push(ls);
488
+ return ls.id;
489
+ }
490
+ // <signalReference> records grouped by the referenced signal id, so a
491
+ // signal controlling several roads recovers its full validity set.
492
+ const referencesBySignalId = new Map();
493
+ for (const road of roads) {
494
+ for (const ref of road.signalReferences) {
495
+ if (!ref.id)
496
+ continue;
497
+ const list = referencesBySignalId.get(ref.id) ?? [];
498
+ list.push({ road, s: ref.s, validity: ref.validity });
499
+ referencesBySignalId.set(ref.id, list);
500
+ }
501
+ }
502
+ /**
503
+ * Convert traffic light signals (type 1000001/1000002) into traffic light
504
+ * shapes. The position is the (s, t) station evaluated on the reference
505
+ * line; `affectedLaneIds` resolves the <validity> lane range against the
506
+ * lane section containing s (falling back to every driving lane of the
507
+ * road), merged with the lanes of any road that re-applies the signal via
508
+ * <signalReference>. A <userData code="stopLine"> record is rebuilt into a
509
+ * stop-line linestring and linked through `stopLineId`.
510
+ */
511
+ function materializeSignals(road, samples) {
512
+ for (const sig of road.signals) {
513
+ if (!TRAFFIC_LIGHT_SIGNAL_TYPES.has(sig.type))
514
+ continue;
515
+ const pose = poseAt(samples, sig.s);
516
+ // Unit normal toward +t (left of the reference direction in ENU).
517
+ const ex = pose.x - Math.sin(pose.hdg) * sig.t;
518
+ const ey = pose.y + Math.cos(pose.hdg) * sig.t;
519
+ const affected = [];
520
+ resolveAffectedLanes(road, sig.s, sig.validity, affected);
521
+ for (const ref of referencesBySignalId.get(sig.id) ?? []) {
522
+ if (ref.road.id === road.id)
523
+ continue;
524
+ resolveAffectedLanes(ref.road, ref.s, ref.validity, affected);
525
+ }
526
+ const data = {
527
+ id: idAllocator.next('traffic_light'),
528
+ x: enuToCanvasX(ex),
529
+ y: enuToCanvasY(ey),
530
+ // Default editor proportions (30x60 px) when the signal carries no size.
531
+ w: sig.width > 0 ? sig.width * PIXELS_PER_METER : 30,
532
+ h: sig.height > 0 ? sig.height * PIXELS_PER_METER : 60,
533
+ osmId: '',
534
+ affectedLaneIds: affected,
535
+ stopLineId: materializeStopLine(sig.userData['stopLine']),
536
+ attributes: {
537
+ type: 'traffic_light',
538
+ odr_signal_id: sig.id,
539
+ odr_road_id: road.id,
540
+ odr_signal_type: sig.type,
541
+ odr_signal_subtype: sig.subtype,
542
+ },
543
+ };
544
+ result.trafficLights.push(data);
545
+ convertedSignalCount++;
546
+ }
547
+ }
548
+ for (const { road, samples } of signalRoads) {
549
+ materializeSignals(road, samples);
550
+ }
551
+ /** Driving-lane shape ids of a road (regulatory link resolution). */
552
+ const drivingLanesOf = (roadId) => {
553
+ const out = [];
554
+ for (const reg of lanesByRoad.get(roadId) ?? []) {
555
+ if (reg.laneType === 'driving' && !out.includes(reg.shapeId))
556
+ out.push(reg.shapeId);
557
+ }
558
+ return out;
559
+ };
560
+ /** Shape ids of a specific (road, ODR lane id) pair, across all sections. */
561
+ const lanesOfRoadLane = (roadId, odrLaneId) => {
562
+ const out = [];
563
+ for (const reg of lanesByRoad.get(roadId) ?? []) {
564
+ if (reg.odrLaneId === odrLaneId && !out.includes(reg.shapeId))
565
+ out.push(reg.shapeId);
566
+ }
567
+ return out;
568
+ };
569
+ /** Resolve a [[roadId, laneId], ...] JSON record to lane shape ids. */
570
+ const resolveLanePairs = (pairs) => {
571
+ const out = [];
572
+ if (!Array.isArray(pairs))
573
+ return out;
574
+ for (const entry of pairs) {
575
+ if (!Array.isArray(entry) || entry.length < 2)
576
+ continue;
577
+ const [rid, lid] = entry;
578
+ if (typeof rid !== 'string' || typeof lid !== 'string')
579
+ continue;
580
+ const odrLaneId = parseInt(lid, 10);
581
+ if (!Number.isFinite(odrLaneId))
582
+ continue;
583
+ for (const shapeId of lanesOfRoadLane(rid, odrLaneId)) {
584
+ if (!out.includes(shapeId))
585
+ out.push(shapeId);
586
+ }
587
+ }
588
+ return out;
589
+ };
590
+ /**
591
+ * Convert crosswalk objects into crosswalk shapes. The band center is the
592
+ * (s, t) station on the reference line; the walking axis follows the
593
+ * object's heading (relative to the road direction), spanning `length`
594
+ * with band width `width`. Regulatory links stashed by the exporter in
595
+ * <userData code="crosswalkLinks"> (affected roads + stop line polyline)
596
+ * are resolved back to lane shape ids / a stop-line linestring.
597
+ */
598
+ function materializeCrosswalks(road, samples) {
599
+ for (const obj of road.objects) {
600
+ if (obj.type !== 'crosswalk')
601
+ continue;
602
+ if (!(obj.length > 0) || !(obj.width > 0))
603
+ continue;
604
+ const pose = poseAt(samples, obj.s);
605
+ // Unit normal toward +t (left of the reference direction in ENU).
606
+ const cx = pose.x - Math.sin(pose.hdg) * obj.t;
607
+ const cy = pose.y + Math.cos(pose.hdg) * obj.t;
608
+ const axisHdg = pose.hdg + obj.hdg;
609
+ const hx = (Math.cos(axisHdg) * obj.length) / 2;
610
+ const hy = (Math.sin(axisHdg) * obj.length) / 2;
611
+ const startX = enuToCanvasX(cx - hx);
612
+ const startY = enuToCanvasY(cy - hy);
613
+ const endX = enuToCanvasX(cx + hx);
614
+ const endY = enuToCanvasY(cy + hy);
615
+ const shapeX = (startX + endX) / 2;
616
+ const shapeY = (startY + endY) / 2;
617
+ const affected = [];
618
+ let stopLineId = null;
619
+ const rawLinks = obj.userData['crosswalkLinks'];
620
+ if (rawLinks) {
621
+ try {
622
+ const links = JSON.parse(rawLinks);
623
+ // Current exports carry lane-precise [[roadId, laneId], ...] pairs;
624
+ // legacy files only listed road ids (=> every driving lane).
625
+ if (Array.isArray(links.affectedLanes)) {
626
+ for (const shapeId of resolveLanePairs(links.affectedLanes)) {
627
+ if (!affected.includes(shapeId))
628
+ affected.push(shapeId);
629
+ }
630
+ }
631
+ else if (Array.isArray(links.affectedRoads)) {
632
+ for (const rid of links.affectedRoads) {
633
+ if (typeof rid !== 'string')
634
+ continue;
635
+ for (const shapeId of drivingLanesOf(rid)) {
636
+ if (!affected.includes(shapeId))
637
+ affected.push(shapeId);
638
+ }
639
+ }
640
+ }
641
+ if (Array.isArray(links.stopLine)) {
642
+ stopLineId = materializeStopLine(JSON.stringify(links.stopLine));
643
+ }
644
+ }
645
+ catch {
646
+ // Malformed userData JSON is ignored (third-party files).
647
+ }
648
+ }
649
+ const data = {
650
+ id: idAllocator.next('crosswalk'),
651
+ x: shapeX,
652
+ y: shapeY,
653
+ startX: startX - shapeX,
654
+ startY: startY - shapeY,
655
+ endX: endX - shapeX,
656
+ endY: endY - shapeY,
657
+ crosswalkWidth: obj.width * PIXELS_PER_METER,
658
+ osmId: '',
659
+ affectedLaneIds: affected,
660
+ stopLineId,
661
+ attributes: {
662
+ type: 'crosswalk',
663
+ odr_road_id: road.id,
664
+ odr_object_id: obj.id,
665
+ },
666
+ };
667
+ result.crosswalks.push(data);
668
+ convertedObjectCount++;
669
+ }
670
+ }
671
+ for (const { road, samples } of signalRoads) {
672
+ materializeCrosswalks(road, samples);
673
+ }
674
+ // Restore right-of-way links stashed by the exporter.
675
+ // Current exports carry <userData code="yieldLanes"> with per-lane
676
+ // { ownLaneId: [[roadId, laneId], ...] } records; legacy files carried
677
+ // <userData code="yieldRoads"> (every driving lane of the carrying road
678
+ // yields over the driving lanes of the listed roads).
679
+ for (const road of roads) {
680
+ const rawYieldLanes = road.userData['yieldLanes'];
681
+ if (rawYieldLanes) {
682
+ let byLane;
683
+ try {
684
+ byLane = JSON.parse(rawYieldLanes);
685
+ }
686
+ catch {
687
+ continue;
688
+ }
689
+ if (!byLane || typeof byLane !== 'object' || Array.isArray(byLane))
690
+ continue;
691
+ for (const [laneIdStr, pairs] of Object.entries(byLane)) {
692
+ const odrLaneId = parseInt(laneIdStr, 10);
693
+ if (!Number.isFinite(odrLaneId))
694
+ continue;
695
+ const yieldLaneIds = resolveLanePairs(pairs);
696
+ if (yieldLaneIds.length === 0)
697
+ continue;
698
+ for (const shapeId of lanesOfRoadLane(road.id, odrLaneId)) {
699
+ const lane = laneShapeById.get(shapeId);
700
+ if (lane)
701
+ lane.yieldLaneIds = [...yieldLaneIds];
702
+ }
703
+ }
704
+ continue;
705
+ }
706
+ const rawYield = road.userData['yieldRoads'];
707
+ if (!rawYield)
708
+ continue;
709
+ let yieldRoadIds;
710
+ try {
711
+ yieldRoadIds = JSON.parse(rawYield);
712
+ }
713
+ catch {
714
+ continue;
715
+ }
716
+ if (!Array.isArray(yieldRoadIds))
717
+ continue;
718
+ const yieldLaneIds = [];
719
+ for (const rid of yieldRoadIds) {
720
+ if (typeof rid !== 'string')
721
+ continue;
722
+ for (const shapeId of drivingLanesOf(rid)) {
723
+ if (!yieldLaneIds.includes(shapeId))
724
+ yieldLaneIds.push(shapeId);
725
+ }
726
+ }
727
+ if (yieldLaneIds.length === 0)
728
+ continue;
729
+ for (const shapeId of drivingLanesOf(road.id)) {
730
+ const lane = laneShapeById.get(shapeId);
731
+ if (lane)
732
+ lane.yieldLaneIds = [...yieldLaneIds];
733
+ }
734
+ }
735
+ function materializeSection(road, sec, secIdx, stations) {
736
+ // Unit normals pointing toward +t (left of the reference direction in ENU).
737
+ const normals = stations.map(st => ({ x: -Math.sin(st.hdg), y: Math.cos(st.hdg) }));
738
+ // Lane reference polyline: reference line shifted by the laneOffset.
739
+ const centerPts = stations.map((st, j) => {
740
+ const off = laneOffsetAt(road, st.s);
741
+ return { x: st.x + normals[j].x * off, y: st.y + normals[j].y * off };
742
+ });
743
+ // Accumulate boundary polylines from the center outward. Index 0 is the
744
+ // center; index i is the outer boundary of the i-th lane (inner-to-outer
745
+ // order). Widths of skipped lanes still shift the outer boundaries.
746
+ const accumulate = (lanes, sign) => {
747
+ const boundaries = [centerPts];
748
+ let prev = centerPts;
749
+ for (const lane of lanes) {
750
+ const next = prev.map((p, j) => {
751
+ const w = laneWidthAt(lane, stations[j].s - sec.s);
752
+ return { x: p.x + sign * normals[j].x * w, y: p.y + sign * normals[j].y * w };
753
+ });
754
+ boundaries.push(next);
755
+ prev = next;
756
+ }
757
+ return boundaries;
758
+ };
759
+ const leftBoundaries = accumulate(sec.left, 1);
760
+ const rightBoundaries = accumulate(sec.right, -1);
761
+ // Lazily materialize boundary linestrings so adjacent lanes share them.
762
+ // The center boundary (index 0) is shared across both sides.
763
+ const lsCache = new Map();
764
+ const getLinestring = (side, index, pts, rm) => {
765
+ const key = index === 0 ? 'C' : `${side}${index}`;
766
+ const cached = lsCache.get(key);
767
+ if (cached)
768
+ return cached;
769
+ const pointIds = [];
770
+ let firstX = 0;
771
+ let firstY = 0;
772
+ pts.forEach((p, j) => {
773
+ const x = enuToCanvasX(p.x);
774
+ const y = enuToCanvasY(p.y);
775
+ if (j === 0) {
776
+ firstX = x;
777
+ firstY = y;
778
+ }
779
+ const pointId = idAllocator.next('point');
780
+ const data = { id: pointId, x, y, osmId: '' };
781
+ result.points.push(data);
782
+ pointIds.push(pointId);
783
+ });
784
+ const data = {
785
+ id: idAllocator.next('linestring'),
786
+ x: firstX,
787
+ y: firstY,
788
+ pointIds,
789
+ osmId: '',
790
+ attributes: {
791
+ type: 'line_thin',
792
+ subtype: roadMarkToSubtype(rm),
793
+ width: '0.2',
794
+ },
795
+ };
796
+ result.linestrings.push(data);
797
+ lsCache.set(key, data);
798
+ return data;
799
+ };
800
+ const centerRoadMark = sec.center.find(l => l.id === 0)?.roadMarks[0];
801
+ const boundaryRoadMark = (lanes, index) => index === 0 ? centerRoadMark : lanes[index - 1]?.roadMarks[0];
802
+ const materializeSide = (lanes, boundaries, side) => {
803
+ for (let i = 0; i < lanes.length; i++) {
804
+ const lane = lanes[i];
805
+ let maxWidth = 0;
806
+ for (const st of stations) {
807
+ const w = laneWidthAt(lane, st.s - sec.s);
808
+ if (w > maxWidth)
809
+ maxWidth = w;
810
+ }
811
+ if (!shouldKeepLane(lane, maxWidth))
812
+ continue;
813
+ const innerLs = getLinestring(side, i, boundaries[i], boundaryRoadMark(lanes, i));
814
+ const outerLs = getLinestring(side, i + 1, boundaries[i + 1], boundaryRoadMark(lanes, i + 1));
815
+ // Left lanes (positive ids) travel opposite to the reference line, so
816
+ // their boundaries (stored in reference-line order) are read reversed.
817
+ // For both sides the inner boundary is the lane's left edge in travel
818
+ // direction (verified against screen coordinates with y pointing down).
819
+ const isLeftLane = lane.id > 0;
820
+ const laneShapeId = idAllocator.next('lane');
821
+ const attributes = {
822
+ type: 'lanelet',
823
+ subtype: laneletSubtypeFor(lane.type),
824
+ one_way: 'yes',
825
+ // Lanelet tags stashed by the exporter in <userData
826
+ // code="laneAttributes"> (speed_limit, turn_direction, location,
827
+ // one_way=no, exact subtype, custom tags) override the defaults.
828
+ ...restoredLaneAttributes(road, lane.id),
829
+ odr_type: lane.type,
830
+ odr_road_id: road.id,
831
+ odr_lane_id: String(lane.id),
832
+ odr_section_s: String(sec.s),
833
+ };
834
+ attributes.type = 'lanelet';
835
+ if (road.junction !== '-1') {
836
+ attributes.odr_junction_id = road.junction;
837
+ if (!attributes.turn_direction) {
838
+ attributes.turn_direction = turnDirectionFor(stations, isLeftLane);
839
+ }
840
+ }
841
+ const data = {
842
+ id: laneShapeId,
843
+ x: innerLs.x,
844
+ y: innerLs.y,
845
+ leftBoundaryId: innerLs.id,
846
+ rightBoundaryId: outerLs.id,
847
+ invertLeft: isLeftLane,
848
+ invertRight: isLeftLane,
849
+ osmId: '',
850
+ attributes,
851
+ next: [],
852
+ prev: [],
853
+ };
854
+ result.lanes.push(data);
855
+ laneShapeById.set(laneShapeId, data);
856
+ const registered = {
857
+ shapeId: laneShapeId,
858
+ roadId: road.id,
859
+ sectionIdx: secIdx,
860
+ odrLaneId: lane.id,
861
+ laneType: lane.type,
862
+ };
863
+ laneRegistry.set(registryKey(road.id, secIdx, lane.id), registered);
864
+ const roadLanes = lanesByRoad.get(road.id) ?? [];
865
+ roadLanes.push(registered);
866
+ lanesByRoad.set(road.id, roadLanes);
867
+ }
868
+ };
869
+ materializeSide(sec.left, leftBoundaries, 'L');
870
+ materializeSide(sec.right, rightBoundaries, 'R');
871
+ }
872
+ // ---- Connectivity ----
873
+ const connect = (fromShapeId, toShapeId) => {
874
+ const from = laneShapeById.get(fromShapeId);
875
+ const to = laneShapeById.get(toShapeId);
876
+ if (!from || !to)
877
+ return;
878
+ if (!from.next.includes(toShapeId))
879
+ from.next.push(toShapeId);
880
+ if (!to.prev.includes(fromShapeId))
881
+ to.prev.push(fromShapeId);
882
+ };
883
+ /**
884
+ * Registered lanes at a road's start (first section) or end (last
885
+ * section). When the outermost section is a skipped micro section, the
886
+ * lane reference is resolved through it along the lane-level links to the
887
+ * first materialized section.
888
+ */
889
+ const lanesAt = (roadId, contact, odrLaneId) => {
890
+ const road = roadById.get(roadId);
891
+ const count = sectionCount.get(roadId) ?? 0;
892
+ if (!road || count === 0)
893
+ return [];
894
+ const secIdx = contact === 'start' ? 0 : count - 1;
895
+ const direct = laneRegistry.get(registryKey(roadId, secIdx, odrLaneId));
896
+ if (direct)
897
+ return [direct];
898
+ const out = [];
899
+ for (const t of resolveThroughSkipped(road, secIdx, [odrLaneId], contact === 'start' ? 1 : -1)) {
900
+ const reg = laneRegistry.get(registryKey(roadId, t.secIdx, t.laneId));
901
+ if (reg && !out.includes(reg))
902
+ out.push(reg);
903
+ }
904
+ return out;
905
+ };
906
+ /**
907
+ * Resolve a (road, contact, lane id) endpoint to materialized lanes.
908
+ *
909
+ * Within a road this is `lanesAt` (which already bridges skipped micro
910
+ * sections). When the whole road was skipped — e.g. the short synthesized
911
+ * junction connecting roads this exporter emits, or any sub-threshold road
912
+ * in third-party files — the resolution continues across the road: lane
913
+ * links are walked through its (skipped) sections to the far end, and the
914
+ * road-level link there is followed into the neighbouring road, recursively,
915
+ * so connectivity is bridged across skipped roads instead of being lost.
916
+ */
917
+ const resolveLaneEndpoints = (roadId, contact, odrLaneId, depth = 0) => {
918
+ const direct = lanesAt(roadId, contact, odrLaneId);
919
+ if (direct.length > 0)
920
+ return direct.map(reg => ({ reg, odrLaneId, contact }));
921
+ const road = roadById.get(roadId);
922
+ if (!road || depth > 4)
923
+ return [];
924
+ // Only bridge across roads with no materialized lanes at all; partially
925
+ // materialized roads are fully handled by the in-road resolution above.
926
+ if ((lanesByRoad.get(roadId) ?? []).length > 0)
927
+ return [];
928
+ const n = road.laneSections.length;
929
+ if (n === 0)
930
+ return [];
931
+ // Walk lane-level links through the skipped sections to the far end.
932
+ const farContact = contact === 'start' ? 'end' : 'start';
933
+ const dir = contact === 'start' ? 1 : -1;
934
+ let idx = contact === 'start' ? 0 : n - 1;
935
+ let ids = [odrLaneId];
936
+ for (let step = 0; step < n - 1 && ids.length > 0; step++) {
937
+ const sec = road.laneSections[idx];
938
+ const nextIds = [];
939
+ for (const id of ids) {
940
+ const lane = [...sec.left, ...sec.right].find(l => l.id === id);
941
+ if (!lane)
942
+ continue;
943
+ for (const linked of dir === 1 ? lane.successorIds : lane.predecessorIds) {
944
+ if (!nextIds.includes(linked))
945
+ nextIds.push(linked);
946
+ }
947
+ }
948
+ ids = nextIds;
949
+ idx += dir;
950
+ }
951
+ const farSec = road.laneSections[farContact === 'start' ? 0 : n - 1];
952
+ const link = farContact === 'end' ? road.successor : road.predecessor;
953
+ if (!farSec || !link || link.elementType !== 'road')
954
+ return [];
955
+ const cpB = link.contactPoint ?? (farContact === 'end' ? 'start' : 'end');
956
+ const out = [];
957
+ for (const id of ids) {
958
+ const lane = [...farSec.left, ...farSec.right].find(l => l.id === id);
959
+ if (!lane)
960
+ continue;
961
+ const targetIds = farContact === 'end' ? lane.successorIds : lane.predecessorIds;
962
+ for (const tid of targetIds) {
963
+ for (const ep of resolveLaneEndpoints(link.elementId, cpB, tid, depth + 1)) {
964
+ if (!out.some(o => o.reg === ep.reg && o.odrLaneId === ep.odrLaneId && o.contact === ep.contact)) {
965
+ out.push(ep);
966
+ }
967
+ }
968
+ }
969
+ }
970
+ return out;
971
+ };
972
+ /**
973
+ * Link two lanes meeting at a shared contact, respecting travel direction:
974
+ * right lanes (id < 0) travel toward the road's end, left lanes (id > 0)
975
+ * toward its start. A connection is `a -> b` when a's travel exits at the
976
+ * contact and b's travel enters there (and vice versa); when both exit or
977
+ * both enter, the directions are inconsistent and the pair is skipped.
978
+ */
979
+ const linkLanes = (a, aOdrId, cpA, b, bOdrId, cpB) => {
980
+ const aExits = (aOdrId < 0) === (cpA === 'end');
981
+ const bEnters = (bOdrId < 0) === (cpB === 'start');
982
+ if (aExits && bEnters)
983
+ connect(a.shapeId, b.shapeId);
984
+ else if (!aExits && !bEnters)
985
+ connect(b.shapeId, a.shapeId);
986
+ };
987
+ // Chain consecutive lane sections within each road via lane-level links.
988
+ // References into skipped micro sections are resolved through them to the
989
+ // nearest materialized section.
990
+ for (const road of roads) {
991
+ for (let secIdx = 0; secIdx < road.laneSections.length; secIdx++) {
992
+ const sec = road.laneSections[secIdx];
993
+ for (const lane of [...sec.left, ...sec.right]) {
994
+ const cur = laneRegistry.get(registryKey(road.id, secIdx, lane.id));
995
+ if (!cur)
996
+ continue;
997
+ if (secIdx + 1 < road.laneSections.length) {
998
+ for (const succId of lane.successorIds) {
999
+ for (const t of resolveThroughSkipped(road, secIdx + 1, [succId], 1)) {
1000
+ const nxt = laneRegistry.get(registryKey(road.id, t.secIdx, t.laneId));
1001
+ if (!nxt)
1002
+ continue;
1003
+ if (lane.id < 0 && t.laneId < 0)
1004
+ connect(cur.shapeId, nxt.shapeId);
1005
+ else if (lane.id > 0 && t.laneId > 0)
1006
+ connect(nxt.shapeId, cur.shapeId);
1007
+ }
1008
+ }
1009
+ }
1010
+ if (secIdx > 0) {
1011
+ for (const predId of lane.predecessorIds) {
1012
+ for (const t of resolveThroughSkipped(road, secIdx - 1, [predId], -1)) {
1013
+ const prv = laneRegistry.get(registryKey(road.id, t.secIdx, t.laneId));
1014
+ if (!prv)
1015
+ continue;
1016
+ if (lane.id < 0 && t.laneId < 0)
1017
+ connect(prv.shapeId, cur.shapeId);
1018
+ else if (lane.id > 0 && t.laneId > 0)
1019
+ connect(cur.shapeId, prv.shapeId);
1020
+ }
1021
+ }
1022
+ }
1023
+ }
1024
+ }
1025
+ }
1026
+ // Road-level links (road <-> road). Junction links are resolved separately
1027
+ // through the junction connection table.
1028
+ const processRoadLink = (roadA, link, cpA) => {
1029
+ if (!link || link.elementType !== 'road')
1030
+ return;
1031
+ const roadB = roadById.get(link.elementId);
1032
+ if (!roadB)
1033
+ return;
1034
+ const cpB = link.contactPoint ?? (cpA === 'end' ? 'start' : 'end');
1035
+ const sec = roadA.laneSections[cpA === 'start' ? 0 : roadA.laneSections.length - 1];
1036
+ if (!sec)
1037
+ return;
1038
+ for (const lane of [...sec.left, ...sec.right]) {
1039
+ const targetIds = cpA === 'end' ? lane.successorIds : lane.predecessorIds;
1040
+ for (const a of resolveLaneEndpoints(roadA.id, cpA, lane.id)) {
1041
+ for (const toId of targetIds) {
1042
+ for (const b of resolveLaneEndpoints(roadB.id, cpB, toId)) {
1043
+ linkLanes(a.reg, a.odrLaneId, a.contact, b.reg, b.odrLaneId, b.contact);
1044
+ }
1045
+ }
1046
+ }
1047
+ }
1048
+ };
1049
+ for (const road of roads) {
1050
+ processRoadLink(road, road.predecessor, 'start');
1051
+ processRoadLink(road, road.successor, 'end');
1052
+ }
1053
+ // Junction connections: incomingRoad -> connectingRoad laneLinks.
1054
+ for (const junction of map.junctions) {
1055
+ for (const conn of junction.connections) {
1056
+ const roadA = roadById.get(conn.incomingRoad);
1057
+ const roadC = roadById.get(conn.connectingRoad);
1058
+ if (!roadA || !roadC)
1059
+ continue;
1060
+ // Which end of the incoming road faces this junction?
1061
+ const contacts = [];
1062
+ if (roadA.successor?.elementType === 'junction' && roadA.successor.elementId === junction.id) {
1063
+ contacts.push('end');
1064
+ }
1065
+ if (roadA.predecessor?.elementType === 'junction' && roadA.predecessor.elementId === junction.id) {
1066
+ contacts.push('start');
1067
+ }
1068
+ if (contacts.length === 0)
1069
+ contacts.push('end'); // Tolerant default.
1070
+ for (const cpA of contacts) {
1071
+ for (const ll of conn.laneLinks) {
1072
+ for (const a of resolveLaneEndpoints(roadA.id, cpA, ll.from)) {
1073
+ for (const b of resolveLaneEndpoints(roadC.id, conn.contactPoint, ll.to)) {
1074
+ linkLanes(a.reg, a.odrLaneId, a.contact, b.reg, b.odrLaneId, b.contact);
1075
+ }
1076
+ }
1077
+ }
1078
+ }
1079
+ }
1080
+ }
1081
+ // Hidden lane links: edges the exporter could not express as standard
1082
+ // <link>/<laneLink> records because a contact width is zero (the OpenDRIVE
1083
+ // zero-width / appearing-lane link rules), stashed per road as
1084
+ // <userData code="hiddenLaneLinks" value="[{fr,fl,tr,tl},...]"> with
1085
+ // from-road / from-lane / to-road / to-lane ids (from end -> to start in
1086
+ // travel direction). Restored here into next/prev like any other link.
1087
+ for (const road of roads) {
1088
+ const raw = road.userData['hiddenLaneLinks'];
1089
+ if (!raw)
1090
+ continue;
1091
+ let recs;
1092
+ try {
1093
+ recs = JSON.parse(raw);
1094
+ }
1095
+ catch {
1096
+ continue; // Malformed userData JSON is ignored (third-party files).
1097
+ }
1098
+ if (!Array.isArray(recs))
1099
+ continue;
1100
+ for (const rec of recs) {
1101
+ if (!rec || typeof rec !== 'object')
1102
+ continue;
1103
+ const { fr, fl, tr, tl } = rec;
1104
+ if (typeof fl !== 'number' || typeof tl !== 'number')
1105
+ continue;
1106
+ const fromRoad = fr === undefined ? road.id : String(fr);
1107
+ const toRoad = tr === undefined ? road.id : String(tr);
1108
+ for (const a of resolveLaneEndpoints(fromRoad, 'end', fl)) {
1109
+ for (const b of resolveLaneEndpoints(toRoad, 'start', tl)) {
1110
+ linkLanes(a.reg, a.odrLaneId, a.contact, b.reg, b.odrLaneId, b.contact);
1111
+ }
1112
+ }
1113
+ }
1114
+ }
1115
+ // Junction <priority high low> records restore right-of-way links: the
1116
+ // lanes standing in for the prioritized connecting road gain yieldLaneIds
1117
+ // over the lanes standing in for the yielding one. A materialized
1118
+ // connecting road is represented by its own lanes; a skipped (short
1119
+ // synthesized) one resolves through its predecessor link to the incoming
1120
+ // lanes the maneuver started from — the lanes the exporter originally read
1121
+ // the yieldLaneIds off. Merged with any userData-restored links above.
1122
+ const priorityRoadLanes = (roadId) => {
1123
+ const own = lanesByRoad.get(roadId) ?? [];
1124
+ if (own.length > 0) {
1125
+ const out = [];
1126
+ for (const reg of own) {
1127
+ if (!out.includes(reg.shapeId))
1128
+ out.push(reg.shapeId);
1129
+ }
1130
+ return out;
1131
+ }
1132
+ const road = roadById.get(roadId);
1133
+ if (!road)
1134
+ return [];
1135
+ const lastSec = road.laneSections[road.laneSections.length - 1];
1136
+ if (!lastSec)
1137
+ return [];
1138
+ const out = [];
1139
+ for (const lane of [...lastSec.left, ...lastSec.right]) {
1140
+ for (const ep of resolveLaneEndpoints(roadId, 'end', lane.id)) {
1141
+ if (!out.includes(ep.reg.shapeId))
1142
+ out.push(ep.reg.shapeId);
1143
+ }
1144
+ }
1145
+ return out;
1146
+ };
1147
+ for (const junction of map.junctions) {
1148
+ for (const pr of junction.priorities) {
1149
+ const highLanes = priorityRoadLanes(pr.high);
1150
+ const lowLanes = priorityRoadLanes(pr.low);
1151
+ if (highLanes.length === 0 || lowLanes.length === 0)
1152
+ continue;
1153
+ for (const shapeId of highLanes) {
1154
+ const lane = laneShapeById.get(shapeId);
1155
+ if (!lane)
1156
+ continue;
1157
+ const merged = lane.yieldLaneIds ?? [];
1158
+ for (const lowId of lowLanes) {
1159
+ if (lowId !== shapeId && !merged.includes(lowId))
1160
+ merged.push(lowId);
1161
+ }
1162
+ if (merged.length > 0)
1163
+ lane.yieldLaneIds = merged;
1164
+ }
1165
+ }
1166
+ }
1167
+ // ---- Round-trip fidelity post-processing ----
1168
+ // 1. Boundaries that are geometrically one line (each exported road carries
1169
+ // its own copy of a boundary shared with its neighbour) collapse into a
1170
+ // single linestring so left/right adjacency is expressed by sharing.
1171
+ // 2. Boundary endpoints of connected lanes are welded into shared Point
1172
+ // shapes so a Lanelet2 export emits shared nodes (Autoware routing and
1173
+ // shared-node connection detection both depend on this).
1174
+ dedupeSharedBoundaries(result);
1175
+ weldConnectedLaneContacts(result);
1176
+ removeOrphanPoints(result);
1177
+ // ---- Carry-through records ----
1178
+ // Per-road state hashes for `exportToOpenDrive({ sidecar })`: a road whose
1179
+ // hash still matches at export time was not edited and is re-emitted
1180
+ // verbatim from the sidecar XML. Hashes are taken AFTER all post-processing
1181
+ // (dedupe / weld / orphan removal) so they describe exactly the shapes the
1182
+ // editor will hold; the exporter recomputes them from the live shapes.
1183
+ {
1184
+ const recPointById = new Map(result.points.map(p => [p.id, p]));
1185
+ const recLsById = new Map(result.linestrings.map(l => [l.id, l]));
1186
+ const boundaryPts = (lsId, invert) => {
1187
+ if (!lsId)
1188
+ return null;
1189
+ const ls = recLsById.get(lsId);
1190
+ if (!ls)
1191
+ return null;
1192
+ const ids = invert ? [...ls.pointIds].reverse() : ls.pointIds;
1193
+ const pts = [];
1194
+ for (const pid of ids) {
1195
+ const p = recPointById.get(pid);
1196
+ if (p)
1197
+ pts.push({ x: p.x, y: p.y });
1198
+ }
1199
+ return pts.length >= 2 ? pts : null;
1200
+ };
1201
+ const laneRoadOf = new Map();
1202
+ for (const [roadId, regs] of lanesByRoad) {
1203
+ for (const reg of regs)
1204
+ laneRoadOf.set(reg.shapeId, roadId);
1205
+ }
1206
+ // Regulatory shapes touching a road: attached to it (odr_road_id) or
1207
+ // affecting any of its lanes. Mirrored by the exporter's hash builder.
1208
+ const regStatesByRoad = new Map();
1209
+ const addRegState = (state, affected, own) => {
1210
+ const touching = new Set();
1211
+ if (own && roadById.has(own))
1212
+ touching.add(own);
1213
+ for (const lid of affected) {
1214
+ const rid = laneRoadOf.get(lid);
1215
+ if (rid)
1216
+ touching.add(rid);
1217
+ }
1218
+ for (const rid of touching) {
1219
+ const list = regStatesByRoad.get(rid) ?? [];
1220
+ list.push(state);
1221
+ regStatesByRoad.set(rid, list);
1222
+ }
1223
+ };
1224
+ for (const tl of result.trafficLights) {
1225
+ addRegState({
1226
+ kind: 'traffic_light',
1227
+ shapeId: tl.id,
1228
+ numbers: [tl.x, tl.y, tl.w, tl.h, 0],
1229
+ attributes: tl.attributes,
1230
+ affectedLaneIds: tl.affectedLaneIds,
1231
+ stopLinePts: boundaryPts(tl.stopLineId, false),
1232
+ controllerId: '',
1233
+ }, tl.affectedLaneIds, tl.attributes['odr_road_id']);
1234
+ }
1235
+ for (const cw of result.crosswalks) {
1236
+ addRegState({
1237
+ kind: 'crosswalk',
1238
+ shapeId: cw.id,
1239
+ numbers: [cw.x, cw.y, cw.startX, cw.startY, cw.endX, cw.endY, cw.crosswalkWidth, 0],
1240
+ attributes: cw.attributes,
1241
+ affectedLaneIds: cw.affectedLaneIds,
1242
+ stopLinePts: boundaryPts(cw.stopLineId, false),
1243
+ controllerId: '',
1244
+ }, cw.affectedLaneIds, cw.attributes['odr_road_id']);
1245
+ }
1246
+ const roadRecords = {};
1247
+ for (const road of roads) {
1248
+ const regLanes = lanesByRoad.get(road.id) ?? [];
1249
+ const laneStates = regLanes.map(reg => {
1250
+ const lane = laneShapeById.get(reg.shapeId);
1251
+ return {
1252
+ leftPts: boundaryPts(lane.leftBoundaryId, lane.invertLeft),
1253
+ rightPts: boundaryPts(lane.rightBoundaryId, lane.invertRight),
1254
+ attributes: lane.attributes,
1255
+ next: lane.next,
1256
+ prev: lane.prev,
1257
+ yieldLaneIds: lane.yieldLaneIds ?? [],
1258
+ };
1259
+ });
1260
+ roadRecords[road.id] = {
1261
+ laneShapeIds: regLanes.map(r => r.shapeId),
1262
+ stateHash: hashRoadState(laneStates, regStatesByRoad.get(road.id) ?? []),
1263
+ };
1264
+ }
1265
+ result.sidecar.roadRecords = roadRecords;
1266
+ }
1267
+ // ---- Aggregated warnings ----
1268
+ if (elevationRoads > 0) {
1269
+ warnings.push(`Elevation profiles on ${elevationRoads} road(s) were flattened to 2D.`);
1270
+ }
1271
+ if (superelevationRoads > 0) {
1272
+ warnings.push(`Superelevation/lateral profiles on ${superelevationRoads} road(s) were ignored (2D import).`);
1273
+ }
1274
+ if (poly3Roads > 0) {
1275
+ warnings.push(`Deprecated <poly3> geometry on ${poly3Roads} road(s) was approximated (local abscissa taken as arc length).`);
1276
+ }
1277
+ const unconvertedSignals = signalCount - convertedSignalCount;
1278
+ const unconvertedObjects = objectCount - convertedObjectCount;
1279
+ if (unconvertedSignals > 0 || unconvertedObjects > 0) {
1280
+ warnings.push(`${unconvertedSignals} signal(s) and ${unconvertedObjects} object(s) were parsed but not converted to shapes.`);
1281
+ }
1282
+ if (microSectionRoads > 0) {
1283
+ warnings.push(`Micro lane sections (< ${MIN_SECTION_LEN_M} m) on ${microSectionRoads} road(s) were skipped; lane connectivity was bridged across them.`);
1284
+ }
1285
+ // ---- Bounds ----
1286
+ for (const point of result.points) {
1287
+ if (point.x < result.bounds.minX)
1288
+ result.bounds.minX = point.x;
1289
+ if (point.x > result.bounds.maxX)
1290
+ result.bounds.maxX = point.x;
1291
+ if (point.y < result.bounds.minY)
1292
+ result.bounds.minY = point.y;
1293
+ if (point.y > result.bounds.maxY)
1294
+ result.bounds.maxY = point.y;
1295
+ }
1296
+ if (result.points.length > 0) {
1297
+ result.bounds.width = result.bounds.maxX - result.bounds.minX;
1298
+ result.bounds.height = result.bounds.maxY - result.bounds.minY;
1299
+ result.bounds.centerX = result.bounds.minX + result.bounds.width / 2;
1300
+ result.bounds.centerY = result.bounds.minY + result.bounds.height / 2;
1301
+ }
1302
+ return result;
1303
+ }
1304
+ // ---------------------------------------------------------------------------
1305
+ // Round-trip fidelity post-processing
1306
+ // ---------------------------------------------------------------------------
1307
+ /**
1308
+ * Max pointwise deviation (m) for two boundaries to count as one line, in
1309
+ * the interior of the polyline. Two genuinely distinct parallel boundaries
1310
+ * are at least a lane width apart in their interior, so this can stay tight.
1311
+ */
1312
+ const BOUNDARY_DEDUPE_INTERIOR_TOL_M = 0.3;
1313
+ /**
1314
+ * Max pointwise deviation (m) near the polyline ends. Contact-point welding
1315
+ * (see weldConnectedLaneContacts) moves junction corners by up to a couple
1316
+ * of meters, and the per-road reconstruction of a shared boundary diverges
1317
+ * around such a kink, so the comparison is more permissive there. A false
1318
+ * match would require two boundaries that pinch below this at both ends AND
1319
+ * run within the interior tolerance in between — i.e. a degenerate sliver.
1320
+ */
1321
+ const BOUNDARY_DEDUPE_END_TOL_M = 1.5;
1322
+ /**
1323
+ * Max contact gap (m) tolerated when welding the boundary endpoints of two
1324
+ * lanes joined by a next/prev edge. At junction entries/exits the corner on
1325
+ * the outer side of a turning connecting lane legitimately sits up to about
1326
+ * `laneWidth * 2 * sin(turnAngle / 2)` away from the incoming lane's corner,
1327
+ * so this is generous; the weld is only ever applied across declared edges,
1328
+ * never discovered by proximity.
1329
+ */
1330
+ const CONTACT_WELD_MAX_GAP_M = 10;
1331
+ /**
1332
+ * Merge boundary linestrings that trace the same geometry (all resampled
1333
+ * points within tolerance), so adjacent lanes reference one shared
1334
+ * linestring. Reversed duplicates merge too, flipping the lane's invert flag.
1335
+ *
1336
+ * The OpenDRIVE exporter emits one road per lane with its own reference
1337
+ * line, so a boundary shared between two adjacent lanes comes back as two
1338
+ * near-identical linestrings; this pass restores the sharing (and with it
1339
+ * the left/right adjacency information).
1340
+ */
1341
+ function dedupeSharedBoundaries(result) {
1342
+ const pointById = new Map(result.points.map(p => [p.id, p]));
1343
+ const boundaryIds = new Set();
1344
+ for (const lane of result.lanes) {
1345
+ boundaryIds.add(lane.leftBoundaryId);
1346
+ boundaryIds.add(lane.rightBoundaryId);
1347
+ }
1348
+ const entries = [];
1349
+ for (const ls of result.linestrings) {
1350
+ if (!boundaryIds.has(ls.id))
1351
+ continue;
1352
+ const pts = [];
1353
+ for (const pid of ls.pointIds) {
1354
+ const p = pointById.get(pid);
1355
+ if (p)
1356
+ pts.push({ x: p.x, y: p.y });
1357
+ }
1358
+ if (pts.length >= 2)
1359
+ entries.push({ ls, pts });
1360
+ }
1361
+ const candidates = [];
1362
+ for (let i = 0; i < entries.length; i++) {
1363
+ for (let j = i + 1; j < entries.length; j++) {
1364
+ const m = boundaryMatchScore(entries[i].pts, entries[j].pts);
1365
+ if (m === null)
1366
+ continue;
1367
+ candidates.push({
1368
+ a: entries[i].ls.id,
1369
+ b: entries[j].ls.id,
1370
+ reversed: m.reversed,
1371
+ score: m.score,
1372
+ });
1373
+ }
1374
+ }
1375
+ if (candidates.length === 0)
1376
+ return;
1377
+ // Best (lowest-deviation) matches merge first, so the true duplicate of a
1378
+ // boundary wins over a nearby copy across a very narrow lane; the
1379
+ // left!=right constraint below then blocks the false match.
1380
+ candidates.sort((x, y) => x.score - y.score);
1381
+ // 2. Union-find with orientation parity (0 = same order as parent, 1 =
1382
+ // reversed), constrained so no lane ends up with left === right.
1383
+ const parent = new Map();
1384
+ const parity = new Map();
1385
+ const findWithParity = (x) => {
1386
+ let root = x;
1387
+ let p = 0;
1388
+ while (true) {
1389
+ const up = parent.get(root);
1390
+ if (up === undefined || up === root)
1391
+ break;
1392
+ p ^= parity.get(root) ?? 0;
1393
+ root = up;
1394
+ }
1395
+ // Path compression (re-walk, pointing every node at the root).
1396
+ let cur = x;
1397
+ let curP = p;
1398
+ while (cur !== root) {
1399
+ const up = parent.get(cur);
1400
+ const upP = parity.get(cur) ?? 0;
1401
+ parent.set(cur, root);
1402
+ parity.set(cur, curP);
1403
+ cur = up;
1404
+ curP ^= upP;
1405
+ }
1406
+ return { root, parity: p };
1407
+ };
1408
+ const pairKey = (x, y) => (x < y ? `${x}|${y}` : `${y}|${x}`);
1409
+ /** Current root pairs (left|right) of every lane; merges may not collapse one. */
1410
+ const lanePairs = new Set();
1411
+ const lanesByRoot = new Map();
1412
+ for (const lane of result.lanes) {
1413
+ lanePairs.add(pairKey(lane.leftBoundaryId, lane.rightBoundaryId));
1414
+ for (const b of [lane.leftBoundaryId, lane.rightBoundaryId]) {
1415
+ const list = lanesByRoot.get(b) ?? [];
1416
+ list.push(lane);
1417
+ lanesByRoot.set(b, list);
1418
+ }
1419
+ }
1420
+ let merges = 0;
1421
+ for (const c of candidates) {
1422
+ const fa = findWithParity(c.a);
1423
+ const fb = findWithParity(c.b);
1424
+ if (fa.root === fb.root)
1425
+ continue;
1426
+ if (lanePairs.has(pairKey(fa.root, fb.root)))
1427
+ continue; // would collapse a lane
1428
+ // Attach b's tree under a's root, composing orientation parities.
1429
+ parent.set(fb.root, fa.root);
1430
+ parity.set(fb.root, fa.parity ^ (c.reversed ? 1 : 0) ^ fb.parity);
1431
+ merges++;
1432
+ // Re-key the root pairs of the lanes that referenced b's old root.
1433
+ const moved = lanesByRoot.get(fb.root) ?? [];
1434
+ const target = lanesByRoot.get(fa.root) ?? [];
1435
+ for (const lane of moved) {
1436
+ target.push(lane);
1437
+ lanePairs.add(pairKey(findWithParity(lane.leftBoundaryId).root, findWithParity(lane.rightBoundaryId).root));
1438
+ }
1439
+ lanesByRoot.set(fa.root, target);
1440
+ lanesByRoot.delete(fb.root);
1441
+ }
1442
+ if (merges === 0)
1443
+ return;
1444
+ // 3. Apply: every boundary resolves to its root linestring; a reversed
1445
+ // parity flips the lane's invert flag for that boundary.
1446
+ const replaced = new Map();
1447
+ for (const entry of entries) {
1448
+ const id = entry.ls.id;
1449
+ const f = findWithParity(id);
1450
+ if (f.root !== id)
1451
+ replaced.set(id, { keepId: f.root, reversed: f.parity === 1 });
1452
+ }
1453
+ for (const lane of result.lanes) {
1454
+ const left = replaced.get(lane.leftBoundaryId);
1455
+ if (left) {
1456
+ lane.leftBoundaryId = left.keepId;
1457
+ if (left.reversed)
1458
+ lane.invertLeft = !lane.invertLeft;
1459
+ }
1460
+ const right = replaced.get(lane.rightBoundaryId);
1461
+ if (right) {
1462
+ lane.rightBoundaryId = right.keepId;
1463
+ if (right.reversed)
1464
+ lane.invertRight = !lane.invertRight;
1465
+ }
1466
+ }
1467
+ result.linestrings = result.linestrings.filter(ls => !replaced.has(ls.id));
1468
+ }
1469
+ /**
1470
+ * Position-dependent dedupe tolerance (px): the interior tolerance over the
1471
+ * middle half of the polyline, tapering up to the end tolerance at t=0 / t=1.
1472
+ */
1473
+ function dedupeTolAt(t) {
1474
+ const interior = BOUNDARY_DEDUPE_INTERIOR_TOL_M * PIXELS_PER_METER;
1475
+ const end = BOUNDARY_DEDUPE_END_TOL_M * PIXELS_PER_METER;
1476
+ // 0 for t in [0.25, 0.75], rising linearly to 1 at t = 0 / t = 1.
1477
+ const edge = Math.max(0, Math.abs(t - 0.5) * 2 - 0.5) / 0.5;
1478
+ return interior + (end - interior) * edge;
1479
+ }
1480
+ /**
1481
+ * Score how well two boundary polylines trace the same line. Each polyline
1482
+ * is resampled by normalized arc length and the distance from every sample
1483
+ * to the OTHER polyline (nearest point on any segment) is taken, normalized
1484
+ * by the graded tolerance; the worst ratio over both directions is the score
1485
+ * (<= 1 means a match). Nearest-point distance is used instead of comparing
1486
+ * param-matched samples because differing vertex distributions of the same
1487
+ * curve cause longitudinal slip that is not a geometric deviation.
1488
+ * The relative orientation is decided by the endpoint pairing, which also
1489
+ * acts as a cheap pre-filter. Returns null for no match.
1490
+ */
1491
+ function boundaryMatchScore(a, b) {
1492
+ const d = (p, q) => Math.hypot(p.x - q.x, p.y - q.y);
1493
+ const endTolPx = BOUNDARY_DEDUPE_END_TOL_M * PIXELS_PER_METER;
1494
+ const a0 = a[0];
1495
+ const a1 = a[a.length - 1];
1496
+ const b0 = b[0];
1497
+ const b1 = b[b.length - 1];
1498
+ const forwardEnds = Math.max(d(a0, b0), d(a1, b1));
1499
+ const reversedEnds = Math.max(d(a0, b1), d(a1, b0));
1500
+ if (Math.min(forwardEnds, reversedEnds) > endTolPx)
1501
+ return null;
1502
+ const score = Math.max(polylineDeviationScore(a, b), polylineDeviationScore(b, a));
1503
+ if (score > 1)
1504
+ return null;
1505
+ return { reversed: reversedEnds < forwardEnds, score };
1506
+ }
1507
+ /**
1508
+ * Worst nearest-point distance from arc-length resampled points of `a` to
1509
+ * the polyline `b`, normalized by the graded tolerance (1 = at tolerance).
1510
+ */
1511
+ function polylineDeviationScore(a, b) {
1512
+ const n = Math.max(a.length, 8);
1513
+ let worst = 0;
1514
+ for (let i = 0; i < n; i++) {
1515
+ const t = i / (n - 1);
1516
+ const pa = sampleAtParam(a, t);
1517
+ const score = distanceToPolyline(pa, b) / dedupeTolAt(t);
1518
+ if (score > worst) {
1519
+ worst = score;
1520
+ if (worst > 1)
1521
+ return worst;
1522
+ }
1523
+ }
1524
+ return worst;
1525
+ }
1526
+ /** Distance from a point to the nearest segment of a polyline. */
1527
+ function distanceToPolyline(q, poly) {
1528
+ let best = Infinity;
1529
+ for (let i = 0; i < poly.length - 1; i++) {
1530
+ const ax = poly[i].x;
1531
+ const ay = poly[i].y;
1532
+ const dx = poly[i + 1].x - ax;
1533
+ const dy = poly[i + 1].y - ay;
1534
+ const len2 = dx * dx + dy * dy;
1535
+ let t = len2 > 0 ? ((q.x - ax) * dx + (q.y - ay) * dy) / len2 : 0;
1536
+ if (t < 0)
1537
+ t = 0;
1538
+ else if (t > 1)
1539
+ t = 1;
1540
+ const px = ax + dx * t;
1541
+ const py = ay + dy * t;
1542
+ const dist = Math.hypot(q.x - px, q.y - py);
1543
+ if (dist < best)
1544
+ best = dist;
1545
+ }
1546
+ return best;
1547
+ }
1548
+ /**
1549
+ * Weld the boundary endpoint Points of lanes joined by a next/prev edge into
1550
+ * shared Point shapes (union-find; the cluster centroid is the welded
1551
+ * position). Lane-section chains and road links meet exactly; junction
1552
+ * connections can differ on the outer corner of a turn, which is exactly the
1553
+ * corner a Lanelet2-style map shares between consecutive lanelets.
1554
+ *
1555
+ * Welds are derived ONLY from declared edges, and each weld joins a lane's
1556
+ * end to its successor's start; a lane's own start and end are never merged,
1557
+ * so short connecting lanes survive unchanged.
1558
+ */
1559
+ function weldConnectedLaneContacts(result) {
1560
+ const maxGapPx = CONTACT_WELD_MAX_GAP_M * PIXELS_PER_METER;
1561
+ const pointById = new Map(result.points.map(p => [p.id, p]));
1562
+ const lsById = new Map(result.linestrings.map(l => [l.id, l]));
1563
+ const laneById = new Map(result.lanes.map(l => [l.id, l]));
1564
+ const parent = new Map();
1565
+ const find = (x) => {
1566
+ let root = x;
1567
+ while (true) {
1568
+ const p = parent.get(root);
1569
+ if (p === undefined || p === root)
1570
+ break;
1571
+ root = p;
1572
+ }
1573
+ let cur = x;
1574
+ while (cur !== root) {
1575
+ const next = parent.get(cur);
1576
+ parent.set(cur, root);
1577
+ cur = next;
1578
+ }
1579
+ return root;
1580
+ };
1581
+ const union = (a, b) => {
1582
+ if (!parent.has(a))
1583
+ parent.set(a, a);
1584
+ if (!parent.has(b))
1585
+ parent.set(b, b);
1586
+ const ra = find(a);
1587
+ const rb = find(b);
1588
+ if (ra !== rb)
1589
+ parent.set(rb, ra);
1590
+ };
1591
+ /** Boundary endpoint Point id at the lane's travel start/end. */
1592
+ const corner = (lane, boundary, side) => {
1593
+ const ls = lsById.get(boundary === 'left' ? lane.leftBoundaryId : lane.rightBoundaryId);
1594
+ if (!ls || ls.pointIds.length === 0)
1595
+ return null;
1596
+ const invert = boundary === 'left' ? lane.invertLeft : lane.invertRight;
1597
+ const ids = ls.pointIds;
1598
+ const atStoredStart = side === 'start' ? !invert : invert;
1599
+ return atStoredStart ? ids[0] : ids[ids.length - 1];
1600
+ };
1601
+ const gap = (aId, bId) => {
1602
+ const a = pointById.get(aId);
1603
+ const b = pointById.get(bId);
1604
+ if (!a || !b)
1605
+ return Infinity;
1606
+ return Math.hypot(a.x - b.x, a.y - b.y);
1607
+ };
1608
+ let welds = 0;
1609
+ for (const lane of result.lanes) {
1610
+ for (const nextId of lane.next) {
1611
+ const next = laneById.get(nextId);
1612
+ if (!next)
1613
+ continue;
1614
+ const aL = corner(lane, 'left', 'end');
1615
+ const aR = corner(lane, 'right', 'end');
1616
+ const bL = corner(next, 'left', 'start');
1617
+ const bR = corner(next, 'right', 'start');
1618
+ if (!aL || !aR || !bL || !bR)
1619
+ continue;
1620
+ if (gap(aL, bL) > maxGapPx || gap(aR, bR) > maxGapPx)
1621
+ continue;
1622
+ union(aL, bL);
1623
+ union(aR, bR);
1624
+ welds++;
1625
+ }
1626
+ }
1627
+ if (welds === 0)
1628
+ return;
1629
+ // Cluster centroid becomes the welded position (representative value).
1630
+ const clusters = new Map();
1631
+ for (const id of parent.keys()) {
1632
+ const root = find(id);
1633
+ const list = clusters.get(root) ?? [];
1634
+ list.push(id);
1635
+ clusters.set(root, list);
1636
+ }
1637
+ for (const [root, members] of clusters) {
1638
+ if (members.length < 2)
1639
+ continue;
1640
+ let sx = 0;
1641
+ let sy = 0;
1642
+ let n = 0;
1643
+ for (const m of members) {
1644
+ const p = pointById.get(m);
1645
+ if (!p)
1646
+ continue;
1647
+ sx += p.x;
1648
+ sy += p.y;
1649
+ n++;
1650
+ }
1651
+ const rp = pointById.get(root);
1652
+ if (!rp || n === 0)
1653
+ continue;
1654
+ rp.x = sx / n;
1655
+ rp.y = sy / n;
1656
+ }
1657
+ // Rewrite linestring point references to the cluster roots and refresh the
1658
+ // anchor coordinates (linestrings anchor on their first point).
1659
+ for (const ls of result.linestrings) {
1660
+ let changed = false;
1661
+ const ids = ls.pointIds.map(id => {
1662
+ if (!parent.has(id))
1663
+ return id;
1664
+ const root = find(id);
1665
+ if (root !== id)
1666
+ changed = true;
1667
+ return root;
1668
+ });
1669
+ if (changed)
1670
+ ls.pointIds = ids;
1671
+ const first = pointById.get(ls.pointIds[0]);
1672
+ if (first) {
1673
+ ls.x = first.x;
1674
+ ls.y = first.y;
1675
+ }
1676
+ }
1677
+ for (const lane of result.lanes) {
1678
+ const ls = lsById.get(lane.leftBoundaryId);
1679
+ if (ls) {
1680
+ lane.x = ls.x;
1681
+ lane.y = ls.y;
1682
+ }
1683
+ }
1684
+ }
1685
+ /** Drop Point records no longer referenced by any linestring. */
1686
+ function removeOrphanPoints(result) {
1687
+ const used = new Set();
1688
+ for (const ls of result.linestrings) {
1689
+ for (const pid of ls.pointIds)
1690
+ used.add(pid);
1691
+ }
1692
+ if (used.size === result.points.length)
1693
+ return;
1694
+ result.points = result.points.filter(p => used.has(p.id));
1695
+ }
1696
+ //# sourceMappingURL=odrToShapes.js.map