diff --git a/bin/pcb-to-stencil b/bin/pcb-to-stencil index f184884..1636828 100755 Binary files a/bin/pcb-to-stencil and b/bin/pcb-to-stencil differ diff --git a/bin/pcb-to-stencil.exe b/bin/pcb-to-stencil.exe deleted file mode 100755 index d0d3ec8..0000000 Binary files a/bin/pcb-to-stencil.exe and /dev/null differ diff --git a/enclosure.go b/enclosure.go index 426e077..e9002b4 100644 --- a/enclosure.go +++ b/enclosure.go @@ -33,18 +33,218 @@ type EnclosureResult struct { // SideCutout defines a cutout on a side wall face type SideCutout struct { - Side int // 1-indexed side number (clockwise from top) - X, Y float64 // Position on the face in mm (from left edge, from bottom) + Side int // 1-indexed side number (matches BoardSide.Num) + X, Y float64 // Position on the face in mm (from StartX/StartY, from bottom) Width float64 // Width in mm Height float64 // Height in mm CornerRadius float64 // Corner radius in mm (0 for square) } +// BoardSide represents a physical straight edge of the board outline +type BoardSide struct { + Num int `json:"num"` + Label string `json:"label"` + Length float64 `json:"length"` + StartX float64 `json:"startX"` + StartY float64 `json:"startY"` + EndX float64 `json:"endX"` + EndY float64 `json:"endY"` + Angle float64 `json:"angle"` // Angle in radians of the normal vector pushing OUT of the board +} + +func perpendicularDistance(pt, lineStart, lineEnd [2]float64) float64 { + dx := lineEnd[0] - lineStart[0] + dy := lineEnd[1] - lineStart[1] + + // Normalize line vector + mag := math.Sqrt(dx*dx + dy*dy) + if mag == 0 { + return math.Sqrt((pt[0]-lineStart[0])*(pt[0]-lineStart[0]) + (pt[1]-lineStart[1])*(pt[1]-lineStart[1])) + } + dx /= mag + dy /= mag + + // Vector from lineStart to pt + px := pt[0] - lineStart[0] + py := pt[1] - lineStart[1] + + // Cross product gives perpendicular distance + return math.Abs(px*dy - py*dx) +} + +func simplifyPolygonRDP(points [][2]float64, epsilon float64) [][2]float64 { + if len(points) < 3 { + return points + } + + dmax := 0.0 + index := 0 + end := len(points) - 1 + + for i := 1; i < end; i++ { + d := perpendicularDistance(points[i], points[0], points[end]) + if d > dmax { + index = i + dmax = d + } + } + + if dmax > epsilon { + recResults1 := simplifyPolygonRDP(points[:index+1], epsilon) + recResults2 := simplifyPolygonRDP(points[index:], epsilon) + + result := append([][2]float64{}, recResults1[:len(recResults1)-1]...) + result = append(result, recResults2...) + return result + } + + return [][2]float64{points[0], points[end]} +} + +func ExtractBoardSides(poly [][2]float64) []BoardSide { + if len(poly) < 3 { + return nil + } + + // Determine "center" of polygon to find outward normals + cx, cy := 0.0, 0.0 + for _, p := range poly { + cx += p[0] + cy += p[1] + } + cx /= float64(len(poly)) + cy /= float64(len(poly)) + + // Ensure the polygon is closed for RDP, if it isn't already + if poly[0][0] != poly[len(poly)-1][0] || poly[0][1] != poly[len(poly)-1][1] { + poly = append(poly, poly[0]) + } + + simplified := simplifyPolygonRDP(poly, 0.2) // 0.2mm tolerance + fmt.Printf("[DEBUG] ExtractBoardSides: poly points = %d, simplified points = %d\n", len(poly), len(simplified)) + + var sides []BoardSide + sideNum := 1 + + for i := 0; i < len(simplified)-1; i++ { + p1 := simplified[i] + p2 := simplified[i+1] + + dx := p2[0] - p1[0] + dy := p2[1] - p1[1] + length := math.Sqrt(dx*dx + dy*dy) + + // Only keep substantial straight edges (e.g. > 4mm) + if length > 4.0 { + // Calculate outward normal angle + // The segment path vector is (dx, dy). Normal is either (-dy, dx) or (dy, -dx) + nx := dy + ny := -dx + // Dot product with center->midpoint to check if it points out + midX := (p1[0] + p2[0]) / 2.0 + midY := (p1[1] + p2[1]) / 2.0 + vx := midX - cx + vy := midY - cy + if nx*vx+ny*vy < 0 { + nx = -nx + ny = -ny + } + angle := math.Atan2(ny, nx) + + sides = append(sides, BoardSide{ + Num: sideNum, + Label: fmt.Sprintf("Side %d (%.1fmm)", sideNum, length), + Length: length, + StartX: p1[0], + StartY: p1[1], + EndX: p2[0], + EndY: p2[1], + Angle: angle, + }) + sideNum++ + } + } + return sides +} + +// ExtractBoardSidesFromMask traces the outer boundary of a boolean mask +// and simplifies it into BoardSides. This perfectly matches the 3D generation. +func ExtractBoardSidesFromMask(mask []bool, imgW, imgH int, pixelToMM float64, bounds *Bounds) []BoardSide { + // Find top-leftmost pixel of mask + startX, startY := -1, -1 +outer: + for y := 0; y < imgH; y++ { + for x := 0; x < imgW; x++ { + if mask[y*imgW+x] { + startX, startY = x, y + break outer + } + } + } + if startX == -1 { + return nil + } + + // Moore-neighbor boundary tracing + var boundary [][2]int + dirs := [8][2]int{{1, 0}, {1, 1}, {0, 1}, {-1, 1}, {-1, 0}, {-1, -1}, {0, -1}, {1, -1}} + + curX, curY := startX, startY + boundary = append(boundary, [2]int{curX, curY}) + + // Initial previous neighbor direction (up/west of top-left is empty) + pDir := 6 + + for { + found := false + for i := 0; i < 8; i++ { + // Scan clockwise starting from dir after the previous background pixel + testDir := (pDir + 1 + i) % 8 + nx, ny := curX+dirs[testDir][0], curY+dirs[testDir][1] + if nx >= 0 && nx < imgW && ny >= 0 && ny < imgH && mask[ny*imgW+nx] { + curX, curY = nx, ny + boundary = append(boundary, [2]int{curX, curY}) + // The new background pixel is opposite to the direction we found the solid one + pDir = (testDir + 4) % 8 + found = true + break + } + } + if !found { + break // Isolated pixel + } + // Stop when we return to the start and moved in the same direction + if curX == startX && curY == startY { + break + } + // Failsafe for complex shapes + if len(boundary) > imgW*imgH { + break + } + } + + // Convert boundary pixels to Gerber mm coordinates + var poly [][2]float64 + for _, p := range boundary { + px := float64(p[0])*pixelToMM + bounds.MinX + // Image Y=0 is MaxY in Gerber space + py := bounds.MaxY - float64(p[1])*pixelToMM + poly = append(poly, [2]float64{px, py}) + } + + sides := ExtractBoardSides(poly) + fmt.Printf("[DEBUG] ExtractBoardSidesFromMask: mask size=%dx%d, boundary pixels=%d, sides extracted=%d\n", imgW, imgH, len(boundary), len(sides)) + if len(sides) == 0 && len(poly) > 0 { + fmt.Printf("[DEBUG] poly[0]=%v, poly[n/2]=%v, poly[last]=%v\n", poly[0], poly[len(poly)/2], poly[len(poly)-1]) + } + return sides +} + // GenerateEnclosure creates enclosure + tray meshes from a board outline image and drill holes. // The enclosure walls conform to the actual board outline shape. // courtyardImg is optional — if provided, component courtyard regions are cut from the lid (flood-filled). // soldermaskImg is optional — if provided, soldermask pad openings are also cut from the lid. -func GenerateEnclosure(outlineImg image.Image, drillHoles []DrillHole, cfg EnclosureConfig, courtyardImg image.Image, soldermaskImg image.Image, sideCutouts []SideCutout) *EnclosureResult { +func GenerateEnclosure(outlineImg image.Image, drillHoles []DrillHole, cfg EnclosureConfig, courtyardImg image.Image, soldermaskImg image.Image, sideCutouts []SideCutout, boardSides []BoardSide) *EnclosureResult { pixelToMM := 25.4 / cfg.DPI bounds := outlineImg.Bounds() imgW := bounds.Max.X @@ -228,45 +428,34 @@ func GenerateEnclosure(outlineImg image.Image, drillHoles []DrillHole, cfg Enclo } // Determine which side this wall pixel belongs to - // Find distance to each side of the board bounding box - dTop := math.Abs(float64(y) - float64(minBY)) - dBottom := math.Abs(float64(y) - float64(maxBY)) - dLeft := math.Abs(float64(x) - float64(minBX)) - dRight := math.Abs(float64(x) - float64(maxBX)) + bx := float64(x)*pixelToMM + cfg.OutlineBounds.MinX + by := cfg.OutlineBounds.MaxY - float64(y)*pixelToMM - sideNum := 0 - minDist := dTop - sideNum = 1 // top - if dRight < minDist { - minDist = dRight - sideNum = 2 // right - } - if dBottom < minDist { - minDist = dBottom - sideNum = 3 // bottom - } - if dLeft < minDist { - sideNum = 4 // left - } - - // Position along the side in mm + sideNum := -1 + minDist := math.MaxFloat64 var posAlongSide float64 - var zPos float64 - switch sideNum { - case 1: // top — position = X distance from left board edge - posAlongSide = float64(x-minBX) * pixelToMM - zPos = 0 // all Z heights for walls - case 2: // right — position = Y distance from top board edge - posAlongSide = float64(y-minBY) * pixelToMM - zPos = 0 - case 3: // bottom — position = X distance from left board edge - posAlongSide = float64(x-minBX) * pixelToMM - zPos = 0 - case 4: // left — position = Y distance from top board edge - posAlongSide = float64(y-minBY) * pixelToMM - zPos = 0 + + for _, bs := range boardSides { + dx := bs.EndX - bs.StartX + dy := bs.EndY - bs.StartY + lenSq := dx*dx + dy*dy + if lenSq == 0 { + continue + } + + t := ((bx-bs.StartX)*dx + (by-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((bx-projX)*(bx-projX) + (by-projY)*(by-projY)) + if dist < minDist { + minDist = dist + sideNum = bs.Num + posAlongSide = t * bs.Length + } } - _ = zPos // Check all cutouts for this side for _, c := range sideCutouts { @@ -387,26 +576,30 @@ func GenerateEnclosure(outlineImg image.Image, drillHoles []DrillHole, cfg Enclo _ = midIdx // Find the dominant side and cutout for this run - dTop := math.Abs(float64(y) - float64(minBY)) - dBottom := math.Abs(float64(y) - float64(maxBY)) - dLeft := math.Abs(float64(midX) - float64(minBX)) - dRight := math.Abs(float64(midX) - float64(maxBX)) + bx := float64(midX)*pixelToMM + cfg.OutlineBounds.MinX + by := cfg.OutlineBounds.MaxY - float64(y)*pixelToMM - sideNum := 1 - minDist := dTop - if dRight < minDist { - minDist = dRight - sideNum = 2 - } - if dBottom < minDist { - minDist = dBottom - sideNum = 3 - } - if dLeft < minDist { - sideNum = 4 + sideNum := -1 + minDist := math.MaxFloat64 + for _, bs := range boardSides { + dx := bs.EndX - bs.StartX + dy := bs.EndY - bs.StartY + lenSq := dx*dx + dy*dy + if lenSq == 0 { + continue + } + t := ((bx-bs.StartX)*dx + (by-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((bx-projX)*(bx-projX) + (by-projY)*(by-projY)) + if dist < minDist { + minDist = dist + sideNum = bs.Num + } } - bx := float64(runStart) * pixelToMM + bx2 := float64(runStart) * pixelToMM by2 := float64(y) * pixelToMM bw := float64(x-runStart) * pixelToMM bh := pixelToMM @@ -418,12 +611,12 @@ func GenerateEnclosure(outlineImg image.Image, drillHoles []DrillHole, cfg Enclo } // Wall below cutout: from 0 to cutout.Y if c.Y > 0.1 { - addBoxAtZ(&cutoutEncTris, bx, by2, 0, bw, bh, c.Y) + addBoxAtZ(&cutoutEncTris, bx2, by2, 0, bw, bh, c.Y) } // Wall above cutout: from cutout.Y+cutout.H to totalH cutTop := c.Y + c.Height if cutTop < totalH-0.1 { - addBoxAtZ(&cutoutEncTris, bx, by2, cutTop, bw, bh, totalH-cutTop) + addBoxAtZ(&cutoutEncTris, bx2, by2, cutTop, bw, bh, totalH-cutTop) } break } diff --git a/gerber.go b/gerber.go index 424ceab..4029d8f 100644 --- a/gerber.go +++ b/gerber.go @@ -43,14 +43,16 @@ type GerberState struct { FormatX, FormatY struct { Integer, Decimal int } - Units string // "MM" or "IN" + Units string // "MM" or "IN" + CurrentFootprint string // Stored from %TO.C,Footprint,...*% } type GerberCommand struct { - Type string // "D01", "D02", "D03", "AD", "FS", etc. - X, Y *float64 - I, J *float64 - D *int + Type string // "D01", "D02", "D03", "AD", "FS", etc. + X, Y *float64 + I, J *float64 + D *int + Footprint string } type GerberFile struct { @@ -58,6 +60,63 @@ type GerberFile struct { State GerberState } +// Footprint represents a component bounding area deduced from Gerber X2 attributes +type Footprint struct { + Name string `json:"name"` + MinX float64 `json:"minX"` + MinY float64 `json:"minY"` + MaxX float64 `json:"maxX"` + MaxY float64 `json:"maxY"` + CenterX float64 `json:"centerX"` + CenterY float64 `json:"centerY"` +} + +func ExtractFootprints(gf *GerberFile) []Footprint { + fps := make(map[string]*Footprint) + + for _, cmd := range gf.Commands { + if cmd.Footprint == "" { + continue + } + if cmd.X == nil || cmd.Y == nil { + continue + } + + fp, exists := fps[cmd.Footprint] + if !exists { + fp = &Footprint{ + Name: cmd.Footprint, + MinX: *cmd.X, + MaxX: *cmd.X, + MinY: *cmd.Y, + MaxY: *cmd.Y, + } + fps[cmd.Footprint] = fp + } else { + if *cmd.X < fp.MinX { + fp.MinX = *cmd.X + } + if *cmd.X > fp.MaxX { + fp.MaxX = *cmd.X + } + if *cmd.Y < fp.MinY { + fp.MinY = *cmd.Y + } + if *cmd.Y > fp.MaxY { + fp.MaxY = *cmd.Y + } + } + } + + var result []Footprint + for _, fp := range fps { + fp.CenterX = (fp.MinX + fp.MaxX) / 2.0 + fp.CenterY = (fp.MinY + fp.MaxY) / 2.0 + result = append(result, *fp) + } + return result +} + func NewGerberFile() *GerberFile { return &GerberFile{ State: GerberState{ @@ -174,6 +233,18 @@ func ParseGerber(filename string) (*GerberFile, error) { } else { gf.State.Units = "MM" } + } else if strings.HasPrefix(line, "%TO") { + parts := strings.Split(line, ",") + if len(parts) >= 2 && strings.HasPrefix(parts[0], "%TO.C") { + refDes := strings.TrimSuffix(parts[1], "*%") + if refDes != "" { + gf.State.CurrentFootprint = refDes + } + } + } else if strings.HasPrefix(line, "%TD") { + if strings.Contains(line, "%TD*%") || strings.Contains(line, "%TD,C*%") || strings.Contains(line, "%TD,Footprint*%") { + gf.State.CurrentFootprint = "" + } } continue } @@ -222,7 +293,7 @@ func ParseGerber(filename string) (*GerberFile, error) { // X...Y...D01* matches := reCoord.FindAllStringSubmatch(part, -1) if len(matches) > 0 { - cmd := GerberCommand{Type: "MOVE"} + cmd := GerberCommand{Type: "MOVE", Footprint: gf.State.CurrentFootprint} for _, m := range matches { valStr := m[2] diff --git a/main.go b/main.go index 333155c..6ca5ce1 100644 --- a/main.go +++ b/main.go @@ -11,6 +11,8 @@ import ( "fmt" "html/template" "image" + "image/color" + "image/draw" "image/png" "io" "log" @@ -1001,6 +1003,7 @@ func enclosureUploadHandler(w http.ResponseWriter, r *http.Request) { MinBX: float64(minBX), MaxBX: float64(maxBX), BoardCenterY: boardCenterY, + Sides: ExtractBoardSidesFromMask(boardMask, imgW, imgH, 25.4/ecfg.DPI, &outlineBounds), } sessionsMu.Lock() sessions[uuid] = session @@ -1011,6 +1014,93 @@ func enclosureUploadHandler(w http.ResponseWriter, r *http.Request) { } +func footprintUploadHandler(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + err := r.ParseMultipartForm(50 << 20) // 50 MB + if err != nil { + http.Error(w, "Error parsing form", http.StatusBadRequest) + return + } + + sessionID := r.FormValue("sessionId") + if sessionID == "" { + http.Error(w, "Missing sessionId", http.StatusBadRequest) + return + } + + sessionsMu.Lock() + session, ok := sessions[sessionID] + sessionsMu.Unlock() + if !ok { + http.Error(w, "Invalid session", http.StatusBadRequest) + return + } + + files := r.MultipartForm.File["gerbers"] + var allFootprints []Footprint + var fabGfList []*GerberFile + + for _, fileHeader := range files { + f, err := fileHeader.Open() + if err != nil { + continue + } + + b := make([]byte, 8) + rand.Read(b) + tempPath := filepath.Join("temp", fmt.Sprintf("%x_%s", b, fileHeader.Filename)) + out, err := os.Create(tempPath) + if err == nil { + io.Copy(out, f) + out.Close() + gf, err := ParseGerber(tempPath) + if err == nil { + allFootprints = append(allFootprints, ExtractFootprints(gf)...) + fabGfList = append(fabGfList, gf) + } + } + f.Close() + } + + // Composite Fab images into one transparent overlay + if len(fabGfList) > 0 { + bounds := session.OutlineBounds + imgW := int((bounds.MaxX - bounds.MinX) * session.Config.DPI / 25.4) + imgH := int((bounds.MaxY - bounds.MinY) * session.Config.DPI / 25.4) + if imgW > 0 && imgH > 0 { + composite := image.NewRGBA(image.Rect(0, 0, imgW, imgH)) + // Initialize with pure transparency + draw.Draw(composite, composite.Bounds(), &image.Uniform{color.Transparent}, image.Point{}, draw.Src) + + for _, gf := range fabGfList { + layerImg := gf.Render(session.Config.DPI, &bounds) + if rgba, ok := layerImg.(*image.RGBA); ok { + for y := 0; y < imgH; y++ { + for x := 0; x < imgW; x++ { + // Gerber render background is Black. White is drawn pixels. + if r, _, _, _ := rgba.At(x, y).RGBA(); r > 0x7FFF { + // Set as cyan overlay for visibility + composite.Set(x, y, color.RGBA{0, 255, 255, 180}) + } + } + } + } + } + sessionsMu.Lock() + session.FabImg = composite + sessionsMu.Unlock() + } + } + + // Return all parsed footprints for visual selection + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(allFootprints) +} + func renderResult(w http.ResponseWriter, message string, files []string, backURL string, zipFile string) { tmpl, err := template.ParseFS(staticFiles, "static/result.html") if err != nil { @@ -1043,6 +1133,8 @@ type EnclosureSession struct { MinBX float64 MaxBX float64 BoardCenterY float64 + Sides []BoardSide + FabImg image.Image } var ( @@ -1061,13 +1153,21 @@ func previewHandler(w http.ResponseWriter, r *http.Request) { } boardInfo := struct { - BoardW float64 `json:"boardW"` - BoardH float64 `json:"boardH"` - TotalH float64 `json:"totalH"` + BoardW float64 `json:"boardW"` + BoardH float64 `json:"boardH"` + TotalH float64 `json:"totalH"` + Sides []BoardSide `json:"sides"` + MinX float64 `json:"minX"` + MaxY float64 `json:"maxY"` + DPI float64 `json:"dpi"` }{ BoardW: session.BoardW, BoardH: session.BoardH, TotalH: session.TotalH, + Sides: session.Sides, + MinX: session.OutlineBounds.MinX, + MaxY: session.OutlineBounds.MaxY, + DPI: session.Config.DPI, } boardJSON, _ := json.Marshal(boardInfo) @@ -1172,7 +1272,7 @@ func generateEnclosureHandler(w http.ResponseWriter, r *http.Request) { // Process STL if wantsType("stl") { fmt.Println("Generating STLs...") - result := GenerateEnclosure(session.OutlineImg, session.DrillHoles, session.Config, session.CourtyardImg, session.SoldermaskImg, sideCutouts) + result := GenerateEnclosure(session.OutlineImg, session.DrillHoles, session.Config, session.CourtyardImg, session.SoldermaskImg, sideCutouts, session.Sides) encPath := filepath.Join("temp", id+"_enclosure.stl") trayPath := filepath.Join("temp", id+"_tray.stl") WriteSTL(encPath, result.EnclosureTriangles) @@ -1186,8 +1286,8 @@ func generateEnclosureHandler(w http.ResponseWriter, r *http.Request) { 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) + WriteNativeSCAD(scadPathEnc, false, outlinePoly, session.Config, session.DrillHoles, sideCutouts, session.Sides, session.MinBX, session.MaxBX, session.BoardCenterY) + WriteNativeSCAD(scadPathTray, true, outlinePoly, session.Config, session.DrillHoles, sideCutouts, session.Sides, session.MinBX, session.MaxBX, session.BoardCenterY) generatedFiles = append(generatedFiles, filepath.Base(scadPathEnc), filepath.Base(scadPathTray)) } @@ -1264,6 +1364,7 @@ func runServer(port string) { http.HandleFunc("/", indexHandler) http.HandleFunc("/upload", uploadHandler) http.HandleFunc("/upload-enclosure", enclosureUploadHandler) + http.HandleFunc("/upload-footprints", footprintUploadHandler) http.HandleFunc("/preview", previewHandler) http.HandleFunc("/preview-image/", previewImageHandler) http.HandleFunc("/generate-enclosure", generateEnclosureHandler) diff --git a/pcb-to-stencil b/pcb-to-stencil deleted file mode 100755 index db866bb..0000000 Binary files a/pcb-to-stencil and /dev/null differ diff --git a/run_server.sh b/run_server.sh new file mode 100755 index 0000000..f202855 --- /dev/null +++ b/run_server.sh @@ -0,0 +1,4 @@ +#!/bin/bash +pkill -f ./bin/pcb-to-stencil\ -server +go clean && go build -o bin/pcb-to-stencil . +./bin/pcb-to-stencil -server diff --git a/scad.go b/scad.go index c9329f1..a329c1d 100644 --- a/scad.go +++ b/scad.go @@ -39,7 +39,8 @@ func WriteSCAD(filename string, triangles [][3]Point) error { // ExtractPolygonFromGerber traces the Edge.Cuts gerber to form a continuous 2D polygon func ExtractPolygonFromGerber(gf *GerberFile) [][2]float64 { - var points [][2]float64 + var strokes [][][2]float64 + var currentStroke [][2]float64 curX, curY := 0.0, 0.0 interpolationMode := "G01" @@ -57,13 +58,18 @@ func ExtractPolygonFromGerber(gf *GerberFile) [][2]float64 { curY = *cmd.Y } - if cmd.Type == "DRAW" { - if len(points) == 0 { - points = append(points, [2]float64{prevX, prevY}) + if cmd.Type == "MOVE" { + if len(currentStroke) > 0 { + strokes = append(strokes, currentStroke) + currentStroke = nil + } + } else if cmd.Type == "DRAW" { + if len(currentStroke) == 0 { + currentStroke = append(currentStroke, [2]float64{prevX, prevY}) } if interpolationMode == "G01" { - points = append(points, [2]float64{curX, curY}) + currentStroke = append(currentStroke, [2]float64{curX, curY}) } else { iVal, jVal := 0.0, 0.0 if cmd.I != nil { @@ -94,16 +100,117 @@ func ExtractPolygonFromGerber(gf *GerberFile) [][2]float64 { 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}) + currentStroke = append(currentStroke, [2]float64{ax, ay}) } } } } - return points + if len(currentStroke) > 0 { + strokes = append(strokes, currentStroke) + } + + if len(strokes) == 0 { + return nil + } + + // Stitch strokes into closed loops + var loops [][][2]float64 + used := make([]bool, len(strokes)) + epsilon := 0.05 // 0.05mm tolerance + + for startIdx := 0; startIdx < len(strokes); startIdx++ { + if used[startIdx] { + continue + } + used[startIdx] = true + path := append([][2]float64{}, strokes[startIdx]...) + + for { + endPt := path[len(path)-1] + startPt := path[0] + found := false + + for j := 0; j < len(strokes); j++ { + if used[j] { + continue + } + s := strokes[j] + sStart := s[0] + sEnd := s[len(s)-1] + + dist := func(a, b [2]float64) float64 { + dx, dy := a[0]-b[0], a[1]-b[1] + return math.Sqrt(dx*dx + dy*dy) + } + + if dist(endPt, sStart) < epsilon { + path = append(path, s[1:]...) + used[j] = true + found = true + break + } else if dist(endPt, sEnd) < epsilon { + for k := len(s) - 2; k >= 0; k-- { + path = append(path, s[k]) + } + used[j] = true + found = true + break + } else if dist(startPt, sEnd) < epsilon { + // prepend + newPath := append([][2]float64{}, s[:len(s)-1]...) + path = append(newPath, path...) + used[j] = true + found = true + break + } else if dist(startPt, sStart) < epsilon { + // reversed prepend + var newPath [][2]float64 + for k := len(s) - 1; k > 0; k-- { + newPath = append(newPath, s[k]) + } + path = append(newPath, path...) + used[j] = true + found = true + break + } + } + if !found { + break + } + } + loops = append(loops, path) + } + + // Find the longest loop (the main board outline) + var bestLoop [][2]float64 + maxLen := 0.0 + for _, l := range loops { + loopLen := 0.0 + for i := 0; i < len(l)-1; i++ { + dx := l[i+1][0] - l[i][0] + dy := l[i+1][1] - l[i][1] + loopLen += math.Sqrt(dx*dx + dy*dy) + } + if loopLen > maxLen { + maxLen = loopLen + bestLoop = l + } + } + + // Always ensure path is closed + if len(bestLoop) > 2 { + first := bestLoop[0] + last := bestLoop[len(bestLoop)-1] + if math.Abs(first[0]-last[0]) > epsilon || math.Abs(first[1]-last[1]) > epsilon { + bestLoop = append(bestLoop, first) + } + } + + return bestLoop } // 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 { +func WriteNativeSCAD(filename string, isTray bool, outlineVertices [][2]float64, cfg EnclosureConfig, holes []DrillHole, cutouts []SideCutout, sides []BoardSide, minBX, maxBX, boardCenterY float64) error { f, err := os.Create(filename) if err != nil { return err @@ -152,26 +259,35 @@ func WriteNativeSCAD(filename string, isTray bool, outlineVertices [][2]float64, // 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) + var bs *BoardSide + for i := range sides { + if sides[i].Num == c.Side { + bs = &sides[i] + break + } } + if bs == nil { + continue + } + + // Cutouts are relative to board. + z := c.Height/2 + trayFloor + pcbT + w, d, h := c.Width, 20.0, c.Height // d is deep enough to cut through walls + + dx := bs.EndX - bs.StartX + dy := bs.EndY - bs.StartY + length := math.Sqrt(dx*dx + dy*dy) + if length > 0 { + dx /= length + dy /= length + } + + midX := bs.StartX + dx*(c.X+w/2) + midY := bs.StartY + dy*(c.X+w/2) + + 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) } fmt.Fprintf(f, "}\n\n") diff --git a/static/preview.html b/static/preview.html index 4587ee2..4e6f0ad 100644 --- a/static/preview.html +++ b/static/preview.html @@ -232,6 +232,61 @@ text-align: right; margin-top: 0.2rem; } + + /* Auto-Align Modal */ + .modal { + display: none; + position: fixed; + z-index: 1000; + left: 0; + top: 0; + width: 100%; + height: 100%; + background-color: rgba(0, 0, 0, 0.5); + align-items: center; + justify-content: center; + } + + .modal-content { + background-color: #fefefe; + padding: 24px; + border-radius: 8px; + width: 90%; + max-width: 500px; + box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); + } + + .close-modal { + color: #aaa; + float: right; + font-size: 28px; + font-weight: bold; + cursor: pointer; + line-height: 1; + margin-top: -5px; + } + + .close-modal:hover { + color: black; + } + + .fp-item { + padding: 10px; + border: 1px solid #e5e7eb; + margin-bottom: 5px; + border-radius: 4px; + cursor: pointer; + font-size: 0.85rem; + } + + .fp-item:hover { + background-color: #f9fafb; + } + + .fp-item.selected { + background-color: #eff6ff; + border-color: #3b82f6; + } @@ -316,6 +371,13 @@
+ +