This commit is contained in:
Jason Stedwell
2026-06-17 16:03:26 -05:00
parent fa1e9b68c7
commit c1abe36822
99 changed files with 1562887 additions and 0 deletions
+187
View File
@@ -0,0 +1,187 @@
"""
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)