diff --git a/src/slicer/infill/patterns/concentric.coffee b/src/slicer/infill/patterns/concentric.coffee index 7524833b..39cb7f4e 100644 --- a/src/slicer/infill/patterns/concentric.coffee +++ b/src/slicer/infill/patterns/concentric.coffee @@ -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 = @@ -50,93 +50,187 @@ 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 = [] - pointsInHoles = 0 + for i in [0...currentLoop.length] - for sampleIdx in [0...sampleCount] + segStart = currentLoop[i] + segEnd = currentLoop[(i + 1) % currentLoop.length] - # Distribute samples evenly across the loop's length. - pointIdx = Math.floor(sampleIdx * currentLoop.length / sampleCount) - testPoint = currentLoop[pointIdx] + clippedSegs = clipping.clipLineWithHoles(segStart, segEnd, infillBoundary, holeInnerWalls) - # Check if this point is inside any hole. - for holeWall in holeInnerWalls + for seg in clippedSegs - if holeWall.length >= 3 and primitives.pointInPolygon(testPoint, holeWall) + validSegments.push(seg) - pointsInHoles++ - break + continue if validSegments.length is 0 - # Skip loop only if majority of sampled points are inside holes. - # This prevents skipping loops that merely pass near holes. - if pointsInHoles > sampleCount / 2 + # Group consecutive segments into polylines to minimize travel moves. + polylines = [] + currentPolyline = [validSegments[0].start, validSegments[0].end] - skipLoop = true + for segIdx in [1...validSegments.length] - continue if skipLoop + seg = validSegments[segIdx] + prevEnd = currentPolyline[currentPolyline.length - 1] - # Find optimal start point on this loop if we have a last position. - startIndex = 0 + dx = seg.start.x - prevEnd.x + dy = seg.start.y - prevEnd.y - if lastEndPoint? + if dx * dx + dy * dy < 0.001 * 0.001 - minDistSq = Infinity + currentPolyline.push(seg.end) - for i in [0...currentLoop.length] + else + + polylines.push(currentPolyline) + currentPolyline = [seg.start, seg.end] + + polylines.push(currentPolyline) + + # Select and render polylines in nearest-neighbour order to minimize travel. + remainingPolylines = polylines.slice() + + while remainingPolylines.length > 0 + + if lastEndPoint? + + # Find the polyline start/end closest to the current position. + bestIdx = 0 + bestFlipped = false + minDistSq = Infinity + + for plIdx in [0...remainingPolylines.length] + + pl = remainingPolylines[plIdx] + continue if pl.length < 2 + + startDistSq = (pl[0].x - lastEndPoint.x) ** 2 + (pl[0].y - lastEndPoint.y) ** 2 + endDistSq = (pl[pl.length - 1].x - lastEndPoint.x) ** 2 + (pl[pl.length - 1].y - lastEndPoint.y) ** 2 + + if startDistSq < minDistSq + + minDistSq = startDistSq + bestIdx = plIdx + bestFlipped = false + + if endDistSq < minDistSq + + minDistSq = endDistSq + bestIdx = plIdx + bestFlipped = true + + polyline = remainingPolylines.splice(bestIdx, 1)[0] + + if bestFlipped + + polyline = polyline.slice().reverse() + + else + + polyline = remainingPolylines.shift() + + 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 diff --git a/src/slicer/infill/patterns/concentric.test.coffee b/src/slicer/infill/patterns/concentric.test.coffee index 9eed1e67..005d8fb6 100644 --- a/src/slicer/infill/patterns/concentric.test.coffee +++ b/src/slicer/infill/patterns/concentric.test.coffee @@ -12,6 +12,34 @@ describe 'Concentric Infill Generation', -> slicer = new Polyslice({progressCallback: null}) + # Parse X/Y coordinates from all infill extrusion (G1 ... E) lines in a G-code string. + parseFillExtrusionCoords = (gcode) -> + + lines = gcode.split('\n') + inFill = false + coords = [] + + 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 + coords.push({ x: parseFloat(xMatch[1]), y: parseFloat(yMatch[1]) }) + + return coords + + # Tolerance (mm) applied to hole-radius assertions to absorb polygon approximation + # of circular hole boundaries (sliced as polygons, not true circles). + HOLE_TOLERANCE = 0.5 + describe 'Pattern Generation Tests', -> test 'should generate infill for middle layers', -> @@ -199,35 +227,16 @@ describe 'Concentric Infill Generation', -> # Verify that infill doesn't generate in the hole area. # Extract coordinates from infill sections. - 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') - - # Parse X and Y coordinates from G-code. - # Regex captures numeric value after X or Y (e.g., "X110.5" → match[1] = "110.5"). - xMatch = line.match(/X([\d.-]+)/) - yMatch = line.match(/Y([\d.-]+)/) - - if xMatch and yMatch - fillCoords.push({ x: parseFloat(xMatch[1]), y: parseFloat(yMatch[1]) }) + fillCoords = parseFillExtrusionCoords(result) # Check that no infill points are in the center hole area. # 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. + # Use HOLE_TOLERANCE to absorb polygon approximation of the circular hole. holeRadius = 3 - - # Count how many infill points are near the hole center. pointsNearHole = 0 for coord in fillCoords @@ -236,8 +245,75 @@ describe 'Concentric Infill Generation', -> dy = coord.y - centerY distToCenter = Math.sqrt(dx * dx + dy * dy) - if distToCenter < holeRadius + if distToCenter < holeRadius - HOLE_TOLERANCE pointsNearHole++ # 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 using shared helper. + fillCoords = parseFillExtrusionCoords(result) + + centerX = slicer.getBuildPlateWidth() / 2 + centerY = slicer.getBuildPlateLength() / 2 + + # The circular hole has radius 6mm. Use HOLE_TOLERANCE to absorb + # polygon approximation of the circular hole boundary. + 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 - HOLE_TOLERANCE + pointsNearHole++ + + # No infill should be generated inside the circular hole. + expect(pointsNearHole).toBe(0)