diff --git a/README.md b/README.md index f7e6ed9..c93b5d4 100644 --- a/README.md +++ b/README.md @@ -116,9 +116,9 @@ python3 -m http.server 8765 ## 实验:车辆巡航 -`preview` 和 `cesium` 阶段会额外生成 `-vehicle-route.json`。该文件从 OSM bounds 内的可行驶 `highway` way 提取道路中心线,Cesium 预览页会加载最长道路段并显示一辆实验车辆循环巡航。 +`preview` 和 `cesium` 阶段会额外生成 `-vehicle-route.json` 和 `-vehicle-car.glb`。路线文件从 OSM bounds 内的可行驶 `highway` way 提取道路中心线,并向右偏移约 1.3 米作为车辆行驶线,避免车辆压道路中心线。Cesium 预览页会加载最长道路段并显示一辆实验车辆循环巡航。 -这是用于验证高精度巡航可用性的预览层功能,不会改变 Blender/GLB 资产本身。 +这是用于验证高精度巡航可用性的预览层功能,不会改变 Blender/GLB 主资产本身。车辆模型使用 Khronos glTF Sample Assets 的 `ToyCar` 示例模型,来源和许可记录在 `assets/vehicles/README.md`。 ## 已沉淀区域 diff --git a/assets/vehicles/README.md b/assets/vehicles/README.md new file mode 100644 index 0000000..f531223 --- /dev/null +++ b/assets/vehicles/README.md @@ -0,0 +1,10 @@ +# Vehicle Assets + +## ToyCar.glb + +- Source: Khronos glTF Sample Assets, `Toy Car` +- URL: https://github.com/KhronosGroup/glTF-Sample-Assets/tree/main/Models/ToyCar +- License summary from Khronos model list: CC0 1.0 Universal +- Credit: © 2017, Analytical Graphics, Inc. + +Used only by the experimental Cesium vehicle cruise preview. diff --git a/assets/vehicles/ToyCar.glb b/assets/vehicles/ToyCar.glb new file mode 100644 index 0000000..6b3eaab Binary files /dev/null and b/assets/vehicles/ToyCar.glb differ diff --git a/docs/changelog.md b/docs/changelog.md index 54dcba8..805c486 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -3,6 +3,7 @@ ## 2026-07-27 - 实验分支新增 Cesium 车辆巡航预览:从 OSM 可行驶 `highway` 提取 bounds 内路线,输出 `-vehicle-route.json`,并在预览页中驱动车辆循环移动。 +- 巡航路线从道路中心线向右偏移约 1.3 米,车辆模型改用 Khronos glTF Sample Assets 的无 logo `ToyCar.glb`。 - 将项目主入口重构为区域资产管线:`scripts/build-area.js`。 - 新增 `config/areas/nantaizi-lake-innovation-valley.json` 和 `config/areas/hanyang-block.json`,支持按 OSM 输入生成独立输出目录。 - `npm run build` 现在默认走区域资产管线;旧 QGIS 管线保留为 `npm run build:qgis`。 diff --git a/scripts/build-area.js b/scripts/build-area.js index 2f06583..c312458 100755 --- a/scripts/build-area.js +++ b/scripts/build-area.js @@ -83,7 +83,7 @@ function normalizeAreaConfig(raw) { outputOverrides.cesiumPreview || path.join(areaDir, `${fileStem}-cesium-preview.html`), ), vehicleRoute: path.resolve(outputOverrides.vehicleRoute || path.join(areaDir, `${fileStem}-vehicle-route.json`)), - vehicleModel: path.resolve(outputOverrides.vehicleModel || path.join(areaDir, `${fileStem}-vehicle-car.gltf`)), + vehicleModel: path.resolve(outputOverrides.vehicleModel || path.join(areaDir, `${fileStem}-vehicle-car.glb`)), pipelineDir: path.resolve(outputOverrides.pipelineDir || path.join(areaDir, "_pipeline")), }; @@ -292,9 +292,10 @@ function writeVehicleRoute(area) { } function writeVehicleModel(area) { - const gltf = makeVehicleGltf(); + const source = path.join(repoRoot, "assets", "vehicles", "ToyCar.glb"); + ensureFile(source, "Vehicle model asset"); fs.mkdirSync(path.dirname(area.outputs.vehicleModel), { recursive: true }); - fs.writeFileSync(area.outputs.vehicleModel, `${JSON.stringify(gltf, null, 2)}\n`); + fs.copyFileSync(source, area.outputs.vehicleModel); console.log(`Vehicle model: ${area.outputs.vehicleModel}`); } @@ -330,13 +331,17 @@ function buildVehicleRoute(osmPath) { 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, - coordinates: run, + laneOffsetMeters, + coordinates: shiftedRun, + centerlineCoordinates: run, }); } } @@ -394,6 +399,35 @@ function compactCoords(coords) { 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 = []; @@ -444,174 +478,6 @@ 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, cy, cz, sx, sy, sz) { - const x0 = cx - sx / 2; - const x1 = cx + sx / 2; - const y0 = cy - sy / 2; - const y1 = cy + sy / 2; - const z0 = cz - sz / 2; - const z1 = cz + sz / 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, cy, cz, radius, width, segments) { - const positions = []; - const indices = []; - const y0 = cy - width / 2; - const y1 = cy + width / 2; - for (const y of [y0, y1]) { - positions.push(cx, y, cz); - for (let i = 0; i < segments; i += 1) { - const angle = 2 * Math.PI * i / segments; - positions.push(cx + Math.cos(angle) * radius, y, cz + Math.sin(angle) * radius); - } - } - 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, y0, cz - radius], - max: [cx + radius, y1, cz + radius], - }; -} - function cesiumPreviewHtml(glbName, metadataName, routeName, vehicleModelName, areaId) { return ` @@ -743,12 +609,12 @@ function cesiumPreviewHtml(glbName, metadataName, routeName, vehicleModelName, a const positions = new Cesium.SampledPositionProperty(); let elapsed = 0; let previous = segment.coordinates[0]; - positions.addSample(start, Cesium.Cartesian3.fromDegrees(previous[0], previous[1], 1.15)); + positions.addSample(start, Cesium.Cartesian3.fromDegrees(previous[0], previous[1], 0.8)); for (let i = 1; i < segment.coordinates.length; i += 1) { const coord = segment.coordinates[i]; elapsed += distanceMeters(previous, coord) / speed; const time = Cesium.JulianDate.addSeconds(start, elapsed, new Cesium.JulianDate()); - positions.addSample(time, Cesium.Cartesian3.fromDegrees(coord[0], coord[1], 1.15)); + positions.addSample(time, Cesium.Cartesian3.fromDegrees(coord[0], coord[1], 0.8)); previous = coord; } positions.setInterpolationOptions({ @@ -785,7 +651,7 @@ function cesiumPreviewHtml(glbName, metadataName, routeName, vehicleModelName, a orientation: new Cesium.VelocityOrientationProperty(positions), model: { uri: "${escapeJs(vehicleModelName)}", - scale: 1.0, + scale: 0.006, minimumPixelSize: 24, maximumScale: 80 },