@drawtonomy/sdk 0.9.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -29,6 +29,7 @@ import { evalPoly3, sampleReferenceLine } from './odrGeometry';
29
29
  import { sampleAtParam } from './laneCenterline';
30
30
  import { createShapeIdAllocator, } from './osmToShapes';
31
31
  import { PIXELS_PER_METER } from './units';
32
+ import { hashRoadState, } from './odrCarryThrough';
32
33
  /** Lanes narrower than this (m) carry no usable area and are skipped. */
33
34
  const WIDTH_EPS = 1e-3;
34
35
  const S_EPS = 1e-6;
@@ -255,35 +256,52 @@ export function odrToShapes(map, options = {}) {
255
256
  const roadById = new Map();
256
257
  /** Roads (with their reference samples) whose signals are converted after all lanes exist. */
257
258
  const signalRoads = [];
258
- /** Lane attributes restored from <userData code="laneAttributes"> per road. */
259
259
  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;
260
+ const pickStringAttrs = (obj) => {
261
+ const out = {};
262
+ for (const [k, v] of Object.entries(obj)) {
263
+ // `type` is fixed to 'lanelet' and odr_* meta is regenerated.
264
+ if (typeof v !== 'string' || k === 'type' || k.startsWith('odr_'))
265
+ continue;
266
+ out[k] = v;
267
+ }
268
+ return Object.keys(out).length > 0 ? out : null;
269
+ };
270
+ const restoredLaneAttributes = (road, odrLaneId) => {
271
+ let entry = restoredAttrCache.get(road.id);
272
+ if (entry === undefined) {
273
+ entry = { flat: null, perLane: null };
274
+ const raw = road.userData['laneAttributes'];
275
+ if (raw) {
276
+ try {
277
+ const obj = JSON.parse(raw);
278
+ if (obj && typeof obj === 'object' && !Array.isArray(obj)) {
279
+ const flat = {};
280
+ for (const [k, v] of Object.entries(obj)) {
281
+ if (v && typeof v === 'object' && !Array.isArray(v) && /^-?\d+$/.test(k)) {
282
+ const laneAttrs = pickStringAttrs(v);
283
+ if (laneAttrs) {
284
+ entry.perLane = entry.perLane ?? new Map();
285
+ entry.perLane.set(parseInt(k, 10), laneAttrs);
286
+ }
287
+ }
288
+ else {
289
+ flat[k] = v;
290
+ }
291
+ }
292
+ entry.flat = pickStringAttrs(flat);
276
293
  }
277
- if (Object.keys(parsed).length === 0)
278
- parsed = null;
294
+ }
295
+ catch {
296
+ // Malformed userData JSON is ignored (third-party files).
279
297
  }
280
298
  }
281
- catch {
282
- // Malformed userData JSON is ignored (third-party files).
283
- }
299
+ restoredAttrCache.set(road.id, entry);
284
300
  }
285
- restoredAttrCache.set(road.id, parsed);
286
- return parsed;
301
+ const perLane = entry.perLane?.get(odrLaneId) ?? null;
302
+ if (!entry.flat && !perLane)
303
+ return null;
304
+ return { ...(entry.flat ?? {}), ...(perLane ?? {}) };
287
305
  };
288
306
  const registryKey = (roadId, sectionIdx, odrLaneId) => `${roadId}|${sectionIdx}|${odrLaneId}`;
289
307
  for (const road of roads) {
@@ -539,6 +557,36 @@ export function odrToShapes(map, options = {}) {
539
557
  }
540
558
  return out;
541
559
  };
560
+ /** Shape ids of a specific (road, ODR lane id) pair, across all sections. */
561
+ const lanesOfRoadLane = (roadId, odrLaneId) => {
562
+ const out = [];
563
+ for (const reg of lanesByRoad.get(roadId) ?? []) {
564
+ if (reg.odrLaneId === odrLaneId && !out.includes(reg.shapeId))
565
+ out.push(reg.shapeId);
566
+ }
567
+ return out;
568
+ };
569
+ /** Resolve a [[roadId, laneId], ...] JSON record to lane shape ids. */
570
+ const resolveLanePairs = (pairs) => {
571
+ const out = [];
572
+ if (!Array.isArray(pairs))
573
+ return out;
574
+ for (const entry of pairs) {
575
+ if (!Array.isArray(entry) || entry.length < 2)
576
+ continue;
577
+ const [rid, lid] = entry;
578
+ if (typeof rid !== 'string' || typeof lid !== 'string')
579
+ continue;
580
+ const odrLaneId = parseInt(lid, 10);
581
+ if (!Number.isFinite(odrLaneId))
582
+ continue;
583
+ for (const shapeId of lanesOfRoadLane(rid, odrLaneId)) {
584
+ if (!out.includes(shapeId))
585
+ out.push(shapeId);
586
+ }
587
+ }
588
+ return out;
589
+ };
542
590
  /**
543
591
  * Convert crosswalk objects into crosswalk shapes. The band center is the
544
592
  * (s, t) station on the reference line; the walking axis follows the
@@ -572,7 +620,15 @@ export function odrToShapes(map, options = {}) {
572
620
  if (rawLinks) {
573
621
  try {
574
622
  const links = JSON.parse(rawLinks);
575
- if (Array.isArray(links.affectedRoads)) {
623
+ // Current exports carry lane-precise [[roadId, laneId], ...] pairs;
624
+ // legacy files only listed road ids (=> every driving lane).
625
+ if (Array.isArray(links.affectedLanes)) {
626
+ for (const shapeId of resolveLanePairs(links.affectedLanes)) {
627
+ if (!affected.includes(shapeId))
628
+ affected.push(shapeId);
629
+ }
630
+ }
631
+ else if (Array.isArray(links.affectedRoads)) {
576
632
  for (const rid of links.affectedRoads) {
577
633
  if (typeof rid !== 'string')
578
634
  continue;
@@ -615,10 +671,38 @@ export function odrToShapes(map, options = {}) {
615
671
  for (const { road, samples } of signalRoads) {
616
672
  materializeCrosswalks(road, samples);
617
673
  }
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.
674
+ // Restore right-of-way links stashed by the exporter.
675
+ // Current exports carry <userData code="yieldLanes"> with per-lane
676
+ // { ownLaneId: [[roadId, laneId], ...] } records; legacy files carried
677
+ // <userData code="yieldRoads"> (every driving lane of the carrying road
678
+ // yields over the driving lanes of the listed roads).
621
679
  for (const road of roads) {
680
+ const rawYieldLanes = road.userData['yieldLanes'];
681
+ if (rawYieldLanes) {
682
+ let byLane;
683
+ try {
684
+ byLane = JSON.parse(rawYieldLanes);
685
+ }
686
+ catch {
687
+ continue;
688
+ }
689
+ if (!byLane || typeof byLane !== 'object' || Array.isArray(byLane))
690
+ continue;
691
+ for (const [laneIdStr, pairs] of Object.entries(byLane)) {
692
+ const odrLaneId = parseInt(laneIdStr, 10);
693
+ if (!Number.isFinite(odrLaneId))
694
+ continue;
695
+ const yieldLaneIds = resolveLanePairs(pairs);
696
+ if (yieldLaneIds.length === 0)
697
+ continue;
698
+ for (const shapeId of lanesOfRoadLane(road.id, odrLaneId)) {
699
+ const lane = laneShapeById.get(shapeId);
700
+ if (lane)
701
+ lane.yieldLaneIds = [...yieldLaneIds];
702
+ }
703
+ }
704
+ continue;
705
+ }
622
706
  const rawYield = road.userData['yieldRoads'];
623
707
  if (!rawYield)
624
708
  continue;
@@ -741,7 +825,7 @@ export function odrToShapes(map, options = {}) {
741
825
  // Lanelet tags stashed by the exporter in <userData
742
826
  // code="laneAttributes"> (speed_limit, turn_direction, location,
743
827
  // one_way=no, exact subtype, custom tags) override the defaults.
744
- ...restoredLaneAttributes(road),
828
+ ...restoredLaneAttributes(road, lane.id),
745
829
  odr_type: lane.type,
746
830
  odr_road_id: road.id,
747
831
  odr_lane_id: String(lane.id),
@@ -819,6 +903,72 @@ export function odrToShapes(map, options = {}) {
819
903
  }
820
904
  return out;
821
905
  };
906
+ /**
907
+ * Resolve a (road, contact, lane id) endpoint to materialized lanes.
908
+ *
909
+ * Within a road this is `lanesAt` (which already bridges skipped micro
910
+ * sections). When the whole road was skipped — e.g. the short synthesized
911
+ * junction connecting roads this exporter emits, or any sub-threshold road
912
+ * in third-party files — the resolution continues across the road: lane
913
+ * links are walked through its (skipped) sections to the far end, and the
914
+ * road-level link there is followed into the neighbouring road, recursively,
915
+ * so connectivity is bridged across skipped roads instead of being lost.
916
+ */
917
+ const resolveLaneEndpoints = (roadId, contact, odrLaneId, depth = 0) => {
918
+ const direct = lanesAt(roadId, contact, odrLaneId);
919
+ if (direct.length > 0)
920
+ return direct.map(reg => ({ reg, odrLaneId, contact }));
921
+ const road = roadById.get(roadId);
922
+ if (!road || depth > 4)
923
+ return [];
924
+ // Only bridge across roads with no materialized lanes at all; partially
925
+ // materialized roads are fully handled by the in-road resolution above.
926
+ if ((lanesByRoad.get(roadId) ?? []).length > 0)
927
+ return [];
928
+ const n = road.laneSections.length;
929
+ if (n === 0)
930
+ return [];
931
+ // Walk lane-level links through the skipped sections to the far end.
932
+ const farContact = contact === 'start' ? 'end' : 'start';
933
+ const dir = contact === 'start' ? 1 : -1;
934
+ let idx = contact === 'start' ? 0 : n - 1;
935
+ let ids = [odrLaneId];
936
+ for (let step = 0; step < n - 1 && ids.length > 0; step++) {
937
+ const sec = road.laneSections[idx];
938
+ const nextIds = [];
939
+ for (const id of ids) {
940
+ const lane = [...sec.left, ...sec.right].find(l => l.id === id);
941
+ if (!lane)
942
+ continue;
943
+ for (const linked of dir === 1 ? lane.successorIds : lane.predecessorIds) {
944
+ if (!nextIds.includes(linked))
945
+ nextIds.push(linked);
946
+ }
947
+ }
948
+ ids = nextIds;
949
+ idx += dir;
950
+ }
951
+ const farSec = road.laneSections[farContact === 'start' ? 0 : n - 1];
952
+ const link = farContact === 'end' ? road.successor : road.predecessor;
953
+ if (!farSec || !link || link.elementType !== 'road')
954
+ return [];
955
+ const cpB = link.contactPoint ?? (farContact === 'end' ? 'start' : 'end');
956
+ const out = [];
957
+ for (const id of ids) {
958
+ const lane = [...farSec.left, ...farSec.right].find(l => l.id === id);
959
+ if (!lane)
960
+ continue;
961
+ const targetIds = farContact === 'end' ? lane.successorIds : lane.predecessorIds;
962
+ for (const tid of targetIds) {
963
+ for (const ep of resolveLaneEndpoints(link.elementId, cpB, tid, depth + 1)) {
964
+ if (!out.some(o => o.reg === ep.reg && o.odrLaneId === ep.odrLaneId && o.contact === ep.contact)) {
965
+ out.push(ep);
966
+ }
967
+ }
968
+ }
969
+ }
970
+ return out;
971
+ };
822
972
  /**
823
973
  * Link two lanes meeting at a shared contact, respecting travel direction:
824
974
  * right lanes (id < 0) travel toward the road's end, left lanes (id > 0)
@@ -887,10 +1037,10 @@ export function odrToShapes(map, options = {}) {
887
1037
  return;
888
1038
  for (const lane of [...sec.left, ...sec.right]) {
889
1039
  const targetIds = cpA === 'end' ? lane.successorIds : lane.predecessorIds;
890
- for (const a of lanesAt(roadA.id, cpA, lane.id)) {
1040
+ for (const a of resolveLaneEndpoints(roadA.id, cpA, lane.id)) {
891
1041
  for (const toId of targetIds) {
892
- for (const b of lanesAt(roadB.id, cpB, toId)) {
893
- linkLanes(a, lane.id, cpA, b, toId, cpB);
1042
+ for (const b of resolveLaneEndpoints(roadB.id, cpB, toId)) {
1043
+ linkLanes(a.reg, a.odrLaneId, a.contact, b.reg, b.odrLaneId, b.contact);
894
1044
  }
895
1045
  }
896
1046
  }
@@ -919,15 +1069,101 @@ export function odrToShapes(map, options = {}) {
919
1069
  contacts.push('end'); // Tolerant default.
920
1070
  for (const cpA of contacts) {
921
1071
  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);
1072
+ for (const a of resolveLaneEndpoints(roadA.id, cpA, ll.from)) {
1073
+ for (const b of resolveLaneEndpoints(roadC.id, conn.contactPoint, ll.to)) {
1074
+ linkLanes(a.reg, a.odrLaneId, a.contact, b.reg, b.odrLaneId, b.contact);
925
1075
  }
926
1076
  }
927
1077
  }
928
1078
  }
929
1079
  }
930
1080
  }
1081
+ // Hidden lane links: edges the exporter could not express as standard
1082
+ // <link>/<laneLink> records because a contact width is zero (the OpenDRIVE
1083
+ // zero-width / appearing-lane link rules), stashed per road as
1084
+ // <userData code="hiddenLaneLinks" value="[{fr,fl,tr,tl},...]"> with
1085
+ // from-road / from-lane / to-road / to-lane ids (from end -> to start in
1086
+ // travel direction). Restored here into next/prev like any other link.
1087
+ for (const road of roads) {
1088
+ const raw = road.userData['hiddenLaneLinks'];
1089
+ if (!raw)
1090
+ continue;
1091
+ let recs;
1092
+ try {
1093
+ recs = JSON.parse(raw);
1094
+ }
1095
+ catch {
1096
+ continue; // Malformed userData JSON is ignored (third-party files).
1097
+ }
1098
+ if (!Array.isArray(recs))
1099
+ continue;
1100
+ for (const rec of recs) {
1101
+ if (!rec || typeof rec !== 'object')
1102
+ continue;
1103
+ const { fr, fl, tr, tl } = rec;
1104
+ if (typeof fl !== 'number' || typeof tl !== 'number')
1105
+ continue;
1106
+ const fromRoad = fr === undefined ? road.id : String(fr);
1107
+ const toRoad = tr === undefined ? road.id : String(tr);
1108
+ for (const a of resolveLaneEndpoints(fromRoad, 'end', fl)) {
1109
+ for (const b of resolveLaneEndpoints(toRoad, 'start', tl)) {
1110
+ linkLanes(a.reg, a.odrLaneId, a.contact, b.reg, b.odrLaneId, b.contact);
1111
+ }
1112
+ }
1113
+ }
1114
+ }
1115
+ // Junction <priority high low> records restore right-of-way links: the
1116
+ // lanes standing in for the prioritized connecting road gain yieldLaneIds
1117
+ // over the lanes standing in for the yielding one. A materialized
1118
+ // connecting road is represented by its own lanes; a skipped (short
1119
+ // synthesized) one resolves through its predecessor link to the incoming
1120
+ // lanes the maneuver started from — the lanes the exporter originally read
1121
+ // the yieldLaneIds off. Merged with any userData-restored links above.
1122
+ const priorityRoadLanes = (roadId) => {
1123
+ const own = lanesByRoad.get(roadId) ?? [];
1124
+ if (own.length > 0) {
1125
+ const out = [];
1126
+ for (const reg of own) {
1127
+ if (!out.includes(reg.shapeId))
1128
+ out.push(reg.shapeId);
1129
+ }
1130
+ return out;
1131
+ }
1132
+ const road = roadById.get(roadId);
1133
+ if (!road)
1134
+ return [];
1135
+ const lastSec = road.laneSections[road.laneSections.length - 1];
1136
+ if (!lastSec)
1137
+ return [];
1138
+ const out = [];
1139
+ for (const lane of [...lastSec.left, ...lastSec.right]) {
1140
+ for (const ep of resolveLaneEndpoints(roadId, 'end', lane.id)) {
1141
+ if (!out.includes(ep.reg.shapeId))
1142
+ out.push(ep.reg.shapeId);
1143
+ }
1144
+ }
1145
+ return out;
1146
+ };
1147
+ for (const junction of map.junctions) {
1148
+ for (const pr of junction.priorities) {
1149
+ const highLanes = priorityRoadLanes(pr.high);
1150
+ const lowLanes = priorityRoadLanes(pr.low);
1151
+ if (highLanes.length === 0 || lowLanes.length === 0)
1152
+ continue;
1153
+ for (const shapeId of highLanes) {
1154
+ const lane = laneShapeById.get(shapeId);
1155
+ if (!lane)
1156
+ continue;
1157
+ const merged = lane.yieldLaneIds ?? [];
1158
+ for (const lowId of lowLanes) {
1159
+ if (lowId !== shapeId && !merged.includes(lowId))
1160
+ merged.push(lowId);
1161
+ }
1162
+ if (merged.length > 0)
1163
+ lane.yieldLaneIds = merged;
1164
+ }
1165
+ }
1166
+ }
931
1167
  // ---- Round-trip fidelity post-processing ----
932
1168
  // 1. Boundaries that are geometrically one line (each exported road carries
933
1169
  // its own copy of a boundary shared with its neighbour) collapse into a
@@ -938,6 +1174,96 @@ export function odrToShapes(map, options = {}) {
938
1174
  dedupeSharedBoundaries(result);
939
1175
  weldConnectedLaneContacts(result);
940
1176
  removeOrphanPoints(result);
1177
+ // ---- Carry-through records ----
1178
+ // Per-road state hashes for `exportToOpenDrive({ sidecar })`: a road whose
1179
+ // hash still matches at export time was not edited and is re-emitted
1180
+ // verbatim from the sidecar XML. Hashes are taken AFTER all post-processing
1181
+ // (dedupe / weld / orphan removal) so they describe exactly the shapes the
1182
+ // editor will hold; the exporter recomputes them from the live shapes.
1183
+ {
1184
+ const recPointById = new Map(result.points.map(p => [p.id, p]));
1185
+ const recLsById = new Map(result.linestrings.map(l => [l.id, l]));
1186
+ const boundaryPts = (lsId, invert) => {
1187
+ if (!lsId)
1188
+ return null;
1189
+ const ls = recLsById.get(lsId);
1190
+ if (!ls)
1191
+ return null;
1192
+ const ids = invert ? [...ls.pointIds].reverse() : ls.pointIds;
1193
+ const pts = [];
1194
+ for (const pid of ids) {
1195
+ const p = recPointById.get(pid);
1196
+ if (p)
1197
+ pts.push({ x: p.x, y: p.y });
1198
+ }
1199
+ return pts.length >= 2 ? pts : null;
1200
+ };
1201
+ const laneRoadOf = new Map();
1202
+ for (const [roadId, regs] of lanesByRoad) {
1203
+ for (const reg of regs)
1204
+ laneRoadOf.set(reg.shapeId, roadId);
1205
+ }
1206
+ // Regulatory shapes touching a road: attached to it (odr_road_id) or
1207
+ // affecting any of its lanes. Mirrored by the exporter's hash builder.
1208
+ const regStatesByRoad = new Map();
1209
+ const addRegState = (state, affected, own) => {
1210
+ const touching = new Set();
1211
+ if (own && roadById.has(own))
1212
+ touching.add(own);
1213
+ for (const lid of affected) {
1214
+ const rid = laneRoadOf.get(lid);
1215
+ if (rid)
1216
+ touching.add(rid);
1217
+ }
1218
+ for (const rid of touching) {
1219
+ const list = regStatesByRoad.get(rid) ?? [];
1220
+ list.push(state);
1221
+ regStatesByRoad.set(rid, list);
1222
+ }
1223
+ };
1224
+ for (const tl of result.trafficLights) {
1225
+ addRegState({
1226
+ kind: 'traffic_light',
1227
+ shapeId: tl.id,
1228
+ numbers: [tl.x, tl.y, tl.w, tl.h, 0],
1229
+ attributes: tl.attributes,
1230
+ affectedLaneIds: tl.affectedLaneIds,
1231
+ stopLinePts: boundaryPts(tl.stopLineId, false),
1232
+ controllerId: '',
1233
+ }, tl.affectedLaneIds, tl.attributes['odr_road_id']);
1234
+ }
1235
+ for (const cw of result.crosswalks) {
1236
+ addRegState({
1237
+ kind: 'crosswalk',
1238
+ shapeId: cw.id,
1239
+ numbers: [cw.x, cw.y, cw.startX, cw.startY, cw.endX, cw.endY, cw.crosswalkWidth, 0],
1240
+ attributes: cw.attributes,
1241
+ affectedLaneIds: cw.affectedLaneIds,
1242
+ stopLinePts: boundaryPts(cw.stopLineId, false),
1243
+ controllerId: '',
1244
+ }, cw.affectedLaneIds, cw.attributes['odr_road_id']);
1245
+ }
1246
+ const roadRecords = {};
1247
+ for (const road of roads) {
1248
+ const regLanes = lanesByRoad.get(road.id) ?? [];
1249
+ const laneStates = regLanes.map(reg => {
1250
+ const lane = laneShapeById.get(reg.shapeId);
1251
+ return {
1252
+ leftPts: boundaryPts(lane.leftBoundaryId, lane.invertLeft),
1253
+ rightPts: boundaryPts(lane.rightBoundaryId, lane.invertRight),
1254
+ attributes: lane.attributes,
1255
+ next: lane.next,
1256
+ prev: lane.prev,
1257
+ yieldLaneIds: lane.yieldLaneIds ?? [],
1258
+ };
1259
+ });
1260
+ roadRecords[road.id] = {
1261
+ laneShapeIds: regLanes.map(r => r.shapeId),
1262
+ stateHash: hashRoadState(laneStates, regStatesByRoad.get(road.id) ?? []),
1263
+ };
1264
+ }
1265
+ result.sidecar.roadRecords = roadRecords;
1266
+ }
941
1267
  // ---- Aggregated warnings ----
942
1268
  if (elevationRoads > 0) {
943
1269
  warnings.push(`Elevation profiles on ${elevationRoads} road(s) were flattened to 2D.`);