diff --git a/app/etc/spatial.coffee b/app/etc/spatial.coffee index 1179040..aede1ca 100644 --- a/app/etc/spatial.coffee +++ b/app/etc/spatial.coffee @@ -279,7 +279,7 @@ Polytree.intersectPlane = (input, plane, target = []) -> # @return Array of arrays, each containing Line3 segments for that layer. Polytree.sliceIntoLayers = (input, layerHeight, minZ, maxZ, normal = new Vector3(0, 0, 1)) -> - return [] unless input and layerHeight > 0 and minZ < maxZ + return [] unless input and layerHeight > 0 and minZ <= maxZ # Convert input to polytree once at the beginning. result = convertToPolytree(input) @@ -327,23 +327,53 @@ Polytree.sliceIntoLayers = (input, layerHeight, minZ, maxZ, normal = new Vector3 startDist = planeNormal.dot(startPoint) + planeConstant endDist = planeNormal.dot(endPoint) + planeConstant - # Skip edges that lie entirely in the plane (coplanar). - continue if startDist is 0 and endDist is 0 + # Calculate edge vector and length for adaptive epsilon. + edgeVector = new Vector3().subVectors(endPoint, startPoint) + edgeLength = edgeVector.length() + + # Skip zero-length edges (degenerate). + continue if edgeLength < 1e-10 + + # Normalize edge vector. + edgeDir = edgeVector.clone().divideScalar(edgeLength) + + # Calculate angle between edge and plane normal. + # For edges parallel to plane, dot product with normal approaches 0. + dotWithNormal = Math.abs(edgeDir.dot(planeNormal)) + + # Adaptive epsilon based on edge characteristics. + # For edges nearly parallel to plane (dotWithNormal close to 0), + # use larger epsilon to handle floating-point precision issues. + # Base epsilon scales with edge length. + baseEpsilon = Math.max(1e-10, edgeLength * 1e-9) + + # Angle-adaptive factor: increase epsilon for near-parallel edges. + # When dotWithNormal is small (< 0.017 ≈ 1°), scale epsilon significantly. + angleFactor = if dotWithNormal < 0.02 then 100.0 else 1.0 + + epsilon = baseEpsilon * angleFactor + + # Absolute distances for threshold checks. + absStartDist = Math.abs(startDist) + absEndDist = Math.abs(endDist) + + # Skip edges that lie entirely in the plane (both endpoints within epsilon). + continue if absStartDist < epsilon and absEndDist < epsilon # Check if edge crosses the plane or has one endpoint on it. - # Use <= to catch edges where one endpoint is exactly on the plane. + # Use epsilon-based checks instead of exact equality. if (startDist * endDist) <= 0 # Calculate intersection point. - if startDist is 0 + if absStartDist < epsilon - # Start point exactly on plane. + # Start point on or very near plane. pt = startPoint.clone() intersectionPoints.push(pt) unless pointExists(pt, intersectionPoints) - else if endDist is 0 + else if absEndDist < epsilon - # End point exactly on plane. + # End point on or very near plane. pt = endPoint.clone() intersectionPoints.push(pt) unless pointExists(pt, intersectionPoints) diff --git a/app/etc/spatial.test.coffee b/app/etc/spatial.test.coffee index d44c5a8..c80ab9f 100644 --- a/app/etc/spatial.test.coffee +++ b/app/etc/spatial.test.coffee @@ -276,6 +276,156 @@ describe 'Spatial Query Utilities', -> return + it 'should handle edges nearly parallel to slicing plane', -> + + # Create triangles with edges nearly parallel to Z=0.5 plane. + # This tests the adaptive epsilon for near-parallel edge detection. + vertexArray = new Float32Array([ + # Triangle 1: Clear intersection, one edge nearly parallel to plane. + -1.0, -1.0, 0.2, # Well below plane. + 1.0, -1.0, 0.4999998, # Very slightly below Z=0.5 (nearly on plane). + 1.0, 1.0, 0.7, # Well above plane. + + # Triangle 2: Similar configuration, different position. + -1.0, 1.0, 0.3, # Well below plane. + -0.5, 0.5, 0.5000002, # Very slightly above Z=0.5 (nearly on plane). + 1.0, 1.0, 0.8, # Well above plane. + ]) + + normalArray = new Float32Array([ + 0, 0, 1, 0, 0, 1, 0, 0, 1, + 0, 0, 1, 0, 0, 1, 0, 0, 1, + ]) + + geometry = new BufferGeometry() + geometry.setAttribute('position', new BufferAttribute(vertexArray, 3)) + geometry.setAttribute('normal', new BufferAttribute(normalArray, 3)) + geometry.setIndex([0, 1, 2, 3, 4, 5]) + + mesh = new Mesh(geometry, new MeshBasicMaterial()) + + # Slice at Z=0.5 where edges are nearly parallel. + layers = Polytree.sliceIntoLayers(mesh, 0.1, 0.5, 0.5) + + expect(layers.length).toBe(1) + + # Should detect intersection segments despite near-parallel edges. + # Both triangles should produce segments. + expect(layers[0].length).toBe(2) + + return + + it 'should handle very small edge distances with adaptive epsilon', -> + + # Create geometry where edge endpoints have very small distances to plane. + # This simulates floating-point precision issues in real-world meshes. + vertexArray = new Float32Array([ + # Triangle crossing Z=1.0 with tiny distances. + -1.0, -1.0, 0.8, # Below plane. + 1.0, -1.0, 0.9999999999, # 1e-10 below plane. + 1.0, 1.0, 1.2, # Above plane. + + # Triangle 2: One vertex extremely close to plane. + -1.0, 1.0, 0.9, # Well below Z=1.0. + -0.5, 0.5, 1.0000000005, # 5e-10 above plane. + 1.0, 1.0, 1.2, # Well above plane. + ]) + + normalArray = new Float32Array([ + 0, 0, 1, 0, 0, 1, 0, 0, 1, + 0, 0, 1, 0, 0, 1, 0, 0, 1, + ]) + + geometry = new BufferGeometry() + geometry.setAttribute('position', new BufferAttribute(vertexArray, 3)) + geometry.setAttribute('normal', new BufferAttribute(normalArray, 3)) + geometry.setIndex([0, 1, 2, 3, 4, 5]) + + mesh = new Mesh(geometry, new MeshBasicMaterial()) + + layers = Polytree.sliceIntoLayers(mesh, 0.5, 1.0, 1.0) + + expect(layers.length).toBe(1) + + # Should correctly identify intersections with adaptive epsilon. + expect(layers[0].length).toBe(2) # Two triangles should intersect. + + return + + it 'should not duplicate segments for edges on plane with epsilon tolerance', -> + + # Create geometry with edges exactly on the slicing plane. + # Tests that duplicate detection works with epsilon-based comparisons. + vertexArray = new Float32Array([ + # Two adjacent triangles sharing an edge on Z=2.0. + -1.0, 0.0, 2.0, # Shared vertex 1 on plane. + 1.0, 0.0, 2.0, # Shared vertex 2 on plane. + 0.0, -1.0, 1.5, # Triangle 1 below. + + -1.0, 0.0, 2.0, # Shared vertex 1 (same as above). + 1.0, 0.0, 2.0, # Shared vertex 2 (same as above). + 0.0, 1.0, 2.5, # Triangle 2 above. + ]) + + normalArray = new Float32Array([ + 0, 0, 1, 0, 0, 1, 0, 0, 1, + 0, 0, 1, 0, 0, 1, 0, 0, 1, + ]) + + geometry = new BufferGeometry() + geometry.setAttribute('position', new BufferAttribute(vertexArray, 3)) + geometry.setAttribute('normal', new BufferAttribute(normalArray, 3)) + geometry.setIndex([0, 1, 2, 3, 4, 5]) + + mesh = new Mesh(geometry, new MeshBasicMaterial()) + + layers = Polytree.sliceIntoLayers(mesh, 1.0, 2.0, 2.0) + + expect(layers.length).toBe(1) + + # Should have 2 segments (one from each triangle). + # Duplicate detection should prevent extra segments. + expect(layers[0].length).toBe(2) + + return + + it 'should handle long edges with scaled epsilon', -> + + # Create geometry with very long edges to test epsilon scaling. + # Long edges accumulate more floating-point error. + vertexArray = new Float32Array([ + # Triangle with very long edges crossing Z=10.0. + -100.0, -100.0, 9.5, # Below plane. + 100.0, 100.0, 9.9999, # Very close below, far from origin. + 100.0, -100.0, 10.5, # Above plane, far from origin. + + # Smaller triangle for comparison. + -1.0, 0.0, 9.5, + 1.0, 0.0, 10.5, + 0.0, 1.0, 12.0, + ]) + + normalArray = new Float32Array([ + 0, 0, 1, 0, 0, 1, 0, 0, 1, + 0, 0, 1, 0, 0, 1, 0, 0, 1, + ]) + + geometry = new BufferGeometry() + geometry.setAttribute('position', new BufferAttribute(vertexArray, 3)) + geometry.setAttribute('normal', new BufferAttribute(normalArray, 3)) + geometry.setIndex([0, 1, 2, 3, 4, 5]) + + mesh = new Mesh(geometry, new MeshBasicMaterial()) + + layers = Polytree.sliceIntoLayers(mesh, 1.0, 10.0, 10.0) + + expect(layers.length).toBe(1) + + # Adaptive epsilon should handle both long and short edges correctly. + expect(layers[0].length).toBe(2) + + return + describe 'shapecast', -> it 'should find triangles matching custom query', ->