// 流式扫描:不构造网格,单遍算出所有统计量。
//
// 为什么值得单独写一遍:**一个三角形在内存里的开销远大于它在文件里的开销**。
//
// 文件里(二进制 STL) 3 个坐标 + 1 个法线 = 12 个 float32 = 50 字节
// 内存里(Triangle) 4 个 Vec3 = 12 个 Double = 96 字节
//
// 也就是说解析一个 480 MB 的模型,光是"把三角形存下来"就要再吃掉 960 MB。
// 渲染库必须存下来——它们的顶点最终要上传到 GPU,所以这是结构性的,
// 不是"它们没做优化"。
//
// 但做**统计**不需要存:体积、表面积、包围盒都能一边读一边累加。
// 只有需要拓扑的两项(水密性、绕向)才麻烦,下面单独说怎么办。
///|
/// 流式扫描的结果。
///
/// 字段和 `ValidationReport` 有意保持一致,方便并排显示。
pub(all) struct StreamReport {
/// 三角形总数
triangle_count : Int
/// 文件里写了多少个顶点(二进制 STL 就是三角形数 × 3)
raw_vertex_count : Int
/// 去重之后剩下多少个不同的顶点
unique_vertex_count : Int
/// 只被一个面用到的边。大于零说明有破洞
boundary_edge_count : Int
/// 被三个及以上面共用的边
non_manifold_edge_count : Int
/// 体积
volume : Double
/// 表面积
surface_area : Double
/// 轴对齐包围盒
bbox : Aabb?
}
///|
/// 网格是不是水密的。判据和全量校验一致。
pub fn StreamReport::is_watertight(self : StreamReport) -> Bool {
self.boundary_edge_count == 0 && self.non_manifold_edge_count == 0
}
///|
/// 流式扫描二进制 STL。
///
/// 二进制格式天生适合流式:每个三角形固定 50 字节,顺序读就行,
/// 不需要回头。ASCII 格式要逐行扫,见 `stream_stl_ascii`。
pub fn stream_stl_binary(data : Bytes) -> Result[StreamReport, String] {
let count = match stl_binary_count(data) {
Ok(count) => count
Err(message) => return Err(message)
}
// 累加器。这些都不需要保留中间结果,扫到哪算到哪
let mut volume = 0.0
let mut area = 0.0
let mut min : Vec3? = None
let mut max : Vec3? = None
// 需要保留的两样东西:
// 1. 顶点去重表 —— 只存「量化坐标 → 编号」,不存三角形本身
// 2. 边表 —— 只存每条边的两个端点编号,最后排序统计
let weld : Map[PositionKey, Int] = Map([])
let edges : Array[UInt64] = []
let mut unique_vertices = 0
for i = 0; i < count; i = i + 1 {
let base = 84 + i * 50
if stl_binary_record_error(data, base, i) is Some(message) {
return Err(message)
}
// 法线虽不参与统计,仍与全量路径校验同一输入契约。
let a = read_vec3_f32(data, base + 12)
let b = read_vec3_f32(data, base + 24)
let c = read_vec3_f32(data, base + 36)
// 体积:三角形与原点构成的四面体的有符号体积,累加
volume = volume + a.dot(b.cross(c)) / 6.0
// 表面积:两条边叉乘模长的一半
area = area + b.sub(a).cross(c.sub(a)).length() / 2.0
// 包围盒:逐个比大小
for v in [a, b, c] {
let (lo, hi) = expand_bbox(min, max, v)
min = lo
max = hi
}
// 顶点焊接。三个顶点各自查表拿编号
let ia = weld_vertex(weld, a)
let ib = weld_vertex(weld, b)
let ic = weld_vertex(weld, c)
// 查表时新分配了编号就说明是个新顶点
let after = weld.length()
if after > unique_vertices {
unique_vertices = after
}
// 三条边压成 UInt64 存进数组,最后统一排序统计。
//
// 用「排序 + 扫描」而不是哈希表:哈希表每项的开销(键 + 值 + 桶)
// 比一个 8 字节的整数大好几倍,而这个数组是紧凑的。
// 1500 万条边只需要 120 MB,哈希表要 400 MB 以上。
if ia != ib {
edges.push(pack_edge(ia, ib))
}
if ib != ic {
edges.push(pack_edge(ib, ic))
}
if ic != ia {
edges.push(pack_edge(ic, ia))
}
}
let (boundary, non_manifold) = count_edges(edges)
Ok(StreamReport::{
triangle_count: count,
raw_vertex_count: count * 3,
unique_vertex_count: unique_vertices,
boundary_edge_count: boundary,
non_manifold_edge_count: non_manifold,
volume: volume.abs(),
surface_area: area,
bbox: match (min, max) {
(Some(lo), Some(hi)) => Some(Aabb::{ min: lo, max: hi, })
_ => None
},
})
}
///|
/// 把一个顶点查表拿编号,没有就分配一个新的。
///
/// 量化方式和全量校验那边一致(`validate.mbt` 的 `quantize`),
/// 这样两条路径对「两个点算不算同一个」的判断是一样的。
fn weld_vertex(table : Map[PositionKey, Int], v : Vec3) -> Int {
let key = position_key(v, default_weld_tolerance())
match table.get(key) {
Some(id) => id
None => {
let id = table.length()
table.set(key, id)
id
}
}
}
///|
/// 逐分量取小。
fn vec_min(a : Vec3, b : Vec3) -> Vec3 {
Vec3::{ x: a.x.min(b.x), y: a.y.min(b.y), z: a.z.min(b.z), }
}
///|
/// 逐分量取大。
fn vec_max(a : Vec3, b : Vec3) -> Vec3 {
Vec3::{ x: a.x.max(b.x), y: a.y.max(b.y), z: a.z.max(b.z), }
}
///|
/// 把一个点并进包围盒。
///
/// 抽成函数是因为「在循环里写嵌套的结构体字面量」MoonBit 会报歧义
/// (`ambiguous_block`),写成命名函数最省事。
fn expand_bbox(min : Vec3?, max : Vec3?, v : Vec3) -> (Vec3?, Vec3?) {
match (min, max) {
(Some(lo), Some(hi)) => (Some(vec_min(lo, v)), Some(vec_max(hi, v)))
_ => (Some(v), Some(v))
}
}
///|
/// 把一条边的两个端点编号打包成一个 64 位的整数。
///
/// 无向边:小的编号放高位,这样 (a,b) 和 (b,a) 得到同一个键。
fn pack_edge(a : Int, b : Int) -> UInt64 {
let lo = if a < b { a } else { b }
let hi = if a < b { b } else { a }
(lo.to_uint64() << 32) | hi.to_uint64()
}
///|
/// 把边数组排序后扫描一遍,数出边界边和非流形边。
///
/// 排序之后同一条边必然挨在一起,扫一遍数连续相同的个数就行,
/// 不需要哈希表。
fn count_edges(edges : Array[UInt64]) -> (Int, Int) {
if edges.length() == 0 {
return (0, 0)
}
edges.sort()
let mut boundary = 0
let mut non_manifold = 0
let mut run = 1
for i = 1; i < edges.length(); i = i + 1 {
if edges[i] == edges[i - 1] {
run = run + 1
} else {
if run == 1 {
boundary = boundary + 1
} else if run > 2 {
non_manifold = non_manifold + 1
}
run = 1
}
}
// 最后一段
if run == 1 {
boundary = boundary + 1
} else if run > 2 {
non_manifold = non_manifold + 1
}
(boundary, non_manifold)
}
///|
/// 流式扫描 ASCII STL。
///
/// 逐行扫,同样是累加不保留。ASCII 文件本身比二进制大好几倍,
/// 流式的意义反而更大。
pub fn stream_stl_ascii(text : String) -> Result[StreamReport, String] {
let mut volume = 0.0
let mut area = 0.0
let mut min : Vec3? = None
let mut max : Vec3? = None
let weld : Map[PositionKey, Int] = Map([])
let edges : Array[UInt64] = []
let mut unique_vertices = 0
let triangles = match
walk_stl_ascii(text, t => {
let (va, vb, vc) = (t.a, t.b, t.c)
volume = volume + va.dot(vb.cross(vc)) / 6.0
area = area + vb.sub(va).cross(vc.sub(va)).length() / 2.0
for v in [va, vb, vc] {
let (lo, hi) = expand_bbox(min, max, v)
min = lo
max = hi
}
let ia = weld_vertex(weld, va)
let ib = weld_vertex(weld, vb)
let ic = weld_vertex(weld, vc)
unique_vertices = weld.length()
if ia != ib {
edges.push(pack_edge(ia, ib))
}
if ib != ic {
edges.push(pack_edge(ib, ic))
}
if ic != ia {
edges.push(pack_edge(ic, ia))
}
}) {
Ok(count) => count
Err(message) => return Err(message)
}
let (boundary, non_manifold) = count_edges(edges)
Ok(StreamReport::{
triangle_count: triangles,
raw_vertex_count: triangles * 3,
unique_vertex_count: unique_vertices,
boundary_edge_count: boundary,
non_manifold_edge_count: non_manifold,
volume: volume.abs(),
surface_area: area,
bbox: match (min, max) {
(Some(lo), Some(hi)) => Some(Aabb::{ min: lo, max: hi, })
_ => None
},
})
}
// ── 分块扫描 ────────────────────────────────────────────────────────────
//
// 上面那两个 `stream_stl_*` 仍然是「整个文件都在内存里」。要真正把文件
// 那份内存也省掉,得能**分块读**——这需要 `moonbitlang/async/fs` 的
// `File::read_at`,而那是异步的。
//
// 所以这里把解析逻辑抽成一个「喂数据块」的扫描器:它本身是纯同步的、
// 不依赖任何外部库,调用方(命令行工具)负责用异步 IO 把文件一块块读进来。
// 这样库本体仍然是零第三方依赖。
///|
/// 分块扫描二进制 STL 的状态机。
///
/// 用法:反复 `feed` 数据块,最后 `finish` 出报告。
/// 内部只保留「没凑够一个完整记录的零头」和统计需要的那两张表,
/// 不保留文件和三角形本身;顶点表、边表仍随网格规模增长。
pub(all) struct StlScanner {
/// 上一次喂进来、还没凑够一个完整记录的零头
priv tail : Array[Byte]
/// 文件头读完了没有
priv mut started : Bool
/// 头部声称的三角形数
priv mut declared : Int
/// 已经处理了多少个三角形
priv mut done : Int
priv mut volume : Double
priv mut area : Double
priv mut min : Vec3?
priv mut max : Vec3?
/// 顶点去重表
priv weld : Map[PositionKey, Int]
/// 边表,最后统一排序统计
priv edges : Array[UInt64]
/// 读到的总字节数,用来核对文件长度
priv mut consumed : UInt64
priv mut failure : String?
priv mut peak_buffered_bytes : Int
}
///|
/// 二进制 STL 的文件头长度:80 字节说明 + 4 字节三角形数。
const STL_HEADER_SIZE : Int = 84
///|
/// 二进制 STL 每个三角形固定 50 字节。
const STL_RECORD_SIZE : Int = 50
///|
pub fn StlScanner::new() -> StlScanner {
StlScanner::{
tail: [],
started: false,
declared: 0,
done: 0,
volume: 0.0,
area: 0.0,
min: None,
max: None,
weld: Map([]),
edges: [],
consumed: 0UL,
failure: None,
peak_buffered_bytes: 0,
}
}
///|
/// 喂一块数据进去。
///
/// 块的大小随意,不要求对齐到 50 字节——零头会在内部攒着,
/// 和下一块拼起来。调用方按 64 KB 之类的尺寸读就行。
pub fn StlScanner::feed(self : StlScanner, chunk : Bytes) -> Unit {
if self.failure is Some(_) {
return
}
self.consumed = self.consumed + chunk.length().to_uint64()
for i = 0; i < chunk.length(); i = i + 1 {
self.tail.push(chunk[i])
}
self.peak_buffered_bytes = self.peak_buffered_bytes.max(self.tail.length())
self.drain()
}
///|
/// Logical retained-record counts, not allocator capacity or process RSS.
pub(all) struct ScanStorage {
unique_vertices : Int
edge_records : Int
pending_bytes : Int
peak_buffered_bytes : Int
}
///|
pub fn StlScanner::storage(self : StlScanner) -> ScanStorage {
{
unique_vertices: self.weld.length(),
edge_records: self.edges.length(),
pending_bytes: self.tail.length(),
peak_buffered_bytes: self.peak_buffered_bytes,
}
}
///|
/// 把 tail 里已经凑够整记录的部分处理掉。
fn StlScanner::drain(self : StlScanner) -> Unit {
let mut pos = 0
// 先处理文件头
if !self.started {
if self.tail.length() < STL_HEADER_SIZE {
return
}
// 三角形数量在偏移 80 处,小端
let head = Bytes::from_array(self.tail[:STL_HEADER_SIZE])
let count = read_u32_le(head, 80)
if count > 2147483647U {
self.failure = Some(
"二进制 STL 三角形数量超出当前实现支持范围",
)
return
}
self.declared = count.reinterpret_as_int()
self.started = true
pos = STL_HEADER_SIZE
}
// 不把尾部垃圾当成三角形;完整字节数最终仍须严格核对。
let expected = 84UL + self.declared.to_uint64() * 50UL
if self.consumed > expected {
self.failure = stl_binary_length_error(
self.declared.reinterpret_as_uint(),
self.consumed,
)
self.tail.clear()
return
}
// 再逐个处理声明范围内的完整记录。
while self.done < self.declared && self.tail.length() - pos >= STL_RECORD_SIZE {
let rec = Bytes::from_array(self.tail[pos:pos + STL_RECORD_SIZE])
self.absorb(rec)
self.done = self.done + 1
pos = pos + STL_RECORD_SIZE
}
if pos > 0 {
// 把已处理的部分丢掉,只留零头。
// 这里不能直接 `self.tail = self.tail[pos:]`——那样每块都要重建数组,
// 攒够一次再截。
let rest : Array[Byte] = []
for i = pos; i < self.tail.length(); i = i + 1 {
rest.push(self.tail[i])
}
self.tail.clear()
for b in rest {
self.tail.push(b)
}
}
}
///|
/// 处理一个 50 字节的三角形记录。
fn StlScanner::absorb(self : StlScanner, rec : Bytes) -> Unit {
if self.failure is Some(_) {
return
}
if stl_binary_record_error(rec, 0, self.done) is Some(message) {
self.failure = Some(message)
return
}
// 法线不参与统计,但必须通过有限数校验。
let a = read_vec3_f32(rec, 12)
let b = read_vec3_f32(rec, 24)
let c = read_vec3_f32(rec, 36)
self.volume = self.volume + a.dot(b.cross(c)) / 6.0
self.area = self.area + b.sub(a).cross(c.sub(a)).length() / 2.0
for v in [a, b, c] {
let (lo, hi) = expand_bbox(self.min, self.max, v)
self.min = lo
self.max = hi
}
let ia = weld_vertex(self.weld, a)
let ib = weld_vertex(self.weld, b)
let ic = weld_vertex(self.weld, c)
if ia != ib {
self.edges.push(pack_edge(ia, ib))
}
if ib != ic {
self.edges.push(pack_edge(ib, ic))
}
if ic != ia {
self.edges.push(pack_edge(ic, ia))
}
}
///|
/// 结束扫描,出报告。
pub fn StlScanner::finish(self : StlScanner) -> Result[StreamReport, String] {
if self.failure is Some(message) {
return Err(message)
}
let count = if self.started {
self.declared.reinterpret_as_uint()
} else {
0U
}
if stl_binary_length_error(count, self.consumed) is Some(message) {
return Err(message)
}
let (boundary, non_manifold) = count_edges(self.edges)
Ok(StreamReport::{
triangle_count: self.done,
raw_vertex_count: self.done * 3,
unique_vertex_count: self.weld.length(),
boundary_edge_count: boundary,
non_manifold_edge_count: non_manifold,
volume: self.volume.abs(),
surface_area: self.area,
bbox: match (self.min, self.max) {
(Some(lo), Some(hi)) => Some(Aabb::{ min: lo, max: hi, })
_ => None
},
})
}