Files
road-compiler/workbench/server.js
que01 3297d0c97a 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>
2026-08-27 10:29:45 +08:00

431 lines
19 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env node
'use strict';
const fs = require('fs');
const http = require('http');
const path = require('path');
const { 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');
function startWorkbench({
area = null,
input = null,
inputFile = null,
repoRoot = process.cwd(),
dataRoot = path.join(repoRoot, 'workbench-data'),
configPath = null,
compileFresh = null,
readAreaConfig = null,
junctionReference = null,
debug = false,
port = 8787,
}) {
if (typeof junctionReference === 'string') junctionReference = readJunctionReference(junctionReference);
// `--debug` surfaces advisory compiler findings that have no geometry layer of
// their own — currently the complex-junction candidates. Off by default so the
// normal editing view stays uncluttered.
if (!Number.isInteger(port) || port < 1024 || port > 65535)
throw new Error('--port must be an integer in [1024, 65535].');
const session = {
area,
context: { repoRoot, configPath, compileFresh, readAreaConfig },
junctionReference,
debug,
dataRoot,
};
if (input) {
session.context.compileFresh = () => {
const compiled = compileWorkbenchInput(input);
session.area = compiled.area;
return compiled;
};
session.context.compileFresh();
}
const server = http.createServer((request, response) => handle(request, response, session));
server.on('error', (error) => {
console.error(`Road Workbench failed to listen: ${error.message}`);
process.exitCode = 1;
});
server.listen(port, '127.0.0.1', () =>
console.log(`Road Workbench: http://127.0.0.1:${port}/${debug ? ' (debug: 复杂路口候选已开启)' : ''}`),
);
return server;
}
function handle(request, response, session) {
const area = session.area;
const context = session.context;
const junctionReference = session.junctionReference;
const debug = session.debug;
const url = new URL(request.url, 'http://127.0.0.1');
if (request.method === 'GET' && url.pathname === '/') return sendWorkbenchApp(response);
if (request.method === 'GET' && url.pathname.startsWith('/vendor/'))
return sendVendorFile(response, url.pathname, context.repoRoot);
if (request.method === 'GET' && url.pathname === '/api/state')
return area
? sendJson(response, 200, state(area, junctionReference, debug))
: sendJson(response, 200, { active: false });
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')
return readUpload(request, session)
.then((result) =>
sendJson(response, 200, { ok: true, areaId: result.area.id, ...state(result.area, junctionReference, debug) }),
)
.catch((error) => sendJson(response, 400, { ok: false, error: error.message }));
if (request.method === 'GET' && url.pathname === '/api/export.zip')
return Promise.resolve()
.then(() => {
if (!area) throw new Error('请先导入 OSM 文件。');
const exported = exportNativeRoadPackage(area.outputs.nativeRoadDir);
response.writeHead(200, {
'Content-Type': 'application/zip',
'Content-Disposition': `attachment; filename="${area.id}.native-road.zip"`,
'Content-Length': exported.bytes.length,
'Cache-Control': 'no-store',
});
response.end(Buffer.from(exported.bytes));
})
.catch((error) => sendJson(response, 400, { ok: false, error: error.message }));
if (request.method === 'POST' && url.pathname === '/api/traffic-signals')
return readBody(request)
.then((body) => {
if (!area) throw new Error('请先导入 OSM 文件。');
const document = validateDocument(body, fs.readFileSync(area.input, 'utf8'));
writeJsonAtomic(area.outputs.nativeTrafficSignals, document);
sendJson(response, 200, { ok: true, trafficSignals: document, runtime: runtime(document) });
})
.catch((error) => sendJson(response, 400, { ok: false, error: error.message }));
if (request.method === 'POST' && url.pathname === '/api/traffic-signals/generate')
return Promise.resolve()
.then(() => {
if (!area) throw new Error('请先导入 OSM 文件。');
const compiled = readCompiled(area);
const generated = generate(
fs.readFileSync(area.input, 'utf8'),
readLayer(path.join(area.outputs.nativeRoadDir, 'layers', 'vehicle_stop_lines.geojson')),
readLayer(path.join(area.outputs.nativeRoadDir, 'layers', 'intersection_surface.geojson')),
);
const current = validateDocument(
readJson(area.outputs.nativeTrafficSignals),
fs.readFileSync(area.input, 'utf8'),
);
const present = new Set(current.assemblies.features.map((feature) => feature.properties.signal_uid));
current.assemblies.features.push(
...generated.assemblies.features.filter((feature) => !present.has(feature.properties.signal_uid)),
);
writeJsonAtomic(area.outputs.nativeTrafficSignals, current);
sendJson(response, 200, {
ok: true,
trafficSignals: current,
runtime: runtime(current),
generated: generated.assemblies.features.length,
compiled: Boolean(compiled),
});
})
.catch((error) => sendJson(response, 400, { ok: false, error: error.message }));
if (request.method === 'POST' && url.pathname === '/api/overrides')
return readBody(request)
.then((body) => {
if (!area) throw new Error('请先导入 OSM 文件。');
const compiled = readCompiled(area);
const overrides = validateOverrides(body, { roads: compiled.model.roads, endpoints: compiled.model.endpoints });
writeJsonAtomic(area.outputs.nativeRoadOverrides, overrides);
sendJson(response, 200, { ok: true, overrides });
})
.catch((error) => sendJson(response, 400, { ok: false, error: error.message }));
if (request.method === 'POST' && url.pathname === '/api/junction-clusters')
return readBody(request)
.then((body) => {
if (!area) throw new Error('请先导入 OSM 文件。');
if (!debug) throw new Error('该接口仅在 --debug 模式下可用。');
const added = addJunctionCluster(body, readCompiled(area), area.nativeRoad);
setActiveAreaConfig(path.dirname(area.input), added.options);
context.compileFresh();
sendJson(response, 200, {
ok: true,
added: added.cluster,
configSnippet: { nativeRoad: { junctionTemplates: added.options.junctionTemplates } },
...state(session.area, junctionReference, debug),
});
})
.catch((error) => sendJson(response, 400, { ok: false, error: error.message }));
if (request.method === 'POST' && url.pathname === '/api/compile')
return Promise.resolve()
.then(() => {
if (!area || typeof context.compileFresh !== 'function') throw new Error('请先导入 OSM 文件。');
context.compileFresh();
sendJson(response, 200, state(session.area, junctionReference, debug));
})
.catch((error) => sendJson(response, 500, { ok: false, error: error.message }));
if (request.method === 'GET' && !url.pathname.startsWith('/api/')) return sendWorkbenchAsset(response, url.pathname);
sendJson(response, 404, { error: 'Not found' });
}
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 nativeDir = area.outputs.nativeRoadDir;
const osm2streetsRoadSurface = area.outputs.geojsonDir
? path.join(area.outputs.geojsonDir, 'road_surface.geojson')
: null;
const trafficSignals = fs.existsSync(area.outputs.nativeTrafficSignals)
? validateDocument(readJson(area.outputs.nativeTrafficSignals), fs.readFileSync(area.input, 'utf8'))
: {
schema: 'native-traffic-signals/v1',
provenance: 'empty',
assemblies: { type: 'FeatureCollection', features: [] },
};
const trafficRuntime = runtime(trafficSignals);
const compiled = readCompiled(area);
return {
areaId: area.id,
debug: debug ? { junctionCandidates: junctionCandidates(compiled) } : null,
compiled,
overrides: loadOverrides(area.outputs.nativeRoadOverrides),
trafficSignals,
trafficRuntime,
comparison: readJson(path.join(nativeDir, 'comparison.json')),
junctionReference,
layers: {
nativeRoadSurface: readLayer(path.join(nativeDir, 'layers', 'road_surface.geojson')),
edgeLines: readLayer(path.join(nativeDir, 'layers', 'edge_lines.geojson')),
nativeSidewalkSurface: readLayer(path.join(nativeDir, 'layers', 'sidewalk_surface.geojson')),
nativeIntersectionSurface: readLayer(path.join(nativeDir, 'layers', 'intersection_surface.geojson')),
laneCenterlines: readLayer(path.join(nativeDir, 'layers', 'lane_centerlines.geojson')),
laneSeparators: readLayer(path.join(nativeDir, 'layers', 'lane_separators.geojson')),
centerLines: readLayer(path.join(nativeDir, 'layers', 'center_lines.geojson')),
directionArrows: readLayer(path.join(nativeDir, 'layers', 'direction_arrows.geojson')),
turnArrows: readLayer(path.join(nativeDir, 'layers', 'turn_arrows.geojson')),
crosswalks: readLayer(path.join(nativeDir, 'layers', 'crosswalks.geojson')),
vehicleStopLines: readLayer(path.join(nativeDir, 'layers', 'vehicle_stop_lines.geojson')),
connectors: readLayer(path.join(nativeDir, 'layers', 'connectors.geojson')),
osm2streetsRoadSurface:
osm2streetsRoadSurface && fs.existsSync(osm2streetsRoadSurface) ? readLayer(osm2streetsRoadSurface) : null,
},
};
}
// 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配置.
// Append one detected cluster to the active area-config snapshot. The candidate
// must still be present in the latest compile, so a stale browser tab cannot
// write a cluster that no longer exists. The external config remains untouched;
// the response carries a copyable snippet while future compiles use the snapshot.
function addJunctionCluster(body, compiled, options) {
const index = Number(body?.index);
if (!Number.isInteger(index)) throw new Error('请求缺少候选编号 index。');
const candidate = junctionCandidates(compiled).find((item) => item.index === index);
if (!candidate) throw new Error(`候选 #${index} 不在最新一次编译结果里,请刷新页面后重试。`);
const templates = options?.junctionTemplates;
if (!templates) throw new Error('区域配置缺少 nativeRoad.junctionTemplates请先手工建立该节点。');
const clusters = Array.isArray(templates.clusters) ? templates.clusters : [];
const taken = new Set(clusters.flatMap((cluster) => (cluster.nodeIds || []).map(String)));
const clash = candidate.nodeIds.filter((nodeId) => taken.has(String(nodeId)));
if (clash.length) throw new Error(`节点 ${clash.join('、')} 已属于其他复杂路口配置。`);
const id = uniqueClusterId(`cluster-${candidate.nodeIds[0]}`, new Set(clusters.map((cluster) => cluster.id)));
const cluster = {
id,
template: candidate.template,
coreRadiusMeters: candidate.coreRadiusMeters,
cornerRadiusMeters: 12,
outerRadiusExtraMeters: 18,
nodeIds: candidate.nodeIds.map(String),
};
const nextOptions = {
...options,
junctionTemplates: { ...templates, enabled: true, clusters: [...clusters, cluster] },
};
return { cluster, options: nextOptions };
}
function uniqueClusterId(base, taken) {
if (!taken.has(base)) return base;
for (let suffix = 2; suffix < 100; suffix += 1) if (!taken.has(`${base}-${suffix}`)) return `${base}-${suffix}`;
throw new Error('无法生成唯一的 cluster id。');
}
function junctionCandidates(compiled) {
return (compiled?.diagnostics || [])
.filter((item) => item.rule === 'complex-junction-candidate' && item.suggestedCluster)
.sort(
(first, second) =>
second.suggestedCluster.nodeCount - first.suggestedCluster.nodeCount ||
first.suggestedCluster.diameterMeters - second.suggestedCluster.diameterMeters,
)
.map((item, index) => ({
index: index + 1,
id: item.id,
message: item.message,
coordinate: item.geometry?.coordinates || null,
...item.suggestedCluster,
}));
}
function readJunctionReference(file) {
if (!fs.existsSync(file)) throw new Error(`Junction reference not found: ${file}`);
const converted = convertGeoJson(JSON.parse(fs.readFileSync(file, 'utf8')));
return { source: file, coordinateSystem: 'GCJ-02', converted };
}
function readUpload(request, session) {
return readMultipart(request, 20 * 1024 * 1024).then(({ filename, data }) => {
if (!filename || !/\.osm$/i.test(filename)) throw new Error('请选择 .osm 文件。');
if (!data.length) throw new Error('OSM 文件不能为空。');
const base =
path
.basename(filename, path.extname(filename))
.replace(/[^a-zA-Z0-9_-]+/g, '-')
.replace(/^-+|-+$/g, '')
.toLowerCase() || 'osm-import';
fs.mkdirSync(session.dataRoot, { recursive: true });
const root = fs.mkdtempSync(path.join(session.dataRoot, 'import-'));
const areaId = `${base}-${path.basename(root).slice(-6)}`;
const outRoot = path.join(root, 'outputs');
const input = {
areaId,
osmFile: path.join(root, 'source.osm'),
outDir: path.join(outRoot, 'native-road'),
stagingDir: path.join(outRoot, '_pipeline'),
overridesFile: path.join(root, 'native-road-overrides.json'),
trafficSignalsFile: path.join(root, 'native-traffic-signals.json'),
options: { edgeLines: false, junctionTemplates: { enabled: false, references: [] } },
};
fs.writeFileSync(input.osmFile, data);
fs.writeFileSync(
input.overridesFile,
JSON.stringify({ schema: 'native-road-overrides/v1', overrides: [] }, null, 2),
);
fs.writeFileSync(
input.trafficSignalsFile,
JSON.stringify(
{
schema: 'native-traffic-signals/v1',
provenance: 'empty',
assemblies: { type: 'FeatureCollection', features: [] },
},
null,
2,
),
);
try {
const compiled = compileWorkbenchInput(input);
session.area = compiled.area;
return compiled;
} catch (error) {
fs.rmSync(root, { recursive: true, force: true });
throw error;
}
});
}
function compileWorkbenchInput(input) {
const workspace = path.dirname(input.osmFile);
const initialized = ensureRevisionStore(workspace, input.options);
return compileInput({
...input,
areaConfigSnapshotFile: initialized.paths.activeAreaConfig,
editsFile: initialized.paths.activeEdits,
});
}
function readMultipart(request, limit) {
return new Promise((resolve, reject) => {
const type = request.headers['content-type'] || '';
const match = /boundary=(?:"([^"]+)"|([^;]+))/i.exec(type);
if (!match) return reject(new Error('请使用 multipart/form-data 上传 OSM 文件。'));
const boundary = `--${match[1] || match[2]}`;
const chunks = [];
let size = 0;
request.on('data', (chunk) => {
size += chunk.length;
if (size > limit) {
reject(new Error('上传文件超过 20 MB 限制。'));
request.destroy();
return;
}
chunks.push(chunk);
});
request.on('error', reject);
request.on('end', () => {
const body = Buffer.concat(chunks);
const start = body.indexOf(Buffer.from('\r\n\r\n'));
const end = body.lastIndexOf(Buffer.from(`\r\n${boundary}--`));
if (start < 0 || end < start) return reject(new Error('上传内容格式无效。'));
const header = body.slice(0, start).toString();
const name = /filename="([^"]*)"/i.exec(header)?.[1] || 'upload.osm';
resolve({ filename: name, data: body.slice(start + 4, end) });
});
});
}
function readCompiled(area) {
return readJson(path.join(area.outputs.nativeRoadDir, 'compiled.json'));
}
function readJson(file) {
return JSON.parse(fs.readFileSync(file, 'utf8'));
}
function readLayer(file) {
return fs.existsSync(file) ? readJson(file) : { type: 'FeatureCollection', features: [] };
}
function readBody(request) {
return new Promise((resolve, reject) => {
let body = '';
request.setEncoding('utf8');
request.on('data', (part) => {
body += part;
if (body.length > 1024 * 1024) request.destroy();
});
request.on('end', () => {
try {
resolve(JSON.parse(body));
} catch (_) {
reject(new Error('Request body must be JSON.'));
}
});
request.on('error', reject);
});
}
function sendWorkbenchApp(response) {
const built = path.join(__dirname, 'client', 'dist', 'index.html');
return sendFile(
response,
fs.existsSync(built) ? built : path.join(__dirname, 'client', 'index.html'),
'text/html; charset=utf-8',
);
}
function sendWorkbenchAsset(response, pathname) {
const root = path.join(__dirname, 'client', 'dist');
const file = path.resolve(root, `.${pathname}`);
if (file.startsWith(`${root}${path.sep}`) && fs.existsSync(file) && fs.statSync(file).isFile()) {
const extension = path.extname(file);
const types = {
'.js': 'text/javascript; charset=utf-8',
'.css': 'text/css; charset=utf-8',
'.svg': 'image/svg+xml',
'.png': 'image/png',
'.woff2': 'font/woff2',
};
return sendFile(response, file, types[extension] || 'application/octet-stream');
}
return sendWorkbenchApp(response);
}
function sendFile(response, file, type) {
response.writeHead(200, { 'Content-Type': type, 'Cache-Control': 'no-store' });
fs.createReadStream(file).pipe(response);
}
function sendVendorFile(response, pathname, repoRoot) {
const match = /^\/vendor\/(ol|rbush|quickselect)\/(.+)$/.exec(pathname);
if (!match) return sendJson(response, 404, { error: 'Not found' });
const root = path.join(repoRoot, 'node_modules', match[1]);
const file = path.resolve(root, match[2]);
if (!file.startsWith(`${root}${path.sep}`) || !fs.existsSync(file) || !fs.statSync(file).isFile())
return sendJson(response, 404, { error: 'Not found' });
return sendFile(response, file, file.endsWith('.css') ? 'text/css; charset=utf-8' : 'text/javascript; charset=utf-8');
}
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 };