From f6d880b7b8b385b6460bc8745114b05852ffe864 Mon Sep 17 00:00:00 2001 From: Ozan Bozkurt Date: Fri, 21 Aug 2026 02:46:42 +0100 Subject: [PATCH] feat(skirt-weights): inner-front anchor + import cache-bust MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- clothing/skirt_garment_weights.py | 70 ++++++++++++++++++++++++++++++- 1 file changed, 69 insertions(+), 1 deletion(-) diff --git a/clothing/skirt_garment_weights.py b/clothing/skirt_garment_weights.py index b731551..ef50ff1 100644 --- a/clothing/skirt_garment_weights.py +++ b/clothing/skirt_garment_weights.py @@ -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 (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 @@ -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())