feat(skirt-weights): inner-front anchor + import cache-bust

SKIRT_ANCHOR_IFP: pins the front-inner band of a garment to its thigh at
W=1.0 (asymmetric sector: 100deg inner / 135deg outer so back cloth never
pins to the thigh front) — the stitch answer to colliders being unable to
move strand root joints (probe-measured 32mm penetration at full stride).

save() now stamps asset.extras.skirt_weights_rev every run: weight-only
rewrites left the .gltf byte-identical, Godot's source_md5 check skipped
the reimport, and engines ran STALE weights (cost 2026-08-20 afternoon).
This commit is contained in:
2026-08-21 02:46:42 +01:00
parent ca1af52574
commit f6d880b7b8
+69 -1
View File
@@ -46,6 +46,7 @@ import math
import os
import struct
import sys
import time
HERE = os.path.dirname(os.path.abspath(__file__))
ANIM_ROOT = os.path.dirname(HERE)
@@ -139,6 +140,32 @@ PANTS_FROM = _envf("SKIRT_PANTS_FROM", 0.0)
TUBE = os.environ.get("SKIRT_FOLLOW_TUBE") == "1"
TUBE_FRONT = _envf("SKIRT_TUBE_FRONT", 45.0) # half-angle of the full-follow front sector, degrees
TUBE_BACK = _envf("SKIRT_TUBE_BACK", 0.25) # follow multiplier directly behind the leg
# INNER-FRONT ANCHOR (2026-08-20, Ozan watching the male run: "the front pugu can
# go UNDER the balls — attach the clothing to the blue dots' furthest points;
# as legs move, cloth stretches freely"). Push-only colliders can never stop the
# panel sliding under the pusher balls — attachment can. The front panel's
# front-INNER band (the diagonal the InnerFrontPush_* colliders ride) is pinned
# to its thigh at SKIRT_ANCHOR_W, so the advancing leg CARRIES its cloth and
# only the span between the two anchored bands stays spring-driven (the stretch
# membrane). Requires SKIRT_FOLLOW_TUBE=1 (the anchor sector keys off tube_fwd).
ANCHOR = os.environ.get("SKIRT_ANCHOR_IFP") == "1"
ANCHOR_W = _envf("SKIRT_ANCHOR_W", 1.0) # pin strength at the sector center
ANCHOR_TOP = _envf("SKIRT_ANCHOR_TOP", 0.04) # band, fraction of cloth drop
ANCHOR_BOT = _envf("SKIRT_ANCHOR_BOT", 0.58)
ANCHOR_FADE = _envf("SKIRT_ANCHOR_FADE", 0.06) # vertical edge fade (no hard step)
ANCHOR_FRONT = _envf("SKIRT_ANCHOR_FRONT", 0.90) # diagonal mix — matches SKIRT_IFP_*
ANCHOR_INNER = _envf("SKIRT_ANCHOR_INNER", 0.45)
# 2026-08-20 Ozan: "two legs work independently — stitch the cloth to the balls'
# outer direction" + "the cloth trying to make an inner curve shows body —
# disable it". Sector widened 55°→80°: each leg's whole front quadrant rides its
# thigh rigidly; only the narrow midline strip stays spring (the stretch zone).
ANCHOR_SECTOR = _envf("SKIRT_ANCHOR_SECTOR", 100.0) # half-angle toward INNER, degrees
# Probe finding (2026-08-20): the strand-top penetrations drive the panel's top
# OUTER corners (110135° from the diagonal — the hip/outer-front cloth), which
# the symmetric sector rejected; those corners are what slid under the flexed
# thigh. The outer side gets a wider gate; the inner side stays narrower so
# BACK-flap verts (147°+ from the diagonal) are never pinned to the thigh front.
ANCHOR_SECTOR_OUTER = _envf("SKIRT_ANCHOR_SECTOR_OUTER", 135.0)
# Azimuth spread, in strand-gap units (2026-08-19, long tifi): the classic
# 2-strand linear blend makes the hem fold PIECEWISE-LINEAR — mid-stride the
# tube creases into a hard triangle where the front-leg cloth meets the
@@ -253,6 +280,12 @@ class Gltf:
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:
@@ -413,7 +446,7 @@ def main():
f"cloth hangs {span:.3f} below the ring top")
out_j, out_w = [], []
stats = {"waistband": 0, "follow_sum": 0.0}
stats = {"waistband": 0, "follow_sum": 0.0, "anchored": 0}
per_strand = {k: 0.0 for k in body_ring}
thigh_total = 0.0
@@ -541,7 +574,39 @@ def main():
(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.
@@ -605,6 +670,9 @@ def main():
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())