diff --git a/src/NwcIO/Model/Sectioning/NwcMeshClipper.cs b/src/NwcIO/Model/Sectioning/NwcMeshClipper.cs index 6928958..30cbec8 100644 --- a/src/NwcIO/Model/Sectioning/NwcMeshClipper.cs +++ b/src/NwcIO/Model/Sectioning/NwcMeshClipper.cs @@ -21,6 +21,14 @@ public static NwcMesh Clip(NwcMesh mesh, IReadOnlyList planes) return mesh; } + // The planes are in world space and the vertices in the mesh's local space: cut with the planes mapped into + // local space, so the result keeps its vertices and Transform + if (mesh.Transform is not null) + { + double[] m = mesh.Transform.ToMatrix4x4(); + activePlanes = activePlanes.Select(p => ToLocalSpace(p, m)).ToList(); + } + var currentVerts = new List(mesh.Vertices); var currentIndices = new List(mesh.Indices); @@ -57,6 +65,30 @@ public static NwcMesh Clip(NwcMesh mesh, NwcSectionBox sectionBox) return Clip(mesh, sectionBox.ToSectionPlanes()); } + /// + /// Maps a world-space plane n·x = d (n normalized, as reads it) into the local space + /// of a mesh whose world position is x = M·l + t, with laid out as in + /// : (Mᵀn)·l = d − n·t, then normalized. + /// + private static NwcSectionPlane ToLocalSpace(NwcSectionPlane plane, double[] m) + { + var n = plane.Normal.Normalize(); + var localNormal = new Vector3d( + m[0] * n.X + m[1] * n.Y + m[2] * n.Z, + m[4] * n.X + m[5] * n.Y + m[6] * n.Z, + m[8] * n.X + m[9] * n.Y + m[10] * n.Z); + double localDistance = plane.Distance - (n.X * m[12] + n.Y * m[13] + n.Z * m[14]); + + double length = localNormal.Length; + if (!(length > 1e-12) || !double.IsFinite(length) || !double.IsFinite(localDistance)) + { + // Like GetWorldVertices, which keeps the local vertices for a transform it can't apply + return plane; + } + + return new NwcSectionPlane(plane.PlaneIndex, localNormal / length, localDistance / length, plane.IsEnabled, plane.Alignment); + } + private static void ClipMeshByPlane( List inVerts, List inIndices, diff --git a/tests/NwcIO.Tests/SectioningTests.cs b/tests/NwcIO.Tests/SectioningTests.cs index 4ec686d..0465006 100644 --- a/tests/NwcIO.Tests/SectioningTests.cs +++ b/tests/NwcIO.Tests/SectioningTests.cs @@ -155,6 +155,49 @@ public void NwcMeshClipper_Clip_DisabledPlane_LeavesMeshUnchanged() Assert.Equal(mesh.Indices.Count, clipped.Indices.Count); } + [Fact] + public void NwcMeshClipper_Clip_TranslatedMesh_CutsInWorldSpace() + { + var mesh = new NwcMesh + { + Id = 1, + Vertices = [new(0, 0, 0), new(1, 0, 0), new(0, 1, 0)], + Indices = [0, 1, 2], + Transform = new NwcTransform { Id = 1, Translation = new Vector3d(0, 0, 100) }, + }; + + var above = NwcMeshClipper.Clip(mesh, [NwcSectionPlane.CreateBottom(50)]); + var below = NwcMeshClipper.Clip(mesh, [NwcSectionPlane.CreateTop(50)]); + + Assert.Equal(1, above.TriangleCount); + Assert.Same(mesh.Transform, above.Transform); + Assert.All(above.GetWorldVertices(), v => Assert.Equal(100, v.Z, 9)); + Assert.Equal(0, below.TriangleCount); + } + + [Fact] + public void NwcMeshClipper_Clip_RotatedMesh_CutsInWorldSpace() + { + // A quarter turn about Z maps local X onto world Y: the triangle spans world Y 2..4 + double s = Math.Sqrt(0.5); + var mesh = new NwcMesh + { + Id = 1, + Vertices = [new(2, 0, 0), new(4, 0, 0), new(4, 0, 1)], + Indices = [0, 1, 2], + Transform = new NwcTransform { Id = 1, Translation = new Vector3d(10, 0, 0), Rotation = new Quaterniond(0, 0, s, s) }, + }; + + var clipped = NwcMeshClipper.Clip(mesh, [NwcSectionPlane.CreateFront(3)]); + + Assert.True(clipped.TriangleCount > 0); + var world = clipped.GetWorldVertices(); + Assert.All(world, v => Assert.True(v.Y >= 3 - 1e-9, $"Y = {v.Y}")); + Assert.Contains(world, v => Math.Abs(v.Y - 4) < 1e-9); + Assert.Contains(world, v => Math.Abs(v.Y - 3) < 1e-9); + Assert.All(world, v => Assert.Equal(10, v.X, 9)); + } + [Theory] [InlineData(0, true)] [InlineData(1, true)]