mooncsg

    Pure MoonBit 3D solid modeling kernel: signed distance primitives, exact CSG booleans, marching tetrahedra meshing, STL/OBJ export, no FFI.

    csg
    sdf
    3d
    modeling
    mesh
    Download zip
    Author
    Version
    0.1.0
    License
    Apache-2.0
    Last updated
    5 hours ago
    Downloads
    6

    No dependencies

    #mooncsg

    纯 MoonBit 的 3D 实体建模内核:有符号距离场(SDF)原语、CSG 布尔、 marching tetrahedra 网格化、OBJ / STL 导出。没有 FFI,没有第三方几何库。

    目标是让「程序化生成一个能直接进切片软件的实体」这件事只依赖一份 可读的 MoonBit 代码——几何在数学上闭合(水密、朝向一致、体积可对数), 而不是「看起来像」。

    #现状

    已覆盖 M0–M6:

    • vec.mbt:Vec3 / Mat3 / Aabb
    • sdf.mbt:球、盒、半空间、圆柱、环面;平移/旋转/缩放;并/交/差/平滑并; 保守包围盒
    • marching.mbt:规则体素 + 6 四面体分解 + 边键焊接 + 梯度投影
    • mesh.mbt:散度体积、面积、水密判定、欧拉示性数 / 亏格、FNV-1a 指纹
    • export.mbt:OBJ、ASCII STL、二进制 STL,以及二进制 STL 回读
    • examples/web:wasm 验收壳(页面 + ?selftest=1 自动跑)
    • examples/bench:网格化耗时基线(native / js / wasm-gc 同一份代码)
    • tools/oracle.py:独立解析基准(只用标准库)
    • tools/wasm_check.mjs:在 Node 里实例化同一份 wasm 跑验收
    • tools/acceptance.mjs:一条命令跑全部验收项并打表

    #快速开始

    moon test # 31 个测试(native / js / wasm-gc 都要跑) moon run examples/demo # 盒减球,打印体积/面积/拓扑 moon run examples/bench --release # 网格化耗时基线 moon check --deny-warn moon fmt --check # 浏览器验收壳 node tools/build_web.mjs # 编译 wasm 并复制到 examples/web/ node tools/serve.mjs # 打开 http://localhost:8000/?selftest=1 node tools/wasm_check.mjs # 不开浏览器,在 Node 里跑同一份 wasm # 一次跑完全部验收项(check/fmt/三后端测试/package/demo/web/oracle) node tools/acceptance.mjs

    examples/demo 的输出:

    triangles: 126484 vertices: 63242 volume: 17058.878927955866 watertight: true euler: 0 (genus 1)

    解析体积 24000 - 2π·10·(144 - 100/3) = 17046.6,相对误差 0.07%。

    #性能基线

    moon run examples/bench --release 走 moonbitlang/core/bench 的单调时钟 (native 绑 clock_gettime,wasm 绑 time_unstable.instant_now,微秒), 先跑一次预热,再取三次的最小值。机器:Intel Core Ultra 9 275HX / Windows 11 / Node 24(wasm-gc 与 js 都跑在 V8 上)。

    用例三角形wasm-gcjs
    球 r=10,32³2628013.1 ms37.6 ms
    球 r=10,64³10468864.1 ms165.1 ms
    立方体 40³ 挖球 r=24,48³10728087.5 ms220.1 ms
    环面 R=8 r=3,48³3617220.3 ms40.5 ms

    两点说明:

    • wasm-gc 比 js 后端快 1.9–2.9 倍,这是同一份 MoonBit 源码换后端的结果, 不是两份实现——wasm-gc 直接吃 f64 运算,js 后端还要过一层 BigInt/boxing 的 边界检查。
    • native 数字本地没有:这台 Windows 机器没装 MSVC Build Tools,native 后端 整体不可用(tools/acceptance.mjs 会把它判成 SKIP 而不是 FAIL)。CI 的 ubuntu runner 上 moon test --target native 会跑,所以 native 的正确性有 持续覆盖,只是没有本地耗时对照。本表不编 native 的数。

    #验收口径

    每条断言都能把当前代码判为失败,不是「跑通了就算」:

    判据测试
    4 个原语在 1000 个随机点上与解析距离误差 < 1e-12sdf_test.mbt
    布尔与 min / max / 取负逐点一致sdf_test.mbt
    球体积误差 < 0.5%(128 分辨率)、盒 < 1%(40 分辨率)mesh_test.mbt
    环面亏格 = 1、欧拉示性数 = 0mesh_test.mbt
    盒挖两个贯通孔后亏格 = 2、欧拉示性数 = -2、体积误差 < 0.5%mesh_test.mbt
    盒减球体积与球带公式误差 < 1%mesh_test.mbt
    完全被挖空时输出空网格mesh_test.mbt
    相切球、零厚度板、空交集、贴面格点、退化包围盒、超低分辨率都不崩mesh_test.mbt
    等值面正好落在格点上时仍水密、体积误差 < 1%mesh_test.mbt
    每条无向边恰好 2 个面(水密)mesh_test.mbt
    同输入两次网格化逐位相同mesh_test.mbt
    网格指纹等于常量 7885752854646016839(js / wasm-gc 本地跑,native 在 CI 跑,三后端同值)mesh_test.mbt
    分辨率或朝向变化时指纹必然改变(指纹确实覆盖几何与拓扑)mesh_test.mbt
    OBJ/ASCII STL 行数与索引范围、字节级确定性export_test.mbt
    二进制 STL 往返后三角数/顶点数/欧拉示性数不变、体积相对误差 < 1e-6export_test.mbt
    截断的二进制 STL 返回 None 而不是越界export_test.mbt
    wasm 验收壳 4/4 通过(Node 与浏览器同一份 wasm)tools/wasm_check.mjs
    全部验收项一条命令跑完(check/fmt/三后端测试/package/demo/web/oracle)tools/acceptance.mjs

    tools/oracle.py 用 Python 标准库手算同样的解析值,CI 里单独跑一遍, 避免「期望值是从被测实现抄来的」。

    #设计要点

    f == 0 一律算外部。 边界点归外部,配上一个相对体素尺度的插值偏置 (t ∈ [1e-6, 1-1e-6]),避免零面积三角形;否则同一格点会在相邻体素里 被两边都算一次,网格出现重面。

    焊接不靠 eps 量化。 交点的键是「两个格点索引的 min/max」:同一条 几何边无论从哪个四面体、哪个方向被访问都得到同一个键,位置也统一按 min -> max 方向插值。于是焊接是精确的,而 O(1) 查表不依赖哈希遍历顺序。

    绕向用顶点梯度,不用格点当参考。 格点可能恰好贴着等值面,参考方向会 被浮点噪声决定;顶点就在面上,梯度就是外法线。逐面判定后仍保留 「体积为负则整体翻转」的兜底。

    四面体对角固定。 立方体只沿 (0,0,0)-(1,1,1) 拆 6 个四面体。 换一种拆分,同一 SDF 同一分辨率会得到不同三角数,体积就不可比。

    网格化是确定性的。 固定遍历顺序、固定四面体表、固定排序(自写归并), 不使用哈希迭代顺序决定输出顺序。这份确定性是可验证的:Mesh::hash() 把 顶点位模式与索引序列压成 FNV-1a 64 位指纹,测试用常量断言,native / js / wasm-gc 三个后端必须算出同一个数。

    #已知限制

    • 体积与面积是结果度量,不是约束求解;没有做保体积简化(decimation)。
    • 半空间没有有限包围盒,单独网格化需要调用方给范围。
    • 只有均匀缩放;非均匀缩放会让 SDF 不再是距离场(需要除以梯度模)。
    • 二进制 STL 的坐标是 f32,这是格式限制;要更高精度请用 OBJ。
    • 回读只做了二进制 STL(OBJ 回读需要文本数字解析,暂缓)。
    • 没有 BVH / 距离查询加速,eval 直接遍历树;雏版靠分辨率控制成本。

    #许可

    Apache-2.0。

    更细的取舍说明(每个文件「为什么长这样」、验收策略、AI 参与与验证流程)见 docs/ARCHITECTURE.md,版本变更见 CHANGELOG.md。

    Aabb

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

    轴对齐包围盒。min 逐分量小于等于 max 才是有效盒。

    Aabb::center

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

    Aabb::contains

    fn Aabb::contains(self : Aabb, p : Vec3) -> Bool

    Aabb::diagonal

    fn Aabb::diagonal(self : Aabb) -> Double

    Aabb::expand

    fn Aabb::expand(self : Aabb, r : Double) -> Aabb

    各方向等比外扩 r,网格化时用来给采样留一圈边界。

    Aabb::from_center_half

    fn Aabb::from_center_half(center : Vec3, half : Vec3) -> Aabb

    Aabb::intersect

    fn Aabb::intersect(self : Aabb, o : Aabb) -> Aabb

    Aabb::is_empty

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

    空盒用「min > max」表示,不额外加标志位。

    Aabb::new

    fn Aabb::new(min : Vec3, max : Vec3) -> Aabb

    Aabb::size

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

    Aabb::union

    fn Aabb::union(self : Aabb, o : Aabb) -> Aabb

    Mat3

    pub(all) struct Mat3 {
    c0 : Vec3
    c1 : Vec3
    c2 : Vec3
    }

    3×3 矩阵,按列存:c0/c1/c2 是三个基向量。

    按列存是为了 mul_vec 写成一次线性组合——这也是后面做旋转 与梯度变换时唯一需要的方向。

    Mat3::from_columns

    fn Mat3::from_columns(c0 : Vec3, c1 : Vec3, c2 : Vec3) -> Mat3

    Mat3::identity

    fn Mat3::identity() -> Mat3

    Mat3::mul_vec

    fn Mat3::mul_vec(self : Mat3, v : Vec3) -> Vec3

    Mat3::transpose

    fn Mat3::transpose(self : Mat3) -> Mat3

    转置:旋转的逆。SDF 变换里要用它把世界坐标拉回局部坐标。

    Mesh

    pub(all) struct Mesh {
    positions : Array[Vec3]
    indices : Array[Int]
    }

    三角网格:位置数组 + 索引数组(每 3 个索引一个三角形)。

    顶点是焊接过的——同一个几何位置只出现一次,否则「每条边恰好 2 面」 这个水密判据会被重复顶点破坏。索引用 Int,Int 在 wasm-gc 是 32 位, 顶点数上限 2^31 对建模场景足够。

    Mesh::area

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

    Mesh::bounds

    fn Mesh::bounds(self : Mesh) -> Aabb

    Mesh::edge_count

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

    唯一边数。

    Mesh::edge_keys

    fn Mesh::edge_keys(self : Mesh) -> Array[Int64]

    每条边编码成 min * V + max 后的升序数组。V 是顶点数, 所以编码无歧义;V 不会超过 2^31,乘积在 Int64 内。

    Mesh::empty

    fn Mesh::empty() -> Mesh

    Mesh::euler

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

    欧拉示性数 V - E + F。闭合曲面上等于 2 - 2g。

    Mesh::flipped

    fn Mesh::flipped(self : Mesh) -> Mesh

    翻转全部三角形。朝向统一翻,不逐面判断——逐面判断在凹面上会被 数值梯度的噪声带偏。

    Mesh::from_stl_binary

    fn Mesh::from_stl_binary(data : Bytes) -> Mesh?

    二进制 STL 回读。

    STL 没有索引,同一个顶点会在每个相邻三角形里重复出现。这里按 f32 位模式做精确去重(不是 eps 距离):同一位置写出去时位模式 相同,读回来也相同,所以能还原原始顶点数。这个等价关系只在 「导出后未经过任何编辑」时成立,正好是往返测试要的性质。

    Mesh::genus

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

    亏格。欧拉示性数为奇数说明网格不闭合或不连通处有共享顶点, 返回 -1 而不是编一个数。

    Mesh::hash

    fn Mesh::hash(self : Mesh) -> UInt64

    网格内容的 64 位指纹:顶点坐标的位模式(按存储顺序)+ 索引序列。

    「同输入两次网格化逐位相同」只能证明同后端内确定;把指纹写成测试 常量之后,wasm-gc / js / native 任意后端算出的值都必须是同一个, 这才叫跨后端确定性。顶点与索引的顺序本身是确定性的产物,所以指纹 也顺带覆盖了遍历顺序。

    Mesh::is_empty

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

    Mesh::is_watertight

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

    水密:每条无向边恰好被 2 个三角形共享。

    Mesh::new

    fn Mesh::new(positions : Array[Vec3], indices : Array[Int]) -> Mesh

    Mesh::to_obj

    fn Mesh::to_obj(self : Mesh, name : String) -> String

    文本格式导出。先做 OBJ 与 ASCII STL:两者都不需要二进制打包, 结果可以直接用 diff 比对,也就顺势成了回归测试的一部分。 二进制 STL 见文件末尾:它需要位打包,但也带来一个好处—— 可以写回读,用「导出再导入」检验网格是不是真的没丢东西。

    Mesh::to_stl

    fn Mesh::to_stl(self : Mesh, name : String) -> String

    ASCII STL。STL 不带顶点索引,每个三角形重复三个顶点, 所以文件通常比 OBJ 大——换来的是所有切片软件/查看器都认。

    Mesh::to_stl_binary

    fn Mesh::to_stl_binary(self : Mesh) -> Bytes

    二进制 STL:80 字节头 + uint32 三角形数 + 每三角形 50 字节 (法线 12 字节 + 三顶点 36 字节 + 属性 2 字节)。全部小端。

    头 80 字节留空。很多工具往里塞模型名,但格式本身不规定, 空着头反而省掉一次「名字超过 80 字节怎么办」的讨论。 顶点坐标按 f32 存储,这是格式本身的限制。往返测试因此用 「相对误差 < 1e-6」而不是逐位相等:f32 只有 24 位有效数字。

    Mesh::triangle

    fn Mesh::triangle(self : Mesh, i : Int) -> (Vec3, Vec3, Vec3)

    第 i 个三角形的三个顶点。

    Mesh::triangle_count

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

    Mesh::vertex_count

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

    Mesh::volume

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

    散度定理求体积:V = Σ a · (b × c) / 6。

    朝向正确时结果为正,所以这个数同时充当朝向自检——不需要再单独 遍历面判法线。

    MeshOptions

    pub(all) struct MeshOptions {
    resolution : Int
    project : Bool
    iterations : Int
    }

    网格化选项。

    MeshOptions::new

    fn MeshOptions::new(resolution : Int) -> MeshOptions

    Sdf

    pub(all) enum Sdf {
    Sphere(Vec3, Double)
    Box(Vec3, Vec3)
    Plane(Vec3, Double)
    Cylinder(Vec3, Double, Double)
    Torus(Vec3, Double, Double)
    Translate(Sdf, Vec3)
    Rotate(Sdf, Mat3)
    Scale(Sdf, Double)
    Union(Sdf, Sdf)
    Intersect(Sdf, Sdf)
    Subtract(Sdf, Sdf)
    SmoothUnion(Sdf, Sdf, Double)
    }

    有符号距离场(SDF)。

    约定:实体内部为负、外部为正、边界为 0。所有原语与布尔都保持这个约定, 因为它们只由 min / max / - 组合,而这三个运算保号。

    树是公开的(pub(all)):调用方可以直接构造,也可以把自己的节点 写进 eval 之外的分析(例如 bounds)。递归节点不做共享引用, 同一棵子树被复用两次就是两份拷贝——几何树很小,不做引用计数。

    Sdf::at

    fn Sdf::at(self : Sdf, center : Vec3) -> Sdf

    Sdf::bounds

    fn Sdf::bounds(self : Sdf) -> Aabb?

    保守包围盒:只做「不会漏掉实体」的放大,不求紧。 半空间没有有限包围盒,参与 Union 时整体返回 None, 参与 Intersect 时由另一侧决定,做刀具时被忽略。

    Sdf::box

    fn Sdf::box(half : Vec3) -> Sdf

    Sdf::cylinder

    fn Sdf::cylinder(radius : Double, half_height : Double) -> Sdf

    Sdf::eval

    fn Sdf::eval(self : Sdf, p : Vec3) -> Double

    求有符号距离。这是整个内核唯一的「真相来源」:网格化、体积、法线 全部由它派生,所以每个原语都直接照抄解析公式,不做近似。

    Sdf::gradient

    fn Sdf::gradient(self : Sdf, p : Vec3) -> Vec3

    数值梯度(中心差分)指向距离增大方向,也就是实体外部。

    步长随坐标尺度放大,避免在远处把差分吃掉;这里只用 + - * / sqrt, 不引入三角函数,保证同一输入的梯度在任何后端一致。

    Sdf::intersect

    fn Sdf::intersect(self : Sdf, o : Sdf) -> Sdf

    Sdf::is_inside

    fn Sdf::is_inside(self : Sdf, p : Vec3) -> Bool

    内部/外部判定。f >= 0 一律算外部:f == 0 的抖动会让网格化漏面或重面, 所以边界点统一归到外部,由采样偏置处理(见 ARCHITECTURE)。

    Sdf::plane

    fn Sdf::plane(normal : Vec3, offset : Double) -> Sdf

    法线在这里归一化:Plane 的距离公式要求 |normal| = 1, 否则返回值会被整体缩放。

    Sdf::scale

    fn Sdf::scale(self : Sdf, s : Double) -> Sdf

    Sdf::smooth_union

    fn Sdf::smooth_union(self : Sdf, o : Sdf, k : Double) -> Sdf

    Sdf::sphere

    fn Sdf::sphere(radius : Double) -> Sdf

    Sdf::subtract

    fn Sdf::subtract(self : Sdf, o : Sdf) -> Sdf

    Sdf::union

    fn Sdf::union(self : Sdf, o : Sdf) -> Sdf

    Vec3

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

    三维向量。几何内核只做静态求值,所有分量用 Double: 这里的正确性标准是「与解析公式逐点一致」,不是跨后端逐位一致 (后者是 moonroll 的定点数要解决的问题)。

    Vec3::abs

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

    Vec3::add

    fn Vec3::add(self : Vec3, o : Vec3) -> Vec3

    Vec3::cross

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

    Vec3::div

    fn Vec3::div(self : Vec3, s : Double) -> Vec3

    Vec3::dot

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

    Vec3::length

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

    Vec3::length_sq

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

    Vec3::lerp

    fn Vec3::lerp(self : Vec3, o : Vec3, t : Double) -> Vec3

    Vec3::max

    fn Vec3::max(self : Vec3, o : Vec3) -> Vec3

    Vec3::max_zero

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

    逐分量 max(v, 0),SDF 盒公式里反复用到。

    Vec3::min

    fn Vec3::min(self : Vec3, o : Vec3) -> Vec3

    Vec3::mul

    fn Vec3::mul(self : Vec3, s : Double) -> Vec3

    Vec3::mul_vec

    fn Vec3::mul_vec(self : Vec3, o : Vec3) -> Vec3

    逐分量乘。

    Vec3::neg

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

    Vec3::new

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

    Vec3::normalize

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

    零向量归一化返回零向量:调用方不该靠 panic 处理退化输入。

    Vec3::splat

    fn Vec3::splat(v : Double) -> Vec3

    Vec3::sub

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

    Vec3::zero

    fn Vec3::zero() -> Vec3

    mesh

    fn mesh(sdf : Sdf, bounds : Aabb, opts : MeshOptions) -> Mesh

    从 SDF 生成水密三角网格。

    采样在包围盒内做规则体素网格,每个体素拆 6 个四面体,按四面体顶点的 符号取出 0/1/2 个三角形,交点在格点边上插值、按边键焊接。 输出朝向由散度体积统一校正:体积为负就整体翻转。