技术设计: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/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 + <area>.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:本任务尚未生成,无需迁移