Files
osmWorkflow/scripts/build-area.js

684 lines
28 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 { buildNativeTrafficSimulation } = require("./lib/native-preview-traffic-simulation");
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));
const simulation = buildNativeTrafficSimulation(area);
fs.mkdirSync(path.dirname(area.outputs.trafficSimulation), { recursive: true });
fs.writeFileSync(area.outputs.trafficSimulation, `${JSON.stringify(simulation, null, 2)}\n`);
previewInputs.trafficSimulation = fileRecord(area.outputs.trafficSimulation);
routeArtifact = area.outputs.trafficSimulation;
}
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 = previewRelativePath(area.outputs.areaDir, area.outputs.vehicleModel);
const descriptor = { routeName, 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.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;
}