Files
animation/characters/work/lena/17_panel_weld.py
T

291 lines
11 KiB
Python
Raw Normal View History

# ============================================================================================
# 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")