The ported overlay used the correct REST paths but lost the data handling
from the source dashboard's live-intersection view (HologramCross), so
signals rendered permanently red and vehicles often never appeared.
- Lamp status codes now follow the dashboard dictionary (11/21/22/23/31).
The previous 2/3 reading made every real push fall through to red. The
dictionary lives only in the overlay; the preview consumes normalized
{nodeKeys, color, countDown} entries so the two copies cannot drift.
- Bind V2X phases to native signal heads geometrically. The runtime
document has no phaseNo, so the old lookup fell back to signal.id and
never matched, leaving the dynamic assembly dark. Travel heading is
recovered as faceHeadingDegrees + 180, per the generator's
mast = travel - 90 / face = travel + 180. Verified 7/7 exact matches
against the fengshu-er-road runtime document.
- A phase now lights every approach it drives; the phase -> single entity
map silently overwrote all but the last.
- Drive the countdown assets from the push's countDown field.
- All three sockets heartbeat every 30s and reconnect with backoff,
replaying their subscription frame. Without this the service dropped
the connection and the scene emptied after about a minute.
- The OBU socket sends its bounds frame on connect and on camera move;
it previously sent nothing at all.
- Vehicles are swept when a push goes stale and their slots reused, so
they no longer accumulate as ghosts. Models follow the dashboard's
car_obu.glb / ${type}${subType}.glb naming.
- Parse vehicle pushes leniently, since the dashboard uses saferEval and
the payload is not guaranteed to be strict JSON. Failures are counted
and surfaced rather than dropped; no eval is introduced.
- Drop FlowTravelRatio/queryListWeek, which is not part of this view.
Also corrects a stale spec rule that required vehicleModelNames to be
empty. Live V2X vehicles need packaged models; the real invariant is no
generated routes or traffic simulation.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
681 lines
27 KiB
JavaScript
Executable File
681 lines
27 KiB
JavaScript
Executable File
#!/usr/bin/env node
|
|
|
|
const fs = require("fs");
|
|
const path = require("path");
|
|
const { spawnSync } = require("child_process");
|
|
const { readAreaConfig } = require("./lib/area-config");
|
|
const { compileArea: compileNativeRoads } = require("./compile-native-roads");
|
|
const { resolveStages } = require("./lib/build-stages");
|
|
const { validateManifest, addIntegrity } = require("./lib/package-contract");
|
|
const { blenderExecutable: resolveBlenderExecutable } = require("./lib/tool-paths");
|
|
const {
|
|
SCENE_LAYERS,
|
|
SCENE_FILE,
|
|
SCENE_STYLE_FILE,
|
|
layerFile,
|
|
} = require("./lib/scene-layers");
|
|
const { digest: glbDigest } = require("./glb-digest");
|
|
const { buildVehicleRoute: buildPreviewVehicleRoute } = require("./lib/vehicle-route");
|
|
const { writePreviewVehicleLibrary } = require("./lib/vehicle-library");
|
|
const { readTrafficSignals } = require("./lib/traffic-signals");
|
|
const {
|
|
cesiumPreviewHtml,
|
|
previewSummary,
|
|
writeCesiumPreviewSupportFiles,
|
|
} = require("./lib/area-preview");
|
|
const {
|
|
fileRecord,
|
|
evaluateGlbBudget,
|
|
glbBudgetWarnings,
|
|
glbSummary,
|
|
optionalFileRecord,
|
|
stageManifestPath,
|
|
writeStageManifest,
|
|
} = require("./lib/stage-manifest");
|
|
|
|
const repoRoot = path.resolve(__dirname, "..");
|
|
const args = parseArgs(process.argv.slice(2));
|
|
const configPath = path.resolve(
|
|
args.config || path.join(repoRoot, "config", "areas", "nantaizi-lake-innovation-valley.json"),
|
|
);
|
|
const area = readAreaConfig(configPath, { repoRoot });
|
|
const requestedStages = args.stages
|
|
? splitList(args.stages)
|
|
: null;
|
|
const stages = resolveStages(area.stages, requestedStages);
|
|
const roadProvider = args.roadProvider || area.blender.roadProvider;
|
|
if (!new Set(["osm2streets", "native"]).has(roadProvider)) {
|
|
throw new Error("--road-provider must be \"osm2streets\" or \"native\".");
|
|
}
|
|
|
|
|
|
console.log(`Area: ${area.id}`);
|
|
console.log(`Config: ${configPath}`);
|
|
console.log(`Output: ${area.outputs.areaDir}`);
|
|
|
|
if (stages.intermediates) {
|
|
buildIntermediates(area);
|
|
}
|
|
if (stages.reimport) {
|
|
reimportGpkg(area);
|
|
}
|
|
if (stages.blender) {
|
|
buildBlenderScene(area, roadProvider);
|
|
}
|
|
if (stages.cesium) {
|
|
exportCesium(area, roadProvider);
|
|
}
|
|
if (stages.compress) {
|
|
compressCesiumGlb(area);
|
|
}
|
|
if (stages.package) {
|
|
publishPackage(area, roadProvider);
|
|
}
|
|
if (stages.preview) {
|
|
writeCesiumPreview(area, roadProvider);
|
|
}
|
|
|
|
console.log("Done.");
|
|
|
|
function parseArgs(argv) {
|
|
const out = {};
|
|
for (let i = 0; i < argv.length; i += 1) {
|
|
const arg = argv[i];
|
|
if (!arg.startsWith("--")) continue;
|
|
const key = arg.slice(2).replace(/-([a-z])/g, (_, c) => c.toUpperCase());
|
|
const next = argv[i + 1];
|
|
if (!next || next.startsWith("--")) {
|
|
out[key] = "true";
|
|
} else {
|
|
out[key] = next;
|
|
i += 1;
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
|
|
function splitList(value) {
|
|
return String(value)
|
|
.split(",")
|
|
.map((item) => item.trim())
|
|
.filter(Boolean);
|
|
}
|
|
|
|
|
|
function writeDerivedConfig(area) {
|
|
fs.mkdirSync(area.outputs.pipelineDir, { recursive: true });
|
|
const derivedConfig = {
|
|
qgisApp: area.qgisApp,
|
|
input: area.input,
|
|
outDir: area.outputs.geojsonDir,
|
|
gpkg: area.outputs.gpkg,
|
|
project: area.outputs.qgisProject,
|
|
preview: area.outputs.qgisPreview,
|
|
trafficSignalAssemblies: area.outputs.trafficSignalAssemblies,
|
|
arrowScale: area.qgis.arrowScale,
|
|
arrowMergeTriangles: area.qgis.arrowMergeTriangles,
|
|
arrowOutlineSimplifyMeters: area.qgis.arrowOutlineSimplifyMeters,
|
|
intersectionCornerSourceMaxDimensionMeters: area.qgis.intersectionCornerSourceMaxDimensionMeters,
|
|
clipPad: area.qgis.clipPad,
|
|
canvasPad: area.qgis.canvasPad,
|
|
previewPad: area.qgis.previewPad,
|
|
canvasExtent: area.qgis.canvasExtent,
|
|
previewExtent: area.qgis.previewExtent,
|
|
layerPrefix: area.qgis.layerPrefix,
|
|
turnLaneArrows: area.turnLaneArrows,
|
|
osm2streets: area.osm2streets,
|
|
};
|
|
const derivedConfigPath = path.join(area.outputs.pipelineDir, "osm2streets-qgis.config.json");
|
|
fs.writeFileSync(derivedConfigPath, `${JSON.stringify(derivedConfig, null, 2)}\n`);
|
|
return derivedConfigPath;
|
|
}
|
|
|
|
function buildIntermediates(area) {
|
|
const started = Date.now();
|
|
const startedAt = new Date(started).toISOString();
|
|
const derivedConfigPath = writeDerivedConfig(area);
|
|
|
|
console.log("Stage: intermediates (osm2streets GeoJSON + QGIS)");
|
|
runCommand(process.execPath, [
|
|
path.join(repoRoot, "scripts", "build-osm2streets-qgis.js"),
|
|
"--config",
|
|
derivedConfigPath,
|
|
], "intermediates");
|
|
writeTrafficSignals(area);
|
|
fs.rmSync(stageManifestPath(area, "reimport"), { force: true });
|
|
const finished = Date.now();
|
|
writeStageManifest(area, {
|
|
stage: "intermediates",
|
|
status: "ok",
|
|
config: configPath,
|
|
startedAt,
|
|
finishedAt: new Date(finished).toISOString(),
|
|
durationMs: finished - started,
|
|
inputs: {
|
|
config: fileRecord(configPath),
|
|
osm: fileRecord(area.input),
|
|
},
|
|
outputs: {
|
|
derivedConfig: fileRecord(derivedConfigPath),
|
|
geojsonDir: fileRecord(area.outputs.geojsonDir),
|
|
...sceneGeojsonRecords(area),
|
|
trafficSignalAssemblies: fileRecord(area.outputs.trafficSignalAssemblies),
|
|
trafficSignals: fileRecord(area.outputs.trafficSignals),
|
|
gpkg: fileRecord(area.outputs.gpkg),
|
|
qgisProject: fileRecord(area.outputs.qgisProject),
|
|
qgisPreview: optionalFileRecord(area.outputs.qgisPreview),
|
|
},
|
|
summary: {
|
|
geojson: geojsonFeatureCounts(area),
|
|
trafficSignalAssemblies: featureCount(area.outputs.trafficSignalAssemblies),
|
|
},
|
|
warnings: [],
|
|
});
|
|
}
|
|
|
|
function reimportGpkg(area) {
|
|
const started = Date.now();
|
|
const startedAt = new Date(started).toISOString();
|
|
const derivedConfigPath = writeDerivedConfig(area);
|
|
|
|
console.log("Stage: reimport (GeoPackage -> GeoJSON)");
|
|
runCommand(process.execPath, [
|
|
path.join(repoRoot, "scripts", "reimport-gpkg.js"),
|
|
"--config",
|
|
derivedConfigPath,
|
|
], "reimport");
|
|
writeTrafficSignals(area);
|
|
fs.rmSync(stageManifestPath(area, "intermediates"), { force: true });
|
|
const finished = Date.now();
|
|
writeStageManifest(area, {
|
|
stage: "reimport",
|
|
status: "ok",
|
|
config: configPath,
|
|
startedAt,
|
|
finishedAt: new Date(finished).toISOString(),
|
|
durationMs: finished - started,
|
|
inputs: {
|
|
config: fileRecord(configPath),
|
|
derivedConfig: fileRecord(derivedConfigPath),
|
|
gpkg: fileRecord(area.outputs.gpkg),
|
|
},
|
|
outputs: {
|
|
geojsonDir: fileRecord(area.outputs.geojsonDir),
|
|
...sceneGeojsonRecords(area),
|
|
trafficSignalAssemblies: fileRecord(area.outputs.trafficSignalAssemblies),
|
|
trafficSignals: fileRecord(area.outputs.trafficSignals),
|
|
},
|
|
summary: {
|
|
geojson: geojsonFeatureCounts(area),
|
|
trafficSignalAssemblies: featureCount(area.outputs.trafficSignalAssemblies),
|
|
},
|
|
warnings: [],
|
|
});
|
|
}
|
|
|
|
function buildBlenderScene(area, roadProvider) {
|
|
ensureFile(blenderExecutable(area), "Blender executable");
|
|
ensureFile(path.join(repoRoot, "blender", "generate_scene.py"), "Blender scene generator");
|
|
if (roadProvider === "osm2streets") {
|
|
ensureFile(area.outputs.trafficSignalAssemblies, "Editable traffic signal assemblies");
|
|
// Blender consumes the editable assembly layer; OSM only initializes it in
|
|
// intermediates, so QGIS edits remain authoritative across later stages.
|
|
writeTrafficSignals(area);
|
|
ensureFile(area.outputs.trafficSignals, "Traffic signal anchors");
|
|
}
|
|
fs.mkdirSync(path.dirname(area.outputs.blend), { recursive: true });
|
|
fs.mkdirSync(path.dirname(area.outputs.render), { recursive: true });
|
|
if (roadProvider === "native") {
|
|
compileNativeRoads(configPath);
|
|
ensureNativeRoadLayers(area);
|
|
}
|
|
|
|
const blenderArgs = [
|
|
"--background",
|
|
"--factory-startup",
|
|
"--python",
|
|
path.join(repoRoot, "blender", "generate_scene.py"),
|
|
"--",
|
|
"--osm",
|
|
area.input,
|
|
"--output",
|
|
area.outputs.blend,
|
|
"--render",
|
|
area.outputs.render,
|
|
"--tree-style",
|
|
area.blender.treeStyle,
|
|
];
|
|
if (roadProvider === "native") {
|
|
blenderArgs.push("--native-road", area.outputs.nativeRoadDir);
|
|
blenderArgs.push("--traffic-signals", path.join(area.outputs.nativeRoadDir, "traffic-signals.json"));
|
|
} else {
|
|
blenderArgs.push("--geojson", area.outputs.geojsonDir);
|
|
}
|
|
if (area.blender.officeOverrides) {
|
|
blenderArgs.push("--office-overrides", area.blender.officeOverrides);
|
|
}
|
|
|
|
console.log("Stage: blender");
|
|
const started = Date.now();
|
|
const startedAt = new Date(started).toISOString();
|
|
runCommand(blenderExecutable(area), blenderArgs, "blender");
|
|
const finished = Date.now();
|
|
writeStageManifest(area, {
|
|
stage: "blender",
|
|
status: "ok",
|
|
config: configPath,
|
|
startedAt,
|
|
finishedAt: new Date(finished).toISOString(),
|
|
durationMs: finished - started,
|
|
inputs: {
|
|
config: fileRecord(configPath),
|
|
osm: fileRecord(area.input),
|
|
...(roadProvider === "native" ? nativeRoadRecords(area) : sceneGeojsonRecords(area)),
|
|
...(roadProvider === "osm2streets" ? {
|
|
geojsonDir: fileRecord(area.outputs.geojsonDir),
|
|
trafficSignalAssemblies: fileRecord(area.outputs.trafficSignalAssemblies),
|
|
trafficSignals: fileRecord(area.outputs.trafficSignals),
|
|
} : {}),
|
|
},
|
|
outputs: {
|
|
blend: fileRecord(area.outputs.blend),
|
|
render: fileRecord(area.outputs.render),
|
|
},
|
|
summary: {
|
|
...(roadProvider === "native" ? { nativeRoad: nativeRoadFeatureCounts(area) } : { geojson: geojsonFeatureCounts(area) }),
|
|
roadProvider,
|
|
blendBytes: fileRecord(area.outputs.blend).bytes,
|
|
renderBytes: fileRecord(area.outputs.render).bytes,
|
|
},
|
|
warnings: [],
|
|
});
|
|
}
|
|
|
|
function ensureNativeRoadLayers(area) {
|
|
ensureFile(path.join(area.outputs.nativeRoadDir, "compiled.json"), "Native road compilation");
|
|
for (const file of ["road_surface.geojson", "edge_lines.geojson", "intersection_surface.geojson", "sidewalk_surface.geojson", "lane_separators.geojson", "center_lines.geojson", "direction_arrows.geojson", "turn_arrows.geojson", "crosswalks.geojson", "vehicle_stop_lines.geojson"]) {
|
|
ensureFile(path.join(area.outputs.nativeRoadDir, "layers", file), `Native road layer ${file}`);
|
|
}
|
|
}
|
|
|
|
function nativeRoadRecords(area) {
|
|
const root = path.join(area.outputs.nativeRoadDir, "layers");
|
|
return {
|
|
nativeRoadCompiled: fileRecord(path.join(area.outputs.nativeRoadDir, "compiled.json")),
|
|
nativeRoadSurface: fileRecord(path.join(root, "road_surface.geojson")),
|
|
nativeEdgeLines: fileRecord(path.join(root, "edge_lines.geojson")),
|
|
nativeIntersectionSurface: fileRecord(path.join(root, "intersection_surface.geojson")),
|
|
nativeSidewalkSurface: fileRecord(path.join(root, "sidewalk_surface.geojson")),
|
|
nativeLaneSeparators: fileRecord(path.join(root, "lane_separators.geojson")),
|
|
nativeCenterLines: fileRecord(path.join(root, "center_lines.geojson")),
|
|
nativeDirectionArrows: fileRecord(path.join(root, "direction_arrows.geojson")),
|
|
nativeTurnArrows: fileRecord(path.join(root, "turn_arrows.geojson")),
|
|
nativeCrosswalks: fileRecord(path.join(root, "crosswalks.geojson")),
|
|
nativeVehicleStopLines: fileRecord(path.join(root, "vehicle_stop_lines.geojson")),
|
|
};
|
|
}
|
|
|
|
function nativeRoadFeatureCounts(area) {
|
|
const root = path.join(area.outputs.nativeRoadDir, "layers");
|
|
return {
|
|
roadSurface: featureCount(path.join(root, "road_surface.geojson")),
|
|
edgeLines: featureCount(path.join(root, "edge_lines.geojson")),
|
|
intersectionSurface: featureCount(path.join(root, "intersection_surface.geojson")),
|
|
sidewalkSurface: featureCount(path.join(root, "sidewalk_surface.geojson")),
|
|
laneSeparators: featureCount(path.join(root, "lane_separators.geojson")),
|
|
centerLines: featureCount(path.join(root, "center_lines.geojson")),
|
|
directionArrows: featureCount(path.join(root, "direction_arrows.geojson")),
|
|
turnArrows: featureCount(path.join(root, "turn_arrows.geojson")),
|
|
crosswalks: featureCount(path.join(root, "crosswalks.geojson")),
|
|
vehicleStopLines: featureCount(path.join(root, "vehicle_stop_lines.geojson")),
|
|
};
|
|
}
|
|
|
|
function exportCesium(area, roadProvider) {
|
|
ensureFile(blenderExecutable(area), "Blender executable");
|
|
ensureFile(area.outputs.blend, "Blend scene");
|
|
ensureFile(path.join(repoRoot, "blender", "export_cesium.py"), "Cesium exporter");
|
|
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();
|
|
const startedAt = new Date(started).toISOString();
|
|
const exporterArgs = [
|
|
"--background",
|
|
"--factory-startup",
|
|
// Blender 4.5 on this macOS host can crash while probing Metal extensions
|
|
// before the exporter script runs; this is Blender's documented workaround.
|
|
"--debug-gpu-force-workarounds",
|
|
"--python",
|
|
path.join(repoRoot, "blender", "export_cesium.py"),
|
|
"--",
|
|
"--blend",
|
|
area.outputs.blend,
|
|
"--glb",
|
|
area.outputs.glb,
|
|
"--metadata",
|
|
area.outputs.metadata,
|
|
];
|
|
exporterArgs.splice(-2, 0,
|
|
"--dynamic-glb", area.outputs.trafficSignalsDynamicGlb,
|
|
"--countdown-0-glb", area.outputs.trafficSignalsCountdown0Glb,
|
|
"--countdown-1-glb", area.outputs.trafficSignalsCountdown1Glb,
|
|
);
|
|
runCommand(blenderExecutable(area), exporterArgs, "cesium");
|
|
ensureFile(area.outputs.trafficSignalsDynamicGlb, "Dynamic traffic signal GLB");
|
|
ensureFile(area.outputs.trafficSignalsCountdown0Glb, "Traffic countdown group 0 GLB");
|
|
ensureFile(area.outputs.trafficSignalsCountdown1Glb, "Traffic countdown group 1 GLB");
|
|
const semanticAssets = semanticAssetRecords(area);
|
|
const finished = Date.now();
|
|
const digest = glbDigest(area.outputs.glb);
|
|
writeStageManifest(area, {
|
|
stage: "cesium",
|
|
status: "ok",
|
|
config: configPath,
|
|
startedAt,
|
|
finishedAt: new Date(finished).toISOString(),
|
|
durationMs: finished - started,
|
|
inputs: {
|
|
blend: fileRecord(area.outputs.blend),
|
|
},
|
|
outputs: {
|
|
glb: fileRecord(area.outputs.glb),
|
|
metadata: fileRecord(area.outputs.metadata),
|
|
trafficSignalsDynamicGlb: fileRecord(area.outputs.trafficSignalsDynamicGlb),
|
|
trafficSignalsCountdown0Glb: fileRecord(area.outputs.trafficSignalsCountdown0Glb),
|
|
trafficSignalsCountdown1Glb: fileRecord(area.outputs.trafficSignalsCountdown1Glb),
|
|
semanticAssets,
|
|
},
|
|
summary: {
|
|
glb: glbSummary(digest),
|
|
budget: evaluateGlbBudget(digest, area.budget),
|
|
roadProvider,
|
|
},
|
|
warnings: glbBudgetWarnings("Cesium", digest, area.budget),
|
|
});
|
|
}
|
|
|
|
function semanticAssetRecords(area) {
|
|
const metadata = JSON.parse(fs.readFileSync(area.outputs.metadata, "utf8"));
|
|
const assets = Array.isArray(metadata.assets) ? metadata.assets : [];
|
|
const records = {};
|
|
for (const asset of assets) {
|
|
if (asset.role !== "layer") continue;
|
|
const file = path.resolve(area.outputs.packageStagingDir, asset.uri);
|
|
ensureFile(file, `Cesium semantic asset '${asset.id}'`);
|
|
records[asset.id] = fileRecord(file);
|
|
}
|
|
return records;
|
|
}
|
|
|
|
function compressCesiumGlb(area) {
|
|
ensureFile(area.outputs.glb, "Cesium GLB");
|
|
ensureFile(area.outputs.metadata, "Cesium metadata");
|
|
ensureFile(path.join(repoRoot, "scripts", "compress-glb.js"), "GLB compressor");
|
|
const started = Date.now();
|
|
const startedAt = new Date(started).toISOString();
|
|
fs.mkdirSync(area.outputs.pipelineDir, { recursive: true });
|
|
const temporaryDir = fs.mkdtempSync(path.join(area.outputs.pipelineDir, "compress-"));
|
|
|
|
// The compressor rejects an in-place transform. Preserve the exporter output
|
|
// in a temporary directory, then atomically promote the compressed delivery.
|
|
const sourceDir = path.join(temporaryDir, "source");
|
|
const outputDir = path.join(temporaryDir, "delivery");
|
|
const stagedSourceGlb = path.join(sourceDir, path.basename(area.outputs.glb));
|
|
const stagedSourceMetadata = path.join(sourceDir, path.basename(area.outputs.metadata));
|
|
const stagedDeliveryGlb = path.join(outputDir, path.basename(area.outputs.glb));
|
|
const stagedDeliveryMetadata = path.join(outputDir, path.basename(area.outputs.metadata));
|
|
try {
|
|
fs.mkdirSync(sourceDir, { recursive: true });
|
|
fs.copyFileSync(area.outputs.glb, stagedSourceGlb);
|
|
fs.copyFileSync(area.outputs.metadata, stagedSourceMetadata);
|
|
const sourceDigest = glbDigest(stagedSourceGlb);
|
|
const compressArgs = [
|
|
path.join(repoRoot, "scripts", "compress-glb.js"),
|
|
"--input", stagedSourceGlb,
|
|
"--output", stagedDeliveryGlb,
|
|
"--texture-size", String(area.compress.textureSize),
|
|
"--quality", String(area.compress.quality),
|
|
"--effort", String(area.compress.effort),
|
|
"--metadata", stagedSourceMetadata,
|
|
"--metadata-output", stagedDeliveryMetadata,
|
|
];
|
|
if (area.compress.meshopt) compressArgs.push("--meshopt");
|
|
|
|
console.log("Stage: compress (Cesium GLB texture resize + WebP)");
|
|
runCommand(process.execPath, compressArgs, "compress");
|
|
ensureFile(stagedDeliveryGlb, "Compressed delivery GLB");
|
|
ensureFile(stagedDeliveryMetadata, "Compressed delivery metadata");
|
|
const compressedDigest = glbDigest(stagedDeliveryGlb);
|
|
fs.renameSync(stagedDeliveryGlb, area.outputs.glb);
|
|
fs.renameSync(stagedDeliveryMetadata, area.outputs.metadata);
|
|
const finished = Date.now();
|
|
writeStageManifest(area, {
|
|
stage: "compress",
|
|
status: "ok",
|
|
config: configPath,
|
|
startedAt,
|
|
finishedAt: new Date(finished).toISOString(),
|
|
durationMs: finished - started,
|
|
inputs: {},
|
|
// package promotes these staged files, so only its manifest owns final paths.
|
|
outputs: {},
|
|
summary: {
|
|
sourceGlb: glbSummary(sourceDigest),
|
|
glb: glbSummary(compressedDigest),
|
|
budget: evaluateGlbBudget(compressedDigest, area.budget),
|
|
options: {
|
|
textureSize: area.compress.textureSize,
|
|
quality: area.compress.quality,
|
|
effort: area.compress.effort,
|
|
meshopt: area.compress.meshopt,
|
|
},
|
|
compressionRatio: Number((compressedDigest.fileBytes / sourceDigest.fileBytes).toFixed(4)),
|
|
savedBytes: sourceDigest.fileBytes - compressedDigest.fileBytes,
|
|
},
|
|
warnings: glbBudgetWarnings("Compressed", compressedDigest, area.budget),
|
|
});
|
|
} finally {
|
|
fs.rmSync(temporaryDir, { recursive: true, force: true });
|
|
}
|
|
}
|
|
|
|
function publishPackage(area, roadProvider) {
|
|
ensureFile(area.outputs.packageStagingManifest, "Staged package manifest");
|
|
const trafficSignalsSource = roadProvider === "native"
|
|
? path.join(area.outputs.nativeRoadDir, "traffic-signals.json")
|
|
: area.outputs.trafficSignals;
|
|
ensureFile(trafficSignalsSource, "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(trafficSignalsSource, 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`);
|
|
validateManifest(manifest, area.outputs.packageStagingDir);
|
|
const backup = `${area.outputs.packageDir}.previous`;
|
|
fs.rmSync(backup, { recursive: true, force: true });
|
|
try {
|
|
if (fs.existsSync(area.outputs.packageDir)) fs.renameSync(area.outputs.packageDir, backup);
|
|
fs.renameSync(area.outputs.packageStagingDir, area.outputs.packageDir);
|
|
fs.rmSync(backup, { recursive: true, force: true });
|
|
} catch (error) {
|
|
if (!fs.existsSync(area.outputs.packageDir) && fs.existsSync(backup)) fs.renameSync(backup, area.outputs.packageDir);
|
|
throw error;
|
|
}
|
|
const finished = Date.now();
|
|
writeStageManifest(area, {
|
|
stage: "package", status: "ok", config: configPath,
|
|
startedAt: new Date(started).toISOString(), finishedAt: new Date(finished).toISOString(), durationMs: finished - started,
|
|
inputs: { stagingManifest: fileRecord(path.join(area.outputs.packageDir, "manifest.json")) },
|
|
outputs: {
|
|
packageDir: fileRecord(area.outputs.packageDir),
|
|
manifest: fileRecord(area.outputs.packageManifest),
|
|
primaryGlb: fileRecord(area.outputs.packagePrimaryGlb),
|
|
},
|
|
summary: { assets: manifest.assets.length, packageDir: area.outputs.packageDir }, warnings: [],
|
|
});
|
|
}
|
|
|
|
function blenderExecutable(area) {
|
|
return resolveBlenderExecutable(area.blenderApp);
|
|
}
|
|
|
|
function ensureFile(file, label) {
|
|
if (!fs.existsSync(file)) {
|
|
throw new Error(`${label} not found: ${file}`);
|
|
}
|
|
}
|
|
|
|
function runCommand(command, commandArgs, stage) {
|
|
const result = spawnSync(command, commandArgs, { stdio: "inherit" });
|
|
if (result.error) {
|
|
throw result.error;
|
|
}
|
|
if (result.status !== 0) {
|
|
const signal = result.signal ? ` signal=${result.signal}` : "";
|
|
throw new Error(`Stage '${stage}' failed with status=${result.status}${signal}`);
|
|
}
|
|
}
|
|
|
|
function writeCesiumPreview(area, roadProvider) {
|
|
ensureFile(area.outputs.packageManifest, "Published asset package manifest");
|
|
ensureFile(area.outputs.packageTrafficSignals, "Published traffic signal anchors");
|
|
let vehicleRoute = null;
|
|
let routeArtifact = null;
|
|
const previewInputs = {
|
|
config: fileRecord(configPath),
|
|
osm: fileRecord(area.input),
|
|
previewCss: fileRecord(path.join(repoRoot, "scripts", "lib", "cesium-preview.css")),
|
|
previewJs: fileRecord(path.join(repoRoot, "scripts", "lib", "cesium-preview.js")),
|
|
v2xPreviewJs: fileRecord(path.join(repoRoot, "scripts", "lib", "v2x-cesium-overlay.js")),
|
|
};
|
|
if (roadProvider === "osm2streets") {
|
|
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");
|
|
ensureFile(lanePolygons, "Driving lane polygons");
|
|
ensureFile(network, "osm2streets network");
|
|
ensureFile(intersectionSurface, "Intersection surfaces");
|
|
vehicleRoute = buildPreviewVehicleRoute(area.input, lanePolygons, network, intersectionSurface);
|
|
Object.assign(previewInputs, {
|
|
lanePolygons: fileRecord(lanePolygons),
|
|
network: fileRecord(network),
|
|
intersectionSurface: fileRecord(intersectionSurface),
|
|
});
|
|
} else {
|
|
Object.assign(previewInputs, nativeRoadRecords(area));
|
|
// This operational preview intentionally has no synthetic vehicle source.
|
|
// A stale descriptor from an older build must not resurrect simulated routes.
|
|
fs.rmSync(area.outputs.trafficSimulation, { force: true });
|
|
}
|
|
const htmlPath = area.outputs.cesiumPreview;
|
|
const started = Date.now();
|
|
const startedAt = new Date(started).toISOString();
|
|
fs.mkdirSync(path.dirname(htmlPath), { recursive: true });
|
|
if (vehicleRoute) {
|
|
writeVehicleRoute(area, vehicleRoute);
|
|
} else {
|
|
// Do not let a route from an earlier legacy preview survive into native output.
|
|
fs.rmSync(area.outputs.vehicleRoute, { force: true });
|
|
}
|
|
const vehicleModelNames = writeVehicleModel(area);
|
|
writeCesiumPreviewSupportFiles(path.dirname(htmlPath));
|
|
const glbName = "package/manifest.json";
|
|
const metadataName = "package/manifest.json";
|
|
const routeName = vehicleRoute || routeArtifact
|
|
? previewRelativePath(area.outputs.areaDir, vehicleRoute ? area.outputs.vehicleRoute : routeArtifact)
|
|
: null;
|
|
const vehicleModelName = vehicleModelNames[0] ? `_preview/${vehicleModelNames[0]}` : null;
|
|
const descriptor = { routeName, vehicleModelName, 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.map((name) => `_preview/${name}`), "package/runtime/traffic-signals.json", "_preview/descriptor.json", area.v2xPreview));
|
|
console.log(`Cesium preview: ${htmlPath}`);
|
|
const finished = Date.now();
|
|
writeStageManifest(area, {
|
|
stage: "preview",
|
|
status: "ok",
|
|
config: configPath,
|
|
startedAt,
|
|
finishedAt: new Date(finished).toISOString(),
|
|
durationMs: finished - started,
|
|
inputs: {
|
|
...previewInputs,
|
|
glb: fileRecord(area.outputs.packagePrimaryGlb),
|
|
metadata: fileRecord(area.outputs.packageManifest),
|
|
},
|
|
outputs: {
|
|
cesiumPreview: fileRecord(area.outputs.cesiumPreview),
|
|
vehicleRoute: optionalFileRecord(area.outputs.vehicleRoute),
|
|
...(routeArtifact ? { trafficSimulation: fileRecord(routeArtifact) } : {}),
|
|
vehicleModel: fileRecord(area.outputs.vehicleModel),
|
|
trafficSignals: fileRecord(area.outputs.packageTrafficSignals),
|
|
},
|
|
summary: previewSummary(area, vehicleRoute ? area.outputs.vehicleRoute : routeArtifact),
|
|
warnings: [],
|
|
});
|
|
}
|
|
|
|
function writeTrafficSignals(area) {
|
|
const signals = readTrafficSignals(area.outputs.trafficSignalAssemblies, area.input);
|
|
fs.writeFileSync(area.outputs.trafficSignals, `${JSON.stringify(signals, null, 2)}\n`);
|
|
console.log(`Traffic signals: ${signals.signals.length} anchors in ${area.outputs.trafficSignals}`);
|
|
}
|
|
|
|
function previewRelativePath(fromDir, target) {
|
|
return path.relative(fromDir, target).split(path.sep).join("/");
|
|
}
|
|
|
|
function writeVehicleRoute(area, route) {
|
|
fs.mkdirSync(path.dirname(area.outputs.vehicleRoute), { recursive: true });
|
|
fs.writeFileSync(area.outputs.vehicleRoute, `${JSON.stringify(route, null, 2)}\n`);
|
|
console.log(`Vehicle route: ${area.outputs.vehicleRoute}`);
|
|
}
|
|
|
|
function writeVehicleModel(area) {
|
|
fs.mkdirSync(path.dirname(area.outputs.vehicleModel), { recursive: true });
|
|
const outDir = path.dirname(area.outputs.vehicleModel);
|
|
const fileStem = path.basename(area.outputs.vehicleModel, ".gltf").replace(/-vehicle-car$/, "");
|
|
const models = writePreviewVehicleLibrary(outDir, fileStem);
|
|
// 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;
|
|
}
|
|
|
|
function sceneGeojsonRecords(area) {
|
|
const records = {};
|
|
for (const layer of SCENE_LAYERS) {
|
|
records[layer.id] = fileRecord(path.join(area.outputs.geojsonDir, layerFile(layer)));
|
|
}
|
|
records[SCENE_FILE] = fileRecord(path.join(area.outputs.geojsonDir, SCENE_FILE));
|
|
records[SCENE_STYLE_FILE] = fileRecord(path.join(area.outputs.geojsonDir, SCENE_STYLE_FILE));
|
|
return records;
|
|
}
|
|
|
|
function geojsonFeatureCounts(area) {
|
|
const out = {};
|
|
for (const layer of SCENE_LAYERS) {
|
|
const file = path.join(area.outputs.geojsonDir, layerFile(layer));
|
|
out[layer.id] = featureCount(file);
|
|
}
|
|
out[SCENE_FILE] = featureCount(path.join(area.outputs.geojsonDir, SCENE_FILE));
|
|
return out;
|
|
}
|
|
|
|
function featureCount(file) {
|
|
const parsed = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
return Array.isArray(parsed.features) ? parsed.features.length : null;
|
|
}
|