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

@@ -414,6 +414,49 @@ GeoJSONnative 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. 范围与触发条件

View File

@@ -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": [],

View File

@@ -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."}

View File

@@ -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.

View File

@@ -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."}

View File

@@ -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.

View File

@@ -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`.

View File

@@ -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": {}
}

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);