From 0def373c091cacbc69464209c1b23e2fbdaeeda6 Mon Sep 17 00:00:00 2001 From: que01 Date: Fri, 31 Jul 2026 16:56:22 +0800 Subject: [PATCH] Extract Cesium preview assets --- blender/export_cesium.py | 7 + scripts/build-area.js | 421 +++------------------- scripts/lib/cesium-preview.css | 117 ++++++ scripts/lib/cesium-preview.js | 630 +++++++++++++++++++++++++++++++++ 4 files changed, 802 insertions(+), 373 deletions(-) create mode 100644 scripts/lib/cesium-preview.css create mode 100644 scripts/lib/cesium-preview.js 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 - +
- - - - - 8 m/s +
+ + + + + 8 m/s +
+
+ + + + + + +
+
+ + + + +
+
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); + }); +}());