"""Weld coincident geometry, retain corner UV seams, then collapse to target. Studio GLBs split vertices at normals and UV seams. Decimating those as loose triangles drops patches rather than simplifying a continuous surface. """ import hashlib import json import sys from pathlib import Path import bpy import bmesh source, target, requested = sys.argv[sys.argv.index('--') + 1:][:3] source, target, requested = Path(source), Path(target), int(requested) bpy.ops.wm.read_factory_settings(use_empty=True) bpy.ops.import_scene.gltf(filepath=str(source), merge_vertices=False) objects = [o for o in bpy.context.scene.objects if o.type == 'MESH'] assert len(objects) == 1 obj = objects[0] bpy.context.view_layer.objects.active = obj bpy.ops.object.select_all(action='DESELECT') obj.select_set(True) bpy.ops.object.transform_apply(location=True, rotation=True, scale=True) assert obj.data.uv_layers.active def corners(): # Reordering by bmesh is harmless; the (position, per-corner UV) multiset # must stay the same through welding, including UV seam discontinuities. uv = obj.data.uv_layers.active.data return sorted(tuple(round(c, 6) for c in (*obj.data.vertices[l.vertex_index].co, *uv[l.index].uv)) for l in obj.data.loops) before_corners = corners() vertices_before = len(obj.data.vertices) bm = bmesh.new() bm.from_mesh(obj.data) bmesh.ops.remove_doubles(bm, verts=list(bm.verts), dist=max(obj.dimensions) * 1e-7) bm.to_mesh(obj.data) bm.free() obj.data.update() assert before_corners == corners(), 'Welding changed the existing corner UV mapping' vertices_welded = len(obj.data.vertices) tri = obj.modifiers.new('Triangulate', 'TRIANGULATE') bpy.ops.object.modifier_apply(modifier=tri.name) before = len(obj.data.polygons) area_before = sum(p.area for p in obj.data.polygons) modifier = obj.modifiers.new('PreserveCornerUVDecimate', 'DECIMATE') modifier.decimate_type = 'COLLAPSE' modifier.use_collapse_triangulate = True lower, upper = 0.0, 1.0 candidate = min(1.0, requested / before) best = None for _ in range(22): modifier.ratio = candidate bpy.context.view_layer.update() count = len(obj.evaluated_get(bpy.context.evaluated_depsgraph_get()).data.polygons) rank = (abs(count - requested), count > requested) if best is None or rank < best[0]: best = (rank, candidate) if count == requested: break if count > requested: upper = candidate else: lower = candidate candidate = (lower + upper) / 2 modifier.ratio = best[1] bpy.ops.object.modifier_apply(modifier=modifier.name) after = len(obj.data.polygons) area_after = sum(p.area for p in obj.data.polygons) print('Final faces', after, 'target', requested, 'best', best, flush=True) assert after <= requested * 1.30, (after, requested, best) assert area_after >= area_before * .80, 'Reduction lost too much surface area' assert obj.data.uv_layers.active bpy.ops.export_scene.gltf(filepath=str(target), export_format='GLB', use_selection=True, export_materials='EXPORT', export_image_format='AUTO') report = dict(faces_before=before, faces_after=after, target_faces=requested, vertices_before=vertices_before, vertices_after_weld=vertices_welded, cornerUVPreservedDuringWeld=True, uv_mode='preserved_no_unwrap', area_before=area_before, area_after=area_after, area_ratio=area_after / area_before, ratio=best[1], threshold_percent=30, triggered=True) target.with_suffix('.json').write_text(json.dumps(report, indent=2), encoding='utf-8') print('MT_SUMMARY ' + json.dumps(report), flush=True)