ca1af52574
Ozan 2026-08-20: 'the leg is a tube pushing in and out, not just the line.' Proximity follow keyed off distance to the thigh AXIS, so cloth beside the stitch line followed the line while the leg's VOLUME swept through it. With SKIRT_FOLLOW_TUBE=1 the contact term is modulated by the vertex's radial direction around the leg (body forward measured off foot->ball): front sector +/-TUBE_FRONT deg follows at full strength, fading to TUBE_BACK behind. Default off — approved recipes regenerate bit-identical. point_seg_distance now also returns the closest point for the radial direction.
639 lines
34 KiB
Python
639 lines
34 KiB
Python
#!/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
|
|
|
|
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)
|
|
# 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
|
|
# 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)
|
|
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)
|
|
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}
|
|
per_strand = {k: 0.0 for k in body_ring}
|
|
thigh_total = 0.0
|
|
|
|
for v in pos:
|
|
w = {}
|
|
if v[1] > y_top - BAND:
|
|
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_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
|
|
strand_scale = 1.0 - pants_mix
|
|
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))
|
|
|
|
# ── gates FIRST: never leave a broken garment on disk ──
|
|
n = len(pos)
|
|
log(f"waistband (pelvis only): {stats['waistband']} verts")
|
|
log(f"mean thigh follow on panel verts: "
|
|
f"{stats['follow_sum'] / max(1, n - stats['waistband']):.3f}")
|
|
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()
|