461 lines
15 KiB
Go
461 lines
15 KiB
Go
package main
|
||
|
||
import (
|
||
"bytes"
|
||
"fmt"
|
||
"io"
|
||
"os"
|
||
"path/filepath"
|
||
)
|
||
|
||
// Standard cylindrical cell dimensions: diameter × length (mm)
|
||
var StandardCells = map[string][2]float64{
|
||
"10440": {10, 44},
|
||
"14500": {14, 50},
|
||
"16340": {16, 34},
|
||
"18350": {18, 35},
|
||
"18650": {18, 65},
|
||
"20700": {20, 70},
|
||
"21700": {21, 70},
|
||
"26650": {26, 65},
|
||
"32650": {32, 65},
|
||
}
|
||
|
||
// BatteryConfig defines a battery compartment to be generated inside an enclosure.
|
||
type BatteryConfig struct {
|
||
CellSize string `json:"cellSize"` // preset key or "custom"
|
||
CellDiameter float64 `json:"cellDiameter"` // mm, used when CellSize == "custom"
|
||
CellLength float64 `json:"cellLength"` // mm, used when CellSize == "custom"
|
||
Series int `json:"series"`
|
||
Parallel int `json:"parallel"`
|
||
PositionX float64 `json:"positionX"` // bay origin X in enclosure coords
|
||
PositionY float64 `json:"positionY"` // bay origin Y in enclosure coords
|
||
Rotation float64 `json:"rotation"` // degrees, placement rotation on the canvas
|
||
Placement string `json:"placement"` // "bottom", "side-1", "side-2", etc.
|
||
Orientation string `json:"orientation"` // "horizontal" or "vertical"
|
||
WallThick float64 `json:"wallThick"` // internal wall thickness (default 1.2)
|
||
Clearance float64 `json:"clearance"` // gap around each cell (default 0.3)
|
||
HasLid bool `json:"hasLid"` // generate a clip-on lid for the bay
|
||
RoutingHoles []BatteryRoutingHole `json:"routingHoles"`
|
||
}
|
||
|
||
// BatteryRoutingHole is a wire passthrough in the bay's outer walls or floor
|
||
// to route power wires from the battery bay to the PCB.
|
||
type BatteryRoutingHole struct {
|
||
Face string `json:"face"` // "front", "back", "left", "right", "floor"
|
||
Position float64 `json:"position"` // 0..1 along the face width
|
||
Height float64 `json:"height"` // 0..1 along the face height (ignored for floor)
|
||
Diameter float64 `json:"diameter"` // hole diameter mm
|
||
}
|
||
|
||
// BatteryLayout holds computed dimensions for a battery bay.
|
||
type BatteryLayout struct {
|
||
BayWidth float64 `json:"bayWidth"`
|
||
BayDepth float64 `json:"bayDepth"`
|
||
BayHeight float64 `json:"bayHeight"`
|
||
Rows int `json:"rows"`
|
||
Cols int `json:"cols"`
|
||
CellDia float64 `json:"cellDia"`
|
||
CellLen float64 `json:"cellLen"`
|
||
Wall float64 `json:"wall"`
|
||
Clr float64 `json:"clr"`
|
||
TotalCells int `json:"totalCells"`
|
||
}
|
||
|
||
func (cfg *BatteryConfig) resolveDimensions() (dia, length float64) {
|
||
if dims, ok := StandardCells[cfg.CellSize]; ok {
|
||
return dims[0], dims[1]
|
||
}
|
||
return cfg.CellDiameter, cfg.CellLength
|
||
}
|
||
|
||
func (cfg *BatteryConfig) wallThick() float64 {
|
||
if cfg.WallThick > 0 {
|
||
return cfg.WallThick
|
||
}
|
||
return 1.2
|
||
}
|
||
|
||
func (cfg *BatteryConfig) clearance() float64 {
|
||
if cfg.Clearance > 0 {
|
||
return cfg.Clearance
|
||
}
|
||
return 0.3
|
||
}
|
||
|
||
// ComputeLayout calculates the physical dimensions of the battery bay.
|
||
func (cfg *BatteryConfig) ComputeLayout() BatteryLayout {
|
||
dia, length := cfg.resolveDimensions()
|
||
wall := cfg.wallThick()
|
||
clr := cfg.clearance()
|
||
|
||
rows := cfg.Series
|
||
cols := cfg.Parallel
|
||
if rows < 1 {
|
||
rows = 1
|
||
}
|
||
if cols < 1 {
|
||
cols = 1
|
||
}
|
||
|
||
cellSlot := dia + 2*clr
|
||
|
||
var bayW, bayD, bayH float64
|
||
|
||
if cfg.Orientation == "vertical" {
|
||
bayW = float64(cols)*(cellSlot+wall) + wall
|
||
bayD = float64(rows)*(cellSlot+wall) + wall
|
||
bayH = length + clr + wall
|
||
} else {
|
||
// horizontal: cell axis along X
|
||
bayW = length + 2*clr + 2*wall
|
||
bayD = float64(cols)*(cellSlot+wall) + wall
|
||
bayH = float64(rows)*(cellSlot+wall) + wall
|
||
}
|
||
|
||
return BatteryLayout{
|
||
BayWidth: bayW,
|
||
BayDepth: bayD,
|
||
BayHeight: bayH,
|
||
Rows: rows,
|
||
Cols: cols,
|
||
CellDia: dia,
|
||
CellLen: length,
|
||
Wall: wall,
|
||
Clr: clr,
|
||
TotalCells: rows * cols,
|
||
}
|
||
}
|
||
|
||
// WriteBatteryBaySCAD writes the battery bay base piece to a file.
|
||
func WriteBatteryBaySCAD(filename string, cfg *BatteryConfig) error {
|
||
f, err := os.Create(filename)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
defer f.Close()
|
||
return writeBatteryBaySCADTo(f, cfg)
|
||
}
|
||
|
||
// GenerateBatteryBaySCADString returns the bay SCAD as a string.
|
||
func GenerateBatteryBaySCADString(cfg *BatteryConfig) (string, error) {
|
||
var buf bytes.Buffer
|
||
if err := writeBatteryBaySCADTo(&buf, cfg); err != nil {
|
||
return "", err
|
||
}
|
||
return buf.String(), nil
|
||
}
|
||
|
||
func writeBatteryBaySCADTo(f io.Writer, cfg *BatteryConfig) error {
|
||
lay := cfg.ComputeLayout()
|
||
wall := lay.Wall
|
||
clr := lay.Clr
|
||
cellSlot := lay.CellDia + 2*clr
|
||
|
||
fmt.Fprintf(f, "// Battery bay — %ds%dp %s cells (%s)\n", lay.Rows, lay.Cols, cfg.CellSize, cfg.Orientation)
|
||
fmt.Fprintf(f, "$fn = 48;\n\n")
|
||
fmt.Fprintf(f, "cell_dia = %f;\n", lay.CellDia)
|
||
fmt.Fprintf(f, "cell_len = %f;\n", lay.CellLen)
|
||
fmt.Fprintf(f, "wall = %f;\n", wall)
|
||
fmt.Fprintf(f, "clr = %f;\n", clr)
|
||
fmt.Fprintf(f, "cell_slot = cell_dia + 2*clr;\n")
|
||
fmt.Fprintf(f, "bay_w = %f;\n", lay.BayWidth)
|
||
fmt.Fprintf(f, "bay_d = %f;\n", lay.BayDepth)
|
||
fmt.Fprintf(f, "bay_h = %f;\n\n", lay.BayHeight)
|
||
|
||
if cfg.Orientation == "vertical" {
|
||
writeVerticalBay(f, lay, cellSlot, wall, clr, cfg)
|
||
} else {
|
||
writeHorizontalBay(f, lay, cellSlot, wall, clr, cfg)
|
||
}
|
||
|
||
return nil
|
||
}
|
||
|
||
func writeHorizontalBay(f io.Writer, lay BatteryLayout, cellSlot, wall, clr float64, cfg *BatteryConfig) {
|
||
cellCavityLen := lay.CellLen + 2*clr
|
||
|
||
fmt.Fprintf(f, "// Battery bay base — cells lie horizontal, open top for insertion\n")
|
||
fmt.Fprintf(f, "difference() {\n")
|
||
fmt.Fprintf(f, " cube([bay_w, bay_d, bay_h]);\n\n")
|
||
|
||
for row := 0; row < lay.Rows; row++ {
|
||
for col := 0; col < lay.Cols; col++ {
|
||
cy := wall + cellSlot/2 + float64(col)*(cellSlot+wall)
|
||
cz := wall + cellSlot/2 + float64(row)*(cellSlot+wall)
|
||
|
||
fmt.Fprintf(f, " // Cell [%d,%d]\n", row, col)
|
||
fmt.Fprintf(f, " translate([wall, %f, %f])\n", cy, cz)
|
||
fmt.Fprintf(f, " rotate([0, 90, 0])\n")
|
||
fmt.Fprintf(f, " cylinder(d=cell_slot, h=%f);\n", cellCavityLen)
|
||
|
||
// Open above cell center for top insertion
|
||
fmt.Fprintf(f, " translate([wall, %f, %f])\n", cy-cellSlot/2, cz)
|
||
fmt.Fprintf(f, " cube([%f, cell_slot, bay_h]);\n\n", cellCavityLen)
|
||
}
|
||
}
|
||
|
||
writeRoutingHoles(f, lay, cfg)
|
||
|
||
fmt.Fprintf(f, "}\n")
|
||
}
|
||
|
||
func writeVerticalBay(f io.Writer, lay BatteryLayout, cellSlot, wall, clr float64, cfg *BatteryConfig) {
|
||
fmt.Fprintf(f, "// Battery bay base — cells stand vertical\n")
|
||
fmt.Fprintf(f, "difference() {\n")
|
||
fmt.Fprintf(f, " cube([bay_w, bay_d, bay_h]);\n\n")
|
||
|
||
for row := 0; row < lay.Rows; row++ {
|
||
for col := 0; col < lay.Cols; col++ {
|
||
cx := wall + cellSlot/2 + float64(col)*(cellSlot+wall)
|
||
cy := wall + cellSlot/2 + float64(row)*(cellSlot+wall)
|
||
|
||
fmt.Fprintf(f, " // Cell [%d,%d]\n", row, col)
|
||
fmt.Fprintf(f, " translate([%f, %f, wall])\n", cx, cy)
|
||
fmt.Fprintf(f, " cylinder(d=cell_slot, h=bay_h);\n\n")
|
||
}
|
||
}
|
||
|
||
writeRoutingHoles(f, lay, cfg)
|
||
|
||
fmt.Fprintf(f, "}\n")
|
||
}
|
||
|
||
func writeRoutingHoles(f io.Writer, lay BatteryLayout, cfg *BatteryConfig) {
|
||
wall := lay.Wall
|
||
for _, rh := range cfg.RoutingHoles {
|
||
d := rh.Diameter
|
||
if d <= 0 {
|
||
d = 3.0
|
||
}
|
||
pos := rh.Position
|
||
if pos < 0 {
|
||
pos = 0
|
||
}
|
||
if pos > 1 {
|
||
pos = 1
|
||
}
|
||
ht := rh.Height
|
||
if ht < 0 {
|
||
ht = 0
|
||
}
|
||
if ht > 1 {
|
||
ht = 1
|
||
}
|
||
|
||
switch rh.Face {
|
||
case "front":
|
||
cx := wall + pos*(lay.BayWidth-2*wall)
|
||
cz := wall + ht*(lay.BayHeight-2*wall)
|
||
fmt.Fprintf(f, " // Routing hole (front wall)\n")
|
||
fmt.Fprintf(f, " translate([%f, -0.1, %f])\n", cx, cz)
|
||
fmt.Fprintf(f, " rotate([-90, 0, 0])\n")
|
||
fmt.Fprintf(f, " cylinder(d=%f, h=%f);\n\n", d, wall+0.2)
|
||
case "back":
|
||
cx := wall + pos*(lay.BayWidth-2*wall)
|
||
cz := wall + ht*(lay.BayHeight-2*wall)
|
||
fmt.Fprintf(f, " // Routing hole (back wall)\n")
|
||
fmt.Fprintf(f, " translate([%f, %f, %f])\n", cx, lay.BayDepth-wall-0.1, cz)
|
||
fmt.Fprintf(f, " rotate([-90, 0, 0])\n")
|
||
fmt.Fprintf(f, " cylinder(d=%f, h=%f);\n\n", d, wall+0.2)
|
||
case "left":
|
||
cy := wall + pos*(lay.BayDepth-2*wall)
|
||
cz := wall + ht*(lay.BayHeight-2*wall)
|
||
fmt.Fprintf(f, " // Routing hole (left wall)\n")
|
||
fmt.Fprintf(f, " translate([-0.1, %f, %f])\n", cy, cz)
|
||
fmt.Fprintf(f, " rotate([0, 90, 0])\n")
|
||
fmt.Fprintf(f, " cylinder(d=%f, h=%f);\n\n", d, wall+0.2)
|
||
case "right":
|
||
cy := wall + pos*(lay.BayDepth-2*wall)
|
||
cz := wall + ht*(lay.BayHeight-2*wall)
|
||
fmt.Fprintf(f, " // Routing hole (right wall)\n")
|
||
fmt.Fprintf(f, " translate([%f, %f, %f])\n", lay.BayWidth-wall-0.1, cy, cz)
|
||
fmt.Fprintf(f, " rotate([0, 90, 0])\n")
|
||
fmt.Fprintf(f, " cylinder(d=%f, h=%f);\n\n", d, wall+0.2)
|
||
case "floor":
|
||
cx := wall + pos*(lay.BayWidth-2*wall)
|
||
cy := wall + ht*(lay.BayDepth-2*wall)
|
||
fmt.Fprintf(f, " // Routing hole (floor)\n")
|
||
fmt.Fprintf(f, " translate([%f, %f, -0.1])\n", cx, cy)
|
||
fmt.Fprintf(f, " cylinder(d=%f, h=%f);\n\n", d, wall+0.2)
|
||
}
|
||
}
|
||
}
|
||
|
||
// WriteBatterySeparatorSCAD writes the egg-carton separator piece.
|
||
func WriteBatterySeparatorSCAD(filename string, cfg *BatteryConfig) error {
|
||
f, err := os.Create(filename)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
defer f.Close()
|
||
return writeBatterySeparatorSCADTo(f, cfg)
|
||
}
|
||
|
||
// GenerateBatterySeparatorSCADString returns the separator SCAD as a string.
|
||
func GenerateBatterySeparatorSCADString(cfg *BatteryConfig) (string, error) {
|
||
var buf bytes.Buffer
|
||
if err := writeBatterySeparatorSCADTo(&buf, cfg); err != nil {
|
||
return "", err
|
||
}
|
||
return buf.String(), nil
|
||
}
|
||
|
||
func writeBatterySeparatorSCADTo(f io.Writer, cfg *BatteryConfig) error {
|
||
lay := cfg.ComputeLayout()
|
||
wall := lay.Wall
|
||
clr := lay.Clr
|
||
cellSlot := lay.CellDia + 2*clr
|
||
|
||
if cfg.Orientation == "vertical" {
|
||
return writeVerticalSeparator(f, lay, cellSlot, wall, clr)
|
||
}
|
||
return writeHorizontalSeparator(f, lay, cellSlot, wall, clr)
|
||
}
|
||
|
||
func writeHorizontalSeparator(f io.Writer, lay BatteryLayout, cellSlot, wall, clr float64) error {
|
||
// The separator mirrors the bay base: same external dimensions, with
|
||
// egg-carton bumps that capture the top half of each cell.
|
||
// Total height = same as bay so it registers properly.
|
||
cellCavityLen := lay.CellLen + 2*clr
|
||
|
||
fmt.Fprintf(f, "// Battery separator (egg-carton retainer) — horizontal cells\n")
|
||
fmt.Fprintf(f, "$fn = 48;\n\n")
|
||
fmt.Fprintf(f, "cell_slot = %f;\n", cellSlot)
|
||
fmt.Fprintf(f, "wall = %f;\n", wall)
|
||
fmt.Fprintf(f, "bay_w = %f;\n", lay.BayWidth)
|
||
fmt.Fprintf(f, "bay_d = %f;\n", lay.BayDepth)
|
||
fmt.Fprintf(f, "bay_h = %f;\n\n", lay.BayHeight)
|
||
|
||
fmt.Fprintf(f, "difference() {\n")
|
||
fmt.Fprintf(f, " cube([bay_w, bay_d, bay_h]);\n\n")
|
||
|
||
for row := 0; row < lay.Rows; row++ {
|
||
for col := 0; col < lay.Cols; col++ {
|
||
cy := wall + cellSlot/2 + float64(col)*(cellSlot+wall)
|
||
cz := wall + cellSlot/2 + float64(row)*(cellSlot+wall)
|
||
|
||
fmt.Fprintf(f, " // Cell [%d,%d]\n", row, col)
|
||
// Cell cylinder — same position as bay
|
||
fmt.Fprintf(f, " translate([wall, %f, %f])\n", cy, cz)
|
||
fmt.Fprintf(f, " rotate([0, 90, 0])\n")
|
||
fmt.Fprintf(f, " cylinder(d=cell_slot, h=%f);\n", cellCavityLen)
|
||
|
||
// Cut below cell center (keep top half = retainer bump)
|
||
fmt.Fprintf(f, " translate([wall, %f, -0.1])\n", cy-cellSlot/2)
|
||
fmt.Fprintf(f, " cube([%f, cell_slot, %f]);\n\n", cellCavityLen, cz+0.1)
|
||
}
|
||
}
|
||
|
||
fmt.Fprintf(f, "}\n")
|
||
return nil
|
||
}
|
||
|
||
func writeVerticalSeparator(f io.Writer, lay BatteryLayout, cellSlot, wall, clr float64) error {
|
||
// For vertical cells, the separator is a plate with circular holes
|
||
// that sit over the cells, providing lateral retention.
|
||
plateH := wall
|
||
lipDepth := cellSlot * 0.3 // how far the lip extends down into the cell socket
|
||
sepH := plateH + lipDepth
|
||
|
||
fmt.Fprintf(f, "// Battery separator (retainer plate) — vertical cells\n")
|
||
fmt.Fprintf(f, "$fn = 48;\n\n")
|
||
|
||
fmt.Fprintf(f, "difference() {\n")
|
||
fmt.Fprintf(f, " cube([%f, %f, %f]);\n\n", lay.BayWidth, lay.BayDepth, sepH)
|
||
|
||
for row := 0; row < lay.Rows; row++ {
|
||
for col := 0; col < lay.Cols; col++ {
|
||
cx := wall + cellSlot/2 + float64(col)*(cellSlot+wall)
|
||
cy := wall + cellSlot/2 + float64(row)*(cellSlot+wall)
|
||
|
||
// Hole slightly smaller than cell so it retains but allows insertion
|
||
retainDia := lay.CellDia - 1.0
|
||
if retainDia < lay.CellDia*0.7 {
|
||
retainDia = lay.CellDia * 0.7
|
||
}
|
||
fmt.Fprintf(f, " // Cell [%d,%d] retention hole\n", row, col)
|
||
fmt.Fprintf(f, " translate([%f, %f, -0.1])\n", cx, cy)
|
||
fmt.Fprintf(f, " cylinder(d=%f, h=%f);\n\n", retainDia, sepH+0.2)
|
||
}
|
||
}
|
||
|
||
fmt.Fprintf(f, "}\n")
|
||
return nil
|
||
}
|
||
|
||
// WriteBatteryLidSCAD writes the clip-on lid for the battery bay.
|
||
func WriteBatteryLidSCAD(filename string, cfg *BatteryConfig) error {
|
||
f, err := os.Create(filename)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
defer f.Close()
|
||
return writeBatteryLidSCADTo(f, cfg)
|
||
}
|
||
|
||
// GenerateBatteryLidSCADString returns the lid SCAD as a string.
|
||
func GenerateBatteryLidSCADString(cfg *BatteryConfig) (string, error) {
|
||
var buf bytes.Buffer
|
||
if err := writeBatteryLidSCADTo(&buf, cfg); err != nil {
|
||
return "", err
|
||
}
|
||
return buf.String(), nil
|
||
}
|
||
|
||
func writeBatteryLidSCADTo(f io.Writer, cfg *BatteryConfig) error {
|
||
lay := cfg.ComputeLayout()
|
||
wall := lay.Wall
|
||
lipH := 2.0 // how far the lid lip extends down into the bay
|
||
lidPlate := wall // top plate thickness
|
||
clipTol := 0.15 // tolerance for clip fit
|
||
|
||
fmt.Fprintf(f, "// Battery bay lid — clips onto bay walls\n")
|
||
fmt.Fprintf(f, "$fn = 48;\n\n")
|
||
|
||
outerW := lay.BayWidth + 2*clipTol
|
||
outerD := lay.BayDepth + 2*clipTol
|
||
innerW := lay.BayWidth - 2*wall + 2*clipTol
|
||
innerD := lay.BayDepth - 2*wall + 2*clipTol
|
||
|
||
fmt.Fprintf(f, "difference() {\n")
|
||
fmt.Fprintf(f, " cube([%f, %f, %f]);\n", outerW, outerD, lipH+lidPlate)
|
||
fmt.Fprintf(f, " translate([%f, %f, -0.1])\n", (outerW-innerW)/2, (outerD-innerD)/2)
|
||
fmt.Fprintf(f, " cube([%f, %f, %f]);\n", innerW, innerD, lipH+0.1)
|
||
fmt.Fprintf(f, "}\n")
|
||
|
||
return nil
|
||
}
|
||
|
||
// GenerateBatteryOutputs generates all battery SCAD files for the given config.
|
||
func GenerateBatteryOutputs(cfg *BatteryConfig, outputDir string) ([]string, error) {
|
||
if cfg == nil {
|
||
return nil, fmt.Errorf("no battery config")
|
||
}
|
||
os.MkdirAll(outputDir, 0755)
|
||
|
||
var files []string
|
||
|
||
bayPath := filepath.Join(outputDir, "battery_bay.scad")
|
||
if err := WriteBatteryBaySCAD(bayPath, cfg); err != nil {
|
||
return nil, fmt.Errorf("write battery bay: %v", err)
|
||
}
|
||
files = append(files, bayPath)
|
||
|
||
if cfg.HasLid {
|
||
lidPath := filepath.Join(outputDir, "battery_lid.scad")
|
||
if err := WriteBatteryLidSCAD(lidPath, cfg); err != nil {
|
||
return nil, fmt.Errorf("write battery lid: %v", err)
|
||
}
|
||
files = append(files, lidPath)
|
||
} else {
|
||
sepPath := filepath.Join(outputDir, "battery_separator.scad")
|
||
if err := WriteBatterySeparatorSCAD(sepPath, cfg); err != nil {
|
||
return nil, fmt.Errorf("write battery separator: %v", err)
|
||
}
|
||
files = append(files, sepPath)
|
||
}
|
||
|
||
return files, nil
|
||
}
|