Added automatic USB port cutout placement based on F.Fab and B.Fab layers
This commit is contained in:
parent
448987d97b
commit
cf424d4611
Binary file not shown.
Binary file not shown.
309
enclosure.go
309
enclosure.go
|
|
@ -33,18 +33,218 @@ type EnclosureResult struct {
|
||||||
|
|
||||||
// SideCutout defines a cutout on a side wall face
|
// SideCutout defines a cutout on a side wall face
|
||||||
type SideCutout struct {
|
type SideCutout struct {
|
||||||
Side int // 1-indexed side number (clockwise from top)
|
Side int // 1-indexed side number (matches BoardSide.Num)
|
||||||
X, Y float64 // Position on the face in mm (from left edge, from bottom)
|
X, Y float64 // Position on the face in mm (from StartX/StartY, from bottom)
|
||||||
Width float64 // Width in mm
|
Width float64 // Width in mm
|
||||||
Height float64 // Height in mm
|
Height float64 // Height in mm
|
||||||
CornerRadius float64 // Corner radius in mm (0 for square)
|
CornerRadius float64 // Corner radius in mm (0 for square)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// BoardSide represents a physical straight edge of the board outline
|
||||||
|
type BoardSide struct {
|
||||||
|
Num int `json:"num"`
|
||||||
|
Label string `json:"label"`
|
||||||
|
Length float64 `json:"length"`
|
||||||
|
StartX float64 `json:"startX"`
|
||||||
|
StartY float64 `json:"startY"`
|
||||||
|
EndX float64 `json:"endX"`
|
||||||
|
EndY float64 `json:"endY"`
|
||||||
|
Angle float64 `json:"angle"` // Angle in radians of the normal vector pushing OUT of the board
|
||||||
|
}
|
||||||
|
|
||||||
|
func perpendicularDistance(pt, lineStart, lineEnd [2]float64) float64 {
|
||||||
|
dx := lineEnd[0] - lineStart[0]
|
||||||
|
dy := lineEnd[1] - lineStart[1]
|
||||||
|
|
||||||
|
// Normalize line vector
|
||||||
|
mag := math.Sqrt(dx*dx + dy*dy)
|
||||||
|
if mag == 0 {
|
||||||
|
return math.Sqrt((pt[0]-lineStart[0])*(pt[0]-lineStart[0]) + (pt[1]-lineStart[1])*(pt[1]-lineStart[1]))
|
||||||
|
}
|
||||||
|
dx /= mag
|
||||||
|
dy /= mag
|
||||||
|
|
||||||
|
// Vector from lineStart to pt
|
||||||
|
px := pt[0] - lineStart[0]
|
||||||
|
py := pt[1] - lineStart[1]
|
||||||
|
|
||||||
|
// Cross product gives perpendicular distance
|
||||||
|
return math.Abs(px*dy - py*dx)
|
||||||
|
}
|
||||||
|
|
||||||
|
func simplifyPolygonRDP(points [][2]float64, epsilon float64) [][2]float64 {
|
||||||
|
if len(points) < 3 {
|
||||||
|
return points
|
||||||
|
}
|
||||||
|
|
||||||
|
dmax := 0.0
|
||||||
|
index := 0
|
||||||
|
end := len(points) - 1
|
||||||
|
|
||||||
|
for i := 1; i < end; i++ {
|
||||||
|
d := perpendicularDistance(points[i], points[0], points[end])
|
||||||
|
if d > dmax {
|
||||||
|
index = i
|
||||||
|
dmax = d
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if dmax > epsilon {
|
||||||
|
recResults1 := simplifyPolygonRDP(points[:index+1], epsilon)
|
||||||
|
recResults2 := simplifyPolygonRDP(points[index:], epsilon)
|
||||||
|
|
||||||
|
result := append([][2]float64{}, recResults1[:len(recResults1)-1]...)
|
||||||
|
result = append(result, recResults2...)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
return [][2]float64{points[0], points[end]}
|
||||||
|
}
|
||||||
|
|
||||||
|
func ExtractBoardSides(poly [][2]float64) []BoardSide {
|
||||||
|
if len(poly) < 3 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine "center" of polygon to find outward normals
|
||||||
|
cx, cy := 0.0, 0.0
|
||||||
|
for _, p := range poly {
|
||||||
|
cx += p[0]
|
||||||
|
cy += p[1]
|
||||||
|
}
|
||||||
|
cx /= float64(len(poly))
|
||||||
|
cy /= float64(len(poly))
|
||||||
|
|
||||||
|
// Ensure the polygon is closed for RDP, if it isn't already
|
||||||
|
if poly[0][0] != poly[len(poly)-1][0] || poly[0][1] != poly[len(poly)-1][1] {
|
||||||
|
poly = append(poly, poly[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
simplified := simplifyPolygonRDP(poly, 0.2) // 0.2mm tolerance
|
||||||
|
fmt.Printf("[DEBUG] ExtractBoardSides: poly points = %d, simplified points = %d\n", len(poly), len(simplified))
|
||||||
|
|
||||||
|
var sides []BoardSide
|
||||||
|
sideNum := 1
|
||||||
|
|
||||||
|
for i := 0; i < len(simplified)-1; i++ {
|
||||||
|
p1 := simplified[i]
|
||||||
|
p2 := simplified[i+1]
|
||||||
|
|
||||||
|
dx := p2[0] - p1[0]
|
||||||
|
dy := p2[1] - p1[1]
|
||||||
|
length := math.Sqrt(dx*dx + dy*dy)
|
||||||
|
|
||||||
|
// Only keep substantial straight edges (e.g. > 4mm)
|
||||||
|
if length > 4.0 {
|
||||||
|
// Calculate outward normal angle
|
||||||
|
// The segment path vector is (dx, dy). Normal is either (-dy, dx) or (dy, -dx)
|
||||||
|
nx := dy
|
||||||
|
ny := -dx
|
||||||
|
// Dot product with center->midpoint to check if it points out
|
||||||
|
midX := (p1[0] + p2[0]) / 2.0
|
||||||
|
midY := (p1[1] + p2[1]) / 2.0
|
||||||
|
vx := midX - cx
|
||||||
|
vy := midY - cy
|
||||||
|
if nx*vx+ny*vy < 0 {
|
||||||
|
nx = -nx
|
||||||
|
ny = -ny
|
||||||
|
}
|
||||||
|
angle := math.Atan2(ny, nx)
|
||||||
|
|
||||||
|
sides = append(sides, BoardSide{
|
||||||
|
Num: sideNum,
|
||||||
|
Label: fmt.Sprintf("Side %d (%.1fmm)", sideNum, length),
|
||||||
|
Length: length,
|
||||||
|
StartX: p1[0],
|
||||||
|
StartY: p1[1],
|
||||||
|
EndX: p2[0],
|
||||||
|
EndY: p2[1],
|
||||||
|
Angle: angle,
|
||||||
|
})
|
||||||
|
sideNum++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return sides
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExtractBoardSidesFromMask traces the outer boundary of a boolean mask
|
||||||
|
// and simplifies it into BoardSides. This perfectly matches the 3D generation.
|
||||||
|
func ExtractBoardSidesFromMask(mask []bool, imgW, imgH int, pixelToMM float64, bounds *Bounds) []BoardSide {
|
||||||
|
// Find top-leftmost pixel of mask
|
||||||
|
startX, startY := -1, -1
|
||||||
|
outer:
|
||||||
|
for y := 0; y < imgH; y++ {
|
||||||
|
for x := 0; x < imgW; x++ {
|
||||||
|
if mask[y*imgW+x] {
|
||||||
|
startX, startY = x, y
|
||||||
|
break outer
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if startX == -1 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Moore-neighbor boundary tracing
|
||||||
|
var boundary [][2]int
|
||||||
|
dirs := [8][2]int{{1, 0}, {1, 1}, {0, 1}, {-1, 1}, {-1, 0}, {-1, -1}, {0, -1}, {1, -1}}
|
||||||
|
|
||||||
|
curX, curY := startX, startY
|
||||||
|
boundary = append(boundary, [2]int{curX, curY})
|
||||||
|
|
||||||
|
// Initial previous neighbor direction (up/west of top-left is empty)
|
||||||
|
pDir := 6
|
||||||
|
|
||||||
|
for {
|
||||||
|
found := false
|
||||||
|
for i := 0; i < 8; i++ {
|
||||||
|
// Scan clockwise starting from dir after the previous background pixel
|
||||||
|
testDir := (pDir + 1 + i) % 8
|
||||||
|
nx, ny := curX+dirs[testDir][0], curY+dirs[testDir][1]
|
||||||
|
if nx >= 0 && nx < imgW && ny >= 0 && ny < imgH && mask[ny*imgW+nx] {
|
||||||
|
curX, curY = nx, ny
|
||||||
|
boundary = append(boundary, [2]int{curX, curY})
|
||||||
|
// The new background pixel is opposite to the direction we found the solid one
|
||||||
|
pDir = (testDir + 4) % 8
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
break // Isolated pixel
|
||||||
|
}
|
||||||
|
// Stop when we return to the start and moved in the same direction
|
||||||
|
if curX == startX && curY == startY {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
// Failsafe for complex shapes
|
||||||
|
if len(boundary) > imgW*imgH {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert boundary pixels to Gerber mm coordinates
|
||||||
|
var poly [][2]float64
|
||||||
|
for _, p := range boundary {
|
||||||
|
px := float64(p[0])*pixelToMM + bounds.MinX
|
||||||
|
// Image Y=0 is MaxY in Gerber space
|
||||||
|
py := bounds.MaxY - float64(p[1])*pixelToMM
|
||||||
|
poly = append(poly, [2]float64{px, py})
|
||||||
|
}
|
||||||
|
|
||||||
|
sides := ExtractBoardSides(poly)
|
||||||
|
fmt.Printf("[DEBUG] ExtractBoardSidesFromMask: mask size=%dx%d, boundary pixels=%d, sides extracted=%d\n", imgW, imgH, len(boundary), len(sides))
|
||||||
|
if len(sides) == 0 && len(poly) > 0 {
|
||||||
|
fmt.Printf("[DEBUG] poly[0]=%v, poly[n/2]=%v, poly[last]=%v\n", poly[0], poly[len(poly)/2], poly[len(poly)-1])
|
||||||
|
}
|
||||||
|
return sides
|
||||||
|
}
|
||||||
|
|
||||||
// GenerateEnclosure creates enclosure + tray meshes from a board outline image and drill holes.
|
// GenerateEnclosure creates enclosure + tray meshes from a board outline image and drill holes.
|
||||||
// The enclosure walls conform to the actual board outline shape.
|
// The enclosure walls conform to the actual board outline shape.
|
||||||
// courtyardImg is optional — if provided, component courtyard regions are cut from the lid (flood-filled).
|
// courtyardImg is optional — if provided, component courtyard regions are cut from the lid (flood-filled).
|
||||||
// soldermaskImg is optional — if provided, soldermask pad openings are also cut from the lid.
|
// soldermaskImg is optional — if provided, soldermask pad openings are also cut from the lid.
|
||||||
func GenerateEnclosure(outlineImg image.Image, drillHoles []DrillHole, cfg EnclosureConfig, courtyardImg image.Image, soldermaskImg image.Image, sideCutouts []SideCutout) *EnclosureResult {
|
func GenerateEnclosure(outlineImg image.Image, drillHoles []DrillHole, cfg EnclosureConfig, courtyardImg image.Image, soldermaskImg image.Image, sideCutouts []SideCutout, boardSides []BoardSide) *EnclosureResult {
|
||||||
pixelToMM := 25.4 / cfg.DPI
|
pixelToMM := 25.4 / cfg.DPI
|
||||||
bounds := outlineImg.Bounds()
|
bounds := outlineImg.Bounds()
|
||||||
imgW := bounds.Max.X
|
imgW := bounds.Max.X
|
||||||
|
|
@ -228,45 +428,34 @@ func GenerateEnclosure(outlineImg image.Image, drillHoles []DrillHole, cfg Enclo
|
||||||
}
|
}
|
||||||
|
|
||||||
// Determine which side this wall pixel belongs to
|
// Determine which side this wall pixel belongs to
|
||||||
// Find distance to each side of the board bounding box
|
bx := float64(x)*pixelToMM + cfg.OutlineBounds.MinX
|
||||||
dTop := math.Abs(float64(y) - float64(minBY))
|
by := cfg.OutlineBounds.MaxY - float64(y)*pixelToMM
|
||||||
dBottom := math.Abs(float64(y) - float64(maxBY))
|
|
||||||
dLeft := math.Abs(float64(x) - float64(minBX))
|
|
||||||
dRight := math.Abs(float64(x) - float64(maxBX))
|
|
||||||
|
|
||||||
sideNum := 0
|
sideNum := -1
|
||||||
minDist := dTop
|
minDist := math.MaxFloat64
|
||||||
sideNum = 1 // top
|
|
||||||
if dRight < minDist {
|
|
||||||
minDist = dRight
|
|
||||||
sideNum = 2 // right
|
|
||||||
}
|
|
||||||
if dBottom < minDist {
|
|
||||||
minDist = dBottom
|
|
||||||
sideNum = 3 // bottom
|
|
||||||
}
|
|
||||||
if dLeft < minDist {
|
|
||||||
sideNum = 4 // left
|
|
||||||
}
|
|
||||||
|
|
||||||
// Position along the side in mm
|
|
||||||
var posAlongSide float64
|
var posAlongSide float64
|
||||||
var zPos float64
|
|
||||||
switch sideNum {
|
for _, bs := range boardSides {
|
||||||
case 1: // top — position = X distance from left board edge
|
dx := bs.EndX - bs.StartX
|
||||||
posAlongSide = float64(x-minBX) * pixelToMM
|
dy := bs.EndY - bs.StartY
|
||||||
zPos = 0 // all Z heights for walls
|
lenSq := dx*dx + dy*dy
|
||||||
case 2: // right — position = Y distance from top board edge
|
if lenSq == 0 {
|
||||||
posAlongSide = float64(y-minBY) * pixelToMM
|
continue
|
||||||
zPos = 0
|
}
|
||||||
case 3: // bottom — position = X distance from left board edge
|
|
||||||
posAlongSide = float64(x-minBX) * pixelToMM
|
t := ((bx-bs.StartX)*dx + (by-bs.StartY)*dy) / lenSq
|
||||||
zPos = 0
|
tClamp := math.Max(0, math.Min(1, t))
|
||||||
case 4: // left — position = Y distance from top board edge
|
|
||||||
posAlongSide = float64(y-minBY) * pixelToMM
|
projX := bs.StartX + tClamp*dx
|
||||||
zPos = 0
|
projY := bs.StartY + tClamp*dy
|
||||||
|
|
||||||
|
dist := math.Sqrt((bx-projX)*(bx-projX) + (by-projY)*(by-projY))
|
||||||
|
if dist < minDist {
|
||||||
|
minDist = dist
|
||||||
|
sideNum = bs.Num
|
||||||
|
posAlongSide = t * bs.Length
|
||||||
|
}
|
||||||
}
|
}
|
||||||
_ = zPos
|
|
||||||
|
|
||||||
// Check all cutouts for this side
|
// Check all cutouts for this side
|
||||||
for _, c := range sideCutouts {
|
for _, c := range sideCutouts {
|
||||||
|
|
@ -387,26 +576,30 @@ func GenerateEnclosure(outlineImg image.Image, drillHoles []DrillHole, cfg Enclo
|
||||||
_ = midIdx
|
_ = midIdx
|
||||||
|
|
||||||
// Find the dominant side and cutout for this run
|
// Find the dominant side and cutout for this run
|
||||||
dTop := math.Abs(float64(y) - float64(minBY))
|
bx := float64(midX)*pixelToMM + cfg.OutlineBounds.MinX
|
||||||
dBottom := math.Abs(float64(y) - float64(maxBY))
|
by := cfg.OutlineBounds.MaxY - float64(y)*pixelToMM
|
||||||
dLeft := math.Abs(float64(midX) - float64(minBX))
|
|
||||||
dRight := math.Abs(float64(midX) - float64(maxBX))
|
|
||||||
|
|
||||||
sideNum := 1
|
sideNum := -1
|
||||||
minDist := dTop
|
minDist := math.MaxFloat64
|
||||||
if dRight < minDist {
|
for _, bs := range boardSides {
|
||||||
minDist = dRight
|
dx := bs.EndX - bs.StartX
|
||||||
sideNum = 2
|
dy := bs.EndY - bs.StartY
|
||||||
}
|
lenSq := dx*dx + dy*dy
|
||||||
if dBottom < minDist {
|
if lenSq == 0 {
|
||||||
minDist = dBottom
|
continue
|
||||||
sideNum = 3
|
}
|
||||||
}
|
t := ((bx-bs.StartX)*dx + (by-bs.StartY)*dy) / lenSq
|
||||||
if dLeft < minDist {
|
tClamp := math.Max(0, math.Min(1, t))
|
||||||
sideNum = 4
|
projX := bs.StartX + tClamp*dx
|
||||||
|
projY := bs.StartY + tClamp*dy
|
||||||
|
dist := math.Sqrt((bx-projX)*(bx-projX) + (by-projY)*(by-projY))
|
||||||
|
if dist < minDist {
|
||||||
|
minDist = dist
|
||||||
|
sideNum = bs.Num
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
bx := float64(runStart) * pixelToMM
|
bx2 := float64(runStart) * pixelToMM
|
||||||
by2 := float64(y) * pixelToMM
|
by2 := float64(y) * pixelToMM
|
||||||
bw := float64(x-runStart) * pixelToMM
|
bw := float64(x-runStart) * pixelToMM
|
||||||
bh := pixelToMM
|
bh := pixelToMM
|
||||||
|
|
@ -418,12 +611,12 @@ func GenerateEnclosure(outlineImg image.Image, drillHoles []DrillHole, cfg Enclo
|
||||||
}
|
}
|
||||||
// Wall below cutout: from 0 to cutout.Y
|
// Wall below cutout: from 0 to cutout.Y
|
||||||
if c.Y > 0.1 {
|
if c.Y > 0.1 {
|
||||||
addBoxAtZ(&cutoutEncTris, bx, by2, 0, bw, bh, c.Y)
|
addBoxAtZ(&cutoutEncTris, bx2, by2, 0, bw, bh, c.Y)
|
||||||
}
|
}
|
||||||
// Wall above cutout: from cutout.Y+cutout.H to totalH
|
// Wall above cutout: from cutout.Y+cutout.H to totalH
|
||||||
cutTop := c.Y + c.Height
|
cutTop := c.Y + c.Height
|
||||||
if cutTop < totalH-0.1 {
|
if cutTop < totalH-0.1 {
|
||||||
addBoxAtZ(&cutoutEncTris, bx, by2, cutTop, bw, bh, totalH-cutTop)
|
addBoxAtZ(&cutoutEncTris, bx2, by2, cutTop, bw, bh, totalH-cutTop)
|
||||||
}
|
}
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
|
||||||
83
gerber.go
83
gerber.go
|
|
@ -43,14 +43,16 @@ type GerberState struct {
|
||||||
FormatX, FormatY struct {
|
FormatX, FormatY struct {
|
||||||
Integer, Decimal int
|
Integer, Decimal int
|
||||||
}
|
}
|
||||||
Units string // "MM" or "IN"
|
Units string // "MM" or "IN"
|
||||||
|
CurrentFootprint string // Stored from %TO.C,Footprint,...*%
|
||||||
}
|
}
|
||||||
|
|
||||||
type GerberCommand struct {
|
type GerberCommand struct {
|
||||||
Type string // "D01", "D02", "D03", "AD", "FS", etc.
|
Type string // "D01", "D02", "D03", "AD", "FS", etc.
|
||||||
X, Y *float64
|
X, Y *float64
|
||||||
I, J *float64
|
I, J *float64
|
||||||
D *int
|
D *int
|
||||||
|
Footprint string
|
||||||
}
|
}
|
||||||
|
|
||||||
type GerberFile struct {
|
type GerberFile struct {
|
||||||
|
|
@ -58,6 +60,63 @@ type GerberFile struct {
|
||||||
State GerberState
|
State GerberState
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Footprint represents a component bounding area deduced from Gerber X2 attributes
|
||||||
|
type Footprint struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
MinX float64 `json:"minX"`
|
||||||
|
MinY float64 `json:"minY"`
|
||||||
|
MaxX float64 `json:"maxX"`
|
||||||
|
MaxY float64 `json:"maxY"`
|
||||||
|
CenterX float64 `json:"centerX"`
|
||||||
|
CenterY float64 `json:"centerY"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func ExtractFootprints(gf *GerberFile) []Footprint {
|
||||||
|
fps := make(map[string]*Footprint)
|
||||||
|
|
||||||
|
for _, cmd := range gf.Commands {
|
||||||
|
if cmd.Footprint == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if cmd.X == nil || cmd.Y == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
fp, exists := fps[cmd.Footprint]
|
||||||
|
if !exists {
|
||||||
|
fp = &Footprint{
|
||||||
|
Name: cmd.Footprint,
|
||||||
|
MinX: *cmd.X,
|
||||||
|
MaxX: *cmd.X,
|
||||||
|
MinY: *cmd.Y,
|
||||||
|
MaxY: *cmd.Y,
|
||||||
|
}
|
||||||
|
fps[cmd.Footprint] = fp
|
||||||
|
} else {
|
||||||
|
if *cmd.X < fp.MinX {
|
||||||
|
fp.MinX = *cmd.X
|
||||||
|
}
|
||||||
|
if *cmd.X > fp.MaxX {
|
||||||
|
fp.MaxX = *cmd.X
|
||||||
|
}
|
||||||
|
if *cmd.Y < fp.MinY {
|
||||||
|
fp.MinY = *cmd.Y
|
||||||
|
}
|
||||||
|
if *cmd.Y > fp.MaxY {
|
||||||
|
fp.MaxY = *cmd.Y
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var result []Footprint
|
||||||
|
for _, fp := range fps {
|
||||||
|
fp.CenterX = (fp.MinX + fp.MaxX) / 2.0
|
||||||
|
fp.CenterY = (fp.MinY + fp.MaxY) / 2.0
|
||||||
|
result = append(result, *fp)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
func NewGerberFile() *GerberFile {
|
func NewGerberFile() *GerberFile {
|
||||||
return &GerberFile{
|
return &GerberFile{
|
||||||
State: GerberState{
|
State: GerberState{
|
||||||
|
|
@ -174,6 +233,18 @@ func ParseGerber(filename string) (*GerberFile, error) {
|
||||||
} else {
|
} else {
|
||||||
gf.State.Units = "MM"
|
gf.State.Units = "MM"
|
||||||
}
|
}
|
||||||
|
} else if strings.HasPrefix(line, "%TO") {
|
||||||
|
parts := strings.Split(line, ",")
|
||||||
|
if len(parts) >= 2 && strings.HasPrefix(parts[0], "%TO.C") {
|
||||||
|
refDes := strings.TrimSuffix(parts[1], "*%")
|
||||||
|
if refDes != "" {
|
||||||
|
gf.State.CurrentFootprint = refDes
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if strings.HasPrefix(line, "%TD") {
|
||||||
|
if strings.Contains(line, "%TD*%") || strings.Contains(line, "%TD,C*%") || strings.Contains(line, "%TD,Footprint*%") {
|
||||||
|
gf.State.CurrentFootprint = ""
|
||||||
|
}
|
||||||
}
|
}
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
@ -222,7 +293,7 @@ func ParseGerber(filename string) (*GerberFile, error) {
|
||||||
// X...Y...D01*
|
// X...Y...D01*
|
||||||
matches := reCoord.FindAllStringSubmatch(part, -1)
|
matches := reCoord.FindAllStringSubmatch(part, -1)
|
||||||
if len(matches) > 0 {
|
if len(matches) > 0 {
|
||||||
cmd := GerberCommand{Type: "MOVE"}
|
cmd := GerberCommand{Type: "MOVE", Footprint: gf.State.CurrentFootprint}
|
||||||
for _, m := range matches {
|
for _, m := range matches {
|
||||||
valStr := m[2]
|
valStr := m[2]
|
||||||
|
|
||||||
|
|
|
||||||
113
main.go
113
main.go
|
|
@ -11,6 +11,8 @@ import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"html/template"
|
"html/template"
|
||||||
"image"
|
"image"
|
||||||
|
"image/color"
|
||||||
|
"image/draw"
|
||||||
"image/png"
|
"image/png"
|
||||||
"io"
|
"io"
|
||||||
"log"
|
"log"
|
||||||
|
|
@ -1001,6 +1003,7 @@ func enclosureUploadHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
MinBX: float64(minBX),
|
MinBX: float64(minBX),
|
||||||
MaxBX: float64(maxBX),
|
MaxBX: float64(maxBX),
|
||||||
BoardCenterY: boardCenterY,
|
BoardCenterY: boardCenterY,
|
||||||
|
Sides: ExtractBoardSidesFromMask(boardMask, imgW, imgH, 25.4/ecfg.DPI, &outlineBounds),
|
||||||
}
|
}
|
||||||
sessionsMu.Lock()
|
sessionsMu.Lock()
|
||||||
sessions[uuid] = session
|
sessions[uuid] = session
|
||||||
|
|
@ -1011,6 +1014,93 @@ func enclosureUploadHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func footprintUploadHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
err := r.ParseMultipartForm(50 << 20) // 50 MB
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Error parsing form", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
sessionID := r.FormValue("sessionId")
|
||||||
|
if sessionID == "" {
|
||||||
|
http.Error(w, "Missing sessionId", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
sessionsMu.Lock()
|
||||||
|
session, ok := sessions[sessionID]
|
||||||
|
sessionsMu.Unlock()
|
||||||
|
if !ok {
|
||||||
|
http.Error(w, "Invalid session", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
files := r.MultipartForm.File["gerbers"]
|
||||||
|
var allFootprints []Footprint
|
||||||
|
var fabGfList []*GerberFile
|
||||||
|
|
||||||
|
for _, fileHeader := range files {
|
||||||
|
f, err := fileHeader.Open()
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
b := make([]byte, 8)
|
||||||
|
rand.Read(b)
|
||||||
|
tempPath := filepath.Join("temp", fmt.Sprintf("%x_%s", b, fileHeader.Filename))
|
||||||
|
out, err := os.Create(tempPath)
|
||||||
|
if err == nil {
|
||||||
|
io.Copy(out, f)
|
||||||
|
out.Close()
|
||||||
|
gf, err := ParseGerber(tempPath)
|
||||||
|
if err == nil {
|
||||||
|
allFootprints = append(allFootprints, ExtractFootprints(gf)...)
|
||||||
|
fabGfList = append(fabGfList, gf)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
f.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Composite Fab images into one transparent overlay
|
||||||
|
if len(fabGfList) > 0 {
|
||||||
|
bounds := session.OutlineBounds
|
||||||
|
imgW := int((bounds.MaxX - bounds.MinX) * session.Config.DPI / 25.4)
|
||||||
|
imgH := int((bounds.MaxY - bounds.MinY) * session.Config.DPI / 25.4)
|
||||||
|
if imgW > 0 && imgH > 0 {
|
||||||
|
composite := image.NewRGBA(image.Rect(0, 0, imgW, imgH))
|
||||||
|
// Initialize with pure transparency
|
||||||
|
draw.Draw(composite, composite.Bounds(), &image.Uniform{color.Transparent}, image.Point{}, draw.Src)
|
||||||
|
|
||||||
|
for _, gf := range fabGfList {
|
||||||
|
layerImg := gf.Render(session.Config.DPI, &bounds)
|
||||||
|
if rgba, ok := layerImg.(*image.RGBA); ok {
|
||||||
|
for y := 0; y < imgH; y++ {
|
||||||
|
for x := 0; x < imgW; x++ {
|
||||||
|
// Gerber render background is Black. White is drawn pixels.
|
||||||
|
if r, _, _, _ := rgba.At(x, y).RGBA(); r > 0x7FFF {
|
||||||
|
// Set as cyan overlay for visibility
|
||||||
|
composite.Set(x, y, color.RGBA{0, 255, 255, 180})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sessionsMu.Lock()
|
||||||
|
session.FabImg = composite
|
||||||
|
sessionsMu.Unlock()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return all parsed footprints for visual selection
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(allFootprints)
|
||||||
|
}
|
||||||
|
|
||||||
func renderResult(w http.ResponseWriter, message string, files []string, backURL string, zipFile string) {
|
func renderResult(w http.ResponseWriter, message string, files []string, backURL string, zipFile string) {
|
||||||
tmpl, err := template.ParseFS(staticFiles, "static/result.html")
|
tmpl, err := template.ParseFS(staticFiles, "static/result.html")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -1043,6 +1133,8 @@ type EnclosureSession struct {
|
||||||
MinBX float64
|
MinBX float64
|
||||||
MaxBX float64
|
MaxBX float64
|
||||||
BoardCenterY float64
|
BoardCenterY float64
|
||||||
|
Sides []BoardSide
|
||||||
|
FabImg image.Image
|
||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
|
|
@ -1061,13 +1153,21 @@ func previewHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
}
|
}
|
||||||
|
|
||||||
boardInfo := struct {
|
boardInfo := struct {
|
||||||
BoardW float64 `json:"boardW"`
|
BoardW float64 `json:"boardW"`
|
||||||
BoardH float64 `json:"boardH"`
|
BoardH float64 `json:"boardH"`
|
||||||
TotalH float64 `json:"totalH"`
|
TotalH float64 `json:"totalH"`
|
||||||
|
Sides []BoardSide `json:"sides"`
|
||||||
|
MinX float64 `json:"minX"`
|
||||||
|
MaxY float64 `json:"maxY"`
|
||||||
|
DPI float64 `json:"dpi"`
|
||||||
}{
|
}{
|
||||||
BoardW: session.BoardW,
|
BoardW: session.BoardW,
|
||||||
BoardH: session.BoardH,
|
BoardH: session.BoardH,
|
||||||
TotalH: session.TotalH,
|
TotalH: session.TotalH,
|
||||||
|
Sides: session.Sides,
|
||||||
|
MinX: session.OutlineBounds.MinX,
|
||||||
|
MaxY: session.OutlineBounds.MaxY,
|
||||||
|
DPI: session.Config.DPI,
|
||||||
}
|
}
|
||||||
boardJSON, _ := json.Marshal(boardInfo)
|
boardJSON, _ := json.Marshal(boardInfo)
|
||||||
|
|
||||||
|
|
@ -1172,7 +1272,7 @@ func generateEnclosureHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
// Process STL
|
// Process STL
|
||||||
if wantsType("stl") {
|
if wantsType("stl") {
|
||||||
fmt.Println("Generating STLs...")
|
fmt.Println("Generating STLs...")
|
||||||
result := GenerateEnclosure(session.OutlineImg, session.DrillHoles, session.Config, session.CourtyardImg, session.SoldermaskImg, sideCutouts)
|
result := GenerateEnclosure(session.OutlineImg, session.DrillHoles, session.Config, session.CourtyardImg, session.SoldermaskImg, sideCutouts, session.Sides)
|
||||||
encPath := filepath.Join("temp", id+"_enclosure.stl")
|
encPath := filepath.Join("temp", id+"_enclosure.stl")
|
||||||
trayPath := filepath.Join("temp", id+"_tray.stl")
|
trayPath := filepath.Join("temp", id+"_tray.stl")
|
||||||
WriteSTL(encPath, result.EnclosureTriangles)
|
WriteSTL(encPath, result.EnclosureTriangles)
|
||||||
|
|
@ -1186,8 +1286,8 @@ func generateEnclosureHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
scadPathEnc := filepath.Join("temp", id+"_enclosure.scad")
|
scadPathEnc := filepath.Join("temp", id+"_enclosure.scad")
|
||||||
scadPathTray := filepath.Join("temp", id+"_tray.scad")
|
scadPathTray := filepath.Join("temp", id+"_tray.scad")
|
||||||
outlinePoly := ExtractPolygonFromGerber(session.OutlineGf)
|
outlinePoly := ExtractPolygonFromGerber(session.OutlineGf)
|
||||||
WriteNativeSCAD(scadPathEnc, false, outlinePoly, session.Config, session.DrillHoles, sideCutouts, session.MinBX, session.MaxBX, session.BoardCenterY)
|
WriteNativeSCAD(scadPathEnc, false, outlinePoly, session.Config, session.DrillHoles, sideCutouts, session.Sides, session.MinBX, session.MaxBX, session.BoardCenterY)
|
||||||
WriteNativeSCAD(scadPathTray, true, outlinePoly, session.Config, session.DrillHoles, sideCutouts, session.MinBX, session.MaxBX, session.BoardCenterY)
|
WriteNativeSCAD(scadPathTray, true, outlinePoly, session.Config, session.DrillHoles, sideCutouts, session.Sides, session.MinBX, session.MaxBX, session.BoardCenterY)
|
||||||
generatedFiles = append(generatedFiles, filepath.Base(scadPathEnc), filepath.Base(scadPathTray))
|
generatedFiles = append(generatedFiles, filepath.Base(scadPathEnc), filepath.Base(scadPathTray))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1264,6 +1364,7 @@ func runServer(port string) {
|
||||||
http.HandleFunc("/", indexHandler)
|
http.HandleFunc("/", indexHandler)
|
||||||
http.HandleFunc("/upload", uploadHandler)
|
http.HandleFunc("/upload", uploadHandler)
|
||||||
http.HandleFunc("/upload-enclosure", enclosureUploadHandler)
|
http.HandleFunc("/upload-enclosure", enclosureUploadHandler)
|
||||||
|
http.HandleFunc("/upload-footprints", footprintUploadHandler)
|
||||||
http.HandleFunc("/preview", previewHandler)
|
http.HandleFunc("/preview", previewHandler)
|
||||||
http.HandleFunc("/preview-image/", previewImageHandler)
|
http.HandleFunc("/preview-image/", previewImageHandler)
|
||||||
http.HandleFunc("/generate-enclosure", generateEnclosureHandler)
|
http.HandleFunc("/generate-enclosure", generateEnclosureHandler)
|
||||||
|
|
|
||||||
BIN
pcb-to-stencil
BIN
pcb-to-stencil
Binary file not shown.
|
|
@ -0,0 +1,4 @@
|
||||||
|
#!/bin/bash
|
||||||
|
pkill -f ./bin/pcb-to-stencil\ -server
|
||||||
|
go clean && go build -o bin/pcb-to-stencil .
|
||||||
|
./bin/pcb-to-stencil -server
|
||||||
170
scad.go
170
scad.go
|
|
@ -39,7 +39,8 @@ func WriteSCAD(filename string, triangles [][3]Point) error {
|
||||||
|
|
||||||
// ExtractPolygonFromGerber traces the Edge.Cuts gerber to form a continuous 2D polygon
|
// ExtractPolygonFromGerber traces the Edge.Cuts gerber to form a continuous 2D polygon
|
||||||
func ExtractPolygonFromGerber(gf *GerberFile) [][2]float64 {
|
func ExtractPolygonFromGerber(gf *GerberFile) [][2]float64 {
|
||||||
var points [][2]float64
|
var strokes [][][2]float64
|
||||||
|
var currentStroke [][2]float64
|
||||||
curX, curY := 0.0, 0.0
|
curX, curY := 0.0, 0.0
|
||||||
interpolationMode := "G01"
|
interpolationMode := "G01"
|
||||||
|
|
||||||
|
|
@ -57,13 +58,18 @@ func ExtractPolygonFromGerber(gf *GerberFile) [][2]float64 {
|
||||||
curY = *cmd.Y
|
curY = *cmd.Y
|
||||||
}
|
}
|
||||||
|
|
||||||
if cmd.Type == "DRAW" {
|
if cmd.Type == "MOVE" {
|
||||||
if len(points) == 0 {
|
if len(currentStroke) > 0 {
|
||||||
points = append(points, [2]float64{prevX, prevY})
|
strokes = append(strokes, currentStroke)
|
||||||
|
currentStroke = nil
|
||||||
|
}
|
||||||
|
} else if cmd.Type == "DRAW" {
|
||||||
|
if len(currentStroke) == 0 {
|
||||||
|
currentStroke = append(currentStroke, [2]float64{prevX, prevY})
|
||||||
}
|
}
|
||||||
|
|
||||||
if interpolationMode == "G01" {
|
if interpolationMode == "G01" {
|
||||||
points = append(points, [2]float64{curX, curY})
|
currentStroke = append(currentStroke, [2]float64{curX, curY})
|
||||||
} else {
|
} else {
|
||||||
iVal, jVal := 0.0, 0.0
|
iVal, jVal := 0.0, 0.0
|
||||||
if cmd.I != nil {
|
if cmd.I != nil {
|
||||||
|
|
@ -94,16 +100,117 @@ func ExtractPolygonFromGerber(gf *GerberFile) [][2]float64 {
|
||||||
t := float64(s) / float64(steps)
|
t := float64(s) / float64(steps)
|
||||||
a := startAngle + t*(endAngle-startAngle)
|
a := startAngle + t*(endAngle-startAngle)
|
||||||
ax, ay := centerX+radius*math.Cos(a), centerY+radius*math.Sin(a)
|
ax, ay := centerX+radius*math.Cos(a), centerY+radius*math.Sin(a)
|
||||||
points = append(points, [2]float64{ax, ay})
|
currentStroke = append(currentStroke, [2]float64{ax, ay})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return points
|
if len(currentStroke) > 0 {
|
||||||
|
strokes = append(strokes, currentStroke)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(strokes) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stitch strokes into closed loops
|
||||||
|
var loops [][][2]float64
|
||||||
|
used := make([]bool, len(strokes))
|
||||||
|
epsilon := 0.05 // 0.05mm tolerance
|
||||||
|
|
||||||
|
for startIdx := 0; startIdx < len(strokes); startIdx++ {
|
||||||
|
if used[startIdx] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
used[startIdx] = true
|
||||||
|
path := append([][2]float64{}, strokes[startIdx]...)
|
||||||
|
|
||||||
|
for {
|
||||||
|
endPt := path[len(path)-1]
|
||||||
|
startPt := path[0]
|
||||||
|
found := false
|
||||||
|
|
||||||
|
for j := 0; j < len(strokes); j++ {
|
||||||
|
if used[j] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
s := strokes[j]
|
||||||
|
sStart := s[0]
|
||||||
|
sEnd := s[len(s)-1]
|
||||||
|
|
||||||
|
dist := func(a, b [2]float64) float64 {
|
||||||
|
dx, dy := a[0]-b[0], a[1]-b[1]
|
||||||
|
return math.Sqrt(dx*dx + dy*dy)
|
||||||
|
}
|
||||||
|
|
||||||
|
if dist(endPt, sStart) < epsilon {
|
||||||
|
path = append(path, s[1:]...)
|
||||||
|
used[j] = true
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
} else if dist(endPt, sEnd) < epsilon {
|
||||||
|
for k := len(s) - 2; k >= 0; k-- {
|
||||||
|
path = append(path, s[k])
|
||||||
|
}
|
||||||
|
used[j] = true
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
} else if dist(startPt, sEnd) < epsilon {
|
||||||
|
// prepend
|
||||||
|
newPath := append([][2]float64{}, s[:len(s)-1]...)
|
||||||
|
path = append(newPath, path...)
|
||||||
|
used[j] = true
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
} else if dist(startPt, sStart) < epsilon {
|
||||||
|
// reversed prepend
|
||||||
|
var newPath [][2]float64
|
||||||
|
for k := len(s) - 1; k > 0; k-- {
|
||||||
|
newPath = append(newPath, s[k])
|
||||||
|
}
|
||||||
|
path = append(newPath, path...)
|
||||||
|
used[j] = true
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
loops = append(loops, path)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the longest loop (the main board outline)
|
||||||
|
var bestLoop [][2]float64
|
||||||
|
maxLen := 0.0
|
||||||
|
for _, l := range loops {
|
||||||
|
loopLen := 0.0
|
||||||
|
for i := 0; i < len(l)-1; i++ {
|
||||||
|
dx := l[i+1][0] - l[i][0]
|
||||||
|
dy := l[i+1][1] - l[i][1]
|
||||||
|
loopLen += math.Sqrt(dx*dx + dy*dy)
|
||||||
|
}
|
||||||
|
if loopLen > maxLen {
|
||||||
|
maxLen = loopLen
|
||||||
|
bestLoop = l
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Always ensure path is closed
|
||||||
|
if len(bestLoop) > 2 {
|
||||||
|
first := bestLoop[0]
|
||||||
|
last := bestLoop[len(bestLoop)-1]
|
||||||
|
if math.Abs(first[0]-last[0]) > epsilon || math.Abs(first[1]-last[1]) > epsilon {
|
||||||
|
bestLoop = append(bestLoop, first)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return bestLoop
|
||||||
}
|
}
|
||||||
|
|
||||||
// WriteNativeSCAD generates native parametrically defined CSG OpenSCAD code
|
// WriteNativeSCAD generates native parametrically defined CSG OpenSCAD code
|
||||||
func WriteNativeSCAD(filename string, isTray bool, outlineVertices [][2]float64, cfg EnclosureConfig, holes []DrillHole, cutouts []SideCutout, minBX, maxBX, boardCenterY float64) error {
|
func WriteNativeSCAD(filename string, isTray bool, outlineVertices [][2]float64, cfg EnclosureConfig, holes []DrillHole, cutouts []SideCutout, sides []BoardSide, minBX, maxBX, boardCenterY float64) error {
|
||||||
f, err := os.Create(filename)
|
f, err := os.Create(filename)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
|
@ -152,26 +259,35 @@ func WriteNativeSCAD(filename string, isTray bool, outlineVertices [][2]float64,
|
||||||
// Print Side Cutouts module
|
// Print Side Cutouts module
|
||||||
fmt.Fprintf(f, "module side_cutouts() {\n")
|
fmt.Fprintf(f, "module side_cutouts() {\n")
|
||||||
for _, c := range cutouts {
|
for _, c := range cutouts {
|
||||||
// Cutouts are relative to board.
|
var bs *BoardSide
|
||||||
x, y, z := 0.0, 0.0, c.Height/2+trayFloor+pcbT
|
for i := range sides {
|
||||||
w, d, h := c.Width, 20.0, c.Height // d is deep enough to cut through walls
|
if sides[i].Num == c.Side {
|
||||||
if c.Side == 0 { // Top
|
bs = &sides[i]
|
||||||
y = outlineVertices[0][1] + 10 // rough outside pos
|
break
|
||||||
x = c.X
|
}
|
||||||
fmt.Fprintf(f, " translate([%f, %f, %f]) cube([%f, %f, %f], center=true);\n", x, y, z, w, d, h)
|
|
||||||
} else if c.Side == 1 { // Right
|
|
||||||
x = maxBX
|
|
||||||
y = c.Y
|
|
||||||
fmt.Fprintf(f, " translate([%f, %f, %f]) cube([%f, %f, %f], center=true);\n", x, y, z, d, w, h)
|
|
||||||
} else if c.Side == 2 { // Bottom
|
|
||||||
y = outlineVertices[0][1] - 10
|
|
||||||
x = c.X
|
|
||||||
fmt.Fprintf(f, " translate([%f, %f, %f]) cube([%f, %f, %f], center=true);\n", x, y, z, w, d, h)
|
|
||||||
} else if c.Side == 3 { // Left
|
|
||||||
x = minBX
|
|
||||||
y = c.Y
|
|
||||||
fmt.Fprintf(f, " translate([%f, %f, %f]) cube([%f, %f, %f], center=true);\n", x, y, z, d, w, h)
|
|
||||||
}
|
}
|
||||||
|
if bs == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cutouts are relative to board.
|
||||||
|
z := c.Height/2 + trayFloor + pcbT
|
||||||
|
w, d, h := c.Width, 20.0, c.Height // d is deep enough to cut through walls
|
||||||
|
|
||||||
|
dx := bs.EndX - bs.StartX
|
||||||
|
dy := bs.EndY - bs.StartY
|
||||||
|
length := math.Sqrt(dx*dx + dy*dy)
|
||||||
|
if length > 0 {
|
||||||
|
dx /= length
|
||||||
|
dy /= length
|
||||||
|
}
|
||||||
|
|
||||||
|
midX := bs.StartX + dx*(c.X+w/2)
|
||||||
|
midY := bs.StartY + dy*(c.X+w/2)
|
||||||
|
|
||||||
|
rotDeg := (bs.Angle * 180.0 / math.Pi) - 90.0
|
||||||
|
|
||||||
|
fmt.Fprintf(f, " translate([%f, %f, %f]) rotate([0, 0, %f]) cube([%f, %f, %f], center=true);\n", midX, midY, z, rotDeg, w, d, h)
|
||||||
}
|
}
|
||||||
fmt.Fprintf(f, "}\n\n")
|
fmt.Fprintf(f, "}\n\n")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -232,6 +232,61 @@
|
||||||
text-align: right;
|
text-align: right;
|
||||||
margin-top: 0.2rem;
|
margin-top: 0.2rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Auto-Align Modal */
|
||||||
|
.modal {
|
||||||
|
display: none;
|
||||||
|
position: fixed;
|
||||||
|
z-index: 1000;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background-color: rgba(0, 0, 0, 0.5);
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-content {
|
||||||
|
background-color: #fefefe;
|
||||||
|
padding: 24px;
|
||||||
|
border-radius: 8px;
|
||||||
|
width: 90%;
|
||||||
|
max-width: 500px;
|
||||||
|
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-modal {
|
||||||
|
color: #aaa;
|
||||||
|
float: right;
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: bold;
|
||||||
|
cursor: pointer;
|
||||||
|
line-height: 1;
|
||||||
|
margin-top: -5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-modal:hover {
|
||||||
|
color: black;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fp-item {
|
||||||
|
padding: 10px;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fp-item:hover {
|
||||||
|
background-color: #f9fafb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fp-item.selected {
|
||||||
|
background-color: #eff6ff;
|
||||||
|
border-color: #3b82f6;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
|
|
@ -316,6 +371,13 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="cutout-list" id="cutoutList"></div>
|
<div class="cutout-list" id="cutoutList"></div>
|
||||||
|
|
||||||
|
<hr style="margin: 1.5rem 0 1rem; border: 0; border-top: 1px solid #e5e7eb;">
|
||||||
|
<div style="text-align: center;">
|
||||||
|
<button type="button" class="btn-preset" id="btnOpenAutoAlign"
|
||||||
|
style="font-size: 0.85rem; padding: 0.5rem 1rem; border-color: #8b5cf6; color: #7c3aed; background: #f5f3ff;">✨
|
||||||
|
Auto-Align USB Port</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form id="generateForm" method="POST" action="/generate-enclosure">
|
<form id="generateForm" method="POST" action="/generate-enclosure">
|
||||||
|
|
@ -331,27 +393,70 @@
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Auto-Align Modal -->
|
||||||
|
<div id="autoAlignModal" class="modal">
|
||||||
|
<div class="modal-content">
|
||||||
|
<span class="close-modal" id="closeModal">×</span>
|
||||||
|
<h2 style="margin-top:0; font-size: 1.25rem; font-weight: 600; color: #111827;">Auto-Align USB Port</h2>
|
||||||
|
<p style="font-size:0.85rem; color:#6b7280; margin-bottom: 1rem;">Upload your **Fabrication** Gerbers (e.g.,
|
||||||
|
F.Fab,
|
||||||
|
B.Fab) to visually select your USB-C footprint on the board canvas.<br><strong
|
||||||
|
style="color:#ef4444">Note: You must upload the Fab layer.</strong> Copper, Mask, and Edge Cuts do
|
||||||
|
not contain footprint component data!</p>
|
||||||
|
<form id="autoAlignForm">
|
||||||
|
<div style="margin-bottom: 1rem;">
|
||||||
|
<input type="file" id="autoAlignGerbers" name="gerbers" multiple accept=".gbr" required
|
||||||
|
style="font-size: 0.8rem;">
|
||||||
|
</div>
|
||||||
|
<button type="button" id="btnUploadFootprints" class="btn-small">Upload & Align</button>
|
||||||
|
</form>
|
||||||
|
<div id="autoAlignStatus" style="margin-top:0.75rem; font-size:0.85rem; color:#4b5563;"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Floating Align Instructions -->
|
||||||
|
<div id="alignInstructions"
|
||||||
|
style="display:none; position:fixed; top:20px; left:50%; transform:translateX(-50%); background:#2563eb; color:white; padding:12px 24px; border-radius:30px; font-weight:600; box-shadow:0 10px 15px -3px rgba(0,0,0,0.1); z-index:1000; align-items:center; gap:12px;">
|
||||||
|
<span id="alignInstructionsText">Select the USB-C footprint</span>
|
||||||
|
<button id="btnCancelAlign"
|
||||||
|
style="background:rgba(255,255,255,0.2); border:none; color:white; padding:4px 10px; border-radius:4px; cursor:pointer;">Cancel</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
var sideCutouts = [];
|
var sideCutouts = [];
|
||||||
var currentSide = 1;
|
var currentSide = 1;
|
||||||
var dragStart = null;
|
var dragStart = null;
|
||||||
var dragCurrent = null;
|
var dragCurrent = null;
|
||||||
|
|
||||||
|
// Visual Alignment Globals
|
||||||
|
var alignMode = null; // 'SELECT_FOOTPRINT' | 'SELECT_EDGE'
|
||||||
|
var footprintsData = [];
|
||||||
|
var hoverFootprint = null;
|
||||||
|
var selectedFootprint = null;
|
||||||
|
var hoverEdge = null; // 'top'|'bottom'|'left'|'right'
|
||||||
|
var fabImg = new Image();
|
||||||
|
var imgLoaded = false;
|
||||||
|
|
||||||
|
fabImg.onload = function () {
|
||||||
|
imgLoaded = true;
|
||||||
|
drawBoardWithLabels();
|
||||||
|
};
|
||||||
|
|
||||||
// Board dimensions from server
|
// Board dimensions from server
|
||||||
var boardInfo = {{.BoardInfoJSON }};
|
var boardInfo = {{.BoardInfoJSON }};
|
||||||
var sessionId = '{{.SessionID}}';
|
var sessionId = '{{.SessionID}}';
|
||||||
|
|
||||||
document.getElementById('sessionId').value = sessionId;
|
document.getElementById('sessionId').value = sessionId;
|
||||||
|
|
||||||
// Define sides as numbered segments (clockwise from top)
|
var sides = boardInfo.sides || [];
|
||||||
// For rectangular boards: Side 1=top, 2=right, 3=bottom, 4=left
|
if (sides.length === 0) {
|
||||||
// Future: server could pass actual polygon segments for irregular boards
|
sides = [
|
||||||
var sides = [
|
{ num: 1, label: 'Side 1 (Top)', length: boardInfo.boardW, pos: 'top' },
|
||||||
{ num: 1, label: 'Side 1 (Top)', length: boardInfo.boardW, pos: 'top' },
|
{ num: 2, label: 'Side 2 (Right)', length: boardInfo.boardH, pos: 'right' },
|
||||||
{ num: 2, label: 'Side 2 (Right)', length: boardInfo.boardH, pos: 'right' },
|
{ num: 3, label: 'Side 3 (Bottom)', length: boardInfo.boardW, pos: 'bottom' },
|
||||||
{ num: 3, label: 'Side 3 (Bottom)', length: boardInfo.boardW, pos: 'bottom' },
|
{ num: 4, label: 'Side 4 (Left)', length: boardInfo.boardH, pos: 'left' }
|
||||||
{ num: 4, label: 'Side 4 (Left)', length: boardInfo.boardH, pos: 'left' }
|
];
|
||||||
];
|
}
|
||||||
|
|
||||||
// Build side tabs dynamically
|
// Build side tabs dynamically
|
||||||
var tabsContainer = document.getElementById('faceTabs');
|
var tabsContainer = document.getElementById('faceTabs');
|
||||||
|
|
@ -392,12 +497,82 @@
|
||||||
var h = boardImg.height * scale;
|
var h = boardImg.height * scale;
|
||||||
var x = (boardCanvas.width - w) / 2;
|
var x = (boardCanvas.width - w) / 2;
|
||||||
var y = (boardCanvas.height - h) / 2;
|
var y = (boardCanvas.height - h) / 2;
|
||||||
boardRect = { x: x, y: y, w: w, h: h };
|
boardRect = { x: x, y: y, w: w, h: h, scale: scale };
|
||||||
|
|
||||||
ctx.fillStyle = '#1a1a2e';
|
ctx.fillStyle = '#1a1a2e';
|
||||||
ctx.fillRect(0, 0, boardCanvas.width, boardCanvas.height);
|
ctx.fillRect(0, 0, boardCanvas.width, boardCanvas.height);
|
||||||
ctx.drawImage(boardImg, x, y, w, h);
|
ctx.drawImage(boardImg, x, y, w, h);
|
||||||
|
|
||||||
|
if (alignMode && imgLoaded) {
|
||||||
|
ctx.globalAlpha = 0.5;
|
||||||
|
ctx.drawImage(fabImg, x, y, w, h);
|
||||||
|
ctx.globalAlpha = 1.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw visual alignment overlays
|
||||||
|
if (alignMode) {
|
||||||
|
var pxFromMmX = function (mmX) { return x + (mmX - boardInfo.minX) * boardInfo.dpi / 25.4 * scale; };
|
||||||
|
var pxFromMmY = function (mmY) { return y + (boardInfo.maxY - mmY) * boardInfo.dpi / 25.4 * scale; };
|
||||||
|
|
||||||
|
if (alignMode === 'SELECT_FOOTPRINT') {
|
||||||
|
footprintsData.forEach(function (fp) {
|
||||||
|
var px1 = pxFromMmX(fp.minX);
|
||||||
|
var py1 = pxFromMmY(fp.maxY); // Y is flipped
|
||||||
|
var px2 = pxFromMmX(fp.maxX);
|
||||||
|
var py2 = pxFromMmY(fp.minY);
|
||||||
|
var fw = px2 - px1;
|
||||||
|
var fh = py2 - py1;
|
||||||
|
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.rect(px1, py1, fw, fh);
|
||||||
|
if (hoverFootprint && hoverFootprint.name === fp.name && hoverFootprint.centerX === fp.centerX) {
|
||||||
|
ctx.fillStyle = 'rgba(59, 130, 246, 0.4)';
|
||||||
|
ctx.fill();
|
||||||
|
ctx.strokeStyle = '#3b82f6';
|
||||||
|
ctx.lineWidth = 2;
|
||||||
|
} else {
|
||||||
|
ctx.fillStyle = 'rgba(255, 255, 255, 0.1)';
|
||||||
|
ctx.fill();
|
||||||
|
ctx.strokeStyle = 'rgba(255, 255, 255, 0.5)';
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
}
|
||||||
|
ctx.stroke();
|
||||||
|
});
|
||||||
|
} else if (alignMode === 'SELECT_EDGE' && selectedFootprint) {
|
||||||
|
var px1 = pxFromMmX(selectedFootprint.minX);
|
||||||
|
var py1 = pxFromMmY(selectedFootprint.maxY);
|
||||||
|
var px2 = pxFromMmX(selectedFootprint.maxX);
|
||||||
|
var py2 = pxFromMmY(selectedFootprint.minY);
|
||||||
|
|
||||||
|
// Draw base box
|
||||||
|
ctx.strokeStyle = 'rgba(59, 130, 246, 0.5)';
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
ctx.strokeRect(px1, py1, px2 - px1, py2 - py1);
|
||||||
|
|
||||||
|
// Draw 4 edges
|
||||||
|
var edges = [
|
||||||
|
{ id: 'top', x1: px1, y1: py1, x2: px2, y2: py1 },
|
||||||
|
{ id: 'bottom', x1: px1, y1: py2, x2: px2, y2: py2 },
|
||||||
|
{ id: 'left', x1: px1, y1: py1, x2: px1, y2: py2 },
|
||||||
|
{ id: 'right', x1: px2, y1: py1, x2: px2, y2: py2 }
|
||||||
|
];
|
||||||
|
|
||||||
|
edges.forEach(function (e) {
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(e.x1, e.y1);
|
||||||
|
ctx.lineTo(e.x2, e.y2);
|
||||||
|
if (hoverEdge === e.id) {
|
||||||
|
ctx.strokeStyle = '#ef4444';
|
||||||
|
ctx.lineWidth = 4;
|
||||||
|
} else {
|
||||||
|
ctx.strokeStyle = '#3b82f6';
|
||||||
|
ctx.lineWidth = 2;
|
||||||
|
}
|
||||||
|
ctx.stroke();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Draw numbered side labels around the board
|
// Draw numbered side labels around the board
|
||||||
ctx.font = 'bold 13px sans-serif';
|
ctx.font = 'bold 13px sans-serif';
|
||||||
ctx.textAlign = 'center';
|
ctx.textAlign = 'center';
|
||||||
|
|
@ -412,23 +587,49 @@
|
||||||
ctx.lineWidth = 2;
|
ctx.lineWidth = 2;
|
||||||
|
|
||||||
var lx, ly;
|
var lx, ly;
|
||||||
switch (side.pos) {
|
if (side.startX !== undefined) {
|
||||||
case 'top':
|
var px1 = x + (side.startX - boardInfo.minX) * boardInfo.dpi / 25.4 * scale;
|
||||||
lx = x + w / 2; ly = y - labelPad;
|
var py1 = y + (boardInfo.maxY - side.startY) * boardInfo.dpi / 25.4 * scale;
|
||||||
ctx.beginPath(); ctx.moveTo(x, y - 1); ctx.lineTo(x + w, y - 1); ctx.stroke();
|
var px2 = x + (side.endX - boardInfo.minX) * boardInfo.dpi / 25.4 * scale;
|
||||||
break;
|
var py2 = y + (boardInfo.maxY - side.endY) * boardInfo.dpi / 25.4 * scale;
|
||||||
case 'right':
|
|
||||||
lx = x + w + labelPad; ly = y + h / 2;
|
ctx.beginPath();
|
||||||
ctx.beginPath(); ctx.moveTo(x + w + 1, y); ctx.lineTo(x + w + 1, y + h); ctx.stroke();
|
ctx.moveTo(px1, py1);
|
||||||
break;
|
ctx.lineTo(px2, py2);
|
||||||
case 'bottom':
|
ctx.stroke();
|
||||||
lx = x + w / 2; ly = y + h + labelPad;
|
|
||||||
ctx.beginPath(); ctx.moveTo(x, y + h + 1); ctx.lineTo(x + w, y + h + 1); ctx.stroke();
|
var midX_mm = (side.startX + side.endX) / 2;
|
||||||
break;
|
var midY_mm = (side.startY + side.endY) / 2;
|
||||||
case 'left':
|
var imgPxX = (midX_mm - boardInfo.minX) * boardInfo.dpi / 25.4;
|
||||||
lx = x - labelPad; ly = y + h / 2;
|
var imgPxY = (boardInfo.maxY - midY_mm) * boardInfo.dpi / 25.4;
|
||||||
ctx.beginPath(); ctx.moveTo(x - 1, y); ctx.lineTo(x - 1, y + h); ctx.stroke();
|
|
||||||
break;
|
var cx = x + imgPxX * scale;
|
||||||
|
var cy = y + imgPxY * scale;
|
||||||
|
|
||||||
|
var nx = Math.cos(side.angle);
|
||||||
|
var ny = -Math.sin(side.angle); // -sin because canvas Y is flipped
|
||||||
|
|
||||||
|
lx = cx + nx * labelPad;
|
||||||
|
ly = cy + ny * labelPad;
|
||||||
|
} else {
|
||||||
|
switch (side.pos) {
|
||||||
|
case 'top':
|
||||||
|
lx = x + w / 2; ly = y - labelPad;
|
||||||
|
ctx.beginPath(); ctx.moveTo(x, y - 1); ctx.lineTo(x + w, y - 1); ctx.stroke();
|
||||||
|
break;
|
||||||
|
case 'right':
|
||||||
|
lx = x + w + labelPad; ly = y + h / 2;
|
||||||
|
ctx.beginPath(); ctx.moveTo(x + w + 1, y); ctx.lineTo(x + w + 1, y + h); ctx.stroke();
|
||||||
|
break;
|
||||||
|
case 'bottom':
|
||||||
|
lx = x + w / 2; ly = y + h + labelPad;
|
||||||
|
ctx.beginPath(); ctx.moveTo(x, y + h + 1); ctx.lineTo(x + w, y + h + 1); ctx.stroke();
|
||||||
|
break;
|
||||||
|
case 'left':
|
||||||
|
lx = x - labelPad; ly = y + h / 2;
|
||||||
|
ctx.beginPath(); ctx.moveTo(x - 1, y); ctx.lineTo(x - 1, y + h); ctx.stroke();
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Draw circled number
|
// Draw circled number
|
||||||
|
|
@ -659,6 +860,172 @@
|
||||||
btn.disabled = true;
|
btn.disabled = true;
|
||||||
btn.innerText = 'Generating...';
|
btn.innerText = 'Generating...';
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// --- Auto-Align Logic ---
|
||||||
|
var autoAlignModal = document.getElementById('autoAlignModal');
|
||||||
|
|
||||||
|
document.getElementById('btnOpenAutoAlign').addEventListener('click', function () {
|
||||||
|
autoAlignModal.style.display = 'flex';
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('closeModal').addEventListener('click', function () {
|
||||||
|
autoAlignModal.style.display = 'none';
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('btnCancelAlign').addEventListener('click', function () {
|
||||||
|
alignMode = null;
|
||||||
|
document.getElementById('alignInstructions').style.display = 'none';
|
||||||
|
drawBoardWithLabels();
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('btnUploadFootprints').addEventListener('click', function () {
|
||||||
|
var files = document.getElementById('autoAlignGerbers').files;
|
||||||
|
if (files.length === 0) return;
|
||||||
|
|
||||||
|
var fd = new FormData();
|
||||||
|
for (var i = 0; i < files.length; i++) {
|
||||||
|
fd.append('gerbers', files[i]);
|
||||||
|
}
|
||||||
|
fd.append('sessionId', sessionId);
|
||||||
|
|
||||||
|
document.getElementById('autoAlignStatus').textContent = 'Processing files...';
|
||||||
|
|
||||||
|
fetch('/upload-footprints', {
|
||||||
|
method: 'POST',
|
||||||
|
body: fd
|
||||||
|
}).then(r => r.json()).then(data => {
|
||||||
|
if (!data || data.length === 0) {
|
||||||
|
document.getElementById('autoAlignStatus').textContent = 'No matching footprints found.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
footprintsData = data;
|
||||||
|
autoAlignModal.style.display = 'none';
|
||||||
|
|
||||||
|
// Switch to visual selection mode
|
||||||
|
alignMode = 'SELECT_FOOTPRINT';
|
||||||
|
hoverFootprint = null;
|
||||||
|
selectedFootprint = null;
|
||||||
|
hoverEdge = null;
|
||||||
|
|
||||||
|
document.getElementById('alignInstructions').style.display = 'flex';
|
||||||
|
document.getElementById('alignInstructionsText').textContent = 'Select the USB-C footprint on the board';
|
||||||
|
|
||||||
|
// Fetch the rendered composite fab layer
|
||||||
|
fabImg.src = '/fab-image/' + sessionId + '?t=' + Date.now();
|
||||||
|
drawBoardWithLabels();
|
||||||
|
}).catch(e => {
|
||||||
|
document.getElementById('autoAlignStatus').textContent = 'Error: ' + e;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Interactive Canvas Event Listeners
|
||||||
|
boardCanvas.addEventListener('mousemove', function (e) {
|
||||||
|
if (!alignMode) return;
|
||||||
|
var rect = boardCanvas.getBoundingClientRect();
|
||||||
|
var pxX = (e.clientX - rect.left) * (boardCanvas.width / rect.width);
|
||||||
|
var pxY = (e.clientY - rect.top) * (boardCanvas.height / rect.height);
|
||||||
|
|
||||||
|
// Convert canvas px to mm
|
||||||
|
var scale = boardRect.scale;
|
||||||
|
var mmX = boardInfo.minX + (pxX - boardRect.x) / scale * 25.4 / boardInfo.dpi;
|
||||||
|
var mmY = boardInfo.maxY - (pxY - boardRect.y) / scale * 25.4 / boardInfo.dpi;
|
||||||
|
|
||||||
|
if (alignMode === 'SELECT_FOOTPRINT') {
|
||||||
|
hoverFootprint = null;
|
||||||
|
for (var i = 0; i < footprintsData.length; i++) {
|
||||||
|
var fp = footprintsData[i];
|
||||||
|
if (mmX >= fp.minX && mmX <= fp.maxX && mmY >= fp.minY && mmY <= fp.maxY) {
|
||||||
|
hoverFootprint = fp;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
drawBoardWithLabels();
|
||||||
|
} else if (alignMode === 'SELECT_EDGE' && selectedFootprint) {
|
||||||
|
hoverEdge = null;
|
||||||
|
var dists = [
|
||||||
|
{ id: 'top', d: Math.abs(mmY - selectedFootprint.maxY) },
|
||||||
|
{ id: 'bottom', d: Math.abs(mmY - selectedFootprint.minY) },
|
||||||
|
{ id: 'left', d: Math.abs(mmX - selectedFootprint.minX) },
|
||||||
|
{ id: 'right', d: Math.abs(mmX - selectedFootprint.maxX) }
|
||||||
|
];
|
||||||
|
dists.sort(function (a, b) { return a.d - b.d; });
|
||||||
|
if (dists[0].d < 3.0) { // snap tolerance 3mm
|
||||||
|
hoverEdge = dists[0].id;
|
||||||
|
}
|
||||||
|
drawBoardWithLabels();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
boardCanvas.addEventListener('click', function (e) {
|
||||||
|
if (!alignMode) return;
|
||||||
|
|
||||||
|
if (alignMode === 'SELECT_FOOTPRINT' && hoverFootprint) {
|
||||||
|
selectedFootprint = hoverFootprint;
|
||||||
|
alignMode = 'SELECT_EDGE';
|
||||||
|
document.getElementById('alignInstructionsText').textContent = 'Select the outermost edge of the connector lip';
|
||||||
|
drawBoardWithLabels();
|
||||||
|
} else if (alignMode === 'SELECT_EDGE' && hoverEdge && selectedFootprint) {
|
||||||
|
applyAlignment(selectedFootprint, hoverEdge);
|
||||||
|
alignMode = null;
|
||||||
|
document.getElementById('alignInstructions').style.display = 'none';
|
||||||
|
drawBoardWithLabels();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function applyAlignment(fp, lip) {
|
||||||
|
var bx = fp.centerX;
|
||||||
|
var by = fp.centerY;
|
||||||
|
|
||||||
|
if (lip === 'top') by = fp.maxY;
|
||||||
|
else if (lip === 'bottom') by = fp.minY;
|
||||||
|
else if (lip === 'left') bx = fp.minX;
|
||||||
|
else if (lip === 'right') bx = fp.maxX;
|
||||||
|
|
||||||
|
// Find closest board side
|
||||||
|
var closestSide = null;
|
||||||
|
var minDist = Infinity;
|
||||||
|
var bestPosX = 0;
|
||||||
|
|
||||||
|
sides.forEach(function (s) {
|
||||||
|
if (s.startX !== undefined) {
|
||||||
|
var dx = s.endX - s.startX;
|
||||||
|
var dy = s.endY - s.startY;
|
||||||
|
var lenSq = dx * dx + dy * dy;
|
||||||
|
if (lenSq > 0) {
|
||||||
|
var t = ((bx - s.startX) * dx + (by - s.startY) * dy) / lenSq;
|
||||||
|
t = Math.max(0, Math.min(1, t));
|
||||||
|
var rx = s.startX + t * dx;
|
||||||
|
var ry = s.startY + t * dy;
|
||||||
|
var dist = Math.sqrt(Math.pow(bx - rx, 2) + Math.pow(by - ry, 2));
|
||||||
|
if (dist < minDist) {
|
||||||
|
minDist = dist;
|
||||||
|
closestSide = s;
|
||||||
|
bestPosX = t * s.length;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (closestSide) {
|
||||||
|
// Set typical USB-C parameters
|
||||||
|
document.getElementById('cutW').value = '9.00';
|
||||||
|
document.getElementById('cutH').value = '3.50';
|
||||||
|
document.getElementById('cutR').value = '1.30';
|
||||||
|
|
||||||
|
// Center based on projected posX
|
||||||
|
var cutX = bestPosX - (9.0 / 2);
|
||||||
|
document.getElementById('cutX').value = cutX.toFixed(2);
|
||||||
|
document.getElementById('cutY').value = '0.00';
|
||||||
|
|
||||||
|
currentSide = closestSide.num;
|
||||||
|
document.getElementById('btnAddCutout').click();
|
||||||
|
|
||||||
|
// Activate the correct side tab
|
||||||
|
document.querySelector('.face-tab[data-side="' + closestSide.num + '"]').click();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue