phase 0
This commit is contained in:
@@ -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)
|
||||
Reference in New Issue
Block a user