--- name: transform-onshape-urdf description: >- Normalize a raw ONShape-exported YAM-family URDF into the aligned, world-referenced form used across i2rt robot models, as a two-stage pipeline around a human inspection checkpoint. Use when importing a fresh ONShape URDF export (robot named "urdf_top_assembly", package:// mesh paths, a synthetic "root" link, dof_joint* names, and duplicate *_1 links wired by 分组/紧固 fixed joints); when removing a synthetic root link and baking its rotation into the base while keeping every downstream link in place; when applying a world-axis heading correction that rotates the base mesh AND inertia together and rigidly rotates the whole arm; when fixing mesh filename case/paths; or when re-running such a transform idempotently. Runs normalize_onshape_urdf.py (stage 1) and apply_urdf_heading.py (stage 3), with view_urdf.py as the checkpoint in between. --- # Transform ONShape URDF → aligned model Turns a raw ONShape YAM-family export into the clean form established by `yam.urdf` (commit `8975c25`). This was one script (`transform_onshape_urdf.py`); it is now **two**, split around a mandatory visual-inspection checkpoint so the heading rotation -- a human judgment -- is never applied as a default guess: - [`scripts/normalize_onshape_urdf.py`](scripts/normalize_onshape_urdf.py) — stage 1, deterministic cleanup (meshes, joint names, remove root, bake `R0`). No heading. - [`scripts/view_urdf.py`](scripts/view_urdf.py) — stage 2, the checkpoint: render the normalized model in MuJoCo with world axes and ask the user which heading correction is needed. - [`scripts/apply_urdf_heading.py`](scripts/apply_urdf_heading.py) — stage 3, apply the user's rotation to the base (mesh + inertia together) and the arm. The shared math/XML helpers live in [`scripts/urdf_align_lib.py`](scripts/urdf_align_lib.py). The downstream URDF→MJCF alignment (regenerate the `.xml`, then re-sync gripper mounts) is owned by [`.agents/skills/align-urdf-mjcf/SKILL.md`](../align-urdf-mjcf/SKILL.md). Treat the URDF as the source of truth; regenerate the `.xml` (MJCF) from the fixed URDF. ## When to use - A fresh ONShape export needs to become a usable URDF: ``, `package://.../meshes/Name.stl` paths, a `root` link above `base`, `dof_joint0..N` joints, and duplicate `link*_1` / `分组` / `紧固` structural joints. - You need to drop a synthetic root and re-home the base heading without disturbing the arm. - You need to re-run normalization safely (it must be a no-op the second time). ## Run it ```bash # Stage 1 — normalize (deterministic; edits in place): python .agents/skills/transform-onshape-urdf/scripts/normalize_onshape_urdf.py \ [--assets-dir assets] [--name NAME] [--dry-run] [--force] # Stage 2 — inspect (required checkpoint): python .agents/skills/transform-onshape-urdf/scripts/view_urdf.py # or --screenshot out.png (headless) # Stage 3 — apply the user's heading (previews by default; --apply writes): python .agents/skills/transform-onshape-urdf/scripts/apply_urdf_heading.py --axis z --deg [--apply] ``` - `normalize` `--name` defaults to the URDF's parent dir name, skipping a `vN` version dir (`arm/yam/v1/yam.urdf` -> `yam`). Pass it explicitly when the export sits somewhere unrelated. - `normalize` `--dry-run` prints the verification report and the resulting `base` + `joint1`, writing nothing. Always dry-run first. - `apply_urdf_heading` writes only with `--apply`; without it, it previews. Different YAM arms need different headings (each export's baked `R0` differs) — inspect and confirm visually. Common case is a yaw about world Z; `yam_pro` needed 0°. ## Stage 1 — the transform (what `normalize_onshape_urdf.py` does) RPY convention (URDF): `R = Rz(yaw) @ Ry(pitch) @ Rx(roll)`. 1. **Keep only the actuated chain.** The real kinematic tree is the `dof_joint*` joints. Drop every other joint (`分组`/`紧固`/fastened duplicates) and every link not on that chain. Rename the survivors to canonical names by stripping a trailing `_` (`base_1`→`base`, `link2_1`→`link2`, `gripper_1`→`gripper`, `tip_right_1`→`tip_right`), **and rename the base link to `base` whatever the export called it** (`ultra_base`→`base`). The base is identified structurally as the parent of `dof_joint1`, never by name — an export that names it after the product would otherwise slip past step 3's `child == "base"` guard (reported as "synthetic root already removed") and be rejected by every downstream stage, all of which require the name `base`. 2. **Fix meshes.** Rewrite each reference to `assets/`, preferring an exact case-insensitive match on disk, else the suffix-stripped lowercase name with step 1's link renames applied to the stem (`Gripper.stl`→`gripper.stl`, `link5_1.stl`→`link5.stl`, `ultra_base.stl`→`base.stl`) — a link and its mesh are always renamed together. 3. **Remove the synthetic root, baking `R0`.** The synthetic root is the *parent of the joint whose child is `base`* (in a raw export that is `dof_joint0`, `root -> base`). Capture its rotation `R0 = Rz(yaw)Ry(pitch)Rx(roll)`, delete the `root` link and that joint, and left-multiply `R0` into the `base` **visual** origin, the `base` **inertial** origin (position + rpy), and the `base -> link1` joint origin. The inertia tensor components and every joint's local `axis` are unchanged; joints 2..N are untouched, so the arm rotates rigidly. Rotating the base mesh and its inertia by the *same* `R0` keeps them consistent. **Guard:** if no joint has child `base`, the root is already gone and this step is skipped — it never re-bakes and never mistakes a rootless `base` for a synthetic root. 4. **Rename joints** `dof_jointN` → `jointN`; set the robot name. 5. **Insert an `onshape-normalize:` marker** recording `R0` and that a heading is still pending. **No heading is applied here.** That is stage 3, gated by the checkpoint. ## Stage 3 — the heading (what `apply_urdf_heading.py` does) Construct the requested rotation `R` (`--axis`/`--deg`, or `--rpy "r p y"`) and left-multiply it into the `base` visual origin, the `base` inertial origin (**the same `R`** — mesh and inertia never diverge), and the `base -> link1` joint origin (rigidly rotating the whole arm). Because origins are left-multiplied, this composes with stage 1's `R0` as `R @ R0` — identical to the old one-shot transform's combined `--heading-deg`. Inertia components and downstream joints are untouched. An `onshape-heading:` marker is appended. The op is **not idempotent** (re-running stacks another rotation), so it previews by default and writes only with `--apply`. ## Idempotency contract (stage 1) Before doing anything, `normalize_onshape_urdf.py` classifies the file structurally: - **`is_raw`**: a `root` link, `dof_joint*` names, `package://` paths, non-arm (`分组`/`紧固`) joints, or `*_` duplicate links. A non-raw file has nothing to normalize (each step is a no-op), so it is skipped. - **`has_marker`**: the `onshape-normalize:` comment is present. Decision: not raw → skip (already normalized). Raw + marker + no `--force` → skip. Raw and (`--force` or no marker) → normalize, then write the marker. Every step is individually idempotent, so re-running is safe. ## Verify (the scripts refuse to write on failure) Both stage scripts verify before writing and print a report: - **Rigidity**: parent→child origins for every joint below `base -> link1` are unchanged (the arm is a pure rigid rotation). - **Joint world axes** are recomputed and printed; the FK also proves the tree is acyclic with a single `base` root. - **Base mesh & inertia aligned**: the base visual and inertial orientations must match (the mesh and inertia were rotated by the identical `R`). - **Meshes** all resolve on disk. After writing, additionally: ```bash git diff -- # base visual+inertial + joint1 rotated; root/dof_joint0 gone; # joints renamed; mesh case fixed; other links byte-identical ``` Then **inspect** with `python .agents/skills/transform-onshape-urdf/scripts/view_urdf.py ` and confirm the arm assembles and the base faces the intended forward direction — the required heading checkpoint from `align-urdf-mjcf`. Finally regenerate the MJCF from the URDF (and re-sync gripper mounts — see `align-urdf-mjcf`). ## Assumptions / limits - ONShape naming conventions: the actuated chain uses `dof_joint*`; structural duplicates use `分组`/`紧固`; per-instance suffixes are `_`; the base link is the parent of `dof_joint1` and may be named anything. Verified end-to-end on `yam_pro` and `yam_ultra` v2. - The heading is a single rotation about one world axis (or an explicit `--rpy`). If a model needs a different base orientation, pass a different `--axis`/`--deg` and confirm by rendering. - **ONShape prints `R0` at 5 decimals** (`rpy="0 1.5708 ..."`, and `1.5708 - π/2 = 3.673e-6`), so a plain `--deg` heading leaves a ~0.0002° tilt in the base that every downstream frame inherits. Since the heading left-multiplies (`R_head @ R0`), you can absorb it: pass the intended heading *composed with the correction* via `--rpy`. For `yam_ultra` v2 (`R0 = Ry(1.5708)`, heading z+180°) that was `--rpy "0 -3.673205103379957e-06 3.141592653589793"` → the product is exactly `Rz(π)·Ry(π/2)`, giving `base` rpy `0 1.57079632679 3.14159265359` and `joint1` xyz `0 0 0.0733`.