pcb-to-stencil/former.go

413 lines
12 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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.01.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 065535; 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
}