diff --git a/MeshDecimatorCore/MeshDecimatorCore.csproj b/MeshDecimatorCore/MeshDecimatorCore.csproj
index e0ad4b5..1d8d3f4 100644
--- a/MeshDecimatorCore/MeshDecimatorCore.csproj
+++ b/MeshDecimatorCore/MeshDecimatorCore.csproj
@@ -3,7 +3,7 @@
net10.0
enable
- enable
+ disable
diff --git a/Obj2Gltf/Converter.cs b/Obj2Gltf/Converter.cs
index 43aedec..da4b712 100644
--- a/Obj2Gltf/Converter.cs
+++ b/Obj2Gltf/Converter.cs
@@ -391,7 +391,13 @@ private List AddVertexAttributes(GltfModel gltfModel,
var hasColors = objModel.Colors.Count == objModel.Vertices.Count;
var materialIndex = GetMaterialIndexOrDefault(gltfModel, objModel, f.MatName);
- var material = materialIndex < objModel.Materials.Count ? objModel.Materials[materialIndex] : null;
+ // Fix Issue #36: look up the OBJ material by name instead of relying
+ // on the gltfModel index, which can diverge from objModel.Materials
+ // when the default material is inserted at index 0.
+ var material = !string.IsNullOrEmpty(f.MatName)
+ ? objModel.Materials.FirstOrDefault(m => m.Name == f.MatName)
+ ?? (materialIndex < objModel.Materials.Count ? objModel.Materials[materialIndex] : null)
+ : objModel.Materials.FirstOrDefault();
var materialHasTexture = material?.DiffuseTextureFile != null;
// every primitive needs their own vertex indices(v,t,n)
diff --git a/Obj2Gltf/WaveFront/ObjParser.cs b/Obj2Gltf/WaveFront/ObjParser.cs
index b9578f5..0abd60f 100644
--- a/Obj2Gltf/WaveFront/ObjParser.cs
+++ b/Obj2Gltf/WaveFront/ObjParser.cs
@@ -74,7 +74,7 @@ public ObjModel Parse(string objFilePath, bool removeDegenerateFaces = false, En
using (_reader)
{
- model.Materials.Add(new Material() { Ambient = new Reflectivity(new FactorColor(1)) });
+ model.Materials.Add(new Material() { Name = "default", Ambient = new Reflectivity(new FactorColor(1)) });
var currentMaterialName = "default";
var currentGeometries = model.GetOrAddGeometries("default");
Face currentFace = null;
diff --git a/Obj2Tiles.Common/CommonUtils.cs b/Obj2Tiles.Common/CommonUtils.cs
index e8f9f12..a9124d8 100644
--- a/Obj2Tiles.Common/CommonUtils.cs
+++ b/Obj2Tiles.Common/CommonUtils.cs
@@ -62,7 +62,7 @@ public static bool FilesAreEqual(FileInfo first, FileInfo second)
return true;
}
- public static TValue SafeGetValue(this IDictionary dictionary, TKey key)
+ public static TValue? SafeGetValue(this IDictionary dictionary, TKey key)
{
return !dictionary.TryGetValue(key, out var value) ? default : value;
}
@@ -74,7 +74,7 @@ public static TValue SafeGetValue(this IDictionary d
}
///
- /// Ensures that the sqlite database folder exists
+ /// Ensures that the sqlite database folder exists
///
///
public static void EnsureFolderCreated(string connstr)
@@ -93,7 +93,7 @@ public static void EnsureFolderCreated(string connstr)
if (folder != null)
Directory.CreateDirectory(folder);
- }
+ }
}
}
@@ -125,7 +125,7 @@ public static string ComputeFileHash(string filePath)
private static string ConvertBytesToString(byte[] bytes)
{
- // Convert byte array to a string
+ // Convert byte array to a string
var builder = new StringBuilder();
foreach (var t in bytes)
builder.Append(t.ToString("x2"));
@@ -202,7 +202,7 @@ public static bool SafeDelete(string path)
return false;
}
}
-
+
public static bool SafeCopy(string source, string dest, bool overwrite = true)
{
try
@@ -316,7 +316,7 @@ public static string[] SafeTreeDelete(string baseTempFolder, int rounds = 3, int
}
}
- if (!entries.Any())
+ if (!entries.Any())
return [];
Thread.Sleep(delay);
@@ -363,7 +363,7 @@ public static string[] SafeTreeDelete(string baseTempFolder, int rounds = 3, int
// Credit https://stackoverflow.com/a/11124118
///
- /// Returns the human-readable file size for an arbitrary, 64-bit file size
+ /// Returns the human-readable file size for an arbitrary, 64-bit file size
///
///
///
@@ -420,7 +420,7 @@ public static bool Validate(T obj, out ICollection results)
{
results = new List();
- return Validator.TryValidateObject(obj, new ValidationContext(obj), results, true);
+ return Validator.TryValidateObject(obj!, new ValidationContext(obj!), results, true);
}
public static string ToErrorString(this IEnumerable results)
@@ -448,7 +448,7 @@ public static async Task WaitForFile(string fullPath, FileMode mode,
int hops = 15, int baseDelay = 10, int incrementDelay = 2)
{
int delay = baseDelay;
-
+
for (var hop = 0; hop < hops; hops++)
{
FileStream fs = null;
@@ -472,13 +472,13 @@ public static async Task WaitForFile(string fullPath, FileMode mode,
return null;
}*/
- public static async Task WaitForFile(string fullPath, FileMode mode, FileAccess access,
+ public static async Task WaitForFile(string fullPath, FileMode mode, FileAccess access,
FileShare share,
int delay = 50, int retries = 1200)
{
for (var numTries = 0; numTries < retries; numTries++)
{
- FileStream fs = null;
+ FileStream? fs = null;
try
{
fs = new FileStream(fullPath, mode, access, share);
diff --git a/Obj2Tiles.Common/TestArea.cs b/Obj2Tiles.Common/TestArea.cs
index bb0f3a6..b451767 100644
--- a/Obj2Tiles.Common/TestArea.cs
+++ b/Obj2Tiles.Common/TestArea.cs
@@ -19,7 +19,7 @@ public TestArea(string name)
}
- public TestArea() : this(new StackFrame(1).GetMethod()?.Name)
+ public TestArea() : this(new StackFrame(1).GetMethod()?.Name ?? "Unknown")
{
//
}
diff --git a/Obj2Tiles.Common/TestFS.cs b/Obj2Tiles.Common/TestFS.cs
index 87fc855..7cb3698 100644
--- a/Obj2Tiles.Common/TestFS.cs
+++ b/Obj2Tiles.Common/TestFS.cs
@@ -24,7 +24,7 @@ public class TestFS : IDisposable
///
public string BaseTestFolder { get; }
- private readonly string _oldCurrentDirectory = null;
+ private readonly string? _oldCurrentDirectory = null;
///
/// Creates a new instance of TestFS
diff --git a/Obj2Tiles.Library.Test/BoundsTests.cs b/Obj2Tiles.Library.Test/BoundsTests.cs
new file mode 100644
index 0000000..feba0d5
--- /dev/null
+++ b/Obj2Tiles.Library.Test/BoundsTests.cs
@@ -0,0 +1,146 @@
+using System.IO;
+using NUnit.Framework;
+using Obj2Tiles.Library.Geometry;
+using Shouldly;
+
+namespace Obj2Tiles.Library.Test;
+
+///
+/// Tests for Box3 bounding box and mesh Bounds calculation.
+///
+public class BoundsTests
+{
+ private const string TestDataPath = "TestData";
+
+ [Test]
+ public void Bounds_Triangle_Correct()
+ {
+ var mesh = MeshUtils.LoadMesh(Path.Combine(TestDataPath, "triangle.obj"));
+ var bounds = mesh.Bounds;
+
+ bounds.Min.X.ShouldBe(0.0);
+ bounds.Min.Y.ShouldBe(0.0);
+ bounds.Min.Z.ShouldBe(0.0);
+ bounds.Max.X.ShouldBe(1.0);
+ bounds.Max.Y.ShouldBe(1.0);
+ bounds.Max.Z.ShouldBe(0.0);
+ }
+
+ [Test]
+ public void Bounds_Cube3D_SymmetricAroundOrigin()
+ {
+ var mesh = MeshUtils.LoadMesh(Path.Combine(TestDataPath, "cube-3d.obj"));
+ var bounds = mesh.Bounds;
+
+ bounds.Min.X.ShouldBe(-1.0);
+ bounds.Min.Y.ShouldBe(-1.0);
+ bounds.Min.Z.ShouldBe(-1.0);
+ bounds.Max.X.ShouldBe(1.0);
+ bounds.Max.Y.ShouldBe(1.0);
+ bounds.Max.Z.ShouldBe(1.0);
+
+ bounds.Width.ShouldBe(2.0);
+ bounds.Height.ShouldBe(2.0);
+ bounds.Depth.ShouldBe(2.0);
+ }
+
+ [Test]
+ public void Bounds_Center_IsCorrect()
+ {
+ var mesh = MeshUtils.LoadMesh(Path.Combine(TestDataPath, "cube-3d.obj"));
+ var center = mesh.Bounds.Center;
+
+ center.X.ShouldBe(0.0);
+ center.Y.ShouldBe(0.0);
+ center.Z.ShouldBe(0.0);
+ }
+
+ [Test]
+ public void Bounds_AfterSplit_SubsetsOfOriginal()
+ {
+ var mesh = MeshUtils.LoadMesh(Path.Combine(TestDataPath, "cube-3d.obj"));
+ var originalBounds = mesh.Bounds;
+ var xutils = new VertexUtilsX();
+
+ mesh.Split(xutils, 0.0, out var left, out var right);
+
+ var lb = left.Bounds;
+ lb.Min.X.ShouldBeGreaterThanOrEqualTo(originalBounds.Min.X - 1e-9);
+ lb.Max.X.ShouldBeLessThanOrEqualTo(0.0 + 1e-9);
+
+ var rb = right.Bounds;
+ rb.Min.X.ShouldBeGreaterThanOrEqualTo(0.0 - 1e-9);
+ rb.Max.X.ShouldBeLessThanOrEqualTo(originalBounds.Max.X + 1e-9);
+ }
+
+ // --- Box3 Split ---
+
+ [Test]
+ public void Box3_SplitX_CoversFull()
+ {
+ var box = new Box3(-1, -1, -1, 1, 1, 1);
+ var halves = box.Split(Axis.X);
+
+ halves.Length.ShouldBe(2);
+ halves[0].Min.X.ShouldBe(-1.0);
+ halves[0].Max.X.ShouldBe(0.0);
+ halves[1].Min.X.ShouldBe(0.0);
+ halves[1].Max.X.ShouldBe(1.0);
+
+ // Y and Z unchanged
+ halves[0].Min.Y.ShouldBe(-1.0);
+ halves[0].Max.Y.ShouldBe(1.0);
+ }
+
+ [Test]
+ public void Box3_SplitY_CoversFull()
+ {
+ var box = new Box3(-1, -1, -1, 1, 1, 1);
+ var halves = box.Split(Axis.Y);
+
+ halves[0].Max.Y.ShouldBe(0.0);
+ halves[1].Min.Y.ShouldBe(0.0);
+ }
+
+ [Test]
+ public void Box3_SplitZ_CoversFull()
+ {
+ var box = new Box3(-1, -1, -1, 1, 1, 1);
+ var halves = box.Split(Axis.Z);
+
+ halves[0].Max.Z.ShouldBe(0.0);
+ halves[1].Min.Z.ShouldBe(0.0);
+ }
+
+ [Test]
+ public void Box3_SplitAtPosition_Correct()
+ {
+ var box = new Box3(0, 0, 0, 10, 10, 10);
+ var halves = box.Split(Axis.X, 3.0);
+
+ halves[0].Max.X.ShouldBe(3.0);
+ halves[1].Min.X.ShouldBe(3.0);
+ }
+
+ [Test]
+ public void Box3_Center_Calculation()
+ {
+ var box = new Box3(2, 4, 6, 8, 10, 12);
+ var center = box.Center;
+
+ center.X.ShouldBe(5.0);
+ center.Y.ShouldBe(7.0);
+ center.Z.ShouldBe(9.0);
+ }
+
+ [Test]
+ public void Box3_Equality()
+ {
+ var a = new Box3(0, 0, 0, 1, 1, 1);
+ var b = new Box3(0, 0, 0, 1, 1, 1);
+ var c = new Box3(0, 0, 0, 2, 1, 1);
+
+ (a == b).ShouldBeTrue();
+ (a != c).ShouldBeTrue();
+ }
+}
diff --git a/Obj2Tiles.Library.Test/MtlParsingTests.cs b/Obj2Tiles.Library.Test/MtlParsingTests.cs
new file mode 100644
index 0000000..4820eea
--- /dev/null
+++ b/Obj2Tiles.Library.Test/MtlParsingTests.cs
@@ -0,0 +1,151 @@
+using System.IO;
+using System.Linq;
+using NUnit.Framework;
+using Obj2Tiles.Library.Materials;
+using Shouldly;
+
+namespace Obj2Tiles.Library.Test;
+
+///
+/// Tests for Material.ReadMtl — the MTL parser.
+/// Covers multi-material files, all property keywords, and edge cases.
+///
+public class MtlParsingTests
+{
+ private const string TestDataPath = "TestData";
+
+ [Test]
+ public void ReadMtl_MultiMaterial_ParsesBothMaterials()
+ {
+ var materials = Material.ReadMtl(Path.Combine(TestDataPath, "multi-material.mtl"), out var deps);
+
+ materials.Length.ShouldBe(2);
+ materials[0].Name.ShouldBe("Red");
+ materials[1].Name.ShouldBe("Blue");
+ }
+
+ [Test]
+ public void ReadMtl_AllProperties_ParsedCorrectly()
+ {
+ var materials = Material.ReadMtl(Path.Combine(TestDataPath, "multi-material.mtl"), out _);
+
+ // Red material
+ var red = materials[0];
+ red.AmbientColor.ShouldNotBeNull();
+ red.AmbientColor!.R.ShouldBe(0.1, tolerance: 0.001);
+ red.DiffuseColor.ShouldNotBeNull();
+ red.DiffuseColor!.R.ShouldBe(0.8, tolerance: 0.001);
+ red.SpecularColor.ShouldNotBeNull();
+ red.SpecularExponent!.Value.ShouldBe(100.0);
+ red.Dissolve!.Value.ShouldBe(1.0);
+ red.IlluminationModel.ShouldBe(Materials.IlluminationModel.HighlightOn);
+
+ // Blue material
+ var blue = materials[1];
+ blue.DiffuseColor.ShouldNotBeNull();
+ blue.DiffuseColor!.B.ShouldBe(0.8, tolerance: 0.001);
+ blue.SpecularExponent!.Value.ShouldBe(50.0);
+ blue.Dissolve!.Value.ShouldBe(0.9, tolerance: 0.001);
+ }
+
+ [Test]
+ public void ReadMtl_MaterialWithoutTexture_TextureIsNull()
+ {
+ var materials = Material.ReadMtl(Path.Combine(TestDataPath, "multi-material.mtl"), out var deps);
+
+ materials[0].Texture.ShouldBeNull();
+ materials[1].Texture.ShouldBeNull();
+ deps.Length.ShouldBe(0); // no texture files as dependencies
+ }
+
+ [Test]
+ public void ReadMtl_Tr_InvertsToDissolve()
+ {
+ // Create a temp MTL with Tr instead of d
+ var tempFile = Path.GetTempFileName();
+ try
+ {
+ File.WriteAllText(tempFile, "newmtl TestMat\nTr 0.3\n");
+ var materials = Material.ReadMtl(tempFile, out _);
+
+ materials.Length.ShouldBe(1);
+ materials[0].Dissolve!.Value.ShouldBe(0.7, tolerance: 0.001); // d = 1 - Tr
+ }
+ finally
+ {
+ File.Delete(tempFile);
+ }
+ }
+
+ [Test]
+ public void ReadMtl_EmptyFile_ReturnsSingleEmptyMaterial()
+ {
+ // The parser always adds the last material on exit
+ var tempFile = Path.GetTempFileName();
+ try
+ {
+ File.WriteAllText(tempFile, "newmtl Empty\n");
+ var materials = Material.ReadMtl(tempFile, out _);
+
+ materials.Length.ShouldBe(1);
+ materials[0].Name.ShouldBe("Empty");
+ materials[0].DiffuseColor.ShouldBeNull();
+ materials[0].Texture.ShouldBeNull();
+ }
+ finally
+ {
+ File.Delete(tempFile);
+ }
+ }
+
+ [Test]
+ public void ReadMtl_ToMtl_RoundTrip()
+ {
+ var materials = Material.ReadMtl(Path.Combine(TestDataPath, "multi-material.mtl"), out _);
+
+ // Write and re-read
+ var tempFile = Path.GetTempFileName();
+ try
+ {
+ var content = string.Join("\n", materials.Select(m => m.ToMtl()));
+ File.WriteAllText(tempFile, content);
+
+ var reread = Material.ReadMtl(tempFile, out _);
+ reread.Length.ShouldBe(2);
+ reread[0].Name.ShouldBe("Red");
+ reread[1].Name.ShouldBe("Blue");
+ reread[0].SpecularExponent.ShouldBe(100.0);
+ }
+ finally
+ {
+ File.Delete(tempFile);
+ }
+ }
+
+ // --- DiffuseColor-only material ---
+
+ [Test]
+ public void ReadMtl_DiffuseColorOnly_KdParsed()
+ {
+ // Materials with only Kd (no map_Kd) should still have DiffuseColor set.
+ // Issue #36 (grey materials in glTF) is fixed in Converter.cs and ObjParser.cs.
+ // This test validates the Obj2Tiles.Library MTL parser side.
+ var tempFile = Path.GetTempFileName();
+ try
+ {
+ File.WriteAllText(tempFile, "newmtl ColorOnly\nKd 0.8 0.2 0.3\n");
+ var materials = Material.ReadMtl(tempFile, out _);
+
+ materials.Length.ShouldBe(1);
+ materials[0].DiffuseColor.ShouldNotBeNull();
+ materials[0].DiffuseColor!.R.ShouldBe(0.8, tolerance: 0.001);
+ materials[0].DiffuseColor!.G.ShouldBe(0.2, tolerance: 0.001);
+ materials[0].DiffuseColor!.B.ShouldBe(0.3, tolerance: 0.001);
+ materials[0].Texture.ShouldBeNull();
+ }
+ finally
+ {
+ File.Delete(tempFile);
+ }
+ }
+}
diff --git a/Obj2Tiles.Library.Test/Obj2Tiles.Library.Test.csproj b/Obj2Tiles.Library.Test/Obj2Tiles.Library.Test.csproj
index 1680064..46040f1 100644
--- a/Obj2Tiles.Library.Test/Obj2Tiles.Library.Test.csproj
+++ b/Obj2Tiles.Library.Test/Obj2Tiles.Library.Test.csproj
@@ -91,6 +91,63 @@
PreserveNewest
+
+ PreserveNewest
+
+
+ PreserveNewest
+
+
+ PreserveNewest
+
+
+ PreserveNewest
+
+
+ PreserveNewest
+
+
+ PreserveNewest
+
+
+ PreserveNewest
+
+
+ PreserveNewest
+
+
+ PreserveNewest
+
+
+ PreserveNewest
+
+
+ PreserveNewest
+
+
+ PreserveNewest
+
+
+ PreserveNewest
+
+
+ PreserveNewest
+
+
+ PreserveNewest
+
+
+ PreserveNewest
+
+
+ PreserveNewest
+
+
+ PreserveNewest
+
+
+ PreserveNewest
+
diff --git a/Obj2Tiles.Library.Test/ObjParsingTests.cs b/Obj2Tiles.Library.Test/ObjParsingTests.cs
new file mode 100644
index 0000000..fa3f475
--- /dev/null
+++ b/Obj2Tiles.Library.Test/ObjParsingTests.cs
@@ -0,0 +1,226 @@
+using System;
+using System.IO;
+using NUnit.Framework;
+using Obj2Tiles.Library.Geometry;
+using Shouldly;
+
+namespace Obj2Tiles.Library.Test;
+
+///
+/// Tests for MeshUtils.LoadMesh — the OBJ parser used by the splitting stage.
+/// Covers all OBJ format corner cases and validates fixes for Issues #35, #60, #64.
+///
+public class ObjParsingTests
+{
+ private const string TestDataPath = "TestData";
+
+ // --- Vertex parsing ---
+
+ [Test]
+ public void LoadMesh_Triangle_ParsesVerticesAndFaces()
+ {
+ var mesh = MeshUtils.LoadMesh(Path.Combine(TestDataPath, "triangle.obj"));
+
+ mesh.ShouldBeOfType();
+ mesh.VertexCount.ShouldBe(4);
+ mesh.FacesCount.ShouldBe(2);
+ }
+
+ [Test]
+ public void LoadMesh_ScientificNotation_ParsesCorrectly()
+ {
+ var mesh = MeshUtils.LoadMesh(Path.Combine(TestDataPath, "scientific-notation.obj"));
+
+ mesh.ShouldBeOfType();
+ mesh.VertexCount.ShouldBe(3);
+ mesh.FacesCount.ShouldBe(1);
+
+ // v 1.5e-3 2.0e2 -3.1e1 → (0.0015, 200, -31)
+ var bounds = mesh.Bounds;
+ bounds.Min.X.ShouldBe(0.0, tolerance: 0.01);
+ bounds.Max.Y.ShouldBe(200.0, tolerance: 0.01);
+ }
+
+ [Test]
+ public void LoadMesh_CommentsAndBlanks_IgnoredCorrectly()
+ {
+ var mesh = MeshUtils.LoadMesh(Path.Combine(TestDataPath, "comments-and-blanks.obj"));
+
+ mesh.ShouldBeOfType();
+ mesh.VertexCount.ShouldBe(3);
+ mesh.FacesCount.ShouldBe(1);
+ }
+
+ [Test]
+ public void LoadMesh_EmptyFile_ReturnsEmptyMesh()
+ {
+ var mesh = MeshUtils.LoadMesh(Path.Combine(TestDataPath, "empty.obj"));
+
+ mesh.VertexCount.ShouldBe(0);
+ mesh.FacesCount.ShouldBe(0);
+ }
+
+ [Test]
+ public void LoadMesh_OnlyVertices_NoFaces()
+ {
+ var mesh = MeshUtils.LoadMesh(Path.Combine(TestDataPath, "only-vertices.obj"));
+
+ mesh.VertexCount.ShouldBe(4);
+ mesh.FacesCount.ShouldBe(0);
+ }
+
+ // --- Face format variants ---
+
+ [Test]
+ public void LoadMesh_FaceFormatV_VN_ParsesCorrectly()
+ {
+ // f v//vn format — normals are recognized but ignored, mesh is non-textured
+ var mesh = MeshUtils.LoadMesh(Path.Combine(TestDataPath, "triangle-normals.obj"));
+
+ mesh.ShouldBeOfType();
+ mesh.VertexCount.ShouldBe(3);
+ mesh.FacesCount.ShouldBe(1);
+ }
+
+ [Test]
+ public void LoadMesh_FaceFormatV_VT_VN_ParsesCorrectly()
+ {
+ // f v/vt/vn format — produces MeshT
+ var mesh = MeshUtils.LoadMesh(Path.Combine(TestDataPath, "triangle-vt-vn.obj"));
+
+ mesh.ShouldBeOfType();
+ mesh.VertexCount.ShouldBe(3);
+ mesh.FacesCount.ShouldBe(1);
+ }
+
+ // --- Quad and N-gon triangulation (Fixed: Issue #60) ---
+
+ [Test]
+ public void LoadMesh_QuadFace_TriangulatedTo2Triangles()
+ {
+ // Fixed: Issue #60 — quads were silently ignored, now fan-triangulated
+ var mesh = MeshUtils.LoadMesh(Path.Combine(TestDataPath, "quad.obj"));
+
+ mesh.ShouldBeOfType();
+ mesh.VertexCount.ShouldBe(4);
+ mesh.FacesCount.ShouldBe(2); // quad → 2 triangles via fan triangulation
+ }
+
+ [Test]
+ public void LoadMesh_NgonFace_TriangulatedCorrectly()
+ {
+ // Fixed: Issue #60 — n-gons (5+ vertices) now fan-triangulated
+ var mesh = MeshUtils.LoadMesh(Path.Combine(TestDataPath, "ngon.obj"));
+
+ mesh.ShouldBeOfType();
+ mesh.VertexCount.ShouldBe(5);
+ mesh.FacesCount.ShouldBe(3); // pentagon → 3 triangles via fan triangulation
+ }
+
+ // --- UV coordinate wrapping (Fixed: Issue #35) ---
+
+ [Test]
+ public void LoadMesh_NegativeUV_WrappedToUnitRange()
+ {
+ // Fixed: Issue #35 — negative UV coordinates now wrapped via modulo instead of throwing
+ var mesh = MeshUtils.LoadMesh(Path.Combine(TestDataPath, "triangle-uv-negative.obj"));
+
+ mesh.ShouldBeOfType();
+ mesh.VertexCount.ShouldBe(3);
+ mesh.FacesCount.ShouldBe(1);
+ // If we got here, UVs were wrapped instead of throwing
+ }
+
+ // --- Line element skipping (Fixed: Issue #64) ---
+
+ [Test]
+ public void LoadMesh_LineElement_SkippedGracefully()
+ {
+ // Fixed: Issue #64 — 'l' elements now skipped instead of throwing NotSupportedException
+ var mesh = MeshUtils.LoadMesh(Path.Combine(TestDataPath, "triangle-with-lines.obj"));
+
+ mesh.ShouldBeOfType();
+ mesh.VertexCount.ShouldBe(4);
+ mesh.FacesCount.ShouldBe(1);
+ }
+
+ [Test]
+ public void LoadMesh_CstypeElement_StillThrows()
+ {
+ // Curvilinear elements remain unsupported and should still throw
+ var tempFile = Path.GetTempFileName();
+ try
+ {
+ File.WriteAllText(tempFile, "v 0 0 0\nv 1 0 0\nv 0.5 1 0\ncstype bezier\nf 1 2 3\n");
+ Should.Throw(() => MeshUtils.LoadMesh(tempFile));
+ }
+ finally
+ {
+ File.Delete(tempFile);
+ }
+ }
+
+ // --- Degenerate faces ---
+
+ [Test]
+ public void LoadMesh_DegenerateFace_LoadsWithoutCrash()
+ {
+ var mesh = MeshUtils.LoadMesh(Path.Combine(TestDataPath, "degenerate-faces.obj"));
+
+ mesh.ShouldBeOfType();
+ mesh.FacesCount.ShouldBe(2); // both faces loaded, even the degenerate one
+ }
+
+ // --- Material handling ---
+
+ [Test]
+ public void LoadMesh_MultiMaterial_ParsesMaterials()
+ {
+ var mesh = MeshUtils.LoadMesh(Path.Combine(TestDataPath, "multi-material.obj"), out var deps);
+
+ mesh.ShouldBeOfType();
+ mesh.FacesCount.ShouldBe(4);
+ deps.Length.ShouldBeGreaterThan(0); // mtl file is a dependency
+ }
+
+ [Test]
+ public void LoadMesh_MissingMtlFile_ThrowsFileNotFound()
+ {
+ // mtllib points to a non-existent file → should throw (not "Access denied")
+ Should.Throw(() =>
+ MeshUtils.LoadMesh(Path.Combine(TestDataPath, "mtllib-missing.obj")));
+ }
+
+ // --- Normals parsing (Fixed: segs.Length condition) ---
+
+ [Test]
+ public void LoadMesh_FileWithNormals_DoesNotThrow()
+ {
+ // Fixed: vn case had wrong segs.Length == 3 (should be >= 4)
+ // The normals are recognized and skipped without error
+ var mesh = MeshUtils.LoadMesh(Path.Combine(TestDataPath, "cube2", "cube.obj"));
+
+ mesh.ShouldBeOfType();
+ mesh.FacesCount.ShouldBe(12);
+ }
+
+ // --- Cube geometry validation ---
+
+ [Test]
+ public void LoadMesh_Cube3D_CorrectBounds()
+ {
+ var mesh = MeshUtils.LoadMesh(Path.Combine(TestDataPath, "cube-3d.obj"));
+
+ mesh.ShouldBeOfType();
+ mesh.VertexCount.ShouldBe(8);
+ mesh.FacesCount.ShouldBe(12);
+
+ var bounds = mesh.Bounds;
+ bounds.Min.X.ShouldBe(-1.0);
+ bounds.Min.Y.ShouldBe(-1.0);
+ bounds.Min.Z.ShouldBe(-1.0);
+ bounds.Max.X.ShouldBe(1.0);
+ bounds.Max.Y.ShouldBe(1.0);
+ bounds.Max.Z.ShouldBe(1.0);
+ }
+}
diff --git a/Obj2Tiles.Library.Test/RecurseSplitTests.cs b/Obj2Tiles.Library.Test/RecurseSplitTests.cs
new file mode 100644
index 0000000..4e7c699
--- /dev/null
+++ b/Obj2Tiles.Library.Test/RecurseSplitTests.cs
@@ -0,0 +1,194 @@
+using System.Collections.Concurrent;
+using System.IO;
+using System.Linq;
+using System.Threading.Tasks;
+using NUnit.Framework;
+using Obj2Tiles.Library.Geometry;
+using Shouldly;
+
+namespace Obj2Tiles.Library.Test;
+
+///
+/// Tests for RecurseSplitXY and RecurseSplitXYZ — the recursive mesh splitting algorithms.
+/// These were previously untested.
+///
+public class RecurseSplitTests
+{
+ private const string TestDataPath = "TestData";
+
+ private IMesh LoadCube3D() => MeshUtils.LoadMesh(Path.Combine(TestDataPath, "cube-3d.obj"));
+ private IMesh LoadCubeColors3D() => MeshUtils.LoadMesh(Path.Combine(TestDataPath, "cube-colors-3d.obj"));
+
+ private static Vertex3 GetBaricenter(IMesh mesh) => mesh.GetVertexBaricenter();
+
+ // --- RecurseSplitXY with bounds ---
+
+ [Test]
+ public async Task RecurseSplitXY_Depth0_ReturnsSameMesh()
+ {
+ var mesh = LoadCube3D();
+ var bag = new ConcurrentBag();
+
+ await MeshUtils.RecurseSplitXY(mesh, 0, mesh.Bounds, bag);
+
+ bag.Count.ShouldBe(1);
+ bag.First().FacesCount.ShouldBe(mesh.FacesCount);
+ }
+
+ [Test]
+ public async Task RecurseSplitXY_Depth1_ProducesUpTo4Meshes()
+ {
+ var mesh = LoadCube3D();
+ var bag = new ConcurrentBag();
+
+ await MeshUtils.RecurseSplitXY(mesh, 1, mesh.Bounds, bag);
+
+ bag.Count.ShouldBeGreaterThan(0);
+ bag.Count.ShouldBeLessThanOrEqualTo(4);
+
+ // All meshes should have faces
+ foreach (var m in bag)
+ m.FacesCount.ShouldBeGreaterThan(0);
+ }
+
+ [Test]
+ public async Task RecurseSplitXY_Depth2_ProducesUpTo16Meshes()
+ {
+ var mesh = LoadCube3D();
+ var bag = new ConcurrentBag();
+
+ await MeshUtils.RecurseSplitXY(mesh, 2, mesh.Bounds, bag);
+
+ bag.Count.ShouldBeGreaterThan(0);
+ bag.Count.ShouldBeLessThanOrEqualTo(16);
+ }
+
+ [Test]
+ public async Task RecurseSplitXY_AllMeshesHaveFaces()
+ {
+ var mesh = LoadCube3D();
+ var bag = new ConcurrentBag();
+
+ await MeshUtils.RecurseSplitXY(mesh, 2, mesh.Bounds, bag);
+
+ foreach (var m in bag)
+ m.FacesCount.ShouldBeGreaterThan(0, $"Mesh '{m.Name}' has no faces");
+ }
+
+ [Test]
+ public async Task RecurseSplitXY_TotalFacesAtLeastOriginal()
+ {
+ var mesh = LoadCube3D();
+ var originalFaces = mesh.FacesCount;
+ var bag = new ConcurrentBag();
+
+ await MeshUtils.RecurseSplitXY(mesh, 1, mesh.Bounds, bag);
+
+ var totalFaces = bag.Sum(m => m.FacesCount);
+ totalFaces.ShouldBeGreaterThanOrEqualTo(originalFaces);
+ }
+
+ // --- RecurseSplitXY with getSplitPoint function ---
+
+ [Test]
+ public async Task RecurseSplitXY_WithFunc_ProducesUpTo4Meshes()
+ {
+ var mesh = LoadCube3D();
+ var bag = new ConcurrentBag();
+
+ await MeshUtils.RecurseSplitXY(mesh, 1, GetBaricenter, bag);
+
+ bag.Count.ShouldBeGreaterThan(0);
+ bag.Count.ShouldBeLessThanOrEqualTo(4);
+ }
+
+ // --- RecurseSplitXYZ ---
+
+ [Test]
+ public async Task RecurseSplitXYZ_Depth1_ProducesUpTo8Meshes()
+ {
+ var mesh = LoadCube3D();
+ var bag = new ConcurrentBag();
+
+ await MeshUtils.RecurseSplitXYZ(mesh, 1, GetBaricenter, bag);
+
+ bag.Count.ShouldBeGreaterThan(0);
+ bag.Count.ShouldBeLessThanOrEqualTo(8);
+
+ foreach (var m in bag)
+ m.FacesCount.ShouldBeGreaterThan(0);
+ }
+
+ [Test]
+ public async Task RecurseSplitXYZ_WithBounds_Depth1_ProducesUpTo8Meshes()
+ {
+ var mesh = LoadCube3D();
+ var bag = new ConcurrentBag();
+
+ await MeshUtils.RecurseSplitXYZ(mesh, 1, mesh.Bounds, bag);
+
+ bag.Count.ShouldBeGreaterThan(0);
+ bag.Count.ShouldBeLessThanOrEqualTo(8);
+ }
+
+ // --- Vertex colors through recursion ---
+
+ [Test]
+ public async Task RecurseSplitXY_WithColors_ColorsPreserved()
+ {
+ var mesh = LoadCubeColors3D();
+ var bag = new ConcurrentBag();
+
+ await MeshUtils.RecurseSplitXY(mesh, 1, mesh.Bounds, bag);
+
+ bag.Count.ShouldBeGreaterThan(0);
+
+ foreach (var m in bag)
+ {
+ var typedMesh = m.ShouldBeOfType();
+ typedMesh.VertexColors.ShouldNotBeNull();
+ typedMesh.VertexColors!.Count.ShouldBe(m.VertexCount);
+ }
+ }
+
+ [Test]
+ public async Task RecurseSplitXYZ_WithColors_ColorsPreserved()
+ {
+ var mesh = LoadCubeColors3D();
+ var bag = new ConcurrentBag();
+
+ await MeshUtils.RecurseSplitXYZ(mesh, 1, mesh.Bounds, bag);
+
+ bag.Count.ShouldBeGreaterThan(0);
+
+ foreach (var m in bag)
+ {
+ var typedMesh = m.ShouldBeOfType();
+ typedMesh.VertexColors.ShouldNotBeNull();
+ typedMesh.VertexColors!.Count.ShouldBe(m.VertexCount);
+ }
+ }
+
+ // --- Union of bounds covers original ---
+
+ [Test]
+ public async Task RecurseSplitXY_UnionBoundsCoversOriginal()
+ {
+ var mesh = LoadCube3D();
+ var originalBounds = mesh.Bounds;
+ var bag = new ConcurrentBag();
+
+ await MeshUtils.RecurseSplitXY(mesh, 1, mesh.Bounds, bag);
+
+ var allMeshes = bag.ToList();
+ var minX = allMeshes.Min(m => m.Bounds.Min.X);
+ var minY = allMeshes.Min(m => m.Bounds.Min.Y);
+ var maxX = allMeshes.Max(m => m.Bounds.Max.X);
+ var maxY = allMeshes.Max(m => m.Bounds.Max.Y);
+
+ minX.ShouldBeLessThanOrEqualTo(originalBounds.Min.X + 1e-9);
+ minY.ShouldBeLessThanOrEqualTo(originalBounds.Min.Y + 1e-9);
+ maxX.ShouldBeGreaterThanOrEqualTo(originalBounds.Max.X - 1e-9);
+ maxY.ShouldBeGreaterThanOrEqualTo(originalBounds.Max.Y - 1e-9);
+ }
+}
diff --git a/Obj2Tiles.Library.Test/SplitMultiAxisTests.cs b/Obj2Tiles.Library.Test/SplitMultiAxisTests.cs
new file mode 100644
index 0000000..ef5214e
--- /dev/null
+++ b/Obj2Tiles.Library.Test/SplitMultiAxisTests.cs
@@ -0,0 +1,241 @@
+using System.IO;
+using NUnit.Framework;
+using Obj2Tiles.Library.Geometry;
+using Shouldly;
+
+namespace Obj2Tiles.Library.Test;
+
+///
+/// Tests for mesh splitting on all three axes (X, Y, Z).
+/// Validates geometry correctness, vertex color preservation, and edge intersection handling.
+/// The existing test suite only tested split on X axis — these tests cover Y and Z.
+///
+public class SplitMultiAxisTests
+{
+ private const string TestDataPath = "TestData";
+
+ private static readonly IVertexUtils XUtils = new VertexUtilsX();
+ private static readonly IVertexUtils YUtils = new VertexUtilsY();
+ private static readonly IVertexUtils ZUtils = new VertexUtilsZ();
+
+ private IMesh LoadCube3D() => MeshUtils.LoadMesh(Path.Combine(TestDataPath, "cube-3d.obj"));
+ private IMesh LoadCubeColors3D() => MeshUtils.LoadMesh(Path.Combine(TestDataPath, "cube-colors-3d.obj"));
+ private IMesh LoadCubeTextured() => MeshUtils.LoadMesh(Path.Combine(TestDataPath, "cube2", "cube.obj"));
+
+ // --- Split on all three axes ---
+
+ [Test]
+ public void SplitX_Cube_ProducesTwoHalves()
+ {
+ var mesh = LoadCube3D();
+ var center = mesh.Bounds.Center;
+
+ mesh.Split(XUtils, center.X, out var left, out var right);
+
+ left.FacesCount.ShouldBeGreaterThan(0);
+ right.FacesCount.ShouldBeGreaterThan(0);
+ }
+
+ [Test]
+ public void SplitY_Cube_ProducesTwoHalves()
+ {
+ var mesh = LoadCube3D();
+ var center = mesh.Bounds.Center;
+
+ mesh.Split(YUtils, center.Y, out var left, out var right);
+
+ left.FacesCount.ShouldBeGreaterThan(0);
+ right.FacesCount.ShouldBeGreaterThan(0);
+ }
+
+ [Test]
+ public void SplitZ_Cube_ProducesTwoHalves()
+ {
+ var mesh = LoadCube3D();
+ var center = mesh.Bounds.Center;
+
+ mesh.Split(ZUtils, center.Z, out var left, out var right);
+
+ left.FacesCount.ShouldBeGreaterThan(0);
+ right.FacesCount.ShouldBeGreaterThan(0);
+ }
+
+ // --- Geometry correctness: vertices are on the correct side ---
+
+ [Test]
+ public void SplitX_Cube_LeftVerticesHaveXLessOrEqualThreshold()
+ {
+ var mesh = LoadCube3D();
+ var q = mesh.Bounds.Center.X;
+
+ mesh.Split(XUtils, q, out var left, out _);
+
+ var bounds = left.Bounds;
+ bounds.Max.X.ShouldBeLessThanOrEqualTo(q + 1e-9);
+ }
+
+ [Test]
+ public void SplitY_Cube_LeftVerticesHaveYLessOrEqualThreshold()
+ {
+ var mesh = LoadCube3D();
+ var q = mesh.Bounds.Center.Y;
+
+ mesh.Split(YUtils, q, out var left, out _);
+
+ var bounds = left.Bounds;
+ bounds.Max.Y.ShouldBeLessThanOrEqualTo(q + 1e-9);
+ }
+
+ [Test]
+ public void SplitZ_Cube_LeftVerticesHaveZLessOrEqualThreshold()
+ {
+ var mesh = LoadCube3D();
+ var q = mesh.Bounds.Center.Z;
+
+ mesh.Split(ZUtils, q, out var left, out _);
+
+ var bounds = left.Bounds;
+ bounds.Max.Z.ShouldBeLessThanOrEqualTo(q + 1e-9);
+ }
+
+ // --- Face count invariant: split can only create more faces ---
+
+ [Test]
+ [TestCase("X")]
+ [TestCase("Y")]
+ [TestCase("Z")]
+ public void Split_TotalFaceCount_GreaterOrEqualOriginal(string axis)
+ {
+ var mesh = LoadCube3D();
+ var originalFaces = mesh.FacesCount;
+ var center = mesh.Bounds.Center;
+
+ var utils = axis switch { "X" => XUtils, "Y" => YUtils, _ => ZUtils };
+ var q = axis switch { "X" => center.X, "Y" => center.Y, _ => center.Z };
+
+ mesh.Split(utils, q, out var left, out var right);
+
+ (left.FacesCount + right.FacesCount).ShouldBeGreaterThanOrEqualTo(originalFaces);
+ }
+
+ // --- Split at boundary: one side should be empty ---
+
+ [Test]
+ public void SplitX_AtMinBoundary_RightContainsAll()
+ {
+ var mesh = LoadCube3D();
+ var bounds = mesh.Bounds;
+
+ // Split below minimum — everything should be on the right
+ mesh.Split(XUtils, bounds.Min.X - 1.0, out var left, out var right);
+
+ left.FacesCount.ShouldBe(0);
+ right.FacesCount.ShouldBe(mesh.FacesCount);
+ }
+
+ [Test]
+ public void SplitX_AtMaxBoundary_LeftContainsAll()
+ {
+ var mesh = LoadCube3D();
+ var bounds = mesh.Bounds;
+
+ // Split above maximum — everything should be on the left
+ mesh.Split(XUtils, bounds.Max.X + 1.0, out var left, out var right);
+
+ left.FacesCount.ShouldBe(mesh.FacesCount);
+ right.FacesCount.ShouldBe(0);
+ }
+
+ // --- Vertex colors through split on all axes ---
+
+ [Test]
+ [TestCase("X")]
+ [TestCase("Y")]
+ [TestCase("Z")]
+ public void Split_WithColors_ColorsPreservedOnBothSides(string axis)
+ {
+ var mesh = LoadCubeColors3D();
+ var center = mesh.Bounds.Center;
+
+ var utils = axis switch { "X" => XUtils, "Y" => YUtils, _ => ZUtils };
+ var q = axis switch { "X" => center.X, "Y" => center.Y, _ => center.Z };
+
+ mesh.Split(utils, q, out var left, out var right);
+
+ left.FacesCount.ShouldBeGreaterThan(0);
+ right.FacesCount.ShouldBeGreaterThan(0);
+
+ // Both halves should be Mesh with colors (not MeshT since no textures)
+ var leftMesh = left.ShouldBeOfType();
+ var rightMesh = right.ShouldBeOfType();
+
+ leftMesh.VertexColors.ShouldNotBeNull();
+ rightMesh.VertexColors.ShouldNotBeNull();
+ leftMesh.VertexColors!.Count.ShouldBe(left.VertexCount);
+ rightMesh.VertexColors!.Count.ShouldBe(right.VertexCount);
+ }
+
+ // --- Textured mesh split on Y and Z ---
+
+ [Test]
+ public void SplitY_MeshT_ProducesTwoTexturedHalves()
+ {
+ var mesh = LoadCubeTextured();
+ var center = mesh.Bounds.Center;
+
+ mesh.Split(YUtils, center.Y, out var left, out var right);
+
+ left.FacesCount.ShouldBeGreaterThan(0);
+ right.FacesCount.ShouldBeGreaterThan(0);
+ left.ShouldBeOfType();
+ right.ShouldBeOfType();
+ }
+
+ [Test]
+ public void SplitZ_MeshT_ProducesTwoTexturedHalves()
+ {
+ var mesh = LoadCubeTextured();
+ var center = mesh.Bounds.Center;
+
+ mesh.Split(ZUtils, center.Z, out var left, out var right);
+
+ left.FacesCount.ShouldBeGreaterThan(0);
+ right.FacesCount.ShouldBeGreaterThan(0);
+ left.ShouldBeOfType();
+ right.ShouldBeOfType();
+ }
+
+ // --- Split bounds are subsets of original bounds ---
+
+ [Test]
+ [TestCase("X")]
+ [TestCase("Y")]
+ [TestCase("Z")]
+ public void Split_BoundsAreSubsetsOfOriginal(string axis)
+ {
+ var mesh = LoadCube3D();
+ var originalBounds = mesh.Bounds;
+ var center = originalBounds.Center;
+
+ var utils = axis switch { "X" => XUtils, "Y" => YUtils, _ => ZUtils };
+ var q = axis switch { "X" => center.X, "Y" => center.Y, _ => center.Z };
+
+ mesh.Split(utils, q, out var left, out var right);
+
+ if (left.FacesCount > 0)
+ {
+ var lb = left.Bounds;
+ lb.Min.X.ShouldBeGreaterThanOrEqualTo(originalBounds.Min.X - 1e-9);
+ lb.Min.Y.ShouldBeGreaterThanOrEqualTo(originalBounds.Min.Y - 1e-9);
+ lb.Min.Z.ShouldBeGreaterThanOrEqualTo(originalBounds.Min.Z - 1e-9);
+ }
+
+ if (right.FacesCount > 0)
+ {
+ var rb = right.Bounds;
+ rb.Max.X.ShouldBeLessThanOrEqualTo(originalBounds.Max.X + 1e-9);
+ rb.Max.Y.ShouldBeLessThanOrEqualTo(originalBounds.Max.Y + 1e-9);
+ rb.Max.Z.ShouldBeLessThanOrEqualTo(originalBounds.Max.Z + 1e-9);
+ }
+ }
+}
diff --git a/Obj2Tiles.Library.Test/TestData/comments-and-blanks.obj b/Obj2Tiles.Library.Test/TestData/comments-and-blanks.obj
new file mode 100644
index 0000000..b050668
--- /dev/null
+++ b/Obj2Tiles.Library.Test/TestData/comments-and-blanks.obj
@@ -0,0 +1,15 @@
+# File with many comments, blank lines, and whitespace-only lines
+# This is a comment
+
+# Another comment
+
+v 0 0 0
+
+v 1 0 0
+
+# Vertex 3
+v 0.5 1 0
+
+# Face definition
+f 1 2 3
+# End of file
diff --git a/Obj2Tiles.Library.Test/TestData/cube-3d.obj b/Obj2Tiles.Library.Test/TestData/cube-3d.obj
new file mode 100644
index 0000000..b432d71
--- /dev/null
+++ b/Obj2Tiles.Library.Test/TestData/cube-3d.obj
@@ -0,0 +1,28 @@
+# Cube with full 3D geometry for multi-axis split testing
+# 8 vertices, 12 triangle faces
+v -1 -1 -1
+v 1 -1 -1
+v 1 1 -1
+v -1 1 -1
+v -1 -1 1
+v 1 -1 1
+v 1 1 1
+v -1 1 1
+# Front face
+f 5 6 7
+f 5 7 8
+# Back face
+f 1 4 3
+f 1 3 2
+# Left face
+f 1 5 8
+f 1 8 4
+# Right face
+f 2 3 7
+f 2 7 6
+# Top face
+f 4 8 7
+f 4 7 3
+# Bottom face
+f 1 2 6
+f 1 6 5
diff --git a/Obj2Tiles.Library.Test/TestData/cube-colors-3d.obj b/Obj2Tiles.Library.Test/TestData/cube-colors-3d.obj
new file mode 100644
index 0000000..460aba8
--- /dev/null
+++ b/Obj2Tiles.Library.Test/TestData/cube-colors-3d.obj
@@ -0,0 +1,21 @@
+# Cube with vertex colors — 3D for multi-axis split color testing
+v -1 -1 -1 1.0 0.0 0.0
+v 1 -1 -1 0.0 1.0 0.0
+v 1 1 -1 0.0 0.0 1.0
+v -1 1 -1 1.0 1.0 0.0
+v -1 -1 1 1.0 0.0 1.0
+v 1 -1 1 0.0 1.0 1.0
+v 1 1 1 0.5 0.5 0.5
+v -1 1 1 1.0 1.0 1.0
+f 5 6 7
+f 5 7 8
+f 1 4 3
+f 1 3 2
+f 1 5 8
+f 1 8 4
+f 2 3 7
+f 2 7 6
+f 4 8 7
+f 4 7 3
+f 1 2 6
+f 1 6 5
diff --git a/Obj2Tiles.Library.Test/TestData/degenerate-faces.obj b/Obj2Tiles.Library.Test/TestData/degenerate-faces.obj
new file mode 100644
index 0000000..4356c2c
--- /dev/null
+++ b/Obj2Tiles.Library.Test/TestData/degenerate-faces.obj
@@ -0,0 +1,7 @@
+# Face with degenerate triangle (all 3 vertices are coincident)
+v 0 0 0
+v 1 0 0
+v 0.5 1 0
+v 0 0 0
+f 1 2 3
+f 1 4 1
diff --git a/Obj2Tiles.Library.Test/TestData/empty.obj b/Obj2Tiles.Library.Test/TestData/empty.obj
new file mode 100644
index 0000000..e69de29
diff --git a/Obj2Tiles.Library.Test/TestData/mtllib-empty.obj b/Obj2Tiles.Library.Test/TestData/mtllib-empty.obj
new file mode 100644
index 0000000..6fb04bc
--- /dev/null
+++ b/Obj2Tiles.Library.Test/TestData/mtllib-empty.obj
@@ -0,0 +1,6 @@
+# MTL reference with empty filename
+mtllib
+v 0 0 0
+v 1 0 0
+v 0.5 1 0
+f 1 2 3
diff --git a/Obj2Tiles.Library.Test/TestData/mtllib-missing.obj b/Obj2Tiles.Library.Test/TestData/mtllib-missing.obj
new file mode 100644
index 0000000..c65b66b
--- /dev/null
+++ b/Obj2Tiles.Library.Test/TestData/mtllib-missing.obj
@@ -0,0 +1,6 @@
+# Reference to a non-existent MTL file
+mtllib nonexistent-material-file.mtl
+v 0 0 0
+v 1 0 0
+v 0.5 1 0
+f 1 2 3
diff --git a/Obj2Tiles.Library.Test/TestData/multi-material.mtl b/Obj2Tiles.Library.Test/TestData/multi-material.mtl
new file mode 100644
index 0000000..9e2b72b
--- /dev/null
+++ b/Obj2Tiles.Library.Test/TestData/multi-material.mtl
@@ -0,0 +1,15 @@
+newmtl Red
+Ka 0.1 0.0 0.0
+Kd 0.8 0.1 0.1
+Ks 1.0 1.0 1.0
+Ns 100.0
+d 1.0
+illum 2
+
+newmtl Blue
+Ka 0.0 0.0 0.1
+Kd 0.1 0.1 0.8
+Ks 1.0 1.0 1.0
+Ns 50.0
+d 0.9
+illum 2
diff --git a/Obj2Tiles.Library.Test/TestData/multi-material.obj b/Obj2Tiles.Library.Test/TestData/multi-material.obj
new file mode 100644
index 0000000..85e34ca
--- /dev/null
+++ b/Obj2Tiles.Library.Test/TestData/multi-material.obj
@@ -0,0 +1,29 @@
+# OBJ with multiple materials
+mtllib multi-material.mtl
+o MultiMatMesh
+
+v -1 -1 0
+v 1 -1 0
+v 1 1 0
+v -1 1 0
+v 0 2 0
+v 0 -2 0
+
+vt 0 0
+vt 1 0
+vt 1 1
+vt 0 1
+vt 0.5 1
+vt 0.5 0
+
+usemtl Red
+f 1/1 2/2 3/3
+
+usemtl Blue
+f 1/1 3/3 4/4
+
+usemtl Red
+f 3/3 5/5 4/4
+
+usemtl Blue
+f 1/1 6/6 2/2
diff --git a/Obj2Tiles.Library.Test/TestData/ngon.obj b/Obj2Tiles.Library.Test/TestData/ngon.obj
new file mode 100644
index 0000000..fbbe91d
--- /dev/null
+++ b/Obj2Tiles.Library.Test/TestData/ngon.obj
@@ -0,0 +1,7 @@
+# A pentagon (5 vertices, 1 n-gon face)
+v 0.0 1.0 0.0
+v 0.951 0.309 0.0
+v 0.588 -0.809 0.0
+v -0.588 -0.809 0.0
+v -0.951 0.309 0.0
+f 1 2 3 4 5
diff --git a/Obj2Tiles.Library.Test/TestData/only-vertices.obj b/Obj2Tiles.Library.Test/TestData/only-vertices.obj
new file mode 100644
index 0000000..fab0eb9
--- /dev/null
+++ b/Obj2Tiles.Library.Test/TestData/only-vertices.obj
@@ -0,0 +1,5 @@
+# Only vertices, no faces
+v 0 0 0
+v 1 0 0
+v 0.5 1 0
+v 0.5 0.5 0.5
diff --git a/Obj2Tiles.Library.Test/TestData/quad.obj b/Obj2Tiles.Library.Test/TestData/quad.obj
new file mode 100644
index 0000000..738ae1b
--- /dev/null
+++ b/Obj2Tiles.Library.Test/TestData/quad.obj
@@ -0,0 +1,6 @@
+# A single quad face (4 vertices)
+v 0 0 0
+v 1 0 0
+v 1 1 0
+v 0 1 0
+f 1 2 3 4
diff --git a/Obj2Tiles.Library.Test/TestData/scientific-notation.obj b/Obj2Tiles.Library.Test/TestData/scientific-notation.obj
new file mode 100644
index 0000000..4dbafca
--- /dev/null
+++ b/Obj2Tiles.Library.Test/TestData/scientific-notation.obj
@@ -0,0 +1,5 @@
+# Coordinates in scientific notation
+v 1.5e-3 2.0e2 -3.1e1
+v 0.0e0 1.0e0 0.0e0
+v 5.0e-1 0.0e0 1.0e-1
+f 1 2 3
diff --git a/Obj2Tiles.Library.Test/TestData/triangle-normals.obj b/Obj2Tiles.Library.Test/TestData/triangle-normals.obj
new file mode 100644
index 0000000..034a299
--- /dev/null
+++ b/Obj2Tiles.Library.Test/TestData/triangle-normals.obj
@@ -0,0 +1,6 @@
+# Triangle with normals (f v//vn format)
+v 0 0 0
+v 1 0 0
+v 0.5 1 0
+vn 0 0 1
+f 1//1 2//1 3//1
diff --git a/Obj2Tiles.Library.Test/TestData/triangle-uv-negative.mtl b/Obj2Tiles.Library.Test/TestData/triangle-uv-negative.mtl
new file mode 100644
index 0000000..c3ec189
--- /dev/null
+++ b/Obj2Tiles.Library.Test/TestData/triangle-uv-negative.mtl
@@ -0,0 +1,2 @@
+newmtl Default
+Kd 0.8 0.8 0.8
diff --git a/Obj2Tiles.Library.Test/TestData/triangle-uv-negative.obj b/Obj2Tiles.Library.Test/TestData/triangle-uv-negative.obj
new file mode 100644
index 0000000..1444e88
--- /dev/null
+++ b/Obj2Tiles.Library.Test/TestData/triangle-uv-negative.obj
@@ -0,0 +1,10 @@
+# Triangle with negative/out-of-range UV coordinates (wrapping/UDIM)
+v 0 0 0
+v 1 0 0
+v 0.5 1 0
+vt -0.5 1.5
+vt 2.3 -0.2
+vt 0.5 0.5
+mtllib triangle-uv-negative.mtl
+usemtl Default
+f 1/1 2/2 3/3
diff --git a/Obj2Tiles.Library.Test/TestData/triangle-vt-vn.mtl b/Obj2Tiles.Library.Test/TestData/triangle-vt-vn.mtl
new file mode 100644
index 0000000..c3ec189
--- /dev/null
+++ b/Obj2Tiles.Library.Test/TestData/triangle-vt-vn.mtl
@@ -0,0 +1,2 @@
+newmtl Default
+Kd 0.8 0.8 0.8
diff --git a/Obj2Tiles.Library.Test/TestData/triangle-vt-vn.obj b/Obj2Tiles.Library.Test/TestData/triangle-vt-vn.obj
new file mode 100644
index 0000000..23caaac
--- /dev/null
+++ b/Obj2Tiles.Library.Test/TestData/triangle-vt-vn.obj
@@ -0,0 +1,11 @@
+# Triangle with texture coordinates and normals (f v/vt/vn format)
+v 0 0 0
+v 1 0 0
+v 0.5 1 0
+vt 0 0
+vt 1 0
+vt 0.5 1
+vn 0 0 1
+mtllib triangle-vt-vn.mtl
+usemtl Default
+f 1/1/1 2/2/1 3/3/1
diff --git a/Obj2Tiles.Library.Test/TestData/triangle-with-lines.obj b/Obj2Tiles.Library.Test/TestData/triangle-with-lines.obj
new file mode 100644
index 0000000..bc5a441
--- /dev/null
+++ b/Obj2Tiles.Library.Test/TestData/triangle-with-lines.obj
@@ -0,0 +1,7 @@
+# Triangle with line element (should be skipped gracefully)
+v 0 0 0
+v 1 0 0
+v 0.5 1 0
+v 0 1 0
+l 1 2 3
+f 1 2 3
diff --git a/Obj2Tiles.Library/Geometry/Box3.cs b/Obj2Tiles.Library/Geometry/Box3.cs
index 418f985..be13a35 100644
--- a/Obj2Tiles.Library/Geometry/Box3.cs
+++ b/Obj2Tiles.Library/Geometry/Box3.cs
@@ -15,7 +15,7 @@ public Box3(Vertex3 min, Vertex3 max)
Min = min;
Max = max;
}
-
+
public Box3(double minX, double minY, double minZ, double maxX, double maxY, double maxZ)
{
Min = new Vertex3(minX, minY, minZ);
@@ -32,9 +32,9 @@ public override string ToString()
{
return $"{Min:0.00} - {Max:0.00} ({Width:0.00}x{Height:0.00}x{Depth:0.00}) c: {Center:0.00}";
}
-
+
// Override equals operator
- public override bool Equals(object obj)
+ public override bool Equals(object? obj)
{
if (obj is Box3 box)
{
@@ -42,22 +42,22 @@ public override bool Equals(object obj)
}
return false;
}
-
+
public override int GetHashCode()
{
return Min.GetHashCode() ^ Max.GetHashCode();
}
-
+
public static bool operator ==(Box3 left, Box3 right)
{
return left.Equals(right);
}
-
+
public static bool operator !=(Box3 left, Box3 right)
{
return !(left == right);
}
-
+
// Split box into two along the given axis
public Box3[] Split(Axis axis, double position)
{
diff --git a/Obj2Tiles.Library/Geometry/MeshT.cs b/Obj2Tiles.Library/Geometry/MeshT.cs
index 14ad518..0cf8515 100644
--- a/Obj2Tiles.Library/Geometry/MeshT.cs
+++ b/Obj2Tiles.Library/Geometry/MeshT.cs
@@ -544,8 +544,8 @@ private void BinPackTextures(string targetFolder, int materialIndex, IReadOnlyLi
var texture = material.Texture != null ? TexturesCache.GetTexture(material.Texture) : null;
var normalMap = material.NormalMap != null ? TexturesCache.GetTexture(material.NormalMap) : null;
- int textureWidth = material.Texture != null ? texture.Width : normalMap.Width;
- int textureHeight = material.Texture != null ? texture.Height : normalMap.Height;
+ int textureWidth = material.Texture != null ? texture!.Width : normalMap!.Width;
+ int textureHeight = material.Texture != null ? texture!.Height : normalMap!.Height;
var clustersRects = clusters.Select(GetClusterRect).ToArray();
@@ -628,14 +628,14 @@ private void BinPackTextures(string targetFolder, int materialIndex, IReadOnlyLi
? $"{Name}-texture-normal-{material.Name}{Path.GetExtension(material.NormalMap)}" : null;
if (material.Texture != null) {
- newPathTexture = Path.Combine(targetFolder, textureFileName);
- newTexture.Save(newPathTexture); newTexture.Dispose();
+ newPathTexture = Path.Combine(targetFolder, textureFileName!);
+ newTexture!.Save(newPathTexture); newTexture!.Dispose();
}
if (material.NormalMap != null) {
- newPathNormalMap = Path.Combine(targetFolder, normalMapFileName);
- newNormalMap.Save(newPathNormalMap);
- newNormalMap.Dispose();
+ newPathNormalMap = Path.Combine(targetFolder, normalMapFileName!);
+ newNormalMap!.Save(newPathNormalMap);
+ newNormalMap!.Dispose();
}
// fresh atlas
@@ -668,13 +668,13 @@ private void BinPackTextures(string targetFolder, int materialIndex, IReadOnlyLi
if (material.Texture != null)
{
- using var block = BuildPaddedBlock(texture, srcRect, PADDING);
- newTexture.Mutate(c => c.DrawImage(block, new Point(destOuterX, destOuterY), 1f));
+ using var block = BuildPaddedBlock(texture!, srcRect, PADDING);
+ newTexture!.Mutate(c => c.DrawImage(block, new Point(destOuterX, destOuterY), 1f));
}
if (material.NormalMap != null)
{
- using var blockN = BuildPaddedBlock(normalMap, srcRect, PADDING);
- newNormalMap.Mutate(c => c.DrawImage(blockN, new Point(destOuterX, destOuterY), 1f));
+ using var blockN = BuildPaddedBlock(normalMap!, srcRect, PADDING);
+ newNormalMap!.Mutate(c => c.DrawImage(blockN, new Point(destOuterX, destOuterY), 1f));
}
// Inner rect size in pixels
@@ -741,11 +741,11 @@ Vertex2 MapUV(double rx, double ry) =>
var saveTaskTexture = new Task(t =>
{
- var tx = t as Image;
+ var tx = (Image)t!;
switch (TexturesStrategy)
{
- case TexturesStrategy.RepackCompressed: tx.SaveAsJpeg(newPathTexture, encoder); break;
- case TexturesStrategy.Repack: tx.Save(newPathTexture); break;
+ case TexturesStrategy.RepackCompressed: tx.SaveAsJpeg(newPathTexture!, encoder); break;
+ case TexturesStrategy.Repack: tx.Save(newPathTexture!); break;
default: throw new InvalidOperationException("KeepOriginal/Compress are meaningless here");
}
Debug.WriteLine("Saved texture to " + newPathTexture);
@@ -754,11 +754,11 @@ Vertex2 MapUV(double rx, double ry) =>
var saveTaskNormalMap = new Task(t =>
{
- var tx = t as Image;
+ var tx = (Image)t!;
switch (TexturesStrategy)
{
- case TexturesStrategy.RepackCompressed: tx.SaveAsJpeg(newPathNormalMap, encoder); break;
- case TexturesStrategy.Repack: tx.Save(newPathNormalMap); break;
+ case TexturesStrategy.RepackCompressed: tx.SaveAsJpeg(newPathNormalMap!, encoder); break;
+ case TexturesStrategy.Repack: tx.Save(newPathNormalMap!); break;
default: throw new InvalidOperationException("KeepOriginal/Compress are meaningless here");
}
Debug.WriteLine("Saved texture to " + newPathNormalMap);
diff --git a/Obj2Tiles.Library/Geometry/MeshUtils.cs b/Obj2Tiles.Library/Geometry/MeshUtils.cs
index cb39a27..9e11a69 100644
--- a/Obj2Tiles.Library/Geometry/MeshUtils.cs
+++ b/Obj2Tiles.Library/Geometry/MeshUtils.cs
@@ -59,13 +59,14 @@ public static IMesh LoadMesh(string fileName, out string[] dependencies)
double.Parse(segs[1], CultureInfo.InvariantCulture),
double.Parse(segs[2], CultureInfo.InvariantCulture));
- if (vtx.X < 0 || vtx.Y < 0)
- throw new Exception("Invalid texture coordinates: " + vtx);
+ // Wrap UV coordinates to [0, 1] range for mirroring/UDIM workflows (Issue #35)
+ if (vtx.X < 0 || vtx.X > 1 || vtx.Y < 0 || vtx.Y > 1)
+ vtx = new Vertex2(vtx.X - Math.Floor(vtx.X), vtx.Y - Math.Floor(vtx.Y));
textureVertices.Add(vtx);
break;
- case "vn" when segs.Length == 3:
- // Skipping normals
+ case "vn" when segs.Length >= 4:
+ // Skipping normals (recognized but not used)
break;
case "usemtl" when segs.Length == 2:
{
@@ -126,6 +127,57 @@ public static IMesh LoadMesh(string fileName, out string[] dependencies)
break;
}
+ case "f" when segs.Length >= 5:
+ {
+ // Fan triangulation for quads and n-gons (Issue #60)
+ // Splits polygon v0-v1-v2-...-vN into triangles: (v0,v1,v2), (v0,v2,v3), ..., (v0,vN-1,vN)
+ var faceVerts = new string[segs.Length - 1][];
+ for (var fi = 0; fi < segs.Length - 1; fi++)
+ faceVerts[fi] = segs[fi + 1].Split('/');
+
+ // Determine whether all vertices have texture coordinates.
+ var anyHasTex = false;
+ var allHaveTex = true;
+ for (var fi = 0; fi < faceVerts.Length; fi++)
+ {
+ var hasVt = faceVerts[fi].Length > 1 && faceVerts[fi][1].Length > 0;
+ anyHasTex |= hasVt;
+ allHaveTex &= hasVt;
+ }
+
+ if (anyHasTex && !allHaveTex)
+ throw new FormatException("OBJ face has inconsistent texture coordinate indices (mixed v/vt and v//vn or missing vt) which is not supported.");
+
+ var hasTex = allHaveTex;
+
+ // Parse the pivot vertex once outside the loop
+ var fv0 = int.Parse(faceVerts[0][0]) - 1;
+ var ft0 = hasTex ? int.Parse(faceVerts[0][1]) - 1 : 0;
+
+ for (var fi = 1; fi < faceVerts.Length - 1; fi++)
+ {
+ var fv1 = int.Parse(faceVerts[fi][0]) - 1;
+ var fv2 = int.Parse(faceVerts[fi + 1][0]) - 1;
+
+ if (hasTex)
+ {
+ var ft1 = int.Parse(faceVerts[fi][1]) - 1;
+ var ft2 = int.Parse(faceVerts[fi + 1][1]) - 1;
+
+ var materialIndex = 0;
+ if (currentMaterial != string.Empty)
+ materialIndex = materialsDict[currentMaterial];
+
+ facesT.Add(new FaceT(fv0, fv1, fv2, ft0, ft1, ft2, materialIndex));
+ }
+ else
+ {
+ faces.Add(new Face(fv0, fv1, fv2));
+ }
+ }
+
+ break;
+ }
case "mtllib" when segs.Length == 2:
{
var mtlFileName = segs[1];
@@ -144,7 +196,10 @@ public static IMesh LoadMesh(string fileName, out string[] dependencies)
break;
}
- case "l" or "cstype" or "deg" or "bmat" or "step" or "curv" or "curv2" or "surf" or "parm" or "trim"
+ case "l":
+ // Line elements are irrelevant for triangular meshes, skip gracefully (Issue #64)
+ break;
+ case "cstype" or "deg" or "bmat" or "step" or "curv" or "curv2" or "surf" or "parm" or "trim"
or "end" or "hole" or "scrv" or "sp" or "con":
throw new NotSupportedException("Element not supported: '" + line + "'");
diff --git a/Obj2Tiles.Library/Materials/Material.cs b/Obj2Tiles.Library/Materials/Material.cs
index 1e6f8d6..c3a7b30 100644
--- a/Obj2Tiles.Library/Materials/Material.cs
+++ b/Obj2Tiles.Library/Materials/Material.cs
@@ -58,8 +58,8 @@ public static Material[] ReadMtl(string path, out string[] dependencies)
var materials = new List();
var deps = new List();
- string texture = null;
- string normalMap = null;
+ string? texture = null;
+ string? normalMap = null;
var name = string.Empty;
RGB? ambientColor = null, diffuseColor = null, specularColor = null;
double? specularExponent = null, dissolve = null;
@@ -69,7 +69,7 @@ public static Material[] ReadMtl(string path, out string[] dependencies)
{
if (line.StartsWith("#") || string.IsNullOrWhiteSpace(line))
continue;
-
+
var lineTrimmed = line.Trim();
var parts = lineTrimmed.Split(' ');
switch (parts[0])
@@ -87,9 +87,9 @@ public static Material[] ReadMtl(string path, out string[] dependencies)
texture = Path.IsPathRooted(parts[1])
? parts[1]
: Path.GetFullPath(Path.Combine(Path.GetDirectoryName(path)!, parts[1]));
-
+
deps.Add(texture);
-
+
break;
case "norm":
normalMap = Path.IsPathRooted(parts[1])
@@ -139,7 +139,7 @@ public static Material[] ReadMtl(string path, out string[] dependencies)
illuminationModel));
dependencies = deps.ToArray();
-
+
return materials.ToArray();
}
diff --git a/Obj2Tiles.Test/BoxDTO.cs b/Obj2Tiles.Test/BoxDTO.cs
index 42c26c8..2e2c225 100644
--- a/Obj2Tiles.Test/BoxDTO.cs
+++ b/Obj2Tiles.Test/BoxDTO.cs
@@ -4,14 +4,14 @@ namespace Obj2Tiles.Test;
public class BoxDTO
{
- public VertexDTO Min { get; set; }
- public VertexDTO Max { get; set; }
+ public VertexDTO Min { get; set; } = null!;
+ public VertexDTO Max { get; set; } = null!;
public double Width { get; set; }
public double Height { get; set; }
public double Depth { get; set; }
- public VertexDTO Center { get; set; }
+ public VertexDTO Center { get; set; } = null!;
public BoxDTO()
{
diff --git a/Obj2Tiles.Test/Obj2Tiles.Test.csproj b/Obj2Tiles.Test/Obj2Tiles.Test.csproj
index 8541bc2..5046232 100644
--- a/Obj2Tiles.Test/Obj2Tiles.Test.csproj
+++ b/Obj2Tiles.Test/Obj2Tiles.Test.csproj
@@ -23,6 +23,7 @@
+
@@ -176,6 +177,45 @@
PreserveNewest
+
+ PreserveNewest
+
+
+ PreserveNewest
+
+
+ PreserveNewest
+
+
+ PreserveNewest
+
+
+ PreserveNewest
+
+
+ PreserveNewest
+
+
+ PreserveNewest
+
+
+ PreserveNewest
+
+
+ PreserveNewest
+
+
+ PreserveNewest
+
+
+ PreserveNewest
+
+
+ PreserveNewest
+
+
+ PreserveNewest
+
diff --git a/Obj2Tiles.Test/ObjMeshParsingTests.cs b/Obj2Tiles.Test/ObjMeshParsingTests.cs
new file mode 100644
index 0000000..1162d20
--- /dev/null
+++ b/Obj2Tiles.Test/ObjMeshParsingTests.cs
@@ -0,0 +1,218 @@
+using System.IO;
+using System.Linq;
+using NUnit.Framework;
+using Obj2Tiles.Stages.Model;
+using Shouldly;
+
+namespace Obj2Tiles.Test;
+
+///
+/// Tests for ObjMesh.ReadFile — the OBJ parser used by the decimation stage.
+/// Covers quad triangulation, negative indices, normals, and edge cases.
+///
+public class ObjMeshParsingTests
+{
+ private const string TestDataPath = "TestData";
+
+ private ObjMesh LoadMesh(string relativePath)
+ {
+ var mesh = new ObjMesh();
+ mesh.ReadFile(Path.Combine(TestDataPath, relativePath));
+ return mesh;
+ }
+
+ // --- Quad triangulation ---
+
+ [Test]
+ public void ReadFile_QuadFace_TriangulatedTo2Triangles()
+ {
+ var mesh = LoadMesh("quad-mesh.obj");
+
+ mesh.Vertices!.Length.ShouldBe(4);
+ // Quad → 2 triangles = 6 indices in one sub-mesh
+ var totalIndices = mesh.SubMeshIndices!.Sum(s => s.Length);
+ (totalIndices / 3).ShouldBe(2);
+ }
+
+ // --- Negative indices ---
+
+ [Test]
+ public void ReadFile_NegativeIndices_ResolvedCorrectly()
+ {
+ var mesh = LoadMesh("negative-idx.obj");
+
+ mesh.Vertices!.Length.ShouldBeGreaterThan(0);
+ var totalTriangles = mesh.SubMeshIndices!.Sum(s => s.Length) / 3;
+ totalTriangles.ShouldBe(2);
+
+ // All indices should be valid (non-negative, within bounds)
+ foreach (var submesh in mesh.SubMeshIndices!)
+ {
+ foreach (var idx in submesh)
+ {
+ idx.ShouldBeGreaterThanOrEqualTo(0);
+ idx.ShouldBeLessThan(mesh.Vertices!.Length);
+ }
+ }
+ }
+
+ // --- Normals ---
+
+ [Test]
+ public void ReadFile_FileWithNormals_NormalsAreParsed()
+ {
+ // ObjMesh fully parses normals (unlike MeshUtils.LoadMesh which skips them)
+ var mesh = LoadMesh("cube2-normals.obj");
+
+ mesh.Normals.ShouldNotBeNull();
+ mesh.Normals!.Length.ShouldBeGreaterThan(0);
+ }
+
+ // --- Empty file ---
+
+ [Test]
+ public void ReadFile_EmptyFile_NoVerticesOrFaces()
+ {
+ var tempFile = Path.GetTempFileName();
+ try
+ {
+ File.WriteAllText(tempFile, "");
+ var mesh = new ObjMesh();
+ mesh.ReadFile(tempFile);
+
+ mesh.Vertices!.Length.ShouldBe(0);
+ mesh.SubMeshIndices!.Length.ShouldBe(0);
+ }
+ finally
+ {
+ File.Delete(tempFile);
+ }
+ }
+
+ // --- Scientific notation ---
+
+ [Test]
+ public void ReadFile_ScientificNotation_ParsesCorrectly()
+ {
+ var mesh = LoadMesh("scientific-notation.obj");
+
+ mesh.Vertices!.Length.ShouldBeGreaterThan(0);
+ var totalTriangles = mesh.SubMeshIndices!.Sum(s => s.Length) / 3;
+ totalTriangles.ShouldBe(1);
+ }
+
+ // --- Vertex colors ---
+
+ [Test]
+ public void ReadFile_VertexColors_Parsed()
+ {
+ var mesh = LoadMesh("cube-colors-3d.obj");
+
+ mesh.VertexColors.ShouldNotBeNull();
+ mesh.VertexColors!.Length.ShouldBe(mesh.Vertices!.Length);
+ }
+
+ [Test]
+ public void ReadFile_NoColors_VertexColorsIsNull()
+ {
+ var mesh = LoadMesh("cube-3d.obj");
+
+ mesh.VertexColors.ShouldBeNull();
+ }
+
+ // --- Write/Read round-trip ---
+
+ [Test]
+ public void WriteRead_RoundTrip_PreservesGeometry()
+ {
+ var mesh = LoadMesh("quad-mesh.obj");
+ var originalVertexCount = mesh.Vertices!.Length;
+ var originalTriangles = mesh.SubMeshIndices!.Sum(s => s.Length) / 3;
+
+ var tempFile = Path.GetTempFileName();
+ try
+ {
+ mesh.WriteFile(tempFile);
+
+ var reloaded = new ObjMesh();
+ reloaded.ReadFile(tempFile);
+
+ reloaded.Vertices!.Length.ShouldBe(originalVertexCount);
+ var reloadedTriangles = reloaded.SubMeshIndices!.Sum(s => s.Length) / 3;
+ reloadedTriangles.ShouldBe(originalTriangles);
+ }
+ finally
+ {
+ File.Delete(tempFile);
+ }
+ }
+
+ [Test]
+ public void ReadFile_UsemtlWithoutFaces_DoesNotThrow()
+ {
+ // Fixed: Issue #54 — trailing usemtl without subsequent faces no longer
+ // creates orphan material entries that cause mismatch with SubMeshIndices.
+ var tempFile = Path.GetTempFileName();
+ var roundTripFile = Path.GetTempFileName();
+ try
+ {
+ File.WriteAllText(tempFile, "v 0 0 0\nv 1 0 0\nv 0.5 1 0\nf 1 2 3\nusemtl OrphanMaterial\n");
+ var mesh = new ObjMesh();
+ // ReadFile should not crash even with trailing usemtl
+ Should.NotThrow(() => mesh.ReadFile(tempFile));
+
+ // The orphan usemtl must not create a material entry without
+ // matching indices — that mismatch is the root cause of Issue #54.
+ if (mesh.SubMeshMaterials != null)
+ mesh.SubMeshMaterials.Length.ShouldBe(mesh.SubMeshIndices!.Length);
+
+ // WriteFile must also survive (this is where the original crash was reported).
+ Should.NotThrow(() => mesh.WriteFile(roundTripFile));
+ }
+ finally
+ {
+ File.Delete(tempFile);
+ File.Delete(roundTripFile);
+ }
+ }
+
+ [Test]
+ public void ReadFile_MultiMaterial_TrailingUsemtl_MaterialsMatchIndices()
+ {
+ // Issue #54 — realistic scenario: two active materials with faces,
+ // then a trailing orphan usemtl with no subsequent faces.
+ var tempFile = Path.GetTempFileName();
+ var roundTripFile = Path.GetTempFileName();
+ try
+ {
+ File.WriteAllText(tempFile,
+ "v 0 0 0\nv 1 0 0\nv 0.5 1 0\nv 0 1 0\n" +
+ "usemtl MatA\nf 1 2 3\n" +
+ "usemtl MatB\nf 1 3 4\n" +
+ "usemtl OrphanC\n");
+
+ var mesh = new ObjMesh();
+ Should.NotThrow(() => mesh.ReadFile(tempFile));
+
+ // SubMeshMaterials must match SubMeshIndices length
+ mesh.SubMeshMaterials.ShouldNotBeNull();
+ mesh.SubMeshIndices.ShouldNotBeNull();
+ mesh.SubMeshMaterials!.Length.ShouldBe(mesh.SubMeshIndices!.Length);
+
+ // OrphanC must not be present
+ mesh.SubMeshMaterials.ShouldNotContain("OrphanC");
+
+ // Both active materials should be present
+ mesh.SubMeshMaterials.ShouldContain("MatA");
+ mesh.SubMeshMaterials.ShouldContain("MatB");
+
+ // WriteFile round-trip must also survive
+ Should.NotThrow(() => mesh.WriteFile(roundTripFile));
+ }
+ finally
+ {
+ File.Delete(tempFile);
+ File.Delete(roundTripFile);
+ }
+ }
+}
diff --git a/Obj2Tiles.Test/ObjMeshVertexColorEdgeCaseTests.cs b/Obj2Tiles.Test/ObjMeshVertexColorEdgeCaseTests.cs
index b2b7687..5e94f4d 100644
--- a/Obj2Tiles.Test/ObjMeshVertexColorEdgeCaseTests.cs
+++ b/Obj2Tiles.Test/ObjMeshVertexColorEdgeCaseTests.cs
@@ -45,7 +45,7 @@ public void ReadFile_MixedColors_IndicesAligned()
mesh.ReadFile(Path.Combine(TestDataPath, "mixed-colors.obj"));
mesh.VertexColors.ShouldNotBeNull();
- mesh.VertexColors.Length.ShouldBe(mesh.Vertices.Length);
+ mesh.VertexColors!.Length.ShouldBe(mesh.Vertices!.Length);
}
[Test]
@@ -56,7 +56,7 @@ public void ReadFile_MixedColors_DefaultColorIsWhite()
mesh.ReadFile(Path.Combine(TestDataPath, "mixed-colors.obj"));
// Find a white vertex — there should be at least one from the non-colored v3
- var hasWhite = mesh.VertexColors.Any(c =>
+ var hasWhite = mesh.VertexColors!.Any(c =>
Math.Abs(c.x - 1f) < 0.001f &&
Math.Abs(c.y - 1f) < 0.001f &&
Math.Abs(c.z - 1f) < 0.001f &&
@@ -73,7 +73,7 @@ public void ReadFile_LateColors_BackFillsPreviousVertices()
mesh.ReadFile(Path.Combine(TestDataPath, "late-colors.obj"));
mesh.VertexColors.ShouldNotBeNull();
- mesh.VertexColors.Length.ShouldBe(mesh.Vertices.Length);
+ mesh.VertexColors!.Length.ShouldBe(mesh.Vertices!.Length);
// v3 (last in original OBJ) should have the explicit color (0.5, 0.5, 0.5)
var hasGray = mesh.VertexColors.Any(c =>
@@ -98,7 +98,7 @@ public void ReadFile_MixedColors_WriteRoundTrip_PreservesAlignment()
reloaded.ReadFile(outPath);
reloaded.VertexColors.ShouldNotBeNull();
- reloaded.VertexColors.Length.ShouldBe(reloaded.Vertices.Length);
+ reloaded.VertexColors!.Length.ShouldBe(reloaded.Vertices!.Length);
// Every vertex in the re-loaded file should now have an explicit color
// (because WriteFile wrote colors for all vertices)
@@ -135,7 +135,7 @@ public void ReadFile_MalformedAlpha_ParsesOtherComponentsCorrectly()
mesh.ReadFile(Path.Combine(TestDataPath, "alpha-colors.obj"));
mesh.VertexColors.ShouldNotBeNull();
- mesh.VertexColors.Length.ShouldBe(mesh.Vertices.Length);
+ mesh.VertexColors!.Length.ShouldBe(mesh.Vertices!.Length);
// v1: (1, 0, 0, 0.5) — valid alpha
// v2: (0, 1, 0, 0) — malformed alpha defaults to 0 via TryParse
diff --git a/Obj2Tiles.Test/ObjParserGltfTests.cs b/Obj2Tiles.Test/ObjParserGltfTests.cs
new file mode 100644
index 0000000..4026ed9
--- /dev/null
+++ b/Obj2Tiles.Test/ObjParserGltfTests.cs
@@ -0,0 +1,244 @@
+using System;
+using System.IO;
+using System.Linq;
+using NUnit.Framework;
+using SilentWave.Obj2Gltf.WaveFront;
+using Shouldly;
+
+namespace Obj2Tiles.Test;
+
+///
+/// Tests for ObjParser.Parse — the OBJ parser used by the glTF conversion stage.
+/// Covers quad/ngon triangulation, groups, degenerate faces, and vertex colors.
+///
+public class ObjParserGltfTests
+{
+ private const string TestDataPath = "TestData";
+ private const string LibTestDataPath = TestDataPath;
+
+ private ObjModel ParseFile(string path)
+ {
+ var parser = new ObjParser();
+ return parser.Parse(path);
+ }
+
+ // --- Basic parsing ---
+
+ [Test]
+ public void Parse_Triangle_CorrectCounts()
+ {
+ var model = ParseFile(Path.Combine(LibTestDataPath, "triangle.obj"));
+
+ model.Vertices.Count.ShouldBe(4);
+ var totalTriangles = model.Geometries
+ .SelectMany(g => g.Faces)
+ .SelectMany(f => f.Triangles)
+ .Count();
+ totalTriangles.ShouldBe(2);
+ }
+
+ // --- Quad triangulation ---
+
+ [Test]
+ public void Parse_QuadFace_TriangulatedTo2Triangles()
+ {
+ var model = ParseFile(Path.Combine(LibTestDataPath, "quad-simple.obj"));
+
+ model.Vertices.Count.ShouldBe(4);
+ var totalTriangles = model.Geometries
+ .SelectMany(g => g.Faces)
+ .SelectMany(f => f.Triangles)
+ .Count();
+ totalTriangles.ShouldBe(2);
+ }
+
+ // --- N-gon triangulation ---
+
+ [Test]
+ public void Parse_NgonFace_Triangulated()
+ {
+ var model = ParseFile(Path.Combine(LibTestDataPath, "ngon.obj"));
+
+ model.Vertices.Count.ShouldBe(5);
+ var totalTriangles = model.Geometries
+ .SelectMany(g => g.Faces)
+ .SelectMany(f => f.Triangles)
+ .Count();
+ totalTriangles.ShouldBe(3); // pentagon → 3 triangles
+ }
+
+ // --- Vertex colors ---
+
+ [Test]
+ public void Parse_VertexColors_Parsed()
+ {
+ var model = ParseFile(Path.Combine(LibTestDataPath, "cube-colors-3d.obj"));
+
+ model.Colors.Count.ShouldBe(8);
+ model.Vertices.Count.ShouldBe(8);
+ }
+
+ [Test]
+ public void Parse_NoColors_EmptyColorsList()
+ {
+ var model = ParseFile(Path.Combine(LibTestDataPath, "triangle.obj"));
+
+ model.Colors.Count.ShouldBe(0);
+ }
+
+ // --- Degenerate faces ---
+
+ [Test]
+ public void Parse_DegenerateFaces_WithRemoval()
+ {
+ var parser = new ObjParser();
+ var model = parser.Parse(Path.Combine(LibTestDataPath, "degenerate-faces.obj"), removeDegenerateFaces: true);
+
+ // Should have removed the degenerate face (all 3 vertices coincident)
+ var totalTriangles = model.Geometries
+ .SelectMany(g => g.Faces)
+ .SelectMany(f => f.Triangles)
+ .Count();
+ totalTriangles.ShouldBeLessThan(2); // at most 1 (the degenerate one removed)
+ }
+
+ [Test]
+ public void Parse_DegenerateFaces_WithoutRemoval()
+ {
+ var parser = new ObjParser();
+ var model = parser.Parse(Path.Combine(LibTestDataPath, "degenerate-faces.obj"), removeDegenerateFaces: false);
+
+ var totalTriangles = model.Geometries
+ .SelectMany(g => g.Faces)
+ .SelectMany(f => f.Triangles)
+ .Count();
+ totalTriangles.ShouldBe(2); // both faces kept
+ }
+
+ // --- Empty file ---
+
+ [Test]
+ public void Parse_EmptyFile_ReturnsEmptyModel()
+ {
+ var model = ParseFile(Path.Combine(LibTestDataPath, "empty.obj"));
+
+ model.Vertices.Count.ShouldBe(0);
+ // ObjParser always creates a default geometry group, so Count is 1
+ // but the geometry should have zero faces
+ model.Geometries.Count().ShouldBe(1);
+ model.Geometries.First().Faces.Count.ShouldBe(0);
+ }
+
+ // --- Scientific notation ---
+
+ [Test]
+ public void Parse_ScientificNotation_ParsesCorrectly()
+ {
+ var model = ParseFile(Path.Combine(LibTestDataPath, "scientific-notation.obj"));
+
+ model.Vertices.Count.ShouldBe(3);
+ // v 1.5e-3 → 0.0015
+ model.Vertices[0].X.ShouldBe(0.0015f, tolerance: 0.0001f);
+ }
+
+ // --- Comments and blanks ---
+
+ [Test]
+ public void Parse_CommentsAndBlanks_ParsesCorrectly()
+ {
+ var model = ParseFile(Path.Combine(LibTestDataPath, "comments-and-blanks.obj"));
+
+ model.Vertices.Count.ShouldBe(3);
+ }
+
+ // --- Normals ---
+
+ [Test]
+ public void Parse_FileWithNormals_NormalsAreParsed()
+ {
+ var model = ParseFile(Path.Combine(LibTestDataPath, "triangle-normals.obj"));
+
+ model.Normals.Count.ShouldBe(1); // one normal shared by 3 face vertices
+ }
+
+ [Test]
+ public void Parse_DefaultMaterial_HasName()
+ {
+ // The default material inserted by ObjParser must have Name="default"
+ // so that GetMaterialIndexOrDefault can find it and avoid falling back
+ // to the grey GetDefault() material.
+ var model = ParseFile(Path.Combine(LibTestDataPath, "triangle.obj"));
+
+ model.Materials.Count.ShouldBeGreaterThanOrEqualTo(1);
+ model.Materials[0].Name.ShouldBe("default");
+ }
+
+ [Test]
+ public void ConvertMaterial_DiffuseColorOnly_BaseColorFactorMatchesKd()
+ {
+ // Issue #36 — material with Kd but no map_Kd should produce
+ // BaseColorFactor reflecting the Kd color, not grey (0.5).
+ var mat = new SilentWave.Obj2Gltf.WaveFront.Material
+ {
+ Name = "ColorMat",
+ Diffuse = new SilentWave.Obj2Gltf.WaveFront.Reflectivity(
+ new SilentWave.Obj2Gltf.WaveFront.FactorColor(0.8, 0.2, 0.3))
+ };
+
+ var gltfMat = SilentWave.Obj2Gltf.Converter.ConvertMaterial(mat, _ => 0);
+
+ gltfMat.PbrMetallicRoughness.ShouldNotBeNull();
+ var bcf = gltfMat.PbrMetallicRoughness.BaseColorFactor;
+ bcf.ShouldNotBeNull();
+ bcf[0].ShouldBe(0.8, tolerance: 0.001);
+ bcf[1].ShouldBe(0.2, tolerance: 0.001);
+ bcf[2].ShouldBe(0.3, tolerance: 0.001);
+ bcf[3].ShouldBe(1.0, tolerance: 0.001);
+ }
+
+ [Test]
+ public void Parse_MaterialFromMtl_DiffuseColorPreservedEndToEnd()
+ {
+ // Issue #36 end-to-end: OBJ references MTL with only Kd (no map_Kd).
+ // After ObjParser + MtlParser merge, the material must carry the
+ // correct diffuse color so that ConvertMaterial does NOT produce grey.
+ var tempDir = Path.Combine(Path.GetTempPath(), "obj2tiles_test_" + Guid.NewGuid().ToString("N"));
+ Directory.CreateDirectory(tempDir);
+ try
+ {
+ var mtlPath = Path.Combine(tempDir, "color.mtl");
+ var objPath = Path.Combine(tempDir, "color.obj");
+
+ File.WriteAllText(mtlPath, "newmtl RedMat\nKd 0.9 0.1 0.2\n");
+ File.WriteAllText(objPath,
+ "mtllib color.mtl\nv 0 0 0\nv 1 0 0\nv 0.5 1 0\nusemtl RedMat\nf 1 2 3\n");
+
+ // Simulate the same flow as Converter.Convert:
+ // 1. ObjParser.Parse adds default material
+ // 2. MtlParser adds materials from .mtl file
+ var parser = new ObjParser();
+ var model = parser.Parse(objPath);
+
+ var mtlParser = new MtlParser();
+ var mats = mtlParser.Parse(mtlPath);
+ model.Materials.AddRange(mats);
+
+ // RedMat must be found by name
+ var redMat = model.Materials.FirstOrDefault(m => m.Name == "RedMat");
+ redMat.ShouldNotBeNull();
+ redMat!.Diffuse.ShouldNotBeNull();
+ redMat.Diffuse.Color.Red.ShouldBe(0.9, tolerance: 0.001);
+
+ // ConvertMaterial must produce the correct BaseColorFactor
+ var gltfMat = SilentWave.Obj2Gltf.Converter.ConvertMaterial(
+ redMat, _ => 0);
+ gltfMat.PbrMetallicRoughness.BaseColorFactor[0].ShouldBe(0.9, tolerance: 0.001);
+ gltfMat.PbrMetallicRoughness.BaseColorFactor[1].ShouldBe(0.1, tolerance: 0.001);
+ gltfMat.PbrMetallicRoughness.BaseColorFactor[2].ShouldBe(0.2, tolerance: 0.001);
+ }
+ finally
+ {
+ Directory.Delete(tempDir, true);
+ }
+ }
+}
diff --git a/Obj2Tiles.Test/TestData/comments-and-blanks.obj b/Obj2Tiles.Test/TestData/comments-and-blanks.obj
new file mode 100644
index 0000000..8a925ad
--- /dev/null
+++ b/Obj2Tiles.Test/TestData/comments-and-blanks.obj
@@ -0,0 +1,15 @@
+# File with many comments, blank lines, and whitespace-only lines
+# This is a comment
+
+# Another comment
+
+v 0 0 0
+
+v 1 0 0
+
+# Vertex 3
+v 0.5 1 0
+
+# Face definition
+f 1 2 3
+# End of file
diff --git a/Obj2Tiles.Test/TestData/cube-3d.obj b/Obj2Tiles.Test/TestData/cube-3d.obj
new file mode 100644
index 0000000..b432d71
--- /dev/null
+++ b/Obj2Tiles.Test/TestData/cube-3d.obj
@@ -0,0 +1,28 @@
+# Cube with full 3D geometry for multi-axis split testing
+# 8 vertices, 12 triangle faces
+v -1 -1 -1
+v 1 -1 -1
+v 1 1 -1
+v -1 1 -1
+v -1 -1 1
+v 1 -1 1
+v 1 1 1
+v -1 1 1
+# Front face
+f 5 6 7
+f 5 7 8
+# Back face
+f 1 4 3
+f 1 3 2
+# Left face
+f 1 5 8
+f 1 8 4
+# Right face
+f 2 3 7
+f 2 7 6
+# Top face
+f 4 8 7
+f 4 7 3
+# Bottom face
+f 1 2 6
+f 1 6 5
diff --git a/Obj2Tiles.Test/TestData/cube-colors-3d.obj b/Obj2Tiles.Test/TestData/cube-colors-3d.obj
new file mode 100644
index 0000000..460aba8
--- /dev/null
+++ b/Obj2Tiles.Test/TestData/cube-colors-3d.obj
@@ -0,0 +1,21 @@
+# Cube with vertex colors — 3D for multi-axis split color testing
+v -1 -1 -1 1.0 0.0 0.0
+v 1 -1 -1 0.0 1.0 0.0
+v 1 1 -1 0.0 0.0 1.0
+v -1 1 -1 1.0 1.0 0.0
+v -1 -1 1 1.0 0.0 1.0
+v 1 -1 1 0.0 1.0 1.0
+v 1 1 1 0.5 0.5 0.5
+v -1 1 1 1.0 1.0 1.0
+f 5 6 7
+f 5 7 8
+f 1 4 3
+f 1 3 2
+f 1 5 8
+f 1 8 4
+f 2 3 7
+f 2 7 6
+f 4 8 7
+f 4 7 3
+f 1 2 6
+f 1 6 5
diff --git a/Obj2Tiles.Test/TestData/cube2-normals.obj b/Obj2Tiles.Test/TestData/cube2-normals.obj
new file mode 100644
index 0000000..8b28878
--- /dev/null
+++ b/Obj2Tiles.Test/TestData/cube2-normals.obj
@@ -0,0 +1,65 @@
+mtllib cube.mtl
+o Mesh
+v -1 -1 -1
+v 1 -1 -1
+v 1 1 -1
+v -1 1 -1
+v -1 -1 1
+v 1 -1 1
+v 1 1 1
+v -1 1 1
+vn 0 -1 0.5
+vn 0 1 0.5
+vn -1 0 0.5
+vn 1 0 0
+vn 0 0 1
+vn 0 0 -1
+
+vt 0 0
+vt 0.25 0
+vt 0 0.25
+vt 0.25 0.25
+
+vt 0.25 0
+vt 0.5 0
+vt 0.25 0.25
+vt 0.5 0.25
+
+vt 0.5 0
+vt 0.75 0
+vt 0.5 0.25
+vt 0.75 0.25
+
+vt 0 0.25
+vt 0.25 0.25
+vt 0 0.5
+vt 0.25 0.5
+
+vt 0.25 0.25
+vt 0.5 0.25
+vt 0.25 0.5
+vt 0.5 0.5
+
+vt 0.5 0.25
+vt 0.75 0.25
+vt 0.5 0.5
+vt 0.75 0.5
+
+usemtl Texture
+f 1/1/1 2/2/1 6/4/1
+f 1/1/1 6/4/1 5/3/1
+
+f 3/5/2 4/6/2 8/8/2
+f 3/5/2 8/8/2 7/7/2
+
+f 4/9/3 1/10/3 5/12/3
+f 4/9/3 5/12/3 8/11/3
+
+f 2/13/4 3/14/4 7/16/4
+f 2/13/4 7/16/4 6/15/4
+
+f 5/17/5 6/18/5 7/20/5
+f 5/17/5 7/20/5 8/19/5
+
+f 4/21/6 3/22/6 2/24/6
+f 4/21/6 2/24/6 1/23/6
\ No newline at end of file
diff --git a/Obj2Tiles.Test/TestData/degenerate-faces.obj b/Obj2Tiles.Test/TestData/degenerate-faces.obj
new file mode 100644
index 0000000..4356c2c
--- /dev/null
+++ b/Obj2Tiles.Test/TestData/degenerate-faces.obj
@@ -0,0 +1,7 @@
+# Face with degenerate triangle (all 3 vertices are coincident)
+v 0 0 0
+v 1 0 0
+v 0.5 1 0
+v 0 0 0
+f 1 2 3
+f 1 4 1
diff --git a/Obj2Tiles.Test/TestData/empty.obj b/Obj2Tiles.Test/TestData/empty.obj
new file mode 100644
index 0000000..e69de29
diff --git a/Obj2Tiles.Test/TestData/negative-idx.obj b/Obj2Tiles.Test/TestData/negative-idx.obj
new file mode 100644
index 0000000..7297edc
--- /dev/null
+++ b/Obj2Tiles.Test/TestData/negative-idx.obj
@@ -0,0 +1,7 @@
+# Negative indices test for ObjMesh.ReadFile
+v 0 0 0
+v 1 0 0
+v 0.5 1 0
+v 0 1 0
+f -4 -3 -2
+f -3 -2 -1
diff --git a/Obj2Tiles.Test/TestData/ngon.obj b/Obj2Tiles.Test/TestData/ngon.obj
new file mode 100644
index 0000000..fbbe91d
--- /dev/null
+++ b/Obj2Tiles.Test/TestData/ngon.obj
@@ -0,0 +1,7 @@
+# A pentagon (5 vertices, 1 n-gon face)
+v 0.0 1.0 0.0
+v 0.951 0.309 0.0
+v 0.588 -0.809 0.0
+v -0.588 -0.809 0.0
+v -0.951 0.309 0.0
+f 1 2 3 4 5
diff --git a/Obj2Tiles.Test/TestData/quad-mesh.obj b/Obj2Tiles.Test/TestData/quad-mesh.obj
new file mode 100644
index 0000000..73a46e9
--- /dev/null
+++ b/Obj2Tiles.Test/TestData/quad-mesh.obj
@@ -0,0 +1,6 @@
+# Quad mesh for ObjMesh.ReadFile quad triangulation test
+v 0 0 0
+v 1 0 0
+v 1 1 0
+v 0 1 0
+f 1 2 3 4
diff --git a/Obj2Tiles.Test/TestData/quad-simple.obj b/Obj2Tiles.Test/TestData/quad-simple.obj
new file mode 100644
index 0000000..738ae1b
--- /dev/null
+++ b/Obj2Tiles.Test/TestData/quad-simple.obj
@@ -0,0 +1,6 @@
+# A single quad face (4 vertices)
+v 0 0 0
+v 1 0 0
+v 1 1 0
+v 0 1 0
+f 1 2 3 4
diff --git a/Obj2Tiles.Test/TestData/scientific-notation.obj b/Obj2Tiles.Test/TestData/scientific-notation.obj
new file mode 100644
index 0000000..4dbafca
--- /dev/null
+++ b/Obj2Tiles.Test/TestData/scientific-notation.obj
@@ -0,0 +1,5 @@
+# Coordinates in scientific notation
+v 1.5e-3 2.0e2 -3.1e1
+v 0.0e0 1.0e0 0.0e0
+v 5.0e-1 0.0e0 1.0e-1
+f 1 2 3
diff --git a/Obj2Tiles.Test/TestData/triangle-normals.obj b/Obj2Tiles.Test/TestData/triangle-normals.obj
new file mode 100644
index 0000000..034a299
--- /dev/null
+++ b/Obj2Tiles.Test/TestData/triangle-normals.obj
@@ -0,0 +1,6 @@
+# Triangle with normals (f v//vn format)
+v 0 0 0
+v 1 0 0
+v 0.5 1 0
+vn 0 0 1
+f 1//1 2//1 3//1
diff --git a/Obj2Tiles.Test/TestData/triangle.obj b/Obj2Tiles.Test/TestData/triangle.obj
new file mode 100644
index 0000000..5e7ef42
--- /dev/null
+++ b/Obj2Tiles.Test/TestData/triangle.obj
@@ -0,0 +1,6 @@
+v 0 0 0
+v 0.5 0 0
+v 1 0 0
+v 0.5 1 0
+f 1 2 4
+f 2 3 4
diff --git a/Obj2Tiles/Options.cs b/Obj2Tiles/Options.cs
index 4fdc394..3cc2e7f 100644
--- a/Obj2Tiles/Options.cs
+++ b/Obj2Tiles/Options.cs
@@ -6,32 +6,32 @@ namespace Obj2Tiles;
public sealed class Options
{
[Value(0, MetaName = "Input", Required = true, HelpText = "Input OBJ file.")]
- public string Input { get; set; }
+ public string Input { get; set; } = null!;
[Value(1, MetaName = "Output", Required = true, HelpText = "Output folder.")]
- public string Output { get; set; }
+ public string Output { get; set; } = null!;
[Option('s', "stage", Required = false, HelpText = "Stage to stop at (Decimation, Splitting, Tiling)", Default = Stage.Tiling)]
public Stage StopAt { get; set; }
[Option('d', "divisions", Required = false, HelpText = "How many tiles divisions", Default = 2)]
public int Divisions { get; set; }
-
+
[Option('z', "zsplit", Required = false, HelpText = "Splits along z-axis too", Default = false)]
- public bool ZSplit { get; set; }
-
+ public bool ZSplit { get; set; }
+
[Option('l', "lods", Required = false, HelpText = "How many levels of details", Default = 3)]
public int LODs { get; set; }
[Option('k', "keeptextures", Required = false, HelpText = "Keeps original textures", Default = false)]
public bool KeepOriginalTextures { get; set; }
-
+
[Option("lat", Required = false, HelpText = "Latitude of the mesh", Default = null)]
public double? Latitude { get; set; }
-
+
[Option("lon", Required = false, HelpText = "Longitude of the mesh", Default = null)]
public double? Longitude { get; set; }
-
+
[Option("alt", Required = false, HelpText = "Altitude of the mesh (meters)", Default = 0)]
public double Altitude { get; set; }
@@ -40,10 +40,10 @@ public sealed class Options
[Option('e',"error", Required = false, HelpText = "Base error for root node", Default = 100.0)]
public double BaseError { get; set; }
-
+
[Option("use-system-temp", Required = false, HelpText = "Uses the system temp folder", Default = false)]
public bool UseSystemTempFolder { get; set; }
-
+
[Option("keep-intermediate", Required = false, HelpText = "Keeps the intermediate files (do not cleanup)", Default = false)]
public bool KeepIntermediateFiles { get; set; }
diff --git a/Obj2Tiles/Properties/PublishProfiles/linux-arm64.pubxml b/Obj2Tiles/Properties/PublishProfiles/linux-arm64.pubxml
index 38e2675..4c53525 100644
--- a/Obj2Tiles/Properties/PublishProfiles/linux-arm64.pubxml
+++ b/Obj2Tiles/Properties/PublishProfiles/linux-arm64.pubxml
@@ -1,14 +1,14 @@
Release
Any CPU
- bin\Release\net9.0\publish\linux-arm64\
+ bin\Release\net10.0\publish\linux-arm64\
FileSystem
- net9.0
+ net10.0
linux-arm64
true
True
diff --git a/Obj2Tiles/Properties/PublishProfiles/linux-x64.pubxml b/Obj2Tiles/Properties/PublishProfiles/linux-x64.pubxml
index 186d4c5..227920d 100644
--- a/Obj2Tiles/Properties/PublishProfiles/linux-x64.pubxml
+++ b/Obj2Tiles/Properties/PublishProfiles/linux-x64.pubxml
@@ -1,14 +1,14 @@
Release
Any CPU
- bin\Release\net9.0\publish\linux-x64\
+ bin\Release\net10.0\publish\linux-x64\
FileSystem
- net9.0
+ net10.0
linux-x64
true
True
diff --git a/Obj2Tiles/Properties/PublishProfiles/osx-x64.pubxml b/Obj2Tiles/Properties/PublishProfiles/osx-x64.pubxml
index 1419161..d078b9b 100644
--- a/Obj2Tiles/Properties/PublishProfiles/osx-x64.pubxml
+++ b/Obj2Tiles/Properties/PublishProfiles/osx-x64.pubxml
@@ -1,14 +1,14 @@
Release
Any CPU
- bin\Release\net9.0\publish\osx-x64\
+ bin\Release\net10.0\publish\osx-x64\
FileSystem
- net9.0
+ net10.0
osx-x64
true
True
diff --git a/Obj2Tiles/Properties/PublishProfiles/win-arm64.pubxml b/Obj2Tiles/Properties/PublishProfiles/win-arm64.pubxml
index 0b693c0..af00cee 100644
--- a/Obj2Tiles/Properties/PublishProfiles/win-arm64.pubxml
+++ b/Obj2Tiles/Properties/PublishProfiles/win-arm64.pubxml
@@ -1,14 +1,14 @@
Release
Any CPU
- bin\Release\net9.0\publish\win-arm64\
+ bin\Release\net10.0\publish\win-arm64\
FileSystem
- net9.0
+ net10.0
win-arm64
true
True
diff --git a/Obj2Tiles/Properties/PublishProfiles/win-x64.pubxml b/Obj2Tiles/Properties/PublishProfiles/win-x64.pubxml
index 5e6d7c9..7a85fa3 100644
--- a/Obj2Tiles/Properties/PublishProfiles/win-x64.pubxml
+++ b/Obj2Tiles/Properties/PublishProfiles/win-x64.pubxml
@@ -1,14 +1,14 @@
Release
Any CPU
- bin\Release\net9.0\publish\win-x64\
+ bin\Release\net10.0\publish\win-x64\
FileSystem
- net9.0
+ net10.0
win-x64
true
True
diff --git a/Obj2Tiles/Stages/DecimationStage.cs b/Obj2Tiles/Stages/DecimationStage.cs
index aa2ed95..541b3f3 100644
--- a/Obj2Tiles/Stages/DecimationStage.cs
+++ b/Obj2Tiles/Stages/DecimationStage.cs
@@ -62,7 +62,7 @@ private static void InternalDecimate(ObjMesh sourceObjMesh, string destPath, flo
var sourceTexCoords3D = sourceObjMesh.TexCoords3D;
var sourceSubMeshIndices = sourceObjMesh.SubMeshIndices;
- var sourceMesh = new Mesh(sourceVertices, sourceSubMeshIndices)
+ var sourceMesh = new Mesh(sourceVertices!, sourceSubMeshIndices!)
{
Normals = sourceNormals,
Colors = sourceObjMesh.VertexColors
@@ -77,11 +77,11 @@ private static void InternalDecimate(ObjMesh sourceObjMesh, string destPath, flo
sourceMesh.SetUVs(0, sourceTexCoords3D);
}
- var currentTriangleCount = sourceSubMeshIndices.Sum(t => t.Length / 3);
+ var currentTriangleCount = sourceSubMeshIndices!.Sum(t => t.Length / 3);
var targetTriangleCount = (int)Math.Ceiling(currentTriangleCount * quality);
Console.WriteLine(" ?> Input: {0} vertices, {1} triangles (target {2})",
- sourceVertices.Length, currentTriangleCount, targetTriangleCount);
+ sourceVertices!.Length, currentTriangleCount, targetTriangleCount);
var stopwatch = new Stopwatch();
stopwatch.Reset();
diff --git a/Obj2Tiles/Stages/Model/DecimateResult.cs b/Obj2Tiles/Stages/Model/DecimateResult.cs
index 2c2e6b8..e462416 100644
--- a/Obj2Tiles/Stages/Model/DecimateResult.cs
+++ b/Obj2Tiles/Stages/Model/DecimateResult.cs
@@ -4,7 +4,7 @@ namespace Obj2Tiles.Stages.Model
{
public class DecimateResult
{
- public string[] DestFiles { get; set; }
- public Box3 Bounds { get; set; }
+ public string[] DestFiles { get; set; } = null!;
+ public Box3 Bounds { get; set; } = default!;
}
}
\ No newline at end of file
diff --git a/Obj2Tiles/Stages/Model/ObjMesh.cs b/Obj2Tiles/Stages/Model/ObjMesh.cs
index 30f3039..5144110 100644
--- a/Obj2Tiles/Stages/Model/ObjMesh.cs
+++ b/Obj2Tiles/Stages/Model/ObjMesh.cs
@@ -93,15 +93,15 @@ public override string ToString()
#region Fields
- private Vector3d[] vertices = null;
- private Vector3[] normals = null;
- private Vector4[] vertexColors = null;
- private Vector2[] texCoords2D = null;
- private Vector3[] texCoords3D = null;
- private int[][] subMeshIndices = null;
- private string[] subMeshMaterials = null;
+ private Vector3d[]? vertices = null;
+ private Vector3[]? normals = null;
+ private Vector4[]? vertexColors = null;
+ private Vector2[]? texCoords2D = null;
+ private Vector3[]? texCoords3D = null;
+ private int[][]? subMeshIndices = null;
+ private string[]? subMeshMaterials = null;
- private string[] materialLibraries = null;
+ private string[]? materialLibraries = null;
#endregion
@@ -110,7 +110,7 @@ public override string ToString()
///
/// Gets or sets the vertices for this mesh.
///
- public Vector3d[] Vertices
+ public Vector3d[]? Vertices
{
get => vertices;
set => vertices = value;
@@ -119,7 +119,7 @@ public Vector3d[] Vertices
///
/// Gets or sets the normals for this mesh.
///
- public Vector3[] Normals
+ public Vector3[]? Normals
{
get => normals;
set => normals = value;
@@ -128,7 +128,7 @@ public Vector3[] Normals
///
/// Gets or sets the vertex colors (RGBA) for this mesh.
///
- public Vector4[] VertexColors
+ public Vector4[]? VertexColors
{
get => vertexColors;
set => vertexColors = value;
@@ -137,7 +137,7 @@ public Vector4[] VertexColors
///
/// Gets or sets the 2D texture coordinates for this mesh.
///
- public Vector2[] TexCoords2D
+ public Vector2[]? TexCoords2D
{
get => texCoords2D;
set
@@ -150,7 +150,7 @@ public Vector2[] TexCoords2D
///
/// Gets or sets the 3D texture coordinates for this mesh.
///
- public Vector3[] TexCoords3D
+ public Vector3[]? TexCoords3D
{
get => texCoords3D;
set
@@ -170,7 +170,7 @@ public Vector3[] TexCoords3D
/// Note that setting this will remove any existing sub-meshes and turn it into just one sub-mesh.
///
[Obsolete("Prefer to use the 'SubMeshIndices' property instead.", false)]
- public int[] Indices
+ public int[]? Indices
{
get
{
@@ -210,7 +210,7 @@ public int[] Indices
///
/// Gets or sets the indices divided by sub-meshes.
///
- public int[][] SubMeshIndices
+ public int[][]? SubMeshIndices
{
get => subMeshIndices;
set
@@ -231,7 +231,7 @@ public int[][] SubMeshIndices
///
/// Gets or sets the names of each sub-mesh material.
///
- public string[] SubMeshMaterials
+ public string[]? SubMeshMaterials
{
get => subMeshMaterials;
set
@@ -252,13 +252,13 @@ public string[] SubMeshMaterials
///
/// Gets or sets the paths to material libraries used by this mesh.
///
- public string[] MaterialLibraries
+ public string[]? MaterialLibraries
{
get => materialLibraries;
set => materialLibraries = value;
}
- public Box3 Bounds { get; private set; }
+ public Box3 Bounds { get; private set; } = default!;
#endregion
@@ -308,13 +308,13 @@ public void ReadFile(string path)
{
var materialLibraryList = new List();
var readVertexList = new List(VertexInitialCapacity);
- List readColorList = null;
- List readNormalList = null;
- List readTexCoordList = null;
+ List? readColorList = null;
+ List? readNormalList = null;
+ List? readTexCoordList = null;
var vertexList = new List(VertexInitialCapacity);
- List colorList = null;
- List normalList = null;
- List texCoordList = null;
+ List? colorList = null;
+ List? normalList = null;
+ List? texCoordList = null;
var triangleIndexList = new List(IndexInitialCapacity);
var subMeshIndicesList = new List();
var subMeshMaterialList = new List();
@@ -322,9 +322,9 @@ public void ReadFile(string path)
var tempFaceList = new List(6);
bool texCoordsAre3D = false;
- string currentGroup = null;
- string currentObject = null;
- string currentMaterial = null;
+ string? currentGroup = null;
+ string? currentObject = null;
+ string? currentMaterial = null;
int newFaceIndex = 0;
double minX = double.MaxValue, maxX = double.MinValue;
@@ -333,7 +333,7 @@ public void ReadFile(string path)
using (var reader = File.OpenText(path))
{
- string line;
+ string? line;
while ((line = reader.ReadLine()) != null)
{
if (line.Length == 0 || line[0] == '#')
@@ -442,7 +442,7 @@ public void ReadFile(string path)
int.TryParse(word1, out vertexIndex);
int.TryParse(word2, out texIndex);
vertexIndex = ShiftIndex(vertexIndex, readVertexList.Count);
- texIndex = ShiftIndex(texIndex, readTexCoordList.Count);
+ texIndex = ShiftIndex(texIndex, readTexCoordList!.Count);
normalIndex = -1;
}
else if (slashCount == 2)
@@ -458,14 +458,14 @@ public void ReadFile(string path)
vertexIndex = ShiftIndex(vertexIndex, readVertexList.Count);
if (hasTexCoord)
{
- texIndex = ShiftIndex(texIndex, readTexCoordList.Count);
+ texIndex = ShiftIndex(texIndex, readTexCoordList!.Count);
}
else
{
texIndex = -1;
}
- normalIndex = ShiftIndex(normalIndex, readNormalList.Count);
+ normalIndex = ShiftIndex(normalIndex, readNormalList!.Count);
}
else
{
@@ -518,7 +518,7 @@ public void ReadFile(string path)
{
texCoordList ??= new List(VertexInitialCapacity);
- if (texIndex >= 0 && texIndex < readTexCoordList.Count)
+ if (texIndex >= 0 && texIndex < readTexCoordList!.Count)
{
texCoordList.Add(readTexCoordList[texIndex]);
}
@@ -586,14 +586,15 @@ public void ReadFile(string path)
{
subMeshIndicesList.Add(triangleIndexList.ToArray());
triangleIndexList.Clear();
-
- /*
- if (currentMaterial == null)
- {
- subMeshMaterialList.Add("none");
- }*/
}
+ // Fix Issue #54: Remove orphan material entries that have no corresponding
+ // index arrays. This happens when a trailing 'usemtl' appears without
+ // subsequent faces, causing a mismatch between SubMeshMaterials and
+ // SubMeshIndices that crashes WriteFile.
+ while (subMeshMaterialList.Count > subMeshIndicesList.Count)
+ subMeshMaterialList.RemoveAt(subMeshMaterialList.Count - 1);
+
int subMeshCount = subMeshIndicesList.Count;
bool hasNormals = (readNormalList != null);
bool hasTexCoords = (readTexCoordList != null);
@@ -621,17 +622,17 @@ public void ReadFile(string path)
processedVertexList.Add(vertexList[index]);
if (hasColors)
{
- processedColorList.Add(colorList[index]);
+ processedColorList!.Add(colorList![index]);
}
if (hasNormals)
{
- processedNormalList.Add(normalList[index]);
+ processedNormalList!.Add(normalList![index]);
}
if (hasTexCoords)
{
- processedTexCoordList.Add(texCoordList[index]);
+ processedTexCoordList!.Add(texCoordList![index]);
}
mappedIndex = processedVertexList.Count - 1;
@@ -710,13 +711,13 @@ public void WriteFile(string path)
writer.WriteLine();
}
- WriteVertices(writer, vertices, vertexColors);
+ WriteVertices(writer, vertices!, vertexColors);
WriteNormals(writer, normals);
WriteTextureCoords(writer, texCoords2D, texCoords3D);
bool hasTexCoords = (texCoords2D != null || texCoords3D != null);
bool hasNormals = (normals != null);
- WriteSubMeshes(writer, subMeshIndices, subMeshMaterials, hasTexCoords, hasNormals);
+ WriteSubMeshes(writer, subMeshIndices!, subMeshMaterials, hasTexCoords, hasNormals);
}
}
@@ -726,7 +727,7 @@ public void WriteFile(string path)
#region Private Methods
- private static void WriteVertices(TextWriter writer, Vector3d[] vertices, Vector4[] colors)
+ private static void WriteVertices(TextWriter writer, Vector3d[] vertices, Vector4[]? colors)
{
bool hasColors = (colors != null && colors.Length == vertices.Length);
for (int i = 0; i < vertices.Length; i++)
@@ -740,7 +741,7 @@ private static void WriteVertices(TextWriter writer, Vector3d[] vertices, Vector
writer.Write(vertex.z.ToString("g", CultureInfo.InvariantCulture));
if (hasColors)
{
- var c = colors[i];
+ var c = colors![i];
writer.Write(' ');
writer.Write(c.x.ToString("g", CultureInfo.InvariantCulture));
writer.Write(' ');
@@ -757,7 +758,7 @@ private static void WriteVertices(TextWriter writer, Vector3d[] vertices, Vector
}
}
- private static void WriteNormals(TextWriter writer, Vector3[] normals)
+ private static void WriteNormals(TextWriter writer, Vector3[]? normals)
{
if (normals == null)
return;
@@ -775,7 +776,7 @@ private static void WriteNormals(TextWriter writer, Vector3[] normals)
}
}
- private static void WriteTextureCoords(TextWriter writer, Vector2[] texCoords2D, Vector3[] texCoords3D)
+ private static void WriteTextureCoords(TextWriter writer, Vector2[]? texCoords2D, Vector3[]? texCoords3D)
{
if (texCoords2D != null)
{
@@ -805,7 +806,7 @@ private static void WriteTextureCoords(TextWriter writer, Vector2[] texCoords2D,
}
}
- private static void WriteSubMeshes(TextWriter writer, int[][] subMeshIndices, string[] subMeshMaterials,
+ private static void WriteSubMeshes(TextWriter writer, int[][] subMeshIndices, string[]? subMeshMaterials,
bool hasTexCoords, bool hasNormals)
{
for (int subMeshIndex = 0; subMeshIndex < subMeshIndices.Length; subMeshIndex++)
diff --git a/Obj2Tiles/Stages/SplitStage.cs b/Obj2Tiles/Stages/SplitStage.cs
index 89756c7..fc866a2 100644
--- a/Obj2Tiles/Stages/SplitStage.cs
+++ b/Obj2Tiles/Stages/SplitStage.cs
@@ -10,14 +10,14 @@ public static partial class StagesFacade
public static async Task[]> Split(string[] sourceFiles, string destFolder, int divisions,
bool zsplit, Box3 bounds, bool keepOriginalTextures = false)
{
-
+
var tasks = new List>>();
for (var index = 0; index < sourceFiles.Length; index++)
{
var file = sourceFiles[index];
var dest = Path.Combine(destFolder, "LOD-" + index);
-
+
// We compress textures except the first one (the original one)
var textureStrategy = keepOriginalTextures ? TexturesStrategy.KeepOriginal :
index == 0 ? TexturesStrategy.Repack : TexturesStrategy.RepackCompressed;
@@ -42,7 +42,7 @@ public static async Task> Split(string sourcePath, stri
var tilesBounds = new Dictionary();
Directory.CreateDirectory(destPath);
-
+
Console.WriteLine($" -> Loading OBJ file \"{sourcePath}\"");
sw.Start();
@@ -57,13 +57,13 @@ public static async Task> Split(string sourcePath, stri
if (mesh is MeshT t)
t.TexturesStrategy = TexturesStrategy.Compress;
-
+
mesh.WriteObj(Path.Combine(destPath, $"{mesh.Name}.obj"));
-
+
return new Dictionary { { mesh.Name, mesh.Bounds } };
-
+
}
-
+
Console.WriteLine(
$" -> Splitting with a depth of {divisions}{(zSplit ? " with z-split" : "")}");
@@ -73,7 +73,7 @@ public static async Task> Split(string sourcePath, stri
int count;
- if (bounds != null)
+ if (bounds is not null)
{
count = zSplit
? await MeshUtils.RecurseSplitXYZ(mesh, divisions, bounds, meshes)
diff --git a/Obj2Tiles/Tiles/B3dm.cs b/Obj2Tiles/Tiles/B3dm.cs
index 42bbb01..be671ff 100644
--- a/Obj2Tiles/Tiles/B3dm.cs
+++ b/Obj2Tiles/Tiles/B3dm.cs
@@ -24,7 +24,7 @@ public B3dm(byte[] glb): this()
public byte[] FeatureTableBinary { get; set; }
public string BatchTableJson { get; set; }
public byte[] BatchTableBinary { get; set; }
- public byte[] GlbData { get; set; }
+ public byte[] GlbData { get; set; } = [];
public byte[] ToBytes()
{