项目源起
OpenCSG.NET 并非从零开始,而是一次"站在前人肩膀上"的整合优化。它的技术血统可以追溯到 JavaScript 社区的一行经典代码:
OpenJsCad csg.js(JavaScript 原始实现)
└── praeclarum/Csg(手工 C# 移植)
└── hypar-io/Csg(BSP 精度修复)
├── Csg ──────── Union 原点居中、NaN 校验
└── DotNetCsg ── 二进制 STL、迭代 BSP、Rotate 变换
└── OpenCSG.NET(本项目)
关键借鉴与改良:
-
Csg 贡献了
Union原点居中算法------这是从 hypar-io 社区 PR 来的核心改进。大坐标下 BSP 树浮点精度衰减是经典问题,Csg 的解法是:运算前将所有形体平移到包围盒中心,在原点附近完成裁剪,再平移回去。这一思路直接沿用在 OpenCSG.NET 中。 -
DotNetCsg 贡献了三项工程化改进:(1)
Formats.cs增加了二进制 STL 写出(原版仅支持 ASCII),文件体积缩小约 2/3;(2)Tree.cs将 BSP 递归遍历改写为显式Queue/Stack迭代,消除了栈溢出风险;(3)Solid.cs新增RotateX/Y/Z便捷方法。此外还有实际工程示例Runner.CPurlin(钢构 C 型檩条开孔建模),可作为 API 调用参考。 -
跨平台兼容是整合过程中遇到的最大技术挑战(见下文),最终通过几何等价性检查而非逐顶点比对来保证测试可重复性。
1. 核心算法:BSP 树的布尔运算
CSG 的本质是在三维空间中做集合运算。给定两个形体 A 和 B:
- 并集 (Union):A 在 B 外的部分 + B 在 A 外的部分
- 差集 (Subtract):A 在 B 外的部分 + B 在 A 内的部分(翻转法线)
- 交集 (Intersect):A 在 B 内的部分 + B 在 A 内的部分
这些运算的数学定义简洁,但工程实现的核心挑战只有一个:如何判断一个多边形在另一个形体的内部还是外部?
BSP(Binary Space Partitioning,二叉空间分割)树给出了答案。它是计算机图形学中的经典数据结构,最早用于 Doom 等游戏的可见面渲染。
BSP 树构建
以形体 A 的所有多边形作为输入,算法流程如下:
- 选择一个多边形,用它的所在平面作为分割平面
- 将剩余多边形按分割平面分类:在前面的归入
front子树,后面的归入back子树 - 被平面穿过的多边形一分为二,分别归入前后
- 在
front和back子树上递归执行步骤 1-3
最终得到的二叉树,每一层的平面将空间分为"内部"和"外部"两个半空间。
裁剪运算
这是 BSP 树的核心操作。当形体 A 相对于 B 做裁剪时:
- A 的每个多边形递归穿过 B 的 BSP 树
- 到达叶节点时,判断多边形在该节点平面之前还是之后
- 在前:继续裁剪
front子树 - 在后:继续裁剪
back子树 - 被分割:分两半分别处理
- 结果为"内部"(在 B 内):丢弃该片段
- 结果为"外部"(在 B 外):保留
csharp
// 裁剪的核心:递归遍历 BSP 树
void ClipPolygons(List<Polygon> polygons, Node node)
{
var front = new List<Polygon>();
var back = new List<Polygon>();
foreach (var poly in polygons)
node.Plane.SplitPolygon(poly, ref front, ref back, ref onFront, ref onBack);
if (node.Front != null) ClipPolygons(front, node.Front);
if (node.Back != null) ClipPolygons(back, node.Back);
// 在 back 叶节点保留多边形(外部),在 front 叶节点丢弃(内部)
}
实际实现中,Tree.cs 使用了显式 Stack<Args> 迭代而非递归,避免深度形体导致的栈溢出:
csharp
// DotNetCsg 的迭代优化:用显式栈替代递归
struct Args { public List<Polygon> Polygons; public Node Node; }
void ClipPolygons(List<Polygon> polygons, Node node)
{
var stack = new Stack<Args>();
stack.Push(new Args { Polygons = polygons, Node = node });
while (stack.Count > 0)
{
var args = stack.Pop();
// 裁剪逻辑...
}
}
并集运算的精度优化
经典 BSP 并集有一个隐蔽问题:在大坐标下,浮点数的有限精度导致平面分类出现微小偏差,最终形体表面出现"裂缝"(缺失三角形)。Csg 项目的 PR 贡献了一个优雅解法:
csharp
// OpenCSG.NET/Solid.cs --- 带原点居中的 Union
public Solid Union(params Solid[] others)
{
var all = new List<Solid> { this };
all.AddRange(others);
// 计算所有形体的联合包围盒
var bounds = CombinedBoundsAll(all);
var center = bounds.Center;
var halfSize = bounds.Size * 0.5;
// 将全部形体平移到包围盒中心,确保裁剪在原点附近进行
for (var i = 0; i < all.Count; i++)
all[i] = all[i].TranslateBy(-center);
// 在原点附近裁剪(浮点精度最佳)
var result = all[0];
for (var i = 1; i < all.Count; i++)
result = UnionSubLocal(result, all[i]);
// 平移回原始位置
return result.TranslateBy(center);
}
为什么有效? 双精度浮点数在 0 附近的分辨率最高(约 2.2e-16),而在数万单位的坐标下分辨率衰减。将形体移至原点裁剪,等于将所有关键计算放在浮点数的最优精度区间。这一思路不仅适用于 CSG,任何涉及空间计算的工程场景都可以借鉴。
2. 多边形重三角化:从 BSP 碎片到优化输出
BSP 裁剪会产生大量共面碎片------原本一个完整的面被分割平面切成了多个小三角形。San 最终输出优化且可渲染的网格,需要将这些共面碎片重新合并三角化。
OpenCSG.NET 采用的算法是 Coplanar Retesselation(共面重三角化),这是 OpenJsCad csg.js 原版的核心贡献之一。
算法原理:
- 收集所有共面的多边形,投影到 2D 平面上
- 使用扫描线算法------按 Y 坐标逐行扫描,维护活跃边列表
- 在扫描线之间用梯形填充多边形轮廓
- 将 2D 三角形投影回 3D 空间,插值纹理坐标
关键细节: 由于浮点容差,相邻多边形的共享边可能"几乎但不完全"重合。解决方案是使用 FuzzyCsgFactory(容差 1e-5)进行顶点去重------空间距离在容差范围内的顶点被视为同一点:
csharp
// 顶点去重工厂 --- 1e-5 空间容差
class VertexFactory
{
Dictionary<Vector3DRounded, Vertex> _vertices;
Vertex GetVertex(Vector3D pos)
{
var key = new Vector3DRounded(pos); // 四舍五入到 1e-5
if (_vertices.TryGetValue(key, out var v))
return v; // 复用已有顶点
var newVertex = new Vertex(pos);
_vertices[key] = newVertex;
return newVertex;
}
}
这一步虽然增加了运算开销,但对于形体的"水密性"(Watertightness)------即网格没有裂缝、是一个封闭流形------至关重要。
3. 基础形体生成
OpenCSG.NET 提供三种基础形体,全部通过 static class Solids 的工厂方法创建:
Sphere(球体)
经典的经纬网格------双循环生成顶点和三角形带:
csharp
public static Solid Sphere(double r = 1, int resolution = 12, bool center = true)
- 分辨率控制平滑度:
resolution=12对应横向 24 列、纵向 12 行 - 默认圆心在原点,可通过
SphereOptions自定义轴向(支持椭球体)
Cylinder(圆柱体)
支持 Start 和 End 端点定义任意方向的柱体,以及两端不同半径(截锥):
csharp
public static Solid Cylinder(
double r = 1, double h = 1,
Vector3D? start = null, Vector3D? end = null,
int resolution = 12, double sectorAngle = 360, bool center = true)
- 支持
SectorAngle控制扇形角度(非 360° 即部分圆柱) RadiusStart/RadiusEnd可生成锥台(Frustum)
Cube(立方体)
通过位运算生成 8 个顶点、6 个面的索引表------简单但高效:
csharp
public static Solid Cube(
double size = 1, bool center = true,
Vector3D? sizeXYZ = null)
4. 跨平台浮点差异的终极解法
这是项目中遇到的最深刻的工程问题。
问题本质
同一段 C# 代码,在 Windows 和 Linux 上运行,产生不同结构 的 STL 输出。差异不是浮点末位舍入问题,而是 BSP 树的拓扑结构发生了改变------生成的三角形数量和连接方式不同。
根因
根源在于 .NET Runtime 的 Math.Cos、Math.Sin 等超越函数在两个平台上返回的值存在 1 ULP 级别的差异。这看似微不足道,但在 CSG 算法链中被逐级放大:
Math.Cos 1 ULP 差异
→ 形体顶点坐标微小偏移
→ Plane.SplitPolygon 平面分类摇摆(点在平面"之前"还是"之后")
→ BSP 树结构不同(哪个多边形成为分割平面?前/后分支不同)
→ 裁剪结果不同(不同的三角形划分)
关键洞察: Plane 分类是一个二值决策------distance > 0 还是 distance < 0。距离恰好在零附近的点,1 ULP 的差异足以翻转分类结果。这导致整个 BSP 树结构变成了"薛定谔的树"------在不同平台上坍缩为不同的形态。
解法
放弃逐顶点精确比对,改用几何不变量进行等价性验证:
csharp
bool StlStringEquals(string a, string b, double tolerance, out string message)
{
var aTriangles = ParseTriangles(a);
var bTriangles = ParseTriangles(b);
// 检查 1:三角形数量
if (aTriangles.Length != bTriangles.Length)
return false;
// 检查 2:总表面积(容忍 1e-10 相对误差)
var aArea = ComputeTotalArea(aTriangles);
var bArea = ComputeTotalArea(bTriangles);
if (Math.Abs(aArea - bArea) > tolerance &&
Math.Abs(aArea - bArea) > tolerance * Math.Max(aArea, bArea))
return false;
// 检查 3:包围盒
var aBB = ComputeBounds(aTriangles);
var bBB = ComputeBounds(bTriangles);
// 六个边界值逐一比较...
return true;
}
三项检查------三角形数量、总表面积、包围盒------都是 BSP 树计算的不变量。无论平面分类如何摇摆,只要算法正确,两个楔入同一位置的单位球,其并集的表面积一定是相同的,体积占用的空间也一定是相同的。
方法论启示: 浮点数的不确定性不应被"修复"(这违反物理定律),而应被契约重新定义------将"精确输出"的契约放宽为"几何等价",在更抽象的层面上验证正确性。
5. STL 格式输出
STL(Stereolithography)是 3D 打印和 CAD 领域的通用格式,本质是将形体表示为三角形面片列表。
OpenCSG.NET 支持两种输出模式:
ASCII 格式(人类可读):
solid myshape
facet normal 0.0 0.0 1.0
outer loop
vertex 1.0 2.0 3.0
vertex 4.0 5.0 6.0
vertex 7.0 8.0 9.0
endloop
endfacet
...
endsolid myshape
二进制格式(高效紧凑):
80-byte header + 4-byte triangle count + N × (12 floats + 2 bytes)
ASCII 适合调试和简单工具链,二进制适合生产环境------对于含有数万三角形的形体,二进制 STL 体积通常只有 ASCII 的 1/3。
csharp
// 两行代码写出 STL
using (var fs = File.Create("output.stl"))
using (var writer = new BinaryWriter(fs))
mySolid.WriteStl("mySolid", writer);
结语
OpenCSG.NET 是一个小而精的几何计算库------核心算法不到 3000 行代码,但每一个函数背后都凝聚了计算机图形学数十年的研究成果。BSP 树的裁剪精度、共面重三角化的扫描线算法、超越函数的跨平台不确定性------这些都是典型的几何计算工程挑战。
如果你对 CSG 或计算几何感兴趣,欢迎浏览源码。STL 导出到切片软件即可 3D 打印------理论离物理世界只有一行代码的距离。