fix: render live V2X vehicles in Cesium preview

This commit is contained in:
2026-08-25 12:57:35 +08:00
parent bc845444bb
commit 25cf82e7c7
23 changed files with 198 additions and 58 deletions

View File

@@ -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/<area>.json --stages preview`
检查 descriptor 中 `routeName` 为空、不存在 traffic-simulation 文件,
`vehicleModelNames` 已写出(实时车辆渲染需要)。

View File

@@ -176,9 +176,11 @@ registry.list() -> VehicleRecord[]
渲染适配层据 `visible` 切 `entity.show`**不删实体**,与槽位复用配套。
模型选择R8`modelNameFor(vehicle)` 返回 `car_obu.glb` 或 `${type}${subType}.glb`
在包内 `vehicleModelNames` 中按文件名匹配,命中不了则回退 `vehicleModelNames[0]` 并累加
`missingModels` 计数供面板展示。
模型选择R8OBU 的数据 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` 插值,替代当前的直接赋值跳变。

View File

@@ -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

View File

@@ -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。

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -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-api-host>:<port> \
V2X_UPSTREAM=http://<v2x-api-host>:<port>/dashboardApi \
V2X_WS_UPSTREAM=http://<v2x-ws-host>:<port>/dashboardWebsocket \
npm run serve:v2x-preview -- --root outputs/fengshu-er-road --host 0.0.0.0 --port 7862
```

View File

@@ -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) {

View File

@@ -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(", "));

View File

@@ -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; }
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(push.vehicles, push.interval);
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; },
};
}

View File

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

View File

@@ -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.

View File

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

View File

@@ -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);

View File

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