diff --git a/scripts/lib/native-road.js b/scripts/lib/native-road.js index f0073b6..008123d 100644 --- a/scripts/lib/native-road.js +++ b/scripts/lib/native-road.js @@ -246,9 +246,97 @@ function nearbyManualCandidates(endpoints, from) { return endpoints.filter((to) => to.side === "start" && to.roadId !== from.roadId && !sameOsmWay(endpoints, from.roadId, to.roadId)).map((to) => ({ to, distanceMeters: distanceMeters(from.coordinate, to.coordinate) })).filter((item) => item.distanceMeters <= 35).sort((a, b) => a.distanceMeters - b.distanceMeters).slice(0, 3).map(({ to, distanceMeters: meters }) => ({ toEndpointId: to.id, roadId: to.roadId, distanceMeters: Math.round(meters * 10) / 10 })); } +// A single physical intersection is often mapped as several nodes joined by +// short links: a dual carriageway crossing, a slip lane, a staggered junction. +// Each node then compiles its own surface and the shared area between them is +// left as ordinary road, which is what produces the width jumps and stray +// medians around those nodes. Report the clusters so they can be configured as +// `complex-junction-v1`. Detection is advisory only — it never enables a +// template or changes geometry, because flipping a junction between the +// ordinary and complex paths silently on an OSM edit would be unpredictable. +const COMPLEX_CANDIDATE_MAX_LINK_METERS = 30; +const COMPLEX_CANDIDATE_MIN_NODES = 2; +// Short links chain transitively, so a run of closely spaced junctions along +// one street unions into a single 'cluster' that is really a corridor. A real +// intersection stays compact, so bound the cluster by its own diameter: the +// surveyed 珠山湖大道 cluster spans 24.6 m on its four mapped nodes and 40.7 m +// once the neighbouring service-road junction is folded in. +const COMPLEX_CANDIDATE_MAX_DIAMETER_METERS = 45; + +function detectComplexJunctionCandidates(model, junctionPlans, options, diagnostics) { + const configured = new Set((options.junctionTemplates?.clusters || []).flatMap((cluster) => (cluster.nodeIds || []).map(String))); + const segmentsByNode = new Map(); + for (const endpoint of model.endpoints) { + const key = String(endpoint.nodeId); + if (!segmentsByNode.has(key)) segmentsByNode.set(key, new Set()); + segmentsByNode.get(key).add(endpoint.roadId.replace(/:(forward|backward)$/, "")); + } + const junctionNodes = new Set([...segmentsByNode].filter(([, segments]) => segments.size >= 3).map(([nodeId]) => nodeId)); + const parent = new Map(); + const find = (id) => { + if (!parent.has(id)) parent.set(id, id); + while (parent.get(id) !== id) { parent.set(id, parent.get(parent.get(id))); id = parent.get(id); } + return id; + }; + const union = (first, second) => { const a = find(first); const b = find(second); if (a !== b) parent.set(a, b); }; + const links = new Map(); + const seenSegments = new Set(); + for (const road of model.roads) { + if (seenSegments.has(road.segmentId)) continue; + seenSegments.add(road.segmentId); + const start = String(road.sourceNodeIds[0]); const end = String(road.sourceNodeIds.at(-1)); + if (start === end || !junctionNodes.has(start) || !junctionNodes.has(end)) continue; + const length = lineLengthMeters(road.centerline); + if (length > COMPLEX_CANDIDATE_MAX_LINK_METERS) continue; + union(start, end); + links.set(road.segmentId, { start, end, length }); + } + const clusters = new Map(); + for (const nodeId of parent.keys()) { + const root = find(nodeId); + if (!clusters.has(root)) clusters.set(root, []); + clusters.get(root).push(nodeId); + } + for (const nodeIds of clusters.values()) { + if (nodeIds.length < COMPLEX_CANDIDATE_MIN_NODES) continue; + if (nodeIds.some((nodeId) => configured.has(nodeId))) continue; + const points = nodeIds.map((nodeId) => junctionPlans.get(nodeId)?.node).filter(Boolean); + if (points.length !== nodeIds.length) continue; + const center = points.reduce((sum, point) => [sum[0] + point[0] / points.length, sum[1] + point[1] / points.length], [0, 0]); + const spreadMeters = Math.max(...points.map((point) => distanceMeters(center, point))); + let diameterMeters = 0; + for (let first = 0; first < points.length; first += 1) { + for (let second = first + 1; second < points.length; second += 1) diameterMeters = Math.max(diameterMeters, distanceMeters(points[first], points[second])); + } + if (diameterMeters > COMPLEX_CANDIDATE_MAX_DIAMETER_METERS) continue; + const inner = [...links.values()].filter((link) => nodeIds.includes(link.start) && nodeIds.includes(link.end)); + const widths = nodeIds.flatMap((nodeId) => (junctionPlans.get(nodeId)?.approaches || []).map((approach) => approach.widthMeters)); + const widestApproach = widths.length ? Math.max(...widths) : 0; + // Enough core to cover every member node plus the widest approach's half + // width, with a little slack. A starting point for tuning, not a result. + const suggestedCoreRadius = Math.min(80, Math.max(12, Math.round(spreadMeters + widestApproach / 2 + 4))); + diagnostics.push({ + ...diagnostic("info", `junction-cluster-candidate:${nodeIds.slice().sort().join("+")}`, nodeIds, "complex-junction-candidate", + `检测到 ${nodeIds.length} 个路口节点由 ${inner.length} 条短路段(最长 ${Math.round(Math.max(...inner.map((link) => link.length)) * 10) / 10} 米)相连,可能是同一个物理路口。当前按独立路口编译;如需合并请在 nativeRoad.junctionTemplates.clusters 中配置。`, + center), + suggestedCluster: { + template: "complex-junction-v1", + nodeIds: nodeIds.slice().sort(), + nodeCount: nodeIds.length, + spreadMeters: Math.round(spreadMeters * 10) / 10, + diameterMeters: Math.round(diameterMeters * 10) / 10, + longestLinkMeters: Math.round(Math.max(...inner.map((link) => link.length)) * 10) / 10, + widestApproachMeters: Math.round(widestApproach * 100) / 100, + coreRadiusMeters: suggestedCoreRadius, + }, + }); + } +} + function compileGeometry(model, overrides = { overrides: [] }, options = {}) { const diagnostics = [...model.diagnostics]; const junctionPlans = compileJunctionPlans(model, options, diagnostics); + detectComplexJunctionCandidates(model, junctionPlans, options, diagnostics); const features = []; const activeClusters = options.junctionTemplates?.enabled ? (options.junctionTemplates.clusters || []) : []; const clusterByNode = new Map(activeClusters.flatMap((cluster) => cluster.nodeIds.map((nodeId) => [String(nodeId), cluster]))); @@ -311,7 +399,7 @@ function compileGeometry(model, overrides = { overrides: [] }, options = {}) { diagnostics.push(...generated.diagnostics); } const lanes = compileLaneCenterlines(model, diagnostics, junctionPlans, options, { crosswalks: generatedComplexCrosswalks, stopLines: generatedComplexStopLines }); - const edgeLines = options.edgeLines === false ? [] : compileEdgeLines(model, overrides, junctionPlans); + const edgeLines = options.edgeLines === false ? [] : compileEdgeLines(model, overrides, junctionPlans, options, features); const controls = compileControlMarkings(model, lanes, diagnostics, junctionPlans); const allControls = { crosswalks: [...controls.crosswalks, ...generatedComplexCrosswalks], stopLines: [...controls.stopLines, ...generatedComplexStopLines] }; const centerLines = compileCenterLines(model, overrides, junctionPlans, allControls, diagnostics, options); @@ -415,11 +503,30 @@ function laneWasClipped(original, visible) { return Boolean(visible) && lineLengthMeters(visible) < lineLengthMeters(original) - .01; } -function compileEdgeLines(model, overrides, junctionPlans) { +function compileEdgeLines(model, overrides, junctionPlans, options = {}, roadSurfaces = []) { const features = []; + const clusters = options.junctionTemplates?.enabled ? (options.junctionTemplates.clusters || []) : []; + const clusterByNode = new Map(clusters.flatMap((cluster) => cluster.nodeIds.map((nodeId) => [String(nodeId), cluster]))); + const clusterCenters = new Map(clusters.map((cluster) => [cluster.id, clusterCenter(cluster, junctionPlans)])); + // Roads swallowed by a complex cluster never get a surface. Deriving their + // edge from the centerline anyway paints a curb across bare ground, so take + // the surfaces actually emitted as the authority on what can be outlined. + const surfaced = new Set(roadSurfaces.flatMap((feature) => String(feature.properties?.directional_road_ids || "").split(",")).filter(Boolean)); for (const road of model.roads) { - const line = trimLineAtJunctions(road.centerline, road.sourceNodeIds, junctionPlans); - const bidirectional = model.roads.some((item) => item.segmentId === road.segmentId && item.id !== road.id); + if (surfaced.size && !surfaced.has(road.id)) continue; + // The road surface is one polygon per segment, centred on this centerline + // and spanning the sum of both directions. Deriving the edge from a single + // direction's width puts it half a carriageway inside the asphalt. + const directions = model.roads.filter((item) => item.segmentId === road.segmentId); + const totalWidth = directions.reduce((sum, item) => sum + item.widthMeters, 0); + const bidirectional = directions.length > 1; + // Match the surface's trim exactly. A cluster road is cut at the cluster + // boundary, not at the ordinary junction cutback; using the cutback here + // runs the edge line out past the asphalt it is supposed to outline. + const cluster = clusterByNode.get(String(road.sourceNodeIds[0])) || clusterByNode.get(String(road.sourceNodeIds.at(-1))); + const line = cluster?.template === "complex-junction-v1" + ? trimLineAtComplexCluster(road.centerline, road.sourceNodeIds, junctionPlans, cluster, clusterCenters.get(cluster.id)) + : trimLineAtJunctions(road.centerline, road.sourceNodeIds, junctionPlans); // On a two-way segment, the inner edge is the road centre boundary and is // owned by center_lines. Emit only each directional carriageway's outer // edge; emitting both sides makes the layer look like a second centreline. @@ -427,7 +534,8 @@ function compileEdgeLines(model, overrides, junctionPlans) { for (const offset of offsets) { const side = offset < 0 ? "right" : "left"; const style = edgeLineStyle(overrides, road.id, side); - const centerline = offsetLine(line, offset * road.widthMeters / 2); + const centerline = offsetLine(line, offset * totalWidth / 2); + if (!centerline) continue; if (style.pattern === "solid") { const ring = roadRing(centerline, .12); if (ring) features.push(edgeLineFeature(road, side, style, ring)); @@ -1180,17 +1288,64 @@ function junctionApproaches(model, endpoints) { }); } +// `roadRing` builds the trimmed road's end edge from the direction at the +// cutback point, not at the node. Taking the node-side heading here instead +// leaves the two edges non-parallel whenever the way bends inside the cutback, +// and the junction surface then opens a wedge against the road it should meet. +function headingAtCutback(line, cutbackMeters) { + const point = pointAlongLine(line, cutbackMeters); + if (!point) return null; + let traversed = 0; + for (let index = 1; index < line.length; index += 1) { + traversed += distanceMeters(line[index - 1], line[index]); + // The first original vertex past the cutback is what the trimmed line + // carries as its second point, so match that pair exactly. + if (traversed > cutbackMeters + 1e-9) return headingDegrees(point, line[index]); + } + return headingAtEndpoint(line); +} + +// A dual carriageway reaches a node as two approaches on almost the same +// bearing. Their four side points interleave once sorted by angle, and because +// consecutive points then belong to different segments a rounded corner gets +// inserted between each interleaved pair. Those curves dive back toward the +// node and render as arch-shaped holes in the junction. Merge such approaches +// into one face and keep only its outermost edges. +const PARALLEL_APPROACH_DEGREES = 25; + +function signedHeadingDelta(value) { return ((value + 180) % 360 + 360) % 360 - 180; } + +function mergeParallelApproachPoints(points, node) { + const groups = []; + for (const item of points) { + const group = groups.find((candidate) => Math.abs(signedHeadingDelta(candidate.heading - item.outwardHeading)) < PARALLEL_APPROACH_DEGREES); + if (group) { group.points.push(item); continue; } + groups.push({ heading: item.outwardHeading, points: [item] }); + } + return groups.flatMap((group) => { + const segments = [...new Set(group.points.map((item) => item.segmentId))]; + if (segments.length < 2) return group.points; + // Order across the face by bearing measured from the group's own heading, + // so the comparison never straddles the +/-180 discontinuity. + const sorted = [...group.points].sort((first, second) => + signedHeadingDelta(headingDegrees(node, first.point) - group.heading) - signedHeadingDelta(headingDegrees(node, second.point) - group.heading)); + const merged = segments.sort().join("+"); + return [sorted[0], sorted.at(-1)].map((item) => ({ ...item, segmentId: merged, sourceWayKey: merged })); + }); +} + function junctionBoundary(approaches, node, cutbackMeters, cornerRadiusMultiplier = 1, approachWidthMultiplier = 1) { const points = []; for (const approach of approaches) { const cutback = pointAlongLine(approach.line, cutbackMeters); if (!cutback) continue; - const heading = headingAtEndpoint(approach.line); + const heading = headingAtCutback(approach.line, cutbackMeters) ?? headingAtEndpoint(approach.line); const half = approach.widthMeters * approachWidthMultiplier / 2; points.push({ point: offsetCoordinate(cutback, heading + 90, half), segmentId: approach.segmentId, sourceWayKey: approach.sourceWayKey, outwardHeading: heading }); points.push({ point: offsetCoordinate(cutback, heading - 90, half), segmentId: approach.segmentId, sourceWayKey: approach.sourceWayKey, outwardHeading: heading }); } - const ordered = points.sort((a, b) => angleAround(node, a.point) - angleAround(node, b.point)); + const faces = mergeParallelApproachPoints(points, node); + const ordered = faces.sort((a, b) => angleAround(node, a.point) - angleAround(node, b.point)); if (ordered.length < 3) return { points: [], mode: "approach-envelope" }; const boundary = []; let rounded = 0; @@ -1447,6 +1602,31 @@ function circleRing(center, radius, segments) { return ring; } +// Offsetting every vertex by a fixed distance along its averaged normal has no +// miter limit: where the centerline turns and the neighbouring segment is short +// — typically the stub left after junction trimming — consecutive offset points +// swap order and the edge doubles back. The ring then self-intersects and the +// folded lobe renders as a hole. Drop the reversed vertices so each offset +// edge keeps travelling the same way as the centerline segment it follows. +function removeOffsetFolds(offset, centerline) { + let kept = offset.map((point, index) => ({ point, index })); + for (let guard = 0; guard < offset.length && kept.length > 2; guard += 1) { + let removed = false; + for (let position = 0; position < kept.length - 1; position += 1) { + const from = kept[position]; const to = kept[position + 1]; + const alongCenter = [centerline[to.index][0] - centerline[from.index][0], centerline[to.index][1] - centerline[from.index][1]]; + const alongOffset = [to.point[0] - from.point[0], to.point[1] - from.point[1]]; + if (alongCenter[0] * alongOffset[0] + alongCenter[1] * alongOffset[1] >= 0) continue; + // Keep both termini: they are where the surface meets its junctions. + kept.splice(position + 1 === kept.length - 1 ? position : position + 1, 1); + removed = true; + break; + } + if (!removed) break; + } + return kept.map((item) => item.point); +} + function roadRing(line, width) { if (line.length < 2 || !Number.isFinite(width)) return null; const origin = line[0]; @@ -1458,10 +1638,13 @@ function roadRing(line, width) { 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)); + left.push([points[i][0] + nx, points[i][1] + ny]); + right.push([points[i][0] - nx, points[i][1] - ny]); } - const ring = [...left, ...right.reverse(), left[0]]; + const leftEdge = removeOffsetFolds(left, points).map((point) => unproject(point, origin)); + const rightEdge = removeOffsetFolds(right, points).map((point) => unproject(point, origin)); + if (leftEdge.length < 2 || rightEdge.length < 2) return null; + const ring = [...leftEdge, ...rightEdge.reverse(), leftEdge[0]]; return ring.every((point) => point.every(Number.isFinite)) ? ring : null; } diff --git a/scripts/road-workbench.js b/scripts/road-workbench.js index ac45ae0..f78f706 100644 --- a/scripts/road-workbench.js +++ b/scripts/road-workbench.js @@ -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], { diff --git a/scripts/test-native-road.js b/scripts/test-native-road.js index 6846930..b065e1f 100644 --- a/scripts/test-native-road.js +++ b/scripts/test-native-road.js @@ -77,6 +77,24 @@ assert.ok(geometry.intersectionSurface.features.every((feature) => feature.prope assert.ok(geometry.intersectionSurface.features.every((feature) => ["approach-envelope", "rounded-approach-envelope", "connector-convex-fallback"].includes(feature.properties.boundary_mode))); assert.ok(geometry.intersectionSurface.features.every((feature) => feature.properties.approach_area_m2 > 0 && feature.properties.surface_area_m2 > 0 && feature.properties.expansion_ratio >= 1)); for (const feature of geometry.intersectionSurface.features.filter((item) => item.properties.boundary_mode === "connector-convex-fallback")) assert.ok(geometry.diagnostics.some((item) => item.subjectId === feature.properties.native_id && item.rule === "junction-connector-envelope-fallback")); +// Complex-junction candidate detection: two junction nodes joined by a short +// link are one physical intersection; the same pair pushed far apart is a +// corridor and must not be reported. Detection is advisory only, so the +// geometry must be identical either way. +const candidateOsm = (lonB) => ``; +const nearGeometry = compileGeometry(compileRoadModel(candidateOsm("114.0002"), empty)); +const nearCandidates = nearGeometry.diagnostics.filter((item) => item.rule === "complex-junction-candidate"); +assert.equal(nearCandidates.length, 1); +assert.deepEqual(nearCandidates[0].suggestedCluster.nodeIds, ["2", "3"]); +assert.equal(nearCandidates[0].suggestedCluster.template, "complex-junction-v1"); +assert.equal(nearCandidates[0].severity, "info"); +assert.ok(nearCandidates[0].suggestedCluster.diameterMeters > 0 && nearCandidates[0].suggestedCluster.coreRadiusMeters >= 12); +// Same topology, nodes 2 and 3 now ~965 m apart: no cluster, and no template. +const farGeometry = compileGeometry(compileRoadModel(candidateOsm("114.01"), empty)); +assert.equal(farGeometry.diagnostics.filter((item) => item.rule === "complex-junction-candidate").length, 0); +assert.ok(nearGeometry.intersectionSurface.features.every((feature) => feature.properties.template === null)); +assert.ok(nearGeometry.roadSurface.features.every((feature) => !feature.properties.cluster_id)); + const controlOsm = ``; const controlGeometry = compileGeometry(compileRoadModel(controlOsm, empty)); assert.equal(controlGeometry.crosswalks.features.length, 6); diff --git a/scripts/workbench/app.js b/scripts/workbench/app.js index 0d052d0..c32c3a2 100644 --- a/scripts/workbench/app.js +++ b/scripts/workbench/app.js @@ -10,6 +10,8 @@ import Style from "/vendor/ol/style/Style.js"; import Fill from "/vendor/ol/style/Fill.js"; import Stroke from "/vendor/ol/style/Stroke.js"; import CircleStyle from "/vendor/ol/style/Circle.js"; +import Text from "/vendor/ol/style/Text.js"; +import Polygon from "/vendor/ol/geom/Polygon.js"; import RegularShape from "/vendor/ol/style/RegularShape.js"; import Select from "/vendor/ol/interaction/Select.js"; import { click } from "/vendor/ol/events/condition.js"; @@ -61,6 +63,14 @@ controlsToggle.innerHTML = ' new Style({ image: new RegularShape({ points: 3, radius: 9, rotation: feature.get("rotation"), fill: new Fill({ color: "#006e91" }), stroke: new Stroke({ color: "#fff", width: 1.5 }) }) }), zIndex: 11 }), connectors: new VectorLayer({ source: source(), style: (feature) => effectiveConnectorEnabled(feature.getProperties()) ? new Style({ stroke: new Stroke({ color: roadIdFromLane(feature.get("from_lane_id")) === selectedRoad?.id ? "#d1226f" : "#ad3a76", width: roadIdFromLane(feature.get("from_lane_id")) === selectedRoad?.id ? 4 : 2, lineDash: [7, 5] }) }) : null }), + junctionCandidates: new VectorLayer({ source: source(), visible: true, zIndex: 25, style: (feature) => [ + new Style({ fill: new Fill({ color: "rgba(219, 39, 119, .12)" }), stroke: new Stroke({ color: "#db2777", width: 2, lineDash: [8, 5] }) }), + new Style({ text: new Text({ text: `#${feature.get("index")} ${feature.get("nodeCount")}节点`, font: "bold 13px system-ui, sans-serif", fill: new Fill({ color: "#831843" }), stroke: new Stroke({ color: "#fff", width: 3 }), offsetY: -14 }) }), + ] }), diagnostics: new VectorLayer({ source: source(), style: (feature) => new Style({ image: new CircleStyle({ radius: 6, fill: new Fill({ color: feature.get("severity") === "error" ? "#bf3b2e" : "#d49318" }), stroke: new Stroke({ color: "#fff", width: 1 }) }) }) }), selectedRoad: new VectorLayer({ source: source(), style: new Style({ stroke: new Stroke({ color: "#00a5cf", width: 8 }) }), zIndex: 10 }), selectedMovement: new VectorLayer({ source: source(), style: new Style({ stroke: new Stroke({ color: "#f0b323", width: 6 }) }), zIndex: 12 }), }; -const map = new Map({ target: "map", layers: [layers.gaodeReference, layers.reference, layers.native, layers.edgeLines, layers.sidewalks, layers.osm, layers.lanes, layers.directionArrows, layers.markings, layers.centerLines, layers.controls, layers.signals, layers.connectors, layers.diagnostics, layers.selectedRoad, layers.osmDirection, layers.selectedMovement], view: new View({ center: [0, 0], zoom: 2 }) }); -const select = new Select({ condition: click, layers: (layer) => manualFromEndpoint ? layer === layers.osm : [layers.osm, layers.lanes, layers.directionArrows, layers.markings, layers.centerLines, layers.edgeLines, layers.controls, layers.signals, layers.connectors, layers.native, layers.diagnostics].includes(layer), hitTolerance: 12, style: null }); +const map = new Map({ target: "map", layers: [layers.gaodeReference, layers.reference, layers.native, layers.edgeLines, layers.sidewalks, layers.osm, layers.lanes, layers.directionArrows, layers.markings, layers.centerLines, layers.controls, layers.signals, layers.connectors, layers.junctionCandidates, layers.diagnostics, layers.selectedRoad, layers.osmDirection, layers.selectedMovement], view: new View({ center: [0, 0], zoom: 2 }) }); +const select = new Select({ condition: click, layers: (layer) => manualFromEndpoint ? layer === layers.osm : [layers.osm, layers.lanes, layers.directionArrows, layers.markings, layers.centerLines, layers.edgeLines, layers.controls, layers.signals, layers.connectors, layers.native, layers.junctionCandidates, layers.diagnostics].includes(layer), hitTolerance: 12, style: null }); map.addInteraction(select); select.on("select", ({ selected }) => { const feature = selected[0]; if (!feature) return; + if (feature.get("candidate_index")) return selectJunctionCandidate(feature); if (feature.get("signal_uid")) return selectSignal(poleFeatureForSignal(feature.get("signal_uid")) || feature); if (manualFromEndpoint) return chooseManualTarget(roadForFeature(feature)); const junction = junctionForFeature(feature); @@ -204,15 +219,108 @@ function updateSources() { layers.controls.getSource().clear(); layers.controls.getSource().addFeatures([...readFeatures(state.layers.crosswalks), ...readFeatures(state.layers.vehicleStopLines)]); const signalFeatures = readFeatures(state.trafficSignals?.assemblies || { type: "FeatureCollection", features: [] }); const armFeatures = []; const faceFeatures = []; const headFeatures = []; for (const signal of state.trafficRuntime?.signals || []) { const arm = signal.pose?.arm; const head = signal.pose?.head; if (!arm || !head) continue; const properties = { signal_uid: signal.id }; const headPoint = fromLonLat([head.longitude, head.latitude]); const radians = Number(head.faceHeadingDegrees) * Math.PI / 180; const faceEnd = [headPoint[0] + Math.sin(radians) * 2.5, headPoint[1] + Math.cos(radians) * 2.5]; armFeatures.push(new Feature({ geometry: new LineString([fromLonLat([arm.from.longitude, arm.from.latitude]), fromLonLat([arm.to.longitude, arm.to.latitude])]), signal_component: "mast", ...properties })); faceFeatures.push(new Feature({ geometry: new LineString([headPoint, faceEnd]), signal_component: "face", ...properties })); headFeatures.push(new Feature({ geometry: new Point(faceEnd), signal_component: "head", face_heading_deg: head.faceHeadingDegrees, ...properties })); } layers.signals.getSource().clear(); layers.signals.getSource().addFeatures([...armFeatures, ...faceFeatures, ...signalFeatures, ...headFeatures]); const pickerValue = signalPicker.value; signalPicker.replaceChildren(new Option("选择设施", "")); signalFeatures.forEach((feature) => signalPicker.add(new Option(feature.get("display_id") || feature.get("signal_uid"), feature.get("signal_uid")))); signalPicker.value = pickerValue; layers.connectors.getSource().clear(); layers.connectors.getSource().addFeatures(readFeatures(state.layers.connectors, (feature) => !feature.properties?.cluster_internal)); + candidatesToggle.hidden = !(state.debug?.junctionCandidates || []).length; + renderJunctionCandidates(); layers.diagnostics.getSource().clear(); layers.diagnostics.getSource().addFeatures(readFeatures({ type: "FeatureCollection", features: state.compiled.diagnostics.filter((item) => item.geometry).map(({ geometry, ...properties }) => ({ type: "Feature", properties, geometry })) })); const extent = layers.osm.getSource().getExtent(); if (Number.isFinite(extent[0])) map.getView().fit(extent, { padding: [48, 48, 48, 48], maxZoom: 19 }); } +// Draw one convex-ish hull per candidate cluster so the whole intersection is +// outlined, not just its centre, and label it with the same index the inspector +// and the console listing use. +function renderJunctionCandidates() { + const source = layers.junctionCandidates.getSource(); + source.clear(); + const list = state.debug?.junctionCandidates || []; + if (!list.length) return; + for (const candidate of list) { + const nodes = candidate.nodeIds.map((nodeId) => nodeCoordinate(nodeId)).filter(Boolean); + if (!nodes.length) continue; + const ring = candidateRing(nodes, Math.max(12, candidate.coreRadiusMeters * .6)); + source.addFeature(new Feature({ geometry: new Polygon([ring]), candidate_index: candidate.index, index: candidate.index, nodeCount: candidate.nodeCount, candidate_id: candidate.id })); + } +} +function nodeCoordinate(nodeId) { + for (const road of state.compiled.model.roads) { + const at = road.sourceNodeIds.findIndex((item) => String(item) === String(nodeId)); + if (at === 0) return road.centerline[0]; + if (at === road.sourceNodeIds.length - 1) return road.centerline.at(-1); + } + return null; +} +// A rounded envelope around the member nodes: sample a circle of `padMeters` +// around each node and take the outer boundary by angle from the centroid. +function candidateRing(nodes, padMeters) { + const centre = nodes.reduce((sum, point) => [sum[0] + point[0] / nodes.length, sum[1] + point[1] / nodes.length], [0, 0]); + const metresPerLon = 111320 * Math.cos(centre[1] * Math.PI / 180); + const points = []; + for (let degrees = 0; degrees < 360; degrees += 12) { + const radians = degrees * Math.PI / 180; + let best = null; + for (const node of nodes) { + const point = [node[0] + Math.sin(radians) * padMeters / metresPerLon, node[1] + Math.cos(radians) * padMeters / 111320]; + const reach = (point[0] - centre[0]) * metresPerLon * Math.sin(radians) + (point[1] - centre[1]) * 111320 * Math.cos(radians); + if (!best || reach > best.reach) best = { point, reach }; + } + points.push(fromLonLat(best.point)); + } + return [...points, points[0]]; +} +function selectJunctionCandidate(feature) { + const candidate = (state.debug?.junctionCandidates || []).find((item) => item.index === feature.get("candidate_index")); + if (!candidate) return; + selectedRoad = null; selectedMovement = null; selectedJunction = null; + form.hidden = true; hint.hidden = false; selectedJunctionPanel.hidden = true; + hint.textContent = `复杂路口候选 #${candidate.index}:${candidate.nodeCount} 个节点,直径 ${candidate.diameterMeters} 米。下方是可直接粘贴到 config 的片段。`; + evidence.textContent = JSON.stringify({ + 说明: "复制到 config/areas/<区域>.json 的 nativeRoad.junctionTemplates.clusters 数组,然后重新编译", + 片段: { + id: `cluster-${candidate.nodeIds[0]}`, + template: candidate.template, + nodeIds: candidate.nodeIds, + coreRadiusMeters: candidate.coreRadiusMeters, + cornerRadiusMeters: 12, + outerRadiusExtraMeters: 18, + }, + 实测: { 节点数: candidate.nodeCount, 直径米: candidate.diameterMeters, 最长内部连接米: candidate.longestLinkMeters, 最宽进口米: candidate.widestApproachMeters }, + 提示: "coreRadiusMeters 是按节点跨度估的起点,配好后按实际效果调整;有高德参考几何时它会被校准值覆盖。", + }, null, 2); + renderCandidateAction(candidate); + message(`已选中复杂路口候选 #${candidate.index}`); +} +// The accept button lives beside the snippet so the manual path stays available +// if the write is refused; both describe the same cluster. +function renderCandidateAction(candidate) { + candidateAction.replaceChildren(); + const button = document.createElement("button"); + button.type = "button"; + button.textContent = `把候选 #${candidate.index} 加入配置并重新编译`; + button.onclick = async () => { + button.disabled = true; + message(`正在把候选 #${candidate.index} 写入区域配置...`); + try { + const response = await fetch("/api/junction-clusters", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ index: candidate.index }), cache: "no-store" }); + const result = await response.json(); + if (!response.ok || result.ok === false) throw new Error(result.error || `HTTP ${response.status}`); + state = result; + staged = []; + updateDirtyState(); updateSources(); renderDiagnostics(); renderSummary(); + candidateAction.replaceChildren(); + hint.textContent = `已加入复杂路口 ${result.added.id}(${result.added.nodeIds.length} 个节点),配置已更新并重新编译。`; + message(`已加入 ${result.added.id};如需撤销可 git checkout 区域配置`); + } catch (error) { + button.disabled = false; + message(`加入失败:${error.message}`); + } + }; + candidateAction.append(button); +} function laneId(connection, side) { return connection[side === "from" ? "fromLaneId" : "toLaneId"] || connection[side === "from" ? "from_lane_id" : "to_lane_id"]; } function effectiveLaneEnabled(connector) { const id = `车道连接:${laneId(connector, "from")}->${laneId(connector, "to")}`; const override = [...staged, ...state.overrides.overrides].find((item) => item.id === id); return override ? override.enabled : connector.enabled !== false; } function effectiveConnectionEnabled(connection) { const id = `连接:${connection.id}`; const override = [...staged, ...state.overrides.overrides].find((item) => item.id === id); return override ? override.enabled : connection.enabled; } function effectiveConnectorEnabled(connector) { const connection = state?.compiled.model.connections.find((item) => item.id === connector.connection_id); return effectiveLaneEnabled(connector) && (!connection || effectiveConnectionEnabled(connection)); } function selectRoad(road, note, movement = null) { + candidateAction.replaceChildren(); selectedRoad = road; selectedMovement = movement; selectedJunction = null; selectedJunctionPanel.hidden = true; layers.osm.changed(); layers.lanes.changed(); layers.connectors.changed(); refreshOsmDirection(); refreshSelectedMovement(); layers.selectedRoad.getSource().clear(); if (road) layers.selectedRoad.getSource().addFeature(new Feature({ geometry: new LineString(road.centerline).transform("EPSG:4326", "EPSG:3857") })); form.hidden = !road; hint.hidden = Boolean(road); if (!road) return; @@ -223,6 +331,7 @@ function selectRoad(road, note, movement = null) { renderDirectionSwitch(road); renderMovementSummary(road); renderSelectedMovement(); renderConnections(road); message(note || (selectedMovement ? `已选中行驶动作:${turnLabel(selectedMovement.turn)}` : `已选中:${roadLabel(road)}`)); } function selectJunction(feature) { + candidateAction.replaceChildren(); selectedJunction = feature; selectedRoad = null; selectedMovement = null; form.hidden = true; hint.hidden = true; selectedJunctionPanel.hidden = false; const properties = feature.getProperties(); const roadIds = String(properties.source_road_ids || "").split(",").filter(Boolean); const roads = roadIds.map((id) => state.compiled.model.roads.find((road) => road.id === id)).filter(Boolean);