Files
animation/characters/work/lena/17_panel_weld.py
T
jeremy 3d8825f5a9 reorg(characters): ship-time folders — lena_base_v01 ships, lane moves to work/lena
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>
2026-08-12 07:17:15 -07:00

291 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# ============================================================================================
# REJECTED 2026-08-06 — DO NOT RUN. Its premise is false and running it DAMAGES the mesh.
#
# It assumes the scan-panel seams are disconnected vertex runs that need welding. They are not.
# 16c_topology.py measured the opposite: the whole mesh has only 830 boundary edges in 689 loops
# of 3-5 edges, and for kink verts the nearest vertex outside the 3-ring sits at 1.58x the local
# edge length (median) versus 1.73x for control skin — no crack network exists.
#
# The default eps of 0.0012 units is about the 1.85 mm MEAN EDGE LENGTH, so "twins" were mostly
# ordinary neighbours. Welding them collapsed real triangles and tore the mesh:
# boundary edges 830 -> 24,114
# non-manifold 1,649 -> 39,854
# The output was discarded. The lines are GEOMETRY, not topology — see 26_finish.py part A, which
# removes them with a ring-median filter.
#
# Kept only as the record of the dead end. Everything below argues confidently for a fix that
# does not work.
# ============================================================================================
# Stage 17: TRUE panel weld + hole closure, self-detected on the CURRENT mesh.
#
# blender --background --python 17_panel_weld.py -- <in.blend> <out.blend> [eps_mm_units]
#
# WHY THIS EXISTS (stage 15 already tried to weld and the lines survived)
# Stage 15 chose its merge set by mapping the RAW glb's boundary verts onto the working mesh at
# 2.5 mm. By then the sculpt + five membrane passes had moved those verts further than the
# tolerance, so most of the panel network was never selected. Measured after stage 15
# (16_diagnose): 58.7% of shading-kink verts STILL have a non-adjacent twin within 0.8 mm, i.e.
# the two sides of each seam are separate vertex runs that shade independently. A crack survives
# any amount of vertex MOVEMENT, which is why 11-14's membranes could not remove it.
#
# So this stage never consults the raw mesh. It finds the defect where it actually is:
# candidates = shading-kink verts boundary verts, grown 2 rings
# a PAIR is two candidates within eps that are NOT edge-adjacent and whose normals agree
# (dot > 0.5). The normal test is what makes this safe: two sides of one seam face the same
# way, whereas two surfaces that merely come close (inner thighs, armpit) face opposite ways
# and are never paired.
# pairs -> union-find -> bmesh.ops.weld_verts with an explicit targetmap.
# weld_verts (not remove_doubles) because the targetmap is exact: only vertices this script has
# validated get merged, so no collateral merge is possible inside the eps ball.
#
# Loops keep their own UVs through a weld, so the baked atlas is unaffected — a welded vertex
# simply carries two UV corners, which is what every UV seam already is.
#
# Then holes: the body should be watertight below the chin. The largest boundary cluster is the
# bra-bow excision at the sternum (z 0.648-0.729, 329 edges) — that hole is the black gash that
# reads as a broken cleavage. Filled with bmesh triangle_fill and smoothed by the next stage.
# Head openings (z > HEAD_Z: mouth, eyes, nostrils) are left alone: they are supposed to be open.
import bpy, bmesh, sys, time, math
import numpy as np
from mathutils import Vector
from mathutils.kdtree import KDTree
argv = sys.argv[sys.argv.index("--") + 1:]
BLEND, OUT = argv[0], argv[1]
EPS = float(argv[2]) if len(argv) > 2 else 0.0012 # mesh units (~2.2 mm real: 1 unit=1.815 m)
t0 = time.time()
KINK_DEG = 4.0 # generous: anything that could read as a line
DEV_MIN = 0.00008 # or a small-scale bump/groove this deep (mesh units)
NORM_DOT = 0.5 # same-facing test that makes the weld safe
GROW = 2
Z_LO, Z_HI = 0.04, 0.90 # below the chin, above the soles
X_MAX = 0.36 # excludes hands/wrists so fingers can never weld together
HEAD_Z = 0.90 # holes above this are real openings (mouth/eyes/nostrils)
def log(m):
print(f"[weld {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)
log(f"in: {n_v}v {len(me.polygons)}f custom_normals={me.has_custom_normals}")
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)
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):
acc = np.add.reduceat(X[n_s], ptr[:-1], axis=0)
acc[np.diff(ptr) == 0] = X[np.diff(ptr) == 0]
return acc / cnt[:, None]
# ---- candidates: shading kinks + small-scale relief + existing boundary ----
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)))
sm = co.copy()
for _ in range(12):
sm = nbr_mean(sm)
dev = np.abs(((co - sm) * N).sum(axis=1))
bm = bmesh.new()
bm.from_mesh(me)
bm.verts.ensure_lookup_table()
bnd_v = np.zeros(n_v, dtype=bool)
for e in bm.edges:
if len(e.link_faces) == 1:
bnd_v[e.verts[0].index] = True
bnd_v[e.verts[1].index] = True
n_bnd0 = len([e for e in bm.edges if len(e.link_faces) == 1])
n_nm0 = len([e for e in bm.edges if len(e.link_faces) > 2])
log(f"before: boundary_edges={n_bnd0} nonmanifold_edges={n_nm0} boundary_verts={bnd_v.sum()}")
zone = (co[:, 2] > Z_LO) & (co[:, 2] < Z_HI) & (np.abs(co[:, 0]) < X_MAX)
cand = zone & ((ang > KINK_DEG) | (dev > DEV_MIN) | bnd_v)
for _ in range(GROW):
hit = cand[ev[:, 0]] | cand[ev[:, 1]]
c2 = cand.copy()
c2[ev[:, 0]] |= hit
c2[ev[:, 1]] |= hit
cand = c2 & zone
cidx = np.nonzero(cand)[0]
log(f"candidates: {len(cidx)} verts (kink>{KINK_DEG}deg {(zone&(ang>KINK_DEG)).sum()}, "
f"dev {(zone&(dev>DEV_MIN)).sum()}, boundary {(zone&bnd_v).sum()}, +{GROW} rings)")
# adjacency lookup restricted to candidates
adj = {}
sel_mask = cand
mE = sel_mask[ev[:, 0]] & sel_mask[ev[:, 1]]
for a, b in ev[mE]:
adj.setdefault(int(a), set()).add(int(b))
adj.setdefault(int(b), set()).add(int(a))
kd = KDTree(len(cidx))
for j, i in enumerate(cidx):
kd.insert(Vector(co[i]), j)
kd.balance()
log("KD built over candidates")
# ---- pair up twins ----
pairs = []
dists = []
for j, i in enumerate(cidx):
ai = adj.get(int(i), ())
for (_, k, d) in kd.find_range(Vector(co[i]), EPS):
o = int(cidx[k])
if o <= int(i) or o in ai:
continue
if float(nrm[i] @ nrm[o]) < NORM_DOT:
continue
pairs.append((int(i), o))
dists.append(d)
log(f"pairs: {len(pairs)}")
if dists:
dh = np.array(dists)
print("PAIR-DISTANCE histogram (mesh units, 1 unit = 1.815 m):")
hist, edges = np.histogram(dh, bins=np.linspace(0, EPS, 9))
for c, lo, hi in zip(hist, edges[:-1], edges[1:]):
print(f" {lo*1000:5.3f}-{hi*1000:5.3f} mm-units: {c:7d} "
f"({lo*1815:5.2f}-{hi*1815:5.2f} real mm)")
# ---- union-find ----
parent = {}
def find(x):
parent.setdefault(x, x)
while parent[x] != x:
parent[x] = parent[parent[x]]
x = parent[x]
return x
def union(a, b):
ra, rb = find(a), find(b)
if ra != rb:
parent[min(ra, rb)] = min(ra, rb)
parent[max(ra, rb)] = min(ra, rb)
for a, b in pairs:
union(a, b)
clusters = {}
for v in list(parent):
clusters.setdefault(find(v), []).append(v)
sizes = np.array([len(c) for c in clusters.values()])
log(f"clusters: {len(clusters)} covering {int(sizes.sum())} verts "
f"(max {sizes.max() if len(sizes) else 0}, mean {sizes.mean() if len(sizes) else 0:.2f})")
# guard: a huge cluster would mean the eps ball is chaining across a whole region
if len(sizes) and sizes.max() > 40:
log(f"WARNING: largest cluster {sizes.max()} verts — chaining suspected; "
f"clusters >40 verts are SKIPPED")
targetmap = {}
merged_verts = 0
for root, members in clusters.items():
if len(members) < 2 or len(members) > 40:
continue
members = sorted(members)
keep = members[0]
ctr = co[members].mean(axis=0)
bm.verts[keep].co = Vector(ctr)
for m in members[1:]:
targetmap[bm.verts[m]] = bm.verts[keep]
merged_verts += 1
log(f"targetmap: merging {merged_verts} verts into {len(set(targetmap.values()))} survivors")
if targetmap:
bmesh.ops.weld_verts(bm, targetmap=targetmap)
log("weld_verts done")
# ---- close holes below the chin ----
bm.edges.ensure_lookup_table()
open_e = [e for e in bm.edges if len(e.link_faces) == 1]
body_e = [e for e in open_e
if max(v.co.z for v in e.verts) < HEAD_Z and min(v.co.z for v in e.verts) > Z_LO * 0.5]
log(f"open edges after weld: {len(open_e)} total, {len(body_e)} below the chin -> filling")
if body_e:
res = bmesh.ops.triangle_fill(bm, use_beauty=True, use_dissolve=False, edges=body_e)
log(f"triangle_fill created {len(res.get('geom', []))} elements")
# anything still open: try holes_fill as a second pass
bm.edges.ensure_lookup_table()
still = [e for e in bm.edges if len(e.link_faces) == 1
and max(v.co.z for v in e.verts) < HEAD_Z]
if still:
bmesh.ops.holes_fill(bm, edges=still, sides=0)
log(f"holes_fill on {len(still)} remaining open edges")
bm.edges.ensure_lookup_table()
n_bnd1 = len([e for e in bm.edges if len(e.link_faces) == 1])
n_nm1 = len([e for e in bm.edges if len(e.link_faces) > 2])
dgn = [f for f in bm.faces if f.calc_area() < 1e-12]
if dgn:
bmesh.ops.delete(bm, geom=dgn, context='FACES')
log(f"deleted {len(dgn)} degenerate faces")
bm.to_mesh(me)
bm.free()
me.update()
n_after = len(me.vertices)
log(f"after: {n_after}v (-{n_v - n_after}) boundary_edges={n_bnd1} nonmanifold_edges={n_nm1}")
# ---- shared vertex normals across the now-shared seams ----
vn = np.empty(n_after * 3, dtype=np.float32)
me.vertices.foreach_get("normal", vn)
me.normals_split_custom_set_from_vertices(vn.reshape(-1, 3))
log("custom split normals reset from vertex normals")
# ---- re-measure the lines ----
co2 = np.empty(n_after * 3)
me.vertices.foreach_get("co", co2)
co2 = co2.reshape(-1, 3)
nr2 = np.empty(n_after * 3)
me.vertices.foreach_get("normal", nr2)
nr2 = nr2.reshape(-1, 3)
ev2 = np.empty(len(me.edges) * 2, dtype=np.int32)
me.edges.foreach_get("vertices", ev2)
ev2 = ev2.reshape(-1, 2)
o2 = np.concatenate([ev2[:, 0], ev2[:, 1]])
n2 = np.concatenate([ev2[:, 1], ev2[:, 0]])
s2 = np.argsort(o2, kind="stable")
o2s, n2s = o2[s2], n2[s2]
p2 = np.searchsorted(o2s, np.arange(n_after + 1))
c2 = np.maximum(np.diff(p2), 1)
N2 = nr2.copy()
for _ in range(5):
acc = np.add.reduceat(N2[n2s], p2[:-1], axis=0)
acc[np.diff(p2) == 0] = N2[np.diff(p2) == 0]
N2 = acc / c2[:, None]
N2 /= np.maximum(np.linalg.norm(N2, axis=1, keepdims=True), 1e-12)
ang2 = np.degrees(np.arccos(np.clip((nr2 * N2).sum(axis=1), -1, 1)))
torso2 = (co2[:, 2] > 0.28) & (co2[:, 2] < 0.90)
for thr in (8.0, 12.0, 20.0):
print(f"KINK >{thr:4.1f}deg after weld: {int((torso2 & (ang2 > thr)).sum()):6d} verts")
bpy.context.preferences.filepaths.save_version = 0 # no .blend1 autosave
bpy.ops.wm.save_as_mainfile(filepath=OUT)
log(f"WROTE {OUT}")
print("WELD_DONE")