#!/usr/bin/env python """draft_garment.py -- ONE template that replaces the four copy-paste MD garment scripts. python tools/tailor/draft_garment.py --config --emit [--stage draft|drape|publish|all] [--timeout-hint] GENERATION MODE ONLY. This script never talks to Marvelous Designer: it reads the `md` block of a garment config and WRITES a self-contained bridge script that `tools/md_bridge.py --file` executes inside MD. So it runs headless, in CI, with no session, and the emitted script is a reviewable artifact in `work//`. python tools/tailor/draft_garment.py --config work/pari/resolved.json \ --emit work/pari/md_script.py python tools/md_bridge.py --file work/pari/md_script.py --timeout 500 Replaces (which stay in place until this is proven on a live session): `md_pari.py`, `md_piupiu.py`, `md_tee_v1.py`, `md_skirt_v1.py`. Their identical ~100% shell is the template below; only panels/seams/arrangements/fabric differed. `md_recon.py` is an API-introspection tool and does NOT fold in here. -------------------------------------------------------------------------------- THE `md` CONFIG BLOCK -------------------------------------------------------------------------------- Everything is optional except `panels`. Relative paths resolve against the config file's directory, then the repo root; emitted paths are always absolute (MD needs absolute paths). ```jsonc "md": { // --- scene ------------------------------------------------------------- "reset": "new_project", // "new_project" (default) | "clear_patterns" | "none" // new_project = utility_api.NewProject() [DELETES the avatar] // clear_patterns = delete every pattern, keep the loaded avatar "avatar_fbx": "C:/.../Lena_QuatSkin_Avatar.fbx", // omit to keep the avatar already in the scene "avatar_scale": 10.0, // Blender-exported FBX lands 10x small; default 10.0 "add_arrangement_points": true,// default true -- without it there is nowhere to hang cloth "auto_translate": true, // default true // --- fabric ------------------------------------------------------------ "zfab": "C:/Users/Public/Documents/MarvelousDesigner/New Assets/Fabric/(Default for Simulation).zfab", "texture": "C:/.../textures/taniko.png", // optional; DPI in the PNG sets physical size "texture_dpi": 96.012, // optional; VERIFIED against the PNG at emit time "base_color": [0.13, 0.3, 0.75, 1.0], // optional RGBA; alternative to `texture` // (fabric calls are emitted only when `zfab` is set) // --- pattern ----------------------------------------------------------- "panels": [ // 2D pattern, millimetres, +y is UP in the 3D mapping {"name": "front", "dx": 0.0, "dy": 0.0, "note": "front scoop floor = band top", "points": [[65.0, 325.0], [155.0, 350.0], ...], // line i = point[i] -> point[i+1] "particle_distance": 20.0, // optional; sim mesh resolution (mm) -- light/gappy panels "thickness_collision": 3.0} // optional; stand-off from the skin (mm), set BEFORE the settle ], "seams": [ // whole-edge pairs only {"a": "front", "b": "back", "lines": [0, 5, 7, 9]}, // SAME index both panels (safe form) {"a": "front", "a_line": 1, "b": "back", "b_line": 3, // explicit form "reverse_a": false, "reverse_b": false} ], "seam_check": true, // default true: fingerprint every seam pair with GetLineLength // and FAIL the draft on any inequality > 0.1 mm -- paired edges // must be EQUAL, not close (a 0.075 mm "rounding" gap was a // slanted edge that notched a waistband) "arrangements": [ // looked up BY NAME at run time, never by index {"panel": "front", "point": "Body_Front_Center_1", "offset": [50, 55, 50]} ], // --- drape ------------------------------------------------------------- "sim": { "strengthen": true, // true = every panel | ["front"] = those panels | false/absent = none "settle_frames": 250, // main settle, cloth still stiff "elastic": [ // optional; applied MID-SETTLE (after settle_frames), never frame 0 {"panel": "front", "line": 0, "total_length": 450.0, "strength": null} ], "elastic_frames": 80, // sim frames after enabling elastic (default 80 when elastic set) "relax_frames": 50 // 0/absent = no relax pass (strengthen stays as-is) }, "cam_viewpoint": 2, // optional utility_api.SetCamViewPoint(2) = front, before snapshots "presim_snapshot": "C:/.../pari_presim.png", // optional; THE diagnostic (see lessons below) "snapshot": "C:/.../tinqs_md_pari_v2.png", // ExportSnapshot3D target for the drape stage "back_snapshot": "C:/.../pari_back.png", // optional; rear view via ExportTurntableImages(4) // -- ExportSnapshot3D CANNOT show the back at all // --- publish ----------------------------------------------------------- "export_dir": "C:/Users/Jeremy/tinqs/animation/tools/tailor", // default: tools/tailor/ "export_basename": "lena_pari_v3", // versioned; .zprj / _garment.fbx / // _garment.obj / .zpac "exports": ["zprj", "fbx", "obj", "zpac"] // default: all four } ``` -------------------------------------------------------------------------------- STAGES (`--stage`, matching the contract's three MD stages) -------------------------------------------------------------------------------- | stage | emits | |-----------|------------------------------------------------------------------------| | `draft` | reset, avatar import, panels, seams, fabric, arrangement assignment | | | (+ `ResetClothArrangement` and a 0-frame snapshot iff `presim_snapshot`)| | `drape` | strengthen, `ResetClothArrangement`, `Simulate`, relax, `Refresh3DWindow`, `ExportSnapshot3D` | | `publish` | `ExportZPrj` / `ExportFBX` / `ExportOBJ` / `ExportZPac` | | `all` | all three, in one script -- **the default, and the recommended path** | Split stages work because MD's exec namespace persists WITHIN a session: `draft` stores `{"garment", "patterns", "fabric"}` in a `TINQS_GARMENT` dict that `drape` and `publish` read back, and they fail loudly if it is missing or belongs to a different garment. It does NOT persist across sessions, so a session restart means re-running `draft`. `--stage all` sidesteps all of that and reproduces exactly the call order of the four originals -- prefer it unless you are iterating on a drape. -------------------------------------------------------------------------------- HARD-WON MD FACTS (also emitted into every generated script) -------------------------------------------------------------------------------- - 2D pattern space: units are mm, +y is UP in the 3D mapping. Straps/neck at HIGH y, hem at y=0. (Panels built y-down drape upside-down over the head and tangle -- it looks like a seam bug, it isn't. Seven "seam" iterations were this.) - MD internal 3D unit is mm (gravity default -9800). Blender-exported FBX avatars land 10x small -- import with op.scale = 10. - ImportAvatar() is .avt ONLY and returns False on FBX; use import_api.ImportFBX. - op.bAddArrangementPoints = True auto-generates ~98 named arrangement points. - CreatePatternWithPoints takes (x, y, type) triples; type 0 = corner. The returned id is the pattern index; line i = edge point[i] -> point[i+1]. - Seams: AddSeamlinePairGroup(patA, lineA, patB, lineB, False, False) with the SAME line index on mirrored front/back panels; cross-pairing sews the garment over the face. Whole-edge sewing only -> build neck gaps and armholes into the outline as extra points, not as partial seams. - SetArrangement() only ASSIGNS; utility_api.ResetClothArrangement() APPLIES it (ReDrape3DArrangement only materializes not-yet-draped cloth). - Arrangement INDICES REGENERATE on every avatar import -- always look up by name from GetArrangementList(). Offsets aren't stable either: verify with a 0-frame snapshot. - utility_api.NewProject() DELETES the avatar -- re-import after. - fabric_api.AddFabric() needs a .zfab FILE PATH; a name string silently fails. Fabric index 0 is the shared default -- coloring it dyes every garment. - SetBaseTextureMapImageGivenFilePath(path, fabricIdx) -- PATH IS ARG 0. - AssignFabricToPattern() returns False for every arg order tried; use pattern_api.SetPatternPieceFabricIndex(pattern, fabric). - PNG DPI sets a texture's physical size in MD (1024 px @ 54.2 dpi = 480 mm). Control tiling by setting dpi in PIL, not by scaling the image. - SetViewPoint() does nothing; utility_api.SetCamViewPoint(2) = front view. - Strengthen through the WHOLE settle, relax only at the end -- soft fabric from frame 0 rolls into a bunch at the waist. - Elastic mid-settle, never from the start; bottoms stay up by TENSION (waist cut smaller than hips), not elastic. - One garment per scene: a second garment, even frozen, grabs and inverts the new one. - utility_api.Simulate(n) is synchronous, ~1 min per 300 frames -- raise the bridge client --timeout accordingly. - GetClothPositions() stays empty from Python: there is NO mesh introspection, so every drape judgement is image-based (that is what gate G1 measures). Exit codes: 0 emitted · 2 invalid config · 3 could not write. """ import argparse import datetime import json import os import sys STAGES = ("draft", "drape", "publish", "all") EXPORT_KINDS = ("zprj", "fbx", "obj", "zpac") # three dirnames: this file lives at /tools/tailor/draft_garment.py REPO_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) DEFAULT_EXPORT_DIR = os.path.join(REPO_ROOT, "tools", "tailor") # Emitted verbatim into every generated script -- the lessons must travel with the # code a human actually reads in work//md_script.py, not only live here. LESSONS = """\ # HARD-WON MD FACTS (do not rediscover -- see tools/tailor/draft_garment.py): # - 2D pattern space: units are mm, +y is UP in the 3D mapping. Straps/neck at # HIGH y, hem at y=0. Panels built y-down drape upside-down over the head and # tangle -- it looks like a seam bug, it isn't. # - MD's 3D unit is mm (gravity -9800). Blender-exported FBX avatars land 10x # small -- import with op.scale = 10. ImportAvatar() is .avt only (False on FBX). # - op.bAddArrangementPoints = True generates the ~98 named arrangement points; # without it there is nowhere to hang cloth. # - CreatePatternWithPoints takes (x, y, type) triples, type 0 = corner. Returned # id is the pattern index; line i = edge point[i] -> point[i+1]. # - Seams are WHOLE-EDGE only: AddSeamlinePairGroup(a, lineA, b, lineB, False, # False) with the SAME line index on mirrored front/back panels. Cross-pairing # sews the garment over the face. Neck gaps and armholes are extra points in # the outline, not partial seams. # - SetArrangement() only ASSIGNS; utility_api.ResetClothArrangement() APPLIES it # (ReDrape3DArrangement only materializes not-yet-draped cloth). # - Arrangement INDICES REGENERATE on every avatar import -- always look up by # NAME from GetArrangementList(). Offsets aren't stable either; verify with a # 0-frame snapshot (the diagnostic that unsticks everything). # - utility_api.NewProject() DELETES the avatar -- re-import after. # - AddFabric() needs a .zfab FILE PATH (a name string silently fails); fabric 0 # is the shared default, coloring it dyes every garment. Assign with # SetPatternPieceFabricIndex -- AssignFabricToPattern() never worked. # - SetBaseTextureMapImageGivenFilePath(PATH, fabricIdx) -- path is arg 0. # - PNG DPI sets a texture's physical size in MD; control tiling via dpi in PIL. # - Strengthen through the WHOLE settle, relax only at the end; elastic (if any) # mid-settle, never from frame 0. One garment per scene. # - Simulate(n) is synchronous, ~1 min per 300 frames -- raise the client timeout. # - GetClothPositions() stays empty: no mesh introspection, QC is image-based. """ class ConfigError(Exception): """Raised for anything the `md` block gets wrong -- exits 2.""" # --------------------------------------------------------------------------- io def _load_config(path): with open(path, "r", encoding="utf-8") as fh: return json.load(fh) def _resolve(path, config_dir): """Config paths -> absolute. Try as-given, then config-dir-relative, then repo root.""" if not path: return path p = os.path.expanduser(str(path)) if os.path.isabs(p): return os.path.normpath(p) for base in (config_dir, REPO_ROOT): cand = os.path.normpath(os.path.join(base, p)) if os.path.exists(cand): return cand return os.path.normpath(os.path.join(config_dir, p)) def _warn(msg): print("warning: {}".format(msg), file=sys.stderr) # ---------------------------------------------------------------------- schema def _ident(name, used): """Panel name -> a safe, unique python identifier for the generated script.""" safe = "".join(c if (c.isalnum() or c == "_") else "_" for c in str(name)) if not safe or safe[0].isdigit(): safe = "p_" + safe base = "p_" + safe out, n = base, 2 while out in used: out, n = "{}_{}".format(base, n), n + 1 used.add(out) return out def parse_md(config, config_path): """Validate + normalise the `md` block. Raises ConfigError. Returns a plain dict.""" config_dir = os.path.dirname(os.path.abspath(config_path)) md = config.get("md") if not isinstance(md, dict): raise ConfigError("config has no `md` block (this garment has no MD upstream stage)") spec = {"name": config.get("name") or os.path.splitext(os.path.basename(config_path))[0]} # --- scene reset = md.get("reset", "new_project") if reset not in ("new_project", "clear_patterns", "none"): raise ConfigError("md.reset must be new_project|clear_patterns|none, got {!r}".format(reset)) spec["reset"] = reset spec["avatar_fbx"] = _resolve(md.get("avatar_fbx"), config_dir) if spec["avatar_fbx"] and not os.path.exists(spec["avatar_fbx"]): _warn("md.avatar_fbx does not exist: {}".format(spec["avatar_fbx"])) if reset == "new_project" and not spec["avatar_fbx"]: _warn("md.reset=new_project DELETES the avatar and no md.avatar_fbx is set -- " "the garment will drape on nothing") spec["avatar_scale"] = float(md.get("avatar_scale", 10.0)) spec["add_arrangement_points"] = bool(md.get("add_arrangement_points", True)) spec["auto_translate"] = bool(md.get("auto_translate", True)) # --- fabric spec["zfab"] = _resolve(md.get("zfab"), config_dir) if spec["zfab"] and not os.path.exists(spec["zfab"]): _warn("md.zfab does not exist: {} (AddFabric needs a real .zfab path)".format(spec["zfab"])) spec["texture"] = _resolve(md.get("texture"), config_dir) spec["texture_dpi"] = md.get("texture_dpi") if spec["texture"]: if not spec["zfab"]: raise ConfigError("md.texture set without md.zfab -- fabric index 0 is the shared " "default and texturing it dyes every garment in the scene") _check_texture(spec["texture"], spec["texture_dpi"]) color = md.get("base_color") if color is not None: if len(color) not in (3, 4): raise ConfigError("md.base_color must be [r,g,b] or [r,g,b,a]") color = [float(c) for c in color] if len(color) == 3: color.append(1.0) spec["base_color"] = color # --- panels panels = md.get("panels") if not isinstance(panels, list) or not panels: raise ConfigError("md.panels must be a non-empty list") used, seen = set(), set() spec["panels"] = [] for i, raw in enumerate(panels): if not isinstance(raw, dict): raise ConfigError("md.panels[{}] must be an object".format(i)) name = raw.get("name") or "panel{}".format(i) if name in seen: raise ConfigError("duplicate panel name {!r}".format(name)) seen.add(name) pts = raw.get("points") if not isinstance(pts, list) or len(pts) < 3: raise ConfigError("panel {!r} needs at least 3 points".format(name)) clean = [] for j, pt in enumerate(pts): if len(pt) < 2: raise ConfigError("panel {!r} point {} must be [x, y]".format(name, j)) clean.append((float(pt[0]), float(pt[1]))) spec["panels"].append({ "name": name, "var": _ident(name, used), "points": clean, "dx": float(raw.get("dx", 0.0)), "dy": float(raw.get("dy", 0.0)), "point_type": int(raw.get("point_type", 0)), "note": raw.get("note", ""), "particle_distance": (None if raw.get("particle_distance") is None else float(raw["particle_distance"])), "thickness_collision": (None if raw.get("thickness_collision") is None else float(raw["thickness_collision"])), }) by_name = {p["name"]: p for p in spec["panels"]} # --- seams spec["seams"] = [] for i, raw in enumerate(md.get("seams", []) or []): a, b = raw.get("a"), raw.get("b") for who in (a, b): if who not in by_name: raise ConfigError("md.seams[{}] references unknown panel {!r}".format(i, who)) ra, rb = bool(raw.get("reverse_a", False)), bool(raw.get("reverse_b", False)) if "lines" in raw: pairs = [(int(n), int(n)) for n in raw["lines"]] elif "a_line" in raw and "b_line" in raw: pairs = [(int(raw["a_line"]), int(raw["b_line"]))] else: raise ConfigError("md.seams[{}] needs `lines` or both `a_line`/`b_line`".format(i)) na, nb = len(by_name[a]["points"]), len(by_name[b]["points"]) for la, lb in pairs: if not 0 <= la < na: raise ConfigError("seam line {} out of range for panel {!r} ({} lines)" .format(la, a, na)) if not 0 <= lb < nb: raise ConfigError("seam line {} out of range for panel {!r} ({} lines)" .format(lb, b, nb)) if a != b and la != lb: _warn("seam {}.{} <-> {}.{} cross-pairs different line indices; on mirrored " "front/back panels that sews the garment over the face" .format(a, la, b, lb)) spec["seams"].append({"a": by_name[a], "a_line": la, "b": by_name[b], "b_line": lb, "reverse_a": ra, "reverse_b": rb}) spec["seam_check"] = bool(md.get("seam_check", True)) # --- arrangements spec["arrangements"] = [] for i, raw in enumerate(md.get("arrangements", []) or []): panel = raw.get("panel") if panel not in by_name: raise ConfigError("md.arrangements[{}] references unknown panel {!r}".format(i, panel)) point = raw.get("point") if not point: raise ConfigError("md.arrangements[{}] needs an arrangement point `point` name " "(indices regenerate; names are the only stable handle)".format(i)) off = raw.get("offset") if off is not None and len(off) != 3: raise ConfigError("md.arrangements[{}].offset must be [x, y, z]".format(i)) spec["arrangements"].append({ "panel": by_name[panel], "point": str(point), "offset": None if off is None else [_num(v) for v in off], }) # --- sim sim = md.get("sim") or {} strengthen = sim.get("strengthen", False) if strengthen is True: targets = [p["name"] for p in spec["panels"]] elif strengthen in (False, None): targets = [] elif isinstance(strengthen, list): for n in strengthen: if n not in by_name: raise ConfigError("md.sim.strengthen references unknown panel {!r}".format(n)) targets = list(strengthen) else: raise ConfigError("md.sim.strengthen must be true, false, or a list of panel names") spec["strengthen"] = [by_name[n] for n in targets] spec["settle_frames"] = int(sim.get("settle_frames", sim.get("frames", 250))) spec["relax_frames"] = int(sim.get("relax_frames", 0) or 0) if spec["relax_frames"] and not spec["strengthen"]: _warn("md.sim.relax_frames is set but nothing is strengthened -- the relax pass only " "makes sense as the 'stiff settle, then soften' recipe") # --- elastic (mid-settle, NEVER frame 0 -- elastic-first drapes slide off a hip) spec["elastic"] = [] for i, raw in enumerate(sim.get("elastic", []) or []): panel = raw.get("panel") if panel not in by_name: raise ConfigError("md.sim.elastic[{}] references unknown panel {!r}".format(i, panel)) line = int(raw.get("line", -1)) if not 0 <= line < len(by_name[panel]["points"]): raise ConfigError("md.sim.elastic[{}] line {} out of range for panel {!r}" .format(i, line, panel)) if raw.get("total_length") is None: raise ConfigError("md.sim.elastic[{}] needs total_length (mm) -- the target " "cinched length of the edge".format(i)) spec["elastic"].append({ "panel": by_name[panel], "line": line, "total_length": float(raw["total_length"]), "strength": (None if raw.get("strength") is None else float(raw["strength"])), }) spec["elastic_frames"] = int(sim.get("elastic_frames", 80)) if spec["elastic"] else 0 # --- snapshots / camera spec["cam_viewpoint"] = md.get("cam_viewpoint") spec["presim_snapshot"] = _resolve(md.get("presim_snapshot"), config_dir) spec["snapshot"] = _resolve(md.get("snapshot"), config_dir) spec["back_snapshot"] = _resolve(md.get("back_snapshot"), config_dir) if not spec["snapshot"]: _warn("md.snapshot is not set -- the drape stage will produce no image, and gate G1 " "(drape placement) has nothing to measure") # --- publish spec["export_dir"] = _resolve(md.get("export_dir") or DEFAULT_EXPORT_DIR, config_dir) spec["export_basename"] = md.get("export_basename") kinds = md.get("exports", list(EXPORT_KINDS)) bad = [k for k in kinds if k not in EXPORT_KINDS] if bad: raise ConfigError("md.exports has unknown kinds {}; valid: {}" .format(bad, list(EXPORT_KINDS))) spec["exports"] = list(kinds) if spec["exports"] and not spec["export_basename"]: raise ConfigError("md.export_basename is required to publish (use a versioned name " "like lena_pari_v3 -- never overwrite a shipped export)") return spec def _num(v): f = float(v) return int(f) if f == int(f) else f def _check_texture(path, declared_dpi): """DPI is the texture's physical size in MD. Verify the PNG really carries it.""" if not os.path.exists(path): _warn("md.texture does not exist: {}".format(path)) return try: from PIL import Image except ImportError: _warn("PIL not available -- skipping the md.texture_dpi check") return try: with Image.open(path) as im: size, dpi = im.size, im.info.get("dpi") except Exception as exc: # noqa: BLE001 _warn("could not read md.texture {}: {}".format(path, exc)) return if declared_dpi is None: return if not dpi: _warn("md.texture {} carries no DPI but md.texture_dpi={} is declared -- MD will size " "the cloth from the file, not from the config".format(path, declared_dpi)) return if abs(float(dpi[0]) - float(declared_dpi)) > 0.05: _warn("md.texture DPI mismatch: {} has {:.3f} dpi, config declares {} " "(1024 px @ {:.3f} dpi = {:.0f} mm of cloth)" .format(path, float(dpi[0]), declared_dpi, float(dpi[0]), size[0] / float(dpi[0]) * 25.4)) # -------------------------------------------------------------------- emission def _r(path): """Windows path -> a python raw-string literal for the generated script.""" return 'r"{}"'.format(str(path).replace('"', '\\"')) def emit(spec, stage, config_path): """Render the bridge script. `stage` in draft|drape|publish|all.""" want = {"draft", "drape", "publish"} if stage == "all" else {stage} L = [] a = L.append a("# GENERATED by tools/tailor/draft_garment.py -- DO NOT EDIT.") a("# garment : {}".format(spec["name"])) a("# stage : {}".format(stage)) a("# config : {}".format(config_path)) a("# emitted : {}".format(datetime.datetime.now().replace(microsecond=0).isoformat())) a("# Edit the config's `md` block and re-emit; edits here are lost.") a("#") a("# Run inside an MD bridge session (a human must click Plugin > TinqsMDBridge first):") a("# python tools/md_bridge.py --file --timeout {}" .format(_timeout_hint(spec))) a("#") a(LESSONS.rstrip()) a("") a("import os") a("") a("report = {{\"garment\": {!r}, \"stage\": {!r}}}".format(spec["name"], stage)) a("") if "draft" in want: L.extend(_emit_draft(spec)) else: L.extend(_emit_state_load(spec)) if "drape" in want: L.extend(_emit_drape(spec)) if "publish" in want: L.extend(_emit_publish(spec)) a("result = report") return "\n".join(L) + "\n" def _emit_state_load(spec): """drape/publish run in a later bridge request: recover the pattern ids draft stored.""" return [ "# The exec namespace persists WITHIN an MD session, so `draft` handed these over.", "# It does NOT persist across sessions: if MD was restarted, re-run --stage draft.", "if \"TINQS_GARMENT\" not in globals():", " raise RuntimeError(\"no TINQS_GARMENT in this MD session -- run --stage draft \"", " \"first, or emit --stage all\")", "if TINQS_GARMENT.get(\"garment\") != {!r}:".format(spec["name"]), " raise RuntimeError(\"this MD session holds garment %r, not {} -- re-run \"".format( spec["name"]), " \"--stage draft\" % TINQS_GARMENT.get(\"garment\"))", "_pat = TINQS_GARMENT[\"patterns\"]", ] + [ "{} = _pat[{!r}]".format(p["var"], p["name"]) for p in spec["panels"] ] + [ "fab = TINQS_GARMENT.get(\"fabric\")", "", ] def _emit_draft(spec): L = [] a = L.append a("# ---- scene ---------------------------------------------------------------") if spec["reset"] == "new_project": a("utility_api.NewProject() # NOTE: this DELETES the avatar; re-import below") elif spec["reset"] == "clear_patterns": a("# keep the loaded avatar, drop every pattern (delete back-to-front: ids shift)") a("for _i in range(pattern_api.GetPatternCount() - 1, -1, -1):") a(" pattern_api.DeletePatternPiece(_i)") if spec["avatar_fbx"]: a("AVATAR_FBX = {}".format(_r(spec["avatar_fbx"]))) a("op = ApiTypes.ImportExportOption()") a("op.scale = {} # Blender-exported FBX lands 10x small" .format(_fmt(spec["avatar_scale"]))) if spec["add_arrangement_points"]: a("op.bAddArrangementPoints = True # ~98 named points to hang cloth on") if spec["auto_translate"]: a("op.bAutoTranslate = True") a("report[\"avatar\"] = import_api.ImportFBX(AVATAR_FBX, op) " "# ImportAvatar() is .avt only") a("") a("# ---- pattern (mm; +y is UP in 3D -- hem at y=0, neck/straps at high y) ----") for p in spec["panels"]: if p["note"]: a("# {}: {}".format(p["name"], p["note"])) a("_pts_{} = [".format(p["var"])) for chunk in _chunk(p["points"], 3): a(" " + " ".join("({}, {}),".format(_fmt(x), _fmt(y)) for x, y in chunk)) a("]") a("{} = pattern_api.CreatePatternWithPoints(" "[(x + {}, y + {}, {}) for (x, y) in _pts_{}])" .format(p["var"], _fmt(p["dx"]), _fmt(p["dy"]), p["point_type"], p["var"])) if p["particle_distance"] is not None: a("pattern_api.SetParticleDistanceOfPattern({}, {}) # sim mesh resolution (mm)" .format(p["var"], _fmt(p["particle_distance"]))) if p["thickness_collision"] is not None: a("pattern_api.SetAddlThicknessCollision({}, {}) " "# skin stand-off; the sim resolves it, set BEFORE the settle" .format(p["var"], _fmt(p["thickness_collision"]))) a("report[\"ids\"] = [{}]".format(", ".join(p["var"] for p in spec["panels"]))) a("") if spec["seams"]: a("# ---- seams (WHOLE-EDGE only; same line index on mirrored panels) ---------") for s in spec["seams"]: a("pattern_api.AddSeamlinePairGroup({}, {}, {}, {}, {}, {})".format( s["a"]["var"], s["a_line"], s["b"]["var"], s["b_line"], s["reverse_a"], s["reverse_b"])) a("") if spec.get("seam_check", True): a("# Paired edges must be EQUAL, not close: a 0.075 mm 'rounding' difference was") a("# a slanted edge that notched a waistband. Fail loudly BEFORE wasting a sim.") a("_pairs = [{}]".format(", ".join( "({}, {}, {}, {})".format(s["a"]["var"], s["a_line"], s["b"]["var"], s["b_line"]) for s in spec["seams"]))) a("report[\"seam_lengths\"] = []") a("_bad = []") a("for _pa, _la, _pb, _lb in _pairs:") a(" _fa = pattern_api.GetLineLength(_pa, _la)") a(" _fb = pattern_api.GetLineLength(_pb, _lb)") a(" report[\"seam_lengths\"].append((_pa, _la, round(_fa, 3), _pb, _lb, round(_fb, 3)))") a(" if abs(_fa - _fb) > 0.1:") a(" _bad.append(\"%s.%s=%.3f vs %s.%s=%.3f\" % (_pa, _la, _fa, _pb, _lb, _fb))") a("if _bad:") a(" raise RuntimeError(\"seam length mismatch (construction bug, not rounding): \"") a(" + \"; \".join(_bad))") a("") if spec["zfab"]: a("# ---- fabric (never touch index 0: it is the scene-wide default) ---------") a("ZFAB = {}".format(_r(spec["zfab"]))) a("fab = fabric_api.AddFabric(ZFAB) # a name string silently fails") if spec["texture"]: dpi = " (dpi {} -> physical size)".format(spec["texture_dpi"]) \ if spec["texture_dpi"] else "" a("TEX = {}{}".format(_r(spec["texture"]), dpi and " #" + dpi or "")) a("fabric_api.SetBaseTextureMapImageGivenFilePath(TEX, fab) # path is arg 0") if spec["base_color"]: a("fabric_api.SetFabricPBRMaterialBaseColor(fab, 0, {})".format( ", ".join(_fmt(c) for c in spec["base_color"]))) a("for _p in ({},):".format(", ".join(p["var"] for p in spec["panels"]))) a(" pattern_api.SetPatternPieceFabricIndex(_p, fab) " "# AssignFabricToPattern never worked") a("") else: a("fab = None") a("") if spec["arrangements"]: a("# ---- arrangement (BY NAME: indices regenerate on every avatar import) ----") a("arr = {a[\"ArrangementName\"]: int(a[\"ArrangementIndex\"])") a(" for a in pattern_api.GetArrangementList()}") for ar in spec["arrangements"]: a("pattern_api.SetArrangement({}, arr[{!r}])".format(ar["panel"]["var"], ar["point"])) for ar in spec["arrangements"]: if ar["offset"] is not None: a("pattern_api.SetArrangementPosition({}, {})".format( ar["panel"]["var"], ", ".join(_fmt(v) for v in ar["offset"]))) a("") a("# Hand the pattern ids to a later --stage drape/publish in the SAME session.") a("TINQS_GARMENT = {{\"garment\": {!r}, \"fabric\": fab, \"patterns\": {{{}}}}}".format( spec["name"], ", ".join("{!r}: {}".format(p["name"], p["var"]) for p in spec["panels"]))) a("") if spec["presim_snapshot"]: a("# THE diagnostic: apply the arrangement and shoot with ZERO sim frames. It shows") a("# where the panels actually start, before physics muddies it. Reach for this the") a("# moment a drape misbehaves -- upside-down panels look exactly like seam bugs.") a("utility_api.ResetClothArrangement()") L.extend(_emit_snapshot(spec, spec["presim_snapshot"], "presim_snap")) a("") return L def _emit_drape(spec): L = [] a = L.append a("# ---- drape ---------------------------------------------------------------") if spec["strengthen"]: a("# Stiffen through the WHOLE settle: soft fabric from frame 0 rolls into a bunch.") for p in spec["strengthen"]: a("pattern_api.SetPatternStrengthen({}, True)".format(p["var"])) a("utility_api.ResetClothArrangement() # SetArrangement only assigns; THIS applies it") a("report[\"sim1\"] = utility_api.Simulate({}) # synchronous, ~1 min / 300 frames" .format(spec["settle_frames"])) if spec["elastic"]: a("# Elastic MID-SETTLE, never from frame 0 -- elastic-first drapes cinch the") a("# garment off one hip before the cloth has wrapped.") for e in spec["elastic"]: a("pattern_api.SetPatternPieceElastic({}, {}, True)".format( e["panel"]["var"], e["line"])) a("pattern_api.SetPatternPieceElasticTotalLength({}, {}, {})".format( e["panel"]["var"], e["line"], _fmt(e["total_length"]))) if e["strength"] is not None: a("pattern_api.SetPatternPieceElasticStrength({}, {}, {})".format( e["panel"]["var"], e["line"], _fmt(e["strength"]))) a("report[\"sim_elastic\"] = utility_api.Simulate({})".format(spec["elastic_frames"])) if spec["relax_frames"]: a("# Relax only at the end, so the settled shape falls into natural folds.") for p in spec["strengthen"]: a("pattern_api.SetPatternStrengthen({}, False)".format(p["var"])) a("report[\"sim2\"] = utility_api.Simulate({})".format(spec["relax_frames"])) a("utility_api.Refresh3DWindow()") if spec["snapshot"]: L.extend(_emit_snapshot(spec, spec["snapshot"], "snap")) if spec["back_snapshot"]: a("# ExportSnapshot3D has NO rear view (SetCamViewPoint 0-7: none is the back).") a("# Turntable ignores any path arg and reuses the same output names -- copy now.") a("import shutil") a("_tt = export_api.ExportTurntableImages(4) # 0 front, 1 side, 2 BACK, 3 side") a("if _tt and len(_tt) > 2:") a(" back_snap = {}".format(_r(spec["back_snapshot"]))) a(" os.makedirs(os.path.dirname(back_snap), exist_ok=True)") a(" shutil.copyfile(_tt[2], back_snap)") a(" report[\"back_snapshot\"] = back_snap") a("else:") a(" report[\"back_snapshot\"] = \"TURNTABLE RETURNED NOTHING -- rear unverified\"") a("") return L def _emit_snapshot(spec, path, key): L = [] a = L.append if spec["cam_viewpoint"] is not None: a("utility_api.SetCamViewPoint({}) # SetViewPoint() does nothing; 2 = front" .format(spec["cam_viewpoint"])) a("{} = {}".format(key, _r(path))) a("os.makedirs(os.path.dirname({}), exist_ok=True)".format(key)) a("export_api.ExportSnapshot3D({})".format(key)) a("report[{!r}] = {}".format(key, key)) return L def _emit_publish(spec): L = [] a = L.append if not spec["exports"]: return L base = os.path.join(spec["export_dir"], spec["export_basename"]) a("# ---- publish (all scriptable; the FBX/OBJ flags are the load-bearing part) -") a("os.makedirs({}, exist_ok=True)".format(_r(spec["export_dir"]))) a("xop = ApiTypes.ImportExportOption()") a("xop.bExportGarment = True") a("xop.bExportAvatar = False # the avatar is the game body; ship cloth only") if "zprj" in spec["exports"]: a("report[\"zprj\"] = {}".format(_r(base + ".zprj"))) a("export_api.ExportZPrj(report[\"zprj\"]) " "# editable source of truth; no SaveProjectFile exists") if "fbx" in spec["exports"]: a("report[\"fbx\"] = {}".format(_r(base + "_garment.fbx"))) a("export_api.ExportFBX(report[\"fbx\"], xop) # -> clothing/garment_pipeline.py") if "obj" in spec["exports"]: a("report[\"obj\"] = {}".format(_r(base + "_garment.obj"))) a("export_api.ExportOBJ(report[\"obj\"], xop)") if "zpac" in spec["exports"]: a("report[\"zpac\"] = {}".format(_r(base + ".zpac"))) a("export_api.ExportZPac(report[\"zpac\"]) " "# for outfit assembly (ImportZpac op.bAdd=True)") a("") return L def _fmt(v): if isinstance(v, bool): return "True" if v else "False" f = float(v) return repr(int(f)) if f == int(f) and abs(f) < 1e15 and isinstance(v, int) else repr(f) def _chunk(seq, n): for i in range(0, len(seq), n): yield seq[i:i + n] def _timeout_hint(spec): """~1 min per 300 sim frames, plus import/export headroom.""" frames = spec["settle_frames"] + spec["relax_frames"] + spec.get("elastic_frames", 0) return max(120, int(frames / 300.0 * 60.0 * 2.5) + 120) # ----------------------------------------------------------------------- entry def main(argv=None): ap = argparse.ArgumentParser( description="Emit a Marvelous Designer bridge script from a garment config's `md` block.") ap.add_argument("--config", required=True, help="resolved.json (or any garment config carrying an `md` block)") ap.add_argument("--emit", required=True, help="output path for the generated bridge script ('-' = stdout)") ap.add_argument("--stage", default="all", choices=STAGES, help="which MD stage(s) to emit (default: %(default)s)") ap.add_argument("--timeout-hint", action="store_true", help="print only the suggested md_bridge.py --timeout and exit") args = ap.parse_args(argv) try: config = _load_config(args.config) except (OSError, ValueError) as exc: print("error: could not read config {}: {}".format(args.config, exc), file=sys.stderr) return 3 try: spec = parse_md(config, args.config) except ConfigError as exc: print("error: {}".format(exc), file=sys.stderr) return 2 if args.timeout_hint: print(_timeout_hint(spec)) return 0 text = emit(spec, args.stage, os.path.abspath(args.config)) try: compile(text, args.emit, "exec") except SyntaxError as exc: print("error: generated script does not parse ({}) -- this is a template bug" .format(exc), file=sys.stderr) return 3 if args.emit == "-": sys.stdout.write(text) return 0 try: out_dir = os.path.dirname(os.path.abspath(args.emit)) if out_dir: os.makedirs(out_dir, exist_ok=True) with open(args.emit, "w", encoding="utf-8") as fh: fh.write(text) except OSError as exc: print("error: could not write {}: {}".format(args.emit, exc), file=sys.stderr) return 3 print("emitted {} ({} stage, {} panels, {} seams) -> {}".format( spec["name"], args.stage, len(spec["panels"]), len(spec["seams"]), args.emit)) print(" python tools/md_bridge.py --file {} --timeout {}".format( args.emit, _timeout_hint(spec))) return 0 if __name__ == "__main__": sys.exit(main())