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

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

View File

@@ -1,37 +1,69 @@
<mat-card>
<mat-card-title>3D Print Cost Calculator</mat-card-title>
<div class="calculator-container">
<mat-card>
<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>
<mat-card-content>
<div class="upload-section">
<input type="file" (change)="onFileSelected($event)" accept=".stl" #fileInput style="display: none;">
<button mat-raised-button color="primary" (click)="fileInput.click()">
{{ file ? file.name : 'Select STL File' }}
</button>
<button mat-raised-button color="accent"
[disabled]="!file || loading"
(click)="uploadAndCalculate()">
Calculate Quote
</button>
</div>
<input
type="file"
accept=".stl"
(change)="onFileSelected($event)"
/>
<div *ngIf="loading" class="spinner-container">
<mat-progress-spinner mode="indeterminate"></mat-progress-spinner>
<p>Slicing model... this may take a minute...</p>
</div>
<button
mat-raised-button
color="primary"
(click)="uploadAndCalculate()"
[disabled]="!file || loading"
>
{{ loading ? 'Calculating...' : 'Calculate' }}
</button>
<div *ngIf="error" class="error-message">
{{ error }}
</div>
<mat-progress-spinner
*ngIf="loading"
diameter="30"
mode="indeterminate"
></mat-progress-spinner>
<div *ngIf="results" class="results-section">
<div class="total-price">
<h3>Total Estimate: € {{ results.cost.total | number:'1.2-2' }}</h3>
</div>
<p *ngIf="error" class="error">{{ error }}</p>
<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>
<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>
</mat-card>
<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>
</div>

View File

@@ -4,10 +4,22 @@ import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { HttpClient } from '@angular/common/http';
import { MatCardModule } from '@angular/material/card';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatButtonModule } from '@angular/material/button';
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({
selector: 'app-calculator',
standalone: true,
@@ -15,7 +27,6 @@ import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
CommonModule,
FormsModule,
MatCardModule,
MatFormFieldModule,
MatButtonModule,
MatProgressSpinnerModule
],
@@ -24,7 +35,7 @@ import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
})
export class CalculatorComponent {
file: File | null = null;
results: any = null;
results: QuoteResponse | null = null;
error = '';
loading = false;
@@ -41,22 +52,26 @@ export class CalculatorComponent {
uploadAndCalculate(): void {
if (!this.file) {
this.error = 'Seleziona un file STL prima di procedere.';
this.error = 'Please select a file first.';
return;
}
const formData = new FormData();
formData.append('file', this.file);
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({
next: res => {
this.results = res;
this.loading = false;
},
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;
}
});
}
}
}

5
start.sh Normal file → Executable file
View File

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