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,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()
|
||||
Reference in New Issue
Block a user