214 lines
11 KiB
Python
214 lines
11 KiB
Python
|
|
#!/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()
|