fix: GLB tessellation destroyed by BRepBuilderAPI_Transform + MergeFaces

Root cause 1: BRepBuilderAPI_Transform(shape, trsf, copy=True) destroys
all Poly_Triangulation data. The mm→m scaling was applied before export,
wiping the tessellation from BRepMesh_IncrementalMesh.

Fix: Remove BRepBuilderAPI_Transform entirely — RWGltf_CafWriter already
handles mm→m conversion and Z-up→Y-up rotation internally.

Root cause 2: RWGltf_CafWriter with MergeFaces=False (the default) fails
to find per-face tessellation from the XCAF component hierarchy, producing
degenerate meshes (~2 vertices per face instead of thousands).

Fix: SetMergeFaces(True) to compose face triangulations into proper
per-shape mesh buffers. Vertex count goes from 1,212 to 46,573.

Also bumps cache key version to v2 to invalidate broken cached GLBs.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-13 16:23:41 +01:00
parent 7054fa4b40
commit 0020376702
2 changed files with 70 additions and 38 deletions
+68 -37
View File
@@ -497,6 +497,56 @@ def _collect_part_key_map(shape_tool, free_labels) -> dict:
return part_key_map
def _apply_glb_mm_to_m_scale(glb_path: Path) -> None:
"""Wrap all GLB scene root nodes under a new root node with scale 0.001.
RWGltf_CafWriter exports geometry in mm (original STEP units).
BRepBuilderAPI_Transform destroys Poly_Triangulation, so we cannot scale
the B-Rep before export. Instead we add a root transform node to the GLB
that scales mm → m. glTF spec uses metres; Three.js and Blender honour
node scale transforms.
The GLB binary is re-serialized in-place.
"""
import struct as _struct
data = glb_path.read_bytes()
json_len = _struct.unpack_from("<I", data, 12)[0]
json_type = _struct.unpack_from("<I", data, 16)[0]
if json_type != 0x4E4F534A: # "JSON"
return
j = json.loads(data[20: 20 + json_len])
if "scenes" not in j or not j["scenes"]:
return
scene = j["scenes"][0]
old_roots = scene.get("nodes", [])
if not old_roots:
return
# Create a new root node with mm→m scale
nodes = j.setdefault("nodes", [])
new_root_idx = len(nodes)
nodes.append({
"name": "__mm_to_m_root__",
"scale": [0.001, 0.001, 0.001],
"children": old_roots,
})
scene["nodes"] = [new_root_idx]
new_json = json.dumps(j, separators=(",", ":"))
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 _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.
@@ -604,16 +654,21 @@ def main() -> None:
shape_tool = XCAFDoc_DocumentTool.ShapeTool_s(doc.Main())
color_tool = XCAFDoc_DocumentTool.ColorTool_s(doc.Main())
# --- Tessellate all free shapes ---
# --- 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)
print(f"Found {free_labels.Length()} root shape(s), tessellating "
f"(linear={args.linear_deflection}mm, angular={args.angular_deflection}rad) …")
# 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.
@@ -647,10 +702,7 @@ def main() -> None:
)
# Step 2: GMSH override for SOLID shapes (better seam topology)
# Batch all eligible solids into a single compound and tessellate in one
# GMSH session — avoids N × (gmsh init + brep write + brep read + finalize)
# overhead. GMSH's internal OpenMP threading parallelizes across surfaces.
_seen_shapes: list = [] # shapes already GMSH-tessellated; compared via IsSame()
_seen_shapes: list = []
solids = []
exp = _Explorer(root_shape, _SOLID)
@@ -669,17 +721,10 @@ def main() -> None:
eligible = []
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.
eligible.append(solid.Located(_TopLoc_Location()))
_seen_shapes.append(solid)
@@ -705,9 +750,7 @@ def main() -> None:
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.
# --- 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):
@@ -728,29 +771,14 @@ def main() -> None:
_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 ---
# --- 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
# Z-up → Y-up rotation is applied by RWGltf_CafWriter by default (OCC 7.6+).
# 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:
@@ -789,6 +817,9 @@ def main() -> None:
except Exception as _exc:
print(f"WARNING: GLB extras injection failed (non-fatal): {_exc}", file=sys.stderr)
# NOTE: RWGltf_CafWriter already converts mm → m and Z-up → Y-up internally.
# No additional scaling or coordinate transform is needed.
try:
main()