@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
@@ -1,5 +1,6 @@
1
1
  // Convert a parsed OpenDRIVE model (`OdrMap`) into the shape primitives that
2
- // the drawtonomy editor consumes (points, linestrings, lanes, traffic lights).
2
+ // the drawtonomy editor consumes (points, linestrings, lanes, traffic lights,
3
+ // traffic signs, crosswalks).
3
4
  //
4
5
  // Mirrors the Lanelet2 OSM import path (`osmToShapes`): the output is the
5
6
  // same intermediate `ImportedShapes` structure, extended with a sidecar (the
@@ -29,6 +30,7 @@ import { evalPoly3, sampleReferenceLine } from './odrGeometry';
29
30
  import { sampleAtParam } from './laneCenterline';
30
31
  import { createShapeIdAllocator, } from './osmToShapes';
31
32
  import { PIXELS_PER_METER } from './units';
33
+ import { hashRoadState, } from './odrCarryThrough';
32
34
  /** Lanes narrower than this (m) carry no usable area and are skipped. */
33
35
  const WIDTH_EPS = 1e-3;
34
36
  const S_EPS = 1e-6;
@@ -219,6 +221,7 @@ export function odrToShapes(map, options = {}) {
219
221
  linestrings: [],
220
222
  lanes: [],
221
223
  trafficLights: [],
224
+ trafficSigns: [],
222
225
  crosswalks: [],
223
226
  bounds: emptyBounds(),
224
227
  sidecar: {
@@ -255,35 +258,52 @@ export function odrToShapes(map, options = {}) {
255
258
  const roadById = new Map();
256
259
  /** Roads (with their reference samples) whose signals are converted after all lanes exist. */
257
260
  const signalRoads = [];
258
- /** Lane attributes restored from <userData code="laneAttributes"> per road. */
259
261
  const restoredAttrCache = new Map();
260
- const restoredLaneAttributes = (road) => {
261
- const cached = restoredAttrCache.get(road.id);
262
- if (cached !== undefined)
263
- return cached;
264
- let parsed = null;
265
- const raw = road.userData['laneAttributes'];
266
- if (raw) {
267
- try {
268
- const obj = JSON.parse(raw);
269
- if (obj && typeof obj === 'object' && !Array.isArray(obj)) {
270
- parsed = {};
271
- for (const [k, v] of Object.entries(obj)) {
272
- // `type` is fixed to 'lanelet' and odr_* meta is regenerated.
273
- if (typeof v !== 'string' || k === 'type' || k.startsWith('odr_'))
274
- continue;
275
- parsed[k] = v;
262
+ const pickStringAttrs = (obj) => {
263
+ const out = {};
264
+ for (const [k, v] of Object.entries(obj)) {
265
+ // `type` is fixed to 'lanelet' and odr_* meta is regenerated.
266
+ if (typeof v !== 'string' || k === 'type' || k.startsWith('odr_'))
267
+ continue;
268
+ out[k] = v;
269
+ }
270
+ return Object.keys(out).length > 0 ? out : null;
271
+ };
272
+ const restoredLaneAttributes = (road, odrLaneId) => {
273
+ let entry = restoredAttrCache.get(road.id);
274
+ if (entry === undefined) {
275
+ entry = { flat: null, perLane: null };
276
+ const raw = road.userData['laneAttributes'];
277
+ if (raw) {
278
+ try {
279
+ const obj = JSON.parse(raw);
280
+ if (obj && typeof obj === 'object' && !Array.isArray(obj)) {
281
+ const flat = {};
282
+ for (const [k, v] of Object.entries(obj)) {
283
+ if (v && typeof v === 'object' && !Array.isArray(v) && /^-?\d+$/.test(k)) {
284
+ const laneAttrs = pickStringAttrs(v);
285
+ if (laneAttrs) {
286
+ entry.perLane = entry.perLane ?? new Map();
287
+ entry.perLane.set(parseInt(k, 10), laneAttrs);
288
+ }
289
+ }
290
+ else {
291
+ flat[k] = v;
292
+ }
293
+ }
294
+ entry.flat = pickStringAttrs(flat);
276
295
  }
277
- if (Object.keys(parsed).length === 0)
278
- parsed = null;
296
+ }
297
+ catch {
298
+ // Malformed userData JSON is ignored (third-party files).
279
299
  }
280
300
  }
281
- catch {
282
- // Malformed userData JSON is ignored (third-party files).
283
- }
301
+ restoredAttrCache.set(road.id, entry);
284
302
  }
285
- restoredAttrCache.set(road.id, parsed);
286
- return parsed;
303
+ const perLane = entry.perLane?.get(odrLaneId) ?? null;
304
+ if (!entry.flat && !perLane)
305
+ return null;
306
+ return { ...(entry.flat ?? {}), ...(perLane ?? {}) };
287
307
  };
288
308
  const registryKey = (roadId, sectionIdx, odrLaneId) => `${roadId}|${sectionIdx}|${odrLaneId}`;
289
309
  for (const road of roads) {
@@ -482,17 +502,22 @@ export function odrToShapes(map, options = {}) {
482
502
  }
483
503
  }
484
504
  /**
485
- * Convert traffic light signals (type 1000001/1000002) into traffic light
486
- * shapes. The position is the (s, t) station evaluated on the reference
487
- * line; `affectedLaneIds` resolves the <validity> lane range against the
488
- * lane section containing s (falling back to every driving lane of the
489
- * road), merged with the lanes of any road that re-applies the signal via
505
+ * Convert signals into shapes: type 1000001/1000002 become traffic lights,
506
+ * any other type with dynamic != "yes" becomes a static traffic sign. The
507
+ * position is the (s, t) station evaluated on the reference line;
508
+ * `affectedLaneIds` resolves the <validity> lane range against the lane
509
+ * section containing s (falling back to every driving lane of the road),
510
+ * merged with the lanes of any road that re-applies the signal via
490
511
  * <signalReference>. A <userData code="stopLine"> record is rebuilt into a
491
- * stop-line linestring and linked through `stopLineId`.
512
+ * stop-line linestring and linked through `stopLineId`; sign attributes
513
+ * stashed in <userData code="signAttributes"> (sign_code etc.) are restored.
492
514
  */
493
515
  function materializeSignals(road, samples) {
494
516
  for (const sig of road.signals) {
495
- if (!TRAFFIC_LIGHT_SIGNAL_TYPES.has(sig.type))
517
+ const isTrafficLight = TRAFFIC_LIGHT_SIGNAL_TYPES.has(sig.type);
518
+ // Dynamic signals of unknown type cannot be represented as static
519
+ // signs; they stay sidecar-only (counted in the warnings).
520
+ if (!isTrafficLight && sig.dynamic === 'yes')
496
521
  continue;
497
522
  const pose = poseAt(samples, sig.s);
498
523
  // Unit normal toward +t (left of the reference direction in ENU).
@@ -505,25 +530,71 @@ export function odrToShapes(map, options = {}) {
505
530
  continue;
506
531
  resolveAffectedLanes(ref.road, ref.s, ref.validity, affected);
507
532
  }
533
+ if (isTrafficLight) {
534
+ const data = {
535
+ id: idAllocator.next('traffic_light'),
536
+ x: enuToCanvasX(ex),
537
+ y: enuToCanvasY(ey),
538
+ // Default editor proportions (30x60 px) when the signal carries no size.
539
+ w: sig.width > 0 ? sig.width * PIXELS_PER_METER : 30,
540
+ h: sig.height > 0 ? sig.height * PIXELS_PER_METER : 60,
541
+ osmId: '',
542
+ affectedLaneIds: affected,
543
+ stopLineId: materializeStopLine(sig.userData['stopLine']),
544
+ attributes: {
545
+ type: 'traffic_light',
546
+ odr_signal_id: sig.id,
547
+ odr_road_id: road.id,
548
+ odr_signal_type: sig.type,
549
+ odr_signal_subtype: sig.subtype,
550
+ },
551
+ };
552
+ result.trafficLights.push(data);
553
+ convertedSignalCount++;
554
+ continue;
555
+ }
556
+ // Static traffic sign. Attributes stashed by the exporter in
557
+ // <userData code="signAttributes"> (sign_code, sign_type, regulatory
558
+ // element subtype, custom tags) are restored verbatim; third-party
559
+ // signs fall back to the signal name as the sign code.
560
+ const attributes = { type: 'traffic_sign' };
561
+ const rawSignAttrs = sig.userData['signAttributes'];
562
+ if (rawSignAttrs) {
563
+ try {
564
+ const obj = JSON.parse(rawSignAttrs);
565
+ if (obj && typeof obj === 'object' && !Array.isArray(obj)) {
566
+ for (const [k, v] of Object.entries(obj)) {
567
+ if (typeof v !== 'string' || k === 'type' || k.startsWith('odr_'))
568
+ continue;
569
+ attributes[k] = v;
570
+ }
571
+ }
572
+ }
573
+ catch {
574
+ // Malformed userData JSON is ignored (third-party files).
575
+ }
576
+ }
577
+ if (!attributes.sign_code && sig.name)
578
+ attributes.sign_code = sig.name;
579
+ attributes.odr_signal_id = sig.id;
580
+ attributes.odr_road_id = road.id;
581
+ attributes.odr_signal_type = sig.type;
582
+ attributes.odr_signal_subtype = sig.subtype;
583
+ if (sig.country)
584
+ attributes.odr_country = sig.country;
508
585
  const data = {
509
- id: idAllocator.next('traffic_light'),
586
+ id: idAllocator.next('traffic_sign'),
510
587
  x: enuToCanvasX(ex),
511
588
  y: enuToCanvasY(ey),
512
- // Default editor proportions (30x60 px) when the signal carries no size.
589
+ // Default editor proportions (30x30 px) when the signal carries no size.
513
590
  w: sig.width > 0 ? sig.width * PIXELS_PER_METER : 30,
514
- h: sig.height > 0 ? sig.height * PIXELS_PER_METER : 60,
591
+ h: sig.height > 0 ? sig.height * PIXELS_PER_METER : 30,
515
592
  osmId: '',
516
593
  affectedLaneIds: affected,
517
594
  stopLineId: materializeStopLine(sig.userData['stopLine']),
518
- attributes: {
519
- type: 'traffic_light',
520
- odr_signal_id: sig.id,
521
- odr_road_id: road.id,
522
- odr_signal_type: sig.type,
523
- odr_signal_subtype: sig.subtype,
524
- },
595
+ attributes,
525
596
  };
526
- result.trafficLights.push(data);
597
+ result.trafficSigns.push(data);
527
598
  convertedSignalCount++;
528
599
  }
529
600
  }
@@ -539,6 +610,36 @@ export function odrToShapes(map, options = {}) {
539
610
  }
540
611
  return out;
541
612
  };
613
+ /** Shape ids of a specific (road, ODR lane id) pair, across all sections. */
614
+ const lanesOfRoadLane = (roadId, odrLaneId) => {
615
+ const out = [];
616
+ for (const reg of lanesByRoad.get(roadId) ?? []) {
617
+ if (reg.odrLaneId === odrLaneId && !out.includes(reg.shapeId))
618
+ out.push(reg.shapeId);
619
+ }
620
+ return out;
621
+ };
622
+ /** Resolve a [[roadId, laneId], ...] JSON record to lane shape ids. */
623
+ const resolveLanePairs = (pairs) => {
624
+ const out = [];
625
+ if (!Array.isArray(pairs))
626
+ return out;
627
+ for (const entry of pairs) {
628
+ if (!Array.isArray(entry) || entry.length < 2)
629
+ continue;
630
+ const [rid, lid] = entry;
631
+ if (typeof rid !== 'string' || typeof lid !== 'string')
632
+ continue;
633
+ const odrLaneId = parseInt(lid, 10);
634
+ if (!Number.isFinite(odrLaneId))
635
+ continue;
636
+ for (const shapeId of lanesOfRoadLane(rid, odrLaneId)) {
637
+ if (!out.includes(shapeId))
638
+ out.push(shapeId);
639
+ }
640
+ }
641
+ return out;
642
+ };
542
643
  /**
543
644
  * Convert crosswalk objects into crosswalk shapes. The band center is the
544
645
  * (s, t) station on the reference line; the walking axis follows the
@@ -572,7 +673,15 @@ export function odrToShapes(map, options = {}) {
572
673
  if (rawLinks) {
573
674
  try {
574
675
  const links = JSON.parse(rawLinks);
575
- if (Array.isArray(links.affectedRoads)) {
676
+ // Current exports carry lane-precise [[roadId, laneId], ...] pairs;
677
+ // legacy files only listed road ids (=> every driving lane).
678
+ if (Array.isArray(links.affectedLanes)) {
679
+ for (const shapeId of resolveLanePairs(links.affectedLanes)) {
680
+ if (!affected.includes(shapeId))
681
+ affected.push(shapeId);
682
+ }
683
+ }
684
+ else if (Array.isArray(links.affectedRoads)) {
576
685
  for (const rid of links.affectedRoads) {
577
686
  if (typeof rid !== 'string')
578
687
  continue;
@@ -615,10 +724,38 @@ export function odrToShapes(map, options = {}) {
615
724
  for (const { road, samples } of signalRoads) {
616
725
  materializeCrosswalks(road, samples);
617
726
  }
618
- // Restore right-of-way links stashed by the exporter in
619
- // <userData code="yieldRoads">: every driving lane of the carrying road
620
- // yields priority over the driving lanes of the listed roads.
727
+ // Restore right-of-way links stashed by the exporter.
728
+ // Current exports carry <userData code="yieldLanes"> with per-lane
729
+ // { ownLaneId: [[roadId, laneId], ...] } records; legacy files carried
730
+ // <userData code="yieldRoads"> (every driving lane of the carrying road
731
+ // yields over the driving lanes of the listed roads).
621
732
  for (const road of roads) {
733
+ const rawYieldLanes = road.userData['yieldLanes'];
734
+ if (rawYieldLanes) {
735
+ let byLane;
736
+ try {
737
+ byLane = JSON.parse(rawYieldLanes);
738
+ }
739
+ catch {
740
+ continue;
741
+ }
742
+ if (!byLane || typeof byLane !== 'object' || Array.isArray(byLane))
743
+ continue;
744
+ for (const [laneIdStr, pairs] of Object.entries(byLane)) {
745
+ const odrLaneId = parseInt(laneIdStr, 10);
746
+ if (!Number.isFinite(odrLaneId))
747
+ continue;
748
+ const yieldLaneIds = resolveLanePairs(pairs);
749
+ if (yieldLaneIds.length === 0)
750
+ continue;
751
+ for (const shapeId of lanesOfRoadLane(road.id, odrLaneId)) {
752
+ const lane = laneShapeById.get(shapeId);
753
+ if (lane)
754
+ lane.yieldLaneIds = [...yieldLaneIds];
755
+ }
756
+ }
757
+ continue;
758
+ }
622
759
  const rawYield = road.userData['yieldRoads'];
623
760
  if (!rawYield)
624
761
  continue;
@@ -741,7 +878,7 @@ export function odrToShapes(map, options = {}) {
741
878
  // Lanelet tags stashed by the exporter in <userData
742
879
  // code="laneAttributes"> (speed_limit, turn_direction, location,
743
880
  // one_way=no, exact subtype, custom tags) override the defaults.
744
- ...restoredLaneAttributes(road),
881
+ ...restoredLaneAttributes(road, lane.id),
745
882
  odr_type: lane.type,
746
883
  odr_road_id: road.id,
747
884
  odr_lane_id: String(lane.id),
@@ -819,6 +956,72 @@ export function odrToShapes(map, options = {}) {
819
956
  }
820
957
  return out;
821
958
  };
959
+ /**
960
+ * Resolve a (road, contact, lane id) endpoint to materialized lanes.
961
+ *
962
+ * Within a road this is `lanesAt` (which already bridges skipped micro
963
+ * sections). When the whole road was skipped — e.g. the short synthesized
964
+ * junction connecting roads this exporter emits, or any sub-threshold road
965
+ * in third-party files — the resolution continues across the road: lane
966
+ * links are walked through its (skipped) sections to the far end, and the
967
+ * road-level link there is followed into the neighbouring road, recursively,
968
+ * so connectivity is bridged across skipped roads instead of being lost.
969
+ */
970
+ const resolveLaneEndpoints = (roadId, contact, odrLaneId, depth = 0) => {
971
+ const direct = lanesAt(roadId, contact, odrLaneId);
972
+ if (direct.length > 0)
973
+ return direct.map(reg => ({ reg, odrLaneId, contact }));
974
+ const road = roadById.get(roadId);
975
+ if (!road || depth > 4)
976
+ return [];
977
+ // Only bridge across roads with no materialized lanes at all; partially
978
+ // materialized roads are fully handled by the in-road resolution above.
979
+ if ((lanesByRoad.get(roadId) ?? []).length > 0)
980
+ return [];
981
+ const n = road.laneSections.length;
982
+ if (n === 0)
983
+ return [];
984
+ // Walk lane-level links through the skipped sections to the far end.
985
+ const farContact = contact === 'start' ? 'end' : 'start';
986
+ const dir = contact === 'start' ? 1 : -1;
987
+ let idx = contact === 'start' ? 0 : n - 1;
988
+ let ids = [odrLaneId];
989
+ for (let step = 0; step < n - 1 && ids.length > 0; step++) {
990
+ const sec = road.laneSections[idx];
991
+ const nextIds = [];
992
+ for (const id of ids) {
993
+ const lane = [...sec.left, ...sec.right].find(l => l.id === id);
994
+ if (!lane)
995
+ continue;
996
+ for (const linked of dir === 1 ? lane.successorIds : lane.predecessorIds) {
997
+ if (!nextIds.includes(linked))
998
+ nextIds.push(linked);
999
+ }
1000
+ }
1001
+ ids = nextIds;
1002
+ idx += dir;
1003
+ }
1004
+ const farSec = road.laneSections[farContact === 'start' ? 0 : n - 1];
1005
+ const link = farContact === 'end' ? road.successor : road.predecessor;
1006
+ if (!farSec || !link || link.elementType !== 'road')
1007
+ return [];
1008
+ const cpB = link.contactPoint ?? (farContact === 'end' ? 'start' : 'end');
1009
+ const out = [];
1010
+ for (const id of ids) {
1011
+ const lane = [...farSec.left, ...farSec.right].find(l => l.id === id);
1012
+ if (!lane)
1013
+ continue;
1014
+ const targetIds = farContact === 'end' ? lane.successorIds : lane.predecessorIds;
1015
+ for (const tid of targetIds) {
1016
+ for (const ep of resolveLaneEndpoints(link.elementId, cpB, tid, depth + 1)) {
1017
+ if (!out.some(o => o.reg === ep.reg && o.odrLaneId === ep.odrLaneId && o.contact === ep.contact)) {
1018
+ out.push(ep);
1019
+ }
1020
+ }
1021
+ }
1022
+ }
1023
+ return out;
1024
+ };
822
1025
  /**
823
1026
  * Link two lanes meeting at a shared contact, respecting travel direction:
824
1027
  * right lanes (id < 0) travel toward the road's end, left lanes (id > 0)
@@ -887,10 +1090,10 @@ export function odrToShapes(map, options = {}) {
887
1090
  return;
888
1091
  for (const lane of [...sec.left, ...sec.right]) {
889
1092
  const targetIds = cpA === 'end' ? lane.successorIds : lane.predecessorIds;
890
- for (const a of lanesAt(roadA.id, cpA, lane.id)) {
1093
+ for (const a of resolveLaneEndpoints(roadA.id, cpA, lane.id)) {
891
1094
  for (const toId of targetIds) {
892
- for (const b of lanesAt(roadB.id, cpB, toId)) {
893
- linkLanes(a, lane.id, cpA, b, toId, cpB);
1095
+ for (const b of resolveLaneEndpoints(roadB.id, cpB, toId)) {
1096
+ linkLanes(a.reg, a.odrLaneId, a.contact, b.reg, b.odrLaneId, b.contact);
894
1097
  }
895
1098
  }
896
1099
  }
@@ -919,15 +1122,101 @@ export function odrToShapes(map, options = {}) {
919
1122
  contacts.push('end'); // Tolerant default.
920
1123
  for (const cpA of contacts) {
921
1124
  for (const ll of conn.laneLinks) {
922
- for (const a of lanesAt(roadA.id, cpA, ll.from)) {
923
- for (const b of lanesAt(roadC.id, conn.contactPoint, ll.to)) {
924
- linkLanes(a, ll.from, cpA, b, ll.to, conn.contactPoint);
1125
+ for (const a of resolveLaneEndpoints(roadA.id, cpA, ll.from)) {
1126
+ for (const b of resolveLaneEndpoints(roadC.id, conn.contactPoint, ll.to)) {
1127
+ linkLanes(a.reg, a.odrLaneId, a.contact, b.reg, b.odrLaneId, b.contact);
925
1128
  }
926
1129
  }
927
1130
  }
928
1131
  }
929
1132
  }
930
1133
  }
1134
+ // Hidden lane links: edges the exporter could not express as standard
1135
+ // <link>/<laneLink> records because a contact width is zero (the OpenDRIVE
1136
+ // zero-width / appearing-lane link rules), stashed per road as
1137
+ // <userData code="hiddenLaneLinks" value="[{fr,fl,tr,tl},...]"> with
1138
+ // from-road / from-lane / to-road / to-lane ids (from end -> to start in
1139
+ // travel direction). Restored here into next/prev like any other link.
1140
+ for (const road of roads) {
1141
+ const raw = road.userData['hiddenLaneLinks'];
1142
+ if (!raw)
1143
+ continue;
1144
+ let recs;
1145
+ try {
1146
+ recs = JSON.parse(raw);
1147
+ }
1148
+ catch {
1149
+ continue; // Malformed userData JSON is ignored (third-party files).
1150
+ }
1151
+ if (!Array.isArray(recs))
1152
+ continue;
1153
+ for (const rec of recs) {
1154
+ if (!rec || typeof rec !== 'object')
1155
+ continue;
1156
+ const { fr, fl, tr, tl } = rec;
1157
+ if (typeof fl !== 'number' || typeof tl !== 'number')
1158
+ continue;
1159
+ const fromRoad = fr === undefined ? road.id : String(fr);
1160
+ const toRoad = tr === undefined ? road.id : String(tr);
1161
+ for (const a of resolveLaneEndpoints(fromRoad, 'end', fl)) {
1162
+ for (const b of resolveLaneEndpoints(toRoad, 'start', tl)) {
1163
+ linkLanes(a.reg, a.odrLaneId, a.contact, b.reg, b.odrLaneId, b.contact);
1164
+ }
1165
+ }
1166
+ }
1167
+ }
1168
+ // Junction <priority high low> records restore right-of-way links: the
1169
+ // lanes standing in for the prioritized connecting road gain yieldLaneIds
1170
+ // over the lanes standing in for the yielding one. A materialized
1171
+ // connecting road is represented by its own lanes; a skipped (short
1172
+ // synthesized) one resolves through its predecessor link to the incoming
1173
+ // lanes the maneuver started from — the lanes the exporter originally read
1174
+ // the yieldLaneIds off. Merged with any userData-restored links above.
1175
+ const priorityRoadLanes = (roadId) => {
1176
+ const own = lanesByRoad.get(roadId) ?? [];
1177
+ if (own.length > 0) {
1178
+ const out = [];
1179
+ for (const reg of own) {
1180
+ if (!out.includes(reg.shapeId))
1181
+ out.push(reg.shapeId);
1182
+ }
1183
+ return out;
1184
+ }
1185
+ const road = roadById.get(roadId);
1186
+ if (!road)
1187
+ return [];
1188
+ const lastSec = road.laneSections[road.laneSections.length - 1];
1189
+ if (!lastSec)
1190
+ return [];
1191
+ const out = [];
1192
+ for (const lane of [...lastSec.left, ...lastSec.right]) {
1193
+ for (const ep of resolveLaneEndpoints(roadId, 'end', lane.id)) {
1194
+ if (!out.includes(ep.reg.shapeId))
1195
+ out.push(ep.reg.shapeId);
1196
+ }
1197
+ }
1198
+ return out;
1199
+ };
1200
+ for (const junction of map.junctions) {
1201
+ for (const pr of junction.priorities) {
1202
+ const highLanes = priorityRoadLanes(pr.high);
1203
+ const lowLanes = priorityRoadLanes(pr.low);
1204
+ if (highLanes.length === 0 || lowLanes.length === 0)
1205
+ continue;
1206
+ for (const shapeId of highLanes) {
1207
+ const lane = laneShapeById.get(shapeId);
1208
+ if (!lane)
1209
+ continue;
1210
+ const merged = lane.yieldLaneIds ?? [];
1211
+ for (const lowId of lowLanes) {
1212
+ if (lowId !== shapeId && !merged.includes(lowId))
1213
+ merged.push(lowId);
1214
+ }
1215
+ if (merged.length > 0)
1216
+ lane.yieldLaneIds = merged;
1217
+ }
1218
+ }
1219
+ }
931
1220
  // ---- Round-trip fidelity post-processing ----
932
1221
  // 1. Boundaries that are geometrically one line (each exported road carries
933
1222
  // its own copy of a boundary shared with its neighbour) collapse into a
@@ -938,6 +1227,107 @@ export function odrToShapes(map, options = {}) {
938
1227
  dedupeSharedBoundaries(result);
939
1228
  weldConnectedLaneContacts(result);
940
1229
  removeOrphanPoints(result);
1230
+ // ---- Carry-through records ----
1231
+ // Per-road state hashes for `exportToOpenDrive({ sidecar })`: a road whose
1232
+ // hash still matches at export time was not edited and is re-emitted
1233
+ // verbatim from the sidecar XML. Hashes are taken AFTER all post-processing
1234
+ // (dedupe / weld / orphan removal) so they describe exactly the shapes the
1235
+ // editor will hold; the exporter recomputes them from the live shapes.
1236
+ {
1237
+ const recPointById = new Map(result.points.map(p => [p.id, p]));
1238
+ const recLsById = new Map(result.linestrings.map(l => [l.id, l]));
1239
+ const boundaryPts = (lsId, invert) => {
1240
+ if (!lsId)
1241
+ return null;
1242
+ const ls = recLsById.get(lsId);
1243
+ if (!ls)
1244
+ return null;
1245
+ const ids = invert ? [...ls.pointIds].reverse() : ls.pointIds;
1246
+ const pts = [];
1247
+ for (const pid of ids) {
1248
+ const p = recPointById.get(pid);
1249
+ if (p)
1250
+ pts.push({ x: p.x, y: p.y });
1251
+ }
1252
+ return pts.length >= 2 ? pts : null;
1253
+ };
1254
+ const laneRoadOf = new Map();
1255
+ for (const [roadId, regs] of lanesByRoad) {
1256
+ for (const reg of regs)
1257
+ laneRoadOf.set(reg.shapeId, roadId);
1258
+ }
1259
+ // Regulatory shapes touching a road: attached to it (odr_road_id) or
1260
+ // affecting any of its lanes. Mirrored by the exporter's hash builder.
1261
+ const regStatesByRoad = new Map();
1262
+ const addRegState = (state, affected, own) => {
1263
+ const touching = new Set();
1264
+ if (own && roadById.has(own))
1265
+ touching.add(own);
1266
+ for (const lid of affected) {
1267
+ const rid = laneRoadOf.get(lid);
1268
+ if (rid)
1269
+ touching.add(rid);
1270
+ }
1271
+ for (const rid of touching) {
1272
+ const list = regStatesByRoad.get(rid) ?? [];
1273
+ list.push(state);
1274
+ regStatesByRoad.set(rid, list);
1275
+ }
1276
+ };
1277
+ for (const tl of result.trafficLights) {
1278
+ addRegState({
1279
+ kind: 'traffic_light',
1280
+ shapeId: tl.id,
1281
+ numbers: [tl.x, tl.y, tl.w, tl.h, 0],
1282
+ attributes: tl.attributes,
1283
+ affectedLaneIds: tl.affectedLaneIds,
1284
+ stopLinePts: boundaryPts(tl.stopLineId, false),
1285
+ controllerId: '',
1286
+ }, tl.affectedLaneIds, tl.attributes['odr_road_id']);
1287
+ }
1288
+ for (const ts of result.trafficSigns) {
1289
+ addRegState({
1290
+ kind: 'traffic_sign',
1291
+ shapeId: ts.id,
1292
+ numbers: [ts.x, ts.y, ts.w, ts.h, 0],
1293
+ attributes: ts.attributes,
1294
+ affectedLaneIds: ts.affectedLaneIds,
1295
+ stopLinePts: boundaryPts(ts.stopLineId, false),
1296
+ controllerId: '',
1297
+ }, ts.affectedLaneIds, ts.attributes['odr_road_id']);
1298
+ }
1299
+ for (const cw of result.crosswalks) {
1300
+ addRegState({
1301
+ kind: 'crosswalk',
1302
+ shapeId: cw.id,
1303
+ numbers: [cw.x, cw.y, cw.startX, cw.startY, cw.endX, cw.endY, cw.crosswalkWidth, 0],
1304
+ attributes: cw.attributes,
1305
+ affectedLaneIds: cw.affectedLaneIds,
1306
+ stopLinePts: boundaryPts(cw.stopLineId, false),
1307
+ controllerId: '',
1308
+ }, cw.affectedLaneIds, cw.attributes['odr_road_id']);
1309
+ }
1310
+ const roadRecords = {};
1311
+ for (const road of roads) {
1312
+ const regLanes = lanesByRoad.get(road.id) ?? [];
1313
+ const laneStates = regLanes.map(reg => {
1314
+ const lane = laneShapeById.get(reg.shapeId);
1315
+ return {
1316
+ leftPts: boundaryPts(lane.leftBoundaryId, lane.invertLeft),
1317
+ rightPts: boundaryPts(lane.rightBoundaryId, lane.invertRight),
1318
+ attributes: lane.attributes,
1319
+ next: lane.next,
1320
+ prev: lane.prev,
1321
+ yieldLaneIds: lane.yieldLaneIds ?? [],
1322
+ };
1323
+ });
1324
+ roadRecords[road.id] = {
1325
+ laneShapeIds: regLanes.map(r => r.shapeId),
1326
+ stateHash: hashRoadState(laneStates, regStatesByRoad.get(road.id) ?? []),
1327
+ };
1328
+ }
1329
+ result.sidecar.roadRecords = roadRecords;
1330
+ }
941
1331
  // ---- Aggregated warnings ----
942
1332
  if (elevationRoads > 0) {
943
1333
  warnings.push(`Elevation profiles on ${elevationRoads} road(s) were flattened to 2D.`);