diff --git a/.trellis/tasks/08-26-direct-edit-solver-api/task.json b/.trellis/tasks/08-26-direct-edit-solver-api/task.json index 55dd2c0..da806a0 100644 --- a/.trellis/tasks/08-26-direct-edit-solver-api/task.json +++ b/.trellis/tasks/08-26-direct-edit-solver-api/task.json @@ -3,7 +3,7 @@ "name": "direct-edit-solver-api", "title": "约束求解器与编辑 API", "description": "resolveDirectEditConstraints 纯函数边界,以及预览/保存/revision/rebase API", - "status": "planning", + "status": "in_progress", "dev_type": null, "scope": null, "package": null, diff --git a/package.json b/package.json index b4817a1..bae2221 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ "road-compiler": "bin/road-compiler.js" }, "scripts": { - "test": "node test/index.js && node test/native-road-edits.js && node test/road-revisions.js && node test/fixtures.js", + "test": "node test/index.js && node test/native-road-edits.js && node test/road-revisions.js && node test/direct-edit-solver.js && node test/fixtures.js", "test:fixtures:update-baseline": "node test/update-fixture-baselines.js", "road:workbench": "node bin/road-workbench.js", "road:export": "node bin/road-compiler.js", diff --git a/src/compile/compiler.js b/src/compile/compiler.js index 2374e6b..9c2c9fe 100644 --- a/src/compile/compiler.js +++ b/src/compile/compiler.js @@ -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) { diff --git a/src/compile/diagnostics.js b/src/compile/diagnostics.js new file mode 100644 index 0000000..b2a8093 --- /dev/null +++ b/src/compile/diagnostics.js @@ -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 }; diff --git a/src/compile/direct-edit-solver.js b/src/compile/direct-edit-solver.js new file mode 100644 index 0000000..5ea34bc --- /dev/null +++ b/src/compile/direct-edit-solver.js @@ -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, +}; diff --git a/src/compile/native-road.js b/src/compile/native-road.js index 4c4cba4..ce1b36d 100644 --- a/src/compile/native-road.js +++ b/src/compile/native-road.js @@ -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]; diff --git a/test/direct-edit-solver.js b/test/direct-edit-solver.js new file mode 100644 index 0000000..d1811ea --- /dev/null +++ b/test/direct-edit-solver.js @@ -0,0 +1,116 @@ +'use strict'; + +const assert = require('assert/strict'); +const { resolveDirectEditConstraints, SOLVED_KINDS } = require('../src/compile/direct-edit-solver'); + +const model = { roads: [], endpoints: [], connections: [], diagnostics: [] }; + +function constraint(overrides = {}) { + return { + id: 'constraint-1', + kind: 'road-edge-offset', + anchor: { type: 'road-interval', roadId: 'road:way/1:forward', startStation: 0.3, endStation: 0.7 }, + anchorSnapshot: { + coordinate: [113.1, 30.2], + tangentAzimuth: 45, + roadLengthMeters: 120, + osmNodeIds: ['1', '2'], + }, + value: { offsetMeters: 1.5, transition: 'smoothstep' }, + enabled: true, + status: 'exact', + provenance: { operationId: 'operation-1', createdAt: '2026-08-27T00:00:00.000Z' }, + ...overrides, + }; +} + +function document(constraints) { + return { schema: 'native-road-edits/v2', documentVersion: 1, base: {}, constraints, operations: [] }; +} + +// A missing or empty document must resolve to the identity: this is what keeps +// the stage insertable ahead of compileGeometry without moving any geometry. +for (const empty of [null, undefined, document([])]) { + const resolution = resolveDirectEditConstraints(model, empty); + assert.equal(resolution.schema, 'road-edit-resolution/v1'); + assert.equal(resolution.roadProfiles.size, 0); + assert.equal(resolution.junctionPlans.size, 0); + assert.deepEqual(resolution.constraintStates, []); + assert.deepEqual(resolution.diagnostics, []); + assert.equal(resolution.handles.schema, 'road-edit-handles/v1'); + assert.deepEqual(resolution.handles.handles, []); + assert.deepEqual(resolution.handles.reserves, []); + assert.equal(resolution.handles.revisionId, null); + assert.equal(resolution.handles.previewSeq, 0); +} + +// Handle manifest context is echoed so a preview response can be matched to the +// request that asked for it. +const withContext = resolveDirectEditConstraints(model, null, { revisionId: 'rev-0007', previewSeq: 42 }); +assert.equal(withContext.handles.revisionId, 'rev-0007'); +assert.equal(withContext.handles.previewSeq, 42); + +// A disabled constraint is reported, never applied, and produces no diagnostic: +// the user turned it off deliberately, so it is not a problem to report. +const disabled = resolveDirectEditConstraints(model, document([constraint({ enabled: false })])); +assert.deepEqual(disabled.constraintStates, [ + { constraintId: 'constraint-1', kind: 'road-edge-offset', status: 'exact', applied: false, reason: 'disabled' }, +]); +assert.deepEqual(disabled.diagnostics, []); + +// Only `exact` and `recheck` may reach the solver. The other three statuses mean +// the anchor is not trustworthy, so they are skipped with the status as reason. +for (const status of ['pending', 'conflicted', 'stale']) { + const resolution = resolveDirectEditConstraints(model, document([constraint({ status })])); + assert.deepEqual(resolution.constraintStates, [ + { constraintId: 'constraint-1', kind: 'road-edge-offset', status, applied: false, reason: `status-${status}` }, + ]); + assert.deepEqual(resolution.diagnostics, []); +} + +// Until a kind is wired into the solver its constraints are reported as +// unapplied with an explicit diagnostic, rather than approximated or ignored. +for (const status of ['exact', 'recheck']) { + const resolution = resolveDirectEditConstraints(model, document([constraint({ status })])); + const expected = SOLVED_KINDS.has('road-edge-offset') + ? { applied: true, reason: null, diagnostics: 0 } + : { applied: false, reason: 'kind-not-solved', diagnostics: 1 }; + assert.deepEqual(resolution.constraintStates, [ + { + constraintId: 'constraint-1', + kind: 'road-edge-offset', + status, + applied: expected.applied, + reason: expected.reason, + }, + ]); + assert.equal(resolution.diagnostics.length, expected.diagnostics); + for (const item of resolution.diagnostics) { + assert.equal(item.severity, 'info'); + assert.equal(item.rule, 'direct-edit-kind-not-solved'); + 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 +// report per-row status without re-deriving the mapping. +const many = resolveDirectEditConstraints( + model, + document([ + constraint({ id: 'a', enabled: false }), + constraint({ id: 'b', status: 'stale' }), + constraint({ id: 'c' }), + ]), +); +assert.deepEqual( + many.constraintStates.map((state) => state.constraintId), + ['a', 'b', 'c'], +); + +// The solver must be free of file and network access so preview can share it. +const source = require('fs').readFileSync(require.resolve('../src/compile/direct-edit-solver'), 'utf8'); +for (const forbidden of ["require('fs')", "require('path')", "require('http')", "require('https')"]) + assert.equal(source.includes(forbidden), false, `direct-edit-solver must not ${forbidden}`); + +console.log('direct edit solver tests passed'); diff --git a/test/road-revisions.js b/test/road-revisions.js index 850411f..ecee4ed 100644 --- a/test/road-revisions.js +++ b/test/road-revisions.js @@ -7,6 +7,7 @@ const os = require('os'); const path = require('path'); const { nativeRoadEdits, roadRevisions } = require('../src'); const { compileInput } = require('../src/compile/compiler'); +const { snapshot: outputSnapshot } = require('./fixture-baseline'); const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'road-revisions-')); const source = '\n'; @@ -127,5 +128,39 @@ assert.throws( /Area config snapshot is missing/, ); +// The workbench compile is the one caller that passes editsFile. With no v2 +// constraints the direct-edit solver stage must be an identity transform, so +// both compiles have to publish byte-identical outputs. +const activeEditsFile = path.join(directory, 'active', 'native-road-edits.json'); +assert.ok(fs.existsSync(activeEditsFile)); +assert.deepEqual(nativeRoadEdits.loadEditDocument(activeEditsFile).constraints, []); +const identityBase = { ...compileBase, areaId: 'edits-identity-test' }; +const withoutEdits = { + ...identityBase, + outDir: path.join(directory, 'no-edits-output'), + stagingDir: path.join(directory, 'no-edits-pipeline'), +}; +const withEdits = { + ...identityBase, + outDir: path.join(directory, 'with-edits-output'), + stagingDir: path.join(directory, 'with-edits-pipeline'), + editsFile: activeEditsFile, +}; +compileInput(withoutEdits); +compileInput(withEdits); +assert.deepEqual( + outputSnapshot(withEdits).files, + outputSnapshot(withoutEdits).files, + 'an empty v2 document must not change compiled output', +); +assert.throws( + () => compileInput({ ...identityBase, editsFile: '' }), + /editsFile must be a non-empty string when present/, +); +assert.throws( + () => compileInput({ ...identityBase, editsFile: '' }), + /editsFile must be a non-empty string when present/, +); + fs.rmSync(directory, { recursive: true, force: true }); console.log('road revision storage tests passed'); diff --git a/workbench/server.js b/workbench/server.js index 4f688e4..04bb81f 100644 --- a/workbench/server.js +++ b/workbench/server.js @@ -324,7 +324,11 @@ function readUpload(request, session) { function compileWorkbenchInput(input) { const workspace = path.dirname(input.osmFile); const initialized = ensureRevisionStore(workspace, input.options); - return compileInput({ ...input, areaConfigSnapshotFile: initialized.paths.activeAreaConfig }); + return compileInput({ + ...input, + areaConfigSnapshotFile: initialized.paths.activeAreaConfig, + editsFile: initialized.paths.activeEdits, + }); } function readMultipart(request, limit) { return new Promise((resolve, reject) => {