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,785 @@
1
+ """Probe a SimReady asset's physics in Isaac Sim: what a robot or a person would do to it, measured against what the
2
+ asset's own values predict.
3
+
4
+ OMNI_KIT_ACCEPT_EULA=YES <isaac sim python> isaac_probe.py <asset.usda> <out_dir> [test ...]
5
+
6
+ Tests, each from the asset's rest pose on a grid ground (static friction 0.8, dynamic 0.6):
7
+ tilt the ground and the asset tilted together, steeper run by run, in four directions: the angle where it
8
+ first slides or tips, against atan(friction) and atan(support / height of the centre of mass)
9
+ push with the part's parent held still, a 10 N fingertip push at each moving part's rim (along a slide), at
10
+ most at a hand's speed, then let go: whether a hand can move it, whether it stays or springs back, and at
11
+ its limit how far past it goes and whether the joint holds
12
+ lift the main body held by a hand, lifted 30 cm, shaken +-5 cm at 2 Hz, set down: joints that overrun or part
13
+ settle 10 s at rest: how far anything still creeps after 5 s
14
+ load a box a quarter of the asset's mass dropped on its top from 10 cm: it fails only if the asset topples or
15
+ the simulation blows up
16
+ Writes out_dir/probe.json. Named tests run only those and keep the others' results from an existing probe.json;
17
+ `versions` says which probe version wrote each test. Exit code 0 when every test passes, 1 when one fails, 2 on an
18
+ error.
19
+ """
20
+
21
+ import json
22
+ import math
23
+ import sys
24
+ import traceback
25
+ from pathlib import Path
26
+
27
+ ASSET = str(Path(sys.argv[1]).resolve()).replace("\\", "/")
28
+ OUT = Path(sys.argv[2]).resolve()
29
+ ONLY = sys.argv[3:]
30
+ OUT.mkdir(parents=True, exist_ok=True)
31
+
32
+ from isaacsim import SimulationApp
33
+
34
+ app = SimulationApp({"headless": True})
35
+ import numpy as np
36
+ import omni.usd
37
+ from omni.physx import get_physx_interface, get_physx_simulation_interface
38
+ from pxr import Gf, PhysicsSchemaTools, Sdf, Usd, UsdGeom, UsdPhysics
39
+
40
+ AXIS = {"X": 0, "Y": 1, "Z": 2}
41
+ TESTS = ("settle", "tilt", "push", "lift", "load")
42
+ # 2: recalibrated criteria; 3: the push holds the part's parent; 4: a part standing on the ground is not judged on
43
+ # whether a fingertip moves it; 5: the push keeps to a hand's speed, and masses and centres of mass come from PhysX;
44
+ # 6: the push is a fingertip that sets its force each step to keep to that speed (5's on-off push overshot light parts);
45
+ # 7: the fingertip presses harder while a spring or a stop holds the part back (a one-step boost: too little);
46
+ # 8: it relearns how much a newton moves the part every step (still threw light levers apart, swung a sprung arm);
47
+ # 9: the push is a velocity drive on a second joint, at hand speed and at most 10 N, solved by PhysX itself (it broke
48
+ # the limits of joints outside an articulation); 10: that drive in an articulation, 8's fingertip force outside one;
49
+ # 11: the push passes when the joint survives it (no blow-up, limits and joint hold); operable_by_hand is reported
50
+ VERSION = 11
51
+ HAND_SPEED = 0.3 # m/s: how fast a fingertip moves a part along
52
+ PUSH = 10.0 # N: the most a fingertip pushes with
53
+ HZ = 120 # physics steps per simulated second, stepped by hand with no rendering (as the judge's drop is)
54
+ G = 9.81
55
+ GROUND_STATIC = 0.8
56
+ TOLERANCE = 0.005 # m: movement smaller than this is settling, not sliding
57
+ # label: (the rotation axis, its sign, the downhill direction). +X turned about X lifts +Y; about Y it lowers +X.
58
+ TILTS = {
59
+ "front down (-Y)": (0, 1.0, (0, -1, 0)),
60
+ "back down (+Y)": (0, -1.0, (0, 1, 0)),
61
+ "right down (+X)": (1, 1.0, (1, 0, 0)),
62
+ "left down (-X)": (1, -1.0, (-1, 0, 0)),
63
+ }
64
+
65
+
66
+ def quat_matrix(q):
67
+ w, (x, y, z) = q.GetReal(), q.GetImaginary()
68
+ return np.array(
69
+ [
70
+ [1 - 2 * (y * y + z * z), 2 * (x * y - w * z), 2 * (x * z + w * y)],
71
+ [2 * (x * y + w * z), 1 - 2 * (x * x + z * z), 2 * (y * z - w * x)],
72
+ [2 * (x * z - w * y), 2 * (y * z + w * x), 1 - 2 * (x * x + y * y)],
73
+ ]
74
+ )
75
+
76
+
77
+ def setup():
78
+ ctx = omni.usd.get_context()
79
+ ctx.new_stage()
80
+ for _ in range(5):
81
+ app.update()
82
+ stage = ctx.get_stage()
83
+ UsdGeom.SetStageUpAxis(stage, UsdGeom.Tokens.z)
84
+ UsdGeom.SetStageMetersPerUnit(stage, 1.0)
85
+ UsdGeom.Xform.Define(stage, "/World")
86
+ UsdPhysics.Scene.Define(stage, "/World/physicsScene").CreateGravityMagnitudeAttr(G)
87
+ tilt = UsdGeom.Xform.Define(stage, "/World/Tilt") # the ground and the asset, tilted together
88
+ tilt_op = tilt.AddRotateXYZOp()
89
+ ground = UsdGeom.Mesh.Define(stage, "/World/Tilt/Ground")
90
+ h = 50.0
91
+ ground.CreatePointsAttr([(-h, -h, 0), (h, -h, 0), (h, h, 0), (-h, h, 0)])
92
+ ground.CreateFaceVertexCountsAttr([4])
93
+ ground.CreateFaceVertexIndicesAttr([0, 1, 2, 3])
94
+ UsdPhysics.CollisionAPI.Apply(ground.GetPrim())
95
+ from pxr import UsdShade
96
+
97
+ look = UsdShade.Material.Define(stage, "/World/Looks/Ground")
98
+ grip = UsdPhysics.MaterialAPI.Apply(look.GetPrim())
99
+ grip.CreateStaticFrictionAttr(GROUND_STATIC)
100
+ grip.CreateDynamicFrictionAttr(0.6)
101
+ grip.CreateRestitutionAttr(0.1)
102
+ UsdShade.MaterialBindingAPI.Apply(ground.GetPrim()).Bind(look, UsdShade.Tokens.weakerThanDescendants, "physics")
103
+ holder = UsdGeom.Xform.Define(stage, "/World/Tilt/Asset")
104
+ holder.GetPrim().GetReferences().AddReference(ASSET)
105
+ for _ in range(10):
106
+ app.update()
107
+ box = UsdGeom.BBoxCache(Usd.TimeCode.Default(), [UsdGeom.Tokens.default_, UsdGeom.Tokens.render])
108
+ rng = box.ComputeWorldBound(holder.GetPrim()).ComputeAlignedRange()
109
+ lo, hi = rng.GetMin(), rng.GetMax()
110
+ holder.AddTranslateOp().Set(Gf.Vec3d(-(lo[0] + hi[0]) / 2, -(lo[1] + hi[1]) / 2, 0.001 - lo[2]))
111
+ for _ in range(10):
112
+ app.update()
113
+ return stage, tilt_op
114
+
115
+
116
+ def poses(bodies):
117
+ cache = UsdGeom.XformCache()
118
+ out = {}
119
+ for b in bodies:
120
+ m = cache.GetLocalToWorldTransform(b)
121
+ out[str(b.GetPath())] = (np.array(m.ExtractRotationMatrix()).T, np.array(m.ExtractTranslation()))
122
+ return out
123
+
124
+
125
+ def turned(r0, r1):
126
+ """Degrees between two rotations."""
127
+ return math.degrees(math.acos(max(-1.0, min(1.0, (np.trace(r0.T @ r1) - 1) / 2))))
128
+
129
+
130
+ class Joint:
131
+ """A joint read from the stage: its bodies, axis and anchor in each body's frame, limits, and its position."""
132
+
133
+ def __init__(self, prim):
134
+ j = UsdPhysics.Joint(prim)
135
+ self.prim, self.name = prim, prim.GetName()
136
+ self.revolute = prim.IsA(UsdPhysics.RevoluteJoint)
137
+ self.b0, self.b1 = str(j.GetBody0Rel().GetTargets()[0]), str(j.GetBody1Rel().GetTargets()[0])
138
+ self.rot0 = quat_matrix(j.GetLocalRot0Attr().Get()) if j.GetLocalRot0Attr().Get() is not None else np.eye(3)
139
+ self.rot1 = quat_matrix(j.GetLocalRot1Attr().Get()) if j.GetLocalRot1Attr().Get() is not None else np.eye(3)
140
+ self.pos0 = np.array(j.GetLocalPos0Attr().Get() or (0, 0, 0), dtype=float)
141
+ self.pos1 = np.array(j.GetLocalPos1Attr().Get() or (0, 0, 0), dtype=float)
142
+ self.index = AXIS[prim.GetAttribute("physics:axis").Get()]
143
+ lo, hi = prim.GetAttribute("physics:lowerLimit").Get(), prim.GetAttribute("physics:upperLimit").Get()
144
+ lo = -math.inf if lo is None else lo
145
+ hi = math.inf if hi is None else hi
146
+ self.free = not (math.isfinite(lo) and math.isfinite(hi)) or max(abs(lo), abs(hi)) > 1e6
147
+ self.lo, self.hi = (-90.0, 90.0) if self.free and self.revolute else (lo, hi)
148
+ self.value = self.last = None
149
+
150
+ def frames(self, f):
151
+ (r0, t0), (r1, t1) = f[self.b0], f[self.b1]
152
+ return r0 @ self.rot0, r0 @ self.pos0 + t0, r1 @ self.rot1, r1 @ self.pos1 + t1
153
+
154
+ def sample(self, f):
155
+ """The joint coordinate (degrees, or metres), followed in small steps; and the anchors' separation (m)."""
156
+ frame0, anchor0, frame1, anchor1 = self.frames(f)
157
+ if self.revolute:
158
+ rel = frame0.T @ frame1
159
+ if self.last is None:
160
+ self.value = self._angle(rel)
161
+ else:
162
+ self.value += self._angle(rel @ self.last.T)
163
+ self.last = rel
164
+ else:
165
+ self.value = float(frame0[:, self.index] @ (anchor1 - anchor0))
166
+ return self.value, float(np.linalg.norm(anchor1 - anchor0) if self.revolute else self._off_axis(f))
167
+
168
+ def _angle(self, rotation):
169
+ angle = math.degrees(math.acos(max(-1.0, min(1.0, (np.trace(rotation) - 1) / 2))))
170
+ w = np.array(
171
+ [rotation[2, 1] - rotation[1, 2], rotation[0, 2] - rotation[2, 0], rotation[1, 0] - rotation[0, 1]]
172
+ )
173
+ return angle if w[self.index] >= 0 else -angle
174
+
175
+ def _off_axis(self, f):
176
+ frame0, anchor0, _, anchor1 = self.frames(f)
177
+ d = anchor1 - anchor0
178
+ axis = frame0[:, self.index]
179
+ return float(np.linalg.norm(d - (d @ axis) * axis))
180
+
181
+ def far(self):
182
+ """The side of the range a push goes to: the larger one, as the review drives it."""
183
+ return self.lo if abs(self.lo) > abs(self.hi) else self.hi
184
+
185
+ def push_force(self, f, child_point, size):
186
+ """The world force at the world point that pushes this joint toward its far side with `size` newtons."""
187
+ frame0, anchor0, _, _ = self.frames(f)
188
+ axis = frame0[:, self.index]
189
+ sign = 1.0 if self.far() > 0 else -1.0
190
+ if not self.revolute:
191
+ return axis * size * sign
192
+ arm = child_point - anchor0
193
+ arm = arm - (arm @ axis) * axis
194
+ tangent = np.cross(axis, arm)
195
+ n = np.linalg.norm(tangent)
196
+ return tangent / n * size * sign if n > 1e-6 else np.zeros(3)
197
+
198
+
199
+ class Finger:
200
+ """A fingertip pushing at a part's rim from outside, for a joint outside any articulation (where a second joint's
201
+ drive broke the joint's own limits): it learns each step how much a newton speeds the part up and pushes only as
202
+ hard as hand speed needs, pressing on to PUSH newtons while something holds the part back."""
203
+
204
+ def __init__(self):
205
+ self.point, self.speed, self.pushed, self.gain = None, 0.0, 0.0, None
206
+
207
+ def force(self, point, direction):
208
+ """Newtons to push with this step, given the contact point now and the push's unit direction."""
209
+ speed = 0.0 if self.point is None else float((point - self.point) @ direction) * HZ
210
+ previous = self.speed
211
+ self.point, self.speed = point, speed
212
+ if self.pushed > 0:
213
+ observed = max(speed - previous, 0.0) / self.pushed # m/s per newton over the last step
214
+ if observed > 0 or self.gain is not None:
215
+ self.gain = observed if self.gain is None else 0.5 * self.gain + 0.5 * observed
216
+ if self.gain is None: # nothing has moved yet: press harder, from 0.1 N up
217
+ wanted = 0.1 if self.pushed == 0 else 2 * self.pushed
218
+ else:
219
+ wanted = (HAND_SPEED - speed) / max(self.gain, 1e-9)
220
+ self.pushed = float(np.clip(wanted, 0.0, PUSH))
221
+ return self.pushed
222
+
223
+
224
+ class Probe:
225
+ def __init__(self, stage, tilt_op):
226
+ self.stage, self.tilt_op = stage, tilt_op
227
+ root = stage.GetPrimAtPath("/World/Tilt/Asset")
228
+ self.bodies = [p for p in Usd.PrimRange(root) if p.HasAPI(UsdPhysics.RigidBodyAPI)]
229
+ self.joints = [
230
+ Joint(p) for p in Usd.PrimRange(root) if p.IsA(UsdPhysics.RevoluteJoint) or p.IsA(UsdPhysics.PrismaticJoint)
231
+ ]
232
+ self.physx = get_physx_simulation_interface()
233
+ self.stage_id = omni.usd.get_context().get_stage_id()
234
+ self.articulated = any(p.HasAPI(UsdPhysics.ArticulationRootAPI) for p in Usd.PrimRange(root))
235
+ self.rest = poses(self.bodies)
236
+ self.inertial = self._inertial() # PhysX's own: authored, or from the colliders where the asset leaves it out
237
+ authored = {str(b.GetPath()): UsdPhysics.MassAPI(b).GetMassAttr().Get() or 0.0 for b in self.bodies}
238
+ self.mass = {k: self.inertial[k][0] if k in self.inertial else m for k, m in authored.items()}
239
+ children = {j.b1 for j in self.joints}
240
+ roots = [str(b.GetPath()) for b in self.bodies if str(b.GetPath()) not in children]
241
+ self.root = max(roots, key=lambda b: self.mass[b]) if roots else None # the main body: heaviest root
242
+ self.loose = len(roots) > 1 # a pile or a set of parts not jointed into one tree
243
+ self.box = (
244
+ UsdGeom.BBoxCache(Usd.TimeCode.Default(), [UsdGeom.Tokens.default_, UsdGeom.Tokens.render])
245
+ .ComputeWorldBound(root)
246
+ .ComputeAlignedRange()
247
+ )
248
+
249
+ # -------------------------------------------------------------- running
250
+ def run(self, seconds, each=None, bodies=None):
251
+ """Simulate from the stage as it stands for `seconds`; each(step, poses) may apply forces or move things."""
252
+ physx = get_physx_interface()
253
+ tracked = bodies or self.bodies
254
+ physx.start_simulation()
255
+ t, dt = 0.0, 1.0 / HZ
256
+ for step in range(int(seconds * HZ)):
257
+ if each is not None:
258
+ each(step, poses(tracked))
259
+ physx.update_simulation(dt, t)
260
+ t += dt
261
+ physx.update_transformations(False, True, True, False)
262
+ return poses(tracked)
263
+
264
+ def reset(self):
265
+ """Back to the rest pose: the simulation restores the transforms it started from."""
266
+ get_physx_interface().reset_simulation()
267
+
268
+ def _hold(self, body, at):
269
+ """A kinematic hand fixed to `body` at the world point `at`, keeping the body in its rest pose until the hand
270
+ moves. Returns the hand's translate op."""
271
+ r, t = self.rest[body]
272
+ hand = UsdGeom.Xform.Define(self.stage, "/World/Hand")
273
+ op = hand.AddTranslateOp()
274
+ op.Set(Gf.Vec3d(*[float(x) for x in at]))
275
+ UsdPhysics.RigidBodyAPI.Apply(hand.GetPrim()).CreateKinematicEnabledAttr(True)
276
+ UsdPhysics.MassAPI.Apply(hand.GetPrim()).CreateMassAttr(1.0)
277
+ fixed = UsdPhysics.FixedJoint.Define(self.stage, "/World/HandJoint")
278
+ fixed.CreateBody0Rel().SetTargets([hand.GetPath()])
279
+ fixed.CreateBody1Rel().SetTargets([Sdf.Path(body)])
280
+ fixed.CreateLocalPos0Attr(Gf.Vec3f(0, 0, 0))
281
+ fixed.CreateLocalPos1Attr(Gf.Vec3f(*[float(x) for x in r.T @ (np.asarray(at) - t)]))
282
+ # the hand is world-aligned, so the body's side of the joint undoes its rest rotation (a Gf matrix is the
283
+ # transpose of these column-vector ones, so r as a Gf matrix is r.T here)
284
+ q = Gf.Matrix4d(Gf.Matrix3d(*r.flatten().tolist()), Gf.Vec3d(0, 0, 0)).ExtractRotation().GetQuat()
285
+ fixed.CreateLocalRot1Attr(Gf.Quatf(float(q.GetReal()), Gf.Vec3f(*[float(x) for x in q.GetImaginary()])))
286
+ fixed.CreateExcludeFromArticulationAttr(True)
287
+ for _ in range(3):
288
+ app.update()
289
+ return op
290
+
291
+ def _let_go(self):
292
+ for path in ("/World/Finger", "/World/HandJoint", "/World/Hand"):
293
+ self.stage.RemovePrim(path)
294
+ for _ in range(3):
295
+ app.update()
296
+
297
+ def force(self, body, force, point):
298
+ self.physx.apply_force_at_pos(
299
+ self.stage_id,
300
+ PhysicsSchemaTools.sdfPathToInt(Sdf.Path(body)),
301
+ Gf.Vec3f(*[float(x) for x in force]),
302
+ Gf.Vec3f(*[float(x) for x in point]),
303
+ "Force",
304
+ )
305
+
306
+ @staticmethod
307
+ def finite(f):
308
+ return all(np.isfinite(r).all() and np.isfinite(t).all() for r, t in f.values())
309
+
310
+ # -------------------------------------------------------------- what the asset's values predict
311
+ def _inertial(self):
312
+ """Each body's mass and centre of mass (in its own frame) as PhysX computes them: {path: (kg, centre)}. An
313
+ asset that leaves them out (a density, or nothing) gets them from its colliders, so the predictions below hold
314
+ for every pipeline's assets alike."""
315
+ from omni.physx import get_physx_property_query_interface
316
+ from omni.physx.bindings._physx import PhysxPropertyQueryMode, PhysxPropertyQueryResult
317
+
318
+ out = {}
319
+ for b in self.bodies:
320
+ path = str(b.GetPath())
321
+
322
+ def report(info, path=path):
323
+ if info.result == PhysxPropertyQueryResult.VALID:
324
+ out[path] = (float(info.mass), np.array(list(info.center_of_mass), dtype=float))
325
+
326
+ get_physx_property_query_interface().query_prim(
327
+ stage_id=self.stage_id,
328
+ prim_id=PhysicsSchemaTools.sdfPathToInt(b.GetPath()),
329
+ query_mode=PhysxPropertyQueryMode.QUERY_RIGID_BODY_WITH_COLLIDERS,
330
+ timeout_ms=30000,
331
+ rigid_body_fn=report,
332
+ )
333
+ for _ in range(200): # collider cooking can finish a few updates later
334
+ if len(out) == len(self.bodies):
335
+ break
336
+ app.update()
337
+ return out
338
+
339
+ def centre_of_mass(self):
340
+ total, moment = 0.0, np.zeros(3)
341
+ cache = UsdGeom.XformCache()
342
+ for b in self.bodies:
343
+ m = self.mass[str(b.GetPath())]
344
+ com = UsdPhysics.MassAPI(b).GetCenterOfMassAttr().Get()
345
+ local = np.array(com, dtype=float) if com is not None and abs(com[0]) < 1e6 else np.zeros(3)
346
+ if str(b.GetPath()) in self.inertial:
347
+ local = self.inertial[str(b.GetPath())][1]
348
+ world = np.array(cache.GetLocalToWorldTransform(b).Transform(Gf.Vec3d(*local)))
349
+ total, moment = total + m, moment + m * world
350
+ return moment / total if total else None
351
+
352
+ def support(self):
353
+ """Collider points within 4 mm of the lowest collider point at rest (the visual meshes may sit above or below
354
+ the colliders), and the least static friction among their materials."""
355
+ cache = UsdGeom.XformCache()
356
+ points, friction = [], []
357
+ floor = self._lowest_collider(cache)
358
+ for b in self.bodies:
359
+ for c in Usd.PrimRange(b):
360
+ if not (c.HasAPI(UsdPhysics.CollisionAPI) and c.IsA(UsdGeom.Mesh)):
361
+ continue
362
+ pts = np.array(UsdGeom.Mesh(c).GetPointsAttr().Get() or [], dtype=float)
363
+ if not len(pts):
364
+ continue
365
+ m = np.array(cache.GetLocalToWorldTransform(c))
366
+ world = pts @ m[:3, :3] + m[3, :3]
367
+ low = world[world[:, 2] < floor + 0.004]
368
+ if len(low):
369
+ points.append(low)
370
+ from pxr import UsdShade
371
+
372
+ mat = UsdShade.MaterialBindingAPI(c).ComputeBoundMaterial("physics")[0]
373
+ value = mat.GetPrim().GetAttribute("physics:staticFriction").Get() if mat else None
374
+ friction.append(0.5 if value is None else float(value))
375
+ return (np.vstack(points) if points else np.zeros((0, 3))), (min(friction) if friction else 0.5)
376
+
377
+ def _standing(self):
378
+ """The bodies whose colliders come within 4 mm of the lowest point at rest: what the asset stands on."""
379
+ cache = UsdGeom.XformCache()
380
+ floor = self._lowest_collider(cache)
381
+ return {str(b.GetPath()) for b in self.bodies if self._lowest_collider(cache, [b]) < floor + 0.004}
382
+
383
+ def _lowest_collider(self, cache, bodies=None):
384
+ lows = []
385
+ for b in bodies or self.bodies:
386
+ for c in Usd.PrimRange(b):
387
+ if c.HasAPI(UsdPhysics.CollisionAPI) and c.IsA(UsdGeom.Mesh):
388
+ pts = np.array(UsdGeom.Mesh(c).GetPointsAttr().Get() or [], dtype=float)
389
+ if len(pts):
390
+ m = np.array(cache.GetLocalToWorldTransform(c))
391
+ lows.append(float((pts @ m[:3, :3] + m[3, :3])[:, 2].min()))
392
+ return min(lows) if lows else (0.0 if bodies is None else math.inf)
393
+
394
+ def blown(self, f):
395
+ """NaN, or any body flung further than ten times the asset's size: the simulation blew up."""
396
+ if not self.finite(f):
397
+ return True
398
+ reach = 10.0 * max(float((self.box.GetMax() - self.box.GetMin()).GetLength()), 0.1)
399
+ return any(np.linalg.norm(f[k][1] - self.rest[k][1]) > reach for k in f if k in self.rest)
400
+
401
+ # -------------------------------------------------------------- tests
402
+ def tilt(self):
403
+ """Ground and asset tilted together, steeper run by run (a binary search over 0-45 degrees) in four directions:
404
+ the angle where the main body (or, for a loose set, any body) first slides or tips, against what its friction
405
+ and its centre of mass over its support predict, and the angle where a part first moves on its own joint."""
406
+ com = self.centre_of_mass()
407
+ support, friction = self.support()
408
+ mu = (friction + GROUND_STATIC) / 2 # PhysX averages two materials' friction by default
409
+ slide = math.degrees(math.atan(mu))
410
+ out = {
411
+ "friction_expected": round(mu, 3),
412
+ "slide_expected_deg": round(slide, 1),
413
+ "mass_from": "physx" if len(self.inertial) == len(self.bodies) else "authored",
414
+ }
415
+ for label, (axis, sign, downhill) in TILTS.items():
416
+ down = np.array(downhill, dtype=float)
417
+ tip = None
418
+ if com is not None and len(support):
419
+ reach = float((support @ down).max() - com @ down) # from the centre of mass to the edge it tips over
420
+ tip = math.degrees(math.atan2(max(reach, 0.0), max(float(com[2]), 1e-6)))
421
+ lo_deg, hi_deg, first, part_from = 0.0, 45.0, None, None
422
+ for _ in range(6):
423
+ angle = (lo_deg + hi_deg) / 2
424
+ kind, part = self._tilted(axis, sign, angle)
425
+ if part and (part_from is None or angle < part_from):
426
+ part_from = angle
427
+ if kind:
428
+ hi_deg, first = angle, kind
429
+ else:
430
+ lo_deg = angle
431
+ out[label] = {
432
+ "moves_at_deg": round(hi_deg, 1) if first else None,
433
+ "how": first or "holds to 45",
434
+ "tip_expected_deg": None if tip is None else round(tip, 1),
435
+ "a_part_moves_from_deg": None if part_from is None else round(part_from, 1),
436
+ }
437
+ rows = [v for v in out.values() if isinstance(v, dict)]
438
+ predicted = min([slide] + [v["tip_expected_deg"] for v in rows if v["tip_expected_deg"] is not None])
439
+ moved = [v["moves_at_deg"] for v in rows if v["moves_at_deg"] is not None]
440
+ out["first_move_deg"] = min(moved) if moved else None
441
+ out["predicted_first_deg"] = round(predicted, 1)
442
+ # a pass: the asset does not give way more than 5 degrees before its own values say it should
443
+ out["passed"] = bool(self.loose or not moved or min(moved) >= predicted - 5.0)
444
+ return out
445
+
446
+ def _tilted(self, axis, sign, angle):
447
+ """Run 1.5 s on ground tilted `angle` degrees. Returns (None, 'slides', 'tips', 'rolls' or 'explodes' for the
448
+ asset as a whole) and whether a part moved on its own joint."""
449
+ rot = [0.0, 0.0, 0.0]
450
+ rot[axis] = angle * sign
451
+ self.tilt_op.Set(Gf.Vec3f(*rot))
452
+ for _ in range(2):
453
+ app.update()
454
+ start = poses(self.bodies)
455
+ end = self.run(1.5)
456
+ self.reset()
457
+ self.tilt_op.Set(Gf.Vec3f(0, 0, 0))
458
+ if not self.finite(end):
459
+ return "explodes", False
460
+ judged = list(end) if self.loose or self.root is None else [self.root]
461
+ shift = max(np.linalg.norm(end[k][1] - start[k][1]) for k in judged)
462
+ spin = max(turned(start[k][0], end[k][0]) for k in judged)
463
+ part = False
464
+ if not self.loose and self.root is not None:
465
+ r = self.root
466
+ part = any(
467
+ np.linalg.norm((end[k][1] - end[r][1]) - (start[k][1] - start[r][1])) > TOLERANCE for k in end if k != r
468
+ )
469
+ if spin > 10 and shift > TOLERANCE:
470
+ return ("rolls" if self.loose else "tips"), part
471
+ if shift > TOLERANCE:
472
+ return "slides", part
473
+ return None, part
474
+
475
+ def push(self):
476
+ """With the joint's parent body held still (a hand on the base), a fingertip pushes the moving part toward its
477
+ far side at a hand's speed with at most 10 N at its rim (along a slide) for 1.5 s, then lets go for 1.5 s (a
478
+ drive on a second joint in an articulation, a force at the rim outside one; `pushed_by` says which):
479
+ whether a hand can move it, whether it stays or springs back, and, where it reaches its limit, how far past the
480
+ limit it goes and whether the joint holds together. Unheld, the push shoved or spun whole light assets across
481
+ the ground. A part that stands on the ground (an A-frame's leg, a stand) is not judged on whether a fingertip
482
+ moves it: the asset's weight holds it, and a person lifts the asset first."""
483
+ results = {}
484
+ standing = self._standing()
485
+ below = {} # each body's descendants through the joints
486
+ for j in self.joints:
487
+ below.setdefault(j.b0, set()).add(j.b1)
488
+
489
+ def subtree(body):
490
+ out, todo = set(), [body]
491
+ while todo:
492
+ b = todo.pop()
493
+ if b not in out:
494
+ out.add(b)
495
+ todo.extend(below.get(b, ()))
496
+ return out
497
+
498
+ for j in self.joints:
499
+ gauge = Joint(j.prim)
500
+ trace, gaps = [], []
501
+ self._hold(j.b0, self.rest[j.b0][1])
502
+ rim = self._rim(j)
503
+ # in an articulation a second joint's drive pushes (PhysX solves it, stable for a 5 g lever); outside one
504
+ # that drive broke the joint's own limits, so a fingertip force pushes from outside instead
505
+ inside = self.articulated and not UsdPhysics.Joint(j.prim).GetExcludeFromArticulationAttr().Get()
506
+ drive = self._pusher(j, rim) if inside else None
507
+ finger = Finger()
508
+
509
+ def each(step, f, j=j, gauge=gauge, trace=trace, gaps=gaps, drive=drive, finger=finger, rim=rim):
510
+ value, gap = gauge.sample(f)
511
+ trace.append(value)
512
+ gaps.append(gap)
513
+ if drive is not None:
514
+ if step == int(1.5 * HZ): # let go
515
+ drive.GetMaxForceAttr().Set(0.0)
516
+ drive.GetDampingAttr().Set(0.0)
517
+ drive.GetTargetVelocityAttr().Set(0.0)
518
+ elif step < 1.5 * HZ:
519
+ r1, t1 = f[j.b1]
520
+ point = r1 @ rim + t1
521
+ direction = j.push_force(f, point, 1.0)
522
+ self.force(j.b1, direction * finger.force(point, direction), point)
523
+
524
+ end = self.run(3.0, each)
525
+ self.reset()
526
+ self._let_go()
527
+ span = j.hi - j.lo
528
+ rest = trace[0] if trace else 0.0
529
+ pushed = max(trace[: int(1.5 * HZ)], key=lambda v: abs(v - rest)) if trace else rest
530
+ final = trace[-1] if trace else rest
531
+ moved = abs(pushed - rest)
532
+ kept = abs(final - rest) / max(moved, 1e-9)
533
+ limit = j.far()
534
+ overrun = (
535
+ 0.0
536
+ if j.free
537
+ else max((abs(v) - abs(limit) for v in trace if np.sign(v) == np.sign(limit)), default=0.0)
538
+ )
539
+ noticeable = 15.0 if j.revolute else 0.015 # degrees, or metres: a hand sees it move
540
+ blown = self.blown(end)
541
+ stands = bool(subtree(j.b1) & standing)
542
+ operable = stands or j.free or moved >= min(0.2 * span, noticeable) or overrun >= -0.02 * span
543
+ holds = max(overrun, 0.0) <= 0.05 * span + (1.0 if j.revolute else 0.001)
544
+ # an anchor gap already there at rest is the authoring's, not the push's
545
+ apart = max(gaps, default=0.0) - (gaps[0] if gaps else 0.0)
546
+ together = apart < 0.005
547
+ results[j.name] = {
548
+ "moved": round(moved, 4),
549
+ "moved_units": "deg" if j.revolute else "m",
550
+ "travel_of_range": round(moved / span, 3) if span else None,
551
+ "after_release": "stays" if kept > 0.8 else "springs back" if kept < 0.2 else "partly back",
552
+ "overrun_of_range": round(max(overrun, 0.0) / span, 3) if span and not j.free else 0.0,
553
+ "separation_mm": round(apart * 1000, 2),
554
+ "anchor_gap_at_rest_mm": round((gaps[0] if gaps else 0.0) * 1000, 2),
555
+ "blew_up": bool(blown),
556
+ "operable_by_hand": None if stands else bool(operable), # None: it stands on the ground
557
+ "pushed_by": "a drive (in an articulation)" if inside else "a force at the rim (no articulation)",
558
+ # passes when the joint survives a hand's push; whether a hand moves it is reported, not judged: a
559
+ # screw cap with drag or a sprung lamp arm rightly resists a fingertip, a stiff drawer does not
560
+ "passed": bool(not blown and holds and together),
561
+ }
562
+ return results
563
+
564
+ def _pusher(self, j, rim):
565
+ """A hand pushing the joint's child toward its far side at a hand's speed, with at most PUSH newtons at the rim:
566
+ a second joint on the same frames, outside the articulation, free but for a velocity drive on the tested axis,
567
+ which PhysX solves with the rest of the step. Forces set from outside step by step threw a 6 g lever apart or
568
+ set a sprung arm swinging. Returns the drive, to let go."""
569
+ source = UsdPhysics.Joint(j.prim)
570
+ joint = UsdPhysics.Joint.Define(self.stage, "/World/Finger")
571
+ joint.CreateBody0Rel().SetTargets([Sdf.Path(j.b0)])
572
+ joint.CreateBody1Rel().SetTargets([Sdf.Path(j.b1)])
573
+ for read, write in (
574
+ (source.GetLocalPos0Attr, joint.CreateLocalPos0Attr),
575
+ (source.GetLocalRot0Attr, joint.CreateLocalRot0Attr),
576
+ (source.GetLocalPos1Attr, joint.CreateLocalPos1Attr),
577
+ (source.GetLocalRot1Attr, joint.CreateLocalRot1Attr),
578
+ ):
579
+ if read().Get() is not None:
580
+ write(read().Get())
581
+ joint.CreateExcludeFromArticulationAttr(True)
582
+ joint.CreateCollisionEnabledAttr(False)
583
+ frame0, anchor0, _, _ = j.frames(self.rest)
584
+ r1, t1 = self.rest[j.b1]
585
+ arm = r1 @ rim + t1 - anchor0
586
+ axis = frame0[:, j.index]
587
+ reach = max(float(np.linalg.norm(arm - (arm @ axis) * axis)), 0.005) if j.revolute else 1.0
588
+ speed = math.degrees(HAND_SPEED / reach) if j.revolute else HAND_SPEED # deg/s, or m/s
589
+ cap = PUSH * reach # N*m at the rim's reach, or N along a slide
590
+ drive = UsdPhysics.DriveAPI.Apply(joint.GetPrim(), ("rot" if j.revolute else "trans") + "XYZ"[j.index])
591
+ drive.CreateTypeAttr("force")
592
+ drive.CreateStiffnessAttr(0.0)
593
+ drive.CreateDampingAttr(cap / (0.1 * speed)) # the whole push once the part lags a tenth of hand speed
594
+ drive.CreateTargetVelocityAttr((1.0 if j.far() > 0 else -1.0) * speed)
595
+ drive.CreateMaxForceAttr(cap)
596
+ for _ in range(3):
597
+ app.update()
598
+ return drive
599
+
600
+ def _rim(self, j):
601
+ """A point on the child, in its own frame, far from the joint axis: where a fingertip would push."""
602
+ child = self.stage.GetPrimAtPath(j.b1)
603
+ cache = UsdGeom.XformCache()
604
+ r1, t1 = self.rest[j.b1]
605
+ pts = []
606
+ for c in Usd.PrimRange(child):
607
+ if c.HasAPI(UsdPhysics.CollisionAPI) and c.IsA(UsdGeom.Mesh):
608
+ p = np.array(UsdGeom.Mesh(c).GetPointsAttr().Get() or [], dtype=float)
609
+ if len(p):
610
+ m = np.array(cache.GetLocalToWorldTransform(c))
611
+ pts.append(p @ m[:3, :3] + m[3, :3])
612
+ if not pts:
613
+ return np.zeros(3)
614
+ world = np.vstack(pts)
615
+ frame0, anchor0, _, _ = j.frames(self.rest)
616
+ axis = frame0[:, j.index]
617
+ if j.revolute:
618
+ arm = world - anchor0
619
+ arm = arm - np.outer(arm @ axis, axis)
620
+ point = world[int(np.argmax(np.linalg.norm(arm, axis=1)))] * 0.6 + world.mean(0) * 0.4 # in from the edge
621
+ else:
622
+ point = world.mean(0)
623
+ return r1.T @ (point - t1)
624
+
625
+ def lift(self):
626
+ """The main body held by a kinematic hand at its top: lifted 30 cm in 1 s, shaken +-5 cm at 2 Hz for 2 s, set
627
+ down in 1 s, held 1 s."""
628
+ if self.root is None:
629
+ return {"skipped": "no main body"}
630
+ lo, hi = self.box.GetMin(), self.box.GetMax()
631
+ grasp = np.array([(lo[0] + hi[0]) / 2, (lo[1] + hi[1]) / 2, hi[2]])
632
+ hand_op = self._hold(self.root, grasp)
633
+ gauges = [Joint(j.prim) for j in self.joints]
634
+ worst = {"overrun": 0.0, "gap": 0.0}
635
+
636
+ def path(step):
637
+ t = step / HZ
638
+ if t < 1.0:
639
+ return np.array([0, 0, 0.3 * t])
640
+ if t < 3.0:
641
+ return np.array([0.05 * math.sin(2 * math.pi * 2 * (t - 1.0)), 0, 0.3])
642
+ if t < 4.0:
643
+ return np.array([0, 0, 0.3 * (4.0 - t)])
644
+ return np.zeros(3)
645
+
646
+ def each(step, f):
647
+ hand_op.Set(Gf.Vec3d(*(grasp + path(step))))
648
+ for g in gauges:
649
+ value, gap = g.sample(f)
650
+ span = g.hi - g.lo
651
+ over = max(g.lo - value, value - g.hi, 0.0)
652
+ worst["overrun"] = max(worst["overrun"], over / span if span and not g.free else 0.0)
653
+ worst["gap"] = max(worst["gap"], gap)
654
+
655
+ end = self.run(5.0, each)
656
+ self.reset()
657
+ self._let_go()
658
+ fell = []
659
+ if self.loose:
660
+ fell = [k.rsplit("/", 1)[-1] for k in end if k != self.root]
661
+ ok = self.finite(end) and worst["overrun"] <= 0.1 and worst["gap"] < 0.005
662
+ return {
663
+ "held_body": self.root.rsplit("/", 1)[-1],
664
+ "joint_overrun_of_range": round(worst["overrun"], 3),
665
+ "joint_separation_mm": round(worst["gap"] * 1000, 2),
666
+ "nan": not self.finite(end),
667
+ "loose_bodies_left_behind": len(fell),
668
+ "passed": bool(ok),
669
+ }
670
+
671
+ def settle(self):
672
+ """10 s at rest with the asset's own drives: how far anything moves between 5 s and 10 s."""
673
+ marks = {}
674
+
675
+ def each(step, f):
676
+ if step == 5 * HZ:
677
+ marks["mid"] = f
678
+
679
+ end = self.run(10.0, each)
680
+ self.reset()
681
+ mid = marks.get("mid", end)
682
+ creep = max((np.linalg.norm(end[k][1] - mid[k][1]) for k in end), default=0.0)
683
+ moved = max((np.linalg.norm(end[k][1] - self.rest[k][1]) for k in end), default=0.0)
684
+ return {
685
+ "creep_after_5s_mm": round(float(creep) * 1000, 2),
686
+ "moved_from_pose_mm": round(float(moved) * 1000, 1),
687
+ "nan": not self.finite(end),
688
+ "passed": bool(self.finite(end) and creep < 0.002),
689
+ }
690
+
691
+ def load(self):
692
+ """A box a quarter of the asset's mass (50 g to 2 kg) dropped on the middle of its top from 10 cm."""
693
+ lo, hi = self.box.GetMin(), self.box.GetMax()
694
+ size = float(np.clip(0.3 * min(hi[0] - lo[0], hi[1] - lo[1]), 0.03, 0.2))
695
+ mass = float(np.clip(0.25 * sum(self.mass.values()), 0.05, 2.0))
696
+ top = float(hi[2])
697
+ cube = UsdGeom.Cube.Define(self.stage, "/World/Load")
698
+ cube.CreateSizeAttr(size)
699
+ cube.AddTranslateOp().Set(Gf.Vec3d((lo[0] + hi[0]) / 2, (lo[1] + hi[1]) / 2, top + 0.1 + size / 2))
700
+ UsdPhysics.RigidBodyAPI.Apply(cube.GetPrim())
701
+ UsdPhysics.CollisionAPI.Apply(cube.GetPrim())
702
+ UsdPhysics.MassAPI.Apply(cube.GetPrim()).CreateMassAttr(mass)
703
+ for _ in range(3):
704
+ app.update()
705
+ load_body = self.stage.GetPrimAtPath("/World/Load")
706
+ end = self.run(3.0, bodies=[*self.bodies, load_body])
707
+ self.reset()
708
+ self.stage.RemovePrim("/World/Load")
709
+ for _ in range(3):
710
+ app.update()
711
+ t = end["/World/Load"][1]
712
+ bottom = t[2] - size / 2
713
+ inside = lo[0] <= t[0] <= hi[0] and lo[1] <= t[1] <= hi[1]
714
+ if not np.isfinite(t).all():
715
+ where = "explodes"
716
+ elif bottom >= top - 0.25 * (top - lo[2]) - 0.01 and inside:
717
+ where = "rests on it"
718
+ elif inside and bottom < top - 0.25 * (top - lo[2]):
719
+ where = "sinks into it"
720
+ else:
721
+ where = "slides off"
722
+ asset_end = {k: v for k, v in end.items() if k != "/World/Load"}
723
+ moved = max((np.linalg.norm(asset_end[k][1] - self.rest[k][1]) for k in asset_end), default=0.0)
724
+ toppled = self.root is not None and turned(self.rest[self.root][0], asset_end[self.root][0]) > 30
725
+ return {
726
+ "box_kg": round(mass, 3),
727
+ "box_mm": round(size * 1000),
728
+ "box": where, # "sinks into it" is fine for an open shoe or a cup: information, not a fault
729
+ "asset_moved_mm": round(float(moved) * 1000, 1),
730
+ "asset_toppled": bool(toppled),
731
+ "passed": bool(where != "explodes" and not self.blown(asset_end) and not toppled),
732
+ }
733
+
734
+
735
+ def main():
736
+ unknown = sorted(set(ONLY) - set(TESTS))
737
+ if unknown:
738
+ raise ValueError(f"no test named {unknown}; the tests are {TESTS}")
739
+ stage, tilt_op = setup()
740
+ probe = Probe(stage, tilt_op)
741
+ earlier = OUT / "probe.json"
742
+ old = json.loads(earlier.read_text(encoding="utf-8")) if ONLY and earlier.is_file() else {}
743
+ report = {
744
+ "asset": ASSET,
745
+ "bodies": len(probe.bodies),
746
+ "joints": len(probe.joints),
747
+ "loose": probe.loose,
748
+ }
749
+ run = [t for t in TESTS if t in ONLY] if ONLY else list(TESTS)
750
+ versions = old.get("versions") or {t: old.get("version") for t in TESTS if t in old}
751
+ for name in TESTS:
752
+ if name not in run and name in old:
753
+ report[name] = old[name]
754
+ for name in run:
755
+ versions[name] = VERSION
756
+ try:
757
+ report[name] = getattr(probe, name)()
758
+ except Exception as error: # noqa: BLE001 one test's failure must not hide the others' results
759
+ report[name] = {"error": f"{type(error).__name__}: {error}", "passed": False}
760
+ traceback.print_exc()
761
+ probe.reset()
762
+
763
+ def passed(result):
764
+ if isinstance(result, dict) and "passed" in result:
765
+ return result["passed"]
766
+ if isinstance(result, dict):
767
+ return all(passed(v) for v in result.values() if isinstance(v, dict))
768
+ return True
769
+
770
+ report["versions"] = {t: versions.get(t) for t in TESTS}
771
+ report["version"] = min((v for v in report["versions"].values() if v is not None), default=VERSION)
772
+ report["passed"] = {name: passed(report[name]) for name in TESTS if name in report}
773
+ report["all_passed"] = all(report["passed"].values())
774
+ (OUT / "probe.json").write_text(json.dumps(report, indent=1, default=float), encoding="utf-8")
775
+ print("PROBE " + json.dumps(report["passed"]))
776
+ return report["all_passed"]
777
+
778
+
779
+ try:
780
+ code = 0 if main() else 1
781
+ except Exception: # noqa: BLE001 Kit swallows uncaught exceptions and exits 0
782
+ traceback.print_exc()
783
+ code = 2
784
+ app.close()
785
+ sys.exit(code)