Former/former.go

656 lines
20 KiB
Go
Raw Permalink 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"
"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.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"`
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 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
}