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>
This commit is contained in:
@@ -0,0 +1,666 @@
|
||||
# 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()
|
||||
Reference in New Issue
Block a user