fix: correct native road edge and junction boundary geometry
修复四处几何缺陷,并新增复杂路口候选识别与工作台调试视图。 几何修复: - junctionBoundary 的进口边改用 cutback 处朝向 (headingAtCutback)。原先取节点端 首段朝向, 与 roadRing 的端边不平行, 15 个进口有偏差 (最大 152°), 路口面与道路面 之间张开楔形。凸包兜底路口 11 -> 8。 - compileEdgeLines 偏移量改用 totalWidth/2。原用单方向 road.widthMeters/2, 双向段的路缘线画在车行道正中间 (实测中位偏差 1.63m = w/2)。同时以实际发出的 路面为准过滤无路面的路, 并统一复杂路口的裁剪口径。 顶点偏离 >0.5m: 64% -> 0%, 最大偏离 4.53m -> 0.06m (= 半个带宽, 理论最优)。 - roadRing 新增 removeOffsetFolds。定宽法线偏移无斜接限制, 转弯处相邻段较短时 偏移点前后颠倒, 多边形折回自身, 按 even-odd 渲染成叶片状空洞。 道路面自交 1 -> 0, 最尖角 0.1° -> 18.8°。 - junctionBoundary 新增 mergeParallelApproachPoints。双幅路两幅同向汇入时, 四个侧点按角度排序后交错, 在交错点之间插入的圆角曲线从外圈深挖回节点, 渲染成拱形凹口。合并近平行 (<25°) 进口为单一面。 路口面最尖角 6.9° -> 全部 >=82.6°, 自交 0。 新增: - detectComplexJunctionCandidates: 按"短链接连接的路口节点"聚类并做紧凑度过滤 (直径 <=45m), 输出 complex-junction-candidate 诊断与建议参数。纯诊断, 不启用模板、不改几何; test:native-road 断言了这条契约。 - road:workbench --debug: 地图标注候选编号与包络, 点选给出可粘贴配置片段, 并提供 POST /api/junction-clusters 一键写入区域配置。写入前用 readAreaConfig 校验、检测节点冲突、要求候选存在于最新编译结果, 原子落盘。 Regression: test:native-road / test:road-workbench / test:gaode-junction-reference / test:preflight / test:native-preview-traffic / test:package-contract / test:traffic-signals 全绿; road:check ok=true, errors=[]。 源 OSM 与参考 GeoJSON 校验和未变。 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -19,23 +19,27 @@ function main() {
|
||||
if (args.noCompile !== "true") compileArea(configPath);
|
||||
const area = readAreaConfig(configPath, { repoRoot });
|
||||
const junctionReference = args.junctionReference ? readJunctionReference(path.resolve(args.junctionReference)) : null;
|
||||
// `--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.
|
||||
const debug = args.debug === "true";
|
||||
const port = Number(args.port || 8787);
|
||||
if (!Number.isInteger(port) || port < 1024 || port > 65535) throw new Error("--port must be an integer in [1024, 65535].");
|
||||
const server = http.createServer((request, response) => handle(request, response, area, configPath, junctionReference));
|
||||
const server = http.createServer((request, response) => handle(request, response, area, configPath, junctionReference, debug));
|
||||
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}/`));
|
||||
server.listen(port, "127.0.0.1", () => console.log(`Road Workbench: http://127.0.0.1:${port}/${debug ? " (debug: 复杂路口候选已开启)" : ""}`));
|
||||
}
|
||||
|
||||
function handle(request, response, area, configPath, junctionReference) {
|
||||
function handle(request, response, area, configPath, junctionReference, debug = false) {
|
||||
const url = new URL(request.url, "http://127.0.0.1");
|
||||
if (request.method === "GET" && url.pathname === "/") return sendFile(response, path.join(repoRoot, "scripts", "workbench", "index.html"), "text/html; charset=utf-8");
|
||||
if (request.method === "GET" && url.pathname === "/app.js") return sendFile(response, path.join(repoRoot, "scripts", "workbench", "app.js"), "text/javascript; charset=utf-8");
|
||||
if (request.method === "GET" && url.pathname === "/app.css") return sendFile(response, path.join(repoRoot, "scripts", "workbench", "app.css"), "text/css; charset=utf-8");
|
||||
if (request.method === "GET" && url.pathname.startsWith("/vendor/")) return sendVendorFile(response, url.pathname);
|
||||
if (request.method === "GET" && url.pathname === "/api/state") return sendJson(response, 200, state(area, junctionReference));
|
||||
if (request.method === "GET" && url.pathname === "/api/state") return sendJson(response, 200, state(area, junctionReference, debug));
|
||||
if (request.method === "POST" && url.pathname === "/api/traffic-signals") return readBody(request).then((body) => {
|
||||
const document = validateDocument(body, fs.readFileSync(area.input, "utf8"));
|
||||
writeJsonAtomic(area.outputs.nativeTrafficSignals, document);
|
||||
@@ -56,6 +60,13 @@ function handle(request, response, area, configPath, junctionReference) {
|
||||
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 (!debug) throw new Error("该接口仅在 --debug 模式下可用。");
|
||||
const added = addJunctionCluster(configPath, body, readCompiled(area));
|
||||
compileFresh(configPath);
|
||||
const refreshed = readAreaConfig(configPath, { repoRoot });
|
||||
sendJson(response, 200, { ok: true, added, ...state(refreshed, junctionReference, debug) });
|
||||
}).catch((error) => sendJson(response, 400, { ok: false, error: error.message }));
|
||||
if (request.method === "POST" && url.pathname === "/api/compile") return Promise.resolve().then(() => {
|
||||
compileFresh(configPath);
|
||||
sendJson(response, 200, state(area, junctionReference));
|
||||
@@ -63,15 +74,71 @@ function handle(request, response, area, configPath, junctionReference) {
|
||||
sendJson(response, 404, { error: "Not found" });
|
||||
}
|
||||
|
||||
function state(area, junctionReference = null) {
|
||||
function state(area, junctionReference = null, debug = false) {
|
||||
const nativeDir = area.outputs.nativeRoadDir;
|
||||
const osm2streetsRoadSurface = path.join(area.outputs.geojsonDir, "road_surface.geojson");
|
||||
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);
|
||||
return { areaId: area.id, compiled: readCompiled(area), 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: fs.existsSync(osm2streetsRoadSurface) ? readLayer(osm2streetsRoadSurface) : null } };
|
||||
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: 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 hand-authored area config. The candidate
|
||||
// must still be present in the latest compile, so a stale browser tab cannot
|
||||
// write a cluster that no longer exists. The edited config is validated by the
|
||||
// real loader before it replaces the file: an invalid write would break every
|
||||
// later command, and the file is git-tracked so a bad accept stays revertible.
|
||||
function addJunctionCluster(configPath, body, compiled) {
|
||||
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 raw = readJson(configPath);
|
||||
const templates = raw.nativeRoad?.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 next = { ...raw, nativeRoad: { ...raw.nativeRoad, junctionTemplates: { ...templates, enabled: true, clusters: [...clusters, cluster] } } };
|
||||
const staging = `${configPath}.candidate-${process.pid}.json`;
|
||||
fs.writeFileSync(staging, `${JSON.stringify(next, null, 2)}\n`);
|
||||
try {
|
||||
readAreaConfig(staging, { repoRoot });
|
||||
} catch (error) {
|
||||
fs.unlinkSync(staging);
|
||||
throw new Error(`写入后的配置无法通过校验,已放弃:${error.message}`);
|
||||
}
|
||||
fs.unlinkSync(staging);
|
||||
writeJsonAtomic(configPath, next);
|
||||
return cluster;
|
||||
}
|
||||
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 compileFresh(configPath) {
|
||||
try {
|
||||
return execFileSync(process.execPath, [path.join(repoRoot, "scripts", "compile-native-roads.js"), "--config", configPath], {
|
||||
|
||||
Reference in New Issue
Block a user