diff --git a/.trellis/spec/preview/index.md b/.trellis/spec/preview/index.md index 3eeafc9..76d117f 100644 --- a/.trellis/spec/preview/index.md +++ b/.trellis/spec/preview/index.md @@ -351,7 +351,13 @@ OBU 消息使用 `carCode|obuCode`、`lon`、`lat`、`angle`、`speed`;目标 一相位可点亮多条进口道,一条 link 可点亮同进口道多个灯头,两侧都取并集。 未绑定相位须计数上报,可用 `v2xPreview.phaseSignalMap` 显式覆盖。 - **车辆生命周期**:超过 `interval * 1.5` 未更新即隐藏(不删除),隐藏项作为同模型的可复用槽位; - socket 断开清空车辆。模型名沿用看板语义:OBU 为 `car_obu.glb`,目标车为 `${type}${subType}.glb`。 + socket 断开清空车辆。OBU 数据 hook 标记为 `car_obu.glb`,但 dashboard 的最终 + `CrossCars` 模板实际渲染 `11.glb`,预览必须以 `11.glb` 为准;目标车为 + `${type}${subType}.glb`。`11/12/13/14/15/16/17/31/221/222.glb` 必须打入 + `_preview/v2x-vehicles/`。 +- **可见性是运行时契约**:有效推送创建的模型必须在路口概览下至少为 `28px`,并允许最大 + `8x` 放大;位置高度须高于静态道路表面。首次有效车辆推送应聚焦到车群附近,标签须不受 + 静态 GLB 的深度遮挡。仅验证 WS 收包或 registry 条数不算通过。 - 车辆推送体解析必须容忍非严格 JSON(源看板用 `saferEval`),失败须计数并在面板可见, **不得**静默丢弃;实现不得使用 `eval` / `new Function`。 - 周流量比 `FlowTravelRatio/queryListWeek` **不属于**实时路口范围。 @@ -381,7 +387,7 @@ OBU 消息使用 `carCode|obuCode`、`lon`、`lat`、`angle`、`speed`;目标 同相位多进口道并集、车辆超时与槽位复用、心跳间隔与重连重放订阅帧、bounds 报文。 - `npm run test:preview-assets`:断言三条 WebSocket 存在,runtime 不调用 `addVehicleCruises`、不显示 simulation 诊断,且预览层**不再自带** `lampColorName` - 或 `Number(status) === 3` 判断。 + 或 `Number(status) === 3` 判断;断言实时车辆最小像素尺寸和诊断可见数量读数存在。 - 对目标区域运行 `npm run build:area -- --config config/areas/.json --stages preview`, 检查 descriptor 中 `routeName` 为空、不存在 traffic-simulation 文件, 且 `vehicleModelNames` 已写出(实时车辆渲染需要)。 diff --git a/.trellis/tasks/08-24-v2x-realtime-cross-fidelity/design.md b/.trellis/tasks/08-24-v2x-realtime-cross-fidelity/design.md index d982987..2fbaef6 100644 --- a/.trellis/tasks/08-24-v2x-realtime-cross-fidelity/design.md +++ b/.trellis/tasks/08-24-v2x-realtime-cross-fidelity/design.md @@ -176,9 +176,11 @@ registry.list() -> VehicleRecord[] 渲染适配层据 `visible` 切 `entity.show`,**不删实体**,与槽位复用配套。 -模型选择(R8):`modelNameFor(vehicle)` 返回 `car_obu.glb` 或 `${type}${subType}.glb`; -在包内 `vehicleModelNames` 中按文件名匹配,命中不了则回退 `vehicleModelNames[0]` 并累加 -`missingModels` 计数供面板展示。 +模型选择(R8):OBU 的数据 hook 虽标记 `car_obu.glb`,但最终的 `CrossCars/index.vue` 模板固定 +传入 `11.glb`,因此 `modelNameFor(vehicle)` 对 OBU 返回 `11.glb`;目标车辆返回 +`${type}${subType}.glb`。预览阶段把 dashboard 的 `11/12/13/14/15/16/17/31/221/222.glb` +复制到 `_preview/v2x-vehicles/`,并在 `vehicleModelNames` 中按文件名匹配;未知车型回退 `11.glb` +并累加 `missingModels` 计数供面板展示。 平滑:用 `Cesium.SampledPositionProperty` 按 `duration` 插值,替代当前的直接赋值跳变。 diff --git a/.trellis/tasks/08-24-v2x-realtime-cross-fidelity/implement.md b/.trellis/tasks/08-24-v2x-realtime-cross-fidelity/implement.md index c5b6477..9a3f72d 100644 --- a/.trellis/tasks/08-24-v2x-realtime-cross-fidelity/implement.md +++ b/.trellis/tasks/08-24-v2x-realtime-cross-fidelity/implement.md @@ -74,7 +74,7 @@ 2. `interval` 取自推送体,`0` 或缺失回退 `500`。 3. `sweep`:`|now - timeStamp| >= interval*1.5` → `visible=false`。 4. 槽位复用:优先占用同 `model` 的不可见记录。 -5. `modelNameFor(vehicle)`:OBU → `car_obu.glb`;target → `${type}${subType}.glb`。 +5. `modelNameFor(vehicle)`:OBU → dashboard 最终模板使用的 `11.glb`;target → `${type}${subType}.glb`。把 dashboard 车辆 GLB 复制到预览包。 6. `normalizeTargetVehicles` 补上源项目的 `type==1 && isNil(subType) → subType=1`。 7. 导出 `createVehicleRegistry`、`modelNameFor`。 @@ -83,7 +83,7 @@ - 假时钟推进 `interval*1.5` 后 `sweep()` → 该车 `visible=false`。 - 再注入同 key → 复用原槽位,`list().length` 不变。 - `clear()` 后为空。 -- `modelNameFor({kind:"obu"})==="car_obu.glb"`;`{kind:"target",type:1,subType:2}` → `"12.glb"`。 +- `modelNameFor({kind:"obu"})==="11.glb"`;`{kind:"target",type:1,subType:2}` → `"12.glb"`。 ### 步骤 5 — WebSocket 连接层(R1、R6) diff --git a/.trellis/tasks/08-24-v2x-realtime-cross-fidelity/prd.md b/.trellis/tasks/08-24-v2x-realtime-cross-fidelity/prd.md index a3e1db8..3c7faf5 100644 --- a/.trellis/tasks/08-24-v2x-realtime-cross-fidelity/prd.md +++ b/.trellis/tasks/08-24-v2x-realtime-cross-fidelity/prd.md @@ -57,9 +57,9 @@ - socket 断开时清空车辆列表。 - 车辆位置更新使用推送间隔做平滑插值,避免逐帧跳变。 -### R8 车辆模型命名对齐 -- OBU 固定使用 `car_obu.glb` 语义的模型槽;目标识别车辆使用 `${type}${subType}.glb` 语义的模型槽。 -- 包内缺少对应模型时回退到默认车模型,并在面板提示,不得静默显示错误车型。 +### R8 车辆模型命名与资产对齐 +- OBU 数据层标记为 `car_obu.glb`,但 dashboard 的实际 `CrossCars` 模板固定渲染 `11.glb`;预览必须遵从该最终渲染契约。目标识别车辆使用 `${type}${subType}.glb`。 +- 预览包必须携带 dashboard 实际使用的车辆模型;包内缺少未知车型时回退到 `11.glb`,并在面板提示,不得静默显示错误车型。 ### R9 推送体解析健壮性 - 源项目对两个车辆 socket 使用 `saferEval` 而非 `JSON.parse`,说明载荷不保证严格 JSON。 diff --git a/assets/models/v2x-vehicles/11.glb b/assets/models/v2x-vehicles/11.glb new file mode 100644 index 0000000..f3d6ed0 Binary files /dev/null and b/assets/models/v2x-vehicles/11.glb differ diff --git a/assets/models/v2x-vehicles/12.glb b/assets/models/v2x-vehicles/12.glb new file mode 100644 index 0000000..5bd3352 Binary files /dev/null and b/assets/models/v2x-vehicles/12.glb differ diff --git a/assets/models/v2x-vehicles/13.glb b/assets/models/v2x-vehicles/13.glb new file mode 100644 index 0000000..28eb4b4 Binary files /dev/null and b/assets/models/v2x-vehicles/13.glb differ diff --git a/assets/models/v2x-vehicles/14.glb b/assets/models/v2x-vehicles/14.glb new file mode 100644 index 0000000..df2285f Binary files /dev/null and b/assets/models/v2x-vehicles/14.glb differ diff --git a/assets/models/v2x-vehicles/15.glb b/assets/models/v2x-vehicles/15.glb new file mode 100644 index 0000000..149a8f9 Binary files /dev/null and b/assets/models/v2x-vehicles/15.glb differ diff --git a/assets/models/v2x-vehicles/16.glb b/assets/models/v2x-vehicles/16.glb new file mode 100644 index 0000000..a1e7ef4 Binary files /dev/null and b/assets/models/v2x-vehicles/16.glb differ diff --git a/assets/models/v2x-vehicles/17.glb b/assets/models/v2x-vehicles/17.glb new file mode 100644 index 0000000..b882472 Binary files /dev/null and b/assets/models/v2x-vehicles/17.glb differ diff --git a/assets/models/v2x-vehicles/221.glb b/assets/models/v2x-vehicles/221.glb new file mode 100644 index 0000000..98836a8 Binary files /dev/null and b/assets/models/v2x-vehicles/221.glb differ diff --git a/assets/models/v2x-vehicles/222.glb b/assets/models/v2x-vehicles/222.glb new file mode 100644 index 0000000..b2d44b6 Binary files /dev/null and b/assets/models/v2x-vehicles/222.glb differ diff --git a/assets/models/v2x-vehicles/31.glb b/assets/models/v2x-vehicles/31.glb new file mode 100644 index 0000000..b00333b Binary files /dev/null and b/assets/models/v2x-vehicles/31.glb differ diff --git a/docs/v2x-cesium-preview.md b/docs/v2x-cesium-preview.md index a8abcc5..be8a4c4 100644 --- a/docs/v2x-cesium-preview.md +++ b/docs/v2x-cesium-preview.md @@ -95,10 +95,12 @@ Unbound phases are counted in the panel — override them with `phaseSignalMap`. Vehicles follow the dashboard's rules: the push `interval` (`0` falls back to 500 ms) sets the animation duration, a vehicle not updated within `interval * 1.5` is hidden, and hidden entries stay as reusable slots rather -than being removed. Models are named as the dashboard names them — -`car_obu.glb` for OBU vehicles and `${type}${subType}.glb` for recognized -targets — falling back to the first packaged model, with the shortfall reported -in the panel. +than being removed. The OBU data hook calls its model `car_obu.glb`, but the +dashboard's final `CrossCars` template renders `11.glb`; the preview uses that +final rendering contract. Targets use `${type}${subType}.glb`. The dashboard +vehicle assets are packaged under `_preview/v2x-vehicles/`. Valid live vehicles +are lifted clear of the road, remain at least 28 screen pixels in overview, and +the first live push focuses the camera near the vehicle group. ## Coordinate Contract @@ -119,7 +121,8 @@ without Nginx, use the repository's proxy static server instead of `python3 -m http.server`: ```bash -V2X_UPSTREAM=http://: \ +V2X_UPSTREAM=http://:/dashboardApi \ +V2X_WS_UPSTREAM=http://:/dashboardWebsocket \ npm run serve:v2x-preview -- --root outputs/fengshu-er-road --host 0.0.0.0 --port 7862 ``` diff --git a/scripts/build-area.js b/scripts/build-area.js index 5aa4347..f19eb1b 100755 --- a/scripts/build-area.js +++ b/scripts/build-area.js @@ -16,7 +16,7 @@ const { } = require("./lib/scene-layers"); const { digest: glbDigest } = require("./glb-digest"); const { buildVehicleRoute: buildPreviewVehicleRoute } = require("./lib/vehicle-route"); -const { writePreviewVehicleLibrary } = require("./lib/vehicle-library"); +const { writePreviewVehicleLibrary, writePreviewV2xVehicleLibrary } = require("./lib/vehicle-library"); const { readTrafficSignals } = require("./lib/traffic-signals"); const { cesiumPreviewHtml, @@ -648,10 +648,11 @@ function writeVehicleModel(area) { const outDir = path.dirname(area.outputs.vehicleModel); const fileStem = path.basename(area.outputs.vehicleModel, ".gltf").replace(/-vehicle-car$/, ""); const models = writePreviewVehicleLibrary(outDir, fileStem); + const v2xModels = area.v2xPreview.enabled ? writePreviewV2xVehicleLibrary(outDir) : []; // Preserve the existing output/manifest contract for old preview HTML. fs.copyFileSync(path.join(outDir, models[0]), area.outputs.vehicleModel); - console.log(`Vehicle models: ${models.length} candidates in ${path.dirname(area.outputs.vehicleModel)}`); - return models; + console.log(`Vehicle models: ${models.length + v2xModels.length} candidates in ${path.dirname(area.outputs.vehicleModel)}`); + return [...models, ...v2xModels]; } function sceneGeojsonRecords(area) { diff --git a/scripts/lib/cesium-preview.js b/scripts/lib/cesium-preview.js index 1f8357e..7185d8b 100644 --- a/scripts/lib/cesium-preview.js +++ b/scripts/lib/cesium-preview.js @@ -1437,7 +1437,10 @@ ]; if (v2xOverlay?.state) { lines.push("Vehicle data: " + (v2xOverlay.state.vehicleSource === "live" ? "live V2X" : "waiting for live V2X")); - lines.push("Live vehicles: " + v2xOverlay.state.vehicles.size); + lines.push("Live vehicles: " + (v2xOverlay.state?.registry?.visibleSize ?? 0)); + lines.push("Target WS: " + (v2xOverlay.state?.targetMessages ?? 0) + " messages / " + (v2xOverlay.state?.targetLastCount ?? 0) + " parsed"); + lines.push("Target payload: " + (v2xOverlay.state?.targetPayloadShape || "waiting")); + if (v2xOverlay.state?.targetLastError) lines.push("Target error: " + v2xOverlay.state.targetLastError); } if (failed.length) { lines.push("Failed assets: " + failed.map((asset) => asset.url).join(", ")); diff --git a/scripts/lib/v2x-cesium-overlay.js b/scripts/lib/v2x-cesium-overlay.js index e8ea13d..6d06e8d 100644 --- a/scripts/lib/v2x-cesium-overlay.js +++ b/scripts/lib/v2x-cesium-overlay.js @@ -5,6 +5,8 @@ const EARTH_A = 6378245.0; const EARTH_EE = 0.00669342162296594323; const TOKEN_KEY = "osm-asset-preview-v2x-token"; + const LIVE_VEHICLE_HEIGHT_METERS = 1.2; + const LIVE_VEHICLE_HEADING_OFFSET_DEGREES = 90; function createV2xCesiumOverlay(context) { const settings = context.config.v2xPreview || {}; @@ -22,6 +24,13 @@ vehicleSource: "waiting", parseFailures: 0, missingModels: new Set(), + targetMessages: 0, + targetVehiclesReceived: 0, + targetLastCount: 0, + targetLastError: "", + targetPayloadType: "", + targetPayloadSample: "", + targetPayloadShape: "", }; // This preview intentionally starts at the login gate. Do not revive a // previous tab's token and begin REST/WebSocket traffic before sign-in. @@ -47,6 +56,7 @@ if (state.phaseBinding.unbound.length) parts.push(`${state.phaseBinding.unbound.length} unbound`); if (state.missingModels.size) parts.push(`${state.missingModels.size} models missing`); if (state.parseFailures) parts.push(`${state.parseFailures} unparsed pushes`); + if (state.targetMessages) parts.push(`${state.targetVehiclesReceived} target vehicles`); return parts.join(", "); } @@ -55,7 +65,6 @@ state.entities = []; registry.list().forEach((record) => { if (record.entity) context.viewer.entities.remove(record.entity); - if (record.trace) context.viewer.entities.remove(record.trace); }); registry.clear(); state.linkEntitiesByPhase.clear(); @@ -264,12 +273,29 @@ const targetIds = arrayValue(deviceConfig?.deviceConfig?.target); send(JSON.stringify({ deviceId: targetIds.length ? targetIds.join(",") : null })); }, (value) => { - const push = normalizeTargetPush(value); - if (!push.vehicles.length) { if (!isHeartbeat(value) && parseLoosePayload(value) === null) state.parseFailures += 1; return; } - useLiveVehicles(); - registry.ingest(push.vehicles, push.interval); - registry.sweep(); - renderVehicles(); + if (isHeartbeat(value)) return; + state.targetMessages += 1; + try { + state.targetPayloadType = Object.prototype.toString.call(value); + state.targetPayloadSample = typeof value === "string" ? value.slice(0, 800) : String(value).slice(0, 800); + const payload = parseLoosePayload(value); + const data = payload?.data; + state.targetPayloadShape = data === null ? "data:null" : Array.isArray(data) ? `data:array(${data.length})` : typeof data === "object" ? `data:object(${Object.keys(data || {}).join(",")})` : `data:${typeof data}`; + const vehicles = targetVehiclesFrom(payload); + state.targetLastCount = vehicles.length; + if (!vehicles.length) { + if (payload === null) state.parseFailures += 1; + return; + } + state.targetVehiclesReceived += vehicles.length; + useLiveVehicles(); + registry.ingest(vehicles, payload?.interval); + registry.sweep(); + renderVehicles(); + } catch (error) { + state.targetLastError = String(error?.message || error); + state.parseFailures += 1; + } }, "Target vehicle WebSocket unavailable"); } @@ -370,10 +396,8 @@ function syncVehicleEntity(viewer, record, state) { const [longitude, latitude] = gcj02ToWgs84([record.longitude, record.latitude]); if (!Number.isFinite(longitude) || !Number.isFinite(latitude)) return; - const position = Cesium.Cartesian3.fromDegrees(longitude, latitude, .65); + const position = Cesium.Cartesian3.fromDegrees(longitude, latitude, LIVE_VEHICLE_HEIGHT_METERS); if (!record.entity) { - const tracePositions = [position]; - record.tracePositions = tracePositions; record.renderedPosition = position; record.targetPosition = position; record.sampleStart = Date.now(); @@ -390,35 +414,27 @@ () => vehicleOrientation(record.targetPosition, record.angle), false), model: { uri: vehicleModelUri(record, state), - scale: 0.9, - minimumPixelSize: 14, - maximumScale: 2.0, + // The full-intersection overview needs more than a couple of screen + // pixels for a car to remain distinguishable from road markings. + scale: 1.0, + minimumPixelSize: 28, + maximumScale: 8.0, show: arrayValue(state.vehicleModels).length > 0, }, - label: { text: record.label, font: "11px sans-serif", fillColor: Cesium.Color.WHITE, outlineColor: Cesium.Color.BLACK, outlineWidth: 2, style: Cesium.LabelStyle.FILL_AND_OUTLINE, pixelOffset: new Cesium.Cartesian2(0, -16) }, - }); - record.trace = viewer.entities.add({ - name: `${record.label} live trace`, - polyline: { positions: new Cesium.CallbackProperty(() => record.tracePositions, false), width: 3, material: record.kind === "obu" ? Cesium.Color.LIME : Cesium.Color.ORANGE, arcType: Cesium.ArcType.NONE }, }); } else { record.renderedPosition = record.targetPosition || position; record.targetPosition = position; record.sampleStart = Date.now(); - record.tracePositions.push(position); - if (record.tracePositions.length > 24) record.tracePositions.shift(); - if (record.entity.label) record.entity.label.text = record.label; } // Stale vehicles are hidden, not removed, so the slot stays reusable. record.entity.show = record.visible; - record.trace.show = record.visible; - if (!record.visible) record.tracePositions.length = 0; } function vehicleOrientation(position, angle) { return Cesium.Transforms.headingPitchRollQuaternion( position, - new Cesium.HeadingPitchRoll(Cesium.Math.toRadians(Number(angle) || 0), 0, 0), + new Cesium.HeadingPitchRoll(Cesium.Math.toRadians((Number(angle) || 0) + LIVE_VEHICLE_HEADING_OFFSET_DEGREES), 0, 0), ); } @@ -441,7 +457,7 @@ function relaxJson(text) { return text .replace(/'/g, "\"") - .replace(/([{,]\s*)([A-Za-z_$][\w$]*)\s*:/g, "$1\"$2\":") + .replace(/([{,]\s*)([A-Za-z_$][\w$]*|\d+)\s*:/g, "$1\"$2\":") .replace(/,\s*([}\]])/g, "$1") .replace(/:\s*(NaN|-?Infinity)\s*([,}\]])/g, ": null$2"); } @@ -620,7 +636,21 @@ // dashboard's onConnected. A reconnect without this receives nothing. settings.onOpen?.({ send }); }; - socket.onmessage = (event) => settings.onMessage?.(event?.data); + socket.onmessage = (event) => { + const payload = event?.data; + // The upstream sometimes marks UTF-8 JSON as a binary WS frame. DevTools + // renders that Blob as JSON, but JSON.parse cannot consume a Blob; decode + // it before applying the same dashboard payload parser. + if (typeof Blob !== "undefined" && payload instanceof Blob) { + payload.text().then((text) => settings.onMessage?.(text)).catch((error) => settings.onError?.(error)); + return; + } + if (typeof ArrayBuffer !== "undefined" && payload instanceof ArrayBuffer) { + settings.onMessage?.(new TextDecoder().decode(payload)); + return; + } + settings.onMessage?.(payload); + }; socket.onerror = (event) => settings.onError?.(event); socket.onclose = (event) => { stopHeartbeat(); @@ -702,11 +732,11 @@ }).filter(Boolean)); } - // OBU vehicles use a fixed model; targets are keyed by type/subType. Mirrors - // useObuCars.ts ('car_obu.glb') and useTargetCars.ts (`${type}${subType}.glb`). + // The dashboard's CrossCars template renders OBU with 11.glb, while target + // vehicles retain their `${type}${subType}.glb` model selection. function modelNameFor(vehicle) { if (!vehicle) return ""; - if (vehicle.kind === "obu") return "car_obu.glb"; + if (vehicle.kind === "obu") return "11.glb"; return `${vehicle.type || 1}${vehicle.subType || 1}.glb`; } @@ -777,6 +807,7 @@ list: () => records.slice(), get interval() { return interval; }, get size() { return records.length; }, + get visibleSize() { return records.filter((record) => record.visible).length; }, }; } diff --git a/scripts/lib/vehicle-library.js b/scripts/lib/vehicle-library.js index 3f4c309..73d96a3 100644 --- a/scripts/lib/vehicle-library.js +++ b/scripts/lib/vehicle-library.js @@ -12,8 +12,12 @@ const VEHICLE_IDS = [ "truck_a03_001", ]; const LIBRARY_ROOT = path.resolve(__dirname, "..", "..", "assets", "models", "custom", "lowpoly_cars"); +const V2X_LIBRARY_ROOT = path.resolve(__dirname, "..", "..", "assets", "models", "v2x-vehicles"); const TEXTURE_NAME = "color_512x512.jpg"; const REVERSED_MODEL_IDS = new Set(["truck_a03_001"]); +// These are the dashboard's published vehicle assets. OBU rendering uses +// 11.glb in CrossCars/index.vue; target vehicles use `${type}${subType}.glb`. +const V2X_VEHICLE_MODEL_NAMES = ["11.glb", "12.glb", "13.glb", "14.glb", "15.glb", "16.glb", "17.glb", "31.glb", "221.glb", "222.glb"]; function writePreviewVehicleLibrary(outDir, fileStem) { const textureOutput = `${fileStem}-vehicle-texture.jpg`; @@ -46,4 +50,21 @@ function writePreviewVehicleLibrary(outDir, fileStem) { }); } -module.exports = { VEHICLE_IDS, REVERSED_MODEL_IDS, writePreviewVehicleLibrary }; +function writePreviewV2xVehicleLibrary(outDir) { + const targetDir = path.join(outDir, "v2x-vehicles"); + fs.mkdirSync(targetDir, { recursive: true }); + for (const modelName of V2X_VEHICLE_MODEL_NAMES) { + const source = path.join(V2X_LIBRARY_ROOT, modelName); + if (!fs.existsSync(source)) throw new Error(`V2X vehicle model not found: ${source}`); + fs.copyFileSync(source, path.join(targetDir, modelName)); + } + return V2X_VEHICLE_MODEL_NAMES.map((modelName) => `v2x-vehicles/${modelName}`); +} + +module.exports = { + VEHICLE_IDS, + REVERSED_MODEL_IDS, + V2X_VEHICLE_MODEL_NAMES, + writePreviewVehicleLibrary, + writePreviewV2xVehicleLibrary, +}; diff --git a/scripts/test-preview-assets.js b/scripts/test-preview-assets.js index 252f0f8..4ef8110 100644 --- a/scripts/test-preview-assets.js +++ b/scripts/test-preview-assets.js @@ -7,7 +7,13 @@ const os = require("os"); const path = require("path"); const { cesiumPreviewHtml } = require("./lib/area-preview"); const { makeVehicleGltf } = require("./lib/vehicle-model"); -const { VEHICLE_IDS, REVERSED_MODEL_IDS, writePreviewVehicleLibrary } = require("./lib/vehicle-library"); +const { + VEHICLE_IDS, + REVERSED_MODEL_IDS, + V2X_VEHICLE_MODEL_NAMES, + writePreviewVehicleLibrary, + writePreviewV2xVehicleLibrary, +} = require("./lib/vehicle-library"); const { allowedTurns, buildVehicleRoute, @@ -235,6 +241,14 @@ if (fs.existsSync(vehicleLibraryRoot)) { } } +const v2xVehicleLibraryRoot = path.join(__dirname, "..", "assets", "models", "v2x-vehicles"); +if (fs.existsSync(v2xVehicleLibraryRoot)) { + const v2xLibraryDir = path.join(tempDir, "v2x-vehicle-library"); + const models = writePreviewV2xVehicleLibrary(v2xLibraryDir); + assert.deepEqual(models, V2X_VEHICLE_MODEL_NAMES.map((name) => `v2x-vehicles/${name}`)); + for (const model of models) assert.ok(fs.existsSync(path.join(v2xLibraryDir, model))); +} + const html = cesiumPreviewHtml( "scene<&>.glb", "scene.json", @@ -298,6 +312,18 @@ assert.match(previewRuntime, /entry\.nodeKeys/, assert.match(previewRuntime, /paintCountdown\(/, "countDown from the push drives the countdown assets"); assert.match(previewRuntime, /nativeSignals: \(signalData && signalData\.signals\) \|\| \[\]/, "the overlay needs the native signal list to bind phases"); +assert.match(previewRuntime, /registry\?\.visibleSize/, + "diagnostics must report the visible live-vehicle count"); +assert.match(previewRuntime, /Target WS:/, + "diagnostics must expose target websocket receipt and parsing counts"); +assert.match(previewRuntime, /Target payload:/, + "diagnostics must expose the payload shape when target pushes have no vehicles"); +assert.match(v2xRuntime, /minimumPixelSize: 28/, + "live vehicles remain visible from the intersection overview"); +assert.match(v2xRuntime, /LIVE_VEHICLE_HEIGHT_METERS = 1\.2/, + "live vehicles stay clear of the static road surface"); +assert.match(v2xRuntime, /LIVE_VEHICLE_HEADING_OFFSET_DEGREES = 90/, + "live dashboard vehicle models receive their required clockwise heading correction"); // Overlay-side regressions: lamp codes, heartbeat, subscription frames, // vehicle lifecycle and the removed out-of-scope request. diff --git a/scripts/test-v2x-cesium-overlay.js b/scripts/test-v2x-cesium-overlay.js index 99467ae..d8248e6 100644 --- a/scripts/test-v2x-cesium-overlay.js +++ b/scripts/test-v2x-cesium-overlay.js @@ -11,6 +11,8 @@ const source = fs.readFileSync(path.join(__dirname, "lib", "v2x-cesium-overlay.j const context = { window: { location: new URL("https://preview.example.test/areas/fengshu/") }, URL, + Blob, + TextDecoder, console, }; vm.runInNewContext(source, context, { filename: "v2x-cesium-overlay.js" }); @@ -69,6 +71,9 @@ assert.equal(lampColorName(999), "other"); // --- AC7: loose payload parsing -------------------------------------------- assert.deepEqual(parseLoosePayload('{"a":1}'), { a: 1 }); assert.deepEqual(parseLoosePayload("{a:1,b:'x',}"), { a: 1, b: "x" }); +assert.deepEqual(parseLoosePayload('{data:{1519:[{id:"53122",longitude:114.12871535072304,latitude:30.46061866463221}]}}'), { + data: { 1519: [{ id: "53122", longitude: 114.12871535072304, latitude: 30.46061866463221 }] }, +}, "targetPosition uses unquoted numeric device-id keys"); assert.deepEqual(parseLoosePayload('{"a":NaN}'), { a: null }); assert.equal(parseLoosePayload("{bad"), null); assert.equal(parseLoosePayload(null), null); @@ -161,6 +166,10 @@ assert.equal(obuPush.vehicle.id, "obu-c1"); assert.deepEqual(normalizeTargetVehicles(JSON.stringify({ data: { 8: [{ id: 9, longitude: 114.12866, latitude: 30.4605, type: 1, subType: 2, angle: 180, speed: 5 }] } })), [{ id: "8-9-12", label: "8-9", kind: "target", longitude: 114.12866, latitude: 30.4605, angle: 180, speed: 5, type: 1, subType: 2, }]); +assert.equal(normalizeTargetPush(JSON.stringify({ data: { 1519: [{ id: "50089", longitude: 114.12876415699589, latitude: 30.46060781353787, type: 1, subType: 1 }] }, interval: 1000 })).vehicles.length, 1, + "the live targetPosition payload uses numeric object keys and string target ids"); +assert.equal(normalizeTargetPush('{data:{1519:[{id:"53122",longitude:114.12871535072304,latitude:30.46061866463221,type:1,subType:1}]},interval:1000}').vehicles.length, 1, + "unquoted numeric device-id keys are accepted from the live targetPosition stream"); const targetPush = normalizeTargetPush(JSON.stringify({ interval: 0, data: { 8: [{ id: 9, longitude: 114.1, latitude: 30.4, type: 1 }] } })); assert.equal(targetPush.vehicles[0].subType, 1, "type 1 without subType defaults to subType 1"); assert.equal(resolvePushInterval(targetPush.interval), 500, "interval 0 falls back to 500"); @@ -168,7 +177,7 @@ assert.equal(resolvePushInterval(undefined), 500); assert.equal(resolvePushInterval(900), 900); // --- AC8: model naming matches the dashboard -------------------------------- -assert.equal(modelNameFor({ kind: "obu" }), "car_obu.glb"); +assert.equal(modelNameFor({ kind: "obu" }), "11.glb"); assert.equal(modelNameFor({ kind: "target", type: 1, subType: 2 }), "12.glb"); assert.equal(modelNameFor({ kind: "target" }), "11.glb"); @@ -178,10 +187,12 @@ const registry = createVehicleRegistry({ now: () => clock }); registry.ingest([{ id: "v1", kind: "target", type: 1, subType: 1 }], 1000); assert.equal(registry.list().length, 1); assert.equal(registry.list()[0].visible, true); +assert.equal(registry.visibleSize, 1); assert.equal(registry.list()[0].duration, 1000); assert.deepEqual(registry.sweep(clock + 1499), [], "still fresh below interval * 1.5"); assert.deepEqual(registry.sweep(clock + 1500), ["v1"], "hidden at interval * 1.5"); assert.equal(registry.list()[0].visible, false); +assert.equal(registry.visibleSize, 0); assert.equal(registry.list().length, 1, "hidden vehicles are kept as reusable slots, not removed"); clock = 5000; diff --git a/scripts/test-v2x-preview-server.js b/scripts/test-v2x-preview-server.js index 2937d39..7907af0 100644 --- a/scripts/test-v2x-preview-server.js +++ b/scripts/test-v2x-preview-server.js @@ -30,6 +30,10 @@ async function request(port, pathName, options = {}) { assert.equal(isProxyPath("/package/manifest.json"), false); assert.equal(upstreamPath("/api/facilities/api/sys/login?x=1"), "/facilities/api/sys/login?x=1"); assert.equal(upstreamPath("/websocket/network/ws/network/signal"), "/network/ws/network/signal"); + // An upstream base path (deployments front the API as /dashboardApi) is kept. + assert.equal(upstreamPath("/api/facilities/api/sys/login", "/dashboardApi"), "/dashboardApi/facilities/api/sys/login"); + assert.equal(upstreamPath("/websocket/network/ws/x", "/dashboardWebsocket/"), "/dashboardWebsocket/network/ws/x"); + assert.equal(upstreamPath("/api/x", "/"), "/x"); const upstream = http.createServer((req, res) => { let body = ""; @@ -59,6 +63,28 @@ async function request(port, pathName, options = {}) { } finally { await Promise.all([new Promise((resolve) => preview.close(resolve)), new Promise((resolve) => upstream.close(resolve))]); } + + // Same upstream, but reached through a prefixed base path. + const prefixedUpstream = http.createServer((req, res) => { + res.setHeader("Content-Type", "application/json"); + res.end(JSON.stringify({ path: req.url })); + }); + const prefixedPort = await listen(prefixedUpstream); + const prefixedPreview = createV2xPreviewServer({ + root: path.join(__dirname, "..", "outputs", "fengshu-er-road"), + upstream: `http://127.0.0.1:${prefixedPort}/dashboardApi`, + wsUpstream: `http://127.0.0.1:${prefixedPort}/dashboardWebsocket`, + }); + const prefixedPreviewPort = await listen(prefixedPreview); + try { + const proxied = await request(prefixedPreviewPort, "/api/facilities/api/sys/login"); + assert.deepEqual(JSON.parse(proxied.body), { path: "/dashboardApi/facilities/api/sys/login" }); + } finally { + await Promise.all([ + new Promise((resolve) => prefixedPreview.close(resolve)), + new Promise((resolve) => prefixedUpstream.close(resolve)), + ]); + } console.log("V2X preview server tests passed."); })().catch((error) => { console.error(error); diff --git a/scripts/v2x-preview-server.js b/scripts/v2x-preview-server.js index 95d9c6a..497b2d8 100644 --- a/scripts/v2x-preview-server.js +++ b/scripts/v2x-preview-server.js @@ -34,11 +34,15 @@ function parseArgs(argv) { return values; } -function createV2xPreviewServer({ root, upstream }) { +function createV2xPreviewServer({ root, upstream, wsUpstream }) { const staticRoot = path.resolve(root); const upstreamUrl = new URL(upstream); + // Deployments front the API and the WebSocket with different prefixes (e.g. + // /dashboardApi and /dashboardWebsocket), so the two targets are separate. + const wsUpstreamUrl = wsUpstream ? new URL(wsUpstream) : upstreamUrl; if (!fs.statSync(staticRoot).isDirectory()) throw new Error(`Preview root is not a directory: ${staticRoot}`); if (!/^https?:$/.test(upstreamUrl.protocol)) throw new Error("V2X upstream must use http or https"); + if (!/^https?:$/.test(wsUpstreamUrl.protocol)) throw new Error("V2X websocket upstream must use http or https"); const server = http.createServer((request, response) => { if (isProxyPath(request.url)) { @@ -53,7 +57,7 @@ function createV2xPreviewServer({ root, upstream }) { socket.destroy(); return; } - proxyWebSocket(request, socket, head, upstreamUrl); + proxyWebSocket(request, socket, head, wsUpstreamUrl); }); return server; } @@ -64,10 +68,15 @@ function isProxyPath(url) { pathname === "/websocket" || pathname.startsWith("/websocket/"); } -function upstreamPath(url) { +// The browser calls /api/... and /websocket/...; the local prefix is dropped and +// whatever base path the upstream URL carries is prepended, so an upstream of +// http://host:7862/dashboardApi maps /api/facilities/... onto +// /dashboardApi/facilities/... instead of the bare backend path. +function upstreamPath(url, basePath) { const parsed = new URL(url, "http://preview.local"); const pathname = parsed.pathname.replace(/^\/(api|websocket)(?=\/|$)/, "") || "/"; - return `${pathname}${parsed.search}`; + const base = String(basePath || "").replace(/\/+$/, ""); + return `${base}${pathname}${parsed.search}`; } function upstreamRequestOptions(request, upstreamUrl) { @@ -76,7 +85,7 @@ function upstreamRequestOptions(request, upstreamUrl) { hostname: upstreamUrl.hostname, port: upstreamUrl.port || undefined, method: request.method, - path: upstreamPath(request.url), + path: upstreamPath(request.url, upstreamUrl.pathname), headers: { ...request.headers, host: upstreamUrl.host }, }; } @@ -155,12 +164,13 @@ if (require.main === module) { const args = parseArgs(process.argv.slice(2)); const root = args.root || process.cwd(); const upstream = args.upstream || process.env.V2X_UPSTREAM; + const wsUpstream = args.wsUpstream || process.env.V2X_WS_UPSTREAM; const port = Number(args.port || process.env.PORT || 7862); const host = args.host || process.env.HOST || "0.0.0.0"; if (!upstream) throw new Error("Set V2X_UPSTREAM or pass --upstream http://host:port"); if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error("Port must be an integer between 1 and 65535"); - const server = createV2xPreviewServer({ root, upstream }); - server.listen(port, host, () => console.log(`V2X preview server: http://${host}:${port} -> ${upstream}`)); + const server = createV2xPreviewServer({ root, upstream, wsUpstream }); + server.listen(port, host, () => console.log(`V2X preview server: http://${host}:${port} -> ${upstream} (ws -> ${wsUpstream || upstream})`)); } module.exports = { createV2xPreviewServer, isProxyPath, upstreamPath };