feat: add Cesium semantic inspection mode

This commit is contained in:
2026-08-05 13:24:39 +08:00
parent 400525dd07
commit 607d8fc0b1
18 changed files with 542 additions and 56 deletions

View File

@@ -324,6 +324,7 @@ function exportCesium(area) {
"--metadata",
area.outputs.metadata,
], "cesium");
const semanticAssets = semanticAssetRecords(area);
writeCesiumPreview(area);
const finished = Date.now();
const digest = glbDigest(area.outputs.glb);
@@ -340,6 +341,7 @@ function exportCesium(area) {
outputs: {
glb: fileRecord(area.outputs.glb),
metadata: fileRecord(area.outputs.metadata),
semanticAssets,
},
summary: {
glb: glbSummary(digest),
@@ -349,6 +351,19 @@ function exportCesium(area) {
});
}
function semanticAssetRecords(area) {
const metadata = JSON.parse(fs.readFileSync(area.outputs.metadata, "utf8"));
const assets = Array.isArray(metadata.assets) ? metadata.assets : [];
const records = {};
for (const asset of assets) {
if (asset.category !== "semantic") continue;
const file = path.join(area.outputs.areaDir, asset.url);
ensureFile(file, `Cesium semantic asset '${asset.id}'`);
records[asset.id] = fileRecord(file);
}
return records;
}
function compressCesiumGlb(area) {
ensureFile(area.outputs.glb, "Cesium GLB");
ensureFile(area.outputs.metadata, "Cesium metadata");

View File

@@ -144,13 +144,31 @@ function writeCompressedMetadata(sourceMetadata, outputGlb, outputMetadata) {
const metadata = JSON.parse(fs.readFileSync(sourceMetadata, "utf8"));
const glbName = path.basename(outputGlb);
metadata.asset = glbName;
metadata.assets = [{
id: "main",
label: "Compressed Scene",
type: "model",
url: glbName,
enabled: true,
}];
const assets = Array.isArray(metadata.assets) ? metadata.assets : [];
const main = assets.find((asset) => asset.id === "main");
metadata.assets = assets.length
? assets.map((asset) => asset.id === "main" ? {
...asset,
label: "Compressed Scene",
url: glbName,
enabled: true,
} : asset)
: [{
id: "main",
label: "Compressed Scene",
type: "model",
url: glbName,
enabled: true,
}];
if (!main && assets.length) {
metadata.assets.unshift({
id: "main",
label: "Compressed Scene",
type: "model",
url: glbName,
enabled: true,
});
}
if (typeof metadata.cesium_js === "string") {
metadata.cesium_js = metadata.cesium_js.replace(
/url: '[^']+\.glb'/,
@@ -270,9 +288,13 @@ function main() {
}
}
try {
main();
} catch (error) {
console.error(error.message);
process.exit(1);
if (require.main === module) {
try {
main();
} catch (error) {
console.error(error.message);
process.exit(1);
}
}
module.exports = { writeCompressedMetadata };

View File

@@ -42,8 +42,14 @@ function cesiumPreviewHtml(glbName, metadataName, routeName, vehicleModelName, a
<span id="speedLabel">8 m/s</span>
</div>
<div class="control-group">
<span class="control-label">View</span>
<span id="viewMode" class="segmented-control" role="group" aria-label="Scene view mode">
<button type="button" data-view-mode="scene" aria-pressed="true">Scene</button>
<button type="button" data-view-mode="inspect" aria-pressed="false">Inspect</button>
</span>
<label><input id="toggleScene" type="checkbox" checked> Scene</label>
<span id="assetToggles" class="control-subgroup"></span>
<span id="semanticToggles" class="control-subgroup hidden"></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>

View File

@@ -198,6 +198,35 @@ body.scene-error .loading-bar span {
white-space: nowrap;
}
.control-label {
color: rgba(255, 255, 255, 0.68);
font-size: 11px;
text-transform: uppercase;
}
.segmented-control {
display: inline-flex;
overflow: hidden;
border: 1px solid rgba(255, 255, 255, 0.24);
border-radius: 4px;
}
.segmented-control button {
min-width: 58px;
border-radius: 0 !important;
background: transparent !important;
color: rgba(255, 255, 255, 0.78) !important;
}
.segmented-control button + button {
border-left: 1px solid rgba(255, 255, 255, 0.20) !important;
}
.segmented-control button[aria-pressed="true"] {
background: #e7f2f2 !important;
color: #143337 !important;
}
/* Playback / layers / camera each read as their own row of the panel. */
.control-group {
display: flex;
@@ -228,3 +257,32 @@ body.scene-error .loading-bar span {
.hidden {
display: none;
}
/* This must outrank `#controls label { display: inline-flex }` above. */
#controls .hidden {
display: none;
}
@media (max-width: 700px) {
#controls {
right: 10px;
left: 10px;
top: 10px;
}
#diagnostics {
top: auto;
right: 10px;
bottom: 56px;
left: 10px;
min-width: 0;
max-width: none;
}
#status {
right: 10px;
left: 10px;
bottom: 10px;
max-width: none;
}
}

View File

@@ -13,10 +13,12 @@
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
@@ -39,7 +41,8 @@
const cameras = createCameraPresets(viewer, metadata, placement, cruise);
buildAssetToggles(assets);
bindRuntimeControls(viewer, assets, cruise, cameras);
buildSemanticToggles(viewer, assets, placement);
bindRuntimeControls(viewer, assets, cruise, cameras, placement);
startDiagnostics(viewer, metadata, assets, cruise, placement);
cameras.overview();
baseStatus = summaryText(metadata, assets, cruise);
@@ -167,28 +170,47 @@
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 });
}
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)) {
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);
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
@@ -221,8 +243,40 @@
toggleScene.indeterminate = shown > 0 && shown < live.length;
}
function bindRuntimeControls(viewer, assets, cruise, cameras) {
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)) {
@@ -245,6 +299,58 @@
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);

View File

@@ -0,0 +1,32 @@
#!/usr/bin/env node
"use strict";
const assert = require("assert");
const fs = require("fs");
const os = require("os");
const path = require("path");
const { writeCompressedMetadata } = require("./compress-glb");
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "compress-glb-"));
const source = path.join(tempDir, "scene.json");
const output = path.join(tempDir, "scene-compressed.json");
const compressedGlb = path.join(tempDir, "scene-compressed.glb");
fs.writeFileSync(source, `${JSON.stringify({
asset: "scene.glb",
assets: [
{ id: "main", label: "Scene", type: "model", url: "scene.glb", enabled: true },
{ id: "roads", label: "道路", type: "model", url: "scene-roads.glb", enabled: false, category: "semantic" },
],
}, null, 2)}\n`);
writeCompressedMetadata(source, compressedGlb, output);
const metadata = JSON.parse(fs.readFileSync(output, "utf8"));
assert.equal(metadata.asset, "scene-compressed.glb");
assert.deepEqual(metadata.assets.map((asset) => asset.id), ["main", "roads"]);
assert.equal(metadata.assets[0].url, "scene-compressed.glb");
assert.equal(metadata.assets[1].url, "scene-roads.glb");
assert.equal(metadata.assets[1].category, "semantic");
fs.rmSync(tempDir, { recursive: true, force: true });
console.log("GLB compression metadata tests passed.");

View File

@@ -80,6 +80,9 @@ assert.match(html, /<title>north&lt;&amp;&gt;\u2028valley Cesium Preview<\/title
assert.match(html, /Loading scene&lt;&amp;&gt;\.glb/);
assert.match(html, /"areaId":"north\\u003c\\u0026\\u003e\\u2028valley"/);
assert.match(html, /"glbName":"scene\\u003c\\u0026\\u003e\.glb"/);
assert.match(html, /id="viewMode"/);
assert.match(html, /data-view-mode="inspect"/);
assert.match(html, /id="semanticToggles" class="control-subgroup hidden"/);
fs.rmSync(tempDir, { recursive: true, force: true });
console.log("Preview asset tests passed.");