// 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 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 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 { 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 . */ 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 , then parse its children. * * @param robotNode - The 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 (found <${robotNode.nodeName}>)`); } this.robot.name = robotNode.getAttribute("name") || ""; this.parseColorsFromRobot(robotNode); this.parseLinks(robotNode); this.parseJoints(robotNode); return this.robot; } /** * Look at all tags under and store their names → RGBA values. * * @param robotNode - The 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 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 under and build an IUrdfLink entry containing: * - name * - arrays of IUrdfVisual for tags * - arrays of IUrdfVisual for tags * - a pointer to its original XML Element (elem) * * @param robotNode - The 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 children const visualXmls = linkXml.getElementsByTagName("visual"); for (let j = 0; j < visualXmls.length; j++) { linkObj.visual.push(this.parseVisual(visualXmls[j])); } // Parse all 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 or element into an IUrdfVisual. Reads: * - (calls parseGeometry to extract mesh, cylinder, box, etc.) * - (xyz, rpy) * - (either embedded or named reference) * * @param node - The or Element. * @returns A fully populated IUrdfVisual object. */ private parseVisual(node: Element): IUrdfVisual { const visual: Partial = { 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 element inside or . * Currently only supports . If you need or , * you can extend this function similarly. * * @param node - The Element. * @param visual - A partial IUrdfVisual object to populate */ private parseGeometry(node: Element, visual: Partial) { 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(" 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 or , 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 under 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 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 or :", 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 element. * * @returns A string beginning with '' followed by the current XML. */ getURDFXML(): string { return this.robot.elem ? '\n' + this.robot.elem.outerHTML : ""; } } /** * ============================================================================== * Example of how the parsed data (IUrdfRobot) maps from the URDF XML ("so_arm100"): * * { * // The name attribute * name: "so_arm100", * * // Materials/colors parsed from tags * colors: { * "green": [0.06, 0.4, 0.1, 1.0], * "black": [0.1, 0.1, 0.1, 1.0] * }, * * // Each under becomes an entry in `links` * links: { * "Base": { * name: "Base", * * // Array of visuals: each inside * visual: [ * { * elem: // the 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 in visual * origin_rpy: [0, 0, 0], // default since no in visual * color_rgba: [0.06, 0.4, 0.1, 1.0] // matches * }, * { * elem: // the second 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 * } * ], * * // Array of collisions: each inside * collision: [ * { * elem: // the 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 under 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 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 Element object * }, * * // ... additional joints ("Elbow", "Wrist_Pitch", "Wrist_Roll", "Jaw") follow similarly * ] * } * * ============================================================================== */