craft-mesh: ignore empty OBJ material assignments

* Ignore empty OBJ material assignments

* Preserve empty material resets in OBJ writes

Co-authored-by: NetterSilver <133535268+Silvertree2010@users.noreply.github.com>

---------

Co-authored-by: NetterSilver <133535268+Silvertree2010@users.noreply.github.com>
This commit is contained in:
Dylan M. TaylorandNetterSilver authored and GitHub committed 2026-11-12 06:26:04 -05:00
1 parent f759a35db8
commit e3c6ec4a23
2 files changed
+43 -2

No files matched your search

+19 -2
View File
@@ -236,6 +236,13 @@ impl Reader<'_> {
fn use_material(&mut self, name: &str) -> Result<()> {
self.slot = None;
if name.trim().is_empty() {
// A bare `usemtl` selects no named material. In particular, do not create a
// material with an empty name: callers can map the missing material to their own
// default, and writers need not emit a meaningless empty material name.
self.material = None;
return Ok(());
}
self.material = match self.material_ids.get(name) {
Some(i) => Some(*i),
None if self.scene.materials.len() >= self.limits.max_materials => {
@@ -518,6 +525,7 @@ pub fn write(scene: &Scene, mtllib: Option<&str>, limits: &Limits) -> Result<Str
// OBJ indices run on across objects.
let (mut vbase, mut tbase, mut nbase) = (1u64, 1u64, 1u64);
let mut smoothing = 0;
let mut current_material: Option<Option<usize>> = None;
for (index, world) in scene.instances(limits)? {
let Some(mesh) = scene.meshes.get(index) else { continue };
mesh.validate()?;
@@ -546,8 +554,17 @@ pub fn write(scene: &Scene, mtllib: Option<&str>, limits: &Limits) -> Result<Str
if !part.name.trim().is_empty() {
let _ = writeln!(text, "g {}", part.name.trim().replace(|c: char| c.is_control(), " "));
}
if let Some(m) = part.material.and_then(|m| materials.get(m)) {
let _ = writeln!(text, "usemtl {m}");
if current_material != Some(part.material) {
match part.material.and_then(|m| materials.get(m)) {
Some(m) => {
let _ = writeln!(text, "usemtl {m}");
}
None if current_material.is_some() => {
let _ = writeln!(text, "usemtl");
}
None => {}
}
current_material = Some(part.material);
}
if part.smoothing != smoothing {
smoothing = part.smoothing;
+24
View File
@@ -125,6 +125,30 @@ fn objects_groups_quads_and_negative_indices() {
assert_eq!((s.meshes.len(), s.meshes[0].name.as_str(), s.meshes[0].parts.len()), (1, "", 2));
}
#[test]
fn bare_usemtl_does_not_create_an_empty_named_material() {
for line in ["usemtl", "usemtl "] {
let src = format!("v 0 0 0\nv 1 0 0\nv 0 1 0\n{line}\nf 1 2 3\n");
let scene = read(src.as_bytes(), &Limits::default(), &mut |_| None).unwrap();
assert!(scene.materials.is_empty(), "{line:?}: {:?}", scene.materials);
assert_eq!(scene.meshes.iter().map(Mesh::triangle_count).sum::<usize>(), 1);
assert_eq!(scene.meshes[0].parts[0].material, None);
scene.validate().unwrap();
}
}
#[test]
fn writing_a_bare_usemtl_preserves_a_material_reset() {
let src = b"v 0 0 0\nv 1 0 0\nv 0 1 0\nv 1 1 0\nusemtl Named\nf 1 2 3\nusemtl\nf 2 4 3\n";
let scene = read(src, &Limits::default(), &mut no_external).unwrap();
assert_eq!(scene.meshes[0].parts.iter().map(|p| p.material).collect::<Vec<_>>(), [Some(0), None]);
let text = write(&scene, None, &Limits::default()).unwrap();
assert!(text.contains("usemtl Named\n") && text.contains("usemtl\nf 2 4 3\n"), "{text}");
let roundtrip = read(text.as_bytes(), &Limits::default(), &mut no_external).unwrap();
assert_eq!(roundtrip.meshes[0].parts.iter().map(|p| p.material).collect::<Vec<_>>(), [Some(0), None]);
}
#[test]
fn corner_normals_and_smoothing_groups() {
let vertices = "v 0 0 0\nv 1 0 0\nv 0 1 0\nv 0 -0.8660254037844386 0.5\n";