feat: solve direct edit constraints
This commit is contained in:
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user