poc of functions
This commit is contained in:
254
cmd/actions.go
254
cmd/actions.go
@@ -1,42 +1,276 @@
|
|||||||
package main
|
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) {
|
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) {
|
mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|
||||||
if r.Method != http.MethodGet {
|
if r.Method != http.MethodGet {
|
||||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
w.WriteHeader(http.StatusOK)
|
w.WriteHeader(http.StatusOK)
|
||||||
w.Write([]byte("OK"))
|
w.Write([]byte("OK"))
|
||||||
|
|
||||||
})
|
})
|
||||||
|
|
||||||
mux.HandleFunc("/analyze-fff", func(w http.ResponseWriter, r *http.Request) {
|
mux.HandleFunc("/analyze-fff", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|
||||||
if r.Method != http.MethodPost {
|
if r.Method != http.MethodPost {
|
||||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
w.WriteHeader(http.StatusOK)
|
modelPath, cleanup, err := saveUploadedFile(r, "file")
|
||||||
w.Write([]byte("OK"))
|
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) {
|
mux.HandleFunc("/analyze-sla", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|
||||||
if r.Method != http.MethodPost {
|
if r.Method != http.MethodPost {
|
||||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
w.WriteHeader(http.StatusOK)
|
modelPath, cleanup, err := saveUploadedFile(r, "file")
|
||||||
w.Write([]byte("OK"))
|
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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,11 +4,14 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
|
"runtime/debug"
|
||||||
|
|
||||||
"golang.org/x/net/http2"
|
"golang.org/x/net/http2"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
|
var memLimitMb int64 = 512
|
||||||
|
debug.SetMemoryLimit(memLimitMb * 1024 * 1024)
|
||||||
// Your code here
|
// Your code here
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
|
|
||||||
|
|||||||
63
config.json
Normal file
63
config.json
Normal file
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,39 +1,16 @@
|
|||||||
FROM ubuntu:plucky
|
FROM fedora:45
|
||||||
|
|
||||||
RUN echo "deb https://ppa.launchpadcontent.net/longsleep/golang-backports/ubuntu ubuntu-plucky main" | tee /etc/apt/sources.list.d/docker.list
|
RUN dnf install -y golang prusa-slicer f3d glibc-langpack-en nodejs npm && dnf clean all
|
||||||
RUN apt update
|
RUN npm i -g nodemon@3.1.0
|
||||||
|
|
||||||
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 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 /src
|
||||||
|
|
||||||
WORKDIR /build
|
COPY ../docker/nodemon.json /run/nodemon.json
|
||||||
|
|
||||||
COPY ../ .
|
RUN dnf install fuser unzip -y
|
||||||
|
|
||||||
RUN go build -o /bin/slic3r-api ./cmd
|
ENV LIBGL_ALWAYS_SOFTWARE=1
|
||||||
|
|
||||||
RUN rm -rf /build/*
|
CMD [ "nodemon", "--config", "docker/nodemon.json" ]
|
||||||
|
|
||||||
CMD [ "/bin/slic3r-api" ]
|
|
||||||
|
|||||||
22
docker/Dockerfile.prod
Normal file
22
docker/Dockerfile.prod
Normal file
@@ -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" ]
|
||||||
@@ -3,7 +3,6 @@ services:
|
|||||||
build:
|
build:
|
||||||
context: ../
|
context: ../
|
||||||
dockerfile: docker/Dockerfile
|
dockerfile: docker/Dockerfile
|
||||||
command: /bin/bash -c "cd /src && go run ./cmd"
|
|
||||||
volumes:
|
volumes:
|
||||||
- ../:/src
|
- ../:/src
|
||||||
- ../configs:/configs
|
- ../configs:/configs
|
||||||
@@ -11,5 +10,20 @@ services:
|
|||||||
- PORT=3030
|
- PORT=3030
|
||||||
- HOST=0.0.0.0
|
- HOST=0.0.0.0
|
||||||
- CONFIGS=/configs/
|
- CONFIGS=/configs/
|
||||||
|
- VALKEY_URL=redis://valkey:6379
|
||||||
ports:
|
ports:
|
||||||
- 3030:3030
|
- 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
|
||||||
7
docker/nodemon.json
Normal file
7
docker/nodemon.json
Normal file
@@ -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"
|
||||||
|
}
|
||||||
5
go.mod
5
go.mod
@@ -3,6 +3,11 @@ module github.com/sotigr/slic3r-api
|
|||||||
go 1.24.2
|
go 1.24.2
|
||||||
|
|
||||||
require (
|
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/net v0.41.0 // indirect
|
||||||
golang.org/x/text v0.26.0 // indirect
|
golang.org/x/text v0.26.0 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
10
go.sum
10
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 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw=
|
||||||
golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA=
|
golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA=
|
||||||
golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M=
|
golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M=
|
||||||
|
|||||||
61
internal/cache/cache.go
vendored
Normal file
61
internal/cache/cache.go
vendored
Normal file
@@ -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) {
|
||||||
|
}
|
||||||
34
internal/cache/key.go
vendored
Normal file
34
internal/cache/key.go
vendored
Normal file
@@ -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: "<endpoint>:<filehash>:scale=<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
|
||||||
|
}
|
||||||
@@ -55,6 +55,7 @@ func execNoCheck(ctx context.Context, command string, args []string) (string, st
|
|||||||
|
|
||||||
type Pipe struct {
|
type Pipe struct {
|
||||||
Command string
|
Command string
|
||||||
|
SuffixSymbol string
|
||||||
Args []string
|
Args []string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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) {
|
func ExecuteCLIPipeLine(ctx context.Context, pipes []Pipe) (string, string, error) {
|
||||||
|
|
||||||
pipeLen := len(pipes)
|
pipeLen := len(pipes)
|
||||||
@@ -94,9 +111,13 @@ func ExecuteCLIPipeLine(ctx context.Context, pipes []Pipe) (string, string, erro
|
|||||||
for i, p := range pipes {
|
for i, p := range pipes {
|
||||||
cmdArg += p.Command + " " + strings.Join(p.Args, " ")
|
cmdArg += p.Command + " " + strings.Join(p.Args, " ")
|
||||||
if i != pipeLen {
|
if i != pipeLen {
|
||||||
|
if p.SuffixSymbol != "" {
|
||||||
|
cmdArg += " " + p.SuffixSymbol + " "
|
||||||
|
} else {
|
||||||
cmdArg += " | "
|
cmdArg += " | "
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
args = append(args, cmdArg)
|
args = append(args, cmdArg)
|
||||||
|
|
||||||
|
|||||||
282
internal/slicer/slicer.go
Normal file
282
internal/slicer/slicer.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
46
internal/thumbnail/thumbnail.go
Normal file
46
internal/thumbnail/thumbnail.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
89
internal/valkeydb/db.go
Normal file
89
internal/valkeydb/db.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user