657 lines
26 KiB
JavaScript
Executable File
657 lines
26 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);
|
|
}
|
|
if (stages.preview) {
|
|
writeCesiumPreview(area);
|
|
}
|
|
|
|
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);
|
|
} 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", "intersection_surface.geojson", "sidewalk_surface.geojson", "lane_separators.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")),
|
|
nativeIntersectionSurface: fileRecord(path.join(root, "intersection_surface.geojson")),
|
|
nativeSidewalkSurface: fileRecord(path.join(root, "sidewalk_surface.geojson")),
|
|
nativeLaneSeparators: fileRecord(path.join(root, "lane_separators.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")),
|
|
intersectionSurface: featureCount(path.join(root, "intersection_surface.geojson")),
|
|
sidewalkSurface: featureCount(path.join(root, "sidewalk_surface.geojson")),
|
|
laneSeparators: featureCount(path.join(root, "lane_separators.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 });
|
|
if (roadProvider === "osm2streets") {
|
|
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,
|
|
];
|
|
if (roadProvider === "osm2streets") {
|
|
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");
|
|
if (roadProvider === "osm2streets") {
|
|
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),
|
|
...(roadProvider === "osm2streets" ? {
|
|
trafficSignalsDynamicGlb: fileRecord(area.outputs.trafficSignalsDynamicGlb),
|
|
} : {}),
|
|
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) {
|
|
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`);
|
|
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) {
|
|
ensureFile(area.outputs.packageManifest, "Published asset package manifest");
|
|
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");
|
|
ensureFile(lanePolygons, "Driving lane polygons");
|
|
ensureFile(network, "osm2streets network");
|
|
ensureFile(intersectionSurface, "Intersection surfaces");
|
|
// 在创建或覆盖任何 preview 产物前完成权威车道输入的解析与路线计算。
|
|
const vehicleRoute = buildPreviewVehicleRoute(area.input, lanePolygons, network, intersectionSurface);
|
|
const htmlPath = area.outputs.cesiumPreview;
|
|
const started = Date.now();
|
|
const startedAt = new Date(started).toISOString();
|
|
fs.mkdirSync(path.dirname(htmlPath), { recursive: true });
|
|
writeVehicleRoute(area, vehicleRoute);
|
|
const vehicleModelNames = writeVehicleModel(area);
|
|
writeCesiumPreviewSupportFiles(path.dirname(htmlPath));
|
|
const glbName = "package/manifest.json";
|
|
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: "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"));
|
|
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: {
|
|
config: fileRecord(configPath),
|
|
osm: fileRecord(area.input),
|
|
glb: fileRecord(area.outputs.packagePrimaryGlb),
|
|
metadata: fileRecord(area.outputs.packageManifest),
|
|
lanePolygons: fileRecord(lanePolygons),
|
|
network: fileRecord(network),
|
|
intersectionSurface: fileRecord(intersectionSurface),
|
|
previewCss: fileRecord(path.join(repoRoot, "scripts", "lib", "cesium-preview.css")),
|
|
previewJs: fileRecord(path.join(repoRoot, "scripts", "lib", "cesium-preview.js")),
|
|
},
|
|
outputs: {
|
|
cesiumPreview: fileRecord(area.outputs.cesiumPreview),
|
|
vehicleRoute: fileRecord(area.outputs.vehicleRoute),
|
|
vehicleModel: fileRecord(area.outputs.vehicleModel),
|
|
trafficSignals: fileRecord(area.outputs.packageTrafficSignals),
|
|
},
|
|
summary: previewSummary(area),
|
|
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;
|
|
}
|