diff --git a/bin/pcb-to-stencil b/bin/pcb-to-stencil index 1636828..910388a 100755 Binary files a/bin/pcb-to-stencil and b/bin/pcb-to-stencil differ diff --git a/enclosure.go b/enclosure.go index e9002b4..f5bd1e0 100644 --- a/enclosure.go +++ b/enclosure.go @@ -513,15 +513,36 @@ func GenerateEnclosure(outlineImg image.Image, drillHoles []DrillHole, cfg Enclo bw := float64(x-runStartX) * pixelToMM bh := pixelToMM + isSpringRelief := false + if !curIsInner && bx >= float64(minBX)*pixelToMM-clearance-wt-1.0 && bx <= float64(maxBX)*pixelToMM+clearance+wt+1.0 { + // Check if the current pixel run constitutes either the left or right clip relief + pryWMM := 8.0 + by_center := float64(int(boardCenterY/float64(boardCount))) * pixelToMM + + leftClipX := float64(minBX)*pixelToMM - clearance - wt + rightClipX := float64(maxBX)*pixelToMM + clearance + wt + + if by >= by_center-pryWMM/2.0-0.5 && by <= by_center+pryWMM/2.0+0.5 { + if math.Abs(bx-leftClipX) <= 1.5 || math.Abs(bx-rightClipX) <= 1.5 { + isSpringRelief = true + } + } + } + if curIsInner { addBoxAtZ(&encTris, bx, by, trayFloor+snapHeight, bw, bh, totalH-(trayFloor+snapHeight)) - } else if curIsSnap { - // Snap groove: remove material from (trayFloor+snapHeight-0.7) to (trayFloor+snapHeight-0.1) - addBoxAtZ(&encTris, bx, by, trayFloor, bw, bh, snapHeight-0.7) - addBoxAtZ(&encTris, bx, by, trayFloor+snapHeight-0.1, bw, bh, totalH-(trayFloor+snapHeight-0.1)) } else { - // Outer wall - addBoxAtZ(&encTris, bx, by, trayFloor, bw, bh, totalH-trayFloor) + if isSpringRelief { + // For relief wall cut, omit the bottom solid wall material from the tray floor + addBoxAtZ(&encTris, bx, by, trayFloor+snapHeight+1.0, bw, bh, totalH-(trayFloor+snapHeight+1.0)) + } else if curIsSnap { + // Snap groove: remove material from (trayFloor+snapHeight-0.7) to (trayFloor+snapHeight-0.1) + addBoxAtZ(&encTris, bx, by, trayFloor, bw, bh, snapHeight-0.7) + addBoxAtZ(&encTris, bx, by, trayFloor+snapHeight-0.1, bw, bh, totalH-(trayFloor+snapHeight-0.1)) + } else { + // Outer wall + addBoxAtZ(&encTris, bx, by, trayFloor, bw, bh, totalH-trayFloor) + } } runStartX = x @@ -535,13 +556,33 @@ func GenerateEnclosure(outlineImg image.Image, drillHoles []DrillHole, cfg Enclo bw := float64(x-runStartX) * pixelToMM bh := pixelToMM + isSpringRelief := false + if !curIsInner && bx >= float64(minBX)*pixelToMM-clearance-wt-1.0 && bx <= float64(maxBX)*pixelToMM+clearance+wt+1.0 { + pryWMM := 8.0 + by_center := float64(int(boardCenterY/float64(boardCount))) * pixelToMM + + leftClipX := float64(minBX)*pixelToMM - clearance - wt + rightClipX := float64(maxBX)*pixelToMM + clearance + wt + + if by >= by_center-pryWMM/2.0-0.5 && by <= by_center+pryWMM/2.0+0.5 { + if math.Abs(bx-leftClipX) <= 1.5 || math.Abs(bx-rightClipX) <= 1.5 { + isSpringRelief = true + } + } + } + if curIsInner { addBoxAtZ(&encTris, bx, by, trayFloor+snapHeight, bw, bh, totalH-(trayFloor+snapHeight)) - } else if curIsSnap { - addBoxAtZ(&encTris, bx, by, trayFloor, bw, bh, snapHeight-0.7) - addBoxAtZ(&encTris, bx, by, trayFloor+snapHeight-0.1, bw, bh, totalH-(trayFloor+snapHeight-0.1)) } else { - addBoxAtZ(&encTris, bx, by, trayFloor, bw, bh, totalH-trayFloor) + if isSpringRelief { + // For relief wall cut, omit the bottom solid wall material from the tray floor + addBoxAtZ(&encTris, bx, by, trayFloor+snapHeight+1.0, bw, bh, totalH-(trayFloor+snapHeight+1.0)) + } else if curIsSnap { + addBoxAtZ(&encTris, bx, by, trayFloor, bw, bh, snapHeight-0.7) + addBoxAtZ(&encTris, bx, by, trayFloor+snapHeight-0.1, bw, bh, totalH-(trayFloor+snapHeight-0.1)) + } else { + addBoxAtZ(&encTris, bx, by, trayFloor, bw, bh, totalH-trayFloor) + } } runStartX = -1 } @@ -581,6 +622,7 @@ func GenerateEnclosure(outlineImg image.Image, drillHoles []DrillHole, cfg Enclo sideNum := -1 minDist := math.MaxFloat64 + var bestPosAlongSide float64 for _, bs := range boardSides { dx := bs.EndX - bs.StartX dy := bs.EndY - bs.StartY @@ -596,6 +638,7 @@ func GenerateEnclosure(outlineImg image.Image, drillHoles []DrillHole, cfg Enclo if dist < minDist { minDist = dist sideNum = bs.Num + bestPosAlongSide = t * bs.Length } } @@ -609,14 +652,18 @@ func GenerateEnclosure(outlineImg image.Image, drillHoles []DrillHole, cfg Enclo if c.Side != sideNum { continue } - // Wall below cutout: from 0 to cutout.Y - if c.Y > 0.1 { - addBoxAtZ(&cutoutEncTris, bx2, by2, 0, bw, bh, c.Y) + + minZ, maxZ := cutoutZBounds(c, bestPosAlongSide) + minZ += trayFloor + pcbT + maxZ += trayFloor + pcbT + + // Wall below cutout: from 0 to minZ + if minZ > 0.05 { + addBoxAtZ(&cutoutEncTris, bx2, by2, 0, bw, bh, minZ) } - // Wall above cutout: from cutout.Y+cutout.H to totalH - cutTop := c.Y + c.Height - if cutTop < totalH-0.1 { - addBoxAtZ(&cutoutEncTris, bx2, by2, cutTop, bw, bh, totalH-cutTop) + // Wall above cutout: from maxZ to totalH + if maxZ < totalH-0.05 { + addBoxAtZ(&cutoutEncTris, bx2, by2, maxZ, bw, bh, totalH-maxZ) } break } @@ -736,11 +783,9 @@ func GenerateEnclosure(outlineImg image.Image, drillHoles []DrillHole, cfg Enclo for y := 0; y < imgH; y++ { runStartX := -1 curIsWall := false - curIsBump := false for x := 0; x <= imgW; x++ { isTrayFloor := false isTrayWall := false - isTrayBump := false if x < imgW { idx := y*imgW + x if !pegMask[idx] { @@ -753,10 +798,6 @@ func GenerateEnclosure(outlineImg image.Image, drillHoles []DrillHole, cfg Enclo if dist > clearanceDistPx && dist <= trayWallOuterPx && !boardMask[idx] { isTrayWall = true } - // Tray Bumps sit on the outside of the Tray Wall - if dist > trayWallOuterPx && dist <= trayWallOuterPx+snapDepthPx && !boardMask[idx] { - isTrayBump = true - } } } @@ -764,24 +805,76 @@ func GenerateEnclosure(outlineImg image.Image, drillHoles []DrillHole, cfg Enclo if runStartX == -1 { runStartX = x curIsWall = isTrayWall - curIsBump = isTrayBump - } else if isTrayWall != curIsWall || isTrayBump != curIsBump { + } else if isTrayWall != curIsWall { bx := float64(runStartX) * pixelToMM by := float64(y) * pixelToMM bw := float64(x-runStartX) * pixelToMM bh := pixelToMM addBoxAtZ(&trayTris, bx, by, 0, bw, bh, trayFloor) - if curIsWall { - addBoxAtZ(&trayTris, bx, by, trayFloor, bw, bh, snapHeight) - } else if curIsBump { - // Adds a small 0.4mm bump on the outside of the wall - addBoxAtZ(&trayTris, bx, by, trayFloor+snapHeight-0.6, bw, bh, 0.4) + + wallBase := trayFloor + wallH := snapHeight + + // Evaluate cutout limits if this pixel run falls into a cutout mask + isCutout := false + for testX := runStartX; testX < x; testX++ { + if wallCutoutMask[y*imgW+testX] { + isCutout = true + break + } + } + + if isCutout && len(sideCutouts) > 0 { + midX := (runStartX + x) / 2 + bxMid := float64(midX)*pixelToMM + cfg.OutlineBounds.MinX + byMid := cfg.OutlineBounds.MaxY - float64(y)*pixelToMM + + sideNum := -1 + minDist := math.MaxFloat64 + var bestPosAlongSide float64 + for _, bs := range boardSides { + dx := bs.EndX - bs.StartX + dy := bs.EndY - bs.StartY + lenSq := dx*dx + dy*dy + if lenSq == 0 { + continue + } + t := ((bxMid-bs.StartX)*dx + (byMid-bs.StartY)*dy) / lenSq + tClamp := math.Max(0, math.Min(1, t)) + projX := bs.StartX + tClamp*dx + projY := bs.StartY + tClamp*dy + dist := math.Sqrt((bxMid-projX)*(bxMid-projX) + (byMid-projY)*(byMid-projY)) + if dist < minDist { + minDist = dist + sideNum = bs.Num + bestPosAlongSide = t * bs.Length + } + } + + for _, c := range sideCutouts { + if c.Side == sideNum { + minZ, _ := cutoutZBounds(c, bestPosAlongSide) + minZ += trayFloor + pcbT + + // Tray wall goes up to trayFloor + snapHeight. If minZ is lower, truncate it. + if minZ < trayFloor+wallH { + wallH = minZ - trayFloor + if wallH < 0 { + wallH = 0 + } + } + break + } + } + } + + if curIsWall && wallH > 0.05 { + addBoxAtZ(&trayTris, bx, by, wallBase, bw, bh, wallH) } runStartX = x curIsWall = isTrayWall - curIsBump = isTrayBump } } else { if runStartX != -1 { @@ -791,10 +884,64 @@ func GenerateEnclosure(outlineImg image.Image, drillHoles []DrillHole, cfg Enclo bh := pixelToMM addBoxAtZ(&trayTris, bx, by, 0, bw, bh, trayFloor) - if curIsWall { - addBoxAtZ(&trayTris, bx, by, trayFloor, bw, bh, snapHeight) - } else if curIsBump { - addBoxAtZ(&trayTris, bx, by, trayFloor+snapHeight-0.6, bw, bh, 0.4) + + wallBase := trayFloor + wallH := snapHeight + + // Evaluate cutout limits if this pixel run falls into a cutout mask + isCutout := false + for testX := runStartX; testX < x; testX++ { + if wallCutoutMask[y*imgW+testX] { + isCutout = true + break + } + } + + if isCutout && len(sideCutouts) > 0 { + midX := (runStartX + x) / 2 + bxMid := float64(midX)*pixelToMM + cfg.OutlineBounds.MinX + byMid := cfg.OutlineBounds.MaxY - float64(y)*pixelToMM + + sideNum := -1 + minDist := math.MaxFloat64 + var bestPosAlongSide float64 + for _, bs := range boardSides { + dx := bs.EndX - bs.StartX + dy := bs.EndY - bs.StartY + lenSq := dx*dx + dy*dy + if lenSq == 0 { + continue + } + t := ((bxMid-bs.StartX)*dx + (byMid-bs.StartY)*dy) / lenSq + tClamp := math.Max(0, math.Min(1, t)) + projX := bs.StartX + tClamp*dx + projY := bs.StartY + tClamp*dy + dist := math.Sqrt((bxMid-projX)*(bxMid-projX) + (byMid-projY)*(byMid-projY)) + if dist < minDist { + minDist = dist + sideNum = bs.Num + bestPosAlongSide = t * bs.Length + } + } + + for _, c := range sideCutouts { + if c.Side == sideNum { + minZ, _ := cutoutZBounds(c, bestPosAlongSide) + minZ += trayFloor + pcbT + + if minZ < trayFloor+wallH { + wallH = minZ - trayFloor + if wallH < 0 { + wallH = 0 + } + } + break + } + } + } + + if curIsWall && wallH > 0.05 { + addBoxAtZ(&trayTris, bx, by, wallBase, bw, bh, wallH) } runStartX = -1 } @@ -802,11 +949,40 @@ func GenerateEnclosure(outlineImg image.Image, drillHoles []DrillHole, cfg Enclo } } - // (Old PCB support rim, snap bump, embossed lip, and removal tab loops have been permanently removed because the Tray geometry forms a flush fitting bottom shoe-box lid interface) - fmt.Printf("Enclosure: %d triangles, Tray: %d triangles\n", len(encTris), len(trayTris)) + // Add Pry Clips to the Tray to sit under the Enclosure Pry Slots + if boardCount > 0 { + pryWMM := 8.0 + pryDMM := 1.0 + clipH := 0.8 + + leftX := float64(minBX)*pixelToMM - clearance - wt + rightX := float64(maxBX)*pixelToMM + clearance + wt + by_center := float64(int(boardCenterY/float64(boardCount))) * pixelToMM + + // Z coordinates: trayFloor + snapHeight - clipH ensures the clip finishes flush with the top of the tray wall + addBoxAtZ(&trayTris, leftX-pryDMM, by_center-pryWMM/2.0, trayFloor+snapHeight-clipH, pryDMM, pryWMM, clipH) + addBoxAtZ(&trayTris, rightX, by_center-pryWMM/2.0, trayFloor+snapHeight-clipH, pryDMM, pryWMM, clipH) + } _ = math.Pi // keep math import for Phase 2 cylindrical pegs + // Shift meshes to origin so the exported STL is centered + offsetX := float64(imgW) * pixelToMM / 2.0 + offsetY := float64(imgH) * pixelToMM / 2.0 + + for i := range encTris { + for j := 0; j < 3; j++ { + encTris[i][j].X -= offsetX + encTris[i][j].Y -= offsetY + } + } + for i := range trayTris { + for j := 0; j < 3; j++ { + trayTris[i][j].X -= offsetX + trayTris[i][j].Y -= offsetY + } + } + return &EnclosureResult{ EnclosureTriangles: encTris, TrayTriangles: trayTris, @@ -941,3 +1117,27 @@ func floodFillExterior(pixels []bool, w, h int) []bool { return exterior } + +// cutoutZBounds calculates the accurate Z bounds taking into account corner radii +func cutoutZBounds(c SideCutout, posAlongSide float64) (float64, float64) { + minZ := c.Y + maxZ := c.Y + c.Height + + if c.CornerRadius > 0 { + r := c.CornerRadius + localX := posAlongSide - c.X + + if localX < r { + dx := r - localX + dy := r - math.Sqrt(math.Max(0, r*r-dx*dx)) + minZ += dy + maxZ -= dy + } else if localX > c.Width-r { + dx := localX - (c.Width - r) + dy := r - math.Sqrt(math.Max(0, r*r-dx*dx)) + minZ += dy + maxZ -= dy + } + } + return minZ, maxZ +} diff --git a/main.go b/main.go index 6ca5ce1..3d8d50f 100644 --- a/main.go +++ b/main.go @@ -988,6 +988,7 @@ func enclosureUploadHandler(w http.ResponseWriter, r *http.Request) { } } + pixelToMM := 25.4 / ecfg.DPI session := &EnclosureSession{ Exports: r.Form["exports"], OutlineGf: outlineGf, @@ -1000,10 +1001,10 @@ func enclosureUploadHandler(w http.ResponseWriter, r *http.Request) { BoardW: actualBoardW, BoardH: actualBoardH, TotalH: ecfg.WallHeight + ecfg.PCBThickness + 1.0, - MinBX: float64(minBX), - MaxBX: float64(maxBX), - BoardCenterY: boardCenterY, - Sides: ExtractBoardSidesFromMask(boardMask, imgW, imgH, 25.4/ecfg.DPI, &outlineBounds), + MinBX: float64(minBX)*pixelToMM + outlineBounds.MinX, + MaxBX: float64(maxBX)*pixelToMM + outlineBounds.MinX, + BoardCenterY: outlineBounds.MaxY - boardCenterY*pixelToMM, + Sides: ExtractBoardSidesFromMask(boardMask, imgW, imgH, pixelToMM, &outlineBounds), } sessionsMu.Lock() sessions[uuid] = session diff --git a/scad.go b/scad.go index a329c1d..e5442d7 100644 --- a/scad.go +++ b/scad.go @@ -270,8 +270,8 @@ func WriteNativeSCAD(filename string, isTray bool, outlineVertices [][2]float64, continue } - // Cutouts are relative to board. - z := c.Height/2 + trayFloor + pcbT + // Cutouts are relative to board. UI specifies c.Y from bottom, so c.Y adds to Z. + z := c.Height/2 + trayFloor + pcbT + c.Y w, d, h := c.Width, 20.0, c.Height // d is deep enough to cut through walls dx := bs.EndX - bs.StartX @@ -287,7 +287,19 @@ func WriteNativeSCAD(filename string, isTray bool, outlineVertices [][2]float64, rotDeg := (bs.Angle * 180.0 / math.Pi) - 90.0 - fmt.Fprintf(f, " translate([%f, %f, %f]) rotate([0, 0, %f]) cube([%f, %f, %f], center=true);\n", midX, midY, z, rotDeg, w, d, h) + if c.CornerRadius > 0 { + r := c.CornerRadius + fmt.Fprintf(f, " translate([%f, %f, %f]) rotate([0, 0, %f]) {\n", midX, midY, z, rotDeg) + fmt.Fprintf(f, " hull() {\n") + fmt.Fprintf(f, " translate([%f, 0, %f]) rotate([90, 0, 0]) cylinder(r=%f, h=%f, center=true);\n", w/2-r, h/2-r, r, d) + fmt.Fprintf(f, " translate([%f, 0, %f]) rotate([90, 0, 0]) cylinder(r=%f, h=%f, center=true);\n", -(w/2 - r), h/2-r, r, d) + fmt.Fprintf(f, " translate([%f, 0, %f]) rotate([90, 0, 0]) cylinder(r=%f, h=%f, center=true);\n", w/2-r, -(h/2 - r), r, d) + fmt.Fprintf(f, " translate([%f, 0, %f]) rotate([90, 0, 0]) cylinder(r=%f, h=%f, center=true);\n", -(w/2 - r), -(h/2 - r), r, d) + fmt.Fprintf(f, " }\n") + fmt.Fprintf(f, " }\n") + } else { + fmt.Fprintf(f, " translate([%f, %f, %f]) rotate([0, 0, %f]) cube([%f, %f, %f], center=true);\n", midX, midY, z, rotDeg, w, d, h) + } } fmt.Fprintf(f, "}\n\n") @@ -299,6 +311,18 @@ func WriteNativeSCAD(filename string, isTray bool, outlineVertices [][2]float64, fmt.Fprintf(f, " translate([%f, %f, 0]) cube([%f, %f, %f], center=true);\n", maxBX+clearance+wt-pryD/2, boardCenterY, pryD*2, pryW, snapHeight*3) fmt.Fprintf(f, "}\n\n") + // Print Pry Clips Module + fmt.Fprintf(f, "module pry_clips() {\n") + clipH := 0.8 + clipZ := trayFloor + snapHeight - clipH/2.0 + fmt.Fprintf(f, " translate([%f, %f, %f]) cube([%f, %f, %f], center=true);\n", minBX-clearance-wt-0.5, boardCenterY, clipZ, 1.0, pryW, clipH) + fmt.Fprintf(f, " translate([%f, %f, %f]) cube([%f, %f, %f], center=true);\n", maxBX+clearance+wt+0.5, boardCenterY, clipZ, 1.0, pryW, clipH) + fmt.Fprintf(f, "}\n\n") + + centerX := cfg.OutlineBounds.MinX + (cfg.OutlineBounds.MaxX-cfg.OutlineBounds.MinX)/2.0 + centerY := cfg.OutlineBounds.MinY + (cfg.OutlineBounds.MaxY-cfg.OutlineBounds.MinY)/2.0 + fmt.Fprintf(f, "translate([%f, %f, 0]) {\n", -centerX, -centerY) + if isTray { // --- TRAY --- fmt.Fprintf(f, "// --- TRAY ---\n") @@ -311,19 +335,6 @@ func WriteNativeSCAD(filename string, isTray bool, outlineVertices [][2]float64, fmt.Fprintf(f, " offset(r=%f) board_polygon();\n", clearance+wt) fmt.Fprintf(f, " offset(r=%f) board_polygon();\n", clearance) fmt.Fprintf(f, " }\n") - fmt.Fprintf(f, " // Snap Bumps (on outside of tray wall)\n") - fmt.Fprintf(f, " translate([0,0,%f]) linear_extrude(height=%f) difference() {\n", trayFloor+snapHeight-0.6, 0.4) - fmt.Fprintf(f, " offset(r=%f) board_polygon();\n", clearance+wt+0.4) - fmt.Fprintf(f, " offset(r=%f) board_polygon();\n", clearance+wt) - fmt.Fprintf(f, " }\n") - fmt.Fprintf(f, " // Mounting Pegs\n") - for _, hole := range holes { - if hole.Type != DrillTypeMounting { - continue - } - pegRadius := (hole.Diameter / 2.0) - 0.15 - fmt.Fprintf(f, " translate([%f,%f,0]) cylinder(h=%f, r=%f, $fn=32);\n", hole.X, hole.Y, totalH-lidThick, pegRadius) - } fmt.Fprintf(f, " }\n") fmt.Fprintf(f, " // Subtract Lip Recess (for easy opening)\n") @@ -340,9 +351,9 @@ func WriteNativeSCAD(filename string, isTray bool, outlineVertices [][2]float64, socketRadius := (hole.Diameter / 2.0) + 0.1 fmt.Fprintf(f, " translate([%f,%f,-1]) cylinder(h=%f, r=%f, $fn=32);\n", hole.X, hole.Y, trayFloor+2, socketRadius) } - fmt.Fprintf(f, " pry_slots();\n") fmt.Fprintf(f, " side_cutouts();\n") - fmt.Fprintf(f, "}\n\n") + fmt.Fprintf(f, "}\n") + fmt.Fprintf(f, "pry_clips();\n\n") } else { // --- ENCLOSURE --- @@ -357,16 +368,18 @@ func WriteNativeSCAD(filename string, isTray bool, outlineVertices [][2]float64, fmt.Fprintf(f, " // Subtract Tray Recess (Accommodates Tray Wall)\n") fmt.Fprintf(f, " translate([0,0,-1]) linear_extrude(height=%f) offset(r=%f) board_polygon();\n", trayFloor+snapHeight+0.2, clearance+wt+0.15) - fmt.Fprintf(f, " // Subtract Snap Groove\n") - fmt.Fprintf(f, " translate([0,0,%f]) linear_extrude(height=%f) difference() {\n", trayFloor+snapHeight-0.7, 0.6) - fmt.Fprintf(f, " offset(r=%f) board_polygon();\n", clearance+wt+0.5) - fmt.Fprintf(f, " offset(r=%f) board_polygon();\n", clearance+wt) - fmt.Fprintf(f, " }\n") + fmt.Fprintf(f, " // Vertical relief slots for the tray clips to slide into\n") + fmt.Fprintf(f, " clipZ = %f;\n", trayFloor+snapHeight) + fmt.Fprintf(f, " translate([%f, %f, trayFloor - 1]) cube([1.5, %f, clipZ+1], center=true);\n", minBX-clearance-wt-0.25, boardCenterY, pryW+1.0) + fmt.Fprintf(f, " translate([%f, %f, trayFloor - 1]) cube([1.5, %f, clipZ+1], center=true);\n", maxBX+clearance+wt+0.25, boardCenterY, pryW+1.0) + fmt.Fprintf(f, " pry_slots();\n") fmt.Fprintf(f, " side_cutouts();\n") fmt.Fprintf(f, "}\n") fmt.Fprintf(f, "mounting_pegs(false);\n") } + fmt.Fprintf(f, "}\n") // Close the top-level translate + return nil } diff --git a/static/index.html b/static/index.html index c490b99..7c3405a 100644 --- a/static/index.html +++ b/static/index.html @@ -27,7 +27,8 @@ Export