feat(cli): add interactive area build menu

This commit is contained in:
2026-08-11 14:08:22 +08:00
parent d26921c6d1
commit b68be063ad
7 changed files with 517 additions and 55 deletions

View File

@@ -4,6 +4,7 @@ const fs = require("fs");
const path = require("path");
const { spawnSync } = require("child_process");
const { readAreaConfig } = require("./lib/area-config");
const { resolveStages } = require("./lib/build-stages");
const { blenderExecutable: resolveBlenderExecutable } = require("./lib/tool-paths");
const {
SCENE_LAYERS,
@@ -41,15 +42,6 @@ const requestedStages = args.stages
: null;
const stages = resolveStages(area.stages, requestedStages);
// intermediates deletes and rebuilds the GeoPackage from OSM, which is exactly
// the manual work reimport exists to recover. Refuse the combination instead of
// silently letting one undo the other.
if (stages.intermediates && stages.reimport) {
throw new Error(
"Stages 'intermediates' and 'reimport' are mutually exclusive: " +
"intermediates rebuilds the GeoPackage from OSM and would discard the QGIS edits reimport reads back.",
);
}
console.log(`Area: ${area.id}`);
console.log(`Config: ${configPath}`);
@@ -100,49 +92,6 @@ function splitList(value) {
.filter(Boolean);
}
function resolveStages(defaults, requested) {
if (!requested) return defaults;
// 'reimport' is deliberately absent from 'all': it is a recovery step for
// hand-edited GeoPackages, never part of a full build.
// 'compress' is also absent from 'all': it creates an alternate Cesium GLB,
// not the baseline asset.
const aliases = {
all: ["intermediates", "blender", "cesium"],
qgis: ["intermediates"],
osm2streets: ["intermediates"],
geojson: ["intermediates"],
intermediate: ["intermediates"],
intermediates: ["intermediates"],
reimport: ["reimport"],
gpkg: ["reimport"],
blender: ["blender"],
scene: ["blender"],
cesium: ["cesium"],
glb: ["cesium"],
preview: ["preview"],
html: ["preview"],
cesiumPreview: ["preview"],
compress: ["compress"],
compression: ["compress"],
compressedCesium: ["compress"],
};
const out = {
intermediates: false,
reimport: false,
blender: false,
cesium: false,
preview: false,
compress: false,
};
for (const stage of requested) {
const mapped = aliases[stage];
if (!mapped) {
throw new Error(`Unknown stage '${stage}'. Use intermediates, reimport, blender, cesium, preview, compress, or all.`);
}
for (const key of mapped) out[key] = true;
}
return out;
}
function writeDerivedConfig(area) {
fs.mkdirSync(area.outputs.pipelineDir, { recursive: true });

View File

@@ -0,0 +1,49 @@
#!/usr/bin/env node
"use strict";
const fs = require("fs");
const path = require("path");
const { spawnSync } = require("child_process");
const { STAGES, resolveStages, canonicalStages } = require("./lib/build-stages");
const repoRoot = path.resolve(__dirname, "..");
function areaConfigs() {
return fs.readdirSync(path.join(repoRoot, "config", "areas"))
.filter((file) => file.endsWith(".json"))
.sort()
.map((file) => path.join(repoRoot, "config", "areas", file));
}
function requireTty(input = process.stdin, output = process.stdout) {
if (!input.isTTY || !output.isTTY) throw new Error("Interactive build requires a TTY. Use npm run build:area -- --config <area> --stages <stages> instead.");
}
async function main() {
requireTty();
const configs = areaConfigs();
if (!configs.length) throw new Error("No area configs found in config/areas.");
const { select, checkbox } = await import("@inquirer/prompts");
const config = await select({
message: "Choose an area",
choices: configs.map((file) => ({ name: path.basename(file, ".json"), value: file })),
});
const chosen = await checkbox({
message: "Choose build stages",
choices: STAGES.map((stage) => ({ name: `${stage.id} - ${stage.description}`, value: stage.id })),
required: true,
});
if (!chosen.length) throw new Error("Choose at least one build stage.");
const stages = canonicalStages(resolveStages({}, chosen));
console.log(`Area: ${path.basename(config)}`);
console.log(`Stages: ${stages.join(", ")}`);
const result = spawnSync(process.execPath, [path.join(__dirname, "build-area.js"), "--config", config, "--stages", stages.join(",")], { stdio: "inherit" });
if (result.error) throw result.error;
process.exitCode = result.status || 0;
}
if (require.main === module) main().catch((error) => {
if (error.name !== "ExitPromptError") console.error(`Error: ${error.message}`);
process.exitCode = error.name === "ExitPromptError" ? 0 : 1;
});
module.exports = { areaConfigs, requireTty };

View File

@@ -0,0 +1,41 @@
"use strict";
const STAGES = [
{ id: "intermediates", label: "OSM / QGIS intermediates", description: "Rebuild GeoJSON and GeoPackage from OSM" },
{ id: "reimport", label: "Reimport QGIS edits", description: "Copy GeoPackage layers back to GeoJSON" },
{ id: "blender", label: "Blender scene", description: "Generate the editable scene and render" },
{ id: "cesium", label: "Cesium export", description: "Export GLB, metadata, and preview" },
{ id: "preview", label: "Preview refresh", description: "Regenerate preview HTML and vehicles" },
{ id: "compress", label: "Compressed preview", description: "Create optional compressed GLB artifacts" },
];
const ALIASES = {
all: ["intermediates", "blender", "cesium"],
qgis: ["intermediates"], osm2streets: ["intermediates"], geojson: ["intermediates"], intermediate: ["intermediates"],
intermediates: ["intermediates"], reimport: ["reimport"], gpkg: ["reimport"], blender: ["blender"], scene: ["blender"],
cesium: ["cesium"], glb: ["cesium"], preview: ["preview"], html: ["preview"], cesiumPreview: ["preview"],
compress: ["compress"], compression: ["compress"], compressedCesium: ["compress"],
};
function resolveStages(defaults, requested) {
const selected = requested || Object.keys(defaults).filter((key) => defaults[key]);
const result = Object.fromEntries(STAGES.map(({ id }) => [id, false]));
for (const stage of selected) {
const mapped = ALIASES[stage];
if (!mapped) throw new Error(`Unknown stage '${stage}'. Use ${stageNames().join(", ")}, or all.`);
for (const id of mapped) result[id] = true;
}
assertCompatible(result);
return result;
}
function assertCompatible(stages) {
if (stages.intermediates && stages.reimport) {
throw new Error("Stages 'intermediates' and 'reimport' are mutually exclusive: intermediates rebuilds the GeoPackage from OSM and would discard the QGIS edits reimport reads back.");
}
}
function stageNames() { return STAGES.map(({ id }) => id); }
function canonicalStages(stages) { return stageNames().filter((id) => stages[id]); }
module.exports = { STAGES, resolveStages, canonicalStages, stageNames };

View File

@@ -0,0 +1,8 @@
#!/usr/bin/env node
"use strict";
const assert = require("assert");
const { resolveStages, canonicalStages } = require("./lib/build-stages");
assert.deepEqual(canonicalStages(resolveStages({}, ["compress", "blender", "preview"])), ["blender", "preview", "compress"]);
assert.deepEqual(canonicalStages(resolveStages({}, ["all"])), ["intermediates", "blender", "cesium"]);
assert.throws(() => resolveStages({}, ["intermediates", "reimport"]), /mutually exclusive/);
console.log("Build stage tests passed.");