Files
Sotig 0c40983293 ui
2026-05-29 21:16:10 +03:00

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
}