Files
jeremy 3d8825f5a9 reorg(characters): ship-time folders — lena_base_v01 ships, lane moves to work/lena
REGISTRY rewritten around the central rule: a character folder is born only
when a body ships to ariki-game (<character>_base_v<NN> = ship ordinal).
lena_nude dissolves accordingly:
- characters/female/lena_base_v01/ — SHIPPED 2026-08-10: AccuRig GLB carrier,
  T-pose/rig FBX + JSON, previews, frozen README
- characters/work/lena/ — the live lane: recipes 01-47 (incl. new 36-47:
  refill/sheets/clay/despeckle/musculature/spin/AccuRig export/graft/pose QC),
  masters (athletic_v04 blend + textures, accurig blend), lane-history README
- hires_claude/hires_work intermediates (blends, logs, probes) pruned

Supporting docs: AGENTS.md, working-files rule, rig-graft plan addendum,
originals README, prune_lane.py.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-12 07:17:15 -07:00

613 lines
24 KiB
Python

# Stage 3 (v3): take the top off and sculpt the breasts, at full 954k-vert density.
#
# blender --background --python 03_sculpt.py -- <00_welded.blend> <masks.npz>
# <out.blend> [amp_target=0.034]
#
# METHOD — and the two failure modes v3 exists to kill:
#
# * v1/v2 read the replacement surface off the faired proxy with BVH find_nearest. A 19k proxy
# is FACETED (~7 mm triangles): nearest-point positions are piecewise planar and the face
# normals jump at every proxy edge, so the lifted surface imprinted the proxy tessellation
# onto 954k verts — the "crust" in the renders was the proxy's facets, not fabric.
# v3 never touches proxy faces: the complete TARGET surface (wall + zone field) is computed
# per proxy VERTEX and lifted by inverse-distance blending over the 6 nearest proxy verts —
# continuous by construction — followed by a short Taubin polish at full density.
#
# * The shoulder straps' paint is nearly skin-coloured (median sat 0.57-0.61 vs 0.39 on the bra
# body), so no colour threshold can own them. v3 catches them GEOMETRICALLY: the proxy wall
# is also solved under a shoulder corridor, and any full-density vert there standing
# > 2.5 mm proud of the wall is fabric — paint is irrelevant.
#
# Zone targets: cups/shield -> wall + AMPLIFIED mound field (her own under-bra anatomy,
# recovered as surface-minus-wall on the proxy where 1 mm weave cannot exist) with the cleavage
# valley carved where the bra bridged it; rest of top + proud corridor -> wall; briefs -> wall
# + field at 1x (keeps hip/butt anatomy, sheds weave and waistband/leg lips).
import bpy, sys, time
from collections import deque
import numpy as np
from mathutils import Vector
from mathutils.kdtree import KDTree
from mathutils.bvhtree import BVHTree
argv = sys.argv[sys.argv.index("--") + 1:]
BLEND, MASKS, OUT = argv[0], argv[1], argv[2]
AMP_TARGET = float(argv[3]) if len(argv) > 3 else 0.034
CLEAV_GAP, CLEAV_W = 0.007, 0.013 # narrow gap: reference mounds nearly touch
PROXY_RATIO = 0.012 # ~11.5k proxy: dense wall solve stays under ~2 min; the
# vertex-IDW lift makes coarser proxies safe (no facet imprint)
PDN_SMOOTH = 25
BLEND_RINGS = 12
K_LIFT = 6
POLISH_ITERS = 12
PROUD_THR = 0.0025 # corridor verts standing this proud of the wall are fabric
t0 = time.time()
def log(m):
print(f"[sculpt {time.time()-t0:6.1f}s] {m}", flush=True)
bpy.ops.wm.open_mainfile(filepath=BLEND)
ob = max([o for o in bpy.data.objects if o.type == 'MESH'],
key=lambda o: len(o.data.vertices))
me = ob.data
n_v = len(me.vertices)
M = np.load(MASKS)
cups, shield, toprest = M["cups"], M["shield"], M["toprest"]
briefs, garment = M["briefs"], M["garment"]
co = np.empty(n_v * 3, dtype=np.float64)
me.vertices.foreach_get("co", co)
co = co.reshape(-1, 3)
corridor = (co[:, 2] > 0.64) & (co[:, 2] < 0.86) \
& (np.abs(co[:, 0]) > 0.02) & (np.abs(co[:, 0]) < 0.145)
core_top = (co[:, 2] > 0.600) & (co[:, 2] < 0.855) & (np.abs(co[:, 0]) < 0.14)
core_bot = (co[:, 2] > 0.435) & (co[:, 2] <= 0.600) & (np.abs(co[:, 0]) < 0.15)
# ROUGHNESS, computed up-front because it feeds the PROXY fair region: fabric is wrinkled
# where this sculpt's skin is glassy, and unkeyed fabric (the bow's shadowed folds) must be
# faired on the proxy or the target cage carries it and replaces it with itself. Blanket-
# fairing the whole band instead was catastrophic: it removed the membrane's interior anchors
# (belly/waist/hip skin) and the wall collapsed into a cone spanning shoulders to thighs.
ev_r = np.empty(len(me.edges) * 2, dtype=np.int32)
me.edges.foreach_get("vertices", ev_r)
ev_r = ev_r.reshape(-1, 2)
o_r = np.concatenate([ev_r[:, 0], ev_r[:, 1]])
n_r = np.concatenate([ev_r[:, 1], ev_r[:, 0]])
s_r = np.argsort(o_r, kind="stable")
o_rs = o_r[s_r]
n_rs = n_r[s_r]
ptr_r = np.searchsorted(o_rs, np.arange(n_v + 1))
cnt_r = np.maximum(np.diff(ptr_r), 1)
sm_r = co.copy()
for _ in range(8):
su = np.add.reduceat(sm_r[n_rs], ptr_r[:-1], axis=0)
emp = np.diff(ptr_r) == 0
su[emp] = sm_r[emp]
sm_r = su / cnt_r[:, None]
rough = np.linalg.norm(co - sm_r, axis=1)
rough_zone = (core_top | core_bot) \
& ~((np.abs(co[:, 0]) < 0.018) & (co[:, 2] > 0.50) & (co[:, 2] < 0.55)) # navel
fabric_rough = rough_zone & (rough > 0.0008)
region = garment | corridor | fabric_rough
log(f"loaded {n_v}v; garment={garment.sum()} corridor={corridor.sum()} "
f"rough={fabric_rough.sum()}")
# ---------------- proxy ----------------
proxy = ob.copy()
proxy.data = ob.data.copy()
bpy.context.collection.objects.link(proxy)
dec = proxy.modifiers.new("dec", 'DECIMATE')
dec.ratio = PROXY_RATIO
bpy.context.view_layer.objects.active = proxy
bpy.ops.object.modifier_apply(modifier="dec")
pme = proxy.data
np_v = len(pme.vertices)
pco = np.empty(np_v * 3, dtype=np.float64)
pme.vertices.foreach_get("co", pco)
pco = pco.reshape(-1, 3)
log(f"proxy: {np_v}v")
# proxy zone classification by nearest hires vert (0 none,1 cup,2 toprest,3 briefs)
zone = np.zeros(n_v, dtype=np.int8)
zone[toprest] = 2
zone[briefs] = 3
zone[cups | shield] = 1
zone[corridor & (zone == 0)] = 4 # corridor-only: candidate fabric, decided later
zone[fabric_rough & core_top & (zone == 0)] = 2
zone[fabric_rough & core_bot & (zone == 0)] = 3
pool = np.concatenate([np.nonzero(region)[0], np.nonzero(~region)[0][::20]])
kd_h = KDTree(len(pool))
for j, i in enumerate(pool):
kd_h.insert(Vector(co[i]), j)
kd_h.balance()
p_zone = np.zeros(np_v, dtype=np.int8)
for i in range(np_v):
_, j, _ = kd_h.find(Vector(pco[i]))
p_zone[i] = zone[pool[j]]
p_free = p_zone > 0
log(f"proxy region: {p_free.sum()} (zones: " +
", ".join(f"{z}:{(p_zone==z).sum()}" for z in (1, 2, 3, 4)) + ")")
pn_e = len(pme.edges)
pev = np.empty(pn_e * 2, dtype=np.int32)
pme.edges.foreach_get("vertices", pev)
pev = pev.reshape(-1, 2)
padj = [[] for _ in range(np_v)]
for a, b in pev:
padj[a].append(b)
padj[b].append(a)
free = p_free.copy()
collar = free.copy()
for _ in range(2):
nxt = collar.copy()
for gi in np.nonzero(collar)[0]:
for nb in padj[gi]:
nxt[nb] = True
collar = nxt
collar &= ~free
S = np.nonzero(free | collar)[0]
in_S = np.zeros(np_v, dtype=bool)
in_S[S] = True
gl = np.full(np_v, -1, dtype=np.int64)
gl[S] = np.arange(len(S))
# S-local symmetric graph Laplacian (Ls = deg - adjacency), matrix-free
se = pev[in_S[pev].all(axis=1)]
a_l = gl[se[:, 0]]
b_l = gl[se[:, 1]]
deg = np.zeros(len(S))
np.add.at(deg, a_l, 1.0)
np.add.at(deg, b_l, 1.0)
freeS = free[S]
def Ls(X):
out = deg[:, None] * X
np.add.at(out, a_l, -X[b_l])
np.add.at(out, b_l, -X[a_l])
return out
def A_op(U): # (Ls^2)_ff applied to free values
X = np.zeros((len(S), 3))
X[freeS] = U
Y = Ls(Ls(X))
return Y[freeS]
Xc = np.zeros((len(S), 3))
Xc[~freeS] = pco[S[~freeS]]
b_rhs = -Ls(Ls(Xc))[freeS]
# CG (SPD system); matrix-free, so region size no longer matters
U = pco[S[freeS]].copy()
r = b_rhs - A_op(U)
pdir = r.copy()
rs = (r * r).sum()
for cg_it in range(20000):
Ap = A_op(pdir)
alpha = rs / max((pdir * Ap).sum(), 1e-30)
U += alpha * pdir
r -= alpha * Ap
rs_new = (r * r).sum()
if rs_new < 1e-18:
break
pdir = r + (rs_new / rs) * pdir
rs = rs_new
log(f"CG converged in {cg_it} iterations, residual {rs_new:.2e}")
p_wall = pco.copy()
p_wall[S[freeS]] = U
log(f"proxy wall solved, max move {np.linalg.norm(p_wall-pco,axis=1).max():.4f}")
# proxy wall vertex normals (area-weighted, at wall coords)
pl_tot = np.empty(len(pme.polygons), dtype=np.int32)
pme.polygons.foreach_get("loop_total", pl_tot)
pl_start = np.empty(len(pme.polygons), dtype=np.int32)
pme.polygons.foreach_get("loop_start", pl_start)
pl_v = np.empty(len(pme.loops), dtype=np.int32)
pme.loops.foreach_get("vertex_index", pl_v)
p_nrm = np.zeros((np_v, 3))
for s, t in zip(pl_start, pl_tot):
idxs = pl_v[s:s + t]
fn = np.cross(p_wall[idxs[1]] - p_wall[idxs[0]], p_wall[idxs[2]] - p_wall[idxs[0]])
for vi in idxs:
p_nrm[vi] += fn
p_nrm /= np.maximum(np.linalg.norm(p_nrm, axis=1, keepdims=True), 1e-12)
# proxy mound field: ERODE (2-ring min filter), then smooth. The sports top's decorative
# bow-knot and the hem lips are narrow POSITIVE relief riding on the broad mound; smoothing
# alone spreads them, and amplification then blew the bow up into a fist-sized rosette on the
# inner cup. A min-filter deletes narrow positive relief outright while barely shrinking the
# wide mound underneath.
p_dn = np.einsum("ij,ij->i", pco - p_wall, p_nrm)
for _ in range(3):
p_min = p_dn.copy()
np.minimum.at(p_min, pev[:, 0], p_dn[pev[:, 1]])
np.minimum.at(p_min, pev[:, 1], p_dn[pev[:, 0]])
p_dn = p_min
for _ in range(PDN_SMOOTH):
acc = np.zeros(np_v)
cnt = np.zeros(np_v)
np.add.at(acc, pev[:, 0], p_dn[pev[:, 1]])
np.add.at(acc, pev[:, 1], p_dn[pev[:, 0]])
np.add.at(cnt, pev[:, 0], 1)
np.add.at(cnt, pev[:, 1], 1)
sm = acc / np.maximum(cnt, 1)
upd = free | collar
p_dn[upd] = 0.5 * p_dn[upd] + 0.5 * sm[upd]
# per-proxy-vertex TARGET surface
p_cle = np.clip((np.abs(pco[:, 0]) - CLEAV_GAP) / CLEAV_W, 0.0, 1.0)
p_cle = p_cle * p_cle * (3 - 2 * p_cle)
cup_dn = p_dn[(p_zone == 1)]
k_amp = AMP_TARGET / max(np.percentile(cup_dn, 99.5), 1e-4)
p_field = np.zeros(np_v)
mcup = p_zone == 1
p_field[mcup] = np.maximum(p_dn[mcup], 0.0) * k_amp * p_cle[mcup]
mbri = p_zone == 3
bri_fade = np.clip((0.595 - pco[:, 2]) / 0.030, 0.0, 1.0)
bri_fade = bri_fade * bri_fade * (3 - 2 * bri_fade)
p_field[mbri] = p_dn[mbri] * bri_fade[mbri]
# zones 2 (toprest) and 4 (corridor) stay 0 -> target = wall
# The cup field is NOT baked into the cage any more. Sequence proven by v3.13: first take the
# top fully off (wall replacement + melt + measured excision -> clean flat chest), THEN sculpt
# the breasts onto the healed surface as a separate post-pass. Entangling the mound field with
# the fabric-removal surface made every fabric fix fight the breast shape. The briefs field
# stays in the cage: it is her real hip/butt anatomy, not an addition.
p_field_cup = np.where(p_zone == 1, p_field, 0.0)
up_fade = np.clip((0.752 - pco[:, 2]) / 0.022, 0.0, 1.0)
up_fade = up_fade * up_fade * (3 - 2 * up_fade)
p_field_cup *= up_fade
# The zone mask is paint-keyed and SPECKLED at its borders; a cage built from a discontinuous
# field grows radial spikes that the delta application then amplifies into shredding. Diffuse
# the scalar over the proxy graph until the cage is built from a smooth function.
for _ in range(15):
accc = np.zeros(np_v)
cntc = np.zeros(np_v)
np.add.at(accc, pev[:, 0], p_field_cup[pev[:, 1]])
np.add.at(accc, pev[:, 1], p_field_cup[pev[:, 0]])
np.add.at(cntc, pev[:, 0], 1)
np.add.at(cntc, pev[:, 1], 1)
p_field_cup = 0.5 * p_field_cup + 0.5 * (accc / np.maximum(cntc, 1))
p_T = p_wall + p_nrm * (p_field - p_field_cup)[:, None]
log(f"amplification k = {k_amp:.2f}; proxy target ready (cup field deferred)")
# Reconstruct SMOOTH dense targets from the coarse fields via Catmull-Clark subdivision.
# (v3.0 lifted with inverse-distance weighting of scattered proxy points instead; IDW has
# vanishing gradients at every data site, so each proxy vertex owned a visible flat cell —
# the "crumpled polygon" look. A subdivided cage is the proper smooth-surface reconstruction:
# C2 almost everywhere, facets far below visual scale.)
def smooth_bvh(vcoords):
dup = ob.copy()
dup.data = pme_src.copy()
bpy.context.collection.objects.link(dup)
dup.data.vertices.foreach_set("co", vcoords.reshape(-1).astype(np.float64))
dup.data.update()
sub = dup.modifiers.new("s", 'SUBSURF')
sub.levels = 2
sub.render_levels = 2
bpy.context.view_layer.objects.active = dup
bpy.ops.object.modifier_apply(modifier="s")
dme = dup.data
dv = np.empty(len(dme.vertices) * 3)
dme.vertices.foreach_get("co", dv)
dv = dv.reshape(-1, 3)
dl_tot = np.empty(len(dme.polygons), dtype=np.int32)
dme.polygons.foreach_get("loop_total", dl_tot)
dl_start = np.empty(len(dme.polygons), dtype=np.int32)
dme.polygons.foreach_get("loop_start", dl_start)
dl_v = np.empty(len(dme.loops), dtype=np.int32)
dme.loops.foreach_get("vertex_index", dl_v)
dpolys = [dl_v[st:st + tt].tolist() for st, tt in zip(dl_start, dl_tot)]
tree = BVHTree.FromPolygons([Vector(v) for v in dv], dpolys,
all_triangles=False, epsilon=0.0)
bpy.data.objects.remove(dup, do_unlink=True)
return tree
pme_src = pme.copy() # keep proxy topology before the proxy object is deleted
bvh_wall = smooth_bvh(p_wall)
bvh_tgt = smooth_bvh(p_T)
bpy.data.objects.remove(proxy, do_unlink=True)
log("smooth subdivided targets ready")
# ---------------- decide the ACTIVE set first, then lift targets for ALL of it -------------
# (v3.2/3.3 grew the intent mask with morphological close and a boundary push, but targets were
# only computed for the original paint+corridor region — the bow at the sternum, strap tops
# above the corridor and the armpit folds ended up marked active WITHOUT a target, so they kept
# their fabric geometry untouched. Invariant now: active == lifted == replaced.)
n_e0 = len(me.edges)
ev0 = np.empty(n_e0 * 2, dtype=np.int32)
me.edges.foreach_get("vertices", ev0)
ev0 = ev0.reshape(-1, 2)
def grow_edges(mask, rings, edges):
out = mask.copy()
for _ in range(rings):
nxt = out.copy()
nxt[edges[:, 0]] |= out[edges[:, 1]]
nxt[edges[:, 1]] |= out[edges[:, 0]]
out = nxt
return out
def proud_of_wall(vi):
return (Vector(co[vi]) - bvh_wall.find_nearest(Vector(co[vi]))[0]).length > PROUD_THR
# corridor fabric: geometry decides
corr_idx = np.nonzero(corridor & ~garment)[0]
fabric_extra = np.zeros(n_v, dtype=bool)
for vi in corr_idx:
if proud_of_wall(vi):
fabric_extra[vi] = True
log(f"corridor fabric catch: {fabric_extra.sum()} of {len(corr_idx)}")
act_set = garment | fabric_extra | fabric_rough | core_top | core_bot
g5 = grow_edges(act_set, 3, ev0)
inv = grow_edges(~g5, 3, ev0)
act_set = ~inv
log(f"active after band+close: {act_set.sum()}")
# bounded push: boundary must rest on skin, never on proud fabric
allowed = (co[:, 2] > 0.38) & (co[:, 2] < 0.88) & (np.abs(co[:, 0]) < 0.17)
proud_cache = {}
for it in range(30):
bnd = np.zeros(n_v, dtype=bool)
e_mix = act_set[ev0[:, 0]] != act_set[ev0[:, 1]]
bnd[ev0[e_mix].ravel()] = True
bnd &= act_set
bad = []
for vi in np.nonzero(bnd)[0]:
if vi not in proud_cache:
proud_cache[vi] = proud_of_wall(vi)
if proud_cache[vi]:
bad.append(vi)
if not bad:
log(f"boundary clean after {it} grow steps")
break
ring = np.zeros(n_v, dtype=bool)
ring[bad] = True
act_set |= grow_edges(ring, 2, ev0) & allowed
else:
log(f"NOTE: boundary push capped at 30 steps ({len(bad)} proud verts remain, "
f"likely at the allowed-region rim)")
# ---------------- lift: snap every ACTIVE vert to the smooth target surface ----------------
ridx = np.nonzero(act_set)[0]
active = np.ones(len(ridx), dtype=bool)
T = np.zeros((len(ridx), 3))
for k, i in enumerate(ridx):
T[k] = bvh_tgt.find_nearest(Vector(co[i]))[0]
log(f"lift done for {len(ridx)} active verts")
# ring-depth blend: 0 at the (now skin-resting) boundary, 1 from BLEND_RINGS inward
order = np.concatenate([ev0[:, 0], ev0[:, 1]])
nbr = np.concatenate([ev0[:, 1], ev0[:, 0]])
srt = np.argsort(order, kind="stable")
o_s = order[srt]
n_s = nbr[srt]
ptr = np.searchsorted(o_s, np.arange(n_v + 1))
depth = np.zeros(n_v, dtype=np.int32)
dq = deque()
seen = np.zeros(n_v, dtype=bool)
for a, b in ev0:
if act_set[a] != act_set[b]:
sv = b if act_set[b] else a
if not seen[sv]:
seen[sv] = True
depth[sv] = 1
dq.append(sv)
while dq:
c = dq.popleft()
for nb in n_s[ptr[c]:ptr[c + 1]]:
if act_set[nb] and not seen[nb]:
seen[nb] = True
depth[nb] = depth[c] + 1
dq.append(nb)
depth[act_set & ~seen] = BLEND_RINGS + 1
wgt = np.clip(depth[ridx] / float(BLEND_RINGS), 0.0, 1.0)
wgt = wgt * wgt * (3 - 2 * wgt)
log(f"blend: {(wgt >= 1).sum()} full, {((wgt > 0) & (wgt < 1)).sum()} ramp")
co_new = co.copy()
co_new[ridx] = co[ridx] + (T - co[ridx]) * wgt[:, None]
# short Taubin polish over the replaced area (kills residual IDW dimples)
cnt_all = np.maximum(np.diff(ptr), 1)
def nb_mean(P):
sums = np.add.reduceat(P[n_s], ptr[:-1], axis=0)
empty = np.diff(ptr) == 0
sums[empty] = P[empty]
return sums / cnt_all[:, None]
pol = act_set & (depth >= BLEND_RINGS)
pidx = np.nonzero(pol)[0]
for _ in range(POLISH_ITERS):
for f in (0.55, -0.58):
d = (nb_mean(co_new) - co_new) * f
co_new[pidx] += d[pidx]
log(f"polish: {len(pidx)} verts, {POLISH_ITERS} Taubin pairs")
# MELT pass: whatever fabric decoration still shows (the bow/lacing scar defeated the colour
# key, the roughness threshold AND the proud test), it is by definition ROUGH ON THE RESULT.
# Detect residual roughness inside the front-chest window on co_new itself and aggressively
# smooth just those verts into the surrounding replaced surface. Local and bounded: it cannot
# move anything that is already smooth.
sm2 = co_new.copy()
for _ in range(8):
su2 = np.add.reduceat(sm2[n_rs], ptr_r[:-1], axis=0)
emp2 = np.diff(ptr_r) == 0
su2[emp2] = sm2[emp2]
sm2 = su2 / cnt_r[:, None]
rough2 = np.linalg.norm(co_new - sm2, axis=1)
melt_win = (co[:, 2] > 0.42) & (co[:, 2] < 0.81) & (np.abs(co[:, 0]) < 0.15) & ~((np.abs(co[:, 0]) < 0.018) & (co[:, 2] > 0.50) & (co[:, 2] < 0.55)) # navel
melt = melt_win & (rough2 > 0.0006)
melt = grow_edges(melt, 3, ev0)
midx = np.nonzero(melt)[0]
for _ in range(60):
for f in (0.55, -0.58):
d2 = (nb_mean(co_new) - co_new) * f
co_new[midx] += d2[midx]
log(f"melt: {len(midx)} rough verts smoothed hard")
# BOW EXCISION (v4.8 configuration, restored): bi-harmonic heal of the decorated front
# window, run to CONVERGENCE. Wall-snap variants sampled unfaired cage patches (raw bow) back
# onto the chest, and fairing the window on the proxy collapsed the mound source field —
# both reverted. The converged membrane leaves a soft valley between the upper mounds, which
# the reference image shows as natural anatomy.
bow_win = (co[:, 1] < 0) & (np.abs(co[:, 0]) < 0.080) & (co[:, 2] > 0.630) & (co[:, 2] < 0.802)
bow_free = grow_edges(bow_win, 2, ev0)
bow_collar = grow_edges(bow_free, 2, ev0) & ~bow_free
Sb = np.nonzero(bow_free | bow_collar)[0]
in_Sb = np.zeros(n_v, dtype=bool)
in_Sb[Sb] = True
glb_ = np.full(n_v, -1, dtype=np.int64)
glb_[Sb] = np.arange(len(Sb))
seb = ev0[in_Sb[ev0].all(axis=1)]
a_b = glb_[seb[:, 0]]
b_b = glb_[seb[:, 1]]
degb = np.zeros(len(Sb))
np.add.at(degb, a_b, 1.0)
np.add.at(degb, b_b, 1.0)
freeB = bow_free[Sb]
def Lsb(X):
out = degb[:, None] * X
np.add.at(out, a_b, -X[b_b])
np.add.at(out, b_b, -X[a_b])
return out
def A_b(U):
X = np.zeros((len(Sb), 3))
X[freeB] = U
return Lsb(Lsb(X))[freeB]
Xcb = np.zeros((len(Sb), 3))
Xcb[~freeB] = co_new[Sb[~freeB]]
rhs_b = -Lsb(Lsb(Xcb))[freeB]
Ub = co_new[Sb[freeB]].copy()
r_b = rhs_b - A_b(Ub)
p_b = r_b.copy()
rs_b = (r_b * r_b).sum()
rs0_b = rs_b
for it_b in range(120000):
Apb = A_b(p_b)
al = rs_b / max((p_b * Apb).sum(), 1e-30)
Ub += al * p_b
r_b -= al * Apb
rs2 = (r_b * r_b).sum()
if rs2 < 1e-18 or rs2 < rs0_b * 1e-14:
break
p_b = r_b + (rs2 / rs_b) * p_b
rs_b = rs2
co_new[Sb[freeB]] = Ub
log(f"bow excision: {freeB.sum()} verts healed (CG {it_b} iters, "
f"rel residual {rs2/max(rs0_b,1e-30):.2e})")
# ---- STEP 2: sculpt the breasts onto the healed chest ----
# Applied as the DELTA between two subdivided cages: (wall + cup field) minus (wall). Smooth
# everywhere by construction and exactly zero outside the mound footprint, so it composes with
# the healed chest without steps. (A scalar IDW lift was tried first and re-created the
# flat-spot bubbling that killed v3.0 — same lesson, same fix: reconstruct through a
# subdivided cage, never by scattered-point interpolation.)
bvh_mound = smooth_bvh(p_wall + p_nrm * p_field_cup[:, None])
chest_win = (co[:, 1] < 0.02) & (co[:, 2] > 0.585) & (co[:, 2] < 0.80) & (np.abs(co[:, 0]) < 0.115)
widx = np.nonzero(chest_win)[0]
applied = 0
apex_d = 0.0
# Sample the mound HEIGHT in the wall's own frame: nearest wall point W with its smooth normal
# N, then ray-cast the mound cage along N. One shared frame — unlike the nearest-point delta
# (whose two nearest points are DIFFERENT surface locations on steep slopes; their difference
# carries wild tangential components and shredded the mounds), height-along-normal is a
# continuous scalar field over the wall, so the applied surface inherits both cages' smoothness.
h_arr = np.zeros(len(widx))
N_arr = np.zeros((len(widx), 3))
for k_, i_ in enumerate(widx):
pos_v = Vector(co_new[i_])
hw = bvh_wall.find_nearest(pos_v)
Wp, Nn = hw[0], hw[1]
N_arr[k_] = np.array(Nn)
rc = bvh_mound.ray_cast(Wp - Nn * 0.004, Nn, 0.09)
if rc[0] is not None:
h_arr[k_] = max((Vector(rc[0]) - Wp).dot(Nn), 0.0)
# The raw per-vertex heights carry sampling noise (adjacent rays graze different cage faces),
# which rendered as hairline cracks. Smooth the SCALAR height over the window's mesh graph,
# then renormalise to the target apex — a smooth scalar along smooth normals is artifact-free.
in_win = np.zeros(n_v, dtype=bool)
in_win[widx] = True
pos_win = np.full(n_v, -1, dtype=np.int64)
pos_win[widx] = np.arange(len(widx))
we = ev0[in_win[ev0].all(axis=1)]
wa = pos_win[we[:, 0]]
wb = pos_win[we[:, 1]]
for _ in range(15):
acch = np.zeros(len(widx))
cnth = np.zeros(len(widx))
np.add.at(acch, wa, h_arr[wb])
np.add.at(acch, wb, h_arr[wa])
np.add.at(cnth, wa, 1)
np.add.at(cnth, wb, 1)
mh = acch / np.maximum(cnth, 1)
h_arr = 0.5 * h_arr + 0.5 * mh
# the DIRECTION field cracks too: find_nearest returns per-face normals of the subdivided
# cage, and at 30+ mm of displacement a 2-degree jump between neighbouring faces opens a
# millimetre crack. Smooth the normals with the heights.
accn = np.zeros((len(widx), 3))
np.add.at(accn, wa, N_arr[wb])
np.add.at(accn, wb, N_arr[wa])
mn_ = accn / np.maximum(cnth, 1)[:, None]
N_arr = 0.5 * N_arr + 0.5 * mn_
N_arr /= np.maximum(np.linalg.norm(N_arr, axis=1, keepdims=True), 1e-12)
if h_arr.max() > 1e-4:
# gamma < 1 fattens the mid-slopes: the reference mounds are near-hemispherical (full
# shoulder), not shallow domes
h_arr = h_arr.max() * (h_arr / h_arr.max()) ** 0.75
h_arr *= AMP_TARGET / h_arr.max()
co_new[widx] += N_arr * h_arr[:, None]
log(f"breast field applied (smoothed heights): {(h_arr > 1e-4).sum()} verts, "
f"apex {h_arr.max():.4f}")
# seam polish: the ramp boundaries leave faint horizontal lines at the old band edges; both
# sides are smooth surfaces now, so a light local Taubin along the boundary rings erases the
# lines without moving anything else
bnd_f = np.zeros(n_v, dtype=bool)
e_mix2 = act_set[ev0[:, 0]] != act_set[ev0[:, 1]]
bnd_f[ev0[e_mix2].ravel()] = True
seam_band = grow_edges(bnd_f, 4, ev0)
sidx = np.nonzero(seam_band)[0]
for _ in range(20):
for f in (0.55, -0.58):
d3 = (nb_mean(co_new) - co_new) * f
co_new[sidx] += d3[sidx]
log(f"seam polish: {len(sidx)} boundary-band verts")
me.vertices.foreach_set("co", co_new.reshape(-1))
me.update()
if me.has_custom_normals:
vn = np.empty(n_v * 3, dtype=np.float32)
me.vertices.foreach_get("normal", vn)
me.normals_split_custom_set_from_vertices(vn.reshape(-1, 3))
# save active mask for the texture stage (corridor fabric needs repainting too)
np.savez_compressed(MASKS.replace(".npz", "_active.npz"), active=act_set)
bpy.context.preferences.filepaths.save_version = 0 # no .blend1 autosave
bpy.ops.wm.save_as_mainfile(filepath=OUT)
log(f"WROTE {OUT}")
fmax = p_field.max()
log(f"GATE apex projection (proxy field): {fmax:.4f} (target {AMP_TARGET})")
print("SCULPT_DONE")