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>
431 lines
19 KiB
JavaScript
431 lines
19 KiB
JavaScript
#!/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 };
|