42 Commits

Author SHA1 Message Date
9a8dbc1a74 feat: 枫树二路 2026-08-18 17:15:17 +08:00
5403936ae4 feat: add native preview traffic simulation 2026-08-18 16:57:27 +08:00
0bc949bc24 fix: clear stale legacy preview route 2026-08-18 14:29:13 +08:00
6a0437ff83 chore: record journal 2026-08-18 14:17:08 +08:00
3cad384b25 chore(task): archive 08-18-native-only-pipeline-replacement 2026-08-18 14:15:45 +08:00
9e0e25aadf feat: make native road pipeline the default 2026-08-18 14:15:08 +08:00
e9ab1f03eb chore(task): plan native-only pipeline replacement 2026-08-18 13:59:42 +08:00
1154de3ba1 chore: record journal 2026-08-18 11:49:30 +08:00
c057258085 chore(task): archive 08-13-native-road-compiler 2026-08-18 11:48:34 +08:00
41d32457f2 chore(task): archive 08-18-native-traffic-signal-parity 2026-08-18 11:47:16 +08:00
5accc0a4b1 feat: complete native traffic signal workflow 2026-08-18 11:46:19 +08:00
7204c28161 chore: record journal 2026-08-18 09:27:57 +08:00
a63a3ae0d8 chore(task): archive 08-17-native-rounded-junctions 2026-08-18 09:26:58 +08:00
f7e71cfe9c fix: disable native edge lines by default 2026-08-18 09:07:29 +08:00
e41bfd13ec feat: smooth native junction boundaries 2026-08-17 17:39:07 +08:00
46542c5f4e chore: record journal 2026-08-17 16:09:50 +08:00
1896884900 chore(task): archive 08-17-native-road-marking-semantics 2026-08-17 16:09:37 +08:00
9bb97d4507 feat: add native road marking semantics 2026-08-17 16:08:56 +08:00
bd38aa55d3 chore(task): archive 08-17-native-road-edge-markings 2026-08-17 15:26:59 +08:00
b318f7b641 feat: add native road edge markings 2026-08-17 15:25:18 +08:00
d065dcdaf3 chore: record journal 2026-08-17 14:47:24 +08:00
472a83c670 chore(task): archive 08-17-native-lane-separator-styles 2026-08-17 14:46:09 +08:00
bc045177a5 feat: add native lane separator style overrides 2026-08-17 14:45:48 +08:00
8409d2c6c3 chore: record journal 2026-08-17 13:34:25 +08:00
5f59ad0ef0 chore(task): archive 08-17-native-road-center-line-styles 2026-08-17 13:33:30 +08:00
47efb78f1e feat: add native center line style overrides 2026-08-17 13:29:54 +08:00
8bfc02e66e chore: record journal 2026-08-17 10:45:52 +08:00
9d42181246 chore(task): archive 08-17-native-road-center-lines 2026-08-17 10:44:50 +08:00
fb863dafb2 feat: add native road center lines 2026-08-17 10:44:01 +08:00
7dc5c947a3 chore: record journal 2026-08-17 10:12:27 +08:00
866b59acf8 chore(task): archive 08-17-native-road-control-markings 2026-08-17 10:10:38 +08:00
4c4f4534c0 feat: add native road control markings 2026-08-17 10:01:44 +08:00
ea8a3622b9 chore: record journal 2026-08-14 18:06:00 +08:00
03a8d6159b chore(task): archive 08-14-native-road-lane-markings 2026-08-14 18:05:43 +08:00
1b9829d9ed feat: add native road direction arrows 2026-08-14 18:05:21 +08:00
822e6ef936 feat: report native junction quality metrics 2026-08-14 16:21:26 +08:00
3eea12c6ea fix: stabilize native road Cesium preview 2026-08-14 16:07:55 +08:00
3d57655497 feat: inspect native junctions in workbench 2026-08-14 15:31:48 +08:00
65cf8b96d9 feat: add native road compiler provider 2026-08-14 15:19:57 +08:00
e1f3fc10ca fix: persist and render native sidewalks 2026-08-14 10:53:20 +08:00
f43a122ff7 fix: accept inferred road widths in workbench 2026-08-14 10:43:47 +08:00
3e22a1872e feat: clarify road movement editing feedback 2026-08-14 10:38:22 +08:00
115 changed files with 19954 additions and 221 deletions

View File

@@ -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,6 +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` | `"native"` | Blender 道路来源。`"native"` 时仅使用 `native-road/` 的道路、路口和人行道面;`"osm2streets"` 仅用于显式 legacy/debug 构建。 |
### `compress`
@@ -186,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`**——它们能一次性影响全部派生路径。逐个覆盖容易漏。

View File

@@ -367,6 +367,341 @@ if (!networkSaysIntersection && (roadCounts.get(endpoint.id) || 0) < 3) return n
fs.writeFileSync(diagnosticsPath, `${JSON.stringify(diagnostics, null, 2)}\n`);
```
## Native 道路标线
### 1. 范围与触发条件
`node scripts/compile-native-roads.js --config <area>` 将原生道路标线写入
`outputs/<area>/native-road/layers/`。它不读取 osm2streets 渲染几何;只复用
`turn-lane-arrows.js` 中已测试的箭头模板。工作台 `GET /api/state` 原样服务这些
GeoJSONnative Blender 构建通过 `catalog.NATIVE_ROAD_LAYERS` 消费它们。
### 2. 图层契约
| 文件 | 语义 | 必需 provenance | Blender material layer |
|---|---|---|---|
| `lane_separators.geojson` | 同向相邻车道的分隔线 | `native-road-lane-separator/v1` | `lane_separators` |
| `direction_arrows.geojson` | 沿定向车道重复的直行方向箭头 | `native-road-direction-arrow/v1` | `lane_arrows_webscale` |
| `turn_arrows.geojson` | 明确 `turn:lanes` 的路口动作箭头 | `native-road-turn-arrow/v1` | `lane_arrows_webscale` |
方向箭头必须带 `road_id``lane_id``osm_way_ids``direction``lane_index`
`sequence``distance_along_lane_meters``placement_interval_meters`。转向箭头
必须带 `maneuver``placement_distance_meters`。两者不能共用 provenance 或假装为
彼此:前者表达沿路行驶方向,后者表达路口处允许动作。
### 3. 放置与错误矩阵
| 条件 | 结果 |
|---|---|
| 车道长度不足以容纳两端 14m 缓冲 | 不生成道路方向箭头 |
| 可用车道长度 | 从 14m 起按 32m 间距生成 `through` 箭头 |
| OSM 未提供 `turn:lanes` | 不生成路口转向箭头 |
| `turn:lanes` 存在但动作不受已测试模板支持 | 记录诊断,不猜测动作 |
| native Blender 构建缺任一图层文件 | 在 `ensureNativeRoadLayers()` 失败,不能静默漏画 |
### 4. 必需测试
- `npm run test:native-road`:方向箭头的 Polygon、provenance、间距以及无标签道路
不生成路口转向箭头。
- `npm run test:road-workbench`:方向箭头开关、选择溯源和概览标签。
- `npm run build:area -- --config config/areas/nantaizi-lake-innovation-valley.json --stages blender,cesium,preview --road-provider native`:日志必须列出
`direction_arrows``turn_arrows`,且不运行 `package`
### 5. 错误与正确写法
错误:把默认 `through` 当作路口 `turn:lanes` 动作,统一写入 `turn_arrows.geojson`
正确:道路方向箭头进入 `direction_arrows.geojson`;只有 OSM 明确标注的动作进入
`turn_arrows.geojson`。工作台用两个开关呈现Blender 复用同一现有箭头材质。
## Native 普通路口圆角
### 1. 范围与触发条件
`compile-native-roads.js` 为普通 T / 十字路口生成 `intersection_surface.geojson`
`sidewalk_surface.geojson` 的路口边界。路口道路面必须在同一 cutback 处结束,不能
用未裁剪的道路矩形覆盖圆角边界。
### 2. 几何契约
- 相邻道路边缘使用两条支持切线的交点作为二次曲线控制点;采样段数由
`JUNCTION_CURVE_SEGMENTS` 统一控制。
- 机动车路口边界、人行道内侧路缘和人行道外侧边界都必须使用同一切线圆角规则;外侧
不能只对内侧采样点做线性偏移,避免内外曲率不一致。
- `boundary_mode` 使用 `rounded-approach-envelope`,无法安全构造的角保持确定性直线
回退,并写入 `junction-rounded-corner-fallback` warning。
- 已发布的 connector 必须包含在最终边界内,边界退化或 connector 越界时才允许使用
`connector-convex-fallback`
### 3. 校验与错误矩阵
| 条件 | 结果 |
|---|---|
| 支持切线交点有限且曲线不过远 | 生成采样圆角 |
| 切线近似平行或交点退化 | 保留该角直线并记录 `junction-rounded-corner-fallback` |
| 边界自相交或 connector 越界 | 使用 connector 凸包兜底;仍自相交则不发布路口面 |
### 4. 必需测试
- `npm run test:native-road`:普通 T / 十字路口的圆角顶点数、内收方向、内外人行道
曲线和 continuation 语义。
- `npm run test:road-workbench`:工作台仍能加载 native 路口及人行道图层。
- `npm run road:compile -- --config config/areas/nantaizi-lake-innovation-valley.json`
- `npm run road:check -- --config config/areas/nantaizi-lake-innovation-valley.json`
### 5. 错误与正确写法
错误:先对路缘生成圆角,再把外侧边界按每个采样点线性平移;这会导致内外曲率不同,
在人行道角落留下不一致的折面。
正确:对内侧和外侧分别用相同的道路边缘支持切线规则生成曲线,仅在外侧切线退化时
使用确定性的偏移回退。
## Native 道路中心虚线
### 1. 范围与触发条件
`node scripts/compile-native-roads.js --config <area>` 为可确认的双向非 service
道路段写入 `native-road/layers/center_lines.geojson`。这是原生几何:只能依据
canonical OSM 中心线、native 双方向道路模型和 native 路口 cutback 生成osm2streets
`center_lines.geojson` 只可作为视觉基准,绝不能作为输入。
### 2. 调用形式
```bash
npm run road:compile -- --config config/areas/nantaizi-lake-innovation-valley.json
npm run road:workbench -- --config config/areas/nantaizi-lake-innovation-valley.json
npm run build:area -- --config config/areas/nantaizi-lake-innovation-valley.json --stages blender,cesium,preview --road-provider native
```
工作台 `GET /api/state` 通过 `layers.centerLines` 返回该 FeatureCollectionnative
Blender adapter 必须将 source `center_lines` 映射到既有 `center_lines` material layer。
### 3. 契约
- 每一 dash 均为 Polygon`2m`、宽 `0.25m`、确定性间隔 `2m`,使用
`native-road-center-line/v1` provenance。
- 要素必须含有 `native_id``segment_id``road_id``directional_road_ids`
`osm_way_ids``dash_index``dash_length_m``dash_gap_m``placement_rule`,以便
Workbench 用中文显示“道路中心虚线”并可回溯来源。
- 只有同一 `segment_id` 恰有一条 forward 和一条 backward native road 时才生成;单向、
`highway=service` 或退化中心线都不能伪造中心线。退化中心线须写
`invalid-center-line` diagnostic。
- 线段必须先经过 native junction cutback再与 crosswalk、vehicle stop line 的控制面
求冲突,冲突 dash 直接略去。控制标线优先于中心虚线。
- `compiled.json.layers.centerLines``comparison.json.nativeCenterLineFeatures`
`build-area.js` 的 native input records 与 required-layer 验证必须全部使用
`layers/center_lines.geojson`;缺失时在 Blender 启动前失败,不得静默漏画。
### 4. 校验与错误矩阵
| 条件 | 结果 |
|---|---|
| 可确认的双向普通道路段 | 生成 2m / 0.25m 黄虚线,固定 2m gap |
| 单向或 `highway=service` 道路 | 不生成中心虚线 |
| 中心线不足两点、长度不可用 | `invalid-center-line` diagnostic不写畸形 Polygon |
| dash 进入 junction cutback | trim 后不生成该范围 dash |
| dash 与斑马线或停止线相交 | 不生成冲突 dash |
| native Blender 输入缺 `center_lines.geojson` | `ensureNativeRoadLayers()` 抛错 |
### 5. 正常、基础与错误示例
- 正常:一条有 forward/backward carriageway 的 residential 段在两个方向道路之间生成黄虚线。
- 基础:没有双向证据的道路仍可有车道分隔线,但不产生道路中心虚线。
- 错误:从 osm2streets 图层复制或裁剪中心线;这会将渲染器缺陷重新变成 native 数据依赖。
### 6. 必需测试
- `npm run test:native-road`断言双向生成、provenance、2m 尺寸/间隔、单向和 service 跳过。
- `npm run test:road-workbench`:断言 `centerLines` API、中文开关、选择溯源和概览计数。
- `npm run road:compile -- --config config/areas/nantaizi-lake-innovation-valley.json`:输出合法图层,检查中心线与控制标线不重叠。
- `npm run build:area -- --config config/areas/nantaizi-lake-innovation-valley.json --stages blender,cesium,preview --road-provider native`:日志列出 `center_lines`,且不运行 `package`
### 7. 错误与正确写法
错误:只按一条 directional road 生成中心线,或忽略控制标线。
```js
const line = road.centerline;
features.push(makeDash(line));
```
正确:先确认成对的双方向道路、做路口裁剪,再排除控制标线冲突。
```js
if (roads.length !== 2 || !forward || !backward || forward.highway === "service") continue;
const line = trimLineAtJunctions(forward.centerline, forward.sourceNodeIds, junctionPlans);
if (!ringsOverlapControl([ring], [...controls.crosswalks, ...controls.stopLines])) features.push(dash);
```
## Native 道路中心线样式覆写
### 1. 范围与触发条件
Road Workbench 选中 `native-road-center-line/v1` 要素后,可为其 `segment_id`
保存样式覆写。覆写属于 `native-road-overrides.json`,不是对
`center_lines.geojson` 的手工编辑;重新编译必须从覆写重建图层。
### 2. 调用形式
```json
{
"id": "道路中心线:segment:way/123/1",
"kind": "center-line-style",
"segmentId": "segment:way/123/1",
"color": "white",
"pattern": "solid"
}
```
### 3. 契约
- `color` 只能是 `yellow``white``pattern` 只能是 `dashed``solid`
目标 `segmentId` 必须属于当前 native road model。
- `double: true` 只表示双黄实线,因此只能与 `color: "yellow"`
`pattern: "solid"` 组合。编译器为每段的每个有效实线 mark 写两条相距 0.32m 的
平行 Polygon并记录 `double: true``effective_style:
"double-yellow-solid"`;不得把它实现为可任意组合的双线开关。
- 未覆写段保持黄色虚线2m dash、2m gap`solid` 为 0 gap但相邻的 2m
几何块须重叠 `0.04m`,避免投影精度造成可见裂缝。
- 每个生成面记录 `color``pattern``effective_style`。Workbench 以这些属性
着色native Blender 将 white centre lines 分派至 `Native Center Line White`
yellow 则继续复用 `Center Line`
- 下拉框变化即暂存覆写,顶部“保存并重新生成”是唯一写盘/重编译动作;不要求用户
再点击一个容易遗漏的暂存按钮。
### 4. 校验与错误矩阵
| 条件 | 结果 |
|---|---|
| 合法颜色、图案和当前 segment | 保存后重新生成有效样式 |
| 非法颜色/图案或不存在 segment | `validateOverrides()` 拒绝整个请求 |
| 实线块触及控制标线 | 该块略去,不以连续性为由穿过控制标线 |
| 白色中心线进入 native Blender | 使用白线材质,不改变 legacy 图层材质 |
### 5. 正常、基础与错误示例
- 正常:点选任意 dash选择“白色实线”保存重编译后整段显示连续白线。
- 基础:选择“黄色虚线(默认)”仍是显式覆写,但几何与默认规则一致。
- 错误:只在 Workbench 改填充色Blender/Cesium 会继续显示旧黄色。
### 6. 必需测试
- `npm run test:native-road`:合法/非法样式覆写、solid 属性、控制标线避让。
- `npm run test:road-workbench`:中文样式面板、下拉框自动暂存和 API payload。
- `python3 -m unittest discover blender/tests`catalog 仍是可导入的纯 Python。
- Nantaizi native `blender,cesium,preview` 构建:既有 yellow centre lines 不回归。
### 7. 错误与正确写法
错误:实线块仅以零间隔精确相接,且每块使用高对比 outline。
正确:小幅重叠相邻块,并让 Workbench 实线 stroke 与 fill 同色。
## Native 道路外缘线样式覆写
`edge_lines.geojson` 是车行道最外侧边界标线,使用 `native-road-edge-line/v1`
provenance。单向道路生成左右两条外缘线双向道路每个方向只生成远离道路中心的外缘
线,双向道路中间分界由 `center_lines.geojson` 负责。默认是白色实线,必须带
`road_id``side``left` / `right`)、`osm_way_ids``color``pattern`
`effective_style`
区域配置中的 `nativeRoad.edgeLines` 默认是 `false`;因此 native Blender/Cesium 默认不
生成或显示该层。只有明确设置为 `true` 才会发布 `edge_lines.geojson` 并交给下游消费。
Road Workbench 点选外缘线后以中文显示其方向侧边,可暂存以下 area-local 覆写:
```json
{
"id": "道路外缘线:road:way/123:forward:left",
"kind": "edge-line-style",
"roadId": "road:way/123:forward",
"side": "left",
"color": "white",
"pattern": "solid"
}
```
`roadId` 必须是当前 directional native road 的精确 ID`side` 必须为 `left``right`
实线使用连续 offset line虚线使用确定性
2m mark / 2m gap。工作台的暂存与“保存并重新生成”是唯一的写盘路径不能只改浏览器填充色
native Blender 将该层映射到既有 `lane_separators` material layer。
## Native 控制标线
### 1. 范围与触发条件
`node scripts/compile-native-roads.js --config <area>` 为 native road provider
生成 `crosswalks.geojson``vehicle_stop_lines.geojson`。这是原生道路的独立
产物,禁止读取 osm2streets 的渲染图层作为几何输入。
### 2. 调用形式
```bash
npm run road:compile -- --config config/areas/nantaizi-lake-innovation-valley.json
npm run road:workbench -- --config config/areas/nantaizi-lake-innovation-valley.json
```
工作台 `GET /api/state` 通过 `layers.crosswalks`
`layers.vehicleStopLines` 返回两个 FeatureCollection。
### 3. 契约
- 只有 `highway=crossing``crossing:markings` 不是 `no``none`
`unmarked` 的 OSM 节点可以生成斑马线。每个安全匹配点生成六条 stripe
`crossing_node_id``road_id``lane_id``osm_way_ids``direction`
`placement_method``junction_inset_m``native-road-crosswalk/v1` provenance。
- 停止线还必须对应一个启用的 native arrival endpoint且过街节点位于该进口到
路口的安全距离内;其 provenance 是 `native-road-stop-line/v1`。不能确认进口
时保留斑马线并写 `crossing-no-safe-stop-line` diagnostic不得猜测一条线。停止线
必须复用斑马线的 `junction_inset_m`,保持与斑马线的上游间距。
- 有安全进口且存在普通路口 plan 时,斑马线中心推进到 cutback 内约 1.5 米;单次
最大推进 4 米。`junction_inset_m` 记录实际推进量,避免远离路口的 crossing 被过度
移动。
- `catalog.NATIVE_ROAD_LAYERS` 将两个源层映射到现有的 `crosswalks`
`vehicle_stop_lines` 材质层。不得把它们加入 legacy `SCENE_LAYERS`
- 控制标线优先于箭头:默认直行箭头与其相交时跳过;路口转向箭头依次尝试在距路口
6、10、14、18、22 米处放置,均冲突时记录 `turn-arrow-control-conflict`
### 4. 校验与错误矩阵
| 条件 | 结果 |
|---|---|
| 标记过街没有可匹配 native lane | `crossing-no-native-lane`,不生成任何控制标线 |
| 有横道但没有安全进口方向 | 生成斑马线,记录 `crossing-no-safe-stop-line`,不生成停止线 |
| 箭头与任一控制标线相交 | 直行箭头跳过;转向箭头后移或记录冲突 diagnostic |
| native Blender 输入缺任一控制图层 | `ensureNativeRoadLayers()` 在启动 Blender 前失败 |
### 5. 正常、基础与错误示例
- 正常:一个靠近路口的 marked crossing 生成 6 条斑马线和 1 条进口停止线。
- 基础:一条孤立的 marked crossing 可以生成斑马线,但不能凭邻近道路方向臆造停止线。
- 错误:先生成箭头再叠加停止线,导致两者重叠;控制标线是道路控制语义,必须优先。
### 6. 必需测试
- `npm run test:native-road`:断言 marked / unmarked / 无 native lane 的输出,停止线的
provenance、斑马线与停止线共享 `junction_inset_m`,以及箭头遇控制标线时后移。
- `npm run test:road-workbench`:断言 controls 开关、两条 API layer 和中文选中溯源。
- `npm run road:compile -- --config config/areas/nantaizi-lake-innovation-valley.json`:核查两层
feature count 及每条停止线都是 native arrival direction。
- `npm run build:area -- --config config/areas/nantaizi-lake-innovation-valley.json --stages blender,cesium,preview --road-provider native`:不运行 `package`
### 7. 错误与正确写法
错误:以最近任意方向车道和固定正向偏移生成停止线。
```js
const stopCenter = offsetByMeters(nearestLane.point, nearestLane.axis, 2.7);
```
正确:先确认该方向的终点是一个已启用的路口 arrival再在人行横道的上游生成停止线。
```js
const approach = candidates.find((item) => arrivalEndpointIds.has(`endpoint:${item.road.id}:end`));
const stopCenter = offsetByMeters(laneCenterAtCrossing, approach.placement.axis, -2.7);
```
## 斑马线与停止线来源
### 1. 范围与触发条件
@@ -1029,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` |(显式 legacyOSM → 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` |
@@ -1051,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

View File

@@ -120,6 +120,56 @@ SCENE_LAYERS.forEach((layer, index) => {
## Blender 调用
### macOS Blender 4.5 的 Metal 启动兼容
#### 1. Scope / Trigger
`export_cesium.py` 在 macOS 的 Blender 4.5.12 后台启动时,可能在 Python 脚本加载前的 Metal 扩展探测中崩溃;这不是场景或道路数据错误。
#### 2. Signatures
Cesium 阶段的调用参数必须包含:
```text
--background --factory-startup --debug-gpu-force-workarounds --python blender/export_cesium.py -- ...
```
#### 3. Contracts
`--debug-gpu-force-workarounds` 是 Blender 的官方 CLI 参数。它只约束导出进程的 GPU 扩展探测,不改变 `.blend`、GeoJSON 或导出脚本的输入输出契约。
#### 4. Validation & Error Matrix
| 情况 | 结果 |
|---|---|
| 缺少该参数且启动时崩在 Metal 初始化 | 不应归因于道路数据;补齐参数后重跑 Cesium 阶段 |
| 参数存在且 `CESIUM_EXPORT_DONE` / stage manifest 写出 | 继续 GLB digest 与预览验证 |
#### 5. Good / Base / Bad Cases
- Good: 保留 `--factory-startup`,并在 Cesium 导出加入 workaround。
- Base: Blender 场景阶段未受影响时,不额外改变其启动参数。
- Bad: 为绕过启动崩溃删除 `--factory-startup`,这会重新引入本机偏好和 addon 的不确定性。
#### 6. Tests Required
- `npm run test:build-stages` 断言导出参数仍包含 workaround。
- 对目标区域运行 `--stages blender,cesium,preview`,并用 `glb-digest.js` 解析输出。
#### 7. Wrong vs Correct
Wrong:
```text
--background --python blender/export_cesium.py
```
Correct:
```text
--background --factory-startup --debug-gpu-force-workarounds --python blender/export_cesium.py
```
### 两种调用姿势
| 阶段 | 参数 | 出处 |

View File

@@ -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.pyQGIS 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.htmlnative 可无车辆路线)
│ + 拷贝 lib/cesium-preview.{js,css}
+ _preview/ 车辆巡航路线、模型、动态信号
│ → _pipeline/stages/preview.manifest.json

View File

@@ -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
完全一致,最终画面仍会随离锚点距离产生横向偏移。

View File

@@ -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."}

View File

@@ -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.

View File

@@ -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."}

View File

@@ -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.

View File

@@ -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.

View File

@@ -1,7 +1,7 @@
{
"id": "native-road-compiler",
"name": "native-road-compiler",
"title": "Native road compiler workbench",
"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,
@@ -10,7 +10,7 @@
"priority": "P2",
"creator": "dingkang",
"assignee": "dingkang",
"createdAt": "2026-08-13",
"createdAt": "2026-08-18",
"completedAt": null,
"branch": null,
"base_branch": "feature/native-road-compiler",

View File

@@ -0,0 +1,32 @@
{
"id": "native-road-compiler",
"name": "native-road-compiler",
"title": "Native road compiler workbench",
"description": "",
"status": "completed",
"dev_type": null,
"scope": null,
"package": null,
"priority": "P2",
"creator": "dingkang",
"assignee": "dingkang",
"createdAt": "2026-08-13",
"completedAt": "2026-08-18",
"branch": null,
"base_branch": "feature/native-road-compiler",
"worktree_path": null,
"commit": null,
"pr_url": null,
"subtasks": [],
"children": [
"08-14-native-road-lane-markings",
"08-17-native-road-control-markings",
"08-17-native-road-center-lines",
"08-17-native-rounded-junctions",
"08-18-native-traffic-signal-parity"
],
"parent": null,
"relatedFiles": [],
"notes": "",
"meta": {}
}

View File

@@ -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."}

View File

@@ -0,0 +1,77 @@
# Native Lane Markings Design
## Architecture
The native compiler remains the source of truth. It derives two new polygon
layers alongside its existing surface, sidewalk, lane-centerline, and
connector outputs:
```text
canonical directed roads + lane centerlines + junction cutbacks
|
+-- lane separators: paint polygons between adjacent same-direction lanes
|
+-- direction arrows: repeated through-arrow template on directed lanes,
| outside the reserved junction marking zone
|
+-- turn arrows: tested existing template, anchored to an incoming lane
only when that lane has an explicit supported turn:lanes value
```
No OSM2streets rendered geometry is consumed by this path. The existing arrow
template library is reused only as a geometry/style asset, so the native lane
ID and OSM tags remain the evidence for placement.
## Contracts
- `layers/lane_separators.geojson`: polygon FeatureCollection. Each feature
records `native_id`, directed `road_id`, adjacent lane indices, source OSM
ways, and `native-road-lane-separator/v1` provenance.
- `layers/turn_arrows.geojson`: polygon FeatureCollection. Each feature records
its `native_id`, `road_id`, `lane_id`, OSM way IDs, direction, lane index,
maneuver, template asset, placement distance, and placement provenance.
- Unsupported turn values, a missing usable incoming-lane segment, or an
insufficient pre-junction placement distance create a diagnostic and no
arrow geometry.
- `layers/direction_arrows.geojson`: polygon FeatureCollection. Each feature
records the native lane and directed road, OSM way IDs, a stable sequence
index, its distance along the lane, and `native-road-direction-arrow/v1`
provenance. It uses the tested `through` template but is not a turn claim.
- The workbench serves both layers, draws them separately from its current
centerline/connector debug layer, and selects them by `native_id`.
- `catalog.NATIVE_ROAD_LAYERS` maps the two native sources to the existing
`lane_separators` and `lane_arrows_webscale` Blender materials. The native
adapter does not extend the osm2streets scene-layer registry.
## Placement
An incoming lane is oriented in driving direction. A turn arrow is sampled
from that lane's endpoint backwards by the configured safe distance, staying
outside the junction cutback. Its template basis uses the sampled lane tangent;
therefore it is on and aligned with the lane rather than the OSM centerline or
a screen-space direction. Multiple template rings remain separate polygons.
Lane separators are narrow polygons centered between adjacent lane centerlines
on a single directional carriageway. They stop at the same junction cutbacks as
the lane centerlines. A one-lane direction produces none.
Direction arrows are sampled at a fixed road-scale interval along the same
directed lane centerline. Their candidates exclude both endpoint buffers and
the turn-arrow reserve at the incoming end. This preserves a readable repeated
direction cue without overlapping a turn instruction at a junction.
## Compatibility And Rollback
All new files are additive under `native-road/layers/`. The existing
osm2streets/QGIS layer contract and `package/` are unchanged. Selecting
`--road-provider osm2streets` remains rollback. Missing native marking files
are a native Blender build error rather than a silent omission.
## Risks
- OSM turn tags can be incomplete or incompatible with the inferred lane
count. These are diagnostics, not guessed arrows.
- Very short approaches can have no safe position before the cutback. They are
skipped with a source-traceable diagnostic.
- Blender and Cesium need a real native build to verify the mesh/material
contract, not only GeoJSON unit tests.

View File

@@ -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."}

View File

@@ -0,0 +1,31 @@
# Implementation Plan
1. Add reusable template-placement helper to the existing turn-arrow module,
preserving its supported-asset gate and output ring shape.
2. Extend the native compiler with lane-separator, repeated road-direction
arrow, and explicit-turn-arrow polygon generation plus source diagnostics
and layer persistence.
3. Extend native output records/counts and Blender adapter mappings. Reuse
existing `lane_separators` and `lane_arrows_webscale` materials only.
4. Add Workbench layers, toggles, selection/provenance inspector entries, and
preserve the existing Workbench-only direction triangle behavior.
5. Add focused fixtures for supported turn placement, unsupported maneuver,
short approach skip, and separator geometry; run native/workbench/build
stage tests.
6. Build Nantaizi with `blender,cesium,preview --road-provider native`, inspect
the final preview, and confirm no `package/` publication occurred.
## Validation
```bash
npm run test:native-road
npm run test:road-workbench
npm run test:turn-lane-arrows
npm run test:build-stages
npm run road:compile -- --config config/areas/nantaizi-lake-innovation-valley.json
npm run build:area -- --config config/areas/nantaizi-lake-innovation-valley.json \
--stages blender,cesium,preview --road-provider native
```
Rollback is selecting `--road-provider osm2streets`; no existing output path
is replaced.

View File

@@ -0,0 +1,74 @@
# Native road lane markings and turn arrows
## Goal
Complete the native-road visual language for Nantaizi before any cross-area
migration: lane separators, travel-direction markers, and OSM-backed turn
arrows must be inspectable in the Road Workbench and visible in Blender and
Cesium output.
## Confirmed Facts
- Native output currently contains directed lane centerlines and connector
curves, but it does not emit paintable lane-separator or turn-arrow polygons.
- The existing osm2streets path has tested arrow templates in
`scripts/lib/turn-lane-arrows.js`, the `lane_arrows_webscale` material layer,
and a matching Blender material. Reuse these instead of introducing a second
arrow style.
- Earlier reviews established that direction markers must sit on the OSM / lane
centerline, use a clearly directional sharp triangle, and never be treated as
a road-surface decoration that drifts sideways.
- Turn arrows must follow the actual incoming lane and be placed before its
junction, with OSM source way, direction, lane index, and maneuver retained
as provenance.
- Scope remains Nantaizi only. Existing osm2streets output remains untouched.
## Requirements
- R1: Native compilation emits polygonal lane-separator markings derived from
its own directed lane geometry.
- R2: Native compilation emits turn-arrow polygons for supported OSM
`turn:lanes` maneuvers, using the existing tested arrow templates and the
exact native incoming-lane centerline for placement.
- R2a: Native compilation emits repeated straight-ahead direction-arrow
polygons along directed lanes, matching the visual role of osm2streets'
ordinary road arrows. These are a separate layer from turn arrows, retain
their own provenance, and leave a clear buffer around junction turn arrows.
- R3: Native output preserves provenance for every marking: native road/lane
ID, OSM way IDs, direction, lane number, maneuver, and placement method.
- R4: The Workbench renders markings in a separately controllable layer and
exposes those provenance fields on selection.
- R5: The native Blender adapter consumes native marking layers through the
existing lane-separator and lane-arrow material layers; Cesium must receive
the same geometry through the exported GLB.
- R6: Unsupported, unplaceable, or ambiguous arrow inputs become diagnostics;
the compiler must not invent a maneuver.
## Acceptance Criteria
- [ ] Nantaizi native output contains valid polygon GeoJSON for generated lane
separators, repeated road direction arrows, and every supported, explicitly
tagged turn arrow.
- [ ] A Workbench user can toggle, select, and inspect a generated marker and
see its lane, OSM, maneuver, and placement provenance.
- [ ] A selected direction marker is geometrically aligned to its directed lane
centerline; a selected turn arrow is on its incoming lane before the junction.
- [ ] Blender scene output and Cesium GLB contain native lane markings and
arrows with the existing visual material language.
- [ ] Unit tests cover a normal supported arrow, an unsupported maneuver, and
an unsafe/too-short placement; native compile and final Nantaizi visual build
pass without publishing `package/`.
## Out Of Scope
- Inventing turn arrows for untagged lanes, traffic-control semantics, changing
QGIS/osm2streets layers, or processing another area.
## Key Decision
- Sharp travel-direction triangles remain a Workbench-only inspection aid.
They explain raw OSM node order after a road is selected.
- Repeated `through` direction arrows are final road markings, distinct from
both those debug triangles and OSM-backed junction turn arrows. They are
placed on native lane centerlines at a fixed interval and enter the same
Blender/Cesium material layer as turn arrows.

View File

@@ -0,0 +1,26 @@
{
"id": "native-road-lane-markings",
"name": "native-road-lane-markings",
"title": "Native road lane markings and turn arrows",
"description": "Complete Nantaizi native lane separators, travel direction and turn-arrow geometry through Road Workbench, Blender and Cesium before cross-area migration.",
"status": "completed",
"dev_type": null,
"scope": null,
"package": null,
"priority": "P1",
"creator": "dingkang",
"assignee": "dingkang",
"createdAt": "2026-08-14",
"completedAt": "2026-08-14",
"branch": null,
"base_branch": "feature/native-road-compiler",
"worktree_path": null,
"commit": null,
"pr_url": null,
"subtasks": [],
"children": [],
"parent": "08-13-native-road-compiler",
"relatedFiles": [],
"notes": "",
"meta": {}
}

View File

@@ -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."}

View File

@@ -0,0 +1,35 @@
# Native Lane Separator Style Overrides Design
## Contract
The override is identified by the stable `roadId` plus the adjacent lane pair:
```json
{
"id": "车道分隔线:road:way/123:forward:1-2",
"kind": "lane-separator-style",
"roadId": "road:way/123:forward",
"leftLaneIndex": 1,
"rightLaneIndex": 2,
"color": "white",
"pattern": "dashed"
}
```
The same `yellow|white` and `dashed|solid` enums are reused. A default is
white dashed. The generated Polygon stores `color`, `pattern` and
`effective_style`; source IDs remain unchanged.
## Rendering
The existing lane separator polygon represents the whole lane-pair path.
`solid` retains it as one continuous feature. `dashed` samples regular dash
polygons along the shared centreline with deterministic spacing and preserves
junction cutback. White uses the existing `lane_separators` material; yellow
uses a native-only yellow material route. Workbench separates lane separators
from turn arrows so their styles remain selectable and visible.
## Compatibility
No override means current visual default. Road edges, curbs and centre lines
are untouched. Existing `native-road-overrides/v1` files remain valid.

View File

@@ -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."}

View File

@@ -0,0 +1,7 @@
# Implementation Plan
1. Validate and resolve per-lane-pair style overrides.
2. Generate default/dashed/solid style geometry and effective properties.
3. Add the native Blender yellow lane-separator material route.
4. Add Workbench selection, Chinese editor and automatic staging.
5. Test compiler, Workbench, Blender catalog and Nantaizi native build.

View File

@@ -0,0 +1,47 @@
# Native lane separator style overrides
## Goal
Make native same-direction lane separators selectable and persistently editable
in Road Workbench, using the style override pattern proven for centre lines.
## Confirmed Facts
- `compileLaneMarkings()` writes one continuous `0.12m` Polygon separator
between each adjacent lane pair, identified by `road_id`, `left_lane_index`
and `right_lane_index`.
- Lane separators currently have fixed light marking material and are displayed
together with turn arrows in Workbench.
- `native-road-overrides.json` already supports validated, persistent centre
line styles by logical native segment. The new type must not alter road-edge,
curb or sidewalk geometry.
## Initial Requirements
- Selecting a lane separator must show its lane-pair source and a Chinese
style editor.
- The first style catalog should mirror centre lines: white/yellow and
dashed/solid.
- The effective style must be generated into GeoJSON, visible in Workbench,
and consumed by native Blender/Cesium.
- Default output must remain the current white dashed-style separator.
## Out Of Scope
- Road-edge lines, curbs, sidewalks, double lines, partial ranges, lane-specific
legal restrictions and osm2streets layers.
## Key Decision
Each override applies only to the selected adjacent lane pair. This preserves
separate marking semantics on roads with three or more lanes.
## Acceptance Criteria
- [ ] Selecting a separator exposes a Chinese style editor for its adjacent
lane pair; choosing a style stages it automatically.
- [ ] Save and regeneration preserve the per-pair style across reloads while
unedited separators retain the default.
- [ ] GeoJSON, Workbench, Blender and Cesium show the same effective style.
- [ ] Tests cover validation, defaults, per-pair isolation, automatic staging
and native scene consumption.

View File

@@ -0,0 +1,26 @@
{
"id": "native-lane-separator-styles",
"name": "native-lane-separator-styles",
"title": "Native lane separator style overrides",
"description": "",
"status": "completed",
"dev_type": null,
"scope": null,
"package": null,
"priority": "P2",
"creator": "dingkang",
"assignee": "dingkang",
"createdAt": "2026-08-17",
"completedAt": "2026-08-17",
"branch": null,
"base_branch": "feature/native-road-compiler",
"worktree_path": null,
"commit": null,
"pr_url": null,
"subtasks": [],
"children": [],
"parent": null,
"relatedFiles": [],
"notes": "",
"meta": {}
}

View File

@@ -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."}

View File

@@ -0,0 +1,64 @@
# Native Road Centre-Line Style Overrides Design
## Architecture
```text
Workbench selects a generated centre-line dash
-> segment_id identifies the logical road segment
-> staged center-line-style override
-> native-road-overrides.json
-> compileCenterLines resolves default or effective style
-> center_lines.geojson polygons with style fields
-> Workbench / Blender / Cesium
```
The authoritative edit is a new override kind, not a mutation of
`center_lines.geojson`. It has a deterministic ID based on `segmentId`, so a
later recompilation replaces the segment's style rather than accumulating
records.
## Override Contract
```json
{
"id": "道路中心线:segment:way/123/1",
"kind": "center-line-style",
"segmentId": "segment:way/123/1",
"color": "yellow",
"pattern": "dashed"
}
```
`validateOverrides()` accepts only known model segment IDs and the finite
string enums `yellow|white` and `dashed|solid`. A style override applies once
to the paired forward/backward native roads for that segment. The existing
schema version remains `native-road-overrides/v1` because this is an additive
kind and old files remain valid.
## Geometry and Material
- Default remains yellow dashed: 2m dash, 2m gap, 0.25m width.
- `solid` generates deterministic adjacent 2m pieces with no gap. Pieces still
undergo the same junction cutback and control-marking exclusion as dashed
lines; this avoids creating a solid polygon across an excluded crossing.
- Style fields `color`, `pattern`, `dash_length_m`, `dash_gap_m`, and
`effective_style` are stored on every generated polygon.
- Blender currently maps all `center_lines` to one yellow material, so the
native adapter must support a white centre-line material route without
changing legacy osm2streets layers. The route must preserve the existing
yellow material for default and yellow overrides.
## Workbench UX
Selecting a centre-line dash shows a compact Chinese style panel in the
existing form area. A select control presents the four named choices. Choosing
one stages an override; the existing 保存 / 保存并重新生成 actions remain the
only persistence and generation actions. The panel also shows whether the
style is default or overridden and identifies the native road segment.
## Compatibility and Rollback
Unedited segments generate byte-compatible geometry style defaults apart from
the added style properties. Oneway/service filtering and control priority are
unchanged. Selecting `--road-provider osm2streets` remains a full rollback.

View File

@@ -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."}

View File

@@ -0,0 +1,31 @@
# Implementation Plan
1. Add validated `center-line-style` overrides and a helper which resolves the
effective default/override style by segment ID.
2. Generate yellow/white and dashed/solid centre-line geometry while retaining
cutback and control-marking exclusion behaviour; publish effective style
properties.
3. Extend the native Blender layer adapter/material handling to distinguish
white from yellow centre-line features without affecting legacy layers.
4. Add Workbench style controls, staging, Chinese selection evidence, and
immediate regenerated-layer display.
5. Add focused compiler, override, Workbench, Blender catalog, and build-stage
tests; compile/check Nantaizi and build Blender/Cesium/preview without the
package stage.
## Validation
```bash
npm run test:native-road
npm run test:road-workbench
python3 -m unittest discover blender/tests
npm run test:build-stages
npm run road:compile -- --config config/areas/nantaizi-lake-innovation-valley.json
npm run road:check -- --config config/areas/nantaizi-lake-innovation-valley.json
npm run build:area -- --config config/areas/nantaizi-lake-innovation-valley.json --stages blender,cesium,preview --road-provider native
```
## Rollback
Remove the override records or choose `--road-provider osm2streets`; no legacy
output is modified.

View File

@@ -0,0 +1,62 @@
# Native road center line style overrides
## Goal
Allow a Road Workbench user to select a generated native road centre line and
persistently override its marking style, without turning the generated GeoJSON
into the source of truth.
## Confirmed Facts
- Native `center_lines.geojson` currently contains automatic yellow dashed
polygons (`2m` dash, `2m` gap, `0.25m` width), each tied to a `segment_id`.
- A displayed dash can already be selected and exposes its segment provenance,
but has no editing controls.
- `native-road-overrides.json` is the persistent authority for existing road
and connection edits. The Workbench already stages, validates, saves, then
recompiles those overrides.
- The generated layer is consumed by both Workbench and native Blender/Cesium;
styling must therefore be compiled geometry and use the existing
`center_lines` material path, not a Workbench-only display tint.
## Requirements
- R1: Selecting a centre-line dash must expose a Chinese style editor for its
logical target and show the current effective style.
- R2: Supported styles must include at least solid/dashed and white/yellow
marking colours.
- R3: Style choices must be stored in `native-road-overrides.json`, validated,
reapplied during compilation, and survive a future Workbench launch.
- R4: The regenerated GeoJSON must carry source traceability and effective
style fields so Workbench, Blender and Cesium show the same marking.
- R5: Default automatic centre lines remain unchanged for segments without an
override; one-way/service exclusions and control-marking avoidance remain
authoritative.
## Initial Scope Boundary
- Editing individual dash polygons is out of scope: they are derived pieces,
not user-owned objects.
- Per-segment control is recommended for the first version because current
native segments already stop at junctions and have stable IDs.
- Double-line semantics, legal `overtaking` inference, hand-drawn partial
ranges, and changes to osm2streets output are out of scope unless explicitly
accepted during planning.
## Acceptance Criteria
- [ ] A user can select a centre line, choose a supported style in Chinese,
save it, regenerate, and see the result immediately in the Workbench.
- [ ] After reload and recompilation, the selected segment retains its style
while unedited segments retain the automatic yellow dashed default.
- [ ] Native Blender/Cesium uses the same effective style and does not require
osm2streets geometry.
- [ ] Tests cover schema validation, style geometry, default fallback,
persistence/API wiring, and Workbench selection/edit controls.
## Key Decisions
- The first version applies one override to the complete native segment between
junctions. Individual dash and partial-range editing are deferred.
- The initial style catalog is four explicit choices: yellow dashed, white
dashed, yellow solid, and white solid. Double-line semantics are deferred.

View File

@@ -0,0 +1,26 @@
{
"id": "native-road-center-line-styles",
"name": "native-road-center-line-styles",
"title": "Native road center line style overrides",
"description": "",
"status": "completed",
"dev_type": null,
"scope": null,
"package": null,
"priority": "P2",
"creator": "dingkang",
"assignee": "dingkang",
"createdAt": "2026-08-17",
"completedAt": "2026-08-17",
"branch": null,
"base_branch": "feature/native-road-compiler",
"worktree_path": null,
"commit": null,
"pr_url": null,
"subtasks": [],
"children": [],
"parent": null,
"relatedFiles": [],
"notes": "",
"meta": {}
}

View File

@@ -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."}

View File

@@ -0,0 +1,50 @@
# Native Road Centre Lines Design
## Architecture
The native compiler adds one independent polygon layer:
```text
canonical bidirectional segment + trimmed OSM centreline
-> repeated 2m x 0.25m dash polygons
-> native-road/layers/center_lines.geojson
-> Workbench / native Blender adapter / Cesium
```
It does not read osm2streets `center_lines.geojson`. The legacy layer is a
visual baseline only.
## Placement
- A candidate must have exactly forward and backward native roads for the same
`segmentId`, neither be `highway=service`, and have a valid trimmed OSM
centreline.
- Dashes use the legacy observed dimensions: 2m length, 0.25m width, with a
deterministic 2m gap. The first dash starts at a fixed segment-local offset
so rebuilds do not drift.
- The line is trimmed with the same `junctionPlans` cutback used by native lane
centreline generation. Any dash intersecting a crosswalk or stop line is
omitted, preserving the control-marking priority already established.
- One-way and service segments have no generated feature. A degenerate source
line produces a diagnostic rather than malformed geometry.
## Contracts
`layers/center_lines.geojson` is a Polygon FeatureCollection. Every feature
has `native_id`, `segment_id`, `directional_road_ids`, `osm_way_ids`,
`dash_index`, `dash_length_m`, `dash_gap_m`, `placement_rule`, and
`provenance="native-road-center-line/v1"`.
`compiled.json.layers.centerLines`, comparison count
`nativeCenterLineFeatures`, and `build-area.js` required native records use the
same filename. `catalog.NATIVE_ROAD_LAYERS` maps source `center_lines` to the
existing `center_lines` material layer.
The Workbench loads this source into the marking overlay, exposes a Chinese
toggle, and identifies it as `道路中心虚线` on selection.
## Compatibility
The change is additive within `native-road/`; `--road-provider osm2streets`
remains unaffected and is rollback. A native Blender build treats a missing
layer as an input error rather than silently omitting it.

View File

@@ -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."}

View File

@@ -0,0 +1,19 @@
# Implementation Plan
1. Add centre-dash constants and a pure native compiler routine based on the
shared bidirectional segment model and junction-trimmed centreline.
2. Filter invalid, one-way, service, and control-marking-conflicting dashes;
record actionable diagnostics for invalid geometry only.
3. Write `center_lines.geojson`, compilation/comparison counts, native build
records, required-layer checks, and Blender material adapter mapping.
4. Add Workbench API state, Chinese toggle, summary count, rendering, and
source evidence for a selected centre dash.
5. Add focused native and Workbench tests for generation, skips, control
avoidance, provenance, and file/API contracts.
6. Run native/unit/workbench/build-stage tests, compile/check Nantaizi, then
validate `blender,cesium,preview --road-provider native` without `package`.
## Rollback
Choose `--road-provider osm2streets`; no legacy layer or published package is
modified.

View File

@@ -0,0 +1,58 @@
# Native road center lines
## Goal
Restore the existing osm2streets-style road-centre dashed markings in the
native-road provider before treating native output as ready for broader quality
gates or new-area validation.
## Confirmed Facts
- Nantaizi's existing `osm2streets_web_out/center_lines.geojson` has 885
`type="center line"` polygons. Its sampled dash geometry is approximately
2.00m long by 0.25m wide.
- The legacy extraction excludes centre lines that overlap crosswalk zones and
that fall on service driving polygons (`build-osm2streets-qgis.js:474-480`).
- Native output currently has only same-direction `lane_separators`; it has no
`center_lines.geojson`, so a bidirectional road's directional carriageways
lack their visual and semantic divider.
- Existing Blender already owns a `center_lines` / `Center Line` material
layer. Native must adapt to it rather than creating a second scene registry.
## Requirements
- R1: For supported non-service, bidirectional native road segments, emit a
`center_lines.geojson` dashed centre divider derived from the canonical OSM
centreline and the segment's two directional carriageways.
- R2: Match the established visual baseline: yellow 0.25m-wide, 2m-long
dashes, clipped away from ordinary junction cutbacks and control markings.
- R3: Preserve source traceability: each dash records its native segment,
source OSM way, involved directional roads, placement interval/rule, and a
dedicated provenance value.
- R4: Add the layer to compilation records, Road Workbench display/selection
in Chinese, and the existing Blender/Cesium `center_lines` material path.
- R5: Do not render a centre divider on one-way or `highway=service` segments;
report invalid geometry instead of fabricating a divider.
## Acceptance Criteria
- [ ] Native Nantaizi output contains a valid `layers/center_lines.geojson`
with source-traceable 2m x 0.25m dashed polygons.
- [ ] No native centre dash intersects a generated crosswalk or vehicle stop
line, and no dash reaches into the supported junction surface cutback.
- [ ] The Workbench can toggle and select centre lines, presenting their
source and rule in Chinese rather than calling them lane separators.
- [ ] Native `blender,cesium,preview` consumes the layer using the existing
`center_lines` material and does not run `package`.
- [ ] Tests cover two-way generation, one-way/service skips, control-marking
avoidance, provenance, output contract, and Workbench wiring.
## Out Of Scope
- Centre-line editing overrides, solid/double-centre-line semantics, arbitrary
OSM `overtaking` interpretation, lane colouring changes, and other areas.
## Key Decision
This task deliberately matches the existing stable visual layer first. It does
not claim to infer a country-wide legal marking taxonomy from sparse OSM tags.

View File

@@ -0,0 +1,26 @@
{
"id": "native-road-center-lines",
"name": "native-road-center-lines",
"title": "Native road center lines",
"description": "Align native-road output with the existing center_lines layer before broader quality work.",
"status": "completed",
"dev_type": null,
"scope": null,
"package": null,
"priority": "P2",
"creator": "dingkang",
"assignee": "dingkang",
"createdAt": "2026-08-17",
"completedAt": "2026-08-17",
"branch": null,
"base_branch": "feature/native-road-compiler",
"worktree_path": null,
"commit": null,
"pr_url": null,
"subtasks": [],
"children": [],
"parent": "08-13-native-road-compiler",
"relatedFiles": [],
"notes": "",
"meta": {}
}

View File

@@ -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."}

View File

@@ -0,0 +1,66 @@
# Native Road Control Markings Design
## Architecture
The native compiler remains the source of truth. It extends its canonical OSM
parse with marked crossing nodes, then derives two additive polygon layers from
the crossing evidence, native directed lanes, and native junction plans:
```text
OSM crossing node + native directed lane centerlines + junction plans
|
+-- crosswalks.geojson: six zebra stripe polygons per safe crossing
|
+-- vehicle_stop_lines.geojson: one safe approach stop-line polygon
```
No osm2streets GeoJSON is read. Existing crossing geometry helpers may be
extracted or adapted only when they operate on native lane data and retain
native provenance.
## Source And Placement
- A source node is eligible only when `highway=crossing` and
`crossing:markings` is not `no`, `none`, or `unmarked`.
- The compiler finds native roads containing the crossing's OSM node and uses
the nearest compatible directed lane centerline to obtain the road tangent.
- Crosswalk stripes are perpendicular to that tangent and constrained to the
native road width. Existing fixed zebra dimensions are retained initially:
six 0.45m stripes with 0.45m gaps, 0.45m stripe width, and a highway-based
stripe length.
- A stop line is generated only when the crossing can be associated with a
supported junction approach and a safe outside-of-junction side. Otherwise
the crosswalk may remain valid but the missing stop line is diagnostic.
- Duplicate nearby crossing nodes use a stable cluster representative so one
physical crosswalk does not produce duplicate stripes.
## Contracts
`layers/crosswalks.geojson` and `layers/vehicle_stop_lines.geojson` are Polygon
FeatureCollections. Each feature records its crossing OSM node, source OSM way,
native directed road/lane when available, direction, placement method, and
`native-road-crosswalk/v1` or `native-road-stop-line/v1` provenance.
The Workbench API returns both layers as `state.layers.crosswalks` and
`state.layers.vehicleStopLines`. Its browser map uses separate toggleable
layers and selection evidence; the scene-preview toggle leaves real control
markings visible.
`catalog.NATIVE_ROAD_LAYERS` maps the sources to existing `crosswalks` and
`vehicle_stop_lines` material layers. This native adapter must not add them to
the osm2streets `ROAD_LAYERS` / `SCENE_LAYERS` registry.
## Compatibility And Rollback
The new files are additive under `native-road/layers/`. Existing osm2streets
output and QGIS input are unchanged. Selecting `--road-provider osm2streets`
remains rollback. A native Blender build treats a missing new layer as an error
rather than silently omitting a visible marking.
## Risks
- Crossing nodes can be detached from a routable road or lie on an ambiguous
multi-road segment. These become diagnostics rather than guessed geometry.
- A physical crossing split into several OSM nodes must deduplicate stably.
- A crosswalk near a non-supported junction may get stripes but no valid stop
line; this difference must be exposed in workbench provenance.

View File

@@ -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."}

View File

@@ -0,0 +1,31 @@
# Implementation Plan
1. Extend the native OSM parse/model with marked crossing-node evidence while
preserving existing road IDs and parser behavior.
2. Add native crossing clustering, tangent resolution from directed lane
centerlines, stripe geometry, safe stop-line placement, and source
diagnostics.
3. Persist the two new layers in `compile-native-roads.js`, comparison counts,
native build records, required native layer checks, and existing Blender
material mappings.
4. Add Workbench API/state fields, Chinese layer toggles, selection evidence,
and summary counts while retaining scene-preview behavior.
5. Add focused fixtures for marked and unmarked crossings, duplicate cluster
handling, a missing native-lane diagnostic, and output layer contracts.
6. Run native/unit/workbench/build-stage tests, compile/check Nantaizi, then
build `blender,cesium,preview --road-provider native` without `package/`.
## Validation
```bash
npm run test:native-road
npm run test:road-workbench
npm run test:build-stages
npm run road:compile -- --config config/areas/nantaizi-lake-innovation-valley.json
npm run road:check -- --config config/areas/nantaizi-lake-innovation-valley.json
npm run build:area -- --config config/areas/nantaizi-lake-innovation-valley.json \
--stages blender,cesium,preview --road-provider native
```
Rollback is selecting `--road-provider osm2streets`; no legacy output path is
changed.

View File

@@ -0,0 +1,61 @@
# Native road control markings
## Goal
Give Nantaizi's native-road provider inspectable, source-traceable crosswalk
and vehicle stop-line geometry, so intersection control markings do not depend
on the osm2streets render output.
## Confirmed Facts
- Native road output already owns road surfaces, sidewalks, lane separators,
repeated direction arrows, and explicit junction-turn arrows.
- Nantaizi's OSM input contains explicit marked crossings, including zebra and
traffic-signal crossings. The existing osm2streets output currently has 48
crosswalk-stripe polygons and 8 stop-line polygons.
- `build-osm2streets-qgis.js:1067` derives these markings from crossing nodes
plus osm2streets driving lanes. Native must not read that rendered geometry;
it can reuse only the tested geometry rules after adapting them to native
directed lanes and junction plans.
- Existing Blender materials already provide `crosswalks` and
`vehicle_stop_lines`; native may map into them without changing the legacy
osm2streets layer registry.
## Requirements
- R1: Native compilation uses explicit marked OSM crossing nodes only, and
emits crosswalk-stripe polygons plus approach stop lines only where safe
native directed-road placement exists.
- R2: Each generated feature preserves crossing node, OSM road, native road or
lane, direction, placement method, and relevant junction provenance.
- R3: The Road Workbench independently toggles, selects, and describes native
crosswalks and stop lines in Chinese.
- R4: Native Blender and Cesium builds consume both layers with the existing
control-marking materials; no `package/` publication is part of validation.
- R5: Missing compatible road context, ambiguous geometry, and unsupported
crossing inputs are diagnostics; the compiler must not invent a crossing.
## Acceptance Criteria
- [ ] Nantaizi native output contains valid `crosswalks.geojson` and
`vehicle_stop_lines.geojson` features with source-traceable properties,
without reading osm2streets rendered layers.
- [ ] A Workbench user can toggle and select either marking type and see the
crossing node, associated road/direction, and placement evidence.
- [ ] Native Blender/Cesium output contains both marking types using existing
materials, after `blender,cesium,preview --road-provider native` and without
publishing `package/`.
- [ ] Tests cover a marked crossing, an unmarked crossing skip, a missing or
ambiguous native-road placement skip, and output-layer contract checks.
## Out Of Scope
- Hand-placed control-marking overrides, traffic-signal state-machine changes,
freehand polygon editing, processing another region, and importing
osm2streets-rendered crosswalk geometry.
## Key Decision
The first version is explicit-OSM-only. This is feasible for Nantaizi and
keeps control markings evidence-backed; missing data remains a diagnostic for
OSM improvement rather than a silent geometric guess.

View File

@@ -0,0 +1,26 @@
{
"id": "native-road-control-markings",
"name": "native-road-control-markings",
"title": "Native road control markings",
"description": "",
"status": "completed",
"dev_type": null,
"scope": null,
"package": null,
"priority": "P2",
"creator": "dingkang",
"assignee": "dingkang",
"createdAt": "2026-08-17",
"completedAt": "2026-08-17",
"branch": null,
"base_branch": "feature/native-road-compiler",
"worktree_path": null,
"commit": null,
"pr_url": null,
"subtasks": [],
"children": [],
"parent": "08-13-native-road-compiler",
"relatedFiles": [],
"notes": "",
"meta": {}
}

View File

@@ -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."}

View File

@@ -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."}

View File

@@ -0,0 +1,19 @@
# Native road edge markings
## Goal
TBD.
## Requirements
- TBD
## Acceptance Criteria
- [ ] TBD
## Notes
- Keep `prd.md` focused on requirements, constraints, and acceptance criteria.
- Lightweight tasks can remain PRD-only.
- For complex tasks, add `design.md` for technical design and `implement.md` for execution planning before `task.py start`.

View File

@@ -0,0 +1,26 @@
{
"id": "native-road-edge-markings",
"name": "native-road-edge-markings",
"title": "Native road edge markings",
"description": "",
"status": "completed",
"dev_type": null,
"scope": null,
"package": null,
"priority": "P2",
"creator": "dingkang",
"assignee": "dingkang",
"createdAt": "2026-08-17",
"completedAt": "2026-08-17",
"branch": null,
"base_branch": "feature/native-road-compiler",
"worktree_path": null,
"commit": null,
"pr_url": null,
"subtasks": [],
"children": [],
"parent": null,
"relatedFiles": [],
"notes": "",
"meta": {}
}

View File

@@ -0,0 +1,3 @@
{"file":".trellis/spec/pipeline/cli-and-stages.md","reason":"Check compiler and workbench marking contracts."}
{"file":".trellis/spec/blender/testing.md","reason":"Run required Blender test suite."}
{"file":".trellis/spec/guides/cross-layer-thinking-guide.md","reason":"Verify browser, compiler, and Blender consume the same output."}

View File

@@ -0,0 +1,22 @@
# Design
The compiler owns marking semantics. `center-line-style` remains segment
scoped because a bidirectional road segment shares one centre-line decision.
`edge-line-style` is scoped to `{ roadId, side }`, since a directional
carriageway has independently editable left and right outside edges.
`double: true` is valid only with yellow solid centre lines. Generation emits
two 0.32m-separated parallel polygons for each normal solid mark, preserving
junction cutback and crosswalk/stop-line clearance. This keeps the existing
solid line implementation and avoids a separate geometry pipeline.
Road edge lines use the existing road-edge offset geometry. A solid style is
one continuous buffered offset line; a dashed style uses deterministic 2m
marks at 4m spacing. Both carry `effective_style`, source IDs, side, and
native provenance.
The Workbench uses one marking form with an explicit selected target type.
Only a centre-line target exposes the double-yellow option; lane separators
and edge lines retain the ordinary colour/pattern choices. Staged changes pass
through the existing save and regenerate flow, so browser, compiler and
Blender read the same persisted override.

View File

@@ -0,0 +1,3 @@
{"file":".trellis/spec/pipeline/cli-and-stages.md","reason":"Native-road output, override, and stage contracts."}
{"file":".trellis/spec/blender/asset-generation.md","reason":"Native provider adapter and Blender output requirements."}
{"file":".trellis/spec/guides/artifact-parity-guide.md","reason":"Intentional render-output change validation."}

View File

@@ -0,0 +1,10 @@
# Implementation
1. Extend override validation and native marking generation for explicit
double-yellow centre lines and per-side edge-line styles.
2. Extend the Chinese Workbench selection/form flow without adding another
editor or output format.
3. Add compiler and Workbench regression coverage for valid and invalid
payloads, generated geometry, and layer adapter completeness.
4. Compile/check Nantaizi, run native/Workbench/build-stage/Blender tests,
then run the native Blender/Cesium/preview chain.

View File

@@ -0,0 +1,36 @@
# Native road marking semantics
## Goal
Finish the native road marking model so the Nantaizi Road Workbench can
persist meaningful centre-line and road-edge-line styles, regenerate native
geometry deterministically, and deliver the same result to Blender/Cesium.
## Requirements
- Add an explicit double-yellow-solid centre-line semantic. It must not be a
generic double-line switch that permits invalid colour/pattern combinations.
- Keep all marking edits area-local in `native-road-overrides.json`; generated
GeoJSON remains derived output.
- Make road edge lines selectable and style-editable in the Chinese Road
Workbench, alongside existing centre lines and lane separators.
- Preserve the default Nantaizi output unless a user supplies an override.
- Native geometry must remain independent of osm2streets render geometry.
## Acceptance Criteria
- [ ] A `center-line-style` override with `double: true`, `yellow`, and
`solid` emits two offset centre-line polygons with an explicit effective
style, while invalid double combinations are rejected.
- [ ] An `edge-line-style` override targets one directional road side and
supports white/yellow plus solid/dashed styles.
- [ ] The Workbench exposes Chinese selection feedback, stages the correct
override payload, and only shows the double-yellow choice for centre lines.
- [ ] Nantaizi native compile/check, Workbench/native tests, Blender pure
tests, and native Blender/Cesium/preview build pass.
## Notes
- Keep `prd.md` focused on requirements, constraints, and acceptance criteria.
- Lightweight tasks can remain PRD-only.
- For complex tasks, add `design.md` for technical design and `implement.md` for execution planning before `task.py start`.

View File

@@ -0,0 +1,26 @@
{
"id": "native-road-marking-semantics",
"name": "native-road-marking-semantics",
"title": "Native road marking semantics",
"description": "",
"status": "completed",
"dev_type": null,
"scope": null,
"package": null,
"priority": "P2",
"creator": "dingkang",
"assignee": "dingkang",
"createdAt": "2026-08-17",
"completedAt": "2026-08-17",
"branch": null,
"base_branch": "feature/native-road-compiler",
"worktree_path": null,
"commit": null,
"pr_url": null,
"subtasks": [],
"children": [],
"parent": null,
"relatedFiles": [],
"notes": "",
"meta": {}
}

View File

@@ -0,0 +1,2 @@
{"file":".trellis/spec/pipeline/cli-and-stages.md","reason":"Native geometry correctness and output contracts."}
{"file":".trellis/spec/blender/testing.md","reason":"Blender validation requirements."}

View File

@@ -0,0 +1,18 @@
# Design
Each approach contributes its two carriageway-edge points at the common
cutback distance. Points are ordered around the junction node. For each pair
from adjacent approaches, the compiler samples a deterministic quadratic
Bezier whose control point follows the pedestrian-side curb arc toward the
junction. Rounded plans use a larger cutback than the legacy straight envelope
so this visible curb shape still contains all turning connectors. Approach road
surfaces terminate at the same cutback, so they cannot cover the junction
outline in 3D output.
The curve is accepted only when the support intersection is finite, the pair
belongs to different approaches, and the resulting ring remains valid and
contains all published connector coordinates. Otherwise the original straight
chord remains for that corner and the plan reports a mixed/fallback boundary.
Lane connectors remain a separate vehicle-path layer. This task changes only
the road/intersection outline and sidewalk-corner shape.

View File

@@ -0,0 +1,2 @@
{"file":".trellis/spec/pipeline/cli-and-stages.md","reason":"Native geometry and build-stage contracts."}
{"file":".trellis/spec/guides/artifact-parity-guide.md","reason":"Intentional geometry output change validation."}

View File

@@ -0,0 +1,7 @@
# Implementation
1. Build a rounded junction boundary from ordered approach-edge records with
tangent support-line intersections and deterministic curve samples.
2. Expose boundary mode/provenance and preserve containment fallback.
3. Extend focused native-road tests for curved ordinary intersections.
4. Validate Nantaizi compile/check, workbench tests, and native 3D build.

View File

@@ -0,0 +1,38 @@
# Rounded native road junctions
## Goal
Replace the octagonal native junction outline with smooth, tangentially joined
road-edge corners for ordinary Nantaizi T and cross junctions.
## Requirements
- Junction surface boundaries must connect adjacent approach carriageway edges
with a smooth outward curve rather than a straight octagonal chord.
- Sidewalk corner surfaces must use the same rounded boundary concept so road
and pedestrian geometry do not disagree visually.
- Preserve a deterministic straight-edge fallback and an explicit diagnostic
when a corner cannot be safely constructed.
- Do not change lane connector semantics or derive geometry from osm2streets.
## Acceptance Criteria
- [x] Ordinary cross/T fixtures generate rounded junction polygons with more
than the prior eight straight boundary vertices and `boundary_mode` records
the chosen style.
- [x] Connector containment remains valid and degenerate geometry falls back
without publishing self-intersecting polygons.
- [x] Nantaizi compile/check and native Blender/Cesium/preview succeed.
## Verification Record
- Native geometry and Workbench tests passed.
- Nantaizi native compile/check passed with `ok=true`.
- Native Blender/Cesium/preview verification was completed manually in a
working Blender environment.
## Notes
- Keep `prd.md` focused on requirements, constraints, and acceptance criteria.
- Lightweight tasks can remain PRD-only.
- For complex tasks, add `design.md` for technical design and `implement.md` for execution planning before `task.py start`.

View File

@@ -0,0 +1,26 @@
{
"id": "native-rounded-junctions",
"name": "native-rounded-junctions",
"title": "Rounded native road junctions",
"description": "",
"status": "completed",
"dev_type": null,
"scope": null,
"package": null,
"priority": "P2",
"creator": "dingkang",
"assignee": "dingkang",
"createdAt": "2026-08-17",
"completedAt": "2026-08-18",
"branch": null,
"base_branch": "feature/native-road-compiler",
"worktree_path": null,
"commit": null,
"pr_url": null,
"subtasks": [],
"children": [],
"parent": "08-13-native-road-compiler",
"relatedFiles": [],
"notes": "",
"meta": {}
}

View File

@@ -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."}

View File

@@ -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.

View File

@@ -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."}

View File

@@ -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.

View File

@@ -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.

View File

@@ -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": {}
}

View File

@@ -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."}

View File

@@ -0,0 +1,55 @@
# Design
## Source Of Truth And Migration
The native compiler owns a versioned area-local signal model and override
artifact. It is generated from OSM controls and native geometry inputs, then
edited by the Workbench without QGIS. The existing
`traffic_signal_assemblies.geojson` is a migration adapter: it can be imported
into the native model and exported for legacy QGIS/reimport workflows, but a
native compile never requires it to exist.
The existing `buildTrafficSignalFeatures()` and validation functions remain the
compatibility implementation for initial generation and import/export. Imported
features retain their legacy `signal_uid` where valid; newly generated native
features use the same deterministic identity rule so downstream runtime IDs do
not fork.
## Workbench API And Editing
`GET /api/state` adds the normalized native signal model, source control
metadata, migration provenance, and derived runtime signal records. A signal
edit is represented as an atomic replacement of the validated native signal
override artifact through a dedicated signal save endpoint. A separate import
or export action handles the legacy QGIS collection; road overrides remain in
their existing file and schema.
The browser uses stable `signal_uid` values. It supports:
- generate: choose an OSM traffic-signal control and arm, then create the
deterministic assembly using the existing generator contract;
- move: update the Point coordinates while retaining stop-line/source fields;
- rotate: update `heading_deg` with normalized degrees;
- delete: remove the assembly from the editable collection;
- edit enabled state, display ID, mast reach, z offset, and phase group.
Every save validates uniqueness, identity, finite geometry, source references,
and field ranges before an atomic write. Deleted features are absent from the
runtime output; disabled features remain in the editable/QGIS layer but are
omitted by `buildTrafficSignalsFromFeatures()`.
## Delivery Flow
The native road compile result includes signal assemblies and derived runtime
metadata without adding them to road geometry layers. Native Blender/Cesium
stages consume `traffic_signals.json` and dynamic GLB inputs generated directly
from the native model. The legacy QGIS adapter may materialize the old GeoJSON,
but it is not in the native build's critical path.
## Compatibility And Rollback
QGIS reimport continues to read/export the compatibility GeoJSON while the
legacy path remains unchanged. If native signal editing fails validation, the
previous atomic native override remains in place and the user receives a
field-level error. Rollback is selecting the legacy provider or exporting the
last native state to the QGIS adapter.

View File

@@ -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."}

View File

@@ -0,0 +1,31 @@
# Implementation Plan
1. Define the native signal model/override artifact and migration adapter;
reuse existing generation, validation, and deterministic signal UID rules.
2. Add QGIS import/export commands that translate
`traffic_signal_assemblies.geojson` to/from the native artifact without
making native compile depend on QGIS.
3. Extend native compile/workbench state to expose signal assemblies, OSM
controls/arms, and derived runtime provenance.
4. Add validated atomic signal save operations for generate, move, rotate,
delete, and field edits; preserve legacy ID compatibility.
5. Add Workbench map styling, selection, editing controls, dirty state, save,
compile/reload, and clear error handling for signal assemblies.
6. Ensure native Blender/Cesium/preview stages consume runtime signal data
generated from the native model, while legacy stages remain compatible.
7. Add focused traffic-signal, Workbench, migration, and cross-layer round-trip tests;
run the existing legacy traffic-signal and preview suites.
## Validation
```bash
npm run test:traffic-signals
npm run test:preview-assets
npm run test:road-workbench
npm run road:compile -- --config config/areas/nantaizi-lake-innovation-valley.json
npm run road:check -- --config config/areas/nantaizi-lake-innovation-valley.json
```
Manual acceptance must cover native-only generate/move/rotate/delete -> save ->
recompile -> runtime JSON and preview, plus QGIS import/export compatibility;
disabled signals must be omitted from runtime output.

View File

@@ -0,0 +1,55 @@
# Native traffic signal parity with QGIS
## Goal
Move traffic-signal ownership from the QGIS editing chain into the native road
compiler and Workbench. Existing QGIS signal assemblies remain import/export
compatibility data during migration, but native overrides become the long-term
source for generation, editing, and Blender/Cesium/preview delivery.
## Confirmed Facts
- QGIS currently edits `traffic_signal_assemblies.geojson`; this is the
migration input/output contract, not the desired long-term authority.
- `scripts/reimport-gpkg.js` validates and reimports that legacy layer.
- `scripts/build-area.js` currently derives runtime `traffic_signals.json` from
the edited assemblies; the native path must replace this dependency.
- Existing native road layers do not read traffic-signal artifacts. Historical
native Cesium work intentionally omitted signal runtime assets.
- Stable signal identity and editable fields already include `signal_uid`,
enabled state, source/control/approach IDs, arm direction, pose, mast reach,
and phase-group/runtime data.
## Requirements
- R1: Native compile/workbench must own a versioned area-local signal model and
override artifact, with an explicit one-time/import compatibility path from
existing QGIS assemblies.
- R2: Workbench state must expose signal provenance and the existing editable
signal fields using stable IDs, and support generating, moving, rotating,
and deleting signal assemblies.
- R3: Native compile and preview delivery must generate runtime signal data
directly from the native model, preserving enabled/disabled state, arm
direction/pose, and phase-group data without requiring QGIS.
- R4: A compatibility adapter must import/export the existing QGIS assembly
format during migration and preserve legacy signal IDs where possible.
## Scope Boundary
- Native signal model and overrides are the long-term source of truth.
- QGIS GeoJSON/GeoPackage support is transitional compatibility only; do not
make native compile depend on QGIS or regenerate native edits from QGIS.
- Do not redesign signal geometry, timing logic, or vehicle behavior in this
task.
## Acceptance Criteria
- [ ] A native compile/reopen round trip preserves edited signal assemblies,
stable IDs, enabled state, and provenance without QGIS running.
- [ ] The Road Workbench can generate, inspect, move, rotate, and delete signal
assemblies, then save durable edits without breaking QGIS reimport.
- [ ] Native Blender/Cesium/preview consume runtime signal data generated from
the native model, including disabled signals being omitted from runtime.
- [ ] QGIS import/export compatibility and legacy build stages continue to
pass while the migration adapter exists.
- [ ] Nantaizi has documented native-only and QGIS-imported round trips.

View File

@@ -0,0 +1,26 @@
{
"id": "native-traffic-signal-parity",
"name": "native-traffic-signal-parity",
"title": "Align native traffic signals with QGIS",
"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": "08-13-native-road-compiler",
"relatedFiles": [],
"notes": "",
"meta": {}
}

View File

@@ -8,8 +8,8 @@
<!-- @@@auto:current-status -->
- **Active File**: `journal-1.md`
- **Total Sessions**: 30
- **Last Active**: 2026-08-12
- **Total Sessions**: 39
- **Last Active**: 2026-08-18
<!-- @@@/auto:current-status -->
---
@@ -19,7 +19,7 @@
<!-- @@@auto:active-documents -->
| File | Lines | Status |
|------|-------|--------|
| `journal-1.md` | ~642 | Active |
| `journal-1.md` | ~845 | Active |
<!-- @@@/auto:active-documents -->
---
@@ -29,6 +29,15 @@
<!-- @@@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` |
| 34 | 2026-08-17 | Add center line style overrides | `47efb78` | `feature/native-road-compiler` |
| 33 | 2026-08-17 | Add native road center lines | `fb863da` | `feature/native-road-compiler` |
| 32 | 2026-08-17 | Native road control markings | `4c4f453` | `feature/native-road-compiler` |
| 31 | 2026-08-14 | Native road lane markings | `1b9829d` | `feature/native-road-compiler` |
| 30 | 2026-08-12 | Add vehicle incident preview cards | `761a526` | `main` |
| 29 | 2026-08-12 | Publish reusable area asset packages | `f385009`, `c925890`, `db0fba5`, `f2b8d79`, `0102ffb` | `main` |
| 28 | 2026-08-11 | 默认压缩交付 | `0790cbd` | `main` |

View File

@@ -640,3 +640,206 @@ Added click-to-open vehicle information cards with normal, breakdown, and accide
### Status
[OK] **Completed**
## Session 31: Native road lane markings
**Date**: 2026-08-14
**Task**: Native road lane markings
**Branch**: `feature/native-road-compiler`
### Summary
Implemented native lane separators, repeated road direction arrows, explicit junction turn arrows, Workbench inspection/toggles, and Blender/Cesium adapter support for Nantaizi; validated native compile, checks, tests, and non-package visual build.
### Git Commits
| Hash | Message |
|------|---------|
| `1b9829d` | (see git log) |
### Status
[OK] **Completed**
## Session 32: Native road control markings
**Date**: 2026-08-17
**Task**: Native road control markings
**Branch**: `feature/native-road-compiler`
### Summary
Added native-source crosswalk and stop-line layers with workbench inspection, Blender/Cesium delivery, and control-marking arrow avoidance.
### Main Changes
- Compiled explicit marked OSM crossings into source-traceable native GeoJSON.
- Added Chinese workbench control-layer toggle, selection evidence, and summary counts.
### Git Commits
| Hash | Message |
|------|---------|
| `4c4f453` | (see git log) |
### Testing
- [OK] Passed native-road, road-workbench, turn-lane-arrows, build-stages, native compile/check, and Blender/Cesium/preview build.
### Status
[OK] **Completed**
### Next Steps
- Continue native road compiler workbench with the next user-approved priority.
## Session 33: Add native road center lines
**Date**: 2026-08-17
**Task**: Add native road center lines
**Branch**: `feature/native-road-compiler`
### Summary
Implemented Nantaizi native-road center_lines output, workbench selection/toggle, Blender adapter, control-marking avoidance, and cross-layer verification.
### Git Commits
| Hash | Message |
|------|---------|
| `fb863da` | (see git log) |
### Status
[OK] **Completed**
## Session 34: Add center line style overrides
**Date**: 2026-08-17
**Task**: Add center line style overrides
**Branch**: `feature/native-road-compiler`
### Summary
Added persistent native road centre-line colour and pattern overrides, automatic Workbench staging, continuous solid-line rendering, and Blender white material support.
### Git Commits
| Hash | Message |
|------|---------|
| `47efb78` | (see git log) |
### Status
[OK] **Completed**
## Session 35: Complete native lane separator styles
**Date**: 2026-08-17
**Task**: Complete native lane separator styles
**Branch**: `feature/native-road-compiler`
### Summary
Completed per lane-pair separator colour/pattern overrides, Workbench staging, Blender yellow material routing, and centre-line control clearance.
### Git Commits
| Hash | Message |
|------|---------|
| `bc04517` | (see git log) |
### Status
[OK] **Completed**
## Session 36: Complete native road marking semantics
**Date**: 2026-08-17
**Task**: Complete native road marking semantics
**Branch**: `feature/native-road-compiler`
### Summary
Added explicit double-yellow centre lines and per-side edge-line overrides; made all three marking classes selectable in the Chinese Road Workbench; synchronized Blender adapter coverage and verified Nantaizi native compile, quality gate, Blender/Cesium/preview manifests, and automated tests.
### Git Commits
| Hash | Message |
|------|---------|
| `9bb97d4` | (see git log) |
### Status
[OK] **Completed**
## Session 37: Complete rounded native junction verification
**Date**: 2026-08-18
**Task**: Complete rounded native junction verification
**Branch**: `feature/native-road-compiler`
### Summary
Recorded user-confirmed native Blender/Cesium/preview verification for rounded native junctions, checked off all acceptance criteria, and archived the completed 08-17-native-rounded-junctions task.
### Git Commits
| Hash | Message |
|------|---------|
| `e41bfd1` | (see git log) |
| `f7e71cf` | (see git log) |
### 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**

View File

@@ -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 countsBlender 记录 `.blend` / renderCesium/压缩记录 GLB digest
preview 记录 GLB、metadata、`lane_polygons.geojson``network.json``intersection_surface.geojson`
车辆路线和 runtime 文件。巡航道路区间按 osm2streets internal road 匹配真实 Driving lane 中轴,
native-road 或 legacy GeoJSON feature countsBlender 记录 `.blend` / renderCesium/压缩记录 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 工程会一并同步

View File

@@ -657,6 +657,8 @@ def export(args):
raise RuntimeError("Dynamic traffic signal collection is empty")
export_glb(args["dynamic_glb"], dynamic_meshes)
for group, key in ((0, "countdown_0_glb"), (1, "countdown_1_glb")):
if not args.get(key):
continue
if not countdown_meshes[group]:
raise RuntimeError("Traffic countdown collection %d is empty" % group)
export_glb(args[key], countdown_meshes[group])

View File

@@ -110,7 +110,7 @@ TREE_STYLES = frozenset(("natural", "procedural")) | frozenset(_tree.MODEL_STYLE
def cli_args():
values = {"osm": None, "geojson": None, "output": None, "render": None,
values = {"osm": None, "geojson": None, "native_road": None, "output": None, "render": None,
"office_overrides": "", "tree_style": "natural"}
argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else []
i = 0
@@ -663,6 +663,10 @@ def build(args):
layer["id"]: material_from_spec(spec)
for layer, spec in zip(catalog.ROAD_LAYERS, catalog.road_material_specs())
}
road_mats["native_center_line_white"] = material_from_spec(
catalog.MATERIALS["native_center_line_white"])
road_mats["native_lane_separator_yellow"] = material_from_spec(
catalog.MATERIALS["native_lane_separator_yellow"])
traffic_signal_mats = {
"metal": material_from_spec(catalog.MATERIALS["traffic_signal_metal"]),
"housing": material_from_spec(catalog.MATERIALS["traffic_signal_housing"]),
@@ -789,8 +793,36 @@ def build(args):
_features.dispatch_ways(ways, projector, way_handlers)
geojson_dir = args.get("geojson")
native_road_dir = args.get("native_road")
road_counts = {}
if geojson_dir and os.path.isdir(geojson_dir):
if native_road_dir:
if not os.path.isdir(native_road_dir):
raise RuntimeError("--native-road directory does not exist: " + native_road_dir)
material_layers = {layer["id"]: layer for layer in catalog.ROAD_LAYERS}
for source in catalog.NATIVE_ROAD_LAYERS:
target = source["material_layer"]
layer = material_layers[target]
source_path = os.path.join(native_road_dir, "layers", source["source"] + ".geojson")
if not os.path.isfile(source_path):
raise RuntimeError("Native road layer is missing: " + source_path)
if source["source"] == "center_lines":
count = _roads.assemble_geojson_layer(
source_path, source["source"], projector, roads_c,
road_mats[target], layer["z"],
lambda props: props.get("color") != "white")
count += _roads.assemble_geojson_layer(
source_path, source["source"] + "_white", projector, roads_c,
road_mats["native_center_line_white"], layer["z"],
lambda props: props.get("color") == "white")
elif source["source"] == "lane_separators":
count = _roads.assemble_geojson_layer(source_path, source["source"], projector, roads_c, road_mats[target], layer["z"], lambda props: props.get("color") != "yellow")
count += _roads.assemble_geojson_layer(source_path, source["source"] + "_yellow", projector, roads_c, road_mats["native_lane_separator_yellow"], layer["z"], lambda props: props.get("color") == "yellow")
else:
count = _roads.assemble_geojson_layer(
source_path, source["source"], projector, roads_c,
road_mats[target], layer["z"])
road_counts[source["source"]] = count
elif geojson_dir and os.path.isdir(geojson_dir):
for problem in catalog.check_layers(geojson_dir):
print("Layer catalog warning:", problem)
for layer in catalog.ROAD_LAYERS:
@@ -799,11 +831,11 @@ def build(args):
os.path.join(geojson_dir, layer_id + ".geojson"), layer_id,
projector, roads_c, road_mats[layer_id], layer["z"])
if road_counts.get("road_surface", 0) == 0:
if not native_road_dir and road_counts.get("road_surface", 0) == 0:
_roads.assemble_osm_fallback(
ways, projector, roads_c, road_mats["road_surface"])
traffic_signal_path = os.path.join(geojson_dir or "", "traffic_signals.json")
traffic_signal_path = args.get("traffic_signals") or os.path.join(geojson_dir or "", "traffic_signals.json")
dynamic_signal_objects = 0
if os.path.exists(traffic_signal_path):
try:
@@ -910,6 +942,7 @@ def build(args):
scene.render.filepath = args["render"]
scene["source_osm"] = args["osm"]
scene["source_geojson"] = geojson_dir or ""
scene["source_native_road"] = native_road_dir or ""
scene["osm_bounds"] = json.dumps(bounds, ensure_ascii=True)
scene["building_count"] = counts["building_count"]
scene["industrial_building_count"] = counts["industrial_count"]

View File

@@ -48,6 +48,21 @@ ROAD_LAYERS = [
SCENE_STYLE_FILE = "osm2streets_scene_style.json"
# Native-road output intentionally maps into existing scene material layers.
# It is a provider adapter, not a second scene-layer registry.
NATIVE_ROAD_LAYERS = (
{"source": "road_surface", "material_layer": "road_surface"},
{"source": "edge_lines", "material_layer": "lane_separators"},
{"source": "intersection_surface", "material_layer": "intersection_surface"},
{"source": "sidewalk_surface", "material_layer": "sidewalks"},
{"source": "lane_separators", "material_layer": "lane_separators"},
{"source": "center_lines", "material_layer": "center_lines"},
{"source": "direction_arrows", "material_layer": "lane_arrows_webscale"},
{"source": "turn_arrows", "material_layer": "lane_arrows_webscale"},
{"source": "crosswalks", "material_layer": "crosswalks"},
{"source": "vehicle_stop_lines", "material_layer": "vehicle_stop_lines"},
)
# Material specs. `kind` selects the builder:
# solid — flat base colour
@@ -186,7 +201,13 @@ MATERIALS = {
"traffic_signal_active_green": {"kind": "solid", "name": "Traffic Signal Active Green",
"color": (0.04, 0.82, 0.22), "roughness": 0.25,
"cesium": {"base_color": (0.04, 0.82, 0.22),
"emission": ((0.04, 0.82, 0.22), 1.0)}},
"emission": ((0.04, 0.82, 0.22), 1.0)}},
"native_center_line_white": {"kind": "solid", "name": "Native Center Line White",
"color": (0.95, 0.94, 0.82), "roughness": 0.52,
"cesium": {"base_color": (0.95, 0.94, 0.82)}},
"native_lane_separator_yellow": {"kind": "solid", "name": "Native Lane Separator Yellow",
"color": (0.94, 0.58, 0.06), "roughness": 0.52,
"cesium": {"base_color": (0.94, 0.58, 0.06)}},
}

View File

@@ -7,7 +7,8 @@ from osmassets.geom import clip_polygon, feature_in_bounds, geometry_rings
from osmassets.mesh import MeshBatch, add_polyline
def assemble_geojson_layer(path, layer_id, projector, collection, material, z):
def assemble_geojson_layer(path, layer_id, projector, collection, material, z,
property_filter=None):
if not os.path.exists(path):
return 0
with open(path, "r", encoding="utf-8") as handle:
@@ -18,6 +19,8 @@ def assemble_geojson_layer(path, layer_id, projector, collection, material, z):
xmax, ymax = projector.xy((b["max_lon"], b["max_lat"]))
count = 0
for feature in data.get("features", []):
if property_filter and not property_filter(feature.get("properties", {})):
continue
if not feature_in_bounds(feature, projector):
continue
for ring in geometry_rings(feature.get("geometry")):

View File

@@ -30,7 +30,7 @@ from osmassets.geom import (
sample_tree_row,
signed_polygon_area,
)
from osmassets.catalog import ROAD_LAYERS
from osmassets.catalog import NATIVE_ROAD_LAYERS, ROAD_LAYERS
from osmassets.osm import Projector, parse_height, parse_osm, tags
@@ -46,6 +46,31 @@ class RoadLayerCatalogTest(unittest.TestCase):
self.assertNotEqual(layers["road_surface"]["z"],
layers["intersection_surface"]["z"])
def test_native_provider_maps_to_existing_material_layers(self):
layers = {layer["id"] for layer in ROAD_LAYERS}
self.assertEqual(
[(entry["source"], entry["material_layer"])
for entry in NATIVE_ROAD_LAYERS],
[("road_surface", "road_surface"),
("edge_lines", "lane_separators"),
("intersection_surface", "intersection_surface"),
("sidewalk_surface", "sidewalks"),
("lane_separators", "lane_separators"),
("center_lines", "center_lines"),
("direction_arrows", "lane_arrows_webscale"),
("turn_arrows", "lane_arrows_webscale"),
("crosswalks", "crosswalks"),
("vehicle_stop_lines", "vehicle_stop_lines")])
self.assertTrue(all(entry["material_layer"] in layers
for entry in NATIVE_ROAD_LAYERS))
def test_cesium_export_keeps_signal_assets_optional(self):
exporter = os.path.join(os.path.dirname(os.path.abspath(__file__)),
"..", "export_cesium.py")
with open(exporter, encoding="utf-8") as handle:
source = handle.read()
self.assertIn('if not args.get(key):\n continue', source)
class GeometryRingsTest(unittest.TestCase):
def test_polygon_keeps_only_the_exterior_ring(self):

View 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": ""
}
}

View File

@@ -5,7 +5,7 @@
"qgisApp": "/Applications/QGIS.app",
"blenderApp": "/Applications/Blender.app",
"stages": {
"intermediates": true,
"intermediates": false,
"blender": true,
"cesium": true
},

View File

@@ -5,7 +5,7 @@
"qgisApp": "/Applications/QGIS.app",
"blenderApp": "/Applications/Blender.app",
"stages": {
"intermediates": true,
"intermediates": false,
"blender": true,
"cesium": true
},
@@ -24,6 +24,9 @@
"turnLaneArrows": {
"enabled": true
},
"nativeRoad": {
"edgeLines": false
},
"osm2streets": {
"debug_each_step": false,
"dual_carriageway_experiment": false,
@@ -33,6 +36,7 @@
},
"blender": {
"treeStyle": "shapespark",
"officeOverrides": ""
"officeOverrides": "",
"roadProvider": "native"
}
}

View File

@@ -5,7 +5,7 @@
"qgisApp": "/Applications/QGIS.app",
"blenderApp": "/Applications/Blender.app",
"stages": {
"intermediates": true,
"intermediates": false,
"blender": true,
"cesium": true
},
@@ -24,6 +24,9 @@
"turnLaneArrows": {
"enabled": false
},
"nativeRoad": {
"edgeLines": false
},
"osm2streets": {
"debug_each_step": false,
"dual_carriageway_experiment": false,
@@ -33,7 +36,8 @@
},
"blender": {
"treeStyle": "natural",
"officeOverrides": ""
"officeOverrides": "",
"roadProvider": "native"
},
"compress": {
"textureSize": 768,

View 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

File diff suppressed because it is too large Load Diff

View File

@@ -14,6 +14,7 @@
"road:compile": "node scripts/compile-native-roads.js",
"road:check": "node scripts/check-native-roads.js",
"road:workbench": "node scripts/road-workbench.js",
"test:road-workbench": "node scripts/test-road-workbench.js",
"test:native-road": "node scripts/test-native-road.js",
"test:preflight": "node scripts/test-area-preflight.js",
"test:build-stages": "node scripts/test-build-stages.js",
@@ -24,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": {

View File

@@ -4,6 +4,7 @@ const fs = require("fs");
const path = require("path");
const { spawnSync } = require("child_process");
const { readAreaConfig } = require("./lib/area-config");
const { compileArea: compileNativeRoads } = require("./compile-native-roads");
const { resolveStages } = require("./lib/build-stages");
const { validateManifest, addIntegrity } = require("./lib/package-contract");
const { blenderExecutable: resolveBlenderExecutable } = require("./lib/tool-paths");
@@ -15,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 {
@@ -42,6 +44,10 @@ const requestedStages = args.stages
? splitList(args.stages)
: null;
const stages = resolveStages(area.stages, requestedStages);
const roadProvider = args.roadProvider || area.blender.roadProvider;
if (!new Set(["osm2streets", "native"]).has(roadProvider)) {
throw new Error("--road-provider must be \"osm2streets\" or \"native\".");
}
console.log(`Area: ${area.id}`);
@@ -55,19 +61,19 @@ if (stages.reimport) {
reimportGpkg(area);
}
if (stages.blender) {
buildBlenderScene(area);
buildBlenderScene(area, roadProvider);
}
if (stages.cesium) {
exportCesium(area);
exportCesium(area, roadProvider);
}
if (stages.compress) {
compressCesiumGlb(area);
}
if (stages.package) {
publishPackage(area);
publishPackage(area, roadProvider);
}
if (stages.preview) {
writeCesiumPreview(area);
writeCesiumPreview(area, roadProvider);
}
console.log("Done.");
@@ -208,16 +214,22 @@ function reimportGpkg(area) {
});
}
function buildBlenderScene(area) {
function buildBlenderScene(area, roadProvider) {
ensureFile(blenderExecutable(area), "Blender executable");
ensureFile(path.join(repoRoot, "blender", "generate_scene.py"), "Blender scene generator");
ensureFile(area.outputs.trafficSignalAssemblies, "Editable traffic signal assemblies");
// Blender consumes the editable assembly layer; OSM only initializes it in
// intermediates, so QGIS edits remain authoritative across later stages.
writeTrafficSignals(area);
ensureFile(area.outputs.trafficSignals, "Traffic signal anchors");
if (roadProvider === "osm2streets") {
ensureFile(area.outputs.trafficSignalAssemblies, "Editable traffic signal assemblies");
// Blender consumes the editable assembly layer; OSM only initializes it in
// intermediates, so QGIS edits remain authoritative across later stages.
writeTrafficSignals(area);
ensureFile(area.outputs.trafficSignals, "Traffic signal anchors");
}
fs.mkdirSync(path.dirname(area.outputs.blend), { recursive: true });
fs.mkdirSync(path.dirname(area.outputs.render), { recursive: true });
if (roadProvider === "native") {
compileNativeRoads(configPath);
ensureNativeRoadLayers(area);
}
const blenderArgs = [
"--background",
@@ -227,8 +239,6 @@ function buildBlenderScene(area) {
"--",
"--osm",
area.input,
"--geojson",
area.outputs.geojsonDir,
"--output",
area.outputs.blend,
"--render",
@@ -236,6 +246,12 @@ function buildBlenderScene(area) {
"--tree-style",
area.blender.treeStyle,
];
if (roadProvider === "native") {
blenderArgs.push("--native-road", area.outputs.nativeRoadDir);
blenderArgs.push("--traffic-signals", path.join(area.outputs.nativeRoadDir, "traffic-signals.json"));
} else {
blenderArgs.push("--geojson", area.outputs.geojsonDir);
}
if (area.blender.officeOverrides) {
blenderArgs.push("--office-overrides", area.blender.officeOverrides);
}
@@ -255,17 +271,20 @@ function buildBlenderScene(area) {
inputs: {
config: fileRecord(configPath),
osm: fileRecord(area.input),
geojsonDir: fileRecord(area.outputs.geojsonDir),
...sceneGeojsonRecords(area),
trafficSignalAssemblies: fileRecord(area.outputs.trafficSignalAssemblies),
trafficSignals: fileRecord(area.outputs.trafficSignals),
...(roadProvider === "native" ? nativeRoadRecords(area) : sceneGeojsonRecords(area)),
...(roadProvider === "osm2streets" ? {
geojsonDir: fileRecord(area.outputs.geojsonDir),
trafficSignalAssemblies: fileRecord(area.outputs.trafficSignalAssemblies),
trafficSignals: fileRecord(area.outputs.trafficSignals),
} : {}),
},
outputs: {
blend: fileRecord(area.outputs.blend),
render: fileRecord(area.outputs.render),
},
summary: {
geojson: geojsonFeatureCounts(area),
...(roadProvider === "native" ? { nativeRoad: nativeRoadFeatureCounts(area) } : { geojson: geojsonFeatureCounts(area) }),
roadProvider,
blendBytes: fileRecord(area.outputs.blend).bytes,
renderBytes: fileRecord(area.outputs.render).bytes,
},
@@ -273,7 +292,47 @@ function buildBlenderScene(area) {
});
}
function exportCesium(area) {
function ensureNativeRoadLayers(area) {
ensureFile(path.join(area.outputs.nativeRoadDir, "compiled.json"), "Native road compilation");
for (const file of ["road_surface.geojson", "edge_lines.geojson", "intersection_surface.geojson", "sidewalk_surface.geojson", "lane_separators.geojson", "center_lines.geojson", "direction_arrows.geojson", "turn_arrows.geojson", "crosswalks.geojson", "vehicle_stop_lines.geojson"]) {
ensureFile(path.join(area.outputs.nativeRoadDir, "layers", file), `Native road layer ${file}`);
}
}
function nativeRoadRecords(area) {
const root = path.join(area.outputs.nativeRoadDir, "layers");
return {
nativeRoadCompiled: fileRecord(path.join(area.outputs.nativeRoadDir, "compiled.json")),
nativeRoadSurface: fileRecord(path.join(root, "road_surface.geojson")),
nativeEdgeLines: fileRecord(path.join(root, "edge_lines.geojson")),
nativeIntersectionSurface: fileRecord(path.join(root, "intersection_surface.geojson")),
nativeSidewalkSurface: fileRecord(path.join(root, "sidewalk_surface.geojson")),
nativeLaneSeparators: fileRecord(path.join(root, "lane_separators.geojson")),
nativeCenterLines: fileRecord(path.join(root, "center_lines.geojson")),
nativeDirectionArrows: fileRecord(path.join(root, "direction_arrows.geojson")),
nativeTurnArrows: fileRecord(path.join(root, "turn_arrows.geojson")),
nativeCrosswalks: fileRecord(path.join(root, "crosswalks.geojson")),
nativeVehicleStopLines: fileRecord(path.join(root, "vehicle_stop_lines.geojson")),
};
}
function nativeRoadFeatureCounts(area) {
const root = path.join(area.outputs.nativeRoadDir, "layers");
return {
roadSurface: featureCount(path.join(root, "road_surface.geojson")),
edgeLines: featureCount(path.join(root, "edge_lines.geojson")),
intersectionSurface: featureCount(path.join(root, "intersection_surface.geojson")),
sidewalkSurface: featureCount(path.join(root, "sidewalk_surface.geojson")),
laneSeparators: featureCount(path.join(root, "lane_separators.geojson")),
centerLines: featureCount(path.join(root, "center_lines.geojson")),
directionArrows: featureCount(path.join(root, "direction_arrows.geojson")),
turnArrows: featureCount(path.join(root, "turn_arrows.geojson")),
crosswalks: featureCount(path.join(root, "crosswalks.geojson")),
vehicleStopLines: featureCount(path.join(root, "vehicle_stop_lines.geojson")),
};
}
function exportCesium(area, roadProvider) {
ensureFile(blenderExecutable(area), "Blender executable");
ensureFile(area.outputs.blend, "Blend scene");
ensureFile(path.join(repoRoot, "blender", "export_cesium.py"), "Cesium exporter");
@@ -287,9 +346,12 @@ function exportCesium(area) {
console.log("Stage: cesium");
const started = Date.now();
const startedAt = new Date(started).toISOString();
runCommand(blenderExecutable(area), [
const exporterArgs = [
"--background",
"--factory-startup",
// Blender 4.5 on this macOS host can crash while probing Metal extensions
// before the exporter script runs; this is Blender's documented workaround.
"--debug-gpu-force-workarounds",
"--python",
path.join(repoRoot, "blender", "export_cesium.py"),
"--",
@@ -297,15 +359,15 @@ function exportCesium(area) {
area.outputs.blend,
"--glb",
area.outputs.glb,
"--dynamic-glb",
area.outputs.trafficSignalsDynamicGlb,
"--countdown-0-glb",
area.outputs.trafficSignalsCountdown0Glb,
"--countdown-1-glb",
area.outputs.trafficSignalsCountdown1Glb,
"--metadata",
area.outputs.metadata,
], "cesium");
];
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");
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");
@@ -326,11 +388,14 @@ function exportCesium(area) {
glb: fileRecord(area.outputs.glb),
metadata: fileRecord(area.outputs.metadata),
trafficSignalsDynamicGlb: fileRecord(area.outputs.trafficSignalsDynamicGlb),
trafficSignalsCountdown0Glb: fileRecord(area.outputs.trafficSignalsCountdown0Glb),
trafficSignalsCountdown1Glb: fileRecord(area.outputs.trafficSignalsCountdown1Glb),
semanticAssets,
},
summary: {
glb: glbSummary(digest),
budget: evaluateGlbBudget(digest, area.budget),
roadProvider,
},
warnings: glbBudgetWarnings("Cesium", digest, area.budget),
});
@@ -421,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" });
@@ -481,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"));
@@ -517,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: [],
});
}

View File

@@ -5,6 +5,7 @@ const fs = require("fs");
const path = require("path");
const { readAreaConfig } = require("./lib/area-config");
const { compileRoadModel, compileGeometry, loadOverrides, validateOverrides, writeJsonAtomic } = require("./lib/native-road");
const { loadOrGenerate, runtime } = require("./lib/native-traffic-signals");
const repoRoot = path.resolve(__dirname, "..");
@@ -24,25 +25,40 @@ function compileArea(configPath) {
const model = compileRoadModel(fs.readFileSync(area.input, "utf8"), overrides);
validateOverrides(overrides, model);
fs.mkdirSync(area.outputs.pipelineDir, { recursive: true });
const compiled = compileGeometry(model, overrides);
const compiled = compileGeometry(model, overrides, { edgeLines: area.nativeRoad.edgeLines });
const signalDocument = loadOrGenerate(area.outputs.nativeTrafficSignals, fs.readFileSync(area.input, "utf8"), compiled.vehicleStopLines, compiled.intersectionSurface);
const signalRuntime = runtime(signalDocument);
// Persist validation normalization, including one-time legacy heading migration.
writeJsonAtomic(area.outputs.nativeTrafficSignals, signalDocument);
const staging = fs.mkdtempSync(path.join(area.outputs.pipelineDir, "native-road-"));
try {
const result = {
schema: "native-road-compiled/v1",
areaId: area.id,
source: { osm: area.input, overrides: area.outputs.nativeRoadOverrides },
source: { osm: area.input, overrides: area.outputs.nativeRoadOverrides, trafficSignals: area.outputs.nativeTrafficSignals },
model: { roads: model.roads, endpoints: model.endpoints, connections: model.connections },
movements: compiled.movements,
trafficSignals: { assemblies: "traffic-signal-assemblies.json", runtime: "traffic-signals.json", count: signalRuntime.signals.length },
diagnostics: compiled.diagnostics,
layers: { roadSurface: "layers/road_surface.geojson", intersectionSurface: "layers/intersection_surface.geojson", laneCenterlines: "layers/lane_centerlines.geojson", connectors: "layers/connectors.geojson" },
layers: { roadSurface: "layers/road_surface.geojson", edgeLines: "layers/edge_lines.geojson", sidewalkSurface: "layers/sidewalk_surface.geojson", intersectionSurface: "layers/intersection_surface.geojson", laneCenterlines: "layers/lane_centerlines.geojson", laneSeparators: "layers/lane_separators.geojson", centerLines: "layers/center_lines.geojson", directionArrows: "layers/direction_arrows.geojson", turnArrows: "layers/turn_arrows.geojson", crosswalks: "layers/crosswalks.geojson", vehicleStopLines: "layers/vehicle_stop_lines.geojson", connectors: "layers/connectors.geojson" },
};
const comparison = compareOsm2Streets(area, result.model, compiled);
writeJsonAtomic(path.join(staging, "compiled.json"), result);
writeJsonAtomic(path.join(staging, "diagnostics.json"), { schema: "native-road-diagnostics/v1", diagnostics: compiled.diagnostics });
writeJsonAtomic(path.join(staging, "comparison.json"), comparison);
writeJsonAtomic(path.join(staging, "traffic-signal-assemblies.json"), signalDocument.assemblies);
writeJsonAtomic(path.join(staging, "traffic-signals.json"), signalRuntime);
writeJsonAtomic(path.join(staging, "layers", "road_surface.geojson"), compiled.roadSurface);
writeJsonAtomic(path.join(staging, "layers", "edge_lines.geojson"), compiled.edgeLines);
writeJsonAtomic(path.join(staging, "layers", "sidewalk_surface.geojson"), compiled.sidewalkSurface);
writeJsonAtomic(path.join(staging, "layers", "intersection_surface.geojson"), compiled.intersectionSurface);
writeJsonAtomic(path.join(staging, "layers", "lane_centerlines.geojson"), compiled.laneCenterlines);
writeJsonAtomic(path.join(staging, "layers", "lane_separators.geojson"), compiled.laneSeparators);
writeJsonAtomic(path.join(staging, "layers", "center_lines.geojson"), compiled.centerLines);
writeJsonAtomic(path.join(staging, "layers", "direction_arrows.geojson"), compiled.directionArrows);
writeJsonAtomic(path.join(staging, "layers", "turn_arrows.geojson"), compiled.turnArrows);
writeJsonAtomic(path.join(staging, "layers", "crosswalks.geojson"), compiled.crosswalks);
writeJsonAtomic(path.join(staging, "layers", "vehicle_stop_lines.geojson"), compiled.vehicleStopLines);
writeJsonAtomic(path.join(staging, "layers", "connectors.geojson"), compiled.connectors);
fs.rmSync(area.outputs.nativeRoadDir, { recursive: true, force: true });
fs.renameSync(staging, area.outputs.nativeRoadDir);
@@ -67,12 +83,24 @@ function compareOsm2Streets(area, model, compiled) {
diagnosticsByRule[item.rule] = (diagnosticsByRule[item.rule] || 0) + 1;
}
const dangling = compiled.diagnostics.filter((item) => item.rule === "unconnected-interior-road-end");
const junctions = compiled.intersectionSurface.features;
const fallbackJunctions = junctions.filter((feature) => feature.properties.boundary_mode === "connector-convex-fallback");
return {
schema: "native-road-comparison/v2",
nativeRoadCount: model.roads.length,
nativeRoadSurfaceFeatures: compiled.roadSurface.features.length,
nativeSidewalkSurfaceFeatures: compiled.sidewalkSurface.features.length,
nativeJunctionSurfaceFeatures: compiled.intersectionSurface.features.length,
nativeApproachEnvelopeJunctions: junctions.length - fallbackJunctions.length,
nativeFallbackJunctions: fallbackJunctions.length,
nativeMaxJunctionExpansionRatio: junctions.reduce((maximum, feature) => Math.max(maximum, Number(feature.properties.expansion_ratio) || 0), 0),
nativeLaneCenterlineFeatures: compiled.laneCenterlines.features.length,
nativeLaneSeparatorFeatures: compiled.laneSeparators.features.length,
nativeCenterLineFeatures: compiled.centerLines.features.length,
nativeDirectionArrowFeatures: compiled.directionArrows.features.length,
nativeTurnArrowFeatures: compiled.turnArrows.features.length,
nativeCrosswalkFeatures: compiled.crosswalks.features.length,
nativeVehicleStopLineFeatures: compiled.vehicleStopLines.features.length,
nativeConnectorFeatures: compiled.connectors.features.length,
nativeMovementCount: compiled.movements.length,
nativePublishedMovementCount: compiled.movements.filter((movement) => movement.geometryPublished).length,

View File

@@ -38,6 +38,7 @@ function normalizeAreaConfig(raw, options = {}) {
geojsonDir,
nativeRoadDir,
nativeRoadOverrides: path.resolve(outputOverrides.nativeRoadOverrides || path.join(areaDir, "native-road-overrides.json")),
nativeTrafficSignals: path.resolve(outputOverrides.nativeTrafficSignals || path.join(areaDir, "native-traffic-signals.json")),
gpkg: path.resolve(outputOverrides.gpkg || path.join(areaDir, `${fileStem}.gpkg`)),
qgisProject: path.resolve(outputOverrides.qgisProject || path.join(areaDir, `${fileStem}.qgz`)),
qgisPreview: path.resolve(outputOverrides.qgisPreview || path.join(areaDir, `${fileStem}-preview.png`)),
@@ -65,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(
@@ -84,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,
@@ -107,6 +111,9 @@ function normalizeAreaConfig(raw, options = {}) {
turnLaneArrows: {
enabled: booleanOption(raw.turnLaneArrows?.enabled, false, "turnLaneArrows.enabled"),
},
nativeRoad: {
edgeLines: booleanOption(raw.nativeRoad?.edgeLines, false, "nativeRoad.edgeLines"),
},
osm2streets: raw.osm2streets || {
debug_each_step: false,
dual_carriageway_experiment: false,
@@ -117,6 +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 ?? "native"),
},
compress,
budget,
@@ -172,6 +180,14 @@ function numberOption(value, fallback, label, min, max) {
return number;
}
function roadProviderOption(value) {
const provider = value ?? "osm2streets";
if (provider !== "osm2streets" && provider !== "native") {
throw new Error("blender.roadProvider must be \"osm2streets\" or \"native\".");
}
return provider;
}
function integerOption(value, fallback, label) {
const number = value === undefined ? fallback : Number(value);
if (!Number.isInteger(number) || number < 1) {

View File

@@ -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.");

View File

@@ -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,
};
}

View File

@@ -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"],

View File

@@ -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(", "));
}

View 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 };

View File

@@ -2,18 +2,39 @@
const fs = require("fs");
const path = require("path");
const { arrowRingsAt, normalizeManeuver } = require("./turn-lane-arrows");
const OVERRIDE_SCHEMA = "native-road-overrides/v1";
const MOTOR_HIGHWAYS = new Set(["motorway", "trunk", "primary", "secondary", "tertiary", "unclassified", "residential", "living_street", "service"]);
const DEFAULT_WIDTHS = { motorway: 12, trunk: 10, primary: 10, secondary: 8, tertiary: 7, unclassified: 6, residential: 6, living_street: 5, service: 4 };
const DEFAULT_SIDEWALK_WIDTH_METERS = 2;
const DIRECTION_ARROW_INTERVAL_METERS = 32;
const DIRECTION_ARROW_ENDPOINT_BUFFER_METERS = 14;
const STOP_LINE_OFFSET_METERS = 2.7;
const STOP_LINE_MAX_APPROACH_DISTANCE_METERS = 25;
const CROSSWALK_JUNCTION_INSET_METERS = 1.5;
const CROSSWALK_MAX_JUNCTION_INSET_METERS = 4;
const CENTER_LINE_DASH_LENGTH_METERS = 2;
const CENTER_LINE_DASH_GAP_METERS = 2;
const CENTER_LINE_WIDTH_METERS = .25;
const CENTER_LINE_SOLID_OVERLAP_METERS = .04;
const CENTER_LINE_CONTROL_CLEARANCE_METERS = 1;
const CENTER_LINE_COLORS = new Set(["yellow", "white"]);
const CENTER_LINE_PATTERNS = new Set(["dashed", "solid"]);
const CONNECTOR_BOUNDARY_TOLERANCE_METERS = .05;
const JUNCTION_CURVE_SEGMENTS = 8;
function parseOsmRoads(xml) {
const nodes = new Map();
const crossingNodes = [];
for (const match of xml.matchAll(/<node\b([^>]*?)(?:\/>|>([\s\S]*?)<\/node>)/g)) {
const attrs = xmlAttrs(match[1]);
if (attrs.action === "delete" || !attrs.id || attrs.lon === undefined || attrs.lat === undefined) continue;
const coordinate = [Number(attrs.lon), Number(attrs.lat)];
if (coordinate.every(Number.isFinite)) nodes.set(String(attrs.id), coordinate);
if (!coordinate.every(Number.isFinite)) continue;
const id = String(attrs.id); const tags = parseTags(match[2] || "");
nodes.set(id, coordinate);
if (tags.highway === "crossing" && !["no", "none", "unmarked"].includes(tags["crossing:markings"])) crossingNodes.push({ id, coordinate, tags });
}
const ways = [];
for (const match of xml.matchAll(/<way\b([^>]*)>([\s\S]*?)<\/way>/g)) {
@@ -26,7 +47,7 @@ function parseOsmRoads(xml) {
if (coords.length < 2 || coords.length !== refs.length) continue;
ways.push({ id: String(attrs.id), refs: refs.map(String), coords, tags });
}
return { nodes, ways };
return { nodes, ways, crossingNodes };
}
function compileRoadModel(xml, overrides) {
@@ -35,12 +56,19 @@ function compileRoadModel(xml, overrides) {
const roads = [];
const endpoints = [];
const byNode = new Map();
for (const way of parsed.ways) {
const sharedNodeWayIds = new Map();
for (const way of parsed.ways) for (const nodeId of new Set(way.refs)) {
if (!sharedNodeWayIds.has(nodeId)) sharedNodeWayIds.set(nodeId, new Set());
sharedNodeWayIds.get(nodeId).add(way.id);
}
for (const sourceWay of parsed.ways) {
const segments = splitWayAtSharedNodes(sourceWay, sharedNodeWayIds);
for (const way of segments) {
const directions = way.tags.oneway === "yes" || way.tags.oneway === "1" || way.tags.junction === "roundabout" ? ["forward"] : ["forward", "backward"];
for (const direction of directions) {
const base = roadAttributes(way.tags, direction);
const id = `road:way/${way.id}:${direction}`;
const road = { id, osmWayIds: [way.id], direction, highway: way.tags.highway, centerline: direction === "forward" ? way.coords : [...way.coords].reverse(), sourceNodeIds: direction === "forward" ? [way.refs[0], way.refs.at(-1)] : [way.refs.at(-1), way.refs[0]], tags: way.tags, ...base, appliedOverrideIds: [], diagnostics: [] };
const id = `road:way/${way.id}${way.segmentIndex === null ? "" : `:segment/${way.segmentIndex}`}:${direction}`;
const road = { id, osmWayIds: [way.id], segmentId: `segment:way/${way.id}/${way.segmentIndex ?? 0}`, sourceRoadId: `road:way/${way.id}:${direction}`, direction, highway: way.tags.highway, centerline: direction === "forward" ? way.coords : [...way.coords].reverse(), sourceNodeIds: direction === "forward" ? [way.refs[0], way.refs.at(-1)] : [way.refs.at(-1), way.refs[0]], tags: way.tags, ...base, appliedOverrideIds: [], diagnostics: [] };
applyRoadOverrides(road, overrides, diagnostics);
roads.push(road);
for (const side of ["start", "end"]) {
@@ -51,6 +79,7 @@ function compileRoadModel(xml, overrides) {
byNode.get(nodeId).push(endpoint);
}
}
}
}
const connections = resolveConnections(endpoints, byNode, overrides, diagnostics);
const extent = roadExtent(roads);
@@ -60,7 +89,19 @@ function compileRoadModel(xml, overrides) {
diagnostics.push({ ...diagnostic("warning", endpoint.roadId, [nodeId], "unconnected-interior-road-end", "道路在区域内部结束,未连接到其他机动车道路。请确认这是实际断头,还是 OSM 节点尚未连接。", endpoint.coordinate), endpointId: endpoint.id, manualCandidates: nearbyManualCandidates(endpoints, endpoint) });
}
}
return { schema: "native-road-model/v1", roads, endpoints, connections, diagnostics };
const crossings = parsed.crossingNodes.map((crossing) => ({ ...crossing, osmWayIds: parsed.ways.filter((way) => way.refs.includes(crossing.id)).map((way) => way.id) }));
return { schema: "native-road-model/v1", roads, endpoints, connections, crossings, diagnostics };
}
function splitWayAtSharedNodes(way, sharedNodeWayIds) {
const splitIndexes = [0];
for (let index = 1; index < way.refs.length - 1; index += 1) if ((sharedNodeWayIds.get(way.refs[index])?.size || 0) > 1) splitIndexes.push(index);
splitIndexes.push(way.refs.length - 1);
if (splitIndexes.length === 2) return [{ ...way, segmentIndex: null }];
return splitIndexes.slice(1).map((end, index) => {
const start = splitIndexes[index];
return { ...way, refs: way.refs.slice(start, end + 1), coords: way.coords.slice(start, end + 1), segmentIndex: index + 1 };
});
}
function roadExtent(roads) {
@@ -112,9 +153,11 @@ function loadOverrides(file) {
function validateOverrides(value, model) {
if (!value || value.schema !== OVERRIDE_SCHEMA || !Array.isArray(value.overrides)) throw new Error(`Overrides must use ${OVERRIDE_SCHEMA}.`);
const ids = new Set();
const roadIds = model ? new Set(model.roads.map((road) => road.id)) : null;
const roadIds = model ? new Set(model.roads.flatMap((road) => [road.id, road.sourceRoadId])) : null;
const directionalRoadIds = model ? new Set(model.roads.map((road) => road.id)) : null;
const endpointIds = model ? new Set(model.endpoints.map((endpoint) => endpoint.id)) : null;
const laneIds = model ? new Set(model.roads.flatMap((road) => Array.from({ length: road.laneCount }, (_, index) => `lane:${road.id}:${index + 1}`))) : null;
const segmentIds = model ? new Set(model.roads.map((road) => road.segmentId)) : null;
for (const item of value.overrides) {
if (!item || typeof item.id !== "string" || !item.id || ids.has(item.id)) throw new Error("Each override needs a unique id.");
ids.add(item.id);
@@ -127,13 +170,23 @@ function validateOverrides(value, model) {
if (model && !connectionEndpointsCompatible(model, item.fromEndpointId, item.toEndpointId)) throw new Error("A manual junction connection must go from a road end to a nearby road start (within 35m).");
} else if (item.kind === "lane-connection") {
if (typeof item.fromLaneId !== "string" || typeof item.toLaneId !== "string" || typeof item.enabled !== "boolean" || (laneIds && (!laneIds.has(item.fromLaneId) || !laneIds.has(item.toLaneId)))) throw new Error("Invalid lane connection override.");
} else if (item.kind === "center-line-style") {
if (typeof item.segmentId !== "string" || !CENTER_LINE_COLORS.has(item.color) || !CENTER_LINE_PATTERNS.has(item.pattern) || (item.double !== undefined && typeof item.double !== "boolean") || (item.double && (item.color !== "yellow" || item.pattern !== "solid")) || (segmentIds && !segmentIds.has(item.segmentId))) throw new Error("Invalid center line style override.");
} else if (item.kind === "lane-separator-style") {
if (typeof item.roadId !== "string" || (roadIds && !roadIds.has(item.roadId)) || !Number.isInteger(item.leftLaneIndex) || item.rightLaneIndex !== item.leftLaneIndex + 1 || !CENTER_LINE_COLORS.has(item.color) || !CENTER_LINE_PATTERNS.has(item.pattern)) throw new Error("Invalid lane separator style override.");
} else if (item.kind === "edge-line-style") {
if (typeof item.roadId !== "string" || (directionalRoadIds && !directionalRoadIds.has(item.roadId)) || !["left", "right"].includes(item.side) || !CENTER_LINE_COLORS.has(item.color) || !CENTER_LINE_PATTERNS.has(item.pattern)) throw new Error("Invalid edge line style override.");
} else throw new Error(`Unsupported override kind: ${item.kind}`);
}
return { schema: OVERRIDE_SCHEMA, overrides: value.overrides };
}
function applyRoadOverrides(road, overrides, diagnostics) {
for (const item of overrides.overrides.filter((entry) => entry.kind === "road" && entry.roadId === road.id)) {
const matching = overrides.overrides.filter((entry) => entry.kind === "road" && (entry.roadId === road.sourceRoadId || entry.roadId === road.id));
// A legacy whole-way edit remains the baseline; a segment-specific edit can
// deliberately refine it after the compiler has introduced split segments.
matching.sort((first, second) => Number(first.roadId === road.id) - Number(second.roadId === road.id));
for (const item of matching) {
for (const key of ["widthMeters", "laneCount", "sidewalkLeft", "sidewalkRight"]) if (item[key] !== undefined) road[key] = item[key];
road.appliedOverrideIds.push(item.id);
for (const key of ["widthMeters", "laneCount"]) if (item[key] !== undefined) road.provenance[key] = `override:${item.id}`;
@@ -171,8 +224,9 @@ function resolveConnections(endpoints, byNode, overrides, diagnostics) {
function endpointNode(model, endpointId) { return model.endpoints.find((endpoint) => endpoint.id === endpointId)?.nodeId; }
function sameOsmWay(endpoints, firstRoadId, secondRoadId) {
const wayId = (roadId) => roadId.split(":")[1];
return wayId(firstRoadId) === wayId(secondRoadId);
const roadFor = (roadId) => endpoints.find((endpoint) => endpoint.roadId === roadId)?.roadId;
const segmentId = (roadId) => roadId.replace(/:(forward|backward)$/, "");
return segmentId(roadFor(firstRoadId) || firstRoadId) === segmentId(roadFor(secondRoadId) || secondRoadId);
}
function connectionEndpointsCompatible(model, fromId, toId) {
const from = model.endpoints.find((endpoint) => endpoint.id === fromId);
@@ -188,25 +242,350 @@ function nearbyManualCandidates(endpoints, from) {
return endpoints.filter((to) => to.side === "start" && to.roadId !== from.roadId && !sameOsmWay(endpoints, from.roadId, to.roadId)).map((to) => ({ to, distanceMeters: distanceMeters(from.coordinate, to.coordinate) })).filter((item) => item.distanceMeters <= 35).sort((a, b) => a.distanceMeters - b.distanceMeters).slice(0, 3).map(({ to, distanceMeters: meters }) => ({ toEndpointId: to.id, roadId: to.roadId, distanceMeters: Math.round(meters * 10) / 10 }));
}
function compileGeometry(model, overrides = { overrides: [] }) {
function compileGeometry(model, overrides = { overrides: [] }, options = {}) {
const diagnostics = [...model.diagnostics];
const junctionPlans = compileJunctionPlans(model);
const features = [];
const emittedWays = new Set();
const emittedSegments = new Set();
for (const road of model.roads) {
const wayKey = road.osmWayIds.join(",");
if (emittedWays.has(wayKey)) continue;
emittedWays.add(wayKey);
const directions = model.roads.filter((item) => item.osmWayIds.join(",") === wayKey);
const segmentKey = road.segmentId;
if (emittedSegments.has(segmentKey)) continue;
emittedSegments.add(segmentKey);
const directions = model.roads.filter((item) => item.segmentId === segmentKey);
const totalWidth = directions.reduce((sum, item) => sum + item.widthMeters, 0);
const ring = roadRing(road.centerline, totalWidth);
// The approach surface stops at the junction cutback. The junction layer
// owns the intervening rounded corners; leaving approaches untrimmed
// would cover that outline with rectangular road ends in Blender/Cesium.
const ring = roadRing(trimLineAtJunctions(road.centerline, road.sourceNodeIds, junctionPlans), totalWidth);
if (!ring) { diagnostics.push(diagnostic("error", road.id, road.osmWayIds, "unclosed-road-surface", "Could not construct a valid road polygon from this centerline.", road.centerline[0])); continue; }
features.push({ type: "Feature", properties: { native_id: `surface:way/${wayKey}`, directional_road_ids: directions.map((item) => item.id).join(","), osm_way_ids: wayKey, width_m: totalWidth, lane_count: directions.reduce((sum, item) => sum + item.laneCount, 0), provenance: JSON.stringify(directions.map((item) => item.provenance)), override_ids: directions.flatMap((item) => item.appliedOverrideIds).join(",") }, geometry: { type: "Polygon", coordinates: [ring] } });
const surfaceId = road.segmentId.endsWith("/0") ? `surface:way/${road.osmWayIds.join(",")}` : `surface:${segmentKey}`;
features.push({ type: "Feature", properties: { native_id: surfaceId, directional_road_ids: directions.map((item) => item.id).join(","), osm_way_ids: road.osmWayIds.join(","), source_road_id: road.sourceRoadId, width_m: totalWidth, lane_count: directions.reduce((sum, item) => item.laneCount + sum, 0), provenance: JSON.stringify(directions.map((item) => item.provenance)), override_ids: directions.flatMap((item) => item.appliedOverrideIds).join(",") }, geometry: { type: "Polygon", coordinates: [ring] } });
}
const lanes = compileLaneCenterlines(model, diagnostics);
const connectorResult = compileConnectors(model, lanes, diagnostics, overrides);
const junctionFeatures = compileJunctionSurfaces(model, lanes, connectorResult.features, connectorResult.movements, diagnostics);
const lanes = compileLaneCenterlines(model, diagnostics, junctionPlans);
const edgeLines = options.edgeLines === false ? [] : compileEdgeLines(model, overrides, junctionPlans);
const controls = compileControlMarkings(model, lanes, diagnostics, junctionPlans);
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, junctionPlans);
const junctionFeatures = compileJunctionSurfaces(model, junctionPlans, connectorResult.features, connectorResult.movements, diagnostics);
validateConnectorContainment(connectorResult.features, junctionFeatures, diagnostics);
return { roadSurface: { type: "FeatureCollection", features }, intersectionSurface: { type: "FeatureCollection", features: junctionFeatures }, laneCenterlines: { type: "FeatureCollection", features: lanes.features }, connectors: { type: "FeatureCollection", features: connectorResult.features }, movements: connectorResult.movements, 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 };
}
function compileEdgeLines(model, overrides, junctionPlans) {
const features = [];
for (const road of model.roads) {
const line = trimLineAtJunctions(road.centerline, road.sourceNodeIds, junctionPlans);
const bidirectional = model.roads.some((item) => item.segmentId === road.segmentId && item.id !== road.id);
// On a two-way segment, the inner edge is the road centre boundary and is
// owned by center_lines. Emit only each directional carriageway's outer
// edge; emitting both sides makes the layer look like a second centreline.
const offsets = bidirectional ? [-1] : [-1, 1];
for (const offset of offsets) {
const side = offset < 0 ? "right" : "left";
const style = edgeLineStyle(overrides, road.id, side);
const centerline = offsetLine(line, offset * road.widthMeters / 2);
if (style.pattern === "solid") {
const ring = roadRing(centerline, .12);
if (ring) features.push(edgeLineFeature(road, side, style, ring));
continue;
}
for (let distance = 1, part = 1; distance + 1 <= lineLengthMeters(centerline); distance += 4, part += 1) {
const placement = pointAndAxisAlongLine(centerline, distance);
if (!placement) continue;
features.push(edgeLineFeature(road, side, style, rectangleAt(placement.point, placement.axis, [-placement.axis[1], placement.axis[0]], 2, .12, 0), part));
}
}
}
return features;
}
function edgeLineFeature(road, side, style, ring, part = null) {
return { type: "Feature", properties: { native_id: `edge-line:${road.id}:${side}${part ? `:${part}` : ""}`, road_id: road.id, side, osm_way_ids: road.osmWayIds.join(","), color: style.color, pattern: style.pattern, effective_style: `${style.color}-${style.pattern}`, provenance: "native-road-edge-line/v1" }, geometry: { type: "Polygon", coordinates: [ring] } };
}
function compileCenterLines(model, overrides, junctionPlans, controls, diagnostics) {
const features = [];
const controlFeatures = [...controls.crosswalks, ...controls.stopLines];
const segments = new Map();
for (const road of model.roads) {
if (!segments.has(road.segmentId)) segments.set(road.segmentId, []);
segments.get(road.segmentId).push(road);
}
for (const [segmentId, roads] of segments) {
const forward = roads.find((road) => road.direction === "forward");
const backward = roads.find((road) => road.direction === "backward");
if (roads.length !== 2 || !forward || !backward || forward.highway === "service" || backward.highway === "service") continue;
const line = trimLineAtJunctions(forward.centerline, forward.sourceNodeIds, junctionPlans);
const length = lineLengthMeters(line);
if (line.length < 2 || !Number.isFinite(length)) { diagnostics.push(diagnostic("warning", segmentId, forward.osmWayIds, "invalid-center-line", "双向道路无法生成有效道路中心虚线。", forward.centerline[0])); continue; }
const style = centerLineStyle(overrides, segmentId);
const gap = style.pattern === "solid" ? 0 : CENTER_LINE_DASH_GAP_METERS;
const markLength = CENTER_LINE_DASH_LENGTH_METERS + (style.pattern === "solid" ? CENTER_LINE_SOLID_OVERLAP_METERS : 0);
for (let start = 0, dashIndex = 1; start + markLength <= length; start += CENTER_LINE_DASH_LENGTH_METERS + gap, dashIndex += 1) {
const placement = pointAndAxisAlongLine(line, start + markLength / 2);
if (!placement) continue;
const clearanceRing = rectangleAt(placement.point, placement.axis, [-placement.axis[1], placement.axis[0]], markLength + CENTER_LINE_CONTROL_CLEARANCE_METERS * 2, CENTER_LINE_WIDTH_METERS + CENTER_LINE_CONTROL_CLEARANCE_METERS * 2, 0);
if (ringsOverlapControl([clearanceRing], controlFeatures)) continue;
for (const offset of style.double ? [-.16, .16] : [0]) { const ring = rectangleAt(placement.point, placement.axis, [-placement.axis[1], placement.axis[0]], markLength, CENTER_LINE_WIDTH_METERS, offset); features.push({ type: "Feature", properties: { native_id: `center-line:${segmentId}:${dashIndex}:${offset}`, segment_id: segmentId, road_id: forward.id, directional_road_ids: roads.map((road) => road.id).join(","), osm_way_ids: forward.osmWayIds.join(","), dash_index: dashIndex, dash_length_m: markLength, dash_gap_m: gap, color: style.color, pattern: style.pattern, double: Boolean(style.double), effective_style: `${style.double ? "double-" : ""}${style.color}-${style.pattern}`, placement_rule: "native-bidirectional-centerline/v1", provenance: "native-road-center-line/v1" }, geometry: { type: "Polygon", coordinates: [ring] } }); }
}
}
return features;
}
function centerLineStyle(overrides, segmentId) {
const override = overrides.overrides.find((item) => item.kind === "center-line-style" && item.segmentId === segmentId);
return override ? { color: override.color, pattern: override.pattern, double: Boolean(override.double) } : { color: "yellow", pattern: "dashed", double: false };
}
function edgeLineStyle(overrides, roadId, side) {
const value = overrides.overrides.find((item) => item.kind === "edge-line-style" && item.roadId === roadId && item.side === side);
return value ? { color: value.color, pattern: value.pattern } : { color: "white", pattern: "solid" };
}
function compileControlMarkings(model, lanes, diagnostics, junctionPlans = new Map()) {
const crosswalks = []; const stopLines = [];
const arrivalEndpointIds = new Set(model.connections.filter((connection) => connection.enabled).map((connection) => connection.fromEndpointId));
for (const crossing of model.crossings || []) {
const candidates = model.roads.filter((road) => crossing.osmWayIds.includes(road.osmWayIds[0])).flatMap((road) => (lanes.byRoadId.get(road.id) || []).map((lane) => ({ road, lane, placement: nearestLanePlacement(lane.coordinates, crossing.coordinate), junctionDistanceMeters: distanceMeters(crossing.coordinate, road.centerline.at(-1)) })).filter((item) => item.placement));
const candidate = candidates.sort((a, b) => a.placement.distance - b.placement.distance)[0];
if (!candidate || candidate.placement.distance > 12) { diagnostics.push(diagnostic("warning", `crossing:node/${crossing.id}`, [crossing.id], "crossing-no-native-lane", "人行横道无法匹配到安全的原生车道,未生成标线。", crossing.coordinate)); continue; }
const approach = candidates.filter((item) => arrivalEndpointIds.has(`endpoint:${item.road.id}:end`) && item.junctionDistanceMeters > STOP_LINE_OFFSET_METERS && item.junctionDistanceMeters <= STOP_LINE_MAX_APPROACH_DISTANCE_METERS).sort((a, b) => a.junctionDistanceMeters - b.junctionDistanceMeters || a.placement.distance - b.placement.distance)[0];
const crosswalkCandidate = approach || candidate;
const junctionInsetMeters = approach ? crossingJunctionInset(approach, junctionPlans) : 0;
const controlCenter = offsetByMeters(crossing.coordinate, crosswalkCandidate.placement.axis, junctionInsetMeters);
const { axis } = crosswalkCandidate.placement; const across = [-axis[1], axis[0]];
for (let index = 0; index < 6; index += 1) crosswalks.push(controlFeature("crosswalk", crossing, crosswalkCandidate, index + 1, rectangleAt(controlCenter, axis, across, 3, .45, -2.25 + index * .9), { junctionInsetMeters }));
if (!approach) { diagnostics.push(diagnostic("info", `crossing:node/${crossing.id}`, [crossing.id], "crossing-no-safe-stop-line", "人行横道没有可确认的路口进口车道,保留斑马线但未生成停止线。", crossing.coordinate)); continue; }
const rawRoadPlacement = nearestLanePlacement(approach.road.centerline, controlCenter);
const laneOffset = rawRoadPlacement ? project(approach.placement.point, rawRoadPlacement.point) : [0, 0];
const lateralOffset = laneOffset[0] * across[0] + laneOffset[1] * across[1];
const laneCenterAtCrossing = offsetByMeters(controlCenter, across, lateralOffset);
const stopCenter = offsetByMeters(laneCenterAtCrossing, approach.placement.axis, -STOP_LINE_OFFSET_METERS);
stopLines.push(controlFeature("stop-line", crossing, approach, 1, rectangleAt(stopCenter, across, approach.placement.axis, approach.road.widthMeters, .45, 0), { junctionInsetMeters }));
}
return { crosswalks, stopLines };
}
function crossingJunctionInset(candidate, junctionPlans) {
const junctionNodeId = candidate.road.sourceNodeIds.at(-1);
const plan = junctionPlans.get(junctionNodeId);
if (!plan) return 0;
const targetDistance = Math.max(0, plan.cutbackMeters - CROSSWALK_JUNCTION_INSET_METERS);
return Math.min(CROSSWALK_MAX_JUNCTION_INSET_METERS, Math.max(0, candidate.junctionDistanceMeters - targetDistance));
}
function nearestLanePlacement(line, target) { let best = null; let traversedMeters = 0; for (let index = 1; index < line.length; index += 1) { const a = line[index - 1]; const b = line[index]; const vector = project(b, a); const length = Math.hypot(...vector); if (!length) continue; const relative = project(target, a); const ratio = Math.max(0, Math.min(1, (relative[0] * vector[0] + relative[1] * vector[1]) / (length * length))); const point = interpolate(a, b, ratio); const distance = distanceMeters(point, target); if (!best || distance < best.distance) best = { point, axis: [vector[0] / length, vector[1] / length], distance, distanceToEndMeters: lineLengthMeters(line) - traversedMeters - length * ratio }; traversedMeters += length; } return best; }
function offsetByMeters(point, axis, meters) { return unproject([axis[0] * meters, axis[1] * meters], point); }
function rectangleAt(center, axis, across, length, width, offset) { const shifted = offsetByMeters(center, across, offset); const corners = [[-length / 2, -width / 2], [length / 2, -width / 2], [length / 2, width / 2], [-length / 2, width / 2]].map(([forward, side]) => unproject([axis[0] * forward + across[0] * side, axis[1] * forward + across[1] * side], shifted)); return [...corners, corners[0]]; }
function controlFeature(kind, crossing, candidate, part, ring, placement = {}) { const stop = kind === "stop-line"; return { type: "Feature", properties: { native_id: `${kind}:node/${crossing.id}:${part}`, crossing_node_id: crossing.id, road_id: candidate.road.id, lane_id: candidate.lane.id, osm_way_ids: candidate.road.osmWayIds.join(","), direction: candidate.road.direction, placement_method: "native-lane-nearest-point/v1", junction_inset_m: Math.round((placement.junctionInsetMeters || 0) * 100) / 100, provenance: stop ? "native-road-stop-line/v1" : "native-road-crosswalk/v1" }, geometry: { type: "Polygon", coordinates: [ring] } }; }
function compileLaneMarkings(model, overrides, lanes, diagnostics, junctionPlans, controls) {
const separators = []; const directionArrows = []; const turnArrows = [];
const controlFeatures = [...controls.crosswalks, ...controls.stopLines];
for (const road of model.roads) {
const roadLanes = lanes.byRoadId.get(road.id) || [];
for (let index = 1; index < roadLanes.length; index += 1) {
const left = roadLanes[index - 1].coordinates; const right = roadLanes[index].coordinates;
if (left.length !== right.length) continue;
const centerline = left.map((point, pointIndex) => [(point[0] + right[pointIndex][0]) / 2, (point[1] + right[pointIndex][1]) / 2]);
const style = laneSeparatorStyle(overrides, road.id, index, index + 1);
const properties = { road_id: road.id, left_lane_index: index, right_lane_index: index + 1, osm_way_ids: road.osmWayIds.join(","), color: style.color, pattern: style.pattern, effective_style: `${style.color}-${style.pattern}`, provenance: "native-road-lane-separator/v1" };
if (style.pattern === "solid") { const ring = roadRing(centerline, 0.12); if (ring) separators.push({ type: "Feature", properties: { native_id: `lane-separator:${road.id}:${index}-${index + 1}`, ...properties }, geometry: { type: "Polygon", coordinates: [ring] } }); }
else for (let distance = 1, part = 1; distance + 1 <= lineLengthMeters(centerline); distance += 4, part += 1) { const placement = pointAndAxisAlongLine(centerline, distance); if (!placement) continue; const ring = rectangleAt(placement.point, placement.axis, [-placement.axis[1], placement.axis[0]], 2, .12, 0); separators.push({ type: "Feature", properties: { native_id: `lane-separator:${road.id}:${index}-${index + 1}:${part}`, ...properties }, geometry: { type: "Polygon", coordinates: [ring] } }); }
}
for (const lane of roadLanes) directionArrows.push(...directionArrowFeatures(road, lane, controlFeatures, diagnostics));
const turns = road.tags[`turn:lanes:${road.direction}`] ?? road.tags["turn:lanes"];
const maneuvers = turns ? String(turns).split("|") : [];
for (let index = 0; index < roadLanes.length; index += 1) {
const lane = roadLanes[index]; const explicitManeuver = maneuvers[index];
if (!explicitManeuver) continue;
const maneuver = normalizeManeuver(explicitManeuver);
if (!lane) { diagnostics.push(diagnostic("warning", road.id, road.osmWayIds, "turn-arrow-lane-missing", "转向标签引用了不存在的车道,未生成箭头。", road.centerline.at(-1))); continue; }
if (!arrowRingsAt(maneuver, lane.coordinates.at(-1), [0, 1]).length) { diagnostics.push(diagnostic("info", lane.id, road.osmWayIds, "turn-arrow-unsupported", "转向标签不在当前已测试的箭头集合中,未生成箭头。", lane.coordinates.at(-1))); continue; }
if (lineLengthMeters(lane.coordinates) < 8) { diagnostics.push(diagnostic("warning", lane.id, road.osmWayIds, "turn-arrow-no-safe-placement", "驶入路口前的车道过短,未生成转向箭头。", lane.coordinates.at(-1))); continue; }
const previous = lane.coordinates.at(-2); const end = lane.coordinates.at(-1);
const meters = project(end, end); const vector = project(previous, end); const length = Math.hypot(-vector[0], -vector[1]);
const axis = length ? [-vector[0] / length, -vector[1] / length] : null;
const placement = axis ? [6, 10, 14, 18, 22].find((distance) => distance < lineLengthMeters(lane.coordinates) - 2 && !ringsOverlapControl(arrowRingsAt(maneuver, pointAlongLine([...lane.coordinates].reverse(), distance), axis), controlFeatures)) : null;
if (!placement) { diagnostics.push(diagnostic("info", lane.id, road.osmWayIds, "turn-arrow-control-conflict", "转向箭头会压住斑马线或停止线,未生成该箭头。", lane.coordinates.at(-1))); continue; }
const center = pointAlongLine([...lane.coordinates].reverse(), placement);
const rings = arrowRingsAt(maneuver, center, axis);
if (!rings.length) continue;
for (let part = 0; part < rings.length; part += 1) turnArrows.push({ type: "Feature", properties: { native_id: `turn-arrow:${lane.id}:${maneuver}:${part}`, road_id: road.id, lane_id: lane.id, osm_way_ids: road.osmWayIds.join(","), direction: road.direction, lane_index: lane.index, maneuver, arrow_part: part, placement_distance_meters: placement, provenance: "native-road-turn-arrow/v1" }, geometry: { type: "Polygon", coordinates: [rings[part]] } });
}
}
return { separators, directionArrows, turnArrows };
}
function laneSeparatorStyle(overrides, roadId, leftLaneIndex, rightLaneIndex) { const value = overrides.overrides.find((item) => item.kind === "lane-separator-style" && item.roadId === roadId && item.leftLaneIndex === leftLaneIndex && item.rightLaneIndex === rightLaneIndex); return value ? { color: value.color, pattern: value.pattern } : { color: "white", pattern: "dashed" }; }
function directionArrowFeatures(road, lane, controlFeatures, diagnostics) {
const length = lineLengthMeters(lane.coordinates);
const features = [];
for (let distance = DIRECTION_ARROW_ENDPOINT_BUFFER_METERS, sequence = 1; distance <= length - DIRECTION_ARROW_ENDPOINT_BUFFER_METERS; distance += DIRECTION_ARROW_INTERVAL_METERS, sequence += 1) {
const placement = pointAndAxisAlongLine(lane.coordinates, distance);
if (!placement) continue;
const rings = arrowRingsAt("through", placement.point, placement.axis);
if (ringsOverlapControl(rings, controlFeatures)) { diagnostics.push(diagnostic("info", lane.id, road.osmWayIds, "direction-arrow-control-conflict", "默认直行箭头会压住斑马线或停止线,已跳过该位置。", placement.point)); continue; }
for (let part = 0; part < rings.length; part += 1) features.push({ type: "Feature", properties: { native_id: `direction-arrow:${lane.id}:${sequence}:${part}`, road_id: road.id, lane_id: lane.id, osm_way_ids: road.osmWayIds.join(","), direction: road.direction, lane_index: lane.index, maneuver: "through", sequence, distance_along_lane_meters: Math.round(distance * 10) / 10, placement_interval_meters: DIRECTION_ARROW_INTERVAL_METERS, provenance: "native-road-direction-arrow/v1" }, geometry: { type: "Polygon", coordinates: [rings[part]] } });
}
return features;
}
function ringsOverlapControl(rings, controls) {
return rings.some((ring) => controls.some((feature) => ringsOverlap(ring, feature.geometry.coordinates[0])));
}
function ringsOverlap(first, second) {
const bounds = (ring) => [Math.min(...ring.map((point) => point[0])), Math.min(...ring.map((point) => point[1])), Math.max(...ring.map((point) => point[0])), Math.max(...ring.map((point) => point[1]))];
const a = bounds(first); const b = bounds(second);
if (a[0] > b[2] || a[2] < b[0] || a[1] > b[3] || a[3] < b[1]) return false;
if (first.some((point) => pointInPolygon(point, second)) || second.some((point) => pointInPolygon(point, first))) return true;
return first.slice(1).some((point, index) => second.slice(1).some((other, otherIndex) => segmentsIntersect(first[index], point, second[otherIndex], other)));
}
function compileSidewalkSurfaces(model, diagnostics, junctionPlans) {
const features = [];
const byWay = new Map();
for (const road of model.roads) {
const key = road.segmentId;
if (!byWay.has(key)) byWay.set(key, []);
byWay.get(key).push(road);
}
for (const [wayKey, directions] of byWay) {
const forward = directions.find((road) => road.direction === "forward") || directions[0];
const backward = directions.find((road) => road.id !== forward.id);
const totalWidth = directions.reduce((sum, road) => sum + road.widthMeters, 0);
const sides = [
["left", forward.sidewalkLeft || Boolean(backward?.sidewalkRight)],
["right", forward.sidewalkRight || Boolean(backward?.sidewalkLeft)],
];
for (const [side, enabled] of sides) {
if (!enabled) continue;
const centerline = trimLineAtJunctions(forward.centerline, forward.sourceNodeIds, junctionPlans);
const ring = sidewalkRing(centerline, totalWidth / 2, totalWidth / 2 + DEFAULT_SIDEWALK_WIDTH_METERS, side === "left" ? 1 : -1);
if (!ring) { diagnostics.push(diagnostic("warning", forward.id, forward.osmWayIds, "invalid-sidewalk-surface", "无法为该道路生成连续人行道面。", forward.centerline[0])); continue; }
const sidewalkId = forward.segmentId.endsWith("/0") ? `sidewalk:way/${forward.osmWayIds.join(",")}:${side}` : `sidewalk:${wayKey}:${side}`;
features.push({ type: "Feature", properties: { native_id: sidewalkId, osm_way_ids: forward.osmWayIds.join(","), source_road_id: forward.sourceRoadId, side, width_m: DEFAULT_SIDEWALK_WIDTH_METERS, directional_road_ids: directions.map((road) => road.id).join(","), provenance: "native-road-sidewalk/v1", override_ids: directions.flatMap((road) => road.appliedOverrideIds).join(",") }, geometry: { type: "Polygon", coordinates: [ring] } });
}
}
features.push(...compileSidewalkCorners(model, junctionPlans));
return features;
}
function compileSidewalkCorners(model, junctionPlans) {
const result = [];
for (const [nodeId, plan] of junctionPlans) {
const candidates = [];
for (const approach of plan.approaches) {
const directions = model.roads.filter((road) => road.segmentId === approach.segmentId);
const forward = directions.find((road) => road.direction === "forward") || directions[0];
if (!forward) continue;
const outwardIsForward = forward.sourceNodeIds[0] === nodeId;
const sideStates = outwardIsForward
? { left: forward.sidewalkLeft, right: forward.sidewalkRight }
: { left: forward.sidewalkRight, right: forward.sidewalkLeft };
const cutback = pointAlongLine(approach.line, plan.cutbackMeters);
if (!cutback) continue;
const heading = headingAtEndpoint(approach.line);
const halfWidth = approach.widthMeters / 2;
for (const [side, enabled] of Object.entries(sideStates)) {
if (!enabled) continue;
// offsetLine's positive normal is driver's left, which is heading -90
// in this north-based heading convention.
const sideHeading = heading + (side === "left" ? -90 : 90);
candidates.push({
wayKey: approach.segmentId,
sourceWayKey: forward.osmWayIds.join(","),
side,
outwardHeading: heading,
normalDegrees: sideHeading,
curb: offsetCoordinate(cutback, sideHeading, halfWidth),
outer: offsetCoordinate(cutback, sideHeading, halfWidth + DEFAULT_SIDEWALK_WIDTH_METERS),
});
}
}
candidates.sort((a, b) => angleAround(plan.node, a.curb) - angleAround(plan.node, b.curb));
for (let index = 0; index < candidates.length; index += 1) {
const first = candidates[index];
const second = candidates[(index + 1) % candidates.length];
if (first.wayKey === second.wayKey) continue;
const continuation = isStraightSidewalkContinuation(first, second);
if (first.sourceWayKey === second.sourceWayKey && !continuation) continue;
// A split-through road has two approaches at this node. Its pedestrian
// strip is a direct continuation, not a curb corner. Treating it as a
// curve creates the oversized outer lobe seen at T junctions.
const ring = continuation
? [first.curb, first.outer, second.outer, second.curb, first.curb]
: roundedSidewalkCorner(plan.node, first, second);
if (hasSelfIntersection(ring)) continue;
if (continuation && cornerFallsIntoOtherApproach(ring, first.sourceWayKey, plan.approaches)) continue;
result.push({
type: "Feature",
properties: {
native_id: `sidewalk-corner:node/${nodeId}:${first.wayKey}:${first.side}->${second.wayKey}:${second.side}`,
osm_node_id: nodeId,
kind: continuation ? "continuation" : "corner",
width_m: DEFAULT_SIDEWALK_WIDTH_METERS,
provenance: continuation ? "native-road-sidewalk-continuation/v1" : "native-road-sidewalk-corner/v1",
},
geometry: { type: "Polygon", coordinates: [ring] },
});
}
}
return result;
}
function roundedSidewalkCorner(node, first, second) {
// Keep the established vehicle curb geometry, then derive the outer edge
// from it. Independent Bezier curves drift apart and leave asphalt exposed
// between the junction and pedestrian layers.
const curbForward = roundedCorner(node, first.curb, second.curb, first.outwardHeading, second.outwardHeading) || [first.curb, second.curb];
// Construct the outside edge from the same tangent-support rule. A linear
// point-by-point offset changes the curvature and makes the two boundaries
// visibly disagree at the middle of the corner.
const outerForward = roundedCorner(node, first.outer, second.outer, first.outwardHeading, second.outwardHeading)
|| offsetCornerArc(curbForward, first.curb, first.outer, second.curb, second.outer);
const curbArc = [...curbForward].reverse();
return [
first.curb,
first.outer,
...outerForward.slice(1, -1),
second.outer,
second.curb,
...curbArc.slice(1, -1),
first.curb,
];
}
function offsetCornerArc(curbArc, firstCurb, firstOuter, secondCurb, secondOuter) {
return curbArc.map((point, index) => {
const ratio = curbArc.length === 1 ? 0 : index / (curbArc.length - 1);
const firstOffset = [firstOuter[0] - firstCurb[0], firstOuter[1] - firstCurb[1]];
const secondOffset = [secondOuter[0] - secondCurb[0], secondOuter[1] - secondCurb[1]];
return [point[0] + firstOffset[0] + (secondOffset[0] - firstOffset[0]) * ratio, point[1] + firstOffset[1] + (secondOffset[1] - firstOffset[1]) * ratio];
});
}
function samePhysicalSide(first, second) {
const radians = (first.normalDegrees - second.normalDegrees) * Math.PI / 180;
return Math.cos(radians) >= 0.98;
}
function isStraightSidewalkContinuation(first, second) {
if (first.sourceWayKey !== second.sourceWayKey || !samePhysicalSide(first, second)) return false;
const radians = (first.outwardHeading - second.outwardHeading) * Math.PI / 180;
return Math.cos(radians) <= -0.98;
}
function cornerFallsIntoOtherApproach(ring, sourceWayKey, approaches) {
const vertices = ring.slice(0, -1);
const center = vertices.reduce((sum, point) => [sum[0] + point[0] / vertices.length, sum[1] + point[1] / vertices.length], [0, 0]);
return approaches.filter((approach) => approach.sourceWayKey !== sourceWayKey).some((approach) => {
const carriageway = roadRing(approach.line, approach.widthMeters);
return carriageway && pointInPolygon(center, carriageway);
});
}
function validateConnectorContainment(connectors, junctionFeatures, diagnostics) {
@@ -215,7 +594,7 @@ function validateConnectorContainment(connectors, junctionFeatures, diagnostics)
const junction = junctionByNode.get(connector.properties.node_id);
if (!junction) continue;
const ring = junction.geometry.coordinates[0];
if (!connector.geometry.coordinates.every((point) => pointInPolygon(point, ring))) {
if (!connector.geometry.coordinates.every((point) => pointInOrNearPolygon(point, ring, CONNECTOR_BOUNDARY_TOLERANCE_METERS))) {
diagnostics.push(diagnostic("warning", connector.properties.connection_id, [connector.properties.node_id], "connector-outside-junction", "转向路径有部分落在路口面外,请检查道路截面或转向连接。", connector.geometry.coordinates[0]));
}
}
@@ -231,19 +610,30 @@ function pointInPolygon(point, ring) {
}
return inside;
}
function pointInOrNearPolygon(point, ring, toleranceMeters) {
return pointInPolygon(point, ring) || ring.slice(1).some((end, index) => distancePointToSegmentMeters(point, ring[index], end) <= toleranceMeters);
}
function distancePointToSegmentMeters(point, start, end) {
const localPoint = project(point, start);
const localEnd = project(end, start);
const lengthSquared = localEnd[0] ** 2 + localEnd[1] ** 2;
if (lengthSquared < .0001) return Math.hypot(...localPoint);
const ratio = Math.max(0, Math.min(1, (localPoint[0] * localEnd[0] + localPoint[1] * localEnd[1]) / lengthSquared));
return Math.hypot(localPoint[0] - localEnd[0] * ratio, localPoint[1] - localEnd[1] * ratio);
}
function pointOnSegment(point, a, b) {
const cross = (point[0] - a[0]) * (b[1] - a[1]) - (point[1] - a[1]) * (b[0] - a[0]);
if (Math.abs(cross) > 1e-12) return false;
return point[0] >= Math.min(a[0], b[0]) - 1e-12 && point[0] <= Math.max(a[0], b[0]) + 1e-12 && point[1] >= Math.min(a[1], b[1]) - 1e-12 && point[1] <= Math.max(a[1], b[1]) + 1e-12;
}
function compileLaneCenterlines(model, diagnostics) {
function compileLaneCenterlines(model, diagnostics, junctionPlans) {
const features = [];
const byRoadId = new Map();
for (const road of model.roads) {
const lanes = [];
const laneWidth = road.widthMeters / road.laneCount;
const siblings = model.roads.filter((item) => item.osmWayIds.join(",") === road.osmWayIds.join(","));
const siblings = model.roads.filter((item) => item.segmentId === road.segmentId);
const opposite = siblings.find((item) => item.id !== road.id);
// OSM centerline is the shared carriageway center. On a two-way road,
// offset each directed carriageway to its own side before placing lanes.
@@ -252,7 +642,7 @@ function compileLaneCenterlines(model, diagnostics) {
// OSM `turn:lanes` is ordered from left to right. Keep lane 1 on the
// driver's left so tag positions and generated lane IDs have one meaning.
const offset = carriagewayOffset + (road.widthMeters / 2 - laneWidth * (index + 0.5));
const coordinates = offsetLine(road.centerline, offset);
const coordinates = offsetLine(trimLineAtJunctions(road.centerline, road.sourceNodeIds, junctionPlans), offset);
if (!coordinates) { diagnostics.push(diagnostic("error", road.id, road.osmWayIds, "invalid-lane-centerline", "无法为该道路生成车道中心线。", road.centerline[0])); continue; }
const lane = { id: `lane:${road.id}:${index + 1}`, roadId: road.id, index: index + 1, coordinates };
lanes.push(lane);
@@ -263,7 +653,7 @@ function compileLaneCenterlines(model, diagnostics) {
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)) {
@@ -279,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;
@@ -322,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) {
@@ -352,30 +781,32 @@ function offsetLine(line, offsetMeters) {
}
function lineLengthMeters(line) { return line.slice(1).reduce((sum, point, index) => { const previous = line[index]; const dx = (point[0] - previous[0]) * 111320 * Math.cos(point[1] * Math.PI / 180); const dy = (point[1] - previous[1]) * 111320; return sum + Math.hypot(dx, dy); }, 0); }
function compileJunctionSurfaces(model, lanes, connectors, movements, diagnostics) {
const byNode = new Map();
for (const endpoint of model.endpoints) {
if (!byNode.has(endpoint.nodeId)) byNode.set(endpoint.nodeId, []);
byNode.get(endpoint.nodeId).push(endpoint);
function polygonAreaMeters(ring) {
if (ring.length < 3) return 0;
const origin = ring[0];
const points = ring.map((point) => project(point, origin));
let twiceArea = 0;
for (let index = 0; index < points.length; index += 1) {
const next = points[(index + 1) % points.length];
twiceArea += points[index][0] * next[1] - next[0] * points[index][1];
}
return Math.abs(twiceArea) / 2;
}
function compileJunctionSurfaces(model, junctionPlans, connectors, movements, diagnostics) {
const result = [];
for (const [nodeId, endpoints] of byNode) {
const wayIds = new Set(endpoints.map((endpoint) => endpoint.roadId.split(":")[1]));
if (wayIds.size < 3 || wayIds.size > 4) continue;
const node = endpoints[0].coordinate;
const approaches = junctionApproaches(model, endpoints);
const cutbackMeters = Math.max(...approaches.map((approach) => approach.widthMeters)) * 1.4;
const boundary = junctionBoundary(approaches, node, cutbackMeters);
for (const [nodeId, plan] of junctionPlans) {
const { segmentIds, node, approaches, cutbackMeters, boundary } = plan;
const junctionConnectors = connectors.filter((feature) => feature.properties.node_id === nodeId);
const junctionMovements = movements.filter((movement) => movement.nodeId === nodeId);
if (boundary.length < 3 || !junctionMovements.length) {
diagnostics.push(diagnostic("warning", `junction:node/${nodeId}`, [nodeId], "junction-surface-deferred", "路口缺少足够的截面或转向路径,暂不生成路口面。", node));
continue;
}
const approachAreaMeters = polygonAreaMeters(boundary);
let ring = [...boundary, boundary[0]];
let boundaryMode = "approach-envelope";
if (hasSelfIntersection(ring) || !junctionConnectors.every((feature) => feature.geometry.coordinates.every((point) => pointInPolygon(point, ring)))) {
let boundaryMode = plan.boundaryMode || "approach-envelope";
if (hasSelfIntersection(ring) || !junctionConnectors.every((feature) => feature.geometry.coordinates.every((point) => pointInOrNearPolygon(point, ring, CONNECTOR_BOUNDARY_TOLERANCE_METERS)))) {
const envelope = convexHull([...boundary, ...junctionConnectors.flatMap((feature) => feature.geometry.coordinates)]);
ring = [...envelope, envelope[0]];
boundaryMode = "connector-convex-fallback";
@@ -384,24 +815,51 @@ function compileJunctionSurfaces(model, lanes, connectors, movements, diagnostic
diagnostics.push(diagnostic("error", `junction:node/${nodeId}`, [nodeId], "invalid-junction-surface", "路口截面边界发生自相交,未发布路口面。请检查道路方向或路口拓扑。", node));
continue;
}
result.push({ type: "Feature", properties: { native_id: `junction:node/${nodeId}`, osm_node_id: nodeId, kind: wayIds.size === 3 ? "t" : "cross", source_road_ids: approaches.flatMap((approach) => approach.roadIds).join(","), cutback_m: cutbackMeters, movement_count: junctionMovements.length, connector_count: junctionConnectors.length, boundary_mode: boundaryMode, rule: "junction-approach-envelope/v2" }, geometry: { type: "Polygon", coordinates: [ring] } });
const surfaceAreaMeters = polygonAreaMeters(ring);
const expansionRatio = approachAreaMeters > 0 ? surfaceAreaMeters / approachAreaMeters : null;
result.push({ type: "Feature", properties: { native_id: `junction:node/${nodeId}`, osm_node_id: nodeId, kind: segmentIds.size === 3 ? "t" : "cross", source_road_ids: approaches.flatMap((approach) => approach.roadIds).join(","), cutback_m: cutbackMeters, movement_count: junctionMovements.length, connector_count: junctionConnectors.length, boundary_mode: boundaryMode, approach_area_m2: Math.round(approachAreaMeters * 10) / 10, surface_area_m2: Math.round(surfaceAreaMeters * 10) / 10, expansion_ratio: expansionRatio === null ? null : Math.round(expansionRatio * 100) / 100, rule: "junction-shared-cutback/v4-shared-node-split" }, geometry: { type: "Polygon", coordinates: [ring] } });
if (boundaryMode === "connector-convex-fallback") diagnostics.push(diagnostic("warning", `junction:node/${nodeId}`, [nodeId], "junction-connector-envelope-fallback", "路口面需要按转向路径的凸包兜底生成;请检查外缘和路缘与步行带是否符合实际。", node));
if (plan.boundaryFallbacks) diagnostics.push(diagnostic("warning", `junction:node/${nodeId}`, [nodeId], "junction-rounded-corner-fallback", "部分路口圆角无法按道路边缘切线安全构造,已对该角使用确定性的直线回退。", node));
diagnostics.push(diagnostic("info", `junction:node/${nodeId}`, [nodeId], "ordinary-junction-surface", "已按道路截面与转向路径生成普通路口面。", node));
}
return result;
}
function compileJunctionPlans(model) {
const byNode = new Map();
for (const endpoint of model.endpoints) {
if (!byNode.has(endpoint.nodeId)) byNode.set(endpoint.nodeId, []);
byNode.get(endpoint.nodeId).push(endpoint);
}
const plans = new Map();
for (const [nodeId, endpoints] of byNode) {
const segmentIds = new Set(endpoints.map((endpoint) => endpoint.roadId.replace(/:(forward|backward)$/, "")));
if (segmentIds.size < 3 || segmentIds.size > 4) continue;
const approaches = junctionApproaches(model, endpoints);
if (approaches.length !== segmentIds.size) continue;
// Rounded curb corners need enough approach length to retain the full
// turning envelope after the corner is cut toward the junction.
const cutbackMeters = Math.max(...approaches.map((approach) => approach.widthMeters)) * 1.4;
const node = endpoints[0].coordinate;
const boundary = junctionBoundary(approaches, node, cutbackMeters);
if (boundary.points.length < 3) continue;
plans.set(nodeId, { node, segmentIds, approaches, cutbackMeters, boundary: boundary.points, boundaryMode: boundary.mode, boundaryFallbacks: boundary.fallbacks });
}
return plans;
}
function junctionApproaches(model, endpoints) {
const groups = new Map();
for (const endpoint of endpoints) {
const road = model.roads.find((item) => item.id === endpoint.roadId);
if (!road) continue;
const key = road.osmWayIds.join(",");
const key = road.segmentId;
if (!groups.has(key)) groups.set(key, []);
groups.get(key).push({ endpoint, road });
}
return [...groups.values()].map((directions) => {
const { endpoint, road } = directions[0];
return { line: endpoint.side === "end" ? [...road.centerline].reverse() : road.centerline, roadIds: directions.map((item) => item.road.id), widthMeters: directions.reduce((sum, item) => sum + item.road.widthMeters, 0) };
return { segmentId: road.segmentId, sourceWayKey: road.osmWayIds.join(","), line: endpoint.side === "end" ? [...road.centerline].reverse() : road.centerline, roadIds: directions.map((item) => item.road.id), widthMeters: directions.reduce((sum, item) => sum + item.road.widthMeters, 0) };
});
}
@@ -412,10 +870,66 @@ function junctionBoundary(approaches, node, cutbackMeters) {
if (!cutback) continue;
const heading = headingAtEndpoint(approach.line);
const half = approach.widthMeters / 2;
points.push(offsetCoordinate(cutback, heading + 90, half));
points.push(offsetCoordinate(cutback, heading - 90, half));
points.push({ point: offsetCoordinate(cutback, heading + 90, half), segmentId: approach.segmentId, sourceWayKey: approach.sourceWayKey, outwardHeading: heading });
points.push({ point: offsetCoordinate(cutback, heading - 90, half), segmentId: approach.segmentId, sourceWayKey: approach.sourceWayKey, outwardHeading: heading });
}
return sortAround(node, points);
const ordered = points.sort((a, b) => angleAround(node, a.point) - angleAround(node, b.point));
if (ordered.length < 3) return { points: [], mode: "approach-envelope" };
const boundary = [];
let rounded = 0;
let fallbacks = 0;
for (let index = 0; index < ordered.length; index += 1) {
const first = ordered[index]; const second = ordered[(index + 1) % ordered.length];
boundary.push(first.point);
// One physical OSM way is often split at an intersection node. Its two
// opposite approaches share a continuous road edge; rounding that edge
// bends the far side of a T junction and exposes junction asphalt beyond
// the pedestrian strip.
if (first.segmentId === second.segmentId || isStraightJunctionEdge(first, second)) continue;
const curve = roundedCorner(node, first.point, second.point, first.outwardHeading, second.outwardHeading);
if (!curve) { fallbacks += 1; continue; }
boundary.push(...curve.slice(1, -1));
rounded += 1;
}
return { points: boundary, mode: rounded ? "rounded-approach-envelope" : "approach-envelope", fallbacks };
}
function isStraightJunctionEdge(first, second) {
if (first.sourceWayKey !== second.sourceWayKey) return false;
const radians = (first.outwardHeading - second.outwardHeading) * Math.PI / 180;
return Math.cos(radians) <= -0.98;
}
function roundedCorner(node, first, second, firstHeading, secondHeading) {
const origin = node;
const a = project(first, origin); const b = project(second, origin);
const chord = Math.hypot(a[0] - b[0], a[1] - b[1]);
if (chord < .5 || !Number.isFinite(firstHeading) || !Number.isFinite(secondHeading)) return null;
const firstDirection = headingVector(firstHeading);
const secondDirection = headingVector(secondHeading);
const intersection = lineIntersection(a, firstDirection, b, secondDirection);
if (!intersection) return null;
const controlDistance = Math.hypot(...intersection);
const endpointDistance = Math.max(Math.hypot(...a), Math.hypot(...b));
// Adjacent approach edge tangents should meet in the corner between the
// node and the cutback. Reject near-parallel or remote intersections rather
// than publishing a huge/self-crossing curve.
if (controlDistance < .01 || controlDistance > endpointDistance * 1.5 || controlDistance > 80) return null;
const control = unproject(intersection, origin);
return quadraticCurve(first, control, second, JUNCTION_CURVE_SEGMENTS);
}
function headingVector(degrees) {
const radians = degrees * Math.PI / 180;
return [Math.sin(radians), Math.cos(radians)];
}
function lineIntersection(firstPoint, firstDirection, secondPoint, secondDirection) {
const cross = firstDirection[0] * secondDirection[1] - firstDirection[1] * secondDirection[0];
if (Math.abs(cross) < 1e-4) return null;
const delta = [secondPoint[0] - firstPoint[0], secondPoint[1] - firstPoint[1]];
const firstDistance = (delta[0] * secondDirection[1] - delta[1] * secondDirection[0]) / cross;
return [firstPoint[0] + firstDirection[0] * firstDistance, firstPoint[1] + firstDirection[1] * firstDistance];
}
function pointAlongLine(line, meters) {
@@ -428,9 +942,46 @@ function pointAlongLine(line, meters) {
return line.at(-1);
}
function pointAndAxisAlongLine(line, meters) {
let remaining = meters;
for (let index = 1; index < line.length; index += 1) {
const start = line[index - 1]; const end = line[index];
const length = distanceMeters(start, end);
if (length < 0.01) continue;
if (length >= remaining) {
const vector = project(end, start);
return { point: interpolate(start, end, remaining / length), axis: [vector[0] / length, vector[1] / length] };
}
remaining -= length;
}
return null;
}
function trimLineAtJunctions(line, sourceNodeIds, junctionPlans) {
const startCutback = junctionPlans.get(sourceNodeIds[0])?.cutbackMeters || 0;
const endCutback = junctionPlans.get(sourceNodeIds.at(-1))?.cutbackMeters || 0;
if (!startCutback && !endCutback) return line;
const total = lineLengthMeters(line);
// Short OSM fragments cannot safely lose both ends. Keep their source
// geometry intact and let the junction diagnostic surface the ambiguity.
if (startCutback + endCutback >= total - 0.5) return line;
const result = [];
let traversed = 0;
const start = pointAlongLine(line, startCutback);
const end = pointAlongLine(line, total - endCutback);
result.push(start);
for (let index = 1; index < line.length - 1; index += 1) {
traversed += distanceMeters(line[index - 1], line[index]);
if (traversed > startCutback && traversed < total - endCutback) result.push(line[index]);
}
result.push(end);
return result;
}
function headingAtEndpoint(line) { return headingDegrees(line[0], line[1]); }
function headingDegrees(a, b) { return Math.atan2((b[0] - a[0]) * Math.cos(a[1] * Math.PI / 180), b[1] - a[1]) * 180 / Math.PI; }
function offsetCoordinate(point, degrees, meters) { const radians = degrees * Math.PI / 180; return [point[0] + Math.sin(radians) * meters / (111320 * Math.cos(point[1] * Math.PI / 180)), point[1] + Math.cos(radians) * meters / 111320]; }
function angleAround(center, point) { return Math.atan2(point[1] - center[1], point[0] - center[0]); }
function sortAround(center, points) { return points.sort((a, b) => Math.atan2(a[1] - center[1], a[0] - center[0]) - Math.atan2(b[1] - center[1], b[0] - center[0])); }
function convexHull(points) {
const unique = [...new Map(points.map((point) => [`${point[0]},${point[1]}`, point])).values()].sort((a, b) => a[0] - b[0] || a[1] - b[1]);
@@ -483,6 +1034,14 @@ function roadRing(line, width) {
return ring.every((point) => point.every(Number.isFinite)) ? ring : null;
}
function sidewalkRing(line, innerOffset, outerOffset, side) {
const inner = offsetLine(line, innerOffset * side);
const outer = offsetLine(line, outerOffset * side);
if (!inner || !outer) return null;
const ring = [...inner, ...outer.reverse(), inner[0]];
return ring.every((point) => point.every(Number.isFinite)) ? ring : null;
}
function project(point, origin) { const scale = 111320; return [(point[0] - origin[0]) * scale * Math.cos(origin[1] * Math.PI / 180), (point[1] - origin[1]) * scale]; }
function unproject(point, origin) { const scale = 111320; return [point[0] / (scale * Math.cos(origin[1] * Math.PI / 180)) + origin[0], point[1] / scale + origin[1]]; }
function diagnostic(severity, subjectId, sourceIds, rule, message, coordinate) { return { id: `diagnostic:${rule}:${subjectId}`, severity, subjectId, sourceIds, rule, message, geometry: coordinate ? { type: "Point", coordinates: coordinate } : null }; }

Some files were not shown because too many files have changed in this diff Show More