feat: add native road compiler workbench
This commit is contained in:
71
scripts/compile-native-roads.js
Normal file
71
scripts/compile-native-roads.js
Normal file
@@ -0,0 +1,71 @@
|
||||
#!/usr/bin/env node
|
||||
"use strict";
|
||||
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const { readAreaConfig } = require("./lib/area-config");
|
||||
const { compileRoadModel, compileGeometry, loadOverrides, validateOverrides, writeJsonAtomic } = require("./lib/native-road");
|
||||
|
||||
const repoRoot = path.resolve(__dirname, "..");
|
||||
|
||||
function parseArgs(argv) {
|
||||
const result = {};
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
if (!argv[index].startsWith("--")) continue;
|
||||
const key = argv[index].slice(2).replace(/-([a-z])/g, (_, letter) => letter.toUpperCase());
|
||||
result[key] = argv[index + 1] && !argv[index + 1].startsWith("--") ? argv[++index] : "true";
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function compileArea(configPath) {
|
||||
const area = readAreaConfig(configPath, { repoRoot });
|
||||
const overrides = loadOverrides(area.outputs.nativeRoadOverrides);
|
||||
const model = compileRoadModel(fs.readFileSync(area.input, "utf8"), overrides);
|
||||
validateOverrides(overrides, model);
|
||||
const compiled = compileGeometry(model);
|
||||
const staging = fs.mkdtempSync(path.join(area.outputs.pipelineDir, "native-road-"));
|
||||
try {
|
||||
const result = {
|
||||
schema: "native-road-compiled/v1",
|
||||
areaId: area.id,
|
||||
source: { osm: area.input, overrides: area.outputs.nativeRoadOverrides },
|
||||
model: { roads: model.roads, endpoints: model.endpoints, connections: model.connections },
|
||||
diagnostics: compiled.diagnostics,
|
||||
layers: { roadSurface: "layers/road_surface.geojson", intersectionSurface: "layers/intersection_surface.geojson" },
|
||||
};
|
||||
const comparison = compareOsm2Streets(area, result.model.roads.length);
|
||||
writeJsonAtomic(path.join(staging, "compiled.json"), result);
|
||||
writeJsonAtomic(path.join(staging, "diagnostics.json"), { schema: "native-road-diagnostics/v1", diagnostics: compiled.diagnostics });
|
||||
writeJsonAtomic(path.join(staging, "comparison.json"), comparison);
|
||||
writeJsonAtomic(path.join(staging, "layers", "road_surface.geojson"), compiled.roadSurface);
|
||||
writeJsonAtomic(path.join(staging, "layers", "intersection_surface.geojson"), compiled.intersectionSurface);
|
||||
fs.rmSync(area.outputs.nativeRoadDir, { recursive: true, force: true });
|
||||
fs.renameSync(staging, area.outputs.nativeRoadDir);
|
||||
return { area, result, comparison };
|
||||
} catch (error) {
|
||||
fs.rmSync(staging, { recursive: true, force: true });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function compareOsm2Streets(area, nativeRoadCount) {
|
||||
const source = path.join(area.outputs.geojsonDir, "road_surface.geojson");
|
||||
let featureCount = null;
|
||||
if (fs.existsSync(source)) {
|
||||
const collection = JSON.parse(fs.readFileSync(source, "utf8"));
|
||||
featureCount = Array.isArray(collection.features) ? collection.features.length : null;
|
||||
}
|
||||
return { schema: "native-road-comparison/v1", nativeRoadCount, osm2streetsRoadSurfaceFeatures: featureCount, osm2streetsAvailable: featureCount !== null, note: "Counts are coverage evidence only; geometry quality requires diagnostic and visual review." };
|
||||
}
|
||||
|
||||
function main() {
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
const configPath = path.resolve(args.config || path.join(repoRoot, "config", "areas", "nantaizi-lake-innovation-valley.json"));
|
||||
const { area, result, comparison } = compileArea(configPath);
|
||||
console.log(`NATIVE_ROAD_COMPILE_DONE ${JSON.stringify({ areaId: area.id, roads: result.model.roads.length, endpoints: result.model.endpoints.length, diagnostics: result.diagnostics.length, output: area.outputs.nativeRoadDir, comparison })}`);
|
||||
}
|
||||
|
||||
if (require.main === module) main();
|
||||
|
||||
module.exports = { compileArea, parseArgs };
|
||||
@@ -32,9 +32,12 @@ function normalizeAreaConfig(raw, options = {}) {
|
||||
const packageStagingRuntimeDir = path.resolve(outputOverrides.packageStagingRuntimeDir || path.join(packageStagingDir, "runtime"));
|
||||
const previewDir = path.resolve(outputOverrides.previewDir || path.join(areaDir, "_preview"));
|
||||
const geojsonDir = path.resolve(outputOverrides.geojsonDir || path.join(areaDir, "osm2streets_web_out"));
|
||||
const nativeRoadDir = path.resolve(outputOverrides.nativeRoadDir || path.join(areaDir, "native-road"));
|
||||
const outputs = {
|
||||
areaDir,
|
||||
geojsonDir,
|
||||
nativeRoadDir,
|
||||
nativeRoadOverrides: path.resolve(outputOverrides.nativeRoadOverrides || path.join(areaDir, "native-road-overrides.json")),
|
||||
gpkg: path.resolve(outputOverrides.gpkg || path.join(areaDir, `${fileStem}.gpkg`)),
|
||||
qgisProject: path.resolve(outputOverrides.qgisProject || path.join(areaDir, `${fileStem}.qgz`)),
|
||||
qgisPreview: path.resolve(outputOverrides.qgisPreview || path.join(areaDir, `${fileStem}-preview.png`)),
|
||||
|
||||
229
scripts/lib/native-road.js
Normal file
229
scripts/lib/native-road.js
Normal file
@@ -0,0 +1,229 @@
|
||||
"use strict";
|
||||
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
const OVERRIDE_SCHEMA = "native-road-overrides/v1";
|
||||
const MOTOR_HIGHWAYS = new Set(["motorway", "trunk", "primary", "secondary", "tertiary", "unclassified", "residential", "living_street", "service"]);
|
||||
const DEFAULT_WIDTHS = { motorway: 12, trunk: 10, primary: 10, secondary: 8, tertiary: 7, unclassified: 6, residential: 6, living_street: 5, service: 4 };
|
||||
|
||||
function parseOsmRoads(xml) {
|
||||
const nodes = new Map();
|
||||
for (const match of xml.matchAll(/<node\b([^>]*?)(?:\/>|>([\s\S]*?)<\/node>)/g)) {
|
||||
const attrs = xmlAttrs(match[1]);
|
||||
if (attrs.action === "delete" || !attrs.id || attrs.lon === undefined || attrs.lat === undefined) continue;
|
||||
const coordinate = [Number(attrs.lon), Number(attrs.lat)];
|
||||
if (coordinate.every(Number.isFinite)) nodes.set(String(attrs.id), coordinate);
|
||||
}
|
||||
const ways = [];
|
||||
for (const match of xml.matchAll(/<way\b([^>]*)>([\s\S]*?)<\/way>/g)) {
|
||||
const attrs = xmlAttrs(match[1]);
|
||||
const body = match[2];
|
||||
const tags = parseTags(body);
|
||||
if (attrs.action === "delete" || !MOTOR_HIGHWAYS.has(tags.highway || "")) continue;
|
||||
const refs = [...body.matchAll(/<nd\b([^>]*)\/?\s*>/g)].map((item) => xmlAttrs(item[1]).ref).filter(Boolean);
|
||||
const coords = refs.map((ref) => nodes.get(String(ref))).filter(Boolean);
|
||||
if (coords.length < 2 || coords.length !== refs.length) continue;
|
||||
ways.push({ id: String(attrs.id), refs: refs.map(String), coords, tags });
|
||||
}
|
||||
return { nodes, ways };
|
||||
}
|
||||
|
||||
function compileRoadModel(xml, overrides) {
|
||||
const parsed = parseOsmRoads(xml);
|
||||
const diagnostics = [];
|
||||
const roads = [];
|
||||
const endpoints = [];
|
||||
const byNode = new Map();
|
||||
for (const way of parsed.ways) {
|
||||
const directions = way.tags.oneway === "yes" || way.tags.oneway === "1" || way.tags.junction === "roundabout" ? ["forward"] : ["forward", "backward"];
|
||||
for (const direction of directions) {
|
||||
const base = roadAttributes(way.tags, direction);
|
||||
const id = `road:way/${way.id}:${direction}`;
|
||||
const road = { id, osmWayIds: [way.id], direction, highway: way.tags.highway, centerline: direction === "forward" ? way.coords : [...way.coords].reverse(), sourceNodeIds: direction === "forward" ? [way.refs[0], way.refs.at(-1)] : [way.refs.at(-1), way.refs[0]], tags: way.tags, ...base, appliedOverrideIds: [], diagnostics: [] };
|
||||
applyRoadOverrides(road, overrides, diagnostics);
|
||||
roads.push(road);
|
||||
for (const side of ["start", "end"]) {
|
||||
const nodeId = side === "start" ? road.sourceNodeIds[0] : road.sourceNodeIds[1];
|
||||
const endpoint = { id: `endpoint:${road.id}:${side}`, roadId: id, side, nodeId, coordinate: side === "start" ? road.centerline[0] : road.centerline.at(-1), direction };
|
||||
endpoints.push(endpoint);
|
||||
if (!byNode.has(nodeId)) byNode.set(nodeId, []);
|
||||
byNode.get(nodeId).push(endpoint);
|
||||
}
|
||||
}
|
||||
}
|
||||
const connections = resolveConnections(endpoints, byNode, overrides, diagnostics);
|
||||
const extent = roadExtent(roads);
|
||||
for (const [nodeId, items] of byNode) {
|
||||
if (items.length === 1 && distanceToExtentEdgeMeters(items[0].coordinate, extent) > 25) {
|
||||
const endpoint = items[0];
|
||||
diagnostics.push({ ...diagnostic("warning", endpoint.roadId, [nodeId], "unconnected-interior-road-end", "道路在区域内部结束,未连接到其他机动车道路。请确认这是实际断头,还是 OSM 节点尚未连接。", endpoint.coordinate), endpointId: endpoint.id });
|
||||
}
|
||||
}
|
||||
return { schema: "native-road-model/v1", roads, endpoints, connections, diagnostics };
|
||||
}
|
||||
|
||||
function roadExtent(roads) {
|
||||
const points = roads.flatMap((road) => road.centerline);
|
||||
return { minLon: Math.min(...points.map((point) => point[0])), maxLon: Math.max(...points.map((point) => point[0])), minLat: Math.min(...points.map((point) => point[1])), maxLat: Math.max(...points.map((point) => point[1])) };
|
||||
}
|
||||
|
||||
function distanceToExtentEdgeMeters(point, extent) {
|
||||
const lonScale = 111320 * Math.cos(point[1] * Math.PI / 180);
|
||||
return Math.min((point[0] - extent.minLon) * lonScale, (extent.maxLon - point[0]) * lonScale, (point[1] - extent.minLat) * 111320, (extent.maxLat - point[1]) * 111320);
|
||||
}
|
||||
|
||||
function roadAttributes(tags, direction) {
|
||||
const directional = direction === "forward" ? "forward" : "backward";
|
||||
const laneTag = tags[`lanes:${directional}`] ?? (tags.oneway === "yes" ? tags.lanes : null);
|
||||
const parsedLanes = positiveInteger(laneTag);
|
||||
const totalLanes = positiveInteger(tags.lanes);
|
||||
const lanes = parsedLanes || (totalLanes ? Math.max(1, Math.ceil(totalLanes / (tags.oneway === "yes" ? 1 : 2))) : 1);
|
||||
const parsedWidth = positiveNumber(tags.width);
|
||||
const forwardLanes = positiveInteger(tags["lanes:forward"]);
|
||||
const backwardLanes = positiveInteger(tags["lanes:backward"]);
|
||||
const directionalLaneTotal = forwardLanes && backwardLanes ? forwardLanes + backwardLanes : totalLanes;
|
||||
// `width` describes the whole OSM way. A directional road receives its lane
|
||||
// share; absent width falls back to a realistic per-lane carriageway width.
|
||||
const width = parsedWidth ? parsedWidth * lanes / (directionalLaneTotal || (tags.oneway === "yes" ? lanes : lanes * 2)) : lanes * 3.25;
|
||||
return {
|
||||
laneCount: lanes,
|
||||
widthMeters: width,
|
||||
sidewalkLeft: sidewalkState(tags, direction, "left"),
|
||||
sidewalkRight: sidewalkState(tags, direction, "right"),
|
||||
provenance: {
|
||||
laneCount: parsedLanes || totalLanes ? `tag:${parsedLanes ? `lanes:${directional}` : "lanes"}` : "inferred:default-lanes",
|
||||
widthMeters: parsedWidth ? "tag:width (按方向车道数分配)" : "inferred:3.25m-per-lane",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function sidewalkState(tags, direction, side) {
|
||||
const osmSide = direction === "forward" ? side : side === "left" ? "right" : "left";
|
||||
const value = tags[`sidewalk:${osmSide}`] ?? tags.sidewalk;
|
||||
return value === "both" || value === "yes" || value === osmSide;
|
||||
}
|
||||
|
||||
function loadOverrides(file) {
|
||||
if (!fs.existsSync(file)) return { schema: OVERRIDE_SCHEMA, overrides: [] };
|
||||
return validateOverrides(JSON.parse(fs.readFileSync(file, "utf8")));
|
||||
}
|
||||
|
||||
function validateOverrides(value, model) {
|
||||
if (!value || value.schema !== OVERRIDE_SCHEMA || !Array.isArray(value.overrides)) throw new Error(`Overrides must use ${OVERRIDE_SCHEMA}.`);
|
||||
const ids = new Set();
|
||||
const roadIds = model ? new Set(model.roads.map((road) => road.id)) : null;
|
||||
const endpointIds = model ? new Set(model.endpoints.map((endpoint) => endpoint.id)) : null;
|
||||
for (const item of value.overrides) {
|
||||
if (!item || typeof item.id !== "string" || !item.id || ids.has(item.id)) throw new Error("Each override needs a unique id.");
|
||||
ids.add(item.id);
|
||||
if (item.kind === "road") {
|
||||
if (typeof item.roadId !== "string" || roadIds && !roadIds.has(item.roadId)) throw new Error(`Unknown road override target: ${item.roadId}`);
|
||||
for (const key of ["widthMeters", "laneCount"]) if (item[key] !== undefined && (!Number.isFinite(item[key]) || item[key] <= 0 || (key === "laneCount" && !Number.isInteger(item[key])))) throw new Error(`Invalid road override ${key}.`);
|
||||
for (const key of ["sidewalkLeft", "sidewalkRight"]) if (item[key] !== undefined && typeof item[key] !== "boolean") throw new Error(`Invalid road override ${key}.`);
|
||||
} else if (item.kind === "junction-connection") {
|
||||
if (typeof item.fromEndpointId !== "string" || typeof item.toEndpointId !== "string" || typeof item.enabled !== "boolean" || (endpointIds && (!endpointIds.has(item.fromEndpointId) || !endpointIds.has(item.toEndpointId)))) throw new Error("Invalid junction connection override.");
|
||||
} else throw new Error(`Unsupported override kind: ${item.kind}`);
|
||||
}
|
||||
return { schema: OVERRIDE_SCHEMA, overrides: value.overrides };
|
||||
}
|
||||
|
||||
function applyRoadOverrides(road, overrides, diagnostics) {
|
||||
for (const item of overrides.overrides.filter((entry) => entry.kind === "road" && entry.roadId === road.id)) {
|
||||
for (const key of ["widthMeters", "laneCount", "sidewalkLeft", "sidewalkRight"]) if (item[key] !== undefined) road[key] = item[key];
|
||||
road.appliedOverrideIds.push(item.id);
|
||||
for (const key of ["widthMeters", "laneCount"]) if (item[key] !== undefined) road.provenance[key] = `override:${item.id}`;
|
||||
}
|
||||
if (road.widthMeters < road.laneCount * 2.4) diagnostics.push(diagnostic("warning", road.id, road.osmWayIds, "narrow-lane-width", "Configured road width is narrow for the selected lane count.", road.centerline[0]));
|
||||
}
|
||||
|
||||
function resolveConnections(endpoints, byNode, overrides, diagnostics) {
|
||||
const result = [];
|
||||
for (const [nodeId, items] of byNode) {
|
||||
const arrivals = items.filter((endpoint) => endpoint.side === "end");
|
||||
const departures = items.filter((endpoint) => endpoint.side === "start");
|
||||
for (const arrival of arrivals) for (const departure of departures) {
|
||||
if (arrival.roadId === departure.roadId) continue;
|
||||
const override = overrides.overrides.find((entry) => entry.kind === "junction-connection" && entry.fromEndpointId === arrival.id && entry.toEndpointId === departure.id);
|
||||
result.push({ id: `connection:${arrival.id}:${departure.id}`, nodeId, fromEndpointId: arrival.id, toEndpointId: departure.id, enabled: override ? override.enabled : true, provenance: override ? `override:${override.id}` : "osm:shared-node" });
|
||||
}
|
||||
if (items.length > 8) diagnostics.push(diagnostic("warning", `junction:node/${nodeId}`, [nodeId], "complex-junction", "Junction has more than eight directional endpoints and is not compiled as an ordinary junction.", items[0].coordinate));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function compileGeometry(model) {
|
||||
const diagnostics = [...model.diagnostics];
|
||||
const features = [];
|
||||
const emittedWays = new Set();
|
||||
for (const road of model.roads) {
|
||||
const wayKey = road.osmWayIds.join(",");
|
||||
if (emittedWays.has(wayKey)) continue;
|
||||
emittedWays.add(wayKey);
|
||||
const directions = model.roads.filter((item) => item.osmWayIds.join(",") === wayKey);
|
||||
const totalWidth = directions.reduce((sum, item) => sum + item.widthMeters, 0);
|
||||
const ring = roadRing(road.centerline, totalWidth);
|
||||
if (!ring) { diagnostics.push(diagnostic("error", road.id, road.osmWayIds, "unclosed-road-surface", "Could not construct a valid road polygon from this centerline.", road.centerline[0])); continue; }
|
||||
features.push({ type: "Feature", properties: { native_id: `surface:way/${wayKey}`, directional_road_ids: directions.map((item) => item.id).join(","), osm_way_ids: wayKey, width_m: totalWidth, lane_count: directions.reduce((sum, item) => sum + item.laneCount, 0), provenance: JSON.stringify(directions.map((item) => item.provenance)), override_ids: directions.flatMap((item) => item.appliedOverrideIds).join(",") }, geometry: { type: "Polygon", coordinates: [ring] } });
|
||||
}
|
||||
const junctionFeatures = ordinaryJunctionFeatures(model, diagnostics);
|
||||
return { roadSurface: { type: "FeatureCollection", features }, intersectionSurface: { type: "FeatureCollection", features: junctionFeatures }, diagnostics };
|
||||
}
|
||||
|
||||
function ordinaryJunctionFeatures(model, diagnostics) {
|
||||
const byNode = new Map();
|
||||
for (const endpoint of model.endpoints) {
|
||||
if (!byNode.has(endpoint.nodeId)) byNode.set(endpoint.nodeId, []);
|
||||
byNode.get(endpoint.nodeId).push(endpoint);
|
||||
}
|
||||
const result = [];
|
||||
for (const [nodeId, endpoints] of byNode) {
|
||||
const wayIds = new Set(endpoints.map((endpoint) => endpoint.roadId.split(":")[1]));
|
||||
if (wayIds.size < 3 || wayIds.size > 4) continue;
|
||||
const roads = endpoints.map((endpoint) => model.roads.find((road) => road.id === endpoint.roadId));
|
||||
const radius = Math.max(...roads.map((road) => road.widthMeters)) * 0.65;
|
||||
const ring = circleRing(endpoints[0].coordinate, radius, 16);
|
||||
result.push({ type: "Feature", properties: { native_id: `junction:node/${nodeId}`, osm_node_id: nodeId, kind: wayIds.size === 3 ? "t" : "cross", source_road_ids: [...new Set(roads.map((road) => road.id))].join(","), rule: "ordinary-junction-disc/v1" }, geometry: { type: "Polygon", coordinates: [ring] } });
|
||||
diagnostics.push(diagnostic("info", `junction:node/${nodeId}`, [nodeId], "ordinary-junction-surface", "Generated a conservative ordinary junction surface; connector geometry is deferred.", endpoints[0].coordinate));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function circleRing(center, radius, segments) {
|
||||
const origin = center;
|
||||
const ring = [];
|
||||
for (let index = 0; index <= segments; index += 1) {
|
||||
const angle = index / segments * Math.PI * 2;
|
||||
ring.push(unproject([Math.cos(angle) * radius, Math.sin(angle) * radius], origin));
|
||||
}
|
||||
return ring;
|
||||
}
|
||||
|
||||
function roadRing(line, width) {
|
||||
if (line.length < 2 || !Number.isFinite(width)) return null;
|
||||
const origin = line[0];
|
||||
const points = line.map((point) => project(point, origin));
|
||||
const left = []; const right = [];
|
||||
const half = width / 2;
|
||||
for (let i = 0; i < points.length; i += 1) {
|
||||
const prior = points[Math.max(0, i - 1)]; const next = points[Math.min(points.length - 1, i + 1)];
|
||||
const dx = next[0] - prior[0]; const dy = next[1] - prior[1]; const length = Math.hypot(dx, dy);
|
||||
if (length < 0.01) return null;
|
||||
const nx = -dy / length * half; const ny = dx / length * half;
|
||||
left.push(unproject([points[i][0] + nx, points[i][1] + ny], origin));
|
||||
right.push(unproject([points[i][0] - nx, points[i][1] - ny], origin));
|
||||
}
|
||||
const ring = [...left, ...right.reverse(), left[0]];
|
||||
return ring.every((point) => point.every(Number.isFinite)) ? ring : null;
|
||||
}
|
||||
|
||||
function project(point, origin) { const scale = 111320; return [(point[0] - origin[0]) * scale * Math.cos(origin[1] * Math.PI / 180), (point[1] - origin[1]) * scale]; }
|
||||
function unproject(point, origin) { const scale = 111320; return [point[0] / (scale * Math.cos(origin[1] * Math.PI / 180)) + origin[0], point[1] / scale + origin[1]]; }
|
||||
function diagnostic(severity, subjectId, sourceIds, rule, message, coordinate) { return { id: `diagnostic:${rule}:${subjectId}`, severity, subjectId, sourceIds, rule, message, geometry: coordinate ? { type: "Point", coordinates: coordinate } : null }; }
|
||||
function xmlAttrs(text) { const attrs = {}; for (const match of text.matchAll(/([:\w-]+)\s*=\s*(?:"([^"]*)"|'([^']*)')/g)) attrs[match[1]] = match[2] ?? match[3]; return attrs; }
|
||||
function parseTags(body) { const tags = {}; for (const match of body.matchAll(/<tag\b([^>]*)\/?\s*>/g)) { const attrs = xmlAttrs(match[1]); if (attrs.k) tags[attrs.k] = attrs.v || ""; } return tags; }
|
||||
function positiveInteger(value) { const number = Number(value); return Number.isInteger(number) && number > 0 ? number : null; }
|
||||
function positiveNumber(value) { const match = String(value ?? "").match(/^\s*(\d+(?:\.\d+)?)/); const number = match ? Number(match[1]) : null; return Number.isFinite(number) && number > 0 ? number : null; }
|
||||
function writeJsonAtomic(file, data) { fs.mkdirSync(path.dirname(file), { recursive: true }); const temporary = `${file}.${process.pid}.tmp`; fs.writeFileSync(temporary, `${JSON.stringify(data, null, 2)}\n`); fs.renameSync(temporary, file); }
|
||||
|
||||
module.exports = { OVERRIDE_SCHEMA, compileRoadModel, compileGeometry, loadOverrides, validateOverrides, writeJsonAtomic };
|
||||
58
scripts/road-workbench.js
Normal file
58
scripts/road-workbench.js
Normal file
@@ -0,0 +1,58 @@
|
||||
#!/usr/bin/env node
|
||||
"use strict";
|
||||
|
||||
const fs = require("fs");
|
||||
const http = require("http");
|
||||
const path = require("path");
|
||||
const { readAreaConfig } = require("./lib/area-config");
|
||||
const { loadOverrides, validateOverrides, writeJsonAtomic } = require("./lib/native-road");
|
||||
const { compileArea, parseArgs } = require("./compile-native-roads");
|
||||
|
||||
const repoRoot = path.resolve(__dirname, "..");
|
||||
|
||||
function main() {
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
const configPath = path.resolve(args.config || path.join(repoRoot, "config", "areas", "nantaizi-lake-innovation-valley.json"));
|
||||
if (args.noCompile !== "true") compileArea(configPath);
|
||||
const area = readAreaConfig(configPath, { repoRoot });
|
||||
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));
|
||||
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}/`));
|
||||
}
|
||||
|
||||
function handle(request, response, area, configPath) {
|
||||
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 === "/api/state") return sendJson(response, 200, state(area));
|
||||
if (request.method === "POST" && url.pathname === "/api/overrides") return readBody(request).then((body) => {
|
||||
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/compile") return Promise.resolve().then(() => {
|
||||
compileArea(configPath);
|
||||
sendJson(response, 200, state(area));
|
||||
}).catch((error) => sendJson(response, 500, { ok: false, error: error.message }));
|
||||
sendJson(response, 404, { error: "Not found" });
|
||||
}
|
||||
|
||||
function state(area) {
|
||||
const nativeDir = area.outputs.nativeRoadDir;
|
||||
const osm2streetsRoadSurface = path.join(area.outputs.geojsonDir, "road_surface.geojson");
|
||||
return { areaId: area.id, compiled: readCompiled(area), overrides: loadOverrides(area.outputs.nativeRoadOverrides), comparison: readJson(path.join(nativeDir, "comparison.json")), layers: { nativeRoadSurface: readLayer(path.join(nativeDir, "layers", "road_surface.geojson")), nativeIntersectionSurface: readLayer(path.join(nativeDir, "layers", "intersection_surface.geojson")), osm2streetsRoadSurface: fs.existsSync(osm2streetsRoadSurface) ? readLayer(osm2streetsRoadSurface) : null } };
|
||||
}
|
||||
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 sendFile(response, file, type) { response.writeHead(200, { "Content-Type": type, "Cache-Control": "no-store" }); fs.createReadStream(file).pipe(response); }
|
||||
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`); }
|
||||
if (require.main === module) main();
|
||||
28
scripts/test-native-road.js
Normal file
28
scripts/test-native-road.js
Normal file
@@ -0,0 +1,28 @@
|
||||
#!/usr/bin/env node
|
||||
"use strict";
|
||||
|
||||
const assert = require("assert");
|
||||
const { compileRoadModel, compileGeometry, validateOverrides } = require("./lib/native-road");
|
||||
|
||||
const osm = `<osm><node id="1" lon="114" lat="30"/><node id="2" lon="114.001" lat="30"/><node id="3" lon="114.001" lat="30.001"/><way id="10"><nd ref="1"/><nd ref="2"/><tag k="highway" v="residential"/><tag k="lanes" v="2"/><tag k="sidewalk" v="both"/></way><way id="11"><nd ref="2"/><nd ref="3"/><tag k="highway" v="residential"/><tag k="oneway" v="yes"/></way></osm>`;
|
||||
const empty = { schema: "native-road-overrides/v1", overrides: [] };
|
||||
const initial = compileRoadModel(osm, empty);
|
||||
assert.equal(initial.roads.length, 3);
|
||||
const target = initial.roads.find((road) => road.id === "road:way/10:forward");
|
||||
const overrides = validateOverrides({ schema: "native-road-overrides/v1", overrides: [{ id: "road-width", kind: "road", roadId: target.id, widthMeters: 9, laneCount: 2, sidewalkLeft: false }] }, initial);
|
||||
const model = compileRoadModel(osm, overrides);
|
||||
const edited = model.roads.find((road) => road.id === target.id);
|
||||
assert.equal(edited.widthMeters, 9);
|
||||
assert.equal(edited.provenance.widthMeters, "override:road-width");
|
||||
assert.equal(edited.sidewalkLeft, false);
|
||||
const geometry = compileGeometry(model);
|
||||
assert.equal(geometry.roadSurface.features.length, 2);
|
||||
assert.ok(geometry.roadSurface.features.every((feature) => feature.geometry.coordinates[0].length >= 5));
|
||||
const connection = initial.connections[0];
|
||||
assert.ok(initial.connections.every((item) => item.fromEndpointId.endsWith(":end") && item.toEndpointId.endsWith(":start")));
|
||||
assert.equal(initial.connections.length, new Set(initial.connections.map((item) => `${item.fromEndpointId}->${item.toEndpointId}`)).size);
|
||||
const connectionOverrides = validateOverrides({ schema: "native-road-overrides/v1", overrides: [{ id: "disconnect", kind: "junction-connection", fromEndpointId: connection.fromEndpointId, toEndpointId: connection.toEndpointId, enabled: false }] }, initial);
|
||||
assert.equal(validateOverrides(connectionOverrides).overrides.length, 1);
|
||||
assert.equal(compileRoadModel(osm, connectionOverrides).connections.find((item) => item.id === connection.id).enabled, false);
|
||||
assert.throws(() => validateOverrides({ schema: "native-road-overrides/v1", overrides: [{ id: "bad", kind: "road", roadId: "missing", widthMeters: 4 }] }, initial), /Unknown road/);
|
||||
console.log("native road tests passed");
|
||||
1
scripts/workbench/app.css
Normal file
1
scripts/workbench/app.css
Normal file
@@ -0,0 +1 @@
|
||||
*{box-sizing:border-box}body{margin:0;background:#eef1ef;color:#202523;font:14px system-ui,sans-serif}header{height:50px;display:flex;gap:12px;align-items:center;padding:0 16px;background:#183a32;color:#fff}header span{color:#c9d8d2}button{border:1px solid #82988f;background:#fff;color:#1d392f;padding:7px 10px;border-radius:3px;cursor:pointer}header button:first-of-type{margin-left:auto}main{display:grid;grid-template-columns:260px minmax(0,1fr) 320px;height:calc(100vh - 50px)}aside{overflow:auto;background:#fff;padding:16px}.issues{border-right:1px solid #d5dfda}.inspector{border-left:1px solid #d5dfda}.map{position:relative;background:#d7e2de;min-height:400px}canvas{width:100%;height:100%;display:block}.legend{position:absolute;bottom:12px;left:12px;background:#fff;padding:8px;box-shadow:0 1px 4px #0003}.legend i{display:inline-block;width:18px;height:7px;margin:0 4px -1px 10px}.reference{background:#a5b0b5}.native{background:#296654}.line{height:3px!important;background:#263630}.junction{width:10px!important;height:10px!important;background:#0e7860;border-radius:50%}.warning{width:10px!important;height:10px!important;background:#d49318;border-radius:50%}h1{font-size:16px;margin:0 0 8px}h2{font-size:14px;margin:12px 0 8px}.muted,output,pre{color:#52615b}.issues ul{list-style:none;padding:0;margin:0}.issues button{width:100%;text-align:left;margin:4px 0;background:#fff7e5;border-color:#e7c67b;color:#693c00}.issues button.error{background:#fff0ee;border-color:#e3a49b;color:#8d261a}label{display:block;margin:10px 0}input[type=number]{display:block;width:100%;padding:7px;border:1px solid #aab8b2;border-radius:2px}output,pre{display:block;white-space:pre-wrap;overflow-wrap:anywhere}form button{margin-top:8px;background:#286956;color:white;border:0}hr{border:0;border-top:1px solid #dde4e1;margin:16px 0}details{margin-top:16px}summary{cursor:pointer;font-weight:600}@media(max-width:900px){main{grid-template-columns:minmax(0,1fr)}.issues{display:none}.inspector{position:absolute;right:0;bottom:0;width:min(360px,100%);max-height:55vh;border-top:1px solid #d5dfda}}
|
||||
116
scripts/workbench/app.js
Normal file
116
scripts/workbench/app.js
Normal file
@@ -0,0 +1,116 @@
|
||||
"use strict";
|
||||
|
||||
const canvas = document.querySelector("#map");
|
||||
const context = canvas.getContext("2d");
|
||||
const status = document.querySelector("#status");
|
||||
const form = document.querySelector("#road-form");
|
||||
const areaLabel = document.querySelector("#area");
|
||||
const widthInput = document.querySelector("#width");
|
||||
const lanesInput = document.querySelector("#lanes");
|
||||
const leftInput = document.querySelector("#left");
|
||||
const rightInput = document.querySelector("#right");
|
||||
const evidence = document.querySelector("#evidence");
|
||||
const saveButton = document.querySelector("#save");
|
||||
const compileButton = document.querySelector("#compile");
|
||||
const diagnostics = document.querySelector("#diagnostics");
|
||||
const directionSwitch = document.querySelector("#direction-switch");
|
||||
let state;
|
||||
let selected = null;
|
||||
let staged = [];
|
||||
|
||||
function message(text) { status.textContent = text; }
|
||||
function roadLabel(road) { return road.tags.name || `${road.highway}(OSM ${road.osmWayIds.join(", ")})`; }
|
||||
function coord(point) { const b = state.bounds; return [(point[0] - b.minX) / (b.maxX - b.minX) * canvas.width, canvas.height - (point[1] - b.minY) / (b.maxY - b.minY) * canvas.height]; }
|
||||
function setBounds() { const points = state.compiled.model.roads.flatMap((road) => road.centerline); const xs = points.map((point) => point[0]); const ys = points.map((point) => point[1]); const pad = Math.max((Math.max(...xs) - Math.min(...xs)) * 0.06, 0.0001); state.bounds = { minX: Math.min(...xs) - pad, maxX: Math.max(...xs) + pad, minY: Math.min(...ys) - pad, maxY: Math.max(...ys) + pad }; }
|
||||
function resize() { canvas.width = canvas.clientWidth * devicePixelRatio; canvas.height = canvas.clientHeight * devicePixelRatio; draw(); }
|
||||
function polygon(feature, fill) { const ring = feature.geometry?.coordinates?.[0]; if (!ring) return; context.beginPath(); ring.forEach((point, index) => { const point2d = coord(point); index ? context.lineTo(...point2d) : context.moveTo(...point2d); }); context.fillStyle = fill; context.fill(); }
|
||||
function draw() {
|
||||
if (!state) return;
|
||||
context.clearRect(0, 0, canvas.width, canvas.height);
|
||||
const layers = state.layers || {};
|
||||
for (const feature of layers.osm2streetsRoadSurface?.features || []) polygon(feature, "#9ba8ae55");
|
||||
for (const feature of layers.nativeRoadSurface?.features || []) polygon(feature, "#28695666");
|
||||
for (const feature of layers.nativeIntersectionSurface?.features || []) polygon(feature, "#0e786066");
|
||||
for (const road of state.compiled.model.roads) {
|
||||
context.beginPath(); road.centerline.forEach((point, index) => { const point2d = coord(point); index ? context.lineTo(...point2d) : context.moveTo(...point2d); });
|
||||
context.strokeStyle = road.id === selected?.id ? "#006e91" : "#263630";
|
||||
context.lineWidth = (road.id === selected?.id ? 4 : 2) * devicePixelRatio;
|
||||
context.stroke();
|
||||
}
|
||||
if (selected) drawDirectionArrow(selected);
|
||||
for (const diagnostic of state.compiled.diagnostics) {
|
||||
if (!diagnostic.geometry) continue;
|
||||
const point = coord(diagnostic.geometry.coordinates); const isJunction = diagnostic.rule === "ordinary-junction-surface"; context.fillStyle = isJunction ? "#0e7860" : diagnostic.severity === "error" ? "#bf3b2e" : "#d49318"; context.beginPath(); context.arc(...point, isJunction ? 4 * devicePixelRatio : 5 * devicePixelRatio, 0, Math.PI * 2); context.fill();
|
||||
}
|
||||
if (state.focusedDiagnostic?.geometry) { const point = coord(state.focusedDiagnostic.geometry.coordinates); context.strokeStyle = "#006e91"; context.lineWidth = 3 * devicePixelRatio; context.beginPath(); context.arc(...point, 11 * devicePixelRatio, 0, Math.PI * 2); context.stroke(); }
|
||||
}
|
||||
|
||||
function drawDirectionArrow(road) {
|
||||
const middle = Math.max(1, Math.floor(road.centerline.length / 2));
|
||||
const a = coord(road.centerline[middle - 1]); const b = coord(road.centerline[middle]);
|
||||
const angle = Math.atan2(b[1] - a[1], b[0] - a[0]); const size = 10 * devicePixelRatio;
|
||||
context.save(); context.translate(b[0], b[1]); context.rotate(angle); context.fillStyle = "#006e91";
|
||||
context.beginPath(); context.moveTo(size, 0); context.lineTo(-size * 0.8, -size * 0.6); context.lineTo(-size * 0.8, size * 0.6); context.closePath(); context.fill(); context.restore();
|
||||
}
|
||||
|
||||
function select(road) {
|
||||
selected = road; form.hidden = !road; document.querySelector("#hint").hidden = Boolean(road);
|
||||
if (!road) return;
|
||||
document.querySelector("#road-name").textContent = `${roadLabel(road)}(${road.direction === "forward" ? "沿 OSM 方向行驶" : "逆 OSM 方向行驶"})`;
|
||||
widthInput.value = road.widthMeters; lanesInput.value = road.laneCount; leftInput.checked = road.sidewalkLeft; rightInput.checked = road.sidewalkRight;
|
||||
evidence.textContent = JSON.stringify({ OSM道路: road.osmWayIds, 名称: road.tags.name || "未标注", 参数来源: road.provenance, 已应用修改: road.appliedOverrideIds, 原始标签: road.tags }, null, 2);
|
||||
renderDirectionSwitch(road);
|
||||
renderConnections(road); draw();
|
||||
}
|
||||
|
||||
function renderDirectionSwitch(road) {
|
||||
directionSwitch.innerHTML = "";
|
||||
const alternatives = state.compiled.model.roads.filter((item) => item.osmWayIds.join(",") === road.osmWayIds.join(","));
|
||||
if (alternatives.length < 2) { directionSwitch.textContent = "单向道路:沿 OSM 节点顺序行驶"; return; }
|
||||
const note = document.createElement("label"); note.textContent = "编辑方向(地图蓝色箭头表示当前方向)"; directionSwitch.append(note);
|
||||
for (const item of alternatives) {
|
||||
const button = document.createElement("button");
|
||||
button.type = "button"; button.textContent = item.direction === "forward" ? "沿 OSM 节点顺序" : "逆 OSM 节点顺序";
|
||||
button.disabled = item.id === road.id; button.onclick = () => select(item); directionSwitch.append(button);
|
||||
}
|
||||
}
|
||||
|
||||
function renderConnections(road) {
|
||||
const box = document.querySelector("#connections"); box.innerHTML = "";
|
||||
const endpoint = state.compiled.model.endpoints.find((item) => item.roadId === road.id && item.side === "end");
|
||||
const rows = state.compiled.model.connections.filter((connection) => connection.fromEndpointId === endpoint?.id);
|
||||
if (!rows.length) { box.textContent = "当前行驶方向到达道路终点后,没有可编辑的驶出道路。"; return; }
|
||||
const intro = document.createElement("p"); intro.textContent = "到达终点路口后,允许驶入:"; box.append(intro);
|
||||
const seen = new Set();
|
||||
for (const connection of rows) {
|
||||
const targetEndpoint = state.compiled.model.endpoints.find((item) => item.id === connection.toEndpointId);
|
||||
const target = state.compiled.model.roads.find((item) => item.id === targetEndpoint?.roadId);
|
||||
if (!target || seen.has(target.id)) continue;
|
||||
seen.add(target.id);
|
||||
const input = document.createElement("input"); const label = document.createElement("label");
|
||||
input.type = "checkbox"; input.checked = connection.enabled; input.onchange = () => stageConnection(connection, input.checked);
|
||||
label.append(input, ` ${turnName(road, target)}:${roadLabel(target)}`); box.append(label);
|
||||
}
|
||||
}
|
||||
|
||||
function turnName(from, to) {
|
||||
const a = heading(from.centerline.at(-2), from.centerline.at(-1));
|
||||
const b = heading(to.centerline[0], to.centerline[1]);
|
||||
const delta = ((b - a + 540) % 360) - 180;
|
||||
if (Math.abs(delta) >= 150) return "掉头";
|
||||
if (Math.abs(delta) <= 30) return "直行";
|
||||
return delta > 0 ? "右转" : "左转";
|
||||
}
|
||||
function heading(a, b) { return Math.atan2(b[0] - a[0], b[1] - a[1]) * 180 / Math.PI; }
|
||||
function stageConnection(connection, enabled) { const id = `连接:${connection.id}`; staged = staged.filter((item) => item.id !== id); staged.push({ id, kind: "junction-connection", fromEndpointId: connection.fromEndpointId, toEndpointId: connection.toEndpointId, enabled }); message("有未保存修改"); }
|
||||
function pointToSegmentDistance(point, a, b) { const dx = b[0] - a[0]; const dy = b[1] - a[1]; const lengthSquared = dx * dx + dy * dy; const t = lengthSquared ? Math.max(0, Math.min(1, ((point[0] - a[0]) * dx + (point[1] - a[1]) * dy) / lengthSquared)) : 0; return Math.hypot(point[0] - (a[0] + t * dx), point[1] - (a[1] + t * dy)); }
|
||||
function pickRoad(point) { let best = null; let distance = Infinity; for (const road of state.compiled.model.roads) for (let index = 1; index < road.centerline.length; index += 1) { const candidate = pointToSegmentDistance(point, coord(road.centerline[index - 1]), coord(road.centerline[index])); if (candidate < distance) { distance = candidate; best = road; } } return distance <= 18 * devicePixelRatio ? best : null; }
|
||||
canvas.onclick = (event) => { const rect = canvas.getBoundingClientRect(); select(pickRoad([(event.clientX - rect.left) * devicePixelRatio, (event.clientY - rect.top) * devicePixelRatio])); };
|
||||
form.onsubmit = (event) => { event.preventDefault(); const id = `道路:${selected.id}`; staged = staged.filter((item) => item.id !== id); staged.push({ id, kind: "road", roadId: selected.id, widthMeters: Number(widthInput.value), laneCount: Number(lanesInput.value), sidewalkLeft: leftInput.checked, sidewalkRight: rightInput.checked }); message("有未保存修改"); };
|
||||
saveButton.onclick = async () => { const existing = state.overrides.overrides.filter((item) => !staged.some((change) => change.id === item.id)); const response = await fetch("/api/overrides", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ schema: "native-road-overrides/v1", overrides: [...existing, ...staged] }) }); const data = await response.json(); if (!data.ok) return message(data.error); state.overrides = data.overrides; staged = []; message("已保存,点击“保存并重新生成”生效"); };
|
||||
compileButton.onclick = async () => { message("正在重新生成..."); const response = await fetch("/api/compile", { method: "POST" }); state = await response.json(); staged = []; setup(); message("已按保存的修改重新生成"); };
|
||||
function focusDiagnostic(diagnostic) { state.focusedDiagnostic = diagnostic; const road = state.compiled.model.roads.find((item) => item.id === diagnostic.subjectId); if (road) { select(road); message(diagnostic.message); } else { draw(); message(diagnostic.message); } }
|
||||
function showInIssueList(diagnostic) { return diagnostic.severity === "error" || diagnostic.rule !== "ordinary-junction-surface"; }
|
||||
function setup() { areaLabel.textContent = state.areaId; diagnostics.innerHTML = ""; const issues = state.compiled.diagnostics.filter(showInIssueList); if (!issues.length) diagnostics.innerHTML = "<li>没有需要人工检查的问题。</li>"; for (const diagnostic of issues) { const item = document.createElement("li"); const button = document.createElement("button"); button.className = diagnostic.severity === "error" ? "error" : ""; button.textContent = diagnostic.message; button.onclick = () => focusDiagnostic(diagnostic); item.append(button); diagnostics.append(item); } setBounds(); resize(); select(null); }
|
||||
fetch("/api/state").then((response) => response.json()).then((value) => { state = value; setup(); message(`${state.compiled.model.roads.length} 条方向道路,${state.compiled.diagnostics.length} 个待检查项`); }).catch((error) => message(error.message));
|
||||
window.onresize = resize;
|
||||
4
scripts/workbench/index.html
Normal file
4
scripts/workbench/index.html
Normal file
@@ -0,0 +1,4 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>道路编译工作台</title><link rel="stylesheet" href="/app.css"></head>
|
||||
<body><header><strong>道路编译工作台</strong><span id="area"></span><span id="status"></span><button id="save">保存修改</button><button id="compile">保存并重新生成</button></header>
|
||||
<main><aside class="issues"><h1>待检查问题</h1><p class="muted">点击问题可定位到道路或路口。</p><ul id="diagnostics"></ul></aside><section class="map"><canvas id="map"></canvas><div class="legend"><i class="reference"></i> osm2streets 参考面 <i class="native"></i> 自研道路面 <i class="line"></i> 道路中心线 <i class="junction"></i> 已识别路口 <i class="warning"></i> 待检查点</div></section><aside class="inspector"><h1>当前道路设置</h1><p id="hint">在地图中点击道路,查看和调整参数。</p><form id="road-form" hidden><label>道路</label><output id="road-name"></output><div id="direction-switch"></div><label>本方向道路宽度(米)<input id="width" type="number" min="1" step="0.1"></label><label>本方向车道数<input id="lanes" type="number" min="1" step="1"></label><label><input id="left" type="checkbox"> 左侧有人行道</label><label><input id="right" type="checkbox"> 右侧有人行道</label><button type="submit">暂存本道路修改</button></form><hr><h2>路口连接</h2><div id="connections">请选择一条道路。</div><details><summary>技术详情与来源</summary><pre id="evidence">无</pre></details></aside></main><script src="/app.js"></script></body></html>
|
||||
Reference in New Issue
Block a user