2026-08-06 15:55:43 -07:00
|
|
|
|
#!/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
|
2026-08-21 02:46:42 +01:00
|
|
|
|
import time
|
2026-08-06 15:55:43 -07:00
|
|
|
|
|
|
|
|
|
|
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
|
2026-08-19 03:49:25 +01:00
|
|
|
|
# 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)
|
2026-08-19 08:03:46 +01:00
|
|
|
|
# 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)
|
2026-08-20 18:33:10 +01:00
|
|
|
|
# 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
|
2026-08-21 02:46:42 +01:00
|
|
|
|
# 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 (110–135° 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)
|
2026-08-19 08:03:46 +01:00
|
|
|
|
# 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)
|
2026-08-06 15:55:43 -07:00
|
|
|
|
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):
|
2026-08-20 18:33:10 +01:00
|
|
|
|
"""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)."""
|
2026-08-06 15:55:43 -07:00
|
|
|
|
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))
|
2026-08-20 18:33:10 +01:00
|
|
|
|
return math.sqrt(sum((p[i] - closest[i]) ** 2 for i in range(3))), closest
|
2026-08-06 15:55:43 -07:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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)
|
2026-08-21 02:46:42 +01:00
|
|
|
|
# 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()}
|
2026-08-06 15:55:43 -07:00
|
|
|
|
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}
|
|
|
|
|
|
|
2026-08-20 18:33:10 +01:00
|
|
|
|
# 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")
|
|
|
|
|
|
|
2026-08-06 15:55:43 -07:00
|
|
|
|
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 = [], []
|
2026-08-21 02:46:42 +01:00
|
|
|
|
stats = {"waistband": 0, "follow_sum": 0.0, "anchored": 0}
|
2026-08-06 15:55:43 -07:00
|
|
|
|
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:
|
2026-08-19 03:49:25 +01:00
|
|
|
|
# ── 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)
|
2026-08-19 08:03:46 +01:00
|
|
|
|
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)
|
2026-08-19 03:49:25 +01:00
|
|
|
|
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
|
2026-08-06 15:55:43 -07:00
|
|
|
|
# ── azimuth → the two neighbouring strands, linear across the gap ──
|
|
|
|
|
|
az = math.atan2(v[2] - pelvis[2], v[0] - pelvis[0])
|
2026-08-19 08:03:46 +01:00
|
|
|
|
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
|
2026-08-06 15:55:43 -07:00
|
|
|
|
lo = None
|
2026-08-19 08:03:46 +01:00
|
|
|
|
for i, s in enumerate(strands) if share is None else []:
|
2026-08-06 15:55:43 -07:00
|
|
|
|
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
|
2026-08-19 08:03:46 +01:00
|
|
|
|
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)
|
2026-08-06 15:55:43 -07:00
|
|
|
|
|
|
|
|
|
|
# ── 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 ──
|
2026-08-20 18:33:10 +01:00
|
|
|
|
near, dist, radial = None, 1e9, None
|
2026-08-06 15:55:43 -07:00
|
|
|
|
for bone, a, b in legs:
|
2026-08-20 18:33:10 +01:00
|
|
|
|
dd, cp = point_seg_distance(v, a, b)
|
2026-08-06 15:55:43 -07:00
|
|
|
|
if dd < dist:
|
|
|
|
|
|
near, dist = bone, dd
|
2026-08-20 18:33:10 +01:00
|
|
|
|
radial = (v[0] - cp[0], v[2] - cp[2])
|
2026-08-06 15:55:43 -07:00
|
|
|
|
contact = 1.0 - smoothstep((dist - CONTACT_R) / FALLOFF)
|
2026-08-20 18:33:10 +01:00
|
|
|
|
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)
|
2026-08-06 15:55:43 -07:00
|
|
|
|
hem_fade = 1.0 - smoothstep((t - (1.0 - HEM_FREE)) / HEM_FREE)
|
|
|
|
|
|
follow = FOLLOW_MAX * contact * hem_fade
|
|
|
|
|
|
stats["follow_sum"] += follow
|
|
|
|
|
|
|
2026-08-19 03:49:25 +01:00
|
|
|
|
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))
|
2026-08-19 08:03:46 +01:00
|
|
|
|
if PANTS_MIX_EDGE >= 0.0:
|
|
|
|
|
|
pants_mix = pants_mix + (PANTS_MIX_EDGE - pants_mix) * abs(side)
|
2026-08-19 03:49:25 +01:00
|
|
|
|
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))
|
2026-08-19 08:03:46 +01:00
|
|
|
|
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)
|
2026-08-19 03:49:25 +01:00
|
|
|
|
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
|
2026-08-21 02:46:42 +01:00
|
|
|
|
# ── 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
|
2026-08-19 08:03:46 +01:00
|
|
|
|
strand_scale = 1.0 - pants_mix
|
2026-08-21 02:46:42 +01:00
|
|
|
|
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
|
2026-08-19 08:03:46 +01:00
|
|
|
|
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
|
2026-08-06 15:55:43 -07:00
|
|
|
|
if follow > 0.0:
|
2026-08-19 03:49:25 +01:00
|
|
|
|
w[slot[near]] = w.get(slot[near], 0.0) + follow * strand_scale
|
2026-08-06 15:55:43 -07:00
|
|
|
|
for strand, sh in share.values():
|
2026-08-19 03:49:25 +01:00
|
|
|
|
ring = (1.0 - follow) * sh * strand_scale
|
2026-08-06 15:55:43 -07:00
|
|
|
|
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}")
|
2026-08-21 02:46:42 +01:00
|
|
|
|
if ANCHOR:
|
|
|
|
|
|
log(f"inner-front anchor: {stats['anchored']} verts pinned "
|
|
|
|
|
|
f"(band {ANCHOR_TOP:.2f}–{ANCHOR_BOT:.2f}, W={ANCHOR_W:.2f})")
|
2026-08-06 15:55:43 -07:00
|
|
|
|
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:
|
2026-08-19 03:49:25 +01:00
|
|
|
|
# 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")
|
2026-08-06 15:55:43 -07:00
|
|
|
|
# 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()
|