diff --git a/scripts/compile-native-roads.js b/scripts/compile-native-roads.js index 038bac9..95244f3 100644 --- a/scripts/compile-native-roads.js +++ b/scripts/compile-native-roads.js @@ -68,12 +68,17 @@ function compareOsm2Streets(area, model, compiled) { diagnosticsByRule[item.rule] = (diagnosticsByRule[item.rule] || 0) + 1; } const dangling = compiled.diagnostics.filter((item) => item.rule === "unconnected-interior-road-end"); + const junctions = compiled.intersectionSurface.features; + const fallbackJunctions = junctions.filter((feature) => feature.properties.boundary_mode === "connector-convex-fallback"); return { schema: "native-road-comparison/v2", nativeRoadCount: model.roads.length, nativeRoadSurfaceFeatures: compiled.roadSurface.features.length, nativeSidewalkSurfaceFeatures: compiled.sidewalkSurface.features.length, nativeJunctionSurfaceFeatures: compiled.intersectionSurface.features.length, + nativeApproachEnvelopeJunctions: junctions.length - fallbackJunctions.length, + nativeFallbackJunctions: fallbackJunctions.length, + nativeMaxJunctionExpansionRatio: junctions.reduce((maximum, feature) => Math.max(maximum, Number(feature.properties.expansion_ratio) || 0), 0), nativeLaneCenterlineFeatures: compiled.laneCenterlines.features.length, nativeConnectorFeatures: compiled.connectors.features.length, nativeMovementCount: compiled.movements.length, diff --git a/scripts/lib/native-road.js b/scripts/lib/native-road.js index 5d9af52..72af556 100644 --- a/scripts/lib/native-road.js +++ b/scripts/lib/native-road.js @@ -480,6 +480,17 @@ function offsetLine(line, offsetMeters) { } function lineLengthMeters(line) { return line.slice(1).reduce((sum, point, index) => { const previous = line[index]; const dx = (point[0] - previous[0]) * 111320 * Math.cos(point[1] * Math.PI / 180); const dy = (point[1] - previous[1]) * 111320; return sum + Math.hypot(dx, dy); }, 0); } +function polygonAreaMeters(ring) { + if (ring.length < 3) return 0; + const origin = ring[0]; + const points = ring.map((point) => project(point, origin)); + let twiceArea = 0; + for (let index = 0; index < points.length; index += 1) { + const next = points[(index + 1) % points.length]; + twiceArea += points[index][0] * next[1] - next[0] * points[index][1]; + } + return Math.abs(twiceArea) / 2; +} function compileJunctionSurfaces(model, junctionPlans, connectors, movements, diagnostics) { const result = []; @@ -491,6 +502,7 @@ function compileJunctionSurfaces(model, junctionPlans, connectors, movements, di diagnostics.push(diagnostic("warning", `junction:node/${nodeId}`, [nodeId], "junction-surface-deferred", "路口缺少足够的截面或转向路径,暂不生成路口面。", node)); continue; } + 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)))) { @@ -502,7 +514,9 @@ function compileJunctionSurfaces(model, junctionPlans, connectors, movements, di diagnostics.push(diagnostic("error", `junction:node/${nodeId}`, [nodeId], "invalid-junction-surface", "路口截面边界发生自相交,未发布路口面。请检查道路方向或路口拓扑。", node)); continue; } - 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, rule: "junction-shared-cutback/v4-shared-node-split" }, geometry: { type: "Polygon", coordinates: [ring] } }); + const surfaceAreaMeters = polygonAreaMeters(ring); + 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)); diagnostics.push(diagnostic("info", `junction:node/${nodeId}`, [nodeId], "ordinary-junction-surface", "已按道路截面与转向路径生成普通路口面。", node)); } diff --git a/scripts/test-native-road.js b/scripts/test-native-road.js index bf259e6..5011c90 100644 --- a/scripts/test-native-road.js +++ b/scripts/test-native-road.js @@ -34,6 +34,7 @@ assert.ok(geometry.movements.every((movement) => movement.id.startsWith("movemen 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) => 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 crossOsm = ``; const crossCenter = [114.001, 30]; @@ -94,6 +95,8 @@ try { assert.equal(compiledArea.comparison.schema, "native-road-comparison/v2"); assert.equal(compiledArea.comparison.nativeRoadCount, compiledArea.result.model.roads.length); assert.equal(compiledArea.comparison.nativePublishedMovementCount, compiledArea.result.movements.filter((movement) => movement.geometryPublished).length); + assert.equal(compiledArea.comparison.nativeApproachEnvelopeJunctions + compiledArea.comparison.nativeFallbackJunctions, compiledArea.comparison.nativeJunctionSurfaceFeatures); + assert.ok(compiledArea.comparison.nativeMaxJunctionExpansionRatio >= 0); assert.equal(checkArea(config).ok, true); } finally { fs.rmSync(freshArea, { recursive: true, force: true }); diff --git a/scripts/test-road-workbench.js b/scripts/test-road-workbench.js index 67c4023..45464e5 100644 --- a/scripts/test-road-workbench.js +++ b/scripts/test-road-workbench.js @@ -23,4 +23,6 @@ assert.match(app, /scenePreviewToggle\.onchange/); assert.match(app, /layers\.sidewalks\.setVisible\(document\.querySelector\('\[data-layer="sidewalks"\]'\)\.checked\)/); assert.match(app, /function selectJunction\(feature\)/); assert.match(app, /candidate\.get\("native_id"\) === item\.subjectId/); +assert.match(app, /外缘扩张倍率/); +assert.match(app, /最大外缘扩张/); console.log("road workbench tests passed"); diff --git a/scripts/workbench/app.js b/scripts/workbench/app.js index b4513a0..4cafa88 100644 --- a/scripts/workbench/app.js +++ b/scripts/workbench/app.js @@ -137,7 +137,7 @@ function selectJunction(feature) { selectedJunction = feature; selectedRoad = null; selectedMovement = null; form.hidden = true; hint.hidden = true; selectedJunctionPanel.hidden = false; const properties = feature.getProperties(); const roadIds = String(properties.source_road_ids || "").split(",").filter(Boolean); const roads = roadIds.map((id) => state.compiled.model.roads.find((road) => road.id === id)).filter(Boolean); - junctionDetail.textContent = JSON.stringify({ OSM节点: properties.osm_node_id, 类型: properties.kind === "t" ? "T字路口" : "十字路口", 参与方向道路: roads.map((road) => ({ 道路: roadLabel(road), OSM道路: road.osmWayIds, 节点顺序: road.sourceNodeIds })), 构面规则: properties.rule, 边界策略: properties.boundary_mode, 路口退让距离米: properties.cutback_m, 行驶动作数: properties.movement_count, 已绘制连接数: properties.connector_count }, null, 2); + junctionDetail.textContent = JSON.stringify({ OSM节点: properties.osm_node_id, 类型: properties.kind === "t" ? "T字路口" : "十字路口", 参与方向道路: roads.map((road) => ({ 道路: roadLabel(road), OSM道路: road.osmWayIds, 节点顺序: road.sourceNodeIds })), 构面规则: properties.rule, 边界策略: properties.boundary_mode, 基础截面面积平方米: properties.approach_area_m2, 最终路口面积平方米: properties.surface_area_m2, 外缘扩张倍率: properties.expansion_ratio, 路口退让距离米: properties.cutback_m, 行驶动作数: properties.movement_count, 已绘制连接数: properties.connector_count }, null, 2); message(`已选中路口:OSM 节点 ${properties.osm_node_id}`); } function turnLabel(turn) { return { left: "左转", through: "直行", right: "右转", uturn: "掉头" }[turn] || turn; } @@ -164,7 +164,7 @@ addConnectionButton.onclick = () => { const endpoint = endpointFor(selectedRoad, function focusDiagnostic(item) { const feature = layers.diagnostics.getSource().getFeatures().find((candidate) => candidate.get("id") === item.id); if (feature) map.getView().fit(feature.getGeometry().getExtent(), { padding: [80, 80, 80, 360], maxZoom: 18, duration: 250 }); const junction = layers.native.getSource().getFeatures().find((candidate) => candidate.get("native_id") === item.subjectId); if (junction) return selectJunction(junction); selectRoad(state.compiled.model.roads.find((road) => road.id === item.subjectId), `已定位:${item.message}`); } function diagnosticLabel(item) { const road = state.compiled.model.roads.find((candidate) => candidate.id === item.subjectId); if (item.rule !== "unconnected-interior-road-end" || !road) return item.message; const candidateCount = item.manualCandidates?.length || 0; return `${roadLabel(road)}(${osmDirectionLabel(road)},节点 ${item.sourceIds[0]}):内部端点未连接${candidateCount ? `,附近有 ${candidateCount} 个可手工连接候选` : ""}`; } function renderDiagnostics() { const all = state.compiled.diagnostics.filter((diagnostic) => diagnostic.rule !== "ordinary-junction-surface"); const counts = { all: all.length, candidates: all.filter((item) => item.manualCandidates?.length).length, other: all.filter((item) => !item.manualCandidates?.length).length }; for (const button of diagnosticFilters.querySelectorAll("button")) { const filter = button.dataset.diagnosticFilter; button.classList.toggle("active", filter === diagnosticFilter); button.textContent = `${filter === "all" ? "全部" : filter === "candidates" ? "可连接" : "其他"}(${counts[filter]})`; } const visible = all.filter((item) => diagnosticFilter === "all" || diagnosticFilter === "candidates" ? Boolean(item.manualCandidates?.length) : !item.manualCandidates?.length).sort((a, b) => (b.manualCandidates?.length || 0) - (a.manualCandidates?.length || 0)); diagnostics.innerHTML = ""; for (const item of visible) { const button = document.createElement("button"); button.textContent = diagnosticLabel(item); button.onclick = () => focusDiagnostic(item); diagnostics.append(button); } } -function renderSummary() { const comparison = state.comparison; const rows = [["方向道路", comparison.nativeRoadCount], ["路缘与步行带", comparison.nativeSidewalkSurfaceFeatures], ["路口面", comparison.nativeJunctionSurfaceFeatures], ["行驶动作", comparison.nativeMovementCount], ["已绘制路径", comparison.nativePublishedMovementCount], ["内部断头", comparison.unconnectedInteriorRoadEnds], ["可手工复核", comparison.unconnectedEndsWithManualCandidates], ["osm2streets 参考", comparison.osm2streetsAvailable ? comparison.osm2streetsRoadSurfaceFeatures : "无"]]; summary.innerHTML = ""; for (const [label, value] of rows) { const term = document.createElement("dt"); const detail = document.createElement("dd"); term.textContent = label; detail.textContent = value; summary.append(term, detail); } } +function renderSummary() { const comparison = state.comparison; const rows = [["方向道路", comparison.nativeRoadCount], ["路缘与步行带", comparison.nativeSidewalkSurfaceFeatures], ["路口面", comparison.nativeJunctionSurfaceFeatures], ["普通构面路口", comparison.nativeApproachEnvelopeJunctions], ["兜底构面路口", comparison.nativeFallbackJunctions], ["最大外缘扩张", comparison.nativeMaxJunctionExpansionRatio], ["行驶动作", comparison.nativeMovementCount], ["已绘制路径", comparison.nativePublishedMovementCount], ["内部断头", comparison.unconnectedInteriorRoadEnds], ["可手工复核", comparison.unconnectedEndsWithManualCandidates], ["osm2streets 参考", comparison.osm2streetsAvailable ? comparison.osm2streetsRoadSurfaceFeatures : "无"]]; summary.innerHTML = ""; for (const [label, value] of rows) { const term = document.createElement("dt"); const detail = document.createElement("dd"); term.textContent = label; detail.textContent = value; summary.append(term, detail); } } function stageRoadOverride(road, changes) { const id = `道路:${road.id}`; const existing = staged.find((item) => item.id === id) || state.overrides.overrides.find((item) => item.id === id); staged = staged.filter((item) => item.id !== id); staged.push({ ...existing, id, kind: "road", roadId: road.id, ...changes }); } form.onsubmit = (event) => { event.preventDefault(); const roadChanges = { widthMeters: Number(widthInput.value), laneCount: Number(lanesInput.value), sidewalkLeft: leftInput.checked, sidewalkRight: rightInput.checked }; stageRoadOverride(selectedRoad, roadChanges); const opposite = state.compiled.model.roads.find((road) => road.id !== selectedRoad.id && road.segmentId === selectedRoad.segmentId); if (opposite) stageRoadOverride(opposite, { sidewalkLeft: rightInput.checked, sidewalkRight: leftInput.checked }); updateDirtyState(); message(opposite ? "有未保存修改:双向道路的路缘与步行带已按实际侧边同步" : "有未保存修改"); }; async function saveStagedChanges() { if (!staged.length) return true; const existing = state.overrides.overrides.filter((item) => !staged.some((change) => change.id === item.id)); const response = await fetch("/api/overrides", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ schema: "native-road-overrides/v1", overrides: [...existing, ...staged] }) }); const result = await response.json(); if (!result.ok) { message(result.error); return false; } state.overrides = result.overrides; staged = []; updateDirtyState(); return true; }