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
|
@@ -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)
|