// OBJ 解析。
//
// OBJ 跟 STL 有两个根本区别,决定了这个解析器长得完全不一样:
//
// **一、OBJ 有共享顶点表。** 文件开头先把所有顶点列一遍(`v x y z`),
// 后面的面只写编号(`f 1 2 3`)。一个立方体是 8 行 v + 6 行 f,
// 而 STL 要写 36 遍顶点。所以这里得先建表再查表。
//
// **二、OBJ 的面可以是多边形。** `f 1 2 3 4` 是个四边形,甚至可以是
// 十七边形。而我们的 Mesh 只装三角形,所以要在这里做三角化——
// 凸多边形用扇形法,凹多边形用耳切法;自相交或非平面多边形明确报错。
//
// 另外 OBJ 的编号从 1 开始,而且可以是负数(表示从末尾往前数)。
///|
/// 解析 OBJ 文本。
///
/// 只认几何相关的关键字:`v`(顶点)、`f`(面)、`vn`(法线)会读,
/// `vt` 读取并检查面引用,纹理值不保存在 Mesh;其余(`g`、`o`、`s`、`mtllib`、`usemtl`、注释)跳过——
/// 这些语义不进入 Mesh;跳过不表示已验证材质或工艺。
pub fn parse_obj(text : String) -> Result[Mesh, String] {
// 顶点表。OBJ 的编号是 1 基的,为了少一次减法,这里在下标 0 处
// 塞一个占位,让 vertices[1] 就是文件里的第 1 号顶点。
let vertices : Array[Vec3] = [Vec3::zero()]
// 法线表,同样是 1 基
let normals : Array[Vec3] = [Vec3::zero()]
let textures : Array[Vec3] = [Vec3::zero()]
let triangles : Array[Triangle] = []
let mut line_no = 0
for raw_line in text.split("\n") {
line_no = line_no + 1
let tokens : Array[StringView] = []
for raw_token in obj_words(raw_line) {
let token = strip_cr(raw_token)
if token.length() > 0 {
if token.has_prefix("#") {
break
}
tokens.push(token)
}
}
match tokens {
["v", x, y, z] =>
match parse_vec3(x, y, z, line_no) {
Ok(v) => vertices.push(v)
Err(msg) => return Err(msg)
}
["vn", x, y, z] =>
match parse_vec3(x, y, z, line_no) {
Ok(v) => normals.push(v)
Err(msg) => return Err(msg)
}
["v", x, y, z, w] => {
let weight = @string.parse_double(w) catch {
_ => return Err("第 \{line_no} 行:OBJ v 的 w 不是有限数字")
}
if weight != 1.0 {
return Err(
"第 \{line_no} 行:当前 OBJ 多边形读取子集只接受省略 w 或 w=1",
)
}
match parse_vec3(x, y, z, line_no) {
Ok(v) => vertices.push(v)
Err(msg) => return Err(msg)
}
}
["v", ..] | ["vn", ..] =>
return Err(
"第 \{line_no} 行:OBJ v/vn 字段数量不符合当前几何读取子集",
)
["vt", .. values] => {
let uvw = match values {
[u] => parse_vec3(u, "0", "0", line_no)
[u, v] => parse_vec3(u, v, "0", line_no)
[u, v, w] => parse_vec3(u, v, w, line_no)
_ =>
return Err(
"第 \{line_no} 行:OBJ vt 必须有 1..3 个有限坐标",
)
}
match uvw {
Ok(v) => textures.push(v)
Err(msg) => return Err(msg)
}
}
["f", .. rest] =>
match parse_face(rest, vertices, normals, textures, line_no) {
Ok(new_triangles) =>
for t in new_triangles {
triangles.push(t)
}
Err(msg) => return Err(msg)
}
_ => ()
}
}
if triangles.length() == 0 {
return Err("这个 OBJ 里一个面都没有,可能不是 OBJ 文件")
}
Ok(Mesh::new(triangles))
}
///|
/// 空格、制表符和 CR 都是分隔符;不依赖只替换第一处的 String::replace。
fn obj_words(line : StringView) -> Array[StringView] {
let words : Array[StringView] = []
let mut i = 0
while i < line.length() {
while i < line.length() && is_ascii_space(line.code_unit_at(i).to_int()) {
i = i + 1
}
let start = i
while i < line.length() && !is_ascii_space(line.code_unit_at(i).to_int()) {
i = i + 1
}
if i > start {
words.push(line[start:i])
}
}
words
}
///|
/// 解析一个面,返回它三角化之后的三角形。
///
/// 每个顶点记号可能是 `3`、`3/5`、`3//7`、`3/5/7` 四种形式之一,
/// 分别表示「只有顶点」「顶点/纹理」「顶点//法线」「顶点/纹理/法线」。
/// 纹理坐标仅用于合法性和索引范围检查,不进入 Mesh。
fn parse_face(
refs : ArrayView[StringView],
vertices : Array[Vec3],
normals : Array[Vec3],
textures : Array[Vec3],
line_no : Int,
) -> Result[Array[Triangle], String] {
if refs.length() < 3 {
return Err(
"第 \{line_no} 行:一个面至少要有 3 个顶点,这里只有 \{refs.length()} 个",
)
}
// 先把这个面的所有顶点解出来
let mut reference_shape : Int? = None
let face_vertices : Array[Vec3] = []
let face_normals : Array[Vec3] = []
for vref in refs {
let fields = vref.split("/").to_array()
let shape = fields.length() * 2 +
(if fields.length() > 1 && fields[1].length() == 0 { 1 } else { 0 })
match reference_shape {
Some(old) =>
if old != shape {
return Err(
"第 \{line_no} 行:OBJ 同一面的引用形式必须一致",
)
}
None => reference_shape = Some(shape)
}
match parse_face_ref(vref, vertices, normals, textures, line_no) {
Ok(pair) => {
face_vertices.push(pair.0)
face_normals.push(pair.1)
}
Err(msg) => return Err(msg)
}
}
let faces = match triangulate_polygon(face_vertices) {
Ok(faces) => faces
Err(msg) => return Err("第 \{line_no} 行:\{msg}")
}
let result : Array[Triangle] = []
for face in faces {
let (a, b, c) = face
// 法线优先用文件里写的。一个平面的三个顶点法线一样时直接用
// (3D 打印模型里绝大多数面都是平的),不一样就留零,
// 零向量随后跳过声明面法线反向检查;不验证不同的平滑顶点法线。
let normal = if same_vec(face_normals[a], face_normals[b]) &&
same_vec(face_normals[b], face_normals[c]) {
face_normals[a]
} else {
Vec3::zero()
}
result.push({
normal,
a: face_vertices[a],
b: face_vertices[b],
c: face_vertices[c],
})
}
Ok(result)
}
///|
/// 两个向量是不是逐分量完全相等。
///
/// 用精确比较而不是带容差:这里的用途是判断「同一个面里几个顶点的法线
/// 是不是同一份数据」,它们要么来自文件里同一个 `vn` 编号(那就完全一样),
/// 要么就不是同一个。引入容差反而会把「相近但不相同」误判成相同。
fn same_vec(a : Vec3, b : Vec3) -> Bool {
a.x == b.x && a.y == b.y && a.z == b.z
}
///|
/// 解析面里的一个顶点记号,返回(顶点, 法线)。
///
/// 法线可能没有(`f 1 2 3` 或 `f 1/1 2/2 3/3`),那就返回零向量。
fn parse_face_ref(
vref : StringView,
vertices : Array[Vec3],
normals : Array[Vec3],
textures : Array[Vec3],
line_no : Int,
) -> Result[(Vec3, Vec3), String] {
// 按 `/` 切开,最多三段:顶点[/纹理][/法线]
let parts : Array[StringView] = []
for part in vref.split("/") {
parts.push(part)
}
match parts {
[v] =>
if v.length() == 0 {
return Err("第 \{line_no} 行:空的顶点引用")
}
[v, t] =>
if v.length() == 0 || t.length() == 0 {
return Err("第 \{line_no} 行:OBJ v/vt 引用不完整")
}
[v, _, n] =>
if v.length() == 0 || n.length() == 0 {
return Err("第 \{line_no} 行:OBJ 法线引用不完整")
}
_ =>
return Err(
"第 \{line_no} 行:OBJ 面引用只接受 v、v/vt、v//vn、v/vt/vn",
)
}
let vertex_index = match parts {
[v, ..] => v
[] => return Err("第 \{line_no} 行:空的顶点记号")
}
if parts.length() >= 2 && parts[1].length() > 0 {
match lookup(parts[1], textures, line_no, "纹理") {
Ok(_) => ()
Err(msg) => return Err(msg)
}
}
let vertex = match lookup(vertex_index, vertices, line_no, "顶点") {
Ok(v) => v
Err(msg) => return Err(msg)
}
// 第三段才是法线;第二段纹理索引已在上面验证,纹理数据不进入 Mesh。
let normal = match parts {
[_, _, n, ..] =>
if n.length() == 0 {
Vec3::zero()
} else {
match lookup(n, normals, line_no, "法线") {
Ok(v) => v
Err(msg) => return Err(msg)
}
}
_ => Vec3::zero()
}
Ok((vertex, normal))
}
///|
/// 把 OBJ 的编号查成表里的值。
///
/// OBJ 的编号有两个规矩:
/// - **从 1 开始**,不是从 0。所以查表时要用 `index - 1`;
/// 我们的表在开头塞了个占位,正好抵消。
/// - **可以是负数**,表示从当前表的末尾往前数:-1 是最后一个,
/// -2 是倒数第二个。有些导出器喜欢这么写。
fn[T] lookup(
token : StringView,
table : Array[T],
line_no : Int,
what : String,
) -> Result[T, String] {
let index = match parse_int(token, line_no, what) {
Ok(i) => i
Err(msg) => return Err(msg)
}
if index == 0 {
return Err(
"第 \{line_no} 行:\{what}编号不能是 0(OBJ 从 1 开始数)",
)
}
// 表里有 n 个真实条目(下标 0 是占位),合法编号是 1..n 和 -1..-n
let count = table.length() - 1
let resolved = if index > 0 { index } else { count + index + 1 }
if resolved < 1 || resolved > count {
return Err(
"第 \{line_no} 行:\{what}编号 \{index} 超出范围(文件里一共有 \{count} 个\{what})",
)
}
Ok(table[resolved])
}
///|
/// 解析一个整数,失败时给出中文提示。
fn parse_int(
token : StringView,
line_no : Int,
what : String,
) -> Result[Int, String] {
if !profile_integer_token(token) {
return Err("第 \{line_no} 行:OBJ 编号必须为十进制整数")
}
Ok(@string.parse_int(token)) catch {
_ =>
Err(
"第 \{line_no} 行:『\{token}』不是合法的\{what}编号(OBJ 的编号必须是整数)",
)
}
}