Extract Cesium preview assets

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

View File

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