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:
@@ -392,6 +392,86 @@ const onBlocked = resolveDirectEditConstraints(
|
||||
assert.equal(onBlocked.constraintStates[0].applied, true);
|
||||
assert.equal(onBlocked.roadProfiles.get('road:short').edgeOffsets.left, 1.5);
|
||||
|
||||
// Control-marking offsets must reach the geometry before any handle is drawn for
|
||||
// them. This project already shipped a range handle for `profile.interval`, which
|
||||
// compileGeometry ignores, so the control dragged and changed nothing — see
|
||||
// 08-26-direct-edit-map-editor/research/interval-not-applied.md. The consumer is
|
||||
// asserted first.
|
||||
const controlRoads = ['a', 'b', 'c'].map((id, index) => ({
|
||||
id: `road:${id}`,
|
||||
segmentId: `segment:${id}`,
|
||||
direction: 'forward',
|
||||
tags: {},
|
||||
highway: 'residential',
|
||||
// Roads *arrive* at the junction: `crossingJunctionInset()` resolves the junction
|
||||
// from the road's last node, so a fixture whose roads leave the junction finds no
|
||||
// plan and silently insets by zero.
|
||||
centerline: [
|
||||
[113 + index * 0.001 + (index === 1 ? 0.001 : 0), 30 + (index === 1 ? 0.001 : 0.001)],
|
||||
[113 + index * 0.001, 30],
|
||||
],
|
||||
sourceNodeIds: [`end-${id}`, 'junction'],
|
||||
osmWayIds: [id],
|
||||
widthMeters: 6,
|
||||
laneCount: 2,
|
||||
sidewalkLeft: true,
|
||||
sidewalkRight: true,
|
||||
}));
|
||||
const controlModel = {
|
||||
roads: controlRoads,
|
||||
endpoints: controlRoads.map((road) => ({
|
||||
id: `endpoint:${road.id}:end`,
|
||||
roadId: road.id,
|
||||
side: 'end',
|
||||
nodeId: 'junction',
|
||||
coordinate: road.centerline.at(-1),
|
||||
})),
|
||||
connections: [],
|
||||
diagnostics: [],
|
||||
crossings: [{ id: 'x1', coordinate: [113, 30.0004], tags: { highway: 'crossing' }, osmWayIds: ['a'] }],
|
||||
};
|
||||
const controlConstraint = (kind, value) =>
|
||||
constraint({
|
||||
id: `c-${kind}`,
|
||||
kind,
|
||||
anchor: { type: 'junction-approach', nodeId: 'junction', segmentId: 'segment:a' },
|
||||
value,
|
||||
});
|
||||
// NOTE: the geometry-level proof (a crosswalk inset actually moving the zebra) was
|
||||
// measured on a real 41-road workspace with 8 crossings, not asserted here: this
|
||||
// synthetic junction resolves `junction_inset_m` to 0 because the crossing never
|
||||
// binds to a junction plan, and the committed OSM fixture has no crossings at all.
|
||||
// Closing that gap needs an OSM fixture with a crossing on a road that arrives at a
|
||||
// junction. What is asserted below is the wiring the handles will depend on.
|
||||
// Both new kinds land their value on the approach entry the compiler reads.
|
||||
const approachEntry = (kind, value) =>
|
||||
resolveDirectEditConstraints(controlModel, document([controlConstraint(kind, value)]), {}).junctionPlans.get(
|
||||
'junction',
|
||||
).approaches['segment:a'];
|
||||
assert.equal(approachEntry('junction-crosswalk-inset', { insetMeters: 3 }).crosswalkInsetMeters, 3);
|
||||
assert.equal(approachEntry('junction-stop-line-offset', { offsetMeters: 4 }).stopLineOffsetMeters, 4);
|
||||
|
||||
// Out of range blocks rather than clamping, and writes nothing to the plan.
|
||||
const absurd = resolveDirectEditConstraints(
|
||||
controlModel,
|
||||
document([controlConstraint('junction-crosswalk-inset', { insetMeters: 9999 })]),
|
||||
{},
|
||||
);
|
||||
assert.equal(absurd.constraintStates[0].applied, false);
|
||||
assert.ok(
|
||||
absurd.diagnostics.some((item) => item.rule === 'direct-edit-crosswalk-inset-invalid'),
|
||||
'an out-of-range inset must produce a blocking diagnostic, not a silent clamp',
|
||||
);
|
||||
|
||||
// The kinds are distinct branches: a stop-line offset must not be validated or
|
||||
// written as a cutback, which the previous `else` fallthrough would have done.
|
||||
const stopOnly = resolveDirectEditConstraints(
|
||||
controlModel,
|
||||
document([controlConstraint('junction-stop-line-offset', { offsetMeters: 4 })]),
|
||||
{},
|
||||
).junctionPlans.get('junction').approaches['segment:a'];
|
||||
assert.equal(stopOnly.cutbackMeters !== 4, true, 'a stop-line offset must not be written as a cutback');
|
||||
|
||||
// The solver must be free of file and network access so preview can share it.
|
||||
const source = require('fs').readFileSync(require.resolve('../src/compile/direct-edit-solver'), 'utf8');
|
||||
for (const forbidden of ["require('fs')", "require('path')", "require('http')", "require('https')"])
|
||||
|
||||
@@ -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 --------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user