3d8825f5a9
REGISTRY rewritten around the central rule: a character folder is born only when a body ships to ariki-game (<character>_base_v<NN> = ship ordinal). lena_nude dissolves accordingly: - characters/female/lena_base_v01/ — SHIPPED 2026-08-10: AccuRig GLB carrier, T-pose/rig FBX + JSON, previews, frozen README - characters/work/lena/ — the live lane: recipes 01-47 (incl. new 36-47: refill/sheets/clay/despeckle/musculature/spin/AccuRig export/graft/pose QC), masters (athletic_v04 blend + textures, accurig blend), lane-history README - hires_claude/hires_work intermediates (blends, logs, probes) pruned Supporting docs: AGENTS.md, working-files rule, rig-graft plan addendum, originals README, prune_lane.py. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
82 lines
3.0 KiB
Python
82 lines
3.0 KiB
Python
# 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 -- <mesh.glb|blend>
|
|
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<t).mean():5.1f}%")
|
|
print("SHELLS_DONE")
|