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.
- simready_forge/__init__.py +14 -0
- simready_forge/cli.py +221 -0
- simready_forge/cost.py +120 -0
- simready_forge/examples/__init__.py +0 -0
- simready_forge/examples/cabinet.py +124 -0
- simready_forge/export.py +170 -0
- simready_forge/isaac_probe.py +785 -0
- simready_forge/isaac_review.py +420 -0
- simready_forge/kit.py +1047 -0
- simready_forge/poses.py +293 -0
- simready_forge/prefs.py +105 -0
- simready_forge/preview.py +74 -0
- simready_forge/readback.py +177 -0
- simready_forge/review.py +167 -0
- simready_forge/scan.py +129 -0
- simready_forge/trellis.py +378 -0
- simready_forge/validate.py +66 -0
- simready_forge-0.1.0.dist-info/METADATA +165 -0
- simready_forge-0.1.0.dist-info/RECORD +22 -0
- simready_forge-0.1.0.dist-info/WHEEL +4 -0
- simready_forge-0.1.0.dist-info/entry_points.txt +2 -0
- simready_forge-0.1.0.dist-info/licenses/LICENSE +21 -0
simready_forge/poses.py
ADDED
|
@@ -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
|
simready_forge/prefs.py
ADDED
|
@@ -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
|
+
}
|