Case study · Atomic Rig · SimReady integration

From robot CAD to avatar bits

How I built the pipeline that turns NVIDIA SimReady robot packages, simulation assets published by robot makers, into game-ready parts for Atomic Rig: discovered, cut into parts, decimated, deduplicated by shape, license-tracked and published from a single admin page.

Solo buildOct – Nov 2026OpenUSD · Blender · meshoptimizer · Three.js · Postgres

Independent work. Neither Atomic Rig nor I am affiliated with or endorsed by NVIDIA, Hugging Face or any robot maker named here.

~150
Hugging Face datasets scanned
186
packages screened from 18 robot repos
100+
robot parts baked into bits
3 tiers
400 · 1,000 · 2,500 tris per bit
10,742
triangle cap, Roblox export
1,200+
unit tests (private repo)

The problem

Why SimReady

Atomic Rig builds avatars from bits: rigid parts placed on a humanoid skeleton and exported as one character. A builder like that is only as good as its library, and I wanted a large, credible robot library quickly without modeling every part by hand.

SimReady is NVIDIA's OpenUSD packaging standard for simulation-ready assets. On October 21, 2026, NVIDIA announced SimReady Central, more than 2,000 of these assets on Hugging Face, each in its owner's own dataset under the owner's own license. Robot makers including FANUC (Apache 2.0), Franka (CC BY 4.0), Robotiq (CC BY 4.0), Comau (CC BY 4.0) and AgiBot (Apache 2.0) publish engineering-accurate models of their arms, grippers and humanoids there under licenses that allow commercial reuse. That is a great source, but simulation assets are not game assets:

  • A single link can be 155,000 triangles of CAD tessellation, with hidden interior faces and inverted patches.
  • One part is often a dozen flat CAD materials. A bit needs one material with real PBR maps.
  • Packages are organized for physics (links, joints, colliders), not for the slots an avatar needs.
  • They carry logos and brand colors, and each one has its own license and attribution terms.
  • Some packages are several gigabytes, and the same robot often appears in more than one repo.

So the job was a pipeline, not a script: one that a person could steer in minutes per package, and that would never publish a part it couldn't credit.

End to end

The pipeline

Machine steps run from one CLI, simready-ingest. Every human decision happens on one admin page. The database only ever changes through that page or through SQL a person runs, never as a side effect of a script.

AGENT+ CLICURATOR+ USERSPOSTGRES+ STORAGEONE COMMAND PER MACHINE STEP · ONE PAGE FOR EVERY HUMAN DECISIONCandidatesverdict + reasonsParts mapsstorageGLBsbake.json manifestBits+ provenanceCreditsfrozen per exportDiscoverHugging Facepackage scanListmetadata only,auto-rejectTriagekeep · skip ·confirm licensePreviewfetch · segmentparts mapPicktick the partsworth bakingBakeclean · decimatebake · checkPublishdedup, review,one transactionCollectgripper sets,free listingExportcomposed avatarwith credits
The nine stages across three lanes. Blue steps are automated, amber ones are decisions on the admin page, green is what each step writes.

The first version had six CLI commands, three SQL pastes, two upload approvals and two separate review pages for every package. Collapsing that to one command and one page with a Publish button was the single biggest improvement in the project.

Stage 1–3

Discovery and selection

Discovery searches the Hugging Face dataset API and keeps only repos whose file tree contains a SimReady packaging file, which separates real packages from the many repos that only use the word. Of the roughly 150 datasets the search returned, the robot repos in that format were listed, 18 so far, holding 186 packages between them. list then reads each package's small metadata files (packaging, validation, benchmark, license and NOTICE text) and downloads no asset bytes at all.

Auto-reject rules

  • License must be on the allow-list: CC0, CC BY 4.0, Apache 2.0 or BSD 3-Clause, read per package, not per repo. NC is out because we sell, ND because we decimate, SA because it would spread to every avatar a part is used in.
  • Validation and benchmark results must exist with no blocking failure. Physics benchmark failures are ignored: they say nothing about the mesh.
  • A size cap keeps one package from filling the disk.

Anything ambiguous is held, not rejected: a failed visual benchmark, or a second license file inside the package. NVIDIA's packages, for example, ship a BSD and Apache license for their shader library alongside the model's own license. Both are compatible, but a person confirms that once rather than code guessing.

Selection also means saying no. One repo turned out to be a rehost of other people's assets under the original publisher's package ids, which would have made the credit wrong. It was excluded on sight.

Stage 4

Segmentation

preview fetches a kept package and flattens it with OpenUSD into rigid bodies, joints and extents. Rules, not a model, turn those into proposed bits. That was a deliberate choice: the same package always segments the same way, every rule has a unit test, and a curator can see exactly why a part was proposed.

USD BODIES + JOINTS (facts.json)RULE → PROPOSED SLOT (A CURATOR CONFIRMS EVERY ONE)base_linkDiscardflat root with nothing jointed beneath itshoulder_linkshoulderlink named for a body partlink_2upper_armelongated (≥ 2.5×) between two moving jointselbow_linkforearmrobots name links after the joint that drives themgripper_basehandhand-sized with ≥ 2 moving children: collection rootfinger_leftfingerdescendant of a hand: collection memberfinger_rightmirrorsame mesh mirrored: reuse finger_left, rotatedAlso discarded: small parts repeated four or more times (fasteners). Rules only, no LLM: the same package always segments the same way.
How a robot arm's links become proposed slots. Bodies welded by fixed joints count as one part. Left/right copies within a millimeter are mirrors, so one bit serves both sides.

The result is a parts map: the assembled robot rendered with every proposed bit in its own color and discards in gray. The curator ticks the parts worth baking right on the card.

Parts map of the Comau S18 1.7, each proposed bit in its own color
Comau S18 1.7
Comau · CC BY 4.0
Modified: rendered, parts recolored
Parts map of the SO-100 arm, each proposed bit in its own color
SO-100 arm
NVIDIA Isaac Sim assets · Apache 2.0
Modified: rendered, parts recolored
Parts map of the OpenArm bimanual, each proposed bit in its own color
OpenArm bimanual
NVIDIA Isaac Sim assets · Apache 2.0
Modified: rendered, parts recolored
Parts map of the AgiBot A3 Ultra, each proposed bit in its own color
AgiBot A3 Ultra
AgiBot · CC BY 4.0
Modified: rendered, parts recolored
Real parts maps from this batch: an industrial arm, a hobby arm with gripper, a bimanual research rig and a humanoid.

Stage 6

Decimation and baking

Every bit has a budget. Small or simple parts start at S (400 triangles), the rest at M (1,000), and a part that can't hold its shape moves up to L (2,500). The budgets are about the library, not any one platform: a SimReady link arrives at tens of thousands of triangles, and an avatar is dozens of bits edited live in a browser. Fixed tiers keep every bit's cost predictable, so the editor stays real-time and downloads stay small. Platform limits are a separate job, handled at export (see Composite avatars).

The modeling work runs headless and deterministic. Blender does the cleanup, the voxel remesh behind retry cages, and the PBR bake; meshoptimizer simplifies; glTF-Transform handles the files; and Three.js in headless Chromium, driven by Playwright, renders the comparisons. The first thing the loop does is clean up CAD data, because most bake artifacts come from the source, not the decimator.

SOURCEPart filesUSD → GLBY-up meters10k–155k trisBLENDER, HEADLESSCleanweld vertices < 0.01 mmdrop pieces < 3% of the partcull faces unseen from 42 dirsflip inverted CAD windinglimbs: longest axis onto +YMESHOPTIMIZERSimplifyto the tier budgetS 400 · M 1,000L 2,500 trisBLENDER, HEADLESSPBR bakefresh UVs on the low meshcolor · roughness · metalAO · tangent normalsmany CAD materials → 1THREE.JS + PLAYWRIGHTVisual checksilhouette: front, top, sidedark pixels over 10 viewswhere the source isn't darkPASSGLB + report.jsontris at every stage, attempts,tool versions, the commandFAILRetry from a remeshed cageBlender voxel remesh,same tier, new low meshSTILL FAILSMove up a tierS → M → LFAILS EVEN AT LFlag for curator reviewshown on the admin cardpassfailre-bakestill failstier upat LSmall or simple parts start at S, the rest at M. Example: a Comau upper arm went 18,285 → 17,724 clean (2 pieces and 318 hidden faces removed) → 988 tris at M, silhouette within 2.7%.
The bake loop. Each part keeps a report of triangles at every stage, every attempt, texture sizes, tool versions and the exact command, so any bake can be reproduced or questioned.

The visual check

A triangle count says nothing about whether a part still looks right. Each bake is rendered next to its source from ten orthographic views and compared on two measures: how far the silhouette moved (on the front, top and side views), and how much of the bake is dark where the source isn't, averaged over all ten (the signature of inverted normals and failed bakes). A failing part is retried from a remeshed cage, then moved up a tier. If it still fails at L, it isn't silently dropped or shipped. It is flagged on the admin card for a person to judge.

Cleanup carries most of the weight. Welding, dropping fasteners under 3% of the part's size, and removing faces no camera can see from 42 directions routinely halve a part before simplification starts. The first link of the OpenArm went from 114,829 triangles to 54,686 after cleanup, then to 2,494 at L.

Where it sits
Where it sits
Comau · CC BY 4.0
Modified: rendered, part highlighted
Upper arm, baked
Upper arm, baked
Comau · CC BY 4.0
Modified: 18,285 → 988 tris, re-baked
A3 Ultra torso, baked
A3 Ultra torso, baked
AgiBot · CC BY 4.0
Modified: 37,128 → 998 tris, re-baked
OpenArm wrist, baked
OpenArm wrist, baked
NVIDIA Isaac Sim assets · Apache 2.0
Modified: decimated, re-baked
Left: the context image the curator sees, the part highlighted on the assembled robot. Then three finished bakes, each one material with baked color, roughness, metal, occlusion and normals.

Stage 7 · Matching by shape

A topology-independent duplicate check

Robot makers reuse parts across a product line, and NVIDIA republishes some of the same robots under its own account. Eighty near-identical industrial arms are not eighty useful bits, and a marketplace full of lightly-renamed copies is worse than a small one. Duplicates had to be caught automatically, across every package and against everything already on the site.

The obvious tool, a hash of the mesh, catches almost nothing here. The same bracket arrives re-tessellated by a different exporter, at a different scale, rotated, offset or mirrored for the other side, and every one of those has a different hash. So the check ignores topology entirely and compares shapes: each part becomes a fingerprint of its surface that doesn't care how the surface was triangulated.

1 · TWO MESHES2 · SAMPLE 2,000 POINTS3 · NORMALIZE + ALIGN4 · D2 SHAPE HISTOGRAM5 · SIMILARITYSAME PART · DIFFERENT TOPOLOGY, POSE, SIZE AND HANDEDNESSSource18,272 trisBake988 trisbake rotated 70°, ×2.5, mirroredarea-weighted, so density drops outthe clouds coincidecentered, unit size, principal axes, best flipnearfarpoint-pair distancesourcebakenear-identical: passes the prefiltermesh hash: different1.0000.70.9Duplicatedropped by default, reason shownA DIFFERENT PART · THE FOREARM OF THE SAME ROBOTUpper arm18,272 trisForearm994 trissimilar size, same maker, same stylesame sampling, same seedthe ends don't line upeven at the best of 8 orientationsnearfarpoint-pair distanceupper armforearmdistributions divergerelated, not the same0.7300.70.9Shown, not droppedthe curator sees the match
The check run on real parts from this batch: the upper arm of the Comau S18 1.7 as its raw source mesh and as its 988-triangle bake, then rotated 70°, scaled 2.5× and mirrored. Their mesh hashes share nothing; their shapes score 1.000. The forearm of the same robot scores 0.730: close enough to show the curator, not close enough to drop. Every number here is output of the production fingerprint code. Meshes from Comau S18-17 by Comau, CC BY 4.0; modified: rendered as wireframes and point samples, the bake decimated.
BUILD ONCE PER PART · ANY TRIANGULATION, SCALE, POSITION OR ROTATIONSTEP 1Sample the surface2,000 points, area-weightedseeded: same mesh, same pointsSTEP 2Normalizecenter on the centroidscale to unit RMS radiusSTEP 3Alignrotate onto principal axes(PCA), longest on XSTEP 4D2 histogram20,000 random point pairs64 distance binsSTOREDFingerprintpoints + eigenvalues + D2versioned, recomputed if staleCOMPARE TWO FINGERPRINTS · CHEAP STAGES FIRST, MOST PAIRS EXIT IN MILLISECONDSPREFILTERD2 distancefar apart: unrelated,score 0, no searchORIENTATIONS8 sign flipsalso covers mirrors; spinswhen two axes tieREFINEScreen → polish32 → 256 points, thensmall rotationsSCOREChamfer distancesymmetric, all points→ similarity 0–1DECISIONSimilarity≥ 90%: duplicate, drop≥ 70%: show the matchWhy not hash the mesh?An exact mesh hash only catches byte-identical copies. The same bracket re-exported at another tessellation, rotated, scaled, moved ormirrored has a different hash every time. Comparing sampled surfaces makes the check independent of topology: only the shape counts.
Building a fingerprint once per part, and comparing two of them. The comparison runs cheap stages first, so the large majority of pairs are rejected in milliseconds and only near-matches get the full search.

How it works

  • Sample, don't read the mesh. 2,000 points scattered over the surface, weighted by triangle area, with a fixed seed so the same mesh always gives the same fingerprint. A cylinder with 12 segments and one with 48 produce nearly the same cloud.
  • Remove pose and size. Center on the centroid, scale to unit RMS radius (uniform only, so a stretched part still looks different), and rotate onto the principal axes.
  • A cheap summary for filtering. A 64-bin histogram of distances between 20,000 random point pairs (the D2 shape distribution). Pairs whose histograms are far apart are scored 0 without any search, which keeps whole-library matching fast.
  • An exact score for the rest. PCA leaves the axis signs ambiguous, so all 8 sign flips are tried, which also catches mirrored left/right copies. When two axes tie (a square plate) or all three do (a cube), it sweeps rotations as well. The best orientation is polished with small rotations and scored by symmetric Chamfer distance, mapped to a 0–1 similarity.

Calibrated, not guessed

The thresholds come from unit tests on procedural meshes: pairs that must match (resampled, re-segmented, spun, mirrored) and pairs that must not (a 2:1 plate against a square one, a box against a cylinder). Every duplicate scores 0.975 or higher, every non-duplicate 0.891 or lower, and the line sits at 0.9. A read-only calibration script reruns the same report against a real baked package before any threshold changes.

CALIBRATED ON PROCEDURAL TEST MESHES (UNIT TESTS)0.40.50.60.70.80.91.0duplicate ≥ 0.9show match ≥ 0.7cube resampled + spun · 0.975cylinder, 48 vs 12 segments · 0.986sphere, 16 vs 8 rings · 0.994rotated, scaled, moved, mirrored · 1.000plate 2:1 vs 1:1 · 0.891box vs cylinder · 0.737L-bracket, one arm doubled · 0.712box vs L-bracket · 0.652cylinder vs plate · 0.391
Where the test pairs land. Green: must count as duplicates. Red: must not. The closest non-duplicate (two plates of different proportions) is the thin margin the tests watch.

From score to decision

Each baked part is compared with three pools, in order of precedence: every bit already on the site, its sibling parts in the same package, and parts of other packages waiting for review. A match at 90% or more drops the part by default, with the reason on the card ("97% similar to link-3 in CRX-10iA"). Matches from 70% are shown without dropping anything. Precedence makes the outcome stable: of two identical twins only one is ever dropped, and of two packages carrying the same part, the one that sorts first keeps it.

The curator can override any default. Publish recomputes the same rule on the server from the same inputs and refuses (409) if the result no longer matches what the page showed, so nothing is published from a stale view.

Human in the loop

The admin

One admin curation page, with one card per package. A card only ever moves forward through a small state machine, and each state shows exactly the decision it needs.

CURATORPickparts map, tick theparts worth bakingCLIBakingsimready-ingest runuploads bake.jsonCURATORReadybaked parts + shapematches, keep or dropPOSTGRESPublishedbits + provenancein one transactionCURATORCollectgripper set,free listingLICENSE HOLDBlockeda second license fileSkippedduplicates, out of scopeConfirm licenseSkip409: matches changed, review againPublish is pressed twice; the server recomputes what is kept
Package card states. Blocked is a license hold that needs a person. Publish is a single Postgres function, so a package's bits, provenance and rejected parts land together or not at all.
  • Publish is transactional. It writes the bits, their provenance records and the rejected parts (with why) in one function call.
  • The server never trusts the page's view. If the shape matches changed since the page loaded, Publish answers 409 and the curator reviews again.
  • Grippers become collections. A gripper's base and fingers are saved as one set rooted on the hand bone and listed on the marketplace for free.
  • Dropped parts stay recoverable. A part dropped at Publish can be published later on its own, under the package's existing credit.

Data

Storage

The rule is to keep what proves where a part came from and to discard everything that can be fetched again. There is no source archive: Hugging Face, pinned to a commit, is the source of truth.

SOURCE OF TRUTHHugging Facerevision pinned to a commitBOM: SHA-256 per filenothing mirroredTEMPORARYLocal work folderonly files the root layercomposes, fetched in roundsdisk check: size × 10 + 1 GBSUPABASE STORAGEsimready/<package>/<part>.<sha>.glbcontent-hashed, never overwrittenthumbnails · context · bake.jsonPOSTGRESRowscandidates · bitsprovenance · creditsfetchuploadPublishKEPT• Source URL and commit, package and file hashes• License and NOTICE text, stored once per license• Packaging, validation and benchmark JSON• Baked GLBs, thumbnails, context images, parts maps• bake.json: what was baked, how, and with what remapNEVER STORED• Multi-gigabyte source archives• Files the root layer does not reference• Intermediate meshes (deleted by clean)• A second copy of any upload: a hash path that• already exists is that file, so it is left alone
Files move left to right. The fetch asks OpenUSD what is still unresolved after each round, so only the files the root layer actually composes are downloaded, each checked against the package's bill of materials.

Every upload goes to a content-hashed path, so an upload never overwrites anything. If a path exists, it already holds that exact file. That made re-runs safe by construction: baking a package twice, or retrying after a dropped connection, costs time but can't corrupt a published bit. It is also what let me give the agent standing approval to upload to that one folder.

Attribution

License tracking

CC BY only allows reuse if you credit the creator in the way they ask, link the license and say what you changed. Get that wrong and the license can end, which would make every sale of that part an infringement. So attribution is part of the data model, not a footer.

TABLElicensesSPDX id, full textallowed for ingest?TABLEasset_sourcespackage, commit shacredited creator, noticeTABLEbit_provenancebit ↔ source, changes[]:decimated, logos painted outbitspublic, freeavatars+ collections+ downloadsCOMPUTED, NEVER STORED PER ASSETCredit uniondeduped sources behind every bit usedGLBasset.copyright+ extrasUSDlayer metadataCREDITS.txtFBX · Roblox R15/credits/[id]public pageexport_creditsfrozen snapshot
Each license is stored once. Each package is one asset source. Each bit links to its source through a provenance record with a log of changes. Credits are computed, never copied onto assets.
  • Credits are a union, computed on demand: the deduplicated sources behind every bit in an avatar, collection or download.
  • They travel with the file: in GLB metadata, USD layer metadata, a CREDITS.txt next to FBX and Roblox exports, and on a public credits page linked from each file.
  • They are frozen per export. A download keeps the credits it was made with, even if a source record is corrected later, which is why credit lines are agreed before anything is baked.
  • Credit is set deliberately, per vendor. A missing vendor entry stops the run. For repos that publish many makers' robots under one account, credit and branding resolve per package, never from the repo owner's name.
  • Logos are always stripped. A permissive copyright license still grants no trademark rights, so materials carrying lettering or brand colors can be repainted before baking.

The payoff

Composite avatars

A published part is an ordinary bit. In the editor it sits alongside starter bits, user uploads, image-to-3D generations and marketplace collections, bound rigidly to a Mixamo-compatible skeleton. Export bakes the live scene into the target format.

SimReady robot partsStarter bitsUploads + image-to-3DMarketplace collectionsEDITOROne skeleton, many bitsMixamo-compatible humanoid rigeach bit bound rigidly to a bonecollections root on a bone(a gripper on the hand)live in Three.js / R3FEXPORTBake the sceneT-pose, armature bakedtextures capped, creditsattached to the fileGLBgeneric, any engineFBX 7.4own binary writerVRChatPC + QuestRoblox R1515 parts · ≤ 10,742 tris · studs
One avatar, four targets. The Roblox target produces a single R15 character with every bit baked in: one mesh per body part with its bits atlased together, units in studs, within the 10,742-triangle budget.

The FBX path uses my own binary FBX 7.4 writer rather than a library, because each target (Unity, Blender, Roblox Studio) needs a slightly different node layout and every one was verified by importing the file in that tool. Platform budgets live here, not at ingest: the Roblox target decimates the whole composed avatar to 10,742 triangles, so the library never has to be baked down for the strictest platform.

How it was built

Automation and skills

I built this with Claude Code as a pair, and treated the agent like a new teammate who needs a runbook and clear permissions, not a magic box.

Trust boundaries

The agent may, without asking

  • Read the database and Hugging Face metadata
  • Run list, preview, run and clean
  • Upload to content-hashed simready/ paths
  • Open PRs with tests, written first

A person decides

  • Every SQL write and migration
  • Keep, skip, confirm a license
  • Which parts to bake, and Publish
  • Credit lines, brand paint-outs, merges

The boundaries are enforced in the system, not just in the prompt: the CLI never writes the database, the admin routes are admin-only, Publish is a single database function, and uploads can't overwrite.

A skill as the runbook

The pipeline's knowledge lives in a project skill: a short document an agent loads whenever it touches ingest. It covers who does what, the nine stages, the checks before baking and the common mistakes. I tested it the way you'd test code, with one trial each, so treat it as a smoke test rather than a benchmark. Without the skill, a fresh agent given a realistic ingest task rediscovered the pipeline from source and missed the last two stages. With the skill, a second fresh agent on the same task reused a listing from that morning, skipped known duplicates, and caught that preview without a package filter downloads every candidate. That gap went straight back into the skill.

Tests as the contract

Every rule (auto-reject, segmentation, tier choice, credit resolution, the kept/dropped logic) is pure and unit-tested against recorded package metadata. Bug fixes start with a test that reproduces the bug. The pipeline lives in Atomic Rig's private repository, which runs over 1,200 unit tests on every push. The public atomic-rig repository is a snapshot from August 2026, before this pipeline existed, and has fewer tests.

Honest notes

What broke

  • A metadata format nobody else used. NVIDIA's own robot repo writes its list of root files in a different shape. The code fell back to a path from NVIDIA's validation run that doesn't exist in the published repo, and every package in that repo failed to download. One failing test and a short fix later, all 134 of its packages that had passed the license check could be fetched.
  • The wrong name on the credit. Resolving credit by repo owner would have credited and branded every robot in that repo, from a dozen makers, as one company. Credit now resolves per package for repos like that.
  • A quality bar has to be enforced, not remembered. An early bulk re-bake replaced full-quality starter bits with budget bakes, guarded only by a darkness check. Content-hashed storage meant the originals were untouched and the fix was a rollback. The rule since: the library stays full quality, budgets belong to the bake and export, and no bake replaces a bit without a person comparing before and after.
  • CAD surfaces bake black. Inverted patches and hidden interior faces were the main source of failed bakes, which is why the 42-direction visibility pass and the dark-pixel check exist.
  • Networks drop. A package failed mid-download with nothing more than "terminated". Hash-verified, resumable steps meant the retry just worked.

Roadmap

What's next

  • Per-package logo paint-outs for the large multi-maker batch, chosen from the parts maps.
  • Roblox smoke tests: composed SimReady avatars imported into Studio and play-tested.
  • Solid fitting: turning a part's mesh into a signed distance field and fitting clean solids, for bits that can be edited, not just placed.
  • Making Confirm license a recorded notice instead of a hold when the second license is already on the allow-list.

Work with me

I build 3D products end to end: real-time rendering, asset pipelines, avatar systems and the tooling around them, with twenty years of production 3D underneath. If you're working on something like this, I'd like to hear about it.

Robot images are derived from SimReady packages on Hugging Face: Comau S18-17 by Comau and A3 Ultra by AgiBot, both under CC BY 4.0, and NVIDIA Isaac Sim assets under Apache 2.0. They were modified: rendered with parts recolored or highlighted, decimated and re-baked. The originals are unchanged at their sources.

Neither Atomic Rig nor I am affiliated with, sponsored by or endorsed by NVIDIA, Hugging Face or any robot maker named on this page. Product names are trademarks of their owners and are used only to identify the source assets.

© 2026 Justin Fischer.