Sign in

    stlkit

    MoonBit 实现的 STL/OBJ/3MF 三维网格工具链:解析、校验、修复、分析、流式扫描

    stl
    obj
    3mf
    3d
    mesh
    3d-printing
    watertight
    Download zip
    Author
    Version
    0.1.0
    License
    Apache-2.0
    Last updated
    22 days ago
    Downloads
    30

    #stlkit

    用 MoonBit 写的 STL / OBJ 三维网格工具链。回答一个问题:这个模型能不能 3D 打印。

    体检报告

    #为什么需要它

    3D 打印失败,最常见的原因不是打印机,是模型本身有问题:

    网格的问题打印时会发生什么
    不水密(有破洞)切片软件分不清哪是实心哪是空的,算出来的填充是错的
    有非流形边(一条边被三个以上的面共用)切片软件不知道该往哪一侧填材料
    有退化面(三个点共线)算出零面积的层,产生碎片
    法线朝向反了有的工具会把内外面画颠倒

    渲染器能告诉你模型长什么样,但告诉不了你有没有破洞。 那要靠网格拓扑分析——数清楚每条边被几个面共用。这个工具就是干这件事的。

    而且这些问题肉眼看不出来:一个缺了一个三角形的立方体,看上去还是完完整整一个立方体,但它的体积已经从 1 变成 0.833 了——因为体积是靠各面的有符号体积正负抵消算出来的,有破洞就抵消不干净。

    破洞的模型

    #能做什么

    解析 STL(ASCII + 二进制)、OBJ(含多边形面与负数编号)、 3MF(含装配体与单位换算)、PLY(ASCII + 两种字节序的二进制) 校验 水密性、非流形边、退化面、法线朝向、绕向一致性 分析 体积、表面积、包围盒、定向包围盒、尺寸、三角形数、去重顶点数、 壁厚(从表面往里打射线量对面有多远)、 零件数(几块互不相连的几何)、欧拉示性数、 重心与转动惯量(密度取 1) 预览 等轴测 SVG 图(自带背面剔除与明暗) 修复 删退化面、去重复面、补洞、重算法线;可导出成 STL / OBJ / PLY 流式 分块扫描超大模型,内存不随模型大小增长 形态 库 + 命令行 + 网页

    #用法

    #网页

    拖一个 STL 或 OBJ 文件进页面,左边出预览图,右边出体检报告。全程在浏览器里完成,文件不会上传到任何地方。

    页面上的分析逻辑不是 JavaScript 重写的,而是同一份 MoonBit 代码经 moon build --target js 编译出来的。

    #命令行

    # 出一份体检报告 moon run cmd/main examples/cube.stl # 顺便导出预览图 moon run cmd/main examples/cube.stl --svg cube.svg # 跑基准测试,对比三种解析方式的耗时与内存 moon run cmd/main big.stl --bench # 不带参数打印帮助 moon run cmd/main

    输出长这样:

    examples/broken.stl 格式 ASCII STL 三角形 11 个 顶点 33 个(去重后 8 个) 包围盒 1 × 1 × 1 mm 体积 1 mm³ 表面积 5.5 mm² ✗ 不是水密网格 —— 有 3 条边只被一个面共用 网格上有破洞,切片软件分不清哪里是实心哪里是空的,直接打印会失败 结论:直接打印会失败,需要先修复

    把错误写清楚是刻意的:看到「有 3 条边界边」的人不知道这意味着什么,看到「切片软件分不清哪里是实心哪里是空的」才知道要修。

    #库

    let data = @fs.read_file_to_bytes("model.stl")
    let mesh = match @stlkit.parse_mesh(data) {
    Ok(mesh) => mesh
    Err(msg) => { println("读不了:\{msg}"); return }
    }
    let check = @stlkit.validate_default(mesh)
    if check.is_watertight() {
    println("可以打印,体积 \{mesh.volume()} mm³")
    } else {
    println("有 \{check.boundary_edge_count} 条边界边,需要先修复")
    }

    #3MF:现代切片软件用的格式

    STL 是 1987 年的格式,除了三角形什么都没有。3MF 是它的正式替代品——PrusaSlicer、 Bambu Studio、Cura 导出的都是它。它和 STL 有两处根本不同:

    1. 它是个 ZIP 包(OPC 容器),模型数据装在里面一个叫 3D/3dmodel.model 的 XML 里
    2. 顶点是共享的,三角形存的是顶点表下标,和 OBJ 一样

    第二点对「能不能打印」特别重要:拓扑关系是现成的。STL 里「哪些三角形共用同一个 顶点」必须靠容差去猜,猜错就把水密性判错了;3MF 直接写着下标,不存在猜的可能。

    #实测:拿官方样例验算

    3MF Consortium 的官方样例库拿来跑,结果能和数学对上:

    文件三角形 F边 E = 3F/2顶点 VV − E + F应该是
    box.3mf1218822(球面)
    torus.3mf22003300110000(圆环面)
    multiple_cylinders.3mf5287922761212(6 个闭合体 × 2)

    闭曲面满足欧拉示性数 V − E + F = 2 − 2g。三个样例分毫不差。这同时验证了 顶点表下标没读错、容差焊接没焊错、水密性判断是对的——下标只要偏一位, 这几个数立刻就崩。

    最后一行还多验了一层:那个文件里只有一个对象,但 build 里放了 6 个 item,各带一个不同的平移变换,所以最终是 6 个互不相连的闭合体。同一份 网格被引用多次、每次套不同变换这条路径也一并验过了。

    #官方一致性测试:13 个全过

    3MF Consortium 除了示例模型,还出了一套一致性测试(3mf-Verify)。 其中 MUSTPASS 目录的意思是:凡是符合规范的读取器,必须能正确处理这些文件。 正好拿来当外部标尺——不是我造几个测试夸自己,是官方出的题。

    13 个文件全部通过,都收在 examples/conformance/ 下,可以自己跑。几个特别值得看:

    • 只输出被引用的对象(Chapter3.4.3a):文件里有 2 个对象,但 <build> 只引用 了 1 个。工具输出 12 个三角形而不是 24 个——没偷懒把 resources 全倒出来
    • 两条不同路径给出同一个结果:Chapter3.2c(1 个对象 + 2 个 item)和 Chapter4.2(2 个对象 + 2 个 component)用 3MF 里两种完全不同的机制描述同一几何, 结果逐位相同(60 个三角形、14000.003 mm³)。这两条路径在实现里是分开的 (一个是 build 层的重复引用,一个是对象层的嵌套引用,后者还要做矩阵合成), 能对上说明两边都对
    • 无关内容不干扰解析:Chapter2.3a(可忽略标记)和 Chapter3.4.1c (未定义的 metadata 名)往文件里塞了一堆和几何无关的东西,解析器不能被带偏

    box.3mf 的包围盒是 10 × 20 × 30,体积算出来是正好 6000。

    #装配体

    3MF 的对象可以由别的对象拼出来,各带一个 4×3 的变换矩阵:

    <object id="2" type="model"> <components> <component objectid="1" transform="1 0 0 0 1 0 0 0 1 10 0 0"/> </components> </object>

    矩阵用的是行向量约定,12 个数按行排,平移在最后三个(第 9/10/11 个):

    m00 m01 m02 m10 m11 m12 m20 m21 m22 m30 m31 m32 ← 平移在这里,不是前三个

    装配链上组件自己的变换要先做、再套外层的,顺序反了位置就错了。这两个点都是 写的时候最容易搞混的地方,各配了一个测试专门盯着。

    对象还能互相引用成环——坏文件里真会出现,所以递归有 32 层上限,不然会栈溢出。

    #官方样例自己就有缺陷

    examples/cube_gears.3mf 跑出来:

    ✗ 有 53 个面的绕向和其他面相反(顶点顺序写反了) ✗ 有 24 条边被三个以上的面共用(非流形边) ✗ 有 24 个退化三角形(三个点共线,没有面积)

    这不是解析器出错:该文件是 17 个对象各带一个纯平移拼起来的、没有任何 <component>,所以对象引用和矩阵合成那两块代码根本不参与;其余三个官方样例 都报水密零缺陷;53 个反向面只占 25692 的 0.2%,是局部现象而不是整体崩坏。

    一个格式官方提供的样例模型,看起来完全正常,实际有 24 个退化面—— 渲染器画出来一点问题都没有,切片的时候才会出问题。这正是这个工具存在的理由。

    #超大模型:流式解析

    #问题出在内存,不是速度

    一个三角形在文件里和内存里的大小差三倍:

    组成大小
    文件里(二进制 STL)3 个坐标 + 1 个法线 = 12 个 float3250 字节
    内存里(Triangle)4 个 Vec3 = 12 个 Double96 字节

    解析一个 480 MB 的模型,光是「把三角形存下来」就要再吃掉 900 MB 以上。加上文件本身,一个不到 500 MB 的文件能顶到 1.5 GB。

    这不是渲染库没做优化。 Three.js 这类库必须把顶点留下来——它们的顶点最终要上传到 GPU,存下来是结构性的。但做统计不需要存:体积、表面积、包围盒都能一边读一边累加,读完就扔掉。

    #做法

    StlScanner 是一个可以反复喂数据块的状态机,内部只留三样东西:

    1. 没凑够一个完整记录的零头——块的大小随便给,不要求对齐到 50 字节
    2. 顶点去重表——只存「量化坐标 → 编号」,8 字节一个键,不存三角形
    3. 边表——每条边压成一个 UInt64(两个端点编号各占 32 位)

    统计水密性需要知道「每条边被几个面共用」,这是唯一躲不掉拓扑的地方。实现上是收集完边之后排序再扫一遍,而不是用哈希集合:同一个 UInt64 在哈希表里要占 30~50 字节(键 + 值 + 桶 + 装载因子),1500 万条边就是 400 MB 以上;紧凑数组里只要 120 MB。

    文件读取走 moonbitlang/async/fs 的 read_at,256 KB 一块循环复用——整个过程中文件不进内存,缓冲区大小是固定的,文件再大也不会变大。

    #实测

    /c/tmp/huge.stl,95.37 MB,200 万个三角形:

    $ moon run cmd/main huge.stl --bench 全量解析(保留网格,能校验能修复) 耗时 18087.6 ms 内存 文件 95.37 MB + 网格 198.36 MB = 293.73 MB 流式扫描(不保留网格,文件整块读) 耗时 2048.4 ms 内存 文件 95.37 MB + 顶点边表 45.85 MB = 141.22 MB 流式扫描(不保留网格,文件分块读) 耗时 3334.3 ms 内存 缓冲 256 KB + 顶点边表 45.85 MB = 46.1 MB 省内存 247.63 MB(84.3%) 提速 5.4 倍

    摊到每个三角形上:全量 154 字节,分块流式 24 字节——少了 6.4 倍。

    内存数字是按各自需要的数据结构算出来的,不是读操作系统的进程占用:wasm 的线性内存按页预留和增长,直接看进程 RSS 不准。公式写在 full_memory / stream_memory 的注释里,可以核对。

    #两条路径必须给出同样的结论

    快而错没有意义。所以 --bench 内置了交叉验证:三条路径(全量解析、整块流式、分块流式)的三角形数和边界边数必须完全一致,对不上就直接报错,不当作基准。stream_wbtest.mbt 里也把流式结果的每个字段和全量校验逐项对照——两份独立实现,只要体积累加、坐标量化、边打包有任何一个环节写得不一样,结果就会悄悄分叉。

    有一项流式做不到:绕向一致性。它需要反复遍历邻接面,而流式扫描是单遍的、不保留面与面的关系。所以报告里会明说「流式做不到这项」,而不是假装查过了。

    #两条路怎么选

    全量解析流式扫描
    体积 / 表面积 / 包围盒✓✓
    水密性 / 非流形边✓✓
    绕向一致性✓✗
    修复、导出、预览✓✗
    内存随模型线性增长固定(分块模式下)

    小文件走全量——能多查出绕向问题,还能顺手修复。几百 MB 的扫描件走流式——先知道「有没有破洞」往往就够了。

    #实现上的几个决定

    坐标一律用 Double,即使 STL 二进制里存的是 32 位浮点。 体积求和是几十万项累加,用 32 位会在累加过程里越飘越远。读进来就转双精度,误差只剩原始数据本身的。

    解析阶段不做顶点去重。 STL 每个三角形都把自己的三个顶点重写一遍,一个立方体的 8 个顶点会写 36 遍。但「文件里有多少重复顶点」本身就是一条要报告的校验信息,而且浮点去重涉及「多少算同一个点」的容差问题,不该混进解析。

    格式判断靠内容不靠扩展名。 从别处拷来的文件经常带着错的扩展名,而且二进制 STL 根本没有可靠的文本特征。做法是先按二进制试——从第 80 字节读三角形数量,如果 84 + 数量 × 50 正好等于文件长度,那就是二进制;对不上再按文本关键字找。

    顶点焊接按容差量化坐标。 这是「两个顶点算不算同一个点」的答案:把每个坐标除以容差(默认 1e-6 毫米,也就是 1 纳米)再四舍五入,落在同一格的就算同一个点。这样把浮点比较换成整数比较,避开「0.1 + 0.2 != 0.3」这类问题。1 纳米的容差能盖住 32 位浮点的重复顶点误差,又远小于任何有意义的建模特征(打印机精度是 0.1 毫米量级)。

    预览用画家算法,不是 Z-buffer。 每个三角形投影到屏幕,按离眼睛的远近排序,远的先画近的后画。对凸的、不怎么自遮挡的模型够用——3D 打印模型基本都属于这类。真正准确的消隐要上光栅化,对这个项目是杀鸡用牛刀。

    法线一律自己算,不用文件里写的。 很多导出器写的法线是错的甚至直接填 0 0 0。校验时会拿文件里的和按顶点顺序算出来的比,不一致就报出来;但渲染和分析时一律用自己算的。

    #开发

    仓库根目录就是 MoonBit 模块根:

    moon test # 跑测试 moon run cmd/main # 不带参数会打印帮助 bash web/build.sh # 重新编译网页用的 JS

    网页部分由 GitHub Actions 自动发布,见 .github/workflows/pages.yml。

    #测试

    实测结果:

    Total tests: 239, passed: 239, failed: 0.

    moon check --target all --deny-warn 零警告。

    测试的手法统一是「拿一个正确的立方体,故意弄坏它,看能不能查出来」——一个查不出问题的校验器等于没有,所以每条检查都配了反例:

    • 拿走 1 个三角形要报 3 条边界边,拿走一整个面要报 4 条
    • 三个面共用一条边要报非流形
    • 翻转法线要被抓到,但法线填零不算错(那是「没填」不是「填错了」)
    • 容差两侧各测一次:差 1e-9 的要焊成一个点,差 0.001 的不能
    • OBJ 走的是完全不同的代码路径(查顶点表、扇形三角化),但算出来的体积必须和 STL 版一致
    • 流式扫描的每个字段都和全量校验逐项对照,分块扫描还要用 1、3、7、13、49、51、83、85 字节这些不对齐的块大小各跑一遍——实现里只要假设了「每次喂进来的都是完整记录」,这些尺寸立刻算出垃圾
    • 3MF 的测试样本在内存里现拼(用 fzip 的 zip_sync 把几段 XML 打成 ZIP),想改哪一处就改哪一处。除了正常路径,还专门盯了几个易错点:英寸/厘米/米的换算(不换算体积差 16387 倍)、矩阵平移在最后三个数而不是前三个、组件变换和 build 变换的叠加顺序、以及对象互相引用成环时不能栈溢出

    单位立方体是整套测试的基准:12 个三角形,体积必然是 1、表面积必然是 6、包围盒必然是 1×1×1。任何一个环节算错,这几个数就对不上。

    #依赖

    解析 STL / OBJ 和全部几何分析只用 MoonBit 自带的核心库(core/string、core/math、core/encoding/utf8),没有第三方依赖。

    3MF 支持引入两个库:hustcer/fzip(解 ZIP)和 Milky2018/xml(解 XML)。不自己造是因为 这两件事的坑都很深——ZIP 要写 inflate,还得防 zip bomb;XML 的实体、命名空间、属性引号 一个都不能漏。fzip 自带解压比检查和路径安全检查,解析陌生人传上来的文件正需要。

    两者都是纯 MoonBit、没有 FFI,所以 native / wasm / js 三端都能跑这条不受影响。 代价是网页产物从 334 KB 涨到 770 KB。

    命令行包另外引入 moonbitlang/x(读文件、设退出码)、core/argparse(参数解析) 和 moonbitlang/async(分块读文件,只有 --bench 用)。

    #项目结构

    • vec3.mbt — 三维向量与点乘/叉乘/单位化
    • mesh.mbt — Triangle / Aabb / Mesh,以及体积、表面积、包围盒
    • stl.mbt — STL 解析(两种形态)与格式判断
    • obj.mbt — OBJ 解析(共享顶点表、多边形三角化、负数编号)
    • three_mf.mbt — 3MF 解析(ZIP + XML、装配体、单位换算)
    • validate.mbt — 顶点焊接与四项校验
    • winding.mbt — 绕向一致性检查与统一
    • repair.mbt — 补洞、删退化/重复面、统一绕向、重算法线
    • writer.mbt — 导出二进制 / ASCII STL 与 OBJ
    • preview.mbt — SVG 等轴测渲染
    • stream.mbt — 流式扫描(不建网格、分块读取,内存不随模型增长)
    • cmd/main/ — 命令行
    • web/ — 网页版(含编译产物 dist/web.js)
    • examples/ — 演示与测试用的模型:完好、破洞、绕向反了、圆环,STL 与 OBJ 各一份

    #修复

    --fix 把网格修一遍再存出去。支持四项:删退化面、删重复面、补洞、重算法线。

    moon run cmd/main examples/broken.stl --fix fixed.stl

    修复前 边界边 3 非流形边 0 退化面 0 修复后 边界边 0 非流形边 0 退化面 0 已存到 fixed.stl(684 字节),现在可以切片打印了

    导出格式按文件名后缀选:.stl 出二进制 STL,.obj 出 OBJ,.ply 出二进制 PLY。这是全项目唯一一处看扩展名的地方——别处判断输入格式都看内容,因为导出必须由用户指定格式,同一个网格好几种格式都能写,没有内容可看。

    补洞是最有价值的一项,也最容易出错:新补的三角形绕向必须和原来的面一致。原来的面里有一条边 a→b 只被一个面用到,新补的三角形就得用 b→a,这条边才会变成「被两个面以相反方向共用」,也就是水密。方向搞反的话补完还是不通。

    洞的形状可能是凹多边形,所以补法是「环中心 + 环上相邻两点」而不是直接扇形三角化——直接扇形会补出翻到实体外面的三角形。三角形的洞(三个顶点)例外,直接补一个三角形,不用加中心点。

    修完会重新跑一遍校验,把修复前后的边界边数对照着打出来。只报「修复完成」没法让人判断到底修好了没有,显示「边界边 3 → 0」才叫证据。

    网页版上也有这个按钮:有问题才会出现(没问题的模型给个按钮反而让人以为要修什么),点一下就地修好、更新预览、给一个下载链接。

    修复

    #绕向:水密检查查不出的那种坏

    有一类坏数据藏得很深:某个面的顶点顺序写反了。它仍然是「每条边恰好被两个面共用」的闭合曲面——水密性检查说没问题;按它自己的顺序重算法线也是自洽的——法线检查也说没问题。但那个面朝里了,体积求和时它的贡献符号反了:

    $ moon run cmd/main examples/flipped.stl 体积 0.667 mm³ ← 错!应该是 1 ✗ 有 1 个面的绕向和其他面相反(顶点顺序写反了) 这些面朝里了:水密性检查看不出问题,但体积会算错,切片软件也可能把实心区域填反 ⚠ 有 1 个三角形的法线朝向和顶点顺序不一致

    查它的办法是从一个面出发做广度优先遍历,沿共享边往外推:相邻的两个面必须以相反方向使用它们共用的那条边。顺藤摸瓜就能给每个面定下朝向,跟主体不一致的就是写反的。

    修的时候要注意顺序——统一绕向必须排在重算法线之前:绕向一变法线也跟着变,反过来做的话刚算好的法线会被绕向翻转作废。

    还有一步不能省:遍历只能保证「互相一致」,保证不了「朝外」。一个整体朝里的网格所有面都是一致的,但体积是负的。所以遍历完还要看有符号体积的正负,为负就把全体翻一遍。

    $ moon run cmd/main examples/flipped.stl --fix fixed.stl 修复: 翻转 1 个绕向反了的面(体积从 0.667 修正为 1) 已存到 fixed.stl(684 字节),现在可以切片打印了

    实现上踩了两个坑,都记在 winding.mbt 的注释里:

    1. 相邻面的方向必须跟当前面比,不能跟边的规范顺序比。拿规范顺序比会得到「所有面都和规范顺序一致」这种恒真的结论,规则直接失效。
    2. 同一条边的两个面都要留着。绕向反了的那条边上,两个面走的是同一个方向——只存「谁走了这个方向」的话,后写入的会覆盖先写入的,查找时还会返回自己。

    #参考

    设计上参考过 trimesh(Python 的通用三角网格库):

    • 零件数(count_parts)对应它的 mesh.split()——按面的邻接数连通块
    • 欧拉示性数(ValidationReport::euler_number)对应它的 mesh.euler_number
    • 水密性、绕向一致性的判据也是同一套拓扑定义

    这两个能力是看完 trimesh 的设计之后补的。没有复制它的任何代码(它是 MIT 许可的 Python 库,本项目是 MoonBit 实现,语言和数据结构都不同),借鉴的是「该有哪几项指标」 这个层面的思路。

    和它的完整对比——包括它有哪些能力、stlkit 有哪些没有、两者能跑的地方差在哪—— 在 COMPARISON.md。

    其它参考:

    • 3MF 的格式定义来自 3MF Consortium 的规范; examples/conformance/ 下收的 13 个一致性测试文件也来自他们的 3mf-samples 仓库(BSD 2-Clause)

    #许可证

    Apache License 2.0,见 LICENSE。

    Aabb

    pub(all) struct Aabb {
    min : Vec3
    max : Vec3
    }

    轴对齐包围盒。

    Aabb::size

    fn Aabb::size(self : Aabb) -> Vec3

    包围盒的尺寸(长宽高)。

    Mesh

    pub(all) struct Mesh {
    triangles : Array[Triangle]
    }

    网格:一个三角形列表,就这么多。

    Mesh::bounding_box

    fn Mesh::bounding_box(self : Mesh) -> Aabb?

    轴对齐包围盒。空网格返回 None。

    Mesh::center_mass

    fn Mesh::center_mass(self : Mesh) -> Vec3

    网格的重心(质心)。假设材料密度均匀。

    返回的是绝对坐标。网格不水密时这个数不可信——一阶矩和体积一样, 靠各面的正负抵消算出来,有破洞就抵消不干净。

    Mesh::empty

    fn Mesh::empty() -> Mesh

    空网格。

    Mesh::is_empty

    fn Mesh::is_empty(self : Mesh) -> Bool

    网格是不是空的(一个三角形都没有)。

    Mesh::moment_inertia

    fn Mesh::moment_inertia(self : Mesh) -> FixedArray[Double]

    网格关于重心的转动惯量张量,假设密度为 1。

    返回 9 个数,按行排列:

    [0] [1] [2] Ixx Ixy Ixz [3] [4] [5] = Iyx Iyy Iyz [6] [7] [8] Izx Izy Izz

    张量是对称的(Ixy = Iyx),所以实际只有 6 个独立分量, 另外 3 个是重复的——保留完整 3×3 是为了和 trimesh 的 moment_inertia 对得上,用起来不用想「哪个下标是哪个」。

    对应 trimesh 的 mesh.moment_inertia(density=1 时的结果)。

    Mesh::nearest_point

    fn Mesh::nearest_point(self : Mesh, p : Vec3) -> (Double, Vec3)

    网格上离给定点最近的位置和距离。

    对应 trimesh 的 mesh.nearest.on_surface。

    做法是逐三角形求「点到三角形的最短距离」: 先看投影落不落在三角形内(落进去就是点到平面的垂直距离), 落不进去就是到三条边的最短距离。

    这是无符号距离,不区分点在模型里面还是外面。

    Mesh::new

    fn Mesh::new(triangles : Array[Triangle]) -> Mesh

    Mesh::oriented_bounding_box

    fn Mesh::oriented_bounding_box(self : Mesh) -> Obb?

    求有向包围盒。空网格返回 None。

    Mesh::principal_moments

    fn Mesh::principal_moments(self : Mesh) -> FixedArray[Double]

    惯量主轴上的三个主转动惯量(把转动惯量张量对角化之后的三个特征值)。

    对应 trimesh 的 mesh.principal_inertia_components。

    三个数按从大到小返回。物理含义是:绕着对应主轴转的时候, 需要用到的力矩——最小的那个轴就是「最容易转起来的轴」。

    张量是对称的,所以特征值有闭式解(Cardano 公式),不用迭代。

    Mesh::ray_cast

    fn Mesh::ray_cast(self : Mesh, origin : Vec3, dir : Vec3) -> RayHit?

    射线穿过整个网格,返回最近的一次命中。

    Mesh::surface_area

    fn Mesh::surface_area(self : Mesh) -> Double

    网格的总表面积:所有三角形面积之和。

    这个数不受水密性影响——破洞的网格也能算出「现有这些面的总面积」, 只是它不等于「这个实体的表面积」。

    Mesh::triangle_count

    fn Mesh::triangle_count(self : Mesh) -> Int

    三角形数量。

    Mesh::vertices

    fn Mesh::vertices(self : Mesh) -> Array[Vec3]

    所有顶点,含重复。数量是三角形数 × 3。

    「文件里写了多少个顶点」和「实际有多少个不同的顶点」是两回事, 校验环节会分别报告,所以这里不做去重。

    Mesh::volume

    fn Mesh::volume(self : Mesh) -> Double

    网格围出的体积。

    把每个三角形的有符号体积加起来。这个算法要求网格是水密的: 闭合曲面上各面的贡献会正负抵消,只留下包围的那块空间;有破洞就 抵消不干净,算出来的数没有意义。

    所以调用方在报这个数之前,应该先看水密性校验的结果。现在还没有 校验环节,先直接算;等做校验那一步,报告里会注明这个前提。

    MeshFormat

    pub(all) enum MeshFormat {
    Ascii
    Binary
    ThreeMF
    Ply
    Unknown
    }

    支持的格式。

    Unknown 不是「认不出来」,而是「不是 STL,去试试 OBJ」—— 调用方看到它应该走 OBJ 那条路,见 parse_mesh。

    Obb

    pub(all) struct Obb {
    center : Vec3
    axes : Array[Vec3]
    half : Array[Double]
    }

    有向包围盒。

    Obb::size

    fn Obb::size(self : Obb) -> Vec3

    三条边的长度。

    Obb::volume

    fn Obb::volume(self : Obb) -> Double

    盒子的体积。真实体积的上界——模型装在里面,一般填不满。

    PreviewOptions

    pub(all) struct PreviewOptions {
    width : Int
    height : Int
    yaw : Double
    pitch : Double
    stroke : String
    stroke_width : Double
    }

    渲染参数。

    PreviewOptions::default

    默认渲染参数:从一个斜上方 45 度的常见视角看。

    RayHit

    pub(all) struct RayHit {
    distance : Double
    triangle : Int
    point : Vec3
    }

    一次射线命中。

    RepairOptions

    pub(all) struct RepairOptions {
    tolerance : Double
    drop_degenerate : Bool
    drop_duplicates : Bool
    fill_holes : Bool
    recompute_normals : Bool
    unify_winding : Bool
    }

    修复选项。

    RepairOptions::default

    fn RepairOptions::default() -> RepairOptions

    默认修复:什么都做。

    RepairOptions::normals_only

    fn RepairOptions::normals_only() -> RepairOptions

    只修法线,不动拓扑。适合「模型本来是好的,只是法线写错了」的情况。

    RepairResult

    pub(all) struct RepairResult {
    mesh : Mesh
    removed_degenerate : Int
    removed_duplicates : Int
    filled_holes : Int
    added_triangles : Int
    recomputed_normals : Int
    flipped_faces : Int
    }

    修复结果。除了修好的网格,还报告每一项具体做了多少。

    报告做了什么很重要:如果跑了修复但边界边从 3 变成 3,用户需要知道 是「补洞没生效」还是「补了三处又破了三处」。光给一个网格说不清楚。

    RepairResult::changed

    fn RepairResult::changed(self : RepairResult) -> Bool

    做了什么修改。

    StlScanner

    pub(all) struct StlScanner {
    // private fields
    }

    分块扫描二进制 STL 的状态机。

    用法:反复 feed 数据块,最后 finish 出报告。 内部只保留「没凑够一个完整记录的零头」和统计需要的那两张表, 不保留三角形本身,所以内存不随模型规模增长。

    StlScanner::feed

    fn StlScanner::feed(self : StlScanner, chunk : Bytes) -> Unit

    喂一块数据进去。

    块的大小随意,不要求对齐到 50 字节——零头会在内部攒着, 和下一块拼起来。调用方按 64 KB 之类的尺寸读就行。

    StlScanner::finish

    fn StlScanner::finish(self : StlScanner) -> Result[StreamReport, String]

    结束扫描,出报告。

    StlScanner::new

    fn StlScanner::new() -> StlScanner

    StreamReport

    pub(all) struct StreamReport {
    triangle_count : Int
    raw_vertex_count : Int
    unique_vertex_count : Int
    boundary_edge_count : Int
    non_manifold_edge_count : Int
    volume : Double
    surface_area : Double
    bbox : Aabb?
    }

    流式扫描的结果。

    字段和 ValidationReport 有意保持一致,方便并排显示。

    StreamReport::is_watertight

    fn StreamReport::is_watertight(self : StreamReport) -> Bool

    网格是不是水密的。判据和全量校验一致。

    ThicknessReport

    pub(all) struct ThicknessReport {
    samples : Int
    hits : Int
    min_thickness : Double
    min_at : Vec3?
    mean_thickness : Double
    }

    壁厚检查的结果。

    Triangle

    pub(all) struct Triangle {
    normal : Vec3
    a : Vec3
    b : Vec3
    c : Vec3
    }

    一个三角形。

    法线是文件里写的那个,不一定可信——很多导出器写得一塌糊涂,甚至直接 填 0 0 0。所以别的地方要用朝向时,一律用 computed_normal() 自己算。

    Triangle::area

    fn Triangle::area(self : Triangle) -> Double

    三角形面积。

    两条边叉乘的模长等于这两个边张成的平行四边形面积,三角形是它的一半。

    Triangle::computed_normal

    fn Triangle::computed_normal(self : Triangle) -> Vec3

    按右手定则从顶点算出的法线(已单位化)。

    面法线 = (b - a) × (c - a)。三个点共线时叉乘为零向量, normalize 会原样返回零向量,调用方靠这个判断退化面。

    Triangle::from_vertices

    fn Triangle::from_vertices(a : Vec3, b : Vec3, c : Vec3) -> Triangle

    用三个顶点建三角形,法线留零,留给调用方填 (STL 二进制文件里有现成的法线,OBJ 需要自己算)。

    Triangle::signed_volume

    fn Triangle::signed_volume(self : Triangle) -> Double

    有符号体积贡献。

    把「三角形 + 原点」看成一个四面体,用混合积算它的有符号体积。 整个闭合网格把每个三角形的贡献加起来,原点以外的部分会正负抵消, 剩下的就是这个网格的体积——这就是为什么要求网格是水密的: 有破洞就抵消不干净,算出来的体积是错的。

    Triangle::vertices

    fn Triangle::vertices(self : Triangle) -> Array[Vec3]

    三角形的三个顶点,按 a、b、c 的顺序。

    ValidationReport

    pub(all) struct ValidationReport {
    triangle_count : Int
    raw_vertex_count : Int
    unique_vertex_count : Int
    boundary_edge_count : Int
    non_manifold_edge_count : Int
    degenerate_count : Int
    wrong_normal_count : Int
    unique_edge_count : Int
    }

    网格校验报告。

    ValidationReport::euler_number

    fn ValidationReport::euler_number(self : ValidationReport) -> Int

    欧拉示性数 χ = V − E + F。

    对应 trimesh 的 mesh.euler_number。和 count_parts 一样, 是看了 trimesh 的设计之后补的,详细对比见 COMPARISON.md。

    水密网格上这个数是有拓扑含义的:

    • 单个实体、没有洞(topologically 一个球面):χ = 2
    • 有一个穿透的洞(比如甜甜圈):χ = 0
    • 有两个穿透的洞:χ = −2

    有 k 个互不相连的实体时,χ = 2 × k(每个实体贡献 2)。 所以这个数还能反过来验证零件数:零件 × 2 − χ 一定是偶数, 它的一半就是所有零件上「洞」的总数。

    注意:网格不水密时这个数没有上面的含义,只是一堆计数。 报告里也只在结论是水密的情况下才拿它说事。

    ValidationReport::is_watertight

    fn ValidationReport::is_watertight(self : ValidationReport) -> Bool

    网格是不是水密的。

    判据:每条边都恰好被两个面共用。这是 3D 打印能成功的必要条件—— 切片软件要靠这个判断哪是「里面」哪是「外面」,有破洞就分不清, 算出来的实心区域是错的。

    Vec3

    pub(all) struct Vec3 {
    x : Double
    y : Double
    z : Double
    }

    三维向量。

    坐标用 Double,即使 STL 二进制格式里存的是 32 位浮点。原因是后面 要做的体积求和是几十万项累加,用 32 位会在累加过程里越飘越远; 读进来就转成双精度,误差只来自原始数据本身。

    Vec3::cross

    fn Vec3::cross(self : Vec3, other : Vec3) -> Vec3

    叉乘。

    这个库用得最多的运算:
    • 两条边向量叉乘得到三角形法线
    • 法线模长的一半就是三角形面积
    • 法线与视线点乘可以判断面朝向

    Vec3::dot

    fn Vec3::dot(self : Vec3, other : Vec3) -> Double

    点乘。

    Vec3::is_zero

    fn Vec3::is_zero(self : Vec3, tolerance : Double) -> Bool

    判断是否为零向量(在给定容差内)。

    退化检测要用:三角形两条边叉乘结果接近零,说明三个点共线, 这个面没有面积。

    Vec3::length

    fn Vec3::length(self : Vec3) -> Double

    模长。

    Vec3::new

    fn Vec3::new(x : Double, y : Double, z : Double) -> Vec3

    Vec3::normalize

    fn Vec3::normalize(self : Vec3) -> Vec3

    单位化。零向量原样返回——网格里出现零长度法线时不该让整个流程崩掉, 交给校验环节去报告。

    Vec3::scale

    fn Vec3::scale(self : Vec3, factor : Double) -> Vec3

    逐分量乘一个系数。

    3MF 的单位换算要用:文件里可能是英寸、厘米,读进来统一乘到毫米。

    Vec3::sub

    fn Vec3::sub(self : Vec3, other : Vec3) -> Vec3

    self - other:从 other 指向 self 的向量。

    计算三角形法线时用到:两条边向量相减得到同一个起点出发的两条边。

    Vec3::zero

    fn Vec3::zero() -> Vec3

    零向量,累加包围盒之类的场景当起点用。

    WeldResult

    pub(all) struct WeldResult {
    ids : Array[Int]
    unique_count : Int
    }

    顶点焊接的结果。

    WindingReport

    pub(all) struct WindingReport {
    flipped_faces : Int
    consistent : Bool
    signed_volume : Double
    }

    绕向检查报告。

    check_thickness

    fn check_thickness(mesh : Mesh, samples : Int) -> ThicknessReport

    检查壁厚。

    采样是确定性的:同一个模型每次跑出来的数一样。 用黄金比之类的低差异序列把采样点铺开,而不是伪随机数—— 「上次跑 0.42,这次跑 0.38」这种报告没法用来对比修复前后的变化。

    check_thickness_default

    fn check_thickness_default(mesh : Mesh) -> ThicknessReport

    用默认采样数检查壁厚。

    check_winding

    fn check_winding(mesh : Mesh, tolerance : Double) -> WindingReport

    检查网格的绕向一致性。

    check_winding_default

    fn check_winding_default(mesh : Mesh) -> WindingReport

    用默认容差检查绕向。

    count_parts

    fn count_parts(mesh : Mesh, tolerance : Double) -> Int

    数一数这个网格由几个互不相连的实体组成。

    对应 trimesh 的 mesh.split()。这个能力是看了 trimesh 的设计之后补的, 详细对比见仓库根目录的 COMPARISON.md。

    判据是面的邻接:两个三角形共用一条边就算连在一起, 连通块的个数就是零件数。这和水密性用的是同一套拓扑, 不需要额外的几何判断。

    为什么 3D 打印要看这个:切片软件按连通块分别生成路径, 一个「看起来是一个模型」的文件里如果有 5 块互不相连的几何, 打出来就是 5 个零散的东西——它们可能根本没连在一起,拿起来就散了。 而且这类问题从预览图上看不出来,模型画出来是完整的。

    注意只有共用边才算连接。两个零件刚好顶点碰着顶点不算—— 那种接触在切片时是断的,和分开放没有区别。 (这也是为什么不用「顶点相同就算连通」来判,那样会漏报。)

    count_parts_default

    fn count_parts_default(mesh : Mesh) -> Int

    用默认容差数零件。

    default_weld_tolerance

    fn default_weld_tolerance() -> Double

    默认的顶点焊接容差。

    1e-6 毫米 = 1 纳米。选这个量级是因为:STL 二进制里存的是 32 位浮点, 有效精度大约 7 位十进制,所以同一个点在文件里重复出现时可能有 1e-7 相对量级的差异。1e-6 的绝对容差能盖住这个误差,又远小于任何 有意义的建模特征(3D 打印机的精度是 0.1 毫米量级)。

    detect_format

    fn detect_format(data : Bytes) -> MeshFormat

    判断数据是哪种格式。

    顺序有讲究:带魔数的排前面,靠长度算术的排后面。 二进制 STL 的判据是「文件长度正好等于 84 + 数量 × 50」, 一个随便什么文件撞上这个式子的概率大约是 1/50——所以不能在它前面 留一个「可能会被误判成二进制 STL」的岔路。3MF 和 PLY 都只看开头 几个固定字节,几乎不会误判,放前面最稳。

    is_3mf

    fn is_3mf(data : Bytes) -> Bool

    3MF 是个 ZIP 包。只看魔数,真正的校验在 parse_3mf 里做—— 「打开才知道」的检查不该塞进一个只看开头的判断函数。

    is_ply

    fn is_ply(data : Bytes) -> Bool

    是不是 PLY。

    规范里第一行必须是 ply,所以要求后面跟换行——只比三个字母的话, 万一有个二进制 STL 的文件头恰好以 "ply" 开头就会误判 (STL 那 80 字节头是自由的,写什么都合法)。

    parse_3mf

    fn parse_3mf(data : Bytes) -> Result[Mesh, String]

    解析 3MF:解 ZIP → 找模型部件 → 解 XML → 按 build 里列的对象拼出网格。

    parse_mesh

    fn parse_mesh(data : Bytes) -> Result[Mesh, String]

    按内容自动判断格式并解析。STL、OBJ、3MF 都能读。

    判断靠内容不靠扩展名——从别处拷来的文件经常带着错的扩展名, 而且二进制 STL 根本没有可靠的文本特征。

    顺序是:3MF(ZIP 魔数)→ STL 的两种形态(靠长度校验和关键字)→ 都不认再当 OBJ 试。

    parse_obj

    fn parse_obj(text : String) -> Result[Mesh, String]

    解析 OBJ 文本。

    只认几何相关的关键字:v(顶点)、f(面)、vn(法线)会读, 其余(vt、g、o、s、mtllib、usemtl、注释)跳过—— 它们不影响几何形状,也不影响能不能打印。

    parse_ply

    fn parse_ply(data : Bytes) -> Result[Mesh, String]

    解析 PLY。ASCII 和二进制(小端 / 大端)三种都支持。

    parse_stl

    fn parse_stl(data : Bytes) -> Result[Mesh, String]

    从 STL 数据解析出网格,自动判断是 ASCII 还是二进制。

    判断方法:先按二进制试——第 80 字节处读出的三角形数量如果能让总长度 刚好对上(84 + 数量 × 50),那就是二进制。对不上再按文本解析。

    为什么不用「开头是不是 solid」判断?因为二进制 STL 的 80 字节文件头 是随便写的,很多导出器就塞一句 "solid ...",靠这个判断会误判。 反过来,长度校验几乎不会误判。

    parse_stl_ascii

    fn parse_stl_ascii(text : String) -> Result[Mesh, String]

    解析 ASCII STL。

    逐行扫,认三种关键字: facet normal x y z 开一个新面,记下法线 vertex x y z 往当前面里加一个顶点 endfacet 当前面结束,凑够三个顶点就收一个三角形

    其余的行(solid、outer、loop、endloop、endsolid)直接跳过—— 它们不影响几何信息。

    parse_stl_binary

    fn parse_stl_binary(data : Bytes) -> Result[Mesh, String]

    解析二进制 STL。

    ray_triangle

    fn ray_triangle(origin : Vec3, dir : Vec3, a : Vec3, b : Vec3, c : Vec3) -> Double?

    一条射线打在一个三角形上,返回距离(None = 没打中)。

    Möller–Trumbore:把命中点表示成 a + u·(b−a) + v·(c−a), 直接解出 u、v 和距离 t。三个约束各自能提前退出,不用算完整。

    只有 t > 0 才算命中——反方向的交点是「背后的东西」,不算。

    render_svg

    fn render_svg(mesh : Mesh, options : PreviewOptions) -> String

    把网格渲染成一张 SVG 图。

    空网格返回一张空图而不是报错——调用方(网页)在文件还没上传时 需要有个东西显示。

    repair

    fn repair(mesh : Mesh, options : RepairOptions) -> RepairResult

    修复一个网格。

    stream_stl_ascii

    fn stream_stl_ascii(text : String) -> Result[StreamReport, String]

    流式扫描 ASCII STL。

    逐行扫,同样是累加不保留。ASCII 文件本身比二进制大好几倍, 流式的意义反而更大。

    stream_stl_binary

    fn stream_stl_binary(data : Bytes) -> Result[StreamReport, String]

    流式扫描二进制 STL。

    二进制格式天生适合流式:每个三角形固定 50 字节,顺序读就行, 不需要回头。ASCII 格式要逐行扫,见 stream_stl_ascii。

    symmetric_eigenvalues

    fn symmetric_eigenvalues(m : FixedArray[Double]) -> FixedArray[Double]

    对称 3×3 矩阵的特征值,从大到小。

    输入按行排列的 9 个数([0..2] 第一行,[3..5] 第二行,[6..8] 第三行), 只用得到上三角——对称矩阵的下三角是重复的。

    symmetric_eigenvector

    fn symmetric_eigenvector(m : FixedArray[Double], lambda : Double) -> Vec3

    求某个特征值对应的单位特征向量。

    做法:算 B = A − λI,然后取 B 的三行两两叉乘,挑模长最大的那个。

    为什么不直接解方程组:λ 是数值算出来的,代入之后 B 通常不满秩, 靠选主元解出来的向量可能全是噪声。而叉乘天然给出「垂直于两行」的方向, 哪个叉乘最大就说明哪两行最独立——这也顺带躲开了「某一行恰好是零行」的情况。

    unify_winding

    fn unify_winding(mesh : Mesh, tolerance : Double) -> Mesh

    把网格的绕向统一,并保证整体朝外。

    两步:
    1. 用 winding_flags 让所有面互相一致
    2. 看有符号体积——为负说明整体朝里,把所有面翻一遍

    第 2 步不能省。只做第 1 步的话,一个整体朝里的网格会保持朝里, 体积仍然是负的。

    validate

    fn validate(mesh : Mesh, tolerance : Double) -> ValidationReport

    全面校验一个网格。

    validate_default

    fn validate_default(mesh : Mesh) -> ValidationReport

    用默认容差校验。

    write_mesh

    fn write_mesh(mesh : Mesh, path : String) -> Result[Bytes, String]

    按文件名后缀选格式导出。

    这里是唯一一处看扩展名的地方,别处判断格式都看内容。因为导出 必须由用户指定格式——同一个网格既可以写成 STL 也可以写成 OBJ, 没有内容可看。

    write_obj

    fn write_obj(mesh : Mesh) -> String

    导出成 OBJ。

    OBJ 有共享顶点表,所以这里要先按位置去重再写——不然就白费了 OBJ 的格式优势,写出来比 STL 还大。

    注意:去重会让拓扑变成共享顶点的形式,这个文件重新读进来之后 「有多少重复顶点」那条校验信息就跟原来不一样了。这是格式本身的 差别,不是数据丢了。

    write_ply

    fn write_ply(mesh : Mesh) -> Bytes

    导出成 PLY,默认走二进制——和 trimesh 的默认一致,文件也小得多。

    write_ply_ascii

    fn write_ply_ascii(mesh : Mesh) -> String

    导出成 ASCII PLY。

    ASCII 的好处是人眼能看、能直接 diff;代价是文件大好几倍 (一个坐标要写十几位十进制,二进制只要 4 个字节)。 要存下来给别人用就选二进制的那个。

    write_ply_binary

    fn write_ply_binary(mesh : Mesh) -> Bytes

    导出成二进制 PLY(小端)。

    选小端是因为现在的读写器基本都是小端,大端只剩考古价值了—— 但读的时候两种都得认,谁知道会收到什么文件。

    write_stl_ascii

    fn write_stl_ascii(mesh : Mesh, name : String) -> String

    导出成 ASCII STL。人眼能读,但体积是二进制版的好几倍。

    write_stl_binary

    fn write_stl_binary(mesh : Mesh) -> Bytes

    导出成二进制 STL。

    写之前会把法线重算一遍——文件里存的那份可能是错的,而导出的文件 是要拿去打印的,不能把错误带出去。