""" loader.py — STEP file loading with build123d primary and FreeCAD fallback. Returns a StepModel dataclass used by all other modules. FreeCAD fallback invokes the signed app bundle Python to avoid Gatekeeper issues on macOS 15 Sequoia. """ import json import logging import subprocess import tempfile from dataclasses import dataclass, field from pathlib import Path from typing import Any, Optional logger = logging.getLogger("step_processor.loader") FREECAD_PYTHON = "/Applications/FreeCAD.app/Contents/Resources/bin/python" FREECAD_LIB = "/Applications/FreeCAD.app/Contents/Resources/lib" FREECAD_CMD = "/Applications/FreeCAD.app/Contents/Resources/bin/freecadcmd" @dataclass class StepModel: """Unified model object returned by load_step(). Used by all modules.""" shape: Any backend: str # "build123d" | "freecad" path: Path parts: list = field(default_factory=list) face_count: int = 0 metadata: dict = field(default_factory=dict) def load_step(filepath) -> Optional["StepModel"]: """Load a STEP file. Tries build123d first; falls back to FreeCAD.""" step_path = Path(filepath).expanduser().resolve() if not step_path.exists(): logger.error(f"File not found: {step_path}") return None try: return _load_via_build123d(step_path) except ImportError: logger.warning("build123d not available — falling back to FreeCAD") return _load_via_freecad(step_path) except Exception as e: logger.warning(f"build123d failed ({type(e).__name__}: {e}) — falling back to FreeCAD") return _load_via_freecad(step_path) def _load_via_build123d(step_path: Path) -> "StepModel": """Load using build123d. Raises on failure.""" from build123d import import_step logger.info(f"[build123d] Loading: {step_path.name}") shape = import_step(str(step_path)) face_count = 0 try: face_count = sum(1 for _ in shape.faces()) except Exception: pass parts = _extract_parts_build123d(shape) logger.info(f"[build123d] Loaded: {step_path.name} | {face_count} faces | {len(parts)} parts") return StepModel(shape=shape, backend="build123d", path=step_path, parts=parts, face_count=face_count) def _fix_gbk_mojibake(s: str) -> str: """ Recover Chinese text stored as mojibake in STEP part labels. STEP files from Chinese CAD tools (SolidWorks CN, etc.) embed raw GBK bytes in PRODUCT name strings. OpenCASCADE reads STEP strings as latin-1, which re-interprets those GBK bytes as latin-1 code points — classic mojibake. Fix: re-encode the string to latin-1 (restoring the original GBK byte sequence) then decode as GBK to get correct Unicode Chinese characters. If the string is pure ASCII, or the round-trip fails (already valid Unicode or a non-GBK extended char), returns the original string unchanged. """ if not s or all(ord(c) < 128 for c in s): return s # pure ASCII: nothing to fix try: return s.encode('latin-1').decode('gbk') except (UnicodeDecodeError, UnicodeEncodeError): return s # not GBK mojibake — leave original def _extract_parts_build123d(shape) -> list: """Walk build123d compound tree and extract named parts.""" parts = [] def _walk(compound, level=0, parent_name=""): children = [] try: children = compound.children if hasattr(compound, "children") else [] except Exception: pass if children: for child in children: raw = (getattr(child, "label", "") or getattr(child, "name", "") or f"Part_{level}") name = _fix_gbk_mojibake(raw) parts.append({"name": name, "level": level, "parent": parent_name}) _walk(child, level + 1, name) else: raw = (getattr(compound, "label", "") or getattr(compound, "name", "") or "") if raw: name = _fix_gbk_mojibake(raw) parts.append({"name": name, "level": level, "parent": parent_name}) _walk(shape) return parts def _load_via_freecad(step_path: Path) -> Optional["StepModel"]: """Load using FreeCAD app bundle Python via subprocess.""" if not Path(FREECAD_PYTHON).exists(): logger.error(f"FreeCAD Python not found at {FREECAD_PYTHON}. Install FreeCAD.app.") return None logger.info(f"[FreeCAD] Loading: {step_path.name}") script = f""" import sys, json sys.path.insert(0, {repr(FREECAD_LIB)}) import FreeCAD, Part try: shape = Part.read({repr(str(step_path))}) bb = shape.BoundBox sub = shape.SubShapes if hasattr(shape, 'SubShapes') else [] parts = [{{"name": f"Part_{{i}}", "level": 1, "parent": "root"}} for i in range(len(sub))] print(json.dumps({{"ok": True, "face_count": len(shape.Faces), "parts": parts, "bbox": {{"XMin": bb.XMin, "XMax": bb.XMax, "YMin": bb.YMin, "YMax": bb.YMax, "ZMin": bb.ZMin, "ZMax": bb.ZMax}}}})) except Exception as e: print(json.dumps({{"ok": False, "error": str(e)}})) """ with tempfile.NamedTemporaryFile(mode="w", suffix=".py", delete=False) as f: f.write(script) script_path = f.name try: proc = subprocess.run([FREECAD_PYTHON, script_path], capture_output=True, text=True, timeout=120) json_line = next((l.strip() for l in proc.stdout.splitlines() if l.strip().startswith("{")), None) if not json_line: logger.error(f"[FreeCAD] No JSON output. stderr: {proc.stderr[:300]}") return None data = json.loads(json_line) if not data.get("ok"): logger.error(f"[FreeCAD] Load failed: {data.get('error')}") return None proxy = _FreeCADShapeProxy(data["bbox"], data["face_count"]) logger.info(f"[FreeCAD] Loaded: {step_path.name} | {data['face_count']} faces") return StepModel(shape=proxy, backend="freecad", path=step_path, parts=data.get("parts", []), face_count=data["face_count"], metadata={"bbox": data["bbox"]}) except subprocess.TimeoutExpired: logger.error("[FreeCAD] Load timed out after 120s") return None except Exception as e: logger.error(f"[FreeCAD] Unexpected error: {e}") return None finally: Path(script_path).unlink(missing_ok=True) class _FreeCADShapeProxy: """Proxy carrying FreeCAD geometry data extracted via subprocess.""" def __init__(self, bbox_dict: dict, face_count: int): self.BoundBox = _BoundBox(bbox_dict) self.face_count = face_count self.Faces = [None] * face_count def faces(self): for _ in range(self.face_count): yield object() class _BoundBox: def __init__(self, d: dict): self.XMin = d.get("XMin", 0); self.XMax = d.get("XMax", 0) self.YMin = d.get("YMin", 0); self.YMax = d.get("YMax", 0) self.ZMin = d.get("ZMin", 0); self.ZMax = d.get("ZMax", 0)