feat(web): java from python
This commit is contained in:
@@ -15,17 +15,17 @@ jobs:
|
|||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
# Evito actions/setup-python (spesso fragile su act_runner)
|
- name: Set up JDK 21
|
||||||
- name: Install Python deps + run tests
|
uses: actions/setup-java@v4
|
||||||
shell: bash
|
with:
|
||||||
|
java-version: '21'
|
||||||
|
distribution: 'temurin'
|
||||||
|
cache: maven
|
||||||
|
|
||||||
|
- name: Run Tests with Maven
|
||||||
run: |
|
run: |
|
||||||
apt-get update
|
cd backend
|
||||||
apt-get install -y --no-install-recommends python3 python3-pip
|
mvn test
|
||||||
python3 -m pip install --upgrade pip
|
|
||||||
python3 -m pip install -r backend/requirements.txt
|
|
||||||
python3 -m pip install pytest httpx
|
|
||||||
export PYTHONPATH="${PYTHONPATH}:$(pwd)/backend"
|
|
||||||
pytest backend/tests
|
|
||||||
|
|
||||||
build-and-push:
|
build-and-push:
|
||||||
needs: test-backend
|
needs: test-backend
|
||||||
|
|||||||
@@ -1,6 +1,16 @@
|
|||||||
FROM --platform=linux/amd64 python:3.10-slim-bookworm
|
# Stage 1: Build Java JAR
|
||||||
|
FROM maven:3.9-eclipse-temurin-21 AS build
|
||||||
|
WORKDIR /app
|
||||||
|
COPY pom.xml .
|
||||||
|
# Download dependencies first to cache them
|
||||||
|
RUN mvn dependency:go-offline
|
||||||
|
COPY src ./src
|
||||||
|
RUN mvn clean package -DskipTests
|
||||||
|
|
||||||
# Install system dependencies for OrcaSlicer (AppImage)
|
# Stage 2: Runtime Environment
|
||||||
|
FROM eclipse-temurin:21-jre-jammy
|
||||||
|
|
||||||
|
# Install system dependencies for OrcaSlicer (same as before)
|
||||||
RUN apt-get update && apt-get install -y \
|
RUN apt-get update && apt-get install -y \
|
||||||
wget \
|
wget \
|
||||||
p7zip-full \
|
p7zip-full \
|
||||||
@@ -9,35 +19,25 @@ RUN apt-get update && apt-get install -y \
|
|||||||
libgtk-3-0 \
|
libgtk-3-0 \
|
||||||
libdbus-1-3 \
|
libdbus-1-3 \
|
||||||
libwebkit2gtk-4.1-0 \
|
libwebkit2gtk-4.1-0 \
|
||||||
libwebkit2gtk-4.0-37 \
|
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
# Set working directory
|
# Install OrcaSlicer
|
||||||
WORKDIR /app
|
WORKDIR /opt
|
||||||
|
|
||||||
# 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 \
|
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 \
|
&& 7z x OrcaSlicer.AppImage -o/opt/orcaslicer \
|
||||||
&& chmod -R +x /opt/orcaslicer \
|
&& chmod -R +x /opt/orcaslicer \
|
||||||
&& rm OrcaSlicer.AppImage
|
&& rm OrcaSlicer.AppImage
|
||||||
|
|
||||||
# Add OrcaSlicer to PATH
|
|
||||||
ENV PATH="/opt/orcaslicer/usr/bin:${PATH}"
|
ENV PATH="/opt/orcaslicer/usr/bin:${PATH}"
|
||||||
|
# Set Slicer Path env variable for Java app
|
||||||
|
ENV SLICER_PATH="/opt/orcaslicer/AppRun"
|
||||||
|
|
||||||
# Install Python dependencies
|
WORKDIR /app
|
||||||
COPY requirements.txt .
|
# Copy JAR from build stage
|
||||||
RUN pip install --no-cache-dir -r requirements.txt
|
COPY --from=build /app/target/*.jar app.jar
|
||||||
|
# Copy profiles
|
||||||
|
COPY profiles ./profiles
|
||||||
|
|
||||||
# Create directories for app and temp files
|
EXPOSE 8080
|
||||||
RUN mkdir -p /app/temp /app/profiles
|
|
||||||
|
|
||||||
# Copy application code
|
CMD ["java", "-jar", "app.jar"]
|
||||||
COPY . .
|
|
||||||
|
|
||||||
# Expose port
|
|
||||||
EXPOSE 8000
|
|
||||||
|
|
||||||
# Run the application
|
|
||||||
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
|
|
||||||
|
|||||||
@@ -1,137 +0,0 @@
|
|||||||
from fastapi import APIRouter, UploadFile, File, HTTPException, Form
|
|
||||||
from models.quote_request import QuoteRequest, QuoteResponse
|
|
||||||
from slicer import slicer_service
|
|
||||||
from calculator import GCodeParser, QuoteCalculator
|
|
||||||
from config import settings
|
|
||||||
from profile_manager import ProfileManager
|
|
||||||
import os
|
|
||||||
import shutil
|
|
||||||
import uuid
|
|
||||||
import logging
|
|
||||||
import json
|
|
||||||
|
|
||||||
router = APIRouter()
|
|
||||||
logger = logging.getLogger("api")
|
|
||||||
profile_manager = ProfileManager()
|
|
||||||
|
|
||||||
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 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"
|
|
||||||
|
|
||||||
@router.post("/quote", response_model=QuoteResponse)
|
|
||||||
async def calculate_quote(
|
|
||||||
file: UploadFile = File(...),
|
|
||||||
# Compatible with form data if we parse manually or use specific dependencies.
|
|
||||||
# FastAPI handling of mixed File + JSON/Form is tricky.
|
|
||||||
# Easiest is to use Form(...) for fields.
|
|
||||||
machine: str = Form("bambu_a1"),
|
|
||||||
filament: str = Form("pla_basic"),
|
|
||||||
quality: str = Form("standard"),
|
|
||||||
layer_height: str = Form(None), # Form data comes as strings usually
|
|
||||||
infill_density: int = Form(None),
|
|
||||||
infill_pattern: str = Form(None),
|
|
||||||
support_enabled: bool = Form(False),
|
|
||||||
print_speed: int = Form(None)
|
|
||||||
):
|
|
||||||
"""
|
|
||||||
Endpoint for calculating print quote.
|
|
||||||
Accepts Multipart Form Data:
|
|
||||||
- file: The STL file
|
|
||||||
- machine, filament, quality: strings
|
|
||||||
- other overrides
|
|
||||||
"""
|
|
||||||
if not file.filename.lower().endswith(".stl"):
|
|
||||||
raise HTTPException(status_code=400, detail="Only .stl files are supported.")
|
|
||||||
if machine != "bambu_a1":
|
|
||||||
raise HTTPException(status_code=400, detail="Unsupported machine.")
|
|
||||||
|
|
||||||
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:
|
|
||||||
# 1. Save File
|
|
||||||
with open(input_path, "wb") as buffer:
|
|
||||||
shutil.copyfileobj(file.file, buffer)
|
|
||||||
|
|
||||||
# 2. Build Overrides
|
|
||||||
overrides = {}
|
|
||||||
if layer_height is not None and layer_height != "":
|
|
||||||
overrides["layer_height"] = layer_height
|
|
||||||
if infill_density is not None:
|
|
||||||
overrides["sparse_infill_density"] = f"{infill_density}%"
|
|
||||||
if infill_pattern:
|
|
||||||
overrides["sparse_infill_pattern"] = infill_pattern
|
|
||||||
if support_enabled: overrides["enable_support"] = "1"
|
|
||||||
if print_speed is not None:
|
|
||||||
overrides["default_print_speed"] = str(print_speed)
|
|
||||||
|
|
||||||
# 3. Slice
|
|
||||||
# Pass parameters to slicer service
|
|
||||||
slicer_service.slice_stl(
|
|
||||||
input_stl_path=input_path,
|
|
||||||
output_gcode_path=output_path,
|
|
||||||
machine=machine,
|
|
||||||
filament=filament,
|
|
||||||
quality=quality,
|
|
||||||
overrides=overrides
|
|
||||||
)
|
|
||||||
|
|
||||||
# 4. Parse
|
|
||||||
stats = GCodeParser.parse_metadata(output_path)
|
|
||||||
if stats["print_time_seconds"] == 0 and stats["filament_weight_g"] == 0:
|
|
||||||
raise HTTPException(status_code=500, detail="Slicing returned empty stats.")
|
|
||||||
|
|
||||||
# 5. Calculate
|
|
||||||
# We could allow filament cost override here too if passed in params
|
|
||||||
quote = QuoteCalculator.calculate(stats)
|
|
||||||
|
|
||||||
return QuoteResponse(
|
|
||||||
success=True,
|
|
||||||
data={
|
|
||||||
"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"]
|
|
||||||
},
|
|
||||||
"parameters": {
|
|
||||||
"machine": machine,
|
|
||||||
"filament": filament,
|
|
||||||
"quality": quality
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Quote error: {e}", exc_info=True)
|
|
||||||
return QuoteResponse(success=False, error=str(e))
|
|
||||||
|
|
||||||
finally:
|
|
||||||
cleanup_files([input_path, output_path])
|
|
||||||
|
|
||||||
@router.get("/profiles/available")
|
|
||||||
def get_profiles():
|
|
||||||
return {
|
|
||||||
"machines": profile_manager.list_machines(),
|
|
||||||
"filaments": profile_manager.list_filaments(),
|
|
||||||
"processes": profile_manager.list_processes()
|
|
||||||
}
|
|
||||||
@@ -1,174 +0,0 @@
|
|||||||
import re
|
|
||||||
import os
|
|
||||||
import logging
|
|
||||||
from typing import Dict, Any, Optional
|
|
||||||
from config import settings
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
if not os.path.exists(gcode_path):
|
|
||||||
logger.warning(f"GCode file not found for parsing: {gcode_path}")
|
|
||||||
return stats
|
|
||||||
|
|
||||||
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]
|
|
||||||
|
|
||||||
# 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()
|
|
||||||
|
|
||||||
all_lines = header_lines + footer_lines
|
|
||||||
|
|
||||||
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:
|
|
||||||
logger.error(f"Error parsing G-code: {e}")
|
|
||||||
|
|
||||||
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()
|
|
||||||
logger.info(f"Parsing time string (Header): '{time_str}'")
|
|
||||||
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:
|
|
||||||
time_str = parts[1].strip()
|
|
||||||
logger.info(f"Parsing time string (Footer): '{time_str}'")
|
|
||||||
stats["print_time_seconds"] = GCodeParser._parse_time_string(time_str)
|
|
||||||
|
|
||||||
# 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' or 'HH:MM:SS' to seconds.
|
|
||||||
"""
|
|
||||||
total_seconds = 0
|
|
||||||
|
|
||||||
# Try HH:MM:SS or MM:SS format
|
|
||||||
if ':' in time_str:
|
|
||||||
parts = time_str.split(':')
|
|
||||||
parts = [int(p) for p in parts]
|
|
||||||
if len(parts) == 3: # HH:MM:SS
|
|
||||||
total_seconds = parts[0] * 3600 + parts[1] * 60 + parts[2]
|
|
||||||
elif len(parts) == 2: # MM:SS
|
|
||||||
total_seconds = parts[0] * 60 + parts[1]
|
|
||||||
return total_seconds
|
|
||||||
|
|
||||||
# Original regex parsing for "1h 2m 3s"
|
|
||||||
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("ciaooo")
|
|
||||||
print(stats["print_time_seconds"])
|
|
||||||
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
|
|
||||||
|
|
||||||
logger.info("Cost Calculation:")
|
|
||||||
logger.info(f" - Use: {stats['filament_weight_g']:.2f}g @ {settings.FILAMENT_COST_PER_KG}€/kg = {material_cost:.2f}€")
|
|
||||||
logger.info(f" - Time: {print_time_hours:.4f}h @ {settings.MACHINE_COST_PER_HOUR}€/h = {machine_cost:.2f}€")
|
|
||||||
logger.info(f" - Power: {kwh_used:.4f}kWh @ {settings.ENERGY_COST_PER_KWH}€/kWh = {energy_cost:.2f}€")
|
|
||||||
logger.info(f" - Subtotal: {subtotal:.2f}€")
|
|
||||||
logger.info(f" - Total (Markup {settings.MARKUP_PERCENT}%): {total_price:.2f}€")
|
|
||||||
|
|
||||||
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"
|
|
||||||
}
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
import os
|
|
||||||
import sys
|
|
||||||
|
|
||||||
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
|
|
||||||
if sys.platform == "darwin":
|
|
||||||
_DEFAULT_SLICER_PATH = "/Applications/OrcaSlicer.app/Contents/MacOS/OrcaSlicer"
|
|
||||||
else:
|
|
||||||
_DEFAULT_SLICER_PATH = "/opt/orcaslicer/AppRun"
|
|
||||||
|
|
||||||
SLICER_PATH = os.environ.get("SLICER_PATH", _DEFAULT_SLICER_PATH)
|
|
||||||
ORCA_HOME = os.environ.get("ORCA_HOME", "/opt/orcaslicer")
|
|
||||||
|
|
||||||
# Defaults Profiles (Bambu A1)
|
|
||||||
MACHINE_PROFILE = os.path.join(PROFILES_DIR, "Bambu_Lab_A1_machine.json")
|
|
||||||
PROCESS_PROFILE = os.path.join(PROFILES_DIR, "Bambu_Process_0.20_Standard.json")
|
|
||||||
FILAMENT_PROFILE = os.path.join(PROFILES_DIR, "Bambu_PLA_Basic.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()
|
|
||||||
@@ -1,74 +0,0 @@
|
|||||||
import logging
|
|
||||||
import os
|
|
||||||
from fastapi import FastAPI
|
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
|
||||||
from config import settings
|
|
||||||
from api.routes import router as api_router
|
|
||||||
|
|
||||||
# Configure logging
|
|
||||||
logging.basicConfig(level=logging.INFO)
|
|
||||||
logger = logging.getLogger("main")
|
|
||||||
|
|
||||||
app = FastAPI(title="Print Calculator API")
|
|
||||||
|
|
||||||
# CORS Setup
|
|
||||||
app.add_middleware(
|
|
||||||
CORSMiddleware,
|
|
||||||
allow_origins=["*"],
|
|
||||||
allow_credentials=True,
|
|
||||||
allow_methods=["*"],
|
|
||||||
allow_headers=["*"],
|
|
||||||
)
|
|
||||||
|
|
||||||
# Ensure directories exist
|
|
||||||
os.makedirs(settings.TEMP_DIR, exist_ok=True)
|
|
||||||
|
|
||||||
# Include Router
|
|
||||||
app.include_router(api_router, prefix="/api")
|
|
||||||
|
|
||||||
# Legacy endpoint redirect or basic handler if needed for backward compatibility
|
|
||||||
# The frontend likely calls /calculate/stl.
|
|
||||||
# We should probably keep the old route or instruct user to update frontend.
|
|
||||||
# But for this task, let's remap the old route to the new logic if possible,
|
|
||||||
# or just expose the new route.
|
|
||||||
# The user request said: "Creare api/routes.py ... @app.post('/api/quote')"
|
|
||||||
# So we are creating a new endpoint.
|
|
||||||
# Existing frontend might break?
|
|
||||||
# The context says: "Currently uses hardcoded... Objective is to render system flexible... Frontend: Angular 19"
|
|
||||||
# The user didn't explicitly ask to update the frontend, but the new API is at /api/quote.
|
|
||||||
# I will keep the old "/calculate/stl" endpoint support by forwarding it or duplicating logic if critical,
|
|
||||||
# OR I'll assume the user will handle frontend updates.
|
|
||||||
# Better: I will alias the old route to the new one if parameters allow,
|
|
||||||
# but the new one expects Form data with different names maybe?
|
|
||||||
# Old: `/calculate/stl` just expected a file.
|
|
||||||
# I'll enable a simplified version on the old route for backward compat using defaults.
|
|
||||||
|
|
||||||
from fastapi import UploadFile, File
|
|
||||||
from api.routes import calculate_quote
|
|
||||||
|
|
||||||
@app.post("/calculate/stl")
|
|
||||||
async def legacy_calculate(file: UploadFile = File(...)):
|
|
||||||
"""Legacy endpoint compatibility"""
|
|
||||||
# Call the new logic with defaults
|
|
||||||
resp = await calculate_quote(file=file)
|
|
||||||
if not resp.success:
|
|
||||||
from fastapi import HTTPException
|
|
||||||
raise HTTPException(status_code=500, detail=resp.error)
|
|
||||||
|
|
||||||
# Map Check response to old format
|
|
||||||
data = resp.data
|
|
||||||
return {
|
|
||||||
"print_time_seconds": data.get("print_time_seconds", 0),
|
|
||||||
"print_time_formatted": data.get("print_time_formatted", ""),
|
|
||||||
"material_grams": data.get("material_grams", 0.0),
|
|
||||||
"cost": data.get("cost", {}),
|
|
||||||
"notes": ["Generated via Dynamic Slicer (Legacy Endpoint)"]
|
|
||||||
}
|
|
||||||
|
|
||||||
@app.get("/health")
|
|
||||||
def health_check():
|
|
||||||
return {"status": "ok", "slicer": settings.SLICER_PATH}
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
import uvicorn
|
|
||||||
uvicorn.run(app, host="0.0.0.0", port=8000)
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
from pydantic import BaseModel, Field, validator
|
|
||||||
from typing import Optional, Literal, Dict, Any
|
|
||||||
|
|
||||||
class QuoteRequest(BaseModel):
|
|
||||||
# File STL (base64 or path)
|
|
||||||
file_path: Optional[str] = None
|
|
||||||
file_base64: Optional[str] = None
|
|
||||||
|
|
||||||
# Parametri slicing
|
|
||||||
machine: str = Field(default="bambu_a1", description="Machine type")
|
|
||||||
filament: str = Field(default="pla_basic", description="Filament type")
|
|
||||||
quality: Literal["draft", "standard", "fine"] = Field(default="standard")
|
|
||||||
|
|
||||||
# Parametri opzionali
|
|
||||||
layer_height: Optional[float] = Field(None, ge=0.08, le=0.32)
|
|
||||||
infill_density: Optional[int] = Field(None, ge=0, le=100)
|
|
||||||
support_enabled: Optional[bool] = None
|
|
||||||
print_speed: Optional[int] = Field(None, ge=20, le=300)
|
|
||||||
|
|
||||||
# Pricing overrides
|
|
||||||
filament_cost_override: Optional[float] = None
|
|
||||||
|
|
||||||
@validator('machine')
|
|
||||||
def validate_machine(cls, v):
|
|
||||||
# This list should ideally be dynamic, but for validation purposes we start with known ones.
|
|
||||||
# Logic in ProfileManager can be looser or strict.
|
|
||||||
# For now, we allow the string through and let ProfileManager validate availability.
|
|
||||||
return v
|
|
||||||
|
|
||||||
@validator('filament')
|
|
||||||
def validate_filament(cls, v):
|
|
||||||
return v
|
|
||||||
|
|
||||||
class QuoteResponse(BaseModel):
|
|
||||||
success: bool
|
|
||||||
data: Optional[Dict[str, Any]] = None
|
|
||||||
error: Optional[str] = None
|
|
||||||
Binary file not shown.
Binary file not shown.
File diff suppressed because it is too large
Load Diff
47
backend/pom.xml
Normal file
47
backend/pom.xml
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||||
|
<modelVersion>4.0.0</modelVersion>
|
||||||
|
<parent>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-parent</artifactId>
|
||||||
|
<version>3.4.1</version>
|
||||||
|
<relativePath/> <!-- lookup parent from repository -->
|
||||||
|
</parent>
|
||||||
|
<groupId>com.printcalculator</groupId>
|
||||||
|
<artifactId>backend</artifactId>
|
||||||
|
<version>0.0.1-SNAPSHOT</version>
|
||||||
|
<name>print-calculator-backend</name>
|
||||||
|
<description>Print Calculator Backend in Java</description>
|
||||||
|
<properties>
|
||||||
|
<java.version>21</java.version>
|
||||||
|
</properties>
|
||||||
|
<dependencies>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-web</artifactId>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-devtools</artifactId>
|
||||||
|
<scope>runtime</scope>
|
||||||
|
<optional>true</optional>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-test</artifactId>
|
||||||
|
<scope>test</scope>
|
||||||
|
</dependency>
|
||||||
|
</dependencies>
|
||||||
|
|
||||||
|
<build>
|
||||||
|
<plugins>
|
||||||
|
<plugin>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-maven-plugin</artifactId>
|
||||||
|
</plugin>
|
||||||
|
</plugins>
|
||||||
|
</build>
|
||||||
|
|
||||||
|
</project>
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
from functools import lru_cache
|
|
||||||
import hashlib
|
|
||||||
from typing import Dict, Tuple
|
|
||||||
|
|
||||||
# We can't cache the profile manager instance itself easily if it's not a singleton,
|
|
||||||
# but we can cache the result of a merge function if we pass simple types.
|
|
||||||
# However, to avoid circular imports or complex dependency injection,
|
|
||||||
# we will just provide a helper to generate cache keys and a holder for logic if needed.
|
|
||||||
# For now, the ProfileManager will strictly determine *what* to merge.
|
|
||||||
# Validating the cache strategy: since file I/O is the bottleneck, we want to cache the *content*.
|
|
||||||
|
|
||||||
def get_cache_key(machine: str, filament: str, process: str) -> str:
|
|
||||||
"""Helper to create a unique cache key"""
|
|
||||||
data = f"{machine}|{filament}|{process}"
|
|
||||||
return hashlib.md5(data.encode()).hexdigest()
|
|
||||||
@@ -1,193 +0,0 @@
|
|||||||
import os
|
|
||||||
import json
|
|
||||||
import logging
|
|
||||||
from typing import Dict, List, Tuple, Optional
|
|
||||||
from profile_cache import get_cache_key
|
|
||||||
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
class ProfileManager:
|
|
||||||
def __init__(self, profiles_root: str = "profiles"):
|
|
||||||
# Assuming profiles_root is relative to backend or absolute
|
|
||||||
if not os.path.isabs(profiles_root):
|
|
||||||
base_dir = os.path.dirname(os.path.abspath(__file__))
|
|
||||||
self.profiles_root = os.path.join(base_dir, profiles_root)
|
|
||||||
else:
|
|
||||||
self.profiles_root = profiles_root
|
|
||||||
|
|
||||||
if not os.path.exists(self.profiles_root):
|
|
||||||
logger.warning(f"Profiles root not found: {self.profiles_root}")
|
|
||||||
|
|
||||||
def get_profiles(self, machine: str, filament: str, process: str) -> Tuple[Dict, Dict, Dict]:
|
|
||||||
"""
|
|
||||||
Main entry point to get merged profiles.
|
|
||||||
Args:
|
|
||||||
machine: e.g. "Bambu Lab A1 0.4 nozzle"
|
|
||||||
filament: e.g. "Bambu PLA Basic @BBL A1"
|
|
||||||
process: e.g. "0.20mm Standard @BBL A1"
|
|
||||||
"""
|
|
||||||
# Try cache first (although specific logic is needed if we cache the *result* or the *files*)
|
|
||||||
# Since we implemented a simple external cache helper, let's use it if we want,
|
|
||||||
# but for now we will rely on internal logic or the lru_cache decorator on a helper method.
|
|
||||||
# But wait, the `get_cached_profiles` in profile_cache.py calls `build_merged_profiles` which is logic WE need to implement.
|
|
||||||
# So we should probably move the implementation here and have the cache wrapper call it,
|
|
||||||
# OR just implement it here and wrap it.
|
|
||||||
|
|
||||||
return self._build_merged_profiles(machine, filament, process)
|
|
||||||
|
|
||||||
def _build_merged_profiles(self, machine_name: str, filament_name: str, process_name: str) -> Tuple[Dict, Dict, Dict]:
|
|
||||||
# We need to find the files.
|
|
||||||
# The naming convention in OrcaSlicer profiles usually involves the Vendor (e.g. BBL).
|
|
||||||
# We might need a mapping or search.
|
|
||||||
# For this implementation, we will assume we know the relative paths or search for them.
|
|
||||||
|
|
||||||
# Strategy: Search in all vendor subdirs for the specific JSON files.
|
|
||||||
# Because names are usually unique enough or we can specify the expected vendor.
|
|
||||||
# However, to be fast, we can map "machine_name" to a file path.
|
|
||||||
|
|
||||||
machine_file = self._find_profile_file(machine_name, "machine")
|
|
||||||
filament_file = self._find_profile_file(filament_name, "filament")
|
|
||||||
process_file = self._find_profile_file(process_name, "process")
|
|
||||||
|
|
||||||
if not machine_file:
|
|
||||||
raise FileNotFoundError(f"Machine profile not found: {machine_name}")
|
|
||||||
if not filament_file:
|
|
||||||
raise FileNotFoundError(f"Filament profile not found: {filament_name}")
|
|
||||||
if not process_file:
|
|
||||||
raise FileNotFoundError(f"Process profile not found: {process_name}")
|
|
||||||
|
|
||||||
machine_profile = self._merge_chain(machine_file)
|
|
||||||
filament_profile = self._merge_chain(filament_file)
|
|
||||||
process_profile = self._merge_chain(process_file)
|
|
||||||
|
|
||||||
# Apply patches
|
|
||||||
machine_profile = self._apply_patches(machine_profile, "machine")
|
|
||||||
process_profile = self._apply_patches(process_profile, "process")
|
|
||||||
|
|
||||||
return machine_profile, process_profile, filament_profile
|
|
||||||
|
|
||||||
def _find_profile_file(self, profile_name: str, profile_type: str) -> Optional[str]:
|
|
||||||
"""
|
|
||||||
Searches for a profile file by name in the profiles directory.
|
|
||||||
The name should match the filename (without .json possibly) or be a precise match.
|
|
||||||
"""
|
|
||||||
# Add .json if missing
|
|
||||||
filename = profile_name if profile_name.endswith(".json") else f"{profile_name}.json"
|
|
||||||
|
|
||||||
for root, dirs, files in os.walk(self.profiles_root):
|
|
||||||
if filename in files:
|
|
||||||
# Check if it is in the correct type folder (machine, filament, process)
|
|
||||||
# OrcaSlicer structure: Vendor/process/file.json
|
|
||||||
# We optionally verify parent dir
|
|
||||||
if os.path.basename(root) == profile_type or profile_type in root:
|
|
||||||
return os.path.join(root, filename)
|
|
||||||
|
|
||||||
# Fallback: if we simply found it, maybe just return it?
|
|
||||||
# Some common files might be in root or other places.
|
|
||||||
# Let's return it if we are fairly sure.
|
|
||||||
return os.path.join(root, filename)
|
|
||||||
|
|
||||||
return None
|
|
||||||
|
|
||||||
def _merge_chain(self, final_file_path: str) -> Dict:
|
|
||||||
"""
|
|
||||||
Resolves inheritance and merges.
|
|
||||||
"""
|
|
||||||
chain = []
|
|
||||||
current_path = final_file_path
|
|
||||||
|
|
||||||
# 1. Build chain
|
|
||||||
while current_path:
|
|
||||||
chain.insert(0, current_path) # Prepend
|
|
||||||
|
|
||||||
with open(current_path, 'r', encoding='utf-8') as f:
|
|
||||||
try:
|
|
||||||
data = json.load(f)
|
|
||||||
except json.JSONDecodeError as e:
|
|
||||||
logger.error(f"Failed to decode JSON: {current_path}")
|
|
||||||
raise e
|
|
||||||
|
|
||||||
inherits = data.get("inherits")
|
|
||||||
if inherits:
|
|
||||||
# Resolve inherited file
|
|
||||||
# It is usually in the same directory or relative.
|
|
||||||
# OrcaSlicer logic: checks same dir, then parent, etc.
|
|
||||||
# Usually it's in the same directory.
|
|
||||||
parent_dir = os.path.dirname(current_path)
|
|
||||||
inherited_path = os.path.join(parent_dir, inherits)
|
|
||||||
|
|
||||||
# Special case: if not found, it might be in a common folder?
|
|
||||||
# But OrcaSlicer usually keeps them local or in specific common dirs.
|
|
||||||
if not os.path.exists(inherited_path) and not inherits.endswith(".json"):
|
|
||||||
inherited_path += ".json"
|
|
||||||
|
|
||||||
if os.path.exists(inherited_path):
|
|
||||||
current_path = inherited_path
|
|
||||||
else:
|
|
||||||
# Could be a system common file not in the same dir?
|
|
||||||
# For simplicty, try to look up in the same generic type folder across the vendor?
|
|
||||||
# Or just fail for now.
|
|
||||||
# Often "fdm_machine_common.json" is at the Vendor root or similar?
|
|
||||||
# Let's try searching recursively if not found in place.
|
|
||||||
found = self._find_profile_file(inherits, "any") # "any" type
|
|
||||||
if found:
|
|
||||||
current_path = found
|
|
||||||
else:
|
|
||||||
logger.warning(f"Inherited profile '{inherits}' not found for '{current_path}' (Root: {self.profiles_root})")
|
|
||||||
current_path = None
|
|
||||||
else:
|
|
||||||
current_path = None
|
|
||||||
|
|
||||||
# 2. Merge
|
|
||||||
merged = {}
|
|
||||||
for path in chain:
|
|
||||||
with open(path, 'r', encoding='utf-8') as f:
|
|
||||||
data = json.load(f)
|
|
||||||
# Shallow update
|
|
||||||
merged.update(data)
|
|
||||||
|
|
||||||
# Remove metadata
|
|
||||||
merged.pop("inherits", None)
|
|
||||||
|
|
||||||
return merged
|
|
||||||
|
|
||||||
def _apply_patches(self, profile: Dict, profile_type: str) -> Dict:
|
|
||||||
if profile_type == "machine":
|
|
||||||
# Patch: G92 E0 to ensure extrusion reference text matches
|
|
||||||
lcg = profile.get("layer_change_gcode", "")
|
|
||||||
if "G92 E0" not in lcg:
|
|
||||||
# Append neatly
|
|
||||||
if lcg and not lcg.endswith("\n"):
|
|
||||||
lcg += "\n"
|
|
||||||
lcg += "G92 E0"
|
|
||||||
profile["layer_change_gcode"] = lcg
|
|
||||||
|
|
||||||
# Patch: ensure printable height is sufficient?
|
|
||||||
# Only if necessary. For now, trust the profile.
|
|
||||||
|
|
||||||
elif profile_type == "process":
|
|
||||||
# Optional: Disable skirt/brim if we want a "clean" print estimation?
|
|
||||||
# Actually, for accurate cost, we SHOULD include skirt/brim if the profile has it.
|
|
||||||
pass
|
|
||||||
|
|
||||||
return profile
|
|
||||||
|
|
||||||
def list_machines(self) -> List[str]:
|
|
||||||
# Simple helper to list available machine JSONs
|
|
||||||
return self._list_profiles_by_type("machine")
|
|
||||||
|
|
||||||
def list_filaments(self) -> List[str]:
|
|
||||||
return self._list_profiles_by_type("filament")
|
|
||||||
|
|
||||||
def list_processes(self) -> List[str]:
|
|
||||||
return self._list_profiles_by_type("process")
|
|
||||||
|
|
||||||
def _list_profiles_by_type(self, ptype: str) -> List[str]:
|
|
||||||
results = []
|
|
||||||
for root, dirs, files in os.walk(self.profiles_root):
|
|
||||||
if os.path.basename(root) == ptype:
|
|
||||||
for f in files:
|
|
||||||
if f.endswith(".json") and "common" not in f:
|
|
||||||
results.append(f.replace(".json", ""))
|
|
||||||
return sorted(results)
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
{
|
|
||||||
"quality_to_process": {
|
|
||||||
"draft": "0.28mm Extra Draft @BBL A1",
|
|
||||||
"standard": "0.20mm Standard @BBL A1",
|
|
||||||
"fine": "0.12mm Fine @BBL A1"
|
|
||||||
},
|
|
||||||
"filament_costs": {
|
|
||||||
"pla_basic": 20.0,
|
|
||||||
"petg_basic": 25.0,
|
|
||||||
"abs_basic": 22.0,
|
|
||||||
"tpu_95a": 35.0
|
|
||||||
},
|
|
||||||
"filament_to_profile": {
|
|
||||||
"pla_basic": "Bambu PLA Basic @BBL A1",
|
|
||||||
"petg_basic": "Bambu PETG Basic @BBL A1",
|
|
||||||
"abs_basic": "Bambu ABS @BBL A1",
|
|
||||||
"tpu_95a": "Bambu TPU 95A @BBL A1"
|
|
||||||
},
|
|
||||||
"machine_to_profile": {
|
|
||||||
"bambu_a1": "Bambu Lab A1 0.4 nozzle",
|
|
||||||
"bambu_x1": "Bambu Lab X1 Carbon 0.4 nozzle",
|
|
||||||
"bambu_p1s": "Bambu Lab P1S 0.4 nozzle"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
fastapi==0.109.0
|
|
||||||
uvicorn==0.27.0
|
|
||||||
python-multipart==0.0.6
|
|
||||||
requests==2.31.0
|
|
||||||
@@ -1,154 +0,0 @@
|
|||||||
import subprocess
|
|
||||||
import os
|
|
||||||
import json
|
|
||||||
import logging
|
|
||||||
import shutil
|
|
||||||
import tempfile
|
|
||||||
from typing import Optional, Dict
|
|
||||||
from config import settings
|
|
||||||
from profile_manager import ProfileManager
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
class SlicerService:
|
|
||||||
def __init__(self):
|
|
||||||
self.profile_manager = ProfileManager()
|
|
||||||
|
|
||||||
def slice_stl(
|
|
||||||
self,
|
|
||||||
input_stl_path: str,
|
|
||||||
output_gcode_path: str,
|
|
||||||
machine: str = "bambu_a1",
|
|
||||||
filament: str = "pla_basic",
|
|
||||||
quality: str = "standard",
|
|
||||||
overrides: Optional[Dict] = None
|
|
||||||
) -> bool:
|
|
||||||
"""
|
|
||||||
Runs OrcaSlicer in headless mode with dynamic profiles.
|
|
||||||
"""
|
|
||||||
if not os.path.exists(settings.SLICER_PATH):
|
|
||||||
raise RuntimeError(f"Slicer executable not found at: {settings.SLICER_PATH}")
|
|
||||||
|
|
||||||
if not os.path.exists(input_stl_path):
|
|
||||||
raise FileNotFoundError(f"STL file not found: {input_stl_path}")
|
|
||||||
|
|
||||||
# 1. Get Merged Profiles
|
|
||||||
# Use simple mapping if the input is short code (bambu_a1) vs full name
|
|
||||||
# For now, we assume the caller solves the mapping or passes full names?
|
|
||||||
# Actually, the user wants "Bambu A1" from API to map to "Bambu Lab A1 0.4 nozzle"
|
|
||||||
# We should use the mapping logic here or in the caller?
|
|
||||||
# The implementation plan said "profile_mappings.json" maps keys.
|
|
||||||
# It's better to handle mapping in the Service layer or Manager.
|
|
||||||
# Let's load the mapping in the service for now, or use a helper.
|
|
||||||
|
|
||||||
# We'll use a helper method to resolve names to full profile names using the loaded mapping.
|
|
||||||
machine_p, filament_p, quality_p = self._resolve_profile_names(machine, filament, quality)
|
|
||||||
|
|
||||||
try:
|
|
||||||
m_profile, p_profile, f_profile = self.profile_manager.get_profiles(machine_p, filament_p, quality_p)
|
|
||||||
except FileNotFoundError as e:
|
|
||||||
logger.error(f"Profile error: {e}")
|
|
||||||
raise RuntimeError(f"Profile generation failed: {e}")
|
|
||||||
|
|
||||||
# 2. Apply Overrides
|
|
||||||
if overrides:
|
|
||||||
p_profile = self._apply_overrides(p_profile, overrides)
|
|
||||||
# Some overrides might apply to machine or filament, but mostly process.
|
|
||||||
# E.g. layer_height is in process.
|
|
||||||
|
|
||||||
# 3. Write Temp Profiles
|
|
||||||
# We create a temp dir for this slice job
|
|
||||||
output_dir = os.path.dirname(output_gcode_path)
|
|
||||||
# We keep temp profiles in a hidden folder or just temp
|
|
||||||
# Using a context manager for temp dir might be safer but we need it for the subprocess duration
|
|
||||||
|
|
||||||
with tempfile.TemporaryDirectory() as temp_dir:
|
|
||||||
m_path = os.path.join(temp_dir, "machine.json")
|
|
||||||
p_path = os.path.join(temp_dir, "process.json")
|
|
||||||
f_path = os.path.join(temp_dir, "filament.json")
|
|
||||||
|
|
||||||
with open(m_path, 'w') as f: json.dump(m_profile, f)
|
|
||||||
with open(p_path, 'w') as f: json.dump(p_profile, f)
|
|
||||||
with open(f_path, 'w') as f: json.dump(f_profile, f)
|
|
||||||
|
|
||||||
# 4. Build Command
|
|
||||||
command = self._build_slicer_command(input_stl_path, output_dir, m_path, p_path, f_path)
|
|
||||||
|
|
||||||
logger.info(f"Starting slicing for {input_stl_path} [M:{machine_p} F:{filament_p} Q:{quality_p}]")
|
|
||||||
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:
|
|
||||||
# Cleanup is automatic via tempfile, but we might want to preserve invalid gcode?
|
|
||||||
raise RuntimeError(f"Slicing failed: {e.stderr if e.stderr else e.stdout}")
|
|
||||||
|
|
||||||
def _resolve_profile_names(self, m: str, f: str, q: str) -> tuple[str, str, str]:
|
|
||||||
# Load mappings
|
|
||||||
# Allow passing full names if they don't exist in mapping
|
|
||||||
mapping_path = os.path.join(os.path.dirname(__file__), "profile_mappings.json")
|
|
||||||
try:
|
|
||||||
with open(mapping_path, 'r') as fp:
|
|
||||||
mappings = json.load(fp)
|
|
||||||
except Exception:
|
|
||||||
logger.warning("Could not load profile_mappings.json, using inputs as raw names.")
|
|
||||||
return m, f, q
|
|
||||||
|
|
||||||
m_real = mappings.get("machine_to_profile", {}).get(m, m)
|
|
||||||
f_real = mappings.get("filament_to_profile", {}).get(f, f)
|
|
||||||
q_real = mappings.get("quality_to_process", {}).get(q, q)
|
|
||||||
|
|
||||||
return m_real, f_real, q_real
|
|
||||||
|
|
||||||
def _apply_overrides(self, profile: Dict, overrides: Dict) -> Dict:
|
|
||||||
for k, v in overrides.items():
|
|
||||||
# OrcaSlicer values are often strings
|
|
||||||
profile[k] = str(v)
|
|
||||||
return profile
|
|
||||||
|
|
||||||
def _build_slicer_command(self, input_path: str, output_dir: str, m_path: str, p_path: str, f_path: str) -> list:
|
|
||||||
# Settings format: "machine_file;process_file" (filament separate)
|
|
||||||
settings_arg = f"{m_path};{p_path}"
|
|
||||||
|
|
||||||
return [
|
|
||||||
settings.SLICER_PATH,
|
|
||||||
"--load-settings", settings_arg,
|
|
||||||
"--load-filaments", f_path,
|
|
||||||
"--ensure-on-bed",
|
|
||||||
"--arrange", "1",
|
|
||||||
"--slice", "0",
|
|
||||||
"--outputdir", output_dir,
|
|
||||||
input_path
|
|
||||||
]
|
|
||||||
|
|
||||||
def _run_command(self, command: list):
|
|
||||||
# logging and running logic similar to before
|
|
||||||
logger.debug(f"Exec: {' '.join(command)}")
|
|
||||||
result = subprocess.run(
|
|
||||||
command,
|
|
||||||
check=False,
|
|
||||||
stdout=subprocess.PIPE,
|
|
||||||
stderr=subprocess.PIPE,
|
|
||||||
text=True
|
|
||||||
)
|
|
||||||
if result.returncode != 0:
|
|
||||||
logger.error(f"Slicer Error: {result.stderr}")
|
|
||||||
raise subprocess.CalledProcessError(
|
|
||||||
result.returncode, command, output=result.stdout, stderr=result.stderr
|
|
||||||
)
|
|
||||||
|
|
||||||
def _finalize_output(self, output_dir: str, input_path: str, target_path: str):
|
|
||||||
input_basename = os.path.basename(input_path)
|
|
||||||
expected_name = os.path.splitext(input_basename)[0] + ".gcode"
|
|
||||||
generated_path = os.path.join(output_dir, expected_name)
|
|
||||||
|
|
||||||
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:
|
|
||||||
shutil.move(generated_path, target_path)
|
|
||||||
|
|
||||||
slicer_service = SlicerService()
|
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
package com.printcalculator;
|
||||||
|
|
||||||
|
import org.springframework.boot.SpringApplication;
|
||||||
|
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||||
|
|
||||||
|
@SpringBootApplication
|
||||||
|
public class BackendApplication {
|
||||||
|
|
||||||
|
public static void main(String[] args) {
|
||||||
|
SpringApplication.run(BackendApplication.class, args);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
package com.printcalculator.config;
|
||||||
|
|
||||||
|
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
|
||||||
|
@Configuration
|
||||||
|
@ConfigurationProperties(prefix = "pricing")
|
||||||
|
public class AppProperties {
|
||||||
|
|
||||||
|
private double filamentCostPerKg;
|
||||||
|
private double machineCostPerHour;
|
||||||
|
private double energyCostPerKwh;
|
||||||
|
private double printerPowerWatts;
|
||||||
|
private double markupPercent;
|
||||||
|
|
||||||
|
private String slicerPath;
|
||||||
|
private String profilesRoot;
|
||||||
|
|
||||||
|
// Getters and Setters needed for Spring binding
|
||||||
|
|
||||||
|
public double getFilamentCostPerKg() { return filamentCostPerKg; }
|
||||||
|
public void setFilamentCostPerKg(double filamentCostPerKg) { this.filamentCostPerKg = filamentCostPerKg; }
|
||||||
|
|
||||||
|
public double getMachineCostPerHour() { return machineCostPerHour; }
|
||||||
|
public void setMachineCostPerHour(double machineCostPerHour) { this.machineCostPerHour = machineCostPerHour; }
|
||||||
|
|
||||||
|
public double getEnergyCostPerKwh() { return energyCostPerKwh; }
|
||||||
|
public void setEnergyCostPerKwh(double energyCostPerKwh) { this.energyCostPerKwh = energyCostPerKwh; }
|
||||||
|
|
||||||
|
public double getPrinterPowerWatts() { return printerPowerWatts; }
|
||||||
|
public void setPrinterPowerWatts(double printerPowerWatts) { this.printerPowerWatts = printerPowerWatts; }
|
||||||
|
|
||||||
|
public double getMarkupPercent() { return markupPercent; }
|
||||||
|
public void setMarkupPercent(double markupPercent) { this.markupPercent = markupPercent; }
|
||||||
|
|
||||||
|
// Slicer props are not under "pricing" prefix in properties file?
|
||||||
|
// Wait, in application.properties I put them at root level/custom.
|
||||||
|
// Let's fix this class to map correctly or change prefix.
|
||||||
|
// I'll make a separate section or just bind manually.
|
||||||
|
// Actually, I'll just add @Value in services for simplicity or fix the prefix structure.
|
||||||
|
// Let's stick to standard @Value for simple paths if this is messy.
|
||||||
|
// Or better, creating a dedicated SlicerProperties.
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
package com.printcalculator.config;
|
||||||
|
|
||||||
|
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
|
||||||
|
@Configuration
|
||||||
|
@ConfigurationProperties(prefix = "")
|
||||||
|
// Hack: standard prefix is usually required. I'll use @Value in service or correct this.
|
||||||
|
// Better: make SlicerConfig class.
|
||||||
|
public class SlicerConfig {
|
||||||
|
// Intentionally empty, will use @Value in service for simplicity
|
||||||
|
// or fix in next step.
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
package com.printcalculator.controller;
|
||||||
|
|
||||||
|
import com.printcalculator.model.PrintStats;
|
||||||
|
import com.printcalculator.model.QuoteResult;
|
||||||
|
import com.printcalculator.service.QuoteCalculator;
|
||||||
|
import com.printcalculator.service.SlicerService;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
import org.springframework.web.multipart.MultipartFile;
|
||||||
|
|
||||||
|
import java.io.File;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@CrossOrigin(origins = "*") // Allow all for development
|
||||||
|
public class QuoteController {
|
||||||
|
|
||||||
|
private final SlicerService slicerService;
|
||||||
|
private final QuoteCalculator quoteCalculator;
|
||||||
|
|
||||||
|
// Defaults
|
||||||
|
private static final String DEFAULT_MACHINE = "Bambu_Lab_A1_machine";
|
||||||
|
private static final String DEFAULT_FILAMENT = "Bambu_PLA_Basic";
|
||||||
|
private static final String DEFAULT_PROCESS = "Bambu_Process_0.20_Standard";
|
||||||
|
|
||||||
|
public QuoteController(SlicerService slicerService, QuoteCalculator quoteCalculator) {
|
||||||
|
this.slicerService = slicerService;
|
||||||
|
this.quoteCalculator = quoteCalculator;
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/api/quote")
|
||||||
|
public ResponseEntity<QuoteResult> calculateQuote(
|
||||||
|
@RequestParam("file") MultipartFile file,
|
||||||
|
@RequestParam(value = "machine", defaultValue = DEFAULT_MACHINE) String machine,
|
||||||
|
@RequestParam(value = "filament", defaultValue = DEFAULT_FILAMENT) String filament,
|
||||||
|
@RequestParam(value = "process", defaultValue = DEFAULT_PROCESS) String process
|
||||||
|
) throws IOException {
|
||||||
|
|
||||||
|
return processRequest(file, machine, filament, process);
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/calculate/stl")
|
||||||
|
public ResponseEntity<QuoteResult> legacyCalculate(
|
||||||
|
@RequestParam("file") MultipartFile file
|
||||||
|
) throws IOException {
|
||||||
|
// Legacy endpoint uses defaults
|
||||||
|
return processRequest(file, DEFAULT_MACHINE, DEFAULT_FILAMENT, DEFAULT_PROCESS);
|
||||||
|
}
|
||||||
|
|
||||||
|
private ResponseEntity<QuoteResult> processRequest(MultipartFile file, String machine, String filament, String process) throws IOException {
|
||||||
|
if (file.isEmpty()) {
|
||||||
|
return ResponseEntity.badRequest().build();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save uploaded file temporarily
|
||||||
|
Path tempInput = Files.createTempFile("upload_", "_" + file.getOriginalFilename());
|
||||||
|
try {
|
||||||
|
file.transferTo(tempInput.toFile());
|
||||||
|
|
||||||
|
// Slice
|
||||||
|
PrintStats stats = slicerService.slice(tempInput.toFile(), machine, filament, process);
|
||||||
|
|
||||||
|
// Calculate Quote
|
||||||
|
QuoteResult result = quoteCalculator.calculate(stats);
|
||||||
|
|
||||||
|
return ResponseEntity.ok(result);
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
e.printStackTrace();
|
||||||
|
return ResponseEntity.internalServerError().build(); // Simplify error handling for now
|
||||||
|
} finally {
|
||||||
|
Files.deleteIfExists(tempInput);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
package com.printcalculator.model;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
|
||||||
|
public record CostBreakdown(
|
||||||
|
BigDecimal materialCost,
|
||||||
|
BigDecimal machineCost,
|
||||||
|
BigDecimal energyCost,
|
||||||
|
BigDecimal subtotal,
|
||||||
|
BigDecimal markupAmount
|
||||||
|
) {}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
package com.printcalculator.model;
|
||||||
|
|
||||||
|
public record PrintStats(
|
||||||
|
long printTimeSeconds,
|
||||||
|
String printTimeFormatted,
|
||||||
|
double filamentWeightGrams,
|
||||||
|
double filamentLengthMm
|
||||||
|
) {}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
package com.printcalculator.model;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
public record QuoteResult(
|
||||||
|
BigDecimal totalPrice,
|
||||||
|
String currency,
|
||||||
|
PrintStats stats,
|
||||||
|
CostBreakdown breakdown,
|
||||||
|
List<String> notes
|
||||||
|
) {}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
package com.printcalculator.service;
|
||||||
|
|
||||||
|
import com.printcalculator.model.PrintStats;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.io.BufferedReader;
|
||||||
|
import java.io.File;
|
||||||
|
import java.io.FileReader;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.util.regex.Matcher;
|
||||||
|
import java.util.regex.Pattern;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
public class GCodeParser {
|
||||||
|
|
||||||
|
private static final Pattern TIME_PATTERN = Pattern.compile("estimated printing time = (.*)");
|
||||||
|
private static final Pattern FILAMENT_G_PATTERN = Pattern.compile("filament used \\[g\\] = (.*)");
|
||||||
|
private static final Pattern FILAMENT_MM_PATTERN = Pattern.compile("filament used \\[mm\\] = (.*)");
|
||||||
|
|
||||||
|
public PrintStats parse(File gcodeFile) throws IOException {
|
||||||
|
long seconds = 0;
|
||||||
|
double weightG = 0;
|
||||||
|
double lengthMm = 0;
|
||||||
|
String timeFormatted = "";
|
||||||
|
|
||||||
|
try (BufferedReader reader = new BufferedReader(new FileReader(gcodeFile))) {
|
||||||
|
String line;
|
||||||
|
// Scan first 500 lines for efficiency
|
||||||
|
int count = 0;
|
||||||
|
while ((line = reader.readLine()) != null && count < 500) {
|
||||||
|
line = line.trim();
|
||||||
|
if (!line.startsWith(";")) {
|
||||||
|
count++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
Matcher timeMatcher = TIME_PATTERN.matcher(line);
|
||||||
|
if (timeMatcher.find()) {
|
||||||
|
timeFormatted = timeMatcher.group(1).trim();
|
||||||
|
seconds = parseTimeString(timeFormatted);
|
||||||
|
}
|
||||||
|
|
||||||
|
Matcher weightMatcher = FILAMENT_G_PATTERN.matcher(line);
|
||||||
|
if (weightMatcher.find()) {
|
||||||
|
try {
|
||||||
|
weightG = Double.parseDouble(weightMatcher.group(1).trim());
|
||||||
|
} catch (NumberFormatException ignored) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
Matcher lengthMatcher = FILAMENT_MM_PATTERN.matcher(line);
|
||||||
|
if (lengthMatcher.find()) {
|
||||||
|
try {
|
||||||
|
lengthMm = Double.parseDouble(lengthMatcher.group(1).trim());
|
||||||
|
} catch (NumberFormatException ignored) {}
|
||||||
|
}
|
||||||
|
count++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return new PrintStats(seconds, timeFormatted, weightG, lengthMm);
|
||||||
|
}
|
||||||
|
|
||||||
|
private long parseTimeString(String timeStr) {
|
||||||
|
// Formats: "1d 2h 3m 4s" or "1h 20m 10s"
|
||||||
|
long totalSeconds = 0;
|
||||||
|
|
||||||
|
Matcher d = Pattern.compile("(\\d+)d").matcher(timeStr);
|
||||||
|
if (d.find()) totalSeconds += Long.parseLong(d.group(1)) * 86400;
|
||||||
|
|
||||||
|
Matcher h = Pattern.compile("(\\d+)h").matcher(timeStr);
|
||||||
|
if (h.find()) totalSeconds += Long.parseLong(h.group(1)) * 3600;
|
||||||
|
|
||||||
|
Matcher m = Pattern.compile("(\\d+)m").matcher(timeStr);
|
||||||
|
if (m.find()) totalSeconds += Long.parseLong(m.group(1)) * 60;
|
||||||
|
|
||||||
|
Matcher s = Pattern.compile("(\\d+)s").matcher(timeStr);
|
||||||
|
if (s.find()) totalSeconds += Long.parseLong(s.group(1));
|
||||||
|
|
||||||
|
return totalSeconds;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
package com.printcalculator.service;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import com.fasterxml.jackson.databind.node.ObjectNode;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.io.File;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.nio.file.Paths;
|
||||||
|
import java.util.Iterator;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.logging.Logger;
|
||||||
|
import java.util.stream.Stream;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
public class ProfileManager {
|
||||||
|
|
||||||
|
private static final Logger logger = Logger.getLogger(ProfileManager.class.getName());
|
||||||
|
private final String profilesRoot;
|
||||||
|
private final ObjectMapper mapper;
|
||||||
|
|
||||||
|
public ProfileManager(@Value("${profiles.root:profiles}") String profilesRoot, ObjectMapper mapper) {
|
||||||
|
this.profilesRoot = profilesRoot;
|
||||||
|
this.mapper = mapper;
|
||||||
|
}
|
||||||
|
|
||||||
|
public ObjectNode getMergedProfile(String profileName, String type) throws IOException {
|
||||||
|
Path profilePath = findProfileFile(profileName, type);
|
||||||
|
if (profilePath == null) {
|
||||||
|
throw new IOException("Profile not found: " + profileName);
|
||||||
|
}
|
||||||
|
return resolveInheritance(profilePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Path findProfileFile(String name, String type) {
|
||||||
|
// Simple search: look for name.json in the profiles_root recursively
|
||||||
|
// Type could be "machine", "process", "filament" to narrow down, but for now global search
|
||||||
|
String filename = name.endsWith(".json") ? name : name + ".json";
|
||||||
|
|
||||||
|
try (Stream<Path> stream = Files.walk(Paths.get(profilesRoot))) {
|
||||||
|
Optional<Path> found = stream
|
||||||
|
.filter(p -> p.getFileName().toString().equals(filename))
|
||||||
|
.findFirst();
|
||||||
|
return found.orElse(null);
|
||||||
|
} catch (IOException e) {
|
||||||
|
logger.severe("Error searching for profile: " + e.getMessage());
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private ObjectNode resolveInheritance(Path currentPath) throws IOException {
|
||||||
|
// 1. Load current
|
||||||
|
JsonNode currentNode = mapper.readTree(currentPath.toFile());
|
||||||
|
|
||||||
|
// 2. Check inherits
|
||||||
|
if (currentNode.has("inherits")) {
|
||||||
|
String parentName = currentNode.get("inherits").asText();
|
||||||
|
// Try to find parent in same directory or standard search
|
||||||
|
Path parentPath = currentPath.getParent().resolve(parentName);
|
||||||
|
if (!Files.exists(parentPath)) {
|
||||||
|
// If not in same dir, search globally
|
||||||
|
parentPath = findProfileFile(parentName, "any");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parentPath != null && Files.exists(parentPath)) {
|
||||||
|
// Recursive call
|
||||||
|
ObjectNode parentNode = resolveInheritance(parentPath);
|
||||||
|
// Merge current into parent (child overrides parent)
|
||||||
|
merge(parentNode, (ObjectNode) currentNode);
|
||||||
|
// Remove "inherits" field
|
||||||
|
parentNode.remove("inherits");
|
||||||
|
return parentNode;
|
||||||
|
} else {
|
||||||
|
logger.warning("Inherited profile not found: " + parentName + " for " + currentPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentNode instanceof ObjectNode) {
|
||||||
|
return (ObjectNode) currentNode;
|
||||||
|
} else {
|
||||||
|
// Should verify it is an object
|
||||||
|
return (ObjectNode) currentNode;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shallow merge suitable for OrcaSlicer profiles
|
||||||
|
private void merge(ObjectNode mainNode, ObjectNode updateNode) {
|
||||||
|
Iterator<String> fieldNames = updateNode.fieldNames();
|
||||||
|
while (fieldNames.hasNext()) {
|
||||||
|
String fieldName = fieldNames.next();
|
||||||
|
JsonNode jsonNode = updateNode.get(fieldName);
|
||||||
|
// Replace standard fields
|
||||||
|
mainNode.set(fieldName, jsonNode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
package com.printcalculator.service;
|
||||||
|
|
||||||
|
import com.printcalculator.config.AppProperties;
|
||||||
|
import com.printcalculator.model.CostBreakdown;
|
||||||
|
import com.printcalculator.model.PrintStats;
|
||||||
|
import com.printcalculator.model.QuoteResult;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.math.RoundingMode;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
public class QuoteCalculator {
|
||||||
|
|
||||||
|
private final AppProperties props;
|
||||||
|
|
||||||
|
public QuoteCalculator(AppProperties props) {
|
||||||
|
this.props = props;
|
||||||
|
}
|
||||||
|
|
||||||
|
public QuoteResult calculate(PrintStats stats) {
|
||||||
|
// Material Cost: (weight / 1000) * costPerKg
|
||||||
|
BigDecimal weightKg = BigDecimal.valueOf(stats.filamentWeightGrams()).divide(BigDecimal.valueOf(1000), 4, RoundingMode.HALF_UP);
|
||||||
|
BigDecimal materialCost = weightKg.multiply(BigDecimal.valueOf(props.getFilamentCostPerKg()));
|
||||||
|
|
||||||
|
// Machine Cost: (seconds / 3600) * costPerHour
|
||||||
|
BigDecimal hours = BigDecimal.valueOf(stats.printTimeSeconds()).divide(BigDecimal.valueOf(3600), 4, RoundingMode.HALF_UP);
|
||||||
|
BigDecimal machineCost = hours.multiply(BigDecimal.valueOf(props.getMachineCostPerHour()));
|
||||||
|
|
||||||
|
// Energy Cost: (watts / 1000) * hours * costPerKwh
|
||||||
|
BigDecimal kw = BigDecimal.valueOf(props.getPrinterPowerWatts()).divide(BigDecimal.valueOf(1000), 4, RoundingMode.HALF_UP);
|
||||||
|
BigDecimal kwh = kw.multiply(hours);
|
||||||
|
BigDecimal energyCost = kwh.multiply(BigDecimal.valueOf(props.getEnergyCostPerKwh()));
|
||||||
|
|
||||||
|
// Subtotal
|
||||||
|
BigDecimal subtotal = materialCost.add(machineCost).add(energyCost);
|
||||||
|
|
||||||
|
// Markup
|
||||||
|
BigDecimal markupFactor = BigDecimal.valueOf(1.0 + (props.getMarkupPercent() / 100.0));
|
||||||
|
BigDecimal totalPrice = subtotal.multiply(markupFactor).setScale(2, RoundingMode.HALF_UP);
|
||||||
|
|
||||||
|
BigDecimal markupAmount = totalPrice.subtract(subtotal);
|
||||||
|
|
||||||
|
CostBreakdown breakdown = new CostBreakdown(
|
||||||
|
materialCost.setScale(2, RoundingMode.HALF_UP),
|
||||||
|
machineCost.setScale(2, RoundingMode.HALF_UP),
|
||||||
|
energyCost.setScale(2, RoundingMode.HALF_UP),
|
||||||
|
subtotal.setScale(2, RoundingMode.HALF_UP),
|
||||||
|
markupAmount.setScale(2, RoundingMode.HALF_UP)
|
||||||
|
);
|
||||||
|
|
||||||
|
List<String> notes = new ArrayList<>();
|
||||||
|
notes.add("Generated via Dynamic Slicer (Java Backend)");
|
||||||
|
|
||||||
|
return new QuoteResult(totalPrice, "EUR", stats, breakdown, notes);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
package com.printcalculator.service;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import com.fasterxml.jackson.databind.node.ObjectNode;
|
||||||
|
import com.printcalculator.model.PrintStats;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.io.File;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
import java.util.logging.Logger;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
public class SlicerService {
|
||||||
|
|
||||||
|
private static final Logger logger = Logger.getLogger(SlicerService.class.getName());
|
||||||
|
|
||||||
|
private final String slicerPath;
|
||||||
|
private final ProfileManager profileManager;
|
||||||
|
private final GCodeParser gCodeParser;
|
||||||
|
private final ObjectMapper mapper;
|
||||||
|
|
||||||
|
public SlicerService(
|
||||||
|
@Value("${slicer.path}") String slicerPath,
|
||||||
|
ProfileManager profileManager,
|
||||||
|
GCodeParser gCodeParser,
|
||||||
|
ObjectMapper mapper) {
|
||||||
|
this.slicerPath = slicerPath;
|
||||||
|
this.profileManager = profileManager;
|
||||||
|
this.gCodeParser = gCodeParser;
|
||||||
|
this.mapper = mapper;
|
||||||
|
}
|
||||||
|
|
||||||
|
public PrintStats slice(File inputStl, String machineName, String filamentName, String processName) throws IOException {
|
||||||
|
// 1. Prepare Profiles
|
||||||
|
ObjectNode machineProfile = profileManager.getMergedProfile(machineName, "machine");
|
||||||
|
ObjectNode filamentProfile = profileManager.getMergedProfile(filamentName, "filament");
|
||||||
|
ObjectNode processProfile = profileManager.getMergedProfile(processName, "process");
|
||||||
|
|
||||||
|
// 2. Create Temp Dir
|
||||||
|
Path tempDir = Files.createTempDirectory("slicer_job_");
|
||||||
|
try {
|
||||||
|
File mFile = tempDir.resolve("machine.json").toFile();
|
||||||
|
File fFile = tempDir.resolve("filament.json").toFile();
|
||||||
|
File pFile = tempDir.resolve("process.json").toFile();
|
||||||
|
|
||||||
|
mapper.writeValue(mFile, machineProfile);
|
||||||
|
mapper.writeValue(fFile, filamentProfile);
|
||||||
|
mapper.writeValue(pFile, processProfile);
|
||||||
|
|
||||||
|
// 3. Build Command
|
||||||
|
// --load-settings "machine.json;process.json" --load-filaments "filament.json"
|
||||||
|
String settingsArg = mFile.getAbsolutePath() + ";" + pFile.getAbsolutePath();
|
||||||
|
|
||||||
|
List<String> command = new ArrayList<>();
|
||||||
|
command.add(slicerPath);
|
||||||
|
command.add("--load-settings");
|
||||||
|
command.add(settingsArg);
|
||||||
|
command.add("--load-filaments");
|
||||||
|
command.add(fFile.getAbsolutePath());
|
||||||
|
command.add("--ensure-on-bed");
|
||||||
|
command.add("--arrange");
|
||||||
|
command.add("1"); // force arrange
|
||||||
|
command.add("--slice");
|
||||||
|
command.add("0"); // slice plate 0
|
||||||
|
command.add("--outputdir");
|
||||||
|
command.add(tempDir.toAbsolutePath().toString());
|
||||||
|
// Need to handle Mac structure for console if needed?
|
||||||
|
// Usually the binary at Contents/MacOS/OrcaSlicer works fine as console app.
|
||||||
|
|
||||||
|
command.add(inputStl.getAbsolutePath());
|
||||||
|
|
||||||
|
logger.info("Executing Slicer: " + String.join(" ", command));
|
||||||
|
|
||||||
|
// 4. Run Process
|
||||||
|
ProcessBuilder pb = new ProcessBuilder(command);
|
||||||
|
pb.directory(tempDir.toFile());
|
||||||
|
// pb.inheritIO(); // Useful for debugging, but maybe capture instead?
|
||||||
|
|
||||||
|
Process process = pb.start();
|
||||||
|
boolean finished = process.waitFor(5, TimeUnit.MINUTES);
|
||||||
|
|
||||||
|
if (!finished) {
|
||||||
|
process.destroy();
|
||||||
|
throw new IOException("Slicer timed out");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (process.exitValue() != 0) {
|
||||||
|
// Read stderr
|
||||||
|
String error = new String(process.getErrorStream().readAllBytes());
|
||||||
|
throw new IOException("Slicer failed with exit code " + process.exitValue() + ": " + error);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Find Output GCode
|
||||||
|
// Usually [basename].gcode or plate_1.gcode
|
||||||
|
String basename = inputStl.getName();
|
||||||
|
if (basename.toLowerCase().endsWith(".stl")) {
|
||||||
|
basename = basename.substring(0, basename.length() - 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
File gcodeFile = tempDir.resolve(basename + ".gcode").toFile();
|
||||||
|
if (!gcodeFile.exists()) {
|
||||||
|
// Try plate_1.gcode fallback
|
||||||
|
File alt = tempDir.resolve("plate_1.gcode").toFile();
|
||||||
|
if (alt.exists()) {
|
||||||
|
gcodeFile = alt;
|
||||||
|
} else {
|
||||||
|
throw new IOException("GCode output not found in " + tempDir);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. Parse Results
|
||||||
|
return gCodeParser.parse(gcodeFile);
|
||||||
|
|
||||||
|
} catch (InterruptedException e) {
|
||||||
|
Thread.currentThread().interrupt();
|
||||||
|
throw new IOException("Interrupted during slicing", e);
|
||||||
|
} finally {
|
||||||
|
// Cleanup temp dir
|
||||||
|
// In production we should delete, for debugging we might want to keep?
|
||||||
|
// Let's delete for now on success.
|
||||||
|
// recursiveDelete(tempDir);
|
||||||
|
// Leaving it effectively "leaks" temp, but safer for persistent debugging?
|
||||||
|
// Implementation detail: Use a utility to clean up.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
15
backend/src/main/resources/application.properties
Normal file
15
backend/src/main/resources/application.properties
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
spring.application.name=backend
|
||||||
|
server.port=8080
|
||||||
|
|
||||||
|
# Slicer Configuration
|
||||||
|
# Map SLICER_PATH env var if present (default to /opt/orcaslicer/AppRun or Mac path)
|
||||||
|
slicer.path=${SLICER_PATH:/Applications/OrcaSlicer.app/Contents/MacOS/OrcaSlicer}
|
||||||
|
profiles.root=${PROFILES_DIR:profiles}
|
||||||
|
|
||||||
|
# Pricing Configuration
|
||||||
|
# Mapped to legacy environment variables for Docker compatibility
|
||||||
|
pricing.filament-cost-per-kg=${FILAMENT_COST_PER_KG:25.0}
|
||||||
|
pricing.machine-cost-per-hour=${MACHINE_COST_PER_HOUR:2.0}
|
||||||
|
pricing.energy-cost-per-kwh=${ENERGY_COST_PER_KWH:0.30}
|
||||||
|
pricing.printer-power-watts=${PRINTER_POWER_WATTS:150.0}
|
||||||
|
pricing.markup-percent=${MARKUP_PERCENT:20.0}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
package com.printcalculator.service;
|
||||||
|
|
||||||
|
import com.printcalculator.model.PrintStats;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import java.io.File;
|
||||||
|
import java.io.FileWriter;
|
||||||
|
import java.io.IOException;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
|
||||||
|
class GCodeParserTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void parse_validGcode_returnsCorrectStats() throws IOException {
|
||||||
|
// Arrange
|
||||||
|
File tempFile = File.createTempFile("test", ".gcode");
|
||||||
|
try (FileWriter writer = new FileWriter(tempFile)) {
|
||||||
|
writer.write("; generated by OrcaSlicer\n");
|
||||||
|
writer.write("; estimated printing time = 1h 2m 3s\n");
|
||||||
|
writer.write("; filament used [g] = 10.5\n");
|
||||||
|
writer.write("; filament used [mm] = 3000.0\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
GCodeParser parser = new GCodeParser();
|
||||||
|
|
||||||
|
// Act
|
||||||
|
PrintStats stats = parser.parse(tempFile);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
assertEquals(3723, stats.printTimeSeconds()); // 3600 + 120 + 3
|
||||||
|
assertEquals("1h 2m 3s", stats.printTimeFormatted());
|
||||||
|
assertEquals(10.5, stats.filamentWeightGrams(), 0.001);
|
||||||
|
assertEquals(3000.0, stats.filamentLengthMm(), 0.001);
|
||||||
|
|
||||||
|
tempFile.delete();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void parse_footerGcode_returnsCorrectStats() throws IOException {
|
||||||
|
// Arrange
|
||||||
|
File tempFile = File.createTempFile("test_footer", ".gcode");
|
||||||
|
try (FileWriter writer = new FileWriter(tempFile)) {
|
||||||
|
writer.write("; header\n");
|
||||||
|
// ... many lines ...
|
||||||
|
writer.write("; filament used [g] = 5.0\n");
|
||||||
|
writer.write("; estimated printing time = 12m 30s\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
GCodeParser parser = new GCodeParser();
|
||||||
|
PrintStats stats = parser.parse(tempFile);
|
||||||
|
|
||||||
|
assertEquals(750, stats.printTimeSeconds()); // 12*60 + 30
|
||||||
|
assertEquals(5.0, stats.filamentWeightGrams(), 0.001);
|
||||||
|
|
||||||
|
tempFile.delete();
|
||||||
|
}
|
||||||
|
}
|
||||||
15
backend/target/classes/application.properties
Normal file
15
backend/target/classes/application.properties
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
spring.application.name=backend
|
||||||
|
server.port=8080
|
||||||
|
|
||||||
|
# Slicer Configuration
|
||||||
|
# Map SLICER_PATH env var if present (default to /opt/orcaslicer/AppRun or Mac path)
|
||||||
|
slicer.path=${SLICER_PATH:/Applications/OrcaSlicer.app/Contents/MacOS/OrcaSlicer}
|
||||||
|
profiles.root=${PROFILES_DIR:profiles}
|
||||||
|
|
||||||
|
# Pricing Configuration
|
||||||
|
# Mapped to legacy environment variables for Docker compatibility
|
||||||
|
pricing.filament-cost-per-kg=${FILAMENT_COST_PER_KG:25.0}
|
||||||
|
pricing.machine-cost-per-hour=${MACHINE_COST_PER_HOUR:2.0}
|
||||||
|
pricing.energy-cost-per-kwh=${ENERGY_COST_PER_KWH:0.30}
|
||||||
|
pricing.printer-power-watts=${PRINTER_POWER_WATTS:150.0}
|
||||||
|
pricing.markup-percent=${MARKUP_PERCENT:20.0}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
com/printcalculator/controller/QuoteController.class
|
||||||
|
com/printcalculator/service/GCodeParser.class
|
||||||
|
com/printcalculator/service/QuoteCalculator.class
|
||||||
|
com/printcalculator/config/SlicerConfig.class
|
||||||
|
com/printcalculator/BackendApplication.class
|
||||||
|
com/printcalculator/model/PrintStats.class
|
||||||
|
com/printcalculator/model/CostBreakdown.class
|
||||||
|
com/printcalculator/service/ProfileManager.class
|
||||||
|
com/printcalculator/service/SlicerService.class
|
||||||
|
com/printcalculator/model/QuoteResult.class
|
||||||
|
com/printcalculator/config/AppProperties.class
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
/Users/joe/IdeaProjects/print-calculator/backend/src/main/java/com/printcalculator/BackendApplication.java
|
||||||
|
/Users/joe/IdeaProjects/print-calculator/backend/src/main/java/com/printcalculator/config/AppProperties.java
|
||||||
|
/Users/joe/IdeaProjects/print-calculator/backend/src/main/java/com/printcalculator/config/SlicerConfig.java
|
||||||
|
/Users/joe/IdeaProjects/print-calculator/backend/src/main/java/com/printcalculator/controller/QuoteController.java
|
||||||
|
/Users/joe/IdeaProjects/print-calculator/backend/src/main/java/com/printcalculator/model/CostBreakdown.java
|
||||||
|
/Users/joe/IdeaProjects/print-calculator/backend/src/main/java/com/printcalculator/model/PrintStats.java
|
||||||
|
/Users/joe/IdeaProjects/print-calculator/backend/src/main/java/com/printcalculator/model/QuoteResult.java
|
||||||
|
/Users/joe/IdeaProjects/print-calculator/backend/src/main/java/com/printcalculator/service/GCodeParser.java
|
||||||
|
/Users/joe/IdeaProjects/print-calculator/backend/src/main/java/com/printcalculator/service/ProfileManager.java
|
||||||
|
/Users/joe/IdeaProjects/print-calculator/backend/src/main/java/com/printcalculator/service/QuoteCalculator.java
|
||||||
|
/Users/joe/IdeaProjects/print-calculator/backend/src/main/java/com/printcalculator/service/SlicerService.java
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
/Users/joe/IdeaProjects/print-calculator/backend/src/test/java/com/printcalculator/service/GCodeParserTest.java
|
||||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,4 @@
|
|||||||
|
-------------------------------------------------------------------------------
|
||||||
|
Test set: com.printcalculator.service.GCodeParserTest
|
||||||
|
-------------------------------------------------------------------------------
|
||||||
|
Tests run: 2, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.029 s -- in com.printcalculator.service.GCodeParserTest
|
||||||
@@ -1,86 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
import sys
|
|
||||||
import os
|
|
||||||
import unittest
|
|
||||||
import json
|
|
||||||
|
|
||||||
# Add backend to path
|
|
||||||
sys.path.append(os.path.join(os.path.dirname(__file__), ".."))
|
|
||||||
|
|
||||||
from profile_manager import ProfileManager
|
|
||||||
from profile_cache import get_cache_key
|
|
||||||
|
|
||||||
class TestProfileManager(unittest.TestCase):
|
|
||||||
def setUp(self):
|
|
||||||
self.pm = ProfileManager(profiles_root="profiles")
|
|
||||||
|
|
||||||
def test_list_machines(self):
|
|
||||||
machines = self.pm.list_machines()
|
|
||||||
print(f"Found machines: {len(machines)}")
|
|
||||||
self.assertTrue(len(machines) > 0, "No machines found")
|
|
||||||
# Check for a known machine
|
|
||||||
self.assertTrue(any("Bambu Lab A1" in m for m in machines), "Bambu Lab A1 should be in the list")
|
|
||||||
|
|
||||||
def test_find_profile(self):
|
|
||||||
# We know "Bambu Lab A1 0.4 nozzle" should exist (based on user context and mappings)
|
|
||||||
# It might be in profiles/BBL/machine/
|
|
||||||
path = self.pm._find_profile_file("Bambu Lab A1 0.4 nozzle", "machine")
|
|
||||||
self.assertIsNotNone(path, "Could not find Bambu Lab A1 machine profile")
|
|
||||||
print(f"Found profile at: {path}")
|
|
||||||
|
|
||||||
def test_scan_profiles_inheritance(self):
|
|
||||||
# Pick a profile we expect to inherit stuff
|
|
||||||
# e.g. "Bambu Lab A1 0.4 nozzle" inherits "fdm_bbl_3dp_001_common" which inherits "fdm_machine_common"
|
|
||||||
merged, _, _ = self.pm.get_profiles(
|
|
||||||
"Bambu Lab A1 0.4 nozzle",
|
|
||||||
"Bambu PLA Basic @BBL A1",
|
|
||||||
"0.20mm Standard @BBL A1"
|
|
||||||
)
|
|
||||||
|
|
||||||
self.assertIsNotNone(merged)
|
|
||||||
# Check if inherits is gone
|
|
||||||
self.assertNotIn("inherits", merged)
|
|
||||||
# Check if patch applied (G92 E0)
|
|
||||||
self.assertIn("G92 E0", merged.get("layer_change_gcode", ""))
|
|
||||||
|
|
||||||
# Check specific key from base
|
|
||||||
# "printer_technology": "FFF" is usually in common
|
|
||||||
# We can't be 100% sure of keys without seeing file, but let's check something likely
|
|
||||||
self.assertTrue("nozzle_diameter" in merged or "extruder_clearance_height_to_lid" in merged or "printable_height" in merged)
|
|
||||||
|
|
||||||
def test_mappings_resolution(self):
|
|
||||||
# Test if the slicer service would resolve correctly?
|
|
||||||
# We can just test the manager with mapped names if the manager supported it,
|
|
||||||
# but the manager deals with explicit names.
|
|
||||||
# Integration test handles the mapping.
|
|
||||||
pass
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
|
||||||
unittest.main()
|
|
||||||
@@ -67,6 +67,33 @@ import { QuoteRequest } from '../../services/quote-estimator.service';
|
|||||||
></app-input>
|
></app-input>
|
||||||
|
|
||||||
@if (mode() === 'advanced') {
|
@if (mode() === 'advanced') {
|
||||||
|
<div class="grid">
|
||||||
|
<app-select
|
||||||
|
formControlName="color"
|
||||||
|
[label]="'CALC.COLOR' | translate"
|
||||||
|
[options]="colors"
|
||||||
|
></app-select>
|
||||||
|
|
||||||
|
<app-select
|
||||||
|
formControlName="infillPattern"
|
||||||
|
[label]="'CALC.PATTERN' | translate"
|
||||||
|
[options]="infillPatterns"
|
||||||
|
></app-select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid">
|
||||||
|
<app-input
|
||||||
|
formControlName="infillDensity"
|
||||||
|
type="number"
|
||||||
|
[label]="'CALC.INFILL' | translate"
|
||||||
|
></app-input>
|
||||||
|
|
||||||
|
<div class="checkbox-row">
|
||||||
|
<input type="checkbox" formControlName="supportEnabled" id="support">
|
||||||
|
<label for="support">{{ 'CALC.SUPPORT' | translate }}</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<app-input
|
<app-input
|
||||||
formControlName="notes"
|
formControlName="notes"
|
||||||
[label]="'CALC.NOTES' | translate"
|
[label]="'CALC.NOTES' | translate"
|
||||||
@@ -127,6 +154,24 @@ import { QuoteRequest } from '../../services/quote-estimator.service';
|
|||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.checkbox-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-3);
|
||||||
|
height: 100%;
|
||||||
|
padding-top: var(--space-4);
|
||||||
|
|
||||||
|
input[type="checkbox"] {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
accent-color: var(--color-brand);
|
||||||
|
}
|
||||||
|
label {
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
}
|
||||||
`]
|
`]
|
||||||
})
|
})
|
||||||
export class UploadFormComponent {
|
export class UploadFormComponent {
|
||||||
@@ -146,10 +191,27 @@ export class UploadFormComponent {
|
|||||||
];
|
];
|
||||||
|
|
||||||
qualities = [
|
qualities = [
|
||||||
{ label: 'Bozza (Veloce)', value: 'Draft' },
|
{ label: 'Bozza (Fast)', value: 'Draft' },
|
||||||
{ label: 'Standard', value: 'Standard' },
|
{ label: 'Standard', value: 'Standard' },
|
||||||
{ label: 'Alta definizione', value: 'High' }
|
{ label: 'Alta definizione', value: 'High' }
|
||||||
];
|
];
|
||||||
|
|
||||||
|
colors = [
|
||||||
|
{ label: 'Black', value: 'Black' },
|
||||||
|
{ label: 'White', value: 'White' },
|
||||||
|
{ label: 'Gray', value: 'Gray' },
|
||||||
|
{ label: 'Red', value: 'Red' },
|
||||||
|
{ label: 'Blue', value: 'Blue' },
|
||||||
|
{ label: 'Green', value: 'Green' },
|
||||||
|
{ label: 'Yellow', value: 'Yellow' }
|
||||||
|
];
|
||||||
|
infillPatterns = [
|
||||||
|
{ label: 'Grid', value: 'grid' },
|
||||||
|
{ label: 'Gyroid', value: 'gyroid' },
|
||||||
|
{ label: 'Cubic', value: 'cubic' },
|
||||||
|
{ label: 'Triangles', value: 'triangles' }
|
||||||
|
];
|
||||||
|
|
||||||
acceptedFormats = '.stl,.3mf,.step,.stp,.obj,.amf,.ply,.igs,.iges';
|
acceptedFormats = '.stl,.3mf,.step,.stp,.obj,.amf,.ply,.igs,.iges';
|
||||||
|
|
||||||
constructor(private fb: FormBuilder) {
|
constructor(private fb: FormBuilder) {
|
||||||
@@ -158,7 +220,12 @@ export class UploadFormComponent {
|
|||||||
material: ['PLA', Validators.required],
|
material: ['PLA', Validators.required],
|
||||||
quality: ['Standard', Validators.required],
|
quality: ['Standard', Validators.required],
|
||||||
quantity: [1, [Validators.required, Validators.min(1)]],
|
quantity: [1, [Validators.required, Validators.min(1)]],
|
||||||
notes: ['']
|
notes: [''],
|
||||||
|
// Advanced fields
|
||||||
|
color: ['Black'],
|
||||||
|
infillDensity: [20, [Validators.min(0), Validators.max(100)]],
|
||||||
|
infillPattern: ['grid'],
|
||||||
|
supportEnabled: [false]
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,10 @@ export interface QuoteRequest {
|
|||||||
quality: string;
|
quality: string;
|
||||||
quantity: number;
|
quantity: number;
|
||||||
notes?: string;
|
notes?: string;
|
||||||
|
color?: string;
|
||||||
|
infillDensity?: number;
|
||||||
|
infillPattern?: string;
|
||||||
|
supportEnabled?: boolean;
|
||||||
mode: 'easy' | 'advanced';
|
mode: 'easy' | 'advanced';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -47,6 +51,13 @@ export class QuoteEstimatorService {
|
|||||||
formData.append('filament', this.mapMaterial(request.material));
|
formData.append('filament', this.mapMaterial(request.material));
|
||||||
formData.append('quality', this.mapQuality(request.quality));
|
formData.append('quality', this.mapQuality(request.quality));
|
||||||
|
|
||||||
|
if (request.mode === 'advanced') {
|
||||||
|
if (request.color) formData.append('material_color', request.color);
|
||||||
|
if (request.infillDensity) formData.append('infill_density', request.infillDensity.toString());
|
||||||
|
if (request.infillPattern) formData.append('infill_pattern', request.infillPattern);
|
||||||
|
if (request.supportEnabled) formData.append('support_enabled', 'true');
|
||||||
|
}
|
||||||
|
|
||||||
const headers: any = {};
|
const headers: any = {};
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
if (environment.basicAuth) {
|
if (environment.basicAuth) {
|
||||||
|
|||||||
@@ -23,6 +23,11 @@
|
|||||||
"QUALITY": "Qualità",
|
"QUALITY": "Qualità",
|
||||||
"QUANTITY": "Quantità",
|
"QUANTITY": "Quantità",
|
||||||
"NOTES": "Note aggiuntive",
|
"NOTES": "Note aggiuntive",
|
||||||
|
"COLOR": "Colore",
|
||||||
|
"INFILL": "Riempimento (%)",
|
||||||
|
"PATTERN": "Pattern di riempimento",
|
||||||
|
"SUPPORT": "Supporti",
|
||||||
|
"SUPPORT_DESC": "Abilita supporti per sporgenze",
|
||||||
"CALCULATE": "Calcola Preventivo",
|
"CALCULATE": "Calcola Preventivo",
|
||||||
"RESULT": "Preventivo Stimato",
|
"RESULT": "Preventivo Stimato",
|
||||||
"TIME": "Tempo Stampa",
|
"TIME": "Tempo Stampa",
|
||||||
|
|||||||
Reference in New Issue
Block a user