第08章:STL 导出与文件格式
建模的最终目的通常是产出一个可制造、可查看的文件。OpenCSG.NET 内置了对 STL(STereoLithography) 格式的导出支持——这是 3D 打印和快速原型领域事实上的通用格式。本章讲清 ASCII 与二进制两种 STL 的写出方式、扇形三角化原理、数字格式化的陷阱、二进制头部的”solid”坑,以及导出实践中的注意事项。
库里现在有两套导出路线,本章以现代路线为主(两套的三角形切分算法与格式细节完全一致):
- 现代管线(推荐):
CsgMesh.TrianglesOf(solid)把实体转成 Z-up 三角网格,交给StlWriter.Write(...)写出——格式显式指定、返回面数/字节数统计(本章 8.2、8.4~8.7)。 - 经典扩展方法(源码仍在用):
Formats.cs里挂在Solid/Polygon/Vertex/Vector3D上的WriteStl/ToStlString扩展方法,直接在 CSG 的 Y-up 坐标里写出(本章 8.3)。
8.1 STL 格式速览
STL 用一堆三角面片描述实体表面,每个三角面片记录一个法线和三个顶点。它有两种编码:
| 特性 | ASCII STL | 二进制 STL |
|---|---|---|
| 可读性 | 纯文本,可直接阅读/diff | 二进制,不可读 |
| 文件体积 | 大(每三角约几百字节) | 小(每三角固定 50 字节) |
| 数值精度 | 保留 double 的文本精度 |
降为 32 位 float |
| 适用 | 调试、版本对比、小模型 | 生产、大模型、传输 |
一个共同点:STL 只存储三角面片,没有单位、没有颜色(标准)、没有材质。所谓”这个模型是毫米还是英寸”,完全由下游软件约定——STL 里的数字就是裸坐标值。
8.2 现代管线:CsgMesh + StlWriter
推荐的导出流程只有两步——转网格、写文件:
using Csg;
using static Csg.Solids;
var part = Cube(size: 10, center: true).Subtract(Sphere(r: 6));
// ① 实体 → Z-up 三角网格
var triangles = CsgMesh.TrianglesOf(part);
// ② 网格 → STL 文件(默认二进制)
var result = StlWriter.Write("part.stl", triangles, StlFormat.Binary, header: "part");
Console.WriteLine($"{result.TriangleCount} 面, {result.Bytes} 字节 → {result.Path}");
StlWriter 的关键成员:
public enum StlFormat { Binary, Ascii }
public sealed class StlWriteResult
{
public string Path { get; }
public int TriangleCount { get; }
public long Bytes { get; }
}
public static StlWriteResult Write(string path, IReadOnlyList<MeshTriangle> triangles,
StlFormat format = StlFormat.Binary, string? header = null);
选择 ASCII 只需改一个枚举值:
StlWriter.Write("part_ascii.stl", triangles, StlFormat.Ascii, header: "part");
8.2.1 坐标系:Y-up → Z-up(与经典方法的差异)
CsgMesh 会把 CSG 实体的 Y-up 坐标换算成网格的 Z-up(FromCsg 做 Y↔Z 交换),并把扇形三角化的绕序反转一次补偿反射——这样导出的 STL 与”在 Z-up CAD 里直接落图的实体”姿态一致。导出口径的完整原理在第 12 章,这里只需记住:
- 现代管线(CsgMesh + StlWriter)= Z-up;
- 经典扩展方法(Formats.cs)= Y-up(CSG 原生坐标)。
同一次建模,两套管线导出的坐标数值不同(Y、Z 互换)——下游软件若不区分,模型会”躺倒”。同一项目内只用一套口径是最省心的策略。
8.2.2 头部处理与数字格式
header省略时默认"STL export"(纯 ASCII);给定值中的\r/\n一律替换为空格,防止破坏文件结构。- 二进制路径超长头截断到 79 字节(80 字节定长头末位留零);ASCII 路径的
solid/endsolid名完整写出、不截断。 - ASCII 数字格式为
{0:0.######}(六位小数)+InvariantCulture(见 8.5 的国际化坑)。 - 法向由绕序重算(
StlWriter.NormalOf),不信任调用方;退化三角形写零法向。
8.3 经典扩展方法:WriteStl 与 ToStlString
源码里仍然保留了 Formats.cs 的扩展方法入口——官方示例(Runner.Examples、Runner.CPurlin)至今在使用。它在 CSG 的 Y-up 原生坐标里直接写出:
// 方式一:写入文件(ASCII)
using (var writer = new StreamWriter("part.stl"))
{
part.WriteStl("part", writer);
}
// 方式二:写入文件(二进制)——同名方法,换成 BinaryWriter 即切换编码
using (var fs = File.Create("part_bin.stl"))
using (var bw = new BinaryWriter(fs))
{
part.WriteStl("part", bw);
}
WriteStl(this Solid csg, string name, TextWriter writer) 的第二个参数 name 是写进 STL 头尾的实体名(solid <name> ... endsolid <name>)。生成的文本大致长这样:
solid part
facet normal 0 0 1
outer loop
vertex 5 5 5
vertex -5 5 5
vertex -5 -5 5
endloop
endfacet
...
endsolid part
8.3.1 不落盘:ToStlString
如果你想在内存里检查、比较或通过网络发送 STL,可以用 ToStlString:
string stl = part.ToStlString("part"); // 返回完整 ASCII STL 文本(Y-up 坐标)
Console.WriteLine(stl.Substring(0, 200)); // 看看开头
ToStlString 内部就是用一个 StringWriter 调 WriteStl,非常适合单元测试里对导出结果做断言。
8.3.2 二进制头部的 “solid” 陷阱
经典二进制路径有一个精妙的兼容性处理。STL 规范建议:二进制文件的 80 字节头部绝不能以 ASCII 字符串 “solid” 开头,否则某些软件会把它误判成 ASCII STL(因为 ASCII STL 正是以 solid 开头的)。
Formats.cs 的二进制导出为此做了保护:如果你传入的 name 恰好以 “solid”(不区分大小写)开头,它会自动在头部前面加上 “stlbin” 前缀,避免被误判:
// 假设 name = "solidBracket"
// 二进制头部会写成 "stlbin" + "solidBracket",而不是直接 "solidBracket"
之后再把 name 以 ASCII 编码填进头部剩余空间(超长会被截断到 80 字节以内)。你通常不必关心这个细节,但了解它能解释”为什么我的二进制 STL 头部多了 stlbin 几个字”。现代管线 StlWriter 则用另一个角度解决同一问题:默认头 "STL export" + 换行净化 + 截断 79 字节,不依赖前缀游戏。
8.3.3 精度:double 降为 float
务必注意:二进制 STL 用 32 位单精度浮点(REAL32) 存坐标,而 OpenCSG.NET 内部用的是 64 位 double。导出二进制时会发生 (float) 强制转换,精度下降。对绝大多数打印/加工场景,float 精度绰绰有余;但如果你的模型坐标数量级很大、或对微米级精度敏感,要意识到这一步的精度损失。ASCII STL 则保留 double 的完整文本精度(六位小数输出)。
8.3.4 二进制 STL 的文件结构
无论哪套管线,二进制 STL 的布局都是固定的:
UINT8[80] 头部(80 字节,通常被忽略)
UINT32 三角形数量
每个三角形(50 字节):
REAL32[3] 法线向量 (12 字节)
REAL32[3] 顶点 1 (12 字节)
REAL32[3] 顶点 2 (12 字节)
REAL32[3] 顶点 3 (12 字节)
UINT16 属性字节数 (2 字节,这里恒为 0)
StlWriter 中的 HeaderLength = 80、FacetLength = 50 两个常量正对应这个布局。
8.4 扇形三角化:多边形如何变成三角面片
Solid 内部存的是多边形(可能是四边形、五边形……),而 STL 只认三角形。导出时需要把每个多边形三角化。OpenCSG.NET 用最简单的扇形三角化(fan triangulation):以多边形的第一个顶点为”扇心”,依次和后续相邻两点组成三角形。
对一个有 N 个顶点的多边形 v0, v1, ..., v(N-1),生成 N − 2 个三角形:
(v0, v1, v2), (v0, v2, v3), (v0, v3, v4), ...
现代管线的实现(CsgMesh.TrianglesOf)就是这个循环,外加绕序反转补偿 Y↔Z 反射(第 12 章详解):
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)));
8.4.1 为什么这依赖”多边形是凸的”
扇形三角化只对凸多边形正确。如果多边形是凹的,从第一个顶点拉出的扇形三角形可能落到多边形外部,产生错误面片。幸运的是,第 3、4 章讲过——OpenCSG.NET 的所有 Polygon 都被约定为凸多边形,布尔运算与规范化也维持这个不变量。所以扇形三角化在这个库里总是安全的。这也解释了为什么该库不需要复杂的耳切/三角化算法来导出 STL(但生成型材截面时需要,见第 10 章)。
8.4.2 法线从哪来
现代管线中,每个三角面片的法线由 StlWriter.NormalOf 按绕序(右手法则)重算并归一化——不信任任何外部输入;退化三角形(面积趋零)写零法向,符合”读入方可自行忽略”的 STL 约定。因为一个平面多边形三角化出的所有三角形共面,重算法线与多边形平面法线天然一致。顶点少于 3 个的退化多边形会被跳过。
8.5 数字格式化:InvariantCulture 的重要性
一个看似不起眼、实则关乎”能不能被正确读取”的细节:数字必须用不变文化(InvariantCulture)格式化。
在某些区域设置(如德语、法语)里,小数点是逗号 , 而不是句点 .。如果 STL 里写出 vertex 1,5 2,0 3,0,几乎所有 STL 读取器都会解析失败或错乱。OpenCSG.NET 的 Formats 明确用 CultureInfo.InvariantCulture 来格式化所有坐标:
static readonly IFormatProvider icult = System.Globalization.CultureInfo.InvariantCulture;
// ...
string.Format(icult, "vertex {0} {1} {2}", pos.X, pos.Y, pos.Z);
这样无论程序运行在什么区域设置下,导出的 STL 都用标准的 . 小数点,保证跨环境可读。如果你自己扩展导出逻辑,切记也要用 InvariantCulture,这是一个经典的国际化坑。
8.6 导出实践清单
把导出相关的经验汇总成一份清单:
- 选 ASCII 还是二进制? 调试、想 diff、模型小 → ASCII;生产、模型大、要传输 → 二进制。
- 文件扩展名:两种编码都用
.stl。查看器靠内容而非扩展名区分。 - 确保水密:布尔运算若输入了非流形/破面几何,导出的 STL 也会有洞。导出前保证你的实体是干净的闭合实体(合理使用”挖孔略微超出”等技巧,见第 6 章)。
- 单位:STL 无单位。团队内约定好”1 单位 = 1 毫米”之类,并在整个建模流程中保持一致。
- 分辨率:导出前把曲面分辨率调到目标质量(第 5 章);ASCII 高分辨率模型文件会很大,此时优先二进制。
- 用
using管理写入器:确保StreamWriter/BinaryWriter被正确 flush 和释放,否则文件可能不完整。 - 验证:导出后用查看器(Windows”3D 查看器”、Blender、在线 STL viewer)打开确认;或在测试里用
ToStlString断言面数/内容。
8.7 一个完整的导出示例
把建模到导出走一遍完整流程,用现代管线同时导出二进制与 ASCII 两份对照:
using Csg;
using static Csg.Solids;
// 建模:一个带通孔的圆角立方体
var body = Intersection(
Cube(size: 20, center: true),
Sphere(r: 13, center: true)) // 圆角
.Subtract(
Cylinder(r: 4, h: 30, center: true).RotateX(90)); // 通孔(略高,避免共面)
Console.WriteLine($"多边形数:{body.Polygons.Count}");
// 实体 → 网格(Z-up)
var triangles = CsgMesh.TrianglesOf(body);
// 二进制(默认)
var bin = StlWriter.Write("body_bin.stl", triangles, StlFormat.Binary, "body");
// ASCII
var asc = StlWriter.Write("body_ascii.stl", triangles, StlFormat.Ascii, "body");
Console.WriteLine($"bin: {bin.TriangleCount} 面 / {bin.Bytes} 字节");
Console.WriteLine($"ascii: {asc.TriangleCount} 面 / {asc.Bytes} 字节");
运行后你会得到 body_ascii.stl 与 body_bin.stl 两个文件,几何一致但体积悬殊——这直观展示了两种编码的取舍。你还可以顺手验算一下二进制文件的字节数:80 + 4 + 50 × 面数,与 result.Bytes 应严格相等。
8.8 本章小结
- 导出有两套路线:现代管线
CsgMesh.TrianglesOf→StlWriter.Write(path, triangles, format, header)(Z-up、有StlWriteResult统计);经典扩展方法solid.WriteStl(name, TextWriter/BinaryWriter)、ToStlString(name)(Y-up 原生坐标)。同一项目内保持一套口径。 - ASCII 可读、可 diff、保留 double 精度但体积大;二进制体积小但坐标降为 float,布局为
80 + 4 + 50×面数字节。 - 导出用扇形三角化把凸多边形拆成 N−2 个三角形;
CsgMesh在三角化时反转绕序,补偿 Y↔Z 反射带来的朝向翻转。 - 二进制头部有两个防护:经典方法对以 “solid” 开头的名字自动加 “stlbin” 前缀;现代管线用 ASCII 安全默认头 + 换行净化 + 截断 79 字节。
- 所有数字用 InvariantCulture 格式化(ASCII 路径六位小数),保证跨区域设置可读——自定义导出时务必遵循。
- 实践上注意 ASCII/二进制的取舍、水密性、无单位约定、分辨率(第 12 章的自适应细分为此而生)、以及用
using正确释放写入器。
至此,命令式 API 的”创建—组合—变换—导出”闭环全部打通。下一章我们转向声明式 API——用不可变的 CsgNode 树来描述建模意图,为序列化和参数化持久化打下基础。