docs(lena): land pending stage-07 crotch-fill recipe

`07_fill_crotch.py` and its README autopsy were written in an earlier session
and had been sitting uncommitted. Committing them unchanged, separately from
today's hand-weight work, so neither is misattributed to the other.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-18 11:31:55 -07:00
parent 4c3870336f
commit 3f335ee2d7
2 changed files with 880 additions and 13 deletions
@@ -0,0 +1,820 @@
# lena_leafbikini lane, stage 07: BARBIE-FILL the crotch — melt the briefs leaves into a
# smooth featureless surface. The bust holes stay cut open (stage 05 behaviour).
#
# blender --background --factory-startup --python 07_fill_crotch.py -- \
# <pristine.glb> <leaf_mask.npz> <out.glb> [--z-split 0.615] [--free-rings 2]
# [--puff-mm 0.0] [--blend <out.blend>]
#
# TARGET. "Barbie doll anatomy": a completely smooth, undifferentiated pelvic surface — no
# cleft, no features, a taut convex continuation of belly into inner thighs. That is exactly
# what a bi-harmonic membrane produces: solve L²x = 0 with the surrounding skin held fixed.
# The rim supplies POSITION (the "distance between the two sides"), the collar behind it
# supplies SLOPE through the second Laplacian application (the "angle"), and a bi-harmonic
# surface cannot invent detail — no crease, no cleft, by construction.
#
# WHY MELT, NOT FILL. This stage was first written as hole-filling on the stage-05 cut, and it
# failed twice, instructively:
#
# attempt 1 — triangle_fill each rim + densify + membrane. The briefs rim is ONE ~2,500-vert
# loop snaking front -> between the legs -> back; beauty triangulation of a loop that long
# and that non-convex connects the WRONG BANKS at every bend. The membrane then faithfully
# smooths garbage into rippled sheets. Bonus failure: the rim itself still carried leaf-root
# remnants, and a membrane anchors position AND slope to its rim, so it reproduced the
# crumple (the mesh-repair playbook's "collar on CLEAN skin" lesson, re-learned).
# attempt 2 — rim erosion (3 rings) + interleaved Delaunay flips + double solve. Better rims,
# same disease: flips are local, the mis-bridging is global. Long sliver strands shot off
# the hips where chords bridged front rim to back rim, and triangle_fill did not even close
# the pinched loop (2,177 faces where ~2,471 were needed; 819 boundary edges left).
#
# The fix is to stop inventing topology. THE LEAF SHELL IS the disk that spans the hole — the
# scan's own manifold surface, connected to the true rim at every point, no bank ever bridged
# wrongly. So in the crotch band the leaves are not deleted at all: their vertices are FREED,
# a --free-rings collar of surrounding skin is freed with them (this erases the under-leaf rim
# crease, the same move as the nude lane's fair_rim_band), and the membrane collapses the
# whole shell onto the smooth spanning surface. Folded flaps famously resist melting by
# ITERATIVE flow (the 06*-era lesson) — but L²x = 0 is linear with a unique solution, and PCG
# run to convergence lands on it regardless of where the folds start.
#
# THE REMNANT SWEEP. Near the melt zone the colour key is re-run without the stage-04 mask's
# blind spots (min-comp speckle filter, value gate): any fixed vertex within 6 rings of the
# primary free set that is greenish (hue 46..200, no gates) or blown-white (val >= 0.78,
# sat <= 0.25 — nothing on her actual skin is that colour) joins the melt, grown 2 rings.
# Freeing a few honest skin verts by accident is harmless — the membrane returns them almost
# in place; a pinned leaf fragment is not.
#
# THE BALLOON EXCISION. The melt's first run still left half a dozen smooth raised nubs, and a
# debug bake proved they were FREE verts — melted, converged, and still bulging. That is not a
# solver bug, it is what L²x = 0 does to a PENDANT BALLOON: a fully-masked leaf is a closed
# shell attached along its root line, its excess surface area has nowhere to go, and the
# bi-harmonic solution — smooth in GRAPH terms, with no maximum principle — parks it as a
# rounded mound. Two remedies failed before this one worked:
# * proudness detection (60-sweep, then 400-sweep Taubin reference): a smoothing-built
# reference partially FOLLOWS any bump wider than its radius, so visibly 4 mm nubs measured
# 1.7 mm and thresholds caught only their tips;
# * Laplacian deflation of what it did catch: flattened tips, kept the wave.
# What a wad cannot hide is its AREA: several layers of surface over one spot put several times
# the vertices of honest membrane into the same cell of a 4 mm grid. So: detect wads by vertex
# density, EXCISE every face touching one, and refill the scars with triangle_fill + densify +
# a small membrane solve — which is exactly the right tool at this scale (round holes a
# centimetre or two across; its failure mode was only ever the giant winding channel).
# CAVEAT the run exposed: the whole melted shell lies ~4 layers deep (cell median 37), so
# density above median finds the FLAT piles — worth excising, they would z-fight — but the
# visible INFLATED caps sit at ~2 layers, BELOW median. Density cannot see them either.
#
# THE FLOW FINISH — the step that actually guarantees a smooth result, with no detector at
# all: damped pure-Laplacian flow over the entire changed region, fixed skin held, weight
# ramping 0 -> 1 over the first 8 rings so the membrane's C1 rim blend survives. The maximum
# principle does what every detector could not promise: a raised cap has strictly nowhere to
# go but down, while the broad pubic web barely moves (flow erases features at ~1/size², and
# the web is 5-10x wider than any cap). A short Taubin polish follows to undo the slight
# overall shrink. This ordering — bi-harmonic for shape, flow for guarantees — is the recipe.
#
# TEXTURE. Melted faces keep leaf texels, so every face whose vertices are all masked gets ONE
# donor texel — an old thigh-band vertex whose albedo is closest to the band's median skin tone
# and whose normal-map texel is nearest neutral. Interpolating UVs instead is meaningless here:
# the atlas is Tripo chart soup (the sibling Lena mesh had 5,870 charts). Flat is correct —
# Barbie plastic has no albedo detail either. The baked leaf contact shadows still darken the
# surviving skin just outside the melt; that is an albedo problem for a later stage.
#
# TOPOLOGY GROUND RULES (as stages 04/05): the glTF importer splits every UV seam, so the mesh
# is welded (exact duplicates, 1e-5) before boundaries or adjacency mean anything — UVs live
# per face corner and survive the weld. Custom split normals do not survive the bmesh round
# trip; on the WELDED mesh "clear + shade smooth" is seamless, which is the second reason the
# weld comes first.
import bpy, bmesh, sys, os, time, argparse
from collections import deque
import numpy as np
def interior_edges(faces):
"""Edges whose every adjacent face is a patch face — the only ones safe to subdivide."""
return list({e for f in faces for e in f.edges
if all(lf in faces for lf in e.link_faces)})
def refresh(faces, *rets):
"""Re-collect live patch faces after a bmesh op invalidated / created some."""
out = {f for f in faces if f.is_valid}
for ret in rets:
for key in ("geom", "geom_inner", "faces"):
for g in ret.get(key, ()):
if isinstance(g, bmesh.types.BMFace) and g.is_valid:
out.add(g)
return out
argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else []
ap = argparse.ArgumentParser()
ap.add_argument("glb")
ap.add_argument("mask")
ap.add_argument("out")
ap.add_argument("--z-split", type=float, default=0.615,
help="fraction of body height separating briefs (melted) from bust (cut open); "
"briefs mask tops out at 0.594, bust starts at 0.631")
ap.add_argument("--free-rings", type=int, default=2,
help="rings of surrounding skin freed with the leaves, to erase the rim crease")
ap.add_argument("--puff-mm", type=float, default=0.0,
help="optional outward dome on top of the membrane, peak amplitude in mm")
ap.add_argument("--blend", default="")
A = ap.parse_args(argv)
GLB, MASK, OUT = os.path.abspath(A.glb), os.path.abspath(A.mask), os.path.abspath(A.out)
os.makedirs(os.path.dirname(OUT), exist_ok=True)
t0 = time.time()
def log(m):
print(f"[melt {time.time()-t0:6.1f}s] {m}", flush=True)
def smoothstep(x):
x = np.clip(x, 0.0, 1.0)
return x * x * (3.0 - 2.0 * x)
# =============================================================================================
# load original + mask, split the mask at the waist
# =============================================================================================
bpy.ops.wm.read_factory_settings(use_empty=True)
bpy.ops.import_scene.gltf(filepath=GLB)
body = max([o for o in bpy.data.objects if o.type == 'MESH'], key=lambda o: len(o.data.vertices))
me = body.data
bpy.context.view_layer.objects.active = body
body.select_set(True)
n0 = len(me.vertices)
log(f"in : '{body.name}' {n0}v {len(me.polygons)}f")
z = np.load(MASK)
inv, mk = z["inv"].astype(np.int64), z["mask"]
if len(inv) != n0:
raise SystemExit(f"[melt] FATAL: mask was built for {len(inv)} verts, this GLB has {n0}")
vm = mk[inv] # per raw vertex: is it leaf?
co = np.empty(n0 * 3); me.vertices.foreach_get("co", co); P0 = co.reshape(-1, 3)
Z0, H = float(P0[:, 2].min()), float(P0[:, 2].max() - P0[:, 2].min())
MM = 1000.0 * 1.777 / H
zf0 = (P0[:, 2] - Z0) / H
crotch = vm & (zf0 <= A.z_split)
bust = vm & (zf0 > A.z_split)
log(f"mask: {vm.sum()} leaf verts -> {crotch.sum()} briefs (melt), {bust.sum()} bust (cut)")
# per-vertex albedo HSV for the remnant sweep — sampled NOW, on the raw import, because UV
# indexing goes stale the moment bmesh touches the topology
base_img = None
for mat in [m_ for m_ in me.materials if m_]:
bsdf = next((x for x in mat.node_tree.nodes if x.type == 'BSDF_PRINCIPLED'), None)
lnk = bsdf and bsdf.inputs["Base Color"].links
if lnk:
nd = lnk[0].from_node
while nd.type != 'TEX_IMAGE':
up = [i for i in nd.inputs if i.links]
if not up:
break
nd = up[0].links[0].from_node
if nd.type == 'TEX_IMAGE':
base_img = nd.image
if base_img is None:
raise SystemExit("[melt] FATAL: no base-colour image")
nl0 = len(me.loops)
lv0 = np.empty(nl0, dtype=np.int32); me.loops.foreach_get("vertex_index", lv0)
uv0 = np.empty(nl0 * 2); me.uv_layers.active.data.foreach_get("uv", uv0); uv0 = uv0.reshape(-1, 2)
vuv0 = np.zeros((n0, 2)); vuv0[lv0[::-1]] = uv0[::-1]
w_, h_ = base_img.size
buf = np.empty(w_ * h_ * 4, dtype=np.float32); base_img.pixels.foreach_get(buf)
px = buf.reshape(h_, w_, 4)[:, :, :3]; del buf
xi = np.clip((vuv0[:, 0] * (w_ - 1)).astype(np.int64), 0, w_ - 1)
yi = np.clip((vuv0[:, 1] * (h_ - 1)).astype(np.int64), 0, h_ - 1)
C = px[yi, xi].astype(np.float64); del px
S = np.clip(np.where(C <= 0.0031308, C * 12.92, 1.055 * np.maximum(C, 0) ** (1 / 2.4) - 0.055), 0, 1)
R, G, B = S[:, 0], S[:, 1], S[:, 2]
mx = S.max(1); mn = S.min(1); dd = mx - mn
hue = np.zeros(n0)
nz = dd > 1e-6
im = np.argmax(S, axis=1)
sel = nz & (im == 0); hue[sel] = 60 * (((G[sel] - B[sel]) / dd[sel]) % 6)
sel = nz & (im == 1); hue[sel] = 60 * ((B[sel] - R[sel]) / dd[sel] + 2)
sel = nz & (im == 2); hue[sel] = 60 * ((R[sel] - G[sel]) / dd[sel] + 4)
sat = np.where(mx > 1e-6, dd / np.maximum(mx, 1e-6), 0.0)
# ride the mask + HSV through the weld as attributes (per-vertex custom data survives
# remove_doubles on the surviving vertex of each duplicate cluster)
for name, arr in (("melt_m", crotch), ("bust_m", bust)):
at = me.attributes.new(name=name, type='INT', domain='POINT')
at.data.foreach_set("value", arr.astype(np.int32))
for name, arr in (("hsv_h", hue), ("hsv_s", sat), ("hsv_v", mx)):
at = me.attributes.new(name=name, type='FLOAT', domain='POINT')
at.data.foreach_set("value", arr.astype(np.float32))
# =============================================================================================
# weld, cut the bust open, free the briefs
# =============================================================================================
bm = bmesh.new()
bm.from_mesh(me)
bmesh.ops.remove_doubles(bm, verts=list(bm.verts), dist=1e-5)
bm.verts.ensure_lookup_table()
lm = bm.verts.layers.int["melt_m"]
lb = bm.verts.layers.int["bust_m"]
log(f"welded: {len(bm.verts)}v")
# bust: delete fully-masked faces (stage 05 cut rule), then despike the new rim
kill = [f for f in bm.faces if all(v[lb] for v in f.verts)]
bmesh.ops.delete(bm, geom=kill, context='FACES')
log(f"bust cut: -{len(kill)} faces")
for it in range(4):
spikes = [f for f in bm.faces
if sum(1 for e in f.edges if len(e.link_faces) == 1) >= 2
and (sum(v.co.z for v in f.verts) / len(f.verts) - Z0) / H > A.z_split]
if not spikes:
break
bmesh.ops.delete(bm, geom=spikes, context='FACES')
log(f"bust despike pass {it+1}: -{len(spikes)} dangling faces")
loose = [v for v in bm.verts if not v.link_faces]
if loose:
bmesh.ops.delete(bm, geom=loose, context='VERTS')
# briefs: free = leaf verts + a skin collar, grown over true (welded) adjacency
free_set = {v for v in bm.verts if v[lm]}
for _ in range(A.free_rings):
free_set |= {o for v in free_set for e in v.link_edges for o in e.verts}
log(f"melt set: {len(free_set)} free verts (leaves + {A.free_rings}-ring skin collar)")
# the remnant sweep (see header): re-key the fixed verts near the melt without the mask's
# speckle filter or value gate, so missed leaf fragments melt too instead of pinning welts
lh = bm.verts.layers.float["hsv_h"]
lsat = bm.verts.layers.float["hsv_s"]
lval = bm.verts.layers.float["hsv_v"]
near = set(free_set)
for _ in range(6):
near |= {o for v in near for e in v.link_edges for o in e.verts}
adds = {v for v in near - free_set
if (v.co.z - Z0) / H <= A.z_split + 0.01
and ((46.0 <= v[lh] <= 200.0) or (v[lval] >= 0.78 and v[lsat] <= 0.25))}
grown = set(adds)
for _ in range(2):
grown |= {o for v in grown for e in v.link_edges for o in e.verts}
free_set |= grown
log(f"remnant sweep: +{len(adds)} keyed (+{len(grown - adds)} ring growth) "
f"-> {len(free_set)} free verts")
# mark free verts and melted-face texels via flags that survive to_mesh
for v in bm.verts:
v.select_set(False)
for v in free_set:
v.select_set(True)
texel = {v for v in bm.verts if v[lm]} | grown # leaf faces + swept remnants, not the collar
for f in bm.faces:
f.select_set(all(v in texel for v in f.verts))
bm.to_mesh(me)
bm.free()
me.update()
n = len(me.vertices)
log(f"topology done: {n}v {len(me.polygons)}f")
# =============================================================================================
# the melt: matrix-free PCG on L²x = 0, everything but the briefs held fixed
# =============================================================================================
co = np.empty(n * 3); me.vertices.foreach_get("co", co); P = co.reshape(-1, 3).copy()
P_orig = P.copy() # for the fixed-verts gate at the end
vsel = np.empty(n, dtype=bool); me.vertices.foreach_get("select", vsel)
free = vsel.copy()
log(f"free verts: {free.sum()}")
ev = np.empty(len(me.edges) * 2, dtype=np.int32); me.edges.foreach_get("vertices", ev)
ea, eb = ev[0::2].astype(np.int64), ev[1::2].astype(np.int64)
zfw = (P[:, 2] - Z0) / H # fixed verts never move, so this stays valid
def grow_np(mask, rings):
out = mask.copy()
for _ in range(rings):
hit = np.zeros(n, dtype=bool)
m = out[ea] | out[eb]
hit[ea[m]] = True
hit[eb[m]] = True
out |= hit
return out
# full-mesh adjacency in CSR form, built once — the solver restricts it per pass
fsrc = np.concatenate([ea, eb]); fdst = np.concatenate([eb, ea])
fo = np.argsort(fsrc, kind='stable'); fsrc, fdst = fsrc[fo], fdst[fo]
fdeg = np.bincount(fsrc, minlength=n).astype(np.float64)
fptr = np.concatenate([[0], np.cumsum(fdeg)]).astype(np.int64)
def solve_membrane(free_mask, tag, warm_harmonic):
"""Bi-harmonic solve for the free verts; reads and writes me's positions in place.
Returns (ridx, Fl, deg, ptr, dst) so the puff step can reuse the last pass's graph."""
co_ = np.empty(n * 3); me.vertices.foreach_get("co", co_); Pv = co_.reshape(-1, 3).copy()
region = grow_np(free_mask, 3) # ring1 enters L, ring2 enters L², ring3 margin
ridx = np.nonzero(region)[0]
loc = np.full(n, -1, dtype=np.int64)
loc[ridx] = np.arange(len(ridx))
m = region[ea] & region[eb]
ra, rb = loc[ea[m]], loc[eb[m]]
src = np.concatenate([ra, rb]); dst = np.concatenate([rb, ra])
o = np.argsort(src, kind='stable'); src, dst = src[o], dst[o]
deg = np.bincount(src, minlength=len(ridx)).astype(np.float64)
ptr = np.concatenate([[0], np.cumsum(deg)]).astype(np.int64)
Fl = free_mask[ridx]
log(f"solve[{tag}]: {len(ridx)} region verts ({Fl.sum()} free), {len(src)//2} edges")
def Lap(Xv):
s = np.add.reduceat(Xv[dst], ptr[:-1], axis=0)
s[deg == 0] = 0.0
return s - deg[:, None] * Xv
X = Pv[ridx].copy()
def T_free(XF):
Y = np.zeros_like(X)
Y[Fl] = XF
return Lap(Lap(Y))[Fl]
if warm_harmonic:
# Lx = 0 converges in ~diameter sweeps and lands within a crease of the bi-harmonic
# answer, cutting the expensive solve's iterations roughly in half. Only worth it when
# starting from the raw leaf shell — a re-solve already sits near the answer.
Y = X.copy()
for it in range(3000):
d = Lap(Y)
Y[Fl] += 0.9 / np.maximum(deg[Fl], 1.0)[:, None] * d[Fl]
if it % 500 == 499 and float(np.abs(d[Fl]).max()) * MM < 1e-4:
break
X[Fl] = Y[Fl]
log(f" harmonic warm start: {it+1} sweeps")
r = -Lap(Lap(X))[Fl]
Mjac = (deg[Fl] ** 2 + deg[Fl])[:, None] # diag(L²) = deg² + deg
zv = r / Mjac
p = zv.copy()
rz = float((r * zv).sum())
b0 = float(np.linalg.norm(-Lap(Lap(np.where(Fl[:, None], 0.0, X)))[Fl])) + 1e-30
xF = X[Fl].copy()
rn = float(np.linalg.norm(r))
for it in range(20000):
Ap = T_free(p)
alpha = rz / (float((p * Ap).sum()) + 1e-300)
xF += alpha * p
r -= alpha * Ap
rn = float(np.linalg.norm(r))
if rn / b0 < 3e-7:
break
zv = r / Mjac
rz2 = float((r * zv).sum())
p = zv + (rz2 / rz) * p
rz = rz2
if it % 2000 == 1999:
log(f" PCG iter {it+1}: residual {rn/b0:.2e}")
X[Fl] = xF
log(f"solve[{tag}]: {it+1} iters (residual {rn/b0:.2e}), "
f"max move {np.linalg.norm(X[Fl]-Pv[ridx][Fl],axis=1).max()*MM:.1f} mm")
Pv[ridx] = X
me.vertices.foreach_set("co", Pv.ravel())
me.update()
return ridx, Fl, deg, ptr, dst
ridx, Fl, deg, ptr, dst = solve_membrane(free, "melt", warm_harmonic=True)
# THE SOLID-WELT PASS. Some leaf roots are not shells at all — they are SOLID ridges sculpted
# into the body surface and painted in skin tones, which is why every shell-hunting detector
# (density, occlusion, fin normals) returned almost nothing while four caps sat in plain view.
# A solid bump is honest single surface, so the ORIGINAL remedy is the right one: free it and
# let the membrane pull it down — no excess area, no pendant balloon. Detector exactly as the
# debug probe validated it: 400-sweep Taubin reference over the WHOLE band (everything moves,
# so there is no anchored-strip chord and no frozen-zone blindness), proud along the normal
# > 0.8 mm, fixed verts only. At that reference the smooth melt web reads ~0.2 mm (p95) and
# the caps read 1-2.4 mm.
def full_nbmean(Xv):
s = np.add.reduceat(Xv[fdst], fptr[:-1], axis=0)
s[fdeg == 0] = Xv[fdeg == 0]
return s / np.maximum(fdeg, 1.0)[:, None]
mvW = (zfw >= 0.40) & (zfw <= A.z_split + 0.02)
for wpass in range(2): # re-reference and re-detect: the first fix
cow = np.empty(n * 3) # exposes whatever its 3-ring growth missed
me.vertices.foreach_get("co", cow)
Pw = cow.reshape(-1, 3).copy()
Qw = Pw.copy()
for _ in range(400):
Qw[mvW] += 0.50 * (full_nbmean(Qw) - Qw)[mvW]
Qw[mvW] += -0.53 * (full_nbmean(Qw) - Qw)[mvW]
nrw = np.empty(n * 3); me.vertices.foreach_get("normal", nrw)
proudW = np.einsum('ij,ij->i', Pw - Qw, nrw.reshape(-1, 3)) * MM
# 0.5 mm, not 0.8: the caps are ~1.8 mm PLATEAUS with sharp edges (the hard shadows in
# clay renders oversell their height), and 0.8 clipped 220 crown verts while the body of
# each plateau survived. Honest skin reads p95 +0.13 mm against this reference — 0.5 mm
# is still 4x above the noise floor.
weltS = ~free & mvW & (proudW > 0.5)
log(f"solid-welt pass {wpass+1}: {weltS.sum()} proud fixed verts "
f"(band fixed p95 {np.percentile(proudW[~free & mvW], 95):+.2f} mm, "
f"max {proudW[~free & mvW].max():+.2f} mm)")
if not weltS.sum():
break
weltG = grow_np(weltS, 3) & ~free
free |= weltG
solve_membrane(weltG, f"weltfix{wpass+1}", warm_harmonic=False)
# their paint is leaf-root shadow, not skin: hand their faces to the donor texel too
fselW = np.empty(len(me.polygons), dtype=bool); me.polygons.foreach_get("select", fselW)
lvW = np.empty(len(me.loops), dtype=np.int32); me.loops.foreach_get("vertex_index", lvW)
lsW = np.empty(len(me.polygons), dtype=np.int32); me.polygons.foreach_get("loop_start", lsW)
ltW = np.empty(len(me.polygons), dtype=np.int32); me.polygons.foreach_get("loop_total", ltW)
allin = np.add.reduceat(weltG[lvW].astype(np.int32), lsW.astype(np.int64)) == ltW
me.polygons.foreach_set("select", fselW | allin)
log(f"solid-welt pass {wpass+1}: freed {weltG.sum()} verts, "
f"{int(allin.sum())} faces to donor texel")
# THE BALLOON DEFLATION (see header). Wide reference: 400 Taubin sweeps over the melt zone —
# diffusion radius ~sqrt(400) = 20 rings (~26 mm here), wide enough that a centimetre nub reads
# as fully proud instead of being absorbed into its own reference.
def full_nbmean(Xv):
s = np.add.reduceat(Xv[fdst], fptr[:-1], axis=0)
s[fdeg == 0] = Xv[fdeg == 0]
return s / np.maximum(fdeg, 1.0)[:, None]
co2 = np.empty(n * 3); me.vertices.foreach_get("co", co2); Pm = co2.reshape(-1, 3).copy()
# the reference zone must extend PAST anything the detectors are asked to judge: outside `mv`
# the smoothed copy equals the input and proudness is identically zero by construction — a
# 4-ring halo silently blinded the welt detector to caps sitting 5+ rings out
mv = grow_np(free, 30) & (zfw <= A.z_split + 0.03)
# Detection is by DENSITY, not proudness: a smoothing-built reference partially follows any
# bump wider than its radius (a 400-sweep probe read the visibly 4 mm nubs at 1.7 mm), but a
# wad cannot hide its area — multiple layers over one spot of surface put several times the
# verts of honest membrane into the same cell of a 3D grid.
CELL = 4.0 / MM
key3 = np.floor(Pm[free] / CELL).astype(np.int64)
_, cinv, ccnt = np.unique(key3, axis=0, return_inverse=True, return_counts=True)
per_vert_cnt = ccnt[cinv]
med_cnt = float(np.median(per_vert_cnt))
hot_thr = max(3.0 * med_cnt, 18.0)
balloon = np.zeros(n, dtype=bool)
balloon[np.nonzero(free)[0][per_vert_cnt > hot_thr]] = True
balloon = grow_np(balloon, 1)
log(f"balloon pass: cell median {med_cnt:.0f} verts, threshold {hot_thr:.0f} -> "
f"{balloon.sum()} wad verts to excise")
# THE FOLD DETECTOR — for the rim-attached flaps density cannot see (~2 layers, BELOW the
# piled median) and the flow finish cannot reach (they live in the rim-damped zone). A folded
# flap betrays itself by its NORMALS: its flanks and underside disagree with the smoothed
# reference field by 80-180 degrees, which honest skin never does — even the walls of a deep
# concave crease stay within ~70 degrees of a 150-sweep reference. Restricted to FREE verts,
# so fixed anatomy can never be excised no matter how it folds.
ltF = np.empty(len(me.polygons), dtype=np.int32); me.polygons.foreach_get("loop_total", ltF)
if (ltF == 3).all():
lvF = np.empty(len(me.loops), dtype=np.int32); me.loops.foreach_get("vertex_index", lvF)
lsF = np.empty(len(me.polygons), dtype=np.int32); me.polygons.foreach_get("loop_start", lsF)
def vnormals(Pts):
va, vb, vc = lvF[lsF], lvF[lsF + 1], lvF[lsF + 2]
fn = np.cross(Pts[vb] - Pts[va], Pts[vc] - Pts[va])
acc = np.zeros_like(Pts)
for idx in (va, vb, vc):
np.add.at(acc, idx, fn)
return acc / np.maximum(np.linalg.norm(acc, axis=1, keepdims=True), 1e-12)
Qf = Pm.copy()
for _ in range(400):
Qf[mv] += 0.50 * (full_nbmean(Qf) - Qf)[mv]
Qf[mv] += -0.53 * (full_nbmean(Qf) - Qf)[mv]
ncur = vnormals(Pm)
dotn = np.einsum('ij,ij->i', ncur, vnormals(Qf))
fold = free & (dotn < 0.15)
log(f"fold pass: {fold.sum()} inverted-normal verts "
f"(free dot p05 {np.percentile(dotn[free], 5):+.2f})")
balloon |= grow_np(fold, 1)
# THE FIXED-WELT PASS — by RAY-CAST OCCLUSION, the playbook's own move, after every
# reference-surface detector failed for a structural reason worth recording:
# * proudness vs a Taubin reference: Taubin is shape-PRESERVING — a 1.5 cm cap sits
# inside its passband, so the reference reproduces the cap and P-Q reads ~0 forever;
# * proudness at all: a FIN's wall normals are perpendicular to its height, the dot
# is ~0 no matter how far it sticks out;
# * diffusion references: run wide they chord across convex anatomy (+1.9 mm on honest
# hips), run narrow they cannot see the cap tops standing 15+ rings out.
# A flap needs no reference: it stands OVER surface, so a short ray cast INWARD from it
# hits geometry within millimetres — its own opposite wall (fins are 1-2 mm thick) or the
# web below — while honest skin's inward ray travels centimetres of flesh before exiting.
# The gluteal crease is safe by construction: its walls' inward rays point into the flesh,
# AWAY from each other; only a +n ray could cross the crease gap, and none is cast.
import mathutils
deps = bpy.context.evaluated_depsgraph_get()
bvh = mathutils.bvhtree.BVHTree.FromObject(body, deps)
nearF = grow_np(free, 20) & ~free & (zfw >= 0.40) & (zfw <= A.z_split + 0.01)
EPS, DMAX = 0.4 / MM, 4.5 / MM
widx = []
for vi in np.nonzero(nearF)[0]:
p, nv = Pm[vi], ncur[vi]
o = mathutils.Vector((p[0] - nv[0] * EPS, p[1] - nv[1] * EPS, p[2] - nv[2] * EPS))
if bvh.ray_cast(o, mathutils.Vector((-nv[0], -nv[1], -nv[2])), DMAX)[0] is not None:
widx.append(int(vi))
welt = np.zeros(n, dtype=bool)
welt[widx] = True
log(f"fixed-welt pass: {welt.sum()} occluded (flap) verts of {nearF.sum()} candidates")
balloon |= grow_np(welt, 1)
else:
log("fold pass: SKIPPED (non-triangle faces present)")
if balloon.sum():
# excise every face touching a wad vert (kills whole balloons, leaves no orphan shells),
# tidy the scar, and refill: at this scale — round holes a centimetre or two across —
# triangle_fill is exactly the right tool; its failure mode was the giant winding channel
bm = bmesh.new(); bm.from_mesh(me)
bm.verts.ensure_lookup_table()
bidx = set(np.nonzero(balloon)[0].tolist())
kill = [f for f in bm.faces if any(v.index in bidx for v in f.verts)]
bmesh.ops.delete(bm, geom=kill, context='FACES')
for it in range(4):
spikes = [f for f in bm.faces
if sum(1 for e in f.edges if len(e.link_faces) == 1) >= 2
and (sum(v.co.z for v in f.verts) / len(f.verts) - Z0) / H <= A.z_split]
if not spikes:
break
bmesh.ops.delete(bm, geom=spikes, context='FACES')
loose = [v for v in bm.verts if not v.link_faces]
if loose:
bmesh.ops.delete(bm, geom=loose, context='VERTS')
log(f"excision: -{len(kill)} wad faces (+{len(loose)} loose verts)")
# zip the pre-existing Tripo slits where they meet the scars (nude lane, close_rim_slits):
# a scar boundary that runs into a slit is a RIBBON, not a closed loop, and both fill
# operators refuse it — these were the 10 holes that survived three sweeps untouched
sl = [v for v in bm.verts
if Z0 + 0.40 * H <= v.co.z <= Z0 + (A.z_split + 0.01) * H
and any(len(e.link_faces) == 1 for e in v.link_edges)]
v0 = len(bm.verts)
bmesh.ops.remove_doubles(bm, verts=sl, dist=0.8 / MM)
bm.verts.ensure_lookup_table()
log(f"slit weld: {len(sl)} band boundary verts, {v0 - len(bm.verts)} merged at 0.8 mm")
# the weld leaves zero-area faces and zero-length edges; feeding those to triangle_fill
# took Blender down with an access violation, not an exception — clean them first
dg = [e for e in bm.edges
if Z0 + 0.38 * H <= (e.verts[0].co.z + e.verts[1].co.z) / 2 <= Z0 + 0.64 * H]
bmesh.ops.dissolve_degenerate(bm, dist=1e-5, edges=dg)
bm.verts.ensure_lookup_table()
log(f"degenerate dissolve: {len(bm.verts)}v {len(bm.faces)}f")
pre2 = {(round(v.co.x, 6), round(v.co.y, 6), round(v.co.z, 6)) for v in bm.verts}
patch_faces = set()
for sweep in range(3): # re-detect after filling: one triangle_fill
bedges2 = [e for e in bm.edges # failing silently must not leave a pinhole
if len(e.link_faces) == 1
and Z0 + 0.42 * H <= (e.verts[0].co.z + e.verts[1].co.z) / 2
<= Z0 + A.z_split * H]
v2b2 = {}
for e in bedges2:
for v in e.verts:
v2b2.setdefault(v, []).append(e)
seen2, scars = set(), []
for e0 in bedges2:
if e0 in seen2:
continue
comp, q = [], deque([e0])
seen2.add(e0)
while q:
e = q.popleft()
comp.append(e)
for v in e.verts:
for e2 in v2b2[v]:
if e2 not in seen2:
seen2.add(e2)
q.append(e2)
if len(comp) >= 3:
scars.append(comp)
if not scars:
break
log(f"scar sweep {sweep+1}: {len(scars)} holes "
f"(sizes {sorted(len(c) for c in scars)[::-1][:10]})")
sweep_faces = set() # densify THIS sweep's fills only — letting a
for comp in scars: # later sweep's sliver scars re-densify earlier
if len(comp) > 900: # patches to their microscopic target once
log(f" REFUSING {len(comp)}-edge boundary tangle (fill would crash/garble)")
continue
live = [e for e in comp if e.is_valid]
try: # (3.3M-face / 17 h lesson)
ret = bmesh.ops.triangle_fill(bm, use_beauty=True, use_dissolve=False,
edges=live)
newf = [g for g in ret["geom"] if isinstance(g, bmesh.types.BMFace)]
except Exception:
newf = []
if not newf:
try:
ret = bmesh.ops.holes_fill(bm, edges=live, sides=0)
newf = list(ret["faces"])
except Exception:
newf = []
sweep_faces.update(newf)
tgt = max(1.5 * float(np.median([e.calc_length() for c in scars for e in c])),
2.4 / MM) # floored at 2.4 mm: sliver rims must not set it
for it in range(5):
sweep_faces = {f for f in sweep_faces if f.is_valid}
if len(sweep_faces) > 120000:
log(f" densify CAPPED at {len(sweep_faces)} faces")
break
longe = [e for e in interior_edges(sweep_faces) if e.calc_length() > 1.45 * tgt]
if not longe:
break
r1 = bmesh.ops.subdivide_edges(bm, edges=longe, cuts=1, use_grid_fill=True)
sweep_faces = refresh(sweep_faces, r1)
r2 = bmesh.ops.triangulate(bm, faces=list(sweep_faces))
sweep_faces = refresh(set(), r2)
r3 = bmesh.ops.beautify_fill(bm, faces=list(sweep_faces),
edges=interior_edges(sweep_faces))
sweep_faces = refresh(sweep_faces, r3)
patch_faces = {f for f in patch_faces if f.is_valid} | sweep_faces
log(f"scar fill: {len(patch_faces)} patch faces")
for f in patch_faces: # patches join the donor-texel set
if f.is_valid:
f.select_set(True)
bm.to_mesh(me)
bm.free()
me.update()
# topology changed: rebuild the globals the solver reads, then relax the patches
n = len(me.vertices)
ev = np.empty(len(me.edges) * 2, dtype=np.int32); me.edges.foreach_get("vertices", ev)
ea, eb = ev[0::2].astype(np.int64), ev[1::2].astype(np.int64)
co2 = np.empty(n * 3); me.vertices.foreach_get("co", co2)
Pn = co2.reshape(-1, 3)
patch_free = np.array([tuple(k) not in pre2 for k in np.round(Pn, 6)], dtype=bool)
log(f"patch verts: {patch_free.sum()}")
if patch_free.sum():
solve_membrane(patch_free, "patch", warm_harmonic=False)
co3 = np.empty(n * 3); me.vertices.foreach_get("co", co3); Pout = co3.reshape(-1, 3).copy()
# every vertex is now either at a pristine post-cut position or it is part of the melt/patch;
# `changed` is the union of moved and newly created — the gate + donor selection key off it
orig_keys = {tuple(k) for k in np.round(P_orig, 6)}
changed = np.array([tuple(k) not in orig_keys for k in np.round(Pout, 6)], dtype=bool)
free = changed
# THE FLOW FINISH. Whatever pendant caps survived every detector above die here, and nothing
# has to find them first: damped pure-Laplacian flow over the ENTIRE changed region, fixed skin
# held. Laplacian flow obeys the maximum principle — no point can move outside the hull of its
# neighbours — so a raised cap has strictly nowhere to go but down, while the broad pubic web
# barely moves (flow erases features at a rate ~1/size², and the web is 5-10x wider than any
# cap). The weight ramps from 0 at the fixed rim to 1 by ring 8, so the bi-harmonic C1 blend
# earned by the membrane is untouched where it matters.
fsrc = np.concatenate([ea, eb]); fdst = np.concatenate([eb, ea])
fo = np.argsort(fsrc, kind='stable'); fsrc, fdst = fsrc[fo], fdst[fo]
fdeg = np.bincount(fsrc, minlength=n).astype(np.float64)
fptr = np.concatenate([[0], np.cumsum(fdeg)]).astype(np.int64)
def nbmean2(Xv):
s = np.add.reduceat(Xv[fdst], fptr[:-1], axis=0)
s[fdeg == 0] = Xv[fdeg == 0]
return s / np.maximum(fdeg, 1.0)[:, None]
depth = np.zeros(n)
reach = ~free
d = 0
while not reach.all() and d < 200:
d += 1
nxt = reach.copy()
hit = np.zeros(n, dtype=bool)
m2 = reach[fsrc]
hit[fdst[m2]] = True
nxt |= hit
ring = nxt & ~reach
if not ring.any():
break
depth[ring] = d
reach = nxt
# ramp over 3 rings, NOT 8. A pixel-ray probe finally identified the last "caps" as the melt
# web itself bridging taut over the inguinal hollow — and the hip-vine channel is only
# ~10-20 rings wide, so an 8-ring ramp kept essentially the whole strip in the damped zone
# and the flow never engaged exactly where the bridge needed pulling down. Three rings still
# protects the immediate C1 blend; everything past it flows.
w = smoothstep(depth / 3.0)[:, None]
log(f"flow finish: max depth {int(depth.max())} rings")
for _ in range(300):
Pout[free] += (0.55 * w[free]) * (nbmean2(Pout) - Pout)[free]
for _ in range(8): # Taubin polish: undo the slight flow shrink
Pout[free] += (0.55 * w[free]) * (nbmean2(Pout) - Pout)[free]
Pout[free] += (-0.58 * w[free]) * (nbmean2(Pout) - Pout)[free]
moved_fin = np.linalg.norm(Pout[free] - co3.reshape(-1, 3)[free], axis=1)
log(f"flow finish: moved p50 {np.percentile(moved_fin,50)*MM:.2f} mm, "
f"max {moved_fin.max()*MM:.1f} mm")
me.vertices.foreach_set("co", Pout.ravel())
me.update()
# optional Barbie dome: outward along the membrane normal, smoothstep of rim distance,
# zero value AND zero slope at the rim so the C1 blend survives
if A.puff_mm > 0:
dist = np.zeros(len(ridx))
unv = set(np.nonzero(Fl)[0].tolist())
cur = set(np.nonzero(~Fl)[0].tolist())
d = 0
while unv and cur:
d += 1
nxt = set()
for c in cur:
for j in range(int(ptr[c]), int(ptr[c + 1])):
nb = int(dst[j])
if nb in unv:
unv.discard(nb)
dist[nb] = d
nxt.add(nb)
cur = nxt
t = dist / max(dist.max(), 1.0)
nrm = np.empty(n * 3); me.vertices.foreach_get("normal", nrm); nrm = nrm.reshape(-1, 3)
Pout[ridx] += nrm[ridx] * (smoothstep(t) * (A.puff_mm / MM))[:, None] * Fl[:, None]
me.vertices.foreach_set("co", Pout.ravel())
me.update()
log(f"puff: +{A.puff_mm} mm dome over {int(dist.max())} rings")
# =============================================================================================
# texture the melt: one clean donor texel on the leaf-texel faces
# =============================================================================================
imgs = {}
for mat in [m_ for m_ in me.materials if m_]:
for nd in mat.node_tree.nodes:
if nd.type == 'TEX_IMAGE' and nd.image:
for out_ in nd.outputs:
for lnk in out_.links:
if lnk.to_socket.name == 'Base Color':
imgs['base'] = nd.image
if 'normal' in nd.image.name.lower():
imgs['normal'] = nd.image
def sample(img, uvs):
w, h = img.size
buf = np.empty(w * h * 4, dtype=np.float32)
img.pixels.foreach_get(buf)
px = buf.reshape(h, w, 4)[:, :, :3]
xi = np.clip((uvs[:, 0] * (w - 1)).astype(np.int64), 0, w - 1)
yi = np.clip((uvs[:, 1] * (h - 1)).astype(np.int64), 0, h - 1)
out = px[yi, xi].copy()
del buf, px
return out
nl = len(me.loops)
lv = np.empty(nl, dtype=np.int32); me.loops.foreach_get("vertex_index", lv)
uvb = np.empty(nl * 2); me.uv_layers.active.data.foreach_get("uv", uvb); uvb = uvb.reshape(-1, 2)
vuv = np.zeros((n, 2)); vuv[lv[::-1]] = uvb[::-1]
zf = (Pout[:, 2] - Z0) / H
cand = np.nonzero(~free & (zf > 0.30) & (zf < 0.42))[0][::37] # thigh band, thinned
cb = sample(imgs['base'], vuv[cand])
med = np.median(cb, axis=0)
score = np.linalg.norm(cb - med, axis=1)
if 'normal' in imgs:
cn = sample(imgs['normal'], vuv[cand])
score += 2.0 * np.linalg.norm(cn - np.array([0.5, 0.5, 1.0]), axis=1)
best = int(np.argmin(score))
donor = cand[best]
log(f"donor texel: vert {donor} zf={zf[donor]:.3f} albedo={np.round(cb[best],3)} "
f"(band median {np.round(med,3)})")
fsel = np.empty(len(me.polygons), dtype=bool); me.polygons.foreach_get("select", fsel)
ls = np.empty(len(me.polygons), dtype=np.int32); me.polygons.foreach_get("loop_start", ls)
lt = np.empty(len(me.polygons), dtype=np.int32); me.polygons.foreach_get("loop_total", lt)
duv = vuv[donor]
touched = 0
for fi in np.nonzero(fsel)[0]:
for li in range(ls[fi], ls[fi] + lt[fi]):
uvb[li] = duv
touched += 1
me.uv_layers.active.data.foreach_set("uv", uvb.ravel())
log(f"UVs: {touched} loops on {int(fsel.sum())} melted faces -> donor texel")
# =============================================================================================
# normals, gates, export
# =============================================================================================
if me.has_custom_normals:
bpy.ops.mesh.customdata_custom_splitnormals_clear()
me.polygons.foreach_set("use_smooth", np.ones(len(me.polygons), dtype=bool))
me.update()
# the excision/refill renumbers vertices, so "fixed didn't move" is asserted by position:
# every vertex is either bit-identical to a pristine post-cut position, or it is changed —
# and everything changed must live inside the crotch band
bm = bmesh.new(); bm.from_mesh(me)
band_open = sum(1 for e in bm.edges if len(e.link_faces) == 1
and (0.5 * (e.verts[0].co.z + e.verts[1].co.z) - Z0) / H <= A.z_split)
bm.free()
mz = zf[changed]
# +0.04, not +0.01: the solid-welt pass detects up to z_split+0.02 and grows 3 rings, so its
# legitimate reach is a little above the split — the gate must allow what the recipe declares
in_band_ok = bool((mz.min() >= 0.42) and (mz.max() <= A.z_split + 0.04))
log(f"gate changed geometry confined to band: z {mz.min():.3f}..{mz.max():.3f} "
f"({'PASS' if in_band_ok else 'FAIL'})")
log(f"gate crotch band boundary edges: {band_open} (pre-existing Tripo slits only)")
if not in_band_ok:
raise SystemExit("[melt] FATAL: geometry changed outside the crotch band")
if A.blend:
bpy.ops.wm.save_as_mainfile(filepath=os.path.abspath(A.blend))
log(f"WROTE {A.blend}")
for o in bpy.data.objects:
o.select_set(True)
bpy.ops.export_scene.gltf(filepath=OUT, export_format='GLB', use_selection=True,
export_yup=True, export_skins=False, export_animations=False,
export_apply=False, export_image_format='AUTO',
export_tangents=False, export_normals=True)
log(f"WROTE {OUT} ({os.path.getsize(OUT)/1e6:.2f} MB)")