feat: classify direct edit replay states

This commit is contained in:
2026-08-27 12:43:35 +08:00
parent 94e319c717
commit 735ce69d26
3 changed files with 91 additions and 18 deletions

View File

@@ -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,

View File

@@ -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,
}; };

View File

@@ -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