feat: smooth native junction boundaries
This commit is contained in:
@@ -414,6 +414,49 @@ GeoJSON,native Blender 构建通过 `catalog.NATIVE_ROAD_LAYERS` 消费它们
|
|||||||
正确:道路方向箭头进入 `direction_arrows.geojson`;只有 OSM 明确标注的动作进入
|
正确:道路方向箭头进入 `direction_arrows.geojson`;只有 OSM 明确标注的动作进入
|
||||||
`turn_arrows.geojson`。工作台用两个开关呈现,Blender 复用同一现有箭头材质。
|
`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 道路中心虚线
|
## Native 道路中心虚线
|
||||||
|
|
||||||
### 1. 范围与触发条件
|
### 1. 范围与触发条件
|
||||||
|
|||||||
@@ -21,7 +21,8 @@
|
|||||||
"children": [
|
"children": [
|
||||||
"08-14-native-road-lane-markings",
|
"08-14-native-road-lane-markings",
|
||||||
"08-17-native-road-control-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,
|
"parent": null,
|
||||||
"relatedFiles": [],
|
"relatedFiles": [],
|
||||||
|
|||||||
@@ -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."}
|
||||||
18
.trellis/tasks/08-17-native-rounded-junctions/design.md
Normal file
18
.trellis/tasks/08-17-native-rounded-junctions/design.md
Normal 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.
|
||||||
@@ -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."}
|
||||||
@@ -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.
|
||||||
31
.trellis/tasks/08-17-native-rounded-junctions/prd.md
Normal file
31
.trellis/tasks/08-17-native-rounded-junctions/prd.md
Normal 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`.
|
||||||
26
.trellis/tasks/08-17-native-rounded-junctions/task.json
Normal file
26
.trellis/tasks/08-17-native-rounded-junctions/task.json
Normal 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": {}
|
||||||
|
}
|
||||||
@@ -19,6 +19,8 @@ const CENTER_LINE_SOLID_OVERLAP_METERS = .04;
|
|||||||
const CENTER_LINE_CONTROL_CLEARANCE_METERS = 1;
|
const CENTER_LINE_CONTROL_CLEARANCE_METERS = 1;
|
||||||
const CENTER_LINE_COLORS = new Set(["yellow", "white"]);
|
const CENTER_LINE_COLORS = new Set(["yellow", "white"]);
|
||||||
const CENTER_LINE_PATTERNS = new Set(["dashed", "solid"]);
|
const CENTER_LINE_PATTERNS = new Set(["dashed", "solid"]);
|
||||||
|
const CONNECTOR_BOUNDARY_TOLERANCE_METERS = .05;
|
||||||
|
const JUNCTION_CURVE_SEGMENTS = 8;
|
||||||
|
|
||||||
function parseOsmRoads(xml) {
|
function parseOsmRoads(xml) {
|
||||||
const nodes = new Map();
|
const nodes = new Map();
|
||||||
@@ -249,10 +251,10 @@ function compileGeometry(model, overrides = { overrides: [] }) {
|
|||||||
emittedSegments.add(segmentKey);
|
emittedSegments.add(segmentKey);
|
||||||
const directions = model.roads.filter((item) => item.segmentId === segmentKey);
|
const directions = model.roads.filter((item) => item.segmentId === segmentKey);
|
||||||
const totalWidth = directions.reduce((sum, item) => sum + item.widthMeters, 0);
|
const totalWidth = directions.reduce((sum, item) => sum + item.widthMeters, 0);
|
||||||
// Road and junction asphalt share one final material. Keep the carriageway
|
// The approach surface stops at the junction cutback. The junction layer
|
||||||
// continuous through the semantic junction overlay; cutting it back creates
|
// owns the intervening rounded corners; leaving approaches untrimmed
|
||||||
// visible wedges/gaps without improving the rendered result.
|
// would cover that outline with rectangular road ends in Blender/Cesium.
|
||||||
const ring = roadRing(road.centerline, totalWidth);
|
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; }
|
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}`;
|
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] } });
|
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,
|
wayKey: approach.segmentId,
|
||||||
sourceWayKey: forward.osmWayIds.join(","),
|
sourceWayKey: forward.osmWayIds.join(","),
|
||||||
side,
|
side,
|
||||||
|
outwardHeading: heading,
|
||||||
normalDegrees: sideHeading,
|
normalDegrees: sideHeading,
|
||||||
curb: offsetCoordinate(cutback, sideHeading, halfWidth),
|
curb: offsetCoordinate(cutback, sideHeading, halfWidth),
|
||||||
outer: offsetCoordinate(cutback, sideHeading, halfWidth + DEFAULT_SIDEWALK_WIDTH_METERS),
|
outer: offsetCoordinate(cutback, sideHeading, halfWidth + DEFAULT_SIDEWALK_WIDTH_METERS),
|
||||||
@@ -491,17 +494,24 @@ function compileSidewalkCorners(model, junctionPlans) {
|
|||||||
const first = candidates[index];
|
const first = candidates[index];
|
||||||
const second = candidates[(index + 1) % candidates.length];
|
const second = candidates[(index + 1) % candidates.length];
|
||||||
if (first.wayKey === second.wayKey) continue;
|
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 (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({
|
result.push({
|
||||||
type: "Feature",
|
type: "Feature",
|
||||||
properties: {
|
properties: {
|
||||||
native_id: `sidewalk-corner:node/${nodeId}:${first.wayKey}:${first.side}->${second.wayKey}:${second.side}`,
|
native_id: `sidewalk-corner:node/${nodeId}:${first.wayKey}:${first.side}->${second.wayKey}:${second.side}`,
|
||||||
osm_node_id: nodeId,
|
osm_node_id: nodeId,
|
||||||
kind: "corner",
|
kind: continuation ? "continuation" : "corner",
|
||||||
width_m: DEFAULT_SIDEWALK_WIDTH_METERS,
|
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] },
|
geometry: { type: "Polygon", coordinates: [ring] },
|
||||||
});
|
});
|
||||||
@@ -510,13 +520,51 @@ function compileSidewalkCorners(model, junctionPlans) {
|
|||||||
return result;
|
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) {
|
function samePhysicalSide(first, second) {
|
||||||
const radians = (first.normalDegrees - second.normalDegrees) * Math.PI / 180;
|
const radians = (first.normalDegrees - second.normalDegrees) * Math.PI / 180;
|
||||||
return Math.cos(radians) >= 0.98;
|
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) {
|
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) => {
|
return approaches.filter((approach) => approach.sourceWayKey !== sourceWayKey).some((approach) => {
|
||||||
const carriageway = roadRing(approach.line, approach.widthMeters);
|
const carriageway = roadRing(approach.line, approach.widthMeters);
|
||||||
return carriageway && pointInPolygon(center, carriageway);
|
return carriageway && pointInPolygon(center, carriageway);
|
||||||
@@ -529,7 +577,7 @@ function validateConnectorContainment(connectors, junctionFeatures, diagnostics)
|
|||||||
const junction = junctionByNode.get(connector.properties.node_id);
|
const junction = junctionByNode.get(connector.properties.node_id);
|
||||||
if (!junction) continue;
|
if (!junction) continue;
|
||||||
const ring = junction.geometry.coordinates[0];
|
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]));
|
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;
|
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) {
|
function pointOnSegment(point, a, b) {
|
||||||
const cross = (point[0] - a[0]) * (b[1] - a[1]) - (point[1] - a[1]) * (b[0] - a[0]);
|
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;
|
if (Math.abs(cross) > 1e-12) return false;
|
||||||
@@ -690,8 +749,8 @@ function compileJunctionSurfaces(model, junctionPlans, connectors, movements, di
|
|||||||
}
|
}
|
||||||
const approachAreaMeters = polygonAreaMeters(boundary);
|
const approachAreaMeters = polygonAreaMeters(boundary);
|
||||||
let ring = [...boundary, boundary[0]];
|
let ring = [...boundary, boundary[0]];
|
||||||
let boundaryMode = "approach-envelope";
|
let boundaryMode = plan.boundaryMode || "approach-envelope";
|
||||||
if (hasSelfIntersection(ring) || !junctionConnectors.every((feature) => feature.geometry.coordinates.every((point) => pointInPolygon(point, ring)))) {
|
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)]);
|
const envelope = convexHull([...boundary, ...junctionConnectors.flatMap((feature) => feature.geometry.coordinates)]);
|
||||||
ring = [...envelope, envelope[0]];
|
ring = [...envelope, envelope[0]];
|
||||||
boundaryMode = "connector-convex-fallback";
|
boundaryMode = "connector-convex-fallback";
|
||||||
@@ -704,6 +763,7 @@ function compileJunctionSurfaces(model, junctionPlans, connectors, movements, di
|
|||||||
const expansionRatio = approachAreaMeters > 0 ? surfaceAreaMeters / approachAreaMeters : null;
|
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] } });
|
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 (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));
|
diagnostics.push(diagnostic("info", `junction:node/${nodeId}`, [nodeId], "ordinary-junction-surface", "已按道路截面与转向路径生成普通路口面。", node));
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
@@ -721,11 +781,13 @@ function compileJunctionPlans(model) {
|
|||||||
if (segmentIds.size < 3 || segmentIds.size > 4) continue;
|
if (segmentIds.size < 3 || segmentIds.size > 4) continue;
|
||||||
const approaches = junctionApproaches(model, endpoints);
|
const approaches = junctionApproaches(model, endpoints);
|
||||||
if (approaches.length !== segmentIds.size) continue;
|
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 cutbackMeters = Math.max(...approaches.map((approach) => approach.widthMeters)) * 1.4;
|
||||||
const node = endpoints[0].coordinate;
|
const node = endpoints[0].coordinate;
|
||||||
const boundary = junctionBoundary(approaches, node, cutbackMeters);
|
const boundary = junctionBoundary(approaches, node, cutbackMeters);
|
||||||
if (boundary.length < 3) continue;
|
if (boundary.points.length < 3) continue;
|
||||||
plans.set(nodeId, { node, segmentIds, approaches, cutbackMeters, boundary });
|
plans.set(nodeId, { node, segmentIds, approaches, cutbackMeters, boundary: boundary.points, boundaryMode: boundary.mode, boundaryFallbacks: boundary.fallbacks });
|
||||||
}
|
}
|
||||||
return plans;
|
return plans;
|
||||||
}
|
}
|
||||||
@@ -752,10 +814,66 @@ function junctionBoundary(approaches, node, cutbackMeters) {
|
|||||||
if (!cutback) continue;
|
if (!cutback) continue;
|
||||||
const heading = headingAtEndpoint(approach.line);
|
const heading = headingAtEndpoint(approach.line);
|
||||||
const half = approach.widthMeters / 2;
|
const half = approach.widthMeters / 2;
|
||||||
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(offsetCoordinate(cutback, heading - 90, half));
|
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) {
|
function pointAlongLine(line, meters) {
|
||||||
|
|||||||
@@ -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) => 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.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) => 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));
|
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"));
|
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>`;
|
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 crossCenter = [114.001, 30];
|
||||||
const crossGeometry = compileGeometry(compileRoadModel(crossOsm, empty));
|
const crossGeometry = compileGeometry(compileRoadModel(crossOsm, empty));
|
||||||
assert.equal(crossGeometry.intersectionSurface.features.length, 1);
|
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.equal(crossGeometry.turnArrows.features.length, 0);
|
||||||
assert.ok(crossGeometry.directionArrows.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.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]);
|
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.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 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);
|
const sharedInteriorModel = compileRoadModel(sharedInteriorNodeOsm, empty);
|
||||||
assert.equal(sharedInteriorModel.roads.length, 6);
|
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.osm_node_id, "2");
|
||||||
assert.equal(sharedInteriorGeometry.intersectionSurface.features[0].properties.kind, "t");
|
assert.equal(sharedInteriorGeometry.intersectionSurface.features[0].properties.kind, "t");
|
||||||
assert.ok(sharedInteriorGeometry.connectors.features.length >= 4);
|
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];
|
const connection = initial.connections[0];
|
||||||
assert.ok(initial.connections.every((item) => item.fromEndpointId.endsWith(":end") && item.toEndpointId.endsWith(":start")));
|
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);
|
assert.equal(initial.connections.length, new Set(initial.connections.map((item) => `${item.fromEndpointId}->${item.toEndpointId}`)).size);
|
||||||
|
|||||||
Reference in New Issue
Block a user