Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
169 changes: 112 additions & 57 deletions src/slicer/infill/patterns/concentric.coffee
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
# Concentric infill pattern implementation for Polyslice.

coders = require('../../gcode/coders')
clipping = require('../../utils/clipping')
paths = require('../../utils/paths')
combing = require('../../geometry/combing')
primitives = require('../../utils/primitives')

module.exports =

Expand Down Expand Up @@ -50,93 +50,148 @@ module.exports =

continue if currentLoop.length < 3

# Check if this loop should be skipped because it's inside a hole.
# Sample multiple points on the loop to ensure accurate detection.
skipLoop = false

if holeInnerWalls.length > 0

# Sample points evenly distributed around the loop.
sampleCount = Math.min(8, currentLoop.length)
# Clip each edge of the loop against hole walls to prevent
# infill from being generated inside holes.
validSegments = []

for i in [0...currentLoop.length]

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

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

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

# Check if this point is inside any hole.
for holeWall in holeInnerWalls
validSegments.push(seg)

if holeWall.length >= 3 and primitives.pointInPolygon(testPoint, holeWall)
continue if validSegments.length is 0

pointsInHoles++
break
# Group consecutive segments into polylines to minimize travel moves.
polylines = []
currentPolyline = [validSegments[0].start, validSegments[0].end]

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

skipLoop = true
seg = validSegments[segIdx]
prevEnd = currentPolyline[currentPolyline.length - 1]

continue if skipLoop
dx = seg.start.x - prevEnd.x
dy = seg.start.y - prevEnd.y

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

if lastEndPoint?
currentPolyline.push(seg.end)

minDistSq = Infinity
else

for i in [0...currentLoop.length]
polylines.push(currentPolyline)
currentPolyline = [seg.start, seg.end]

polylines.push(currentPolyline)

# Render each polyline with combing travel and extrusion.
for polyline in polylines

continue if polyline.length < 2

startPoint = polyline[0]

combingPath = combing.findCombingPath(lastEndPoint or startPoint, startPoint, holeOuterWalls, infillBoundary, nozzleDiameter)

for i in [0...combingPath.length - 1]

waypoint = combingPath[i + 1]
offsetWaypointX = waypoint.x + centerOffsetX
offsetWaypointY = waypoint.y + centerOffsetY

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))

prevPoint = polyline[0]

for ptIdx in [1...polyline.length]

point = polyline[ptIdx]

dx = point.x - prevPoint.x
dy = point.y - prevPoint.y

distance = Math.sqrt(dx * dx + dy * dy)

if distance > 0.001

extrusionDelta = slicer.calculateExtrusion(distance, nozzleDiameter)
slicer.cumulativeE += extrusionDelta

offsetX = point.x + centerOffsetX
offsetY = point.y + centerOffsetY

slicer.gcode += coders.codeLinearMovement(slicer, offsetX, offsetY, z, slicer.cumulativeE, infillSpeedMmMin)

prevPoint = point

lastEndPoint = prevPoint

else

# No holes: use optimized full-loop rendering with start point selection.

# Find optimal start point on this loop if we have a last position.
startIndex = 0

if lastEndPoint?

minDistSq = Infinity

for i in [0...currentLoop.length]

point = currentLoop[i]
distSq = (point.x - lastEndPoint.x) ** 2 + (point.y - lastEndPoint.y) ** 2
point = currentLoop[i]
distSq = (point.x - lastEndPoint.x) ** 2 + (point.y - lastEndPoint.y) ** 2

if distSq < minDistSq
if distSq < minDistSq

minDistSq = distSq
startIndex = i
minDistSq = distSq
startIndex = i

# Travel to start point with combing.
firstPoint = currentLoop[startIndex]
# Travel to start point with combing.
firstPoint = currentLoop[startIndex]

combingPath = combing.findCombingPath(lastEndPoint or firstPoint, firstPoint, holeOuterWalls, infillBoundary, nozzleDiameter)
combingPath = combing.findCombingPath(lastEndPoint or firstPoint, firstPoint, holeOuterWalls, infillBoundary, nozzleDiameter)

for i in [0...combingPath.length - 1]
for i in [0...combingPath.length - 1]

waypoint = combingPath[i + 1]
offsetWaypointX = waypoint.x + centerOffsetX
offsetWaypointY = waypoint.y + centerOffsetY
waypoint = combingPath[i + 1]
offsetWaypointX = waypoint.x + centerOffsetX
offsetWaypointY = waypoint.y + centerOffsetY

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))
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))

# Print the loop starting from startIndex.
prevPoint = currentLoop[startIndex]
# Print the loop starting from startIndex.
prevPoint = currentLoop[startIndex]

for i in [1..currentLoop.length]
for i in [1..currentLoop.length]

currentIndex = (startIndex + i) % currentLoop.length
point = currentLoop[currentIndex]
currentIndex = (startIndex + i) % currentLoop.length
point = currentLoop[currentIndex]

dx = point.x - prevPoint.x
dy = point.y - prevPoint.y
dx = point.x - prevPoint.x
dy = point.y - prevPoint.y

distance = Math.sqrt(dx * dx + dy * dy)
distance = Math.sqrt(dx * dx + dy * dy)

if distance > 0.001
if distance > 0.001

extrusionDelta = slicer.calculateExtrusion(distance, nozzleDiameter)
slicer.cumulativeE += extrusionDelta
extrusionDelta = slicer.calculateExtrusion(distance, nozzleDiameter)
slicer.cumulativeE += extrusionDelta

offsetX = point.x + centerOffsetX
offsetY = point.y + centerOffsetY
offsetX = point.x + centerOffsetX
offsetY = point.y + centerOffsetY

slicer.gcode += coders.codeLinearMovement(slicer, offsetX, offsetY, z, slicer.cumulativeE, infillSpeedMmMin)
slicer.gcode += coders.codeLinearMovement(slicer, offsetX, offsetY, z, slicer.cumulativeE, infillSpeedMmMin)

prevPoint = point
prevPoint = point

# Update last end point for next loop.
lastEndPoint = prevPoint
# Update last end point for next loop.
lastEndPoint = prevPoint
84 changes: 83 additions & 1 deletion src/slicer/infill/patterns/concentric.test.coffee
Original file line number Diff line number Diff line change
Expand Up @@ -223,7 +223,7 @@ describe 'Concentric Infill Generation', ->
# Derive build plate center from slicer configuration to avoid hardcoding.
centerX = slicer.getBuildPlateWidth() / 2
centerY = slicer.getBuildPlateLength() / 2

# For a torus with radius 5 and tube 2, the hole radius is approximately 3mm.
holeRadius = 3

Expand All @@ -241,3 +241,85 @@ describe 'Concentric Infill Generation', ->

# Should have no points in the hole area after the fix.
expect(pointsNearHole).toBe(0) # 100% elimination of hole violations.

test 'should clip partial-overlap loops when rectangular loops intersect circular hole (regression for dome)', ->

# Create a shape with a 20x20mm rectangular outer boundary and a
# circular inner hole of radius 6mm at the center. Extruded 10mm tall.
# This replicates dome-like cross-sections where concentric loops shrink as
# rectangles but the hole is circular: at ~10x10mm loop size the four side
# midpoints fall inside the 6mm hole while the corners stay outside.
# Old code: 4/8 sampled points inside β†’ ≀50% threshold β†’ loop NOT skipped
# β†’ parts of the loop printed inside the hole (bug).
# New code: each edge is clipped against the hole β†’ correct arcs only.
outerShape = new THREE.Shape()
outerShape.moveTo(-10, -10)
outerShape.lineTo(10, -10)
outerShape.lineTo(10, 10)
outerShape.lineTo(-10, 10)
outerShape.lineTo(-10, -10)

holePath = new THREE.Path()
holePath.absarc(0, 0, 6, 0, Math.PI * 2, false)
outerShape.holes.push(holePath)

extrudeSettings = { depth: 10, bevelEnabled: false }
geometry = new THREE.ExtrudeGeometry(outerShape, extrudeSettings)

mesh = new THREE.Mesh(geometry, new THREE.MeshBasicMaterial())
mesh.position.set(0, 0, 5)
mesh.updateMatrixWorld()

slicer.setNozzleDiameter(0.4)
slicer.setShellWallThickness(0.8)
slicer.setShellSkinThickness(0.4)
slicer.setLayerHeight(0.2)
slicer.setInfillDensity(20)
slicer.setInfillPattern('concentric')
slicer.setVerbose(true)

result = slicer.slice(mesh)

# Should generate valid G-code with infill.
expect(result).toBeTruthy()
expect(result).toContain('; TYPE: FILL')

lines = result.split('\n')

# Extract infill extrusion G-code coordinates.
inFill = false
fillCoords = []

for line in lines

if line.includes('; TYPE: FILL')
inFill = true
else if line.includes('; TYPE:') and not line.includes('; TYPE: FILL')
inFill = false

if inFill and line.includes('G1') and line.includes('E')

xMatch = line.match(/X([\d.-]+)/)
yMatch = line.match(/Y([\d.-]+)/)

if xMatch and yMatch
fillCoords.push({ x: parseFloat(xMatch[1]), y: parseFloat(yMatch[1]) })

centerX = slicer.getBuildPlateWidth() / 2
centerY = slicer.getBuildPlateLength() / 2

# The circular hole has radius 6mm β€” no infill point should fall inside it.
holeRadius = 6
pointsNearHole = 0

for coord in fillCoords

dx = coord.x - centerX
dy = coord.y - centerY
distToCenter = Math.sqrt(dx * dx + dy * dy)

if distToCenter < holeRadius
pointsNearHole++

# No infill should be generated inside the circular hole.
expect(pointsNearHole).toBe(0)