Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 38 additions & 8 deletions app/etc/spatial.coffee
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)

Expand Down
150 changes: 150 additions & 0 deletions app/etc/spatial.test.coffee
Original file line number Diff line number Diff line change
Expand Up @@ -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', ->
Expand Down