diff --git a/packages/drawtonomy-sdk/__tests__/validator/geometry.test.ts b/packages/drawtonomy-sdk/__tests__/validator/geometry.test.ts
index f000f85..afb12d7 100644
--- a/packages/drawtonomy-sdk/__tests__/validator/geometry.test.ts
+++ b/packages/drawtonomy-sdk/__tests__/validator/geometry.test.ts
@@ -114,14 +114,23 @@ describe('checkGeometry', () => {
})
describe('road link contact', () => {
- const pair = (bx: number, offsetA = 0, offsetB = 0): string => `
+ // Road A runs along y = 0 from x = 0 to x = 100. Road B starts at
+ // (bx, by). Each carries one 3.5 m right lane, so a road's lane boundaries
+ // sit at its centre line (the reference line shifted by its laneOffset)
+ // and 3.5 m to the right of it.
+ const pair = (
+ bx: number,
+ opts: { by?: number; offsetA?: string; offsetB?: string } = {}
+ ): string => {
+ const { by = 0, offsetA = 'a="0" b="0"', offsetB = 'a="0" b="0"' } = opts
+ return `
-
+
@@ -140,6 +149,48 @@ describe('checkGeometry', () => {
`
+ }
+
+ /**
+ * Two roads linked end-to-start, with each side's lane content injectable.
+ * Road A runs along y = 0 from x = 0 to x = 100; road B starts at
+ * (100, by). Defaults give both a single 3.5 m right lane, so their lane
+ * boundaries coincide and the pair is clean.
+ */
+ const twoRoads = (opts: {
+ by?: number
+ aLanes?: string
+ bLanes?: string
+ aSections?: string
+ bOffset?: string
+ }): string => {
+ const {
+ by = 0,
+ aLanes = '',
+ bLanes = '',
+ aSections,
+ bOffset = '',
+ } = opts
+ const aLaneXml =
+ aSections ??
+ `
${bLanes}
+
+
+`
+ }
it('accepts roads that touch', () => {
expect(geomRules(pair(100))).toEqual([])
@@ -153,10 +204,116 @@ describe('checkGeometry', () => {
expect(validateOpenDrive(pair(150)).verdict).toBe('yellow')
})
- it('subtracts the lane-offset difference', () => {
- // fabriksgatan's pattern: a connecting road with laneOffset 1.75 linking
- // to a mainline with 0. The reference lines are 1.75 m apart by design.
- expect(geomRules(pair(101.75, 1.75, 0))).toEqual([])
+ it('reports the lane distance, not the reference-line distance', () => {
+ const found = findingsFor(pair(150)).filter(f => f.rule === 'geom.road-link-gap')
+ expect(found[0].message).toContain('lane boundaries')
+ expect(found[0].message).toContain('50.000 m apart')
+ })
+
+ // The false positive this rule exists to avoid. Road B's reference line is
+ // pushed 5 m off road A's by a laneOffset that ramps along s, so a check
+ // comparing reference-line endpoints (even after subtracting the offset
+ // difference at the contact) sees a gap — while the lanes themselves meet
+ // exactly. Measured on a real map, every report of the old proxy was of
+ // this shape and every one had a true lane distance of 0.000 m.
+ it('accepts lanes that meet while the reference lines are far apart', () => {
+ // A: no offset, so its centre is y = 0 and its lane edge y = -3.5.
+ // B: reference line at y = -5, laneOffset ramping from +5 at s = 0, so
+ // its centre is also y = 0 at the contact and its lane edge y = -3.5.
+ const xml = pair(100, { by: -5, offsetB: 'a="5" b="-0.02"' })
+ expect(geomRules(xml)).toEqual([])
+ })
+
+ it('still detects lanes that are genuinely apart', () => {
+ // Same construction, but B's offset leaves its lanes 1.5 m off A's.
+ const xml = pair(100, { by: -5, offsetB: 'a="3.5" b="-0.02"' })
+ const found = findingsFor(xml).filter(f => f.rule === 'geom.road-link-gap')
+ expect(found.length).toBeGreaterThan(0)
+ expect(found[0].message).toContain('1.500 m apart')
+ })
+
+ it('honours a caller-supplied threshold', () => {
+ const xml = pair(100, { by: -5, offsetB: 'a="3.5" b="-0.02"' })
+ expect(geomRules(xml, { geometry: { roadLinkGapMeters: 2 } })).toEqual([])
+ })
+
+ // A lateral mismatch is not a gap. Lane counts and widths routinely differ
+ // across a link (merges, ramps and junction connectors: 91 of the 156
+ // links on one real map), so the rule asks whether the two cross sections
+ // *touch*, not whether they are congruent. What it must catch is the two
+ // sections being wholly apart, which is the soderleden defect.
+ it('accepts a link where the roads touch but the lane counts differ', () => {
+ const xml = twoRoads({
+ aLanes: '',
+ bLanes:
+ '' +
+ '',
+ })
+ expect(geomRules(xml)).toEqual([])
+ })
+
+ it('measures a multi-section road at the section covering the contact', () => {
+ // Road A's lane widens to 5 m from s = 50, and road B is shifted so that
+ // it meets that widened edge. Reading A's *first* section would place its
+ // edge 1.5 m away and report a gap that does not exist.
+ const xml = twoRoads({
+ aSections:
+ '
' +
+ '' +
+ '
' +
+ '',
+ // B is a lone boundary pair 5 m below the reference line: its centre is
+ // at y = -5.0, exactly A's widened outer edge.
+ by: -5,
+ bLanes: '',
+ })
+ expect(xml).toContain('laneSection s="50"')
+ expect(geomRules(xml)).toEqual([])
+
+ // The same B against a road whose lane never widens is 1.5 m short.
+ const narrow = twoRoads({
+ by: -5,
+ bLanes: '',
+ })
+ const found = findingsFor(narrow).filter(f => f.rule === 'geom.road-link-gap')
+ expect(found.length).toBeGreaterThan(0)
+ expect(found[0].message).toContain('1.500 m apart')
+ })
+
+ it('evaluates the width record covering the contact, not the first', () => {
+ // One lane section, two records: 3.5 m up to sOffset 50 and 6.0 m
+ // after it. The contact at s = 100 must read 6.0 m, putting A's outer
+ // edge at y = -6.0 where B's lone boundary sits.
+ const xml = twoRoads({
+ aLanes:
+ '' +
+ '' +
+ '' +
+ '',
+ by: -6,
+ bLanes: '',
+ })
+ expect(geomRules(xml)).toEqual([])
+
+ // Reading only the first record would have put A's edge at -3.5, which
+ // is where this B sits — and that must be reported as 2.5 m away.
+ const wrong = twoRoads({
+ aLanes:
+ '' +
+ '' +
+ '' +
+ '',
+ by: -3.5,
+ bLanes: '',
+ })
+ const found = findingsFor(wrong).filter(f => f.rule === 'geom.road-link-gap')
+ expect(found.length).toBeGreaterThan(0)
+ expect(found[0].message).toContain('2.500 m apart')
+ })
+
+ it('takes the nearer end when the link declares no contact point', () => {
+ const xml = pair(100).replace(' contactPoint="start"', '')
+ expect(geomRules(xml)).toEqual([])
})
})
@@ -177,7 +334,7 @@ describe('checkGeometry', () => {
expect(DEFAULT_GEOMETRY_THRESHOLDS).toEqual({
planViewGapMeters: 0.02,
planViewHeadingRad: 0.005,
- roadLinkGapMeters: 0.5,
+ roadLinkGapMeters: 0.3,
lengthMismatchRatio: 0.01,
negativeWidthToleranceMeters: 0.001,
})
diff --git a/packages/drawtonomy-sdk/src/validator/layers/geometry.ts b/packages/drawtonomy-sdk/src/validator/layers/geometry.ts
index 1102977..d41e691 100644
--- a/packages/drawtonomy-sdk/src/validator/layers/geometry.ts
+++ b/packages/drawtonomy-sdk/src/validator/layers/geometry.ts
@@ -24,24 +24,40 @@
// road length vs sum observed max 4.0e-16 default 1 % (huge)
//
// Between roads it is a different story, and the reason is structural rather
-// than a matter of precision. `road@length`-scale gaps at a link are normal:
+// than a matter of precision. `road@length`-scale gaps between *reference
+// lines* at a link are normal:
//
// 1. A road carrying a has its reference line laterally shifted
// from the lane geometry. Two roads with different offsets meet along
-// their *lanes* while their reference lines stay apart by exactly the
-// offset difference. fabriksgatan's connecting roads (laneOffset a=1.75)
-// linking to mainlines (a=0) produce a uniform 1.75 m; multi_intersections
-// produces 3.75 m the same way. Both maps are correct.
+// their *lanes* while their reference lines stay apart. fabriksgatan's
+// connecting roads (laneOffset a=1.75) linking to mainlines (a=0) produce
+// a uniform 1.75 m; multi_intersections produces 3.75 m the same way. Both
+// maps are correct.
// 2. Some shipped maps simply contain link records their geometry does not
// honour: soderleden road 7 declares its predecessor to be road 2 at that
-// road's end, 66 m from where road 7 actually begins. esmini drives this
-// map regardless, because it routes through the junction rather than the
-// stray link.
+// road's end, tens of metres from where road 7 actually begins. esmini
+// drives this map regardless, because it routes through the junction
+// rather than the stray link.
//
-// So the road-link check is reported as a *warning*: it is real evidence worth
-// surfacing, but it must not redden a map that ships and works. The lane offset
-// difference is subtracted before comparing, which removes cause (1) exactly
-// and leaves cause (2) visible.
+// An earlier revision of this rule compared reference-line endpoints and
+// subtracted |laneOffsetA - laneOffsetB| as slack. That proxy is wrong whenever
+// the offset varies with s or the two roads meet at an angle: measured over a
+// real map, every one of its ten reports was a false positive whose lanes in
+// fact touched to 0.000 m. The rule therefore measures what it actually claims
+// to measure — the distance between the two roads' *lane boundary cross
+// sections* at the contact — which removes cause (1) by construction, with no
+// slack term to tune, and leaves cause (2) visible.
+//
+// What is compared is the *minimum* distance between the two boundary sets,
+// i.e. "do these two cross sections touch anywhere", not "are they congruent".
+// That is deliberate: lane counts and widths routinely differ across a link at
+// merges, ramps and junction connectors (91 of 156 links on one measured map),
+// so requiring congruence would re-introduce false positives of a new shape.
+// A road whose lanes are wholly displaced from its neighbour's — the soderleden
+// case — still separates every pair of boundaries and is still reported.
+//
+// The check is still reported as a *warning*: it is real evidence worth
+// surfacing, but it must not redden a map that ships and works.
//
// Lane widths need a tolerance for the opposite reason — not structure, but
// arithmetic. Merge and exit lanes are authored to close at exactly zero, e.g.
@@ -53,7 +69,12 @@
// the two cleanly rather than splitting a continuum.
import { evalGeometry } from '../../exporter/odrGeometry.js'
-import type { OdrMap, OdrRoad, OdrWidth } from '../../exporter/opendriveParser.js'
+import type {
+ OdrLaneSection,
+ OdrMap,
+ OdrRoad,
+ OdrWidth,
+} from '../../exporter/opendriveParser.js'
import type { OdrFinding, ResolvedGeometryThresholds } from '../types.js'
/** Wrap an angle to (-pi, pi]. */
@@ -102,6 +123,91 @@ function poseAtEnd(road: OdrRoad, at: 'start' | 'end'): Pose | null {
const stationAtEnd = (road: OdrRoad, at: 'start' | 'end'): number =>
at === 'start' ? 0 : road.length
+/**
+ * Width of a lane at `ds` metres into its lane section.
+ *
+ * A `` record applies from its own `sOffset` until the next one, and its
+ * polynomial is in `ds - sOffset`. Records before the first `sOffset` (which a
+ * conforming file does not produce, but a hand-edited one might) fall back to
+ * the first record evaluated at its own origin.
+ */
+function laneWidthAt(widths: readonly OdrWidth[], ds: number): number {
+ if (widths.length === 0) return 0
+ let applicable = widths[0]
+ for (const rec of widths) {
+ if (rec.sOffset <= ds) applicable = rec
+ else break
+ }
+ return evalCubic(applicable, Math.max(ds - applicable.sOffset, 0))
+}
+
+/** The lane section covering station `s` (the last one starting at or before it). */
+function sectionAt(road: OdrRoad, s: number): OdrLaneSection | null {
+ if (road.laneSections.length === 0) return null
+ let applicable = road.laneSections[0]
+ for (const section of road.laneSections) {
+ if (section.s <= s + 1e-9) applicable = section
+ else break
+ }
+ return applicable
+}
+
+interface Point {
+ x: number
+ y: number
+}
+
+/**
+ * World positions of every lane boundary at a road's start or end.
+ *
+ * The boundaries are the centre line (the reference line shifted laterally by
+ * ``) plus the running sum of lane widths outward in each
+ * direction: left lanes accumulate in +t, right lanes in -t, where the lateral
+ * unit vector is the reference-line normal `(-sin hdg, cos hdg)`.
+ *
+ * Returns null when the road has no plan view to evaluate. A road without lane
+ * sections still yields its centre point, which is the best available
+ * statement of where it ends.
+ */
+function laneCrossSection(road: OdrRoad, at: 'start' | 'end'): Point[] | null {
+ const pose = poseAtEnd(road, at)
+ if (!pose) return null
+
+ const s = stationAtEnd(road, at)
+ const nx = -Math.sin(pose.hdg)
+ const ny = Math.cos(pose.hdg)
+
+ const offsets: number[] = [laneOffsetAt(road, s)]
+ const section = sectionAt(road, s)
+ if (section) {
+ // `ds` is measured from the section's own start, which is where a
+ // `` record's sOffset is relative to.
+ const ds = Math.max(s - section.s, 0)
+ for (const side of [section.left, section.right] as const) {
+ let t = offsets[0]
+ for (const lane of side) {
+ const width = laneWidthAt(lane.widths, ds)
+ t += side === section.left ? width : -width
+ offsets.push(t)
+ }
+ }
+ }
+
+ return offsets.map(t => ({ x: pose.x + nx * t, y: pose.y + ny * t }))
+}
+
+/** Smallest distance between any point of `a` and any point of `b`. */
+function minPointDistance(a: readonly Point[], b: readonly Point[]): number {
+ let best = Infinity
+ for (const p of a) {
+ for (const q of b) {
+ const d = Math.hypot(p.x - q.x, p.y - q.y)
+ if (d < best) best = d
+ }
+ }
+ return best
+}
+
export function checkGeometry(
map: OdrMap,
thresholds: ResolvedGeometryThresholds
@@ -219,28 +325,37 @@ export function checkGeometry(
if (!target) continue // layer 2 reported the dangling link
const myEnd = which === 'successor' ? 'end' : 'start'
- const theirEnd = link.contactPoint ?? (which === 'successor' ? 'start' : 'end')
- const mine = poseAtEnd(road, myEnd)
- const theirs = poseAtEnd(target, theirEnd)
- if (!mine || !theirs) continue
-
- // Subtract the lane-offset difference: two roads whose lanes meet can
- // have reference lines apart by exactly that amount, and flagging it
- // would redden fabriksgatan and multi_intersections, which are correct.
- const myOffset = laneOffsetAt(road, stationAtEnd(road, myEnd))
- const theirOffset = laneOffsetAt(target, stationAtEnd(target, theirEnd))
- const offsetSlack = Math.abs(myOffset - theirOffset)
-
- const raw = Math.hypot(mine.x - theirs.x, mine.y - theirs.y)
- const gap = Math.max(raw - offsetSlack, 0)
+ const mine = laneCrossSection(road, myEnd)
+ if (!mine) continue
+
+ // With an explicit contactPoint there is one cross section to compare
+ // against. Without one, the link is under-specified, so both of the
+ // target's ends are tried and the nearer is taken: reporting the larger
+ // of two readings would be inventing a defect the document never
+ // asserted.
+ const theirEnds: readonly ('start' | 'end')[] = link.contactPoint
+ ? [link.contactPoint]
+ : ['start', 'end']
+
+ let gap = Infinity
+ let theirEnd: 'start' | 'end' = theirEnds[0]
+ for (const end of theirEnds) {
+ const theirs = laneCrossSection(target, end)
+ if (!theirs) continue
+ const d = minPointDistance(mine, theirs)
+ if (d < gap) {
+ gap = d
+ theirEnd = end
+ }
+ }
+ if (!Number.isFinite(gap)) continue
+
if (gap > thresholds.roadLinkGapMeters) {
findings.push({
severity: 'warning',
category: 'MAP_DEFECT',
rule: 'geom.road-link-gap',
- message:
- `road ${road.id} <${which}> declares road ${target.id} at its ${theirEnd}, but the two reference lines are ${gap.toFixed(3)} m apart` +
- (offsetSlack > 0 ? ` (after allowing ${offsetSlack.toFixed(3)} m of lane offset)` : ''),
+ message: `road ${road.id} <${which}> declares road ${target.id} at its ${theirEnd}, but the lane boundaries of the two roads are ${gap.toFixed(3)} m apart at the contact`,
location: { roadId: road.id },
})
}
diff --git a/packages/drawtonomy-sdk/src/validator/types.ts b/packages/drawtonomy-sdk/src/validator/types.ts
index 0997977..6d40070 100644
--- a/packages/drawtonomy-sdk/src/validator/types.ts
+++ b/packages/drawtonomy-sdk/src/validator/types.ts
@@ -90,8 +90,8 @@ export interface OdrGeometryThresholds {
*/
planViewHeadingRad?: number
/**
- * Maximum allowed position gap at a road-to-road link contact point (m).
- * Default 0.5.
+ * Maximum allowed distance between the lane-boundary cross sections of two
+ * linked roads at their contact point (m). Default 0.3.
*/
roadLinkGapMeters?: number
/**
@@ -131,7 +131,7 @@ export interface ResolvedGeometryThresholds {
export const DEFAULT_GEOMETRY_THRESHOLDS: ResolvedGeometryThresholds = {
planViewGapMeters: 0.02,
planViewHeadingRad: 0.005,
- roadLinkGapMeters: 0.5,
+ roadLinkGapMeters: 0.3,
lengthMismatchRatio: 0.01,
negativeWidthToleranceMeters: 0.001,
}