feat: persist direct edits with revision rebase

This commit is contained in:
2026-08-27 12:52:40 +08:00
parent 41c314fc61
commit f3f763f94c
2 changed files with 122 additions and 6 deletions

View File

@@ -15,9 +15,14 @@ const { generate, validateDocument, runtime } = require('../src/native-traffic-s
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 {
createCheckpoint,
ensureRevisionStore,
readRevision,
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');
const { loadEditDocument, saveEditDocument, validateEditDocument } = require('../src/compile/native-road-edits');
function startWorkbench({
area = null,
@@ -90,8 +95,44 @@ function handle(request, response, session) {
})
.then((value) => sendJson(response, 200, value))
.catch((error) => sendJson(response, 400, { ok: false, error: error.message }));
if (request.method === 'POST' && url.pathname === '/api/edits')
return readBody(request)
.then((body) => {
if (!area) throw new Error('请先导入 OSM 文件。');
return saveEdits(area, body);
})
.then((value) => sendJson(response, 200, value))
.catch((error) =>
sendJson(response, error.statusCode || 400, {
ok: false,
error: error.message,
...(error.current ? { current: error.current } : {}),
}),
);
if (request.method === 'POST' && url.pathname === '/api/revisions')
return readBody(request)
.then((body) => {
if (!area) throw new Error('请先导入 OSM 文件。');
const revision = createCheckpoint(path.dirname(area.input), body.label);
return { ok: true, revision: revision.manifest, ...editState(area) };
})
.then((value) => sendJson(response, 200, value))
.catch((error) => sendJson(response, 400, { ok: false, error: error.message }));
const rebase = /^\/api\/revisions\/(rev-\d{4})\/rebase$/.exec(url.pathname);
if (request.method === 'POST' && rebase)
return Promise.resolve()
.then(() => {
if (!area) throw new Error('请先导入 OSM 文件。');
return rebaseEdits(area, rebase[1]);
})
.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 });
return sendJson(response, 200, {
active: Boolean(session.area),
areaId: session.area?.id || null,
...(session.area ? activeRevisionFields(session.area) : {}),
});
if (request.method === 'POST' && url.pathname === '/api/import')
return readUpload(request, session)
.then((result) =>
@@ -190,7 +231,7 @@ function handle(request, response, session) {
function state(area, junctionReference = null, debug = false) {
// This is the read entry point for both a fresh import and a reopened legacy
// workspace. Initialization only adds the v2 layout; it never rewrites v1 files.
ensureRevisionStore(path.dirname(area.input), area.nativeRoad);
const revisionStore = ensureRevisionStore(path.dirname(area.input), area.nativeRoad);
const nativeDir = area.outputs.nativeRoadDir;
const osm2streetsRoadSurface = area.outputs.geojsonDir
? path.join(area.outputs.geojsonDir, 'road_surface.geojson')
@@ -206,6 +247,8 @@ function state(area, junctionReference = null, debug = false) {
const compiled = readCompiled(area);
return {
areaId: area.id,
activeRevisionId: revisionStore.active.activeRevisionId,
documentVersion: revisionStore.active.documentVersion,
debug: debug ? { junctionCandidates: junctionCandidates(compiled) } : null,
compiled,
overrides: loadOverrides(area.outputs.nativeRoadOverrides),
@@ -232,6 +275,11 @@ function state(area, junctionReference = null, debug = false) {
};
}
function activeRevisionFields(area) {
const store = ensureRevisionStore(path.dirname(area.input), area.nativeRoad);
return { activeRevisionId: store.active.activeRevisionId, documentVersion: store.active.documentVersion };
}
function editState(area) {
const workspace = path.dirname(area.input);
const initialized = ensureRevisionStore(workspace, area.nativeRoad);
@@ -308,6 +356,59 @@ function editPreview(area, body = {}) {
},
};
}
function saveEdits(area, body = {}) {
const workspace = path.dirname(area.input);
const initialized = ensureRevisionStore(workspace, area.nativeRoad);
const current = loadEditDocument(initialized.paths.activeEdits);
if (!Number.isInteger(body.expectedDocumentVersion)) throw new Error('expectedDocumentVersion must be an integer.');
if (body.expectedDocumentVersion !== current.documentVersion) {
const error = new Error('编辑文档版本已变化,请刷新后重试。');
error.statusCode = 409;
error.current = { documentVersion: current.documentVersion };
throw error;
}
const candidate = body.document
? { ...body.document, documentVersion: current.documentVersion }
: {
...current,
...(Array.isArray(body.constraints) ? { constraints: body.constraints } : {}),
...(Array.isArray(body.operations) ? { operations: body.operations } : {}),
documentVersion: current.documentVersion,
};
const saved = saveEditDocument(initialized.paths.activeEdits, candidate);
writeJsonAtomic(initialized.paths.activeState, {
...initialized.active,
documentVersion: saved.documentVersion,
});
return {
ok: true,
document: saved,
documentVersion: saved.documentVersion,
activeRevisionId: initialized.active.activeRevisionId,
};
}
function rebaseEdits(area, revisionId) {
const workspace = path.dirname(area.input);
const revision = readRevision(workspace, revisionId);
const model = compileRoadModel(fs.readFileSync(area.input, 'utf8'), loadOverrides(area.outputs.nativeRoadOverrides));
const resolution = resolveDirectEditConstraints(model, revision.directEdits, {
revisionId,
compilerGeometryVersion: GEOMETRY_VERSION,
});
const counts = Object.fromEntries(
['exact', 'recheck', 'pending', 'conflicted', 'stale'].map((status) => [status, 0]),
);
for (const state of resolution.constraintStates) counts[state.status] = (counts[state.status] || 0) + 1;
return {
ok: true,
revisionId,
counts,
constraints: resolution.constraintStates,
diagnostics: resolution.diagnostics,
};
}
// 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配置.
@@ -525,4 +626,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, editState, editPreview };
module.exports = { startWorkbench, readMultipart, editState, editPreview, saveEdits, rebaseEdits };