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 }