第14章:测试、性能优化与二次开发
最后一章聚焦工程质量与可扩展性——OpenCSG.NET 如何用测试守护正确性、如何用基准量化性能,以及当内置能力不够时,你如何给它做二次开发。学完本章,你不仅会”用”这个库,还能”改进”它、把它安全地融入自己的产品与 CI 流程。
14.1 质量保证:批准测试(Approval Testing)
几何算法的正确性很难用”断言某个数值等于常量”来验证——一次布尔运算产出成百上千个多边形,你不可能手写期望值。OpenCSG.NET 采用了更聪明的策略:批准测试(Approval Testing,又称黄金文件测试)。
思路是:把每个测试生成的 STL 与一份事先人工审核并”批准”的标准结果(golden file)比对。仓库的 tests/OpenCSG.NET.Tests/Results/ 目录里存着 40 多个 .accepted.stl 标准文件,测试运行时逐一核对。核心断言方法是 SolidTest.AssertAcceptedStl:
[Test]
public void Rectangle_Simple()
{
var node = new ExtrudeNode(Profiles.Rectangle(4, 2), 1);
var solid = CsgEvaluator.Evaluate(node);
AssertAcceptedStl(solid, "Extrude_Rectangle"); // 与批准结果比对
}
工作流是:
- 首次运行、还没有批准文件时,测试把结果写成一个
..._.stl(rejected,被拒) 文件,并把测试标记为Inconclusive(不确定)。 - 开发者人工检查这个结果(用 STL 查看器看模型对不对),确认无误后把它重命名为 accepted、提交进仓库。
- 此后每次运行都拿新结果和这份 accepted 比对,一致才算通过;不一致就写出 rejected 文件供 diff,测试
Fail。
这种方式让”复杂几何是否正确”变成”结果是否和上次批准的一致”,既能防回归,又能在有意改动算法时通过”重新批准”来更新基线。
14.2 几何等价比较:不是逐字节比对
AssertAcceptedStl 一个关键的巧思是:它不做逐字节文本比较(那会因浮点格式化的微小差异而误报),而是解析出三角形后做几何等价判断,容差 STL_TOLERANCE = 1e-10。它比较四个不变量:
| 比较项 | 含义 |
|---|---|
| STL 头 | 名称/头部字符串必须完全一致 |
| 三角形数量 | 面片数必须相同 |
| 总表面积 | 所有三角形面积之和,容差内相等(绝对或相对) |
| 包围盒 | min/max 的 X/Y/Z 六个边界,容差内相等 |
用”面数 + 总面积 + 包围盒”这组几何指纹来判等,既对无意义的浮点抖动免疫,又能敏锐捕捉真正的几何变化(多一个面、体积变了、位置偏了)。这是几何软件测试的实用范式,值得借鉴到你自己的项目。
14.3 测试套件全景与运行方式
测试项目 tests/OpenCSG.NET.Tests/ 基于 NUnit,覆盖了库的各个层面:
| 测试文件 | 覆盖内容 |
|---|---|
CubeTest / SphereTest / CylinderTest |
基础形体各重载与选项 |
UnionTest / SubtractTest / IntersectTest |
三种布尔运算 |
ExtrudeTest / WedgeTest |
拉伸型材与楔形 |
CsgEvaluatorTest |
声明式节点求值 |
CsgSerializationTest |
JSON 序列化往返 |
LargeCoordinateUnionTest |
大坐标数值稳定性(见第 12 章) |
ExamplesTest |
端到端示例 |
运行全部测试(回顾第 2 章的环境):
dotnet test tests/OpenCSG.NET.Tests/OpenCSG.NET.Tests.csproj -c Release
提示:批准测试依赖
Results/目录里的标准文件。若你在某个环境下看到大量Inconclusive,多半是找不到该目录或标准文件——这是设计如此的”未批准”状态,并非 Bug。
14.4 性能基准:BenchmarkDotNet
正确性之外是性能。perf/OpenCSG.NET.PerfTest 用业界标准的 BenchmarkDotNet 做微基准测试,还挂了 [MemoryDiagnoser] 来同时度量内存分配:
[MemoryDiagnoser]
public class PerfTest
{
[Benchmark(Baseline = true)]
public void Resolution10() => TestRes(10);
[Benchmark]
public void Resolution50() => TestRes(50);
}
基准负载是一个有代表性的重活:两个半径 1000 的球体做差集,分别在分辨率 10 和 50 下测量。TestRes 记录输入面数、输出面数和耗时:
static TestResult TestRes(int res)
{
var sphere1 = Sphere(new SphereOptions { Resolution = res, Radius = 1000, Center = new Vector3D(-500, 0, 0) });
var sphere2 = Sphere(new SphereOptions { Resolution = res, Radius = 1000, Center = new Vector3D( 500, 0, 0) });
var sub = Difference(sphere1, sphere2);
// …记录 Polygons / OutputPolygons / Time…
}
运行基准:
dotnet run -c Release --project perf/OpenCSG.NET.PerfTest
Resolution10 被设为 Baseline = true,报告里会给出 Resolution50 相对它的倍率,直观展示分辨率对性能的放大效应。
14.5 性能特征与优化建议
结合第 12 章的内核剖析和这个基准,可以总结出用好性能的实用经验:
- 分辨率是最大的旋钮:球/圆柱面数随分辨率平方级增长(分辨率 50 的球面数约是 10 的 25 倍),而布尔的比较成本又与面数相关(
TestResult.Compares直接用Polygons²估算,点明了最坏情况下的二次方代价)。够用就好——预览用低分辨率,出图再调高。 - 善用包围盒短路:相距很远、不相交的实体做并集会走
MayOverlap快速通道,几乎零成本(第 12 章)。装配大量分离零件时这很划算。 - 靠近原点建模:远离原点的大坐标会触发数值问题并可能增加清理开销;必要时先
Translate到原点附近(第 12 章)。 - 减少不必要的中间清理:
Subtract/Intersect的多参数重载只在最后一步做Retesselate/Canonicalize。把多次减料写成一次a.Subtract(b, c, d)通常比a.Subtract(b).Subtract(c).Subtract(d)更省——后者每步都清理一遍。 - 复用与缓存:变换用 Tag 缓存(第 7、12 章),重复子件建一次多次
Translate比重新构造便宜。
14.6 二次开发(一):给求值器补全圆锥
第 9 章提到 ConeNode 求值会抛异常。这是最典型的一个练手扩展点。补全思路:把圆锥/圆台映射成一个上下半径不等的 CylinderOptions(第 5 章讲过 RadiusEnd = 0 即真圆锥)。在 CsgEvaluator 里把那句 throw 换成真正的实现:
// CsgEvaluator.Evaluate 的 switch 分支
ConeNode n => EvaluateCone(n),
// 新增私有方法(仿照 EvaluateCylinder,沿 Y 轴、以 Center 居中)
private static Solid EvaluateCone(ConeNode n)
{
var start = n.Center + new Vector3D(0, -n.Height / 2, 0);
var end = n.Center + new Vector3D(0, n.Height / 2, 0);
return Solids.Cylinder(new CylinderOptions
{
Start = start, End = end,
RadiusStart = n.BottomRadius, // 底半径
RadiusEnd = n.TopRadius // 顶半径(为 0 则是尖圆锥)
});
}
改完记得给它补一个批准测试(AssertAcceptedStl),首次运行生成结果、人工确认后批准入库。这就是一次完整、规范的功能扩展。
14.7 二次开发(二):添加自定义截面
假设你需要一个内置七种之外的截面(比如 Z 型钢、圆管)。回顾第 10 章的拉伸管线,添加一个 Profile2D 需要三步:
- 定义记录(在
Profiles.cs):public record ZBeamProfile(double Height, double FlangeWidth, double Thickness) : Profile2D;并在
Profiles工厂加一个便捷方法。 - 在
CsgEvaluator.ExpandProfile的switch里加一个分支,把参数展开成一圈逆时针、简单不自交的Vector2D顶点(这是耳切三角化的硬性要求,第 10 章强调过)。 - 若截面带孔(如圆管),记住第 10 章的教训——不要试图在一个
Profile2D里表达洞,而是像SquareTubeProfile那样只给外轮廓,用SubtractNode在节点层挖空。
只要顶点顺序和简单多边形约束满足,拉伸、封顶、侧壁都会自动适配你的新截面。
14.8 二次开发(三):扩展序列化
如果你新增了节点或截面类型,并希望它们能被 JSON 持久化,别忘了同步第 11 章的判别式。在 CsgSerialization.cs 对应的转换器里,Read 和 Write 两处 switch 都要加上你的新 $type:
// Profile2DConverter.Read 的 switch
"ZBeam" => JsonSerializer.Deserialize<ZBeamProfile>(json, options)!,
// Profile2DConverter.Write 的 switch
ZBeamProfile => "ZBeam",
漏掉任意一处,序列化或反序列化就会因”未知 $type“而抛 JsonException。两处对称添加,才能保证第 11 章说的往返保真与幂等。
14.9 打包发布到 NuGet
库自带一条现成的发布流水线 .github/workflows/nuget-publish.yml,在推送 v* 版本标签时自动发布到 NuGet.org:
on:
push:
tags: ['v*']
流程清晰且稳健:
- 检出 + 装 .NET 8 SDK。
- 先跑测试(
dotnet test -c Release)——测试不过就不发布,把质量门禁前置。 - 从标签解析版本号:
v1.2.3→VERSION=1.2.3。 - 打包:
dotnet pack ... -p:Version=$VERSION覆盖 csproj 里的占位版本。 - 推送:
dotnet nuget push ... --skip-duplicate,用仓库密钥NUGET_API_KEY鉴权,--skip-duplicate让同版本重跑不报错。
所以为这个库(或你 fork 的版本)发新版,只需:确保测试通过 → 打一个 vX.Y.Z 标签并推送 → CI 自动测试、打包、上传。这是一个可直接照搬到自己 .NET 库的极简发布范式。
14.10 本章小结与教程结语
本章要点:
- 正确性用批准测试保证:生成 STL 与人工批准的黄金文件比对,靠几何等价(头、面数、总面积、包围盒,容差
1e-10)判等而非逐字节,dotnet test一键运行。 - 性能用 BenchmarkDotNet + MemoryDiagnoser 量化;分辨率是平方级放大器,善用包围盒短路、靠近原点、合并布尔步骤、Tag 缓存来优化。
- 二次开发三大方向:给求值器补全圆锥(映射到不等半径圆柱)、添加自定义截面(CCW 简单多边形 + 带孔靠布尔)、扩展序列化(Read/Write 两处
$type对称添加)。 - 发布用
v*标签触发的 NuGet 流水线,测试前置为质量门禁。
至此,这套 OpenCSG.NET 使用与开发教程 全部十四章讲完。我们从 CSG 与 BSP 的第一性原理出发(第 1–3 章),掌握了数学基础与命令式建模(第 4–8 章),进阶到声明式节点树、参数化型材与序列化(第 9–11 章),深入内核算法(第 12 章),完成了一个真实的冷弯 C 型钢实战(第 13 章),最后落到测试、性能与扩展(第 14 章)。
希望这套教程既能让你快速上手用几行代码造出三维实体,也能让你看透原理、在需要时改进甚至扩展这个库。愿 OpenCSG.NET 成为你参数化建模、BIM 二次开发、增材制造工具链中一件称手的利器。