feat: add direct edit preview APIs

This commit is contained in:
2026-08-27 12:48:29 +08:00
parent 735ce69d26
commit 41c314fc61
3 changed files with 162 additions and 3 deletions

View File

@@ -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/direct-edit-solver.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/workbench-edit-api.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",

View File

@@ -0,0 +1,61 @@
'use strict';
const assert = require('assert/strict');
const fs = require('fs');
const os = require('os');
const path = require('path');
const { compileInput } = require('../src/compile/compiler');
const { ensureRevisionStore } = require('../src/compile/road-revisions');
const { editState, editPreview } = require('../workbench/server');
const workspace = fs.mkdtempSync(path.join(os.tmpdir(), 'workbench-edit-api-'));
const input = {
areaId: 'edit-api-test',
osmFile: path.join(workspace, 'source.osm'),
outDir: path.join(workspace, 'outputs', 'native-road'),
stagingDir: path.join(workspace, 'outputs', '_pipeline'),
overridesFile: path.join(workspace, 'native-road-overrides.json'),
trafficSignalsFile: path.join(workspace, 'native-traffic-signals.json'),
options: { edgeLines: false, junctionTemplates: { enabled: false, references: [] } },
};
fs.copyFileSync(path.join(__dirname, 'fixtures', 'fengshu-er-road.osm'), input.osmFile);
fs.writeFileSync(input.overridesFile, `${JSON.stringify({ schema: 'native-road-overrides/v1', overrides: [] })}\n`);
fs.writeFileSync(
input.trafficSignalsFile,
`${JSON.stringify({ schema: 'native-traffic-signals/v1', provenance: 'empty', assemblies: { type: 'FeatureCollection', features: [] } })}\n`,
);
const initialized = ensureRevisionStore(workspace, input.options);
const area = compileInput({
...input,
areaConfigSnapshotFile: initialized.paths.activeAreaConfig,
editsFile: initialized.paths.activeEdits,
}).area;
const state = editState(area);
assert.equal(state.active, true);
assert.equal(state.documentVersion, 0);
assert.equal(state.handles.schema, 'road-edit-handles/v1');
assert.equal(state.activeRevisionId, 'rev-0001');
const trackedFiles = [
initialized.paths.activeEdits,
initialized.paths.activeState,
initialized.paths.activeAreaConfig,
].map((file) => ({
file,
bytes: fs.readFileSync(file),
mtimeMs: fs.statSync(file).mtimeMs,
}));
const preview = editPreview(area, { previewSeq: 17, document: state.document });
assert.equal(preview.ok, true);
assert.equal(preview.previewSeq, 17);
assert.equal(preview.degraded, false);
assert.equal(preview.handles.schema, 'road-edit-handles/v1');
assert.ok(preview.layers.roadSurface);
for (const tracked of trackedFiles) {
assert.deepEqual(fs.readFileSync(tracked.file), tracked.bytes, `${tracked.file} bytes unchanged by preview`);
assert.equal(fs.statSync(tracked.file).mtimeMs, tracked.mtimeMs, `${tracked.file} mtime unchanged by preview`);
}
fs.rmSync(workspace, { recursive: true, force: true });
console.log('workbench edit API tests passed');

View File

@@ -4,12 +4,20 @@
const fs = require('fs');
const http = require('http');
const path = require('path');
const { loadOverrides, validateOverrides, writeJsonAtomic } = require('../src/compile/native-road');
const {
compileRoadModel,
compileGeometry,
loadOverrides,
validateOverrides,
writeJsonAtomic,
} = require('../src/compile/native-road');
const { generate, validateDocument, runtime } = require('../src/native-traffic-signals');
const { convertGeoJson } = require('../src/reference/gaode');
const { exportNativeRoadPackage } = require('../src/export/native-road-package');
const { compileInput } = require('../src/compile/compiler');
const { ensureRevisionStore, setActiveAreaConfig } = require('../src/compile/road-revisions');
const { GEOMETRY_VERSION, resolveDirectEditConstraints } = require('../src/compile/direct-edit-solver');
const { loadEditDocument, validateEditDocument } = require('../src/compile/native-road-edits');
function startWorkbench({
area = null,
@@ -69,6 +77,19 @@ function handle(request, response, session) {
return area
? sendJson(response, 200, state(area, junctionReference, debug))
: sendJson(response, 200, { active: false });
if (request.method === 'GET' && url.pathname === '/api/edit-state')
return Promise.resolve()
.then(() => (area ? editState(area) : { active: false }))
.then((value) => sendJson(response, 200, value))
.catch((error) => sendJson(response, 400, { ok: false, error: error.message }));
if (request.method === 'POST' && url.pathname === '/api/edit-preview')
return readBody(request)
.then((body) => {
if (!area) throw new Error('请先导入 OSM 文件。');
return editPreview(area, body);
})
.then((value) => sendJson(response, 200, value))
.catch((error) => sendJson(response, 400, { ok: false, error: error.message }));
if (request.method === 'GET' && url.pathname === '/api/session')
return sendJson(response, 200, { active: Boolean(session.area), areaId: session.area?.id || null });
if (request.method === 'POST' && url.pathname === '/api/import')
@@ -210,6 +231,83 @@ function state(area, junctionReference = null, debug = false) {
},
};
}
function editState(area) {
const workspace = path.dirname(area.input);
const initialized = ensureRevisionStore(workspace, area.nativeRoad);
const directEdits = loadEditDocument(initialized.paths.activeEdits);
const model = compileRoadModel(fs.readFileSync(area.input, 'utf8'), loadOverrides(area.outputs.nativeRoadOverrides));
const resolution = resolveDirectEditConstraints(model, directEdits, {
revisionId: initialized.active.activeRevisionId,
compilerGeometryVersion: GEOMETRY_VERSION,
});
return {
active: true,
areaId: area.id,
activeRevisionId: initialized.active.activeRevisionId,
documentVersion: directEdits.documentVersion,
document: directEdits,
constraintStates: resolution.constraintStates,
diagnostics: resolution.diagnostics,
handles: resolution.handles,
};
}
function editPreview(area, body = {}) {
const startedAt = Date.now();
const workspace = path.dirname(area.input);
const activeEditsFile = path.join(workspace, 'active', 'native-road-edits.json');
const activeStateFile = path.join(workspace, 'active', 'state.json');
if (!fs.existsSync(activeEditsFile) || !fs.existsSync(activeStateFile))
throw new Error('编辑工作区尚未初始化,请先重新导入 OSM 文件。');
const activeEdits = loadEditDocument(activeEditsFile);
const draft = body.document
? validateEditDocument(body.document)
: validateEditDocument({
...activeEdits,
...(Array.isArray(body.constraints) ? { constraints: body.constraints } : {}),
...(Array.isArray(body.operations) ? { operations: body.operations } : {}),
});
const activeState = readJson(activeStateFile);
const model = compileRoadModel(fs.readFileSync(area.input, 'utf8'), loadOverrides(area.outputs.nativeRoadOverrides));
const previewSeq = Number.isInteger(body.previewSeq) ? body.previewSeq : 0;
const resolution = resolveDirectEditConstraints(model, draft, {
revisionId: activeState.activeRevisionId,
previewSeq,
compilerGeometryVersion: GEOMETRY_VERSION,
});
const compiled = compileGeometry(model, loadOverrides(area.outputs.nativeRoadOverrides), {
edgeLines: area.nativeRoad.edgeLines,
junctionTemplates: area.nativeRoad.junctionTemplates,
directEdit: resolution,
});
const diagnostics = [...compiled.diagnostics, ...resolution.diagnostics];
const degraded = Date.now() - startedAt > 300;
return {
ok: true,
previewSeq,
degraded,
revisionId: activeState.activeRevisionId,
documentVersion: activeEdits.documentVersion,
constraintStates: resolution.constraintStates,
diagnostics,
handles: resolution.handles,
layers: {
roadSurface: compiled.roadSurface,
edgeLines: compiled.edgeLines,
sidewalkSurface: compiled.sidewalkSurface,
intersectionSurface: compiled.intersectionSurface,
laneCenterlines: compiled.laneCenterlines,
laneSeparators: compiled.laneSeparators,
centerLines: compiled.centerLines,
directionArrows: compiled.directionArrows,
turnArrows: compiled.turnArrows,
crosswalks: compiled.crosswalks,
vehicleStopLines: compiled.vehicleStopLines,
connectors: compiled.connectors,
},
};
}
// The compiler reports candidates as advisory diagnostics. Lift them into their
// own payload with a stable index so the map can label them "#1, #2, ..." and
// the inspector can offer a ready-to-paste cluster配置.
@@ -427,4 +525,4 @@ function sendJson(response, status, value) {
response.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' });
response.end(`${JSON.stringify(value)}\n`);
}
module.exports = { startWorkbench, readMultipart };
module.exports = { startWorkbench, readMultipart, editState, editPreview };