Buckets:
| #!/usr/bin/env python3 | |
| """CLI: fetch a Franka Panda + Robotiq 2F-85 URDF and meshes. | |
| Two sources are supported via ``--source``: | |
| ``pointworld`` (default) | |
| ``NVlabs/PointWorld``'s ``assets/franka_description/``. This is the | |
| source to actually use: its flange offset is correct and every mesh its | |
| URDF references was actually committed upstream. See "why pointworld" | |
| below. | |
| ``polymetis`` | |
| The original source this script fetched: ``facebookresearch/fairo``'s | |
| ``polymetis/polymetis/data/franka_panda_robotiq_85/``. Kept for | |
| backwards compatibility; known-defective, see below. | |
| Why pointworld replaced polymetis as the default | |
| -------------------------------------------------- | |
| The polymetis URDF's ``panda_joint_ee`` places the flange at | |
| ``xyz="0 0 0.045"``, but the real Panda flange (``panda_link8``) sits at | |
| ``0.107``. That 62 mm shortfall isn't a guess: FK against DROID's own | |
| recorded ``cartesian_position`` gave a residual of exactly ``[0, 0, 0.062] m`` | |
| with zero variance over 128 frames, and ``0.107 - 0.045 = 0.062`` -- an exact | |
| match. Separately, its Robotiq gripper's visual ``.obj`` meshes 404 upstream | |
| (see the ``polymetis`` fetch path's own docstring below for the substitution | |
| this forces), and its visual/collision mesh scales are inconsistent (``0.1`` | |
| vs ``0.001``, and ``pad.stl`` at ``0.0001`` visual vs ``0.001`` collision -- | |
| a 10x fingertip error). pointworld's URDF has none of these problems: its | |
| ``panda_joint8`` origin is ``xyz="0 0 0.107"`` (correct), and every mesh it | |
| references actually exists at the resolved URL -- no patching needed. | |
| polymetis-specific quirks (only relevant to ``--source polymetis``) | |
| --------------------------------------------------------------------- | |
| The upstream layout at ``facebookresearch/fairo``'s | |
| ``polymetis/polymetis/data/franka_panda_robotiq_85/`` ships the URDF next to | |
| ``meshes/visual``, ``meshes/collision`` and ``meshes/robotiq-2f`` *git | |
| symlinks* rather than real directories -- they point at | |
| ``../franka_panda/meshes/{visual,collision}`` and | |
| ``../kuka_iiwa/meshes/robotiq-2f`` respectively. ``raw.githubusercontent.com`` | |
| serves a symlink's literal text content (the target path string), not the | |
| file it points to, so a naive fetch of the URDF's own relative mesh paths | |
| would download a handful of one-line text files instead of meshes. This | |
| script resolves each symlink itself and downloads from the real upstream | |
| path, while writing every file into a local flat layout that mirrors the | |
| URDF's *unresolved* relative paths -- exactly what a URDF loader expects to | |
| find sitting next to it. | |
| Separately (and unrelated to the symlinks): the Robotiq gripper's *visual* | |
| meshes (``meshes/robotiq-2f/visual/{base,driver,coupler,follower, | |
| spring_link}.obj``) were never committed to the fairo repo at all -- fetching | |
| them 404s even at their fully-resolved path. Only orphaned ``.mtl`` material | |
| files and a texture set survive. This script writes a patched copy of the | |
| URDF that substitutes the *collision* ``.stl`` geometry for the missing | |
| visual meshes, which is exactly what fairo's own MJCF conversion of this | |
| gripper does for the same reason -- see :func:`patch_urdf_visuals`. | |
| """ | |
| from __future__ import annotations | |
| import argparse | |
| import logging | |
| import math | |
| import sys | |
| import xml.etree.ElementTree as ET | |
| from dataclasses import dataclass | |
| from pathlib import Path, PurePosixPath | |
| sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src")) | |
| import requests # noqa: E402 | |
| from fpgm.utils.io import ( # noqa: E402 | |
| build_retrying_session, | |
| download_with_resume, | |
| ensure_dir, | |
| human_bytes, | |
| ) | |
| from fpgm.utils.logging import get_logger, setup_logging # noqa: E402 | |
| logger = get_logger(__name__) | |
| FAIRO_REPO_URL = "https://github.com/facebookresearch/fairo" | |
| FAIRO_DATA_ROOT = "polymetis/polymetis/data/" | |
| FAIRO_RAW_BASE = f"https://raw.githubusercontent.com/facebookresearch/fairo/main/{FAIRO_DATA_ROOT}" | |
| FAIRO_COMMITS_API = ( | |
| "https://api.github.com/repos/facebookresearch/fairo/commits" | |
| f"?path={FAIRO_DATA_ROOT}franka_panda_robotiq_85/panda_robotiq_85.urdf&per_page=1" | |
| ) | |
| #: Last-known-good resolution of the query above (2026-07-30), used if the API | |
| #: call itself fails (rate limiting, no network egress to api.github.com, etc). | |
| _FALLBACK_COMMIT_SHA = "768b118cb8f8ab7700504f40a74ed561f73b7289" | |
| URDF_FILENAME = "panda_robotiq_85.urdf" | |
| PATCHED_URDF_FILENAME = "panda_robotiq_85_patched.urdf" | |
| DEFAULT_OUT_DIR = Path("third_party/robot_description/franka_panda_robotiq_85") | |
| POINTWORLD_REPO_URL = "https://github.com/NVlabs/PointWorld" | |
| POINTWORLD_DATA_ROOT = "assets/franka_description/" | |
| POINTWORLD_RAW_BASE = f"https://raw.githubusercontent.com/NVlabs/PointWorld/main/{POINTWORLD_DATA_ROOT}" | |
| POINTWORLD_URDF_FILENAME = "franka_panda_robotiq_2f85.urdf" | |
| POINTWORLD_COMMITS_API = ( | |
| "https://api.github.com/repos/NVlabs/PointWorld/commits" | |
| f"?path={POINTWORLD_DATA_ROOT}{POINTWORLD_URDF_FILENAME}&per_page=1" | |
| ) | |
| #: Last-known-good resolution of the query above (2026-07-30), used if the API | |
| #: call itself fails (rate limiting, no network egress to api.github.com, etc). | |
| _POINTWORLD_FALLBACK_COMMIT_SHA = "8dff83df34cf80d2c69bec88ce3fd74afdb7e951" | |
| DEFAULT_POINTWORLD_OUT_DIR = Path("third_party/robot_description/pointworld_franka_robotiq_2f85") | |
| #: The real Panda flange (panda_link8) sits this far along +z from | |
| #: panda_link7. This is the whole reason pointworld replaced polymetis as the | |
| #: default source -- see the module docstring's "why pointworld" section for | |
| #: the measured 62 mm discrepancy this catches. | |
| EXPECTED_PANDA_LINK8_Z = 0.107 | |
| ARM_LINK_NAMES = tuple(f"link{i}" for i in range(8)) | |
| GRIPPER_COLLISION_PARTS = ("base", "coupler", "driver", "follower", "pad", "spring_link") | |
| #: STL collision meshes are authored at millimetre scale (matching every | |
| #: <collision> entry already in the URDF); the missing visual .obj meshes were | |
| #: authored at a different scale (0.1) and the missing visual pad.stl at yet | |
| #: another (0.0001). Blindly copying either onto a collision STL over-scales | |
| #: the gripper 100x-1000x -- see :func:`patch_urdf_visuals`. | |
| COLLISION_MESH_SCALE = "0.001 0.001 0.001" | |
| class AssetSpec: | |
| """One file to fetch: its resolved upstream URL and its local landing spot. | |
| Attributes: | |
| url: Fully-resolved raw.githubusercontent.com URL (symlinks already | |
| resolved -- see module docstring). | |
| local_relpath: Path relative to the output directory, matching the | |
| *unresolved* relative path the URDF itself references. | |
| """ | |
| url: str | |
| local_relpath: str | |
| def build_asset_specs() -> list[AssetSpec]: | |
| """The URDF plus every mesh it (or its patched copy) needs, flat-laid-out.""" | |
| specs = [AssetSpec(FAIRO_RAW_BASE + f"franka_panda_robotiq_85/{URDF_FILENAME}", URDF_FILENAME)] | |
| for link in ARM_LINK_NAMES: | |
| specs.append( | |
| AssetSpec( | |
| f"{FAIRO_RAW_BASE}franka_panda/meshes/visual/{link}.dae", | |
| f"meshes/visual/{link}.dae", | |
| ) | |
| ) | |
| for link in ARM_LINK_NAMES: | |
| specs.append( | |
| AssetSpec( | |
| f"{FAIRO_RAW_BASE}franka_panda/meshes/collision/{link}.stl", | |
| f"meshes/collision/{link}.stl", | |
| ) | |
| ) | |
| for part in GRIPPER_COLLISION_PARTS: | |
| specs.append( | |
| AssetSpec( | |
| f"{FAIRO_RAW_BASE}kuka_iiwa/meshes/robotiq-2f/collision/{part}.stl", | |
| f"meshes/robotiq-2f/collision/{part}.stl", | |
| ) | |
| ) | |
| return specs | |
| def download_assets( | |
| out_dir: Path, specs: list[AssetSpec], *, force: bool, session: requests.Session | |
| ) -> None: | |
| """Download every spec into ``out_dir``, skipping already-cached files. | |
| Args: | |
| out_dir: Root of the flat local layout (see module docstring). | |
| specs: Files to fetch. | |
| force: Re-download even if a same-named file already sits at the | |
| destination. | |
| session: Shared retrying HTTP session. | |
| Note: | |
| This checks "already present with non-zero size" itself rather than | |
| leaning solely on :func:`download_with_resume`'s own HEAD-vs-disk-size | |
| cache check, because GitHub's CDN gzip-compresses ``text/plain`` | |
| responses (the ``.dae`` meshes and the URDF, unlike the | |
| ``application/octet-stream`` STLs): its ``Content-Length`` header is | |
| the compressed size, while ``requests`` transparently decompresses | |
| the body before it hits disk. Those two sizes never match, so relying | |
| on that check alone would silently re-download every ``.dae`` and the | |
| ``.urdf`` on every single run. | |
| """ | |
| for spec in specs: | |
| dest = out_dir / spec.local_relpath | |
| if force and dest.exists(): | |
| dest.unlink() | |
| if dest.exists() and dest.stat().st_size > 0: | |
| logger.debug("already present, skipping: %s", dest) | |
| continue | |
| download_with_resume(spec.url, dest, session=session) | |
| def collect_referenced_meshes(xml_text: str) -> set[str]: | |
| """Every ``<mesh filename="...">`` a URDF references, from <visual> or <collision>. | |
| Deliberately parses rather than hardcodes a mesh list: pointworld's | |
| ``meshes/visual/`` directory ships 53 files (22.5 MB) covering this | |
| assembly plus unrelated extras, and only a fraction of them are actually | |
| referenced by this URDF. Reading the requirement off the URDF itself is | |
| the only way to fetch exactly what's needed without either guessing or | |
| downloading everything. | |
| Primitive geometries (``<box>``, ``<cylinder>``, ``<sphere>``) have no | |
| ``filename`` attribute and aren't ``<mesh>`` elements at all, so they're | |
| skipped implicitly by only iterating ``mesh`` tags. | |
| Args: | |
| xml_text: A URDF's full text. | |
| Returns: | |
| The set of relative mesh paths exactly as written in the URDF (no | |
| normalisation of any kind), with duplicates collapsed. | |
| """ | |
| root = ET.fromstring(xml_text) | |
| return {mesh.attrib["filename"] for mesh in root.iter("mesh") if "filename" in mesh.attrib} | |
| def build_pointworld_mesh_specs(xml_text: str) -> list[AssetSpec]: | |
| """One :class:`AssetSpec` per mesh :func:`collect_referenced_meshes` finds in ``xml_text``.""" | |
| return [ | |
| AssetSpec(POINTWORLD_RAW_BASE + relpath, relpath) | |
| for relpath in sorted(collect_referenced_meshes(xml_text)) | |
| ] | |
| def assert_single_camera_mount_link(xml_text: str) -> None: | |
| """Guard rail: pointworld's URDF defines ``camera_mount_link`` twice in the file. | |
| The first definition (around line 705 of the upstream file) sits inside | |
| an XML comment, so :mod:`xml.etree.ElementTree` -- which drops comments | |
| -- never sees it; only the second, active definition is parsed. That's a | |
| fragile invariant to rely on silently: if a future upstream edit | |
| uncomments the first block, standard XML parsing would then see two | |
| ``<link name="camera_mount_link">`` elements and duplicate the geometry. | |
| This asserts the invariant explicitly so that future breaks loudly | |
| instead. | |
| Args: | |
| xml_text: The URDF's full text. | |
| Raises: | |
| RuntimeError: If the parsed tree contains anything other than | |
| exactly one ``camera_mount_link`` link. | |
| """ | |
| root = ET.fromstring(xml_text) | |
| matches = [ | |
| link for link in root.findall("link") if link.attrib.get("name") == "camera_mount_link" | |
| ] | |
| if len(matches) != 1: | |
| raise RuntimeError( | |
| f"expected exactly one parsed camera_mount_link, found {len(matches)} -- " | |
| "the upstream URDF has a second definition guarded by an XML comment; " | |
| "see assert_single_camera_mount_link's docstring" | |
| ) | |
| def assets_missing_or_empty(out_dir: Path, specs: list[AssetSpec]) -> list[str]: | |
| """Local relpaths from ``specs`` that are absent from disk or zero bytes.""" | |
| missing = [] | |
| for spec in specs: | |
| dest = out_dir / spec.local_relpath | |
| if not dest.exists() or dest.stat().st_size == 0: | |
| missing.append(spec.local_relpath) | |
| return missing | |
| def patch_urdf_visuals(xml_text: str) -> str: | |
| """Repoint missing Robotiq visual meshes at their collision STL siblings. | |
| Every ``<visual>`` mesh reference under ``meshes/robotiq-2f/visual/`` is | |
| rewritten to the equivalent file under ``meshes/robotiq-2f/collision/`` | |
| (extension forced to ``.stl``, scale forced to | |
| :data:`COLLISION_MESH_SCALE`). ``<collision>`` subtrees are never touched. | |
| This single rule also covers ``pad``, whose upstream ``<visual>`` already | |
| pointed at an ``.stl`` (not ``.obj``) at scale ``0.0001`` -- that file | |
| still lives under the same missing ``visual/`` directory (see module | |
| docstring) and was never downloaded, so it gets the same treatment as the | |
| ``.obj`` meshes: repoint to ``collision/pad.stl`` and correct the scale to | |
| ``0.001``. The original ``0.0001`` was calibrated for the missing visual | |
| mesh's units, not the collision STL's -- keeping it would render the pad | |
| roughly 10x too small relative to the rest of the (rescaled) gripper. | |
| Args: | |
| xml_text: The original URDF's full text. | |
| Returns: | |
| The patched URDF as a string, ready to write to disk. | |
| """ | |
| root = ET.fromstring(xml_text) | |
| n_patched = 0 | |
| for visual in root.iter("visual"): | |
| mesh = visual.find("geometry/mesh") | |
| if mesh is None or "filename" not in mesh.attrib: | |
| continue | |
| filename = mesh.attrib["filename"] | |
| if "robotiq-2f/visual/" not in filename: | |
| continue | |
| resolved = filename.replace("robotiq-2f/visual/", "robotiq-2f/collision/") | |
| resolved = str(PurePosixPath(resolved).with_suffix(".stl")) | |
| mesh.set("filename", resolved) | |
| mesh.set("scale", COLLISION_MESH_SCALE) | |
| n_patched += 1 | |
| logger.debug("patched %d robotiq visual mesh reference(s)", n_patched) | |
| ET.indent(root, space=" ") | |
| return ET.tostring(root, encoding="unicode") + "\n" | |
| def write_patched_urdf(out_dir: Path) -> Path: | |
| """Read the original URDF and write :func:`patch_urdf_visuals`'s output beside it.""" | |
| original_path = out_dir / URDF_FILENAME | |
| patched_path = out_dir / PATCHED_URDF_FILENAME | |
| patched_path.write_text(patch_urdf_visuals(original_path.read_text())) | |
| return patched_path | |
| def resolve_commit_sha(session: requests.Session, api_url: str, fallback_sha: str) -> str: | |
| """Look up the commit that last touched the upstream URDF, for provenance. | |
| Falls back to a last-known-good SHA (rather than failing the whole run) | |
| if the GitHub API call itself doesn't succeed -- unauthenticated calls to | |
| it are rate-limited and this isn't essential to actually fetching assets. | |
| Args: | |
| session: Shared retrying HTTP session. | |
| api_url: A GitHub "list commits touching this path" API URL. | |
| fallback_sha: Returned if the API call fails for any reason. | |
| """ | |
| try: | |
| resp = session.get(api_url, timeout=30.0) | |
| resp.raise_for_status() | |
| commits = resp.json() | |
| return str(commits[0]["sha"]) | |
| except (requests.RequestException, KeyError, IndexError, ValueError) as exc: | |
| logger.warning("could not resolve commit sha from GitHub API (%s); using fallback", exc) | |
| return fallback_sha | |
| def write_provenance(out_dir: Path, specs: list[AssetSpec], commit_sha: str) -> None: | |
| """Record where every file came from and why the gripper visuals were substituted.""" | |
| lines = [ | |
| f"Source: {FAIRO_REPO_URL}", | |
| f"Resolved commit: {commit_sha}", | |
| f"Data root: {FAIRO_DATA_ROOT}", | |
| "", | |
| "Downloaded files:", | |
| ] | |
| for spec in specs: | |
| size = (out_dir / spec.local_relpath).stat().st_size | |
| lines.append(f" {spec.local_relpath}\t{size} bytes ({human_bytes(size)})\t<- {spec.url}") | |
| lines += [ | |
| "", | |
| f"Wrote patched URDF: {PATCHED_URDF_FILENAME}", | |
| "", | |
| "Note: the Robotiq 2F-85 gripper's VISUAL meshes referenced by the " | |
| "upstream URDF (meshes/robotiq-2f/visual/{base,driver,coupler,follower," | |
| "spring_link}.obj) were never committed to the fairo repo -- fetching " | |
| "them returns HTTP 404, and only their orphaned .mtl material files " | |
| "and a texture set survive. We substitute the COLLISION .stl geometry " | |
| "for these visual meshes instead (rescaled to match STL units), which " | |
| "is exactly what fairo's own MJCF conversion of this same gripper " | |
| "does, for the same reason. See panda_robotiq_85_patched.urdf for the " | |
| "rewritten <visual> mesh references; panda_robotiq_85.urdf is kept " | |
| "unmodified so the two can be diffed.", | |
| ] | |
| (out_dir / "PROVENANCE.txt").write_text("\n".join(lines) + "\n") | |
| def write_pointworld_provenance(out_dir: Path, specs: list[AssetSpec], commit_sha: str) -> None: | |
| """Record where every file came from and why this source replaced polymetis.""" | |
| lines = [ | |
| f"Source: {POINTWORLD_REPO_URL}", | |
| f"Resolved commit: {commit_sha}", | |
| f"Data root: {POINTWORLD_DATA_ROOT}", | |
| "", | |
| "Downloaded files:", | |
| ] | |
| for spec in specs: | |
| size = (out_dir / spec.local_relpath).stat().st_size | |
| lines.append(f" {spec.local_relpath}\t{size} bytes ({human_bytes(size)})\t<- {spec.url}") | |
| lines += [ | |
| "", | |
| "Why this source replaced facebookresearch/fairo's polymetis URDF " | |
| "(third_party/robot_description/franka_panda_robotiq_85):", | |
| " - polymetis's panda_joint_ee places the flange at xyz=\"0 0 0.045\", but " | |
| "the real Panda flange (panda_link8) sits at 0.107 m. The 62 mm shortfall " | |
| "is measured, not assumed: FK against DROID's own recorded " | |
| "cartesian_position gave a residual of exactly [0, 0, 0.062] m with zero " | |
| "variance over 128 frames, and 0.107 - 0.045 = 0.062 exactly.", | |
| " - polymetis's Robotiq gripper visual .obj meshes 404 upstream -- they " | |
| "were never committed to the fairo repo at all (only orphaned .mtl and " | |
| "texture files survive). See franka_panda_robotiq_85/PROVENANCE.txt for " | |
| "the collision-mesh substitution this forced.", | |
| " - polymetis's visual/collision mesh scales are inconsistent (0.1 vs " | |
| "0.001, and pad.stl at 0.0001 visual vs 0.001 collision -- a 10x " | |
| "fingertip error) and its Robotiq mount joints lack an <origin>.", | |
| f" This URDF's panda_joint8 origin is xyz=\"0 0 {EXPECTED_PANDA_LINK8_Z}\" -- " | |
| "correct -- and every mesh it references was actually downloaded above, " | |
| "so (unlike the polymetis path) no URDF patching is needed here.", | |
| ] | |
| (out_dir / "PROVENANCE.txt").write_text("\n".join(lines) + "\n") | |
| def verify_patched_urdf(patched_path: Path): | |
| """Load the patched URDF with meshes and confirm nothing failed to resolve. | |
| yourdfpy doesn't raise when a referenced mesh file is missing -- it logs a | |
| ``"Can't find ..."`` warning on the ``yourdfpy.urdf`` logger and silently | |
| drops that geometry from the scene. Catching that warning is therefore | |
| the only reliable way to detect an unresolved mesh reference; a clean | |
| load with zero visuals dropped would otherwise look identical to success. | |
| Args: | |
| patched_path: Path to the patched URDF to load. | |
| Returns: | |
| The loaded ``yourdfpy.URDF`` model. | |
| Raises: | |
| RuntimeError: If loading fails outright, or any visual mesh could not | |
| be resolved. | |
| """ | |
| import yourdfpy | |
| missing: list[str] = [] | |
| class _CaptureMissingMeshWarnings(logging.Handler): | |
| def emit(self, record: logging.LogRecord) -> None: | |
| message = record.getMessage() | |
| if "Can't find" in message: | |
| missing.append(message) | |
| handler = _CaptureMissingMeshWarnings() | |
| yourdfpy_logger = logging.getLogger("yourdfpy") | |
| yourdfpy_logger.addHandler(handler) | |
| try: | |
| model = yourdfpy.URDF.load(str(patched_path), load_meshes=True, build_scene_graph=True) | |
| except Exception as exc: # yourdfpy raises assorted exception types on bad input | |
| raise RuntimeError(f"failed to load {patched_path}: {exc}") from exc | |
| finally: | |
| yourdfpy_logger.removeHandler(handler) | |
| if missing: | |
| raise RuntimeError( | |
| f"{len(missing)} visual mesh(es) failed to resolve while loading {patched_path}: " | |
| + "; ".join(missing) | |
| ) | |
| return model | |
| def verify_pointworld_urdf(urdf_path: Path): | |
| """Load the URDF with meshes and confirm the flange offset is correct. | |
| Unlike :func:`verify_patched_urdf`, this source has no missing meshes to | |
| detect (see the module docstring) -- so nothing here watches for "Can't | |
| find" warnings. What it does check is the entire reason this source | |
| replaced polymetis as the default: ``panda_link8``'s parent joint must | |
| place the flange at :data:`EXPECTED_PANDA_LINK8_Z`, not polymetis's | |
| (measured-wrong) 0.045 m. | |
| Args: | |
| urdf_path: Path to the URDF to load. | |
| Returns: | |
| A ``(model, flange_z, finger_joint)`` tuple: the loaded | |
| ``yourdfpy.URDF``, the measured flange z-offset, and the | |
| ``finger_joint`` (the single joint every ``<mimic>`` in this URDF | |
| drives). | |
| Raises: | |
| RuntimeError: If loading fails, ``panda_link8`` or its parent joint | |
| is missing, its origin's z-offset isn't | |
| :data:`EXPECTED_PANDA_LINK8_Z`, or ``finger_joint`` is missing. | |
| """ | |
| import yourdfpy | |
| try: | |
| model = yourdfpy.URDF.load(str(urdf_path), load_meshes=True, build_scene_graph=True) | |
| except Exception as exc: # yourdfpy raises assorted exception types on bad input | |
| raise RuntimeError(f"failed to load {urdf_path}: {exc}") from exc | |
| if "panda_link8" not in model.link_map: | |
| raise RuntimeError(f"panda_link8 not found among links loaded from {urdf_path}") | |
| flange_joint = next((j for j in model.joint_map.values() if j.child == "panda_link8"), None) | |
| if flange_joint is None or flange_joint.origin is None: | |
| raise RuntimeError("no joint with an <origin> was found for panda_link8") | |
| flange_z = float(flange_joint.origin[2, 3]) | |
| if not math.isclose(flange_z, EXPECTED_PANDA_LINK8_Z, abs_tol=1e-9): | |
| raise RuntimeError( | |
| f"panda_link8 flange z-offset is {flange_z}, expected " | |
| f"{EXPECTED_PANDA_LINK8_Z} -- this assertion is the entire reason " | |
| "pointworld replaced polymetis as the default source, see module docstring" | |
| ) | |
| finger_joint = model.joint_map.get("finger_joint") | |
| if finger_joint is None: | |
| raise RuntimeError(f"finger_joint not found among joints loaded from {urdf_path}") | |
| return model, flange_z, finger_joint | |
| def parse_args() -> argparse.Namespace: | |
| parser = argparse.ArgumentParser(description=__doc__) | |
| parser.add_argument( | |
| "--source", | |
| choices=("pointworld", "polymetis"), | |
| default="pointworld", | |
| help="asset source: 'pointworld' (default; correct 0.107m flange offset) or " | |
| "'polymetis' (fairo; kept for compatibility, has a measured 62mm flange bug)", | |
| ) | |
| parser.add_argument( | |
| "--out-dir", | |
| type=Path, | |
| default=None, | |
| help="defaults to a source-specific directory under third_party/robot_description/", | |
| ) | |
| parser.add_argument("--force", action="store_true", help="re-download files even if cached") | |
| parser.add_argument( | |
| "--skip-verify", action="store_true", help="skip the yourdfpy load-and-check step" | |
| ) | |
| parser.add_argument("--log-level", default="INFO") | |
| return parser.parse_args() | |
| def _main_polymetis(args: argparse.Namespace, session: requests.Session) -> int: | |
| out_dir = ensure_dir(args.out_dir or DEFAULT_OUT_DIR) | |
| specs = build_asset_specs() | |
| logger.info("fetching %d file(s) -> %s", len(specs), out_dir) | |
| download_assets(out_dir, specs, force=args.force, session=session) | |
| patched_path = write_patched_urdf(out_dir) | |
| logger.info("wrote patched urdf -> %s", patched_path) | |
| commit_sha = resolve_commit_sha(session, FAIRO_COMMITS_API, _FALLBACK_COMMIT_SHA) | |
| write_provenance(out_dir, specs, commit_sha) | |
| logger.info("wrote provenance -> %s", out_dir / "PROVENANCE.txt") | |
| if args.skip_verify: | |
| return 0 | |
| try: | |
| model = verify_patched_urdf(patched_path) | |
| except RuntimeError as exc: | |
| logger.error("verification failed: %s", exc) | |
| return 1 | |
| logger.info( | |
| "verified: %d links, %d actuated joints: %s", | |
| len(model.link_map), | |
| model.num_actuated_joints, | |
| model.actuated_joint_names, | |
| ) | |
| return 0 | |
| def _main_pointworld(args: argparse.Namespace, session: requests.Session) -> int: | |
| out_dir = ensure_dir(args.out_dir or DEFAULT_POINTWORLD_OUT_DIR) | |
| urdf_dest = out_dir / POINTWORLD_URDF_FILENAME | |
| urdf_spec = AssetSpec(POINTWORLD_RAW_BASE + POINTWORLD_URDF_FILENAME, POINTWORLD_URDF_FILENAME) | |
| logger.info("fetching urdf -> %s", urdf_dest) | |
| download_assets(out_dir, [urdf_spec], force=args.force, session=session) | |
| xml_text = urdf_dest.read_text() | |
| try: | |
| assert_single_camera_mount_link(xml_text) | |
| except RuntimeError as exc: | |
| logger.error("urdf structure check failed: %s", exc) | |
| return 1 | |
| mesh_specs = build_pointworld_mesh_specs(xml_text) | |
| logger.info("urdf references %d mesh file(s); fetching -> %s", len(mesh_specs), out_dir) | |
| download_assets(out_dir, mesh_specs, force=args.force, session=session) | |
| all_specs = [urdf_spec, *mesh_specs] | |
| missing = assets_missing_or_empty(out_dir, all_specs) | |
| if missing: | |
| logger.error( | |
| "%d referenced mesh(es) missing or empty after download: %s", | |
| len(missing), | |
| ", ".join(missing), | |
| ) | |
| return 1 | |
| mesh_bytes = sum((out_dir / spec.local_relpath).stat().st_size for spec in mesh_specs) | |
| logger.info( | |
| "downloaded %d referenced mesh(es), %s total (%d file(s) overall)", | |
| len(mesh_specs), | |
| human_bytes(mesh_bytes), | |
| len(all_specs), | |
| ) | |
| commit_sha = resolve_commit_sha( | |
| session, POINTWORLD_COMMITS_API, _POINTWORLD_FALLBACK_COMMIT_SHA | |
| ) | |
| write_pointworld_provenance(out_dir, all_specs, commit_sha) | |
| logger.info("wrote provenance -> %s", out_dir / "PROVENANCE.txt") | |
| if args.skip_verify: | |
| return 0 | |
| try: | |
| model, flange_z, finger_joint = verify_pointworld_urdf(urdf_dest) | |
| except RuntimeError as exc: | |
| logger.error("verification failed: %s", exc) | |
| return 1 | |
| logger.info( | |
| "verified: %d links, %d actuated joints: %s", | |
| len(model.link_map), | |
| model.num_actuated_joints, | |
| model.actuated_joint_names, | |
| ) | |
| logger.info( | |
| "panda_link8 flange z = %.3f m (expected %.3f m) -- the fix this source exists for", | |
| flange_z, | |
| EXPECTED_PANDA_LINK8_Z, | |
| ) | |
| limit = finger_joint.limit | |
| logger.info( | |
| "finger_joint limits: lower=%s upper=%s effort=%s velocity=%s", | |
| limit.lower if limit else None, | |
| limit.upper if limit else None, | |
| limit.effort if limit else None, | |
| limit.velocity if limit else None, | |
| ) | |
| return 0 | |
| def main() -> int: | |
| args = parse_args() | |
| setup_logging(args.log_level) | |
| session = build_retrying_session() | |
| if args.source == "polymetis": | |
| return _main_polymetis(args, session) | |
| return _main_pointworld(args, session) | |
| if __name__ == "__main__": | |
| raise SystemExit(main()) | |
Xet Storage Details
- Size:
- 27.5 kB
- Xet hash:
- 75d0d0e401cf58d224326834a8ba07ac1a13ca8ac9fba4879d708f0c41e465a6
·
Xet efficiently stores files, intelligently splitting them into unique chunks and accelerating uploads and downloads. More info.