package slicer import ( "bufio" "context" "encoding/json" "fmt" "math" "os" "path/filepath" "regexp" "strconv" "strings" "time" "github.com/sotigr/slic3r-api/internal/cli" ) // ModelSize holds the bounding box dimensions of the model in millimeters. type ModelSize struct { X float64 `json:"x_mm"` Y float64 `json:"y_mm"` Z float64 `json:"z_mm"` } // FFFResult holds the analysis results for a Fused Filament Fabrication print. type FFFResult struct { PrintTimeSeconds int64 `json:"print_time_seconds"` FilamentWeightG float64 `json:"filament_weight_g"` } // SLAResult holds the analysis results for a Stereolithography (resin) print. type SLAResult struct { PrintTimeSeconds int64 `json:"print_time_seconds"` MaterialVolumeMl float64 `json:"material_volume_ml"` } // AnalyzeOptions configures optional parameters for analysis. type AnalyzeOptions struct { // Scale is the target size in millimeters applied before slicing. // The model is scaled uniformly so its longest axis fits within Scale mm. // A value of 0 means no scaling. Scale float64 } // SlicerService analyzes 3D model files using PrusaSlicer. type SlicerService interface { AnalyzeFFF(modelPath string, opts AnalyzeOptions) (*FFFResult, error) AnalyzeSLA(modelPath string, opts AnalyzeOptions) (*SLAResult, error) } type prusaSlicerService struct { fffConfigPath string slaConfigPath string } // NewSlicerService creates a SlicerService backed by PrusaSlicer CLI. func NewSlicerService(fffConfigPath, slaConfigPath string) SlicerService { return &prusaSlicerService{ fffConfigPath: fffConfigPath, slaConfigPath: slaConfigPath, } } // bedDimensions parses bed_shape and max_print_height from a PrusaSlicer INI config. // Returns the usable bed width (xMM), depth (yMM), and max height (zMM). func bedDimensions(configPath string) (xMM, yMM, zMM float64) { data, err := os.ReadFile(configPath) if err != nil { return } bedShapeRe := regexp.MustCompile(`^bed_shape\s*=\s*(.+)$`) maxHeightRe := regexp.MustCompile(`^max_print_height\s*=\s*([\d.]+)`) pointRe := regexp.MustCompile(`([\d.]+)x([\d.]+)`) minX := math.MaxFloat64 minY := math.MaxFloat64 maxX := -math.MaxFloat64 maxY := -math.MaxFloat64 scanner := bufio.NewScanner(strings.NewReader(string(data))) for scanner.Scan() { line := scanner.Text() if m := bedShapeRe.FindStringSubmatch(line); m != nil { for _, pt := range pointRe.FindAllStringSubmatch(m[1], -1) { x, _ := strconv.ParseFloat(pt[1], 64) y, _ := strconv.ParseFloat(pt[2], 64) if x < minX { minX = x } if x > maxX { maxX = x } if y < minY { minY = y } if y > maxY { maxY = y } } } if m := maxHeightRe.FindStringSubmatch(line); m != nil { zMM, _ = strconv.ParseFloat(m[1], 64) } } if maxX > -math.MaxFloat64 { xMM = maxX - minX yMM = maxY - minY } return } // parsePrintTime converts a PrusaSlicer time string like "3h 27m 26s" into seconds. func parsePrintTime(s string) int64 { var h, m, sec int64 if match := regexp.MustCompile(`(\d+)h`).FindStringSubmatch(s); match != nil { h, _ = strconv.ParseInt(match[1], 10, 64) } if match := regexp.MustCompile(`(\d+)m`).FindStringSubmatch(s); match != nil { m, _ = strconv.ParseInt(match[1], 10, 64) } if match := regexp.MustCompile(`(\d+)s`).FindStringSubmatch(s); match != nil { sec, _ = strconv.ParseInt(match[1], 10, 64) } return h*3600 + m*60 + sec } // scaleArgs returns --scale-to-fit CLI arguments for the given options, or nil if no scaling is needed. // Scale is treated as the target size in mm; the model is scaled uniformly to fit within a cube of that size. func scaleArgs(opts AnalyzeOptions) []string { if opts.Scale > 0 { s := strconv.FormatFloat(opts.Scale, 'f', -1, 64) return []string{"--scale-to-fit", s + "," + s + "," + s} } return nil } // parseModelSize extracts the bounding box from PrusaSlicer --info output. func parseModelSize(output string) ModelSize { re := regexp.MustCompile(`Size:\s*([\d.]+)\s*x\s*([\d.]+)\s*x\s*([\d.]+)`) if m := re.FindStringSubmatch(output); m != nil { x, _ := strconv.ParseFloat(m[1], 64) y, _ := strconv.ParseFloat(m[2], 64) z, _ := strconv.ParseFloat(m[3], 64) return ModelSize{X: x, Y: y, Z: z} } return ModelSize{} } func parseDurationToSeconds(input string) (int, error) { // 1. Remove spaces to format the string for time.ParseDuration // Example: "2h 41m 24s" -> "2h41m24s" cleaned := strings.ReplaceAll(input, " ", "") // 2. Parse the string into a time.Duration object d, err := time.ParseDuration(cleaned) if err != nil { return 0, err } // 3. Convert duration to seconds (as an integer) return int(d.Seconds()), nil } func (s *prusaSlicerService) AnalyzeFFF(modelPath string, opts AnalyzeOptions) (*FFFResult, error) { tmpDir, err := os.MkdirTemp("", "slicer-fff-*") if err != nil { return nil, fmt.Errorf("create temp dir: %w", err) } defer os.RemoveAll(tmpDir) outputPath := filepath.Join("./", "output.gcode") args := []string{"--load", s.fffConfigPath, "--export-gcode", "--info", "--output", outputPath} args = append(args, scaleArgs(opts)...) args = append(args, modelPath) pipe := cli.Pipe{ Command: "prusa-slicer", Args: args, } _, _, err = pipe.ExecuteCombined(context.Background()) if err != nil { return nil, fmt.Errorf("prusa-slicer: %w", err) } gcodeRe := regexp.MustCompile(`\W?\;\W?(.*)=(.*)`) b, err := os.ReadFile(outputPath) if err != nil { return nil, err } gcodeLines := strings.Split(string(b), "\n") filamentGrams := 0.0 time := 0 for _, line := range gcodeLines { l := strings.TrimSpace(line) if strings.HasPrefix(l, ";") { matches := gcodeRe.FindStringSubmatch(l) if len(matches) > 0 { key := strings.TrimSpace(matches[1]) value := strings.TrimSpace(matches[2]) switch key { case "filament used [g]": filamentGrams, _ = strconv.ParseFloat(value, 32) } if strings.HasPrefix(key, "estimated printing time") { time, _ = parseDurationToSeconds(value) } } } } return &FFFResult{ PrintTimeSeconds: int64(time), FilamentWeightG: filamentGrams, }, nil } type slaPwmxOutputConfig struct { UsedMaterial float32 `json:"usedMaterial"` PrintTimeSeconds float32 `json:"printTime"` } func (s *prusaSlicerService) AnalyzeSLA(modelPath string, opts AnalyzeOptions) (*SLAResult, error) { tmpDir, err := os.MkdirTemp("", "slicer-sla-*") if err != nil { return nil, fmt.Errorf("create temp dir: %w", err) } defer os.RemoveAll(tmpDir) outputPath := filepath.Join("./", "output.pwmx") args := []string{"--load", s.slaConfigPath, "--export-sla", "--info", "--output", outputPath} args = append(args, scaleArgs(opts)...) args = append(args, modelPath) pipe := cli.Pipe{ Command: "prusa-slicer", Args: args, } _, _, err = pipe.Execute(context.Background()) if err != nil { return nil, fmt.Errorf("prusa-slicer: %w", err) } pipe = cli.Pipe{ SuffixSymbol: ">", Command: "unzip", Args: []string{"-p", outputPath, "config.json"}, } outputPipe := cli.Pipe{ Command: "config.json", } _, _, err = cli.ExecuteCLIPipeLine(context.Background(), []cli.Pipe{ pipe, outputPipe, }) if err != nil { return nil, fmt.Errorf("sla-analysi: %w", err) } file, err := os.ReadFile("config.json") if err != nil { return nil, err } var outputCfg slaPwmxOutputConfig err = json.Unmarshal(file, &outputCfg) if err != nil { return nil, err } return &SLAResult{ PrintTimeSeconds: int64(outputCfg.PrintTimeSeconds), MaterialVolumeMl: float64(outputCfg.UsedMaterial), }, nil }