diff --git a/.trellis/spec/pipeline/cli-and-stages.md b/.trellis/spec/pipeline/cli-and-stages.md index a42e71e..02929ea 100644 --- a/.trellis/spec/pipeline/cli-and-stages.md +++ b/.trellis/spec/pipeline/cli-and-stages.md @@ -70,7 +70,8 @@ npm run compress:glb -- --input in.glb --output out.glb [options] - `-compressed-webp768.glb` - `-compressed-webp768.json` - `-compressed-webp768-cesium-preview.html` -- 伴生 metadata 的 `asset` 和 `assets[0].url` 改为压缩 GLB 文件名。 +- 伴生 metadata 的 `asset` 与 `id="main"` 资产 URL 改为压缩 GLB 文件名;其余资产(包括 + `category="semantic"` 的 Cesium 分类检查 GLB)必须原样保留。 - preview HTML 只替换 `window.OSM_ASSET_PREVIEW_CONFIG` 的 `glbName` / `metadataName` 和 loading 文案,不改 preview runtime。 - 成功时 stdout 打印 `GLB_COMPRESS_DONE `,包含压缩前后大小、image / @@ -100,6 +101,7 @@ npm run compress:glb -- --input in.glb --output out.glb [options] ### 6. Tests Required - `node --check scripts/compress-glb.js` +- `npm run test:compress-glb`:断言压缩 metadata 只替换主资产,不丢失语义资产。 - `node --check scripts/build-area.js` - 对目标区域跑一次 `npm run compress:glb -- ... --metadata --preview` - 对目标区域跑一次 `npm run build:area -- --stages compress` diff --git a/.trellis/spec/preview/index.md b/.trellis/spec/preview/index.md index 9cadd2b..64513d7 100644 --- a/.trellis/spec/preview/index.md +++ b/.trellis/spec/preview/index.md @@ -201,6 +201,73 @@ GLB 停留在**局部 ENU 坐标系**(X 东、Y 北、Z 上),靠伴生 JSO `scenePlacement(metadata)`(`:131`)负责这一步。**改动导出侧的坐标约定必须同步改这里。** +## 语义检查资产 + +### 1. 范围与触发条件 + +`cesium` 阶段除完整主 GLB 外,会按 Blender 顶层集合导出可选检查资产:道路、建筑、 +绿化与设施、水体。它们只服务于预览检查;主 GLB 仍是下游兼容基线,不能被替换。 + +### 2. 调用形式 + +不新增 CLI 参数。正常运行 Cesium 阶段即可: + +```bash +npm run build:area -- --config config/areas/.json --stages cesium +``` + +### 3. 契约 + +- `blender/export_cesium.py:SEMANTIC_ASSETS` 是集合名、稳定资产 ID 与展示名的唯一映射: + `03_Roads → roads`、`04_Buildings → buildings`、`02_Green + 05_Props → vegetation`、 + `01_Water → water`。 +- 每个有几何的类别额外写 `-.glb`,且必须保留与主 GLB 相同的局部 ENU 坐标和 + 已处理的 Cesium 材质。 +- metadata 的主资产保持 `id="main"`、`enabled=true`;辅助项设 + `category="semantic"`、`enabled=false`,并提供 `id`、`label`、`type="model"`、`url`。 +- `build-area.js:semanticAssetRecords()` 必须验证 metadata 声明的每个语义文件存在后才写 + Cesium manifest。 +- 浏览器先加载非语义资产;只有点 `Inspect` 才加载辅助 GLB。检查模式必须隐藏主场景, + 返回 `Scene` 必须隐藏辅助模型,禁止两套几何重叠渲染。 +- 没有 `category="semantic"` 的旧 metadata 仍按单资产预览打开,`Inspect` 按钮禁用。 + +### 4. 校验与错误矩阵 + +| 条件 | 结果 | +|---|---| +| 类别集合没有可导出 mesh | metadata 不声明该类别,预览不显示该开关 | +| metadata 声明语义资产但文件不存在 | Cesium 阶段失败,不能写成功 manifest | +| 辅助 GLB 浏览器加载失败 | 该开关禁用并写入诊断;主场景继续可用 | +| 旧 metadata 没有语义项 | 完整场景照常显示,`Inspect` 不可点击 | + +### 5. 正常、基础与错误示例 + +- 正常:进入 `Inspect` 后道路、建筑、绿化与设施、水体全部显示,再单独取消任一类别。 +- 基础:旧的只有 `main` 资产的 metadata 不展示分类控件,所有原有控制仍可用。 +- 错误:主 GLB 和语义 GLB 同时可见,导致道路、建筑等重复渲染和闪烁。 + +### 6. 必需测试 + +- `node scripts/test-preview-assets.js`:断言生成页包含模式切换与语义开关挂载点。 +- `node --check scripts/build-area.js`、`node --check scripts/lib/area-preview.js`、 + `node --check scripts/lib/cesium-preview.js`。 +- 目标区域运行 `--stages cesium`,确认 metadata 的语义 `assets` 与同名辅助 GLB 一一对应。 +- 浏览器在桌面及窄屏分别切换 `Scene` / `Inspect`,确认不重叠且控制不溢出。 + +### 7. 错误与正确写法 + +错误:把辅助模型标为默认启用,页面加载时把它们与主 GLB 一起绘制。 + +```json +{ "id": "roads", "enabled": true, "category": "semantic" } +``` + +正确:默认关闭并在检查模式按需加载。 + +```json +{ "id": "roads", "enabled": false, "category": "semantic" } +``` + --- ## 本地预览必须走 HTTP diff --git a/.trellis/tasks/08-05-cesium-preview-controls/check.jsonl b/.trellis/tasks/08-05-cesium-preview-controls/check.jsonl new file mode 100644 index 0000000..9dd3234 --- /dev/null +++ b/.trellis/tasks/08-05-cesium-preview-controls/check.jsonl @@ -0,0 +1 @@ +{"_example": "Fill with {\"file\": \"\", \"reason\": \"\"}. 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."} diff --git a/.trellis/tasks/08-05-cesium-preview-controls/design.md b/.trellis/tasks/08-05-cesium-preview-controls/design.md new file mode 100644 index 0000000..ff757c0 --- /dev/null +++ b/.trellis/tasks/08-05-cesium-preview-controls/design.md @@ -0,0 +1,28 @@ +# 设计:Cesium 语义资产检查 + +## 边界 + +Blender 场景已按集合组织要素。Cesium 导出器在不修改作者场景的前提下,复用现有导出材质处理,先生成完整主 GLB,再按集合筛选对象生成道路、建筑、植被和水体四份辅助 GLB。主 GLB 继续是默认资产和兼容基线。 + +## 资产契约 + +metadata 保持现有 `asset` 字段指向主 GLB,并将 `assets` 扩展为: + +- `main`:完整场景,默认启用; +- `roads`、`buildings`、`vegetation`、`water`:语义检查资产,默认关闭。 + +每个资产提供稳定的 `id`、中文 `label`、`type: model`、相对 `url` 与 `enabled`。旧 metadata 缺少这些额外资产时,预览按现有单资产回退路径加载。 + +## 预览交互 + +主场景模式只显示主 GLB,并保留现有 `Scene` 总开关。切入分类检查模式时隐藏主 GLB、显示四个类别复选项;选择类别时加载并显隐对应资产。离开分类模式后恢复主场景,避免完整模型和类别模型重叠渲染。 + +控制面板继续是贴边的紧凑工具面,不添加嵌套卡片。模式切换使用分段控件,类别开关按一行标签排列;窄屏时自然换行。颜色只用于状态和可访问性反馈,不做大面积装饰。 + +## 兼容与回滚 + +主 GLB、主 metadata 和既有预览文件名均不变。任意辅助 GLB 缺失时,预览记录该资产失败但完整场景仍可加载。回滚只需恢复旧 exporter/runtime,主产物仍可使用。 + +## 风险 + +多次导出会增加 Cesium 阶段耗时与磁盘占用,但只影响验证辅助产物,不改变主 GLB。导出时需确保每份辅助资产保留与主 GLB 相同的局部 ENU 坐标和材质处理。 diff --git a/.trellis/tasks/08-05-cesium-preview-controls/implement.jsonl b/.trellis/tasks/08-05-cesium-preview-controls/implement.jsonl new file mode 100644 index 0000000..9dd3234 --- /dev/null +++ b/.trellis/tasks/08-05-cesium-preview-controls/implement.jsonl @@ -0,0 +1 @@ +{"_example": "Fill with {\"file\": \"\", \"reason\": \"\"}. 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."} diff --git a/.trellis/tasks/08-05-cesium-preview-controls/implement.md b/.trellis/tasks/08-05-cesium-preview-controls/implement.md new file mode 100644 index 0000000..5103383 --- /dev/null +++ b/.trellis/tasks/08-05-cesium-preview-controls/implement.md @@ -0,0 +1,21 @@ +# 实施计划:Cesium 语义资产检查 + +1. 读取 Blender 场景集合,定义四个稳定的语义导出组;完整 GLB 仍走当前路径。 +2. 扩展 `export_cesium.py`:导出四份辅助 GLB,并把资产描述写进 metadata。 +3. 扩展 `build-area.js` 的 Cesium 输出检查和 manifest,使辅助资产成为该阶段的受管产物。 +4. 更新 `area-preview.js`、`cesium-preview.js` 与 CSS:增加完整/分类模式及分类显隐控件,保持旧 metadata 回退。 +5. 为 HTML 注入、metadata 回退和模式状态编写 Node 侧测试;运行既有预览与纯 Python 测试。 +6. 在本机重建一个区域,确认辅助 GLB 坐标、材质、模式切换及窄屏布局;此环境若 Blender 沙箱失败,不将其计为代码失败。 + +## 验证 + +```bash +node scripts/test-preview-assets.js +python3 -m unittest discover blender/tests +git diff --check +npm run build:area -- --config config/areas/nantaizi-lake-innovation-valley.json --stages cesium +``` + +## 回滚点 + +主 GLB 与原 preview 文件保持不变。若辅助导出或分类预览异常,可删除语义资产声明并恢复为单资产加载,不影响既有场景。 diff --git a/.trellis/tasks/08-05-cesium-preview-controls/prd.md b/.trellis/tasks/08-05-cesium-preview-controls/prd.md new file mode 100644 index 0000000..46c1ec8 --- /dev/null +++ b/.trellis/tasks/08-05-cesium-preview-controls/prd.md @@ -0,0 +1,47 @@ +# Cesium 预览控制 + +## 目标 + +让 Cesium 预览更便于检查已生成的园区场景,不涉及 QGIS 数据编辑或压缩产物策略。 + +## 已确认事实 + +- 预览已有 `Overview`、`Oblique`、`Detail`、`Route` 相机预设,以及车辆跟随;重复开发这些控件没有价值。 +- 预览已有主场景、路线、车辆的显隐开关;多 GLB 时也会自动提供资产级开关。 +- 当前导出物只有一个主 GLB,metadata 只记录场景统计和一个 `main` 资产,不含可用于点选的要素属性。 +- 预览是验证层,改动不应改变 Blender 主资产的几何或 QGIS 工作流。 + +## 候选范围 + +- 按场景类别显隐,例如道路、建筑、植被、水体。 +- 点击可识别对象后显示基础信息。 +- 保持既有相机预设、车辆控制和单场景加载的兼容性。 + +## 已确认决策 + +- 场景按道路、建筑、植被、水体等语义类别额外导出 GLB,Cesium 预览分别加载。 +- 保留现有主 GLB,作为完整场景基线和兼容入口。 +- 首版不提供点选要素属性;这需要逐要素 OSM 元数据映射,另行规划。 +- 控件遵循现有低干扰预览风格:紧凑分组、清晰状态、克制色彩和无装饰性卡片堆叠。 + +## 需求 + +1. Cesium 导出额外生成道路、建筑、植被、水体四类语义 GLB,并写入 metadata 资产清单。 +2. 预览默认加载主 GLB;用户可切换到语义资产检查模式,在该模式独立显示或隐藏每个类别。 +3. 预览保留现有相机预设、车辆巡航、主场景开关及单 GLB metadata 的兼容行为。 +4. 控件在桌面和窄屏下保持可读、可操作且不遮挡关键画面。 + +## 不在范围内 + +- QGIS 图层、人工数据修补和 reimport 流程。 +- 将压缩 GLB 设为默认产物。 +- 地图、地形或在线底图功能。 +- 点击要素属性、OSM ID 或名称映射。 + +## 验收标准 + +- [ ] 一次 Cesium 导出会保留主 GLB,并产出四个可独立加载的类别 GLB。 +- [ ] metadata 的 `assets` 同时声明主场景和各类别资产,旧 metadata 仍可作为单资产场景打开。 +- [ ] 预览可在完整场景与分类检查模式之间切换;分类模式可独立控制道路、建筑、植被、水体。 +- [ ] 现有 Overview、Oblique、Detail、Route、车辆和诊断控件仍可使用。 +- [ ] 控件在常规桌面与窄屏宽度下无重叠、无溢出,视觉层级与现有预览一致。 diff --git a/.trellis/tasks/08-05-cesium-preview-controls/task.json b/.trellis/tasks/08-05-cesium-preview-controls/task.json new file mode 100644 index 0000000..cd5cc3a --- /dev/null +++ b/.trellis/tasks/08-05-cesium-preview-controls/task.json @@ -0,0 +1,26 @@ +{ + "id": "cesium-preview-controls", + "name": "cesium-preview-controls", + "title": "Cesium preview controls", + "description": "", + "status": "in_progress", + "dev_type": null, + "scope": null, + "package": null, + "priority": "P2", + "creator": "dingkang", + "assignee": "dingkang", + "createdAt": "2026-08-05", + "completedAt": null, + "branch": null, + "base_branch": "main", + "worktree_path": null, + "commit": null, + "pr_url": null, + "subtasks": [], + "children": [], + "parent": null, + "relatedFiles": [], + "notes": "", + "meta": {} +} \ No newline at end of file diff --git a/README.md b/README.md index f2701dd..12ab47a 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,8 @@ - `.blend`:Blender 场景,包含道路、建筑、水体、植被等 - `.png`:Blender 预览渲染 -- `.glb`:Cesium 可加载的 3D 模型 +- `.glb`:Cesium 可加载的完整 3D 模型(基线产物) +- `-roads.glb`、`-buildings.glb`、`-vegetation.glb`、`-water.glb`:Cesium 分类检查用的辅助模型 - `.json`:Cesium 放置元数据和示例代码 - `-cesium-preview.html`:Cesium 本地预览页 - `-compressed-webp768.glb/json/html`:显式 `compress` 阶段生成的可选压缩预览产物 @@ -57,6 +58,9 @@ npm run build:area -- --config config/areas/nantaizi-lake-innovation-valley.json `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-手工修正工作流)。 +Cesium 预览默认显示完整场景。点击 `Inspect` 后会按道路、建筑、绿化与设施、水体加载辅助 GLB;该模式用于单独检查生成结果,主 GLB 仍是完整场景和下游使用的基线。 +压缩预览同样保留这些分类检查资产;重跑 `compress` 后应打开新生成的压缩预览 HTML。 + `compress` 不含在 `all` 里,也不能从配置文件默认开启。需要重导出 Cesium 后立刻生成压缩产物时,显式跑: ```bash diff --git a/blender/export_cesium.py b/blender/export_cesium.py index 31d63e2..b6df908 100644 --- a/blender/export_cesium.py +++ b/blender/export_cesium.py @@ -29,6 +29,16 @@ from osmassets.materials import CESIUM_EXPORT_PROPERTY, link_alpha_clip # noqa: # mesh's shared slots can recognise its own output and leave it alone. EXPORT_PREFIX = "Cesium " +# Top-level collections are the authoring scene's stable semantic boundary. +# Their numeric prefixes are draw-order labels in Blender, not part of the +# Cesium asset contract below. +SEMANTIC_ASSETS = ( + ("roads", "道路", ("03_Roads",)), + ("buildings", "建筑", ("04_Buildings",)), + ("vegetation", "绿化与设施", ("02_Green", "05_Props")), + ("water", "水体", ("01_Water",)), +) + # Fraction of its own albedo a cut-out foliage material emits, to keep the # shadowed side of a crown off Cesium's near-black ambient floor. Kept well # under the 0.18 the buildings use: a tree still has to read as lit from one @@ -544,6 +554,54 @@ def triangulate_mesh(obj): obj.modifiers.remove(modifier) +def export_glb(filepath, meshes): + """Export a prepared object subset without changing authoring visibility.""" + bpy.ops.object.select_all(action="DESELECT") + for obj in meshes: + obj.select_set(True) + bpy.context.view_layer.objects.active = meshes[0] if meshes else None + os.makedirs(os.path.dirname(filepath), exist_ok=True) + bpy.ops.export_scene.gltf( + filepath=filepath, + export_format="GLB", + use_selection=True, + export_apply=False, + export_texcoords=True, + export_normals=True, + export_materials="EXPORT", + export_image_format="AUTO", + export_tangents=True, + export_extras=True, + export_cameras=False, + export_lights=False, + ) + + +def semantic_asset_specs(glb_path, meshes): + """Derive optional inspection assets from the scene's named collections.""" + stem, extension = os.path.splitext(glb_path) + by_collection = {} + for collection in bpy.context.scene.collection.children: + by_collection[collection.name] = set(collection.all_objects) + + specs = [] + for asset_id, label, collection_names in SEMANTIC_ASSETS: + objects = set() + for name in collection_names: + objects.update(by_collection.get(name, set())) + subset = [obj for obj in meshes if obj in objects] + if not subset: + continue + path = stem + "-" + asset_id + extension + specs.append({ + "id": asset_id, + "label": label, + "path": path, + "meshes": subset, + }) + return specs + + def export(args): if not os.path.exists(args["blend"]): raise FileNotFoundError(args["blend"]) @@ -583,30 +641,10 @@ def export(args): material_map[source.name] = make_export_material(source) slot.material = material_map[source.name] - bpy.ops.object.select_all(action="DESELECT") - for obj in meshes: - obj.select_set(True) - bpy.context.view_layer.objects.active = meshes[0] if meshes else None - - os.makedirs(os.path.dirname(args["glb"]), exist_ok=True) - bpy.ops.export_scene.gltf( - filepath=args["glb"], - export_format="GLB", - use_selection=True, - export_apply=False, - export_texcoords=True, - export_normals=True, - export_materials="EXPORT", - export_image_format="AUTO", - # The foliage materials carry a normal map, and glTF leaves tangent - # derivation to the renderer when TANGENT is absent. On thin - # double-sided leaf cards that derivation is unreliable, so ship real - # tangents — it costs four floats a vertex on meshes this small. - export_tangents=True, - export_extras=True, - export_cameras=False, - export_lights=False, - ) + export_glb(args["glb"], meshes) + semantic_assets = semantic_asset_specs(args["glb"], meshes) + for asset in semantic_assets: + export_glb(asset["path"], asset["meshes"]) scene = bpy.context.scene try: @@ -626,7 +664,14 @@ def export(args): "type": "model", "url": os.path.basename(args["glb"]), "enabled": True, - }], + }] + [{ + "id": asset["id"], + "label": asset["label"], + "type": "model", + "url": os.path.basename(asset["path"]), + "enabled": False, + "category": "semantic", + } for asset in semantic_assets], "coordinate_system": "local ENU meters (X east, Y north, Z up)", "heading_correction_degrees": -90.0, "anchor": {"longitude": center_lon, "latitude": center_lat, "height": 0.35}, @@ -660,6 +705,7 @@ def export(args): print("CESIUM_EXPORT_DONE", json.dumps({ "glb": args["glb"], "metadata": args["metadata"], "meshes": len(meshes), "materials": len(material_map), + "semantic_assets": [asset["id"] for asset in semantic_assets], "anchor": [center_lon, center_lat], }, ensure_ascii=True)) diff --git a/package.json b/package.json index e110ad0..5d6783f 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "test:preflight": "node scripts/test-area-preflight.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", "test:turn-lane-arrows": "node scripts/test-turn-lane-arrows.js", "render:turn-lane-arrow-samples": "node scripts/render-turn-lane-arrow-samples.js" }, diff --git a/scripts/build-area.js b/scripts/build-area.js index fba336c..9086473 100755 --- a/scripts/build-area.js +++ b/scripts/build-area.js @@ -324,6 +324,7 @@ function exportCesium(area) { "--metadata", area.outputs.metadata, ], "cesium"); + const semanticAssets = semanticAssetRecords(area); writeCesiumPreview(area); const finished = Date.now(); const digest = glbDigest(area.outputs.glb); @@ -340,6 +341,7 @@ function exportCesium(area) { outputs: { glb: fileRecord(area.outputs.glb), metadata: fileRecord(area.outputs.metadata), + semanticAssets, }, summary: { glb: glbSummary(digest), @@ -349,6 +351,19 @@ function exportCesium(area) { }); } +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.category !== "semantic") continue; + const file = path.join(area.outputs.areaDir, asset.url); + 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"); diff --git a/scripts/compress-glb.js b/scripts/compress-glb.js index 37e66ab..5b8217d 100644 --- a/scripts/compress-glb.js +++ b/scripts/compress-glb.js @@ -144,13 +144,31 @@ 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, - }]; + const assets = Array.isArray(metadata.assets) ? metadata.assets : []; + const main = assets.find((asset) => asset.id === "main"); + metadata.assets = assets.length + ? assets.map((asset) => asset.id === "main" ? { + ...asset, + label: "Compressed Scene", + url: glbName, + enabled: true, + } : asset) + : [{ + id: "main", + label: "Compressed Scene", + type: "model", + url: glbName, + enabled: true, + }]; + if (!main && assets.length) { + metadata.assets.unshift({ + 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'/, @@ -270,9 +288,13 @@ function main() { } } -try { - main(); -} catch (error) { - console.error(error.message); - process.exit(1); +if (require.main === module) { + try { + main(); + } catch (error) { + console.error(error.message); + process.exit(1); + } } + +module.exports = { writeCompressedMetadata }; diff --git a/scripts/lib/area-preview.js b/scripts/lib/area-preview.js index 899b9cf..a35eca7 100644 --- a/scripts/lib/area-preview.js +++ b/scripts/lib/area-preview.js @@ -42,8 +42,14 @@ function cesiumPreviewHtml(glbName, metadataName, routeName, vehicleModelName, a 8 m/s
+ View + + + + + diff --git a/scripts/lib/cesium-preview.css b/scripts/lib/cesium-preview.css index 5d82b2b..957ca2f 100644 --- a/scripts/lib/cesium-preview.css +++ b/scripts/lib/cesium-preview.css @@ -198,6 +198,35 @@ body.scene-error .loading-bar span { white-space: nowrap; } +.control-label { + color: rgba(255, 255, 255, 0.68); + font-size: 11px; + text-transform: uppercase; +} + +.segmented-control { + display: inline-flex; + overflow: hidden; + border: 1px solid rgba(255, 255, 255, 0.24); + border-radius: 4px; +} + +.segmented-control button { + min-width: 58px; + border-radius: 0 !important; + background: transparent !important; + color: rgba(255, 255, 255, 0.78) !important; +} + +.segmented-control button + button { + border-left: 1px solid rgba(255, 255, 255, 0.20) !important; +} + +.segmented-control button[aria-pressed="true"] { + background: #e7f2f2 !important; + color: #143337 !important; +} + /* Playback / layers / camera each read as their own row of the panel. */ .control-group { display: flex; @@ -228,3 +257,32 @@ body.scene-error .loading-bar span { .hidden { display: none; } + +/* This must outrank `#controls label { display: inline-flex }` above. */ +#controls .hidden { + display: none; +} + +@media (max-width: 700px) { + #controls { + right: 10px; + left: 10px; + top: 10px; + } + + #diagnostics { + top: auto; + right: 10px; + bottom: 56px; + left: 10px; + min-width: 0; + max-width: none; + } + + #status { + right: 10px; + left: 10px; + bottom: 10px; + max-width: none; + } +} diff --git a/scripts/lib/cesium-preview.js b/scripts/lib/cesium-preview.js index 6bfae7b..4a094a2 100644 --- a/scripts/lib/cesium-preview.js +++ b/scripts/lib/cesium-preview.js @@ -13,10 +13,12 @@ const toggleFps = document.getElementById("toggleFps"); const toggleDiagnostics = document.getElementById("toggleDiagnostics"); const assetToggles = document.getElementById("assetToggles"); + const semanticToggles = document.getElementById("semanticToggles"); const vehicleSelect = document.getElementById("vehicleSelect"); const speedControl = document.getElementById("speedControl"); const speedLabel = document.getElementById("speedLabel"); const cameraButtons = Array.from(document.querySelectorAll("[data-camera]")); + const viewModeButtons = Array.from(document.querySelectorAll("[data-view-mode]")); // Status carries two kinds of message: the scene summary, which is what the // panel should read whenever nothing else is going on, and transient notes @@ -39,7 +41,8 @@ const cameras = createCameraPresets(viewer, metadata, placement, cruise); buildAssetToggles(assets); - bindRuntimeControls(viewer, assets, cruise, cameras); + buildSemanticToggles(viewer, assets, placement); + bindRuntimeControls(viewer, assets, cruise, cameras, placement); startDiagnostics(viewer, metadata, assets, cruise, placement); cameras.overview(); baseStatus = summaryText(metadata, assets, cruise); @@ -167,28 +170,47 @@ for (const asset of normalizedAssets(metadata)) { const id = asset.id || "asset-" + loaded.length; const label = asset.label || asset.id || asset.url; - try { - const model = await Cesium.Model.fromGltfAsync({ - url: asset.url, - modelMatrix: placement.modelMatrix, - scale: Number(asset.scale || 1.0) - }); - model.show = asset.enabled !== false; - viewer.scene.primitives.add(model); - loaded.push({ id, label, url: asset.url, model, error: null }); - } catch (error) { - console.error(error); - loaded.push({ id, label, url: asset.url, model: null, error }); - } + const entry = { ...asset, id, label, model: null, error: null }; + loaded.push(entry); + // Semantic assets are inspection aids. Defer their network and GPU cost + // until the user explicitly switches out of the normal scene view. + if (entry.category === "semantic") continue; + await loadAsset(viewer, entry, placement); } - if (!loaded.some((asset) => asset.model)) { + if (!loaded.some((asset) => asset.model && asset.category !== "semantic")) { throw new Error("No scene model could be loaded (" + loaded.length + " declared)"); } return loaded; } + async function loadAsset(viewer, asset, placement) { + if (asset.model || asset.error) return asset.model; + if (asset.loading) return asset.loading; + asset.loading = Cesium.Model.fromGltfAsync({ + url: asset.url, + modelMatrix: placement.modelMatrix, + scale: Number(asset.scale || 1.0) + }).then((model) => { + model.show = asset.enabled !== false; + viewer.scene.primitives.add(model); + asset.model = model; + return model; + }).catch((error) => { + console.error(error); + asset.error = error; + return null; + }).finally(() => { + asset.loading = null; + }); + return asset.loading; + } + function liveAssets(assets) { - return assets.filter((asset) => asset.model); + return assets.filter((asset) => asset.model && asset.category !== "semantic"); + } + + function semanticAssets(assets) { + return assets.filter((asset) => asset.category === "semantic"); } // A single-asset scene keeps the plain "Scene" checkbox; a multi-asset one @@ -221,8 +243,40 @@ toggleScene.indeterminate = shown > 0 && shown < live.length; } - function bindRuntimeControls(viewer, assets, cruise, cameras) { + function buildSemanticToggles(viewer, assets, placement) { + for (const asset of semanticAssets(assets)) { + const label = document.createElement("label"); + const input = document.createElement("input"); + input.type = "checkbox"; + input.checked = false; + input.dataset.assetId = asset.id; + input.addEventListener("change", async () => { + if (input.checked) { + const model = await loadAsset(viewer, asset, placement); + if (!model) { + input.checked = false; + input.disabled = true; + setStatus(asset.label + " unavailable"); + return; + } + model.show = true; + } else if (asset.model) { + asset.model.show = false; + } + setStatus(asset.label + (input.checked ? " visible" : " hidden")); + }); + label.appendChild(input); + label.appendChild(document.createTextNode(" " + asset.label)); + semanticToggles.appendChild(label); + asset.toggle = input; + } + } + + function bindRuntimeControls(viewer, assets, cruise, cameras, placement) { const hasVehicles = cruise.vehicles.length > 0; + const hasSemanticAssets = semanticAssets(assets).length > 0; + const sceneLabel = toggleScene.closest("label"); + let viewMode = "scene"; toggleScene.addEventListener("change", () => { for (const asset of liveAssets(assets)) { @@ -245,6 +299,58 @@ diagnosticsEl.classList.toggle("hidden", !toggleDiagnostics.checked); }); + async function setViewMode(nextMode) { + if (nextMode === viewMode) return; + viewMode = nextMode; + const inspecting = viewMode === "inspect"; + for (const button of viewModeButtons) { + button.setAttribute("aria-pressed", String(button.dataset.viewMode === viewMode)); + } + semanticToggles.classList.toggle("hidden", !inspecting); + assetToggles.classList.toggle("hidden", inspecting); + if (sceneLabel) sceneLabel.classList.toggle("hidden", inspecting); + + if (!inspecting) { + for (const asset of semanticAssets(assets)) { + if (asset.model) asset.model.show = false; + } + for (const asset of liveAssets(assets)) asset.model.show = toggleScene.checked; + setStatus("Scene view"); + return; + } + + for (const asset of liveAssets(assets)) asset.model.show = false; + await Promise.all(semanticAssets(assets).map(async (asset) => { + const model = await loadAsset(viewer, asset, placement); + if (!model) { + if (asset.toggle) { + asset.toggle.checked = false; + asset.toggle.disabled = true; + } + return; + } + model.show = true; + if (asset.toggle) asset.toggle.checked = true; + })); + if (viewMode !== "inspect") { + for (const asset of semanticAssets(assets)) { + if (asset.model) asset.model.show = false; + } + return; + } + setStatus("Inspection view"); + } + + for (const button of viewModeButtons) { + if (button.dataset.viewMode === "inspect" && !hasSemanticAssets) { + button.disabled = true; + continue; + } + button.addEventListener("click", () => { + void setViewMode(button.dataset.viewMode); + }); + } + let stopFollow = function () {}; if (hasVehicles) { stopFollow = bindCruiseControls(viewer, cruise); diff --git a/scripts/test-compress-glb.js b/scripts/test-compress-glb.js new file mode 100644 index 0000000..1566a96 --- /dev/null +++ b/scripts/test-compress-glb.js @@ -0,0 +1,32 @@ +#!/usr/bin/env node +"use strict"; + +const assert = require("assert"); +const fs = require("fs"); +const os = require("os"); +const path = require("path"); +const { writeCompressedMetadata } = require("./compress-glb"); + +const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "compress-glb-")); +const source = path.join(tempDir, "scene.json"); +const output = path.join(tempDir, "scene-compressed.json"); +const compressedGlb = path.join(tempDir, "scene-compressed.glb"); + +fs.writeFileSync(source, `${JSON.stringify({ + asset: "scene.glb", + assets: [ + { id: "main", label: "Scene", type: "model", url: "scene.glb", enabled: true }, + { id: "roads", label: "道路", type: "model", url: "scene-roads.glb", enabled: false, category: "semantic" }, + ], +}, null, 2)}\n`); + +writeCompressedMetadata(source, compressedGlb, output); +const metadata = JSON.parse(fs.readFileSync(output, "utf8")); +assert.equal(metadata.asset, "scene-compressed.glb"); +assert.deepEqual(metadata.assets.map((asset) => asset.id), ["main", "roads"]); +assert.equal(metadata.assets[0].url, "scene-compressed.glb"); +assert.equal(metadata.assets[1].url, "scene-roads.glb"); +assert.equal(metadata.assets[1].category, "semantic"); + +fs.rmSync(tempDir, { recursive: true, force: true }); +console.log("GLB compression metadata tests passed."); diff --git a/scripts/test-preview-assets.js b/scripts/test-preview-assets.js index 0b81938..cfbf9e3 100644 --- a/scripts/test-preview-assets.js +++ b/scripts/test-preview-assets.js @@ -80,6 +80,9 @@ assert.match(html, /north<&>\u2028valley Cesium Preview<\/title assert.match(html, /Loading scene<&>\.glb/); assert.match(html, /"areaId":"north\\u003c\\u0026\\u003e\\u2028valley"/); assert.match(html, /"glbName":"scene\\u003c\\u0026\\u003e\.glb"/); +assert.match(html, /id="viewMode"/); +assert.match(html, /data-view-mode="inspect"/); +assert.match(html, /id="semanticToggles" class="control-subgroup hidden"/); fs.rmSync(tempDir, { recursive: true, force: true }); console.log("Preview asset tests passed.");