diff --git a/README.md b/README.md
index 71e2ff9..8bffc2c 100644
--- a/README.md
+++ b/README.md
@@ -37,12 +37,14 @@ npm install
## 主流程
-默认构建南台子湖创新谷样例:
+交互式选择区域和构建阶段:
```bash
npm run build
```
+使用方向键移动、空格多选阶段、回车开始构建;`intermediates` 与 `reimport` 不能同时选择。
+
指定区域配置:
```bash
@@ -310,7 +312,7 @@ npm run compress:glb -- \
南台子湖创新谷当前可直接使用下面这条命令:
```bash
-npm run build -- --config config/areas/nantaizi-lake-innovation-valley.json --stages reimport,blender,cesium
+npm run build:area -- --config config/areas/nantaizi-lake-innovation-valley.json --stages reimport,blender,cesium
```
`reimport` 阶段(`scripts/reimport-gpkg.js`)做两件事:
diff --git a/package-lock.json b/package-lock.json
index c180020..e627584 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -8,14 +8,425 @@
"name": "osm-asset-pipeline",
"version": "0.3.0",
"dependencies": {
+ "@inquirer/prompts": "^8.5.2",
"osm2streets-js-node": "0.1.4"
}
},
+ "node_modules/@inquirer/ansi": {
+ "version": "2.0.7",
+ "resolved": "http://172.16.1.86:4873/@inquirer/ansi/-/ansi-2.0.7.tgz",
+ "integrity": "sha512-3eTuUO1vH2cZm2ZKHeQxnOqlTi9EfZDGgIe3BL3I4u+rJHocr9Fz86M4fjYABPvFnQG/gGK551HqDiIcETwU6Q==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=23.5.0 || ^22.13.0 || ^20.17.0"
+ }
+ },
+ "node_modules/@inquirer/checkbox": {
+ "version": "5.2.1",
+ "resolved": "http://172.16.1.86:4873/@inquirer/checkbox/-/checkbox-5.2.1.tgz",
+ "integrity": "sha512-b6xmA/VlTe0ZgDQHDui+Nav470u7u49nRd8/iuhOcQPO9Ch7lGuogydhi2VOmNlZ+zXcM8IcPuNSwQcdJaF/kw==",
+ "license": "MIT",
+ "dependencies": {
+ "@inquirer/ansi": "^2.0.7",
+ "@inquirer/core": "^11.2.1",
+ "@inquirer/figures": "^2.0.7",
+ "@inquirer/type": "^4.0.7"
+ },
+ "engines": {
+ "node": ">=23.5.0 || ^22.13.0 || ^20.17.0"
+ },
+ "peerDependencies": {
+ "@types/node": ">=18"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@inquirer/confirm": {
+ "version": "6.1.1",
+ "resolved": "http://172.16.1.86:4873/@inquirer/confirm/-/confirm-6.1.1.tgz",
+ "integrity": "sha512-eb8DBZcz/2qHWQda4rk2JiQk5h9QV/cVHi1yjt0f69WFZMRFn0sJTye3EAP8icut8UDMjQPsaH5KbcOogefrFQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@inquirer/core": "^11.2.1",
+ "@inquirer/type": "^4.0.7"
+ },
+ "engines": {
+ "node": ">=23.5.0 || ^22.13.0 || ^20.17.0"
+ },
+ "peerDependencies": {
+ "@types/node": ">=18"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@inquirer/core": {
+ "version": "11.2.1",
+ "resolved": "http://172.16.1.86:4873/@inquirer/core/-/core-11.2.1.tgz",
+ "integrity": "sha512-Qd6GJT1yVyrZZCfN8W2qKF5ApmqryXRhRKCuip8h01x2w/esJQ2XIYc6f9abMIHgKQdBfFTSOdbHRLAhuM09UA==",
+ "license": "MIT",
+ "dependencies": {
+ "@inquirer/ansi": "^2.0.7",
+ "@inquirer/figures": "^2.0.7",
+ "@inquirer/type": "^4.0.7",
+ "cli-width": "^4.1.0",
+ "fast-wrap-ansi": "^0.2.0",
+ "mute-stream": "^3.0.0",
+ "signal-exit": "^4.1.0"
+ },
+ "engines": {
+ "node": ">=23.5.0 || ^22.13.0 || ^20.17.0"
+ },
+ "peerDependencies": {
+ "@types/node": ">=18"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@inquirer/editor": {
+ "version": "5.2.2",
+ "resolved": "http://172.16.1.86:4873/@inquirer/editor/-/editor-5.2.2.tgz",
+ "integrity": "sha512-ZRVd/oD+sYsUd5zVm0NflqEzlqfYCyHNsqkHl2oWXEUHs12tCbcSFi+wVFEvD8+LGRaMUsVrE7qeo6lSG/S1Vg==",
+ "license": "MIT",
+ "dependencies": {
+ "@inquirer/core": "^11.2.1",
+ "@inquirer/external-editor": "^3.0.3",
+ "@inquirer/type": "^4.0.7"
+ },
+ "engines": {
+ "node": ">=23.5.0 || ^22.13.0 || ^20.17.0"
+ },
+ "peerDependencies": {
+ "@types/node": ">=18"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@inquirer/expand": {
+ "version": "5.1.1",
+ "resolved": "http://172.16.1.86:4873/@inquirer/expand/-/expand-5.1.1.tgz",
+ "integrity": "sha512-YmQpenjbFSHAK3sOd44puHh3V1KXXr+JiNpUztoSQ4drLh2rTVzTap/YtlAVu/5xavifIlBfNEzJ/neZJ1a/1g==",
+ "license": "MIT",
+ "dependencies": {
+ "@inquirer/core": "^11.2.1",
+ "@inquirer/type": "^4.0.7"
+ },
+ "engines": {
+ "node": ">=23.5.0 || ^22.13.0 || ^20.17.0"
+ },
+ "peerDependencies": {
+ "@types/node": ">=18"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@inquirer/external-editor": {
+ "version": "3.0.3",
+ "resolved": "http://172.16.1.86:4873/@inquirer/external-editor/-/external-editor-3.0.3.tgz",
+ "integrity": "sha512-6thf5I8q7lZwzGLAxPaaGEREEkZ3nyePPDQ1oyobblxmEE8mqTLguScP7pDjUTAibiyb4hfXl+qjUEJ+di/aNA==",
+ "license": "MIT",
+ "dependencies": {
+ "chardet": "^2.1.1",
+ "iconv-lite": "^0.7.2"
+ },
+ "engines": {
+ "node": ">=23.5.0 || ^22.13.0 || ^20.17.0"
+ },
+ "peerDependencies": {
+ "@types/node": ">=18"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@inquirer/figures": {
+ "version": "2.0.7",
+ "resolved": "http://172.16.1.86:4873/@inquirer/figures/-/figures-2.0.7.tgz",
+ "integrity": "sha512-aJ8TBPOGB6f/2qziPfElISTCEd5XOYTFckA2SGjhNmiKzfK/u4ot3v0DUzGVdUnKjN10EqnnEPck36BkyfLnJw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=23.5.0 || ^22.13.0 || ^20.17.0"
+ }
+ },
+ "node_modules/@inquirer/input": {
+ "version": "5.1.2",
+ "resolved": "http://172.16.1.86:4873/@inquirer/input/-/input-5.1.2.tgz",
+ "integrity": "sha512-9K/DDBSQpOyZSkt6sOVP9Vo0TR7atX2kuILsUu0x3wVcVbe97lJwIJKMLdMw25tDYuXl/qp6erT0Xs1rfmcfZg==",
+ "license": "MIT",
+ "dependencies": {
+ "@inquirer/core": "^11.2.1",
+ "@inquirer/type": "^4.0.7"
+ },
+ "engines": {
+ "node": ">=23.5.0 || ^22.13.0 || ^20.17.0"
+ },
+ "peerDependencies": {
+ "@types/node": ">=18"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@inquirer/number": {
+ "version": "4.1.1",
+ "resolved": "http://172.16.1.86:4873/@inquirer/number/-/number-4.1.1.tgz",
+ "integrity": "sha512-XF4IXAbPnGPgw0wsbC/i2tPcyfdZgDpUlhsqU0SfT4IRIGWha6Xm9VRgN5yYxJq+jnyXlfXI/nQ3ulfk0iEICA==",
+ "license": "MIT",
+ "dependencies": {
+ "@inquirer/core": "^11.2.1",
+ "@inquirer/type": "^4.0.7"
+ },
+ "engines": {
+ "node": ">=23.5.0 || ^22.13.0 || ^20.17.0"
+ },
+ "peerDependencies": {
+ "@types/node": ">=18"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@inquirer/password": {
+ "version": "5.1.1",
+ "resolved": "http://172.16.1.86:4873/@inquirer/password/-/password-5.1.1.tgz",
+ "integrity": "sha512-3XBfF7DAsp5qeDsvN5Rd1HmbNokVvEQoUM0QLrRcybC9nX96w3Pbmu7qUsb3IT3J3jBvs2+mTXaKHOUsgHMLzg==",
+ "license": "MIT",
+ "dependencies": {
+ "@inquirer/ansi": "^2.0.7",
+ "@inquirer/core": "^11.2.1",
+ "@inquirer/type": "^4.0.7"
+ },
+ "engines": {
+ "node": ">=23.5.0 || ^22.13.0 || ^20.17.0"
+ },
+ "peerDependencies": {
+ "@types/node": ">=18"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@inquirer/prompts": {
+ "version": "8.5.2",
+ "resolved": "http://172.16.1.86:4873/@inquirer/prompts/-/prompts-8.5.2.tgz",
+ "integrity": "sha512-IYR/3C/paEVVQYQvdDlFZVjRCJVYHHON0XXMH91KO9GSxs0TdKYWlUdvfQl2EfAHDxUaN3IBffkE/BDTh5nJ6g==",
+ "license": "MIT",
+ "dependencies": {
+ "@inquirer/checkbox": "^5.2.1",
+ "@inquirer/confirm": "^6.1.1",
+ "@inquirer/editor": "^5.2.2",
+ "@inquirer/expand": "^5.1.1",
+ "@inquirer/input": "^5.1.2",
+ "@inquirer/number": "^4.1.1",
+ "@inquirer/password": "^5.1.1",
+ "@inquirer/rawlist": "^5.3.1",
+ "@inquirer/search": "^4.2.1",
+ "@inquirer/select": "^5.2.1"
+ },
+ "engines": {
+ "node": ">=23.5.0 || ^22.13.0 || ^20.17.0"
+ },
+ "peerDependencies": {
+ "@types/node": ">=18"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@inquirer/rawlist": {
+ "version": "5.3.1",
+ "resolved": "http://172.16.1.86:4873/@inquirer/rawlist/-/rawlist-5.3.1.tgz",
+ "integrity": "sha512-QqdTqQddL3qPX/PPrjobpsO25NZ4dWXgTLenrR445L2ptLEYE6Z+PD5c5CNDJNx4ugRgELAIpSIJxZaO2jJ2Og==",
+ "license": "MIT",
+ "dependencies": {
+ "@inquirer/core": "^11.2.1",
+ "@inquirer/type": "^4.0.7"
+ },
+ "engines": {
+ "node": ">=23.5.0 || ^22.13.0 || ^20.17.0"
+ },
+ "peerDependencies": {
+ "@types/node": ">=18"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@inquirer/search": {
+ "version": "4.2.1",
+ "resolved": "http://172.16.1.86:4873/@inquirer/search/-/search-4.2.1.tgz",
+ "integrity": "sha512-xJj8QWKRSrfKoBIITLZK61dD3zwo0Rz11fgDImku30/Oe81zMdIdGgrLY2h6RkJ+KZ/GhNYIRMKnH/62qBTA5g==",
+ "license": "MIT",
+ "dependencies": {
+ "@inquirer/core": "^11.2.1",
+ "@inquirer/figures": "^2.0.7",
+ "@inquirer/type": "^4.0.7"
+ },
+ "engines": {
+ "node": ">=23.5.0 || ^22.13.0 || ^20.17.0"
+ },
+ "peerDependencies": {
+ "@types/node": ">=18"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@inquirer/select": {
+ "version": "5.2.1",
+ "resolved": "http://172.16.1.86:4873/@inquirer/select/-/select-5.2.1.tgz",
+ "integrity": "sha512-FlDndEUww8m7BfukO2nJa25vhD+H5jxxCv4oGioKqzyWz3nPHhhw4LKdYRSlXuAx7DsdWia7iyaBPKKS95Evfw==",
+ "license": "MIT",
+ "dependencies": {
+ "@inquirer/ansi": "^2.0.7",
+ "@inquirer/core": "^11.2.1",
+ "@inquirer/figures": "^2.0.7",
+ "@inquirer/type": "^4.0.7"
+ },
+ "engines": {
+ "node": ">=23.5.0 || ^22.13.0 || ^20.17.0"
+ },
+ "peerDependencies": {
+ "@types/node": ">=18"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@inquirer/type": {
+ "version": "4.0.7",
+ "resolved": "http://172.16.1.86:4873/@inquirer/type/-/type-4.0.7.tgz",
+ "integrity": "sha512-t28inv14nMQ1PhKpsJPY+kEs/c00qzeCOS2gTNRyTjG5d6qsVA2fItxW4hkvGZ5lvanGLdtCzVIx5dwdRpN1+g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=23.5.0 || ^22.13.0 || ^20.17.0"
+ },
+ "peerDependencies": {
+ "@types/node": ">=18"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/chardet": {
+ "version": "2.2.0",
+ "resolved": "http://172.16.1.86:4873/chardet/-/chardet-2.2.0.tgz",
+ "integrity": "sha512-rddelWYNPRrXq6PtNEN2S3f6t9ILzvqaN5pVgi4kqt9jHQaXIial9PznB5iSPVlQSLNaaH22ItWz3EJtQ10+OA==",
+ "license": "MIT"
+ },
+ "node_modules/cli-width": {
+ "version": "4.1.0",
+ "resolved": "http://172.16.1.86:4873/cli-width/-/cli-width-4.1.0.tgz",
+ "integrity": "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==",
+ "license": "ISC",
+ "engines": {
+ "node": ">= 12"
+ }
+ },
+ "node_modules/fast-string-truncated-width": {
+ "version": "3.0.3",
+ "resolved": "http://172.16.1.86:4873/fast-string-truncated-width/-/fast-string-truncated-width-3.0.3.tgz",
+ "integrity": "sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g==",
+ "license": "MIT"
+ },
+ "node_modules/fast-string-width": {
+ "version": "3.0.2",
+ "resolved": "http://172.16.1.86:4873/fast-string-width/-/fast-string-width-3.0.2.tgz",
+ "integrity": "sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==",
+ "license": "MIT",
+ "dependencies": {
+ "fast-string-truncated-width": "^3.0.2"
+ }
+ },
+ "node_modules/fast-wrap-ansi": {
+ "version": "0.2.2",
+ "resolved": "http://172.16.1.86:4873/fast-wrap-ansi/-/fast-wrap-ansi-0.2.2.tgz",
+ "integrity": "sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q==",
+ "license": "MIT",
+ "dependencies": {
+ "fast-string-width": "^3.0.2"
+ }
+ },
+ "node_modules/iconv-lite": {
+ "version": "0.7.3",
+ "resolved": "http://172.16.1.86:4873/iconv-lite/-/iconv-lite-0.7.3.tgz",
+ "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==",
+ "license": "MIT",
+ "dependencies": {
+ "safer-buffer": ">= 2.1.2 < 3.0.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/mute-stream": {
+ "version": "3.0.0",
+ "resolved": "http://172.16.1.86:4873/mute-stream/-/mute-stream-3.0.0.tgz",
+ "integrity": "sha512-dkEJPVvun4FryqBmZ5KhDo0K9iDXAwn08tMLDinNdRBNPcYEDiWYysLcc6k3mjTMlbP9KyylvRpd4wFtwrT9rw==",
+ "license": "ISC",
+ "engines": {
+ "node": "^20.17.0 || >=22.9.0"
+ }
+ },
"node_modules/osm2streets-js-node": {
"version": "0.1.4",
"resolved": "https://registry.npmjs.org/osm2streets-js-node/-/osm2streets-js-node-0.1.4.tgz",
"integrity": "sha512-JjS6qJJjrrlEKEZxqV4s/N4qU0YfvdMUasdvfoyYCmZxEpNKAYjpuZeWJWeLEcGOqhu+YuV3YDIk9jgvMU0kKQ==",
"license": "Apache-2.0"
+ },
+ "node_modules/safer-buffer": {
+ "version": "2.1.2",
+ "resolved": "http://172.16.1.86:4873/safer-buffer/-/safer-buffer-2.1.2.tgz",
+ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
+ "license": "MIT"
+ },
+ "node_modules/signal-exit": {
+ "version": "4.1.0",
+ "resolved": "http://172.16.1.86:4873/signal-exit/-/signal-exit-4.1.0.tgz",
+ "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=14"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
}
}
}
diff --git a/package.json b/package.json
index 7190021..e34ad7a 100644
--- a/package.json
+++ b/package.json
@@ -4,7 +4,7 @@
"private": true,
"type": "commonjs",
"scripts": {
- "build": "node scripts/build-area.js",
+ "build": "node scripts/interactive-build.js",
"build:area": "node scripts/build-area.js",
"build:qgis": "node scripts/build-osm2streets-qgis.js",
"check:area": "node scripts/check-area.js",
@@ -12,6 +12,7 @@
"diagnose:area": "node scripts/diagnose-area.js",
"preflight:area": "node scripts/preflight-area.js",
"test:preflight": "node scripts/test-area-preflight.js",
+ "test:build-stages": "node scripts/test-build-stages.js",
"test:budgets": "node scripts/test-asset-budgets.js",
"test:preview-assets": "node scripts/test-preview-assets.js",
"test:compress-glb": "node scripts/test-compress-glb.js",
@@ -20,6 +21,7 @@
"render:turn-lane-arrow-samples": "node scripts/render-turn-lane-arrow-samples.js"
},
"dependencies": {
+ "@inquirer/prompts": "^8.5.2",
"osm2streets-js-node": "0.1.4"
}
}
diff --git a/scripts/build-area.js b/scripts/build-area.js
index 6b690c6..8308a95 100755
--- a/scripts/build-area.js
+++ b/scripts/build-area.js
@@ -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 });
diff --git a/scripts/interactive-build.js b/scripts/interactive-build.js
new file mode 100644
index 0000000..5e041e8
--- /dev/null
+++ b/scripts/interactive-build.js
@@ -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 --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 };
diff --git a/scripts/lib/build-stages.js b/scripts/lib/build-stages.js
new file mode 100644
index 0000000..53dff31
--- /dev/null
+++ b/scripts/lib/build-stages.js
@@ -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 };
diff --git a/scripts/test-build-stages.js b/scripts/test-build-stages.js
new file mode 100644
index 0000000..bfd63e5
--- /dev/null
+++ b/scripts/test-build-stages.js
@@ -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.");