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:
2026-08-06 15:55:43 -07:00
parent 3363209cac
commit 3ba86b2ea8
558 changed files with 68622 additions and 8 deletions
@@ -0,0 +1,128 @@
# Stage 5b: global rosy-tone grade. Hard vertex-mask repaints leave visible boundaries
# (lighter V patch vs darker shoulders, pink neck above the z-cap). Instead: per-texel,
# compute the SMOOTHED red-green excess over the belly reference and subtract it with a
# z-tapered weight. Smooth field in, smooth field out — no boundaries; the high-frequency
# detail (pores, grain) rides on top untouched. Face is excluded by the taper (w=0 above
# z 0.91); lips/cheeks keep their red.
# blender --background --python 05b_tone_grade.py -- <07_textured.blend> <out.blend>
import bpy, sys, time
import numpy as np
argv = sys.argv[sys.argv.index("--") + 1:]
BLEND, OUT = argv[0], argv[1]
t0 = time.time()
REF_RG = None # belly reference; measured from the mesh below if None
STRENGTH = 0.90
Z_UP0, Z_UP1 = 0.26, 0.30 # taper in above the feet
Z_DN0, Z_DN1 = 0.88, 0.91 # taper out below the face
BLUR_R = 12
def log(m):
print(f"[grade {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)
base = next(i for i in bpy.data.images if "basecolor" in i.name.lower())
w, h = base.size
buf = np.empty(w * h * 4, dtype=np.float32)
base.pixels.foreach_get(buf)
rgb = buf.reshape(h, w, 4)
out_rgb = rgb[:, :, :3].astype(np.float64)
loops_v = np.empty(len(me.loops), dtype=np.int32)
me.loops.foreach_get("vertex_index", loops_v)
uv = np.empty(len(me.loops) * 2)
me.uv_layers.active.data.foreach_get("uv", uv)
uv = uv.reshape(-1, 2)
lx = np.clip(uv[:, 0], 0, 1) * (w - 1)
ly = np.clip(uv[:, 1], 0, 1) * (h - 1)
# belly reference from the mesh (sample texels under belly verts)
first_loop = np.full(n_v, len(loops_v), dtype=np.int64)
np.minimum.at(first_loop, loops_v, np.arange(len(loops_v), dtype=np.int64))
first_loop = np.minimum(first_loop, len(loops_v) - 1)
vcol = out_rgb[ly[first_loop].astype(int), lx[first_loop].astype(int)]
belly = (co[:, 1] < 0) & (co[:, 2] > 0.45) & (co[:, 2] < 0.60) & (np.abs(co[:, 0]) < 0.08)
ref = REF_RG if REF_RG is not None else float(np.median(vcol[belly, 0] - vcol[belly, 1]))
log(f"belly ref rg: {ref:.3f}")
def wz(z):
a = np.clip((z - Z_UP0) / (Z_UP1 - Z_UP0), 0, 1)
b = np.clip((Z_DN1 - z) / (Z_DN1 - Z_DN0), 0, 1)
t = np.minimum(a, b)
return t * t * (3 - 2 * t)
# rasterise the per-texel weight from vertex z over ALL faces
n_f = len(me.polygons)
l_tot = np.empty(n_f, dtype=np.int32)
me.polygons.foreach_get("loop_total", l_tot)
l_start = np.empty(n_f, dtype=np.int32)
me.polygons.foreach_get("loop_start", l_start)
vw = wz(co[:, 2])
W = np.zeros((h, w))
for fi in range(n_f):
s, t = l_start[fi], l_tot[fi]
li = np.arange(s, s + min(t, 3))
V = loops_v[li]
if vw[V].max() <= 0:
continue
P = np.stack([lx[li], ly[li]], axis=1)
x0, x1 = int(P[:, 0].min()), int(np.ceil(P[:, 0].max()))
y0, y1 = int(P[:, 1].min()), int(np.ceil(P[:, 1].max()))
if x1 - x0 > 256 or y1 - y0 > 256 or x1 < x0 or y1 < y0:
continue
d = ((P[1, 1] - P[2, 1]) * (P[0, 0] - P[2, 0]) +
(P[2, 0] - P[1, 0]) * (P[0, 1] - P[2, 1]))
if abs(d) < 1e-12:
continue
gx, gy = np.meshgrid(np.arange(x0, min(x1, w - 1) + 1),
np.arange(y0, min(y1, h - 1) + 1))
a = ((P[1, 1] - P[2, 1]) * (gx - P[2, 0]) + (P[2, 0] - P[1, 0]) * (gy - P[2, 1])) / d
b = ((P[2, 1] - P[0, 1]) * (gx - P[2, 0]) + (P[0, 0] - P[2, 0]) * (gy - P[2, 1])) / d
c = 1.0 - a - b
ins = (a >= -0.03) & (b >= -0.03) & (c >= -0.03)
if not ins.any():
continue
wv = a[ins] * vw[V[0]] + b[ins] * vw[V[1]] + c[ins] * vw[V[2]]
W[gy[ins], gx[ins]] = np.maximum(W[gy[ins], gx[ins]], np.clip(wv, 0, 1))
log(f"weight rasterised: {(W > 0).sum()} texels")
def box_blur1(a2, r):
def b1(x, axis):
p = [(0, 0)] * x.ndim
p[axis] = (r, r)
cs = np.cumsum(np.pad(x, p, mode="edge"), axis=axis)
return (np.take(cs, np.arange(2 * r, cs.shape[axis]), axis=axis) -
np.take(cs, np.arange(0, cs.shape[axis] - 2 * r), axis=axis)) / (2 * r)
return b1(b1(a2, 0), 1)
# smoothed rg field, weighted by W so face/eye texels can't bleed into the blur across
# island borders more than locally
rg_s = box_blur1(out_rgb[:, :, 0], BLUR_R) - box_blur1(out_rgb[:, :, 1], BLUR_R)
delta = np.maximum(0.0, rg_s - ref) * W * STRENGTH
out_rgb[:, :, 0] -= delta * 0.8
out_rgb[:, :, 1] += delta * 0.2
log(f"graded: {(delta > 0.005).sum()} texels above 0.005, max delta {delta.max():.3f}")
buf4 = rgb.copy()
buf4[:, :, :3] = np.clip(out_rgb, 0, 1).astype(np.float32)
base.pixels.foreach_set(buf4.reshape(-1))
base.pack()
bpy.context.preferences.filepaths.save_version = 0 # no .blend1 autosave
bpy.ops.wm.save_as_mainfile(filepath=OUT)
log(f"WROTE {OUT}")
print("GRADE_DONE")