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 { loadOrGenerate, runtime } = require('../native-traffic-signals');
|
||||||
const { readAreaConfigSnapshot } = require('./road-revisions');
|
const { readAreaConfigSnapshot } = require('./road-revisions');
|
||||||
const { loadEditDocument } = require('./native-road-edits');
|
const { loadEditDocument } = require('./native-road-edits');
|
||||||
const { resolveDirectEditConstraints } = require('./direct-edit-solver');
|
const { GEOMETRY_VERSION, resolveDirectEditConstraints } = require('./direct-edit-solver');
|
||||||
|
|
||||||
function compileInput(input) {
|
function compileInput(input) {
|
||||||
validateInput(input);
|
validateInput(input);
|
||||||
@@ -46,7 +46,9 @@ function compileInput(input) {
|
|||||||
// compileGeometry. An area with no v2 document resolves to an empty result,
|
// compileGeometry. An area with no v2 document resolves to an empty result,
|
||||||
// which leaves the geometry byte-identical to a compile without this stage.
|
// which leaves the geometry byte-identical to a compile without this stage.
|
||||||
const directEdits = input.editsFile ? loadEditDocument(input.editsFile) : null;
|
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, {
|
const compiled = compileGeometry(model, overrides, {
|
||||||
edgeLines: area.nativeRoad.edgeLines,
|
edgeLines: area.nativeRoad.edgeLines,
|
||||||
junctionTemplates: area.nativeRoad.junctionTemplates,
|
junctionTemplates: area.nativeRoad.junctionTemplates,
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ const KINDS = [
|
|||||||
'junction-corner-radius',
|
'junction-corner-radius',
|
||||||
];
|
];
|
||||||
const MIN_LANE_WIDTH_METERS = 2.4;
|
const MIN_LANE_WIDTH_METERS = 2.4;
|
||||||
|
const GEOMETRY_VERSION = 'native-road-package/v1.1';
|
||||||
|
|
||||||
function emptyHandleManifest(context) {
|
function emptyHandleManifest(context) {
|
||||||
return {
|
return {
|
||||||
@@ -163,6 +164,60 @@ function junctionReserves(model) {
|
|||||||
return { reserves, approaches };
|
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) {
|
function roadForAnchor(model, roadId) {
|
||||||
return (model.roads || []).find(
|
return (model.roads || []).find(
|
||||||
(road) => road.id === roadId || road.sourceRoadId === roadId || road.segmentId === roadId,
|
(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 handles = emptyHandleManifest(context);
|
||||||
const groups = roadGroups(model || {});
|
const groups = roadGroups(model || {});
|
||||||
const junctionData = junctionReserves(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.reserves = junctionData.reserves;
|
||||||
handles.handles = [
|
handles.handles = [
|
||||||
...makeRoadHandles(model || {}, groups, handles.reserves, constraints),
|
...makeRoadHandles(model || {}, groups, handles.reserves, reconciled),
|
||||||
...makeJunctionHandles(model || {}, junctionData, constraints),
|
...makeJunctionHandles(model || {}, junctionData, reconciled),
|
||||||
];
|
];
|
||||||
return {
|
return {
|
||||||
schema: RESOLUTION_SCHEMA,
|
schema: RESOLUTION_SCHEMA,
|
||||||
@@ -638,5 +694,6 @@ module.exports = {
|
|||||||
ACTIVE_STATUSES,
|
ACTIVE_STATUSES,
|
||||||
SOLVED_KINDS,
|
SOLVED_KINDS,
|
||||||
KINDS,
|
KINDS,
|
||||||
|
GEOMETRY_VERSION,
|
||||||
resolveDirectEditConstraints,
|
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').approaches['segment:a'].cutbackMeters, 2);
|
||||||
assert.equal(allKindsResolution.junctionPlans.get('junction').corners['segment:a->segment:b'], 4);
|
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(
|
const invalidDivider = resolveDirectEditConstraints(
|
||||||
junctionModel,
|
junctionModel,
|
||||||
document([
|
document([
|
||||||
@@ -155,8 +175,8 @@ const invalidDivider = resolveDirectEditConstraints(
|
|||||||
]),
|
]),
|
||||||
);
|
);
|
||||||
assert.equal(invalidDivider.constraintStates[0].applied, false);
|
assert.equal(invalidDivider.constraintStates[0].applied, false);
|
||||||
assert.equal(invalidDivider.constraintStates[0].reason, 'invalid');
|
assert.equal(invalidDivider.constraintStates[0].status, 'stale');
|
||||||
assert.equal(invalidDivider.diagnostics[0].severity, 'error');
|
assert.equal(invalidDivider.constraintStates[0].reason, 'status-stale');
|
||||||
assert.equal(invalidDivider.roadProfiles.size, 0);
|
assert.equal(invalidDivider.roadProfiles.size, 0);
|
||||||
|
|
||||||
// A disabled constraint is reported, never applied, and produces no diagnostic:
|
// 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, []);
|
assert.deepEqual(resolution.diagnostics, []);
|
||||||
}
|
}
|
||||||
|
|
||||||
// A solved kind with a missing road anchor is rejected as a blocking
|
// An exact constraint whose anchor disappeared during reimport is stale and
|
||||||
// constraint diagnostic rather than approximated or ignored.
|
// must not enter the solver.
|
||||||
for (const status of ['exact', 'recheck']) {
|
for (const status of ['exact', 'recheck']) {
|
||||||
const resolution = resolveDirectEditConstraints(model, document([constraint({ status })]));
|
const resolution = resolveDirectEditConstraints(model, document([constraint({ status })]));
|
||||||
assert.deepEqual(resolution.constraintStates, [
|
assert.deepEqual(resolution.constraintStates, [
|
||||||
{
|
{
|
||||||
constraintId: 'constraint-1',
|
constraintId: 'constraint-1',
|
||||||
kind: 'road-edge-offset',
|
kind: 'road-edge-offset',
|
||||||
status,
|
status: 'stale',
|
||||||
applied: false,
|
applied: false,
|
||||||
reason: 'invalid',
|
reason: 'status-stale',
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
assert.equal(resolution.diagnostics.length, 1);
|
assert.deepEqual(resolution.diagnostics, []);
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Every constraint gets exactly one state, in document order, so a caller can
|
// Every constraint gets exactly one state, in document order, so a caller can
|
||||||
|
|||||||
Reference in New Issue
Block a user