Sign in

    stlkit

    MoonBit 实现的 STL/OBJ/3MF/PLY 网格工具链:解析、打印前复核、修复、分析与流式扫描

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

    #stlkit:接口与实现

    MoonBit 的 STL / OBJ / 3MF / PLY 网格工具链。本次修订版本为 0.1.2;安装、命令行和网页版入口见 README.md。Mooncakes 使用 moon add mik1e80/stlkit@0.1.2,源码固定到 v0.1.2。0.1.0 / 0.1.1 不包含本轮坐标 schema 与路径规范化修复,复核范围及迁移说明见 0.1.2 发布说明。

    #数据与单位

    Vec3 使用 Double;Triangle 保存三个点及声明法线;Mesh 保存三角形数组。解析阶段展开 OBJ / PLY / 3MF 的索引和对象引用,不保留原文件的独立顶点身份。

    拓扑分析按容差重建顶点关系。全量校验、流式扫描、PLY 导出和定向包围盒共用完整的三分量坐标键,不截断坐标位数,也不转换为 32 位整数。哈希冲突通过完整相等判定区分。

    默认焊接容差为 1e-6 毫米,公开的 validate(mesh, tolerance) 可调整容差。量化不是距离搜索:格子边界两侧即使很近也可能分开。STL / OBJ / PLY 按毫米解释;3MF 的声明单位换算到毫米。

    #读取与导出

    接口用途
    parse_mesh(data)按内容检测 STL / OBJ / 3MF / PLY,返回 Result[Mesh, String]
    parse_stl_ascii(text) / parse_stl_binary(data)STL 读取
    parse_obj(text)OBJ 顶点、法线、负索引和多边形面;支持空格、制表符、CRLF、行内注释
    parse_3mf(data)ZIP / XML、对象引用、组件变换与单位
    parse_ply(data, lenient_ascii=false)ASCII / 小端 / 大端 PLY;默认连续按声明消费正文,不丢弃行尾 token
    write_stl_ascii(mesh) / write_stl_binary(mesh)STL 导出
    write_obj(mesh)OBJ 导出
    write_ply_ascii(mesh) / write_ply_binary(mesh)PLY 导出,统一容差去重
    write_mesh(mesh, filename)按输出后缀选择格式

    OBJ / PLY 的三角形原样保留,由校验层检查退化性。平面简单凸多边形保留扇形切分;凹多边形用耳切,并保持绕向。自相交和非平面多边形明确报错,不猜测几何。未实现 OBJ 材质渲染以及完整 3MF 颜色、材料与扩展语义。

    ASCII PLY 默认严格按声明消费正文:记录可跨行或共用一行,未知元素、标量与变长列表照声明读取后丢弃,拒绝缺失值和声明外的尾随 token。面先声明也可读取,索引验证延后到顶点表完整之后。header 仍按行读取;“严格”描述正文布局规则,不表示完整 PLY 规范验证。仅有点云的文件仍报错。

    parse_ply(data, lenient_ascii=true) 显式启用异常 ASCII 文件的逐行兼容:一行一条记录,丢弃该行未声明的余项,行内缺少声明值时报错,不跨行补齐。这会有意丢弃数据,调用方应先确认布局;不能用于多条记录共用一行。该选项对二进制输入无影响。parse_mesh()、CLI、网页保持默认模式,不做失败后自动回退。普通 parse_ply(data) 调用方式保留;依赖旧版隐式忽略行尾数据的调用方需修正文件声明或显式启用兼容模式。

    #格式探测与解析

    PLY 的每个 vertex 元素(含 count=0 的声明)必须分别包含且只包含一个标量 x、y、z。在构建正文游标前拒绝缺失、list 型或重复坐标属性,不从默认零值推断未声明的坐标。三种编码和显式逐行兼容模式采用同一 schema 规则;坐标重排、真实零值及未知属性仍可读取。回归见 ply_schema_wbtest.mbt 和自建的 examples/missing-z.ply。

    detect_format(data) 返回 Ascii(ASCII STL)、Binary(二进制 STL)、Obj、ThreeMF、Ply 或 Unknown。它依据内容提供解析路由提示,不使用文件后缀,也不证明文件有效或完整符合规范。

    探测先检查 ZIP/3MF 候选与 PLY 文件头,再检查二进制 STL 的计数和精确文件长度。其余文本逐行查找行首几何记录:STL 的 facet/vertex/endfacet 或 OBJ 的 v/vn/f;仅有 solid/endsolid 时作为 ASCII STL 候选。注释和对象名称中嵌入这些单词不会被当成几何记录。文本扫描直到找到提示或到达末尾,最坏为 O(n),不以 512 字节作为识别上限。

    parse_mesh() 将已识别格式送到对应解析器;Unknown 文本依次尝试 ASCII STL 和 OBJ,均失败时返回“无法识别网格格式”并附两者原因。已识别格式的解析错误直接保留,不因文件损坏而换一种格式蒙混通过。Unknown 不再是 OBJ 的别名;parse_stl() 明确拒绝 OBJ、PLY 和 3MF。

    可复现:examples/long-header.stl 的 solid 名称长 2048 个字符,首个 facet 位于 512 字节之后;examples/obj-stl-keywords.obj 的注释和对象名称包含 STL 关键字。两者均应按实际内容显示正确格式、读取 12 个三角形;库、CLI、网页都有回归检查。

    #声明面法线检查范围

    当前仅检查已保留的声明面法线方向是否与三角形绕向相反:用声明向量与顶点绕序计算的单位几何法线做点积,结果小于 0 时计入 wrong_normal_count。声明向量长度不超过 1e-9 时跳过;零值计数不表示所有法线均已验证。

    不检查声明法线是否单位化、是否垂直于三角形,或是否与几何法线精确一致。例如,与几何法线正交的声明向量点积为 0,不计为反向;长度为 2 的同向向量也不会因此报错。退化面由单独的退化检查报告,不靠这项证明法线有效。

    OBJ 的 vn 是逐顶点数据。本库只在一个三角形的三个顶点法线逐分量完全相同时保留为单个声明面法线;缺失或三个向量不同时置零,之后跳过声明面法线反向检查。它不会验证或保留完整的平滑顶点法线场,也不会将不相同的 vn 自动判为错误。相邻三角形绕向由另一项检查处理。

    因此,报告中的兼容字段“错误法线”只表示已保留声明面法线的反向计数;不是完整法线正确性检查。normal_scope_wbtest.mbt 给出正交、非单位及不同 OBJ 顶点法线的可执行边界案例。

    #校验与打印前复核

    validate_default(mesh) 返回三角形数、去重顶点数、边界边、非流形边、退化面、已保留声明面法线反向计数(兼容字段 wrong_normal_count)与边计数。退化检查包含顶点合并和三点共线两种情况。

    check_winding_default(mesh) 按共享边连通分量检查绕向。WindingReport.components 的每项包含本次报告的分量编号 index、原始面索引 faces、closed、consistent、flipped_faces、signed_volume、orientation 和包围盒。重排输入面可能改变分量编号;总体 signed_volume 仅作兼容汇总,不能让正体积大零件掩盖负体积小零件。

    orientation 为 PositiveVolume、NegativeVolume 或 Indeterminate;开放、零体积或存在绕向冲突时不判断朝内外。negative_components() 返回确定的负体积闭合分量数;orientation_reliable() 要求非空、所有分量为正且没有可能嵌套 / 重叠标记。potentially_nested 基于包围盒重叠 / 接触的保守筛选,不是精确壳层分类。各分量使用局部原点与补偿求和,降低平移造成的体积相消。

    unify_winding() 统一可定向分量的相邻绕向,按独立实心零件假设翻转包围盒互不重叠的闭合负体积分量;潜在嵌套壳层保持整体符号。RepairResult.reoriented_components 计整体翻转分量数,原 flipped_faces 的局部调整语义保留,changed() 包含两者。

    本次源码新增的统一入口:

    // mesh 是已解析的网格。0.5 是调用方选择的参考壁厚,单位毫米。
    let review = @stlkit.check_printability(mesh, 0.5)
    println(review.verdict())
    println(review.inward_component_count()) // 确定的负体积闭合分量数
    if review.needs_review() {
    println("请按报告检查模型和加工条件")
    }

    PrintabilityReport 包含 validation、winding、可选的 thickness 和 min_thickness。inward_component_count() 从 winding.components 返回确定的负体积闭合分量数,不保存重复计数;兼容方法 inward() 等价于该计数大于 0。两者不依赖总体积;负号不直接等于设计错误,内腔仍须人工解释。

    thin_wall()、thickness_unavailable() 表示具体问题;needs_review() 和 verdict() 被 CLI 与网页共用。needs_review() 独立检查分量朝向,任一负体积、朝向未定或可能嵌套 / 重叠均须复核,不依赖厚度是否可用来间接拦截。即使调用方替换了公开报告的 thickness 字段,分量问题仍触发复核。负体积分量数为 0 不等于无需复核。

    参考阈值为非负有限数,0 不设薄壁阈值。CLI 默认 0.8mm,网页版可调整;它是参考值,不是通用工艺标准。结果为“本次网格检查未发现已覆盖的问题”或“本次检查发现需要复核的问题”,不承诺打印成功。

    检查范围不包括网格自相交、所有非流形顶点、支撑规划、喷嘴 / 树脂 / 材料适配、切片设置等。

    #壁厚

    check_printability(mesh, threshold, samples=1000) 可指定采样预算,原两参数调用保持兼容。review_scope() 返回 CLI / 网页共同的工艺边界说明;接口名字不代表可打印认证。按面编号采样不保证面积均匀,采样预算和面顺序会影响结果。docs/review-cases.md 给出默认预算漏掉 0.05 mm 薄板的反例。

    check_thickness(mesh, samples) / check_thickness_default(mesh) 从确定性表面采样点沿内法线打射线,记录命中数、采样最小厚度、命中样本平均值和最小样本位置。默认 1000 次采样,每条射线遍历三角形,时间复杂度约为 O(samples × faces)。

    报告是局部采样估计,可能漏掉未采样的薄处;射线起点的微小偏移也影响数值。命中数为 0 表示无法评估,不能解读为实际壁厚 0。统一复核入口在网格开放、退化、任一分量朝向非正或未定、可能嵌套 / 重叠时不作可靠壁厚估计。

    trimesh 的 trimesh.proximity.thickness() 同样提供厚度查询。本项目实现的是 MoonBit 的确定性采样与射线报告,不宣称该能力独有。

    #分析

    • Mesh::volume():有符号四面体贡献求和后取绝对值;开放或绕向异常时不能作为可信实体体积。
    • Mesh::surface_area()、bounding_box():现有三角形的面积与轴对齐包围盒。
    • oriented_bounding_box():PCA 方向的包围盒,不保证最小体积。协方差矩阵由 Jacobi 一次分解为完整正交基,两重或三重特征值均不再分别求向量;重复特征值下方向不唯一,不能保证恢复模型原始边方向。
    • symmetric_eigendecomposition(m):输入至少九项、按行排列的有限实对称 3×3 矩阵,使用上三角;返回 SymmetricEigen,values 为降序特征值,vectors 为对应正交轴。内部先缩放,最多 64 次最大非对角元 Jacobi 旋转;是 Double 数值算法,不保证任意尺度的精确结果。
    • symmetric_eigenvalues(m) 保留并复用上述分解。symmetric_eigenvector(m, lambda) 选择最接近 lambda 的单个基向量;同一重根反复查询返回同一选择,要取得完整基应使用分解接口。
    • count_parts_default(mesh):按共享边连通数零件,只有点接触不算连通。
    • ValidationReport::euler_number():χ = V − E + F。对闭合可定向曲面,χ = 2k − 2Σg;不能脱离这些前提解释“洞”的数量。
    • center_mass()、moment_inertia()、principal_moments():密度取 1,底层函数不自动验证网格,保留整体反向模型的计算行为;调用方负责几何前提。
    • PrintabilityReport::mass_properties_available():CLI / 网页共用的质量属性输出条件,报告必须来自同一网格。要求非空、边计数闭合、无退化面、绕向一致、无需局部翻面,且分量均为正体积、无潜在嵌套 / 重叠标记。失败时入口显示“未评估”;网页协议新增“质量属性”“质量属性前提”,异常时继续省略“重心”“主惯量”数值字段。薄壁与参考阈值不单独阻断质量属性;此方法不验证自交、全部非流形顶点或数值精度。
    • ray_cast():内部归一化方向,RayHit.distance 为最近命中的欧氏距离;零 / 非有限方向返回 None。
    • ray_triangle():底层返回 origin + dir*t 中的参数 t,不归一化方向。
    • nearest_point():表面最近点及无符号距离。

    #STL 输入契约

    STL 输入契约:parse_stl_binary()、stream_stl_binary() 和 StlScanner::finish() 均要求严格匹配 84 + 50 × count 字节,三角形数大于 0,声明法线与顶点有限。任何额外尾字节、截断或非有限字段均失败;仅完成 feed() 不表示文件有效。数量字段以无符号数读取,不允许有符号截断变成负数。

    parse_stl_ascii() 与 stream_stl_ascii() 共用逐面状态机,要求 facet normal、outer loop、三个 vertex、endloop、endfacet 的完整顺序。空行、ASCII 空白和 CRLF 可读,solid 包装可省略;未知记录、坏数字、错误顶点数、嵌套或未结束的面会报错。几何退化仍由验证层处理。detect_format() 是内容提示,二进制长度匹配只是结构证据;自动入口还须解析成功。

    兼容性:此前被部分入口忽略的尾部垃圾、坏面和非有限声明法线现在统一拒绝;没有增加宽松模式。stream_stl_ascii() 仍接收完整文本,真正分块文件入口目前仅支持二进制 STL。

    #射线命中距离

    Mesh::ray_cast(origin, dir) 在内部归一化有限的非零方向,返回最近命中的 RayHit。distance 是起点到命中点的欧氏距离,单位与网格坐标相同;point 是命中坐标。将方向乘以正数不会改变射线及其距离,负倍数则改变射线朝向。例如,从单位立方体下方 (0.25, 0.5, -1) 沿 (0, 0, 2) 发射,最近命中 z=0,距离为 1,而不是参数值 0.5。

    零方向、非有限起点或方向返回 None;无命中时同样返回 None,该返回类型不区分这些原因。归一化先按方向的最大绝对分量缩放,避免直接平方极大 / 极小方向造成溢出或下溢。求交仍使用 Double 和原有平行 / 自交阈值,不是任意几何尺度下的精确计算保证。

    底层 ray_triangle(origin, dir, a, b, c) 保持返回射线参数 t:命中点为 origin + dir*t。它不归一化方向;只有单位方向下参数才等于欧氏距离。不要把这个底层返回值与 RayHit.distance 混淆。

    此次修复不改变函数签名。依赖旧版非单位方向下参数值的 ray_cast() 调用方应改用正确的几何距离;需要命中点时直接读取 hit.point,不要将距离再次乘以原始非单位方向。轴向、斜向、极大 / 极小正比例方向及无效输入回归见 ray_direction_wbtest.mbt。

    #修复

    repair(mesh, RepairOptions::default()) 删除退化面和重复面、补简单闭合边界环、统一绕向并重算法线。RepairResult 返回修改后的网格及删除面数、补洞数、新增三角形数等。

    补洞采用边界环和中心扇形法;仅闭合环会参与补洞。复杂、凹形或非平面洞的几何质量不受保证,需人工检查。自动修复不能增加设计壁厚,也不负责网格布尔运算或修复自相交。

    CLI 和网页修复后都再次运行统一复核入口。水密性改善不等于其他风险已消除。

    CLI 支持 --fix fixed.stl --svg fixed.svg 组合:保存最终网格后,由同一个最终网格导出 SVG。没有可修复改动时也保存所请求的网格;--strict 在导出后依据最终复核结果退出,仍有问题时退出 1。网格写入失败则不继续 SVG;SVG 写入失败则退出 1,保留已写出的网格。两个输出在写入前按宿主平台规则比较规范化绝对路径(含 .、..、分隔符和 Windows 大小写),相同则拒绝;此词法比较不识别符号链接或硬链接别名。

    #流式路径

    stream_stl_ascii(text) / stream_stl_binary(data) 接收整份已在内存中的数据。StlScanner::new() / feed(chunk) / finish() 是二进制 STL 的分块入口,由调用方负责文件读取。

    分块扫描不保存完整文件和三角形数组,但仍保留顶点表及全部三角形的边记录。空间 O(V+R+chunk),非退化三角形 R=3F,R 包含重复边,不是不同边数;排序还有临时存储。StlScanner::storage() 返回顶点项、边记录、残留及单次缓冲峰值,不是分配器容量或进程内存测量。--stream-only 只运行这一路径,不提供完整绕向、壁厚、修复或预览。

    --bench 比较三条路径,并核对三角形数和拓扑计数;由于同时运行全量解析,它不适合作为单独的低内存扫描命令。耗时使用计时器测量;数据量按主要字段粗略估算,未包含对象头、哈希桶、分配器与运行时,不是进程内存实测。

    #验证

    moon update moon check --target all --deny-warn moon build moon test --target wasm moon test --target wasm-gc moon test --target js moon test --target native node cmd/main/smoke.cjs bash web/build.sh node web/smoke.cjs

    回归包括正常立方体、缺面、法线 / 绕向、单位与变换、破损输入,以及本次新增的坐标碰撞、3000mm 平移、共线退化面、凹多边形、独立破洞和报告边界。

    原有示例和新增审查案例用于手工复现,其中 17 个 3MF 文件来自官方样例库。读取成功不等于通过完整 3MF 规范认证。网页回归执行实际编译产物和报告逻辑,最小 DOM 仅验证判断和交互状态,不验证布局。

    #模块与依赖

    • vec3.mbt / mesh.mbt:几何数据。
    • stl.mbt / obj.mbt / three_mf.mbt / ply.mbt:格式读取;polygon.mbt 共用多边形三角化。
    • validate.mbt / winding.mbt / printability.mbt:拓扑和统一打印前复核。
    • repair.mbt / writer.mbt:修复与导出。
    • mass.mbt / mat3.mbt / obb.mbt / ray.mbt / thickness.mbt:分析。
    • stream.mbt:流式扫描。
    • preview.mbt:SVG。
    • cmd/main/ / web/:CLI 与网页导出层。

    核心几何算法只使用 MoonBit 核心库。3MF 使用 hustcer/fzip 和 Milky2018/xml;CLI 使用 moonbitlang/x 和 moonbitlang/async。代码支持 wasm / JS / native;CLI 文件读取只支持 wasm / native。

    #许可与参考

    项目采用 Apache-2.0。参考 trimesh 的 API 与模块设计,差异和能力边界见 COMPARISON.md。随附 3MF 样例采用 BSD-2-Clause,完整版权、条款及免责声明见 THIRD_PARTY_NOTICES.md。

    #输入边界补充(2026-10-17)

    ASCII STL / OBJ 的坐标和被读取的法线必须有限;二进制 STL 全量与流式读取均拒绝非有限坐标与声明法线。分块扫描遇错后保留错误,finish() 返回 Err;完整输入契约见上文“STL 输入契约”。

    3MF 拒绝非有限变换、嵌套变换或坐标计算溢出,以及单位换算后的坐标溢出。当前不实现扩展语义:非空 requiredextensions 一律明确拒绝;可选扩展仍可忽略。因此成功读取核心三角网格不表示完整支持所有 3MF 扩展。

    moon run cmd/main examples/nonfinite-rejected.obj --strict 应拒绝输入并以 1 退出,不生成检查通过结论。内存构造的 3MF、二进制 STL 与分块反例见 input_boundary_wbtest.mbt。变更与兼容约定见 CHANGELOG.md。

    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。 不自动验证几何前提,与 center_mass() 的调用责任相同。

    返回 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?

    射线穿过整个网格,返回最近的一次命中。 内部归一化有限非零方向,distance 不受方向向量长度影响。 零方向、非有限起点或方向返回 None;方向的正倍数表示同一条射线。

    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
    Obj
    ThreeMF
    Ply
    Unknown
    }

    格式探测结果;不是文件有效性或完整格式兼容性的证明。

    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 度的常见视角看。

    PrintabilityReport

    pub(all) struct PrintabilityReport {
    validation : ValidationReport
    winding : WindingReport
    thickness : ThicknessReport?
    min_thickness : Double
    }

    PrintabilityReport::inward

    fn PrintabilityReport::inward(self : PrintabilityReport) -> Bool

    PrintabilityReport::inward_component_count

    fn PrintabilityReport::inward_component_count(self : PrintabilityReport) -> Int

    确定为负体积的闭合分量数;负号也可能对应合法内腔,需要复核。 从分量明细计算,避免与 winding.components 保存重复且可能不一致的计数。

    PrintabilityReport::mass_properties_available

    fn PrintabilityReport::mass_properties_available(self : PrintabilityReport) -> Bool

    CLI / 网页输出质量属性的几何前提,与参考壁厚阈值无关。 不验证自相交、全部非流形顶点或浮点计算精度;报告须对应同一网格。

    PrintabilityReport::needs_review

    fn PrintabilityReport::needs_review(self : PrintabilityReport) -> Bool

    PrintabilityReport::thickness_unavailable

    fn PrintabilityReport::thickness_unavailable(self : PrintabilityReport) -> Bool

    PrintabilityReport::thin_wall

    fn PrintabilityReport::thin_wall(self : PrintabilityReport) -> Bool

    PrintabilityReport::verdict

    fn PrintabilityReport::verdict(self : PrintabilityReport) -> String

    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
    reoriented_components : Int
    }

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

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

    RepairResult::changed

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

    做了什么修改。

    ScanStorage

    pub(all) struct ScanStorage {
    unique_vertices : Int
    edge_records : Int
    pending_bytes : Int
    peak_buffered_bytes : Int
    }

    Logical retained-record counts, not allocator capacity or process RSS.

    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

    StlScanner::storage

    fn StlScanner::storage(self : StlScanner) -> ScanStorage

    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

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

    SymmetricEigen

    pub(all) struct SymmetricEigen {
    values : FixedArray[Double]
    vectors : Array[Vec3]
    }

    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 个独立表面时,χ = 2k − 2Σg,g 为各表面的亏格。 边闭合不证明网格无自相交或非流形顶点;解释前需要核实这些前提。

    ValidationReport::is_watertight

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

    网格是不是水密的。

    判据:每条边都恰好被两个面共用;不检查非流形顶点、自相交或朝向。 空网格也满足这项边计数判据,调用方应另行检查三角形数。

    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
    }

    顶点焊接的结果。

    WindingComponent

    pub(all) struct WindingComponent {
    index : Int
    faces : Array[Int]
    flipped_faces : Int
    closed : Bool
    consistent : Bool
    signed_volume : Double
    orientation : WindingOrientation
    bounds : Aabb
    potentially_nested : Bool
    }

    WindingOrientation

    pub(all) enum WindingOrientation {
    PositiveVolume
    NegativeVolume
    Indeterminate
    } derive(Eq)

    WindingOrientation::equal

    WindingOrientation::label

    fn WindingOrientation::label(self : WindingOrientation) -> String

    WindingOrientation::not_equal

    WindingReport

    pub(all) struct WindingReport {
    flipped_faces : Int
    consistent : Bool
    signed_volume : Double
    components : Array[WindingComponent]
    }

    WindingReport::negative_components

    fn WindingReport::negative_components(self : WindingReport) -> Int

    WindingReport::orientation_reliable

    fn WindingReport::orientation_reliable(self : WindingReport) -> Bool

    check_printability

    fn check_printability(mesh : Mesh, min_thickness : Double, samples? : Int) -> PrintabilityReport

    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 毫米是坐标格子宽度,不保证覆盖所有 float32 舍入误差。 物理尺度与文件精度不同的模型,应由调用方选择合适容差。

    detect_format

    fn detect_format(data : Bytes) -> MeshFormat

    先识别 ZIP/3MF 和 PLY,再检查二进制 STL 长度。 其余文本按行首记录探测,直到找到几何记录或读完,不设 512 字节上限。 关键字在注释、对象名称或 solid 名称中出现时不作为几何记录。

    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 / PLY 解析器,不使用文件扩展名。 无提示的文本依次尝试 ASCII 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, lenient_ascii? : Bool) -> Result[Mesh, String]

    解析 PLY。ASCII 和二进制(小端 / 大端)三种都支持。 ASCII 默认连续消费空白分隔 token,不丢弃行尾,拒绝声明外的尾随数据。 lenient_ascii=true 仅用于每条记录占一行、行尾有未声明值的异常 ASCII 文件。

    parse_stl

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

    自动读取 STL;格式探测只是路由提示,几何仍由解析器验证。

    parse_stl_ascii

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

    解析 ASCII STL;与流式入口共用逐面状态机。 solid / endsolid 包装可省略;每个 facet 必须有完整 loop 和恰好三个有限顶点。

    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?

    一条射线打在一个三角形上,返回参数 t(None = 没打中)。 命中点为 origin + dir*t;仅当 dir 为单位向量时,t 才等于欧氏距离。 此底层入口不归一化方向;需要几何距离时使用 Mesh::ray_cast。

    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

    修复一个网格。

    review_scope

    fn review_scope() -> String

    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_eigendecomposition

    fn symmetric_eigendecomposition(m : FixedArray[Double]) -> SymmetricEigen

    Return a complete orthonormal basis, including repeated eigenvalues. Directions inside a repeated eigenspace are not unique.

    symmetric_eigenvalues

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

    Descending eigenvalues, using the same decomposition as PCA.

    symmetric_eigenvector

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

    Compatibility lookup: choose the basis vector nearest to lambda. Repeating this call for the same repeated eigenvalue returns the same vector; use symmetric_eigendecomposition() when a complete basis is needed.

    unify_winding

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

    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。

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