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:
@@ -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)")
|
||||
@@ -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 |
|
||||
| `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 |
|
||||
| `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
|
||||
|
||||
@@ -109,18 +111,63 @@ Stage 05's gates, all reported and the first two fatal:
|
||||
| surviving shells | 1 |
|
||||
| removed geometry's extent | z 0.455–0.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 (10–20 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
|
||||
|
||||
- **The holes are not filled, by request.** Filling is a separate and harder decision:
|
||||
the crotch/gusset history in `work/lena/06*.py` is what happens when a membrane spans a
|
||||
wide footprint — it flattens the anatomy it spans, which is why the nude lane only ever
|
||||
faired a narrow rim band. Whatever fills these has to rebuild bust and crotch anatomy,
|
||||
not just span them.
|
||||
- The base-colour map still carries the leaves and their baked contact shadows. The rim of
|
||||
each hole is skin painted with leaf shadow, so it reads darker than her surrounding tone.
|
||||
- **The cut is unrigged**, because it was authored on the pristine original. It transfers
|
||||
to the shipped body 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 it directly. Cutting the shipped GLB is running stage 05 with a
|
||||
different first argument — but the result is a new ship folder, never an edit to the
|
||||
frozen one.
|
||||
- **The hip-side shelf bulges are a known consequence, not a bug to chase further.** Where
|
||||
the vine crossed the inguinal crease there was never skin underneath, and "take the
|
||||
distance and angle between the two sides" — the membrane — spans its anchors by
|
||||
construction. Any method anchored at the rims produces the same taut bridge (harmonic,
|
||||
bi-harmonic, and flow all agree; the maximum principle *protects* a two-sided span).
|
||||
Killing them means SCULPTING the crease through the strip — invented anatomy, its own
|
||||
decision, its own stage. Ten runs of detector archaeology confirmed there is nothing
|
||||
foreign left there to remove.
|
||||
- **The bust holes are not filled, by request** (v01 deliverable; v02 fills the crotch
|
||||
only). Whatever fills the bust has to rebuild breast anatomy, not just span it — see
|
||||
`tools/make_lena_nude_body.py` for how the nude lane sculpted hers procedurally.
|
||||
- The base-colour map still carries the leaves and their baked contact shadows. Around the
|
||||
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.
|
||||
|
||||
Reference in New Issue
Block a user