# 技术设计:Trellis spec 重建 > 对应 `prd.md`。本文件定义目录结构、每个文件的内容边界与取材来源。 --- ## 1. 为什么按这四个包切分 Trellis 单仓模式下,`.trellis/spec/` 的每个子目录自动成为一个 "spec layer" (`packages_context.py:30-41`,`guides` 被显式排除)。零配置,不需要动 `config.yaml` 的 `packages:`(那是 monorepo 用的)。 切分依据是**运行时边界**,不是目录名: | 包 | 覆盖代码 | 运行时 | 切分理由 | |---|---|---|---| | `pipeline/` | `scripts/*.js`, `scripts/lib/scene-layers.js` | Node(宿主机) | 唯一能调外部进程(QGIS/Blender/ogr2ogr)的层 | | `blender/` | `blender/**/*.py` | Blender 内嵌 Python + 系统 Python | 依赖 `bpy`,且内部还有一条纯 Python 子边界 | | `preview/` | `scripts/lib/cesium-preview.{js,css}` | 浏览器 | 无构建步骤的 IIFE,唯一的 DOM 环境 | | `config/` | `config/areas/*.json`, `config/examples/template.json` | 数据(无运行时) | 三层共同消费的契约,改一个字段会同时影响三层 | `scripts/normalize-lane-arrows.py` 虽是 Python,但跑在 QGIS 的 Python 里、由 `build-osm2streets-qgis.js` 调起,属于管线的外部工具调用,归 `pipeline/`。 ## 2. 目标结构 ``` .trellis/spec/ ├── index.md # 顶层索引:四个包 + guides 的路由表 ├── pipeline/ │ ├── index.md # 包索引 + 五个 stage 的数据流全景 │ ├── cli-and-stages.md │ ├── layer-registry.md │ └── external-tools.md ├── blender/ │ ├── index.md # 包索引 + osmassets 依赖分层图 │ ├── module-structure.md │ ├── asset-generation.md │ └── testing.md ├── preview/ │ └── index.md ├── config/ │ └── index.md └── guides/ ├── index.md # 更新:触发点换成本项目的 ├── code-reuse-thinking-guide.md # 保留,补本项目实例 ├── cross-layer-thinking-guide.md # 保留,补本项目实例 └── artifact-parity-guide.md # 新增 ``` 被删除:`.trellis/spec/frontend/` 全部 7 个文件(6 个模板 + index)。 ## 3. 每个文件的内容边界与取材 ### 3.1 `pipeline/cli-and-stages.md` | 要写什么 | 取材 | |---|---| | `--kebab-case` → `camelCase` 的参数解析约定,重复实现在三个脚本里 | `build-area.js:50`, `reimport-gpkg.js:93`, `build-osm2streets-qgis.js` | | 五个 stage 的职责与依赖顺序 | `build-area.js:32-46` 的顶层调度 | | `reimport` 不在 `all` 里,是恢复步骤不是构建步骤 | `build-area.js:157-160` 注释 | | `intermediates` × `reimport` 互斥,显式抛错 | `build-area.js:19-26` | | 配置归一化:从 `id` 推导默认输出路径,`outputs` 可覆盖 | `build-area.js:74` `normalizeAreaConfig` | | 缺失必填项用 `requireText` 抛错而非默认值兜底 | `build-area.js:143`, `reimport-gpkg.js:125` | | 外部命令统一走 `runCommand`,非零退出即终止 | `build-area.js:301` | **边界**:只写"怎么加一个 stage / 怎么加一个 CLI 参数",不复述 README 里的用法示例。 ### 3.2 `pipeline/layer-registry.md` 最重要的一篇。核心是"九个图层在两种语言里各存一份"这个刻意设计。 | 要写什么 | 取材 | |---|---| | `SCENE_LAYERS` 是 JS 侧唯一事实源,`zIndex` 兼作绘制顺序 | `scene-layers.js:3-13` 顶部注释 | | 从前同一份表复制了四次(z_index 表 / 样式 JSON / QGIS 工程 / README),改一处漏一处会静默错栈 | `scene-layers.js:5-10` | | `outline: null` = 无描边,QGIS 侧转成全透明 | `scene-layers.js:13-14` | | `mergeScene(getCollection)` 用回调取集合,让 build(内存)和 reimport(磁盘)共用同一套合并 | `scene-layers.js:107-124` | | Python 侧 `catalog.ROAD_LAYERS` 存的是另一组事实(Blender 高度 `z` + 线性色) | `catalog.py:25-47` | | **颜色故意不同步**:JS 是 QGIS sRGB 调试色,Python 是 Blender 线性场景色,分别调过 | `catalog.py:11-15` | | `check_layers()` 只校验图层**集合与顺序**,读的是产物 `osm2streets_scene_style.json` | `catalog.py:162-195` | | 校验是 warn 不是 fail:过期或缺失的输出目录不该阻断重建 | `catalog.py:168-169` | | **加一个图层的完整清单**:改 `scene-layers.js` + `catalog.py` 两处,顺序必须一致 | 综合 | ### 3.3 `pipeline/external-tools.md` | 要写什么 | 取材 | |---|---| | QGIS 可执行文件路径推导(`Contents/MacOS/{ogr2ogr,ogrinfo}`),启动前 `existsSync` 校验 | `reimport-gpkg.js:30-41` | | GDAL 必须注入 `PROJ_LIB` / `GDAL_DATA`,否则坐标系静默出错 | `reimport-gpkg.js:132-137` | | **不设 `COORDINATE_PRECISION`**:会触发精度裁剪,实测丢 28 个顶点 | `reimport-gpkg.js:152-157` | | `ogr2ogr` 失败会留 0 字节文件 → 必须 staging 目录先导出+校验,全通过才拷回 | `reimport-gpkg.js:11-13, 61-91` | | 用 `copyFileSync` 不用 `rename`:临时目录可能跨文件系统 | `reimport-gpkg.js:72-73` | | 导出后必须验 `type === "FeatureCollection"` 且 `features` 是数组 | `reimport-gpkg.js:168-179` | | Blender 以 `--background --factory-startup` 调起,`--` 之后才是脚本参数 | `build-area.js:237-290`, README 低层命令 | | 空图层是警告不是错误 | `reimport-gpkg.js:85-88` | ### 3.4 `blender/module-structure.md` | 要写什么 | 取材 | |---|---| | **按依赖分包不按功能**:`osm.py`/`geom.py` 纯 Python,其余可 import `bpy` | `osmassets/__init__.py:1-12` | | 这条线是几何可测试的前提,拆分前只能靠渲染整片区域来验证 | `__init__.py:9-12`, `test_pure.py:5-8` | | 各模块职责一览(geom/osm/mesh/materials/catalog/tree/water/grass/scrub) | 各文件 docstring | | 要素模块统一 `assemble(...)` 签名,返回计数供调用方汇总 | `water.py:7`, `grass.py:7`, `scrub.py:6` | | 要素模块接收裁剪边界参数,自己调 `clip_polygon`,不假设调用方已裁剪 | 三个 `assemble` 的首行 | | `catalog` 声明"是什么"、`materials` 负责"怎么建"——这个拆分让 catalog 能被无 Blender 环境读取 | `materials.py:1-7` | | 新增一种 OSM 要素 = 新增一个模块 + 注册一行,不改 `build()` | `docs/refactor-plan.md` 目标节 | ### 3.5 `blender/asset-generation.md` | 要写什么 | 取材 | |---|---| | `MeshBatch` 是主力:批成一个 mesh datablock,压低对象数和 glTF 节点数 | `mesh.py:1-9` | | 累积几何后调一次 `finish()` | `mesh.py:6-8` | | 树用实例化:import 一次 → bake 朝向 → 每棵树只链一个轻对象复用 datablock | `tree.py:1-8` | | 材质在本地重建而非沿用源文件,因为两个源模型都不可直接用(apple 57% 贴图透明,需 alpha-clip) | `tree.py:12-16` | | `MATERIALS` / `ROAD_LAYERS` 是 list,**顺序决定 GLB 材质索引**,只能追加 | `catalog.py:16-18` | | `kind` 选择构建器:`solid` / `textured`,`procedural` 叠加噪声 | `catalog.py:52-55` | | Cesium 侧的调色覆盖(`cesium` 字段 + `CESIUM_EXPORT`)为什么单独存在 | `catalog.py:139-153` | | Cesium 默认光照偏白,场景内材质普遍手工提亮过;新资产不提亮会显得发黑 | `docs/changelog.md` 2026-07-31 条目 | ### 3.6 `blender/testing.md` | 要写什么 | 取材 | |---|---| | 运行方式:`python3 -m unittest discover blender/tests`,不需要 Blender | `test_pure.py:1-3` | | **期望值必须从几何推导,不能录制当前实现**——录制型测试会把 bug 固化成规范 | `test_pure.py:9-11` | | 每个几何函数都覆盖退化输入(空、单点、共线、重复顶点、零长线段) | `test_pure.py` 各 `test_degenerate_*` | | 覆盖除零守卫要写明针对哪一行代码 | `test_pure.py:76-78` | | 随机采样函数必须验 seed 可复现 | `test_pure.py:135-138` | | 解析器的容错语义:坏节点跳过不致命,缺 bounds 直接抛 | `test_pure.py:343-356` | | 只能测纯 Python 侧;`bpy` 侧的回归靠 parity 校验 | 指向 `guides/artifact-parity-guide.md` | ### 3.7 `preview/index.md` | 要写什么 | 取材 | |---|---| | 无构建步骤:IIFE + `"use strict"`,通过 `window.OSM_ASSET_PREVIEW_CONFIG` 接参 | `cesium-preview.js:1-4` | | HTML 由 `build-area.js:697 cesiumPreviewHtml()` 生成,注入需转义(`escapeHtml` / `escapeScriptJson`) | `build-area.js:759-767` | | DOM 句柄在顶部集中获取,不散在函数里 | `cesium-preview.js:5-20` | | status 分两类消息(场景摘要 vs 瞬时提示),摘要要保留以便瞬时提示可被替换而非摧毁 | `cesium-preview.js:21-25` | | `Cesium.Ion.defaultAccessToken = ""`:不依赖 Ion 服务 | `cesium-preview.js:27` | | 必须用 HTTP 服务打开,`file://` 会被浏览器拦截 | README 预览节 | | 车辆巡航是预览层实验功能,不影响 Blender/GLB 主资产 | README 实验节 | ### 3.8 `config/index.md` | 要写什么 | 取材 | |---|---| | 字段全表与默认值 | `config/examples/template.json`, `build-area.js:74` | | 路径必须绝对 | template.json | | `outputs` 覆盖是逃生舱,默认从 `id` 推导 | README 区域配置节 | | `qgis.*` 各旋钮的物理含义(arrowScale / arrowMergeTriangles / arrowOutlineSimplifyMeters / intersectionCornerSourceMaxDimensionMeters) | README QGIS knobs 节 | | `arrowOutlineSimplifyMeters: 0.05` 这个默认值的来历(去掉两个畸形尾顶点而不动箭头头部) | README | | `intersectionCornerSourceMaxDimensionMeters` 为什么要过滤大多边形(会盖住可行驶路口) | README | | 新增区域:从 `config/examples/template.json` 复制,落到 `config/areas/` | README | | 已沉淀的两个区域配置 | `config/areas/` | ### 3.9 `guides/artifact-parity-guide.md`(新增) | 要写什么 | 取材 | |---|---| | 什么时候需要跑 parity:任何声称"纯重构"的改动 | `docs/refactor-plan.md` 硬约束节 | | 三件套分工:`scene_digest.py`(.blend 结构)/ `glb-digest.js`(GLB 结构)/ `parity.js`(驱动+比对) | refactor-plan 工具表 | | **必须先做 control 实验**(同代码跑两次)确定天然不稳定字段,跳过这步的 parity 校验是假的 | refactor-plan | | 已确定的不稳定字段与原因:blend sha256(内嵌绝对路径+图片打包顺序)、PNG(EEVEE 非位级可复现)、accessor 数(UV 浮点噪声影响去重) | refactor-plan control 结论 | | 忽略名单写在 `parity.js:IGNORED_PATHS` 且必须附原因 | `parity.js:28-32` | | 真正的契约:stage stdout 标记 + .blend 全量结构摘要 + GLB node/mesh/material/image + `.json` | refactor-plan | | 基线落在 gitignore 的 `outputs/_refactor-baseline/`,是本地草稿不是产物 | `parity.js:16-17` | ### 3.10 `guides/` 现有两篇的本地化 保留通用内容,把"触发点"清单换成本项目的真实场景: - `cross-layer-thinking-guide.md`:加图层表 JS↔Python 双向同步、GeoJSON→gpkg→GeoJSON 往返、材质名跨 `generate_scene.py`/`export_cesium.py` 对接 - `code-reuse-thinking-guide.md`:加 `parseArgs` 在三个脚本重复实现、`clip_polygon` 在四个要素模块各调一次 ### 3.11 `spec/index.md`(新增) 一张路由表:改哪类代码 → 先读哪个包的 index。加一句"跨语言/跨层改动先读 `guides/`"。 ## 4. 风险与对策 | 风险 | 对策 | |---|---| | 写成"应该怎么做"的理想规范,与代码实际不符 | 每条约定必须能追到 `path:line`;PRD 验收标准里已定"每文件至少 2 处真实引用" | | 把 README 的用法说明搬进 spec,造成两处维护 | spec 只写"怎么改代码",用法一律指向 README | | 删 `frontend/` 时误删 `guides/` | 只删 `.trellis/spec/frontend/` 单个目录,删后立即用 `--mode packages` 验证 | | 行号引用随代码变动失效 | 行号只作定位提示,同时写函数名/常量名;关键处引用注释原文而非行号 | ## 5. 兼容性 - 不动 `config.yaml`:单仓模式自动扫描 `spec/` 子目录 - 不动 `.template-hashes.json`:只有 2 个条目,均与本任务文件无关,`trellis update` 不会覆盖 - 现有任务的 `implement.jsonl` / `check.jsonl`:本任务尚未生成,无需迁移