3d8825f5a9
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>
613 lines
24 KiB
Python
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")
|