#!/usr/bin/env node const fs = require("fs"); const path = require("path"); const { spawnSync } = require("child_process"); const { readAreaConfig } = require("./lib/area-config"); const { importNativeRoadPackage } = require("./lib/native-road-package"); 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, writePreviewV2xVehicleLibrary } = 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 }); const nativePackage = roadProvider === "native" ? importNativeRoadPackage(area) : null; 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", nativePackage.rootDir); blenderArgs.push("--traffic-signals", path.join(nativePackage.rootDir, "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(nativePackage) : 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(nativePackage) } : { geojson: geojsonFeatureCounts(area) }), roadProvider, blendBytes: fileRecord(area.outputs.blend).bytes, renderBytes: fileRecord(area.outputs.render).bytes, }, warnings: [], }); } function nativeRoadRecords(nativePackage) { const root = path.join(nativePackage.rootDir, "layers"); return { nativeRoadZip: nativePackage.zipRecord, nativeRoadImport: fileRecord(nativePackage.rootDir), nativeRoadCompiled: fileRecord(path.join(nativePackage.rootDir, "compiled.json")), nativeRoadSignals: fileRecord(path.join(nativePackage.rootDir, "traffic-signals.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(nativePackage) { const root = path.join(nativePackage.rootDir, "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 nativePackage = roadProvider === "native" ? importNativeRoadPackage(area) : null; const trafficSignalsSource = nativePackage ? path.join(nativePackage.rootDir, "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(importNativeRoadPackage(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); 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 + v2xModels.length} candidates in ${path.dirname(area.outputs.vehicleModel)}`); return [...models, ...v2xModels]; } 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; }