Skip to content

Commit d79e1de

Browse files
authored
Merge pull request #39 from jgphilpott/copilot/investigate-slice-into-layers-issue
Fix sliceIntoLayers missing segments for near-parallel edges
2 parents f730296 + 41ab6dc commit d79e1de

File tree

2 files changed

+188
-8
lines changed

2 files changed

+188
-8
lines changed

app/etc/spatial.coffee

Lines changed: 38 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -279,7 +279,7 @@ Polytree.intersectPlane = (input, plane, target = []) ->
279279
# @return Array of arrays, each containing Line3 segments for that layer.
280280
Polytree.sliceIntoLayers = (input, layerHeight, minZ, maxZ, normal = new Vector3(0, 0, 1)) ->
281281

282-
return [] unless input and layerHeight > 0 and minZ < maxZ
282+
return [] unless input and layerHeight > 0 and minZ <= maxZ
283283

284284
# Convert input to polytree once at the beginning.
285285
result = convertToPolytree(input)
@@ -327,23 +327,53 @@ Polytree.sliceIntoLayers = (input, layerHeight, minZ, maxZ, normal = new Vector3
327327
startDist = planeNormal.dot(startPoint) + planeConstant
328328
endDist = planeNormal.dot(endPoint) + planeConstant
329329

330-
# Skip edges that lie entirely in the plane (coplanar).
331-
continue if startDist is 0 and endDist is 0
330+
# Calculate edge vector and length for adaptive epsilon.
331+
edgeVector = new Vector3().subVectors(endPoint, startPoint)
332+
edgeLength = edgeVector.length()
333+
334+
# Skip zero-length edges (degenerate).
335+
continue if edgeLength < 1e-10
336+
337+
# Normalize edge vector.
338+
edgeDir = edgeVector.clone().divideScalar(edgeLength)
339+
340+
# Calculate angle between edge and plane normal.
341+
# For edges parallel to plane, dot product with normal approaches 0.
342+
dotWithNormal = Math.abs(edgeDir.dot(planeNormal))
343+
344+
# Adaptive epsilon based on edge characteristics.
345+
# For edges nearly parallel to plane (dotWithNormal close to 0),
346+
# use larger epsilon to handle floating-point precision issues.
347+
# Base epsilon scales with edge length.
348+
baseEpsilon = Math.max(1e-10, edgeLength * 1e-9)
349+
350+
# Angle-adaptive factor: increase epsilon for near-parallel edges.
351+
# When dotWithNormal is small (< 0.017 ≈ 1°), scale epsilon significantly.
352+
angleFactor = if dotWithNormal < 0.02 then 100.0 else 1.0
353+
354+
epsilon = baseEpsilon * angleFactor
355+
356+
# Absolute distances for threshold checks.
357+
absStartDist = Math.abs(startDist)
358+
absEndDist = Math.abs(endDist)
359+
360+
# Skip edges that lie entirely in the plane (both endpoints within epsilon).
361+
continue if absStartDist < epsilon and absEndDist < epsilon
332362

333363
# Check if edge crosses the plane or has one endpoint on it.
334-
# Use <= to catch edges where one endpoint is exactly on the plane.
364+
# Use epsilon-based checks instead of exact equality.
335365
if (startDist * endDist) <= 0
336366

337367
# Calculate intersection point.
338-
if startDist is 0
368+
if absStartDist < epsilon
339369

340-
# Start point exactly on plane.
370+
# Start point on or very near plane.
341371
pt = startPoint.clone()
342372
intersectionPoints.push(pt) unless pointExists(pt, intersectionPoints)
343373

344-
else if endDist is 0
374+
else if absEndDist < epsilon
345375

346-
# End point exactly on plane.
376+
# End point on or very near plane.
347377
pt = endPoint.clone()
348378
intersectionPoints.push(pt) unless pointExists(pt, intersectionPoints)
349379

app/etc/spatial.test.coffee

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -276,6 +276,156 @@ describe 'Spatial Query Utilities', ->
276276

277277
return
278278

279+
it 'should handle edges nearly parallel to slicing plane', ->
280+
281+
# Create triangles with edges nearly parallel to Z=0.5 plane.
282+
# This tests the adaptive epsilon for near-parallel edge detection.
283+
vertexArray = new Float32Array([
284+
# Triangle 1: Clear intersection, one edge nearly parallel to plane.
285+
-1.0, -1.0, 0.2, # Well below plane.
286+
1.0, -1.0, 0.4999998, # Very slightly below Z=0.5 (nearly on plane).
287+
1.0, 1.0, 0.7, # Well above plane.
288+
289+
# Triangle 2: Similar configuration, different position.
290+
-1.0, 1.0, 0.3, # Well below plane.
291+
-0.5, 0.5, 0.5000002, # Very slightly above Z=0.5 (nearly on plane).
292+
1.0, 1.0, 0.8, # Well above plane.
293+
])
294+
295+
normalArray = new Float32Array([
296+
0, 0, 1, 0, 0, 1, 0, 0, 1,
297+
0, 0, 1, 0, 0, 1, 0, 0, 1,
298+
])
299+
300+
geometry = new BufferGeometry()
301+
geometry.setAttribute('position', new BufferAttribute(vertexArray, 3))
302+
geometry.setAttribute('normal', new BufferAttribute(normalArray, 3))
303+
geometry.setIndex([0, 1, 2, 3, 4, 5])
304+
305+
mesh = new Mesh(geometry, new MeshBasicMaterial())
306+
307+
# Slice at Z=0.5 where edges are nearly parallel.
308+
layers = Polytree.sliceIntoLayers(mesh, 0.1, 0.5, 0.5)
309+
310+
expect(layers.length).toBe(1)
311+
312+
# Should detect intersection segments despite near-parallel edges.
313+
# Both triangles should produce segments.
314+
expect(layers[0].length).toBe(2)
315+
316+
return
317+
318+
it 'should handle very small edge distances with adaptive epsilon', ->
319+
320+
# Create geometry where edge endpoints have very small distances to plane.
321+
# This simulates floating-point precision issues in real-world meshes.
322+
vertexArray = new Float32Array([
323+
# Triangle crossing Z=1.0 with tiny distances.
324+
-1.0, -1.0, 0.8, # Below plane.
325+
1.0, -1.0, 0.9999999999, # 1e-10 below plane.
326+
1.0, 1.0, 1.2, # Above plane.
327+
328+
# Triangle 2: One vertex extremely close to plane.
329+
-1.0, 1.0, 0.9, # Well below Z=1.0.
330+
-0.5, 0.5, 1.0000000005, # 5e-10 above plane.
331+
1.0, 1.0, 1.2, # Well above plane.
332+
])
333+
334+
normalArray = new Float32Array([
335+
0, 0, 1, 0, 0, 1, 0, 0, 1,
336+
0, 0, 1, 0, 0, 1, 0, 0, 1,
337+
])
338+
339+
geometry = new BufferGeometry()
340+
geometry.setAttribute('position', new BufferAttribute(vertexArray, 3))
341+
geometry.setAttribute('normal', new BufferAttribute(normalArray, 3))
342+
geometry.setIndex([0, 1, 2, 3, 4, 5])
343+
344+
mesh = new Mesh(geometry, new MeshBasicMaterial())
345+
346+
layers = Polytree.sliceIntoLayers(mesh, 0.5, 1.0, 1.0)
347+
348+
expect(layers.length).toBe(1)
349+
350+
# Should correctly identify intersections with adaptive epsilon.
351+
expect(layers[0].length).toBe(2) # Two triangles should intersect.
352+
353+
return
354+
355+
it 'should not duplicate segments for edges on plane with epsilon tolerance', ->
356+
357+
# Create geometry with edges exactly on the slicing plane.
358+
# Tests that duplicate detection works with epsilon-based comparisons.
359+
vertexArray = new Float32Array([
360+
# Two adjacent triangles sharing an edge on Z=2.0.
361+
-1.0, 0.0, 2.0, # Shared vertex 1 on plane.
362+
1.0, 0.0, 2.0, # Shared vertex 2 on plane.
363+
0.0, -1.0, 1.5, # Triangle 1 below.
364+
365+
-1.0, 0.0, 2.0, # Shared vertex 1 (same as above).
366+
1.0, 0.0, 2.0, # Shared vertex 2 (same as above).
367+
0.0, 1.0, 2.5, # Triangle 2 above.
368+
])
369+
370+
normalArray = new Float32Array([
371+
0, 0, 1, 0, 0, 1, 0, 0, 1,
372+
0, 0, 1, 0, 0, 1, 0, 0, 1,
373+
])
374+
375+
geometry = new BufferGeometry()
376+
geometry.setAttribute('position', new BufferAttribute(vertexArray, 3))
377+
geometry.setAttribute('normal', new BufferAttribute(normalArray, 3))
378+
geometry.setIndex([0, 1, 2, 3, 4, 5])
379+
380+
mesh = new Mesh(geometry, new MeshBasicMaterial())
381+
382+
layers = Polytree.sliceIntoLayers(mesh, 1.0, 2.0, 2.0)
383+
384+
expect(layers.length).toBe(1)
385+
386+
# Should have 2 segments (one from each triangle).
387+
# Duplicate detection should prevent extra segments.
388+
expect(layers[0].length).toBe(2)
389+
390+
return
391+
392+
it 'should handle long edges with scaled epsilon', ->
393+
394+
# Create geometry with very long edges to test epsilon scaling.
395+
# Long edges accumulate more floating-point error.
396+
vertexArray = new Float32Array([
397+
# Triangle with very long edges crossing Z=10.0.
398+
-100.0, -100.0, 9.5, # Below plane.
399+
100.0, 100.0, 9.9999, # Very close below, far from origin.
400+
100.0, -100.0, 10.5, # Above plane, far from origin.
401+
402+
# Smaller triangle for comparison.
403+
-1.0, 0.0, 9.5,
404+
1.0, 0.0, 10.5,
405+
0.0, 1.0, 12.0,
406+
])
407+
408+
normalArray = new Float32Array([
409+
0, 0, 1, 0, 0, 1, 0, 0, 1,
410+
0, 0, 1, 0, 0, 1, 0, 0, 1,
411+
])
412+
413+
geometry = new BufferGeometry()
414+
geometry.setAttribute('position', new BufferAttribute(vertexArray, 3))
415+
geometry.setAttribute('normal', new BufferAttribute(normalArray, 3))
416+
geometry.setIndex([0, 1, 2, 3, 4, 5])
417+
418+
mesh = new Mesh(geometry, new MeshBasicMaterial())
419+
420+
layers = Polytree.sliceIntoLayers(mesh, 1.0, 10.0, 10.0)
421+
422+
expect(layers.length).toBe(1)
423+
424+
# Adaptive epsilon should handle both long and short edges correctly.
425+
expect(layers[0].length).toBe(2)
426+
427+
return
428+
279429
describe 'shapecast', ->
280430

281431
it 'should find triangles matching custom query', ->

0 commit comments

Comments
 (0)