From 5accc0a4b19c252b5197539c72adc61f56d807f3 Mon Sep 17 00:00:00 2001 From: que01 Date: Tue, 18 Aug 2026 11:46:19 +0800 Subject: [PATCH] feat: complete native traffic signal workflow --- .../08-13-native-road-compiler/task.json | 3 +- .../check.jsonl | 1 + .../design.md | 55 +++++++++++++++++++ .../implement.jsonl | 1 + .../implement.md | 31 +++++++++++ .../08-18-native-traffic-signal-parity/prd.md | 55 +++++++++++++++++++ .../task.json | 26 +++++++++ blender/generate_scene.py | 2 +- scripts/build-area.js | 1 + scripts/compile-native-roads.js | 10 +++- scripts/lib/area-config.js | 1 + scripts/lib/native-traffic-signals.js | 33 +++++++++++ scripts/lib/traffic-signals.js | 44 ++++++++++++--- scripts/road-workbench.js | 21 ++++++- scripts/test-road-workbench.js | 25 +++++++++ scripts/test-traffic-signals.js | 21 ++++++- scripts/workbench/app.js | 44 +++++++++++++-- 17 files changed, 355 insertions(+), 19 deletions(-) create mode 100644 .trellis/tasks/08-18-native-traffic-signal-parity/check.jsonl create mode 100644 .trellis/tasks/08-18-native-traffic-signal-parity/design.md create mode 100644 .trellis/tasks/08-18-native-traffic-signal-parity/implement.jsonl create mode 100644 .trellis/tasks/08-18-native-traffic-signal-parity/implement.md create mode 100644 .trellis/tasks/08-18-native-traffic-signal-parity/prd.md create mode 100644 .trellis/tasks/08-18-native-traffic-signal-parity/task.json create mode 100644 scripts/lib/native-traffic-signals.js diff --git a/.trellis/tasks/08-13-native-road-compiler/task.json b/.trellis/tasks/08-13-native-road-compiler/task.json index dbdcb68..0252bdb 100644 --- a/.trellis/tasks/08-13-native-road-compiler/task.json +++ b/.trellis/tasks/08-13-native-road-compiler/task.json @@ -22,7 +22,8 @@ "08-14-native-road-lane-markings", "08-17-native-road-control-markings", "08-17-native-road-center-lines", - "08-17-native-rounded-junctions" + "08-17-native-rounded-junctions", + "08-18-native-traffic-signal-parity" ], "parent": null, "relatedFiles": [], diff --git a/.trellis/tasks/08-18-native-traffic-signal-parity/check.jsonl b/.trellis/tasks/08-18-native-traffic-signal-parity/check.jsonl new file mode 100644 index 0000000..9dd3234 --- /dev/null +++ b/.trellis/tasks/08-18-native-traffic-signal-parity/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-18-native-traffic-signal-parity/design.md b/.trellis/tasks/08-18-native-traffic-signal-parity/design.md new file mode 100644 index 0000000..2d6d1f0 --- /dev/null +++ b/.trellis/tasks/08-18-native-traffic-signal-parity/design.md @@ -0,0 +1,55 @@ +# Design + +## Source Of Truth And Migration + +The native compiler owns a versioned area-local signal model and override +artifact. It is generated from OSM controls and native geometry inputs, then +edited by the Workbench without QGIS. The existing +`traffic_signal_assemblies.geojson` is a migration adapter: it can be imported +into the native model and exported for legacy QGIS/reimport workflows, but a +native compile never requires it to exist. + +The existing `buildTrafficSignalFeatures()` and validation functions remain the +compatibility implementation for initial generation and import/export. Imported +features retain their legacy `signal_uid` where valid; newly generated native +features use the same deterministic identity rule so downstream runtime IDs do +not fork. + +## Workbench API And Editing + +`GET /api/state` adds the normalized native signal model, source control +metadata, migration provenance, and derived runtime signal records. A signal +edit is represented as an atomic replacement of the validated native signal +override artifact through a dedicated signal save endpoint. A separate import +or export action handles the legacy QGIS collection; road overrides remain in +their existing file and schema. + +The browser uses stable `signal_uid` values. It supports: + +- generate: choose an OSM traffic-signal control and arm, then create the + deterministic assembly using the existing generator contract; +- move: update the Point coordinates while retaining stop-line/source fields; +- rotate: update `heading_deg` with normalized degrees; +- delete: remove the assembly from the editable collection; +- edit enabled state, display ID, mast reach, z offset, and phase group. + +Every save validates uniqueness, identity, finite geometry, source references, +and field ranges before an atomic write. Deleted features are absent from the +runtime output; disabled features remain in the editable/QGIS layer but are +omitted by `buildTrafficSignalsFromFeatures()`. + +## Delivery Flow + +The native road compile result includes signal assemblies and derived runtime +metadata without adding them to road geometry layers. Native Blender/Cesium +stages consume `traffic_signals.json` and dynamic GLB inputs generated directly +from the native model. The legacy QGIS adapter may materialize the old GeoJSON, +but it is not in the native build's critical path. + +## Compatibility And Rollback + +QGIS reimport continues to read/export the compatibility GeoJSON while the +legacy path remains unchanged. If native signal editing fails validation, the +previous atomic native override remains in place and the user receives a +field-level error. Rollback is selecting the legacy provider or exporting the +last native state to the QGIS adapter. diff --git a/.trellis/tasks/08-18-native-traffic-signal-parity/implement.jsonl b/.trellis/tasks/08-18-native-traffic-signal-parity/implement.jsonl new file mode 100644 index 0000000..9dd3234 --- /dev/null +++ b/.trellis/tasks/08-18-native-traffic-signal-parity/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-18-native-traffic-signal-parity/implement.md b/.trellis/tasks/08-18-native-traffic-signal-parity/implement.md new file mode 100644 index 0000000..29aec3a --- /dev/null +++ b/.trellis/tasks/08-18-native-traffic-signal-parity/implement.md @@ -0,0 +1,31 @@ +# Implementation Plan + +1. Define the native signal model/override artifact and migration adapter; + reuse existing generation, validation, and deterministic signal UID rules. +2. Add QGIS import/export commands that translate + `traffic_signal_assemblies.geojson` to/from the native artifact without + making native compile depend on QGIS. +3. Extend native compile/workbench state to expose signal assemblies, OSM + controls/arms, and derived runtime provenance. +4. Add validated atomic signal save operations for generate, move, rotate, + delete, and field edits; preserve legacy ID compatibility. +5. Add Workbench map styling, selection, editing controls, dirty state, save, + compile/reload, and clear error handling for signal assemblies. +6. Ensure native Blender/Cesium/preview stages consume runtime signal data + generated from the native model, while legacy stages remain compatible. +7. Add focused traffic-signal, Workbench, migration, and cross-layer round-trip tests; + run the existing legacy traffic-signal and preview suites. + +## Validation + +```bash +npm run test:traffic-signals +npm run test:preview-assets +npm run test:road-workbench +npm run road:compile -- --config config/areas/nantaizi-lake-innovation-valley.json +npm run road:check -- --config config/areas/nantaizi-lake-innovation-valley.json +``` + +Manual acceptance must cover native-only generate/move/rotate/delete -> save -> +recompile -> runtime JSON and preview, plus QGIS import/export compatibility; +disabled signals must be omitted from runtime output. diff --git a/.trellis/tasks/08-18-native-traffic-signal-parity/prd.md b/.trellis/tasks/08-18-native-traffic-signal-parity/prd.md new file mode 100644 index 0000000..c5e3d35 --- /dev/null +++ b/.trellis/tasks/08-18-native-traffic-signal-parity/prd.md @@ -0,0 +1,55 @@ +# Native traffic signal parity with QGIS + +## Goal + +Move traffic-signal ownership from the QGIS editing chain into the native road +compiler and Workbench. Existing QGIS signal assemblies remain import/export +compatibility data during migration, but native overrides become the long-term +source for generation, editing, and Blender/Cesium/preview delivery. + +## Confirmed Facts + +- QGIS currently edits `traffic_signal_assemblies.geojson`; this is the + migration input/output contract, not the desired long-term authority. +- `scripts/reimport-gpkg.js` validates and reimports that legacy layer. +- `scripts/build-area.js` currently derives runtime `traffic_signals.json` from + the edited assemblies; the native path must replace this dependency. +- Existing native road layers do not read traffic-signal artifacts. Historical + native Cesium work intentionally omitted signal runtime assets. +- Stable signal identity and editable fields already include `signal_uid`, + enabled state, source/control/approach IDs, arm direction, pose, mast reach, + and phase-group/runtime data. + +## Requirements + +- R1: Native compile/workbench must own a versioned area-local signal model and + override artifact, with an explicit one-time/import compatibility path from + existing QGIS assemblies. +- R2: Workbench state must expose signal provenance and the existing editable + signal fields using stable IDs, and support generating, moving, rotating, + and deleting signal assemblies. +- R3: Native compile and preview delivery must generate runtime signal data + directly from the native model, preserving enabled/disabled state, arm + direction/pose, and phase-group data without requiring QGIS. +- R4: A compatibility adapter must import/export the existing QGIS assembly + format during migration and preserve legacy signal IDs where possible. + +## Scope Boundary + +- Native signal model and overrides are the long-term source of truth. +- QGIS GeoJSON/GeoPackage support is transitional compatibility only; do not + make native compile depend on QGIS or regenerate native edits from QGIS. +- Do not redesign signal geometry, timing logic, or vehicle behavior in this + task. + +## Acceptance Criteria + +- [ ] A native compile/reopen round trip preserves edited signal assemblies, + stable IDs, enabled state, and provenance without QGIS running. +- [ ] The Road Workbench can generate, inspect, move, rotate, and delete signal + assemblies, then save durable edits without breaking QGIS reimport. +- [ ] Native Blender/Cesium/preview consume runtime signal data generated from + the native model, including disabled signals being omitted from runtime. +- [ ] QGIS import/export compatibility and legacy build stages continue to + pass while the migration adapter exists. +- [ ] Nantaizi has documented native-only and QGIS-imported round trips. diff --git a/.trellis/tasks/08-18-native-traffic-signal-parity/task.json b/.trellis/tasks/08-18-native-traffic-signal-parity/task.json new file mode 100644 index 0000000..b4cec12 --- /dev/null +++ b/.trellis/tasks/08-18-native-traffic-signal-parity/task.json @@ -0,0 +1,26 @@ +{ + "id": "native-traffic-signal-parity", + "name": "native-traffic-signal-parity", + "title": "Align native traffic signals with QGIS", + "description": "", + "status": "in_progress", + "dev_type": null, + "scope": null, + "package": null, + "priority": "P2", + "creator": "dingkang", + "assignee": "dingkang", + "createdAt": "2026-08-18", + "completedAt": null, + "branch": null, + "base_branch": "feature/native-road-compiler", + "worktree_path": null, + "commit": null, + "pr_url": null, + "subtasks": [], + "children": [], + "parent": "08-13-native-road-compiler", + "relatedFiles": [], + "notes": "", + "meta": {} +} \ No newline at end of file diff --git a/blender/generate_scene.py b/blender/generate_scene.py index cc59afb..a337af5 100644 --- a/blender/generate_scene.py +++ b/blender/generate_scene.py @@ -835,7 +835,7 @@ def build(args): _roads.assemble_osm_fallback( ways, projector, roads_c, road_mats["road_surface"]) - traffic_signal_path = os.path.join(geojson_dir or "", "traffic_signals.json") + traffic_signal_path = args.get("traffic_signals") or os.path.join(geojson_dir or "", "traffic_signals.json") dynamic_signal_objects = 0 if os.path.exists(traffic_signal_path): try: diff --git a/scripts/build-area.js b/scripts/build-area.js index 5acd810..ef58ada 100755 --- a/scripts/build-area.js +++ b/scripts/build-area.js @@ -247,6 +247,7 @@ function buildBlenderScene(area, roadProvider) { ]; if (roadProvider === "native") { blenderArgs.push("--native-road", area.outputs.nativeRoadDir); + blenderArgs.push("--traffic-signals", path.join(area.outputs.nativeRoadDir, "traffic-signals.json")); } else { blenderArgs.push("--geojson", area.outputs.geojsonDir); } diff --git a/scripts/compile-native-roads.js b/scripts/compile-native-roads.js index 65863c2..b1d2eb6 100644 --- a/scripts/compile-native-roads.js +++ b/scripts/compile-native-roads.js @@ -5,6 +5,7 @@ const fs = require("fs"); const path = require("path"); const { readAreaConfig } = require("./lib/area-config"); const { compileRoadModel, compileGeometry, loadOverrides, validateOverrides, writeJsonAtomic } = require("./lib/native-road"); +const { loadOrGenerate, runtime } = require("./lib/native-traffic-signals"); const repoRoot = path.resolve(__dirname, ".."); @@ -25,14 +26,19 @@ function compileArea(configPath) { validateOverrides(overrides, model); fs.mkdirSync(area.outputs.pipelineDir, { recursive: true }); const compiled = compileGeometry(model, overrides, { edgeLines: area.nativeRoad.edgeLines }); + const signalDocument = loadOrGenerate(area.outputs.nativeTrafficSignals, fs.readFileSync(area.input, "utf8"), compiled.vehicleStopLines, compiled.intersectionSurface); + const signalRuntime = runtime(signalDocument); + // Persist validation normalization, including one-time legacy heading migration. + writeJsonAtomic(area.outputs.nativeTrafficSignals, signalDocument); const staging = fs.mkdtempSync(path.join(area.outputs.pipelineDir, "native-road-")); try { const result = { schema: "native-road-compiled/v1", areaId: area.id, - source: { osm: area.input, overrides: area.outputs.nativeRoadOverrides }, + source: { osm: area.input, overrides: area.outputs.nativeRoadOverrides, trafficSignals: area.outputs.nativeTrafficSignals }, model: { roads: model.roads, endpoints: model.endpoints, connections: model.connections }, movements: compiled.movements, + trafficSignals: { assemblies: "traffic-signal-assemblies.json", runtime: "traffic-signals.json", count: signalRuntime.signals.length }, diagnostics: compiled.diagnostics, layers: { roadSurface: "layers/road_surface.geojson", edgeLines: "layers/edge_lines.geojson", sidewalkSurface: "layers/sidewalk_surface.geojson", intersectionSurface: "layers/intersection_surface.geojson", laneCenterlines: "layers/lane_centerlines.geojson", laneSeparators: "layers/lane_separators.geojson", centerLines: "layers/center_lines.geojson", directionArrows: "layers/direction_arrows.geojson", turnArrows: "layers/turn_arrows.geojson", crosswalks: "layers/crosswalks.geojson", vehicleStopLines: "layers/vehicle_stop_lines.geojson", connectors: "layers/connectors.geojson" }, }; @@ -40,6 +46,8 @@ function compileArea(configPath) { writeJsonAtomic(path.join(staging, "compiled.json"), result); writeJsonAtomic(path.join(staging, "diagnostics.json"), { schema: "native-road-diagnostics/v1", diagnostics: compiled.diagnostics }); writeJsonAtomic(path.join(staging, "comparison.json"), comparison); + writeJsonAtomic(path.join(staging, "traffic-signal-assemblies.json"), signalDocument.assemblies); + writeJsonAtomic(path.join(staging, "traffic-signals.json"), signalRuntime); writeJsonAtomic(path.join(staging, "layers", "road_surface.geojson"), compiled.roadSurface); writeJsonAtomic(path.join(staging, "layers", "edge_lines.geojson"), compiled.edgeLines); writeJsonAtomic(path.join(staging, "layers", "sidewalk_surface.geojson"), compiled.sidewalkSurface); diff --git a/scripts/lib/area-config.js b/scripts/lib/area-config.js index 5a83302..004c996 100644 --- a/scripts/lib/area-config.js +++ b/scripts/lib/area-config.js @@ -38,6 +38,7 @@ function normalizeAreaConfig(raw, options = {}) { geojsonDir, nativeRoadDir, nativeRoadOverrides: path.resolve(outputOverrides.nativeRoadOverrides || path.join(areaDir, "native-road-overrides.json")), + nativeTrafficSignals: path.resolve(outputOverrides.nativeTrafficSignals || path.join(areaDir, "native-traffic-signals.json")), gpkg: path.resolve(outputOverrides.gpkg || path.join(areaDir, `${fileStem}.gpkg`)), qgisProject: path.resolve(outputOverrides.qgisProject || path.join(areaDir, `${fileStem}.qgz`)), qgisPreview: path.resolve(outputOverrides.qgisPreview || path.join(areaDir, `${fileStem}-preview.png`)), diff --git a/scripts/lib/native-traffic-signals.js b/scripts/lib/native-traffic-signals.js new file mode 100644 index 0000000..8c9f774 --- /dev/null +++ b/scripts/lib/native-traffic-signals.js @@ -0,0 +1,33 @@ +"use strict"; + +const fs = require("fs"); +const { parseOsm } = require("./osm"); +const { + buildTrafficSignalFeatures, + buildTrafficSignalsFromFeatures, + validateTrafficSignalFeatures, + validateTrafficSignalSourceReferences, +} = require("./traffic-signals"); + +const SCHEMA = "native-traffic-signals/v1"; + +function loadOrGenerate(file, osmText, stopLines, intersections) { + if (fs.existsSync(file)) return validateDocument(JSON.parse(fs.readFileSync(file, "utf8")), osmText); + return generate(osmText, stopLines, intersections); +} + +function generate(osmText, stopLines, intersections) { + const controls = parseOsm(osmText).trafficSignalControls; + return { schema: SCHEMA, provenance: "generated:osm-controls", assemblies: buildTrafficSignalFeatures(stopLines, intersections, controls) }; +} + +function validateDocument(value, osmText) { + if (value?.schema !== SCHEMA) throw new Error(`Expected ${SCHEMA} signal document`); + const assemblies = validateTrafficSignalFeatures(value.assemblies); + if (osmText) validateTrafficSignalSourceReferences(assemblies, parseOsm(osmText).trafficSignalControls); + return { schema: SCHEMA, provenance: value.provenance || "native", assemblies }; +} + +function runtime(document) { return buildTrafficSignalsFromFeatures(document.assemblies); } + +module.exports = { SCHEMA, generate, loadOrGenerate, validateDocument, runtime }; diff --git a/scripts/lib/traffic-signals.js b/scripts/lib/traffic-signals.js index 859cd01..7c31ac2 100644 --- a/scripts/lib/traffic-signals.js +++ b/scripts/lib/traffic-signals.js @@ -59,7 +59,11 @@ function buildTrafficSignalFeatures(stopLines, intersections, controls = []) { properties: { signal_uid: signalUid, display_id: signalUid, control_id: String(control.id), approach_id: approachId, source_way_id: sourceWayId, - heading_deg: candidate.headingDegrees, phase_group: groups[index], + // These are independent assembly controls. heading_deg remains a + // migration hint for older native documents only. + mast_heading_deg: normalizeDegrees(candidate.headingDegrees - 90), + face_heading_deg: normalizeDegrees(candidate.headingDegrees + 180), + phase_group: groups[index], mast_reach_m: MAST_REACH_METERS, stop_lon: candidate.center[0], stop_lat: candidate.center[1], enabled: true, z_offset_m: 0, @@ -114,13 +118,30 @@ function validateTrafficSignalFeatures(collection) { if (!approachId.startsWith(`${sourceWayId}:`)) throw new Error(`${label}: approach_id does not match source_way_id`); const expectedUid = `osm-${controlId}-${approachId.replace(":", "-")}`; if (signalUid !== expectedUid) throw new Error(`${label}: signal_uid does not match source identity (expected '${expectedUid}')`); + const legacyHeading = input.heading_deg == null || input.heading_deg === "" ? null : normalizeDegrees(number("heading_deg")); + if (legacyHeading == null && (input.mast_heading_deg == null || input.mast_heading_deg === "")) { + throw new Error(`${label}: missing mast_heading_deg`); + } + if (legacyHeading == null && (input.face_heading_deg == null || input.face_heading_deg === "")) { + throw new Error(`${label}: missing face_heading_deg`); + } + const mastHeading = input.mast_heading_deg == null || input.mast_heading_deg === "" + ? normalizeDegrees((legacyHeading == null ? 0 : legacyHeading) - 90) + : normalizeDegrees(number("mast_heading_deg")); + const faceHeading = input.face_heading_deg == null || input.face_heading_deg === "" + ? normalizeDegrees((legacyHeading == null ? 0 : legacyHeading) + 180) + : normalizeDegrees(number("face_heading_deg")); return { type: "Feature", geometry: { type: "Point", coordinates: feature.geometry.coordinates.slice(0, 2).map(Number) }, properties: { ...input, signal_uid: signalUid, display_id: displayId, control_id: controlId, approach_id: approachId, - source_way_id: sourceWayId, heading_deg: normalizeDegrees(number("heading_deg")), + source_way_id: sourceWayId, + // Retain the legacy value only for migration compatibility. Runtime + // geometry is entirely defined by mast_heading_deg and face_heading_deg. + heading_deg: legacyHeading, + mast_heading_deg: mastHeading, face_heading_deg: faceHeading, phase_group: phaseGroup, mast_reach_m: number("mast_reach_m", { min: 0.1, max: 30 }), stop_lon: number("stop_lon", { min: -180, max: 180 }), stop_lat: number("stop_lat", { min: -90, max: 90 }), @@ -136,16 +157,20 @@ function buildTrafficSignalsFromFeatures(collection) { const signals = normalized.features.filter((feature) => feature.properties.enabled).map((feature) => { const p = feature.properties; const point = feature.geometry.coordinates; - const axis = headingVector(p.heading_deg); + const mastAxis = headingVector(p.mast_heading_deg); return { id: p.signal_uid, signalUid: p.signal_uid, displayId: p.display_id, nodeKey: signalNodeKey(p.signal_uid), controlId: p.control_id, approachId: p.approach_id, sourceWayId: p.source_way_id, phaseGroup: p.phase_group, longitude: point[0], latitude: point[1], stopLongitude: p.stop_lon, stopLatitude: p.stop_lat, - headingDegrees: p.heading_deg, mastReachMeters: p.mast_reach_m, + // Existing Blender readers require headingDegrees. It is a compatibility + // alias only; the independent mast/face fields below define all geometry. + headingDegrees: p.heading_deg == null ? p.mast_heading_deg : p.heading_deg, + mastHeadingDegrees: p.mast_heading_deg, + faceHeadingDegrees: p.face_heading_deg, mastReachMeters: p.mast_reach_m, zOffsetMeters: p.z_offset_m, - pose: buildSignalPose(point, axis, p.mast_reach_m, p.z_offset_m), + pose: buildSignalPose(point, mastAxis, p.face_heading_deg, p.mast_reach_m, p.z_offset_m), }; }); return { version: 3, layout: SIGNAL_LAYOUT, signals }; @@ -235,12 +260,13 @@ function phaseGroups(arms) { groups[main[0]] = 0; groups[main[1]] = 0; return groups; } -function buildSignalPose(pole, axis, mastReach, zOffset = 0) { - const lateral = [axis[1], -axis[0]]; const face = [-axis[0], -axis[1]]; - const head = moveMeters(pole, lateral, -mastReach); const faceHeadingDegrees = Math.atan2(face[0], face[1]) * 180 / Math.PI; +function buildSignalPose(pole, mastAxis, faceHeadingDegrees, mastReach, zOffset = 0) { + const face = headingVector(faceHeadingDegrees); + const head = moveMeters(pole, mastAxis, mastReach); const position = (point, height) => ({ longitude: point[0], latitude: point[1], height: height + zOffset }); const lensPoint = moveMeters(head, face, SIGNAL_LAYOUT.lensFaceOffsetMeters); - const board = moveMeters(moveMeters(head, lateral, SIGNAL_LAYOUT.countdownLateralMeters), face, SIGNAL_LAYOUT.countdownFaceOffsetMeters); + const faceRight = [-face[1], face[0]]; + const board = moveMeters(moveMeters(head, faceRight, SIGNAL_LAYOUT.countdownLateralMeters), face, SIGNAL_LAYOUT.countdownFaceOffsetMeters); return { pole: position(pole, 0), arm: { from: position(pole, SIGNAL_LAYOUT.mastHeightMeters), to: position(head, SIGNAL_LAYOUT.mastHeightMeters) }, head: { ...position(head, SIGNAL_LAYOUT.headCenterHeightMeters), faceHeadingDegrees }, lenses: ["red", "yellow", "green"].map((state, index) => ({ state, ...position(lensPoint, SIGNAL_LAYOUT.headCenterHeightMeters + SIGNAL_LAYOUT.lensVerticalOffsetsMeters[index]) })), countdown: { ...position(board, SIGNAL_LAYOUT.mastHeightMeters), faceHeadingDegrees } }; } diff --git a/scripts/road-workbench.js b/scripts/road-workbench.js index 120e5a9..17c868b 100644 --- a/scripts/road-workbench.js +++ b/scripts/road-workbench.js @@ -6,6 +6,7 @@ const http = require("http"); const path = require("path"); const { readAreaConfig } = require("./lib/area-config"); const { loadOverrides, validateOverrides, writeJsonAtomic } = require("./lib/native-road"); +const { generate, validateDocument, runtime } = require("./lib/native-traffic-signals"); const { compileArea, parseArgs } = require("./compile-native-roads"); const repoRoot = path.resolve(__dirname, ".."); @@ -32,6 +33,20 @@ function handle(request, response, area, configPath) { if (request.method === "GET" && url.pathname === "/app.css") return sendFile(response, path.join(repoRoot, "scripts", "workbench", "app.css"), "text/css; charset=utf-8"); if (request.method === "GET" && url.pathname.startsWith("/vendor/")) return sendVendorFile(response, url.pathname); if (request.method === "GET" && url.pathname === "/api/state") return sendJson(response, 200, state(area)); + if (request.method === "POST" && url.pathname === "/api/traffic-signals") return readBody(request).then((body) => { + const document = validateDocument(body, fs.readFileSync(area.input, "utf8")); + writeJsonAtomic(area.outputs.nativeTrafficSignals, document); + sendJson(response, 200, { ok: true, trafficSignals: document, runtime: runtime(document) }); + }).catch((error) => sendJson(response, 400, { ok: false, error: error.message })); + if (request.method === "POST" && url.pathname === "/api/traffic-signals/generate") return Promise.resolve().then(() => { + const compiled = readCompiled(area); + const generated = generate(fs.readFileSync(area.input, "utf8"), readLayer(path.join(area.outputs.nativeRoadDir, "layers", "vehicle_stop_lines.geojson")), readLayer(path.join(area.outputs.nativeRoadDir, "layers", "intersection_surface.geojson"))); + const current = validateDocument(readJson(area.outputs.nativeTrafficSignals), fs.readFileSync(area.input, "utf8")); + const present = new Set(current.assemblies.features.map((feature) => feature.properties.signal_uid)); + current.assemblies.features.push(...generated.assemblies.features.filter((feature) => !present.has(feature.properties.signal_uid))); + writeJsonAtomic(area.outputs.nativeTrafficSignals, current); + sendJson(response, 200, { ok: true, trafficSignals: current, runtime: runtime(current), generated: generated.assemblies.features.length, compiled: Boolean(compiled) }); + }).catch((error) => sendJson(response, 400, { ok: false, error: error.message })); if (request.method === "POST" && url.pathname === "/api/overrides") return readBody(request).then((body) => { const compiled = readCompiled(area); const overrides = validateOverrides(body, { roads: compiled.model.roads, endpoints: compiled.model.endpoints }); @@ -48,7 +63,11 @@ function handle(request, response, area, configPath) { function state(area) { const nativeDir = area.outputs.nativeRoadDir; const osm2streetsRoadSurface = path.join(area.outputs.geojsonDir, "road_surface.geojson"); - return { areaId: area.id, compiled: readCompiled(area), overrides: loadOverrides(area.outputs.nativeRoadOverrides), comparison: readJson(path.join(nativeDir, "comparison.json")), layers: { nativeRoadSurface: readLayer(path.join(nativeDir, "layers", "road_surface.geojson")), edgeLines: readLayer(path.join(nativeDir, "layers", "edge_lines.geojson")), nativeSidewalkSurface: readLayer(path.join(nativeDir, "layers", "sidewalk_surface.geojson")), nativeIntersectionSurface: readLayer(path.join(nativeDir, "layers", "intersection_surface.geojson")), laneCenterlines: readLayer(path.join(nativeDir, "layers", "lane_centerlines.geojson")), laneSeparators: readLayer(path.join(nativeDir, "layers", "lane_separators.geojson")), centerLines: readLayer(path.join(nativeDir, "layers", "center_lines.geojson")), directionArrows: readLayer(path.join(nativeDir, "layers", "direction_arrows.geojson")), turnArrows: readLayer(path.join(nativeDir, "layers", "turn_arrows.geojson")), crosswalks: readLayer(path.join(nativeDir, "layers", "crosswalks.geojson")), vehicleStopLines: readLayer(path.join(nativeDir, "layers", "vehicle_stop_lines.geojson")), connectors: readLayer(path.join(nativeDir, "layers", "connectors.geojson")), osm2streetsRoadSurface: fs.existsSync(osm2streetsRoadSurface) ? readLayer(osm2streetsRoadSurface) : null } }; + const trafficSignals = fs.existsSync(area.outputs.nativeTrafficSignals) + ? validateDocument(readJson(area.outputs.nativeTrafficSignals), fs.readFileSync(area.input, "utf8")) + : { schema: "native-traffic-signals/v1", provenance: "empty", assemblies: { type: "FeatureCollection", features: [] } }; + const trafficRuntime = runtime(trafficSignals); + return { areaId: area.id, compiled: readCompiled(area), overrides: loadOverrides(area.outputs.nativeRoadOverrides), trafficSignals, trafficRuntime, comparison: readJson(path.join(nativeDir, "comparison.json")), layers: { nativeRoadSurface: readLayer(path.join(nativeDir, "layers", "road_surface.geojson")), edgeLines: readLayer(path.join(nativeDir, "layers", "edge_lines.geojson")), nativeSidewalkSurface: readLayer(path.join(nativeDir, "layers", "sidewalk_surface.geojson")), nativeIntersectionSurface: readLayer(path.join(nativeDir, "layers", "intersection_surface.geojson")), laneCenterlines: readLayer(path.join(nativeDir, "layers", "lane_centerlines.geojson")), laneSeparators: readLayer(path.join(nativeDir, "layers", "lane_separators.geojson")), centerLines: readLayer(path.join(nativeDir, "layers", "center_lines.geojson")), directionArrows: readLayer(path.join(nativeDir, "layers", "direction_arrows.geojson")), turnArrows: readLayer(path.join(nativeDir, "layers", "turn_arrows.geojson")), crosswalks: readLayer(path.join(nativeDir, "layers", "crosswalks.geojson")), vehicleStopLines: readLayer(path.join(nativeDir, "layers", "vehicle_stop_lines.geojson")), connectors: readLayer(path.join(nativeDir, "layers", "connectors.geojson")), osm2streetsRoadSurface: fs.existsSync(osm2streetsRoadSurface) ? readLayer(osm2streetsRoadSurface) : null } }; } function readCompiled(area) { return readJson(path.join(area.outputs.nativeRoadDir, "compiled.json")); } function readJson(file) { return JSON.parse(fs.readFileSync(file, "utf8")); } diff --git a/scripts/test-road-workbench.js b/scripts/test-road-workbench.js index f280587..3b88826 100644 --- a/scripts/test-road-workbench.js +++ b/scripts/test-road-workbench.js @@ -52,4 +52,29 @@ assert.match(app, /state\.layers\.vehicleStopLines/); assert.match(app, /native-road-crosswalk\/v1/); assert.match(app, /native-road-stop-line\/v1/); assert.match(app, /term\.textContent = label; detail\.textContent = value; summary\.append\(term, detail\)/); +assert.match(app, /原生红绿灯/); +assert.match(app, /function selectSignal\(feature\)/); +assert.match(app, /\/api\/traffic-signals/); +assert.match(app, /从 OSM 生成缺失信号灯/); +assert.match(app, /data-layer="signals" type="checkbox" checked> 红绿灯设施/); +assert.match(app, /红绿灯设施/); +assert.match(app, /signalAssemblyStyle/); +assert.match(app, /signal_component: "mast"/); +assert.match(app, /signal_component: "face"/); +assert.match(app, /signal_component: "head"/); +assert.match(app, /faceHeadingDegrees/); +assert.match(app, /横杆方向(度)/); +assert.match(app, /横杆长度(米)/); +assert.match(app, /灯面朝向(度)/); +assert.match(app, /检查信号灯 道路外缘线'; const controlsToggle = document.createElement("label"); controlsToggle.innerHTML = ' 斑马线与停止线'; -document.querySelector('[data-layer="lanes"]').closest("label").after(directionArrowsToggle, markingsToggle, centerLinesToggle, edgeLinesToggle, controlsToggle); +const signalsToggle = document.createElement("label"); +signalsToggle.innerHTML = ' 红绿灯设施'; +document.querySelector('[data-layer="lanes"]').closest("label").after(directionArrowsToggle, markingsToggle, centerLinesToggle, edgeLinesToggle, controlsToggle, signalsToggle); let state; let selectedRoad = null; @@ -70,6 +73,13 @@ let scenePreview = false; let selectedCenterLineSegment = null; let selectedLaneSeparator = null; let selectedEdgeLine = null; +let selectedSignal = null; +const signalPanel = document.createElement("section"); +signalPanel.innerHTML = '

原生红绿灯

'; +document.querySelector(".inspector").insertBefore(signalPanel, document.querySelector(".inspector details")); +const signalForm = signalPanel.querySelector("form"); +const signalOutput = signalForm.querySelector("output"); +const signalPicker = signalPanel.querySelector('[name="signal-picker"]'); 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 }) }) }), @@ -82,18 +92,20 @@ const layers = { markings: new VectorLayer({ source: source(), style: markingStyle }), centerLines: new VectorLayer({ source: source(), style: centerLineStyle }), controls: new VectorLayer({ source: source(), style: markingStyle }), + signals: new VectorLayer({ source: source(), style: signalAssemblyStyle, zIndex: 30 }), osmDirection: new VectorLayer({ source: source(), style: (feature) => new Style({ image: new RegularShape({ points: 3, radius: 9, rotation: feature.get("rotation"), fill: new Fill({ color: "#006e91" }), stroke: new Stroke({ color: "#fff", width: 1.5 }) }) }), zIndex: 11 }), connectors: new VectorLayer({ source: source(), style: (feature) => effectiveConnectorEnabled(feature.getProperties()) ? new Style({ stroke: new Stroke({ color: roadIdFromLane(feature.get("from_lane_id")) === selectedRoad?.id ? "#d1226f" : "#ad3a76", width: roadIdFromLane(feature.get("from_lane_id")) === selectedRoad?.id ? 4 : 2, lineDash: [7, 5] }) }) : null }), diagnostics: new VectorLayer({ source: source(), style: (feature) => new Style({ image: new CircleStyle({ radius: 6, fill: new Fill({ color: feature.get("severity") === "error" ? "#bf3b2e" : "#d49318" }), stroke: new Stroke({ color: "#fff", width: 1 }) }) }) }), selectedRoad: new VectorLayer({ source: source(), style: new Style({ stroke: new Stroke({ color: "#00a5cf", width: 8 }) }), zIndex: 10 }), selectedMovement: new VectorLayer({ source: source(), style: new Style({ stroke: new Stroke({ color: "#f0b323", width: 6 }) }), zIndex: 12 }), }; -const map = new Map({ target: "map", layers: [layers.reference, layers.native, layers.edgeLines, layers.sidewalks, layers.osm, layers.lanes, layers.directionArrows, layers.markings, layers.centerLines, layers.controls, layers.connectors, layers.diagnostics, layers.selectedRoad, layers.osmDirection, layers.selectedMovement], view: new View({ center: [0, 0], zoom: 2 }) }); -const select = new Select({ condition: click, layers: (layer) => manualFromEndpoint ? layer === layers.osm : [layers.osm, layers.lanes, layers.directionArrows, layers.markings, layers.centerLines, layers.edgeLines, layers.controls, layers.connectors, layers.native, layers.diagnostics].includes(layer), hitTolerance: 8, style: new Style({ stroke: new Stroke({ color: "#005e89", width: 5 }), fill: new Fill({ color: "rgba(0, 94, 137, .18)" }) }) }); +const map = new Map({ target: "map", layers: [layers.reference, layers.native, layers.edgeLines, layers.sidewalks, layers.osm, layers.lanes, layers.directionArrows, layers.markings, layers.centerLines, layers.controls, layers.signals, layers.connectors, layers.diagnostics, layers.selectedRoad, layers.osmDirection, layers.selectedMovement], view: new View({ center: [0, 0], zoom: 2 }) }); +const select = new Select({ condition: click, layers: (layer) => manualFromEndpoint ? layer === layers.osm : [layers.osm, layers.lanes, layers.directionArrows, layers.markings, layers.centerLines, layers.edgeLines, layers.controls, layers.signals, layers.connectors, layers.native, layers.diagnostics].includes(layer), hitTolerance: 12, style: null }); map.addInteraction(select); select.on("select", ({ selected }) => { const feature = selected[0]; if (!feature) return; + if (feature.get("signal_uid")) return selectSignal(poleFeatureForSignal(feature.get("signal_uid")) || feature); if (manualFromEndpoint) return chooseManualTarget(roadForFeature(feature)); const junction = junctionForFeature(feature); if (junction) return selectJunction(junction); @@ -145,6 +157,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(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 signalAssemblyStyle(feature) { const component = feature.get("signal_component"); if (component === "mast") return [new Style({ stroke: new Stroke({ color: "#fff", width: 9 }) }), new Style({ stroke: new Stroke({ color: "#007f99", width: 5 }) })]; if (component === "face") return [new Style({ stroke: new Stroke({ color: "#fff", width: 7 }) }), new Style({ stroke: new Stroke({ color: "#df2435", width: 3 }) })]; if (component === "head") { const heading = Number(feature.get("face_heading_deg")) || 0; return new Style({ image: new RegularShape({ points: 3, radius: 8, rotation: heading * Math.PI / 180, fill: new Fill({ color: "#df2435" }), stroke: new Stroke({ color: "#fff", width: 2 }) }) }); } return new Style({ image: new RegularShape({ points: 4, radius: 6, angle: Math.PI / 4, fill: new Fill({ color: "#263630" }), stroke: new Stroke({ color: "#fff", width: 2 }) }) }); } 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 @@ -183,6 +196,7 @@ function updateSources() { layers.markings.getSource().clear(); layers.markings.getSource().addFeatures([...readFeatures(state.layers.laneSeparators), ...readFeatures(state.layers.turnArrows)]); layers.centerLines.getSource().clear(); layers.centerLines.getSource().addFeatures(readFeatures(state.layers.centerLines)); layers.controls.getSource().clear(); layers.controls.getSource().addFeatures([...readFeatures(state.layers.crosswalks), ...readFeatures(state.layers.vehicleStopLines)]); + const signalFeatures = readFeatures(state.trafficSignals?.assemblies || { type: "FeatureCollection", features: [] }); const armFeatures = []; const faceFeatures = []; const headFeatures = []; for (const signal of state.trafficRuntime?.signals || []) { const arm = signal.pose?.arm; const head = signal.pose?.head; if (!arm || !head) continue; const properties = { signal_uid: signal.id }; const headPoint = fromLonLat([head.longitude, head.latitude]); const radians = Number(head.faceHeadingDegrees) * Math.PI / 180; const faceEnd = [headPoint[0] + Math.sin(radians) * 2.5, headPoint[1] + Math.cos(radians) * 2.5]; armFeatures.push(new Feature({ geometry: new LineString([fromLonLat([arm.from.longitude, arm.from.latitude]), fromLonLat([arm.to.longitude, arm.to.latitude])]), signal_component: "mast", ...properties })); faceFeatures.push(new Feature({ geometry: new LineString([headPoint, faceEnd]), signal_component: "face", ...properties })); headFeatures.push(new Feature({ geometry: new Point(faceEnd), signal_component: "head", face_heading_deg: head.faceHeadingDegrees, ...properties })); } layers.signals.getSource().clear(); layers.signals.getSource().addFeatures([...armFeatures, ...faceFeatures, ...signalFeatures, ...headFeatures]); const pickerValue = signalPicker.value; signalPicker.replaceChildren(new Option("选择设施", "")); signalFeatures.forEach((feature) => signalPicker.add(new Option(feature.get("display_id") || feature.get("signal_uid"), feature.get("signal_uid")))); signalPicker.value = pickerValue; layers.connectors.getSource().clear(); layers.connectors.getSource().addFeatures(readFeatures(state.layers.connectors)); layers.diagnostics.getSource().clear(); layers.diagnostics.getSource().addFeatures(readFeatures({ type: "FeatureCollection", features: state.compiled.diagnostics.filter((item) => item.geometry).map(({ geometry, ...properties }) => ({ type: "Feature", properties, geometry })) })); const extent = layers.osm.getSource().getExtent(); if (Number.isFinite(extent[0])) map.getView().fit(extent, { padding: [48, 48, 48, 48], maxZoom: 19 }); @@ -209,6 +223,23 @@ function selectJunction(feature) { junctionDetail.textContent = JSON.stringify({ OSM节点: properties.osm_node_id, 类型: properties.kind === "t" ? "T字路口" : "十字路口", 参与方向道路: roads.map((road) => ({ 道路: roadLabel(road), OSM道路: road.osmWayIds, 节点顺序: road.sourceNodeIds })), 构面规则: properties.rule, 边界策略: properties.boundary_mode, 基础截面面积平方米: properties.approach_area_m2, 最终路口面积平方米: properties.surface_area_m2, 外缘扩张倍率: properties.expansion_ratio, 路口退让距离米: properties.cutback_m, 行驶动作数: properties.movement_count, 已绘制连接数: properties.connector_count }, null, 2); message(`已选中路口:OSM 节点 ${properties.osm_node_id}`); } +function selectSignal(feature) { + selectedSignal = feature.get("signal_uid"); const p = feature.getProperties(); signalPicker.value = selectedSignal; + const [x, y] = feature.getGeometry().getCoordinates(); + map.getView().fit([x - 25, y - 25, x + 25, y + 25], { padding: [80, 80, 80, 360], maxZoom: 22, duration: 250 }); + signalForm.hidden = false; signalOutput.textContent = `${p.display_id || p.signal_uid}(${p.signal_uid})`; + signalForm.lon.value = feature.getGeometry().clone().transform("EPSG:3857", "EPSG:4326").getCoordinates()[0]; + signalForm.lat.value = feature.getGeometry().clone().transform("EPSG:3857", "EPSG:4326").getCoordinates()[1]; + signalForm.mastHeading.value = p.mast_heading_deg; signalForm.mastReach.value = p.mast_reach_m; signalForm.faceHeading.value = p.face_heading_deg; signalForm.phase.value = p.phase_group; signalForm.enabled.checked = p.enabled; + evidence.textContent = JSON.stringify({ 信号灯: p.signal_uid, 控制节点: p.control_id, 路口方向: p.approach_id, 来源: state.trafficSignals.provenance }, null, 2); message("已选中原生红绿灯"); +} +function poleFeatureForSignal(signalUid) { return layers.signals.getSource().getFeatures().find((item) => item.get("signal_uid") === signalUid && !item.get("signal_component")); } +async function saveSignals(document) { const response = await fetch("/api/traffic-signals", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(document) }); const result = await response.json(); if (!result.ok) throw new Error(result.error); state.trafficSignals = result.trafficSignals; state.trafficRuntime = result.runtime; updateSources(); renderSummary(); } +function signalDocumentWithChange(change) { const document = structuredClone(state.trafficSignals); document.assemblies.features = change(document.assemblies.features); return document; } +signalForm.onsubmit = async (event) => { event.preventDefault(); try { await saveSignals(signalDocumentWithChange((features) => features.map((feature) => feature.properties.signal_uid !== selectedSignal ? feature : { ...feature, geometry: { type: "Point", coordinates: [Number(signalForm.lon.value), Number(signalForm.lat.value)] }, properties: { ...feature.properties, mast_heading_deg: Number(signalForm.mastHeading.value), mast_reach_m: Number(signalForm.mastReach.value), face_heading_deg: Number(signalForm.faceHeading.value), phase_group: Number(signalForm.phase.value), enabled: signalForm.enabled.checked } }))); message("红绿灯已保存"); } catch (error) { message(error.message); } }; +signalPanel.querySelector('[data-signal="delete"]').onclick = async () => { try { await saveSignals(signalDocumentWithChange((features) => features.filter((feature) => feature.properties.signal_uid !== selectedSignal))); signalForm.hidden = true; selectedSignal = null; message("红绿灯已删除"); } catch (error) { message(error.message); } }; +signalPanel.querySelector('[data-signal="generate"]').onclick = async () => { try { const response = await fetch("/api/traffic-signals/generate", { method: "POST" }); const result = await response.json(); if (!result.ok) throw new Error(result.error); state.trafficSignals = result.trafficSignals; state.trafficRuntime = result.runtime; updateSources(); message("已补充 OSM 信号灯"); } catch (error) { message(error.message); } }; +signalPicker.onchange = () => { const feature = poleFeatureForSignal(signalPicker.value); if (feature) selectSignal(feature); }; function turnLabel(turn) { return { left: "左转", through: "直行", right: "右转", uturn: "掉头" }[turn] || turn; } function renderSelectedMovement() { selectedMovementPanel.hidden = !selectedMovement; if (!selectedMovement) return; const targetRoad = state.compiled.model.roads.find((road) => road.id === selectedMovement.toRoadId); const geometry = selectedMovement.geometryStatus === "connector" ? "已绘制路径" : selectedMovement.geometryStatus === "continuous" ? "节点连续" : "路径过长未绘制"; movementDetail.textContent = `${turnLabel(selectedMovement.turn)}:${lanePositionLabel(selectedRoad, laneIndex(selectedMovement.fromLaneId))} → ${lanePositionLabel(targetRoad, laneIndex(selectedMovement.toLaneId))}\n目标:${roadLabel(targetRoad)}(${osmDirectionLabel(targetRoad)})\n来源端点:${selectedRoad.sourceNodeIds.at(-1)};目标端点:${targetRoad.sourceNodeIds[0]}\n路口节点:${selectedMovement.nodeId}\n状态:${geometry}\n来源:${selectedMovement.provenance}`; } function renderDirectionSwitch(road) { @@ -247,6 +278,7 @@ function renderSummary() { ["路口转向箭头", comparison.nativeTurnArrowFeatures], ["斑马线条带", comparison.nativeCrosswalkFeatures], ["停止线", comparison.nativeVehicleStopLineFeatures], + ["红绿灯设施", state.trafficSignals?.assemblies?.features?.length || 0], ["行驶动作", comparison.nativeMovementCount], ["已绘制路径", comparison.nativePublishedMovementCount], ["可手工复核", comparison.unconnectedEndsWithManualCandidates], @@ -273,7 +305,7 @@ 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("已保存并重新生成"); }; -for (const input of document.querySelectorAll("[data-layer]")) input.onchange = () => { layers[input.dataset.layer].setVisible(input.checked); if (input.dataset.layer === "osm") layers.osmDirection.setVisible(input.checked); }; +for (const input of document.querySelectorAll("[data-layer]")) input.onchange = () => { const visible = input.checked; layers[input.dataset.layer].setVisible(visible); if (input.dataset.layer === "osm") layers.osmDirection.setVisible(visible); }; scenePreviewToggle.onchange = () => { scenePreview = scenePreviewToggle.checked; for (const input of document.querySelectorAll("[data-layer]")) { @@ -285,9 +317,11 @@ scenePreviewToggle.onchange = () => { layers.sidewalks.setVisible(document.querySelector('[data-layer="sidewalks"]').checked); layers.centerLines.setVisible(document.querySelector('[data-layer="centerLines"]').checked); layers.controls.setVisible(document.querySelector('[data-layer="controls"]').checked); + const signalsVisible = document.querySelector('[data-layer="signals"]').checked; + layers.signals.setVisible(signalsVisible); layers.diagnostics.setVisible(!scenePreview); layers.native.changed(); layers.sidewalks.changed(); message(scenePreview ? "场景效果预览:当前编译面" : "编辑图层预览"); }; for (const button of diagnosticFilters.querySelectorAll("button")) button.onclick = () => { diagnosticFilter = button.dataset.diagnosticFilter; renderDiagnostics(); }; -fetch("/api/state").then((response) => response.json()).then((value) => { state = value; updateDirtyState(); updateSources(); renderDiagnostics(); renderSummary(); areaLabel.textContent = state.areaId; message(`已加载 ${state.compiled.model.roads.length} 条方向道路`); }).catch((error) => message(error.message)); +fetch("/api/state").then((response) => response.json()).then((value) => { state = value; updateDirtyState(); updateSources(); renderDiagnostics(); renderSummary(); areaLabel.textContent = state.areaId; const signalUid = new URLSearchParams(location.search).get("signal"); const signal = signalUid && poleFeatureForSignal(signalUid); if (signal) selectSignal(signal); message(`已加载 ${state.compiled.model.roads.length} 条方向道路`); }).catch((error) => message(error.message));