656 lines
20 KiB
Go
656 lines
20 KiB
Go
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
|
||
}
|