feat: smooth native junction boundaries

This commit is contained in:
2026-08-17 17:39:07 +08:00
parent 46542c5f4e
commit e41bfd13ec
10 changed files with 300 additions and 22 deletions

View File

@@ -19,6 +19,8 @@ const CENTER_LINE_SOLID_OVERLAP_METERS = .04;
const CENTER_LINE_CONTROL_CLEARANCE_METERS = 1;
const CENTER_LINE_COLORS = new Set(["yellow", "white"]);
const CENTER_LINE_PATTERNS = new Set(["dashed", "solid"]);
const CONNECTOR_BOUNDARY_TOLERANCE_METERS = .05;
const JUNCTION_CURVE_SEGMENTS = 8;
function parseOsmRoads(xml) {
const nodes = new Map();
@@ -249,10 +251,10 @@ function compileGeometry(model, overrides = { overrides: [] }) {
emittedSegments.add(segmentKey);
const directions = model.roads.filter((item) => item.segmentId === segmentKey);
const totalWidth = directions.reduce((sum, item) => sum + item.widthMeters, 0);
// Road and junction asphalt share one final material. Keep the carriageway
// continuous through the semantic junction overlay; cutting it back creates
// visible wedges/gaps without improving the rendered result.
const ring = roadRing(road.centerline, totalWidth);
// The approach surface stops at the junction cutback. The junction layer
// owns the intervening rounded corners; leaving approaches untrimmed
// would cover that outline with rectangular road ends in Blender/Cesium.
const ring = roadRing(trimLineAtJunctions(road.centerline, road.sourceNodeIds, junctionPlans), 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; }
const surfaceId = road.segmentId.endsWith("/0") ? `surface:way/${road.osmWayIds.join(",")}` : `surface:${segmentKey}`;
features.push({ type: "Feature", properties: { native_id: surfaceId, directional_road_ids: directions.map((item) => item.id).join(","), osm_way_ids: road.osmWayIds.join(","), source_road_id: road.sourceRoadId, width_m: totalWidth, lane_count: directions.reduce((sum, item) => item.laneCount + sum, 0), provenance: JSON.stringify(directions.map((item) => item.provenance)), override_ids: directions.flatMap((item) => item.appliedOverrideIds).join(",") }, geometry: { type: "Polygon", coordinates: [ring] } });
@@ -480,6 +482,7 @@ function compileSidewalkCorners(model, junctionPlans) {
wayKey: approach.segmentId,
sourceWayKey: forward.osmWayIds.join(","),
side,
outwardHeading: heading,
normalDegrees: sideHeading,
curb: offsetCoordinate(cutback, sideHeading, halfWidth),
outer: offsetCoordinate(cutback, sideHeading, halfWidth + DEFAULT_SIDEWALK_WIDTH_METERS),
@@ -491,17 +494,24 @@ function compileSidewalkCorners(model, junctionPlans) {
const first = candidates[index];
const second = candidates[(index + 1) % candidates.length];
if (first.wayKey === second.wayKey) continue;
const ring = [first.curb, first.outer, second.outer, second.curb, first.curb];
const continuation = isStraightSidewalkContinuation(first, second);
if (first.sourceWayKey === second.sourceWayKey && !continuation) continue;
// A split-through road has two approaches at this node. Its pedestrian
// strip is a direct continuation, not a curb corner. Treating it as a
// curve creates the oversized outer lobe seen at T junctions.
const ring = continuation
? [first.curb, first.outer, second.outer, second.curb, first.curb]
: roundedSidewalkCorner(plan.node, first, second);
if (hasSelfIntersection(ring)) continue;
if (first.sourceWayKey === second.sourceWayKey && (!samePhysicalSide(first, second) || cornerFallsIntoOtherApproach(ring, first.sourceWayKey, plan.approaches))) continue;
if (continuation && cornerFallsIntoOtherApproach(ring, first.sourceWayKey, plan.approaches)) continue;
result.push({
type: "Feature",
properties: {
native_id: `sidewalk-corner:node/${nodeId}:${first.wayKey}:${first.side}->${second.wayKey}:${second.side}`,
osm_node_id: nodeId,
kind: "corner",
kind: continuation ? "continuation" : "corner",
width_m: DEFAULT_SIDEWALK_WIDTH_METERS,
provenance: "native-road-sidewalk-corner/v1",
provenance: continuation ? "native-road-sidewalk-continuation/v1" : "native-road-sidewalk-corner/v1",
},
geometry: { type: "Polygon", coordinates: [ring] },
});
@@ -510,13 +520,51 @@ function compileSidewalkCorners(model, junctionPlans) {
return result;
}
function roundedSidewalkCorner(node, first, second) {
// Keep the established vehicle curb geometry, then derive the outer edge
// from it. Independent Bezier curves drift apart and leave asphalt exposed
// between the junction and pedestrian layers.
const curbForward = roundedCorner(node, first.curb, second.curb, first.outwardHeading, second.outwardHeading) || [first.curb, second.curb];
// Construct the outside edge from the same tangent-support rule. A linear
// point-by-point offset changes the curvature and makes the two boundaries
// visibly disagree at the middle of the corner.
const outerForward = roundedCorner(node, first.outer, second.outer, first.outwardHeading, second.outwardHeading)
|| offsetCornerArc(curbForward, first.curb, first.outer, second.curb, second.outer);
const curbArc = [...curbForward].reverse();
return [
first.curb,
first.outer,
...outerForward.slice(1, -1),
second.outer,
second.curb,
...curbArc.slice(1, -1),
first.curb,
];
}
function offsetCornerArc(curbArc, firstCurb, firstOuter, secondCurb, secondOuter) {
return curbArc.map((point, index) => {
const ratio = curbArc.length === 1 ? 0 : index / (curbArc.length - 1);
const firstOffset = [firstOuter[0] - firstCurb[0], firstOuter[1] - firstCurb[1]];
const secondOffset = [secondOuter[0] - secondCurb[0], secondOuter[1] - secondCurb[1]];
return [point[0] + firstOffset[0] + (secondOffset[0] - firstOffset[0]) * ratio, point[1] + firstOffset[1] + (secondOffset[1] - firstOffset[1]) * ratio];
});
}
function samePhysicalSide(first, second) {
const radians = (first.normalDegrees - second.normalDegrees) * Math.PI / 180;
return Math.cos(radians) >= 0.98;
}
function isStraightSidewalkContinuation(first, second) {
if (first.sourceWayKey !== second.sourceWayKey || !samePhysicalSide(first, second)) return false;
const radians = (first.outwardHeading - second.outwardHeading) * Math.PI / 180;
return Math.cos(radians) <= -0.98;
}
function cornerFallsIntoOtherApproach(ring, sourceWayKey, approaches) {
const center = ring.slice(0, -1).reduce((sum, point) => [sum[0] + point[0] / 4, sum[1] + point[1] / 4], [0, 0]);
const vertices = ring.slice(0, -1);
const center = vertices.reduce((sum, point) => [sum[0] + point[0] / vertices.length, sum[1] + point[1] / vertices.length], [0, 0]);
return approaches.filter((approach) => approach.sourceWayKey !== sourceWayKey).some((approach) => {
const carriageway = roadRing(approach.line, approach.widthMeters);
return carriageway && pointInPolygon(center, carriageway);
@@ -529,7 +577,7 @@ function validateConnectorContainment(connectors, junctionFeatures, diagnostics)
const junction = junctionByNode.get(connector.properties.node_id);
if (!junction) continue;
const ring = junction.geometry.coordinates[0];
if (!connector.geometry.coordinates.every((point) => pointInPolygon(point, ring))) {
if (!connector.geometry.coordinates.every((point) => pointInOrNearPolygon(point, ring, CONNECTOR_BOUNDARY_TOLERANCE_METERS))) {
diagnostics.push(diagnostic("warning", connector.properties.connection_id, [connector.properties.node_id], "connector-outside-junction", "转向路径有部分落在路口面外,请检查道路截面或转向连接。", connector.geometry.coordinates[0]));
}
}
@@ -545,6 +593,17 @@ function pointInPolygon(point, ring) {
}
return inside;
}
function pointInOrNearPolygon(point, ring, toleranceMeters) {
return pointInPolygon(point, ring) || ring.slice(1).some((end, index) => distancePointToSegmentMeters(point, ring[index], end) <= toleranceMeters);
}
function distancePointToSegmentMeters(point, start, end) {
const localPoint = project(point, start);
const localEnd = project(end, start);
const lengthSquared = localEnd[0] ** 2 + localEnd[1] ** 2;
if (lengthSquared < .0001) return Math.hypot(...localPoint);
const ratio = Math.max(0, Math.min(1, (localPoint[0] * localEnd[0] + localPoint[1] * localEnd[1]) / lengthSquared));
return Math.hypot(localPoint[0] - localEnd[0] * ratio, localPoint[1] - localEnd[1] * ratio);
}
function pointOnSegment(point, a, b) {
const cross = (point[0] - a[0]) * (b[1] - a[1]) - (point[1] - a[1]) * (b[0] - a[0]);
if (Math.abs(cross) > 1e-12) return false;
@@ -690,8 +749,8 @@ function compileJunctionSurfaces(model, junctionPlans, connectors, movements, di
}
const approachAreaMeters = polygonAreaMeters(boundary);
let ring = [...boundary, boundary[0]];
let boundaryMode = "approach-envelope";
if (hasSelfIntersection(ring) || !junctionConnectors.every((feature) => feature.geometry.coordinates.every((point) => pointInPolygon(point, ring)))) {
let boundaryMode = plan.boundaryMode || "approach-envelope";
if (hasSelfIntersection(ring) || !junctionConnectors.every((feature) => feature.geometry.coordinates.every((point) => pointInOrNearPolygon(point, ring, CONNECTOR_BOUNDARY_TOLERANCE_METERS)))) {
const envelope = convexHull([...boundary, ...junctionConnectors.flatMap((feature) => feature.geometry.coordinates)]);
ring = [...envelope, envelope[0]];
boundaryMode = "connector-convex-fallback";
@@ -704,6 +763,7 @@ function compileJunctionSurfaces(model, junctionPlans, connectors, movements, di
const expansionRatio = approachAreaMeters > 0 ? surfaceAreaMeters / approachAreaMeters : null;
result.push({ type: "Feature", properties: { native_id: `junction:node/${nodeId}`, osm_node_id: nodeId, kind: segmentIds.size === 3 ? "t" : "cross", source_road_ids: approaches.flatMap((approach) => approach.roadIds).join(","), cutback_m: cutbackMeters, movement_count: junctionMovements.length, connector_count: junctionConnectors.length, boundary_mode: boundaryMode, approach_area_m2: Math.round(approachAreaMeters * 10) / 10, surface_area_m2: Math.round(surfaceAreaMeters * 10) / 10, expansion_ratio: expansionRatio === null ? null : Math.round(expansionRatio * 100) / 100, rule: "junction-shared-cutback/v4-shared-node-split" }, geometry: { type: "Polygon", coordinates: [ring] } });
if (boundaryMode === "connector-convex-fallback") diagnostics.push(diagnostic("warning", `junction:node/${nodeId}`, [nodeId], "junction-connector-envelope-fallback", "路口面需要按转向路径的凸包兜底生成;请检查外缘和路缘与步行带是否符合实际。", node));
if (plan.boundaryFallbacks) diagnostics.push(diagnostic("warning", `junction:node/${nodeId}`, [nodeId], "junction-rounded-corner-fallback", "部分路口圆角无法按道路边缘切线安全构造,已对该角使用确定性的直线回退。", node));
diagnostics.push(diagnostic("info", `junction:node/${nodeId}`, [nodeId], "ordinary-junction-surface", "已按道路截面与转向路径生成普通路口面。", node));
}
return result;
@@ -721,11 +781,13 @@ function compileJunctionPlans(model) {
if (segmentIds.size < 3 || segmentIds.size > 4) continue;
const approaches = junctionApproaches(model, endpoints);
if (approaches.length !== segmentIds.size) continue;
// Rounded curb corners need enough approach length to retain the full
// turning envelope after the corner is cut toward the junction.
const cutbackMeters = Math.max(...approaches.map((approach) => approach.widthMeters)) * 1.4;
const node = endpoints[0].coordinate;
const boundary = junctionBoundary(approaches, node, cutbackMeters);
if (boundary.length < 3) continue;
plans.set(nodeId, { node, segmentIds, approaches, cutbackMeters, boundary });
if (boundary.points.length < 3) continue;
plans.set(nodeId, { node, segmentIds, approaches, cutbackMeters, boundary: boundary.points, boundaryMode: boundary.mode, boundaryFallbacks: boundary.fallbacks });
}
return plans;
}
@@ -752,10 +814,66 @@ function junctionBoundary(approaches, node, cutbackMeters) {
if (!cutback) continue;
const heading = headingAtEndpoint(approach.line);
const half = approach.widthMeters / 2;
points.push(offsetCoordinate(cutback, heading + 90, half));
points.push(offsetCoordinate(cutback, heading - 90, half));
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 });
}
return sortAround(node, points);
const ordered = points.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;
let fallbacks = 0;
for (let index = 0; index < ordered.length; index += 1) {
const first = ordered[index]; const second = ordered[(index + 1) % ordered.length];
boundary.push(first.point);
// One physical OSM way is often split at an intersection node. Its two
// opposite approaches share a continuous road edge; rounding that edge
// bends the far side of a T junction and exposes junction asphalt beyond
// the pedestrian strip.
if (first.segmentId === second.segmentId || isStraightJunctionEdge(first, second)) continue;
const curve = roundedCorner(node, first.point, second.point, first.outwardHeading, second.outwardHeading);
if (!curve) { fallbacks += 1; continue; }
boundary.push(...curve.slice(1, -1));
rounded += 1;
}
return { points: boundary, mode: rounded ? "rounded-approach-envelope" : "approach-envelope", fallbacks };
}
function isStraightJunctionEdge(first, second) {
if (first.sourceWayKey !== second.sourceWayKey) return false;
const radians = (first.outwardHeading - second.outwardHeading) * Math.PI / 180;
return Math.cos(radians) <= -0.98;
}
function roundedCorner(node, first, second, firstHeading, secondHeading) {
const origin = node;
const a = project(first, origin); const b = project(second, origin);
const chord = Math.hypot(a[0] - b[0], a[1] - b[1]);
if (chord < .5 || !Number.isFinite(firstHeading) || !Number.isFinite(secondHeading)) return null;
const firstDirection = headingVector(firstHeading);
const secondDirection = headingVector(secondHeading);
const intersection = lineIntersection(a, firstDirection, b, secondDirection);
if (!intersection) return null;
const controlDistance = Math.hypot(...intersection);
const endpointDistance = Math.max(Math.hypot(...a), Math.hypot(...b));
// Adjacent approach edge tangents should meet in the corner between the
// node and the cutback. Reject near-parallel or remote intersections rather
// than publishing a huge/self-crossing curve.
if (controlDistance < .01 || controlDistance > endpointDistance * 1.5 || controlDistance > 80) return null;
const control = unproject(intersection, origin);
return quadraticCurve(first, control, second, JUNCTION_CURVE_SEGMENTS);
}
function headingVector(degrees) {
const radians = degrees * Math.PI / 180;
return [Math.sin(radians), Math.cos(radians)];
}
function lineIntersection(firstPoint, firstDirection, secondPoint, secondDirection) {
const cross = firstDirection[0] * secondDirection[1] - firstDirection[1] * secondDirection[0];
if (Math.abs(cross) < 1e-4) return null;
const delta = [secondPoint[0] - firstPoint[0], secondPoint[1] - firstPoint[1]];
const firstDistance = (delta[0] * secondDirection[1] - delta[1] * secondDirection[0]) / cross;
return [firstPoint[0] + firstDirection[0] * firstDistance, firstPoint[1] + firstDirection[1] * firstDistance];
}
function pointAlongLine(line, meters) {

View File

@@ -71,7 +71,7 @@ assert.ok(geometry.movements.length >= geometry.connectors.features.length);
assert.ok(geometry.movements.every((movement) => movement.id.startsWith("movement:") && movement.connectorId.startsWith("connector:")));
assert.ok(geometry.movements.every((movement) => ["connector", "continuous", "deferred-too-long"].includes(movement.geometryStatus)));
assert.ok(geometry.intersectionSurface.features.every((feature) => feature.properties.rule === "junction-shared-cutback/v3"));
assert.ok(geometry.intersectionSurface.features.every((feature) => ["approach-envelope", "connector-convex-fallback"].includes(feature.properties.boundary_mode)));
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"));
const controlOsm = `<osm><node id="1" lon="114" lat="30"/><node id="2" lon="114.00080" lat="30"><tag k="highway" v="crossing"/><tag k="crossing:markings" v="zebra"/></node><node id="3" lon="114.001" lat="30"/><node id="4" lon="114.002" lat="30"><tag k="highway" v="crossing"/><tag k="crossing:markings" v="unmarked"/></node><node id="5" lon="114.0035" lat="30"><tag k="highway" v="crossing"/></node><node id="6" lon="114.004" lat="30"/><node id="7" lon="114.001" lat="30.001"/><way id="60"><nd ref="1"/><nd ref="2"/><nd ref="3"/><nd ref="4"/><tag k="highway" v="residential"/></way><way id="61"><nd ref="5"/><nd ref="6"/><tag k="highway" v="footway"/></way><way id="62"><nd ref="3"/><nd ref="7"/><tag k="highway" v="residential"/></way></osm>`;
@@ -96,13 +96,43 @@ const crossOsm = `<osm><node id="1" lon="114" lat="30"/><node id="2" lon="114.00
const crossCenter = [114.001, 30];
const crossGeometry = compileGeometry(compileRoadModel(crossOsm, empty));
assert.equal(crossGeometry.intersectionSurface.features.length, 1);
assert.equal(crossGeometry.intersectionSurface.features[0].properties.boundary_mode, "rounded-approach-envelope");
assert.ok(crossGeometry.intersectionSurface.features[0].geometry.coordinates[0].length > 9);
const crossBoundary = crossGeometry.intersectionSurface.features[0].geometry.coordinates[0];
const crossRadius = (point) => Math.hypot((point[0] - crossCenter[0]) * 96400, (point[1] - crossCenter[1]) * 111320);
// The sampled tangent arc must cut inward from its old straight chord; an
// outward-bowed control point leaks asphalt into the pedestrian corner.
const firstCurveEnd = crossBoundary[8];
assert.ok(crossRadius(crossBoundary[4]) < crossRadius([(crossBoundary[0][0] + firstCurveEnd[0]) / 2, (crossBoundary[0][1] + firstCurveEnd[1]) / 2]));
assert.equal(crossGeometry.turnArrows.features.length, 0);
assert.ok(crossGeometry.directionArrows.features.length > 0);
assert.ok(crossGeometry.directionArrows.features.every((feature) => feature.properties.maneuver === "through" && feature.properties.provenance === "native-road-direction-arrow/v1"));
assert.ok(crossGeometry.roadSurface.features.some((feature) => Math.min(...feature.geometry.coordinates[0].map((point) => Math.hypot((point[0] - crossCenter[0]) * 96400, (point[1] - crossCenter[1]) * 111320))) < 4));
// Approach asphalt ends at the shared cutback; the rounded junction surface
// exclusively owns the central road area so its boundary remains visible.
assert.ok(crossGeometry.roadSurface.features.every((feature) => Math.min(...feature.geometry.coordinates[0].map((point) => Math.hypot((point[0] - crossCenter[0]) * 96400, (point[1] - crossCenter[1]) * 111320))) > 4));
const exteriorRings = (geometry) => geometry.type === "Polygon" ? [geometry.coordinates[0]] : geometry.coordinates.map((polygon) => polygon[0]);
assert.ok(crossGeometry.sidewalkSurface.features.every((feature) => Math.min(...exteriorRings(feature.geometry).flat().map((point) => Math.hypot((point[0] - crossCenter[0]) * 96400, (point[1] - crossCenter[1]) * 111320))) > 5));
assert.equal(crossGeometry.sidewalkSurface.features.filter((feature) => feature.properties.kind === "corner").length, 4);
const crossSidewalkCorners = crossGeometry.sidewalkSurface.features.filter((feature) => feature.properties.kind === "corner");
assert.equal(crossSidewalkCorners.length, 4);
// A rounded sidewalk corner must sample both the curb and outer boundaries.
// The legacy wedge had five closing-ring points; two curved edges need more.
assert.ok(crossSidewalkCorners.every((feature) => feature.geometry.coordinates[0].length > 9));
assert.ok(crossSidewalkCorners.every((feature) => {
const ring = feature.geometry.coordinates[0];
const outerStart = ring[1];
const outerCurvePoint = ring[2];
const outerEnd = ring[(ring.length - 1) / 2];
const twiceArea = (outerEnd[0] - outerStart[0]) * (outerCurvePoint[1] - outerStart[1]) - (outerEnd[1] - outerStart[1]) * (outerCurvePoint[0] - outerStart[0]);
return Math.abs(twiceArea) > 1e-12;
}));
assert.ok(crossSidewalkCorners.every((feature) => {
const ring = feature.geometry.coordinates[0];
const curbStart = ring[10];
const curbCurvePoint = ring[11];
const curbEnd = ring[0];
const twiceArea = (curbEnd[0] - curbStart[0]) * (curbCurvePoint[1] - curbStart[1]) - (curbEnd[1] - curbStart[1]) * (curbCurvePoint[0] - curbStart[0]);
return Math.abs(twiceArea) > 1e-12;
}));
const sharedInteriorNodeOsm = `<osm><node id="1" lon="114" lat="30"/><node id="2" lon="114.001" lat="30"/><node id="3" lon="114.002" lat="30"/><node id="4" lon="114.001" lat="30.001"/><way id="50"><nd ref="1"/><nd ref="2"/><nd ref="3"/><tag k="highway" v="residential"/><tag k="sidewalk" v="both"/></way><way id="51"><nd ref="4"/><nd ref="2"/><tag k="highway" v="residential"/><tag k="sidewalk" v="both"/></way></osm>`;
const sharedInteriorModel = compileRoadModel(sharedInteriorNodeOsm, empty);
assert.equal(sharedInteriorModel.roads.length, 6);
@@ -113,7 +143,7 @@ assert.equal(sharedInteriorGeometry.intersectionSurface.features.length, 1);
assert.equal(sharedInteriorGeometry.intersectionSurface.features[0].properties.osm_node_id, "2");
assert.equal(sharedInteriorGeometry.intersectionSurface.features[0].properties.kind, "t");
assert.ok(sharedInteriorGeometry.connectors.features.length >= 4);
assert.ok(sharedInteriorGeometry.sidewalkSurface.features.some((feature) => feature.properties.kind === "corner" && /segment:way\/50\/1:.*->segment:way\/50\/2:/.test(feature.properties.native_id)));
assert.ok(sharedInteriorGeometry.sidewalkSurface.features.some((feature) => feature.properties.kind === "continuation" && /segment:way\/50\/1:.*->segment:way\/50\/2:/.test(feature.properties.native_id)));
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);