806 lines
30 KiB
JavaScript
806 lines
30 KiB
JavaScript
(function () {
|
|
"use strict";
|
|
|
|
const config = window.OSM_ASSET_PREVIEW_CONFIG || {};
|
|
const statusEl = document.getElementById("status");
|
|
const diagnosticsEl = document.getElementById("diagnostics");
|
|
const loadingOverlay = document.getElementById("loadingOverlay");
|
|
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 semanticToggles = document.getElementById("semanticToggles");
|
|
const vehicleSelect = document.getElementById("vehicleSelect");
|
|
const speedControl = document.getElementById("speedControl");
|
|
const speedLabel = document.getElementById("speedLabel");
|
|
const cameraButtons = Array.from(document.querySelectorAll("[data-camera]"));
|
|
const viewModeButtons = Array.from(document.querySelectorAll("[data-view-mode]"));
|
|
|
|
// 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 = "";
|
|
const PREVIEW_BACKGROUND = "#d9e0e2";
|
|
|
|
Cesium.Ion.defaultAccessToken = "";
|
|
|
|
async function main() {
|
|
setLoadingMessage("Loading scene", config.areaId || "");
|
|
const metadata = await fetchJson(config.metadataName);
|
|
const routeData = await fetchOptionalJson(config.routeName);
|
|
const placement = scenePlacement(metadata);
|
|
const viewer = createViewer();
|
|
setLoadingMessage("Loading model", config.glbName || "");
|
|
const assets = await loadSceneAssets(viewer, metadata, placement);
|
|
const cruise = addVehicleCruises(viewer, routeData, config.vehicleModelName);
|
|
const cameras = createCameraPresets(viewer, metadata, placement, cruise);
|
|
|
|
buildAssetToggles(assets);
|
|
buildSemanticToggles(viewer, assets, placement);
|
|
bindRuntimeControls(viewer, assets, cruise, cameras, placement);
|
|
startDiagnostics(viewer, metadata, assets, cruise, placement);
|
|
cameras.overview();
|
|
baseStatus = summaryText(metadata, assets, cruise);
|
|
setStatus(baseStatus);
|
|
setLoadingMessage("Preparing view", "finalizing materials");
|
|
await waitForStableFrames(viewer);
|
|
document.body.classList.add("scene-ready");
|
|
// 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.show = false;
|
|
viewer.scene.globe.depthTestAgainstTerrain = false;
|
|
viewer.scene.backgroundColor = Cesium.Color.fromCssColorString(PREVIEW_BACKGROUND);
|
|
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(PREVIEW_BACKGROUND);
|
|
return viewer;
|
|
}
|
|
|
|
function setLoadingMessage(title, detail) {
|
|
if (!loadingOverlay) return;
|
|
const titleEl = loadingOverlay.querySelector("strong");
|
|
const detailEl = loadingOverlay.querySelector("span");
|
|
if (titleEl) titleEl.textContent = title;
|
|
if (detailEl) detailEl.textContent = detail || "";
|
|
}
|
|
|
|
function waitForStableFrames(viewer) {
|
|
return new Promise((resolve) => {
|
|
const started = performance.now();
|
|
let frames = 0;
|
|
let done = false;
|
|
let remove = function () {};
|
|
function finish() {
|
|
if (done) return;
|
|
done = true;
|
|
remove();
|
|
resolve();
|
|
}
|
|
remove = viewer.scene.postRender.addEventListener(() => {
|
|
frames += 1;
|
|
const elapsed = performance.now() - started;
|
|
if ((frames >= 14 && elapsed >= 650) || elapsed >= 2800) {
|
|
finish();
|
|
}
|
|
});
|
|
window.setTimeout(finish, 3200);
|
|
viewer.scene.requestRender();
|
|
});
|
|
}
|
|
|
|
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;
|
|
const entry = { ...asset, id, label, model: null, error: null };
|
|
loaded.push(entry);
|
|
// Semantic assets are inspection aids. Defer their network and GPU cost
|
|
// until the user explicitly switches out of the normal scene view.
|
|
if (entry.category === "semantic") continue;
|
|
await loadAsset(viewer, entry, placement);
|
|
}
|
|
if (!loaded.some((asset) => asset.model && asset.category !== "semantic")) {
|
|
throw new Error("No scene model could be loaded (" + loaded.length + " declared)");
|
|
}
|
|
return loaded;
|
|
}
|
|
|
|
async function loadAsset(viewer, asset, placement) {
|
|
if (asset.model || asset.error) return asset.model;
|
|
if (asset.loading) return asset.loading;
|
|
asset.loading = Cesium.Model.fromGltfAsync({
|
|
url: asset.url,
|
|
modelMatrix: placement.modelMatrix,
|
|
scale: Number(asset.scale || 1.0)
|
|
}).then((model) => {
|
|
model.show = asset.enabled !== false;
|
|
viewer.scene.primitives.add(model);
|
|
asset.model = model;
|
|
return model;
|
|
}).catch((error) => {
|
|
console.error(error);
|
|
asset.error = error;
|
|
return null;
|
|
}).finally(() => {
|
|
asset.loading = null;
|
|
});
|
|
return asset.loading;
|
|
}
|
|
|
|
function liveAssets(assets) {
|
|
return assets.filter((asset) => asset.model && asset.category !== "semantic");
|
|
}
|
|
|
|
function semanticAssets(assets) {
|
|
return assets.filter((asset) => asset.category === "semantic");
|
|
}
|
|
|
|
// 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 buildSemanticToggles(viewer, assets, placement) {
|
|
for (const asset of semanticAssets(assets)) {
|
|
const label = document.createElement("label");
|
|
const input = document.createElement("input");
|
|
input.type = "checkbox";
|
|
input.checked = false;
|
|
input.dataset.assetId = asset.id;
|
|
input.addEventListener("change", async () => {
|
|
if (input.checked) {
|
|
const model = await loadAsset(viewer, asset, placement);
|
|
if (!model) {
|
|
input.checked = false;
|
|
input.disabled = true;
|
|
setStatus(asset.label + " unavailable");
|
|
return;
|
|
}
|
|
model.show = true;
|
|
} else if (asset.model) {
|
|
asset.model.show = false;
|
|
}
|
|
setStatus(asset.label + (input.checked ? " visible" : " hidden"));
|
|
});
|
|
label.appendChild(input);
|
|
label.appendChild(document.createTextNode(" " + asset.label));
|
|
semanticToggles.appendChild(label);
|
|
asset.toggle = input;
|
|
}
|
|
}
|
|
|
|
function bindRuntimeControls(viewer, assets, cruise, cameras, placement) {
|
|
const hasVehicles = cruise.vehicles.length > 0;
|
|
const hasSemanticAssets = semanticAssets(assets).length > 0;
|
|
const sceneLabel = toggleScene.closest("label");
|
|
let viewMode = "scene";
|
|
|
|
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);
|
|
});
|
|
|
|
async function setViewMode(nextMode) {
|
|
if (nextMode === viewMode) return;
|
|
viewMode = nextMode;
|
|
const inspecting = viewMode === "inspect";
|
|
for (const button of viewModeButtons) {
|
|
button.setAttribute("aria-pressed", String(button.dataset.viewMode === viewMode));
|
|
}
|
|
semanticToggles.classList.toggle("hidden", !inspecting);
|
|
assetToggles.classList.toggle("hidden", inspecting);
|
|
if (sceneLabel) sceneLabel.classList.toggle("hidden", inspecting);
|
|
|
|
if (!inspecting) {
|
|
for (const asset of semanticAssets(assets)) {
|
|
if (asset.model) asset.model.show = false;
|
|
}
|
|
for (const asset of liveAssets(assets)) asset.model.show = toggleScene.checked;
|
|
setStatus("Scene view");
|
|
return;
|
|
}
|
|
|
|
for (const asset of liveAssets(assets)) asset.model.show = false;
|
|
await Promise.all(semanticAssets(assets).map(async (asset) => {
|
|
const model = await loadAsset(viewer, asset, placement);
|
|
if (!model) {
|
|
if (asset.toggle) {
|
|
asset.toggle.checked = false;
|
|
asset.toggle.disabled = true;
|
|
}
|
|
return;
|
|
}
|
|
model.show = true;
|
|
if (asset.toggle) asset.toggle.checked = true;
|
|
}));
|
|
if (viewMode !== "inspect") {
|
|
for (const asset of semanticAssets(assets)) {
|
|
if (asset.model) asset.model.show = false;
|
|
}
|
|
return;
|
|
}
|
|
setStatus("Inspection view");
|
|
}
|
|
|
|
for (const button of viewModeButtons) {
|
|
if (button.dataset.viewMode === "inspect" && !hasSemanticAssets) {
|
|
button.disabled = true;
|
|
continue;
|
|
}
|
|
button.addEventListener("click", () => {
|
|
void setViewMode(button.dataset.viewMode);
|
|
});
|
|
}
|
|
|
|
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.routes || 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 = routeLabel(segment, index);
|
|
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,
|
|
arcType: Cesium.ArcType.NONE,
|
|
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: routeLabel(segment, index),
|
|
};
|
|
}
|
|
|
|
function routeLabel(route, index) {
|
|
const counts = { left: 0, right: 0, through: 0 };
|
|
for (const maneuver of route.maneuvers || []) {
|
|
if (maneuver in counts) counts[maneuver] += 1;
|
|
}
|
|
const actions = [
|
|
counts.left ? "左 " + counts.left : "",
|
|
counts.right ? "右 " + counts.right : "",
|
|
counts.through ? "直 " + counts.through : "",
|
|
].filter(Boolean).join(" / ");
|
|
return "#" + (index + 1) + " · " + Math.round(route.lengthMeters || 0) + " m" +
|
|
(actions ? " · " + actions : "");
|
|
}
|
|
|
|
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();
|
|
const aheadTime = new Cesium.JulianDate();
|
|
const hpr = new Cesium.HeadingPitchRoll(0.0, 0.0, 0.0);
|
|
const base = new Cesium.Quaternion();
|
|
return new Cesium.CallbackProperty((time, result) => {
|
|
routePosition(route, start, time, speed, current);
|
|
Cesium.JulianDate.addSeconds(time, 0.8, aheadTime);
|
|
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);
|
|
hpr.heading = Math.atan2(eastComponent, northComponent);
|
|
Cesium.Transforms.headingPitchRollQuaternion(
|
|
current,
|
|
hpr,
|
|
undefined,
|
|
undefined,
|
|
base
|
|
);
|
|
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 scratchPreviousTime = new Cesium.JulianDate();
|
|
const scratchHpr = new Cesium.HeadingPitchRoll(0.0, 0.0, 0.0);
|
|
const scratchTransform = new Cesium.Matrix4();
|
|
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;
|
|
Cesium.JulianDate.addSeconds(time, -0.8, scratchPreviousTime);
|
|
const previous = positions.getValue(scratchPreviousTime, 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);
|
|
scratchHpr.heading = Math.atan2(eastComponent, northComponent);
|
|
Cesium.Cartesian3.fromElements(0.0, -state.distance, state.height, offset);
|
|
Cesium.Transforms.headingPitchRollToFixedFrame(
|
|
position,
|
|
scratchHpr,
|
|
undefined,
|
|
undefined,
|
|
scratchTransform
|
|
);
|
|
viewer.camera.lookAtTransform(scratchTransform, 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);
|
|
document.body.classList.add("scene-error");
|
|
setLoadingMessage("Failed to load scene", error.message);
|
|
});
|
|
}());
|