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

@@ -23,6 +23,8 @@ const SOLVED_KINDS = new Set([
'junction-approach-width',
'junction-cutback',
'junction-corner-radius',
'junction-crosswalk-inset',
'junction-stop-line-offset',
]);
const KINDS = [
'road-edge-offset',
@@ -31,8 +33,15 @@ const KINDS = [
'junction-approach-width',
'junction-cutback',
'junction-corner-radius',
'junction-crosswalk-inset',
'junction-stop-line-offset',
];
const MIN_LANE_WIDTH_METERS = 2.4;
// Bounds for the control-marking offsets. The crosswalk may sit anywhere from the
// junction boundary out to a few metres; the stop line must stay clear of the
// crossing it protects.
const MAX_CROSSWALK_INSET_METERS = 8;
const MIN_STOP_LINE_OFFSET_METERS = 0.5;
const GEOMETRY_VERSION = 'native-road-package/v1.1';
function emptyHandleManifest(context) {
@@ -366,27 +375,65 @@ function applyJunctionConstraint(model, constraint, junctionPlans, junctionData,
widthMeters: approach.items.reduce((sum, item) => sum + item.road.widthMeters, 0),
cutbackMeters: approach.cutback,
});
if (constraint.kind === 'junction-approach-width') {
const width = Number(constraint.value?.widthMeters);
if (!Number.isFinite(width) || width < 2.4) {
blocking(diagnostics, constraint, 'direct-edit-min-approach-width', '路口进口宽度不能小于 2.4 米。');
return false;
// An explicit branch per kind: the previous `else` meant every kind that was not
// approach-width fell through to the cutback validator, so a new kind would have
// been silently validated as, and written as, a cutback.
switch (constraint.kind) {
case 'junction-approach-width': {
const width = Number(constraint.value?.widthMeters);
if (!Number.isFinite(width) || width < MIN_LANE_WIDTH_METERS) {
blocking(diagnostics, constraint, 'direct-edit-min-approach-width', '路口进口宽度不能小于 2.4 米。');
return false;
}
entry.widthMeters = width;
return true;
}
entry.widthMeters = width;
} else {
const cutback = Number(constraint.value?.cutbackMeters);
if (!Number.isFinite(cutback) || cutback < 1 || cutback > approach.length * 0.45) {
blocking(
diagnostics,
constraint,
'direct-edit-cutback-invalid',
'路口 cutback 必须落在进口可用长度的 45% 以内。',
);
return false;
case 'junction-cutback': {
const cutback = Number(constraint.value?.cutbackMeters);
if (!Number.isFinite(cutback) || cutback < 1 || cutback > approach.length * 0.45) {
blocking(
diagnostics,
constraint,
'direct-edit-cutback-invalid',
'路口 cutback 必须落在进口可用长度的 45% 以内。',
);
return false;
}
entry.cutbackMeters = cutback;
return true;
}
entry.cutbackMeters = cutback;
case 'junction-crosswalk-inset': {
const inset = Number(constraint.value?.insetMeters);
if (!Number.isFinite(inset) || inset < 0 || inset > MAX_CROSSWALK_INSET_METERS) {
blocking(
diagnostics,
constraint,
'direct-edit-crosswalk-inset-invalid',
`斑马线内缩必须在 0 到 ${MAX_CROSSWALK_INSET_METERS} 米之间。`,
);
return false;
}
entry.crosswalkInsetMeters = inset;
return true;
}
case 'junction-stop-line-offset': {
const offset = Number(constraint.value?.offsetMeters);
if (!Number.isFinite(offset) || offset < MIN_STOP_LINE_OFFSET_METERS || offset > approach.length * 0.45) {
blocking(
diagnostics,
constraint,
'direct-edit-stop-line-offset-invalid',
`停止线退距必须在 ${MIN_STOP_LINE_OFFSET_METERS} 米到进口可用长度的 45% 之间。`,
);
return false;
}
entry.stopLineOffsetMeters = offset;
return true;
}
default:
blocking(diagnostics, constraint, 'direct-edit-unknown-junction-kind', '未知的路口约束类型。');
return false;
}
return true;
}
function solveConstraints(model, constraints, junctionData, diagnostics) {

View File

@@ -15,6 +15,8 @@ const CONSTRAINT_ANCHORS = {
'junction-approach-width': 'junction-approach',
'junction-cutback': 'junction-approach',
'junction-corner-radius': 'junction-corner',
'junction-crosswalk-inset': 'junction-approach',
'junction-stop-line-offset': 'junction-approach',
};
const CONSTRAINT_KINDS = Object.freeze(Object.keys(CONSTRAINT_ANCHORS));
const ANCHOR_TYPES = new Set(['road-station', 'road-interval', 'junction-approach', 'junction-corner']);
@@ -113,6 +115,16 @@ function validateValue(scope, kind, value) {
} else if (kind === 'junction-corner-radius') {
if (!isFiniteNumber(value.radiusMeters) || value.radiusMeters < 0)
fail(scope, 'value.radiusMeters', 'must be a non-negative number of meters');
} else if (kind === 'junction-crosswalk-inset') {
// How far the zebra sits back from the junction boundary. Negative would put
// it inside the intersection surface.
if (!isFiniteNumber(value.insetMeters) || value.insetMeters < 0)
fail(scope, 'value.insetMeters', 'must be a non-negative number of meters');
} else if (kind === 'junction-stop-line-offset') {
// Distance from the crossing back along the approach. Zero would put the stop
// line on the crosswalk itself.
if (!isFiniteNumber(value.offsetMeters) || value.offsetMeters <= 0)
fail(scope, 'value.offsetMeters', 'must be a positive number of meters');
}
}

View File

@@ -856,6 +856,11 @@ function applyDirectJunctionPlans(plans, directEdit) {
approach.cutbackMeters = override.cutbackMeters;
changed = true;
}
// Control-marking offsets move where the zebra and stop line sit; they do not
// reshape the junction, so they deliberately do not set `changed` and trigger
// a boundary recompute.
if (Number.isFinite(override.crosswalkInsetMeters)) approach.crosswalkInsetMeters = override.crosswalkInsetMeters;
if (Number.isFinite(override.stopLineOffsetMeters)) approach.stopLineOffsetMeters = override.stopLineOffsetMeters;
}
const cutbacks = Object.values(directPlan.approaches || {})
.map((entry) => entry.cutbackMeters)
@@ -1279,7 +1284,11 @@ function compileControlMarkings(model, lanes, diagnostics, junctionPlans = new M
const laneOffset = rawRoadPlacement ? project(approach.placement.point, rawRoadPlacement.point) : [0, 0];
const lateralOffset = laneOffset[0] * across[0] + laneOffset[1] * across[1];
const laneCenterAtCrossing = offsetByMeters(controlCenter, across, lateralOffset);
const stopCenter = offsetByMeters(laneCenterAtCrossing, approach.placement.axis, -STOP_LINE_OFFSET_METERS);
const stopCenter = offsetByMeters(
laneCenterAtCrossing,
approach.placement.axis,
-approachControls(junctionPlans, approach.road).stopLineOffsetMeters,
);
stopLines.push(
controlFeature(
'stop-line',
@@ -1294,11 +1303,30 @@ function compileControlMarkings(model, lanes, diagnostics, junctionPlans = new M
return { crosswalks, stopLines };
}
/**
* Per-approach overrides for the control markings, falling back to the module
* defaults. The junction a road approaches is the one at its far node, which is
* how `crossingJunctionInset()` has always resolved it.
*/
function approachControls(junctionPlans, road) {
const plan = junctionPlans.get(road?.sourceNodeIds?.at(-1));
const approach = plan?.approaches?.find?.((item) => item.segmentId === road.segmentId);
return {
crosswalkInsetMeters: Number.isFinite(approach?.crosswalkInsetMeters)
? approach.crosswalkInsetMeters
: CROSSWALK_JUNCTION_INSET_METERS,
stopLineOffsetMeters: Number.isFinite(approach?.stopLineOffsetMeters)
? approach.stopLineOffsetMeters
: STOP_LINE_OFFSET_METERS,
};
}
function crossingJunctionInset(candidate, junctionPlans) {
const junctionNodeId = candidate.road.sourceNodeIds.at(-1);
const plan = junctionPlans.get(junctionNodeId);
if (!plan) return 0;
const targetDistance = Math.max(0, plan.cutbackMeters - CROSSWALK_JUNCTION_INSET_METERS);
const inset = approachControls(junctionPlans, candidate.road).crosswalkInsetMeters;
const targetDistance = Math.max(0, plan.cutbackMeters - inset);
return Math.min(CROSSWALK_MAX_JUNCTION_INSET_METERS, Math.max(0, candidate.junctionDistanceMeters - targetDistance));
}