Files
animation/clothing/skirt_garment_weights.py
T

898 lines
51 KiB
Python
Raw Normal View History

#!/usr/bin/env python3
"""
skirt_garment_weights.py — weight a skirt garment to the skirt_* spring ring.
python clothing/skirt_garment_weights.py [garment.gltf]
(default: assets/quaternius/outfits/kapahaka/Female_KapahakaSB_Legs.gltf)
Rewrites JOINTS_0/WEIGHTS_0 in place on a garment whose skin ALREADY carries the
SAME skirt ring as the body it is weighted against — both the strand count and the
segment count are read off that body, and a mismatch is a hard failure, not a
silent partial rebind (see skirt_rig_body.py). Geometry, materials,
UVs and the joint list are untouched — only the skin weights are recomputed, and
they are derived from vertex POSITION, so re-running is idempotent.
Why this exists: the first skirt-boned piupiu was weighted by an ad-hoc script
that wasn't kept, and it was badly lopsided — strand 1 got ZERO weight, strand 2
got 8x strand 6, and only 31% of the garment sat on the ring at all (measured
2026-07-31). The spring sim then moved a few strands and left the rest skinned to
the thigh, so the skirt hitched to one side instead of hanging. Weights are the
product here, not the sim tuning.
The three influences, per vertex:
WAISTBAND (top few cm) → pelvis alone. A waistband that swings looks broken;
it must ride the hip exactly as the body does.
PANEL → the two nearest strands by AZIMUTH, blended linearly
across the 45 degree gap. Bone names carry no
directional meaning; ring positions do, so azimuths
are read off the bones rather than assumed from the
NN index. Uniform coverage is guaranteed by
construction — no strand can come out dead.
THIGH FOLLOW → mixed in by PROXIMITY to the thigh segment, so cloth
resting on the thigh is carried up WITH the knee and
keeps covering it. This is the difference between a
skirt that drapes over a raised knee and one the
spring flicks aside to bare the thigh. It fades out
over the bottom of the panel: the hem must stay free
for the spring to swing it, or the skirt turns into
a pair of trousers painted on the legs.
Godot takes only 4 influences per vertex, so the assembled set is truncated to
the 4 heaviest and renormalised.
"""
import json
import math
import os
import struct
import sys
import time
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")
DEFAULT = os.path.join(
GAME, "assets/quaternius/outfits/kapahaka/Female_KapahakaSB_Legs.gltf")
SRC = sys.argv[1] if len(sys.argv) > 1 else DEFAULT
BODY = sys.argv[2] if len(sys.argv) > 2 else os.path.join(
GAME, "assets/quaternius/derived-bodies/Ariki_Female_QuatSkin_SkirtRig.glb")
# Strand + segment counts are READ OFF THE RIG (see body_ring in main), never constants.
# Everything ABOVE the ring top is pelvis by necessity — no skirt bone reaches it.
# On the piupiu that is already ~870 verts (the garment runs to the true waist at
# y=1.113 while the ring starts at the hip, y=0.952, where a piupiu is actually
# tied), so this only adds a thin band BELOW the ring top and must stay small or
# it freezes the top third of the panel.
BAND = 0.02
# Thigh follow is the one influence that can LIFT cloth: collision only ever pushes, but a
# thigh-weighted vertex is carried wherever the thigh goes. At 0.55 the swinging leg hauled
# the hem up with it — the skirt rode up on the upswing while the standing side hung long
# (back view, walk 2026-07-31). Keep just enough to drape cloth over a raised knee, and let
# collision do the rest now that it actually runs.
# ENV-OVERRIDABLE (defaults unchanged — an unset env behaves exactly as before).
# Added because these were tuned against the SHORT continuous piupiu, and a longer
# strand skirt needs different numbers: bottomTest1 hangs to y 0.451, so HEM_FREE 0.55
# puts its whole KNEE region (y~0.517) inside the no-follow band and the knee comes
# straight through the cloth. Per-garment tuning without touching the shared defaults.
def _envf(key, default):
import os
try:
return float(os.environ[key])
except (KeyError, ValueError):
return default
FOLLOW_MAX = _envf("SKIRT_FOLLOW_MAX", 0.20)
CONTACT_R = _envf("SKIRT_CONTACT_R", 0.13) # at/inside this distance from the thigh axis = full contact
FALLOFF = _envf("SKIRT_FALLOFF", 0.10) # follow decays to 0 over this much extra distance
HEM_FREE = _envf("SKIRT_HEM_FREE", 0.55) # bottom fraction of panel with NO thigh follow
# PANTS TOP (2026-08-19, measured off the pack's own Peasant pants): Quaternius skins
# crotch-spanning cloth to BOTH thighs (~49/49 at the center, pelvis ≈ 0 below the
# waistband). SKIRT_PANTS_TOP=<frac> gives the garment's top fraction that same
# treatment — thigh pair split by lateral position, pelvis fading — and blends into
# the strand weights below it. 0 (default) = off, pure strand skirt.
PANTS_TOP = _envf("SKIRT_PANTS_TOP", 0.0)
# Cap on the pants share: cloth with NO strand weight is collision-DEAF (colliders
# only move strand bones), and a fully pants-weighted midline gets sliced by the
# advancing thigh's inner edge (the 50/50 average can't move with one leg). Keeping
# ~35% strand share lets the cage push the cloth forward off the leg.
PANTS_MIX = _envf("SKIRT_PANTS_MIX", 0.65)
# LATERAL EDGE MIX (2026-08-19, Superhero male pugu): a hem corner at PANTS_MIX
# 0.65 follows only 65% of its thigh's swing — the remaining strand share is
# pelvis-anchored, so the thigh surface overtakes the cloth by 35% of its travel.
# That deficit scales with the thigh's front protrusion, which is why only the
# widest body showed it ("kiyafet alt koseleri bacak hala yiyor"). Verts near the
# panel's lateral edges belong unambiguously to ONE thigh, so they can follow it
# almost fully; the midline keeps PANTS_MIX (its 50/50 thigh average must stay
# collision-driven or the advancing thigh's inner edge slices it — measured).
# < 0 (default) = off, edges behave exactly like the midline.
PANTS_MIX_EDGE = _envf("SKIRT_PANTS_MIX_EDGE", -1.0)
# SOLID-4 pants zone (2026-08-19, female pugu): in the mixed pants zone a vertex
# can accumulate 7-8 influences (pelvis + both thighs + strand-pair x segment-pair
# + proximity follow) and the top-4 truncation keeps DIFFERENT survivor sets on
# neighbouring verts — mid-swing they diverge and single-vertex holes open in the
# panel. With SKIRT_PANTS_SOLID4=1 the pants zone builds exactly four influences
# (pelvis, thigh_l, thigh_r, nearest strand's height-matched segment): nothing is
# ever truncated, neighbours stay consistent. The strand share is small there
# (<= 1-PANTS_MIX), so collapsing its azimuth/segment blends is invisible.
PANTS_SOLID4 = os.environ.get("SKIRT_PANTS_SOLID4") == "1"
# Lower bound of the pants zone (fraction of garment height from the top).
# Bone-rigid cloth CANNOT track the hip-crease skin bulge at peak thigh flexion —
# on the female pugu the crease punched holes through the pants-weighted upper
# panel that no static clearance could absorb (2026-08-19). Above this line the
# cloth stays strand-driven, where the thigh cage + groin spheres own the
# interaction (that regime never holed). 0 (default) = pants all the way up.
PANTS_FROM = _envf("SKIRT_PANTS_FROM", 0.0)
# MIDLINE GATE (2026-08-22, wrap leg-springs architecture): when > 0, the pants
# split applies ONLY inside ±<deg> of the forward axis — the front SECTORS
# beyond it fall through to the classic strand+follow path, whose strands the
# runtime re-roots onto the thighs (SKIRT_LEG_SPRINGS), so that cloth is carried
# by leg springs instead of a rigid thigh split. The crotch midline keeps the
# pack's pants answer; sides/back (|az| ≥ 80°) keep pants exactly as before.
# 0 (default) = off, pants everywhere as before.
PANTS_MIDLINE_AZ = _envf("SKIRT_PANTS_MIDLINE_AZ", 0.0)
# FOLLOW TUBE (2026-08-20, Ozan: "the leg is a tube pushing in and out, not just the
# line — govern the front quarter"): proximity follow keys off distance to the thigh
# AXIS, so cloth beside the stitch line follows the line while the leg's VOLUME
# sweeps through it. With SKIRT_FOLLOW_TUBE=1 the contact term is modulated by the
# vertex's radial direction around the leg: the FRONT sector (±TUBE_FRONT degrees
# of the body's forward axis, measured off foot→ball) follows at full strength,
# fading to TUBE_BACK directly behind. The advancing leg's front face then carries
# its cloth; cloth behind the leg barely follows (it never gets hauled upward on
# the backswing — the ride-up channel stays shut). 0 (default) = legacy radial
# follow, approved recipes bit-identical.
TUBE = os.environ.get("SKIRT_FOLLOW_TUBE") == "1"
TUBE_FRONT = _envf("SKIRT_TUBE_FRONT", 45.0) # half-angle of the full-follow front sector, degrees
TUBE_BACK = _envf("SKIRT_TUBE_BACK", 0.25) # follow multiplier directly behind the leg
# INNER-FRONT ANCHOR (2026-08-20, Ozan watching the male run: "the front pugu can
# go UNDER the balls — attach the clothing to the blue dots' furthest points;
# as legs move, cloth stretches freely"). Push-only colliders can never stop the
# panel sliding under the pusher balls — attachment can. The front panel's
# front-INNER band (the diagonal the InnerFrontPush_* colliders ride) is pinned
# to its thigh at SKIRT_ANCHOR_W, so the advancing leg CARRIES its cloth and
# only the span between the two anchored bands stays spring-driven (the stretch
# membrane). Requires SKIRT_FOLLOW_TUBE=1 (the anchor sector keys off tube_fwd).
ANCHOR = os.environ.get("SKIRT_ANCHOR_IFP") == "1"
ANCHOR_W = _envf("SKIRT_ANCHOR_W", 1.0) # pin strength at the sector center
ANCHOR_TOP = _envf("SKIRT_ANCHOR_TOP", 0.04) # band, fraction of cloth drop
ANCHOR_BOT = _envf("SKIRT_ANCHOR_BOT", 0.58)
ANCHOR_FADE = _envf("SKIRT_ANCHOR_FADE", 0.06) # vertical edge fade (no hard step)
ANCHOR_FRONT = _envf("SKIRT_ANCHOR_FRONT", 0.90) # diagonal mix — matches SKIRT_IFP_*
ANCHOR_INNER = _envf("SKIRT_ANCHOR_INNER", 0.45)
# 2026-08-20 Ozan: "two legs work independently — stitch the cloth to the balls'
# outer direction" + "the cloth trying to make an inner curve shows body —
# disable it". Sector widened 55°→80°: each leg's whole front quadrant rides its
# thigh rigidly; only the narrow midline strip stays spring (the stretch zone).
ANCHOR_SECTOR = _envf("SKIRT_ANCHOR_SECTOR", 100.0) # half-angle toward INNER, degrees
# Probe finding (2026-08-20): the strand-top penetrations drive the panel's top
# OUTER corners (110135° from the diagonal — the hip/outer-front cloth), which
# the symmetric sector rejected; those corners are what slid under the flexed
# thigh. The outer side gets a wider gate; the inner side stays narrower so
# BACK-flap verts (147°+ from the diagonal) are never pinned to the thigh front.
ANCHOR_SECTOR_OUTER = _envf("SKIRT_ANCHOR_SECTOR_OUTER", 135.0)
# Azimuth spread, in strand-gap units (2026-08-19, long tifi): the classic
# 2-strand linear blend makes the hem fold PIECEWISE-LINEAR — mid-stride the
# tube creases into a hard triangle where the front-leg cloth meets the
# back-leg cloth ("strict triangle ... should be smoothed ... more like cloth").
# > 0 blends each vertex across every strand within this many gaps using a
# raised-cosine kernel, so folds curve instead of cornering. 0 = classic.
AZ_SPREAD = _envf("SKIRT_AZ_SPREAD", 0.0)
# WAISTBAND RIDE BAND (2026-08-22, wrap skirt — PokeDiag-measured): the slab
# above the strand ring top is pelvis=1.00 by construction ("no skirt bone
# reaches it"), and at ~90° hip flexion the advancing thigh's front surface
# sweeps straight THROUGH that rigid cloth over each thigh front (worst vert
# measured 9.5cm inside the skin, rest az ±45°). Horizontal ease can't help a
# vert that never moves. SKIRT_RIDE_FRONT=<share> gives the pelvis-forced
# verts in the front sector (|az off forward| in [RIDE_AZ_LO, RIDE_AZ_HI]) a
# thigh split instead, so the rising thigh CARRIES that cloth over its front.
# The midline and the back stay pelvis-rigid (a swinging center/back waistband
# looks broken). 0 (default) = off, approved recipes bit-identical.
RIDE_FRONT = _envf("SKIRT_RIDE_FRONT", 0.0)
RIDE_AZ_LO = _envf("SKIRT_RIDE_AZ_LO", 30.0)
RIDE_AZ_HI = _envf("SKIRT_RIDE_AZ_HI", 65.0)
RIDE_AZ_FADE = _envf("SKIRT_RIDE_AZ_FADE", 12.0) # waistband edge fade, degrees
# Only apply the ride BELOW this drop fraction (2026-08-23, wrap rope): the
# belly rope ring sits at the very top of the garment and must stay pure
# pelvis — a thigh-riding rope ring distorts mid-stride. 0 (default) = whole
# band rides (backwards compatible).
RIDE_T_LO = _envf("SKIRT_RIDE_T_LO", 0.0)
# HARD FRONT PIN (2026-08-22, wrap — PokeDiag: hem-tip verts at 84% thigh still
# sink 3.7cm into the advancing thigh at 90° flexion; the leftover pelvis+strand
# share is the drag, and the ANCHOR only redistributes the strand share, never
# the pants/pelvis share). SKIRT_PIN_FRONT=<share> post-passes EVERY vert in the
# front zone (az PIN_AZ_LOHI off forward, drop fraction PIN_T_LOHI) so its
# thigh total is AT LEAST <share> — 1.0 = glued to the thigh surface, "the bone
# that is already there" (Ozan: "can you just put the bone where the error
# persists"). 0 (default) = off.
PIN_FRONT = _envf("SKIRT_PIN_FRONT", 0.0)
# BACK half of the pin zone (2026-08-23, wrap run-from-behind: back hem wedges
# opened at full split). Same window/snap/seam as the front, measured off the
# BACK centerline. 0 (default) = back unpinned (approved-garment safe).
PIN_BACK = _envf("SKIRT_PIN_BACK", 0.0)
PIN_AZ_LO = _envf("SKIRT_PIN_AZ_LO", 20.0)
PIN_AZ_HI = _envf("SKIRT_PIN_AZ_HI", 70.0)
PIN_T_LO = _envf("SKIRT_PIN_T_LO", -0.4)
PIN_T_HI = _envf("SKIRT_PIN_T_HI", 1.0)
PIN_AZ_FADE = _envf("SKIRT_PIN_AZ_FADE", 12.0) # edge fade width, degrees
# FLAP SNAP (2026-08-22, run cycle): inside the pin zone the L/R split factor
# f_l is a smooth ramp over hip width, so front-center verts land ~0.5/0.5
# (or 0.7/0.3) across BOTH thighs — at full leg split those verts park between
# the legs and the advancing thigh swallows them (PokeDiag run: pen -2.5cm at
# az~180 hem point, w 0.68-0.88 mixed). SKIRT_PIN_SNAP=1 snaps f_l to the
# vert's OWN side (±0.05 smooth transition at the centerline): the front hangs
# as two flaps, each glued to its own thigh, like a real wrap's overlap.
PIN_SNAP = _envf("SKIRT_PIN_SNAP", 0.0)
# CENTER STRETCH SEAM (2026-08-22 v31, Ozan: "two skirt ends connect at the
# center but don't cross the leg — stretch logic can be there"): with the pure
# snap the front opens into a V down to the crotch at full leg split. Verts
# within ±PIN_SEAM degrees of the front centerline override the snap with a
# RAMP from the flap's own thigh (zone edge) to an exact 50/50 split
# (centerline) — a narrow membrane that TIES the flaps together, stretches
# between the legs, and parks at the midpoint BETWEEN them (never inside a
# leg). 2026-08-23: flat 50/50 band → ramp (its 1.0→0.5 step read as a cut).
PIN_SEAM = _envf("SKIRT_PIN_SEAM", 0.0) # degrees each side of front center
# SPATIAL WEIGHT SMOOTHING (2026-08-23, Ozan run review: "running creates too
# many small triangles at the bottom — can't we smooth those stretch
# triangles"): stretch notches are NEIGHBOURING verts with divergent weight
# vectors shearing apart mid-stride. Laplacian pass after the main loop: each
# vert blends SMOOTH_W toward the mean of its SMOOTH_K nearest rest-position
# neighbors — SAME snap side only (the centerline snap/tie discontinuity must
# survive) and the top band is skipped (rope stays pure pelvis). 0 = off
# (default; approved recipes reproduce bit-identical).
SMOOTH_W = _envf("SKIRT_SMOOTH_W", 0.0)
SMOOTH_K = int(_envf("SKIRT_SMOOTH_K", 4.0))
MAX_INFLUENCES = 4
COMP_FMT = {5120: "b", 5121: "B", 5122: "h", 5123: "H", 5125: "I", 5126: "f"}
NCOMP = {"SCALAR": 1, "VEC2": 2, "VEC3": 3, "VEC4": 4, "MAT4": 16}
def log(msg):
print(f"[skirt_weights] {msg}", flush=True)
def smoothstep(x):
x = max(0.0, min(1.0, x))
return x * x * (3.0 - 2.0 * x)
def mat4_inverse_translation(m):
"""Translation of inverse(m), where m is a glTF 4x4 — COLUMN-major, so the
flat index for row r / col c is c*4+r (reading it row-major silently yields
the transpose, which lands every bone at ~the origin). Gauss-Jordan rather
than a rigid-transform shortcut because IBMs may carry scale.
Returns (x, y, z) — the bone head in the space POSITION lives in."""
a = [[m[c * 4 + r] for c in range(4)] + [1.0 if r == i else 0.0 for i in range(4)]
for r in range(4)]
for col in range(4):
piv = max(range(col, 4), key=lambda r: abs(a[r][col]))
if abs(a[piv][col]) < 1e-12:
raise ValueError("singular inverse-bind matrix")
a[col], a[piv] = a[piv], a[col]
d = a[col][col]
a[col] = [v / d for v in a[col]]
for r in range(4):
if r != col and a[r][col] != 0.0:
f = a[r][col]
a[r] = [v - f * w for v, w in zip(a[r], a[col])]
return (a[0][7], a[1][7], a[2][7]) # inverse's 4th column = translation
def point_seg_distance(p, a, b):
"""Distance from p to segment ab (thigh head→knee), plus the closest point
(the follow tube needs the radial direction around the leg, not just the gap)."""
ab = tuple(b[i] - a[i] for i in range(3))
ap = tuple(p[i] - a[i] for i in range(3))
ll = sum(v * v for v in ab)
t = 0.0 if ll < 1e-12 else max(0.0, min(1.0, sum(ap[i] * ab[i] for i in range(3)) / ll))
closest = tuple(a[i] + ab[i] * t for i in range(3))
return math.sqrt(sum((p[i] - closest[i]) ** 2 for i in range(3))), closest
class Gltf:
def __init__(self, path):
self.path = path
if path.lower().endswith(".glb"):
# GLB: 12-byte header, then JSON chunk, then the BIN chunk.
with open(path, "rb") as f:
blob = f.read()
_, _, _ = struct.unpack_from("<III", blob, 0)
clen, _ = struct.unpack_from("<II", blob, 12)
self.d = json.loads(blob[20:20 + clen])
blen, _ = struct.unpack_from("<II", blob, 20 + clen)
start = 20 + clen + 8
self.buf = bytearray(blob[start:start + blen])
self.bin_path = None # GLB is read-only here (body donor)
return
with open(path) as f:
self.d = json.load(f)
self.bin_path = os.path.join(os.path.dirname(path), self.d["buffers"][0]["uri"])
with open(self.bin_path, "rb") as f:
self.buf = bytearray(f.read())
def read(self, idx):
a = self.d["accessors"][idx]
bv = self.d["bufferViews"][a["bufferView"]]
off = bv.get("byteOffset", 0) + a.get("byteOffset", 0)
fmt = COMP_FMT[a["componentType"]]
n = NCOMP[a["type"]]
size = struct.calcsize("<" + fmt * n)
stride = bv.get("byteStride") or size
return [struct.unpack_from("<" + fmt * n, self.buf, off + i * stride)
for i in range(a["count"])]
def overwrite(self, idx, rows):
"""Rewrite an accessor's bytes IN PLACE, keeping its declared layout.
Appending fresh bufferViews instead would orphan the old ones and grow
the .bin on every run, so the tool would stop being safely re-runnable.
Both skin attributes here own a tightly-packed dedicated bufferView, so
an in-place write is exact — verified against the layout, not assumed."""
a = self.d["accessors"][idx]
bv = self.d["bufferViews"][a["bufferView"]]
fmt = COMP_FMT[a["componentType"]]
n = NCOMP[a["type"]]
size = struct.calcsize("<" + fmt * n)
if len(rows) != a["count"]:
raise SystemExit(f"[skirt_weights] FATAL: accessor {idx} holds "
f"{a['count']} rows, got {len(rows)}")
if bv.get("byteStride") not in (None, size) or bv["byteLength"] != size * len(rows):
raise SystemExit(f"[skirt_weights] FATAL: accessor {idx} shares or "
"interleaves its bufferView — in-place write unsafe")
if a["componentType"] == 5121 and any(v > 255 for row in rows for v in row):
raise SystemExit("[skirt_weights] FATAL: joint index over 255 will not fit "
"the garment's unsigned-byte JOINTS_0")
off = bv.get("byteOffset", 0) + a.get("byteOffset", 0)
for i, row in enumerate(rows):
struct.pack_into("<" + fmt * n, self.buf, off + i * size, *row)
def save(self):
self.d["buffers"][0]["byteLength"] = len(self.buf)
# CACHE-BUST (2026-08-20, cost an afternoon): weight-only rewrites leave the
# .gltf byte-identical → Godot's source_md5 check skips the reimport and the
# engine keeps running STALE weights. Stamp a changing extras token so the
# md5 moves every run and the importer always picks the new .bin up.
self.d.setdefault("asset", {})["extras"] = {
"skirt_weights_rev": time.time_ns()}
with open(self.bin_path, "wb") as f:
f.write(self.buf)
with open(self.path, "w") as f:
json.dump(self.d, f, separators=(",", ":"))
def main():
g = Gltf(SRC)
skin = g.d["skins"][0]
joints = skin["joints"]
names = [g.d["nodes"][j].get("name") for j in joints]
slot = {n: i for i, n in enumerate(names)}
for required in ("pelvis", "thigh_l", "thigh_r", "calf_l", "calf_r", "skirt_00"):
if required not in slot:
raise SystemExit(f"[skirt_weights] FATAL: no '{required}' joint in {SRC} "
"— is this a *_SkirtRig-skinned garment?")
# ── REBIND the skirt bones from the body ────────────────────────────────────
# The garment's inverseBindMatrices describe the ring it was armatured against.
# Re-proportioning the ring in make_skirt_rig_body.py moves those bones, and a
# garment still bound to the OLD ring is skinned to a skeleton that no longer
# exists — it deforms toward stale rest positions. Names all still resolve, so no
# name gate catches it; only the positions differ. Copy the body's skirt-bone
# binds over so the garment is bound to the ring it will actually be worn on.
body = Gltf(BODY)
body_skin = body.d["skins"][0]
body_names = [body.d["nodes"][j].get("name") for j in body_skin["joints"]]
body_ibms = body.read(body_skin["inverseBindMatrices"])
body_ibm = {n: m for n, m in zip(body_names, body_ibms) if n}
# Forward axis for the follow tube, measured off the BODY's foot→ball (toe)
# direction in the horizontal plane — never an assumed axis constant.
tube_fwd = None
if TUBE:
if "foot_l" in body_ibm and "ball_l" in body_ibm:
fa = mat4_inverse_translation(body_ibm["foot_l"])
ta = mat4_inverse_translation(body_ibm["ball_l"])
dx, dz = ta[0] - fa[0], ta[2] - fa[2]
n = math.hypot(dx, dz)
if n > 1e-6:
tube_fwd = (dx / n, dz / n)
if tube_fwd is None:
log("WARNING: SKIRT_FOLLOW_TUBE=1 but no foot_l/ball_l on the body — "
"falling back to legacy radial follow")
ibms = g.read(skin["inverseBindMatrices"])
# ── The RIG defines the ring; the garment must carry ALL of it ──────────────
# STRAND count comes from the body, exactly like segment count. Hardcoding
# either lets a garment silently keep an old ring. Measured failure: a 32-chain
# body against an 8-chain garment rebound only the 8 names that happened to
# match, then weighted a ring the mesh was never fitted to — 46:1 strand
# imbalance, garbage on the character, and NO gate fired, because the old gate
# compared the garment's bones against the garment's own joint list (2026-07-31).
body_ring = {}
for nm in body_ibm:
if not nm.startswith("skirt_"):
continue
parts = nm.split("_")
if len(parts) < 2 or not parts[1].isdigit():
continue
body_ring.setdefault(int(parts[1]), set()).add(nm)
if not body_ring:
raise SystemExit("[skirt_weights] FATAL: no skirt_* bones in "
f"{os.path.basename(BODY)} — is it a *_SkirtRig body?")
absent = sorted(b for bones in body_ring.values() for b in bones if b not in slot)
if absent:
raise SystemExit(
f"[skirt_weights] FATAL: {os.path.basename(BODY)} has "
f"{len(body_ring)} strands / {sum(len(v) for v in body_ring.values())} skirt "
f"bones, but this garment's skin cannot reference {len(absent)} of them "
f"(e.g. {absent[:4]}).\nA garment can only be weighted to a ring its SKIN "
"carries. Re-export the garment against this body first (the tailor lane's "
"pipeline picks up every armature bone automatically), then re-run this.")
stale = sorted(n for n in names
if n and n.startswith("skirt_") and n not in body_ibm)
if stale:
raise SystemExit(
f"[skirt_weights] FATAL: this garment's skin carries {len(stale)} skirt bones "
f"that do not exist on {os.path.basename(BODY)} (e.g. {stale[:4]}) — it was "
"fitted to a DIFFERENT ring. Re-export against this body, or point --body at "
"the one it was built for.")
rebound, moved = 0, 0.0
for i, nm in enumerate(names):
if nm is None or not nm.startswith("skirt_") or nm not in body_ibm:
continue
before = mat4_inverse_translation(ibms[i])
after = mat4_inverse_translation(body_ibm[nm])
moved = max(moved, math.dist(before, after))
ibms[i] = body_ibm[nm]
rebound += 1
log(f"rebound {rebound} skirt joints from {os.path.basename(BODY)} "
f"({len(body_ring)} strands) — largest bind move {moved * 100:.1f} cm")
# NOTE: the IBM write is deliberately AFTER the gates above. It used to run first,
# so a rejected garment still had its bind matrices rewritten on the way out.
g.overwrite(skin["inverseBindMatrices"], ibms)
origin = {}
for i, nm in enumerate(names):
if nm is None:
continue
origin[nm] = mat4_inverse_translation(ibms[i])
# glTF is Y-up: vertical is Y, the ring azimuth lives in the XZ plane.
pelvis = origin["pelvis"]
strands = []
for k in sorted(body_ring):
# Segment count comes from the rig, not a constant here — make_skirt_rig_body.py
# owns N, and reading it back keeps the two tools from silently disagreeing.
segs = [f"skirt_{k:02d}"]
while f"{segs[0]}_{len(segs):02d}" in origin:
segs.append(f"{segs[0]}_{len(segs):02d}")
top = origin[segs[0]]
# Segment HEAD heights are the real boundaries. Segment lengths are NOT uniform
# (the rig deliberately uses a short upper segment), so nothing here may assume
# span/N. The final segment simply owns everything below its head — the hem is a
# bone TAIL, which glTF does not store, and deriving it is unnecessary.
strands.append({
"k": k, "segs": segs, "top_y": top[1],
"heads": [origin[s][1] for s in segs],
"az": math.atan2(top[2] - pelvis[2], top[0] - pelvis[0]),
})
strands.sort(key=lambda s: s["az"])
n_seg = len(strands[0]["segs"])
log(f"{n_seg} segments per strand (read off the rig), "
f"boundaries y " + ", ".join(f"{y:.3f}" for y in strands[0]["heads"]))
y_top = max(s["top_y"] for s in strands)
ring_drop = y_top - min(s["heads"][-1] for s in strands)
ring_radius = max(math.dist((s_top[0], s_top[2]), (pelvis[0], pelvis[2]))
for s_top in (origin[s["segs"][0]] for s in strands))
log(f"ring: {len(strands)} strands, top y {y_top:.3f}, radius {ring_radius:.3f}, "
f"last boundary {y_top - ring_drop:.3f}")
if ring_drop < 0.02 or ring_radius < 0.03:
raise SystemExit("[skirt_weights] FATAL: the ring has collapsed to a point, so "
"the bone origins are wrong — check the inverseBindMatrices "
"read (glTF matrices are COLUMN-major) before trusting weights")
for s in strands:
missing = [b for b in s["segs"] if b not in slot]
if missing:
raise SystemExit(f"[skirt_weights] FATAL: the rig has bones this garment's "
f"skin cannot reference: {missing}. A garment must be "
"re-armatured onto the current SkirtRig skeleton before it "
"can be weighted to it.")
legs = [("thigh_l", origin["thigh_l"], origin["calf_l"]),
("thigh_r", origin["thigh_r"], origin["calf_r"])]
prim = g.d["meshes"][0]["primitives"][0]
pos = g.read(prim["attributes"]["POSITION"])
# The vertical ramp (segment pick, thigh-follow hem fade) is measured against the
# CLOTH, not the ring: the last bone's tail is the hem and glTF stores no tails, and
# measuring to the last bone HEAD instead made the ramp collapse into the top 11 cm
# the moment the ring stopped being an even 50/50 split.
span = y_top - min(p[1] for p in pos)
log(f"{os.path.basename(SRC)}: {len(pos)} verts, "
f"cloth hangs {span:.3f} below the ring top")
out_j, out_w = [], []
stats = {"waistband": 0, "follow_sum": 0.0, "anchored": 0, "ride": 0, "pinned": 0}
per_strand = {k: 0.0 for k in body_ring}
thigh_total = 0.0
for v in pos:
w = {}
# ── hard front pin (see the SKIRT_PIN_FRONT header): MUST run before the
# pants/SOLID4 branches — those pack and `continue`, so a post-pass after
# them never sees the hem verts (first attempt changed nothing, measured).
pinned = False
if (PIN_FRONT > 0.0 or PIN_BACK > 0.0) and tube_fwd is not None:
fwd_ang = math.atan2(tube_fwd[1], tube_fwd[0])
rel = abs((math.atan2(v[2] - pelvis[2], v[0] - pelvis[0])
- fwd_ang + math.pi) % (2 * math.pi) - math.pi)
t_rel = (y_top - v[1]) / span if span > 1e-9 else 0.0
# Edge-faded zone (Ozan 2026-08-22: "lower parts create triangle on
# the left while walking"): a HARD pin boundary tears a wedge where
# the pinned sector hugs the leg and the free midline hangs. Fade
# the share to 0 across PIN_AZ_FADE degrees on both sides instead.
# 2026-08-23 BACK zone (Ozan, run from behind: "the back side has
# triangles, extra stuff laying around — back of leg pushes
# cloth"): the same wedge opened at the BACK hem center at full
# split. d = angular distance to the NEAREST centerline (front or
# back); the back half pins with PIN_BACK, ties with the same seam.
fade = math.radians(PIN_AZ_FADE)
d = rel if rel <= math.pi / 2 else math.pi - rel
zone = PIN_FRONT if rel <= math.pi / 2 else PIN_BACK
strength = (smoothstep((d - (math.radians(PIN_AZ_LO) - fade)) / fade)
* (1.0 - smoothstep((d - math.radians(PIN_AZ_HI)) / fade)))
if strength > 0.0 and zone > 0.0 and PIN_T_LO <= t_rel <= PIN_T_HI:
share = zone * strength
hip_half = max(0.02, abs(origin["thigh_l"][0] - pelvis[0]))
side = max(-1.0, min(1.0, (v[0] - pelvis[0]) / hip_half))
sign_l = 1.0 if origin["thigh_l"][0] >= pelvis[0] else -1.0
f_l = 0.5 + 0.5 * side * sign_l
if PIN_SNAP > 0.0:
f_l = smoothstep((side * sign_l + 0.05) / 0.10)
if PIN_SEAM > 0.0 and d < math.radians(PIN_SEAM):
# TIE the flap ends (2026-08-23, Ozan: "a sharp cut, two
# separate lines at full split — tie those end surfaces"):
# a FLAT 50/50 seam band steps 1.0→0.5 at its boundary and
# the step reads as a cut. Ramp f_l from the flap's own
# thigh at the zone edge to 0.5 at the centerline instead —
# the stretch distributes across the whole seam, no step.
u = d / math.radians(PIN_SEAM)
f_own = 1.0 if (side * sign_l) >= 0.0 else 0.0
f_l = 0.5 + (f_own - 0.5) * u
w[slot["pelvis"]] = 1.0 - share
w[slot["thigh_l"]] = share * f_l
w[slot["thigh_r"]] = share * (1.0 - f_l)
stats["pinned"] += 1
pinned = True
if pinned:
pass
elif v[1] > y_top - BAND:
# Waistband ride band (see the SKIRT_RIDE_FRONT header): front-sector
# waistband verts get a thigh split so the advancing thigh CARRIES the
# junction cloth over its front instead of sweeping through it.
ridden = False
t_rel_r = (y_top - v[1]) / span if span > 1e-9 else 0.0
if RIDE_FRONT > 0.0 and tube_fwd is not None and t_rel_r >= RIDE_T_LO:
fwd_ang = math.atan2(tube_fwd[1], tube_fwd[0])
rel = abs((math.atan2(v[2] - pelvis[2], v[0] - pelvis[0])
- fwd_ang + math.pi) % (2 * math.pi) - math.pi)
fade_r = math.radians(RIDE_AZ_FADE)
if (math.radians(RIDE_AZ_LO) - fade_r <= rel
<= math.radians(RIDE_AZ_HI) + fade_r):
# Edge fade (2026-08-22 v31, Ozan: "too many triangles on
# top skirt, looks distorted"): the hard 22°/65° boundary
# put pelvis=1.0 verts next to pelvis=0.15 verts along the
# waistband — a weight step that animates as a sawtooth top
# edge. Fade the share over RIDE_AZ_FADE degrees both sides.
r_str = (smoothstep((rel - (math.radians(RIDE_AZ_LO) - fade_r)) / fade_r)
* (1.0 - smoothstep((rel - math.radians(RIDE_AZ_HI)) / fade_r)))
if r_str > 0.0:
ride = RIDE_FRONT * r_str
hip_half = max(0.02, abs(origin["thigh_l"][0] - pelvis[0]))
side = max(-1.0, min(1.0, (v[0] - pelvis[0]) / hip_half))
sign_l = 1.0 if origin["thigh_l"][0] >= pelvis[0] else -1.0
f_l = 0.5 + 0.5 * side * sign_l
w[slot["pelvis"]] = 1.0 - ride
w[slot["thigh_l"]] = ride * f_l
w[slot["thigh_r"]] = ride * (1.0 - f_l)
stats["ride"] += 1
ridden = True
if not ridden:
w[slot["pelvis"]] = 1.0
stats["waistband"] += 1
else:
# ── PANTS-TOP zone: the pack's own crotch answer (thigh pair, no strands) ──
t_cloth = max(0.0, min(1.0, (y_top - v[1]) / span)) if span > 1e-9 else 0.0
pants_mix = 0.0
if PANTS_TOP > 0.0:
blend_band = 0.15
if t_cloth < PANTS_TOP:
pants_mix = PANTS_MIX
elif t_cloth < PANTS_TOP + blend_band:
pants_mix = PANTS_MIX * (1.0 - (t_cloth - PANTS_TOP) / blend_band)
if PANTS_FROM > 0.0 and t_cloth < PANTS_FROM:
# fade pants IN across the same-width band below the line
pants_mix *= max(0.0, 1.0 - (PANTS_FROM - t_cloth) / blend_band)
if (PANTS_MIDLINE_AZ > 0.0 and tube_fwd is not None):
fwd_ang = math.atan2(tube_fwd[1], tube_fwd[0])
rel = abs((math.atan2(v[2] - pelvis[2], v[0] - pelvis[0])
- fwd_ang + math.pi) % (2 * math.pi) - math.pi)
deg = math.degrees(rel)
if PANTS_MIDLINE_AZ < deg < 80.0:
pants_mix = 0.0 # front sectors → strand/leg springs
if pants_mix > 0.0:
hip_half = max(0.02, abs(origin["thigh_l"][0] - pelvis[0]))
side = max(-1.0, min(1.0, (v[0] - pelvis[0]) / hip_half))
sign_l = 1.0 if origin["thigh_l"][0] >= pelvis[0] else -1.0
f_l = 0.5 + 0.5 * side * sign_l
pelvis_w = 0.15 * (1.0 - t_cloth / max(1e-5, PANTS_TOP + blend_band))
pants = {slot["pelvis"]: pelvis_w,
slot["thigh_l"]: (1.0 - pelvis_w) * f_l,
slot["thigh_r"]: (1.0 - pelvis_w) * (1.0 - f_l)}
if pants_mix >= 1.0:
for j, x in pants.items():
w[j] = w.get(j, 0.0) + x
out_j_w_done = True
top4 = sorted(w.items(), key=lambda kv: -kv[1])[:MAX_INFLUENCES]
total = sum(x for _, x in top4) or 1.0
top4 = [(j, x / total) for j, x in top4]
while len(top4) < MAX_INFLUENCES:
top4.append((0, 0.0))
out_j.append(tuple(j for j, _ in top4))
out_w.append(tuple(x for _, x in top4))
stats["follow_sum"] += 0.0
continue
# ── azimuth → the two neighbouring strands, linear across the gap ──
az = math.atan2(v[2] - pelvis[2], v[0] - pelvis[0])
if AZ_SPREAD > 0.0:
gap = 2 * math.pi / max(1, len(strands))
width = AZ_SPREAD * gap
share = {}
for st in strands:
d = abs((az - st["az"] + math.pi) % (2 * math.pi) - math.pi)
if d < width:
share[id(st)] = (st, 0.5 * (1.0 + math.cos(math.pi * d / width)))
if not share:
nearest = min(strands, key=lambda st: abs((az - st["az"] + math.pi)
% (2 * math.pi) - math.pi))
share[id(nearest)] = (nearest, 1.0)
total = sum(x for _, x in share.values())
share = {k: (st, x / total) for k, (st, x) in share.items()}
else:
share = None
lo = None
for i, s in enumerate(strands) if share is None else []:
nxt = strands[(i + 1) % len(strands)]
d = (nxt["az"] - s["az"]) % (2 * math.pi)
off = (az - s["az"]) % (2 * math.pi)
if off <= d:
lo, hi, frac = s, nxt, (off / d if d > 1e-9 else 0.0)
break
if share is None:
if lo is None: # numerically outside every gap
lo = hi = min(strands, key=lambda s: abs((az - s["az"] + math.pi)
% (2 * math.pi) - math.pi))
frac = 0.0
share = {id(lo): (lo, 1.0 - frac)}
if id(hi) in share:
share[id(hi)] = (hi, share[id(hi)][1] + frac)
else:
share[id(hi)] = (hi, frac)
# ── height → which segment(s) of the strand ──
# Blend across the neighbouring pair rather than snapping, or the seam
# between segments creases when the spring bends the strand.
t = max(0.0, min(1.0, (y_top - v[1]) / span)) if span > 1e-9 else 0.0
# ── thigh follow by proximity, faded out across the free hem ──
near, dist, radial = None, 1e9, None
for bone, a, b in legs:
dd, cp = point_seg_distance(v, a, b)
if dd < dist:
near, dist = bone, dd
radial = (v[0] - cp[0], v[2] - cp[2])
contact = 1.0 - smoothstep((dist - CONTACT_R) / FALLOFF)
if tube_fwd is not None and radial is not None:
# Quarter-tube govern (Ozan 2026-08-20): the leg's front face owns
# its cloth; the back of the leg barely follows.
rl = math.hypot(radial[0], radial[1])
if rl > 1e-6:
cos_fwd = (radial[0] * tube_fwd[0] + radial[1] * tube_fwd[1]) / rl
cos_front = math.cos(math.radians(TUBE_FRONT))
if cos_fwd < cos_front:
# 0 at the sector edge → 1 directly behind the leg
u = (cos_front - cos_fwd) / (cos_front + 1.0)
contact *= 1.0 - (1.0 - TUBE_BACK) * smoothstep(u)
hem_fade = 1.0 - smoothstep((t - (1.0 - HEM_FREE)) / HEM_FREE)
follow = FOLLOW_MAX * contact * hem_fade
stats["follow_sum"] += follow
if pants_mix > 0.0:
hip_half = max(0.02, abs(origin["thigh_l"][0] - pelvis[0]))
side = max(-1.0, min(1.0, (v[0] - pelvis[0]) / hip_half))
if PANTS_MIX_EDGE >= 0.0:
pants_mix = pants_mix + (PANTS_MIX_EDGE - pants_mix) * abs(side)
sign_l = 1.0 if origin["thigh_l"][0] >= pelvis[0] else -1.0
f_l = 0.5 + 0.5 * side * sign_l
pelvis_w = 0.15 * (1.0 - t_cloth / max(1e-5, PANTS_TOP + 0.15))
if os.environ.get("SKIRT_BAND_RAMP") == "1":
# Ramp out of the pelvis-1.0 waistband over 5cm — the hard
# step from band to pants opened a single-vert speck at the
# boundary mid-swing (2026-08-19). Env-gated: OFF reproduces
# the approved male pugu weights bit-for-bit.
band_ramp = max(0.0, 1.0 - (y_top - BAND - v[1]) / 0.05)
pelvis_w = max(pelvis_w, band_ramp)
for j, x in ((slot["pelvis"], pelvis_w),
(slot["thigh_l"], (1.0 - pelvis_w) * f_l),
(slot["thigh_r"], (1.0 - pelvis_w) * (1.0 - f_l))):
w[j] = w.get(j, 0.0) + x * pants_mix
# ── inner-front anchor strength for this vertex (0 = free cloth) ──
anchor = 0.0
if (ANCHOR and tube_fwd is not None and near is not None
and radial is not None and ANCHOR_TOP <= t <= ANCHOR_BOT):
rl = math.hypot(radial[0], radial[1])
if rl > 1e-6:
sign_l = 1.0 if origin["thigh_l"][0] >= pelvis[0] else -1.0
inner_x = -sign_l if near == "thigh_l" else sign_l
dx = tube_fwd[0] * ANCHOR_FRONT + inner_x * ANCHOR_INNER
dz = tube_fwd[1] * ANCHOR_FRONT
dn = math.hypot(dx, dz)
if dn > 1e-6:
cos_d = (radial[0] * dx + radial[1] * dz) / (rl * dn)
# Signed side of the diagonal: inner vs outer (mirrored
# per leg — inner_x flips the 2D cross sign).
cross = dx * radial[1] - dz * radial[0]
lim = (ANCHOR_SECTOR if cross * (-inner_x) > 0.0
else ANCHOR_SECTOR_OUTER)
cos_sec = math.cos(math.radians(lim))
if cos_d > cos_sec:
af = smoothstep((cos_d - cos_sec) / max(1e-6, 1.0 - cos_sec))
vf = (smoothstep((t - ANCHOR_TOP) / ANCHOR_FADE)
* (1.0 - smoothstep((t - (ANCHOR_BOT - ANCHOR_FADE))
/ ANCHOR_FADE)))
anchor = ANCHOR_W * af * vf
strand_scale = 1.0 - pants_mix
if ANCHOR and anchor > 0.0:
# Rigid pin of the front-inner band to its thigh; the ring keeps
# only what's left, so the panel between the bands is the stretch
# membrane (Ozan 2026-08-20).
w[slot[near]] = w.get(slot[near], 0.0) + anchor * strand_scale
stats["anchored"] += 1
strand_scale *= 1.0 - anchor
if PANTS_SOLID4 and pants_mix > 0.0:
# exactly ONE strand bone: nearest strand by azimuth, its
# height-matched segment — total influence count stays at 4.
best = max(share.values(), key=lambda p: p[1])[0]
segs, heads = best["segs"], best["heads"]
si = len(segs) - 1
for i in range(len(heads) - 1):
if v[1] > heads[i + 1]:
si = i
break
w[slot[segs[si]]] = w.get(slot[segs[si]], 0.0) + strand_scale
per_strand[best["k"]] += strand_scale
top4 = sorted(w.items(), key=lambda kv: -kv[1])[:MAX_INFLUENCES]
total = sum(x for _, x in top4) or 1.0
top4 = [(j, x / total) for j, x in top4]
while len(top4) < MAX_INFLUENCES:
top4.append((0, 0.0))
out_j.append(tuple(j for j, _ in top4))
out_w.append(tuple(x for _, x in top4))
continue
if follow > 0.0:
w[slot[near]] = w.get(slot[near], 0.0) + follow * strand_scale
for strand, sh in share.values():
ring = (1.0 - follow) * sh * strand_scale
if ring <= 0.0:
continue
segs, heads = strand["segs"], strand["heads"]
# Continuous position in SEGMENT-INDEX units, from the real boundaries —
# segment lengths are non-uniform, so t * len(segs) would misplace it.
# Segment i spans [heads[i+1], heads[i]]; below the last head everything
# belongs to the last bone.
f = float(len(segs) - 1)
for i in range(len(heads) - 1):
if v[1] > heads[i + 1]:
h = heads[i] - heads[i + 1]
f = i + (0.0 if h < 1e-9
else max(0.0, min(1.0, (heads[i] - v[1]) / h)))
break
i0 = min(len(segs) - 1, int(f))
i1 = min(len(segs) - 1, i0 + 1)
frac_seg = f - i0
for si, part in ((i0, 1.0 - frac_seg), (i1, frac_seg)):
if part <= 0.0:
continue
s = slot[segs[si]]
w[s] = w.get(s, 0.0) + ring * part
per_strand[strand["k"]] += ring
thigh_total += follow
# Godot reads 4 influences — keep the heaviest and renormalise.
top4 = sorted(w.items(), key=lambda kv: -kv[1])[:MAX_INFLUENCES]
total = sum(x for _, x in top4) or 1.0
top4 = [(j, x / total) for j, x in top4]
while len(top4) < MAX_INFLUENCES:
top4.append((0, 0.0))
out_j.append(tuple(j for j, _ in top4))
out_w.append(tuple(x for _, x in top4))
# ── spatial weight smoothing post-pass (see the SKIRT_SMOOTH_W header) ──
if SMOOTH_W > 0.0:
dense = [{j: x for j, x in zip(js, ws) if x > 0.0}
for js, ws in zip(out_j, out_w)]
side_of = [1.0 if p[0] >= pelvis[0] else -1.0 for p in pos]
t_rel_of = [(y_top - p[1]) / span if span > 1e-9 else 0.0 for p in pos]
for i in range(len(pos)):
if t_rel_of[i] < PIN_T_LO: # rope / top band: keep pure pelvis
continue
d2 = sorted(((pos[i][0] - pos[j][0]) ** 2
+ (pos[i][1] - pos[j][1]) ** 2
+ (pos[i][2] - pos[j][2]) ** 2, j)
for j in range(len(pos))
if j != i and side_of[j] == side_of[i])[:SMOOTH_K]
acc = {}
for _, j in d2:
for b, x in dense[j].items():
acc[b] = acc.get(b, 0.0) + x / SMOOTH_K
blended = {b: (1.0 - SMOOTH_W) * dense[i].get(b, 0.0) + SMOOTH_W * acc.get(b, 0.0)
for b in set(dense[i]) | set(acc)}
top4 = sorted(blended.items(), key=lambda kv: -kv[1])[:MAX_INFLUENCES]
total = sum(x for _, x in top4) or 1.0
top4 = [(j, x / total) for j, x in top4]
while len(top4) < MAX_INFLUENCES:
top4.append((0, 0.0))
out_j[i] = tuple(j for j, _ in top4)
out_w[i] = tuple(x for _, x in top4)
log(f"spatial smooth: W={SMOOTH_W} K={SMOOTH_K} "
f"(same-side only, top band skipped)")
# ── gates FIRST: never leave a broken garment on disk ──
n = len(pos)
log(f"waistband (pelvis only): {stats['waistband']} verts")
if RIDE_FRONT > 0.0:
log(f"waistband ride band: {stats['ride']} verts thigh-split "
f"(share {RIDE_FRONT:.2f}, az {RIDE_AZ_LO:.0f}{RIDE_AZ_HI:.0f}°)")
log(f"mean thigh follow on panel verts: "
f"{stats['follow_sum'] / max(1, n - stats['waistband']):.3f}")
if ANCHOR:
log(f"inner-front anchor: {stats['anchored']} verts pinned "
f"(band {ANCHOR_TOP:.2f}{ANCHOR_BOT:.2f}, W={ANCHOR_W:.2f})")
log("per-strand ring weight (must be non-zero everywhere):")
for k in sorted(per_strand):
peak = max(per_strand.values())
bar = "#" * int(per_strand[k] / peak * 40) if peak else ""
log(f" strand {k}: {per_strand[k]:7.1f} {bar}")
dead = [k for k in sorted(per_strand) if per_strand[k] <= 0.0]
if dead:
# PARTIAL-COVERAGE garments (SKIRT_ALLOW_DEAD=1): a pugu loincloth is a front
# panel + back flap with OPEN sides by design — the side strands legitimately
# carry no cloth, and a bare strand just swings unskinned (harmless). Full
# skirts keep the hard gate: a dead strand under cloth tears the skirt.
if os.environ.get("SKIRT_ALLOW_DEAD") == "1":
log(f"WARNING: strands {dead} carry no weight — accepted "
"(SKIRT_ALLOW_DEAD=1, partial-coverage garment)")
else:
raise SystemExit(f"[skirt_weights] FATAL: strands {dead} got no weight — "
"the ring is not covered, the spring sim will tear the skirt")
# Per-strand totals track VERTEX DENSITY per azimuth, not correctness — a
# wrapped piupiu really does carry more geometry at the front overlap. The
# gate that matters is the one above: every strand must be driving something,
# or the spring sim pulls a panel the mesh can't follow and the skirt tears.
log(f"heaviest/lightest strand ratio {max(per_strand.values()) / max(1e-9, min(per_strand.values())):.2f} "
"(density, not a defect — the dead-strand gate above is the real check)")
g.overwrite(prim["attributes"]["JOINTS_0"], out_j)
g.overwrite(prim["attributes"]["WEIGHTS_0"], out_w)
g.save()
log(f"WROTE {SRC} + {os.path.basename(g.bin_path)} ({len(g.buf)} bytes)")
main()