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>
215 lines
7.7 KiB
Python
215 lines
7.7 KiB
Python
# Stage 16c (diagnosis only): settle whether the visible lines are TOPOLOGY or GEOMETRY, and
|
|
# inventory the real holes. Stage 15 asserted "the two sides of every seam are disconnected
|
|
# vertex runs"; stage 17's weld attempt on that premise tore the mesh (830 -> 24k boundary
|
|
# edges), which is itself evidence the premise is wrong.
|
|
#
|
|
# blender --background --python 16c_topology.py -- <blend>
|
|
#
|
|
# Tests
|
|
# 1. LOCAL EDGE LENGTH — the scale everything else must be judged against. A "twin at 0.8 mm"
|
|
# means nothing until you know the mesh spacing is ~1.2 mm; at that scale a non-adjacent
|
|
# vertex 0.8 mm away is just a 2-ring neighbour, not a crack. This is the control the earlier
|
|
# split-panel test lacked.
|
|
# 2. TRUE TWIN TEST — nearest vertex that is outside the 3-ring topological neighbourhood,
|
|
# normalised by local edge length. A real crack gives a spike at ratio << 1; ordinary mesh
|
|
# gives a distribution centred near/above 1.
|
|
# 3. HOLE INVENTORY — boundary edges grouped into loops, with size and location, so filling can
|
|
# be per-loop instead of one net (which is what produced 39k non-manifold edges).
|
|
# 4. RELIEF SCALE-SPACE — for the kink verts, small-scale vs large-scale normal offset. A scan
|
|
# seam is thin (small-scale relief, no large-scale relief); real anatomy (underbust fold,
|
|
# gluteal fold, clavicle) has both. This is the discriminator a heal mask must use so it
|
|
# erases seams without erasing her.
|
|
import bpy, bmesh, sys, time
|
|
import numpy as np
|
|
from mathutils import Vector
|
|
from mathutils.kdtree import KDTree
|
|
from collections import deque
|
|
|
|
argv = sys.argv[sys.argv.index("--") + 1:]
|
|
BLEND = argv[0]
|
|
t0 = time.time()
|
|
UNIT_MM = 1815.0 # 1 mesh unit = 1.815 m (body is 0.979 units for 1.777 m)
|
|
|
|
|
|
def log(m):
|
|
print(f"[topo {time.time()-t0:6.1f}s] {m}", flush=True)
|
|
|
|
|
|
bpy.ops.wm.open_mainfile(filepath=BLEND)
|
|
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 = len(me.vertices)
|
|
co = np.empty(n_v * 3)
|
|
me.vertices.foreach_get("co", co)
|
|
co = co.reshape(-1, 3)
|
|
nrm = np.empty(n_v * 3)
|
|
me.vertices.foreach_get("normal", nrm)
|
|
nrm = nrm.reshape(-1, 3)
|
|
ev = np.empty(len(me.edges) * 2, dtype=np.int32)
|
|
me.edges.foreach_get("vertices", ev)
|
|
ev = ev.reshape(-1, 2)
|
|
log(f"{n_v}v {len(me.polygons)}f {len(me.edges)}e")
|
|
|
|
# ---- 1. local edge length ----
|
|
elen = np.linalg.norm(co[ev[:, 0]] - co[ev[:, 1]], axis=1)
|
|
acc = np.zeros(n_v)
|
|
cntv = np.zeros(n_v)
|
|
np.add.at(acc, ev[:, 0], elen)
|
|
np.add.at(acc, ev[:, 1], elen)
|
|
np.add.at(cntv, ev[:, 0], 1.0)
|
|
np.add.at(cntv, ev[:, 1], 1.0)
|
|
L = acc / np.maximum(cntv, 1)
|
|
print(f"EDGE LENGTH mesh units: mean {elen.mean():.6f} ({elen.mean()*UNIT_MM:.2f} real mm) "
|
|
f"p05 {np.percentile(elen,5):.6f} p95 {np.percentile(elen,95):.6f}")
|
|
|
|
order = np.concatenate([ev[:, 0], ev[:, 1]])
|
|
nbr = np.concatenate([ev[:, 1], ev[:, 0]])
|
|
srt = np.argsort(order, kind="stable")
|
|
o_s, n_s = order[srt], nbr[srt]
|
|
ptr = np.searchsorted(o_s, np.arange(n_v + 1))
|
|
cnt = np.maximum(np.diff(ptr), 1)
|
|
|
|
|
|
def nbr_mean(X):
|
|
a = np.add.reduceat(X[n_s], ptr[:-1], axis=0)
|
|
a[np.diff(ptr) == 0] = X[np.diff(ptr) == 0]
|
|
return a / cnt[:, None]
|
|
|
|
|
|
N = nrm.copy()
|
|
for _ in range(5):
|
|
N = nbr_mean(N)
|
|
N /= np.maximum(np.linalg.norm(N, axis=1, keepdims=True), 1e-12)
|
|
ang = np.degrees(np.arccos(np.clip((nrm * N).sum(axis=1), -1, 1)))
|
|
torso = (co[:, 2] > 0.28) & (co[:, 2] < 0.90)
|
|
kink = torso & (ang > 12.0)
|
|
log(f"kink>12deg in torso: {kink.sum()}")
|
|
|
|
# ---- 2. true twin test (outside the 3-ring) ----
|
|
kidx = np.nonzero(kink)[0]
|
|
sample = kidx[::max(1, len(kidx) // 3000)]
|
|
ctrl = np.nonzero(torso & (ang < 3.0))[0]
|
|
ctrl = ctrl[::max(1, len(ctrl) // 3000)] # control: quiet skin, same test
|
|
kd = KDTree(n_v)
|
|
for i in range(n_v):
|
|
kd.insert(Vector(co[i]), i)
|
|
kd.balance()
|
|
adj = {}
|
|
for a, b in ev:
|
|
adj.setdefault(int(a), set()).add(int(b))
|
|
adj.setdefault(int(b), set()).add(int(a))
|
|
|
|
|
|
def ring3(i):
|
|
seen = {i}
|
|
frontier = {i}
|
|
for _ in range(3):
|
|
nxt = set()
|
|
for v in frontier:
|
|
nxt |= adj.get(v, set())
|
|
nxt -= seen
|
|
seen |= nxt
|
|
frontier = nxt
|
|
return seen
|
|
|
|
|
|
def twin_ratio(idxs):
|
|
out = []
|
|
for i in idxs:
|
|
excl = ring3(int(i))
|
|
best = None
|
|
for (_, j, d) in kd.find_range(Vector(co[i]), L[i] * 2.0):
|
|
if int(j) in excl:
|
|
continue
|
|
if float(nrm[i] @ nrm[j]) < 0.0:
|
|
continue # opposite-facing surface (thigh vs thigh) is not a seam twin
|
|
if best is None or d < best:
|
|
best = d
|
|
out.append(best / L[i] if best is not None else np.nan)
|
|
return np.array(out)
|
|
|
|
|
|
tr_k = twin_ratio(sample)
|
|
tr_c = twin_ratio(ctrl)
|
|
log("twin test done")
|
|
for nm, tr in (("KINK verts", tr_k), ("CONTROL quiet skin", tr_c)):
|
|
v = tr[~np.isnan(tr)]
|
|
print(f"TWIN-RATIO {nm}: n={len(v)}/{len(tr)} "
|
|
f"p05={np.percentile(v,5):.2f} p25={np.percentile(v,25):.2f} "
|
|
f"median={np.median(v):.2f} p75={np.percentile(v,75):.2f}"
|
|
if len(v) else f"TWIN-RATIO {nm}: no hits")
|
|
if len(v):
|
|
print(f" fraction with a non-3-ring vertex closer than 0.35x edge length: "
|
|
f"{100.0*(v<0.35).mean():.1f}% (<0.6x: {100.0*(v<0.6).mean():.1f}%)")
|
|
|
|
# ---- 3. hole inventory ----
|
|
bm = bmesh.new()
|
|
bm.from_mesh(me)
|
|
open_e = [e for e in bm.edges if len(e.link_faces) == 1]
|
|
print(f"\nHOLES: {len(open_e)} boundary edges, "
|
|
f"{len([e for e in bm.edges if len(e.link_faces) > 2])} non-manifold edges")
|
|
eset = set(e.index for e in open_e)
|
|
emap = {e.index: e for e in open_e}
|
|
seen = set()
|
|
loops = []
|
|
for e in open_e:
|
|
if e.index in seen:
|
|
continue
|
|
q = deque([e.index])
|
|
seen.add(e.index)
|
|
comp = []
|
|
while q:
|
|
ei = q.popleft()
|
|
cur = emap[ei]
|
|
comp.append(cur)
|
|
for v in cur.verts:
|
|
for e2 in v.link_edges:
|
|
if e2.index in eset and e2.index not in seen:
|
|
seen.add(e2.index)
|
|
q.append(e2.index)
|
|
loops.append(comp)
|
|
loops.sort(key=len, reverse=True)
|
|
print(f"HOLES: {len(loops)} separate boundary loops")
|
|
for i, lp in enumerate(loops[:14]):
|
|
zs = [v.co.z for e in lp for v in e.verts]
|
|
xs = [v.co.x for e in lp for v in e.verts]
|
|
ys = [v.co.y for e in lp for v in e.verts]
|
|
print(f" loop{i:2d}: {len(lp):5d} edges z {min(zs):.3f}-{max(zs):.3f} "
|
|
f"x {min(xs):+.3f}..{max(xs):+.3f} y {min(ys):+.3f}..{max(ys):+.3f}")
|
|
small = [lp for lp in loops if len(lp) <= 60]
|
|
print(f"HOLES: {len(small)} loops <=60 edges (safe per-loop fills), "
|
|
f"{len(loops)-len(small)} larger")
|
|
bm.free()
|
|
|
|
# ---- 4. relief scale-space ----
|
|
def smooth_n(X, k):
|
|
Y = X.copy()
|
|
for _ in range(k):
|
|
Y = nbr_mean(Y)
|
|
return Y
|
|
|
|
|
|
sm_s = smooth_n(co, 8)
|
|
sm_l = smooth_n(co, 60)
|
|
dev_s = ((co - sm_s) * N).sum(axis=1)
|
|
dev_l = ((co - sm_l) * N).sum(axis=1)
|
|
print("\nRELIEF SCALE-SPACE (real mm along the smooth normal)")
|
|
for nm, m in (("kink>12deg", kink),
|
|
("quiet skin", torso & (ang < 3.0))):
|
|
if not m.sum():
|
|
continue
|
|
print(f" {nm}: |small-scale| median {np.median(np.abs(dev_s[m]))*UNIT_MM:.3f} mm, "
|
|
f"p95 {np.percentile(np.abs(dev_s[m]),95)*UNIT_MM:.3f} mm | "
|
|
f"|large-scale| median {np.median(np.abs(dev_l[m]))*UNIT_MM:.3f} mm, "
|
|
f"p95 {np.percentile(np.abs(dev_l[m]),95)*UNIT_MM:.3f} mm")
|
|
band = np.abs(dev_s) * UNIT_MM
|
|
bandpass = torso & (band > 0.35) & (np.abs(dev_l) * UNIT_MM < 1.6)
|
|
print(f" BAND-PASS candidate mask (thin relief >0.35 mm, broad relief <1.6 mm): "
|
|
f"{int(bandpass.sum())} verts")
|
|
hist, edges = np.histogram(co[bandpass, 2], bins=14)
|
|
for c, lo, hi in zip(hist, edges[:-1], edges[1:]):
|
|
if c > 50:
|
|
print(f" z {lo:.3f}-{hi:.3f}: {c:6d}")
|
|
print("TOPO_DONE")
|