package main import ( "image" "image/color" "math" "strings" ) // KiCad-standard layer colors (NRGBA, no alpha — alpha is controlled by BaseAlpha) var ( ColorFCu = color.NRGBA{R: 200, G: 52, B: 52, A: 255} ColorBCu = color.NRGBA{R: 77, G: 127, B: 196, A: 255} ColorFPaste = color.NRGBA{R: 200, G: 52, B: 52, A: 255} ColorBPaste = color.NRGBA{R: 0, G: 194, B: 194, A: 255} ColorFMask = color.NRGBA{R: 132, G: 0, B: 132, A: 255} ColorBMask = color.NRGBA{R: 0, G: 132, B: 132, A: 255} ColorFSilkS = color.NRGBA{R: 232, G: 232, B: 232, A: 255} ColorBSilkS = color.NRGBA{R: 200, G: 200, B: 200, A: 255} ColorFab = color.NRGBA{R: 132, G: 132, B: 132, A: 255} ColorEdgeCuts = color.NRGBA{R: 200, G: 200, B: 0, A: 255} ColorCrtYd = color.NRGBA{R: 194, G: 194, B: 194, A: 255} ColorEnclosure = color.NRGBA{R: 255, G: 253, B: 230, A: 255} ColorStencil = color.NRGBA{R: 180, G: 180, B: 180, A: 255} ) // FormerLayer represents a single displayable layer in The Former. type FormerLayer struct { Name string Color color.NRGBA Source image.Image // raw white-on-black gerber render Visible bool Highlight bool BaseAlpha float64 // default opacity 0.0–1.0 (all layers slightly transparent) SourceFile string // original gerber filename (key into AllLayerGerbers) } // ElementBBox describes a selectable graphic element's bounding box on a layer. type ElementBBox struct { ID int `json:"id"` MinX float64 `json:"minX"` MinY float64 `json:"minY"` MaxX float64 `json:"maxX"` MaxY float64 `json:"maxY"` Type string `json:"type"` Shape string `json:"shape"` // "circle" or "rect" Footprint string `json:"footprint"` } // macroApertureSize examines a macro's primitives to determine shape and half-extents // by computing the full bounding box from all primitive positions and sizes. func macroApertureSize(macro Macro, params []float64) (float64, float64, string) { onlyCircles := true // track if macro has ONLY circle primitives centered at origin minX, minY := math.MaxFloat64, math.MaxFloat64 maxX, maxY := -math.MaxFloat64, -math.MaxFloat64 hasPrimitives := false expand := func(x1, y1, x2, y2 float64) { if x1 < minX { minX = x1 } if y1 < minY { minY = y1 } if x2 > maxX { maxX = x2 } if y2 > maxY { maxY = y2 } hasPrimitives = true } for _, prim := range macro.Primitives { switch prim.Code { case 1: // Circle: exposure, diameter, centerX, centerY if len(prim.Modifiers) >= 4 { exposure := evaluateMacroExpression(prim.Modifiers[0], params) if exposure == 0 { continue } dia := evaluateMacroExpression(prim.Modifiers[1], params) cx := evaluateMacroExpression(prim.Modifiers[2], params) cy := evaluateMacroExpression(prim.Modifiers[3], params) r := dia / 2.0 expand(cx-r, cy-r, cx+r, cy+r) if cx != 0 || cy != 0 { onlyCircles = false } } else if len(prim.Modifiers) >= 2 { exposure := evaluateMacroExpression(prim.Modifiers[0], params) if exposure == 0 { continue } dia := evaluateMacroExpression(prim.Modifiers[1], params) r := dia / 2.0 expand(-r, -r, r, r) } case 5: // Regular polygon: exposure, numVerts, centerX, centerY, diameter, rotation if len(prim.Modifiers) >= 5 { exposure := evaluateMacroExpression(prim.Modifiers[0], params) if exposure == 0 { continue } cx := evaluateMacroExpression(prim.Modifiers[2], params) cy := evaluateMacroExpression(prim.Modifiers[3], params) dia := evaluateMacroExpression(prim.Modifiers[4], params) r := dia / 2.0 expand(cx-r, cy-r, cx+r, cy+r) if cx != 0 || cy != 0 { onlyCircles = false } } case 4: // Outline polygon: exposure, numVerts, x1, y1, ..., xn, yn, rotation onlyCircles = false if len(prim.Modifiers) >= 3 { exposure := evaluateMacroExpression(prim.Modifiers[0], params) if exposure == 0 { continue } numVerts := int(evaluateMacroExpression(prim.Modifiers[1], params)) for i := 0; i < numVerts && 2+i*2+1 < len(prim.Modifiers); i++ { vx := evaluateMacroExpression(prim.Modifiers[2+i*2], params) vy := evaluateMacroExpression(prim.Modifiers[2+i*2+1], params) expand(vx, vy, vx, vy) } } case 20: // Vector line: exposure, width, startX, startY, endX, endY, rotation onlyCircles = false if len(prim.Modifiers) >= 6 { exposure := evaluateMacroExpression(prim.Modifiers[0], params) if exposure == 0 { continue } w := evaluateMacroExpression(prim.Modifiers[1], params) sx := evaluateMacroExpression(prim.Modifiers[2], params) sy := evaluateMacroExpression(prim.Modifiers[3], params) ex := evaluateMacroExpression(prim.Modifiers[4], params) ey := evaluateMacroExpression(prim.Modifiers[5], params) hw := w / 2 expand(math.Min(sx, ex)-hw, math.Min(sy, ey)-hw, math.Max(sx, ex)+hw, math.Max(sy, ey)+hw) } case 21: // Center line: exposure, width, height, centerX, centerY, rotation onlyCircles = false if len(prim.Modifiers) >= 5 { exposure := evaluateMacroExpression(prim.Modifiers[0], params) if exposure == 0 { continue } w := evaluateMacroExpression(prim.Modifiers[1], params) h := evaluateMacroExpression(prim.Modifiers[2], params) cx := evaluateMacroExpression(prim.Modifiers[3], params) cy := evaluateMacroExpression(prim.Modifiers[4], params) expand(cx-w/2, cy-h/2, cx+w/2, cy+h/2) } } } if !hasPrimitives { if len(params) > 0 { r := params[0] / 2.0 return r, r, "rect" } return 0.25, 0.25, "rect" } hw := (maxX - minX) / 2.0 hh := (maxY - minY) / 2.0 // If the macro only has circle primitives centered at origin, it's a circle if onlyCircles { return hw, hh, "circle" } return hw, hh, "rect" } // ExtractElementBBoxes walks gerber commands and returns bounding boxes in image pixel coordinates. func ExtractElementBBoxes(gf *GerberFile, dpi float64, bounds *Bounds) []ElementBBox { if gf == nil { return nil } mmToPx := func(mmX, mmY float64) (float64, float64) { px := (mmX - bounds.MinX) * dpi / 25.4 py := (bounds.MaxY - mmY) * dpi / 25.4 return px, py } apertureSize := func(dcode int) (float64, float64, string) { ap, ok := gf.State.Apertures[dcode] if !ok || len(ap.Modifiers) == 0 { return 0.25, 0.25, "rect" } switch ap.Type { case ApertureCircle: r := ap.Modifiers[0] / 2.0 return r, r, "circle" case ApertureRect: hw := ap.Modifiers[0] / 2.0 hh := hw if len(ap.Modifiers) >= 2 { hh = ap.Modifiers[1] / 2.0 } return hw, hh, "rect" case ApertureObround: hw := ap.Modifiers[0] / 2.0 hh := hw if len(ap.Modifiers) >= 2 { hh = ap.Modifiers[1] / 2.0 } return hw, hh, "obround" case AperturePolygon: r := ap.Modifiers[0] / 2.0 return r, r, "circle" default: // Check if this is a macro aperture and determine shape from primitives if macro, mok := gf.State.Macros[ap.Type]; mok { return macroApertureSize(macro, ap.Modifiers) } r := ap.Modifiers[0] / 2.0 return r, r, "rect" } } apertureRadius := func(dcode int) float64 { if ap, ok := gf.State.Apertures[dcode]; ok && len(ap.Modifiers) > 0 { return ap.Modifiers[0] / 2.0 } return 0.25 } var elements []ElementBBox id := 0 curX, curY := 0.0, 0.0 curDCode := 0 interpMode := "G01" // linear by default for _, cmd := range gf.Commands { switch cmd.Type { case "APERTURE": if cmd.D != nil { curDCode = *cmd.D } continue case "G01", "G02", "G03": interpMode = cmd.Type continue case "G36", "G37": continue } prevX, prevY := curX, curY if cmd.X != nil { curX = *cmd.X } if cmd.Y != nil { curY = *cmd.Y } switch cmd.Type { case "FLASH": // D03 hw, hh, shape := apertureSize(curDCode) px, py := mmToPx(curX, curY) hwPx := hw * dpi / 25.4 hhPx := hh * dpi / 25.4 elements = append(elements, ElementBBox{ ID: id, MinX: px - hwPx, MinY: py - hhPx, MaxX: px + hwPx, MaxY: py + hhPx, Type: "pad", Shape: shape, Footprint: cmd.Footprint, }) id++ case "DRAW": // D01 r := apertureRadius(curDCode) rpx := r * dpi / 25.4 if (interpMode == "G02" || interpMode == "G03") && cmd.I != nil && cmd.J != nil { // Arc draw: compute actual arc bounding box from center + radius ci := *cmd.I cj := *cmd.J cx := prevX + ci cy := prevY + cj arcR := math.Sqrt(ci*ci + cj*cj) // Arc bounding box: conservatively use the full circle extent // (precise arc bbox requires start/end angle clipping, but this is good enough) arcRPx := arcR * dpi / 25.4 cpx, cpy := mmToPx(cx, cy) elements = append(elements, ElementBBox{ ID: id, MinX: cpx - arcRPx - rpx, MinY: cpy - arcRPx - rpx, MaxX: cpx + arcRPx + rpx, MaxY: cpy + arcRPx + rpx, Type: "arc", Shape: "circle", Footprint: cmd.Footprint, }) } else { // Linear draw px1, py1 := mmToPx(prevX, prevY) px2, py2 := mmToPx(curX, curY) minPx := math.Min(px1, px2) maxPx := math.Max(px1, px2) minPy := math.Min(py1, py2) maxPy := math.Max(py1, py2) elements = append(elements, ElementBBox{ ID: id, MinX: minPx - rpx, MinY: minPy - rpx, MaxX: maxPx + rpx, MaxY: maxPy + rpx, Type: "trace", Shape: "rect", Footprint: cmd.Footprint, }) } id++ } } // Post-process: merge overlapping arc elements into single circles. // Half-circle arcs sharing the same center produce overlapping bboxes. if len(elements) > 1 { var merged []ElementBBox used := make([]bool, len(elements)) for i := 0; i < len(elements); i++ { if used[i] { continue } el := elements[i] if el.Type == "arc" { // Try to merge with nearby arcs that overlap substantially for j := i + 1; j < len(elements); j++ { if used[j] || elements[j].Type != "arc" { continue } ej := elements[j] // Check if centers are close (overlapping circles from same center) cx1 := (el.MinX + el.MaxX) / 2 cy1 := (el.MinY + el.MaxY) / 2 cx2 := (ej.MinX + ej.MaxX) / 2 cy2 := (ej.MinY + ej.MaxY) / 2 r1 := (el.MaxX - el.MinX) / 2 dist := math.Sqrt((cx2-cx1)*(cx2-cx1) + (cy2-cy1)*(cy2-cy1)) if dist < r1*0.5 { // Merge: expand bbox if ej.MinX < el.MinX { el.MinX = ej.MinX } if ej.MinY < el.MinY { el.MinY = ej.MinY } if ej.MaxX > el.MaxX { el.MaxX = ej.MaxX } if ej.MaxY > el.MaxY { el.MaxY = ej.MaxY } used[j] = true } } } merged = append(merged, el) } elements = merged // Also merge trace elements by footprint type fpGroup struct { minX, minY, maxX, maxY float64 } groups := map[string]*fpGroup{} var final []ElementBBox for _, el := range elements { if el.Footprint == "" || el.Type == "arc" || el.Type == "pad" { final = append(final, el) continue } g, ok := groups[el.Footprint] if !ok { g = &fpGroup{minX: el.MinX, minY: el.MinY, maxX: el.MaxX, maxY: el.MaxY} groups[el.Footprint] = g } else { if el.MinX < g.minX { g.minX = el.MinX } if el.MinY < g.minY { g.minY = el.MinY } if el.MaxX > g.maxX { g.maxX = el.MaxX } if el.MaxY > g.maxY { g.maxY = el.MaxY } } } for fp, g := range groups { w := g.maxX - g.minX h := g.maxY - g.minY shape := "rect" if w > 0 && h > 0 { ratio := w / h if ratio > 0.85 && ratio < 1.15 { shape = "circle" } } final = append(final, ElementBBox{ ID: id, MinX: g.minX, MinY: g.minY, MaxX: g.maxX, MaxY: g.maxY, Type: "component", Shape: shape, Footprint: fp, }) id++ } if len(groups) > 0 { elements = final } } return elements } // colorizeLayer converts a white-on-black source image into a colored NRGBA image. // Bright pixels become the layer color at the given alpha; dark pixels become transparent. func colorizeLayer(src image.Image, col color.NRGBA, alpha float64) *image.NRGBA { bounds := src.Bounds() w := bounds.Dx() h := bounds.Dy() dst := image.NewNRGBA(image.Rect(0, 0, w, h)) a := uint8(alpha * 255) for y := 0; y < h; y++ { for x := 0; x < w; x++ { r, _, _, _ := src.At(x+bounds.Min.X, y+bounds.Min.Y).RGBA() // r is 0–65535; treat anything above ~10% as "active" if r > 6500 { dst.SetNRGBA(x, y, color.NRGBA{R: col.R, G: col.G, B: col.B, A: a}) } // else: stays transparent (zero value) } } return dst } // composeLayers blends all visible layers into a single image. // Background matches the app theme. If any layer has Highlight=true, // that layer renders at full BaseAlpha while others are dimmed. func composeLayers(layers []*FormerLayer, width, height int) *image.NRGBA { dst := image.NewNRGBA(image.Rect(0, 0, width, height)) // Fill with theme background bg := color.NRGBA{R: 52, G: 53, B: 60, A: 255} for i := 0; i < width*height*4; i += 4 { dst.Pix[i+0] = bg.R dst.Pix[i+1] = bg.G dst.Pix[i+2] = bg.B dst.Pix[i+3] = bg.A } // Check if any layer is highlighted hasHighlight := false for _, l := range layers { if l.Highlight && l.Visible { hasHighlight = true break } } for _, l := range layers { if !l.Visible || l.Source == nil { continue } alpha := l.BaseAlpha if hasHighlight && !l.Highlight { alpha *= 0.3 // dim non-highlighted layers } colored := colorizeLayer(l.Source, l.Color, alpha) // Alpha-blend colored layer onto dst srcBounds := colored.Bounds() for y := 0; y < height && y < srcBounds.Dy(); y++ { for x := 0; x < width && x < srcBounds.Dx(); x++ { si := (y*srcBounds.Dx() + x) * 4 di := (y*width + x) * 4 sa := float64(colored.Pix[si+3]) / 255.0 if sa == 0 { continue } sr := float64(colored.Pix[si+0]) sg := float64(colored.Pix[si+1]) sb := float64(colored.Pix[si+2]) dr := float64(dst.Pix[di+0]) dg := float64(dst.Pix[di+1]) db := float64(dst.Pix[di+2]) inv := 1.0 - sa dst.Pix[di+0] = uint8(sr*sa + dr*inv) dst.Pix[di+1] = uint8(sg*sa + dg*inv) dst.Pix[di+2] = uint8(sb*sa + db*inv) dst.Pix[di+3] = 255 } } } return dst } // layerInfo holds the display name and color for a KiCad layer. type layerInfo struct { Name string Color color.NRGBA DefaultOn bool // visible by default Alpha float64 // base alpha SortOrder int // lower = drawn first (bottom) } // inferLayer maps a gerber filename to its KiCad layer name, color, and defaults. func inferLayer(filename string) layerInfo { lf := strings.ToLower(filename) switch { case strings.Contains(lf, "edge_cuts") || strings.Contains(lf, "edge.cuts"): return layerInfo{"Edge Cuts", ColorEdgeCuts, true, 0.7, 10} case strings.Contains(lf, "f_cu") || strings.Contains(lf, "f.cu") || strings.Contains(lf, "-gtl"): return layerInfo{"F.Cu", ColorFCu, false, 0.7, 20} case strings.Contains(lf, "b_cu") || strings.Contains(lf, "b.cu") || strings.Contains(lf, "-gbl"): return layerInfo{"B.Cu", ColorBCu, false, 0.7, 21} case (strings.Contains(lf, "in1") || strings.Contains(lf, "in_1")) && strings.Contains(lf, "cu"): return layerInfo{"In1.Cu", color.NRGBA{R: 200, G: 160, B: 52, A: 255}, false, 0.7, 22} case (strings.Contains(lf, "in2") || strings.Contains(lf, "in_2")) && strings.Contains(lf, "cu"): return layerInfo{"In2.Cu", color.NRGBA{R: 200, G: 52, B: 200, A: 255}, false, 0.7, 23} case strings.Contains(lf, "f_paste") || strings.Contains(lf, "f.paste") || strings.Contains(lf, "-gtp"): return layerInfo{"F.Paste", ColorFPaste, false, 0.7, 30} case strings.Contains(lf, "b_paste") || strings.Contains(lf, "b.paste") || strings.Contains(lf, "-gbp"): return layerInfo{"B.Paste", ColorBPaste, false, 0.7, 31} case strings.Contains(lf, "f_silks") || strings.Contains(lf, "f.silks") || strings.Contains(lf, "f_silk"): return layerInfo{"F.SilkS", ColorFSilkS, false, 0.7, 40} case strings.Contains(lf, "b_silks") || strings.Contains(lf, "b.silks") || strings.Contains(lf, "b_silk"): return layerInfo{"B.SilkS", ColorBSilkS, false, 0.7, 41} case strings.Contains(lf, "f_mask") || strings.Contains(lf, "f.mask") || strings.Contains(lf, "-gts"): return layerInfo{"F.Mask", ColorFMask, false, 0.6, 50} case strings.Contains(lf, "b_mask") || strings.Contains(lf, "b.mask") || strings.Contains(lf, "-gbs"): return layerInfo{"B.Mask", ColorBMask, false, 0.6, 51} case strings.Contains(lf, "f_courtyard") || strings.Contains(lf, "f_crtyd") || strings.Contains(lf, "f.crtyd"): return layerInfo{"F.CrtYd", ColorCrtYd, false, 0.6, 60} case strings.Contains(lf, "b_courtyard") || strings.Contains(lf, "b_crtyd") || strings.Contains(lf, "b.crtyd"): return layerInfo{"B.CrtYd", ColorCrtYd, false, 0.6, 61} case strings.Contains(lf, "f_fab") || strings.Contains(lf, "f.fab"): return layerInfo{"F.Fab", ColorFab, false, 0.6, 70} case strings.Contains(lf, "b_fab") || strings.Contains(lf, "b.fab"): return layerInfo{"B.Fab", ColorFab, false, 0.6, 71} case strings.Contains(lf, ".gbrjob"): return layerInfo{} // skip job files default: return layerInfo{filename, color.NRGBA{R: 160, G: 160, B: 160, A: 255}, false, 0.6, 100} } } // renderEnclosureWallImage generates a 2D top-down image of the enclosure walls // from the outline image and config. White pixels = wall area. func renderEnclosureWallImage(outlineImg image.Image, cfg EnclosureConfig) image.Image { bounds := outlineImg.Bounds() w := bounds.Dx() h := bounds.Dy() pixelToMM := 25.4 / cfg.DPI wallDist, boardMask := ComputeWallMask(outlineImg, cfg.WallThickness+cfg.Clearance, pixelToMM) wallThickPx := int(cfg.WallThickness / pixelToMM) dst := image.NewRGBA(image.Rect(0, 0, w, h)) for y := 0; y < h; y++ { for x := 0; x < w; x++ { idx := y*w + x // Wall area: outside the board, within wall thickness distance if !boardMask[idx] && wallDist[idx] > 0 && wallDist[idx] <= wallThickPx { dst.Pix[idx*4+0] = 255 dst.Pix[idx*4+1] = 255 dst.Pix[idx*4+2] = 255 dst.Pix[idx*4+3] = 255 } } } return dst } // buildStencilLayers creates FormerLayer slice for the stencil workflow. func buildStencilLayers(pasteImg, outlineImg image.Image) []*FormerLayer { var layers []*FormerLayer if outlineImg != nil { layers = append(layers, &FormerLayer{ Name: "Edge Cuts", Color: ColorEdgeCuts, Source: outlineImg, Visible: true, BaseAlpha: 0.7, }) } if pasteImg != nil { layers = append(layers, &FormerLayer{ Name: "Solder Paste", Color: ColorFPaste, Source: pasteImg, Visible: true, BaseAlpha: 0.8, }) } return layers } // buildEnclosureLayers creates FormerLayer slice for the enclosure workflow // using ALL uploaded gerber layers, not just the ones with special roles. func buildEnclosureLayers(session *EnclosureSession) []*FormerLayer { type sortedLayer struct { layer *FormerLayer order int } var sorted []sortedLayer // Tray — hidden by default, rendered as 3D geometry in the Former if session.EnclosureWallImg != nil { sorted = append(sorted, sortedLayer{ layer: &FormerLayer{ Name: "Tray", Color: color.NRGBA{R: 180, G: 200, B: 160, A: 255}, Source: session.EnclosureWallImg, // placeholder image Visible: false, BaseAlpha: 0.4, }, order: -1, }) } // Enclosure walls — bottom layer, very transparent if session.EnclosureWallImg != nil { sorted = append(sorted, sortedLayer{ layer: &FormerLayer{ Name: "Enclosure", Color: ColorEnclosure, Source: session.EnclosureWallImg, Visible: true, BaseAlpha: 0.35, }, order: 0, }) } // All gerber layers from uploaded files for origName, img := range session.AllLayerImages { if img == nil { continue } info := inferLayer(origName) if info.Name == "" { continue } sorted = append(sorted, sortedLayer{ layer: &FormerLayer{ Name: info.Name, Color: info.Color, Source: img, Visible: info.DefaultOn, BaseAlpha: info.Alpha, SourceFile: origName, }, order: info.SortOrder, }) } // Sort by order (lower = bottom) for i := 0; i < len(sorted); i++ { for j := i + 1; j < len(sorted); j++ { if sorted[j].order < sorted[i].order { sorted[i], sorted[j] = sorted[j], sorted[i] } } } layers := make([]*FormerLayer, len(sorted)) for i, s := range sorted { layers[i] = s.layer } return layers }