diff --git a/cmd/actions.go b/cmd/actions.go index f647f20..118146d 100644 --- a/cmd/actions.go +++ b/cmd/actions.go @@ -1,42 +1,276 @@ package main -import "net/http" +import ( + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "os" + "path/filepath" + "strconv" + "strings" + "time" + + "github.com/google/uuid" + "github.com/sotigr/slic3r-api/internal/cache" + "github.com/sotigr/slic3r-api/internal/slicer" + "github.com/sotigr/slic3r-api/internal/thumbnail" +) + +var allowedModelExtensions = map[string]bool{ + ".stl": true, + ".3mf": true, + ".obj": true, + ".amf": true, +} + +func getEnvOrDefault(key, defaultVal string) string { + if v := os.Getenv(key); v != "" { + return v + } + return defaultVal +} func actions(mux *http.ServeMux) { + slicerSvc := slicer.NewSlicerService( + getEnvOrDefault("FFF_CONFIG_PATH", "configs/p1s-cfg.ini"), + getEnvOrDefault("SLA_CONFIG_PATH", "configs/gktwo-sla-cfg.ini"), + ) + thumbnailSvc := thumbnail.NewThumbnailService() + + var c cache.Cache + if url := getEnvOrDefault("VALKEY_URL", ""); url != "" { + vc, err := cache.NewValkeyCache(url) + if err != nil { + log.Printf("cache: failed to connect to Valkey at %s: %v — running without cache", url, err) + c = cache.NewNoopCache() + } else { + log.Printf("cache: connected to Valkey at %s", url) + c = vc + } + } else { + log.Print("cache: VALKEY_URL not set — running without cache") + c = cache.NewNoopCache() + } + + cacheTTL := 24 * time.Hour + if s := getEnvOrDefault("CACHE_TTL_SECONDS", ""); s != "" { + if v, err := strconv.ParseInt(s, 10, 64); err == nil && v > 0 { + cacheTTL = time.Duration(v) * time.Second + } + } mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodGet { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } - w.WriteHeader(http.StatusOK) w.Write([]byte("OK")) - }) mux.HandleFunc("/analyze-fff", func(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } - w.WriteHeader(http.StatusOK) - w.Write([]byte("OK")) + modelPath, cleanup, err := saveUploadedFile(r, "file") + if err != nil { + http.Error(w, fmt.Sprintf("failed to read file: %v", err), http.StatusBadRequest) + return + } + defer cleanup() + opts, err := parseAnalyzeOptions(r) + if err != nil { + http.Error(w, fmt.Sprintf("invalid options: %v", err), http.StatusBadRequest) + return + } + + fileHash, err := cache.HashFile(modelPath) + if err != nil { + http.Error(w, "failed to hash file", http.StatusInternalServerError) + return + } + key := cache.AnalysisKey("fff", fileHash, opts.Scale) + + if cached, ok := c.Get(r.Context(), key); ok { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + w.Write(cached) + return + } + + result, err := slicerSvc.AnalyzeFFF(modelPath, opts) + if err != nil { + http.Error(w, fmt.Sprintf("analysis failed: %v", err), http.StatusInternalServerError) + return + } + + payload, _ := json.Marshal(result) + c.Set(r.Context(), key, payload, cacheTTL) + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + w.Write(payload) }) mux.HandleFunc("/analyze-sla", func(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } - w.WriteHeader(http.StatusOK) - w.Write([]byte("OK")) + modelPath, cleanup, err := saveUploadedFile(r, "file") + if err != nil { + http.Error(w, fmt.Sprintf("failed to read file: %v", err), http.StatusBadRequest) + return + } + defer cleanup() + opts, err := parseAnalyzeOptions(r) + if err != nil { + http.Error(w, fmt.Sprintf("invalid options: %v", err), http.StatusBadRequest) + return + } + + fileHash, err := cache.HashFile(modelPath) + if err != nil { + http.Error(w, "failed to hash file", http.StatusInternalServerError) + return + } + key := cache.AnalysisKey("sla", fileHash, opts.Scale) + + if cached, ok := c.Get(r.Context(), key); ok { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + w.Write(cached) + return + } + + result, err := slicerSvc.AnalyzeSLA(modelPath, opts) + if err != nil { + http.Error(w, fmt.Sprintf("analysis failed: %v", err), http.StatusInternalServerError) + return + } + + payload, _ := json.Marshal(result) + c.Set(r.Context(), key, payload, cacheTTL) + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + w.Write(payload) + }) + + mux.HandleFunc("/thumbnail", func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + modelPath, cleanup, err := saveUploadedFile(r, "file") + if err != nil { + http.Error(w, fmt.Sprintf("failed to read file: %v", err), http.StatusBadRequest) + return + } + defer cleanup() + + fileHash, err := cache.HashFile(modelPath) + if err != nil { + http.Error(w, "failed to hash file", http.StatusInternalServerError) + return + } + key := cache.ThumbnailKey(fileHash) + + if cached, ok := c.Get(r.Context(), key); ok { + w.Header().Set("Content-Type", "image/webp") + w.WriteHeader(http.StatusOK) + w.Write(cached) + return + } + + outputPath := filepath.Join(filepath.Dir(modelPath), "thumbnail.webp") + + if err := thumbnailSvc.Generate(r.Context(), modelPath, outputPath); err != nil { + http.Error(w, fmt.Sprintf("thumbnail generation failed: %v", err), http.StatusInternalServerError) + return + } + + pngData, err := os.ReadFile(outputPath) + os.Remove(outputPath) + if err != nil { + http.Error(w, "failed to read generated thumbnail", http.StatusInternalServerError) + return + } + + c.Set(r.Context(), key, pngData, cacheTTL) + + w.Header().Set("Content-Type", "image/webp") + w.WriteHeader(http.StatusOK) + w.Write(pngData) }) } + +// saveUploadedFile reads the named file field from a multipart request and saves it to a +// temporary directory. Returns the saved path and a cleanup function to remove the directory. +func saveUploadedFile(r *http.Request, field string) (string, func(), error) { + const maxUploadSize = 512 << 20 // 64 MB + if err := r.ParseMultipartForm(maxUploadSize); err != nil { + return "", nil, fmt.Errorf("parse multipart form: %w", err) + } + + file, header, err := r.FormFile(field) + if err != nil { + return "", nil, fmt.Errorf("get form field %q: %w", field, err) + } + defer file.Close() + + origName := filepath.Base(header.Filename) + ext := strings.ToLower(filepath.Ext(origName)) + if !allowedModelExtensions[ext] { + return "", nil, fmt.Errorf("unsupported file format %q, allowed: .stl .3mf .obj .amf", ext) + } + + tmpDir, err := os.MkdirTemp("", "upload-*") + if err != nil { + return "", nil, fmt.Errorf("create temp dir: %w", err) + } + cleanup := func() { os.RemoveAll(tmpDir) } + + dst := filepath.Join(tmpDir, uuid.New().String()+ext) + f, err := os.Create(dst) + if err != nil { + cleanup() + return "", nil, fmt.Errorf("create temp file: %w", err) + } + defer f.Close() + + if _, err := io.Copy(f, file); err != nil { + cleanup() + return "", nil, fmt.Errorf("write uploaded file: %w", err) + } + + return dst, cleanup, nil +} + +// parseAnalyzeOptions reads optional analysis parameters from the multipart form. +// Supported fields: scale (float, e.g. "1.5" = 150% size). +func parseAnalyzeOptions(r *http.Request) (slicer.AnalyzeOptions, error) { + var opts slicer.AnalyzeOptions + if s := r.FormValue("scale"); s != "" { + scale, err := strconv.ParseFloat(s, 64) + if err != nil || scale <= 0 { + return opts, fmt.Errorf("scale must be a positive number of millimeters, got %q", s) + } + opts.Scale = scale + } + return opts, nil +} + +func writeJSON(w http.ResponseWriter, v any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(v) +} diff --git a/cmd/main.go b/cmd/main.go index 4ca85c4..b409087 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -4,11 +4,14 @@ import ( "fmt" "net/http" "os" + "runtime/debug" "golang.org/x/net/http2" ) func main() { + var memLimitMb int64 = 512 + debug.SetMemoryLimit(memLimitMb * 1024 * 1024) // Your code here mux := http.NewServeMux() diff --git a/config.json b/config.json new file mode 100644 index 0000000..ffae9be --- /dev/null +++ b/config.json @@ -0,0 +1,63 @@ +{ + "action": "print", + "expTime": 3, + "expTimeFirst": 30, + "expUserProfile": 0, + "fileCreationTimestamp": "2026-04-10 at 07:29:05 UTC", + "hollow": 0, + "jobDir": "output", + "layerHeight": 0.03, + "materialName": "Prusament Resin Tough Prusa Orange @0.05", + "numFade": 10, + "numFast": 1063, + "numSlow": 0, + "printProfile": "0.05 Normal", + "printTime": 9128.405000, + "printerModel": "SL1", + "printerProfile": "Original Prusa SL1", + "printerVariant": "default", + "prusaSlicerVersion": "PrusaSlicer-2.9.4+Fedora", + "usedMaterial": 28.229211, + "version": 1, + "exposure_profile": { + "area_fill": 35, + "below_area_fill": { + "delay_before_exposure_ms": 0, + "delay_after_exposure_ms": 0, + "tower_hop_height_nm": 0, + "tower_profile": "layer22", + "use_tilt": true, + "tilt_down_initial_profile": "layer400", + "tilt_down_offset_steps": 0, + "tilt_down_offset_delay_ms": 0, + "tilt_down_finish_profile": "layer1750", + "tilt_down_cycles": 1, + "tilt_down_delay_ms": 0, + "tilt_up_initial_profile": "move5120", + "tilt_up_offset_steps": 400, + "tilt_up_offset_delay_ms": 0, + "tilt_up_finish_profile": "layer400", + "tilt_up_cycles": 1, + "tilt_up_delay_ms": 0 + }, + "above_area_fill": { + "delay_before_exposure_ms": 1000, + "delay_after_exposure_ms": 0, + "tower_hop_height_nm": 0, + "tower_profile": "layer22", + "use_tilt": true, + "tilt_down_initial_profile": "layer400", + "tilt_down_offset_steps": 0, + "tilt_down_offset_delay_ms": 0, + "tilt_down_finish_profile": "layer1500", + "tilt_down_cycles": 1, + "tilt_down_delay_ms": 0, + "tilt_up_initial_profile": "move5120", + "tilt_up_offset_steps": 400, + "tilt_up_offset_delay_ms": 0, + "tilt_up_finish_profile": "layer400", + "tilt_up_cycles": 1, + "tilt_up_delay_ms": 0 + } + } +} diff --git a/docker/Dockerfile b/docker/Dockerfile index fbd8519..43700a4 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -1,39 +1,16 @@ -FROM ubuntu:plucky - -RUN echo "deb https://ppa.launchpadcontent.net/longsleep/golang-backports/ubuntu ubuntu-plucky main" | tee /etc/apt/sources.list.d/docker.list -RUN apt update - -ENV GO_VERSION=1.24 - -RUN apt install -y golang-${GO_VERSION} -RUN ln -s "/usr/lib/go-${GO_VERSION}/bin/go" /usr/local/bin/ - -RUN apt-get clean && apt-get update && apt-get install -y locales wget -RUN locale-gen en_US.UTF-8 -RUN update-locale LC_ALL=en_US.UTF-8 LANG=en_US.UTF-8 -ENV LANG=en_US.UTF-8 -ENV LC_CTYPE="en_US.UTF-8" -ENV LC_NUMERIC="en_US.UTF-8" -ENV LC_TIME="en_US.UTF-8" -ENV LC_COLLATE="en_US.UTF-8" -ENV LC_MONETARY="en_US.UTF-8" -ENV LC_MESSAGES="en_US.UTF-8" -ENV LC_PAPER="en_US.UTF-8" -ENV LC_NAME="en_US.UTF-8" -ENV LC_ADDRESS="en_US.UTF-8" -ENV LC_TELEPHONE="en_US.UTF-8" -ENV LC_MEASUREMENT="en_US.UTF-8" -ENV LC_IDENTIFICATION="en_US.UTF-8" -ENV LC_ALL= - -RUN apt install -y prusa-slicer - -WORKDIR /build - -COPY ../ . - -RUN go build -o /bin/slic3r-api ./cmd - -RUN rm -rf /build/* +FROM fedora:45 -CMD [ "/bin/slic3r-api" ] \ No newline at end of file +RUN dnf install -y golang prusa-slicer f3d glibc-langpack-en nodejs npm && dnf clean all +RUN npm i -g nodemon@3.1.0 + +ENV LANG=en_US.UTF-8 + +WORKDIR /src + +COPY ../docker/nodemon.json /run/nodemon.json + +RUN dnf install fuser unzip -y + +ENV LIBGL_ALWAYS_SOFTWARE=1 + +CMD [ "nodemon", "--config", "docker/nodemon.json" ] diff --git a/docker/Dockerfile.prod b/docker/Dockerfile.prod new file mode 100644 index 0000000..8ead96f --- /dev/null +++ b/docker/Dockerfile.prod @@ -0,0 +1,22 @@ +FROM fedora:45 + +RUN dnf install -y golang prusa-slicer f3d glibc-langpack-en && dnf clean all + +ENV LANG=en_US.UTF-8 + +WORKDIR /build + +COPY ../uvtools /usr/share/uvtools +COPY ../go.mod go.mod +COPY ../go.sum go.sum +COPY ../cmd cmd +COPY ../configs configs +COPY ../internal internal + +RUN go build -o /bin/slic3r-api ./cmd + +RUN rm -rf /build/* + +ENV LIBGL_ALWAYS_SOFTWARE=1 + +CMD [ "/bin/slic3r-api" ] diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 9ed9688..4419daa 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -1,15 +1,29 @@ services: slic3r-api: - build: + build: context: ../ dockerfile: docker/Dockerfile - command: /bin/bash -c "cd /src && go run ./cmd" volumes: - ../:/src - ../configs:/configs environment: - PORT=3030 - - HOST=0.0.0.0 - - CONFIGS=/configs/ + - HOST=0.0.0.0 + - CONFIGS=/configs/ + - VALKEY_URL=redis://valkey:6379 ports: - - 3030:3030 \ No newline at end of file + - 3030:3030 + depends_on: + valkey: + condition: service_healthy + + valkey: + image: valkey/valkey:8-alpine + command: valkey-server --maxmemory 256mb --maxmemory-policy allkeys-lru --appendonly yes + ports: + - 6379:6379 + healthcheck: + test: ["CMD", "valkey-cli", "ping"] + interval: 5s + timeout: 3s + retries: 5 \ No newline at end of file diff --git a/docker/nodemon.json b/docker/nodemon.json new file mode 100644 index 0000000..a963804 --- /dev/null +++ b/docker/nodemon.json @@ -0,0 +1,7 @@ +{ + "verbose": true, + "ext": "go,mod", + "ignore": ["/src/vendor/*", "/src/build/*", "/src/.git/*"], + "exec": "fuser -n tcp -k 3030 || go run ./cmd", + "signal": "SIGKILL" +} \ No newline at end of file diff --git a/go.mod b/go.mod index 9f499fd..f2e87e7 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,11 @@ module github.com/sotigr/slic3r-api go 1.24.2 require ( + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/redis/go-redis/v9 v9.18.0 // indirect + go.uber.org/atomic v1.11.0 // indirect golang.org/x/net v0.41.0 // indirect golang.org/x/text v0.26.0 // indirect ) diff --git a/go.sum b/go.sum index bf3398a..d230869 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,13 @@ +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/redis/go-redis/v9 v9.18.0 h1:pMkxYPkEbMPwRdenAzUNyFNrDgHx9U+DrBabWNfSRQs= +github.com/redis/go-redis/v9 v9.18.0/go.mod h1:k3ufPphLU5YXwNTUcCRXGxUoF1fqxnhFQmscfkCoDA0= +go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= +go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= golang.org/x/net v0.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw= golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA= golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M= diff --git a/internal/cache/cache.go b/internal/cache/cache.go new file mode 100644 index 0000000..c9f0657 --- /dev/null +++ b/internal/cache/cache.go @@ -0,0 +1,61 @@ +package cache + +import ( + "context" + "time" + + "github.com/redis/go-redis/v9" +) + +// Cache is a minimal interface for key/value byte storage with TTL. +// Both the Valkey implementation and the no-op satisfy it. +type Cache interface { + Get(ctx context.Context, key string) ([]byte, bool) + Set(ctx context.Context, key string, value []byte, ttl time.Duration) +} + +// valkeyCache wraps a go-redis client connected to a Valkey (or Redis) server. +type valkeyCache struct { + client *redis.Client +} + +// NewValkeyCache parses url (e.g. "redis://localhost:6379"), pings the server, +// and returns a ready Cache. Returns an error if the server is unreachable. +func NewValkeyCache(url string) (Cache, error) { + opts, err := redis.ParseURL(url) + if err != nil { + return nil, err + } + client := redis.NewClient(opts) + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if err := client.Ping(ctx).Err(); err != nil { + client.Close() + return nil, err + } + return &valkeyCache{client: client}, nil +} + +func (c *valkeyCache) Get(ctx context.Context, key string) ([]byte, bool) { + val, err := c.client.Get(ctx, key).Bytes() + if err != nil { + return nil, false + } + return val, true +} + +func (c *valkeyCache) Set(ctx context.Context, key string, value []byte, ttl time.Duration) { + // Best-effort: cache errors are non-fatal. + c.client.Set(ctx, key, value, ttl) +} + +// noopCache is used when VALKEY_URL is not configured. +// All Gets miss; all Sets are discarded. +type noopCache struct{} + +// NewNoopCache returns a Cache that never stores anything. +func NewNoopCache() Cache { return &noopCache{} } + +func (c *noopCache) Get(_ context.Context, _ string) ([]byte, bool) { return nil, false } +func (c *noopCache) Set(_ context.Context, _ string, _ []byte, _ time.Duration) { +} diff --git a/internal/cache/key.go b/internal/cache/key.go new file mode 100644 index 0000000..315da2f --- /dev/null +++ b/internal/cache/key.go @@ -0,0 +1,34 @@ +package cache + +import ( + "crypto/sha256" + "fmt" + "io" + "os" +) + +// HashFile computes the SHA-256 hex digest of the file at path. +func HashFile(path string) (string, error) { + f, err := os.Open(path) + if err != nil { + return "", err + } + defer f.Close() + h := sha256.New() + if _, err := io.Copy(h, f); err != nil { + return "", err + } + return fmt.Sprintf("%x", h.Sum(nil)), nil +} + +// AnalysisKey builds a cache key for FFF or SLA analysis. +// Format: "::scale=" +// scale uses %.6g so "1.50" and "1.5" map to the same key. +func AnalysisKey(endpoint, fileHash string, scale float64) string { + return fmt.Sprintf("%s:%s:scale=%.6g", endpoint, fileHash, scale) +} + +// ThumbnailKey builds a cache key for thumbnail generation. +func ThumbnailKey(fileHash string) string { + return "thumbnail:" + fileHash +} diff --git a/internal/cli/cli.go b/internal/cli/cli.go index 9b62dd4..39ac1bd 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -54,8 +54,9 @@ func execNoCheck(ctx context.Context, command string, args []string) (string, st } type Pipe struct { - Command string - Args []string + Command string + SuffixSymbol string + Args []string } func (p *Pipe) IsSanatory() bool { @@ -70,6 +71,22 @@ func (p *Pipe) Execute(ctx context.Context) (string, string, error) { } } +// ExecuteCombined runs the command and returns combined stdout+stderr output. +// Useful when a tool writes useful output to either stream. +func (p *Pipe) ExecuteCombined(ctx context.Context) (string, string, error) { + if !p.IsSanatory() { + return "", "", errors.New("failed to execute cli command: " + p.Command + " " + strings.Join(p.Args, " ") + " some arguments are not allowed") + } + cmd := exec.CommandContext(ctx, p.Command, p.Args...) + + cmdStr := cmd.String() + out, err := cmd.CombinedOutput() + if err != nil { + return "", cmdStr, errors.New(err.Error() + " | " + string(out)) + } + return string(out), cmdStr, nil +} + func ExecuteCLIPipeLine(ctx context.Context, pipes []Pipe) (string, string, error) { pipeLen := len(pipes) @@ -94,7 +111,11 @@ func ExecuteCLIPipeLine(ctx context.Context, pipes []Pipe) (string, string, erro for i, p := range pipes { cmdArg += p.Command + " " + strings.Join(p.Args, " ") if i != pipeLen { - cmdArg += " | " + if p.SuffixSymbol != "" { + cmdArg += " " + p.SuffixSymbol + " " + } else { + cmdArg += " | " + } } } diff --git a/internal/slicer/slicer.go b/internal/slicer/slicer.go new file mode 100644 index 0000000..03af304 --- /dev/null +++ b/internal/slicer/slicer.go @@ -0,0 +1,282 @@ +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 +} diff --git a/internal/thumbnail/thumbnail.go b/internal/thumbnail/thumbnail.go new file mode 100644 index 0000000..d60f9f4 --- /dev/null +++ b/internal/thumbnail/thumbnail.go @@ -0,0 +1,46 @@ +package thumbnail + +import ( + "context" + "fmt" + + "github.com/sotigr/slic3r-api/internal/cli" +) + +// ThumbnailService generates preview images of 3D model files. +type ThumbnailService interface { + Generate(ctx context.Context, modelPath, outputPath string) error +} + +type f3dService struct{} + +// NewThumbnailService creates a ThumbnailService backed by the f3d renderer. +func NewThumbnailService() ThumbnailService { + return &f3dService{} +} + +func (s *f3dService) Generate(ctx context.Context, modelPath, outputPath string) error { + pipe := cli.Pipe{ + Command: "f3d", + Args: []string{ + modelPath, + "--resolution=128,128", + "-a", + "--anti-aliasing-mode=taa", + "--filename=false", + "--metadata=false", + "--color=#999", + "-t", + "--grid=false", + "--axis=false", + "--no-background", + "--output=" + outputPath, + }, + } + + _, _, err := pipe.Execute(ctx) + if err != nil { + return fmt.Errorf("f3d: %w", err) + } + return nil +} diff --git a/internal/valkeydb/db.go b/internal/valkeydb/db.go new file mode 100644 index 0000000..a75ef89 --- /dev/null +++ b/internal/valkeydb/db.go @@ -0,0 +1,89 @@ +package valkeydb + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/google/uuid" + "github.com/redis/go-redis/v9" +) + +type DbItem map[string]any + +type ValkeyDB struct { + client *redis.Client + name string +} + +func NewValkeyDB(client *redis.Client, dbName string) *ValkeyDB { + return &ValkeyDB{ + client: client, + name: dbName, + } +} + +func (db *ValkeyDB) GetName() string { + return db.name +} + +type Table[T any] struct { + Name string + db *ValkeyDB +} + +func NewTable[T any](db *ValkeyDB, tableName string) *Table[T] { + return &Table[T]{ + Name: fmt.Sprintf("%s:%s", db.GetName(), tableName), + db: db, + } +} + +func (t *Table[T]) createItemHash(id string) string { + return fmt.Sprintf("%s:%s", t.Name, id) +} + +func (t *Table[T]) createNewId() (string, string) { + newId := uuid.New().String() + hash := t.createItemHash(newId) + return hash, newId +} + +func (t *Table[T]) Insert(ctx context.Context, item *T) (string, error) { + hash, newId := t.createNewId() + jItem, err := json.Marshal(t) + if err != nil { + return "", err + } + cmd := t.db.client.HSet(ctx, hash, "$", string(jItem)) + if cmd.Err() != nil { + return "", err + } + return newId, nil +} + +func (t *Table[T]) Get(ctx context.Context, id string) (*T, error) { + hash := t.createItemHash(id) + cmd := t.db.client.HGet(ctx, hash, "$") + err := cmd.Err() + if err != nil { + return nil, err + } + + var res T + err = json.Unmarshal([]byte(cmd.Val()), &res) + if err != nil { + return nil, err + } + return &res, nil +} + +func (t *Table[T]) Remove(ctx context.Context, id string) error { + hash := t.createItemHash(id) + cmd := t.db.client.HDel(ctx, hash) + err := cmd.Err() + if err != nil { + return err + } + return nil +}