feat: insert direct edit constraint solver stage

Adds resolveDirectEditConstraints between compileRoadModel and
compileGeometry, per the parent design's data flow. The stage is an
identity transform until the individual constraint kinds land: it
returns empty road profiles, junction plans and handle manifest, so
compileGeometry's geometry code is untouched and output stays
byte-identical.

Constraint planning is separated from solving up front. Only `exact`
and `recheck` constraints may reach the solver; `disabled`,
`pending`, `conflicted` and `stale` are reported as unapplied with a
reason. SOLVED_KINDS starts empty and grows one entry per kind, so a
partially delivered solver reports what it skipped instead of
publishing half-solved geometry.

diagnostic() moves to src/compile/diagnostics.js so the solver can
share the compiler's diagnostic shape without depending on fs, which
would break the pure-function boundary that lets preview, the full
compile and the CLI export share one implementation.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-27 10:29:45 +08:00
parent c5e762e1a9
commit 3297d0c97a
9 changed files with 274 additions and 14 deletions

View File

@@ -13,6 +13,8 @@ const {
const { LAYER_REGISTRY, manifestForArea, validatePublishedLayers } = require('./layer-manifest');
const { loadOrGenerate, runtime } = require('../native-traffic-signals');
const { readAreaConfigSnapshot } = require('./road-revisions');
const { loadEditDocument } = require('./native-road-edits');
const { resolveDirectEditConstraints } = require('./direct-edit-solver');
function compileInput(input) {
validateInput(input);
@@ -40,10 +42,17 @@ function compileInput(input) {
for (const item of validated.stale)
console.warn(`[warning] 忽略失效的 override目标已不存在${item.id} -> ${item.target}`);
fs.mkdirSync(area.outputs.pipelineDir, { recursive: true });
// Direct edits resolve against the model *after* v1 overrides, then feed
// 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 compiled = compileGeometry(model, overrides, {
edgeLines: area.nativeRoad.edgeLines,
junctionTemplates: area.nativeRoad.junctionTemplates,
directEdit,
});
compiled.diagnostics.push(...directEdit.diagnostics);
compiled.diagnostics.push(
...validated.stale.map((item) => ({
id: `diagnostic:stale-override:${item.id}`,
@@ -169,6 +178,8 @@ function validateInput(input) {
(typeof input.areaConfigSnapshotFile !== 'string' || !input.areaConfigSnapshotFile)
)
throw new Error('RoadCompilerInput.areaConfigSnapshotFile must be a non-empty string when present');
if (input.editsFile !== undefined && (typeof input.editsFile !== 'string' || !input.editsFile))
throw new Error('RoadCompilerInput.editsFile must be a non-empty string when present');
}
function validateOptions(options) {

View File

@@ -0,0 +1,19 @@
'use strict';
// Shared so the geometry compiler and the constraint solver report problems in
// one shape. The solver must stay free of `fs`, which rules out reaching into
// native-road.js for this, and re-declaring the shape there would let the two
// drift apart field by field.
function diagnostic(severity, subjectId, sourceIds, rule, message, coordinate) {
return {
id: `diagnostic:${rule}:${subjectId}`,
severity,
subjectId,
sourceIds,
rule,
message,
geometry: coordinate ? { type: 'Point', coordinates: coordinate } : null,
};
}
module.exports = { diagnostic };

View File

@@ -0,0 +1,85 @@
'use strict';
const { diagnostic } = require('./diagnostics');
const RESOLUTION_SCHEMA = 'road-edit-resolution/v1';
const HANDLE_MANIFEST_SCHEMA = 'road-edit-handles/v1';
// Only these two statuses may reshape geometry. `pending`, `conflicted` and
// `stale` are reported and skipped on purpose: applying a constraint whose
// anchor no longer resolves would silently reshape the wrong road, which is the
// exact failure the replay rules exist to prevent. Matching decides the status;
// this module only obeys it.
const ACTIVE_STATUSES = new Set(['exact', 'recheck']);
// Grows one entry per kind as the solver learns to apply it. A kind absent here
// is reported as unapplied rather than approximated, so a partially delivered
// solver never publishes half-solved geometry.
const SOLVED_KINDS = new Set();
function emptyHandleManifest(context) {
return {
schema: HANDLE_MANIFEST_SCHEMA,
revisionId: typeof context.revisionId === 'string' ? context.revisionId : null,
previewSeq: Number.isInteger(context.previewSeq) ? context.previewSeq : 0,
handles: [],
reserves: [],
};
}
function constraintState(constraint, applied, reason) {
return { constraintId: constraint.id, kind: constraint.kind, status: constraint.status, applied, reason };
}
// Phase one: decide which constraints are allowed to touch geometry and record
// why the rest are not. Keeping this separate from solving is what stops a
// diagnostic and a geometry decision from being derived from each other.
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);
});
}
// Pure: no file or network access, so preview, the full compile and the CLI
// export can share one implementation and unit fixtures can drive it directly.
// `model` is the road model after v1 overrides are applied; `editDocument` is a
// validated `native-road-edits/v2` document, or null when an area has no edits.
function resolveDirectEditConstraints(model, editDocument = null, context = {}) {
const diagnostics = [];
const constraints = Array.isArray(editDocument && editDocument.constraints) ? editDocument.constraints : [];
return {
schema: RESOLUTION_SCHEMA,
// Empty means "use the baseline cross-section / junction geometry". Later
// steps populate these per road and per node; an empty resolution is an
// identity transform over compileGeometry by construction.
roadProfiles: new Map(),
junctionPlans: new Map(),
handles: emptyHandleManifest(context),
constraintStates: planConstraints(constraints, diagnostics),
diagnostics,
};
}
module.exports = {
RESOLUTION_SCHEMA,
HANDLE_MANIFEST_SCHEMA,
ACTIVE_STATUSES,
SOLVED_KINDS,
resolveDirectEditConstraints,
};

View File

@@ -4,6 +4,7 @@ const fs = require('fs');
const path = require('path');
const { arrowRingsAt, normalizeManeuver } = require('./turn-lane-arrows');
const { buildComplexJunctionGeometry, complexJunctionMetrics } = require('./complex-junction');
const { diagnostic } = require('./diagnostics');
const OVERRIDE_SCHEMA = 'native-road-overrides/v1';
const MOTOR_HIGHWAYS = new Set([
@@ -3089,17 +3090,6 @@ function unproject(point, origin) {
const scale = 111320;
return [point[0] / (scale * Math.cos((origin[1] * Math.PI) / 180)) + origin[0], point[1] / scale + origin[1]];
}
function diagnostic(severity, subjectId, sourceIds, rule, message, coordinate) {
return {
id: `diagnostic:${rule}:${subjectId}`,
severity,
subjectId,
sourceIds,
rule,
message,
geometry: coordinate ? { type: 'Point', coordinates: coordinate } : null,
};
}
function xmlAttrs(text) {
const attrs = {};
for (const match of text.matchAll(/([:\w-]+)\s*=\s*(?:"([^"]*)"|'([^']*)')/g)) attrs[match[1]] = match[2] ?? match[3];