413 lines
12 KiB
Go
413 lines
12 KiB
Go
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
|
||
}
|