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