From f7e71cfe9cb9b74f83eef0db827a9c5c8ccfe532 Mon Sep 17 00:00:00 2001 From: que01 Date: Tue, 18 Aug 2026 09:07:29 +0800 Subject: [PATCH] fix: disable native edge lines by default --- .trellis/spec/pipeline/cli-and-stages.md | 21 ++++++++--- .../nantaizi-lake-innovation-valley.json | 3 ++ config/examples/template.json | 3 ++ scripts/build-area.js | 1 + scripts/compile-native-roads.js | 2 +- scripts/lib/area-config.js | 3 ++ scripts/lib/native-road.js | 37 ++++++++++++++----- scripts/test-native-road.js | 7 ++++ scripts/test-road-workbench.js | 2 + scripts/workbench/app.js | 4 +- 10 files changed, 64 insertions(+), 19 deletions(-) diff --git a/.trellis/spec/pipeline/cli-and-stages.md b/.trellis/spec/pipeline/cli-and-stages.md index b50659c..ec02fb7 100644 --- a/.trellis/spec/pipeline/cli-and-stages.md +++ b/.trellis/spec/pipeline/cli-and-stages.md @@ -600,9 +600,14 @@ Road Workbench 选中 `native-road-center-line/v1` 要素后,可为其 `segmen ## Native 道路外缘线样式覆写 -`edge_lines.geojson` 是每条 directional native road 两侧的外缘标线,使用 -`native-road-edge-line/v1` provenance。默认是白色实线,必须带 `road_id`、`side` -(`left` / `right`)、`osm_way_ids`、`color`、`pattern` 与 `effective_style`。 +`edge_lines.geojson` 是车行道最外侧边界标线,使用 `native-road-edge-line/v1` +provenance。单向道路生成左右两条外缘线;双向道路每个方向只生成远离道路中心的外缘 +线,双向道路中间分界由 `center_lines.geojson` 负责。默认是白色实线,必须带 +`road_id`、`side`(`left` / `right`)、`osm_way_ids`、`color`、`pattern` 与 +`effective_style`。 + +区域配置中的 `nativeRoad.edgeLines` 默认是 `false`;因此 native Blender/Cesium 默认不 +生成或显示该层。只有明确设置为 `true` 才会发布 `edge_lines.geojson` 并交给下游消费。 Road Workbench 点选外缘线后以中文显示其方向侧边,可暂存以下 area-local 覆写: @@ -645,10 +650,14 @@ npm run road:workbench -- --config config/areas/nantaizi-lake-innovation-valley. - 只有 `highway=crossing` 且 `crossing:markings` 不是 `no`、`none` 或 `unmarked` 的 OSM 节点可以生成斑马线。每个安全匹配点生成六条 stripe,带 `crossing_node_id`、`road_id`、`lane_id`、`osm_way_ids`、`direction`、 - `placement_method` 和 `native-road-crosswalk/v1` provenance。 + `placement_method`、`junction_inset_m` 和 `native-road-crosswalk/v1` provenance。 - 停止线还必须对应一个启用的 native arrival endpoint,且过街节点位于该进口到 路口的安全距离内;其 provenance 是 `native-road-stop-line/v1`。不能确认进口 - 时保留斑马线并写 `crossing-no-safe-stop-line` diagnostic,不得猜测一条线。 + 时保留斑马线并写 `crossing-no-safe-stop-line` diagnostic,不得猜测一条线。停止线 + 必须复用斑马线的 `junction_inset_m`,保持与斑马线的上游间距。 +- 有安全进口且存在普通路口 plan 时,斑马线中心推进到 cutback 内约 1.5 米;单次 + 最大推进 4 米。`junction_inset_m` 记录实际推进量,避免远离路口的 crossing 被过度 + 移动。 - `catalog.NATIVE_ROAD_LAYERS` 将两个源层映射到现有的 `crosswalks` 和 `vehicle_stop_lines` 材质层。不得把它们加入 legacy `SCENE_LAYERS`。 - 控制标线优先于箭头:默认直行箭头与其相交时跳过;路口转向箭头依次尝试在距路口 @@ -672,7 +681,7 @@ npm run road:workbench -- --config config/areas/nantaizi-lake-innovation-valley. ### 6. 必需测试 - `npm run test:native-road`:断言 marked / unmarked / 无 native lane 的输出,停止线的 - provenance,以及箭头遇控制标线时后移。 + provenance、斑马线与停止线共享 `junction_inset_m`,以及箭头遇控制标线时后移。 - `npm run test:road-workbench`:断言 controls 开关、两条 API layer 和中文选中溯源。 - `npm run road:compile -- --config config/areas/nantaizi-lake-innovation-valley.json`:核查两层 feature count 及每条停止线都是 native arrival direction。 diff --git a/config/areas/nantaizi-lake-innovation-valley.json b/config/areas/nantaizi-lake-innovation-valley.json index 5f5aa11..e9e6aac 100644 --- a/config/areas/nantaizi-lake-innovation-valley.json +++ b/config/areas/nantaizi-lake-innovation-valley.json @@ -24,6 +24,9 @@ "turnLaneArrows": { "enabled": true }, + "nativeRoad": { + "edgeLines": false + }, "osm2streets": { "debug_each_step": false, "dual_carriageway_experiment": false, diff --git a/config/examples/template.json b/config/examples/template.json index 07b81c4..9ca7207 100644 --- a/config/examples/template.json +++ b/config/examples/template.json @@ -24,6 +24,9 @@ "turnLaneArrows": { "enabled": false }, + "nativeRoad": { + "edgeLines": false + }, "osm2streets": { "debug_each_step": false, "dual_carriageway_experiment": false, diff --git a/scripts/build-area.js b/scripts/build-area.js index ba4a1f1..5acd810 100755 --- a/scripts/build-area.js +++ b/scripts/build-area.js @@ -318,6 +318,7 @@ function nativeRoadFeatureCounts(area) { const root = path.join(area.outputs.nativeRoadDir, "layers"); return { roadSurface: featureCount(path.join(root, "road_surface.geojson")), + edgeLines: featureCount(path.join(root, "edge_lines.geojson")), intersectionSurface: featureCount(path.join(root, "intersection_surface.geojson")), sidewalkSurface: featureCount(path.join(root, "sidewalk_surface.geojson")), laneSeparators: featureCount(path.join(root, "lane_separators.geojson")), diff --git a/scripts/compile-native-roads.js b/scripts/compile-native-roads.js index a152628..65863c2 100644 --- a/scripts/compile-native-roads.js +++ b/scripts/compile-native-roads.js @@ -24,7 +24,7 @@ function compileArea(configPath) { const model = compileRoadModel(fs.readFileSync(area.input, "utf8"), overrides); validateOverrides(overrides, model); fs.mkdirSync(area.outputs.pipelineDir, { recursive: true }); - const compiled = compileGeometry(model, overrides); + const compiled = compileGeometry(model, overrides, { edgeLines: area.nativeRoad.edgeLines }); const staging = fs.mkdtempSync(path.join(area.outputs.pipelineDir, "native-road-")); try { const result = { diff --git a/scripts/lib/area-config.js b/scripts/lib/area-config.js index b5c373c..5a83302 100644 --- a/scripts/lib/area-config.js +++ b/scripts/lib/area-config.js @@ -107,6 +107,9 @@ function normalizeAreaConfig(raw, options = {}) { turnLaneArrows: { enabled: booleanOption(raw.turnLaneArrows?.enabled, false, "turnLaneArrows.enabled"), }, + nativeRoad: { + edgeLines: booleanOption(raw.nativeRoad?.edgeLines, false, "nativeRoad.edgeLines"), + }, osm2streets: raw.osm2streets || { debug_each_step: false, dual_carriageway_experiment: false, diff --git a/scripts/lib/native-road.js b/scripts/lib/native-road.js index 4202c96..622895b 100644 --- a/scripts/lib/native-road.js +++ b/scripts/lib/native-road.js @@ -12,6 +12,8 @@ const DIRECTION_ARROW_INTERVAL_METERS = 32; const DIRECTION_ARROW_ENDPOINT_BUFFER_METERS = 14; const STOP_LINE_OFFSET_METERS = 2.7; const STOP_LINE_MAX_APPROACH_DISTANCE_METERS = 25; +const CROSSWALK_JUNCTION_INSET_METERS = 1.5; +const CROSSWALK_MAX_JUNCTION_INSET_METERS = 4; const CENTER_LINE_DASH_LENGTH_METERS = 2; const CENTER_LINE_DASH_GAP_METERS = 2; const CENTER_LINE_WIDTH_METERS = .25; @@ -240,7 +242,7 @@ function nearbyManualCandidates(endpoints, from) { return endpoints.filter((to) => to.side === "start" && to.roadId !== from.roadId && !sameOsmWay(endpoints, from.roadId, to.roadId)).map((to) => ({ to, distanceMeters: distanceMeters(from.coordinate, to.coordinate) })).filter((item) => item.distanceMeters <= 35).sort((a, b) => a.distanceMeters - b.distanceMeters).slice(0, 3).map(({ to, distanceMeters: meters }) => ({ toEndpointId: to.id, roadId: to.roadId, distanceMeters: Math.round(meters * 10) / 10 })); } -function compileGeometry(model, overrides = { overrides: [] }) { +function compileGeometry(model, overrides = { overrides: [] }, options = {}) { const diagnostics = [...model.diagnostics]; const junctionPlans = compileJunctionPlans(model); const features = []; @@ -260,8 +262,8 @@ function compileGeometry(model, overrides = { overrides: [] }) { 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] } }); } const lanes = compileLaneCenterlines(model, diagnostics, junctionPlans); - const edgeLines = compileEdgeLines(model, overrides, junctionPlans); - const controls = compileControlMarkings(model, lanes, diagnostics); + const edgeLines = options.edgeLines === false ? [] : compileEdgeLines(model, overrides, junctionPlans); + const controls = compileControlMarkings(model, lanes, diagnostics, junctionPlans); const centerLines = compileCenterLines(model, overrides, junctionPlans, controls, diagnostics); const markings = compileLaneMarkings(model, overrides, lanes, diagnostics, junctionPlans, controls); const sidewalks = compileSidewalkSurfaces(model, diagnostics, junctionPlans); @@ -275,7 +277,12 @@ function compileEdgeLines(model, overrides, junctionPlans) { const features = []; for (const road of model.roads) { const line = trimLineAtJunctions(road.centerline, road.sourceNodeIds, junctionPlans); - for (const offset of [-1, 1]) { + const bidirectional = model.roads.some((item) => item.segmentId === road.segmentId && item.id !== road.id); + // On a two-way segment, the inner edge is the road centre boundary and is + // owned by center_lines. Emit only each directional carriageway's outer + // edge; emitting both sides makes the layer look like a second centreline. + const offsets = bidirectional ? [-1] : [-1, 1]; + for (const offset of offsets) { const side = offset < 0 ? "right" : "left"; const style = edgeLineStyle(overrides, road.id, side); const centerline = offsetLine(line, offset * road.widthMeters / 2); @@ -337,7 +344,7 @@ function edgeLineStyle(overrides, roadId, side) { return value ? { color: value.color, pattern: value.pattern } : { color: "white", pattern: "solid" }; } -function compileControlMarkings(model, lanes, diagnostics) { +function compileControlMarkings(model, lanes, diagnostics, junctionPlans = new Map()) { const crosswalks = []; const stopLines = []; const arrivalEndpointIds = new Set(model.connections.filter((connection) => connection.enabled).map((connection) => connection.fromEndpointId)); for (const crossing of model.crossings || []) { @@ -346,23 +353,33 @@ function compileControlMarkings(model, lanes, diagnostics) { if (!candidate || candidate.placement.distance > 12) { diagnostics.push(diagnostic("warning", `crossing:node/${crossing.id}`, [crossing.id], "crossing-no-native-lane", "人行横道无法匹配到安全的原生车道,未生成标线。", crossing.coordinate)); continue; } const approach = candidates.filter((item) => arrivalEndpointIds.has(`endpoint:${item.road.id}:end`) && item.junctionDistanceMeters > STOP_LINE_OFFSET_METERS && item.junctionDistanceMeters <= STOP_LINE_MAX_APPROACH_DISTANCE_METERS).sort((a, b) => a.junctionDistanceMeters - b.junctionDistanceMeters || a.placement.distance - b.placement.distance)[0]; const crosswalkCandidate = approach || candidate; + const junctionInsetMeters = approach ? crossingJunctionInset(approach, junctionPlans) : 0; + const controlCenter = offsetByMeters(crossing.coordinate, crosswalkCandidate.placement.axis, junctionInsetMeters); const { axis } = crosswalkCandidate.placement; const across = [-axis[1], axis[0]]; - for (let index = 0; index < 6; index += 1) crosswalks.push(controlFeature("crosswalk", crossing, crosswalkCandidate, index + 1, rectangleAt(crossing.coordinate, axis, across, 3, .45, -2.25 + index * .9))); + for (let index = 0; index < 6; index += 1) crosswalks.push(controlFeature("crosswalk", crossing, crosswalkCandidate, index + 1, rectangleAt(controlCenter, axis, across, 3, .45, -2.25 + index * .9), { junctionInsetMeters })); if (!approach) { diagnostics.push(diagnostic("info", `crossing:node/${crossing.id}`, [crossing.id], "crossing-no-safe-stop-line", "人行横道没有可确认的路口进口车道,保留斑马线但未生成停止线。", crossing.coordinate)); continue; } - const rawRoadPlacement = nearestLanePlacement(approach.road.centerline, crossing.coordinate); + const rawRoadPlacement = nearestLanePlacement(approach.road.centerline, controlCenter); const laneOffset = rawRoadPlacement ? project(approach.placement.point, rawRoadPlacement.point) : [0, 0]; const lateralOffset = laneOffset[0] * across[0] + laneOffset[1] * across[1]; - const laneCenterAtCrossing = offsetByMeters(crossing.coordinate, across, lateralOffset); + const laneCenterAtCrossing = offsetByMeters(controlCenter, across, lateralOffset); const stopCenter = offsetByMeters(laneCenterAtCrossing, approach.placement.axis, -STOP_LINE_OFFSET_METERS); - stopLines.push(controlFeature("stop-line", crossing, approach, 1, rectangleAt(stopCenter, across, approach.placement.axis, approach.road.widthMeters, .45, 0))); + stopLines.push(controlFeature("stop-line", crossing, approach, 1, rectangleAt(stopCenter, across, approach.placement.axis, approach.road.widthMeters, .45, 0), { junctionInsetMeters })); } return { crosswalks, stopLines }; } +function crossingJunctionInset(candidate, junctionPlans) { + const junctionNodeId = candidate.road.sourceNodeIds.at(-1); + const plan = junctionPlans.get(junctionNodeId); + if (!plan) return 0; + const targetDistance = Math.max(0, plan.cutbackMeters - CROSSWALK_JUNCTION_INSET_METERS); + return Math.min(CROSSWALK_MAX_JUNCTION_INSET_METERS, Math.max(0, candidate.junctionDistanceMeters - targetDistance)); +} + function nearestLanePlacement(line, target) { let best = null; let traversedMeters = 0; for (let index = 1; index < line.length; index += 1) { const a = line[index - 1]; const b = line[index]; const vector = project(b, a); const length = Math.hypot(...vector); if (!length) continue; const relative = project(target, a); const ratio = Math.max(0, Math.min(1, (relative[0] * vector[0] + relative[1] * vector[1]) / (length * length))); const point = interpolate(a, b, ratio); const distance = distanceMeters(point, target); if (!best || distance < best.distance) best = { point, axis: [vector[0] / length, vector[1] / length], distance, distanceToEndMeters: lineLengthMeters(line) - traversedMeters - length * ratio }; traversedMeters += length; } return best; } function offsetByMeters(point, axis, meters) { return unproject([axis[0] * meters, axis[1] * meters], point); } function rectangleAt(center, axis, across, length, width, offset) { const shifted = offsetByMeters(center, across, offset); const corners = [[-length / 2, -width / 2], [length / 2, -width / 2], [length / 2, width / 2], [-length / 2, width / 2]].map(([forward, side]) => unproject([axis[0] * forward + across[0] * side, axis[1] * forward + across[1] * side], shifted)); return [...corners, corners[0]]; } -function controlFeature(kind, crossing, candidate, part, ring) { const stop = kind === "stop-line"; return { type: "Feature", properties: { native_id: `${kind}:node/${crossing.id}:${part}`, crossing_node_id: crossing.id, road_id: candidate.road.id, lane_id: candidate.lane.id, osm_way_ids: candidate.road.osmWayIds.join(","), direction: candidate.road.direction, placement_method: "native-lane-nearest-point/v1", provenance: stop ? "native-road-stop-line/v1" : "native-road-crosswalk/v1" }, geometry: { type: "Polygon", coordinates: [ring] } }; } +function controlFeature(kind, crossing, candidate, part, ring, placement = {}) { const stop = kind === "stop-line"; return { type: "Feature", properties: { native_id: `${kind}:node/${crossing.id}:${part}`, crossing_node_id: crossing.id, road_id: candidate.road.id, lane_id: candidate.lane.id, osm_way_ids: candidate.road.osmWayIds.join(","), direction: candidate.road.direction, placement_method: "native-lane-nearest-point/v1", junction_inset_m: Math.round((placement.junctionInsetMeters || 0) * 100) / 100, provenance: stop ? "native-road-stop-line/v1" : "native-road-crosswalk/v1" }, geometry: { type: "Polygon", coordinates: [ring] } }; } function compileLaneMarkings(model, overrides, lanes, diagnostics, junctionPlans, controls) { const separators = []; const directionArrows = []; const turnArrows = []; diff --git a/scripts/test-native-road.js b/scripts/test-native-road.js index 3120575..9a4346c 100644 --- a/scripts/test-native-road.js +++ b/scripts/test-native-road.js @@ -22,6 +22,7 @@ assert.equal(edited.provenance.widthMeters, "override:road-width"); assert.equal(edited.sidewalkLeft, false); const geometry = compileGeometry(model); assert.equal(geometry.roadSurface.features.length, 2); +assert.equal(compileGeometry(model, empty, { edgeLines: false }).edgeLines.features.length, 0); assert.ok(geometry.roadSurface.features.every((feature) => feature.geometry.coordinates[0].length >= 5)); assert.equal(geometry.sidewalkSurface.features.length, 2); assert.ok(geometry.sidewalkSurface.features.every((feature) => feature.geometry.coordinates[0].length >= 5)); @@ -57,6 +58,8 @@ assert.throws(() => validateOverrides({ schema: "native-road-overrides/v1", over assert.throws(() => validateOverrides({ schema: "native-road-overrides/v1", overrides: [{ id: "bad-center", kind: "center-line-style", segmentId: target.segmentId, color: "blue", pattern: "solid" }] }, model), /Invalid center line style override/); const edgeLine = geometry.edgeLines.features[0]; assert.ok(edgeLine && edgeLine.properties.effective_style === "white-solid"); +const twoWayEdgeLines = geometry.edgeLines.features.filter((feature) => feature.properties.road_id.includes("way/10")); +assert.ok(twoWayEdgeLines.length > 0 && twoWayEdgeLines.every((feature) => feature.properties.side === "right")); const edgeOverride = validateOverrides({ schema: "native-road-overrides/v1", overrides: [{ id: "edge-yellow-dashed", kind: "edge-line-style", roadId: edgeLine.properties.road_id, side: edgeLine.properties.side, color: "yellow", pattern: "dashed" }] }, model); const styledEdgeLines = compileGeometry(model, edgeOverride).edgeLines.features.filter((feature) => feature.properties.road_id === edgeLine.properties.road_id && feature.properties.side === edgeLine.properties.side); assert.ok(styledEdgeLines.length > 1 && styledEdgeLines.every((feature) => feature.properties.effective_style === "yellow-dashed")); @@ -80,6 +83,8 @@ assert.equal(controlGeometry.crosswalks.features.length, 6); assert.equal(controlGeometry.vehicleStopLines.features.length, 1); assert.ok(controlGeometry.crosswalks.features.every((feature) => feature.properties.crossing_node_id === "2" && feature.properties.provenance === "native-road-crosswalk/v1")); assert.ok(controlGeometry.vehicleStopLines.features.every((feature) => feature.properties.crossing_node_id === "2" && feature.properties.provenance === "native-road-stop-line/v1")); +assert.ok(controlGeometry.crosswalks.features.every((feature) => feature.properties.junction_inset_m > 0)); +assert.equal(controlGeometry.vehicleStopLines.features[0].properties.junction_inset_m, controlGeometry.crosswalks.features[0].properties.junction_inset_m); assert.ok(controlGeometry.diagnostics.some((item) => item.rule === "crossing-no-native-lane" && item.sourceIds.includes("5"))); assert.ok(controlGeometry.centerLines.features.length > 0); assert.ok(controlGeometry.centerLines.features.every((dash) => ![...controlGeometry.crosswalks.features, ...controlGeometry.vehicleStopLines.features].some((control) => ringsOverlap(dash.geometry.coordinates[0], control.geometry.coordinates[0])))); @@ -185,8 +190,10 @@ try { assert.equal(compiledArea.result.areaId, "fresh"); assert.ok(fs.existsSync(path.join(outputRoot, "fresh", "native-road", "compiled.json"))); const centerLineLayer = JSON.parse(fs.readFileSync(path.join(outputRoot, "fresh", "native-road", "layers", "center_lines.geojson"), "utf8")); + const edgeLineLayer = JSON.parse(fs.readFileSync(path.join(outputRoot, "fresh", "native-road", "layers", "edge_lines.geojson"), "utf8")); assert.equal(centerLineLayer.type, "FeatureCollection"); assert.equal(centerLineLayer.features.length, compiledArea.comparison.nativeCenterLineFeatures); + assert.equal(edgeLineLayer.features.length, 0); 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); diff --git a/scripts/test-road-workbench.js b/scripts/test-road-workbench.js index 8d40f6b..f280587 100644 --- a/scripts/test-road-workbench.js +++ b/scripts/test-road-workbench.js @@ -42,6 +42,8 @@ assert.match(app, /const double = parts\[0\] === "double"/); assert.match(app, /function selectEdgeLine\(feature\)/); assert.match(app, /edge-line-style/); assert.match(app, /道路外缘线样式/); +assert.match(app, /data-layer="edgeLines" type="checkbox"> 道路外缘线/); +assert.match(app, /edgeLines: new VectorLayer\(\{ source: source\(\), visible: false/); assert.match(html, /id="marking-style-heading"/); assert.match(app, /data-layer="controls" type="checkbox" checked> 斑马线与停止线/); assert.match(app, /controls: new VectorLayer/); diff --git a/scripts/workbench/app.js b/scripts/workbench/app.js index 71729c5..d655151 100644 --- a/scripts/workbench/app.js +++ b/scripts/workbench/app.js @@ -54,7 +54,7 @@ markingsToggle.innerHTML = ' new VectorSource(); const layers = { reference: new VectorLayer({ source: source(), visible: false, style: new Style({ fill: new Fill({ color: "rgba(123, 140, 148, .28)" }), stroke: new Stroke({ color: "#8999a0", width: 1 }) }) }), native: new VectorLayer({ source: source(), style: nativeSurfaceStyle }), - edgeLines: new VectorLayer({ source: source(), style: markingStyle }), + edgeLines: new VectorLayer({ source: source(), visible: false, style: markingStyle }), sidewalks: new VectorLayer({ source: source(), style: sidewalkSurfaceStyle }), osm: new VectorLayer({ source: source(), style: (feature) => new Style({ stroke: new Stroke({ color: feature.get("road_id") === selectedRoad?.id ? "#006e91" : "#263630", width: feature.get("road_id") === selectedRoad?.id ? 5 : 2 }) }) }), lanes: new VectorLayer({ source: source(), style: laneStyle }),