Refactor documentation structure and add new specifications

- Updated MSH documentation to reflect changes in material, wear, and texture specifications.
- Introduced new `render.md` file detailing the render pipeline process.
- Removed outdated sections from `runtime-pipeline.md` and redirected to `render.md`.
- Added detailed specifications for `Texm` texture format and `WEAR` wear table.
- Updated navigation in `mkdocs.yml` to align with new documentation structure.
This commit is contained in:
2026-02-19 04:46:23 +04:00
parent 8a69872576
commit 0e19660eb5
30 changed files with 2795 additions and 2832 deletions

View File

@@ -0,0 +1,84 @@
use msh_core::Model;
#[derive(Clone, Debug)]
pub struct RenderMesh {
pub vertices: Vec<[f32; 3]>,
pub batch_count: usize,
}
impl RenderMesh {
pub fn triangle_count(&self) -> usize {
self.vertices.len() / 3
}
}
/// Builds an expanded triangle list for a specific LOD/group pair.
///
/// The output is suitable for simple `glDrawArrays(GL_TRIANGLES, ...)` paths.
pub fn build_render_mesh(model: &Model, lod: usize, group: usize) -> RenderMesh {
let mut vertices = Vec::new();
let mut batch_count = 0usize;
for node_index in 0..model.node_count {
let Some(slot_idx) = model.slot_index(node_index, lod, group) else {
continue;
};
let Some(slot) = model.slots.get(slot_idx) else {
continue;
};
let batch_start = usize::from(slot.batch_start);
let batch_end = batch_start.saturating_add(usize::from(slot.batch_count));
if batch_end > model.batches.len() {
continue;
}
for batch in &model.batches[batch_start..batch_end] {
let index_start = usize::try_from(batch.index_start).unwrap_or(usize::MAX);
let index_count = usize::from(batch.index_count);
let index_end = index_start.saturating_add(index_count);
if index_end > model.indices.len() || index_count < 3 {
continue;
}
for &idx in &model.indices[index_start..index_end] {
let final_idx_u64 = u64::from(batch.base_vertex).saturating_add(u64::from(idx));
let Ok(final_idx) = usize::try_from(final_idx_u64) else {
continue;
};
let Some(pos) = model.positions.get(final_idx) else {
continue;
};
vertices.push(*pos);
}
batch_count += 1;
}
}
RenderMesh {
vertices,
batch_count,
}
}
pub fn compute_bounds(vertices: &[[f32; 3]]) -> Option<([f32; 3], [f32; 3])> {
let mut iter = vertices.iter();
let first = iter.next()?;
let mut min_v = *first;
let mut max_v = *first;
for v in iter {
for i in 0..3 {
if v[i] < min_v[i] {
min_v[i] = v[i];
}
if v[i] > max_v[i] {
max_v[i] = v[i];
}
}
}
Some((min_v, max_v))
}
#[cfg(test)]
mod tests;

View File

@@ -0,0 +1,101 @@
use super::*;
use msh_core::parse_model_payload;
use nres::Archive;
use std::fs;
use std::path::{Path, PathBuf};
fn collect_files_recursive(root: &Path, out: &mut Vec<PathBuf>) {
let Ok(entries) = fs::read_dir(root) else {
return;
};
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
collect_files_recursive(&path, out);
} else if path.is_file() {
out.push(path);
}
}
}
fn nres_test_files() -> Vec<PathBuf> {
let root = Path::new(env!("CARGO_MANIFEST_DIR"))
.join("..")
.join("..")
.join("testdata");
let mut files = Vec::new();
collect_files_recursive(&root, &mut files);
files.sort();
files
.into_iter()
.filter(|path| {
fs::read(path)
.map(|bytes| bytes.get(0..4) == Some(b"NRes"))
.unwrap_or(false)
})
.collect()
}
#[test]
fn build_render_mesh_for_real_models() {
let archives = nres_test_files();
if archives.is_empty() {
eprintln!("skipping build_render_mesh_for_real_models: no NRes files in testdata");
return;
}
let mut models_checked = 0usize;
let mut meshes_non_empty = 0usize;
let mut bounds_non_empty = 0usize;
for archive_path in archives {
let archive = Archive::open_path(&archive_path)
.unwrap_or_else(|err| panic!("failed to open {}: {err}", archive_path.display()));
for entry in archive.entries() {
if !entry.meta.name.to_ascii_lowercase().ends_with(".msh") {
continue;
}
models_checked += 1;
let payload = archive.read(entry.id).unwrap_or_else(|err| {
panic!(
"failed to read model '{}' from {}: {err}",
entry.meta.name,
archive_path.display()
)
});
let model = parse_model_payload(payload.as_slice()).unwrap_or_else(|err| {
panic!(
"failed to parse model '{}' from {}: {err}",
entry.meta.name,
archive_path.display()
)
});
let mesh = build_render_mesh(&model, 0, 0);
if !mesh.vertices.is_empty() {
meshes_non_empty += 1;
}
if compute_bounds(&mesh.vertices).is_some() {
bounds_non_empty += 1;
}
}
}
assert!(models_checked > 0, "no MSH models found");
assert!(
meshes_non_empty > 0,
"all generated render meshes are empty"
);
assert_eq!(
meshes_non_empty, bounds_non_empty,
"bounds must be available for every non-empty mesh"
);
}
#[test]
fn compute_bounds_handles_empty_and_non_empty() {
assert!(compute_bounds(&[]).is_none());
let bounds = compute_bounds(&[[1.0, 2.0, 3.0], [-2.0, 5.0, 0.5], [0.0, -1.0, 9.0]])
.expect("bounds expected");
assert_eq!(bounds.0, [-2.0, -1.0, 0.5]);
assert_eq!(bounds.1, [1.0, 5.0, 9.0]);
}