Files
animation/tools/tailor/draft_garment.py
T

822 lines
40 KiB
Python
Raw Normal View History

#!/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 <resolved.json> --emit <out_script.py>
[--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/<name>/`.
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; <base>.zprj / <base>_garment.fbx /
// <base>_garment.obj / <base>.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 <repo>/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/<name>/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 <this 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())