From 396df5127cc978d3bf11eb60efd89fa619f9efd1 Mon Sep 17 00:00:00 2001 From: que01 Date: Tue, 4 Aug 2026 12:55:20 +0800 Subject: [PATCH] Refactor area preview orchestration --- .trellis/spec/pipeline/cli-and-stages.md | 19 + .trellis/spec/pipeline/index.md | 5 +- .trellis/spec/preview/index.md | 17 +- .../check.jsonl | 1 + .../design.md | 24 + .../implement.jsonl | 1 + .../implement.md | 20 + .../prd.md | 41 ++ .../task.json | 26 + docs/changelog.md | 6 + package.json | 3 +- scripts/build-area.js | 456 +----------------- scripts/lib/area-preview.js | 110 +++++ scripts/lib/vehicle-model.js | 39 ++ scripts/lib/vehicle-route.js | 143 ++++++ scripts/test-preview-assets.js | 85 ++++ 16 files changed, 541 insertions(+), 455 deletions(-) create mode 100644 .trellis/tasks/08-04-split-area-build-orchestration/check.jsonl create mode 100644 .trellis/tasks/08-04-split-area-build-orchestration/design.md create mode 100644 .trellis/tasks/08-04-split-area-build-orchestration/implement.jsonl create mode 100644 .trellis/tasks/08-04-split-area-build-orchestration/implement.md create mode 100644 .trellis/tasks/08-04-split-area-build-orchestration/prd.md create mode 100644 .trellis/tasks/08-04-split-area-build-orchestration/task.json create mode 100644 scripts/lib/area-preview.js create mode 100644 scripts/lib/vehicle-model.js create mode 100644 scripts/lib/vehicle-route.js create mode 100644 scripts/test-preview-assets.js diff --git a/.trellis/spec/pipeline/cli-and-stages.md b/.trellis/spec/pipeline/cli-and-stages.md index 3c0d8fe..c3d6caf 100644 --- a/.trellis/spec/pipeline/cli-and-stages.md +++ b/.trellis/spec/pipeline/cli-and-stages.md @@ -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` diff --git a/.trellis/spec/pipeline/index.md b/.trellis/spec/pipeline/index.md index d8d1ad9..aec0370 100644 --- a/.trellis/spec/pipeline/index.md +++ b/.trellis/spec/pipeline/index.md @@ -87,7 +87,7 @@ config/areas/.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/.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 | 产物一致性校验驱动 | diff --git a/.trellis/spec/preview/index.md b/.trellis/spec/preview/index.md index 075376c..9cadd2b 100644 --- a/.trellis/spec/preview/index.md +++ b/.trellis/spec/preview/index.md @@ -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//cesium-preview.js scripts/lib/cesium-preview.css ─── 原样 copyFileSync ──▶ outputs//cesium-preview.css - (build-area.js:328-335) + (area-preview.js:writeCesiumPreviewSupportFiles) -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`) | -| ` - - - - -
-
-
- - - - - 8 m/s -
-
- - - - - - -
-
- - - - -
-
-
Loading diagnostics...
-
Loading ${escapeHtml(glbName)}...
-
-
- -
- Loading scene - ${escapeHtml(areaId)} -
- -
-
- - - - -`; -} - -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, - }; -} diff --git a/scripts/lib/area-preview.js b/scripts/lib/area-preview.js new file mode 100644 index 0000000..899b9cf --- /dev/null +++ b/scripts/lib/area-preview.js @@ -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 ` + + + + + ${escapeHtml(areaId)} Cesium Preview + + + + + +
+
+
+ + + + + 8 m/s +
+
+ + + + + + +
+
+ + + + +
+
+
Loading diagnostics...
+
Loading ${escapeHtml(glbName)}...
+
+
+ +
+ Loading scene + ${escapeHtml(areaId)} +
+ +
+
+ + + + +`; +} + +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, +}; diff --git a/scripts/lib/vehicle-model.js b/scripts/lib/vehicle-model.js new file mode 100644 index 0000000..6c070d6 --- /dev/null +++ b/scripts/lib/vehicle-model.js @@ -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 }; diff --git a/scripts/lib/vehicle-route.js b/scripts/lib/vehicle-route.js new file mode 100644 index 0000000..b16a02f --- /dev/null +++ b/scripts/lib/vehicle-route.js @@ -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(/]*)>/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(/]*)>([\s\S]*?)<\/way>/g)) { + const attrs = xmlAttrs(match[1]); + const body = match[2]; + const tags = {}; + for (const tagMatch of body.matchAll(/]*)\/?>/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(/]*)\/?>/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(/]*)\/?>/); + 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 }; diff --git a/scripts/test-preview-assets.js b/scripts/test-preview-assets.js new file mode 100644 index 0000000..0b81938 --- /dev/null +++ b/scripts/test-preview-assets.js @@ -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, ` + + + + + + + + + + + + + + + + + + +`); + +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, /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.");