package main import ( "image" "image/color" "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"` Footprint string `json:"footprint"` } // 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 } 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 for _, cmd := range gf.Commands { switch cmd.Type { case "APERTURE": if cmd.D != nil { curDCode = *cmd.D } continue case "G01", "G02", "G03", "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 r := apertureRadius(curDCode) px, py := mmToPx(curX, curY) rpx := r * dpi / 25.4 elements = append(elements, ElementBBox{ ID: id, MinX: px - rpx, MinY: py - rpx, MaxX: px + rpx, MaxY: py + rpx, Type: "pad", Footprint: cmd.Footprint, }) id++ case "DRAW": // D01 r := apertureRadius(curDCode) px1, py1 := mmToPx(prevX, prevY) px2, py2 := mmToPx(curX, curY) rpx := r * dpi / 25.4 minPx := px1 if px2 < minPx { minPx = px2 } maxPx := px1 if px2 > maxPx { maxPx = px2 } minPy := py1 if py2 < minPy { minPy = py2 } maxPy := py1 if py2 > maxPy { maxPy = py2 } elements = append(elements, ElementBBox{ ID: id, MinX: minPx - rpx, MinY: minPy - rpx, MaxX: maxPx + rpx, MaxY: maxPy + rpx, Type: "trace", Footprint: cmd.Footprint, }) id++ } } 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 }