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>
194 lines
7.2 KiB
Python
194 lines
7.2 KiB
Python
# Stage 6: replace the crotch with the MALE game body's construction (Jeremy's directive —
|
|
# the male is already the smooth doll-like build; the female must match it exactly).
|
|
#
|
|
# blender --background --python 06_crotch.py -- <03_final_geometry.blend> <male.glb> <out.blend>
|
|
#
|
|
# Method: detect the crotch saddle landmark on both bodies (lowest midline point of the torso
|
|
# between the leg roots), scale the male crotch patch by body-height ratio, translate saddle to
|
|
# saddle, subdivide the patch to a smooth cage, and snap the female crotch window onto it with
|
|
# a ring-depth blend. The male GLB is in METRES; this sculpt is 0.9792 units tall.
|
|
import bpy, sys, time
|
|
from collections import deque
|
|
import numpy as np
|
|
from mathutils import Vector
|
|
from mathutils.bvhtree import BVHTree
|
|
|
|
argv = sys.argv[sys.argv.index("--") + 1:]
|
|
BLEND, MALE, OUT = argv[0], argv[1], argv[2]
|
|
t0 = time.time()
|
|
|
|
WIN_R_Z = 0.042 # female window: half-height around the saddle
|
|
WIN_R_X = 0.034 # half-width — must NOT reach the inner-thigh walls
|
|
SNAP_MAX = 0.020 # reject snaps to far surfaces (a bad cage grabbed thighs at 60 mm)
|
|
BLEND_RINGS = 8
|
|
|
|
|
|
def log(m):
|
|
print(f"[crotch {time.time()-t0:6.1f}s] {m}", flush=True)
|
|
|
|
|
|
bpy.ops.wm.open_mainfile(filepath=BLEND)
|
|
ob = max([o for o in bpy.data.objects if o.type == 'MESH'],
|
|
key=lambda o: len(o.data.vertices))
|
|
me = ob.data
|
|
n_v = len(me.vertices)
|
|
co = np.empty(n_v * 3)
|
|
me.vertices.foreach_get("co", co)
|
|
co = co.reshape(-1, 3)
|
|
f_height = co[:, 2].max() - co[:, 2].min()
|
|
|
|
# female saddle: lowest midline torso point between the legs (the briefs bridge the crotch,
|
|
# so the midline strip is continuous surface)
|
|
mid_f = (np.abs(co[:, 0]) < 0.01) & (co[:, 2] > 0.38) & (co[:, 2] < 0.50)
|
|
saddle_f = co[mid_f][np.argmin(co[mid_f, 2])]
|
|
log(f"female: height {f_height:.4f}, saddle {saddle_f}")
|
|
|
|
# ---- male donor ----
|
|
before = set(bpy.data.objects)
|
|
bpy.ops.import_scene.gltf(filepath=MALE)
|
|
new = [o for o in bpy.data.objects if o not in before]
|
|
male = max([o for o in new if o.type == 'MESH'], key=lambda o: len(o.data.vertices))
|
|
mme = male.data
|
|
nm = len(mme.vertices)
|
|
mco = np.empty(nm * 3)
|
|
mme.vertices.foreach_get("co", mco)
|
|
mco = mco.reshape(-1, 3)
|
|
m_height = mco[:, 2].max() - mco[:, 2].min()
|
|
mid_m = (np.abs(mco[:, 0]) < 0.02) & (mco[:, 2] > 0.55 * m_height) * (mco[:, 2] < 0.75 * m_height)
|
|
if not mid_m.any():
|
|
mid_m = (np.abs(mco[:, 0]) < 0.02)
|
|
saddle_m = mco[mid_m][np.argmin(mco[mid_m, 2])]
|
|
s = f_height / m_height
|
|
log(f"male '{male.name}': {nm}v height {m_height:.3f} m, saddle {saddle_m}, scale {s:.4f}")
|
|
|
|
# male crotch patch: faces whose verts lie near the saddle (generous; the cage is only a target)
|
|
sel_m = (np.abs(mco[:, 0] - saddle_m[0]) < WIN_R_X / s * 1.6) \
|
|
& (np.abs(mco[:, 2] - saddle_m[2]) < WIN_R_Z / s * 1.6)
|
|
log(f"male patch verts: {sel_m.sum()}")
|
|
|
|
# transform male verts into female space — and AUTO-DETECT front/back orientation: the male
|
|
# body may face the opposite way; test identity and y-mirror, keep whichever cage lands closer
|
|
# to the female window verts
|
|
mco_t = (mco - saddle_m) * s + saddle_f
|
|
mco_t_flip = mco_t.copy()
|
|
mco_t_flip[:, 1] = 2 * saddle_f[1] - mco_t[:, 1]
|
|
|
|
# build patch mesh -> subdivide -> BVH
|
|
ml_tot = np.empty(len(mme.polygons), dtype=np.int32)
|
|
mme.polygons.foreach_get("loop_total", ml_tot)
|
|
ml_start = np.empty(len(mme.polygons), dtype=np.int32)
|
|
mme.polygons.foreach_get("loop_start", ml_start)
|
|
ml_v = np.empty(len(mme.loops), dtype=np.int32)
|
|
mme.loops.foreach_get("vertex_index", ml_v)
|
|
faces = []
|
|
for fs, ft in zip(ml_start, ml_tot):
|
|
idxs = ml_v[fs:fs + ft]
|
|
if sel_m[idxs].all():
|
|
faces.append(idxs.tolist())
|
|
log(f"male patch faces: {len(faces)}")
|
|
used = sorted(set(i for f in faces for i in f))
|
|
remap = {g: i for i, g in enumerate(used)}
|
|
pm = bpy.data.meshes.new("crotch_patch")
|
|
pm.from_pydata([Vector(mco_t[i]) for i in used], [],
|
|
[[remap[i] for i in f] for f in faces])
|
|
pm.update()
|
|
po = bpy.data.objects.new("crotch_patch", pm)
|
|
bpy.context.collection.objects.link(po)
|
|
sub = po.modifiers.new("s", 'SUBSURF')
|
|
sub.levels = 2
|
|
bpy.context.view_layer.objects.active = po
|
|
bpy.ops.object.modifier_apply(modifier="s")
|
|
dme = po.data
|
|
dv = np.empty(len(dme.vertices) * 3)
|
|
dme.vertices.foreach_get("co", dv)
|
|
dv = dv.reshape(-1, 3)
|
|
dl_tot = np.empty(len(dme.polygons), dtype=np.int32)
|
|
dme.polygons.foreach_get("loop_total", dl_tot)
|
|
dl_start = np.empty(len(dme.polygons), dtype=np.int32)
|
|
dme.polygons.foreach_get("loop_start", dl_start)
|
|
dl_v = np.empty(len(dme.loops), dtype=np.int32)
|
|
dme.loops.foreach_get("vertex_index", dl_v)
|
|
polys_d = [dl_v[fs:fs + ft].tolist() for fs, ft in zip(dl_start, dl_tot)]
|
|
bvh = BVHTree.FromPolygons([Vector(v) for v in dv], polys_d,
|
|
all_triangles=False, epsilon=0.0)
|
|
dv_f = dv.copy()
|
|
dv_f[:, 1] = 2 * saddle_f[1] - dv[:, 1]
|
|
bvh_f = BVHTree.FromPolygons([Vector(v) for v in dv_f], polys_d,
|
|
all_triangles=False, epsilon=0.0)
|
|
for o in new + [po]:
|
|
bpy.data.objects.remove(o, do_unlink=True)
|
|
log("donor cages ready (both orientations)")
|
|
|
|
# ---- female window snap with ring-depth blend ----
|
|
win = (np.abs(co[:, 0] - saddle_f[0]) < WIN_R_X) \
|
|
& (np.abs(co[:, 2] - saddle_f[2]) < WIN_R_Z)
|
|
widx = np.nonzero(win)[0]
|
|
log(f"female window: {len(widx)} verts")
|
|
|
|
n_e = len(me.edges)
|
|
ev = np.empty(n_e * 2, dtype=np.int32)
|
|
me.edges.foreach_get("vertices", ev)
|
|
ev = ev.reshape(-1, 2)
|
|
order = np.concatenate([ev[:, 0], ev[:, 1]])
|
|
nbr = np.concatenate([ev[:, 1], ev[:, 0]])
|
|
srt = np.argsort(order, kind="stable")
|
|
o_s = order[srt]
|
|
n_s = nbr[srt]
|
|
ptr = np.searchsorted(o_s, np.arange(n_v + 1))
|
|
depth = np.zeros(n_v, dtype=np.int32)
|
|
dq = deque()
|
|
seen = np.zeros(n_v, dtype=bool)
|
|
for a, b in ev:
|
|
if win[a] != win[b]:
|
|
sv = a if win[a] else b
|
|
if not seen[sv]:
|
|
seen[sv] = True
|
|
depth[sv] = 1
|
|
dq.append(sv)
|
|
while dq:
|
|
c = dq.popleft()
|
|
for nb in n_s[ptr[c]:ptr[c + 1]]:
|
|
if win[nb] and not seen[nb]:
|
|
seen[nb] = True
|
|
depth[nb] = depth[c] + 1
|
|
dq.append(nb)
|
|
depth[win & ~seen] = BLEND_RINGS + 2
|
|
wgt = np.clip(depth[widx] / float(BLEND_RINGS), 0.0, 1.0)
|
|
wgt = wgt * wgt * (3 - 2 * wgt)
|
|
|
|
# orientation pick: median nearest-distance over a sample of window verts
|
|
samp = widx[::37]
|
|
def med_d(tree):
|
|
ds = []
|
|
for i in samp:
|
|
h = tree.find_nearest(Vector(co[i]), 0.08)
|
|
ds.append(h[3] if h[0] is not None else 0.08)
|
|
return float(np.median(ds))
|
|
d_id, d_fl = med_d(bvh), med_d(bvh_f)
|
|
use = bvh if d_id <= d_fl else bvh_f
|
|
log(f"orientation: identity {d_id:.4f} vs flipped {d_fl:.4f} -> "
|
|
f"{'identity' if d_id <= d_fl else 'flipped'}")
|
|
|
|
co_new = co.copy()
|
|
moved = 0
|
|
for k, i in enumerate(widx):
|
|
hit = use.find_nearest(Vector(co[i]), SNAP_MAX)
|
|
if hit[0] is None:
|
|
continue
|
|
tgt = np.array(hit[0])
|
|
co_new[i] += (tgt - co[i]) * wgt[k]
|
|
moved += 1
|
|
delta = np.linalg.norm(co_new - co, axis=1)
|
|
log(f"snapped {moved} verts, max move {delta.max():.4f}")
|
|
|
|
me.vertices.foreach_set("co", co_new.reshape(-1))
|
|
me.update()
|
|
if me.has_custom_normals:
|
|
vn = np.empty(n_v * 3, dtype=np.float32)
|
|
me.vertices.foreach_get("normal", vn)
|
|
me.normals_split_custom_set_from_vertices(vn.reshape(-1, 3))
|
|
bpy.context.preferences.filepaths.save_version = 0 # no .blend1 autosave
|
|
bpy.ops.wm.save_as_mainfile(filepath=OUT)
|
|
log(f"WROTE {OUT}")
|
|
print("CROTCH_DONE")
|