diff --git a/blender/export_cesium.py b/blender/export_cesium.py
index 0c68596..485e013 100644
--- a/blender/export_cesium.py
+++ b/blender/export_cesium.py
@@ -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},
diff --git a/scripts/build-area.js b/scripts/build-area.js
index 5237cf3..2834a1f 100755
--- a/scripts/build-area.js
+++ b/scripts/build-area.js
@@ -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 `
@@ -694,383 +710,37 @@ function cesiumPreviewHtml(glbName, metadataName, routeName, vehicleModelName, a
${escapeHtml(areaId)} Cesium Preview
-
+
+ Loading diagnostics...
Loading ${escapeHtml(glbName)}...
-
+
+
`;
@@ -1084,6 +754,11 @@ function escapeHtml(value) {
.replaceAll('"', """);
}
-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");
}
diff --git a/scripts/lib/cesium-preview.css b/scripts/lib/cesium-preview.css
new file mode 100644
index 0000000..e963a59
--- /dev/null
+++ b/scripts/lib/cesium-preview.css
@@ -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;
+}
diff --git a/scripts/lib/cesium-preview.js b/scripts/lib/cesium-preview.js
new file mode 100644
index 0000000..7b2e380
--- /dev/null
+++ b/scripts/lib/cesium-preview.js
@@ -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);
+ });
+}());