[formats] XBG7 meshes: index buffers are triangle STRIPS, not lists

The weapon/prop models (rou_fxxx_wep_nn.xpr) rendered with triangular holes in
the hull and misplaced/extra spikes. Root cause: XBG7 sub-mesh index buffers are
triangle STRIPS, but the decoder read them as triangle LISTS — a strip of N
indices is N-2 triangles, a list is only N/3, so ~2/3 of every hull was missing
(the holes) and each list-triple of strip data spanned unrelated vertices (the
spikes). The tell: triangles-per-vertex was 0.5-1.0 with 0 unreferenced vertices
(a closed surface needs ~2.0).

- mesh.rs: expand_triangle_strip() converts strip -> list with alternating
  winding, skipping degenerate triangles (repeated index = strip restart).
  Applied in both from_xpr2 (weapons) and read_pool_mesh (stage sub-models).
  All 71 weapon submeshes now have healthy ratios; hulls fill in (wep_03 cannon,
  wep_04 pod, wep_11, wep_37 verified coherent).
- Module doc updated (strip, not list).
- sylpheed-cli: `mesh info` reports per-submesh degenerate/unref/spanning-triangle
  counts; `mesh render` gains --dist (camera zoom). XMESHDBG env dumps the
  descriptor index markers.

Known residual: an index buffer may concatenate several strips with no degenerate
bridge, leaving ~2 spanning "spike" triangles at each restart (index jump to a new
vertex region) — <1% of tris on most models. Decoding the restart mechanism is a
follow-up (a blanket spanning-filter is unsafe: clean models have legit elongated
tip triangles).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-07-18 13:04:12 +02:00
parent 7143ea18fe
commit a937779c77
2 changed files with 116 additions and 21 deletions

View File

@@ -157,6 +157,9 @@ enum MeshCommands {
/// Camera pitch in degrees
#[arg(long, default_value_t = 22.0)]
pitch: f32,
/// Camera distance multiplier (1.0 = framed; <1 zooms in, >1 out)
#[arg(long, default_value_t = 1.0)]
dist: f32,
/// Force the stage grid layout even for single models
#[arg(long)]
row: bool,
@@ -205,8 +208,8 @@ async fn main() -> Result<()> {
},
Commands::Mesh { cmd } => match cmd {
MeshCommands::Info { file } => cmd_mesh_info(&file),
MeshCommands::Render { file, output, size, yaw, pitch, row, only } => {
cmd_mesh_render(&file, &output, size, yaw, pitch, row, only)
MeshCommands::Render { file, output, size, yaw, pitch, dist, row, only } => {
cmd_mesh_render(&file, &output, size, yaw, pitch, dist, row, only)
}
},
Commands::Pak { cmd } => match cmd {
@@ -466,6 +469,48 @@ fn cmd_mesh_info(file: &Path) -> Result<()> {
hi[1] - lo[1],
hi[2] - lo[2]
);
// Per-sub-mesh integrity diagnostics: degenerate triangles (a zero-area
// "hole"), vertices referenced by no triangle (dropped geometry), and the
// referenced index range vs the vertex count (short/over reads).
for (si, sub) in m.meshes.iter().enumerate() {
let nv = sub.positions.len();
let mut referenced = vec![false; nv];
let (mut degen, mut oob, mut imax) = (0usize, 0usize, 0u32);
for tri in sub.indices.chunks_exact(3) {
let (a, b, c) = (tri[0], tri[1], tri[2]);
imax = imax.max(a).max(b).max(c);
if a == b || b == c || a == c {
degen += 1;
}
for &i in tri {
if (i as usize) < nv {
referenced[i as usize] = true;
} else {
oob += 1;
}
}
}
let unref = referenced.iter().filter(|&&r| !r).count();
// Spanning triangles: longest edge ≫ the median (strip-junction spikes).
let edge = |a: u32, b: u32| {
let (p, q) = (sub.positions[a as usize], sub.positions[b as usize]);
((p[0] - q[0]).powi(2) + (p[1] - q[1]).powi(2) + (p[2] - q[2]).powi(2)).sqrt()
};
let mut maxedges: Vec<f32> = sub
.indices
.chunks_exact(3)
.map(|t| edge(t[0], t[1]).max(edge(t[1], t[2])).max(edge(t[0], t[2])))
.collect();
maxedges.sort_by(|a, b| a.partial_cmp(b).unwrap());
let median = maxedges.get(maxedges.len() / 2).copied().unwrap_or(1.0).max(1e-6);
let spanning = maxedges.iter().filter(|&&e| e > 6.0 * median).count();
println!(
" sub{si}: {nv} v, {} tris | degenerate {degen}, unref-verts {unref}, spanning>6×med {spanning}, idx_max {imax}/{}{}",
sub.indices.len() / 3,
nv.saturating_sub(1),
if oob > 0 { format!(", OOB {oob}") } else { String::new() },
);
}
}
println!(
" {} {} sub-models · {} verts · {} tris",
@@ -484,6 +529,7 @@ fn cmd_mesh_render(
size: u32,
yaw: f32,
pitch: f32,
dist: f32,
force_row: bool,
only: Option<String>,
) -> Result<()> {
@@ -535,20 +581,22 @@ fn cmd_mesh_render(
(1.0, [0.0, 0.0, 0.0])
};
for sub in &m.meshes {
let f = |i: usize| {
let p = sub.positions[i];
[
(p[0] - center[0]) * scale + cell[0],
(p[1] - center[1]) * scale + cell[1],
(p[2] - center[2]) * scale + cell[2],
]
};
// Sub-mesh indices are a triangle list (the decoder has already
// expanded the file's triangle strips).
let n = sub.positions.len();
for tri in sub.indices.chunks_exact(3) {
let (a, b, c) = (tri[0] as usize, tri[1] as usize, tri[2] as usize);
if a >= sub.positions.len() || b >= sub.positions.len() || c >= sub.positions.len() {
continue;
if a < n && b < n && c < n {
tris.push([f(a), f(b), f(c)]);
}
let f = |i: usize| {
let p = sub.positions[i];
[
(p[0] - center[0]) * scale + cell[0],
(p[1] - center[1]) * scale + cell[1],
(p[2] - center[2]) * scale + cell[2],
]
};
tris.push([f(a), f(b), f(c)]);
}
}
}
@@ -556,7 +604,7 @@ fn cmd_mesh_render(
anyhow::bail!("no triangles to render");
}
let rgba = rasterize(&tris, size, yaw, pitch);
let rgba = rasterize(&tris, size, yaw, pitch, dist);
image::save_buffer(output, &rgba, size, size, image::ExtendedColorType::Rgba8)
.with_context(|| format!("writing PNG {}", output.display()))?;
println!(
@@ -575,7 +623,7 @@ fn cmd_mesh_render(
/// Minimal software rasterizer: orthographic, z-buffered, two-sided Lambert +
/// headlight shading over a flat grey material on a dark background. Enough to
/// judge whether recovered geometry is coherent.
fn rasterize(tris: &[[[f32; 3]; 3]], size: u32, yaw_deg: f32, pitch_deg: f32) -> Vec<u8> {
fn rasterize(tris: &[[[f32; 3]; 3]], size: u32, yaw_deg: f32, pitch_deg: f32, dist: f32) -> Vec<u8> {
let n = size as usize;
let (yaw, pitch) = (yaw_deg.to_radians(), pitch_deg.to_radians());
let (cy, sy) = (yaw.cos(), yaw.sin());
@@ -602,7 +650,7 @@ fn rasterize(tris: &[[[f32; 3]; 3]], size: u32, yaw_deg: f32, pitch_deg: f32) ->
}
}
let span = (hi[0] - lo[0]).max(hi[1] - lo[1]).max(1e-3);
let scale = (n as f32) * 0.9 / span;
let scale = (n as f32) * 0.9 / (span * dist.max(1e-3));
let cx = (lo[0] + hi[0]) * 0.5;
let cyv = (lo[1] + hi[1]) * 0.5;
let to_screen = |q: [f32; 3]| -> (f32, f32, f32) {