feat(orca): added orcaslicer preset
This commit is contained in:
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user