From 47efb78f1eafca7b1010dc12e55494acfe9ea0b4 Mon Sep 17 00:00:00 2001 From: que01 Date: Mon, 17 Aug 2026 13:29:54 +0800 Subject: [PATCH] feat: add native center line style overrides --- .trellis/spec/pipeline/cli-and-stages.md | 60 +++++++++++++++++ .../check.jsonl | 1 + .../design.md | 64 +++++++++++++++++++ .../implement.jsonl | 1 + .../implement.md | 31 +++++++++ .../prd.md | 62 ++++++++++++++++++ .../task.json | 26 ++++++++ blender/generate_scene.py | 18 +++++- blender/osmassets/catalog.py | 5 +- blender/osmassets/roads.py | 5 +- scripts/lib/native-road.js | 26 ++++++-- scripts/test-native-road.js | 7 ++ scripts/test-road-workbench.js | 4 ++ scripts/workbench/app.js | 14 +++- scripts/workbench/index.html | 2 +- 15 files changed, 313 insertions(+), 13 deletions(-) create mode 100644 .trellis/tasks/08-17-native-road-center-line-styles/check.jsonl create mode 100644 .trellis/tasks/08-17-native-road-center-line-styles/design.md create mode 100644 .trellis/tasks/08-17-native-road-center-line-styles/implement.jsonl create mode 100644 .trellis/tasks/08-17-native-road-center-line-styles/implement.md create mode 100644 .trellis/tasks/08-17-native-road-center-line-styles/prd.md create mode 100644 .trellis/tasks/08-17-native-road-center-line-styles/task.json diff --git a/.trellis/spec/pipeline/cli-and-stages.md b/.trellis/spec/pipeline/cli-and-stages.md index ad6e6c5..6e02f27 100644 --- a/.trellis/spec/pipeline/cli-and-stages.md +++ b/.trellis/spec/pipeline/cli-and-stages.md @@ -491,6 +491,66 @@ const line = trimLineAtJunctions(forward.centerline, forward.sourceNodeIds, junc if (!ringsOverlapControl([ring], [...controls.crosswalks, ...controls.stopLines])) features.push(dash); ``` +## Native 道路中心线样式覆写 + +### 1. 范围与触发条件 + +Road Workbench 选中 `native-road-center-line/v1` 要素后,可为其 `segment_id` +保存样式覆写。覆写属于 `native-road-overrides.json`,不是对 +`center_lines.geojson` 的手工编辑;重新编译必须从覆写重建图层。 + +### 2. 调用形式 + +```json +{ + "id": "道路中心线:segment:way/123/1", + "kind": "center-line-style", + "segmentId": "segment:way/123/1", + "color": "white", + "pattern": "solid" +} +``` + +### 3. 契约 + +- `color` 只能是 `yellow` 或 `white`;`pattern` 只能是 `dashed` 或 `solid`。 + 目标 `segmentId` 必须属于当前 native road model。 +- 未覆写段保持黄色虚线(2m dash、2m gap);`solid` 为 0 gap,但相邻的 2m + 几何块须重叠 `0.04m`,避免投影精度造成可见裂缝。 +- 每个生成面记录 `color`、`pattern` 和 `effective_style`。Workbench 以这些属性 + 着色;native Blender 将 white centre lines 分派至 `Native Center Line White`, + yellow 则继续复用 `Center Line`。 +- 下拉框变化即暂存覆写,顶部“保存并重新生成”是唯一写盘/重编译动作;不要求用户 + 再点击一个容易遗漏的暂存按钮。 + +### 4. 校验与错误矩阵 + +| 条件 | 结果 | +|---|---| +| 合法颜色、图案和当前 segment | 保存后重新生成有效样式 | +| 非法颜色/图案或不存在 segment | `validateOverrides()` 拒绝整个请求 | +| 实线块触及控制标线 | 该块略去,不以连续性为由穿过控制标线 | +| 白色中心线进入 native Blender | 使用白线材质,不改变 legacy 图层材质 | + +### 5. 正常、基础与错误示例 + +- 正常:点选任意 dash,选择“白色实线”,保存重编译后整段显示连续白线。 +- 基础:选择“黄色虚线(默认)”仍是显式覆写,但几何与默认规则一致。 +- 错误:只在 Workbench 改填充色;Blender/Cesium 会继续显示旧黄色。 + +### 6. 必需测试 + +- `npm run test:native-road`:合法/非法样式覆写、solid 属性、控制标线避让。 +- `npm run test:road-workbench`:中文样式面板、下拉框自动暂存和 API payload。 +- `python3 -m unittest discover blender/tests`:catalog 仍是可导入的纯 Python。 +- Nantaizi native `blender,cesium,preview` 构建:既有 yellow centre lines 不回归。 + +### 7. 错误与正确写法 + +错误:实线块仅以零间隔精确相接,且每块使用高对比 outline。 + +正确:小幅重叠相邻块,并让 Workbench 实线 stroke 与 fill 同色。 + ## Native 控制标线 ### 1. 范围与触发条件 diff --git a/.trellis/tasks/08-17-native-road-center-line-styles/check.jsonl b/.trellis/tasks/08-17-native-road-center-line-styles/check.jsonl new file mode 100644 index 0000000..9dd3234 --- /dev/null +++ b/.trellis/tasks/08-17-native-road-center-line-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-road-center-line-styles/design.md b/.trellis/tasks/08-17-native-road-center-line-styles/design.md new file mode 100644 index 0000000..48e322a --- /dev/null +++ b/.trellis/tasks/08-17-native-road-center-line-styles/design.md @@ -0,0 +1,64 @@ +# Native Road Centre-Line Style Overrides Design + +## Architecture + +```text +Workbench selects a generated centre-line dash + -> segment_id identifies the logical road segment + -> staged center-line-style override + -> native-road-overrides.json + -> compileCenterLines resolves default or effective style + -> center_lines.geojson polygons with style fields + -> Workbench / Blender / Cesium +``` + +The authoritative edit is a new override kind, not a mutation of +`center_lines.geojson`. It has a deterministic ID based on `segmentId`, so a +later recompilation replaces the segment's style rather than accumulating +records. + +## Override Contract + +```json +{ + "id": "道路中心线:segment:way/123/1", + "kind": "center-line-style", + "segmentId": "segment:way/123/1", + "color": "yellow", + "pattern": "dashed" +} +``` + +`validateOverrides()` accepts only known model segment IDs and the finite +string enums `yellow|white` and `dashed|solid`. A style override applies once +to the paired forward/backward native roads for that segment. The existing +schema version remains `native-road-overrides/v1` because this is an additive +kind and old files remain valid. + +## Geometry and Material + +- Default remains yellow dashed: 2m dash, 2m gap, 0.25m width. +- `solid` generates deterministic adjacent 2m pieces with no gap. Pieces still + undergo the same junction cutback and control-marking exclusion as dashed + lines; this avoids creating a solid polygon across an excluded crossing. +- Style fields `color`, `pattern`, `dash_length_m`, `dash_gap_m`, and + `effective_style` are stored on every generated polygon. +- Blender currently maps all `center_lines` to one yellow material, so the + native adapter must support a white centre-line material route without + changing legacy osm2streets layers. The route must preserve the existing + yellow material for default and yellow overrides. + +## Workbench UX + +Selecting a centre-line dash shows a compact Chinese style panel in the +existing form area. A select control presents the four named choices. Choosing +one stages an override; the existing 保存 / 保存并重新生成 actions remain the +only persistence and generation actions. The panel also shows whether the +style is default or overridden and identifies the native road segment. + +## Compatibility and Rollback + +Unedited segments generate byte-compatible geometry style defaults apart from +the added style properties. Oneway/service filtering and control priority are +unchanged. Selecting `--road-provider osm2streets` remains a full rollback. + diff --git a/.trellis/tasks/08-17-native-road-center-line-styles/implement.jsonl b/.trellis/tasks/08-17-native-road-center-line-styles/implement.jsonl new file mode 100644 index 0000000..9dd3234 --- /dev/null +++ b/.trellis/tasks/08-17-native-road-center-line-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-road-center-line-styles/implement.md b/.trellis/tasks/08-17-native-road-center-line-styles/implement.md new file mode 100644 index 0000000..9f57ee8 --- /dev/null +++ b/.trellis/tasks/08-17-native-road-center-line-styles/implement.md @@ -0,0 +1,31 @@ +# Implementation Plan + +1. Add validated `center-line-style` overrides and a helper which resolves the + effective default/override style by segment ID. +2. Generate yellow/white and dashed/solid centre-line geometry while retaining + cutback and control-marking exclusion behaviour; publish effective style + properties. +3. Extend the native Blender layer adapter/material handling to distinguish + white from yellow centre-line features without affecting legacy layers. +4. Add Workbench style controls, staging, Chinese selection evidence, and + immediate regenerated-layer display. +5. Add focused compiler, override, Workbench, Blender catalog, and build-stage + tests; compile/check Nantaizi and build Blender/Cesium/preview without the + package stage. + +## Validation + +```bash +npm run test:native-road +npm run test:road-workbench +python3 -m unittest discover blender/tests +npm run test:build-stages +npm run road:compile -- --config config/areas/nantaizi-lake-innovation-valley.json +npm run road:check -- --config config/areas/nantaizi-lake-innovation-valley.json +npm run build:area -- --config config/areas/nantaizi-lake-innovation-valley.json --stages blender,cesium,preview --road-provider native +``` + +## Rollback + +Remove the override records or choose `--road-provider osm2streets`; no legacy +output is modified. diff --git a/.trellis/tasks/08-17-native-road-center-line-styles/prd.md b/.trellis/tasks/08-17-native-road-center-line-styles/prd.md new file mode 100644 index 0000000..2493727 --- /dev/null +++ b/.trellis/tasks/08-17-native-road-center-line-styles/prd.md @@ -0,0 +1,62 @@ +# Native road center line style overrides + +## Goal + +Allow a Road Workbench user to select a generated native road centre line and +persistently override its marking style, without turning the generated GeoJSON +into the source of truth. + +## Confirmed Facts + +- Native `center_lines.geojson` currently contains automatic yellow dashed + polygons (`2m` dash, `2m` gap, `0.25m` width), each tied to a `segment_id`. +- A displayed dash can already be selected and exposes its segment provenance, + but has no editing controls. +- `native-road-overrides.json` is the persistent authority for existing road + and connection edits. The Workbench already stages, validates, saves, then + recompiles those overrides. +- The generated layer is consumed by both Workbench and native Blender/Cesium; + styling must therefore be compiled geometry and use the existing + `center_lines` material path, not a Workbench-only display tint. + +## Requirements + +- R1: Selecting a centre-line dash must expose a Chinese style editor for its + logical target and show the current effective style. +- R2: Supported styles must include at least solid/dashed and white/yellow + marking colours. +- R3: Style choices must be stored in `native-road-overrides.json`, validated, + reapplied during compilation, and survive a future Workbench launch. +- R4: The regenerated GeoJSON must carry source traceability and effective + style fields so Workbench, Blender and Cesium show the same marking. +- R5: Default automatic centre lines remain unchanged for segments without an + override; one-way/service exclusions and control-marking avoidance remain + authoritative. + +## Initial Scope Boundary + +- Editing individual dash polygons is out of scope: they are derived pieces, + not user-owned objects. +- Per-segment control is recommended for the first version because current + native segments already stop at junctions and have stable IDs. +- Double-line semantics, legal `overtaking` inference, hand-drawn partial + ranges, and changes to osm2streets output are out of scope unless explicitly + accepted during planning. + +## Acceptance Criteria + +- [ ] A user can select a centre line, choose a supported style in Chinese, + save it, regenerate, and see the result immediately in the Workbench. +- [ ] After reload and recompilation, the selected segment retains its style + while unedited segments retain the automatic yellow dashed default. +- [ ] Native Blender/Cesium uses the same effective style and does not require + osm2streets geometry. +- [ ] Tests cover schema validation, style geometry, default fallback, + persistence/API wiring, and Workbench selection/edit controls. + +## Key Decisions + +- The first version applies one override to the complete native segment between + junctions. Individual dash and partial-range editing are deferred. +- The initial style catalog is four explicit choices: yellow dashed, white + dashed, yellow solid, and white solid. Double-line semantics are deferred. diff --git a/.trellis/tasks/08-17-native-road-center-line-styles/task.json b/.trellis/tasks/08-17-native-road-center-line-styles/task.json new file mode 100644 index 0000000..8073dac --- /dev/null +++ b/.trellis/tasks/08-17-native-road-center-line-styles/task.json @@ -0,0 +1,26 @@ +{ + "id": "native-road-center-line-styles", + "name": "native-road-center-line-styles", + "title": "Native road center line 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 af56985..f87bcac 100644 --- a/blender/generate_scene.py +++ b/blender/generate_scene.py @@ -663,6 +663,8 @@ def build(args): layer["id"]: material_from_spec(spec) for layer, spec in zip(catalog.ROAD_LAYERS, catalog.road_material_specs()) } + road_mats["native_center_line_white"] = material_from_spec( + catalog.MATERIALS["native_center_line_white"]) traffic_signal_mats = { "metal": material_from_spec(catalog.MATERIALS["traffic_signal_metal"]), "housing": material_from_spec(catalog.MATERIALS["traffic_signal_housing"]), @@ -801,9 +803,19 @@ def build(args): source_path = os.path.join(native_road_dir, "layers", source["source"] + ".geojson") if not os.path.isfile(source_path): raise RuntimeError("Native road layer is missing: " + source_path) - count = _roads.assemble_geojson_layer( - source_path, source["source"], projector, roads_c, - road_mats[target], layer["z"]) + if source["source"] == "center_lines": + count = _roads.assemble_geojson_layer( + source_path, source["source"], projector, roads_c, + road_mats[target], layer["z"], + lambda props: props.get("color") != "white") + count += _roads.assemble_geojson_layer( + source_path, source["source"] + "_white", projector, roads_c, + road_mats["native_center_line_white"], layer["z"], + lambda props: props.get("color") == "white") + else: + count = _roads.assemble_geojson_layer( + source_path, source["source"], projector, roads_c, + road_mats[target], layer["z"]) road_counts[source["source"]] = count elif geojson_dir and os.path.isdir(geojson_dir): for problem in catalog.check_layers(geojson_dir): diff --git a/blender/osmassets/catalog.py b/blender/osmassets/catalog.py index 92aea28..7c21615 100644 --- a/blender/osmassets/catalog.py +++ b/blender/osmassets/catalog.py @@ -200,7 +200,10 @@ MATERIALS = { "traffic_signal_active_green": {"kind": "solid", "name": "Traffic Signal Active Green", "color": (0.04, 0.82, 0.22), "roughness": 0.25, "cesium": {"base_color": (0.04, 0.82, 0.22), - "emission": ((0.04, 0.82, 0.22), 1.0)}}, + "emission": ((0.04, 0.82, 0.22), 1.0)}}, + "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)}}, } diff --git a/blender/osmassets/roads.py b/blender/osmassets/roads.py index 8d47019..5c3d0c9 100644 --- a/blender/osmassets/roads.py +++ b/blender/osmassets/roads.py @@ -7,7 +7,8 @@ from osmassets.geom import clip_polygon, feature_in_bounds, geometry_rings from osmassets.mesh import MeshBatch, add_polyline -def assemble_geojson_layer(path, layer_id, projector, collection, material, z): +def assemble_geojson_layer(path, layer_id, projector, collection, material, z, + property_filter=None): if not os.path.exists(path): return 0 with open(path, "r", encoding="utf-8") as handle: @@ -18,6 +19,8 @@ def assemble_geojson_layer(path, layer_id, projector, collection, material, z): xmax, ymax = projector.xy((b["max_lon"], b["max_lat"])) count = 0 for feature in data.get("features", []): + if property_filter and not property_filter(feature.get("properties", {})): + continue if not feature_in_bounds(feature, projector): continue for ring in geometry_rings(feature.get("geometry")): diff --git a/scripts/lib/native-road.js b/scripts/lib/native-road.js index fa8dbfb..2577278 100644 --- a/scripts/lib/native-road.js +++ b/scripts/lib/native-road.js @@ -15,6 +15,9 @@ const STOP_LINE_MAX_APPROACH_DISTANCE_METERS = 25; 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_COLORS = new Set(["yellow", "white"]); +const CENTER_LINE_PATTERNS = new Set(["dashed", "solid"]); function parseOsmRoads(xml) { const nodes = new Map(); @@ -148,6 +151,7 @@ function validateOverrides(value, model) { const roadIds = model ? new Set(model.roads.flatMap((road) => [road.id, road.sourceRoadId])) : null; const endpointIds = model ? new Set(model.endpoints.map((endpoint) => endpoint.id)) : null; const laneIds = model ? new Set(model.roads.flatMap((road) => Array.from({ length: road.laneCount }, (_, index) => `lane:${road.id}:${index + 1}`))) : null; + const segmentIds = model ? new Set(model.roads.map((road) => road.segmentId)) : null; for (const item of value.overrides) { if (!item || typeof item.id !== "string" || !item.id || ids.has(item.id)) throw new Error("Each override needs a unique id."); ids.add(item.id); @@ -160,6 +164,8 @@ function validateOverrides(value, model) { if (model && !connectionEndpointsCompatible(model, item.fromEndpointId, item.toEndpointId)) throw new Error("A manual junction connection must go from a road end to a nearby road start (within 35m)."); } else if (item.kind === "lane-connection") { if (typeof item.fromLaneId !== "string" || typeof item.toLaneId !== "string" || typeof item.enabled !== "boolean" || (laneIds && (!laneIds.has(item.fromLaneId) || !laneIds.has(item.toLaneId)))) throw new Error("Invalid lane connection override."); + } else 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 throw new Error(`Unsupported override kind: ${item.kind}`); } return { schema: OVERRIDE_SCHEMA, overrides: value.overrides }; @@ -247,7 +253,7 @@ function compileGeometry(model, overrides = { overrides: [] }) { } const lanes = compileLaneCenterlines(model, diagnostics, junctionPlans); const controls = compileControlMarkings(model, lanes, diagnostics); - const centerLines = compileCenterLines(model, junctionPlans, controls, diagnostics); + const centerLines = compileCenterLines(model, overrides, junctionPlans, controls, diagnostics); const markings = compileLaneMarkings(model, lanes, diagnostics, junctionPlans, controls); const sidewalks = compileSidewalkSurfaces(model, diagnostics, junctionPlans); const connectorResult = compileConnectors(model, lanes, diagnostics, overrides); @@ -256,7 +262,7 @@ function compileGeometry(model, overrides = { overrides: [] }) { return { roadSurface: { type: "FeatureCollection", features }, sidewalkSurface: { type: "FeatureCollection", features: sidewalks }, intersectionSurface: { type: "FeatureCollection", features: junctionFeatures }, laneCenterlines: { type: "FeatureCollection", features: lanes.features }, laneSeparators: { type: "FeatureCollection", features: markings.separators }, centerLines: { type: "FeatureCollection", features: centerLines }, directionArrows: { type: "FeatureCollection", features: markings.directionArrows }, turnArrows: { type: "FeatureCollection", features: markings.turnArrows }, crosswalks: { type: "FeatureCollection", features: controls.crosswalks }, vehicleStopLines: { type: "FeatureCollection", features: controls.stopLines }, connectors: { type: "FeatureCollection", features: connectorResult.features }, movements: connectorResult.movements, diagnostics }; } -function compileCenterLines(model, junctionPlans, controls, diagnostics) { +function compileCenterLines(model, overrides, junctionPlans, controls, diagnostics) { const features = []; const controlFeatures = [...controls.crosswalks, ...controls.stopLines]; const segments = new Map(); @@ -271,17 +277,25 @@ function compileCenterLines(model, junctionPlans, controls, diagnostics) { const line = trimLineAtJunctions(forward.centerline, forward.sourceNodeIds, junctionPlans); const length = lineLengthMeters(line); if (line.length < 2 || !Number.isFinite(length)) { diagnostics.push(diagnostic("warning", segmentId, forward.osmWayIds, "invalid-center-line", "双向道路无法生成有效道路中心虚线。", forward.centerline[0])); continue; } - for (let start = 0, dashIndex = 1; start + CENTER_LINE_DASH_LENGTH_METERS <= length; start += CENTER_LINE_DASH_LENGTH_METERS + CENTER_LINE_DASH_GAP_METERS, dashIndex += 1) { - const placement = pointAndAxisAlongLine(line, start + CENTER_LINE_DASH_LENGTH_METERS / 2); + const style = centerLineStyle(overrides, segmentId); + const gap = style.pattern === "solid" ? 0 : CENTER_LINE_DASH_GAP_METERS; + const markLength = CENTER_LINE_DASH_LENGTH_METERS + (style.pattern === "solid" ? CENTER_LINE_SOLID_OVERLAP_METERS : 0); + for (let start = 0, dashIndex = 1; start + markLength <= length; start += CENTER_LINE_DASH_LENGTH_METERS + gap, dashIndex += 1) { + const placement = pointAndAxisAlongLine(line, start + markLength / 2); if (!placement) continue; - const ring = rectangleAt(placement.point, placement.axis, [-placement.axis[1], placement.axis[0]], CENTER_LINE_DASH_LENGTH_METERS, 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; - 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: CENTER_LINE_DASH_LENGTH_METERS, dash_gap_m: CENTER_LINE_DASH_GAP_METERS, 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] } }); } } return features; } +function centerLineStyle(overrides, segmentId) { + const override = overrides.overrides.find((item) => item.kind === "center-line-style" && item.segmentId === segmentId); + return override ? { color: override.color, pattern: override.pattern } : { color: "yellow", pattern: "dashed" }; +} + function compileControlMarkings(model, lanes, diagnostics) { const crosswalks = []; const stopLines = []; const arrivalEndpointIds = new Set(model.connections.filter((connection) => connection.enabled).map((connection) => connection.fromEndpointId)); diff --git a/scripts/test-native-road.js b/scripts/test-native-road.js index e94ab5d..ed54a90 100644 --- a/scripts/test-native-road.js +++ b/scripts/test-native-road.js @@ -35,6 +35,11 @@ for (const feature of geometry.centerLines.features) { assert.ok(Math.abs(lengths[0] - .25) < .01 && Math.abs(lengths[1] - 2) < .01); assert.ok(feature.properties.segment_id && feature.properties.directional_road_ids && feature.properties.osm_way_ids && feature.properties.placement_rule); } +const centerLineOverride = validateOverrides({ schema: "native-road-overrides/v1", overrides: [{ id: "center-white-solid", kind: "center-line-style", segmentId: target.segmentId, color: "white", pattern: "solid" }] }, model); +const styledCenterLines = compileGeometry(model, centerLineOverride).centerLines.features.filter((feature) => feature.properties.segment_id === target.segmentId); +assert.ok(styledCenterLines.length > 0); +assert.ok(styledCenterLines.every((feature) => feature.properties.color === "white" && feature.properties.pattern === "solid" && feature.properties.effective_style === "white-solid" && feature.properties.dash_gap_m === 0)); +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/); assert.ok(geometry.directionArrows.features.every((feature) => feature.geometry.type === "Polygon" && feature.properties.provenance === "native-road-direction-arrow/v1" && feature.properties.placement_interval_meters === 32)); assert.ok(geometry.turnArrows.features.every((feature) => feature.geometry.type === "Polygon" && feature.properties.provenance === "native-road-turn-arrow/v1")); assert.ok(geometry.connectors.features.length > 0); @@ -56,6 +61,8 @@ assert.ok(controlGeometry.vehicleStopLines.features.every((feature) => feature.p 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])))); +const solidControlLines = compileGeometry(compileRoadModel(controlOsm, empty), { schema: "native-road-overrides/v1", overrides: [{ id: "solid-control", kind: "center-line-style", segmentId: controlGeometry.centerLines.features[0].properties.segment_id, color: "yellow", pattern: "solid" }] }).centerLines.features; +assert.ok(solidControlLines.every((dash) => ![...controlGeometry.crosswalks.features, ...controlGeometry.vehicleStopLines.features].some((control) => ringsOverlap(dash.geometry.coordinates[0], control.geometry.coordinates[0])))); const arrowControlOsm = ``; const arrowControlGeometry = compileGeometry(compileRoadModel(arrowControlOsm, empty)); assert.ok(arrowControlGeometry.turnArrows.features.length > 0); diff --git a/scripts/test-road-workbench.js b/scripts/test-road-workbench.js index 5d2117e..503e5c2 100644 --- a/scripts/test-road-workbench.js +++ b/scripts/test-road-workbench.js @@ -31,6 +31,10 @@ 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, /center-line-style/); +assert.match(app, /centerLineStyleInput\.onchange = stageSelectedCenterLineStyle/); +assert.match(html, /道路中心线样式/); +assert.match(html, /white-solid/); assert.match(app, /data-layer="controls" type="checkbox" checked> 斑马线与停止线/); assert.match(app, /controls: new VectorLayer/); assert.match(app, /state\.layers\.crosswalks/); diff --git a/scripts/workbench/app.js b/scripts/workbench/app.js index 49ee8b3..dca420c 100644 --- a/scripts/workbench/app.js +++ b/scripts/workbench/app.js @@ -29,6 +29,9 @@ const widthInput = document.querySelector("#width"); const lanesInput = document.querySelector("#lanes"); const leftInput = document.querySelector("#left"); const rightInput = document.querySelector("#right"); +const centerLineForm = document.querySelector("#center-line-form"); +const centerLineSegment = document.querySelector("#center-line-segment"); +const centerLineStyleInput = document.querySelector("#center-line-style"); const evidence = document.querySelector("#evidence"); const diagnostics = document.querySelector("#diagnostics"); const diagnosticFilters = document.querySelector("#diagnostic-filters"); @@ -59,6 +62,7 @@ let staged = []; let manualFromEndpoint = null; let diagnosticFilter = "all"; let scenePreview = false; +let selectedCenterLineSegment = 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 }) }) }), @@ -95,6 +99,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(); evidence.textContent = JSON.stringify({ 标线类型: markingType, 人行横道节点: crosswalk || stopLine ? feature.get("crossing_node_id") : null, @@ -104,6 +109,7 @@ select.on("select", ({ selected }) => { 方向: feature.get("direction"), 车道: feature.get("lane_id") || feature.get("lane_index") || `${feature.get("left_lane_index")} 与 ${feature.get("right_lane_index")} 之间`, 转向: turnArrow ? feature.get("maneuver") : null, + 样式: centerLine ? feature.get("effective_style") : null, 放置方法: feature.get("placement_method") || null, 道路内距离米: feature.get("distance_along_lane_meters") || null, 路口前距离米: feature.get("placement_distance_meters") || null, @@ -130,7 +136,7 @@ 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 centerLineStyle() { return new Style({ fill: new Fill({ color: "#f5be2a" }), stroke: new Stroke({ color: "#d29d16", width: .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) { // Split road features meet at OSM junction nodes. Their per-feature outlines // are editing aids, not physical seams, so scene mode must render fills only. @@ -244,7 +250,13 @@ 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 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("有未保存修改:道路中心线样式"); } +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; } saveButton.onclick = async () => { if (await saveStagedChanges()) message("已保存,点击“保存并重新生成”写入几何"); }; compileButton.onclick = async () => { if (!await saveStagedChanges()) return; message("正在保存修改并重新生成..."); const response = await fetch("/api/compile", { method: "POST" }); state = await response.json(); staged = []; updateDirtyState(); updateSources(); renderDiagnostics(); renderSummary(); selectRoad(selectedRoad ? state.compiled.model.roads.find((road) => road.id === selectedRoad.id) : null); message("已保存并重新生成"); }; diff --git a/scripts/workbench/index.html b/scripts/workbench/index.html index 5b7b537..1aee7ff 100644 --- a/scripts/workbench/index.html +++ b/scripts/workbench/index.html @@ -1,4 +1,4 @@ 道路编译工作台
道路编译工作台
-
+