@drawtonomy/sdk 0.7.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,13 +1,18 @@
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';
15
+ import { originToProjString } from './projection';
11
16
  import { escapeXml, fmt, pxToEnuX, pxToEnuY, pxToMeter } from './units';
12
17
  /** O(1) shape lookup by id. */
13
18
  function buildShapeMap(shapes) {
@@ -91,7 +96,7 @@ function buildBoundaryAlignmentOverrides(shapeMap, lanes, epsilonPx = 30) {
91
96
  const pt = shapeMap.get(pid);
92
97
  if (!pt)
93
98
  continue;
94
- 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 });
95
100
  }
96
101
  };
97
102
  // Restrict to lanes that participate in a next/prev relationship.
@@ -103,22 +108,37 @@ function buildBoundaryAlignmentOverrides(shapeMap, lanes, epsilonPx = 30) {
103
108
  if (hasPrev)
104
109
  collectEndpoints(lane, 'start');
105
110
  }
106
- // 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.
107
121
  const clusters = [];
108
122
  const eps2 = epsilonPx * epsilonPx;
109
123
  for (const ep of endpoints) {
110
- let placed = false;
124
+ let best = null;
125
+ let bestD2 = Infinity;
111
126
  for (const cluster of clusters) {
112
127
  const c0 = cluster[0];
113
128
  const dx = ep.x - c0.x;
114
129
  const dy = ep.y - c0.y;
115
- if (dx * dx + dy * dy <= eps2) {
116
- cluster.push(ep);
117
- placed = true;
118
- break;
119
- }
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;
120
138
  }
121
- if (!placed)
139
+ if (best)
140
+ best.push(ep);
141
+ else
122
142
  clusters.push([ep]);
123
143
  }
124
144
  // Use each cluster centroid as the snapped position.
@@ -160,31 +180,58 @@ function emitPlanView(geom) {
160
180
  lines.push(` </planView>`);
161
181
  return lines.join('\n');
162
182
  }
163
- 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) {
164
215
  const lines = [];
165
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);
166
222
  lines.push(` <laneSection s="0">`);
167
- // Left side: id=+1 (OpenDRIVE numbers lanes positively to the left of the reference line).
168
- lines.push(` <left>`);
169
- lines.push(` <lane id="1" type="driving" level="false">`);
170
- emitLaneLink(lines, hasPrev, hasNext, 1);
171
- emitHalfWidthEntries(geom, lines);
172
- lines.push(` <roadMark sOffset="0" type="solid" weight="standard" color="white" width="0.13"/>`);
173
- lines.push(` </lane>`);
174
- lines.push(` </left>`);
175
- // Center reference line.
223
+ // Center reference line (= left boundary after the laneOffset shift).
176
224
  lines.push(` <center>`);
177
225
  lines.push(` <lane id="0" type="none" level="false">`);
178
226
  lines.push(` <link/>`);
179
- 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"/>`);
180
228
  lines.push(` </lane>`);
181
229
  lines.push(` </center>`);
182
- // Right side: id=-1.
183
230
  lines.push(` <right>`);
184
- lines.push(` <lane id="-1" type="driving" level="false">`);
231
+ lines.push(` <lane id="-1" type="${laneType}" level="false">`);
185
232
  emitLaneLink(lines, hasPrev, hasNext, -1);
186
- emitHalfWidthEntries(geom, lines);
187
- 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"/>`);
188
235
  lines.push(` </lane>`);
189
236
  lines.push(` </right>`);
190
237
  lines.push(` </laneSection>`);
@@ -205,28 +252,139 @@ function emitLaneLink(out, hasPrev, hasNext, laneId) {
205
252
  }
206
253
  out.push(` </link>`);
207
254
  }
208
- function emitHalfWidthEntries(geom, out) {
255
+ /** Piecewise-linear laneOffset records: +width/2 toward the left boundary. */
256
+ function emitLaneOffsetEntries(geom, out) {
209
257
  for (let i = 0; i < geom.odrSamples.length - 1; i++) {
210
258
  const s = geom.odrSamples[i].s;
211
259
  const halfA = geom.odrSamples[i].width / 2;
212
260
  const halfB = geom.odrSamples[i + 1].width / 2;
213
261
  const segLen = geom.odrSamples[i + 1].s - s;
214
- // Linear fit a + b*ds (c=d=0).
215
- const a = halfA;
216
262
  const b = segLen > 1e-9 ? (halfB - halfA) / segLen : 0;
217
- 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"/>`);
275
+ }
276
+ }
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);
218
361
  }
362
+ return plan;
219
363
  }
220
- function emitLink(lane, laneIdToRoadId) {
364
+ function emitLink(lane, laneIdToRoadId, junctionPlan) {
221
365
  const lines = [];
222
366
  lines.push(` <link>`);
223
- if (lane.props.prev?.[0] && laneIdToRoadId.has(lane.props.prev[0])) {
224
- const rid = laneIdToRoadId.get(lane.props.prev[0]);
225
- 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
+ }
377
+ }
378
+ const nextJunction = junctionPlan.nextJunction.get(lane.id);
379
+ if (nextJunction !== undefined) {
380
+ lines.push(` <successor elementType="junction" elementId="${nextJunction}"/>`);
226
381
  }
227
- if (lane.props.next?.[0] && laneIdToRoadId.has(lane.props.next[0])) {
228
- const rid = laneIdToRoadId.get(lane.props.next[0]);
229
- lines.push(` <successor elementType="road" elementId="${rid}" contactPoint="start"/>`);
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
+ }
230
388
  }
231
389
  lines.push(` </link>`);
232
390
  return lines.join('\n');
@@ -275,9 +433,11 @@ function projectToRoad(geom, xG, yG) {
275
433
  }
276
434
  return { s: bestS, t: bestT, hdg: bestHdg, distance: bestDist, clampedAtEnd: bestClamped };
277
435
  }
278
- function attachShapesToRoads(trafficLights, crosswalks, polygons, laneToGeom, laneIdToRoadId, maxAttachDistanceMeter = 50) {
436
+ function attachShapesToRoads(shapeMap, trafficLights, crosswalks, polygons, laneToGeom, laneIdToRoadId, maxAttachDistanceMeter = 50) {
279
437
  const roadSignals = new Map();
280
438
  const roadObjects = new Map();
439
+ const roadSignalRefs = new Map();
440
+ const signalIdByShape = new Map();
281
441
  let signalIdCounter = 1;
282
442
  let objectIdCounter = 1;
283
443
  const roads = [];
@@ -286,16 +446,43 @@ function attachShapesToRoads(trafficLights, crosswalks, polygons, laneToGeom, la
286
446
  if (roadId !== undefined)
287
447
  roads.push({ laneId, geom, roadId });
288
448
  });
449
+ const geomByRoadId = new Map();
450
+ for (const r of roads)
451
+ geomByRoadId.set(r.roadId, r.geom);
289
452
  for (const tl of trafficLights) {
290
453
  const xG = pxToEnuX(tl.x);
291
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
+ }
292
465
  let best = null;
293
- for (const r of roads) {
294
- const proj = projectToRoad(r.geom, xG, yG);
295
- if (!best || proj.distance < best.proj.distance)
296
- 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
+ }
297
474
  }
298
- 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)
299
486
  continue;
300
487
  const style = tl.props.style ?? '';
301
488
  const isPed = style.startsWith('pedestrian') || style.includes('ped');
@@ -304,7 +491,7 @@ function attachShapesToRoads(trafficLights, crosswalks, polygons, laneToGeom, la
304
491
  const heightM = pxToMeter(tl.props.h);
305
492
  const widthM = pxToMeter(tl.props.w);
306
493
  const list = roadSignals.get(best.roadId) ?? [];
307
- list.push({
494
+ const entry = {
308
495
  id: signalIdCounter++,
309
496
  s: best.proj.s,
310
497
  t: best.proj.t,
@@ -316,8 +503,69 @@ function attachShapesToRoads(trafficLights, crosswalks, polygons, laneToGeom, la
316
503
  subtype: '-1',
317
504
  dynamic: 'yes',
318
505
  orientation: best.proj.t >= 0 ? '+' : '-',
319
- });
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);
320
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
+ }
321
569
  }
322
570
  for (const cw of crosswalks) {
323
571
  const rotDeg = cw.rotation || 0;
@@ -330,13 +578,35 @@ function attachShapesToRoads(trafficLights, crosswalks, polygons, laneToGeom, la
330
578
  const cyGlobal = cw.y + cyLocal;
331
579
  const xG = pxToEnuX(cxGlobal);
332
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
+ }
333
590
  let best = null;
334
- for (const r of roads) {
335
- const proj = projectToRoad(r.geom, xG, yG);
336
- if (!best || proj.distance < best.proj.distance)
337
- 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
+ }
338
599
  }
339
- 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)
340
610
  continue;
341
611
  const dxLocal = cw.props.endX - cw.props.startX;
342
612
  const dyLocal = cw.props.endY - cw.props.startY;
@@ -358,6 +628,23 @@ function attachShapesToRoads(trafficLights, crosswalks, polygons, laneToGeom, la
358
628
  // π/2 regardless of the user-drawn axis direction.
359
629
  void relativeHdg;
360
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
+ }
361
648
  list.push({
362
649
  id: objectIdCounter++,
363
650
  s: best.proj.s,
@@ -370,6 +657,7 @@ function attachShapesToRoads(trafficLights, crosswalks, polygons, laneToGeom, la
370
657
  name: 'crosswalk',
371
658
  type: 'crosswalk',
372
659
  orientation: 'none',
660
+ userData: userData.length ? userData : undefined,
373
661
  });
374
662
  roadObjects.set(best.roadId, list);
375
663
  }
@@ -444,19 +732,42 @@ function attachShapesToRoads(trafficLights, crosswalks, polygons, laneToGeom, la
444
732
  });
445
733
  roadObjects.set(best.roadId, list);
446
734
  }
447
- return { roadSignals, roadObjects };
735
+ return { roadSignals, roadObjects, roadSignalRefs, signalIdByShape };
448
736
  }
449
- function emitSignals(signals) {
450
- if (!signals.length)
737
+ function emitSignals(signals, references) {
738
+ if (!signals.length && !references.length)
451
739
  return ` <signals/>`;
452
740
  const lines = [];
453
741
  lines.push(` <signals>`);
454
742
  for (const s of signals) {
455
- 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>`);
456
763
  }
457
764
  lines.push(` </signals>`);
458
765
  return lines.join('\n');
459
766
  }
767
+ /** Round to millimeter precision for compact embedded JSON. */
768
+ function roundMm(v) {
769
+ return Math.round(v * 1000) / 1000;
770
+ }
460
771
  function emitObjects(objects) {
461
772
  if (!objects.length)
462
773
  return ` <objects/>`;
@@ -477,20 +788,89 @@ function emitObjects(objects) {
477
788
  lines.push(` </object>`);
478
789
  }
479
790
  else {
480
- 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
+ }
481
802
  }
482
803
  }
483
804
  lines.push(` </objects>`);
484
805
  return lines.join('\n');
485
806
  }
486
- 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) {
487
866
  const speed = lane.props.attributes?.speed_limit;
488
867
  const name = escapeXml(lane.props.attributes?.subtype || 'road');
489
- const hasPrev = !!(lane.props.prev?.[0] && laneIdToRoadId.has(lane.props.prev[0]));
490
- 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;
491
871
  const lines = [];
492
- lines.push(` <road name="${name}" length="${fmt(geom.length)}" id="${roadId}" junction="-1">`);
493
- 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));
494
874
  if (speed) {
495
875
  lines.push(` <type s="0" type="town">`);
496
876
  lines.push(` <speed max="${escapeXml(speed)}" unit="km/h"/>`);
@@ -499,9 +879,15 @@ function emitRoad(lane, geom, roadId, laneIdToRoadId, signals, objects) {
499
879
  lines.push(emitPlanView(geom));
500
880
  lines.push(` <elevationProfile/>`);
501
881
  lines.push(` <lateralProfile/>`);
502
- 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)));
503
883
  lines.push(emitObjects(objects));
504
- 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);
505
891
  lines.push(` </road>`);
506
892
  return lines.join('\n');
507
893
  }
@@ -534,32 +920,124 @@ export function exportToOpenDrive(snapshot) {
534
920
  polygons.push({ shape: poly, vertices });
535
921
  }
536
922
  }
537
- // 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.
538
925
  const laneIdToRoadId = new Map();
539
- lanes.forEach((lane, i) => laneIdToRoadId.set(lane.id, i + 1));
540
926
  const dateStr = new Date().toISOString();
927
+ const bbox = computeEnuBoundingBox(shapeMap);
928
+ const geoRefProj = originToProjString(snapshot.origin);
541
929
  const lines = [];
542
930
  lines.push(`<?xml version="1.0" encoding="UTF-8"?>`);
543
931
  lines.push(`<OpenDRIVE>`);
544
- lines.push(` <header revMajor="1" revMinor="8" name="drawtonomy" version="1.0" date="${dateStr}" north="0" south="0" east="0" west="0" vendor="drawtonomy"/>`);
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>`);
545
942
  const pointOverrides = buildBoundaryAlignmentOverrides(shapeMap, lanes);
546
943
  const laneToGeom = new Map();
547
944
  for (const lane of lanes) {
548
945
  const geom = buildRoadGeometry(shapeMap, lane, pointOverrides);
549
- if (geom)
946
+ if (geom && geom.length >= 0.01 && geom.odrSamples.length >= 2) {
550
947
  laneToGeom.set(lane.id, geom);
948
+ }
949
+ }
950
+ let nextRoadId = 1;
951
+ for (const lane of lanes) {
952
+ if (laneToGeom.has(lane.id))
953
+ laneIdToRoadId.set(lane.id, nextRoadId++);
551
954
  }
552
- const { roadSignals, roadObjects } = attachShapesToRoads(trafficLights, crosswalks, polygons, laneToGeom, laneIdToRoadId);
955
+ const junctionPlan = planJunctions(lanes, laneIdToRoadId, nextRoadId);
956
+ const { roadSignals, roadObjects, roadSignalRefs, signalIdByShape } = attachShapesToRoads(shapeMap, trafficLights, crosswalks, polygons, laneToGeom, laneIdToRoadId);
553
957
  for (const lane of lanes) {
554
958
  const geom = laneToGeom.get(lane.id);
555
959
  if (!geom)
556
960
  continue;
557
961
  const roadId = laneIdToRoadId.get(lane.id);
558
962
  const signals = roadSignals.get(roadId) ?? [];
963
+ const signalRefs = roadSignalRefs.get(roadId) ?? [];
559
964
  const objects = roadObjects.get(roadId) ?? [];
560
- 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>`);
561
998
  }
562
999
  lines.push(`</OpenDRIVE>`);
563
1000
  return lines.join('\n');
564
1001
  }
1002
+ /**
1003
+ * Compute the axis-aligned bounding box of all point shapes in ENU metres.
1004
+ * Used to populate OpenDRIVE <header> north/south/east/west attributes.
1005
+ * Returns zeros when the snapshot has no points.
1006
+ */
1007
+ function computeEnuBoundingBox(shapeMap) {
1008
+ let minX = Infinity;
1009
+ let maxX = -Infinity;
1010
+ let minY = Infinity;
1011
+ let maxY = -Infinity;
1012
+ for (const s of shapeMap.values()) {
1013
+ if (s.type !== 'point')
1014
+ continue;
1015
+ if (s.x < minX)
1016
+ minX = s.x;
1017
+ if (s.x > maxX)
1018
+ maxX = s.x;
1019
+ if (s.y < minY)
1020
+ minY = s.y;
1021
+ if (s.y > maxY)
1022
+ maxY = s.y;
1023
+ }
1024
+ if (!Number.isFinite(minX)) {
1025
+ return { north: 0, south: 0, east: 0, west: 0 };
1026
+ }
1027
+ // Canvas y points down, ENU y points up — flip when reporting bounds.
1028
+ return {
1029
+ west: pxToEnuX(minX),
1030
+ east: pxToEnuX(maxX),
1031
+ south: pxToEnuY(maxY),
1032
+ north: pxToEnuY(minY),
1033
+ };
1034
+ }
1035
+ /**
1036
+ * Escape a string so it can appear safely inside an XML CDATA section. The
1037
+ * only character sequence that ends a CDATA section is `]]>`, so we split it
1038
+ * across two CDATA sections.
1039
+ */
1040
+ function escapeCdata(s) {
1041
+ return s.replace(/]]>/g, ']]]]><![CDATA[>');
1042
+ }
565
1043
  //# sourceMappingURL=opendrive.js.map