638b93bb1e
Bug 1 — Missing parts (mirror/repeated instances): - id(solid.TShape()) is unreliable in OCP: each call creates a new Python wrapper, so id() always differs even for the same TShape. Replaced with IsSame() for correct TShape-pointer deduplication. - TopExp_Explorer(SOLID) misses free shells/faces in assemblies. Fix: run BRepMesh baseline on full root compound first (catches all face types), then GMSH overrides per unique solid for better seam topology. REVERSED solids keep BRepMesh to avoid inverted Jacobians. Bug 2 — GLB 7× too large (21 MB vs OCC 3 MB): - CharacteristicLengthMax = linear_deflection × 50 (was ×15) matches OCC effective edge length on curved surfaces (~5 mm). - MinimumCirclePoints = min(12, ...) (was min(20, ...)) - Result: GMSH 91% of OCC file size (target ≤120% ✓) Verified on rolling bearing STEP: same 4 skipped nodes as OCC, 25 unique GMSH tessellations (IsSame deduplication), no OOM. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
676 lines
28 KiB
Python
676 lines
28 KiB
Python
"""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 3–5× 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 _inject_glb_extras(glb_path: Path, extras: dict) -> None:
|
||
"""Patch a GLB binary to add/update scenes[0].extras JSON field.
|
||
|
||
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 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)
|
||
|
||
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 ---
|
||
free_labels = TDF_LabelSequence()
|
||
shape_tool.GetFreeShapes(free_labels)
|
||
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 = [] # shapes already GMSH-tessellated; compared via IsSame()
|
||
|
||
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()
|
||
|
||
for solid in solids:
|
||
# Skip REVERSED (mirrored) solids — keep BRepMesh tessellation.
|
||
# GMSH produces inverted-Jacobian meshes for negative-scale shapes.
|
||
if solid.Orientation() == _REVERSED:
|
||
continue
|
||
# Skip duplicate TShape instances — GMSH tessellation is already on the
|
||
# shared TShape from the first occurrence; overwriting would be redundant.
|
||
# IsSame() compares underlying TShape pointers (reliable; id() is not).
|
||
if any(solid.IsSame(s) for s in _seen_shapes):
|
||
continue
|
||
# Strip location: GMSH tessellates in definition space.
|
||
# The XCAF writer applies instance transforms at GLB export time.
|
||
solid_def = solid.Located(_TopLoc_Location())
|
||
_tessellate_with_gmsh(solid_def, args.linear_deflection, args.angular_deflection)
|
||
_seen_shapes.append(solid)
|
||
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 (before mm→m scaling so coords are in mm) ---
|
||
# Collect all free shapes into one list for the extraction function.
|
||
# The extraction uses the freshly tessellated XCAF shapes.
|
||
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)")
|
||
|
||
# --- Scale shapes mm → m before GLB export ---
|
||
# RWMesh_CoordinateSystemConverter is not wrapped in OCP Python bindings.
|
||
# Pre-scale each free shape by 0.001 (mm → m) using BRepBuilderAPI_Transform.
|
||
from OCP.gp import gp_Trsf
|
||
from OCP.BRepBuilderAPI import BRepBuilderAPI_Transform
|
||
|
||
trsf = gp_Trsf()
|
||
trsf.SetScaleFactor(0.001)
|
||
|
||
for i in range(1, free_labels.Length() + 1):
|
||
label = free_labels.Value(i)
|
||
orig_shape = shape_tool.GetShape_s(label)
|
||
if not orig_shape.IsNull():
|
||
scaled = BRepBuilderAPI_Transform(orig_shape, trsf, True).Shape()
|
||
shape_tool.SetShape(label, scaled)
|
||
|
||
print("Shapes scaled mm → m")
|
||
|
||
# --- Export GLB via RWGltf_CafWriter ---
|
||
from OCP.RWGltf import RWGltf_CafWriter
|
||
|
||
writer = RWGltf_CafWriter(TCollection_AsciiString(args.output_path), True) # True = binary GLB
|
||
# Z-up → Y-up rotation is applied by RWGltf_CafWriter by default (OCC 7.6+).
|
||
|
||
# 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 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"].
|
||
if sharp_pairs:
|
||
try:
|
||
_inject_glb_extras(out, {
|
||
"schaeffler_sharp_edge_pairs": sharp_pairs,
|
||
"schaeffler_sharp_threshold_deg": args.sharp_threshold,
|
||
})
|
||
print(f"Injected {len(sharp_pairs)} sharp edge segment pairs into GLB extras")
|
||
except Exception as _exc:
|
||
print(f"WARNING: GLB extras injection failed (non-fatal): {_exc}", file=sys.stderr)
|
||
|
||
|
||
try:
|
||
main()
|
||
except SystemExit:
|
||
raise
|
||
except Exception:
|
||
traceback.print_exc()
|
||
sys.exit(1)
|