Refactor area preview orchestration
This commit is contained in:
@@ -488,6 +488,21 @@ File records use this shape:
|
||||
所有权属于独立 `preview` manifest。否则单跑 `--stages preview` 会把 Cesium manifest
|
||||
错误判 stale。
|
||||
|
||||
### Preview Assembly Boundary
|
||||
|
||||
`build-area.js` 是 stage orchestrator:它检查 GLB / metadata、按既有顺序写 route、vehicle、
|
||||
runtime 与 HTML,并写 preview manifest。预览内容实现不得回流到这里:
|
||||
|
||||
- `lib/vehicle-route.js`:OSM XML -> route JSON object;保持坐标、排序、lane offset 和
|
||||
`generatedAt` 语义。
|
||||
- `lib/vehicle-model.js`:-> preview vehicle glTF object;保持 mesh/material 名称和内嵌
|
||||
buffer layout。
|
||||
- `lib/area-preview.js`:复制 browser runtime、生成 HTML、HTML/script JSON 转义和
|
||||
preview summary。
|
||||
|
||||
这是保持 standalone `--stages preview` 与 preview manifest ownership 不变的边界;这些模块
|
||||
不能读取 area config、写 stage manifest 或启动外部进程。
|
||||
|
||||
`preview` manifest:
|
||||
|
||||
- `inputs.config`
|
||||
@@ -549,6 +564,10 @@ Manifest files are written atomically via `*.tmp` then `renameSync`.
|
||||
- `node --check scripts/build-area.js`
|
||||
- `node --check scripts/diagnose-area.js`
|
||||
- `node --check scripts/check-area.js`
|
||||
- `node --check scripts/lib/vehicle-route.js`
|
||||
- `node --check scripts/lib/vehicle-model.js`
|
||||
- `node --check scripts/lib/area-preview.js`
|
||||
- `npm run test:preview-assets`
|
||||
- `npm run build:area -- --config config/areas/nantaizi-lake-innovation-valley.json --stages intermediates`
|
||||
- `npm run build:area -- --config config/areas/nantaizi-lake-innovation-valley.json --stages blender`
|
||||
- `npm run build:area -- --config config/areas/nantaizi-lake-innovation-valley.json --stages cesium`
|
||||
|
||||
@@ -87,7 +87,7 @@ config/areas/<id>.json
|
||||
|
||||
| 文件 | 行数 | 职责 |
|
||||
|---|---|---|
|
||||
| `build-area.js` | 961 | 主入口:区域配置读取、阶段调度、Cesium 预览页、车辆巡航和 stage manifest 写入 |
|
||||
| `build-area.js` | 约 530 | 主入口:区域配置读取、阶段调度、preview 文件写入和 stage manifest ownership |
|
||||
| `diagnose-area.js` | 36 | 快速诊断入口:调用共享 area diagnostics 并打印完整报告 |
|
||||
| `check-area.js` | 74 | 区域质量门入口:调用共享 area diagnostics,输出 PASS/FAIL 并设置退出码 |
|
||||
| `lib/area-diagnostics.js` | 776 | 共享区域诊断事实源:OSM、产物、metadata、stage manifest、GLB digest 和质量门分类 |
|
||||
@@ -96,6 +96,9 @@ config/areas/<id>.json
|
||||
| `build-osm2streets-qgis.js` | 1468 | intermediates:osm2streets 解析、图层拆分、人行道转角合成、GeoPackage 与 QGIS 工程生成 |
|
||||
| `reimport-gpkg.js` | 179 | reimport:GeoPackage → GeoJSON 反向导出 |
|
||||
| `lib/scene-layers.js` | 164 | 九个图层的单一事实源 + 四个派生函数 |
|
||||
| `lib/vehicle-route.js` | 约 180 | 从 OSM 提取确定性预览巡航路线 |
|
||||
| `lib/vehicle-model.js` | 约 150 | 生成内嵌 buffer 的预览车辆 glTF |
|
||||
| `lib/area-preview.js` | 约 110 | 复制 preview runtime、生成 HTML 与转义配置注入 |
|
||||
| `lib/cesium-preview.js` / `.css` | 672 / 230 | 预览页运行时,见 [../preview/](../preview/index.md) |
|
||||
| `normalize-lane-arrows.py` | 182 | 合并 osm2streets 的三角网箭头(跑在 QGIS Python 里) |
|
||||
| `parity.js` | 270 | 产物一致性校验驱动 |
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
# Preview:Cesium 预览层
|
||||
|
||||
> 覆盖 `scripts/lib/cesium-preview.js`(672 行)与 `cesium-preview.css`(230 行)。
|
||||
> 覆盖浏览器运行时 `scripts/lib/cesium-preview.js`(672 行)与
|
||||
> `cesium-preview.css`(230 行),以及 Node 侧的 `scripts/lib/area-preview.js`。
|
||||
> 运行时:浏览器。全仓唯一的 DOM 环境。
|
||||
|
||||
---
|
||||
@@ -19,9 +20,9 @@
|
||||
```
|
||||
scripts/lib/cesium-preview.js ─── 原样 copyFileSync ──▶ outputs/<area>/cesium-preview.js
|
||||
scripts/lib/cesium-preview.css ─── 原样 copyFileSync ──▶ outputs/<area>/cesium-preview.css
|
||||
(build-area.js:328-335)
|
||||
(area-preview.js:writeCesiumPreviewSupportFiles)
|
||||
<area>-cesium-preview.html ─── 模板字符串生成 ────▶ 同目录
|
||||
(build-area.js:697)
|
||||
(area-preview.js:cesiumPreviewHtml)
|
||||
```
|
||||
|
||||
所以:**没有打包、没有转译、没有 npm 依赖、没有模块系统**。浏览器直接吃。
|
||||
@@ -40,18 +41,22 @@ const config = window.OSM_ASSET_PREVIEW_CONFIG || {}; // :4
|
||||
// config.areaId / .glbName / .metadataName / .routeName / .vehicleModelName
|
||||
```
|
||||
|
||||
生成侧在 `build-area.js:697 cesiumPreviewHtml()`,注入时**必须转义**:
|
||||
生成侧在 `scripts/lib/area-preview.js` 的 `cesiumPreviewHtml()`,注入时**必须转义**:
|
||||
|
||||
| 场景 | 用 |
|
||||
|---|---|
|
||||
| HTML 文本/属性 | `escapeHtml()`(`build-area.js:759`) |
|
||||
| `<script>` 里的 JSON | `escapeScriptJson()`(`:767`) |
|
||||
| HTML 文本/属性 | `escapeHtml()` |
|
||||
| `<script>` 里的 JSON | `escapeScriptJson()` |
|
||||
|
||||
`|| {}` 的兜底不能删——它让 JS 在没有配置块时也不至于在第一行就崩。
|
||||
|
||||
**加一个新的可配置项**:`cesiumPreviewHtml()` 里加进注入的 JSON,JS 侧从 `config` 读,
|
||||
两边都要动。
|
||||
|
||||
`build-area.js` 只保留 GLB / metadata 依赖检查、写入顺序和 preview manifest ownership;
|
||||
不要把 HTML 模板、runtime copy 或转义实现移回阶段调度器。路线 JSON 与车辆 glTF 分别由
|
||||
`vehicle-route.js` 和 `vehicle-model.js` 生成,二者都是不启动外部工具的 Node 模块。
|
||||
|
||||
---
|
||||
|
||||
## 加载流程
|
||||
|
||||
@@ -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,24 @@
|
||||
# Split area build orchestration design
|
||||
|
||||
## Module Boundaries
|
||||
|
||||
```text
|
||||
scripts/lib/vehicle-route.js OSM XML -> preview route object
|
||||
scripts/lib/vehicle-model.js -> vehicle glTF object
|
||||
scripts/lib/area-preview.js HTML/template + runtime file copy + preview summary
|
||||
scripts/build-area.js dependency checks, writes, stage timing, manifest ownership
|
||||
```
|
||||
|
||||
`vehicle-route.js` and `vehicle-model.js` are pure Node modules. `area-preview.js` may use Node filesystem and path, but receives all names/paths through arguments; it must not import area config, stage manifest or spawn processes.
|
||||
|
||||
## Preview Flow
|
||||
|
||||
`build-area.writeCesiumPreview()` continues to ensure GLB/metadata, establish timing, write route/model/runtime/HTML in the existing order, and write `preview.manifest.json`. It delegates route construction, vehicle construction, support copy and HTML generation to the three modules.
|
||||
|
||||
## Compatibility
|
||||
|
||||
Public CLI and all generated filenames remain unchanged. The route's `generatedAt` naturally changes on each generation and is not a stable parity field; route segments, vehicle glTF structure and preview config are the stable contract. No Blender/GLB parity run is needed because no Blender or GLB generation code changes.
|
||||
|
||||
## Rollback
|
||||
|
||||
Restore the extracted functions to `build-area.js` and delete the three modules; no data migration or output cleanup 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,20 @@
|
||||
# Split area build orchestration implementation plan
|
||||
|
||||
1. Extract vehicle route helpers into `vehicle-route.js`; add unit coverage using fixture OSM.
|
||||
2. Extract vehicle glTF builder into `vehicle-model.js`; assert schema, mesh/material counts and data URI.
|
||||
3. Extract preview support-file copy, HTML template, escaping and summary into `area-preview.js`.
|
||||
4. Replace `build-area.js` bodies with imports/calls while preserving preview manifest ownership and ordering.
|
||||
5. Run Node syntax/unit tests, then only the nantaizi `preview`, diagnose and quality gate commands.
|
||||
6. Update pipeline/preview specs and changelog; inspect diff for accidental stage or output changes.
|
||||
|
||||
## Validation
|
||||
|
||||
- `node --check scripts/build-area.js`
|
||||
- `node --check scripts/lib/vehicle-route.js`
|
||||
- `node --check scripts/lib/vehicle-model.js`
|
||||
- `node --check scripts/lib/area-preview.js`
|
||||
- `npm run test:preview-assets`
|
||||
- `npm run build:area -- --config config/areas/nantaizi-lake-innovation-valley.json --stages preview`
|
||||
- `npm run diagnose:area -- --config config/areas/nantaizi-lake-innovation-valley.json`
|
||||
- `npm run check:area -- --config config/areas/nantaizi-lake-innovation-valley.json`
|
||||
- `git diff --check`
|
||||
41
.trellis/tasks/08-04-split-area-build-orchestration/prd.md
Normal file
41
.trellis/tasks/08-04-split-area-build-orchestration/prd.md
Normal file
@@ -0,0 +1,41 @@
|
||||
# Split area build orchestration
|
||||
|
||||
## Goal
|
||||
|
||||
将区域构建入口收敛为阶段调度器,把车辆路线、车辆 glTF 和 Cesium preview 组装移到职责明确的模块,降低后续修改预览功能时触碰 QGIS/Blender/Cesium 调度逻辑的风险。
|
||||
|
||||
## Confirmed Facts
|
||||
|
||||
- `scripts/build-area.js` 负责阶段调度,但其 440-928 行还包含 preview manifest、OSM 路线解析、路线几何、车辆 glTF 建模、HTML 模板和转义。
|
||||
- `preview` 已有独立 manifest ownership;`cesium` 调用 preview 生成,但 standalone `--stages preview` 必须继续有效。
|
||||
- preview HTML 向浏览器运行时注入 `areaId`、GLB、metadata、route 和 vehicle model 文件名;HTML / script JSON 使用不同转义函数。
|
||||
- 车辆路线和车辆 glTF 是预览辅助产物,不改变主 GLB;其内容仍属于 preview manifest 输出。
|
||||
- 本任务只验证南台子,不能改动 `hanyang-block` 或主动重跑重型阶段。
|
||||
|
||||
## Requirements
|
||||
|
||||
1. 新建纯 Node 模块承载 OSM 到车辆路线的解析与几何计算,保持 route JSON 的字段、排序、lane offset、时间戳语义和 deterministic coordinates 不变。
|
||||
2. 新建纯 Node 模块承载 preview 车辆 glTF 生成,保持 glTF schema、mesh/material 名称和二进制布局不变。
|
||||
3. 新建 preview assembly 模块承载 runtime 文件复制、HTML 模板与安全转义;`build-area.js` 仍拥有 preview 阶段依赖检查、文件写入顺序和 manifest 写入。
|
||||
4. 主入口不再定义以上预览/车辆实现细节,只通过模块 API 组装 preview 阶段。
|
||||
5. 不改变 CLI、stage 名称/aliases、stdout marker、manifest 字段、输出路径、默认行为或浏览器运行时。
|
||||
6. 对抽出的纯逻辑补 Node 测试,验证路线与 vehicle glTF 结构;通过南台子 preview 阶段和 diagnostics/check 确认真实产物仍有效。
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [x] `build-area.js` 不再含路线解析、车辆 glTF mesh 构建、preview HTML 模板或转义实现。
|
||||
- [x] 新模块在不依赖 Blender/QGIS 的 Node 测试中覆盖路线和车辆 glTF 关键结构。
|
||||
- [x] 南台子 `--stages preview` 成功,preview manifest 仍 fresh,route / vehicle / HTML 文件可读且配置文件名不变。
|
||||
- [x] `diagnose:area` 和 `check:area` 对南台子仍通过。
|
||||
- [x] 所有修改脚本通过 Node 语法检查、`git diff --check`,且不运行 `hanyang-block`。
|
||||
|
||||
## Out Of Scope
|
||||
|
||||
- 不改变车辆路线算法、车模型外观、预览 HTML/UI、Cesium runtime 或 GLB 导出。
|
||||
- 不拆分 QGIS、Blender、Cesium 或 compression 的阶段实现。
|
||||
- 不新增前端构建系统或运行时依赖。
|
||||
|
||||
## Key Decisions
|
||||
|
||||
- 这是保守纯重构;输出内容和 manifest 契约比文件行数更重要。
|
||||
- preview assembly 依赖注入 filesystem/path/manifest 所需值,避免模块反向读取区域配置或启动外部进程。
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"id": "split-area-build-orchestration",
|
||||
"name": "split-area-build-orchestration",
|
||||
"title": "Split area build orchestration",
|
||||
"description": "Extract vehicle route, vehicle model, and preview assembly from build-area.js without changing area outputs.",
|
||||
"status": "in_progress",
|
||||
"dev_type": null,
|
||||
"scope": null,
|
||||
"package": null,
|
||||
"priority": "P2",
|
||||
"creator": "dingkang",
|
||||
"assignee": "dingkang",
|
||||
"createdAt": "2026-08-04",
|
||||
"completedAt": null,
|
||||
"branch": null,
|
||||
"base_branch": "main",
|
||||
"worktree_path": null,
|
||||
"commit": null,
|
||||
"pr_url": null,
|
||||
"subtasks": [],
|
||||
"children": [],
|
||||
"parent": null,
|
||||
"relatedFiles": [],
|
||||
"notes": "",
|
||||
"meta": {}
|
||||
}
|
||||
@@ -2,6 +2,12 @@
|
||||
|
||||
## 2026-08-04
|
||||
|
||||
- 重构区域构建入口的 preview 实现边界:`build-area.js` 现在只调度阶段、写 preview
|
||||
文件并拥有 manifest;OSM 巡航路线、轻量车辆 glTF、preview runtime copy / HTML 模板 /
|
||||
安全转义分别位于 `scripts/lib/vehicle-route.js`、`vehicle-model.js` 和
|
||||
`area-preview.js`。CLI、stage aliases、文件名、manifest 字段和浏览器运行时不变;新增
|
||||
`npm run test:preview-assets` 覆盖路线、glTF buffer/schema 和 HTML/script 注入转义,
|
||||
并通过南台子 standalone preview、诊断和质量门验证。
|
||||
- GLB 预算现在由区域配置契约化:`budget.glbSizeMb`、`nodes`、`images`、`triangles` 和
|
||||
`embeddedImageBytesMb` 可覆盖默认限制;任何放宽必须有 `budget.reason`。共享 digest 新增
|
||||
静态/实例化 render triangles、嵌入贴图字节、节点来源和最大贴图摘要,`diagnose:area`、
|
||||
|
||||
@@ -12,7 +12,8 @@
|
||||
"diagnose:area": "node scripts/diagnose-area.js",
|
||||
"preflight:area": "node scripts/preflight-area.js",
|
||||
"test:preflight": "node scripts/test-area-preflight.js",
|
||||
"test:budgets": "node scripts/test-asset-budgets.js"
|
||||
"test:budgets": "node scripts/test-asset-budgets.js",
|
||||
"test:preview-assets": "node scripts/test-preview-assets.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"osm2streets-js-node": "0.1.4"
|
||||
|
||||
@@ -11,6 +11,13 @@ const {
|
||||
layerFile,
|
||||
} = require("./lib/scene-layers");
|
||||
const { digest: glbDigest } = require("./glb-digest");
|
||||
const { buildVehicleRoute: buildPreviewVehicleRoute } = require("./lib/vehicle-route");
|
||||
const { makeVehicleGltf: makePreviewVehicleGltf } = require("./lib/vehicle-model");
|
||||
const {
|
||||
cesiumPreviewHtml,
|
||||
previewSummary,
|
||||
writeCesiumPreviewSupportFiles,
|
||||
} = require("./lib/area-preview");
|
||||
const {
|
||||
fileRecord,
|
||||
evaluateGlbBudget,
|
||||
@@ -479,454 +486,20 @@ function writeCesiumPreview(area) {
|
||||
});
|
||||
}
|
||||
|
||||
function writeCesiumPreviewSupportFiles(outDir) {
|
||||
for (const file of ["cesium-preview.css", "cesium-preview.js"]) {
|
||||
const source = path.join(repoRoot, "scripts", "lib", file);
|
||||
ensureFile(source, `Cesium preview support file '${file}'`);
|
||||
fs.copyFileSync(source, path.join(outDir, file));
|
||||
}
|
||||
}
|
||||
|
||||
function writeVehicleRoute(area) {
|
||||
const route = buildVehicleRoute(area.input);
|
||||
const route = buildPreviewVehicleRoute(area.input);
|
||||
fs.mkdirSync(path.dirname(area.outputs.vehicleRoute), { recursive: true });
|
||||
fs.writeFileSync(area.outputs.vehicleRoute, `${JSON.stringify(route, null, 2)}\n`);
|
||||
console.log(`Vehicle route: ${area.outputs.vehicleRoute}`);
|
||||
}
|
||||
|
||||
function writeVehicleModel(area) {
|
||||
const gltf = makeVehicleGltf();
|
||||
const gltf = makePreviewVehicleGltf();
|
||||
fs.mkdirSync(path.dirname(area.outputs.vehicleModel), { recursive: true });
|
||||
fs.writeFileSync(area.outputs.vehicleModel, `${JSON.stringify(gltf, null, 2)}\n`);
|
||||
console.log(`Vehicle model: ${area.outputs.vehicleModel}`);
|
||||
}
|
||||
|
||||
function buildVehicleRoute(osmPath) {
|
||||
const xml = fs.readFileSync(osmPath, "utf8");
|
||||
const bounds = osmBounds(xml);
|
||||
const nodes = new Map();
|
||||
for (const match of xml.matchAll(/<node\b([^>]*)>/g)) {
|
||||
const attrs = xmlAttrs(match[1]);
|
||||
if (!attrs.id || attrs.lon === undefined || attrs.lat === undefined) continue;
|
||||
nodes.set(attrs.id, [Number(attrs.lon), Number(attrs.lat)]);
|
||||
}
|
||||
|
||||
const segments = [];
|
||||
for (const match of xml.matchAll(/<way\b([^>]*)>([\s\S]*?)<\/way>/g)) {
|
||||
const attrs = xmlAttrs(match[1]);
|
||||
const body = match[2];
|
||||
const tags = {};
|
||||
for (const tagMatch of body.matchAll(/<tag\b([^>]*)\/?>/g)) {
|
||||
const tag = xmlAttrs(tagMatch[1]);
|
||||
if (tag.k) tags[tag.k] = tag.v || "";
|
||||
}
|
||||
if (!isCruiseHighway(tags)) continue;
|
||||
const coords = [];
|
||||
for (const ndMatch of body.matchAll(/<nd\b([^>]*)\/?>/g)) {
|
||||
const nd = xmlAttrs(ndMatch[1]);
|
||||
const coord = nodes.get(nd.ref);
|
||||
if (coord) coords.push(coord);
|
||||
}
|
||||
const runs = splitInBounds(compactCoords(coords), bounds);
|
||||
let runIndex = 0;
|
||||
for (const run of runs) {
|
||||
const lengthMeters = routeLength(run);
|
||||
if (lengthMeters < 20) continue;
|
||||
runIndex += 1;
|
||||
const laneOffsetMeters = 1.3;
|
||||
const shiftedRun = offsetPolylineRight(run, laneOffsetMeters);
|
||||
segments.push({
|
||||
id: runIndex === 1 ? (attrs.id || `way-${segments.length + 1}`) : `${attrs.id || "way"}-${runIndex}`,
|
||||
name: tags.name || tags.highway || "road",
|
||||
highway: tags.highway || "",
|
||||
oneWay: tags.oneway || "",
|
||||
lengthMeters,
|
||||
laneOffsetMeters,
|
||||
coordinates: shiftedRun,
|
||||
centerlineCoordinates: run,
|
||||
});
|
||||
}
|
||||
}
|
||||
segments.sort((a, b) => b.lengthMeters - a.lengthMeters);
|
||||
return {
|
||||
source: osmPath,
|
||||
bounds,
|
||||
generatedAt: new Date().toISOString(),
|
||||
speedMetersPerSecond: 8.0,
|
||||
loop: true,
|
||||
segments,
|
||||
};
|
||||
}
|
||||
|
||||
function osmBounds(xml) {
|
||||
const match = xml.match(/<bounds\b([^>]*)\/?>/);
|
||||
if (!match) return null;
|
||||
const attrs = xmlAttrs(match[1]);
|
||||
const bounds = {
|
||||
minLon: Number(attrs.minlon),
|
||||
minLat: Number(attrs.minlat),
|
||||
maxLon: Number(attrs.maxlon),
|
||||
maxLat: Number(attrs.maxlat),
|
||||
};
|
||||
return Object.values(bounds).every(Number.isFinite) ? bounds : null;
|
||||
}
|
||||
|
||||
function xmlAttrs(text) {
|
||||
const attrs = {};
|
||||
for (const match of text.matchAll(/([:\w-]+)\s*=\s*("([^"]*)"|'([^']*)')/g)) {
|
||||
attrs[match[1]] = match[3] !== undefined ? match[3] : match[4];
|
||||
}
|
||||
return attrs;
|
||||
}
|
||||
|
||||
function isCruiseHighway(tags) {
|
||||
const highway = tags.highway || "";
|
||||
if (!highway) return false;
|
||||
if (tags.area === "yes") return false;
|
||||
const blocked = new Set([
|
||||
"footway", "path", "pedestrian", "steps", "cycleway", "service", "track",
|
||||
"bridleway", "corridor", "elevator", "platform", "construction",
|
||||
]);
|
||||
return !blocked.has(highway);
|
||||
}
|
||||
|
||||
function compactCoords(coords) {
|
||||
const out = [];
|
||||
for (const coord of coords) {
|
||||
const last = out[out.length - 1];
|
||||
if (!last || last[0] !== coord[0] || last[1] !== coord[1]) {
|
||||
out.push(coord);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function offsetPolylineRight(coords, offsetMeters) {
|
||||
if (coords.length < 2 || offsetMeters === 0) return coords;
|
||||
const refLat = coords.reduce((sum, coord) => sum + coord[1], 0) / coords.length;
|
||||
const metersPerLat = 111320.0;
|
||||
const metersPerLon = 111320.0 * Math.cos(degreesToRadians(refLat));
|
||||
const points = coords.map((coord) => ({
|
||||
x: coord[0] * metersPerLon,
|
||||
y: coord[1] * metersPerLat,
|
||||
lon: coord[0],
|
||||
lat: coord[1],
|
||||
}));
|
||||
return points.map((point, index) => {
|
||||
const prev = points[Math.max(0, index - 1)];
|
||||
const next = points[Math.min(points.length - 1, index + 1)];
|
||||
let dx = next.x - prev.x;
|
||||
let dy = next.y - prev.y;
|
||||
const length = Math.hypot(dx, dy);
|
||||
if (length < 0.001) return [point.lon, point.lat];
|
||||
dx /= length;
|
||||
dy /= length;
|
||||
const rightX = dy;
|
||||
const rightY = -dx;
|
||||
return [
|
||||
(point.x + rightX * offsetMeters) / metersPerLon,
|
||||
(point.y + rightY * offsetMeters) / metersPerLat,
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
function splitInBounds(coords, bounds) {
|
||||
if (!bounds) return [coords];
|
||||
const runs = [];
|
||||
let current = [];
|
||||
for (const coord of coords) {
|
||||
if (insideBounds(coord, bounds)) {
|
||||
current.push(coord);
|
||||
} else if (current.length) {
|
||||
if (current.length >= 2) runs.push(current);
|
||||
current = [];
|
||||
}
|
||||
}
|
||||
if (current.length >= 2) runs.push(current);
|
||||
return runs;
|
||||
}
|
||||
|
||||
function insideBounds(coord, bounds) {
|
||||
const pad = 0.00002;
|
||||
return (
|
||||
coord[0] >= bounds.minLon - pad &&
|
||||
coord[0] <= bounds.maxLon + pad &&
|
||||
coord[1] >= bounds.minLat - pad &&
|
||||
coord[1] <= bounds.maxLat + pad
|
||||
);
|
||||
}
|
||||
|
||||
function routeLength(coords) {
|
||||
let total = 0;
|
||||
for (let i = 1; i < coords.length; i += 1) {
|
||||
total += haversineMeters(coords[i - 1], coords[i]);
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
function haversineMeters(a, b) {
|
||||
const radius = 6371008.8;
|
||||
const lat1 = degreesToRadians(a[1]);
|
||||
const lat2 = degreesToRadians(b[1]);
|
||||
const dLat = degreesToRadians(b[1] - a[1]);
|
||||
const dLon = degreesToRadians(b[0] - a[0]);
|
||||
const sinLat = Math.sin(dLat / 2);
|
||||
const sinLon = Math.sin(dLon / 2);
|
||||
const h = sinLat * sinLat + Math.cos(lat1) * Math.cos(lat2) * sinLon * sinLon;
|
||||
return 2 * radius * Math.asin(Math.min(1, Math.sqrt(h)));
|
||||
}
|
||||
|
||||
function degreesToRadians(value) {
|
||||
return value * Math.PI / 180;
|
||||
}
|
||||
|
||||
function makeVehicleGltf() {
|
||||
const meshes = [];
|
||||
const nodes = [];
|
||||
const bufferParts = [];
|
||||
const bufferViews = [];
|
||||
const accessors = [];
|
||||
|
||||
function align4(bytes) {
|
||||
while (bytes.length % 4 !== 0) bytes.push(0);
|
||||
}
|
||||
|
||||
function addBufferView(bytes, target) {
|
||||
align4(bufferParts);
|
||||
const offset = bufferParts.length;
|
||||
bufferParts.push(...bytes);
|
||||
const view = { buffer: 0, byteOffset: offset, byteLength: bytes.length };
|
||||
if (target) view.target = target;
|
||||
bufferViews.push(view);
|
||||
return bufferViews.length - 1;
|
||||
}
|
||||
|
||||
function floatBytes(values) {
|
||||
const buffer = Buffer.alloc(values.length * 4);
|
||||
values.forEach((value, index) => buffer.writeFloatLE(value, index * 4));
|
||||
return Array.from(buffer);
|
||||
}
|
||||
|
||||
function ushortBytes(values) {
|
||||
const buffer = Buffer.alloc(values.length * 2);
|
||||
values.forEach((value, index) => buffer.writeUInt16LE(value, index * 2));
|
||||
return Array.from(buffer);
|
||||
}
|
||||
|
||||
function addAccessor(bufferView, componentType, count, type, min, max) {
|
||||
const accessor = { bufferView, componentType, count, type };
|
||||
if (min) accessor.min = min;
|
||||
if (max) accessor.max = max;
|
||||
accessors.push(accessor);
|
||||
return accessors.length - 1;
|
||||
}
|
||||
|
||||
function addMesh(name, geometry, material) {
|
||||
const positionView = addBufferView(floatBytes(geometry.positions), 34962);
|
||||
const indexView = addBufferView(ushortBytes(geometry.indices), 34963);
|
||||
const positionAccessor = addAccessor(
|
||||
positionView,
|
||||
5126,
|
||||
geometry.positions.length / 3,
|
||||
"VEC3",
|
||||
geometry.min,
|
||||
geometry.max,
|
||||
);
|
||||
const indexAccessor = addAccessor(indexView, 5123, geometry.indices.length, "SCALAR");
|
||||
meshes.push({
|
||||
name,
|
||||
primitives: [{
|
||||
attributes: { POSITION: positionAccessor },
|
||||
indices: indexAccessor,
|
||||
material,
|
||||
}],
|
||||
});
|
||||
nodes.push({ name, mesh: meshes.length - 1 });
|
||||
}
|
||||
|
||||
addMesh("body", cuboid(0, 0, 0.72, 4.6, 1.9, 0.9), 0);
|
||||
addMesh("hood", cuboid(1.35, 0, 1.1, 1.25, 1.74, 0.38), 0);
|
||||
addMesh("cabin", cuboid(-0.55, 0, 1.42, 1.75, 1.55, 0.82), 1);
|
||||
addMesh("rear", cuboid(-1.65, 0, 1.08, 0.95, 1.78, 0.42), 0);
|
||||
addMesh("front_windshield", cuboid(0.28, 0, 1.58, 0.12, 1.42, 0.58), 2);
|
||||
addMesh("left_window", cuboid(-0.55, -0.82, 1.52, 1.25, 0.08, 0.48), 2);
|
||||
addMesh("right_window", cuboid(-0.55, 0.82, 1.52, 1.25, 0.08, 0.48), 2);
|
||||
for (const x of [-1.55, 1.45]) {
|
||||
for (const y of [-1.02, 1.02]) {
|
||||
addMesh(`wheel_${x}_${y}`, cylinderY(x, y, 0.46, 0.38, 0.32, 16), 3);
|
||||
addMesh(`hub_${x}_${y}`, cylinderY(x, y, 0.46, 0.2, 0.34, 12), 4);
|
||||
}
|
||||
}
|
||||
addMesh("left_headlight", cuboid(2.36, -0.48, 0.9, 0.08, 0.32, 0.16), 5);
|
||||
addMesh("right_headlight", cuboid(2.36, 0.48, 0.9, 0.08, 0.32, 0.16), 5);
|
||||
addMesh("left_tail", cuboid(-2.36, -0.55, 0.9, 0.08, 0.28, 0.16), 6);
|
||||
addMesh("right_tail", cuboid(-2.36, 0.55, 0.9, 0.08, 0.28, 0.16), 6);
|
||||
|
||||
const buffer = Buffer.from(bufferParts);
|
||||
return {
|
||||
asset: { version: "2.0", generator: "osm-asset-pipeline vehicle preview" },
|
||||
scene: 0,
|
||||
scenes: [{ nodes: nodes.map((_, index) => index) }],
|
||||
nodes,
|
||||
meshes,
|
||||
buffers: [{
|
||||
byteLength: buffer.length,
|
||||
uri: `data:application/octet-stream;base64,${buffer.toString("base64")}`,
|
||||
}],
|
||||
bufferViews,
|
||||
accessors,
|
||||
materials: [
|
||||
material("paint red", [0.82, 0.05, 0.035, 1], 0.55, 0.25),
|
||||
material("dark roof", [0.08, 0.08, 0.085, 1], 0.45, 0.35),
|
||||
material("glass", [0.04, 0.12, 0.16, 0.82], 0.18, 0.08),
|
||||
material("tire", [0.015, 0.014, 0.013, 1], 0.75, 0.65),
|
||||
material("wheel hub", [0.72, 0.72, 0.68, 1], 0.35, 0.85),
|
||||
material("headlight", [1.0, 0.92, 0.62, 1], 0.12, 0.0),
|
||||
material("tail light", [0.95, 0.03, 0.03, 1], 0.25, 0.0),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function material(name, color, roughness, metallic) {
|
||||
return {
|
||||
name,
|
||||
pbrMetallicRoughness: {
|
||||
baseColorFactor: color,
|
||||
roughnessFactor: roughness,
|
||||
metallicFactor: metallic,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function cuboid(cx, lateral, up, sx, width, height) {
|
||||
const x0 = cx - sx / 2;
|
||||
const x1 = cx + sx / 2;
|
||||
const y0 = up - height / 2;
|
||||
const y1 = up + height / 2;
|
||||
const z0 = lateral - width / 2;
|
||||
const z1 = lateral + width / 2;
|
||||
const positions = [
|
||||
x0, y0, z0, x1, y0, z0, x1, y1, z0, x0, y1, z0,
|
||||
x0, y0, z1, x1, y0, z1, x1, y1, z1, x0, y1, z1,
|
||||
];
|
||||
const indices = [
|
||||
0, 1, 2, 0, 2, 3, 4, 6, 5, 4, 7, 6,
|
||||
0, 4, 5, 0, 5, 1, 1, 5, 6, 1, 6, 2,
|
||||
2, 6, 7, 2, 7, 3, 3, 7, 4, 3, 4, 0,
|
||||
];
|
||||
return { positions, indices, min: [x0, y0, z0], max: [x1, y1, z1] };
|
||||
}
|
||||
|
||||
function cylinderY(cx, lateral, up, radius, width, segments) {
|
||||
const positions = [];
|
||||
const indices = [];
|
||||
const z0 = lateral - width / 2;
|
||||
const z1 = lateral + width / 2;
|
||||
for (const z of [z0, z1]) {
|
||||
positions.push(cx, up, z);
|
||||
for (let i = 0; i < segments; i += 1) {
|
||||
const angle = 2 * Math.PI * i / segments;
|
||||
positions.push(cx + Math.cos(angle) * radius, up + Math.sin(angle) * radius, z);
|
||||
}
|
||||
}
|
||||
const center0 = 0;
|
||||
const center1 = segments + 1;
|
||||
for (let i = 0; i < segments; i += 1) {
|
||||
const a0 = center0 + 1 + i;
|
||||
const b0 = center0 + 1 + ((i + 1) % segments);
|
||||
const a1 = center1 + 1 + i;
|
||||
const b1 = center1 + 1 + ((i + 1) % segments);
|
||||
indices.push(center0, b0, a0);
|
||||
indices.push(center1, a1, b1);
|
||||
indices.push(a0, b0, b1, a0, b1, a1);
|
||||
}
|
||||
return {
|
||||
positions,
|
||||
indices,
|
||||
min: [cx - radius, up - radius, z0],
|
||||
max: [cx + radius, up + radius, z1],
|
||||
};
|
||||
}
|
||||
|
||||
function cesiumPreviewHtml(glbName, metadataName, routeName, vehicleModelName, areaId) {
|
||||
const previewConfig = {
|
||||
areaId,
|
||||
glbName,
|
||||
metadataName,
|
||||
routeName,
|
||||
vehicleModelName,
|
||||
};
|
||||
return `<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>${escapeHtml(areaId)} Cesium Preview</title>
|
||||
<script src="https://cdn.jsdelivr.net/npm/cesium@1.121.1/Build/Cesium/Cesium.js"></script>
|
||||
<link href="https://cdn.jsdelivr.net/npm/cesium@1.121.1/Build/Cesium/Widgets/widgets.css" rel="stylesheet">
|
||||
<link href="cesium-preview.css" rel="stylesheet">
|
||||
</head>
|
||||
<body>
|
||||
<div id="cesiumContainer"></div>
|
||||
<div id="controls">
|
||||
<div class="control-group">
|
||||
<button id="toggleCruise">Pause</button>
|
||||
<button id="toggleFollow">Follow</button>
|
||||
<label>Vehicle <select id="vehicleSelect"></select></label>
|
||||
<label>Speed <input id="speedControl" type="range" min="2" max="22" step="1" value="8"></label>
|
||||
<span id="speedLabel">8 m/s</span>
|
||||
</div>
|
||||
<div class="control-group">
|
||||
<label><input id="toggleScene" type="checkbox" checked> Scene</label>
|
||||
<span id="assetToggles" class="control-subgroup"></span>
|
||||
<label><input id="toggleRoutes" type="checkbox" checked> Routes</label>
|
||||
<label><input id="toggleVehicles" type="checkbox" checked> Vehicles</label>
|
||||
<label><input id="toggleFps" type="checkbox"> FPS</label>
|
||||
<label><input id="toggleDiagnostics" type="checkbox" checked> Info</label>
|
||||
</div>
|
||||
<div class="control-group">
|
||||
<button data-camera="overview">Overview</button>
|
||||
<button data-camera="oblique">Oblique</button>
|
||||
<button data-camera="detail">Detail</button>
|
||||
<button data-camera="route">Route</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="diagnostics">Loading diagnostics...</div>
|
||||
<div id="status">Loading ${escapeHtml(glbName)}...</div>
|
||||
<div id="loadingOverlay">
|
||||
<div class="loading-card">
|
||||
<div class="loading-spinner" aria-hidden="true"></div>
|
||||
<div class="loading-copy">
|
||||
<strong>Loading scene</strong>
|
||||
<span>${escapeHtml(areaId)}</span>
|
||||
</div>
|
||||
<div class="loading-bar" aria-hidden="true"><span></span></div>
|
||||
</div>
|
||||
</div>
|
||||
<script>window.OSM_ASSET_PREVIEW_CONFIG = ${escapeScriptJson(JSON.stringify(previewConfig))};</script>
|
||||
<script src="cesium-preview.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
}
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value)
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """);
|
||||
}
|
||||
|
||||
function escapeScriptJson(value) {
|
||||
return String(value)
|
||||
.replaceAll("<", "\\u003c")
|
||||
.replaceAll(">", "\\u003e")
|
||||
.replaceAll("&", "\\u0026")
|
||||
.replaceAll("\u2028", "\\u2028")
|
||||
.replaceAll("\u2029", "\\u2029");
|
||||
}
|
||||
|
||||
function sceneGeojsonRecords(area) {
|
||||
const records = {};
|
||||
for (const layer of SCENE_LAYERS) {
|
||||
@@ -951,14 +524,3 @@ function featureCount(file) {
|
||||
const parsed = JSON.parse(fs.readFileSync(file, "utf8"));
|
||||
return Array.isArray(parsed.features) ? parsed.features.length : null;
|
||||
}
|
||||
|
||||
function previewSummary(area) {
|
||||
const route = JSON.parse(fs.readFileSync(area.outputs.vehicleRoute, "utf8"));
|
||||
return {
|
||||
glbName: path.basename(area.outputs.glb),
|
||||
metadataName: path.basename(area.outputs.metadata),
|
||||
routeName: path.basename(area.outputs.vehicleRoute),
|
||||
vehicleModelName: path.basename(area.outputs.vehicleModel),
|
||||
routeSegments: Array.isArray(route.segments) ? route.segments.length : null,
|
||||
};
|
||||
}
|
||||
|
||||
110
scripts/lib/area-preview.js
Normal file
110
scripts/lib/area-preview.js
Normal file
@@ -0,0 +1,110 @@
|
||||
"use strict";
|
||||
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
function writeCesiumPreviewSupportFiles(outDir) {
|
||||
for (const file of ["cesium-preview.css", "cesium-preview.js"]) {
|
||||
const source = path.join(__dirname, file);
|
||||
if (!fs.existsSync(source)) {
|
||||
throw new Error(`Cesium preview support file '${file}' not found: ${source}`);
|
||||
}
|
||||
fs.copyFileSync(source, path.join(outDir, file));
|
||||
}
|
||||
}
|
||||
|
||||
function cesiumPreviewHtml(glbName, metadataName, routeName, vehicleModelName, areaId) {
|
||||
const previewConfig = {
|
||||
areaId,
|
||||
glbName,
|
||||
metadataName,
|
||||
routeName,
|
||||
vehicleModelName,
|
||||
};
|
||||
return `<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>${escapeHtml(areaId)} Cesium Preview</title>
|
||||
<script src="https://cdn.jsdelivr.net/npm/cesium@1.121.1/Build/Cesium/Cesium.js"></script>
|
||||
<link href="https://cdn.jsdelivr.net/npm/cesium@1.121.1/Build/Cesium/Widgets/widgets.css" rel="stylesheet">
|
||||
<link href="cesium-preview.css" rel="stylesheet">
|
||||
</head>
|
||||
<body>
|
||||
<div id="cesiumContainer"></div>
|
||||
<div id="controls">
|
||||
<div class="control-group">
|
||||
<button id="toggleCruise">Pause</button>
|
||||
<button id="toggleFollow">Follow</button>
|
||||
<label>Vehicle <select id="vehicleSelect"></select></label>
|
||||
<label>Speed <input id="speedControl" type="range" min="2" max="22" step="1" value="8"></label>
|
||||
<span id="speedLabel">8 m/s</span>
|
||||
</div>
|
||||
<div class="control-group">
|
||||
<label><input id="toggleScene" type="checkbox" checked> Scene</label>
|
||||
<span id="assetToggles" class="control-subgroup"></span>
|
||||
<label><input id="toggleRoutes" type="checkbox" checked> Routes</label>
|
||||
<label><input id="toggleVehicles" type="checkbox" checked> Vehicles</label>
|
||||
<label><input id="toggleFps" type="checkbox"> FPS</label>
|
||||
<label><input id="toggleDiagnostics" type="checkbox" checked> Info</label>
|
||||
</div>
|
||||
<div class="control-group">
|
||||
<button data-camera="overview">Overview</button>
|
||||
<button data-camera="oblique">Oblique</button>
|
||||
<button data-camera="detail">Detail</button>
|
||||
<button data-camera="route">Route</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="diagnostics">Loading diagnostics...</div>
|
||||
<div id="status">Loading ${escapeHtml(glbName)}...</div>
|
||||
<div id="loadingOverlay">
|
||||
<div class="loading-card">
|
||||
<div class="loading-spinner" aria-hidden="true"></div>
|
||||
<div class="loading-copy">
|
||||
<strong>Loading scene</strong>
|
||||
<span>${escapeHtml(areaId)}</span>
|
||||
</div>
|
||||
<div class="loading-bar" aria-hidden="true"><span></span></div>
|
||||
</div>
|
||||
</div>
|
||||
<script>window.OSM_ASSET_PREVIEW_CONFIG = ${escapeScriptJson(JSON.stringify(previewConfig))};</script>
|
||||
<script src="cesium-preview.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
}
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value)
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """);
|
||||
}
|
||||
|
||||
function escapeScriptJson(value) {
|
||||
return String(value)
|
||||
.replaceAll("<", "\\u003c")
|
||||
.replaceAll(">", "\\u003e")
|
||||
.replaceAll("&", "\\u0026")
|
||||
.replaceAll("\u2028", "\\u2028")
|
||||
.replaceAll("\u2029", "\\u2029");
|
||||
}
|
||||
|
||||
function previewSummary(area) {
|
||||
const route = JSON.parse(fs.readFileSync(area.outputs.vehicleRoute, "utf8"));
|
||||
return {
|
||||
glbName: path.basename(area.outputs.glb),
|
||||
metadataName: path.basename(area.outputs.metadata),
|
||||
routeName: path.basename(area.outputs.vehicleRoute),
|
||||
vehicleModelName: path.basename(area.outputs.vehicleModel),
|
||||
routeSegments: Array.isArray(route.segments) ? route.segments.length : null,
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
cesiumPreviewHtml,
|
||||
previewSummary,
|
||||
writeCesiumPreviewSupportFiles,
|
||||
};
|
||||
39
scripts/lib/vehicle-model.js
Normal file
39
scripts/lib/vehicle-model.js
Normal file
@@ -0,0 +1,39 @@
|
||||
"use strict";
|
||||
|
||||
function makeVehicleGltf() {
|
||||
const meshes = [], nodes = [], bufferParts = [], bufferViews = [], accessors = [];
|
||||
function align4(bytes) { while (bytes.length % 4 !== 0) bytes.push(0); }
|
||||
function addBufferView(bytes, target) {
|
||||
align4(bufferParts); const offset = bufferParts.length; bufferParts.push(...bytes);
|
||||
const view = { buffer: 0, byteOffset: offset, byteLength: bytes.length }; if (target) view.target = target;
|
||||
bufferViews.push(view); return bufferViews.length - 1;
|
||||
}
|
||||
function floatBytes(values) { const buffer = Buffer.alloc(values.length * 4); values.forEach((value, index) => buffer.writeFloatLE(value, index * 4)); return Array.from(buffer); }
|
||||
function ushortBytes(values) { const buffer = Buffer.alloc(values.length * 2); values.forEach((value, index) => buffer.writeUInt16LE(value, index * 2)); return Array.from(buffer); }
|
||||
function addAccessor(bufferView, componentType, count, type, min, max) { const accessor = { bufferView, componentType, count, type }; if (min) accessor.min = min; if (max) accessor.max = max; accessors.push(accessor); return accessors.length - 1; }
|
||||
function addMesh(name, geometry, materialIndex) {
|
||||
const positionView = addBufferView(floatBytes(geometry.positions), 34962);
|
||||
const indexView = addBufferView(ushortBytes(geometry.indices), 34963);
|
||||
const positionAccessor = addAccessor(positionView, 5126, geometry.positions.length / 3, "VEC3", geometry.min, geometry.max);
|
||||
const indexAccessor = addAccessor(indexView, 5123, geometry.indices.length, "SCALAR");
|
||||
meshes.push({ name, primitives: [{ attributes: { POSITION: positionAccessor }, indices: indexAccessor, material: materialIndex }] });
|
||||
nodes.push({ name, mesh: meshes.length - 1 });
|
||||
}
|
||||
addMesh("body", cuboid(0, 0, 0.72, 4.6, 1.9, 0.9), 0);
|
||||
addMesh("hood", cuboid(1.35, 0, 1.1, 1.25, 1.74, 0.38), 0);
|
||||
addMesh("cabin", cuboid(-0.55, 0, 1.42, 1.75, 1.55, 0.82), 1);
|
||||
addMesh("rear", cuboid(-1.65, 0, 1.08, 0.95, 1.78, 0.42), 0);
|
||||
addMesh("front_windshield", cuboid(0.28, 0, 1.58, 0.12, 1.42, 0.58), 2);
|
||||
addMesh("left_window", cuboid(-0.55, -0.82, 1.52, 1.25, 0.08, 0.48), 2);
|
||||
addMesh("right_window", cuboid(-0.55, 0.82, 1.52, 1.25, 0.08, 0.48), 2);
|
||||
for (const x of [-1.55, 1.45]) for (const y of [-1.02, 1.02]) { addMesh(`wheel_${x}_${y}`, cylinderY(x, y, 0.46, 0.38, 0.32, 16), 3); addMesh(`hub_${x}_${y}`, cylinderY(x, y, 0.46, 0.2, 0.34, 12), 4); }
|
||||
addMesh("left_headlight", cuboid(2.36, -0.48, 0.9, 0.08, 0.32, 0.16), 5); addMesh("right_headlight", cuboid(2.36, 0.48, 0.9, 0.08, 0.32, 0.16), 5);
|
||||
addMesh("left_tail", cuboid(-2.36, -0.55, 0.9, 0.08, 0.28, 0.16), 6); addMesh("right_tail", cuboid(-2.36, 0.55, 0.9, 0.08, 0.28, 0.16), 6);
|
||||
const buffer = Buffer.from(bufferParts);
|
||||
return { asset: { version: "2.0", generator: "osm-asset-pipeline vehicle preview" }, scene: 0, scenes: [{ nodes: nodes.map((_, index) => index) }], nodes, meshes, buffers: [{ byteLength: buffer.length, uri: `data:application/octet-stream;base64,${buffer.toString("base64")}` }], bufferViews, accessors, materials: [material("paint red", [0.82, 0.05, 0.035, 1], 0.55, 0.25), material("dark roof", [0.08, 0.08, 0.085, 1], 0.45, 0.35), material("glass", [0.04, 0.12, 0.16, 0.82], 0.18, 0.08), material("tire", [0.015, 0.014, 0.013, 1], 0.75, 0.65), material("wheel hub", [0.72, 0.72, 0.68, 1], 0.35, 0.85), material("headlight", [1, 0.92, 0.62, 1], 0.12, 0), material("tail light", [0.95, 0.03, 0.03, 1], 0.25, 0)] };
|
||||
}
|
||||
function material(name, color, roughness, metallic) { return { name, pbrMetallicRoughness: { baseColorFactor: color, roughnessFactor: roughness, metallicFactor: metallic } }; }
|
||||
function cuboid(cx, lateral, up, sx, width, height) { const x0 = cx - sx / 2, x1 = cx + sx / 2, y0 = up - height / 2, y1 = up + height / 2, z0 = lateral - width / 2, z1 = lateral + width / 2; return { positions: [x0,y0,z0,x1,y0,z0,x1,y1,z0,x0,y1,z0,x0,y0,z1,x1,y0,z1,x1,y1,z1,x0,y1,z1], indices: [0,1,2,0,2,3,4,6,5,4,7,6,0,4,5,0,5,1,1,5,6,1,6,2,2,6,7,2,7,3,3,7,4,3,4,0], min: [x0,y0,z0], max: [x1,y1,z1] }; }
|
||||
function cylinderY(cx, lateral, up, radius, width, segments) { const positions = [], indices = [], z0 = lateral - width / 2, z1 = lateral + width / 2; for (const z of [z0, z1]) { positions.push(cx, up, z); for (let i = 0; i < segments; i += 1) { const angle = 2 * Math.PI * i / segments; positions.push(cx + Math.cos(angle) * radius, up + Math.sin(angle) * radius, z); } } const center0 = 0, center1 = segments + 1; for (let i = 0; i < segments; i += 1) { const a0 = center0 + 1 + i, b0 = center0 + 1 + ((i + 1) % segments), a1 = center1 + 1 + i, b1 = center1 + 1 + ((i + 1) % segments); indices.push(center0,b0,a0,center1,a1,b1,a0,b0,b1,a0,b1,a1); } return { positions, indices, min: [cx - radius, up - radius, z0], max: [cx + radius, up + radius, z1] }; }
|
||||
|
||||
module.exports = { makeVehicleGltf };
|
||||
143
scripts/lib/vehicle-route.js
Normal file
143
scripts/lib/vehicle-route.js
Normal file
@@ -0,0 +1,143 @@
|
||||
"use strict";
|
||||
|
||||
const fs = require("fs");
|
||||
|
||||
function buildVehicleRoute(osmPath) {
|
||||
const xml = fs.readFileSync(osmPath, "utf8");
|
||||
const bounds = osmBounds(xml);
|
||||
const nodes = new Map();
|
||||
for (const match of xml.matchAll(/<node\b([^>]*)>/g)) {
|
||||
const attrs = xmlAttrs(match[1]);
|
||||
if (!attrs.id || attrs.lon === undefined || attrs.lat === undefined) continue;
|
||||
nodes.set(attrs.id, [Number(attrs.lon), Number(attrs.lat)]);
|
||||
}
|
||||
const segments = [];
|
||||
for (const match of xml.matchAll(/<way\b([^>]*)>([\s\S]*?)<\/way>/g)) {
|
||||
const attrs = xmlAttrs(match[1]);
|
||||
const body = match[2];
|
||||
const tags = {};
|
||||
for (const tagMatch of body.matchAll(/<tag\b([^>]*)\/?>/g)) {
|
||||
const tag = xmlAttrs(tagMatch[1]);
|
||||
if (tag.k) tags[tag.k] = tag.v || "";
|
||||
}
|
||||
if (!isCruiseHighway(tags)) continue;
|
||||
const coords = [];
|
||||
for (const ndMatch of body.matchAll(/<nd\b([^>]*)\/?>/g)) {
|
||||
const nd = xmlAttrs(ndMatch[1]);
|
||||
const coord = nodes.get(nd.ref);
|
||||
if (coord) coords.push(coord);
|
||||
}
|
||||
const runs = splitInBounds(compactCoords(coords), bounds);
|
||||
let runIndex = 0;
|
||||
for (const run of runs) {
|
||||
const lengthMeters = routeLength(run);
|
||||
if (lengthMeters < 20) continue;
|
||||
runIndex += 1;
|
||||
const laneOffsetMeters = 1.3;
|
||||
segments.push({
|
||||
id: runIndex === 1 ? (attrs.id || `way-${segments.length + 1}`) : `${attrs.id || "way"}-${runIndex}`,
|
||||
name: tags.name || tags.highway || "road",
|
||||
highway: tags.highway || "",
|
||||
oneWay: tags.oneway || "",
|
||||
lengthMeters,
|
||||
laneOffsetMeters,
|
||||
coordinates: offsetPolylineRight(run, laneOffsetMeters),
|
||||
centerlineCoordinates: run,
|
||||
});
|
||||
}
|
||||
}
|
||||
segments.sort((a, b) => b.lengthMeters - a.lengthMeters);
|
||||
return { source: osmPath, bounds, generatedAt: new Date().toISOString(), speedMetersPerSecond: 8.0, loop: true, segments };
|
||||
}
|
||||
|
||||
function osmBounds(xml) {
|
||||
const match = xml.match(/<bounds\b([^>]*)\/?>/);
|
||||
if (!match) return null;
|
||||
const attrs = xmlAttrs(match[1]);
|
||||
const bounds = { minLon: Number(attrs.minlon), minLat: Number(attrs.minlat), maxLon: Number(attrs.maxlon), maxLat: Number(attrs.maxlat) };
|
||||
return Object.values(bounds).every(Number.isFinite) ? bounds : null;
|
||||
}
|
||||
|
||||
function xmlAttrs(text) {
|
||||
const attrs = {};
|
||||
for (const match of text.matchAll(/([:\w-]+)\s*=\s*("([^"]*)"|'([^']*)')/g)) {
|
||||
attrs[match[1]] = match[3] !== undefined ? match[3] : match[4];
|
||||
}
|
||||
return attrs;
|
||||
}
|
||||
|
||||
function isCruiseHighway(tags) {
|
||||
const highway = tags.highway || "";
|
||||
if (!highway || tags.area === "yes") return false;
|
||||
return !new Set(["footway", "path", "pedestrian", "steps", "cycleway", "service", "track", "bridleway", "corridor", "elevator", "platform", "construction"]).has(highway);
|
||||
}
|
||||
|
||||
function compactCoords(coords) {
|
||||
const out = [];
|
||||
for (const coord of coords) {
|
||||
const last = out[out.length - 1];
|
||||
if (!last || last[0] !== coord[0] || last[1] !== coord[1]) out.push(coord);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function offsetPolylineRight(coords, offsetMeters) {
|
||||
if (coords.length < 2 || offsetMeters === 0) return coords;
|
||||
const refLat = coords.reduce((sum, coord) => sum + coord[1], 0) / coords.length;
|
||||
const metersPerLat = 111320.0;
|
||||
const metersPerLon = 111320.0 * Math.cos(degreesToRadians(refLat));
|
||||
const points = coords.map((coord) => ({ x: coord[0] * metersPerLon, y: coord[1] * metersPerLat, lon: coord[0], lat: coord[1] }));
|
||||
return points.map((point, index) => {
|
||||
const prev = points[Math.max(0, index - 1)];
|
||||
const next = points[Math.min(points.length - 1, index + 1)];
|
||||
let dx = next.x - prev.x;
|
||||
let dy = next.y - prev.y;
|
||||
const length = Math.hypot(dx, dy);
|
||||
if (length < 0.001) return [point.lon, point.lat];
|
||||
dx /= length;
|
||||
dy /= length;
|
||||
return [(point.x + dy * offsetMeters) / metersPerLon, (point.y - dx * offsetMeters) / metersPerLat];
|
||||
});
|
||||
}
|
||||
|
||||
function splitInBounds(coords, bounds) {
|
||||
if (!bounds) return [coords];
|
||||
const runs = [];
|
||||
let current = [];
|
||||
for (const coord of coords) {
|
||||
if (insideBounds(coord, bounds)) current.push(coord);
|
||||
else if (current.length) {
|
||||
if (current.length >= 2) runs.push(current);
|
||||
current = [];
|
||||
}
|
||||
}
|
||||
if (current.length >= 2) runs.push(current);
|
||||
return runs;
|
||||
}
|
||||
|
||||
function insideBounds(coord, bounds) {
|
||||
const pad = 0.00002;
|
||||
return coord[0] >= bounds.minLon - pad && coord[0] <= bounds.maxLon + pad && coord[1] >= bounds.minLat - pad && coord[1] <= bounds.maxLat + pad;
|
||||
}
|
||||
|
||||
function routeLength(coords) {
|
||||
let total = 0;
|
||||
for (let i = 1; i < coords.length; i += 1) total += haversineMeters(coords[i - 1], coords[i]);
|
||||
return total;
|
||||
}
|
||||
|
||||
function haversineMeters(a, b) {
|
||||
const radius = 6371008.8;
|
||||
const lat1 = degreesToRadians(a[1]);
|
||||
const lat2 = degreesToRadians(b[1]);
|
||||
const dLat = degreesToRadians(b[1] - a[1]);
|
||||
const dLon = degreesToRadians(b[0] - a[0]);
|
||||
const sinLat = Math.sin(dLat / 2);
|
||||
const sinLon = Math.sin(dLon / 2);
|
||||
const h = sinLat * sinLat + Math.cos(lat1) * Math.cos(lat2) * sinLon * sinLon;
|
||||
return 2 * radius * Math.asin(Math.min(1, Math.sqrt(h)));
|
||||
}
|
||||
|
||||
function degreesToRadians(value) { return value * Math.PI / 180; }
|
||||
|
||||
module.exports = { buildVehicleRoute };
|
||||
85
scripts/test-preview-assets.js
Normal file
85
scripts/test-preview-assets.js
Normal file
@@ -0,0 +1,85 @@
|
||||
#!/usr/bin/env node
|
||||
"use strict";
|
||||
|
||||
const assert = require("assert");
|
||||
const fs = require("fs");
|
||||
const os = require("os");
|
||||
const path = require("path");
|
||||
const { cesiumPreviewHtml } = require("./lib/area-preview");
|
||||
const { makeVehicleGltf } = require("./lib/vehicle-model");
|
||||
const { buildVehicleRoute } = require("./lib/vehicle-route");
|
||||
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "preview-assets-"));
|
||||
const osmPath = path.join(tempDir, "fixture.osm");
|
||||
|
||||
fs.writeFileSync(osmPath, `<?xml version="1.0"?>
|
||||
<osm version="0.6">
|
||||
<bounds minlon="120" minlat="30" maxlon="120.01" maxlat="30.01"/>
|
||||
<node id="1" lon="120.001" lat="30.001"/>
|
||||
<node id="2" lon="120.003" lat="30.001"/>
|
||||
<node id="3" lon="120.006" lat="30.001"/>
|
||||
<node id="4" lon="120.001" lat="30.003"/>
|
||||
<node id="5" lon="120.001" lat="30.007"/>
|
||||
<way id="major-road">
|
||||
<nd ref="1"/><nd ref="2"/><nd ref="3"/>
|
||||
<tag k="highway" v="primary"/><tag k="name" v="Major Road"/><tag k="oneway" v="yes"/>
|
||||
</way>
|
||||
<way id="minor-road">
|
||||
<nd ref="4"/><nd ref="5"/><tag k="highway" v="residential"/>
|
||||
</way>
|
||||
<way id="footpath">
|
||||
<nd ref="1"/><nd ref="5"/><tag k="highway" v="footway"/>
|
||||
</way>
|
||||
</osm>
|
||||
`);
|
||||
|
||||
const route = buildVehicleRoute(osmPath);
|
||||
assert.equal(route.source, osmPath);
|
||||
assert.deepEqual(route.bounds, {
|
||||
minLon: 120,
|
||||
minLat: 30,
|
||||
maxLon: 120.01,
|
||||
maxLat: 30.01,
|
||||
});
|
||||
assert.equal(route.speedMetersPerSecond, 8);
|
||||
assert.equal(route.loop, true);
|
||||
assert.equal(route.segments.length, 2);
|
||||
assert.deepEqual(route.segments.map((segment) => segment.id), ["major-road", "minor-road"]);
|
||||
assert.equal(route.segments[0].name, "Major Road");
|
||||
assert.equal(route.segments[0].oneWay, "yes");
|
||||
assert.equal(route.segments[0].laneOffsetMeters, 1.3);
|
||||
assert.notDeepEqual(route.segments[0].coordinates, route.segments[0].centerlineCoordinates);
|
||||
assert.ok(route.segments[0].lengthMeters > route.segments[1].lengthMeters);
|
||||
|
||||
const vehicle = makeVehicleGltf();
|
||||
assert.equal(vehicle.asset.version, "2.0");
|
||||
assert.equal(vehicle.asset.generator, "osm-asset-pipeline vehicle preview");
|
||||
assert.equal(vehicle.scene, 0);
|
||||
assert.equal(vehicle.meshes.length, vehicle.nodes.length);
|
||||
assert.equal(vehicle.scenes[0].nodes.length, vehicle.nodes.length);
|
||||
assert.deepEqual(
|
||||
vehicle.materials.map((material) => material.name),
|
||||
["paint red", "dark roof", "glass", "tire", "wheel hub", "headlight", "tail light"],
|
||||
);
|
||||
assert.ok(vehicle.meshes.some((mesh) => mesh.name === "body"));
|
||||
assert.ok(vehicle.meshes.some((mesh) => mesh.name === "wheel_-1.55_-1.02"));
|
||||
assert.match(vehicle.buffers[0].uri, /^data:application\/octet-stream;base64,/);
|
||||
assert.equal(
|
||||
Buffer.from(vehicle.buffers[0].uri.split(",")[1], "base64").length,
|
||||
vehicle.buffers[0].byteLength,
|
||||
);
|
||||
|
||||
const html = cesiumPreviewHtml(
|
||||
"scene<&>.glb",
|
||||
"scene.json",
|
||||
"route.json",
|
||||
"vehicle.gltf",
|
||||
"north<&>\u2028valley",
|
||||
);
|
||||
assert.match(html, /<title>north<&>\u2028valley Cesium Preview<\/title>/);
|
||||
assert.match(html, /Loading scene<&>\.glb/);
|
||||
assert.match(html, /"areaId":"north\\u003c\\u0026\\u003e\\u2028valley"/);
|
||||
assert.match(html, /"glbName":"scene\\u003c\\u0026\\u003e\.glb"/);
|
||||
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
console.log("Preview asset tests passed.");
|
||||
Reference in New Issue
Block a user