diff --git a/.trellis/spec/preview/index.md b/.trellis/spec/preview/index.md index af4e3c0..f0c6d25 100644 --- a/.trellis/spec/preview/index.md +++ b/.trellis/spec/preview/index.md @@ -190,6 +190,21 @@ let baseStatus = ""; 这会让用户误判材质和模型质量。若确实需要性能模式,应做成显式开关,而不是默认牺牲 预览清晰度。 +### 车辆事件仅属于预览会话 + +点击车辆的信息卡可在 `normal`、`breakdown`、`accident` 三态间切换。这是验证交互, +不得写入 `package/`、路线 JSON 或 OSM。状态必须附着在 `addCruiseVehicle()` 返回的记录上; +`createTrafficAwarePositions()` 只在状态为 `normal` 时推进已有的 route distance,恢复正常从 +当前停点继续。 + +- `breakdown`:黄色扳手 label,路线保留原色。 +- `accident`:红色警示 label,路线设为红色。 +- `normal`:隐藏 label,恢复原路线颜色。 + +车辆实体以 `properties.vehicleId` 标识;点击拾取必须只处理此属性,不能把静态模型、路线或 +信号灯当作车辆。Cesium InfoBox 在本预览中关闭,信息卡必须使用 HTML/CSS,并把新增 DOM 句柄 +集中在 `cesium-preview.js` 顶部。 + ### 单资产 vs 多资产的开关 ```js diff --git a/assets/preview/vehicle-accident.png b/assets/preview/vehicle-accident.png new file mode 100644 index 0000000..12372d6 Binary files /dev/null and b/assets/preview/vehicle-accident.png differ diff --git a/assets/preview/vehicle-breakdown.png b/assets/preview/vehicle-breakdown.png new file mode 100644 index 0000000..621cd62 Binary files /dev/null and b/assets/preview/vehicle-breakdown.png differ diff --git a/scripts/lib/area-preview.js b/scripts/lib/area-preview.js index 3a910ed..4522338 100644 --- a/scripts/lib/area-preview.js +++ b/scripts/lib/area-preview.js @@ -4,8 +4,13 @@ const fs = require("fs"); const path = require("path"); function writeCesiumPreviewSupportFiles(outDir) { - for (const file of ["cesium-preview.css", "cesium-preview.js"]) { - const source = path.join(__dirname, file); + const files = [ + [path.join(__dirname, "cesium-preview.css"), "cesium-preview.css"], + [path.join(__dirname, "cesium-preview.js"), "cesium-preview.js"], + [path.join(__dirname, "..", "..", "assets", "preview", "vehicle-breakdown.png"), "vehicle-breakdown.png"], + [path.join(__dirname, "..", "..", "assets", "preview", "vehicle-accident.png"), "vehicle-accident.png"], + ]; + for (const [source, file] of files) { if (!fs.existsSync(source)) { throw new Error(`Cesium preview support file '${file}' not found: ${source}`); } @@ -69,6 +74,21 @@ function cesiumPreviewHtml(glbName, metadataName, routeName, vehicleModelName, a
Loading diagnostics...
Loading ${escapeHtml(glbName)}...
+
diff --git a/scripts/lib/cesium-preview.css b/scripts/lib/cesium-preview.css index 957ca2f..9e5b221 100644 --- a/scripts/lib/cesium-preview.css +++ b/scripts/lib/cesium-preview.css @@ -20,7 +20,8 @@ body.scene-ready #cesiumContainer { #controls, #diagnostics, -#status { +#status, +#vehicleInfoCard { position: absolute; z-index: 1; border-radius: 4px; @@ -160,6 +161,96 @@ body.scene-error .loading-bar span { white-space: pre-line; } +#vehicleInfoCard { + left: 0; + top: 0; + width: min(300px, calc(100vw - 24px)); + padding: 10px 12px; + transform: translate(-50%, calc(-100% - 18px)); + transform-origin: bottom center; + pointer-events: auto; +} + +#vehicleInfoCard::after { + position: absolute; + left: 50%; + bottom: -7px; + width: 12px; + height: 12px; + content: ""; + transform: translateX(-50%) rotate(45deg); + background: rgba(20, 24, 28, 0.82); +} + +.vehicle-card-heading, +.vehicle-card-actions { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; +} + +.vehicle-card-heading strong { + font-size: 13px; +} + +#vehicleInfoCard button { + height: 27px; + border: 0; + border-radius: 4px; + padding: 0 8px; + background: #f2f5f7; + color: #111; + cursor: pointer; +} + +#vehicleInfoDetails { + display: grid; + grid-template-columns: 88px 1fr; + gap: 4px 8px; + margin: 10px 0; +} + +#vehicleInfoDetails dt { + color: rgba(255, 255, 255, 0.62); +} + +#vehicleInfoDetails dd { + margin: 0; + overflow-wrap: anywhere; +} + +.vehicle-card-field { + display: grid; + gap: 4px; + color: rgba(255, 255, 255, 0.78); +} + +#vehicleIncidentNote { + box-sizing: border-box; + width: 100%; + resize: vertical; + border: 1px solid rgba(255, 255, 255, 0.30); + border-radius: 4px; + padding: 6px; + background: rgba(255, 255, 255, 0.12); + color: #fff; + font: inherit; +} + +.vehicle-card-actions { + justify-content: flex-start; + margin-top: 10px; +} + +.vehicle-card-actions button[data-vehicle-status="breakdown"] { + background: #f3c84b; +} + +.vehicle-card-actions button[data-vehicle-status="accident"] { + background: #e95950; +} + #controls button, #controls select { height: 28px; @@ -285,4 +376,5 @@ body.scene-error .loading-bar span { bottom: 10px; max-width: none; } + } diff --git a/scripts/lib/cesium-preview.js b/scripts/lib/cesium-preview.js index 153b2da..ded3754 100644 --- a/scripts/lib/cesium-preview.js +++ b/scripts/lib/cesium-preview.js @@ -16,6 +16,12 @@ const toggleFps = document.getElementById("toggleFps"); const toggleDiagnostics = document.getElementById("toggleDiagnostics"); const assetToggles = document.getElementById("assetToggles"); + const vehicleInfoCard = document.getElementById("vehicleInfoCard"); + const vehicleInfoTitle = document.getElementById("vehicleInfoTitle"); + const vehicleInfoDetails = document.getElementById("vehicleInfoDetails"); + const vehicleIncidentNote = document.getElementById("vehicleIncidentNote"); + const closeVehicleInfo = document.getElementById("closeVehicleInfo"); + const vehicleStatusButtons = Array.from(document.querySelectorAll("[data-vehicle-status]")); // The exported road surface sits at the 0.35m scene anchor plus 0.03m. // Vehicle models have their wheels at local Y=0, so keep them just clear // of the asphalt instead of using the old visibly floating 1.15m height. @@ -56,6 +62,7 @@ buildAssetToggles(assets); buildSemanticToggles(viewer, assets, placement); bindRuntimeControls(viewer, assets, cruise, cameras, placement, trafficSignals); + bindVehicleInfoCard(viewer, cruise); startDiagnostics(viewer, metadata, assets, cruise, placement, trafficSignals); cameras.overview(); baseStatus = summaryText(metadata, assets, cruise); @@ -726,7 +733,8 @@ function addCruiseVehicle(viewer, segment, index, start, speed, signalData, vehicleModelName) { const route = prepareRoute(segment, signalData); - const trafficMotion = createTrafficAwarePositions(viewer, route, start, speed); + let record = null; + const trafficMotion = createTrafficAwarePositions(viewer, route, start, speed, () => record?.status === "normal"); const positions = trafficMotion.positions; const flat = []; for (const coord of segment.coordinates) { @@ -761,14 +769,41 @@ minimumPixelSize: 0 } }); - return { + record = { + id: "vehicle-" + (index + 1), entity: vehicle, routeEntity, positions, route, segment, label: routeLabel(segment, index), + modelName: vehicleModelLabel(vehicleModelName), + status: "normal", + incidentNote: "", + motion: trafficMotion, + routeColor, + markerEntity: null, }; + vehicle.properties = new Cesium.PropertyBag({ vehicleId: record.id }); + record.markerEntity = viewer.entities.add({ + name: "Vehicle incident marker " + (index + 1), + position: positions, + billboard: { + image: "", + show: false, + width: 36, + height: 44, + pixelOffset: new Cesium.Cartesian2(0, -40), + verticalOrigin: Cesium.VerticalOrigin.BOTTOM, + disableDepthTestDistance: Number.POSITIVE_INFINITY, + }, + }); + return record; + } + + function vehicleModelLabel(name) { + const file = String(name || "").split("/").pop().replace(/\.gltf$/i, ""); + return file.replace(/^.*-vehicle-/, "").replaceAll("_", " "); } function routeLabel(route, index) { @@ -789,6 +824,96 @@ return cruise.vehicles[cruise.state.selectedIndex] || cruise.vehicles[0]; } + function vehicleById(cruise, id) { + return cruise.vehicles.find((vehicle) => vehicle.id === id) || null; + } + + function setVehicleStatus(viewer, vehicle, status) { + if (!vehicle || !["normal", "breakdown", "accident"].includes(status)) return; + vehicle.status = status; + const incident = status !== "normal"; + const accident = status === "accident"; + vehicle.markerEntity.billboard.image = status === "breakdown" + ? "vehicle-breakdown.png" + : accident ? "vehicle-accident.png" : ""; + vehicle.markerEntity.billboard.show = incident; + vehicle.routeEntity.polyline.material = accident ? Cesium.Color.RED : vehicle.routeColor; + if (viewer.scene.requestRender) viewer.scene.requestRender(); + } + + function bindVehicleInfoCard(viewer, cruise) { + if (!vehicleInfoCard || !cruise.vehicles.length) return; + let activeVehicle = null; + const render = () => { + if (!activeVehicle) return; + vehicleInfoTitle.textContent = activeVehicle.label; + vehicleIncidentNote.value = activeVehicle.incidentNote; + const details = [ + ["Vehicle", activeVehicle.id], + ["Model", activeVehicle.modelName], + ["Route", activeVehicle.label], + ["Speed", cruise.baseSpeed + " m/s"], + ["Status", activeVehicle.status], + ]; + vehicleInfoDetails.replaceChildren(...details.flatMap(([term, value]) => { + const dt = document.createElement("dt"); + const dd = document.createElement("dd"); + dt.textContent = term; + dd.textContent = value; + return [dt, dd]; + })); + for (const button of vehicleStatusButtons) { + button.disabled = button.dataset.vehicleStatus === activeVehicle.status; + } + }; + const open = (vehicle) => { + activeVehicle = vehicle; + const index = cruise.vehicles.indexOf(vehicle); + cruise.state.selectedIndex = index; + vehicleSelect.value = String(index); + syncSelectedRouteVisibility(cruise); + vehicleInfoCard.classList.remove("hidden"); + render(); + }; + const positionCard = () => { + if (!activeVehicle || vehicleInfoCard.classList.contains("hidden")) return; + const position = activeVehicle.entity.position?.getValue(viewer.clock.currentTime); + const windowPosition = position && Cesium.SceneTransforms.worldToWindowCoordinates(viewer.scene, position); + if (!windowPosition) { + vehicleInfoCard.classList.add("hidden"); + return; + } + const margin = 12; + const width = vehicleInfoCard.offsetWidth || 300; + const x = Cesium.Math.clamp(windowPosition.x, margin + width / 2, window.innerWidth - margin - width / 2); + vehicleInfoCard.style.left = x + "px"; + vehicleInfoCard.style.top = Math.max(windowPosition.y, margin + 20) + "px"; + }; + closeVehicleInfo.addEventListener("click", () => { + activeVehicle = null; + vehicleInfoCard.classList.add("hidden"); + }); + vehicleIncidentNote.addEventListener("input", () => { + if (activeVehicle) activeVehicle.incidentNote = vehicleIncidentNote.value.trim(); + }); + for (const button of vehicleStatusButtons) { + button.addEventListener("click", () => { + if (!activeVehicle) return; + setVehicleStatus(viewer, activeVehicle, button.dataset.vehicleStatus); + render(); + setStatus(activeVehicle.label + " " + activeVehicle.status); + }); + } + const handler = new Cesium.ScreenSpaceEventHandler(viewer.scene.canvas); + handler.setInputAction((movement) => { + const picked = viewer.scene.pick(movement.position); + const id = picked?.id?.properties?.vehicleId?.getValue?.(); + const vehicle = vehicleById(cruise, id); + if (vehicle) open(vehicle); + }, Cesium.ScreenSpaceEventType.LEFT_CLICK); + viewer.scene.postRender.addEventListener(positionCard); + } + function prepareRoute(segment, signalData) { const distances = [0.0]; for (let i = 1; i < segment.coordinates.length; i += 1) { @@ -820,12 +945,13 @@ return [...found.values()].sort((a, b) => a.distance - b.distance); } - function createTrafficAwarePositions(viewer, route, start, speed) { + function createTrafficAwarePositions(viewer, route, start, speed, isMoving = () => true) { const state = { distance: 0, lastTime: start.clone() }; viewer.clock.onTick.addEventListener((clock) => { const elapsed = Cesium.JulianDate.secondsDifference(clock.currentTime, state.lastTime); Cesium.JulianDate.clone(clock.currentTime, state.lastTime); if (elapsed <= 0) return; + if (!isMoving()) return; const next = nextRouteStop(route, state.distance); const advance = elapsed * speed; if (next && signalPhase(next.signal.phaseGroup, clock.currentTime, start).active !== "green") { diff --git a/scripts/test-preview-assets.js b/scripts/test-preview-assets.js index 3a656e9..24b1156 100644 --- a/scripts/test-preview-assets.js +++ b/scripts/test-preview-assets.js @@ -251,6 +251,10 @@ assert.match(html, /"glbName":"scene\\u003c\\u0026\\u003e\.glb"/); assert.match(html, /"vehicleModelNames":\["car-a\.gltf","truck-a\.gltf"\]/); assert.match(html, /"trafficSignalsName":"traffic-signals\.json"/); assert.match(html, /id="toggleSignals"/); +assert.match(html, /id="vehicleInfoCard" class="hidden"/); +assert.match(html, /id="vehicleIncidentNote"/); +assert.match(html, /data-vehicle-status="breakdown"/); +assert.match(html, /data-vehicle-status="accident"/); assert.match(html, /id="toggleBuildingGhost"/); assert.match(html, /id="viewMode"/); assert.match(html, /data-view-mode="inspect"/); @@ -264,6 +268,14 @@ assert.ok(fs.existsSync(countdownFont), "7LED countdown font must be versioned w assert.doesNotMatch(previewRuntime, /cylinder: \{ length: 6\.7/); assert.doesNotMatch(previewRuntime, /Traffic Signal Housing/); assert.match(previewRuntime, /asset\.category === "dynamic"/); +assert.match(previewRuntime, /new Cesium\.ScreenSpaceEventHandler/); +assert.match(previewRuntime, /vehicleId: record\.id/); +assert.match(previewRuntime, /status === "breakdown"\s+\? "vehicle-breakdown\.png"/); +assert.match(previewRuntime, /accident \? "vehicle-accident\.png"/); +assert.match(previewRuntime, /disableDepthTestDistance: Number\.POSITIVE_INFINITY/); +assert.match(previewRuntime, /routeEntity\.polyline\.material = accident \? Cesium\.Color\.RED : vehicle\.routeColor/); +assert.match(previewRuntime, /\(\) => record\?\.status === "normal"/); +assert.match(previewRuntime, /Cesium\.SceneTransforms\.worldToWindowCoordinates/); assert.match(previewRuntime, /TrafficSignalDynamic_/); assert.match(previewRuntime, /TrafficSignalDynamic_\$\{nodeKey\}_countdown_\$\{String\(value\)\.padStart\(2, "0"\)\}/); assert.match(previewRuntime, /ColorBlendMode\.REPLACE/);