@drawtonomy/sdk 0.9.0 → 0.11.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 (39) hide show
  1. package/dist/exporter/index.d.ts +4 -4
  2. package/dist/exporter/index.d.ts.map +1 -1
  3. package/dist/exporter/index.js +1 -1
  4. package/dist/exporter/index.js.map +1 -1
  5. package/dist/exporter/lanelet2.d.ts +12 -0
  6. package/dist/exporter/lanelet2.d.ts.map +1 -1
  7. package/dist/exporter/lanelet2.js +112 -31
  8. package/dist/exporter/lanelet2.js.map +1 -1
  9. package/dist/exporter/odrCarryThrough.d.ts +89 -0
  10. package/dist/exporter/odrCarryThrough.d.ts.map +1 -0
  11. package/dist/exporter/odrCarryThrough.js +186 -0
  12. package/dist/exporter/odrCarryThrough.js.map +1 -0
  13. package/dist/exporter/odrGeometryFit.d.ts +37 -0
  14. package/dist/exporter/odrGeometryFit.d.ts.map +1 -0
  15. package/dist/exporter/odrGeometryFit.js +476 -0
  16. package/dist/exporter/odrGeometryFit.js.map +1 -0
  17. package/dist/exporter/odrToShapes.d.ts +8 -0
  18. package/dist/exporter/odrToShapes.d.ts.map +1 -1
  19. package/dist/exporter/odrToShapes.js +444 -54
  20. package/dist/exporter/odrToShapes.js.map +1 -1
  21. package/dist/exporter/opendrive.d.ts +14 -1
  22. package/dist/exporter/opendrive.d.ts.map +1 -1
  23. package/dist/exporter/opendrive.js +1603 -315
  24. package/dist/exporter/opendrive.js.map +1 -1
  25. package/dist/exporter/opendriveParser.d.ts +12 -0
  26. package/dist/exporter/opendriveParser.d.ts.map +1 -1
  27. package/dist/exporter/opendriveParser.js +7 -2
  28. package/dist/exporter/opendriveParser.js.map +1 -1
  29. package/dist/exporter/osmToShapes.d.ts +26 -1
  30. package/dist/exporter/osmToShapes.d.ts.map +1 -1
  31. package/dist/exporter/osmToShapes.js +54 -9
  32. package/dist/exporter/osmToShapes.js.map +1 -1
  33. package/dist/exporter/units.d.ts +7 -0
  34. package/dist/exporter/units.d.ts.map +1 -1
  35. package/dist/exporter/units.js +11 -0
  36. package/dist/exporter/units.js.map +1 -1
  37. package/dist/types.d.ts +28 -0
  38. package/dist/types.d.ts.map +1 -1
  39. package/package.json +1 -1
@@ -2,18 +2,31 @@
2
2
  // No external library dependencies.
3
3
  //
4
4
  // Design:
5
- // - Each lane is emitted as an independent <road>
6
- // - Reference line is the midpoints of the left/right boundary samples
7
- // - Each segment is a <line> geometry; lane width is a per-sample <width>
8
- // - Lane connectivity (next/prev) is written into <link>; branch/merge edges
9
- // that a single road link cannot express are synthesized into <junction>
10
- // elements (see planJunctions)
11
- // - Lanelet-only lane tags are stashed in <userData code="laneAttributes">
12
- // and restored on import; multi-road signal validity uses <signalReference>
5
+ // - Laterally adjacent same-direction lanes (detected through shared boundary
6
+ // linestrings) are grouped into one road bundle and emitted as a single
7
+ // <road> with lanes -1, -2, ... (inner to outer)
8
+ // - The road reference line is the bundle's leftmost boundary (the left edge
9
+ // in travel direction), so lane 0 sits on it and no laneOffset is needed
10
+ // - The reference polyline is fitted into analytic primitives (<line>, <arc>,
11
+ // <paramPoly3>) by odrGeometryFit; lane widths are piecewise-linear <width>
12
+ // records measured along the fitted reference normals (the exact inverse of
13
+ // the importer's offset-along-normal reconstruction)
14
+ // - Lane connectivity (next/prev) is written into road/lane <link> records;
15
+ // branch/merge edges that road links cannot express are synthesized into
16
+ // <junction> elements with short connecting roads (one per lane edge, each
17
+ // carrying predecessor/successor road links) so the standard
18
+ // incoming -> connecting -> outgoing structure holds (see planConnectivity)
19
+ // - Lanelet-only lane tags are stashed per lane in
20
+ // <userData code="laneAttributes"> and restored on import; signal validity
21
+ // uses <validity fromLane toLane> lane ranges within a road and
22
+ // <signalReference> only when a signal spans several roads
13
23
  // - Coordinate frame: canvas (x right, y down) → ENU (x right, y up); y is flipped
14
- import { computeCenterlineWithWidth } from './laneCenterline';
24
+ import { evalGeometry } from './odrGeometry';
25
+ import { fitPlanView } from './odrGeometryFit';
15
26
  import { originToProjString } from './projection';
16
- import { escapeXml, fmt, pxToEnuX, pxToEnuY, pxToMeter } from './units';
27
+ import { escapeXml, fmt, fmtPrecise, pxToEnuX, pxToEnuY, pxToMeter } from './units';
28
+ import { extractOdrDocument, hashRoadState, rewriteRoadLinkTargets, } from './odrCarryThrough';
29
+ import { trafficSignCode } from './lanelet2';
17
30
  /** O(1) shape lookup by id. */
18
31
  function buildShapeMap(shapes) {
19
32
  const map = new Map();
@@ -21,36 +34,6 @@ function buildShapeMap(shapes) {
21
34
  map.set(s.id, s);
22
35
  return map;
23
36
  }
24
- /**
25
- * Build the centerline + width samples for a lane from its two boundaries.
26
- * If pointOverrides is provided, those point ids are read from the override
27
- * map instead of from the shape map (used for boundary alignment snapping).
28
- */
29
- function buildRoadGeometry(shapeMap, lane, pointOverrides) {
30
- const leftId = lane.props.leftBoundaryId;
31
- const rightId = lane.props.rightBoundaryId;
32
- if (!leftId || !rightId)
33
- return null;
34
- const left = shapeMap.get(leftId);
35
- const right = shapeMap.get(rightId);
36
- if (!left || !right)
37
- return null;
38
- const leftPts = collectPoints(shapeMap, left.props.pointIds, lane.props.invertLeft, pointOverrides);
39
- const rightPts = collectPoints(shapeMap, right.props.pointIds, lane.props.invertRight, pointOverrides);
40
- if (leftPts.length < 2 || rightPts.length < 2)
41
- return null;
42
- const samples = computeCenterlineWithWidth(leftPts, rightPts);
43
- if (samples.length < 2)
44
- return null;
45
- const odrSamples = samples.map((s) => ({
46
- x: pxToEnuX(s.x),
47
- y: pxToEnuY(s.y),
48
- width: pxToMeter(s.width),
49
- s: pxToMeter(s.s),
50
- }));
51
- const length = odrSamples[odrSamples.length - 1].s;
52
- return { laneId: lane.id, samples, odrSamples, length };
53
- }
54
37
  function collectPoints(shapeMap, pointIds, invert, pointOverrides) {
55
38
  const ids = invert ? [...pointIds].reverse() : pointIds;
56
39
  const pts = [];
@@ -66,6 +49,263 @@ function collectPoints(shapeMap, pointIds, invert, pointOverrides) {
66
49
  }
67
50
  return pts;
68
51
  }
52
+ /** Boundary polyline of a linestring in travel order, or null when unusable. */
53
+ function boundaryPointsOf(shapeMap, boundaryId, invert, pointOverrides) {
54
+ if (!boundaryId)
55
+ return null;
56
+ const ls = shapeMap.get(boundaryId);
57
+ if (!ls)
58
+ return null;
59
+ const pts = collectPoints(shapeMap, ls.props.pointIds, invert, pointOverrides);
60
+ return pts.length >= 2 ? pts : null;
61
+ }
62
+ /**
63
+ * Group lanes into road bundles by lateral adjacency.
64
+ *
65
+ * Lane B is the direct right neighbour of lane A when A's right boundary IS
66
+ * B's left boundary — the same linestring traversed in the same direction
67
+ * (`A.invertRight === B.invertLeft`); a direction mismatch means the
68
+ * neighbour travels the other way (e.g. the two sides of a two-way road) and
69
+ * belongs in its own bundle. The relation must be unique on both sides so
70
+ * pathological data (several lanes claiming one boundary side) degrades to
71
+ * separate bundles instead of guessing.
72
+ *
73
+ * Because adjacency requires sharing the whole linestring, every lane of a
74
+ * bundle spans the same longitudinal extent by construction.
75
+ */
76
+ function detectBundles(lanes) {
77
+ const byLeft = new Map();
78
+ const byRight = new Map();
79
+ for (const lane of lanes) {
80
+ const l = lane.props.leftBoundaryId;
81
+ const r = lane.props.rightBoundaryId;
82
+ if (l)
83
+ byLeft.set(l, [...(byLeft.get(l) ?? []), lane]);
84
+ if (r)
85
+ byRight.set(r, [...(byRight.get(r) ?? []), lane]);
86
+ }
87
+ const rightNeighbor = new Map();
88
+ const hasLeftNeighbor = new Set();
89
+ for (const lane of lanes) {
90
+ const rb = lane.props.rightBoundaryId;
91
+ if (!rb)
92
+ continue;
93
+ const candidates = (byLeft.get(rb) ?? []).filter(b => b.id !== lane.id && b.props.invertLeft === lane.props.invertRight);
94
+ if (candidates.length !== 1)
95
+ continue;
96
+ const b = candidates[0];
97
+ const owners = (byRight.get(rb) ?? []).filter(a => a.props.invertRight === b.props.invertLeft);
98
+ if (owners.length !== 1 || owners[0].id !== lane.id)
99
+ continue;
100
+ rightNeighbor.set(lane.id, b);
101
+ hasLeftNeighbor.add(b.id);
102
+ }
103
+ const bundles = [];
104
+ const visited = new Set();
105
+ const walk = (start) => {
106
+ const bundle = [];
107
+ let cur = start;
108
+ while (cur && !visited.has(cur.id)) {
109
+ visited.add(cur.id);
110
+ bundle.push(cur);
111
+ cur = rightNeighbor.get(cur.id);
112
+ }
113
+ if (bundle.length > 0)
114
+ bundles.push(bundle);
115
+ };
116
+ // Start from the leftmost lane of each chain ...
117
+ for (const lane of lanes) {
118
+ if (!visited.has(lane.id) && !hasLeftNeighbor.has(lane.id))
119
+ walk(lane);
120
+ }
121
+ // ... and break adjacency cycles (degenerate ring data) deterministically.
122
+ for (const lane of lanes) {
123
+ if (!visited.has(lane.id))
124
+ walk(lane);
125
+ }
126
+ return bundles;
127
+ }
128
+ /** Distance from `p` to the polyline `pts` (projection onto each segment). */
129
+ function distancePointToPolyline(p, pts) {
130
+ let best = Infinity;
131
+ for (let i = 0; i < pts.length - 1; i++) {
132
+ const a = pts[i];
133
+ const b = pts[i + 1];
134
+ const dx = b.x - a.x;
135
+ const dy = b.y - a.y;
136
+ const len2 = dx * dx + dy * dy;
137
+ let t = len2 > 1e-18 ? ((p.x - a.x) * dx + (p.y - a.y) * dy) / len2 : 0;
138
+ if (t < 0)
139
+ t = 0;
140
+ if (t > 1)
141
+ t = 1;
142
+ const d = Math.hypot(p.x - (a.x + t * dx), p.y - (a.y + t * dy));
143
+ if (d < best)
144
+ best = d;
145
+ }
146
+ return best;
147
+ }
148
+ /**
149
+ * Signed lateral offset (m, positive toward -t / the right of the travel
150
+ * direction) from each fitted reference pose to a boundary polyline, measured
151
+ * along the pose normal — the exact inverse of the importer's
152
+ * offset-along-normal boundary reconstruction. Among multiple normal/boundary
153
+ * intersections the one closest to the previous station's offset wins
154
+ * (continuity); stations whose normal misses the boundary entirely (e.g. the
155
+ * very ends, where boundary extents differ slightly) fall back to the
156
+ * closest-point distance.
157
+ */
158
+ /**
159
+ * Largest believable offset change between neighbouring width stations (m).
160
+ * An intersection jumping further than this is the normal ray hitting a far
161
+ * branch of the boundary (e.g. the opposite end of a ~180° ramp), not the
162
+ * adjacent lane edge, and is discarded for the closest-point fallback.
163
+ */
164
+ const OFFSET_JUMP_TOL_M = 5;
165
+ function normalOffsets(poses, bnd) {
166
+ const out = [];
167
+ let prev = null;
168
+ for (const pose of poses) {
169
+ // Right normal of heading h in ENU: (sin h, -cos h).
170
+ const nx = Math.sin(pose.hdg);
171
+ const ny = -Math.cos(pose.hdg);
172
+ const fallback = distancePointToPolyline({ x: pose.x, y: pose.y }, bnd);
173
+ const refVal = prev ?? fallback;
174
+ let best = null;
175
+ for (let i = 0; i < bnd.length - 1; i++) {
176
+ const dx = bnd[i + 1].x - bnd[i].x;
177
+ const dy = bnd[i + 1].y - bnd[i].y;
178
+ // Solve pose + t·n = bnd[i] + w·(bnd[i+1]-bnd[i]) for (t, w).
179
+ const det = dx * ny - dy * nx;
180
+ if (Math.abs(det) < 1e-12)
181
+ continue;
182
+ const rx = bnd[i].x - pose.x;
183
+ const ry = bnd[i].y - pose.y;
184
+ const w = (nx * ry - ny * rx) / det;
185
+ if (w < -1e-9 || w > 1 + 1e-9)
186
+ continue;
187
+ const t = (dx * ry - dy * rx) / det;
188
+ if (best === null || Math.abs(t - refVal) < Math.abs(best - refVal))
189
+ best = t;
190
+ }
191
+ if (best === null || Math.abs(best - refVal) > OFFSET_JUMP_TOL_M)
192
+ best = fallback;
193
+ prev = best;
194
+ out.push(best);
195
+ }
196
+ return out;
197
+ }
198
+ /**
199
+ * Build the bundle geometry: the fitted reference line (leftmost boundary)
200
+ * plus per-lane width samples.
201
+ *
202
+ * The reference polyline keeps the boundary's own vertices and is refined
203
+ * with uniform arc-length stations so the width grid is at least as dense as
204
+ * the densest boundary of the bundle. The polyline is then fitted into
205
+ * analytic plan-view primitives (line / arc / paramPoly3), and the width of
206
+ * lane i at station j is the gap between its inner and outer boundary
207
+ * measured along the fitted reference normal at that station, so the
208
+ * importer's offset-along-normal reconstruction reproduces the original
209
+ * boundaries with no longitudinal skew.
210
+ */
211
+ function buildBundleGeometry(shapeMap, bundleLanes, pointOverrides) {
212
+ const first = bundleLanes[0];
213
+ const boundaries = [];
214
+ const left = boundaryPointsOf(shapeMap, first.props.leftBoundaryId, first.props.invertLeft, pointOverrides);
215
+ if (!left)
216
+ return null;
217
+ boundaries.push(left);
218
+ for (const lane of bundleLanes) {
219
+ const right = boundaryPointsOf(shapeMap, lane.props.rightBoundaryId, lane.props.invertRight, pointOverrides);
220
+ if (!right)
221
+ return null;
222
+ boundaries.push(right);
223
+ }
224
+ // Boundaries in OpenDRIVE meters (ENU).
225
+ const bndOdr = boundaries.map(b => b.map(p => ({ x: pxToEnuX(p.x), y: pxToEnuY(p.y) })));
226
+ // The plan view is fitted to the reference boundary's own vertices only:
227
+ // they are true samples of the drawn curve, so the fitter's tangent
228
+ // estimates are sound there. Densifying the polyline before the fit would
229
+ // insert points along its chords, whose collinear runs masquerade as
230
+ // straight stretches and corrupt the tangent estimates (worst at the road
231
+ // ends, where the start/end heading defines the contact cross-section
232
+ // shared with the neighbouring roads).
233
+ const ref = bndOdr[0];
234
+ const fit = fitPlanView(ref);
235
+ if (fit.geometries.length === 0 || !(fit.length > 0))
236
+ return null;
237
+ // Width stations: the reference vertices (corners must survive into the
238
+ // width records) merged with a uniform grid as dense as the densest
239
+ // boundary (inner-boundary detail must survive too). Grid stations are
240
+ // placed by chord-length interpolation between the fitted stations of the
241
+ // surrounding reference vertices and posed on the fitted curve.
242
+ let n = 2;
243
+ for (const b of bndOdr)
244
+ n = Math.max(n, b.length);
245
+ const cum = [0];
246
+ for (let i = 1; i < ref.length; i++) {
247
+ cum.push(cum[i - 1] + Math.hypot(ref[i].x - ref[i - 1].x, ref[i].y - ref[i - 1].y));
248
+ }
249
+ const total = cum[cum.length - 1];
250
+ if (!(total > 0))
251
+ return null;
252
+ const params = cum.map(c => c / total);
253
+ for (let j = 0; j < n; j++)
254
+ params.push(j / (n - 1));
255
+ params.sort((a, b) => a - b);
256
+ const stationOf = (t) => {
257
+ const target = t * total;
258
+ let i = 1;
259
+ while (i < cum.length - 1 && cum[i] < target)
260
+ i++;
261
+ const c0 = cum[i - 1];
262
+ const c1 = cum[i];
263
+ const s0 = fit.samplePoses[i - 1].s;
264
+ const s1 = fit.samplePoses[i].s;
265
+ const f = c1 > c0 ? (target - c0) / (c1 - c0) : 0;
266
+ return s0 + (s1 - s0) * f;
267
+ };
268
+ const poseAtStation = (s) => {
269
+ const clamped = Math.min(Math.max(s, 0), fit.length);
270
+ let g = fit.geometries[0];
271
+ for (const geom of fit.geometries) {
272
+ if (geom.s <= clamped + 1e-12)
273
+ g = geom;
274
+ else
275
+ break;
276
+ }
277
+ const p = evalGeometry(g, Math.min(Math.max(clamped - g.s, 0), g.length));
278
+ return { s: clamped, x: p.x, y: p.y, hdg: p.hdg };
279
+ };
280
+ const samplePoses = [];
281
+ for (const t of params) {
282
+ const pose = poseAtStation(stationOf(t));
283
+ const last = samplePoses[samplePoses.length - 1];
284
+ if (!last || pose.s > last.s + 1e-9 || samplePoses.length === 0)
285
+ samplePoses.push(pose);
286
+ }
287
+ if (samplePoses.length < 2)
288
+ return null;
289
+ const offsets = bndOdr.map(b => normalOffsets(samplePoses, b));
290
+ // Contact stations measure each boundary's own endpoint (projected onto
291
+ // the contact normal) instead of the ray/polyline crossing: the endpoints
292
+ // are the welded corners shared with the neighbouring road, so both sides
293
+ // of a contact derive their border positions from the same drawn points
294
+ // and meet without a lateral step. (The ray crossing drifts by up to a few
295
+ // centimeters when a boundary meets the contact at a skew.)
296
+ const projectEndpoint = (pose, p, fallback) => {
297
+ const t = (p.x - pose.x) * Math.sin(pose.hdg) - (p.y - pose.y) * Math.cos(pose.hdg);
298
+ return Math.abs(t - fallback) <= 0.5 ? t : fallback;
299
+ };
300
+ const lastIdx = samplePoses.length - 1;
301
+ for (let b = 0; b < bndOdr.length; b++) {
302
+ const bnd = bndOdr[b];
303
+ offsets[b][0] = projectEndpoint(samplePoses[0], bnd[0], offsets[b][0]);
304
+ offsets[b][lastIdx] = projectEndpoint(samplePoses[lastIdx], bnd[bnd.length - 1], offsets[b][lastIdx]);
305
+ }
306
+ const laneWidths = bundleLanes.map((_, i) => samplePoses.map((_, j) => Math.max(0, offsets[i + 1][j] - offsets[i][j])));
307
+ return { planView: fit.geometries, samplePoses, laneWidths, length: fit.length };
308
+ }
69
309
  /**
70
310
  * Snap together boundary endpoints of connected lanes by clustering nearby
71
311
  * points and using the centroid as the canonical position.
@@ -96,7 +336,14 @@ function buildBoundaryAlignmentOverrides(shapeMap, lanes, epsilonPx = 30) {
96
336
  const pt = shapeMap.get(pid);
97
337
  if (!pt)
98
338
  continue;
99
- endpoints.push({ pointId: pid, laneId: lane.id, side, x: pt.x, y: pt.y });
339
+ endpoints.push({
340
+ pointId: pid,
341
+ laneId: lane.id,
342
+ side,
343
+ boundary: sideKey === 'leftBoundaryId' ? 'left' : 'right',
344
+ x: pt.x,
345
+ y: pt.y,
346
+ });
100
347
  }
101
348
  };
102
349
  // Restrict to lanes that participate in a next/prev relationship.
@@ -108,35 +355,91 @@ function buildBoundaryAlignmentOverrides(shapeMap, lanes, epsilonPx = 30) {
108
355
  if (hasPrev)
109
356
  collectEndpoints(lane, 'start');
110
357
  }
111
- // Greedy clustering: group points within epsilon of each other, with two
358
+ // Forbidden point pairs: a lane's start-side and end-side endpoint Points
359
+ // must never land in the same cluster. A connecting lane shorter than
360
+ // epsilon would otherwise get its start and end merged into one cluster,
361
+ // collapsing its boundaries below the degenerate-road export guard and
362
+ // silently dropping the lane (and its next/prev chain). The constraint is
363
+ // tracked by point id — not by the owning lane entry — because boundary
364
+ // endpoints are often Point shapes shared with the neighbouring lanes, and
365
+ // a neighbour's entry could otherwise pull both of a short lane's end
366
+ // points into one cluster.
367
+ const sidePids = new Map();
368
+ for (const ep of endpoints) {
369
+ const entry = sidePids.get(ep.laneId) ?? { start: new Set(), end: new Set() };
370
+ entry[ep.side].add(ep.pointId);
371
+ sidePids.set(ep.laneId, entry);
372
+ }
373
+ const forbidden = new Map();
374
+ const forbid = (a, b) => {
375
+ forbidden.set(a, (forbidden.get(a) ?? new Set()).add(b));
376
+ forbidden.set(b, (forbidden.get(b) ?? new Set()).add(a));
377
+ };
378
+ for (const { start, end } of sidePids.values()) {
379
+ for (const s of start) {
380
+ for (const e of end) {
381
+ if (s !== e)
382
+ forbid(s, e);
383
+ }
384
+ }
385
+ }
386
+ // A lane's left-boundary endpoint must never merge with its right-boundary
387
+ // endpoint on the same side: lanes narrower than epsilon would be pinched
388
+ // to zero width at the contact (and the welded neighbours dragged along).
389
+ // Tracked by point id like above, so a genuine zero-width taper — where
390
+ // left and right already share one Point — is unaffected.
391
+ const boundaryPids = new Map();
392
+ for (const ep of endpoints) {
393
+ const key = `${ep.laneId}|${ep.side}`;
394
+ const entry = boundaryPids.get(key) ?? { left: new Set(), right: new Set() };
395
+ entry[ep.boundary].add(ep.pointId);
396
+ boundaryPids.set(key, entry);
397
+ }
398
+ for (const { left, right } of boundaryPids.values()) {
399
+ for (const l of left) {
400
+ for (const r of right) {
401
+ if (l !== r)
402
+ forbid(l, r);
403
+ }
404
+ }
405
+ }
406
+ // Greedy clustering: group points within epsilon of each other, with three
112
407
  // refinements over plain first-fit grouping:
113
- // 1. An endpoint never joins a cluster that already holds the opposite end
114
- // of the same lane. A connecting lane shorter than epsilon would
115
- // otherwise get its start and end merged into one cluster, collapsing
116
- // its centerline below the degenerate-road export guard and silently
117
- // dropping the lane (and its next/prev chain).
408
+ // 1. An endpoint never joins a cluster holding a point its point id is
409
+ // forbidden against (see above).
118
410
  // 2. Among the eligible clusters the nearest one wins, so the far end of a
119
411
  // short lane clusters with its true counterpart rather than with the
120
412
  // first cluster found within epsilon.
413
+ // 3. An endpoint whose nearest in-range cluster is a forbidden one never
414
+ // hops to a farther eligible cluster: the forbidden cluster marks a
415
+ // neighbouring corner of the same contact (narrow lane / short lane),
416
+ // so anything beyond it belongs to a different corner entirely and
417
+ // merging would drag the contact sideways. It opens its own cluster.
121
418
  const clusters = [];
122
419
  const eps2 = epsilonPx * epsilonPx;
123
420
  for (const ep of endpoints) {
124
421
  let best = null;
125
422
  let bestD2 = Infinity;
423
+ let blockedD2 = Infinity;
424
+ const epForbidden = forbidden.get(ep.pointId);
126
425
  for (const cluster of clusters) {
127
426
  const c0 = cluster[0];
128
427
  const dx = ep.x - c0.x;
129
428
  const dy = ep.y - c0.y;
130
429
  const d2 = dx * dx + dy * dy;
131
- if (d2 > eps2 || d2 >= bestD2)
430
+ if (d2 > eps2)
132
431
  continue;
133
- const conflictsOwnLane = cluster.some((m) => m.laneId === ep.laneId && m.side !== ep.side);
134
- if (conflictsOwnLane)
432
+ if (epForbidden && cluster.some((m) => epForbidden.has(m.pointId))) {
433
+ if (d2 < blockedD2)
434
+ blockedD2 = d2;
435
+ continue;
436
+ }
437
+ if (d2 >= bestD2)
135
438
  continue;
136
439
  best = cluster;
137
440
  bestD2 = d2;
138
441
  }
139
- if (best)
442
+ if (best && bestD2 <= blockedD2)
140
443
  best.push(ep);
141
444
  else
142
445
  clusters.push([ep]);
@@ -164,17 +467,20 @@ function buildBoundaryAlignmentOverrides(shapeMap, lanes, epsilonPx = 30) {
164
467
  function emitPlanView(geom) {
165
468
  const lines = [];
166
469
  lines.push(` <planView>`);
167
- for (let i = 0; i < geom.odrSamples.length - 1; i++) {
168
- const a = geom.odrSamples[i];
169
- const b = geom.odrSamples[i + 1];
170
- const dx = b.x - a.x;
171
- const dy = b.y - a.y;
172
- const segLen = Math.hypot(dx, dy);
173
- if (segLen < 1e-9)
470
+ for (const g of geom.planView) {
471
+ if (g.length < 1e-9)
174
472
  continue;
175
- const hdg = Math.atan2(dy, dx);
176
- lines.push(` <geometry s="${fmt(a.s)}" x="${fmt(a.x)}" y="${fmt(a.y)}" hdg="${fmt(hdg)}" length="${fmt(segLen)}">`);
177
- lines.push(` <line/>`);
473
+ lines.push(` <geometry s="${fmt(g.s)}" x="${fmt(g.x)}" y="${fmt(g.y)}" hdg="${fmt(g.hdg)}" length="${fmt(g.length)}">`);
474
+ if (g.kind === 'arc') {
475
+ lines.push(` <arc curvature="${fmtPrecise(g.curvature)}"/>`);
476
+ }
477
+ else if (g.kind === 'paramPoly3') {
478
+ lines.push(` <paramPoly3 aU="${fmtPrecise(g.aU)}" bU="${fmtPrecise(g.bU)}" cU="${fmtPrecise(g.cU)}" dU="${fmtPrecise(g.dU)}" ` +
479
+ `aV="${fmtPrecise(g.aV)}" bV="${fmtPrecise(g.bV)}" cV="${fmtPrecise(g.cV)}" dV="${fmtPrecise(g.dV)}" pRange="arcLength"/>`);
480
+ }
481
+ else {
482
+ lines.push(` <line/>`);
483
+ }
178
484
  lines.push(` </geometry>`);
179
485
  }
180
486
  lines.push(` </planView>`);
@@ -211,93 +517,240 @@ function roadMarkTypeFor(shapeMap, boundaryId) {
211
517
  const ls = shapeMap.get(boundaryId);
212
518
  return ls?.props?.attributes?.subtype === 'dashed' ? 'broken' : 'solid';
213
519
  }
214
- function emitLanes(geom, hasPrev, hasNext, laneType, centerMark, outerMark) {
520
+ function emitLanes(bundle, plan, shapeMap) {
521
+ const geom = bundle.geom;
215
522
  const lines = [];
216
523
  lines.push(` <lanes>`);
217
- // The plan view follows the lane centerline; shifting the lane reference by
218
- // +width/2 puts center lane 0 on the lane's left boundary, so a single
219
- // right lane (-1) spans the full lane width. One drawtonomy lane therefore
220
- // maps to exactly one OpenDRIVE driving lane (not two half-width lanes).
221
- emitLaneOffsetEntries(geom, lines);
524
+ // The plan view follows the bundle's leftmost boundary, so lane 0 (center)
525
+ // lies on the left edge of lane -1 and no laneOffset is required. Lanes are
526
+ // emitted -1, -2, ... from the reference line outward (left→right in travel
527
+ // direction), each spanning its full drawn width.
222
528
  lines.push(` <laneSection s="0">`);
223
- // Center reference line (= left boundary after the laneOffset shift).
224
529
  lines.push(` <center>`);
225
530
  lines.push(` <lane id="0" type="none" level="false">`);
226
531
  lines.push(` <link/>`);
532
+ const centerMark = roadMarkTypeFor(shapeMap, bundle.lanes[0].props.leftBoundaryId);
227
533
  lines.push(` <roadMark sOffset="0" type="${centerMark}" weight="standard" color="white" width="0.13"/>`);
228
534
  lines.push(` </lane>`);
229
535
  lines.push(` </center>`);
230
536
  lines.push(` <right>`);
231
- lines.push(` <lane id="-1" type="${laneType}" level="false">`);
232
- emitLaneLink(lines, hasPrev, hasNext, -1);
233
- emitWidthEntries(geom, lines);
234
- lines.push(` <roadMark sOffset="0" type="${outerMark}" weight="standard" color="white" width="0.13"/>`);
235
- lines.push(` </lane>`);
537
+ bundle.lanes.forEach((lane, i) => {
538
+ const odrId = -(i + 1);
539
+ lines.push(` <lane id="${odrId}" type="${odrLaneTypeFor(lane)}" level="false">`);
540
+ emitLaneLink(lines, plan.lanePredecessor.get(lane.id), plan.laneSuccessor.get(lane.id));
541
+ emitWidthEntries(geom, i, lines);
542
+ const outerMark = roadMarkTypeFor(shapeMap, lane.props.rightBoundaryId);
543
+ lines.push(` <roadMark sOffset="0" type="${outerMark}" weight="standard" color="white" width="0.13"/>`);
544
+ lines.push(` </lane>`);
545
+ });
236
546
  lines.push(` </right>`);
237
547
  lines.push(` </laneSection>`);
238
548
  lines.push(` </lanes>`);
239
549
  return lines.join('\n');
240
550
  }
241
- function emitLaneLink(out, hasPrev, hasNext, laneId) {
242
- if (!hasPrev && !hasNext) {
551
+ function emitLaneLink(out, predId, succId) {
552
+ if (predId === undefined && succId === undefined) {
243
553
  out.push(` <link/>`);
244
554
  return;
245
555
  }
246
556
  out.push(` <link>`);
247
- if (hasPrev) {
248
- out.push(` <predecessor id="${laneId}"/>`);
557
+ if (predId !== undefined) {
558
+ out.push(` <predecessor id="${predId}"/>`);
249
559
  }
250
- if (hasNext) {
251
- out.push(` <successor id="${laneId}"/>`);
560
+ if (succId !== undefined) {
561
+ out.push(` <successor id="${succId}"/>`);
252
562
  }
253
563
  out.push(` </link>`);
254
564
  }
255
- /** Piecewise-linear laneOffset records: +width/2 toward the left boundary. */
256
- function emitLaneOffsetEntries(geom, out) {
257
- for (let i = 0; i < geom.odrSamples.length - 1; i++) {
258
- const s = geom.odrSamples[i].s;
259
- const halfA = geom.odrSamples[i].width / 2;
260
- const halfB = geom.odrSamples[i + 1].width / 2;
261
- const segLen = geom.odrSamples[i + 1].s - s;
262
- const b = segLen > 1e-9 ? (halfB - halfA) / segLen : 0;
263
- out.push(` <laneOffset s="${fmt(s)}" a="${fmt(halfA)}" b="${fmt(b)}" c="0" d="0"/>`);
565
+ /**
566
+ * Maximum width error (m) tolerated when folding stations into one record.
567
+ * Width samples carry chordal noise of the same order (the boundary polyline
568
+ * is a chordal approximation of the original curve), so a 1 cm band mostly
569
+ * absorbs that noise while staying far below the 5 cm position tolerance.
570
+ */
571
+ const WIDTH_SIMPLIFY_TOL_M = 0.01;
572
+ /**
573
+ * Piecewise-linear full lane width records: a + b*ds (c=d=0). Station runs
574
+ * are simplified greedily: a record absorbs every following station whose
575
+ * widths stay within WIDTH_SIMPLIFY_TOL_M of the straight ramp between the
576
+ * record start and the run end, so constant-width lanes collapse to a single
577
+ * record and smoothly varying lanes to a few.
578
+ */
579
+ function emitWidthEntries(geom, laneIndex, out) {
580
+ const allWidths = geom.laneWidths[laneIndex];
581
+ const poses = geom.samplePoses;
582
+ // Strictly increasing stations (duplicates share one width sample).
583
+ const sArr = [];
584
+ const wArr = [];
585
+ for (let i = 0; i < poses.length; i++) {
586
+ if (sArr.length === 0 || poses[i].s > sArr[sArr.length - 1] + 1e-9) {
587
+ sArr.push(poses[i].s);
588
+ wArr.push(allWidths[i]);
589
+ }
264
590
  }
265
- }
266
- /** Piecewise-linear full lane width records: a + b*ds (c=d=0). */
267
- function emitWidthEntries(geom, out) {
268
- for (let i = 0; i < geom.odrSamples.length - 1; i++) {
269
- const s = geom.odrSamples[i].s;
270
- const wA = geom.odrSamples[i].width;
271
- const wB = geom.odrSamples[i + 1].width;
272
- const segLen = geom.odrSamples[i + 1].s - s;
273
- const b = segLen > 1e-9 ? (wB - wA) / segLen : 0;
274
- out.push(` <width sOffset="${fmt(s)}" a="${fmt(wA)}" b="${fmt(b)}" c="0" d="0"/>`);
591
+ const recs = [];
592
+ if (sArr.length < 2) {
593
+ recs.push({ s: 0, a: wArr[0] ?? 0, b: 0 });
594
+ }
595
+ let i0 = 0;
596
+ while (i0 < sArr.length - 1) {
597
+ let end = i0 + 1;
598
+ for (let j = i0 + 2; j < sArr.length; j++) {
599
+ const slope = (wArr[j] - wArr[i0]) / (sArr[j] - sArr[i0]);
600
+ let ok = true;
601
+ for (let k = i0 + 1; k < j; k++) {
602
+ if (Math.abs(wArr[i0] + slope * (sArr[k] - sArr[i0]) - wArr[k]) > WIDTH_SIMPLIFY_TOL_M) {
603
+ ok = false;
604
+ break;
605
+ }
606
+ }
607
+ if (!ok)
608
+ break;
609
+ end = j;
610
+ }
611
+ recs.push({ s: sArr[i0], a: wArr[i0], b: (wArr[end] - wArr[i0]) / (sArr[end] - sArr[i0]) });
612
+ i0 = end;
613
+ }
614
+ for (const r of recs) {
615
+ out.push(` <width sOffset="${fmt(r.s)}" a="${fmt(r.a)}" b="${fmtPrecise(r.b)}" c="0" d="0"/>`);
275
616
  }
276
617
  }
618
+ /** Length (m) of a synthesized junction connecting road. Kept below the
619
+ * importer's micro-section threshold so re-imports skip it and bridge the
620
+ * lane links across instead of materializing an extra sliver lane. Also kept
621
+ * below the 1 cm contact-point gap tolerance of ASAM quality checkers: the
622
+ * incoming lane end and the outgoing lane start coincide in the drawing, so
623
+ * the stub necessarily overlaps the outgoing road and its whole length shows
624
+ * up as a contact-point discontinuity to gap checks. */
625
+ const CONNECTING_ROAD_LENGTH_M = 0.005;
277
626
  /**
278
- * Plan synthesized <junction> elements for branch / merge connectivity.
627
+ * Contact widths below this (m) count as zero for lane linking: OpenDRIVE
628
+ * forbids predecessor/successor records on lanes that have zero width at the
629
+ * linked contact (zero-width / appearing-lane semantics). Welded taper lanes
630
+ * produce exact zeros; the epsilon also covers values that round to zero in
631
+ * the 6-decimal output.
632
+ */
633
+ const ZERO_WIDTH_LINK_EPS_M = 1e-3;
634
+ /** Wrap an angle to (-pi, pi]. */
635
+ function wrapAngleRad(a) {
636
+ while (a > Math.PI)
637
+ a -= 2 * Math.PI;
638
+ while (a < -Math.PI)
639
+ a += 2 * Math.PI;
640
+ return a;
641
+ }
642
+ /**
643
+ * Plan road links, lane links and synthesized <junction> elements.
279
644
  *
280
- * A road <link> can name only one predecessor and one successor, so an edge
281
- * is representable as plain road links only when it is its source's only
282
- * `next` AND its target's only `prev`. Every other edge (branching or
283
- * merging) is routed through a synthesized junction: the target roads become
284
- * connecting roads (their <road junction> attribute is set) and each edge is
285
- * emitted as a <connection incomingRoad connectingRoad contactPoint="start">
286
- * carrying a <laneLink from="-1" to="-1"/> (the exporter emits exactly one
287
- * driving lane -1 per road). Edges that share a lane collapse into the same
645
+ * A road <link> can name only one predecessor and one successor, so a road
646
+ * pair (P → Q) is representable as a plain road link only when Q is P's only
647
+ * successor road AND P is Q's only predecessor road AND every lane edge
648
+ * between them is 1:1 (its source's only `next` and its target's only
649
+ * `prev`). Every other lane edge is routed through a synthesized junction
650
+ * with the standard structure: a short connecting road (junction-stamped,
651
+ * with a guaranteed road-level predecessor=incoming / successor=outgoing
652
+ * link) is synthesized at the contact point for each lane edge, and the
653
+ * junction's <connection incomingRoad connectingRoad contactPoint="start">
654
+ * carries the per-lane <laneLink>. The mainline roads stay junction="-1" and
655
+ * link to the junction by id. Edges that share a road collapse into the same
288
656
  * junction (connected components), so a 2-in x 2-out diamond becomes one
289
657
  * junction with four connections.
658
+ *
659
+ * Right-of-way lane pairs (`yieldLaneIds`) whose two lanes both feed
660
+ * connecting roads of the same junction are emitted as standard
661
+ * <priority high low> records between those connecting roads.
290
662
  */
291
- function planJunctions(lanes, laneIdToRoadId, firstJunctionId) {
663
+ function planConnectivity(exportBundles, roadIdOf, odrIdOf, firstJunctionId, connectingSourceFor, connectingTargetFor, contactWidth, externalLanes = new Map()) {
292
664
  const validNext = new Map();
293
665
  const validPrev = new Map();
294
- for (const lane of lanes) {
295
- if (!laneIdToRoadId.has(lane.id))
666
+ for (const bundle of exportBundles) {
667
+ for (const lane of bundle.lanes) {
668
+ validNext.set(lane.id, (lane.props.next ?? []).filter(id => roadIdOf.has(id)));
669
+ validPrev.set(lane.id, (lane.props.prev ?? []).filter(id => roadIdOf.has(id)));
670
+ }
671
+ }
672
+ // Carry-through: lanes of verbatim (unedited) roads participate as link
673
+ // endpoints — their roads are never re-emitted here, but regenerated roads
674
+ // must still link to / from them. Edges between two external lanes are
675
+ // covered by the verbatim XML and are skipped below.
676
+ for (const [id, lane] of externalLanes) {
677
+ validNext.set(id, (lane.props.next ?? []).filter(t => roadIdOf.has(t)));
678
+ validPrev.set(id, (lane.props.prev ?? []).filter(t => roadIdOf.has(t)));
679
+ }
680
+ // Lanes with (near) zero width at a linked contact must not carry standard
681
+ // link records there (zero-width / appearing-lane rules), so those edges
682
+ // are diverted into the hiddenLaneLinks userData stash. The stash lives on
683
+ // the `from` road when it is re-emitted in this export, else on the `to`
684
+ // road (one of the two always is: external-external edges stay verbatim).
685
+ const hiddenLaneEdges = [];
686
+ for (const [laneId, nexts] of validNext) {
687
+ if (nexts.length === 0)
296
688
  continue;
297
- validNext.set(lane.id, (lane.props.next ?? []).filter((id) => laneIdToRoadId.has(id)));
298
- validPrev.set(lane.id, (lane.props.prev ?? []).filter((id) => laneIdToRoadId.has(id)));
689
+ const kept = [];
690
+ for (const to of nexts) {
691
+ if (externalLanes.has(laneId) && externalLanes.has(to)) {
692
+ kept.push(to);
693
+ continue;
694
+ }
695
+ const wFrom = contactWidth(laneId, 'end');
696
+ const wTo = contactWidth(to, 'start');
697
+ if ((wFrom !== null && wFrom < ZERO_WIDTH_LINK_EPS_M) ||
698
+ (wTo !== null && wTo < ZERO_WIDTH_LINK_EPS_M)) {
699
+ hiddenLaneEdges.push({ from: laneId, to, home: externalLanes.has(laneId) ? to : laneId });
700
+ const prevs = validPrev.get(to);
701
+ if (prevs)
702
+ validPrev.set(to, prevs.filter(p => p !== laneId));
703
+ continue;
704
+ }
705
+ kept.push(to);
706
+ }
707
+ if (kept.length !== nexts.length)
708
+ validNext.set(laneId, kept);
299
709
  }
300
- // Union-find over the lanes participating in junction-routed edges.
710
+ const succRoads = new Map();
711
+ const predRoads = new Map();
712
+ const edgesByPair = new Map();
713
+ for (const [laneId, nexts] of validNext) {
714
+ const fromRoad = roadIdOf.get(laneId);
715
+ for (const to of nexts) {
716
+ if (externalLanes.has(laneId) && externalLanes.has(to))
717
+ continue;
718
+ const toRoad = roadIdOf.get(to);
719
+ succRoads.set(fromRoad, (succRoads.get(fromRoad) ?? new Set()).add(toRoad));
720
+ predRoads.set(toRoad, (predRoads.get(toRoad) ?? new Set()).add(fromRoad));
721
+ const key = `${fromRoad}->${toRoad}`;
722
+ edgesByPair.set(key, [...(edgesByPair.get(key) ?? []), { from: laneId, to }]);
723
+ }
724
+ }
725
+ const plan = {
726
+ roadPredecessor: new Map(),
727
+ roadSuccessor: new Map(),
728
+ lanePredecessor: new Map(),
729
+ laneSuccessor: new Map(),
730
+ junctions: [],
731
+ connectingRoads: [],
732
+ handledYieldPairs: new Set(),
733
+ hiddenLaneEdges,
734
+ };
735
+ const junctionPairs = [];
736
+ for (const [key, laneEdges] of edgesByPair) {
737
+ const [fromRoad, toRoad] = key.split('->').map(Number);
738
+ const uniquePair = succRoads.get(fromRoad).size === 1 && predRoads.get(toRoad).size === 1;
739
+ const lanesOneToOne = laneEdges.every(e => (validNext.get(e.from) ?? []).length === 1 && (validPrev.get(e.to) ?? []).length === 1);
740
+ if (uniquePair && lanesOneToOne) {
741
+ plan.roadSuccessor.set(fromRoad, { kind: 'road', id: toRoad });
742
+ plan.roadPredecessor.set(toRoad, { kind: 'road', id: fromRoad });
743
+ for (const e of laneEdges) {
744
+ plan.laneSuccessor.set(e.from, odrIdOf.get(e.to));
745
+ plan.lanePredecessor.set(e.to, odrIdOf.get(e.from));
746
+ }
747
+ }
748
+ else {
749
+ junctionPairs.push({ incoming: fromRoad, outgoing: toRoad, laneEdges });
750
+ }
751
+ }
752
+ // Union-find over road ids: junction-routed pairs sharing a road merge into
753
+ // one junction.
301
754
  const parent = new Map();
302
755
  const find = (x) => {
303
756
  let root = x;
@@ -325,66 +778,234 @@ function planJunctions(lanes, laneIdToRoadId, firstJunctionId) {
325
778
  if (ra !== rb)
326
779
  parent.set(rb, ra);
327
780
  };
328
- const junctionEdges = [];
329
- for (const [laneId, nexts] of validNext) {
330
- for (const to of nexts) {
331
- const prevsOfTarget = validPrev.get(to) ?? [];
332
- if (nexts.length === 1 && prevsOfTarget.length === 1)
333
- continue; // plain road link
334
- junctionEdges.push({ from: laneId, to });
335
- union(laneId, to);
336
- }
337
- }
338
- const plan = {
339
- nextJunction: new Map(),
340
- prevJunction: new Map(),
341
- roadJunction: new Map(),
342
- junctions: [],
343
- };
781
+ for (const pair of junctionPairs)
782
+ union(pair.incoming, pair.outgoing);
783
+ // Pass 1: one junction per connected component (ids first, so junction ids
784
+ // and connecting road ids stay sequential and collision-free).
344
785
  const junctionByRoot = new Map();
345
786
  let nextId = firstJunctionId;
346
- for (const e of junctionEdges) {
347
- const root = find(e.from);
348
- let junction = junctionByRoot.get(root);
349
- if (!junction) {
350
- junction = { id: nextId++, connections: [] };
787
+ for (const pair of junctionPairs) {
788
+ const root = find(pair.incoming);
789
+ if (!junctionByRoot.has(root)) {
790
+ const junction = { id: nextId++, connections: [], priorities: [] };
351
791
  junctionByRoot.set(root, junction);
352
792
  plan.junctions.push(junction);
353
793
  }
354
- junction.connections.push({
355
- incoming: laneIdToRoadId.get(e.from),
356
- connecting: laneIdToRoadId.get(e.to),
357
- });
358
- plan.nextJunction.set(e.from, junction.id);
359
- plan.prevJunction.set(e.to, junction.id);
360
- plan.roadJunction.set(e.to, junction.id);
794
+ }
795
+ // Pass 2: synthesize one short connecting road per junction-routed lane
796
+ // edge and register it as a <connection> of its junction.
797
+ const connectingByLane = new Map();
798
+ for (const pair of junctionPairs) {
799
+ const junction = junctionByRoot.get(find(pair.incoming));
800
+ for (const e of pair.laneEdges.slice().sort((a, b) => odrIdOf.get(b.from) - odrIdOf.get(a.from) || odrIdOf.get(b.to) - odrIdOf.get(a.to))) {
801
+ const source = connectingSourceFor(e.from);
802
+ if (!source)
803
+ continue;
804
+ const spec = {
805
+ roadId: nextId++,
806
+ junctionId: junction.id,
807
+ incomingRoadId: pair.incoming,
808
+ outgoingRoadId: pair.outgoing,
809
+ fromOdrLaneId: odrIdOf.get(e.from),
810
+ toOdrLaneId: odrIdOf.get(e.to),
811
+ source,
812
+ target: connectingTargetFor(e.to),
813
+ };
814
+ plan.connectingRoads.push(spec);
815
+ junction.connections.push({
816
+ incoming: pair.incoming,
817
+ connecting: spec.roadId,
818
+ laneLinks: [{ from: spec.fromOdrLaneId, to: -1 }],
819
+ });
820
+ const list = connectingByLane.get(e.from) ?? [];
821
+ list.push(spec);
822
+ connectingByLane.set(e.from, list);
823
+ }
824
+ plan.roadSuccessor.set(pair.incoming, { kind: 'junction', id: junction.id });
825
+ plan.roadPredecessor.set(pair.outgoing, { kind: 'junction', id: junction.id });
826
+ }
827
+ // Right-of-way: a lane pair (X has priority, Y yields) whose maneuvers both
828
+ // run through connecting roads of one junction becomes <priority high low>
829
+ // records between those connecting roads.
830
+ const junctionById = new Map(plan.junctions.map(j => [j.id, j]));
831
+ for (const bundle of exportBundles) {
832
+ for (const lane of bundle.lanes) {
833
+ const highSpecs = connectingByLane.get(lane.id);
834
+ if (!highSpecs?.length)
835
+ continue;
836
+ for (const yieldShapeId of lane.props.yieldLaneIds ?? []) {
837
+ const lowSpecs = connectingByLane.get(yieldShapeId);
838
+ if (!lowSpecs?.length)
839
+ continue;
840
+ let expressed = false;
841
+ for (const hi of highSpecs) {
842
+ for (const lo of lowSpecs) {
843
+ if (hi.junctionId !== lo.junctionId)
844
+ continue;
845
+ const junction = junctionById.get(hi.junctionId);
846
+ if (!junction.priorities.some(p => p.high === hi.roadId && p.low === lo.roadId)) {
847
+ junction.priorities.push({ high: hi.roadId, low: lo.roadId });
848
+ }
849
+ expressed = true;
850
+ }
851
+ }
852
+ if (expressed)
853
+ plan.handledYieldPairs.add(`${lane.id}|${yieldShapeId}`);
854
+ }
855
+ }
361
856
  }
362
857
  return plan;
363
858
  }
364
- function emitLink(lane, laneIdToRoadId, junctionPlan) {
365
- const lines = [];
366
- lines.push(` <link>`);
367
- const prevJunction = junctionPlan.prevJunction.get(lane.id);
368
- if (prevJunction !== undefined) {
369
- lines.push(` <predecessor elementType="junction" elementId="${prevJunction}"/>`);
370
- }
371
- else {
372
- const prevLane = (lane.props.prev ?? []).find((id) => laneIdToRoadId.has(id));
373
- if (prevLane) {
374
- const rid = laneIdToRoadId.get(prevLane);
375
- lines.push(` <predecessor elementType="road" elementId="${rid}" contactPoint="end"/>`);
859
+ /**
860
+ * Emit a synthesized junction connecting road: a single short segment
861
+ * starting at the incoming lane's inner-boundary endpoint, heading along the
862
+ * incoming road's end direction, carrying one right lane as wide as the
863
+ * source lane. When the outgoing lane starts with a different heading or
864
+ * width (drawn branch points may kink), the stub blends onto them — an <arc>
865
+ * sweeping the heading difference and a linear width ramp — so the borders
866
+ * meet both neighbours without a lateral step. The road always links
867
+ * predecessor=incoming(road, end) and successor=outgoing(road, start), so
868
+ * standard consumers can traverse incoming -> connecting -> outgoing without
869
+ * dead ends.
870
+ */
871
+ function emitConnectingRoad(spec) {
872
+ const { x, y, hdg, width } = spec.source;
873
+ const dHdg = spec.target ? wrapAngleRad(spec.target.hdg - hdg) : 0;
874
+ // Target border point in the source frame: when the outgoing lane's
875
+ // emitted start sits measurably ahead of the source corner (drawn branch
876
+ // points stagger by centimeters), a cubic Hermite interpolates both end
877
+ // poses exactly; otherwise a minimum-length arc (or line) blends the
878
+ // heading in place.
879
+ let geometry = '';
880
+ let len = CONNECTING_ROAD_LENGTH_M;
881
+ if (spec.target) {
882
+ const cosH = Math.cos(hdg);
883
+ const sinH = Math.sin(hdg);
884
+ const ex = spec.target.x - x;
885
+ const ey = spec.target.y - y;
886
+ const u1 = ex * cosH + ey * sinH;
887
+ const v1 = -ex * sinH + ey * cosH;
888
+ const dist = Math.hypot(ex, ey);
889
+ // A target at or behind the source corner (the outgoing road's emitted
890
+ // start can sit a few millimeters behind the drawn weld) is unreachable
891
+ // by a forward curve; an in-place blend as short as representable keeps
892
+ // the leftover contact offset at the stagger itself.
893
+ if (u1 < CONNECTING_ROAD_LENGTH_M)
894
+ len = 0.001;
895
+ if (dist >= CONNECTING_ROAD_LENGTH_M && u1 >= 0.7 * dist && Math.abs(dHdg) <= 1.45) {
896
+ // Hermite with parameter domain [0, L]: u(0)=0,u'(0)=1,v(0)=0,v'(0)=0,
897
+ // u(L)=u1, u'(L)=cosθ, v(L)=v1, v'(L)=sinθ (same construction as the
898
+ // plan-view fitter, emitted as paramPoly3 pRange="arcLength").
899
+ let L = Math.max(dist, u1);
900
+ let cU = 0;
901
+ let dU = 0;
902
+ let cV = 0;
903
+ let dV = 0;
904
+ const cosT = Math.cos(dHdg);
905
+ const sinT = Math.sin(dHdg);
906
+ const solve = (dom) => {
907
+ const A = u1 - dom;
908
+ const B = cosT - 1;
909
+ cU = (3 * A - B * dom) / (dom * dom);
910
+ dU = (B * dom - 2 * A) / (dom * dom * dom);
911
+ cV = (3 * v1 - sinT * dom) / (dom * dom);
912
+ dV = (sinT * dom - 2 * v1) / (dom * dom * dom);
913
+ };
914
+ const arcLength = (dom) => {
915
+ const n = 32;
916
+ let acc = 0;
917
+ let px = 0;
918
+ let py = 0;
919
+ for (let k = 1; k <= n; k++) {
920
+ const p = (dom * k) / n;
921
+ const lu = p * (1 + p * (cU + p * dU));
922
+ const lv = p * p * (cV + p * dV);
923
+ acc += Math.hypot(lu - px, lv - py);
924
+ px = lu;
925
+ py = lv;
926
+ }
927
+ return acc;
928
+ };
929
+ for (let iter = 0; iter < 3; iter++) {
930
+ solve(L);
931
+ const actual = arcLength(L);
932
+ if (!(actual > 1e-6))
933
+ break;
934
+ if (Math.abs(actual - L) < 1e-6)
935
+ break;
936
+ L = actual;
937
+ }
938
+ solve(L);
939
+ // Stay below the importer's micro-section threshold (0.3 m) so the
940
+ // stub keeps being bridged on re-import instead of materializing as a
941
+ // sliver lane; larger staggers fall back to the in-place blend.
942
+ if (L > 1e-6 && L <= 0.25) {
943
+ len = L;
944
+ geometry = ` <paramPoly3 aU="0" bU="1" cU="${fmtPrecise(cU)}" dU="${fmtPrecise(dU)}" aV="0" bV="0" cV="${fmtPrecise(cV)}" dV="${fmtPrecise(dV)}" pRange="arcLength"/>`;
945
+ }
376
946
  }
377
947
  }
378
- const nextJunction = junctionPlan.nextJunction.get(lane.id);
379
- if (nextJunction !== undefined) {
380
- lines.push(` <successor elementType="junction" elementId="${nextJunction}"/>`);
948
+ if (!geometry) {
949
+ geometry =
950
+ Math.abs(dHdg) > 1e-4
951
+ ? ` <arc curvature="${fmtPrecise(dHdg / len)}"/>`
952
+ : ` <line/>`;
381
953
  }
382
- else {
383
- const nextLane = (lane.props.next ?? []).find((id) => laneIdToRoadId.has(id));
384
- if (nextLane) {
385
- const rid = laneIdToRoadId.get(nextLane);
386
- lines.push(` <successor elementType="road" elementId="${rid}" contactPoint="start"/>`);
387
- }
954
+ const widthSlope = spec.target !== null && Math.abs(spec.target.width - width) > 1e-6
955
+ ? (spec.target.width - width) / len
956
+ : 0;
957
+ const lines = [];
958
+ lines.push(` <road name="connecting" length="${fmt(len)}" id="${spec.roadId}" junction="${spec.junctionId}">`);
959
+ lines.push(` <link>`);
960
+ lines.push(` <predecessor elementType="road" elementId="${spec.incomingRoadId}" contactPoint="end"/>`);
961
+ lines.push(` <successor elementType="road" elementId="${spec.outgoingRoadId}" contactPoint="start"/>`);
962
+ lines.push(` </link>`);
963
+ lines.push(` <planView>`);
964
+ lines.push(` <geometry s="0" x="${fmt(x)}" y="${fmt(y)}" hdg="${fmt(hdg)}" length="${fmt(len)}">`);
965
+ lines.push(geometry);
966
+ lines.push(` </geometry>`);
967
+ lines.push(` </planView>`);
968
+ lines.push(` <elevationProfile/>`);
969
+ lines.push(` <lateralProfile/>`);
970
+ lines.push(` <lanes>`);
971
+ lines.push(` <laneSection s="0">`);
972
+ lines.push(` <center>`);
973
+ lines.push(` <lane id="0" type="none" level="false">`);
974
+ lines.push(` <link/>`);
975
+ lines.push(` <roadMark sOffset="0" type="none" weight="standard" color="white" width="0.13"/>`);
976
+ lines.push(` </lane>`);
977
+ lines.push(` </center>`);
978
+ lines.push(` <right>`);
979
+ lines.push(` <lane id="-1" type="${spec.source.laneType}" level="false">`);
980
+ lines.push(` <link>`);
981
+ lines.push(` <predecessor id="${spec.fromOdrLaneId}"/>`);
982
+ lines.push(` <successor id="${spec.toOdrLaneId}"/>`);
983
+ lines.push(` </link>`);
984
+ lines.push(` <width sOffset="0" a="${fmt(width)}" b="${widthSlope === 0 ? '0' : fmtPrecise(widthSlope)}" c="0" d="0"/>`);
985
+ lines.push(` <roadMark sOffset="0" type="none" weight="standard" color="white" width="0.13"/>`);
986
+ lines.push(` </lane>`);
987
+ lines.push(` </right>`);
988
+ lines.push(` </laneSection>`);
989
+ lines.push(` </lanes>`);
990
+ lines.push(` <objects/>`);
991
+ lines.push(` <signals/>`);
992
+ lines.push(` </road>`);
993
+ return lines.join('\n');
994
+ }
995
+ function emitLink(roadId, plan) {
996
+ const lines = [];
997
+ lines.push(` <link>`);
998
+ const pred = plan.roadPredecessor.get(roadId);
999
+ if (pred) {
1000
+ lines.push(pred.kind === 'junction'
1001
+ ? ` <predecessor elementType="junction" elementId="${pred.id}"/>`
1002
+ : ` <predecessor elementType="road" elementId="${pred.id}" contactPoint="end"/>`);
1003
+ }
1004
+ const succ = plan.roadSuccessor.get(roadId);
1005
+ if (succ) {
1006
+ lines.push(succ.kind === 'junction'
1007
+ ? ` <successor elementType="junction" elementId="${succ.id}"/>`
1008
+ : ` <successor elementType="road" elementId="${succ.id}" contactPoint="start"/>`);
388
1009
  }
389
1010
  lines.push(` </link>`);
390
1011
  return lines.join('\n');
@@ -395,7 +1016,9 @@ function projectToRoad(geom, xG, yG) {
395
1016
  let bestDist = Infinity;
396
1017
  let bestHdg = 0;
397
1018
  let bestClamped = false;
398
- const samples = geom.odrSamples;
1019
+ // The fitted sample poses lie on the analytic reference line, so chord
1020
+ // projection between them yields stations directly in the emitted s domain.
1021
+ const samples = geom.samplePoses;
399
1022
  for (let i = 0; i < samples.length - 1; i++) {
400
1023
  const a = samples[i];
401
1024
  const b = samples[i + 1];
@@ -423,7 +1046,7 @@ function projectToRoad(geom, xG, yG) {
423
1046
  const dist = Math.hypot(xG - projX, yG - projY);
424
1047
  if (dist < bestDist) {
425
1048
  bestDist = dist;
426
- bestS = a.s + tNorm;
1049
+ bestS = a.s + (tNorm / segLen) * (b.s - a.s);
427
1050
  const nx = -uy;
428
1051
  const ny = ux;
429
1052
  bestT = px * nx + py * ny;
@@ -433,39 +1056,71 @@ function projectToRoad(geom, xG, yG) {
433
1056
  }
434
1057
  return { s: bestS, t: bestT, hdg: bestHdg, distance: bestDist, clampedAtEnd: bestClamped };
435
1058
  }
436
- function attachShapesToRoads(shapeMap, trafficLights, crosswalks, polygons, laneToGeom, laneIdToRoadId, maxAttachDistanceMeter = 50) {
1059
+ /** Contiguous <validity> ranges from a set of ODR lane ids. */
1060
+ function laneIdRanges(ids) {
1061
+ const sorted = [...new Set(ids)].sort((a, b) => a - b);
1062
+ const ranges = [];
1063
+ if (sorted.length === 0)
1064
+ return ranges;
1065
+ let start = sorted[0];
1066
+ let prev = sorted[0];
1067
+ for (let i = 1; i < sorted.length; i++) {
1068
+ const v = sorted[i];
1069
+ if (v === prev + 1) {
1070
+ prev = v;
1071
+ continue;
1072
+ }
1073
+ ranges.push({ fromLane: start, toLane: prev });
1074
+ start = v;
1075
+ prev = v;
1076
+ }
1077
+ ranges.push({ fromLane: start, toLane: prev });
1078
+ return ranges;
1079
+ }
1080
+ function attachShapesToRoads(shapeMap, trafficLights, trafficSigns, crosswalks, polygons, roads, laneIdToRoadId, laneIdToOdrLaneId, maxAttachDistanceMeter = 50, signalIdStart = 1) {
437
1081
  const roadSignals = new Map();
438
1082
  const roadObjects = new Map();
439
1083
  const roadSignalRefs = new Map();
440
1084
  const signalIdByShape = new Map();
441
- let signalIdCounter = 1;
1085
+ let signalIdCounter = signalIdStart;
442
1086
  let objectIdCounter = 1;
443
- const roads = [];
444
- laneToGeom.forEach((geom, laneId) => {
445
- const roadId = laneIdToRoadId.get(laneId);
446
- if (roadId !== undefined)
447
- roads.push({ laneId, geom, roadId });
448
- });
449
1087
  const geomByRoadId = new Map();
450
1088
  for (const r of roads)
451
1089
  geomByRoadId.set(r.roadId, r.geom);
452
- for (const tl of trafficLights) {
1090
+ /** Affected lanes grouped per road: road id -> ODR lane ids. */
1091
+ const affectedLanesByRoad = (laneShapeIds) => {
1092
+ const byRoad = new Map();
1093
+ for (const laneShapeId of laneShapeIds ?? []) {
1094
+ const rid = laneIdToRoadId.get(laneShapeId);
1095
+ const oid = laneIdToOdrLaneId.get(laneShapeId);
1096
+ if (rid === undefined || oid === undefined)
1097
+ continue;
1098
+ const list = byRoad.get(rid) ?? [];
1099
+ if (!list.includes(oid))
1100
+ list.push(oid);
1101
+ byRoad.set(rid, list);
1102
+ }
1103
+ return byRoad;
1104
+ };
1105
+ // Traffic lights (dynamic signals) and traffic signs (static signals)
1106
+ // attach to roads through the same projection / validity machinery; only
1107
+ // the emitted <signal> attributes differ per kind.
1108
+ const signalShapes = [
1109
+ ...trafficLights.map(shape => ({ kind: 'traffic_light', shape })),
1110
+ ...trafficSigns.map(shape => ({ kind: 'traffic_sign', shape })),
1111
+ ];
1112
+ for (const { kind, shape: tl } of signalShapes) {
453
1113
  const xG = pxToEnuX(tl.x);
454
1114
  const yG = pxToEnuY(tl.y);
455
1115
  // Regulatory layer: a signal that names affected lanes attaches to the
456
- // nearest of those lanes' roads and carries a <validity> record for the
457
- // road's single driving lane (-1). Distance gating does not apply — the
458
- // assignment is explicit.
459
- const affectedRoadIds = new Set();
460
- for (const laneShapeId of tl.props.affectedLaneIds ?? []) {
461
- const rid = laneIdToRoadId.get(laneShapeId);
462
- if (rid !== undefined)
463
- affectedRoadIds.add(rid);
464
- }
1116
+ // nearest of those lanes' roads and carries <validity> records for the
1117
+ // affected lane range. Distance gating does not apply — the assignment is
1118
+ // explicit.
1119
+ const affectedByRoad = affectedLanesByRoad(tl.props.affectedLaneIds);
465
1120
  let best = null;
466
- if (affectedRoadIds.size > 0) {
1121
+ if (affectedByRoad.size > 0) {
467
1122
  for (const r of roads) {
468
- if (!affectedRoadIds.has(r.roadId))
1123
+ if (!affectedByRoad.has(r.roadId))
469
1124
  continue;
470
1125
  const proj = projectToRoad(r.geom, xG, yG);
471
1126
  if (!best || proj.distance < best.proj.distance)
@@ -484,40 +1139,74 @@ function attachShapesToRoads(shapeMap, trafficLights, crosswalks, polygons, lane
484
1139
  }
485
1140
  if (!best)
486
1141
  continue;
487
- const style = tl.props.style ?? '';
488
- const isPed = style.startsWith('pedestrian') || style.includes('ped');
489
- // Conventional signal type codes: 1000001 = vehicle, 1000002 = pedestrian.
490
- const sigType = isPed ? '1000002' : '1000001';
491
1142
  const heightM = pxToMeter(tl.props.h);
492
1143
  const widthM = pxToMeter(tl.props.w);
493
1144
  const list = roadSignals.get(best.roadId) ?? [];
494
- const entry = {
495
- id: signalIdCounter++,
496
- s: best.proj.s,
497
- t: best.proj.t,
498
- zOffset: isPed ? 1.5 : 4.5,
499
- height: heightM,
500
- width: widthM,
501
- name: style,
502
- type: sigType,
503
- subtype: '-1',
504
- dynamic: 'yes',
505
- orientation: best.proj.t >= 0 ? '+' : '-',
506
- };
507
- if (affectedRoadIds.size > 0) {
508
- // The exporter emits one driving lane (-1) per road.
509
- entry.validity = { fromLane: -1, toLane: -1 };
1145
+ let entry;
1146
+ if (kind === 'traffic_light') {
1147
+ const style = tl.props.style ?? '';
1148
+ const isPed = style.startsWith('pedestrian') || style.includes('ped');
1149
+ // Conventional signal type codes: 1000001 = vehicle, 1000002 = pedestrian.
1150
+ const sigType = isPed ? '1000002' : '1000001';
1151
+ entry = {
1152
+ id: signalIdCounter++,
1153
+ s: best.proj.s,
1154
+ t: best.proj.t,
1155
+ zOffset: isPed ? 1.5 : 4.5,
1156
+ height: heightM,
1157
+ width: widthM,
1158
+ name: style,
1159
+ type: sigType,
1160
+ subtype: '-1',
1161
+ dynamic: 'yes',
1162
+ orientation: best.proj.t >= 0 ? '+' : '-',
1163
+ };
1164
+ }
1165
+ else {
1166
+ // Static traffic sign: reuse the exact OpenDRIVE type / subtype /
1167
+ // country recorded at import time; fresh signs fall back to type "-1"
1168
+ // with the sign code as the name. Full attribute round-trip rides on
1169
+ // <userData code="signAttributes">.
1170
+ const attrs = (tl.props.attributes ?? {});
1171
+ entry = {
1172
+ id: signalIdCounter++,
1173
+ s: best.proj.s,
1174
+ t: best.proj.t,
1175
+ zOffset: 2,
1176
+ height: heightM,
1177
+ width: widthM,
1178
+ name: trafficSignCode(attrs),
1179
+ type: attrs.odr_signal_type || '-1',
1180
+ subtype: attrs.odr_signal_subtype || '-1',
1181
+ country: attrs.odr_country || undefined,
1182
+ dynamic: 'no',
1183
+ orientation: best.proj.t >= 0 ? '+' : '-',
1184
+ };
1185
+ const stash = {};
1186
+ for (const [k, v] of Object.entries(attrs)) {
1187
+ if (k === 'type' || k === 'refers_osm_id' || k.startsWith('odr_'))
1188
+ continue;
1189
+ if (v === undefined || v === null || v === '')
1190
+ continue;
1191
+ stash[k] = String(v);
1192
+ }
1193
+ if (Object.keys(stash).length > 0) {
1194
+ entry.userData = [{ code: 'signAttributes', value: JSON.stringify(stash) }];
1195
+ }
1196
+ }
1197
+ if (affectedByRoad.size > 0) {
1198
+ entry.validity = laneIdRanges(affectedByRoad.get(best.roadId));
510
1199
  }
511
1200
  list.push(entry);
512
1201
  roadSignals.set(best.roadId, list);
513
1202
  signalIdByShape.set(tl.id, entry.id);
514
- // A signal controlling several lanes spans several roads in the
515
- // 1-lane-per-road model. The remaining affected roads get a standard
516
- // <signalReference> pointing back at the signal so the validity links
517
- // survive a round trip (and ODR consumers see the full coverage).
518
- if (affectedRoadIds.size > 1) {
1203
+ // A signal controlling lanes in several road bundles cannot carry a
1204
+ // single <validity> (it cannot cross roads); the remaining affected roads
1205
+ // get a standard <signalReference> pointing back at the signal with their
1206
+ // own lane ranges, so the validity links survive a round trip.
1207
+ if (affectedByRoad.size > 1) {
519
1208
  for (const r of roads) {
520
- if (r.roadId === best.roadId || !affectedRoadIds.has(r.roadId))
1209
+ if (r.roadId === best.roadId || !affectedByRoad.has(r.roadId))
521
1210
  continue;
522
1211
  const proj = projectToRoad(r.geom, xG, yG);
523
1212
  const refs = roadSignalRefs.get(r.roadId) ?? [];
@@ -526,6 +1215,7 @@ function attachShapesToRoads(shapeMap, trafficLights, crosswalks, polygons, lane
526
1215
  s: proj.s,
527
1216
  t: proj.t,
528
1217
  orientation: proj.t >= 0 ? '+' : '-',
1218
+ validity: laneIdRanges(affectedByRoad.get(r.roadId)),
529
1219
  });
530
1220
  roadSignalRefs.set(r.roadId, refs);
531
1221
  }
@@ -581,16 +1271,11 @@ function attachShapesToRoads(shapeMap, trafficLights, crosswalks, polygons, lane
581
1271
  // Regulatory layer: a crosswalk that names affected lanes attaches to the
582
1272
  // nearest of those lanes' roads (no distance gate — the assignment is
583
1273
  // explicit), mirroring the signal behavior above.
584
- const affectedRoadIds = new Set();
585
- for (const laneShapeId of cw.props.affectedLaneIds ?? []) {
586
- const rid = laneIdToRoadId.get(laneShapeId);
587
- if (rid !== undefined)
588
- affectedRoadIds.add(rid);
589
- }
1274
+ const affectedByRoad = affectedLanesByRoad(cw.props.affectedLaneIds);
590
1275
  let best = null;
591
- if (affectedRoadIds.size > 0) {
1276
+ if (affectedByRoad.size > 0) {
592
1277
  for (const r of roads) {
593
- if (!affectedRoadIds.has(r.roadId))
1278
+ if (!affectedByRoad.has(r.roadId))
594
1279
  continue;
595
1280
  const proj = projectToRoad(r.geom, xG, yG);
596
1281
  if (!best || proj.distance < best.proj.distance)
@@ -628,13 +1313,19 @@ function attachShapesToRoads(shapeMap, trafficLights, crosswalks, polygons, lane
628
1313
  // π/2 regardless of the user-drawn axis direction.
629
1314
  void relativeHdg;
630
1315
  const crosswalkHdg = Math.PI / 2;
631
- // Regulatory links (affected roads + stop line polyline) ride along as
1316
+ // Regulatory links (affected lanes + stop line polyline) ride along as
632
1317
  // <userData> — OpenDRIVE's standard extension mechanism — so they survive
633
1318
  // an .xodr round trip. Coordinates are ENU meters.
634
1319
  const userData = [];
635
- if (affectedRoadIds.size > 0) {
1320
+ if (affectedByRoad.size > 0) {
1321
+ const affectedLanes = [];
1322
+ for (const [rid, ids] of [...affectedByRoad.entries()].sort((a, b) => a[0] - b[0])) {
1323
+ for (const oid of [...ids].sort((a, b) => a - b)) {
1324
+ affectedLanes.push([String(rid), String(oid)]);
1325
+ }
1326
+ }
636
1327
  const links = {
637
- affectedRoads: [...affectedRoadIds].sort((a, b) => a - b).map(String),
1328
+ affectedLanes,
638
1329
  };
639
1330
  if (cw.props.stopLineId) {
640
1331
  const stopLs = shapeMap.get(cw.props.stopLineId);
@@ -682,12 +1373,13 @@ function attachShapesToRoads(shapeMap, trafficLights, crosswalks, polygons, lane
682
1373
  for (const r of roads) {
683
1374
  const proj = projectToRoad(r.geom, xG, yG);
684
1375
  if (proj.clampedAtEnd) {
685
- if (!fallback || proj.distance < fallback.proj.distance)
686
- fallback = { roadId: r.roadId, proj };
1376
+ if (!fallback || proj.distance < fallback.proj.distance) {
1377
+ fallback = { roadId: r.roadId, geom: r.geom, proj };
1378
+ }
687
1379
  continue;
688
1380
  }
689
1381
  if (!best || proj.distance < best.proj.distance)
690
- best = { roadId: r.roadId, proj };
1382
+ best = { roadId: r.roadId, geom: r.geom, proj };
691
1383
  }
692
1384
  if (!best)
693
1385
  best = fallback;
@@ -695,7 +1387,7 @@ function attachShapesToRoads(shapeMap, trafficLights, crosswalks, polygons, lane
695
1387
  continue;
696
1388
  const cosH = Math.cos(best.proj.hdg);
697
1389
  const sinH = Math.sin(best.proj.hdg);
698
- const samples = laneToGeom.get([...laneToGeom.keys()].find((k) => laneIdToRoadId.get(k) === best.roadId)).odrSamples;
1390
+ const samples = best.geom.samplePoses;
699
1391
  const anchorPoint = best.proj.clampedAtEnd && best.proj.s >= samples[samples.length - 1].s - 1e-6
700
1392
  ? samples[samples.length - 1]
701
1393
  : samples[0];
@@ -740,16 +1432,19 @@ function emitSignals(signals, references) {
740
1432
  const lines = [];
741
1433
  lines.push(` <signals>`);
742
1434
  for (const s of signals) {
743
- const attrs = `id="${s.id}" s="${fmt(s.s)}" t="${fmt(s.t)}" zOffset="${fmt(s.zOffset)}" name="${escapeXml(s.name)}" dynamic="${s.dynamic}" orientation="${s.orientation}" type="${s.type}" subtype="${s.subtype}" country="OpenDRIVE" value="0" height="${fmt(s.height)}" width="${fmt(s.width)}"`;
744
- if (s.validity || s.stopLinePoints) {
1435
+ const attrs = `id="${s.id}" s="${fmt(s.s)}" t="${fmt(s.t)}" zOffset="${fmt(s.zOffset)}" name="${escapeXml(s.name)}" dynamic="${s.dynamic}" orientation="${s.orientation}" type="${s.type}" subtype="${s.subtype}" country="${escapeXml(s.country ?? 'OpenDRIVE')}" value="0" height="${fmt(s.height)}" width="${fmt(s.width)}"`;
1436
+ if (s.validity?.length || s.stopLinePoints || s.userData?.length) {
745
1437
  lines.push(` <signal ${attrs}>`);
746
- if (s.validity) {
747
- lines.push(` <validity fromLane="${s.validity.fromLane}" toLane="${s.validity.toLane}"/>`);
1438
+ for (const v of s.validity ?? []) {
1439
+ lines.push(` <validity fromLane="${v.fromLane}" toLane="${v.toLane}"/>`);
748
1440
  }
749
1441
  if (s.stopLinePoints) {
750
1442
  const json = JSON.stringify(s.stopLinePoints.map((p) => [roundMm(p.x), roundMm(p.y)]));
751
1443
  lines.push(` <userData code="stopLine" value="${escapeXml(json)}"/>`);
752
1444
  }
1445
+ for (const ud of s.userData ?? []) {
1446
+ lines.push(` <userData code="${escapeXml(ud.code)}" value="${escapeXml(ud.value)}"/>`);
1447
+ }
753
1448
  lines.push(` </signal>`);
754
1449
  }
755
1450
  else {
@@ -758,7 +1453,9 @@ function emitSignals(signals, references) {
758
1453
  }
759
1454
  for (const ref of references) {
760
1455
  lines.push(` <signalReference s="${fmt(ref.s)}" t="${fmt(ref.t)}" id="${ref.id}" orientation="${ref.orientation}">`);
761
- lines.push(` <validity fromLane="-1" toLane="-1"/>`);
1456
+ for (const v of ref.validity) {
1457
+ lines.push(` <validity fromLane="${v.fromLane}" toLane="${v.toLane}"/>`);
1458
+ }
762
1459
  lines.push(` </signalReference>`);
763
1460
  }
764
1461
  lines.push(` </signals>`);
@@ -807,40 +1504,86 @@ function emitObjects(objects) {
807
1504
  /**
808
1505
  * Lane attributes that have no OpenDRIVE representation (speed_limit only
809
1506
  * partially maps, one_way / turn_direction / location and custom tags not at
810
- * all) are stashed verbatim as JSON in <userData code="laneAttributes"> —
811
- * OpenDRIVE's standard extension mechanism — and restored by the importer.
1507
+ * all) are stashed as JSON in <userData code="laneAttributes"> — OpenDRIVE's
1508
+ * standard extension mechanism — keyed by the lane's ODR id so per-lane
1509
+ * attributes stay separate in multi-lane roads, and restored by the importer.
812
1510
  * `odr_*` meta attributes are excluded: they are regenerated on import.
813
1511
  */
814
- function emitLaneAttributesUserData(lane) {
815
- const stash = {};
816
- for (const [k, v] of Object.entries(lane.props.attributes ?? {})) {
817
- if (k === 'type' || k.startsWith('odr_'))
818
- continue;
819
- if (v === undefined || v === null || v === '')
820
- continue;
821
- stash[k] = String(v);
822
- }
823
- if (Object.keys(stash).length === 0)
1512
+ function emitLaneAttributesUserData(bundleLanes) {
1513
+ const byLane = {};
1514
+ bundleLanes.forEach((lane, i) => {
1515
+ const stash = {};
1516
+ for (const [k, v] of Object.entries(lane.props.attributes ?? {})) {
1517
+ if (k === 'type' || k.startsWith('odr_'))
1518
+ continue;
1519
+ if (v === undefined || v === null || v === '')
1520
+ continue;
1521
+ stash[k] = String(v);
1522
+ }
1523
+ if (Object.keys(stash).length > 0)
1524
+ byLane[String(-(i + 1))] = stash;
1525
+ });
1526
+ if (Object.keys(byLane).length === 0)
1527
+ return null;
1528
+ return ` <userData code="laneAttributes" value="${escapeXml(JSON.stringify(byLane))}"/>`;
1529
+ }
1530
+ /**
1531
+ * Right-of-way links (`yieldLaneIds`) between two lanes that both feed a
1532
+ * connecting road of the same junction are expressed as standard junction
1533
+ * <priority> records (see planConnectivity); every remaining link is stashed
1534
+ * in <userData code="yieldLanes"> as { ownLaneId: [[roadId, laneId], ...] }
1535
+ * and restored by the importer.
1536
+ */
1537
+ function emitYieldLanesUserData(bundleLanes, laneIdToRoadId, laneIdToOdrLaneId, handledYieldPairs) {
1538
+ const byLane = {};
1539
+ bundleLanes.forEach((lane, i) => {
1540
+ const targets = [];
1541
+ for (const yieldShapeId of lane.props.yieldLaneIds ?? []) {
1542
+ if (handledYieldPairs.has(`${lane.id}|${yieldShapeId}`))
1543
+ continue;
1544
+ const rid = laneIdToRoadId.get(yieldShapeId);
1545
+ const oid = laneIdToOdrLaneId.get(yieldShapeId);
1546
+ if (rid === undefined || oid === undefined)
1547
+ continue;
1548
+ if (!targets.some(t => t[0] === String(rid) && t[1] === String(oid))) {
1549
+ targets.push([String(rid), String(oid)]);
1550
+ }
1551
+ }
1552
+ if (targets.length > 0) {
1553
+ targets.sort((a, b) => Number(a[0]) - Number(b[0]) || Number(a[1]) - Number(b[1]));
1554
+ byLane[String(-(i + 1))] = targets;
1555
+ }
1556
+ });
1557
+ if (Object.keys(byLane).length === 0)
824
1558
  return null;
825
- return ` <userData code="laneAttributes" value="${escapeXml(JSON.stringify(stash))}"/>`;
1559
+ return ` <userData code="yieldLanes" value="${escapeXml(JSON.stringify(byLane))}"/>`;
826
1560
  }
827
1561
  /**
828
- * Right-of-way links (`yieldLaneIds`) have no canonical OpenDRIVE road-level
829
- * representation in this exporter (junction <priority> mapping is out of
830
- * scope), so the yielding lanes' road ids are stashed in
831
- * <userData code="yieldRoads"> and restored by the importer.
1562
+ * Zero-width-contact lane edges homed on this road (see
1563
+ * ConnectivityPlan.hiddenLaneEdges), stashed as
1564
+ * <userData code="hiddenLaneLinks"> records of
1565
+ * { fr, fl, tr, tl } = from road id / from ODR lane id / to road id /
1566
+ * to ODR lane id (from end -> to start in travel direction), and restored
1567
+ * into next/prev by the importer.
832
1568
  */
833
- function emitYieldRoadsUserData(lane, laneIdToRoadId) {
834
- const yieldRoadIds = [];
835
- for (const yieldShapeId of lane.props.yieldLaneIds ?? []) {
836
- const rid = laneIdToRoadId.get(yieldShapeId);
837
- if (rid !== undefined && !yieldRoadIds.includes(rid))
838
- yieldRoadIds.push(rid);
839
- }
840
- if (yieldRoadIds.length === 0)
1569
+ function emitHiddenLinksUserData(bundleLanes, plan, laneIdToRoadId, laneIdToOdrLaneId) {
1570
+ const inBundle = new Set(bundleLanes.map(l => l.id));
1571
+ const recs = [];
1572
+ for (const e of plan.hiddenLaneEdges) {
1573
+ if (!inBundle.has(e.home))
1574
+ continue;
1575
+ const fr = laneIdToRoadId.get(e.from);
1576
+ const fl = laneIdToOdrLaneId.get(e.from);
1577
+ const tr = laneIdToRoadId.get(e.to);
1578
+ const tl = laneIdToOdrLaneId.get(e.to);
1579
+ if (fr === undefined || fl === undefined || tr === undefined || tl === undefined)
1580
+ continue;
1581
+ recs.push({ fr, fl, tr, tl });
1582
+ }
1583
+ if (recs.length === 0)
841
1584
  return null;
842
- const json = JSON.stringify(yieldRoadIds.sort((a, b) => a - b).map(String));
843
- return ` <userData code="yieldRoads" value="${escapeXml(json)}"/>`;
1585
+ recs.sort((a, b) => a.fr - b.fr || a.fl - b.fl || a.tr - b.tr || a.tl - b.tl);
1586
+ return ` <userData code="hiddenLaneLinks" value="${escapeXml(JSON.stringify(recs))}"/>`;
844
1587
  }
845
1588
  /**
846
1589
  * Road length attribute. The plan-view geometries are emitted with rounded
@@ -850,55 +1593,343 @@ function emitYieldRoadsUserData(lane, laneIdToRoadId) {
850
1593
  */
851
1594
  function emittedRoadLength(geom) {
852
1595
  let extent = geom.length;
853
- for (let i = 0; i < geom.odrSamples.length - 1; i++) {
854
- const a = geom.odrSamples[i];
855
- const b = geom.odrSamples[i + 1];
856
- const segLen = Math.hypot(b.x - a.x, b.y - a.y);
857
- if (segLen < 1e-9)
858
- continue;
859
- const end = parseFloat(fmt(a.s)) + parseFloat(fmt(segLen));
1596
+ for (const g of geom.planView) {
1597
+ const end = parseFloat(fmt(g.s)) + parseFloat(fmt(g.length));
860
1598
  if (end > extent)
861
1599
  extent = end;
862
1600
  }
863
1601
  return extent + 1e-4;
864
1602
  }
865
- function emitRoad(lane, geom, roadId, laneIdToRoadId, junctionPlan, signals, signalRefs, objects, shapeMap) {
866
- const speed = lane.props.attributes?.speed_limit;
867
- const name = escapeXml(lane.props.attributes?.subtype || 'road');
868
- const hasPrev = (lane.props.prev ?? []).some((id) => laneIdToRoadId.has(id));
869
- const hasNext = (lane.props.next ?? []).some((id) => laneIdToRoadId.has(id));
870
- const junctionAttr = junctionPlan.roadJunction.get(lane.id) ?? -1;
1603
+ function emitRoad(bundle, roadId, plan, signals, signalRefs, objects, shapeMap, laneIdToRoadId, laneIdToOdrLaneId) {
1604
+ const first = bundle.lanes[0];
1605
+ const speed = bundle.lanes.find(l => l.props.attributes?.speed_limit)?.props.attributes?.speed_limit;
1606
+ const name = escapeXml(first.props.attributes?.subtype || 'road');
871
1607
  const lines = [];
872
- lines.push(` <road name="${name}" length="${fmt(emittedRoadLength(geom))}" id="${roadId}" junction="${junctionAttr}">`);
873
- lines.push(emitLink(lane, laneIdToRoadId, junctionPlan));
1608
+ // Mainline (bundle) roads never belong to a junction; junction membership
1609
+ // is carried by the synthesized connecting roads (emitConnectingRoad).
1610
+ lines.push(` <road name="${name}" length="${fmt(emittedRoadLength(bundle.geom))}" id="${roadId}" junction="-1">`);
1611
+ lines.push(emitLink(roadId, plan));
874
1612
  if (speed) {
875
1613
  lines.push(` <type s="0" type="town">`);
876
1614
  lines.push(` <speed max="${escapeXml(speed)}" unit="km/h"/>`);
877
1615
  lines.push(` </type>`);
878
1616
  }
879
- lines.push(emitPlanView(geom));
1617
+ lines.push(emitPlanView(bundle.geom));
880
1618
  lines.push(` <elevationProfile/>`);
881
1619
  lines.push(` <lateralProfile/>`);
882
- lines.push(emitLanes(geom, hasPrev, hasNext, odrLaneTypeFor(lane), roadMarkTypeFor(shapeMap, lane.props.leftBoundaryId), roadMarkTypeFor(shapeMap, lane.props.rightBoundaryId)));
1620
+ lines.push(emitLanes(bundle, plan, shapeMap));
883
1621
  lines.push(emitObjects(objects));
884
1622
  lines.push(emitSignals(signals, signalRefs));
885
- const userData = emitLaneAttributesUserData(lane);
1623
+ const userData = emitLaneAttributesUserData(bundle.lanes);
886
1624
  if (userData)
887
1625
  lines.push(userData);
888
- const yieldUserData = emitYieldRoadsUserData(lane, laneIdToRoadId);
1626
+ const yieldUserData = emitYieldLanesUserData(bundle.lanes, laneIdToRoadId, laneIdToOdrLaneId, plan.handledYieldPairs);
889
1627
  if (yieldUserData)
890
1628
  lines.push(yieldUserData);
1629
+ const hiddenLinksUserData = emitHiddenLinksUserData(bundle.lanes, plan, laneIdToRoadId, laneIdToOdrLaneId);
1630
+ if (hiddenLinksUserData)
1631
+ lines.push(hiddenLinksUserData);
891
1632
  lines.push(` </road>`);
892
1633
  return lines.join('\n');
893
1634
  }
1635
+ /** Empty point-override map (raw stored coordinates). */
1636
+ const NO_OVERRIDES = new Map();
1637
+ /**
1638
+ * Decide which original roads can be re-emitted verbatim.
1639
+ *
1640
+ * A recorded road is clean when every lane shape it produced still exists and
1641
+ * the state hash recomputed from the live shapes equals the import-time hash
1642
+ * (geometry, attributes, connectivity, right-of-way, and the regulatory
1643
+ * shapes touching the road — see odrCarryThrough.ts).
1644
+ *
1645
+ * Dirtiness then propagates until stable:
1646
+ * - A junction is dirty when any member road (connecting roads, incoming /
1647
+ * outgoing roads, roads linking to the junction) is dirty or unrecorded.
1648
+ * A dirty junction regenerates together with its CONNECTING
1649
+ * (junction-stamped) roads, whose connection table it replaces; clean
1650
+ * incoming / outgoing roads stay verbatim and their junction link
1651
+ * elementIds are re-pointed at the regenerated junction on emission.
1652
+ * - Regulatory shapes are atomic: a traffic light / crosswalk touching a
1653
+ * dirty road dirties every road it touches, so its signal + references are
1654
+ * either all verbatim or all regenerated.
1655
+ *
1656
+ * Roads referencing unrecorded elements (e.g. a selective import) are never
1657
+ * carried verbatim, so verbatim output cannot dangle into missing roads.
1658
+ */
1659
+ function planCarryThrough(sidecar, shapeMap, trafficLights, trafficSigns, crosswalks) {
1660
+ const records = sidecar?.roadRecords;
1661
+ if (!sidecar || !records || Object.keys(records).length === 0)
1662
+ return null;
1663
+ const doc = extractOdrDocument(sidecar.rawXml);
1664
+ if (!doc)
1665
+ return null;
1666
+ const docRoadById = new Map(doc.roads.map(r => [r.id, r]));
1667
+ const docJunctionById = new Map(doc.junctions.map(j => [j.id, j]));
1668
+ // laneShapeId -> recorded road id (over every record).
1669
+ const laneRoadOf = new Map();
1670
+ for (const [rid, rec] of Object.entries(records)) {
1671
+ for (const lid of rec.laneShapeIds)
1672
+ laneRoadOf.set(lid, rid);
1673
+ }
1674
+ const stopLinePts = (lsId) => {
1675
+ if (!lsId)
1676
+ return null;
1677
+ const ls = shapeMap.get(lsId);
1678
+ if (!ls)
1679
+ return null;
1680
+ const pts = collectPoints(shapeMap, ls.props.pointIds, false, NO_OVERRIDES);
1681
+ return pts.length >= 2 ? pts : null;
1682
+ };
1683
+ // Regulatory shapes: state + the set of recorded roads each one touches
1684
+ // (mirrors the importer's record builder).
1685
+ const regStatesByRoad = new Map();
1686
+ const regShapes = [];
1687
+ const addRegState = (state, affected, own) => {
1688
+ const touching = new Set();
1689
+ if (own && records[own])
1690
+ touching.add(own);
1691
+ for (const lid of affected) {
1692
+ const rid = laneRoadOf.get(lid);
1693
+ if (rid)
1694
+ touching.add(rid);
1695
+ }
1696
+ for (const rid of touching) {
1697
+ const list = regStatesByRoad.get(rid) ?? [];
1698
+ list.push(state);
1699
+ regStatesByRoad.set(rid, list);
1700
+ }
1701
+ regShapes.push({ shapeId: state.shapeId, touching });
1702
+ };
1703
+ for (const tl of trafficLights) {
1704
+ addRegState({
1705
+ kind: 'traffic_light',
1706
+ shapeId: tl.id,
1707
+ numbers: [tl.x, tl.y, tl.props.w, tl.props.h, tl.rotation || 0],
1708
+ attributes: tl.props.attributes ?? {},
1709
+ affectedLaneIds: tl.props.affectedLaneIds ?? [],
1710
+ stopLinePts: stopLinePts(tl.props.stopLineId),
1711
+ controllerId: tl.props.controllerId ?? '',
1712
+ }, tl.props.affectedLaneIds ?? [], tl.props.attributes?.odr_road_id);
1713
+ }
1714
+ for (const ts of trafficSigns) {
1715
+ addRegState({
1716
+ kind: 'traffic_sign',
1717
+ shapeId: ts.id,
1718
+ numbers: [ts.x, ts.y, ts.props.w, ts.props.h, ts.rotation || 0],
1719
+ attributes: ts.props.attributes ?? {},
1720
+ affectedLaneIds: ts.props.affectedLaneIds ?? [],
1721
+ stopLinePts: stopLinePts(ts.props.stopLineId),
1722
+ controllerId: '',
1723
+ }, ts.props.affectedLaneIds ?? [], ts.props.attributes?.odr_road_id);
1724
+ }
1725
+ for (const cw of crosswalks) {
1726
+ addRegState({
1727
+ kind: 'crosswalk',
1728
+ shapeId: cw.id,
1729
+ numbers: [
1730
+ cw.x,
1731
+ cw.y,
1732
+ cw.props.startX,
1733
+ cw.props.startY,
1734
+ cw.props.endX,
1735
+ cw.props.endY,
1736
+ cw.props.crosswalkWidth,
1737
+ cw.rotation || 0,
1738
+ ],
1739
+ attributes: cw.props.attributes ?? {},
1740
+ affectedLaneIds: cw.props.affectedLaneIds ?? [],
1741
+ stopLinePts: stopLinePts(cw.props.stopLineId),
1742
+ controllerId: '',
1743
+ }, cw.props.affectedLaneIds ?? [], cw.props.attributes?.odr_road_id);
1744
+ }
1745
+ // Export-side lane states (null when any recorded lane shape is missing).
1746
+ const exportLaneStates = (rec) => {
1747
+ const states = [];
1748
+ for (const lid of rec.laneShapeIds) {
1749
+ const shape = shapeMap.get(lid);
1750
+ if (!shape || shape.type !== 'lane')
1751
+ return null;
1752
+ const lane = shape;
1753
+ states.push({
1754
+ leftPts: boundaryPointsOf(shapeMap, lane.props.leftBoundaryId, lane.props.invertLeft, NO_OVERRIDES),
1755
+ rightPts: boundaryPointsOf(shapeMap, lane.props.rightBoundaryId, lane.props.invertRight, NO_OVERRIDES),
1756
+ attributes: lane.props.attributes ?? {},
1757
+ next: lane.props.next ?? [],
1758
+ prev: lane.props.prev ?? [],
1759
+ yieldLaneIds: lane.props.yieldLaneIds ?? [],
1760
+ });
1761
+ }
1762
+ return states;
1763
+ };
1764
+ // Seed dirtiness: hash mismatch, missing shapes, or references that leave
1765
+ // the recorded set.
1766
+ const dirty = new Set();
1767
+ for (const [rid, rec] of Object.entries(records)) {
1768
+ const docRoad = docRoadById.get(rid);
1769
+ if (!docRoad) {
1770
+ dirty.add(rid);
1771
+ continue;
1772
+ }
1773
+ if (docRoad.linkRoadRefs.some(ref => !records[ref]) ||
1774
+ docRoad.linkJunctionRefs.some(ref => !docJunctionById.has(ref))) {
1775
+ dirty.add(rid);
1776
+ continue;
1777
+ }
1778
+ const laneStates = exportLaneStates(rec);
1779
+ if (!laneStates || hashRoadState(laneStates, regStatesByRoad.get(rid) ?? []) !== rec.stateHash) {
1780
+ dirty.add(rid);
1781
+ }
1782
+ }
1783
+ // Junction membership: connection roads, junction-stamped roads (plus
1784
+ // their link targets — the maneuver's incoming/outgoing roads), and roads
1785
+ // whose link references the junction.
1786
+ const members = new Map();
1787
+ const junctionStamped = new Map();
1788
+ for (const j of doc.junctions) {
1789
+ members.set(j.id, new Set(j.memberRoadIds));
1790
+ junctionStamped.set(j.id, new Set());
1791
+ }
1792
+ for (const r of doc.roads) {
1793
+ if (r.junction !== '-1') {
1794
+ const set = members.get(r.junction);
1795
+ if (set) {
1796
+ set.add(r.id);
1797
+ for (const ref of r.linkRoadRefs)
1798
+ set.add(ref);
1799
+ junctionStamped.get(r.junction).add(r.id);
1800
+ }
1801
+ }
1802
+ for (const jref of r.linkJunctionRefs)
1803
+ members.get(jref)?.add(r.id);
1804
+ }
1805
+ // Propagate to a fixpoint. A dirty junction drags only its connecting
1806
+ // (junction-stamped) roads into regeneration — clean incoming / outgoing
1807
+ // roads keep their verbatim text (with the junction link id rewritten) —
1808
+ // so a single edited road regenerates its own junctions, not the whole
1809
+ // junction graph. Regulatory shapes are atomic across the roads they touch.
1810
+ const dirtyJunctionIds = new Set();
1811
+ let changed = true;
1812
+ while (changed) {
1813
+ changed = false;
1814
+ for (const [jid, memberSet] of members) {
1815
+ let bad = false;
1816
+ for (const m of memberSet) {
1817
+ if (!records[m] || dirty.has(m)) {
1818
+ bad = true;
1819
+ break;
1820
+ }
1821
+ }
1822
+ if (!bad)
1823
+ continue;
1824
+ if (!dirtyJunctionIds.has(jid)) {
1825
+ dirtyJunctionIds.add(jid);
1826
+ changed = true;
1827
+ }
1828
+ for (const m of junctionStamped.get(jid) ?? []) {
1829
+ if (records[m] && !dirty.has(m)) {
1830
+ dirty.add(m);
1831
+ changed = true;
1832
+ }
1833
+ }
1834
+ }
1835
+ for (const reg of regShapes) {
1836
+ let bad = false;
1837
+ for (const rid of reg.touching) {
1838
+ if (dirty.has(rid)) {
1839
+ bad = true;
1840
+ break;
1841
+ }
1842
+ }
1843
+ if (!bad)
1844
+ continue;
1845
+ for (const rid of reg.touching) {
1846
+ if (!dirty.has(rid)) {
1847
+ dirty.add(rid);
1848
+ changed = true;
1849
+ }
1850
+ }
1851
+ }
1852
+ }
1853
+ const cleanRoadIds = new Set();
1854
+ for (const rid of Object.keys(records)) {
1855
+ if (!dirty.has(rid) && docRoadById.has(rid))
1856
+ cleanRoadIds.add(rid);
1857
+ }
1858
+ const verbatimLaneIds = new Set();
1859
+ for (const rid of cleanRoadIds) {
1860
+ for (const lid of records[rid].laneShapeIds)
1861
+ verbatimLaneIds.add(lid);
1862
+ }
1863
+ const consumedShapeIds = new Set();
1864
+ for (const reg of regShapes) {
1865
+ if (reg.touching.size === 0)
1866
+ continue;
1867
+ let allClean = true;
1868
+ for (const rid of reg.touching) {
1869
+ if (!cleanRoadIds.has(rid)) {
1870
+ allClean = false;
1871
+ break;
1872
+ }
1873
+ }
1874
+ if (allClean)
1875
+ consumedShapeIds.add(reg.shapeId);
1876
+ }
1877
+ const verbatimRoads = [];
1878
+ for (const r of doc.roads) {
1879
+ if (cleanRoadIds.has(r.id))
1880
+ verbatimRoads.push(r);
1881
+ }
1882
+ const verbatimJunctionTexts = [];
1883
+ for (const j of doc.junctions) {
1884
+ if (!dirtyJunctionIds.has(j.id))
1885
+ verbatimJunctionTexts.push(j.text);
1886
+ }
1887
+ // A controller stays verbatim when every signal it controls is defined in
1888
+ // a verbatim road.
1889
+ const signalRoadOf = new Map();
1890
+ for (const r of doc.roads) {
1891
+ for (const sid of r.signalIds)
1892
+ signalRoadOf.set(sid, r.id);
1893
+ }
1894
+ const verbatimControllerTexts = [];
1895
+ for (const c of doc.controllers) {
1896
+ const ok = c.signalIds.length > 0 &&
1897
+ c.signalIds.every(sid => {
1898
+ const rid = signalRoadOf.get(sid);
1899
+ return rid !== undefined && cleanRoadIds.has(rid);
1900
+ });
1901
+ if (ok)
1902
+ verbatimControllerTexts.push(c.text);
1903
+ }
1904
+ return {
1905
+ doc,
1906
+ records,
1907
+ cleanRoadIds,
1908
+ dirtyRecordedIds: dirty,
1909
+ verbatimLaneIds,
1910
+ consumedShapeIds,
1911
+ headerText: doc.headerText,
1912
+ verbatimRoads,
1913
+ dirtyJunctionIds,
1914
+ verbatimJunctionTexts,
1915
+ verbatimControllerTexts,
1916
+ idBase: Math.max(doc.maxNumericElementId, 0) + 1,
1917
+ signalIdBase: Math.max(doc.maxNumericSignalId, 0) + 1,
1918
+ controllerIdBase: Math.max(doc.maxNumericControllerId, 0) + 1,
1919
+ };
1920
+ }
894
1921
  /**
895
1922
  * Build an OpenDRIVE 1.8 XML document from a snapshot.
1923
+ *
1924
+ * With `options.sidecar` (captured by the OpenDRIVE importer), unedited
1925
+ * roads are re-emitted verbatim from the original XML; see planCarryThrough.
896
1926
  */
897
- export function exportToOpenDrive(snapshot) {
1927
+ export function exportToOpenDrive(snapshot, options = {}) {
898
1928
  const shapes = snapshot.shapes;
899
1929
  const shapeMap = buildShapeMap(shapes);
900
1930
  const lanes = [];
901
1931
  const trafficLights = [];
1932
+ const trafficSigns = [];
902
1933
  const crosswalks = [];
903
1934
  const polygons = [];
904
1935
  for (const s of shapes) {
@@ -906,6 +1937,8 @@ export function exportToOpenDrive(snapshot) {
906
1937
  lanes.push(s);
907
1938
  else if (s.type === 'traffic_light')
908
1939
  trafficLights.push(s);
1940
+ else if (s.type === 'traffic_sign')
1941
+ trafficSigns.push(s);
909
1942
  else if (s.type === 'crosswalk')
910
1943
  crosswalks.push(s);
911
1944
  else if (s.type === 'polygon') {
@@ -920,54 +1953,299 @@ export function exportToOpenDrive(snapshot) {
920
1953
  polygons.push({ shape: poly, vertices });
921
1954
  }
922
1955
  }
923
- // Road ids are assigned after geometry construction so degenerate lanes
924
- // (zero-length centerlines) neither emit empty roads nor occupy link ids.
925
- const laneIdToRoadId = new Map();
1956
+ // Carry-through: with an importer sidecar, unedited original roads are
1957
+ // re-emitted verbatim and excluded from regeneration.
1958
+ const carry = planCarryThrough(options.sidecar, shapeMap, trafficLights, trafficSigns, crosswalks);
1959
+ const regenLanes = carry ? lanes.filter(l => !carry.verbatimLaneIds.has(l.id)) : lanes;
1960
+ const regenTrafficLights = carry
1961
+ ? trafficLights.filter(t => !carry.consumedShapeIds.has(t.id))
1962
+ : trafficLights;
1963
+ const regenTrafficSigns = carry
1964
+ ? trafficSigns.filter(t => !carry.consumedShapeIds.has(t.id))
1965
+ : trafficSigns;
1966
+ const regenCrosswalks = carry
1967
+ ? crosswalks.filter(c => !carry.consumedShapeIds.has(c.id))
1968
+ : crosswalks;
926
1969
  const dateStr = new Date().toISOString();
927
1970
  const bbox = computeEnuBoundingBox(shapeMap);
928
1971
  const geoRefProj = originToProjString(snapshot.origin);
929
1972
  const lines = [];
930
1973
  lines.push(`<?xml version="1.0" encoding="UTF-8"?>`);
931
1974
  lines.push(`<OpenDRIVE>`);
932
- // OpenDRIVE 1.8 expects <geoReference> inside <header>. We always emit one —
933
- // tmerc-at-origin when snapshot.origin is set, WGS84 longlat as a fallback —
934
- // so downstream tools (esmini, RoadRunner, asam-qc-opendrive) see a defined
935
- // coordinate reference system rather than nothing. The N/S/E/W attributes
936
- // are populated from the actual point cloud so the header bbox reflects the
937
- // map extent in ENU metres.
938
- lines.push(` <header revMajor="1" revMinor="8" name="drawtonomy" version="1.0" date="${dateStr}" ` +
939
- `north="${fmt(bbox.north)}" south="${fmt(bbox.south)}" east="${fmt(bbox.east)}" west="${fmt(bbox.west)}" vendor="drawtonomy">`);
940
- lines.push(` <geoReference><![CDATA[${escapeCdata(geoRefProj)}]]></geoReference>`);
941
- lines.push(` </header>`);
1975
+ if (carry?.headerText) {
1976
+ // Carry-through keeps the original header (geoReference, bbox, vendor)
1977
+ // so an unedited round trip preserves the source coordinate frame.
1978
+ lines.push(carry.headerText);
1979
+ }
1980
+ else {
1981
+ // OpenDRIVE 1.8 expects <geoReference> inside <header>. We always emit one
1982
+ // tmerc-at-origin when snapshot.origin is set, WGS84 longlat as a fallback —
1983
+ // so downstream tools (esmini, RoadRunner, asam-qc-opendrive) see a defined
1984
+ // coordinate reference system rather than nothing. The N/S/E/W attributes
1985
+ // are populated from the actual point cloud so the header bbox reflects the
1986
+ // map extent in ENU metres.
1987
+ lines.push(` <header revMajor="1" revMinor="8" name="drawtonomy" version="1.0" date="${dateStr}" ` +
1988
+ `north="${fmt(bbox.north)}" south="${fmt(bbox.south)}" east="${fmt(bbox.east)}" west="${fmt(bbox.west)}" vendor="drawtonomy">`);
1989
+ lines.push(` <geoReference><![CDATA[${escapeCdata(geoRefProj)}]]></geoReference>`);
1990
+ lines.push(` </header>`);
1991
+ }
942
1992
  const pointOverrides = buildBoundaryAlignmentOverrides(shapeMap, lanes);
943
- const laneToGeom = new Map();
944
- for (const lane of lanes) {
945
- const geom = buildRoadGeometry(shapeMap, lane, pointOverrides);
946
- if (geom && geom.length >= 0.01 && geom.odrSamples.length >= 2) {
947
- laneToGeom.set(lane.id, geom);
1993
+ // Group laterally adjacent lanes into road bundles and build their
1994
+ // geometry. Degenerate bundles (zero-length reference lines) are dropped;
1995
+ // a multi-lane bundle whose geometry cannot be built (broken boundary
1996
+ // references) degrades to per-lane bundles so one bad lane does not drop
1997
+ // its neighbours.
1998
+ const exportBundles = [];
1999
+ for (const bundleLanes of detectBundles(regenLanes)) {
2000
+ const geom = buildBundleGeometry(shapeMap, bundleLanes, pointOverrides);
2001
+ if (geom && geom.length >= 0.01) {
2002
+ exportBundles.push({ lanes: bundleLanes, geom });
2003
+ }
2004
+ else if (bundleLanes.length > 1) {
2005
+ for (const lane of bundleLanes) {
2006
+ const g = buildBundleGeometry(shapeMap, [lane], pointOverrides);
2007
+ if (g && g.length >= 0.01)
2008
+ exportBundles.push({ lanes: [lane], geom: g });
2009
+ }
948
2010
  }
949
2011
  }
950
- let nextRoadId = 1;
951
- for (const lane of lanes) {
952
- if (laneToGeom.has(lane.id))
953
- laneIdToRoadId.set(lane.id, nextRoadId++);
2012
+ // Stable road id assignment: bundles ordered by their first lane's position
2013
+ // in the snapshot. Lane ids count -1, -2, ... left→right within a bundle.
2014
+ // Carry-through: a regenerated bundle covering exactly the lane set of a
2015
+ // dirty original road keeps that road's id, so links inside verbatim
2016
+ // neighbours stay valid without rewriting; other bundles take fresh ids
2017
+ // above every original id.
2018
+ const laneOrder = new Map();
2019
+ lanes.forEach((lane, i) => laneOrder.set(lane.id, i));
2020
+ exportBundles.sort((a, b) => Math.min(...a.lanes.map(l => laneOrder.get(l.id))) - Math.min(...b.lanes.map(l => laneOrder.get(l.id))));
2021
+ const reuseKey = (ids) => [...ids].sort().join('\n');
2022
+ const reusableRoadIds = new Map();
2023
+ if (carry) {
2024
+ for (const rid of carry.dirtyRecordedIds) {
2025
+ const rec = carry.records[rid];
2026
+ if (!rec || rec.laneShapeIds.length === 0 || !/^\d+$/.test(rid))
2027
+ continue;
2028
+ reusableRoadIds.set(reuseKey(rec.laneShapeIds), parseInt(rid, 10));
2029
+ }
954
2030
  }
955
- const junctionPlan = planJunctions(lanes, laneIdToRoadId, nextRoadId);
956
- const { roadSignals, roadObjects, roadSignalRefs, signalIdByShape } = attachShapesToRoads(shapeMap, trafficLights, crosswalks, polygons, laneToGeom, laneIdToRoadId);
957
- for (const lane of lanes) {
958
- const geom = laneToGeom.get(lane.id);
959
- if (!geom)
960
- continue;
961
- const roadId = laneIdToRoadId.get(lane.id);
962
- const signals = roadSignals.get(roadId) ?? [];
963
- const signalRefs = roadSignalRefs.get(roadId) ?? [];
964
- const objects = roadObjects.get(roadId) ?? [];
965
- lines.push(emitRoad(lane, geom, roadId, laneIdToRoadId, junctionPlan, signals, signalRefs, objects, shapeMap));
2031
+ const laneIdToRoadId = new Map();
2032
+ const laneIdToOdrLaneId = new Map();
2033
+ const roadIdByBundle = new Map();
2034
+ let nextRoadId = carry ? carry.idBase : 1;
2035
+ for (const bundle of exportBundles) {
2036
+ const key = reuseKey(bundle.lanes.map(l => l.id));
2037
+ const reused = reusableRoadIds.get(key);
2038
+ if (reused !== undefined)
2039
+ reusableRoadIds.delete(key);
2040
+ const roadId = reused ?? nextRoadId++;
2041
+ roadIdByBundle.set(bundle, roadId);
2042
+ bundle.lanes.forEach((lane, i) => {
2043
+ laneIdToRoadId.set(lane.id, roadId);
2044
+ laneIdToOdrLaneId.set(lane.id, -(i + 1));
2045
+ });
2046
+ }
2047
+ // Carry-through: lanes of verbatim roads join the connectivity id maps as
2048
+ // external endpoints, so regenerated roads link to / from them.
2049
+ const externalLanes = new Map();
2050
+ if (carry) {
2051
+ for (const rid of carry.cleanRoadIds) {
2052
+ if (!/^\d+$/.test(rid))
2053
+ continue;
2054
+ for (const lid of carry.records[rid].laneShapeIds) {
2055
+ const shape = shapeMap.get(lid);
2056
+ if (!shape)
2057
+ continue;
2058
+ const odrLaneId = parseInt(shape.props.attributes?.odr_lane_id ?? '', 10);
2059
+ if (!Number.isFinite(odrLaneId))
2060
+ continue;
2061
+ laneIdToRoadId.set(lid, parseInt(rid, 10));
2062
+ laneIdToOdrLaneId.set(lid, odrLaneId);
2063
+ externalLanes.set(lid, shape);
2064
+ }
2065
+ }
2066
+ }
2067
+ // Geometry seed for synthesized connecting roads: bundle lanes read it off
2068
+ // their fitted bundle geometry; external (verbatim) lanes off their drawn
2069
+ // boundary endpoints.
2070
+ const laneLocation = new Map();
2071
+ for (const bundle of exportBundles) {
2072
+ bundle.lanes.forEach((lane, index) => laneLocation.set(lane.id, { bundle, index }));
2073
+ }
2074
+ const connectingSourceFor = (laneShapeId) => {
2075
+ const loc = laneLocation.get(laneShapeId);
2076
+ if (loc) {
2077
+ const geom = loc.bundle.geom;
2078
+ const lastGeom = geom.planView[geom.planView.length - 1];
2079
+ const endPose = evalGeometry(lastGeom, lastGeom.length);
2080
+ // Lane boundaries sit toward -t (right of the reference direction): the
2081
+ // inner boundary of lane -(i+1) is offset by the widths of lanes 0..i-1.
2082
+ const lastIdx = geom.samplePoses.length - 1;
2083
+ let offset = 0;
2084
+ for (let m = 0; m < loc.index; m++)
2085
+ offset += geom.laneWidths[m][lastIdx];
2086
+ return {
2087
+ x: endPose.x + Math.sin(endPose.hdg) * offset,
2088
+ y: endPose.y - Math.cos(endPose.hdg) * offset,
2089
+ hdg: endPose.hdg,
2090
+ width: geom.laneWidths[loc.index][lastIdx],
2091
+ laneType: odrLaneTypeFor(loc.bundle.lanes[loc.index]),
2092
+ };
2093
+ }
2094
+ const lane = externalLanes.get(laneShapeId);
2095
+ if (!lane)
2096
+ return null;
2097
+ const left = boundaryPointsOf(shapeMap, lane.props.leftBoundaryId, lane.props.invertLeft, NO_OVERRIDES);
2098
+ const right = boundaryPointsOf(shapeMap, lane.props.rightBoundaryId, lane.props.invertRight, NO_OVERRIDES);
2099
+ if (!left || !right)
2100
+ return null;
2101
+ const ex = pxToEnuX(left[left.length - 1].x);
2102
+ const ey = pxToEnuY(left[left.length - 1].y);
2103
+ const px = pxToEnuX(left[left.length - 2].x);
2104
+ const py = pxToEnuY(left[left.length - 2].y);
2105
+ const rx = pxToEnuX(right[right.length - 1].x);
2106
+ const ry = pxToEnuY(right[right.length - 1].y);
2107
+ return {
2108
+ x: ex,
2109
+ y: ey,
2110
+ hdg: Math.atan2(ey - py, ex - px),
2111
+ width: Math.hypot(rx - ex, ry - ey),
2112
+ laneType: odrLaneTypeFor(lane),
2113
+ };
2114
+ };
2115
+ // Travel heading / width of a connecting road's target lane at its start,
2116
+ // for blending the stub onto the outgoing road (see ConnectingTarget).
2117
+ const connectingTargetFor = (laneShapeId) => {
2118
+ const loc = laneLocation.get(laneShapeId);
2119
+ if (loc) {
2120
+ const geom = loc.bundle.geom;
2121
+ if (geom.samplePoses.length === 0)
2122
+ return null;
2123
+ const pose = geom.samplePoses[0];
2124
+ // Lane boundaries sit toward -t (right of the reference direction).
2125
+ let offset = 0;
2126
+ for (let m = 0; m < loc.index; m++)
2127
+ offset += geom.laneWidths[m][0];
2128
+ return {
2129
+ x: pose.x + Math.sin(pose.hdg) * offset,
2130
+ y: pose.y - Math.cos(pose.hdg) * offset,
2131
+ hdg: pose.hdg,
2132
+ width: geom.laneWidths[loc.index][0],
2133
+ };
2134
+ }
2135
+ const lane = externalLanes.get(laneShapeId);
2136
+ if (!lane)
2137
+ return null;
2138
+ const left = boundaryPointsOf(shapeMap, lane.props.leftBoundaryId, lane.props.invertLeft, NO_OVERRIDES);
2139
+ const right = boundaryPointsOf(shapeMap, lane.props.rightBoundaryId, lane.props.invertRight, NO_OVERRIDES);
2140
+ if (!left || !right || left.length < 2 || right.length < 1)
2141
+ return null;
2142
+ const ax = pxToEnuX(left[0].x);
2143
+ const ay = pxToEnuY(left[0].y);
2144
+ const bx = pxToEnuX(left[1].x);
2145
+ const by = pxToEnuY(left[1].y);
2146
+ const rx = pxToEnuX(right[0].x);
2147
+ const ry = pxToEnuY(right[0].y);
2148
+ return {
2149
+ x: ax,
2150
+ y: ay,
2151
+ hdg: Math.atan2(by - ay, bx - ax),
2152
+ width: Math.hypot(rx - ax, ry - ay),
2153
+ };
2154
+ };
2155
+ // Full lane width at a linked contact, for the zero-width link rules:
2156
+ // bundle lanes read their fitted width samples, external (verbatim) lanes
2157
+ // measure their drawn boundary endpoints.
2158
+ const contactWidth = (laneShapeId, contact) => {
2159
+ const loc = laneLocation.get(laneShapeId);
2160
+ if (loc) {
2161
+ const widths = loc.bundle.geom.laneWidths[loc.index];
2162
+ if (!widths || widths.length === 0)
2163
+ return null;
2164
+ return contact === 'start' ? widths[0] : widths[widths.length - 1];
2165
+ }
2166
+ const lane = externalLanes.get(laneShapeId);
2167
+ if (!lane)
2168
+ return null;
2169
+ const left = boundaryPointsOf(shapeMap, lane.props.leftBoundaryId, lane.props.invertLeft, NO_OVERRIDES);
2170
+ const right = boundaryPointsOf(shapeMap, lane.props.rightBoundaryId, lane.props.invertRight, NO_OVERRIDES);
2171
+ if (!left || !right || left.length === 0 || right.length === 0)
2172
+ return null;
2173
+ const li = contact === 'start' ? left[0] : left[left.length - 1];
2174
+ const ri = contact === 'start' ? right[0] : right[right.length - 1];
2175
+ return pxToMeter(Math.hypot(ri.x - li.x, ri.y - li.y));
2176
+ };
2177
+ const plan = planConnectivity(exportBundles, laneIdToRoadId, laneIdToOdrLaneId, nextRoadId, connectingSourceFor, connectingTargetFor, contactWidth, externalLanes);
2178
+ const roads = exportBundles.map(b => ({ roadId: roadIdByBundle.get(b), geom: b.geom }));
2179
+ const { roadSignals, roadObjects, roadSignalRefs, signalIdByShape } = attachShapesToRoads(shapeMap, regenTrafficLights, regenTrafficSigns, regenCrosswalks, polygons, roads, laneIdToRoadId, laneIdToOdrLaneId, undefined, carry?.signalIdBase);
2180
+ // Verbatim road blocks first (original document order). Two minimal
2181
+ // rewrites keep their links valid; nothing else is touched:
2182
+ // - road links to a dirty road whose lanes regenerated into exactly one
2183
+ // bundle under a different id are re-pointed at that bundle;
2184
+ // - junction links to a dirty (regenerated) junction are re-pointed at the
2185
+ // synthesized junction this road participates in (junction-routed pairs
2186
+ // sharing a road always merge, so the target is unique per road).
2187
+ if (carry) {
2188
+ const rewriteMap = new Map();
2189
+ const bundleRoadOfLane = new Map();
2190
+ for (const bundle of exportBundles) {
2191
+ const rid = roadIdByBundle.get(bundle);
2192
+ for (const l of bundle.lanes)
2193
+ bundleRoadOfLane.set(l.id, rid);
2194
+ }
2195
+ for (const rid of carry.dirtyRecordedIds) {
2196
+ const rec = carry.records[rid];
2197
+ if (!rec)
2198
+ continue;
2199
+ const newIds = new Set();
2200
+ for (const lid of rec.laneShapeIds) {
2201
+ const nid = bundleRoadOfLane.get(lid);
2202
+ if (nid !== undefined)
2203
+ newIds.add(nid);
2204
+ }
2205
+ if (newIds.size === 1) {
2206
+ const nid = String([...newIds][0]);
2207
+ if (nid !== rid)
2208
+ rewriteMap.set(rid, nid);
2209
+ }
2210
+ }
2211
+ const newJunctionOfRoad = new Map();
2212
+ for (const spec of plan.connectingRoads) {
2213
+ newJunctionOfRoad.set(spec.incomingRoadId, spec.junctionId);
2214
+ newJunctionOfRoad.set(spec.outgoingRoadId, spec.junctionId);
2215
+ }
2216
+ for (const r of carry.verbatimRoads) {
2217
+ let junctionMap;
2218
+ for (const jref of r.linkJunctionRefs) {
2219
+ if (!carry.dirtyJunctionIds.has(jref))
2220
+ continue;
2221
+ const exportedId = /^\d+$/.test(r.id) ? parseInt(r.id, 10) : NaN;
2222
+ const replacement = newJunctionOfRoad.get(exportedId);
2223
+ if (replacement !== undefined) {
2224
+ junctionMap = junctionMap ?? new Map();
2225
+ junctionMap.set(jref, String(replacement));
2226
+ }
2227
+ }
2228
+ lines.push(rewriteRoadLinkTargets(r.text, rewriteMap, junctionMap ?? new Map()));
2229
+ }
2230
+ }
2231
+ for (const bundle of exportBundles) {
2232
+ const roadId = roadIdByBundle.get(bundle);
2233
+ lines.push(emitRoad(bundle, roadId, plan, roadSignals.get(roadId) ?? [], roadSignalRefs.get(roadId) ?? [], roadObjects.get(roadId) ?? [], shapeMap, laneIdToRoadId, laneIdToOdrLaneId));
2234
+ }
2235
+ // Synthesized junction connecting roads (standard incoming -> connecting ->
2236
+ // outgoing structure; see planConnectivity).
2237
+ for (const spec of plan.connectingRoads) {
2238
+ lines.push(emitConnectingRoad(spec));
2239
+ }
2240
+ // Verbatim controllers (every controlled signal lives in a verbatim road).
2241
+ if (carry) {
2242
+ for (const text of carry.verbatimControllerTexts)
2243
+ lines.push(text);
966
2244
  }
967
2245
  // Signal groups: traffic lights sharing a controllerId (one intersection)
968
2246
  // become a <controller> listing their emitted signals as <control> records.
969
2247
  const controllerGroups = new Map();
970
- for (const tl of trafficLights) {
2248
+ for (const tl of regenTrafficLights) {
971
2249
  const groupId = tl.props.controllerId;
972
2250
  if (!groupId)
973
2251
  continue;
@@ -978,7 +2256,7 @@ export function exportToOpenDrive(snapshot) {
978
2256
  group.push(signalId);
979
2257
  controllerGroups.set(groupId, group);
980
2258
  }
981
- let controllerIdCounter = 1;
2259
+ let controllerIdCounter = carry ? carry.controllerIdBase : 1;
982
2260
  for (const [groupId, signalIds] of controllerGroups) {
983
2261
  lines.push(` <controller id="${controllerIdCounter++}" name="${escapeXml(groupId)}" sequence="0">`);
984
2262
  for (const signalId of signalIds) {
@@ -986,14 +2264,24 @@ export function exportToOpenDrive(snapshot) {
986
2264
  }
987
2265
  lines.push(` </controller>`);
988
2266
  }
989
- // Synthesized junctions for branch / merge connectivity (see planJunctions).
990
- for (const junction of junctionPlan.junctions) {
2267
+ // Verbatim junctions (all member roads verbatim).
2268
+ if (carry) {
2269
+ for (const text of carry.verbatimJunctionTexts)
2270
+ lines.push(text);
2271
+ }
2272
+ // Synthesized junctions for branch / merge connectivity (see planConnectivity).
2273
+ for (const junction of plan.junctions) {
991
2274
  lines.push(` <junction id="${junction.id}" name="junction${junction.id}">`);
992
2275
  junction.connections.forEach((conn, idx) => {
993
2276
  lines.push(` <connection id="${idx}" incomingRoad="${conn.incoming}" connectingRoad="${conn.connecting}" contactPoint="start">`);
994
- lines.push(` <laneLink from="-1" to="-1"/>`);
2277
+ for (const ll of conn.laneLinks) {
2278
+ lines.push(` <laneLink from="${ll.from}" to="${ll.to}"/>`);
2279
+ }
995
2280
  lines.push(` </connection>`);
996
2281
  });
2282
+ for (const pr of junction.priorities) {
2283
+ lines.push(` <priority high="${pr.high}" low="${pr.low}"/>`);
2284
+ }
997
2285
  lines.push(` </junction>`);
998
2286
  }
999
2287
  lines.push(`</OpenDRIVE>`);