fix(assets): publish controllable traffic signal runtime

This commit is contained in:
2026-08-12 10:17:34 +08:00
parent 41a0e81a6f
commit f13d890ffb
16 changed files with 88 additions and 31 deletions

View File

@@ -280,6 +280,9 @@ function exportCesium(area) {
fs.rmSync(area.outputs.packageStagingDir, { recursive: true, force: true });
fs.mkdirSync(path.dirname(area.outputs.glb), { recursive: true });
fs.mkdirSync(path.dirname(area.outputs.metadata), { recursive: true });
fs.mkdirSync(path.dirname(area.outputs.trafficSignalsDynamicGlb), { recursive: true });
fs.mkdirSync(path.dirname(area.outputs.trafficSignalsCountdown0Glb), { recursive: true });
fs.mkdirSync(path.dirname(area.outputs.trafficSignalsCountdown1Glb), { recursive: true });
console.log("Stage: cesium");
const started = Date.now();
@@ -420,8 +423,15 @@ function compressCesiumGlb(area) {
function publishPackage(area) {
ensureFile(area.outputs.packageStagingManifest, "Staged package manifest");
ensureFile(area.outputs.trafficSignals, "Traffic signal anchors");
const started = Date.now();
const manifest = JSON.parse(fs.readFileSync(area.outputs.packageStagingManifest, "utf8"));
fs.mkdirSync(area.outputs.packageStagingRuntimeDir, { recursive: true });
fs.copyFileSync(area.outputs.trafficSignals, area.outputs.packageStagingTrafficSignals);
manifest.runtime = Array.isArray(manifest.runtime) ? manifest.runtime : [];
if (!manifest.runtime.some((runtime) => runtime.id === "traffic-signals")) {
manifest.runtime.push({ id: "traffic-signals", type: "traffic-signal-anchors", uri: "runtime/traffic-signals.json" });
}
validateManifest(manifest, area.outputs.packageStagingDir);
addIntegrity(manifest, area.outputs.packageStagingDir);
fs.writeFileSync(area.outputs.packageStagingManifest, `${JSON.stringify(manifest, null, 2)}\n`);
@@ -473,7 +483,7 @@ function runCommand(command, commandArgs, stage) {
function writeCesiumPreview(area) {
ensureFile(area.outputs.packageManifest, "Published asset package manifest");
ensureFile(area.outputs.trafficSignals, "Traffic signal anchors");
ensureFile(area.outputs.packageTrafficSignals, "Published traffic signal anchors");
const lanePolygons = path.join(area.outputs.geojsonDir, "lane_polygons.geojson");
const network = path.join(area.outputs.geojsonDir, "network.json");
const intersectionSurface = path.join(area.outputs.geojsonDir, "intersection_surface.geojson");
@@ -493,14 +503,10 @@ function writeCesiumPreview(area) {
const metadataName = "package/manifest.json";
const routeName = previewRelativePath(area.outputs.areaDir, area.outputs.vehicleRoute);
const vehicleModelName = previewRelativePath(area.outputs.areaDir, area.outputs.vehicleModel);
const descriptor = { routeName: previewRelativePath(area.outputs.areaDir, area.outputs.vehicleRoute), vehicleModelName: previewRelativePath(area.outputs.areaDir, area.outputs.vehicleModel), vehicleModelNames: vehicleModelNames.map((name) => `_preview/${name}`), trafficSignalsName: previewRelativePath(area.outputs.areaDir, area.outputs.trafficSignals), assets: [
{ id: "traffic-dynamic", url: previewRelativePath(area.outputs.areaDir, area.outputs.trafficSignalsDynamicGlb), category: "dynamic", enabled: true },
{ id: "traffic-countdown-0", url: previewRelativePath(area.outputs.areaDir, area.outputs.trafficSignalsCountdown0Glb), category: "countdown", enabled: true, phaseGroup: 0 },
{ id: "traffic-countdown-1", url: previewRelativePath(area.outputs.areaDir, area.outputs.trafficSignalsCountdown1Glb), category: "countdown", enabled: true, phaseGroup: 1 },
] };
const descriptor = { routeName: previewRelativePath(area.outputs.areaDir, area.outputs.vehicleRoute), vehicleModelName: previewRelativePath(area.outputs.areaDir, area.outputs.vehicleModel), vehicleModelNames: vehicleModelNames.map((name) => `_preview/${name}`), trafficSignalsName: "package/runtime/traffic-signals.json", assets: [] };
fs.mkdirSync(area.outputs.previewDir, { recursive: true });
fs.writeFileSync(area.outputs.previewDescriptor, `${JSON.stringify(descriptor, null, 2)}\n`);
fs.writeFileSync(htmlPath, cesiumPreviewHtml(glbName, metadataName, routeName, vehicleModelName, area.id, vehicleModelNames, previewRelativePath(area.outputs.areaDir, area.outputs.trafficSignals), "_preview/descriptor.json"));
fs.writeFileSync(htmlPath, cesiumPreviewHtml(glbName, metadataName, routeName, vehicleModelName, area.id, vehicleModelNames.map((name) => `_preview/${name}`), "package/runtime/traffic-signals.json", "_preview/descriptor.json"));
console.log(`Cesium preview: ${htmlPath}`);
const finished = Date.now();
writeStageManifest(area, {
@@ -525,7 +531,7 @@ function writeCesiumPreview(area) {
cesiumPreview: fileRecord(area.outputs.cesiumPreview),
vehicleRoute: fileRecord(area.outputs.vehicleRoute),
vehicleModel: fileRecord(area.outputs.vehicleModel),
trafficSignals: fileRecord(area.outputs.trafficSignals),
trafficSignals: fileRecord(area.outputs.packageTrafficSignals),
},
summary: previewSummary(area),
warnings: [],

View File

@@ -28,6 +28,8 @@ function normalizeAreaConfig(raw, options = {}) {
const pipelineDir = path.resolve(outputOverrides.pipelineDir || path.join(areaDir, "_pipeline"));
const packageDir = path.resolve(outputOverrides.packageDir || path.join(areaDir, "package"));
const packageStagingDir = path.resolve(outputOverrides.packageStagingDir || path.join(pipelineDir, "package-staging"));
const packageRuntimeDir = path.resolve(outputOverrides.packageRuntimeDir || path.join(packageDir, "runtime"));
const packageStagingRuntimeDir = path.resolve(outputOverrides.packageStagingRuntimeDir || path.join(packageStagingDir, "runtime"));
const previewDir = path.resolve(outputOverrides.previewDir || path.join(areaDir, "_preview"));
const geojsonDir = path.resolve(outputOverrides.geojsonDir || path.join(areaDir, "osm2streets_web_out"));
const outputs = {
@@ -47,11 +49,13 @@ function normalizeAreaConfig(raw, options = {}) {
packageStagingModelDir: path.resolve(outputOverrides.packageStagingModelDir || path.join(packageStagingDir, "models")),
packagePrimaryGlb: path.resolve(outputOverrides.packagePrimaryGlb || path.join(packageDir, "models", `${fileStem}.glb`)),
glb: path.resolve(outputOverrides.glb || path.join(packageStagingDir, "models", `${fileStem}.glb`)),
trafficSignalsDynamicGlb: path.resolve(
outputOverrides.trafficSignalsDynamicGlb || path.join(previewDir, `${fileStem}-traffic-signals-dynamic.glb`),
),
trafficSignalsCountdown0Glb: path.resolve(outputOverrides.trafficSignalsCountdown0Glb || path.join(previewDir, `${fileStem}-traffic-signals-countdown-0.glb`)),
trafficSignalsCountdown1Glb: path.resolve(outputOverrides.trafficSignalsCountdown1Glb || path.join(previewDir, `${fileStem}-traffic-signals-countdown-1.glb`)),
packageRuntimeDir,
packageStagingRuntimeDir,
trafficSignalsDynamicGlb: path.resolve(outputOverrides.trafficSignalsDynamicGlb || path.join(packageStagingRuntimeDir, "traffic-signals-dynamic.glb")),
trafficSignalsCountdown0Glb: path.resolve(outputOverrides.trafficSignalsCountdown0Glb || path.join(packageStagingRuntimeDir, "traffic-signals-countdown-0.glb")),
trafficSignalsCountdown1Glb: path.resolve(outputOverrides.trafficSignalsCountdown1Glb || path.join(packageStagingRuntimeDir, "traffic-signals-countdown-1.glb")),
packageTrafficSignals: path.resolve(outputOverrides.packageTrafficSignals || path.join(packageRuntimeDir, "traffic-signals.json")),
packageStagingTrafficSignals: path.resolve(outputOverrides.packageStagingTrafficSignals || path.join(packageStagingRuntimeDir, "traffic-signals.json")),
metadata: path.resolve(outputOverrides.metadata || path.join(packageStagingDir, "manifest.json")),
cesiumPreview: path.resolve(
outputOverrides.cesiumPreview || path.join(areaDir, `${fileStem}-cesium-preview.html`),

View File

@@ -329,7 +329,7 @@ function artifactStatus(area) {
["QGIS project", area.outputs.qgisProject, true, "file"],
["QGIS preview", area.outputs.qgisPreview, true, "file"],
["Traffic signal assemblies", area.outputs.trafficSignalAssemblies, true, "file"],
["Traffic signal runtime", area.outputs.trafficSignals, true, "file"],
["Traffic signal runtime", area.outputs.packageTrafficSignals, true, "file"],
["Blend scene", area.outputs.blend, true, "file"],
["Render PNG", area.outputs.render, true, "file"],
["Package manifest", area.outputs.packageManifest, true, "file"],
@@ -403,7 +403,7 @@ function stageManifestStatus(area, configPath = null) {
geojsonDir: area.outputs.geojsonDir,
...sceneGeojsonFiles(area),
trafficSignalAssemblies: area.outputs.trafficSignalAssemblies,
trafficSignals: area.outputs.trafficSignals,
trafficSignals: area.outputs.packageTrafficSignals,
gpkg: area.outputs.gpkg,
qgisProject: area.outputs.qgisProject,
qgisPreview: optionalExpectedFile(area.outputs.qgisPreview),
@@ -421,7 +421,7 @@ function stageManifestStatus(area, configPath = null) {
geojsonDir: area.outputs.geojsonDir,
...sceneGeojsonFiles(area),
trafficSignalAssemblies: area.outputs.trafficSignalAssemblies,
trafficSignals: area.outputs.trafficSignals,
trafficSignals: area.outputs.packageTrafficSignals,
},
},
{
@@ -433,7 +433,7 @@ function stageManifestStatus(area, configPath = null) {
geojsonDir: area.outputs.geojsonDir,
...sceneGeojsonFiles(area),
trafficSignalAssemblies: area.outputs.trafficSignalAssemblies,
trafficSignals: area.outputs.trafficSignals,
trafficSignals: area.outputs.packageTrafficSignals,
},
outputs: {
blend: area.outputs.blend,

View File

@@ -110,7 +110,7 @@ function previewSummary(area) {
metadataName: "package/manifest.json",
routeName: path.relative(area.outputs.areaDir, area.outputs.vehicleRoute).split(path.sep).join("/"),
vehicleModelName: path.relative(area.outputs.areaDir, area.outputs.vehicleModel).split(path.sep).join("/"),
trafficSignalsName: path.relative(area.outputs.areaDir, area.outputs.trafficSignals).split(path.sep).join("/"),
trafficSignalsName: "package/runtime/traffic-signals.json",
routeSegments: Array.isArray(route.segments) ? route.segments.length : null,
};
}

View File

@@ -177,7 +177,16 @@
category: asset.category,
semantic: asset.role === "layer",
}));
return staticAssets.concat(metadata.previewAssets || []);
const runtimeAssets = (metadata.runtime || []).filter((asset) => asset.type !== "traffic-signal-anchors").map((asset) => ({
id: asset.id,
label: asset.id,
type: "model",
url: new URL(asset.uri, new URL(config.metadataName, window.location.href)).href,
enabled: true,
category: asset.type === "traffic-signal-lenses" ? "dynamic" : asset.type === "traffic-signal-countdown" ? "countdown" : asset.type,
phaseGroup: asset.phaseGroup,
}));
return staticAssets.concat(runtimeAssets, metadata.previewAssets || []);
}
const assets = Array.isArray(metadata.assets) && metadata.assets.length
? metadata.assets

View File

@@ -49,6 +49,17 @@ function validateManifest(manifest, packageDir, options = {}) {
if (!resolved.startsWith(`${path.resolve(packageDir)}${path.sep}`) || !fs.existsSync(resolved)) fail(`asset '${asset.id}' is missing from package: ${asset.uri}`);
}
}
if (manifest.runtime !== undefined && !Array.isArray(manifest.runtime)) fail("runtime must be an array");
for (const runtime of manifest?.runtime || []) {
if (!runtime || typeof runtime.id !== "string" || !runtime.id) { fail("runtime asset id is required"); continue; }
if (ids.has(runtime.id)) fail(`duplicate asset id '${runtime.id}'`); ids.add(runtime.id);
if (typeof runtime.type !== "string" || !runtime.type) fail(`runtime '${runtime.id}' type is required`);
try { relativeUri(runtime.uri); } catch (error) { fail(error.message); continue; }
if (packageDir && options.requireFiles !== false) {
const resolved = path.resolve(packageDir, runtime.uri);
if (!resolved.startsWith(`${path.resolve(packageDir)}${path.sep}`) || !fs.existsSync(resolved)) fail(`runtime '${runtime.id}' is missing from package: ${runtime.uri}`);
}
}
if (sceneCount !== 1) fail("exactly one scene asset is required");
if (errors.length) throw new Error(`Invalid asset package manifest: ${errors.join("; ")}`);
return manifest;
@@ -60,6 +71,11 @@ function addIntegrity(manifest, packageDir) {
const buffer = fs.readFileSync(file);
asset.integrity = { bytes: buffer.length, sha256: crypto.createHash("sha256").update(buffer).digest("hex") };
}
for (const runtime of manifest.runtime || []) {
const file = path.resolve(packageDir, runtime.uri);
const buffer = fs.readFileSync(file);
runtime.integrity = { bytes: buffer.length, sha256: crypto.createHash("sha256").update(buffer).digest("hex") };
}
return manifest;
}

View File

@@ -10,7 +10,8 @@ const { SCHEMA, validateManifest } = require("./lib/package-contract");
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "asset-package-"));
fs.mkdirSync(path.join(dir, "models"));
fs.writeFileSync(path.join(dir, "models", "area.glb"), "glb");
const valid = { schema: SCHEMA, packageVersion: "1.0.0", areaId: "area", coordinateSystem: { axes: "ENU", units: "meters", x: "east", y: "north", z: "up" }, placement: { longitude: 120, latitude: 30, height: 0, headingCorrectionDegrees: -90 }, bounds: { minLon: 119, minLat: 29, maxLon: 121, maxLat: 31 }, assets: [{ id: "main", role: "scene", category: "scene", uri: "models/area.glb", defaultLoad: true }] };
fs.writeFileSync(path.join(dir, "runtime.json"), "{}");
const valid = { schema: SCHEMA, packageVersion: "1.0.0", areaId: "area", coordinateSystem: { axes: "ENU", units: "meters", x: "east", y: "north", z: "up" }, placement: { longitude: 120, latitude: 30, height: 0, headingCorrectionDegrees: -90 }, bounds: { minLon: 119, minLat: 29, maxLon: 121, maxLat: 31 }, assets: [{ id: "main", role: "scene", category: "scene", uri: "models/area.glb", defaultLoad: true }], runtime: [{ id: "traffic-signals", type: "traffic-signal-anchors", uri: "runtime.json" }] };
assert.doesNotThrow(() => validateManifest(valid, dir));
for (const change of [
(m) => { m.schema = "bad"; }, (m) => { m.assets[0].uri = "../area.glb"; },

View File

@@ -1,9 +1,10 @@
#!/usr/bin/env node
"use strict";
const assert = require("assert");
const { resolveAsset, placement } = require("../examples/asset-package-resolver");
const manifest = { coordinateSystem: { axes: "ENU" }, placement: { longitude: 120, latitude: 30, height: 2, headingCorrectionDegrees: -90 }, assets: [{ id: "main", uri: "models/a.glb" }, { id: "roads", uri: "models/roads.glb" }] };
const { resolveAsset, resolveRuntime, placement } = require("../examples/asset-package-resolver");
const manifest = { coordinateSystem: { axes: "ENU" }, placement: { longitude: 120, latitude: 30, height: 2, headingCorrectionDegrees: -90 }, assets: [{ id: "main", uri: "models/a.glb" }, { id: "roads", uri: "models/roads.glb" }], runtime: [{ id: "traffic-signals", uri: "runtime/traffic-signals.json" }] };
assert.equal(resolveAsset("https://consumer.example/copied/manifest.json", manifest), "https://consumer.example/copied/models/a.glb");
assert.equal(resolveAsset("https://consumer.example/copied/manifest.json", manifest, "roads"), "https://consumer.example/copied/models/roads.glb");
assert.equal(resolveRuntime("https://consumer.example/copied/manifest.json", manifest, "traffic-signals"), "https://consumer.example/copied/runtime/traffic-signals.json");
assert.equal(placement(manifest).coordinateSystem.axes, "ENU");
console.log("Asset package example resolver tests passed.");

View File

@@ -257,6 +257,8 @@ assert.match(html, /data-view-mode="inspect"/);
assert.match(html, /id="semanticToggles" class="control-subgroup hidden"/);
const previewRuntime = fs.readFileSync(path.join(__dirname, "lib", "cesium-preview.js"), "utf8");
const buildAreaSource = fs.readFileSync(path.join(__dirname, "build-area.js"), "utf8");
assert.match(buildAreaSource, /vehicleModelNames\.map\(\(name\) => `_preview\/\$\{name\}`\)/);
const countdownFont = path.join(__dirname, "..", "assets", "fonts", "7LED-1.ttf");
assert.ok(fs.existsSync(countdownFont), "7LED countdown font must be versioned with the project");
assert.doesNotMatch(previewRuntime, /cylinder: \{ length: 6\.7/);