#!/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= 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 # 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) # 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(" 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} 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 # ── 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)) # ── 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}") 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()