diff --git a/.trellis/tasks/08-17-native-lane-separator-styles/check.jsonl b/.trellis/tasks/08-17-native-lane-separator-styles/check.jsonl new file mode 100644 index 0000000..9dd3234 --- /dev/null +++ b/.trellis/tasks/08-17-native-lane-separator-styles/check.jsonl @@ -0,0 +1 @@ +{"_example": "Fill with {\"file\": \"\", \"reason\": \"\"}. 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."} diff --git a/.trellis/tasks/08-17-native-lane-separator-styles/design.md b/.trellis/tasks/08-17-native-lane-separator-styles/design.md new file mode 100644 index 0000000..6bb294a --- /dev/null +++ b/.trellis/tasks/08-17-native-lane-separator-styles/design.md @@ -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. diff --git a/.trellis/tasks/08-17-native-lane-separator-styles/implement.jsonl b/.trellis/tasks/08-17-native-lane-separator-styles/implement.jsonl new file mode 100644 index 0000000..9dd3234 --- /dev/null +++ b/.trellis/tasks/08-17-native-lane-separator-styles/implement.jsonl @@ -0,0 +1 @@ +{"_example": "Fill with {\"file\": \"\", \"reason\": \"\"}. 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."} diff --git a/.trellis/tasks/08-17-native-lane-separator-styles/implement.md b/.trellis/tasks/08-17-native-lane-separator-styles/implement.md new file mode 100644 index 0000000..e67b0b8 --- /dev/null +++ b/.trellis/tasks/08-17-native-lane-separator-styles/implement.md @@ -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. diff --git a/.trellis/tasks/08-17-native-lane-separator-styles/prd.md b/.trellis/tasks/08-17-native-lane-separator-styles/prd.md new file mode 100644 index 0000000..681d5df --- /dev/null +++ b/.trellis/tasks/08-17-native-lane-separator-styles/prd.md @@ -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. diff --git a/.trellis/tasks/08-17-native-lane-separator-styles/task.json b/.trellis/tasks/08-17-native-lane-separator-styles/task.json new file mode 100644 index 0000000..8965345 --- /dev/null +++ b/.trellis/tasks/08-17-native-lane-separator-styles/task.json @@ -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": {} +} \ No newline at end of file diff --git a/blender/generate_scene.py b/blender/generate_scene.py index f87bcac..cc59afb 100644 --- a/blender/generate_scene.py +++ b/blender/generate_scene.py @@ -665,6 +665,8 @@ def build(args): } road_mats["native_center_line_white"] = material_from_spec( catalog.MATERIALS["native_center_line_white"]) + road_mats["native_lane_separator_yellow"] = material_from_spec( + catalog.MATERIALS["native_lane_separator_yellow"]) traffic_signal_mats = { "metal": material_from_spec(catalog.MATERIALS["traffic_signal_metal"]), "housing": material_from_spec(catalog.MATERIALS["traffic_signal_housing"]), @@ -812,6 +814,9 @@ def build(args): source_path, source["source"] + "_white", projector, roads_c, road_mats["native_center_line_white"], layer["z"], 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: count = _roads.assemble_geojson_layer( source_path, source["source"], projector, roads_c, diff --git a/blender/osmassets/catalog.py b/blender/osmassets/catalog.py index 7c21615..7042dc3 100644 --- a/blender/osmassets/catalog.py +++ b/blender/osmassets/catalog.py @@ -204,6 +204,9 @@ MATERIALS = { "native_center_line_white": {"kind": "solid", "name": "Native Center Line White", "color": (0.95, 0.94, 0.82), "roughness": 0.52, "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)}}, } diff --git a/scripts/lib/native-road.js b/scripts/lib/native-road.js index 2577278..880789b 100644 --- a/scripts/lib/native-road.js +++ b/scripts/lib/native-road.js @@ -16,6 +16,7 @@ const CENTER_LINE_DASH_LENGTH_METERS = 2; const CENTER_LINE_DASH_GAP_METERS = 2; const CENTER_LINE_WIDTH_METERS = .25; 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_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."); } 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."); + } 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}`); } return { schema: OVERRIDE_SCHEMA, overrides: value.overrides }; @@ -254,7 +257,7 @@ function compileGeometry(model, overrides = { overrides: [] }) { const lanes = compileLaneCenterlines(model, diagnostics, junctionPlans); const controls = compileControlMarkings(model, lanes, 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 connectorResult = compileConnectors(model, lanes, diagnostics, overrides); 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); if (!placement) continue; 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] } }); } } @@ -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 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 controlFeatures = [...controls.crosswalks, ...controls.stopLines]; 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; 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 ring = roadRing(centerline, 0.12); - 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 style = laneSeparatorStyle(overrides, road.id, index, index + 1); + 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)); 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 }; } +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) { const length = lineLengthMeters(lane.coordinates); const features = []; diff --git a/scripts/test-native-road.js b/scripts/test-native-road.js index ed54a90..368d60b 100644 --- a/scripts/test-native-road.js +++ b/scripts/test-native-road.js @@ -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.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")); +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.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) { diff --git a/scripts/test-road-workbench.js b/scripts/test-road-workbench.js index 503e5c2..1ac4dca 100644 --- a/scripts/test-road-workbench.js +++ b/scripts/test-road-workbench.js @@ -31,6 +31,8 @@ assert.match(app, /data-layer="centerLines" type="checkbox" checked> 道路中 assert.match(app, /centerLines: new VectorLayer/); assert.match(app, /state\.layers\.centerLines/); 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, /centerLineStyleInput\.onchange = stageSelectedCenterLineStyle/); assert.match(html, /道路中心线样式/); diff --git a/scripts/workbench/app.js b/scripts/workbench/app.js index dca420c..3b07103 100644 --- a/scripts/workbench/app.js +++ b/scripts/workbench/app.js @@ -63,6 +63,7 @@ let manualFromEndpoint = null; let diagnosticFilter = "all"; let scenePreview = false; let selectedCenterLineSegment = null; +let selectedLaneSeparator = null; 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 }) }) }), @@ -99,7 +100,7 @@ select.on("select", ({ selected }) => { const stopLine = provenance === "native-road-stop-line/v1"; const markingType = centerLine ? "道路中心虚线" : crosswalk ? "斑马线" : stopLine ? "停止线" : directionArrow ? "道路方向箭头" : turnArrow ? "路口转向箭头" : "车道分隔线"; 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({ 标线类型: markingType, 人行横道节点: 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 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 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 nativeSurfaceStyle(feature) { // 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 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 }); } 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(); }; 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; }