feat(skirt-weights): SKIRT_FOLLOW_TUBE — directional front-quarter thigh follow

Ozan 2026-08-20: 'the leg is a tube pushing in and out, not just the line.'
Proximity follow keyed off distance to the thigh AXIS, so cloth beside the
stitch line followed the line while the leg's VOLUME swept through it. With
SKIRT_FOLLOW_TUBE=1 the contact term is modulated by the vertex's radial
direction around the leg (body forward measured off foot->ball): front sector
+/-TUBE_FRONT deg follows at full strength, fading to TUBE_BACK behind.
Default off — approved recipes regenerate bit-identical. point_seg_distance
now also returns the closest point for the radial direction.
This commit is contained in:
2026-08-20 18:33:10 +01:00
parent 8964309329
commit ca1af52574
+45 -4
View File
@@ -126,6 +126,19 @@ PANTS_SOLID4 = os.environ.get("SKIRT_PANTS_SOLID4") == "1"
# 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
# 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
@@ -171,13 +184,14 @@ def mat4_inverse_translation(m):
def point_seg_distance(p, a, b):
"""Distance from p to segment ab (thigh head→knee)."""
"""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)))
return math.sqrt(sum((p[i] - closest[i]) ** 2 for i in range(3))), closest
class Gltf:
@@ -270,6 +284,21 @@ def main():
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 ──────────────
@@ -471,12 +500,24 @@ def main():
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 = None, 1e9
near, dist, radial = None, 1e9, None
for bone, a, b in legs:
dd = point_seg_distance(v, a, b)
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