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,293 @@
1
+ """The review's joint poses without Isaac Sim: put a USD asset in the poses the Isaac review drives it to (every joint
2
+ at 80 % of the far side of its range, then every two-sided joint at 80 % of the other side), and report where
3
+ colliders of bodies that are not jointed to each other overlap, and how low the asset reaches (the ground is z = 0).
4
+
5
+ Collision shapes are taken as the convex hull of each collider mesh, as PhysX takes a convexHull approximation; an
6
+ overlap is the least push along a face normal of either hull that separates two hulls (edge-to-edge contacts are not
7
+ tested, so a few overlaps reported may be near misses). Good enough to catch an arm's head in the desk or a lid
8
+ swung into its box before a review.
9
+ """
10
+
11
+ import json
12
+ import math
13
+ from pathlib import Path
14
+
15
+ import numpy as np
16
+ from pxr import Usd, UsdGeom, UsdPhysics
17
+ from scipy.spatial import ConvexHull, QhullError
18
+
19
+ AXIS = {"X": 0, "Y": 1, "Z": 2}
20
+ TOLERANCE = 0.001 # m: overlaps and dips below the ground smaller than this are contact, not a fault
21
+ EMPTY = 0.02 # an opening counts as open when colliders fill at most 2 % of its box
22
+ NEAR = 0.002 # m: bodies closer than this at rest touch through PhysX's contact offset and prop each other up
23
+ SWEEP = 0.003 # m: how much deeper than at rest two jointed bodies may sit (hinge knuckles interleave at rest)
24
+
25
+
26
+ def _quat(q):
27
+ """A Gf quaternion as a 3x3 rotation acting on column vectors."""
28
+ w, (x, y, z) = q.GetReal(), q.GetImaginary()
29
+ return np.array(
30
+ [
31
+ [1 - 2 * (y * y + z * z), 2 * (x * y - w * z), 2 * (x * z + w * y)],
32
+ [2 * (x * y + w * z), 1 - 2 * (x * x + z * z), 2 * (y * z - w * x)],
33
+ [2 * (x * z - w * y), 2 * (y * z + w * x), 1 - 2 * (x * x + y * y)],
34
+ ]
35
+ )
36
+
37
+
38
+ def _world(cache, prim):
39
+ """A prim's world rotation (column vectors) and translation, in metres."""
40
+ m = np.array(cache.GetLocalToWorldTransform(prim)) # row vectors: p_world = p @ m[:3, :3] + m[3, :3]
41
+ return m[:3, :3].T, m[3, :3]
42
+
43
+
44
+ def _rotation(axis, degrees):
45
+ """Rodrigues: a rotation of `degrees` about the unit `axis`."""
46
+ a = axis / np.linalg.norm(axis)
47
+ k = np.array([[0, -a[2], a[1]], [a[2], 0, -a[0]], [-a[1], a[0], 0]])
48
+ t = math.radians(degrees)
49
+ return np.eye(3) + math.sin(t) * k + (1 - math.cos(t)) * k @ k
50
+
51
+
52
+ class Joint:
53
+ """A revolute or prismatic joint read from the stage: its bodies, world anchor and axis at rest, its position at
54
+ rest in its own coordinate, and the review's targets."""
55
+
56
+ def __init__(self, prim, cache):
57
+ joint = UsdPhysics.Joint(prim)
58
+ self.name = prim.GetName()
59
+ self.revolute = prim.IsA("PhysicsRevoluteJoint")
60
+ b0, b1 = joint.GetBody0Rel().GetTargets(), joint.GetBody1Rel().GetTargets()
61
+ self.body0, self.body1 = (str(b0[0]) if b0 else None), str(b1[0])
62
+ stage = prim.GetStage()
63
+ r0, t0 = _world(cache, stage.GetPrimAtPath(self.body0)) if self.body0 else (np.eye(3), np.zeros(3))
64
+ r1, t1 = _world(cache, stage.GetPrimAtPath(self.body1))
65
+ rot0, rot1 = joint.GetLocalRot0Attr().Get(), joint.GetLocalRot1Attr().Get()
66
+ frame0 = r0 @ (_quat(rot0) if rot0 is not None else np.eye(3))
67
+ frame1 = r1 @ (_quat(rot1) if rot1 is not None else np.eye(3))
68
+ pos0 = np.array(joint.GetLocalPos0Attr().Get() or (0, 0, 0), dtype=float)
69
+ pos1 = np.array(joint.GetLocalPos1Attr().Get() or (0, 0, 0), dtype=float)
70
+ index = AXIS[prim.GetAttribute("physics:axis").Get()]
71
+ self.anchor = r0 @ pos0 + t0
72
+ self.axis = frame0[:, index]
73
+ relative = frame0.T @ frame1
74
+ if self.revolute: # the rest position: frame 1's turn about the axis from frame 0 (a lamp can rest at -37)
75
+ angle = math.degrees(math.acos(max(-1.0, min(1.0, (np.trace(relative) - 1) / 2))))
76
+ w = np.array(
77
+ [relative[2, 1] - relative[1, 2], relative[0, 2] - relative[2, 0], relative[1, 0] - relative[0, 1]]
78
+ )
79
+ self.rest = angle if w[index] >= 0 else -angle
80
+ else:
81
+ self.rest = float(frame0[:, index] @ ((r1 @ pos1 + t1) - self.anchor))
82
+ lo, hi = prim.GetAttribute("physics:lowerLimit").Get(), prim.GetAttribute("physics:upperLimit").Get()
83
+ lo = -math.inf if lo is None else lo
84
+ hi = math.inf if hi is None else hi
85
+ free = not (math.isfinite(lo) and math.isfinite(hi)) or max(abs(lo), abs(hi)) > 1e6
86
+ if free and not self.revolute:
87
+ self.sides = [] # the review leaves an unlimited slide alone
88
+ return
89
+ if free:
90
+ lo, hi = -90.0, 90.0
91
+ far, near = (lo, hi) if abs(lo) > abs(hi) else (hi, lo)
92
+ self.sides = [0.8 * far] + ([0.8 * near] if abs(near) > 0.1 * (hi - lo) else [])
93
+
94
+ def motion(self, rotation, translation, target):
95
+ """The joint's move to `target` once its parent has moved by (rotation, translation), as a rigid transform."""
96
+ anchor, axis = rotation @ self.anchor + translation, rotation @ self.axis
97
+ travel = target - self.rest
98
+ if self.revolute:
99
+ turn = _rotation(axis, travel)
100
+ return turn, anchor - turn @ anchor
101
+ return np.eye(3), axis / np.linalg.norm(axis) * travel
102
+
103
+
104
+ def _colliders(stage, cache):
105
+ """Every collider mesh as (body path, collider name, its points in world stage units at rest)."""
106
+ found = []
107
+ for prim in stage.Traverse():
108
+ if not prim.HasAPI("PhysicsRigidBodyAPI"):
109
+ continue
110
+ for child in Usd.PrimRange(prim):
111
+ if child != prim and child.HasAPI("PhysicsRigidBodyAPI"):
112
+ continue
113
+ if child.HasAPI("PhysicsCollisionAPI") and child.IsA("Mesh"):
114
+ points = np.array(UsdGeom.Mesh(child).GetPointsAttr().Get() or [], dtype=float)
115
+ if len(points) >= 4:
116
+ m = np.array(cache.GetLocalToWorldTransform(child))
117
+ found.append((str(prim.GetPath()), child.GetName(), points @ m[:3, :3] + m[3, :3]))
118
+ return found
119
+
120
+
121
+ def _hulls(stage, cache):
122
+ """Each rigid body's collider hulls, in world stage units at rest."""
123
+ bodies = {str(p.GetPath()): [] for p in stage.Traverse() if p.HasAPI("PhysicsRigidBodyAPI")}
124
+ for body, _, points in _colliders(stage, cache):
125
+ bodies[body].append(points)
126
+ return bodies
127
+
128
+
129
+ def _empty(colliders, box, samples=14):
130
+ """How much of a box that should stay empty (an opening, a cup's inside) the colliders fill, and which ones."""
131
+ lo, hi = np.asarray(box[:3], dtype=float), np.asarray(box[3:], dtype=float)
132
+ axes = [np.linspace(lo[i], hi[i], samples) for i in range(3)]
133
+ points = np.stack(np.meshgrid(*axes, indexing="ij"), axis=-1).reshape(-1, 3)
134
+ filled = np.zeros(len(points), bool)
135
+ by = {}
136
+ for _, name, hull in colliders:
137
+ if np.any(hull.max(0) < lo) or np.any(hull.min(0) > hi):
138
+ continue
139
+ try:
140
+ equations = ConvexHull(hull).equations
141
+ except QhullError:
142
+ continue
143
+ inside = np.all(points @ equations[:, :3].T + equations[:, 3] <= 0, axis=1)
144
+ if inside.any():
145
+ by[name] = round(float(inside.mean()), 3)
146
+ filled |= inside
147
+ worst = sorted(by.items(), key=lambda kv: -kv[1])[:5]
148
+ return {"filled": round(float(filled.mean()), 3), "by": [{"collider": k, "share": v} for k, v in worst]}
149
+
150
+
151
+ def _normals(hull):
152
+ try:
153
+ return ConvexHull(hull).equations[:, :3]
154
+ except QhullError: # flat or degenerate: no volume to sink into
155
+ return np.zeros((0, 3))
156
+
157
+
158
+ def _depth(a, b):
159
+ """How far two convex hulls sit in each other: the least overlap of their shadows along any face normal of
160
+ either (the separating-axis test over face normals); negative, the gap, when some face separates them."""
161
+ normals = np.vstack([_normals(a), _normals(b)])
162
+ if not len(normals):
163
+ return -math.inf
164
+ pa, pb = a @ normals.T, b @ normals.T
165
+ overlap = np.minimum(pa.max(0), pb.max(0)) - np.maximum(pa.min(0), pb.min(0))
166
+ return float(overlap.min())
167
+
168
+
169
+ def _depths(moved, margin=0.0):
170
+ """How deep each pair of bodies' hulls sit in each other (negative: the gap), for every pair whose boxes come
171
+ within `margin`."""
172
+ found = {}
173
+ names = sorted(moved)
174
+ for i, a in enumerate(names):
175
+ for b in names[i + 1 :]:
176
+ depth = -math.inf
177
+ for ha in moved[a]:
178
+ for hb in moved[b]:
179
+ lo, hi = np.maximum(ha.min(0), hb.min(0)), np.minimum(ha.max(0), hb.max(0))
180
+ if np.all(lo <= hi + margin):
181
+ depth = max(depth, _depth(ha, hb))
182
+ if depth > -margin:
183
+ found[frozenset((a, b))] = depth
184
+ return found
185
+
186
+
187
+ def _overlaps(depths, rest, jointed, tolerance, sweep, scale):
188
+ """Bodies not jointed to each other that overlap; jointed ones that sit deeper in each other than at rest. PhysX
189
+ does not collide jointed bodies, so a lid whose limits swing it into its box goes straight through."""
190
+ found = []
191
+ for pair, depth in sorted(depths.items(), key=lambda kv: sorted(kv[0])):
192
+ joined = pair in jointed
193
+ if (depth - max(rest.get(pair, 0.0), 0.0) > sweep) if joined else (depth > tolerance):
194
+ entry = {"bodies": sorted(b.rsplit("/", 1)[-1] for b in pair), "depth_mm": round(depth * scale * 1000, 1)}
195
+ found.append({**entry, "jointed": True} if joined else entry)
196
+ return found
197
+
198
+
199
+ def poses(path, empty=(), chosen=None):
200
+ """The asset in its rest pose and the review's two combined poses: overlaps and the lowest point of each."""
201
+ stage = Usd.Stage.Open(str(path))
202
+ scale = UsdGeom.GetStageMetersPerUnit(stage)
203
+ cache = UsdGeom.XformCache()
204
+ tolerance, sweep = TOLERANCE / scale, SWEEP / scale # everything below is in stage units; the report in mm
205
+ hulls = _hulls(stage, cache)
206
+ joints = [
207
+ Joint(p, cache) for p in stage.Traverse() if p.IsA("PhysicsRevoluteJoint") or p.IsA("PhysicsPrismaticJoint")
208
+ ]
209
+ jointed = {frozenset((j.body0, j.body1)) for j in joints if j.body0}
210
+ children = {}
211
+ for j in joints:
212
+ children.setdefault(j.body0, []).append(j)
213
+ roots = [b for b in hulls if b not in {j.body1 for j in joints}]
214
+
215
+ rest_depths = {}
216
+ rest_low = {b: min((float(h[:, 2].min()) for h in hs), default=math.inf) for b, hs in hulls.items()}
217
+ floor = min(rest_low.values(), default=0.0)
218
+ supports = [b for b, z in rest_low.items() if z <= floor + NEAR / scale]
219
+
220
+ def pose(targets):
221
+ moved = {b: (np.eye(3), np.zeros(3)) for b in hulls}
222
+ todo = list(roots) + ([None] if None in children else [])
223
+ while todo:
224
+ parent = todo.pop(0)
225
+ rotation, translation = moved.get(str(parent), (np.eye(3), np.zeros(3))) # None: the world
226
+ for j in children.get(parent, []):
227
+ target = targets.get(j.name, j.rest)
228
+ turn, shift = j.motion(rotation, translation, target)
229
+ moved[j.body1] = (turn @ rotation, turn @ translation + shift)
230
+ todo.append(j.body1)
231
+ placed = {b: [h @ moved[b][0].T + moved[b][1] for h in hs] for b, hs in hulls.items()}
232
+
233
+ def low(bodies):
234
+ return min((float(h[:, 2].min()) for b in bodies for h in placed[b]), default=math.inf)
235
+
236
+ # a body that stands on the ground at rest is a support: folding it lowers the stance, and the asset settles
237
+ # onto it; anything else going below the ground (an arm's head into the desk) is a fault
238
+ free_low, stance_low = low([b for b in placed if b not in supports]), low(supports)
239
+ lowest = min(free_low, stance_low) if math.isfinite(min(free_low, stance_low)) else 0.0
240
+ found = {
241
+ "targets": {k: round(v, 4) for k, v in targets.items()},
242
+ "overlaps": _overlaps(
243
+ _depths(placed) if targets else rest_depths, rest_depths, jointed, tolerance, sweep, scale
244
+ ),
245
+ "lowest_mm": round(lowest * scale * 1000, 1),
246
+ "below_ground": bool(math.isfinite(free_low) and free_low < -tolerance),
247
+ }
248
+ if math.isfinite(stance_low) and stance_low < -tolerance:
249
+ found["stance_lowered_mm"] = round(-stance_low * scale * 1000, 1) # supports folded under: expected
250
+ return found
251
+
252
+ near = NEAR / scale
253
+ rest_depths.update(_depths({b: hulls[b] for b in hulls}, margin=near))
254
+ far = {j.name: j.sides[0] for j in joints if j.sides}
255
+ other = {**far, **{j.name: j.sides[1] for j in joints if len(j.sides) > 1}}
256
+ report = {"asset": str(path), "rest": pose({}), "far": pose(far)}
257
+ if other != far:
258
+ report["other_side"] = pose(other)
259
+ singles = [(j.name, side) for j in joints for side in j.sides] # each joint alone: a chain's own working poses
260
+ checked = [{"joint": name, **pose({name: side})} for name, side in singles]
261
+ report["single"] = [p for p in checked if p["overlaps"] or p["below_ground"]]
262
+ report["single_checked"] = len(singles)
263
+ report["near_at_rest"] = [
264
+ {"bodies": sorted(b.rsplit("/", 1)[-1] for b in pair), "gap_mm": round(-depth * scale * 1000, 2)}
265
+ for pair, depth in sorted(rest_depths.items(), key=lambda kv: sorted(kv[0]))
266
+ if pair not in jointed and -near + 1e-5 / scale < depth <= tolerance # 2 mm itself is far enough
267
+ ]
268
+ every = [p for p in report.values() if isinstance(p, dict)] + report["single"]
269
+ report["clear"] = all(not p["overlaps"] and not p["below_ground"] for p in every)
270
+ if chosen: # joint targets the builder picks: degrees for a hinge, mm for a slide
271
+ by_name = {j.name: j for j in joints}
272
+ unknown = sorted(set(chosen) - set(by_name))
273
+ if unknown:
274
+ raise ValueError(f"no joint named {unknown}; the joints are {sorted(by_name)}")
275
+ targets = {k: v if by_name[k].revolute else v * 0.001 / scale for k, v in chosen.items()}
276
+ report["chosen"] = pose(targets)
277
+ report["clear"] = report["clear"] and not report["chosen"]["overlaps"] and not report["chosen"]["below_ground"]
278
+ if empty: # boxes in mm that should stay empty at rest: openings, cavities
279
+ colliders = _colliders(stage, cache)
280
+ to_stage = 0.001 / scale
281
+ report["empty"] = [{"box_mm": list(b), **_empty(colliders, [x * to_stage for x in b])} for b in empty]
282
+ report["clear"] = report["clear"] and all(e["filled"] <= EMPTY for e in report["empty"])
283
+ return report
284
+
285
+
286
+ def main(path, out=None, empty=(), chosen=None):
287
+ report = poses(path, empty, chosen)
288
+ text = json.dumps(report, indent=1)
289
+ if out:
290
+ Path(out).parent.mkdir(parents=True, exist_ok=True)
291
+ Path(out).write_text(text, encoding="utf-8")
292
+ print(text)
293
+ return 0 if report["clear"] else 1
@@ -0,0 +1,105 @@
1
+ """Remembered choices: the plugin's intake asks once and remembers for a session, a project or always.
2
+
3
+ The file is preferences.json in the plugin's data folder (the make skill passes ${CLAUDE_PLUGIN_DATA}), else
4
+ ~/.simready-forge. A session's or a project's value wins over the always value, which wins over the defaults.
5
+ """
6
+
7
+ import json
8
+ from pathlib import Path
9
+
10
+ DEFAULTS = {
11
+ "mode": "standard", # quick | standard | max
12
+ "builder_model": "sonnet", # sonnet | opus | haiku | fable | inherit
13
+ "builder_effort": "inherit", # low | medium | high | xhigh | max | inherit
14
+ "critic_model": "opus",
15
+ "critics": "auto", # none | <rounds> | auto
16
+ "max_rounds": 4,
17
+ "geometry": "auto", # auto | cad | mesh | hybrid
18
+ "trellis_effort": "medium", # low | medium | high
19
+ "formats": ["usd", "urdf", "glb"],
20
+ "articulation": "all", # all | main | static
21
+ "materials": "textured", # flat | textured | photo
22
+ "license": "Proprietary",
23
+ "out_dir": "simready_assets",
24
+ "isaac_python": None,
25
+ "comfyui_url": "http://127.0.0.1:8188",
26
+ }
27
+ SCOPES = ("session", "project", "always")
28
+
29
+
30
+ def _file(data_dir=None):
31
+ folder = Path(data_dir) if data_dir else Path.home() / ".simready-forge"
32
+ folder.mkdir(parents=True, exist_ok=True)
33
+ return folder / "preferences.json"
34
+
35
+
36
+ def _load(data_dir=None):
37
+ path = _file(data_dir)
38
+ return (
39
+ json.loads(path.read_text(encoding="utf-8")) if path.is_file() else {"always": {}, "project": {}, "session": {}}
40
+ )
41
+
42
+
43
+ def resolve(data_dir=None, session=None, project=None):
44
+ """The effective choices, and where each came from."""
45
+ store = _load(data_dir)
46
+ values, source = dict(DEFAULTS), dict.fromkeys(DEFAULTS, "default")
47
+ layers = [
48
+ ("always", store.get("always", {})),
49
+ ("project", store.get("project", {}).get(str(project), {})),
50
+ ("session", store.get("session", {}).get(str(session), {})),
51
+ ]
52
+ for scope, layer in layers:
53
+ for key, value in layer.items():
54
+ values[key], source[key] = value, scope
55
+ return values, source
56
+
57
+
58
+ def remembered(data_dir=None, session=None, project=None):
59
+ """True when anything beyond the defaults applies to this session or project (the intake can be skipped)."""
60
+ _, source = resolve(data_dir, session, project)
61
+ return any(scope != "default" for scope in source.values())
62
+
63
+
64
+ def set_values(values, scope, data_dir=None, session=None, project=None):
65
+ if scope not in SCOPES:
66
+ raise ValueError(f"scope must be one of {SCOPES}")
67
+ unknown = set(values) - set(DEFAULTS)
68
+ if unknown:
69
+ raise ValueError(f"unknown preferences {sorted(unknown)}; known: {sorted(DEFAULTS)}")
70
+ store = _load(data_dir)
71
+ if scope == "always":
72
+ store.setdefault("always", {}).update(values)
73
+ else:
74
+ key = str(session if scope == "session" else project)
75
+ if key == "None":
76
+ raise ValueError(f"scope {scope} needs --{scope}")
77
+ store.setdefault(scope, {}).setdefault(key, {}).update(values)
78
+ _file(data_dir).write_text(json.dumps(store, indent=1), encoding="utf-8")
79
+
80
+
81
+ def reset(scope, data_dir=None, session=None, project=None):
82
+ store = _load(data_dir)
83
+ if scope == "always":
84
+ store["always"] = {}
85
+ else:
86
+ store.get(scope, {}).pop(str(session if scope == "session" else project), None)
87
+ _file(data_dir).write_text(json.dumps(store, indent=1), encoding="utf-8")
88
+
89
+
90
+ def parse(pairs):
91
+ """key=value strings into typed values: numbers, true/false/none, and comma lists for formats."""
92
+ out = {}
93
+ for pair in pairs:
94
+ key, _, raw = pair.partition("=")
95
+ value: object = raw
96
+ if raw.lower() in ("none", "null"):
97
+ value = None
98
+ elif raw.lower() in ("true", "false"):
99
+ value = raw.lower() == "true"
100
+ elif raw.isdigit():
101
+ value = int(raw)
102
+ elif key == "formats":
103
+ value = [f.strip() for f in raw.split(",") if f.strip()]
104
+ out[key.strip()] = value
105
+ return out
@@ -0,0 +1,74 @@
1
+ """Software previews (matplotlib, no GPU): the package thumbnail and a multi-view sheet when Isaac Sim is absent."""
2
+
3
+ import io
4
+ from typing import TYPE_CHECKING, cast
5
+
6
+ import numpy as np
7
+
8
+ if TYPE_CHECKING:
9
+ from mpl_toolkits.mplot3d import Axes3D
10
+
11
+ LIGHT = np.array([0.35, -0.55, 0.75]) / np.linalg.norm([0.35, -0.55, 0.75])
12
+
13
+
14
+ def _polys(meshes, cap=80000):
15
+ polys, colours = [], []
16
+ for points, tris, rgb, opacity in meshes:
17
+ corners = points[tris]
18
+ normal = np.cross(corners[:, 1] - corners[:, 0], corners[:, 2] - corners[:, 0])
19
+ normal /= np.linalg.norm(normal, axis=1, keepdims=True) + 1e-12
20
+ shade = 0.3 + 0.7 * np.clip(normal @ LIGHT, 0, 1)
21
+ rgba = np.empty((len(tris), 4))
22
+ rgba[:, :3] = np.clip(np.array(rgb)[None, :3] * shade[:, None], 0, 1)
23
+ rgba[:, 3] = max(opacity, 0.35)
24
+ polys.append(corners)
25
+ colours.append(rgba)
26
+ polys, colours = np.concatenate(polys), np.concatenate(colours)
27
+ if len(polys) > cap: # keep previews fast on dense scans
28
+ pick = np.random.default_rng(0).choice(len(polys), cap, replace=False)
29
+ polys, colours = polys[pick], colours[pick]
30
+ return polys, colours
31
+
32
+
33
+ def _render(polys, colours, elevation, azimuth, pixels):
34
+ import matplotlib
35
+
36
+ matplotlib.use("Agg")
37
+ import matplotlib.pyplot as plt
38
+ from mpl_toolkits.mplot3d.art3d import Poly3DCollection
39
+ from PIL import Image
40
+
41
+ lo, hi = polys.reshape(-1, 3).min(0), polys.reshape(-1, 3).max(0)
42
+ centre, half = (lo + hi) / 2, (hi - lo).max() / 2 * 0.82
43
+ fig = plt.figure(figsize=(4, 4), dpi=128)
44
+ ax = cast("Axes3D", fig.add_axes((0, 0, 1, 1), projection="3d"))
45
+ ax.set_axis_off()
46
+ ax.view_init(elev=elevation, azim=azimuth)
47
+ # edges in the face colour hide the seams matplotlib leaves between triangles
48
+ ax.add_collection3d(Poly3DCollection(polys, facecolors=colours, edgecolors=colours, linewidths=0.3))
49
+ for setter, c in zip((ax.set_xlim, ax.set_ylim, ax.set_zlim), centre, strict=True):
50
+ setter(c - half, c + half)
51
+ ax.set_box_aspect((1, 1, 1))
52
+ buffer = io.BytesIO()
53
+ fig.savefig(buffer, format="png", facecolor=(0.92, 0.92, 0.93))
54
+ plt.close(fig)
55
+ return Image.open(buffer).convert("RGB").resize((pixels, pixels), Image.Resampling.LANCZOS)
56
+
57
+
58
+ def thumbnail(meshes, path, size=256):
59
+ """The package's 256x256 thumbnail. meshes = [(points, tris, rgb, opacity)]."""
60
+ polys, colours = _polys(meshes)
61
+ _render(polys, colours, 24, -58, size).save(path)
62
+
63
+
64
+ def sheet(meshes, path):
65
+ """Front, three-quarter, side and top previews side by side, for a review without Isaac Sim."""
66
+ from PIL import Image
67
+
68
+ polys, colours = _polys(meshes)
69
+ views = [(8, -90), (24, -58), (8, 0), (75, -90)]
70
+ canvas = Image.new("RGB", (4 * 320, 320), (22, 24, 28))
71
+ for i, (elevation, azimuth) in enumerate(views):
72
+ canvas.paste(_render(polys, colours, elevation, azimuth, 320), (i * 320, 0))
73
+ canvas.save(path)
74
+ return path
@@ -0,0 +1,177 @@
1
+ """What a USD asset holds, read from the stage itself: units, metadata, size, rigid bodies with their mass and
2
+ colliders, joints with their frames, limits and drives, and materials with their visual, physics and sensor values.
3
+ No mesh data: a claim about the asset can be checked against this instead of megabytes of points."""
4
+
5
+ import json
6
+ from pathlib import Path
7
+
8
+ from pxr import Usd, UsdGeom, UsdPhysics, UsdShade
9
+
10
+ DRIVES = ("angular", "linear", "transX", "transY", "transZ", "rotX", "rotY", "rotZ")
11
+ SHADER_INPUTS = ("diffuseColor", "metallic", "roughness", "opacity", "ior", "emissiveColor")
12
+
13
+
14
+ def _plain(value):
15
+ """A USD value as JSON: vectors and quaternions as lists, assets as paths, numbers rounded."""
16
+ if value is None:
17
+ return None
18
+ if isinstance(value, bool | int | str):
19
+ return value
20
+ if isinstance(value, float):
21
+ return round(value, 6)
22
+ if hasattr(value, "path"): # Sdf.AssetPath
23
+ return value.path
24
+ if hasattr(value, "GetReal"): # Gf.Quat
25
+ return [_plain(value.GetReal()), *[_plain(x) for x in value.GetImaginary()]]
26
+ try:
27
+ return [_plain(x) for x in value]
28
+ except TypeError:
29
+ return str(value)
30
+
31
+
32
+ def _attributes(prim, names):
33
+ found = {}
34
+ for name in names:
35
+ attr = prim.GetAttribute(name)
36
+ if attr and attr.HasAuthoredValue():
37
+ found[name.split(":")[-1]] = _plain(attr.Get())
38
+ return found
39
+
40
+
41
+ def _labels(prim):
42
+ return {a.GetName(): _plain(a.Get()) for a in prim.GetAttributes() if a.GetName().startswith("semantics:")}
43
+
44
+
45
+ def _body(stage, prim, cache):
46
+ meshes, triangles, colliders = 0, 0, []
47
+ for child in Usd.PrimRange(prim):
48
+ if child != prim and child.HasAPI("PhysicsRigidBodyAPI"):
49
+ continue # a nested body is listed on its own
50
+ if not child.IsA("Mesh"):
51
+ continue
52
+ counts = UsdGeom.Mesh(child).GetFaceVertexCountsAttr().Get() or []
53
+ if child.HasAPI("PhysicsCollisionAPI"):
54
+ approximation = UsdPhysics.MeshCollisionAPI(child).GetApproximationAttr().Get()
55
+ binding = UsdShade.MaterialBindingAPI(child).ComputeBoundMaterial("physics")[0]
56
+ colliders.append(
57
+ {
58
+ "path": str(child.GetPath()),
59
+ "approximation": approximation or "none",
60
+ "points": len(UsdGeom.Mesh(child).GetPointsAttr().Get() or []),
61
+ "purpose": UsdGeom.Imageable(child).ComputePurpose(),
62
+ "physics_material": str(binding.GetPath()) if binding else None,
63
+ }
64
+ )
65
+ else:
66
+ meshes += 1
67
+ triangles += sum(max(0, int(c) - 2) for c in counts)
68
+ for child in Usd.PrimRange(prim): # analytic colliders (cubes, spheres, capsules) count too
69
+ if child.HasAPI("PhysicsCollisionAPI") and not child.IsA("Mesh"):
70
+ colliders.append({"path": str(child.GetPath()), "approximation": child.GetTypeName()})
71
+ mass = UsdPhysics.MassAPI(prim)
72
+ world = cache.GetLocalToWorldTransform(prim).ExtractTranslation()
73
+ return {
74
+ "path": str(prim.GetPath()),
75
+ "mass": _plain(mass.GetMassAttr().Get()) if prim.HasAPI("PhysicsMassAPI") else None,
76
+ "density": _plain(mass.GetDensityAttr().Get()) if prim.HasAPI("PhysicsMassAPI") else None,
77
+ "centre_of_mass": _attributes(prim, ["physics:centerOfMass"]).get("centerOfMass"),
78
+ "position": _plain(world),
79
+ "visual_meshes": meshes,
80
+ "triangles": triangles,
81
+ "colliders": colliders,
82
+ "labels": _labels(prim),
83
+ "articulation_root": prim.HasAPI("PhysicsArticulationRootAPI"),
84
+ }
85
+
86
+
87
+ def _joint(prim):
88
+ joint = UsdPhysics.Joint(prim)
89
+ found: dict[str, object] = {
90
+ "path": str(prim.GetPath()),
91
+ "type": prim.GetTypeName(),
92
+ "body0": [str(t) for t in joint.GetBody0Rel().GetTargets()],
93
+ "body1": [str(t) for t in joint.GetBody1Rel().GetTargets()],
94
+ **_attributes(
95
+ prim,
96
+ [
97
+ "physics:axis",
98
+ "physics:localPos0",
99
+ "physics:localPos1",
100
+ "physics:localRot0",
101
+ "physics:localRot1",
102
+ "physics:lowerLimit",
103
+ "physics:upperLimit",
104
+ "physics:collisionEnabled",
105
+ "physics:jointEnabled",
106
+ ],
107
+ ),
108
+ "labels": _labels(prim),
109
+ }
110
+ drives = {}
111
+ for kind in DRIVES:
112
+ if f"PhysicsDriveAPI:{kind}" in prim.GetAppliedSchemas():
113
+ names = ["type", "stiffness", "damping", "targetPosition", "targetVelocity", "maxForce"]
114
+ drives[kind] = _attributes(prim, [f"drive:{kind}:physics:{n}" for n in names])
115
+ if drives:
116
+ found["drives"] = drives
117
+ return found
118
+
119
+
120
+ def _material(prim):
121
+ material = UsdShade.Material(prim)
122
+ found: dict[str, object] = {"path": str(prim.GetPath())}
123
+ for context in ("", "mdl"): # a UsdPreviewSurface, else an MDL shader such as OmniPBR
124
+ surface = material.ComputeSurfaceSource(context)[0]
125
+ if surface:
126
+ break
127
+ if surface:
128
+ shader = surface.GetPrim()
129
+ found["shader"] = _plain(
130
+ shader.GetAttribute("info:id").Get() or shader.GetAttribute("info:mdl:sourceAsset:subIdentifier").Get()
131
+ )
132
+ names = [i.GetBaseName() for i in surface.GetInputs()]
133
+ for name in [n for n in SHADER_INPUTS if n in names] + [n for n in names if n not in SHADER_INPUTS]:
134
+ value = surface.GetInput(name)
135
+ source = value.GetConnectedSources()[0]
136
+ if source:
137
+ upstream = source[0].source.GetPrim()
138
+ found[name] = {"texture": _plain(upstream.GetAttribute("inputs:file").Get()) or str(upstream.GetPath())}
139
+ elif value.Get() is not None:
140
+ found[name] = _plain(value.Get())
141
+ if prim.HasAPI("PhysicsMaterialAPI"):
142
+ found["physics"] = _attributes(
143
+ prim,
144
+ ["physics:staticFriction", "physics:dynamicFriction", "physics:restitution", "physics:density"],
145
+ )
146
+ sensor = {a.GetName(): _plain(a.Get()) for a in prim.GetAttributes() if "nonvisual" in a.GetName()}
147
+ if sensor:
148
+ found["nonvisual"] = sensor
149
+ return found
150
+
151
+
152
+ def inspect(path):
153
+ """The asset's structure as a dict (see the module's docstring)."""
154
+ stage = Usd.Stage.Open(str(path))
155
+ cache = UsdGeom.XformCache()
156
+ box = (
157
+ UsdGeom.BBoxCache(Usd.TimeCode.Default(), [UsdGeom.Tokens.default_, UsdGeom.Tokens.render])
158
+ .ComputeWorldBound(stage.GetPseudoRoot())
159
+ .ComputeAlignedRange()
160
+ )
161
+ default = stage.GetDefaultPrim()
162
+ layer = stage.GetRootLayer().customLayerData or {}
163
+ prims = list(stage.Traverse())
164
+ return {
165
+ "asset": str(Path(path)),
166
+ "default_prim": str(default.GetPath()) if default else None,
167
+ "meters_per_unit": UsdGeom.GetStageMetersPerUnit(stage),
168
+ "up_axis": UsdGeom.GetStageUpAxis(stage),
169
+ "kilograms_per_unit": UsdPhysics.GetStageKilogramsPerUnit(stage),
170
+ "size": _plain(box.GetSize()) if not box.IsEmpty() else None,
171
+ "metadata": json.loads(json.dumps(layer.get("SimReady_Metadata", {}), default=str)),
172
+ "labels": _labels(default) if default else {},
173
+ "articulation_roots": [str(p.GetPath()) for p in prims if p.HasAPI("PhysicsArticulationRootAPI")],
174
+ "bodies": [_body(stage, p, cache) for p in prims if p.HasAPI("PhysicsRigidBodyAPI")],
175
+ "joints": [_joint(p) for p in prims if p.IsA("PhysicsJoint")],
176
+ "materials": [_material(p) for p in prims if p.IsA("Material")],
177
+ }