Add optional GLB compression stage
This commit is contained in:
@@ -12,7 +12,7 @@
|
||||
|
||||
```
|
||||
config/areas/<id>.json ← 你写的
|
||||
│ build-area.js: normalizeAreaConfig() 补默认值 + 推导 14 个输出路径
|
||||
│ build-area.js: normalizeAreaConfig() 补默认值 + 推导输出路径
|
||||
▼
|
||||
<areaDir>/_pipeline/osm2streets-qgis.config.json ← 生成的,不要手改
|
||||
│
|
||||
@@ -52,6 +52,7 @@ cp config/examples/template.json config/areas/my-area.json
|
||||
| `qgis` | | 见下 | QGIS/osm2streets 旋钮 |
|
||||
| `osm2streets` | | 见下 | 透传给 osm2streets 的选项 |
|
||||
| `blender` | | 见下 | Blender 侧选项 |
|
||||
| `compress` | | 见下 | 显式 `compress` 阶段的 GLB 压缩选项 |
|
||||
| `outputs` | | 从 `id` 推导 | 输出路径覆盖,逃生舱 |
|
||||
|
||||
**路径一律绝对**。`normalizeAreaConfig` 对每一项都做 `path.resolve`,相对路径会
|
||||
@@ -65,10 +66,11 @@ cp config/examples/template.json config/areas/my-area.json
|
||||
| `blender` | `true` | |
|
||||
| `cesium` | `true` | |
|
||||
|
||||
`reimport` 和 `preview` **在这里配也没用**——`normalizeAreaConfig:117-118` 把它们
|
||||
`reimport`、`preview` 和 `compress` **在这里配也没用**——`normalizeAreaConfig` 把它们
|
||||
硬编码为 `false`,只能靠 `--stages` 显式请求。
|
||||
|
||||
> 恢复动作(reimport)和补丁动作(preview)不该被一份配置文件变成默认行为。
|
||||
> 恢复动作(reimport)、补丁动作(preview)和替代产物动作(compress)不该被一份
|
||||
> 配置文件变成默认行为。
|
||||
|
||||
`--stages` 会整体覆盖这里的默认值。
|
||||
|
||||
@@ -117,6 +119,18 @@ cp config/examples/template.json config/areas/my-area.json
|
||||
| `treeStyle` | `"natural"` | 合法值见 `generate_scene.py` 的 `TREE_STYLES`(`natural`、`procedural`、`shapespark`) |
|
||||
| `officeOverrides` | `""` | 旧名 `office_overrides` 仍被接受 |
|
||||
|
||||
### `compress`
|
||||
|
||||
只影响显式 `--stages compress`。默认压缩链是 texture resize + WebP transcode,
|
||||
不覆盖默认 `<area-id>.glb`。
|
||||
|
||||
| 字段 | 默认 | 说明 |
|
||||
|---|---|---|
|
||||
| `textureSize` | `768` | 最大纹理宽高,范围 `64..4096` |
|
||||
| `quality` | `82` | WebP 质量,范围 `1..100` |
|
||||
| `effort` | `80` | WebP 编码 effort,范围 `0..100` |
|
||||
| `meshopt` | `false` | 是否追加 `EXT_meshopt_compression`。开启前要单独验证 Cesium 兼容性 |
|
||||
|
||||
### `outputs`(逃生舱)
|
||||
|
||||
默认全部从 `id` 推导为 `<outputRoot>/<id>/<fileStem>.<ext>`。需要定制时逐项覆盖:
|
||||
@@ -134,6 +148,7 @@ cp config/examples/template.json config/areas/my-area.json
|
||||
|
||||
可覆盖的键(`build-area.js:87-102`):`areaDir`、`fileStem`、`geojsonDir`、`gpkg`、
|
||||
`qgisProject`、`qgisPreview`、`blend`、`render`、`glb`、`metadata`、`cesiumPreview`、
|
||||
`compressedFileStem`、`compressedGlb`、`compressedMetadata`、`compressedCesiumPreview`、
|
||||
`vehicleRoute`、`vehicleModel`、`pipelineDir`。
|
||||
|
||||
**优先改 `fileStem` 或 `areaDir`**——它们能一次性影响全部派生路径。逐个覆盖容易漏。
|
||||
@@ -175,7 +190,7 @@ cp config/examples/template.json config/areas/my-area.json
|
||||
| 布尔字段用 `\|\|` 兜底 | `false` 被翻转 |
|
||||
| 给新字段造顶层平铺别名 | 扩大历史包袱 |
|
||||
| 逐个覆盖 `outputs` 而不用 `fileStem` | 漏掉某个产物路径 |
|
||||
| 在 `stages` 里配 `reimport` / `preview` | 无效,被硬编码为 false |
|
||||
| 在 `stages` 里配 `reimport` / `preview` / `compress` | 无效,被硬编码为 false |
|
||||
| 加数值字段不做范围校验 | 错配置在中途才崩,输出已被破坏 |
|
||||
|
||||
---
|
||||
|
||||
@@ -18,6 +18,105 @@
|
||||
全部是 CommonJS(`package.json` 的 `"type": "commonjs"`),无构建步骤、无 TypeScript、
|
||||
零运行时依赖(唯一依赖 `osm2streets-js-node` 只被 `build-osm2streets-qgis.js` 用)。
|
||||
|
||||
## 可选 GLB 压缩阶段
|
||||
|
||||
### 1. Scope / Trigger
|
||||
|
||||
`compress` 是显式请求的替代产物阶段,不属于 `all`,也不覆盖默认 `<area-id>.glb`。
|
||||
它用于在已有 Cesium GLB 上生成并列压缩 GLB、metadata 和预览页。当前主路径是
|
||||
texture resize + WebP transcode。
|
||||
|
||||
`scripts/compress-glb.js` 是该阶段调用的低层脚本,也可单独运行做实验。
|
||||
|
||||
### 2. Signatures
|
||||
|
||||
阶段入口:
|
||||
|
||||
```bash
|
||||
npm run build:area -- --config config/areas/<area>.json --stages compress
|
||||
npm run build:area -- --config config/areas/<area>.json --stages cesium,compress
|
||||
```
|
||||
|
||||
低层脚本入口:
|
||||
|
||||
```bash
|
||||
npm run compress:glb -- --input in.glb --output out.glb [options]
|
||||
```
|
||||
|
||||
可选参数:
|
||||
|
||||
| 参数 | 默认 | 语义 |
|
||||
|---|---:|---|
|
||||
| `--texture-size` | `1024` | 最大纹理宽高,范围 `64..4096` |
|
||||
| `--quality` | `82` | WebP 质量,范围 `1..100` |
|
||||
| `--effort` | `80` | WebP 编码 effort,范围 `0..100` |
|
||||
| `--meshopt` | off | 追加 `EXT_meshopt_compression` |
|
||||
| `--metadata source.json` | none | 复制伴生 metadata 并指向压缩 GLB |
|
||||
| `--metadata-output out.json` | `<output>.json` | 覆盖 metadata 输出路径 |
|
||||
| `--preview source.html` | none | 复制 Cesium preview 并指向压缩 metadata |
|
||||
| `--preview-output out.html` | `<output>-cesium-preview.html` | 覆盖 preview 输出路径 |
|
||||
|
||||
### 3. Contracts
|
||||
|
||||
- 输入必须是现有 `.glb` 文件;`--output` 必须不同于 `--input`。
|
||||
- 输出是并列压缩 GLB;默认构建产物不被替换。
|
||||
- `compress` 阶段依赖默认 `glb`、`metadata` 和 `cesiumPreview` 已存在。
|
||||
- 默认链固定为 `gltf-transform resize -> gltf-transform webp`。
|
||||
- `compress` 阶段默认 `textureSize=768`;低层脚本单独运行时默认 `--texture-size 1024`。
|
||||
- `build-area.js` 默认写:
|
||||
- `<fileStem>-compressed-webp768.glb`
|
||||
- `<fileStem>-compressed-webp768.json`
|
||||
- `<fileStem>-compressed-webp768-cesium-preview.html`
|
||||
- 伴生 metadata 的 `asset` 和 `assets[0].url` 改为压缩 GLB 文件名。
|
||||
- preview HTML 只替换 `window.OSM_ASSET_PREVIEW_CONFIG` 的 `glbName` /
|
||||
`metadataName` 和 loading 文案,不改 preview runtime。
|
||||
- 成功时 stdout 打印 `GLB_COMPRESS_DONE <json>`,包含压缩前后大小、image /
|
||||
non-image bytes、结构计数、扩展、metadata / preview 输出路径。
|
||||
|
||||
### 4. Validation & Error Matrix
|
||||
|
||||
| 条件 | 结果 |
|
||||
|---|---|
|
||||
| 缺 `--input` / `--output` | 打印 usage 并退出非零 |
|
||||
| 输入文件不存在 | `Input GLB not found: <path>` |
|
||||
| `--output` 等于 `--input` | 抛错,避免覆盖源 GLB |
|
||||
| 数值参数超范围 | 抛错并指出合法范围 |
|
||||
| `gltf-transform` 退出非零 | 抛错并带上 status / signal |
|
||||
| `--preview` 没有 `--metadata` | 抛错,因为 preview 必须指向存在的 metadata |
|
||||
| preview HTML 找不到配置块 | 抛错,不做猜测替换 |
|
||||
| `--stages compress` 但默认 preview 不存在 | `Cesium preview not found: <path>` |
|
||||
|
||||
### 5. Good/Base/Bad Cases
|
||||
|
||||
- Good: `--stages cesium,compress` 先重导默认 GLB,再生成并列压缩产物。
|
||||
- Good: `--texture-size 768 --metadata --preview` 生成压缩 GLB、metadata、HTML,
|
||||
源 GLB 保持不变。
|
||||
- Base: 只传 `--input --output` 生成压缩 GLB,不生成伴生文件。
|
||||
- Bad: 使用 `--meshopt` 后没有做 Cesium 兼容性验证就当默认产物发布。
|
||||
|
||||
### 6. Tests Required
|
||||
|
||||
- `node --check scripts/compress-glb.js`
|
||||
- `node --check scripts/build-area.js`
|
||||
- 对目标区域跑一次 `npm run compress:glb -- ... --metadata --preview`
|
||||
- 对目标区域跑一次 `npm run build:area -- --stages compress`
|
||||
- `node scripts/glb-digest.js <compressed.glb>` 确认可解析结构和扩展
|
||||
- 浏览器/Cesium 预览压缩 HTML,确认 `EXT_texture_webp` 在目标环境可加载
|
||||
|
||||
### 7. Wrong vs Correct
|
||||
|
||||
Wrong:
|
||||
|
||||
```bash
|
||||
npm run compress:glb -- --input outputs/a/a.glb --output outputs/a/a.glb
|
||||
```
|
||||
|
||||
Correct:
|
||||
|
||||
```bash
|
||||
npm run compress:glb -- --input outputs/a/a.glb --output outputs/a/a-compressed-webp768.glb
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## CLI 参数解析
|
||||
@@ -94,7 +193,7 @@ gpkg: path.resolve(outputOverrides.gpkg || path.join(areaDir, `${fileStem}.gpkg`
|
||||
|
||||
---
|
||||
|
||||
## 五个阶段
|
||||
## 阶段
|
||||
|
||||
| 阶段 | 做什么 | 读 | 写 |
|
||||
|---|---|---|---|
|
||||
@@ -103,8 +202,9 @@ gpkg: path.resolve(outputOverrides.gpkg || path.join(areaDir, `${fileStem}.gpkg`
|
||||
| `blender` | OSM + GeoJSON → 场景 | `.osm`、`osm2streets_web_out/` | `.blend`、`.png` |
|
||||
| `cesium` | 场景 → GLB + 元数据 + 预览页 | `.blend` | `.glb`、`.json`、预览 HTML 及其静态资源 |
|
||||
| `preview` | 只补生成预览页 | `.glb`、`.json` | 预览 HTML 及其静态资源 |
|
||||
| `compress` | 生成并列压缩 Cesium 产物 | `.glb`、`.json`、默认预览 HTML | `-compressed-webp*.glb/json/html` |
|
||||
|
||||
调度是顶层的五个 `if`(`build-area.js:32-46`),顺序固定,**阶段之间不传内存状态,
|
||||
调度是顶层的阶段 `if`(`build-area.js` 开头),顺序固定,**阶段之间不传内存状态,
|
||||
只通过磁盘产物耦合**。这就是单跑某个阶段能work 的原因。
|
||||
|
||||
`cesium` 阶段结束时会直接调 `writeCesiumPreview(area)`(`build-area.js:285`),所以
|
||||
@@ -126,7 +226,8 @@ gpkg: path.resolve(outputOverrides.gpkg || path.join(areaDir, `${fileStem}.gpkg`
|
||||
all: ["intermediates", "blender", "cesium"],
|
||||
```
|
||||
|
||||
`preview` 同样不在 `all` 里——`cesium` 已经包含它。
|
||||
`preview` 同样不在 `all` 里——`cesium` 已经包含它。`compress` 也不在 `all` 里——
|
||||
它生成的是替代压缩产物,不是 baseline GLB。
|
||||
|
||||
### `intermediates` 与 `reimport` 互斥
|
||||
|
||||
@@ -136,9 +237,9 @@ all: ["intermediates", "blender", "cesium"],
|
||||
|
||||
这是**显式拒绝而不是警告**——两者同时开,无论谁先跑,另一个的工作都白做。
|
||||
|
||||
`normalizeAreaConfig` 里 `stages.reimport` 和 `stages.preview` 硬编码为 `false`
|
||||
(`build-area.js:117-118`),**不能从配置文件打开**,只能靠 `--stages` 显式请求。
|
||||
恢复动作和补丁动作都不该被一份配置文件变成默认行为。
|
||||
`normalizeAreaConfig` 里 `stages.reimport`、`stages.preview` 和 `stages.compress` 硬编码为 `false`,
|
||||
**不能从配置文件打开**,只能靠 `--stages` 显式请求。
|
||||
恢复动作、补丁动作和替代产物动作都不该被一份配置文件变成默认行为。
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
{"_example": "Fill with {\"file\": \"<path>\", \"reason\": \"<why>\"}. Put spec/research files only — no code paths. Run `python3 .trellis/scripts/get_context.py --mode packages` to list available specs. Delete this line once real entries are added."}
|
||||
@@ -0,0 +1 @@
|
||||
{"_example": "Fill with {\"file\": \"<path>\", \"reason\": \"<why>\"}. Put spec/research files only — no code paths. Run `python3 .trellis/scripts/get_context.py --mode packages` to list available specs. Delete this line once real entries are added."}
|
||||
55
.trellis/tasks/08-03-experiment-glb-compression/prd.md
Normal file
55
.trellis/tasks/08-03-experiment-glb-compression/prd.md
Normal file
@@ -0,0 +1,55 @@
|
||||
# Experiment GLB compression
|
||||
|
||||
## Goal
|
||||
|
||||
Create and validate a repeatable GLB compression experiment for
|
||||
`nantaizi-lake-innovation-valley`, focused on reducing file size without making
|
||||
the current uncompressed GLB output the wrong baseline.
|
||||
|
||||
Current measurement:
|
||||
|
||||
- `nantaizi-lake-innovation-valley.glb`: `20.50 MB`
|
||||
- embedded images: `17.72 MB`
|
||||
- non-image binary buffer: `2.45 MB`
|
||||
- conclusion: texture compression / resize should be tested before geometry
|
||||
compression.
|
||||
|
||||
## Requirements
|
||||
|
||||
1. Add a standalone compression tool or script that consumes an existing GLB and
|
||||
writes a separate compressed GLB output.
|
||||
2. Do not replace the default `<area>.glb` in this first experiment.
|
||||
3. Prefer texture-first compression; geometry compression may be evaluated but
|
||||
should not be treated as the primary win.
|
||||
4. Keep the experiment repeatable from the command line.
|
||||
5. Validate only `nantaizi-lake-innovation-valley`.
|
||||
6. After visual review accepts the WebP result, integrate it as an explicit
|
||||
optional `compress` stage that does not run in `all` and does not replace the
|
||||
default GLB.
|
||||
7. Report:
|
||||
- source GLB size
|
||||
- compressed GLB size
|
||||
- image vs non-image size before compression
|
||||
- GLB extensions introduced
|
||||
- Cesium preview compatibility status or blocker
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [x] Compression command/script runs from this repo.
|
||||
- [x] It produces a separate compressed GLB for nantaizi.
|
||||
- [x] Source GLB is left intact.
|
||||
- [x] `scripts/glb-digest.js` can inspect the compressed GLB or the blocker is
|
||||
documented if the digest needs extension support.
|
||||
- [x] Visual/Cesium compatibility next step is clear.
|
||||
- [x] Optional `compress` stage can generate compressed nantaizi artifacts from
|
||||
the existing Cesium outputs.
|
||||
- [x] `compress` is not included in `all` and cannot be enabled from area
|
||||
`stages`.
|
||||
- [x] No `outputs/` files are committed.
|
||||
|
||||
## Out Of Scope
|
||||
|
||||
- Making compressed GLB replace the default pipeline artifact.
|
||||
- hanyang validation.
|
||||
- CDN/server gzip/brotli configuration.
|
||||
- Large frontend UI changes.
|
||||
99
.trellis/tasks/08-03-experiment-glb-compression/report.md
Normal file
99
.trellis/tasks/08-03-experiment-glb-compression/report.md
Normal file
@@ -0,0 +1,99 @@
|
||||
# GLB compression experiment report
|
||||
|
||||
## Baseline
|
||||
|
||||
Target: `nantaizi-lake-innovation-valley`
|
||||
|
||||
- source GLB: `20.50 MB`
|
||||
- embedded images: `17.72 MB`
|
||||
- non-image buffers: `2.45 MB`
|
||||
- counts: `830` nodes, `113` meshes, `32` materials, `18` images, `675` accessors
|
||||
|
||||
The file is texture-heavy, so texture resize / transcode is the primary lever.
|
||||
|
||||
## Implemented
|
||||
|
||||
Added `scripts/compress-glb.js` and `npm run compress:glb`.
|
||||
|
||||
Default chain:
|
||||
|
||||
```text
|
||||
gltf-transform resize -> gltf-transform webp
|
||||
```
|
||||
|
||||
The script:
|
||||
|
||||
- leaves source `.glb` intact
|
||||
- writes a separate compressed `.glb`
|
||||
- optionally copies metadata to point at the compressed GLB
|
||||
- optionally copies the existing Cesium preview HTML to point at compressed
|
||||
metadata
|
||||
- prints `GLB_COMPRESS_DONE` with before/after size, image/non-image size,
|
||||
counts, and extensions
|
||||
|
||||
After visual review accepted the WebP result, added an explicit `compress`
|
||||
stage to `scripts/build-area.js`:
|
||||
|
||||
```bash
|
||||
npm run build:area -- --config config/areas/nantaizi-lake-innovation-valley.json --stages compress
|
||||
```
|
||||
|
||||
The stage calls the same script, writes `<fileStem>-compressed-webp768.*` by
|
||||
default, and remains outside `all`.
|
||||
|
||||
## Results
|
||||
|
||||
Commands:
|
||||
|
||||
```bash
|
||||
npm run compress:glb -- --input outputs/nantaizi-lake-innovation-valley/nantaizi-lake-innovation-valley.glb --output outputs/nantaizi-lake-innovation-valley/nantaizi-lake-innovation-valley-compressed-webp1024.glb --metadata outputs/nantaizi-lake-innovation-valley/nantaizi-lake-innovation-valley.json --preview outputs/nantaizi-lake-innovation-valley/nantaizi-lake-innovation-valley-cesium-preview.html
|
||||
npm run compress:glb -- --input outputs/nantaizi-lake-innovation-valley/nantaizi-lake-innovation-valley.glb --output outputs/nantaizi-lake-innovation-valley/nantaizi-lake-innovation-valley-compressed-webp768.glb --texture-size 768 --metadata outputs/nantaizi-lake-innovation-valley/nantaizi-lake-innovation-valley.json --preview outputs/nantaizi-lake-innovation-valley/nantaizi-lake-innovation-valley-cesium-preview.html
|
||||
```
|
||||
|
||||
Results:
|
||||
|
||||
| Variant | Size | Image bytes | Non-image bytes | Structure |
|
||||
|---|---:|---:|---:|---|
|
||||
| source | `20.50 MB` | `17.72 MB` | `2.45 MB` | baseline |
|
||||
| WebP 1024 | `5.48 MB` | `2.73 MB` | `2.45 MB` | unchanged counts |
|
||||
| WebP 768 | `4.80 MB` | `2.04 MB` | `2.45 MB` | unchanged counts |
|
||||
|
||||
Both WebP outputs keep `830` nodes, `113` meshes, `32` materials, `18` images,
|
||||
and `675` accessors. They introduce `EXT_texture_webp` and
|
||||
`KHR_texture_transform`.
|
||||
|
||||
`scripts/glb-digest.js` can inspect both compressed GLBs and reports the same
|
||||
node/mesh/material/image/accessor counts as the source.
|
||||
|
||||
Visual review in Cesium on 2026-08-03: the user compared the generated WebP
|
||||
preview and reported no obvious visible difference from the source preview.
|
||||
|
||||
Extra experiments:
|
||||
|
||||
- `optimize --texture-compress webp --texture-size 1024`: `4.29 MB`, but mesh
|
||||
and material counts changed because `optimize` runs broader structural passes.
|
||||
- `meshopt + WebP 1024`: `3.66 MB` with direct `meshopt`, introducing
|
||||
`EXT_meshopt_compression` and `KHR_mesh_quantization`.
|
||||
- KTX2 was blocked locally because glTF Transform requires external
|
||||
KTX-Software `ktx`, which is not installed.
|
||||
|
||||
## Recommendation
|
||||
|
||||
Use the explicit `compress` stage for optional compressed Cesium review /
|
||||
delivery candidates. Keep the uncompressed GLB as the baseline artifact. Do not
|
||||
make Meshopt or KTX2 default until Cesium compatibility is verified visually.
|
||||
|
||||
Generated preview candidates:
|
||||
|
||||
- `outputs/nantaizi-lake-innovation-valley/nantaizi-lake-innovation-valley-compressed-webp1024-cesium-preview.html`
|
||||
- `outputs/nantaizi-lake-innovation-valley/nantaizi-lake-innovation-valley-compressed-webp768-cesium-preview.html`
|
||||
|
||||
Validation:
|
||||
|
||||
- `node --check scripts/compress-glb.js`
|
||||
- `node --check scripts/build-area.js`
|
||||
- `npm run build:area -- --config config/areas/nantaizi-lake-innovation-valley.json --stages compress`
|
||||
- `node scripts/glb-digest.js outputs/nantaizi-lake-innovation-valley/nantaizi-lake-innovation-valley-compressed-webp768.glb`
|
||||
- `node scripts/glb-digest.js outputs/nantaizi-lake-innovation-valley/nantaizi-lake-innovation-valley-compressed-webp1024.glb`
|
||||
- `python3 ./.trellis/scripts/task.py validate .trellis/tasks/08-03-experiment-glb-compression`
|
||||
- `git diff --check`
|
||||
26
.trellis/tasks/08-03-experiment-glb-compression/task.json
Normal file
26
.trellis/tasks/08-03-experiment-glb-compression/task.json
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"id": "experiment-glb-compression",
|
||||
"name": "experiment-glb-compression",
|
||||
"title": "Experiment GLB compression",
|
||||
"description": "",
|
||||
"status": "in_progress",
|
||||
"dev_type": null,
|
||||
"scope": null,
|
||||
"package": null,
|
||||
"priority": "P2",
|
||||
"creator": "dingkang",
|
||||
"assignee": "dingkang",
|
||||
"createdAt": "2026-08-03",
|
||||
"completedAt": null,
|
||||
"branch": null,
|
||||
"base_branch": "main",
|
||||
"worktree_path": null,
|
||||
"commit": null,
|
||||
"pr_url": null,
|
||||
"subtasks": [],
|
||||
"children": [],
|
||||
"parent": null,
|
||||
"relatedFiles": [],
|
||||
"notes": "",
|
||||
"meta": {}
|
||||
}
|
||||
45
README.md
45
README.md
@@ -11,6 +11,7 @@
|
||||
- `<area-id>.glb`:Cesium 可加载的 3D 模型
|
||||
- `<area-id>.json`:Cesium 放置元数据和示例代码
|
||||
- `<area-id>-cesium-preview.html`:Cesium 本地预览页
|
||||
- `<area-id>-compressed-webp768.glb/json/html`:显式 `compress` 阶段生成的可选压缩预览产物
|
||||
- `osm2streets_web_out/`:osm2streets GeoJSON 中间层
|
||||
- `<area-id>.gpkg` / `<area-id>.qgz` / `<area-id>-preview.png`:QGIS 调试资产
|
||||
|
||||
@@ -50,10 +51,17 @@ npm run build:area -- --config config/areas/nantaizi-lake-innovation-valley.json
|
||||
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
|
||||
npm run build:area -- --config config/areas/nantaizi-lake-innovation-valley.json --stages compress
|
||||
npm run build:area -- --config config/areas/nantaizi-lake-innovation-valley.json --stages reimport
|
||||
```
|
||||
|
||||
`intermediates` 会生成 osm2streets GeoJSON、GeoPackage、QGIS 工程和 QGIS 预览图。`blender` 使用 OSM 和 osm2streets GeoJSON 生成 `.blend`/`.png`。`cesium` 从 `.blend` 导出 `.glb`/`.json`,并生成 Cesium 预览 HTML。`preview` 只在已有 `.glb/.json` 时补生成 HTML。`reimport` 把手工编辑过的 GeoPackage 回导为 GeoJSON,不含在 `all` 里,详见 [QGIS 手工修正工作流](#qgis-手工修正工作流)。
|
||||
`intermediates` 会生成 osm2streets GeoJSON、GeoPackage、QGIS 工程和 QGIS 预览图。`blender` 使用 OSM 和 osm2streets GeoJSON 生成 `.blend`/`.png`。`cesium` 从 `.blend` 导出 `.glb`/`.json`,并生成 Cesium 预览 HTML。`preview` 只在已有 `.glb/.json` 时补生成 HTML。`compress` 从已有 `.glb/.json/html` 生成并列压缩产物,不覆盖默认 GLB。`reimport` 把手工编辑过的 GeoPackage 回导为 GeoJSON,不含在 `all` 里,详见 [QGIS 手工修正工作流](#qgis-手工修正工作流)。
|
||||
|
||||
`compress` 不含在 `all` 里,也不能从配置文件默认开启。需要重导出 Cesium 后立刻生成压缩产物时,显式跑:
|
||||
|
||||
```bash
|
||||
npm run build:area -- --config config/areas/nantaizi-lake-innovation-valley.json --stages cesium,compress
|
||||
```
|
||||
|
||||
## 区域配置
|
||||
|
||||
@@ -92,6 +100,12 @@ cp config/examples/template.json config/areas/my-area.json
|
||||
"blender": {
|
||||
"treeStyle": "natural",
|
||||
"officeOverrides": ""
|
||||
},
|
||||
"compress": {
|
||||
"textureSize": 768,
|
||||
"quality": 82,
|
||||
"effort": 80,
|
||||
"meshopt": false
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -111,7 +125,8 @@ QGIS road-layer knobs:
|
||||
"areaDir": "/absolute/path/to/custom-area",
|
||||
"blend": "/absolute/path/to/custom.blend",
|
||||
"glb": "/absolute/path/to/custom.glb",
|
||||
"cesiumPreview": "/absolute/path/to/custom-preview.html"
|
||||
"cesiumPreview": "/absolute/path/to/custom-preview.html",
|
||||
"compressedGlb": "/absolute/path/to/custom-compressed.glb"
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -174,6 +189,32 @@ Cesium GLB 低层导出:
|
||||
|
||||
详见 [blender/README.md](blender/README.md)。
|
||||
|
||||
## 压缩 GLB
|
||||
|
||||
推荐使用显式 `compress` 阶段。它不会覆盖默认 `<area-id>.glb`,只生成并列的压缩 GLB、
|
||||
metadata 和预览页:
|
||||
|
||||
```bash
|
||||
npm run build:area -- \
|
||||
--config config/areas/nantaizi-lake-innovation-valley.json \
|
||||
--stages compress
|
||||
```
|
||||
|
||||
底层脚本也可以单独调用:
|
||||
|
||||
```bash
|
||||
npm run compress:glb -- \
|
||||
--input outputs/nantaizi-lake-innovation-valley/nantaizi-lake-innovation-valley.glb \
|
||||
--output outputs/nantaizi-lake-innovation-valley/nantaizi-lake-innovation-valley-compressed-webp768.glb \
|
||||
--texture-size 768 \
|
||||
--metadata outputs/nantaizi-lake-innovation-valley/nantaizi-lake-innovation-valley.json \
|
||||
--preview outputs/nantaizi-lake-innovation-valley/nantaizi-lake-innovation-valley-cesium-preview.html
|
||||
```
|
||||
|
||||
该脚本通过 `npx @gltf-transform/cli@4.1.4` 执行 `resize -> webp`。如需进一步压几何,
|
||||
可追加 `--meshopt`,但会引入 `EXT_meshopt_compression` / `KHR_mesh_quantization`,
|
||||
应先确认目标 Cesium 版本加载正常。
|
||||
|
||||
## QGIS 手工修正工作流
|
||||
|
||||
如果已经在现成的 `.qgz` 项目里直接编辑了 `gpkg` 图层,不要再重跑 `intermediates`,否则会把手工修改覆盖掉。推荐流程是:
|
||||
|
||||
@@ -31,5 +31,11 @@
|
||||
"blender": {
|
||||
"treeStyle": "natural",
|
||||
"officeOverrides": ""
|
||||
},
|
||||
"compress": {
|
||||
"textureSize": 768,
|
||||
"quality": 82,
|
||||
"effort": 80,
|
||||
"meshopt": false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,12 @@
|
||||
|
||||
## 2026-08-03
|
||||
|
||||
- 新增显式 `compress` 阶段与 `scripts/compress-glb.js`,用于从已有 Cesium GLB 生成并列
|
||||
WebP 压缩产物,不进入 `all`,不覆盖默认 `<area-id>.glb`。nantaizi 试验结果:
|
||||
`20.50 MB -> 4.80 MB`,embedded images `17.72 MB -> 2.04 MB`,nodes / meshes /
|
||||
materials / images / accessors 均保持 `830 / 113 / 32 / 18 / 675`,引入
|
||||
`EXT_texture_webp` 和 `KHR_texture_transform`。用户在 Cesium 预览里目测压缩版与
|
||||
默认版无明显区别;Meshopt / KTX2 仍不作为默认路径。
|
||||
- 固化 Shapespark low-poly plants kit 作为当前植被资产来源:`nantaizi-lake-innovation-valley`
|
||||
使用 `treeStyle: shapespark`、12 个确定性树变体、`bush-03` scrub 边缘灌木、
|
||||
`grass-01/02/03` 草坪草簇;Cesium 对 Shapespark cut-out 材质走单独温和 profile
|
||||
|
||||
@@ -6,7 +6,8 @@
|
||||
"scripts": {
|
||||
"build": "node scripts/build-area.js",
|
||||
"build:area": "node scripts/build-area.js",
|
||||
"build:qgis": "node scripts/build-osm2streets-qgis.js"
|
||||
"build:qgis": "node scripts/build-osm2streets-qgis.js",
|
||||
"compress:glb": "node scripts/compress-glb.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"osm2streets-js-node": "0.1.4"
|
||||
|
||||
@@ -44,6 +44,9 @@ if (stages.cesium) {
|
||||
if (stages.preview) {
|
||||
writeCesiumPreview(area);
|
||||
}
|
||||
if (stages.compress) {
|
||||
compressCesiumGlb(area);
|
||||
}
|
||||
|
||||
console.log("Done.");
|
||||
|
||||
@@ -82,6 +85,9 @@ function normalizeAreaConfig(raw) {
|
||||
const outputOverrides = raw.outputs || {};
|
||||
const areaDir = path.resolve(outputOverrides.areaDir || path.join(outputRoot, id));
|
||||
const fileStem = outputOverrides.fileStem || id;
|
||||
const compress = normalizeCompressConfig(raw.compress);
|
||||
const compressedFileStem = outputOverrides.compressedFileStem ||
|
||||
`${fileStem}-compressed-webp${compress.textureSize}${compress.meshopt ? "-meshopt" : ""}`;
|
||||
const outputs = {
|
||||
areaDir,
|
||||
geojsonDir: path.resolve(outputOverrides.geojsonDir || path.join(areaDir, "osm2streets_web_out")),
|
||||
@@ -95,6 +101,15 @@ function normalizeAreaConfig(raw) {
|
||||
cesiumPreview: path.resolve(
|
||||
outputOverrides.cesiumPreview || path.join(areaDir, `${fileStem}-cesium-preview.html`),
|
||||
),
|
||||
compressedGlb: path.resolve(
|
||||
outputOverrides.compressedGlb || path.join(areaDir, `${compressedFileStem}.glb`),
|
||||
),
|
||||
compressedMetadata: path.resolve(
|
||||
outputOverrides.compressedMetadata || path.join(areaDir, `${compressedFileStem}.json`),
|
||||
),
|
||||
compressedCesiumPreview: path.resolve(
|
||||
outputOverrides.compressedCesiumPreview || path.join(areaDir, `${compressedFileStem}-cesium-preview.html`),
|
||||
),
|
||||
vehicleRoute: path.resolve(outputOverrides.vehicleRoute || path.join(areaDir, `${fileStem}-vehicle-route.json`)),
|
||||
vehicleModel: path.resolve(outputOverrides.vehicleModel || path.join(areaDir, `${fileStem}-vehicle-car.gltf`)),
|
||||
pipelineDir: path.resolve(outputOverrides.pipelineDir || path.join(areaDir, "_pipeline")),
|
||||
@@ -112,6 +127,7 @@ function normalizeAreaConfig(raw) {
|
||||
cesium: raw.stages?.cesium ?? true,
|
||||
reimport: false,
|
||||
preview: false,
|
||||
compress: false,
|
||||
},
|
||||
qgis: {
|
||||
arrowScale: raw.qgis?.arrowScale ?? raw.arrowScale ?? 0.8,
|
||||
@@ -136,6 +152,7 @@ function normalizeAreaConfig(raw) {
|
||||
treeStyle: raw.blender?.treeStyle || "natural",
|
||||
officeOverrides: raw.blender?.officeOverrides || raw.blender?.office_overrides || "",
|
||||
},
|
||||
compress,
|
||||
outputs,
|
||||
};
|
||||
}
|
||||
@@ -147,6 +164,32 @@ function requireText(value, key) {
|
||||
return value;
|
||||
}
|
||||
|
||||
function normalizeCompressConfig(raw) {
|
||||
const value = raw || {};
|
||||
return {
|
||||
textureSize: numberOption(value.textureSize, 768, "compress.textureSize", 64, 4096),
|
||||
quality: numberOption(value.quality, 82, "compress.quality", 1, 100),
|
||||
effort: numberOption(value.effort, 80, "compress.effort", 0, 100),
|
||||
meshopt: booleanOption(value.meshopt, false, "compress.meshopt"),
|
||||
};
|
||||
}
|
||||
|
||||
function numberOption(value, fallback, label, min, max) {
|
||||
const number = value === undefined ? fallback : Number(value);
|
||||
if (!Number.isFinite(number) || number < min || number > max) {
|
||||
throw new Error(`${label} must be a finite number in [${min}, ${max}]`);
|
||||
}
|
||||
return number;
|
||||
}
|
||||
|
||||
function booleanOption(value, fallback, label) {
|
||||
if (value === undefined) return fallback;
|
||||
if (typeof value === "boolean") return value;
|
||||
if (value === "true") return true;
|
||||
if (value === "false") return false;
|
||||
throw new Error(`${label} must be boolean`);
|
||||
}
|
||||
|
||||
function splitList(value) {
|
||||
return String(value)
|
||||
.split(",")
|
||||
@@ -158,6 +201,8 @@ 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"],
|
||||
@@ -174,12 +219,22 @@ function resolveStages(defaults, requested) {
|
||||
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,
|
||||
};
|
||||
const out = { intermediates: false, reimport: 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, reimport, blender, cesium, preview, or all.`);
|
||||
throw new Error(`Unknown stage '${stage}'. Use intermediates, reimport, blender, cesium, preview, compress, or all.`);
|
||||
}
|
||||
for (const key of mapped) out[key] = true;
|
||||
}
|
||||
@@ -288,6 +343,44 @@ function exportCesium(area) {
|
||||
writeCesiumPreview(area);
|
||||
}
|
||||
|
||||
function compressCesiumGlb(area) {
|
||||
ensureFile(area.outputs.glb, "Cesium GLB");
|
||||
ensureFile(area.outputs.metadata, "Cesium metadata");
|
||||
ensureFile(area.outputs.cesiumPreview, "Cesium preview");
|
||||
ensureFile(path.join(repoRoot, "scripts", "compress-glb.js"), "GLB compressor");
|
||||
fs.mkdirSync(path.dirname(area.outputs.compressedGlb), { recursive: true });
|
||||
fs.mkdirSync(path.dirname(area.outputs.compressedMetadata), { recursive: true });
|
||||
fs.mkdirSync(path.dirname(area.outputs.compressedCesiumPreview), { recursive: true });
|
||||
|
||||
const compressArgs = [
|
||||
path.join(repoRoot, "scripts", "compress-glb.js"),
|
||||
"--input",
|
||||
area.outputs.glb,
|
||||
"--output",
|
||||
area.outputs.compressedGlb,
|
||||
"--texture-size",
|
||||
String(area.compress.textureSize),
|
||||
"--quality",
|
||||
String(area.compress.quality),
|
||||
"--effort",
|
||||
String(area.compress.effort),
|
||||
"--metadata",
|
||||
area.outputs.metadata,
|
||||
"--metadata-output",
|
||||
area.outputs.compressedMetadata,
|
||||
"--preview",
|
||||
area.outputs.cesiumPreview,
|
||||
"--preview-output",
|
||||
area.outputs.compressedCesiumPreview,
|
||||
];
|
||||
if (area.compress.meshopt) {
|
||||
compressArgs.push("--meshopt");
|
||||
}
|
||||
|
||||
console.log("Stage: compress (Cesium GLB texture resize + WebP)");
|
||||
runCommand(process.execPath, compressArgs, "compress");
|
||||
}
|
||||
|
||||
function blenderExecutable(area) {
|
||||
return path.join(area.blenderApp, "Contents", "MacOS", "Blender");
|
||||
}
|
||||
|
||||
278
scripts/compress-glb.js
Normal file
278
scripts/compress-glb.js
Normal file
@@ -0,0 +1,278 @@
|
||||
#!/usr/bin/env node
|
||||
"use strict";
|
||||
|
||||
// Experimental GLB compression wrapper. Keeps the source GLB intact and writes
|
||||
// a separate compressed artifact for visual comparison in Cesium.
|
||||
|
||||
const fs = require("fs");
|
||||
const os = require("os");
|
||||
const path = require("path");
|
||||
const { spawnSync } = require("child_process");
|
||||
|
||||
const GLTF_TRANSFORM_VERSION = "4.1.4";
|
||||
const PREVIEW_CONFIG_PATTERN =
|
||||
/window\.OSM_ASSET_PREVIEW_CONFIG\s*=\s*(\{[\s\S]*?\});\s*<\/script>/;
|
||||
|
||||
function parseArgs(argv) {
|
||||
const values = {};
|
||||
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("--")) {
|
||||
values[key] = "true";
|
||||
} else {
|
||||
values[key] = next;
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
function usage() {
|
||||
return [
|
||||
"usage: node scripts/compress-glb.js --input in.glb --output out.glb [options]",
|
||||
"",
|
||||
"Options:",
|
||||
" --texture-size 1024 Max texture width/height. Default: 1024",
|
||||
" --quality 82 WebP quality. Default: 82",
|
||||
" --effort 80 WebP encoder effort. Default: 80",
|
||||
" --meshopt Also apply EXT_meshopt_compression",
|
||||
" --metadata scene.json Write sidecar metadata for the compressed GLB",
|
||||
" --metadata-output out.json Override compressed metadata path",
|
||||
" --preview preview.html Write a copied preview pointing to compressed metadata",
|
||||
" --preview-output out.html Override compressed preview path",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function requireText(value, label) {
|
||||
if (!value || !String(value).trim()) {
|
||||
throw new Error(`${label} is required\n\n${usage()}`);
|
||||
}
|
||||
return String(value);
|
||||
}
|
||||
|
||||
function numberOption(value, fallback, label, min, max) {
|
||||
const number = value === undefined ? fallback : Number(value);
|
||||
if (!Number.isFinite(number) || number < min || number > max) {
|
||||
throw new Error(`${label} must be a finite number in [${min}, ${max}]`);
|
||||
}
|
||||
return number;
|
||||
}
|
||||
|
||||
function ensureFile(file, label) {
|
||||
if (!fs.existsSync(file) || !fs.statSync(file).isFile()) {
|
||||
throw new Error(`${label} not found: ${file}`);
|
||||
}
|
||||
}
|
||||
|
||||
function runGltfTransform(args) {
|
||||
const command = process.env.NPX_BINARY || "npx";
|
||||
const result = spawnSync(
|
||||
command,
|
||||
["--yes", `@gltf-transform/cli@${GLTF_TRANSFORM_VERSION}`, ...args],
|
||||
{ stdio: "inherit" },
|
||||
);
|
||||
if (result.error) throw result.error;
|
||||
if (result.status !== 0) {
|
||||
const signal = result.signal ? ` signal=${result.signal}` : "";
|
||||
throw new Error(`gltf-transform ${args[0]} failed with status=${result.status}${signal}`);
|
||||
}
|
||||
}
|
||||
|
||||
function readGlbJson(file) {
|
||||
const buffer = fs.readFileSync(file);
|
||||
if (buffer.length < 12 || buffer.readUInt32LE(0) !== 0x46546c67) {
|
||||
throw new Error(`Not a GLB (bad magic): ${file}`);
|
||||
}
|
||||
let offset = 12;
|
||||
while (offset + 8 <= buffer.length) {
|
||||
const chunkLength = buffer.readUInt32LE(offset);
|
||||
const chunkType = buffer.readUInt32LE(offset + 4);
|
||||
const start = offset + 8;
|
||||
if (chunkType === 0x4e4f534a) {
|
||||
return JSON.parse(buffer.slice(start, start + chunkLength).toString("utf8"));
|
||||
}
|
||||
offset = start + chunkLength;
|
||||
}
|
||||
throw new Error(`No JSON chunk found in ${file}`);
|
||||
}
|
||||
|
||||
function glbStats(file) {
|
||||
const gltf = readGlbJson(file);
|
||||
const bufferViews = gltf.bufferViews || [];
|
||||
const imageViews = new Set(
|
||||
(gltf.images || []).map((image) => image.bufferView).filter((value) => value !== undefined),
|
||||
);
|
||||
let imageBytes = 0;
|
||||
let nonImageBytes = 0;
|
||||
bufferViews.forEach((view, index) => {
|
||||
if (imageViews.has(index)) imageBytes += view.byteLength || 0;
|
||||
else nonImageBytes += view.byteLength || 0;
|
||||
});
|
||||
return {
|
||||
fileBytes: fs.statSync(file).size,
|
||||
imageBytes,
|
||||
nonImageBytes,
|
||||
counts: {
|
||||
nodes: (gltf.nodes || []).length,
|
||||
meshes: (gltf.meshes || []).length,
|
||||
materials: (gltf.materials || []).length,
|
||||
images: (gltf.images || []).length,
|
||||
accessors: (gltf.accessors || []).length,
|
||||
},
|
||||
extensionsUsed: (gltf.extensionsUsed || []).slice().sort(),
|
||||
};
|
||||
}
|
||||
|
||||
function mb(bytes) {
|
||||
return Number((bytes / 1024 / 1024).toFixed(2));
|
||||
}
|
||||
|
||||
function metadataOutputPath(output, explicitPath) {
|
||||
if (explicitPath) return path.resolve(explicitPath);
|
||||
return output.replace(/\.glb$/i, ".json");
|
||||
}
|
||||
|
||||
function previewOutputPath(output, explicitPath) {
|
||||
if (explicitPath) return path.resolve(explicitPath);
|
||||
return output.replace(/\.glb$/i, "-cesium-preview.html");
|
||||
}
|
||||
|
||||
function writeCompressedMetadata(sourceMetadata, outputGlb, outputMetadata) {
|
||||
const metadata = JSON.parse(fs.readFileSync(sourceMetadata, "utf8"));
|
||||
const glbName = path.basename(outputGlb);
|
||||
metadata.asset = glbName;
|
||||
metadata.assets = [{
|
||||
id: "main",
|
||||
label: "Compressed Scene",
|
||||
type: "model",
|
||||
url: glbName,
|
||||
enabled: true,
|
||||
}];
|
||||
if (typeof metadata.cesium_js === "string") {
|
||||
metadata.cesium_js = metadata.cesium_js.replace(
|
||||
/url: '[^']+\.glb'/,
|
||||
`url: '${glbName}'`,
|
||||
);
|
||||
}
|
||||
fs.mkdirSync(path.dirname(outputMetadata), { recursive: true });
|
||||
fs.writeFileSync(outputMetadata, `${JSON.stringify(metadata, null, 2)}\n`);
|
||||
}
|
||||
|
||||
function readPreviewConfig(html) {
|
||||
const match = html.match(PREVIEW_CONFIG_PATTERN);
|
||||
if (!match) return null;
|
||||
return JSON.parse(match[1]);
|
||||
}
|
||||
|
||||
function escapeScriptJson(value) {
|
||||
return String(value)
|
||||
.replaceAll("<", "\\u003c")
|
||||
.replaceAll(">", "\\u003e")
|
||||
.replaceAll("&", "\\u0026")
|
||||
.replaceAll("\u2028", "\\u2028")
|
||||
.replaceAll("\u2029", "\\u2029");
|
||||
}
|
||||
|
||||
function writeCompressedPreview(sourcePreview, outputPreview, outputGlb, outputMetadata) {
|
||||
const html = fs.readFileSync(sourcePreview, "utf8");
|
||||
const config = readPreviewConfig(html);
|
||||
if (!config) {
|
||||
throw new Error(`Preview config not found in ${sourcePreview}`);
|
||||
}
|
||||
config.glbName = path.basename(outputGlb);
|
||||
config.metadataName = path.basename(outputMetadata);
|
||||
const nextHtml = html
|
||||
.replace(
|
||||
PREVIEW_CONFIG_PATTERN,
|
||||
`window.OSM_ASSET_PREVIEW_CONFIG = ${escapeScriptJson(JSON.stringify(config))};</script>`,
|
||||
)
|
||||
.replace(/Loading [^<]+\.glb\.\.\./, `Loading ${path.basename(outputGlb)}...`);
|
||||
fs.mkdirSync(path.dirname(outputPreview), { recursive: true });
|
||||
fs.writeFileSync(outputPreview, nextHtml);
|
||||
}
|
||||
|
||||
function main() {
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
const input = path.resolve(requireText(args.input, "--input"));
|
||||
const output = path.resolve(requireText(args.output, "--output"));
|
||||
const textureSize = numberOption(args.textureSize, 1024, "--texture-size", 64, 4096);
|
||||
const quality = numberOption(args.quality, 82, "--quality", 1, 100);
|
||||
const effort = numberOption(args.effort, 80, "--effort", 0, 100);
|
||||
const meshopt = args.meshopt === "true";
|
||||
|
||||
ensureFile(input, "Input GLB");
|
||||
if (input === output) {
|
||||
throw new Error("--output must differ from --input");
|
||||
}
|
||||
fs.mkdirSync(path.dirname(output), { recursive: true });
|
||||
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "osm-glb-compress-"));
|
||||
const resized = path.join(tempDir, "resized.glb");
|
||||
const webp = meshopt ? path.join(tempDir, "webp.glb") : output;
|
||||
try {
|
||||
const before = glbStats(input);
|
||||
runGltfTransform([
|
||||
"resize", input, resized,
|
||||
"--width", String(textureSize),
|
||||
"--height", String(textureSize),
|
||||
]);
|
||||
runGltfTransform([
|
||||
"webp", resized, webp,
|
||||
"--quality", String(quality),
|
||||
"--effort", String(effort),
|
||||
]);
|
||||
if (meshopt) {
|
||||
runGltfTransform(["meshopt", webp, output, "--level", "high"]);
|
||||
}
|
||||
|
||||
let metadataOutput = null;
|
||||
let previewOutput = null;
|
||||
if (args.metadata) {
|
||||
const metadata = path.resolve(args.metadata);
|
||||
ensureFile(metadata, "Source metadata");
|
||||
metadataOutput = metadataOutputPath(output, args.metadataOutput);
|
||||
writeCompressedMetadata(metadata, output, metadataOutput);
|
||||
}
|
||||
if (args.preview) {
|
||||
if (!metadataOutput) {
|
||||
throw new Error("--preview requires --metadata so the copied preview has a metadata file");
|
||||
}
|
||||
const preview = path.resolve(args.preview);
|
||||
ensureFile(preview, "Source preview");
|
||||
previewOutput = previewOutputPath(output, args.previewOutput);
|
||||
writeCompressedPreview(preview, previewOutput, output, metadataOutput);
|
||||
}
|
||||
|
||||
const after = glbStats(output);
|
||||
console.log("GLB_COMPRESS_DONE " + JSON.stringify({
|
||||
input,
|
||||
output,
|
||||
texture_size: textureSize,
|
||||
quality,
|
||||
effort,
|
||||
meshopt,
|
||||
input_mb: mb(before.fileBytes),
|
||||
output_mb: mb(after.fileBytes),
|
||||
image_mb_before: mb(before.imageBytes),
|
||||
image_mb_after: mb(after.imageBytes),
|
||||
non_image_mb_before: mb(before.nonImageBytes),
|
||||
non_image_mb_after: mb(after.nonImageBytes),
|
||||
counts: after.counts,
|
||||
extensions_used: after.extensionsUsed,
|
||||
metadata: metadataOutput,
|
||||
preview: previewOutput,
|
||||
}));
|
||||
} finally {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
main();
|
||||
} catch (error) {
|
||||
console.error(error.message);
|
||||
process.exit(1);
|
||||
}
|
||||
Reference in New Issue
Block a user