Files
animation/tools/make_lena_nude_body.py
T
jeremy 3ba86b2ea8 feat: clothing lane, character sources, and DCC bridges
Bulk import of the working lanes that were living untracked on the PC.

Content:
- characters/  Lena/male body lanes, bakes, texture work, run logs
- clothing/    garment pipeline, configs, gates, contract docs
- garments/    MD-authored garment sources (.zprj/.zpac)
- UAL-Lib/     Universal Animation Library 2 source (.blend/.fbx/.glb)
- tools/       blender_bridge, iclone_bridge, md_bridge, tailor, glm_agent
- docs/, plans/, dev/, .agents/plans/

Repo hygiene:
- .gitattributes: LFS now covers .blend, .zprj, .zpac, .obj, .npy and the
  Reallusion .iAvatar/.ccAvatar/.ccRestore containers. Without this the
  ~3.8 GB in this commit would land as raw blobs. .png/.jpg are left out
  on purpose — ~250 are already tracked raw and converting them would
  rewrite every one without shrinking history.
- .gitignore: exclude /accurig/ (~1 GB AccuRig program files, redistributable
  from Reallusion, nothing authored here) and /dev/null/ (git-lfs hook copies
  dropped by a `>/dev/null` redirect on Windows).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 15:55:43 -07:00

667 lines
31 KiB
Python

# Build the NUDE Lena body variant from the CANONICAL body: erase the sculpted-in underwear
# (geometry AND its rim creases), sculpt anatomical breasts procedurally, swap in the nude
# albedo, export a structurally-identical GLB.
#
# blender --background --python tools/make_lena_nude_body.py -- --out <path.glb> [--size 1.0]
# [--texture <png>] [--texture-name <n>] [--probe] [--dump-mask <glb>]
# [--no-fair] [--no-breasts]
#
# WHERE THE OUTPUT GOES. While this body is work in progress it is staged in THIS repo at
# characters/female/lena_nude/lena_nude_quatskin_glb_v01.glb
# and NOT next to the canonical bodies in ariki-game. characters/REGISTRY.md rule 8 (the boundary
# law) reserves PascalCase "Ariki_*" for game-repo ship names: the rename to
# Ariki_Female_QuatSkin_Nude.glb happens exactly once, at release into
# ariki-game/assets/quaternius/derived-bodies/. --out is required so that choice is always
# deliberate.
#
# ---------------------------------------------------------------------------------------------
# WHY THIS SHAPE OF SOLUTION (findings that killed the obvious approaches — v1 AND v2)
#
# 1. THE UNDERWEAR IS THE BODY SURFACE. A ray-crossing census through the torso finds exactly
# two surface crossings at every height — thigh, belly, bra cup, briefs alike. There is no
# skin underneath the bra or briefs: they are the single body skin, modelled flush (proudness
# +0.41 mm vs +0.34 mm for plain torso skin) with a hard rim crease, and painted beige.
# DELETING garment faces opens a hole — that is the defect in Ariki_Female_QuatSkin_Bare.glb,
# which renders a black cavity across the chest even untextured.
#
# 2. THE GARMENT INTERIOR *IS* THE ANATOMY. The briefs surface is her butt and hips; a membrane
# fill of the whole footprint would flatten them. So the interior is left alone and only a
# NARROW BAND along the garment boundary is re-faired (bi-Laplacian flow, fixed outside the
# band) — that is where the visible crease lives, and a narrow band cannot erase anatomy.
# v1 instead applied a Taubin reference clamped to 2.5 mm over the whole footprint: the clamp
# protected the hips but also PRESERVED THE CREASE, which is exactly what Jeremy could still
# see. Removing proudness is not the same as removing an edge.
#
# 3. CHESTV1 IS GONE. v1 projected the chest onto the ChestV1 experiment, but that sculpt was
# made OVER the bra — the result read as "a bra with volume" (bandeau silhouette, bra
# neckline crease). v2 smooths the chest back to a plain chest wall and sculpts breasts
# PROCEDURALLY from anatomy: elliptical root on the ribcage, teardrop profile (fuller lower
# pole, long gradual upper slope), apex slightly lateral, true cleavage separation, C1 blend
# at the root by construction. No nipples by construction — the field is smooth.
#
# 4. NO VERTEX CORRESPONDENCE EXISTS between body variants (the exporter reorders verts; the UV
# atlas mirrors islands left/right with intra-key spreads to 1.77 m), so nothing here ever
# relies on matching vertices across files.
#
# 5. THE IMPORTER DOES NOT WELD UV SEAMS. Every glTF vertex stays a separate Blender vertex, so
# the mesh is topologically shattered along every seam (23,978 "boundary" edges, only ~3.2k
# real). All geometry runs over a POSITION-WELDED view; displacing seam-split copies of one
# point differently would tear the mesh open.
#
# 6. THE MESH IS NOT WATERTIGHT (3,809 boundary edges). The painted underwear was hiding open
# slits that read as a dotted line of black triangles once it is gone — they are welded shut
# in the torso band before anything else measures the mesh.
#
# Also: every one of these GLBs carries a stray unparented 42-vert "Icosphere" (radius 1.0)
# beside the body. It comes from stock, so it is kept for structural parity — but the body must
# always be picked by max vertex count, never as "the first MESH object".
import bpy, bmesh, sys, os, math, argparse
from collections import defaultdict, deque
from mathutils import Vector
from mathutils.kdtree import KDTree
import numpy as np
# This tool lives in the ANIMATION repo (moved from ariki-game 2026-08-06: it authors characters,
# and characters/REGISTRY.md governs its subjects). The canonical RIGGED bodies it reads still
# live in the game repo, so that path is RESOLVED, never assumed: $ARIKI_GAME wins, otherwise a
# sibling checkout is guessed and then verified. An ariki-relative default that silently fails to
# resolve is a known trap here — see .agents/wiki/ARCHITECTURE.md on the retargeters' --target.
REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
GAME = os.environ.get("ARIKI_GAME") or os.path.abspath(os.path.join(REPO, os.pardir, "ariki-game"))
BODIES = os.path.join(GAME, "assets/quaternius/derived-bodies")
STOCK = os.path.join(BODIES, "Ariki_Female_QuatSkin.glb")
STOCK_TEX = os.path.join(BODIES, "Ariki_Female_QuatSkin_Lena_Body_Toned.png")
def game_asset(path, flag):
"""Fail loudly on an unresolved cross-repo path instead of reading the wrong file."""
if not os.path.isfile(path):
raise SystemExit(
f"[nude] FATAL: cannot find {os.path.basename(path)}\n"
f" looked in: {path}\n"
f" That is an ariki-game asset. Pass {flag} explicitly, or set ARIKI_GAME\n"
f" to your ariki-game checkout (the sibling-directory guess failed).")
return path
# --- underwear colour key (measured per-vertex against the stock albedo) -----------------
# fabric: sat ~0.35-0.37, R/B ~1.55 | skin: sat 0.55-0.66, R/B 2.2-3.0
SAT_MAX = 0.47
RB_MAX = 1.95
UW_Z_LO, UW_Z_HI = 0.80, 1.52 # torso incl. shoulder straps; keeps eyes/teeth out (head)
UW_X_MAX = 0.30 # T-pose guard: keeps low-saturation fingernails out (arms)
MASK_GROW = 2 # ring steps, to swallow the rim crease in the weight mask
# --- rim-band fairing ---------------------------------------------------------------------
BAND_RINGS = 3 # band half-width in mesh rings each side of the boundary
# --- chest wall + procedural breasts ------------------------------------------------------
CHEST_BONES = ("spine_02", "spine_03")
CHEST_W_MIN = 0.35
CHEST_Z_LO, CHEST_Z_HI = 1.11, 1.38 # bottom under the bra band's bulge (z≈1.19); top capped
CHEST_Z_FADE = 0.04 # BELOW the clavicles (z≈1.40) so their definition survives
CHEST_FRONT_MAX_Y = 0.02
CHEST_FREE_MIN = 0.25 # mask weight above which a group is a free membrane vert
# Calibrated against Lena's OWN source art: the 974k-vert Tripo sculpt has real breast anatomy
# modelled under its bra shell (apex z=1.248 on a 1.777 m body, apexes +/-6.0 cm from midline,
# ~12.7 cm base diameter, underbust line z=1.163, ~3.0 cm bare-flesh projection after
# subtracting the ~1.1 cm bra shell). Projecting from that sculpt directly is impossible — the
# bra IS its skin, same construction as the game body — so it serves as the metrics source.
BREAST = { # defaults; --size scales depth and radii together
"cx": 0.061, # root centre, metres from midline (apex measured at 0.060)
"cz": 1.262, # root centre height
"rx": 0.058, # root half-width (base ~12.7 cm across)
"rz": 0.070, # root half-height (before the teardrop asymmetry)
"depth": 0.050, # apex projection off the chest wall (wall sits ~15 mm behind
# the old bra surface, so this reads smaller than it sounds)
"upper_stretch": 1.45, # reach above centre = rz*this: long slope to z~1.35
"lower_stretch": 1.15, # reach below centre = rz*this: lower pole to z~1.167,
# matching the sculpt's underbust line
"apex_drop": 0.014, # amplitude peak sits this far below the root centre (z~1.248)
"gap": 0.008, # half-width of the cleavage dead zone at the sternum
"gap_w": 0.030, # cleavage falloff width — wide, so the forms meet softly
}
NORMAL_RING_FADE = 2 # rings of old->new normal transition outside touched area
def log(m):
print(f"[nude] {m}", flush=True)
def smoothstep(x):
x = max(0.0, min(1.0, x))
return x * x * (3.0 - 2.0 * x)
# =============================================================================================
# mesh plumbing
# =============================================================================================
def import_body(path):
before = set(bpy.data.objects)
bpy.ops.import_scene.gltf(filepath=path)
new = [o for o in bpy.data.objects if o not in before]
meshes = [o for o in new if o.type == 'MESH']
body = max(meshes, key=lambda o: len(o.data.vertices))
rig = next((o for o in new if o.type == 'ARMATURE'), None)
log(f"imported {os.path.basename(path)}: '{body.name}' {len(body.data.vertices)}v/"
f"{len(body.data.polygons)}f" + (f" + {[o.name for o in meshes if o is not body]}"
if len(meshes) > 1 else ""))
return body, rig
class Welded:
"""Position-welded view of a mesh: groups of coincident Blender vertices, their shared
position/normal, and true surface adjacency across UV seams."""
def __init__(self, me, prec=6):
self.me = me
buckets = defaultdict(list)
for v in me.vertices:
buckets[(round(v.co.x, prec), round(v.co.y, prec), round(v.co.z, prec))].append(v.index)
self.groups = list(buckets.values())
self.gov = [0] * len(me.vertices)
for gi, members in enumerate(self.groups):
for vi in members:
self.gov[vi] = gi
self.n = len(self.groups)
self.adj = [set() for _ in range(self.n)]
for e in me.edges:
a, b = self.gov[e.vertices[0]], self.gov[e.vertices[1]]
if a != b:
self.adj[a].add(b)
self.adj[b].add(a)
self.co = np.array([[*me.vertices[m[0]].co] for m in self.groups], dtype=np.float64)
nrm = np.zeros((self.n, 3))
for gi, m in enumerate(self.groups):
acc = Vector((0, 0, 0))
for vi in m:
acc += me.vertices[vi].normal
nrm[gi] = [*acc]
ln = np.linalg.norm(nrm, axis=1, keepdims=True)
self.nrm = nrm / np.maximum(ln, 1e-12)
# flat adjacency for fast numpy smoothing
idx, ptr = [], [0]
for gi in range(self.n):
idx.extend(self.adj[gi])
ptr.append(len(idx))
self.adj_idx = np.array(idx, dtype=np.int64)
self.adj_ptr = np.array(ptr, dtype=np.int64)
self.adj_cnt = np.maximum(np.diff(self.adj_ptr), 1)
def neighbour_mean(self, P):
sums = np.add.reduceat(P[self.adj_idx], self.adj_ptr[:-1], axis=0)
empty = np.diff(self.adj_ptr) == 0
sums[empty] = P[empty]
return sums / self.adj_cnt[:, None]
def laplacian(self, P):
return self.neighbour_mean(P) - P
def taubin(self, P, iters, lam=0.55, mu=-0.58, mask=None):
"""Taubin lambda/mu smoothing — shrink then inflate, so the surface sheds detail
without deflating the way plain Laplacian smoothing would over many passes."""
Q = P.copy()
w = None if mask is None else mask[:, None]
for _ in range(iters):
for f in (lam, mu):
d = self.laplacian(Q) * f
Q += d if w is None else d * w
return Q
def grow(self, sel, rings):
"""Boolean selection grown over welded adjacency."""
out = sel.copy()
for _ in range(rings):
nxt = out.copy()
for gi in np.nonzero(out)[0]:
for nb in self.adj[gi]:
nxt[nb] = True
out = nxt
return out
def group_normals(self, P):
"""Fresh area-weighted normals per welded group computed from positions P — without
touching the mesh, so intermediate stages can direct displacement off the CURRENT
surface rather than the imported one."""
acc = np.zeros((self.n, 3))
for p in self.me.polygons:
vs = [self.gov[vi] for vi in p.vertices]
n = np.cross(P[vs[1]] - P[vs[0]], P[vs[2]] - P[vs[0]])
for gi in vs:
acc[gi] += n
ln = np.linalg.norm(acc, axis=1, keepdims=True)
return acc / np.maximum(ln, 1e-12)
def write(self, P):
for gi, members in enumerate(self.groups):
c = Vector(P[gi])
for vi in members: # every seam copy moves alike -> no tearing
self.me.vertices[vi].co = c
def close_rim_slits(body, dist_mm=4.0, zband=(0.80, 1.45)):
"""Weld shut the open slits in the torso band (see header, finding 6). Welding is preferred
over hole-filling: it adds no faces, so no new UVs have to be invented. Exact-duplicate
merging first recovers true topology (UV-seam splits masquerade as boundaries); merging
position duplicates is safe — UVs live per face corner and survive the merge. The band stays
below the head so mouth/nostril/ear openings are never candidates."""
me = body.data
bm = bmesh.new()
bm.from_mesh(me)
v0 = len(bm.verts)
bmesh.ops.remove_doubles(bm, verts=list(bm.verts), dist=1e-5)
bm.verts.ensure_lookup_table()
seams = len([e for e in bm.edges if len(e.link_faces) == 1])
sel = [v for v in bm.verts
if zband[0] <= v.co.z <= zband[1]
and any(len(e.link_faces) == 1 for e in v.link_edges)]
if sel:
bmesh.ops.remove_doubles(bm, verts=sel, dist=dist_mm / 1000.0)
after = len([e for e in bm.edges if len(e.link_faces) == 1])
bm.to_mesh(me)
bm.free()
me.update()
log(f"rim slits: {v0} -> {len(me.vertices)} verts; boundary edges {seams} -> {after} "
f"({len(sel)} band verts welded at {dist_mm} mm)")
# =============================================================================================
# 1. garment mask + rim-band fairing
# =============================================================================================
def sample_albedo(me, png):
img = bpy.data.images.load(png, check_existing=True)
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]
uvl = me.uv_layers.active.data
first = {}
for p in me.polygons:
for li in p.loop_indices:
vi = me.loops[li].vertex_index
if vi not in first:
first[vi] = uvl[li].uv[:]
return px, w, h, first
def garment_groups(me, wl, png):
"""Boolean per welded group: is this point painted fabric? (bpy-imported UVs — already
v-flipped by the importer, unlike raw-parsed glTF UVs; see _bake_nude_body_texture.py)."""
px, w, h, first = sample_albedo(me, png)
fabric = np.zeros(wl.n, dtype=bool)
for gi, members in enumerate(wl.groups):
x_, y_, z_ = wl.co[gi]
if not (UW_Z_LO <= z_ <= UW_Z_HI) or abs(x_) > UW_X_MAX:
continue
uv = next((first[vi] for vi in members if vi in first), None)
if uv is None:
continue
xi = int(min(max(uv[0], 0.0), 1.0) * (w - 1))
yi = int(min(max(uv[1], 0.0), 1.0) * (h - 1))
r, g, b = px[yi, xi]
mx, mn = max(r, g, b), min(r, g, b)
sat = (mx - mn) / mx if mx > 1e-5 else 0.0
if sat < SAT_MAX and r / max(b, 1e-5) < RB_MAX:
fabric[gi] = True
# component filter kills stitch-pixel speckle
keep = np.zeros_like(fabric)
seen = np.zeros_like(fabric)
for s in np.nonzero(fabric)[0]:
if seen[s]:
continue
q = deque([s]); seen[s] = True; comp = [s]
while q:
c = q.popleft()
for nb in wl.adj[c]:
if fabric[nb] and not seen[nb]:
seen[nb] = True; q.append(nb); comp.append(nb)
if len(comp) >= 40:
keep[comp] = True
log(f"garment mask: {fabric.sum()} colour-keyed -> {keep.sum()} after component filter")
return keep
def membrane_fair(wl, free, P, collar_rings=2, quiet=False):
"""Replace the surface over `free` with a bi-harmonic membrane spanning its surroundings:
solve L²x = 0 with two collar rings held fixed (position + slope boundary conditions),
as a DIRECT dense solve.
Direct rather than iterative flow on purpose: explicit bi-Laplacian flow needs on the order
of diameter^4 sweeps to converge, which is fine for a several-ring band and hopeless for the
whole chest (~40 rings across — the same class of mistake as the v1 texture fill's 400
Jacobi passes). And Taubin smoothing is the wrong tool entirely here: it removes
high-frequency detail but PRESERVES low-frequency shape by design, and the bra bulge is
low-frequency — v2's first attempt left a 42 mm cliff at the old neckline (z=1.304) to
prove it. The membrane deletes the shape and re-derives it from the ribcage boundary."""
collar = wl.grow(free, collar_rings) & ~free
S = np.nonzero(free | collar)[0]
loc = {g: i for i, g in enumerate(S)}
n = len(S)
L = np.zeros((n, n))
for i, g in enumerate(S):
L[i, i] = -1.0
nbs = wl.adj[g]
if not nbs:
L[i, i] = 0.0
continue
wnb = 1.0 / len(nbs)
for nb in nbs:
if nb in loc:
L[i, loc[nb]] += wnb
# neighbours outside S contribute to the mean as constants; fold them into the
# right-hand side by treating them as extra fixed columns below
# constant contribution from neighbours outside S
C = np.zeros((n, 3))
for i, g in enumerate(S):
nbs = wl.adj[g]
if not nbs:
continue
wnb = 1.0 / len(nbs)
for nb in nbs:
if nb not in loc:
C[i] += wnb * P[nb]
# L(x) over S = Lmat@x + C, with C the folded contribution of fixed neighbours outside S.
# Free verts sit >=2 rings inside, so L²(x)|free = (Lmat²@x + Lmat@C)|free exactly.
M = L @ L
free_rows = np.array([i for i, g in enumerate(S) if free[g]])
fixed_rows = np.array([i for i, g in enumerate(S) if not free[g]])
x_fixed = P[S[fixed_rows]]
A = M[np.ix_(free_rows, free_rows)]
rhs = -(M[np.ix_(free_rows, fixed_rows)] @ x_fixed) - (L @ C)[free_rows]
Q = P.copy()
try:
sol = np.linalg.solve(A, rhs)
except np.linalg.LinAlgError:
sol = np.linalg.lstsq(A, rhs, rcond=None)[0]
log("membrane: singular system, used lstsq")
Q[S[free_rows]] = sol
if not quiet:
moved = np.linalg.norm(Q - P, axis=1)
log(f"membrane: {free.sum()} free / {collar.sum()} collar, "
f"max moved {moved.max()*1000:.1f} mm")
return Q
def fair_rim_band(wl, garment, P, rings=BAND_RINGS):
"""Erase the garment's rim creases: exact bi-harmonic membrane over a narrow band each side
of every garment/skin boundary, everything outside the band held fixed.
The band is deliberately narrow (finding 2 in the header): the garment interior IS her
anatomy, so only the crease line is rebuilt. The band splits into separate closed loops
(neckline, armholes, bra band, straps, waistband, leg holes), and each loop is solved with
the DIRECT membrane solver independently — a few hundred verts each, so the dense solve is
trivial. The first version ran explicit bi-Laplacian flow over the whole band instead; at
400 sweeps it had merely SOFTENED the creases (the ghost detector still flagged the
racerback's neck scoop, armholes and band on the back at 1.3-1.5x control), because
explicit flow needs ~diameter^4 sweeps and never truly converges."""
boundary = np.zeros(wl.n, dtype=bool)
for gi in np.nonzero(garment)[0]:
for nb in wl.adj[gi]:
if not garment[nb]:
boundary[gi] = True
boundary[nb] = True
band = wl.grow(boundary, rings)
log(f"rim band: {boundary.sum()} boundary groups -> {band.sum()} in band (+/-{rings} rings)")
# split the band into connected loops
seen = np.zeros(wl.n, dtype=bool)
comps = []
for s in np.nonzero(band)[0]:
if seen[s]:
continue
q = deque([s]); seen[s] = True; comp = [s]
while q:
c = q.popleft()
for nb in wl.adj[c]:
if band[nb] and not seen[nb]:
seen[nb] = True; q.append(nb); comp.append(nb)
comps.append(comp)
log(f"rim band: {len(comps)} loops "
f"({', '.join(str(len(c)) for c in sorted(comps, key=len, reverse=True)[:6])}...)")
Q = P.copy()
for comp in comps:
if len(comp) < 8:
continue
free = np.zeros(wl.n, dtype=bool)
free[comp] = True
Q = membrane_fair(wl, free, Q, quiet=True)
moved = np.linalg.norm(Q - P, axis=1)
idx = np.nonzero(band)[0]
log(f"rim fairing: max {moved.max()*1000:.2f} mm, median(band) "
f"{np.median(moved[idx])*1000:.2f} mm")
return Q, band
# =============================================================================================
# 2. chest wall + procedural breasts
# =============================================================================================
def chest_mask(body, wl):
"""Anatomical bust mask: skin weight on the spine bones that carry the bust, intersected
with a Z window capped below the clavicles. A raw Z slice is useless in a T-pose — arms and
hands cross any band containing the bust."""
gi_of = {g.name: g.index for g in body.vertex_groups}
bones = {gi_of[b] for b in CHEST_BONES if b in gi_of}
if not bones:
raise SystemExit("[nude] FATAL: chest bones missing from vertex groups")
me = body.data
w = np.zeros(wl.n)
for gi, members in enumerate(wl.groups):
v = me.vertices[members[0]]
co = v.co
if co.z < CHEST_Z_LO - CHEST_Z_FADE or co.z > CHEST_Z_HI + CHEST_Z_FADE \
or co.y > CHEST_FRONT_MAX_Y:
continue
skin = sum(g.weight for g in v.groups if g.group in bones)
if skin < CHEST_W_MIN:
continue
a = smoothstep((skin - CHEST_W_MIN) / 0.25)
a *= smoothstep((co.z - (CHEST_Z_LO - CHEST_Z_FADE)) / CHEST_Z_FADE)
a *= smoothstep(((CHEST_Z_HI + CHEST_Z_FADE) - co.z) / CHEST_Z_FADE)
a *= smoothstep((CHEST_FRONT_MAX_Y - co.y) / 0.04)
w[gi] = a
log(f"chest mask: {(w > 0.01).sum()} welded groups")
return w
def breast_field(wl, P, mask, size):
"""Sculpt two anatomical breasts onto the (already smoothed) chest wall.
The shape is built from anatomy, not from any donor mesh: an elliptical root attachment on
the ribcage, a teardrop profile — the vertical coordinate is stretched above the root centre
(long gradual slope toward the collarbone, no shelf) and compressed below it (full lower
pole), the amplitude peak sits slightly below centre (apex_drop), and a smoothstep dead zone
at the sternum separates the two forms with real cleavage. Displacement runs along the local
chest-wall normal. The radial profile (1-u^2)^2 has zero slope at u=1, so the root blends C1
into the wall by construction, and the field is smooth everywhere — no nipple can exist.
Eligibility is geometric (front hemisphere, torso half-width, the lobes' own z window), NOT
the chest mask — the mask's front-Y taper fades exactly where the outer root lands, and
gating by it cut a notch into the outer-upper quadrant of each breast. The profile vanishes
at the root on its own; nothing else may taper it."""
p = dict(BREAST)
p["depth"] *= size
p["rx"] *= math.sqrt(size)
p["rz"] *= math.sqrt(size)
nrm = wl.group_normals(P)
Q = P.copy()
applied = []
max_d = 0.0
for gi in range(wl.n):
x, y, z = P[gi]
if y > 0.05 or abs(x) > 0.13:
continue
s = 1.0 if x >= 0 else -1.0
dx = x - s * p["cx"]
dz = z - (p["cz"] - p["apex_drop"])
dz_eff = dz / (p["upper_stretch"] if dz > 0 else p["lower_stretch"]) # reach = rz*stretch
u2 = (dx / p["rx"]) ** 2 + (dz_eff / p["rz"]) ** 2
if u2 >= 1.0:
continue
# (1-u^2.4)^1.6: flatter dome / rounder shoulder than (1-u^2)^2, still zero-slope at
# the root (exponent > 1) so the C1 blend survives
a = p["depth"] * (1.0 - u2 ** 1.2) ** 1.6
a *= smoothstep((abs(x) - p["gap"]) / p["gap_w"]) # cleavage separation
if a <= 1e-6:
continue
Q[gi] += nrm[gi] * a
applied.append(gi)
max_d = max(max_d, a)
log(f"breasts: size={size}, {len(applied)} groups displaced, apex {max_d*1000:.1f} mm")
lobes = np.zeros(wl.n, dtype=bool)
lobes[applied] = True
return Q, lobes
# =============================================================================================
# 3. normals / texture / export
# =============================================================================================
def rebuild_normals(body, wl, touched):
"""Fresh area-weighted smooth normals over the WELDED topology wherever geometry changed,
with a short ring-distance fade into the original custom split normals outside. v1 blended
old->new by displacement magnitude, which PRESERVED the crease shading where the crease
geometry had barely moved — the old baked normals are part of what made the underwear
visible, so inside the touched region they are fully replaced."""
me = body.data
if not me.has_custom_normals:
log("base has no custom split normals; leaving normals to Blender")
return
old = [Vector(c.vector) for c in me.corner_normals]
acc = np.zeros((wl.n, 3))
for poly in me.polygons:
n = np.array([*poly.normal]) * poly.area
for vi in poly.vertices:
acc[wl.gov[vi]] += n
ln = np.linalg.norm(acc, axis=1)
fade = touched.astype(np.float64)
zone = touched.copy()
for k in range(1, NORMAL_RING_FADE + 1):
nxt = wl.grow(zone, 1)
ring = nxt & ~zone
fade[ring] = 1.0 - k / (NORMAL_RING_FADE + 1.0)
zone = nxt
out, changed = [], 0
for li, loop in enumerate(me.loops):
gi = wl.gov[loop.vertex_index]
a = fade[gi]
if a <= 0.0 or ln[gi] < 1e-9:
out.append(old[li])
continue
fresh = Vector(acc[gi] / ln[gi])
v = old[li] * (1.0 - a) + fresh * a
out.append(v.normalized() if v.length > 1e-9 else fresh)
changed += 1
me.normals_split_custom_set(out)
log(f"normals: {changed} of {len(out)} loops rebuilt")
def swap_texture(body, png, name):
"""Point the material's base colour at a new file. The image datablock NAME is set
explicitly — it decides the sidecar the exporter writes, and a WIP asset must not bake a
PascalCase ship name inside itself (REGISTRY.md rule 8)."""
for mat in [ms.material for ms in body.material_slots if ms.material]:
for node in mat.node_tree.nodes:
if node.type == 'TEX_IMAGE' and node.image:
img = bpy.data.images.load(png, check_existing=True)
img.name = name
node.image = img
img.pack()
log(f"texture -> '{img.name}' ({os.path.basename(png)}) on material '{mat.name}'")
return
log("WARNING: no image texture node found; texture NOT swapped")
def dump_mask(body, wl, garment, band, chest_w, path):
"""Bake the selections into vertex colours (R garment, G band, B chest) for eyeballing."""
me = body.data
lay = me.color_attributes.new(name="MaskDebug", type='FLOAT_COLOR', domain='POINT')
for vi in range(len(me.vertices)):
gi = wl.gov[vi]
lay.data[vi].color = (float(garment[gi]), float(band[gi]), float(chest_w[gi]), 1.0)
for mat in [ms.material for ms in body.material_slots if ms.material]:
nt = mat.node_tree
n = nt.nodes.new("ShaderNodeVertexColor")
n.layer_name = "MaskDebug"
bsdf = next((x for x in nt.nodes if x.type == 'BSDF_PRINCIPLED'), None)
if bsdf:
nt.links.new(n.outputs["Color"], bsdf.inputs["Base Color"])
bpy.ops.export_scene.gltf(filepath=path, export_format='GLB', use_selection=False,
export_yup=True, export_skins=True, export_animations=False)
log(f"WROTE mask debug {path}")
def main():
argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else []
ap = argparse.ArgumentParser()
ap.add_argument("--base", default=STOCK)
ap.add_argument("--mask-texture", default=STOCK_TEX,
help="albedo WITH the underwear painted, used for the colour key")
ap.add_argument("--out", required=True)
ap.add_argument("--size", type=float, default=1.0, help="breast volume multiplier")
ap.add_argument("--texture", default="", help="albedo to ship on the result")
ap.add_argument("--texture-name", default="lena_nude_basecolor",
help="image datablock name; drives the exported sidecar filename")
ap.add_argument("--dump-mask", default="")
ap.add_argument("--no-fair", action="store_true")
ap.add_argument("--no-breasts", action="store_true")
ap.add_argument("--weld-slits", type=float, default=4.0)
a = ap.parse_args(argv)
game_asset(a.base, "--base")
game_asset(a.mask_texture, "--mask-texture")
bpy.ops.wm.read_factory_settings(use_empty=True)
body, rig = import_body(a.base)
if a.weld_slits > 0:
close_rim_slits(body, a.weld_slits)
wl = Welded(body.data)
P0 = wl.co.copy()
P = P0.copy()
garment = garment_groups(body.data, wl, a.mask_texture)
band = np.zeros(wl.n, dtype=bool)
if not a.no_fair:
P, band = fair_rim_band(wl, garment, P)
chest_w = chest_mask(body, wl)
lobes = np.zeros(wl.n, dtype=bool)
if not a.no_breasts:
# erase the bra shape down to a plain chest wall, then sculpt on top of it
P = membrane_fair(wl, chest_w > CHEST_FREE_MIN, P)
P, lobes = breast_field(wl, P, chest_w, a.size)
# Grazing-angle polish: each membrane strip lands within a millimetre or two of its
# surroundings, which is invisible front-on but reads as horizontal waviness in side views
# (the ghost detector caught torso90 at 1.67x control while every other view passed). A few
# light Taubin passes over the band zone blend the strips in; the breast lobes are excluded
# so their sculpted shape stays crisp.
if not a.no_fair:
polish = wl.grow(band, 2) & ~lobes
P = wl.taubin(P, 8, mask=polish.astype(np.float64))
log(f"polish: {polish.sum()} groups, 8 light passes")
touched = (np.linalg.norm(P - P0, axis=1) > 1e-6) | band | (chest_w > 0.01)
wl.write(P)
body.data.update()
rebuild_normals(body, wl, touched)
if a.texture:
swap_texture(body, a.texture, a.texture_name)
if a.dump_mask:
dump_mask(body, wl, garment, band, chest_w, a.dump_mask)
for o in bpy.data.objects:
o.select_set(True) # keeps the stray Icosphere, for parity with stock
bpy.ops.export_scene.gltf(filepath=a.out, export_format='GLB', use_selection=True,
export_yup=True, export_skins=True, export_animations=False,
export_apply=False, export_image_format='AUTO',
export_tangents=False, export_normals=True)
log(f"WROTE {a.out} ({os.path.getsize(a.out)/1e6:.2f} MB)")
main()