第12章:网格导出与工程辅助
前十一章覆盖了”从形体到实体”的完整建模链路。本章补齐”把实体交付出去”以及”工程化建模范式”两块内容——它们由库中一组相对独立的模块实现:三角网格导出 CsgMesh + StlWriter(第 8 章旧导出管线的现代化替代)、STL 读回 ViewMeshReader、自适应细分 Tessellation,以及面向工程建模的 CsgHelpers 辅助库、HalfPlaneCut 半空间切割 与 PrimitiveConvention 图元口径。这些模块在第 16 章的三视图投影、第 14 章的 C 型钢实战与第 15 章的测试体系中都有直接应用。
12.1 CsgMesh:Y-up 到 Z-up 的网格桥
CsgMesh(CsgMesh.cs)负责把 CSG 实体转换成三角网格。它定义了两个轻量结构:
public readonly struct MeshPoint { double X, Y, Z; } // 网格顶点(Z-up)
public readonly struct MeshTriangle { MeshPoint A, B, C; } // 三角面
核心入口有两个:
MeshPoint FromCsg(Vector3D p); // 单点换算
List<MeshTriangle> TrianglesOf(Solid solid); // 单实体 → 三角形列表
List<MeshTriangle> TrianglesOf(IEnumerable<Solid> solids); // 多实体合并
12.1.1 坐标系:Y-up → Z-up 的 Y↔Z 交换
OpenCSG.NET 内部的 CSG 几何是 Y-up(回顾第 5 章:Cylinder 沿 Y 轴),而多数 CAD/网格工具链是 Z-up。FromCsg 用一次 Y↔Z 交换完成桥接:
public static MeshPoint FromCsg(Vector3D p) => new(p.X, p.Z, p.Y);
这样导出的网格与”在 Z-up CAD 里直接落图的实体”是同一姿态,不会出现”导出后零件躺着”的问题。
12.1.2 反射补偿:为什么绕序要再翻转一次
Y↔Z 交换是一个反射变换(3×3 行列式为 −1),它会把 CSG 原本”逆时针朝外”的绕序整体翻成”顺时针朝外”——由绕序重算的法向就全部指向实体内部,STL 会出现内外翻转。
TrianglesOf 的对策是在扇形三角化时同时反转顶点序:
// 扇形三角化 + 绕序反转(反射补偿,使法向朝外)
var a = FromCsg(vertices[0].Pos);
for (var i = 1; i + 1 < vertices.Count; i++)
triangles.Add(new MeshTriangle(a, FromCsg(vertices[i + 1].Pos), FromCsg(vertices[i].Pos)));
仓库测试用带符号体积实测验证了这一链条:边长 100 的立方体在 CSG 侧体积 +1,000,000 mm³(朝外);仅做交换后变为 −1,000,000;反转绕序后恢复 +1,000,000。做坐标系转换时务必检查有向体积符号,这是网格导出最容易被忽视的正确性关口。
12.2 StlWriter:现代化的 STL 写出管线
第 8 章讲的 Formats.cs 扩展方法(solid.WriteStl(name, writer))是库的经典导出入口,至今仍被示例程序使用;而 StlWriter(StlWriter.cs)是新增的纯 IO 导出器——不依赖任何 CAD 运行时、可直接单测、自带格式枚举与结果统计:
public enum StlFormat { Binary, Ascii }
public sealed class StlWriteResult
{
public string Path { get; } // 文件路径
public int TriangleCount { get; } // 三角面数
public long Bytes { get; } // 文件字节数
}
public static class StlWriter
{
public const int HeaderLength = 80;
public const int FacetLength = 50;
public static StlWriteResult Write(string path, IReadOnlyList<MeshTriangle> triangles,
StlFormat format = StlFormat.Binary, string? header = null);
public static long WriteBinary(string path, IReadOnlyList<MeshTriangle> triangles, string? header = null);
public static long WriteAscii (string path, IReadOnlyList<MeshTriangle> triangles, string? header = null);
public static MeshPoint NormalOf(MeshTriangle triangle);
}
一次完整的”实体 → 文件”导出只需要三行:
using Csg;
using static Csg.Solids;
var part = Intersection(Cube(size: 20, center: true), Sphere(r: 13, center: true));
var triangles = CsgMesh.TrianglesOf(part); // ① 实体 → 网格
var result = StlWriter.Write("part.stl", triangles, // ② 网格 → 文件
StlFormat.Binary, "part");
Console.WriteLine($"{result.TriangleCount} 面,{result.Bytes} 字节 → {result.Path}");
12.2.1 设计要点
- 目录自动创建:
Write会Directory.CreateDirectory目标目录,可放心传"output/sub/part.stl"。 - 法向由绕序重算:
NormalOf用右手法则从三角形顶点重新计算并归一化,不信任调用方传入的法向;退化三角形(面积趋零,弦长 < 1e-12)写零法向——这是 STL 约定,读入方可自行忽略。 - 头部净化与截断口径:
header省略时默认"STL export"(纯 ASCII,避免中文字符经 ASCII 编码变成?);给定值中的\r/\n一律替换为空格——二进制头带裸换行、ASCII 的solid <head>被拆成两行都会破坏文件结构。超长头截断到 79 字节仅发生在二进制路径(80 字节定长头末位留零);ASCII 路径的solid/endsolid名完整写出、不截断。 - ASCII 数字格式:
{0:0.######}六位小数 +InvariantCulture(回顾第 8 章 8.5 的国际化坑),UTF-8 无 BOM 写出。
12.2.2 新旧两套 STL API 怎么选
| 维度 | Formats.cs 扩展方法 |
StlWriter(本章) |
|---|---|---|
| 输入 | Solid(内部多边形) |
MeshTriangle 列表(CsgMesh 转换后) |
| 调用形态 | solid.WriteStl(name, writer) |
StlWriter.Write(path, triangles, format, header) |
| 格式选择 | 靠 TextWriter/BinaryWriter 参数类型分派 |
StlFormat 枚举显式指定 |
| 坐标系 | Y-up(CSG 原生) | Z-up(CsgMesh 已换算) |
| 返回信息 | 无 | StlWriteResult(面数、字节数) |
| 适用 | 教学、源码阅读、与旧代码兼容 | 新代码、服务端批量导出、需要统计与 Z-up 口径时 |
官方示例(
Runner.Examples、Runner.CPurlin)目前仍用扩展方法;而测试基础设施与三视图管线(第 15、16 章)走的是CsgMesh+StlWriter一侧。两套并存,按交付目标选择。
12.3 ViewMeshReader:把 STL 读回来
ViewMeshReader.ReadStlFile(path) 是库中唯一的文件读取入口——它把 STL 解析成 ViewTriangle 列表,主要服务三视图投影(第 16 章),也适合”离线核对/外部模型导入”:
IReadOnlyList<ViewTriangle> triangles = ViewMeshReader.ReadStlFile("part.stl");
两个值得留意的实现细节:
- 二进制/ASCII 判定用
84 + 50 × 面数 == 文件长度(读取头部 80 字节后的uint32面数并验证总长),而不是”是否以 solid 开头”——后者会把以solid开头的二进制文件误判成 ASCII(回顾第 8 章讲的solid陷阱的另一面)。 - 二进制路径忽略文件中的面法向,由顶点绕序重算法向(与
ViewTriangle.Normal一致);ASCII 路径按行解析vertex关键字,用InvariantCulture解析数字。
12.4 Tessellation:按弦高容差自适应细分
回顾第 5、10 章:圆弧/球面在网格里只能用多边形逼近,分段数(分辨率)决定了逼近误差。旧口径下 Solid.DefaultResolution3D = 12(整圆仅 12 段),对米级半径的圆弧,弦高偏差可达数十毫米——肉眼看就是折线。Tessellation(Tessellation.cs)用”弦高容差”解决这个问题:
Tessellation.ChordTolerance = 0.5; // 模型单位 mm;0 = 关闭自适应(默认)
bool adaptive = Tessellation.IsAdaptive;
int n1 = Tessellation.SegmentsFor(radius: 2000, tolerance: 0.5); // 整圆分段数
int n2 = Tessellation.SegmentsForArc(radius: 2000, sweepDegrees: 180); // 半圆分段数
12.4.1 数学原理
单段圆心角 θ 对应的弦高(弦与弧的最大偏差)为 r·(1 − cos(θ/2))。要求它不超过容差 tol,解出每段圆心角上限 θ = 2·arccos(1 − tol/r),于是整圆分段数:
N = ceil(2π / θ) = ceil(π / arccos(1 − tol/r))
结果被夹在 [MinSegments = 16, MaxSegments = 128] 之间:下限防止小半径圆被画成粗多边形,上限防止大半径把三角面数撑爆。例如半径 2000 mm 在 0.5 mm 容差下取到上限 128 段,弦高偏差 ≤ 约 0.6 mm——从”肉眼折线”变成”贴合圆弧”。
12.4.2 三个关键约定
- 按线程隔离:
ChordTolerance是[ThreadStatic]——导出线程设置、同线程求值,并行测试之间互不串扰(曾因全局静态被并行用例串扰而修复)。 - 默认 0 = 旧口径:不显式启用时
SegmentsFor直接返回Solid.DefaultResolution3D(12 段),保持库既有行为与既有测试不变。这是兼容性优先的默认值设计。 - STL 没有真圆弧:本类只保证”折线足够贴近圆弧”;要真正的圆弧表达必须用 BREP 格式(STEP/IGES/DWG)。声明式求值器已对
SphereNode采用自适应分段(CsgEvaluator里Tessellation.SegmentsFor(n.Radius)),回顾第 9 章。
12.5 CsgHelpers:工程辅助函数库
CsgHelpers(CsgHelpers.cs)是声明式语法树层面的通用几何辅助库——16 个静态方法覆盖位姿烘焙、截面拉伸、工程圆角/倒角/冲孔轮廓等反复出现的模式。它的价值不在于算法新奇,而在于把”用 TransformNode 与 Profile2D 搭出工程形状”的踩坑经验固化成可复用函数。
12.5.1 位姿烘焙:Place 与 RotateAboutCenter
回顾第 9 章 9.5 的结论:TransformNode 是先平移、后绕全局原点旋转,想要”原地自转再就位”必须嵌套两层。Place 把这个模式封装成一行:
public static CsgNode Place(CsgNode body, Vector3D translation, Vector3D rotation);
// 语义 = 先绕原点旋转(内层),再平移(外层)——即 T · R
配套的 RotateAboutCenter 实现绕任意点原位旋转——组合为 T(center) · R · T(−center)(先移到原点、旋转、再移回):
public static CsgNode RotateAboutCenter(CsgNode node, Vector3D center, Vector3D rotation);
12.5.2 截面拉伸:PrismXY / PrismXZ / AddArc
ExtrudeNode 只沿 +Z 拉伸,而工程中常需要”任意断面的棱柱”。两个辅助函数补全这一能力:
// (X,Y) 截面沿 Z 拉伸:内部 = PolygonProfile + Extrude,平移 zCenter − thickness/2 使其以 zCenter 居中
public static CsgNode PrismXY(IReadOnlyList<(double X, double Y)> points, double zCenter, double thickness);
// (X,Z) 断面沿 Y 拉伸:实现 = PrismXY 后绕 X 轴 +90°((x,y,z) → (x,−z,y))
public static CsgNode PrismXZ(IReadOnlyList<(double X, double Z)> points, double yCenter, double length);
AddArc 则用于在断面点列里做相切圆角(含凹角)——把一段圆弧按步数采样追加到点列表,端点与相邻直边共用端点,因此没有台阶、没有外挂体:
public static void AddArc(List<(double X, double Y)> points, double cx, double cy,
double r, double aFromDeg, double aToDeg, int steps);
实验:用”圆柱布尔”做圆角会在几何体上留下整段圆柱本体;用”断面圆弧”(
AddArc+PrismXY)才是做型材圆角的正确姿势。两个坑都在函数注释里记录了成因。
12.5.3 函数总览
| 分类 | 函数 | 用途 |
|---|---|---|
| 位姿 | Place / PlaceRotateThenTranslate |
先旋转后平移的位姿烘焙 |
| 位姿 | RotateAboutCenter |
绕任意中心原位旋转 |
| 拉伸 | PrismXY / PrismXZ |
(X,Y)/(X,Z) 截面沿 Z/Y 拉伸的精确棱柱 |
| 拉伸 | RevolvedStack |
回转体(按(半径, Y)站位序列堆叠) |
| 曲线 | AddArc / ArcSegment |
断面圆弧采样(相切圆角/圆头) |
| 工程轮廓 | CornerWedge / CornerCut |
角部楔块/斜切(倒角、切角) |
| 工程轮廓 | RoundConvexCorner / FilletConcave |
凸角/凹角圆角 |
| 工程轮廓 | TabbedPlate / TabbedPlateSlantAngleDeg |
带凸耳的板(圆头 + 相切斜边 + 孔) |
| 工程轮廓 | CylinderAlongX / CylinderAlongZ |
轴向沿 X/Z 的圆柱(Y 轴形体的转向封装) |
以工程中最典型的”带凸耳板”为例,其轮廓包含”左直立边 + 顶部 R 圆头 + 与圆头相切的斜边 + 底边”,斜边还有 Ø 孔与圆头同轴——TabbedPlate + TabbedPlateSlantAngleDeg 把这条相切切线角反解(解二次方程取较小正根)的计算封装了起来。第 14 章的 C 型钢檩条走的是”薄板 + 圆角 + 冲孔”的命令式路线;当你转向”断面规整的工程件”时,这些辅助函数就是型材库的雏形。
12.6 HalfPlaneCut 与 PrimitiveConvention
最后介绍两个”接地气”但重要的辅助模块。
12.6.1 HalfPlaneCut:半空间切割体
HalfPlaneCut(HalfPlaneCut.cs)构造切割用的半空间盒体——一个旋转 45° 的大方块(边长 √2 × halfExtent),用于把实体”切一刀”:
// Y-Z 平面内的半切:厚度沿 X
public static CsgNode Build(double centerX, double refY, double refZ,
double dirY, double dirZ, double halfExtent,
double overshoot, double sideSign);
// X-Y 平面内的半切:厚度沿 Z
public static CsgNode BuildXY(double refX, double refY, double dirX, double dirY,
double halfExtent, double overshoot, double sideSign,
double centerZ);
(dirY, dirZ)(或 (dirX, dirY))是切割方向,sideSign 选择保留哪一侧,overshoot 加余量避免共面(回顾第 6 章的挖孔技巧)。两个方法返回的都是 CsgNode,与目标子树在声明式层面做差集后统一求值:
var cutter = HalfPlaneCut.BuildXY(refX: 10, refY: 0, dirX: 1, dirY: 1,
halfExtent: 50, overshoot: 5, sideSign: 1, centerZ: 0);
var cut = CsgEvaluator.Evaluate(CsgNodes.Subtract(targetNode, cutter));
提示:
CsgHelpers.PrismXY/CornerCut是更精确的替代方案(半空间盒体的切深较难控制),在需要精确切深的场合优先用棱柱法。
12.6.2 PrimitiveConvention:图元口径参数化
PrimitiveConvention(PrimitiveConvention.cs)把”图元按哪条轴、从哪个基准摆放”的参数集中表达——长方体各轴尺寸、锥台轴向尺寸、楔形三轴尺寸:
public sealed class PrimitiveConvention
{
public double BoxX { get; } public double BoxY { get; } public double BoxZ { get; }
public double FrustumZ { get; }
public double WedgeX { get; } public double WedgeY { get; } public double WedgeZ { get; }
public static PrimitiveConvention OriginBased { get; } // 从原点向正方向生长
public static (double RadiusAlongX, double RadiusAlongY, double TopRadius)
FrustumRadii(double bottomRadius, double topRadius);
public static double ValidateProbeFactor(string hostCall, string axis, double factor);
public static bool IsCanonicalFactor(double factor);
public static double Offset(double center, double size, double probeMinFactor);
}
它解决的问题是图元口径的”中心 vs 角点”混乱(第 5 章提醒过:Cube 默认角在原点、Sphere 默认居中、Cylinder 默认沿 Y)——在工程装配中把这些”默认值”显式参数化之后,调用方就能用同一套口径描述所有图元。ValidateProbeFactor 对探针因子做 NaN/∞/越界校验并抛 InvalidOperationException;Offset 把”中心 + 尺寸 + 探针基准”换算成实际偏移。日常建模可以不接触它;写库/写框架(如把 OpenCSG.NET 包进自己的 CAD 工具) 时会发现它很有用。
12.7 本章小结
CsgMesh把 Solid 转成 Z-up 三角网格:FromCsg做 Y↔Z 交换(反射),TrianglesOf反过来反转绕序补偿,法向才朝外——用有向体积可以验证。StlWriter是纯 IO 新管线:Write/WriteBinary/WriteAscii+StlFormat枚举 +StlWriteResult统计;头部净化换行、二进制截断 79 字节、法向由绕序重算。旧的Formats.cs扩展方法仍然可用,按场景选择。ViewMeshReader.ReadStlFile提供 STL 读回,二进制判定用84 + 50×面数 == 长度。Tessellation按弦高容差r(1−cos(θ/2)) ≤ tol自适应细分,[16, 128]段夹取、[ThreadStatic]按线程隔离、默认 0 保持旧口径。CsgHelpers是 16 个工程辅助函数:位姿烘焙(Place/RotateAboutCenter)、精确棱柱(PrismXY/PrismXZ)、断面圆弧(AddArc)、工程轮廓(凸耳板/圆角/倒角/回转体)。HalfPlaneCut造半空间切割体(注意优先用精确棱柱法);PrimitiveConvention把图元口径参数化,适合库/框架层使用。
这些模块是三视图投影(第 16 章)与测试基础设施的底层构件。下一章我们回到内核,剖析 Solid/Tree/Node 的 BSP 算法实现——理解那台”引擎”如何驱动本章与前面所有章节的一切。