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,420 @@
|
|
|
1
|
+
"""Review a SimReady asset in Isaac Sim: RTX renders at rest and with every joint driven, plus PhysX checks.
|
|
2
|
+
|
|
3
|
+
OMNI_KIT_ACCEPT_EULA=YES <isaac sim python> isaac_review.py <asset.usda> <out_dir>
|
|
4
|
+
|
|
5
|
+
Needs Isaac Sim's Python (the `isaacsim` pip package, or python.bat / python.sh of an install). Writes:
|
|
6
|
+
out_dir/review.png contact sheet: 5 views at rest, 3 with every joint driven to 80% of the far side of its
|
|
7
|
+
range, 2 with each two-sided joint driven to 80% of the other side
|
|
8
|
+
out_dir/view_*.png the single renders
|
|
9
|
+
out_dir/review.json settle (NaN, fell through, drift) and per-joint target vs reached, on each side
|
|
10
|
+
Exit code 0 when the physics checks pass, 1 when one fails, 2 on an error.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
import math
|
|
15
|
+
import sys
|
|
16
|
+
import traceback
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
ASSET = str(Path(sys.argv[1]).resolve()).replace("\\", "/")
|
|
20
|
+
OUT = Path(sys.argv[2]).resolve()
|
|
21
|
+
OUT.mkdir(parents=True, exist_ok=True)
|
|
22
|
+
W, H = 640, 480
|
|
23
|
+
|
|
24
|
+
from isaacsim import SimulationApp
|
|
25
|
+
|
|
26
|
+
app = SimulationApp({"headless": True, "width": W, "height": H, "renderer": "RayTracedLighting", "anti_aliasing": 3})
|
|
27
|
+
import carb
|
|
28
|
+
import numpy as np
|
|
29
|
+
import omni.replicator.core as rep
|
|
30
|
+
import omni.timeline
|
|
31
|
+
import omni.usd
|
|
32
|
+
from PIL import Image, ImageDraw
|
|
33
|
+
from pxr import Gf, Sdf, Usd, UsdGeom, UsdLux, UsdPhysics, UsdShade
|
|
34
|
+
|
|
35
|
+
AXIS = {"X": 0, "Y": 1, "Z": 2}
|
|
36
|
+
VIEWS = [("front", 270, 12), ("front_right", 310, 28), ("right", 0, 12), ("back_left", 135, 28), ("top", 280, 72)]
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def setup():
|
|
40
|
+
ctx = omni.usd.get_context()
|
|
41
|
+
ctx.new_stage()
|
|
42
|
+
for _ in range(5):
|
|
43
|
+
app.update()
|
|
44
|
+
stage = ctx.get_stage()
|
|
45
|
+
UsdGeom.SetStageUpAxis(stage, UsdGeom.Tokens.z)
|
|
46
|
+
UsdGeom.SetStageMetersPerUnit(stage, 1.0)
|
|
47
|
+
UsdGeom.Xform.Define(stage, "/World")
|
|
48
|
+
UsdPhysics.Scene.Define(stage, "/World/physicsScene").CreateGravityMagnitudeAttr(9.81)
|
|
49
|
+
UsdLux.DomeLight.Define(stage, "/World/Dome").CreateIntensityAttr(450.0)
|
|
50
|
+
sun = UsdLux.DistantLight.Define(stage, "/World/Sun")
|
|
51
|
+
sun.CreateIntensityAttr(2200.0)
|
|
52
|
+
sun.CreateAngleAttr(2.0)
|
|
53
|
+
UsdGeom.Xformable(sun.GetPrim()).AddRotateXYZOp().Set(Gf.Vec3f(-48, 18, 30))
|
|
54
|
+
checker = OUT / "_ground.png" # 10 cm grid, darker every metre
|
|
55
|
+
grid = np.full((2000, 2000, 3), 140, np.uint8)
|
|
56
|
+
for k in range(0, 2000, 100): # 1 px = 1 mm: 10 cm lines 8 mm wide, so they survive a 640 px render
|
|
57
|
+
grid[k : k + 8, :] = grid[:, k : k + 8] = 112
|
|
58
|
+
for k in range(0, 2000, 1000):
|
|
59
|
+
grid[k : k + 16, :] = grid[:, k : k + 16] = 72
|
|
60
|
+
Image.fromarray(grid).save(checker)
|
|
61
|
+
ground = UsdGeom.Mesh.Define(stage, "/World/Ground")
|
|
62
|
+
h = 50.0
|
|
63
|
+
ground.CreatePointsAttr([(-h, -h, 0), (h, -h, 0), (h, h, 0), (-h, h, 0)])
|
|
64
|
+
ground.CreateFaceVertexCountsAttr([4])
|
|
65
|
+
ground.CreateFaceVertexIndicesAttr([0, 1, 2, 3])
|
|
66
|
+
UsdGeom.PrimvarsAPI(ground).CreatePrimvar("st", Sdf.ValueTypeNames.TexCoord2fArray, UsdGeom.Tokens.vertex).Set(
|
|
67
|
+
[(-h / 2, -h / 2), (h / 2, -h / 2), (h / 2, h / 2), (-h / 2, h / 2)]
|
|
68
|
+
)
|
|
69
|
+
UsdPhysics.CollisionAPI.Apply(ground.GetPrim())
|
|
70
|
+
mat = UsdShade.Material.Define(stage, "/World/Looks/Ground")
|
|
71
|
+
grip = UsdPhysics.MaterialAPI.Apply(mat.GetPrim()) # not `ground`: that is the mesh the material binds to
|
|
72
|
+
grip.CreateStaticFrictionAttr(0.8)
|
|
73
|
+
grip.CreateDynamicFrictionAttr(0.6) # left at 0, sliding friction against the ground was half the asset's own
|
|
74
|
+
shader = UsdShade.Shader.Define(stage, "/World/Looks/Ground/Shader")
|
|
75
|
+
shader.CreateIdAttr("UsdPreviewSurface")
|
|
76
|
+
tex = UsdShade.Shader.Define(stage, "/World/Looks/Ground/Tex")
|
|
77
|
+
tex.CreateIdAttr("UsdUVTexture")
|
|
78
|
+
tex.CreateInput("file", Sdf.ValueTypeNames.Asset).Set(str(checker).replace("\\", "/"))
|
|
79
|
+
tex.CreateInput("wrapS", Sdf.ValueTypeNames.Token).Set("repeat")
|
|
80
|
+
tex.CreateInput("wrapT", Sdf.ValueTypeNames.Token).Set("repeat")
|
|
81
|
+
st = UsdShade.Shader.Define(stage, "/World/Looks/Ground/St")
|
|
82
|
+
st.CreateIdAttr("UsdPrimvarReader_float2")
|
|
83
|
+
st.CreateInput("varname", Sdf.ValueTypeNames.Token).Set("st")
|
|
84
|
+
tex.CreateInput("st", Sdf.ValueTypeNames.Float2).ConnectToSource(st.ConnectableAPI(), "result")
|
|
85
|
+
shader.CreateInput("diffuseColor", Sdf.ValueTypeNames.Color3f).ConnectToSource(tex.ConnectableAPI(), "rgb")
|
|
86
|
+
shader.CreateInput("roughness", Sdf.ValueTypeNames.Float).Set(0.85)
|
|
87
|
+
mat.CreateSurfaceOutput().ConnectToSource(shader.ConnectableAPI(), "surface")
|
|
88
|
+
UsdShade.MaterialBindingAPI.Apply(ground.GetPrim()).Bind(mat)
|
|
89
|
+
holder = UsdGeom.Xform.Define(stage, "/World/Asset")
|
|
90
|
+
holder.GetPrim().GetReferences().AddReference(ASSET)
|
|
91
|
+
box = (
|
|
92
|
+
UsdGeom.BBoxCache(Usd.TimeCode.Default(), [UsdGeom.Tokens.default_, UsdGeom.Tokens.render])
|
|
93
|
+
.ComputeWorldBound(holder.GetPrim())
|
|
94
|
+
.ComputeAlignedRange()
|
|
95
|
+
)
|
|
96
|
+
holder.AddTranslateOp().Set(Gf.Vec3d(0, 0, 0.001 - box.GetMin()[2])) # lowest point on the ground
|
|
97
|
+
cam = UsdGeom.Camera.Define(stage, "/World/ReviewCam")
|
|
98
|
+
cam.CreateFocalLengthAttr(24.0)
|
|
99
|
+
cam.CreateHorizontalApertureAttr(20.955)
|
|
100
|
+
cam.CreateVerticalApertureAttr(20.955 * H / W)
|
|
101
|
+
cam.CreateClippingRangeAttr(Gf.Vec2f(0.005, 1000.0))
|
|
102
|
+
s = carb.settings.get_settings()
|
|
103
|
+
s.set("/rtx/post/tonemap/op", 4)
|
|
104
|
+
s.set("/rtx/post/aa/op", 3)
|
|
105
|
+
s.set("/rtx/directLighting/sampledLighting/enabled", True)
|
|
106
|
+
for _ in range(30):
|
|
107
|
+
app.update()
|
|
108
|
+
return stage, holder, UsdGeom.Xformable(cam.GetPrim()).AddTransformOp()
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def bounds(stage):
|
|
112
|
+
return (
|
|
113
|
+
UsdGeom.BBoxCache(Usd.TimeCode.Default(), [UsdGeom.Tokens.default_, UsdGeom.Tokens.render])
|
|
114
|
+
.ComputeWorldBound(stage.GetPrimAtPath("/World/Asset"))
|
|
115
|
+
.ComputeAlignedRange()
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def aim(cam_op, rng, azimuth, elevation):
|
|
120
|
+
lo, hi = rng.GetMin(), rng.GetMax()
|
|
121
|
+
centre = (lo + hi) / 2
|
|
122
|
+
radius = max((hi - lo).GetLength() / 2, 0.02)
|
|
123
|
+
dist = radius / math.sin(math.atan((20.955 * H / W) / 2 / 24.0)) * 1.05
|
|
124
|
+
a, e = math.radians(azimuth), math.radians(elevation)
|
|
125
|
+
eye = centre + Gf.Vec3d(math.cos(e) * math.cos(a), math.cos(e) * math.sin(a), math.sin(e)) * dist
|
|
126
|
+
cam_op.Set(Gf.Matrix4d().SetLookAt(eye, centre, Gf.Vec3d(0, 0, 1)).GetInverse())
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def capture(annot, name):
|
|
130
|
+
rep.orchestrator.step(rt_subframes=8, delta_time=0.0, pause_timeline=True)
|
|
131
|
+
image = Image.fromarray(np.asarray(annot.get_data())[:, :, :3])
|
|
132
|
+
image.save(OUT / f"{name}.png")
|
|
133
|
+
return image
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def frames(bodies):
|
|
137
|
+
cache = UsdGeom.XformCache()
|
|
138
|
+
out = {}
|
|
139
|
+
for b in bodies:
|
|
140
|
+
m = cache.GetLocalToWorldTransform(b)
|
|
141
|
+
out[str(b.GetPath())] = (np.array(m.ExtractRotationMatrix()).T, np.array(m.ExtractTranslation()))
|
|
142
|
+
return out
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def quat_matrix(q):
|
|
146
|
+
"""A Gf quaternion as a 3x3 rotation acting on column vectors."""
|
|
147
|
+
w, (x, y, z) = q.GetReal(), q.GetImaginary()
|
|
148
|
+
return np.array(
|
|
149
|
+
[
|
|
150
|
+
[1 - 2 * (y * y + z * z), 2 * (x * y - w * z), 2 * (x * z + w * y)],
|
|
151
|
+
[2 * (x * y + w * z), 1 - 2 * (x * x + z * z), 2 * (y * z - w * x)],
|
|
152
|
+
[2 * (x * z - w * y), 2 * (y * z + w * x), 1 - 2 * (x * x + y * y)],
|
|
153
|
+
]
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def subtree_load(stage, prim, joints, f):
|
|
158
|
+
"""The mass a joint carries (its child body and everything jointed beyond it) and the lever arm from the joint's
|
|
159
|
+
anchor to that mass's centre, from the settled body poses."""
|
|
160
|
+
children = {}
|
|
161
|
+
for other in joints:
|
|
162
|
+
j = UsdPhysics.Joint(other)
|
|
163
|
+
children.setdefault(str(j.GetBody0Rel().GetTargets()[0]), []).append(str(j.GetBody1Rel().GetTargets()[0]))
|
|
164
|
+
joint = UsdPhysics.Joint(prim)
|
|
165
|
+
parent = str(joint.GetBody0Rel().GetTargets()[0])
|
|
166
|
+
todo, seen = [str(joint.GetBody1Rel().GetTargets()[0])], set()
|
|
167
|
+
while todo:
|
|
168
|
+
body = todo.pop()
|
|
169
|
+
if body not in seen and body in f:
|
|
170
|
+
seen.add(body)
|
|
171
|
+
todo += children.get(body, [])
|
|
172
|
+
masses = {}
|
|
173
|
+
for body in seen:
|
|
174
|
+
api = UsdPhysics.MassAPI(stage.GetPrimAtPath(body))
|
|
175
|
+
masses[body] = (api.GetMassAttr().Get() if api else None) or 0.1
|
|
176
|
+
total = sum(masses.values()) or 0.1
|
|
177
|
+
centre = sum(f[b][1] * m for b, m in masses.items()) / total
|
|
178
|
+
r0, t0 = f[parent] if parent in f else (np.eye(3), np.zeros(3))
|
|
179
|
+
pos0 = joint.GetLocalPos0Attr().Get()
|
|
180
|
+
anchor = r0 @ np.array(pos0 if pos0 is not None else (0, 0, 0), dtype=float) + t0
|
|
181
|
+
return total, max(0.02, float(np.linalg.norm(centre - anchor)))
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
class JointGauge:
|
|
185
|
+
"""A joint's position in its own coordinate, the one PhysX's targets and limits use: joint frame 1 against joint
|
|
186
|
+
frame 0 (each body's pose turned by its localRot), about or along the joint's axis. The rest pose need not be
|
|
187
|
+
position 0 (a lamp can rest at -37 degrees on its shoulder). A hinge's angle is followed in small steps from
|
|
188
|
+
the rest value, so travel past 180 degrees (a cap's thread) counts."""
|
|
189
|
+
|
|
190
|
+
def __init__(self, prim):
|
|
191
|
+
joint = UsdPhysics.Joint(prim)
|
|
192
|
+
self.b0 = str(joint.GetBody0Rel().GetTargets()[0])
|
|
193
|
+
self.b1 = str(joint.GetBody1Rel().GetTargets()[0])
|
|
194
|
+
|
|
195
|
+
def frame(attr, fallback):
|
|
196
|
+
value = attr.Get()
|
|
197
|
+
return fallback(value) if value is not None else None
|
|
198
|
+
|
|
199
|
+
self.rot0 = frame(joint.GetLocalRot0Attr(), quat_matrix)
|
|
200
|
+
self.rot1 = frame(joint.GetLocalRot1Attr(), quat_matrix)
|
|
201
|
+
self.rot0 = np.eye(3) if self.rot0 is None else self.rot0
|
|
202
|
+
self.rot1 = np.eye(3) if self.rot1 is None else self.rot1
|
|
203
|
+
self.pos0 = np.array(joint.GetLocalPos0Attr().Get() or (0, 0, 0), dtype=float)
|
|
204
|
+
self.pos1 = np.array(joint.GetLocalPos1Attr().Get() or (0, 0, 0), dtype=float)
|
|
205
|
+
self.axis = np.eye(3)[:, AXIS[prim.GetAttribute("physics:axis").Get()]] # in joint frame 0
|
|
206
|
+
self.prismatic = prim.IsA(UsdPhysics.PrismaticJoint)
|
|
207
|
+
self.rest, self.value, self.last = None, None, None
|
|
208
|
+
|
|
209
|
+
def _relative(self, f):
|
|
210
|
+
"""Joint frame 1 in joint frame 0: its rotation and, for a slide, its offset."""
|
|
211
|
+
(r0, t0), (r1, t1) = f[self.b0], f[self.b1]
|
|
212
|
+
frame0, frame1 = r0 @ self.rot0, r1 @ self.rot1
|
|
213
|
+
offset = frame0.T @ ((r1 @ self.pos1 + t1) - (r0 @ self.pos0 + t0))
|
|
214
|
+
return frame0.T @ frame1, offset
|
|
215
|
+
|
|
216
|
+
def _angle(self, rotation):
|
|
217
|
+
angle = math.degrees(math.acos(max(-1.0, min(1.0, (np.trace(rotation) - 1) / 2))))
|
|
218
|
+
w = np.array(
|
|
219
|
+
[rotation[2, 1] - rotation[1, 2], rotation[0, 2] - rotation[2, 0], rotation[1, 0] - rotation[0, 1]]
|
|
220
|
+
)
|
|
221
|
+
return angle if w @ self.axis >= 0 else -angle
|
|
222
|
+
|
|
223
|
+
def sample(self, f):
|
|
224
|
+
rotation, offset = self._relative(f)
|
|
225
|
+
if self.prismatic:
|
|
226
|
+
self.value = float(offset @ self.axis)
|
|
227
|
+
elif self.last is None:
|
|
228
|
+
self.value = self._angle(rotation)
|
|
229
|
+
else:
|
|
230
|
+
self.value += self._angle(rotation @ self.last.T)
|
|
231
|
+
self.last = rotation
|
|
232
|
+
if self.rest is None:
|
|
233
|
+
self.rest = self.value
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def verdict(joint):
|
|
237
|
+
return "FAIL" if not joint["passed"] else "short" if joint["short"] else "over" if joint.get("over") else "ok"
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def sheet(rest, posed, report):
|
|
241
|
+
tw, th = 320, 240
|
|
242
|
+
settle = report["settle"]
|
|
243
|
+
lines = [
|
|
244
|
+
f"settle: {'ok' if settle['passed'] else 'FAIL'} (drift {settle['drift_m']} m, moved {settle.get('moved_m')} m)"
|
|
245
|
+
]
|
|
246
|
+
for k, v in report["joints"].items():
|
|
247
|
+
line = f"{k}: target {v['target']} reached {v['reached']} {verdict(v)}"
|
|
248
|
+
if "other_side" in v:
|
|
249
|
+
o = v["other_side"]
|
|
250
|
+
line += f"; other side {o['target']} reached {o['reached']} {verdict(o)}"
|
|
251
|
+
lines.append(line)
|
|
252
|
+
canvas = Image.new("RGB", (tw * 5, th * 2 + 30 + 16 * len(lines) + 12), (22, 24, 28))
|
|
253
|
+
draw = ImageDraw.Draw(canvas)
|
|
254
|
+
for i, (name, image) in enumerate(rest + posed):
|
|
255
|
+
x, y = (i % 5) * tw, (i // 5) * (th + 30)
|
|
256
|
+
canvas.paste(image.resize((tw, th)), (x, y))
|
|
257
|
+
draw.rectangle((x, y, x + 8 + 7 * len(name), y + 16), fill=(0, 0, 0))
|
|
258
|
+
draw.text((x + 4, y + 2), name, fill=(255, 255, 255))
|
|
259
|
+
draw.text((6, th + 8), " | ".join(lines)[:260], fill=(230, 230, 120))
|
|
260
|
+
for i, line in enumerate(lines):
|
|
261
|
+
draw.text((6, 2 * th + 36 + i * 16), line, fill=(230, 230, 230))
|
|
262
|
+
canvas.save(OUT / "review.png")
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def main():
|
|
266
|
+
stage, _holder, cam_op = setup()
|
|
267
|
+
rp = rep.create.render_product("/World/ReviewCam", (W, H))
|
|
268
|
+
annot = rep.AnnotatorRegistry.get_annotator("rgb")
|
|
269
|
+
annot.attach([rp])
|
|
270
|
+
rest_box = bounds(stage)
|
|
271
|
+
aim(cam_op, rest_box, *VIEWS[0][1:])
|
|
272
|
+
for _ in range(40):
|
|
273
|
+
app.update()
|
|
274
|
+
rest = []
|
|
275
|
+
for name, az, el in VIEWS:
|
|
276
|
+
aim(cam_op, rest_box, az, el)
|
|
277
|
+
rest.append((f"rest {name}", capture(annot, f"view_rest_{name}")))
|
|
278
|
+
bodies = [p for p in Usd.PrimRange(stage.GetPrimAtPath("/World/Asset")) if p.HasAPI(UsdPhysics.RigidBodyAPI)]
|
|
279
|
+
joints = [
|
|
280
|
+
p
|
|
281
|
+
for p in Usd.PrimRange(stage.GetPrimAtPath("/World/Asset"))
|
|
282
|
+
if p.IsA(UsdPhysics.RevoluteJoint) or p.IsA(UsdPhysics.PrismaticJoint)
|
|
283
|
+
]
|
|
284
|
+
report = {"asset": ASSET, "bodies": len(bodies), "joints": {}}
|
|
285
|
+
start = frames(bodies) # the modelled pose
|
|
286
|
+
timeline = omni.timeline.get_timeline_interface()
|
|
287
|
+
timeline.play()
|
|
288
|
+
for _ in range(90):
|
|
289
|
+
app.update()
|
|
290
|
+
mid = frames(bodies)
|
|
291
|
+
for _ in range(30):
|
|
292
|
+
app.update()
|
|
293
|
+
settled = frames(bodies)
|
|
294
|
+
pos = np.array([t for _, t in settled.values()]) if settled else np.zeros((1, 3))
|
|
295
|
+
drift = max((np.linalg.norm(settled[k][1] - mid[k][1]) for k in settled), default=0.0)
|
|
296
|
+
# how far any body ended from where it was modelled: a pile that rolls apart and comes to rest has no drift
|
|
297
|
+
moved = max((np.linalg.norm(settled[k][1] - start[k][1]) for k in settled if k in start), default=0.0)
|
|
298
|
+
report["settle"] = {
|
|
299
|
+
"nan": bool(not np.isfinite(pos).all()),
|
|
300
|
+
"fell_through": bool(pos[:, 2].min() < -0.5),
|
|
301
|
+
"drift_m": round(float(drift), 4),
|
|
302
|
+
"moved_m": round(float(moved), 4),
|
|
303
|
+
"most_moved": [ # which bodies: the three that ended furthest from their modelled pose
|
|
304
|
+
{"body": k.rsplit("/", 1)[-1], "moved_m": round(float(np.linalg.norm(settled[k][1] - start[k][1])), 4)}
|
|
305
|
+
for k in sorted(
|
|
306
|
+
(k for k in settled if k in start),
|
|
307
|
+
key=lambda k: -float(np.linalg.norm(settled[k][1] - start[k][1])),
|
|
308
|
+
)[:3]
|
|
309
|
+
],
|
|
310
|
+
}
|
|
311
|
+
settle = report["settle"]
|
|
312
|
+
settle["stable"] = bool(not settle["nan"] and not settle["fell_through"])
|
|
313
|
+
# a joint chain that sags under gravity still moves after 1.5 s; a loose part that slid or rolled has moved
|
|
314
|
+
# further than settling explains (half a percent of the asset's size, 1 cm at least)
|
|
315
|
+
allowed = max(0.01, 0.005 * max(rest_box.GetSize()))
|
|
316
|
+
settle["moved_allowed_m"] = round(float(allowed), 4)
|
|
317
|
+
settle["holds_pose"] = bool(drift < 0.005 and moved < allowed)
|
|
318
|
+
settle["passed"] = settle["stable"] and settle["holds_pose"]
|
|
319
|
+
plans = {}
|
|
320
|
+
for prim in joints:
|
|
321
|
+
joint = (
|
|
322
|
+
UsdPhysics.RevoluteJoint(prim) if prim.IsA(UsdPhysics.RevoluteJoint) else UsdPhysics.PrismaticJoint(prim)
|
|
323
|
+
)
|
|
324
|
+
lo, hi = joint.GetLowerLimitAttr().Get(), joint.GetUpperLimitAttr().Get()
|
|
325
|
+
revolute = prim.IsA(UsdPhysics.RevoluteJoint)
|
|
326
|
+
unlimited = not (math.isfinite(lo) and math.isfinite(hi)) or max(abs(lo), abs(hi)) > 1e6
|
|
327
|
+
if unlimited and not revolute:
|
|
328
|
+
continue # an unlimited slide has no range to drive into
|
|
329
|
+
if unlimited:
|
|
330
|
+
lo, hi = -90.0, 90.0 # a wheel or a free knob: turn it a quarter
|
|
331
|
+
far, near = (lo, hi) if abs(lo) > abs(hi) else (hi, lo)
|
|
332
|
+
sides = [0.8 * far] + ([0.8 * near] if abs(near) > 0.1 * (hi - lo) else []) # a one-sided range: one side
|
|
333
|
+
# The drive is sized to the load it carries, as a real actuator is: strong enough to hold the subtree
|
|
334
|
+
# against gravity within about a third of a degree (or 2% of a slide's range), capped so it cannot
|
|
335
|
+
# overpower the joint's limits.
|
|
336
|
+
load, lever = subtree_load(stage, prim, joints, settled)
|
|
337
|
+
weight = load * 9.81
|
|
338
|
+
stiffness = 3.0 * weight * lever if revolute else weight / min(0.002, 0.02 * (hi - lo)) # per deg or per m
|
|
339
|
+
drive = UsdPhysics.DriveAPI.Apply(prim, "angular" if revolute else "linear")
|
|
340
|
+
drive.CreateStiffnessAttr().Set(stiffness)
|
|
341
|
+
drive.CreateDampingAttr().Set(stiffness * 0.15)
|
|
342
|
+
drive.CreateTargetPositionAttr().Set(float(sides[0]))
|
|
343
|
+
drive.CreateMaxForceAttr().Set(50.0 * weight * lever + 1.0 if revolute else 50.0 * weight + 10.0)
|
|
344
|
+
plans[prim] = (sides, lo, hi, drive)
|
|
345
|
+
gauges = {prim: JointGauge(prim) for prim in plans}
|
|
346
|
+
for gauge in gauges.values():
|
|
347
|
+
gauge.sample(settled)
|
|
348
|
+
|
|
349
|
+
def run(steps):
|
|
350
|
+
for _ in range(steps):
|
|
351
|
+
app.update()
|
|
352
|
+
now = frames(bodies) # every step: a light, fast joint can turn 180 degrees in a few
|
|
353
|
+
for gauge in gauges.values():
|
|
354
|
+
gauge.sample(now)
|
|
355
|
+
|
|
356
|
+
def measured(prim, target, lo, hi):
|
|
357
|
+
reached = gauges[prim].value
|
|
358
|
+
miss = reached - target
|
|
359
|
+
if not gauges[prim].prismatic and abs(hi - lo - 360.0) < 0.1: # one full turn: PhysX may go the short way
|
|
360
|
+
miss = (miss + 180.0) % 360.0 - 180.0
|
|
361
|
+
return {
|
|
362
|
+
"target": round(target, 4),
|
|
363
|
+
"reached": round(reached, 4),
|
|
364
|
+
"passed": bool(math.isfinite(reached) and abs(miss) <= 0.15 * (hi - lo)),
|
|
365
|
+
# within the pass band but well short: something stops it (a collision, the drive's cap): look at it
|
|
366
|
+
"short": bool(math.isfinite(reached) and abs(miss) > 0.05 * (hi - lo) and miss * target < 0),
|
|
367
|
+
# well past it: gravity on a light link, a weak hold
|
|
368
|
+
"over": bool(math.isfinite(reached) and abs(miss) > 0.05 * (hi - lo) and miss * target > 0),
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
run(180)
|
|
372
|
+
driven = frames(bodies) # a blow-up while driving leaves review.json looking normal unless it is checked here
|
|
373
|
+
report["driven_nan"] = bool(not all(np.isfinite(t).all() and np.isfinite(r).all() for r, t in driven.values()))
|
|
374
|
+
for prim, (sides, lo, hi, _) in plans.items():
|
|
375
|
+
report["joints"][prim.GetName()] = {
|
|
376
|
+
"type": "revolute deg" if prim.IsA(UsdPhysics.RevoluteJoint) else "prismatic m",
|
|
377
|
+
"limits": [round(lo, 4), round(hi, 4)],
|
|
378
|
+
"rest": round(gauges[prim].rest, 4),
|
|
379
|
+
**measured(prim, sides[0], lo, hi),
|
|
380
|
+
}
|
|
381
|
+
posed = []
|
|
382
|
+
timeline.pause()
|
|
383
|
+
posed_box = bounds(stage)
|
|
384
|
+
for name, az, el in (VIEWS[1], VIEWS[0], VIEWS[4]): # the top shows a cap or dial's turn
|
|
385
|
+
aim(cam_op, posed_box, az, el)
|
|
386
|
+
caption = "joints driven" if plans else "after 5 s" # no joints: the same scene, simulated
|
|
387
|
+
posed.append((f"{caption} {name}", capture(annot, f"view_posed_{name}")))
|
|
388
|
+
two_sided = {prim: plan for prim, plan in plans.items() if len(plan[0]) > 1}
|
|
389
|
+
if two_sided:
|
|
390
|
+
for sides, _, _, drive in two_sided.values():
|
|
391
|
+
drive.GetTargetPositionAttr().Set(float(sides[1]))
|
|
392
|
+
timeline.play()
|
|
393
|
+
run(240) # across the whole range, so a little longer
|
|
394
|
+
timeline.pause()
|
|
395
|
+
for prim, (sides, lo, hi, _) in two_sided.items():
|
|
396
|
+
report["joints"][prim.GetName()]["other_side"] = measured(prim, sides[1], lo, hi)
|
|
397
|
+
other_box = bounds(stage)
|
|
398
|
+
for name, az, el in (VIEWS[1], VIEWS[0]):
|
|
399
|
+
aim(cam_op, other_box, az, el)
|
|
400
|
+
posed.append((f"other side {name}", capture(annot, f"view_other_{name}")))
|
|
401
|
+
timeline.stop()
|
|
402
|
+
report["passed"] = bool(
|
|
403
|
+
report["settle"]["passed"]
|
|
404
|
+
and not report["driven_nan"]
|
|
405
|
+
and all(j["passed"] and j.get("other_side", {"passed": True})["passed"] for j in report["joints"].values())
|
|
406
|
+
)
|
|
407
|
+
sheet(rest, posed, report)
|
|
408
|
+
report["contact_sheet"] = str(OUT / "review.png")
|
|
409
|
+
(OUT / "review.json").write_text(json.dumps(report, indent=1), encoding="utf-8")
|
|
410
|
+
print("REVIEW " + json.dumps(report, indent=1))
|
|
411
|
+
return report["passed"]
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
try:
|
|
415
|
+
code = 0 if main() else 1
|
|
416
|
+
except Exception: # noqa: BLE001 Kit swallows uncaught exceptions and exits 0
|
|
417
|
+
traceback.print_exc()
|
|
418
|
+
code = 2
|
|
419
|
+
app.close()
|
|
420
|
+
sys.exit(code)
|