283 lines
7.5 KiB
Go
283 lines
7.5 KiB
Go
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
|
|
}
|