第15章:测试、性能优化与二次开发
本章聚焦工程质量与可扩展性——OpenCSG.NET 如何用五层测试体系守护正确性、如何用覆盖率门禁与双平台 CI 守住质量底线、如何用基准量化性能,以及当内置能力不够时,你如何给它做二次开发。学完本章,你不仅会”用”这个库,还能”改进”它、把它安全地融入自己的产品与 CI 流程。
15.1 测试体系全景
几何算法很难用”断言某个数值等于常量”验证——一次布尔运算产出成百上千个多边形,你不可能手写期望值。OpenCSG.NET 为此搭建了四个测试工程 + 五层测试的体系:
| 工程 | 目标框架 | 职责 | 规模(约) |
|---|---|---|---|
tests/OpenCSG.NET.Tests |
net8.0;net10.0 |
主套件:单元/契约/黄金基线/缺陷回归 | 377 用例 ×2 TFM |
tests/OpenCSG.NET.SystemTests |
net8.0;net10.0 |
系统层:生成器/属性/性能/稳健性/端到端 | 1257 用例 ×2 TFM |
tests/OpenCSG.NET.TestKit |
net8.0 |
测试基础设施库(生成器、oracle、桥接器,被上两者引用) | — |
tests/OpenCSG.NET.ApiCompat |
netstandard2.0 |
编译期金丝雀:以 netstandard2.0 消费面引用核心库,验证公开 API 对旧平台可用 | — |
主套件的目录结构就是五层测试的物理形态:
| 层 | 目录 | 内容 |
|---|---|---|
| ① 单元测试 | 根目录 + Unit/ |
CubeTest/SphereTest/CylinderTest/ConeTest/UnionTest/… 形体、布尔、变换、求值的确定性用例 |
| ② 契约测试 | Contracts/ |
公开 API 的边界行为:throw 路径、退化输入、命名缺口 |
| ③ 黄金基线 | Golden/ |
几何等价比对(15.2)+ 三视图 JSON 基线(第 16 章) |
| ④ 属性测试 | Golden/、SystemTests/Property/ |
FsCheck 驱动的不变量验证(15.3) |
| ⑤ 端到端 | SystemTests/Integration/ |
管线贯通、STL/JSON 闭环、真实模型(CPurlin) |
另有两个专门目录:Defects/ 存放缺陷回归用例(15.6),Views//MeshIo//Tessellation/ 覆盖对应模块。运行方式:
dotnet test tests/OpenCSG.NET.Tests/OpenCSG.NET.Tests.csproj -c Release # 主套件
dotnet test tests/OpenCSG.NET.SystemTests/OpenCSG.NET.SystemTests.csproj -c Release # 系统套件
dotnet test tests/ -c Release -f net8.0 # 只跑 net8.0 一腿(双 TFM 会各跑一遍)
15.2 黄金基线测试(Golden Testing)
主套件里有一组黄金基线测试:把测试生成的 STL 与一份事先审核并”冻结”的基线文件比对。基线存放在 tests/OpenCSG.NET.Tests/Results/(60 个 .stl + Results/Views/ 下 15 个 .json),命名形如 ConeTest.Frustum.stl、ViewJsonGoldenTest.Cube_ThreeViews.json。
15.2.1 几何等价比较,不是逐字节比对
AssertAcceptedStl 的关键巧思是不做逐字节文本比较(那会因浮点格式化的微小差异误报),而是解析三角形后做几何等价判断(数值容差 STL_TOLERANCE = 1e-10)。它比较这些不变量:
| 比较项 | 含义 |
|---|---|
| STL 头 | 名称/头部字符串一致 |
| 三角形数量 | 面片数相同 |
| 总表面积 | 所有三角形面积之和,容差内相等 |
| 包围盒 | min/max 的 X/Y/Z 六界,容差内相等 |
用”面数 + 总面积 + 包围盒”这组几何指纹判等,既对无意义的浮点抖动免疫,又能敏锐捕捉真正的几何变化(多一个面、体积变了、位置偏了)。这是几何软件测试的实用范式,值得借鉴到你自己的项目。
15.2.2 基线升级工作流
算法有意改动(比如修复了某个缺陷、改了细分口径)时,基线需要显式升级,而不是悄悄通过:
- 测试运行后签名不一致,写出临时文件(
*_.stl/*_.json后缀)供 diff 检查; - 人工查看 diff / 用查看器确认新结果正确;
- 用
tools/promote-goldens.sh做升级前摘要提示并交互确认(--yes跳过确认,适合脚本化),把临时文件提升为正式基线; - 提交基线变更——diff 本身就是一次”几何行为变更评审”。
默认拒绝、显式批准是这个机制的灵魂:几何行为的变化永远会被看见。
15.2.3 跨平台分层
上一小节说”几何指纹”对浮点抖动免疫——但字节级基线做不到。Math.Sin/Cos/Atan2 在 Windows(UCRT)与 Linux(glibc)之间有 ≤1 ULP 的差异,经曲面细分、坐标旋转、圆弧拟合传播后,会把同一个几何放大成不同的数字文本,甚至让投影拓扑的计数发生翻转(登记为 DEFECT-011,裁定”不修但缓解”)。
CI 的处理是分层口径:
- Windows:
File.ReadAllBytes逐字节比对基线(基线按 Windows 输出冻结); - 非 Windows(Linux):语义校验——JSON 的计数/可见性标志必须精确,数值容差
1e-6;STL 的solid名与三角形数必须精确,顶点容差1e-4绝对; - 两个已定性的平台拓扑敏感夹具走专门通道:跳过与 Windows 基线的数值比对,但输出带实测差量的 DEFECT-011 说明——响亮报告,绝不静默。
其余计数/拓扑不一致在两平台一律判红——若某平台转红且不在语义容差内,视为真发现,必须先调查取证并登记缺陷,不允许直接把平台从矩阵里删掉。
15.3 属性测试与 oracle
“举例测试”(给定输入断言输出)覆盖不了几何的组合爆炸。系统套件用 FsCheck 做属性测试——不写具体期望,而是断言”对任意输入都应成立的恒等式”,由框架生成大量随机用例:
| 层 | 主题 | 代表恒等式/oracle |
|---|---|---|
| Tier 1 | 布尔恒等式 | A ∪ B = A + B − A ∩ B(面积恒等式,448 用例) |
| Tier 1 | 变换/序列化 | 变换等价、包围盒、JSON 往返、相等契约 |
| Tier 2 | 解析解 | 与解析几何的独立对拍(sphere/cylinder 等) |
| Tier 3 | 结构不变量 | 绕序、流形/欧拉数、凸包恒等式 |
| Tier 4 | 黄金语义 | 新模块语义基线(CsgHelpers、扇形柱、定向图元、细分容差) |
| Tier 5 | 遮挡 oracle | 射线投射(Möller–Trumbore)+ 双侧判定的独立可见性 oracle |
两个工程上的讲究值得学习:
- 独立 oracle:验证消隐正确性时,不拿实现自己的中间结果当参照,而是另写一套独立算法(射线投射 + 探针)对拍——只有独立实现的”共识”才是有意义的正确性证据。
- oracle 与被测实现共用输入、单独验证属性(如”遮挡关系必须满足反对称性”),比逐例比对更耐几何多样性。
测试基础设施中还有一组生成器(tests/OpenCSG.NET.TestKit/Generation/):节点树生成器(10 种节点 × 8 种截面,种子可复现)、线段集生成器、三档语料(64/512/环境变量驱动的更大规模,corpus.json)——用受控随机输入扩大覆盖面。
15.4 性能基准与回归门禁
15.4.1 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 下测量。运行:
dotnet run -c Release --project perf/OpenCSG.NET.PerfTest
15.4.2 墙钟预算测试与基线门禁
除微基准外,系统套件里还有一组标注 [Category("Perf")] 的墙钟预算用例(BoundaryProjectionTest、ComplexityBudgetTest、LargeInputBudgetTest 等),对 CsgEvaluator、ViewProjector.Project、ViewArcFitter.Fit、ViewSegmentCleanup.Clean 等关键操作设时间上限。两个工程口径:
- CI 环境系数:CI 机器(共享、噪声大)会把墙钟预算放大约 10 倍——但比率守卫(相对基线/相对同类操作的比值)保持收紧,噪声不至于让”规模放大 × 复杂度变坏”这类真实信号漏网。
- 基线 JSON:
perf/baseline.json冻结了本机基线(Union/Intersect/Evaluate 等操作的中位耗时与内存字节数);nightly 工作流对其实施 25% 回归门禁——超过即报警。
15.4.3 性能特征与优化建议
结合第 13 章的内核剖析与这些基准,可以总结出用好性能的实用经验:
- 分辨率是最大的旋钮:球/圆柱面数随分辨率增长,布尔的比较成本与面数相关。够用就好——预览用低分辨率,出图再调高;长半径曲面用第 12 章的弦高容差自适应替代手工调参。
- 善用包围盒短路:相距很远、不相交的实体做并集走
MayOverlap快速通道,几乎零成本(第 13 章)。装配大量分离零件时很划算。 - 靠近原点建模:远离原点的大坐标会削弱
1e-5容差的有效性;必要时要先Translate到原点附近(第 13 章)。 - 减少不必要的中间清理:
Subtract/Intersect的多参数重载只在最后一步做Retesselate/Canonicalize。把多次减料写成一次a.Subtract(b, c, d)通常比链式调用更省。 - 复用与缓存:变换用 Tag 缓存(第 7、13 章),重复子件建一次多次
Translate比重新构造便宜。 - 空集短路:把可能为空的工具实体(零尺寸孔)直接参与布尔是安全的,短路语义避免无谓的 BSP 往返(第 6 章)。
15.5 稳健性、并行与跨运行时
系统套件专门有一组 Robustness/ 用例,覆盖了工程落地时常被忽略的边角:
- 并行执行:整套件并行、跨线程 scope、跨线程静态状态——
Tessellation.ChordTolerance、ViewArcFitter.DiagnosticsEnabled等都是[ThreadStatic],正是为并行测试的隔离性设计的(第 12、16 章)。 - soak(浸泡)测试:长时间重复运算检查无内存泄漏/状态漂移,nightly 工作流用
OPENCSG_FUZZ_CASES=20000跑模糊用例。 - 跨运行时一致性:
tools/OpenCSG.NET.CrossRuntime把同一份源码分别编到net8.0与net10.0,对固定输入(语料求值、三视图、JSON 往返、STL 字节)产物做 SHA256 比对——同一操作系统内两个运行时的输出必须逐位一致。
这组测试回答的问题是:”库在单机演示里对,在生产的多线程/长跑/多运行时环境里还对吗?”
15.6 覆盖率门禁与缺陷登记制度
15.6.1 覆盖率门禁
tools/coverage-gate.py 是 CI 的覆盖率闸门(fail-closed:出错即红,绝不静默通过):
src/整体行覆盖率 ≥ 85%;- 五个重点分组各 ≥ 90%:
View*(三视图子系统)、CsgHelpers、StlWriter、CsgMesh、Tessellation——正是第 12、16 章介绍的新增模块; - 统计口径:两个测试套件的 cobertura 报告取并集(任一套件命中即算覆盖),排除
[Category("Perf")]的墙钟用例(插桩会改变计时)。
它与普通”覆盖率数字”的区别在于分组阈值:整体达标不够,五个新模块必须各自达标——防止”总量漂亮、新代码裸奔”。
15.6.2 缺陷登记制度
tests/DEFECTS.md 是一份双向索引的缺陷登记表:每个缺陷一个 DEFECT-nnn 编号,对应测试用例标注 [Explicit("DEFECT-nnn: ...")](使套件保持绿色、缺陷可一行复现),并由 DefectRegistryTest 强制校验”文档里有编号 ⇔ 代码里有标注”。
规则里有两条工程智慧:
- 产品代码默认只报告不改——测试发现的缺陷先登记、先复现,只有经过批准的最小修复批次才动产品代码。避免”测试写着写着顺手把库改了”的失控。
- 修复后迁移:删掉
[Explicit]标注、从”未修复”区移到”已修复”区并注明修复方式——缺陷的完整生命史留在仓库里。
仓库的最终状态(截至 2026-10-14 的缺陷修复轮):15 条已登记缺陷中 14 条已修复(A1/A2/A4 与 001~010、012),唯一未修复的 DEFECT-011 是跨平台 libm 的字节级不可移植(15.2.3 的分层口径即其缓解方案)。这套制度解释了本书各章”历史提示”的出处——每一条都对应一次真实的修复。
15.7 CI 与发布流水线
15.7.1 CI:双平台矩阵
.github/workflows/ci.yml 在 ubuntu-latest 与 windows-latest 两个平台上:全解决方案构建(10 个工程)、双 TFM 测试(net8.0;net10.0)、覆盖率门禁。fail-fast: false——两个平台各自出结论,一个平台失败不取消另一个(避免”掩盖第二个平台的真实问题”)。SDK 装 10.0.x + 8.0.x 两个版本(与 global.json 的钉版和双 TFM 目标一致)。
.github/workflows/nightly.yml 承担”慢测试”:长模糊用例(OPENCSG_FUZZ_CASES=20000)与性能回归门禁(对 perf/baseline.json 的 25% 阈值)。
双平台为什么必要? 第 15.2.3 节的跨平台 ULP 差异、路径/换行机制、文件系统大小写敏感度——这些都只在真实双平台上跑一遍才暴露。本地全绿的代码在 Linux CI 上红是常态,别把 CI 红当成噪声。
15.7.2 发布:NuGet 可信发布(Trusted Publishing)
.github/workflows/nuget-publish.yml 在推送 v* 版本标签时自动发布到 NuGet.org。当前用的是 NuGet Trusted Publishing(OIDC 临时密钥)——弃用了长期 NUGET_API_KEY:
GitHub 颁发 OIDC 令牌 → NuGet/login 换取临时 API 密钥(有效期 1 小时)→ dotnet nuget push
流程要点:
- 检出 + 装 SDK(10.0.x + 8.0.x——因为测试前置门禁要跑双 TFM)。
- 先跑测试:
dotnet test tests/OpenCSG.NET.Tests/——测试不过就不发布,质量门禁前置。 - 从标签解析版本号:
v1.2.3→VERSION=1.2.3。 - 打包:
dotnet pack src/OpenCSG.NET/OpenCSG.NET.csproj -c Release -o out -p:Version=$VERSION。 - OIDC 换临时密钥(
NuGet/login@v1,用仓库 SecretNUGET_USER指向 nuget.org 用户名),紧挨着推送使用(密钥 1 小时有效、一次性)。 - 推送:
dotnet nuget push out/*.nupkg --skip-duplicate。
所以为这个库(或你 fork 的版本)发新版,只需:确保测试通过 → 打一个 vX.Y.Z 标签并推送 → CI 自动测试、打包、上传。相比”长期 API 密钥”的老路子,OIDC 换取的密钥不落地、不共享、自动过期——这是 .NET 生态当前的推荐做法,值得照搬到你自己的发布流水线。
15.8 二次开发:三个典型扩展点
15.8.1 添加自定义截面
假设你需要一个内置八种之外的特殊截面(比如带孔的多腔型材轮廓)。回顾第 10 章的拉伸管线,添加一个 Profile2D 需要三步:
- 定义记录(在
Profiles.cs):public record ZBeamProfile(double Height, double FlangeWidth, double Thickness) : Profile2D;并在
Profiles工厂加一个便捷方法。 - 在
CsgEvaluator.ExpandProfile的switch里加一个分支,把参数展开成一圈顶点。绕序不必手工保证——自PolygonProfile起,展开路径会按有向面积自动规整为 CCW(第 10 章);但简单不自交仍是硬性要求(耳切三角化的前提)。 - 带孔截面不要试图塞进一个
Profile2D——像SquareTubeProfile那样只给外轮廓,用SubtractNode在节点层挖空。
更轻的替代方案:如果截面已经是现成的点列表,直接用
new PolygonProfile(pts)(第 10 章),无需改库。
15.8.2 扩展序列化
如果你新增了节点或截面类型,并希望它们能被 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 章说的往返保真与幂等。顺带记得把新类型纳入 DefectRegistryTest 之外的往返测试(CsgSerializationTest 的模式照抄即可)。
15.8.3 扩展辅助函数与投影后处理
库的工程辅助层(CsgHelpers,第 12 章)与投影后处理层(View*,第 16 章)是扁平结构——新增一个”回转体”辅助函数、一个新的投影后处理步骤,通常只需:
- 在对应静态类加一个方法(纯函数、无状态是硬约束——注意
[ThreadStatic]的既有先例); - 补一组单元测试(含退化输入);
- 如果属于
coverage-gate.py的五个分组之一,新代码要被 ≥90% 覆盖率门禁追着走——先写测试再写实现是这里的现实选择。
15.9 本章小结与教程结语
本章要点:
- 测试体系是四工程 + 五层:单元/契约/黄金基线/属性/端到端;黄金基线用”面数 + 总面积 + 包围盒”的几何指纹判等,基线升级必须显式批准。
- 属性测试用 FsCheck + 独立 oracle(如射线投射)验证恒等式与不变量;Tier 1~5 覆盖布尔恒等式到遮挡 oracle。
- 性能:BenchmarkDotNet +
[MemoryDiagnoser]微基准、[Category("Perf")]墙钟预算、perf/baseline.json25% 回归门禁;优化要点是分辨率、包围盒短路、原点居中、减少中间清理。 - 质量制度:覆盖率门禁(src ≥85%、五分组 ≥90%,fail-closed)、缺陷登记双向索引(
DEFECTS.md⇔[Explicit])、产品代码默认只报告不改。 - CI/发布:双平台矩阵 + nightly 长测;发布走 NuGet Trusted Publishing(OIDC 临时密钥),
v*标签触发、测试前置。 - 二次开发三方向:添加自定义截面(或直接用
PolygonProfile)、扩展序列化(Read/Write 两处$type对称)、扩展辅助/后处理层(无状态 + 测试先行)。
至此,这套 OpenCSG.NET 使用与开发教程 主线十五章讲完。我们从 CSG 与 BSP 的第一性原理出发(第 1–3 章),掌握了数学基础与命令式建模(第 4–8 章),进阶到声明式节点树、参数化型材与序列化(第 9–11 章),补全了网格导出与工程辅助(第 12 章),深入内核算法(第 13 章),完成了冷弯 C 型钢实战(第 14 章),最后落到测试、质量与发布(本章)。第 16 章另辟专题,把库最新的三视图投影与隐藏线消除子系统完整讲透。
希望这套教程既能让你快速上手用几行代码造出三维实体,也能让你看透原理、在需要时改进甚至扩展这个库。愿 OpenCSG.NET 成为你参数化建模、BIM 二次开发、增材制造工具链中一件称手的利器。