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
+213
View File
@@ -0,0 +1,213 @@
#!/usr/bin/env python3
"""
make_skirt_rig_body.py — derive a skirt-boned COPY of a Quaternius-skeleton body.
"<BLENDER>" --background --python clothing/skirt_rig_body.py -- \
[src.glb] [dst.glb]
Takes Ariki_Female_QuatSkin.glb (default) and writes
Ariki_Female_QuatSkin_SkirtRig.glb: the identical body — every mesh, weight and
the full 65-bone skeleton untouched — plus a ring of 8 MULTI-SEGMENT deform
strands parented to `pelvis` (skirt_NN → skirt_NN_01 → …, waist perimeter down
to knee-hem), hanging straight down in rest.
Segment COUNT is load-bearing. The spring sim puts a collision particle at each
bone END and treats the chain root as a fixed anchor, so the panel can only be
pushed below its first joint. One segment collides at the hem alone and a lifted
knee strikes mid-panel unopposed; two put the first joint at mid-panel and the
thigh still clipped straight through the panel ABOVE it, at hip height, where no
particle existed (measured 2026-07-31). Over-fat capsules are not the answer —
they just rotate whole panels up and bare the thigh under the hem.
Why a copy: the shared skeleton is the hub the whole animation lane targets;
skirt bones stay opt-in (BODY_OVERRIDE / BodyOverridePath) until proven.
Nothing animates these bones — SkirtSpringRig (C#) attaches a
SpringBoneSimulator3D at runtime and lets thigh capsules push them around.
Garments weight to them via garment_pipeline.py's "skirt" weights mode, which
maps each vertex to the nearest two bones by azimuth — bone NAMES carry no
directional meaning, positions do.
"""
import math
import os
import sys
import bpy
ARGS = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else []
HERE = os.path.dirname(os.path.abspath(__file__))
ANIM_ROOT = os.path.dirname(HERE)
# This repo AUTHORS clothing; ariki-game only RECEIVES delivered outfits and bodies, so
# every asset path below points into the game checkout (a sibling of this one). Override
# with ARIKI_GAME_ROOT if the checkouts are not side by side.
GAME = os.environ.get("ARIKI_GAME_ROOT") or os.path.join(
os.path.dirname(ANIM_ROOT), "ariki-game")
SRC = ARGS[0] if len(ARGS) > 0 else os.path.join(
GAME, "assets/quaternius/derived-bodies/Ariki_Female_QuatSkin.glb")
DST = ARGS[1] if len(ARGS) > 1 else os.path.join(
GAME, "assets/quaternius/derived-bodies/Ariki_Female_QuatSkin_SkirtRig.glb")
N_BONES = 8
# Override without editing this file: SKIRT_STRANDS=32
# One chain per VISIBLE flax strand is the point of raising this. Measured 2026-07-31 on
# bottom_test1: the mesh has 32 strands (all 32 azimuth wedges occupied) but only 8 chains,
# so four strands moved as one stiff bundle. A discrete-strand garment also has no reason to
# blend a vertex across neighbouring chains, so one chain per strand both frees each strand
# AND drops each vertex from 4 influences to 2.
#
# CEILING: a garment's JOINTS_0 is UNSIGNED_BYTE, so its skin holds at most 256 joints. With
# 65 body bones that caps N_BONES * N_SEGMENTS at ~190 — 32x5 = 160 (225 total) fits, 32x8
# = 192 (257 total) does NOT. skirt_garment_weights.py gates on this rather than letting the
# joint index silently wrap.
if os.environ.get("SKIRT_STRANDS"):
N_BONES = int(os.environ["SKIRT_STRANDS"])
# Segments per strand. The spring sim puts a collision particle at each bone END and
# treats the chain ROOT as a fixed anchor, so N segments can only push the panel below
# the first joint: with 2 the highest particle was mid-panel (y~0.73) while the thigh
# clipped through at y 0.85-0.95, above every particle, uncorrectable (front view
# 2026-07-31). 4 puts the first joint at ~0.84 and gives the upper panel something to
# rotate. Raise it, don't lower it.
# NON-UNIFORM on purpose. Fractions of the waist→hem span, top-down. A short upper
# segment puts a particle high (0.25 → z 0.838, inside the hip-height band the thigh was
# clipping through) while the long lower segment stays the swinging panel. Uniform
# quarters would work as well but need 4 bones per strand, and a garment's skin has to
# carry every bone it references — adding segments means re-armaturing every skirt, while
# re-proportioning two costs nothing. Must sum to 1.0.
SEGMENT_FRACS = (0.25, 0.75)
# Override without editing this file: SKIRT_SEGMENT_FRACS=0.15,0.2,0.3,0.35
# Exists so a deeper ring can be built to a SIDE path and handed to the tailor lane while
# the live body keeps working — the garment's skin must carry every bone it references, so
# a body and its garments have to change segment count together.
if os.environ.get("SKIRT_SEGMENT_FRACS"):
SEGMENT_FRACS = tuple(float(x) for x in
os.environ["SKIRT_SEGMENT_FRACS"].replace(" ", "").split(","))
N_SEGMENTS = len(SEGMENT_FRACS)
# Ring radius is MEASURED per strand off the body silhouette, not scaled off the thigh.
# The old `thigh|x| * 1.2` put the whole ring at r=0.120 while the body runs 0.18-0.22 and
# the cloth sits at 0.19-0.24 through the same height band — every skirt bone was 6-10 cm
# INSIDE the hips. Collision then had to shove cloth outward from within the body, which
# flares the panel where it reaches and leaves cloth buried in the thigh where it does not
# (measured 2026-07-31). Hips are elliptical, so one radius cannot fit: each strand takes
# the widest body radius in its own azimuth wedge, plus this clearance.
RING_CLEARANCE = 0.015
TOP_LIFT = 0.02 # ring sits this far above the pelvis head (waistband pivot)
HEM_DROP = 0.02 # tails end this far below the knee (calf head)
def log(msg):
print(f"[skirt_rig] {msg}", flush=True)
def measure_ring_radii(rig, meshes, pelvis, z_lo, z_hi):
"""Widest body radius per strand azimuth, within the skirt's height band.
Each body vertex in the band is bucketed to the strand azimuth it is nearest,
and each strand takes the largest radius in its bucket. Vertices are pulled
into ARMATURE space (edit_bones are authored there) — mesh object space is not
the same thing once the rig has a transform. The band excludes the torso and
arms by construction, so this measures hips/thighs only."""
inv = rig.matrix_world.inverted()
step = 2 * math.pi / N_BONES
widest = [0.0] * N_BONES
counted = 0
# Only meshes actually bound to this rig describe the body. Ariki_Female_QuatSkin.glb
# also carries a stray unparented "Icosphere" (42 verts, radius 1.0) that sits right
# across the skirt band and, measured, reported hip radii of 0.5-0.9 m.
skinned = [o for o in meshes if o.parent == rig]
skipped = [o.name for o in meshes if o.parent != rig]
if skipped:
log(f"NOT measuring unrigged mesh(es): {', '.join(skipped)}")
if not skinned:
raise SystemExit("[skirt_rig] FATAL: no mesh is parented to the armature")
for o in skinned:
mw = o.matrix_world
for v in o.data.vertices:
p = inv @ (mw @ v.co)
if not (z_lo <= p.z <= z_hi):
continue
dx, dy = p.x - pelvis.x, p.y - pelvis.y
k = int(round(math.atan2(dy, dx) / step)) % N_BONES
r = math.hypot(dx, dy)
if r > widest[k]:
widest[k] = r
counted += 1
if counted < 100 or min(widest) <= 0.0:
raise SystemExit(f"[skirt_rig] FATAL: only {counted} body verts in the skirt band "
f"z {z_lo:.3f}-{z_hi:.3f} (widest={widest}) — cannot measure the "
"ring, refusing to fall back to a magic scale factor")
log(f"measured {counted} body verts in the skirt band")
return [w + RING_CLEARANCE for w in widest]
def main():
bpy.ops.wm.read_factory_settings(use_empty=True)
bpy.ops.import_scene.gltf(filepath=SRC)
rig = next(o for o in bpy.data.objects if o.type == "ARMATURE")
meshes = [o for o in bpy.data.objects if o.type == "MESH"]
log(f"imported {os.path.basename(SRC)}: {len(rig.data.bones)} bones, "
f"{len(meshes)} meshes ({sum(len(m.data.vertices) for m in meshes)} verts)")
for required in ("pelvis", "thigh_l", "calf_l"):
if required not in rig.data.bones:
raise SystemExit(f"[skirt_rig] FATAL: no '{required}' bone in {SRC}")
if "skirt_00" in rig.data.bones:
raise SystemExit(f"[skirt_rig] FATAL: {SRC} already has skirt bones")
# Landmarks in armature-local space (same space edit_bones are authored in).
pelvis = rig.data.bones["pelvis"].head_local
thigh_x = abs(rig.data.bones["thigh_l"].head_local.x)
knee_z = rig.data.bones["calf_l"].head_local.z
z_top = pelvis.z + TOP_LIFT
z_hem = knee_z - HEM_DROP
log(f"pelvis z={pelvis.z:.3f} thigh |x|={thigh_x:.3f} knee z={knee_z:.3f}")
radii = measure_ring_radii(rig, meshes, pelvis, z_hem, z_top)
log(f"ring z {z_top:.3f} -> {z_hem:.3f} (bone length {z_top - z_hem:.3f}); "
f"measured radii " + ", ".join(f"{r:.3f}" for r in radii))
if abs(sum(SEGMENT_FRACS) - 1.0) > 1e-6:
raise SystemExit(f"[skirt_rig] FATAL: SEGMENT_FRACS must sum to 1.0, "
f"got {sum(SEGMENT_FRACS)}")
span = z_top - z_hem
# Cumulative z of each segment head, plus the hem as the final entry.
knots = [z_top]
for f in SEGMENT_FRACS:
knots.append(knots[-1] - span * f)
bpy.context.view_layer.objects.active = rig
bpy.ops.object.mode_set(mode="EDIT")
eb = rig.data.edit_bones
pelvis_eb = eb["pelvis"]
for k in range(N_BONES):
az = 2 * math.pi * k / N_BONES
x = pelvis.x + radii[k] * math.cos(az)
y = pelvis.y + radii[k] * math.sin(az)
# Segment 0 is the strand ROOT (skirt_NN, child of pelvis); segments 1.. are
# skirt_NN_01, _02, ... The runtime finds roots by PARENTAGE, not by name — the
# strand at ring index 1 is itself "skirt_01", so a name-suffix test cannot tell
# a root from a tip (that bug dropped one panel from the sim entirely).
prev = None
for s in range(N_SEGMENTS):
name = f"skirt_{k:02d}" if s == 0 else f"skirt_{k:02d}_{s:02d}"
b = eb.new(name)
b.head = (x, y, knots[s])
b.tail = (x, y, knots[s + 1])
b.parent = pelvis_eb if s == 0 else prev
b.use_connect = s != 0
b.use_deform = True
prev = b
bpy.ops.object.mode_set(mode="OBJECT")
log(f"added {N_BONES} strands x {N_SEGMENTS} segments under pelvis; "
f"fracs {SEGMENT_FRACS}; collision particles at z "
+ ", ".join(f"{z:.3f}" for z in knots[1:]))
# Whole scene out — every mesh rides along, weights and skeleton untouched.
for o in bpy.data.objects:
o.select_set(True)
bpy.ops.export_scene.gltf(filepath=DST, export_format="GLB",
export_skins=True, export_animations=True,
export_yup=True)
log(f"EXPORTED {DST} ({os.path.getsize(DST)} bytes)")
main()