Compare commits
32 Commits
feature/na
...
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 |
@@ -8,14 +8,14 @@
|
||||
|
||||
## 两层配置
|
||||
|
||||
用户只写第一层,第二层是机器生成的中间产物:
|
||||
用户只写第一层;legacy QGIS 链路需要时才生成第二层中间产物:
|
||||
|
||||
```
|
||||
config/areas/<id>.json ← 你写的
|
||||
│ scripts/lib/area-config.js: normalizeAreaConfig()
|
||||
│ 补默认值 + 推导输出路径
|
||||
▼
|
||||
<areaDir>/_pipeline/osm2streets-qgis.config.json ← 生成的,不要手改
|
||||
<areaDir>/_pipeline/osm2streets-qgis.config.json ← legacy 生成的,不要手改
|
||||
│
|
||||
▼ build-osm2streets-qgis.js / reimport-gpkg.js
|
||||
```
|
||||
@@ -65,7 +65,7 @@ cp config/examples/template.json config/areas/my-area.json
|
||||
|
||||
| 字段 | 默认 | 说明 |
|
||||
|---|---|---|
|
||||
| `intermediates` | `true` | 旧名 `qgis` 仍被接受 |
|
||||
| `intermediates` | `false` | native-only 默认;旧名 `qgis` 仍被接受,显式开启才运行 legacy |
|
||||
| `blender` | `true` | |
|
||||
| `cesium` | `true` | |
|
||||
| `compress` | `true` | 压缩 staged GLB,供随后发布使用 |
|
||||
@@ -132,7 +132,7 @@ cp config/examples/template.json config/areas/my-area.json
|
||||
|---|---|---|
|
||||
| `treeStyle` | `"natural"` | 合法值见 `generate_scene.py` 的 `TREE_STYLES`(`natural`、`procedural`、`shapespark`) |
|
||||
| `officeOverrides` | `""` | 旧名 `office_overrides` 仍被接受 |
|
||||
| `roadProvider` | `"osm2streets"` | Blender 道路来源。`"native"` 时仅使用 `native-road/` 的道路、路口和人行道面;可由 `build:area --road-provider native` 临时覆盖。 |
|
||||
| `roadProvider` | `"native"` | Blender 道路来源。`"native"` 时仅使用 `native-road/` 的道路、路口和人行道面;`"osm2streets"` 仅用于显式 legacy/debug 构建。 |
|
||||
|
||||
### `compress`
|
||||
|
||||
@@ -187,7 +187,8 @@ transcode,成功后替换**package staging** 中的主 GLB 与 manifest;未
|
||||
|
||||
静态发布路径另有 `packageDir`、`packageStagingDir`、`packageManifest`、
|
||||
`packageStagingManifest`、`packageModelDir`、`packageStagingModelDir` 与
|
||||
`packagePrimaryGlb`;预览路径另有 `previewDir` 与 `previewDescriptor`。除非在迁移旧调用,
|
||||
`packagePrimaryGlb`;预览路径另有 `previewDir`、`previewDescriptor` 与
|
||||
`trafficSimulation`(native preview 的可迁移仿真描述符)。除非在迁移旧调用,
|
||||
不要覆盖 `glb` / `metadata`:它们是 staging 内部路径,不是下游资产入口。
|
||||
|
||||
**优先改 `fileStem` 或 `areaDir`**——它们能一次性影响全部派生路径。逐个覆盖容易漏。
|
||||
|
||||
@@ -414,6 +414,294 @@ GeoJSON,native Blender 构建通过 `catalog.NATIVE_ROAD_LAYERS` 消费它们
|
||||
正确:道路方向箭头进入 `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. 范围与触发条件
|
||||
@@ -1076,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` |
|
||||
@@ -1098,12 +1386,11 @@ gpkg: path.resolve(outputOverrides.gpkg || path.join(areaDir, `${fileStem}.gpkg`
|
||||
|
||||
未知阶段名**抛错并列出合法值**(`:181`),不静默忽略。
|
||||
|
||||
### `all` 不含 `reimport`
|
||||
### `all` 是 native-only 完整构建
|
||||
|
||||
```js
|
||||
// 'reimport' is deliberately absent from 'all': it is a recovery step for
|
||||
// hand-edited GeoPackages, never part of a full build. build-area.js:159-160
|
||||
all: ["intermediates", "blender", "cesium", "compress", "package", "preview"],
|
||||
// Legacy intermediates/reimport are deliberately absent from 'all'.
|
||||
all: ["blender", "cesium", "compress", "package", "preview"],
|
||||
```
|
||||
|
||||
`compress`、`package`、`preview` 均在 `all` 中,确保完整构建以压缩后的可复用静态 package
|
||||
|
||||
@@ -30,7 +30,7 @@ config/areas/<id>.json
|
||||
▼ build-area.js — 阶段调度
|
||||
_pipeline/osm2streets-qgis.config.json (派生配置)
|
||||
│
|
||||
├─[intermediates]─▶ build-osm2streets-qgis.js
|
||||
├─[intermediates]─▶ build-osm2streets-qgis.js(显式 legacy/reference)
|
||||
│ osm2streets-js-node 解析 .osm
|
||||
│ → splitLayers() 拆成九个图层
|
||||
│ → normalize-lane-arrows.py(QGIS Python)
|
||||
@@ -45,8 +45,8 @@ config/areas/<id>.json
|
||||
│ → 重建 scene.geojson + scene_style.json
|
||||
│ → _pipeline/stages/reimport.manifest.json
|
||||
│
|
||||
├─[blender]───────▶ Blender + blender/generate_scene.py
|
||||
│ 读 .osm + osm2streets_web_out/
|
||||
├─[blender]───────▶ compile-native-roads.js + Blender + blender/generate_scene.py
|
||||
│ 默认读 .osm + native-road/
|
||||
│ → <id>.blend + <id>.png
|
||||
│ → _pipeline/stages/blender.manifest.json
|
||||
│
|
||||
@@ -62,7 +62,7 @@ config/areas/<id>.json
|
||||
│ → package/manifest.json + package/models/*.glb
|
||||
│ → _pipeline/stages/package.manifest.json
|
||||
│
|
||||
└─[preview]───────▶ 生成 <id>-cesium-preview.html
|
||||
└─[preview]───────▶ 生成 <id>-cesium-preview.html(native 可无车辆路线)
|
||||
│ + 拷贝 lib/cesium-preview.{js,css}
|
||||
+ _preview/ 车辆巡航路线、模型、动态信号
|
||||
│ → _pipeline/stages/preview.manifest.json
|
||||
|
||||
@@ -31,7 +31,7 @@ classifyConnection(incomingEdge, outgoingEdge) =>
|
||||
`maneuvers`、`edgeIds`、`laneSegments` 与 `connectors`。道路区间来自匹配的 Driving lane polygon 中轴。
|
||||
- 路线拓扑以 `network.json` 的 internal road 和 intersection 为准;禁止把整个 OSM way 直接当作一条不可分割 edge。
|
||||
- connector 必须绑定同一个 internal intersection,并位于对应 `intersection_surface.geojson` 内或允许的边界容差内;越界时拒绝候选路线。
|
||||
- preview 必须将 `lane_polygons.geojson`、`network.json` 和 `intersection_surface.geojson` 作为强制输入;缺失或无效时在写产物前失败。
|
||||
- legacy osm2streets preview 必须将 `lane_polygons.geojson`、`network.json` 和 `intersection_surface.geojson` 作为强制输入;native preview 不读取这些文件,路线缺失时保留可用预览并省略车辆巡航。
|
||||
- route 经纬度由 Cesium 按 WGS84 直接放置;最终道路 GLB 必须由 WGS84 ECEF→ENU
|
||||
`Projector` 生成。禁止以固定米/度近似投影道路,否则即使 route 与 lane polygon
|
||||
完全一致,最终画面仍会随离锚点距离产生横向偏移。
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
{"_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.
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"id": "native-preview-traffic-simulation",
|
||||
"name": "native-preview-traffic-simulation",
|
||||
"title": "Native preview traffic simulation and migration contract",
|
||||
"description": "",
|
||||
"status": "in_progress",
|
||||
"dev_type": null,
|
||||
"scope": null,
|
||||
"package": null,
|
||||
"priority": "P2",
|
||||
"creator": "dingkang",
|
||||
"assignee": "dingkang",
|
||||
"createdAt": "2026-08-18",
|
||||
"completedAt": null,
|
||||
"branch": null,
|
||||
"base_branch": "feature/native-road-compiler",
|
||||
"worktree_path": null,
|
||||
"commit": null,
|
||||
"pr_url": null,
|
||||
"subtasks": [],
|
||||
"children": [],
|
||||
"parent": null,
|
||||
"relatedFiles": [],
|
||||
"notes": "",
|
||||
"meta": {}
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
"name": "native-road-compiler",
|
||||
"title": "Native road compiler workbench",
|
||||
"description": "",
|
||||
"status": "in_progress",
|
||||
"status": "completed",
|
||||
"dev_type": null,
|
||||
"scope": null,
|
||||
"package": null,
|
||||
@@ -11,7 +11,7 @@
|
||||
"creator": "dingkang",
|
||||
"assignee": "dingkang",
|
||||
"createdAt": "2026-08-13",
|
||||
"completedAt": null,
|
||||
"completedAt": "2026-08-18",
|
||||
"branch": null,
|
||||
"base_branch": "feature/native-road-compiler",
|
||||
"worktree_path": null,
|
||||
@@ -19,7 +19,11 @@
|
||||
"pr_url": null,
|
||||
"subtasks": [],
|
||||
"children": [
|
||||
"08-14-native-road-lane-markings"
|
||||
"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": [],
|
||||
@@ -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**: 31
|
||||
- **Last Active**: 2026-08-14
|
||||
- **Total Sessions**: 39
|
||||
- **Last Active**: 2026-08-18
|
||||
<!-- @@@/auto:current-status -->
|
||||
|
||||
---
|
||||
@@ -19,7 +19,7 @@
|
||||
<!-- @@@auto:active-documents -->
|
||||
| File | Lines | Status |
|
||||
|------|-------|--------|
|
||||
| `journal-1.md` | ~663 | Active |
|
||||
| `journal-1.md` | ~845 | Active |
|
||||
<!-- @@@/auto:active-documents -->
|
||||
|
||||
---
|
||||
@@ -29,6 +29,14 @@
|
||||
<!-- @@@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` |
|
||||
|
||||
@@ -661,3 +661,185 @@ Implemented native lane separators, repeated road direction arrows, explicit jun
|
||||
### 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 工程会一并同步
|
||||
|
||||
|
||||
@@ -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"]),
|
||||
@@ -799,11 +803,24 @@ def build(args):
|
||||
target = source["material_layer"]
|
||||
layer = material_layers[target]
|
||||
source_path = os.path.join(native_road_dir, "layers", source["source"] + ".geojson")
|
||||
count = _roads.assemble_geojson_layer(
|
||||
source_path, source["source"], projector, roads_c,
|
||||
road_mats[target], layer["z"])
|
||||
if count == 0:
|
||||
raise RuntimeError("Native road layer has no usable geometry: " + source_path)
|
||||
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):
|
||||
@@ -818,7 +835,7 @@ def build(args):
|
||||
_roads.assemble_osm_fallback(
|
||||
ways, projector, roads_c, road_mats["road_surface"])
|
||||
|
||||
traffic_signal_path = os.path.join(geojson_dir or "", "traffic_signals.json")
|
||||
traffic_signal_path = args.get("traffic_signals") or os.path.join(geojson_dir or "", "traffic_signals.json")
|
||||
dynamic_signal_objects = 0
|
||||
if os.path.exists(traffic_signal_path):
|
||||
try:
|
||||
|
||||
@@ -52,11 +52,15 @@ SCENE_STYLE_FILE = "osm2streets_scene_style.json"
|
||||
# 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"},
|
||||
)
|
||||
|
||||
|
||||
@@ -197,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")):
|
||||
|
||||
@@ -52,8 +52,15 @@ class RoadLayerCatalogTest(unittest.TestCase):
|
||||
[(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")])
|
||||
("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))
|
||||
|
||||
|
||||
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,
|
||||
@@ -34,7 +37,7 @@
|
||||
"blender": {
|
||||
"treeStyle": "natural",
|
||||
"officeOverrides": "",
|
||||
"roadProvider": "osm2streets"
|
||||
"roadProvider": "native"
|
||||
},
|
||||
"compress": {
|
||||
"textureSize": 768,
|
||||
|
||||
64
docs/native-preview-traffic-simulation.md
Normal file
64
docs/native-preview-traffic-simulation.md
Normal file
@@ -0,0 +1,64 @@
|
||||
# Native Preview Traffic Simulation
|
||||
|
||||
This document defines the portable preview contract for native road traffic demonstrations. It is
|
||||
not a legal navigation format or a full microscopic traffic simulator.
|
||||
|
||||
## Data Flow
|
||||
|
||||
```text
|
||||
native-road/compiled.json + layers/
|
||||
-> native-preview-traffic-simulation/v1 descriptor
|
||||
package/runtime/traffic-signals.json
|
||||
-> signal phase clock and stop constraints
|
||||
descriptor + package/manifest.json
|
||||
-> Cesium preview vehicles, signals, queues, diagnostics
|
||||
```
|
||||
|
||||
The descriptor is generated from native artifacts and is independent of QGIS/osm2streets. Copying
|
||||
the descriptor, package, and preview runtime to another platform is sufficient to reproduce the
|
||||
visual simulation.
|
||||
|
||||
## Existing Signal Contract
|
||||
|
||||
Signal identity is `signal_uid`; it must not be renamed when `display_id` changes. `phase_group`
|
||||
selects the existing signal phase. `mast_heading_deg`, `face_heading_deg`, `pose.*`, stop-line
|
||||
coordinates, and `enabled` are consumed from `package/runtime/traffic-signals.json`. Disabled signals
|
||||
are absent from runtime control. Consumers must not recalculate lamp or pole geometry.
|
||||
|
||||
## Vehicle Rules
|
||||
|
||||
Each vehicle advances along a route distance. Its target speed is the minimum of desired speed,
|
||||
signal-safe speed, and leader-safe speed. Red/yellow signals create a stop target before the native
|
||||
stop line. Following vehicles apply the configured minimum gap and decelerate behind the leader.
|
||||
The simulation is deterministic for a fixed descriptor, clock start, and settings.
|
||||
|
||||
The descriptor records WGS84 route coordinates. The static package remains local ENU and is placed by
|
||||
the package manifest. Consumers must use the package placement/model matrix for GLB assets and WGS84
|
||||
coordinates for route entities; do not mix a fixed meters-per-degree approximation into either path.
|
||||
|
||||
Route geometry is taken from `native-road/layers/lane_centerlines.geojson` when that native layer is
|
||||
available, keyed by `road_id`; the compiled road centerline is only a compatibility fallback for old
|
||||
native outputs. Each road/connector boundary is checked in meters. Candidates with a missing
|
||||
connector or an endpoint gap over 35 m are omitted from `routes` and retained in `rejectedRoutes`
|
||||
with structured diagnostics. This prevents a malformed movement from becoming a visible diagonal
|
||||
track across an intersection.
|
||||
|
||||
`diagnostics` contains the route-generation warnings, while `rejectedRoutes` preserves the candidate
|
||||
id and the specific movement checks that failed. A consumer should display these as data-quality
|
||||
warnings rather than silently reconstructing a route from legacy QGIS files.
|
||||
|
||||
## Migration Checklist
|
||||
|
||||
1. Load `package/manifest.json` and resolve all package-relative assets.
|
||||
2. Load the traffic-signal runtime and preserve `signal_uid`/`phase_group` values.
|
||||
3. Load the versioned simulation descriptor and verify its source hashes if reproducibility matters.
|
||||
4. Implement the update loop using the descriptor settings and stop/leader constraints.
|
||||
5. Place route coordinates in the same WGS84 scene as the package placement and preserve ENU model
|
||||
placement for static assets.
|
||||
6. Expose route count, signal count, stopped vehicles, and queue length as diagnostics.
|
||||
|
||||
## Rollback
|
||||
|
||||
Deleting or ignoring the optional simulation descriptor returns the native preview to a usable static
|
||||
scene with signal visualization but no vehicle cruise. The QGIS baseline remains available through
|
||||
`qgis-original-baseline-20260818`; it is not required by this contract.
|
||||
15593
inputs/osm/枫树二路.osm
Normal file
15593
inputs/osm/枫树二路.osm
Normal file
File diff suppressed because it is too large
Load Diff
@@ -25,6 +25,7 @@
|
||||
"test:package-examples": "node scripts/test-package-examples.js",
|
||||
"test:turn-lane-arrows": "node scripts/test-turn-lane-arrows.js",
|
||||
"test:traffic-signals": "node scripts/test-traffic-signals.js",
|
||||
"test:native-preview-traffic": "node scripts/test-native-preview-traffic.js",
|
||||
"render:turn-lane-arrow-samples": "node scripts/render-turn-lane-arrow-samples.js"
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
@@ -16,6 +16,7 @@ const {
|
||||
} = require("./lib/scene-layers");
|
||||
const { digest: glbDigest } = require("./glb-digest");
|
||||
const { buildVehicleRoute: buildPreviewVehicleRoute } = require("./lib/vehicle-route");
|
||||
const { buildNativeTrafficSimulation } = require("./lib/native-preview-traffic-simulation");
|
||||
const { writePreviewVehicleLibrary } = require("./lib/vehicle-library");
|
||||
const { readTrafficSignals } = require("./lib/traffic-signals");
|
||||
const {
|
||||
@@ -69,10 +70,10 @@ if (stages.compress) {
|
||||
compressCesiumGlb(area);
|
||||
}
|
||||
if (stages.package) {
|
||||
publishPackage(area);
|
||||
publishPackage(area, roadProvider);
|
||||
}
|
||||
if (stages.preview) {
|
||||
writeCesiumPreview(area);
|
||||
writeCesiumPreview(area, roadProvider);
|
||||
}
|
||||
|
||||
console.log("Done.");
|
||||
@@ -247,6 +248,7 @@ function buildBlenderScene(area, roadProvider) {
|
||||
];
|
||||
if (roadProvider === "native") {
|
||||
blenderArgs.push("--native-road", area.outputs.nativeRoadDir);
|
||||
blenderArgs.push("--traffic-signals", path.join(area.outputs.nativeRoadDir, "traffic-signals.json"));
|
||||
} else {
|
||||
blenderArgs.push("--geojson", area.outputs.geojsonDir);
|
||||
}
|
||||
@@ -292,7 +294,7 @@ function buildBlenderScene(area, roadProvider) {
|
||||
|
||||
function ensureNativeRoadLayers(area) {
|
||||
ensureFile(path.join(area.outputs.nativeRoadDir, "compiled.json"), "Native road compilation");
|
||||
for (const file of ["road_surface.geojson", "intersection_surface.geojson", "sidewalk_surface.geojson", "lane_separators.geojson", "direction_arrows.geojson", "turn_arrows.geojson"]) {
|
||||
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}`);
|
||||
}
|
||||
}
|
||||
@@ -302,11 +304,15 @@ function nativeRoadRecords(area) {
|
||||
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")),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -314,11 +320,15 @@ 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")),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -329,11 +339,9 @@ function exportCesium(area, roadProvider) {
|
||||
fs.rmSync(area.outputs.packageStagingDir, { recursive: true, force: true });
|
||||
fs.mkdirSync(path.dirname(area.outputs.glb), { recursive: true });
|
||||
fs.mkdirSync(path.dirname(area.outputs.metadata), { recursive: true });
|
||||
if (roadProvider === "osm2streets") {
|
||||
fs.mkdirSync(path.dirname(area.outputs.trafficSignalsDynamicGlb), { recursive: true });
|
||||
fs.mkdirSync(path.dirname(area.outputs.trafficSignalsCountdown0Glb), { recursive: true });
|
||||
fs.mkdirSync(path.dirname(area.outputs.trafficSignalsCountdown1Glb), { recursive: true });
|
||||
}
|
||||
fs.mkdirSync(path.dirname(area.outputs.trafficSignalsDynamicGlb), { recursive: true });
|
||||
fs.mkdirSync(path.dirname(area.outputs.trafficSignalsCountdown0Glb), { recursive: true });
|
||||
fs.mkdirSync(path.dirname(area.outputs.trafficSignalsCountdown1Glb), { recursive: true });
|
||||
|
||||
console.log("Stage: cesium");
|
||||
const started = Date.now();
|
||||
@@ -354,19 +362,15 @@ function exportCesium(area, roadProvider) {
|
||||
"--metadata",
|
||||
area.outputs.metadata,
|
||||
];
|
||||
if (roadProvider === "osm2streets") {
|
||||
exporterArgs.splice(-2, 0,
|
||||
"--dynamic-glb", area.outputs.trafficSignalsDynamicGlb,
|
||||
"--countdown-0-glb", area.outputs.trafficSignalsCountdown0Glb,
|
||||
"--countdown-1-glb", area.outputs.trafficSignalsCountdown1Glb,
|
||||
);
|
||||
}
|
||||
exporterArgs.splice(-2, 0,
|
||||
"--dynamic-glb", area.outputs.trafficSignalsDynamicGlb,
|
||||
"--countdown-0-glb", area.outputs.trafficSignalsCountdown0Glb,
|
||||
"--countdown-1-glb", area.outputs.trafficSignalsCountdown1Glb,
|
||||
);
|
||||
runCommand(blenderExecutable(area), exporterArgs, "cesium");
|
||||
if (roadProvider === "osm2streets") {
|
||||
ensureFile(area.outputs.trafficSignalsDynamicGlb, "Dynamic traffic signal GLB");
|
||||
ensureFile(area.outputs.trafficSignalsCountdown0Glb, "Traffic countdown group 0 GLB");
|
||||
ensureFile(area.outputs.trafficSignalsCountdown1Glb, "Traffic countdown group 1 GLB");
|
||||
}
|
||||
ensureFile(area.outputs.trafficSignalsDynamicGlb, "Dynamic traffic signal GLB");
|
||||
ensureFile(area.outputs.trafficSignalsCountdown0Glb, "Traffic countdown group 0 GLB");
|
||||
ensureFile(area.outputs.trafficSignalsCountdown1Glb, "Traffic countdown group 1 GLB");
|
||||
const semanticAssets = semanticAssetRecords(area);
|
||||
const finished = Date.now();
|
||||
const digest = glbDigest(area.outputs.glb);
|
||||
@@ -383,9 +387,9 @@ function exportCesium(area, roadProvider) {
|
||||
outputs: {
|
||||
glb: fileRecord(area.outputs.glb),
|
||||
metadata: fileRecord(area.outputs.metadata),
|
||||
...(roadProvider === "osm2streets" ? {
|
||||
trafficSignalsDynamicGlb: fileRecord(area.outputs.trafficSignalsDynamicGlb),
|
||||
} : {}),
|
||||
trafficSignalsDynamicGlb: fileRecord(area.outputs.trafficSignalsDynamicGlb),
|
||||
trafficSignalsCountdown0Glb: fileRecord(area.outputs.trafficSignalsCountdown0Glb),
|
||||
trafficSignalsCountdown1Glb: fileRecord(area.outputs.trafficSignalsCountdown1Glb),
|
||||
semanticAssets,
|
||||
},
|
||||
summary: {
|
||||
@@ -482,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" });
|
||||
@@ -542,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"));
|
||||
@@ -578,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,29 +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", sidewalkSurface: "layers/sidewalk_surface.geojson", intersectionSurface: "layers/intersection_surface.geojson", laneCenterlines: "layers/lane_centerlines.geojson", laneSeparators: "layers/lane_separators.geojson", directionArrows: "layers/direction_arrows.geojson", turnArrows: "layers/turn_arrows.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);
|
||||
@@ -84,8 +96,11 @@ function compareOsm2Streets(area, model, compiled) {
|
||||
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,7 +124,7 @@ function normalizeAreaConfig(raw, options = {}) {
|
||||
blender: {
|
||||
treeStyle: raw.blender?.treeStyle || "natural",
|
||||
officeOverrides: raw.blender?.officeOverrides || raw.blender?.office_overrides || "",
|
||||
roadProvider: roadProviderOption(raw.blender?.roadProvider),
|
||||
roadProvider: roadProviderOption(raw.blender?.roadProvider ?? "native"),
|
||||
},
|
||||
compress,
|
||||
budget,
|
||||
|
||||
@@ -323,13 +323,17 @@ function addEndpoint(map, ref) {
|
||||
}
|
||||
|
||||
function artifactStatus(area) {
|
||||
const native = area.blender.roadProvider === "native";
|
||||
const entries = [
|
||||
["GeoJSON dir", area.outputs.geojsonDir, true, "dir"],
|
||||
["GeoPackage", area.outputs.gpkg, true, "file"],
|
||||
["QGIS project", area.outputs.qgisProject, true, "file"],
|
||||
["QGIS preview", area.outputs.qgisPreview, true, "file"],
|
||||
["Traffic signal assemblies", area.outputs.trafficSignalAssemblies, true, "file"],
|
||||
["Traffic signal runtime", area.outputs.packageTrafficSignals, true, "file"],
|
||||
...(native ? [] : [
|
||||
["GeoJSON dir", area.outputs.geojsonDir, true, "dir"],
|
||||
["GeoPackage", area.outputs.gpkg, true, "file"],
|
||||
["QGIS project", area.outputs.qgisProject, true, "file"],
|
||||
["QGIS preview", area.outputs.qgisPreview, true, "file"],
|
||||
["Traffic signal assemblies", area.outputs.trafficSignalAssemblies, true, "file"],
|
||||
]),
|
||||
["Native road directory", area.outputs.nativeRoadDir, native, "dir"],
|
||||
["Traffic signal runtime", native ? path.join(area.outputs.nativeRoadDir, "traffic-signals.json") : area.outputs.packageTrafficSignals, true, "file"],
|
||||
["Blend scene", area.outputs.blend, true, "file"],
|
||||
["Render PNG", area.outputs.render, true, "file"],
|
||||
["Package manifest", area.outputs.packageManifest, true, "file"],
|
||||
@@ -373,6 +377,7 @@ function metadataSummary(file, warnings) {
|
||||
}
|
||||
|
||||
function stageManifestStatus(area, configPath = null) {
|
||||
const native = area.blender.roadProvider === "native";
|
||||
const compressionComplete = fs.existsSync(stageManifestPath(area, "compress"));
|
||||
const reimportManifest = stageManifestPath(area, "reimport");
|
||||
const hasReimportManifest = fs.existsSync(reimportManifest);
|
||||
@@ -430,10 +435,12 @@ function stageManifestStatus(area, configPath = null) {
|
||||
inputs: {
|
||||
...(configPath ? { config: configPath } : {}),
|
||||
osm: area.input,
|
||||
geojsonDir: area.outputs.geojsonDir,
|
||||
...sceneGeojsonFiles(area),
|
||||
trafficSignalAssemblies: area.outputs.trafficSignalAssemblies,
|
||||
trafficSignals: area.outputs.packageTrafficSignals,
|
||||
...(native ? nativeRoadRecords(area) : {
|
||||
geojsonDir: area.outputs.geojsonDir,
|
||||
...sceneGeojsonFiles(area),
|
||||
trafficSignalAssemblies: area.outputs.trafficSignalAssemblies,
|
||||
trafficSignals: area.outputs.packageTrafficSignals,
|
||||
}),
|
||||
},
|
||||
outputs: {
|
||||
blend: area.outputs.blend,
|
||||
@@ -461,18 +468,23 @@ function stageManifestStatus(area, configPath = null) {
|
||||
glb: area.outputs.glb,
|
||||
metadata: area.outputs.metadata,
|
||||
}),
|
||||
lanePolygons: path.join(area.outputs.geojsonDir, "lane_polygons.geojson"),
|
||||
network: path.join(area.outputs.geojsonDir, "network.json"),
|
||||
intersectionSurface: path.join(area.outputs.geojsonDir, "intersection_surface.geojson"),
|
||||
...(native ? nativeRoadRecords(area) : {
|
||||
lanePolygons: path.join(area.outputs.geojsonDir, "lane_polygons.geojson"),
|
||||
network: path.join(area.outputs.geojsonDir, "network.json"),
|
||||
intersectionSurface: path.join(area.outputs.geojsonDir, "intersection_surface.geojson"),
|
||||
}),
|
||||
...(native ? { trafficSimulation: area.outputs.trafficSimulation } : {}),
|
||||
previewCss: path.join(path.resolve(__dirname, ".."), "lib", "cesium-preview.css"),
|
||||
previewJs: path.join(path.resolve(__dirname, ".."), "lib", "cesium-preview.js"),
|
||||
},
|
||||
outputs: compressionComplete ? {
|
||||
vehicleRoute: area.outputs.vehicleRoute,
|
||||
vehicleRoute: native ? optionalExpectedFile(area.outputs.vehicleRoute) : area.outputs.vehicleRoute,
|
||||
...(native ? { trafficSimulation: area.outputs.trafficSimulation } : {}),
|
||||
vehicleModel: area.outputs.vehicleModel,
|
||||
} : {
|
||||
cesiumPreview: area.outputs.cesiumPreview,
|
||||
vehicleRoute: area.outputs.vehicleRoute,
|
||||
vehicleRoute: native ? optionalExpectedFile(area.outputs.vehicleRoute) : area.outputs.vehicleRoute,
|
||||
...(native ? { trafficSimulation: area.outputs.trafficSimulation } : {}),
|
||||
vehicleModel: area.outputs.vehicleModel,
|
||||
},
|
||||
},
|
||||
@@ -597,6 +609,24 @@ function sceneGeojsonFiles(area) {
|
||||
return files;
|
||||
}
|
||||
|
||||
function nativeRoadRecords(area) {
|
||||
const root = path.join(area.outputs.nativeRoadDir, "layers");
|
||||
return {
|
||||
nativeRoadCompiled: path.join(area.outputs.nativeRoadDir, "compiled.json"),
|
||||
nativeRoadSignals: path.join(area.outputs.nativeRoadDir, "traffic-signals.json"),
|
||||
nativeRoadSurface: path.join(root, "road_surface.geojson"),
|
||||
nativeEdgeLines: path.join(root, "edge_lines.geojson"),
|
||||
nativeIntersectionSurface: path.join(root, "intersection_surface.geojson"),
|
||||
nativeSidewalkSurface: path.join(root, "sidewalk_surface.geojson"),
|
||||
nativeLaneSeparators: path.join(root, "lane_separators.geojson"),
|
||||
nativeCenterLines: path.join(root, "center_lines.geojson"),
|
||||
nativeDirectionArrows: path.join(root, "direction_arrows.geojson"),
|
||||
nativeTurnArrows: path.join(root, "turn_arrows.geojson"),
|
||||
nativeCrosswalks: path.join(root, "crosswalks.geojson"),
|
||||
nativeVehicleStopLines: path.join(root, "vehicle_stop_lines.geojson"),
|
||||
};
|
||||
}
|
||||
|
||||
function collectWarnings(area, osm, artifacts, manifests, glb, metadata) {
|
||||
const warnings = [];
|
||||
if (!osm.bounds) warnings.push("OSM has no valid <bounds>; scene extent may be wrong.");
|
||||
|
||||
@@ -123,15 +123,15 @@ function escapeScriptJson(value) {
|
||||
.replaceAll("\u2029", "\\u2029");
|
||||
}
|
||||
|
||||
function previewSummary(area) {
|
||||
const route = JSON.parse(fs.readFileSync(area.outputs.vehicleRoute, "utf8"));
|
||||
function previewSummary(area, routePath = area.outputs.vehicleRoute) {
|
||||
const route = routePath && fs.existsSync(routePath) ? JSON.parse(fs.readFileSync(routePath, "utf8")) : null;
|
||||
return {
|
||||
glbName: "package/manifest.json",
|
||||
metadataName: "package/manifest.json",
|
||||
routeName: path.relative(area.outputs.areaDir, area.outputs.vehicleRoute).split(path.sep).join("/"),
|
||||
routeName: route ? path.relative(area.outputs.areaDir, routePath).split(path.sep).join("/") : null,
|
||||
vehicleModelName: path.relative(area.outputs.areaDir, area.outputs.vehicleModel).split(path.sep).join("/"),
|
||||
trafficSignalsName: "package/runtime/traffic-signals.json",
|
||||
routeSegments: Array.isArray(route.segments) ? route.segments.length : null,
|
||||
routeSegments: Array.isArray(route?.routes) ? route.routes.length : Array.isArray(route?.segments) ? route.segments.length : null,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use strict";
|
||||
|
||||
const STAGES = [
|
||||
{ id: "intermediates", label: "OSM / QGIS intermediates", description: "Rebuild GeoJSON and GeoPackage from OSM" },
|
||||
{ id: "intermediates", label: "Legacy QGIS intermediates", description: "Explicit legacy osm2streets/QGIS reference build" },
|
||||
{ id: "reimport", label: "Reimport QGIS edits", description: "Copy GeoPackage layers back to GeoJSON" },
|
||||
{ id: "blender", label: "Blender scene", description: "Generate the editable scene and render" },
|
||||
{ id: "cesium", label: "Cesium export", description: "Export static package staging assets" },
|
||||
@@ -11,7 +11,7 @@ const STAGES = [
|
||||
];
|
||||
|
||||
const ALIASES = {
|
||||
all: ["intermediates", "blender", "cesium", "compress", "package", "preview"],
|
||||
all: ["blender", "cesium", "compress", "package", "preview"],
|
||||
qgis: ["intermediates"], osm2streets: ["intermediates"], geojson: ["intermediates"], intermediate: ["intermediates"],
|
||||
intermediates: ["intermediates"], reimport: ["reimport"], gpkg: ["reimport"], blender: ["blender"], scene: ["blender"],
|
||||
cesium: ["cesium"], glb: ["cesium"], preview: ["preview"], html: ["preview"], cesiumPreview: ["preview"],
|
||||
|
||||
@@ -88,6 +88,7 @@
|
||||
// The route file is an extra on top of the scene, not a precondition for it.
|
||||
// A missing or unreadable route costs the cruise controls, not the preview.
|
||||
async function fetchOptionalJson(url) {
|
||||
if (!url) return null;
|
||||
try {
|
||||
return await fetchJson(url);
|
||||
} catch (error) {
|
||||
@@ -554,16 +555,27 @@
|
||||
.slice(0, 5);
|
||||
const speed = Number((routeData && routeData.speedMetersPerSecond) || 8);
|
||||
const start = trafficStart;
|
||||
const assignments = [];
|
||||
segments.forEach((segment, routeIndex) => {
|
||||
// Put two vehicles on the first route so the native preview visibly
|
||||
// exercises leader following and queue formation.
|
||||
const vehicleCount = routeIndex === 0 ? 2 : 1;
|
||||
for (let vehicleIndex = 0; vehicleIndex < vehicleCount; vehicleIndex += 1) {
|
||||
assignments.push({ segment, routeIndex, vehicleIndex });
|
||||
}
|
||||
});
|
||||
const simulation = createTrafficSimulation(viewer, assignments, start, speed, signalData, routeData?.settings);
|
||||
|
||||
viewer.clock.startTime = start.clone();
|
||||
viewer.clock.currentTime = start.clone();
|
||||
viewer.clock.clockRange = Cesium.ClockRange.UNBOUNDED;
|
||||
viewer.clock.multiplier = 1;
|
||||
viewer.clock.shouldAnimate = segments.length > 0;
|
||||
viewer.clock.shouldAnimate = assignments.length > 0;
|
||||
|
||||
const vehicles = segments.map((segment, index) => {
|
||||
const vehicles = assignments.map((assignment, index) => {
|
||||
const { segment } = assignment;
|
||||
const vehicle = addCruiseVehicle(viewer, segment, index, start, speed, signalData,
|
||||
selectedVehicleModelName(vehicleModelNames, fallbackVehicleModelName));
|
||||
selectedVehicleModelName(vehicleModelNames, fallbackVehicleModelName), simulation.motions[index]);
|
||||
const option = document.createElement("option");
|
||||
option.value = String(index);
|
||||
option.textContent = routeLabel(segment, index);
|
||||
@@ -573,7 +585,8 @@
|
||||
const cruise = {
|
||||
vehicles,
|
||||
baseSpeed: speed,
|
||||
state: { selectedIndex: 0 }
|
||||
state: { selectedIndex: 0 },
|
||||
simulation,
|
||||
};
|
||||
syncSelectedRouteVisibility(cruise);
|
||||
return cruise;
|
||||
@@ -731,10 +744,10 @@
|
||||
return usable[Math.floor(Math.random() * usable.length)] || "";
|
||||
}
|
||||
|
||||
function addCruiseVehicle(viewer, segment, index, start, speed, signalData, vehicleModelName) {
|
||||
const route = prepareRoute(segment, signalData);
|
||||
function addCruiseVehicle(viewer, segment, index, start, speed, signalData, vehicleModelName, trafficMotion) {
|
||||
const route = trafficMotion.route;
|
||||
let record = null;
|
||||
const trafficMotion = createTrafficAwarePositions(viewer, route, start, speed, () => record?.status === "normal");
|
||||
trafficMotion.setMoving(() => record?.status === "normal");
|
||||
const positions = trafficMotion.positions;
|
||||
const flat = [];
|
||||
for (const coord of segment.coordinates) {
|
||||
@@ -945,6 +958,106 @@
|
||||
return [...found.values()].sort((a, b) => a.distance - b.distance);
|
||||
}
|
||||
|
||||
function createTrafficSimulation(viewer, assignments, start, speed, signalData, settings = {}) {
|
||||
const config = {
|
||||
desiredSpeedMetersPerSecond: Number(settings.desiredSpeedMetersPerSecond || speed),
|
||||
accelerationMetersPerSecondSquared: Number(settings.accelerationMetersPerSecondSquared || 1.8),
|
||||
decelerationMetersPerSecondSquared: Number(settings.decelerationMetersPerSecondSquared || 3.5),
|
||||
reactionTimeSeconds: Number(settings.reactionTimeSeconds || 0.8),
|
||||
vehicleLengthMeters: Number(settings.vehicleLengthMeters || 4.6),
|
||||
minimumGapMeters: Number(settings.minimumGapMeters || 7),
|
||||
};
|
||||
const motions = assignments.map((assignment) => {
|
||||
const route = prepareRoute(assignment.segment, signalData);
|
||||
const initialGap = config.vehicleLengthMeters + config.minimumGapMeters;
|
||||
const state = {
|
||||
distance: assignment.vehicleIndex * -initialGap,
|
||||
speed: 0,
|
||||
targetSpeed: config.desiredSpeedMetersPerSecond,
|
||||
stopReason: null,
|
||||
queueAhead: 0,
|
||||
lastTime: start.clone(),
|
||||
};
|
||||
state.distance = (state.distance % route.length + route.length) % route.length;
|
||||
return {
|
||||
route,
|
||||
state,
|
||||
setMoving(callback) { state.isMoving = callback; },
|
||||
positions: new Cesium.CallbackProperty((time, result) => routePositionAtDistance(route, state.distance, result), false),
|
||||
};
|
||||
});
|
||||
const update = (clock) => {
|
||||
const elapsed = Cesium.JulianDate.secondsDifference(clock.currentTime, motions[0]?.state.lastTime || start);
|
||||
if (!(elapsed > 0) || elapsed > 2) {
|
||||
for (const motion of motions) Cesium.JulianDate.clone(clock.currentTime, motion.state.lastTime);
|
||||
return;
|
||||
}
|
||||
const grouped = new Map();
|
||||
assignments.forEach((assignment, index) => {
|
||||
const key = assignment.routeIndex;
|
||||
if (!grouped.has(key)) grouped.set(key, []);
|
||||
grouped.get(key).push(index);
|
||||
});
|
||||
for (const indexes of grouped.values()) {
|
||||
indexes.sort((a, b) => motions[a].state.distance - motions[b].state.distance);
|
||||
}
|
||||
motions.forEach((motion, index) => {
|
||||
const state = motion.state;
|
||||
Cesium.JulianDate.clone(clock.currentTime, state.lastTime);
|
||||
if (state.isMoving && !state.isMoving()) {
|
||||
state.speed = 0;
|
||||
state.stopReason = "incident";
|
||||
return;
|
||||
}
|
||||
const route = motion.route;
|
||||
let target = config.desiredSpeedMetersPerSecond;
|
||||
let stopReason = null;
|
||||
const nextStop = nextRouteStop(route, state.distance);
|
||||
if (nextStop) {
|
||||
const phase = signalPhase(nextStop.signal.phaseGroup, clock.currentTime, start).active;
|
||||
const untilStop = (nextStop.distance - state.distance + route.length) % route.length;
|
||||
if (phase !== "green" && untilStop < Math.max(30, state.speed * config.reactionTimeSeconds + 8)) {
|
||||
target = Math.min(target, Math.max(0, (untilStop - 1.7) / Math.max(config.reactionTimeSeconds, 0.1)));
|
||||
stopReason = "traffic-signal";
|
||||
}
|
||||
}
|
||||
const peers = grouped.get(assignments[index].routeIndex) || [];
|
||||
const peerPosition = peers.indexOf(index);
|
||||
if (peerPosition >= 0 && peers.length > 1) {
|
||||
const leaderIndex = peers[(peerPosition + 1) % peers.length];
|
||||
if (leaderIndex !== index) {
|
||||
const leader = motions[leaderIndex].state;
|
||||
const gap = (leader.distance - state.distance + route.length) % route.length - config.vehicleLengthMeters;
|
||||
const safeGap = config.minimumGapMeters + state.speed * config.reactionTimeSeconds;
|
||||
if (gap < safeGap + 12) {
|
||||
target = Math.min(target, Math.max(0, (gap - config.minimumGapMeters) / Math.max(config.reactionTimeSeconds, 0.1)));
|
||||
if (target < 0.2) stopReason = "leader-gap";
|
||||
state.queueAhead = leader.stopReason ? 1 : 0;
|
||||
} else {
|
||||
state.queueAhead = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
state.targetSpeed = target;
|
||||
const limit = (target >= state.speed ? config.accelerationMetersPerSecondSquared : config.decelerationMetersPerSecondSquared) * elapsed;
|
||||
state.speed += Math.sign(target - state.speed) * Math.min(Math.abs(target - state.speed), limit);
|
||||
state.distance = (state.distance + Math.max(0, state.speed) * elapsed) % route.length;
|
||||
state.stopReason = state.speed < 0.2 ? stopReason : null;
|
||||
});
|
||||
};
|
||||
viewer.clock.onTick.addEventListener(update);
|
||||
return {
|
||||
motions,
|
||||
settings: config,
|
||||
diagnostics() {
|
||||
return {
|
||||
stoppedVehicles: motions.filter((motion) => motion.state.stopReason).length,
|
||||
queueLength: motions.filter((motion) => motion.state.stopReason === "leader-gap").length,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createTrafficAwarePositions(viewer, route, start, speed, isMoving = () => true) {
|
||||
const state = { distance: 0, lastTime: start.clone() };
|
||||
viewer.clock.onTick.addEventListener((clock) => {
|
||||
@@ -1229,6 +1342,12 @@
|
||||
"Trees: " + Number(stats.trees || 0),
|
||||
"Road layer source: " + (metadata.source_geojson ? "osm2streets" : "OSM fallback")
|
||||
];
|
||||
if (cruise.simulation) {
|
||||
const simulation = cruise.simulation.diagnostics();
|
||||
lines.push("Simulation: native-preview-traffic-simulation/v1");
|
||||
lines.push("Stopped: " + simulation.stoppedVehicles);
|
||||
lines.push("Queue: " + simulation.queueLength);
|
||||
}
|
||||
if (failed.length) {
|
||||
lines.push("Failed assets: " + failed.map((asset) => asset.url).join(", "));
|
||||
}
|
||||
|
||||
259
scripts/lib/native-preview-traffic-simulation.js
Normal file
259
scripts/lib/native-preview-traffic-simulation.js
Normal file
@@ -0,0 +1,259 @@
|
||||
"use strict";
|
||||
|
||||
const crypto = require("crypto");
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
const SCHEMA = "native-preview-traffic-simulation/v1";
|
||||
const MAX_ROUTES = 5;
|
||||
const MIN_ROAD_COUNT = 3;
|
||||
const MAX_ROAD_COUNT = 7;
|
||||
const MAX_CONNECTION_GAP_METERS = 35;
|
||||
const DEFAULT_SETTINGS = {
|
||||
desiredSpeedMetersPerSecond: 8,
|
||||
accelerationMetersPerSecondSquared: 1.8,
|
||||
decelerationMetersPerSecondSquared: 3.5,
|
||||
reactionTimeSeconds: 0.8,
|
||||
vehicleLengthMeters: 4.6,
|
||||
minimumGapMeters: 7,
|
||||
};
|
||||
|
||||
function buildNativeTrafficSimulation(area, options = {}) {
|
||||
const root = area.outputs.nativeRoadDir;
|
||||
const compiledPath = path.join(root, "compiled.json");
|
||||
const signalPath = path.join(root, "traffic-signals.json");
|
||||
const connectorPath = path.join(root, "layers", "connectors.geojson");
|
||||
const laneCenterlinePath = path.join(root, "layers", "lane_centerlines.geojson");
|
||||
const stopLinePath = path.join(root, "layers", "vehicle_stop_lines.geojson");
|
||||
for (const file of [compiledPath, signalPath, connectorPath, stopLinePath]) {
|
||||
if (!fs.existsSync(file)) throw new Error(`Native traffic simulation input not found: ${file}`);
|
||||
}
|
||||
const compiled = readJson(compiledPath, "native compiled roads");
|
||||
const signals = readJson(signalPath, "native traffic signals");
|
||||
const connectors = readFeatureCollection(connectorPath, "native connectors");
|
||||
const laneCenterlines = fs.existsSync(laneCenterlinePath) ? readFeatureCollection(laneCenterlinePath, "native lane centerlines") : null;
|
||||
const stopLines = readFeatureCollection(stopLinePath, "native stop lines");
|
||||
const settings = { ...DEFAULT_SETTINGS, ...(options.settings || {}) };
|
||||
validateSettings(settings);
|
||||
const routeBuild = buildRoutes(compiled, connectors, signals.signals || [], stopLines.features, laneCenterlines);
|
||||
return {
|
||||
schema: SCHEMA,
|
||||
areaId: area.id,
|
||||
coordinateSystem: { route: "WGS84", model: "ENU", units: "meters", axes: "X east / Y north / Z up" },
|
||||
generatedAt: new Date().toISOString(),
|
||||
speedMetersPerSecond: settings.desiredSpeedMetersPerSecond,
|
||||
source: sourceRecords(area, [compiledPath, signalPath, connectorPath, stopLinePath, ...(laneCenterlines ? [laneCenterlinePath] : [])]),
|
||||
settings,
|
||||
routes: routeBuild.routes,
|
||||
signals: (signals.signals || []).filter((signal) => signal && signal.id).map(signalDescriptor),
|
||||
diagnostics: [
|
||||
...routeBuild.diagnostics,
|
||||
...(routeBuild.routes.length ? [] : [{ severity: "warning", reason: "no_native_cruise_route", message: "No deterministic native route crossed three or more directional roads." }]),
|
||||
],
|
||||
rejectedRoutes: routeBuild.rejectedRoutes,
|
||||
migration: {
|
||||
schema: SCHEMA,
|
||||
updateLoop: "advance route distance using settings, stop before red/yellow signal, then apply leader minimum gap",
|
||||
signalIdentity: "signals[].id is the existing traffic runtime signal_uid",
|
||||
coordinates: "routes[].coordinates are WGS84; static package remains local ENU placed by package manifest",
|
||||
legacyDependency: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function buildRoutes(compiled, connectorCollection, signals, stopLineFeatures, laneCenterlineCollection) {
|
||||
const roads = new Map((compiled.model?.roads || []).map((road) => [road.id, road]));
|
||||
const movements = (compiled.movements || []).filter((movement) => movement.geometryPublished !== false && roads.has(movement.fromRoadId) && roads.has(movement.toRoadId));
|
||||
const connectorByMovement = new Map(connectorCollection.features.map((feature) => [feature.properties?.movement_id, feature.geometry?.coordinates || []]));
|
||||
const laneCenterlineById = new Map();
|
||||
for (const feature of laneCenterlineCollection?.features || []) {
|
||||
const properties = feature.properties || {};
|
||||
const coordinates = feature.geometry?.coordinates || [];
|
||||
if (properties.native_id) laneCenterlineById.set(properties.native_id, coordinates);
|
||||
if (properties.road_id && Number.isFinite(Number(properties.lane_index))) laneCenterlineById.set(`${properties.road_id}:${properties.lane_index}`, coordinates);
|
||||
}
|
||||
const outgoing = new Map();
|
||||
for (const movement of movements) {
|
||||
if (!outgoing.has(movement.fromRoadId)) outgoing.set(movement.fromRoadId, []);
|
||||
outgoing.get(movement.fromRoadId).push(movement);
|
||||
}
|
||||
for (const list of outgoing.values()) list.sort((a, b) => a.id.localeCompare(b.id));
|
||||
const candidates = [];
|
||||
const seen = new Set();
|
||||
for (const start of [...roads.keys()].sort()) {
|
||||
findCycles(start, start, [], [], outgoing, seen, candidates);
|
||||
if (candidates.length >= MAX_ROUTES * 3) break;
|
||||
}
|
||||
const diagnostics = [];
|
||||
const rejectedRoutes = [];
|
||||
const routes = candidates
|
||||
.map((candidate) => {
|
||||
const result = makeRoute(candidate, roads, connectorByMovement, signals, stopLineFeatures, laneCenterlineById);
|
||||
if (!result.route) {
|
||||
rejectedRoutes.push({ id: `native-loop:${candidate.roadIds.join("-")}`, diagnostics: result.diagnostics });
|
||||
diagnostics.push(...result.diagnostics);
|
||||
}
|
||||
return result.route;
|
||||
})
|
||||
.filter(Boolean)
|
||||
.sort((a, b) => a.id.localeCompare(b.id))
|
||||
.slice(0, MAX_ROUTES);
|
||||
return { routes, diagnostics, rejectedRoutes };
|
||||
}
|
||||
|
||||
function findCycles(start, current, roadIds, movements, outgoing, seen, candidates) {
|
||||
const nextRoadIds = [...roadIds, current];
|
||||
if (nextRoadIds.length > MAX_ROAD_COUNT) return;
|
||||
for (const movement of outgoing.get(current) || []) {
|
||||
if (movement.toRoadId === start && nextRoadIds.length >= MIN_ROAD_COUNT) {
|
||||
if (!laneSequenceCompatible(movements.at(-1), movement) || !laneSequenceCompatible(movement, movements[0])) continue;
|
||||
const signature = [...nextRoadIds, start].join("|");
|
||||
if (!seen.has(signature)) {
|
||||
seen.add(signature);
|
||||
candidates.push({ roadIds: nextRoadIds, movements: [...movements, movement] });
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (nextRoadIds.includes(movement.toRoadId)) continue;
|
||||
if (!laneSequenceCompatible(movements.at(-1), movement)) continue;
|
||||
findCycles(start, movement.toRoadId, nextRoadIds, [...movements, movement], outgoing, seen, candidates);
|
||||
}
|
||||
}
|
||||
|
||||
function laneSequenceCompatible(previous, next) {
|
||||
if (!previous || !previous.toLaneId || !next?.fromLaneId) return true;
|
||||
return previous.toLaneId === next.fromLaneId;
|
||||
}
|
||||
|
||||
function makeRoute(candidate, roads, connectorByMovement, signals, stopLineFeatures, laneCenterlineById) {
|
||||
const coordinates = [];
|
||||
const connectors = [];
|
||||
const diagnostics = [];
|
||||
for (let index = 0; index < candidate.roadIds.length; index += 1) {
|
||||
const road = roads.get(candidate.roadIds[index]);
|
||||
const incomingMovement = candidate.movements[(index - 1 + candidate.movements.length) % candidate.movements.length];
|
||||
const incomingLine = selectLaneCenterline(laneCenterlineById, road, incomingMovement?.toLaneId);
|
||||
const roadLine = incomingLine || road.centerline;
|
||||
append(coordinates, roadLine);
|
||||
const movement = candidate.movements[index];
|
||||
const connector = connectorByMovement.get(movement.id) || [];
|
||||
if (connector.length < 2) {
|
||||
diagnostics.push(routeDiagnostic(candidate, movement, "missing_connector_geometry", "Native movement has no usable connector geometry."));
|
||||
return { route: null, diagnostics };
|
||||
}
|
||||
const startGap = distanceMeters(coordinates.at(-1), connector[0]);
|
||||
if (startGap > MAX_CONNECTION_GAP_METERS) {
|
||||
diagnostics.push(routeDiagnostic(candidate, movement, "road_connector_gap", `Road to connector endpoint gap is ${round(startGap)}m.`));
|
||||
return { route: null, diagnostics };
|
||||
}
|
||||
append(coordinates, connector);
|
||||
if (index < candidate.roadIds.length - 1) {
|
||||
const nextRoad = roads.get(candidate.roadIds[index + 1]);
|
||||
const nextLine = selectLaneCenterline(laneCenterlineById, nextRoad, movement.toLaneId) || nextRoad.centerline;
|
||||
const endGap = distanceMeters(connector.at(-1), nextLine[0]);
|
||||
if (endGap > MAX_CONNECTION_GAP_METERS) {
|
||||
diagnostics.push(routeDiagnostic(candidate, movement, "connector_road_gap", `Connector to road endpoint gap is ${round(endGap)}m.`));
|
||||
return { route: null, diagnostics };
|
||||
}
|
||||
}
|
||||
connectors.push({ id: movement.connectorId, movementId: movement.id, fromRoadId: movement.fromRoadId, toRoadId: movement.toRoadId, turn: movement.turn });
|
||||
}
|
||||
if (coordinates.length < 4) return { route: null, diagnostics };
|
||||
const distances = cumulativeDistances(coordinates);
|
||||
const lengthMeters = distances.at(-1);
|
||||
if (!(lengthMeters > 30)) return { route: null, diagnostics };
|
||||
const stops = matchStops(coordinates, distances, signals, stopLineFeatures);
|
||||
return { route: {
|
||||
id: `native-loop:${candidate.roadIds.join("-")}`,
|
||||
coordinates,
|
||||
centerlineCoordinates: coordinates,
|
||||
distances,
|
||||
lengthMeters: round(lengthMeters),
|
||||
edgeIds: candidate.roadIds,
|
||||
laneSegments: candidate.roadIds.map((roadId) => ({ roadId, laneId: `lane:${roadId}:1` })),
|
||||
connectors,
|
||||
maneuvers: connectors.map((connector) => connector.turn).filter(Boolean),
|
||||
stops,
|
||||
}, diagnostics };
|
||||
}
|
||||
|
||||
function selectLaneCenterline(laneCenterlineById, road, laneId) {
|
||||
if (!laneCenterlineById || !road) return null;
|
||||
if (laneId && laneCenterlineById.has(laneId)) return laneCenterlineById.get(laneId);
|
||||
const fallback = laneCenterlineById.get(`${road.id}:1`);
|
||||
return fallback || null;
|
||||
}
|
||||
|
||||
function routeDiagnostic(candidate, movement, reason, message) {
|
||||
return { severity: "warning", reason, message, routeRoadIds: candidate.roadIds, movementId: movement.id, fromRoadId: movement.fromRoadId, toRoadId: movement.toRoadId };
|
||||
}
|
||||
|
||||
function matchStops(coordinates, distances, signals, stopLineFeatures) {
|
||||
const stopRoadIds = new Set(stopLineFeatures.map((feature) => feature.properties?.road_id).filter(Boolean));
|
||||
return signals.map((signal) => {
|
||||
if (!Number.isFinite(signal.stopLongitude) || !Number.isFinite(signal.stopLatitude)) return null;
|
||||
let best = { distance: Infinity, routeDistance: 0 };
|
||||
for (let index = 0; index < coordinates.length; index += 1) {
|
||||
const distance = haversine(coordinates[index], [signal.stopLongitude, signal.stopLatitude]);
|
||||
if (distance < best.distance) best = { distance, routeDistance: distances[index] };
|
||||
}
|
||||
if (best.distance > 15 || !stopRoadIds.size) return null;
|
||||
return { signalId: signal.id, phaseGroup: signal.phaseGroup, distance: round(best.routeDistance), matchDistanceMeters: round(best.distance), stopReason: "traffic-signal" };
|
||||
}).filter(Boolean).sort((a, b) => a.distance - b.distance);
|
||||
}
|
||||
|
||||
function signalDescriptor(signal) {
|
||||
return { id: signal.id, phaseGroup: signal.phaseGroup, approachId: signal.approachId, sourceWayId: signal.sourceWayId, stopLongitude: signal.stopLongitude, stopLatitude: signal.stopLatitude, enabled: true };
|
||||
}
|
||||
|
||||
function sourceRecords(area, files) {
|
||||
return files.map((file) => ({ path: path.relative(area.outputs.areaDir, file).split(path.sep).join("/"), bytes: fs.statSync(file).size, sha256: sha256(file) }));
|
||||
}
|
||||
|
||||
function append(target, points) {
|
||||
for (const point of points || []) {
|
||||
const coordinate = point.slice(0, 2).map(Number);
|
||||
if (!coordinate.every(Number.isFinite)) continue;
|
||||
const previous = target.at(-1);
|
||||
if (!previous || previous[0] !== coordinate[0] || previous[1] !== coordinate[1]) target.push(coordinate);
|
||||
}
|
||||
}
|
||||
|
||||
function cumulativeDistances(coordinates) {
|
||||
const distances = [0];
|
||||
for (let index = 1; index < coordinates.length; index += 1) distances.push(distances[index - 1] + haversine(coordinates[index - 1], coordinates[index]));
|
||||
return distances;
|
||||
}
|
||||
|
||||
function haversine(a, b) {
|
||||
const radians = Math.PI / 180;
|
||||
const dLat = (b[1] - a[1]) * radians;
|
||||
const dLon = (b[0] - a[0]) * radians;
|
||||
const lat1 = a[1] * radians;
|
||||
const lat2 = b[1] * radians;
|
||||
const value = Math.sin(dLat / 2) ** 2 + Math.cos(lat1) * Math.cos(lat2) * Math.sin(dLon / 2) ** 2;
|
||||
return 6371008.8 * 2 * Math.atan2(Math.sqrt(value), Math.sqrt(1 - value));
|
||||
}
|
||||
|
||||
function distanceMeters(a, b) {
|
||||
return a && b ? haversine(a, b) : Infinity;
|
||||
}
|
||||
|
||||
function readJson(file, label) {
|
||||
try { return JSON.parse(fs.readFileSync(file, "utf8")); } catch (error) { throw new Error(`Invalid ${label} JSON '${file}': ${error.message}`); }
|
||||
}
|
||||
|
||||
function readFeatureCollection(file, label) {
|
||||
const value = readJson(file, label);
|
||||
if (value?.type !== "FeatureCollection" || !Array.isArray(value.features)) throw new Error(`Invalid ${label} '${file}': expected FeatureCollection`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function validateSettings(settings) {
|
||||
for (const [key, value] of Object.entries(settings)) if (!Number.isFinite(value) || value <= 0) throw new Error(`Native traffic simulation setting '${key}' must be positive`);
|
||||
}
|
||||
|
||||
function sha256(file) { return crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex"); }
|
||||
function round(value) { return Math.round(value * 100) / 100; }
|
||||
|
||||
module.exports = { SCHEMA, DEFAULT_SETTINGS, buildNativeTrafficSimulation };
|
||||
@@ -10,14 +10,31 @@ const DEFAULT_WIDTHS = { motorway: 12, trunk: 10, primary: 10, secondary: 8, ter
|
||||
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)) {
|
||||
@@ -30,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) {
|
||||
@@ -72,7 +89,8 @@ 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) {
|
||||
@@ -136,8 +154,10 @@ 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.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);
|
||||
@@ -150,6 +170,12 @@ 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 };
|
||||
@@ -216,7 +242,7 @@ 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 = [];
|
||||
@@ -227,35 +253,149 @@ function compileGeometry(model, overrides = { overrides: [] }) {
|
||||
emittedSegments.add(segmentKey);
|
||||
const directions = model.roads.filter((item) => item.segmentId === segmentKey);
|
||||
const totalWidth = directions.reduce((sum, item) => sum + item.widthMeters, 0);
|
||||
// Road and junction asphalt share one final material. Keep the carriageway
|
||||
// continuous through the semantic junction overlay; cutting it back creates
|
||||
// visible wedges/gaps without improving the rendered result.
|
||||
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; }
|
||||
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, junctionPlans);
|
||||
const markings = compileLaneMarkings(model, lanes, 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);
|
||||
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 }, sidewalkSurface: { type: "FeatureCollection", features: sidewalks }, intersectionSurface: { type: "FeatureCollection", features: junctionFeatures }, laneCenterlines: { type: "FeatureCollection", features: lanes.features }, laneSeparators: { type: "FeatureCollection", features: markings.separators }, directionArrows: { type: "FeatureCollection", features: markings.directionArrows }, turnArrows: { type: "FeatureCollection", features: markings.turnArrows }, 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 compileLaneMarkings(model, lanes, diagnostics, junctionPlans) {
|
||||
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 ring = roadRing(centerline, 0.12);
|
||||
if (ring) separators.push({ type: "Feature", properties: { native_id: `lane-separator:${road.id}:${index}-${index + 1}`, road_id: road.id, left_lane_index: index, right_lane_index: index + 1, osm_way_ids: road.osmWayIds.join(","), provenance: "native-road-lane-separator/v1" }, geometry: { type: "Polygon", coordinates: [ring] } });
|
||||
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));
|
||||
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) {
|
||||
@@ -265,30 +405,46 @@ function compileLaneMarkings(model, lanes, diagnostics, junctionPlans) {
|
||||
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 center = pointAlongLine([...lane.coordinates].reverse(), 6);
|
||||
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 rings = axis ? arrowRingsAt(maneuver, center, axis) : [];
|
||||
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: 6, provenance: "native-road-turn-arrow/v1" }, geometry: { type: "Polygon", coordinates: [rings[part]] } });
|
||||
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 directionArrowFeatures(road, lane) {
|
||||
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();
|
||||
@@ -343,6 +499,7 @@ function compileSidewalkCorners(model, junctionPlans) {
|
||||
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),
|
||||
@@ -354,17 +511,24 @@ function compileSidewalkCorners(model, junctionPlans) {
|
||||
const first = candidates[index];
|
||||
const second = candidates[(index + 1) % candidates.length];
|
||||
if (first.wayKey === second.wayKey) continue;
|
||||
const ring = [first.curb, first.outer, second.outer, second.curb, first.curb];
|
||||
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 (first.sourceWayKey === second.sourceWayKey && (!samePhysicalSide(first, second) || cornerFallsIntoOtherApproach(ring, first.sourceWayKey, plan.approaches))) 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: "corner",
|
||||
kind: continuation ? "continuation" : "corner",
|
||||
width_m: DEFAULT_SIDEWALK_WIDTH_METERS,
|
||||
provenance: "native-road-sidewalk-corner/v1",
|
||||
provenance: continuation ? "native-road-sidewalk-continuation/v1" : "native-road-sidewalk-corner/v1",
|
||||
},
|
||||
geometry: { type: "Polygon", coordinates: [ring] },
|
||||
});
|
||||
@@ -373,13 +537,51 @@ function compileSidewalkCorners(model, junctionPlans) {
|
||||
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 center = ring.slice(0, -1).reduce((sum, point) => [sum[0] + point[0] / 4, sum[1] + point[1] / 4], [0, 0]);
|
||||
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);
|
||||
@@ -392,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]));
|
||||
}
|
||||
}
|
||||
@@ -408,6 +610,17 @@ 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;
|
||||
@@ -440,7 +653,7 @@ function compileLaneCenterlines(model, diagnostics, junctionPlans) {
|
||||
return { features, byRoadId };
|
||||
}
|
||||
|
||||
function compileConnectors(model, lanes, diagnostics, overrides) {
|
||||
function compileConnectors(model, lanes, diagnostics, overrides, junctionPlans) {
|
||||
const features = [];
|
||||
const movements = [];
|
||||
for (const connection of model.connections.filter((item) => item.enabled)) {
|
||||
@@ -456,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;
|
||||
@@ -499,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) {
|
||||
@@ -553,8 +805,8 @@ function compileJunctionSurfaces(model, junctionPlans, connectors, movements, di
|
||||
}
|
||||
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";
|
||||
@@ -567,6 +819,7 @@ function compileJunctionSurfaces(model, junctionPlans, connectors, movements, di
|
||||
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;
|
||||
@@ -584,11 +837,13 @@ function compileJunctionPlans(model) {
|
||||
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.length < 3) continue;
|
||||
plans.set(nodeId, { node, segmentIds, approaches, cutbackMeters, boundary });
|
||||
if (boundary.points.length < 3) continue;
|
||||
plans.set(nodeId, { node, segmentIds, approaches, cutbackMeters, boundary: boundary.points, boundaryMode: boundary.mode, boundaryFallbacks: boundary.fallbacks });
|
||||
}
|
||||
return plans;
|
||||
}
|
||||
@@ -615,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) {
|
||||
|
||||
33
scripts/lib/native-traffic-signals.js
Normal file
33
scripts/lib/native-traffic-signals.js
Normal file
@@ -0,0 +1,33 @@
|
||||
"use strict";
|
||||
|
||||
const fs = require("fs");
|
||||
const { parseOsm } = require("./osm");
|
||||
const {
|
||||
buildTrafficSignalFeatures,
|
||||
buildTrafficSignalsFromFeatures,
|
||||
validateTrafficSignalFeatures,
|
||||
validateTrafficSignalSourceReferences,
|
||||
} = require("./traffic-signals");
|
||||
|
||||
const SCHEMA = "native-traffic-signals/v1";
|
||||
|
||||
function loadOrGenerate(file, osmText, stopLines, intersections) {
|
||||
if (fs.existsSync(file)) return validateDocument(JSON.parse(fs.readFileSync(file, "utf8")), osmText);
|
||||
return generate(osmText, stopLines, intersections);
|
||||
}
|
||||
|
||||
function generate(osmText, stopLines, intersections) {
|
||||
const controls = parseOsm(osmText).trafficSignalControls;
|
||||
return { schema: SCHEMA, provenance: "generated:osm-controls", assemblies: buildTrafficSignalFeatures(stopLines, intersections, controls) };
|
||||
}
|
||||
|
||||
function validateDocument(value, osmText) {
|
||||
if (value?.schema !== SCHEMA) throw new Error(`Expected ${SCHEMA} signal document`);
|
||||
const assemblies = validateTrafficSignalFeatures(value.assemblies);
|
||||
if (osmText) validateTrafficSignalSourceReferences(assemblies, parseOsm(osmText).trafficSignalControls);
|
||||
return { schema: SCHEMA, provenance: value.provenance || "native", assemblies };
|
||||
}
|
||||
|
||||
function runtime(document) { return buildTrafficSignalsFromFeatures(document.assemblies); }
|
||||
|
||||
module.exports = { SCHEMA, generate, loadOrGenerate, validateDocument, runtime };
|
||||
@@ -59,7 +59,11 @@ function buildTrafficSignalFeatures(stopLines, intersections, controls = []) {
|
||||
properties: {
|
||||
signal_uid: signalUid, display_id: signalUid, control_id: String(control.id),
|
||||
approach_id: approachId, source_way_id: sourceWayId,
|
||||
heading_deg: candidate.headingDegrees, phase_group: groups[index],
|
||||
// These are independent assembly controls. heading_deg remains a
|
||||
// migration hint for older native documents only.
|
||||
mast_heading_deg: normalizeDegrees(candidate.headingDegrees - 90),
|
||||
face_heading_deg: normalizeDegrees(candidate.headingDegrees + 180),
|
||||
phase_group: groups[index],
|
||||
mast_reach_m: MAST_REACH_METERS,
|
||||
stop_lon: candidate.center[0], stop_lat: candidate.center[1],
|
||||
enabled: true, z_offset_m: 0,
|
||||
@@ -114,13 +118,30 @@ function validateTrafficSignalFeatures(collection) {
|
||||
if (!approachId.startsWith(`${sourceWayId}:`)) throw new Error(`${label}: approach_id does not match source_way_id`);
|
||||
const expectedUid = `osm-${controlId}-${approachId.replace(":", "-")}`;
|
||||
if (signalUid !== expectedUid) throw new Error(`${label}: signal_uid does not match source identity (expected '${expectedUid}')`);
|
||||
const legacyHeading = input.heading_deg == null || input.heading_deg === "" ? null : normalizeDegrees(number("heading_deg"));
|
||||
if (legacyHeading == null && (input.mast_heading_deg == null || input.mast_heading_deg === "")) {
|
||||
throw new Error(`${label}: missing mast_heading_deg`);
|
||||
}
|
||||
if (legacyHeading == null && (input.face_heading_deg == null || input.face_heading_deg === "")) {
|
||||
throw new Error(`${label}: missing face_heading_deg`);
|
||||
}
|
||||
const mastHeading = input.mast_heading_deg == null || input.mast_heading_deg === ""
|
||||
? normalizeDegrees((legacyHeading == null ? 0 : legacyHeading) - 90)
|
||||
: normalizeDegrees(number("mast_heading_deg"));
|
||||
const faceHeading = input.face_heading_deg == null || input.face_heading_deg === ""
|
||||
? normalizeDegrees((legacyHeading == null ? 0 : legacyHeading) + 180)
|
||||
: normalizeDegrees(number("face_heading_deg"));
|
||||
return {
|
||||
type: "Feature",
|
||||
geometry: { type: "Point", coordinates: feature.geometry.coordinates.slice(0, 2).map(Number) },
|
||||
properties: {
|
||||
...input, signal_uid: signalUid, display_id: displayId,
|
||||
control_id: controlId, approach_id: approachId,
|
||||
source_way_id: sourceWayId, heading_deg: normalizeDegrees(number("heading_deg")),
|
||||
source_way_id: sourceWayId,
|
||||
// Retain the legacy value only for migration compatibility. Runtime
|
||||
// geometry is entirely defined by mast_heading_deg and face_heading_deg.
|
||||
heading_deg: legacyHeading,
|
||||
mast_heading_deg: mastHeading, face_heading_deg: faceHeading,
|
||||
phase_group: phaseGroup, mast_reach_m: number("mast_reach_m", { min: 0.1, max: 30 }),
|
||||
stop_lon: number("stop_lon", { min: -180, max: 180 }),
|
||||
stop_lat: number("stop_lat", { min: -90, max: 90 }),
|
||||
@@ -136,16 +157,20 @@ function buildTrafficSignalsFromFeatures(collection) {
|
||||
const signals = normalized.features.filter((feature) => feature.properties.enabled).map((feature) => {
|
||||
const p = feature.properties;
|
||||
const point = feature.geometry.coordinates;
|
||||
const axis = headingVector(p.heading_deg);
|
||||
const mastAxis = headingVector(p.mast_heading_deg);
|
||||
return {
|
||||
id: p.signal_uid, signalUid: p.signal_uid, displayId: p.display_id,
|
||||
nodeKey: signalNodeKey(p.signal_uid),
|
||||
controlId: p.control_id, approachId: p.approach_id, sourceWayId: p.source_way_id,
|
||||
phaseGroup: p.phase_group, longitude: point[0], latitude: point[1],
|
||||
stopLongitude: p.stop_lon, stopLatitude: p.stop_lat,
|
||||
headingDegrees: p.heading_deg, mastReachMeters: p.mast_reach_m,
|
||||
// Existing Blender readers require headingDegrees. It is a compatibility
|
||||
// alias only; the independent mast/face fields below define all geometry.
|
||||
headingDegrees: p.heading_deg == null ? p.mast_heading_deg : p.heading_deg,
|
||||
mastHeadingDegrees: p.mast_heading_deg,
|
||||
faceHeadingDegrees: p.face_heading_deg, mastReachMeters: p.mast_reach_m,
|
||||
zOffsetMeters: p.z_offset_m,
|
||||
pose: buildSignalPose(point, axis, p.mast_reach_m, p.z_offset_m),
|
||||
pose: buildSignalPose(point, mastAxis, p.face_heading_deg, p.mast_reach_m, p.z_offset_m),
|
||||
};
|
||||
});
|
||||
return { version: 3, layout: SIGNAL_LAYOUT, signals };
|
||||
@@ -235,12 +260,13 @@ function phaseGroups(arms) {
|
||||
groups[main[0]] = 0; groups[main[1]] = 0; return groups;
|
||||
}
|
||||
|
||||
function buildSignalPose(pole, axis, mastReach, zOffset = 0) {
|
||||
const lateral = [axis[1], -axis[0]]; const face = [-axis[0], -axis[1]];
|
||||
const head = moveMeters(pole, lateral, -mastReach); const faceHeadingDegrees = Math.atan2(face[0], face[1]) * 180 / Math.PI;
|
||||
function buildSignalPose(pole, mastAxis, faceHeadingDegrees, mastReach, zOffset = 0) {
|
||||
const face = headingVector(faceHeadingDegrees);
|
||||
const head = moveMeters(pole, mastAxis, mastReach);
|
||||
const position = (point, height) => ({ longitude: point[0], latitude: point[1], height: height + zOffset });
|
||||
const lensPoint = moveMeters(head, face, SIGNAL_LAYOUT.lensFaceOffsetMeters);
|
||||
const board = moveMeters(moveMeters(head, lateral, SIGNAL_LAYOUT.countdownLateralMeters), face, SIGNAL_LAYOUT.countdownFaceOffsetMeters);
|
||||
const faceRight = [-face[1], face[0]];
|
||||
const board = moveMeters(moveMeters(head, faceRight, SIGNAL_LAYOUT.countdownLateralMeters), face, SIGNAL_LAYOUT.countdownFaceOffsetMeters);
|
||||
return { pole: position(pole, 0), arm: { from: position(pole, SIGNAL_LAYOUT.mastHeightMeters), to: position(head, SIGNAL_LAYOUT.mastHeightMeters) }, head: { ...position(head, SIGNAL_LAYOUT.headCenterHeightMeters), faceHeadingDegrees }, lenses: ["red", "yellow", "green"].map((state, index) => ({ state, ...position(lensPoint, SIGNAL_LAYOUT.headCenterHeightMeters + SIGNAL_LAYOUT.lensVerticalOffsetsMeters[index]) })), countdown: { ...position(board, SIGNAL_LAYOUT.mastHeightMeters), faceHeadingDegrees } };
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ const http = require("http");
|
||||
const path = require("path");
|
||||
const { readAreaConfig } = require("./lib/area-config");
|
||||
const { loadOverrides, validateOverrides, writeJsonAtomic } = require("./lib/native-road");
|
||||
const { generate, validateDocument, runtime } = require("./lib/native-traffic-signals");
|
||||
const { compileArea, parseArgs } = require("./compile-native-roads");
|
||||
|
||||
const repoRoot = path.resolve(__dirname, "..");
|
||||
@@ -32,6 +33,20 @@ function handle(request, response, area, configPath) {
|
||||
if (request.method === "GET" && url.pathname === "/app.css") return sendFile(response, path.join(repoRoot, "scripts", "workbench", "app.css"), "text/css; charset=utf-8");
|
||||
if (request.method === "GET" && url.pathname.startsWith("/vendor/")) return sendVendorFile(response, url.pathname);
|
||||
if (request.method === "GET" && url.pathname === "/api/state") return sendJson(response, 200, state(area));
|
||||
if (request.method === "POST" && url.pathname === "/api/traffic-signals") return readBody(request).then((body) => {
|
||||
const document = validateDocument(body, fs.readFileSync(area.input, "utf8"));
|
||||
writeJsonAtomic(area.outputs.nativeTrafficSignals, document);
|
||||
sendJson(response, 200, { ok: true, trafficSignals: document, runtime: runtime(document) });
|
||||
}).catch((error) => sendJson(response, 400, { ok: false, error: error.message }));
|
||||
if (request.method === "POST" && url.pathname === "/api/traffic-signals/generate") return Promise.resolve().then(() => {
|
||||
const compiled = readCompiled(area);
|
||||
const generated = generate(fs.readFileSync(area.input, "utf8"), readLayer(path.join(area.outputs.nativeRoadDir, "layers", "vehicle_stop_lines.geojson")), readLayer(path.join(area.outputs.nativeRoadDir, "layers", "intersection_surface.geojson")));
|
||||
const current = validateDocument(readJson(area.outputs.nativeTrafficSignals), fs.readFileSync(area.input, "utf8"));
|
||||
const present = new Set(current.assemblies.features.map((feature) => feature.properties.signal_uid));
|
||||
current.assemblies.features.push(...generated.assemblies.features.filter((feature) => !present.has(feature.properties.signal_uid)));
|
||||
writeJsonAtomic(area.outputs.nativeTrafficSignals, current);
|
||||
sendJson(response, 200, { ok: true, trafficSignals: current, runtime: runtime(current), generated: generated.assemblies.features.length, compiled: Boolean(compiled) });
|
||||
}).catch((error) => sendJson(response, 400, { ok: false, error: error.message }));
|
||||
if (request.method === "POST" && url.pathname === "/api/overrides") return readBody(request).then((body) => {
|
||||
const compiled = readCompiled(area);
|
||||
const overrides = validateOverrides(body, { roads: compiled.model.roads, endpoints: compiled.model.endpoints });
|
||||
@@ -48,7 +63,11 @@ function handle(request, response, area, configPath) {
|
||||
function state(area) {
|
||||
const nativeDir = area.outputs.nativeRoadDir;
|
||||
const osm2streetsRoadSurface = path.join(area.outputs.geojsonDir, "road_surface.geojson");
|
||||
return { areaId: area.id, compiled: readCompiled(area), overrides: loadOverrides(area.outputs.nativeRoadOverrides), comparison: readJson(path.join(nativeDir, "comparison.json")), layers: { nativeRoadSurface: readLayer(path.join(nativeDir, "layers", "road_surface.geojson")), nativeSidewalkSurface: readLayer(path.join(nativeDir, "layers", "sidewalk_surface.geojson")), nativeIntersectionSurface: readLayer(path.join(nativeDir, "layers", "intersection_surface.geojson")), laneCenterlines: readLayer(path.join(nativeDir, "layers", "lane_centerlines.geojson")), laneSeparators: readLayer(path.join(nativeDir, "layers", "lane_separators.geojson")), directionArrows: readLayer(path.join(nativeDir, "layers", "direction_arrows.geojson")), turnArrows: readLayer(path.join(nativeDir, "layers", "turn_arrows.geojson")), connectors: readLayer(path.join(nativeDir, "layers", "connectors.geojson")), osm2streetsRoadSurface: fs.existsSync(osm2streetsRoadSurface) ? readLayer(osm2streetsRoadSurface) : null } };
|
||||
const trafficSignals = fs.existsSync(area.outputs.nativeTrafficSignals)
|
||||
? validateDocument(readJson(area.outputs.nativeTrafficSignals), fs.readFileSync(area.input, "utf8"))
|
||||
: { schema: "native-traffic-signals/v1", provenance: "empty", assemblies: { type: "FeatureCollection", features: [] } };
|
||||
const trafficRuntime = runtime(trafficSignals);
|
||||
return { areaId: area.id, compiled: readCompiled(area), overrides: loadOverrides(area.outputs.nativeRoadOverrides), trafficSignals, trafficRuntime, comparison: readJson(path.join(nativeDir, "comparison.json")), layers: { nativeRoadSurface: readLayer(path.join(nativeDir, "layers", "road_surface.geojson")), edgeLines: readLayer(path.join(nativeDir, "layers", "edge_lines.geojson")), nativeSidewalkSurface: readLayer(path.join(nativeDir, "layers", "sidewalk_surface.geojson")), nativeIntersectionSurface: readLayer(path.join(nativeDir, "layers", "intersection_surface.geojson")), laneCenterlines: readLayer(path.join(nativeDir, "layers", "lane_centerlines.geojson")), laneSeparators: readLayer(path.join(nativeDir, "layers", "lane_separators.geojson")), centerLines: readLayer(path.join(nativeDir, "layers", "center_lines.geojson")), directionArrows: readLayer(path.join(nativeDir, "layers", "direction_arrows.geojson")), turnArrows: readLayer(path.join(nativeDir, "layers", "turn_arrows.geojson")), crosswalks: readLayer(path.join(nativeDir, "layers", "crosswalks.geojson")), vehicleStopLines: readLayer(path.join(nativeDir, "layers", "vehicle_stop_lines.geojson")), connectors: readLayer(path.join(nativeDir, "layers", "connectors.geojson")), osm2streetsRoadSurface: fs.existsSync(osm2streetsRoadSurface) ? readLayer(osm2streetsRoadSurface) : null } };
|
||||
}
|
||||
function readCompiled(area) { return readJson(path.join(area.outputs.nativeRoadDir, "compiled.json")); }
|
||||
function readJson(file) { return JSON.parse(fs.readFileSync(file, "utf8")); }
|
||||
|
||||
@@ -16,7 +16,11 @@ assert.match(qgisBuildSource, /QgsFieldConstraints\.Constraint\.ConstraintNotNul
|
||||
assert.match(qgisBuildSource, /QgsFieldConstraints\.ConstraintNotNull/);
|
||||
assert.doesNotMatch(qgisBuildSource, /setFieldConstraint\(index, 1\)/);
|
||||
assert.match(areaBuildSource, /exportCesium\(area, roadProvider\)/);
|
||||
assert.match(areaBuildSource, /if \(roadProvider === "osm2streets"\) \{\n exporterArgs\.splice/);
|
||||
assert.match(areaBuildSource, /"--dynamic-glb", area\.outputs\.trafficSignalsDynamicGlb/);
|
||||
assert.match(areaBuildSource, /trafficSignalsCountdown0Glb: fileRecord/);
|
||||
assert.match(areaBuildSource, /if \(roadProvider === "osm2streets"\) \{[\s\S]*?buildPreviewVehicleRoute/);
|
||||
assert.match(areaBuildSource, /Object\.assign\(previewInputs, nativeRoadRecords\(area\)\)/);
|
||||
assert.match(areaBuildSource, /vehicleRoute: optionalFileRecord\(area\.outputs\.vehicleRoute\)/);
|
||||
|
||||
const gltf = {
|
||||
nodes: [
|
||||
@@ -53,8 +57,13 @@ assert.equal(
|
||||
normalizeAreaConfig(base).outputs.trafficSignalAssemblies,
|
||||
path.join(tempDir, "test-area", "osm2streets_web_out", "traffic_signal_assemblies.geojson"),
|
||||
);
|
||||
assert.equal(
|
||||
normalizeAreaConfig(base).outputs.trafficSimulation,
|
||||
path.join(tempDir, "test-area", "_preview", "test-area-traffic-simulation.json"),
|
||||
);
|
||||
assert.equal(normalizeAreaConfig({ ...base, budget: { nodes: 800 } }).budget.glbNodes, 800);
|
||||
assert.equal(normalizeAreaConfig(base).blender.roadProvider, "osm2streets");
|
||||
assert.equal(normalizeAreaConfig(base).stages.intermediates, false);
|
||||
assert.equal(normalizeAreaConfig(base).blender.roadProvider, "native");
|
||||
assert.equal(normalizeAreaConfig({ ...base, blender: { roadProvider: "native" } }).blender.roadProvider, "native");
|
||||
assert.throws(
|
||||
() => normalizeAreaConfig({ ...base, blender: { roadProvider: "other" } }),
|
||||
@@ -78,8 +87,9 @@ assert.equal(
|
||||
);
|
||||
|
||||
const configPath = path.join(tempDir, "area.json");
|
||||
fs.writeFileSync(configPath, `${JSON.stringify(base)}\n`);
|
||||
const area = normalizeAreaConfig(base);
|
||||
const legacyBase = { ...base, blender: { roadProvider: "osm2streets" } };
|
||||
fs.writeFileSync(configPath, `${JSON.stringify(legacyBase)}\n`);
|
||||
const area = normalizeAreaConfig(legacyBase);
|
||||
assert.equal(area.stages.compress, true);
|
||||
assert.equal("compressedGlb" in area.outputs, false);
|
||||
assert.equal("compressedMetadata" in area.outputs, false);
|
||||
|
||||
@@ -4,7 +4,7 @@ const assert = require("assert");
|
||||
const fs = require("fs");
|
||||
const { resolveStages, canonicalStages } = require("./lib/build-stages");
|
||||
assert.deepEqual(canonicalStages(resolveStages({}, ["compress", "blender", "preview"])), ["blender", "compress", "preview"]);
|
||||
assert.deepEqual(canonicalStages(resolveStages({}, ["all"])), ["intermediates", "blender", "cesium", "compress", "package", "preview"]);
|
||||
assert.deepEqual(canonicalStages(resolveStages({}, ["all"])), ["blender", "cesium", "compress", "package", "preview"]);
|
||||
assert.deepEqual(canonicalStages(resolveStages({ intermediates: true, blender: true, cesium: true, compress: true, package: true })), ["intermediates", "blender", "cesium", "compress", "package"]);
|
||||
assert.throws(() => resolveStages({}, ["intermediates", "reimport"]), /mutually exclusive/);
|
||||
const buildAreaSource = fs.readFileSync(require("path").join(__dirname, "build-area.js"), "utf8");
|
||||
|
||||
37
scripts/test-native-preview-traffic.js
Normal file
37
scripts/test-native-preview-traffic.js
Normal file
@@ -0,0 +1,37 @@
|
||||
#!/usr/bin/env node
|
||||
"use strict";
|
||||
|
||||
const assert = require("assert");
|
||||
const fs = require("fs");
|
||||
const os = require("os");
|
||||
const path = require("path");
|
||||
const { SCHEMA, buildNativeTrafficSimulation } = require("./lib/native-preview-traffic-simulation");
|
||||
|
||||
const areaDir = fs.mkdtempSync(path.join(os.tmpdir(), "native-preview-traffic-"));
|
||||
const nativeRoadDir = path.join(areaDir, "native-road");
|
||||
const layersDir = path.join(nativeRoadDir, "layers");
|
||||
fs.mkdirSync(layersDir, { recursive: true });
|
||||
const roads = [
|
||||
{ id: "road:a", centerline: [[120, 30], [120.001, 30]], sourceNodeIds: ["a", "b"] },
|
||||
{ id: "road:b", centerline: [[120.001, 30], [120.001, 30.001]], sourceNodeIds: ["b", "c"] },
|
||||
{ id: "road:c", centerline: [[120.001, 30.001], [120, 30.001]], sourceNodeIds: ["c", "d"] },
|
||||
];
|
||||
const movements = [
|
||||
["road:a", "road:b", "left"], ["road:b", "road:c", "through"], ["road:c", "road:a", "right"],
|
||||
].map(([fromRoadId, toRoadId, turn], index) => ({ id: `movement:${index}`, connectorId: `connector:${index}`, fromRoadId, toRoadId, turn, geometryPublished: true }));
|
||||
fs.writeFileSync(path.join(nativeRoadDir, "compiled.json"), JSON.stringify({ model: { roads }, movements }));
|
||||
fs.writeFileSync(path.join(nativeRoadDir, "traffic-signals.json"), JSON.stringify({ signals: [{ id: "osm-signal-1", phaseGroup: 0, approachId: "way:a", sourceWayId: "a", stopLongitude: 120.0009, stopLatitude: 30, enabled: true }] }));
|
||||
fs.writeFileSync(path.join(layersDir, "connectors.geojson"), JSON.stringify({ type: "FeatureCollection", features: movements.map((movement, index) => ({ type: "Feature", properties: { movement_id: movement.id }, geometry: { type: "LineString", coordinates: [[[120.001, 30], [120.001, 30.0001]], [[120.001, 30.001], [120.0009, 30.001]], [[120, 30.001], [120, 30.0009]]][index] } })) }));
|
||||
fs.writeFileSync(path.join(layersDir, "vehicle_stop_lines.geojson"), JSON.stringify({ type: "FeatureCollection", features: [{ type: "Feature", properties: { road_id: "road:a" }, geometry: { type: "Polygon", coordinates: [] } }] }));
|
||||
fs.writeFileSync(path.join(layersDir, "lane_centerlines.geojson"), JSON.stringify({ type: "FeatureCollection", features: roads.map((road) => ({ type: "Feature", properties: { road_id: road.id, lane_index: 1 }, geometry: { type: "LineString", coordinates: road.centerline } })) }));
|
||||
|
||||
const descriptor = buildNativeTrafficSimulation({ id: "fixture", outputs: { areaDir, nativeRoadDir } });
|
||||
assert.equal(descriptor.schema, SCHEMA);
|
||||
assert.equal(descriptor.coordinateSystem.route, "WGS84");
|
||||
assert.ok(descriptor.routes.length >= 1);
|
||||
assert.ok(descriptor.routes.some((route) => route.connectors.length === 3));
|
||||
assert.ok(descriptor.routes.some((route) => route.stops.some((stop) => stop.signalId === "osm-signal-1")));
|
||||
assert.equal(descriptor.migration.legacyDependency, false);
|
||||
assert.equal(descriptor.source.length, 5);
|
||||
fs.rmSync(areaDir, { recursive: true, force: true });
|
||||
console.log("Native preview traffic tests passed.");
|
||||
@@ -22,11 +22,49 @@ assert.equal(edited.provenance.widthMeters, "override:road-width");
|
||||
assert.equal(edited.sidewalkLeft, false);
|
||||
const geometry = compileGeometry(model);
|
||||
assert.equal(geometry.roadSurface.features.length, 2);
|
||||
assert.equal(compileGeometry(model, empty, { edgeLines: false }).edgeLines.features.length, 0);
|
||||
assert.ok(geometry.roadSurface.features.every((feature) => feature.geometry.coordinates[0].length >= 5));
|
||||
assert.equal(geometry.sidewalkSurface.features.length, 2);
|
||||
assert.ok(geometry.sidewalkSurface.features.every((feature) => feature.geometry.coordinates[0].length >= 5));
|
||||
assert.equal(geometry.laneCenterlines.features.length, model.roads.reduce((sum, road) => sum + road.laneCount, 0));
|
||||
assert.ok(geometry.laneSeparators.features.every((feature) => feature.geometry.type === "Polygon" && feature.properties.provenance === "native-road-lane-separator/v1"));
|
||||
const separator = geometry.laneSeparators.features[0];
|
||||
const separatorOverride = validateOverrides({ schema: "native-road-overrides/v1", overrides: [{ id: "separator-style", kind: "lane-separator-style", roadId: separator.properties.road_id, leftLaneIndex: separator.properties.left_lane_index, rightLaneIndex: separator.properties.right_lane_index, color: "yellow", pattern: "solid" }] }, model);
|
||||
const styledSeparators = compileGeometry(model, separatorOverride).laneSeparators.features.filter((feature) => feature.properties.road_id === separator.properties.road_id && feature.properties.left_lane_index === separator.properties.left_lane_index);
|
||||
assert.ok(styledSeparators.length > 0 && styledSeparators.every((feature) => feature.properties.effective_style === "yellow-solid"));
|
||||
assert.ok(geometry.centerLines.features.length > 0);
|
||||
assert.ok(geometry.centerLines.features.every((feature) => feature.geometry.type === "Polygon" && feature.properties.provenance === "native-road-center-line/v1" && feature.properties.dash_length_m === 2 && feature.properties.dash_gap_m === 2));
|
||||
for (const feature of geometry.centerLines.features) {
|
||||
const ring = feature.geometry.coordinates[0];
|
||||
const lengths = [distance(ring[0], ring[1]), distance(ring[1], ring[2])].sort((a, b) => a - b);
|
||||
assert.ok(Math.abs(lengths[0] - .25) < .01 && Math.abs(lengths[1] - 2) < .01);
|
||||
assert.ok(feature.properties.segment_id && feature.properties.directional_road_ids && feature.properties.osm_way_ids && feature.properties.placement_rule);
|
||||
}
|
||||
const centerLineOverride = validateOverrides({ schema: "native-road-overrides/v1", overrides: [{ id: "center-white-solid", kind: "center-line-style", segmentId: target.segmentId, color: "white", pattern: "solid" }] }, model);
|
||||
const styledCenterLines = compileGeometry(model, centerLineOverride).centerLines.features.filter((feature) => feature.properties.segment_id === target.segmentId);
|
||||
assert.ok(styledCenterLines.length > 0);
|
||||
assert.ok(styledCenterLines.every((feature) => feature.properties.color === "white" && feature.properties.pattern === "solid" && feature.properties.effective_style === "white-solid" && feature.properties.dash_gap_m === 0));
|
||||
const doubleCenterOverride = validateOverrides({ schema: "native-road-overrides/v1", overrides: [{ id: "center-double-yellow", kind: "center-line-style", segmentId: target.segmentId, color: "yellow", pattern: "solid", double: true }] }, model);
|
||||
const doubleCenterLines = compileGeometry(model, doubleCenterOverride).centerLines.features.filter((feature) => feature.properties.segment_id === target.segmentId);
|
||||
assert.equal(doubleCenterLines.length, styledCenterLines.length * 2);
|
||||
assert.ok(doubleCenterLines.every((feature) => feature.properties.double === true && feature.properties.effective_style === "double-yellow-solid"));
|
||||
assert.equal(new Set(doubleCenterLines.map((feature) => feature.properties.dash_index)).size, styledCenterLines.length);
|
||||
for (const dashIndex of new Set(doubleCenterLines.map((feature) => feature.properties.dash_index))) {
|
||||
const pair = doubleCenterLines.filter((feature) => feature.properties.dash_index === dashIndex);
|
||||
const centers = pair.map((feature) => feature.geometry.coordinates[0].slice(0, 4).reduce((sum, point) => [sum[0] + point[0] / 4, sum[1] + point[1] / 4], [0, 0]));
|
||||
assert.ok(Math.hypot((centers[0][0] - centers[1][0]) * 96400, (centers[0][1] - centers[1][1]) * 111320) > .25);
|
||||
}
|
||||
assert.throws(() => validateOverrides({ schema: "native-road-overrides/v1", overrides: [{ id: "bad-double-center", kind: "center-line-style", segmentId: target.segmentId, color: "white", pattern: "solid", double: true }] }, model), /Invalid center line style override/);
|
||||
assert.throws(() => validateOverrides({ schema: "native-road-overrides/v1", overrides: [{ id: "bad-center", kind: "center-line-style", segmentId: target.segmentId, color: "blue", pattern: "solid" }] }, model), /Invalid center line style override/);
|
||||
const edgeLine = geometry.edgeLines.features[0];
|
||||
assert.ok(edgeLine && edgeLine.properties.effective_style === "white-solid");
|
||||
const twoWayEdgeLines = geometry.edgeLines.features.filter((feature) => feature.properties.road_id.includes("way/10"));
|
||||
assert.ok(twoWayEdgeLines.length > 0 && twoWayEdgeLines.every((feature) => feature.properties.side === "right"));
|
||||
const edgeOverride = validateOverrides({ schema: "native-road-overrides/v1", overrides: [{ id: "edge-yellow-dashed", kind: "edge-line-style", roadId: edgeLine.properties.road_id, side: edgeLine.properties.side, color: "yellow", pattern: "dashed" }] }, model);
|
||||
const styledEdgeLines = compileGeometry(model, edgeOverride).edgeLines.features.filter((feature) => feature.properties.road_id === edgeLine.properties.road_id && feature.properties.side === edgeLine.properties.side);
|
||||
assert.ok(styledEdgeLines.length > 1 && styledEdgeLines.every((feature) => feature.properties.effective_style === "yellow-dashed"));
|
||||
assert.throws(() => validateOverrides({ schema: "native-road-overrides/v1", overrides: [{ id: "bad-edge", kind: "edge-line-style", roadId: edgeLine.properties.road_id, side: "middle", color: "yellow", pattern: "solid" }] }, model), /Invalid edge line style override/);
|
||||
assert.throws(() => validateOverrides({ schema: "native-road-overrides/v1", overrides: [{ id: "missing-edge-road", kind: "edge-line-style", roadId: "road:way/missing:forward", side: "left", color: "yellow", pattern: "solid" }] }, model), /Invalid edge line style override/);
|
||||
assert.ok(geometry.directionArrows.features.every((feature) => feature.geometry.type === "Polygon" && feature.properties.provenance === "native-road-direction-arrow/v1" && feature.properties.placement_interval_meters === 32));
|
||||
assert.ok(geometry.turnArrows.features.every((feature) => feature.geometry.type === "Polygon" && feature.properties.provenance === "native-road-turn-arrow/v1"));
|
||||
assert.ok(geometry.connectors.features.length > 0);
|
||||
@@ -36,20 +74,70 @@ assert.ok(geometry.movements.length >= geometry.connectors.features.length);
|
||||
assert.ok(geometry.movements.every((movement) => movement.id.startsWith("movement:") && movement.connectorId.startsWith("connector:")));
|
||||
assert.ok(geometry.movements.every((movement) => ["connector", "continuous", "deferred-too-long"].includes(movement.geometryStatus)));
|
||||
assert.ok(geometry.intersectionSurface.features.every((feature) => feature.properties.rule === "junction-shared-cutback/v3"));
|
||||
assert.ok(geometry.intersectionSurface.features.every((feature) => ["approach-envelope", "connector-convex-fallback"].includes(feature.properties.boundary_mode)));
|
||||
assert.ok(geometry.intersectionSurface.features.every((feature) => ["approach-envelope", "rounded-approach-envelope", "connector-convex-fallback"].includes(feature.properties.boundary_mode)));
|
||||
assert.ok(geometry.intersectionSurface.features.every((feature) => feature.properties.approach_area_m2 > 0 && feature.properties.surface_area_m2 > 0 && feature.properties.expansion_ratio >= 1));
|
||||
for (const feature of geometry.intersectionSurface.features.filter((item) => item.properties.boundary_mode === "connector-convex-fallback")) assert.ok(geometry.diagnostics.some((item) => item.subjectId === feature.properties.native_id && item.rule === "junction-connector-envelope-fallback"));
|
||||
const controlOsm = `<osm><node id="1" lon="114" lat="30"/><node id="2" lon="114.00080" lat="30"><tag k="highway" v="crossing"/><tag k="crossing:markings" v="zebra"/></node><node id="3" lon="114.001" lat="30"/><node id="4" lon="114.002" lat="30"><tag k="highway" v="crossing"/><tag k="crossing:markings" v="unmarked"/></node><node id="5" lon="114.0035" lat="30"><tag k="highway" v="crossing"/></node><node id="6" lon="114.004" lat="30"/><node id="7" lon="114.001" lat="30.001"/><way id="60"><nd ref="1"/><nd ref="2"/><nd ref="3"/><nd ref="4"/><tag k="highway" v="residential"/></way><way id="61"><nd ref="5"/><nd ref="6"/><tag k="highway" v="footway"/></way><way id="62"><nd ref="3"/><nd ref="7"/><tag k="highway" v="residential"/></way></osm>`;
|
||||
const controlGeometry = compileGeometry(compileRoadModel(controlOsm, empty));
|
||||
assert.equal(controlGeometry.crosswalks.features.length, 6);
|
||||
assert.equal(controlGeometry.vehicleStopLines.features.length, 1);
|
||||
assert.ok(controlGeometry.crosswalks.features.every((feature) => feature.properties.crossing_node_id === "2" && feature.properties.provenance === "native-road-crosswalk/v1"));
|
||||
assert.ok(controlGeometry.vehicleStopLines.features.every((feature) => feature.properties.crossing_node_id === "2" && feature.properties.provenance === "native-road-stop-line/v1"));
|
||||
assert.ok(controlGeometry.crosswalks.features.every((feature) => feature.properties.junction_inset_m > 0));
|
||||
assert.equal(controlGeometry.vehicleStopLines.features[0].properties.junction_inset_m, controlGeometry.crosswalks.features[0].properties.junction_inset_m);
|
||||
assert.ok(controlGeometry.diagnostics.some((item) => item.rule === "crossing-no-native-lane" && item.sourceIds.includes("5")));
|
||||
assert.ok(controlGeometry.centerLines.features.length > 0);
|
||||
assert.ok(controlGeometry.centerLines.features.every((dash) => ![...controlGeometry.crosswalks.features, ...controlGeometry.vehicleStopLines.features].some((control) => ringsOverlap(dash.geometry.coordinates[0], control.geometry.coordinates[0]))));
|
||||
const solidControlLines = compileGeometry(compileRoadModel(controlOsm, empty), { schema: "native-road-overrides/v1", overrides: [{ id: "solid-control", kind: "center-line-style", segmentId: controlGeometry.centerLines.features[0].properties.segment_id, color: "yellow", pattern: "solid" }] }).centerLines.features;
|
||||
assert.ok(solidControlLines.every((dash) => ![...controlGeometry.crosswalks.features, ...controlGeometry.vehicleStopLines.features].some((control) => ringsOverlap(dash.geometry.coordinates[0], control.geometry.coordinates[0]))));
|
||||
const arrowControlOsm = `<osm><node id="1" lon="114" lat="30"/><node id="2" lon="114.00095" lat="30"><tag k="highway" v="crossing"/><tag k="crossing:markings" v="zebra"/></node><node id="3" lon="114.001" lat="30"/><way id="63"><nd ref="1"/><nd ref="2"/><nd ref="3"/><tag k="highway" v="residential"/><tag k="oneway" v="yes"/><tag k="lanes" v="1"/><tag k="turn:lanes" v="through"/></way></osm>`;
|
||||
const arrowControlGeometry = compileGeometry(compileRoadModel(arrowControlOsm, empty));
|
||||
assert.ok(arrowControlGeometry.turnArrows.features.length > 0);
|
||||
assert.ok(arrowControlGeometry.turnArrows.features.every((feature) => feature.properties.placement_distance_meters > 6));
|
||||
assert.equal(arrowControlGeometry.centerLines.features.length, 0);
|
||||
const serviceCenterLineOsm = `<osm><node id="1" lon="114" lat="30"/><node id="2" lon="114.001" lat="30"/><way id="64"><nd ref="1"/><nd ref="2"/><tag k="highway" v="service"/></way></osm>`;
|
||||
assert.equal(compileGeometry(compileRoadModel(serviceCenterLineOsm, empty)).centerLines.features.length, 0);
|
||||
const crossOsm = `<osm><node id="1" lon="114" lat="30"/><node id="2" lon="114.001" lat="30"/><node id="3" lon="114.002" lat="30"/><node id="4" lon="114.001" lat="30.001"/><node id="5" lon="114.001" lat="29.999"/><way id="40"><nd ref="1"/><nd ref="2"/><tag k="highway" v="residential"/><tag k="sidewalk" v="both"/></way><way id="41"><nd ref="2"/><nd ref="3"/><tag k="highway" v="residential"/><tag k="sidewalk" v="both"/></way><way id="42"><nd ref="5"/><nd ref="2"/><tag k="highway" v="residential"/><tag k="sidewalk" v="both"/></way><way id="43"><nd ref="2"/><nd ref="4"/><tag k="highway" v="residential"/><tag k="sidewalk" v="both"/></way></osm>`;
|
||||
const crossCenter = [114.001, 30];
|
||||
const crossGeometry = compileGeometry(compileRoadModel(crossOsm, empty));
|
||||
assert.equal(crossGeometry.intersectionSurface.features.length, 1);
|
||||
assert.equal(crossGeometry.intersectionSurface.features[0].properties.boundary_mode, "rounded-approach-envelope");
|
||||
assert.ok(crossGeometry.intersectionSurface.features[0].geometry.coordinates[0].length > 9);
|
||||
const crossBoundary = crossGeometry.intersectionSurface.features[0].geometry.coordinates[0];
|
||||
const crossRadius = (point) => Math.hypot((point[0] - crossCenter[0]) * 96400, (point[1] - crossCenter[1]) * 111320);
|
||||
// The sampled tangent arc must cut inward from its old straight chord; an
|
||||
// outward-bowed control point leaks asphalt into the pedestrian corner.
|
||||
const firstCurveEnd = crossBoundary[8];
|
||||
assert.ok(crossRadius(crossBoundary[4]) < crossRadius([(crossBoundary[0][0] + firstCurveEnd[0]) / 2, (crossBoundary[0][1] + firstCurveEnd[1]) / 2]));
|
||||
assert.equal(crossGeometry.turnArrows.features.length, 0);
|
||||
assert.ok(crossGeometry.directionArrows.features.length > 0);
|
||||
assert.ok(crossGeometry.directionArrows.features.every((feature) => feature.properties.maneuver === "through" && feature.properties.provenance === "native-road-direction-arrow/v1"));
|
||||
assert.ok(crossGeometry.roadSurface.features.some((feature) => Math.min(...feature.geometry.coordinates[0].map((point) => Math.hypot((point[0] - crossCenter[0]) * 96400, (point[1] - crossCenter[1]) * 111320))) < 4));
|
||||
// Approach asphalt ends at the shared cutback; the rounded junction surface
|
||||
// exclusively owns the central road area so its boundary remains visible.
|
||||
assert.ok(crossGeometry.roadSurface.features.every((feature) => Math.min(...feature.geometry.coordinates[0].map((point) => Math.hypot((point[0] - crossCenter[0]) * 96400, (point[1] - crossCenter[1]) * 111320))) > 4));
|
||||
const exteriorRings = (geometry) => geometry.type === "Polygon" ? [geometry.coordinates[0]] : geometry.coordinates.map((polygon) => polygon[0]);
|
||||
assert.ok(crossGeometry.sidewalkSurface.features.every((feature) => Math.min(...exteriorRings(feature.geometry).flat().map((point) => Math.hypot((point[0] - crossCenter[0]) * 96400, (point[1] - crossCenter[1]) * 111320))) > 5));
|
||||
assert.equal(crossGeometry.sidewalkSurface.features.filter((feature) => feature.properties.kind === "corner").length, 4);
|
||||
const crossSidewalkCorners = crossGeometry.sidewalkSurface.features.filter((feature) => feature.properties.kind === "corner");
|
||||
assert.equal(crossSidewalkCorners.length, 4);
|
||||
// A rounded sidewalk corner must sample both the curb and outer boundaries.
|
||||
// The legacy wedge had five closing-ring points; two curved edges need more.
|
||||
assert.ok(crossSidewalkCorners.every((feature) => feature.geometry.coordinates[0].length > 9));
|
||||
assert.ok(crossSidewalkCorners.every((feature) => {
|
||||
const ring = feature.geometry.coordinates[0];
|
||||
const outerStart = ring[1];
|
||||
const outerCurvePoint = ring[2];
|
||||
const outerEnd = ring[(ring.length - 1) / 2];
|
||||
const twiceArea = (outerEnd[0] - outerStart[0]) * (outerCurvePoint[1] - outerStart[1]) - (outerEnd[1] - outerStart[1]) * (outerCurvePoint[0] - outerStart[0]);
|
||||
return Math.abs(twiceArea) > 1e-12;
|
||||
}));
|
||||
assert.ok(crossSidewalkCorners.every((feature) => {
|
||||
const ring = feature.geometry.coordinates[0];
|
||||
const curbStart = ring[10];
|
||||
const curbCurvePoint = ring[11];
|
||||
const curbEnd = ring[0];
|
||||
const twiceArea = (curbEnd[0] - curbStart[0]) * (curbCurvePoint[1] - curbStart[1]) - (curbEnd[1] - curbStart[1]) * (curbCurvePoint[0] - curbStart[0]);
|
||||
return Math.abs(twiceArea) > 1e-12;
|
||||
}));
|
||||
const sharedInteriorNodeOsm = `<osm><node id="1" lon="114" lat="30"/><node id="2" lon="114.001" lat="30"/><node id="3" lon="114.002" lat="30"/><node id="4" lon="114.001" lat="30.001"/><way id="50"><nd ref="1"/><nd ref="2"/><nd ref="3"/><tag k="highway" v="residential"/><tag k="sidewalk" v="both"/></way><way id="51"><nd ref="4"/><nd ref="2"/><tag k="highway" v="residential"/><tag k="sidewalk" v="both"/></way></osm>`;
|
||||
const sharedInteriorModel = compileRoadModel(sharedInteriorNodeOsm, empty);
|
||||
assert.equal(sharedInteriorModel.roads.length, 6);
|
||||
@@ -60,7 +148,15 @@ assert.equal(sharedInteriorGeometry.intersectionSurface.features.length, 1);
|
||||
assert.equal(sharedInteriorGeometry.intersectionSurface.features[0].properties.osm_node_id, "2");
|
||||
assert.equal(sharedInteriorGeometry.intersectionSurface.features[0].properties.kind, "t");
|
||||
assert.ok(sharedInteriorGeometry.connectors.features.length >= 4);
|
||||
assert.ok(sharedInteriorGeometry.sidewalkSurface.features.some((feature) => feature.properties.kind === "corner" && /segment:way\/50\/1:.*->segment:way\/50\/2:/.test(feature.properties.native_id)));
|
||||
const throughConnector = sharedInteriorGeometry.connectors.features.find((feature) => feature.properties.turn === "through");
|
||||
assert.ok(throughConnector, "T junction emits a through connector");
|
||||
const throughCoordinates = throughConnector.geometry.coordinates;
|
||||
const throughStart = throughCoordinates[0]; const throughEnd = throughCoordinates.at(-1);
|
||||
for (const point of throughCoordinates.slice(1, -1)) {
|
||||
const area = Math.abs((throughEnd[0] - throughStart[0]) * (point[1] - throughStart[1]) - (throughEnd[1] - throughStart[1]) * (point[0] - throughStart[0]));
|
||||
assert.ok(area < 1e-12, "through connector stays on its lane-to-lane chord instead of bending through the junction node");
|
||||
}
|
||||
assert.ok(sharedInteriorGeometry.sidewalkSurface.features.some((feature) => feature.properties.kind === "continuation" && /segment:way\/50\/1:.*->segment:way\/50\/2:/.test(feature.properties.native_id)));
|
||||
const connection = initial.connections[0];
|
||||
assert.ok(initial.connections.every((item) => item.fromEndpointId.endsWith(":end") && item.toEndpointId.endsWith(":start")));
|
||||
assert.equal(initial.connections.length, new Set(initial.connections.map((item) => `${item.fromEndpointId}->${item.toEndpointId}`)).size);
|
||||
@@ -101,13 +197,29 @@ try {
|
||||
const compiledArea = compileArea(config);
|
||||
assert.equal(compiledArea.result.areaId, "fresh");
|
||||
assert.ok(fs.existsSync(path.join(outputRoot, "fresh", "native-road", "compiled.json")));
|
||||
const centerLineLayer = JSON.parse(fs.readFileSync(path.join(outputRoot, "fresh", "native-road", "layers", "center_lines.geojson"), "utf8"));
|
||||
const edgeLineLayer = JSON.parse(fs.readFileSync(path.join(outputRoot, "fresh", "native-road", "layers", "edge_lines.geojson"), "utf8"));
|
||||
assert.equal(centerLineLayer.type, "FeatureCollection");
|
||||
assert.equal(centerLineLayer.features.length, compiledArea.comparison.nativeCenterLineFeatures);
|
||||
assert.equal(edgeLineLayer.features.length, 0);
|
||||
assert.equal(compiledArea.comparison.schema, "native-road-comparison/v2");
|
||||
assert.equal(compiledArea.comparison.nativeRoadCount, compiledArea.result.model.roads.length);
|
||||
assert.equal(compiledArea.comparison.nativePublishedMovementCount, compiledArea.result.movements.filter((movement) => movement.geometryPublished).length);
|
||||
assert.equal(compiledArea.comparison.nativeCrosswalkFeatures, 0);
|
||||
assert.equal(compiledArea.comparison.nativeVehicleStopLineFeatures, 0);
|
||||
assert.equal(compiledArea.comparison.nativeApproachEnvelopeJunctions + compiledArea.comparison.nativeFallbackJunctions, compiledArea.comparison.nativeJunctionSurfaceFeatures);
|
||||
assert.ok(compiledArea.comparison.nativeMaxJunctionExpansionRatio >= 0);
|
||||
assert.equal(checkArea(config).ok, true);
|
||||
} finally {
|
||||
fs.rmSync(freshArea, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
function distance(a, b) {
|
||||
return Math.hypot((b[0] - a[0]) * 111320 * Math.cos(a[1] * Math.PI / 180), (b[1] - a[1]) * 111320);
|
||||
}
|
||||
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);
|
||||
return !(a[0] > b[2] || a[2] < b[0] || a[1] > b[3] || a[3] < b[1]);
|
||||
}
|
||||
console.log("native road tests passed");
|
||||
|
||||
@@ -278,6 +278,9 @@ assert.match(previewRuntime, /\(\) => record\?\.status === "normal"/);
|
||||
assert.match(previewRuntime, /Cesium\.SceneTransforms\.worldToWindowCoordinates/);
|
||||
assert.match(previewRuntime, /TrafficSignalDynamic_/);
|
||||
assert.match(previewRuntime, /TrafficSignalDynamic_\$\{nodeKey\}_countdown_\$\{String\(value\)\.padStart\(2, "0"\)\}/);
|
||||
assert.match(previewRuntime, /native-preview-traffic-simulation\/v1/);
|
||||
assert.match(previewRuntime, /leader-gap/);
|
||||
assert.match(previewRuntime, /stopReason = "traffic-signal"/);
|
||||
assert.match(previewRuntime, /ColorBlendMode\.REPLACE/);
|
||||
assert.match(previewRuntime, /setBuildingGhost/);
|
||||
assert.match(previewRuntime, /fetch\(url, \{ cache: "no-store" \}\)/);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user