From 41c314fc61e049d720a090b97c06886c9127655f Mon Sep 17 00:00:00 2001 From: que01 Date: Thu, 27 Aug 2026 12:48:29 +0800 Subject: [PATCH] feat: add direct edit preview APIs --- package.json | 2 +- test/workbench-edit-api.js | 61 ++++++++++++++++++++++ workbench/server.js | 102 ++++++++++++++++++++++++++++++++++++- 3 files changed, 162 insertions(+), 3 deletions(-) create mode 100644 test/workbench-edit-api.js diff --git a/package.json b/package.json index bae2221..3cf4e6c 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/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", diff --git a/test/workbench-edit-api.js b/test/workbench-edit-api.js new file mode 100644 index 0000000..8380b4d --- /dev/null +++ b/test/workbench-edit-api.js @@ -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'); diff --git a/workbench/server.js b/workbench/server.js index 04bb81f..cef6092 100644 --- a/workbench/server.js +++ b/workbench/server.js @@ -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 };