732 lines
23 KiB
Go
732 lines
23 KiB
Go
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
|
||
}
|