Former/accessory.go

732 lines
23 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package main
import (
"fmt"
"math"
"os"
"path/filepath"
"regexp"
"strconv"
"strings"
)
type AccessoryType string
const (
AccSleeve AccessoryType = "sleeve"
AccCuff AccessoryType = "cuff"
AccSocket AccessoryType = "socket"
AccHolder AccessoryType = "holder"
AccRetainer AccessoryType = "retainer"
AccGasket AccessoryType = "gasket"
AccORing AccessoryType = "oring"
AccEnclosure AccessoryType = "enclosure"
AccSpacer AccessoryType = "spacer"
)
type AccessoryConfig struct {
Type AccessoryType `json:"type"`
Clearance float64 `json:"clearance"`
WallThickness float64 `json:"wallThickness"`
Resolution int `json:"resolution"`
// Sleeve/Cuff
OpenTop bool `json:"openTop"`
OpenBottom bool `json:"openBottom"`
CuffAngle float64 `json:"cuffAngle"`
// Gasket/O-Ring
SliceZ float64 `json:"sliceZ"`
GasketWidth float64 `json:"gasketWidth"`
GasketThick float64 `json:"gasketThick"`
CordDiameter float64 `json:"cordDiameter"`
// Socket/Holder/Retainer
SocketDepth float64 `json:"socketDepth"`
BaseThick float64 `json:"baseThick"`
WallHeight float64 `json:"wallHeight"`
// Retainer clips
ClipCount int `json:"clipCount"`
ClipWidth float64 `json:"clipWidth"`
ClipHeight float64 `json:"clipHeight"`
ClipOverhang float64 `json:"clipOverhang"`
ClipThick float64 `json:"clipThick"`
// Enclosure
LidSplit float64 `json:"lidSplit"`
LipHeight float64 `json:"lipHeight"`
// Spacer
SpacerDepth float64 `json:"spacerDepth"`
CornerRadius float64 `json:"cornerRadius"`
}
type AccessoryResult struct {
SCADSource string `json:"scadSource"`
SCADPath string `json:"scadPath"`
Label string `json:"label"`
}
// objectBBox holds axis-aligned bounding box extracted from object geometry.
type objectBBox struct {
minX, minY, minZ float64
maxX, maxY, maxZ float64
}
func (b objectBBox) width() float64 { return b.maxX - b.minX }
func (b objectBBox) depth() float64 { return b.maxY - b.minY }
func (b objectBBox) height() float64 { return b.maxZ - b.minZ }
func (b objectBBox) centerX() float64 { return (b.minX + b.maxX) / 2 }
func (b objectBBox) centerY() float64 { return (b.minY + b.maxY) / 2 }
func (b objectBBox) centerZ() float64 { return (b.minZ + b.maxZ) / 2 }
func applyAccessoryDefaults(cfg *AccessoryConfig) {
if cfg.Clearance <= 0 {
cfg.Clearance = 0.3
}
if cfg.WallThickness <= 0 {
cfg.WallThickness = 2.0
}
if cfg.Resolution <= 0 {
cfg.Resolution = 32
}
if cfg.BaseThick <= 0 {
cfg.BaseThick = 2.0
}
if cfg.GasketWidth <= 0 {
cfg.GasketWidth = 3.0
}
if cfg.GasketThick <= 0 {
cfg.GasketThick = 2.0
}
if cfg.CordDiameter <= 0 {
cfg.CordDiameter = 3.0
}
if cfg.CuffAngle <= 0 {
cfg.CuffAngle = 270
}
if cfg.ClipCount <= 0 {
cfg.ClipCount = 4
}
if cfg.ClipWidth <= 0 {
cfg.ClipWidth = 5.0
}
if cfg.ClipHeight <= 0 {
cfg.ClipHeight = 8.0
}
if cfg.ClipOverhang <= 0 {
cfg.ClipOverhang = 1.5
}
if cfg.ClipThick <= 0 {
cfg.ClipThick = 1.2
}
if cfg.LipHeight <= 0 {
cfg.LipHeight = 3.0
}
if cfg.SpacerDepth <= 0 {
cfg.SpacerDepth = 3.0
}
}
// GenerateAccessorySCAD produces OpenSCAD source for an accessory that fits
// around/on/through the given object. objectSCAD is the polyhedron() or
// import() source; sourceType is "scad" or "stl".
func GenerateAccessorySCAD(objectSCAD, sourceType string, cfg AccessoryConfig) (string, error) {
applyAccessoryDefaults(&cfg)
bbox, err := extractBBox(objectSCAD, sourceType)
if err != nil {
return "", fmt.Errorf("could not determine object bounds: %w", err)
}
debugLog("GenerateAccessorySCAD: type=%s bbox=(%.1f,%.1f,%.1f)→(%.1f,%.1f,%.1f)",
cfg.Type, bbox.minX, bbox.minY, bbox.minZ, bbox.maxX, bbox.maxY, bbox.maxZ)
var buf strings.Builder
buf.WriteString(fmt.Sprintf("// Accessory: %s\n", cfg.Type))
buf.WriteString(fmt.Sprintf("// Object: %.1f × %.1f × %.1f mm\n", bbox.width(), bbox.depth(), bbox.height()))
buf.WriteString(fmt.Sprintf("// Clearance: %.2f mm, Wall: %.2f mm\n\n", cfg.Clearance, cfg.WallThickness))
writeTargetModule(&buf, objectSCAD, sourceType)
switch cfg.Type {
case AccSleeve:
generateSleeve(&buf, cfg, bbox)
case AccCuff:
generateCuff(&buf, cfg, bbox)
case AccSocket:
generateSocket(&buf, cfg, bbox)
case AccHolder:
generateHolder(&buf, cfg, bbox)
case AccRetainer:
generateRetainer(&buf, cfg, bbox)
case AccGasket:
generateGasket(&buf, cfg, bbox)
case AccORing:
generateORing(&buf, cfg, bbox)
case AccEnclosure:
generateEnclosureAcc(&buf, cfg, bbox)
default:
return "", fmt.Errorf("unknown accessory type: %s", cfg.Type)
}
return buf.String(), nil
}
func writeTargetModule(buf *strings.Builder, objectSCAD, sourceType string) {
buf.WriteString("module target() {\n")
if sourceType == "stl" {
buf.WriteString(fmt.Sprintf(" import(%q);\n", objectSCAD))
} else {
for _, line := range strings.Split(objectSCAD, "\n") {
buf.WriteString(" " + line + "\n")
}
}
buf.WriteString("}\n\n")
}
// extractBBox parses the object geometry to find its axis-aligned bounding box.
func extractBBox(objectSCAD, sourceType string) (objectBBox, error) {
if sourceType == "stl" {
return extractBBoxFromSTL(objectSCAD)
}
return extractBBoxFromSCAD(objectSCAD)
}
func extractBBoxFromSCAD(scadSource string) (objectBBox, error) {
re := regexp.MustCompile(`\[\s*(-?[\d.]+)\s*,\s*(-?[\d.]+)\s*,\s*(-?[\d.]+)\s*\]`)
matches := re.FindAllStringSubmatch(scadSource, -1)
if len(matches) == 0 {
return objectBBox{}, fmt.Errorf("no 3D coordinates found in SCAD source")
}
bb := objectBBox{
minX: math.MaxFloat64, minY: math.MaxFloat64, minZ: math.MaxFloat64,
maxX: -math.MaxFloat64, maxY: -math.MaxFloat64, maxZ: -math.MaxFloat64,
}
for _, m := range matches {
x, _ := strconv.ParseFloat(m[1], 64)
y, _ := strconv.ParseFloat(m[2], 64)
z, _ := strconv.ParseFloat(m[3], 64)
if x < bb.minX {
bb.minX = x
}
if y < bb.minY {
bb.minY = y
}
if z < bb.minZ {
bb.minZ = z
}
if x > bb.maxX {
bb.maxX = x
}
if y > bb.maxY {
bb.maxY = y
}
if z > bb.maxZ {
bb.maxZ = z
}
}
return bb, nil
}
func extractBBoxFromSTL(stlPath string) (objectBBox, error) {
data, err := os.ReadFile(stlPath)
if err != nil {
return objectBBox{}, err
}
tris, err := ReadSTL(data)
if err != nil {
return objectBBox{}, err
}
if len(tris) == 0 {
return objectBBox{}, fmt.Errorf("empty STL")
}
bb := objectBBox{
minX: math.MaxFloat64, minY: math.MaxFloat64, minZ: math.MaxFloat64,
maxX: -math.MaxFloat64, maxY: -math.MaxFloat64, maxZ: -math.MaxFloat64,
}
for _, tri := range tris {
for _, p := range tri {
if p.X < bb.minX {
bb.minX = p.X
}
if p.Y < bb.minY {
bb.minY = p.Y
}
if p.Z < bb.minZ {
bb.minZ = p.Z
}
if p.X > bb.maxX {
bb.maxX = p.X
}
if p.Y > bb.maxY {
bb.maxY = p.Y
}
if p.Z > bb.maxZ {
bb.maxZ = p.Z
}
}
}
return bb, nil
}
// SaveAccessorySCAD writes accessory SCAD to the project output directory.
func SaveAccessorySCAD(scad, outputDir, name string) (string, error) {
os.MkdirAll(outputDir, 0755)
path := filepath.Join(outputDir, name+".scad")
if err := os.WriteFile(path, []byte(scad), 0644); err != nil {
return "", err
}
return path, nil
}
// ===== SCAD Generators =====
func generateSleeve(buf *strings.Builder, cfg AccessoryConfig, bb objectBBox) {
cl := cfg.Clearance
wt := cfg.WallThickness
fn := cfg.Resolution
buf.WriteString(fmt.Sprintf("$fn = %d;\n\n", fn))
buf.WriteString("difference() {\n")
buf.WriteString(fmt.Sprintf(" // Outer shell\n minkowski() {\n target();\n sphere(r=%.4f);\n }\n", cl+wt))
buf.WriteString(fmt.Sprintf(" // Inner cavity\n minkowski() {\n target();\n sphere(r=%.4f);\n }\n", cl))
if cfg.OpenTop {
cutZ := bb.maxZ
buf.WriteString(fmt.Sprintf(" // Open top\n translate([0, 0, %.4f]) cube([%.4f, %.4f, %.4f], center=true);\n",
cutZ, bb.width()+4*(cl+wt)+10, bb.depth()+4*(cl+wt)+10, 2*(cl+wt)+10))
}
if cfg.OpenBottom {
cutZ := bb.minZ
buf.WriteString(fmt.Sprintf(" // Open bottom\n translate([0, 0, %.4f]) cube([%.4f, %.4f, %.4f], center=true);\n",
cutZ, bb.width()+4*(cl+wt)+10, bb.depth()+4*(cl+wt)+10, 2*(cl+wt)+10))
}
buf.WriteString("}\n")
}
func generateCuff(buf *strings.Builder, cfg AccessoryConfig, bb objectBBox) {
cl := cfg.Clearance
wt := cfg.WallThickness
fn := cfg.Resolution
angle := cfg.CuffAngle
buf.WriteString(fmt.Sprintf("$fn = %d;\n\n", fn))
buf.WriteString("intersection() {\n")
// Full sleeve (always open top and bottom for a cuff)
buf.WriteString(" difference() {\n")
buf.WriteString(fmt.Sprintf(" minkowski() { target(); sphere(r=%.4f); }\n", cl+wt))
buf.WriteString(fmt.Sprintf(" minkowski() { target(); sphere(r=%.4f); }\n", cl))
buf.WriteString(fmt.Sprintf(" // Open top\n translate([0, 0, %.4f]) cube([%.4f, %.4f, %.4f], center=true);\n",
bb.maxZ, bb.width()+4*(cl+wt)+10, bb.depth()+4*(cl+wt)+10, 2*(cl+wt)+10))
buf.WriteString(fmt.Sprintf(" // Open bottom\n translate([0, 0, %.4f]) cube([%.4f, %.4f, %.4f], center=true);\n",
bb.minZ, bb.width()+4*(cl+wt)+10, bb.depth()+4*(cl+wt)+10, 2*(cl+wt)+10))
buf.WriteString(" }\n")
// Angular wedge
r := math.Max(bb.width(), bb.depth())/2 + cl + wt + 5
h := bb.height() + 2*(cl+wt) + 10
buf.WriteString(fmt.Sprintf(" // Angular section (%.0f°)\n", angle))
buf.WriteString(fmt.Sprintf(" translate([%.4f, %.4f, %.4f])\n",
bb.centerX(), bb.centerY(), bb.minZ-(cl+wt)-5))
buf.WriteString(fmt.Sprintf(" wedge(%.4f, %.4f, %.4f);\n", angle, r, h))
buf.WriteString("}\n\n")
// Wedge module
buf.WriteString(fmt.Sprintf("module wedge(angle, r, h) {\n"))
buf.WriteString(" if (angle <= 180) {\n")
buf.WriteString(" intersection() {\n")
buf.WriteString(" cylinder(h=h, r=r);\n")
buf.WriteString(" cube([r, r, h]);\n")
buf.WriteString(" rotate([0, 0, -(180-angle)]) cube([r, r, h]);\n")
buf.WriteString(" }\n")
buf.WriteString(" } else {\n")
buf.WriteString(" union() {\n")
buf.WriteString(" intersection() {\n")
buf.WriteString(" cylinder(h=h, r=r);\n")
buf.WriteString(" cube([r, r, h]);\n")
buf.WriteString(" }\n")
buf.WriteString(" intersection() {\n")
buf.WriteString(" cylinder(h=h, r=r);\n")
buf.WriteString(" rotate([0, 0, -(180-angle)]) cube([r, r, h]);\n")
buf.WriteString(" }\n")
buf.WriteString(" intersection() {\n")
buf.WriteString(" cylinder(h=h, r=r);\n")
buf.WriteString(" rotate([0, 0, -(360-angle)/2]) translate([-r, 0, 0]) cube([2*r, r, h]);\n")
buf.WriteString(" }\n")
buf.WriteString(" }\n")
buf.WriteString(" }\n")
buf.WriteString("}\n")
}
func generateSocket(buf *strings.Builder, cfg AccessoryConfig, bb objectBBox) {
cl := cfg.Clearance
wt := cfg.WallThickness
bt := cfg.BaseThick
fn := cfg.Resolution
depth := cfg.SocketDepth
if depth <= 0 {
depth = bb.height() * 0.6
}
outerW := bb.width() + 2*cl + 2*wt
outerD := bb.depth() + 2*cl + 2*wt
outerH := depth + bt
buf.WriteString(fmt.Sprintf("$fn = %d;\n\n", fn))
buf.WriteString("difference() {\n")
buf.WriteString(fmt.Sprintf(" // Base block\n translate([%.4f, %.4f, 0])\n cube([%.4f, %.4f, %.4f], center=false);\n",
bb.centerX()-outerW/2, bb.centerY()-outerD/2, outerW, outerD, outerH))
buf.WriteString(fmt.Sprintf(" // Object cavity\n translate([0, 0, %.4f])\n", bt))
buf.WriteString(fmt.Sprintf(" minkowski() {\n target();\n sphere(r=%.4f);\n }\n", cl))
buf.WriteString("}\n")
}
func generateHolder(buf *strings.Builder, cfg AccessoryConfig, bb objectBBox) {
cl := cfg.Clearance
wt := cfg.WallThickness
bt := cfg.BaseThick
fn := cfg.Resolution
depth := cfg.SocketDepth
if depth <= 0 {
depth = bb.height() * 0.6
}
wallH := cfg.WallHeight
if wallH <= 0 {
wallH = bb.height() * 0.3
}
outerW := bb.width() + 2*cl + 2*wt
outerD := bb.depth() + 2*cl + 2*wt
outerH := depth + bt + wallH
buf.WriteString(fmt.Sprintf("$fn = %d;\n\n", fn))
buf.WriteString("difference() {\n")
buf.WriteString(fmt.Sprintf(" // Outer walls\n translate([%.4f, %.4f, 0])\n cube([%.4f, %.4f, %.4f], center=false);\n",
bb.centerX()-outerW/2, bb.centerY()-outerD/2, outerW, outerD, outerH))
// Inner cavity (object clearance volume, full height above base)
innerW := bb.width() + 2*cl
innerD := bb.depth() + 2*cl
innerH := outerH - bt + 1
buf.WriteString(fmt.Sprintf(" // Inner cavity\n translate([%.4f, %.4f, %.4f])\n cube([%.4f, %.4f, %.4f], center=false);\n",
bb.centerX()-innerW/2, bb.centerY()-innerD/2, bt, innerW, innerD, innerH))
// Object-shaped socket at base
buf.WriteString(fmt.Sprintf(" // Object socket\n translate([0, 0, %.4f])\n", bt))
buf.WriteString(fmt.Sprintf(" minkowski() {\n target();\n sphere(r=%.4f);\n }\n", cl))
buf.WriteString("}\n")
}
func generateRetainer(buf *strings.Builder, cfg AccessoryConfig, bb objectBBox) {
cl := cfg.Clearance
wt := cfg.WallThickness
bt := cfg.BaseThick
fn := cfg.Resolution
depth := cfg.SocketDepth
if depth <= 0 {
depth = bb.height() * 0.6
}
outerW := bb.width() + 2*cl + 2*wt
outerD := bb.depth() + 2*cl + 2*wt
outerH := depth + bt
buf.WriteString(fmt.Sprintf("$fn = %d;\n\n", fn))
// Socket base
buf.WriteString("// Socket base\n")
buf.WriteString("difference() {\n")
buf.WriteString(fmt.Sprintf(" translate([%.4f, %.4f, 0])\n cube([%.4f, %.4f, %.4f], center=false);\n",
bb.centerX()-outerW/2, bb.centerY()-outerD/2, outerW, outerD, outerH))
buf.WriteString(fmt.Sprintf(" translate([0, 0, %.4f])\n", bt))
buf.WriteString(fmt.Sprintf(" minkowski() {\n target();\n sphere(r=%.4f);\n }\n", cl))
buf.WriteString("}\n\n")
// Clips around the perimeter
clipW := cfg.ClipWidth
clipH := cfg.ClipHeight
clipOH := cfg.ClipOverhang
clipT := cfg.ClipThick
count := cfg.ClipCount
buf.WriteString("// Retention clips\n")
r := math.Max(outerW, outerD)/2 + 0.5
for i := 0; i < count; i++ {
angle := float64(i) * 360.0 / float64(count)
buf.WriteString(fmt.Sprintf("translate([%.4f, %.4f, %.4f])\n", bb.centerX(), bb.centerY(), outerH))
buf.WriteString(fmt.Sprintf(" rotate([0, 0, %.1f])\n", angle))
buf.WriteString(fmt.Sprintf(" translate([%.4f, %.4f, 0])\n", r-wt, -clipW/2))
buf.WriteString(fmt.Sprintf(" clip_beam(%.4f, %.4f, %.4f, %.4f);\n", clipW, clipH, clipOH, clipT))
}
buf.WriteString(fmt.Sprintf("\nmodule clip_beam(w, h, overhang, thick) {\n"))
buf.WriteString(" // Cantilever beam\n")
buf.WriteString(" cube([thick, w, h]);\n")
buf.WriteString(" // Overhang hook\n")
buf.WriteString(" translate([-overhang, 0, h - overhang])\n")
buf.WriteString(" cube([thick + overhang, w, overhang]);\n")
buf.WriteString("}\n")
}
func generateGasket(buf *strings.Builder, cfg AccessoryConfig, bb objectBBox) {
cl := cfg.Clearance
fn := cfg.Resolution
sliceZ := cfg.SliceZ
if sliceZ <= 0 {
sliceZ = bb.height() / 2
}
gw := cfg.GasketWidth
gt := cfg.GasketThick
buf.WriteString(fmt.Sprintf("$fn = %d;\n\n", fn))
buf.WriteString("module cross_section() {\n")
buf.WriteString(fmt.Sprintf(" projection(cut=true)\n render()\n translate([0, 0, %.4f])\n target();\n", -sliceZ))
buf.WriteString("}\n\n")
buf.WriteString(fmt.Sprintf("translate([0, 0, %.4f])\n", sliceZ))
buf.WriteString(fmt.Sprintf("linear_extrude(%.4f, center=true)\n", gt))
buf.WriteString(" difference() {\n")
buf.WriteString(fmt.Sprintf(" offset(r=%.4f) cross_section();\n", cl+gw))
buf.WriteString(fmt.Sprintf(" offset(r=%.4f) cross_section();\n", cl))
buf.WriteString(" }\n")
}
func generateORing(buf *strings.Builder, cfg AccessoryConfig, bb objectBBox) {
cl := cfg.Clearance
fn := cfg.Resolution
cd := cfg.CordDiameter
cordR := cd / 2
sliceZ := cfg.SliceZ
if sliceZ <= 0 {
sliceZ = bb.height() / 2
}
debugLog("generateORing: cl=%.2f cd=%.2f sliceZ=%.2f fn=%d", cl, cd, sliceZ, fn)
buf.WriteString(fmt.Sprintf("$fn = %d;\n\n", fn))
buf.WriteString("module cross_section() {\n")
buf.WriteString(fmt.Sprintf(" projection(cut=true)\n render()\n translate([0, 0, %.4f])\n target();\n", -sliceZ))
buf.WriteString("}\n\n")
// Circular cross-section via stacked layers — avoids 3D minkowski which
// is O(n²) on face count and effectively hangs on non-trivial geometry.
// Each layer is a ring at the correct width for that Z slice of the cord circle.
steps := 12
debugLog(" building %d layers, cordR=%.3f", steps, cordR)
for i := 0; i <= steps; i++ {
t := float64(i) / float64(steps)
z := -cordR + cd*t
layerR := math.Sqrt(math.Max(0, cordR*cordR-z*z))
if layerR < 0.001 {
continue
}
innerOff := cl + cordR - layerR
outerOff := cl + cordR + layerR
layerH := cd/float64(steps) + 0.01
debugLog(" layer %d: z=%.3f layerR=%.3f inner=%.3f outer=%.3f", i, z, layerR, innerOff, outerOff)
buf.WriteString(fmt.Sprintf("translate([0, 0, %.4f])\n", sliceZ+z))
buf.WriteString(fmt.Sprintf("linear_extrude(%.4f, center=true)\n", layerH))
buf.WriteString(" difference() {\n")
buf.WriteString(fmt.Sprintf(" offset(r=%.4f) cross_section();\n", outerOff))
buf.WriteString(fmt.Sprintf(" offset(r=%.4f) cross_section();\n", innerOff))
buf.WriteString(" }\n\n")
}
}
func generateEnclosureAcc(buf *strings.Builder, cfg AccessoryConfig, bb objectBBox) {
cl := cfg.Clearance
wt := cfg.WallThickness
fn := cfg.Resolution
splitZ := cfg.LidSplit
if splitZ <= 0 {
splitZ = bb.height() * 0.6
}
lip := cfg.LipHeight
buf.WriteString(fmt.Sprintf("$fn = %d;\n\n", fn))
// Base
buf.WriteString("// ===== Base =====\n")
buf.WriteString("module base() {\n")
buf.WriteString(" difference() {\n")
buf.WriteString(fmt.Sprintf(" minkowski() { target(); sphere(r=%.4f); }\n", cl+wt))
buf.WriteString(fmt.Sprintf(" minkowski() { target(); sphere(r=%.4f); }\n", cl))
buf.WriteString(fmt.Sprintf(" // Cut above split\n translate([0, 0, %.4f])\n cube([%.4f, %.4f, %.4f], center=true);\n",
splitZ+lip/2, bb.width()+4*(cl+wt)+10, bb.depth()+4*(cl+wt)+10, bb.height()+4*(cl+wt)+10))
buf.WriteString(" }\n")
buf.WriteString("}\n\n")
// Lid
buf.WriteString("// ===== Lid =====\n")
buf.WriteString("module lid() {\n")
buf.WriteString(" difference() {\n")
buf.WriteString(fmt.Sprintf(" minkowski() { target(); sphere(r=%.4f); }\n", cl+wt))
buf.WriteString(fmt.Sprintf(" minkowski() { target(); sphere(r=%.4f); }\n", cl))
buf.WriteString(fmt.Sprintf(" // Cut below split\n translate([0, 0, %.4f])\n cube([%.4f, %.4f, %.4f], center=true);\n",
splitZ-lip/2-(bb.height()+4*(cl+wt)+10)/2,
bb.width()+4*(cl+wt)+10, bb.depth()+4*(cl+wt)+10, bb.height()+4*(cl+wt)+10))
buf.WriteString(" }\n")
buf.WriteString("}\n\n")
// Show side-by-side
buf.WriteString("base();\n")
buf.WriteString(fmt.Sprintf("translate([%.4f, 0, 0]) lid();\n", bb.width()+2*(cl+wt)+20))
}
// ===== App Methods =====
func (a *App) GenerateAccessory(objectSCAD string, sourceType string, cfgJS AccessoryConfig) (*AccessoryResult, error) {
debugLog("GenerateAccessory: type=%s sourceType=%s", cfgJS.Type, sourceType)
scad, err := GenerateAccessorySCAD(objectSCAD, sourceType, cfgJS)
if err != nil {
return nil, err
}
a.mu.RLock()
projPath := a.projectPath
a.mu.RUnlock()
outDir := filepath.Join(os.TempDir(), "former-accessory")
if projPath != "" {
outDir = filepath.Join(projPath, "output")
}
name := fmt.Sprintf("%s_accessory", cfgJS.Type)
path, err := SaveAccessorySCAD(scad, outDir, name)
if err != nil {
return nil, err
}
label := fmt.Sprintf("%s (cl=%.1f, wall=%.1f)", cfgJS.Type, cfgJS.Clearance, cfgJS.WallThickness)
debugLog("GenerateAccessory: saved to %s", path)
return &AccessoryResult{
SCADSource: scad,
SCADPath: path,
Label: label,
}, nil
}
// GenerateSpacerSCAD produces OpenSCAD source for a flat plate matching
// a traced face outline, extruded to the given depth.
func GenerateSpacerSCAD(face TracedFace, depth, clearance, cornerRadius float64) string {
centered := centerOutline(face.Outline)
var buf strings.Builder
buf.WriteString(fmt.Sprintf("// Spacer: face %d\n", face.FaceNum))
buf.WriteString(fmt.Sprintf("// Depth: %.2f mm, Clearance: %.2f mm", depth, clearance))
if cornerRadius > 0 {
buf.WriteString(fmt.Sprintf(", Corner radius: %.2f mm", cornerRadius))
}
buf.WriteString("\n\n")
indent := ""
buf.WriteString(fmt.Sprintf("linear_extrude(height=%.4f)\n", depth))
indent = " "
if cornerRadius > 0 {
buf.WriteString(fmt.Sprintf("%soffset(r=%.4f, $fn=32)\n", indent, cornerRadius))
indent += " "
}
if clearance > 0 {
buf.WriteString(fmt.Sprintf("%soffset(delta=%.4f)\n", indent, clearance))
indent += " "
}
buf.WriteString(fmt.Sprintf("%spolygon(points=[\n", indent))
for i, pt := range centered {
comma := ","
if i == len(centered)-1 {
comma = ""
}
buf.WriteString(fmt.Sprintf("%s [%.4f, %.4f]%s\n", indent, pt[0], pt[1], comma))
}
buf.WriteString(fmt.Sprintf("%s]);\n", indent))
return buf.String()
}
func (a *App) GenerateSpacers(facesJS []TracedFaceJS, depth, clearance, cornerRadius float64) ([]AccessoryResult, error) {
if len(facesJS) == 0 {
return nil, fmt.Errorf("no faces provided")
}
if depth <= 0 {
depth = 3.0
}
a.mu.RLock()
projPath := a.projectPath
a.mu.RUnlock()
outDir := filepath.Join(os.TempDir(), "former-accessory")
if projPath != "" {
outDir = filepath.Join(projPath, "output")
}
debugLog("GenerateSpacers: %d faces, depth=%.1f, clearance=%.2f, corner=%.2f",
len(facesJS), depth, clearance, cornerRadius)
var results []AccessoryResult
for _, fjs := range facesJS {
outline := fjs.Outline
if n := len(outline); n > 1 && outline[0] == outline[n-1] {
outline = outline[:n-1]
}
face := TracedFace{FaceNum: fjs.FaceNum, Outline: outline}
scad := GenerateSpacerSCAD(face, depth, clearance, cornerRadius)
name := fmt.Sprintf("spacer_face_%d", face.FaceNum)
path, err := SaveAccessorySCAD(scad, outDir, name)
if err != nil {
return nil, fmt.Errorf("face %d: %w", face.FaceNum, err)
}
label := fmt.Sprintf("Face %d spacer (d=%.1f, cl=%.1f)", face.FaceNum, depth, clearance)
debugLog(" saved %s", path)
results = append(results, AccessoryResult{
SCADSource: scad,
SCADPath: path,
Label: label,
})
}
return results, nil
}
// GetTracedFaces returns persisted face outlines from the project's scan helper data.
func (a *App) GetTracedFaces() []TracedFaceJS {
a.mu.RLock()
defer a.mu.RUnlock()
if a.project == nil || a.project.ScanHelper == nil {
return nil
}
traced := a.project.ScanHelper.TracedFaces
if len(traced) == 0 {
return nil
}
var out []TracedFaceJS
for _, f := range traced {
out = append(out, TracedFaceJS{
FaceNum: f.FaceNum,
Outline: f.Outline,
Vertices: len(f.Outline),
})
}
debugLog("GetTracedFaces: returning %d faces from project", len(out))
return out
}