feat: classify direct edit replay states
This commit is contained in:
@@ -14,7 +14,7 @@ const { LAYER_REGISTRY, manifestForArea, validatePublishedLayers } = require('./
|
||||
const { loadOrGenerate, runtime } = require('../native-traffic-signals');
|
||||
const { readAreaConfigSnapshot } = require('./road-revisions');
|
||||
const { loadEditDocument } = require('./native-road-edits');
|
||||
const { resolveDirectEditConstraints } = require('./direct-edit-solver');
|
||||
const { GEOMETRY_VERSION, resolveDirectEditConstraints } = require('./direct-edit-solver');
|
||||
|
||||
function compileInput(input) {
|
||||
validateInput(input);
|
||||
@@ -46,7 +46,9 @@ function compileInput(input) {
|
||||
// compileGeometry. An area with no v2 document resolves to an empty result,
|
||||
// which leaves the geometry byte-identical to a compile without this stage.
|
||||
const directEdits = input.editsFile ? loadEditDocument(input.editsFile) : null;
|
||||
const directEdit = resolveDirectEditConstraints(model, directEdits);
|
||||
const directEdit = resolveDirectEditConstraints(model, directEdits, {
|
||||
compilerGeometryVersion: GEOMETRY_VERSION,
|
||||
});
|
||||
const compiled = compileGeometry(model, overrides, {
|
||||
edgeLines: area.nativeRoad.edgeLines,
|
||||
junctionTemplates: area.nativeRoad.junctionTemplates,
|
||||
|
||||
@@ -33,6 +33,7 @@ const KINDS = [
|
||||
'junction-corner-radius',
|
||||
];
|
||||
const MIN_LANE_WIDTH_METERS = 2.4;
|
||||
const GEOMETRY_VERSION = 'native-road-package/v1.1';
|
||||
|
||||
function emptyHandleManifest(context) {
|
||||
return {
|
||||
@@ -163,6 +164,60 @@ function junctionReserves(model) {
|
||||
return { reserves, approaches };
|
||||
}
|
||||
|
||||
function snapshotCandidates(model, constraint) {
|
||||
const snapshot = constraint.anchorSnapshot;
|
||||
if (!snapshot || !Array.isArray(snapshot.coordinate)) return [];
|
||||
const roads = (model.roads || []).filter((road) => {
|
||||
if (!road.centerline?.length) return false;
|
||||
const nodeMatch = snapshot.osmNodeIds?.some((id) => road.sourceNodeIds?.includes(String(id)));
|
||||
const nearest = road.centerline.reduce(
|
||||
(best, point) => Math.min(best, haversineMeters(snapshot.coordinate, point)),
|
||||
Infinity,
|
||||
);
|
||||
return nodeMatch && nearest <= 50;
|
||||
});
|
||||
return roads;
|
||||
}
|
||||
|
||||
function reconcileConstraints(model, constraints, base, junctionData, context) {
|
||||
const versionChanged = Boolean(
|
||||
context.compilerGeometryVersion &&
|
||||
base?.compilerGeometryVersion &&
|
||||
context.compilerGeometryVersion !== base.compilerGeometryVersion,
|
||||
);
|
||||
return constraints.map((constraint) => {
|
||||
if (!constraint.enabled || !constraint.anchor || !['exact', 'recheck'].includes(constraint.status))
|
||||
return constraint;
|
||||
let matched = false;
|
||||
if (constraint.kind.startsWith('road-')) {
|
||||
const road = roadForAnchor(model, constraint.anchor.roadId);
|
||||
matched = Boolean(road);
|
||||
if (matched && constraint.kind === 'road-lane-divider')
|
||||
matched = Number.isInteger(constraint.value?.boundaryIndex) && constraint.value.boundaryIndex < road.laneCount;
|
||||
if (!matched) {
|
||||
const candidates = snapshotCandidates(model, constraint);
|
||||
if (candidates.length === 1) return { ...constraint, status: 'pending' };
|
||||
return { ...constraint, status: candidates.length > 1 ? 'conflicted' : 'stale' };
|
||||
}
|
||||
} else if (constraint.kind === 'junction-corner-radius') {
|
||||
const approaches = junctionData.approaches.get(String(constraint.anchor.nodeId));
|
||||
matched = Boolean(
|
||||
approaches?.some((item) => item.segmentId === constraint.anchor.incomingRoadId) &&
|
||||
approaches?.some((item) => item.segmentId === constraint.anchor.outgoingRoadId),
|
||||
);
|
||||
} else {
|
||||
matched = Boolean(
|
||||
junctionData.approaches
|
||||
.get(String(constraint.anchor.nodeId))
|
||||
?.some((item) => item.segmentId === constraint.anchor.segmentId),
|
||||
);
|
||||
}
|
||||
if (!matched) return { ...constraint, status: 'stale' };
|
||||
if (versionChanged) return { ...constraint, status: 'recheck' };
|
||||
return constraint;
|
||||
});
|
||||
}
|
||||
|
||||
function roadForAnchor(model, roadId) {
|
||||
return (model.roads || []).find(
|
||||
(road) => road.id === roadId || road.sourceRoadId === roadId || road.segmentId === roadId,
|
||||
@@ -616,11 +671,12 @@ 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);
|
||||
const reconciled = reconcileConstraints(model || {}, constraints, editDocument?.base, junctionData, context);
|
||||
const solved = solveConstraints(model || {}, reconciled, junctionData, diagnostics);
|
||||
handles.reserves = junctionData.reserves;
|
||||
handles.handles = [
|
||||
...makeRoadHandles(model || {}, groups, handles.reserves, constraints),
|
||||
...makeJunctionHandles(model || {}, junctionData, constraints),
|
||||
...makeRoadHandles(model || {}, groups, handles.reserves, reconciled),
|
||||
...makeJunctionHandles(model || {}, junctionData, reconciled),
|
||||
];
|
||||
return {
|
||||
schema: RESOLUTION_SCHEMA,
|
||||
@@ -638,5 +694,6 @@ module.exports = {
|
||||
ACTIVE_STATUSES,
|
||||
SOLVED_KINDS,
|
||||
KINDS,
|
||||
GEOMETRY_VERSION,
|
||||
resolveDirectEditConstraints,
|
||||
};
|
||||
|
||||
@@ -144,6 +144,26 @@ assert.equal(allKindsResolution.junctionPlans.get('junction').approaches['segmen
|
||||
assert.equal(allKindsResolution.junctionPlans.get('junction').approaches['segment:a'].cutbackMeters, 2);
|
||||
assert.equal(allKindsResolution.junctionPlans.get('junction').corners['segment:a->segment:b'], 4);
|
||||
|
||||
const recheckResolution = resolveDirectEditConstraints(
|
||||
junctionModel,
|
||||
{ ...document([anchored]), base: { compilerGeometryVersion: 'old-geometry' } },
|
||||
{ compilerGeometryVersion: 'new-geometry' },
|
||||
);
|
||||
assert.equal(recheckResolution.constraintStates[0].status, 'recheck');
|
||||
assert.equal(recheckResolution.constraintStates[0].applied, true);
|
||||
|
||||
const pendingResolution = resolveDirectEditConstraints(
|
||||
junctionModel,
|
||||
document([
|
||||
constraint({
|
||||
anchor: { type: 'road-interval', roadId: 'missing-road', startStation: 0.2, endStation: 0.8, side: 'left' },
|
||||
anchorSnapshot: { ...constraint().anchorSnapshot, coordinate: [113, 30], osmNodeIds: ['junction'] },
|
||||
}),
|
||||
]),
|
||||
);
|
||||
assert.equal(pendingResolution.constraintStates[0].status, 'pending');
|
||||
assert.equal(pendingResolution.constraintStates[0].applied, false);
|
||||
|
||||
const invalidDivider = resolveDirectEditConstraints(
|
||||
junctionModel,
|
||||
document([
|
||||
@@ -155,8 +175,8 @@ const invalidDivider = resolveDirectEditConstraints(
|
||||
]),
|
||||
);
|
||||
assert.equal(invalidDivider.constraintStates[0].applied, false);
|
||||
assert.equal(invalidDivider.constraintStates[0].reason, 'invalid');
|
||||
assert.equal(invalidDivider.diagnostics[0].severity, 'error');
|
||||
assert.equal(invalidDivider.constraintStates[0].status, 'stale');
|
||||
assert.equal(invalidDivider.constraintStates[0].reason, 'status-stale');
|
||||
assert.equal(invalidDivider.roadProfiles.size, 0);
|
||||
|
||||
// A disabled constraint is reported, never applied, and produces no diagnostic:
|
||||
@@ -177,26 +197,20 @@ for (const status of ['pending', 'conflicted', 'stale']) {
|
||||
assert.deepEqual(resolution.diagnostics, []);
|
||||
}
|
||||
|
||||
// A solved kind with a missing road anchor is rejected as a blocking
|
||||
// constraint diagnostic rather than approximated or ignored.
|
||||
// An exact constraint whose anchor disappeared during reimport is stale and
|
||||
// must not enter the solver.
|
||||
for (const status of ['exact', 'recheck']) {
|
||||
const resolution = resolveDirectEditConstraints(model, document([constraint({ status })]));
|
||||
assert.deepEqual(resolution.constraintStates, [
|
||||
{
|
||||
constraintId: 'constraint-1',
|
||||
kind: 'road-edge-offset',
|
||||
status,
|
||||
status: 'stale',
|
||||
applied: false,
|
||||
reason: 'invalid',
|
||||
reason: 'status-stale',
|
||||
},
|
||||
]);
|
||||
assert.equal(resolution.diagnostics.length, 1);
|
||||
for (const item of resolution.diagnostics) {
|
||||
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);
|
||||
}
|
||||
assert.deepEqual(resolution.diagnostics, []);
|
||||
}
|
||||
|
||||
// Every constraint gets exactly one state, in document order, so a caller can
|
||||
|
||||
Reference in New Issue
Block a user