package main import ( "fmt" "io" "os" "path/filepath" "strings" ) type VWStencilConfigJS struct { Clearance float64 `json:"clearance"` WallThickness float64 `json:"wallThickness"` TranslateX float64 `json:"translateX"` TranslateY float64 `json:"translateY"` Rotation float64 `json:"rotation"` ScaleX float64 `json:"scaleX"` ScaleY float64 `json:"scaleY"` } type VWStencilResultJS struct { SCADSource string `json:"scadSource"` SCADPath string `json:"scadPath"` } // GenerateVectorWrapStencil creates a SCAD file: thin shell conforming to the // object surface with the SVG vector art cut through as stencil openings. func (a *App) GenerateVectorWrapStencil(cfg VWStencilConfigJS) (*VWStencilResultJS, error) { a.mu.RLock() vws := a.vectorWrapSession projPath := a.projectPath a.mu.RUnlock() if vws == nil { return nil, fmt.Errorf("no vector wrap session active") } if vws.SVGPath == "" { return nil, fmt.Errorf("no SVG loaded for vector wrap") } debugLog("GenerateVectorWrapStencil: svg=%s model=%s (%s)", vws.SVGPath, vws.ModelPath, vws.ModelType) if cfg.Clearance <= 0 { cfg.Clearance = 0.2 } if cfg.WallThickness <= 0 { cfg.WallThickness = 0.8 } if cfg.ScaleX == 0 { cfg.ScaleX = 1 } if cfg.ScaleY == 0 { cfg.ScaleY = 1 } outDir := formerTempDir() if projPath != "" { outDir = filepath.Join(projPath, "output") } os.MkdirAll(outDir, 0755) // Copy SVG to output directory so the SCAD can reference it svgDest := filepath.Join(outDir, "stencil_art.svg") if err := copyFile(vws.SVGPath, svgDest); err != nil { return nil, fmt.Errorf("copy SVG: %w", err) } // Determine object source objectSCAD, sourceType, err := a.resolveObjectSource(vws) if err != nil { return nil, err } // If STL, copy it to output dir too stlRef := "" if sourceType == "stl" { stlDest := filepath.Join(outDir, "stencil_model.stl") if err := os.WriteFile(stlDest, vws.ModelSTL, 0644); err != nil { return nil, fmt.Errorf("write STL: %w", err) } stlRef = "stencil_model.stl" } // Get SVG dimensions svgW, svgH := 100.0, 100.0 if vws.SVGDoc != nil { svgW = vws.SVGDoc.Width svgH = vws.SVGDoc.Height } scad := buildVWStencilSCAD(objectSCAD, sourceType, stlRef, svgW, svgH, cfg) scadPath := filepath.Join(outDir, "wrap_stencil.scad") if err := os.WriteFile(scadPath, []byte(scad), 0644); err != nil { return nil, fmt.Errorf("write SCAD: %w", err) } debugLog("GenerateVectorWrapStencil: wrote %s (%d bytes)", scadPath, len(scad)) return &VWStencilResultJS{ SCADSource: scad, SCADPath: scadPath, }, nil } func (a *App) resolveObjectSource(vws *VectorWrapSession) (string, string, error) { // SCAD source if vws.ModelType == "scad" && vws.ModelPath != "" { data, err := os.ReadFile(vws.ModelPath) if err != nil { return "", "", fmt.Errorf("read SCAD model: %w", err) } return string(data), "scad", nil } // STL if vws.ModelType == "stl" && len(vws.ModelSTL) > 0 { return "", "stl", nil } // Project object — check for reconstructed.scad a.mu.RLock() projPath := a.projectPath a.mu.RUnlock() if projPath != "" { scadPath := filepath.Join(projPath, "output", "reconstructed.scad") if data, err := os.ReadFile(scadPath); err == nil && len(data) > 10 { return string(data), "scad", nil } } // Reconstructed STL in vectorwrap dir if vws.ModelPath != "" && len(vws.ModelSTL) > 0 { return "", "stl", nil } return "", "", fmt.Errorf("no object source found") } func buildVWStencilSCAD(objectSCAD, sourceType, stlRef string, svgW, svgH float64, cfg VWStencilConfigJS) string { cl := cfg.Clearance wt := cfg.WallThickness var buf strings.Builder buf.WriteString("// Vector Wrap Stencil — fits around the object for painting/marking\n") buf.WriteString(fmt.Sprintf("// Shell: clearance=%.2fmm, wall=%.2fmm\n\n", cl, wt)) buf.WriteString("$fn = 32;\n\n") // Target object module buf.WriteString("module target() {\n") if sourceType == "stl" { buf.WriteString(fmt.Sprintf(" import(%q);\n", stlRef)) } else { for _, line := range strings.Split(objectSCAD, "\n") { buf.WriteString(" " + line + "\n") } } buf.WriteString("}\n\n") // Shell module buf.WriteString("module shell() {\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(" }\n") buf.WriteString("}\n\n") // SVG art module with user transforms buf.WriteString("module art() {\n") buf.WriteString(fmt.Sprintf(" translate([%.4f, %.4f, 0])\n", cfg.TranslateX, cfg.TranslateY)) buf.WriteString(fmt.Sprintf(" rotate([0, 0, %.4f])\n", cfg.Rotation)) buf.WriteString(fmt.Sprintf(" scale([%.4f, %.4f, 1])\n", cfg.ScaleX, cfg.ScaleY)) // Center the SVG around origin buf.WriteString(fmt.Sprintf(" translate([%.4f, %.4f, 0])\n", -svgW/2, -svgH/2)) buf.WriteString(" import(\"stencil_art.svg\");\n") buf.WriteString("}\n\n") // Stencil: shell minus SVG projected from all 6 cardinal directions buf.WriteString("// Stencil: shell with SVG art cut through from all directions\n") buf.WriteString("difference() {\n") buf.WriteString(" shell();\n\n") // Project from Z+ (top-down) buf.WriteString(" // Top-down projection\n") buf.WriteString(" linear_extrude(height=500, center=true)\n") buf.WriteString(" art();\n\n") // Project from Z- (bottom-up) — same as top-down for extrude center=true // Already covered by center=true // Project from X+ (right side) buf.WriteString(" // Right side projection\n") buf.WriteString(" rotate([90, 0, 90])\n") buf.WriteString(" linear_extrude(height=500, center=true)\n") buf.WriteString(" art();\n\n") // Project from Y+ (front) buf.WriteString(" // Front projection\n") buf.WriteString(" rotate([90, 0, 0])\n") buf.WriteString(" linear_extrude(height=500, center=true)\n") buf.WriteString(" art();\n") buf.WriteString("}\n") return buf.String() } func copyFile(src, dst string) error { in, err := os.Open(src) if err != nil { return err } defer in.Close() out, err := os.Create(dst) if err != nil { return err } defer out.Close() _, err = io.Copy(out, in) return err }