diff --git a/enclosure.go b/enclosure.go index 0bbaab5..426e077 100644 --- a/enclosure.go +++ b/enclosure.go @@ -51,63 +51,92 @@ func GenerateEnclosure(outlineImg image.Image, drillHoles []DrillHole, cfg Enclo imgH := bounds.Max.Y // Use ComputeWallMask to get the board shape and wall around it - // WallThickness for enclosure = clearance + wall thickness - totalWallMM := cfg.Clearance + cfg.WallThickness + // WallThickness for enclosure = clearance + 2 * wall thickness + clearance := cfg.Clearance + wt := cfg.WallThickness + lidThick := wt + snapHeight := 2.5 + totalWallMM := clearance + 2.0*wt fmt.Printf("Computing board shape (wall=%.1fmm)...\n", totalWallMM) - wallMask, boardMask := ComputeWallMask(outlineImg, totalWallMM, pixelToMM) - - // Also compute a thinner mask for just the clearance zone (inner wall boundary) - clearanceMask, _ := ComputeWallMask(outlineImg, cfg.Clearance, pixelToMM) + wallMask, boardMask := ComputeWallMask(outlineImg, totalWallMM, pixelToMM) // wallMask is now an int slice // Determine the actual enclosure boundary = wall | board (expanded by clearance) // wallMask = pixels that are the wall // boardMask = pixels inside the board outline - // clearanceMask = pixels in the clearance zone around the board + // clearanceMask is just an expansion of boardMask using distance logic up to clearance + // However, we already have wallDist which measures distance OUTWARD from board + clearanceDistPx := int(clearance * cfg.DPI / 25.4) + trayWallOuterPx := int((clearance + wt) * cfg.DPI / 25.4) + encWallOuterPx := int((clearance + 2.0*wt) * cfg.DPI / 25.4) + snapDepthPx := int(0.5 * cfg.DPI / 25.4) + if snapDepthPx < 1 { + snapDepthPx = 1 + } - // The enclosure walls are: wallMask pixels that are NOT in the clearance zone - // Actually: wallMask gives us everything from board edge out to totalWall distance - // clearanceMask gives us board edge out to clearance distance - // Real wall = wallMask AND NOT clearanceMask AND NOT boardMask - - // Dimensions - trayFloor := 1.0 // mm + // Total height of the enclosure (from bottom of tray to top of lid) pcbT := cfg.PCBThickness - totalH := cfg.WallHeight + pcbT + trayFloor // total enclosure height - lidThick := cfg.WallThickness // lid thickness at top + trayFloor := pcbT + 0.5 // Tray floor is 0.5mm thick, sits below PCB + totalH := trayFloor + cfg.WallHeight + lidThick - // Snap-fit dimensions - snapHeight := 1.5 - snapFromBottom := trayFloor + 0.3 - - // Tab dimensions - tabW := 8.0 - tabD := 6.0 - tabH := 2.0 - - // ========================================== - // ENCLOSURE (top shell — conforms to board shape) - // ========================================== - var encTris [][3]Point - fmt.Println("Generating edge-cut conforming enclosure...") - - // Walls: scan through the image and create boxes for wall pixels - // A pixel is "wall" if it's in wallMask but NOT in clearanceMask and NOT in boardMask - // Actually simpler: wallMask already represents the OUTSIDE ring. - // wallMask = pixels outside board but within thickness distance - // boardMask = pixels inside the board - // So wall pixels are: wallMask[i] && !boardMask[i] - // But we also want to separate outer wall from inner clearance: - // Outer wall = wallMask && !clearanceMask (the actual solid wall material) - // Inner clearance = clearanceMask (air gap between wall and PCB) - - // For the enclosure walls, we want the OUTER wall portion only - // Wall pixels = wallMask[i] && !clearanceMask[i] && !boardMask[i] - - // For the lid, we want to cover everything within the outer wall boundary - // Lid pixels = wallMask[i] || boardMask[i] || clearanceMask[i] - // (i.e., the entire footprint of the enclosure) size := imgW * imgH + var encTris [][3]Point + var trayTris [][3]Point + + // Mounting pegs from NPTH holes: cylinders going from lid downward + pegMask := make([]bool, size) // true = peg/socket at this pixel (exclude from tray floor) + if cfg.OutlineBounds != nil { + mountingHoles := 0 + for _, h := range drillHoles { + if h.Type != DrillTypeMounting { + continue + } + mountingHoles++ + + // Convert drill mm coordinates to pixel coordinates + px := (h.X - cfg.OutlineBounds.MinX) * cfg.DPI / 25.4 + py := (h.Y - cfg.OutlineBounds.MinY) * cfg.DPI / 25.4 + + // Peg radius slightly smaller than hole for press fit + pegRadiusMM := (h.Diameter / 2) - 0.15 + pegRadiusPx := pegRadiusMM * cfg.DPI / 25.4 + // Socket radius slightly larger for easy insertion + socketRadiusPx := (h.Diameter/2 + 0.1) * cfg.DPI / 25.4 + + // Peg height: from bottom (z=0) up to lid + pegH := totalH - lidThick + + // Scan a bounding box around the hole + rInt := int(socketRadiusPx) + 2 + cx, cy := int(px), int(py) + + for dy := -rInt; dy <= rInt; dy++ { + for dx := -rInt; dx <= rInt; dx++ { + ix, iy := cx+dx, cy+dy + if ix < 0 || ix >= imgW || iy < 0 || iy >= imgH { + continue + } + dist := math.Sqrt(float64(dx*dx + dy*dy)) + + // Peg cylinder (in enclosure, from z=0 up to lid) + if dist <= pegRadiusPx { + bx := float64(ix) * pixelToMM + by := float64(iy) * pixelToMM + addBoxAtZ(&encTris, bx, by, 0, pixelToMM, pixelToMM, pegH) + } + + // Socket mask (for tray floor removal) + if dist <= socketRadiusPx { + pegMask[iy*imgW+ix] = true + } + } + } + } + if mountingHoles > 0 { + fmt.Printf("Generated %d mounting pegs\n", mountingHoles) + } + } + // Pre-compute board bounding box (needed for side cutout detection and removal tabs) minBX, minBY := imgW, imgH maxBX, maxBY := 0, 0 @@ -135,6 +164,57 @@ func GenerateEnclosure(outlineImg image.Image, drillHoles []DrillHole, cfg Enclo } } + // === APPLY PRY SLOT CUTOUTS TO WALL MASK BEFORE MESHING === + // We want 8mm wide by 1.5mm deep slots in the left and right exterior walls + if boardCount > 0 { + pryWPx := int(8.0 * cfg.DPI / 25.4) + pryDPx := int(1.5 * cfg.DPI / 25.4) + if pryWPx < 1 { + pryWPx = 1 + } + if pryDPx < 1 { + pryDPx = 1 + } + + centerYPx := int(boardCenterY / float64(boardCount)) + leftXPx := minBX + rightXPx := maxBX + + // For the left side, we clear the wall mask from minBX-wallPx up to minBX-wallPx+pryDPx + for y := centerYPx - pryWPx/2; y <= centerYPx+pryWPx/2; y++ { + if y < 0 || y >= imgH { + continue + } + // Find outer edge of wall on the left + for x := 0; x < leftXPx; x++ { + idx := y*imgW + x + if wallMask[idx] > clearanceDistPx { + // Blank out the outermost pryDPx pixels of the wall + for dx := 0; dx < pryDPx; dx++ { + if x+dx < imgW { + wallMask[y*imgW+(x+dx)] = -1 + } + } + break // Only do the outer edge + } + } + + // Find outer edge of wall on the right (search backwards) + for x := imgW - 1; x > rightXPx; x-- { + idx := y*imgW + x + if wallMask[idx] > clearanceDistPx { + // Blank out the outermost pryDPx pixels of the wall + for dx := 0; dx < pryDPx; dx++ { + if x-dx >= 0 { + wallMask[y*imgW+(x-dx)] = -1 + } + } + break // Only do the outer edge + } + } + } + } + // Build wall-cutout mask from side cutouts // For each side cutout, determine which wall pixels to subtract wallCutoutMask := make([]bool, size) @@ -143,7 +223,7 @@ func GenerateEnclosure(outlineImg image.Image, drillHoles []DrillHole, cfg Enclo for y := 0; y < imgH; y++ { for x := 0; x < imgW; x++ { idx := y*imgW + x - if !(wallMask[idx] && !clearanceMask[idx] && !boardMask[idx]) { + if !(wallMask[idx] > clearanceDistPx && wallMask[idx] <= encWallOuterPx && !boardMask[idx]) { continue // not a wall pixel } @@ -204,28 +284,77 @@ func GenerateEnclosure(outlineImg image.Image, drillHoles []DrillHole, cfg Enclo fmt.Printf("Wall cutout mask: applied %d side cutouts\n", len(sideCutouts)) } - // Generate walls using RLE + // ENCLOSURE (top shell — conforms to board shape) + // ========================================== + fmt.Println("Generating edge-cut conforming enclosure...") + + // The Enclosure Wall sits on top of the Tray Floor (starts at Z = trayFloor) + // Inner Wall (above snapHeight) = `clearanceDistPx` to `trayWallOuterPx` + // Outer Wall (full height) = `trayWallOuterPx` to `encWallOuterPx` for y := 0; y < imgH; y++ { - runStart := -1 + runStartX := -1 + curIsInner := false + curIsSnap := false for x := 0; x <= imgW; x++ { - isWallPixel := false + isWallPx := false + isInnerWall := false + isSnapGroove := false if x < imgW { idx := y*imgW + x - isWallPixel = wallMask[idx] && !clearanceMask[idx] && !boardMask[idx] + dist := wallMask[idx] + if dist > clearanceDistPx && dist <= encWallOuterPx && !boardMask[idx] && !pegMask[idx] { + isWallPx = true + if dist <= trayWallOuterPx { + isInnerWall = true + } else if dist <= trayWallOuterPx+snapDepthPx { + isSnapGroove = true + } + } } - if isWallPixel { - if runStart == -1 { - runStart = x + if isWallPx { + if runStartX == -1 { + runStartX = x + curIsInner = isInnerWall + curIsSnap = isSnapGroove + } else if isInnerWall != curIsInner || isSnapGroove != curIsSnap { + // boundary between inner, outer, and snap groove + bx := float64(runStartX) * pixelToMM + by := float64(y) * pixelToMM + bw := float64(x-runStartX) * pixelToMM + bh := pixelToMM + + 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) + } + + runStartX = x + curIsInner = isInnerWall + curIsSnap = isSnapGroove } } else { - if runStart != -1 { - bx := float64(runStart) * pixelToMM + if runStartX != -1 { + bx := float64(runStartX) * pixelToMM by := float64(y) * pixelToMM - bw := float64(x-runStart) * pixelToMM + bw := float64(x-runStartX) * pixelToMM bh := pixelToMM - AddBox(&encTris, bx, by, bw, bh, totalH) - runStart = -1 + + 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) + } + runStartX = -1 } } } @@ -315,7 +444,7 @@ func GenerateEnclosure(outlineImg image.Image, drillHoles []DrillHole, cfg Enclo isWallPixel := false if x < imgW { idx := y*imgW + x - isWallPixel = wallMask[idx] && !clearanceMask[idx] && !boardMask[idx] && !wallCutoutMask[idx] + isWallPixel = wallMask[idx] > clearanceDistPx && wallMask[idx] <= encWallOuterPx && !boardMask[idx] && !wallCutoutMask[idx] } if isWallPixel { @@ -327,8 +456,8 @@ func GenerateEnclosure(outlineImg image.Image, drillHoles []DrillHole, cfg Enclo bx := float64(runStart) * pixelToMM by2 := float64(y) * pixelToMM bw := float64(x-runStart) * pixelToMM - bb := pixelToMM - AddBox(&newEncTris, bx, by2, bw, bb, totalH) + bh := pixelToMM + AddBox(&newEncTris, bx, by2, bw, bh, totalH) runStart = -1 } } @@ -339,6 +468,8 @@ func GenerateEnclosure(outlineImg image.Image, drillHoles []DrillHole, cfg Enclo encTris = newEncTris } + // Note: We handled pry slots by cropping the wallMask before running the generation. + // Lid: cover the entire enclosure footprint at the top // Lid pixels = any pixel in wallMask OR clearanceMask OR boardMask // Subtract courtyard regions (component footprints) from the lid @@ -380,7 +511,7 @@ func GenerateEnclosure(outlineImg image.Image, drillHoles []DrillHole, cfg Enclo isLidPixel := false if x < imgW { idx := y*imgW + x - inFootprint := wallMask[idx] || clearanceMask[idx] || boardMask[idx] + inFootprint := (wallMask[idx] >= 0 && wallMask[idx] <= encWallOuterPx) || boardMask[idx] // Cut lid where combined cutout exists inside the board area isCutout := combinedCutout[idx] && boardMask[idx] isLidPixel = inFootprint && !isCutout @@ -402,362 +533,83 @@ func GenerateEnclosure(outlineImg image.Image, drillHoles []DrillHole, cfg Enclo } } } - // Mounting pegs from NPTH holes: cylinders going from lid downward - pegMask := make([]bool, size) // true = peg/socket at this pixel (exclude from tray floor) - if cfg.OutlineBounds != nil { - mountingHoles := 0 - for _, h := range drillHoles { - if h.Type != DrillTypeMounting { - continue - } - mountingHoles++ - - // Convert drill mm coordinates to pixel coordinates - px := (h.X - cfg.OutlineBounds.MinX) * cfg.DPI / 25.4 - py := (h.Y - cfg.OutlineBounds.MinY) * cfg.DPI / 25.4 - - // Peg radius slightly smaller than hole for press fit - pegRadiusMM := (h.Diameter / 2) - 0.15 - pegRadiusPx := pegRadiusMM * cfg.DPI / 25.4 - // Socket radius slightly larger for easy insertion - socketRadiusPx := (h.Diameter/2 + 0.1) * cfg.DPI / 25.4 - - // Peg height: from bottom (z=0) up to lid - pegH := totalH - lidThick - - // Scan a bounding box around the hole - rInt := int(socketRadiusPx) + 2 - cx, cy := int(px), int(py) - - for dy := -rInt; dy <= rInt; dy++ { - for dx := -rInt; dx <= rInt; dx++ { - ix, iy := cx+dx, cy+dy - if ix < 0 || ix >= imgW || iy < 0 || iy >= imgH { - continue - } - dist := math.Sqrt(float64(dx*dx + dy*dy)) - - // Peg cylinder (in enclosure, from z=0 up to lid) - if dist <= pegRadiusPx { - bx := float64(ix) * pixelToMM - by := float64(iy) * pixelToMM - addBoxAtZ(&encTris, bx, by, 0, pixelToMM, pixelToMM, pegH) - } - - // Socket mask (for tray floor removal) - if dist <= socketRadiusPx { - pegMask[iy*imgW+ix] = true - } - } - } - } - if mountingHoles > 0 { - fmt.Printf("Generated %d mounting pegs\n", mountingHoles) - } - } - - // Snap ledges: on the inside of the walls (at the clearance boundary) - // These are pixels that are in clearanceMask but adjacent to wallMask - fmt.Println("Generating snap ledges...") - for y := 1; y < imgH-1; y++ { - runStart := -1 - for x := 0; x <= imgW; x++ { - isSnapPixel := false - if x > 0 && x < imgW-1 { - idx := y*imgW + x - if clearanceMask[idx] && !boardMask[idx] { - // Check if adjacent to a wall pixel - hasAdjacentWall := false - for _, d := range [][2]int{{-1, 0}, {1, 0}, {0, -1}, {0, 1}} { - ni := (y+d[1])*imgW + (x + d[0]) - if ni >= 0 && ni < size { - if wallMask[ni] && !clearanceMask[ni] && !boardMask[ni] { - hasAdjacentWall = true - break - } - } - } - isSnapPixel = hasAdjacentWall - } - } - - if isSnapPixel { - if runStart == -1 { - runStart = x - } - } else { - if runStart != -1 { - bx := float64(runStart) * pixelToMM - by := float64(y) * pixelToMM - bw := float64(x-runStart) * pixelToMM - bh := pixelToMM - addBoxAtZ(&encTris, bx, by, snapFromBottom, bw, bh, snapHeight) - runStart = -1 - } - } - } - } + // (Peg calculations moved above) // ========================================== // TRAY (bottom — conforms to board shape) // ========================================== - var trayTris [][3]Point fmt.Println("Generating edge-cut conforming tray...") - // Tray floor: covers the cavity area (clearanceMask + boardMask) for y := 0; y < imgH; y++ { - runStart := -1 + runStartX := -1 + curIsWall := false + curIsBump := false for x := 0; x <= imgW; x++ { - isTrayPixel := false + isTrayFloor := false + isTrayWall := false + isTrayBump := false if x < imgW { idx := y*imgW + x - isTrayPixel = (clearanceMask[idx] || boardMask[idx]) && !pegMask[idx] + if !pegMask[idx] { + dist := wallMask[idx] + // Tray Floor covers everything up to encWallOuterPx + if (dist >= 0 && dist <= encWallOuterPx) || boardMask[idx] { + isTrayFloor = true + } + // Tray Wall goes from clearance to trayWallOuterPx + 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 + } + } } - if isTrayPixel { - if runStart == -1 { - runStart = x + if isTrayFloor { + if runStartX == -1 { + runStartX = x + curIsWall = isTrayWall + curIsBump = isTrayBump + } else if isTrayWall != curIsWall || isTrayBump != curIsBump { + 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) + } + + runStartX = x + curIsWall = isTrayWall + curIsBump = isTrayBump } } else { - if runStart != -1 { - bx := float64(runStart) * pixelToMM + if runStartX != -1 { + bx := float64(runStartX) * pixelToMM by := float64(y) * pixelToMM - bw := float64(x-runStart) * pixelToMM + bw := float64(x-runStartX) * pixelToMM bh := pixelToMM - AddBox(&trayTris, bx, by, bw, bh, trayFloor) - runStart = -1 - } - } - } - } - // PCB support rim: inner edge of clearance zone (adjacent to board) - fmt.Println("Generating PCB support rim...") - rimH := pcbT * 0.5 - for y := 1; y < imgH-1; y++ { - runStart := -1 - for x := 0; x <= imgW; x++ { - isRimPixel := false - if x > 0 && x < imgW-1 { - idx := y*imgW + x - if clearanceMask[idx] && !boardMask[idx] { - // Adjacent to board? - for _, d := range [][2]int{{-1, 0}, {1, 0}, {0, -1}, {0, 1}} { - ni := (y+d[1])*imgW + (x + d[0]) - if ni >= 0 && ni < size && boardMask[ni] { - isRimPixel = true - break - } + 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) } - } - } - - if isRimPixel { - if runStart == -1 { - runStart = x - } - } else { - if runStart != -1 { - bx := float64(runStart) * pixelToMM - by := float64(y) * pixelToMM - bw := float64(x-runStart) * pixelToMM - bh := pixelToMM - addBoxAtZ(&trayTris, bx, by, trayFloor, bw, bh, rimH) - runStart = -1 + runStartX = -1 } } } } - // Snap bumps: on the outer edge of the tray (adjacent to wall) - fmt.Println("Generating snap bumps...") - snapBumpH := snapHeight + 0.3 - for y := 1; y < imgH-1; y++ { - runStart := -1 - for x := 0; x <= imgW; x++ { - isBumpPixel := false - if x > 0 && x < imgW-1 { - idx := y*imgW + x - if clearanceMask[idx] && !boardMask[idx] { - // Adjacent to wall? - for _, d := range [][2]int{{-1, 0}, {1, 0}, {0, -1}, {0, 1}} { - ni := (y+d[1])*imgW + (x + d[0]) - if ni >= 0 && ni < size { - if wallMask[ni] && !clearanceMask[ni] && !boardMask[ni] { - isBumpPixel = true - break - } - } - } - } - } - - if isBumpPixel { - if runStart == -1 { - runStart = x - } - } else { - if runStart != -1 { - bx := float64(runStart) * pixelToMM - by := float64(y) * pixelToMM - bw := float64(x-runStart) * pixelToMM - bh := pixelToMM - addBoxAtZ(&trayTris, bx, by, snapFromBottom-0.1, bw, bh, snapBumpH) - runStart = -1 - } - } - } - } - - // Removal tabs: INTERNAL — thin finger grips that sit inside the tray cavity - // User can push on them from below to pop the tray out - fmt.Println("Adding internal removal tabs...") - // (uses pre-computed board bounding box: minBX, minBY, maxBX, maxBY, boardCenterY) - if boardCount > 0 { - boardCenterY /= float64(boardCount) - tabCenterY := boardCenterY * pixelToMM - - // Internal tabs: inside the clearance zone, extending inward - // Left tab — just inside the left wall - leftInner := float64(minBX)*pixelToMM - cfg.Clearance - addBoxAtZ(&trayTris, leftInner, tabCenterY-tabW/2, trayFloor, tabD, tabW, tabH) - - // Right tab — just inside the right wall - rightInner := float64(maxBX)*pixelToMM + cfg.Clearance - tabD - addBoxAtZ(&trayTris, rightInner, tabCenterY-tabW/2, trayFloor, tabD, tabW, tabH) - } - - // Embossed lip: a raised ridge around the tray perimeter, 0.5mm thick - // This lip mates against a recess in the enclosure for a tight snap fit - fmt.Println("Adding embossed lip...") - lipH := pcbT + 1.5 // extends above tray floor to grip the enclosure opening - lipThickPx := int(math.Ceil(0.5 * cfg.DPI / 25.4)) // 0.5mm in pixels - if lipThickPx < 1 { - lipThickPx = 1 - } - - // Build lip mask from the adjacency rule, then dilate inward by lipThickPx - lipCoreMask := make([]bool, size) - for y := 1; y < imgH-1; y++ { - for x := 1; x < imgW-1; x++ { - idx := y*imgW + x - if clearanceMask[idx] && !boardMask[idx] { - for _, d := range [][2]int{{-1, 0}, {1, 0}, {0, -1}, {0, 1}} { - ni := (y+d[1])*imgW + (x + d[0]) - if ni >= 0 && ni < size { - if wallMask[ni] && !clearanceMask[ni] && !boardMask[ni] { - lipCoreMask[idx] = true - break - } - } - } - } - } - } - - // Dilate lip mask inward by lipThickPx pixels - lipMask := make([]bool, size) - copy(lipMask, lipCoreMask) - for iter := 1; iter < lipThickPx; iter++ { - nextMask := make([]bool, size) - copy(nextMask, lipMask) - for y := 1; y < imgH-1; y++ { - for x := 1; x < imgW-1; x++ { - idx := y*imgW + x - if lipMask[idx] { - continue // already in lip - } - if !clearanceMask[idx] || boardMask[idx] { - continue // must be in clearance zone - } - // Adjacent to existing lip pixel? - for _, d := range [][2]int{{-1, 0}, {1, 0}, {0, -1}, {0, 1}} { - ni := (y+d[1])*imgW + (x + d[0]) - if ni >= 0 && ni < size && lipMask[ni] { - nextMask[idx] = true - break - } - } - } - } - lipMask = nextMask - } - - // Generate lip boxes - for y := 0; y < imgH; y++ { - runStart := -1 - for x := 0; x <= imgW; x++ { - isLipPx := false - if x < imgW { - isLipPx = lipMask[y*imgW+x] - } - - if isLipPx { - if runStart == -1 { - runStart = x - } - } else { - if runStart != -1 { - bx := float64(runStart) * pixelToMM - by := float64(y) * pixelToMM - bw := float64(x-runStart) * pixelToMM - bh := pixelToMM - addBoxAtZ(&trayTris, bx, by, trayFloor, bw, bh, lipH) - runStart = -1 - } - } - } - } - - // Add matching recess in enclosure for the lip (0.25mm deep groove) - // Recess sits at the inner face of the enclosure wall, where the lip enters - fmt.Println("Adding lip recess in enclosure...") - recessDepth := 0.25 - recessH := lipH + 0.5 // slightly taller than lip for easy entry - for y := 0; y < imgH; y++ { - runStart := -1 - for x := 0; x <= imgW; x++ { - // Recess = wall pixels adjacent to the lip (inner face of wall) - isRecess := false - if x > 0 && x < imgW-1 { - idx := y*imgW + x - if wallMask[idx] && !clearanceMask[idx] && !boardMask[idx] { - for _, d := range [][2]int{{-1, 0}, {1, 0}, {0, -1}, {0, 1}} { - ni := (y+d[1])*imgW + (x + d[0]) - if ni >= 0 && ni < size && lipMask[ni] { - isRecess = true - break - } - } - } - } - - if isRecess { - if runStart == -1 { - runStart = x - } - } else { - if runStart != -1 { - // Subtract recess from enclosure wall by NOT generating here - // Instead, generate wall with gap at recess height - bx := float64(runStart) * pixelToMM - by := float64(y) * pixelToMM - bw := float64(x-runStart) * pixelToMM - bh := pixelToMM - // Wall below recess - if trayFloor > 0.05 { - addBoxAtZ(&encTris, bx, by, 0, bw, bh, trayFloor) - } - // Thinner wall in recess zone (subtract recessDepth from thickness) - // This is handled by just not filling the recess area - _ = recessDepth - // Wall above recess - addBoxAtZ(&encTris, bx, by, trayFloor+recessH, bw, bh, totalH-(trayFloor+recessH)) - runStart = -1 - } - } - } - } + // (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)) _ = math.Pi // keep math import for Phase 2 cylindrical pegs diff --git a/gerber.go b/gerber.go index 6765b60..424ceab 100644 --- a/gerber.go +++ b/gerber.go @@ -17,7 +17,7 @@ const ( ApertureCircle = "C" ApertureRect = "R" ApertureObround = "O" - // Add macros later if needed + AperturePolygon = "P" ) type Aperture struct { @@ -644,6 +644,30 @@ func (gf *GerberFile) drawAperture(img *image.RGBA, x, y int, ap Aperture, scale drawObround(img, x, y, w, h, c) } return + case AperturePolygon: // P + // Modifiers: [0] diameter, [1] vertices, [2] rotation (optional) + if len(ap.Modifiers) >= 2 { + diameter := ap.Modifiers[0] + numVertices := int(ap.Modifiers[1]) + rotation := 0.0 + if len(ap.Modifiers) >= 3 { + rotation = ap.Modifiers[2] + } + + if numVertices >= 3 { + radius := (diameter * scale) / 2 + vertices := make([][2]int, numVertices) + for i := 0; i < numVertices; i++ { + angleDeg := rotation + float64(i)*360.0/float64(numVertices) + angleRad := angleDeg * math.Pi / 180.0 + px := int(radius * math.Cos(angleRad)) + py := int(radius * math.Sin(angleRad)) + vertices[i] = [2]int{x + px, y - py} + } + drawFilledPolygon(img, vertices) + } + } + return } // Check for Macros @@ -699,6 +723,35 @@ func (gf *GerberFile) drawAperture(img *image.RGBA, x, y int, ap Aperture, scale drawFilledPolygon(img, vertices) } } + case 5: // Regular Polygon + // Mods: Exposure, NumVertices, CenterX, CenterY, Diameter, Rotation + if len(prim.Modifiers) >= 6 { + exposure := evaluateMacroExpression(prim.Modifiers[0], ap.Modifiers) + if exposure == 0 { + break + } + numVertices := int(evaluateMacroExpression(prim.Modifiers[1], ap.Modifiers)) + if numVertices >= 3 { + cx := evaluateMacroExpression(prim.Modifiers[2], ap.Modifiers) + cy := evaluateMacroExpression(prim.Modifiers[3], ap.Modifiers) + diameter := evaluateMacroExpression(prim.Modifiers[4], ap.Modifiers) + rotation := evaluateMacroExpression(prim.Modifiers[5], ap.Modifiers) + + radius := (diameter * scale) / 2 + pxCenter := int(cx * scale) + pyCenter := int(cy * scale) + + vertices := make([][2]int, numVertices) + for i := 0; i < numVertices; i++ { + angleDeg := rotation + float64(i)*360.0/float64(numVertices) + angleRad := angleDeg * math.Pi / 180.0 + px := int(radius * math.Cos(angleRad)) + py := int(radius * math.Sin(angleRad)) + vertices[i] = [2]int{x + pxCenter + px, y - pyCenter - py} + } + drawFilledPolygon(img, vertices) + } + } case 20: // Vector Line // Mods: Exposure, Width, StartX, StartY, EndX, EndY, Rotation if len(prim.Modifiers) >= 7 { diff --git a/main.go b/main.go index f2eb58d..333155c 100644 --- a/main.go +++ b/main.go @@ -1,6 +1,7 @@ package main import ( + "archive/zip" "crypto/rand" "embed" "encoding/binary" @@ -140,7 +141,7 @@ func AddBox(triangles *[][3]Point, x, y, w, h, zHeight float64) { // ComputeWallMask generates a mask for the wall based on the outline image. // It identifies the board area (inside the outline) and creates a wall of // specified thickness around it. -func ComputeWallMask(img image.Image, thicknessMM float64, pixelToMM float64) ([]bool, []bool) { +func ComputeWallMask(img image.Image, thicknessMM float64, pixelToMM float64) ([]int, []bool) { bounds := img.Bounds() w := bounds.Max.X h := bounds.Max.Y @@ -322,7 +323,10 @@ func ComputeWallMask(img image.Image, thicknessMM float64, pixelToMM float64) ([ } } - isWall := make([]bool, size) + wallDist := make([]int, size) + for i := range wallDist { + wallDist[i] = -1 + } for len(wQueue) > 0 { idx := wQueue[0] @@ -342,14 +346,14 @@ func ComputeWallMask(img image.Image, thicknessMM float64, pixelToMM float64) ([ nIdx := ny*w + nx if dist[nIdx] == -1 { dist[nIdx] = d + 1 - isWall[nIdx] = true + wallDist[nIdx] = d + 1 wQueue = append(wQueue, nIdx) } } } } - return isWall, isBoard + return wallDist, isBoard } func GenerateMeshFromImages(stencilImg, outlineImg image.Image, cfg Config) [][3]Point { @@ -359,11 +363,11 @@ func GenerateMeshFromImages(stencilImg, outlineImg image.Image, cfg Config) [][3 height := bounds.Max.Y var triangles [][3]Point - var wallMask []bool + var wallDist []int var boardMask []bool if outlineImg != nil { fmt.Println("Computing wall mask...") - wallMask, boardMask = ComputeWallMask(outlineImg, cfg.WallThickness, pixelToMM) + wallDist, boardMask = ComputeWallMask(outlineImg, cfg.WallThickness, pixelToMM) } // Optimization: Run-Length Encoding @@ -380,9 +384,9 @@ func GenerateMeshFromImages(stencilImg, outlineImg image.Image, cfg Config) [][3 // Check wall isWall := false isInsideBoard := true - if wallMask != nil { + if wallDist != nil { idx := y*width + x - isWall = wallMask[idx] + isWall = wallDist[idx] >= 0 if boardMask != nil { isInsideBoard = boardMask[idx] } @@ -450,14 +454,30 @@ func GenerateMeshFromImages(stencilImg, outlineImg image.Image, cfg Config) [][3 // --- Logic --- -func processPCB(gerberPath, outlinePath string, cfg Config) (string, error) { - outputPath := strings.TrimSuffix(gerberPath, filepath.Ext(gerberPath)) + ".stl" +func processPCB(gerberPath, outlinePath string, cfg Config, exports []string) ([]string, error) { + baseName := strings.TrimSuffix(gerberPath, filepath.Ext(gerberPath)) + var generatedFiles []string + + // Helper to check what formats were requested + wantsType := func(t string) bool { + for _, e := range exports { + if e == t { + return true + } + } + return false + } + + // Always default to STL if nothing is specified + if len(exports) == 0 { + exports = []string{"stl"} + } // 1. Parse Gerber(s) fmt.Printf("Parsing %s...\n", gerberPath) gf, err := ParseGerber(gerberPath) if err != nil { - return "", fmt.Errorf("error parsing gerber: %v", err) + return nil, fmt.Errorf("error parsing gerber: %v", err) } var outlineGf *GerberFile @@ -465,7 +485,7 @@ func processPCB(gerberPath, outlinePath string, cfg Config) (string, error) { fmt.Printf("Parsing outline %s...\n", outlinePath) outlineGf, err = ParseGerber(outlinePath) if err != nil { - return "", fmt.Errorf("error parsing outline gerber: %v", err) + return nil, fmt.Errorf("error parsing outline gerber: %v", err) } } @@ -518,18 +538,52 @@ func processPCB(gerberPath, outlinePath string, cfg Config) (string, error) { } } - // 4. Generate Mesh - fmt.Println("Generating mesh...") - triangles := GenerateMeshFromImages(img, outlineImg, cfg) - - // 5. Save STL - fmt.Printf("Saving to %s (%d triangles)...\n", outputPath, len(triangles)) - err = WriteSTL(outputPath, triangles) - if err != nil { - return "", fmt.Errorf("error writing STL: %v", err) + var triangles [][3]Point + if wantsType("stl") || wantsType("scad") { + // 4. Generate Mesh + fmt.Println("Generating mesh...") + triangles = GenerateMeshFromImages(img, outlineImg, cfg) } - return outputPath, nil + // 5. Output based on requested formats + if wantsType("stl") { + outputFilename := baseName + ".stl" + fmt.Printf("Saving to %s (%d triangles)...\n", outputFilename, len(triangles)) + if err := WriteSTL(outputFilename, triangles); err != nil { + return nil, fmt.Errorf("error writing stl: %v", err) + } + generatedFiles = append(generatedFiles, outputFilename) + } + + if wantsType("svg") { + outputFilename := baseName + ".svg" + fmt.Printf("Saving to %s (SVG)...\n", outputFilename) + if err := WriteSVG(outputFilename, gf, &bounds); err != nil { + return nil, fmt.Errorf("error writing svg: %v", err) + } + generatedFiles = append(generatedFiles, outputFilename) + } + + if wantsType("png") { + outputFilename := baseName + ".png" + fmt.Printf("Saving to %s (PNG)...\n", outputFilename) + if f, err := os.Create(outputFilename); err == nil { + png.Encode(f, img) + f.Close() + generatedFiles = append(generatedFiles, outputFilename) + } + } + + if wantsType("scad") { + outputFilename := baseName + ".scad" + fmt.Printf("Saving to %s (SCAD)...\n", outputFilename) + if err := WriteSCAD(outputFilename, triangles); err != nil { + return nil, fmt.Errorf("error writing scad: %v", err) + } + generatedFiles = append(generatedFiles, outputFilename) + } + + return generatedFiles, nil } // --- CLI --- @@ -549,7 +603,7 @@ func runCLI(cfg Config, args []string) { outlinePath = args[1] } - _, err := processPCB(gerberPath, outlinePath, cfg) + _, err := processPCB(gerberPath, outlinePath, cfg, []string{"stl"}) if err != nil { log.Fatalf("Error: %v", err) } @@ -657,15 +711,39 @@ func uploadHandler(w http.ResponseWriter, r *http.Request) { } // Process - outSTL, err := processPCB(gerberPath, outlinePath, cfg) + exports := r.Form["exports"] + generatedPaths, err := processPCB(gerberPath, outlinePath, cfg, exports) if err != nil { log.Printf("Error processing: %v", err) http.Error(w, fmt.Sprintf("Error processing PCB: %v", err), http.StatusInternalServerError) return } + var generatedFiles []string + for _, p := range generatedPaths { + generatedFiles = append(generatedFiles, filepath.Base(p)) + } + + // Generate Master Zip if more than 1 file + var zipFile string + if len(generatedFiles) > 1 { + zipPath := filepath.Join(tempDir, uuid+"_all_exports.zip") + zf, err := os.Create(zipPath) + if err == nil { + zw := zip.NewWriter(zf) + for _, fn := range generatedFiles { + fw, _ := zw.Create(fn) + fb, _ := os.ReadFile(filepath.Join(tempDir, fn)) + fw.Write(fb) + } + zw.Close() + zf.Close() + zipFile = filepath.Base(zipPath) + } + } + // Render Success - renderResult(w, "Your stencil has been generated successfully.", []string{filepath.Base(outSTL)}) + renderResult(w, "Your stencil has been generated successfully.", generatedFiles, "/", zipFile) } func enclosureUploadHandler(w http.ResponseWriter, r *http.Request) { @@ -838,6 +916,39 @@ func enclosureUploadHandler(w http.ResponseWriter, r *http.Request) { ecfg.OutlineBounds = &outlineBounds outlineImg := outlineGf.Render(ecfg.DPI, &outlineBounds) + // Compute board box and count from the rendered image + minBX, minBY, maxBX, maxBY := outlineImg.Bounds().Max.X, outlineImg.Bounds().Max.Y, -1, -1 + var boardCenterY float64 + var boardCount int + + wallMaskInt, boardMask := ComputeWallMask(outlineImg, ecfg.WallThickness+ecfg.Clearance, ecfg.DPI/25.4) + _ = wallMaskInt // Not used here, but we need boardMask + + imgW, imgH := outlineImg.Bounds().Max.X, outlineImg.Bounds().Max.Y + for y := 0; y < imgH; y++ { + for x := 0; x < imgW; x++ { + if boardMask[y*imgW+x] { + if x < minBX { + minBX = x + } + if y < minBY { + minBY = y + } + if x > maxBX { + maxBX = x + } + if y > maxBY { + maxBY = y + } + boardCenterY += float64(y) + boardCount++ + } + } + } + if boardCount > 0 { + boardCenterY /= float64(boardCount) + } + // Auto-discover and render F.Courtyard from job file var courtyardImg image.Image if courtPath, ok := savedGerbers[jobResult.CourtyardFile]; ok && jobResult.CourtyardFile != "" { @@ -875,9 +986,9 @@ func enclosureUploadHandler(w http.ResponseWriter, r *http.Request) { } } - // Generate enclosure (no side cutouts yet — added in preview flow) - // Store session data for preview page session := &EnclosureSession{ + Exports: r.Form["exports"], + OutlineGf: outlineGf, OutlineImg: outlineImg, CourtyardImg: courtyardImg, SoldermaskImg: soldermaskImg, @@ -887,6 +998,9 @@ 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, } sessionsMu.Lock() sessions[uuid] = session @@ -897,7 +1011,7 @@ func enclosureUploadHandler(w http.ResponseWriter, r *http.Request) { } -func renderResult(w http.ResponseWriter, message string, files []string) { +func renderResult(w http.ResponseWriter, message string, files []string, backURL string, zipFile string) { tmpl, err := template.ParseFS(staticFiles, "static/result.html") if err != nil { http.Error(w, "Template error", http.StatusInternalServerError) @@ -906,13 +1020,17 @@ func renderResult(w http.ResponseWriter, message string, files []string) { data := struct { Message string Files []string - }{Message: message, Files: files} + BackURL string + ZipFile string + }{Message: message, Files: files, BackURL: backURL, ZipFile: zipFile} tmpl.Execute(w, data) } // --- Enclosure Preview Session --- type EnclosureSession struct { + Exports []string + OutlineGf *GerberFile OutlineImg image.Image CourtyardImg image.Image SoldermaskImg image.Image @@ -922,6 +1040,9 @@ type EnclosureSession struct { BoardW float64 BoardH float64 TotalH float64 + MinBX float64 + MaxBX float64 + BoardCenterY float64 } var ( @@ -1031,36 +1152,83 @@ func generateEnclosureHandler(w http.ResponseWriter, r *http.Request) { fmt.Printf("Side cutouts: %d\n", len(sideCutouts)) } - // Generate enclosure - fmt.Println("Generating enclosure with side cutouts...") - result := GenerateEnclosure(session.OutlineImg, session.DrillHoles, session.Config, - session.CourtyardImg, session.SoldermaskImg, sideCutouts) + var generatedFiles []string - // Save STLs - encPath := filepath.Join("temp", id+"_enclosure.stl") - trayPath := filepath.Join("temp", id+"_tray.stl") - - fmt.Printf("Saving enclosure to %s (%d triangles)...\n", encPath, len(result.EnclosureTriangles)) - if err := WriteSTL(encPath, result.EnclosureTriangles); err != nil { - http.Error(w, fmt.Sprintf("Error writing enclosure STL: %v", err), http.StatusInternalServerError) - return + // Helper to check what formats were requested + wantsType := func(t string) bool { + for _, e := range session.Exports { + if e == t { + return true + } + } + return false } - fmt.Printf("Saving tray to %s (%d triangles)...\n", trayPath, len(result.TrayTriangles)) - if err := WriteSTL(trayPath, result.TrayTriangles); err != nil { - http.Error(w, fmt.Sprintf("Error writing tray STL: %v", err), http.StatusInternalServerError) - return + // Always default to STL if nothing is specified + if len(session.Exports) == 0 { + session.Exports = []string{"stl"} } - // Clean up session - sessionsMu.Lock() - delete(sessions, id) - sessionsMu.Unlock() + // Process STL + if wantsType("stl") { + fmt.Println("Generating STLs...") + result := GenerateEnclosure(session.OutlineImg, session.DrillHoles, session.Config, session.CourtyardImg, session.SoldermaskImg, sideCutouts) + encPath := filepath.Join("temp", id+"_enclosure.stl") + trayPath := filepath.Join("temp", id+"_tray.stl") + WriteSTL(encPath, result.EnclosureTriangles) + WriteSTL(trayPath, result.TrayTriangles) + generatedFiles = append(generatedFiles, filepath.Base(encPath), filepath.Base(trayPath)) + } - renderResult(w, "Your enclosure has been generated successfully.", []string{ - filepath.Base(encPath), - filepath.Base(trayPath), - }) + // Process SCAD + if wantsType("scad") { + fmt.Println("Generating Native SCAD scripts...") + scadPathEnc := filepath.Join("temp", id+"_enclosure.scad") + scadPathTray := filepath.Join("temp", id+"_tray.scad") + outlinePoly := ExtractPolygonFromGerber(session.OutlineGf) + WriteNativeSCAD(scadPathEnc, false, outlinePoly, session.Config, session.DrillHoles, sideCutouts, session.MinBX, session.MaxBX, session.BoardCenterY) + WriteNativeSCAD(scadPathTray, true, outlinePoly, session.Config, session.DrillHoles, sideCutouts, session.MinBX, session.MaxBX, session.BoardCenterY) + generatedFiles = append(generatedFiles, filepath.Base(scadPathEnc), filepath.Base(scadPathTray)) + } + + // Process SVG + if wantsType("svg") && session.OutlineGf != nil { + fmt.Println("Generating SVG vector outline...") + svgPath := filepath.Join("temp", id+"_outline.svg") + WriteSVG(svgPath, session.OutlineGf, &session.OutlineBounds) + generatedFiles = append(generatedFiles, filepath.Base(svgPath)) + } + + // Process PNG + if wantsType("png") && session.OutlineImg != nil { + fmt.Println("Generating PNG raster outline...") + pngPath := filepath.Join("temp", id+"_outline.png") + fImg, _ := os.Create(pngPath) + png.Encode(fImg, session.OutlineImg) + fImg.Close() + generatedFiles = append(generatedFiles, filepath.Base(pngPath)) + } + + // Generate Master Zip if more than 1 file + var zipFile string + if len(generatedFiles) > 1 { + zipPath := filepath.Join("temp", id+"_all_exports.zip") + zf, err := os.Create(zipPath) + if err == nil { + zw := zip.NewWriter(zf) + for _, fn := range generatedFiles { + fw, _ := zw.Create(fn) + fb, _ := os.ReadFile(filepath.Join("temp", fn)) + fw.Write(fb) + } + zw.Close() + zf.Close() + zipFile = filepath.Base(zipPath) + } + } + + // We intentionally do NOT delete the session here so the user can hit "Back for Adjustments" + renderResult(w, "Your files have been generated successfully.", generatedFiles, "/preview?id="+id, zipFile) } func downloadHandler(w http.ResponseWriter, r *http.Request) { diff --git a/pcb-to-stencil b/pcb-to-stencil index 6f7ce38..db866bb 100755 Binary files a/pcb-to-stencil and b/pcb-to-stencil differ diff --git a/scad.go b/scad.go new file mode 100644 index 0000000..c9329f1 --- /dev/null +++ b/scad.go @@ -0,0 +1,256 @@ +package main + +import ( + "fmt" + "math" + "os" +) + +func WriteSCAD(filename string, triangles [][3]Point) error { + // Fallback/legacy mesh WriteSCAD + f, err := os.Create(filename) + if err != nil { + return err + } + defer f.Close() + + fmt.Fprintf(f, "// Generated by pcb-to-stencil\npolyhedron(\n points=[\n") + for i, t := range triangles { + fmt.Fprintf(f, " [%f, %f, %f], [%f, %f, %f], [%f, %f, %f]", t[0].X, t[0].Y, t[0].Z, t[1].X, t[1].Y, t[1].Z, t[2].X, t[2].Y, t[2].Z) + if i < len(triangles)-1 { + fmt.Fprintf(f, ",\n") + } else { + fmt.Fprintf(f, "\n") + } + } + fmt.Fprintf(f, " ],\n faces=[\n") + for i := 0; i < len(triangles); i++ { + idx := i * 3 + fmt.Fprintf(f, " [%d, %d, %d]", idx, idx+1, idx+2) + if i < len(triangles)-1 { + fmt.Fprintf(f, ",\n") + } else { + fmt.Fprintf(f, "\n") + } + } + fmt.Fprintf(f, " ]\n);\n") + return nil +} + +// ExtractPolygonFromGerber traces the Edge.Cuts gerber to form a continuous 2D polygon +func ExtractPolygonFromGerber(gf *GerberFile) [][2]float64 { + var points [][2]float64 + curX, curY := 0.0, 0.0 + interpolationMode := "G01" + + for _, cmd := range gf.Commands { + if cmd.Type == "G01" || cmd.Type == "G02" || cmd.Type == "G03" { + interpolationMode = cmd.Type + continue + } + + prevX, prevY := curX, curY + if cmd.X != nil { + curX = *cmd.X + } + if cmd.Y != nil { + curY = *cmd.Y + } + + if cmd.Type == "DRAW" { + if len(points) == 0 { + points = append(points, [2]float64{prevX, prevY}) + } + + if interpolationMode == "G01" { + points = append(points, [2]float64{curX, curY}) + } else { + iVal, jVal := 0.0, 0.0 + if cmd.I != nil { + iVal = *cmd.I + } + if cmd.J != nil { + jVal = *cmd.J + } + centerX, centerY := prevX+iVal, prevY+jVal + radius := math.Sqrt(iVal*iVal + jVal*jVal) + startAngle := math.Atan2(prevY-centerY, prevX-centerX) + endAngle := math.Atan2(curY-centerY, curX-centerX) + if interpolationMode == "G03" { + if endAngle <= startAngle { + endAngle += 2 * math.Pi + } + } else { + if startAngle <= endAngle { + startAngle += 2 * math.Pi + } + } + arcLen := math.Abs(endAngle-startAngle) * radius + steps := int(arcLen * 10) + if steps < 5 { + steps = 5 + } + for s := 1; s <= steps; s++ { + t := float64(s) / float64(steps) + a := startAngle + t*(endAngle-startAngle) + ax, ay := centerX+radius*math.Cos(a), centerY+radius*math.Sin(a) + points = append(points, [2]float64{ax, ay}) + } + } + } + } + return points +} + +// WriteNativeSCAD generates native parametrically defined CSG OpenSCAD code +func WriteNativeSCAD(filename string, isTray bool, outlineVertices [][2]float64, cfg EnclosureConfig, holes []DrillHole, cutouts []SideCutout, minBX, maxBX, boardCenterY float64) error { + f, err := os.Create(filename) + if err != nil { + return err + } + defer f.Close() + + fmt.Fprintf(f, "// Generated by pcb-to-stencil (Native SCAD)\n") + fmt.Fprintf(f, "$fn = 60;\n\n") + + // 1. Output the Board Polygon Module + fmt.Fprintf(f, "module board_polygon() {\n polygon(points=[\n") + for i, v := range outlineVertices { + fmt.Fprintf(f, " [%f, %f]", v[0], v[1]) + if i < len(outlineVertices)-1 { + fmt.Fprintf(f, ",\n") + } + } + fmt.Fprintf(f, "\n ]);\n}\n\n") + + // Dimensions + clearance := cfg.Clearance + wt := cfg.WallThickness + lidThick := wt + snapHeight := 2.5 + trayFloor := 1.5 + pcbT := cfg.PCBThickness + totalH := cfg.WallHeight + pcbT + trayFloor + lipH := pcbT + 1.5 + + // Create Peg and Socket helper + fmt.Fprintf(f, "module mounting_pegs(isSocket) {\n") + for _, h := range holes { + if h.Type == DrillTypeMounting { + r := (h.Diameter / 2.0) - 0.15 + if isTray { + // We subtract sockets from the tray floor + r = (h.Diameter / 2.0) + 0.1 + fmt.Fprintf(f, " translate([%f, %f, -1]) cylinder(r=%f, h=%f);\n", h.X, h.Y, r, trayFloor+2) + } else { + fmt.Fprintf(f, " translate([%f, %f, 0]) cylinder(r=%f, h=%f);\n", h.X, h.Y, r, totalH-lidThick) + } + } + } + fmt.Fprintf(f, "}\n\n") + + // Print Side Cutouts module + fmt.Fprintf(f, "module side_cutouts() {\n") + for _, c := range cutouts { + // Cutouts are relative to board. + x, y, z := 0.0, 0.0, c.Height/2+trayFloor+pcbT + w, d, h := c.Width, 20.0, c.Height // d is deep enough to cut through walls + if c.Side == 0 { // Top + y = outlineVertices[0][1] + 10 // rough outside pos + x = c.X + fmt.Fprintf(f, " translate([%f, %f, %f]) cube([%f, %f, %f], center=true);\n", x, y, z, w, d, h) + } else if c.Side == 1 { // Right + x = maxBX + y = c.Y + fmt.Fprintf(f, " translate([%f, %f, %f]) cube([%f, %f, %f], center=true);\n", x, y, z, d, w, h) + } else if c.Side == 2 { // Bottom + y = outlineVertices[0][1] - 10 + x = c.X + fmt.Fprintf(f, " translate([%f, %f, %f]) cube([%f, %f, %f], center=true);\n", x, y, z, w, d, h) + } else if c.Side == 3 { // Left + x = minBX + y = c.Y + fmt.Fprintf(f, " translate([%f, %f, %f]) cube([%f, %f, %f], center=true);\n", x, y, z, d, w, h) + } + } + fmt.Fprintf(f, "}\n\n") + + // Print Pry Slots Module + fmt.Fprintf(f, "module pry_slots() {\n") + pryW := 8.0 + pryD := 1.5 + fmt.Fprintf(f, " translate([%f, %f, 0]) cube([%f, %f, %f], center=true);\n", minBX-clearance-wt+pryD/2, boardCenterY, pryD*2, pryW, snapHeight*3) + 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") + + if isTray { + // --- TRAY --- + fmt.Fprintf(f, "// --- TRAY ---\n") + fmt.Fprintf(f, "difference() {\n") + fmt.Fprintf(f, " union() {\n") + fmt.Fprintf(f, " // Tray Floor (extends to clearance + 2*wt so it is flush with enclosure outside)\n") + fmt.Fprintf(f, " linear_extrude(height=%f) offset(r=%f) board_polygon();\n", trayFloor, clearance+2*wt) + fmt.Fprintf(f, " // Tray Inner Wall (thickness wt)\n") + fmt.Fprintf(f, " translate([0,0,%f]) linear_extrude(height=%f) difference() {\n", trayFloor, snapHeight) + 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") + fmt.Fprintf(f, " translate([0,0,-1]) linear_extrude(height=%f) difference() {\n", trayFloor+lipH+0.5) + fmt.Fprintf(f, " offset(r=%f) board_polygon();\n", clearance+2*wt+1.0) + fmt.Fprintf(f, " offset(r=%f) board_polygon();\n", clearance+2*wt-0.5) // Assuming lipCut is 0.5mm + fmt.Fprintf(f, " }\n") + + // Remove peg holes from floor + for _, hole := range holes { + if hole.Type != DrillTypeMounting { + continue + } + 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") + + } else { + // --- ENCLOSURE --- + fmt.Fprintf(f, "// --- ENCLOSURE ---\n") + fmt.Fprintf(f, "difference() {\n") + fmt.Fprintf(f, " union() {\n") + fmt.Fprintf(f, " // Outer Enclosure block (accommodates Tray Wall + Enclosure Wall)\n") + fmt.Fprintf(f, " translate([0,0,%f]) linear_extrude(height=%f) offset(r=%f) board_polygon();\n", trayFloor, totalH-trayFloor, clearance+2*wt) + fmt.Fprintf(f, " }\n") + fmt.Fprintf(f, " // Subtract Inner Cavity (Base clearance around board)\n") + fmt.Fprintf(f, " translate([0,0,-1]) linear_extrude(height=%f) offset(r=%f) board_polygon();\n", totalH-lidThick+1, clearance) + 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, " pry_slots();\n") + fmt.Fprintf(f, " side_cutouts();\n") + fmt.Fprintf(f, "}\n") + fmt.Fprintf(f, "mounting_pegs(false);\n") + } + + return nil +} diff --git a/static/index.html b/static/index.html index db2a2d4..c490b99 100644 --- a/static/index.html +++ b/static/index.html @@ -12,6 +12,28 @@

PCB Tools by kennycoder + pszsh

+ +
@@ -140,15 +162,96 @@ }); }); - // Loading spinner on submit + // Include export config in form submissions document.querySelectorAll('form').forEach(function (form) { - form.addEventListener('submit', function () { + form.addEventListener('submit', function (e) { + // Clear old hidden inputs to avoid duplicates on re-submission + form.querySelectorAll('input.dynamic-export').forEach(function (el) { el.remove(); }); + + // Read checkboxes and inject as hidden inputs + document.querySelectorAll('.export-options input[type="checkbox"]').forEach(function (cb) { + if (cb.checked) { + var hidden = document.createElement('input'); + hidden.type = 'hidden'; + hidden.name = 'exports'; + hidden.value = cb.value; + hidden.className = 'dynamic-export'; + form.appendChild(hidden); + } + }); + document.getElementById('loading').style.display = 'block'; var btn = form.querySelector('.submit-btn'); - btn.disabled = true; - btn.innerText = 'Processing...'; + if (btn) { + btn.disabled = true; + btn.innerText = 'Processing...'; + } }); }); + + // Config state management + function saveConfig() { + var config = { + stencilHeight: document.getElementById('height').value, + stencilDpi: document.getElementById('dpi').value, + stencilWallHeight: document.getElementById('wallHeight').value, + stencilWallThickness: document.getElementById('wallThickness').value, + + encWallThickness: document.getElementById('enc-wallThickness').value, + encWallHeight: document.getElementById('enc-wallHeight').value, + encClearance: document.getElementById('enc-clearance').value, + encDpi: document.getElementById('enc-dpi').value + }; + + var name = prompt("Save configuration as:", "My Config " + new Date().toLocaleTimeString()); + if (name) { + var recents = JSON.parse(localStorage.getItem('pcbConfigs') || '{}'); + recents[name] = config; + localStorage.setItem('pcbConfigs', JSON.stringify(recents)); + updateRecents(); + } + } + + function loadConfig(name) { + var recents = JSON.parse(localStorage.getItem('pcbConfigs') || '{}'); + var config = recents[name]; + if (config) { + if (config.stencilHeight) document.getElementById('height').value = config.stencilHeight; + if (config.stencilDpi) document.getElementById('dpi').value = config.stencilDpi; + if (config.stencilWallHeight) document.getElementById('wallHeight').value = config.stencilWallHeight; + if (config.stencilWallThickness) document.getElementById('wallThickness').value = config.stencilWallThickness; + + if (config.encWallThickness) document.getElementById('enc-wallThickness').value = config.encWallThickness; + if (config.encWallHeight) document.getElementById('enc-wallHeight').value = config.encWallHeight; + if (config.encClearance) document.getElementById('enc-clearance').value = config.encClearance; + if (config.encDpi) document.getElementById('enc-dpi').value = config.encDpi; + } + } + + function updateRecents() { + var recents = JSON.parse(localStorage.getItem('pcbConfigs') || '{}'); + var list = document.getElementById('recentsList'); + if (!list) return; + + list.innerHTML = 'Saved Configs'; + for (var name in recents) { + var a = document.createElement('a'); + a.className = 'menu-recents-item'; + a.innerText = name; + a.href = "#"; + a.onclick = (function (n) { + return function (e) { + e.preventDefault(); + loadConfig(n); + }; + })(name); + list.appendChild(a); + } + } + + document.addEventListener('DOMContentLoaded', updateRecents); + + // Removed old triggerExport since we now use multi-select checkboxes diff --git a/static/preview.html b/static/preview.html index d77b48f..4587ee2 100644 --- a/static/preview.html +++ b/static/preview.html @@ -322,7 +322,12 @@ - +
+ Go + Back + +
diff --git a/static/result.html b/static/result.html index a6bbb69..98b111f 100644 --- a/static/result.html +++ b/static/result.html @@ -12,12 +12,25 @@

Success!

{{.Message}}

+ {{if .ZipFile}} + Download + All (ZIP) +

+ Individual Files

+ {{end}} + - Back + {{if .BackURL}} + Back for + Adjustments + {{end}} + Start Over
diff --git a/static/style.css b/static/style.css index 7641125..2cdc60e 100644 --- a/static/style.css +++ b/static/style.css @@ -51,6 +51,92 @@ h2 { margin-top: 0; } +/* Menu Bar */ +.menu-bar { + display: flex; + gap: 1rem; + padding: 0.5rem 0; + margin-bottom: 1rem; + border-bottom: 1px solid var(--border); +} + +.menu-item { + position: relative; + cursor: pointer; + font-weight: 500; + font-size: 0.9rem; + color: var(--text); + padding: 0.25rem 0.5rem; + border-radius: 4px; +} + +.menu-item:hover { + background-color: #e5e7eb; +} + +.menu-dropdown { + display: none; + position: absolute; + top: 100%; + left: 0; + min-width: 200px; + background: white; + box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06); + border: 1px solid var(--border); + border-radius: 6px; + z-index: 100; + padding: 0.5rem 0; +} + +.menu-item:hover .menu-dropdown { + display: block; +} + +.menu-dropdown a { + display: block; + padding: 0.5rem 1rem; + color: var(--text); + text-decoration: none; + font-size: 0.85rem; +} + +.menu-dropdown a:hover { + background-color: var(--bg); + color: var(--primary); +} + +.menu-divider { + height: 1px; + background-color: var(--border); + margin: 0.4rem 0; +} + +.menu-recents-title { + display: block; + padding: 0.25rem 1rem; + font-size: 0.75rem; + color: #6b7280; + text-transform: uppercase; + font-weight: 600; +} + +.menu-recents-item { + display: block; + padding: 0.4rem 1rem; + font-size: 0.85rem; + color: var(--text); + text-decoration: none; + cursor: pointer; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.menu-recents-item:hover { + background-color: var(--bg); + color: var(--primary); +} + /* Tabs */ .tabs { display: flex; diff --git a/svg.go b/svg.go new file mode 100644 index 0000000..0187a38 --- /dev/null +++ b/svg.go @@ -0,0 +1,194 @@ +package main + +import ( + "fmt" + "math" + "os" +) + +func WriteSVG(filename string, gf *GerberFile, bounds *Bounds) error { + b := *bounds + + widthMM := b.MaxX - b.MinX + heightMM := b.MaxY - b.MinY + + f, err := os.Create(filename) + if err != nil { + return err + } + defer f.Close() + + // Use mm directly for SVG + fmt.Fprintf(f, ``, + widthMM, heightMM, widthMM, heightMM) + fmt.Fprintf(f, "\n\n") + + // Note: SVG Y-axis points down. We need to invert Y: (heightMM - (y - b.MinY)) + toSVGX := func(x float64) float64 { return x - b.MinX } + toSVGY := func(y float64) float64 { return heightMM - (y - b.MinY) } + + curX, curY := 0.0, 0.0 + curDCode := 0 + interpolationMode := "G01" // Default linear + inRegion := false + var regionVertices [][2]float64 + + for _, cmd := range gf.Commands { + if cmd.Type == "APERTURE" { + curDCode = *cmd.D + continue + } + if cmd.Type == "G01" || cmd.Type == "G02" || cmd.Type == "G03" { + interpolationMode = cmd.Type + continue + } + if cmd.Type == "G36" { + inRegion = true + regionVertices = nil + continue + } + if cmd.Type == "G37" { + if len(regionVertices) >= 3 { + fmt.Fprintf(f, `\n") + } + inRegion = false + regionVertices = nil + continue + } + + prevX, prevY := curX, curY + if cmd.X != nil { + curX = *cmd.X + } + if cmd.Y != nil { + curY = *cmd.Y + } + + if inRegion { + if cmd.Type == "MOVE" || cmd.Type == "DRAW" && interpolationMode == "G01" { + regionVertices = append(regionVertices, [2]float64{curX, curY}) + } else if cmd.Type == "DRAW" && (interpolationMode == "G02" || interpolationMode == "G03") { + // We don't have perfect analytic translation to SVG path for region arcs yet. + // We can just output the line for now, or approximate it as before. + // For SVG, we can just output line segments just like we did for image processing. + iVal, jVal := 0.0, 0.0 + if cmd.I != nil { + iVal = *cmd.I + } + if cmd.J != nil { + jVal = *cmd.J + } + centerX, centerY := prevX+iVal, prevY+jVal + radius := math.Sqrt(iVal*iVal + jVal*jVal) + startAngle := math.Atan2(prevY-centerY, prevX-centerX) + endAngle := math.Atan2(curY-centerY, curX-centerX) + if interpolationMode == "G03" { + if endAngle <= startAngle { + endAngle += 2 * math.Pi + } + } else { + if startAngle <= endAngle { + startAngle += 2 * math.Pi + } + } + arcLen := math.Abs(endAngle-startAngle) * radius + steps := int(arcLen * 10) // 10 segments per mm + if steps < 10 { + steps = 10 + } + for s := 1; s <= steps; s++ { + t := float64(s) / float64(steps) + a := startAngle + t*(endAngle-startAngle) + ax, ay := centerX+radius*math.Cos(a), centerY+radius*math.Sin(a) + regionVertices = append(regionVertices, [2]float64{ax, ay}) + } + } + continue + } + + if cmd.Type == "FLASH" { + ap, ok := gf.State.Apertures[curDCode] + if ok { + writeSVGAperture(f, toSVGX(curX), toSVGY(curY), ap, false) + } + } else if cmd.Type == "DRAW" { + ap, ok := gf.State.Apertures[curDCode] + if ok { + // Basic stroke representation for lines + w := 0.1 // default + if len(ap.Modifiers) > 0 { + w = ap.Modifiers[0] + } + + if interpolationMode == "G01" { + fmt.Fprintf(f, ``+"\n", + toSVGX(prevX), toSVGY(prevY), toSVGX(curX), toSVGY(curY), w) + } else { + iVal, jVal := 0.0, 0.0 + if cmd.I != nil { + iVal = *cmd.I + } + if cmd.J != nil { + jVal = *cmd.J + } + + // SVG path Arc + rx, ry := math.Sqrt(iVal*iVal+jVal*jVal), math.Sqrt(iVal*iVal+jVal*jVal) + sweep := 1 // G03 CCW -> SVG path sweep up due to inverted Y + if interpolationMode == "G02" { + sweep = 0 + } + + fmt.Fprintf(f, ``+"\n", + toSVGX(prevX), toSVGY(prevY), rx, ry, sweep, toSVGX(curX), toSVGY(curY), w) + } + } + } + } + fmt.Fprintf(f, "\n\n") + return nil +} + +func writeSVGAperture(f *os.File, cx, cy float64, ap Aperture, isMacro bool) { + switch ap.Type { + case "C": + if len(ap.Modifiers) > 0 { + r := ap.Modifiers[0] / 2 + fmt.Fprintf(f, ``+"\n", cx, cy, r) + } + case "R": + if len(ap.Modifiers) >= 2 { + w, h := ap.Modifiers[0], ap.Modifiers[1] + fmt.Fprintf(f, ``+"\n", cx-w/2, cy-h/2, w, h) + } + case "O": + if len(ap.Modifiers) >= 2 { + w, h := ap.Modifiers[0], ap.Modifiers[1] + r := w / 2 + if h < w { + r = h / 2 + } + fmt.Fprintf(f, ``+"\n", cx-w/2, cy-h/2, w, h, r, r) + } + case "P": + if len(ap.Modifiers) >= 2 { + dia, numV := ap.Modifiers[0], int(ap.Modifiers[1]) + r := dia / 2 + rot := 0.0 + if len(ap.Modifiers) >= 3 { + rot = ap.Modifiers[2] + } + fmt.Fprintf(f, ``+"\n") + } + } +}