feat(orca): added orcaslicer preset

This commit is contained in:
2026-01-28 14:30:04 +01:00
parent 443ff04430
commit e9cca3daeb
8556 changed files with 455257 additions and 215 deletions

View File

@@ -2,102 +2,119 @@ 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._ensure_profiles_exist()
def _ensure_profiles_exist(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:
"""
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.
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}. Please install OrcaSlicer.")
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)
override_path = self._create_override_machine_config(output_dir)
# 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
# Prepare command
command = self._build_slicer_command(input_stl_path, output_dir, override_path)
logger.info(f"Slicing Command: {' '.join(command)}")
logger.info(f"Using Profiles provided in command settings argument.")
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:
msg = f"Slicing failed. Return code: {e.returncode}\nSTDOUT:\n{e.stdout}\nSTDERR:\n{e.stderr}"
logger.error(msg)
raise RuntimeError(f"Slicing failed: {e.stderr if e.stderr else e.stdout}")
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):
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:
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}")
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}")
# 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
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(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.
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 override_path
return m_real, f_real, q_real
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)
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",
@@ -106,34 +123,32 @@ class SlicerService:
]
def _run_command(self, command: list):
# logging and running logic similar to before
logger.debug(f"Exec: {' '.join(command)}")
result = subprocess.run(
command,
check=True,
check=False,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True
)
if result.stdout:
logger.info(f"Slicer STDOUT:\n{result.stdout[:2000]}...") # Log first 2000 chars to avoid explosion
if result.stderr:
logger.warning(f"Slicer STDERR:\n{result.stderr}")
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):
"""
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)
shutil.move(generated_path, target_path)
slicer_service = SlicerService()