feat: add native lane separator style overrides

This commit is contained in:
2026-08-17 14:45:48 +08:00
parent 8409d2c6c3
commit bc045177a5
12 changed files with 150 additions and 9 deletions

View File

@@ -0,0 +1 @@
{"_example": "Fill with {\"file\": \"<path>\", \"reason\": \"<why>\"}. Put spec/research files only — no code paths. Run `python3 .trellis/scripts/get_context.py --mode packages` to list available specs. Delete this line once real entries are added."}

View File

@@ -0,0 +1,35 @@
# Native Lane Separator Style Overrides Design
## Contract
The override is identified by the stable `roadId` plus the adjacent lane pair:
```json
{
"id": "车道分隔线:road:way/123:forward:1-2",
"kind": "lane-separator-style",
"roadId": "road:way/123:forward",
"leftLaneIndex": 1,
"rightLaneIndex": 2,
"color": "white",
"pattern": "dashed"
}
```
The same `yellow|white` and `dashed|solid` enums are reused. A default is
white dashed. The generated Polygon stores `color`, `pattern` and
`effective_style`; source IDs remain unchanged.
## Rendering
The existing lane separator polygon represents the whole lane-pair path.
`solid` retains it as one continuous feature. `dashed` samples regular dash
polygons along the shared centreline with deterministic spacing and preserves
junction cutback. White uses the existing `lane_separators` material; yellow
uses a native-only yellow material route. Workbench separates lane separators
from turn arrows so their styles remain selectable and visible.
## Compatibility
No override means current visual default. Road edges, curbs and centre lines
are untouched. Existing `native-road-overrides/v1` files remain valid.

View File

@@ -0,0 +1 @@
{"_example": "Fill with {\"file\": \"<path>\", \"reason\": \"<why>\"}. Put spec/research files only — no code paths. Run `python3 .trellis/scripts/get_context.py --mode packages` to list available specs. Delete this line once real entries are added."}

View File

@@ -0,0 +1,7 @@
# Implementation Plan
1. Validate and resolve per-lane-pair style overrides.
2. Generate default/dashed/solid style geometry and effective properties.
3. Add the native Blender yellow lane-separator material route.
4. Add Workbench selection, Chinese editor and automatic staging.
5. Test compiler, Workbench, Blender catalog and Nantaizi native build.

View File

@@ -0,0 +1,47 @@
# Native lane separator style overrides
## Goal
Make native same-direction lane separators selectable and persistently editable
in Road Workbench, using the style override pattern proven for centre lines.
## Confirmed Facts
- `compileLaneMarkings()` writes one continuous `0.12m` Polygon separator
between each adjacent lane pair, identified by `road_id`, `left_lane_index`
and `right_lane_index`.
- Lane separators currently have fixed light marking material and are displayed
together with turn arrows in Workbench.
- `native-road-overrides.json` already supports validated, persistent centre
line styles by logical native segment. The new type must not alter road-edge,
curb or sidewalk geometry.
## Initial Requirements
- Selecting a lane separator must show its lane-pair source and a Chinese
style editor.
- The first style catalog should mirror centre lines: white/yellow and
dashed/solid.
- The effective style must be generated into GeoJSON, visible in Workbench,
and consumed by native Blender/Cesium.
- Default output must remain the current white dashed-style separator.
## Out Of Scope
- Road-edge lines, curbs, sidewalks, double lines, partial ranges, lane-specific
legal restrictions and osm2streets layers.
## Key Decision
Each override applies only to the selected adjacent lane pair. This preserves
separate marking semantics on roads with three or more lanes.
## Acceptance Criteria
- [ ] Selecting a separator exposes a Chinese style editor for its adjacent
lane pair; choosing a style stages it automatically.
- [ ] Save and regeneration preserve the per-pair style across reloads while
unedited separators retain the default.
- [ ] GeoJSON, Workbench, Blender and Cesium show the same effective style.
- [ ] Tests cover validation, defaults, per-pair isolation, automatic staging
and native scene consumption.

View File

@@ -0,0 +1,26 @@
{
"id": "native-lane-separator-styles",
"name": "native-lane-separator-styles",
"title": "Native lane separator style overrides",
"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": null,
"relatedFiles": [],
"notes": "",
"meta": {}
}

View File

@@ -665,6 +665,8 @@ def build(args):
} }
road_mats["native_center_line_white"] = material_from_spec( road_mats["native_center_line_white"] = material_from_spec(
catalog.MATERIALS["native_center_line_white"]) catalog.MATERIALS["native_center_line_white"])
road_mats["native_lane_separator_yellow"] = material_from_spec(
catalog.MATERIALS["native_lane_separator_yellow"])
traffic_signal_mats = { traffic_signal_mats = {
"metal": material_from_spec(catalog.MATERIALS["traffic_signal_metal"]), "metal": material_from_spec(catalog.MATERIALS["traffic_signal_metal"]),
"housing": material_from_spec(catalog.MATERIALS["traffic_signal_housing"]), "housing": material_from_spec(catalog.MATERIALS["traffic_signal_housing"]),
@@ -812,6 +814,9 @@ def build(args):
source_path, source["source"] + "_white", projector, roads_c, source_path, source["source"] + "_white", projector, roads_c,
road_mats["native_center_line_white"], layer["z"], road_mats["native_center_line_white"], layer["z"],
lambda props: props.get("color") == "white") lambda props: props.get("color") == "white")
elif source["source"] == "lane_separators":
count = _roads.assemble_geojson_layer(source_path, source["source"], projector, roads_c, road_mats[target], layer["z"], lambda props: props.get("color") != "yellow")
count += _roads.assemble_geojson_layer(source_path, source["source"] + "_yellow", projector, roads_c, road_mats["native_lane_separator_yellow"], layer["z"], lambda props: props.get("color") == "yellow")
else: else:
count = _roads.assemble_geojson_layer( count = _roads.assemble_geojson_layer(
source_path, source["source"], projector, roads_c, source_path, source["source"], projector, roads_c,

View File

@@ -204,6 +204,9 @@ MATERIALS = {
"native_center_line_white": {"kind": "solid", "name": "Native Center Line White", "native_center_line_white": {"kind": "solid", "name": "Native Center Line White",
"color": (0.95, 0.94, 0.82), "roughness": 0.52, "color": (0.95, 0.94, 0.82), "roughness": 0.52,
"cesium": {"base_color": (0.95, 0.94, 0.82)}}, "cesium": {"base_color": (0.95, 0.94, 0.82)}},
"native_lane_separator_yellow": {"kind": "solid", "name": "Native Lane Separator Yellow",
"color": (0.94, 0.58, 0.06), "roughness": 0.52,
"cesium": {"base_color": (0.94, 0.58, 0.06)}},
} }

View File

@@ -16,6 +16,7 @@ const CENTER_LINE_DASH_LENGTH_METERS = 2;
const CENTER_LINE_DASH_GAP_METERS = 2; const CENTER_LINE_DASH_GAP_METERS = 2;
const CENTER_LINE_WIDTH_METERS = .25; const CENTER_LINE_WIDTH_METERS = .25;
const CENTER_LINE_SOLID_OVERLAP_METERS = .04; 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_COLORS = new Set(["yellow", "white"]);
const CENTER_LINE_PATTERNS = new Set(["dashed", "solid"]); const CENTER_LINE_PATTERNS = new Set(["dashed", "solid"]);
@@ -166,6 +167,8 @@ function validateOverrides(value, model) {
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."); 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 if (item.kind === "center-line-style") { } else if (item.kind === "center-line-style") {
if (typeof item.segmentId !== "string" || !CENTER_LINE_COLORS.has(item.color) || !CENTER_LINE_PATTERNS.has(item.pattern) || (segmentIds && !segmentIds.has(item.segmentId))) throw new Error("Invalid center line style override."); if (typeof item.segmentId !== "string" || !CENTER_LINE_COLORS.has(item.color) || !CENTER_LINE_PATTERNS.has(item.pattern) || (segmentIds && !segmentIds.has(item.segmentId))) throw new Error("Invalid center line style override.");
} else if (item.kind === "lane-separator-style") {
if (typeof item.roadId !== "string" || (roadIds && !roadIds.has(item.roadId)) || !Number.isInteger(item.leftLaneIndex) || item.rightLaneIndex !== item.leftLaneIndex + 1 || !CENTER_LINE_COLORS.has(item.color) || !CENTER_LINE_PATTERNS.has(item.pattern)) throw new Error("Invalid lane separator style override.");
} else throw new Error(`Unsupported override kind: ${item.kind}`); } else throw new Error(`Unsupported override kind: ${item.kind}`);
} }
return { schema: OVERRIDE_SCHEMA, overrides: value.overrides }; return { schema: OVERRIDE_SCHEMA, overrides: value.overrides };
@@ -254,7 +257,7 @@ function compileGeometry(model, overrides = { overrides: [] }) {
const lanes = compileLaneCenterlines(model, diagnostics, junctionPlans); const lanes = compileLaneCenterlines(model, diagnostics, junctionPlans);
const controls = compileControlMarkings(model, lanes, diagnostics); const controls = compileControlMarkings(model, lanes, diagnostics);
const centerLines = compileCenterLines(model, overrides, junctionPlans, controls, diagnostics); const centerLines = compileCenterLines(model, overrides, junctionPlans, controls, diagnostics);
const markings = compileLaneMarkings(model, lanes, diagnostics, junctionPlans, controls); const markings = compileLaneMarkings(model, overrides, lanes, diagnostics, junctionPlans, controls);
const sidewalks = compileSidewalkSurfaces(model, diagnostics, junctionPlans); const sidewalks = compileSidewalkSurfaces(model, diagnostics, junctionPlans);
const connectorResult = compileConnectors(model, lanes, diagnostics, overrides); const connectorResult = compileConnectors(model, lanes, diagnostics, overrides);
const junctionFeatures = compileJunctionSurfaces(model, junctionPlans, connectorResult.features, connectorResult.movements, diagnostics); const junctionFeatures = compileJunctionSurfaces(model, junctionPlans, connectorResult.features, connectorResult.movements, diagnostics);
@@ -284,7 +287,8 @@ function compileCenterLines(model, overrides, junctionPlans, controls, diagnosti
const placement = pointAndAxisAlongLine(line, start + markLength / 2); const placement = pointAndAxisAlongLine(line, start + markLength / 2);
if (!placement) continue; if (!placement) continue;
const ring = rectangleAt(placement.point, placement.axis, [-placement.axis[1], placement.axis[0]], markLength, CENTER_LINE_WIDTH_METERS, 0); const ring = rectangleAt(placement.point, placement.axis, [-placement.axis[1], placement.axis[0]], markLength, CENTER_LINE_WIDTH_METERS, 0);
if (ringsOverlapControl([ring], controlFeatures)) continue; const clearanceRing = rectangleAt(placement.point, placement.axis, [-placement.axis[1], placement.axis[0]], markLength + CENTER_LINE_CONTROL_CLEARANCE_METERS * 2, CENTER_LINE_WIDTH_METERS + CENTER_LINE_CONTROL_CLEARANCE_METERS * 2, 0);
if (ringsOverlapControl([clearanceRing], controlFeatures)) continue;
features.push({ type: "Feature", properties: { native_id: `center-line:${segmentId}:${dashIndex}`, segment_id: segmentId, road_id: forward.id, directional_road_ids: roads.map((road) => road.id).join(","), osm_way_ids: forward.osmWayIds.join(","), dash_index: dashIndex, dash_length_m: markLength, dash_gap_m: gap, color: style.color, pattern: style.pattern, effective_style: `${style.color}-${style.pattern}`, placement_rule: "native-bidirectional-centerline/v1", provenance: "native-road-center-line/v1" }, geometry: { type: "Polygon", coordinates: [ring] } }); features.push({ type: "Feature", properties: { native_id: `center-line:${segmentId}:${dashIndex}`, segment_id: segmentId, road_id: forward.id, directional_road_ids: roads.map((road) => road.id).join(","), osm_way_ids: forward.osmWayIds.join(","), dash_index: dashIndex, dash_length_m: markLength, dash_gap_m: gap, color: style.color, pattern: style.pattern, effective_style: `${style.color}-${style.pattern}`, placement_rule: "native-bidirectional-centerline/v1", provenance: "native-road-center-line/v1" }, geometry: { type: "Polygon", coordinates: [ring] } });
} }
} }
@@ -323,7 +327,7 @@ function offsetByMeters(point, axis, meters) { return unproject([axis[0] * meter
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 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) { 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 compileLaneMarkings(model, lanes, diagnostics, junctionPlans, controls) { function compileLaneMarkings(model, overrides, lanes, diagnostics, junctionPlans, controls) {
const separators = []; const directionArrows = []; const turnArrows = []; const separators = []; const directionArrows = []; const turnArrows = [];
const controlFeatures = [...controls.crosswalks, ...controls.stopLines]; const controlFeatures = [...controls.crosswalks, ...controls.stopLines];
for (const road of model.roads) { for (const road of model.roads) {
@@ -332,8 +336,10 @@ function compileLaneMarkings(model, lanes, diagnostics, junctionPlans, controls)
const left = roadLanes[index - 1].coordinates; const right = roadLanes[index].coordinates; const left = roadLanes[index - 1].coordinates; const right = roadLanes[index].coordinates;
if (left.length !== right.length) continue; if (left.length !== right.length) continue;
const centerline = left.map((point, pointIndex) => [(point[0] + right[pointIndex][0]) / 2, (point[1] + right[pointIndex][1]) / 2]); const centerline = left.map((point, pointIndex) => [(point[0] + right[pointIndex][0]) / 2, (point[1] + right[pointIndex][1]) / 2]);
const ring = roadRing(centerline, 0.12); const style = laneSeparatorStyle(overrides, road.id, index, index + 1);
if (ring) separators.push({ type: "Feature", properties: { native_id: `lane-separator:${road.id}:${index}-${index + 1}`, road_id: road.id, left_lane_index: index, right_lane_index: index + 1, osm_way_ids: road.osmWayIds.join(","), provenance: "native-road-lane-separator/v1" }, geometry: { type: "Polygon", coordinates: [ring] } }); const properties = { road_id: road.id, left_lane_index: index, right_lane_index: index + 1, osm_way_ids: road.osmWayIds.join(","), color: style.color, pattern: style.pattern, effective_style: `${style.color}-${style.pattern}`, provenance: "native-road-lane-separator/v1" };
if (style.pattern === "solid") { const ring = roadRing(centerline, 0.12); if (ring) separators.push({ type: "Feature", properties: { native_id: `lane-separator:${road.id}:${index}-${index + 1}`, ...properties }, geometry: { type: "Polygon", coordinates: [ring] } }); }
else for (let distance = 1, part = 1; distance + 1 <= lineLengthMeters(centerline); distance += 4, part += 1) { const placement = pointAndAxisAlongLine(centerline, distance); if (!placement) continue; const ring = rectangleAt(placement.point, placement.axis, [-placement.axis[1], placement.axis[0]], 2, .12, 0); separators.push({ type: "Feature", properties: { native_id: `lane-separator:${road.id}:${index}-${index + 1}:${part}`, ...properties }, geometry: { type: "Polygon", coordinates: [ring] } }); }
} }
for (const lane of roadLanes) directionArrows.push(...directionArrowFeatures(road, lane, controlFeatures, diagnostics)); for (const lane of roadLanes) directionArrows.push(...directionArrowFeatures(road, lane, controlFeatures, diagnostics));
const turns = road.tags[`turn:lanes:${road.direction}`] ?? road.tags["turn:lanes"]; const turns = road.tags[`turn:lanes:${road.direction}`] ?? road.tags["turn:lanes"];
@@ -359,6 +365,8 @@ function compileLaneMarkings(model, lanes, diagnostics, junctionPlans, controls)
return { separators, directionArrows, turnArrows }; return { separators, directionArrows, turnArrows };
} }
function laneSeparatorStyle(overrides, roadId, leftLaneIndex, rightLaneIndex) { const value = overrides.overrides.find((item) => item.kind === "lane-separator-style" && item.roadId === roadId && item.leftLaneIndex === leftLaneIndex && item.rightLaneIndex === rightLaneIndex); return value ? { color: value.color, pattern: value.pattern } : { color: "white", pattern: "dashed" }; }
function directionArrowFeatures(road, lane, controlFeatures, diagnostics) { function directionArrowFeatures(road, lane, controlFeatures, diagnostics) {
const length = lineLengthMeters(lane.coordinates); const length = lineLengthMeters(lane.coordinates);
const features = []; const features = [];

View File

@@ -27,6 +27,10 @@ assert.equal(geometry.sidewalkSurface.features.length, 2);
assert.ok(geometry.sidewalkSurface.features.every((feature) => feature.geometry.coordinates[0].length >= 5)); assert.ok(geometry.sidewalkSurface.features.every((feature) => feature.geometry.coordinates[0].length >= 5));
assert.equal(geometry.laneCenterlines.features.length, model.roads.reduce((sum, road) => sum + road.laneCount, 0)); assert.equal(geometry.laneCenterlines.features.length, model.roads.reduce((sum, road) => sum + road.laneCount, 0));
assert.ok(geometry.laneSeparators.features.every((feature) => feature.geometry.type === "Polygon" && feature.properties.provenance === "native-road-lane-separator/v1")); assert.ok(geometry.laneSeparators.features.every((feature) => feature.geometry.type === "Polygon" && feature.properties.provenance === "native-road-lane-separator/v1"));
const separator = geometry.laneSeparators.features[0];
const separatorOverride = validateOverrides({ schema: "native-road-overrides/v1", overrides: [{ id: "separator-style", kind: "lane-separator-style", roadId: separator.properties.road_id, leftLaneIndex: separator.properties.left_lane_index, rightLaneIndex: separator.properties.right_lane_index, color: "yellow", pattern: "solid" }] }, model);
const styledSeparators = compileGeometry(model, separatorOverride).laneSeparators.features.filter((feature) => feature.properties.road_id === separator.properties.road_id && feature.properties.left_lane_index === separator.properties.left_lane_index);
assert.ok(styledSeparators.length > 0 && styledSeparators.every((feature) => feature.properties.effective_style === "yellow-solid"));
assert.ok(geometry.centerLines.features.length > 0); assert.ok(geometry.centerLines.features.length > 0);
assert.ok(geometry.centerLines.features.every((feature) => feature.geometry.type === "Polygon" && feature.properties.provenance === "native-road-center-line/v1" && feature.properties.dash_length_m === 2 && feature.properties.dash_gap_m === 2)); assert.ok(geometry.centerLines.features.every((feature) => feature.geometry.type === "Polygon" && feature.properties.provenance === "native-road-center-line/v1" && feature.properties.dash_length_m === 2 && feature.properties.dash_gap_m === 2));
for (const feature of geometry.centerLines.features) { for (const feature of geometry.centerLines.features) {

View File

@@ -31,6 +31,8 @@ assert.match(app, /data-layer="centerLines" type="checkbox" checked> 道路中
assert.match(app, /centerLines: new VectorLayer/); assert.match(app, /centerLines: new VectorLayer/);
assert.match(app, /state\.layers\.centerLines/); assert.match(app, /state\.layers\.centerLines/);
assert.match(app, /native-road-center-line\/v1/); assert.match(app, /native-road-center-line\/v1/);
assert.match(app, /lane-separator-style/);
assert.match(app, /车道分隔线/);
assert.match(app, /center-line-style/); assert.match(app, /center-line-style/);
assert.match(app, /centerLineStyleInput\.onchange = stageSelectedCenterLineStyle/); assert.match(app, /centerLineStyleInput\.onchange = stageSelectedCenterLineStyle/);
assert.match(html, /道路中心线样式/); assert.match(html, /道路中心线样式/);

View File

@@ -63,6 +63,7 @@ let manualFromEndpoint = null;
let diagnosticFilter = "all"; let diagnosticFilter = "all";
let scenePreview = false; let scenePreview = false;
let selectedCenterLineSegment = null; let selectedCenterLineSegment = null;
let selectedLaneSeparator = null;
const source = () => new VectorSource(); const source = () => new VectorSource();
const layers = { 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 }) }) }), 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 }) }) }),
@@ -99,7 +100,7 @@ select.on("select", ({ selected }) => {
const stopLine = provenance === "native-road-stop-line/v1"; const stopLine = provenance === "native-road-stop-line/v1";
const markingType = centerLine ? "道路中心虚线" : crosswalk ? "斑马线" : stopLine ? "停止线" : directionArrow ? "道路方向箭头" : turnArrow ? "路口转向箭头" : "车道分隔线"; const markingType = centerLine ? "道路中心虚线" : crosswalk ? "斑马线" : stopLine ? "停止线" : directionArrow ? "道路方向箭头" : turnArrow ? "路口转向箭头" : "车道分隔线";
selectRoad(road); selectRoad(road);
if (centerLine) selectCenterLine(feature); else clearCenterLineSelection(); if (centerLine) selectCenterLine(feature); else if (provenance === "native-road-lane-separator/v1") selectLaneSeparator(feature); else clearCenterLineSelection();
evidence.textContent = JSON.stringify({ evidence.textContent = JSON.stringify({
标线类型: markingType, 标线类型: markingType,
人行横道节点: crosswalk || stopLine ? feature.get("crossing_node_id") : null, 人行横道节点: crosswalk || stopLine ? feature.get("crossing_node_id") : null,
@@ -135,7 +136,7 @@ function roadIdFromLane(laneId) { return typeof laneId === "string" ? laneId.sli
function laneIndex(laneId) { return Number(String(laneId).split(":").at(-1)); } function laneIndex(laneId) { return Number(String(laneId).split(":").at(-1)); }
function lanePositionLabel(road, index) { return road?.laneCount === 1 ? "唯一车道" : `左起第 ${index} 车道`; } function lanePositionLabel(road, index) { return road?.laneCount === 1 ? "唯一车道" : `左起第 ${index} 车道`; }
function laneStyle(feature) { const selected = feature.get("road_id") === selectedRoad?.id; return new Style({ stroke: new Stroke({ color: selected ? "#006e91" : "#f5f6ee", width: selected ? 3 : 1.3, lineDash: [5, 4] }) }); } function laneStyle(feature) { const selected = feature.get("road_id") === selectedRoad?.id; return new Style({ stroke: new Stroke({ color: selected ? "#006e91" : "#f5f6ee", width: selected ? 3 : 1.3, lineDash: [5, 4] }) }); }
function markingStyle() { return new Style({ fill: new Fill({ color: "#f5f6ee" }), stroke: new Stroke({ color: "#d9dacf", width: 1 }) }); } function markingStyle(feature) { const yellow = feature?.get("color") === "yellow"; return new Style({ fill: new Fill({ color: yellow ? "#f5be2a" : "#f5f6ee" }), stroke: new Stroke({ color: yellow ? "#d29d16" : "#d9dacf", width: 1 }) }); }
function centerLineStyle(feature) { const white = feature.get("color") === "white"; const color = white ? "#faf9ee" : "#f5be2a"; return new Style({ fill: new Fill({ color }), stroke: new Stroke({ color: feature.get("pattern") === "solid" ? color : white ? "#aeb0aa" : "#d29d16", width: feature.get("pattern") === "solid" ? .25 : .8 }) }); } function centerLineStyle(feature) { const white = feature.get("color") === "white"; const color = white ? "#faf9ee" : "#f5be2a"; return new Style({ fill: new Fill({ color }), stroke: new Stroke({ color: feature.get("pattern") === "solid" ? color : white ? "#aeb0aa" : "#d29d16", width: feature.get("pattern") === "solid" ? .25 : .8 }) }); }
function nativeSurfaceStyle(feature) { function nativeSurfaceStyle(feature) {
// Split road features meet at OSM junction nodes. Their per-feature outlines // Split road features meet at OSM junction nodes. Their per-feature outlines
@@ -251,10 +252,11 @@ function renderSummary() {
} }
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 }); } 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 }); }
function selectCenterLine(feature) { selectedCenterLineSegment = feature.get("segment_id"); centerLineForm.hidden = false; centerLineSegment.textContent = `道路段:${selectedCenterLineSegment}`; centerLineStyleInput.value = feature.get("effective_style") || "yellow-dashed"; } function selectCenterLine(feature) { selectedCenterLineSegment = feature.get("segment_id"); centerLineForm.hidden = false; centerLineSegment.textContent = `道路段:${selectedCenterLineSegment}`; centerLineStyleInput.value = feature.get("effective_style") || "yellow-dashed"; }
function clearCenterLineSelection() { selectedCenterLineSegment = null; centerLineForm.hidden = true; } function selectLaneSeparator(feature) { selectedCenterLineSegment = null; selectedLaneSeparator = feature.getProperties(); centerLineForm.hidden = false; centerLineSegment.textContent = `车道分隔线:第 ${selectedLaneSeparator.left_lane_index} 与第 ${selectedLaneSeparator.right_lane_index} 车道之间`; centerLineStyleInput.value = selectedLaneSeparator.effective_style || "white-dashed"; }
function clearCenterLineSelection() { selectedCenterLineSegment = null; selectedLaneSeparator = null; centerLineForm.hidden = true; }
function stageCenterLineStyle(segmentId, style) { const [color, pattern] = style.split("-"); const id = `道路中心线:${segmentId}`; 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: "center-line-style", segmentId, color, pattern }); } function stageCenterLineStyle(segmentId, style) { const [color, pattern] = style.split("-"); const id = `道路中心线:${segmentId}`; 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: "center-line-style", segmentId, color, pattern }); }
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 ? "有未保存修改:双向道路的路缘与步行带已按实际侧边同步" : "有未保存修改"); }; 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 ? "有未保存修改:双向道路的路缘与步行带已按实际侧边同步" : "有未保存修改"); };
function stageSelectedCenterLineStyle() { if (!selectedCenterLineSegment) return; stageCenterLineStyle(selectedCenterLineSegment, centerLineStyleInput.value); updateDirtyState(); message("有未保存修改:道路中心线样式"); } function stageSelectedCenterLineStyle() { if (!selectedCenterLineSegment && !selectedLaneSeparator) return; if (selectedCenterLineSegment) stageCenterLineStyle(selectedCenterLineSegment, centerLineStyleInput.value); else { const [color, pattern] = centerLineStyleInput.value.split("-"); const item = selectedLaneSeparator; const id = `车道分隔线:${item.road_id}:${item.left_lane_index}-${item.right_lane_index}`; staged = staged.filter((change) => change.id !== id); staged.push({ id, kind: "lane-separator-style", roadId: item.road_id, leftLaneIndex: item.left_lane_index, rightLaneIndex: item.right_lane_index, color, pattern }); } updateDirtyState(); message("有未保存修改:线样式"); }
centerLineForm.onsubmit = (event) => { event.preventDefault(); stageSelectedCenterLineStyle(); }; centerLineForm.onsubmit = (event) => { event.preventDefault(); stageSelectedCenterLineStyle(); };
centerLineStyleInput.onchange = stageSelectedCenterLineStyle; centerLineStyleInput.onchange = stageSelectedCenterLineStyle;
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; } 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; }