feat(lena): v02 lane recipes + the archival full-res master
The v02 chain, which exists because bisecting the shard-eye defect proved it lives in
the hires MASTER rather than in any v02 step: the early heal chain ran whole-mesh welds
and reset custom split normals, and the eye/lash shells only read as an eye through
Tripo's authored normals. Every descendant inherits it, including the shipped body.
48_decimate_headsafe body/hands only, head bit-identical (feathered ramp)
49_seams_v02 24_seams with a density-aware hip landmark — the v01 rule fired
at u=0.610, mid-belly, on the head-protected vert count
50_seams_headuv body-only unwrap; the head KEEPS its original Tripo charts.
SLIM collapsed the undecimated lash/brow slivers to points and
ANGLE_BASED packed at half v01's texel density; the face was
the best-mapped region of the source atlas, so it is reused
51_reatlas_v02 31_reatlas + centroid splats for sub-texel triangles — the
skipped set IS the lashes, which rendered as grey glass
52_head_transplant the PRISTINE ORIGINAL head onto the decimated nude body
53_rig_transfer 54_crotch_refill
55_fullres_v02.blend is pinned in .lanekeep as THE archival master: full-res nude body
+ pristine original head, crotch refill and texture despeckle applied, 883,404 v /
1,761,640 f. It supersedes 34_v04 as the lane root (34_v04's head has the shard eyes).
Also: tools/graft_hands.py, the Marvelous Designer hunter-skirt configs v1-v8, the
hunter cloth texture generator, and Mako's measurement card.
Per .agents/rules/working-files.md the per-attempt .blend files under work/lena/v02
are SCRATCH ("never committed") — the .py recipes here are the history and regenerate
any of them from the pinned master. See the ignore rule landing next.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,176 @@
|
||||
# Stage 52 (v02): transplant the PRISTINE ORIGINAL head onto the decimated nude body.
|
||||
#
|
||||
# blender --background --python 52_head_transplant.py -- <body.blend> <out.blend>
|
||||
#
|
||||
# Why: bisecting the shard-eye defect (head renders per stage) proved it is in the hires MASTER,
|
||||
# not in any v02 step — the early heal chain ran whole-mesh welds and reset custom split normals,
|
||||
# and the eye/lash shells only read as an eye through Tripo's authored normals. Every descendant
|
||||
# inherits it, including the currently shipped game body. The one place the eyes are right is the
|
||||
# untouched original, so the head comes from there — geometry, normals, UVs and all.
|
||||
#
|
||||
# Why a cut works HERE and not at the underwear: the bra had no skin beneath it (ray census,
|
||||
# _Bare.glb's black cavity). The throat is ordinary skin on both sides of a plane, so both meshes
|
||||
# take a clean planar bisect and the two rings bridge.
|
||||
#
|
||||
# The two lineages keep their own materials: body faces sample the master's maps (with the nude
|
||||
# fill), head faces sample the ORIGINAL maps — the transfer stage resolves images per material.
|
||||
# Bridge faces are new geometry with no source UVs; each takes a single nearby body-ring UV
|
||||
# (all three corners the same texel), i.e. a flat splat of throat skin, invisible on a uniform
|
||||
# throat and never smearing across two different atlases.
|
||||
import bpy, bmesh, sys, os, time
|
||||
import numpy as np
|
||||
from mathutils import Vector
|
||||
|
||||
argv = sys.argv[sys.argv.index("--") + 1:]
|
||||
BODY_BLEND, OUT = argv[0], argv[1]
|
||||
ORIG_GLB = r"C:\Users\Jeremy\tinqs\animation\characters\originals\female\female_lena_tripo.glb"
|
||||
# TX_ZCUT env overrides the plane. The default 0.775 is what the SHIPPED hybrid used and must
|
||||
# keep reproducing it — but that plane clips the T-POSE ARMS, giving five boundary rings, and at
|
||||
# full density bridge_loops paired the wrong ones (a geometry shelf flared out of the shoulders).
|
||||
# For full-res builds use 0.80: pure throat, exactly one ring per side.
|
||||
Z_CUT = float(os.environ.get("TX_ZCUT", "0.775"))
|
||||
t0 = time.time()
|
||||
|
||||
|
||||
def log(m):
|
||||
print(f"[tx {time.time()-t0:6.1f}s] {m}", flush=True)
|
||||
|
||||
|
||||
def bisect_keep(ob, keep_above):
|
||||
bpy.ops.object.select_all(action='DESELECT')
|
||||
ob.select_set(True)
|
||||
bpy.context.view_layer.objects.active = ob
|
||||
bpy.ops.object.mode_set(mode='EDIT')
|
||||
bpy.ops.mesh.select_all(action='SELECT')
|
||||
# plane normal is +z, so OUTER = above the cut. clear_outer removes the ABOVE side —
|
||||
# the first run had these inverted and stitched the shard head onto the clothed body.
|
||||
bpy.ops.mesh.bisect(plane_co=(0, 0, Z_CUT), plane_no=(0, 0, 1),
|
||||
clear_inner=keep_above, clear_outer=not keep_above, use_fill=False)
|
||||
bpy.ops.object.mode_set(mode='OBJECT')
|
||||
|
||||
|
||||
bpy.ops.wm.open_mainfile(filepath=BODY_BLEND)
|
||||
body = max([o for o in bpy.data.objects if o.type == 'MESH'], key=lambda o: len(o.data.vertices))
|
||||
log(f"body in: {len(body.data.vertices)} v")
|
||||
bisect_keep(body, keep_above=False)
|
||||
log(f"body below z={Z_CUT}: {len(body.data.vertices)} v")
|
||||
# give the body a custom-normals layer BEFORE joining, or join drops the head's authored ones
|
||||
body.data.shade_smooth()
|
||||
try:
|
||||
ln = np.empty(len(body.data.loops) * 3, dtype=np.float32)
|
||||
body.data.corner_normals.foreach_get("vector", ln)
|
||||
body.data.normals_split_custom_set(ln.reshape(-1, 3))
|
||||
except Exception as e:
|
||||
log(f" custom-normal seed on body: {e}")
|
||||
|
||||
before = {o.name for o in bpy.data.objects}
|
||||
bpy.ops.import_scene.gltf(filepath=ORIG_GLB)
|
||||
new = [o for o in bpy.data.objects if o.name not in before]
|
||||
# drop importer widgets (glTF_not_exported) and non-meshes
|
||||
for o in list(new):
|
||||
if o.type != 'MESH' or any(c.name.startswith("glTF_not_exported") for c in o.users_collection):
|
||||
if o.type == 'MESH' or o.type == 'ARMATURE' or o.type == 'EMPTY':
|
||||
bpy.data.objects.remove(o, do_unlink=True)
|
||||
new.remove(o)
|
||||
head = max([o for o in new if o.type == 'MESH'], key=lambda o: len(o.data.vertices))
|
||||
bpy.ops.object.select_all(action='DESELECT')
|
||||
head.select_set(True)
|
||||
bpy.context.view_layer.objects.active = head
|
||||
bpy.ops.object.transform_apply(location=True, rotation=True, scale=True)
|
||||
log(f"original in: {len(head.data.vertices)} v")
|
||||
bisect_keep(head, keep_above=True)
|
||||
log(f"original above z={Z_CUT}: {len(head.data.vertices)} v")
|
||||
|
||||
# BOTH sides must share one UV layer name or join makes two half-empty layers and the head
|
||||
# samples texel (0,0) — measured: the whole head rendered as clay. The body blend's layer is
|
||||
# 'UVMap' (34_v04 lineage); rename both to UVMap_tripo so join merges them into one.
|
||||
for l in head.data.uv_layers:
|
||||
l.name = "UVMap_tripo"
|
||||
for l in body.data.uv_layers:
|
||||
l.name = "UVMap_tripo"
|
||||
|
||||
# remember a body-side ring UV for the bridge splat
|
||||
bm = bmesh.new(); bm.from_mesh(body.data)
|
||||
uvl = bm.loops.layers.uv.get("UVMap_tripo") or bm.loops.layers.uv.active
|
||||
ring_uv = None
|
||||
for v in bm.verts:
|
||||
if abs(v.co.z - Z_CUT) < 1e-4 and v.link_loops:
|
||||
# take the uv of a loop one step AWAY from the ring (interior throat texel)
|
||||
for l_ in v.link_loops:
|
||||
ring_uv = tuple(l_[uvl].uv)
|
||||
break
|
||||
if ring_uv:
|
||||
break
|
||||
bm.free()
|
||||
log(f"bridge splat uv: {ring_uv}")
|
||||
|
||||
# join: body is the target so its material stays slot 0; head material appends as slot 1
|
||||
n_faces_before_join = len(body.data.polygons)
|
||||
bpy.ops.object.select_all(action='DESELECT')
|
||||
body.select_set(True)
|
||||
head.select_set(True)
|
||||
bpy.context.view_layer.objects.active = body
|
||||
bpy.ops.object.join()
|
||||
me = body.data
|
||||
log(f"joined: {len(me.vertices)} v, {len(me.polygons)} f, materials {[m.name for m in me.materials]}")
|
||||
|
||||
# bridge the two rings
|
||||
n_faces_before_bridge = len(me.polygons)
|
||||
bm = bmesh.new(); bm.from_mesh(me)
|
||||
bm.edges.ensure_lookup_table()
|
||||
ring_edges = [e for e in bm.edges
|
||||
if len(e.link_faces) == 1
|
||||
and abs(e.verts[0].co.z - Z_CUT) < 1e-4 and abs(e.verts[1].co.z - Z_CUT) < 1e-4]
|
||||
# how many separate rings? more than 2 means the plane clipped limbs and the bridge will pair wrong
|
||||
_par = {}
|
||||
def _find(a):
|
||||
while _par.get(a, a) != a:
|
||||
_par[a] = _par.get(_par[a], _par[a]); a = _par[a]
|
||||
return a
|
||||
for e in ring_edges:
|
||||
a, b_ = _find(e.verts[0].index), _find(e.verts[1].index)
|
||||
if a != b_: _par[a] = b_
|
||||
_roots = {_find(e.verts[0].index) for e in ring_edges}
|
||||
log(f"ring boundary edges: {len(ring_edges)} in {len(_roots)} loops"
|
||||
+ (" <-- WARNING: >2 loops, plane clips limbs" if len(_roots) > 2 else ""))
|
||||
try:
|
||||
res = bmesh.ops.bridge_loops(bm, edges=ring_edges)
|
||||
new_faces = res.get("faces", [])
|
||||
except Exception as e:
|
||||
log(f"bridge_loops failed: {e}")
|
||||
new_faces = []
|
||||
log(f"bridge created {len(new_faces)} faces")
|
||||
uvl = bm.loops.layers.uv.get("UVMap_tripo") or bm.loops.layers.uv.active
|
||||
for f in new_faces:
|
||||
f.material_index = 0 # body material: throat skin
|
||||
f.smooth = True
|
||||
for l_ in f.loops:
|
||||
l_[uvl].uv = ring_uv # flat splat of one throat texel
|
||||
bm.to_mesh(me)
|
||||
bm.free()
|
||||
me.update()
|
||||
|
||||
# One material for the whole mesh. The transfer stage resamples every map into ONE new atlas,
|
||||
# so a second material would leave head faces pointing at untransferred images with replaced
|
||||
# UVs. Sampling the head from the MASTER's maps is exact: every texture pass in the lane masked
|
||||
# the head out, so its texels are the original's bit-for-bit.
|
||||
mi = np.zeros(len(me.polygons), dtype=np.int32)
|
||||
me.polygons.foreach_set("material_index", mi)
|
||||
while len(me.materials) > 1:
|
||||
me.materials.pop(index=1)
|
||||
log(f"unified material: {[m.name for m in me.materials]}")
|
||||
|
||||
# verify
|
||||
co = np.empty(len(me.vertices) * 3); me.vertices.foreach_get("co", co); co = co.reshape(-1, 3)
|
||||
bm = bmesh.new(); bm.from_mesh(me)
|
||||
open_ring = [e for e in bm.edges if len(e.link_faces) == 1
|
||||
and abs(e.verts[0].co.z - Z_CUT) < 1e-3]
|
||||
bm.free()
|
||||
print(f"\nVERIFY: {len(me.vertices)} v / {len(me.polygons)} f")
|
||||
print(f" height {co[:,2].max()-co[:,2].min():.5f} (feet {co[:,2].min():+.5f})")
|
||||
print(f" open boundary edges left at the cut: {len(open_ring)} (0 = ring fully bridged)")
|
||||
print(f" materials: {[m.name for m in me.materials]}")
|
||||
|
||||
bpy.ops.wm.save_as_mainfile(filepath=OUT)
|
||||
log(f"WROTE {OUT}")
|
||||
print("TRANSPLANT_DONE")
|
||||
Reference in New Issue
Block a user