# 架构与取舍

这份文档回答一个问题：**为什么代码长这样。**
接口列表看 `README.md` 和 `moon info` 生成的 `.mbti`；这里只写判断和代价。

## 模块图

```
        sdf.mbt            vec.mbt
   （隐式实体 + 包围盒）   （Vec3/Mat3/Aabb）
            │                  │
            └────────┬─────────┘
                     ▼
               marching.mbt
        （采样 → 拆四面体 → 焊接 → 投影）
                     │
                     ▼
                 mesh.mbt
        （体积/面积/水密/欧拉/指纹）
                     │
                     ▼
                export.mbt
        （OBJ / ASCII STL / 二进制 STL / 回读）

examples/demo   命令行演示，打印体积与拓扑
examples/bench  耗时基线（core 的单调时钟）
examples/web    wasm-gc 验收壳（页面 + Node 两端跑同一份 wasm）
tools/          外部验收：Python 解析基准、Node 验收、一键 PASS 表
```

数据是单向的：`Sdf`（函数式隐式实体）→ `Mesh`（位置数组 + 索引数组）→
字节流。没有任何一步反向修改上游，所以每一段都能单独对拍。

## vec.mbt：只做几何需要的运算

不做通用线性代数库。`Vec3` 只有加/减/乘/点/叉/长度，`Mat3` 只有转置与
矩阵乘向量，`Aabb` 只有 `min` / `max` / `expand` / `union`。理由很实际：
SDF 的每个节点都要在体素级的循环里求值，多一层抽象就多一层开销，而且
用不到的 API 只会增加 review 面积。

`Mat3` 的列是局部基，`Sdf::Rotate` 求值时用转置把世界坐标拉回局部——
这比存矩阵逆更省，也不用担心非正交矩阵求逆的数值问题（旋转矩阵天然正交）。

## sdf.mbt：隐式实体是一棵树

`Sdf` 是代数数据类型：`Sphere` / `Box` / `Plane` / `Cylinder` / `Torus`，
加三个变换节点（`Translate` / `Rotate` / `Scale`）和四个布尔节点
（`Union` / `Intersect` / `Subtract` / `SmoothUnion`）。

**布尔直接用距离函数组合**，不做网格布尔：`min` / `max` / 取负。这是 SDF
建模的核心优势——布尔不会产生自交、不会丢精度，代价是这对 `min` / `max`
在交线附近给出的距离不是真距离（是保守下界），所以网格化之后必须做顶点
投影（见下）。

**`f == 0` 一律算外部。** 边界点归外部，配合插值时把参数夹在
`[1e-6, 1-1e-6]`，避免零面积三角形。这条约定是整条流水线的前提：
如果一半地方算内、一半算外，相邻体素会把同一个面算两次，网格就出现
重面，水密判据立刻失效。

**每个节点都回答自己的包围盒**（`bounds()` 返回 `Aabb?`，返回 `None`
表示无界）。网格化只在这个盒子外扩一圈的范围里采样，这是唯一一处
「可能算错就会漏掉整个模型」的地方，所以每个组合节点都按「子节点的
并集」取保守值，宁可多采。

**`Scale` 的负值和非正值当空实体处理**（距离恒为 0），而不是 panic：
程序化建模里几何参数来自上游计算，退化是常态，静默给出空结果比崩溃更
容易定位。

## marching.mbt：确定性优先于速度

一个 `Double` 分辨率参数，体素化 `bounds`，每个立方体沿固定对角线
`(0,0,0)-(1,1,1)` 拆成 6 个四面体，逐个四面体按「哪些顶点在内」查表
生成三角形。

**为什么是四面体而不是 marching cubes。** 四面体的 16 种符号组合、
每种最多 2 个三角形，没有歧义面（marching cubes 的 ambiguous case 需要
额外的旋转表来消解）。代码量小一半，而且不存在「同一份 SDF 在不同实现
里给出不同拓扑」的隐藏分支。

**对角方向写死。** 换一种拆分，同一 SDF 同一分辨率会得到不同的三角数，
体积就不可比，跨版本的基线也失效。固定一种，等于把「结果只取决于输入」
这件事钉死在实现里。

**焊接用「两个格点索引的 min/max」当键，不用距离阈值。** 同一条几何边
无论从哪个四面体、哪个方向被访问，`min -> max` 都是同一个键；插值位置也
统一按这个方向算，所以位置逐位一致。`eps` 量化做不到这一点——它会把
「几乎重合但不重合」的两个顶点并成一个，破坏水密；也会让结果依赖遍历
顺序。

**绕向用顶点处的梯度判定。** 参考点不能用格点：格点可能恰好贴在等值面上，
方向由浮点噪声决定。交点就在面上，梯度就是外法线。逐面判定之后仍保留
「整体体积为负则全部翻转」的兜底——两种判据一致时才认为结果可信。

**顶点投影 2 次。** 交点沿梯度往回推回等值面，修正线性插值的误差。次数
写死成 2 而不是「迭代到收敛」：迭代次数随输入变化会让结果不可复现，也
让耗时不稳定。

## mesh.mbt：拓扑判据都是组合量

**体积用散度定理** `V = Σ a·(b×c)/6`。它同时是朝向自检：朝向正确时为正，
不需要再单独遍历面判法线。测试里所有体积断言都隐含了「朝向正确」。

**水密 = 每条无向边恰好 2 个面。** 边编码成 `min * V + max` 的 `Int64`，
排序后扫描分组。`V` 是顶点数，编码无歧义，乘积不会溢出。

**排序自己写。** `sort_i64` 是归并排序，不用标准库：这里要的是「同输入
逐字节可复现」，自己写一份反而最容易保证。代价是几十行代码和一个
`O(n log n)` 的递归。

**欧拉示性数 `V - E + F`。** 球 2、环面 0、两个洞 -2，全都是精确整数，
比体积更适合当回归断言（体积有插值误差，拓扑没有）。`genus()` 在
χ 为奇数时返回 `-1` 而不是编一个数：奇数意味着网格不闭合或有共享顶点
的非流形处，这种时候任何「亏格」都是假的。

**`hash()` 是 FNV-1a 64，覆盖顶点位模式与索引序列。** 同一后端里「跑两次
一样」只是必要条件；把指纹写成测试常量，js / wasm-gc / native 三者算出
同一个值，才排除了浮点求值路径与哈希遍历顺序的差异。

## export.mbt：字节级可复现

**STL 用 `f32`，OBJ / ASCII STL 用十进制。** 二进制 STL 的顶点是 4 字节
浮点，这是格式定的，也因此它比 ASCII 更适合当「导入切片软件」的交付格式。

**回读按 `f32` 位模式精确去重。** STL 没有索引，同一个顶点在每个相邻
三角形里重复出现。用距离阈值去重会把相距很近的两个真顶点并成一个，
顶点数就还原不回去，水密判据也随之失真。按位模式做键，往返后顶点数、
欧拉示性数与体积都能对上（体积相对误差 < 1e-6，剩下的是 `f32` 舍入）。

**字节序手写，不用实验 API。** `read_u32_le` 自己拼 4 个字节，不用
`Bytes::unsafe_extract_uint_le`——后者带 `unsafe` 前缀且属实验接口，
工具链升级会直接把回读弄断。

## examples/web：绕开 wasm-gc 的字符串 ABI

wasm-gc 里 `String` 是 GC 对象，JS 侧拿到的是不透明引用，读不出字符。
所以不导出 `report() -> String`，而是导出三个整数接口：`selftest()` 返回
通过项数，`report_len()` 返回码元数，`report_code_unit(i)` 返回第 `i` 个
UTF-16 码元。宿主一个 `for` 循环就能拼回文本，零胶水代码。

页面用 `?selftest=1` 自动执行并在 DOM 写结论，这样无头浏览器
（`--dump-dom`）能直接抓取。Node 侧 `tools/wasm_check.mjs` 实例化**同一份**
`web.wasm` 做同样的调用——「Node 通过」和「页面通过」是同一件事。

## 验收策略：每条断言都能把当前代码判失败

- **解析基准外置**：`tools/oracle.py` 只用 Python 标准库手算期望值。期望值
  不能从被测实现里抄，否则测试只能证明「代码没变」。
- **随机点对拍**：4 个原语在 1000 个随机点上与解析距离误差 < 1e-12。这比
  挑几个好看的点更能暴露公式抄错。
- **退化用例单独成组**：相切球、零厚度板、空交集、贴面格点、退化包围盒、
  超低分辨率——六个用例要求零 panic，因为上游参数来自计算，退化是常态。
- **协议级验收**：`?selftest=1` 的文本输出与 `wasm_check.mjs` 的结果都被
  当作接口断言，而不是「看一眼截图」。
- **一键全跑**：`node tools/acceptance.mjs` 打出 PASS/SKIP 表；缺少 native
  工具链时判 SKIP 而不是 FAIL，避免「环境问题」被当成代码问题。

## AI 参与与验证

代码由 AI 生成、由人验收。流程固定成三条：

1. **每个里程碑一个语义化提交**，commit message 写清楚「为什么这么改」，
   而不是「改了哪些文件」。
2. **数字必须能复算**：体积、三角数、耗时都来自可重复的命令，闭源截图不算。
   本地没有 native 工具链时，README 的性能表就不写 native 数字（CI 补跑
   正确性，但不补造数据）。
3. **CI 三后端 + 两条独立验收路径**：同一份 wasm 在 Node 与浏览器
   （`--dump-dom`）各跑一次，同一个指纹常量在三个后端各算一次。
