feat(skirt-weights): partial-coverage + pants-top modes for the ariki-game pugu lane

Two env-gated extensions, defaults unchanged (full skirts behave exactly as before):
- SKIRT_ALLOW_DEAD=1 — a pugu loincloth has OPEN sides by design; its bare side
  strands downgrade the dead-strand FATAL to a warning.
- SKIRT_PANTS_TOP=<frac> + SKIRT_PANTS_MIX (0.65) — the garment's top fraction
  is skinned like the pack's own Peasant pants (measured: crotch cloth = 49/49
  both thighs, pelvis ≈ 0 below the waistband), mixed with the strand weights
  so leg colliders keep authority; blends into pure strands below.

Consumed by ariki-game tools/build_pugu_tifi.py garments (pugu/tapa skirts).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BLpZsSvLufgVri2bhvmAS6
This commit is contained in:
2026-08-19 03:49:25 +01:00
parent ce5ac32531
commit be1ee94701
+65 -4
View File
@@ -89,6 +89,17 @@ 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)
MAX_INFLUENCES = 4
COMP_FMT = {5120: "b", 5121: "B", 5122: "h", 5123: "H", 5125: "I", 5126: "f"}
@@ -350,6 +361,37 @@ def main():
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_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])
lo = None
@@ -386,10 +428,21 @@ def main():
follow = FOLLOW_MAX * contact * hem_fade
stats["follow_sum"] += follow
strand_scale = 1.0 - pants_mix
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 + 0.15))
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
if follow > 0.0:
w[slot[near]] = w.get(slot[near], 0.0) + follow
w[slot[near]] = w.get(slot[near], 0.0) + follow * strand_scale
for strand, sh in share.values():
ring = (1.0 - follow) * sh
ring = (1.0 - follow) * sh * strand_scale
if ring <= 0.0:
continue
segs, heads = strand["segs"], strand["heads"]
@@ -436,8 +489,16 @@ def main():
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:
raise SystemExit(f"[skirt_weights] FATAL: strands {dead} got no weight — "
"the ring is not covered, the spring sim will tear the skirt")
# 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,