Compare commits
10 Commits
5accc0a4b1
...
9a8dbc1a74
| Author | SHA1 | Date | |
|---|---|---|---|
| 9a8dbc1a74 | |||
| 5403936ae4 | |||
| 0bc949bc24 | |||
| 6a0437ff83 | |||
| 3cad384b25 | |||
| 9e0e25aadf | |||
| e9ab1f03eb | |||
| 1154de3ba1 | |||
| c057258085 | |||
| 41d32457f2 |
@@ -8,14 +8,14 @@
|
||||
|
||||
## 两层配置
|
||||
|
||||
用户只写第一层,第二层是机器生成的中间产物:
|
||||
用户只写第一层;legacy QGIS 链路需要时才生成第二层中间产物:
|
||||
|
||||
```
|
||||
config/areas/<id>.json ← 你写的
|
||||
│ scripts/lib/area-config.js: normalizeAreaConfig()
|
||||
│ 补默认值 + 推导输出路径
|
||||
▼
|
||||
<areaDir>/_pipeline/osm2streets-qgis.config.json ← 生成的,不要手改
|
||||
<areaDir>/_pipeline/osm2streets-qgis.config.json ← legacy 生成的,不要手改
|
||||
│
|
||||
▼ build-osm2streets-qgis.js / reimport-gpkg.js
|
||||
```
|
||||
@@ -65,7 +65,7 @@ cp config/examples/template.json config/areas/my-area.json
|
||||
|
||||
| 字段 | 默认 | 说明 |
|
||||
|---|---|---|
|
||||
| `intermediates` | `true` | 旧名 `qgis` 仍被接受 |
|
||||
| `intermediates` | `false` | native-only 默认;旧名 `qgis` 仍被接受,显式开启才运行 legacy |
|
||||
| `blender` | `true` | |
|
||||
| `cesium` | `true` | |
|
||||
| `compress` | `true` | 压缩 staged GLB,供随后发布使用 |
|
||||
@@ -132,7 +132,7 @@ cp config/examples/template.json config/areas/my-area.json
|
||||
|---|---|---|
|
||||
| `treeStyle` | `"natural"` | 合法值见 `generate_scene.py` 的 `TREE_STYLES`(`natural`、`procedural`、`shapespark`) |
|
||||
| `officeOverrides` | `""` | 旧名 `office_overrides` 仍被接受 |
|
||||
| `roadProvider` | `"osm2streets"` | Blender 道路来源。`"native"` 时仅使用 `native-road/` 的道路、路口和人行道面;可由 `build:area --road-provider native` 临时覆盖。 |
|
||||
| `roadProvider` | `"native"` | Blender 道路来源。`"native"` 时仅使用 `native-road/` 的道路、路口和人行道面;`"osm2streets"` 仅用于显式 legacy/debug 构建。 |
|
||||
|
||||
### `compress`
|
||||
|
||||
@@ -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`**——它们能一次性影响全部派生路径。逐个覆盖容易漏。
|
||||
|
||||
@@ -1364,9 +1364,9 @@ gpkg: path.resolve(outputOverrides.gpkg || path.join(areaDir, `${fileStem}.gpkg`
|
||||
|
||||
| 阶段 | 做什么 | 读 | 写 |
|
||||
|---|---|---|---|
|
||||
| `intermediates` | OSM → osm2streets GeoJSON → GeoPackage → QGIS 工程 + 预览图 | `.osm` | `osm2streets_web_out/`、`.gpkg`、`.qgz`、`-preview.png` |
|
||||
| `intermediates` |(显式 legacy)OSM → osm2streets GeoJSON → GeoPackage → QGIS 工程 + 预览图 | `.osm` | `osm2streets_web_out/`、`.gpkg`、`.qgz`、`-preview.png` |
|
||||
| `reimport` | GeoPackage → GeoJSON(**反向**) | `.gpkg` | `osm2streets_web_out/` |
|
||||
| `blender` | OSM + GeoJSON → 场景 | `.osm`、`osm2streets_web_out/` | `.blend`、`.png` |
|
||||
| `blender` | 默认 OSM → native road compiler → Blender 场景;legacy provider 时读取 GeoJSON | `.osm`、`native-road/`(legacy 为 `osm2streets_web_out/`) | `.blend`、`.png` |
|
||||
| `cesium` | 场景 → static staging GLB + manifest | `.blend` | `_pipeline/package-staging/models/*.glb`、staged manifest;动态 GLB 到 `_preview/` |
|
||||
| `compress` | 压缩 Cesium staging 主模型 | staged `.glb`、manifest | 压缩 staged `.glb`、manifest |
|
||||
| `package` | 校验并原子发布静态资产包 | staging manifest 与 models | `package/manifest.json`、`package/models/*.glb` |
|
||||
@@ -1386,12 +1386,11 @@ gpkg: path.resolve(outputOverrides.gpkg || path.join(areaDir, `${fileStem}.gpkg`
|
||||
|
||||
未知阶段名**抛错并列出合法值**(`:181`),不静默忽略。
|
||||
|
||||
### `all` 不含 `reimport`
|
||||
### `all` 是 native-only 完整构建
|
||||
|
||||
```js
|
||||
// 'reimport' is deliberately absent from 'all': it is a recovery step for
|
||||
// hand-edited GeoPackages, never part of a full build. build-area.js:159-160
|
||||
all: ["intermediates", "blender", "cesium", "compress", "package", "preview"],
|
||||
// Legacy intermediates/reimport are deliberately absent from 'all'.
|
||||
all: ["blender", "cesium", "compress", "package", "preview"],
|
||||
```
|
||||
|
||||
`compress`、`package`、`preview` 均在 `all` 中,确保完整构建以压缩后的可复用静态 package
|
||||
|
||||
@@ -30,7 +30,7 @@ config/areas/<id>.json
|
||||
▼ build-area.js — 阶段调度
|
||||
_pipeline/osm2streets-qgis.config.json (派生配置)
|
||||
│
|
||||
├─[intermediates]─▶ build-osm2streets-qgis.js
|
||||
├─[intermediates]─▶ build-osm2streets-qgis.js(显式 legacy/reference)
|
||||
│ osm2streets-js-node 解析 .osm
|
||||
│ → splitLayers() 拆成九个图层
|
||||
│ → normalize-lane-arrows.py(QGIS Python)
|
||||
@@ -45,8 +45,8 @@ config/areas/<id>.json
|
||||
│ → 重建 scene.geojson + scene_style.json
|
||||
│ → _pipeline/stages/reimport.manifest.json
|
||||
│
|
||||
├─[blender]───────▶ Blender + blender/generate_scene.py
|
||||
│ 读 .osm + osm2streets_web_out/
|
||||
├─[blender]───────▶ compile-native-roads.js + Blender + blender/generate_scene.py
|
||||
│ 默认读 .osm + native-road/
|
||||
│ → <id>.blend + <id>.png
|
||||
│ → _pipeline/stages/blender.manifest.json
|
||||
│
|
||||
@@ -62,7 +62,7 @@ config/areas/<id>.json
|
||||
│ → package/manifest.json + package/models/*.glb
|
||||
│ → _pipeline/stages/package.manifest.json
|
||||
│
|
||||
└─[preview]───────▶ 生成 <id>-cesium-preview.html
|
||||
└─[preview]───────▶ 生成 <id>-cesium-preview.html(native 可无车辆路线)
|
||||
│ + 拷贝 lib/cesium-preview.{js,css}
|
||||
+ _preview/ 车辆巡航路线、模型、动态信号
|
||||
│ → _pipeline/stages/preview.manifest.json
|
||||
|
||||
@@ -31,7 +31,7 @@ classifyConnection(incomingEdge, outgoingEdge) =>
|
||||
`maneuvers`、`edgeIds`、`laneSegments` 与 `connectors`。道路区间来自匹配的 Driving lane polygon 中轴。
|
||||
- 路线拓扑以 `network.json` 的 internal road 和 intersection 为准;禁止把整个 OSM way 直接当作一条不可分割 edge。
|
||||
- connector 必须绑定同一个 internal intersection,并位于对应 `intersection_surface.geojson` 内或允许的边界容差内;越界时拒绝候选路线。
|
||||
- preview 必须将 `lane_polygons.geojson`、`network.json` 和 `intersection_surface.geojson` 作为强制输入;缺失或无效时在写产物前失败。
|
||||
- legacy osm2streets preview 必须将 `lane_polygons.geojson`、`network.json` 和 `intersection_surface.geojson` 作为强制输入;native preview 不读取这些文件,路线缺失时保留可用预览并省略车辆巡航。
|
||||
- route 经纬度由 Cesium 按 WGS84 直接放置;最终道路 GLB 必须由 WGS84 ECEF→ENU
|
||||
`Projector` 生成。禁止以固定米/度近似投影道路,否则即使 route 与 lane polygon
|
||||
完全一致,最终画面仍会随离锚点距离产生横向偏移。
|
||||
|
||||
@@ -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/<area>-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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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": {}
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
"name": "native-road-compiler",
|
||||
"title": "Native road compiler workbench",
|
||||
"description": "",
|
||||
"status": "in_progress",
|
||||
"status": "completed",
|
||||
"dev_type": null,
|
||||
"scope": null,
|
||||
"package": null,
|
||||
@@ -11,7 +11,7 @@
|
||||
"creator": "dingkang",
|
||||
"assignee": "dingkang",
|
||||
"createdAt": "2026-08-13",
|
||||
"completedAt": null,
|
||||
"completedAt": "2026-08-18",
|
||||
"branch": null,
|
||||
"base_branch": "feature/native-road-compiler",
|
||||
"worktree_path": null,
|
||||
@@ -0,0 +1 @@
|
||||
{"_example": "Fill with {\"file\": \"<path>\", \"reason\": \"<why>\"}. Put spec/research files only — no code paths. Run `python3 .trellis/scripts/get_context.py --mode packages` to list available specs. Delete this line once real entries are added."}
|
||||
@@ -0,0 +1,51 @@
|
||||
# Design
|
||||
|
||||
## Provider Boundary
|
||||
|
||||
`build-area.js` will resolve a native provider by default. Native stages consume only OSM, native
|
||||
road overrides, and `native-road/` outputs. Legacy `intermediates`, `reimport`, and osm2streets
|
||||
Blender input remain behind explicit legacy selection and keep their existing output contract.
|
||||
|
||||
## Native Data Flow
|
||||
|
||||
```text
|
||||
OSM + native-road-overrides.json
|
||||
-> compile-native-roads.js
|
||||
-> native-road/layers/*.geojson + compiled.json + traffic-signals.json
|
||||
-> Blender generate_scene.py --native-road --traffic-signals
|
||||
-> .blend
|
||||
-> Cesium/package GLB + manifest + runtime/traffic-signals.json
|
||||
-> preview HTML + optional native vehicle route
|
||||
```
|
||||
|
||||
The native compiler is the sole authority for road geometry, lane semantics, intersection surfaces,
|
||||
stop lines, and signal runtime. No native stage reads `geojsonDir` or legacy assembly files.
|
||||
|
||||
## Preview Route
|
||||
|
||||
Add a native route adapter that derives route segments from `compiled.json` model roads/endpoints and
|
||||
native lane/connector geometry. If the native route cannot be built for a valid area, preview remains
|
||||
usable without vehicles and records a warning; missing legacy osm2streets lane polygons is never a
|
||||
hard failure on the native path.
|
||||
|
||||
## Package Runtime
|
||||
|
||||
The package stage will copy the native-road signal runtime into its staging runtime directory and
|
||||
declare it in the package manifest. Preview receives the package-relative runtime URI. Legacy signal
|
||||
runtime publication remains conditional on the legacy provider.
|
||||
|
||||
## Configuration And Compatibility
|
||||
|
||||
`roadProvider` defaults to `native`; native stages are the default stage set. QGIS config fields and
|
||||
legacy stage aliases remain accepted for explicit migration/debug commands, but native manifests and
|
||||
docs do not claim them as inputs.
|
||||
|
||||
The Workbench may continue to read an existing `geojsonDir` only for an explicitly labeled reference
|
||||
layer and comparison counters. Those reads are optional, isolated from native compile state, and must
|
||||
never become required inputs for `/api/state`, native editing, package publication, or preview.
|
||||
|
||||
## Rollback
|
||||
|
||||
The original QGIS/osm2streets branch baseline is tagged
|
||||
`qgis-original-baseline-20260818` on `main`. Reverting the native-only work can therefore use the tag
|
||||
or switch to `main`; no destructive cleanup of legacy scripts is required.
|
||||
@@ -0,0 +1 @@
|
||||
{"_example": "Fill with {\"file\": \"<path>\", \"reason\": \"<why>\"}. Put spec/research files only — no code paths. Run `python3 .trellis/scripts/get_context.py --mode packages` to list available specs. Delete this line once real entries are added."}
|
||||
@@ -0,0 +1,27 @@
|
||||
# Implementation Plan
|
||||
|
||||
1. Add native provider/default-stage contracts and explicit legacy provider routing.
|
||||
2. Make package publication and stage manifests consume native runtime artifacts.
|
||||
3. Replace preview's hard dependency on osm2streets lane/network/intersection files with a native
|
||||
route adapter and optional-route behavior.
|
||||
4. Audit Blender, Cesium, preview, diagnostics, and area config for native-path legacy reads; retain
|
||||
only the Workbench's optional, clearly labeled osm2streets reference layer.
|
||||
5. Update README/config/spec documentation and add dependency-boundary regression tests.
|
||||
6. Run native-only tests with legacy directories absent, then run focused legacy compatibility tests.
|
||||
7. Run area compile/check plus available Blender/Cesium/preview validation; record any environment-only
|
||||
limitation without weakening native contracts.
|
||||
|
||||
## Validation
|
||||
|
||||
```bash
|
||||
npm run test:native-road
|
||||
npm run test:traffic-signals
|
||||
npm run test:road-workbench
|
||||
npm run test:preview-assets
|
||||
npm run road:compile -- --config config/areas/nantaizi-lake-innovation-valley.json
|
||||
npm run road:check -- --config config/areas/nantaizi-lake-innovation-valley.json
|
||||
node --check scripts/build-area.js
|
||||
```
|
||||
|
||||
The final gate must also exercise a native-only config/output directory without QGIS/osm2streets
|
||||
intermediates and assert package/runtime/preview outputs.
|
||||
@@ -0,0 +1,63 @@
|
||||
# Complete native-only pipeline replacement
|
||||
|
||||
## Goal
|
||||
|
||||
Make the native road compiler the primary and complete production path. A normal build of an
|
||||
area must generate Blender, Cesium/package, and preview outputs directly from OSM plus native
|
||||
artifacts without installing or running QGIS, GDAL, or osm2streets.
|
||||
|
||||
The existing QGIS/osm2streets workflow remains available only as an explicit legacy/debug path and
|
||||
an optional Workbench reference layer. It is protected by the `qgis-original-baseline-20260818` tag
|
||||
on `main`.
|
||||
|
||||
## Confirmed Current Gaps
|
||||
|
||||
- `normalizeAreaConfig()` defaults `stages.intermediates` to true and `blender.roadProvider` to
|
||||
`osm2streets`.
|
||||
- `build-area.js` sends `intermediates` to `build-osm2streets-qgis.js` and `reimport` to GDAL/QGIS.
|
||||
- Native Blender input exists, but `preview` still requires `osm2streets_web_out/lane_polygons.geojson`,
|
||||
`network.json`, and `intersection_surface.geojson` for vehicle-route generation.
|
||||
- Package/runtime publication still copies `outputs.<area>.trafficSignals` from the legacy GeoJSON
|
||||
directory instead of the native-road runtime artifact.
|
||||
- README, config templates, stage names, and manifest descriptions still present QGIS as the normal
|
||||
workflow.
|
||||
|
||||
## Requirements
|
||||
|
||||
- R1: Native is the default road provider and default build stages do not invoke QGIS, GDAL, or
|
||||
osm2streets.
|
||||
- R2: Native build generates all required road, lane, marking, junction, stop-line, and traffic-signal
|
||||
inputs consumed by Blender and preview; no native stage reads `osm2streets_web_out`.
|
||||
- R3: Native package publication copies runtime traffic signals and any other runtime descriptors
|
||||
from native-road outputs, with stable package-relative URIs.
|
||||
- R4: Native preview route generation uses native lane/road topology and native intersection geometry,
|
||||
or explicitly omits the optional vehicle route when native route data is unavailable; it must not
|
||||
fail because legacy osm2streets files are absent.
|
||||
- R5: QGIS/osm2streets stages remain callable only through an explicit legacy provider/stage selection
|
||||
and are not part of native defaults or native manifests. Workbench may display their existing
|
||||
GeoJSON as a clearly labeled reference/comparison layer, but native editing, compile, package, and
|
||||
preview results must not depend on it.
|
||||
- R6: Documentation and config templates describe native-only as the primary workflow and clearly
|
||||
mark the legacy path as transitional/debug-only.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] A clean native-only area build succeeds with QGIS absent and no `osm2streets-js-node` runtime
|
||||
call, producing `.blend`, package manifest/GLB, runtime traffic signals, and preview HTML.
|
||||
- [ ] Native preview opens when `osm2streets_web_out/` is absent; optional vehicle cruise either uses
|
||||
native route data or is omitted with a visible non-fatal diagnostic.
|
||||
- [ ] Native package manifest contains runtime traffic-signal anchors sourced from
|
||||
`native-road/traffic-signals.json`; disabled signals remain absent from runtime.
|
||||
- [ ] Workbench edits survive save, native recompile, package publication, and preview reload without
|
||||
QGIS or osm2streets files.
|
||||
- [ ] Explicit legacy QGIS/osm2streets commands still pass their existing focused tests, but no longer
|
||||
run when using native defaults.
|
||||
- [ ] Workbench remains usable when the optional osm2streets reference directory is absent; only the
|
||||
reference layer/comparison counters degrade to unavailable.
|
||||
- [ ] Tests assert the native-only dependency boundary and the QGIS baseline tag is documented.
|
||||
|
||||
## Out Of Scope
|
||||
|
||||
- Reimplementing QGIS editing features in the native compiler beyond existing Workbench controls.
|
||||
- Deleting legacy scripts or historical output files in this task; they remain rollback/debug tooling.
|
||||
- Changing Blender/Cesium visual semantics unrelated to removing the input dependency.
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"id": "native-only-pipeline-replacement",
|
||||
"name": "native-only-pipeline-replacement",
|
||||
"title": "Complete native-only pipeline replacement",
|
||||
"description": "",
|
||||
"status": "completed",
|
||||
"dev_type": null,
|
||||
"scope": null,
|
||||
"package": null,
|
||||
"priority": "P2",
|
||||
"creator": "dingkang",
|
||||
"assignee": "dingkang",
|
||||
"createdAt": "2026-08-18",
|
||||
"completedAt": "2026-08-18",
|
||||
"branch": null,
|
||||
"base_branch": "feature/native-road-compiler",
|
||||
"worktree_path": null,
|
||||
"commit": null,
|
||||
"pr_url": null,
|
||||
"subtasks": [],
|
||||
"children": [],
|
||||
"parent": null,
|
||||
"relatedFiles": [],
|
||||
"notes": "",
|
||||
"meta": {}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{"_example": "Fill with {\"file\": \"<path>\", \"reason\": \"<why>\"}. Put spec/research files only — no code paths. Run `python3 .trellis/scripts/get_context.py --mode packages` to list available specs. Delete this line once real entries are added."}
|
||||
@@ -0,0 +1 @@
|
||||
{"_example": "Fill with {\"file\": \"<path>\", \"reason\": \"<why>\"}. Put spec/research files only — no code paths. Run `python3 .trellis/scripts/get_context.py --mode packages` to list available specs. Delete this line once real entries are added."}
|
||||
@@ -3,7 +3,7 @@
|
||||
"name": "native-traffic-signal-parity",
|
||||
"title": "Align native traffic signals with QGIS",
|
||||
"description": "",
|
||||
"status": "in_progress",
|
||||
"status": "completed",
|
||||
"dev_type": null,
|
||||
"scope": null,
|
||||
"package": null,
|
||||
@@ -11,7 +11,7 @@
|
||||
"creator": "dingkang",
|
||||
"assignee": "dingkang",
|
||||
"createdAt": "2026-08-18",
|
||||
"completedAt": null,
|
||||
"completedAt": "2026-08-18",
|
||||
"branch": null,
|
||||
"base_branch": "feature/native-road-compiler",
|
||||
"worktree_path": null,
|
||||
@@ -8,7 +8,7 @@
|
||||
|
||||
<!-- @@@auto:current-status -->
|
||||
- **Active File**: `journal-1.md`
|
||||
- **Total Sessions**: 37
|
||||
- **Total Sessions**: 39
|
||||
- **Last Active**: 2026-08-18
|
||||
<!-- @@@/auto:current-status -->
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
<!-- @@@auto:active-documents -->
|
||||
| File | Lines | Status |
|
||||
|------|-------|--------|
|
||||
| `journal-1.md` | ~803 | Active |
|
||||
| `journal-1.md` | ~845 | Active |
|
||||
<!-- @@@/auto:active-documents -->
|
||||
|
||||
---
|
||||
@@ -29,6 +29,8 @@
|
||||
<!-- @@@auto:session-history -->
|
||||
| # | Date | Title | Commits | Branch |
|
||||
|---|------|-------|---------|--------|
|
||||
| 39 | 2026-08-18 | Complete native-only pipeline replacement | `9e0e25a` | `feature/native-road-compiler` |
|
||||
| 38 | 2026-08-18 | Complete native traffic signal workflow | `5accc0a` | `feature/native-road-compiler` |
|
||||
| 37 | 2026-08-18 | Complete rounded native junction verification | `e41bfd1`, `f7e71cf` | `feature/native-road-compiler` |
|
||||
| 36 | 2026-08-17 | Complete native road marking semantics | `9bb97d4` | `feature/native-road-compiler` |
|
||||
| 35 | 2026-08-17 | Complete native lane separator styles | `bc04517` | `feature/native-road-compiler` |
|
||||
|
||||
@@ -801,3 +801,45 @@ Recorded user-confirmed native Blender/Cesium/preview verification for rounded n
|
||||
### Status
|
||||
|
||||
[OK] **Completed**
|
||||
|
||||
|
||||
## Session 38: Complete native traffic signal workflow
|
||||
|
||||
**Date**: 2026-08-18
|
||||
**Task**: Complete native traffic signal workflow
|
||||
**Branch**: `feature/native-road-compiler`
|
||||
|
||||
### Summary
|
||||
|
||||
Completed native traffic-signal ownership and Workbench editing. Added independent pole position, mast heading/reach, and face heading controls; removed native QGIS dependency; verified runtime preview, browser rendering, compile, tests, and quality gates. Archived native traffic signal and road compiler tasks.
|
||||
|
||||
### Git Commits
|
||||
|
||||
| Hash | Message |
|
||||
|------|---------|
|
||||
| `5accc0a` | (see git log) |
|
||||
|
||||
### Status
|
||||
|
||||
[OK] **Completed**
|
||||
|
||||
|
||||
## Session 39: Complete native-only pipeline replacement
|
||||
|
||||
**Date**: 2026-08-18
|
||||
**Task**: Complete native-only pipeline replacement
|
||||
**Branch**: `feature/native-road-compiler`
|
||||
|
||||
### Summary
|
||||
|
||||
Made native road compilation the default provider and full-build stage set; decoupled package traffic signals and preview from QGIS/osm2streets, made diagnostics provider-aware, updated docs/specs/tests, and preserved explicit legacy reference/debug paths. Native road compile/check and all available Node tests passed. QGIS baseline remains tagged qgis-original-baseline-20260818.
|
||||
|
||||
### Git Commits
|
||||
|
||||
| Hash | Message |
|
||||
|------|---------|
|
||||
| `9e0e25a` | (see git log) |
|
||||
|
||||
### Status
|
||||
|
||||
[OK] **Completed**
|
||||
|
||||
43
README.md
43
README.md
@@ -1,6 +1,8 @@
|
||||
# OSM Asset Pipeline
|
||||
|
||||
把单个园区/片区 OSM XML 转为可消费的 Blender 场景和 Cesium GLB。osm2streets GeoJSON、GeoPackage、QGIS 工程和预览图都是中间资产,用来提供道路几何、调试标线效果,以及给 Blender/Cesium 生成提供输入。
|
||||
原始 QGIS/osm2streets 基线保留在 Git tag `qgis-original-baseline-20260818`,用于回撤和对照;当前分支的默认生产链路是 native-only。
|
||||
|
||||
把单个园区/片区 OSM XML 转为可消费的 Blender 场景和 Cesium GLB。Native road compiler 是默认生产道路来源;osm2streets GeoJSON、GeoPackage、QGIS 工程和预览图仅作为显式 legacy/debug 资产或 Workbench 参考层。
|
||||
|
||||
## 目标产物
|
||||
|
||||
@@ -17,17 +19,19 @@
|
||||
- `<area-id>-roads.glb`、`-buildings.glb`、`-vegetation.glb`、`-water.glb`:Cesium 分类检查用的辅助模型
|
||||
- `<area-id>.json`:Cesium 放置元数据和示例代码
|
||||
- `<area-id>-cesium-preview.html`:Cesium 本地预览页
|
||||
- `osm2streets_web_out/`:osm2streets GeoJSON 中间层
|
||||
- `native-road/`:native road compiler 的道路、路口、标线和信号灯运行时产物
|
||||
- `osm2streets_web_out/`:可选的 osm2streets/QGIS 参考中间层
|
||||
- `<area-id>.gpkg` / `<area-id>.qgz` / `<area-id>-preview.png`:QGIS 调试资产
|
||||
|
||||
## 环境
|
||||
|
||||
需要:
|
||||
默认 native-only 构建需要:
|
||||
|
||||
- QGIS 与 GDAL 工具链
|
||||
- Blender
|
||||
- Node.js / npm
|
||||
|
||||
QGIS/GDAL 仅在显式运行 legacy `intermediates`/`reimport` 或 Workbench 参考链路时需要。
|
||||
|
||||
macOS 默认使用 QGIS `/Applications/QGIS.app` 与 Blender
|
||||
`/Applications/Blender.app`。Linux 配置中,`qgisApp` 是安装前缀(通常为
|
||||
`/usr`),`blenderApp` 是可执行文件路径(通常为 `/usr/bin/blender`)。
|
||||
@@ -66,7 +70,7 @@ npm run build:area -- --config config/areas/nantaizi-lake-innovation-valley.json
|
||||
npm run build:area -- --config config/areas/nantaizi-lake-innovation-valley.json --stages reimport
|
||||
```
|
||||
|
||||
`intermediates` 会生成 osm2streets GeoJSON、GeoPackage、QGIS 工程和 QGIS 预览图。`blender` 使用 OSM 和 osm2streets GeoJSON 生成 `.blend`/`.png`。`cesium` 从 `.blend` 导出包 staging,`compress` 压缩 staging 主 GLB,`package` 校验并原子发布 `package/`,`preview` 生成仅用于本地验证的 HTML 和 `_preview/` 动态描述符。`reimport` 把手工编辑过的 GeoPackage 回导为 GeoJSON,不含在 `all` 里,详见 [QGIS 手工修正工作流](#qgis-手工修正工作流)。
|
||||
默认构建依次由 native road compiler、Blender、Cesium、压缩、package 和 preview 组成,不读取 `osm2streets_web_out/`。native preview 在没有 legacy 路线文件时仍可用,只是不提供可选车辆巡航。`intermediates`/`reimport` 仍可显式运行 legacy QGIS 链路,详见 [QGIS 手工修正工作流](#qgis-手工修正工作流)。
|
||||
|
||||
Cesium 预览默认显示完整场景。点击 `Inspect` 后会按道路、建筑、绿化与设施、水体加载辅助 GLB;该模式用于单独检查生成结果,主 GLB 是压缩后的下游交付资产。
|
||||
|
||||
@@ -110,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/<area-id>/_preview/<area-id>-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
|
||||
@@ -148,9 +168,9 @@ outputs/<area-id>/_pipeline/stages/compress.manifest.json
|
||||
造成假 stale。
|
||||
|
||||
manifest 记录阶段输入/输出文件的 bytes、mtime、sha256、耗时和结构摘要:前段记录 OSM /
|
||||
GeoJSON feature counts,Blender 记录 `.blend` / render,Cesium/压缩记录 GLB digest,
|
||||
preview 记录 GLB、metadata、`lane_polygons.geojson`、`network.json`、`intersection_surface.geojson`、
|
||||
车辆路线和 runtime 文件。巡航道路区间按 osm2streets internal road 匹配真实 Driving lane 中轴,
|
||||
native-road 或 legacy GeoJSON feature counts,Blender 记录 `.blend` / render,Cesium/压缩记录 GLB digest,
|
||||
preview 记录 GLB、metadata、provider 对应的道路输入、可选车辆路线和 runtime 文件。legacy
|
||||
巡航道路区间按 osm2streets internal road 匹配真实 Driving lane 中轴,native preview 可省略巡航,
|
||||
路口 connector 必须通过 intersection surface 越界检查。`diagnose:area` 会读取这些
|
||||
manifest;缺失或当前输入/输出 sha/bytes 不一致会在 `Stage manifests` 和 `Warnings`
|
||||
里标出来。
|
||||
@@ -201,7 +221,7 @@ cp config/examples/template.json config/areas/my-area.json
|
||||
"qgisApp": "/Applications/QGIS.app",
|
||||
"blenderApp": "/Applications/Blender.app",
|
||||
"stages": {
|
||||
"intermediates": true,
|
||||
"intermediates": false,
|
||||
"blender": true,
|
||||
"cesium": true
|
||||
},
|
||||
@@ -219,7 +239,8 @@ cp config/examples/template.json config/areas/my-area.json
|
||||
},
|
||||
"blender": {
|
||||
"treeStyle": "natural",
|
||||
"officeOverrides": ""
|
||||
"officeOverrides": "",
|
||||
"roadProvider": "native"
|
||||
},
|
||||
"compress": {
|
||||
"textureSize": 768,
|
||||
@@ -358,7 +379,7 @@ npm run build:area -- --config config/areas/nantaizi-lake-innovation-valley.json
|
||||
- 这套流程假设你的手工修改已经保存在 `outputs/<area-id>/<area-id>.gpkg` 中
|
||||
- 所有图层先导出到临时目录并校验通过后才写回 `osm2streets_web_out/`;任一图层缺失或导出结果不是合法 FeatureCollection,整批都不落盘(`ogr2ogr` 遇到不存在的图层会留下 0 字节文件,直接覆盖会静默损坏数据)
|
||||
- `intermediates` 与 `reimport` 互斥,同时指定会直接报错:前者用 OSM 重建 `gpkg`,正好会抹掉后者要读回的手工修改
|
||||
- `blender,cesium` 阶段读取的是 `osm2streets_web_out/*.geojson`,不是直接读取 `gpkg`
|
||||
- 在显式 legacy provider 下,`blender,cesium` 阶段读取的是 `osm2streets_web_out/*.geojson`,不是直接读取 `gpkg`;native provider 不读取这些文件
|
||||
- 如果 Blender 当前环境不稳定,先确认 `geojson` 已完成回导,再单独排查 Blender 本身
|
||||
- 增删图层或调整 `z_index` 只需改 `scripts/lib/scene-layers.js`,构建、场景合并、场景样式、QGIS 工程会一并同步
|
||||
|
||||
|
||||
56
config/areas/fengshu-er-road.json
Normal file
56
config/areas/fengshu-er-road.json
Normal file
@@ -0,0 +1,56 @@
|
||||
{
|
||||
"id": "fengshu-er-road",
|
||||
"input": "/Users/que01/osm2streets-qgis-workflow/inputs/osm/枫树二路.osm",
|
||||
"outputRoot": "/Users/que01/osm2streets-qgis-workflow/outputs",
|
||||
"qgisApp": "/Applications/QGIS.app",
|
||||
"blenderApp": "/Applications/Blender.app",
|
||||
"stages": {
|
||||
"intermediates": false,
|
||||
"blender": true,
|
||||
"cesium": true
|
||||
},
|
||||
"qgis": {
|
||||
"arrowScale": 0.8,
|
||||
"arrowMergeTriangles": true,
|
||||
"arrowOutlineSimplifyMeters": 0.05,
|
||||
"intersectionCornerSourceMaxDimensionMeters": 2.6,
|
||||
"clipPad": 0.002,
|
||||
"canvasPad": 0.001,
|
||||
"previewPad": 0.0007,
|
||||
"canvasExtent": null,
|
||||
"previewExtent": null,
|
||||
"layerPrefix": "osm2streets"
|
||||
},
|
||||
"turnLaneArrows": {
|
||||
"enabled": false
|
||||
},
|
||||
"nativeRoad": {
|
||||
"edgeLines": false
|
||||
},
|
||||
"osm2streets": {
|
||||
"debug_each_step": false,
|
||||
"dual_carriageway_experiment": false,
|
||||
"sidepath_zipping_experiment": false,
|
||||
"inferred_sidewalks": true,
|
||||
"osm2lanes": true
|
||||
},
|
||||
"blender": {
|
||||
"treeStyle": "natural",
|
||||
"officeOverrides": "",
|
||||
"roadProvider": "native"
|
||||
},
|
||||
"compress": {
|
||||
"textureSize": 768,
|
||||
"quality": 82,
|
||||
"effort": 80,
|
||||
"meshopt": false
|
||||
},
|
||||
"budget": {
|
||||
"glbSizeMb": 25,
|
||||
"nodes": 1000,
|
||||
"images": 24,
|
||||
"triangles": 250000,
|
||||
"embeddedImageBytesMb": 20,
|
||||
"reason": ""
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,7 @@
|
||||
"qgisApp": "/Applications/QGIS.app",
|
||||
"blenderApp": "/Applications/Blender.app",
|
||||
"stages": {
|
||||
"intermediates": true,
|
||||
"intermediates": false,
|
||||
"blender": true,
|
||||
"cesium": true
|
||||
},
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"qgisApp": "/Applications/QGIS.app",
|
||||
"blenderApp": "/Applications/Blender.app",
|
||||
"stages": {
|
||||
"intermediates": true,
|
||||
"intermediates": false,
|
||||
"blender": true,
|
||||
"cesium": true
|
||||
},
|
||||
@@ -36,6 +36,7 @@
|
||||
},
|
||||
"blender": {
|
||||
"treeStyle": "shapespark",
|
||||
"officeOverrides": ""
|
||||
"officeOverrides": "",
|
||||
"roadProvider": "native"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"qgisApp": "/Applications/QGIS.app",
|
||||
"blenderApp": "/Applications/Blender.app",
|
||||
"stages": {
|
||||
"intermediates": true,
|
||||
"intermediates": false,
|
||||
"blender": true,
|
||||
"cesium": true
|
||||
},
|
||||
@@ -37,7 +37,7 @@
|
||||
"blender": {
|
||||
"treeStyle": "natural",
|
||||
"officeOverrides": "",
|
||||
"roadProvider": "osm2streets"
|
||||
"roadProvider": "native"
|
||||
},
|
||||
"compress": {
|
||||
"textureSize": 768,
|
||||
|
||||
64
docs/native-preview-traffic-simulation.md
Normal file
64
docs/native-preview-traffic-simulation.md
Normal file
@@ -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.
|
||||
15593
inputs/osm/枫树二路.osm
Normal file
15593
inputs/osm/枫树二路.osm
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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": {
|
||||
|
||||
@@ -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 {
|
||||
@@ -69,10 +70,10 @@ if (stages.compress) {
|
||||
compressCesiumGlb(area);
|
||||
}
|
||||
if (stages.package) {
|
||||
publishPackage(area);
|
||||
publishPackage(area, roadProvider);
|
||||
}
|
||||
if (stages.preview) {
|
||||
writeCesiumPreview(area);
|
||||
writeCesiumPreview(area, roadProvider);
|
||||
}
|
||||
|
||||
console.log("Done.");
|
||||
@@ -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: {
|
||||
@@ -491,13 +486,16 @@ function compressCesiumGlb(area) {
|
||||
}
|
||||
}
|
||||
|
||||
function publishPackage(area) {
|
||||
function publishPackage(area, roadProvider) {
|
||||
ensureFile(area.outputs.packageStagingManifest, "Staged package manifest");
|
||||
ensureFile(area.outputs.trafficSignals, "Traffic signal anchors");
|
||||
const trafficSignalsSource = roadProvider === "native"
|
||||
? path.join(area.outputs.nativeRoadDir, "traffic-signals.json")
|
||||
: area.outputs.trafficSignals;
|
||||
ensureFile(trafficSignalsSource, "Traffic signal anchors");
|
||||
const started = Date.now();
|
||||
const manifest = JSON.parse(fs.readFileSync(area.outputs.packageStagingManifest, "utf8"));
|
||||
fs.mkdirSync(area.outputs.packageStagingRuntimeDir, { recursive: true });
|
||||
fs.copyFileSync(area.outputs.trafficSignals, area.outputs.packageStagingTrafficSignals);
|
||||
fs.copyFileSync(trafficSignalsSource, area.outputs.packageStagingTrafficSignals);
|
||||
manifest.runtime = Array.isArray(manifest.runtime) ? manifest.runtime : [];
|
||||
if (!manifest.runtime.some((runtime) => runtime.id === "traffic-signals")) {
|
||||
manifest.runtime.push({ id: "traffic-signals", type: "traffic-signal-anchors", uri: "runtime/traffic-signals.json" });
|
||||
@@ -551,29 +549,57 @@ function runCommand(command, commandArgs, stage) {
|
||||
}
|
||||
}
|
||||
|
||||
function writeCesiumPreview(area) {
|
||||
function writeCesiumPreview(area, roadProvider) {
|
||||
ensureFile(area.outputs.packageManifest, "Published asset package manifest");
|
||||
ensureFile(area.outputs.packageTrafficSignals, "Published traffic signal anchors");
|
||||
const lanePolygons = path.join(area.outputs.geojsonDir, "lane_polygons.geojson");
|
||||
const network = path.join(area.outputs.geojsonDir, "network.json");
|
||||
const intersectionSurface = path.join(area.outputs.geojsonDir, "intersection_surface.geojson");
|
||||
ensureFile(lanePolygons, "Driving lane polygons");
|
||||
ensureFile(network, "osm2streets network");
|
||||
ensureFile(intersectionSurface, "Intersection surfaces");
|
||||
// 在创建或覆盖任何 preview 产物前完成权威车道输入的解析与路线计算。
|
||||
const vehicleRoute = buildPreviewVehicleRoute(area.input, lanePolygons, network, intersectionSurface);
|
||||
let vehicleRoute = null;
|
||||
let routeArtifact = null;
|
||||
const previewInputs = {
|
||||
config: fileRecord(configPath),
|
||||
osm: fileRecord(area.input),
|
||||
previewCss: fileRecord(path.join(repoRoot, "scripts", "lib", "cesium-preview.css")),
|
||||
previewJs: fileRecord(path.join(repoRoot, "scripts", "lib", "cesium-preview.js")),
|
||||
};
|
||||
if (roadProvider === "osm2streets") {
|
||||
const lanePolygons = path.join(area.outputs.geojsonDir, "lane_polygons.geojson");
|
||||
const network = path.join(area.outputs.geojsonDir, "network.json");
|
||||
const intersectionSurface = path.join(area.outputs.geojsonDir, "intersection_surface.geojson");
|
||||
ensureFile(lanePolygons, "Driving lane polygons");
|
||||
ensureFile(network, "osm2streets network");
|
||||
ensureFile(intersectionSurface, "Intersection surfaces");
|
||||
vehicleRoute = buildPreviewVehicleRoute(area.input, lanePolygons, network, intersectionSurface);
|
||||
Object.assign(previewInputs, {
|
||||
lanePolygons: fileRecord(lanePolygons),
|
||||
network: fileRecord(network),
|
||||
intersectionSurface: fileRecord(intersectionSurface),
|
||||
});
|
||||
} 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();
|
||||
const startedAt = new Date(started).toISOString();
|
||||
fs.mkdirSync(path.dirname(htmlPath), { recursive: true });
|
||||
writeVehicleRoute(area, vehicleRoute);
|
||||
if (vehicleRoute) {
|
||||
writeVehicleRoute(area, vehicleRoute);
|
||||
} else {
|
||||
// Do not let a route from an earlier legacy preview survive into native output.
|
||||
fs.rmSync(area.outputs.vehicleRoute, { force: true });
|
||||
}
|
||||
const vehicleModelNames = writeVehicleModel(area);
|
||||
writeCesiumPreviewSupportFiles(path.dirname(htmlPath));
|
||||
const glbName = "package/manifest.json";
|
||||
const metadataName = "package/manifest.json";
|
||||
const routeName = previewRelativePath(area.outputs.areaDir, area.outputs.vehicleRoute);
|
||||
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: previewRelativePath(area.outputs.areaDir, area.outputs.vehicleRoute), vehicleModelName: previewRelativePath(area.outputs.areaDir, area.outputs.vehicleModel), vehicleModelNames: vehicleModelNames.map((name) => `_preview/${name}`), trafficSignalsName: "package/runtime/traffic-signals.json", assets: [] };
|
||||
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 });
|
||||
fs.writeFileSync(area.outputs.previewDescriptor, `${JSON.stringify(descriptor, null, 2)}\n`);
|
||||
fs.writeFileSync(htmlPath, cesiumPreviewHtml(glbName, metadataName, routeName, vehicleModelName, area.id, vehicleModelNames.map((name) => `_preview/${name}`), "package/runtime/traffic-signals.json", "_preview/descriptor.json"));
|
||||
@@ -587,23 +613,18 @@ function writeCesiumPreview(area) {
|
||||
finishedAt: new Date(finished).toISOString(),
|
||||
durationMs: finished - started,
|
||||
inputs: {
|
||||
config: fileRecord(configPath),
|
||||
osm: fileRecord(area.input),
|
||||
...previewInputs,
|
||||
glb: fileRecord(area.outputs.packagePrimaryGlb),
|
||||
metadata: fileRecord(area.outputs.packageManifest),
|
||||
lanePolygons: fileRecord(lanePolygons),
|
||||
network: fileRecord(network),
|
||||
intersectionSurface: fileRecord(intersectionSurface),
|
||||
previewCss: fileRecord(path.join(repoRoot, "scripts", "lib", "cesium-preview.css")),
|
||||
previewJs: fileRecord(path.join(repoRoot, "scripts", "lib", "cesium-preview.js")),
|
||||
},
|
||||
outputs: {
|
||||
cesiumPreview: fileRecord(area.outputs.cesiumPreview),
|
||||
vehicleRoute: fileRecord(area.outputs.vehicleRoute),
|
||||
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: [],
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
@@ -85,7 +86,9 @@ function normalizeAreaConfig(raw, options = {}) {
|
||||
qgisApp: raw.qgisApp || (process.platform === "darwin" ? "/Applications/QGIS.app" : "/usr"),
|
||||
blenderApp: raw.blenderApp || (process.platform === "darwin" ? "/Applications/Blender.app" : "/usr/bin/blender"),
|
||||
stages: {
|
||||
intermediates: raw.stages?.intermediates ?? raw.stages?.qgis ?? true,
|
||||
// Native road compilation is the default. Legacy QGIS/osm2streets
|
||||
// intermediates remain explicitly selectable for reference/debug work.
|
||||
intermediates: raw.stages?.intermediates ?? raw.stages?.qgis ?? false,
|
||||
blender: raw.stages?.blender ?? true,
|
||||
cesium: raw.stages?.cesium ?? true,
|
||||
reimport: false,
|
||||
@@ -121,7 +124,7 @@ function normalizeAreaConfig(raw, options = {}) {
|
||||
blender: {
|
||||
treeStyle: raw.blender?.treeStyle || "natural",
|
||||
officeOverrides: raw.blender?.officeOverrides || raw.blender?.office_overrides || "",
|
||||
roadProvider: roadProviderOption(raw.blender?.roadProvider),
|
||||
roadProvider: roadProviderOption(raw.blender?.roadProvider ?? "native"),
|
||||
},
|
||||
compress,
|
||||
budget,
|
||||
|
||||
@@ -323,13 +323,17 @@ function addEndpoint(map, ref) {
|
||||
}
|
||||
|
||||
function artifactStatus(area) {
|
||||
const native = area.blender.roadProvider === "native";
|
||||
const entries = [
|
||||
["GeoJSON dir", area.outputs.geojsonDir, true, "dir"],
|
||||
["GeoPackage", area.outputs.gpkg, true, "file"],
|
||||
["QGIS project", area.outputs.qgisProject, true, "file"],
|
||||
["QGIS preview", area.outputs.qgisPreview, true, "file"],
|
||||
["Traffic signal assemblies", area.outputs.trafficSignalAssemblies, true, "file"],
|
||||
["Traffic signal runtime", area.outputs.packageTrafficSignals, true, "file"],
|
||||
...(native ? [] : [
|
||||
["GeoJSON dir", area.outputs.geojsonDir, true, "dir"],
|
||||
["GeoPackage", area.outputs.gpkg, true, "file"],
|
||||
["QGIS project", area.outputs.qgisProject, true, "file"],
|
||||
["QGIS preview", area.outputs.qgisPreview, true, "file"],
|
||||
["Traffic signal assemblies", area.outputs.trafficSignalAssemblies, true, "file"],
|
||||
]),
|
||||
["Native road directory", area.outputs.nativeRoadDir, native, "dir"],
|
||||
["Traffic signal runtime", native ? path.join(area.outputs.nativeRoadDir, "traffic-signals.json") : area.outputs.packageTrafficSignals, true, "file"],
|
||||
["Blend scene", area.outputs.blend, true, "file"],
|
||||
["Render PNG", area.outputs.render, true, "file"],
|
||||
["Package manifest", area.outputs.packageManifest, true, "file"],
|
||||
@@ -373,6 +377,7 @@ function metadataSummary(file, warnings) {
|
||||
}
|
||||
|
||||
function stageManifestStatus(area, configPath = null) {
|
||||
const native = area.blender.roadProvider === "native";
|
||||
const compressionComplete = fs.existsSync(stageManifestPath(area, "compress"));
|
||||
const reimportManifest = stageManifestPath(area, "reimport");
|
||||
const hasReimportManifest = fs.existsSync(reimportManifest);
|
||||
@@ -430,10 +435,12 @@ function stageManifestStatus(area, configPath = null) {
|
||||
inputs: {
|
||||
...(configPath ? { config: configPath } : {}),
|
||||
osm: area.input,
|
||||
geojsonDir: area.outputs.geojsonDir,
|
||||
...sceneGeojsonFiles(area),
|
||||
trafficSignalAssemblies: area.outputs.trafficSignalAssemblies,
|
||||
trafficSignals: area.outputs.packageTrafficSignals,
|
||||
...(native ? nativeRoadRecords(area) : {
|
||||
geojsonDir: area.outputs.geojsonDir,
|
||||
...sceneGeojsonFiles(area),
|
||||
trafficSignalAssemblies: area.outputs.trafficSignalAssemblies,
|
||||
trafficSignals: area.outputs.packageTrafficSignals,
|
||||
}),
|
||||
},
|
||||
outputs: {
|
||||
blend: area.outputs.blend,
|
||||
@@ -461,18 +468,23 @@ function stageManifestStatus(area, configPath = null) {
|
||||
glb: area.outputs.glb,
|
||||
metadata: area.outputs.metadata,
|
||||
}),
|
||||
lanePolygons: path.join(area.outputs.geojsonDir, "lane_polygons.geojson"),
|
||||
network: path.join(area.outputs.geojsonDir, "network.json"),
|
||||
intersectionSurface: path.join(area.outputs.geojsonDir, "intersection_surface.geojson"),
|
||||
...(native ? nativeRoadRecords(area) : {
|
||||
lanePolygons: path.join(area.outputs.geojsonDir, "lane_polygons.geojson"),
|
||||
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: area.outputs.vehicleRoute,
|
||||
vehicleRoute: native ? optionalExpectedFile(area.outputs.vehicleRoute) : area.outputs.vehicleRoute,
|
||||
...(native ? { trafficSimulation: area.outputs.trafficSimulation } : {}),
|
||||
vehicleModel: area.outputs.vehicleModel,
|
||||
} : {
|
||||
cesiumPreview: area.outputs.cesiumPreview,
|
||||
vehicleRoute: area.outputs.vehicleRoute,
|
||||
vehicleRoute: native ? optionalExpectedFile(area.outputs.vehicleRoute) : area.outputs.vehicleRoute,
|
||||
...(native ? { trafficSimulation: area.outputs.trafficSimulation } : {}),
|
||||
vehicleModel: area.outputs.vehicleModel,
|
||||
},
|
||||
},
|
||||
@@ -597,6 +609,24 @@ function sceneGeojsonFiles(area) {
|
||||
return files;
|
||||
}
|
||||
|
||||
function nativeRoadRecords(area) {
|
||||
const root = path.join(area.outputs.nativeRoadDir, "layers");
|
||||
return {
|
||||
nativeRoadCompiled: path.join(area.outputs.nativeRoadDir, "compiled.json"),
|
||||
nativeRoadSignals: path.join(area.outputs.nativeRoadDir, "traffic-signals.json"),
|
||||
nativeRoadSurface: path.join(root, "road_surface.geojson"),
|
||||
nativeEdgeLines: path.join(root, "edge_lines.geojson"),
|
||||
nativeIntersectionSurface: path.join(root, "intersection_surface.geojson"),
|
||||
nativeSidewalkSurface: path.join(root, "sidewalk_surface.geojson"),
|
||||
nativeLaneSeparators: path.join(root, "lane_separators.geojson"),
|
||||
nativeCenterLines: path.join(root, "center_lines.geojson"),
|
||||
nativeDirectionArrows: path.join(root, "direction_arrows.geojson"),
|
||||
nativeTurnArrows: path.join(root, "turn_arrows.geojson"),
|
||||
nativeCrosswalks: path.join(root, "crosswalks.geojson"),
|
||||
nativeVehicleStopLines: path.join(root, "vehicle_stop_lines.geojson"),
|
||||
};
|
||||
}
|
||||
|
||||
function collectWarnings(area, osm, artifacts, manifests, glb, metadata) {
|
||||
const warnings = [];
|
||||
if (!osm.bounds) warnings.push("OSM has no valid <bounds>; scene extent may be wrong.");
|
||||
|
||||
@@ -123,15 +123,15 @@ function escapeScriptJson(value) {
|
||||
.replaceAll("\u2029", "\\u2029");
|
||||
}
|
||||
|
||||
function previewSummary(area) {
|
||||
const route = JSON.parse(fs.readFileSync(area.outputs.vehicleRoute, "utf8"));
|
||||
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: path.relative(area.outputs.areaDir, area.outputs.vehicleRoute).split(path.sep).join("/"),
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use strict";
|
||||
|
||||
const STAGES = [
|
||||
{ id: "intermediates", label: "OSM / QGIS intermediates", description: "Rebuild GeoJSON and GeoPackage from OSM" },
|
||||
{ id: "intermediates", label: "Legacy QGIS intermediates", description: "Explicit legacy osm2streets/QGIS reference build" },
|
||||
{ id: "reimport", label: "Reimport QGIS edits", description: "Copy GeoPackage layers back to GeoJSON" },
|
||||
{ id: "blender", label: "Blender scene", description: "Generate the editable scene and render" },
|
||||
{ id: "cesium", label: "Cesium export", description: "Export static package staging assets" },
|
||||
@@ -11,7 +11,7 @@ const STAGES = [
|
||||
];
|
||||
|
||||
const ALIASES = {
|
||||
all: ["intermediates", "blender", "cesium", "compress", "package", "preview"],
|
||||
all: ["blender", "cesium", "compress", "package", "preview"],
|
||||
qgis: ["intermediates"], osm2streets: ["intermediates"], geojson: ["intermediates"], intermediate: ["intermediates"],
|
||||
intermediates: ["intermediates"], reimport: ["reimport"], gpkg: ["reimport"], blender: ["blender"], scene: ["blender"],
|
||||
cesium: ["cesium"], glb: ["cesium"], preview: ["preview"], html: ["preview"], cesiumPreview: ["preview"],
|
||||
|
||||
@@ -88,6 +88,7 @@
|
||||
// The route file is an extra on top of the scene, not a precondition for it.
|
||||
// A missing or unreadable route costs the cruise controls, not the preview.
|
||||
async function fetchOptionalJson(url) {
|
||||
if (!url) return null;
|
||||
try {
|
||||
return await fetchJson(url);
|
||||
} catch (error) {
|
||||
@@ -554,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);
|
||||
@@ -573,7 +585,8 @@
|
||||
const cruise = {
|
||||
vehicles,
|
||||
baseSpeed: speed,
|
||||
state: { selectedIndex: 0 }
|
||||
state: { selectedIndex: 0 },
|
||||
simulation,
|
||||
};
|
||||
syncSelectedRouteVisibility(cruise);
|
||||
return cruise;
|
||||
@@ -731,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) {
|
||||
@@ -945,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) => {
|
||||
@@ -1229,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(", "));
|
||||
}
|
||||
|
||||
259
scripts/lib/native-preview-traffic-simulation.js
Normal file
259
scripts/lib/native-preview-traffic-simulation.js
Normal file
@@ -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 };
|
||||
@@ -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) {
|
||||
|
||||
@@ -16,7 +16,11 @@ 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\)/);
|
||||
|
||||
const gltf = {
|
||||
nodes: [
|
||||
@@ -53,8 +57,13 @@ 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).blender.roadProvider, "osm2streets");
|
||||
assert.equal(normalizeAreaConfig(base).stages.intermediates, false);
|
||||
assert.equal(normalizeAreaConfig(base).blender.roadProvider, "native");
|
||||
assert.equal(normalizeAreaConfig({ ...base, blender: { roadProvider: "native" } }).blender.roadProvider, "native");
|
||||
assert.throws(
|
||||
() => normalizeAreaConfig({ ...base, blender: { roadProvider: "other" } }),
|
||||
@@ -78,8 +87,9 @@ assert.equal(
|
||||
);
|
||||
|
||||
const configPath = path.join(tempDir, "area.json");
|
||||
fs.writeFileSync(configPath, `${JSON.stringify(base)}\n`);
|
||||
const area = normalizeAreaConfig(base);
|
||||
const legacyBase = { ...base, blender: { roadProvider: "osm2streets" } };
|
||||
fs.writeFileSync(configPath, `${JSON.stringify(legacyBase)}\n`);
|
||||
const area = normalizeAreaConfig(legacyBase);
|
||||
assert.equal(area.stages.compress, true);
|
||||
assert.equal("compressedGlb" in area.outputs, false);
|
||||
assert.equal("compressedMetadata" in area.outputs, false);
|
||||
|
||||
@@ -4,7 +4,7 @@ const assert = require("assert");
|
||||
const fs = require("fs");
|
||||
const { resolveStages, canonicalStages } = require("./lib/build-stages");
|
||||
assert.deepEqual(canonicalStages(resolveStages({}, ["compress", "blender", "preview"])), ["blender", "compress", "preview"]);
|
||||
assert.deepEqual(canonicalStages(resolveStages({}, ["all"])), ["intermediates", "blender", "cesium", "compress", "package", "preview"]);
|
||||
assert.deepEqual(canonicalStages(resolveStages({}, ["all"])), ["blender", "cesium", "compress", "package", "preview"]);
|
||||
assert.deepEqual(canonicalStages(resolveStages({ intermediates: true, blender: true, cesium: true, compress: true, package: true })), ["intermediates", "blender", "cesium", "compress", "package"]);
|
||||
assert.throws(() => resolveStages({}, ["intermediates", "reimport"]), /mutually exclusive/);
|
||||
const buildAreaSource = fs.readFileSync(require("path").join(__dirname, "build-area.js"), "utf8");
|
||||
|
||||
37
scripts/test-native-preview-traffic.js
Normal file
37
scripts/test-native-preview-traffic.js
Normal file
@@ -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.");
|
||||
@@ -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")));
|
||||
|
||||
@@ -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" \}\)/);
|
||||
|
||||
Reference in New Issue
Block a user