Files
osmWorkflow/scripts/build-area.js
2026-08-04 09:45:51 +08:00

816 lines
26 KiB
JavaScript
Executable File

#!/usr/bin/env node
const fs = require("fs");
const path = require("path");
const { spawnSync } = require("child_process");
const { readAreaConfig } = require("./lib/area-config");
const { digest: glbDigest } = require("./glb-digest");
const {
fileRecord,
glbBudgetWarnings,
glbSummary,
optionalFileRecord,
writeStageManifest,
} = require("./lib/stage-manifest");
const repoRoot = path.resolve(__dirname, "..");
const args = parseArgs(process.argv.slice(2));
const configPath = path.resolve(
args.config || path.join(repoRoot, "config", "areas", "nantaizi-lake-innovation-valley.json"),
);
const area = readAreaConfig(configPath, { repoRoot });
const requestedStages = args.stages
? splitList(args.stages)
: null;
const stages = resolveStages(area.stages, requestedStages);
// intermediates deletes and rebuilds the GeoPackage from OSM, which is exactly
// the manual work reimport exists to recover. Refuse the combination instead of
// silently letting one undo the other.
if (stages.intermediates && stages.reimport) {
throw new Error(
"Stages 'intermediates' and 'reimport' are mutually exclusive: " +
"intermediates rebuilds the GeoPackage from OSM and would discard the QGIS edits reimport reads back.",
);
}
console.log(`Area: ${area.id}`);
console.log(`Config: ${configPath}`);
console.log(`Output: ${area.outputs.areaDir}`);
if (stages.intermediates) {
buildIntermediates(area);
}
if (stages.reimport) {
reimportGpkg(area);
}
if (stages.blender) {
buildBlenderScene(area);
}
if (stages.cesium) {
exportCesium(area);
}
if (stages.preview) {
writeCesiumPreview(area);
}
if (stages.compress) {
compressCesiumGlb(area);
}
console.log("Done.");
function parseArgs(argv) {
const out = {};
for (let i = 0; i < argv.length; i += 1) {
const arg = argv[i];
if (!arg.startsWith("--")) continue;
const key = arg.slice(2).replace(/-([a-z])/g, (_, c) => c.toUpperCase());
const next = argv[i + 1];
if (!next || next.startsWith("--")) {
out[key] = "true";
} else {
out[key] = next;
i += 1;
}
}
return out;
}
function splitList(value) {
return String(value)
.split(",")
.map((item) => item.trim())
.filter(Boolean);
}
function resolveStages(defaults, requested) {
if (!requested) return defaults;
// 'reimport' is deliberately absent from 'all': it is a recovery step for
// hand-edited GeoPackages, never part of a full build.
// 'compress' is also absent from 'all': it creates an alternate Cesium GLB,
// not the baseline asset.
const aliases = {
all: ["intermediates", "blender", "cesium"],
qgis: ["intermediates"],
osm2streets: ["intermediates"],
geojson: ["intermediates"],
intermediate: ["intermediates"],
intermediates: ["intermediates"],
reimport: ["reimport"],
gpkg: ["reimport"],
blender: ["blender"],
scene: ["blender"],
cesium: ["cesium"],
glb: ["cesium"],
preview: ["preview"],
html: ["preview"],
cesiumPreview: ["preview"],
compress: ["compress"],
compression: ["compress"],
compressedCesium: ["compress"],
};
const out = {
intermediates: false,
reimport: false,
blender: false,
cesium: false,
preview: false,
compress: false,
};
for (const stage of requested) {
const mapped = aliases[stage];
if (!mapped) {
throw new Error(`Unknown stage '${stage}'. Use intermediates, reimport, blender, cesium, preview, compress, or all.`);
}
for (const key of mapped) out[key] = true;
}
return out;
}
function writeDerivedConfig(area) {
fs.mkdirSync(area.outputs.pipelineDir, { recursive: true });
const derivedConfig = {
qgisApp: area.qgisApp,
input: area.input,
outDir: area.outputs.geojsonDir,
gpkg: area.outputs.gpkg,
project: area.outputs.qgisProject,
preview: area.outputs.qgisPreview,
arrowScale: area.qgis.arrowScale,
arrowMergeTriangles: area.qgis.arrowMergeTriangles,
arrowOutlineSimplifyMeters: area.qgis.arrowOutlineSimplifyMeters,
intersectionCornerSourceMaxDimensionMeters: area.qgis.intersectionCornerSourceMaxDimensionMeters,
clipPad: area.qgis.clipPad,
canvasPad: area.qgis.canvasPad,
previewPad: area.qgis.previewPad,
canvasExtent: area.qgis.canvasExtent,
previewExtent: area.qgis.previewExtent,
layerPrefix: area.qgis.layerPrefix,
osm2streets: area.osm2streets,
};
const derivedConfigPath = path.join(area.outputs.pipelineDir, "osm2streets-qgis.config.json");
fs.writeFileSync(derivedConfigPath, `${JSON.stringify(derivedConfig, null, 2)}\n`);
return derivedConfigPath;
}
function buildIntermediates(area) {
const derivedConfigPath = writeDerivedConfig(area);
console.log("Stage: intermediates (osm2streets GeoJSON + QGIS)");
runCommand(process.execPath, [
path.join(repoRoot, "scripts", "build-osm2streets-qgis.js"),
"--config",
derivedConfigPath,
], "intermediates");
}
function reimportGpkg(area) {
const derivedConfigPath = writeDerivedConfig(area);
console.log("Stage: reimport (GeoPackage -> GeoJSON)");
runCommand(process.execPath, [
path.join(repoRoot, "scripts", "reimport-gpkg.js"),
"--config",
derivedConfigPath,
], "reimport");
}
function buildBlenderScene(area) {
ensureFile(blenderExecutable(area), "Blender executable");
ensureFile(path.join(repoRoot, "blender", "generate_scene.py"), "Blender scene generator");
fs.mkdirSync(path.dirname(area.outputs.blend), { recursive: true });
fs.mkdirSync(path.dirname(area.outputs.render), { recursive: true });
const blenderArgs = [
"--background",
"--factory-startup",
"--python",
path.join(repoRoot, "blender", "generate_scene.py"),
"--",
"--osm",
area.input,
"--geojson",
area.outputs.geojsonDir,
"--output",
area.outputs.blend,
"--render",
area.outputs.render,
"--tree-style",
area.blender.treeStyle,
];
if (area.blender.officeOverrides) {
blenderArgs.push("--office-overrides", area.blender.officeOverrides);
}
console.log("Stage: blender");
runCommand(blenderExecutable(area), blenderArgs, "blender");
}
function exportCesium(area) {
ensureFile(blenderExecutable(area), "Blender executable");
ensureFile(area.outputs.blend, "Blend scene");
ensureFile(path.join(repoRoot, "blender", "export_cesium.py"), "Cesium exporter");
fs.mkdirSync(path.dirname(area.outputs.glb), { recursive: true });
fs.mkdirSync(path.dirname(area.outputs.metadata), { recursive: true });
console.log("Stage: cesium");
const started = Date.now();
const startedAt = new Date(started).toISOString();
runCommand(blenderExecutable(area), [
"--background",
"--python",
path.join(repoRoot, "blender", "export_cesium.py"),
"--",
"--blend",
area.outputs.blend,
"--glb",
area.outputs.glb,
"--metadata",
area.outputs.metadata,
], "cesium");
writeCesiumPreview(area);
const finished = Date.now();
const digest = glbDigest(area.outputs.glb);
writeStageManifest(area, {
stage: "cesium",
status: "ok",
config: configPath,
startedAt,
finishedAt: new Date(finished).toISOString(),
durationMs: finished - started,
inputs: {
blend: fileRecord(area.outputs.blend),
},
outputs: {
glb: fileRecord(area.outputs.glb),
metadata: fileRecord(area.outputs.metadata),
cesiumPreview: fileRecord(area.outputs.cesiumPreview),
vehicleRoute: optionalFileRecord(area.outputs.vehicleRoute),
vehicleModel: optionalFileRecord(area.outputs.vehicleModel),
},
summary: {
glb: glbSummary(digest),
},
warnings: glbBudgetWarnings("Cesium", digest),
});
}
function compressCesiumGlb(area) {
ensureFile(area.outputs.glb, "Cesium GLB");
ensureFile(area.outputs.metadata, "Cesium metadata");
ensureFile(area.outputs.cesiumPreview, "Cesium preview");
ensureFile(path.join(repoRoot, "scripts", "compress-glb.js"), "GLB compressor");
fs.mkdirSync(path.dirname(area.outputs.compressedGlb), { recursive: true });
fs.mkdirSync(path.dirname(area.outputs.compressedMetadata), { recursive: true });
fs.mkdirSync(path.dirname(area.outputs.compressedCesiumPreview), { recursive: true });
const compressArgs = [
path.join(repoRoot, "scripts", "compress-glb.js"),
"--input",
area.outputs.glb,
"--output",
area.outputs.compressedGlb,
"--texture-size",
String(area.compress.textureSize),
"--quality",
String(area.compress.quality),
"--effort",
String(area.compress.effort),
"--metadata",
area.outputs.metadata,
"--metadata-output",
area.outputs.compressedMetadata,
"--preview",
area.outputs.cesiumPreview,
"--preview-output",
area.outputs.compressedCesiumPreview,
];
if (area.compress.meshopt) {
compressArgs.push("--meshopt");
}
console.log("Stage: compress (Cesium GLB texture resize + WebP)");
const started = Date.now();
const startedAt = new Date(started).toISOString();
runCommand(process.execPath, compressArgs, "compress");
const finished = Date.now();
const sourceDigest = glbDigest(area.outputs.glb);
const compressedDigest = glbDigest(area.outputs.compressedGlb);
writeStageManifest(area, {
stage: "compress",
status: "ok",
config: configPath,
startedAt,
finishedAt: new Date(finished).toISOString(),
durationMs: finished - started,
inputs: {
glb: fileRecord(area.outputs.glb),
metadata: fileRecord(area.outputs.metadata),
cesiumPreview: fileRecord(area.outputs.cesiumPreview),
},
outputs: {
compressedGlb: fileRecord(area.outputs.compressedGlb),
compressedMetadata: fileRecord(area.outputs.compressedMetadata),
compressedCesiumPreview: fileRecord(area.outputs.compressedCesiumPreview),
},
summary: {
sourceGlb: glbSummary(sourceDigest),
compressedGlb: glbSummary(compressedDigest),
options: {
textureSize: area.compress.textureSize,
quality: area.compress.quality,
effort: area.compress.effort,
meshopt: area.compress.meshopt,
},
compressionRatio: Number((compressedDigest.fileBytes / sourceDigest.fileBytes).toFixed(4)),
savedBytes: sourceDigest.fileBytes - compressedDigest.fileBytes,
},
warnings: glbBudgetWarnings("Compressed", compressedDigest),
});
}
function blenderExecutable(area) {
return path.join(area.blenderApp, "Contents", "MacOS", "Blender");
}
function ensureFile(file, label) {
if (!fs.existsSync(file)) {
throw new Error(`${label} not found: ${file}`);
}
}
function runCommand(command, commandArgs, stage) {
const result = spawnSync(command, commandArgs, { stdio: "inherit" });
if (result.error) {
throw result.error;
}
if (result.status !== 0) {
const signal = result.signal ? ` signal=${result.signal}` : "";
throw new Error(`Stage '${stage}' failed with status=${result.status}${signal}`);
}
}
function writeCesiumPreview(area) {
ensureFile(area.outputs.glb, "Cesium GLB");
ensureFile(area.outputs.metadata, "Cesium metadata");
const htmlPath = area.outputs.cesiumPreview;
fs.mkdirSync(path.dirname(htmlPath), { recursive: true });
writeVehicleRoute(area);
writeVehicleModel(area);
writeCesiumPreviewSupportFiles(path.dirname(htmlPath));
const glbName = path.basename(area.outputs.glb);
const metadataName = path.basename(area.outputs.metadata);
const routeName = path.basename(area.outputs.vehicleRoute);
const vehicleModelName = path.basename(area.outputs.vehicleModel);
fs.writeFileSync(htmlPath, cesiumPreviewHtml(glbName, metadataName, routeName, vehicleModelName, area.id));
console.log(`Cesium preview: ${htmlPath}`);
}
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);
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();
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("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;");
}
function escapeScriptJson(value) {
return String(value)
.replaceAll("<", "\\u003c")
.replaceAll(">", "\\u003e")
.replaceAll("&", "\\u0026")
.replaceAll("\u2028", "\\u2028")
.replaceAll("\u2029", "\\u2029");
}