Extract Cesium preview assets

This commit is contained in:
2026-07-31 16:56:22 +08:00
parent 41a07a78ef
commit 0def373c09
4 changed files with 802 additions and 373 deletions

View File

@@ -576,6 +576,13 @@ def export(args):
center_lon = center_lat = 0.0
metadata = {
"asset": os.path.basename(args["glb"]),
"assets": [{
"id": "main",
"label": "Scene",
"type": "model",
"url": os.path.basename(args["glb"]),
"enabled": True,
}],
"coordinate_system": "local ENU meters (X east, Y north, Z up)",
"heading_correction_degrees": -90.0,
"anchor": {"longitude": center_lon, "latitude": center_lat, "height": 0.35},

View File

@@ -316,6 +316,7 @@ function writeCesiumPreview(area) {
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);
@@ -324,6 +325,14 @@ function writeCesiumPreview(area) {
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 });
@@ -686,6 +695,13 @@ function cylinderY(cx, lateral, up, radius, width, segments) {
}
function cesiumPreviewHtml(glbName, metadataName, routeName, vehicleModelName, areaId) {
const previewConfig = {
areaId,
glbName,
metadataName,
routeName,
vehicleModelName,
};
return `<!doctype html>
<html lang="zh-CN">
<head>
@@ -694,383 +710,37 @@ function cesiumPreviewHtml(glbName, metadataName, routeName, vehicleModelName, a
<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">
<style>
html, body, #cesiumContainer {
width: 100%;
height: 100%;
margin: 0;
overflow: hidden;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
background: #d9e0e2;
}
#status {
position: absolute;
left: 12px;
bottom: 12px;
z-index: 1;
max-width: 520px;
padding: 8px 10px;
border-radius: 4px;
background: rgba(20, 24, 28, 0.78);
color: #fff;
font-size: 12px;
line-height: 1.45;
}
#controls {
position: absolute;
top: 12px;
left: 12px;
z-index: 1;
display: flex;
gap: 8px;
align-items: center;
padding: 8px;
border-radius: 4px;
background: rgba(20, 24, 28, 0.78);
color: #fff;
font-size: 12px;
}
#controls button {
height: 28px;
border: 0;
border-radius: 4px;
padding: 0 10px;
background: #f2f5f7;
color: #111;
cursor: pointer;
}
#controls input {
width: 120px;
}
</style>
<link href="cesium-preview.css" rel="stylesheet">
</head>
<body>
<div id="cesiumContainer"></div>
<div id="controls">
<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 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>
<script>
const statusEl = document.getElementById("status");
const toggleCruise = document.getElementById("toggleCruise");
const toggleFollow = document.getElementById("toggleFollow");
const vehicleSelect = document.getElementById("vehicleSelect");
const speedControl = document.getElementById("speedControl");
const speedLabel = document.getElementById("speedLabel");
Cesium.Ion.defaultAccessToken = "";
async function main() {
const metadata = await fetch("${escapeJs(metadataName)}").then((r) => r.json());
const routeData = await fetch("${escapeJs(routeName)}").then((r) => r.json());
const anchor = metadata.anchor || {};
const longitude = Number(anchor.longitude || 0);
const latitude = Number(anchor.latitude || 0);
const height = Number(anchor.height || 0);
const heading = Number(metadata.heading_correction_degrees || 0);
const viewer = new Cesium.Viewer("cesiumContainer", {
animation: false,
timeline: false,
baseLayerPicker: false,
geocoder: false,
navigationHelpButton: false,
sceneModePicker: false,
homeButton: true,
fullscreenButton: true,
infoBox: false,
selectionIndicator: false,
baseLayer: false
});
viewer.scene.globe.depthTestAgainstTerrain = true;
viewer.scene.backgroundColor = Cesium.Color.fromCssColorString("#d9e0e2");
viewer.scene.skyAtmosphere.show = false;
viewer.scene.skyBox.show = false;
viewer.scene.sun.show = false;
viewer.scene.moon.show = false;
viewer.scene.globe.baseColor = Cesium.Color.fromCssColorString("#d4dcde");
const p = Cesium.Cartesian3.fromDegrees(longitude, latitude, height);
const enu = Cesium.Transforms.eastNorthUpToFixedFrame(p);
const correction = Cesium.Matrix3.fromRotationZ(Cesium.Math.toRadians(heading));
const modelMatrix = Cesium.Matrix4.multiplyByMatrix3(enu, correction, new Cesium.Matrix4());
const model = await Cesium.Model.fromGltfAsync({
url: "${escapeJs(glbName)}",
modelMatrix,
scale: 1.0
});
viewer.scene.primitives.add(model);
const cruise = addVehicleCruises(viewer, routeData);
const center = Cesium.Cartesian3.fromDegrees(longitude, latitude, height);
viewer.camera.flyToBoundingSphere(
new Cesium.BoundingSphere(center, 900.0),
{ duration: 0.0 }
);
statusEl.textContent = "${escapeJs(glbName)} | vehicles " + cruise.vehicles.length;
}
function addVehicleCruises(viewer, routeData) {
const segments = (routeData.segments || [])
.filter((segment) => segment.coordinates && segment.coordinates.length >= 2)
.slice(0, 5);
if (!segments.length) {
throw new Error("No drivable route found in ${escapeJs(routeName)}");
}
const speed = Number(routeData.speedMetersPerSecond || 8);
const start = Cesium.JulianDate.now();
viewer.clock.startTime = start.clone();
viewer.clock.currentTime = start.clone();
viewer.clock.clockRange = Cesium.ClockRange.UNBOUNDED;
viewer.clock.multiplier = 1;
viewer.clock.shouldAnimate = true;
const vehicles = segments.map((segment, index) => {
const vehicle = addCruiseVehicle(viewer, segment, index, start, speed);
const option = document.createElement("option");
option.value = String(index);
option.textContent = "#" + (index + 1) + " " + segment.name + " " + Math.round(segment.lengthMeters) + "m";
vehicleSelect.appendChild(option);
return vehicle;
});
const state = { selectedIndex: 0 };
toggleCruise.addEventListener("click", () => {
viewer.clock.shouldAnimate = !viewer.clock.shouldAnimate;
toggleCruise.textContent = viewer.clock.shouldAnimate ? "Pause" : "Play";
});
speedControl.addEventListener("input", () => {
const value = Number(speedControl.value);
viewer.clock.multiplier = value / speed;
speedLabel.textContent = value + " m/s";
});
vehicleSelect.addEventListener("change", () => {
state.selectedIndex = Number(vehicleSelect.value || 0);
statusEl.textContent = selectedVehicle().label;
});
function selectedVehicle() {
return vehicles[state.selectedIndex] || vehicles[0];
}
const follow = createChaseFollow(viewer, () => selectedVehicle().positions);
toggleFollow.addEventListener("click", () => {
if (follow.enabled) {
follow.stop();
toggleFollow.textContent = "Follow";
} else {
follow.start();
toggleFollow.textContent = "Free";
}
});
statusEl.textContent = selectedVehicle().label;
return { vehicles };
}
function addCruiseVehicle(viewer, segment, index, start, speed) {
const route = prepareRoute(segment);
const positions = new Cesium.CallbackProperty((time, result) => {
return routePosition(route, start, time, speed, result);
}, false);
const flat = [];
for (const coord of segment.coordinates) {
flat.push(coord[0], coord[1], 1.05);
}
const routeColor = [Cesium.Color.CYAN, Cesium.Color.LIME, Cesium.Color.YELLOW, Cesium.Color.ORANGE, Cesium.Color.DEEPSKYBLUE][index % 5];
viewer.entities.add({
name: "Cruise route " + (index + 1),
polyline: {
positions: Cesium.Cartesian3.fromDegreesArrayHeights(flat),
width: 2,
material: routeColor.withAlpha(0.75),
clampToGround: false
}
});
const vehicle = viewer.entities.add({
name: "Cruise vehicle " + (index + 1),
position: positions,
orientation: routeOrientation(route, start, speed, 0.0),
model: {
uri: "${escapeJs(vehicleModelName)}",
scale: 1.0,
minimumPixelSize: 24,
maximumScale: 80
}
});
return {
entity: vehicle,
positions,
segment,
label: "Vehicle #" + (index + 1) + " | route " + segment.id + " | " + Math.round(segment.lengthMeters) + "m"
};
}
function prepareRoute(segment) {
const distances = [0.0];
for (let i = 1; i < segment.coordinates.length; i += 1) {
distances.push(distances[i - 1] + distanceMeters(segment.coordinates[i - 1], segment.coordinates[i]));
}
return {
coordinates: segment.coordinates,
distances,
length: Math.max(1.0, distances[distances.length - 1])
};
}
function routePosition(route, start, time, speed, result) {
const seconds = Math.max(0, Cesium.JulianDate.secondsDifference(time, start));
const distance = (seconds * speed) % route.length;
let index = 1;
while (index < route.distances.length - 1 && route.distances[index] < distance) {
index += 1;
}
const prevDist = route.distances[index - 1];
const nextDist = route.distances[index];
const t = nextDist > prevDist ? (distance - prevDist) / (nextDist - prevDist) : 0;
const a = route.coordinates[index - 1];
const b = route.coordinates[index];
const lon = a[0] + (b[0] - a[0]) * t;
const lat = a[1] + (b[1] - a[1]) * t;
return Cesium.Cartesian3.fromDegrees(lon, lat, 1.15, Cesium.Ellipsoid.WGS84, result);
}
function routeOrientation(route, start, speed, yawDegrees) {
const correction = Cesium.Quaternion.fromAxisAngle(
Cesium.Cartesian3.UNIT_Z,
Cesium.Math.toRadians(yawDegrees)
);
const current = new Cesium.Cartesian3();
const ahead = new Cesium.Cartesian3();
const direction = new Cesium.Cartesian3();
const up = new Cesium.Cartesian3();
const east = new Cesium.Cartesian3();
const north = new Cesium.Cartesian3();
return new Cesium.CallbackProperty((time, result) => {
routePosition(route, start, time, speed, current);
const aheadTime = Cesium.JulianDate.addSeconds(time, 0.8, new Cesium.JulianDate());
routePosition(route, start, aheadTime, speed, ahead);
Cesium.Cartesian3.subtract(ahead, current, direction);
if (Cesium.Cartesian3.magnitudeSquared(direction) < 0.0001) {
return result;
}
Cesium.Cartesian3.normalize(direction, direction);
Cesium.Cartesian3.normalize(current, up);
Cesium.Cartesian3.cross(Cesium.Cartesian3.UNIT_Z, up, east);
if (Cesium.Cartesian3.magnitudeSquared(east) < 0.0001) {
Cesium.Cartesian3.clone(Cesium.Cartesian3.UNIT_X, east);
} else {
Cesium.Cartesian3.normalize(east, east);
}
Cesium.Cartesian3.cross(up, east, north);
Cesium.Cartesian3.normalize(north, north);
const eastComponent = Cesium.Cartesian3.dot(direction, east);
const northComponent = Cesium.Cartesian3.dot(direction, north);
const heading = Math.atan2(eastComponent, northComponent);
const base = Cesium.Transforms.headingPitchRollQuaternion(
current,
new Cesium.HeadingPitchRoll(heading, 0.0, 0.0)
);
return Cesium.Quaternion.multiply(base, correction, result || new Cesium.Quaternion());
}, false);
}
function createChaseFollow(viewer, positionsProvider) {
const scratchPosition = new Cesium.Cartesian3();
const scratchPrevious = new Cesium.Cartesian3();
const scratchDirection = new Cesium.Cartesian3();
const scratchEast = new Cesium.Cartesian3();
const scratchNorth = new Cesium.Cartesian3();
const scratchUp = new Cesium.Cartesian3();
const offset = new Cesium.Cartesian3();
const state = { enabled: false, distance: 36.0, height: 18.0 };
function update(clock) {
const time = clock.currentTime;
const positions = positionsProvider();
const position = positions.getValue(time, scratchPosition);
if (!position) return;
const previousTime = Cesium.JulianDate.addSeconds(time, -0.8, new Cesium.JulianDate());
const previous = positions.getValue(previousTime, scratchPrevious);
if (previous) {
Cesium.Cartesian3.subtract(position, previous, scratchDirection);
} else {
Cesium.Cartesian3.clone(Cesium.Cartesian3.UNIT_X, scratchDirection);
}
if (Cesium.Cartesian3.magnitudeSquared(scratchDirection) < 0.0001) {
Cesium.Cartesian3.clone(Cesium.Cartesian3.UNIT_X, scratchDirection);
}
Cesium.Cartesian3.normalize(scratchDirection, scratchDirection);
Cesium.Cartesian3.normalize(position, scratchUp);
Cesium.Cartesian3.cross(Cesium.Cartesian3.UNIT_Z, scratchUp, scratchEast);
if (Cesium.Cartesian3.magnitudeSquared(scratchEast) < 0.0001) {
Cesium.Cartesian3.clone(Cesium.Cartesian3.UNIT_X, scratchEast);
} else {
Cesium.Cartesian3.normalize(scratchEast, scratchEast);
}
Cesium.Cartesian3.cross(scratchUp, scratchEast, scratchNorth);
Cesium.Cartesian3.normalize(scratchNorth, scratchNorth);
const eastComponent = Cesium.Cartesian3.dot(scratchDirection, scratchEast);
const northComponent = Cesium.Cartesian3.dot(scratchDirection, scratchNorth);
const heading = Math.atan2(eastComponent, northComponent);
Cesium.Cartesian3.fromElements(0.0, -state.distance, state.height, offset);
const transform = Cesium.Transforms.headingPitchRollToFixedFrame(
position,
new Cesium.HeadingPitchRoll(heading, 0.0, 0.0)
);
viewer.camera.lookAtTransform(transform, offset);
}
function onWheel(event) {
if (!state.enabled) return;
event.preventDefault();
const zoom = event.deltaY > 0 ? 1.12 : 0.88;
state.distance = Cesium.Math.clamp(state.distance * zoom, 12.0, 160.0);
state.height = Cesium.Math.clamp(state.height * zoom, 6.0, 90.0);
update(viewer.clock);
}
return {
get enabled() {
return state.enabled;
},
start() {
if (state.enabled) return;
state.enabled = true;
viewer.trackedEntity = undefined;
viewer.canvas.addEventListener("wheel", onWheel, { passive: false });
viewer.clock.onTick.addEventListener(update);
update(viewer.clock);
},
stop() {
if (!state.enabled) return;
state.enabled = false;
viewer.clock.onTick.removeEventListener(update);
viewer.canvas.removeEventListener("wheel", onWheel);
viewer.camera.lookAtTransform(Cesium.Matrix4.IDENTITY);
}
};
}
function distanceMeters(a, b) {
const radius = 6371008.8;
const lat1 = Cesium.Math.toRadians(a[1]);
const lat2 = Cesium.Math.toRadians(b[1]);
const dLat = Cesium.Math.toRadians(b[1] - a[1]);
const dLon = Cesium.Math.toRadians(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)));
}
main().catch((error) => {
console.error(error);
statusEl.textContent = "Failed to load Cesium preview: " + error.message;
});
</script>
<script>window.OSM_ASSET_PREVIEW_CONFIG = ${escapeScriptJson(JSON.stringify(previewConfig))};</script>
<script src="cesium-preview.js"></script>
</body>
</html>
`;
@@ -1084,6 +754,11 @@ function escapeHtml(value) {
.replaceAll('"', "&quot;");
}
function escapeJs(value) {
return String(value).replaceAll("\\", "\\\\").replaceAll('"', '\\"');
function escapeScriptJson(value) {
return String(value)
.replaceAll("<", "\\u003c")
.replaceAll(">", "\\u003e")
.replaceAll("&", "\\u0026")
.replaceAll("\u2028", "\\u2028")
.replaceAll("\u2029", "\\u2029");
}

View File

@@ -0,0 +1,117 @@
html,
body,
#cesiumContainer {
width: 100%;
height: 100%;
margin: 0;
overflow: hidden;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
background: #d9e0e2;
}
#controls,
#diagnostics,
#status {
position: absolute;
z-index: 1;
border-radius: 4px;
background: rgba(20, 24, 28, 0.82);
color: #fff;
font-size: 12px;
line-height: 1.45;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.22);
}
#controls {
top: 12px;
left: 12px;
display: flex;
flex-direction: column;
gap: 6px;
padding: 8px;
}
#status {
left: 12px;
bottom: 12px;
max-width: 560px;
padding: 8px 10px;
}
#diagnostics {
right: 12px;
top: 12px;
min-width: 280px;
max-width: 360px;
padding: 10px 12px;
white-space: pre-line;
}
#controls button,
#controls select {
height: 28px;
border: 0;
border-radius: 4px;
}
#controls button {
padding: 0 10px;
background: #f2f5f7;
color: #111;
cursor: pointer;
}
#controls button:active:not(:disabled) {
transform: translateY(1px);
}
#controls :disabled {
opacity: 0.45;
cursor: default;
}
#controls label:has(:disabled) {
opacity: 0.45;
}
#controls input[type="range"] {
width: 120px;
}
#controls label {
display: inline-flex;
gap: 5px;
align-items: center;
white-space: nowrap;
}
/* Playback / layers / camera each read as their own row of the panel. */
.control-group {
display: flex;
flex-wrap: wrap;
gap: 8px 12px;
align-items: center;
}
.control-group + .control-group {
padding-top: 6px;
border-top: 1px solid rgba(255, 255, 255, 0.16);
}
/* Per-asset checkboxes hang off the master "Scene" toggle. */
.control-subgroup {
display: inline-flex;
flex-wrap: wrap;
gap: 8px 10px;
align-items: center;
padding-left: 10px;
border-left: 1px solid rgba(255, 255, 255, 0.16);
}
.control-subgroup:empty {
display: none;
}
.hidden {
display: none;
}

View File

@@ -0,0 +1,630 @@
(function () {
"use strict";
const config = window.OSM_ASSET_PREVIEW_CONFIG || {};
const statusEl = document.getElementById("status");
const diagnosticsEl = document.getElementById("diagnostics");
const toggleCruise = document.getElementById("toggleCruise");
const toggleFollow = document.getElementById("toggleFollow");
const toggleScene = document.getElementById("toggleScene");
const toggleRoutes = document.getElementById("toggleRoutes");
const toggleVehicles = document.getElementById("toggleVehicles");
const toggleFps = document.getElementById("toggleFps");
const toggleDiagnostics = document.getElementById("toggleDiagnostics");
const assetToggles = document.getElementById("assetToggles");
const vehicleSelect = document.getElementById("vehicleSelect");
const speedControl = document.getElementById("speedControl");
const speedLabel = document.getElementById("speedLabel");
const cameraButtons = Array.from(document.querySelectorAll("[data-camera]"));
// Status carries two kinds of message: the scene summary, which is what the
// panel should read whenever nothing else is going on, and transient notes
// from a control the user just touched. Keep the summary so the transient
// note can be replaced instead of destroying it.
let baseStatus = "";
Cesium.Ion.defaultAccessToken = "";
async function main() {
const metadata = await fetchJson(config.metadataName);
const routeData = await fetchOptionalJson(config.routeName);
const placement = scenePlacement(metadata);
const viewer = createViewer();
const assets = await loadSceneAssets(viewer, metadata, placement);
const cruise = addVehicleCruises(viewer, routeData, config.vehicleModelName);
const cameras = createCameraPresets(viewer, metadata, placement, cruise);
buildAssetToggles(assets);
bindRuntimeControls(viewer, assets, cruise, cameras);
startDiagnostics(viewer, metadata, assets, cruise, placement);
cameras.overview();
baseStatus = summaryText(metadata, assets, cruise);
setStatus(baseStatus);
// Handle for the browser console and for headless checks: everything else
// in here is closed over by the IIFE and unreachable from outside.
window.osmPreview = { viewer, metadata, placement, assets, cruise, cameras };
}
async function fetchJson(url) {
const response = await fetch(url);
if (!response.ok) {
throw new Error("Could not load " + url + ": " + response.status);
}
return response.json();
}
// The route file is an extra on top of the scene, not a precondition for it.
// A missing or unreadable route costs the cruise controls, not the preview.
async function fetchOptionalJson(url) {
try {
return await fetchJson(url);
} catch (error) {
console.warn(error);
return null;
}
}
function createViewer() {
const viewer = new Cesium.Viewer("cesiumContainer", {
animation: false,
timeline: false,
baseLayerPicker: false,
geocoder: false,
navigationHelpButton: false,
sceneModePicker: false,
homeButton: true,
fullscreenButton: true,
infoBox: false,
selectionIndicator: false,
baseLayer: false
});
viewer.scene.globe.depthTestAgainstTerrain = true;
viewer.scene.backgroundColor = Cesium.Color.fromCssColorString("#d9e0e2");
viewer.scene.skyAtmosphere.show = false;
viewer.scene.skyBox.show = false;
viewer.scene.sun.show = false;
viewer.scene.moon.show = false;
viewer.scene.globe.baseColor = Cesium.Color.fromCssColorString("#d4dcde");
return viewer;
}
function scenePlacement(metadata) {
const anchor = metadata.anchor || {};
const longitude = Number(anchor.longitude || 0);
const latitude = Number(anchor.latitude || 0);
const height = Number(anchor.height || 0);
const heading = Number(metadata.heading_correction_degrees || 0);
const position = Cesium.Cartesian3.fromDegrees(longitude, latitude, height);
const enu = Cesium.Transforms.eastNorthUpToFixedFrame(position);
const correction = Cesium.Matrix3.fromRotationZ(Cesium.Math.toRadians(heading));
return {
longitude,
latitude,
height,
heading,
position,
modelMatrix: Cesium.Matrix4.multiplyByMatrix3(enu, correction, new Cesium.Matrix4())
};
}
function normalizedAssets(metadata) {
const assets = Array.isArray(metadata.assets) && metadata.assets.length
? metadata.assets
: [{
id: "main",
label: "Scene",
type: "model",
url: metadata.asset || config.glbName,
enabled: true
}];
return assets.filter((asset) => (asset.type || "model") === "model" && asset.url);
}
// One broken entry in metadata.assets should not blank the whole preview, so
// failures are collected and surfaced in the diagnostics panel instead.
async function loadSceneAssets(viewer, metadata, placement) {
const loaded = [];
for (const asset of normalizedAssets(metadata)) {
const id = asset.id || "asset-" + loaded.length;
const label = asset.label || asset.id || asset.url;
try {
const model = await Cesium.Model.fromGltfAsync({
url: asset.url,
modelMatrix: placement.modelMatrix,
scale: Number(asset.scale || 1.0)
});
model.show = asset.enabled !== false;
viewer.scene.primitives.add(model);
loaded.push({ id, label, url: asset.url, model, error: null });
} catch (error) {
console.error(error);
loaded.push({ id, label, url: asset.url, model: null, error });
}
}
if (!loaded.some((asset) => asset.model)) {
throw new Error("No scene model could be loaded (" + loaded.length + " declared)");
}
return loaded;
}
function liveAssets(assets) {
return assets.filter((asset) => asset.model);
}
// A single-asset scene keeps the plain "Scene" checkbox; a multi-asset one
// gets a child checkbox per model with "Scene" acting as the master.
function buildAssetToggles(assets) {
const live = liveAssets(assets);
if (live.length < 2) return;
for (const asset of live) {
const label = document.createElement("label");
const input = document.createElement("input");
input.type = "checkbox";
input.checked = asset.model.show;
input.dataset.assetId = asset.id;
input.addEventListener("change", () => {
asset.model.show = input.checked;
syncSceneMaster(assets);
setStatus(asset.label + (input.checked ? " visible" : " hidden"));
});
label.appendChild(input);
label.appendChild(document.createTextNode(" " + asset.label));
assetToggles.appendChild(label);
asset.toggle = input;
}
}
function syncSceneMaster(assets) {
const live = liveAssets(assets);
const shown = live.filter((asset) => asset.model.show).length;
toggleScene.checked = shown > 0;
toggleScene.indeterminate = shown > 0 && shown < live.length;
}
function bindRuntimeControls(viewer, assets, cruise, cameras) {
const hasVehicles = cruise.vehicles.length > 0;
toggleScene.addEventListener("change", () => {
for (const asset of liveAssets(assets)) {
asset.model.show = toggleScene.checked;
if (asset.toggle) asset.toggle.checked = toggleScene.checked;
}
toggleScene.indeterminate = false;
setStatus(toggleScene.checked ? "Scene visible" : "Scene hidden");
});
toggleRoutes.addEventListener("change", () => {
for (const vehicle of cruise.vehicles) vehicle.routeEntity.show = toggleRoutes.checked;
});
toggleVehicles.addEventListener("change", () => {
for (const vehicle of cruise.vehicles) vehicle.entity.show = toggleVehicles.checked;
});
toggleFps.addEventListener("change", () => {
viewer.scene.debugShowFramesPerSecond = toggleFps.checked;
});
toggleDiagnostics.addEventListener("change", () => {
diagnosticsEl.classList.toggle("hidden", !toggleDiagnostics.checked);
});
let stopFollow = function () {};
if (hasVehicles) {
stopFollow = bindCruiseControls(viewer, cruise);
} else {
// Nothing to drive: disable the cruise half of the panel rather than
// leaving controls that silently do nothing.
for (const el of [toggleCruise, toggleFollow, vehicleSelect, speedControl, toggleRoutes, toggleVehicles]) {
el.disabled = true;
}
toggleCruise.textContent = "Play";
}
for (const button of cameraButtons) {
const preset = cameras[button.dataset.camera];
if (!preset || (button.dataset.camera === "route" && !hasVehicles)) {
button.disabled = true;
continue;
}
button.addEventListener("click", () => {
// Chase-follow reclaims the camera on every clock tick, so a preset
// applied underneath it would be overwritten before the next frame.
stopFollow();
preset();
});
}
}
function bindCruiseControls(viewer, cruise) {
// The slider is authored with a static default; the route file decides the
// real cruise speed, so adopt it before the first input event.
speedControl.value = String(Cesium.Math.clamp(
Math.round(cruise.baseSpeed),
Number(speedControl.min),
Number(speedControl.max)
));
viewer.clock.multiplier = Number(speedControl.value) / cruise.baseSpeed;
speedLabel.textContent = speedControl.value + " m/s";
toggleCruise.addEventListener("click", () => {
viewer.clock.shouldAnimate = !viewer.clock.shouldAnimate;
toggleCruise.textContent = viewer.clock.shouldAnimate ? "Pause" : "Play";
});
speedControl.addEventListener("input", () => {
const value = Number(speedControl.value);
viewer.clock.multiplier = value / cruise.baseSpeed;
speedLabel.textContent = value + " m/s";
});
vehicleSelect.addEventListener("change", () => {
cruise.state.selectedIndex = Number(vehicleSelect.value || 0);
setStatus(selectedVehicle(cruise).label);
});
const follow = createChaseFollow(viewer, () => selectedVehicle(cruise).positions);
function stopFollow() {
if (!follow.enabled) return;
follow.stop();
toggleFollow.textContent = "Follow";
}
toggleFollow.addEventListener("click", () => {
if (follow.enabled) {
stopFollow();
setStatus(baseStatus);
} else {
follow.start();
toggleFollow.textContent = "Free";
setStatus("Following " + selectedVehicle(cruise).label);
}
});
return stopFollow;
}
function addVehicleCruises(viewer, routeData, vehicleModelName) {
const segments = ((routeData && routeData.segments) || [])
.filter((segment) => segment.coordinates && segment.coordinates.length >= 2)
.slice(0, 5);
const speed = Number((routeData && routeData.speedMetersPerSecond) || 8);
const start = Cesium.JulianDate.now();
viewer.clock.startTime = start.clone();
viewer.clock.currentTime = start.clone();
viewer.clock.clockRange = Cesium.ClockRange.UNBOUNDED;
viewer.clock.multiplier = 1;
viewer.clock.shouldAnimate = segments.length > 0;
const vehicles = segments.map((segment, index) => {
const vehicle = addCruiseVehicle(viewer, segment, index, start, speed, vehicleModelName);
const option = document.createElement("option");
option.value = String(index);
option.textContent = "#" + (index + 1) + " " + segment.name + " " + Math.round(segment.lengthMeters) + "m";
vehicleSelect.appendChild(option);
return vehicle;
});
return {
vehicles,
baseSpeed: speed,
state: { selectedIndex: 0 }
};
}
function addCruiseVehicle(viewer, segment, index, start, speed, vehicleModelName) {
const route = prepareRoute(segment);
const positions = new Cesium.CallbackProperty((time, result) => {
return routePosition(route, start, time, speed, result);
}, false);
const flat = [];
for (const coord of segment.coordinates) {
flat.push(coord[0], coord[1], 1.05);
}
const routeColor = [
Cesium.Color.CYAN,
Cesium.Color.LIME,
Cesium.Color.YELLOW,
Cesium.Color.ORANGE,
Cesium.Color.DEEPSKYBLUE
][index % 5];
const routeEntity = viewer.entities.add({
name: "Cruise route " + (index + 1),
polyline: {
positions: Cesium.Cartesian3.fromDegreesArrayHeights(flat),
width: 2,
material: routeColor.withAlpha(0.75),
clampToGround: false
}
});
const vehicle = viewer.entities.add({
name: "Cruise vehicle " + (index + 1),
position: positions,
orientation: routeOrientation(route, start, speed, 0.0),
model: {
uri: vehicleModelName,
scale: 1.0,
minimumPixelSize: 24,
maximumScale: 80
}
});
return {
entity: vehicle,
routeEntity,
positions,
route,
segment,
label: "Vehicle #" + (index + 1) + " | route " + segment.id + " | " + Math.round(segment.lengthMeters) + "m"
};
}
function selectedVehicle(cruise) {
return cruise.vehicles[cruise.state.selectedIndex] || cruise.vehicles[0];
}
function prepareRoute(segment) {
const distances = [0.0];
for (let i = 1; i < segment.coordinates.length; i += 1) {
distances.push(distances[i - 1] + distanceMeters(segment.coordinates[i - 1], segment.coordinates[i]));
}
return {
coordinates: segment.coordinates,
distances,
length: Math.max(1.0, distances[distances.length - 1])
};
}
function routePosition(route, start, time, speed, result) {
const seconds = Math.max(0, Cesium.JulianDate.secondsDifference(time, start));
const distance = (seconds * speed) % route.length;
let index = 1;
while (index < route.distances.length - 1 && route.distances[index] < distance) {
index += 1;
}
const prevDist = route.distances[index - 1];
const nextDist = route.distances[index];
const t = nextDist > prevDist ? (distance - prevDist) / (nextDist - prevDist) : 0;
const a = route.coordinates[index - 1];
const b = route.coordinates[index];
const lon = a[0] + (b[0] - a[0]) * t;
const lat = a[1] + (b[1] - a[1]) * t;
return Cesium.Cartesian3.fromDegrees(lon, lat, 1.15, Cesium.Ellipsoid.WGS84, result);
}
function routeOrientation(route, start, speed, yawDegrees) {
const correction = Cesium.Quaternion.fromAxisAngle(
Cesium.Cartesian3.UNIT_Z,
Cesium.Math.toRadians(yawDegrees)
);
const current = new Cesium.Cartesian3();
const ahead = new Cesium.Cartesian3();
const direction = new Cesium.Cartesian3();
const up = new Cesium.Cartesian3();
const east = new Cesium.Cartesian3();
const north = new Cesium.Cartesian3();
return new Cesium.CallbackProperty((time, result) => {
routePosition(route, start, time, speed, current);
const aheadTime = Cesium.JulianDate.addSeconds(time, 0.8, new Cesium.JulianDate());
routePosition(route, start, aheadTime, speed, ahead);
Cesium.Cartesian3.subtract(ahead, current, direction);
if (Cesium.Cartesian3.magnitudeSquared(direction) < 0.0001) {
return result;
}
Cesium.Cartesian3.normalize(direction, direction);
Cesium.Cartesian3.normalize(current, up);
Cesium.Cartesian3.cross(Cesium.Cartesian3.UNIT_Z, up, east);
if (Cesium.Cartesian3.magnitudeSquared(east) < 0.0001) {
Cesium.Cartesian3.clone(Cesium.Cartesian3.UNIT_X, east);
} else {
Cesium.Cartesian3.normalize(east, east);
}
Cesium.Cartesian3.cross(up, east, north);
Cesium.Cartesian3.normalize(north, north);
const eastComponent = Cesium.Cartesian3.dot(direction, east);
const northComponent = Cesium.Cartesian3.dot(direction, north);
const heading = Math.atan2(eastComponent, northComponent);
const base = Cesium.Transforms.headingPitchRollQuaternion(
current,
new Cesium.HeadingPitchRoll(heading, 0.0, 0.0)
);
return Cesium.Quaternion.multiply(base, correction, result || new Cesium.Quaternion());
}, false);
}
function createChaseFollow(viewer, positionsProvider) {
const scratchPosition = new Cesium.Cartesian3();
const scratchPrevious = new Cesium.Cartesian3();
const scratchDirection = new Cesium.Cartesian3();
const scratchEast = new Cesium.Cartesian3();
const scratchNorth = new Cesium.Cartesian3();
const scratchUp = new Cesium.Cartesian3();
const offset = new Cesium.Cartesian3();
const state = { enabled: false, distance: 36.0, height: 18.0 };
function update(clock) {
const time = clock.currentTime;
const positions = positionsProvider();
const position = positions.getValue(time, scratchPosition);
if (!position) return;
const previousTime = Cesium.JulianDate.addSeconds(time, -0.8, new Cesium.JulianDate());
const previous = positions.getValue(previousTime, scratchPrevious);
if (previous) {
Cesium.Cartesian3.subtract(position, previous, scratchDirection);
} else {
Cesium.Cartesian3.clone(Cesium.Cartesian3.UNIT_X, scratchDirection);
}
if (Cesium.Cartesian3.magnitudeSquared(scratchDirection) < 0.0001) {
Cesium.Cartesian3.clone(Cesium.Cartesian3.UNIT_X, scratchDirection);
}
Cesium.Cartesian3.normalize(scratchDirection, scratchDirection);
Cesium.Cartesian3.normalize(position, scratchUp);
Cesium.Cartesian3.cross(Cesium.Cartesian3.UNIT_Z, scratchUp, scratchEast);
if (Cesium.Cartesian3.magnitudeSquared(scratchEast) < 0.0001) {
Cesium.Cartesian3.clone(Cesium.Cartesian3.UNIT_X, scratchEast);
} else {
Cesium.Cartesian3.normalize(scratchEast, scratchEast);
}
Cesium.Cartesian3.cross(scratchUp, scratchEast, scratchNorth);
Cesium.Cartesian3.normalize(scratchNorth, scratchNorth);
const eastComponent = Cesium.Cartesian3.dot(scratchDirection, scratchEast);
const northComponent = Cesium.Cartesian3.dot(scratchDirection, scratchNorth);
const heading = Math.atan2(eastComponent, northComponent);
Cesium.Cartesian3.fromElements(0.0, -state.distance, state.height, offset);
const transform = Cesium.Transforms.headingPitchRollToFixedFrame(
position,
new Cesium.HeadingPitchRoll(heading, 0.0, 0.0)
);
viewer.camera.lookAtTransform(transform, offset);
}
function onWheel(event) {
if (!state.enabled) return;
event.preventDefault();
const zoom = event.deltaY > 0 ? 1.12 : 0.88;
state.distance = Cesium.Math.clamp(state.distance * zoom, 12.0, 160.0);
state.height = Cesium.Math.clamp(state.height * zoom, 6.0, 90.0);
update(viewer.clock);
}
return {
get enabled() {
return state.enabled;
},
start() {
if (state.enabled) return;
state.enabled = true;
viewer.trackedEntity = undefined;
viewer.canvas.addEventListener("wheel", onWheel, { passive: false });
viewer.clock.onTick.addEventListener(update);
update(viewer.clock);
},
stop() {
if (!state.enabled) return;
state.enabled = false;
viewer.clock.onTick.removeEventListener(update);
viewer.canvas.removeEventListener("wheel", onWheel);
viewer.camera.lookAtTransform(Cesium.Matrix4.IDENTITY);
}
};
}
function createCameraPresets(viewer, metadata, placement, cruise) {
const radius = Math.max(220.0, boundsRadiusMeters(metadata.bounds) || 900.0);
const center = placement.position;
function lookAt(range, headingDegrees, pitchDegrees) {
viewer.camera.lookAt(
center,
new Cesium.HeadingPitchRange(
Cesium.Math.toRadians(headingDegrees),
Cesium.Math.toRadians(pitchDegrees),
range
)
);
// lookAt locks the camera into the target's reference frame; release it
// so orbit and pan keep working from the new vantage point.
viewer.camera.lookAtTransform(Cesium.Matrix4.IDENTITY);
}
return {
overview() {
viewer.camera.flyToBoundingSphere(new Cesium.BoundingSphere(center, radius), {
duration: 0.0
});
},
oblique() {
lookAt(radius * 0.92, 135.0, -28.0);
},
detail() {
lookAt(radius * 0.32, 115.0, -18.0);
},
route() {
if (!cruise.vehicles.length) return;
const vehicle = selectedVehicle(cruise);
const coord = vehicle.segment.coordinates[Math.floor(vehicle.segment.coordinates.length / 2)];
viewer.camera.flyTo({
destination: Cesium.Cartesian3.fromDegrees(coord[0], coord[1], 90.0),
orientation: {
heading: Cesium.Math.toRadians(0.0),
pitch: Cesium.Math.toRadians(-62.0),
roll: 0.0
},
duration: 0.0
});
}
};
}
// Camera-dependent readouts have to track the camera, so refresh off the
// render loop rather than a fixed timer, throttled to stay off the hot path.
function startDiagnostics(viewer, metadata, assets, cruise, placement) {
const center = placement.position;
const stats = metadata.scene_stats || {};
const failed = assets.filter((asset) => asset.error);
let lastUpdate = 0;
function render() {
const cartographic = viewer.camera.positionCartographic;
const distance = Cesium.Cartesian3.distance(viewer.camera.positionWC, center);
const lines = [
"Area: " + (config.areaId || "(unknown)"),
"Anchor: " + placement.longitude.toFixed(7) + ", " + placement.latitude.toFixed(7),
"Camera height: " + Math.round(cartographic.height) + " m",
"Camera range: " + Math.round(distance) + " m",
"Assets: " + liveAssets(assets).length + " model(s)",
"Vehicles: " + cruise.vehicles.length,
"Buildings: " + Number(stats.buildings || 0),
"Trees: " + Number(stats.trees || 0),
"Road layer source: " + (metadata.source_geojson ? "osm2streets" : "OSM fallback")
];
if (failed.length) {
lines.push("Failed assets: " + failed.map((asset) => asset.url).join(", "));
}
diagnosticsEl.textContent = lines.join("\n");
}
viewer.scene.postRender.addEventListener(() => {
const now = performance.now();
if (now - lastUpdate < 250) return;
lastUpdate = now;
render();
});
render();
}
function summaryText(metadata, assets, cruise) {
const stats = metadata.scene_stats || {};
return [
config.areaId,
liveAssets(assets).map((asset) => asset.url).join(", "),
cruise.vehicles.length ? "vehicles " + cruise.vehicles.length : "no drivable route",
"buildings " + Number(stats.buildings || 0),
"trees " + Number(stats.trees || 0)
].filter(Boolean).join(" | ");
}
function boundsRadiusMeters(bounds) {
if (!bounds) return null;
const minLon = Number(bounds.min_lon);
const minLat = Number(bounds.min_lat);
const maxLon = Number(bounds.max_lon);
const maxLat = Number(bounds.max_lat);
if (![minLon, minLat, maxLon, maxLat].every(Number.isFinite)) return null;
return Math.max(
80.0,
distanceMeters([minLon, minLat], [maxLon, maxLat]) * 0.58
);
}
function distanceMeters(a, b) {
const radius = 6371008.8;
const lat1 = Cesium.Math.toRadians(a[1]);
const lat2 = Cesium.Math.toRadians(b[1]);
const dLat = Cesium.Math.toRadians(b[1] - a[1]);
const dLon = Cesium.Math.toRadians(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 setStatus(message) {
statusEl.textContent = message;
}
main().catch((error) => {
console.error(error);
setStatus("Failed to load Cesium preview: " + error.message);
});
}());