@drawtonomy/sdk 0.8.0 → 0.9.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.
@@ -1,11 +1,15 @@
1
1
  // OpenDRIVE 1.8 (.xodr) exporter — emits a road network from a snapshot.
2
2
  // No external library dependencies.
3
3
  //
4
- // Design (Phase 1):
5
- // - Each lane is emitted as an independent <road> (junctions are out of scope)
4
+ // Design:
5
+ // - Each lane is emitted as an independent <road>
6
6
  // - Reference line is the midpoints of the left/right boundary samples
7
7
  // - Each segment is a <line> geometry; lane width is a per-sample <width>
8
- // - Lane connectivity (next/prev) is written into <link>
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>
9
13
  // - Coordinate frame: canvas (x right, y down) → ENU (x right, y up); y is flipped
10
14
  import { computeCenterlineWithWidth } from './laneCenterline';
11
15
  import { originToProjString } from './projection';
@@ -92,7 +96,7 @@ function buildBoundaryAlignmentOverrides(shapeMap, lanes, epsilonPx = 30) {
92
96
  const pt = shapeMap.get(pid);
93
97
  if (!pt)
94
98
  continue;
95
- endpoints.push({ pointId: pid, x: pt.x, y: pt.y });
99
+ endpoints.push({ pointId: pid, laneId: lane.id, side, x: pt.x, y: pt.y });
96
100
  }
97
101
  };
98
102
  // Restrict to lanes that participate in a next/prev relationship.
@@ -104,22 +108,37 @@ function buildBoundaryAlignmentOverrides(shapeMap, lanes, epsilonPx = 30) {
104
108
  if (hasPrev)
105
109
  collectEndpoints(lane, 'start');
106
110
  }
107
- // Greedy clustering: group points within epsilon of each other.
111
+ // Greedy clustering: group points within epsilon of each other, with two
112
+ // 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).
118
+ // 2. Among the eligible clusters the nearest one wins, so the far end of a
119
+ // short lane clusters with its true counterpart rather than with the
120
+ // first cluster found within epsilon.
108
121
  const clusters = [];
109
122
  const eps2 = epsilonPx * epsilonPx;
110
123
  for (const ep of endpoints) {
111
- let placed = false;
124
+ let best = null;
125
+ let bestD2 = Infinity;
112
126
  for (const cluster of clusters) {
113
127
  const c0 = cluster[0];
114
128
  const dx = ep.x - c0.x;
115
129
  const dy = ep.y - c0.y;
116
- if (dx * dx + dy * dy <= eps2) {
117
- cluster.push(ep);
118
- placed = true;
119
- break;
120
- }
130
+ const d2 = dx * dx + dy * dy;
131
+ if (d2 > eps2 || d2 >= bestD2)
132
+ continue;
133
+ const conflictsOwnLane = cluster.some((m) => m.laneId === ep.laneId && m.side !== ep.side);
134
+ if (conflictsOwnLane)
135
+ continue;
136
+ best = cluster;
137
+ bestD2 = d2;
121
138
  }
122
- if (!placed)
139
+ if (best)
140
+ best.push(ep);
141
+ else
123
142
  clusters.push([ep]);
124
143
  }
125
144
  // Use each cluster centroid as the snapped position.
@@ -161,31 +180,58 @@ function emitPlanView(geom) {
161
180
  lines.push(` </planView>`);
162
181
  return lines.join('\n');
163
182
  }
164
- function emitLanes(geom, hasPrev, hasNext) {
183
+ /**
184
+ * Map a lanelet-style lane subtype to an OpenDRIVE lane type. The exact
185
+ * OpenDRIVE type wins when the lane carries `odr_type` (set by the OpenDRIVE
186
+ * importer), so imported maps round-trip their lane types.
187
+ */
188
+ const LANELET_SUBTYPE_TO_ODR_TYPE = {
189
+ road: 'driving',
190
+ highway: 'driving',
191
+ play_street: 'driving',
192
+ emergency_lane: 'shoulder',
193
+ bus_lane: 'bus',
194
+ bicycle_lane: 'biking',
195
+ walkway: 'sidewalk',
196
+ shared_walkway: 'sidewalk',
197
+ stairs: 'sidewalk',
198
+ crosswalk: 'walking',
199
+ exit: 'exit',
200
+ };
201
+ function odrLaneTypeFor(lane) {
202
+ const attrs = lane.props.attributes ?? {};
203
+ if (attrs.odr_type)
204
+ return attrs.odr_type;
205
+ return LANELET_SUBTYPE_TO_ODR_TYPE[attrs.subtype ?? ''] ?? 'driving';
206
+ }
207
+ /** Road mark type for a boundary linestring (dashed subtype -> broken). */
208
+ function roadMarkTypeFor(shapeMap, boundaryId) {
209
+ if (!boundaryId)
210
+ return 'solid';
211
+ const ls = shapeMap.get(boundaryId);
212
+ return ls?.props?.attributes?.subtype === 'dashed' ? 'broken' : 'solid';
213
+ }
214
+ function emitLanes(geom, hasPrev, hasNext, laneType, centerMark, outerMark) {
165
215
  const lines = [];
166
216
  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);
167
222
  lines.push(` <laneSection s="0">`);
168
- // Left side: id=+1 (OpenDRIVE numbers lanes positively to the left of the reference line).
169
- lines.push(` <left>`);
170
- lines.push(` <lane id="1" type="driving" level="false">`);
171
- emitLaneLink(lines, hasPrev, hasNext, 1);
172
- emitHalfWidthEntries(geom, lines);
173
- lines.push(` <roadMark sOffset="0" type="solid" weight="standard" color="white" width="0.13"/>`);
174
- lines.push(` </lane>`);
175
- lines.push(` </left>`);
176
- // Center reference line.
223
+ // Center reference line (= left boundary after the laneOffset shift).
177
224
  lines.push(` <center>`);
178
225
  lines.push(` <lane id="0" type="none" level="false">`);
179
226
  lines.push(` <link/>`);
180
- lines.push(` <roadMark sOffset="0" type="solid" weight="standard" color="standard" width="0.13"/>`);
227
+ lines.push(` <roadMark sOffset="0" type="${centerMark}" weight="standard" color="white" width="0.13"/>`);
181
228
  lines.push(` </lane>`);
182
229
  lines.push(` </center>`);
183
- // Right side: id=-1.
184
230
  lines.push(` <right>`);
185
- lines.push(` <lane id="-1" type="driving" level="false">`);
231
+ lines.push(` <lane id="-1" type="${laneType}" level="false">`);
186
232
  emitLaneLink(lines, hasPrev, hasNext, -1);
187
- emitHalfWidthEntries(geom, lines);
188
- lines.push(` <roadMark sOffset="0" type="solid" weight="standard" color="white" width="0.13"/>`);
233
+ emitWidthEntries(geom, lines);
234
+ lines.push(` <roadMark sOffset="0" type="${outerMark}" weight="standard" color="white" width="0.13"/>`);
189
235
  lines.push(` </lane>`);
190
236
  lines.push(` </right>`);
191
237
  lines.push(` </laneSection>`);
@@ -206,28 +252,139 @@ function emitLaneLink(out, hasPrev, hasNext, laneId) {
206
252
  }
207
253
  out.push(` </link>`);
208
254
  }
209
- function emitHalfWidthEntries(geom, out) {
255
+ /** Piecewise-linear laneOffset records: +width/2 toward the left boundary. */
256
+ function emitLaneOffsetEntries(geom, out) {
210
257
  for (let i = 0; i < geom.odrSamples.length - 1; i++) {
211
258
  const s = geom.odrSamples[i].s;
212
259
  const halfA = geom.odrSamples[i].width / 2;
213
260
  const halfB = geom.odrSamples[i + 1].width / 2;
214
261
  const segLen = geom.odrSamples[i + 1].s - s;
215
- // Linear fit a + b*ds (c=d=0).
216
- const a = halfA;
217
262
  const b = segLen > 1e-9 ? (halfB - halfA) / segLen : 0;
218
- out.push(` <width sOffset="${fmt(s)}" a="${fmt(a)}" b="${fmt(b)}" c="0" d="0"/>`);
263
+ out.push(` <laneOffset s="${fmt(s)}" a="${fmt(halfA)}" b="${fmt(b)}" c="0" d="0"/>`);
264
+ }
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"/>`);
219
275
  }
220
276
  }
221
- function emitLink(lane, laneIdToRoadId) {
277
+ /**
278
+ * Plan synthesized <junction> elements for branch / merge connectivity.
279
+ *
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
288
+ * junction (connected components), so a 2-in x 2-out diamond becomes one
289
+ * junction with four connections.
290
+ */
291
+ function planJunctions(lanes, laneIdToRoadId, firstJunctionId) {
292
+ const validNext = new Map();
293
+ const validPrev = new Map();
294
+ for (const lane of lanes) {
295
+ if (!laneIdToRoadId.has(lane.id))
296
+ 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)));
299
+ }
300
+ // Union-find over the lanes participating in junction-routed edges.
301
+ const parent = new Map();
302
+ const find = (x) => {
303
+ let root = x;
304
+ while (true) {
305
+ const p = parent.get(root);
306
+ if (p === undefined || p === root)
307
+ break;
308
+ root = p;
309
+ }
310
+ let cur = x;
311
+ while (cur !== root) {
312
+ const p = parent.get(cur);
313
+ parent.set(cur, root);
314
+ cur = p;
315
+ }
316
+ return root;
317
+ };
318
+ const union = (a, b) => {
319
+ if (!parent.has(a))
320
+ parent.set(a, a);
321
+ if (!parent.has(b))
322
+ parent.set(b, b);
323
+ const ra = find(a);
324
+ const rb = find(b);
325
+ if (ra !== rb)
326
+ parent.set(rb, ra);
327
+ };
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
+ };
344
+ const junctionByRoot = new Map();
345
+ 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: [] };
351
+ junctionByRoot.set(root, junction);
352
+ plan.junctions.push(junction);
353
+ }
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);
361
+ }
362
+ return plan;
363
+ }
364
+ function emitLink(lane, laneIdToRoadId, junctionPlan) {
222
365
  const lines = [];
223
366
  lines.push(` <link>`);
224
- if (lane.props.prev?.[0] && laneIdToRoadId.has(lane.props.prev[0])) {
225
- const rid = laneIdToRoadId.get(lane.props.prev[0]);
226
- lines.push(` <predecessor elementType="road" elementId="${rid}" contactPoint="end"/>`);
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"/>`);
376
+ }
227
377
  }
228
- if (lane.props.next?.[0] && laneIdToRoadId.has(lane.props.next[0])) {
229
- const rid = laneIdToRoadId.get(lane.props.next[0]);
230
- lines.push(` <successor elementType="road" elementId="${rid}" contactPoint="start"/>`);
378
+ const nextJunction = junctionPlan.nextJunction.get(lane.id);
379
+ if (nextJunction !== undefined) {
380
+ lines.push(` <successor elementType="junction" elementId="${nextJunction}"/>`);
381
+ }
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
+ }
231
388
  }
232
389
  lines.push(` </link>`);
233
390
  return lines.join('\n');
@@ -276,9 +433,11 @@ function projectToRoad(geom, xG, yG) {
276
433
  }
277
434
  return { s: bestS, t: bestT, hdg: bestHdg, distance: bestDist, clampedAtEnd: bestClamped };
278
435
  }
279
- function attachShapesToRoads(trafficLights, crosswalks, polygons, laneToGeom, laneIdToRoadId, maxAttachDistanceMeter = 50) {
436
+ function attachShapesToRoads(shapeMap, trafficLights, crosswalks, polygons, laneToGeom, laneIdToRoadId, maxAttachDistanceMeter = 50) {
280
437
  const roadSignals = new Map();
281
438
  const roadObjects = new Map();
439
+ const roadSignalRefs = new Map();
440
+ const signalIdByShape = new Map();
282
441
  let signalIdCounter = 1;
283
442
  let objectIdCounter = 1;
284
443
  const roads = [];
@@ -287,16 +446,43 @@ function attachShapesToRoads(trafficLights, crosswalks, polygons, laneToGeom, la
287
446
  if (roadId !== undefined)
288
447
  roads.push({ laneId, geom, roadId });
289
448
  });
449
+ const geomByRoadId = new Map();
450
+ for (const r of roads)
451
+ geomByRoadId.set(r.roadId, r.geom);
290
452
  for (const tl of trafficLights) {
291
453
  const xG = pxToEnuX(tl.x);
292
454
  const yG = pxToEnuY(tl.y);
455
+ // 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
+ }
293
465
  let best = null;
294
- for (const r of roads) {
295
- const proj = projectToRoad(r.geom, xG, yG);
296
- if (!best || proj.distance < best.proj.distance)
297
- best = { roadId: r.roadId, proj };
466
+ if (affectedRoadIds.size > 0) {
467
+ for (const r of roads) {
468
+ if (!affectedRoadIds.has(r.roadId))
469
+ continue;
470
+ const proj = projectToRoad(r.geom, xG, yG);
471
+ if (!best || proj.distance < best.proj.distance)
472
+ best = { roadId: r.roadId, proj };
473
+ }
298
474
  }
299
- if (!best || best.proj.distance > maxAttachDistanceMeter)
475
+ else {
476
+ // Fall back to the nearest road within the attachment distance.
477
+ for (const r of roads) {
478
+ const proj = projectToRoad(r.geom, xG, yG);
479
+ if (!best || proj.distance < best.proj.distance)
480
+ best = { roadId: r.roadId, proj };
481
+ }
482
+ if (best && best.proj.distance > maxAttachDistanceMeter)
483
+ best = null;
484
+ }
485
+ if (!best)
300
486
  continue;
301
487
  const style = tl.props.style ?? '';
302
488
  const isPed = style.startsWith('pedestrian') || style.includes('ped');
@@ -305,7 +491,7 @@ function attachShapesToRoads(trafficLights, crosswalks, polygons, laneToGeom, la
305
491
  const heightM = pxToMeter(tl.props.h);
306
492
  const widthM = pxToMeter(tl.props.w);
307
493
  const list = roadSignals.get(best.roadId) ?? [];
308
- list.push({
494
+ const entry = {
309
495
  id: signalIdCounter++,
310
496
  s: best.proj.s,
311
497
  t: best.proj.t,
@@ -317,8 +503,69 @@ function attachShapesToRoads(trafficLights, crosswalks, polygons, laneToGeom, la
317
503
  subtype: '-1',
318
504
  dynamic: 'yes',
319
505
  orientation: best.proj.t >= 0 ? '+' : '-',
320
- });
506
+ };
507
+ if (affectedRoadIds.size > 0) {
508
+ // The exporter emits one driving lane (-1) per road.
509
+ entry.validity = { fromLane: -1, toLane: -1 };
510
+ }
511
+ list.push(entry);
321
512
  roadSignals.set(best.roadId, list);
513
+ 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) {
519
+ for (const r of roads) {
520
+ if (r.roadId === best.roadId || !affectedRoadIds.has(r.roadId))
521
+ continue;
522
+ const proj = projectToRoad(r.geom, xG, yG);
523
+ const refs = roadSignalRefs.get(r.roadId) ?? [];
524
+ refs.push({
525
+ id: entry.id,
526
+ s: proj.s,
527
+ t: proj.t,
528
+ orientation: proj.t >= 0 ? '+' : '-',
529
+ });
530
+ roadSignalRefs.set(r.roadId, refs);
531
+ }
532
+ }
533
+ // Stop line: emitted on the signal's road as a conventional
534
+ // <object name="StopLine"> at the projected station of the line's midpoint.
535
+ if (tl.props.stopLineId) {
536
+ const stopLs = shapeMap.get(tl.props.stopLineId);
537
+ const pts = stopLs
538
+ ? collectPoints(shapeMap, stopLs.props.pointIds, false, new Map())
539
+ : [];
540
+ if (pts.length >= 2) {
541
+ // Carry the full polyline (ENU meters) on the signal so an importer
542
+ // can rebuild the stop-line linestring and re-link it to the signal.
543
+ entry.stopLinePoints = pts.map((p) => ({ x: pxToEnuX(p.x), y: pxToEnuY(p.y) }));
544
+ const a = pts[0];
545
+ const b = pts[pts.length - 1];
546
+ const midX = pxToEnuX((a.x + b.x) / 2);
547
+ const midY = pxToEnuY((a.y + b.y) / 2);
548
+ const geom = geomByRoadId.get(best.roadId);
549
+ const proj = projectToRoad(geom, midX, midY);
550
+ const objList = roadObjects.get(best.roadId) ?? [];
551
+ objList.push({
552
+ id: objectIdCounter++,
553
+ s: proj.s,
554
+ t: proj.t,
555
+ zOffset: 0,
556
+ // Stop lines lie across the road (like crosswalks): local hdg = π/2,
557
+ // length spanning the painted line, conventional 0.3 m paint width.
558
+ hdg: Math.PI / 2,
559
+ length: pxToMeter(Math.hypot(b.x - a.x, b.y - a.y)),
560
+ width: 0.3,
561
+ height: 0,
562
+ name: 'StopLine',
563
+ type: 'none',
564
+ orientation: 'none',
565
+ });
566
+ roadObjects.set(best.roadId, objList);
567
+ }
568
+ }
322
569
  }
323
570
  for (const cw of crosswalks) {
324
571
  const rotDeg = cw.rotation || 0;
@@ -331,13 +578,35 @@ function attachShapesToRoads(trafficLights, crosswalks, polygons, laneToGeom, la
331
578
  const cyGlobal = cw.y + cyLocal;
332
579
  const xG = pxToEnuX(cxGlobal);
333
580
  const yG = pxToEnuY(cyGlobal);
581
+ // Regulatory layer: a crosswalk that names affected lanes attaches to the
582
+ // nearest of those lanes' roads (no distance gate — the assignment is
583
+ // 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
+ }
334
590
  let best = null;
335
- for (const r of roads) {
336
- const proj = projectToRoad(r.geom, xG, yG);
337
- if (!best || proj.distance < best.proj.distance)
338
- best = { roadId: r.roadId, proj };
591
+ if (affectedRoadIds.size > 0) {
592
+ for (const r of roads) {
593
+ if (!affectedRoadIds.has(r.roadId))
594
+ continue;
595
+ const proj = projectToRoad(r.geom, xG, yG);
596
+ if (!best || proj.distance < best.proj.distance)
597
+ best = { roadId: r.roadId, proj };
598
+ }
339
599
  }
340
- if (!best || best.proj.distance > maxAttachDistanceMeter)
600
+ else {
601
+ for (const r of roads) {
602
+ const proj = projectToRoad(r.geom, xG, yG);
603
+ if (!best || proj.distance < best.proj.distance)
604
+ best = { roadId: r.roadId, proj };
605
+ }
606
+ if (best && best.proj.distance > maxAttachDistanceMeter)
607
+ best = null;
608
+ }
609
+ if (!best)
341
610
  continue;
342
611
  const dxLocal = cw.props.endX - cw.props.startX;
343
612
  const dyLocal = cw.props.endY - cw.props.startY;
@@ -359,6 +628,23 @@ function attachShapesToRoads(trafficLights, crosswalks, polygons, laneToGeom, la
359
628
  // π/2 regardless of the user-drawn axis direction.
360
629
  void relativeHdg;
361
630
  const crosswalkHdg = Math.PI / 2;
631
+ // Regulatory links (affected roads + stop line polyline) ride along as
632
+ // <userData> — OpenDRIVE's standard extension mechanism — so they survive
633
+ // an .xodr round trip. Coordinates are ENU meters.
634
+ const userData = [];
635
+ if (affectedRoadIds.size > 0) {
636
+ const links = {
637
+ affectedRoads: [...affectedRoadIds].sort((a, b) => a - b).map(String),
638
+ };
639
+ if (cw.props.stopLineId) {
640
+ const stopLs = shapeMap.get(cw.props.stopLineId);
641
+ const pts = stopLs ? collectPoints(shapeMap, stopLs.props.pointIds, false, new Map()) : [];
642
+ if (pts.length >= 2) {
643
+ links.stopLine = pts.map((p) => [roundMm(pxToEnuX(p.x)), roundMm(pxToEnuY(p.y))]);
644
+ }
645
+ }
646
+ userData.push({ code: 'crosswalkLinks', value: JSON.stringify(links) });
647
+ }
362
648
  list.push({
363
649
  id: objectIdCounter++,
364
650
  s: best.proj.s,
@@ -371,6 +657,7 @@ function attachShapesToRoads(trafficLights, crosswalks, polygons, laneToGeom, la
371
657
  name: 'crosswalk',
372
658
  type: 'crosswalk',
373
659
  orientation: 'none',
660
+ userData: userData.length ? userData : undefined,
374
661
  });
375
662
  roadObjects.set(best.roadId, list);
376
663
  }
@@ -445,19 +732,42 @@ function attachShapesToRoads(trafficLights, crosswalks, polygons, laneToGeom, la
445
732
  });
446
733
  roadObjects.set(best.roadId, list);
447
734
  }
448
- return { roadSignals, roadObjects };
735
+ return { roadSignals, roadObjects, roadSignalRefs, signalIdByShape };
449
736
  }
450
- function emitSignals(signals) {
451
- if (!signals.length)
737
+ function emitSignals(signals, references) {
738
+ if (!signals.length && !references.length)
452
739
  return ` <signals/>`;
453
740
  const lines = [];
454
741
  lines.push(` <signals>`);
455
742
  for (const s of signals) {
456
- lines.push(` <signal 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)}"/>`);
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) {
745
+ lines.push(` <signal ${attrs}>`);
746
+ if (s.validity) {
747
+ lines.push(` <validity fromLane="${s.validity.fromLane}" toLane="${s.validity.toLane}"/>`);
748
+ }
749
+ if (s.stopLinePoints) {
750
+ const json = JSON.stringify(s.stopLinePoints.map((p) => [roundMm(p.x), roundMm(p.y)]));
751
+ lines.push(` <userData code="stopLine" value="${escapeXml(json)}"/>`);
752
+ }
753
+ lines.push(` </signal>`);
754
+ }
755
+ else {
756
+ lines.push(` <signal ${attrs}/>`);
757
+ }
758
+ }
759
+ for (const ref of references) {
760
+ 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"/>`);
762
+ lines.push(` </signalReference>`);
457
763
  }
458
764
  lines.push(` </signals>`);
459
765
  return lines.join('\n');
460
766
  }
767
+ /** Round to millimeter precision for compact embedded JSON. */
768
+ function roundMm(v) {
769
+ return Math.round(v * 1000) / 1000;
770
+ }
461
771
  function emitObjects(objects) {
462
772
  if (!objects.length)
463
773
  return ` <objects/>`;
@@ -478,20 +788,89 @@ function emitObjects(objects) {
478
788
  lines.push(` </object>`);
479
789
  }
480
790
  else {
481
- lines.push(` <object id="${o.id}" s="${fmt(o.s)}" t="${fmt(o.t)}" zOffset="${fmt(o.zOffset)}" hdg="${fmt(o.hdg)}" name="${escapeXml(o.name)}" type="${o.type}" orientation="${o.orientation}" length="${fmt(o.length)}" width="${fmt(o.width)}" height="${fmt(o.height)}"/>`);
791
+ const attrs = `id="${o.id}" s="${fmt(o.s)}" t="${fmt(o.t)}" zOffset="${fmt(o.zOffset)}" hdg="${fmt(o.hdg)}" name="${escapeXml(o.name)}" type="${o.type}" orientation="${o.orientation}" length="${fmt(o.length)}" width="${fmt(o.width)}" height="${fmt(o.height)}"`;
792
+ if (o.userData?.length) {
793
+ lines.push(` <object ${attrs}>`);
794
+ for (const ud of o.userData) {
795
+ lines.push(` <userData code="${escapeXml(ud.code)}" value="${escapeXml(ud.value)}"/>`);
796
+ }
797
+ lines.push(` </object>`);
798
+ }
799
+ else {
800
+ lines.push(` <object ${attrs}/>`);
801
+ }
482
802
  }
483
803
  }
484
804
  lines.push(` </objects>`);
485
805
  return lines.join('\n');
486
806
  }
487
- function emitRoad(lane, geom, roadId, laneIdToRoadId, signals, objects) {
807
+ /**
808
+ * Lane attributes that have no OpenDRIVE representation (speed_limit only
809
+ * 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.
812
+ * `odr_*` meta attributes are excluded: they are regenerated on import.
813
+ */
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)
824
+ return null;
825
+ return ` <userData code="laneAttributes" value="${escapeXml(JSON.stringify(stash))}"/>`;
826
+ }
827
+ /**
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.
832
+ */
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)
841
+ return null;
842
+ const json = JSON.stringify(yieldRoadIds.sort((a, b) => a - b).map(String));
843
+ return ` <userData code="yieldRoads" value="${escapeXml(json)}"/>`;
844
+ }
845
+ /**
846
+ * Road length attribute. The plan-view geometries are emitted with rounded
847
+ * (6-decimal) s/length values whose cumulative extent can exceed the exact
848
+ * road length by ~1e-6, which strict consumers flag as "s too large". Use the
849
+ * emitted extent plus a tiny pad so the length always covers the geometry.
850
+ */
851
+ function emittedRoadLength(geom) {
852
+ 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));
860
+ if (end > extent)
861
+ extent = end;
862
+ }
863
+ return extent + 1e-4;
864
+ }
865
+ function emitRoad(lane, geom, roadId, laneIdToRoadId, junctionPlan, signals, signalRefs, objects, shapeMap) {
488
866
  const speed = lane.props.attributes?.speed_limit;
489
867
  const name = escapeXml(lane.props.attributes?.subtype || 'road');
490
- const hasPrev = !!(lane.props.prev?.[0] && laneIdToRoadId.has(lane.props.prev[0]));
491
- const hasNext = !!(lane.props.next?.[0] && laneIdToRoadId.has(lane.props.next[0]));
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;
492
871
  const lines = [];
493
- lines.push(` <road name="${name}" length="${fmt(geom.length)}" id="${roadId}" junction="-1">`);
494
- lines.push(emitLink(lane, laneIdToRoadId));
872
+ lines.push(` <road name="${name}" length="${fmt(emittedRoadLength(geom))}" id="${roadId}" junction="${junctionAttr}">`);
873
+ lines.push(emitLink(lane, laneIdToRoadId, junctionPlan));
495
874
  if (speed) {
496
875
  lines.push(` <type s="0" type="town">`);
497
876
  lines.push(` <speed max="${escapeXml(speed)}" unit="km/h"/>`);
@@ -500,9 +879,15 @@ function emitRoad(lane, geom, roadId, laneIdToRoadId, signals, objects) {
500
879
  lines.push(emitPlanView(geom));
501
880
  lines.push(` <elevationProfile/>`);
502
881
  lines.push(` <lateralProfile/>`);
503
- lines.push(emitLanes(geom, hasPrev, hasNext));
882
+ lines.push(emitLanes(geom, hasPrev, hasNext, odrLaneTypeFor(lane), roadMarkTypeFor(shapeMap, lane.props.leftBoundaryId), roadMarkTypeFor(shapeMap, lane.props.rightBoundaryId)));
504
883
  lines.push(emitObjects(objects));
505
- lines.push(emitSignals(signals));
884
+ lines.push(emitSignals(signals, signalRefs));
885
+ const userData = emitLaneAttributesUserData(lane);
886
+ if (userData)
887
+ lines.push(userData);
888
+ const yieldUserData = emitYieldRoadsUserData(lane, laneIdToRoadId);
889
+ if (yieldUserData)
890
+ lines.push(yieldUserData);
506
891
  lines.push(` </road>`);
507
892
  return lines.join('\n');
508
893
  }
@@ -535,9 +920,9 @@ export function exportToOpenDrive(snapshot) {
535
920
  polygons.push({ shape: poly, vertices });
536
921
  }
537
922
  }
538
- // Assign sequential road ids.
923
+ // Road ids are assigned after geometry construction so degenerate lanes
924
+ // (zero-length centerlines) neither emit empty roads nor occupy link ids.
539
925
  const laneIdToRoadId = new Map();
540
- lanes.forEach((lane, i) => laneIdToRoadId.set(lane.id, i + 1));
541
926
  const dateStr = new Date().toISOString();
542
927
  const bbox = computeEnuBoundingBox(shapeMap);
543
928
  const geoRefProj = originToProjString(snapshot.origin);
@@ -558,18 +943,58 @@ export function exportToOpenDrive(snapshot) {
558
943
  const laneToGeom = new Map();
559
944
  for (const lane of lanes) {
560
945
  const geom = buildRoadGeometry(shapeMap, lane, pointOverrides);
561
- if (geom)
946
+ if (geom && geom.length >= 0.01 && geom.odrSamples.length >= 2) {
562
947
  laneToGeom.set(lane.id, geom);
948
+ }
563
949
  }
564
- const { roadSignals, roadObjects } = attachShapesToRoads(trafficLights, crosswalks, polygons, laneToGeom, laneIdToRoadId);
950
+ let nextRoadId = 1;
951
+ for (const lane of lanes) {
952
+ if (laneToGeom.has(lane.id))
953
+ laneIdToRoadId.set(lane.id, nextRoadId++);
954
+ }
955
+ const junctionPlan = planJunctions(lanes, laneIdToRoadId, nextRoadId);
956
+ const { roadSignals, roadObjects, roadSignalRefs, signalIdByShape } = attachShapesToRoads(shapeMap, trafficLights, crosswalks, polygons, laneToGeom, laneIdToRoadId);
565
957
  for (const lane of lanes) {
566
958
  const geom = laneToGeom.get(lane.id);
567
959
  if (!geom)
568
960
  continue;
569
961
  const roadId = laneIdToRoadId.get(lane.id);
570
962
  const signals = roadSignals.get(roadId) ?? [];
963
+ const signalRefs = roadSignalRefs.get(roadId) ?? [];
571
964
  const objects = roadObjects.get(roadId) ?? [];
572
- lines.push(emitRoad(lane, geom, roadId, laneIdToRoadId, signals, objects));
965
+ lines.push(emitRoad(lane, geom, roadId, laneIdToRoadId, junctionPlan, signals, signalRefs, objects, shapeMap));
966
+ }
967
+ // Signal groups: traffic lights sharing a controllerId (one intersection)
968
+ // become a <controller> listing their emitted signals as <control> records.
969
+ const controllerGroups = new Map();
970
+ for (const tl of trafficLights) {
971
+ const groupId = tl.props.controllerId;
972
+ if (!groupId)
973
+ continue;
974
+ const signalId = signalIdByShape.get(tl.id);
975
+ if (signalId === undefined)
976
+ continue;
977
+ const group = controllerGroups.get(groupId) ?? [];
978
+ group.push(signalId);
979
+ controllerGroups.set(groupId, group);
980
+ }
981
+ let controllerIdCounter = 1;
982
+ for (const [groupId, signalIds] of controllerGroups) {
983
+ lines.push(` <controller id="${controllerIdCounter++}" name="${escapeXml(groupId)}" sequence="0">`);
984
+ for (const signalId of signalIds) {
985
+ lines.push(` <control signalId="${signalId}" type="0"/>`);
986
+ }
987
+ lines.push(` </controller>`);
988
+ }
989
+ // Synthesized junctions for branch / merge connectivity (see planJunctions).
990
+ for (const junction of junctionPlan.junctions) {
991
+ lines.push(` <junction id="${junction.id}" name="junction${junction.id}">`);
992
+ junction.connections.forEach((conn, idx) => {
993
+ lines.push(` <connection id="${idx}" incomingRoad="${conn.incoming}" connectingRoad="${conn.connecting}" contactPoint="start">`);
994
+ lines.push(` <laneLink from="-1" to="-1"/>`);
995
+ lines.push(` </connection>`);
996
+ });
997
+ lines.push(` </junction>`);
573
998
  }
574
999
  lines.push(`</OpenDRIVE>`);
575
1000
  return lines.join('\n');