fix: disable native edge lines by default

This commit is contained in:
2026-08-18 09:07:29 +08:00
parent e41bfd13ec
commit f7e71cfe9c
10 changed files with 64 additions and 19 deletions

View File

@@ -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。

View File

@@ -24,6 +24,9 @@
"turnLaneArrows": {
"enabled": true
},
"nativeRoad": {
"edgeLines": false
},
"osm2streets": {
"debug_each_step": false,
"dual_carriageway_experiment": false,

View File

@@ -24,6 +24,9 @@
"turnLaneArrows": {
"enabled": false
},
"nativeRoad": {
"edgeLines": false
},
"osm2streets": {
"debug_each_step": false,
"dual_carriageway_experiment": false,

View File

@@ -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")),

View File

@@ -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 = {

View File

@@ -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,

View File

@@ -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 = [];

View File

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

View File

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

View File

@@ -54,7 +54,7 @@ markingsToggle.innerHTML = '<input data-layer="markings" type="checkbox" checked
const centerLinesToggle = document.createElement("label");
centerLinesToggle.innerHTML = '<input data-layer="centerLines" type="checkbox" checked> 道路中心线';
const edgeLinesToggle = document.createElement("label");
edgeLinesToggle.innerHTML = '<input data-layer="edgeLines" type="checkbox" checked> 道路外缘线';
edgeLinesToggle.innerHTML = '<input data-layer="edgeLines" type="checkbox"> 道路外缘线';
const controlsToggle = document.createElement("label");
controlsToggle.innerHTML = '<input data-layer="controls" type="checkbox" checked> 斑马线与停止线';
document.querySelector('[data-layer="lanes"]').closest("label").after(directionArrowsToggle, markingsToggle, centerLinesToggle, edgeLinesToggle, controlsToggle);
@@ -74,7 +74,7 @@ const source = () => 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 }),