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:
@@ -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) {
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user