package main import ( "fmt" "math" "strings" ) type TabSlot struct { X float64 `json:"x"` Y float64 `json:"y"` W float64 `json:"w"` H float64 `json:"h"` } type UnwrapPanel struct { Label string `json:"label"` FaceType string `json:"faceType"` // "lid", "tray", "side" SideNum int `json:"sideNum"` X float64 `json:"x"` Y float64 `json:"y"` Width float64 `json:"width"` Height float64 `json:"height"` Angle float64 `json:"angle"` Cutouts []Cutout `json:"cutouts"` TabSlots []TabSlot `json:"tabSlots"` // Polygon outline for non-rectangular panels (lid/tray) Polygon [][2]float64 `json:"polygon,omitempty"` } type UnwrapLayout struct { Panels []UnwrapPanel `json:"panels"` TotalW float64 `json:"totalW"` TotalH float64 `json:"totalH"` WallThick float64 `json:"wallThick"` } // offsetPolygon moves each vertex outward along the average of its two adjacent edge normals. func offsetPolygon(poly [][2]float64, dist float64) [][2]float64 { n := len(poly) if n < 3 { return poly } // Remove closing duplicate if present last := poly[n-1] first := poly[0] closed := math.Abs(last[0]-first[0]) < 0.01 && math.Abs(last[1]-first[1]) < 0.01 if closed { n-- } // Compute centroid to determine winding cx, cy := 0.0, 0.0 for i := 0; i < n; i++ { cx += poly[i][0] cy += poly[i][1] } cx /= float64(n) cy /= float64(n) // Compute signed area to detect winding direction area := 0.0 for i := 0; i < n; i++ { j := (i + 1) % n area += poly[i][0]*poly[j][1] - poly[j][0]*poly[i][1] } sign := 1.0 if area > 0 { sign = -1.0 } out := make([][2]float64, n) for i := 0; i < n; i++ { prev := (i - 1 + n) % n next := (i + 1) % n // Edge vectors e1x := poly[i][0] - poly[prev][0] e1y := poly[i][1] - poly[prev][1] e2x := poly[next][0] - poly[i][0] e2y := poly[next][1] - poly[i][1] // Outward normals (perpendicular, pointing away from center) n1x := -e1y * sign n1y := e1x * sign n2x := -e2y * sign n2y := e2x * sign // Normalize l1 := math.Sqrt(n1x*n1x + n1y*n1y) l2 := math.Sqrt(n2x*n2x + n2y*n2y) if l1 > 0 { n1x /= l1 n1y /= l1 } if l2 > 0 { n2x /= l2 n2y /= l2 } // Average normal ax := (n1x + n2x) / 2 ay := (n1y + n2y) / 2 al := math.Sqrt(ax*ax + ay*ay) if al < 1e-9 { out[i] = poly[i] continue } ax /= al ay /= al // Half-angle cosine dot := n1x*n2x + n1y*n2y halfAngle := math.Acos(math.Max(-1, math.Min(1, dot))) / 2 cosHalf := math.Cos(halfAngle) if cosHalf < 0.1 { cosHalf = 0.1 } scale := dist / cosHalf out[i] = [2]float64{poly[i][0] + ax*scale, poly[i][1] + ay*scale} } // Close the polygon out = append(out, out[0]) return out } // ComputeUnwrapLayout generates a flat unfolded net of the enclosure's outer surface. func ComputeUnwrapLayout(session *EnclosureSession, cutouts []Cutout) *UnwrapLayout { cfg := session.Config wt := cfg.WallThickness clearance := cfg.Clearance offset := clearance + 2*wt outlinePoly := ExtractPolygonFromGerber(session.OutlineGf) if len(outlinePoly) < 3 { return nil } lidPoly := offsetPolygon(outlinePoly, offset) // Bounding box of the lid polygon lidMinX, lidMinY := math.Inf(1), math.Inf(1) lidMaxX, lidMaxY := math.Inf(-1), math.Inf(-1) for _, p := range lidPoly { if p[0] < lidMinX { lidMinX = p[0] } if p[1] < lidMinY { lidMinY = p[1] } if p[0] > lidMaxX { lidMaxX = p[0] } if p[1] > lidMaxY { lidMaxY = p[1] } } lidW := lidMaxX - lidMinX lidH := lidMaxY - lidMinY wallH := cfg.WallHeight + cfg.PCBThickness + 1.0 sides := session.Sides if len(sides) == 0 { return nil } // Normalize lid polygon to origin for the SVG layout normLidPoly := make([][2]float64, len(lidPoly)) for i, p := range lidPoly { normLidPoly[i] = [2]float64{p[0] - lidMinX, p[1] - lidMinY} } // Classify cutouts by surface sideCutouts, lidCutouts := SplitCutouts(cutouts, nil) _ = lidCutouts // Pry tab positions pryW := 8.0 pryH := 1.5 // Find longest side for tray attachment longestIdx := 0 longestLen := 0.0 for i, s := range sides { if s.Length > longestLen { longestLen = s.Length longestIdx = i } } // Layout: lid at center, sides folded out from lid edges // We place lid at a starting position, then attach side panels along each edge. // Simple cross layout: lid centered, sides extending outward. margin := 5.0 layoutX := margin layoutY := margin var panels []UnwrapPanel // Lid panel lidPanel := UnwrapPanel{ Label: "L", FaceType: "lid", X: layoutX, Y: layoutY + wallH, // sides above will fold up Width: lidW, Height: lidH, Polygon: normLidPoly, } // Map lid cutouts for _, c := range cutouts { if c.Surface == "top" { mapped := c mapped.X = c.X - lidMinX + lidPanel.X mapped.Y = c.Y - lidMinY + lidPanel.Y lidPanel.Cutouts = append(lidPanel.Cutouts, mapped) } } panels = append(panels, lidPanel) // Side panels: attach along each edge of the lid // For simplicity, we place sides in a strip below the lid, left to right sideX := margin sideY := lidPanel.Y + lidH // below the lid for i, s := range sides { sp := UnwrapPanel{ Label: fmt.Sprintf("S%d", s.Num), FaceType: "side", SideNum: s.Num, X: sideX, Y: sideY, Width: s.Length, Height: wallH, Angle: s.Angle, } // Map side cutouts to this panel for _, sc := range sideCutouts { if sc.Side == s.Num { mapped := Cutout{ ID: fmt.Sprintf("unwrap-s%d", sc.Side), Surface: "side", SideNum: sc.Side, X: sc.X + sp.X, Y: sc.Y + sp.Y, Width: sc.Width, Height: sc.Height, } sp.Cutouts = append(sp.Cutouts, mapped) } } // Tab slots on pry tab sides if s.Num == 1 || s.Num == len(sides) { slot := TabSlot{ X: sp.Width/2 - pryW/2, Y: 0, W: pryW, H: pryH, } sp.TabSlots = append(sp.TabSlots, slot) } panels = append(panels, sp) // If this is the longest side, attach tray below it if i == longestIdx { trayPanel := UnwrapPanel{ Label: "T", FaceType: "tray", X: sideX, Y: sideY + wallH, Width: lidW, Height: lidH, Polygon: normLidPoly, } for _, c := range cutouts { if c.Surface == "bottom" { mapped := c mapped.X = c.X - lidMinX + trayPanel.X mapped.Y = c.Y - lidMinY + trayPanel.Y trayPanel.Cutouts = append(trayPanel.Cutouts, mapped) } } panels = append(panels, trayPanel) } sideX += s.Length + 2.0 } // Compute total layout bounds totalW := margin totalH := margin for _, p := range panels { right := p.X + p.Width bottom := p.Y + p.Height if right > totalW { totalW = right } if bottom > totalH { totalH = bottom } } totalW += margin totalH += margin return &UnwrapLayout{ Panels: panels, TotalW: totalW, TotalH: totalH, WallThick: wt, } } // GenerateUnwrapSVG produces an SVG string of the unfolded enclosure net. func GenerateUnwrapSVG(layout *UnwrapLayout) string { if layout == nil { return "" } var b strings.Builder b.WriteString(fmt.Sprintf(``+"\n", layout.TotalW, layout.TotalH, layout.TotalW, layout.TotalH)) b.WriteString(`\n") for _, p := range layout.Panels { b.WriteString(fmt.Sprintf(``+"\n", p.Label, p.FaceType, p.SideNum, p.X, p.Y, p.Width, p.Height)) // Panel outline if len(p.Polygon) > 2 { b.WriteString(`\n") } else { b.WriteString(fmt.Sprintf(``+"\n", p.X, p.Y, p.Width, p.Height)) } // Cutouts for _, c := range p.Cutouts { if c.Shape == "circle" { r := c.Width / 2 b.WriteString(fmt.Sprintf(``+"\n", c.X+r, c.Y+r, r)) } else { b.WriteString(fmt.Sprintf(``+"\n", c.X, c.Y, c.Width, c.Height)) } } // Tab slots for _, t := range p.TabSlots { b.WriteString(fmt.Sprintf(``+"\n", p.X+t.X, p.Y+t.Y, t.W, t.H)) } // Label cx := p.X + p.Width/2 cy := p.Y + p.Height/2 b.WriteString(fmt.Sprintf(`%s`+"\n", cx, cy, p.Label)) b.WriteString("\n") } // Fold lines between lid and side panels for _, p := range layout.Panels { if p.FaceType == "side" { b.WriteString(fmt.Sprintf(``+"\n", p.X, p.Y, p.X+p.Width, p.Y)) } if p.FaceType == "tray" { b.WriteString(fmt.Sprintf(``+"\n", p.X, p.Y, p.X+p.Width, p.Y)) } } b.WriteString("") return b.String() }