From 735ce69d26604036aef53f1a34d135dd2d12cc5a Mon Sep 17 00:00:00 2001 From: que01 Date: Thu, 27 Aug 2026 12:43:35 +0800 Subject: [PATCH] feat: classify direct edit replay states --- src/compile/compiler.js | 6 ++- src/compile/direct-edit-solver.js | 63 +++++++++++++++++++++++++++++-- test/direct-edit-solver.js | 40 +++++++++++++------- 3 files changed, 91 insertions(+), 18 deletions(-) diff --git a/src/compile/compiler.js b/src/compile/compiler.js index 9c2c9fe..6fd5ee3 100644 --- a/src/compile/compiler.js +++ b/src/compile/compiler.js @@ -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, diff --git a/src/compile/direct-edit-solver.js b/src/compile/direct-edit-solver.js index 9047ebd..27fd504 100644 --- a/src/compile/direct-edit-solver.js +++ b/src/compile/direct-edit-solver.js @@ -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, }; diff --git a/test/direct-edit-solver.js b/test/direct-edit-solver.js index 9c89610..3b055f9 100644 --- a/test/direct-edit-solver.js +++ b/test/direct-edit-solver.js @@ -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