Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
175 changes: 166 additions & 9 deletions packages/drawtonomy-sdk/__tests__/validator/geometry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,14 +114,23 @@ describe('checkGeometry', () => {
})

describe('road link contact', () => {
const pair = (bx: number, offsetA = 0, offsetB = 0): string => `<?xml version="1.0"?>
// 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 `<?xml version="1.0"?>
<OpenDRIVE>
<header revMajor="1" revMinor="6"/>
<road name="a" length="100" id="1" junction="-1">
<link><successor elementType="road" elementId="2" contactPoint="start"/></link>
<planView><geometry s="0" x="0" y="0" hdg="0" length="100"><line/></geometry></planView>
<lanes>
<laneOffset s="0" a="${offsetA}" b="0" c="0" d="0"/>
<laneOffset s="0" ${offsetA} c="0" d="0"/>
<laneSection s="0">
<center><lane id="0" type="none" level="false"/></center>
<right><lane id="-1" type="driving" level="false"><width sOffset="0" a="3.5" b="0" c="0" d="0"/></lane></right>
Expand All @@ -130,16 +139,58 @@ describe('checkGeometry', () => {
</road>
<road name="b" length="100" id="2" junction="-1">
<link><predecessor elementType="road" elementId="1" contactPoint="end"/></link>
<planView><geometry s="0" x="${bx}" y="0" hdg="0" length="100"><line/></geometry></planView>
<planView><geometry s="0" x="${bx}" y="${by}" hdg="0" length="100"><line/></geometry></planView>
<lanes>
<laneOffset s="0" a="${offsetB}" b="0" c="0" d="0"/>
<laneOffset s="0" ${offsetB} c="0" d="0"/>
<laneSection s="0">
<center><lane id="0" type="none" level="false"/></center>
<right><lane id="-1" type="driving" level="false"><width sOffset="0" a="3.5" b="0" c="0" d="0"/></lane></right>
</laneSection>
</lanes>
</road>
</OpenDRIVE>`
}

/**
* 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 = '<lane id="-1" type="driving" level="false"><width sOffset="0" a="3.5" b="0" c="0" d="0"/></lane>',
bLanes = '<lane id="-1" type="driving" level="false"><width sOffset="0" a="3.5" b="0" c="0" d="0"/></lane>',
aSections,
bOffset = '',
} = opts
const aLaneXml =
aSections ??
`<laneSection s="0"><center><lane id="0" type="none" level="false"/></center><right>${aLanes}</right></laneSection>`
return `<?xml version="1.0"?>
<OpenDRIVE>
<header revMajor="1" revMinor="6"/>
<road name="a" length="100" id="1" junction="-1">
<link><successor elementType="road" elementId="2" contactPoint="start"/></link>
<planView><geometry s="0" x="0" y="0" hdg="0" length="100"><line/></geometry></planView>
<lanes>${aLaneXml}</lanes>
</road>
<road name="b" length="100" id="2" junction="-1">
<link><predecessor elementType="road" elementId="1" contactPoint="end"/></link>
<planView><geometry s="0" x="100" y="${by}" hdg="0" length="100"><line/></geometry></planView>
<lanes>${bOffset}
<laneSection s="0"><center><lane id="0" type="none" level="false"/></center><right>${bLanes}</right></laneSection>
</lanes>
</road>
</OpenDRIVE>`
}

it('accepts roads that touch', () => {
expect(geomRules(pair(100))).toEqual([])
Expand All @@ -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: '<lane id="-1" type="driving" level="false"><width sOffset="0" a="3.5" b="0" c="0" d="0"/></lane>',
bLanes:
'<lane id="-1" type="driving" level="false"><width sOffset="0" a="3.5" b="0" c="0" d="0"/></lane>' +
'<lane id="-2" type="driving" level="false"><width sOffset="0" a="3.5" b="0" c="0" d="0"/></lane>',
})
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:
'<laneSection s="0"><center><lane id="0" type="none" level="false"/></center>' +
'<right><lane id="-1" type="driving" level="false"><width sOffset="0" a="3.5" b="0" c="0" d="0"/></lane></right></laneSection>' +
'<laneSection s="50"><center><lane id="0" type="none" level="false"/></center>' +
'<right><lane id="-1" type="driving" level="false"><width sOffset="0" a="5.0" b="0" c="0" d="0"/></lane></right></laneSection>',
// 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: '<lane id="-1" type="driving" level="false"><width sOffset="0" a="0" b="0" c="0" d="0"/></lane>',
})
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: '<lane id="-1" type="driving" level="false"><width sOffset="0" a="0" b="0" c="0" d="0"/></lane>',
})
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 <width> 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:
'<lane id="-1" type="driving" level="false">' +
'<width sOffset="0" a="3.5" b="0" c="0" d="0"/>' +
'<width sOffset="50" a="6.0" b="0" c="0" d="0"/>' +
'</lane>',
by: -6,
bLanes: '<lane id="-1" type="driving" level="false"><width sOffset="0" a="0" b="0" c="0" d="0"/></lane>',
})
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:
'<lane id="-1" type="driving" level="false">' +
'<width sOffset="0" a="3.5" b="0" c="0" d="0"/>' +
'<width sOffset="50" a="6.0" b="0" c="0" d="0"/>' +
'</lane>',
by: -3.5,
bLanes: '<lane id="-1" type="driving" level="false"><width sOffset="0" a="0" b="0" c="0" d="0"/></lane>',
})
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([])
})
})

Expand All @@ -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,
})
Expand Down
Loading
Loading