blanchon's picture
Update
3cdf7b9
// create interface‐instances of the model based on XML‐URDF file.
// These interfaces are closer to the Three.js structure so it's easy to visualize.
import type { IUrdfVisual } from "../interfaces/IUrdfVisual";
import { xyzFromString, rpyFromString, rgbaFromString } from "./helper";
import type IUrdfLink from "../interfaces/IUrdfLink";
import type IUrdfJoint from "../interfaces/IUrdfJoint";
import type IUrdfMesh from "../interfaces/IUrdfMesh";
import type IUrdfRobot from "../interfaces/IUrdfRobot";
/**
* Find all "root" links of a robot. A link is considered a root if
* no joint in the robot references it as a "child". In other words,
* it has no parent joint.
*
* @param robot - The parsed IUrdfRobot object whose links and joints we examine
* @returns An array of IUrdfLink objects that have no parent joint (i.e. root links)
*/
export function getRootLinks(robot: IUrdfRobot): IUrdfLink[] {
// Compute the links
const links: IUrdfLink[] = [];
const joints = robot.joints;
for (const link of Object.values(robot.links)) {
let isRoot = true;
for (const joint of joints) {
if (joint.child.name === link.name) {
isRoot = false;
break;
}
}
if (isRoot) {
links.push(link);
}
}
return links;
}
/**
* Find all "root" joints of a robot. A joint is considered a root if
* its parent link is never used as a "child link" anywhere else.
*
* For example, if Joint A's parent is "Base" and no other joint has
* child="Base", then Joint A is a root joint.
*
* @param robot - The parsed IUrdfRobot object
* @returns An array of IUrdfJoint objects with no parent joint (i.e. root joints)
*/
export function getRootJoints(robot: IUrdfRobot): IUrdfJoint[] {
const joints = robot.joints;
const rootJoints: IUrdfJoint[] = [];
for (const joint of joints) {
let isRoot = true;
// If any other joint's child matches this joint's parent, then this joint isn't root
for (const parentJoint of joints) {
if (joint.parent.name === parentJoint.child.name) {
isRoot = false;
break;
}
}
if (isRoot) {
rootJoints.push(joint);
}
}
return rootJoints;
}
/**
* Given a parent link, find all joints in the robot that use that link as their parent.
*
* @param robot - The parsed IUrdfRobot object
* @param parent - An IUrdfLink object to use as the "parent" in comparison
* @returns A list of IUrdfJoint objects whose parent.name matches parent.name
*/
export function getChildJoints(robot: IUrdfRobot, parent: IUrdfLink): IUrdfJoint[] {
const childJoints: IUrdfJoint[] = [];
const joints = robot.joints;
if (!joints) {
return [];
}
for (const joint of joints) {
if (joint.parent.name === parent.name) {
childJoints.push(joint);
}
}
return childJoints;
}
/**
* Update the <origin> element's attributes (xyz and rpy) in the XML
* for either a joint or a visual element, based on the object's current origin_xyz/origin_rpy.
*
* @param posable - Either an IUrdfJoint or an IUrdfVisual whose `.elem` has an <origin> child
*/
export function updateOrigin(posable: IUrdfJoint | IUrdfVisual) {
const origin = posable.elem.getElementsByTagName("origin")[0];
origin.setAttribute("xyz", posable.origin_xyz.join(" "));
origin.setAttribute("rpy", posable.origin_rpy.join(" "));
}
/**
* Main URDF parser class. Given a URDF filename (or XML string), it will:
* 1) Fetch the URDF text (if given a URL/filename)
* 2) Parse materials/colors
* 3) Parse links (including visual & collision)
* 4) Parse joints
* 5) Build an IUrdfRobot data structure that is easier to traverse in JS/Three.js
*/
export class UrdfParser {
filename: string;
prefix: string; // e.g. "robots/so_arm100/"
colors: { [name: string]: [number, number, number, number] } = {};
robot: IUrdfRobot = { name: "", links: {}, joints: [] };
/**
* @param filename - Path or URL to the URDF file (XML). May be relative.
* @param prefix - A folder prefix used when resolving "package://" or relative mesh paths.
*/
constructor(filename: string, prefix: string = "") {
this.filename = filename;
// Ensure prefix ends with exactly one slash
this.prefix = prefix.endsWith("/") ? prefix : prefix + "/";
}
/**
* Fetch the URDF file from `this.filename` and return its text.
* @returns A promise that resolves to the raw URDF XML string.
*/
async load(): Promise<string> {
return fetch(this.filename).then((res) => res.text());
}
/**
* Clear any previously parsed robot data, preparing for a fresh parse.
*/
reset() {
this.robot = { name: "", links: {}, joints: [] };
}
/**
* Parse a URDF XML string and produce an IUrdfRobot object.
*
* @param data - A string containing valid URDF XML.
* @returns The fully populated IUrdfRobot, including colors, links, and joints.
* @throws If the root element is not <robot>.
*/
fromString(data: string): IUrdfRobot {
this.reset();
const dom = new window.DOMParser().parseFromString(data, "text/xml");
this.robot.elem = dom.documentElement;
return this.parseRobotXMLNode(dom.documentElement);
}
/**
* Internal helper: ensure the root node is <robot>, then parse its children.
*
* @param robotNode - The <robot> Element from the DOMParser.
* @returns The populated IUrdfRobot data structure.
* @throws If robotNode.nodeName !== "robot"
*/
private parseRobotXMLNode(robotNode: Element): IUrdfRobot {
if (robotNode.nodeName !== "robot") {
throw new Error(`Invalid URDF: no <robot> (found <${robotNode.nodeName}>)`);
}
this.robot.name = robotNode.getAttribute("name") || "";
this.parseColorsFromRobot(robotNode);
this.parseLinks(robotNode);
this.parseJoints(robotNode);
return this.robot;
}
/**
* Look at all <material> tags under <robot> and store their names → RGBA values.
*
* @param robotNode - The <robot> Element.
*/
private parseColorsFromRobot(robotNode: Element) {
const xmlMaterials = robotNode.getElementsByTagName("material");
for (let i = 0; i < xmlMaterials.length; i++) {
const matNode = xmlMaterials[i];
if (!matNode.hasAttribute("name")) {
console.warn("Found <material> with no name attribute");
continue;
}
const name = matNode.getAttribute("name")!;
const colorTags = matNode.getElementsByTagName("color");
if (colorTags.length === 0) continue;
const colorElem = colorTags[0];
if (!colorElem.hasAttribute("rgba")) continue;
// e.g. "0.06 0.4 0.1 1.0"
const rgba = rgbaFromString(colorElem) || [0, 0, 0, 1];
this.colors[name] = rgba;
}
}
/**
* Parse every <link> under <robot> and build an IUrdfLink entry containing:
* - name
* - arrays of IUrdfVisual for <visual> tags
* - arrays of IUrdfVisual for <collision> tags
* - a pointer to its original XML Element (elem)
*
* @param robotNode - The <robot> Element.
*/
private parseLinks(robotNode: Element) {
const xmlLinks = robotNode.getElementsByTagName("link");
for (let i = 0; i < xmlLinks.length; i++) {
const linkXml = xmlLinks[i];
if (!linkXml.hasAttribute("name")) {
console.error("Link without a name:", linkXml);
continue;
}
const linkName = linkXml.getAttribute("name")!;
const linkObj: IUrdfLink = {
name: linkName,
visual: [],
collision: [],
elem: linkXml
};
this.robot.links[linkName] = linkObj;
// Parse all <visual> children
const visualXmls = linkXml.getElementsByTagName("visual");
for (let j = 0; j < visualXmls.length; j++) {
linkObj.visual.push(this.parseVisual(visualXmls[j]));
}
// Parse all <collision> children (reuse parseVisual; color is ignored later)
const collXmls = linkXml.getElementsByTagName("collision");
for (let j = 0; j < collXmls.length; j++) {
linkObj.collision.push(this.parseVisual(collXmls[j]));
}
}
}
/**
* Parse a <visual> or <collision> element into an IUrdfVisual. Reads:
* - <geometry> (calls parseGeometry to extract mesh, cylinder, box, etc.)
* - <origin> (xyz, rpy)
* - <material> (either embedded <color> or named reference)
*
* @param node - The <visual> or <collision> Element.
* @returns A fully populated IUrdfVisual object.
*/
private parseVisual(node: Element): IUrdfVisual {
const visual: Partial<IUrdfVisual> = { elem: node };
for (let i = 0; i < node.childNodes.length; i++) {
const child = node.childNodes[i];
// Skip non-element nodes (like text nodes containing whitespace)
if (child.nodeType !== Node.ELEMENT_NODE) {
continue;
}
const childElement = child as Element;
switch (childElement.nodeName) {
case "geometry": {
this.parseGeometry(childElement, visual);
break;
}
case "origin": {
const pos = xyzFromString(childElement);
const rpy = rpyFromString(childElement);
if (pos) visual.origin_xyz = pos;
if (rpy) visual.origin_rpy = rpy;
break;
}
case "material": {
const cols = childElement.getElementsByTagName("color");
if (cols.length > 0 && cols[0].hasAttribute("rgba")) {
// Inline color specification
visual.color_rgba = rgbaFromString(cols[0])!;
} else if (childElement.hasAttribute("name")) {
// Named material → look up previously parsed RGBA
const nm = childElement.getAttribute("name")!;
visual.color_rgba = this.colors[nm];
}
break;
}
default: {
console.warn("Unknown child node:", childElement.nodeName);
break;
}
}
}
return visual as IUrdfVisual;
}
/**
* Parse a <geometry> element inside <visual> or <collision>.
* Currently only supports <mesh>. If you need <cylinder> or <box>,
* you can extend this function similarly.
*
* @param node - The <geometry> Element.
* @param visual - A partial IUrdfVisual object to populate
*/
private parseGeometry(node: Element, visual: Partial<IUrdfVisual>) {
for (let i = 0; i < node.childNodes.length; i++) {
const child = node.childNodes[i];
// Skip non-element nodes (like text nodes containing whitespace)
if (child.nodeType !== Node.ELEMENT_NODE) {
continue;
}
const childElement = child as Element;
if (childElement.nodeName === "mesh") {
const rawFilename = childElement.getAttribute("filename");
if (!rawFilename) {
console.warn("<mesh> missing filename!");
return;
}
// 1) Resolve the URL (handles "package://" or relative paths)
const resolvedUrl = this.resolveFilename(rawFilename);
// 2) Parse optional scale (e.g. "1 1 1")
let scale: [number, number, number] = [1, 1, 1];
if (childElement.hasAttribute("scale")) {
const parts = childElement.getAttribute("scale")!.split(" ").map(parseFloat);
if (parts.length === 3) {
scale = [parts[0], parts[1], parts[2]];
}
}
// 3) Deduce mesh type from file extension
const ext = resolvedUrl.slice(resolvedUrl.lastIndexOf(".") + 1).toLowerCase();
let type: "stl" | "fbx" | "obj" | "dae";
switch (ext) {
case "stl":
type = "stl";
break;
case "fbx":
type = "fbx";
break;
case "obj":
type = "obj";
break;
case "dae":
type = "dae";
break;
default:
throw new Error("Unknown mesh extension: " + ext);
}
visual.geometry = { filename: resolvedUrl, type, scale } as IUrdfMesh;
visual.type = "mesh";
return;
}
// If you also want <cylinder> or <box>, copy your previous logic here:
// e.g. if (childElement.nodeName === "cylinder") { … }
}
}
/**
* Transform a URI‐like string into an actual URL. Handles:
* 1) http(s):// or data: → leave unchanged
* 2) package://some_package/... → replace with prefix + "some_package/...
* 3) package:/some_package/... → same as above
* 4) Anything else (e.g. "meshes/Foo.stl") is treated as relative.
*
* @param raw - The raw filename from URDF (e.g. "meshes/Base.stl" or "package://my_pkg/mesh.dae")
* @returns The fully resolved URL string
*/
private resolveFilename(raw: string): string {
// 1) absolute http(s) or data URIs
if (/^https?:\/\//.test(raw) || raw.startsWith("data:")) {
return raw;
}
// 2) package://some_package/…
if (raw.startsWith("package://")) {
const rel = raw.substring("package://".length);
return this.joinUrl(this.prefix, rel);
}
// 3) package:/some_package/…
if (raw.startsWith("package:/")) {
const rel = raw.substring("package:/".length);
return this.joinUrl(this.prefix, rel);
}
// 4) anything else (e.g. "meshes/Foo.stl") is treated as relative
return this.joinUrl(this.prefix, raw);
}
/**
* Helper to join a base URL with a relative path, ensuring exactly one '/' in between
*
* @param base - e.g. "/robots/so_arm100/"
* @param rel - e.g. "meshes/Base.stl" (with or without a leading slash)
* @returns A string like "/robots/so_arm100/meshes/Base.stl"
*/
private joinUrl(base: string, rel: string): string {
if (!base.startsWith("/")) base = "/" + base;
if (!base.endsWith("/")) base = base + "/";
if (rel.startsWith("/")) rel = rel.substring(1);
return base + rel;
}
/**
* Parse every <joint> under <robot> and build an IUrdfJoint entry. For each joint:
* 1) parent link (lookup in `this.robot.links[parentName]`)
* 2) child link (lookup in `this.robot.links[childName]`)
* 3) origin: xyz + rpy
* 4) axis (default [0,0,1] if absent)
* 5) limit (if present, lower/upper/effort/velocity)
*
* @param robotNode - The <robot> Element.
* @throws If a joint references a link name that doesn't exist.
*/
private parseJoints(robotNode: Element) {
const links = this.robot.links;
const joints: IUrdfJoint[] = [];
this.robot.joints = joints;
const xmlJoints = robotNode.getElementsByTagName("joint");
for (let i = 0; i < xmlJoints.length; i++) {
const jointXml = xmlJoints[i];
const parentElems = jointXml.getElementsByTagName("parent");
const childElems = jointXml.getElementsByTagName("child");
if (parentElems.length !== 1 || childElems.length !== 1) {
console.warn("Joint without exactly one <parent> or <child>:", jointXml);
continue;
}
const parentName = parentElems[0].getAttribute("link")!;
const childName = childElems[0].getAttribute("link")!;
const parentLink = links[parentName];
const childLink = links[childName];
if (!parentLink || !childLink) {
throw new Error(`Joint references missing link: ${parentName} or ${childName}`);
}
// Default origin and rpy
let xyz: [number, number, number] = [0, 0, 0];
let rpy: [number, number, number] = [0, 0, 0];
const originTags = jointXml.getElementsByTagName("origin");
if (originTags.length === 1) {
xyz = xyzFromString(originTags[0]) || xyz;
rpy = rpyFromString(originTags[0]) || rpy;
}
// Default axis
let axis: [number, number, number] = [0, 0, 1];
const axisTags = jointXml.getElementsByTagName("axis");
if (axisTags.length === 1) {
axis = xyzFromString(axisTags[0]) || axis;
}
// Optional limit
let limit;
const limitTags = jointXml.getElementsByTagName("limit");
if (limitTags.length === 1) {
const lim = limitTags[0];
limit = {
lower: parseFloat(lim.getAttribute("lower") || "0"),
upper: parseFloat(lim.getAttribute("upper") || "0"),
effort: parseFloat(lim.getAttribute("effort") || "0"),
velocity: parseFloat(lim.getAttribute("velocity") || "0")
};
}
joints.push({
name: jointXml.getAttribute("name") || undefined,
type: jointXml.getAttribute("type") as
| "revolute"
| "continuous"
| "prismatic"
| "fixed"
| "floating"
| "planar",
origin_xyz: xyz,
origin_rpy: rpy,
axis_xyz: axis,
rotation: [0, 0, 0],
parent: parentLink,
child: childLink,
limit: limit,
elem: jointXml
});
}
}
/**
* If you ever want to re‐serialize the robot back to URDF XML,
* this method returns the stringified root <robot> element.
*
* @returns A string beginning with '<?xml version="1.0" ?>' followed by the current XML.
*/
getURDFXML(): string {
return this.robot.elem ? '<?xml version="1.0" ?>\n' + this.robot.elem.outerHTML : "";
}
}
/**
* ==============================================================================
* Example of how the parsed data (IUrdfRobot) maps from the URDF XML ("so_arm100"):
*
* {
* // The <robot> name attribute
* name: "so_arm100",
*
* // Materials/colors parsed from <material> tags
* colors: {
* "green": [0.06, 0.4, 0.1, 1.0],
* "black": [0.1, 0.1, 0.1, 1.0]
* },
*
* // Each <link> under <robot> becomes an entry in `links`
* links: {
* "Base": {
* name: "Base",
*
* // Array of visuals: each <visual> inside <link name="Base">
* visual: [
* {
* elem: // the <visual> Element object for Base,
* type: "mesh",
* geometry: {
* filename: "/robots/so_arm100/meshes/Base.stl",
* type: "stl",
* scale: [1, 1, 1]
* },
* origin_xyz: [0, 0, 0], // default since no <origin> in visual
* origin_rpy: [0, 0, 0], // default since no <origin> in visual
* color_rgba: [0.06, 0.4, 0.1, 1.0] // matches <material name="green">
* },
* {
* elem: // the second <visual> Element for Base,
* type: "mesh",
* geometry: {
* filename: "/robots/so_arm100/meshes/Base_Motor.stl",
* type: "stl",
* scale: [1, 1, 1]
* },
* origin_xyz: [0, 0, 0],
* origin_rpy: [0, 0, 0],
* color_rgba: [0.1, 0.1, 0.1, 1.0] // matches <material name="black">
* }
* ],
*
* // Array of collisions: each <collision> inside <link name="Base">
* collision: [
* {
* elem: // the <collision> Element for Base,
* type: "mesh",
* geometry: {
* filename: "/robots/so_arm100/meshes/Base.stl",
* type: "stl",
* scale: [1, 1, 1]
* },
* origin_xyz: [0, 0, 0],
* origin_rpy: [0, 0, 0]
* // no color for collisions
* }
* ]
* },
*
* // ... other links (e.g. "Rotation_Pitch", "Upper_Arm", etc.) follow the same structure
* },
*
* // Each <joint> under <robot> becomes an entry in `joints` array
* joints: [
* {
* name: "Rotation",
* type: "revolute",
* origin_xyz: [0, -0.0452, 0.0165],
* origin_rpy: [1.57079, 0, 0],
* axis_xyz: [0, -1, 0],
* rotation: [0, 0, 0], // runtime placeholder
* parent: // reference to links["Base"],
* child: // reference to links["Rotation_Pitch"],
* limit: {
* lower: -2,
* upper: 2,
* effort: 35,
* velocity: 1
* },
* elem: // the <joint name="Rotation"> Element object
* },
*
* {
* name: "Pitch",
* type: "revolute",
* origin_xyz: [0, 0.1025, 0.0306],
* origin_rpy: [-1.8, 0, 0],
* axis_xyz: [1, 0, 0],
* rotation: [0, 0, 0],
* parent: // reference to links["Rotation_Pitch"],
* child: // reference to links["Upper_Arm"],
* limit: {
* lower: 0,
* upper: 3.5,
* effort: 35,
* velocity: 1
* },
* elem: // the <joint name="Pitch"> Element object
* },
*
* // ... additional joints ("Elbow", "Wrist_Pitch", "Wrist_Roll", "Jaw") follow similarly
* ]
* }
*
* ==============================================================================
*/