# 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