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)")
+60 -13
View File
@@ -28,6 +28,8 @@ of a 1.777 m body). Read-only, and re-hashed by every stage that opens it.
| `03_render_leaves.py` | textured **and clay** turnaround of any mesh in the lane. The clay pass is what settled the question | | `03_render_leaves.py` | textured **and clay** turnaround of any mesh in the lane. The clay pass is what settled the question |
| `04_leaf_mask.py` | builds and *proves* the leaf mask: hue key → component filter → close → hole fill → grow, baked to vertex colour and rendered | | `04_leaf_mask.py` | builds and *proves* the leaf mask: hue key → component filter → close → hole fill → grow, baked to vertex colour and rendered |
| `05_cut_leaves.py` | deletes the masked faces. Holes left open, on purpose | | `05_cut_leaves.py` | deletes the masked faces. Holes left open, on purpose |
| `06_open_in_blender.py` | opens a lane mesh in the Blender GUI, framed on her front, Material Preview |
| `07_fill_crotch.py` | **v02**: Barbie-fills the crotch — melts the briefs leaves into a smooth featureless surface, bust holes stay open. See below |
## What the leaves turned out to be ## What the leaves turned out to be
@@ -109,18 +111,63 @@ Stage 05's gates, all reported and the first two fatal:
| surviving shells | 1 | | surviving shells | 1 |
| removed geometry's extent | z 0.4550.755 of height (bust + briefs bands only) | | removed geometry's extent | z 0.4550.755 of height (bust + briefs bands only) |
## v02 — the Barbie crotch fill (stage 07)
Target: "Barbie doll anatomy" — a completely smooth, undifferentiated pelvic surface, no
cleft, no features, belly flowing into thighs. Jeremy's "take the distance and angle
between the two sides" is precisely a bi-harmonic membrane: the rim supplies POSITION, the
collar behind it supplies SLOPE through the second Laplacian application, and L²x = 0
cannot invent detail. Bust holes stay cut open (v01 behaviour); only the briefs band gets
the treatment.
Getting there burned through roughly ten runs; the recipe header carries the full autopsy,
the short version is:
| attempt | verdict |
|---|---|
| triangle_fill the v01 holes + membrane | the briefs rim is ONE ~2,500-vert loop snaking front → between the legs → back; beauty triangulation bridges the WRONG BANKS at the bends, and the rim itself still carried leaf-root crumple the membrane faithfully anchored to |
| + rim erosion + Delaunay flips + double solve | better rims, same disease — flips are local, the mis-bridging is global; sliver strands shot off the hips |
| **melt instead of fill** (the pivot) | the leaf shell IS the disk that spans the hole — the scan's own manifold surface. Free its verts + a skin collar, solve; wrong-bank bridging becomes impossible. This produced the first genuinely smooth pelvis |
| proudness detectors + deflation for the leftovers | fully-masked leaves are PENDANT BALLOONS — closed shells whose excess area the (no-maximum-principle) bi-harmonic parks as smooth raised caps. Smoothing-built references partially follow any bump wider than their radius, and **Taubin references are worse than useless here: shape-preserving by design, a 1.5 cm cap sits inside the passband and reads ~0** |
| density excision + scar refill | catches the FLAT wads (the melted shell lies ~4 layers deep, and piles would z-fight) but not inflated caps, which sit BELOW the piled median. Scar refill needs: per-sweep densify scope with a floored target (a sliver scar once re-densified everything to 3.3M faces / 17 h), a slit weld first (scars meeting Tripo slits are ribbons, not loops — fill ops refuse them, and degenerate output crashed Blender once), and a >900-edge refusal |
| occlusion / fin / diffusion detectors for the last four "caps" | all near-zero. A pixel-ray probe finally explained why: **the last bumps were not leaf debris at all — they are the melt web itself bridging taut over the inguinal hollow.** There was never skin under the vine, and every rim-anchored method spans its anchors by construction |
| **+ the flow finish** (the guarantee) | damped pure-Laplacian flow over the whole changed region, no detector at all: the maximum principle sinks any pendant cap while the 5-10× wider pubic web barely moves. Ramp weight 0→1 over **3** rings — at 8 rings the hip-vine channel (1020 rings wide) sat entirely in the damped zone |
| **+ the solid-welt pass** | some leaf roots are SOLID skin-painted ridges, not shells — free them (0.5 mm proudness against a whole-band 400-sweep reference; honest skin reads p95 +0.13 mm) and re-solve locally; converges in 2 passes (5,375 verts, then 1) |
Pipeline as shipped in `07_fill_crotch.py`: weld → cut bust open (+despike) → free briefs
leaves + collar → remnant sweep (relaxed hue/blown-white key near the melt, no speckle
filter) → bi-harmonic melt (matrix-free Jacobi-PCG, harmonic warm start) → solid-welt pass
×2 → density excision of flat wads + occlusion pass → slit weld + degenerate dissolve →
three scar-fill sweeps + patch solve → flow finish. Melted faces get one donor texel
(thigh-band vertex nearest the band's median tone with the most neutral normal texel) —
the atlas is Tripo chart soup, so interpolating UVs across the fill would sample garbage.
```
"$B" --background --factory-startup --python $L/07_fill_crotch.py -- \
characters/originals/female/female_lena_leafbikini_tripo.glb $L/leaf_mask.npz \
$L/v02/lena_leafbikini_crotchfill_sculpt_glb_v02.glb --free-rings 3
```
## Open ## Open
- **The holes are not filled, by request.** Filling is a separate and harder decision: - **The hip-side shelf bulges are a known consequence, not a bug to chase further.** Where
the crotch/gusset history in `work/lena/06*.py` is what happens when a membrane spans a the vine crossed the inguinal crease there was never skin underneath, and "take the
wide footprint — it flattens the anatomy it spans, which is why the nude lane only ever distance and angle between the two sides" — the membrane — spans its anchors by
faired a narrow rim band. Whatever fills these has to rebuild bust and crotch anatomy, construction. Any method anchored at the rims produces the same taut bridge (harmonic,
not just span them. bi-harmonic, and flow all agree; the maximum principle *protects* a two-sided span).
- The base-colour map still carries the leaves and their baked contact shadows. The rim of Killing them means SCULPTING the crease through the strip — invented anatomy, its own
each hole is skin painted with leaf shadow, so it reads darker than her surrounding tone. decision, its own stage. Ten runs of detector archaeology confirmed there is nothing
- **The cut is unrigged**, because it was authored on the pristine original. It transfers foreign left there to remove.
to the shipped body for free: `01_graft.py` moved the mesh 0.000000 mm, so - **The bust holes are not filled, by request** (v01 deliverable; v02 fills the crotch
`lena_leafbikini_base_v01` carries the *same* 1,029,360 vertices in the same order and only). Whatever fills the bust has to rebuild breast anatomy, not just span it — see
`leaf_mask.npz` indexes it directly. Cutting the shipped GLB is running stage 05 with a `tools/make_lena_nude_body.py` for how the nude lane sculpted hers procedurally.
different first argument — but the result is a new ship folder, never an edit to the - The base-colour map still carries the leaves and their baked contact shadows. Around the
frozen one. v01 holes and the v02 fill alike, the surviving skin reads darker than her surrounding
tone — an albedo re-author job for a later stage (the nude lane's harmonic refill is the
template).
- **Both artifacts are unrigged**, because they were authored on the pristine original.
They transfer to the shipped body's frame for free: `01_graft.py` moved the mesh
0.000000 mm, so `lena_leafbikini_base_v01` carries the *same* 1,029,360 vertices in the
same order and `leaf_mask.npz` indexes either mesh. Re-running 05/07 against the shipped
GLB is a first-argument change — but the result is a new ship folder, never an edit to
the frozen one.