diff --git a/.trellis/spec/pipeline/cli-and-stages.md b/.trellis/spec/pipeline/cli-and-stages.md
index 85e7218..b50659c 100644
--- a/.trellis/spec/pipeline/cli-and-stages.md
+++ b/.trellis/spec/pipeline/cli-and-stages.md
@@ -414,6 +414,49 @@ GeoJSON,native Blender 构建通过 `catalog.NATIVE_ROAD_LAYERS` 消费它们
正确:道路方向箭头进入 `direction_arrows.geojson`;只有 OSM 明确标注的动作进入
`turn_arrows.geojson`。工作台用两个开关呈现,Blender 复用同一现有箭头材质。
+## Native 普通路口圆角
+
+### 1. 范围与触发条件
+
+`compile-native-roads.js` 为普通 T / 十字路口生成 `intersection_surface.geojson`
+和 `sidewalk_surface.geojson` 的路口边界。路口道路面必须在同一 cutback 处结束,不能
+用未裁剪的道路矩形覆盖圆角边界。
+
+### 2. 几何契约
+
+- 相邻道路边缘使用两条支持切线的交点作为二次曲线控制点;采样段数由
+ `JUNCTION_CURVE_SEGMENTS` 统一控制。
+- 机动车路口边界、人行道内侧路缘和人行道外侧边界都必须使用同一切线圆角规则;外侧
+ 不能只对内侧采样点做线性偏移,避免内外曲率不一致。
+- `boundary_mode` 使用 `rounded-approach-envelope`,无法安全构造的角保持确定性直线
+ 回退,并写入 `junction-rounded-corner-fallback` warning。
+- 已发布的 connector 必须包含在最终边界内,边界退化或 connector 越界时才允许使用
+ `connector-convex-fallback`。
+
+### 3. 校验与错误矩阵
+
+| 条件 | 结果 |
+|---|---|
+| 支持切线交点有限且曲线不过远 | 生成采样圆角 |
+| 切线近似平行或交点退化 | 保留该角直线并记录 `junction-rounded-corner-fallback` |
+| 边界自相交或 connector 越界 | 使用 connector 凸包兜底;仍自相交则不发布路口面 |
+
+### 4. 必需测试
+
+- `npm run test:native-road`:普通 T / 十字路口的圆角顶点数、内收方向、内外人行道
+ 曲线和 continuation 语义。
+- `npm run test:road-workbench`:工作台仍能加载 native 路口及人行道图层。
+- `npm run road:compile -- --config config/areas/nantaizi-lake-innovation-valley.json`。
+- `npm run road:check -- --config config/areas/nantaizi-lake-innovation-valley.json`。
+
+### 5. 错误与正确写法
+
+错误:先对路缘生成圆角,再把外侧边界按每个采样点线性平移;这会导致内外曲率不同,
+在人行道角落留下不一致的折面。
+
+正确:对内侧和外侧分别用相同的道路边缘支持切线规则生成曲线,仅在外侧切线退化时
+使用确定性的偏移回退。
+
## Native 道路中心虚线
### 1. 范围与触发条件
diff --git a/.trellis/tasks/08-13-native-road-compiler/task.json b/.trellis/tasks/08-13-native-road-compiler/task.json
index 16eed84..dbdcb68 100644
--- a/.trellis/tasks/08-13-native-road-compiler/task.json
+++ b/.trellis/tasks/08-13-native-road-compiler/task.json
@@ -21,7 +21,8 @@
"children": [
"08-14-native-road-lane-markings",
"08-17-native-road-control-markings",
- "08-17-native-road-center-lines"
+ "08-17-native-road-center-lines",
+ "08-17-native-rounded-junctions"
],
"parent": null,
"relatedFiles": [],
diff --git a/.trellis/tasks/08-17-native-rounded-junctions/check.jsonl b/.trellis/tasks/08-17-native-rounded-junctions/check.jsonl
new file mode 100644
index 0000000..d623bb5
--- /dev/null
+++ b/.trellis/tasks/08-17-native-rounded-junctions/check.jsonl
@@ -0,0 +1,2 @@
+{"file":".trellis/spec/pipeline/cli-and-stages.md","reason":"Native geometry correctness and output contracts."}
+{"file":".trellis/spec/blender/testing.md","reason":"Blender validation requirements."}
diff --git a/.trellis/tasks/08-17-native-rounded-junctions/design.md b/.trellis/tasks/08-17-native-rounded-junctions/design.md
new file mode 100644
index 0000000..2497659
--- /dev/null
+++ b/.trellis/tasks/08-17-native-rounded-junctions/design.md
@@ -0,0 +1,18 @@
+# Design
+
+Each approach contributes its two carriageway-edge points at the common
+cutback distance. Points are ordered around the junction node. For each pair
+from adjacent approaches, the compiler samples a deterministic quadratic
+Bezier whose control point follows the pedestrian-side curb arc toward the
+junction. Rounded plans use a larger cutback than the legacy straight envelope
+so this visible curb shape still contains all turning connectors. Approach road
+surfaces terminate at the same cutback, so they cannot cover the junction
+outline in 3D output.
+
+The curve is accepted only when the support intersection is finite, the pair
+belongs to different approaches, and the resulting ring remains valid and
+contains all published connector coordinates. Otherwise the original straight
+chord remains for that corner and the plan reports a mixed/fallback boundary.
+
+Lane connectors remain a separate vehicle-path layer. This task changes only
+the road/intersection outline and sidewalk-corner shape.
diff --git a/.trellis/tasks/08-17-native-rounded-junctions/implement.jsonl b/.trellis/tasks/08-17-native-rounded-junctions/implement.jsonl
new file mode 100644
index 0000000..18eea83
--- /dev/null
+++ b/.trellis/tasks/08-17-native-rounded-junctions/implement.jsonl
@@ -0,0 +1,2 @@
+{"file":".trellis/spec/pipeline/cli-and-stages.md","reason":"Native geometry and build-stage contracts."}
+{"file":".trellis/spec/guides/artifact-parity-guide.md","reason":"Intentional geometry output change validation."}
diff --git a/.trellis/tasks/08-17-native-rounded-junctions/implement.md b/.trellis/tasks/08-17-native-rounded-junctions/implement.md
new file mode 100644
index 0000000..6f6ef15
--- /dev/null
+++ b/.trellis/tasks/08-17-native-rounded-junctions/implement.md
@@ -0,0 +1,7 @@
+# Implementation
+
+1. Build a rounded junction boundary from ordered approach-edge records with
+ tangent support-line intersections and deterministic curve samples.
+2. Expose boundary mode/provenance and preserve containment fallback.
+3. Extend focused native-road tests for curved ordinary intersections.
+4. Validate Nantaizi compile/check, workbench tests, and native 3D build.
diff --git a/.trellis/tasks/08-17-native-rounded-junctions/prd.md b/.trellis/tasks/08-17-native-rounded-junctions/prd.md
new file mode 100644
index 0000000..26f61b5
--- /dev/null
+++ b/.trellis/tasks/08-17-native-rounded-junctions/prd.md
@@ -0,0 +1,31 @@
+# Rounded native road junctions
+
+## Goal
+
+Replace the octagonal native junction outline with smooth, tangentially joined
+road-edge corners for ordinary Nantaizi T and cross junctions.
+
+## Requirements
+
+- Junction surface boundaries must connect adjacent approach carriageway edges
+ with a smooth outward curve rather than a straight octagonal chord.
+- Sidewalk corner surfaces must use the same rounded boundary concept so road
+ and pedestrian geometry do not disagree visually.
+- Preserve a deterministic straight-edge fallback and an explicit diagnostic
+ when a corner cannot be safely constructed.
+- Do not change lane connector semantics or derive geometry from osm2streets.
+
+## Acceptance Criteria
+
+- [ ] Ordinary cross/T fixtures generate rounded junction polygons with more
+ than the prior eight straight boundary vertices and `boundary_mode` records
+ the chosen style.
+- [ ] Connector containment remains valid and degenerate geometry falls back
+ without publishing self-intersecting polygons.
+- [ ] Nantaizi compile/check and native Blender/Cesium/preview succeed.
+
+## Notes
+
+- Keep `prd.md` focused on requirements, constraints, and acceptance criteria.
+- Lightweight tasks can remain PRD-only.
+- For complex tasks, add `design.md` for technical design and `implement.md` for execution planning before `task.py start`.
diff --git a/.trellis/tasks/08-17-native-rounded-junctions/task.json b/.trellis/tasks/08-17-native-rounded-junctions/task.json
new file mode 100644
index 0000000..136b365
--- /dev/null
+++ b/.trellis/tasks/08-17-native-rounded-junctions/task.json
@@ -0,0 +1,26 @@
+{
+ "id": "native-rounded-junctions",
+ "name": "native-rounded-junctions",
+ "title": "Rounded native road junctions",
+ "description": "",
+ "status": "in_progress",
+ "dev_type": null,
+ "scope": null,
+ "package": null,
+ "priority": "P2",
+ "creator": "dingkang",
+ "assignee": "dingkang",
+ "createdAt": "2026-08-17",
+ "completedAt": null,
+ "branch": null,
+ "base_branch": "feature/native-road-compiler",
+ "worktree_path": null,
+ "commit": null,
+ "pr_url": null,
+ "subtasks": [],
+ "children": [],
+ "parent": "08-13-native-road-compiler",
+ "relatedFiles": [],
+ "notes": "",
+ "meta": {}
+}
\ No newline at end of file
diff --git a/scripts/lib/native-road.js b/scripts/lib/native-road.js
index 2409d5f..4202c96 100644
--- a/scripts/lib/native-road.js
+++ b/scripts/lib/native-road.js
@@ -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) {
diff --git a/scripts/test-native-road.js b/scripts/test-native-road.js
index 539602b..3120575 100644
--- a/scripts/test-native-road.js
+++ b/scripts/test-native-road.js
@@ -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 = ``;
@@ -96,13 +96,43 @@ const crossOsm = ` 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 = ``;
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);