311 lines
19 KiB
Python
311 lines
19 KiB
Python
|
|
# Piupiu v3 — discrete flax strands on a rigid waistband.
|
||
|
|
#
|
||
|
|
# python tools/md_bridge.py --file tools/tailor/md_piupiu_v3.py --timeout 600
|
||
|
|
#
|
||
|
|
# v2 stays untouched; backup in tools/tailor/backup/2026-07-31-pre-v3/.
|
||
|
|
# Continuous-panel variant kept at md_piupiu_v3_flare_fallback.py — reach for it
|
||
|
|
# only if strands prove unworkable.
|
||
|
|
#
|
||
|
|
# ── WHY ──────────────────────────────────────────────────────────────────────
|
||
|
|
# v2 is a taut pencil wrap: waist cut 1000 mm against Lena's 1095 mm hips, so it
|
||
|
|
# arrives stretched over the thighs with 1-3 cm ease (the skirt-rig session
|
||
|
|
# measured cloth at r 0.19-0.24 m against body r 0.18-0.22 m). Downstream agrees:
|
||
|
|
# G3 finds 8 mm penetration AT REST, G5 finds 216-231 verts up to 8.5 cm deep
|
||
|
|
# across Idle/Walk.
|
||
|
|
#
|
||
|
|
# A real piupiu is discrete hanging flax strands, not a closed tube. Modelling it
|
||
|
|
# that way is the cheapest fix available, because it changes what counts as a
|
||
|
|
# defect: leg visible BETWEEN strands is correct, and strands cannot crease the way
|
||
|
|
# a continuous panel does. It also happens to match the spring rig exactly — the
|
||
|
|
# ring IS 8 strands (skirt_00…skirt_07), so 32 strands = 4 per bundle, and each
|
||
|
|
# bundle swings independently instead of dragging a tube with it.
|
||
|
|
#
|
||
|
|
# ── CONSTRUCTION ─────────────────────────────────────────────────────────────
|
||
|
|
# Two comb-shaped panels: a 60 mm solid band with M teeth cut into the OUTLINE,
|
||
|
|
# reaching all the way up to the band. Only the two band side edges are sewn —
|
||
|
|
# whole-edge seams, per doctrine. 24-32 partial seams (one per strand) were
|
||
|
|
# considered and rejected: the failure catalogue says partial seams twist
|
||
|
|
# unpredictably, and cutting shapes into the outline is how the tee's neck gap and
|
||
|
|
# armholes were done.
|
||
|
|
#
|
||
|
|
# The panel is a TRAPEZOID comb, not a rectangular one. Teeth cannot be wider at
|
||
|
|
# the hem than their pitch, so hem fullness has to come from flaring the panel
|
||
|
|
# itself: band edge W_TOP, hem edge W_HEM, teeth cut from the flared body.
|
||
|
|
#
|
||
|
|
# Waistband is cut SMALLER than the hips (750 mm vs 1095 mm) so the hip below
|
||
|
|
# physically blocks it from sliding — no elastic needed, and it keeps the
|
||
|
|
# waistband rigid, which the rig requires (a waistband that swings reads broken).
|
||
|
|
# MD arranges cloth at the arrangement point rather than simulating donning, so
|
||
|
|
# "must pass over the hips" is a real-world constraint that does not apply here.
|
||
|
|
#
|
||
|
|
# ── WHAT THIS ANSWERS FROM THE SKIRT-RIG SESSION ─────────────────────────────
|
||
|
|
# ask 2 (more ease) → COLLISION_MM standoff + strand flare
|
||
|
|
# ask 3 (real strands) → this file; approved by Jeremy 2026-07-31
|
||
|
|
# ask 4 (even density) → every strand is an identical strip, so azimuthal
|
||
|
|
# density is uniform BY CONSTRUCTION (was 5-6:1), and
|
||
|
|
# PARTICLE_MM sets the vertical row count directly
|
||
|
|
# ask 5 (verts above the ring) → the wrap-overlap fold is gone; a 60 mm band
|
||
|
|
# puts ~17% of verts above y 0.952, not 40%
|
||
|
|
# ask 1 (32 skirt joints) is NOT solvable here — it needs their 32-bone
|
||
|
|
# SkirtRig body first, then a pipeline re-export.
|
||
|
|
#
|
||
|
|
# HONEST LIMIT: hem target 0.45 m sits 0.495 m below the hip pivot, so 30 deg of
|
||
|
|
# hip flexion sweeps the leg ~25 cm forward. No believable piupiu silhouette
|
||
|
|
# clears that. Strands make the residual legible instead of wrong; the spring rig
|
||
|
|
# still does the last part of the job.
|
||
|
|
#
|
||
|
|
# QC targets (tools/tailor/qc_placement.py verifies): waistband top 1.05 m +-3 cm,
|
||
|
|
# hem 0.45 m +-4 cm. G1 measured v2 at +8 cm / +14 cm off, so expect to correct
|
||
|
|
# placement either via ARR_Y here or `align.z_nudge` in the downstream config.
|
||
|
|
import os
|
||
|
|
import tempfile
|
||
|
|
|
||
|
|
# ── parameters — this block exists so iteration is one edit ───────────────────
|
||
|
|
M_TEETH = 16 # strands PER PANEL. x2 = 32 total = 4 per rig bundle.
|
||
|
|
W_TOP = 375.0 # mm, band edge per panel. x2 = 750 (waist 675 + 75 ease)
|
||
|
|
W_HEM = 700.0 # mm, hem edge per panel. x2 = 1400 of flare
|
||
|
|
BAND_H = 60.0 # mm, solid waistband depth — the part that must ride rigidly
|
||
|
|
STRAND_H = 530.0 # mm. 570 measured a 0.672 m span (band 1.087 / hem 0.415)
|
||
|
|
# but the QC targets imply 0.60; 530 puts BOTH in tolerance.
|
||
|
|
GAP_TOP = 6.0 # mm, slit width at the band
|
||
|
|
GAP_HEM = 11.0 # mm, slit width at the hem
|
||
|
|
PARTICLE_MM = 20.0 # sim/mesh resolution. Lower = more vertical rows per strand,
|
||
|
|
# which ask 4 also wants between bone joints. 20 gives ~29 rows
|
||
|
|
# per strand; 15 gives ~38 and blows the vert budget.
|
||
|
|
COLLISION_MM = 10.0 # Additional Thickness - Collision: MD resolves the cloth
|
||
|
|
# standing off the skin. Better than the downstream `fit`
|
||
|
|
# normal-push, which self-intersects between touching thighs.
|
||
|
|
ARR_FRONT = "Leg_Skirt_Front" # NOT Body_*_Waist. v2 used the body-waist points
|
||
|
|
ARR_BACK = "Leg_Skirt_Back" # and G1 measured it +8/+14 cm high; the skill
|
||
|
|
# prescribes the Leg_Skirt_* points for skirts.
|
||
|
|
ARR_X_FRONT = 50 # These MUST DIFFER. Swept all four combos with a validated control
|
||
|
|
ARR_X_BACK = 0 # (md_piupiu_arrsweep.py): 50/0 and 0/50 both drape correctly
|
||
|
|
# (band top 1.093 / 1.095), while 50/50 and 0/0 make the skirt FALL TO
|
||
|
|
# THE FLOOR (band top 0.10 / 0.14). So for Leg_Skirt_Front/Back the x
|
||
|
|
# arg selects which half each panel wraps and the split is load-bearing
|
||
|
|
# - NOT a copy-paste artefact. Note this is the OPPOSITE of the top,
|
||
|
|
# where Body_*_Center_1 needed x MATCHED on both panels. Do not
|
||
|
|
# "harmonise" these two recipes.
|
||
|
|
ARR_Y = 92 # per the skill's skirt recipe: SetArrangementPosition(p,x,92,50).
|
||
|
|
# NB: SetArrangementPosition takes 4 INTS — a float raises.
|
||
|
|
# A 60 mm band has far less mass than v2's solid panel, so it
|
||
|
|
# does NOT slide down to the waist on its own — it stays where
|
||
|
|
# it is arranged. Arrangement height IS the placement here.
|
||
|
|
ZFAB = r"C:\Users\Public\Documents\MarvelousDesigner\New Assets\Fabric\(Default for Simulation).zfab"
|
||
|
|
# fabric_api has NO physics setters — stiffness comes from the .zfab. If thin
|
||
|
|
# strands curl or the flare collapses, climb this ladder (one change at a time):
|
||
|
|
# V2_Woven_Canvas_1.zfab < V2_Woven_Denim_1.zfab < V2_Non-Fabric_Tyvek_1.zfab
|
||
|
|
PLAIN = False # Judge SHAPE with the texture off. Cloth is white on its front face
|
||
|
|
# and grey on its back, so a panel showing grey from outside is inside
|
||
|
|
# out and a white streak is a fold. The piupiu texture is vertical
|
||
|
|
# strands on a busy band - it hides both. See the skill's
|
||
|
|
# references/tooling.md SS0 and SS1.3.
|
||
|
|
EXPORT = True # band side seams fixed + arrangement split verified 2026-07-31
|
||
|
|
VERSION = "v3"
|
||
|
|
|
||
|
|
AVATAR_FBX = r"C:\Users\Jeremy\tinqs\animation\tools\tailor\avatar\Lena_QuatSkin_Avatar.fbx"
|
||
|
|
TEX = r"C:\Users\Jeremy\tinqs\animation\tools\tailor\textures\piupiu.png"
|
||
|
|
OUT = r"C:\Users\Jeremy\tinqs\animation\tools\tailor"
|
||
|
|
SHOTS = r"C:\Users\Jeremy\tinqs\animation\tools\tailor\screenshots"
|
||
|
|
|
||
|
|
report = {"params": {"M_TEETH": M_TEETH, "W_TOP": W_TOP, "W_HEM": W_HEM,
|
||
|
|
"BAND_H": BAND_H, "STRAND_H": STRAND_H,
|
||
|
|
"GAP_TOP": GAP_TOP, "GAP_HEM": GAP_HEM,
|
||
|
|
"PARTICLE_MM": PARTICLE_MM, "COLLISION_MM": COLLISION_MM,
|
||
|
|
"ARR_Y": ARR_Y, "zfab": os.path.basename(ZFAB),
|
||
|
|
"EXPORT": EXPORT}}
|
||
|
|
|
||
|
|
utility_api.NewProject() # NB: deletes the avatar — import after
|
||
|
|
op = ApiTypes.ImportExportOption()
|
||
|
|
op.scale = 10.0 # Blender-exported FBX avatars import 10x small
|
||
|
|
op.bAddArrangementPoints = True # else there is nowhere to hang cloth
|
||
|
|
op.bAutoTranslate = True
|
||
|
|
report["avatar"] = import_api.ImportFBX(AVATAR_FBX, op)
|
||
|
|
|
||
|
|
# ── the comb outline ─────────────────────────────────────────────────────────
|
||
|
|
# 2D y+ maps to UP in 3D, so hem sits at y=0 and the band top at y=BAND_TOP.
|
||
|
|
# Drafting y-down drapes the garment upside-down over the head.
|
||
|
|
BAND_TOP = STRAND_H + BAND_H
|
||
|
|
BAND_BOT = STRAND_H
|
||
|
|
INSET = (W_HEM - W_TOP) / 2.0 # trapezoid: band edge centred over the hem edge
|
||
|
|
|
||
|
|
pitch_top = W_TOP / M_TEETH
|
||
|
|
pitch_hem = W_HEM / M_TEETH
|
||
|
|
report["strand_mm"] = {"at_band": round(pitch_top - GAP_TOP, 1),
|
||
|
|
"at_hem": round(pitch_hem - GAP_HEM, 1),
|
||
|
|
"hem_cloth_total": round(2 * M_TEETH * (pitch_hem - GAP_HEM), 1)}
|
||
|
|
|
||
|
|
def outline(dx):
|
||
|
|
"""Comb: band top, band RIGHT edge, M teeth right-to-left, band LEFT edge.
|
||
|
|
|
||
|
|
Both band side edges are exact 60 mm VERTICALS and both end teeth get a half-gap,
|
||
|
|
so the two panels meet symmetrically. The first version got this wrong in two ways
|
||
|
|
and it produced a notch with the cloth's reverse face showing at the side seams:
|
||
|
|
- for j = M-1 it skipped the band-bottom step point, so the rightmost tooth sat
|
||
|
|
FLUSH with the panel edge and was GAP_TOP/2 wider than every other tooth;
|
||
|
|
- it closed straight from the last tooth to the band's top-left corner, making
|
||
|
|
the LEFT band edge a slanted 60.07 mm instead of a vertical 60.0 mm.
|
||
|
|
Sewing a flush tooth to a half-gapped one across a slanted seam is what folded it.
|
||
|
|
The seam-length fingerprint showed this as [60.0, 60.075, 60.0, 60.075] - unequal
|
||
|
|
paired edges are never "close enough".
|
||
|
|
"""
|
||
|
|
pts = [(INSET, BAND_TOP), (INSET + W_TOP, BAND_TOP), (INSET + W_TOP, BAND_BOT)]
|
||
|
|
for j in range(M_TEETH - 1, -1, -1):
|
||
|
|
ta = INSET + j * pitch_top + GAP_TOP / 2.0 # tooth left @ band
|
||
|
|
tb = INSET + (j + 1) * pitch_top - GAP_TOP / 2.0 # tooth right @ band
|
||
|
|
ha = j * pitch_hem + GAP_HEM / 2.0 # tooth left @ hem
|
||
|
|
hb = (j + 1) * pitch_hem - GAP_HEM / 2.0 # tooth right @ hem
|
||
|
|
pts.append((tb, BAND_BOT)) # ALWAYS - including j = M-1 (the old bug)
|
||
|
|
pts.append((hb, 0.0)) # down the tooth's right side
|
||
|
|
pts.append((ha, 0.0)) # across the hem
|
||
|
|
pts.append((ta, BAND_BOT)) # up the tooth's left side
|
||
|
|
pts.append((INSET, BAND_BOT)) # band bottom-left corner, so the closing
|
||
|
|
# LEFT band edge is a true vertical
|
||
|
|
return [(x + dx, y, 0) for (x, y) in pts]
|
||
|
|
|
||
|
|
pts_f = outline(0.0)
|
||
|
|
pts_b = outline(W_HEM + 400.0)
|
||
|
|
sf = pattern_api.CreatePatternWithPoints(pts_f)
|
||
|
|
sb = pattern_api.CreatePatternWithPoints(pts_b)
|
||
|
|
report["ids"] = [sf, sb]
|
||
|
|
|
||
|
|
# Line indices follow from the point order above: 0 = band top, 1 = band RIGHT
|
||
|
|
# edge, last = band LEFT edge. Everything between is teeth and must stay unsewn.
|
||
|
|
L_RIGHT = 1
|
||
|
|
L_LEFT = len(pts_f) - 1
|
||
|
|
report["lines"] = {"n_points": len(pts_f), "L_RIGHT": L_RIGHT, "L_LEFT": L_LEFT}
|
||
|
|
# Fingerprint by length, per doctrine — a band side edge is ~BAND_H long, so if
|
||
|
|
# these come back near BAND_H the indices are right and the seams are safe.
|
||
|
|
try:
|
||
|
|
report["seam_line_lengths"] = [pattern_api.GetLineLength(sf, L_RIGHT),
|
||
|
|
pattern_api.GetLineLength(sf, L_LEFT),
|
||
|
|
pattern_api.GetLineLength(sb, L_RIGHT),
|
||
|
|
pattern_api.GetLineLength(sb, L_LEFT)]
|
||
|
|
except Exception as e:
|
||
|
|
report["line_length_error"] = "%s: %s" % (type(e).__name__, e)
|
||
|
|
|
||
|
|
# Panels are identical, not mirrored, so right↔left rather than same-index.
|
||
|
|
pattern_api.AddSeamlinePairGroup(sf, L_RIGHT, sb, L_LEFT, False, False)
|
||
|
|
pattern_api.AddSeamlinePairGroup(sf, L_LEFT, sb, L_RIGHT, False, False)
|
||
|
|
|
||
|
|
fab = fabric_api.AddFabric(ZFAB) # never reuse index 0
|
||
|
|
if not PLAIN:
|
||
|
|
fabric_api.SetBaseTextureMapImageGivenFilePath(TEX, fab) # path is arg0
|
||
|
|
for p in (sf, sb):
|
||
|
|
pattern_api.SetPatternPieceFabricIndex(p, fab)
|
||
|
|
|
||
|
|
for setter, val, key in ((pattern_api.SetParticleDistanceOfPattern, PARTICLE_MM, "particle"),
|
||
|
|
(pattern_api.SetAddlThicknessCollision, COLLISION_MM, "collision")):
|
||
|
|
if not val:
|
||
|
|
continue
|
||
|
|
try:
|
||
|
|
for p in (sf, sb):
|
||
|
|
setter(p, val)
|
||
|
|
report[key] = val
|
||
|
|
except Exception as e:
|
||
|
|
report[key + "_error"] = "%s: %s" % (type(e).__name__, e)
|
||
|
|
|
||
|
|
arr = {a["ArrangementName"]: int(a["ArrangementIndex"])
|
||
|
|
for a in pattern_api.GetArrangementList()} # indices regenerate per import
|
||
|
|
pattern_api.SetArrangement(sf, arr[ARR_FRONT])
|
||
|
|
pattern_api.SetArrangement(sb, arr[ARR_BACK])
|
||
|
|
# The x arg MUST match across panels. This recipe inherited front=50 / back=0 from
|
||
|
|
# md_piupiu.py, and that asymmetry folded ONE SIDE only: turntable side B showed a
|
||
|
|
# waistband flap jutting out with a grey reverse-face wedge and a strand group folded
|
||
|
|
# over itself, while side A was clean. The identical 50/0 pair folded one shoulder of
|
||
|
|
# the top. An asymmetric defect needs an asymmetric input, and this is the only one.
|
||
|
|
pattern_api.SetArrangementPosition(sf, ARR_X_FRONT, int(ARR_Y), 50)
|
||
|
|
pattern_api.SetArrangementPosition(sb, ARR_X_BACK, int(ARR_Y), 50)
|
||
|
|
|
||
|
|
pattern_api.SetPatternStrengthen(sf, True) # stiff so cloth wraps, not crumples
|
||
|
|
pattern_api.SetPatternStrengthen(sb, True)
|
||
|
|
utility_api.ResetClothArrangement() # SetArrangement only assigns
|
||
|
|
|
||
|
|
# The diagnostic that unsticks everything: where the panels actually start, before
|
||
|
|
# physics muddies it. Zero simulation frames. With a comb outline this is doubly
|
||
|
|
# worth it — it shows whether the teeth read as separate strands or the outline
|
||
|
|
# self-intersected.
|
||
|
|
utility_api.Refresh3DWindow()
|
||
|
|
utility_api.SetCamViewPoint(2) # front view, comparable shots
|
||
|
|
pre = os.path.join(tempfile.gettempdir(), "tinqs_md_piupiu_%s_presim.png" % VERSION)
|
||
|
|
export_api.ExportSnapshot3D(pre)
|
||
|
|
report["presim_snap"] = pre
|
||
|
|
|
||
|
|
report["sim1"] = utility_api.Simulate(250) # main settle, still stiff
|
||
|
|
pattern_api.SetPatternStrengthen(sf, False) # relax into natural folds
|
||
|
|
pattern_api.SetPatternStrengthen(sb, False)
|
||
|
|
report["sim2"] = utility_api.Simulate(50)
|
||
|
|
utility_api.Refresh3DWindow()
|
||
|
|
|
||
|
|
utility_api.SetCamViewPoint(2)
|
||
|
|
snap = os.path.join(SHOTS, "lena_piupiu_%s.png" % VERSION)
|
||
|
|
export_api.ExportSnapshot3D(snap) # required repo review render
|
||
|
|
report["snap"] = snap
|
||
|
|
|
||
|
|
# SetCamViewPoint has NO REAR VIEW (0 bottom, 1/3 front quarters, 2 front, 4/6/7 sides,
|
||
|
|
# 5 top), so ExportSnapshot3D can never show the back. Every render of this garment so
|
||
|
|
# far has been a front view - the same blind spot that hid four defects in the top.
|
||
|
|
utility_api.SetCamViewPoint(5) # top-down: best view of a waistband
|
||
|
|
utility_api.Refresh3DWindow()
|
||
|
|
topv = os.path.join(tempfile.gettempdir(), "tinqs_md_piupiu_%s_top.png" % VERSION)
|
||
|
|
export_api.ExportSnapshot3D(topv)
|
||
|
|
report["top_snap"] = topv
|
||
|
|
utility_api.SetCamViewPoint(2)
|
||
|
|
report["turntable"] = export_api.ExportTurntableImages(4) # idx 0 front, 1 side, 2 BACK
|
||
|
|
|
||
|
|
# ── measurement ──────────────────────────────────────────────────────────────
|
||
|
|
# qc_placement.py cannot read this garment: its pixel classifier needs a solid
|
||
|
|
# silhouette and a strand skirt is mostly gaps (it reported a 1.71 m span on the
|
||
|
|
# first v3 drape — nonsense). Measure the geometry instead. Export a throwaway OBJ
|
||
|
|
# and read the verts: exact, and it also answers the skirt-rig session's ease
|
||
|
|
# question directly, in the same radius units they measured (body r 0.18-0.22 m).
|
||
|
|
mop = ApiTypes.ImportExportOption()
|
||
|
|
mop.bExportGarment = True
|
||
|
|
mop.bExportAvatar = False
|
||
|
|
probe = os.path.join(tempfile.gettempdir(), "tinqs_md_piupiu_%s_probe.obj" % VERSION)
|
||
|
|
export_api.ExportOBJ(probe, mop)
|
||
|
|
|
||
|
|
vs = []
|
||
|
|
with open(probe) as fh:
|
||
|
|
for line in fh:
|
||
|
|
if line.startswith("v "):
|
||
|
|
_, x, y, z = line.split()[:4]
|
||
|
|
vs.append((float(x), float(y), float(z)))
|
||
|
|
if vs:
|
||
|
|
# OBJ exports in MM (measured: raw 1086.9 == 1.0869 m). Note this differs from
|
||
|
|
# the FBX path, where the census reads decimetres and align.scale_z of 0.1
|
||
|
|
# converts — the two exporters do not agree on units.
|
||
|
|
S = 0.001
|
||
|
|
zs = sorted(v[1] * S for v in vs) # OBJ y is up
|
||
|
|
cx = sum(v[0] for v in vs) / len(vs)
|
||
|
|
cz = sum(v[2] for v in vs) / len(vs)
|
||
|
|
def band(lo, hi):
|
||
|
|
r = [((v[0] - cx) ** 2 + (v[2] - cz) ** 2) ** 0.5 * S
|
||
|
|
for v in vs if lo <= v[1] * S <= hi]
|
||
|
|
if not r:
|
||
|
|
return None
|
||
|
|
r.sort()
|
||
|
|
return {"n": len(r), "r_med": round(r[len(r) // 2], 3),
|
||
|
|
"r_max": round(r[-1], 3)}
|
||
|
|
report["measured"] = {
|
||
|
|
"verts": len(vs),
|
||
|
|
"band_top_m": round(zs[-1], 3), # target 1.05 +-0.03
|
||
|
|
"hem_m": round(zs[0], 3), # target 0.45 +-0.04
|
||
|
|
"frac_above_ring_0952": round(sum(1 for z in zs if z > 0.952) / len(zs), 3),
|
||
|
|
"radius_at_hip_0.90_0.96": band(0.90, 0.96),
|
||
|
|
"radius_at_thigh_0.82_0.88": band(0.82, 0.88),
|
||
|
|
"radius_at_knee_0.50_0.56": band(0.50, 0.56),
|
||
|
|
}
|
||
|
|
|
||
|
|
if EXPORT:
|
||
|
|
base = os.path.join(OUT, "lena_piupiu_%s" % VERSION)
|
||
|
|
eop = ApiTypes.ImportExportOption()
|
||
|
|
eop.bExportGarment = True
|
||
|
|
eop.bExportAvatar = False
|
||
|
|
report["zprj"] = export_api.ExportZPrj(base + ".zprj")
|
||
|
|
report["fbx"] = export_api.ExportFBX(base + "_garment.fbx", eop)
|
||
|
|
report["obj"] = export_api.ExportOBJ(base + "_garment.obj", eop)
|
||
|
|
report["zpac"] = export_api.ExportZPac(base + ".zpac")
|
||
|
|
|
||
|
|
result = report
|