Skip to content

Commit 9a8f3ad

Browse files
Copilotjgphilpott
andcommitted
Fix concentric infill hole clipping: clip loop edges instead of sampling points
Co-authored-by: jgphilpott <4128208+jgphilpott@users.noreply.github.com>
1 parent fb6d0fd commit 9a8f3ad

File tree

2 files changed

+194
-57
lines changed

2 files changed

+194
-57
lines changed
Lines changed: 112 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
# Concentric infill pattern implementation for Polyslice.
22

33
coders = require('../../gcode/coders')
4+
clipping = require('../../utils/clipping')
45
paths = require('../../utils/paths')
56
combing = require('../../geometry/combing')
6-
primitives = require('../../utils/primitives')
77

88
module.exports =
99

@@ -50,93 +50,148 @@ module.exports =
5050

5151
continue if currentLoop.length < 3
5252

53-
# Check if this loop should be skipped because it's inside a hole.
54-
# Sample multiple points on the loop to ensure accurate detection.
55-
skipLoop = false
56-
5753
if holeInnerWalls.length > 0
5854

59-
# Sample points evenly distributed around the loop.
60-
sampleCount = Math.min(8, currentLoop.length)
55+
# Clip each edge of the loop against hole walls to prevent
56+
# infill from being generated inside holes.
57+
validSegments = []
58+
59+
for i in [0...currentLoop.length]
6160

62-
pointsInHoles = 0
61+
segStart = currentLoop[i]
62+
segEnd = currentLoop[(i + 1) % currentLoop.length]
6363

64-
for sampleIdx in [0...sampleCount]
64+
clippedSegs = clipping.clipLineWithHoles(segStart, segEnd, infillBoundary, holeInnerWalls)
6565

66-
# Distribute samples evenly across the loop's length.
67-
pointIdx = Math.floor(sampleIdx * currentLoop.length / sampleCount)
68-
testPoint = currentLoop[pointIdx]
66+
for seg in clippedSegs
6967

70-
# Check if this point is inside any hole.
71-
for holeWall in holeInnerWalls
68+
validSegments.push(seg)
7269

73-
if holeWall.length >= 3 and primitives.pointInPolygon(testPoint, holeWall)
70+
continue if validSegments.length is 0
7471

75-
pointsInHoles++
76-
break
72+
# Group consecutive segments into polylines to minimize travel moves.
73+
polylines = []
74+
currentPolyline = [validSegments[0].start, validSegments[0].end]
7775

78-
# Skip loop only if majority of sampled points are inside holes.
79-
# This prevents skipping loops that merely pass near holes.
80-
if pointsInHoles > sampleCount / 2
76+
for segIdx in [1...validSegments.length]
8177

82-
skipLoop = true
78+
seg = validSegments[segIdx]
79+
prevEnd = currentPolyline[currentPolyline.length - 1]
8380

84-
continue if skipLoop
81+
dx = seg.start.x - prevEnd.x
82+
dy = seg.start.y - prevEnd.y
8583

86-
# Find optimal start point on this loop if we have a last position.
87-
startIndex = 0
84+
if dx * dx + dy * dy < 0.001 * 0.001
8885

89-
if lastEndPoint?
86+
currentPolyline.push(seg.end)
9087

91-
minDistSq = Infinity
88+
else
9289

93-
for i in [0...currentLoop.length]
90+
polylines.push(currentPolyline)
91+
currentPolyline = [seg.start, seg.end]
92+
93+
polylines.push(currentPolyline)
94+
95+
# Render each polyline with combing travel and extrusion.
96+
for polyline in polylines
97+
98+
continue if polyline.length < 2
99+
100+
startPoint = polyline[0]
101+
102+
combingPath = combing.findCombingPath(lastEndPoint or startPoint, startPoint, holeOuterWalls, infillBoundary, nozzleDiameter)
103+
104+
for i in [0...combingPath.length - 1]
105+
106+
waypoint = combingPath[i + 1]
107+
offsetWaypointX = waypoint.x + centerOffsetX
108+
offsetWaypointY = waypoint.y + centerOffsetY
109+
110+
slicer.gcode += coders.codeLinearMovement(slicer, offsetWaypointX, offsetWaypointY, z, null, travelSpeedMmMin).replace(slicer.newline, (if verbose then "; Moving to concentric loop" + slicer.newline else slicer.newline))
111+
112+
prevPoint = polyline[0]
113+
114+
for ptIdx in [1...polyline.length]
115+
116+
point = polyline[ptIdx]
117+
118+
dx = point.x - prevPoint.x
119+
dy = point.y - prevPoint.y
120+
121+
distance = Math.sqrt(dx * dx + dy * dy)
122+
123+
if distance > 0.001
124+
125+
extrusionDelta = slicer.calculateExtrusion(distance, nozzleDiameter)
126+
slicer.cumulativeE += extrusionDelta
127+
128+
offsetX = point.x + centerOffsetX
129+
offsetY = point.y + centerOffsetY
130+
131+
slicer.gcode += coders.codeLinearMovement(slicer, offsetX, offsetY, z, slicer.cumulativeE, infillSpeedMmMin)
132+
133+
prevPoint = point
134+
135+
lastEndPoint = prevPoint
136+
137+
else
138+
139+
# No holes: use optimized full-loop rendering with start point selection.
140+
141+
# Find optimal start point on this loop if we have a last position.
142+
startIndex = 0
143+
144+
if lastEndPoint?
145+
146+
minDistSq = Infinity
147+
148+
for i in [0...currentLoop.length]
94149

95-
point = currentLoop[i]
96-
distSq = (point.x - lastEndPoint.x) ** 2 + (point.y - lastEndPoint.y) ** 2
150+
point = currentLoop[i]
151+
distSq = (point.x - lastEndPoint.x) ** 2 + (point.y - lastEndPoint.y) ** 2
97152

98-
if distSq < minDistSq
153+
if distSq < minDistSq
99154

100-
minDistSq = distSq
101-
startIndex = i
155+
minDistSq = distSq
156+
startIndex = i
102157

103-
# Travel to start point with combing.
104-
firstPoint = currentLoop[startIndex]
158+
# Travel to start point with combing.
159+
firstPoint = currentLoop[startIndex]
105160

106-
combingPath = combing.findCombingPath(lastEndPoint or firstPoint, firstPoint, holeOuterWalls, infillBoundary, nozzleDiameter)
161+
combingPath = combing.findCombingPath(lastEndPoint or firstPoint, firstPoint, holeOuterWalls, infillBoundary, nozzleDiameter)
107162

108-
for i in [0...combingPath.length - 1]
163+
for i in [0...combingPath.length - 1]
109164

110-
waypoint = combingPath[i + 1]
111-
offsetWaypointX = waypoint.x + centerOffsetX
112-
offsetWaypointY = waypoint.y + centerOffsetY
165+
waypoint = combingPath[i + 1]
166+
offsetWaypointX = waypoint.x + centerOffsetX
167+
offsetWaypointY = waypoint.y + centerOffsetY
113168

114-
slicer.gcode += coders.codeLinearMovement(slicer, offsetWaypointX, offsetWaypointY, z, null, travelSpeedMmMin).replace(slicer.newline, (if verbose then "; Moving to concentric loop" + slicer.newline else slicer.newline))
169+
slicer.gcode += coders.codeLinearMovement(slicer, offsetWaypointX, offsetWaypointY, z, null, travelSpeedMmMin).replace(slicer.newline, (if verbose then "; Moving to concentric loop" + slicer.newline else slicer.newline))
115170

116-
# Print the loop starting from startIndex.
117-
prevPoint = currentLoop[startIndex]
171+
# Print the loop starting from startIndex.
172+
prevPoint = currentLoop[startIndex]
118173

119-
for i in [1..currentLoop.length]
174+
for i in [1..currentLoop.length]
120175

121-
currentIndex = (startIndex + i) % currentLoop.length
122-
point = currentLoop[currentIndex]
176+
currentIndex = (startIndex + i) % currentLoop.length
177+
point = currentLoop[currentIndex]
123178

124-
dx = point.x - prevPoint.x
125-
dy = point.y - prevPoint.y
179+
dx = point.x - prevPoint.x
180+
dy = point.y - prevPoint.y
126181

127-
distance = Math.sqrt(dx * dx + dy * dy)
182+
distance = Math.sqrt(dx * dx + dy * dy)
128183

129-
if distance > 0.001
184+
if distance > 0.001
130185

131-
extrusionDelta = slicer.calculateExtrusion(distance, nozzleDiameter)
132-
slicer.cumulativeE += extrusionDelta
186+
extrusionDelta = slicer.calculateExtrusion(distance, nozzleDiameter)
187+
slicer.cumulativeE += extrusionDelta
133188

134-
offsetX = point.x + centerOffsetX
135-
offsetY = point.y + centerOffsetY
189+
offsetX = point.x + centerOffsetX
190+
offsetY = point.y + centerOffsetY
136191

137-
slicer.gcode += coders.codeLinearMovement(slicer, offsetX, offsetY, z, slicer.cumulativeE, infillSpeedMmMin)
192+
slicer.gcode += coders.codeLinearMovement(slicer, offsetX, offsetY, z, slicer.cumulativeE, infillSpeedMmMin)
138193

139-
prevPoint = point
194+
prevPoint = point
140195

141-
# Update last end point for next loop.
142-
lastEndPoint = prevPoint
196+
# Update last end point for next loop.
197+
lastEndPoint = prevPoint

src/slicer/infill/patterns/concentric.test.coffee

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -241,3 +241,85 @@ describe 'Concentric Infill Generation', ->
241241

242242
# Should have no points in the hole area after the fix.
243243
expect(pointsNearHole).toBe(0) # 100% elimination of hole violations.
244+
245+
test 'should clip partial-overlap loops when rectangular loops intersect circular hole (regression for dome)', ->
246+
247+
# Create a shape with a 20x20mm rectangular outer boundary and a
248+
# circular inner hole of radius 6mm at the center. Extruded 10mm tall.
249+
# This replicates dome-like cross-sections where concentric loops shrink as
250+
# rectangles but the hole is circular: at ~10x10mm loop size the four side
251+
# midpoints fall inside the 6mm hole while the corners stay outside.
252+
# Old code: 4/8 sampled points inside → ≤50% threshold → loop NOT skipped
253+
# → parts of the loop printed inside the hole (bug).
254+
# New code: each edge is clipped against the hole → correct arcs only.
255+
outerShape = new THREE.Shape()
256+
outerShape.moveTo(-10, -10)
257+
outerShape.lineTo(10, -10)
258+
outerShape.lineTo(10, 10)
259+
outerShape.lineTo(-10, 10)
260+
outerShape.lineTo(-10, -10)
261+
262+
holePath = new THREE.Path()
263+
holePath.absarc(0, 0, 6, 0, Math.PI * 2, false)
264+
outerShape.holes.push(holePath)
265+
266+
extrudeSettings = { depth: 10, bevelEnabled: false }
267+
geometry = new THREE.ExtrudeGeometry(outerShape, extrudeSettings)
268+
269+
mesh = new THREE.Mesh(geometry, new THREE.MeshBasicMaterial())
270+
mesh.position.set(0, 0, 5)
271+
mesh.updateMatrixWorld()
272+
273+
slicer.setNozzleDiameter(0.4)
274+
slicer.setShellWallThickness(0.8)
275+
slicer.setShellSkinThickness(0.4)
276+
slicer.setLayerHeight(0.2)
277+
slicer.setInfillDensity(20)
278+
slicer.setInfillPattern('concentric')
279+
slicer.setVerbose(true)
280+
281+
result = slicer.slice(mesh)
282+
283+
# Should generate valid G-code with infill.
284+
expect(result).toBeTruthy()
285+
expect(result).toContain('; TYPE: FILL')
286+
287+
lines = result.split('\n')
288+
289+
# Extract infill extrusion G-code coordinates.
290+
inFill = false
291+
fillCoords = []
292+
293+
for line in lines
294+
295+
if line.includes('; TYPE: FILL')
296+
inFill = true
297+
else if line.includes('; TYPE:') and not line.includes('; TYPE: FILL')
298+
inFill = false
299+
300+
if inFill and line.includes('G1') and line.includes('E')
301+
302+
xMatch = line.match(/X([\d.-]+)/)
303+
yMatch = line.match(/Y([\d.-]+)/)
304+
305+
if xMatch and yMatch
306+
fillCoords.push({ x: parseFloat(xMatch[1]), y: parseFloat(yMatch[1]) })
307+
308+
centerX = slicer.getBuildPlateWidth() / 2
309+
centerY = slicer.getBuildPlateLength() / 2
310+
311+
# The circular hole has radius 6mm — no infill point should fall inside it.
312+
holeRadius = 6
313+
pointsNearHole = 0
314+
315+
for coord in fillCoords
316+
317+
dx = coord.x - centerX
318+
dy = coord.y - centerY
319+
distToCenter = Math.sqrt(dx * dx + dy * dy)
320+
321+
if distToCenter < holeRadius
322+
pointsNearHole++
323+
324+
# No infill should be generated inside the circular hole.
325+
expect(pointsNearHole).toBe(0)

0 commit comments

Comments
 (0)