znlgis 博客

GIS开发与技术分享 — GDAL · GeoServer · PostGIS · QGIS · OpenLayers · Cesium · FreeCAD · NPOI

第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");   // 与批准结果比对
}

工作流是:

  1. 首次运行、还没有批准文件时,测试把结果写成一个 ..._.stl(rejected,被拒) 文件,并把测试标记为 Inconclusive(不确定)。
  2. 开发者人工检查这个结果(用 STL 查看器看模型对不对),确认无误后把它重命名为 accepted、提交进仓库。
  3. 此后每次运行都拿新结果和这份 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 需要三步:

  1. 定义记录(在 Profiles.cs):
    public record ZBeamProfile(double Height, double FlangeWidth, double Thickness) : Profile2D;
    

    并在 Profiles 工厂加一个便捷方法。

  2. CsgEvaluator.ExpandProfileswitch 里加一个分支,把参数展开成一圈逆时针、简单不自交Vector2D 顶点(这是耳切三角化的硬性要求,第 10 章强调过)。
  3. 若截面带孔(如圆管),记住第 10 章的教训——不要试图在一个 Profile2D 里表达洞,而是像 SquareTubeProfile 那样只给外轮廓,用 SubtractNode 在节点层挖空。

只要顶点顺序和简单多边形约束满足,拉伸、封顶、侧壁都会自动适配你的新截面。

14.8 二次开发(三):扩展序列化

如果你新增了节点或截面类型,并希望它们能被 JSON 持久化,别忘了同步第 11 章的判别式。在 CsgSerialization.cs 对应的转换器里,ReadWrite 两处 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*']

流程清晰且稳健:

  1. 检出 + 装 .NET 8 SDK
  2. 先跑测试dotnet test -c Release)——测试不过就不发布,把质量门禁前置。
  3. 从标签解析版本号v1.2.3VERSION=1.2.3
  4. 打包dotnet pack ... -p:Version=$VERSION 覆盖 csproj 里的占位版本。
  5. 推送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 二次开发、增材制造工具链中一件称手的利器。


← 上一章 | 目录