# Stage 23 (read-only): how many CONNECTED COMPONENTS does the mesh have? # An atlas can never have fewer charts than the mesh has shells. If Tripo's mesh is a soup of # disconnected shells, the chart soup is a symptom, not the disease — and a new atlas has to # start by stitching the mesh. # # blender --background --python 23_shells.py -- import bpy, sys, time import numpy as np argv = sys.argv[sys.argv.index("--") + 1:] SRC = argv[0] t0 = time.time() if SRC.lower().endswith(".glb"): bpy.ops.wm.read_homefile(use_empty=True) bpy.ops.import_scene.gltf(filepath=SRC) else: bpy.ops.wm.open_mainfile(filepath=SRC) ob = max([o for o in bpy.data.objects if o.type == 'MESH'], key=lambda o: len(o.data.vertices)) me = ob.data n_v, n_f = len(me.vertices), len(me.polygons) print(f"mesh '{ob.name}': {n_v}v {n_f}f") ev = np.empty(len(me.edges) * 2, dtype=np.int32); me.edges.foreach_get("vertices", ev) ev = ev.reshape(-1, 2) parent = np.arange(n_v, dtype=np.int64) def find(x): r = x while parent[r] != r: r = parent[r] while parent[x] != r: parent[x], x = r, parent[x] return r for a, b in ev: ra, rb = find(a), find(b) if ra != rb: parent[rb] = ra roots = np.array([find(i) for i in range(n_v)]) _, comp, sizes = np.unique(roots, return_inverse=True, return_counts=True) print(f"\nTOPOLOGICAL SHELLS (edge-connected): {len(sizes)}") o = np.argsort(-sizes) print(f" largest {sizes[o[0]]} verts ({100.0*sizes[o[0]]/n_v:.1f}% of the mesh)") print(f" shells >1000 verts: {int((sizes>1000).sum())} " f">100: {int((sizes>100).sum())} >10: {int((sizes>10).sum())} " f"<=10: {int((sizes<=10).sum())}") print(f" top 15 shell sizes: {sizes[o[:15]].tolist()}") print(f" verts outside the largest shell: {n_v - sizes[o[0]]} " f"({100.0*(n_v-sizes[o[0]])/n_v:.2f}%)") # how far apart are the shells really? if a small shell sits flush against the big one, # a merge-by-distance would stitch it — report the gap. if len(sizes) > 1: from mathutils import Vector from mathutils.kdtree import KDTree co = np.empty(n_v * 3); me.vertices.foreach_get("co", co); co = co.reshape(-1, 3) span = co.max(axis=0) - co.min(axis=0) UNITM = 1.777 / span.max() big = comp == comp[np.argmax(np.bincount(comp))] bidx = np.nonzero(big)[0][::7] kd = KDTree(len(bidx)) for j, i in enumerate(bidx): kd.insert(Vector(co[i]), j) kd.balance() gaps = [] others = np.nonzero(~big)[0] for i in others[::max(1, len(others) // 3000)]: _, _, dist = kd.find(Vector(co[i])) gaps.append(dist * UNITM * 1000.0) gaps = np.array(gaps) if len(gaps): print(f"\n gap from off-shell verts to the main shell (real mm, sampled {len(gaps)}):") for p in (50, 75, 90, 99): print(f" p{p:<2d} {np.percentile(gaps,p):.3f} mm") for t in (0.05, 0.2, 0.5, 1.0, 2.0): print(f" within {t:4.2f} mm: {100.0*(gaps