3ba86b2ea8
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>
667 lines
31 KiB
Python
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()
|