CSG(构造实体几何)的布尔运算,算法本身不复杂,难的是工程细节。我们维护的是一个 .NET 上的 CSG 布尔库,实现走经典路线:BSP 分割,加固定绝对容差(1e-5)的模糊合并------顶点和平面按容差量化后做哈希去重。它的使用场景比较重:坐标经常到数万毫米,模型要导出 STL 交给下游,还要投影成第一角三视图出图。
最近一段时间集中处理了一批问题。下面按主题把其中有代表性的记下来。
一、三处静默失败
先说三个不报错、但结果是错的问题。这类问题排查起来最费时间。
第一个例子:矩阵类型里有个字段,用来标记变换是否含镜像,判断依据是左上 3×3 子阵的行列式是否为负(负缩放、反射都会产生这种情况)。实体的变换路径依赖它决定是否翻转顶点绕序和平面法线。逻辑看起来是完整的,直到给立方体做 Scale(-1, 1, 1):镜像之后法线全部朝内,按散度定理算出的净体积变成 -1。原因很简单:这个字段从来没有被赋过值,注释写了用途,但没有任何一行代码写它,所以它一直是 false。修复就是在构造函数里把行列式算出来。
这件事的另一面是检测。镜像后的立方体和原件在渲染上完全一样,肉眼看不出任何异常,能立刻把问题暴露出来的只有一条不变量:净体积必须为正。
第二个例子:三角化失败后的静默丢点。耳切法找不到合法的"耳朵"时,原实现把剩余的顶点直接丢弃,图能出,但几何少一块。现在改成直接抛异常------非简单多边形或退化输入,本来就该失败。
第三个例子:手写 JSON 转换器大小写不敏感的问题。分量名写小写 x 能读,写成大写 X 被静默忽略,坐标悄悄变成 (0,0,0),程序正常运行,结果是错的。现在分量名不区分大小写,JSON token 类型逐项校验,截面或根节点为 null 直接抛求值异常。
同一批修改里还有两处小加固:圆柱的半径、高度参数非正时直接拒绝(与圆锥的处理保持一致);用一组共线点构造平面时直接报错,而不是让索引越界。这些校验本身不产生功能,但它们决定了出错时是立刻收到信号,还是要花很久去反推原因。
二、大坐标下的键溢出
最麻烦的问题出在布尔的模糊合并工厂。它负责把足够近的顶点和平面合并,实现方式是把坐标量化成哈希键:
csharp
// 旧实现:|坐标| > 21474.8 时键溢出,x64 上会静默得到 int.MinValue
X = (int)(pos.X * 1e5 + 0.5); // 1e5 = 1 / 容差
// 现在:键用 long,取整用 Floor
X = (long)Math.Floor(pos.X * 1e5 + 0.5);
int 上限是 2147483647,除以量化系数 1e5,意味着坐标绝对值一旦超过 21474.8 mm,键就溢出了。而在 x64 上,超范围的 double 转 int 会统一得到 int.MinValue------所有超出该范围的顶点拿到同一个键,被当成同一个点合并掉。
现象很隐蔽:差集和交集退化成两个面,净体积为零,包围盒缩成一个点。并集反而是对的。它内部会先把两个实体平移到共同包围盒中心再运算,坐标回到原点附近,恰好没触发溢出;差集和交集没有这一步,直接在原始坐标上操作。这种"部分操作正常、部分操作出错"的表现最难反推。
平面键有同样的问题,而且更隐蔽:平面键里的 W = n·p 与坐标同量级,坐标到几万毫米时,W × 1e5 必然越界。
修复里还有一个容易漏掉的点:取整必须换成 Math.Floor,不能继续用 (int)(v + 0.5)。对负数,截断和四舍五入不是一回事,桶的边界在零点两侧不对称,-0.05 和 +0.05 会落进不同宽度的桶。镜像几何天然会产生成对的负坐标,同一个零件在两个镜像实例上就会得到不同的规范化结果。
回归测试覆盖三档坐标:测量坐标量级(约 ±5 万)、刚越过 int 键范围的 3 万(旧代码在这个量级必然退化)、以及百万量级。断言方式是"与原点处结果同构":面数相同,体积和包围盒在严格容差内一致,朝向不变量成立。
所有"坐标乘常数再哈希"的实现,都带一个隐含的量级假设。把假设写进注释和测试,它才站得住。
三、截面与离散化
相切轮廓与点列截面
以前做倒圆角、相切轮廓,靠的是拿圆柱等几何做布尔补料。问题出在相切位置:两个面无限接近地交汇,模糊容差在这里很容易把本该精确共享的顶点分出亚微米级的碎屑,图面上就是一道道微台阶。
更稳的做法是从构造层面解决:把截面直接定义成闭合点列,直线段的端点和圆弧的采样点共用同一个顶点,挤出时侧壁按相邻点逐段生成。接点在构造时就是精确共享的,布尔没有机会制造误差。库里新增的任意多边形截面类型就是为这个场景准备的,序列化的类型记号也跟着扩展了。
绕序规范化
点列截面带来一个次生问题:绕序。挤出链路对顺时针和逆时针敏感,而镜像(把 X 取负)会把绕序整个翻过来。处理方式是在截面展开时用带符号面积(鞋带公式)统一规整为逆时针:
csharp
double area2 = 0;
for (int i = 0; i < n; i++)
area2 += pts[i].X * pts[(i + 1) % n].Y - pts[(i + 1) % n].X * pts[i].Y;
if (area2 < 0) pts.Reverse();
这样同一零件的左右镜像实例不会再出现体积不同、落位不同的问题。
弦高容差分段
第二个离散化问题是圆弧分段数。原来全库固定 12 段/整圆,小半径够用,大半径不行:半径近两米的圆弧,单段弦高 r(1 − cos(π/12)) ≈ 0.034 r,约 65 mm,导出 STL 之后肉眼就是一折一折的。
改成按弦高容差反推:要求单段圆心角 θ 满足 r(1 − cos(θ/2)) ≤ tol,解出分段数:
csharp
var half = Math.Acos(1.0 - tol / radius);
int n = (int)Math.Ceiling(Math.PI / half);
n = Math.Min(Math.Max(n, 16), 128);
实测(0.5 mm 容差):R50 取 23 段(弦高 0.47 mm),R240 取 49 段,近两米半径取到上限 128 段(弦高 0.58 mm)。相对固定 12 段的 65 mm,改善约两个数量级。球面、圆柱圆锥、胶囊两端的半圆都接入了同一套逻辑。
实现上有两个决定:
- 默认关闭。容差默认是 0,此时行为与旧口径完全一致,既有测试全部通过;需要的一方(比如导出路径)显式开启。改动长期存在的数值行为,默认不动、显式启用是兼容成本最低的方式。
- 按线程隔离。容差是过程级配置,放普通静态字段上会让并行测试互相串扰------一个导出测试把容差设成 0.5,另一个测试的球体精度跟着变,失败得莫名其妙。加
[ThreadStatic]之后各线程独立。
另外需要说明一点:STL 是三角网格格式,不存在真正的圆弧。以上工作只是让折线足够贴近圆弧;要真正的曲线实体,得换 BREP 格式(STEP/IGES/DWG)。
四、消隐三视图
这一轮补了一条"实体 → 第一角三视图"的消隐管线,顺序是:拓扑焊接 → 特征边筛选 → Z-buffer 消隐 → 圆弧识别 → 碎片清理。每一步都有细节。
拓扑焊接。 三角网格里每个三角形独立存顶点,先按"量化格 + 邻域精确比较"把同位置的顶点焊到一张表上------不能只靠格哈希,碰撞会把不同的点误判成一个。然后按每条边两侧面片的二面角标记锐边,阈值 25°,曲面镶嵌产生的近似共面缝和真实的折边就此区分开。
特征边筛选。 每个视图只保留三类边:轮廓边(相邻两面一正一背对相机)、锐边、边界边;两侧都背对相机的边直接丢弃。这样图面上是工程轮廓,不是网格线团。
Z-buffer 消隐。 正交投影下,深度在屏幕空间是仿射的,线性插值精确,消隐可以退化成光栅化加逐点比较:把朝向相机的三角形栅格化进深度缓冲(900 分辨率),候选边沿长度采样(每边上限 4000 点),逐段与缓冲比较,切成可见段和隐藏段。深度比较要带一个相对偏置(深度跨度的 1e-3),否则贴面的边会自己遮挡自己。
圆弧识别。 这一步必须在碎片清理之前做。消隐把圆投影成一条条 0.1 mm 级的短折线,要做的是反推:把首尾相接的短段串成链,用 Kåsa 方法做代数最小二乘圆拟合,全部条件满足才确认------点偏差不超过 0.05 mm、扫掠角不小于 10°、半径与链尺度之比在合理范围内(否则直线会被拟合成半径极大的圆)。有三个细节比较关键:
- 成链不看可见性。贴着自身面的圆弧轮廓会被消隐切成"可见-隐藏"交替的碎段,按可见性分链则一条弧都认不出来。整条弧的虚实改为由所辖线段的多数票决定。
- 先折叠重复段。轴向视图里,圆柱前缘圆和后缘圆在投影上逐点重合,不折叠的话链会在两条棱之间来回跳,整圆永远闭不上口,实测只得到过 345° 的开口弧。
- 排除退化段。平行于视线的棱在投影里只是一个点,没有方向信息,但会把前缘圆和后缘圆串成一条乱链。
识别失败就回退成折线,不会丢线。离整圆只差一小段(缺口不超过 25°)时补成整圆:栅格丢一条弦是常态,而图纸上的孔应该是一个完整的圆。同圆心同半径的多段碎片弧再按角度并集合并,输出的实体数明显下降。
碎片清理。 栅格消隐的产物里,恰好贴着面的棱会被切成"可见-隐藏"交替的碎屑,实测有大量 10 mm 级的短段。清理做三件事:丢弃过短的段(阈值随视图尺度自适应);把同可见性、方向相近、端点相接的段合并,只延长不缩短;折叠同一条棱的重复投影。有一个细节容易漏:合并判据不能只看方向和投影区间,还必须校验到基准直线的垂距------竖直段在基准方向上的投影退化成一个点,只比区间会把互相垂直的两条棱并成一条错线。
视图基的右手性。 三视图的取轴必须满足 Right × Up = −Look,即构成右手正交基、投影行列式为 +1,图面不会镜像。早期原型里俯视和左视的取轴构成的是左手基,相当于把这两个视图整体镜像,非对称零件会画反。第一角画法的位置关系(俯视图在主视图正下方"长对正"、左视图在正右方"高平齐")也一并固化进了方向定义。
这条管线的一个结论:基于栅格的消隐必然产生抖动碎片,矢量化后处理是必需品。而"先识别圆弧、后清理碎片"的顺序是正确性问题,不是优化问题------清理里的共线合并会把圆弧压成直线,顺序反了,弧就再也认不回来。
五、切割体与建模助手
这一轮沉淀了一批通用助手(位姿组合、棱柱、圆角、回转体等),有几条经验比较通用。
切割体和被切几何要同形。 用旋转的大方盒做半平面切割,在一般场景够用;但做细长弦式的切割,盒子必须放大到完全覆盖,切深(√2·半边长)会膨胀到远超需要,甚至切进零件本体。这类场景适合直接拿切割多边形构造精确楔块。工具实体的形状贴合目标几何,布尔才可控。
圆角用"切尖 + 补相切圆盘"。 凸角倒圆不是直接并一个圆盘(那是加凸包),正确做法是先减掉角尖的方形,再并上与两个面都相切的圆盘,圆盘补回被切掉的部分,包络保持不变。凹角倒圆则是并一个圆心在空料侧、与两面相切的圆盘,半径受截面边界约束。原则是一致的:相切关系用几何精确构造,不要依赖布尔的容差。
相切角用解析解。 圆弧头部与斜边相切的角度,可以由"过一点的直线与圆相切"反解出一个二次方程,取较小正根。用解析角,两个零件相关的边在数学上精确平行或重合;用图纸上的近似值,亚毫米级的发散在装配布尔里就是缝隙或者互穿。
变换语义要写清楚。 库的变换节点语义是"先平移、再绕全局原点旋转"(T·R)。直觉上容易当成"绕自身中心旋转",实际会把零件甩到绕原点公转的位置上。绕任意点旋转要组合成 T(c)·R·T(−c),绕自身中心"原位转向"要拆成先只旋转、再只平移两次变换。这类语义适合写进文档、用测试锁死(旋转合成顺序是 X→Y→Z),再封装成助手函数,比让每个调用方各自踩一遍便宜。
另外一个小技巧:镜像一个成品零件时,与其用负缩放矩阵(要处理绕序翻转),不如绕某个轴旋转 180°。刚体变换保体积保形状,两个镜像实例必然全等。
六、测试:几何不变量
这一轮测试从 58 个增加到 102 个。最大的启发是:golden 文件比较对几何问题几乎不设防。原来的做法是比较导出的 STL------三角形数、总面积、包围盒。但一个绕序全部反转的实体,这三个指标和原件完全相同,镜像体同理。"法线朝内"这个级别的错误,快照比较完全看不出来。
补上的不变量有这些:
- 定向封闭流形:每条无向边恰好被两个面使用,每条有向边恰好出现一次,没有零长边和退化多边形;
- 散度定理净体积:
V = Σ v0·(v1×v2)/6,法线朝外时为正,镜像后仍必须为正(负值意味着整体绕序反转)。检测朝向问题,这是成本最低的一种手段; - 平面自洽:每个多边形存储的平面必须与顶点绕序同向,且平面过顶点;
- 体积恒等式:
A ∪ B = A + B − A ∩ B,以 1e-9 精度成立。BSP 布尔的已知产物是 T 顶点接缝(网格不是严格封闭的 2-流形,但表面不能有面积缺失),恒等式一旦破坏,说明有真实的裂缝或面缺失; - T 顶点和真裂缝的区分:把"只被一个面使用的边"分类------长边内部有顶点、或与其他边共线嵌套的,判为 T 顶点接缝;其余是缺陷,必须为零。
这些断言不看图、不比快照,只算物理量。写一次,之后任何几何改动导致的朝向或体积异常都会被立刻抓住。
七、通用能力的迁入
最后一个主题偏工程整理。这一轮把若干与 CSG 相关、且不依赖上层系统的通用能力迁进了库:建模助手、网格 IO(二进制 STL 读写)、网格后处理(拓扑焊接、图线清理、圆弧拟合),以及前面的消隐投影。过程中有几条经验:
- 库的公共 API 是公共语言。带行业术语、产品名和内部黑话的符号不适合出现在通用库的接口上,这一轮把一批领域专属命名改成了几何描述性名称。这类改名是破坏性变更,但越早做成本越低,调用方同步更新即可。
- 注释和文档要"去宿主化"。把注释里的项目名、件号、图号、专有数值和历史日期清理成通用表述;指向其他仓库、跨仓库无法解析的 XML
cref改成纯文本。库是给别人用的,文档里不该残留别人的私有词汇。 - 迁移验证用双保险:库内测试全绿,能力来源方的测试也全绿,行为不变。上游测试留在原仓库,不为迁移做多余的测试框架转换。
八、小结
- "坐标乘常数再哈希"这类量化键都有隐含的量级假设,键要够宽、取整对负数要对称,回归测试要在真实量级上做;
- 朝向性问题靠不变量抓,净体积的符号比任何渲染截图都灵敏;
- 该报错就直接报错:剖分失败、反序列化异常、参数非法,都应该显式失败,而不是静默降级;
- 离散化的质量由容差驱动,比由段数驱动更合理;改数值行为时默认关闭、显式启用;
- 矢量化管线里,阶段顺序是正确性的一部分;
- 相切、镜像、切割,优先用精确构造,把误差留在必须离散的地方;
- 测试几何要算物理量,而不是比快照。
回头看,这些坑里没有一个是新算法,都是经典算法放进"大坐标、面向工程、要出图"的场景之后暴露出来的。修完之后,内核在数万毫米的测量坐标上体积和朝向依然精确,导出的圆弧不再折线化,投影出的三视图能直接当工程图用。这些改动单个看都不大,凑在一起,整条链路才算真的可靠。