Files
HartOMat/render-worker/scripts/export_step_to_gltf.py
T
Hartmut b583b0d7a2 feat: per-position camera settings, material alias dialog, product delete, media browser links
- Per-render-position focal_length_mm/sensor_width_mm (DB → pipeline → Blender)
- FOV-based camera distance with min clamp fix for wide-angle lenses
- Unmapped materials blocking dialog on "Dispatch Renders" with batch alias creation
- Material check endpoint (GET /orders/{id}/check-materials)
- Batch alias endpoint (POST /materials/batch-aliases)
- Quick-map "No alias" badges on Materials page
- Full product hard-delete with storage cleanup (MinIO + disk files + orphaned CadFile)
- Delete button on ProductDetail page with confirmation
- Clickable product names in Media Browser (links to product page)
- Single-line render dispatch/retry (POST /orders/{id}/lines/{id}/dispatch-render)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-14 12:16:37 +01:00

783 lines
32 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""OCC-native STEP → GLB export script.
Reads a STEP file via OCP/XCAF (preserving part names and embedded colors),
tessellates with BRepMesh or GMSH Frontal-Delaunay, optionally applies
per-part hex colors, and writes a binary GLB in meters (Y-up, glTF convention).
No Blender required. Uses the same OCP bindings that cadquery ships with.
Usage:
python3 export_step_to_gltf.py \
--step_path /path/to/file.stp \
--output_path /path/to/output.glb \
[--linear_deflection 0.1] \
[--angular_deflection 0.5] \
[--tessellation_engine occ|gmsh] \
[--color_map '{"RingInner": "#4C9BE8", "RingOuter": "#E85B4C"}']
Exit 0 on success, exit 1 on failure.
"""
from __future__ import annotations
import argparse
import json
import sys
import traceback
from pathlib import Path
PALETTE_HEX = [
"#4C9BE8", "#E85B4C", "#4CBE72", "#E8A84C", "#A04CE8",
"#4CD4E8", "#E84CA8", "#7EC850", "#E86B30", "#5088C8",
]
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser()
parser.add_argument("--step_path", required=True)
parser.add_argument("--output_path", required=True)
parser.add_argument(
"--linear_deflection", type=float, default=0.1,
help="OCC linear deflection for tessellation (mm). Smaller = finer mesh. Default 0.1",
)
parser.add_argument(
"--angular_deflection", type=float, default=0.5,
help="OCC angular deflection (radians). Default 0.5",
)
parser.add_argument(
"--color_map", default="{}",
help='JSON dict mapping part name → hex color, e.g. \'{"Ring": "#4C9BE8"}\'',
)
parser.add_argument(
"--sharp_threshold", type=float, default=20.0,
help="Dihedral angle threshold (degrees) for sharp B-rep edge detection. Default 20.0",
)
parser.add_argument(
"--tessellation_engine", choices=["occ", "gmsh"], default="occ",
help="Tessellation backend: 'occ' = BRepMesh (default), 'gmsh' = Frontal-Delaunay",
)
return parser.parse_args()
def _hex_to_occ_color(hex_color: str):
"""Convert '#RRGGBB' → Quantity_Color (linear float)."""
from OCP.Quantity import Quantity_Color, Quantity_TOC_RGB
h = hex_color.lstrip("#")
if len(h) < 6:
return Quantity_Color(0.7, 0.7, 0.7, Quantity_TOC_RGB)
r = int(h[0:2], 16) / 255.0
g = int(h[2:4], 16) / 255.0
b = int(h[4:6], 16) / 255.0
return Quantity_Color(r, g, b, Quantity_TOC_RGB)
def _apply_color_map(shape_tool, color_tool, free_labels, color_map: dict) -> None:
"""Apply hex colors from color_map to matching shapes by name (case-insensitive substring)."""
from OCP.TDF import TDF_LabelSequence
from OCP.TDataStd import TDataStd_Name
from OCP.XCAFDoc import XCAFDoc_ShapeTool
# XCAFDoc_ColorType: XCAFDoc_ColorGen=0, XCAFDoc_ColorSurf=1, XCAFDoc_ColorCurv=2
try:
from OCP.XCAFDoc import XCAFDoc_ColorSurf as COLOR_SURF
except ImportError:
COLOR_SURF = 1 # integer fallback
def _visit(label) -> None:
name_attr = TDataStd_Name()
name = ""
if label.FindAttribute(TDataStd_Name.GetID_s(), name_attr):
name = name_attr.Get().ToExtString()
if name:
for part_name, hex_color in color_map.items():
if part_name.lower() in name.lower() or name.lower() in part_name.lower():
color_tool.SetColor(label, _hex_to_occ_color(hex_color), COLOR_SURF)
break
components = TDF_LabelSequence()
XCAFDoc_ShapeTool.GetComponents_s(label, components)
for i in range(1, components.Length() + 1):
_visit(components.Value(i))
for i in range(1, free_labels.Length() + 1):
_visit(free_labels.Value(i))
def _apply_palette_colors(shape_tool, color_tool, free_labels) -> None:
"""Assign palette colors to leaf shapes when no color_map is provided."""
from OCP.TDF import TDF_LabelSequence
from OCP.XCAFDoc import XCAFDoc_ShapeTool
try:
from OCP.XCAFDoc import XCAFDoc_ColorSurf as COLOR_SURF
except ImportError:
COLOR_SURF = 1
leaves: list = []
def _collect(label) -> None:
components = TDF_LabelSequence()
XCAFDoc_ShapeTool.GetComponents_s(label, components)
if components.Length() == 0:
leaves.append(label)
else:
for i in range(1, components.Length() + 1):
_collect(components.Value(i))
for i in range(1, free_labels.Length() + 1):
_collect(free_labels.Value(i))
for idx, label in enumerate(leaves):
occ_color = _hex_to_occ_color(PALETTE_HEX[idx % len(PALETTE_HEX)])
color_tool.SetColor(label, occ_color, COLOR_SURF)
def _extract_sharp_edge_pairs(shape, sharp_threshold_deg: float = 20.0) -> list:
"""Extract geometrically sharp B-rep edges as dense curve sample segment pairs.
For each edge shared by exactly 2 faces, evaluates the dihedral angle using
PCurve-based surface normal evaluation. When the angle exceeds the threshold,
samples the analytical 3D curve uniformly at 0.3mm intervals via
GCPnts_UniformAbscissa. Consecutive sample pairs give the KD-tree in
export_gltf.py enough density to find and mark the correct Blender mesh edges.
Note: BRep_Tool.Polygon3D_s() and PolygonOnTriangulation_s() return None in
XCAF compound context — the tessellation data is stored on component instances,
not on the compound edges. Curve sampling bypasses this entirely.
Args:
shape: OCC TopoDS_Shape (tessellated with BRepMesh_IncrementalMesh)
sharp_threshold_deg: dihedral angle threshold in degrees (default 20°)
Returns:
List of [[x0,y0,z0],[x1,y1,z1]] consecutive segment pairs in mm (OCC
coordinate space, Z-up). Must be called BEFORE mm→m scaling.
"""
import math as _math
from OCP.TopTools import TopTools_IndexedDataMapOfShapeListOfShape
from OCP.TopExp import TopExp as _TopExp
from OCP.TopAbs import TopAbs_EDGE, TopAbs_FACE, TopAbs_FORWARD
from OCP.TopoDS import TopoDS as _TopoDS
from OCP.BRepAdaptor import BRepAdaptor_Surface, BRepAdaptor_Curve2d, BRepAdaptor_Curve
from OCP.BRepLProp import BRepLProp_SLProps
from OCP.GCPnts import GCPnts_UniformAbscissa
edge_face_map = TopTools_IndexedDataMapOfShapeListOfShape()
_TopExp.MapShapesAndAncestors_s(shape, TopAbs_EDGE, TopAbs_FACE, edge_face_map)
sharp_pairs: list = []
n_checked = 0
n_sharp = 0
# Sample step 0.3mm — well below the KD-tree TOL=0.5mm in export_gltf.py.
# Tessellation vertex spacing for default deflection is ~0.78-1.55mm, so at
# least one consecutive sample pair will straddle each tessellation edge.
SAMPLE_STEP_MM = 0.3
for i in range(1, edge_face_map.Extent() + 1):
edge_shape = edge_face_map.FindKey(i)
faces = edge_face_map.FindFromIndex(i)
if faces.Size() < 2:
n_checked += 1
continue
face_shapes = list(faces)
if len(face_shapes) < 2:
n_checked += 1
continue
n_checked += 1
try:
edge = _TopoDS.Edge_s(edge_shape)
face1 = _TopoDS.Face_s(face_shapes[0])
face2 = _TopoDS.Face_s(face_shapes[1])
# PCurve-based normal evaluation at edge midpoint
c2d_1 = BRepAdaptor_Curve2d(edge, face1)
uv1 = c2d_1.Value((c2d_1.FirstParameter() + c2d_1.LastParameter()) / 2.0)
surf1 = BRepAdaptor_Surface(face1)
props1 = BRepLProp_SLProps(surf1, uv1.X(), uv1.Y(), 1, 1e-6)
if not props1.IsNormalDefined():
continue
n1 = props1.Normal()
if face1.Orientation() != TopAbs_FORWARD:
n1.Reverse()
c2d_2 = BRepAdaptor_Curve2d(edge, face2)
uv2 = c2d_2.Value((c2d_2.FirstParameter() + c2d_2.LastParameter()) / 2.0)
surf2 = BRepAdaptor_Surface(face2)
props2 = BRepLProp_SLProps(surf2, uv2.X(), uv2.Y(), 1, 1e-6)
if not props2.IsNormalDefined():
continue
n2 = props2.Normal()
if face2.Orientation() != TopAbs_FORWARD:
n2.Reverse()
cos_angle = max(-1.0, min(1.0, n1.Dot(n2)))
angle_deg = _math.degrees(_math.acos(cos_angle))
# Use exterior (supplement) angle so convex and concave edges both work
if angle_deg > 90.0:
angle_deg = 180.0 - angle_deg
if angle_deg <= sharp_threshold_deg:
continue # smooth transition — skip
n_sharp += 1
# Sample the analytical 3D curve at fixed arc-length intervals.
# GCPnts_UniformAbscissa works on the exact B-rep curve regardless of
# whether tessellation polygon data is stored on the edge or not.
pts: list = []
try:
curve3d = BRepAdaptor_Curve(edge)
f_param = curve3d.FirstParameter()
l_param = curve3d.LastParameter()
if _math.isfinite(f_param) and _math.isfinite(l_param):
sampler = GCPnts_UniformAbscissa()
sampler.Initialize(curve3d, SAMPLE_STEP_MM, 1e-6)
if sampler.IsDone() and sampler.NbPoints() >= 2:
for j in range(1, sampler.NbPoints() + 1):
t = sampler.Parameter(j)
p = curve3d.Value(t)
pts.append([round(p.X(), 4), round(p.Y(), 4), round(p.Z(), 4)])
except Exception:
pts = []
if len(pts) < 2:
continue
# Consecutive segment pairs — KD-tree in export_gltf.py maps each
# endpoint to its nearest Blender vertex; if they differ and share a
# mesh edge, that edge is marked sharp+seam.
for k in range(len(pts) - 1):
sharp_pairs.append([pts[k], pts[k + 1]])
except Exception:
continue
print(
f"Sharp edge extraction: {n_checked} edges checked, "
f"{n_sharp} sharp (>{sharp_threshold_deg:.0f}°), "
f"{len(sharp_pairs)} segment pairs total"
)
return sharp_pairs
def _tessellate_with_gmsh(shape, linear_deflection: float, angular_deflection: float) -> None:
"""Tessellate an OCC TopoDS_Shape using GMSH Frontal-Delaunay mesher.
Writes the resulting Poly_Triangulation back to each TopoDS_Face via
BRep_Builder.UpdateFace(), so the shape's tessellation data is available
to RWGltf_CafWriter exactly like BRepMesh output.
GMSH surface tags correspond 1:1 to faces in TopExp_Explorer(FACE) order
after importShapes() from a .brep file — no coordinate-based matching needed.
Falls back silently to BRepMesh for any face that GMSH cannot mesh (degenerate
geometry, e.g. degenerate poles).
Args:
shape: tessellated in-place (OCC TopoDS_Shape)
linear_deflection: controls CharacteristicLengthMax (mm)
angular_deflection: controls minimum circle subdivision points (rad)
"""
import math as _math
import tempfile
import gmsh
from OCP.BRep import BRep_Builder
from OCP.BRepTools import BRepTools
from OCP.BRepMesh import BRepMesh_IncrementalMesh
from OCP.Poly import Poly_Triangulation, Poly_Array1OfTriangle, Poly_Triangle
from OCP.TColgp import TColgp_Array1OfPnt
from OCP.TopExp import TopExp_Explorer
from OCP.TopAbs import TopAbs_FACE
from OCP.TopoDS import TopoDS as _TopoDS
from OCP.gp import gp_Pnt
# Write shape to temporary .brep for GMSH import
with tempfile.NamedTemporaryFile(suffix=".brep", delete=False) as tmp:
brep_path = tmp.name
n_faces_gmsh = 0
n_faces_fallback = 0
n_triangles_total = 0
try:
BRepTools.Write_s(shape, brep_path)
import os as _os
n_threads = min(_os.cpu_count() or 1, 16) # cap at 16 — sweet spot on benchmark
gmsh.initialize()
gmsh.option.setNumber("General.Terminal", 0) # suppress console output
gmsh.option.setNumber("General.NumThreads", n_threads) # enable OpenMP parallelism
gmsh.option.setNumber("Mesh.MaxNumThreads1D", n_threads) # parallel edge meshing
gmsh.option.setNumber("Mesh.MaxNumThreads2D", n_threads) # parallel surface meshing
gmsh.option.setNumber("Mesh.Algorithm", 6) # Frontal-Delaunay 2D
gmsh.option.setNumber("Mesh.RecombineAll", 0) # keep triangles (no quads)
# CharacteristicLength is an edge LENGTH target; OCC linear_deflection is a surface
# DEVIATION tolerance. Empirically: OCC 0.1mm deflection on a 50mm cylinder produces
# ~5mm edge lengths. Scale by 50× to match OCC density (target ≤120% of OCC file size).
# MinimumCirclePoints: OCC angular_deflection=0.1rad → effectively ~12 uniform pts/circle.
# Cap at 12 to avoid GMSH generating 35× more edges than OCC on cylindrical surfaces.
gmsh.option.setNumber("Mesh.CharacteristicLengthMin", linear_deflection * 0.5)
gmsh.option.setNumber("Mesh.CharacteristicLengthMax", linear_deflection * 50.0)
min_circle_pts = min(12, max(6, int(_math.ceil(2.0 * _math.pi / max(angular_deflection, 0.01)))))
gmsh.option.setNumber("Mesh.MinimumCirclePoints", min_circle_pts)
gmsh.option.setNumber("Mesh.MinimumCurvePoints", 3)
# Reduce noise from GMSH warnings
gmsh.option.setNumber("General.Verbosity", 1)
gmsh.model.add("shape")
gmsh.model.occ.importShapes(brep_path)
gmsh.model.occ.synchronize()
gmsh.model.mesh.generate(2)
# Build lookup: surface tag → list of (node_coords, triangle_node_tags)
# GMSH surface tags from importShapes correspond 1:1 to TopExp_Explorer(FACE) order
surface_tags = [tag for (_, tag) in gmsh.model.getEntities(2)]
# Collect per-surface mesh data
surface_mesh: dict[int, tuple] = {} # tag → (nodes_xyz, triangles)
for stag in surface_tags:
try:
node_tags, coords, _ = gmsh.model.mesh.getNodes(dim=2, tag=stag, includeBoundary=True)
if len(node_tags) == 0:
continue
# coords is flat [x0,y0,z0, x1,y1,z1, ...]
node_map = {} # gmsh tag → 1-based local index
pts_xyz = []
for i, ntag in enumerate(node_tags):
x, y, z = coords[3*i], coords[3*i+1], coords[3*i+2]
node_map[ntag] = i + 1 # 1-based
pts_xyz.append((x, y, z))
elem_types, elem_tags, elem_node_tags = gmsh.model.mesh.getElements(dim=2, tag=stag)
tris = []
for etype, etags, entags in zip(elem_types, elem_tags, elem_node_tags):
if etype != 2: # type 2 = triangle
continue
n_elems = len(etags)
for k in range(n_elems):
a = node_map.get(entags[3*k])
b = node_map.get(entags[3*k+1])
c = node_map.get(entags[3*k+2])
if a and b and c:
tris.append((a, b, c))
if pts_xyz and tris:
surface_mesh[stag] = (pts_xyz, tris)
except Exception:
continue
except Exception as _gmsh_err:
print(f"WARNING: GMSH failed ({_gmsh_err}), falling back to BRepMesh", file=sys.stderr)
# Fallback: full BRepMesh on the whole shape
BRepMesh_IncrementalMesh(shape, linear_deflection, False, angular_deflection, True)
return
finally:
try:
gmsh.finalize()
except Exception:
pass
try:
Path(brep_path).unlink(missing_ok=True)
except Exception:
pass
# Map GMSH surface data back to OCC faces via TopExp_Explorer (same order as importShapes)
builder = BRep_Builder()
explorer = TopExp_Explorer(shape, TopAbs_FACE)
face_index = 0 # 0-based → maps to surface_tags[face_index]
while explorer.More():
face = _TopoDS.Face_s(explorer.Current())
if face_index < len(surface_tags):
stag = surface_tags[face_index]
mesh_data = surface_mesh.get(stag)
if mesh_data:
pts_xyz, tris = mesh_data
n_nodes = len(pts_xyz)
n_tris = len(tris)
try:
arr_pts = TColgp_Array1OfPnt(1, n_nodes)
for idx, (x, y, z) in enumerate(pts_xyz, 1):
arr_pts.SetValue(idx, gp_Pnt(x, y, z))
arr_tris = Poly_Array1OfTriangle(1, n_tris)
for idx, (a, b, c) in enumerate(tris, 1):
arr_tris.SetValue(idx, Poly_Triangle(a, b, c))
tri = Poly_Triangulation(arr_pts, arr_tris)
builder.UpdateFace(face, tri)
n_faces_gmsh += 1
n_triangles_total += n_tris
except Exception as _e:
# Fallback for this face only
BRepMesh_IncrementalMesh(face, linear_deflection, False, angular_deflection, False)
n_faces_fallback += 1
else:
# No GMSH mesh for this surface → BRepMesh fallback for this face
BRepMesh_IncrementalMesh(face, linear_deflection, False, angular_deflection, False)
n_faces_fallback += 1
else:
BRepMesh_IncrementalMesh(face, linear_deflection, False, angular_deflection, False)
n_faces_fallback += 1
face_index += 1
explorer.Next()
print(
f"GMSH tessellation: {n_faces_gmsh} faces meshed, "
f"{n_faces_fallback} BRepMesh fallback, "
f"{n_triangles_total} triangles total"
f" (threads={n_threads})"
)
def _collect_part_key_map(shape_tool, free_labels) -> dict:
"""Return {normalized_source_name: part_key_slug} for all leaf parts in the XCAF hierarchy.
The normalized source name (XCAF label name without _AF\\d+ suffix) is what
Three.js sees after normalizeMeshName() strips the OCC assembly suffix from the
GLB mesh node name. The slug algorithm matches part_key_service.generate_part_key().
"""
import re as _re
import hashlib as _hashlib
from OCP.TDF import TDF_LabelSequence
from OCP.TDataStd import TDataStd_Name
from OCP.XCAFDoc import XCAFDoc_ShapeTool
_af_re = _re.compile(r'_AF\d+$', _re.IGNORECASE)
def _slug(source_name: str, xcaf_path: str = "") -> str:
base = _af_re.sub('', source_name) if source_name else ''
# camelCase split — same as part_key_service.generate_part_key
base = _re.sub(r'([a-z])([A-Z])', r'\1_\2', base)
slug = _re.sub(r'[^a-z0-9]+', '_', base.lower()).strip('_')
if not slug:
slug = f"part_{_hashlib.sha256(xcaf_path.encode()).hexdigest()[:8]}"
return slug[:50]
part_key_map: dict = {}
def _collect(label, path: str = "") -> None:
name_attr = TDataStd_Name()
name = ""
if label.FindAttribute(TDataStd_Name.GetID_s(), name_attr):
name = name_attr.Get().ToExtString()
# Dereference component references to their definition label
# (the definition may itself be an assembly with sub-components)
from OCP.TDF import TDF_Label as _TDF_Label
actual_label = label
if XCAFDoc_ShapeTool.IsReference_s(label):
ref_label = _TDF_Label()
if XCAFDoc_ShapeTool.GetReferredShape_s(label, ref_label):
actual_label = ref_label
components = TDF_LabelSequence()
XCAFDoc_ShapeTool.GetComponents_s(actual_label, components)
xcaf_path = f"{path}/{name}" if name else f"{path}/unnamed"
if components.Length() == 0:
# Leaf node — normalized source name (without _AF suffix) as key
normalized = _af_re.sub('', name) if name else ''
if normalized:
part_key_map[normalized] = _slug(name, xcaf_path)
else:
for i in range(1, components.Length() + 1):
_collect(components.Value(i), xcaf_path)
for i in range(1, free_labels.Length() + 1):
_collect(free_labels.Value(i))
return part_key_map
def _inject_glb_extras(glb_path: Path, extras: dict, part_key_map: dict | None = None) -> None:
"""Patch a GLB binary to add/update scenes[0].extras JSON field.
Also stamps per-node extras.partKey on each GLB node whose name maps to an
entry in part_key_map (the dict returned by _collect_part_key_map). Three.js
GLTFLoader propagates node extras → object.userData, so every THREE.Mesh will
carry userData.partKey after load — no runtime lookup needed in the viewer.
The GLB format stores a JSON chunk immediately after the 12-byte header.
We re-serialize it with the new extras and update chunk + total lengths.
No external dependencies — pure stdlib struct/json.
"""
import re as _re
import struct as _struct
data = glb_path.read_bytes()
# GLB header: magic(4) + version(4) + total_length(4) = 12 bytes
# JSON chunk: chunk_length(4) + chunk_type(4) + chunk_data(chunk_length bytes)
json_len = _struct.unpack_from("<I", data, 12)[0]
json_type = _struct.unpack_from("<I", data, 16)[0]
if json_type != 0x4E4F534A: # "JSON"
print("WARNING: _inject_glb_extras: unexpected chunk type, skipping extras injection",
file=sys.stderr)
return
j = json.loads(data[20: 20 + json_len])
if "scenes" in j and j["scenes"]:
existing = j["scenes"][0].get("extras") or {}
existing.update(extras)
j["scenes"][0]["extras"] = existing
else:
j.setdefault("extras", {}).update(extras)
# Stamp per-node extras.partKey so Three.js maps it to mesh.userData.partKey.
# part_key_map keys are raw OCC names with _AF\d+ stripped but not slugified
# (e.g. "GE360-HF_000_P_ASM_1" → "ge360_hf_000_p_asm_1").
# GLB node names are raw OCC names (may or may not have _AF\d+ suffix).
# Normalize both sides to slugified form for the lookup.
if part_key_map:
_norm_re = _re.compile(r'_AF\d+$', _re.IGNORECASE)
def _slugify(s: str) -> str:
return _re.sub(r'[^a-z0-9]+', '_', _norm_re.sub('', s).lower()).strip('_')
# Build a slug→partKey lookup from the part_key_map
# part_key_map: {raw_name_no_af_suffix: part_key_slug}
slug_to_part_key: dict = {}
for raw_key, part_key in part_key_map.items():
slug_to_part_key[_slugify(raw_key)] = part_key
n_stamped = 0
for node in j.get("nodes", []):
raw = node.get("name", "")
if not raw:
continue
slug = _slugify(raw)
part_key = slug_to_part_key.get(slug)
if part_key:
node.setdefault("extras", {})["partKey"] = part_key
n_stamped += 1
print(f"Stamped partKey extras on {n_stamped} GLB nodes")
new_json = json.dumps(j, separators=(",", ":"))
# Pad to 4-byte boundary with spaces (required by GLB spec)
pad = (4 - len(new_json) % 4) % 4
new_json_bytes = new_json.encode() + b" " * pad
rest = data[20 + json_len:] # BIN chunk and anything after
new_chunk = _struct.pack("<II", len(new_json_bytes), 0x4E4F534A) + new_json_bytes
new_total = 12 + len(new_chunk) + len(rest)
new_header = _struct.pack("<III", 0x46546C67, 2, new_total)
glb_path.write_bytes(new_header + new_chunk + rest)
def main() -> None:
args = parse_args()
color_map: dict = json.loads(args.color_map)
from OCP.STEPCAFControl import STEPCAFControl_Reader
from OCP.TDocStd import TDocStd_Document
from OCP.XCAFApp import XCAFApp_Application
from OCP.XCAFDoc import XCAFDoc_DocumentTool
from OCP.TCollection import TCollection_ExtendedString, TCollection_AsciiString
from OCP.TDF import TDF_LabelSequence
from OCP.BRepMesh import BRepMesh_IncrementalMesh
from OCP.IFSelect import IFSelect_RetDone
from OCP.Message import Message_ProgressRange
# --- Init XDE document ---
app = XCAFApp_Application.GetApplication_s()
doc = TDocStd_Document(TCollection_ExtendedString("MDTV-CAF"))
app.InitDocument(doc)
# --- Read STEP into XDE (preserves part names + embedded colors) ---
reader = STEPCAFControl_Reader()
reader.SetNameMode(True)
reader.SetColorMode(True)
reader.SetLayerMode(True)
status = reader.ReadFile(args.step_path)
if status != IFSelect_RetDone:
print(f"ERROR: STEPCAFControl_Reader failed (status={status})", file=sys.stderr)
sys.exit(1)
reader.Transfer(doc)
shape_tool = XCAFDoc_DocumentTool.ShapeTool_s(doc.Main())
color_tool = XCAFDoc_DocumentTool.ColorTool_s(doc.Main())
# --- Tessellate all free shapes (in mm — original STEP coordinates) ---
# IMPORTANT: We do NOT use BRepBuilderAPI_Transform for mm→m scaling because
# it destroys Poly_Triangulation data, and SetShape on a root XCAF label does
# not propagate to component labels. Instead we tessellate in mm, export the
# GLB in mm, and post-process the GLB to add a root scale node (0.001).
free_labels = TDF_LabelSequence()
shape_tool.GetFreeShapes(free_labels)
# Collect partKeyMap before tessellation (XCAF names are stable at this point)
part_key_map = _collect_part_key_map(shape_tool, free_labels)
print(f"partKeyMap: {len(part_key_map)} unique part names collected")
print(f"Found {free_labels.Length()} root shape(s), tessellating "
f"(linear={args.linear_deflection}mm, angular={args.angular_deflection}rad) …")
engine = getattr(args, "tessellation_engine", "occ")
if engine == "gmsh":
# GMSH: tessellate each solid individually to cap peak RAM usage.
# Strategy:
# 1. BRepMesh baseline on full root_shape — tessellates ALL face types
# (solids, shells, free faces). Ensures nothing is skipped.
# 2. GMSH override per unique SOLID — better seam topology.
# Overrides the BRepMesh triangulation on solid faces only.
# REVERSED solids (mirrored instances) keep BRepMesh to avoid
# GMSH inverted-Jacobian issues.
# Deduplication uses IsSame() (TShape pointer comparison) — NOT id(TShape())
# because OCP creates a new Python wrapper per TShape() call, making id() unreliable.
from OCP.TopExp import TopExp_Explorer as _Explorer
from OCP.TopAbs import TopAbs_SOLID as _SOLID, TopAbs_SHELL as _SHELL, TopAbs_REVERSED as _REVERSED
from OCP.TopLoc import TopLoc_Location as _TopLoc_Location
for i in range(1, free_labels.Length() + 1):
root_shape = shape_tool.GetShape_s(free_labels.Value(i))
if root_shape.IsNull():
continue
# Step 1: BRepMesh baseline — catches non-solid shapes (free faces, shells)
# that TopExp_Explorer(SOLID) would miss. Also provides fallback for any
# solid that GMSH fails to tessellate.
BRepMesh_IncrementalMesh(
root_shape,
args.linear_deflection,
False,
args.angular_deflection,
True,
)
# Step 2: GMSH override for SOLID shapes (better seam topology)
_seen_shapes: list = []
solids = []
exp = _Explorer(root_shape, _SOLID)
while exp.More():
solids.append(exp.Current())
exp.Next()
if not solids:
exp = _Explorer(root_shape, _SHELL)
while exp.More():
solids.append(exp.Current())
exp.Next()
from OCP.TopoDS import TopoDS_Compound as _Compound
from OCP.BRep import BRep_Builder as _BBuilder
eligible = []
for solid in solids:
if solid.Orientation() == _REVERSED:
continue
if any(solid.IsSame(s) for s in _seen_shapes):
continue
eligible.append(solid.Located(_TopLoc_Location()))
_seen_shapes.append(solid)
if eligible:
if len(eligible) == 1:
_tessellate_with_gmsh(eligible[0], args.linear_deflection, args.angular_deflection)
else:
compound = _Compound()
bb = _BBuilder()
bb.MakeCompound(compound)
for s in eligible:
bb.Add(compound, s)
_tessellate_with_gmsh(compound, args.linear_deflection, args.angular_deflection)
else:
for i in range(1, free_labels.Length() + 1):
shape = shape_tool.GetShape_s(free_labels.Value(i))
if not shape.IsNull():
BRepMesh_IncrementalMesh(
shape,
args.linear_deflection,
False, # isRelative
args.angular_deflection,
True, # isInParallel
)
# --- Extract sharp B-rep edge pairs (coords in mm, same as tessellation) ---
sharp_pairs: list = []
try:
for i in range(1, free_labels.Length() + 1):
root_shape = shape_tool.GetShape_s(free_labels.Value(i))
if not root_shape.IsNull():
pairs = _extract_sharp_edge_pairs(root_shape, args.sharp_threshold)
sharp_pairs.extend(pairs)
print(f"Total OCC sharp segment pairs: {len(sharp_pairs)}")
except Exception as _exc:
print(f"WARNING: sharp edge extraction failed (non-fatal): {_exc}", file=sys.stderr)
sharp_pairs = []
# --- Apply colors ---
if color_map:
_apply_color_map(shape_tool, color_tool, free_labels, color_map)
print(f"Applied color_map ({len(color_map)} entries)")
else:
_apply_palette_colors(shape_tool, color_tool, free_labels)
print("Applied palette colors (no color_map provided)")
# --- Export GLB via RWGltf_CafWriter (in mm, Z-up → Y-up handled by writer) ---
from OCP.RWGltf import RWGltf_CafWriter
writer = RWGltf_CafWriter(TCollection_AsciiString(args.output_path), True) # True = binary GLB
# MergeFaces=True merges per-face triangulations into a single buffer per shape.
# Without this, RWGltf_CafWriter fails to find per-face Poly_Triangulation data
# from the XCAF component hierarchy and falls back to degenerate meshes (~2 verts/face).
writer.SetMergeFaces(True)
# Perform export
try:
from OCP.TColStd import TColStd_IndexedDataMapOfStringString
metadata = TColStd_IndexedDataMapOfStringString()
ok = writer.Perform(doc, metadata, Message_ProgressRange())
except TypeError:
# Older API without metadata dict
ok = writer.Perform(doc, Message_ProgressRange())
out = Path(args.output_path)
if not ok or not out.exists() or out.stat().st_size == 0:
print(f"ERROR: RWGltf_CafWriter.Perform returned ok={ok}, file exists={out.exists()}",
file=sys.stderr)
sys.exit(1)
print(f"GLB exported: {out.name} ({out.stat().st_size // 1024} KB)")
# --- Inject sharp edge pairs and partKeyMap into GLB extras ---
# Blender 5.0 reads scenes[0].extras as scene custom properties on import,
# making the data available to export_gltf.py as bpy.context.scene["key"].
# partKeyMap is read by Three.js in ThreeDViewer to resolve partKey from mesh name.
try:
extras_payload: dict = {}
if sharp_pairs:
extras_payload["schaeffler_sharp_edge_pairs"] = sharp_pairs
extras_payload["schaeffler_sharp_threshold_deg"] = args.sharp_threshold
if part_key_map:
extras_payload["partKeyMap"] = part_key_map
if extras_payload:
_inject_glb_extras(out, extras_payload, part_key_map=part_key_map if part_key_map else None)
if sharp_pairs:
print(f"Injected {len(sharp_pairs)} sharp edge segment pairs into GLB extras")
if part_key_map:
print(f"Injected partKeyMap ({len(part_key_map)} entries) into GLB extras")
except Exception as _exc:
print(f"WARNING: GLB extras injection failed (non-fatal): {_exc}", file=sys.stderr)
# NOTE: RWGltf_CafWriter reads unit metadata from the XDE document (set by
# STEPCAFControl_Reader from the STEP file's SI_UNIT declarations) and converts
# mm → m automatically. It also handles Z-up → Y-up coordinate transform.
# No additional scaling or BRepBuilderAPI_Transform is needed.
try:
main()
except SystemExit:
raise
except Exception:
traceback.print_exc()
sys.exit(1)