feat: add Orcaslicer and docker
This commit is contained in:
43
backend/Dockerfile
Normal file
43
backend/Dockerfile
Normal file
@@ -0,0 +1,43 @@
|
||||
FROM --platform=linux/amd64 python:3.10-slim-bookworm
|
||||
|
||||
# Install system dependencies for OrcaSlicer (AppImage)
|
||||
RUN apt-get update && apt-get install -y \
|
||||
wget \
|
||||
p7zip-full \
|
||||
libgl1 \
|
||||
libglib2.0-0 \
|
||||
libgtk-3-0 \
|
||||
libdbus-1-3 \
|
||||
libwebkit2gtk-4.1-0 \
|
||||
libwebkit2gtk-4.0-37 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Set working directory
|
||||
WORKDIR /app
|
||||
|
||||
# Download and extract OrcaSlicer
|
||||
# Using v2.2.0 as a stable recent release
|
||||
# We extract the AppImage to run it without FUSE
|
||||
RUN wget -q https://github.com/SoftFever/OrcaSlicer/releases/download/v2.2.0/OrcaSlicer_Linux_V2.2.0.AppImage -O OrcaSlicer.AppImage \
|
||||
&& 7z x OrcaSlicer.AppImage -o/opt/orcaslicer \
|
||||
&& chmod -R +x /opt/orcaslicer \
|
||||
&& rm OrcaSlicer.AppImage
|
||||
|
||||
# Add OrcaSlicer to PATH
|
||||
ENV PATH="/opt/orcaslicer/usr/bin:${PATH}"
|
||||
|
||||
# Install Python dependencies
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# Create directories for app and temp files
|
||||
RUN mkdir -p /app/temp /app/profiles
|
||||
|
||||
# Copy application code
|
||||
COPY . .
|
||||
|
||||
# Expose port
|
||||
EXPOSE 8000
|
||||
|
||||
# Run the application
|
||||
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
@@ -1,63 +1,150 @@
|
||||
import trimesh
|
||||
import sys
|
||||
import re
|
||||
import os
|
||||
import logging
|
||||
from typing import Dict, Any, Optional
|
||||
from config import settings
|
||||
|
||||
def calcola_volumi(volume_totale, superficie, wall_line_width=0.4, wall_line_count=3,
|
||||
layer_height=0.2, infill_percentage=0.15):
|
||||
# Volume perimetrale stimato = superficie * spessore parete
|
||||
spessore_parete = wall_line_width * wall_line_count
|
||||
volume_pareti = superficie * spessore_parete
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Volume interno (infill)
|
||||
volume_infill = (volume_totale - volume_pareti) * infill_percentage
|
||||
volume_effettivo = volume_pareti + max(volume_infill, 0)
|
||||
class GCodeParser:
|
||||
@staticmethod
|
||||
def parse_metadata(gcode_path: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Parses the G-code to extract estimated time and material usage.
|
||||
Scans both the beginning (header) and end (footer) of the file.
|
||||
"""
|
||||
stats = {
|
||||
"print_time_seconds": 0,
|
||||
"filament_length_mm": 0,
|
||||
"filament_volume_mm3": 0,
|
||||
"filament_weight_g": 0,
|
||||
"slicer_estimated_cost": 0
|
||||
}
|
||||
|
||||
return volume_effettivo, volume_pareti, max(volume_infill, 0)
|
||||
if not os.path.exists(gcode_path):
|
||||
logger.warning(f"GCode file not found for parsing: {gcode_path}")
|
||||
return stats
|
||||
|
||||
def calcola_peso(volume_mm3, densita_g_cm3=1.24):
|
||||
densita_g_mm3 = densita_g_cm3 / 1000
|
||||
return volume_mm3 * densita_g_mm3
|
||||
try:
|
||||
with open(gcode_path, 'r', encoding='utf-8', errors='ignore') as f:
|
||||
# Read header (first 500 lines)
|
||||
header_lines = [f.readline().strip() for _ in range(500) if f]
|
||||
|
||||
def calcola_costo(peso_g, prezzo_kg=20.0):
|
||||
return round((peso_g / 1000) * prezzo_kg, 2)
|
||||
# Read footer (last 20KB)
|
||||
f.seek(0, 2)
|
||||
file_size = f.tell()
|
||||
read_len = min(file_size, 20480)
|
||||
f.seek(file_size - read_len)
|
||||
footer_lines = f.read().splitlines()
|
||||
|
||||
def stima_tempo(volume_mm3 ):
|
||||
velocita_mm3_min = 0.4 *0.2 * 100 *60 # mm/s * mm * 60 s/min
|
||||
tempo_minuti = volume_mm3 / velocita_mm3_min
|
||||
return round(tempo_minuti, 1)
|
||||
all_lines = header_lines + footer_lines
|
||||
|
||||
def main(percorso_stl):
|
||||
try:
|
||||
mesh = trimesh.load(percorso_stl)
|
||||
volume_modello = mesh.volume
|
||||
superficie = mesh.area
|
||||
for line in all_lines:
|
||||
line = line.strip()
|
||||
if not line.startswith(";"):
|
||||
continue
|
||||
|
||||
GCodeParser._parse_line(line, stats)
|
||||
|
||||
volume_stampa, volume_pareti, volume_infill = calcola_volumi(
|
||||
volume_totale=volume_modello,
|
||||
superficie=superficie,
|
||||
wall_line_width=0.4,
|
||||
wall_line_count=3,
|
||||
layer_height=0.2,
|
||||
infill_percentage=0.15
|
||||
)
|
||||
# Fallback calculation
|
||||
if stats["filament_weight_g"] == 0 and stats["filament_length_mm"] > 0:
|
||||
GCodeParser._calculate_weight_fallback(stats)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error parsing G-code: {e}")
|
||||
|
||||
peso = calcola_peso(volume_stampa)
|
||||
costo = calcola_costo(peso)
|
||||
tempo = stima_tempo(volume_stampa)
|
||||
return stats
|
||||
|
||||
print(f"Volume STL: {volume_modello:.2f} mm³")
|
||||
print(f"Superficie esterna: {superficie:.2f} mm²")
|
||||
print(f"Volume stimato pareti: {volume_pareti:.2f} mm³")
|
||||
print(f"Volume stimato infill: {volume_infill:.2f} mm³")
|
||||
print(f"Volume totale da stampare: {volume_stampa:.2f} mm³")
|
||||
print(f"Peso stimato: {peso:.2f} g")
|
||||
print(f"Costo stimato: CHF {costo}")
|
||||
print(f"Tempo stimato: {tempo} min")
|
||||
@staticmethod
|
||||
def _parse_line(line: str, stats: Dict[str, Any]):
|
||||
# Parse Time
|
||||
if "estimated printing time =" in line: # Header
|
||||
time_str = line.split("=")[1].strip()
|
||||
stats["print_time_seconds"] = GCodeParser._parse_time_string(time_str)
|
||||
elif "total estimated time:" in line: # Footer
|
||||
parts = line.split("total estimated time:")
|
||||
if len(parts) > 1:
|
||||
stats["print_time_seconds"] = GCodeParser._parse_time_string(parts[1].strip())
|
||||
|
||||
except Exception as e:
|
||||
print("Errore durante l'elaborazione:", e)
|
||||
# Parse Filament info
|
||||
if "filament used [g] =" in line:
|
||||
try:
|
||||
stats["filament_weight_g"] = float(line.split("=")[1].strip())
|
||||
except ValueError: pass
|
||||
|
||||
if "filament used [mm] =" in line:
|
||||
try:
|
||||
stats["filament_length_mm"] = float(line.split("=")[1].strip())
|
||||
except ValueError: pass
|
||||
|
||||
if __name__ == "__main__":
|
||||
if len(sys.argv) != 2:
|
||||
print("Uso: python calcolatore_stl.py modello.stl")
|
||||
else:
|
||||
main(sys.argv[1])
|
||||
if "filament used [cm3] =" in line:
|
||||
try:
|
||||
# cm3 to mm3
|
||||
stats["filament_volume_mm3"] = float(line.split("=")[1].strip()) * 1000
|
||||
except ValueError: pass
|
||||
|
||||
@staticmethod
|
||||
def _calculate_weight_fallback(stats: Dict[str, Any]):
|
||||
# Assumes 1.75mm diameter and PLA density 1.24
|
||||
radius = 1.75 / 2
|
||||
volume_mm3 = 3.14159 * (radius ** 2) * stats["filament_length_mm"]
|
||||
volume_cm3 = volume_mm3 / 1000.0
|
||||
stats["filament_weight_g"] = volume_cm3 * 1.24
|
||||
|
||||
@staticmethod
|
||||
def _parse_time_string(time_str: str) -> int:
|
||||
"""
|
||||
Converts '1d 2h 3m 4s' to seconds.
|
||||
"""
|
||||
total_seconds = 0
|
||||
days = re.search(r'(\d+)d', time_str)
|
||||
hours = re.search(r'(\d+)h', time_str)
|
||||
mins = re.search(r'(\d+)m', time_str)
|
||||
secs = re.search(r'(\d+)s', time_str)
|
||||
|
||||
if days: total_seconds += int(days.group(1)) * 86400
|
||||
if hours: total_seconds += int(hours.group(1)) * 3600
|
||||
if mins: total_seconds += int(mins.group(1)) * 60
|
||||
if secs: total_seconds += int(secs.group(1))
|
||||
|
||||
return total_seconds
|
||||
|
||||
|
||||
class QuoteCalculator:
|
||||
@staticmethod
|
||||
def calculate(stats: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""
|
||||
Calculates the final quote based on parsed stats and settings.
|
||||
"""
|
||||
# 1. Material Cost
|
||||
# Cost per gram = (Cost per kg / 1000)
|
||||
material_cost = (stats["filament_weight_g"] / 1000.0) * settings.FILAMENT_COST_PER_KG
|
||||
|
||||
# 2. Machine Time Cost
|
||||
# Cost per second = (Cost per hour / 3600)
|
||||
print_time_hours = stats["print_time_seconds"] / 3600.0
|
||||
machine_cost = print_time_hours * settings.MACHINE_COST_PER_HOUR
|
||||
|
||||
# 3. Energy Cost
|
||||
# kWh = (Watts / 1000) * hours
|
||||
kwh_used = (settings.PRINTER_POWER_WATTS / 1000.0) * print_time_hours
|
||||
energy_cost = kwh_used * settings.ENERGY_COST_PER_KWH
|
||||
|
||||
# Subtotal
|
||||
subtotal = material_cost + machine_cost + energy_cost
|
||||
|
||||
# 4. Markup
|
||||
markup_factor = 1.0 + (settings.MARKUP_PERCENT / 100.0)
|
||||
total_price = subtotal * markup_factor
|
||||
|
||||
return {
|
||||
"breakdown": {
|
||||
"material_cost": round(material_cost, 2),
|
||||
"machine_cost": round(machine_cost, 2),
|
||||
"energy_cost": round(energy_cost, 2),
|
||||
"subtotal": round(subtotal, 2),
|
||||
"markup_amount": round(total_price - subtotal, 2)
|
||||
},
|
||||
"total_price": round(total_price, 2),
|
||||
"currency": "EUR"
|
||||
}
|
||||
|
||||
25
backend/config.py
Normal file
25
backend/config.py
Normal file
@@ -0,0 +1,25 @@
|
||||
import os
|
||||
|
||||
class Settings:
|
||||
# Directories
|
||||
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
TEMP_DIR = os.environ.get("TEMP_DIR", os.path.join(BASE_DIR, "temp"))
|
||||
PROFILES_DIR = os.environ.get("PROFILES_DIR", os.path.join(BASE_DIR, "profiles"))
|
||||
|
||||
# Slicer Paths
|
||||
SLICER_PATH = os.environ.get("SLICER_PATH", "/opt/orcaslicer/AppRun")
|
||||
ORCA_HOME = os.environ.get("ORCA_HOME", "/opt/orcaslicer")
|
||||
|
||||
# Defaults Profiles (Bambu A1)
|
||||
MACHINE_PROFILE = os.path.join(ORCA_HOME, "resources/profiles/BBL/machine/Bambu Lab A1 0.4 nozzle.json")
|
||||
PROCESS_PROFILE = os.path.join(ORCA_HOME, "resources/profiles/BBL/process/0.20mm Standard @BBL A1.json")
|
||||
FILAMENT_PROFILE = os.path.join(ORCA_HOME, "resources/profiles/BBL/filament/Generic PLA @BBL A1.json")
|
||||
|
||||
# Pricing
|
||||
FILAMENT_COST_PER_KG = float(os.environ.get("FILAMENT_COST_PER_KG", 25.0))
|
||||
MACHINE_COST_PER_HOUR = float(os.environ.get("MACHINE_COST_PER_HOUR", 2.0))
|
||||
ENERGY_COST_PER_KWH = float(os.environ.get("ENERGY_COST_PER_KWH", 0.30))
|
||||
PRINTER_POWER_WATTS = float(os.environ.get("PRINTER_POWER_WATTS", 150.0))
|
||||
MARKUP_PERCENT = float(os.environ.get("MARKUP_PERCENT", 20.0))
|
||||
|
||||
settings = Settings()
|
||||
132
backend/main.py
132
backend/main.py
@@ -1,10 +1,23 @@
|
||||
import io
|
||||
import trimesh
|
||||
import os
|
||||
import shutil
|
||||
import uuid
|
||||
import logging
|
||||
from fastapi import FastAPI, UploadFile, File, HTTPException
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from pydantic import BaseModel
|
||||
|
||||
app = FastAPI()
|
||||
# Import custom modules
|
||||
from config import settings
|
||||
from slicer import slicer_service
|
||||
from calculator import GCodeParser, QuoteCalculator
|
||||
|
||||
# Configure logging
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
logger = logging.getLogger("api")
|
||||
|
||||
app = FastAPI(title="Print Calculator API")
|
||||
|
||||
# CORS Setup
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
@@ -13,60 +26,87 @@ app.add_middleware(
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
def calculate_volumes(total_volume_mm3: float,
|
||||
surface_area_mm2: float,
|
||||
nozzle_width_mm: float = 0.4,
|
||||
wall_line_count: int = 3,
|
||||
layer_height_mm: float = 0.2,
|
||||
infill_fraction: float = 0.15):
|
||||
wall_thickness_mm = nozzle_width_mm * wall_line_count
|
||||
wall_volume_mm3 = surface_area_mm2 * wall_thickness_mm
|
||||
infill_volume_mm3 = max(total_volume_mm3 - wall_volume_mm3, 0) * infill_fraction
|
||||
total_print_volume_mm3 = wall_volume_mm3 + infill_volume_mm3
|
||||
return total_print_volume_mm3, wall_volume_mm3, infill_volume_mm3
|
||||
# Ensure directories exist
|
||||
os.makedirs(settings.TEMP_DIR, exist_ok=True)
|
||||
|
||||
def calculate_weight(volume_mm3: float, density_g_cm3: float = 1.24):
|
||||
density_g_mm3 = density_g_cm3 / 1000.0
|
||||
return volume_mm3 * density_g_mm3
|
||||
class QuoteResponse(BaseModel):
|
||||
printer: str
|
||||
print_time_seconds: int
|
||||
print_time_formatted: str
|
||||
material_grams: float
|
||||
cost: dict
|
||||
notes: list[str] = []
|
||||
|
||||
def calculate_cost(weight_g: float, price_per_kg: float = 20.0):
|
||||
return round((weight_g / 1000.0) * price_per_kg, 2)
|
||||
def cleanup_files(files: list):
|
||||
for f in files:
|
||||
try:
|
||||
if os.path.exists(f):
|
||||
os.remove(f)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to delete temp file {f}: {e}")
|
||||
|
||||
def estimate_time(volume_mm3: float,
|
||||
nozzle_width_mm: float = 0.4,
|
||||
layer_height_mm: float = 0.2,
|
||||
print_speed_mm_per_s: float = 100.0):
|
||||
volumetric_speed_mm3_per_min = nozzle_width_mm * layer_height_mm * print_speed_mm_per_s * 60.0
|
||||
return round(volume_mm3 / volumetric_speed_mm3_per_min, 1)
|
||||
def format_time(seconds: int) -> str:
|
||||
m, s = divmod(seconds, 60)
|
||||
h, m = divmod(m, 60)
|
||||
if h > 0:
|
||||
return f"{int(h)}h {int(m)}m"
|
||||
return f"{int(m)}m {int(s)}s"
|
||||
|
||||
@app.post("/calculate/stl")
|
||||
@app.post("/calculate/stl", response_model=QuoteResponse)
|
||||
async def calculate_from_stl(file: UploadFile = File(...)):
|
||||
if not file.filename.lower().endswith(".stl"):
|
||||
raise HTTPException(status_code=400, detail="Please upload an STL file.")
|
||||
raise HTTPException(status_code=400, detail="Only .stl files are supported.")
|
||||
|
||||
# Unique ID for this request
|
||||
req_id = str(uuid.uuid4())
|
||||
input_filename = f"{req_id}.stl"
|
||||
output_filename = f"{req_id}.gcode"
|
||||
|
||||
input_path = os.path.join(settings.TEMP_DIR, input_filename)
|
||||
output_path = os.path.join(settings.TEMP_DIR, output_filename)
|
||||
|
||||
try:
|
||||
contents = await file.read()
|
||||
mesh = trimesh.load(io.BytesIO(contents), file_type="stl")
|
||||
model_volume_mm3 = mesh.volume
|
||||
model_surface_area_mm2 = mesh.area
|
||||
# 1. Save Uploaded File
|
||||
with open(input_path, "wb") as buffer:
|
||||
shutil.copyfileobj(file.file, buffer)
|
||||
|
||||
# 2. Slice
|
||||
# slicer_service methods raise exceptions on failure
|
||||
slicer_service.slice_stl(input_path, output_path)
|
||||
|
||||
print_volume, wall_volume, infill_volume = calculate_volumes(
|
||||
total_volume_mm3=model_volume_mm3,
|
||||
surface_area_mm2=model_surface_area_mm2
|
||||
)
|
||||
# 3. Parse Results
|
||||
stats = GCodeParser.parse_metadata(output_path)
|
||||
|
||||
if stats["print_time_seconds"] == 0 and stats["filament_weight_g"] == 0:
|
||||
# Slicing likely failed or produced empty output without throwing error
|
||||
raise HTTPException(status_code=500, detail="Slicing completed but no stats found. Check mesh validity.")
|
||||
|
||||
weight_g = calculate_weight(print_volume)
|
||||
cost_chf = calculate_cost(weight_g)
|
||||
time_min = estimate_time(print_volume)
|
||||
# 4. Calculate Costs
|
||||
quote = QuoteCalculator.calculate(stats)
|
||||
|
||||
return {
|
||||
"stl_volume_mm3": round(model_volume_mm3, 2),
|
||||
"surface_area_mm2": round(model_surface_area_mm2, 2),
|
||||
"wall_volume_mm3": round(wall_volume, 2),
|
||||
"infill_volume_mm3": round(infill_volume, 2),
|
||||
"print_volume_mm3": round(print_volume, 2),
|
||||
"weight_g": round(weight_g, 2),
|
||||
"cost_chf": cost_chf,
|
||||
"time_min": time_min
|
||||
"printer": "BambuLab A1 (Estimated)",
|
||||
"print_time_seconds": stats["print_time_seconds"],
|
||||
"print_time_formatted": format_time(stats["print_time_seconds"]),
|
||||
"material_grams": stats["filament_weight_g"],
|
||||
"cost": {
|
||||
"material": quote["breakdown"]["material_cost"],
|
||||
"machine": quote["breakdown"]["machine_cost"],
|
||||
"energy": quote["breakdown"]["energy_cost"],
|
||||
"markup": quote["breakdown"]["markup_amount"],
|
||||
"total": quote["total_price"]
|
||||
},
|
||||
"notes": ["Estimation generated using OrcaSlicer headless."]
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error processing request: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
finally:
|
||||
# Cleanup
|
||||
cleanup_files([input_path, output_path])
|
||||
|
||||
@app.get("/health")
|
||||
def health_check():
|
||||
return {"status": "ok", "slicer": settings.SLICER_PATH}
|
||||
42
backend/profiles/bambu_a1.ini
Normal file
42
backend/profiles/bambu_a1.ini
Normal file
@@ -0,0 +1,42 @@
|
||||
# base config for Bambu Lab A1 style printer (approximate for estimation)
|
||||
# Save as config.ini
|
||||
|
||||
# MACHINE SETTINGS
|
||||
bed_shape = 0x0,256x0,256x256,0x256
|
||||
nozzle_diameter = 0.4
|
||||
filament_diameter = 1.75
|
||||
max_print_speed = 500
|
||||
travel_speed = 500
|
||||
gcode_flavor = klipper
|
||||
# Bambu uses specific gcode but klipper/marlin is close enough for time est if accel matches
|
||||
machine_max_acceleration_x = 10000
|
||||
machine_max_acceleration_y = 10000
|
||||
machine_max_acceleration_e = 5000
|
||||
machine_max_acceleration_extruding = 5000
|
||||
|
||||
# PRINT SETTINGS
|
||||
layer_height = 0.2
|
||||
first_layer_height = 0.2
|
||||
perimeters = 2
|
||||
fill_density = 15%
|
||||
fill_pattern = grid
|
||||
solid_layers = 3
|
||||
top_solid_layers = 3
|
||||
bottom_solid_layers = 3
|
||||
|
||||
# SPEED SETTINGS (Conservative defaults for A1)
|
||||
perimeter_speed = 200
|
||||
external_perimeter_speed = 150
|
||||
infill_speed = 250
|
||||
solid_infill_speed = 200
|
||||
top_solid_infill_speed = 150
|
||||
support_material_speed = 150
|
||||
bridge_speed = 50
|
||||
gap_fill_speed = 50
|
||||
|
||||
# FILAMENT SETTINGS
|
||||
filament_density = 1.24
|
||||
filament_cost = 25.0
|
||||
filament_max_volumetric_speed = 15
|
||||
temperature = 220
|
||||
bed_temperature = 60
|
||||
21
backend/prova.py
Normal file
21
backend/prova.py
Normal file
@@ -0,0 +1,21 @@
|
||||
import os
|
||||
import argparse
|
||||
|
||||
def write_structure(root_dir, output_file):
|
||||
with open(output_file, 'w', encoding='utf-8') as f:
|
||||
for dirpath, dirnames, filenames in os.walk(root_dir):
|
||||
relative = os.path.relpath(dirpath, root_dir)
|
||||
indent_level = 0 if relative == '.' else relative.count(os.sep) + 1
|
||||
indent = ' ' * (indent_level - 1) if indent_level > 0 else ''
|
||||
dir_name = os.path.basename(dirpath)
|
||||
f.write(f"{indent}{dir_name}/\n")
|
||||
for file in sorted(filenames):
|
||||
if file.endswith('.java'):
|
||||
f.write(f"{indent} {file}\n")
|
||||
|
||||
if __name__ == '__main__':
|
||||
parser = argparse.ArgumentParser(description='Generate a text file listing .java files with folder structure')
|
||||
parser.add_argument('root_dir', nargs='?', default='.', help='Directory to scan')
|
||||
parser.add_argument('-o', '--output', default='java_structure.txt', help='Output text file')
|
||||
args = parser.parse_args()
|
||||
write_structure(args.root_dir, args.output)
|
||||
4
backend/requirements.txt
Normal file
4
backend/requirements.txt
Normal file
@@ -0,0 +1,4 @@
|
||||
fastapi==0.109.0
|
||||
uvicorn==0.27.0
|
||||
python-multipart==0.0.6
|
||||
requests==2.31.0
|
||||
128
backend/slicer.py
Normal file
128
backend/slicer.py
Normal file
@@ -0,0 +1,128 @@
|
||||
import subprocess
|
||||
import os
|
||||
import json
|
||||
import logging
|
||||
from config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class SlicerService:
|
||||
def __init__(self):
|
||||
self._ensure_profiles_exist()
|
||||
|
||||
def _ensure_profiles_exist(self):
|
||||
"""
|
||||
Checks if the internal profiles exist. Low priority check to avoid noise.
|
||||
"""
|
||||
if os.path.exists(settings.ORCA_HOME):
|
||||
for p in [settings.MACHINE_PROFILE, settings.PROCESS_PROFILE, settings.FILAMENT_PROFILE]:
|
||||
if not os.path.exists(p):
|
||||
logger.warning(f"Internal profile not found: {p}")
|
||||
|
||||
def slice_stl(self, input_stl_path: str, output_gcode_path: str) -> bool:
|
||||
"""
|
||||
Runs OrcaSlicer in headless mode to slice the STL file.
|
||||
"""
|
||||
if not os.path.exists(input_stl_path):
|
||||
raise FileNotFoundError(f"STL file not found: {input_stl_path}")
|
||||
|
||||
output_dir = os.path.dirname(output_gcode_path)
|
||||
override_path = self._create_override_machine_config(output_dir)
|
||||
|
||||
# Prepare command
|
||||
command = self._build_slicer_command(input_stl_path, output_dir, override_path)
|
||||
|
||||
logger.info(f"Starting slicing for {input_stl_path}...")
|
||||
try:
|
||||
self._run_command(command)
|
||||
self._finalize_output(output_dir, input_stl_path, output_gcode_path)
|
||||
logger.info("Slicing completed successfully.")
|
||||
return True
|
||||
except subprocess.CalledProcessError as e:
|
||||
logger.error(f"Slicing failed: {e.stderr}")
|
||||
raise RuntimeError(f"Slicing failed: {e.stderr}")
|
||||
|
||||
def _create_override_machine_config(self, output_dir: str) -> str:
|
||||
"""
|
||||
Creates an optionally modified machine config to fix relative addressing and bed size.
|
||||
Returns the path to the override config file.
|
||||
"""
|
||||
override_path = os.path.join(output_dir, "machine_override.json")
|
||||
machine_config = {}
|
||||
|
||||
if os.path.exists(settings.MACHINE_PROFILE):
|
||||
try:
|
||||
with open(settings.MACHINE_PROFILE, 'r') as f:
|
||||
machine_config = json.load(f)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to load machine profile: {e}")
|
||||
|
||||
# Apply Fixes
|
||||
# 1. G92 E0 for relative extrusion safety
|
||||
gcode = machine_config.get("layer_change_gcode", "")
|
||||
if "G92 E0" not in gcode:
|
||||
machine_config["layer_change_gcode"] = (gcode + "\nG92 E0").strip()
|
||||
|
||||
# 2. Expand bed size for large prints estimation
|
||||
machine_config.update({
|
||||
"printable_height": "1000",
|
||||
"printable_area": ["0x0", "1000x0", "1000x1000", "0x1000"],
|
||||
"bed_custom_model": "",
|
||||
"bed_exclude_area": []
|
||||
})
|
||||
|
||||
# Save override
|
||||
try:
|
||||
with open(override_path, "w") as f:
|
||||
json.dump(machine_config, f)
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not save override file: {e}")
|
||||
# If we fail to save, we might just return the original profile or a basic one
|
||||
# But here we return the path anyway as the slicer might fail later if this didn't work.
|
||||
|
||||
return override_path
|
||||
|
||||
def _build_slicer_command(self, input_path: str, output_dir: str, machine_profile: str) -> list:
|
||||
# Construct settings argument
|
||||
# Note: Order matters for some slicers, but here just loading them.
|
||||
settings_items = [machine_profile, settings.PROCESS_PROFILE, settings.FILAMENT_PROFILE]
|
||||
settings_arg = ";".join(settings_items)
|
||||
|
||||
return [
|
||||
settings.SLICER_PATH,
|
||||
"--load-settings", settings_arg,
|
||||
"--ensure-on-bed",
|
||||
"--arrange", "1",
|
||||
"--slice", "0",
|
||||
"--outputdir", output_dir,
|
||||
input_path
|
||||
]
|
||||
|
||||
def _run_command(self, command: list):
|
||||
subprocess.run(
|
||||
command,
|
||||
check=True,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True
|
||||
)
|
||||
|
||||
def _finalize_output(self, output_dir: str, input_path: str, target_path: str):
|
||||
"""
|
||||
Finds the generated G-code and renames it to the target path.
|
||||
"""
|
||||
input_basename = os.path.basename(input_path)
|
||||
# OrcaSlicer usually outputs <basename>.gcode
|
||||
expected_name = os.path.splitext(input_basename)[0] + ".gcode"
|
||||
generated_path = os.path.join(output_dir, expected_name)
|
||||
|
||||
# Fallback for plate_1.gcode
|
||||
if not os.path.exists(generated_path):
|
||||
alt_path = os.path.join(output_dir, "plate_1.gcode")
|
||||
if os.path.exists(alt_path):
|
||||
generated_path = alt_path
|
||||
|
||||
if os.path.exists(generated_path) and generated_path != target_path:
|
||||
os.rename(generated_path, target_path)
|
||||
|
||||
slicer_service = SlicerService()
|
||||
7
backend/temp/result.json
Normal file
7
backend/temp/result.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"error_string": "There are some incorrect slicing parameters in the 3mf. Please verify the slicing of all plates in Orca Slicer before uploading.",
|
||||
"export_time": 93825087757208,
|
||||
"plate_index": 1,
|
||||
"prepare_time": 0,
|
||||
"return_code": -51
|
||||
}
|
||||
86
backend/test_cube.stl
Normal file
86
backend/test_cube.stl
Normal file
@@ -0,0 +1,86 @@
|
||||
solid cube
|
||||
facet normal 0 0 -1
|
||||
outer loop
|
||||
vertex 0 0 0
|
||||
vertex 10 0 0
|
||||
vertex 10 10 0
|
||||
endloop
|
||||
endfacet
|
||||
facet normal 0 0 -1
|
||||
outer loop
|
||||
vertex 0 0 0
|
||||
vertex 10 10 0
|
||||
vertex 0 10 0
|
||||
endloop
|
||||
endfacet
|
||||
facet normal 0 0 1
|
||||
outer loop
|
||||
vertex 0 0 10
|
||||
vertex 0 10 10
|
||||
vertex 10 10 10
|
||||
endloop
|
||||
endfacet
|
||||
facet normal 0 0 1
|
||||
outer loop
|
||||
vertex 0 0 10
|
||||
vertex 10 10 10
|
||||
vertex 10 0 10
|
||||
endloop
|
||||
endfacet
|
||||
facet normal 0 -1 0
|
||||
outer loop
|
||||
vertex 0 0 0
|
||||
vertex 0 0 10
|
||||
vertex 10 0 10
|
||||
endloop
|
||||
endfacet
|
||||
facet normal 0 -1 0
|
||||
outer loop
|
||||
vertex 0 0 0
|
||||
vertex 10 0 10
|
||||
vertex 10 0 0
|
||||
endloop
|
||||
endfacet
|
||||
facet normal 1 0 0
|
||||
outer loop
|
||||
vertex 10 0 0
|
||||
vertex 10 0 10
|
||||
vertex 10 10 10
|
||||
endloop
|
||||
endfacet
|
||||
facet normal 1 0 0
|
||||
outer loop
|
||||
vertex 10 0 0
|
||||
vertex 10 10 10
|
||||
vertex 10 10 0
|
||||
endloop
|
||||
endfacet
|
||||
facet normal 0 1 0
|
||||
outer loop
|
||||
vertex 10 10 0
|
||||
vertex 10 10 10
|
||||
vertex 0 10 10
|
||||
endloop
|
||||
endfacet
|
||||
facet normal 0 1 0
|
||||
outer loop
|
||||
vertex 10 10 0
|
||||
vertex 0 10 10
|
||||
vertex 0 10 0
|
||||
endloop
|
||||
endfacet
|
||||
facet normal -1 0 0
|
||||
outer loop
|
||||
vertex 0 10 0
|
||||
vertex 0 10 10
|
||||
vertex 0 0 10
|
||||
endloop
|
||||
endfacet
|
||||
facet normal -1 0 0
|
||||
outer loop
|
||||
vertex 0 10 0
|
||||
vertex 0 0 10
|
||||
vertex 0 0 0
|
||||
endloop
|
||||
endfacet
|
||||
endsolid cube
|
||||
Reference in New Issue
Block a user