simready-forge 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,167 @@
1
+ """The review of a saved asset: Isaac Sim's RTX renders and PhysX checks when Isaac Sim is installed, a software
2
+ preview sheet when it is not."""
3
+
4
+ import contextlib
5
+ import json
6
+ import os
7
+ import subprocess
8
+ import time
9
+ from pathlib import Path
10
+
11
+ import numpy as np
12
+
13
+ SCRIPT = Path(__file__).with_name("isaac_review.py")
14
+ PROBE = Path(__file__).with_name("isaac_probe.py")
15
+
16
+
17
+ def find_isaac(explicit=None):
18
+ """Isaac Sim's Python: the argument, SIMREADY_FORGE_ISAAC, ISAACSIM_PYTHON, or a common install path."""
19
+ candidates = [explicit, os.environ.get("SIMREADY_FORGE_ISAAC"), os.environ.get("ISAACSIM_PYTHON")]
20
+ home = Path.home()
21
+ candidates += [
22
+ str(p)
23
+ for p in (
24
+ Path("D:/isaac/venv/Scripts/python.exe"),
25
+ Path("C:/isaac/venv/Scripts/python.exe"),
26
+ home / "isaacsim" / "python.bat",
27
+ home / "isaacsim" / "python.sh",
28
+ Path("/isaac-sim/python.sh"),
29
+ )
30
+ ]
31
+ for candidate in candidates:
32
+ if candidate and Path(candidate).is_file():
33
+ return str(candidate)
34
+ return None
35
+
36
+
37
+ @contextlib.contextmanager
38
+ def isaac_turn(wait=3600):
39
+ """One Isaac Sim review at a time on this machine: two at once can exhaust memory. The lock file holds the owner's
40
+ process id; one left by a process that has exited is taken over."""
41
+ lock = Path(os.environ.get("SIMREADY_FORGE_LOCK", Path.home() / ".simready-forge" / "isaac.lock"))
42
+ lock.parent.mkdir(parents=True, exist_ok=True)
43
+ deadline = time.monotonic() + wait
44
+ while True:
45
+ try:
46
+ fd = os.open(lock, os.O_CREAT | os.O_EXCL | os.O_WRONLY)
47
+ os.write(fd, str(os.getpid()).encode())
48
+ os.close(fd)
49
+ break
50
+ except FileExistsError:
51
+ if not _alive(lock):
52
+ lock.unlink(missing_ok=True)
53
+ continue
54
+ if time.monotonic() > deadline:
55
+ raise TimeoutError(f"another Isaac review still holds {lock}") from None
56
+ time.sleep(5)
57
+ try:
58
+ yield
59
+ finally:
60
+ lock.unlink(missing_ok=True)
61
+
62
+
63
+ def _alive(lock):
64
+ try:
65
+ pid = int(lock.read_text(encoding="utf-8").strip() or 0)
66
+ except (OSError, ValueError):
67
+ return False
68
+ if pid <= 0:
69
+ return False
70
+ if os.name == "nt":
71
+ import ctypes
72
+
73
+ handle = ctypes.windll.kernel32.OpenProcess(0x1000, False, pid) # PROCESS_QUERY_LIMITED_INFORMATION
74
+ if not handle:
75
+ return False
76
+ code = ctypes.c_ulong()
77
+ ctypes.windll.kernel32.GetExitCodeProcess(handle, ctypes.byref(code))
78
+ ctypes.windll.kernel32.CloseHandle(handle)
79
+ return code.value == 259 # STILL_ACTIVE
80
+ try:
81
+ os.kill(pid, 0)
82
+ except OSError:
83
+ return False
84
+ return True
85
+
86
+
87
+ def review(asset, out_dir, isaac=None, timeout=1800):
88
+ """Review an asset into out_dir. Returns the report dict (review.json)."""
89
+ out = Path(out_dir)
90
+ out.mkdir(parents=True, exist_ok=True)
91
+ python = find_isaac(isaac)
92
+ if python is None:
93
+ return software_review(asset, out)
94
+ env = {k: v for k, v in os.environ.items() if k not in ("PYTHONPATH", "PYTHONHOME")}
95
+ env["OMNI_KIT_ACCEPT_EULA"] = "YES"
96
+ command = [python, str(SCRIPT), str(Path(asset).resolve()), str(out.resolve())]
97
+ with isaac_turn(), (out / "isaac.log").open("w", encoding="utf-8") as log:
98
+ code = subprocess.run( # noqa: S603 (our own interpreter and script paths)
99
+ command,
100
+ env=env,
101
+ stdout=log,
102
+ stderr=subprocess.STDOUT,
103
+ timeout=timeout,
104
+ check=False,
105
+ ).returncode
106
+ report_file = out / "review.json"
107
+ report = json.loads(report_file.read_text(encoding="utf-8")) if report_file.is_file() else {}
108
+ report.update({"renderer": "isaac", "exit_code": code, "log": str(out / "isaac.log")})
109
+ if code == 2 or not report_file.is_file():
110
+ report["error"] = "the Isaac review crashed: see the log"
111
+ report_file.write_text(json.dumps(report, indent=1), encoding="utf-8")
112
+ return report
113
+
114
+
115
+ def probe(asset, out_dir, isaac=None, timeout=3600, tests=()):
116
+ """Probe an asset's physics in Isaac Sim (isaac_probe.py): settle, tilt, push, lift, load, or only the named
117
+ `tests`, kept beside the others' earlier results. Returns the report dict (probe.json), or one with an error when
118
+ Isaac Sim is missing or the probe crashed."""
119
+ out = Path(out_dir)
120
+ out.mkdir(parents=True, exist_ok=True)
121
+ python = find_isaac(isaac)
122
+ if python is None:
123
+ return {"asset": str(asset), "error": "the probe needs Isaac Sim"}
124
+ env = {k: v for k, v in os.environ.items() if k not in ("PYTHONPATH", "PYTHONHOME")}
125
+ env["OMNI_KIT_ACCEPT_EULA"] = "YES"
126
+ command = [python, str(PROBE), str(Path(asset).resolve()), str(out.resolve()), *tests]
127
+ with isaac_turn(), (out / "isaac_probe.log").open("w", encoding="utf-8") as log:
128
+ code = subprocess.run( # noqa: S603 (our own interpreter and script paths)
129
+ command, env=env, stdout=log, stderr=subprocess.STDOUT, timeout=timeout, check=False
130
+ ).returncode
131
+ report_file = out / "probe.json"
132
+ report = json.loads(report_file.read_text(encoding="utf-8")) if report_file.is_file() else {}
133
+ report.update({"exit_code": code, "log": str(out / "isaac_probe.log")})
134
+ if code == 2 or not report_file.is_file():
135
+ report["error"] = "the Isaac probe crashed: see the log"
136
+ report_file.write_text(json.dumps(report, indent=1), encoding="utf-8")
137
+ return report
138
+
139
+
140
+ def software_review(asset, out):
141
+ """Without Isaac Sim: four matplotlib views of the asset's meshes and no physics."""
142
+ from pxr import Usd, UsdGeom
143
+
144
+ from .preview import sheet
145
+
146
+ stage = Usd.Stage.Open(str(asset))
147
+ cache = UsdGeom.XformCache()
148
+ meshes = []
149
+ for prim in stage.Traverse():
150
+ if not prim.IsA("Mesh") or UsdGeom.Imageable(prim).ComputePurpose() == UsdGeom.Tokens.guide:
151
+ continue
152
+ mesh = UsdGeom.Mesh(prim)
153
+ points = np.array(mesh.GetPointsAttr().Get(), dtype=float)
154
+ matrix = np.array(cache.GetLocalToWorldTransform(prim))
155
+ points = points @ matrix[:3, :3] + matrix[3, :3]
156
+ tris = np.array(mesh.GetFaceVertexIndicesAttr().Get(), dtype=int).reshape(-1, 3)
157
+ colour = mesh.GetDisplayColorAttr().Get()
158
+ meshes.append((points, tris, tuple(colour[0]) if colour else (0.7, 0.7, 0.7), 1.0))
159
+ sheet(meshes, out / "review.png")
160
+ report = {
161
+ "asset": str(asset),
162
+ "renderer": "software",
163
+ "contact_sheet": str(out / "review.png"),
164
+ "note": "No Isaac Sim found: shapes and colours only, physics and joints not checked.",
165
+ }
166
+ (out / "review.json").write_text(json.dumps(report, indent=1), encoding="utf-8")
167
+ return report
simready_forge/scan.py ADDED
@@ -0,0 +1,129 @@
1
+ """Generated textured meshes (GLB from an image-to-3D model such as TRELLIS.2) as scans at real size."""
2
+
3
+ from pathlib import Path
4
+ from typing import cast
5
+
6
+ import numpy as np
7
+
8
+ UNITS = {"m": 1.0, "cm": 0.01, "mm": 0.001}
9
+
10
+
11
+ class Scan:
12
+ """A textured triangle mesh in model units: Z up, front at -Y. Faces are split into bodies with `faces_in`."""
13
+
14
+ def __init__(self, path, points, tris, uv, normals, images, scale):
15
+ self.path, self.points, self.tris, self.uv, self.normals = path, points, tris, uv, normals
16
+ self.images, self.scale = images, scale
17
+ self.used = np.zeros(len(tris), dtype=bool)
18
+ self.centres = points[tris].mean(axis=1)
19
+
20
+ def faces_in(self, lo, hi):
21
+ """Faces whose centre lies in the axis-aligned box lo..hi (model units) and are not assigned yet."""
22
+ lo, hi = np.asarray(lo, dtype=float), np.asarray(hi, dtype=float)
23
+ return np.all((self.centres >= lo) & (self.centres <= hi), axis=1) & ~self.used
24
+
25
+ def remaining(self):
26
+ """Faces not assigned to any body yet."""
27
+ return ~self.used
28
+
29
+ def face_colors(self):
30
+ """Each face's colour in the base texture at its mean UV, sRGB 0-255, one row per face (None without a
31
+ texture): to select a sole, a stripe or a label by colour. `uv` holds one pair per triangle corner, so
32
+ `uv[tris]` would read the wrong pixels."""
33
+ if self.images.get("base") is None or self.uv is None:
34
+ return None
35
+ image = np.asarray(self.images["base"].convert("RGB"), dtype=float)
36
+ uv = self.uv.reshape(-1, 3, 2).mean(1) % 1.0
37
+ px = (uv[:, 0] * (image.shape[1] - 1)).astype(int), ((1 - uv[:, 1]) * (image.shape[0] - 1)).astype(int)
38
+ return image[px[1], px[0]]
39
+
40
+ def views(self, path, size=1100):
41
+ """Front (looking +Y), side (looking -X) and top views with a grid in model units, to choose split boxes."""
42
+ import matplotlib
43
+
44
+ matplotlib.use("Agg")
45
+ import matplotlib.pyplot as plt
46
+ from matplotlib.collections import PolyCollection
47
+
48
+ lo, hi = self.points.min(0), self.points.max(0)
49
+ found = self.face_colors()
50
+ colour = np.full((len(self.tris), 3), 0.6) if found is None else found / 255
51
+ fig, axes = plt.subplots(1, 3, figsize=(size / 100, size / 300), dpi=100)
52
+ corners = self.points[self.tris]
53
+ for ax, (a, b, depth, sign, title) in zip(
54
+ axes,
55
+ (
56
+ (0, 2, 1, 1, "front: X right, Z up"),
57
+ (1, 2, 0, -1, "side: Y right, Z up"),
58
+ (0, 1, 2, -1, "top: X right, Y up"),
59
+ ),
60
+ strict=True,
61
+ ):
62
+ order = np.argsort(sign * corners[:, :, depth].mean(1))[::-1]
63
+ ax.add_collection(PolyCollection(corners[order][:, :, [a, b]], facecolors=colour[order], edgecolors="none"))
64
+ ax.set_xlim(lo[a], hi[a])
65
+ ax.set_ylim(lo[b], hi[b])
66
+ ax.set_aspect("equal")
67
+ ax.grid(visible=True, linewidth=0.4, alpha=0.6)
68
+ ax.set_title(title, fontsize=8)
69
+ ax.tick_params(labelsize=6)
70
+ fig.tight_layout()
71
+ fig.savefig(path)
72
+ plt.close(fig)
73
+ return path
74
+
75
+
76
+ def load_scan(path, units="mm", height=None, longest=None, fit=None, turn=0, size=None):
77
+ """Load a GLB (Y-up glTF) as a Scan at real size. Give exactly one of:
78
+
79
+ height= (Z extent) or longest= (largest extent): base on z=0, centred on X/Y;
80
+ size=(width, depth, height): scaled on each axis to the stated size (a request's 450 x 400 x 550), base on z=0,
81
+ centred on X/Y;
82
+ fit=(lo, hi): scaled uniformly to fit that box and placed in it, base on lo z (a part of an assembly).
83
+ turn: degrees about Z applied first, so the part faces the right way (the GLB's front ends up at -Y).
84
+ """
85
+ import trimesh
86
+
87
+ if sum(x is not None for x in (height, longest, fit, size)) != 1:
88
+ raise ValueError("give exactly one of height=, longest=, size=(w, d, h) or fit=(lo, hi) (model units)")
89
+ scene = cast(trimesh.Scene, trimesh.load(path, force="scene"))
90
+ mesh = cast(trimesh.Trimesh, scene.to_geometry())
91
+ mesh.apply_transform(trimesh.transformations.rotation_matrix(np.pi / 2, [1, 0, 0])) # glTF Y-up -> Z-up
92
+ if turn:
93
+ mesh.apply_transform(trimesh.transformations.rotation_matrix(np.radians(turn), [0, 0, 1]))
94
+ s = UNITS[units]
95
+ if fit is not None:
96
+ lo_box, hi_box = np.asarray(fit[0], dtype=float) * s, np.asarray(fit[1], dtype=float) * s
97
+ mesh.apply_scale(float(np.min((hi_box - lo_box) / mesh.extents)))
98
+ lo, hi = mesh.bounds
99
+ centre = (lo_box + hi_box) / 2
100
+ mesh.apply_translation([centre[0] - (lo[0] + hi[0]) / 2, centre[1] - (lo[1] + hi[1]) / 2, lo_box[2] - lo[2]])
101
+ elif size is not None:
102
+ scale = np.eye(4)
103
+ scale[:3, :3] = np.diag(np.asarray(size, dtype=float) * s / mesh.extents)
104
+ mesh.apply_transform(scale) # normals follow: trimesh recomputes them from the scaled faces
105
+ lo, hi = mesh.bounds
106
+ mesh.apply_translation([-(lo[0] + hi[0]) / 2, -(lo[1] + hi[1]) / 2, -lo[2]])
107
+ else:
108
+ one = height if height is not None else longest
109
+ target = float(one or 0) * s # one of the two is set, checked above
110
+ extent = mesh.extents[2] if height is not None else mesh.extents.max()
111
+ mesh.apply_scale(target / float(extent))
112
+ lo, hi = mesh.bounds
113
+ mesh.apply_translation([-(lo[0] + hi[0]) / 2, -(lo[1] + hi[1]) / 2, -lo[2]])
114
+ material = getattr(mesh.visual, "material", None)
115
+ images = {
116
+ key: getattr(material, attr, None)
117
+ for key, attr in (
118
+ ("base", "baseColorTexture"),
119
+ ("metal_rough", "metallicRoughnessTexture"),
120
+ ("normal", "normalTexture"),
121
+ )
122
+ }
123
+ uv = getattr(mesh.visual, "uv", None)
124
+ tris = np.asarray(mesh.faces)
125
+ # trimesh already flips glTF's V to USD's lower-left origin: flipping again scrambles every texture
126
+ corner_uv = np.asarray(uv, dtype=float)[tris].reshape(-1, 2) if uv is not None else None
127
+ return Scan(
128
+ str(Path(path)), np.asarray(mesh.vertices) / s, tris, corner_uv, np.asarray(mesh.vertex_normals), images, s
129
+ )
@@ -0,0 +1,378 @@
1
+ """Local image-to-3D through a ComfyUI server: Z-Image-Turbo (text -> reference image) and TRELLIS.2 (image ->
2
+ textured GLB). Effort sets the quality and how many candidates come back to choose from.
3
+
4
+ simready-forge trellis txt2img "<prompt>" <out_dir> [--effort low|medium|high] [--server URL]
5
+ simready-forge trellis image <image.png|jpg> <out_dir> [--effort low|medium|high] [--server URL]
6
+
7
+ Effort candidates sampling mesh faces textures
8
+ low 1 fast 80k 1024
9
+ medium 2 template 150k 2048
10
+ high 4 more steps 300k 2048 + finer remesh
11
+ The server defaults to $COMFYUI_URL or http://127.0.0.1:8188. It needs ComfyUI's native TRELLIS.2 nodes and the
12
+ models of its "3d_pixal3d_trellis2_image_to_model" and Z-Image-Turbo templates: trellis_2_int8_convrot,
13
+ trellis_2_shape_vae_bf16, trellis_2_texture_vae_bf16, dino_v3_L_naf_fp32, birefnet, z_image_turbo_bf16,
14
+ qwen_3_4b, ae. Prints one JSON line with the files written.
15
+ """
16
+
17
+ import argparse
18
+ import json
19
+ import os
20
+ import time
21
+ import urllib.error
22
+ import urllib.parse
23
+ import urllib.request
24
+ import uuid
25
+ from pathlib import Path
26
+
27
+ EFFORT = {
28
+ "low": {
29
+ "candidates": 1,
30
+ "steps": (8, 12, 8, 8),
31
+ "upsample": 1024,
32
+ "remesh": 512,
33
+ "faces": 80000,
34
+ "texture": 1024,
35
+ "normal": 1024,
36
+ "ao": 512,
37
+ },
38
+ "medium": {
39
+ "candidates": 2,
40
+ "steps": (12, 20, 12, 12),
41
+ "upsample": 1536,
42
+ "remesh": 768,
43
+ "faces": 150000,
44
+ "texture": 2048,
45
+ "normal": 2048,
46
+ "ao": 1024,
47
+ },
48
+ "high": {
49
+ "candidates": 4,
50
+ "steps": (16, 28, 16, 16),
51
+ "upsample": 1536,
52
+ "remesh": 896,
53
+ "faces": 300000,
54
+ "texture": 2048,
55
+ "normal": 2048,
56
+ "ao": 2048,
57
+ },
58
+ }
59
+
60
+
61
+ class Comfy:
62
+ def __init__(self, server):
63
+ # every request goes to this server, so the urlopen calls below can only reach http or https
64
+ if urllib.parse.urlsplit(server).scheme not in ("http", "https"):
65
+ raise ValueError(f"the ComfyUI server must be an http or https URL, not {server!r}")
66
+ self.server = server.rstrip("/")
67
+
68
+ @staticmethod
69
+ def _read(request, tries=4):
70
+ """The response body; a connection that fails to open is retried (a busy machine ran out of local ports, and
71
+ a 2-candidate run lost its second candidate)."""
72
+ for attempt in range(tries):
73
+ try:
74
+ with urllib.request.urlopen(request) as response: # noqa: S310 (checked scheme in __init__)
75
+ return response.read()
76
+ except (urllib.error.URLError, ConnectionError):
77
+ if attempt == tries - 1:
78
+ raise
79
+ time.sleep(5 * (attempt + 1))
80
+ return b""
81
+
82
+ def post(self, path, payload):
83
+ request = urllib.request.Request( # noqa: S310 (checked scheme)
84
+ self.server + path, data=json.dumps(payload).encode(), headers={"Content-Type": "application/json"}
85
+ )
86
+ return json.loads(self._read(request))
87
+
88
+ def upload(self, image):
89
+ image = Path(image)
90
+ boundary = uuid.uuid4().hex
91
+ body = (
92
+ (
93
+ f'--{boundary}\r\nContent-Disposition: form-data; name="image"; filename="{image.name}"\r\n'
94
+ f"Content-Type: application/octet-stream\r\n\r\n"
95
+ ).encode()
96
+ + image.read_bytes()
97
+ + f'\r\n--{boundary}\r\nContent-Disposition: form-data; name="overwrite"\r\n\r\n'.encode()
98
+ + f"true\r\n--{boundary}--\r\n".encode()
99
+ )
100
+ request = urllib.request.Request( # noqa: S310 (checked scheme)
101
+ self.server + "/upload/image",
102
+ data=body,
103
+ headers={"Content-Type": f"multipart/form-data; boundary={boundary}"},
104
+ )
105
+ return json.loads(self._read(request))["name"]
106
+
107
+ def run(self, graph, timeout=3600):
108
+ prompt_id = self.post("/prompt", {"prompt": graph, "client_id": "simready_kit"})["prompt_id"]
109
+ start = time.time()
110
+ while time.time() - start < timeout:
111
+ history = json.loads(self._read(f"{self.server}/history/{prompt_id}"))
112
+ if prompt_id in history:
113
+ status = history[prompt_id].get("status", {})
114
+ if status.get("status_str") == "error":
115
+ raise RuntimeError(json.dumps(status.get("messages", status))[:3000])
116
+ return history[prompt_id]["outputs"]
117
+ time.sleep(2)
118
+ raise TimeoutError(prompt_id)
119
+
120
+ def fetch(self, outputs, kind, target):
121
+ for value in outputs.values():
122
+ for item in value.get(kind, []) if isinstance(value, dict) else []:
123
+ query = urllib.parse.urlencode(
124
+ {
125
+ "filename": item["filename"],
126
+ "subfolder": item.get("subfolder", ""),
127
+ "type": item.get("type", "output"),
128
+ }
129
+ )
130
+ Path(target).write_bytes(self._read(f"{self.server}/view?{query}"))
131
+ return target
132
+ raise RuntimeError(f"no {kind} output in {list(outputs)}")
133
+
134
+
135
+ def image3d_graph(image_name, seed, p, prefix):
136
+ g = {}
137
+
138
+ def node(i, cls, **inputs):
139
+ g[str(i)] = {"class_type": cls, "inputs": inputs}
140
+
141
+ def ref(i, slot=0):
142
+ return [str(i), slot]
143
+
144
+ s1, s2, s3, s4 = p["steps"]
145
+ node(122, "LoadImage", image=image_name)
146
+ node(193, "LoadBackgroundRemovalModel", bg_removal_name="birefnet.safetensors")
147
+ node(192, "RemoveBackground", bg_removal_model=ref(193), image=ref(122))
148
+ node(
149
+ 312,
150
+ "ImageCropToMask",
151
+ images=ref(122),
152
+ masks=ref(192),
153
+ width=1024,
154
+ height=1024,
155
+ pad_factor=1.1,
156
+ grow_mask=0,
157
+ background="#000000",
158
+ )
159
+ node(15, "CLIPVisionLoader", clip_name="dino_v3_L_naf_fp32.safetensors")
160
+ node(40, "UNETLoader", unet_name="trellis_2_int8_convrot.safetensors", weight_dtype="default")
161
+ node(299, "Trellis2Conditioning", clip_vision_model=ref(15), image=ref(312))
162
+ node(199, "CFGOverride", model=ref(40), cfg=1, start_percent=0.667, end_percent=1)
163
+ node(125, "RescaleCFG", model=ref(199), multiplier=0.7)
164
+ node(108, "ModelSamplingSD3", model=ref(125), shift=5)
165
+ node(87, "EmptyTrellis2LatentStructure", batch_size=1)
166
+ node(
167
+ 3,
168
+ "KSampler",
169
+ model=ref(108),
170
+ seed=seed + 14,
171
+ steps=s1,
172
+ cfg=7.5,
173
+ sampler_name="euler",
174
+ scheduler="normal",
175
+ positive=ref(299, 0),
176
+ negative=ref(299, 1),
177
+ latent_image=ref(87),
178
+ denoise=1,
179
+ )
180
+ node(117, "VAELoader", vae_name="trellis_2_shape_vae_bf16.safetensors")
181
+ node(118, "VAELoader", vae_name="trellis_2_texture_vae_bf16.safetensors")
182
+ node(119, "VaeDecodeStructureTrellis2", samples=ref(3), vae=ref(117), resolution="32")
183
+ node(91, "Trellis2ShapeStage", positive=ref(299, 0), negative=ref(299, 1), voxel=ref(119))
184
+ node(279, "CFGOverride", model=ref(40), cfg=1, start_percent=0.769, end_percent=1)
185
+ node(126, "RescaleCFG", model=ref(279), multiplier=0.5)
186
+ node(
187
+ 18,
188
+ "KSampler",
189
+ model=ref(126),
190
+ seed=seed,
191
+ steps=s2,
192
+ cfg=7.5,
193
+ sampler_name="euler",
194
+ scheduler="normal",
195
+ positive=ref(91, 0),
196
+ negative=ref(91, 1),
197
+ latent_image=ref(91, 2),
198
+ denoise=1,
199
+ )
200
+ node(
201
+ 94,
202
+ "Trellis2UpsampleStage",
203
+ positive=ref(91, 0),
204
+ negative=ref(91, 1),
205
+ shape_latent=ref(18),
206
+ vae=ref(117),
207
+ target_resolution=p["upsample"],
208
+ )
209
+ node(
210
+ 23,
211
+ "KSampler",
212
+ model=ref(126),
213
+ seed=seed,
214
+ steps=s3,
215
+ cfg=7.5,
216
+ sampler_name="euler",
217
+ scheduler="simple",
218
+ positive=ref(94, 0),
219
+ negative=ref(94, 1),
220
+ latent_image=ref(94, 2),
221
+ denoise=1,
222
+ )
223
+ node(92, "VaeDecodeShapeTrellis", samples=ref(23), vae=ref(117))
224
+ node(98, "Trellis2TextureStage", positive=ref(94, 0), negative=ref(94, 1), shape_latent=ref(23))
225
+ node(
226
+ 12,
227
+ "KSampler",
228
+ model=ref(40),
229
+ seed=seed + 1,
230
+ steps=s4,
231
+ cfg=1,
232
+ sampler_name="euler",
233
+ scheduler="normal",
234
+ positive=ref(98, 0),
235
+ negative=ref(98, 1),
236
+ latent_image=ref(98, 2),
237
+ denoise=1,
238
+ )
239
+ node(93, "VaeDecodeTextureTrellis", samples=ref(12), vae=ref(118), shape_subdivides=ref(92, 1))
240
+ node(202, "GetMeshInfo", mesh=ref(92))
241
+ g["241"] = {
242
+ "class_type": "RemeshMesh",
243
+ "inputs": {
244
+ "mesh": ref(202),
245
+ "resolution": p["remesh"],
246
+ "sign_mode": "solid",
247
+ "sign_mode.fill": 20.0,
248
+ "sign_mode.qef": False,
249
+ "band": 1.0,
250
+ "project_back": 0.0,
251
+ "fix_poles": False,
252
+ "smooth_iters": 20,
253
+ "drop_small_components": 0.01,
254
+ "precluster_max_verts": 20000000,
255
+ },
256
+ }
257
+ node(186, "DecimateMesh", mesh=ref(241), target_face_count=p["faces"], placement_mode="midpoint")
258
+ node(238, "MeshSmoothNormals", mesh=ref(186), crease_angle=180)
259
+ node(196, "UnwrapMesh", mesh=ref(238), segmenter="pec", resolution=p["texture"], padding=1, weld_distance=0.0002)
260
+ node(
261
+ 147,
262
+ "BakeTextureFromVoxel",
263
+ mesh=ref(196),
264
+ voxel_colors=ref(93),
265
+ reference_mesh=ref(92),
266
+ texture_size=p["texture"],
267
+ )
268
+ node(
269
+ 224,
270
+ "BakeNormalMapFromMesh",
271
+ low_poly=ref(196),
272
+ high_poly=ref(241),
273
+ resolution=p["normal"],
274
+ cage_distance=0.05,
275
+ ignore_backfaces=True,
276
+ )
277
+ node(
278
+ 233,
279
+ "BakeAmbientOcclusion",
280
+ low_poly=ref(196),
281
+ high_poly=ref(241),
282
+ resolution=p["ao"],
283
+ samples=64,
284
+ max_distance=0.71,
285
+ strength=1,
286
+ bias=0.01,
287
+ )
288
+ node(
289
+ 210,
290
+ "ApplyTextureToMesh",
291
+ mesh=ref(196),
292
+ base_color=ref(147, 0),
293
+ metallic=ref(147, 1),
294
+ roughness=ref(147, 2),
295
+ occlusion=ref(233),
296
+ normal_map=ref(224),
297
+ )
298
+ node(260, "MeshSmoothNormals", mesh=ref(210), crease_angle=180)
299
+ node(900, "SaveGLB", mesh=ref(260), filename_prefix=prefix)
300
+ node(901, "SaveImage", images=ref(312), filename_prefix=prefix + "_input")
301
+ return g
302
+
303
+
304
+ def txt2img_graph(text, seed, prefix):
305
+ return {
306
+ "1": {
307
+ "class_type": "UNETLoader",
308
+ "inputs": {"unet_name": "z_image_turbo_bf16.safetensors", "weight_dtype": "default"},
309
+ },
310
+ "2": {
311
+ "class_type": "CLIPLoader",
312
+ "inputs": {"clip_name": "qwen_3_4b.safetensors", "type": "lumina2", "device": "default"},
313
+ },
314
+ "3": {"class_type": "VAELoader", "inputs": {"vae_name": "ae.safetensors"}},
315
+ "4": {"class_type": "CLIPTextEncode", "inputs": {"clip": ["2", 0], "text": text}},
316
+ "5": {"class_type": "ConditioningZeroOut", "inputs": {"conditioning": ["4", 0]}},
317
+ "6": {"class_type": "EmptySD3LatentImage", "inputs": {"width": 1024, "height": 1024, "batch_size": 1}},
318
+ "7": {"class_type": "ModelSamplingAuraFlow", "inputs": {"model": ["1", 0], "shift": 3}},
319
+ "8": {
320
+ "class_type": "KSampler",
321
+ "inputs": {
322
+ "model": ["7", 0],
323
+ "seed": seed,
324
+ "steps": 8,
325
+ "cfg": 1,
326
+ "sampler_name": "res_multistep",
327
+ "scheduler": "simple",
328
+ "positive": ["4", 0],
329
+ "negative": ["5", 0],
330
+ "latent_image": ["6", 0],
331
+ "denoise": 1,
332
+ },
333
+ },
334
+ "9": {"class_type": "VAEDecode", "inputs": {"samples": ["8", 0], "vae": ["3", 0]}},
335
+ "10": {"class_type": "SaveImage", "inputs": {"images": ["9", 0], "filename_prefix": prefix}},
336
+ }
337
+
338
+
339
+ def main(argv=None):
340
+ parser = argparse.ArgumentParser(prog="simready-forge trellis")
341
+ parser.add_argument("mode", choices=["txt2img", "image"])
342
+ parser.add_argument("source", help="text prompt (txt2img) or image path (image)")
343
+ parser.add_argument("out_dir")
344
+ parser.add_argument("--effort", default="medium", choices=list(EFFORT))
345
+ parser.add_argument("--server", default=os.environ.get("COMFYUI_URL", "http://127.0.0.1:8188"))
346
+ parser.add_argument("--seed", type=int, default=42)
347
+ args = parser.parse_args(argv)
348
+ comfy, p = Comfy(args.server), EFFORT[args.effort]
349
+ out = Path(args.out_dir)
350
+ out.mkdir(parents=True, exist_ok=True)
351
+ from .review import isaac_turn
352
+
353
+ with isaac_turn(): # the same GPU as Isaac Sim: one heavy job on the machine at a time
354
+ written, start = [], time.time()
355
+ if args.mode == "txt2img":
356
+ for k in range(p["candidates"]):
357
+ outputs = comfy.run(
358
+ txt2img_graph(args.source, args.seed + 101 * k, f"simready_kit/ref_{uuid.uuid4().hex[:8]}")
359
+ )
360
+ written.append(str(comfy.fetch(outputs, "images", out / f"reference_{k}.png")))
361
+ else:
362
+ name = comfy.upload(args.source)
363
+ for k in range(p["candidates"]):
364
+ prefix = f"simready_kit/mesh_{uuid.uuid4().hex[:8]}"
365
+ outputs = comfy.run(image3d_graph(name, args.seed + 1000 * k, p, prefix))
366
+ written.append(str(comfy.fetch(outputs, "3d", out / f"candidate_{k}.glb")))
367
+ if k == 0:
368
+ comfy.fetch(outputs, "images", out / "input_crop.png")
369
+ print(
370
+ json.dumps(
371
+ {"mode": args.mode, "effort": args.effort, "files": written, "seconds": round(time.time() - start, 1)}
372
+ )
373
+ )
374
+ return 0
375
+
376
+
377
+ if __name__ == "__main__":
378
+ raise SystemExit(main())