feat: add Orcaslicer and docker
This commit is contained in:
10
Makefile
Normal file
10
Makefile
Normal 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
|
||||
71
README.md
71
README.md
@@ -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
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
|
||||
|
||||
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:
|
||||
mesh = trimesh.load(percorso_stl)
|
||||
volume_modello = mesh.volume
|
||||
superficie = mesh.area
|
||||
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]
|
||||
|
||||
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
|
||||
)
|
||||
# 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()
|
||||
|
||||
peso = calcola_peso(volume_stampa)
|
||||
costo = calcola_costo(peso)
|
||||
tempo = stima_tempo(volume_stampa)
|
||||
all_lines = header_lines + footer_lines
|
||||
|
||||
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")
|
||||
for line in all_lines:
|
||||
line = line.strip()
|
||||
if not line.startswith(";"):
|
||||
continue
|
||||
|
||||
GCodeParser._parse_line(line, stats)
|
||||
|
||||
# Fallback calculation
|
||||
if stats["filament_weight_g"] == 0 and stats["filament_length_mm"] > 0:
|
||||
GCodeParser._calculate_weight_fallback(stats)
|
||||
|
||||
except Exception as e:
|
||||
print("Errore durante l'elaborazione:", e)
|
||||
logger.error(f"Error parsing G-code: {e}")
|
||||
|
||||
if __name__ == "__main__":
|
||||
if len(sys.argv) != 2:
|
||||
print("Uso: python calcolatore_stl.py modello.stl")
|
||||
else:
|
||||
main(sys.argv[1])
|
||||
return stats
|
||||
|
||||
@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())
|
||||
|
||||
# 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
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)
|
||||
|
||||
print_volume, wall_volume, infill_volume = calculate_volumes(
|
||||
total_volume_mm3=model_volume_mm3,
|
||||
surface_area_mm2=model_surface_area_mm2
|
||||
)
|
||||
# 2. Slice
|
||||
# slicer_service methods raise exceptions on failure
|
||||
slicer_service.slice_stl(input_path, output_path)
|
||||
|
||||
weight_g = calculate_weight(print_volume)
|
||||
cost_chf = calculate_cost(weight_g)
|
||||
time_min = estimate_time(print_volume)
|
||||
# 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.")
|
||||
|
||||
# 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
|
||||
28
docker-compose.yml
Normal file
28
docker-compose.yml
Normal 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
1
frontend/.nvmrc
Normal file
@@ -0,0 +1 @@
|
||||
22
|
||||
14
frontend/Dockerfile
Normal file
14
frontend/Dockerfile
Normal 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
9
frontend/nginx.conf
Normal 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;
|
||||
}
|
||||
}
|
||||
2137
frontend/package-lock.json
generated
2137
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
<input
|
||||
type="file"
|
||||
accept=".stl"
|
||||
(change)="onFileSelected($event)"
|
||||
/>
|
||||
|
||||
<button
|
||||
mat-raised-button
|
||||
color="primary"
|
||||
(click)="uploadAndCalculate()"
|
||||
[disabled]="!file || loading"
|
||||
>
|
||||
{{ loading ? 'Calculating...' : 'Calculate' }}
|
||||
<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>
|
||||
|
||||
<mat-progress-spinner
|
||||
*ngIf="loading"
|
||||
diameter="30"
|
||||
mode="indeterminate"
|
||||
></mat-progress-spinner>
|
||||
|
||||
<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>
|
||||
<button mat-raised-button color="accent"
|
||||
[disabled]="!file || loading"
|
||||
(click)="uploadAndCalculate()">
|
||||
Calculate Quote
|
||||
</button>
|
||||
</div>
|
||||
</mat-card>
|
||||
|
||||
<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>
|
||||
</div>
|
||||
@@ -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,20 +52,24 @@ 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;
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user