feat: make crosswalk and stop-line offsets solvable

Step 1 of the control-marking task, and deliberately server-only: no handle is
drawn yet. This project already shipped a range handle for `profile.interval`,
which compileGeometry ignores, so the control dragged and changed nothing. The
consumer comes first now.

Two kinds join the taxonomy — `junction-crosswalk-inset` and
`junction-stop-line-offset`, both on the existing `junction-approach` anchor. The
solver writes them onto the approach entry, `applyDirectJunctionPlans` carries
them onto the compiled approach, and `compileControlMarkings` reads them in place
of the module constants it used for every junction. They move markings without
reshaping the junction, so unlike width and cutback they deliberately do not
trigger a boundary recompute.

`applyJunctionConstraint` becomes an explicit switch. Its trailing `else` had
meant every kind that was not approach-width fell through to the cutback
validator, so a new kind would have been silently validated and written as a
cutback. The same non-exhaustive shape in the test fixture's `valueFor` is fixed
the same way, and now throws for an unnamed kind rather than answering with a
corner radius.

design.md's taxonomy is updated with it — a test asserts the two cannot drift,
which is what caught the omission.

Measured on a 41-road workspace with 8 crossings: both constraints change their
marking geometry, neither drags the other, and out-of-range blocks instead of
clamping. That measurement is not in the suite: the synthetic junction resolves
`junction_inset_m` to 0 because its crossing never binds to a plan, and the
committed OSM fixture has no crossings at all. The tests assert the wiring the
handles will depend on — values reaching the approach entry, distinct branches,
blocking diagnostics — and the gap is recorded in the test itself.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-28 15:01:36 +08:00
parent 813809d25e
commit c7425f5ed4
47 changed files with 10496 additions and 28 deletions

View File

@@ -28,13 +28,30 @@ function anchorFor(kind) {
return { type, nodeId: 'node/9', incomingRoadId: 'road:way/1:forward', outgoingRoadId: 'road:way/2:forward' };
}
// One branch per kind. The trailing `return { radiusMeters: 6 }` this replaces
// silently answered for every kind it did not name, so a new kind was built with a
// corner-radius value and only failed later, in the validator.
function valueFor(kind) {
if (kind === 'road-edge-offset') return { offsetMeters: 0.8, transition: 'smoothstep' };
if (kind === 'road-sidewalk-width') return { widthMeters: 2.5, transition: 'linear' };
if (kind === 'road-lane-divider') return { boundaryIndex: 2, offsetMeters: -1.6, transition: 'smoothstep' };
if (kind === 'junction-approach-width') return { widthMeters: 12.5 };
if (kind === 'junction-cutback') return { cutbackMeters: 4 };
return { radiusMeters: 6 };
switch (kind) {
case 'road-edge-offset':
return { offsetMeters: 0.8, transition: 'smoothstep' };
case 'road-sidewalk-width':
return { widthMeters: 2.5, transition: 'linear' };
case 'road-lane-divider':
return { boundaryIndex: 2, offsetMeters: -1.6, transition: 'smoothstep' };
case 'junction-approach-width':
return { widthMeters: 12.5 };
case 'junction-cutback':
return { cutbackMeters: 4 };
case 'junction-corner-radius':
return { radiusMeters: 6 };
case 'junction-crosswalk-inset':
return { insetMeters: 1.5 };
case 'junction-stop-line-offset':
return { offsetMeters: 2.7 };
default:
throw new Error(`valueFor has no case for ${kind}; the taxonomy grew without this fixture`);
}
}
function constraintFor(kind, id = `c-${kind}`) {
@@ -82,6 +99,8 @@ assert.deepEqual(CONSTRAINT_KINDS, [
'junction-approach-width',
'junction-cutback',
'junction-corner-radius',
'junction-crosswalk-inset',
'junction-stop-line-offset',
]);
// --- round trip is lossless --------------------------------------------------