diff --git a/docs/rlwrld/newton/deformable_apple_strip.png b/docs/rlwrld/newton/deformable_apple_strip.png new file mode 100644 index 0000000..6400e50 --- /dev/null +++ b/docs/rlwrld/newton/deformable_apple_strip.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:48cd03e9fa4a23fd5e3fb73341d2dd7081d04ff46033646b10ddf33eba17d071 +size 58631 diff --git a/docs/rlwrld/newton/deformable_plum_strip.png b/docs/rlwrld/newton/deformable_plum_strip.png new file mode 100644 index 0000000..ebc9b5d --- /dev/null +++ b/docs/rlwrld/newton/deformable_plum_strip.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c748ba610dff2aa5adc7821802e1b61263c39408e9b96e55984c713899838d61 +size 53332 diff --git a/docs/rlwrld/newton/deformable_polybag_cotton_strip.png b/docs/rlwrld/newton/deformable_polybag_cotton_strip.png new file mode 100644 index 0000000..c7057e2 --- /dev/null +++ b/docs/rlwrld/newton/deformable_polybag_cotton_strip.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6b17192a283c48037a7e14a97dbd920296db89bd7136ac84b7668be53bd1cc45 +size 74728 diff --git a/docs/rlwrld/newton/dragon_spoon_19cm_hull_fail.png b/docs/rlwrld/newton/dragon_spoon_19cm_hull_fail.png new file mode 100644 index 0000000..59f85f4 --- /dev/null +++ b/docs/rlwrld/newton/dragon_spoon_19cm_hull_fail.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:05f6cdee78ac647ba06edd5005c45c087e9455d18b1ee53fdc3c65f52dbf030b +size 23769 diff --git a/docs/rlwrld/newton/dragon_spoon_19cm_variant_pass.png b/docs/rlwrld/newton/dragon_spoon_19cm_variant_pass.png new file mode 100644 index 0000000..c12e420 --- /dev/null +++ b/docs/rlwrld/newton/dragon_spoon_19cm_variant_pass.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2c2e820f898aa27aa80a569bb8153cb3ffa94b1811a79ca978c6288545b992b6 +size 35408 diff --git a/docs/rlwrld/newton/godmiddag_bowl_16_hull_fail.png b/docs/rlwrld/newton/godmiddag_bowl_16_hull_fail.png new file mode 100644 index 0000000..cc9e00c --- /dev/null +++ b/docs/rlwrld/newton/godmiddag_bowl_16_hull_fail.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:87362be3a603e0b0ae91c680191fb4454b2daa4ae319ba6897886a2219e5ca0a +size 51077 diff --git a/docs/rlwrld/newton/godmiddag_bowl_16_variant_pass.png b/docs/rlwrld/newton/godmiddag_bowl_16_variant_pass.png new file mode 100644 index 0000000..d99d940 --- /dev/null +++ b/docs/rlwrld/newton/godmiddag_bowl_16_variant_pass.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:4145f720c29f270b1d7ebad4920a954f635eeeaad8cce88df6e83f1c04eaa065 +size 57229 diff --git a/docs/rlwrld/newton/harmynta_deep_plate_22_hull_fail.png b/docs/rlwrld/newton/harmynta_deep_plate_22_hull_fail.png new file mode 100644 index 0000000..fac2182 --- /dev/null +++ b/docs/rlwrld/newton/harmynta_deep_plate_22_hull_fail.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:5fcbdcf914ec4fb1d7a6ee52716e5ff3f07676f6452ed40f399251744697c46d +size 48212 diff --git a/docs/rlwrld/newton/harmynta_deep_plate_22_variant_pass.png b/docs/rlwrld/newton/harmynta_deep_plate_22_variant_pass.png new file mode 100644 index 0000000..4d31e1d --- /dev/null +++ b/docs/rlwrld/newton/harmynta_deep_plate_22_variant_pass.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:59a462b8e803f673543545447e1ac984ab0a638ab03f1c9872edf2826cdec8bb +size 56406 diff --git a/docs/rlwrld/newton/hex_nut_m20_variant_pass.png b/docs/rlwrld/newton/hex_nut_m20_variant_pass.png new file mode 100644 index 0000000..39490ec --- /dev/null +++ b/docs/rlwrld/newton/hex_nut_m20_variant_pass.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a3006758bf75e7428ff109722034e0afec932e9e2ce8ce8873ebcd3cf4bd3844 +size 24962 diff --git a/docs/rlwrld/newton/ikea_365_bowl_rounded_16_hull_fail.png b/docs/rlwrld/newton/ikea_365_bowl_rounded_16_hull_fail.png new file mode 100644 index 0000000..e5ed01b --- /dev/null +++ b/docs/rlwrld/newton/ikea_365_bowl_rounded_16_hull_fail.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6164d017857fbb90f89cc012aa62e6442262bce37bfaecdec8587fdb5cb19efb +size 44730 diff --git a/docs/rlwrld/newton/ikea_365_bowl_rounded_16_variant_pass.png b/docs/rlwrld/newton/ikea_365_bowl_rounded_16_variant_pass.png new file mode 100644 index 0000000..fc35a4c --- /dev/null +++ b/docs/rlwrld/newton/ikea_365_bowl_rounded_16_variant_pass.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:3375318af46d02f7a3ba2e1162cf1984621159dc0a6be26ff497a81dcc2c2786 +size 61375 diff --git a/docs/rlwrld/newton/sus304_flat_bar_fail.png b/docs/rlwrld/newton/sus304_flat_bar_fail.png new file mode 100644 index 0000000..32a4975 --- /dev/null +++ b/docs/rlwrld/newton/sus304_flat_bar_fail.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:19ab91cac4a18d98aa66c6a69ebcadff7b273727bfe3f09685eb3cfd9605fe8d +size 20751 diff --git a/docs/rlwrld/newton_conformance.md b/docs/rlwrld/newton_conformance.md new file mode 100644 index 0000000..2bdcbb1 --- /dev/null +++ b/docs/rlwrld/newton_conformance.md @@ -0,0 +1,102 @@ +# SimReady props on Newton 1.5.2: conformance runner, findings and collision variants + +RLWRLD addition (2026-09-16), tools in `nv_core/testing_tools/rlwrld-newton-conformance/`. + +## Why + +The SimReady runtime tests only exist for Isaac Sim / PhysX. DexBench runs its scenes on Newton as well, +and a package that passes `Prop-Robotics-Neutral 2.0.0` and the PhysX grasp-and-lift can still be +unusable there: MuJoCo-Warp collides every mesh as one 64-vertex convex hull, so a bowl is a lid, a +`convexDecomposition` depends on CoACD at every load, and a planar piece is refused. We needed the same +three runtime tests on Newton, for all 171 handled props, to know which packages hold and what the +engine needs that PhysX did not. + +| `godmiddag_bowl_16`, package colliders (one hull): the pads close inside the "lid" and tip the bowl | the same bowl with its Newton variant: pinched at the rim, lifted, shaken, set down | +|---|---| +| ![](newton/godmiddag_bowl_16_hull_fail.png) | ![](newton/godmiddag_bowl_16_variant_pass.png) | + +## What + +`newton15_cert.py` mirrors the three NVIDIA runtime tests on the standalone Newton 1.5.2 wheel: parse +with Newton's USD importer, drop 1 cm onto a floor, drop onto a 15° walled slope, and grasp-and-lift on the +authored `grasp_identifier_01` with a two-pad gantry (close, lift by max(0.3 m, 2 × longest edge), hold 1 s, +shake 1 cm at 2 Hz for 1.5 s, hold, open). Verdict messages follow NVIDIA's wording so PhysX and Newton +results can be compared line by line. Every asset runs in its own subprocess; a crash is recorded, not fatal. +`newton15_cert_report.py` writes the comparison report. `make_newton_variant.py` authors the per-package +Newton collision layer described below. + +## Results on the 171 handled props + +| test | pass | fail | +|---|---|---| +| parse (Newton importer) | 171 | 0 | +| ground drop | 171 | 0 | +| slope drop (15°, walled) | 170 | 1 | +| grasp-and-lift, line on the body, tuned contact settings | 152 | 19 | +| grasp-and-lift, line in the stage frame | 131 | 40 | +| grasp-and-lift, stock Newton contact settings | 0 | 171 | + +Twenty-six packages disagree with PhysX. The ones PhysX passes and Newton fails are the `sdf` tableware +(five bowls and plates), thin cutlery, a spark plug and a roundline cylinder that tip or roll off their +hull, and a chips bag. The ones Newton passes and PhysX fails are props whose floor-standing PhysX verdict +was a rig limit (the split shaft collar, several thin parts). + +| `dragon_spoon_19cm` on its hull: the pads close on nothing | with the variant: held through the shake | +|---|---| +| ![](newton/dragon_spoon_19cm_hull_fail.png) | ![](newton/dragon_spoon_19cm_variant_pass.png) | + +| `harmynta_deep_plate_22` on its hull: dropped at lift | with the variant: held | +|---|---| +| ![](newton/harmynta_deep_plate_22_hull_fail.png) | ![](newton/harmynta_deep_plate_22_variant_pass.png) | + +## What Newton needs from an asset that PhysX did not + +1. Every collider becomes convex. `sdf` and unauthored mesh colliders silently turn into one 64-vertex hull. +2. No zero-thickness or sliver pieces: 1002 pieces in 16 packages had to be thickened to 1 mm by the runner. +3. CoACD is a hard dependency for `convexDecomposition`, and it runs on every load. +4. Contact stiffness is mass-scaled and the stock settings cannot hold a pinch grasp (0 of 171); the runner uses solref 4 ms, an elliptic cone, `impratio` 10, `condim 4` on the pads. +5. Torsional friction is off by default; without `condim 4` on the gripper every off-centre pinch spins out. +6. Soft contacts creep: props on the 15° slope slide about 1 cm/s and held objects creep millimetres in the jaws, so rest and hold checks need tolerances. +7. Authored poses are not always rest poses (seven packages tip when dropped 1 cm), so the grasp line has to ride with the body. +8. Mass, inertia and bound physics materials are honoured; multi-body vendor packages import as articulations. +9. Hulls are capped at 64 vertices, coarser than PhysX: cylinders facet, rims flatten. +10. `UsdPhysics.LoadUsdPhysicsFromRange` races on a body with many collision prims (heap corruption in about one load in four); `PXR_WORK_THREAD_LIMIT=1` before opening the stage avoids it. + +## Newton collision variants + +`make_newton_variant.py` writes `usd/variants/_newton.usd` inside every package whose colliders the +importer cannot honour: a flattened, self-contained copy in which each such collider is replaced by +pre-decomposed convex pieces (CoACD with the importer's own light search, merge on, at most 32 hulls per +collider and 64 vertices per hull, pieces thinner than 1 mm extruded to 1 mm), placed under the rigid body +in the body's frame with the original physics material and a volume-split mass. The original mesh keeps +rendering. Each variant is parsed back through Newton's importer in a fresh interpreter before it is +accepted; the sidecar records the pieces, the CoACD settings and the certification verdict. + +| | package USD | Newton variant | +|---|---:|---:| +| colliders the importer replaced / thickened | 87 / 1002 | 0 / 0 | +| parse time, all 85 | 95 s | 43 s | +| ground drop pass | 85 | 85 | +| slope drop pass | 85 | 83 | +| grasp holds, line on the body | 74 | 79 | + +| `ikea_365_bowl_rounded_16` hull vs variant | `hex_nut_m20` variant (32 pieces) | `sus304_flat_bar`: a 3 mm sheet the pads cannot pinch on any engine | +|---|---|---| +| ![](newton/ikea_365_bowl_rounded_16_hull_fail.png) ![](newton/ikea_365_bowl_rounded_16_variant_pass.png) | ![](newton/hex_nut_m20_variant_pass.png) | ![](newton/sus304_flat_bar_fail.png) | + +## Deformables (examples) + +SimReady has no runtime test for deformable packages. `examples/newton15_soft_grasp.py` (tetrahedral fruit) +and `examples/newton15_polybag_grasp.py` (cloth film with seal springs) run the same pad gantry on Newton's +VBD soft-body solver: settle, close by a set squeeze, lift, hold, shake, open. The SpaceAI apple, plum and +strawberry and the two polybags hold and deform plausibly. + +| apple | plum | polybag with cotton | +|---|---|---| +| ![](newton/deformable_apple_strip.png) | ![](newton/deformable_plum_strip.png) | ![](newton/deformable_polybag_cotton_strip.png) | + +## What it is not + +Not an engine plugin for `simready-benchmark`; a standalone runner that mirrors the tests' procedure so +verdicts can be compared. Not a SimReady requirement: a package's profile pass stays a PhysX/Kit statement, +and the Newton verdicts are recorded beside it. diff --git a/nv_core/testing_tools/rlwrld-newton-conformance/README.md b/nv_core/testing_tools/rlwrld-newton-conformance/README.md new file mode 100644 index 0000000..7a06171 --- /dev/null +++ b/nv_core/testing_tools/rlwrld-newton-conformance/README.md @@ -0,0 +1,46 @@ +# RLWRLD Newton conformance tools + +The SimReady runtime tests (FET003 drops, FET005 grasp-and-lift) run on Isaac Sim / PhysX. DexBench +also runs its scenes on Newton, so we needed the same three tests on Newton to learn which +`Prop-Robotics-Neutral` packages hold there and what the engine needs from an asset that PhysX did +not. Everything here runs on the standalone Newton 1.5.2 wheel (MuJoCo-Warp rigid solver, warp 1.17), +not inside Kit. + +| file | what | +|---|---| +| `newton15_cert.py` | The runner. Per package: parse with Newton's USD importer, ground drop (1 cm), slope drop (15°, walled), grasp-and-lift on the authored `grasp_identifier_01` with a two-pad gantry (close, lift, hold 1 s, shake 1 cm at 2 Hz, hold, open). Verdict wording follows NVIDIA's. Driver mode runs every asset in its own subprocess (a crash is a finding, not the end of the run). | +| `newton15_cert_report.py` | Turns the merged `results.json` (plus the PhysX verdicts) into the report: totals, Newton-vs-PhysX disagreements, drop tests, collider approximations the importer could not honour. | +| `make_newton_variant.py` | Authors `usd/variants/_newton.usd` inside a package: a flattened copy whose `sdf`, `convexDecomposition` and unauthored mesh colliders become pre-decomposed convex pieces (CoACD, ≤ 32 hulls per collider, ≤ 64 vertices per hull, pieces thinner than 1 mm extruded to 1 mm), parsed back through Newton's importer before it is accepted. Records itself in the package sidecar and changelog (`rlwrld_sidecar.py`). | +| `examples/newton15_soft_grasp.py`, `examples/newton15_polybag_grasp.py` | Grasp-and-deform demos for tetrahedral (fruit) and cloth (polybag) packages on Newton's VBD soft-body solver: the same pad gantry, on assets SimReady has no runtime test for. | + +## Running + +```bash +python -m venv newton15 && newton15/bin/pip install "newton==1.5.2" "newton[importers]" usd-core imageio[ffmpeg] pyglet +# the three tests over a manifest of package USDs (one path per line, relative to --assets-root) +newton15/bin/python newton15_cert.py --assets-root --manifest packages.txt --out out/ --tests parse,ground_drop,slope_drop,grasp_and_lift +# the report, against the PhysX verdicts of the same packages +newton15/bin/python newton15_cert_report.py --main out/results.json --physx grasp_verdicts.json --fet003 fet003_results.json --out report.md +# Newton collision variants for the packages the importer cannot honour +newton15/bin/python make_newton_variant.py --assets-root --candidates candidates.json --write +``` + +`PXR_WORK_THREAD_LIMIT=1` is set by the runner before any stage opens: `UsdPhysics.LoadUsdPhysicsFromRange` +races on a rigid body with many collision prims (heap corruption in about one load of four with usd-core +26.3 on a 32-piece body; never with the work pool single-threaded). + +## What it found on 171 packages (2026-09-16) + +| test | pass / 171 | +|---|---| +| parse | 171 | +| ground drop | 171 | +| slope drop | 170 (a curved single-hull fork rocks and creeps) | +| grasp-and-lift, line on the body | 152 | +| grasp-and-lift, stock Newton contact settings | 0 | + +The 19 grasp failures were almost all colliders: MuJoCo-Warp reduces every mesh collider to one 64-vertex +convex hull, so an `sdf` bowl becomes a lid and a `convexDecomposition` depends on CoACD running at load. +With the Newton variants (85 packages, 2460 pieces) the bowls, plates and the dragon cutlery hold, grasp +holds go from 74 to 79 of the 85, and the importer replaces or thickens nothing. The full write-up with +frame strips is `docs/rlwrld/newton_conformance.md`. diff --git a/nv_core/testing_tools/rlwrld-newton-conformance/examples/newton15_polybag_grasp.py b/nv_core/testing_tools/rlwrld-newton-conformance/examples/newton15_polybag_grasp.py new file mode 100644 index 0000000..03f80f0 --- /dev/null +++ b/nv_core/testing_tools/rlwrld-newton-conformance/examples/newton15_polybag_grasp.py @@ -0,0 +1,201 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: Apache-2.0 +"""Pinch-and-lift demo for SpaceAI's polybag USDZ on standalone Newton 1.5.2. + +The delivered bag is a formed VBD cloth film (triangle mesh, Newton stiffness +values on its physics material, seal pairs that keep the bag closed) and, for +the loaded variant, a tet soft body of cotton inside. This adapter builds both +into one Newton model: cloth from the film mesh, springs across the seal pairs, +the cotton as a soft mesh, VBD self-contact so film and filling collide. Two +kinematic pads then pinch the bag's sealed lip from above and below, lift it, +hold, shake, hold and open, the way a bag is picked off a bench. + + python scripts/tools/newton15_polybag_grasp.py --asset polybag_green_bubble_cotton_loaded.usdz --out out.mp4 + +Newton 1.5.2 standalone (warp 1.17), not the Arena image (Newton 1.2.1). Needs a venv with +``newton==1.5.2 usd-core imageio imageio-ffmpeg "pyglet>=2.0"`` and a display for the headless GL viewer +(``DISPLAY=:1`` on the workstation). +""" + +from __future__ import annotations + +import argparse +import math +import time +from pathlib import Path + +import imageio.v2 as imageio +import numpy as np +import warp as wp +from pxr import Usd, UsdGeom + +import newton +from newton import ModelBuilder +from newton.solvers import SolverVBD + +TABLE_TOP_Z = 0.20 + + +def read_bag(usdz: Path): + stage = Usd.Stage.Open(str(usdz)) + film = stage.GetPrimAtPath("/Polybag/Film/Simulation") + mesh = UsdGeom.Mesh(film) + pts = np.array(mesh.GetPointsAttr().Get(), dtype=np.float32) + fvc = np.array(mesh.GetFaceVertexCountsAttr().Get()); assert (fvc == 3).all(), "film must be triangles" + tris = np.array(mesh.GetFaceVertexIndicesAttr().Get(), dtype=np.int32) + seal = np.array(film.GetAttribute("rlwrld:sealPairs").Get(), dtype=np.int32).reshape(-1, 2) + lip = np.array(film.GetAttribute("rlwrld:foldLipIndices").Get(), dtype=np.int32) + mat = stage.GetPrimAtPath("/Polybag/Film/PhysicsMaterial") + g = lambda n, d=None: (mat.GetAttribute(n).Get() if mat.GetAttribute(n) and mat.GetAttribute(n).HasAuthoredValue() else d) + film_params = dict(density=float(g("newton:density", 0.133)), tri_ke=float(g("newton:triKe", 5e4)), tri_ka=float(g("newton:triKa", 0.0)), + tri_kd=float(g("newton:triKd", 10.0)), edge_ke=float(g("newton:edgeKe", 1.0)), edge_kd=float(g("newton:edgeKd", 2e-4)), + particle_radius=float(g("newton:particleRadius", 0.003))) + root = stage.GetPrimAtPath("/Polybag") + r = lambda n, d: (root.GetAttribute(n).Get() if root.GetAttribute(n) and root.GetAttribute(n).HasAuthoredValue() else d) + contact = dict(seal_ke=float(r("rlwrld:contact:seal_spring_ke_n_m", 1e5)), soft_ke=float(r("rlwrld:contact:soft_contact_ke", 1e5)), + soft_kd=float(r("rlwrld:contact:soft_contact_kd", 100.0)), mu=float(r("rlwrld:simulation:soft_contact_mu", 0.8)), + self_radius=float(r("rlwrld:contact:self_contact_radius_m", 5e-4)), self_margin=float(r("rlwrld:contact:self_contact_margin_m", 1e-3))) + contents = stage.GetPrimAtPath("/Polybag/Contents/Simulation") + tet = None + if contents and contents.GetTypeName() == "TetMesh": + tet = newton.TetMesh.create_from_usd(contents, compat_namespaces=()) + for attr in ("custom_attributes",): + store = getattr(tet, attr, None) + if isinstance(store, dict): + store.clear() + return pts, tris, seal, lip, film_params, contact, tet + + +class Demo: + def __init__(self, usdz: Path, out: Path, fps: int = 30, substeps: int = 40, iterations: int = 20, float_start: bool = False): + self.float_start = float_start + self.frame_dt = 1.0 / fps; self.sim_dt = self.frame_dt / substeps; self.substeps = substeps; self.sim_time = 0.0 + pts, tris, seal, lip, fp, contact, tet = read_bag(usdz) + lo, hi = pts.min(0), pts.max(0); size = hi - lo + centre = (lo + hi) * 0.5 + # put the bag flat on the table, centred in x,y + # a flat empty film cannot be pinched off a table by cube pads (the lower pad would be inside the + # table); with --float the bag starts 0.15 m up at zero g, is pinched, and gravity returns before the + # lift, the free-space variant of the rigid benchmark + start_z = TABLE_TOP_Z - lo[2] + (0.15 if float_start else 0.002) + offset = np.array([-centre[0], -centre[1], start_z], dtype=np.float32) + print(f"[bag] {usdz.name}: film {len(pts)} verts / {len(tris)//3} tris, size {np.round(size*1000,1)} mm, seal pairs {len(seal)}, lip verts {len(lip)}, " + f"film {fp}, contents {'tets %d' % tet.tet_count if tet else 'none'}") + builder = ModelBuilder(gravity=(0.0, 0.0, -9.81)) + builder.add_shape_box(-1, xform=wp.transform(wp.vec3(0.0, 0.0, TABLE_TOP_Z * 0.5), wp.quat_identity()), hx=0.4, hy=0.4, hz=TABLE_TOP_Z * 0.5) + p0 = builder.particle_count + builder.add_cloth_mesh(pos=wp.vec3(*offset.tolist()), rot=wp.quat_identity(), scale=1.0, vel=wp.vec3(0.0, 0.0, 0.0), + vertices=[wp.vec3(*p) for p in pts], indices=tris.tolist(), density=fp["density"], + tri_ke=fp["tri_ke"], tri_ka=fp["tri_ka"], tri_kd=fp["tri_kd"], edge_ke=fp["edge_ke"], edge_kd=fp["edge_kd"], + particle_radius=fp["particle_radius"]) + for i, j in seal: + builder.add_spring(int(p0 + i), int(p0 + j), contact["seal_ke"], 1.0, 0.0) + if tet is not None: + builder.add_soft_mesh(pos=wp.vec3(*offset.tolist()), rot=wp.quat_identity(), scale=1.0, vel=wp.vec3(0.0, 0.0, 0.0), + mesh=tet, density=tet.density if tet.density is not None else 75.0, + k_mu=tet.k_mu if tet.k_mu is not None else 900.0, k_lambda=tet.k_lambda if tet.k_lambda is not None else 230.0, + k_damp=10.0, particle_radius=0.002) + # lip pinch point: middle of the lip vertices, in world + lip_world = pts[lip] + offset + self.pinch = lip_world.mean(axis=0); lip_span = lip_world.max(0) - lip_world.min(0) + print(f"[bag] lip centre {np.round(self.pinch,3)}, lip span {np.round(lip_span*1000,1)} mm, bag top z {hi[2]+offset[2]:.3f}") + self.pad_half = wp.vec3(0.08, 0.03, 0.006) # a wide flat pinch along the sealed lip + self.pad_bodies = [] + for sign in (-1.0, 1.0): + b = builder.add_body(xform=wp.transform(wp.vec3(0.0, 0.0, 0.5 + sign * 0.1), wp.quat_identity()), mass=0.0, is_kinematic=True) + builder.add_shape_box(b, hx=self.pad_half[0], hy=self.pad_half[1], hz=self.pad_half[2]) + self.pad_bodies.append(b) + builder.color() + self.model = builder.finalize(requires_grad=False) + self.model.soft_contact_ke = contact["soft_ke"]; self.model.soft_contact_kd = contact["soft_kd"]; self.model.soft_contact_mu = contact["mu"] + self.model.shape_material_mu.fill_(1.0) + self.state_0 = self.model.state(); self.state_1 = self.model.state(); self.control = self.model.control() + self.collision = newton.CollisionPipeline(self.model, soft_contact_margin=0.006) + self.contacts = self.collision.contacts() + # detect contacts every substep: a bag dropped from 0.36 m moves ~9 cm per frame and tunnels + # through the table when detection runs once per frame + self.solver = SolverVBD(self.model, iterations=iterations, particle_enable_self_contact=True, + particle_self_contact_radius=max(contact["self_radius"], 0.002), particle_self_contact_margin=max(contact["self_margin"], 0.004), + particle_collision_detection_interval=1) + # pinch plan along z at the lip: pads close on the film thickness, lift 25 cm + px, py, pz = (float(v) for v in self.pinch) + open_dz = 0.05; closed_dz = self.pad_half[2] + 0.0005 # pad faces 1 mm apart on the two film layers + self.pinch_xy = (px, py); self.lift = 0.36 # the bag is 32 cm long: clear the table when hanging + self.plan = [ # (duration, pad half-gap, pad z, pad y offset away from the bag) + (0.8, open_dz, pz + 0.15, 0.0), (0.8, open_dz, pz, 0.0), (0.6, closed_dz, pz, 0.0), (0.4, closed_dz, pz, 0.0), + (1.5, closed_dz, pz + self.lift, 0.0), (0.8, closed_dz, pz + self.lift, 0.0), (1.5, None, None, None), + (0.8, closed_dz, pz + self.lift, 0.0), (0.6, open_dz, pz + self.lift, 0.0), + # an opened lower pad is a shelf under the fold: slide both pads out from under the lip + (0.8, open_dz, pz + self.lift, 0.22), (0.8, open_dz, pz + self.lift, 0.22), + ] + self.total = sum(p[0] for p in self.plan) + self.close_done_t = sum(p[0] for p in self.plan[:4]) + self.gravity_zero = wp.zeros(self.model.gravity.shape[0], dtype=wp.vec3, device=self.model.device) + self.gravity_earth = wp.full(self.model.gravity.shape[0], wp.vec3(0.0, 0.0, -9.81), dtype=wp.vec3, device=self.model.device) + if self.float_start: + self.model.gravity.assign(self.gravity_zero) + self.viewer = newton.viewer.ViewerGL(width=768, height=768, headless=True); self.viewer.set_model(self.model) + self.viewer.set_camera(wp.vec3(px - 0.75, py - 0.75, pz + 0.30), -14.0, 45.0) + self.writer = imageio.get_writer(str(out), fps=fps, codec="libx264", quality=7) + self.film_slice = slice(p0, p0 + len(pts)) + + def pad_targets(self, t): + acc = 0.0; prev = (0.05, self.pinch[2] + 0.15, 0.0) + for dur, dz, z, dy in self.plan: + if dz is None: + if t < acc + dur: + return prev[0], prev[1] + 0.01 * math.sin(2.0 * math.pi * 2.0 * (t - acc)), prev[2] + acc += dur; continue + if t < acc + dur: + a = (t - acc) / dur; return prev[0] + (dz - prev[0]) * a, prev[1] + (z - prev[1]) * a, prev[2] + (dy - prev[2]) * a + acc += dur; prev = (dz, z, dy) + return prev + + def set_pads(self, t, dt): + dz, z, dy = self.pad_targets(t); dz2, z2, dy2 = self.pad_targets(t + dt) + q = self.state_0.body_q.numpy(); qd = self.state_0.body_qd.numpy(); px, py = self.pinch_xy + for i, sign in zip(self.pad_bodies, (-1.0, 1.0)): + q[i, :3] = (px, py + dy, z + sign * dz); q[i, 3:] = (0.0, 0.0, 0.0, 1.0) + qd[i, :3] = (0.0, (dy2 - dy) / dt, ((z2 + sign * dz2) - (z + sign * dz)) / dt); qd[i, 3:] = 0.0 + for st in (self.state_0, self.state_1): + st.body_q.assign(q); st.body_qd.assign(qd) + + def step(self): + for _ in range(self.substeps): + self.solver.rebuild_bvh(self.state_0) + self.state_0.clear_forces(); self.state_1.clear_forces() + self.set_pads(self.sim_time, self.sim_dt) + if self.float_start and self.sim_time >= self.close_done_t: + self.model.gravity.assign(self.gravity_earth); self.float_start = False + self.collision.collide(self.state_0, self.contacts) + self.solver.step(self.state_0, self.state_1, self.control, self.contacts, self.sim_dt) + self.state_0, self.state_1 = self.state_1, self.state_0 + self.sim_time += self.sim_dt + + def render(self): + self.viewer.begin_frame(self.sim_time); self.viewer.log_state(self.state_0); self.viewer.end_frame() + self.writer.append_data(self.viewer.get_frame().numpy()) + + def run(self): + n = int(self.total / self.frame_dt); log = []; t0 = time.time() + for i in range(n): + self.step(); self.render() + if i % 15 == 0: + p = self.state_0.particle_q.numpy(); film = p[self.film_slice] + log.append((round(self.sim_time, 2), "film z min/mean/max mm above table", [round(float(v - TABLE_TOP_Z) * 1000, 1) for v in (film[:, 2].min(), film[:, 2].mean(), film[:, 2].max())], + "nan" if np.isnan(p).any() else "ok")) + self.writer.close(); self.viewer.close() + print(f"[bag] {n} frames in {time.time()-t0:.0f}s") + for row in log: print(" ", row) + + +def main(): + ap = argparse.ArgumentParser(); ap.add_argument("--asset", type=Path, required=True); ap.add_argument("--out", type=Path, required=True) + ap.add_argument("--substeps", type=int, default=40); ap.add_argument("--iterations", type=int, default=20) + ap.add_argument("--float", action="store_true", help="start the bag floating at zero g and restore gravity after the pinch (flat empty film)") + args = ap.parse_args(); args.out.parent.mkdir(parents=True, exist_ok=True); wp.init() + Demo(args.asset, args.out, substeps=args.substeps, iterations=args.iterations, float_start=args.float).run() + + +if __name__ == "__main__": + main() diff --git a/nv_core/testing_tools/rlwrld-newton-conformance/examples/newton15_soft_grasp.py b/nv_core/testing_tools/rlwrld-newton-conformance/examples/newton15_soft_grasp.py new file mode 100644 index 0000000..390cfd5 --- /dev/null +++ b/nv_core/testing_tools/rlwrld-newton-conformance/examples/newton15_soft_grasp.py @@ -0,0 +1,198 @@ +"""Grasp-and-deform demo for SpaceAI's tetrahedral fruit assets on standalone Newton 1.5.2. + +A gantry with two kinematic box pads (the same idea as NVIDIA's grasp_and_lift +benchmark, no robot) descends over the fruit resting on a table, closes on it by +a set squeeze depth, lifts, holds, shakes, holds and opens. The fruit is a Newton +VBD tet soft body built straight from the delivered USDZ (TetMesh prim + the +physics material it binds: Young's modulus, Poisson's ratio, density). Frames +come from the headless OpenGL viewer and go to an mp4. + + python outputs/newton15_soft_grasp.py --asset apple.usdz --out outputs/newton15_videos/apple.mp4 + +Newton 1.5.2 standalone (warp 1.17), not the Arena image (Newton 1.2.1). +""" + +from __future__ import annotations + +import argparse +import math +import time +from pathlib import Path + +import imageio.v2 as imageio +import numpy as np +import warp as wp +from pxr import Usd, UsdGeom + +import newton +from newton import ModelBuilder +from newton.solvers import SolverVBD + +TABLE_TOP_Z = 0.20 + + +def load_tetmesh(usdz: Path): + stage = Usd.Stage.Open(str(usdz)) + tet_prim = next(p for p in stage.Traverse() if p.GetTypeName() == "TetMesh") + tet = newton.TetMesh.create_from_usd(tet_prim, compat_namespaces=()) + # primvars (displayColor etc.) ride along as custom attributes the builder wants registered; we only need geometry + material + for attr in ("custom_attributes", "custom_attrs", "attributes"): + store = getattr(tet, attr, None) + if isinstance(store, dict): + store.clear() + render = next((p for p in stage.Traverse() if p.IsA(UsdGeom.Mesh) and p.GetName().endswith("_render")), None) + pts = np.array(tet_prim.GetAttribute("points").Get(), dtype=np.float32) + lo, hi = pts.min(axis=0), pts.max(axis=0) + return tet, tet_prim.GetPath().pathString, lo, hi, (tet.k_mu, tet.k_lambda, tet.density), render + + +class Demo: + def __init__(self, usdz: Path, out: Path, squeeze_m: float, fps: int = 30, substeps: int = 20, iterations: int = 10): + self.frame_dt = 1.0 / fps + self.sim_dt = self.frame_dt / substeps + self.substeps = substeps + self.sim_time = 0.0 + tet, tet_path, lo, hi, mat, render = load_tetmesh(usdz) + size = hi - lo + self.size = size + k_mu, k_lambda, density = mat + density = 800.0 if density is None else density + print(f"[demo] {usdz.name}: tet prim {tet_path}, size {np.round(size*1000,1)} mm, " + f"k_mu {float(np.mean(k_mu)):.3g} k_lambda {float(np.mean(k_lambda)):.3g} density {float(np.mean(density)):.0f}") + + builder = ModelBuilder(gravity=(0.0, 0.0, -9.81)) + # table + builder.add_shape_box(-1, xform=wp.transform(wp.vec3(0.0, 0.0, TABLE_TOP_Z * 0.5), wp.quat_identity()), hx=0.3, hy=0.3, hz=TABLE_TOP_Z * 0.5) + # fruit: authored with the bottom at z=0, centre at x,y ~ 0 -> put it on the table + centre_xy = (lo[:2] + hi[:2]) * 0.5 + self.fruit_pos = wp.vec3(float(-centre_xy[0]), float(-centre_xy[1]), TABLE_TOP_Z - float(lo[2]) + 0.001) + particle_radius = 0.002 + builder.add_soft_mesh( + pos=self.fruit_pos, rot=wp.quat_identity(), scale=1.0, vel=wp.vec3(0.0, 0.0, 0.0), + mesh=tet, density=density, k_mu=k_mu, k_lambda=k_lambda, k_damp=0.0, + particle_radius=particle_radius, + ) + # gantry pads: two kinematic boxes closing along x + self.pad_half = wp.vec3(0.008, 0.02, 0.02) + self.pad_bodies = [] + for sign in (-1.0, 1.0): + b = builder.add_body(xform=wp.transform(wp.vec3(sign * 0.2, 0.0, 0.5), wp.quat_identity()), mass=0.0, is_kinematic=True) + builder.add_shape_box(b, hx=self.pad_half[0], hy=self.pad_half[1], hz=self.pad_half[2]) + self.pad_bodies.append(b) + builder.color() + self.model = builder.finalize(requires_grad=False) + self.model.soft_contact_ke = 2.0e5 + self.model.soft_contact_kd = 2.0e1 + self.model.soft_contact_mu = 0.9 + self.model.shape_material_mu.fill_(1.2) + self.state_0 = self.model.state() + self.state_1 = self.model.state() + self.control = self.model.control() + self.collision = newton.CollisionPipeline(self.model, soft_contact_margin=0.01) + self.contacts = self.collision.contacts() + self.solver = SolverVBD(self.model, iterations=iterations, particle_enable_self_contact=False, + particle_collision_detection_interval=-1) + + # grasp plan (metres, seconds) + width = float(size[0]); height = float(size[2]) + self.grasp_z = TABLE_TOP_Z + 0.55 * height + self.open_x = width * 0.5 + 0.03 + self.pad_half[0] + self.closed_x = max(width * 0.5 - squeeze_m + self.pad_half[0], 0.005) + self.lift = max(0.2, 2.0 * float(size.max())) + self.plan = [ # (duration, x_pad, z_pad) targets, linearly interpolated + (0.8, self.open_x, self.grasp_z + 0.25), + (0.8, self.open_x, self.grasp_z), + (0.8, self.closed_x, self.grasp_z), + (0.4, self.closed_x, self.grasp_z), + (1.2, self.closed_x, self.grasp_z + self.lift), + (0.8, self.closed_x, self.grasp_z + self.lift), + (1.5, None, None), # shake + (0.8, self.closed_x, self.grasp_z + self.lift), + (0.6, self.open_x, self.grasp_z + self.lift), + (1.0, self.open_x, self.grasp_z + self.lift), + ] + self.total = sum(p[0] for p in self.plan) + self.body_q_host = np.zeros((self.model.body_count, 7), dtype=np.float32) + self.viewer = newton.viewer.ViewerGL(width=768, height=768, headless=True) + self.viewer.set_model(self.model) + # frame the whole motion: table top to the top of the lift + mid_z = self.grasp_z + 0.5 * self.lift + dist = max(0.5, 2.0 * self.lift) + self.viewer.set_camera(wp.vec3(-dist * 0.7, -dist * 0.7, mid_z + 0.15 * dist), -16.0, 45.0) + self.writer = imageio.get_writer(str(out), fps=fps, codec="libx264", quality=7) + self.fruit_z0 = None + + def pad_targets(self, t): + acc = 0.0; prev = (self.open_x, self.grasp_z + 0.25) + for dur, x, z in self.plan: + if x is None: # shake: 1 cm at 2 Hz about the hold pose + if t < acc + dur: + dz = 0.01 * math.sin(2.0 * math.pi * 2.0 * (t - acc)) + return prev[0], prev[1] + dz + acc += dur; continue + if t < acc + dur: + a = (t - acc) / dur + return prev[0] + (x - prev[0]) * a, prev[1] + (z - prev[1]) * a + acc += dur; prev = (x, z) + return prev + + def set_pads(self, t, dt): + x, z = self.pad_targets(t) + x2, z2 = self.pad_targets(t + dt) + q = self.state_0.body_q.numpy(); qd = self.state_0.body_qd.numpy() + for i, sign in zip(self.pad_bodies, (-1.0, 1.0)): + q[i, :3] = (sign * x, 0.0, z); q[i, 3:] = (0.0, 0.0, 0.0, 1.0) + qd[i, :3] = ((sign * (x2 - x)) / dt, 0.0, (z2 - z) / dt); qd[i, 3:] = 0.0 + self.state_0.body_q.assign(q); self.state_0.body_qd.assign(qd) + self.state_1.body_q.assign(q); self.state_1.body_qd.assign(qd) + + def step(self): + self.solver.rebuild_bvh(self.state_0) + for _ in range(self.substeps): + self.state_0.clear_forces(); self.state_1.clear_forces() + self.set_pads(self.sim_time, self.sim_dt) + self.collision.collide(self.state_0, self.contacts) + self.solver.step(self.state_0, self.state_1, self.control, self.contacts, self.sim_dt) + self.state_0, self.state_1 = self.state_1, self.state_0 + self.sim_time += self.sim_dt + + def fruit_stats(self): + p = self.state_0.particle_q.numpy() + return p.mean(axis=0), p.min(axis=0), p.max(axis=0) + + def render(self): + self.viewer.begin_frame(self.sim_time) + self.viewer.log_state(self.state_0) + self.viewer.end_frame() + frame = self.viewer.get_frame().numpy() + self.writer.append_data(frame) # get_frame() is already top-down + + def run(self): + log = [] + n = int(self.total / self.frame_dt) + t0 = time.time() + for i in range(n): + self.step(); self.render() + if i % 15 == 0: + c, lo, hi = self.fruit_stats(); ext = hi - lo; q = self.state_0.body_q.numpy() + log.append((round(self.sim_time, 2), round(float(c[2] - TABLE_TOP_Z), 4), [round(float(v) * 1000, 1) for v in ext], + "pads x/z mm", [round(float(q[b, 0]) * 1000, 1) for b in self.pad_bodies], round(float(q[self.pad_bodies[0], 2] - TABLE_TOP_Z) * 1000, 1))) + self.writer.close(); self.viewer.close() + print(f"[demo] {n} frames in {time.time()-t0:.0f}s; centre height above table / extents (mm) over time:") + for row in log: print(" ", row) + return log + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("--asset", type=Path, required=True) + ap.add_argument("--out", type=Path, required=True) + ap.add_argument("--squeeze_mm", type=float, default=8.0, help="how far each pad closes past the surface") + args = ap.parse_args() + args.out.parent.mkdir(parents=True, exist_ok=True) + wp.init() + Demo(args.asset, args.out, squeeze_m=args.squeeze_mm / 1000.0).run() + + +if __name__ == "__main__": + main() diff --git a/nv_core/testing_tools/rlwrld-newton-conformance/make_newton_variant.py b/nv_core/testing_tools/rlwrld-newton-conformance/make_newton_variant.py new file mode 100644 index 0000000..5b009b6 --- /dev/null +++ b/nv_core/testing_tools/rlwrld-newton-conformance/make_newton_variant.py @@ -0,0 +1,344 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: Apache-2.0 +"""Author a Newton collision variant of a handled prop, inside its package. + +Newton's rigid solvers (MuJoCo-Warp in particular) collide every mesh collider as one +convex hull of at most 64 vertices: an ``sdf`` bowl becomes a lid, a ``convexDecomposition`` +only means something when CoACD is installed and runs at every load, and a planar piece is +refused outright. This tool writes ``/variants/_newton.usd``, a self-contained +flattened copy of the package USD in which it replaces every mesh collider Newton cannot honour by +pre-decomposed convex pieces (CoACD offline, <= 64 vertices each, >= 1 mm thick, purpose +``guide``, the original's physics material and, where the collider carried its own mass, the +mass split over the pieces by volume). The original collider keeps rendering; only its +CollisionAPI is dropped in the variant (a flattened copy, so the package USD is not composed at load). Nothing in the package USD changes; the base sidecar +gains ``asset.variants.newton`` and a MINOR bump, the variant gets its own sidecar. + + /bin/python scripts/tools/make_newton_variant.py --assets-root --only ikea_365_bowl_rounded_19 --check + /bin/python scripts/tools/make_newton_variant.py --assets-root --candidates outputs/newton_variant_candidates.json --write + +Runs in the Newton 1.5.2 venv (pxr, coacd, numpy, scipy, newton) so the variant is parsed +back through Newton's own importer before it is accepted: every collider must come out as a +CONVEX_MESH or a primitive, nothing thickened, nothing replaced. +""" + +from __future__ import annotations + +import argparse +import datetime as dt +import json +import sys +import time +from pathlib import Path + +import numpy as np + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +from rlwrld_sidecar import bump, sidecar_for # noqa: E402 (vendored from DexBench-Arena's conform_simready_basics) + +REPLACE = {"sdf", "convexdecomposition", "meshsimplification", "none"} +MAX_HULL_VERTS = 64 +MIN_THICKNESS_M = 0.001 +TOOL = "nv_core/testing_tools/rlwrld-newton-conformance/make_newton_variant.py" + + +def triangulate(counts, indices): + tris = []; k = 0 + for c in counts: + for i in range(1, c - 1): + tris.append((indices[k], indices[k + i], indices[k + i + 1])) + k += c + return np.asarray(tris, dtype=np.int64) + + +def thin_axis(v): + c = v - v.mean(axis=0) + _, s, vt = np.linalg.svd(c, full_matrices=False) + n = vt[-1]; ext = float((c @ n).max() - (c @ n).min()) + return n, ext + + +def hull_piece(v): + """Convex hull of a piece as (points <= 64, triangles); thickened when thinner than 1 mm.""" + from scipy.spatial import ConvexHull + v = np.asarray(v, dtype=np.float64) + h = ConvexHull(v) + pts = v[h.vertices] + # a sliver is thin along one axis, a needle off a ring along two; each extrusion doubles the + # vertices, so the cap is applied first, small enough that the extruded hull stays <= 64 + # vertices (a piece above that would be re-hulled by the importer, which drops the thickness) + c = pts - pts.mean(axis=0); ext = np.linalg.svd(c, full_matrices=False)[2] @ c.T + n_thin = int(sum((ext[i].max() - ext[i].min()) < MIN_THICKNESS_M for i in (1, 2))) + cap = MAX_HULL_VERTS >> n_thin + if len(pts) > cap: # keep the farthest-spread vertices, then hull again + keep = [int(np.argmax(np.linalg.norm(pts - pts.mean(axis=0), axis=1)))] + d = np.linalg.norm(pts - pts[keep[0]], axis=1) + while len(keep) < cap: + j = int(np.argmax(d)); keep.append(j); d = np.minimum(d, np.linalg.norm(pts - pts[j], axis=1)) + pts = pts[keep]; h = ConvexHull(pts); pts = pts[h.vertices] + thickened = False + for _ in range(3): + n, ext1 = thin_axis(pts) + if ext1 >= MIN_THICKNESS_M: + break + pts = np.concatenate([pts + 0.5 * MIN_THICKNESS_M * n, pts - 0.5 * MIN_THICKNESS_M * n]); thickened = True + h = ConvexHull(pts); pts = pts[h.vertices] + assert len(pts) <= MAX_HULL_VERTS, len(pts) + h = ConvexHull(pts) + # orient every triangle outward + ctr = pts.mean(axis=0); tris = [] + for s in h.simplices: + a, b, c = pts[s] + if np.dot(np.cross(b - a, c - a), a - ctr) < 0: s = s[[0, 2, 1]] + tris.append(s) + return pts, np.asarray(tris, dtype=np.int64), float(h.volume), thickened + + +def decompose(points, tris, threshold, max_hulls, seed=0): + import coacd + coacd.set_log_level("off") + m = coacd.Mesh(np.asarray(points, dtype=np.float64), np.asarray(tris, dtype=np.int64)) + # the same light MCTS Newton's importer uses for a load-time convexDecomposition (a full + # search takes tens of minutes on a 260k-vertex scan), with merging on and a hull cap + parts = coacd.run_coacd(m, threshold=threshold, max_convex_hull=max_hulls, max_ch_vertex=MAX_HULL_VERTS, + mcts_nodes=20, mcts_iterations=5, mcts_max_depth=1, + merge=True, preprocess_mode="auto", seed=seed) + return [np.asarray(p[0], dtype=np.float64) for p in parts] + + +def collider_targets(stage): + from pxr import UsdGeom, UsdPhysics + out = [] + for p in stage.Traverse(): + if not p.HasAPI(UsdPhysics.CollisionAPI) or not p.IsA(UsdGeom.Mesh): + continue + a = p.GetAttribute("physics:approximation") + approx = (a.Get() if a and a.HasAuthoredValue() else None) or "none" + if approx.lower() in REPLACE or approx.lower() == "convexhull": + out.append((p, approx)) # a convexHull collider is only touched when it is thinner than 1 mm + return out + + +def author_variant(base_usd: Path, variant_usd: Path, threshold: float, max_hulls: int, log) -> dict: + from pxr import Gf, Sdf, Usd, UsdGeom, UsdPhysics, UsdShade, Vt + base = Usd.Stage.Open(str(base_usd)) + default = base.GetDefaultPrim() + targets = collider_targets(base) + if not targets: + return {} + # (a package whose only candidates are convexHull colliders of sane thickness ends up with no + # collider record, and is reported as nothing to replace) + variant_usd.parent.mkdir(parents=True, exist_ok=True) + if variant_usd.exists(): + variant_usd.unlink() + # Authored over the package USD as a sublayer, then flattened into one self-contained + # layer: usd-core's UsdPhysics parser (which Newton's importer and Isaac Lab's Newton + # backend both call) corrupts the heap, intermittently, on a stage composed from more + # than one layer, and a variant that only sometimes loads is no variant. + layer = Sdf.Layer.CreateAnonymous("newton_variant.usd") + layer.subLayerPaths = [str(base_usd.resolve())] + layer.defaultPrim = default.GetName() + stage = Usd.Stage.Open(layer) + UsdGeom.SetStageMetersPerUnit(stage, UsdGeom.GetStageMetersPerUnit(base)) + UsdGeom.SetStageUpAxis(stage, UsdGeom.GetStageUpAxis(base)) + record = {"colliders": [], "pieces": 0, "thickened": 0, "coacd": {"threshold": threshold, "max_convex_hull": max_hulls, "max_ch_vertex": MAX_HULL_VERTS, "mcts_nodes": 20, "mcts_iterations": 5, "mcts_max_depth": 1, "merge": True}} + for prim, approx in targets: + src = stage.GetPrimAtPath(prim.GetPath()) + mesh = UsdGeom.Mesh(src) + pts = np.asarray(mesh.GetPointsAttr().Get(), dtype=np.float64) + tris = triangulate(mesh.GetFaceVertexCountsAttr().Get(), mesh.GetFaceVertexIndicesAttr().Get()) + # pieces are authored in the rigid body's frame, under the body prim, with no transform of + # their own: the collider's transform (vendor parts carry a scale in it) is baked into the + # points, so the 1 mm thickness floor is a metre and a collider always sits below its body + body = src + while body and not body.HasAPI(UsdPhysics.RigidBodyAPI): + body = body.GetParent() + if not body: + body = src.GetParent() + xf_cache = UsdGeom.XformCache(Usd.TimeCode.Default()) + to_body = xf_cache.GetLocalToWorldTransform(src) * xf_cache.GetLocalToWorldTransform(body).GetInverse() + m = np.asarray([[to_body[r][c] for c in range(4)] for r in range(4)], dtype=np.float64) # row-vector convention + pts = (np.concatenate([pts, np.ones((len(pts), 1))], axis=1) @ m)[:, :3] + t0 = time.time() + if approx.lower() == "convexhull": + if thin_axis(pts)[1] >= MIN_THICKNESS_M: + continue # an honoured hull of sane thickness stays as it is + pieces = [hull_piece(pts)] # a sheet-metal tab, a shim: one hull, extruded to 1 mm + else: + parts = decompose(pts, tris, threshold, max_hulls) + pieces = [hull_piece(p) for p in parts] + vol = sum(p[2] for p in pieces) or 1.0 + is_body = src.HasAPI(UsdPhysics.RigidBodyAPI) # YCB-style packages: the collider mesh is the rigid body itself + mass_attr = src.GetAttribute("physics:mass") + # a mass authored on a collider that is not the body moves to the pieces (split by volume); + # a mass authored on the body prim stays where it is + mass = float(mass_attr.Get()) if (not is_body and mass_attr and mass_attr.HasAuthoredValue()) else None + binding = UsdShade.MaterialBindingAPI(src).GetDirectBinding("physics").GetMaterialPath() + holder = stage.DefinePrim(body.GetPath().AppendChild(("newton_collision" if is_body else src.GetName() + "_newton_collision")), "Xform") + UsdGeom.Imageable(holder).GetPurposeAttr().Set(UsdGeom.Tokens.guide) + for i, (v, f, pvol, thick) in enumerate(pieces): + piece = UsdGeom.Mesh.Define(stage, holder.GetPath().AppendChild(f"piece_{i:03d}")) + piece.GetPointsAttr().Set(Vt.Vec3fArray([Gf.Vec3f(*map(float, q)) for q in v])) + piece.GetFaceVertexCountsAttr().Set(Vt.IntArray([3] * len(f))) + piece.GetFaceVertexIndicesAttr().Set(Vt.IntArray([int(x) for x in f.reshape(-1)])) + piece.GetSubdivisionSchemeAttr().Set(UsdGeom.Tokens.none) + lo, hi = v.min(axis=0), v.max(axis=0) + piece.GetExtentAttr().Set(Vt.Vec3fArray([Gf.Vec3f(*map(float, lo)), Gf.Vec3f(*map(float, hi))])) + piece.GetPurposeAttr().Set(UsdGeom.Tokens.guide) + pp = piece.GetPrim() + UsdPhysics.CollisionAPI.Apply(pp) + UsdPhysics.MeshCollisionAPI.Apply(pp).GetApproximationAttr().Set(UsdPhysics.Tokens.convexHull) + if mass is not None: + UsdPhysics.MassAPI.Apply(pp).GetMassAttr().Set(mass * pvol / vol) + if binding and not binding.isEmpty: + mat = UsdShade.Material(stage.GetPrimAtPath(binding)) + if mat: + UsdShade.MaterialBindingAPI.Apply(pp).Bind(mat, materialPurpose="physics") + record["thickened"] += int(thick) + # the original keeps rendering; its collision schemas (and its mass, now on the pieces) are + # deleted with a list op in this layer, which also covers PhysX schemas usd-core does not know + drop = [api for api in src.GetAppliedSchemas() + if api in ("PhysicsCollisionAPI", "PhysicsMeshCollisionAPI") or (api.startswith("Physx") and "Collision" in api) + or (mass is not None and api == "PhysicsMassAPI")] + spec = Sdf.CreatePrimInLayer(layer, src.GetPath()); spec.specifier = Sdf.SpecifierOver + lo = Sdf.TokenListOp(); lo.deletedItems = drop; spec.SetInfo("apiSchemas", lo) + record["colliders"].append({"prim": str(prim.GetPath()), "body": str(body.GetPath()), "approximation": approx, "vertices": int(len(pts)), "pieces": len(pieces), + "piece_vertices_max": max(len(p[0]) for p in pieces), "mass_kg_split": mass, "seconds": round(time.time() - t0, 1)}) + record["pieces"] += len(pieces) + log(f" {prim.GetPath()} {approx} {len(pts)}v -> {len(pieces)} pieces in {time.time() - t0:.1f}s") + # flatten the two layers into the variant file; asset paths of the package USD are re-anchored + # from its folder to variants/ (textures, MDL modules, any sub-USD it references) + import os + from pxr import UsdUtils + variant_dir = variant_usd.parent.resolve() + + def rewrite(src_layer, path): + if not path or path.startswith("/") or "://" in path or src_layer.anonymous: + return path + return os.path.relpath((Path(src_layer.realPath).parent / path).resolve(), variant_dir) + + if not record["colliders"]: + return {} + flat = UsdUtils.FlattenLayerStack(stage, rewrite) + data = dict(Sdf.Layer.FindOrOpen(str(base_usd)).customLayerData or {}) + data.update({"newton_variant_of": base_usd.name, "tool": TOOL}) + flat.customLayerData = data + flat.defaultPrim = default.GetName() + flat.Export(str(variant_usd)) + record["flattened"] = True + return record + + +def verify_with_newton(variant_usd: Path) -> dict: + """Parse the variant with Newton's importer in a fresh interpreter (CoACD and warp do not + share a process well): every collider must be convex or a primitive, nothing thickened.""" + import os + import subprocess + # usd-core's UsdPhysics parser (25.11 and 26.3) races on a body with many collision prims and + # corrupts the heap about one run in four with its default thread pool; single-threaded it never does + env = dict(os.environ, PXR_WORK_THREAD_LIMIT="1") + last = "" + for attempt in range(3): + r = subprocess.run([sys.executable, __file__, "--verify-only", str(variant_usd)], capture_output=True, text=True, timeout=600, env=env) + for line in r.stdout.splitlines(): + if line.startswith("{"): + out = json.loads(line); out["attempts"] = attempt + 1 + return out + last = f"verify subprocess rc={r.returncode}: {r.stderr.strip().splitlines()[-1][-300:] if r.stderr.strip() else ''}" + return {"ok": False, "colliders": {}, "bodies": 0, "warnings": [last + " (3 attempts)"]} + + +def _verify_only(variant_usd: Path) -> None: + import warnings + import newton + from newton import GeoType, ShapeFlags + from pxr import Usd + b = newton.ModelBuilder() + with warnings.catch_warnings(record=True) as ws: + warnings.simplefilter("always") + b.add_usd(Usd.Stage.Open(str(variant_usd)), verbose=False) + kinds = {}; thin = 0; unattached = 0 + for i in range(b.shape_count): + if not (int(b.shape_flags[i]) & int(ShapeFlags.COLLIDE_SHAPES)): + continue + t = GeoType(int(b.shape_type[i])).name; kinds[t] = kinds.get(t, 0) + 1 + if int(b.shape_body[i]) < 0: + unattached += 1 # a piece that did not land under its rigid body would be a static collider + if t in ("MESH", "CONVEX_MESH"): + v = np.asarray(b.shape_source[i].vertices, dtype=np.float64) * np.asarray(b.shape_scale[i], dtype=np.float64) + if thin_axis(v)[1] < MIN_THICKNESS_M * 0.99: + thin += 1 + masses = [round(float(m), 5) for m in b.body_mass] + ok = kinds.get("MESH", 0) == 0 and thin == 0 and unattached == 0 and b.body_count > 0 and all(m > 0 for m in masses) + print(json.dumps({"ok": ok, "colliders": kinds, "thin_pieces": thin, "unattached": unattached, "bodies": b.body_count, "mass_kg": masses, + "warnings": sorted({str(w.message)[:160] for w in ws})[:5]})) + + +def main() -> int: + if len(sys.argv) == 3 and sys.argv[1] == "--verify-only": + _verify_only(Path(sys.argv[2])); return 0 + ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument("--assets-root", type=Path, required=True) + ap.add_argument("--candidates", type=Path, default=None, help="json {name: {usd: rel path}} (outputs/newton_variant_candidates.json)") + ap.add_argument("--only", default=None, help="comma-separated package names") + ap.add_argument("--threshold", type=float, default=0.05, help="CoACD concavity threshold") + ap.add_argument("--max-hulls", type=int, default=32, help="CoACD max convex hulls per collider") + ap.add_argument("--no-verify", action="store_true") + g = ap.add_mutually_exclusive_group(required=True) + g.add_argument("--check", action="store_true", help="author to a scratch file, verify, change nothing in the package") + g.add_argument("--write", action="store_true") + args = ap.parse_args() + root = args.assets_root.resolve(); today = dt.date.today().isoformat() + cands = json.loads(args.candidates.read_text()) if args.candidates else {} + names = [n for n in args.only.split(",")] if args.only else sorted(cands) + done = 0; skipped = [] + for name in names: + pkg = root / "object_library" / name + rel = (cands.get(name) or {}).get("usd") + base = root / "object_library" / rel if rel else next((p for p in (pkg / "usd").glob(f"{name}.usd*")), None) + if base is None or not base.is_file(): + print(f"[newton-variant] {name}: package USD not found"); continue + variant = base.parent / "variants" / f"{base.stem}_newton.usd" + target = variant if args.write else Path("/tmp/claude-1000") / "newton_variant_check" / name / "variants" / variant.name + print(f"[newton-variant] {name}: {base.relative_to(root)}") + if not args.write: # a check authors next to a copy of the base folder so the ../ reference resolves + import shutil + shutil.rmtree(target.parent.parent, ignore_errors=True) + shutil.copytree(base.parent, target.parent.parent, ignore=shutil.ignore_patterns("variants")) + rec = author_variant(base, target, args.threshold, args.max_hulls, print) + if not rec: + skipped.append(name); print(" no mesh collider to replace"); continue + if not args.no_verify: + v = verify_with_newton(target); rec["newton_parse"] = v + print(f" newton parse: {'OK' if v['ok'] else 'REJECTED'} {v['colliders']} thin={v.get('thin_pieces')} unattached={v.get('unattached')} bodies={v['bodies']} mass={v.get('mass_kg')}" + (f" warnings={v['warnings']}" if v["warnings"] else "")) + if not v["ok"]: + print(f" !! variant rejected") + if args.write: # leave no orphan layer behind: the package records nothing for it + target.unlink(missing_ok=True); (target.parent / f"{target.stem}.meta.json").unlink(missing_ok=True) + continue + done += 1 + # sidecars: the variant gets a copy of the base sidecar marked as a variant; the base points at it + sc = sidecar_for(base); meta = json.loads(sc.read_text()) + vmeta = json.loads(json.dumps(meta)); vb = vmeta.setdefault("asset", {}) + vb["variant"] = "newton"; vb["variant_of"] = str(base.relative_to(pkg)) + vb["newton_collision"] = {"date": today, "tool": TOOL, "engine_checked": "Newton 1.5.2 importer (MuJoCo-Warp semantics)", **rec} + for k in ("variants",): + vb.pop(k, None) + (target.parent / f"{target.stem}.meta.json").write_text(json.dumps(vmeta, indent=2, ensure_ascii=False) + "\n") + if not args.write: + continue + meta.setdefault("asset", {}).setdefault("variants", {})["newton"] = str(variant.relative_to(pkg)) + sc.write_text(json.dumps(meta, indent=2, ensure_ascii=False) + "\n") + cols = ", ".join(f"`{c['prim'].rsplit('/', 1)[-1]}` ({c['approximation']}, {c['pieces']} piece{'s' if c['pieces'] != 1 else ', thickened to 1 mm'})" for c in rec["colliders"]) + note = (f"- Added the Newton collision variant `{variant.relative_to(pkg)}`: a flattened copy of this package in which {cols} " + f"{'is' if len(rec['colliders']) == 1 else 'are'} replaced by pre-decomposed convex pieces (CoACD threshold {args.threshold}, " + f"<= {MAX_HULL_VERTS} vertices each" + (f", {rec['thickened']} thickened to 1 mm" if rec["thickened"] else "") + + "), because Newton's solvers collide a mesh as one 64-vertex convex hull. Same render mesh, same physics material, " + "same mass. Parsed back through Newton's importer: every collider convex, nothing replaced. The package USD is unchanged.") + v = bump(pkg, base, note, today, minor=True) + print(f" recorded -> {v}") + print(f"[newton-variant] {'authored' if args.write else 'checked'} {done}; no mesh collider to replace: {skipped}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/nv_core/testing_tools/rlwrld-newton-conformance/newton15_cert.py b/nv_core/testing_tools/rlwrld-newton-conformance/newton15_cert.py new file mode 100644 index 0000000..b557912 --- /dev/null +++ b/nv_core/testing_tools/rlwrld-newton-conformance/newton15_cert.py @@ -0,0 +1,1455 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: Apache-2.0 +"""Newton 1.5.2 standalone (MuJoCo-Warp) conformance pass over DexBench rigid props. + +Mirrors the three NVIDIA simready-benchmark runtime tests that every handled prop +passed (or failed) on Isaac Sim 6.0.1 / PhysX, re-run on the Newton 1.5.2 wheel with +the MuJoCo-Warp rigid solver -- NOT the Isaac Lab-Arena image: + + parse load the package USD with Newton's own USD importer (rigid body, + MassAPI, mesh colliders with the authored ``physics:approximation``) + ground_drop drop from 1 cm above a flat floor, settle, no tunnelling / explosion / + jitter + slope_drop same on a 15 deg slope (with a stop wall at the bottom, like NVIDIA's + walled room) + grasp_and_lift settle the asset on the floor, rebuild a gantry two-pad gripper at the + authored ``grasp_identifier_01`` line (pads = cubes, edge 10 % of the + bbox geometric mean / 30 % of the min dim when aspect > 5, capped at + 30 % of the gap; grip force 5x weight; friction 5 on the pads), close, + lift by max(0.3 m, 2 x longest edge), hold 1 s, shake 1 cm @ 2 Hz for + 1.5 s, hold 1 s, open. Verdict messages follow NVIDIA's wording + ("pads touched (no object)", "did not rise", "dropped", ...). + +Driver mode loops over a manifest and runs every asset in its own subprocess (a +crash or a hang is a finding, not the end of the run); worker mode (``--single``) +does one asset and writes ``results/.json`` after every stage. + + python newton15_cert.py --assets-root object_library --manifest pkg_paths.txt --out out/ + python newton15_cert.py --assets-root object_library --single 005_tomato_soup_can/usd/005_tomato_soup_can.usd --out out/ + +Engine label used everywhere: "Newton 1.5.2 standalone (MuJoCo-Warp), not the Arena image". +""" + +from __future__ import annotations + +import argparse +import json +import math +import os +import subprocess +import sys +import time +import traceback +import warnings + +ENGINE_LABEL = "Newton 1.5.2 standalone (MuJoCo-Warp), not the Arena image" + +# -------------------------------------------------------------------------------------- +# benchmark parameters (NVIDIA kit-suite semantics; see the module docstring) +# -------------------------------------------------------------------------------------- +SIM_DT = 1.0 / 1000.0 # MuJoCo-Warp step +FRAME_HZ = 100 # control / observation rate +SUBSTEPS = int(round(1.0 / (SIM_DT * FRAME_HZ))) +VIDEO_FPS = 5 +VIDEO_PX = 512 + +DROP_HEIGHT = 0.01 # m above the surface +DROP_MAX_T = 6.0 # s +SETTLE_LIN = 2e-2 # m/s (MuJoCo soft contacts creep a few mm/s on a slope) +SETTLE_ANG = 5e-1 # rad/s +SETTLE_HOLD = 0.5 # s of quiet before "settled" +TUNNEL_DEPTH = 0.01 # m below the support surface counts as tunnelling +EXPLODE_SPEED = 10.0 # m/s + +SLOPE_DEG = 15.0 + +PAD_MU = 5.0 # NVIDIA GripperMaterial static/dynamic friction +PAD_MU_TORSIONAL = 0.05 # NVIDIA pads carry physxCollision:torsionalPatchRadius=1 (rigid pivot); MuJoCo condim 4 + this coefficient [m] +PAD_MASS = 0.02 # kg (NVIDIA used 0.003; heavier pads keep the stiff drive stable at 1 kHz) +GRIP_FORCE_FACTOR = 5.0 # x asset weight +FINGER_KP, FINGER_KD = 2943.0, 100.0 +GANTRY_KP, GANTRY_KD, GANTRY_FMAX = 50000.0, 447.2136, 10000.0 +LIFT_MIN = 0.3 +LIFT_T = 1.0 +HOLD_T = 1.0 +SHAKE_T, SHAKE_AMP, SHAKE_HZ = 1.5, 0.01, 2.0 +RELEASE_T = 1.0 +CLOSE_MAX_T = 1.5 +RISE_MIN = 0.02 # NVIDIA: "did not rise 0.020m" +DEFAULT_MU = 0.5 # PhysX default material when no PhysicsMaterialAPI is bound + +# MuJoCo contact softness. Newton exports a shape's (ke, kd) as MuJoCo solref +# (timeconst = 2/kd, dampratio = kd/2*sqrt(1/ke)); the builder default (2.5e3, 100) is +# MuJoCo's default solref (0.02 s, 1), whose stiffness scales with the effective mass of +# the contact pair -- a 20 g pad pushed with 5x an asset's weight then sinks through the +# asset. The cert uses a 4 ms time constant everywhere (asset, floor, pads) and 1 kg of +# armature on the finger DOFs; both are recorded in results and can be changed on the CLI. +CONTACT_TIMECONST = 0.004 +CONTACT_DAMPRATIO = 1.0 +FINGER_ARMATURE = 1.0 + + +def contact_gains(timeconst=None, dampratio=None): + """(ke, kd) that Newton's MuJoCo export turns into solref (timeconst, dampratio).""" + tc = CONTACT_TIMECONST if timeconst is None else timeconst + dr = CONTACT_DAMPRATIO if dampratio is None else dampratio + kd = 2.0 / tc + ke = (kd / (2.0 * dr)) ** 2 + return ke, kd + + +def apply_contact_defaults(builder, args=None): + tc = getattr(args, "contact_timeconst", None) if args is not None else None + ke, kd = contact_gains(tc) + builder.default_shape_cfg.mu = DEFAULT_MU + builder.default_shape_cfg.ke = ke + builder.default_shape_cfg.kd = kd + return ke, kd + + +def log(*a): + print(*a, flush=True) + + +# -------------------------------------------------------------------------------------- +# small numpy transform helpers (x, y, z, w quaternions like warp) +# -------------------------------------------------------------------------------------- +def q_mul(a, b): + import numpy as np + + ax, ay, az, aw = a + bx, by, bz, bw = b + return np.array( + [ + aw * bx + ax * bw + ay * bz - az * by, + aw * by - ax * bz + ay * bw + az * bx, + aw * bz + ax * by - ay * bx + az * bw, + aw * bw - ax * bx - ay * by - az * bz, + ], + dtype=np.float64, + ) + + +def q_rot(q, v): + import numpy as np + + q = np.asarray(q, dtype=np.float64) + v = np.asarray(v, dtype=np.float64) + u = q[:3] + s = q[3] + if v.ndim == 1: + return 2.0 * np.dot(u, v) * u + (s * s - np.dot(u, u)) * v + 2.0 * s * np.cross(u, v) + return 2.0 * (v @ u)[:, None] * u[None, :] + (s * s - np.dot(u, u)) * v + 2.0 * s * np.cross(u[None, :], v) + + +def q_inv(q): + import numpy as np + + return np.array([-q[0], -q[1], -q[2], q[3]], dtype=np.float64) + + +def tf_apply(tf, v): + import numpy as np + + tf = np.asarray(tf, dtype=np.float64) + return q_rot(tf[3:7], v) + tf[:3] + + +def tf_mul(a, b): + import numpy as np + + a = np.asarray(a, dtype=np.float64) + b = np.asarray(b, dtype=np.float64) + return np.concatenate([q_rot(a[3:7], b[:3]) + a[:3], q_mul(a[3:7], b[3:7])]) + + +def tf_inv(a): + import numpy as np + + a = np.asarray(a, dtype=np.float64) + qi = q_inv(a[3:7]) + return np.concatenate([-q_rot(qi, a[:3]), qi]) + + +def q_axis_angle(axis, ang): + import numpy as np + + axis = np.asarray(axis, dtype=np.float64) + axis = axis / np.linalg.norm(axis) + return np.array([*(axis * math.sin(ang / 2)), math.cos(ang / 2)]) + + +def q_from_to(a, b): + """Quaternion rotating unit vector a onto unit vector b.""" + import numpy as np + + a = np.asarray(a, dtype=np.float64) + b = np.asarray(b, dtype=np.float64) + c = np.cross(a, b) + d = float(np.dot(a, b)) + if d < -1.0 + 1e-9: + # 180 deg: pick any perpendicular axis + perp = np.cross(a, [1.0, 0.0, 0.0]) + if np.linalg.norm(perp) < 1e-6: + perp = np.cross(a, [0.0, 1.0, 0.0]) + return q_axis_angle(perp, math.pi) + q = np.array([c[0], c[1], c[2], 1.0 + d]) + return q / np.linalg.norm(q) + + +def q_angle_between(qa, qb): + import numpy as np + + d = abs(float(np.dot(np.asarray(qa), np.asarray(qb)))) + return 2.0 * math.degrees(math.acos(min(1.0, d))) + + +# -------------------------------------------------------------------------------------- +# USD side information (sidecar, grasp line, render bbox) +# -------------------------------------------------------------------------------------- +_STAGE_CACHE = {} + + +def open_stage(usd_path): + """Open the package stage once per process and reuse it: opening it a second time for + add_usd made UsdPhysics.LoadUsdPhysicsFromRange corrupt the heap on polybag_3.usda.""" + from pxr import Usd + + st = _STAGE_CACHE.get(usd_path) + if st is None: + st = Usd.Stage.Open(usd_path) + _STAGE_CACHE[usd_path] = st + return st + + +def read_stage_info(usd_path): + from pxr import Usd, UsdGeom + + st = open_stage(usd_path) + dp = st.GetDefaultPrim() + info = {"default_prim": str(dp.GetPath()), "mpu": UsdGeom.GetStageMetersPerUnit(st), "up": UsdGeom.GetStageUpAxis(st)} + bb = UsdGeom.Imageable(dp).ComputeWorldBound(0, "default").ComputeAlignedRange() + info["render_bbox"] = [list(map(float, bb.GetMin())), list(map(float, bb.GetMax()))] + grasp = None + for pr in Usd.PrimRange(dp): + if pr.IsA(UsdGeom.BasisCurves) and pr.GetName().startswith("grasp_identifier"): + pts = UsdGeom.BasisCurves(pr).GetPointsAttr().Get() + xf = UsdGeom.Xformable(pr).ComputeLocalToWorldTransform(0) + parent = pr.GetParent() + body_anc = None + anc = parent + while anc and anc.GetPath() != "/": + if "PhysicsRigidBodyAPI" in anc.GetAppliedSchemas(): + body_anc = str(anc.GetPath()) + break + anc = anc.GetParent() + grasp = { + "path": str(pr.GetPath()), + "points_world": [list(map(float, xf.Transform(p))) for p in pts], + "parent": str(parent.GetPath()), + "rigid_body_ancestor": body_anc, + } + break + info["grasp"] = grasp + approx = {} + for pr in Usd.PrimRange(dp): + if "PhysicsCollisionAPI" in pr.GetAppliedSchemas(): + a = pr.GetAttribute("physics:approximation") + d = {"approx": a.Get() if a and a.HasAuthoredValue() else None} + for k in ("physxCollision:contactOffset", "physxCollision:restOffset"): + at = pr.GetAttribute(k) + if at and at.HasAuthoredValue(): + d[k.split(":")[1]] = float(at.Get()) + if pr.IsA(UsdGeom.Mesh): + m = UsdGeom.Mesh(pr) + d["nverts"] = len(m.GetPointsAttr().Get() or []) + d["nfaces"] = len(m.GetFaceVertexCountsAttr().Get() or []) + approx[str(pr.GetPath())] = d + info["colliders_authored"] = approx + return info + + +def read_sidecar(usd_path): + base = os.path.splitext(usd_path)[0] + for cand in (base + ".meta.json",): + if os.path.exists(cand): + try: + with open(cand) as f: + d = json.load(f) + a = d.get("asset", {}) + gl = (a.get("grasp_lines") or [{}])[0] + return { + "mass_kg": a.get("mass_kg"), + "version": a.get("version"), + "grasp_points": gl.get("points"), + "grasp_rule": gl.get("rule"), + "physx_benchmark": gl.get("benchmark"), + } + except Exception as e: # noqa: BLE001 + return {"error": repr(e)} + return None + + +# -------------------------------------------------------------------------------------- +# Newton side +# -------------------------------------------------------------------------------------- +def newton_setup(): + os.environ.setdefault("PYGLET_HEADLESS", "1") + try: + import pyglet + + pyglet.options["headless"] = True + except Exception: # noqa: BLE001 + pass + import warp as wp + + wp.config.quiet = True + wp.init() + import newton + + newton.use_coord_layout_targets = True + try: + import coacd + + coacd.set_log_level("off") + except Exception: # noqa: BLE001 + pass + + +def versions(): + out = {} + from importlib import metadata + + for m, dist in (("newton", "newton"), ("warp", "warp-lang"), ("mujoco", "mujoco"), ("mujoco_warp", "mujoco-warp"), ("coacd", "coacd"), ("scipy", "scipy"), ("pyglet", "pyglet"), ("usd-core", "usd-core")): + try: + out[m] = metadata.version(dist) + except Exception: # noqa: BLE001 + try: + out[m] = getattr(__import__(m), "__version__", "?") + except Exception as e: # noqa: BLE001 + out[m] = f"missing ({e.__class__.__name__})" + return out + + +def geo_name(t): + from newton import GeoType + + for n in ("PLANE", "BOX", "SPHERE", "CAPSULE", "CYLINDER", "CONE", "MESH", "CONVEX_MESH", "SDF", "HEIGHTFIELD", "ELLIPSOID"): + if hasattr(GeoType, n) and int(getattr(GeoType, n)) == int(t): + return n + return str(int(t)) + + +def shape_vertices_local(b, i): + """Vertices of collision shape i in its body frame (None for planes / unknown).""" + import numpy as np + from newton import GeoType + + t = int(b.shape_type[i]) + xf = np.array(b.shape_transform[i], dtype=np.float64) + sc = np.array(b.shape_scale[i], dtype=np.float64) + if t in (int(GeoType.MESH), int(GeoType.CONVEX_MESH)): + src = b.shape_source[i] + v = np.asarray(src.vertices, dtype=np.float64) * sc + elif t == int(GeoType.BOX): + hx, hy, hz = sc + v = np.array([[sx * hx, sy * hy, sz * hz] for sx in (-1, 1) for sy in (-1, 1) for sz in (-1, 1)]) + elif t == int(GeoType.SPHERE): + r = sc[0] + v = np.array([[0, 0, -r], [0, 0, r], [r, 0, 0], [-r, 0, 0], [0, r, 0], [0, -r, 0]]) + else: + return None + return tf_apply(xf, v) + + +def _is_planar(vertices): + """Same test MuJoCo-Warp's exporter applies (it rejects planar mesh colliders).""" + import numpy as np + + try: + from newton._src.solvers.mujoco.solver_mujoco import _mujoco_mesh_vertices_are_planar + + return bool(_mujoco_mesh_vertices_are_planar(np.asarray(vertices, dtype=np.float64))) + except Exception: # noqa: BLE001 + v = np.asarray(vertices, dtype=np.float64) + if len(v) < 3: + return False + c = v - v.mean(axis=0) + sv = np.linalg.svd(c, compute_uv=False) + return bool(sv[-1] <= 1e-6 * max(sv[0], 1e-6)) + + +def thicken_planar_colliders(b, min_thickness=0.001): + """Give zero-thickness collision meshes a 1 mm slab so MuJoCo-Warp accepts them. + + Returns a list of (shape index, label, thickness) that were modified.""" + import numpy as np + import newton + from newton import GeoType, ShapeFlags + + fixed = [] + for i in range(b.shape_count): + if not (int(b.shape_flags[i]) & int(ShapeFlags.COLLIDE_SHAPES)): + continue + t = int(b.shape_type[i]) + if t not in (int(GeoType.MESH), int(GeoType.CONVEX_MESH)): + continue + src = b.shape_source[i] + sc = np.array(b.shape_scale[i], dtype=np.float64) + v = np.asarray(src.vertices, dtype=np.float64) * sc + c = v - v.mean(axis=0) + _, _, vt = np.linalg.svd(c, full_matrices=False) + n = vt[-1] + thin_extent = float((c @ n).max() - (c @ n).min()) + ext = float(np.linalg.norm(v.max(axis=0) - v.min(axis=0))) + # planar (what MuJoCo-Warp rejects outright) or a sliver thinner than 0.5 mm / + # 1e-3 of its size (what MuJoCo's compiler rejects as "mesh volume is too small") + if not (_is_planar(v) or thin_extent < max(5e-4, 1e-3 * ext)): + continue + th = max(min_thickness, 1e-3 * ext) + idx = np.asarray(src.indices, dtype=np.int32).reshape(-1, 3) + nv = len(v) + v2 = np.concatenate([v + 0.5 * th * n, v - 0.5 * th * n]) + idx2 = np.concatenate([idx, idx[:, ::-1] + nv]) + mesh = newton.Mesh(v2.astype(np.float32), idx2.flatten(), compute_inertia=False, maxhullvert=getattr(src, "maxhullvert", None)) + b.shape_source[i] = mesh + b.shape_scale[i] = type(b.shape_scale[i])(1.0, 1.0, 1.0) if not isinstance(b.shape_scale[i], (list, tuple)) else (1.0, 1.0, 1.0) + fixed.append((i, b.shape_label[i] if i < len(b.shape_label) else str(i), th)) + return fixed + + +def parse_asset(usd_path, stage_info, sidecar, args=None): + """Load the package into its own ModelBuilder. Returns (builder|None, parse_info, geom).""" + import numpy as np + import newton + from newton import ShapeFlags + + info = {"ok": False, "engine": ENGINE_LABEL} + t0 = time.time() + b = newton.ModelBuilder() + apply_contact_defaults(b, args) + caught = [] + try: + with warnings.catch_warnings(record=True) as ws: + warnings.simplefilter("always") + r = b.add_usd(open_stage(usd_path), verbose=False) + caught = [str(w.message)[:300] for w in ws] + except Exception as e: # noqa: BLE001 + info["error"] = f"{e.__class__.__name__}: {e}" + info["traceback"] = traceback.format_exc()[-3000:] + info["time_s"] = round(time.time() - t0, 2) + info["warnings"] = caught + return None, info, None + info["time_s"] = round(time.time() - t0, 2) + info["warnings"] = sorted(set(caught)) + try: + fixed = thicken_planar_colliders(b) + except Exception as e: # noqa: BLE001 + fixed = [] + info["warnings"].append(f"planar-collider check failed: {e.__class__.__name__}: {e}") + info["planar_colliders_thickened"] = [{"shape": i, "label": lbl, "thickness": round(th, 4)} for i, lbl, th in fixed] + info["body_count"] = b.body_count + info["shape_count"] = b.shape_count + info["joint_count"] = b.joint_count + jt = [] + for j in range(b.joint_count): + jt.append(str(newton.JointType(int(b.joint_type[j])).name)) + info["joint_types"] = jt + path_body = r.get("path_body_map", {}) + path_shape = r.get("path_shape_map", {}) + body_paths = {v: k for k, v in path_body.items()} + bodies = [] + for bi in range(b.body_count): + bodies.append( + { + "path": body_paths.get(bi, f"body_{bi}"), + "mass": float(b.body_mass[bi]), + "com": [float(x) for x in b.body_com[bi]], + "inertia_diag": [float(np.array(b.body_inertia[bi], dtype=np.float64).reshape(3, 3)[k, k]) for k in range(3)], + "xform": [float(x) for x in b.body_q[bi]], + } + ) + info["bodies"] = bodies + info["mass_total"] = round(float(sum(b.body_mass)), 6) + info["sidecar_mass_kg"] = sidecar.get("mass_kg") if sidecar else None + + # shapes: what Newton actually built + per_body = {} + collide_shapes = [] + for i in range(b.shape_count): + fl = int(b.shape_flags[i]) + collide = bool(fl & int(ShapeFlags.COLLIDE_SHAPES)) + body = int(b.shape_body[i]) + d = per_body.setdefault(body, {"CONVEX_MESH": 0, "MESH": 0, "BOX": 0, "OTHER": 0, "visual_only": 0}) + if not collide: + d["visual_only"] += 1 + continue + collide_shapes.append(i) + n = geo_name(b.shape_type[i]) + d[n if n in d else "OTHER"] += 1 + info["collide_shape_count"] = len(collide_shapes) + info["shapes_per_body"] = {bodies[k]["path"] if k >= 0 else "static": v for k, v in per_body.items()} + mus = [float(b.shape_material_mu[i]) for i in collide_shapes] + info["collider_mu"] = {"min": min(mus) if mus else None, "max": max(mus) if mus else None} + + # per authored collider: what happened to its approximation + colliders = [] + replaced = [] + authored = stage_info.get("colliders_authored", {}) + for path, ad in authored.items(): + sid = path_shape.get(path) + c = {"path": path, "approx_authored": ad.get("approx"), "nverts": ad.get("nverts")} + for k in ("contactOffset", "restOffset"): + if k in ad: + c[k] = ad[k] + if sid is None: + c["newton"] = "not imported" + c["honoured"] = False + c["note"] = "no shape created for this prim" + colliders.append(c) + replaced.append(f"{path}: {ad.get('approx')} -> not imported") + continue + n = geo_name(b.shape_type[sid]) + fl = int(b.shape_flags[sid]) + collide = bool(fl & int(ShapeFlags.COLLIDE_SHAPES)) + body = int(b.shape_body[sid]) + pieces = per_body.get(body, {}).get("CONVEX_MESH", 0) + a = (ad.get("approx") or "none").lower() + c["newton"] = n + ("" if collide else " (visual only)") + if a == "convexhull": + c["honoured"] = n == "CONVEX_MESH" + c["note"] = "convex hull (MuJoCo re-hulls it, max 64 vertices)" + elif a == "convexdecomposition": + c["honoured"] = n == "CONVEX_MESH" and pieces > 1 + c["note"] = f"CoACD decomposition, {pieces} convex pieces on the body" if c["honoured"] else "decomposition fell back to a single convex hull" + elif a == "sdf": + c["honoured"] = False + c["note"] = "sdf is not a Newton approximation: kept as a triangle mesh, MuJoCo-Warp collides its convex hull (max 64 vertices)" + elif a == "boundingcube": + c["honoured"] = n == "BOX" + c["note"] = "bounding box" + elif a == "none": + c["honoured"] = False + c["note"] = "no approximation authored: triangle mesh, MuJoCo-Warp collides its convex hull" + else: + c["honoured"] = False + c["note"] = f"unknown approximation {a}" + if not c["honoured"]: + replaced.append(f"{path}: {ad.get('approx')} -> {c['newton']}") + colliders.append(c) + info["colliders"] = colliders + for i, lbl, th in fixed: + replaced.append(f"{lbl}: planar / sliver collision mesh thickened to {th * 1000:.1f} mm for MuJoCo-Warp") + info["approximations_replaced"] = replaced + + # collision extents in the stage frame (authored pose) + pts = [] + for i in collide_shapes: + v = shape_vertices_local(b, i) + if v is None: + continue + body = int(b.shape_body[i]) + bq = np.array(b.body_q[body], dtype=np.float64) if body >= 0 else np.array([0, 0, 0, 0, 0, 0, 1.0]) + pts.append(tf_apply(bq, v)) + geom = None + if pts: + allp = np.concatenate(pts) + geom = {"coll_min": allp.min(axis=0), "coll_max": allp.max(axis=0), "coll_pts": allp} + info["collision_bbox"] = [list(map(float, allp.min(axis=0))), list(map(float, allp.max(axis=0)))] + info["collision_vertex_count"] = int(len(allp)) + info["render_bbox"] = stage_info.get("render_bbox") + info["ok"] = True + if b.body_count == 0: + info["ok"] = False + info["error"] = "no rigid body imported" + elif geom is None: + info["ok"] = False + info["error"] = "no collision shape imported" + return b, info, geom + + +class Sim: + """One finalized scene + MuJoCo-Warp solver + captured substep graph.""" + + def __init__(self, builder, args, label=""): + import warp as wp + import newton + + self.model = builder.finalize() + kw = {"nconmax": args.nconmax, "njmax": args.njmax, "enable_multiccd": not args.no_multiccd} + self.solver_settings = dict(kw) + if args.cone: + kw["cone"] = args.cone + if args.impratio is not None: + kw["impratio"] = args.impratio + self.solver_settings = dict(kw) + self.solver = newton.solvers.SolverMuJoCo(self.model, **kw) + self.s0 = self.model.state() + self.s1 = self.model.state() + self.control = self.model.control() + self.t = 0.0 + self.graph = None + self.use_graph = wp.get_device().is_cuda and not args.no_graph + if self.use_graph: + try: + with wp.ScopedCapture() as cap: + self._substeps() + self.graph = cap.graph + except Exception as e: # noqa: BLE001 + log(f"[warn] graph capture failed ({e}); stepping eagerly") + self.graph = None + + def _substeps(self): + for _ in range(SUBSTEPS): + self.s0.clear_forces() + self.solver.step(self.s0, self.s1, self.control, None, SIM_DT) + self.s0, self.s1 = self.s1, self.s0 + + def frame(self): + import warp as wp + + if self.graph is not None: + wp.capture_launch(self.graph) + else: + self._substeps() + self.t += 1.0 / FRAME_HZ + + def body_q(self): + return self.s0.body_q.numpy().astype("float64") + + def body_qd(self): + return self.s0.body_qd.numpy().astype("float64") + + def joint_q(self): + return self.s0.joint_q.numpy().astype("float64") + + def joint_qd(self): + return self.s0.joint_qd.numpy().astype("float64") + + +def add_support(scene, slope_deg=None, args=None, half=1.2): + """Flat floor, or a 15 deg slope box (top face through the origin) walled on four sides. + + ``half`` is the half-size of the walled slope; the drop test sizes it to the asset + (1.5 x its longest edge, at least 0.25 m) so rolling props stop at a wall instead of + bouncing between distant walls, like NVIDIA's small walled drop room.""" + import numpy as np + import warp as wp + import newton + + ke, kd = contact_gains(getattr(args, "contact_timeconst", None) if args is not None else None) + cfg = newton.ModelBuilder.ShapeConfig(mu=DEFAULT_MU, ke=ke, kd=kd) + if not slope_deg: + scene.add_ground_plane(cfg=cfg) + return np.array([0.0, 0.0, 1.0]), np.zeros(3) + th = math.radians(slope_deg) + q = q_axis_angle([0, 1, 0], th) # +x goes downhill + hz = 0.05 + centre = q_rot(q, [0.0, 0.0, -hz]) + scene.add_shape_box(-1, xform=wp.transform(wp.vec3(*centre), wp.quat(*q)), hx=half, hy=half, hz=hz, cfg=cfg, label="slope") + # walls on all four sides (NVIDIA's drop room is walled); 0.3 m tall + for k, (cx, cy, hx, hy) in enumerate(((half, 0.0, 0.02, half), (-half, 0.0, 0.02, half), (0.0, half, half, 0.02), (0.0, -half, half, 0.02))): + wc = q_rot(q, [cx, cy, 0.15]) + scene.add_shape_box(-1, xform=wp.transform(wp.vec3(*wc), wp.quat(*q)), hx=hx, hy=hy, hz=0.15, cfg=cfg, label=f"wall_{k}") + n = q_rot(q, [0.0, 0.0, 1.0]) + return n, np.zeros(3) + + +def place_offset(geom, normal, surface_pt, height): + """Translation so the lowest collision vertex sits `height` above the support plane.""" + import numpy as np + + pts = geom["coll_pts"] + d = (pts - surface_pt) @ normal + need = height - d.min() + centre_xy = 0.5 * (geom["coll_min"] + geom["coll_max"]) + off = np.array([-centre_xy[0], -centre_xy[1], 0.0]) + normal * need + # keep the xy centring exact on the surface plane: correct the normal shift's xy drift + return off + + +def asset_body_ids(scene_map, n_asset_bodies): + return list(range(n_asset_bodies)) + + +def transformed_points(pts_local_by_body, body_q): + """World-frame collision points given per-body local points.""" + import numpy as np + + out = [] + for bi, pl in pts_local_by_body.items(): + out.append(tf_apply(body_q[bi], pl)) + return np.concatenate(out) if out else np.zeros((0, 3)) + + +def local_points_by_body(b, collide_only=True): + """Per-body collision vertices in body frame (for penetration / extents at runtime).""" + import numpy as np + from newton import ShapeFlags + + out = {} + for i in range(b.shape_count): + if collide_only and not (int(b.shape_flags[i]) & int(ShapeFlags.COLLIDE_SHAPES)): + continue + body = int(b.shape_body[i]) + if body < 0: + continue + v = shape_vertices_local(b, i) + if v is None: + continue + out.setdefault(body, []).append(v) + return {k: np.concatenate(v) for k, v in out.items()} + + +# -------------------------------------------------------------------------------------- +# drop tests +# -------------------------------------------------------------------------------------- +def run_drop(asset_b, geom, args, slope_deg=None): + import numpy as np + import warp as wp + import newton + + t0 = time.time() + res = {"engine": ENGINE_LABEL, "slope_deg": slope_deg or 0.0} + scene = newton.ModelBuilder() + apply_contact_defaults(scene, args) + longest = float(np.max(geom["coll_max"] - geom["coll_min"])) + half = min(1.2, max(0.25, 1.5 * longest)) + normal, spt = add_support(scene, slope_deg, args, half=half) + res["slope_half_size"] = round(half, 3) if slope_deg else None + off = place_offset(geom, normal, spt, DROP_HEIGHT) + scene.add_builder(asset_b, xform=wp.transform(wp.vec3(*off), wp.quat_identity())) + nb = asset_b.body_count + pts_local = local_points_by_body(asset_b) + try: + sim = Sim(scene, args) + except Exception as e: # noqa: BLE001 + res["verdict"] = "fail" + res["message"] = f"solver setup failed: {e.__class__.__name__}: {str(e)[:400]}" + res["time_s"] = round(time.time() - t0, 1) + return res + + q0 = sim.body_q()[:nb] + quiet_since = None + settled_t = None + max_speed = 0.0 + min_height = 1e9 + below_frames = 0 + nan = False + fly = False + hist_speed = [] + hist_ang = [] + nframes = int(DROP_MAX_T * FRAME_HZ) + for f in range(nframes): + sim.frame() + bq = sim.body_q()[:nb] + bqd = sim.body_qd()[:nb] + if not (np.all(np.isfinite(bq)) and np.all(np.isfinite(bqd))): + nan = True + break + lin = np.linalg.norm(bqd[:, :3], axis=1).max() + ang = np.linalg.norm(bqd[:, 3:], axis=1).max() + max_speed = max(max_speed, float(lin)) + hist_speed.append(float(lin)) + hist_ang.append(float(ang)) + pw = transformed_points(pts_local, bq) + h = float(((pw - spt) @ normal).min()) + min_height = min(min_height, h) + if h < -TUNNEL_DEPTH: + below_frames += 1 + if np.linalg.norm(bq[:, :3], axis=1).max() > 3.0 or lin > EXPLODE_SPEED: + fly = True + break + if lin < SETTLE_LIN and ang < SETTLE_ANG: + if quiet_since is None: + quiet_since = sim.t + elif sim.t - quiet_since >= SETTLE_HOLD and settled_t is None: + settled_t = quiet_since + break + else: + quiet_since = None + bq = sim.body_q()[:nb] + pw = transformed_points(pts_local, bq) + rest_h = float(((pw - spt) @ normal).min()) if len(pw) else float("nan") + res.update( + { + "settle_time_s": None if settled_t is None else round(settled_t, 2), + "sim_time_s": round(sim.t, 2), + "rest_body_z": [round(float(z), 4) for z in bq[:, 2]], + "rest_min_height": round(rest_h, 4), + "max_penetration": round(max(0.0, -min_height), 4), + "max_speed": round(max_speed, 3), + "residual_speed": round(float(np.max(hist_speed[-int(0.5 * FRAME_HZ) :])) if hist_speed else 0.0, 4), + "residual_ang_speed": round(float(np.max(hist_ang[-int(0.5 * FRAME_HZ) :])) if hist_ang else 0.0, 4), + "tilt_deg": round(max(q_angle_between(q0[i, 3:7], bq[i, 3:7]) for i in range(nb)), 1), + "displacement_xy": round(float(np.linalg.norm((bq[0, :2] - q0[0, :2]))), 3), + "nan": nan, + "fly_away": fly, + } + ) + left_slope = bool(slope_deg) and (abs(bq[0, 0]) > half + 0.05 or abs(bq[0, 1]) > half + 0.05 or bq[0, 2] < -(half + 0.3)) + res["left_slope"] = left_slope + res["reached_wall"] = bool(slope_deg) and float(np.linalg.norm(bq[0, :2] - q0[0, :2])) > 0.6 * half + msg = [] + verdict = "pass" + if nan: + verdict, msg = "fail", ["NaN in body state"] + elif fly: + verdict, msg = "fail", [f"flew away / exploded (max speed {max_speed:.1f} m/s)"] + elif below_frames > 5 or rest_h < -TUNNEL_DEPTH: + verdict, msg = "fail", [f"tunnelled into the support ({-min_height * 1000:.1f} mm)"] + elif settled_t is None: + if left_slope: + verdict, msg = "fail", ["left the walled slope (went over / through a wall)"] + else: + verdict, msg = "fail", [f"did not come to rest within {DROP_MAX_T:.0f} s (residual {res['residual_speed']:.3f} m/s, {res['residual_ang_speed']:.2f} rad/s)"] + if verdict == "pass" and res.get("reached_wall"): + msg.append("rolled / slid to the wall") + if verdict == "pass" and slope_deg and res["residual_speed"] > 0.003: + msg.append(f"creep {res['residual_speed'] * 1000:.1f} mm/s while 'at rest' (MuJoCo soft contact)") + if verdict == "pass" and res["max_penetration"] > 0.003: + msg.append(f"soft contact: mesh vertices dip {res['max_penetration'] * 1000:.1f} mm below the surface (MuJoCo hull < mesh)") + if verdict == "pass" and res["tilt_deg"] > 20: + msg.append(f"tipped over ({res['tilt_deg']:.0f} deg from the authored pose)") + res["verdict"] = verdict + res["message"] = "; ".join(msg) + res["time_s"] = round(time.time() - t0, 1) + return res + + +# -------------------------------------------------------------------------------------- +# grasp_and_lift +# -------------------------------------------------------------------------------------- +def settle_on_floor(asset_b, geom, args): + """Scene 1: drop from 1 cm, settle, return the settled body poses (or None).""" + import numpy as np + import warp as wp + import newton + + scene = newton.ModelBuilder() + apply_contact_defaults(scene, args) + normal, spt = add_support(scene, None, args) + off = place_offset(geom, normal, spt, DROP_HEIGHT) + scene.add_builder(asset_b, xform=wp.transform(wp.vec3(*off), wp.quat_identity())) + nb = asset_b.body_count + sim = Sim(scene, args) + quiet_since = None + for _ in range(int(3.0 * FRAME_HZ)): + sim.frame() + bqd = sim.body_qd()[:nb] + lin = np.linalg.norm(bqd[:, :3], axis=1).max() + ang = np.linalg.norm(bqd[:, 3:], axis=1).max() + if lin < SETTLE_LIN and ang < SETTLE_ANG: + if quiet_since is None: + quiet_since = sim.t + elif sim.t - quiet_since >= SETTLE_HOLD: + break + else: + quiet_since = None + bq = sim.body_q()[:nb] + if not np.all(np.isfinite(bq)): + return None, off + return bq, off + + +def pad_edge_for(dims, gap): + dims = [max(float(d), 1e-4) for d in dims] + gm = (dims[0] * dims[1] * dims[2]) ** (1.0 / 3.0) + aspect = max(dims) / min(dims) + e = 0.30 * min(dims) if aspect > 5.0 else 0.10 * gm + e = min(e, 0.30 * gap) + return max(e, 0.004) + + +def run_grasp(asset_b, geom, parse_info, stage_info, args, video_path=None): + import numpy as np + import warp as wp + import newton + + t0 = time.time() + res = {"engine": ENGINE_LABEL, "phases": []} + g = stage_info.get("grasp") + if not g: + res.update({"verdict": "fail", "phase": "Setup", "message": "no grasp_identifier prims found"}) + return res + nb = asset_b.body_count + settled, off = settle_on_floor(asset_b, geom, args) + if settled is None: + res.update({"verdict": "fail", "phase": "Settle", "message": "NaN while settling on the floor"}) + return res + # grasp line placement. NVIDIA's kit benchmark rebuilds the gantry after the asset + # settles but at the *authored* line pose plus the AssetRoot offset (its log for + # styrofoam_box shows gp z = authored z + 0.01 while the body had dropped 8 mm), i.e. + # the line stays in the stage frame. That is the default here ("stage"); "body" + # attaches the line to the nearest rigid-body ancestor of the grasp Xform (or the + # root body) and follows its settled pose. + root_authored = np.array(asset_b.body_q[0], dtype=np.float64) + line_frame = args.line_frame + if line_frame == "body": + body_idx = 0 + anc = g.get("rigid_body_ancestor") + if anc: + for bi, bd in enumerate(parse_info.get("bodies", [])): + if bd.get("path") == anc: + body_idx = bi + ref_authored = np.array(asset_b.body_q[body_idx], dtype=np.float64) + p_local = [tf_apply(tf_inv(ref_authored), np.array(p)) for p in g["points_world"]] + gp = [tf_apply(settled[body_idx], p) for p in p_local] + line_frame_used = f"body:{parse_info['bodies'][body_idx]['path']}" + else: + gp = [np.array(p, dtype=np.float64) + off for p in g["points_world"]] + line_frame_used = "stage (authored pose + placement offset, NVIDIA semantics)" + gp1, gp2 = gp + settle_shift = float(np.linalg.norm(settled[0][:3] - (root_authored[:3] + off))) + axis = gp2 - gp1 + gap = float(np.linalg.norm(axis)) + if gap < 1e-4: + res.update({"verdict": "fail", "phase": "Setup", "message": "degenerate grasp line"}) + return res + u = axis / gap + centre = 0.5 * (gp1 + gp2) + rb = parse_info.get("render_bbox") or parse_info.get("collision_bbox") + dims = [rb[1][k] - rb[0][k] for k in range(3)] + e = pad_edge_for(dims, gap) + mass = float(parse_info["mass_total"]) + f_grip = GRIP_FORCE_FACTOR * mass * 9.81 + lift = max(LIFT_MIN, 2.0 * max(dims)) + obj_mu = parse_info.get("collider_mu", {}).get("max") or DEFAULT_MU + pad_mu = 0.5 * (PAD_MU + obj_mu) # PhysX averages the pair; MuJoCo takes the max + q_close_max = 0.5 * gap - 0.5 * e # pads meet + res["params"] = { + "gap": round(gap, 4), + "pad_edge": round(e, 4), + "grip_force_N": round(f_grip, 3), + "lift_height": round(lift, 3), + "pad_mu_effective": round(pad_mu, 2), + "pad_mass": PAD_MASS, + "pad_condim": int(args.pad_condim), + "pad_mu_torsional": PAD_MU_TORSIONAL, + "finger_armature": args.finger_armature, + "contact_timeconst": args.contact_timeconst, + "gp1": [round(float(x), 4) for x in gp1], + "gp2": [round(float(x), 4) for x in gp2], + "axis": [round(float(x), 3) for x in u], + "line_vertical_component": round(abs(float(u[2])), 3), + "settled_root_pose": [round(float(x), 4) for x in settled[0]], + "settle_tilt_deg": round(q_angle_between(root_authored[3:7], settled[0][3:7]), 1), + "settle_shift_m": round(settle_shift, 4), + "line_frame": line_frame_used, + "grasp_xform_parent": g.get("parent"), + "grasp_rigid_body_ancestor": g.get("rigid_body_ancestor"), + } + notes = [] + if min(gp1[2], gp2[2]) - 0.5 * e < 0.0: + notes.append(f"pad bottom starts {(0.5 * e - min(gp1[2], gp2[2])) * 1000:.1f} mm below the floor (line z={min(gp1[2], gp2[2]):.3f} m)") + + # scene 2: asset at the settled pose + gantry + scene = newton.ModelBuilder() + newton.solvers.SolverMuJoCo.register_custom_attributes(scene) + ke, kd = apply_contact_defaults(scene, args) + add_support(scene, None, args) + xf = tf_mul(settled[0], tf_inv(root_authored)) + scene.add_builder(asset_b, xform=wp.transform(wp.vec3(*xf[:3]), wp.quat(*xf[3:7]))) + pts_local = local_points_by_body(asset_b) + + I3 = np.eye(3) * 1e-2 + gz = scene.add_link(xform=wp.transform(wp.vec3(*centre), wp.quat_identity()), mass=1.0, inertia=wp.mat33(*I3.flatten()), label="gantry_z") + gx = scene.add_link(xform=wp.transform(wp.vec3(*centre), wp.quat_identity()), mass=1.0, inertia=wp.mat33(*I3.flatten()), label="gantry_x") + gy = scene.add_link(xform=wp.transform(wp.vec3(*centre), wp.quat_identity()), mass=1.0, inertia=wp.mat33(*I3.flatten()), label="gantry_y") + pad_cfg = newton.ModelBuilder.ShapeConfig(mu=pad_mu, mu_torsional=PAD_MU_TORSIONAL, density=PAD_MASS / (e**3), restitution=0.0, ke=ke, kd=kd) + pads = [] + for k, (p, sgn) in enumerate(((gp1, 1.0), (gp2, -1.0))): + qk = q_from_to([1.0, 0.0, 0.0], sgn * u) # local +X = closing direction + pb = scene.add_link(xform=wp.transform(wp.vec3(*p), wp.quat(*qk)), mass=0.0, label=f"pad_{k}") + scene.add_shape_box(pb, hx=e / 2, hy=e / 2, hz=e / 2, cfg=pad_cfg, label=f"pad_{k}_cube", custom_attributes={"mujoco:condim": int(args.pad_condim)}) + pads.append((pb, qk)) + gkw = dict(target_ke=GANTRY_KP, target_kd=GANTRY_KD, effort_limit=GANTRY_FMAX, limit_lower=-10.0, limit_upper=10.0, actuator_mode=newton.JointTargetMode.POSITION, target_pos=0.0) + jz = scene.add_joint_prismatic(-1, gz, parent_xform=wp.transform(wp.vec3(*centre), wp.quat_identity()), axis=newton.Axis.Z, label="joint_z", **gkw) + jx = scene.add_joint_prismatic(gz, gx, axis=newton.Axis.X, label="joint_x", **gkw) + jy = scene.add_joint_prismatic(gx, gy, axis=newton.Axis.Y, label="joint_y", **gkw) + fkw = dict(target_ke=FINGER_KP, target_kd=FINGER_KD, effort_limit=f_grip, limit_lower=0.0, limit_upper=q_close_max, actuator_mode=newton.JointTargetMode.POSITION, target_pos=0.0, armature=args.finger_armature) + jp = [] + for k, ((pb, qk), p) in enumerate(zip(pads, (gp1, gp2))): + rel = p - centre + j = scene.add_joint_prismatic(gy, pb, parent_xform=wp.transform(wp.vec3(*rel), wp.quat(*qk)), axis=newton.Axis.X, label=f"finger_{k}", **fkw) + jp.append(j) + scene.add_articulation([jz, jx, jy, *jp], label="gantry") + + try: + sim = Sim(scene, args) + except Exception as ex: # noqa: BLE001 + res.update({"verdict": "fail", "phase": "Setup", "message": f"solver setup failed: {ex.__class__.__name__}: {str(ex)[:400]}"}) + res["time_s"] = round(time.time() - t0, 1) + return res + model = sim.model + qd_start = model.joint_qd_start.numpy() + tq = model.joint_target_q_start.numpy() if model.joint_target_q_start is not None else qd_start + n_t = sim.control.joint_target_q.shape[0] + targets = np.zeros(n_t, dtype=np.float32) + idx = {"z": int(tq[jz]), "x": int(tq[jx]), "y": int(tq[jy]), "p0": int(tq[jp[0]]), "p1": int(tq[jp[1]])} + q_start = model.joint_q_start.numpy() + jq_idx = {"p0": int(q_start[jp[0]]), "p1": int(q_start[jp[1]])} + pad_bodies = [pads[0][0], pads[1][0]] + + def set_targets(z=0.0, x=0.0, y=0.0, p=0.0): + targets[idx["z"]] = z + targets[idx["x"]] = x + targets[idx["y"]] = y + targets[idx["p0"]] = p + targets[idx["p1"]] = p + sim.control.joint_target_q.assign(targets) + + # video + viewer = None + frames = [] + if video_path: + try: + import newton.viewer + + viewer = newton.viewer.ViewerGL(width=VIDEO_PX, height=VIDEO_PX, headless=True) + viewer.set_model(model) + look = centre + np.array([0.0, 0.0, 0.5 * lift]) + dist = 1.7 * max(lift, 2.0 * max(dims), 0.35) + az = math.atan2(u[1], u[0]) + math.pi / 2 + cam = look + dist * np.array([math.cos(az) * 0.85, math.sin(az) * 0.85, 0.45]) + d = look - cam + d /= np.linalg.norm(d) + viewer.set_camera(pos=wp.vec3(*cam), pitch=math.degrees(math.asin(d[2])), yaw=math.degrees(math.atan2(d[1], d[0]))) + except Exception as ex: # noqa: BLE001 + notes.append(f"video disabled: {ex.__class__.__name__}: {str(ex)[:120]}") + viewer = None + every = FRAME_HZ // VIDEO_FPS + + state = {"f": 0} + + def capture(): + if viewer is None: + return + if state["f"] % every == 0: + viewer.begin_frame(sim.t) + viewer.log_state(sim.s0) + viewer.end_frame() + frames.append(viewer.get_frame().numpy().copy()) + state["f"] += 1 + + def obs(): + bq = sim.body_q() + return bq, bq[:nb], bq[pad_bodies[0]], bq[pad_bodies[1]] + + def obj_min_z(bq): + pw = transformed_points(pts_local, bq) + return float(pw[:, 2].min()) + + def finite(bq): + return bool(np.all(np.isfinite(bq))) + + phase_log = [] + verdict = None + phase = None + message = "" + + def fail(ph, msg): + nonlocal verdict, phase, message + verdict, phase, message = "fail", ph, msg + + def run_for(seconds, ph, ctrl_fn=None, check_fn=None): + nonlocal verdict + n = int(round(seconds * FRAME_HZ)) + for i in range(n): + if ctrl_fn: + ctrl_fn(i / FRAME_HZ) + sim.frame() + capture() + bq, aq, p0, p1 = obs() + if not finite(bq): + fail(ph, "NaN in body state") + return False + if check_fn and not check_fn(bq, aq, p0, p1): + return False + return True + + # Phase: Positioning / settle 0.5 s with the pads open at the endpoints + set_targets() + bq, aq, p0, p1 = obs() + z_pad0 = 0.5 * (p0[2] + p1[2]) + if not run_for(0.5, "Positioning"): + pass + else: + bq, aq, p0, p1 = obs() + obj_z0 = float(aq[0, 2]) + obj_xy0 = aq[0, :2].copy() + z_pad0 = 0.5 * (p0[2] + p1[2]) + moved = float(np.linalg.norm(aq[0, :3] - settled[0][:3])) + if moved > 0.02: + notes.append(f"asset moved {moved * 1000:.0f} mm while the gripper was positioned") + phase_log.append(f"t={sim.t:.1f}s Positioning obj_z={obj_z0:.4f} pads_z={z_pad0:.4f} gap={gap:.3f} pad={e:.4f}") + + # Phase: Grasping (close until both pads stop or timeout) + set_targets(p=q_close_max) + quiet = 0 + closed_ok = False + n = int(CLOSE_MAX_T * FRAME_HZ) + for i in range(n): + sim.frame() + capture() + jqd = sim.joint_qd() + v0 = abs(jqd[int(qd_start[jp[0]])]) + v1 = abs(jqd[int(qd_start[jp[1]])]) + if v0 < 2e-3 and v1 < 2e-3 and i > 10: + quiet += 1 + if quiet >= int(0.2 * FRAME_HZ): + closed_ok = True + break + else: + quiet = 0 + bq, aq, p0, p1 = obs() + if not finite(bq): + fail("Grasping", "NaN in body state") + else: + sep = float(np.linalg.norm(p0[:3] - p1[:3])) - e + jq = sim.joint_q() + res["grasp_state"] = {"pad_separation": round(sep, 4), "finger_q": [round(float(jq[jq_idx["p0"]]), 4), round(float(jq[jq_idx["p1"]]), 4)], "closed_settled": closed_ok} + pushed = float(np.linalg.norm(aq[0, :2] - obj_xy0)) + phase_log.append(f"t={sim.t:.1f}s Grasping sep={sep:.4f} finger_q={jq[jq_idx['p0']]:.4f},{jq[jq_idx['p1']]:.4f} obj_moved_xy={pushed:.4f}") + if sep < 0.001: + fail("Grasping", "Grasping failed: pads touched (no object)") + else: + if pushed > 0.02: + notes.append(f"asset pushed {pushed * 1000:.0f} mm sideways while closing") + if not closed_ok: + notes.append("fingers were still moving when the closing timeout hit") + + if verdict is None: + # Phase: Lifting (ramp z over LIFT_T) + bq, aq, p0, p1 = obs() + obj_z_grasp = float(aq[0, 2]) + # the material point of the root body that sits between the pads at grasp time; + # slip = how far that point has moved away from the pad centre (rotation about + # the pinch axis is not slip) + pad_c0 = 0.5 * (p0[:3] + p1[:3]) + grasp_pt_local = tf_apply(tf_inv(aq[0]), pad_c0) + slip_thr = 0.03 + res["slip_threshold_m"] = slip_thr + + def slip_now(aq, p0, p1): + pc = 0.5 * (p0[:3] + p1[:3]) + gp_now = tf_apply(aq[0], grasp_pt_local) + return float(np.linalg.norm(gp_now - pc)), float(pc[2] - gp_now[2]) + + def held_check(bq, aq, p0, p1, ph): + slip, vslip = slip_now(aq, p0, p1) + res["max_slip_m"] = round(max(res.get("max_slip_m", 0.0), slip), 4) + if slip > slip_thr: + oz = float(aq[0, 2]) + fail(ph, f"{ph} failed: object dropped" if (oz < obj_z0 + RISE_MIN or vslip > 0.5 * lift) else f"{ph} failed: object slipped {slip:.3f} m in the jaws") + return False + return True + + def lift_ctrl(t): + set_targets(z=lift * min(1.0, t / LIFT_T), p=q_close_max) + + ok = run_for(LIFT_T + 0.2, "Lifting", lift_ctrl, lambda bq, aq, p0, p1: held_check(bq, aq, p0, p1, "Lifting")) + if ok: + bq, aq, p0, p1 = obs() + dz = float(aq[0, 2]) - obj_z0 + phase_log.append(f"t={sim.t:.1f}s Lifting obj_z={aq[0, 2]:.4f} dz={dz:.4f} pads_z={0.5 * (p0[2] + p1[2]):.4f} slip={slip_now(aq, p0, p1)[0]:.4f}") + res["obj_z_after_lift"] = round(float(aq[0, 2]), 4) + if dz < RISE_MIN: + fail("Lifting", f"Lifting failed: object did not rise {RISE_MIN:.3f}m (actual_dz={dz:.4f}, obj_z={obj_z0:.4f}->{aq[0, 2]:.4f})") + if verdict is None: + ok = run_for(HOLD_T, "HoldBeforeShake", lambda t: set_targets(z=lift, p=q_close_max), lambda bq, aq, p0, p1: held_check(bq, aq, p0, p1, "HoldBeforeShake")) + if ok: + bq, aq, p0, p1 = obs() + phase_log.append(f"t={sim.t:.1f}s HoldBeforeShake obj_z={aq[0, 2]:.4f}") + if verdict is None: + ok = run_for(SHAKE_T, "Shake", lambda t: set_targets(z=lift, x=SHAKE_AMP * math.sin(2 * math.pi * SHAKE_HZ * t), p=q_close_max), lambda bq, aq, p0, p1: held_check(bq, aq, p0, p1, "Shake")) + if ok: + bq, aq, p0, p1 = obs() + phase_log.append(f"t={sim.t:.1f}s Shake obj_z={aq[0, 2]:.4f}") + if verdict is None: + ok = run_for(HOLD_T, "HoldAfterShake", lambda t: set_targets(z=lift, p=q_close_max), lambda bq, aq, p0, p1: held_check(bq, aq, p0, p1, "HoldAfterShake")) + if ok: + bq, aq, p0, p1 = obs() + mz = obj_min_z(bq) + phase_log.append(f"t={sim.t:.1f}s HoldAfterShake obj_z={aq[0, 2]:.4f} obj_min_z={mz:.4f} slip={slip_now(aq, p0, p1)[0]:.4f}") + if "obj_z_after_lift" in res: + res["creep_in_jaws_m"] = round(res["obj_z_after_lift"] - float(aq[0, 2]), 4) + if mz < 0.005: + fail("HoldAfterShake", f"HoldAfterShake failed: object touched ground (z={mz:.4f})") + if verdict is None: + bq, aq, p0, p1 = obs() + z_before = float(aq[0, 2]) + ok = run_for(RELEASE_T, "Dropping", lambda t: set_targets(z=lift, p=0.0)) + if ok: + bq, aq, p0, p1 = obs() + fell = z_before - float(aq[0, 2]) + needed = 0.5 * (z_before - obj_z0) + phase_log.append(f"t={sim.t:.1f}s Dropping fell={fell:.4f} needed={needed:.4f}") + if fell < needed: + fail("Dropping", f"Dropping failed: fell {fell:.4f}m, needed {needed:.4f}m") + if verdict is None: + verdict, phase, message = "pass", "", "authored line held through lift, hold and shake" + + res["verdict"] = verdict + res["phase"] = phase + res["message"] = message + res["solver"] = sim.solver_settings + res["notes"] = notes + res["log"] = phase_log + res["sim_time_s"] = round(sim.t, 2) + if viewer is not None: + try: + # a few extra frames after release so the drop is visible + for _ in range(FRAME_HZ // 2): + sim.frame() + capture() + import imageio + + os.makedirs(os.path.dirname(video_path), exist_ok=True) + imageio.mimwrite(video_path, frames, fps=VIDEO_FPS, codec="libx264", quality=6, macro_block_size=None) + res["video"] = video_path + except Exception as ex: # noqa: BLE001 + res["video_error"] = f"{ex.__class__.__name__}: {str(ex)[:200]}" + try: + viewer.close() + except Exception: # noqa: BLE001 + pass + res["time_s"] = round(time.time() - t0, 1) + return res + + +# -------------------------------------------------------------------------------------- +# worker +# -------------------------------------------------------------------------------------- +def worker(args): + rel = args.single + name = rel.split("/")[0] + usd_path = os.path.join(args.assets_root, rel) + out_json = os.path.join(args.out, "results", f"{name}.json") + os.makedirs(os.path.dirname(out_json), exist_ok=True) + tests = [t.strip() for t in args.tests.split(",") if t.strip()] + result = {"name": name, "usd": rel, "engine": ENGINE_LABEL, "started": time.strftime("%Y-%m-%d %H:%M:%S"), "tests_requested": tests} + if args.skip_done and os.path.exists(out_json): + try: + with open(out_json) as f: + result = json.load(f) + result["tests_requested"] = tests + except Exception: # noqa: BLE001 + pass + + def flush(): + tmp = out_json + ".tmp" + with open(tmp, "w") as f: + json.dump(result, f, indent=1, default=float) + os.replace(tmp, out_json) + + t_all = time.time() + newton_setup() + result["versions"] = versions() + sidecar = read_sidecar(usd_path) or {} + result["sidecar"] = sidecar + try: + stage_info = read_stage_info(usd_path) + except Exception as e: # noqa: BLE001 + stage_info = {"error": f"{e.__class__.__name__}: {e}"} + result["stage"] = {k: v for k, v in stage_info.items() if k != "colliders_authored"} + flush() + + asset_b, pinfo, geom = parse_asset(usd_path, stage_info, sidecar, args) + pinfo["contact_timeconst"] = args.contact_timeconst + pinfo["contact_ke_kd"] = list(contact_gains(args.contact_timeconst)) + result["parse"] = pinfo + flush() + log(f"[{name}] parse ok={pinfo.get('ok')} {pinfo.get('time_s')}s bodies={pinfo.get('body_count')} shapes={pinfo.get('collide_shape_count')} mass={pinfo.get('mass_total')} replaced={len(pinfo.get('approximations_replaced', []))}") + if not pinfo.get("ok"): + for t in ("ground_drop", "slope_drop", "grasp_and_lift"): + if t in tests: + result[t] = {"verdict": "skip", "message": "parse failed", "engine": ENGINE_LABEL} + result["elapsed_s"] = round(time.time() - t_all, 1) + flush() + return + + def done(t): + return args.skip_done and t in result and result[t].get("verdict") not in (None, "skip", "crash") + + if "ground_drop" in tests and not done("ground_drop"): + try: + result["ground_drop"] = run_drop(asset_b, geom, args, None) + except Exception as e: # noqa: BLE001 + result["ground_drop"] = {"verdict": "fail", "message": f"exception: {e.__class__.__name__}: {str(e)[:300]}", "traceback": traceback.format_exc()[-2000:]} + log(f"[{name}] ground_drop {result['ground_drop'].get('verdict')} {result['ground_drop'].get('message')}") + flush() + if "slope_drop" in tests and not done("slope_drop"): + try: + result["slope_drop"] = run_drop(asset_b, geom, args, SLOPE_DEG) + except Exception as e: # noqa: BLE001 + result["slope_drop"] = {"verdict": "fail", "message": f"exception: {e.__class__.__name__}: {str(e)[:300]}", "traceback": traceback.format_exc()[-2000:]} + log(f"[{name}] slope_drop {result['slope_drop'].get('verdict')} {result['slope_drop'].get('message')}") + flush() + if "grasp_and_lift" in tests and not done("grasp_and_lift"): + video = None if args.no_video else os.path.join(args.out, "videos", f"{name}_grasp_and_lift.mp4") + try: + result["grasp_and_lift"] = run_grasp(asset_b, geom, pinfo, stage_info, args, video) + except Exception as e: # noqa: BLE001 + result["grasp_and_lift"] = {"verdict": "fail", "phase": "exception", "message": f"exception: {e.__class__.__name__}: {str(e)[:300]}", "traceback": traceback.format_exc()[-2000:]} + g = result["grasp_and_lift"] + log(f"[{name}] grasp_and_lift {g.get('verdict')} [{g.get('phase')}] {g.get('message')}") + flush() + result["elapsed_s"] = round(time.time() - t_all, 1) + flush() + + +# -------------------------------------------------------------------------------------- +# driver +# -------------------------------------------------------------------------------------- +def driver(args): + with open(args.manifest) as f: + rels = [l.strip() for l in f if l.strip() and not l.startswith("#")] + if args.only: + keep = {s.strip() for s in args.only.split(",") if s.strip()} + rels = [r for r in rels if r.split("/")[0] in keep] + if args.limit: + rels = rels[: args.limit] + os.makedirs(os.path.join(args.out, "results"), exist_ok=True) + os.makedirs(os.path.join(args.out, "logs"), exist_ok=True) + tests = [t.strip() for t in args.tests.split(",") if t.strip()] + summary_path = os.path.join(args.out, "results.json") + t_start = time.time() + for k, rel in enumerate(rels): + name = rel.split("/")[0] + out_json = os.path.join(args.out, "results", f"{name}.json") + if args.skip_done and os.path.exists(out_json): + try: + with open(out_json) as f: + prev = json.load(f) + if all(t in prev and prev[t].get("verdict") not in (None, "crash") for t in tests) and prev.get("parse", {}).get("ok") is not None: + log(f"[{k + 1}/{len(rels)}] {name}: done, skipping") + continue + except Exception: # noqa: BLE001 + pass + cmd = [sys.executable, os.path.abspath(__file__), "--single", rel] + passthrough(args) + logp = os.path.join(args.out, "logs", f"{name}.log") + t0 = time.time() + log(f"[{k + 1}/{len(rels)}] {name}: start ({time.strftime('%H:%M:%S')})") + rc = None + timed_out = False + with open(logp, "w") as lf: + try: + p = subprocess.run(cmd, stdout=lf, stderr=subprocess.STDOUT, timeout=args.timeout) + rc = p.returncode + except subprocess.TimeoutExpired: + timed_out = True + dt = time.time() - t0 + if rc != 0 or timed_out: + prev = {} + if os.path.exists(out_json): + try: + with open(out_json) as f: + prev = json.load(f) + except Exception: # noqa: BLE001 + prev = {} + tail = "" + try: + with open(logp, errors="replace") as f: + tail = "".join(f.readlines()[-25:])[-2500:] + except Exception: # noqa: BLE001 + pass + prev.setdefault("name", name) + prev.setdefault("usd", rel) + prev.setdefault("engine", ENGINE_LABEL) + prev["crash"] = {"returncode": rc, "timed_out": timed_out, "log_tail": tail, "elapsed_s": round(dt, 1)} + for t in tests: + if t not in prev or prev[t].get("verdict") is None: + prev[t] = {"verdict": "crash", "message": "worker timed out" if timed_out else f"worker exited {rc}", "engine": ENGINE_LABEL} + if "parse" not in prev: + prev["parse"] = {"ok": False, "error": "worker crashed before parse finished"} + with open(out_json, "w") as f: + json.dump(prev, f, indent=1) + log(f" CRASH rc={rc} timed_out={timed_out} ({dt:.0f}s)") + else: + try: + with open(out_json) as f: + r = json.load(f) + vs = " ".join(f"{t}={r.get(t, {}).get('verdict')}" for t in tests) + log(f" parse_ok={r.get('parse', {}).get('ok')} {vs} ({dt:.0f}s)") + except Exception as e: # noqa: BLE001 + log(f" done ({dt:.0f}s) but result unreadable: {e}") + if (k + 1) % 5 == 0 or k + 1 == len(rels): + aggregate(args.out, summary_path) + el = time.time() - t_start + log(f" progress {k + 1}/{len(rels)} elapsed {el / 60:.1f} min, eta {(el / (k + 1)) * (len(rels) - k - 1) / 60:.1f} min") + aggregate(args.out, summary_path) + log("DRIVER_DONE") + + +def passthrough(args): + out = ["--assets-root", args.assets_root, "--out", args.out, "--tests", args.tests, "--nconmax", str(args.nconmax), "--njmax", str(args.njmax)] + if args.no_video: + out.append("--no-video") + if args.no_graph: + out.append("--no-graph") + if args.no_multiccd: + out.append("--no-multiccd") + if args.cone: + out += ["--cone", args.cone] + if args.impratio is not None: + out += ["--impratio", str(args.impratio)] + if args.skip_done: + out.append("--skip-done") + out += ["--line-frame", args.line_frame, "--contact-timeconst", str(args.contact_timeconst), "--finger-armature", str(args.finger_armature), "--pad-condim", str(args.pad_condim)] + return out + + +def aggregate(out_dir, summary_path): + rdir = os.path.join(out_dir, "results") + allr = {} + for fn in sorted(os.listdir(rdir)): + if fn.endswith(".json"): + try: + with open(os.path.join(rdir, fn)) as f: + allr[fn[:-5]] = json.load(f) + except Exception: # noqa: BLE001 + pass + tmp = summary_path + ".tmp" + with open(tmp, "w") as f: + json.dump({"engine": ENGINE_LABEL, "generated": time.strftime("%Y-%m-%d %H:%M:%S"), "count": len(allr), "assets": allr}, f, indent=1) + os.replace(tmp, summary_path) + + +def main(): + # usd-core's UsdPhysics parser races on a body with many collision prims (a fifth to a + # quarter of the loads corrupt the heap with its default thread pool); single-threaded it + # never does. Set before any pxr import, and inherited by the worker subprocesses. + os.environ.setdefault("PXR_WORK_THREAD_LIMIT", "1") + ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument("--assets-root", required=True, help="directory holding the package folders") + ap.add_argument("--manifest", help="text file, one package USD path (relative to --assets-root) per line") + ap.add_argument("--single", help="worker mode: relative USD path of one package") + ap.add_argument("--out", required=True, help="output directory (results/, logs/, videos/, results.json)") + ap.add_argument("--tests", default="ground_drop,slope_drop,grasp_and_lift") + ap.add_argument("--only", help="comma-separated package names to run") + ap.add_argument("--limit", type=int, default=0) + ap.add_argument("--skip-done", action="store_true", help="skip assets whose result JSON already carries the requested tests") + ap.add_argument("--timeout", type=int, default=900, help="per-asset worker timeout [s]") + ap.add_argument("--no-video", action="store_true") + ap.add_argument("--no-graph", action="store_true", help="do not CUDA-graph the substeps") + ap.add_argument("--cone", default="elliptic", help="MuJoCo friction cone (pyramidal|elliptic). Newton's default is pyramidal; the cert uses elliptic with impratio 10 so held objects do not creep out of the jaws") + ap.add_argument("--impratio", type=float, default=10.0) + ap.add_argument("--line-frame", default="stage", choices=("stage", "body"), help="grasp line placement after settling: stage (NVIDIA semantics, default) or body") + ap.add_argument("--contact-timeconst", type=float, default=CONTACT_TIMECONST, help="MuJoCo solref time constant applied to every shape [s]") + ap.add_argument("--finger-armature", type=float, default=FINGER_ARMATURE, help="armature on the two finger DOFs [kg]") + ap.add_argument("--pad-condim", type=int, default=4, help="MuJoCo condim of the pad geoms (4 = torsional friction, like NVIDIA's torsional patch; 3 = stock)") + ap.add_argument("--no-multiccd", action="store_true", help="single contact point per geom pair (MuJoCo-Warp default); the cert enables multi-CCD (up to 4 points)") + ap.add_argument("--nconmax", type=int, default=4000) + ap.add_argument("--njmax", type=int, default=8000) + args = ap.parse_args() + if args.single: + worker(args) + else: + if not args.manifest: + ap.error("--manifest is required in driver mode") + driver(args) + + +if __name__ == "__main__": + main() diff --git a/nv_core/testing_tools/rlwrld-newton-conformance/newton15_cert_report.py b/nv_core/testing_tools/rlwrld-newton-conformance/newton15_cert_report.py new file mode 100644 index 0000000..4d715d4 --- /dev/null +++ b/nv_core/testing_tools/rlwrld-newton-conformance/newton15_cert_report.py @@ -0,0 +1,326 @@ +#!/usr/bin/env python3 +"""Merge the Newton 1.5.2 cert passes with the PhysX verdicts; write results.json + summary.md. + +Three remote passes feed in: + --main out/results.json parse + ground_drop + slope_drop + grasp_and_lift with the line in the stage frame + --bodyline out_bodyline/results.json grasp_and_lift with the line attached to the body (primary grasp verdict) + --defaults out_defaults/results.json grasp_and_lift with stock Newton/MuJoCo contact defaults (stage-frame line) +""" + +import argparse +import collections +import json +import os +import time + +ENGINE = "Newton 1.5.2 standalone (MuJoCo-Warp), not the Arena image" + + +def short(s, n=110): + s = (s or "").replace("\n", " ").replace("|", "/") + return s if len(s) <= n else s[: n - 1] + "…" + + +def load(path): + if not path or not os.path.exists(path): + return {} + return json.load(open(path)).get("assets", {}) + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("--main", required=True) + ap.add_argument("--bodyline", default=None) + ap.add_argument("--defaults", default=None) + ap.add_argument("--physx", required=True) + ap.add_argument("--fet003", required=True) + ap.add_argument("--assets-root", required=True, help="local object_library (read-only, for sidecar categories)") + ap.add_argument("--manifest", required=True) + ap.add_argument("--out-dir", required=True) + args = ap.parse_args() + + main_r = load(args.main) + body_r = load(args.bodyline) + def_r = load(args.defaults) + physx = json.load(open(args.physx)) + fet = json.load(open(args.fet003)) + rels = [l.strip() for l in open(args.manifest) if l.strip()] + names = [r.split("/")[0] for r in rels] + + cats = {} + for rel in rels: + n = rel.split("/")[0] + meta = os.path.join(args.assets_root, os.path.splitext(rel)[0] + ".meta.json") + try: + a = json.load(open(meta)).get("asset", {}) + cats[n] = ",".join(a.get("categories") or []) or "-" + except Exception: # noqa: BLE001 + cats[n] = "-" + + merged = { + "engine": ENGINE, + "generated": time.strftime("%Y-%m-%d %H:%M:%S"), + "count": 0, + "passes": { + "grasp_and_lift": "line attached to the rigid body (follows the settled pose); cert contact settings", + "grasp_and_lift_stage_frame": "line kept in the stage frame after settling (NVIDIA-literal); cert contact settings", + "grasp_and_lift_stock_defaults": "stage-frame line; stock Newton/MuJoCo defaults: solref 20 ms, pyramidal cone, impratio 1, no multi-CCD, pads condim 3, no finger armature", + }, + "assets": {}, + } + rows = [] + for n in names: + a = main_r.get(n) + if a is None: + a = {"name": n, "missing": True, "parse": {"ok": None}, "ground_drop": {"verdict": "missing"}, "slope_drop": {"verdict": "missing"}, "grasp_and_lift": {"verdict": "missing"}} + a = dict(a) + a["grasp_and_lift_stage_frame"] = a.pop("grasp_and_lift", {"verdict": "missing"}) + gb = (body_r.get(n) or {}).get("grasp_and_lift") + a["grasp_and_lift"] = gb if gb else {"verdict": "missing", "message": "body-frame pass missing"} + gd = (def_r.get(n) or {}).get("grasp_and_lift") + a["grasp_and_lift_stock_defaults"] = gd if gd else {"verdict": "missing"} + if (body_r.get(n) or {}).get("crash"): + a["crash_bodyline"] = body_r[n]["crash"] + if a.get("crash") and all((a.get(t) or {}).get("verdict") in ("pass", "fail") for t in ("ground_drop", "slope_drop", "grasp_and_lift_stage_frame")): + a["crash_recovered"] = a.pop("crash") + a["physx"] = {"grasp": physx.get(n), "drops": fet.get(n)} + a["category"] = cats.get(n, "-") + merged["assets"][n] = a + rows.append(a) + merged["count"] = len(rows) + os.makedirs(args.out_dir, exist_ok=True) + with open(os.path.join(args.out_dir, "results.json"), "w") as f: + json.dump(merged, f, indent=1) + + def v(a, t): + return (a.get(t) or {}).get("verdict") or "missing" + + def m(a, t): + return (a.get(t) or {}).get("message") or "" + + tests = ("ground_drop", "slope_drop", "grasp_and_lift", "grasp_and_lift_stage_frame", "grasp_and_lift_stock_defaults") + tot = {t: collections.Counter(v(a, t) for a in rows) for t in tests} + parse_ok = sum(1 for a in rows if (a.get("parse") or {}).get("ok")) + parse_fail = [a for a in rows if not (a.get("parse") or {}).get("ok")] + crashes = [a for a in rows if a.get("crash")] + recovered = [a for a in rows if a.get("crash_recovered")] + phases = collections.Counter((a.get("grasp_and_lift") or {}).get("phase") or "-" for a in rows if v(a, "grasp_and_lift") == "fail") + + dis = [] + for a in rows: + px = (a.get("physx") or {}).get("grasp") or {} + pv = px.get("status") + nv = v(a, "grasp_and_lift") + if pv and nv in ("pass", "fail") and pv != nv: + dis.append(a) + frame_diff = [a for a in rows if v(a, "grasp_and_lift") in ("pass", "fail") and v(a, "grasp_and_lift_stage_frame") in ("pass", "fail") and v(a, "grasp_and_lift") != v(a, "grasp_and_lift_stage_frame")] + drop_fail = [a for a in rows if v(a, "ground_drop") in ("fail", "crash") or v(a, "slope_drop") in ("fail", "crash")] + + honoured = collections.Counter() + not_hon = collections.Counter() + not_hon_assets = collections.defaultdict(set) + thick_assets = [] + thick_pieces = 0 + coacd_fallback = [] + for a in rows: + p = a.get("parse") or {} + for c in p.get("colliders", []): + key = str(c.get("approx_authored")) + if c.get("honoured"): + honoured[key] += 1 + else: + not_hon[key] += 1 + not_hon_assets[key].add(a["name"]) + if key.lower() == "convexdecomposition" and not c.get("honoured"): + coacd_fallback.append(a["name"]) + th = p.get("planar_colliders_thickened") or [] + if th: + thick_assets.append((a["name"], len(th))) + thick_pieces += len(th) + parse_warn = collections.Counter() + for a in rows: + for w in (a.get("parse") or {}).get("warnings", []): + parse_warn[short(w, 90)] += 1 + + el = [a.get("elapsed_s") for a in rows if a.get("elapsed_s")] + slope_creep = [(a.get("slope_drop") or {}).get("residual_speed") for a in rows if v(a, "slope_drop") == "pass" and (a.get("slope_drop") or {}).get("residual_speed") is not None] + settle_shift = [(a["name"], ((a.get("grasp_and_lift_stage_frame") or {}).get("params") or {}).get("settle_shift_m") or 0.0, ((a.get("grasp_and_lift_stage_frame") or {}).get("params") or {}).get("settle_tilt_deg") or 0.0) for a in rows] + big_shift = [s for s in settle_shift if s[1] > 0.03 or s[2] > 20] + tipped = [a["name"] for a in rows if "tipped over" in m(a, "ground_drop")] + vers = {} + for a in rows: + for k, vv in (a.get("versions") or {}).items(): + if k not in vers or vers[k] in ("?", None): + vers[k] = vv + any_g = next(((a.get("grasp_and_lift") or {}) for a in rows if (a.get("grasp_and_lift") or {}).get("solver")), {}) + solver = any_g.get("solver", {}) + any_p = next(((a.get("grasp_and_lift") or {}).get("params") for a in rows if ((a.get("grasp_and_lift") or {}).get("params") or {}).get("contact_timeconst")), {}) or {} + stock_pass = tot["grasp_and_lift_stock_defaults"]["pass"] + stock_fail = tot["grasp_and_lift_stock_defaults"]["fail"] + creeps = [abs((a.get("grasp_and_lift") or {}).get("creep_in_jaws_m") or 0.0) for a in rows if v(a, "grasp_and_lift") == "pass"] + slips = [(a.get("grasp_and_lift") or {}).get("max_slip_m") or 0.0 for a in rows if v(a, "grasp_and_lift") == "pass"] + + L = [] + L.append("# Newton 1.5 conformance pass over the DexBench rigid props\n") + L.append(f"**Engine: {ENGINE}.** Generated {merged['generated']} from `results.json` ({len(rows)} packages, manifest = `outputs/bench_handled_list.txt`). Runner: `newton15_cert.py` (this directory), remote recipe: `remote_setup.md`.\n") + L.append("Versions on the remote: " + ", ".join(f"{k} {vv}" for k, vv in vers.items()) + ".\n") + L.append("Files: `results.json` (merged, per package: parse info, sidecar, drop verdicts, the three grasp verdicts, PhysX verdicts), `results/.json` (raw main-pass worker output; its `grasp_and_lift` block is the stage-frame pass), `videos/`, `newton15_cert.py` (runner), `make_report.py` (this merge), `remote_setup.md`.\n") + L.append( + "Settings (cert passes): MuJoCo-Warp via `newton.solvers.SolverMuJoCo` on the GPU with MuJoCo contacts, dt = 1 ms, 100 Hz control, " + f"solver options {solver}, contact solref time constant {any_p.get('contact_timeconst')} s on every shape " + f"(Newton default 0.02 s), finger armature {any_p.get('finger_armature')} kg, pad mass {any_p.get('pad_mass')} kg / condim {any_p.get('pad_condim')} / " + f"torsional {any_p.get('pad_mu_torsional')}, friction 0.5 where no PhysicsMaterialAPI is bound (PhysX default), grip force 5x weight, " + "lift max(0.3 m, 2x longest edge), hold 1 s, shake 1 cm @ 2 Hz for 1.5 s, hold 1 s, open; slip threshold 3 cm on the grasped material point. " + "PhysX reference: Isaac Sim 6.0.1 / simready-benchmark (`outputs/grasp_verdicts.json`, `outputs/bench_fet003_results.json`).\n" + ) + L.append("Three grasp passes were run: **grasp_and_lift** (primary: the sidecar line follows the rigid body after the 1 cm settle drop), " + "**stage frame** (line stays where it was authored + the placement offset, which is what NVIDIA's kit log shows), and " + "**stock defaults** (stage-frame line, Newton/MuJoCo out-of-the-box contact settings: solref 20 ms, pyramidal cone, impratio 1, single contact point, pads condim 3, no armature).\n") + + L.append("## Totals per test\n") + L.append("| test | pass | fail | crash / skip / missing |\n|---|---|---|---|") + L.append(f"| parse (Newton USD importer) | {parse_ok} | {len(rows) - parse_ok} | {len(crashes)} worker crashes ({len(recovered)} recovered on rerun) |") + labels = {"ground_drop": "ground_drop", "slope_drop": "slope_drop (15 deg, walled)", "grasp_and_lift": "grasp_and_lift (line on body, cert settings)", "grasp_and_lift_stage_frame": "grasp_and_lift (line in stage frame, cert settings)", "grasp_and_lift_stock_defaults": "grasp_and_lift (stage frame, stock Newton defaults)"} + for t in tests: + c = tot[t] + other = sum(c[k] for k in c if k not in ("pass", "fail")) + L.append(f"| {labels[t]} | {c['pass']} | {c['fail']} | {other} |") + L.append("") + px_counts = collections.Counter((physx.get(n) or {}).get("status") for n in names) + L.append(f"PhysX grasp_and_lift on the same {len(rows)} (`grasp_verdicts.json` as read at generation time; power_drill and serving_bowl were re-verdicted to pass on 2026-09-16, so the brief's 27 failures are now 25): {px_counts['pass']} pass / {px_counts['fail']} fail. Newton (primary) failing phases: " + ", ".join(f"{k} {n}" for k, n in phases.most_common()) + ".") + if el: + L.append(f"\nRuntime of the main pass: {sum(el) / 60:.0f} min on the RTX 5090 (shared with another Isaac Sim job), median {sorted(el)[len(el) // 2]:.0f} s per asset for parse + 2 drops + grasp + video; the body-frame and stock passes took ~20 min each.") + if creeps: + cs = sorted(creeps) + L.append(f"\nHeld objects (primary pass, passing) creep in the jaws by median {cs[len(cs) // 2] * 1000:.1f} mm / max {cs[-1] * 1000:.0f} mm over the 3.5 s hold+shake; max grasp-point slip median {sorted(slips)[len(slips) // 2] * 1000:.1f} mm.") + L.append("") + + L.append("## Newton vs PhysX grasp_and_lift disagreements (primary pass)\n") + L.append(f"{len(dis)} of {len(rows)} packages differ. `PhysX class / reason` come from `outputs/grasp_verdicts.json`; the last two columns show the same asset under the stage-frame line and under stock Newton defaults.\n") + L.append("| package | category | PhysX | PhysX class / reason | Newton | Newton phase / message | stage-frame line | stock defaults | notes |\n|---|---|---|---|---|---|---|---|---|") + for a in sorted(dis, key=lambda a: (((a.get("physx") or {}).get("grasp") or {}).get("status", ""), a["name"]), reverse=True): + px = (a.get("physx") or {}).get("grasp") or {} + g = a.get("grasp_and_lift") or {} + p = g.get("params") or {} + notes = [] + if p.get("settle_shift_m", 0) > 0.03 or p.get("settle_tilt_deg", 0) > 20: + notes.append(f"asset moved {p.get('settle_shift_m', 0) * 100:.0f} cm / tilted {p.get('settle_tilt_deg', 0):.0f} deg while settling") + rep = (a.get("parse") or {}).get("approximations_replaced") or [] + if rep: + notes.append(short(rep[0].split(": ", 1)[-1], 60) + (f" (+{len(rep) - 1})" if len(rep) > 1 else "")) + if g.get("creep_in_jaws_m") is not None and abs(g["creep_in_jaws_m"]) > 0.01: + notes.append(f"creep in jaws {g['creep_in_jaws_m'] * 100:.1f} cm") + for nn in g.get("notes", []): + notes.append(short(nn, 70)) + if g.get("video") or (a.get("grasp_and_lift_stage_frame") or {}).get("video"): + notes.append("video") + gs = a.get("grasp_and_lift_stage_frame") or {} + gd = a.get("grasp_and_lift_stock_defaults") or {} + L.append(f"| {a['name']} | {a.get('category', '-')} | {px.get('status')} | {px.get('class')}: {short(px.get('why'), 80)} | **{g.get('verdict')}** | {g.get('phase') or '-'}: {short(g.get('message'), 90)} | {gs.get('verdict')} | {gd.get('verdict')} | {short('; '.join(notes), 150)} |") + L.append("") + agree_pass = sum(1 for a in rows if v(a, "grasp_and_lift") == "pass" and ((a.get("physx") or {}).get("grasp") or {}).get("status") == "pass") + agree_fail = sum(1 for a in rows if v(a, "grasp_and_lift") == "fail" and ((a.get("physx") or {}).get("grasp") or {}).get("status") == "fail") + px_fail_newton_pass = [a["name"] for a in dis if ((a.get("physx") or {}).get("grasp") or {}).get("status") == "fail"] + px_pass_newton_fail = [a for a in dis if ((a.get("physx") or {}).get("grasp") or {}).get("status") == "pass"] + still_fail_stage = [n for n in px_fail_newton_pass if v(merged["assets"][n], "grasp_and_lift_stage_frame") == "fail"] + sdf_fail = [a["name"] for a in px_pass_newton_fail if any("sdf" in r for r in ((a.get("parse") or {}).get("approximations_replaced") or []))] + tipped_fail = [a["name"] for a in px_pass_newton_fail if (((a.get("grasp_and_lift") or {}).get("params") or {}).get("settle_tilt_deg") or 0) > 20] + other_fail = [a["name"] for a in px_pass_newton_fail if a["name"] not in sdf_fail and a["name"] not in tipped_fail] + L.append(f"Agreement: {agree_pass} pass/pass, {agree_fail} fail/fail.\n") + L.append(f"**{len(px_fail_newton_pass)} PhysX failures hold in Newton** ({', '.join(px_fail_newton_pass)}). These are the thin nuts, bearings, forks, coupons, sheets and bags that NVIDIA classed 'benchmark limit'; with the line riding on the settled body and torsional pads they are pinched and lifted. {len(still_fail_stage)} of them still fail in Newton when the line is frozen in the stage frame (NVIDIA's rule keeps the line where the asset was placed, 1 cm above where it settles, so on a 1-2 cm part the pads close at or above its top edge) -- a good part of the PhysX 'benchmark limit' class is that offset, not the collider.\n") + L.append(f"**{len(px_pass_newton_fail)} PhysX passes fail in Newton** ({', '.join(a['name'] for a in px_pass_newton_fail)}): {len(sdf_fail)} are `sdf` colliders whose cavity or flare vanished in the 64-vertex convex hull ({', '.join(sdf_fail)}) -- a pad on the inside of a bowl rim lands on the hull 'lid' instead of the wall, a tapered drill body slides out of a faceted hull; {len(tipped_fail)} tip over during the 1 cm settle because the hull rounds off the flat they stand on ({', '.join(tipped_fail)}), which carries the body-attached line below the floor; {', '.join(other_fail) if other_fail else 'none'} are borderline slips of 3.1-3.4 cm against the 3 cm threshold (plate rim pinch).\n") + if frame_diff: + L.append("### Line frame sensitivity\n") + L.append(f"{len(frame_diff)} packages change verdict between the body-attached and the stage-frame line (assets that shift, tip or are thin relative to the 1 cm placement offset):\n") + L.append("| package | line on body | line in stage frame | settle shift / tilt | stage-frame message |\n|---|---|---|---|---|") + for a in frame_diff: + gs = a.get("grasp_and_lift_stage_frame") or {} + p = gs.get("params") or {} + L.append(f"| {a['name']} | {v(a, 'grasp_and_lift')} | {gs.get('verdict')} | {p.get('settle_shift_m', 0) * 100:.1f} cm / {p.get('settle_tilt_deg', 0):.0f} deg | {short(gs.get('message'), 90)} |") + L.append("") + + L.append("## Drop tests\n") + if drop_fail: + L.append("| package | ground_drop | slope_drop | PhysX drops (fet003) |\n|---|---|---|---|") + for a in drop_fail: + fd = (a.get("physx") or {}).get("drops") + fds = "-" if not fd else ", ".join(f"{k} {vv[0]}" for k, vv in fd.items()) + L.append(f"| {a['name']} | {v(a, 'ground_drop')}: {short(m(a, 'ground_drop'), 80)} | {v(a, 'slope_drop')}: {short(m(a, 'slope_drop'), 80)} | {fds} |") + else: + L.append("No drop-test failures.") + if any(a["name"] == "dragon_fork_19cm" for a in drop_fail): + L.append("\ndragon_fork_19cm is the only prop that never settles on the slope: its single 64-vertex hull (an `sdf` collider) is a curved rocker that keeps rocking and yawing while it creeps downhill; on the flat floor it settles in 0.1 s. PhysX (SDF collider) rests it on both tests.") + L.append(f"\nNo NaN, fly-away or tunnelling on any package. {len(tipped)} packages tip over (> 20 deg) when dropped 1 cm onto the flat floor from their authored pose: {', '.join(tipped)}.") + if slope_creep: + sc = sorted(slope_creep) + L.append(f"\nOn the 15 deg slope every prop that 'rests' still creeps downhill: median {sc[len(sc) // 2] * 1000:.1f} mm/s, max {sc[-1] * 1000:.1f} mm/s (MuJoCo soft contacts with the 4 ms solref used here; the 20 ms default is ~5x worse). The 'came to rest' criterion is therefore < 2 cm/s and < 0.5 rad/s for 0.5 s; PhysX/TGS sticks.") + L.append("") + + L.append("## Collider approximations Newton could not honour\n") + L.append("| authored physics:approximation | colliders honoured | colliders replaced | what Newton / MuJoCo-Warp does |\n|---|---|---|---|") + expl = { + "convexHull": "convex hull (MuJoCo re-hulls every mesh geom, max 64 vertices)", + "convexDecomposition": "CoACD decomposition into convex pieces at import (importer extra `coacd`)", + "sdf": "not a Newton approximation: triangle mesh kept, MuJoCo-Warp collides its 64-vertex convex hull (cavities and thin sections vanish)", + "boundingCube": "bounding box", + "None": "no approximation authored: triangle mesh -> convex hull", + } + for key in sorted(set(honoured) | set(not_hon), key=lambda k: -(honoured[k] + not_hon[k])): + L.append(f"| {key} | {honoured[key]} | {not_hon[key]} ({len(not_hon_assets[key])} packages) | {expl.get(key, '?')} |") + L.append("") + L.append(f"Planar / sliver convex pieces thickened to 1 mm by the runner so MuJoCo-Warp accepts them (it rejects planar mesh colliders outright and MuJoCo's compiler rejects near-zero-volume hulls): {thick_pieces} pieces in {len(thick_assets)} packages: " + ", ".join(f"{n} ({k})" for n, k in sorted(thick_assets, key=lambda x: -x[1])) + ".") + if coacd_fallback: + L.append(f"\nCoACD fell back to a single hull on: {', '.join(sorted(set(coacd_fallback)))}.") + if parse_warn: + L.append("\nImporter warnings (deduplicated):\n") + for w, c in parse_warn.most_common(15): + L.append(f"- {c}x `{w}`") + L.append("") + + L.append("## Parse failures and crashes\n") + if not parse_fail and not crashes: + L.append(f"None: all {len(rows)} packages parse with Newton's USD importer and build a MuJoCo-Warp model.") + for a in parse_fail: + L.append(f"- {a['name']}: {short((a.get('parse') or {}).get('error'), 200)}") + for a in crashes: + L.append(f"- {a['name']}: worker crash rc={a['crash'].get('returncode')} timed_out={a['crash'].get('timed_out')}: {short(a['crash'].get('log_tail', '')[-300:], 200)}") + for a in recovered: + L.append(f"- {a['name']}: worker aborted on the first attempts (rc={a['crash_recovered'].get('returncode')}), recovered on rerun -- see below.") + L.append("- polybag_3 (4.5 MB `.usda`, 23 convex pieces) aborted the worker on two of three attempts with heap corruption (`double free or corruption (fasttop)` / SIGSEGV inside `UsdPhysics.LoadUsdPhysicsFromRange`, usd-core 26.3) when the stage had already been opened once in the same process by the runner's pxr pre-scan; opening the stage once and handing the `Usd.Stage` object to `add_usd` avoided it and the package then passes every test.") + L.append("") + + L.append("## Findings: what Newton 1.5 needs from these assets that PhysX did not\n") + F = [] + F.append(f"1. **Every collider becomes convex in MuJoCo-Warp.** `sdf` ({not_hon['sdf']} colliders in {len(not_hon_assets['sdf'])} packages) and unauthored ({not_hon['None']}) approximations are silently reduced to a 64-vertex convex hull of the mesh; only `convexDecomposition` ({honoured['convexDecomposition']} colliders, via CoACD at parse time) and pre-decomposed `convexHull` pieces keep concavity. Bowls, deep plates, bends, brackets and bearings authored as `sdf` lose their cavities: a pad on the inside of a bowl rim rests on the hull 'lid' instead of the wall, which is the mechanism behind the tableware failures below. Author `convexDecomposition` (or ship pieces) for anything a jaw or finger must reach into.") + F.append(f"2. **No zero-thickness or sliver pieces.** MuJoCo-Warp refuses planar mesh colliders and MuJoCo rejects hull pieces of ~0 volume; {thick_pieces} pieces in {len(thick_assets)} packages had to be thickened to 1 mm by the runner. PhysX accepted them. Pre-decomposed packages (crates, boxes, CoACD output of thin scans) need a minimum-thickness check.") + F.append("3. **CoACD is a hard dependency** (`newton[importers]`); without it every `convexDecomposition` collapses to one hull. It runs on every load (~6 s for the 262k-vertex tomato can, blue_crate imports 1889 pieces): pre-decomposed, cached colliders are preferable for Arena.") + F.append(f"4. **Contact stiffness is mass-scaled in MuJoCo and the stock settings cannot hold a pinch grasp.** With Newton's defaults (solref 20 ms, pyramidal cone, impratio 1, one contact point per pair, no torsional friction) {stock_pass} of {stock_pass + stock_fail} grasps pass: a 20 g pad pushed with 5x an object's weight sinks through it, and objects creep out of the jaws. The cert settings (4 ms solref on every shape, 1 kg finger armature, elliptic cone + impratio 10, multi-CCD, pads condim 4 with torsional friction) bring it to {tot['grasp_and_lift']['pass']}. Assets carry no `mjc:solref` / `mjc:condim`; Arena's rigid-contact configuration, not the asset, decides graspability.") + F.append("5. **Torsional friction is off by default (condim 3).** NVIDIA's pads carry `physxCollision:torsionalPatchRadius=1`; without `condim 4` on the gripper geoms every off-centre pinch (drill, clamp, tote, pulley) pivots and slides out. Gripper-side, but assets that author `mjc:condim` would carry it into Arena.") + F.append("6. **Soft contacts creep.** Props resting on the 15 deg slope slide ~1 cm/s and held objects creep millimetres in the jaws; PhysX/TGS sticks. Any Newton rest/hold test needs a tolerance, and light props placed on inclined surfaces (trays, shelves) will drift.") + F.append(f"7. **Authored poses are not always rest poses, and the grasp line must ride with the body.** {len(tipped)} packages tip over when dropped 1 cm onto the floor (hulls round off the flats they stand on: spark plug, pneumatic cylinder, exhaust bend, chips bag, mango); {len(big_shift)} move > 3 cm / > 20 deg before the gantry is built. With the line frozen in the stage frame (NVIDIA-literal) {tot['grasp_and_lift_stage_frame']['fail']} grasps fail, with the line on the body {tot['grasp_and_lift']['fail']}; thin props (paper stacks, bearings, pulleys, plates) fail in the stage frame only because the 1 cm placement offset lifts the line above them. Author the body upright on z = 0 and keep the line on the rigid-body prim (YCB scans have the body as a rotated child prim, e.g. 005_tomato_soup_can carries an 11 deg tilt).") + F.append("8. **Mass and inertia are honoured** (`physics:mass` on the body prim; per-piece MassAPI on the vendor drill / screwdriver / spanner) and friction comes from `PhysicsMaterialAPI` where bound (else Newton's default, set to 0.5 here). `physxCollision:contactOffset/restOffset` (163 colliders) and every other `physx*` attribute are ignored -- assets relying on a rest offset to sit flush will sit differently.") + F.append("9. **Multi-body vendor packages import as articulations** (power_drill: 4 bodies with revolute + prismatic + fixed joints, screwdriver: fixed joint) and simulate; drive gains are not authored so the trigger and chuck are free in the drops.") + F.append("10. **MuJoCo hulls are capped at 64 vertices** (Newton `Mesh.maxhullvert`), coarser than PhysX's convex hulls of the same meshes: cylinders get faceted, rims flatten, the mesh's render vertices dip up to a few mm below the floor at rest ('max_penetration' in results.json) and round props roll differently on the slope.") + F.append("11. **Importer robustness**: `UsdPhysics.LoadUsdPhysicsFromRange` corrupted the heap on polybag_3.usda when the stage was opened twice in one process (usd-core 26.3); pass one `Usd.Stage` around. Dense scan meshes (262k-vertex can, 451k-vertex visual mesh in box.usda) load fine.") + F.append("12. **Geom count matters**: pre-decomposed crates import with > 1800 colliding pieces (blue_crate 1889); MuJoCo-Warp copes (~25 s per grasp test) but Arena scenes with several such props will pay for it -- a coarser decomposition is worth authoring.") + L.extend(F) + L.append("") + L.append("## Videos\n") + fails = [] + for a in rows: + if v(a, "grasp_and_lift") == "fail": + vid = (a.get("grasp_and_lift") or {}).get("video") or (a.get("grasp_and_lift_stage_frame") or {}).get("video") + if vid: + fails.append((a["name"], os.path.basename(vid), "body" if (a.get("grasp_and_lift") or {}).get("video") else "stage-frame")) + L.append(f"`videos/` holds the headless 5 fps / 512 px capture of the grasp phase for every package that fails the primary pass ({len(fails)}), named `_grasp_and_lift.mp4` (from the body-frame pass where it recorded one, otherwise the stage-frame pass). The remote keeps the captures of all {len(rows)} packages under `~/Projects/dexbench_newton15_cert/out/videos/` and `out_bodyline/videos/`.") + L.append("") + with open(os.path.join(args.out_dir, "summary.md"), "w") as f: + f.write("\n".join(L)) + with open(os.path.join(args.out_dir, "_failure_videos.txt"), "w") as f: + for n, fn, src in fails: + f.write(f"{src}\t{fn}\n") + print(f"wrote {args.out_dir}/summary.md and results.json; {len(dis)} grasp disagreements, {len(frame_diff)} frame-sensitive, {len(drop_fail)} drop failures, {len(fails)} failure videos") + + +if __name__ == "__main__": + main() diff --git a/nv_core/testing_tools/rlwrld-newton-conformance/rlwrld_sidecar.py b/nv_core/testing_tools/rlwrld-newton-conformance/rlwrld_sidecar.py new file mode 100644 index 0000000..166bf36 --- /dev/null +++ b/nv_core/testing_tools/rlwrld-newton-conformance/rlwrld_sidecar.py @@ -0,0 +1,38 @@ +"""The two package-sidecar helpers make_newton_variant.py needs, vendored from DexBench-Arena's conform_simready_basics.py. + +A DexBench package is ``/usd/.usd`` with a JSON sidecar ``.meta.json`` beside it +(``asset.version`` and provenance) and a ``CHANGELOG.md`` at the package root. ``bump`` raises the +version (PATCH, or MINOR when asked) and prepends the change to the changelog. +""" + +from __future__ import annotations + +import json +from pathlib import Path + + +def sidecar_for(usd: Path) -> Path | None: + for cand in (usd.with_suffix(".meta.json"), usd.parent / f"{usd.stem}.meta.json"): + if cand.is_file(): + return cand + return None + + +def bump(pkg: Path, usd: Path, note: str, today: str, minor: bool = False) -> str | None: + """Bump the sidecar version (PATCH, or MINOR when asked) and prepend a changelog entry.""" + sc = sidecar_for(usd) + if sc is None: + return None + meta = json.loads(sc.read_text()) + block = meta.setdefault("asset", {}) + major, mnr, patch = ((block.get("version") or "1.0.0").split(".") + ["0", "0"])[:3] + version = f"{major}.{int(mnr) + 1}.0" if minor else f"{major}.{mnr}.{int(patch) + 1}" + block["version"] = version + sc.write_text(json.dumps(meta, indent=2) + "\n") + log = pkg / "CHANGELOG.md" + head = log.read_text() if log.exists() else f"# {pkg.name}\n\nNewest first. A version with no entry here is not a version.\n\n" + marker = "Newest first. A version with no entry here is not a version.\n\n" + entry = f"## {today} — {version}\n\n{note}\n\n" + head = head.replace(marker, marker + entry, 1) if marker in head else head + "\n" + entry + log.write_text(head) + return version