diff --git a/scripts/build-area.js b/scripts/build-area.js index e1e422f..2f06583 100755 --- a/scripts/build-area.js +++ b/scripts/build-area.js @@ -83,6 +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`)), pipelineDir: path.resolve(outputOverrides.pipelineDir || path.join(areaDir, "_pipeline")), }; @@ -274,10 +275,12 @@ function writeCesiumPreview(area) { const htmlPath = area.outputs.cesiumPreview; fs.mkdirSync(path.dirname(htmlPath), { recursive: true }); writeVehicleRoute(area); + writeVehicleModel(area); const glbName = path.basename(area.outputs.glb); const metadataName = path.basename(area.outputs.metadata); const routeName = path.basename(area.outputs.vehicleRoute); - fs.writeFileSync(htmlPath, cesiumPreviewHtml(glbName, metadataName, routeName, area.id)); + const vehicleModelName = path.basename(area.outputs.vehicleModel); + fs.writeFileSync(htmlPath, cesiumPreviewHtml(glbName, metadataName, routeName, vehicleModelName, area.id)); console.log(`Cesium preview: ${htmlPath}`); } @@ -288,6 +291,13 @@ function writeVehicleRoute(area) { console.log(`Vehicle route: ${area.outputs.vehicleRoute}`); } +function writeVehicleModel(area) { + const gltf = makeVehicleGltf(); + 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); @@ -434,7 +444,175 @@ function degreesToRadians(value) { return value * Math.PI / 180; } -function cesiumPreviewHtml(glbName, metadataName, routeName, areaId) { +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 ` @@ -605,11 +783,11 @@ function cesiumPreviewHtml(glbName, metadataName, routeName, areaId) { ]), position: positions, orientation: new Cesium.VelocityOrientationProperty(positions), - box: { - dimensions: new Cesium.Cartesian3(4.6, 1.9, 1.5), - material: Cesium.Color.ORANGERED, - outline: true, - outlineColor: Cesium.Color.WHITE + model: { + uri: "${escapeJs(vehicleModelName)}", + scale: 1.0, + minimumPixelSize: 24, + maximumScale: 80 }, path: { resolution: 1,