Generate Cesium preview pages from area builds

This commit is contained in:
2026-07-27 13:06:57 +08:00
parent 61e4820a4c
commit a5c3ad3cf3
4 changed files with 143 additions and 4 deletions

View File

@@ -10,6 +10,7 @@
- `<area-id>.png`Blender 预览渲染
- `<area-id>.glb`Cesium 可加载的 3D 模型
- `<area-id>.json`Cesium 放置元数据和示例代码
- `<area-id>-cesium-preview.html`Cesium 本地预览页
- `osm2streets_web_out/`osm2streets GeoJSON 中间层
- `<area-id>.gpkg` / `<area-id>.qgz` / `<area-id>-preview.png`QGIS 调试资产
@@ -48,9 +49,10 @@ npm run build:area -- --config config/areas/hanyang-block.json
npm run build:area -- --config config/areas/nantaizi-lake-innovation-valley.json --stages intermediates
npm run build:area -- --config config/areas/nantaizi-lake-innovation-valley.json --stages blender
npm run build:area -- --config config/areas/nantaizi-lake-innovation-valley.json --stages cesium
npm run build:area -- --config config/areas/nantaizi-lake-innovation-valley.json --stages preview
```
`intermediates` 会生成 osm2streets GeoJSON、GeoPackage、QGIS 工程和 QGIS 预览图。`blender` 使用 OSM 和 osm2streets GeoJSON 生成 `.blend`/`.png``cesium``.blend` 导出 `.glb`/`.json`
`intermediates` 会生成 osm2streets GeoJSON、GeoPackage、QGIS 工程和 QGIS 预览图。`blender` 使用 OSM 和 osm2streets GeoJSON 生成 `.blend`/`.png``cesium``.blend` 导出 `.glb`/`.json`,并生成 Cesium 预览 HTML。`preview` 只在已有 `.glb/.json` 时补生成 HTML
## 区域配置
@@ -97,11 +99,21 @@ cp config/examples/template.json config/areas/my-area.json
"outputs": {
"areaDir": "/absolute/path/to/custom-area",
"blend": "/absolute/path/to/custom.blend",
"glb": "/absolute/path/to/custom.glb"
"glb": "/absolute/path/to/custom.glb",
"cesiumPreview": "/absolute/path/to/custom-preview.html"
}
}
```
预览 Cesium 页面时,需要在输出目录启动 HTTP 服务,避免浏览器拦截本地文件请求:
```bash
cd outputs/my-area
python3 -m http.server 8765
```
然后打开 `http://localhost:8765/my-area-cesium-preview.html`
## 已沉淀区域
- `config/areas/nantaizi-lake-innovation-valley.json`

View File

@@ -58,6 +58,7 @@ npm run build:area -- --config config/areas/nantaizi-lake-innovation-valley.json
GLB 使用以 OSM bounds 中心为原点的局部 ENU 坐标系X 东Y 北Z 上)。
使用配套的 JSON 元数据文件将模型放置到 Cesium 中。
区域管线的 `cesium` 阶段还会在输出目录生成 `<area-id>-cesium-preview.html`
导出脚本会:
- 应用网格修改器并创建 UV

View File

@@ -5,6 +5,7 @@
- 将项目主入口重构为区域资产管线:`scripts/build-area.js`
- 新增 `config/areas/nantaizi-lake-innovation-valley.json``config/areas/hanyang-block.json`,支持按 OSM 输入生成独立输出目录。
- `npm run build` 现在默认走区域资产管线;旧 QGIS 管线保留为 `npm run build:qgis`
- `cesium` 阶段会生成 `<area-id>-cesium-preview.html` 本地预览页。
- 修复 Blender 脚本对 `--tree-style``--office-overrides` 这类连字符参数的解析。
## 2026-07-24

View File

@@ -28,6 +28,9 @@ if (stages.blender) {
if (stages.cesium) {
exportCesium(area);
}
if (stages.preview) {
writeCesiumPreview(area);
}
console.log("Done.");
@@ -76,6 +79,9 @@ function normalizeAreaConfig(raw) {
render: path.resolve(outputOverrides.render || path.join(areaDir, `${fileStem}.png`)),
glb: path.resolve(outputOverrides.glb || path.join(areaDir, `${fileStem}.glb`)),
metadata: path.resolve(outputOverrides.metadata || path.join(areaDir, `${fileStem}.json`)),
cesiumPreview: path.resolve(
outputOverrides.cesiumPreview || path.join(areaDir, `${fileStem}-cesium-preview.html`),
),
pipelineDir: path.resolve(outputOverrides.pipelineDir || path.join(areaDir, "_pipeline")),
};
@@ -89,6 +95,7 @@ function normalizeAreaConfig(raw) {
intermediates: raw.stages?.intermediates ?? raw.stages?.qgis ?? true,
blender: raw.stages?.blender ?? true,
cesium: raw.stages?.cesium ?? true,
preview: false,
},
qgis: {
arrowScale: raw.qgis?.arrowScale ?? raw.arrowScale ?? 0.8,
@@ -141,12 +148,15 @@ function resolveStages(defaults, requested) {
scene: ["blender"],
cesium: ["cesium"],
glb: ["cesium"],
preview: ["preview"],
html: ["preview"],
cesiumPreview: ["preview"],
};
const out = { intermediates: false, blender: false, cesium: false };
const out = { intermediates: false, blender: false, cesium: false, preview: false };
for (const stage of requested) {
const mapped = aliases[stage];
if (!mapped) {
throw new Error(`Unknown stage '${stage}'. Use intermediates, blender, cesium, or all.`);
throw new Error(`Unknown stage '${stage}'. Use intermediates, blender, cesium, preview, or all.`);
}
for (const key of mapped) out[key] = true;
}
@@ -233,6 +243,7 @@ function exportCesium(area) {
"--metadata",
area.outputs.metadata,
], "cesium");
writeCesiumPreview(area);
}
function blenderExecutable(area) {
@@ -255,3 +266,117 @@ function runCommand(command, commandArgs, stage) {
throw new Error(`Stage '${stage}' failed with status=${result.status}${signal}`);
}
}
function writeCesiumPreview(area) {
ensureFile(area.outputs.glb, "Cesium GLB");
ensureFile(area.outputs.metadata, "Cesium metadata");
const htmlPath = area.outputs.cesiumPreview;
fs.mkdirSync(path.dirname(htmlPath), { recursive: true });
const glbName = path.basename(area.outputs.glb);
const metadataName = path.basename(area.outputs.metadata);
fs.writeFileSync(htmlPath, cesiumPreviewHtml(glbName, metadataName, area.id));
console.log(`Cesium preview: ${htmlPath}`);
}
function cesiumPreviewHtml(glbName, metadataName, areaId) {
return `<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>${escapeHtml(areaId)} Cesium Preview</title>
<script src="https://cdn.jsdelivr.net/npm/cesium@1.121.1/Build/Cesium/Cesium.js"></script>
<link href="https://cdn.jsdelivr.net/npm/cesium@1.121.1/Build/Cesium/Widgets/widgets.css" rel="stylesheet">
<style>
html, body, #cesiumContainer {
width: 100%;
height: 100%;
margin: 0;
overflow: hidden;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
#status {
position: absolute;
left: 12px;
bottom: 12px;
z-index: 1;
max-width: 520px;
padding: 8px 10px;
border-radius: 4px;
background: rgba(20, 24, 28, 0.78);
color: #fff;
font-size: 12px;
line-height: 1.45;
}
</style>
</head>
<body>
<div id="cesiumContainer"></div>
<div id="status">Loading ${escapeHtml(glbName)}...</div>
<script>
const statusEl = document.getElementById("status");
Cesium.Ion.defaultAccessToken = "";
async function main() {
const metadata = await fetch("${escapeJs(metadataName)}").then((r) => r.json());
const anchor = metadata.anchor || {};
const longitude = Number(anchor.longitude || 0);
const latitude = Number(anchor.latitude || 0);
const height = Number(anchor.height || 0);
const heading = Number(metadata.heading_correction_degrees || 0);
const viewer = new Cesium.Viewer("cesiumContainer", {
animation: false,
timeline: false,
baseLayerPicker: false,
geocoder: false,
navigationHelpButton: false,
sceneModePicker: false,
homeButton: true,
fullscreenButton: true,
infoBox: false,
selectionIndicator: false,
baseLayer: false
});
viewer.scene.globe.depthTestAgainstTerrain = true;
const p = Cesium.Cartesian3.fromDegrees(longitude, latitude, height);
const enu = Cesium.Transforms.eastNorthUpToFixedFrame(p);
const correction = Cesium.Matrix3.fromRotationZ(Cesium.Math.toRadians(heading));
const modelMatrix = Cesium.Matrix4.multiplyByMatrix3(enu, correction, new Cesium.Matrix4());
const model = await Cesium.Model.fromGltfAsync({
url: "${escapeJs(glbName)}",
modelMatrix,
scale: 1.0
});
viewer.scene.primitives.add(model);
const center = Cesium.Cartesian3.fromDegrees(longitude, latitude, height);
viewer.camera.flyToBoundingSphere(
new Cesium.BoundingSphere(center, 900.0),
{ duration: 0.0 }
);
statusEl.textContent = "${escapeJs(glbName)} | " + longitude.toFixed(6) + ", " + latitude.toFixed(6);
}
main().catch((error) => {
console.error(error);
statusEl.textContent = "Failed to load Cesium preview: " + error.message;
});
</script>
</body>
</html>
`;
}
function escapeHtml(value) {
return String(value)
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;");
}
function escapeJs(value) {
return String(value).replaceAll("\\", "\\\\").replaceAll('"', '\\"');
}