diff --git a/scripts/compile-native-roads.js b/scripts/compile-native-roads.js index 9fe4f95..7f2a10a 100644 --- a/scripts/compile-native-roads.js +++ b/scripts/compile-native-roads.js @@ -23,7 +23,7 @@ function compileArea(configPath) { const overrides = loadOverrides(area.outputs.nativeRoadOverrides); const model = compileRoadModel(fs.readFileSync(area.input, "utf8"), overrides); validateOverrides(overrides, model); - const compiled = compileGeometry(model); + const compiled = compileGeometry(model, overrides); const staging = fs.mkdtempSync(path.join(area.outputs.pipelineDir, "native-road-")); try { const result = { diff --git a/scripts/lib/native-road.js b/scripts/lib/native-road.js index d70fca5..0263234 100644 --- a/scripts/lib/native-road.js +++ b/scripts/lib/native-road.js @@ -114,6 +114,7 @@ function validateOverrides(value, model) { const ids = new Set(); const roadIds = model ? new Set(model.roads.map((road) => road.id)) : null; const endpointIds = model ? new Set(model.endpoints.map((endpoint) => endpoint.id)) : null; + const laneIds = model ? new Set(model.roads.flatMap((road) => Array.from({ length: road.laneCount }, (_, index) => `lane:${road.id}:${index + 1}`))) : null; for (const item of value.overrides) { if (!item || typeof item.id !== "string" || !item.id || ids.has(item.id)) throw new Error("Each override needs a unique id."); ids.add(item.id); @@ -124,6 +125,8 @@ function validateOverrides(value, model) { } else if (item.kind === "junction-connection") { if (typeof item.fromEndpointId !== "string" || typeof item.toEndpointId !== "string" || typeof item.enabled !== "boolean" || (endpointIds && (!endpointIds.has(item.fromEndpointId) || !endpointIds.has(item.toEndpointId)))) throw new Error("Invalid junction connection override."); if (model && !connectionEndpointsCompatible(model, item.fromEndpointId, item.toEndpointId)) throw new Error("A manual junction connection must go from a road end to a nearby road start (within 35m)."); + } else if (item.kind === "lane-connection") { + if (typeof item.fromLaneId !== "string" || typeof item.toLaneId !== "string" || typeof item.enabled !== "boolean" || (laneIds && (!laneIds.has(item.fromLaneId) || !laneIds.has(item.toLaneId)))) throw new Error("Invalid lane connection override."); } else throw new Error(`Unsupported override kind: ${item.kind}`); } return { schema: OVERRIDE_SCHEMA, overrides: value.overrides }; @@ -181,7 +184,7 @@ function connectionEndpointsCompatible(model, fromId, toId) { return Math.hypot(dx, dy) <= 35; } -function compileGeometry(model) { +function compileGeometry(model, overrides = { overrides: [] }) { const diagnostics = [...model.diagnostics]; const features = []; const emittedWays = new Set(); @@ -196,7 +199,7 @@ function compileGeometry(model) { features.push({ type: "Feature", properties: { native_id: `surface:way/${wayKey}`, directional_road_ids: directions.map((item) => item.id).join(","), osm_way_ids: wayKey, width_m: totalWidth, lane_count: directions.reduce((sum, item) => sum + item.laneCount, 0), provenance: JSON.stringify(directions.map((item) => item.provenance)), override_ids: directions.flatMap((item) => item.appliedOverrideIds).join(",") }, geometry: { type: "Polygon", coordinates: [ring] } }); } const lanes = compileLaneCenterlines(model, diagnostics); - const connectors = compileConnectors(model, lanes, diagnostics); + const connectors = compileConnectors(model, lanes, diagnostics, overrides); const junctionFeatures = compileJunctionSurfaces(model, lanes, connectors, diagnostics); validateConnectorContainment(connectors, junctionFeatures, diagnostics); return { roadSurface: { type: "FeatureCollection", features }, intersectionSurface: { type: "FeatureCollection", features: junctionFeatures }, laneCenterlines: { type: "FeatureCollection", features: lanes.features }, connectors: { type: "FeatureCollection", features: connectors }, diagnostics }; @@ -256,7 +259,7 @@ function compileLaneCenterlines(model, diagnostics) { return { features, byRoadId }; } -function compileConnectors(model, lanes, diagnostics) { +function compileConnectors(model, lanes, diagnostics, overrides) { const features = []; for (const connection of model.connections.filter((item) => item.enabled)) { const fromRoad = model.roads.find((road) => road.id === endpointRoadId(model, connection.fromEndpointId)); @@ -266,19 +269,23 @@ function compileConnectors(model, lanes, diagnostics) { if (!fromLanes.length || !toLanes.length) { diagnostics.push(diagnostic("warning", connection.id, [connection.nodeId], "connector-missing-lane", "转向连接缺少可用车道中心线。", endpointCoordinate(model, connection.fromEndpointId))); continue; } for (let index = 0; index < fromLanes.length; index += 1) { const turn = connectionTurn(fromRoad, toRoad); - if (!laneAllowsTurn(fromRoad, index, turn)) continue; - const targetIndex = targetLaneIndex(turn, index, fromLanes.length, toLanes.length); - const from = fromLanes[index].coordinates.at(-1); const to = toLanes[targetIndex].coordinates[0]; + const defaultTargetIndex = targetLaneIndex(turn, index, fromLanes.length, toLanes.length); + const defaultFromLane = fromLanes[index]; const defaultToLane = toLanes[defaultTargetIndex]; + const override = laneOverride(overrides, defaultFromLane.id, defaultToLane.id); + if ((!laneAllowsTurn(fromRoad, index, turn) && override?.enabled !== true) || override?.enabled === false) continue; + const targetIndex = defaultTargetIndex; + const from = defaultFromLane.coordinates.at(-1); const to = defaultToLane.coordinates[0]; const control = connectorControlPoint(model, connection, from, to); const coordinates = quadraticCurve(from, control, to, 12); const length = lineLengthMeters(coordinates); if (length < 0.4) continue; if (length > 80) { diagnostics.push(diagnostic("warning", connection.id, [connection.nodeId], "connector-too-long", "转向路径超过 80 米,已跳过;请检查路口拓扑或人工连接。", from)); continue; } - features.push({ type: "Feature", properties: { native_id: `connector:${connection.id}:lane/${index + 1}`, connection_id: connection.id, node_id: connection.nodeId, from_lane_id: fromLanes[index].id, to_lane_id: toLanes[targetIndex].id, turn, provenance: connection.provenance }, geometry: { type: "LineString", coordinates } }); + features.push({ type: "Feature", properties: { native_id: `connector:${connection.id}:${defaultFromLane.id}->${defaultToLane.id}`, connection_id: connection.id, node_id: connection.nodeId, from_lane_id: defaultFromLane.id, to_lane_id: defaultToLane.id, turn, provenance: override ? `override:${override.id}` : connection.provenance }, geometry: { type: "LineString", coordinates } }); } } return features; } +function laneOverride(overrides, fromLaneId, toLaneId) { return overrides.overrides.find((item) => item.kind === "lane-connection" && item.fromLaneId === fromLaneId && item.toLaneId === toLaneId); } function endpointRoadId(model, endpointId) { return model.endpoints.find((endpoint) => endpoint.id === endpointId)?.roadId; } function endpointCoordinate(model, endpointId) { return model.endpoints.find((endpoint) => endpoint.id === endpointId)?.coordinate; } diff --git a/scripts/test-native-road.js b/scripts/test-native-road.js index 21df95b..bd7f82c 100644 --- a/scripts/test-native-road.js +++ b/scripts/test-native-road.js @@ -42,5 +42,7 @@ const turnGeometry = compileGeometry(turnModel); assert.equal(turnGeometry.connectors.features.length, 1); assert.match(turnGeometry.connectors.features[0].properties.from_lane_id, /road:way\/30:forward:1$/); assert.match(turnGeometry.connectors.features[0].properties.to_lane_id, /road:way\/31:forward:1$/); +const laneOverrides = validateOverrides({ schema: "native-road-overrides/v1", overrides: [{ id: "block-left", kind: "lane-connection", fromLaneId: turnGeometry.connectors.features[0].properties.from_lane_id, toLaneId: turnGeometry.connectors.features[0].properties.to_lane_id, enabled: false }] }, turnModel); +assert.equal(compileGeometry(turnModel, laneOverrides).connectors.features.length, 0); assert.throws(() => validateOverrides({ schema: "native-road-overrides/v1", overrides: [{ id: "bad", kind: "road", roadId: "missing", widthMeters: 4 }] }, initial), /Unknown road/); console.log("native road tests passed"); diff --git a/scripts/workbench/app.js b/scripts/workbench/app.js index c7f2eea..f773269 100644 --- a/scripts/workbench/app.js +++ b/scripts/workbench/app.js @@ -15,6 +15,7 @@ const compileButton = document.querySelector("#compile"); const diagnostics = document.querySelector("#diagnostics"); const directionSwitch = document.querySelector("#direction-switch"); const addConnectionButton = document.querySelector("#add-connection"); +const movementSummary = document.querySelector("#movement-summary"); let state; let selected = null; let staged = []; @@ -38,6 +39,7 @@ function draw() { layerLine(feature, active ? "#006e91" : "#f5f6ee", active ? 2.4 : 1.2, true); } for (const feature of layers.connectors?.features || []) { + if (!effectiveConnectorEnabled(feature.properties)) continue; const active = feature.properties.from_lane_id.startsWith(`lane:${selected?.id}:`); layerLine(feature, active ? "#d1226f" : "#ad3a76", active ? 3 : 1.8, true); } @@ -69,10 +71,18 @@ function select(road) { document.querySelector("#road-name").textContent = `${roadLabel(road)}(${road.direction === "forward" ? "沿 OSM 方向行驶" : "逆 OSM 方向行驶"})`; widthInput.value = road.widthMeters; lanesInput.value = road.laneCount; leftInput.checked = road.sidewalkLeft; rightInput.checked = road.sidewalkRight; evidence.textContent = JSON.stringify({ OSM道路: road.osmWayIds, 名称: road.tags.name || "未标注", 参数来源: road.provenance, 已应用修改: road.appliedOverrideIds, 原始标签: road.tags }, null, 2); + renderMovementSummary(road); renderDirectionSwitch(road); renderConnections(road); draw(); } +function renderMovementSummary(road) { + const connectors = (state.layers.connectors?.features || []).filter((feature) => feature.properties.from_lane_id.startsWith(`lane:${road.id}:`) && effectiveConnectorEnabled(feature.properties)); + const byTurn = connectors.reduce((result, feature) => { result[feature.properties.turn] = (result[feature.properties.turn] || 0) + 1; return result; }, {}); + const labels = { left: "左转", through: "直行", right: "右转", uturn: "掉头" }; + movementSummary.textContent = connectors.length ? `当前方向已生成 ${connectors.length} 条路径:${Object.entries(byTurn).map(([turn, count]) => `${labels[turn] || turn} ${count}`).join(",")}` : "当前方向没有已生成的转向路径"; +} + function renderDirectionSwitch(road) { directionSwitch.innerHTML = ""; const alternatives = state.compiled.model.roads.filter((item) => item.osmWayIds.join(",") === road.osmWayIds.join(",")); @@ -100,11 +110,24 @@ function renderConnections(road) { if (!target || seen.has(target.id)) continue; seen.add(target.id); const input = document.createElement("input"); const label = document.createElement("label"); - input.type = "checkbox"; input.checked = connection.enabled; input.onchange = () => stageConnection(connection, input.checked); + input.type = "checkbox"; input.checked = effectiveConnectionEnabled(connection); input.onchange = () => { stageConnection(connection, input.checked); renderConnections(road); draw(); }; label.append(input, ` ${turnName(road, target)}:${roadLabel(target)}`); box.append(label); + renderLaneConnectionControls(box, connection); } } +function renderLaneConnectionControls(box, connection) { + const connectors = state.layers.connectors?.features.filter((feature) => feature.properties.connection_id === connection.id) || []; + const rows = connectors.map((feature) => ({ from_lane_id: feature.properties.from_lane_id, to_lane_id: feature.properties.to_lane_id, enabled: true })); + const sourceRoad = connection.fromEndpointId.slice("endpoint:".length).replace(/:end$/, ""); + const targetRoad = connection.toEndpointId.slice("endpoint:".length).replace(/:start$/, ""); + for (const override of state.overrides.overrides.filter((item) => item.kind === "lane-connection" && item.fromLaneId.startsWith(`lane:${sourceRoad}:`) && item.toLaneId.startsWith(`lane:${targetRoad}:`))) { + if (!rows.some((row) => row.from_lane_id === override.fromLaneId && row.to_lane_id === override.toLaneId)) rows.push({ from_lane_id: override.fromLaneId, to_lane_id: override.toLaneId, enabled: override.enabled }); + } + for (const row of rows) appendLaneConnectionControl(box, row); +} +function appendLaneConnectionControl(box, row) { const fromIndex = row.from_lane_id.split(":").at(-1); const toIndex = row.to_lane_id.split(":").at(-1); const input = document.createElement("input"); const label = document.createElement("label"); input.type = "checkbox"; input.checked = effectiveLaneEnabled(row); input.onchange = () => { stageLaneConnection(row, input.checked); if (selected) { renderConnections(selected); renderMovementSummary(selected); draw(); } }; label.append(input, ` 第 ${fromIndex} 车道 → 第 ${toIndex} 车道`); box.append(label); } + function renderAdditionalConnections(road, endpoint) { const box = document.querySelector("#connections"); const existing = new Set(state.compiled.model.connections.filter((connection) => connection.fromEndpointId === endpoint.id).map((connection) => connection.toEndpointId)); @@ -131,6 +154,10 @@ function turnName(from, to) { function heading(a, b) { return Math.atan2(b[0] - a[0], b[1] - a[1]) * 180 / Math.PI; } function endpointDistanceMeters(a, b) { const dx = (a.coordinate[0] - b.coordinate[0]) * 111320 * Math.cos(a.coordinate[1] * Math.PI / 180); const dy = (a.coordinate[1] - b.coordinate[1]) * 111320; return Math.hypot(dx, dy); } function stageConnection(connection, enabled) { const id = `连接:${connection.id}`; staged = staged.filter((item) => item.id !== id); staged.push({ id, kind: "junction-connection", fromEndpointId: connection.fromEndpointId, toEndpointId: connection.toEndpointId, enabled }); message("有未保存修改"); } +function stageLaneConnection(connector, enabled) { const id = `车道连接:${connector.from_lane_id}->${connector.to_lane_id}`; staged = staged.filter((item) => item.id !== id); staged.push({ id, kind: "lane-connection", fromLaneId: connector.from_lane_id, toLaneId: connector.to_lane_id, enabled }); message("有未保存修改"); } +function effectiveLaneEnabled(connector) { const id = `车道连接:${connector.from_lane_id}->${connector.to_lane_id}`; const override = [...staged, ...state.overrides.overrides].find((item) => item.id === id); return override ? override.enabled : connector.enabled !== false; } +function effectiveConnectionEnabled(connection) { const id = `连接:${connection.id}`; const override = [...staged, ...state.overrides.overrides].find((item) => item.id === id); return override ? override.enabled : connection.enabled; } +function effectiveConnectorEnabled(connector) { const connection = state.compiled.model.connections.find((item) => item.id === connector.connection_id); return effectiveLaneEnabled(connector) && (!connection || effectiveConnectionEnabled(connection)); } function pointToSegmentDistance(point, a, b) { const dx = b[0] - a[0]; const dy = b[1] - a[1]; const lengthSquared = dx * dx + dy * dy; const t = lengthSquared ? Math.max(0, Math.min(1, ((point[0] - a[0]) * dx + (point[1] - a[1]) * dy) / lengthSquared)) : 0; return Math.hypot(point[0] - (a[0] + t * dx), point[1] - (a[1] + t * dy)); } function pickRoad(point) { let best = null; let distance = Infinity; for (const road of state.compiled.model.roads) for (let index = 1; index < road.centerline.length; index += 1) { const candidate = pointToSegmentDistance(point, coord(road.centerline[index - 1]), coord(road.centerline[index])); if (candidate < distance) { distance = candidate; best = road; } } return distance <= 18 * devicePixelRatio ? best : null; } canvas.onclick = (event) => { const rect = canvas.getBoundingClientRect(); select(pickRoad([(event.clientX - rect.left) * devicePixelRatio, (event.clientY - rect.top) * devicePixelRatio])); }; diff --git a/scripts/workbench/index.html b/scripts/workbench/index.html index 279c620..84fb7ad 100644 --- a/scripts/workbench/index.html +++ b/scripts/workbench/index.html @@ -1,4 +1,4 @@