# 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> # [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")