Files
HartOMat/flamenco/scripts/convert_step.py
T
2026-03-05 22:12:38 +01:00

217 lines
8.1 KiB
Python

"""STEP to STL converter for Flamenco tasks.
Usage: python convert_step.py <step_path> <stl_path> <quality>
quality: 'low' or 'high'
Produces:
- Combined STL at <stl_path> (for fallback)
- Per-part STLs in <stl_path_without_ext>_parts/ with manifest.json
"""
import sys
import os
import json
import time
def _export_per_part_stls(step_path, parts_dir, quality):
"""Export one STL per named STEP leaf shape using OCP XCAF.
Creates parts_dir with individual STL files and a manifest.json:
{"parts": [{"index": 0, "name": "PartName", "file": "00_PartName.stl"}, ...]}
Returns the manifest list, or empty list on failure.
"""
tol = 0.01 if quality == "high" else 0.3
angular_tol = 0.05 if quality == "high" else 0.3
try:
from OCP.STEPCAFControl import STEPCAFControl_Reader
from OCP.XCAFDoc import XCAFDoc_DocumentTool, XCAFDoc_ShapeTool
from OCP.TDataStd import TDataStd_Name
from OCP.TDF import TDF_Label as TDF_Label_cls, TDF_LabelSequence
from OCP.XCAFApp import XCAFApp_Application
from OCP.TDocStd import TDocStd_Document
from OCP.TCollection import TCollection_ExtendedString
from OCP.IFSelect import IFSelect_RetDone
import cadquery as cq
except ImportError as e:
print(f"[convert_step] per-part export skipped (import error): {e}")
return []
# Read STEP with XCAF
app = XCAFApp_Application.GetApplication_s()
doc = TDocStd_Document(TCollection_ExtendedString("XmlOcaf"))
app.InitDocument(doc)
reader = STEPCAFControl_Reader()
reader.SetNameMode(True)
status = reader.ReadFile(str(step_path))
if status != IFSelect_RetDone:
print(f"[convert_step] XCAF reader failed with status {status}")
return []
if not reader.Transfer(doc):
print("[convert_step] XCAF transfer failed")
return []
shape_tool = XCAFDoc_DocumentTool.ShapeTool_s(doc.Main())
name_id = TDataStd_Name.GetID_s()
# Recursively collect leaf shapes with their names
leaves = [] # list of (name, TopoDS_Shape)
def _get_label_name(label):
"""Extract name string from a TDF_Label."""
name_attr = TDataStd_Name()
if label.FindAttribute(name_id, name_attr):
return name_attr.Get().ToExtString()
return ""
def _collect_leaves(label):
"""Recursively collect leaf (simple shape) labels."""
if XCAFDoc_ShapeTool.IsAssembly_s(label):
# Get components of this assembly
components = TDF_LabelSequence()
XCAFDoc_ShapeTool.GetComponents_s(label, components)
for i in range(1, components.Length() + 1):
comp_label = components.Value(i)
if XCAFDoc_ShapeTool.IsReference_s(comp_label):
ref_label = TDF_Label_cls()
XCAFDoc_ShapeTool.GetReferredShape_s(comp_label, ref_label)
# Use the component name (instance name), fall back to referred shape name
comp_name = _get_label_name(comp_label)
ref_name = _get_label_name(ref_label)
# Prefer referred shape name — matches material_map keys
name = ref_name or comp_name
if XCAFDoc_ShapeTool.IsAssembly_s(ref_label):
_collect_leaves(ref_label)
elif XCAFDoc_ShapeTool.IsSimpleShape_s(ref_label):
# Use comp_label shape — includes instance transform (position)
shape = XCAFDoc_ShapeTool.GetShape_s(comp_label)
leaves.append((name or f"unnamed_{len(leaves)}", shape))
else:
_collect_leaves(comp_label)
elif XCAFDoc_ShapeTool.IsSimpleShape_s(label):
name = _get_label_name(label)
shape = XCAFDoc_ShapeTool.GetShape_s(label)
leaves.append((name or f"unnamed_{len(leaves)}", shape))
# Get top-level free shapes
top_labels = TDF_LabelSequence()
shape_tool.GetFreeShapes(top_labels)
for i in range(1, top_labels.Length() + 1):
_collect_leaves(top_labels.Value(i))
if not leaves:
print("[convert_step] no leaf shapes found via XCAF")
return []
# Export each leaf shape as individual STL
os.makedirs(parts_dir, exist_ok=True)
manifest = []
for idx, (name, shape) in enumerate(leaves):
# Sanitize filename: replace problematic chars
safe_name = name.replace("/", "_").replace("\\", "_").replace(" ", "_")
filename = f"{idx:02d}_{safe_name}.stl"
filepath = os.path.join(parts_dir, filename)
try:
cq_shape = cq.Shape(shape)
cq_shape.exportStl(filepath, tolerance=tol, angularTolerance=angular_tol)
manifest.append({"index": idx, "name": name, "file": filename})
except Exception as e:
print(f"[convert_step] WARNING: failed to export part '{name}': {e}")
# Write manifest
manifest_path = os.path.join(parts_dir, "manifest.json")
with open(manifest_path, "w") as f:
json.dump({"parts": manifest}, f, indent=2)
total_size = sum(
os.path.getsize(os.path.join(parts_dir, p["file"]))
for p in manifest
if os.path.exists(os.path.join(parts_dir, p["file"]))
)
print(f"[convert_step] exported {len(manifest)} per-part STLs "
f"({total_size / 1024:.0f} KB total) to {parts_dir}")
return manifest
def main():
if len(sys.argv) < 4:
print("Usage: convert_step.py <step_path> <stl_path> <quality>")
sys.exit(1)
step_path = sys.argv[1]
stl_path = sys.argv[2]
quality = sys.argv[3]
if not os.path.isfile(step_path):
print(f"ERROR: STEP file not found: {step_path}")
sys.exit(1)
os.makedirs(os.path.dirname(stl_path), exist_ok=True)
# Cache hit: skip re-conversion if STL already exists and is non-empty
if os.path.isfile(stl_path) and os.path.getsize(stl_path) > 0:
size_kb = os.path.getsize(stl_path) / 1024
print(f"[convert_step] Cache hit: {stl_path} ({size_kb:.0f} KB) — skipping STEP conversion")
stl_stem = os.path.splitext(stl_path)[0]
parts_dir = stl_stem + "_parts"
manifest_path = os.path.join(parts_dir, "manifest.json")
if not os.path.isfile(manifest_path):
print("[convert_step] Per-part STLs missing — exporting from STEP")
t1 = time.time()
try:
manifest = _export_per_part_stls(step_path, parts_dir, quality)
if manifest:
print(f"[convert_step] per-part export took {time.time() - t1:.1f}s")
else:
print("[convert_step] per-part export empty — combined STL only")
except Exception as e:
print(f"[convert_step] per-part export failed (non-fatal): {e}")
else:
print(f"[convert_step] Per-part STLs exist: {parts_dir}")
return
print(f"Converting STEP -> STL: {step_path}")
print(f"Quality: {quality}")
t0 = time.time()
import cadquery as cq
tol = 0.01 if quality == "high" else 0.3
angular_tol = 0.05 if quality == "high" else 0.3
result = cq.importers.importStep(step_path)
cq.exporters.export(
result,
stl_path,
exportType="STL",
tolerance=tol,
angularTolerance=angular_tol,
)
elapsed = time.time() - t0
size_kb = os.path.getsize(stl_path) / 1024
print(f"STL written: {stl_path} ({size_kb:.0f} KB, {elapsed:.1f}s)")
# Export per-part STLs alongside the combined STL (non-fatal)
stl_stem = os.path.splitext(stl_path)[0]
parts_dir = stl_stem + "_parts"
t1 = time.time()
try:
manifest = _export_per_part_stls(step_path, parts_dir, quality)
if manifest:
print(f"[convert_step] per-part export took {time.time() - t1:.1f}s")
else:
print("[convert_step] per-part export failed or empty — combined STL only")
except Exception as e:
print(f"[convert_step] per-part export failed (non-fatal): {e}")
if __name__ == "__main__":
main()