feat: add Orcaslicer and docker

This commit is contained in:
2026-01-27 22:02:49 +01:00
parent 00e62fc558
commit 7dc6741808
21 changed files with 1986 additions and 1123 deletions

10
Makefile Normal file
View File

@@ -0,0 +1,10 @@
.PHONY: install s
install:
@echo "Installing Backend dependencies..."
cd backend && pip install -r requirements.txt || pip install fastapi uvicorn trimesh python-multipart numpy
@echo "Installing Frontend dependencies..."
cd frontend && npm install
start:
@echo "Starting development environment..."
./start.sh

View File

@@ -1 +1,70 @@
# print-calculator # Print Calculator (OrcaSlicer Edition)
Un'applicazione Full Stack (Angular + Python/FastAPI) per calcolare preventivi di stampa 3D precisi utilizzando **OrcaSlicer** in modalità headless.
## Funzionalità
* **Slicing Reale**: Usa il motore di OrcaSlicer per stimare tempo e materiale, non semplici approssimazioni geometriche.
* **Preventivazione Completa**: Calcola costo materiale, ammortamento macchina, energia e ricarico.
* **Configurabile**: Prezzi e parametri macchina modificabili via variabili d'ambiente.
* **Docker Ready**: Tutto containerizzato per un facile deployment.
## Prerequisiti
* Docker Desktop & Docker Compose installati.
## Avvio Rapido
1. Clona il repository.
2. Esegui lo script di avvio o docker-compose:
```bash
docker-compose up --build
```
*Nota: La prima build impiegherà alcuni minuti per scaricare OrcaSlicer (~200MB) e compilare il Frontend.*
3. Accedi all'applicazione:
* **Frontend**: [http://localhost](http://localhost)
* **API Docs**: [http://localhost:8000/docs](http://localhost:8000/docs)
## Configurazione Prezzi
Puoi modificare i prezzi nel file `docker-compose.yml` (sezione `environment` del servizio backend):
* `FILAMENT_COST_PER_KG`: Costo filamento al kg (es. 25.0).
* `MACHINE_COST_PER_HOUR`: Costo orario macchina (ammortamento/manutenzione).
* `ENERGY_COST_PER_KWH`: Costo energia elettrica.
* `MARKUP_PERCENT`: Margine di profitto percentuale (es. 20 = +20%).
## Struttura del Progetto
* `/backend`: API Python FastAPI. Include Dockerfile che scarica OrcaSlicer AppImage.
* `/frontend`: Applicazione Angular 19+ con Material Design.
* `/backend/profiles`: Contiene i profili di slicing (.ini). Attualmente configurato per una stima generica simil-Bambu Lab A1.
## Troubleshooting
### Errore Download OrcaSlicer
Se la build del backend fallisce durante il download di `OrcaSlicer.AppImage`, verifica la tua connessione internet o aggiorna l'URL nel `backend/Dockerfile`.
### Slicing Fallito (Costo 0 o Errore)
Se l'API ritorna errore o valori nulli:
1. Controlla che il file STL sia valido (manifold).
2. Controlla i log del backend: `docker logs print-calculator-backend`.
## Sviluppo Locale (Senza Docker)
**Backend**:
Richiede Linux (o WSL2) per eseguire l'AppImage di OrcaSlicer.
```bash
cd backend
pip install -r requirements.txt
# Assicurati di avere OrcaSlicer installato e nel PATH o aggiorna SLICER_PATH in slicer.py
uvicorn main:app --reload
```
**Frontend**:
```bash
cd frontend
npm install
npm start
```

43
backend/Dockerfile Normal file
View 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"]

View File

@@ -1,63 +1,150 @@
import trimesh import re
import sys 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, logger = logging.getLogger(__name__)
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
# Volume interno (infill) class GCodeParser:
volume_infill = (volume_totale - volume_pareti) * infill_percentage @staticmethod
volume_effettivo = volume_pareti + max(volume_infill, 0) 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
def calcola_costo(peso_g, prezzo_kg=20.0):
return round((peso_g / 1000) * prezzo_kg, 2)
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)
def main(percorso_stl):
try: try:
mesh = trimesh.load(percorso_stl) with open(gcode_path, 'r', encoding='utf-8', errors='ignore') as f:
volume_modello = mesh.volume # Read header (first 500 lines)
superficie = mesh.area header_lines = [f.readline().strip() for _ in range(500) if f]
volume_stampa, volume_pareti, volume_infill = calcola_volumi( # Read footer (last 20KB)
volume_totale=volume_modello, f.seek(0, 2)
superficie=superficie, file_size = f.tell()
wall_line_width=0.4, read_len = min(file_size, 20480)
wall_line_count=3, f.seek(file_size - read_len)
layer_height=0.2, footer_lines = f.read().splitlines()
infill_percentage=0.15
)
peso = calcola_peso(volume_stampa) all_lines = header_lines + footer_lines
costo = calcola_costo(peso)
tempo = stima_tempo(volume_stampa)
print(f"Volume STL: {volume_modello:.2f} mm³") for line in all_lines:
print(f"Superficie esterna: {superficie:.2f} mm²") line = line.strip()
print(f"Volume stimato pareti: {volume_pareti:.2f} mm³") if not line.startswith(";"):
print(f"Volume stimato infill: {volume_infill:.2f} mm³") continue
print(f"Volume totale da stampare: {volume_stampa:.2f} mm³")
print(f"Peso stimato: {peso:.2f} g") GCodeParser._parse_line(line, stats)
print(f"Costo stimato: CHF {costo}")
print(f"Tempo stimato: {tempo} min") # Fallback calculation
if stats["filament_weight_g"] == 0 and stats["filament_length_mm"] > 0:
GCodeParser._calculate_weight_fallback(stats)
except Exception as e: except Exception as e:
print("Errore durante l'elaborazione:", e) logger.error(f"Error parsing G-code: {e}")
if __name__ == "__main__": return stats
if len(sys.argv) != 2:
print("Uso: python calcolatore_stl.py modello.stl") @staticmethod
else: def _parse_line(line: str, stats: Dict[str, Any]):
main(sys.argv[1]) # 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())
# 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 "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
View 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()

View File

@@ -1,10 +1,23 @@
import io import os
import trimesh import shutil
import uuid
import logging
from fastapi import FastAPI, UploadFile, File, HTTPException from fastapi import FastAPI, UploadFile, File, HTTPException
from fastapi.middleware.cors import CORSMiddleware 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( app.add_middleware(
CORSMiddleware, CORSMiddleware,
allow_origins=["*"], allow_origins=["*"],
@@ -13,60 +26,87 @@ app.add_middleware(
allow_headers=["*"], allow_headers=["*"],
) )
def calculate_volumes(total_volume_mm3: float, # Ensure directories exist
surface_area_mm2: float, os.makedirs(settings.TEMP_DIR, exist_ok=True)
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
def calculate_weight(volume_mm3: float, density_g_cm3: float = 1.24): class QuoteResponse(BaseModel):
density_g_mm3 = density_g_cm3 / 1000.0 printer: str
return volume_mm3 * density_g_mm3 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): def cleanup_files(files: list):
return round((weight_g / 1000.0) * price_per_kg, 2) 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, def format_time(seconds: int) -> str:
nozzle_width_mm: float = 0.4, m, s = divmod(seconds, 60)
layer_height_mm: float = 0.2, h, m = divmod(m, 60)
print_speed_mm_per_s: float = 100.0): if h > 0:
volumetric_speed_mm3_per_min = nozzle_width_mm * layer_height_mm * print_speed_mm_per_s * 60.0 return f"{int(h)}h {int(m)}m"
return round(volume_mm3 / volumetric_speed_mm3_per_min, 1) 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(...)): async def calculate_from_stl(file: UploadFile = File(...)):
if not file.filename.lower().endswith(".stl"): 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: try:
contents = await file.read() # 1. Save Uploaded File
mesh = trimesh.load(io.BytesIO(contents), file_type="stl") with open(input_path, "wb") as buffer:
model_volume_mm3 = mesh.volume shutil.copyfileobj(file.file, buffer)
model_surface_area_mm2 = mesh.area
print_volume, wall_volume, infill_volume = calculate_volumes( # 2. Slice
total_volume_mm3=model_volume_mm3, # slicer_service methods raise exceptions on failure
surface_area_mm2=model_surface_area_mm2 slicer_service.slice_stl(input_path, output_path)
)
weight_g = calculate_weight(print_volume) # 3. Parse Results
cost_chf = calculate_cost(weight_g) stats = GCodeParser.parse_metadata(output_path)
time_min = estimate_time(print_volume)
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.")
# 4. Calculate Costs
quote = QuoteCalculator.calculate(stats)
return { return {
"stl_volume_mm3": round(model_volume_mm3, 2), "printer": "BambuLab A1 (Estimated)",
"surface_area_mm2": round(model_surface_area_mm2, 2), "print_time_seconds": stats["print_time_seconds"],
"wall_volume_mm3": round(wall_volume, 2), "print_time_formatted": format_time(stats["print_time_seconds"]),
"infill_volume_mm3": round(infill_volume, 2), "material_grams": stats["filament_weight_g"],
"print_volume_mm3": round(print_volume, 2), "cost": {
"weight_g": round(weight_g, 2), "material": quote["breakdown"]["material_cost"],
"cost_chf": cost_chf, "machine": quote["breakdown"]["machine_cost"],
"time_min": time_min "energy": quote["breakdown"]["energy_cost"],
"markup": quote["breakdown"]["markup_amount"],
"total": quote["total_price"]
},
"notes": ["Estimation generated using OrcaSlicer headless."]
} }
except Exception as e: except Exception as e:
logger.error(f"Error processing request: {e}")
raise HTTPException(status_code=500, detail=str(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}

View 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
View 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
View 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
View 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
View 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
View 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

28
docker-compose.yml Normal file
View File

@@ -0,0 +1,28 @@
services:
backend:
platform: linux/amd64
build:
context: ./backend
platforms:
- linux/amd64
container_name: print-calculator-backend
ports:
- "8000:8000"
environment:
- FILAMENT_COST_PER_KG=22.0
- MACHINE_COST_PER_HOUR=2.50
- ENERGY_COST_PER_KWH=0.30
- PRINTER_POWER_WATTS=150
- MARKUP_PERCENT=20
- TEMP_DIR=/app/temp
- PROFILES_DIR=/app/profiles
restart: unless-stopped
frontend:
build: ./frontend
container_name: print-calculator-frontend
ports:
- "80:80"
depends_on:
- backend
restart: unless-stopped

1
frontend/.nvmrc Normal file
View File

@@ -0,0 +1 @@
22

14
frontend/Dockerfile Normal file
View File

@@ -0,0 +1,14 @@
# Stage 1: Build
FROM node:20 as build
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
RUN npm run build --configuration=production
# Stage 2: Serve
FROM nginx:alpine
COPY --from=build /app/dist/frontend/browser /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80

9
frontend/nginx.conf Normal file
View File

@@ -0,0 +1,9 @@
server {
listen 80;
location / {
root /usr/share/nginx/html;
index index.html index.htm;
try_files $uri $uri/ /index.html;
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -9,24 +9,27 @@
"test": "ng test" "test": "ng test"
}, },
"private": true, "private": true,
"engines": {
"node": "^22.0.0"
},
"dependencies": { "dependencies": {
"@angular/cdk": "^19.2.16", "@angular/cdk": "^19.2.19",
"@angular/common": "^19.2.0", "@angular/common": "^19.2.18",
"@angular/compiler": "^19.2.0", "@angular/compiler": "^19.2.18",
"@angular/core": "^19.2.0", "@angular/core": "^19.2.18",
"@angular/forms": "^19.2.0", "@angular/forms": "^19.2.18",
"@angular/material": "^19.2.16", "@angular/material": "^19.2.19",
"@angular/platform-browser": "^19.2.0", "@angular/platform-browser": "^19.2.18",
"@angular/platform-browser-dynamic": "^19.2.0", "@angular/platform-browser-dynamic": "^19.2.18",
"@angular/router": "^19.2.0", "@angular/router": "^19.2.18",
"rxjs": "~7.8.0", "rxjs": "~7.8.0",
"tslib": "^2.3.0", "tslib": "^2.3.0",
"zone.js": "~0.15.0" "zone.js": "~0.15.0"
}, },
"devDependencies": { "devDependencies": {
"@angular-devkit/build-angular": "^19.2.12", "@angular-devkit/build-angular": "^19.2.19",
"@angular/cli": "^19.2.12", "@angular/cli": "^19.2.19",
"@angular/compiler-cli": "^19.2.0", "@angular/compiler-cli": "^19.2.18",
"@types/jasmine": "~5.1.0", "@types/jasmine": "~5.1.0",
"jasmine-core": "~5.6.0", "jasmine-core": "~5.6.0",
"karma": "~6.4.0", "karma": "~6.4.0",
@@ -35,5 +38,8 @@
"karma-jasmine": "~5.1.0", "karma-jasmine": "~5.1.0",
"karma-jasmine-html-reporter": "~2.1.0", "karma-jasmine-html-reporter": "~2.1.0",
"typescript": "~5.7.2" "typescript": "~5.7.2"
},
"overrides": {
"tar": "7.5.6"
} }
} }

View File

@@ -1,37 +1,69 @@
<div class="calculator-container">
<mat-card> <mat-card>
<mat-card-title>3D Print Cost Calculator</mat-card-title> <mat-card-header>
<mat-card-title>3D Print Quote Calculator</mat-card-title>
<mat-card-subtitle>Bambu Lab A1 Estimation</mat-card-subtitle>
</mat-card-header>
<input <mat-card-content>
type="file" <div class="upload-section">
accept=".stl" <input type="file" (change)="onFileSelected($event)" accept=".stl" #fileInput style="display: none;">
(change)="onFileSelected($event)" <button mat-raised-button color="primary" (click)="fileInput.click()">
/> {{ file ? file.name : 'Select STL File' }}
<button
mat-raised-button
color="primary"
(click)="uploadAndCalculate()"
[disabled]="!file || loading"
>
{{ loading ? 'Calculating...' : 'Calculate' }}
</button> </button>
<mat-progress-spinner <button mat-raised-button color="accent"
*ngIf="loading" [disabled]="!file || loading"
diameter="30" (click)="uploadAndCalculate()">
mode="indeterminate" Calculate Quote
></mat-progress-spinner> </button>
<p *ngIf="error" class="error">{{ error }}</p>
<div *ngIf="results">
<p>Volume STL: {{ results.stl_volume_mm3 }} mm³</p>
<p>Superficie: {{ results.surface_area_mm2 }} mm²</p>
<p>Volume pareti: {{ results.wall_volume_mm3 }} mm³</p>
<p>Volume infill: {{ results.infill_volume_mm3 }} mm³</p>
<p>Volume stampa: {{ results.print_volume_mm3 }} mm³</p>
<p>Peso stimato: {{ results.weight_g }} g</p>
<p>Costo stimato: CHF {{ results.cost_chf }}</p>
<p>Tempo stimato: {{ results.time_min }} min</p>
</div> </div>
<div *ngIf="loading" class="spinner-container">
<mat-progress-spinner mode="indeterminate"></mat-progress-spinner>
<p>Slicing model... this may take a minute...</p>
</div>
<div *ngIf="error" class="error-message">
{{ error }}
</div>
<div *ngIf="results" class="results-section">
<div class="total-price">
<h3>Total Estimate: € {{ results.cost.total | number:'1.2-2' }}</h3>
</div>
<div class="details-grid">
<div class="detail-item">
<span class="label">Print Time:</span>
<span class="value">{{ results.print_time_formatted }}</span>
</div>
<div class="detail-item">
<span class="label">Material Used:</span>
<span class="value">{{ results.material_grams | number:'1.1-1' }} g</span>
</div>
</div>
<h4>Cost Breakdown</h4>
<ul class="breakdown-list">
<li>
<span>Material</span>
<span>€ {{ results.cost.material | number:'1.2-2' }}</span>
</li>
<li>
<span>Machine Time</span>
<span>€ {{ results.cost.machine | number:'1.2-2' }}</span>
</li>
<li>
<span>Energy</span>
<span>€ {{ results.cost.energy | number:'1.2-2' }}</span>
</li>
<li>
<span>Service/Markup</span>
<span>€ {{ results.cost.markup | number:'1.2-2' }}</span>
</li>
</ul>
</div>
</mat-card-content>
</mat-card> </mat-card>
</div>

View File

@@ -4,10 +4,22 @@ import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { HttpClient } from '@angular/common/http'; import { HttpClient } from '@angular/common/http';
import { MatCardModule } from '@angular/material/card'; import { MatCardModule } from '@angular/material/card';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatButtonModule } from '@angular/material/button'; import { MatButtonModule } from '@angular/material/button';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
interface QuoteResponse {
printer: string;
print_time_formatted: string;
material_grams: number;
cost: {
material: number;
machine: number;
energy: number;
markup: number;
total: number;
};
}
@Component({ @Component({
selector: 'app-calculator', selector: 'app-calculator',
standalone: true, standalone: true,
@@ -15,7 +27,6 @@ import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
CommonModule, CommonModule,
FormsModule, FormsModule,
MatCardModule, MatCardModule,
MatFormFieldModule,
MatButtonModule, MatButtonModule,
MatProgressSpinnerModule MatProgressSpinnerModule
], ],
@@ -24,7 +35,7 @@ import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
}) })
export class CalculatorComponent { export class CalculatorComponent {
file: File | null = null; file: File | null = null;
results: any = null; results: QuoteResponse | null = null;
error = ''; error = '';
loading = false; loading = false;
@@ -41,20 +52,24 @@ export class CalculatorComponent {
uploadAndCalculate(): void { uploadAndCalculate(): void {
if (!this.file) { if (!this.file) {
this.error = 'Seleziona un file STL prima di procedere.'; this.error = 'Please select a file first.';
return; return;
} }
const formData = new FormData(); const formData = new FormData();
formData.append('file', this.file); formData.append('file', this.file);
this.loading = true; this.loading = true;
this.http.post<any>('http://localhost:8000/calculate/stl', formData) this.error = '';
this.results = null;
this.http.post<QuoteResponse>('http://localhost:8000/calculate/stl', formData)
.subscribe({ .subscribe({
next: res => { next: res => {
this.results = res; this.results = res;
this.loading = false; this.loading = false;
}, },
error: err => { error: err => {
this.error = err.error?.detail || err.message; console.error(err);
this.error = err.error?.detail || "An error occurred during calculation.";
this.loading = false; this.loading = false;
} }
}); });

5
start.sh Normal file → Executable file
View File

@@ -1,4 +1,3 @@
# start.sh
#!/bin/bash #!/bin/bash
(cd backend && source venv/bin/activate && uvicorn main:app --reload) & echo "Starting Print Calculator with Docker Compose..."
(cd frontend && ng serve) docker-compose up --build