diff --git a/CHANGELOG.md b/CHANGELOG.md index 772ded7cf..249351145 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## 2.4.0 + +### Fixed + +- CSG `Union` produced corrupt geometry at large coordinates (e.g., survey-scale) due to BSP plane-side precision loss. +- LibTess tessellation threw `NullReferenceException` for geometry that produced contour synthesis points (T-junctions, self-intersections). +- Post-union polygon tessellation welded unrelated vertex corners at different positions when CSG `Shared.Tag` collided across faces. + ## 2.1.0 ### Added diff --git a/Elements.MEP/test/SerializationTests.cs b/Elements.MEP/test/SerializationTests.cs index 161f2f4c8..44508e9f2 100644 --- a/Elements.MEP/test/SerializationTests.cs +++ b/Elements.MEP/test/SerializationTests.cs @@ -6,6 +6,7 @@ using Elements.Flow; using Elements.Geometry; using Elements.Geometry.Solids; +using Elements.Serialization.glTF; using Xunit; namespace Elements.MEP.Tests @@ -37,6 +38,59 @@ public void TreeIsInitializedWhenDeserialized() Assert.True(deserializedTree._alreadyTriedInit); } + [Fact] + public void RoofDrain_UpdateRepresentations_UnionTessellatesToGlb() + { + var drain = CreateRoofDrainForUnionTest(0.55); + drain.UpdateRepresentations(); + + Assert.False(drain.Representation.SkipCSGUnion); + Assert.Equal(2, drain.Representation.SolidOperations.Count); + + var model = new Model(); + model.AddElement(drain); + var glb = model.ToGlTF(updateElementsRepresentations: false); + Assert.NotNull(glb); + Assert.NotEmpty(glb); + } + + [Fact] + public void Tree_UpdateRepresentations_UnionTessellates() + { + var tree = FittingsTests.GetSampleTreeWithTrunkBelow(); + tree.UpdateRepresentations(); + + Assert.False(tree.Representation.SkipCSGUnion); + Assert.NotEmpty(tree.Representation.SolidOperations); + + tree.UpdateBoundsAndComputeSolid(); + Assert.True(tree.Bounds.Max.Z - tree.Bounds.Min.Z > 0.01); + + var model = new Model(); + model.AddElement(tree); + var glb = model.ToGlTF(updateElementsRepresentations: false); + Assert.NotNull(glb); + Assert.NotEmpty(glb); + } + + private static RoofDrain CreateRoofDrainForUnionTest(double diameter) + { + var drain = new RoofDrain( + diameter, + 0, + 0, + 0, + false, + 0, + Guid.NewGuid().ToString(), + id: Guid.NewGuid(), + name: "RD-test"); + drain.ConnectorLength = 0.2764999948978424; + drain.ConnectorOuterDiameter = 0.075; + drain.HorizontalConnectorVector = new Vector3(0.5, 0, 0); + return drain; + } + [Fact] public void RoofDrainTests() { diff --git a/Elements/lib/Csg.dll b/Elements/lib/Csg.dll index 40f11f70d..d1947f6e3 100644 Binary files a/Elements/lib/Csg.dll and b/Elements/lib/Csg.dll differ diff --git a/Elements/src/Geometry/CsgExtensions.cs b/Elements/src/Geometry/CsgExtensions.cs index f40260cd9..3a2faab2a 100644 --- a/Elements/src/Geometry/CsgExtensions.cs +++ b/Elements/src/Geometry/CsgExtensions.cs @@ -141,7 +141,7 @@ private static void AddToMesh(this Csg.Polygon p, ref Mesh mesh) }; tess.AddContour(p.Vertices.ToContourVertices()); - tess.Tessellate(WindingRule.Positive, ElementType.Polygons, 3); + tess.Tessellate(WindingRule.Positive, ElementType.Polygons, 3, CombineCallbacks.CsgTexTagCombine); for (var i = 0; i < tess.ElementCount; i++) { @@ -153,9 +153,9 @@ private static void AddToMesh(this Csg.Polygon p, ref Mesh mesh) var b = t2.Position.ToVector3(); var c = t3.Position.ToVector3(); - var dataA = ((Csg.Vector2D, int))t1.Data; - var dataB = ((Csg.Vector2D, int))t2.Data; - var dataC = ((Csg.Vector2D, int))t3.Data; + var dataA = TexTagOrDefault(t1.Data); + var dataB = TexTagOrDefault(t2.Data); + var dataC = TexTagOrDefault(t3.Data); var v1 = mesh.FindOrCreateVertex(a, dataA.Item2, dataA.Item1.ToUV(), n); var v2 = mesh.FindOrCreateVertex(b, dataB.Item2, dataB.Item1.ToUV(), n); @@ -170,6 +170,11 @@ private static void AddToMesh(this Csg.Polygon p, ref Mesh mesh) } } + private static (Csg.Vector2D, int) TexTagOrDefault(object data) + { + return data is ValueTuple t ? t : (new Csg.Vector2D(0, 0), 0); + } + private static Vector3 ToElementsVector(this Csg.Vector3D v) { return new Vector3(v.X, v.Y, v.Z); @@ -244,7 +249,7 @@ internal static ContourVertex[] ToContourVertexArray(this IList vert var cv = new ContourVertex { Position = new Vec3 { X = v.Pos.X, Y = v.Pos.Y, Z = v.Pos.Z }, - Data = (v.Tex.ToUV(), (uint)v.Tag, faceId, solidId) + Data = new CsgVertexData(v.Tex.ToUV(), (uint)v.Tag, faceId, solidId) }; contour[i] = cv; } diff --git a/Elements/src/Geometry/Solids/Solid.cs b/Elements/src/Geometry/Solids/Solid.cs index a8dfad0d6..c02d1c30f 100644 --- a/Elements/src/Geometry/Solids/Solid.cs +++ b/Elements/src/Geometry/Solids/Solid.cs @@ -917,8 +917,8 @@ internal Csg.Solid ToCsg() } else { - var vData1 = ((UV uv, uint tag, uint faceId, uint solidId))v1.Data; - av = csgVertices[(int)vData1.tag]; + var vData1 = (CsgVertexData)v1.Data; + av = csgVertices[(int)vData1.Tag]; } if (v2.Data == null) @@ -928,8 +928,8 @@ internal Csg.Solid ToCsg() } else { - var vData2 = ((UV uv, uint tag, uint faceId, uint solidId))v2.Data; - bv = csgVertices[(int)vData2.tag]; + var vData2 = (CsgVertexData)v2.Data; + bv = csgVertices[(int)vData2.Tag]; } if (v3.Data == null) @@ -939,8 +939,8 @@ internal Csg.Solid ToCsg() } else { - var vData3 = ((UV uv, uint tag, uint faceId, uint solidId))v3.Data; - cv = csgVertices[(int)vData3.tag]; + var vData3 = (CsgVertexData)v3.Data; + cv = csgVertices[(int)vData3.Tag]; } // Don't allow us to create a csg that has zero diff --git a/Elements/src/Geometry/Solids/SolidExtensions.cs b/Elements/src/Geometry/Solids/SolidExtensions.cs index f7ec8c2c9..ec39adae9 100644 --- a/Elements/src/Geometry/Solids/SolidExtensions.cs +++ b/Elements/src/Geometry/Solids/SolidExtensions.cs @@ -22,7 +22,7 @@ internal static ContourVertex[] ToContourVertexArray(this Loop loop, uint faceId var cv = new ContourVertex { Position = new Vec3 { X = p.X, Y = p.Y, Z = p.Z }, - Data = (default(UV), edge.Vertex.Id, faceId, solidId) + Data = new Tessellation.CsgVertexData(default, edge.Vertex.Id, faceId, solidId) }; contour[i] = cv; } diff --git a/Elements/src/Geometry/Tessellation/CombineCallbacks.cs b/Elements/src/Geometry/Tessellation/CombineCallbacks.cs new file mode 100644 index 000000000..2fa016890 --- /dev/null +++ b/Elements/src/Geometry/Tessellation/CombineCallbacks.cs @@ -0,0 +1,88 @@ +using System; +using System.Threading; +using LibTessDotNet.Double; + +namespace Elements.Geometry.Tessellation +{ + /// + /// Shared factories for tessellators. + /// + /// LibTess can synthesize new vertices at contour intersection / T-junction points + /// during . + /// Without a callback, those synthetic vertices end up with Data == null, which + /// later trips a in + /// when it unboxes + /// v.Data to the expected shape. + /// + /// Each callback interpolates the UV from the input vertices, preserves the + /// faceId/solidId of the first non-null input (these are constant within a single + /// face's tessellation), and assigns a unique synthetic tag drawn from a process-global + /// monotonically increasing counter starting in the upper half of the uint range so it + /// can never collide with the small, sequential tags emitted by the CSG library. + /// + internal static class CombineCallbacks + { + // Start synthetic tags in the upper half of the uint range so they cannot + // collide with Csg.Vertex.Tag values, which are sequentially allocated from 0. + private static long _dataCombineCounter = 0x80000000L; + private static long _csgTexTagCombineCounter = 0x90000000L; + + /// + /// Combine callback for tessellation paths that attach + /// to . + /// + internal static CombineCallback DataCombine { get; } = CombineData; + + /// + /// Combine callback for legacy mesh tessellation paths that store + /// (Csg.Vector2D tex, int tag) on . + /// + internal static CombineCallback CsgTexTagCombine { get; } = CombineCsgTexTag; + + private static object CombineData(Vec3 position, object[] data, double[] weights) + { + var uvU = 0.0; + var uvV = 0.0; + uint faceId = 0; + uint solidId = 0; + bool seenInput = false; + + for (var i = 0; i < data.Length; i++) + { + if (data[i] is CsgVertexData t) + { + var w = weights[i]; + uvU += t.Uv.U * w; + uvV += t.Uv.V * w; + if (!seenInput) + { + faceId = t.FaceId; + solidId = t.SolidId; + seenInput = true; + } + } + } + + var tag = (uint)Interlocked.Increment(ref _dataCombineCounter); + return new CsgVertexData(new UV(uvU, uvV), tag, faceId, solidId); + } + + private static object CombineCsgTexTag(Vec3 position, object[] data, double[] weights) + { + var texX = 0.0; + var texY = 0.0; + for (var i = 0; i < data.Length; i++) + { + if (data[i] is ValueTuple t) + { + var w = weights[i]; + texX += t.Item1.X * w; + texY += t.Item1.Y * w; + } + } + + var tag = (int)Interlocked.Increment(ref _csgTexTagCombineCounter); + return (new Csg.Vector2D(texX, texY), tag); + } + } +} diff --git a/Elements/src/Geometry/Tessellation/CsgPolygonTessAdapter.cs b/Elements/src/Geometry/Tessellation/CsgPolygonTessAdapter.cs index ab7b741fa..c2e3765be 100644 --- a/Elements/src/Geometry/Tessellation/CsgPolygonTessAdapter.cs +++ b/Elements/src/Geometry/Tessellation/CsgPolygonTessAdapter.cs @@ -35,7 +35,11 @@ public Tess GetTess() }; tess.AddContour(polygon.Vertices.ToContourVertexArray(faceId, solidId)); - tess.Tessellate(WindingRule.Positive, ElementType.Polygons, 3); + // Register a combine callback so vertices synthesized by LibTess at + // intersection / T-junction points carry the same CsgVertexData shape as + // the input vertices. Without this, the synthesized vertices' Data field + // is null and downstream packing throws a NullReferenceException. + tess.Tessellate(WindingRule.Positive, ElementType.Polygons, 3, CombineCallbacks.DataCombine); return tess; } } diff --git a/Elements/src/Geometry/Tessellation/CsgTessellationTargetProvider.cs b/Elements/src/Geometry/Tessellation/CsgTessellationTargetProvider.cs index 47f8add13..657835f25 100644 --- a/Elements/src/Geometry/Tessellation/CsgTessellationTargetProvider.cs +++ b/Elements/src/Geometry/Tessellation/CsgTessellationTargetProvider.cs @@ -28,9 +28,8 @@ public IEnumerable GetTessellationTargets() { foreach (var p in csg.Polygons) { - // We used the polygon's shared tag, which seems to - // work for planar solids turned into csgs as a discriminator, - // but this may break in the future. + // Shared.Tag groups coplanar polygons so the pack shares vertices across + // them; post-union collisions are caught by position-matched reuse downstream. yield return new CsgPolygonTessAdapter(p, (uint)p.Shared.Tag, solidId); } } diff --git a/Elements/src/Geometry/Tessellation/CsgVertexData.cs b/Elements/src/Geometry/Tessellation/CsgVertexData.cs new file mode 100644 index 000000000..ef12b80f1 --- /dev/null +++ b/Elements/src/Geometry/Tessellation/CsgVertexData.cs @@ -0,0 +1,23 @@ +namespace Elements.Geometry.Tessellation +{ + /// + /// Per-vertex data attached to + /// by the CSG and solid-face tessellation adapters, and consumed by + /// . + /// + internal readonly struct CsgVertexData + { + public readonly UV Uv; + public readonly uint Tag; + public readonly uint FaceId; + public readonly uint SolidId; + + public CsgVertexData(UV uv, uint tag, uint faceId, uint solidId) + { + Uv = uv; + Tag = tag; + FaceId = faceId; + SolidId = solidId; + } + } +} diff --git a/Elements/src/Geometry/Tessellation/SolidFaceTessAdapter.cs b/Elements/src/Geometry/Tessellation/SolidFaceTessAdapter.cs index 9500c49c2..8016f3a95 100644 --- a/Elements/src/Geometry/Tessellation/SolidFaceTessAdapter.cs +++ b/Elements/src/Geometry/Tessellation/SolidFaceTessAdapter.cs @@ -45,7 +45,11 @@ public Tess GetTess() } } - tess.Tessellate(WindingRule.Positive, ElementType.Polygons, 3); + // Register a combine callback so vertices synthesized by LibTess at + // intersection / T-junction points carry the same CsgVertexData shape as + // the input vertices. Without this, the synthesized vertices' Data field + // is null and downstream packing throws a NullReferenceException. + tess.Tessellate(WindingRule.Positive, ElementType.Polygons, 3, CombineCallbacks.DataCombine); return tess; } } diff --git a/Elements/src/Geometry/Tessellation/Tessellation.cs b/Elements/src/Geometry/Tessellation/Tessellation.cs index ff66c8086..474eed2ac 100644 --- a/Elements/src/Geometry/Tessellation/Tessellation.cs +++ b/Elements/src/Geometry/Tessellation/Tessellation.cs @@ -3,6 +3,7 @@ using System.Diagnostics; using System.Linq; using System.Runtime.CompilerServices; +using System.Threading; using LibTessDotNet.Double; [assembly: InternalsVisibleTo("Hypar.Elements.Tests")] @@ -18,6 +19,15 @@ internal static class Tessellation [System.ComponentModel.EditorBrowsable(System.ComponentModel.EditorBrowsableState.Never)] public static bool LOG_TESSELATION = false; + // Synthetic tag counter used when ContourVertex.Data is missing/malformed. + // Starts in the upper half of the uint range so it cannot collide with + // sequentially-allocated Csg.Vertex tags. Shares the same range with + // CombineCallbacks; both produce unique values, so collisions between + // the two synthetic streams are still avoided through Interlocked semantics + // on disjoint counters (they only ever live in vertexMap keyed by + // (tag, faceId, solidId) where faceId == 0 for fallback path). + private static long _syntheticVertexTag = 0xC0000000L; + /// /// Triangulate a collection of CSGs and pack the triangulated data into /// a supplied buffers object. @@ -91,52 +101,70 @@ private static void PackTessellationsIntoBuffers(List tesses, // This is an optimization to use pre-existing csg vertex // data to match vertices. - var (uv, tag, faceId, solidId) = ((UV uv, uint tag, uint faceId, uint solidId))v.Data; - - if (vertexMap.ContainsKey((tag, faceId, solidId))) - { - Debug.WriteLineIf(LOG_TESSELATION, $"Reusing vertex (tag:{tag},faceId:{faceId},solidId:{solidId}"); - // Reference an existing vertex from csg - indices.Add(vertexMap[(tag, faceId, solidId)]); - continue; - } - else if (vertexMap.ContainsKey((index, 0, 0))) + UV uv; + uint tag, faceId, solidId; + if (v.Data is CsgVertexData csgData) { - Debug.WriteLineIf(LOG_TESSELATION, $"Reusing vertex (tag:{tag},faceId:{faceId},solidId:{solidId}"); - // Reference an existing vertex created - // earlier here. - indices.Add(vertexMap[(index, 0, 0)]); - continue; + uv = csgData.Uv; + tag = csgData.Tag; + faceId = csgData.FaceId; + solidId = csgData.SolidId; } else { - // Create a new vertex. - var v1 = new Vector3(v.Position.X, v.Position.Y, v.Position.Z); - Color? c1 = null; + // LibTess can synthesize new ContourVertices at intersection + // and T-junction points. If the upstream tessellator did not + // register a CombineCallback (see CombineCallbacks) the new + // vertex's Data field is null. Treat it as a fresh unique + // vertex (synthetic tag prevents dedup-map collision) and + // fall through to the basis-vector UV fallback below. + uv = default; + tag = (uint)Interlocked.Increment(ref _syntheticVertexTag); + faceId = 0; + solidId = 0; + } - // Solid faces won't have UV coordinates. - if (uv == default) - { - var uu = U.Dot(v1); - var vv = V.Dot(v1); - uv = new UV(uu, vv); - } + var v1 = new Vector3(v.Position.X, v.Position.Y, v.Position.Z); - if (modifyVertexAttributes != null) - { - var mod = modifyVertexAttributes((v1, n, uv, c1)); - vertices.Add((mod.Item1, mod.Item2, mod.Item3, mod.Item4)); - } - else + if (vertexMap.TryGetValue((tag, faceId, solidId), out var existingIdx)) + { + if (vertices[existingIdx].position.IsAlmostEqualTo(v1)) { - vertices.Add((v1, n, uv, c1)); + Debug.WriteLineIf(LOG_TESSELATION, $"Reusing vertex (tag:{tag},faceId:{faceId},solidId:{solidId}"); + indices.Add(existingIdx); + continue; } - Debug.WriteLineIf(LOG_TESSELATION, $"Adding vertex (tag:{tag},faceId:{faceId}):{index}"); - indices.Add((ushort)index); - vertexMap.Add((tag, faceId, solidId), (ushort)index); - newVerts++; - index++; + + // CSG tags are not guaranteed unique across the united solid after + // booleans. Reuse only when the position matches; otherwise allocate + // a fresh synthetic tag so we don't weld unrelated corners. + tag = (uint)Interlocked.Increment(ref _syntheticVertexTag); + } + + Color? c1 = null; + + // Solid faces won't have UV coordinates. + if (uv == default) + { + var uu = U.Dot(v1); + var vv = V.Dot(v1); + uv = new UV(uu, vv); + } + + if (modifyVertexAttributes != null) + { + var mod = modifyVertexAttributes((v1, n, uv, c1)); + vertices.Add((mod.Item1, mod.Item2, mod.Item3, mod.Item4)); + } + else + { + vertices.Add((v1, n, uv, c1)); } + Debug.WriteLineIf(LOG_TESSELATION, $"Adding vertex (tag:{tag},faceId:{faceId}):{index}"); + indices.Add((ushort)index); + vertexMap[(tag, faceId, solidId)] = (ushort)index; + newVerts++; + index++; } tessOffset += newVerts; Debug.WriteLineIf(LOG_TESSELATION, $"----------{tessOffset}"); diff --git a/Elements/test/CsgTests.cs b/Elements/test/CsgTests.cs index 48c077a28..babe9abe8 100644 --- a/Elements/test/CsgTests.cs +++ b/Elements/test/CsgTests.cs @@ -7,6 +7,7 @@ using System.Linq; using Newtonsoft.Json; using Elements.Geometry.Tessellation; +using LibTessDotNet.Double; using Xunit.Abstractions; namespace Elements.Tests @@ -211,6 +212,473 @@ public void TessellatorProducesCorrectVertexNormals() Model.AddElement(arrows); } + [Fact] + public void Tessellate_WithCombinedContourVertices_DoesNotThrowAndProducesValidBuffer() + { + // LibTess synthesizes new ContourVertices at intersection / T-junction points. + // When the upstream Tess wasn't configured with a CombineCallback, those synthetic + // vertices have Data == null, which previously NREd in + // Tessellation.PackTessellationsIntoBuffers when it unboxed v.Data. + // This adapter intentionally omits the CombineCallback to exercise that path. + var adapter = new SelfIntersectingNoCombineTessAdapter(); + var provider = new InlineTessTargetProvider(adapter); + + var ex = Record.Exception(() => + { + var buffer = Tessellation.Tessellate(new[] { provider }); + Assert.NotNull(buffer); + Assert.True(buffer.Indices.Count > 0, "Buffer should contain at least one triangle index."); + Assert.True(buffer.Vertices.Count > 0, "Buffer should contain at least one vertex."); + }); + Assert.Null(ex); + } + + [Fact] + public void CsgPolygonTessAdapter_RegistersCombineCallback_AllVerticesHaveData() + { + // Build a simple csg polygon and tessellate via the adapter. With the combine + // callback registered, every vertex - including any synthesized by LibTess - + // must carry the Hypar 4-tuple Data shape. + var verts = new List + { + new Csg.Vertex(new Csg.Vector3D(0, 0, 0), new Csg.Vector2D(0, 0)), + new Csg.Vertex(new Csg.Vector3D(10, 0, 0), new Csg.Vector2D(1, 0)), + new Csg.Vertex(new Csg.Vector3D(10, 10, 0), new Csg.Vector2D(1, 1)), + new Csg.Vertex(new Csg.Vector3D(0, 10, 0), new Csg.Vector2D(0, 1)), + }; + var poly = new Csg.Polygon(verts); + + var adapter = new CsgPolygonTessAdapter(poly, faceId: 7, solidId: 3); + var tess = adapter.GetTess(); + foreach (var v in tess.Vertices) + { + Assert.True(v.Data is CsgVertexData, + $"All ContourVertex.Data entries must carry the CsgVertexData shape; got {v.Data?.GetType().FullName ?? "null"}."); + } + } + + [Theory] + [InlineData(0.35)] + [InlineData(0.55)] + [InlineData(0.6)] + public void RoofDrainLikeUnion_GraphicsBufferTessellation_ProducesValidMesh(double diameter) + { + const int segments = 20; + var cylinder = new Extrude(new Circle(Vector3.Origin, diameter / 2).ToPolygon(segments), 0.1, Vector3.ZAxis, false); + var profile = new Circle(Vector3.Origin, 0.075 / 2).ToPolygon(segments); + var elbow = Vector3.Origin + Vector3.ZAxis.Negate() * 0.2764999948978424; + var connectorPoint = elbow + new Vector3(0.5, 0, 0); + var connectorPipe = new Sweep(profile, new Polyline(new List { Vector3.Origin, elbow, connectorPoint }), 0, 0, 0, false); + + var geom = new GeometricElement( + new Transform(), + BuiltInMaterials.Steel, + new Representation(new List { cylinder, connectorPipe }), + false, + Guid.NewGuid(), + "roof-drain-like"); + + geom.UpdateBoundsAndComputeSolid(); + var unionCsg = geom.GetFinalCsgFromSolids(); + Assert.NotNull(unionCsg); + + var unionBuffer = Tessellation.Tessellate( + new[] { new CsgTessellationTargetProvider(unionCsg, 0) }); + var unionTriangleCount = unionBuffer.Indices.Count / 3; + var unionBounds = new BBox3(unionBuffer.Vertices.Select(v => v.position)); + + uint solidId = 0; + var providers = new List(); + foreach (var so in geom.Representation.SolidOperations) + { + providers.Add(new SolidTesselationTargetProvider(so.Solid, solidId, so.LocalTransform)); + solidId++; + } + var skipBuffer = Tessellation.Tessellate(providers); + var skipTriangleCount = skipBuffer.Indices.Count / 3; + var skipBounds = new BBox3(skipBuffer.Vertices.Select(v => v.position)); + + Assert.True(unionTriangleCount > 0); + Assert.True(skipTriangleCount > 0); + Assert.True(unionBounds.Volume.ApproximatelyEquals(skipBounds.Volume, 1e-6)); + Assert.True(unionBounds.Min.Z.ApproximatelyEquals(skipBounds.Min.Z, 1e-3)); + Assert.True(unionBounds.Max.Z.ApproximatelyEquals(skipBounds.Max.Z, 1e-3)); + Assert.True(unionTriangleCount >= skipTriangleCount * 0.85, + $"Union mesh ({unionTriangleCount} tris) lost too many triangles vs per-op reference ({skipTriangleCount} tris)."); + } + + [Fact] + public void MultiSweepAssemblyAtSurveyCoordinates_UnionMatchesOriginUnion() + { + const int segmentCount = 20; + const double surveyX = -49256; + const int profileSegments = 12; + var profile = new Circle(Vector3.Origin, 0.05).ToPolygon(profileSegments); + + List BuildOps(Vector3 origin) + { + var ops = new List(); + for (var i = 0; i < segmentCount; i++) + { + var start = origin + new Vector3(i * 0.8, 0, 0); + var mid = start + new Vector3(0.4, (i % 2 == 0 ? 0.3 : -0.3), 0); + var end = mid + new Vector3(0.4, 0, 0); + ops.Add(new Sweep(profile, new Polyline(new List { start, mid, end }), 0, 0, 0, false)); + } + return ops; + } + + int UnionTriangleCount(IList ops) + { + var geom = new GeometricElement( + new Transform(), + BuiltInMaterials.Steel, + new Representation(ops), + false, + Guid.NewGuid(), + "pipe-assembly"); + + geom.UpdateBoundsAndComputeSolid(); + var unionCsg = geom.GetFinalCsgFromSolids(); + Assert.NotNull(unionCsg); + var unionBuffer = Tessellation.Tessellate( + new[] { new CsgTessellationTargetProvider(unionCsg, 0) }); + return unionBuffer.Indices.Count / 3; + } + + var originUnionTriangles = UnionTriangleCount(BuildOps(Vector3.Origin)); + var surveyUnionTriangles = UnionTriangleCount(BuildOps(new Vector3(surveyX, 12000, 5))); + + Assert.True(originUnionTriangles > 0); + Assert.True(surveyUnionTriangles > 0); + Assert.True(Math.Abs(originUnionTriangles - surveyUnionTriangles) <= 2, + $"Survey-coordinate union ({surveyUnionTriangles} tris) should match origin union ({originUnionTriangles} tris)."); + } + + private void AssertNoTagCollisions(string label, global::Csg.Solid unionCsg) + { + // Mesh.FindOrCreateVertex (used by AddToMesh path) keys dedup on tag alone. + // If any Csg.Vertex.Tag appeared at multiple positions post-union, that path + // would weld unrelated corners. This canary asserts current CSG output + // doesn't produce such collisions; if it ever does, fix Mesh.FindOrCreateVertex + // (see TODO there) before this assertion is relaxed. + var tagToPositions = new Dictionary>(); + foreach (var p in unionCsg.Polygons) + { + foreach (var v in p.Vertices) + { + var pos = (Math.Round(v.Pos.X, 6), Math.Round(v.Pos.Y, 6), Math.Round(v.Pos.Z, 6)); + if (!tagToPositions.TryGetValue(v.Tag, out var positions)) + { + positions = new HashSet<(double, double, double)>(); + tagToPositions[v.Tag] = positions; + } + positions.Add(pos); + } + } + var collisions = tagToPositions.Where(kv => kv.Value.Count > 1).ToList(); + output.WriteLine($"[{label}] {tagToPositions.Count} distinct tags, {collisions.Count} colliding across positions"); + Assert.Empty(collisions); + } + + [Fact] + public void RealCsgUnion_OverlappingBoxes_TagCollisionProbe() + { + var s1 = new Extrude(Polygon.Rectangle(2, 2), 1, Vector3.ZAxis, false); + var s2 = new Extrude(Polygon.Rectangle(2, 2), 1, Vector3.ZAxis, false) + { + LocalTransform = new Transform(new Vector3(0.5, 0.5, 0.3)), + }; + var geom = new GeometricElement( + new Transform(), BuiltInMaterials.Steel, + new Representation(new List { s1, s2 }), + false, Guid.NewGuid(), "boxes"); + geom.UpdateBoundsAndComputeSolid(); + AssertNoTagCollisions("boxes", geom.GetFinalCsgFromSolids()); + } + + [Fact] + public void RealCsgUnion_RoofDrainShape_TagCollisionProbe() + { + const int segments = 20; + var cylinder = new Extrude(new Circle(Vector3.Origin, 0.55 / 2).ToPolygon(segments), 0.1, Vector3.ZAxis, false); + var profile = new Circle(Vector3.Origin, 0.075 / 2).ToPolygon(segments); + var elbow = Vector3.Origin + Vector3.ZAxis.Negate() * 0.2764999948978424; + var connectorPoint = elbow + new Vector3(0.5, 0, 0); + var connectorPipe = new Sweep(profile, new Polyline(new List { Vector3.Origin, elbow, connectorPoint }), 0, 0, 0, false); + var geom = new GeometricElement( + new Transform(), BuiltInMaterials.Steel, + new Representation(new List { cylinder, connectorPipe }), + false, Guid.NewGuid(), "roof-drain"); + geom.UpdateBoundsAndComputeSolid(); + AssertNoTagCollisions("roof-drain", geom.GetFinalCsgFromSolids()); + } + + [Fact] + public void RealCsgUnion_PipeChain_TagCollisionProbe() + { + const int segmentCount = 20; + const int profileSegments = 12; + var profile = new Circle(Vector3.Origin, 0.05).ToPolygon(profileSegments); + var ops = new List(); + for (var i = 0; i < segmentCount; i++) + { + var start = new Vector3(i * 0.8, 0, 0); + var mid = start + new Vector3(0.4, (i % 2 == 0 ? 0.3 : -0.3), 0); + var end = mid + new Vector3(0.4, 0, 0); + ops.Add(new Sweep(profile, new Polyline(new List { start, mid, end }), 0, 0, 0, false)); + } + var geom = new GeometricElement( + new Transform(), BuiltInMaterials.Steel, + new Representation(ops), + false, Guid.NewGuid(), "pipe-chain"); + geom.UpdateBoundsAndComputeSolid(); + AssertNoTagCollisions("pipe-chain", geom.GetFinalCsgFromSolids()); + } + + [Fact] + public void AddToMesh_PentagramPolygon_CsgTexTagCombineKeepsSynthesizedVerticesDistinct() + { + // CsgExtensions.AddToMesh's >4-vertex branch invokes LibTess with the + // CsgTexTagCombine callback. The callback assigns a unique synthetic tag + // to each LibTess-synthesized vertex. Without it, synthesized vertices + // all fall back to tag=0 in TexTagOrDefault, and FindOrCreateVertex (keyed + // solely on tag) welds them all into one mesh vertex. + var pts = new[] + { + new Csg.Vector3D(0, 10, 0), + new Csg.Vector3D(6, -8, 0), + new Csg.Vector3D(-10, 3, 0), + new Csg.Vector3D(10, 3, 0), + new Csg.Vector3D(-6, -8, 0), + }; + var verts = new List(pts.Length); + for (var i = 0; i < pts.Length; i++) + { + verts.Add(new Csg.Vertex(pts[i], new Csg.Vector2D(pts[i].X * 0.05, pts[i].Y * 0.05))); + } + var poly = new global::Csg.Polygon(verts); + var csg = global::Csg.Solid.FromPolygons(new List { poly }); + + var mesh = new Mesh(); + csg.Tessellate(ref mesh); + + // Pentagram should yield 5 input + 5 synthesized = 10 distinct mesh vertices. + // If the callback is missing or broken, synthesized vertices share tag=0 + // and FindOrCreateVertex welds them, dropping vertex count well below 10. + Assert.True(mesh.Vertices.Count >= 9, + $"Expected ~10 distinct vertices from pentagram tessellation; got {mesh.Vertices.Count}. " + + "Missing CsgTexTagCombine would weld all synthesized vertices via tag=0 in FindOrCreateVertex."); + } + + [Fact] + public void SolidFaceTessAdapter_PentagramOuterLoop_AdapterWiringCausesSynthesizedVerticesToCarryCsgVertexData() + { + // Construct a Face whose outer Loop is a 5-pointed self-intersecting + // pentagram. WindingRule.Positive forces LibTess to synthesize vertices + // at the 5 edge crossings; with the callback wired, each must carry + // CsgVertexData. Mirror of the CsgPolygonTessAdapter pentagram test. + var points = new[] + { + new Vector3(0, 10, 0), + new Vector3(6, -8, 0), + new Vector3(-10, 3, 0), + new Vector3(10, 3, 0), + new Vector3(-6, -8, 0), + }; + var vertices = new Elements.Geometry.Solids.Vertex[points.Length]; + for (var i = 0; i < points.Length; i++) + { + vertices[i] = new Elements.Geometry.Solids.Vertex((uint)i, points[i]); + } + var halfEdges = new HalfEdge[points.Length]; + for (var i = 0; i < points.Length; i++) + { + halfEdges[i] = new HalfEdge(vertices[i]); + } + var loop = new Loop(halfEdges); + var face = new Face(0, loop, null); + + var adapter = new SolidFaceTessAdapter(face, solidId: 9); + var tess = adapter.GetTess(); + + Assert.True(tess.Vertices.Length > points.Length, + $"Expected LibTess to synthesize vertices for pentagram outer loop; got {tess.Vertices.Length} for {points.Length} input. " + + "If this fails, the test no longer exercises the SolidFaceTessAdapter callback wiring."); + + foreach (var v in tess.Vertices) + { + Assert.True(v.Data is CsgVertexData, + $"Every vertex from SolidFaceTessAdapter must carry CsgVertexData; got {v.Data?.GetType().FullName ?? "null"} at ({v.Position.X},{v.Position.Y},{v.Position.Z})."); + } + } + + [Fact] + public void CsgPolygonTessAdapter_PentagramContour_AdapterWiringCausesSynthesizedVerticesToCarryCsgVertexData() + { + // Build a pentagram (5-pointed star) as a single self-intersecting CSG + // polygon contour. With WindingRule.Positive, LibTess synthesizes vertices + // at the 5 edge crossings. This test routes through the REAL + // CsgPolygonTessAdapter to prove its callback wiring (not the + // CombineCallbacks internals) is the load-bearing piece. + var verts = new List + { + new Csg.Vertex(new Csg.Vector3D(0, 10, 0), new Csg.Vector2D(0.5, 1)), + new Csg.Vertex(new Csg.Vector3D(6, -8, 0), new Csg.Vector2D(0.8, 0.1)), + new Csg.Vertex(new Csg.Vector3D(-10, 3, 0), new Csg.Vector2D(0.0, 0.7)), + new Csg.Vertex(new Csg.Vector3D(10, 3, 0), new Csg.Vector2D(1.0, 0.7)), + new Csg.Vertex(new Csg.Vector3D(-6, -8, 0), new Csg.Vector2D(0.2, 0.1)), + }; + var poly = new Csg.Polygon(verts); + + var adapter = new CsgPolygonTessAdapter(poly, faceId: 42, solidId: 7); + var tess = adapter.GetTess(); + + // Pentagram crossings force LibTess to synthesize vertices. + Assert.True(tess.Vertices.Length > verts.Count, + $"Expected LibTess to synthesize vertices for the pentagram contour; got {tess.Vertices.Length} for {verts.Count} input. " + + "If this fails, the test no longer exercises the adapter's callback wiring."); + + foreach (var v in tess.Vertices) + { + Assert.True(v.Data is CsgVertexData, + $"Every vertex from CsgPolygonTessAdapter must carry CsgVertexData; got {v.Data?.GetType().FullName ?? "null"} at ({v.Position.X},{v.Position.Y},{v.Position.Z})."); + } + } + + [Fact] + public void CombineCallbacks_TwoOverlappingContours_SynthesizedVerticesCarryCsgVertexData() + { + // Two overlapping rectangular contours. With WindingRule.Positive their + // shared region forces LibTess to synthesize vertices at the boundary + // crossings. With DataCombine wired, every synthetic vertex must + // carry CsgVertexData; without the callback it'd be null. + var contourA = new[] + { + MakeRawVertex(0, 0, 0, faceId: 1, solidId: 0, tag: 10), + MakeRawVertex(10, 0, 0, faceId: 1, solidId: 0, tag: 11), + MakeRawVertex(10, 10, 0, faceId: 1, solidId: 0, tag: 12), + MakeRawVertex(0, 10, 0, faceId: 1, solidId: 0, tag: 13), + }; + var contourB = new[] + { + MakeRawVertex(5, 5, 0, faceId: 1, solidId: 0, tag: 20), + MakeRawVertex(15, 5, 0, faceId: 1, solidId: 0, tag: 21), + MakeRawVertex(15, 15, 0, faceId: 1, solidId: 0, tag: 22), + MakeRawVertex(5, 15, 0, faceId: 1, solidId: 0, tag: 23), + }; + + var adapter = new MultiContourTessAdapter(new[] { contourA, contourB }); + var tess = adapter.GetTess(); + + // Confirm LibTess actually synthesized at least one vertex. + Assert.True(tess.Vertices.Length > contourA.Length + contourB.Length, + $"Expected LibTess to synthesize vertices at contour crossings; got {tess.Vertices.Length} for {contourA.Length + contourB.Length} input vertices. " + + "If this fails, the test no longer exercises CombineCallback."); + + foreach (var v in tess.Vertices) + { + Assert.True(v.Data is CsgVertexData, + $"Synthetic and input vertices must all carry CsgVertexData; got {v.Data?.GetType().FullName ?? "null"} at ({v.Position.X},{v.Position.Y},{v.Position.Z})."); + } + } + + [Fact] + public void PackTessellations_TagCollisionAtDifferentPositions_DoesNotWeldUnrelatedCorners() + { + // Two separate contours sharing identical Data tag values but located at + // different positions. Without the position-matched reuse guard, the pack + // dedup map would weld them by key alone, collapsing distinct corners + // into one vertex index and producing degenerate triangles. + var contourA = new[] + { + MakeRawVertex(0, 0, 0, faceId: 5, solidId: 0, tag: 100), + MakeRawVertex(1, 0, 0, faceId: 5, solidId: 0, tag: 101), + MakeRawVertex(1, 1, 0, faceId: 5, solidId: 0, tag: 102), + MakeRawVertex(0, 1, 0, faceId: 5, solidId: 0, tag: 103), + }; + var contourB = new[] + { + MakeRawVertex(10, 10, 0, faceId: 5, solidId: 0, tag: 100), // same key as A's first vertex, different position + MakeRawVertex(11, 10, 0, faceId: 5, solidId: 0, tag: 101), + MakeRawVertex(11, 11, 0, faceId: 5, solidId: 0, tag: 102), + MakeRawVertex(10, 11, 0, faceId: 5, solidId: 0, tag: 103), + }; + + var provider = new InlineTessTargetProvider(new MultiContourTessAdapter(new[] { contourA, contourB })); + var buffer = Tessellation.Tessellate(new[] { provider }); + + // Without position-matched reuse: contourB's 4 vertices weld to contourA's, + // collapsing 8 distinct corners to 4 and producing zero-area triangles. + // With the guard: contourB allocates fresh synthetic tags, all 8 corners survive. + var distinctPositions = buffer.Vertices.Select(v => v.position).Distinct().Count(); + Assert.True(distinctPositions >= 8, + $"Expected 8 distinct vertex positions across two non-overlapping contours; got {distinctPositions}. " + + "Vertex welding across unrelated keys would collapse them."); + } + + private static ContourVertex MakeRawVertex(double x, double y, double z, uint faceId, uint solidId, uint tag) + { + return new ContourVertex + { + Position = new Vec3 { X = x, Y = y, Z = z }, + Data = new CsgVertexData(new UV(x, y), tag, faceId, solidId) + }; + } + + private class MultiContourTessAdapter : ITessAdapter + { + private readonly ContourVertex[][] _contours; + public MultiContourTessAdapter(ContourVertex[][] contours) { _contours = contours; } + public Tess GetTess() + { + var tess = new Tess { NoEmptyPolygons = true }; + foreach (var c in _contours) { tess.AddContour(c); } + tess.Tessellate(WindingRule.Positive, ElementType.Polygons, 3, CombineCallbacks.DataCombine); + return tess; + } + } + + private class InlineTessTargetProvider : ITessellationTargetProvider + { + private readonly ITessAdapter _adapter; + public InlineTessTargetProvider(ITessAdapter adapter) { _adapter = adapter; } + public IEnumerable GetTessellationTargets() { yield return _adapter; } + } + + /// + /// Builds a Tess from a self-intersecting contour without registering a + /// CombineCallback, so LibTess will produce synthetic vertices with Data == null. + /// + private class SelfIntersectingNoCombineTessAdapter : ITessAdapter + { + public Tess GetTess() + { + var tess = new Tess { NoEmptyPolygons = true }; + // Bow-tie / self-crossing quad whose two diagonals force LibTess to + // synthesize a vertex at the crossing point. + var contour = new[] + { + MakeVertex(0, 0, faceId: 1, solidId: 0, tag: 1), + MakeVertex(10, 10, faceId: 1, solidId: 0, tag: 2), + MakeVertex(10, 0, faceId: 1, solidId: 0, tag: 3), + MakeVertex(0, 10, faceId: 1, solidId: 0, tag: 4), + }; + tess.AddContour(contour); + tess.Tessellate(WindingRule.Positive, ElementType.Polygons, 3); + return tess; + } + + private static ContourVertex MakeVertex(double x, double y, uint faceId, uint solidId, uint tag) + { + return new ContourVertex + { + Position = new Vec3 { X = x, Y = y, Z = 0 }, + Data = new CsgVertexData(new UV(x, y), tag, faceId, solidId) + }; + } + } + private class MockGraphicsBuffer : IGraphicsBuffers { public List Indices { get; set; } = new List();