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 };