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