Compare commits
42 Commits
1da985c932
...
fengshu-er
| Author | SHA1 | Date | |
|---|---|---|---|
| 9a8dbc1a74 | |||
| 5403936ae4 | |||
| 0bc949bc24 | |||
| 6a0437ff83 | |||
| 3cad384b25 | |||
| 9e0e25aadf | |||
| e9ab1f03eb | |||
| 1154de3ba1 | |||
| c057258085 | |||
| 41d32457f2 | |||
| 5accc0a4b1 | |||
| 7204c28161 | |||
| a63a3ae0d8 | |||
| f7e71cfe9c | |||
| e41bfd13ec | |||
| 46542c5f4e | |||
| 1896884900 | |||
| 9bb97d4507 | |||
| bd38aa55d3 | |||
| b318f7b641 | |||
| d065dcdaf3 | |||
| 472a83c670 | |||
| bc045177a5 | |||
| 8409d2c6c3 | |||
| 5f59ad0ef0 | |||
| 47efb78f1e | |||
| 8bfc02e66e | |||
| 9d42181246 | |||
| fb863dafb2 | |||
| 7dc5c947a3 | |||
| 866b59acf8 | |||
| 4c4f4534c0 | |||
| ea8a3622b9 | |||
| 03a8d6159b | |||
| 1b9829d9ed | |||
| 822e6ef936 | |||
| 3eea12c6ea | |||
| 3d57655497 | |||
| 65cf8b96d9 | |||
| e1f3fc10ca | |||
| f43a122ff7 | |||
| 3e22a1872e |
@@ -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`**——它们能一次性影响全部派生路径。逐个覆盖容易漏。
|
||||
|
||||
@@ -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` 原样服务这些
|
||||
GeoJSON,native 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` 返回该 FeatureCollection;native
|
||||
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` |(显式 legacy)OSM → osm2streets GeoJSON → GeoPackage → QGIS 工程 + 预览图 | `.osm` | `osm2streets_web_out/`、`.gpkg`、`.qgz`、`-preview.png` |
|
||||
| `reimport` | GeoPackage → GeoJSON(**反向**) | `.gpkg` | `osm2streets_web_out/` |
|
||||
| `blender` | OSM + GeoJSON → 场景 | `.osm`、`osm2streets_web_out/` | `.blend`、`.png` |
|
||||
| `blender` | 默认 OSM → native road compiler → Blender 场景;legacy provider 时读取 GeoJSON | `.osm`、`native-road/`(legacy 为 `osm2streets_web_out/`) | `.blend`、`.png` |
|
||||
| `cesium` | 场景 → static staging GLB + manifest | `.blend` | `_pipeline/package-staging/models/*.glb`、staged manifest;动态 GLB 到 `_preview/` |
|
||||
| `compress` | 压缩 Cesium staging 主模型 | staged `.glb`、manifest | 压缩 staged `.glb`、manifest |
|
||||
| `package` | 校验并原子发布静态资产包 | staging manifest 与 models | `package/manifest.json`、`package/models/*.glb` |
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
### 两种调用姿势
|
||||
|
||||
| 阶段 | 参数 | 出处 |
|
||||
|
||||
@@ -30,7 +30,7 @@ config/areas/<id>.json
|
||||
▼ build-area.js — 阶段调度
|
||||
_pipeline/osm2streets-qgis.config.json (派生配置)
|
||||
│
|
||||
├─[intermediates]─▶ build-osm2streets-qgis.js
|
||||
├─[intermediates]─▶ build-osm2streets-qgis.js(显式 legacy/reference)
|
||||
│ osm2streets-js-node 解析 .osm
|
||||
│ → splitLayers() 拆成九个图层
|
||||
│ → normalize-lane-arrows.py(QGIS Python)
|
||||
@@ -45,8 +45,8 @@ config/areas/<id>.json
|
||||
│ → 重建 scene.geojson + scene_style.json
|
||||
│ → _pipeline/stages/reimport.manifest.json
|
||||
│
|
||||
├─[blender]───────▶ Blender + blender/generate_scene.py
|
||||
│ 读 .osm + osm2streets_web_out/
|
||||
├─[blender]───────▶ compile-native-roads.js + Blender + blender/generate_scene.py
|
||||
│ 默认读 .osm + native-road/
|
||||
│ → <id>.blend + <id>.png
|
||||
│ → _pipeline/stages/blender.manifest.json
|
||||
│
|
||||
@@ -62,7 +62,7 @@ config/areas/<id>.json
|
||||
│ → package/manifest.json + package/models/*.glb
|
||||
│ → _pipeline/stages/package.manifest.json
|
||||
│
|
||||
└─[preview]───────▶ 生成 <id>-cesium-preview.html
|
||||
└─[preview]───────▶ 生成 <id>-cesium-preview.html(native 可无车辆路线)
|
||||
│ + 拷贝 lib/cesium-preview.{js,css}
|
||||
+ _preview/ 车辆巡航路线、模型、动态信号
|
||||
│ → _pipeline/stages/preview.manifest.json
|
||||
|
||||
@@ -31,7 +31,7 @@ classifyConnection(incomingEdge, outgoingEdge) =>
|
||||
`maneuvers`、`edgeIds`、`laneSegments` 与 `connectors`。道路区间来自匹配的 Driving lane polygon 中轴。
|
||||
- 路线拓扑以 `network.json` 的 internal road 和 intersection 为准;禁止把整个 OSM way 直接当作一条不可分割 edge。
|
||||
- connector 必须绑定同一个 internal intersection,并位于对应 `intersection_surface.geojson` 内或允许的边界容差内;越界时拒绝候选路线。
|
||||
- preview 必须将 `lane_polygons.geojson`、`network.json` 和 `intersection_surface.geojson` 作为强制输入;缺失或无效时在写产物前失败。
|
||||
- legacy osm2streets preview 必须将 `lane_polygons.geojson`、`network.json` 和 `intersection_surface.geojson` 作为强制输入;native preview 不读取这些文件,路线缺失时保留可用预览并省略车辆巡航。
|
||||
- route 经纬度由 Cesium 按 WGS84 直接放置;最终道路 GLB 必须由 WGS84 ECEF→ENU
|
||||
`Projector` 生成。禁止以固定米/度近似投影道路,否则即使 route 与 lane polygon
|
||||
完全一致,最终画面仍会随离锚点距离产生横向偏移。
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
{"_example": "Fill with {\"file\": \"<path>\", \"reason\": \"<why>\"}. Put spec/research files only — no code paths. Run `python3 .trellis/scripts/get_context.py --mode packages` to list available specs. Delete this line once real entries are added."}
|
||||
@@ -0,0 +1,70 @@
|
||||
# Design
|
||||
|
||||
## Authority And Boundaries
|
||||
|
||||
The native compiler remains the only road authority. The existing traffic-signal runtime remains the
|
||||
only signal authority. The new feature adds a route/simulation layer beside those artifacts; it does
|
||||
not alter signal geometry, phase grouping, or Blender static assets.
|
||||
|
||||
```text
|
||||
native-road/compiled.json + native-road/layers/*
|
||||
-> native route compiler
|
||||
-> _preview/<area>-traffic-simulation.json
|
||||
-> Cesium preview runtime
|
||||
|
||||
package/runtime/traffic-signals.json
|
||||
-> signal phase clock + stop events
|
||||
```
|
||||
|
||||
The descriptor is intentionally package-adjacent, not embedded in the GLB. A consumer can copy the
|
||||
package and descriptor to another platform and reproduce the simulation without running Node.
|
||||
|
||||
## Descriptor Contract
|
||||
|
||||
Schema: `native-preview-traffic-simulation/v1`.
|
||||
|
||||
Required top-level fields:
|
||||
|
||||
- `areaId`, `coordinateSystem` (`WGS84` route coordinates, `ENU` model placement), `generatedAt`
|
||||
- `source` with relative artifact references and SHA-256 records
|
||||
- `settings` with speed, acceleration, deceleration, reaction time, vehicle length, and minimum gap
|
||||
- `routes[]` with ordered WGS84 coordinates, cumulative distances, connector/maneuver IDs, and
|
||||
`stops[]`
|
||||
- `signals[]` copied by stable `signal_uid` from the runtime contract, including phase group and
|
||||
stop-line association; no regenerated pose fields
|
||||
- `migration` describing schema version, coordinate conversion, update-loop expectations, and
|
||||
compatibility notes
|
||||
|
||||
The descriptor is deterministic for the same OSM, overrides, native compiler version, and settings.
|
||||
|
||||
## Route Construction
|
||||
|
||||
Use native directional roads and published connector geometry. Build a small number of deterministic
|
||||
closed demonstration routes that cross real connectors and retain lane/road IDs. Route generation
|
||||
must reject routes with missing geometry or disabled connectors and write structured diagnostics.
|
||||
Native stop lines are matched by `road_id`/`lane_id` where available, with a bounded geometric
|
||||
fallback recorded in the stop record. No fixed-width or legacy polygon fallback is allowed.
|
||||
|
||||
## Browser Simulation
|
||||
|
||||
Keep the existing signal phase functions and `signalData` payload. Replace the current per-vehicle
|
||||
independent distance advance with a shared simulation state:
|
||||
|
||||
- each vehicle has route distance, speed, desired speed, status, and active stop reason;
|
||||
- signal stop constraints are evaluated before the native stop line for red/yellow phases;
|
||||
- leader constraints are evaluated on the same route and wrap around the loop;
|
||||
- acceleration/deceleration clamps advance toward the minimum of desired speed, signal limit, and
|
||||
leader-safe speed;
|
||||
- vehicle position and orientation remain Cesium callback properties, with no per-frame allocation
|
||||
of heavyweight Cesium objects;
|
||||
- incident-card behavior remains local to each vehicle and must not mutate the descriptor or package.
|
||||
|
||||
Diagnostics expose route count, signal count, stopped vehicles, and queue length in the existing
|
||||
preview diagnostics panel.
|
||||
|
||||
## Migration And Rollback
|
||||
|
||||
Document the descriptor, runtime signal schema, coordinate conversion, and a reference update loop in
|
||||
`docs/native-preview-traffic-simulation.md`. Include a JSON example and a consumer checklist. Legacy
|
||||
preview route files remain readable only through the existing legacy provider path; native generation
|
||||
must not depend on them. Removing the new descriptor restores the existing no-cruise native preview.
|
||||
@@ -0,0 +1 @@
|
||||
{"_example": "Fill with {\"file\": \"<path>\", \"reason\": \"<why>\"}. Put spec/research files only — no code paths. Run `python3 .trellis/scripts/get_context.py --mode packages` to list available specs. Delete this line once real entries are added."}
|
||||
@@ -0,0 +1,37 @@
|
||||
# Implementation Plan
|
||||
|
||||
1. Add a native route compiler module that consumes `native-road/compiled.json` and native layers,
|
||||
emits `native-preview-traffic-simulation/v1`, and records source hashes and migration metadata.
|
||||
2. Wire native preview generation to write the descriptor and keep legacy osm2streets route
|
||||
generation unchanged.
|
||||
3. Extend the browser preview with shared deterministic vehicle state, signal stop constraints,
|
||||
leader following, queue diagnostics, and graceful empty-route behavior.
|
||||
4. Preserve the existing signal phase, `signal_uid`, pose, dynamic lens, countdown, disabled-signal,
|
||||
and incident-card contracts; add focused regression tests for each boundary.
|
||||
5. Add `docs/native-preview-traffic-simulation.md` with schema, example, coordinate rules, update
|
||||
loop, migration checklist, and rollback notes.
|
||||
6. Run focused Node tests, native compile/check, a native build with no legacy directory, and browser
|
||||
preview validation in the available Blender/Cesium environment.
|
||||
|
||||
## Validation Commands
|
||||
|
||||
```bash
|
||||
node --check scripts/lib/native-preview-traffic-simulation.js
|
||||
node --check scripts/build-area.js
|
||||
npm run test:native-road
|
||||
npm run test:traffic-signals
|
||||
npm run test:preview-assets
|
||||
npm run test:native-preview-traffic
|
||||
npm run road:compile -- --config config/areas/nantaizi-lake-innovation-valley.json
|
||||
npm run road:check -- --config config/areas/nantaizi-lake-innovation-valley.json
|
||||
npm run build:area -- --config config/areas/nantaizi-lake-innovation-valley.json --stages blender,cesium,compress,package,preview
|
||||
```
|
||||
|
||||
## Risk Controls
|
||||
|
||||
- Do not change `scripts/lib/traffic-signals.js` geometry or phase semantics unless a test proves a
|
||||
migration bug; prefer adapter code.
|
||||
- Do not add route data to the static package manifest; it belongs beside `_preview/` and is optional.
|
||||
- If native route generation produces no valid route, emit a valid descriptor with diagnostics and
|
||||
keep preview usable.
|
||||
- Keep all route and simulation math unit-testable without Cesium.
|
||||
@@ -0,0 +1,38 @@
|
||||
# Native preview traffic simulation and migration contract
|
||||
|
||||
## Goal
|
||||
|
||||
Extend the native road compiler preview with a deterministic, portable vehicle traffic demonstration that consumes native road topology and the already-validated traffic-signal runtime. The result must be useful as a visual validation tool and as a migration reference for Cesium, Blender, or another platform consuming the generated area package.
|
||||
|
||||
## Confirmed Baseline
|
||||
|
||||
- Native road compilation is the production authority; QGIS/osm2streets is not a runtime input.
|
||||
- The previous traffic-signal work is the source of truth and must remain compatible: deterministic `signal_uid`, `phase_group`, `mast_heading_deg`, `face_heading_deg`, stop-line anchors, shared `pose.*`, dynamic lens/countdown assets, and disabled-signal filtering.
|
||||
- Existing browser preview already has signal phase timing, signal visualization, route stop matching, vehicle incident cards, and a missing-route fallback. Native preview currently omits the route.
|
||||
- Native road output contains compiled roads/connectors and `vehicle_stop_lines.geojson`; the native package contains `runtime/traffic-signals.json`.
|
||||
- The feature is preview-level deterministic behavior, not a legal navigation or microscopic traffic simulator.
|
||||
|
||||
## Requirements
|
||||
|
||||
- R1: Generate a native route artifact from `native-road/compiled.json` and native layer geometry; it must not read `osm2streets_web_out`, QGIS files, or legacy route files.
|
||||
- R2: Preserve and consume the existing traffic-signal contract without deriving a second signal layout in the browser.
|
||||
- R3: Vehicles must stop before a red/yellow signal at the native stop line, resume on green, and expose the active stop reason in preview diagnostics.
|
||||
- R4: Multiple vehicles on the same route must maintain a configurable minimum gap; a stopped front vehicle must cause following vehicles to decelerate and queue rather than overlap it.
|
||||
- R5: Route, signal, vehicle, stop-event, and following-distance data must be written as versioned, package-adjacent JSON with stable ENU/WGS84 and migration metadata. Another platform must be able to consume the artifact without executing the Node compiler.
|
||||
- R6: The browser preview must degrade gracefully when there are no valid routes or signals; the primary scene remains usable and diagnostics explain the missing optional capability.
|
||||
- R7: Documentation must describe the data flow, schemas, coordinate conventions, signal identity, stop-line association, update loop, and migration/rollback guidance.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] Native build emits a versioned route/traffic-simulation descriptor under the area output and preview loads it without any legacy directory present.
|
||||
- [ ] At least one route crosses a native connector and contains a native stop-line association.
|
||||
- [ ] A red/yellow phase visibly stops a vehicle before the stop line; green releases it.
|
||||
- [ ] Two or more vehicles never overlap and maintain the configured minimum gap while cruising and while queued at a signal.
|
||||
- [ ] Disabled native signals are absent from runtime control and do not create vehicle stops.
|
||||
- [ ] Preview diagnostics report route count, signal count, stopped vehicles, queue length, and the active simulation descriptor version.
|
||||
- [ ] Migration documentation and focused tests allow another consumer to reproduce route, signal, stopping, and gap behavior from generated assets alone.
|
||||
- [ ] Existing traffic-signal tests and native road tests remain green; legacy provider behavior is unchanged.
|
||||
|
||||
## Decided Scope
|
||||
|
||||
- Simulation fidelity: MVP is a deterministic preview simulation with configurable speed, acceleration/deceleration, reaction time, and minimum gap. A full lane-changing, collision, priority, and multi-intersection traffic engine is deferred.
|
||||
@@ -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",
|
||||
@@ -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": {}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{"_example": "Fill with {\"file\": \"<path>\", \"reason\": \"<why>\"}. Put spec/research files only — no code paths. Run `python3 .trellis/scripts/get_context.py --mode packages` to list available specs. Delete this line once real entries are added."}
|
||||
@@ -0,0 +1,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.
|
||||
@@ -0,0 +1 @@
|
||||
{"_example": "Fill with {\"file\": \"<path>\", \"reason\": \"<why>\"}. Put spec/research files only — no code paths. Run `python3 .trellis/scripts/get_context.py --mode packages` to list available specs. Delete this line once real entries are added."}
|
||||
@@ -0,0 +1,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.
|
||||
@@ -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.
|
||||
@@ -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": {}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{"_example": "Fill with {\"file\": \"<path>\", \"reason\": \"<why>\"}. Put spec/research files only — no code paths. Run `python3 .trellis/scripts/get_context.py --mode packages` to list available specs. Delete this line once real entries are added."}
|
||||
@@ -0,0 +1,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.
|
||||
@@ -0,0 +1 @@
|
||||
{"_example": "Fill with {\"file\": \"<path>\", \"reason\": \"<why>\"}. Put spec/research files only — no code paths. Run `python3 .trellis/scripts/get_context.py --mode packages` to list available specs. Delete this line once real entries are added."}
|
||||
@@ -0,0 +1,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.
|
||||
@@ -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.
|
||||
@@ -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": {}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{"_example": "Fill with {\"file\": \"<path>\", \"reason\": \"<why>\"}. Put spec/research files only — no code paths. Run `python3 .trellis/scripts/get_context.py --mode packages` to list available specs. Delete this line once real entries are added."}
|
||||
@@ -0,0 +1,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.
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
{"_example": "Fill with {\"file\": \"<path>\", \"reason\": \"<why>\"}. Put spec/research files only — no code paths. Run `python3 .trellis/scripts/get_context.py --mode packages` to list available specs. Delete this line once real entries are added."}
|
||||
@@ -0,0 +1,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.
|
||||
@@ -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.
|
||||
@@ -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": {}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{"_example": "Fill with {\"file\": \"<path>\", \"reason\": \"<why>\"}. Put spec/research files only — no code paths. Run `python3 .trellis/scripts/get_context.py --mode packages` to list available specs. Delete this line once real entries are added."}
|
||||
@@ -0,0 +1,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.
|
||||
@@ -0,0 +1 @@
|
||||
{"_example": "Fill with {\"file\": \"<path>\", \"reason\": \"<why>\"}. Put spec/research files only — no code paths. Run `python3 .trellis/scripts/get_context.py --mode packages` to list available specs. Delete this line once real entries are added."}
|
||||
@@ -0,0 +1,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.
|
||||
@@ -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.
|
||||
@@ -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": {}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{"_example": "Fill with {\"file\": \"<path>\", \"reason\": \"<why>\"}. Put spec/research files only — no code paths. Run `python3 .trellis/scripts/get_context.py --mode packages` to list available specs. Delete this line once real entries are added."}
|
||||
@@ -0,0 +1,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.
|
||||
@@ -0,0 +1 @@
|
||||
{"_example": "Fill with {\"file\": \"<path>\", \"reason\": \"<why>\"}. Put spec/research files only — no code paths. Run `python3 .trellis/scripts/get_context.py --mode packages` to list available specs. Delete this line once real entries are added."}
|
||||
@@ -0,0 +1,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.
|
||||
@@ -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.
|
||||
@@ -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": {}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{"_example": "Fill with {\"file\": \"<path>\", \"reason\": \"<why>\"}. Put spec/research files only — no code paths. Run `python3 .trellis/scripts/get_context.py --mode packages` to list available specs. Delete this line once real entries are added."}
|
||||
@@ -0,0 +1 @@
|
||||
{"_example": "Fill with {\"file\": \"<path>\", \"reason\": \"<why>\"}. Put spec/research files only — no code paths. Run `python3 .trellis/scripts/get_context.py --mode packages` to list available specs. Delete this line once real entries are added."}
|
||||
@@ -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`.
|
||||
@@ -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": {}
|
||||
}
|
||||
@@ -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."}
|
||||
@@ -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.
|
||||
@@ -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."}
|
||||
@@ -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.
|
||||
@@ -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`.
|
||||
@@ -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": {}
|
||||
}
|
||||
@@ -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."}
|
||||
@@ -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.
|
||||
@@ -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."}
|
||||
@@ -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.
|
||||
@@ -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`.
|
||||
@@ -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": {}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{"_example": "Fill with {\"file\": \"<path>\", \"reason\": \"<why>\"}. Put spec/research files only — no code paths. Run `python3 .trellis/scripts/get_context.py --mode packages` to list available specs. Delete this line once real entries are added."}
|
||||
@@ -0,0 +1,51 @@
|
||||
# Design
|
||||
|
||||
## Provider Boundary
|
||||
|
||||
`build-area.js` will resolve a native provider by default. Native stages consume only OSM, native
|
||||
road overrides, and `native-road/` outputs. Legacy `intermediates`, `reimport`, and osm2streets
|
||||
Blender input remain behind explicit legacy selection and keep their existing output contract.
|
||||
|
||||
## Native Data Flow
|
||||
|
||||
```text
|
||||
OSM + native-road-overrides.json
|
||||
-> compile-native-roads.js
|
||||
-> native-road/layers/*.geojson + compiled.json + traffic-signals.json
|
||||
-> Blender generate_scene.py --native-road --traffic-signals
|
||||
-> .blend
|
||||
-> Cesium/package GLB + manifest + runtime/traffic-signals.json
|
||||
-> preview HTML + optional native vehicle route
|
||||
```
|
||||
|
||||
The native compiler is the sole authority for road geometry, lane semantics, intersection surfaces,
|
||||
stop lines, and signal runtime. No native stage reads `geojsonDir` or legacy assembly files.
|
||||
|
||||
## Preview Route
|
||||
|
||||
Add a native route adapter that derives route segments from `compiled.json` model roads/endpoints and
|
||||
native lane/connector geometry. If the native route cannot be built for a valid area, preview remains
|
||||
usable without vehicles and records a warning; missing legacy osm2streets lane polygons is never a
|
||||
hard failure on the native path.
|
||||
|
||||
## Package Runtime
|
||||
|
||||
The package stage will copy the native-road signal runtime into its staging runtime directory and
|
||||
declare it in the package manifest. Preview receives the package-relative runtime URI. Legacy signal
|
||||
runtime publication remains conditional on the legacy provider.
|
||||
|
||||
## Configuration And Compatibility
|
||||
|
||||
`roadProvider` defaults to `native`; native stages are the default stage set. QGIS config fields and
|
||||
legacy stage aliases remain accepted for explicit migration/debug commands, but native manifests and
|
||||
docs do not claim them as inputs.
|
||||
|
||||
The Workbench may continue to read an existing `geojsonDir` only for an explicitly labeled reference
|
||||
layer and comparison counters. Those reads are optional, isolated from native compile state, and must
|
||||
never become required inputs for `/api/state`, native editing, package publication, or preview.
|
||||
|
||||
## Rollback
|
||||
|
||||
The original QGIS/osm2streets branch baseline is tagged
|
||||
`qgis-original-baseline-20260818` on `main`. Reverting the native-only work can therefore use the tag
|
||||
or switch to `main`; no destructive cleanup of legacy scripts is required.
|
||||
@@ -0,0 +1 @@
|
||||
{"_example": "Fill with {\"file\": \"<path>\", \"reason\": \"<why>\"}. Put spec/research files only — no code paths. Run `python3 .trellis/scripts/get_context.py --mode packages` to list available specs. Delete this line once real entries are added."}
|
||||
@@ -0,0 +1,27 @@
|
||||
# Implementation Plan
|
||||
|
||||
1. Add native provider/default-stage contracts and explicit legacy provider routing.
|
||||
2. Make package publication and stage manifests consume native runtime artifacts.
|
||||
3. Replace preview's hard dependency on osm2streets lane/network/intersection files with a native
|
||||
route adapter and optional-route behavior.
|
||||
4. Audit Blender, Cesium, preview, diagnostics, and area config for native-path legacy reads; retain
|
||||
only the Workbench's optional, clearly labeled osm2streets reference layer.
|
||||
5. Update README/config/spec documentation and add dependency-boundary regression tests.
|
||||
6. Run native-only tests with legacy directories absent, then run focused legacy compatibility tests.
|
||||
7. Run area compile/check plus available Blender/Cesium/preview validation; record any environment-only
|
||||
limitation without weakening native contracts.
|
||||
|
||||
## Validation
|
||||
|
||||
```bash
|
||||
npm run test:native-road
|
||||
npm run test:traffic-signals
|
||||
npm run test:road-workbench
|
||||
npm run test:preview-assets
|
||||
npm run road:compile -- --config config/areas/nantaizi-lake-innovation-valley.json
|
||||
npm run road:check -- --config config/areas/nantaizi-lake-innovation-valley.json
|
||||
node --check scripts/build-area.js
|
||||
```
|
||||
|
||||
The final gate must also exercise a native-only config/output directory without QGIS/osm2streets
|
||||
intermediates and assert package/runtime/preview outputs.
|
||||
@@ -0,0 +1,63 @@
|
||||
# Complete native-only pipeline replacement
|
||||
|
||||
## Goal
|
||||
|
||||
Make the native road compiler the primary and complete production path. A normal build of an
|
||||
area must generate Blender, Cesium/package, and preview outputs directly from OSM plus native
|
||||
artifacts without installing or running QGIS, GDAL, or osm2streets.
|
||||
|
||||
The existing QGIS/osm2streets workflow remains available only as an explicit legacy/debug path and
|
||||
an optional Workbench reference layer. It is protected by the `qgis-original-baseline-20260818` tag
|
||||
on `main`.
|
||||
|
||||
## Confirmed Current Gaps
|
||||
|
||||
- `normalizeAreaConfig()` defaults `stages.intermediates` to true and `blender.roadProvider` to
|
||||
`osm2streets`.
|
||||
- `build-area.js` sends `intermediates` to `build-osm2streets-qgis.js` and `reimport` to GDAL/QGIS.
|
||||
- Native Blender input exists, but `preview` still requires `osm2streets_web_out/lane_polygons.geojson`,
|
||||
`network.json`, and `intersection_surface.geojson` for vehicle-route generation.
|
||||
- Package/runtime publication still copies `outputs.<area>.trafficSignals` from the legacy GeoJSON
|
||||
directory instead of the native-road runtime artifact.
|
||||
- README, config templates, stage names, and manifest descriptions still present QGIS as the normal
|
||||
workflow.
|
||||
|
||||
## Requirements
|
||||
|
||||
- R1: Native is the default road provider and default build stages do not invoke QGIS, GDAL, or
|
||||
osm2streets.
|
||||
- R2: Native build generates all required road, lane, marking, junction, stop-line, and traffic-signal
|
||||
inputs consumed by Blender and preview; no native stage reads `osm2streets_web_out`.
|
||||
- R3: Native package publication copies runtime traffic signals and any other runtime descriptors
|
||||
from native-road outputs, with stable package-relative URIs.
|
||||
- R4: Native preview route generation uses native lane/road topology and native intersection geometry,
|
||||
or explicitly omits the optional vehicle route when native route data is unavailable; it must not
|
||||
fail because legacy osm2streets files are absent.
|
||||
- R5: QGIS/osm2streets stages remain callable only through an explicit legacy provider/stage selection
|
||||
and are not part of native defaults or native manifests. Workbench may display their existing
|
||||
GeoJSON as a clearly labeled reference/comparison layer, but native editing, compile, package, and
|
||||
preview results must not depend on it.
|
||||
- R6: Documentation and config templates describe native-only as the primary workflow and clearly
|
||||
mark the legacy path as transitional/debug-only.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] A clean native-only area build succeeds with QGIS absent and no `osm2streets-js-node` runtime
|
||||
call, producing `.blend`, package manifest/GLB, runtime traffic signals, and preview HTML.
|
||||
- [ ] Native preview opens when `osm2streets_web_out/` is absent; optional vehicle cruise either uses
|
||||
native route data or is omitted with a visible non-fatal diagnostic.
|
||||
- [ ] Native package manifest contains runtime traffic-signal anchors sourced from
|
||||
`native-road/traffic-signals.json`; disabled signals remain absent from runtime.
|
||||
- [ ] Workbench edits survive save, native recompile, package publication, and preview reload without
|
||||
QGIS or osm2streets files.
|
||||
- [ ] Explicit legacy QGIS/osm2streets commands still pass their existing focused tests, but no longer
|
||||
run when using native defaults.
|
||||
- [ ] Workbench remains usable when the optional osm2streets reference directory is absent; only the
|
||||
reference layer/comparison counters degrade to unavailable.
|
||||
- [ ] Tests assert the native-only dependency boundary and the QGIS baseline tag is documented.
|
||||
|
||||
## Out Of Scope
|
||||
|
||||
- Reimplementing QGIS editing features in the native compiler beyond existing Workbench controls.
|
||||
- Deleting legacy scripts or historical output files in this task; they remain rollback/debug tooling.
|
||||
- Changing Blender/Cesium visual semantics unrelated to removing the input dependency.
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"id": "native-only-pipeline-replacement",
|
||||
"name": "native-only-pipeline-replacement",
|
||||
"title": "Complete native-only pipeline replacement",
|
||||
"description": "",
|
||||
"status": "completed",
|
||||
"dev_type": null,
|
||||
"scope": null,
|
||||
"package": null,
|
||||
"priority": "P2",
|
||||
"creator": "dingkang",
|
||||
"assignee": "dingkang",
|
||||
"createdAt": "2026-08-18",
|
||||
"completedAt": "2026-08-18",
|
||||
"branch": null,
|
||||
"base_branch": "feature/native-road-compiler",
|
||||
"worktree_path": null,
|
||||
"commit": null,
|
||||
"pr_url": null,
|
||||
"subtasks": [],
|
||||
"children": [],
|
||||
"parent": null,
|
||||
"relatedFiles": [],
|
||||
"notes": "",
|
||||
"meta": {}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{"_example": "Fill with {\"file\": \"<path>\", \"reason\": \"<why>\"}. Put spec/research files only — no code paths. Run `python3 .trellis/scripts/get_context.py --mode packages` to list available specs. Delete this line once real entries are added."}
|
||||
@@ -0,0 +1,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.
|
||||
@@ -0,0 +1 @@
|
||||
{"_example": "Fill with {\"file\": \"<path>\", \"reason\": \"<why>\"}. Put spec/research files only — no code paths. Run `python3 .trellis/scripts/get_context.py --mode packages` to list available specs. Delete this line once real entries are added."}
|
||||
@@ -0,0 +1,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.
|
||||
@@ -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.
|
||||
@@ -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": {}
|
||||
}
|
||||
@@ -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` |
|
||||
|
||||
@@ -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**
|
||||
|
||||
43
README.md
43
README.md
@@ -1,6 +1,8 @@
|
||||
# OSM Asset Pipeline
|
||||
|
||||
把单个园区/片区 OSM XML 转为可消费的 Blender 场景和 Cesium GLB。osm2streets GeoJSON、GeoPackage、QGIS 工程和预览图都是中间资产,用来提供道路几何、调试标线效果,以及给 Blender/Cesium 生成提供输入。
|
||||
原始 QGIS/osm2streets 基线保留在 Git tag `qgis-original-baseline-20260818`,用于回撤和对照;当前分支的默认生产链路是 native-only。
|
||||
|
||||
把单个园区/片区 OSM XML 转为可消费的 Blender 场景和 Cesium GLB。Native road compiler 是默认生产道路来源;osm2streets GeoJSON、GeoPackage、QGIS 工程和预览图仅作为显式 legacy/debug 资产或 Workbench 参考层。
|
||||
|
||||
## 目标产物
|
||||
|
||||
@@ -17,17 +19,19 @@
|
||||
- `<area-id>-roads.glb`、`-buildings.glb`、`-vegetation.glb`、`-water.glb`:Cesium 分类检查用的辅助模型
|
||||
- `<area-id>.json`:Cesium 放置元数据和示例代码
|
||||
- `<area-id>-cesium-preview.html`:Cesium 本地预览页
|
||||
- `osm2streets_web_out/`:osm2streets GeoJSON 中间层
|
||||
- `native-road/`:native road compiler 的道路、路口、标线和信号灯运行时产物
|
||||
- `osm2streets_web_out/`:可选的 osm2streets/QGIS 参考中间层
|
||||
- `<area-id>.gpkg` / `<area-id>.qgz` / `<area-id>-preview.png`:QGIS 调试资产
|
||||
|
||||
## 环境
|
||||
|
||||
需要:
|
||||
默认 native-only 构建需要:
|
||||
|
||||
- QGIS 与 GDAL 工具链
|
||||
- Blender
|
||||
- Node.js / npm
|
||||
|
||||
QGIS/GDAL 仅在显式运行 legacy `intermediates`/`reimport` 或 Workbench 参考链路时需要。
|
||||
|
||||
macOS 默认使用 QGIS `/Applications/QGIS.app` 与 Blender
|
||||
`/Applications/Blender.app`。Linux 配置中,`qgisApp` 是安装前缀(通常为
|
||||
`/usr`),`blenderApp` 是可执行文件路径(通常为 `/usr/bin/blender`)。
|
||||
@@ -66,7 +70,7 @@ npm run build:area -- --config config/areas/nantaizi-lake-innovation-valley.json
|
||||
npm run build:area -- --config config/areas/nantaizi-lake-innovation-valley.json --stages reimport
|
||||
```
|
||||
|
||||
`intermediates` 会生成 osm2streets GeoJSON、GeoPackage、QGIS 工程和 QGIS 预览图。`blender` 使用 OSM 和 osm2streets GeoJSON 生成 `.blend`/`.png`。`cesium` 从 `.blend` 导出包 staging,`compress` 压缩 staging 主 GLB,`package` 校验并原子发布 `package/`,`preview` 生成仅用于本地验证的 HTML 和 `_preview/` 动态描述符。`reimport` 把手工编辑过的 GeoPackage 回导为 GeoJSON,不含在 `all` 里,详见 [QGIS 手工修正工作流](#qgis-手工修正工作流)。
|
||||
默认构建依次由 native road compiler、Blender、Cesium、压缩、package 和 preview 组成,不读取 `osm2streets_web_out/`。native preview 在没有 legacy 路线文件时仍可用,只是不提供可选车辆巡航。`intermediates`/`reimport` 仍可显式运行 legacy QGIS 链路,详见 [QGIS 手工修正工作流](#qgis-手工修正工作流)。
|
||||
|
||||
Cesium 预览默认显示完整场景。点击 `Inspect` 后会按道路、建筑、绿化与设施、水体加载辅助 GLB;该模式用于单独检查生成结果,主 GLB 是压缩后的下游交付资产。
|
||||
|
||||
@@ -110,6 +114,22 @@ npm run road:check -- --config config/areas/nantaizi-lake-innovation-valley.json
|
||||
`road:check` 验证已发布 connector 与语义行驶动作的一致性,并在诊断包含 error、图层缺失或
|
||||
动作/几何对应关系不一致时以非零退出。warning 保留给工作台审查,不会阻止产物打开。
|
||||
|
||||
### Native Preview Traffic Simulation
|
||||
|
||||
native preview 会额外生成:
|
||||
|
||||
```text
|
||||
outputs/<area-id>/_preview/<area-id>-traffic-simulation.json
|
||||
```
|
||||
|
||||
该文件使用 `native-preview-traffic-simulation/v1`,记录 native 路线、connector、停止点、
|
||||
信号 `signal_uid`、源文件 SHA-256、坐标系和仿真参数。它与 `package/manifest.json` 一起可
|
||||
迁移到其他平台;消费方不需要运行 Node 或 QGIS。预览中的车辆会根据现有信号相位在停止线
|
||||
前停车,并按最小车距跟车排队。该功能是确定性的验证预览,不是法规级导航或完整交通仿真。
|
||||
|
||||
迁移契约、JSON 示例、更新循环和回撤方式见
|
||||
[native preview traffic simulation](docs/native-preview-traffic-simulation.md)。
|
||||
|
||||
启动本地浏览器工作台:
|
||||
|
||||
```bash
|
||||
@@ -148,9 +168,9 @@ outputs/<area-id>/_pipeline/stages/compress.manifest.json
|
||||
造成假 stale。
|
||||
|
||||
manifest 记录阶段输入/输出文件的 bytes、mtime、sha256、耗时和结构摘要:前段记录 OSM /
|
||||
GeoJSON feature counts,Blender 记录 `.blend` / render,Cesium/压缩记录 GLB digest,
|
||||
preview 记录 GLB、metadata、`lane_polygons.geojson`、`network.json`、`intersection_surface.geojson`、
|
||||
车辆路线和 runtime 文件。巡航道路区间按 osm2streets internal road 匹配真实 Driving lane 中轴,
|
||||
native-road 或 legacy GeoJSON feature counts,Blender 记录 `.blend` / render,Cesium/压缩记录 GLB digest,
|
||||
preview 记录 GLB、metadata、provider 对应的道路输入、可选车辆路线和 runtime 文件。legacy
|
||||
巡航道路区间按 osm2streets internal road 匹配真实 Driving lane 中轴,native preview 可省略巡航,
|
||||
路口 connector 必须通过 intersection surface 越界检查。`diagnose:area` 会读取这些
|
||||
manifest;缺失或当前输入/输出 sha/bytes 不一致会在 `Stage manifests` 和 `Warnings`
|
||||
里标出来。
|
||||
@@ -201,7 +221,7 @@ cp config/examples/template.json config/areas/my-area.json
|
||||
"qgisApp": "/Applications/QGIS.app",
|
||||
"blenderApp": "/Applications/Blender.app",
|
||||
"stages": {
|
||||
"intermediates": true,
|
||||
"intermediates": false,
|
||||
"blender": true,
|
||||
"cesium": true
|
||||
},
|
||||
@@ -219,7 +239,8 @@ cp config/examples/template.json config/areas/my-area.json
|
||||
},
|
||||
"blender": {
|
||||
"treeStyle": "natural",
|
||||
"officeOverrides": ""
|
||||
"officeOverrides": "",
|
||||
"roadProvider": "native"
|
||||
},
|
||||
"compress": {
|
||||
"textureSize": 768,
|
||||
@@ -358,7 +379,7 @@ npm run build:area -- --config config/areas/nantaizi-lake-innovation-valley.json
|
||||
- 这套流程假设你的手工修改已经保存在 `outputs/<area-id>/<area-id>.gpkg` 中
|
||||
- 所有图层先导出到临时目录并校验通过后才写回 `osm2streets_web_out/`;任一图层缺失或导出结果不是合法 FeatureCollection,整批都不落盘(`ogr2ogr` 遇到不存在的图层会留下 0 字节文件,直接覆盖会静默损坏数据)
|
||||
- `intermediates` 与 `reimport` 互斥,同时指定会直接报错:前者用 OSM 重建 `gpkg`,正好会抹掉后者要读回的手工修改
|
||||
- `blender,cesium` 阶段读取的是 `osm2streets_web_out/*.geojson`,不是直接读取 `gpkg`
|
||||
- 在显式 legacy provider 下,`blender,cesium` 阶段读取的是 `osm2streets_web_out/*.geojson`,不是直接读取 `gpkg`;native provider 不读取这些文件
|
||||
- 如果 Blender 当前环境不稳定,先确认 `geojson` 已完成回导,再单独排查 Blender 本身
|
||||
- 增删图层或调整 `z_index` 只需改 `scripts/lib/scene-layers.js`,构建、场景合并、场景样式、QGIS 工程会一并同步
|
||||
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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)}},
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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")):
|
||||
|
||||
@@ -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):
|
||||
|
||||
56
config/areas/fengshu-er-road.json
Normal file
56
config/areas/fengshu-er-road.json
Normal file
@@ -0,0 +1,56 @@
|
||||
{
|
||||
"id": "fengshu-er-road",
|
||||
"input": "/Users/que01/osm2streets-qgis-workflow/inputs/osm/枫树二路.osm",
|
||||
"outputRoot": "/Users/que01/osm2streets-qgis-workflow/outputs",
|
||||
"qgisApp": "/Applications/QGIS.app",
|
||||
"blenderApp": "/Applications/Blender.app",
|
||||
"stages": {
|
||||
"intermediates": false,
|
||||
"blender": true,
|
||||
"cesium": true
|
||||
},
|
||||
"qgis": {
|
||||
"arrowScale": 0.8,
|
||||
"arrowMergeTriangles": true,
|
||||
"arrowOutlineSimplifyMeters": 0.05,
|
||||
"intersectionCornerSourceMaxDimensionMeters": 2.6,
|
||||
"clipPad": 0.002,
|
||||
"canvasPad": 0.001,
|
||||
"previewPad": 0.0007,
|
||||
"canvasExtent": null,
|
||||
"previewExtent": null,
|
||||
"layerPrefix": "osm2streets"
|
||||
},
|
||||
"turnLaneArrows": {
|
||||
"enabled": false
|
||||
},
|
||||
"nativeRoad": {
|
||||
"edgeLines": false
|
||||
},
|
||||
"osm2streets": {
|
||||
"debug_each_step": false,
|
||||
"dual_carriageway_experiment": false,
|
||||
"sidepath_zipping_experiment": false,
|
||||
"inferred_sidewalks": true,
|
||||
"osm2lanes": true
|
||||
},
|
||||
"blender": {
|
||||
"treeStyle": "natural",
|
||||
"officeOverrides": "",
|
||||
"roadProvider": "native"
|
||||
},
|
||||
"compress": {
|
||||
"textureSize": 768,
|
||||
"quality": 82,
|
||||
"effort": 80,
|
||||
"meshopt": false
|
||||
},
|
||||
"budget": {
|
||||
"glbSizeMb": 25,
|
||||
"nodes": 1000,
|
||||
"images": 24,
|
||||
"triangles": 250000,
|
||||
"embeddedImageBytesMb": 20,
|
||||
"reason": ""
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,7 @@
|
||||
"qgisApp": "/Applications/QGIS.app",
|
||||
"blenderApp": "/Applications/Blender.app",
|
||||
"stages": {
|
||||
"intermediates": true,
|
||||
"intermediates": false,
|
||||
"blender": true,
|
||||
"cesium": true
|
||||
},
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"qgisApp": "/Applications/QGIS.app",
|
||||
"blenderApp": "/Applications/Blender.app",
|
||||
"stages": {
|
||||
"intermediates": true,
|
||||
"intermediates": false,
|
||||
"blender": true,
|
||||
"cesium": true
|
||||
},
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
64
docs/native-preview-traffic-simulation.md
Normal file
64
docs/native-preview-traffic-simulation.md
Normal file
@@ -0,0 +1,64 @@
|
||||
# Native Preview Traffic Simulation
|
||||
|
||||
This document defines the portable preview contract for native road traffic demonstrations. It is
|
||||
not a legal navigation format or a full microscopic traffic simulator.
|
||||
|
||||
## Data Flow
|
||||
|
||||
```text
|
||||
native-road/compiled.json + layers/
|
||||
-> native-preview-traffic-simulation/v1 descriptor
|
||||
package/runtime/traffic-signals.json
|
||||
-> signal phase clock and stop constraints
|
||||
descriptor + package/manifest.json
|
||||
-> Cesium preview vehicles, signals, queues, diagnostics
|
||||
```
|
||||
|
||||
The descriptor is generated from native artifacts and is independent of QGIS/osm2streets. Copying
|
||||
the descriptor, package, and preview runtime to another platform is sufficient to reproduce the
|
||||
visual simulation.
|
||||
|
||||
## Existing Signal Contract
|
||||
|
||||
Signal identity is `signal_uid`; it must not be renamed when `display_id` changes. `phase_group`
|
||||
selects the existing signal phase. `mast_heading_deg`, `face_heading_deg`, `pose.*`, stop-line
|
||||
coordinates, and `enabled` are consumed from `package/runtime/traffic-signals.json`. Disabled signals
|
||||
are absent from runtime control. Consumers must not recalculate lamp or pole geometry.
|
||||
|
||||
## Vehicle Rules
|
||||
|
||||
Each vehicle advances along a route distance. Its target speed is the minimum of desired speed,
|
||||
signal-safe speed, and leader-safe speed. Red/yellow signals create a stop target before the native
|
||||
stop line. Following vehicles apply the configured minimum gap and decelerate behind the leader.
|
||||
The simulation is deterministic for a fixed descriptor, clock start, and settings.
|
||||
|
||||
The descriptor records WGS84 route coordinates. The static package remains local ENU and is placed by
|
||||
the package manifest. Consumers must use the package placement/model matrix for GLB assets and WGS84
|
||||
coordinates for route entities; do not mix a fixed meters-per-degree approximation into either path.
|
||||
|
||||
Route geometry is taken from `native-road/layers/lane_centerlines.geojson` when that native layer is
|
||||
available, keyed by `road_id`; the compiled road centerline is only a compatibility fallback for old
|
||||
native outputs. Each road/connector boundary is checked in meters. Candidates with a missing
|
||||
connector or an endpoint gap over 35 m are omitted from `routes` and retained in `rejectedRoutes`
|
||||
with structured diagnostics. This prevents a malformed movement from becoming a visible diagonal
|
||||
track across an intersection.
|
||||
|
||||
`diagnostics` contains the route-generation warnings, while `rejectedRoutes` preserves the candidate
|
||||
id and the specific movement checks that failed. A consumer should display these as data-quality
|
||||
warnings rather than silently reconstructing a route from legacy QGIS files.
|
||||
|
||||
## Migration Checklist
|
||||
|
||||
1. Load `package/manifest.json` and resolve all package-relative assets.
|
||||
2. Load the traffic-signal runtime and preserve `signal_uid`/`phase_group` values.
|
||||
3. Load the versioned simulation descriptor and verify its source hashes if reproducibility matters.
|
||||
4. Implement the update loop using the descriptor settings and stop/leader constraints.
|
||||
5. Place route coordinates in the same WGS84 scene as the package placement and preserve ENU model
|
||||
placement for static assets.
|
||||
6. Expose route count, signal count, stopped vehicles, and queue length as diagnostics.
|
||||
|
||||
## Rollback
|
||||
|
||||
Deleting or ignoring the optional simulation descriptor returns the native preview to a usable static
|
||||
scene with signal visualization but no vehicle cruise. The QGIS baseline remains available through
|
||||
`qgis-original-baseline-20260818`; it is not required by this contract.
|
||||
15593
inputs/osm/枫树二路.osm
Normal file
15593
inputs/osm/枫树二路.osm
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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": {
|
||||
|
||||
@@ -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: [],
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -323,13 +323,17 @@ function addEndpoint(map, ref) {
|
||||
}
|
||||
|
||||
function artifactStatus(area) {
|
||||
const native = area.blender.roadProvider === "native";
|
||||
const entries = [
|
||||
["GeoJSON dir", area.outputs.geojsonDir, true, "dir"],
|
||||
["GeoPackage", area.outputs.gpkg, true, "file"],
|
||||
["QGIS project", area.outputs.qgisProject, true, "file"],
|
||||
["QGIS preview", area.outputs.qgisPreview, true, "file"],
|
||||
["Traffic signal assemblies", area.outputs.trafficSignalAssemblies, true, "file"],
|
||||
["Traffic signal runtime", area.outputs.packageTrafficSignals, true, "file"],
|
||||
...(native ? [] : [
|
||||
["GeoJSON dir", area.outputs.geojsonDir, true, "dir"],
|
||||
["GeoPackage", area.outputs.gpkg, true, "file"],
|
||||
["QGIS project", area.outputs.qgisProject, true, "file"],
|
||||
["QGIS preview", area.outputs.qgisPreview, true, "file"],
|
||||
["Traffic signal assemblies", area.outputs.trafficSignalAssemblies, true, "file"],
|
||||
]),
|
||||
["Native road directory", area.outputs.nativeRoadDir, native, "dir"],
|
||||
["Traffic signal runtime", native ? path.join(area.outputs.nativeRoadDir, "traffic-signals.json") : area.outputs.packageTrafficSignals, true, "file"],
|
||||
["Blend scene", area.outputs.blend, true, "file"],
|
||||
["Render PNG", area.outputs.render, true, "file"],
|
||||
["Package manifest", area.outputs.packageManifest, true, "file"],
|
||||
@@ -373,6 +377,7 @@ function metadataSummary(file, warnings) {
|
||||
}
|
||||
|
||||
function stageManifestStatus(area, configPath = null) {
|
||||
const native = area.blender.roadProvider === "native";
|
||||
const compressionComplete = fs.existsSync(stageManifestPath(area, "compress"));
|
||||
const reimportManifest = stageManifestPath(area, "reimport");
|
||||
const hasReimportManifest = fs.existsSync(reimportManifest);
|
||||
@@ -430,10 +435,12 @@ function stageManifestStatus(area, configPath = null) {
|
||||
inputs: {
|
||||
...(configPath ? { config: configPath } : {}),
|
||||
osm: area.input,
|
||||
geojsonDir: area.outputs.geojsonDir,
|
||||
...sceneGeojsonFiles(area),
|
||||
trafficSignalAssemblies: area.outputs.trafficSignalAssemblies,
|
||||
trafficSignals: area.outputs.packageTrafficSignals,
|
||||
...(native ? nativeRoadRecords(area) : {
|
||||
geojsonDir: area.outputs.geojsonDir,
|
||||
...sceneGeojsonFiles(area),
|
||||
trafficSignalAssemblies: area.outputs.trafficSignalAssemblies,
|
||||
trafficSignals: area.outputs.packageTrafficSignals,
|
||||
}),
|
||||
},
|
||||
outputs: {
|
||||
blend: area.outputs.blend,
|
||||
@@ -461,18 +468,23 @@ function stageManifestStatus(area, configPath = null) {
|
||||
glb: area.outputs.glb,
|
||||
metadata: area.outputs.metadata,
|
||||
}),
|
||||
lanePolygons: path.join(area.outputs.geojsonDir, "lane_polygons.geojson"),
|
||||
network: path.join(area.outputs.geojsonDir, "network.json"),
|
||||
intersectionSurface: path.join(area.outputs.geojsonDir, "intersection_surface.geojson"),
|
||||
...(native ? nativeRoadRecords(area) : {
|
||||
lanePolygons: path.join(area.outputs.geojsonDir, "lane_polygons.geojson"),
|
||||
network: path.join(area.outputs.geojsonDir, "network.json"),
|
||||
intersectionSurface: path.join(area.outputs.geojsonDir, "intersection_surface.geojson"),
|
||||
}),
|
||||
...(native ? { trafficSimulation: area.outputs.trafficSimulation } : {}),
|
||||
previewCss: path.join(path.resolve(__dirname, ".."), "lib", "cesium-preview.css"),
|
||||
previewJs: path.join(path.resolve(__dirname, ".."), "lib", "cesium-preview.js"),
|
||||
},
|
||||
outputs: compressionComplete ? {
|
||||
vehicleRoute: area.outputs.vehicleRoute,
|
||||
vehicleRoute: native ? optionalExpectedFile(area.outputs.vehicleRoute) : area.outputs.vehicleRoute,
|
||||
...(native ? { trafficSimulation: area.outputs.trafficSimulation } : {}),
|
||||
vehicleModel: area.outputs.vehicleModel,
|
||||
} : {
|
||||
cesiumPreview: area.outputs.cesiumPreview,
|
||||
vehicleRoute: area.outputs.vehicleRoute,
|
||||
vehicleRoute: native ? optionalExpectedFile(area.outputs.vehicleRoute) : area.outputs.vehicleRoute,
|
||||
...(native ? { trafficSimulation: area.outputs.trafficSimulation } : {}),
|
||||
vehicleModel: area.outputs.vehicleModel,
|
||||
},
|
||||
},
|
||||
@@ -597,6 +609,24 @@ function sceneGeojsonFiles(area) {
|
||||
return files;
|
||||
}
|
||||
|
||||
function nativeRoadRecords(area) {
|
||||
const root = path.join(area.outputs.nativeRoadDir, "layers");
|
||||
return {
|
||||
nativeRoadCompiled: path.join(area.outputs.nativeRoadDir, "compiled.json"),
|
||||
nativeRoadSignals: path.join(area.outputs.nativeRoadDir, "traffic-signals.json"),
|
||||
nativeRoadSurface: path.join(root, "road_surface.geojson"),
|
||||
nativeEdgeLines: path.join(root, "edge_lines.geojson"),
|
||||
nativeIntersectionSurface: path.join(root, "intersection_surface.geojson"),
|
||||
nativeSidewalkSurface: path.join(root, "sidewalk_surface.geojson"),
|
||||
nativeLaneSeparators: path.join(root, "lane_separators.geojson"),
|
||||
nativeCenterLines: path.join(root, "center_lines.geojson"),
|
||||
nativeDirectionArrows: path.join(root, "direction_arrows.geojson"),
|
||||
nativeTurnArrows: path.join(root, "turn_arrows.geojson"),
|
||||
nativeCrosswalks: path.join(root, "crosswalks.geojson"),
|
||||
nativeVehicleStopLines: path.join(root, "vehicle_stop_lines.geojson"),
|
||||
};
|
||||
}
|
||||
|
||||
function collectWarnings(area, osm, artifacts, manifests, glb, metadata) {
|
||||
const warnings = [];
|
||||
if (!osm.bounds) warnings.push("OSM has no valid <bounds>; scene extent may be wrong.");
|
||||
|
||||
@@ -123,15 +123,15 @@ function escapeScriptJson(value) {
|
||||
.replaceAll("\u2029", "\\u2029");
|
||||
}
|
||||
|
||||
function previewSummary(area) {
|
||||
const route = JSON.parse(fs.readFileSync(area.outputs.vehicleRoute, "utf8"));
|
||||
function previewSummary(area, routePath = area.outputs.vehicleRoute) {
|
||||
const route = routePath && fs.existsSync(routePath) ? JSON.parse(fs.readFileSync(routePath, "utf8")) : null;
|
||||
return {
|
||||
glbName: "package/manifest.json",
|
||||
metadataName: "package/manifest.json",
|
||||
routeName: path.relative(area.outputs.areaDir, area.outputs.vehicleRoute).split(path.sep).join("/"),
|
||||
routeName: route ? path.relative(area.outputs.areaDir, routePath).split(path.sep).join("/") : null,
|
||||
vehicleModelName: path.relative(area.outputs.areaDir, area.outputs.vehicleModel).split(path.sep).join("/"),
|
||||
trafficSignalsName: "package/runtime/traffic-signals.json",
|
||||
routeSegments: Array.isArray(route.segments) ? route.segments.length : null,
|
||||
routeSegments: Array.isArray(route?.routes) ? route.routes.length : Array.isArray(route?.segments) ? route.segments.length : null,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use strict";
|
||||
|
||||
const STAGES = [
|
||||
{ id: "intermediates", label: "OSM / QGIS intermediates", description: "Rebuild GeoJSON and GeoPackage from OSM" },
|
||||
{ id: "intermediates", label: "Legacy QGIS intermediates", description: "Explicit legacy osm2streets/QGIS reference build" },
|
||||
{ id: "reimport", label: "Reimport QGIS edits", description: "Copy GeoPackage layers back to GeoJSON" },
|
||||
{ id: "blender", label: "Blender scene", description: "Generate the editable scene and render" },
|
||||
{ id: "cesium", label: "Cesium export", description: "Export static package staging assets" },
|
||||
@@ -11,7 +11,7 @@ const STAGES = [
|
||||
];
|
||||
|
||||
const ALIASES = {
|
||||
all: ["intermediates", "blender", "cesium", "compress", "package", "preview"],
|
||||
all: ["blender", "cesium", "compress", "package", "preview"],
|
||||
qgis: ["intermediates"], osm2streets: ["intermediates"], geojson: ["intermediates"], intermediate: ["intermediates"],
|
||||
intermediates: ["intermediates"], reimport: ["reimport"], gpkg: ["reimport"], blender: ["blender"], scene: ["blender"],
|
||||
cesium: ["cesium"], glb: ["cesium"], preview: ["preview"], html: ["preview"], cesiumPreview: ["preview"],
|
||||
|
||||
@@ -88,6 +88,7 @@
|
||||
// The route file is an extra on top of the scene, not a precondition for it.
|
||||
// A missing or unreadable route costs the cruise controls, not the preview.
|
||||
async function fetchOptionalJson(url) {
|
||||
if (!url) return null;
|
||||
try {
|
||||
return await fetchJson(url);
|
||||
} catch (error) {
|
||||
@@ -554,16 +555,27 @@
|
||||
.slice(0, 5);
|
||||
const speed = Number((routeData && routeData.speedMetersPerSecond) || 8);
|
||||
const start = trafficStart;
|
||||
const assignments = [];
|
||||
segments.forEach((segment, routeIndex) => {
|
||||
// Put two vehicles on the first route so the native preview visibly
|
||||
// exercises leader following and queue formation.
|
||||
const vehicleCount = routeIndex === 0 ? 2 : 1;
|
||||
for (let vehicleIndex = 0; vehicleIndex < vehicleCount; vehicleIndex += 1) {
|
||||
assignments.push({ segment, routeIndex, vehicleIndex });
|
||||
}
|
||||
});
|
||||
const simulation = createTrafficSimulation(viewer, assignments, start, speed, signalData, routeData?.settings);
|
||||
|
||||
viewer.clock.startTime = start.clone();
|
||||
viewer.clock.currentTime = start.clone();
|
||||
viewer.clock.clockRange = Cesium.ClockRange.UNBOUNDED;
|
||||
viewer.clock.multiplier = 1;
|
||||
viewer.clock.shouldAnimate = segments.length > 0;
|
||||
viewer.clock.shouldAnimate = assignments.length > 0;
|
||||
|
||||
const vehicles = segments.map((segment, index) => {
|
||||
const vehicles = assignments.map((assignment, index) => {
|
||||
const { segment } = assignment;
|
||||
const vehicle = addCruiseVehicle(viewer, segment, index, start, speed, signalData,
|
||||
selectedVehicleModelName(vehicleModelNames, fallbackVehicleModelName));
|
||||
selectedVehicleModelName(vehicleModelNames, fallbackVehicleModelName), simulation.motions[index]);
|
||||
const option = document.createElement("option");
|
||||
option.value = String(index);
|
||||
option.textContent = routeLabel(segment, index);
|
||||
@@ -573,7 +585,8 @@
|
||||
const cruise = {
|
||||
vehicles,
|
||||
baseSpeed: speed,
|
||||
state: { selectedIndex: 0 }
|
||||
state: { selectedIndex: 0 },
|
||||
simulation,
|
||||
};
|
||||
syncSelectedRouteVisibility(cruise);
|
||||
return cruise;
|
||||
@@ -731,10 +744,10 @@
|
||||
return usable[Math.floor(Math.random() * usable.length)] || "";
|
||||
}
|
||||
|
||||
function addCruiseVehicle(viewer, segment, index, start, speed, signalData, vehicleModelName) {
|
||||
const route = prepareRoute(segment, signalData);
|
||||
function addCruiseVehicle(viewer, segment, index, start, speed, signalData, vehicleModelName, trafficMotion) {
|
||||
const route = trafficMotion.route;
|
||||
let record = null;
|
||||
const trafficMotion = createTrafficAwarePositions(viewer, route, start, speed, () => record?.status === "normal");
|
||||
trafficMotion.setMoving(() => record?.status === "normal");
|
||||
const positions = trafficMotion.positions;
|
||||
const flat = [];
|
||||
for (const coord of segment.coordinates) {
|
||||
@@ -945,6 +958,106 @@
|
||||
return [...found.values()].sort((a, b) => a.distance - b.distance);
|
||||
}
|
||||
|
||||
function createTrafficSimulation(viewer, assignments, start, speed, signalData, settings = {}) {
|
||||
const config = {
|
||||
desiredSpeedMetersPerSecond: Number(settings.desiredSpeedMetersPerSecond || speed),
|
||||
accelerationMetersPerSecondSquared: Number(settings.accelerationMetersPerSecondSquared || 1.8),
|
||||
decelerationMetersPerSecondSquared: Number(settings.decelerationMetersPerSecondSquared || 3.5),
|
||||
reactionTimeSeconds: Number(settings.reactionTimeSeconds || 0.8),
|
||||
vehicleLengthMeters: Number(settings.vehicleLengthMeters || 4.6),
|
||||
minimumGapMeters: Number(settings.minimumGapMeters || 7),
|
||||
};
|
||||
const motions = assignments.map((assignment) => {
|
||||
const route = prepareRoute(assignment.segment, signalData);
|
||||
const initialGap = config.vehicleLengthMeters + config.minimumGapMeters;
|
||||
const state = {
|
||||
distance: assignment.vehicleIndex * -initialGap,
|
||||
speed: 0,
|
||||
targetSpeed: config.desiredSpeedMetersPerSecond,
|
||||
stopReason: null,
|
||||
queueAhead: 0,
|
||||
lastTime: start.clone(),
|
||||
};
|
||||
state.distance = (state.distance % route.length + route.length) % route.length;
|
||||
return {
|
||||
route,
|
||||
state,
|
||||
setMoving(callback) { state.isMoving = callback; },
|
||||
positions: new Cesium.CallbackProperty((time, result) => routePositionAtDistance(route, state.distance, result), false),
|
||||
};
|
||||
});
|
||||
const update = (clock) => {
|
||||
const elapsed = Cesium.JulianDate.secondsDifference(clock.currentTime, motions[0]?.state.lastTime || start);
|
||||
if (!(elapsed > 0) || elapsed > 2) {
|
||||
for (const motion of motions) Cesium.JulianDate.clone(clock.currentTime, motion.state.lastTime);
|
||||
return;
|
||||
}
|
||||
const grouped = new Map();
|
||||
assignments.forEach((assignment, index) => {
|
||||
const key = assignment.routeIndex;
|
||||
if (!grouped.has(key)) grouped.set(key, []);
|
||||
grouped.get(key).push(index);
|
||||
});
|
||||
for (const indexes of grouped.values()) {
|
||||
indexes.sort((a, b) => motions[a].state.distance - motions[b].state.distance);
|
||||
}
|
||||
motions.forEach((motion, index) => {
|
||||
const state = motion.state;
|
||||
Cesium.JulianDate.clone(clock.currentTime, state.lastTime);
|
||||
if (state.isMoving && !state.isMoving()) {
|
||||
state.speed = 0;
|
||||
state.stopReason = "incident";
|
||||
return;
|
||||
}
|
||||
const route = motion.route;
|
||||
let target = config.desiredSpeedMetersPerSecond;
|
||||
let stopReason = null;
|
||||
const nextStop = nextRouteStop(route, state.distance);
|
||||
if (nextStop) {
|
||||
const phase = signalPhase(nextStop.signal.phaseGroup, clock.currentTime, start).active;
|
||||
const untilStop = (nextStop.distance - state.distance + route.length) % route.length;
|
||||
if (phase !== "green" && untilStop < Math.max(30, state.speed * config.reactionTimeSeconds + 8)) {
|
||||
target = Math.min(target, Math.max(0, (untilStop - 1.7) / Math.max(config.reactionTimeSeconds, 0.1)));
|
||||
stopReason = "traffic-signal";
|
||||
}
|
||||
}
|
||||
const peers = grouped.get(assignments[index].routeIndex) || [];
|
||||
const peerPosition = peers.indexOf(index);
|
||||
if (peerPosition >= 0 && peers.length > 1) {
|
||||
const leaderIndex = peers[(peerPosition + 1) % peers.length];
|
||||
if (leaderIndex !== index) {
|
||||
const leader = motions[leaderIndex].state;
|
||||
const gap = (leader.distance - state.distance + route.length) % route.length - config.vehicleLengthMeters;
|
||||
const safeGap = config.minimumGapMeters + state.speed * config.reactionTimeSeconds;
|
||||
if (gap < safeGap + 12) {
|
||||
target = Math.min(target, Math.max(0, (gap - config.minimumGapMeters) / Math.max(config.reactionTimeSeconds, 0.1)));
|
||||
if (target < 0.2) stopReason = "leader-gap";
|
||||
state.queueAhead = leader.stopReason ? 1 : 0;
|
||||
} else {
|
||||
state.queueAhead = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
state.targetSpeed = target;
|
||||
const limit = (target >= state.speed ? config.accelerationMetersPerSecondSquared : config.decelerationMetersPerSecondSquared) * elapsed;
|
||||
state.speed += Math.sign(target - state.speed) * Math.min(Math.abs(target - state.speed), limit);
|
||||
state.distance = (state.distance + Math.max(0, state.speed) * elapsed) % route.length;
|
||||
state.stopReason = state.speed < 0.2 ? stopReason : null;
|
||||
});
|
||||
};
|
||||
viewer.clock.onTick.addEventListener(update);
|
||||
return {
|
||||
motions,
|
||||
settings: config,
|
||||
diagnostics() {
|
||||
return {
|
||||
stoppedVehicles: motions.filter((motion) => motion.state.stopReason).length,
|
||||
queueLength: motions.filter((motion) => motion.state.stopReason === "leader-gap").length,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createTrafficAwarePositions(viewer, route, start, speed, isMoving = () => true) {
|
||||
const state = { distance: 0, lastTime: start.clone() };
|
||||
viewer.clock.onTick.addEventListener((clock) => {
|
||||
@@ -1229,6 +1342,12 @@
|
||||
"Trees: " + Number(stats.trees || 0),
|
||||
"Road layer source: " + (metadata.source_geojson ? "osm2streets" : "OSM fallback")
|
||||
];
|
||||
if (cruise.simulation) {
|
||||
const simulation = cruise.simulation.diagnostics();
|
||||
lines.push("Simulation: native-preview-traffic-simulation/v1");
|
||||
lines.push("Stopped: " + simulation.stoppedVehicles);
|
||||
lines.push("Queue: " + simulation.queueLength);
|
||||
}
|
||||
if (failed.length) {
|
||||
lines.push("Failed assets: " + failed.map((asset) => asset.url).join(", "));
|
||||
}
|
||||
|
||||
259
scripts/lib/native-preview-traffic-simulation.js
Normal file
259
scripts/lib/native-preview-traffic-simulation.js
Normal file
@@ -0,0 +1,259 @@
|
||||
"use strict";
|
||||
|
||||
const crypto = require("crypto");
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
const SCHEMA = "native-preview-traffic-simulation/v1";
|
||||
const MAX_ROUTES = 5;
|
||||
const MIN_ROAD_COUNT = 3;
|
||||
const MAX_ROAD_COUNT = 7;
|
||||
const MAX_CONNECTION_GAP_METERS = 35;
|
||||
const DEFAULT_SETTINGS = {
|
||||
desiredSpeedMetersPerSecond: 8,
|
||||
accelerationMetersPerSecondSquared: 1.8,
|
||||
decelerationMetersPerSecondSquared: 3.5,
|
||||
reactionTimeSeconds: 0.8,
|
||||
vehicleLengthMeters: 4.6,
|
||||
minimumGapMeters: 7,
|
||||
};
|
||||
|
||||
function buildNativeTrafficSimulation(area, options = {}) {
|
||||
const root = area.outputs.nativeRoadDir;
|
||||
const compiledPath = path.join(root, "compiled.json");
|
||||
const signalPath = path.join(root, "traffic-signals.json");
|
||||
const connectorPath = path.join(root, "layers", "connectors.geojson");
|
||||
const laneCenterlinePath = path.join(root, "layers", "lane_centerlines.geojson");
|
||||
const stopLinePath = path.join(root, "layers", "vehicle_stop_lines.geojson");
|
||||
for (const file of [compiledPath, signalPath, connectorPath, stopLinePath]) {
|
||||
if (!fs.existsSync(file)) throw new Error(`Native traffic simulation input not found: ${file}`);
|
||||
}
|
||||
const compiled = readJson(compiledPath, "native compiled roads");
|
||||
const signals = readJson(signalPath, "native traffic signals");
|
||||
const connectors = readFeatureCollection(connectorPath, "native connectors");
|
||||
const laneCenterlines = fs.existsSync(laneCenterlinePath) ? readFeatureCollection(laneCenterlinePath, "native lane centerlines") : null;
|
||||
const stopLines = readFeatureCollection(stopLinePath, "native stop lines");
|
||||
const settings = { ...DEFAULT_SETTINGS, ...(options.settings || {}) };
|
||||
validateSettings(settings);
|
||||
const routeBuild = buildRoutes(compiled, connectors, signals.signals || [], stopLines.features, laneCenterlines);
|
||||
return {
|
||||
schema: SCHEMA,
|
||||
areaId: area.id,
|
||||
coordinateSystem: { route: "WGS84", model: "ENU", units: "meters", axes: "X east / Y north / Z up" },
|
||||
generatedAt: new Date().toISOString(),
|
||||
speedMetersPerSecond: settings.desiredSpeedMetersPerSecond,
|
||||
source: sourceRecords(area, [compiledPath, signalPath, connectorPath, stopLinePath, ...(laneCenterlines ? [laneCenterlinePath] : [])]),
|
||||
settings,
|
||||
routes: routeBuild.routes,
|
||||
signals: (signals.signals || []).filter((signal) => signal && signal.id).map(signalDescriptor),
|
||||
diagnostics: [
|
||||
...routeBuild.diagnostics,
|
||||
...(routeBuild.routes.length ? [] : [{ severity: "warning", reason: "no_native_cruise_route", message: "No deterministic native route crossed three or more directional roads." }]),
|
||||
],
|
||||
rejectedRoutes: routeBuild.rejectedRoutes,
|
||||
migration: {
|
||||
schema: SCHEMA,
|
||||
updateLoop: "advance route distance using settings, stop before red/yellow signal, then apply leader minimum gap",
|
||||
signalIdentity: "signals[].id is the existing traffic runtime signal_uid",
|
||||
coordinates: "routes[].coordinates are WGS84; static package remains local ENU placed by package manifest",
|
||||
legacyDependency: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function buildRoutes(compiled, connectorCollection, signals, stopLineFeatures, laneCenterlineCollection) {
|
||||
const roads = new Map((compiled.model?.roads || []).map((road) => [road.id, road]));
|
||||
const movements = (compiled.movements || []).filter((movement) => movement.geometryPublished !== false && roads.has(movement.fromRoadId) && roads.has(movement.toRoadId));
|
||||
const connectorByMovement = new Map(connectorCollection.features.map((feature) => [feature.properties?.movement_id, feature.geometry?.coordinates || []]));
|
||||
const laneCenterlineById = new Map();
|
||||
for (const feature of laneCenterlineCollection?.features || []) {
|
||||
const properties = feature.properties || {};
|
||||
const coordinates = feature.geometry?.coordinates || [];
|
||||
if (properties.native_id) laneCenterlineById.set(properties.native_id, coordinates);
|
||||
if (properties.road_id && Number.isFinite(Number(properties.lane_index))) laneCenterlineById.set(`${properties.road_id}:${properties.lane_index}`, coordinates);
|
||||
}
|
||||
const outgoing = new Map();
|
||||
for (const movement of movements) {
|
||||
if (!outgoing.has(movement.fromRoadId)) outgoing.set(movement.fromRoadId, []);
|
||||
outgoing.get(movement.fromRoadId).push(movement);
|
||||
}
|
||||
for (const list of outgoing.values()) list.sort((a, b) => a.id.localeCompare(b.id));
|
||||
const candidates = [];
|
||||
const seen = new Set();
|
||||
for (const start of [...roads.keys()].sort()) {
|
||||
findCycles(start, start, [], [], outgoing, seen, candidates);
|
||||
if (candidates.length >= MAX_ROUTES * 3) break;
|
||||
}
|
||||
const diagnostics = [];
|
||||
const rejectedRoutes = [];
|
||||
const routes = candidates
|
||||
.map((candidate) => {
|
||||
const result = makeRoute(candidate, roads, connectorByMovement, signals, stopLineFeatures, laneCenterlineById);
|
||||
if (!result.route) {
|
||||
rejectedRoutes.push({ id: `native-loop:${candidate.roadIds.join("-")}`, diagnostics: result.diagnostics });
|
||||
diagnostics.push(...result.diagnostics);
|
||||
}
|
||||
return result.route;
|
||||
})
|
||||
.filter(Boolean)
|
||||
.sort((a, b) => a.id.localeCompare(b.id))
|
||||
.slice(0, MAX_ROUTES);
|
||||
return { routes, diagnostics, rejectedRoutes };
|
||||
}
|
||||
|
||||
function findCycles(start, current, roadIds, movements, outgoing, seen, candidates) {
|
||||
const nextRoadIds = [...roadIds, current];
|
||||
if (nextRoadIds.length > MAX_ROAD_COUNT) return;
|
||||
for (const movement of outgoing.get(current) || []) {
|
||||
if (movement.toRoadId === start && nextRoadIds.length >= MIN_ROAD_COUNT) {
|
||||
if (!laneSequenceCompatible(movements.at(-1), movement) || !laneSequenceCompatible(movement, movements[0])) continue;
|
||||
const signature = [...nextRoadIds, start].join("|");
|
||||
if (!seen.has(signature)) {
|
||||
seen.add(signature);
|
||||
candidates.push({ roadIds: nextRoadIds, movements: [...movements, movement] });
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (nextRoadIds.includes(movement.toRoadId)) continue;
|
||||
if (!laneSequenceCompatible(movements.at(-1), movement)) continue;
|
||||
findCycles(start, movement.toRoadId, nextRoadIds, [...movements, movement], outgoing, seen, candidates);
|
||||
}
|
||||
}
|
||||
|
||||
function laneSequenceCompatible(previous, next) {
|
||||
if (!previous || !previous.toLaneId || !next?.fromLaneId) return true;
|
||||
return previous.toLaneId === next.fromLaneId;
|
||||
}
|
||||
|
||||
function makeRoute(candidate, roads, connectorByMovement, signals, stopLineFeatures, laneCenterlineById) {
|
||||
const coordinates = [];
|
||||
const connectors = [];
|
||||
const diagnostics = [];
|
||||
for (let index = 0; index < candidate.roadIds.length; index += 1) {
|
||||
const road = roads.get(candidate.roadIds[index]);
|
||||
const incomingMovement = candidate.movements[(index - 1 + candidate.movements.length) % candidate.movements.length];
|
||||
const incomingLine = selectLaneCenterline(laneCenterlineById, road, incomingMovement?.toLaneId);
|
||||
const roadLine = incomingLine || road.centerline;
|
||||
append(coordinates, roadLine);
|
||||
const movement = candidate.movements[index];
|
||||
const connector = connectorByMovement.get(movement.id) || [];
|
||||
if (connector.length < 2) {
|
||||
diagnostics.push(routeDiagnostic(candidate, movement, "missing_connector_geometry", "Native movement has no usable connector geometry."));
|
||||
return { route: null, diagnostics };
|
||||
}
|
||||
const startGap = distanceMeters(coordinates.at(-1), connector[0]);
|
||||
if (startGap > MAX_CONNECTION_GAP_METERS) {
|
||||
diagnostics.push(routeDiagnostic(candidate, movement, "road_connector_gap", `Road to connector endpoint gap is ${round(startGap)}m.`));
|
||||
return { route: null, diagnostics };
|
||||
}
|
||||
append(coordinates, connector);
|
||||
if (index < candidate.roadIds.length - 1) {
|
||||
const nextRoad = roads.get(candidate.roadIds[index + 1]);
|
||||
const nextLine = selectLaneCenterline(laneCenterlineById, nextRoad, movement.toLaneId) || nextRoad.centerline;
|
||||
const endGap = distanceMeters(connector.at(-1), nextLine[0]);
|
||||
if (endGap > MAX_CONNECTION_GAP_METERS) {
|
||||
diagnostics.push(routeDiagnostic(candidate, movement, "connector_road_gap", `Connector to road endpoint gap is ${round(endGap)}m.`));
|
||||
return { route: null, diagnostics };
|
||||
}
|
||||
}
|
||||
connectors.push({ id: movement.connectorId, movementId: movement.id, fromRoadId: movement.fromRoadId, toRoadId: movement.toRoadId, turn: movement.turn });
|
||||
}
|
||||
if (coordinates.length < 4) return { route: null, diagnostics };
|
||||
const distances = cumulativeDistances(coordinates);
|
||||
const lengthMeters = distances.at(-1);
|
||||
if (!(lengthMeters > 30)) return { route: null, diagnostics };
|
||||
const stops = matchStops(coordinates, distances, signals, stopLineFeatures);
|
||||
return { route: {
|
||||
id: `native-loop:${candidate.roadIds.join("-")}`,
|
||||
coordinates,
|
||||
centerlineCoordinates: coordinates,
|
||||
distances,
|
||||
lengthMeters: round(lengthMeters),
|
||||
edgeIds: candidate.roadIds,
|
||||
laneSegments: candidate.roadIds.map((roadId) => ({ roadId, laneId: `lane:${roadId}:1` })),
|
||||
connectors,
|
||||
maneuvers: connectors.map((connector) => connector.turn).filter(Boolean),
|
||||
stops,
|
||||
}, diagnostics };
|
||||
}
|
||||
|
||||
function selectLaneCenterline(laneCenterlineById, road, laneId) {
|
||||
if (!laneCenterlineById || !road) return null;
|
||||
if (laneId && laneCenterlineById.has(laneId)) return laneCenterlineById.get(laneId);
|
||||
const fallback = laneCenterlineById.get(`${road.id}:1`);
|
||||
return fallback || null;
|
||||
}
|
||||
|
||||
function routeDiagnostic(candidate, movement, reason, message) {
|
||||
return { severity: "warning", reason, message, routeRoadIds: candidate.roadIds, movementId: movement.id, fromRoadId: movement.fromRoadId, toRoadId: movement.toRoadId };
|
||||
}
|
||||
|
||||
function matchStops(coordinates, distances, signals, stopLineFeatures) {
|
||||
const stopRoadIds = new Set(stopLineFeatures.map((feature) => feature.properties?.road_id).filter(Boolean));
|
||||
return signals.map((signal) => {
|
||||
if (!Number.isFinite(signal.stopLongitude) || !Number.isFinite(signal.stopLatitude)) return null;
|
||||
let best = { distance: Infinity, routeDistance: 0 };
|
||||
for (let index = 0; index < coordinates.length; index += 1) {
|
||||
const distance = haversine(coordinates[index], [signal.stopLongitude, signal.stopLatitude]);
|
||||
if (distance < best.distance) best = { distance, routeDistance: distances[index] };
|
||||
}
|
||||
if (best.distance > 15 || !stopRoadIds.size) return null;
|
||||
return { signalId: signal.id, phaseGroup: signal.phaseGroup, distance: round(best.routeDistance), matchDistanceMeters: round(best.distance), stopReason: "traffic-signal" };
|
||||
}).filter(Boolean).sort((a, b) => a.distance - b.distance);
|
||||
}
|
||||
|
||||
function signalDescriptor(signal) {
|
||||
return { id: signal.id, phaseGroup: signal.phaseGroup, approachId: signal.approachId, sourceWayId: signal.sourceWayId, stopLongitude: signal.stopLongitude, stopLatitude: signal.stopLatitude, enabled: true };
|
||||
}
|
||||
|
||||
function sourceRecords(area, files) {
|
||||
return files.map((file) => ({ path: path.relative(area.outputs.areaDir, file).split(path.sep).join("/"), bytes: fs.statSync(file).size, sha256: sha256(file) }));
|
||||
}
|
||||
|
||||
function append(target, points) {
|
||||
for (const point of points || []) {
|
||||
const coordinate = point.slice(0, 2).map(Number);
|
||||
if (!coordinate.every(Number.isFinite)) continue;
|
||||
const previous = target.at(-1);
|
||||
if (!previous || previous[0] !== coordinate[0] || previous[1] !== coordinate[1]) target.push(coordinate);
|
||||
}
|
||||
}
|
||||
|
||||
function cumulativeDistances(coordinates) {
|
||||
const distances = [0];
|
||||
for (let index = 1; index < coordinates.length; index += 1) distances.push(distances[index - 1] + haversine(coordinates[index - 1], coordinates[index]));
|
||||
return distances;
|
||||
}
|
||||
|
||||
function haversine(a, b) {
|
||||
const radians = Math.PI / 180;
|
||||
const dLat = (b[1] - a[1]) * radians;
|
||||
const dLon = (b[0] - a[0]) * radians;
|
||||
const lat1 = a[1] * radians;
|
||||
const lat2 = b[1] * radians;
|
||||
const value = Math.sin(dLat / 2) ** 2 + Math.cos(lat1) * Math.cos(lat2) * Math.sin(dLon / 2) ** 2;
|
||||
return 6371008.8 * 2 * Math.atan2(Math.sqrt(value), Math.sqrt(1 - value));
|
||||
}
|
||||
|
||||
function distanceMeters(a, b) {
|
||||
return a && b ? haversine(a, b) : Infinity;
|
||||
}
|
||||
|
||||
function readJson(file, label) {
|
||||
try { return JSON.parse(fs.readFileSync(file, "utf8")); } catch (error) { throw new Error(`Invalid ${label} JSON '${file}': ${error.message}`); }
|
||||
}
|
||||
|
||||
function readFeatureCollection(file, label) {
|
||||
const value = readJson(file, label);
|
||||
if (value?.type !== "FeatureCollection" || !Array.isArray(value.features)) throw new Error(`Invalid ${label} '${file}': expected FeatureCollection`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function validateSettings(settings) {
|
||||
for (const [key, value] of Object.entries(settings)) if (!Number.isFinite(value) || value <= 0) throw new Error(`Native traffic simulation setting '${key}' must be positive`);
|
||||
}
|
||||
|
||||
function sha256(file) { return crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex"); }
|
||||
function round(value) { return Math.round(value * 100) / 100; }
|
||||
|
||||
module.exports = { SCHEMA, DEFAULT_SETTINGS, buildNativeTrafficSimulation };
|
||||
@@ -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
Reference in New Issue
Block a user