fix(render): production GLB sharp edges + materials (25/25)
Sharp edges: - OCC→Blender coordinate transform was wrong: Blender(X,Y,Z) = OCC(X×0.001, -Z×0.001, Y×0.001) (RWGltf Z→Y-up + Blender Y→Z-up cancel to Y↔Z swap with Z negated) - Guard against degenerate edges (idx0==idx1) to prevent bmesh ValueError - Use obj.matrix_world @ v.co for world-space KD-tree (assembly node transforms) Materials: - Single-material fallback: if only 1 library material loaded, apply to all unmatched objects (cadquery vs RWGltf part names differ, name-match covers ~2/25) - Fix mesh data sharing: obj.data.copy() before materials.clear() to avoid clearing shared data blocks - Use obj.name (not id(obj)) for cross-loop tracking — Blender Python wrappers can be recreated between iterations Both fixes applied to export_gltf.py (production GLB) and blender_render.py (thumbnails). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -256,6 +256,73 @@ def _mark_sharp_and_seams(obj, smooth_angle_deg: float, sharp_edge_midpoints=Non
|
||||
bpy.ops.object.mode_set(mode='OBJECT')
|
||||
|
||||
|
||||
def _apply_sharp_edges_from_occ(parts, sharp_edge_pairs):
|
||||
"""Mark edges sharp using OCC-derived vertex-pair data.
|
||||
|
||||
`sharp_edge_pairs` is a list of [[x0,y0,z0],[x1,y1,z1]] in mm.
|
||||
Blender mesh coordinates are in metres (STEP mm * 0.001 scale applied).
|
||||
We match each OCC vertex pair against bmesh vertex positions with a 0.5 mm
|
||||
tolerance (0.0005 m) and mark the matched edge as sharp.
|
||||
"""
|
||||
if not sharp_edge_pairs:
|
||||
return
|
||||
|
||||
import bmesh
|
||||
import mathutils
|
||||
|
||||
SCALE = 0.001 # mm → m
|
||||
TOL = 0.0005 # 0.5 mm in metres
|
||||
|
||||
# OCC STEP space (Z-up, mm) → Blender (Z-up, m):
|
||||
# RWGltf applies Z→Y-up, Blender import applies Y→Z-up.
|
||||
# Net: Blender(X, Y, Z) = OCC(X*0.001, -Z*0.001, Y*0.001)
|
||||
occ_pairs = []
|
||||
for pair in sharp_edge_pairs:
|
||||
v0 = mathutils.Vector((pair[0][0] * SCALE, -pair[0][2] * SCALE, pair[0][1] * SCALE))
|
||||
v1 = mathutils.Vector((pair[1][0] * SCALE, -pair[1][2] * SCALE, pair[1][1] * SCALE))
|
||||
occ_pairs.append((v0, v1))
|
||||
|
||||
marked_total = 0
|
||||
for obj in parts:
|
||||
bm = bmesh.new()
|
||||
bm.from_mesh(obj.data)
|
||||
bm.verts.ensure_lookup_table()
|
||||
bm.edges.ensure_lookup_table()
|
||||
|
||||
# Build KD-tree on vertices in WORLD space — OCC pairs are world coords,
|
||||
# but mesh vertices are in local space (assembly node transform in GLB).
|
||||
world_mat = obj.matrix_world
|
||||
kd = mathutils.kdtree.KDTree(len(bm.verts))
|
||||
for v in bm.verts:
|
||||
kd.insert(world_mat @ v.co, v.index)
|
||||
kd.balance()
|
||||
|
||||
marked = 0
|
||||
for v0_occ, v1_occ in occ_pairs:
|
||||
# Find closest Blender vertex to each OCC endpoint
|
||||
_co0, idx0, dist0 = kd.find(v0_occ)
|
||||
_co1, idx1, dist1 = kd.find(v1_occ)
|
||||
if dist0 > TOL or dist1 > TOL:
|
||||
continue
|
||||
if idx0 == idx1:
|
||||
continue # degenerate — both endpoints map to same vertex
|
||||
# Find the edge shared by these two vertices
|
||||
bv0 = bm.verts[idx0]
|
||||
bv1 = bm.verts[idx1]
|
||||
edge = bm.edges.get((bv0, bv1))
|
||||
if edge is None:
|
||||
edge = bm.edges.get((bv1, bv0))
|
||||
if edge is not None and edge.smooth:
|
||||
edge.smooth = False
|
||||
marked += 1
|
||||
|
||||
bm.to_mesh(obj.data)
|
||||
bm.free()
|
||||
marked_total += marked
|
||||
|
||||
print(f"[blender_render] OCC sharp edges applied: {marked_total} edges marked across {len(parts)} parts", flush=True)
|
||||
|
||||
|
||||
def _import_glb(glb_file):
|
||||
"""Import OCC-generated GLB into Blender.
|
||||
|
||||
@@ -288,8 +355,14 @@ def _import_glb(glb_file):
|
||||
max(v.y for v in all_corners),
|
||||
max(v.z for v in all_corners)))
|
||||
center = (mins + maxs) * 0.5
|
||||
for p in parts:
|
||||
p.location -= center
|
||||
# Move root objects (parentless) to centre. Adjusting a child's local
|
||||
# .location by a world-space vector gives wrong results when the GLB has
|
||||
# Empty parent nodes (OCC assembly hierarchy). Shifting the root moves
|
||||
# the entire hierarchy correctly.
|
||||
all_imported = list(bpy.context.selected_objects)
|
||||
root_objects = [o for o in all_imported if o.parent is None]
|
||||
for obj in root_objects:
|
||||
obj.location -= center
|
||||
|
||||
return parts
|
||||
|
||||
@@ -479,6 +552,10 @@ if use_template:
|
||||
# selected object in a single C call — same effect as calling per-object
|
||||
# but ~100× faster (0.2s vs 16s for 175 parts).
|
||||
_apply_smooth_batch(parts, smooth_angle)
|
||||
# If OCC extracted sharp edge vertex pairs, mark them explicitly.
|
||||
_occ_pairs = _mesh_attrs.get("sharp_edge_pairs") or []
|
||||
if _occ_pairs:
|
||||
_apply_sharp_edges_from_occ(parts, _occ_pairs)
|
||||
_lap("smooth_shading")
|
||||
|
||||
# Material assignment: library materials if available, otherwise palette
|
||||
@@ -571,6 +648,9 @@ else:
|
||||
import time as _time
|
||||
_t_smooth_a = _time.time()
|
||||
_apply_smooth_batch(parts, smooth_angle)
|
||||
_occ_pairs = _mesh_attrs.get("sharp_edge_pairs") or []
|
||||
if _occ_pairs:
|
||||
_apply_sharp_edges_from_occ(parts, _occ_pairs)
|
||||
for part in parts:
|
||||
_assign_failed_material(part)
|
||||
print(f"[blender_render] smooth+fallback-material: {len(parts)} parts ({_time.time()-_t_smooth_a:.2f}s)", flush=True)
|
||||
|
||||
@@ -34,50 +34,130 @@ def parse_args() -> argparse.Namespace:
|
||||
parser.add_argument("--material_map", default="{}")
|
||||
parser.add_argument("--smooth_angle", type=float, default=30.0,
|
||||
help="Auto-smooth angle in degrees (default 30)")
|
||||
parser.add_argument("--mesh_attributes", default="{}",
|
||||
help="JSON dict from cad_file.mesh_attributes (sharp_edge_pairs etc.)")
|
||||
return parser.parse_args(rest)
|
||||
|
||||
|
||||
def _apply_sharp_edges_from_occ(mesh_objects: list, sharp_edge_pairs: list) -> None:
|
||||
"""Mark edges sharp using OCC vertex-pair data (same approach as blender_render.py).
|
||||
|
||||
sharp_edge_pairs: [[x0,y0,z0],[x1,y1,z1]] in mm.
|
||||
Blender mesh coords are in metres (×0.001 scale already applied by OCC export).
|
||||
"""
|
||||
if not sharp_edge_pairs:
|
||||
return
|
||||
|
||||
import bmesh
|
||||
import mathutils
|
||||
|
||||
SCALE = 0.001 # mm → m
|
||||
TOL = 0.0005 # 0.5 mm tolerance in metres
|
||||
|
||||
# OCC STEP space (Z-up, mm) → Blender (Z-up, m):
|
||||
# RWGltf applies Z→Y-up, Blender import applies Y→Z-up.
|
||||
# Net: Blender(X, Y, Z) = OCC(X*0.001, -Z*0.001, Y*0.001)
|
||||
occ_pairs = []
|
||||
for pair in sharp_edge_pairs:
|
||||
v0 = mathutils.Vector((pair[0][0] * SCALE, -pair[0][2] * SCALE, pair[0][1] * SCALE))
|
||||
v1 = mathutils.Vector((pair[1][0] * SCALE, -pair[1][2] * SCALE, pair[1][1] * SCALE))
|
||||
occ_pairs.append((v0, v1))
|
||||
|
||||
marked_total = 0
|
||||
for obj in mesh_objects:
|
||||
bm = bmesh.new()
|
||||
bm.from_mesh(obj.data)
|
||||
bm.verts.ensure_lookup_table()
|
||||
bm.edges.ensure_lookup_table()
|
||||
|
||||
# Build KD-tree in WORLD space — OCC pairs are world coords, but mesh
|
||||
# vertices are in local space (assembly node transform in GLB hierarchy).
|
||||
world_mat = obj.matrix_world
|
||||
kd = mathutils.kdtree.KDTree(len(bm.verts))
|
||||
for v in bm.verts:
|
||||
kd.insert(world_mat @ v.co, v.index)
|
||||
kd.balance()
|
||||
|
||||
marked = 0
|
||||
for v0_occ, v1_occ in occ_pairs:
|
||||
_co0, idx0, dist0 = kd.find(v0_occ)
|
||||
_co1, idx1, dist1 = kd.find(v1_occ)
|
||||
if dist0 > TOL or dist1 > TOL:
|
||||
continue
|
||||
if idx0 == idx1:
|
||||
continue # degenerate — both endpoints map to same vertex
|
||||
bv0, bv1 = bm.verts[idx0], bm.verts[idx1]
|
||||
edge = bm.edges.get((bv0, bv1)) or bm.edges.get((bv1, bv0))
|
||||
if edge is not None and edge.smooth:
|
||||
edge.smooth = False
|
||||
marked += 1
|
||||
|
||||
bm.to_mesh(obj.data)
|
||||
bm.free()
|
||||
marked_total += marked
|
||||
|
||||
print(f"OCC sharp edges applied: {marked_total} edges marked across {len(mesh_objects)} objects")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
args = parse_args()
|
||||
material_map: dict = json.loads(args.material_map)
|
||||
mesh_attributes: dict = json.loads(args.mesh_attributes)
|
||||
|
||||
import bpy # type: ignore[import]
|
||||
import math as _math
|
||||
import re as _re
|
||||
|
||||
# Clean scene
|
||||
bpy.ops.wm.read_factory_settings(use_empty=True)
|
||||
|
||||
# Import geometry GLB from export_step_to_gltf.py (already in metres, Y-up)
|
||||
bpy.ops.import_scene.gltf(filepath=args.glb_path)
|
||||
print(f"Imported geometry GLB: {args.glb_path} "
|
||||
f"({len([o for o in bpy.data.objects if o.type == 'MESH'])} mesh objects)")
|
||||
mesh_objects = [o for o in bpy.data.objects if o.type == "MESH"]
|
||||
print(f"Imported geometry GLB: {args.glb_path} ({len(mesh_objects)} mesh objects)")
|
||||
|
||||
# Apply smooth shading using the configured angle threshold
|
||||
smooth_rad = _math.radians(args.smooth_angle)
|
||||
print(f"Applying smooth shading at {args.smooth_angle}° ({smooth_rad:.3f} rad)")
|
||||
for obj in bpy.data.objects:
|
||||
if obj.type == "MESH":
|
||||
bpy.context.view_layer.objects.active = obj
|
||||
obj.select_set(True)
|
||||
try:
|
||||
bpy.ops.object.shade_smooth_by_angle(angle=smooth_rad)
|
||||
except Exception:
|
||||
# Fallback for older Blender API
|
||||
bpy.ops.object.shade_smooth()
|
||||
if obj.data.use_auto_smooth is not None:
|
||||
obj.data.use_auto_smooth = True
|
||||
obj.data.auto_smooth_angle = smooth_rad
|
||||
for obj in mesh_objects:
|
||||
bpy.context.view_layer.objects.active = obj
|
||||
obj.select_set(True)
|
||||
try:
|
||||
bpy.ops.object.shade_smooth_by_angle(angle=smooth_rad)
|
||||
except Exception:
|
||||
bpy.ops.object.shade_smooth()
|
||||
if hasattr(obj.data, 'use_auto_smooth'):
|
||||
obj.data.use_auto_smooth = True
|
||||
obj.data.auto_smooth_angle = smooth_rad
|
||||
|
||||
# Apply OCC sharp edges if available (overrides pure dihedral-angle shading)
|
||||
sharp_pairs = mesh_attributes.get("sharp_edge_pairs") or []
|
||||
if sharp_pairs:
|
||||
_apply_sharp_edges_from_occ(mesh_objects, sharp_pairs)
|
||||
|
||||
# Apply asset library materials if provided.
|
||||
# link=False (append) is required: the GLTF exporter can only traverse
|
||||
# local (appended) Principled BSDF node trees to extract PBR values.
|
||||
#
|
||||
# IMPORTANT: OCC-exported GLBs name materials generically (mat_0, mat_1, …)
|
||||
# but preserve STEP part names as mesh OBJECT names. We therefore match by
|
||||
# obj.name, not by slot.material.name (which is how blender_render.py works).
|
||||
# Matching strategy (mirrors blender_render.py):
|
||||
# Build mat_map_lower with BOTH the original key AND the _AF-stripped key,
|
||||
# so keys like "RingOuter_AF0" match object names "RingOuter" and vice-versa.
|
||||
# Object names from RWGltf_CafWriter preserve the original STEP part name
|
||||
# (including any _AF suffixes), so we strip from both sides.
|
||||
if args.asset_library_blend and material_map:
|
||||
import re as _re
|
||||
mat_map_lower = {k.lower().strip(): v for k, v in material_map.items()}
|
||||
mat_map_lower: dict = {}
|
||||
for k, v in material_map.items():
|
||||
kl = k.lower().strip()
|
||||
mat_map_lower[kl] = v
|
||||
# Also add the _AF-stripped version so either form matches
|
||||
stripped = kl
|
||||
prev = None
|
||||
while prev != stripped:
|
||||
prev = stripped
|
||||
stripped = _re.sub(r'_af\d+$', '', stripped)
|
||||
if stripped != kl:
|
||||
mat_map_lower.setdefault(stripped, v)
|
||||
|
||||
needed = set(mat_map_lower.values())
|
||||
|
||||
# Append materials from library (link=False so glTF exporter can read nodes)
|
||||
@@ -101,38 +181,60 @@ def main() -> None:
|
||||
|
||||
if appended:
|
||||
assigned = 0
|
||||
mesh_objects = [o for o in bpy.data.objects if o.type == "MESH"]
|
||||
assigned_names: set = set()
|
||||
for obj in mesh_objects:
|
||||
# Strip Blender's .001/.002 deduplication suffix
|
||||
base_name = _re.sub(r'\.\d{3}$', '', obj.name)
|
||||
# Strip OCC assembly-instance suffix (_AF0, _AF1, … added by
|
||||
# RWGltf_CafWriter when the same part appears multiple times).
|
||||
# Apply repeatedly in case of nested suffixes (_AF0_AF1, etc.)
|
||||
# Also strip _AF suffix from object name so both directions match
|
||||
prev = None
|
||||
while prev != base_name:
|
||||
prev = base_name
|
||||
base_name = _re.sub(r'_AF\d+$', '', base_name, flags=_re.IGNORECASE)
|
||||
|
||||
mat_name = mat_map_lower.get(base_name.lower().strip())
|
||||
lower_base = base_name.lower().strip()
|
||||
mat_name = mat_map_lower.get(lower_base)
|
||||
|
||||
# Prefix fallback: some sub-assembly nodes have names that
|
||||
# extend a known key (e.g. key="Ring" matches "Ring_inner_AF0").
|
||||
# Sort by key length descending so the most-specific key wins.
|
||||
# Prefix fallback for sub-assembly nodes
|
||||
if not mat_name:
|
||||
lower_base = base_name.lower().strip()
|
||||
for key, val in sorted(mat_map_lower.items(), key=lambda x: len(x[0]), reverse=True):
|
||||
if len(key) >= 5 and len(lower_base) >= 5 and (
|
||||
if len(key) >= 3 and len(lower_base) >= 3 and (
|
||||
lower_base.startswith(key) or key.startswith(lower_base)
|
||||
):
|
||||
mat_name = val
|
||||
break
|
||||
|
||||
if mat_name and mat_name in appended:
|
||||
# Make mesh data single-user before modifying material slots;
|
||||
# otherwise clearing materials on a shared data block removes
|
||||
# slots from ALL objects that share it.
|
||||
if obj.data.users > 1:
|
||||
obj.data = obj.data.copy()
|
||||
obj.data.materials.clear()
|
||||
obj.data.materials.append(appended[mat_name])
|
||||
assigned += 1
|
||||
assigned_names.add(obj.name)
|
||||
else:
|
||||
pass # name-matching miss — may be covered by single-material fallback below
|
||||
print(f"Material substitution: {assigned}/{len(mesh_objects)} mesh objects assigned")
|
||||
|
||||
# Single-material fallback: if only one library material was loaded,
|
||||
# apply it to every object that name-matching missed.
|
||||
# (mat_map_lower may contain unresolvable pass-through values like
|
||||
# "Stahl; Durotect CMT", so checking appended is more reliable.)
|
||||
if len(appended) == 1:
|
||||
default_mat_name, default_mat = next(iter(appended.items()))
|
||||
if default_mat:
|
||||
fallback = 0
|
||||
for obj in mesh_objects:
|
||||
if obj.name not in assigned_names:
|
||||
if obj.data.users > 1:
|
||||
obj.data = obj.data.copy()
|
||||
obj.data.materials.clear()
|
||||
obj.data.materials.append(default_mat)
|
||||
fallback += 1
|
||||
if fallback:
|
||||
print(f"Single-material fallback: applied '{default_mat_name}' to {fallback} unmatched objects")
|
||||
|
||||
# Export production GLB with full PBR material data
|
||||
try:
|
||||
bpy.ops.export_scene.gltf(
|
||||
|
||||
Reference in New Issue
Block a user