Use clean ToyCar model and lane-offset cruise route

This commit is contained in:
2026-07-27 14:31:56 +08:00
parent 9561ad8530
commit d93212b685
5 changed files with 54 additions and 177 deletions

View File

@@ -116,9 +116,9 @@ python3 -m http.server 8765
## 实验:车辆巡航
`preview``cesium` 阶段会额外生成 `<area-id>-vehicle-route.json`。该文件从 OSM bounds 内的可行驶 `highway` way 提取道路中心线Cesium 预览页会加载最长道路段并显示一辆实验车辆循环巡航。
`preview``cesium` 阶段会额外生成 `<area-id>-vehicle-route.json``<area-id>-vehicle-car.glb`。路线文件从 OSM bounds 内的可行驶 `highway` way 提取道路中心线,并向右偏移约 1.3 米作为车辆行驶线,避免车辆压道路中心线。Cesium 预览页会加载最长道路段并显示一辆实验车辆循环巡航。
这是用于验证高精度巡航可用性的预览层功能,不会改变 Blender/GLB 资产本身。
这是用于验证高精度巡航可用性的预览层功能,不会改变 Blender/GLB 资产本身。车辆模型使用 Khronos glTF Sample Assets 的 `ToyCar` 示例模型,来源和许可记录在 `assets/vehicles/README.md`
## 已沉淀区域

10
assets/vehicles/README.md Normal file
View File

@@ -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.

BIN
assets/vehicles/ToyCar.glb Normal file

Binary file not shown.

View File

@@ -3,6 +3,7 @@
## 2026-07-27
- 实验分支新增 Cesium 车辆巡航预览:从 OSM 可行驶 `highway` 提取 bounds 内路线,输出 `<area-id>-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`

View File

@@ -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 `<!doctype html>
<html lang="zh-CN">
@@ -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
},