feat: solve direct edit constraints

This commit is contained in:
2026-08-27 12:36:35 +08:00
parent 67e67e66bf
commit 0eb404d8f7
3 changed files with 390 additions and 47 deletions

View File

@@ -16,7 +16,14 @@ const {
const RESOLUTION_SCHEMA = 'road-edit-resolution/v1';
const HANDLE_MANIFEST_SCHEMA = 'road-edit-handles/v1';
const ACTIVE_STATUSES = new Set(['exact', 'recheck']);
const SOLVED_KINDS = new Set();
const SOLVED_KINDS = new Set([
'road-edge-offset',
'road-sidewalk-width',
'road-lane-divider',
'junction-approach-width',
'junction-cutback',
'junction-corner-radius',
]);
const KINDS = [
'road-edge-offset',
'road-sidewalk-width',
@@ -41,28 +48,6 @@ function constraintState(constraint, applied, reason) {
return { constraintId: constraint.id, kind: constraint.kind, status: constraint.status, applied, reason };
}
function planConstraints(constraints, diagnostics) {
return constraints.map((constraint) => {
if (!constraint.enabled) return constraintState(constraint, false, 'disabled');
if (!ACTIVE_STATUSES.has(constraint.status))
return constraintState(constraint, false, `status-${constraint.status}`);
if (!SOLVED_KINDS.has(constraint.kind)) {
diagnostics.push(
diagnostic(
'info',
`constraint:${constraint.id}`,
[],
'direct-edit-kind-not-solved',
`直接编辑约束 ${constraint.kind} 尚未接入求解器,本次编译按基线几何输出。`,
null,
),
);
return constraintState(constraint, false, 'kind-not-solved');
}
return constraintState(constraint, true, null);
});
}
function coordinateAt(line, station) {
if (!Array.isArray(line) || line.length === 0) return null;
if (line.length === 1) return [...line[0]];
@@ -112,7 +97,7 @@ function constraintFor(constraints, kind, anchor) {
item.anchor.type === 'road-interval' &&
item.anchor.roadId === anchor.roadId &&
(item.anchor.side || null) === (anchor.side || null) &&
(kind !== 'road-lane-divider' || item.anchor.boundaryIndex === anchor.boundaryIndex)
(kind !== 'road-lane-divider' || item.value?.boundaryIndex === anchor.boundaryIndex)
);
}
if (kind === 'junction-corner-radius')
@@ -178,6 +163,231 @@ function junctionReserves(model) {
return { reserves, approaches };
}
function roadForAnchor(model, roadId) {
return (model.roads || []).find(
(road) => road.id === roadId || road.sourceRoadId === roadId || road.segmentId === roadId,
);
}
function blocking(diagnostics, constraint, rule, message, coordinate = null) {
diagnostics.push(diagnostic('error', `constraint:${constraint.id}`, [], rule, message, coordinate));
}
function profileFor(profiles, road) {
if (!profiles.has(road.id))
profiles.set(road.id, {
roadId: road.id,
segmentId: road.segmentId,
interval: null,
edgeOffsets: { left: 0, right: 0 },
sidewalkWidths: {
left: road.sidewalkLeft ? 2 : 0,
right: road.sidewalkRight ? 2 : 0,
},
laneDividerOffsets: {},
transitions: {},
widthMeters: road.widthMeters,
});
return profiles.get(road.id);
}
function applyRoadConstraint(model, constraint, profiles, diagnostics) {
const road = roadForAnchor(model, constraint.anchor?.roadId);
if (!road) {
blocking(diagnostics, constraint, 'direct-edit-road-anchor-missing', '直接编辑约束的道路锚点不存在。');
return false;
}
const anchor = constraint.anchor;
if (
anchor.type !== 'road-interval' ||
!Number.isFinite(anchor.startStation) ||
!Number.isFinite(anchor.endStation) ||
anchor.startStation < 0 ||
anchor.endStation > 1 ||
anchor.startStation >= anchor.endStation
) {
blocking(
diagnostics,
constraint,
'direct-edit-invalid-interval',
'道路编辑区间必须按 0..1 的归一化 station 严格递增。',
road.centerline?.[0],
);
return false;
}
const profile = profileFor(profiles, road);
profile.interval = profile.interval
? {
startStation: Math.min(profile.interval.startStation, anchor.startStation),
endStation: Math.max(profile.interval.endStation, anchor.endStation),
}
: { startStation: anchor.startStation, endStation: anchor.endStation };
const sides = anchor.side ? [anchor.side] : ['left', 'right'];
if (sides.some((side) => !['left', 'right'].includes(side))) {
blocking(diagnostics, constraint, 'direct-edit-invalid-side', '道路约束 side 必须是 left 或 right。');
return false;
}
if (constraint.kind === 'road-edge-offset') {
const offset = Number(constraint.value?.offsetMeters);
if (!Number.isFinite(offset)) {
blocking(diagnostics, constraint, 'direct-edit-invalid-value', '外缘偏移必须是有限米数。');
return false;
}
for (const side of sides) {
profile.edgeOffsets[side] = offset;
profile.transitions[`road-edge-offset:${side}`] = constraint.value?.transition || 'smoothstep';
}
} else if (constraint.kind === 'road-sidewalk-width') {
const width = Number(constraint.value?.widthMeters);
if (!Number.isFinite(width) || width < 0) {
blocking(diagnostics, constraint, 'direct-edit-invalid-value', '步行带宽度必须是非负米数。');
return false;
}
for (const side of sides) {
profile.sidewalkWidths[side] = width;
profile.transitions[`road-sidewalk-width:${side}`] = constraint.value?.transition || 'smoothstep';
}
} else if (constraint.kind === 'road-lane-divider') {
const boundaryIndex = constraint.value?.boundaryIndex;
const offset = Number(constraint.value?.offsetMeters);
if (!Number.isInteger(boundaryIndex) || boundaryIndex < 1 || boundaryIndex >= road.laneCount) {
blocking(diagnostics, constraint, 'direct-edit-lane-boundary-stale', '车道分隔 boundaryIndex 已超出当前车道数。');
return false;
}
if (!Number.isFinite(offset)) {
blocking(diagnostics, constraint, 'direct-edit-invalid-value', '车道分隔偏移必须是有限米数。');
return false;
}
const laneWidth = road.widthMeters / road.laneCount;
if (laneWidth - Math.abs(offset) < 2.4) {
blocking(diagnostics, constraint, 'direct-edit-min-lane-width', '车道分隔调整会使相邻车道小于 2.4 米。');
return false;
}
profile.laneDividerOffsets[boundaryIndex] = offset;
profile.transitions[`road-lane-divider:${boundaryIndex}`] = constraint.value?.transition || 'smoothstep';
}
profile.widthMeters = road.widthMeters + profile.edgeOffsets.left + profile.edgeOffsets.right;
if (
Math.abs(profile.edgeOffsets.left) > road.widthMeters / 2 ||
Math.abs(profile.edgeOffsets.right) > road.widthMeters / 2 ||
profile.widthMeters < road.laneCount * 2.4
) {
blocking(diagnostics, constraint, 'direct-edit-outer-edges-cross', '左右外缘调整后无法容纳最小车道宽度。');
return false;
}
return true;
}
function applyJunctionConstraint(model, constraint, junctionPlans, junctionData, diagnostics) {
const anchor = constraint.anchor || {};
const approaches = junctionData.approaches.get(String(anchor.nodeId));
const approach = approaches?.find((item) => item.segmentId === anchor.segmentId);
if (constraint.kind !== 'junction-corner-radius' && !approach) {
blocking(diagnostics, constraint, 'direct-edit-junction-anchor-missing', '直接编辑约束的路口进口锚点不存在。');
return false;
}
if (constraint.kind === 'junction-corner-radius') {
const incoming = approaches?.find((item) => item.segmentId === anchor.incomingRoadId);
const outgoing = approaches?.find((item) => item.segmentId === anchor.outgoingRoadId);
const radius = Number(constraint.value?.radiusMeters);
if (!incoming || !outgoing) {
blocking(diagnostics, constraint, 'direct-edit-junction-corner-missing', '直接编辑约束的路口角部锚点不存在。');
return false;
}
if (!Number.isFinite(radius) || radius < 0.5 || radius > 30) {
blocking(diagnostics, constraint, 'direct-edit-corner-radius-invalid', '路口角部半径必须在 0.5 到 30 米之间。');
return false;
}
if (!junctionPlans.has(String(anchor.nodeId)))
junctionPlans.set(String(anchor.nodeId), { nodeId: String(anchor.nodeId), approaches: {}, corners: {} });
const plan = junctionPlans.get(String(anchor.nodeId));
plan.corners[`${anchor.incomingRoadId}->${anchor.outgoingRoadId}`] = radius;
return true;
}
if (!junctionPlans.has(String(anchor.nodeId)))
junctionPlans.set(String(anchor.nodeId), { nodeId: String(anchor.nodeId), approaches: {}, corners: {} });
const plan = junctionPlans.get(String(anchor.nodeId));
const entry = (plan.approaches[anchor.segmentId] ||= {
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;
}
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;
}
entry.cutbackMeters = cutback;
}
return true;
}
function solveConstraints(model, constraints, junctionData, diagnostics) {
const roadProfiles = new Map();
const junctionPlans = new Map();
const states = [];
for (const constraint of constraints) {
if (!constraint.enabled) {
states.push(constraintState(constraint, false, 'disabled'));
continue;
}
if (!ACTIVE_STATUSES.has(constraint.status)) {
states.push(constraintState(constraint, false, `status-${constraint.status}`));
continue;
}
if (!SOLVED_KINDS.has(constraint.kind)) {
states.push(constraintState(constraint, false, 'kind-not-solved'));
continue;
}
const roadSnapshot = new Map(
[...roadProfiles].map(([key, profile]) => [
key,
{
...profile,
edgeOffsets: { ...profile.edgeOffsets },
sidewalkWidths: { ...profile.sidewalkWidths },
laneDividerOffsets: { ...profile.laneDividerOffsets },
transitions: { ...profile.transitions },
interval: profile.interval && { ...profile.interval },
},
]),
);
const junctionSnapshot = new Map(
[...junctionPlans].map(([key, plan]) => [
key,
{
...plan,
approaches: Object.fromEntries(Object.entries(plan.approaches).map(([id, entry]) => [id, { ...entry }])),
corners: { ...plan.corners },
},
]),
);
const applied = constraint.kind.startsWith('road-')
? applyRoadConstraint(model, constraint, roadProfiles, diagnostics)
: applyJunctionConstraint(model, constraint, junctionPlans, junctionData, diagnostics);
if (!applied) {
roadProfiles.clear();
for (const [key, profile] of roadSnapshot) roadProfiles.set(key, profile);
junctionPlans.clear();
for (const [key, plan] of junctionSnapshot) junctionPlans.set(key, plan);
}
states.push(constraintState(constraint, applied, applied ? null : 'invalid'));
}
return { roadProfiles, junctionPlans, states };
}
function roadAffects(roads, model, side, boundaryIndex) {
const ids = [];
for (const road of roads) {
@@ -406,6 +616,7 @@ function resolveDirectEditConstraints(model, editDocument = null, context = {})
const handles = emptyHandleManifest(context);
const groups = roadGroups(model || {});
const junctionData = junctionReserves(model || {});
const solved = solveConstraints(model || {}, constraints, junctionData, diagnostics);
handles.reserves = junctionData.reserves;
handles.handles = [
...makeRoadHandles(model || {}, groups, handles.reserves, constraints),
@@ -413,10 +624,10 @@ function resolveDirectEditConstraints(model, editDocument = null, context = {})
];
return {
schema: RESOLUTION_SCHEMA,
roadProfiles: new Map(),
junctionPlans: new Map(),
roadProfiles: solved.roadProfiles,
junctionPlans: solved.junctionPlans,
handles,
constraintStates: planConstraints(constraints, diagnostics),
constraintStates: solved.states,
diagnostics,
};
}

View File

@@ -608,8 +608,10 @@ function detectComplexJunctionCandidates(model, junctionPlans, options, diagnost
}
function compileGeometry(model, overrides = { overrides: [] }, options = {}) {
model = modelWithDirectEditProfiles(model, options.directEdit);
const diagnostics = [...model.diagnostics];
const junctionPlans = compileJunctionPlans(model, options, diagnostics);
applyDirectJunctionPlans(junctionPlans, options.directEdit);
detectComplexJunctionCandidates(model, junctionPlans, options, diagnostics);
const features = [];
const activeClusters = options.junctionTemplates?.enabled ? options.junctionTemplates.clusters || [] : [];
@@ -806,6 +808,76 @@ function compileGeometry(model, overrides = { overrides: [] }, options = {}) {
};
}
function modelWithDirectEditProfiles(model, directEdit) {
if (!directEdit?.roadProfiles?.size && !directEdit?.junctionPlans?.size) return model;
const approachWidths = new Map();
for (const plan of directEdit.junctionPlans?.values?.() || [])
for (const [segmentId, entry] of Object.entries(plan.approaches || {}))
if (Number.isFinite(entry.widthMeters)) approachWidths.set(segmentId, entry.widthMeters);
const roads = model.roads.map((road) => {
const profile = directEdit.roadProfiles.get(road.id);
const approachWidth = approachWidths.get(road.segmentId);
const siblingWidth = model.roads
.filter((item) => item.segmentId === road.segmentId)
.reduce((sum, item) => sum + item.widthMeters, 0);
const approachScale = Number.isFinite(approachWidth) && siblingWidth > 0 ? approachWidth / siblingWidth : 1;
if (!profile && approachScale === 1) return road;
const centerlineShift = profile ? ((profile.edgeOffsets?.left || 0) - (profile.edgeOffsets?.right || 0)) / 2 : 0;
const shiftedCenterline =
Math.abs(centerlineShift) > 1e-9 ? offsetLine(road.centerline, centerlineShift) : road.centerline;
return {
...road,
centerline: shiftedCenterline || road.centerline,
widthMeters: Number.isFinite(profile?.widthMeters)
? profile.widthMeters * approachScale
: road.widthMeters * approachScale,
sidewalkLeft: profile ? profile.sidewalkWidths?.left > 0 : road.sidewalkLeft,
sidewalkRight: profile ? profile.sidewalkWidths?.right > 0 : road.sidewalkRight,
directEditProfile: profile || null,
};
});
return { ...model, roads };
}
function applyDirectJunctionPlans(plans, directEdit) {
if (!directEdit?.junctionPlans?.size) return;
for (const [nodeId, directPlan] of directEdit.junctionPlans) {
const plan = plans.get(String(nodeId));
if (!plan) continue;
let changed = false;
for (const approach of plan.approaches) {
const override = directPlan.approaches?.[approach.segmentId];
if (!override) continue;
if (Number.isFinite(override.widthMeters)) {
approach.widthMeters = override.widthMeters;
changed = true;
}
if (Number.isFinite(override.cutbackMeters)) {
approach.cutbackMeters = override.cutbackMeters;
changed = true;
}
}
const cutbacks = Object.values(directPlan.approaches || {})
.map((entry) => entry.cutbackMeters)
.filter(Number.isFinite);
if (cutbacks.length) {
plan.cutbackMeters = Math.max(...cutbacks);
changed = true;
}
const requestedRadii = Object.values(directPlan.corners || {}).filter(Number.isFinite);
if (requestedRadii.length) changed = true;
if (!changed) continue;
const referenceWidth = Math.max(...plan.approaches.map((approach) => approach.widthMeters), 1);
const cornerRadiusMultiplier = requestedRadii.length
? Math.max(0.1, Math.min(3, requestedRadii[0] / referenceWidth))
: 1;
const boundary = junctionBoundary(plan.approaches, plan.node, plan.cutbackMeters, cornerRadiusMultiplier, 1);
plan.boundary = boundary.points;
plan.boundaryMode = boundary.mode;
plan.boundaryFallbacks = boundary.fallbacks;
}
}
function compileComplexPreviewArrows(features, stopLines) {
const result = [];
for (const feature of features) {
@@ -1590,16 +1662,13 @@ function compileSidewalkSurfaces(model, diagnostics, junctionPlans, options = {}
const cluster = clusterForRoad(forward, options) || (backward ? clusterForRoad(backward, options) : null);
const center = cluster ? clusterCenter(cluster, junctionPlans) : null;
for (const [side, enabled] of sides) {
if (!enabled) continue;
const profile = forward.directEditProfile;
const sidewalkWidth = profile?.sidewalkWidths?.[side] ?? DEFAULT_SIDEWALK_WIDTH_METERS;
if (!enabled || sidewalkWidth <= 0) continue;
const centerline = cluster
? trimLineAtComplexCluster(forward.centerline, forward.sourceNodeIds, junctionPlans, cluster, center)
: trimLineAtJunctions(forward.centerline, forward.sourceNodeIds, junctionPlans);
const ring = sidewalkRing(
centerline,
totalWidth / 2,
totalWidth / 2 + DEFAULT_SIDEWALK_WIDTH_METERS,
side === 'left' ? 1 : -1,
);
const ring = sidewalkRing(centerline, totalWidth / 2, totalWidth / 2 + sidewalkWidth, side === 'left' ? 1 : -1);
if (!ring) {
diagnostics.push(
diagnostic(
@@ -1622,7 +1691,7 @@ function compileSidewalkSurfaces(model, diagnostics, junctionPlans, options = {}
osm_way_ids: forward.osmWayIds.join(','),
source_road_id: forward.sourceRoadId,
side,
width_m: DEFAULT_SIDEWALK_WIDTH_METERS,
width_m: sidewalkWidth,
directional_road_ids: directions.map((road) => road.id).join(','),
provenance: 'native-road-sidewalk/v1',
override_ids: directions.flatMap((road) => road.appliedOverrideIds).join(','),
@@ -1874,7 +1943,10 @@ function compileLaneCenterlines(model, diagnostics, junctionPlans, options = {},
for (let index = 0; index < road.laneCount; index += 1) {
// OSM `turn:lanes` is ordered from left to right. Keep lane 1 on the
// driver's left so tag positions and generated lane IDs have one meaning.
const offset = carriagewayOffset + (road.widthMeters / 2 - laneWidth * (index + 0.5));
const dividerAdjustment = Object.entries(road.directEditProfile?.laneDividerOffsets || {})
.filter(([boundary]) => index + 1 > Number(boundary))
.reduce((sum, [, value]) => sum + Number(value) / Math.max(road.laneCount, 1), 0);
const offset = carriagewayOffset + (road.widthMeters / 2 - laneWidth * (index + 0.5)) + dividerAdjustment;
const coordinates = offsetLine(trimLineAtJunctions(road.centerline, road.sourceNodeIds, junctionPlans), offset);
const publishedCoordinates = offsetLine(clippedRoadLine, offset);
if (!coordinates || !publishedCoordinates) {

View File

@@ -1,7 +1,7 @@
'use strict';
const assert = require('assert/strict');
const { resolveDirectEditConstraints, SOLVED_KINDS } = require('../src/compile/direct-edit-solver');
const { resolveDirectEditConstraints } = require('../src/compile/direct-edit-solver');
const model = { roads: [], endpoints: [], connections: [], diagnostics: [] };
@@ -96,6 +96,69 @@ const anchoredHandle = anchoredResolution.handles.handles.find(
);
assert.equal(anchoredHandle.constraintId, 'edge-on-road-a');
const allKinds = [
anchored,
constraint({
id: 'sidewalk-on-road-a',
kind: 'road-sidewalk-width',
anchor: { type: 'road-interval', roadId: 'road:a', startStation: 0.2, endStation: 0.8, side: 'left' },
value: { widthMeters: 3, transition: 'linear' },
}),
constraint({
id: 'divider-on-road-a',
kind: 'road-lane-divider',
anchor: { type: 'road-interval', roadId: 'road:a', startStation: 0.2, endStation: 0.8 },
value: { boundaryIndex: 1, offsetMeters: 0.1, transition: 'smoothstep' },
}),
constraint({
id: 'approach-width',
kind: 'junction-approach-width',
anchor: { type: 'junction-approach', nodeId: 'junction', segmentId: 'segment:a' },
value: { widthMeters: 7 },
}),
constraint({
id: 'approach-cutback',
kind: 'junction-cutback',
anchor: { type: 'junction-approach', nodeId: 'junction', segmentId: 'segment:a' },
value: { cutbackMeters: 2 },
}),
constraint({
id: 'corner-radius',
kind: 'junction-corner-radius',
anchor: { type: 'junction-corner', nodeId: 'junction', incomingRoadId: 'segment:a', outgoingRoadId: 'segment:b' },
value: { radiusMeters: 4 },
}),
];
const allKindsResolution = resolveDirectEditConstraints(junctionModel, document(allKinds));
assert.deepEqual(
allKindsResolution.constraintStates.map((state) => state.applied),
[true, true, true, true, true, true],
);
assert.equal(allKindsResolution.roadProfiles.get('road:a').sidewalkWidths.left, 3);
assert.equal(allKindsResolution.roadProfiles.get('road:a').laneDividerOffsets[1], 0.1);
assert.equal(
allKindsResolution.handles.handles.find((handle) => handle.kind === 'road-lane-divider').constraintId,
'divider-on-road-a',
);
assert.equal(allKindsResolution.junctionPlans.get('junction').approaches['segment:a'].widthMeters, 7);
assert.equal(allKindsResolution.junctionPlans.get('junction').approaches['segment:a'].cutbackMeters, 2);
assert.equal(allKindsResolution.junctionPlans.get('junction').corners['segment:a->segment:b'], 4);
const invalidDivider = resolveDirectEditConstraints(
junctionModel,
document([
constraint({
kind: 'road-lane-divider',
anchor: { type: 'road-interval', roadId: 'road:a', startStation: 0.2, endStation: 0.8 },
value: { boundaryIndex: 3, offsetMeters: 0 },
}),
]),
);
assert.equal(invalidDivider.constraintStates[0].applied, false);
assert.equal(invalidDivider.constraintStates[0].reason, 'invalid');
assert.equal(invalidDivider.diagnostics[0].severity, 'error');
assert.equal(invalidDivider.roadProfiles.size, 0);
// A disabled constraint is reported, never applied, and produces no diagnostic:
// the user turned it off deliberately, so it is not a problem to report.
const disabled = resolveDirectEditConstraints(model, document([constraint({ enabled: false })]));
@@ -114,26 +177,23 @@ for (const status of ['pending', 'conflicted', 'stale']) {
assert.deepEqual(resolution.diagnostics, []);
}
// Until a kind is wired into the solver its constraints are reported as
// unapplied with an explicit diagnostic, rather than approximated or ignored.
// A solved kind with a missing road anchor is rejected as a blocking
// constraint diagnostic rather than approximated or ignored.
for (const status of ['exact', 'recheck']) {
const resolution = resolveDirectEditConstraints(model, document([constraint({ status })]));
const expected = SOLVED_KINDS.has('road-edge-offset')
? { applied: true, reason: null, diagnostics: 0 }
: { applied: false, reason: 'kind-not-solved', diagnostics: 1 };
assert.deepEqual(resolution.constraintStates, [
{
constraintId: 'constraint-1',
kind: 'road-edge-offset',
status,
applied: expected.applied,
reason: expected.reason,
applied: false,
reason: 'invalid',
},
]);
assert.equal(resolution.diagnostics.length, expected.diagnostics);
assert.equal(resolution.diagnostics.length, 1);
for (const item of resolution.diagnostics) {
assert.equal(item.severity, 'info');
assert.equal(item.rule, 'direct-edit-kind-not-solved');
assert.equal(item.severity, 'error');
assert.equal(item.rule, 'direct-edit-road-anchor-missing');
assert.equal(item.subjectId, 'constraint:constraint-1');
assert.equal(item.geometry, null);
}