feat: add Cesium semantic inspection mode
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user