Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
4 changes: 2 additions & 2 deletions resources/gcode/benchmarks/lego-brick.gcode
Git LFS file not shown
9 changes: 6 additions & 3 deletions src/slicer/skin/exposure/cavity.coffee
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@ bounds = require('../../utils/bounds')
# - It is interior to currentPath (does not touch its boundary).
# - It is the smaller of the two paired regions (candidateArea < refArea).
# - A reference region covers ≥50% of its area.
# - The size ratio between the two regions is within the step-transition range (10-55%).
# - The size ratio between the two regions is below the step-transition ceiling (<55%).
# No lower bound is imposed: small features such as studs on a large slab are
# legitimate covered regions even when their area is a tiny fraction of the reference.
#
# The boundary check is applied to the candidate only. The reference region may
# legitimately extend to the layer boundary (e.g. the larger slab in an inverted
Expand Down Expand Up @@ -75,9 +77,10 @@ findCoveredRegions = (regionCandidates, regionRefs, currentPathBounds, currentAr
largerArea = Math.max(candidateArea, refArea)
sizeRatio = smallerArea / largerArea

# Filter: size ratio 10-55% (excludes tiny holes and similar-sized regions).
# Filter: size ratio <55% (excludes similar-sized regions).
# No minimum: small features like studs on a large slab are valid covered regions.
# Candidate must be the smaller of the two regions.
if sizeRatio >= 0.10 and sizeRatio < 0.55 and candidateArea < refArea
if sizeRatio < 0.55 and candidateArea < refArea

covered.push(candidate)
break
Expand Down
92 changes: 92 additions & 0 deletions src/slicer/skin/exposure/cavity.test.coffee
Original file line number Diff line number Diff line change
Expand Up @@ -1151,3 +1151,95 @@ describe 'Exposure Detection - Cavity and Hole Detection', ->
expect(sphereLayerSections[sphereTotalLayers - 9].fill).toBeGreaterThan(0)
expect(sphereLayerSections[sphereTotalLayers - 8].skin).toBeGreaterThan(0)
expect(sphereLayerSections[sphereTotalLayers - 8].fill).toBeGreaterThan(0)

describe 'Small Features on Large Base (Lego Stud Scenario)', ->

test 'should detect multiple small cylinders on large flat slab as fully covered regions', ->

# Regression test for the lego brick issue.
# A large flat slab has 6 small cylinders on top (like lego studs).
# The stud cross-sections are ~3-5% of the slab area, well below the old 10%
# minimum size ratio threshold that blocked their detection as covered regions.
# After the fix (removing the 10% lower bound), these small features should
# be detected as fully covered regions so that:
# - The skin infill on the transition layers has exclusion zones under the studs.
# - Each stud area gets a skin wall + regular infill instead of skin infill.
slabWidth = 48
slabDepth = 24
slabHeight = 10
studRadius = 2.4 # Standard lego stud radius (4.8mm diameter).
studHeight = 6

# Create the large flat slab.
slabGeometry = new THREE.BoxGeometry(slabWidth, slabDepth, slabHeight)
slabMesh = new THREE.Mesh(slabGeometry, new THREE.MeshBasicMaterial())
slabMesh.position.set(0, 0, 0)
slabMesh.updateMatrixWorld()

# Create 6 small cylinders (2x3 grid) positioned on top of the slab.
xPositions = [-16, 0, 16]
yPositions = [-8, 8]
combinedMesh = slabMesh

for xPos in xPositions

for yPos in yPositions

studGeometry = new THREE.CylinderGeometry(studRadius, studRadius, studHeight, 20)
studMesh = new THREE.Mesh(studGeometry, new THREE.MeshBasicMaterial())
studMesh.position.set(xPos, yPos, (slabHeight + studHeight) / 2)
studMesh.rotation.x = Math.PI / 2
studMesh.updateMatrixWorld()

combinedMesh = await Polytree.unite(combinedMesh, studMesh)

finalMesh = new THREE.Mesh(combinedMesh.geometry, combinedMesh.material)
finalMesh.position.set(0, 0, slabHeight / 2)
finalMesh.updateMatrixWorld()

# Configure slicer with exposure detection enabled.
slicer.setLayerHeight(0.2)
slicer.setShellSkinThickness(0.8) # 4 skin layers.
slicer.setShellWallThickness(0.8)
slicer.setVerbose(true)
slicer.setAutohome(false)
slicer.setExposureDetection(true)
slicer.setInfillDensity(20)
slicer.setInfillPattern('grid')

result = slicer.slice(finalMesh)

# The slab is 10mm tall → ~50 layers.
# The studs are 6mm tall → ~30 more layers.
# Transition layers (top of slab, below the studs): layers 47-50.
# These layers should have:
# - TYPE: SKIN for the exposed outer area (around the studs).
# - TYPE: FILL for the covered areas below each stud.
lines = result.split('\n')
layerSections = {}
currentLayer = null

for line in lines

if line.includes('LAYER:')

layerMatch = line.match(/LAYER:\s*(\d+) of/)

if layerMatch
currentLayer = parseInt(layerMatch[1])
layerSections[currentLayer] = { skin: 0, fill: 0 }

else if currentLayer? and line.includes('TYPE: SKIN')

layerSections[currentLayer].skin++

else if currentLayer? and line.includes('TYPE: FILL')

layerSections[currentLayer].fill++

# Layer 47 is the first transition layer (top of slab, 4 layers before stud top).
# It must have both SKIN (exposed ring) and FILL (under each stud) sections.
layer47 = layerSections[47]
expect(layer47).toBeDefined()
expect(layer47.skin).toBeGreaterThan(0) # Exposed area around the studs.
expect(layer47.fill).toBeGreaterThan(0) # Regular infill under the studs.