188 lines
7.1 KiB
Python
188 lines
7.1 KiB
Python
"""
|
|
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)
|