diff --git a/.trellis/spec/config/index.md b/.trellis/spec/config/index.md index 9d8da4a..43624ba 100644 --- a/.trellis/spec/config/index.md +++ b/.trellis/spec/config/index.md @@ -187,7 +187,8 @@ transcode,成功后替换**package staging** 中的主 GLB 与 manifest;未 静态发布路径另有 `packageDir`、`packageStagingDir`、`packageManifest`、 `packageStagingManifest`、`packageModelDir`、`packageStagingModelDir` 与 -`packagePrimaryGlb`;预览路径另有 `previewDir` 与 `previewDescriptor`。除非在迁移旧调用, +`packagePrimaryGlb`;预览路径另有 `previewDir`、`previewDescriptor` 与 +`trafficSimulation`(native preview 的可迁移仿真描述符)。除非在迁移旧调用, 不要覆盖 `glb` / `metadata`:它们是 staging 内部路径,不是下游资产入口。 **优先改 `fileStem` 或 `areaDir`**——它们能一次性影响全部派生路径。逐个覆盖容易漏。 diff --git a/.trellis/tasks/08-18-native-preview-traffic-simulation/check.jsonl b/.trellis/tasks/08-18-native-preview-traffic-simulation/check.jsonl new file mode 100644 index 0000000..9dd3234 --- /dev/null +++ b/.trellis/tasks/08-18-native-preview-traffic-simulation/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-preview-traffic-simulation/design.md b/.trellis/tasks/08-18-native-preview-traffic-simulation/design.md new file mode 100644 index 0000000..4e61a1a --- /dev/null +++ b/.trellis/tasks/08-18-native-preview-traffic-simulation/design.md @@ -0,0 +1,70 @@ +# Design + +## Authority And Boundaries + +The native compiler remains the only road authority. The existing traffic-signal runtime remains the +only signal authority. The new feature adds a route/simulation layer beside those artifacts; it does +not alter signal geometry, phase grouping, or Blender static assets. + +```text +native-road/compiled.json + native-road/layers/* + -> native route compiler + -> _preview/-traffic-simulation.json + -> Cesium preview runtime + +package/runtime/traffic-signals.json + -> signal phase clock + stop events +``` + +The descriptor is intentionally package-adjacent, not embedded in the GLB. A consumer can copy the +package and descriptor to another platform and reproduce the simulation without running Node. + +## Descriptor Contract + +Schema: `native-preview-traffic-simulation/v1`. + +Required top-level fields: + +- `areaId`, `coordinateSystem` (`WGS84` route coordinates, `ENU` model placement), `generatedAt` +- `source` with relative artifact references and SHA-256 records +- `settings` with speed, acceleration, deceleration, reaction time, vehicle length, and minimum gap +- `routes[]` with ordered WGS84 coordinates, cumulative distances, connector/maneuver IDs, and + `stops[]` +- `signals[]` copied by stable `signal_uid` from the runtime contract, including phase group and + stop-line association; no regenerated pose fields +- `migration` describing schema version, coordinate conversion, update-loop expectations, and + compatibility notes + +The descriptor is deterministic for the same OSM, overrides, native compiler version, and settings. + +## Route Construction + +Use native directional roads and published connector geometry. Build a small number of deterministic +closed demonstration routes that cross real connectors and retain lane/road IDs. Route generation +must reject routes with missing geometry or disabled connectors and write structured diagnostics. +Native stop lines are matched by `road_id`/`lane_id` where available, with a bounded geometric +fallback recorded in the stop record. No fixed-width or legacy polygon fallback is allowed. + +## Browser Simulation + +Keep the existing signal phase functions and `signalData` payload. Replace the current per-vehicle +independent distance advance with a shared simulation state: + +- each vehicle has route distance, speed, desired speed, status, and active stop reason; +- signal stop constraints are evaluated before the native stop line for red/yellow phases; +- leader constraints are evaluated on the same route and wrap around the loop; +- acceleration/deceleration clamps advance toward the minimum of desired speed, signal limit, and + leader-safe speed; +- vehicle position and orientation remain Cesium callback properties, with no per-frame allocation + of heavyweight Cesium objects; +- incident-card behavior remains local to each vehicle and must not mutate the descriptor or package. + +Diagnostics expose route count, signal count, stopped vehicles, and queue length in the existing +preview diagnostics panel. + +## Migration And Rollback + +Document the descriptor, runtime signal schema, coordinate conversion, and a reference update loop in +`docs/native-preview-traffic-simulation.md`. Include a JSON example and a consumer checklist. Legacy +preview route files remain readable only through the existing legacy provider path; native generation +must not depend on them. Removing the new descriptor restores the existing no-cruise native preview. diff --git a/.trellis/tasks/08-18-native-preview-traffic-simulation/implement.jsonl b/.trellis/tasks/08-18-native-preview-traffic-simulation/implement.jsonl new file mode 100644 index 0000000..9dd3234 --- /dev/null +++ b/.trellis/tasks/08-18-native-preview-traffic-simulation/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-preview-traffic-simulation/implement.md b/.trellis/tasks/08-18-native-preview-traffic-simulation/implement.md new file mode 100644 index 0000000..3a28b97 --- /dev/null +++ b/.trellis/tasks/08-18-native-preview-traffic-simulation/implement.md @@ -0,0 +1,37 @@ +# Implementation Plan + +1. Add a native route compiler module that consumes `native-road/compiled.json` and native layers, + emits `native-preview-traffic-simulation/v1`, and records source hashes and migration metadata. +2. Wire native preview generation to write the descriptor and keep legacy osm2streets route + generation unchanged. +3. Extend the browser preview with shared deterministic vehicle state, signal stop constraints, + leader following, queue diagnostics, and graceful empty-route behavior. +4. Preserve the existing signal phase, `signal_uid`, pose, dynamic lens, countdown, disabled-signal, + and incident-card contracts; add focused regression tests for each boundary. +5. Add `docs/native-preview-traffic-simulation.md` with schema, example, coordinate rules, update + loop, migration checklist, and rollback notes. +6. Run focused Node tests, native compile/check, a native build with no legacy directory, and browser + preview validation in the available Blender/Cesium environment. + +## Validation Commands + +```bash +node --check scripts/lib/native-preview-traffic-simulation.js +node --check scripts/build-area.js +npm run test:native-road +npm run test:traffic-signals +npm run test:preview-assets +npm run test:native-preview-traffic +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,compress,package,preview +``` + +## Risk Controls + +- Do not change `scripts/lib/traffic-signals.js` geometry or phase semantics unless a test proves a + migration bug; prefer adapter code. +- Do not add route data to the static package manifest; it belongs beside `_preview/` and is optional. +- If native route generation produces no valid route, emit a valid descriptor with diagnostics and + keep preview usable. +- Keep all route and simulation math unit-testable without Cesium. diff --git a/.trellis/tasks/08-18-native-preview-traffic-simulation/prd.md b/.trellis/tasks/08-18-native-preview-traffic-simulation/prd.md new file mode 100644 index 0000000..913b423 --- /dev/null +++ b/.trellis/tasks/08-18-native-preview-traffic-simulation/prd.md @@ -0,0 +1,38 @@ +# Native preview traffic simulation and migration contract + +## Goal + +Extend the native road compiler preview with a deterministic, portable vehicle traffic demonstration that consumes native road topology and the already-validated traffic-signal runtime. The result must be useful as a visual validation tool and as a migration reference for Cesium, Blender, or another platform consuming the generated area package. + +## Confirmed Baseline + +- Native road compilation is the production authority; QGIS/osm2streets is not a runtime input. +- The previous traffic-signal work is the source of truth and must remain compatible: deterministic `signal_uid`, `phase_group`, `mast_heading_deg`, `face_heading_deg`, stop-line anchors, shared `pose.*`, dynamic lens/countdown assets, and disabled-signal filtering. +- Existing browser preview already has signal phase timing, signal visualization, route stop matching, vehicle incident cards, and a missing-route fallback. Native preview currently omits the route. +- Native road output contains compiled roads/connectors and `vehicle_stop_lines.geojson`; the native package contains `runtime/traffic-signals.json`. +- The feature is preview-level deterministic behavior, not a legal navigation or microscopic traffic simulator. + +## Requirements + +- R1: Generate a native route artifact from `native-road/compiled.json` and native layer geometry; it must not read `osm2streets_web_out`, QGIS files, or legacy route files. +- R2: Preserve and consume the existing traffic-signal contract without deriving a second signal layout in the browser. +- R3: Vehicles must stop before a red/yellow signal at the native stop line, resume on green, and expose the active stop reason in preview diagnostics. +- R4: Multiple vehicles on the same route must maintain a configurable minimum gap; a stopped front vehicle must cause following vehicles to decelerate and queue rather than overlap it. +- R5: Route, signal, vehicle, stop-event, and following-distance data must be written as versioned, package-adjacent JSON with stable ENU/WGS84 and migration metadata. Another platform must be able to consume the artifact without executing the Node compiler. +- R6: The browser preview must degrade gracefully when there are no valid routes or signals; the primary scene remains usable and diagnostics explain the missing optional capability. +- R7: Documentation must describe the data flow, schemas, coordinate conventions, signal identity, stop-line association, update loop, and migration/rollback guidance. + +## Acceptance Criteria + +- [ ] Native build emits a versioned route/traffic-simulation descriptor under the area output and preview loads it without any legacy directory present. +- [ ] At least one route crosses a native connector and contains a native stop-line association. +- [ ] A red/yellow phase visibly stops a vehicle before the stop line; green releases it. +- [ ] Two or more vehicles never overlap and maintain the configured minimum gap while cruising and while queued at a signal. +- [ ] Disabled native signals are absent from runtime control and do not create vehicle stops. +- [ ] Preview diagnostics report route count, signal count, stopped vehicles, queue length, and the active simulation descriptor version. +- [ ] Migration documentation and focused tests allow another consumer to reproduce route, signal, stopping, and gap behavior from generated assets alone. +- [ ] Existing traffic-signal tests and native road tests remain green; legacy provider behavior is unchanged. + +## Decided Scope + +- Simulation fidelity: MVP is a deterministic preview simulation with configurable speed, acceleration/deceleration, reaction time, and minimum gap. A full lane-changing, collision, priority, and multi-intersection traffic engine is deferred. diff --git a/.trellis/tasks/08-18-native-preview-traffic-simulation/task.json b/.trellis/tasks/08-18-native-preview-traffic-simulation/task.json new file mode 100644 index 0000000..4518cc2 --- /dev/null +++ b/.trellis/tasks/08-18-native-preview-traffic-simulation/task.json @@ -0,0 +1,26 @@ +{ + "id": "native-preview-traffic-simulation", + "name": "native-preview-traffic-simulation", + "title": "Native preview traffic simulation and migration contract", + "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": null, + "relatedFiles": [], + "notes": "", + "meta": {} +} \ No newline at end of file diff --git a/README.md b/README.md index 214a6d1..e637333 100644 --- a/README.md +++ b/README.md @@ -114,6 +114,22 @@ npm run road:check -- --config config/areas/nantaizi-lake-innovation-valley.json `road:check` 验证已发布 connector 与语义行驶动作的一致性,并在诊断包含 error、图层缺失或 动作/几何对应关系不一致时以非零退出。warning 保留给工作台审查,不会阻止产物打开。 +### Native Preview Traffic Simulation + +native preview 会额外生成: + +```text +outputs//_preview/-traffic-simulation.json +``` + +该文件使用 `native-preview-traffic-simulation/v1`,记录 native 路线、connector、停止点、 +信号 `signal_uid`、源文件 SHA-256、坐标系和仿真参数。它与 `package/manifest.json` 一起可 +迁移到其他平台;消费方不需要运行 Node 或 QGIS。预览中的车辆会根据现有信号相位在停止线 +前停车,并按最小车距跟车排队。该功能是确定性的验证预览,不是法规级导航或完整交通仿真。 + +迁移契约、JSON 示例、更新循环和回撤方式见 +[native preview traffic simulation](docs/native-preview-traffic-simulation.md)。 + 启动本地浏览器工作台: ```bash diff --git a/docs/native-preview-traffic-simulation.md b/docs/native-preview-traffic-simulation.md new file mode 100644 index 0000000..6a0e941 --- /dev/null +++ b/docs/native-preview-traffic-simulation.md @@ -0,0 +1,64 @@ +# Native Preview Traffic Simulation + +This document defines the portable preview contract for native road traffic demonstrations. It is +not a legal navigation format or a full microscopic traffic simulator. + +## Data Flow + +```text +native-road/compiled.json + layers/ + -> native-preview-traffic-simulation/v1 descriptor +package/runtime/traffic-signals.json + -> signal phase clock and stop constraints +descriptor + package/manifest.json + -> Cesium preview vehicles, signals, queues, diagnostics +``` + +The descriptor is generated from native artifacts and is independent of QGIS/osm2streets. Copying +the descriptor, package, and preview runtime to another platform is sufficient to reproduce the +visual simulation. + +## Existing Signal Contract + +Signal identity is `signal_uid`; it must not be renamed when `display_id` changes. `phase_group` +selects the existing signal phase. `mast_heading_deg`, `face_heading_deg`, `pose.*`, stop-line +coordinates, and `enabled` are consumed from `package/runtime/traffic-signals.json`. Disabled signals +are absent from runtime control. Consumers must not recalculate lamp or pole geometry. + +## Vehicle Rules + +Each vehicle advances along a route distance. Its target speed is the minimum of desired speed, +signal-safe speed, and leader-safe speed. Red/yellow signals create a stop target before the native +stop line. Following vehicles apply the configured minimum gap and decelerate behind the leader. +The simulation is deterministic for a fixed descriptor, clock start, and settings. + +The descriptor records WGS84 route coordinates. The static package remains local ENU and is placed by +the package manifest. Consumers must use the package placement/model matrix for GLB assets and WGS84 +coordinates for route entities; do not mix a fixed meters-per-degree approximation into either path. + +Route geometry is taken from `native-road/layers/lane_centerlines.geojson` when that native layer is +available, keyed by `road_id`; the compiled road centerline is only a compatibility fallback for old +native outputs. Each road/connector boundary is checked in meters. Candidates with a missing +connector or an endpoint gap over 35 m are omitted from `routes` and retained in `rejectedRoutes` +with structured diagnostics. This prevents a malformed movement from becoming a visible diagonal +track across an intersection. + +`diagnostics` contains the route-generation warnings, while `rejectedRoutes` preserves the candidate +id and the specific movement checks that failed. A consumer should display these as data-quality +warnings rather than silently reconstructing a route from legacy QGIS files. + +## Migration Checklist + +1. Load `package/manifest.json` and resolve all package-relative assets. +2. Load the traffic-signal runtime and preserve `signal_uid`/`phase_group` values. +3. Load the versioned simulation descriptor and verify its source hashes if reproducibility matters. +4. Implement the update loop using the descriptor settings and stop/leader constraints. +5. Place route coordinates in the same WGS84 scene as the package placement and preserve ENU model + placement for static assets. +6. Expose route count, signal count, stopped vehicles, and queue length as diagnostics. + +## Rollback + +Deleting or ignoring the optional simulation descriptor returns the native preview to a usable static +scene with signal visualization but no vehicle cruise. The QGIS baseline remains available through +`qgis-original-baseline-20260818`; it is not required by this contract. diff --git a/package.json b/package.json index 64b6a48..950463b 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,7 @@ "test:package-examples": "node scripts/test-package-examples.js", "test:turn-lane-arrows": "node scripts/test-turn-lane-arrows.js", "test:traffic-signals": "node scripts/test-traffic-signals.js", + "test:native-preview-traffic": "node scripts/test-native-preview-traffic.js", "render:turn-lane-arrow-samples": "node scripts/render-turn-lane-arrow-samples.js" }, "dependencies": { diff --git a/scripts/build-area.js b/scripts/build-area.js index fac8cea..328b766 100755 --- a/scripts/build-area.js +++ b/scripts/build-area.js @@ -16,6 +16,7 @@ const { } = require("./lib/scene-layers"); const { digest: glbDigest } = require("./glb-digest"); const { buildVehicleRoute: buildPreviewVehicleRoute } = require("./lib/vehicle-route"); +const { buildNativeTrafficSimulation } = require("./lib/native-preview-traffic-simulation"); const { writePreviewVehicleLibrary } = require("./lib/vehicle-library"); const { readTrafficSignals } = require("./lib/traffic-signals"); const { @@ -338,11 +339,9 @@ function exportCesium(area, roadProvider) { fs.rmSync(area.outputs.packageStagingDir, { recursive: true, force: true }); fs.mkdirSync(path.dirname(area.outputs.glb), { recursive: true }); fs.mkdirSync(path.dirname(area.outputs.metadata), { recursive: true }); - if (roadProvider === "osm2streets") { - fs.mkdirSync(path.dirname(area.outputs.trafficSignalsDynamicGlb), { recursive: true }); - fs.mkdirSync(path.dirname(area.outputs.trafficSignalsCountdown0Glb), { recursive: true }); - fs.mkdirSync(path.dirname(area.outputs.trafficSignalsCountdown1Glb), { recursive: true }); - } + fs.mkdirSync(path.dirname(area.outputs.trafficSignalsDynamicGlb), { recursive: true }); + fs.mkdirSync(path.dirname(area.outputs.trafficSignalsCountdown0Glb), { recursive: true }); + fs.mkdirSync(path.dirname(area.outputs.trafficSignalsCountdown1Glb), { recursive: true }); console.log("Stage: cesium"); const started = Date.now(); @@ -363,19 +362,15 @@ function exportCesium(area, roadProvider) { "--metadata", area.outputs.metadata, ]; - if (roadProvider === "osm2streets") { - exporterArgs.splice(-2, 0, - "--dynamic-glb", area.outputs.trafficSignalsDynamicGlb, - "--countdown-0-glb", area.outputs.trafficSignalsCountdown0Glb, - "--countdown-1-glb", area.outputs.trafficSignalsCountdown1Glb, - ); - } + exporterArgs.splice(-2, 0, + "--dynamic-glb", area.outputs.trafficSignalsDynamicGlb, + "--countdown-0-glb", area.outputs.trafficSignalsCountdown0Glb, + "--countdown-1-glb", area.outputs.trafficSignalsCountdown1Glb, + ); runCommand(blenderExecutable(area), exporterArgs, "cesium"); - if (roadProvider === "osm2streets") { - ensureFile(area.outputs.trafficSignalsDynamicGlb, "Dynamic traffic signal GLB"); - ensureFile(area.outputs.trafficSignalsCountdown0Glb, "Traffic countdown group 0 GLB"); - ensureFile(area.outputs.trafficSignalsCountdown1Glb, "Traffic countdown group 1 GLB"); - } + ensureFile(area.outputs.trafficSignalsDynamicGlb, "Dynamic traffic signal GLB"); + ensureFile(area.outputs.trafficSignalsCountdown0Glb, "Traffic countdown group 0 GLB"); + ensureFile(area.outputs.trafficSignalsCountdown1Glb, "Traffic countdown group 1 GLB"); const semanticAssets = semanticAssetRecords(area); const finished = Date.now(); const digest = glbDigest(area.outputs.glb); @@ -392,9 +387,9 @@ function exportCesium(area, roadProvider) { outputs: { glb: fileRecord(area.outputs.glb), metadata: fileRecord(area.outputs.metadata), - ...(roadProvider === "osm2streets" ? { - trafficSignalsDynamicGlb: fileRecord(area.outputs.trafficSignalsDynamicGlb), - } : {}), + trafficSignalsDynamicGlb: fileRecord(area.outputs.trafficSignalsDynamicGlb), + trafficSignalsCountdown0Glb: fileRecord(area.outputs.trafficSignalsCountdown0Glb), + trafficSignalsCountdown1Glb: fileRecord(area.outputs.trafficSignalsCountdown1Glb), semanticAssets, }, summary: { @@ -558,6 +553,7 @@ function writeCesiumPreview(area, roadProvider) { ensureFile(area.outputs.packageManifest, "Published asset package manifest"); ensureFile(area.outputs.packageTrafficSignals, "Published traffic signal anchors"); let vehicleRoute = null; + let routeArtifact = null; const previewInputs = { config: fileRecord(configPath), osm: fileRecord(area.input), @@ -579,6 +575,11 @@ function writeCesiumPreview(area, roadProvider) { }); } else { Object.assign(previewInputs, nativeRoadRecords(area)); + const simulation = buildNativeTrafficSimulation(area); + fs.mkdirSync(path.dirname(area.outputs.trafficSimulation), { recursive: true }); + fs.writeFileSync(area.outputs.trafficSimulation, `${JSON.stringify(simulation, null, 2)}\n`); + previewInputs.trafficSimulation = fileRecord(area.outputs.trafficSimulation); + routeArtifact = area.outputs.trafficSimulation; } const htmlPath = area.outputs.cesiumPreview; const started = Date.now(); @@ -594,7 +595,9 @@ function writeCesiumPreview(area, roadProvider) { writeCesiumPreviewSupportFiles(path.dirname(htmlPath)); const glbName = "package/manifest.json"; const metadataName = "package/manifest.json"; - const routeName = vehicleRoute ? previewRelativePath(area.outputs.areaDir, area.outputs.vehicleRoute) : null; + const routeName = vehicleRoute || routeArtifact + ? previewRelativePath(area.outputs.areaDir, vehicleRoute ? area.outputs.vehicleRoute : routeArtifact) + : null; const vehicleModelName = previewRelativePath(area.outputs.areaDir, area.outputs.vehicleModel); const descriptor = { routeName, vehicleModelName: previewRelativePath(area.outputs.areaDir, area.outputs.vehicleModel), vehicleModelNames: vehicleModelNames.map((name) => `_preview/${name}`), trafficSignalsName: "package/runtime/traffic-signals.json", assets: [] }; fs.mkdirSync(area.outputs.previewDir, { recursive: true }); @@ -617,10 +620,11 @@ function writeCesiumPreview(area, roadProvider) { outputs: { cesiumPreview: fileRecord(area.outputs.cesiumPreview), vehicleRoute: optionalFileRecord(area.outputs.vehicleRoute), + ...(routeArtifact ? { trafficSimulation: fileRecord(routeArtifact) } : {}), vehicleModel: fileRecord(area.outputs.vehicleModel), trafficSignals: fileRecord(area.outputs.packageTrafficSignals), }, - summary: previewSummary(area), + summary: previewSummary(area, vehicleRoute ? area.outputs.vehicleRoute : routeArtifact), warnings: [], }); } diff --git a/scripts/lib/area-config.js b/scripts/lib/area-config.js index 1859abf..5f47c10 100644 --- a/scripts/lib/area-config.js +++ b/scripts/lib/area-config.js @@ -66,6 +66,7 @@ function normalizeAreaConfig(raw, options = {}) { ), previewDir, previewDescriptor: path.resolve(outputOverrides.previewDescriptor || path.join(previewDir, "descriptor.json")), + trafficSimulation: path.resolve(outputOverrides.trafficSimulation || path.join(previewDir, `${fileStem}-traffic-simulation.json`)), vehicleRoute: path.resolve(outputOverrides.vehicleRoute || path.join(previewDir, `${fileStem}-vehicle-route.json`)), vehicleModel: path.resolve(outputOverrides.vehicleModel || path.join(previewDir, `${fileStem}-vehicle-car.gltf`)), trafficSignalAssemblies: path.resolve( diff --git a/scripts/lib/area-diagnostics.js b/scripts/lib/area-diagnostics.js index da61eaf..4d502f6 100644 --- a/scripts/lib/area-diagnostics.js +++ b/scripts/lib/area-diagnostics.js @@ -473,15 +473,18 @@ function stageManifestStatus(area, configPath = null) { network: path.join(area.outputs.geojsonDir, "network.json"), intersectionSurface: path.join(area.outputs.geojsonDir, "intersection_surface.geojson"), }), + ...(native ? { trafficSimulation: area.outputs.trafficSimulation } : {}), previewCss: path.join(path.resolve(__dirname, ".."), "lib", "cesium-preview.css"), previewJs: path.join(path.resolve(__dirname, ".."), "lib", "cesium-preview.js"), }, outputs: compressionComplete ? { vehicleRoute: native ? optionalExpectedFile(area.outputs.vehicleRoute) : area.outputs.vehicleRoute, + ...(native ? { trafficSimulation: area.outputs.trafficSimulation } : {}), vehicleModel: area.outputs.vehicleModel, } : { cesiumPreview: area.outputs.cesiumPreview, vehicleRoute: native ? optionalExpectedFile(area.outputs.vehicleRoute) : area.outputs.vehicleRoute, + ...(native ? { trafficSimulation: area.outputs.trafficSimulation } : {}), vehicleModel: area.outputs.vehicleModel, }, }, diff --git a/scripts/lib/area-preview.js b/scripts/lib/area-preview.js index 28bcce8..3d4933b 100644 --- a/scripts/lib/area-preview.js +++ b/scripts/lib/area-preview.js @@ -123,15 +123,15 @@ function escapeScriptJson(value) { .replaceAll("\u2029", "\\u2029"); } -function previewSummary(area) { - const route = fs.existsSync(area.outputs.vehicleRoute) ? JSON.parse(fs.readFileSync(area.outputs.vehicleRoute, "utf8")) : null; +function previewSummary(area, routePath = area.outputs.vehicleRoute) { + const route = routePath && fs.existsSync(routePath) ? JSON.parse(fs.readFileSync(routePath, "utf8")) : null; return { glbName: "package/manifest.json", metadataName: "package/manifest.json", - routeName: route ? path.relative(area.outputs.areaDir, area.outputs.vehicleRoute).split(path.sep).join("/") : null, + routeName: route ? path.relative(area.outputs.areaDir, routePath).split(path.sep).join("/") : null, vehicleModelName: path.relative(area.outputs.areaDir, area.outputs.vehicleModel).split(path.sep).join("/"), trafficSignalsName: "package/runtime/traffic-signals.json", - routeSegments: Array.isArray(route?.segments) ? route.segments.length : null, + routeSegments: Array.isArray(route?.routes) ? route.routes.length : Array.isArray(route?.segments) ? route.segments.length : null, }; } diff --git a/scripts/lib/cesium-preview.js b/scripts/lib/cesium-preview.js index f87aafb..b78db93 100644 --- a/scripts/lib/cesium-preview.js +++ b/scripts/lib/cesium-preview.js @@ -555,16 +555,27 @@ .slice(0, 5); const speed = Number((routeData && routeData.speedMetersPerSecond) || 8); const start = trafficStart; + const assignments = []; + segments.forEach((segment, routeIndex) => { + // Put two vehicles on the first route so the native preview visibly + // exercises leader following and queue formation. + const vehicleCount = routeIndex === 0 ? 2 : 1; + for (let vehicleIndex = 0; vehicleIndex < vehicleCount; vehicleIndex += 1) { + assignments.push({ segment, routeIndex, vehicleIndex }); + } + }); + const simulation = createTrafficSimulation(viewer, assignments, start, speed, signalData, routeData?.settings); viewer.clock.startTime = start.clone(); viewer.clock.currentTime = start.clone(); viewer.clock.clockRange = Cesium.ClockRange.UNBOUNDED; viewer.clock.multiplier = 1; - viewer.clock.shouldAnimate = segments.length > 0; + viewer.clock.shouldAnimate = assignments.length > 0; - const vehicles = segments.map((segment, index) => { + const vehicles = assignments.map((assignment, index) => { + const { segment } = assignment; const vehicle = addCruiseVehicle(viewer, segment, index, start, speed, signalData, - selectedVehicleModelName(vehicleModelNames, fallbackVehicleModelName)); + selectedVehicleModelName(vehicleModelNames, fallbackVehicleModelName), simulation.motions[index]); const option = document.createElement("option"); option.value = String(index); option.textContent = routeLabel(segment, index); @@ -574,7 +585,8 @@ const cruise = { vehicles, baseSpeed: speed, - state: { selectedIndex: 0 } + state: { selectedIndex: 0 }, + simulation, }; syncSelectedRouteVisibility(cruise); return cruise; @@ -732,10 +744,10 @@ return usable[Math.floor(Math.random() * usable.length)] || ""; } - function addCruiseVehicle(viewer, segment, index, start, speed, signalData, vehicleModelName) { - const route = prepareRoute(segment, signalData); + function addCruiseVehicle(viewer, segment, index, start, speed, signalData, vehicleModelName, trafficMotion) { + const route = trafficMotion.route; let record = null; - const trafficMotion = createTrafficAwarePositions(viewer, route, start, speed, () => record?.status === "normal"); + trafficMotion.setMoving(() => record?.status === "normal"); const positions = trafficMotion.positions; const flat = []; for (const coord of segment.coordinates) { @@ -946,6 +958,106 @@ return [...found.values()].sort((a, b) => a.distance - b.distance); } + function createTrafficSimulation(viewer, assignments, start, speed, signalData, settings = {}) { + const config = { + desiredSpeedMetersPerSecond: Number(settings.desiredSpeedMetersPerSecond || speed), + accelerationMetersPerSecondSquared: Number(settings.accelerationMetersPerSecondSquared || 1.8), + decelerationMetersPerSecondSquared: Number(settings.decelerationMetersPerSecondSquared || 3.5), + reactionTimeSeconds: Number(settings.reactionTimeSeconds || 0.8), + vehicleLengthMeters: Number(settings.vehicleLengthMeters || 4.6), + minimumGapMeters: Number(settings.minimumGapMeters || 7), + }; + const motions = assignments.map((assignment) => { + const route = prepareRoute(assignment.segment, signalData); + const initialGap = config.vehicleLengthMeters + config.minimumGapMeters; + const state = { + distance: assignment.vehicleIndex * -initialGap, + speed: 0, + targetSpeed: config.desiredSpeedMetersPerSecond, + stopReason: null, + queueAhead: 0, + lastTime: start.clone(), + }; + state.distance = (state.distance % route.length + route.length) % route.length; + return { + route, + state, + setMoving(callback) { state.isMoving = callback; }, + positions: new Cesium.CallbackProperty((time, result) => routePositionAtDistance(route, state.distance, result), false), + }; + }); + const update = (clock) => { + const elapsed = Cesium.JulianDate.secondsDifference(clock.currentTime, motions[0]?.state.lastTime || start); + if (!(elapsed > 0) || elapsed > 2) { + for (const motion of motions) Cesium.JulianDate.clone(clock.currentTime, motion.state.lastTime); + return; + } + const grouped = new Map(); + assignments.forEach((assignment, index) => { + const key = assignment.routeIndex; + if (!grouped.has(key)) grouped.set(key, []); + grouped.get(key).push(index); + }); + for (const indexes of grouped.values()) { + indexes.sort((a, b) => motions[a].state.distance - motions[b].state.distance); + } + motions.forEach((motion, index) => { + const state = motion.state; + Cesium.JulianDate.clone(clock.currentTime, state.lastTime); + if (state.isMoving && !state.isMoving()) { + state.speed = 0; + state.stopReason = "incident"; + return; + } + const route = motion.route; + let target = config.desiredSpeedMetersPerSecond; + let stopReason = null; + const nextStop = nextRouteStop(route, state.distance); + if (nextStop) { + const phase = signalPhase(nextStop.signal.phaseGroup, clock.currentTime, start).active; + const untilStop = (nextStop.distance - state.distance + route.length) % route.length; + if (phase !== "green" && untilStop < Math.max(30, state.speed * config.reactionTimeSeconds + 8)) { + target = Math.min(target, Math.max(0, (untilStop - 1.7) / Math.max(config.reactionTimeSeconds, 0.1))); + stopReason = "traffic-signal"; + } + } + const peers = grouped.get(assignments[index].routeIndex) || []; + const peerPosition = peers.indexOf(index); + if (peerPosition >= 0 && peers.length > 1) { + const leaderIndex = peers[(peerPosition + 1) % peers.length]; + if (leaderIndex !== index) { + const leader = motions[leaderIndex].state; + const gap = (leader.distance - state.distance + route.length) % route.length - config.vehicleLengthMeters; + const safeGap = config.minimumGapMeters + state.speed * config.reactionTimeSeconds; + if (gap < safeGap + 12) { + target = Math.min(target, Math.max(0, (gap - config.minimumGapMeters) / Math.max(config.reactionTimeSeconds, 0.1))); + if (target < 0.2) stopReason = "leader-gap"; + state.queueAhead = leader.stopReason ? 1 : 0; + } else { + state.queueAhead = 0; + } + } + } + state.targetSpeed = target; + const limit = (target >= state.speed ? config.accelerationMetersPerSecondSquared : config.decelerationMetersPerSecondSquared) * elapsed; + state.speed += Math.sign(target - state.speed) * Math.min(Math.abs(target - state.speed), limit); + state.distance = (state.distance + Math.max(0, state.speed) * elapsed) % route.length; + state.stopReason = state.speed < 0.2 ? stopReason : null; + }); + }; + viewer.clock.onTick.addEventListener(update); + return { + motions, + settings: config, + diagnostics() { + return { + stoppedVehicles: motions.filter((motion) => motion.state.stopReason).length, + queueLength: motions.filter((motion) => motion.state.stopReason === "leader-gap").length, + }; + }, + }; + } + function createTrafficAwarePositions(viewer, route, start, speed, isMoving = () => true) { const state = { distance: 0, lastTime: start.clone() }; viewer.clock.onTick.addEventListener((clock) => { @@ -1230,6 +1342,12 @@ "Trees: " + Number(stats.trees || 0), "Road layer source: " + (metadata.source_geojson ? "osm2streets" : "OSM fallback") ]; + if (cruise.simulation) { + const simulation = cruise.simulation.diagnostics(); + lines.push("Simulation: native-preview-traffic-simulation/v1"); + lines.push("Stopped: " + simulation.stoppedVehicles); + lines.push("Queue: " + simulation.queueLength); + } if (failed.length) { lines.push("Failed assets: " + failed.map((asset) => asset.url).join(", ")); } diff --git a/scripts/lib/native-preview-traffic-simulation.js b/scripts/lib/native-preview-traffic-simulation.js new file mode 100644 index 0000000..a37415b --- /dev/null +++ b/scripts/lib/native-preview-traffic-simulation.js @@ -0,0 +1,259 @@ +"use strict"; + +const crypto = require("crypto"); +const fs = require("fs"); +const path = require("path"); + +const SCHEMA = "native-preview-traffic-simulation/v1"; +const MAX_ROUTES = 5; +const MIN_ROAD_COUNT = 3; +const MAX_ROAD_COUNT = 7; +const MAX_CONNECTION_GAP_METERS = 35; +const DEFAULT_SETTINGS = { + desiredSpeedMetersPerSecond: 8, + accelerationMetersPerSecondSquared: 1.8, + decelerationMetersPerSecondSquared: 3.5, + reactionTimeSeconds: 0.8, + vehicleLengthMeters: 4.6, + minimumGapMeters: 7, +}; + +function buildNativeTrafficSimulation(area, options = {}) { + const root = area.outputs.nativeRoadDir; + const compiledPath = path.join(root, "compiled.json"); + const signalPath = path.join(root, "traffic-signals.json"); + const connectorPath = path.join(root, "layers", "connectors.geojson"); + const laneCenterlinePath = path.join(root, "layers", "lane_centerlines.geojson"); + const stopLinePath = path.join(root, "layers", "vehicle_stop_lines.geojson"); + for (const file of [compiledPath, signalPath, connectorPath, stopLinePath]) { + if (!fs.existsSync(file)) throw new Error(`Native traffic simulation input not found: ${file}`); + } + const compiled = readJson(compiledPath, "native compiled roads"); + const signals = readJson(signalPath, "native traffic signals"); + const connectors = readFeatureCollection(connectorPath, "native connectors"); + const laneCenterlines = fs.existsSync(laneCenterlinePath) ? readFeatureCollection(laneCenterlinePath, "native lane centerlines") : null; + const stopLines = readFeatureCollection(stopLinePath, "native stop lines"); + const settings = { ...DEFAULT_SETTINGS, ...(options.settings || {}) }; + validateSettings(settings); + const routeBuild = buildRoutes(compiled, connectors, signals.signals || [], stopLines.features, laneCenterlines); + return { + schema: SCHEMA, + areaId: area.id, + coordinateSystem: { route: "WGS84", model: "ENU", units: "meters", axes: "X east / Y north / Z up" }, + generatedAt: new Date().toISOString(), + speedMetersPerSecond: settings.desiredSpeedMetersPerSecond, + source: sourceRecords(area, [compiledPath, signalPath, connectorPath, stopLinePath, ...(laneCenterlines ? [laneCenterlinePath] : [])]), + settings, + routes: routeBuild.routes, + signals: (signals.signals || []).filter((signal) => signal && signal.id).map(signalDescriptor), + diagnostics: [ + ...routeBuild.diagnostics, + ...(routeBuild.routes.length ? [] : [{ severity: "warning", reason: "no_native_cruise_route", message: "No deterministic native route crossed three or more directional roads." }]), + ], + rejectedRoutes: routeBuild.rejectedRoutes, + migration: { + schema: SCHEMA, + updateLoop: "advance route distance using settings, stop before red/yellow signal, then apply leader minimum gap", + signalIdentity: "signals[].id is the existing traffic runtime signal_uid", + coordinates: "routes[].coordinates are WGS84; static package remains local ENU placed by package manifest", + legacyDependency: false, + }, + }; +} + +function buildRoutes(compiled, connectorCollection, signals, stopLineFeatures, laneCenterlineCollection) { + const roads = new Map((compiled.model?.roads || []).map((road) => [road.id, road])); + const movements = (compiled.movements || []).filter((movement) => movement.geometryPublished !== false && roads.has(movement.fromRoadId) && roads.has(movement.toRoadId)); + const connectorByMovement = new Map(connectorCollection.features.map((feature) => [feature.properties?.movement_id, feature.geometry?.coordinates || []])); + const laneCenterlineById = new Map(); + for (const feature of laneCenterlineCollection?.features || []) { + const properties = feature.properties || {}; + const coordinates = feature.geometry?.coordinates || []; + if (properties.native_id) laneCenterlineById.set(properties.native_id, coordinates); + if (properties.road_id && Number.isFinite(Number(properties.lane_index))) laneCenterlineById.set(`${properties.road_id}:${properties.lane_index}`, coordinates); + } + const outgoing = new Map(); + for (const movement of movements) { + if (!outgoing.has(movement.fromRoadId)) outgoing.set(movement.fromRoadId, []); + outgoing.get(movement.fromRoadId).push(movement); + } + for (const list of outgoing.values()) list.sort((a, b) => a.id.localeCompare(b.id)); + const candidates = []; + const seen = new Set(); + for (const start of [...roads.keys()].sort()) { + findCycles(start, start, [], [], outgoing, seen, candidates); + if (candidates.length >= MAX_ROUTES * 3) break; + } + const diagnostics = []; + const rejectedRoutes = []; + const routes = candidates + .map((candidate) => { + const result = makeRoute(candidate, roads, connectorByMovement, signals, stopLineFeatures, laneCenterlineById); + if (!result.route) { + rejectedRoutes.push({ id: `native-loop:${candidate.roadIds.join("-")}`, diagnostics: result.diagnostics }); + diagnostics.push(...result.diagnostics); + } + return result.route; + }) + .filter(Boolean) + .sort((a, b) => a.id.localeCompare(b.id)) + .slice(0, MAX_ROUTES); + return { routes, diagnostics, rejectedRoutes }; +} + +function findCycles(start, current, roadIds, movements, outgoing, seen, candidates) { + const nextRoadIds = [...roadIds, current]; + if (nextRoadIds.length > MAX_ROAD_COUNT) return; + for (const movement of outgoing.get(current) || []) { + if (movement.toRoadId === start && nextRoadIds.length >= MIN_ROAD_COUNT) { + if (!laneSequenceCompatible(movements.at(-1), movement) || !laneSequenceCompatible(movement, movements[0])) continue; + const signature = [...nextRoadIds, start].join("|"); + if (!seen.has(signature)) { + seen.add(signature); + candidates.push({ roadIds: nextRoadIds, movements: [...movements, movement] }); + } + continue; + } + if (nextRoadIds.includes(movement.toRoadId)) continue; + if (!laneSequenceCompatible(movements.at(-1), movement)) continue; + findCycles(start, movement.toRoadId, nextRoadIds, [...movements, movement], outgoing, seen, candidates); + } +} + +function laneSequenceCompatible(previous, next) { + if (!previous || !previous.toLaneId || !next?.fromLaneId) return true; + return previous.toLaneId === next.fromLaneId; +} + +function makeRoute(candidate, roads, connectorByMovement, signals, stopLineFeatures, laneCenterlineById) { + const coordinates = []; + const connectors = []; + const diagnostics = []; + for (let index = 0; index < candidate.roadIds.length; index += 1) { + const road = roads.get(candidate.roadIds[index]); + const incomingMovement = candidate.movements[(index - 1 + candidate.movements.length) % candidate.movements.length]; + const incomingLine = selectLaneCenterline(laneCenterlineById, road, incomingMovement?.toLaneId); + const roadLine = incomingLine || road.centerline; + append(coordinates, roadLine); + const movement = candidate.movements[index]; + const connector = connectorByMovement.get(movement.id) || []; + if (connector.length < 2) { + diagnostics.push(routeDiagnostic(candidate, movement, "missing_connector_geometry", "Native movement has no usable connector geometry.")); + return { route: null, diagnostics }; + } + const startGap = distanceMeters(coordinates.at(-1), connector[0]); + if (startGap > MAX_CONNECTION_GAP_METERS) { + diagnostics.push(routeDiagnostic(candidate, movement, "road_connector_gap", `Road to connector endpoint gap is ${round(startGap)}m.`)); + return { route: null, diagnostics }; + } + append(coordinates, connector); + if (index < candidate.roadIds.length - 1) { + const nextRoad = roads.get(candidate.roadIds[index + 1]); + const nextLine = selectLaneCenterline(laneCenterlineById, nextRoad, movement.toLaneId) || nextRoad.centerline; + const endGap = distanceMeters(connector.at(-1), nextLine[0]); + if (endGap > MAX_CONNECTION_GAP_METERS) { + diagnostics.push(routeDiagnostic(candidate, movement, "connector_road_gap", `Connector to road endpoint gap is ${round(endGap)}m.`)); + return { route: null, diagnostics }; + } + } + connectors.push({ id: movement.connectorId, movementId: movement.id, fromRoadId: movement.fromRoadId, toRoadId: movement.toRoadId, turn: movement.turn }); + } + if (coordinates.length < 4) return { route: null, diagnostics }; + const distances = cumulativeDistances(coordinates); + const lengthMeters = distances.at(-1); + if (!(lengthMeters > 30)) return { route: null, diagnostics }; + const stops = matchStops(coordinates, distances, signals, stopLineFeatures); + return { route: { + id: `native-loop:${candidate.roadIds.join("-")}`, + coordinates, + centerlineCoordinates: coordinates, + distances, + lengthMeters: round(lengthMeters), + edgeIds: candidate.roadIds, + laneSegments: candidate.roadIds.map((roadId) => ({ roadId, laneId: `lane:${roadId}:1` })), + connectors, + maneuvers: connectors.map((connector) => connector.turn).filter(Boolean), + stops, + }, diagnostics }; +} + +function selectLaneCenterline(laneCenterlineById, road, laneId) { + if (!laneCenterlineById || !road) return null; + if (laneId && laneCenterlineById.has(laneId)) return laneCenterlineById.get(laneId); + const fallback = laneCenterlineById.get(`${road.id}:1`); + return fallback || null; +} + +function routeDiagnostic(candidate, movement, reason, message) { + return { severity: "warning", reason, message, routeRoadIds: candidate.roadIds, movementId: movement.id, fromRoadId: movement.fromRoadId, toRoadId: movement.toRoadId }; +} + +function matchStops(coordinates, distances, signals, stopLineFeatures) { + const stopRoadIds = new Set(stopLineFeatures.map((feature) => feature.properties?.road_id).filter(Boolean)); + return signals.map((signal) => { + if (!Number.isFinite(signal.stopLongitude) || !Number.isFinite(signal.stopLatitude)) return null; + let best = { distance: Infinity, routeDistance: 0 }; + for (let index = 0; index < coordinates.length; index += 1) { + const distance = haversine(coordinates[index], [signal.stopLongitude, signal.stopLatitude]); + if (distance < best.distance) best = { distance, routeDistance: distances[index] }; + } + if (best.distance > 15 || !stopRoadIds.size) return null; + return { signalId: signal.id, phaseGroup: signal.phaseGroup, distance: round(best.routeDistance), matchDistanceMeters: round(best.distance), stopReason: "traffic-signal" }; + }).filter(Boolean).sort((a, b) => a.distance - b.distance); +} + +function signalDescriptor(signal) { + return { id: signal.id, phaseGroup: signal.phaseGroup, approachId: signal.approachId, sourceWayId: signal.sourceWayId, stopLongitude: signal.stopLongitude, stopLatitude: signal.stopLatitude, enabled: true }; +} + +function sourceRecords(area, files) { + return files.map((file) => ({ path: path.relative(area.outputs.areaDir, file).split(path.sep).join("/"), bytes: fs.statSync(file).size, sha256: sha256(file) })); +} + +function append(target, points) { + for (const point of points || []) { + const coordinate = point.slice(0, 2).map(Number); + if (!coordinate.every(Number.isFinite)) continue; + const previous = target.at(-1); + if (!previous || previous[0] !== coordinate[0] || previous[1] !== coordinate[1]) target.push(coordinate); + } +} + +function cumulativeDistances(coordinates) { + const distances = [0]; + for (let index = 1; index < coordinates.length; index += 1) distances.push(distances[index - 1] + haversine(coordinates[index - 1], coordinates[index])); + return distances; +} + +function haversine(a, b) { + const radians = Math.PI / 180; + const dLat = (b[1] - a[1]) * radians; + const dLon = (b[0] - a[0]) * radians; + const lat1 = a[1] * radians; + const lat2 = b[1] * radians; + const value = Math.sin(dLat / 2) ** 2 + Math.cos(lat1) * Math.cos(lat2) * Math.sin(dLon / 2) ** 2; + return 6371008.8 * 2 * Math.atan2(Math.sqrt(value), Math.sqrt(1 - value)); +} + +function distanceMeters(a, b) { + return a && b ? haversine(a, b) : Infinity; +} + +function readJson(file, label) { + try { return JSON.parse(fs.readFileSync(file, "utf8")); } catch (error) { throw new Error(`Invalid ${label} JSON '${file}': ${error.message}`); } +} + +function readFeatureCollection(file, label) { + const value = readJson(file, label); + if (value?.type !== "FeatureCollection" || !Array.isArray(value.features)) throw new Error(`Invalid ${label} '${file}': expected FeatureCollection`); + return value; +} + +function validateSettings(settings) { + for (const [key, value] of Object.entries(settings)) if (!Number.isFinite(value) || value <= 0) throw new Error(`Native traffic simulation setting '${key}' must be positive`); +} + +function sha256(file) { return crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex"); } +function round(value) { return Math.round(value * 100) / 100; } + +module.exports = { SCHEMA, DEFAULT_SETTINGS, buildNativeTrafficSimulation }; diff --git a/scripts/lib/native-road.js b/scripts/lib/native-road.js index 622895b..25d308a 100644 --- a/scripts/lib/native-road.js +++ b/scripts/lib/native-road.js @@ -267,7 +267,7 @@ function compileGeometry(model, overrides = { overrides: [] }, options = {}) { const centerLines = compileCenterLines(model, overrides, junctionPlans, controls, diagnostics); const markings = compileLaneMarkings(model, overrides, lanes, diagnostics, junctionPlans, controls); const sidewalks = compileSidewalkSurfaces(model, diagnostics, junctionPlans); - const connectorResult = compileConnectors(model, lanes, diagnostics, overrides); + const connectorResult = compileConnectors(model, lanes, diagnostics, overrides, junctionPlans); const junctionFeatures = compileJunctionSurfaces(model, junctionPlans, connectorResult.features, connectorResult.movements, diagnostics); validateConnectorContainment(connectorResult.features, junctionFeatures, diagnostics); return { roadSurface: { type: "FeatureCollection", features }, edgeLines: { type: "FeatureCollection", features: edgeLines }, 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 }; @@ -653,7 +653,7 @@ function compileLaneCenterlines(model, diagnostics, junctionPlans) { return { features, byRoadId }; } -function compileConnectors(model, lanes, diagnostics, overrides) { +function compileConnectors(model, lanes, diagnostics, overrides, junctionPlans) { const features = []; const movements = []; for (const connection of model.connections.filter((item) => item.enabled)) { @@ -669,8 +669,12 @@ function compileConnectors(model, lanes, diagnostics, overrides) { const override = laneOverride(overrides, defaultFromLane.id, defaultToLane.id); if ((!laneAllowsTurn(fromRoad, index, turn) && override?.enabled !== true) || override?.enabled === false) continue; const from = defaultFromLane.coordinates.at(-1); const to = defaultToLane.coordinates[0]; - const control = connectorControlPoint(model, connection, from, to); - const coordinates = quadraticCurve(from, control, to, 12); + const plan = junctionPlans.get(connection.nodeId); + // Cross intersections retain the earlier center-node curve while T junctions + // use lane tangents so their through movement does not bow toward the stem. + const coordinates = plan?.segmentIds.size === 4 + ? quadraticCurve(from, endpointCoordinate(model, connection.fromEndpointId), to, 12) + : connectorCurve(defaultFromLane.coordinates, defaultToLane.coordinates, turn); const length = lineLengthMeters(coordinates); const id = `movement:${connection.id}:${defaultFromLane.id}->${defaultToLane.id}`; const provenance = override ? `override:${override.id}` : connection.provenance; @@ -712,12 +716,47 @@ function targetLaneIndex(turn, sourceIndex, sourceCount, targetCount) { if (turn === "uturn") return 0; return Math.min(targetCount - 1, Math.round(sourceIndex / Math.max(1, sourceCount - 1) * Math.max(0, targetCount - 1))); } -function connectorControlPoint(model, connection, from, to) { - const node = endpointCoordinate(model, connection.fromEndpointId); - if (!node) return [(from[0] + to[0]) / 2, (from[1] + to[1]) / 2]; - // Nearby manual joins may not share exactly the same point. The midpoint - // keeps their curve smooth without rewriting the authoritative OSM geometry. - return node; +function connectorCurve(incoming, outgoing, turn) { + const start = incoming.at(-1); + const end = outgoing[0]; + if (turn === "through") return lineCurve(start, end, 12); + const incomingHeading = headingDegrees(incoming.at(-2), start); + const outgoingHeading = headingDegrees(end, outgoing[1]); + const chord = distanceMeters(start, end); + const incomingSpan = distanceMeters(incoming.at(-2), start); + const outgoingSpan = distanceMeters(end, outgoing[1]); + const tangentIntersection = intersectTangentRays(start, end, incomingHeading, outgoingHeading); + const fallbackDistance = Math.min(8, Math.max(.75, Math.min(chord * .42, incomingSpan * .8, outgoingSpan * .8))); + const firstDistance = tangentIntersection && tangentIntersection.incoming >= 0 ? Math.min(tangentIntersection.incoming, Math.min(8, Math.max(.75, incomingSpan * 2.4))) / 3 : fallbackDistance; + const secondDistance = tangentIntersection && tangentIntersection.outgoing >= 0 ? Math.min(tangentIntersection.outgoing, Math.min(8, Math.max(.75, outgoingSpan * 2.4))) / 3 : fallbackDistance; + const firstControl = offsetCoordinate(start, incomingHeading, firstDistance); + const secondControl = offsetCoordinate(end, outgoingHeading + 180, secondDistance); + return cubicBezier(start, firstControl, secondControl, end, 12); +} + +function intersectTangentRays(start, end, incomingHeading, outgoingHeading) { + const incoming = headingVector(incomingHeading); + const outgoing = headingVector(outgoingHeading); + const delta = project(end, start); + const cross = incoming[0] * outgoing[1] - incoming[1] * outgoing[0]; + if (Math.abs(cross) < 1e-6) return null; + return { + incoming: (delta[0] * outgoing[1] - delta[1] * outgoing[0]) / cross, + outgoing: (delta[0] * incoming[1] - delta[1] * incoming[0]) / cross, + }; +} + +function lineCurve(start, end, segments) { + return Array.from({ length: segments + 1 }, (_, index) => interpolate(start, end, index / segments)); +} + +function cubicBezier(a, firstControl, secondControl, b, segments) { + const result = []; + for (let index = 0; index <= segments; index += 1) { + const t = index / segments; const u = 1 - t; + result.push([u ** 3 * a[0] + 3 * u * u * t * firstControl[0] + 3 * u * t * t * secondControl[0] + t ** 3 * b[0], u ** 3 * a[1] + 3 * u * u * t * firstControl[1] + 3 * u * t * t * secondControl[1] + t ** 3 * b[1]]); + } + return result; } function quadraticCurve(a, control, b, segments) { diff --git a/scripts/test-asset-budgets.js b/scripts/test-asset-budgets.js index f494df4..157d67e 100644 --- a/scripts/test-asset-budgets.js +++ b/scripts/test-asset-budgets.js @@ -16,7 +16,8 @@ assert.match(qgisBuildSource, /QgsFieldConstraints\.Constraint\.ConstraintNotNul assert.match(qgisBuildSource, /QgsFieldConstraints\.ConstraintNotNull/); assert.doesNotMatch(qgisBuildSource, /setFieldConstraint\(index, 1\)/); assert.match(areaBuildSource, /exportCesium\(area, roadProvider\)/); -assert.match(areaBuildSource, /if \(roadProvider === "osm2streets"\) \{\n exporterArgs\.splice/); +assert.match(areaBuildSource, /"--dynamic-glb", area\.outputs\.trafficSignalsDynamicGlb/); +assert.match(areaBuildSource, /trafficSignalsCountdown0Glb: fileRecord/); assert.match(areaBuildSource, /if \(roadProvider === "osm2streets"\) \{[\s\S]*?buildPreviewVehicleRoute/); assert.match(areaBuildSource, /Object\.assign\(previewInputs, nativeRoadRecords\(area\)\)/); assert.match(areaBuildSource, /vehicleRoute: optionalFileRecord\(area\.outputs\.vehicleRoute\)/); @@ -56,6 +57,10 @@ assert.equal( normalizeAreaConfig(base).outputs.trafficSignalAssemblies, path.join(tempDir, "test-area", "osm2streets_web_out", "traffic_signal_assemblies.geojson"), ); +assert.equal( + normalizeAreaConfig(base).outputs.trafficSimulation, + path.join(tempDir, "test-area", "_preview", "test-area-traffic-simulation.json"), +); assert.equal(normalizeAreaConfig({ ...base, budget: { nodes: 800 } }).budget.glbNodes, 800); assert.equal(normalizeAreaConfig(base).stages.intermediates, false); assert.equal(normalizeAreaConfig(base).blender.roadProvider, "native"); diff --git a/scripts/test-native-preview-traffic.js b/scripts/test-native-preview-traffic.js new file mode 100644 index 0000000..7d0c7d3 --- /dev/null +++ b/scripts/test-native-preview-traffic.js @@ -0,0 +1,37 @@ +#!/usr/bin/env node +"use strict"; + +const assert = require("assert"); +const fs = require("fs"); +const os = require("os"); +const path = require("path"); +const { SCHEMA, buildNativeTrafficSimulation } = require("./lib/native-preview-traffic-simulation"); + +const areaDir = fs.mkdtempSync(path.join(os.tmpdir(), "native-preview-traffic-")); +const nativeRoadDir = path.join(areaDir, "native-road"); +const layersDir = path.join(nativeRoadDir, "layers"); +fs.mkdirSync(layersDir, { recursive: true }); +const roads = [ + { id: "road:a", centerline: [[120, 30], [120.001, 30]], sourceNodeIds: ["a", "b"] }, + { id: "road:b", centerline: [[120.001, 30], [120.001, 30.001]], sourceNodeIds: ["b", "c"] }, + { id: "road:c", centerline: [[120.001, 30.001], [120, 30.001]], sourceNodeIds: ["c", "d"] }, +]; +const movements = [ + ["road:a", "road:b", "left"], ["road:b", "road:c", "through"], ["road:c", "road:a", "right"], +].map(([fromRoadId, toRoadId, turn], index) => ({ id: `movement:${index}`, connectorId: `connector:${index}`, fromRoadId, toRoadId, turn, geometryPublished: true })); +fs.writeFileSync(path.join(nativeRoadDir, "compiled.json"), JSON.stringify({ model: { roads }, movements })); +fs.writeFileSync(path.join(nativeRoadDir, "traffic-signals.json"), JSON.stringify({ signals: [{ id: "osm-signal-1", phaseGroup: 0, approachId: "way:a", sourceWayId: "a", stopLongitude: 120.0009, stopLatitude: 30, enabled: true }] })); +fs.writeFileSync(path.join(layersDir, "connectors.geojson"), JSON.stringify({ type: "FeatureCollection", features: movements.map((movement, index) => ({ type: "Feature", properties: { movement_id: movement.id }, geometry: { type: "LineString", coordinates: [[[120.001, 30], [120.001, 30.0001]], [[120.001, 30.001], [120.0009, 30.001]], [[120, 30.001], [120, 30.0009]]][index] } })) })); +fs.writeFileSync(path.join(layersDir, "vehicle_stop_lines.geojson"), JSON.stringify({ type: "FeatureCollection", features: [{ type: "Feature", properties: { road_id: "road:a" }, geometry: { type: "Polygon", coordinates: [] } }] })); +fs.writeFileSync(path.join(layersDir, "lane_centerlines.geojson"), JSON.stringify({ type: "FeatureCollection", features: roads.map((road) => ({ type: "Feature", properties: { road_id: road.id, lane_index: 1 }, geometry: { type: "LineString", coordinates: road.centerline } })) })); + +const descriptor = buildNativeTrafficSimulation({ id: "fixture", outputs: { areaDir, nativeRoadDir } }); +assert.equal(descriptor.schema, SCHEMA); +assert.equal(descriptor.coordinateSystem.route, "WGS84"); +assert.ok(descriptor.routes.length >= 1); +assert.ok(descriptor.routes.some((route) => route.connectors.length === 3)); +assert.ok(descriptor.routes.some((route) => route.stops.some((stop) => stop.signalId === "osm-signal-1"))); +assert.equal(descriptor.migration.legacyDependency, false); +assert.equal(descriptor.source.length, 5); +fs.rmSync(areaDir, { recursive: true, force: true }); +console.log("Native preview traffic tests passed."); diff --git a/scripts/test-native-road.js b/scripts/test-native-road.js index 9a4346c..0e81869 100644 --- a/scripts/test-native-road.js +++ b/scripts/test-native-road.js @@ -148,6 +148,14 @@ assert.equal(sharedInteriorGeometry.intersectionSurface.features.length, 1); assert.equal(sharedInteriorGeometry.intersectionSurface.features[0].properties.osm_node_id, "2"); assert.equal(sharedInteriorGeometry.intersectionSurface.features[0].properties.kind, "t"); assert.ok(sharedInteriorGeometry.connectors.features.length >= 4); +const throughConnector = sharedInteriorGeometry.connectors.features.find((feature) => feature.properties.turn === "through"); +assert.ok(throughConnector, "T junction emits a through connector"); +const throughCoordinates = throughConnector.geometry.coordinates; +const throughStart = throughCoordinates[0]; const throughEnd = throughCoordinates.at(-1); +for (const point of throughCoordinates.slice(1, -1)) { + const area = Math.abs((throughEnd[0] - throughStart[0]) * (point[1] - throughStart[1]) - (throughEnd[1] - throughStart[1]) * (point[0] - throughStart[0])); + assert.ok(area < 1e-12, "through connector stays on its lane-to-lane chord instead of bending through the junction node"); +} assert.ok(sharedInteriorGeometry.sidewalkSurface.features.some((feature) => feature.properties.kind === "continuation" && /segment:way\/50\/1:.*->segment:way\/50\/2:/.test(feature.properties.native_id))); const connection = initial.connections[0]; assert.ok(initial.connections.every((item) => item.fromEndpointId.endsWith(":end") && item.toEndpointId.endsWith(":start"))); diff --git a/scripts/test-preview-assets.js b/scripts/test-preview-assets.js index 24b1156..199945b 100644 --- a/scripts/test-preview-assets.js +++ b/scripts/test-preview-assets.js @@ -278,6 +278,9 @@ assert.match(previewRuntime, /\(\) => record\?\.status === "normal"/); assert.match(previewRuntime, /Cesium\.SceneTransforms\.worldToWindowCoordinates/); assert.match(previewRuntime, /TrafficSignalDynamic_/); assert.match(previewRuntime, /TrafficSignalDynamic_\$\{nodeKey\}_countdown_\$\{String\(value\)\.padStart\(2, "0"\)\}/); +assert.match(previewRuntime, /native-preview-traffic-simulation\/v1/); +assert.match(previewRuntime, /leader-gap/); +assert.match(previewRuntime, /stopReason = "traffic-signal"/); assert.match(previewRuntime, /ColorBlendMode\.REPLACE/); assert.match(previewRuntime, /setBuildingGhost/); assert.match(previewRuntime, /fetch\(url, \{ cache: "no-store" \}\)/);