// 网格校验:发现边界边、非流形边、退化面和已保留声明面法线的反向。
// 这些检查只覆盖部分几何问题,不能保证打印成功。
//
// 各项检查的原理都不复杂,难的是「两个顶点算不算同一个点」这个前置问题,
// 见下面 weld_vertices 的注释。
///|
/// 网格校验报告。
pub(all) struct ValidationReport {
/// 三角形总数
triangle_count : Int
/// 文件里写了多少个顶点(等于三角形数 × 3,STL 每个面都重写一遍自己的顶点)
raw_vertex_count : Int
/// 焊接之后剩下多少个不同的顶点
unique_vertex_count : Int
/// 只被一个面用到的边。数量大于零说明网格有破洞
boundary_edge_count : Int
/// 被三个及以上面共用的边。这种边在真实物体上不该出现
non_manifold_edge_count : Int
/// 退化三角形(三个点共线,面积为零)的数量
degenerate_count : Int
/// 已保留声明面法线与几何法线点积 < 0 的数量;近零向量跳过。
/// 不检查单位长度、垂直性或精确一致性;0 不代表完整法线验证。
wrong_normal_count : Int
/// 不同边的条数(去重后)。和顶点数、三角形数一起能算出欧拉示性数
unique_edge_count : Int
}
///|
/// 欧拉示性数 χ = V − E + F。
///
/// 对应 trimesh 的 `mesh.euler_number`。和 `count_parts` 一样,
/// 是看了 trimesh 的设计之后补的,详细对比见 `COMPARISON.md`。
///
/// 对闭合、可定向的流形表面,这个数具有以下含义:
///
/// - 单个实体、没有洞(topologically 一个球面):**χ = 2**
/// - 有一个穿透的洞(比如甜甜圈):**χ = 0**
/// - 有两个穿透的洞:**χ = −2**
///
/// 有 k 个独立表面时,χ = 2k − 2Σg,g 为各表面的亏格。
/// 边闭合不证明网格无自相交或非流形顶点;解释前需要核实这些前提。
pub fn ValidationReport::euler_number(self : ValidationReport) -> Int {
self.unique_vertex_count - self.unique_edge_count + self.triangle_count
}
///|
/// 网格是不是水密的。
///
/// 判据:每条边都恰好被两个面共用;不检查非流形顶点、自相交或朝向。
/// 空网格也满足这项边计数判据,调用方应另行检查三角形数。
pub fn ValidationReport::is_watertight(self : ValidationReport) -> Bool {
self.boundary_edge_count == 0 && self.non_manifold_edge_count == 0
}
///|
/// 默认的顶点焊接容差。
///
/// 默认 1e-6 毫米是坐标格子宽度,不保证覆盖所有 float32 舍入误差。
/// 物理尺度与文件精度不同的模型,应由调用方选择合适容差。
pub fn default_weld_tolerance() -> Double {
1.0e-6
}
///|
/// 用 Double 保存取整后的格子坐标,避免固定宽度整数截断。
/// 量化按格子相等判定,并非距离搜索;格子边界两侧的近点仍可能分开。
priv enum CoordinateKey {
Grid(Double)
Exact(Double)
} derive(Eq, Hash)
///|
fn quantize(value : Double, tolerance : Double) -> CoordinateKey {
let scaled = value / tolerance
// 不截成 Int,也不截低位。极端尺度除法溢出时,用独立的精确键。
if scaled.is_inf() || scaled.is_nan() {
return Exact(if value == 0.0 { 0.0 } else { value })
}
let q = scaled.round()
Grid(if q == 0.0 { 0.0 } else { q })
}
///|
/// 三个完整的量化分量作为键,哈希冲突仍由逐分量相等判定区分。
priv struct PositionKey {
x : CoordinateKey
y : CoordinateKey
z : CoordinateKey
} derive(Eq, Hash)
///|
fn position_key(v : Vec3, tolerance : Double) -> PositionKey {
{
x: quantize(v.x, tolerance),
y: quantize(v.y, tolerance),
z: quantize(v.z, tolerance),
}
}
///|
/// 顶点焊接的结果。
pub(all) struct WeldResult {
/// 每个顶点焊接后的编号。第 i 个顶点的编号是 ids[i]
ids : Array[Int]
/// 焊接后剩下多少个不同的顶点
unique_count : Int
}
///|
/// 把重复的顶点合并,给每个顶点一个编号。
///
/// STL 格式本身没有「顶点表」——每个三角形把自己的三个顶点各写一遍,
/// 一个立方体 8 个顶点会写 36 遍。要做拓扑分析(哪条边被几个面共用),
/// 必须先把这些重复的点认出来是同一个。
///
/// 用 Map 存「量化后的坐标 → 编号」,第一次见到的坐标分配一个新编号,
/// 之后见到的复用。坐标量化的细节见 quantize。
fn weld_vertices(mesh : Mesh, tolerance : Double) -> WeldResult {
let lookup : Map[PositionKey, Int] = Map([])
let ids : Array[Int] = []
let mut next_id = 0
for triangle in mesh.triangles {
for v in triangle.vertices() {
// 完整的三分量键;哈希只负责查找,相等判定不会丢弃坐标高位。
let key = position_key(v, tolerance)
let id = match lookup.get(key) {
Some(id) => id
None => {
let id = next_id
lookup.set(key, id)
next_id = next_id + 1
id
}
}
ids.push(id)
}
}
{ ids, unique_count: next_id, }
}
///|
/// 把一条边规范化成可比较的形式。
///
/// 一条边有两个端点,但从哪个面看过来顺序可能相反(第一个面是 a→b,
/// 相邻面是 b→a)。要统计「这条边被几个面用了」,得先让两个方向
/// 表示成同一个东西:小的编号在前。
fn edge_key(a : Int, b : Int) -> (Int, Int) {
if a < b {
(a, b)
} else {
(b, a)
}
}
///|
/// 检查边计数、退化面和已保留声明面法线反向等有限项目。
pub fn validate(mesh : Mesh, tolerance : Double) -> ValidationReport {
let weld = weld_vertices(mesh, tolerance)
// 统计每条边被几个三角形共用。
// 键是「小编号,大编号」拼成的字符串,值是出现次数。
let edge_counts : Map[String, Int] = Map([])
let mut degenerate = 0
let mut wrong_normal = 0
for i, triangle in mesh.triangles {
let ia = weld.ids[i * 3]
let ib = weld.ids[i * 3 + 1]
let ic = weld.ids[i * 3 + 2]
// 退化面:三个顶点焊接后有两个或以上是同一个点,说明这个面没有面积。
// 用焊接后的编号判断而不是看面积是否为零——焊接后的判断和拓扑一致,
// 不会因为浮点误差漏掉。
if ia == ib || ib == ic || ia == ic || triangle_is_degenerate(triangle) {
degenerate = degenerate + 1
}
// 法线朝向:拿文件里写的法线和按顶点顺序算出来的比。
// 点积为负仅说明声明向量与几何法线夹角超过 90 度,不能据此断言顶点绕向错误。
// 不校验单位长度或垂直性;正交及同半球的其他向量不会计入。
// 文件里法线填 0 0 0 的不算——那是「没填」,不是「填错了」。
let declared = triangle.normal
if !declared.is_zero(1.0e-9) {
let computed = triangle.computed_normal()
if declared.dot(computed) < 0.0 {
wrong_normal = wrong_normal + 1
}
}
// 三个顶点里如果有重复的,说明这条边是退化的,不该计进拓扑统计
if ia != ib {
let k = edge_key(ia, ib)
let key = "\{k.0},\{k.1}"
edge_counts.set(
key,
match edge_counts.get(key) {
Some(n) => n + 1
None => 1
},
)
}
if ib != ic {
let k = edge_key(ib, ic)
let key = "\{k.0},\{k.1}"
edge_counts.set(
key,
match edge_counts.get(key) {
Some(n) => n + 1
None => 1
},
)
}
if ic != ia {
let k = edge_key(ic, ia)
let key = "\{k.0},\{k.1}"
edge_counts.set(
key,
match edge_counts.get(key) {
Some(n) => n + 1
None => 1
},
)
}
}
let mut boundary = 0
let mut non_manifold = 0
for _, count in edge_counts {
if count == 1 {
boundary = boundary + 1
} else if count > 2 {
non_manifold = non_manifold + 1
}
}
{
triangle_count: mesh.triangle_count(),
raw_vertex_count: mesh.triangles.length() * 3,
unique_vertex_count: weld.unique_count,
boundary_edge_count: boundary,
non_manifold_edge_count: non_manifold,
degenerate_count: degenerate,
wrong_normal_count: wrong_normal,
unique_edge_count: edge_counts.length(),
}
}
///|
/// 用默认容差校验。
pub fn validate_default(mesh : Mesh) -> ValidationReport {
validate(mesh, default_weld_tolerance())
}
///|
/// 数一数这个网格由几个互不相连的实体组成。
///
/// 对应 trimesh 的 `mesh.split()`。这个能力是看了 trimesh 的设计之后补的,
/// 详细对比见仓库根目录的 `COMPARISON.md`。
///
/// 判据是**面的邻接**:两个三角形共用一条边就算连在一起,
/// 连通块的个数就是零件数。这和水密性用的是同一套拓扑,
/// 不需要额外的几何判断。
///
/// 为什么 3D 打印要看这个:切片软件按连通块分别生成路径,
/// 一个「看起来是一个模型」的文件里如果有 5 块互不相连的几何,
/// 打出来就是 5 个零散的东西——它们可能根本没连在一起,拿起来就散了。
/// 而且这类问题从预览图上看不出来,模型画出来是完整的。
///
/// 注意**只有共用边才算连接**。两个零件刚好顶点碰着顶点不算——
/// 那种接触在切片时是断的,和分开放没有区别。
/// (这也是为什么不用「顶点相同就算连通」来判,那样会漏报。)
pub fn count_parts(mesh : Mesh, tolerance : Double) -> Int {
let n = mesh.triangle_count()
if n == 0 {
return 0
}
let weld = weld_vertices(mesh, tolerance)
// 无向边 → 用到它的面。同一条边上的面互为邻居
let edge_faces : Map[String, Array[Int]] = Map([])
for i = 0; i < n; i = i + 1 {
let ia = weld.ids[i * 3]
let ib = weld.ids[i * 3 + 1]
let ic = weld.ids[i * 3 + 2]
for e in [(ia, ib), (ib, ic), (ic, ia)] {
// 焊接后两端重合的边是退化的,不算连接
if e.0 == e.1 {
continue
}
let k = edge_key(e.0, e.1)
let key = "\{k.0},\{k.1}"
let list = match edge_faces.get(key) {
Some(l) => l
None => {
let l : Array[Int] = []
edge_faces.set(key, l)
l
}
}
list.push(i)
}
}
let visited : Array[Bool] = []
for _i = 0; _i < n; _i = _i + 1 {
visited.push(false)
}
let mut parts = 0
// 每个连通块做一次广度优先。用下标当队头而不用 pop,
// 免得每弹一个元素就挪一次数组
let queue : Array[Int] = []
for seed = 0; seed < n; seed = seed + 1 {
if visited[seed] {
continue
}
parts = parts + 1
visited[seed] = true
queue.clear()
queue.push(seed)
let mut head = 0
while head < queue.length() {
let cur = queue[head]
head = head + 1
let a = weld.ids[cur * 3]
let b = weld.ids[cur * 3 + 1]
let c = weld.ids[cur * 3 + 2]
for e in [(a, b), (b, c), (c, a)] {
if e.0 == e.1 {
continue
}
let k = edge_key(e.0, e.1)
match edge_faces.get("\{k.0},\{k.1}") {
Some(list) =>
for other in list {
if !visited[other] {
visited[other] = true
queue.push(other)
}
}
None => ()
}
}
}
}
parts
}
///|
/// 用默认容差数零件。
pub fn count_parts_default(mesh : Mesh) -> Int {
count_parts(mesh, default_weld_tolerance())
}