feat: clothing lane, character sources, and DCC bridges
Bulk import of the working lanes that were living untracked on the PC. Content: - characters/ Lena/male body lanes, bakes, texture work, run logs - clothing/ garment pipeline, configs, gates, contract docs - garments/ MD-authored garment sources (.zprj/.zpac) - UAL-Lib/ Universal Animation Library 2 source (.blend/.fbx/.glb) - tools/ blender_bridge, iclone_bridge, md_bridge, tailor, glm_agent - docs/, plans/, dev/, .agents/plans/ Repo hygiene: - .gitattributes: LFS now covers .blend, .zprj, .zpac, .obj, .npy and the Reallusion .iAvatar/.ccAvatar/.ccRestore containers. Without this the ~3.8 GB in this commit would land as raw blobs. .png/.jpg are left out on purpose — ~250 are already tracked raw and converting them would rewrite every one without shrinking history. - .gitignore: exclude /accurig/ (~1 GB AccuRig program files, redistributable from Reallusion, nothing authored here) and /dev/null/ (git-lfs hook copies dropped by a `>/dev/null` redirect on Windows). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,821 @@
|
||||
#!/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())
|
||||
Reference in New Issue
Block a user