Extract Cesium preview assets
This commit is contained in:
630
scripts/lib/cesium-preview.js
Normal file
630
scripts/lib/cesium-preview.js
Normal 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);
|
||||
});
|
||||
}());
|
||||
Reference in New Issue
Block a user