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() {