diff --git a/ros_robot_visualiser b/ros_robot_visualiser
deleted file mode 160000
index 40905cb..0000000
--- a/ros_robot_visualiser
+++ /dev/null
@@ -1 +0,0 @@
-Subproject commit 40905cbb36a784c7f86bd5966418d263e8143525
diff --git a/ros_robot_visualiser/JointVisualiser.js b/ros_robot_visualiser/JointVisualiser.js
new file mode 100644
index 0000000..0ad7f42
--- /dev/null
+++ b/ros_robot_visualiser/JointVisualiser.js
@@ -0,0 +1,72 @@
+import * as THREE from 'three';
+
+export function createRotationSector(axis, lower, upper, encoderRange, radius = 0.1, segments = 32) {
+ const motorMargin = 0.4;
+ const motorLower = -encoderRange/2 * (Math.PI / 180);// -Math.PI/2;//lower - motorMargin;
+ const motorUpper = encoderRange/2 * (Math.PI / 180);//Math.PI/2;//upper + motorMargin;
+
+ const redMaterial = new THREE.MeshBasicMaterial({ color: 0xff0000, transparent: true, opacity: 0.2, side: THREE.DoubleSide, depthTest: false, depthWrite: false });
+ const whiteMaterial = new THREE.MeshBasicMaterial({ color: 0xffffff, transparent: true, opacity: 0.6, side: THREE.DoubleSide, depthTest: false, depthWrite: false });
+
+ function createArc(start, end, material) {
+ const shape = new THREE.Shape();
+ shape.moveTo(0, 0);
+ for (let i = 0; i <= segments; i++) {
+ const angle = start + (end - start) * (i / segments);
+ shape.lineTo(Math.cos(angle) * radius, Math.sin(angle) * radius);
+ }
+ shape.lineTo(0, 0);
+ return new THREE.Mesh(new THREE.ShapeGeometry(shape), material);
+ }
+
+ const redLeft = createArc(motorLower, lower, redMaterial);
+ const whiteArc = createArc(lower, upper, whiteMaterial);
+ const redRight = createArc(upper, motorUpper, redMaterial);
+
+ const up = new THREE.Vector3(0, 0, 1);
+ const quaternion = new THREE.Quaternion().setFromUnitVectors(up, axis.clone().normalize());
+ const basis = new THREE.Matrix4().makeRotationFromQuaternion(quaternion);
+
+ redLeft.applyMatrix4(basis);
+ whiteArc.applyMatrix4(basis);
+ redRight.applyMatrix4(basis);
+
+ const group = new THREE.Group();
+ group.add(redLeft, whiteArc, redRight);
+ return group;
+}
+
+export function createAngleIndicator(axis, angle, radius = 0.1) {
+ const dir = new THREE.Vector3(Math.cos(angle), Math.sin(angle), 0).multiplyScalar(radius);
+ const geometry = new THREE.BufferGeometry().setFromPoints([new THREE.Vector3(0, 0, 0), dir]);
+ const material = new THREE.LineBasicMaterial({ color: 0xffffff, depthTest: false, depthWrite: false });
+ const line = new THREE.Line(geometry, material);
+
+ const up = new THREE.Vector3(0, 0, 1);
+ const quaternion = new THREE.Quaternion().setFromUnitVectors(up, axis.clone().normalize());
+ line.applyQuaternion(quaternion);
+
+ return line;
+}
+
+export function createJointLabel(name, angle) {
+ const canvas = document.createElement('canvas');
+ canvas.width = 256;
+ canvas.height = 64;
+ const ctx = canvas.getContext('2d');
+
+ ctx.fillStyle = 'rgba(0,0,0,0.6)';
+ ctx.fillRect(0, 0, canvas.width, canvas.height);
+
+ ctx.font = '20px monospace';
+ ctx.fillStyle = '#ffffff';
+ ctx.fillText(`${name}: ${(angle * 180 / Math.PI).toFixed(1)}°`, 10, 40);
+
+ const texture = new THREE.CanvasTexture(canvas);
+ const material = new THREE.SpriteMaterial({ map: texture, transparent: true });
+ const sprite = new THREE.Sprite(material);
+ sprite.scale.set(0.5, 0.125, 1); // adjust size
+
+ return sprite;
+}
+
diff --git a/ros_robot_visualiser/URDFEditor.js b/ros_robot_visualiser/URDFEditor.js
new file mode 100644
index 0000000..9cbc85a
--- /dev/null
+++ b/ros_robot_visualiser/URDFEditor.js
@@ -0,0 +1,468 @@
+import * as THREE from 'three';
+import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js';
+//import URDFLoader from './urdf/ExtendedURDFLoader.js';
+import { createRotationSector, createAngleIndicator, createJointLabel } from './JointVisualiser.js';
+import ExtendedURDFLoader from './urdf/ExtendedURDFLoader.js';
+import { OBJLoader } from 'three/examples/jsm/loaders/OBJLoader.js';
+import { MTLLoader } from 'three/examples/jsm/loaders/MTLLoader.js';
+import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js';
+import { ViewerOverlay } from './ViewerOverlay.js';
+const gltfLoader = new GLTFLoader();
+
+
+export class URDFEditor {
+ constructor(canvas) {
+ this.canvas = canvas;
+ console.log(canvas);
+ this.scene = new THREE.Scene();
+ this.scene.background = new THREE.Color(0xaaaaaa);
+
+ this.camera = new THREE.PerspectiveCamera(60, canvas.clientWidth / canvas.clientHeight, 0.1, 100);
+ this.camera.position.set(-0.3, 0.5, -0.7);
+ this.camera.lookAt(0, 0, 0);
+
+ this.renderer = new THREE.WebGLRenderer({ canvas, antialias: true });
+ this.renderer.setSize(canvas.clientWidth, canvas.clientHeight);
+
+
+
+ this.controls = new OrbitControls(this.camera, this.renderer.domElement);
+ this.controls.target.set(0, 0.1, 0);
+ this.controls.update();
+
+ this.raycaster = new THREE.Raycaster();
+ this.mouse = new THREE.Vector2();
+
+ this.hoveredJoint = null;
+ this.draggedJoint = null;
+ this.worldAxis = null;
+ this.lastX = null;
+ this.isDragging = false;
+ this.jointAngles = {};
+
+ this.loader = new ExtendedURDFLoader();
+ this.loader.loadMeshCb = (path, manager, onComplete) => {
+ if (path.endsWith('.glb') || path.endsWith('.gltf')) {
+ const gltfLoader = new GLTFLoader(manager);
+ gltfLoader.load(path, (gltf) => {
+ onComplete(gltf.scene);
+ });
+ } else {
+ // fall back to default behavior (STL, DAE)
+ URDFLoader.prototype.loadMeshCb.call(this.loader, path, manager, onComplete);
+ }
+ };
+
+ this.loader.packages = {
+ 'Little_Sophia_Face': '/robots/LittleSophia'
+ };
+ this.loader.parseVisual = true;
+ this.loader.parseCollision = false;
+
+
+ this.setupScene();
+ //this.loadURDF();
+ this.loadURDFFromIndexedDB();
+ this.setupEvents();
+
+ this.overlay = new ViewerOverlay(
+ this.renderer,
+ this.robot,
+ this.jointAngles,
+ this.findObjectByName.bind(this),
+ this.saveURDFToIndexedDB.bind(this),
+ this.loadURDFFromIndexedDB.bind(this)
+ );
+
+
+ // Add some lights (optional if using MeshBasicMaterial)
+ const ambient = new THREE.AmbientLight(0x404040);
+ this.scene.add(ambient);
+ const dirLight = new THREE.DirectionalLight(0xffffff, 1);
+ dirLight.position.set(5, 5, 5);
+ this.scene.add(dirLight);
+
+ // this.mesh = null;
+ // gltfLoader.load('meshes/Little_Sophia.glb', (gltf) => {
+ // const model = gltf.scene;
+ // model.scale.set(0.001, 0.001, 0.001); // adjust if needed
+ // model.rotation.y = Math.PI; // radians (180°)
+ // this.scene.add(model);
+
+ // // Optional: fit camera to model
+ // const box = new THREE.Box3().setFromObject(model);
+ // const center = box.getCenter(new THREE.Vector3());
+ // const size = box.getSize(new THREE.Vector3()).length();
+ // });
+
+
+ this.animate();
+ }
+
+ setupScene() {
+ this.scene.add(new THREE.AmbientLight(0xffffff, 0.6));
+ const light1 = new THREE.DirectionalLight(0xffffff, 0.8);
+ light1.position.set(15, 10, 7);
+ this.scene.add(light1);
+
+ const light2 = new THREE.DirectionalLight(0xffffff, 0.8);
+ light2.position.set(-15, 10, -7);
+ this.scene.add(light2);
+
+ this.scene.add(new THREE.AxesHelper(1));
+ this.scene.add(new THREE.GridHelper(2, 10));
+
+
+ }
+
+
+ async loadURDF() {
+ this.urdfPath = '/robots/LittleSophia/urdf/LittleSophia.urdf';
+
+ const urdfText = await fetch(this.urdfPath).then(res => res.text());
+ const robot = await this.loader.loadFromString(urdfText);
+ //addOriginMarkers(robot);
+ robot.rotation.x = -Math.PI / 2;
+ this.scene.add(robot);
+ this.robot = robot;
+
+ //this.overlay.robot = robot;
+ this.overlay.init(robot);
+
+ }
+
+ async loadURDFFromIndexedDB() {
+ return new Promise((resolve, reject) => {
+ const request = indexedDB.open("URDFEditorDB", 1);
+
+ request.onerror = e => reject(e);
+
+ request.onsuccess = e => {
+ const db = e.target.result;
+ const tx = db.transaction("urdfs", "readonly");
+ const getReq = tx.objectStore("urdfs").get("current");
+
+ getReq.onsuccess = async () => {
+ const urdfText = getReq.result;
+ if (!urdfText) {
+ reject("No URDF stored in IndexedDB");
+ return;
+ }
+
+ try {
+ // ✅ feed the text into your loader
+ const robot = await this.loader.loadFromString(urdfText);
+ robot.rotation.x = -Math.PI / 2;
+ this.scene.add(robot);
+ this.robot = robot;
+ this.overlay.init(robot);
+ this.saveURDFToIndexedDB(robot);
+ resolve(robot);
+ } catch (err) {
+ reject(err);
+ }
+ };
+ };
+ });
+ }
+
+ // WRITE TO URDF POSITION IN ROBOT
+ // Find the transmission node for neck_roll
+ // const neckTrans = this.robot.urdfRobotNode.querySelector('transmission[name="neck_roll_trans"]');
+ // if (neckTrans) {
+ // const actuator = neckTrans.querySelector('actuator[name="neck_roll"]');
+ // if (actuator) {
+ // // Example: change encoderValidMin/Max
+ // actuator.querySelector('encoderValidMin').textContent = "100";
+ // actuator.querySelector('encoderValidMax').textContent = "3900";
+ // }
+ // }
+
+ downloadURDF(robot, filename = "robot.urdf") {
+ const serializer = new XMLSerializer();
+ const urdfString = serializer.serializeToString(robot.urdfRobotNode);
+
+ const a = document.createElement("a");
+ a.href = "data:text/xml;charset=utf-8," + encodeURIComponent(urdfString);
+ a.download = filename;
+ a.click();
+ }
+
+ saveURDFToIndexedDB(robot) {
+ const serializer = new XMLSerializer();
+ const urdfString = serializer.serializeToString(robot.urdfRobotNode);
+
+ const request = indexedDB.open("URDFEditorDB", 1);
+ request.onupgradeneeded = e => {
+ const db = e.target.result;
+ db.createObjectStore("urdfs");
+ };
+ request.onsuccess = e => {
+ const db = e.target.result;
+ const tx = db.transaction("urdfs", "readwrite");
+ tx.objectStore("urdfs").put(urdfString, "current");
+ console.log("saved URDF to IndexedDB");
+ };
+
+ }
+
+ listUrdfsFromIndexedDB() {
+ const request = indexedDB.open("URDFEditorDB", 1);
+
+ request.onsuccess = e => {
+ const db = e.target.result;
+ const tx = db.transaction("urdfs", "readonly");
+ const store = tx.objectStore("urdfs");
+ const getAllReq = store.getAll();
+
+ getAllReq.onsuccess = () => {
+ console.log("Stored URDFs:", getAllReq.result);
+ };
+ };
+ }
+
+
+ findObjectByName(root, name) {
+ let result = null;
+ root.traverse(child => {
+ if (child.name === name) {
+ result = child;
+ }
+ });
+ return result;
+ }
+
+
+ setupEvents() {
+ this.canvas.addEventListener('pointermove', this.onPointerMove.bind(this));
+ this.canvas.addEventListener('pointerdown', this.onPointerDown.bind(this));
+ this.canvas.addEventListener('pointerup', this.onPointerUp.bind(this));
+ }
+
+ onPointerMove(event) {
+ const rect = this.canvas.getBoundingClientRect();
+
+ this.mouse.x = ((event.clientX - rect.left) / rect.width) * 2 - 1;
+ this.mouse.y = -((event.clientY - rect.top) / rect.height) * 2 + 1;
+
+ this.raycaster.setFromCamera(this.mouse, this.camera);
+
+ if (!this.robot) return;
+ const intersects = this.raycaster.intersectObjects(this.robot.children, true);
+
+ if (this.isDragging && this.draggedJoint && this.worldAxis) {
+ const deltaX = event.clientX - this.lastX;
+ this.lastX = event.clientX;
+ const angleDelta = deltaX * 0.005;
+
+ let jointName = this.draggedJoint.name;
+ const { lower, upper } = getJointLimits(jointName, this.robot);
+
+ const currentAngle = this.jointAngles[jointName] ?? 0;
+ const proposedAngle = currentAngle + angleDelta;
+ const clampedAngle = Math.max(lower, Math.min(upper, proposedAngle));
+ const actualDelta = clampedAngle - currentAngle;
+
+ // ✅ Rotate dragged joint
+ this.draggedJoint.rotateOnAxis(this.worldAxis, actualDelta);
+ this.jointAngles[jointName] = clampedAngle;
+ //console.log(jointName);
+ // ✅ Apply mimic relationships automatically
+ // ✅ Enforce mimics
+ for (const [name, jointObj] of Object.entries(this.robot.joints)) {
+ if (jointObj.type === 'URDFMimicJoint') {
+ const { mimicJoint, multiplier, offset } = jointObj;
+ if (mimicJoint === jointName) {
+ const mimicAngle = clampedAngle * multiplier + offset;
+ const { lower, upper } = getJointLimits(name, this.robot);
+ const mimicClamped = Math.max(lower, Math.min(upper, mimicAngle));
+ const mimicDelta = mimicClamped - (this.jointAngles[name] ?? 0);
+
+ const mimicNode = this.robot.getObjectByName(name);
+ mimicNode.rotateOnAxis(this.worldAxis, mimicDelta);
+ this.jointAngles[name] = mimicClamped;
+ }
+ }
+ }
+
+ // ✅ Update gizmo indicator
+ const gizmo = this.draggedJoint.userData.gizmo;
+ if (gizmo?.indicator) this.draggedJoint.parent.remove(gizmo.indicator);
+ const newIndicator = createAngleIndicator(this.worldAxis, clampedAngle);
+ newIndicator.position.copy(this.draggedJoint.position);
+ this.draggedJoint.parent.add(newIndicator);
+ this.draggedJoint.userData.gizmo.indicator = newIndicator;
+ }
+
+
+
+ // Hover highlighting
+ if (intersects.length > 0) {
+ const target = intersects[0].object;
+ if (this.hoveredJoint?.material?.emissive) {
+ this.hoveredJoint.material.emissive.setHex(0x000000);
+ }
+ this.hoveredJoint = target;
+ if (this.hoveredJoint.material?.emissive) {
+ this.hoveredJoint.material.emissive.setHex(0x333333);
+ }
+ } else {
+ if (this.hoveredJoint?.material?.emissive) {
+ this.hoveredJoint.material.emissive.setHex(0x000000);
+ }
+ this.hoveredJoint = null;
+ }
+ }
+
+
+ onPointerDown(event) {
+ if (!this.hoveredJoint) return;
+
+ this.isDragging = true;
+ this.lastX = event.clientX;
+ this.controls.enabled = false;
+
+ // Decide how far up the chain to go
+ let levelsUp = 0; // default: climb one level from link → joint
+ if (event.ctrlKey) levelsUp = 1;
+ if (event.altKey) levelsUp = 2;
+
+ this.draggedJoint = this.findJointAncestor(this.hoveredJoint, levelsUp);
+ if (!this.draggedJoint) return;
+
+ // ✅ Skip mimic joints
+ if (this.draggedJoint.type === 'URDFMimicJoint') {
+ this.draggedJoint = findJointAncestor(this.draggedJoint, 1);
+ }
+
+
+ const jointName = this.draggedJoint.name;
+ const jointData = this.robot.joints?.[jointName];
+ if (!(jointData?.axis instanceof THREE.Vector3)) return;
+
+ const axis = jointData.axis.clone();
+ if (axis.lengthSq() === 0) return;
+ this.worldAxis = axis.normalize();
+
+ const { lower, upper } = getJointLimits(jointName, this.robot);
+ const transmission = jointData.transmission;
+ const encoderRange = transmission
+ ? transmission.encoderRange / transmission.mechanicalReduction
+ : Math.PI;
+
+ const sector = createRotationSector(this.worldAxis, lower, upper, encoderRange);
+ const indicator = createAngleIndicator(this.worldAxis, this.jointAngles[jointName] ?? 0);
+
+ sector.position.copy(this.draggedJoint.position);
+ indicator.position.copy(this.draggedJoint.position);
+
+ this.draggedJoint.parent.add(sector);
+ this.draggedJoint.parent.add(indicator);
+ this.draggedJoint.userData.gizmo = { sector, indicator };
+ }
+
+
+
+ onPointerUp() {
+ this.isDragging = false;
+ this.controls.enabled = true;
+
+ const gizmo = this.draggedJoint?.userData.gizmo;
+ if (gizmo) {
+ this.draggedJoint.parent.remove(gizmo.sector);
+ this.draggedJoint.parent.remove(gizmo.indicator);
+ }
+
+ this.draggedJoint = null;
+ this.worldAxis = null;
+ }
+
+ findJointAncestor(joint, levelsUp = 0) {
+ let current = joint;
+ let steps = 0;
+
+ while (current && steps <= levelsUp) {
+ current = current.parent;
+ // climb until we find a URDFJoint (not a mimic)
+ while (current && !(current.type === 'URDFJoint')) {
+ current = current.parent;
+ }
+ steps++;
+ }
+
+ return current;
+ }
+
+
+ animate() {
+ requestAnimationFrame(() => this.animate());
+ //console.log(this.robot.joints)
+ this.renderer.render(this.scene, this.camera);
+ this.overlay.draw();
+ //console.log(this.mesh);
+ }
+}
+
+function ticksToRadians(ticks, actuator) {
+ const ticksPerDeg = actuator.encoderTicks / actuator.encoderRange; // ≈ 22.75
+ const centered = ticks - actuator.encoderTicks / 2; // shift so mid = 0
+ const actuatorDeg = centered / ticksPerDeg;
+ const jointDeg = actuatorDeg / actuator.mechanicalReduction;
+ return jointDeg * (Math.PI / 180); // convert degrees → radians
+}
+
+function radiansToTicks(radians, actuator) {
+ const ticksPerDeg = actuator.encoderTicks / actuator.encoderRange; // ≈ 22.75
+ const jointDeg = radians * (180 / Math.PI); // radians → degrees
+ const actuatorDeg = jointDeg * actuator.mechanicalReduction; // apply reduction
+ const centered = actuatorDeg * ticksPerDeg; // convert to ticks offset from mid
+ const ticks = centered + actuator.encoderTicks / 2; // shift back to full range
+ return ticks;
+}
+
+
+function getJointLimits(jointName, robot) {
+ const transmission = robot.joints[jointName].transmission;
+
+ if (!transmission) {
+ return { lower: -Math.PI, upper: Math.PI };
+ }
+ //console.log(transmission.encoderValidMin, transmission.encoderValidMax);
+ const lowerRad = ticksToRadians(transmission.encoderValidMin, transmission);
+ const upperRad = ticksToRadians(transmission.encoderValidMax, transmission);
+ //console.log(lowerRad, upperRad);
+ return { lower: lowerRad, upper: upperRad };
+}
+
+function getJointTransmission(jointName, robot) {
+ return robot.joints[jointName].transmission;
+}
+
+function loadObjWithMtl(objPath, mtlPath, onLoad) {
+ const mtlLoader = new MTLLoader();
+ mtlLoader.load(mtlPath, (materials) => {
+ materials.preload();
+ const objLoader = new OBJLoader();
+ objLoader.setMaterials(materials);
+ objLoader.load(objPath, (object) => {
+ onLoad(object);
+ });
+ });
+}
+
+function addOriginMarkers(robot) {
+ console.log(robot.joints);
+ for (const [name, joint] of Object.entries(robot.joints)) {
+ const marker = new THREE.Mesh(
+ new THREE.SphereGeometry(0.01),
+ new THREE.MeshBasicMaterial({ color: 0x00ff00 })
+ );
+ joint.add(marker);
+
+ const axes = new THREE.AxesHelper(0.05);
+ joint.add(axes);
+
+ console.log("Added marker for joint:", name);
+ }
+
+}
diff --git a/ros_robot_visualiser/ViewerOverlay.js b/ros_robot_visualiser/ViewerOverlay.js
new file mode 100644
index 0000000..6ff86dd
--- /dev/null
+++ b/ros_robot_visualiser/ViewerOverlay.js
@@ -0,0 +1,296 @@
+import { Button, Panel, Textbox } from './ui/canvasui.js';
+
+export class ViewerOverlay {
+ constructor(renderer, robot, jointAngles, findObjectByName, saveURDF, loadURDF) {
+ this.renderer = renderer;
+ this.robot = robot;
+ this.jointAngles = jointAngles;
+ this.findObjectByName = findObjectByName;
+ this.saveURDF = saveURDF; // 🔑 bound to editor
+ this.loadURDF = loadURDF; // 🔑 bound to editor
+
+
+ this.overlayCanvas = document.getElementById('overlay-canvas');
+ this.overlayCtx = this.overlayCanvas.getContext('2d');
+
+ this.uiElements = []; // for motor list items if you wrap them in UIElements
+ this.panels = []; // for modals or floating panels
+
+
+ // this.motorListPanel = this.createMotorListPanel();
+ // this.panels.push(this.motorListPanel);
+ // console.log(this.panels);
+
+ this.showMotorList = true;
+ this.showConfigModal = false;
+ this.configMotor = null;
+ this.configMotorName = null;
+
+ // Handle resizing
+ const resizeOverlay = () => {
+ const { clientWidth, clientHeight } = this.renderer.domElement;
+ this.overlayCanvas.width = clientWidth;
+ this.overlayCanvas.height = clientHeight;
+ };
+ window.addEventListener('resize', resizeOverlay);
+ resizeOverlay();
+
+ this.panels.push(this.createSystemPanel());
+ const handlePointerEvent = (event) => {
+ const rect = this.overlayCanvas.getBoundingClientRect();
+ const x = event.clientX - rect.left;
+ const y = event.clientY - rect.top;
+
+ let consumed = false;
+
+ // Panels get first chance
+ for (const panel of this.panels) {
+ if (panel.contains(x, y)) {
+ panel.handlePointerEvent(event, x, y);
+ consumed = true;
+ break;
+ }
+ }
+
+
+
+ if (!consumed) {
+ const evt = new event.constructor(event.type, event);
+ this.renderer.domElement.dispatchEvent(evt);
+ }
+
+ if (['pointermove', 'pointerdown', 'pointerup'].includes(event.type)) {
+ this.draw();
+ }
+ };
+ window.addEventListener('keydown', (event) => {
+ this.panels.forEach(panel => panel.handleKeyEvent(event));
+ this.draw();
+ });
+
+
+
+
+ // Attach handlers
+ ['click', 'pointerdown', 'pointerup', 'pointermove', 'wheel'].forEach(type => {
+ this.overlayCanvas.addEventListener(type, handlePointerEvent);
+ });
+ }
+
+ init(robot) {
+ this.robot = robot;
+ this.motorListPanel = this.createMotorListPanel(this.overlayCtx);
+ if (this.motorListPanel) {
+ this.panels.push(this.motorListPanel);
+ }
+ this.draw();
+ }
+
+ draw() {
+ const ctx = this.overlayCtx;
+ ctx.clearRect(0, 0, this.overlayCanvas.width, this.overlayCanvas.height);
+ this.updateMotorLabels();
+ this.panels.forEach(panel => panel.draw(ctx));
+
+ //console.log(this.panels);
+ }
+
+ createSystemPanel() {
+ const w = 160;
+ const h = 80;
+ const x = this.overlayCanvas.width - w - 10; // bottom right
+ const y = this.overlayCanvas.height - h - 10;
+ console.log(this.overlayCanvas.width, this.overlayCanvas.height);
+
+ const panel = new Panel(x, y, w, h, "System");
+
+ // Save button
+ panel.addElement(new Button(x + 20, y + 20, 120, 24, "Save", () => {
+ // delegate to editor’s save
+ this.saveURDF(this.robot);
+ }));
+
+ // Load button
+ panel.addElement(new Button(x + 20, y + 50, 120, 24, "Load", () => {
+ // delegate to editor’s load
+ this.loadURDF();
+ }));
+
+ return panel;
+ }
+
+
+ createMotorListPanel() {
+ if (!this.robot?.joints) return null;
+ this.panels = this.panels.filter(p => !p.title?.startsWith("Config:"));
+ const panel = new Panel(0, 0, 300, this.overlayCanvas.height, "Motors");
+
+ let y = 10;
+ for (const jointName in this.robot.joints) {
+ const jointData = this.robot.joints[jointName];
+ const jointObject = this.findObjectByName(this.robot, jointName);
+ if (!jointData || !jointObject) continue;
+
+ const angle = this.jointAngles?.[jointName] ?? 0;
+ const degrees = (angle * 180 / Math.PI).toFixed(1);
+
+ const motorName = jointData.transmission?.actuatorName || '(no motor)';
+ if (motorName === "(no motor)") continue;
+
+ // Build label string with angle/ticks
+ const t = jointData.transmission;
+ let label = `${motorName} ${degrees}°`;
+ if (t) {
+ const ticks = radiansToTicks(angle, t).toFixed(0);
+ label += ` ${ticks}`;
+ }
+
+
+ // Create button
+ const btn = new Button(10, y + 20, 280, 18, label, () => {
+ this.openMotorConfig(jointName, motorName);
+ });
+
+ btn.jointName = jointName;
+ btn.motorName = motorName;
+
+
+ panel.addElement(btn);
+ y += 22;
+ }
+
+ return panel;
+ }
+
+ updateMotorLabels() {
+ const motorPanel = this.panels.find(p => p.title === "Motors");
+ if (!motorPanel || !this.robot?.joints) return;
+
+ for (const element of motorPanel.elements) {
+ if (!(element instanceof Button) || !element.jointName) continue;
+
+ const jointName = element.jointName;
+ const jointData = this.robot.joints[jointName];
+ if (!jointData) continue;
+
+ const angle = this.jointAngles?.[jointName] ?? 0;
+ const degrees = (angle * 180 / Math.PI).toFixed(1);
+
+ const motorName = jointData.transmission?.actuatorName || '(no motor)';
+ if (motorName === "(no motor)") continue;
+
+ let label = `${motorName} ${degrees}°`;
+ const t = jointData.transmission;
+ if (t) {
+ const ticks = radiansToTicks(angle, t).toFixed(0);
+ label += ` ${ticks}`;
+ }
+
+ element.label = label; // 🔄 update text
+ }
+ }
+
+
+ openMotorConfig(jointName, motorName) {
+
+ const motor = this.robot.joints[jointName];
+
+ // Remove any existing config panel
+ this.panels = this.panels.filter(p => !p.title?.startsWith("Config:"));
+
+ const panel = new Panel(400, 50, 400, 250, `Config: ${motorName}`);
+
+ const motorIdBox = new Textbox(panel.x + 100, panel.y + 45, 80, 24, motor.transmission.motorID || '');
+ panel.addElement(motorIdBox);
+
+ panel.addElement(new Button(panel.x + 20, panel.y + 150, 80, 24, 'Save', () => {
+ const idValue = parseInt(motorIdBox.value, 10) || 0;
+ motor.motorId = idValue;
+ motor.transmission.motorID = idValue;
+ this.addOrUpdateActuatorElement(motorName, "motorID", idValue);
+ this.panels = this.panels.filter(p => p !== panel); // close panel
+ this.draw();
+ }));
+
+ panel.addElement(new Button(panel.x + 120, panel.y + 150, 80, 24, 'Close', () => {
+ this.panels = this.panels.filter(p => p !== panel);
+ this.draw();
+ }));
+
+ this.panels.push(panel);
+ this.draw();
+ }
+
+
+ /**
+ * Ensure an element exists under a given actuator and set its text.
+ * @param {string} transmissionName - The transmission name
+ * @param {string} actuatorName - The actuator name
+ * @param {string} elementName - The child element to add/update (e.g. "motorID")
+ * @param {string|number} value - The value to set
+ */
+ addOrUpdateActuatorElement(actuatorName, elementName, value) {
+ console.log("Updating URDF:", actuatorName, elementName, value);
+ const urdfNode = this.robot?.urdfRobotNode;
+ if (!urdfNode) return;
+
+ // find actuator anywhere in the URDF
+ const actuatorNode = urdfNode.querySelector(`actuator[name="${actuatorName}"]`);
+ if (!actuatorNode) return;
+
+ // find or create the child element
+ let childNode = actuatorNode.querySelector(elementName);
+ if (!childNode) {
+ childNode = urdfNode.ownerDocument.createElement(elementName);
+ actuatorNode.appendChild(childNode);
+ }
+ childNode.textContent = value.toString();
+
+
+ }
+
+
+
+
+
+
+ onClick(event) {
+ console.log("CLICK");
+ const rect = this.overlayCanvas.getBoundingClientRect();
+ const x = event.clientX - rect.left;
+ const y = event.clientY - rect.top;
+
+ for (const hb of this.motorHitboxes) {
+ if (x >= hb.x && x <= hb.x + hb.w &&
+ y >= hb.y && y <= hb.y + hb.h) {
+ this.openMotorConfig(hb.jointName, hb.motorName);
+ break;
+ }
+ }
+ }
+
+ // openMotorConfig(jointName, motorName) {
+ // this.showConfigModal = true;
+ // this.configMotorName = motorName;
+ // this.configMotor = this.robot.joints[jointName];
+ // this.draw();
+ // }
+}
+
+
+function ticksToRadians(ticks, actuator) {
+ const ticksPerDeg = actuator.encoderTicks / actuator.encoderRange; // ≈ 22.75
+ const centered = ticks - actuator.encoderTicks / 2; // shift so mid = 0
+ const actuatorDeg = centered / ticksPerDeg;
+ const jointDeg = actuatorDeg / actuator.mechanicalReduction;
+ return jointDeg * (Math.PI / 180); // convert degrees → radians
+}
+
+function radiansToTicks(radians, actuator) {
+ const ticksPerDeg = actuator.encoderTicks / actuator.encoderRange; // ≈ 22.75
+ const jointDeg = radians * (180 / Math.PI); // radians → degrees
+ const actuatorDeg = jointDeg * actuator.mechanicalReduction; // apply reduction
+ const centered = actuatorDeg * ticksPerDeg; // convert to ticks offset from mid
+ const ticks = centered + actuator.encoderTicks / 2; // shift back to full range
+ return ticks;
+}
\ No newline at end of file
diff --git a/ros_robot_visualiser/index.html b/ros_robot_visualiser/index.html
new file mode 100644
index 0000000..bff5c68
--- /dev/null
+++ b/ros_robot_visualiser/index.html
@@ -0,0 +1,42 @@
+
+
+
+
+
+ URDF Robot Viewer
+
+
+
+
+ Loading URDF...
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/ros_robot_visualiser/package.json b/ros_robot_visualiser/package.json
new file mode 100644
index 0000000..5af3046
--- /dev/null
+++ b/ros_robot_visualiser/package.json
@@ -0,0 +1,19 @@
+{
+ "name": "ros_robot_visualiser",
+ "version": "1.0.0",
+ "description": "",
+ "main": "script.js",
+ "scripts": {
+ "test": "echo \"Error: no test specified\" && exit 1"
+ },
+ "keywords": [],
+ "author": "",
+ "license": "ISC",
+ "type": "commonjs",
+ "dependencies": {
+ "three": "^0.181.0"
+ },
+ "devDependencies": {
+ "vite": "^7.2.2"
+ }
+}
diff --git a/ros_robot_visualiser/roundURDF.py b/ros_robot_visualiser/roundURDF.py
new file mode 100644
index 0000000..6041366
--- /dev/null
+++ b/ros_robot_visualiser/roundURDF.py
@@ -0,0 +1,43 @@
+import xml.etree.ElementTree as ET
+import math
+
+def round_axis(x, y, z):
+ # Normalize vector
+ mag = math.sqrt(x**2 + y**2 + z**2)
+ if mag == 0:
+ return (0, 0, 0)
+ x, y, z = x/mag, y/mag, z/mag
+
+ # Compare to cardinal directions
+ directions = {
+ (1, 0, 0): 'X+',
+ (-1, 0, 0): 'X-',
+ (0, 1, 0): 'Y+',
+ (0, -1, 0): 'Y-',
+ (0, 0, 1): 'Z+',
+ (0, 0, -1): 'Z-'
+ }
+
+ best = max(directions.keys(), key=lambda d: x*d[0] + y*d[1] + z*d[2])
+ return best
+
+def process_urdf(file_path, output_path):
+ tree = ET.parse(file_path)
+ root = tree.getroot()
+
+ for joint in root.findall('joint'):
+ axis_elem = joint.find('axis')
+ if axis_elem is not None:
+ axis_str = axis_elem.attrib.get('xyz', '')
+ try:
+ x, y, z = map(float, axis_str.strip().split())
+ rx, ry, rz = round_axis(x, y, z)
+ axis_elem.set('xyz', f"{rx} {ry} {rz}")
+ except:
+ continue
+
+ tree.write(output_path)
+ print(f"Rounded axes written to {output_path}")
+
+# Example usage
+process_urdf("./urdf/sample.urdf", "./urdf/output.urdf")
diff --git a/ros_robot_visualiser/script.js b/ros_robot_visualiser/script.js
new file mode 100644
index 0000000..cd1e1e4
--- /dev/null
+++ b/ros_robot_visualiser/script.js
@@ -0,0 +1,4 @@
+import { URDFEditor } from './URDFEditor.js';
+
+const canvas = document.getElementById('urdfCanvas');
+const editor = new URDFEditor(canvas, './urdf/sample.urdf');
diff --git a/ros_robot_visualiser/script_old.js b/ros_robot_visualiser/script_old.js
new file mode 100644
index 0000000..f501d97
--- /dev/null
+++ b/ros_robot_visualiser/script_old.js
@@ -0,0 +1,356 @@
+import * as THREE from 'three';
+import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js';
+import URDFLoader from './urdf/URDFLoader.js';
+
+// Setup scene, camera, renderer
+const canvas = document.getElementById('urdf-canvas');
+const renderer = new THREE.WebGLRenderer({ canvas, antialias: true });
+renderer.setSize(window.innerWidth, window.innerHeight);
+
+const scene = new THREE.Scene();
+scene.background = new THREE.Color(0xaaaaaa);
+
+const camera = new THREE.PerspectiveCamera(45, window.innerWidth / window.innerHeight, 0.1, 100);
+camera.position.set(2, 2, 2);
+camera.lookAt(0, 0, 0);
+
+// Lights
+scene.add(new THREE.AmbientLight(0xffffff, 0.6));
+const directional = new THREE.DirectionalLight(0xffffff, 0.8);
+directional.position.set(15, 10, 7);
+const directional2 = new THREE.DirectionalLight(0xffffff, 0.8);
+directional2.position.set(-15, 10, -7);
+scene.add(directional2);
+
+// Controls
+const controls = new OrbitControls(camera, renderer.domElement);
+controls.target.set(0, 0.5, 0);
+controls.update();
+
+// Helpers
+scene.add(new THREE.AxesHelper(1));
+const gridHelper = new THREE.GridHelper(2, 10);
+//gridHelper.rotation.x = Math.PI / 2;
+gridHelper.position.z = 0;
+scene.add(gridHelper);
+
+// Raycasting
+const raycaster = new THREE.Raycaster();
+const mouse = new THREE.Vector2();
+let hoveredJoint = null;
+let isDragging = false;
+let lastX = null;
+let worldAxis = null;
+
+let draggedJoint = null;
+let arc = null;
+const jointAngles = {}; // key: jointName, value: current angle in radians
+
+
+
+
+function findJointAncestor(object) {
+ while (object && object.parent) {
+ if (object.type === 'URDFJoint') return object;
+ object = object.parent;
+ }
+ return null;
+}
+
+function createRotationArc(axis, lower, upper, radius = 0.1, segments = 32) {
+ const points = [];
+ console.log(axis);
+ console.log(axis.clone());
+ // ✅ Ensure axis is normalized
+ const normalizedAxis = axis.clone().normalize();
+
+ // ✅ Find a perpendicular vector
+ let perp = new THREE.Vector3(0, 1, 0);
+ if (Math.abs(normalizedAxis.dot(perp)) > 0.99) {
+ perp = new THREE.Vector3(1, 0, 0); // fallback if axis is nearly parallel to Y
+ }
+ const tangent = perp.cross(normalizedAxis).normalize();
+
+ for (let i = 0; i <= segments; i++) {
+ const angle = lower + (upper - lower) * (i / segments);
+ const point = tangent.clone().applyAxisAngle(normalizedAxis, angle).multiplyScalar(radius);
+ points.push(point);
+ }
+
+ const geometry = new THREE.BufferGeometry().setFromPoints(points);
+ const material = new THREE.LineBasicMaterial({ color: 0xffff00 });
+ return new THREE.Line(geometry, material);
+}
+
+function createRotationSector(axis, lower, upper, radius = 0.1, segments = 64) {
+ const motorMargin = 0.4; // hardcoded extra range beyond joint limits
+ const motorLower = lower - motorMargin;
+ const motorUpper = upper + motorMargin;
+
+ const redMaterial = new THREE.MeshBasicMaterial({
+ color: 0xff0000,
+ transparent: true,
+ opacity: 0.2,
+ side: THREE.DoubleSide,
+ depthTest: false,
+ depthWrite: false
+ });
+
+ const whiteMaterial = new THREE.MeshBasicMaterial({
+ color: 0xffffff,
+ transparent: true,
+ opacity: 0.6,
+ side: THREE.DoubleSide,
+ depthTest: false,
+ depthWrite: false
+ });
+
+ function createArc(start, end, material) {
+ const shape = new THREE.Shape();
+ shape.moveTo(0, 0);
+ for (let i = 0; i <= segments; i++) {
+ const angle = start + (end - start) * (i / segments);
+ shape.lineTo(Math.cos(angle) * radius, Math.sin(angle) * radius);
+ }
+ shape.lineTo(0, 0);
+ const geometry = new THREE.ShapeGeometry(shape);
+ return new THREE.Mesh(geometry, material);
+ }
+
+ const redLeft = createArc(motorLower, lower, redMaterial);
+ const whiteArc = createArc(lower, upper, whiteMaterial);
+ const redRight = createArc(upper, motorUpper, redMaterial);
+
+ // Orient all arcs to match the axis
+ const basis = new THREE.Matrix4();
+ const up = new THREE.Vector3(0, 0, 1);
+ const quaternion = new THREE.Quaternion().setFromUnitVectors(up, axis.clone().normalize());
+ basis.makeRotationFromQuaternion(quaternion);
+
+ redLeft.applyMatrix4(basis);
+ whiteArc.applyMatrix4(basis);
+ redRight.applyMatrix4(basis);
+
+ const group = new THREE.Group();
+ group.add(redLeft);
+ group.add(whiteArc);
+ group.add(redRight);
+
+ return group;
+}
+
+
+function createAngleIndicator(axis, angle, radius = 0.1) {
+ const dir = new THREE.Vector3(Math.cos(angle), Math.sin(angle), 0).multiplyScalar(radius);
+
+ const geometry = new THREE.BufferGeometry().setFromPoints([
+ new THREE.Vector3(0, 0, 0),
+ dir,
+ ]);
+
+ const material = new THREE.LineBasicMaterial({
+ color: 0xffffff, // white
+ linewidth: 2, // note: linewidth only works in some renderers
+ depthTest: false, // ✅ always on top
+ depthWrite: false
+ });
+
+ const line = new THREE.Line(geometry, material);
+
+ // Rotate into axis plane
+ const up = new THREE.Vector3(0, 0, 1);
+ const quaternion = new THREE.Quaternion().setFromUnitVectors(up, axis.clone().normalize());
+ line.applyQuaternion(quaternion);
+
+ return line;
+}
+
+
+
+// Load URDF
+const loader = new URDFLoader();
+loader.packages = { '': './urdf/' };
+
+function findObjectByName(root, name) {
+ let result = null;
+ root.traverse(child => {
+ if (child.name === name) result = child;
+ });
+ return result;
+}
+
+loader.load('./urdf/sample.urdf', robot => {
+ //scene.rotation.x = -Math.PI / 2;
+ robot.rotation.x = -Math.PI / 2;
+ scene.add(robot);
+ document.getElementById('status').textContent = 'URDF Loaded';
+
+ loader.parseVisual = true;
+ loader.parseCollision = false;
+
+ // Add AxesHelpers to all meshes
+ robot.traverse(obj => {
+ if (obj.isMesh) obj.add(new THREE.AxesHelper(0.05));
+ });
+
+ // Setup pointer events
+ canvas.addEventListener('pointermove', event => {
+ mouse.x = (event.clientX / canvas.clientWidth) * 2 - 1;
+ mouse.y = -(event.clientY / canvas.clientHeight) * 2 + 1;
+
+ raycaster.setFromCamera(mouse, camera);
+ const intersects = raycaster.intersectObjects(robot.children, true);
+
+ if (isDragging && draggedJoint && worldAxis) {
+ const deltaX = event.clientX - lastX;
+ lastX = event.clientX;
+ const angleDelta = deltaX * 0.005;
+
+ const jointName = draggedJoint.name;
+ const limits = robot.joints[jointName]?.limit;
+ const lower = limits?.lower ?? -Math.PI;
+ const upper = limits?.upper ?? Math.PI;
+
+ const currentAngle = jointAngles[jointName] ?? 0;
+ const proposedAngle = currentAngle + angleDelta;
+
+ // ✅ Clamp to limits
+ const clampedAngle = Math.max(lower, Math.min(upper, proposedAngle));
+ const actualDelta = clampedAngle - currentAngle;
+
+ // ✅ Apply rotation
+ draggedJoint.rotateOnAxis(worldAxis, actualDelta);
+ jointAngles[jointName] = clampedAngle;
+
+
+ // Remove old indicator
+ if (draggedJoint.userData.gizmo?.indicator) {
+ draggedJoint.parent.remove(draggedJoint.userData.gizmo.indicator);
+ }
+
+ // Add updated indicator
+ const newIndicator = createAngleIndicator(worldAxis, jointAngles[jointName]);
+ newIndicator.position.copy(draggedJoint.position);
+ draggedJoint.parent.add(newIndicator);
+ draggedJoint.userData.gizmo.indicator = newIndicator;
+
+ }
+
+
+
+ if (intersects.length > 0) {
+ const target = intersects[0].object;
+
+ // Reset previous highlight
+ if (
+ hoveredJoint &&
+ hoveredJoint.material &&
+ 'emissive' in hoveredJoint.material &&
+ typeof hoveredJoint.material.emissive.setHex === 'function'
+ ) {
+ hoveredJoint.material.emissive.setHex(0x000000);
+ }
+
+ hoveredJoint = target;
+
+ // Highlight new joint
+ if (
+ hoveredJoint.material &&
+ 'emissive' in hoveredJoint.material &&
+ typeof hoveredJoint.material.emissive.setHex === 'function'
+ ) {
+ hoveredJoint.material.emissive.setHex(0x333333);
+ }
+
+ } else {
+ if (
+ hoveredJoint &&
+ hoveredJoint.material &&
+ 'emissive' in hoveredJoint.material &&
+ typeof hoveredJoint.material.emissive.setHex === 'function'
+ ) {
+ hoveredJoint.material.emissive.setHex(0x000000);
+ }
+ hoveredJoint = null;
+ }
+
+ });
+
+ canvas.addEventListener('pointerdown', event => {
+ if (!hoveredJoint) return;
+
+ isDragging = true;
+ lastX = event.clientX;
+ controls.enabled = false;
+
+ draggedJoint = findJointAncestor(hoveredJoint);
+
+
+ if (!draggedJoint) return;
+
+ const jointName = draggedJoint.name;
+ const jointData = robot.joints?.[jointName];
+
+ // ✅ Check if axis is a valid THREE.Vector3
+ if (!(jointData?.axis instanceof THREE.Vector3)) {
+ console.warn(`Invalid axis for joint "${jointName}":`, jointData?.axis);
+ return;
+ }
+
+ const urdfAxis = jointData.axis.clone();
+ if (urdfAxis.lengthSq() === 0) {
+ console.warn(`Zero-length axis for joint "${jointName}"`);
+ return;
+ }
+
+ worldAxis = urdfAxis.normalize();
+ const limits = robot.joints[jointName]?.limit;
+ const lower = limits?.lower ?? -Math.PI;
+ const upper = limits?.upper ?? Math.PI;
+
+ const sector = createRotationSector(worldAxis, lower, upper);
+ const indicator = createAngleIndicator(worldAxis, jointAngles[jointName] ?? 0);
+
+ sector.position.copy(draggedJoint.position);
+ indicator.position.copy(draggedJoint.position);
+
+ draggedJoint.parent.add(sector);
+ draggedJoint.parent.add(indicator);
+
+ // Store for cleanup
+ draggedJoint.userData.gizmo = { sector, indicator };
+
+
+
+
+ console.log(`Dragging joint "${jointName}" on axis`, worldAxis.toArray());
+ });
+
+
+
+
+
+
+ canvas.addEventListener('pointerup', () => {
+ isDragging = false;
+ controls.enabled = true;
+ const gizmo = draggedJoint?.userData.gizmo;
+ if (gizmo) {
+ draggedJoint.parent.remove(gizmo.sector);
+ draggedJoint.parent.remove(gizmo.indicator);
+ }
+
+
+ draggedJoint = null;
+ worldAxis = null;
+ });
+
+
+ animate();
+});
+
+function animate() {
+ requestAnimationFrame(animate);
+ renderer.render(scene, camera);
+}
+
diff --git a/ros_robot_visualiser/ui/canvasui.js b/ros_robot_visualiser/ui/canvasui.js
new file mode 100644
index 0000000..f112a11
--- /dev/null
+++ b/ros_robot_visualiser/ui/canvasui.js
@@ -0,0 +1,171 @@
+
+
+
+class UIElement {
+ constructor(x, y, w, h) {
+ this.x = x; this.y = y;
+ this.w = w; this.h = h;
+ this.hovered = false;
+ this.active = false;
+
+ }
+
+ contains(px, py) {
+ return px >= this.x && px <= this.x + this.w &&
+ py >= this.y && py <= this.y + this.h;
+ }
+
+ handlePointerEvent(event, px, py) {
+ switch (event.type) {
+ case 'pointermove':
+ this.onPointerMove(px, py);
+ break;
+ case 'pointerdown':
+ this.onPointerDown(px, py);
+ break;
+ case 'pointerup':
+ this.onPointerUp(px, py);
+ break;
+ case 'click':
+ // optional: treat click as pointerup for convenience
+ if (this.contains(px, py)) {
+ this.onClick?.(); // if subclass defines onClick
+ }
+ break;
+ }
+
+ }
+
+
+ onPointerMove(px, py) {
+ this.hovered = this.contains(px, py);
+ //console.log(this);
+ }
+
+ onPointerDown(px, py) {
+ if (this.contains(px, py)) {
+ this.active = true;
+ return true;
+ }
+ return false;
+ }
+
+ onPointerUp(px, py) {
+ if (this.active && this.contains(px, py)) {
+ this.active = false;
+ return true;
+ }
+ this.active = false;
+ return false;
+ }
+
+ draw(ctx) { /* override in subclasses */ }
+}
+
+export class Panel extends UIElement {
+ constructor(x, y, w, h, title = '') {
+ super(x, y, w, h);
+ this.title = title;
+ this.elements = [];
+ }
+
+ addElement(el) { this.elements.push(el); }
+
+ draw(ctx) {
+ ctx.fillStyle = 'rgba(0,0,0,0.5)';
+ ctx.fillRect(this.x, this.y, this.w, this.h);
+ ctx.strokeStyle = '#fff';
+ ctx.strokeRect(this.x, this.y, this.w, this.h);
+
+ if (this.title) {
+ ctx.fillStyle = '#fff';
+ ctx.font = '16px monospace';
+ ctx.fillText(this.title, this.x + 10, this.y + 20);
+ }
+
+ this.elements.forEach(el => el.draw(ctx));
+ }
+
+ handlePointerEvent(event, px, py) {
+ super.handlePointerEvent(event, px, py); // panel itself
+ this.elements.forEach(el => el.handlePointerEvent(event, px, py));
+ }
+
+
+ handleKeyEvent(event) {
+ this.elements.forEach(el => {
+ if (el.onKeyDown) el.onKeyDown(event);
+ });
+ }
+}
+
+
+
+export class Button extends UIElement {
+ constructor(x, y, w, h, label, onClick) {
+ super(x, y, w, h);
+ this.label = label;
+ this.onClick = onClick;
+ }
+
+ draw(ctx) {
+ // ctx.fillStyle = this.active ? '#225433' :
+ // this.hovered ? '#666' : '#333';
+ // ctx.fillRect(this.x, this.y, this.w, this.h);
+
+ ctx.fillStyle = '#fff';
+ if (this.hovered) {
+ ctx.fillStyle = '#666';
+
+ }
+ ctx.font = '14px monospace';
+ ctx.fillText(this.label, this.x + 10, this.y + this.h - 8);
+ }
+
+ onPointerMove(px, py) {
+ this.hovered = this.contains(px, py);
+ //console.log(this);
+ }
+
+ onPointerUp(px, py) {
+ const clicked = super.onPointerUp(px, py);
+ if (clicked && this.onClick) this.onClick();
+ return clicked;
+ }
+}
+
+export class Textbox extends UIElement {
+ constructor(x, y, w, h, initialValue = '') {
+ super(x, y, w, h);
+ this.value = initialValue;
+ this.focused = false;
+ }
+
+ draw(ctx) {
+ ctx.fillStyle = '#000';
+ ctx.fillRect(this.x, this.y, this.w, this.h);
+
+ ctx.strokeStyle = this.focused ? '#0f0' : '#aaa';
+ ctx.strokeRect(this.x, this.y, this.w, this.h);
+
+ ctx.fillStyle = '#fff';
+ ctx.font = '14px monospace';
+ ctx.fillText(this.value, this.x + 5, this.y + this.h - 8);
+ }
+
+ onPointerUp(px, py) {
+ const clicked = super.onPointerUp(px, py);
+ this.focused = clicked;
+ return clicked;
+ }
+
+ onKeyDown(event) {
+ if (!this.focused) return;
+ if (event.key >= '0' && event.key <= '9') {
+ this.value += event.key;
+ } else if (event.key === 'Backspace') {
+ this.value = this.value.slice(0, -1);
+ }
+ }
+}
+
diff --git a/ros_robot_visualiser/urdf/ExtendedURDFLoader.js b/ros_robot_visualiser/urdf/ExtendedURDFLoader.js
new file mode 100644
index 0000000..203a332
--- /dev/null
+++ b/ros_robot_visualiser/urdf/ExtendedURDFLoader.js
@@ -0,0 +1,83 @@
+import URDFLoader from './URDFLoader.js';
+
+export default
+ class ExtendedURDFLoader extends URDFLoader {
+ constructor(manager) {
+ super(manager);
+ this.transmissionMap = {};
+ }
+
+ parseTransmissions(xml) {
+ const transmissions = xml.querySelectorAll('transmission');
+ this.transmissionMap = {};
+
+ transmissions.forEach(trans => {
+ const jointName = trans.querySelector('joint')?.getAttribute('name');
+ const actuator = trans.querySelector('actuator');
+ const reduction = parseFloat(actuator?.querySelector('mechanicalReduction')?.textContent ?? '1');
+ const actuatorName = actuator?.getAttribute('name') ?? null;
+ const encoderTicks = parseInt(actuator?.querySelector('encoderTicks')?.textContent ?? '4096');
+ const encoderValidMin = parseInt(actuator?.querySelector('encoderValidMin')?.textContent ?? '0');
+ const encoderValidMax = parseInt(actuator?.querySelector('encoderValidMax')?.textContent ?? '4095');
+ const encoderRange = parseInt(actuator?.querySelector('encoderRange')?.textContent ?? '180');
+ const motorID = parseInt(actuator?.querySelector('motorID')?.textContent ?? '47');
+
+ if (jointName) {
+ this.transmissionMap[jointName] = {
+ mechanicalReduction: reduction,
+ actuatorName,
+ encoderTicks,
+ encoderValidMin,
+ encoderValidMax,
+ encoderRange,
+ motorID
+ };
+ }
+ });
+ }
+
+ parseMimics(xml) {
+ const joints = xml.querySelectorAll('joint');
+ joints.forEach(jointEl => {
+ const jointName = jointEl.getAttribute('name');
+ const mimicEl = jointEl.querySelector('mimic');
+ if (mimicEl && jointName) {
+ const target = mimicEl.getAttribute('joint');
+ const multiplier = parseFloat(mimicEl.getAttribute('multiplier') ?? '1.0');
+ const offset = parseFloat(mimicEl.getAttribute('offset') ?? '0.0');
+
+ // Attach mimic info directly to the joint object
+ if (this.robot?.joints[jointName]) {
+ this.robot.joints[jointName].mimic = { target, multiplier, offset };
+ }
+ }
+ });
+
+
+ }
+
+
+
+ async loadFromString(urdfText, options = {}) {
+
+ const xml = new DOMParser().parseFromString(urdfText, 'application/xml');
+ const parserError = xml.querySelector('parsererror');
+ if (parserError) {
+ console.error('❌ XML parser error:', parserError.textContent);
+ throw new Error('Invalid URDF XML: ' + parserError.textContent);
+ }
+
+ this.parseTransmissions(xml);
+ //this.parseMimics(xml);
+
+ const robot = this.parse(xml); // ✅ Use inherited parse() directly
+ //console.log(robot.joints);
+ for (const jointName in robot.joints) {
+ if (this.transmissionMap[jointName]) {
+ robot.joints[jointName].transmission = this.transmissionMap[jointName];
+ }
+ }
+
+ return robot;
+ }
+}
diff --git a/ros_robot_visualiser/urdf/URDFClasses.js b/ros_robot_visualiser/urdf/URDFClasses.js
new file mode 100644
index 0000000..88e5b14
--- /dev/null
+++ b/ros_robot_visualiser/urdf/URDFClasses.js
@@ -0,0 +1,469 @@
+import { Euler, Object3D, Vector3, Quaternion, Matrix4 } from 'three';
+
+const _tempAxis = new Vector3();
+const _tempEuler = new Euler();
+const _tempTransform = new Matrix4();
+const _tempOrigTransform = new Matrix4();
+const _tempQuat = new Quaternion();
+const _tempScale = new Vector3(1.0, 1.0, 1.0);
+const _tempPosition = new Vector3();
+
+class URDFBase extends Object3D {
+
+ constructor(...args) {
+
+ super(...args);
+ this.urdfNode = null;
+ this.urdfName = '';
+
+ }
+
+ copy(source, recursive) {
+
+ super.copy(source, recursive);
+
+ this.urdfNode = source.urdfNode;
+ this.urdfName = source.urdfName;
+
+ return this;
+
+ }
+
+}
+
+class URDFCollider extends URDFBase {
+
+ constructor(...args) {
+
+ super(...args);
+ this.isURDFCollider = true;
+ this.type = 'URDFCollider';
+
+ }
+
+}
+
+class URDFVisual extends URDFBase {
+
+ constructor(...args) {
+
+ super(...args);
+ this.isURDFVisual = true;
+ this.type = 'URDFVisual';
+
+ }
+
+}
+
+class URDFLink extends URDFBase {
+
+ constructor(...args) {
+
+ super(...args);
+ this.isURDFLink = true;
+ this.type = 'URDFLink';
+
+ }
+
+}
+
+class URDFJoint extends URDFBase {
+
+ get jointType() {
+
+ return this._jointType;
+
+ }
+
+ set jointType(v) {
+
+ if (this.jointType === v) return;
+ this._jointType = v;
+ this.matrixWorldNeedsUpdate = true;
+ switch (v) {
+
+ case 'fixed':
+ this.jointValue = [];
+ break;
+
+ case 'continuous':
+ case 'revolute':
+ case 'prismatic':
+ this.jointValue = new Array(1).fill(0);
+ break;
+
+ case 'planar':
+ // Planar joints are, 3dof: position XY and rotation Z.
+ this.jointValue = new Array(3).fill(0);
+ this.axis = new Vector3(0, 0, 1);
+ break;
+
+ case 'floating':
+ this.jointValue = new Array(6).fill(0);
+ break;
+
+ }
+
+ }
+
+ get angle() {
+
+ return this.jointValue[0];
+
+ }
+
+ constructor(...args) {
+
+ super(...args);
+
+ this.isURDFJoint = true;
+ this.type = 'URDFJoint';
+
+ this.jointValue = null;
+ this.jointType = 'fixed';
+ this.axis = new Vector3(1, 0, 0);
+ this.limit = { lower: 0, upper: 0 };
+ this.ignoreLimits = false;
+
+ this.origPosition = null;
+ this.origQuaternion = null;
+
+ this.mimicJoints = [];
+
+ }
+
+ /* Overrides */
+ copy(source, recursive) {
+
+ super.copy(source, recursive);
+
+ this.jointType = source.jointType;
+ this.axis = source.axis.clone();
+ this.limit.lower = source.limit.lower;
+ this.limit.upper = source.limit.upper;
+ this.ignoreLimits = false;
+
+ this.jointValue = [...source.jointValue];
+
+ this.origPosition = source.origPosition ? source.origPosition.clone() : null;
+ this.origQuaternion = source.origQuaternion ? source.origQuaternion.clone() : null;
+
+ this.mimicJoints = [...source.mimicJoints];
+
+ return this;
+
+ }
+
+ /* Public Functions */
+ /**
+ * @param {...number|null} values The joint value components to set, optionally null for no-op
+ * @returns {boolean} Whether the invocation of this function resulted in an actual change to the joint value
+ */
+ setJointValue(...values) {
+
+ // Parse all incoming values into numbers except null, which we treat as a no-op for that value component.
+ values = values.map(v => v === null ? null : parseFloat(v));
+
+ if (!this.origPosition || !this.origQuaternion) {
+
+ this.origPosition = this.position.clone();
+ this.origQuaternion = this.quaternion.clone();
+
+ }
+
+ let didUpdate = false;
+
+ this.mimicJoints.forEach(joint => {
+
+ didUpdate = joint.updateFromMimickedJoint(...values) || didUpdate;
+
+ });
+
+ switch (this.jointType) {
+
+ case 'fixed': {
+
+ return didUpdate;
+
+ }
+ case 'continuous':
+ case 'revolute': {
+
+ let angle = values[0];
+ if (angle == null) return didUpdate;
+ if (angle === this.jointValue[0]) return didUpdate;
+
+ if (!this.ignoreLimits && this.jointType === 'revolute') {
+
+ angle = Math.min(this.limit.upper, angle);
+ angle = Math.max(this.limit.lower, angle);
+
+ }
+
+ this.quaternion
+ .setFromAxisAngle(this.axis, angle)
+ .premultiply(this.origQuaternion);
+
+ if (this.jointValue[0] !== angle) {
+
+ this.jointValue[0] = angle;
+ this.matrixWorldNeedsUpdate = true;
+ return true;
+
+ } else {
+
+ return didUpdate;
+
+ }
+
+ }
+
+ case 'prismatic': {
+
+ let pos = values[0];
+ if (pos == null) return didUpdate;
+ if (pos === this.jointValue[0]) return didUpdate;
+
+ if (!this.ignoreLimits) {
+
+ pos = Math.min(this.limit.upper, pos);
+ pos = Math.max(this.limit.lower, pos);
+
+ }
+
+ this.position.copy(this.origPosition);
+ _tempAxis.copy(this.axis).applyEuler(this.rotation);
+ this.position.addScaledVector(_tempAxis, pos);
+
+ if (this.jointValue[0] !== pos) {
+
+ this.jointValue[0] = pos;
+ this.matrixWorldNeedsUpdate = true;
+ return true;
+
+ } else {
+
+ return didUpdate;
+
+ }
+
+ }
+
+ case 'floating': {
+
+ // no-op if all values are identical to existing value or are null
+ if (this.jointValue.every((value, index) => values[index] === value || values[index] === null)) return didUpdate;
+ // Floating joints have six degrees of freedom: X, Y, Z, R, P, Y.
+ this.jointValue[0] = values[0] !== null ? values[0] : this.jointValue[0];
+ this.jointValue[1] = values[1] !== null ? values[1] : this.jointValue[1];
+ this.jointValue[2] = values[2] !== null ? values[2] : this.jointValue[2];
+ this.jointValue[3] = values[3] !== null ? values[3] : this.jointValue[3];
+ this.jointValue[4] = values[4] !== null ? values[4] : this.jointValue[4];
+ this.jointValue[5] = values[5] !== null ? values[5] : this.jointValue[5];
+
+ // Compose transform of joint origin and transform due to joint values
+ _tempOrigTransform.compose(this.origPosition, this.origQuaternion, _tempScale);
+ _tempQuat.setFromEuler(
+ _tempEuler.set(
+ this.jointValue[3],
+ this.jointValue[4],
+ this.jointValue[5],
+ 'XYZ',
+ ),
+ );
+ _tempPosition.set(this.jointValue[0], this.jointValue[1], this.jointValue[2]);
+ _tempTransform.compose(_tempPosition, _tempQuat, _tempScale);
+
+ // Calcualte new transform
+ _tempOrigTransform.premultiply(_tempTransform);
+ this.position.setFromMatrixPosition(_tempOrigTransform);
+ this.rotation.setFromRotationMatrix(_tempOrigTransform);
+
+ this.matrixWorldNeedsUpdate = true;
+ return true;
+ }
+
+ case 'planar': {
+
+ // no-op if all values are identical to existing value or are null
+ if (this.jointValue.every((value, index) => values[index] === value || values[index] === null)) return didUpdate;
+
+ this.jointValue[0] = values[0] !== null ? values[0] : this.jointValue[0];
+ this.jointValue[1] = values[1] !== null ? values[1] : this.jointValue[1];
+ this.jointValue[2] = values[2] !== null ? values[2] : this.jointValue[2];
+
+ // Compose transform of joint origin and transform due to joint values
+ _tempOrigTransform.compose(this.origPosition, this.origQuaternion, _tempScale);
+ _tempQuat.setFromAxisAngle(this.axis, this.jointValue[2]);
+ _tempPosition.set(this.jointValue[0], this.jointValue[1], 0.0);
+ _tempTransform.compose(_tempPosition, _tempQuat, _tempScale);
+
+ // Calculate new transform
+ _tempOrigTransform.premultiply(_tempTransform);
+ this.position.setFromMatrixPosition(_tempOrigTransform);
+ this.rotation.setFromRotationMatrix(_tempOrigTransform);
+
+ this.matrixWorldNeedsUpdate = true;
+ return true;
+ }
+
+ }
+
+ return didUpdate;
+
+ }
+
+}
+
+class URDFMimicJoint extends URDFJoint {
+
+ constructor(...args) {
+
+ super(...args);
+ this.type = 'URDFMimicJoint';
+ this.mimicJoint = null;
+ this.offset = 0;
+ this.multiplier = 1;
+
+ }
+
+ updateFromMimickedJoint(...values) {
+
+ const modifiedValues = values.map(x => x * this.multiplier + this.offset);
+ return super.setJointValue(...modifiedValues);
+
+ }
+
+ /* Overrides */
+ copy(source, recursive) {
+
+ super.copy(source, recursive);
+
+ this.mimicJoint = source.mimicJoint;
+ this.offset = source.offset;
+ this.multiplier = source.multiplier;
+
+ return this;
+
+ }
+
+}
+
+class URDFRobot extends URDFLink {
+
+ constructor(...args) {
+
+ super(...args);
+ this.isURDFRobot = true;
+ this.urdfNode = null;
+
+ this.urdfRobotNode = null;
+ this.robotName = null;
+
+ this.links = null;
+ this.joints = null;
+ this.colliders = null;
+ this.visual = null;
+ this.frames = null;
+
+ }
+
+ copy(source, recursive) {
+
+ super.copy(source, recursive);
+
+ this.urdfRobotNode = source.urdfRobotNode;
+ this.robotName = source.robotName;
+
+ this.links = {};
+ this.joints = {};
+ this.colliders = {};
+ this.visual = {};
+
+ this.traverse(c => {
+
+ if (c.isURDFJoint && c.urdfName in source.joints) {
+
+ this.joints[c.urdfName] = c;
+
+ }
+
+ if (c.isURDFLink && c.urdfName in source.links) {
+
+ this.links[c.urdfName] = c;
+
+ }
+
+ if (c.isURDFCollider && c.urdfName in source.colliders) {
+
+ this.colliders[c.urdfName] = c;
+
+ }
+
+ if (c.isURDFVisual && c.urdfName in source.visual) {
+
+ this.visual[c.urdfName] = c;
+
+ }
+
+ });
+
+ // Repair mimic joint references once we've re-accumulated all our joint data
+ for (const joint in this.joints) {
+ this.joints[joint].mimicJoints = this.joints[joint].mimicJoints.map((mimicJoint) => this.joints[mimicJoint.name]);
+ }
+
+ this.frames = {
+ ...this.colliders,
+ ...this.visual,
+ ...this.links,
+ ...this.joints,
+ };
+
+ return this;
+
+ }
+
+ getFrame(name) {
+
+ return this.frames[name];
+
+ }
+
+ setJointValue(jointName, ...angle) {
+
+ const joint = this.joints[jointName];
+ if (joint) {
+
+ return joint.setJointValue(...angle);
+
+ }
+
+ return false;
+ }
+
+ setJointValues(values) {
+
+ let didChange = false;
+ for (const name in values) {
+
+ const value = values[name];
+ if (Array.isArray(value)) {
+
+ didChange = this.setJointValue(name, ...value) || didChange;
+
+ } else {
+
+ didChange = this.setJointValue(name, value) || didChange;
+
+ }
+
+ }
+
+ return didChange;
+
+ }
+
+}
+
+export { URDFRobot, URDFLink, URDFJoint, URDFMimicJoint, URDFVisual, URDFCollider };
\ No newline at end of file
diff --git a/ros_robot_visualiser/urdf/URDFDragControls.js b/ros_robot_visualiser/urdf/URDFDragControls.js
new file mode 100644
index 0000000..a0776db
--- /dev/null
+++ b/ros_robot_visualiser/urdf/URDFDragControls.js
@@ -0,0 +1,330 @@
+import { Raycaster, Vector3, Plane, Vector2 } from 'three';
+
+// Find the nearest parent that is a joint
+function isJoint(j) {
+
+ return j.isURDFJoint && j.jointType !== 'fixed';
+
+};
+
+function findNearestJoint(child) {
+
+ let curr = child;
+ while (curr) {
+
+ if (isJoint(curr)) {
+
+ return curr;
+
+ }
+
+ curr = curr.parent;
+
+ }
+
+ return curr;
+
+};
+
+const prevHitPoint = new Vector3();
+const newHitPoint = new Vector3();
+const pivotPoint = new Vector3();
+const tempVector = new Vector3();
+const tempVector2 = new Vector3();
+const projectedStartPoint = new Vector3();
+const projectedEndPoint = new Vector3();
+const plane = new Plane();
+export class URDFDragControls {
+
+ constructor(scene) {
+
+ this.enabled = true;
+ this.scene = scene;
+ this.raycaster = new Raycaster();
+ this.initialGrabPoint = new Vector3();
+
+ this.hitDistance = -1;
+ this.hovered = null;
+ this.manipulating = null;
+
+ }
+
+ update() {
+
+ const {
+ raycaster,
+ hovered,
+ manipulating,
+ scene,
+ } = this;
+
+ if (manipulating) {
+
+ return;
+
+ }
+
+ let hoveredJoint = null;
+ const intersections = raycaster.intersectObject(scene, true);
+ if (intersections.length !== 0) {
+
+ const hit = intersections[0];
+ this.hitDistance = hit.distance;
+ hoveredJoint = findNearestJoint(hit.object);
+ this.initialGrabPoint.copy(hit.point);
+
+ }
+
+ if (hoveredJoint !== hovered) {
+
+ if (hovered) {
+
+ this.onUnhover(hovered);
+
+ }
+
+ this.hovered = hoveredJoint;
+
+ if (hoveredJoint) {
+
+ this.onHover(hoveredJoint);
+
+ }
+
+ }
+
+ }
+
+ updateJoint(joint, angle) {
+
+ joint.setJointValue(angle);
+
+ }
+
+ onDragStart(joint) {
+
+ }
+
+ onDragEnd(joint) {
+
+ }
+
+ onHover(joint) {
+
+ }
+
+ onUnhover(joint) {
+
+ }
+
+ getRevoluteDelta(joint, startPoint, endPoint) {
+
+ // set up the plane
+ tempVector
+ .copy(joint.axis)
+ .transformDirection(joint.matrixWorld)
+ .normalize();
+ pivotPoint
+ .set(0, 0, 0)
+ .applyMatrix4(joint.matrixWorld);
+ plane
+ .setFromNormalAndCoplanarPoint(tempVector, pivotPoint);
+
+ // project the drag points onto the plane
+ plane.projectPoint(startPoint, projectedStartPoint);
+ plane.projectPoint(endPoint, projectedEndPoint);
+
+ // get the directions relative to the pivot
+ projectedStartPoint.sub(pivotPoint);
+ projectedEndPoint.sub(pivotPoint);
+
+ tempVector.crossVectors(projectedStartPoint, projectedEndPoint);
+
+ const direction = Math.sign(tempVector.dot(plane.normal));
+ return direction * projectedEndPoint.angleTo(projectedStartPoint);
+
+ }
+
+ getPrismaticDelta(joint, startPoint, endPoint) {
+
+ tempVector.subVectors(endPoint, startPoint);
+ plane
+ .normal
+ .copy(joint.axis)
+ .transformDirection(joint.parent.matrixWorld)
+ .normalize();
+
+ return tempVector.dot(plane.normal);
+
+ }
+
+ moveRay(toRay) {
+
+ const { raycaster, hitDistance, manipulating } = this;
+ const { ray } = raycaster;
+
+ if (manipulating) {
+
+ ray.at(hitDistance, prevHitPoint);
+ toRay.at(hitDistance, newHitPoint);
+
+ let delta = 0;
+ if (manipulating.jointType === 'revolute' || manipulating.jointType === 'continuous') {
+
+ delta = this.getRevoluteDelta(manipulating, prevHitPoint, newHitPoint);
+
+ } else if (manipulating.jointType === 'prismatic') {
+
+ delta = this.getPrismaticDelta(manipulating, prevHitPoint, newHitPoint);
+
+ }
+
+ if (delta) {
+
+ this.updateJoint(manipulating, manipulating.angle + delta);
+
+ }
+
+ }
+
+ this.raycaster.ray.copy(toRay);
+ this.update();
+
+ }
+
+ setGrabbed(grabbed) {
+
+ const { hovered, manipulating } = this;
+
+ if (grabbed) {
+
+ if (manipulating !== null || hovered === null) {
+
+ return;
+
+ }
+
+ this.manipulating = hovered;
+ this.onDragStart(hovered);
+
+ } else {
+
+ if (this.manipulating === null) {
+ return;
+ }
+
+ this.onDragEnd(this.manipulating);
+ this.manipulating = null;
+ this.update();
+
+ }
+
+ }
+
+}
+
+export class PointerURDFDragControls extends URDFDragControls {
+
+ constructor(scene, camera, domElement) {
+
+ super(scene);
+ this.camera = camera;
+ this.domElement = domElement;
+
+ const raycaster = new Raycaster();
+ const mouse = new Vector2();
+
+ function updateMouse(e) {
+
+ const rect = domElement.getBoundingClientRect();
+ mouse.x = ((e.clientX - rect.left) / rect.width) * 2 - 1;
+ mouse.y = -((e.clientY - rect.top) / rect.height) * 2 + 1;
+
+ }
+
+ this._mouseDown = e => {
+
+ updateMouse(e);
+ raycaster.setFromCamera(mouse, this.camera);
+ this.moveRay(raycaster.ray);
+ this.setGrabbed(true);
+
+ };
+
+ this._mouseMove = e => {
+
+ updateMouse(e);
+ raycaster.setFromCamera(mouse, this.camera);
+ this.moveRay(raycaster.ray);
+
+ };
+
+ this._mouseUp = e => {
+
+ updateMouse(e);
+ raycaster.setFromCamera(mouse, this.camera);
+ this.moveRay(raycaster.ray);
+ this.setGrabbed(false);
+
+ };
+
+ domElement.addEventListener('mousedown', this._mouseDown);
+ domElement.addEventListener('mousemove', this._mouseMove);
+ domElement.addEventListener('mouseup', this._mouseUp);
+
+ }
+
+ getRevoluteDelta(joint, startPoint, endPoint) {
+
+ const { camera, initialGrabPoint } = this;
+
+ // set up the plane
+ tempVector
+ .copy(joint.axis)
+ .transformDirection(joint.matrixWorld)
+ .normalize();
+ pivotPoint
+ .set(0, 0, 0)
+ .applyMatrix4(joint.matrixWorld);
+ plane
+ .setFromNormalAndCoplanarPoint(tempVector, pivotPoint);
+
+ tempVector
+ .copy(camera.position)
+ .sub(initialGrabPoint)
+ .normalize();
+
+ // if looking into the plane of rotation
+ if (Math.abs(tempVector.dot(plane.normal)) > 0.3) {
+
+ return super.getRevoluteDelta(joint, startPoint, endPoint);
+
+ } else {
+
+ // get the up direction
+ tempVector.set(0, 1, 0).transformDirection(camera.matrixWorld);
+
+ // get points projected onto the plane of rotation
+ plane.projectPoint(startPoint, projectedStartPoint);
+ plane.projectPoint(endPoint, projectedEndPoint);
+
+ tempVector.set(0, 0, -1).transformDirection(camera.matrixWorld);
+ tempVector.cross(plane.normal);
+ tempVector2.subVectors(endPoint, startPoint);
+
+ return tempVector.dot(tempVector2);
+
+ }
+
+ }
+
+ dispose() {
+
+ const { domElement } = this;
+ domElement.removeEventListener('mousedown', this._mouseDown);
+ domElement.removeEventListener('mousemove', this._mouseMove);
+ domElement.removeEventListener('mouseup', this._mouseUp);
+
+ }
+
+}
\ No newline at end of file
diff --git a/ros_robot_visualiser/urdf/URDFLoader.js b/ros_robot_visualiser/urdf/URDFLoader.js
new file mode 100644
index 0000000..b7fddad
--- /dev/null
+++ b/ros_robot_visualiser/urdf/URDFLoader.js
@@ -0,0 +1,657 @@
+import * as THREE from 'three';
+import { STLLoader } from 'three/examples/jsm/loaders/STLLoader.js';
+import { ColladaLoader } from 'three/examples/jsm/loaders/ColladaLoader.js';
+import { URDFRobot, URDFJoint, URDFLink, URDFCollider, URDFVisual, URDFMimicJoint } from './URDFClasses.js';
+
+/*
+Reference coordinate frames for THREE.js and ROS.
+Both coordinate systems are right handed so the URDF is instantiated without
+frame transforms. The resulting model can be rotated to rectify the proper up,
+right, and forward directions
+
+THREE.js
+ Y
+ |
+ |
+ .-----X
+ /
+Z
+
+ROS URDf
+ Z
+ | X
+ | /
+ Y-----.
+
+*/
+
+const tempQuaternion = new THREE.Quaternion();
+const tempEuler = new THREE.Euler();
+
+// take a vector "x y z" and process it into
+// an array [x, y, z]
+function processTuple(val) {
+
+ if (!val) return [0, 0, 0];
+ return val.trim().split(/\s+/g).map(num => parseFloat(num));
+
+}
+
+// applies a rotation a threejs object in URDF order
+function applyRotation(obj, rpy, additive = false) {
+
+ // if additive is true the rotation is applied in
+ // addition to the existing rotation
+ if (!additive) obj.rotation.set(0, 0, 0);
+
+ tempEuler.set(rpy[0], rpy[1], rpy[2], 'ZYX');
+ tempQuaternion.setFromEuler(tempEuler);
+ tempQuaternion.multiply(obj.quaternion);
+ obj.quaternion.copy(tempQuaternion);
+
+}
+
+/* URDFLoader Class */
+// Loads and reads a URDF file into a THREEjs Object3D format
+export default
+class URDFLoader {
+
+ constructor(manager) {
+
+ this.manager = manager || THREE.DefaultLoadingManager;
+ this.loadMeshCb = this.defaultMeshLoader.bind(this);
+ this.parseVisual = true;
+ this.parseCollision = false;
+ this.packages = '';
+ this.workingPath = '';
+ this.fetchOptions = {};
+
+ }
+
+ /* Public API */
+ loadAsync(urdf) {
+
+ return new Promise((resolve, reject) => {
+
+ this.load(urdf, resolve, null, reject);
+
+ });
+
+ }
+
+ // urdf: The path to the URDF within the package OR absolute
+ // onComplete: Callback that is passed the model once loaded
+ load(urdf, onComplete, onProgress, onError) {
+
+ // Check if a full URI is specified before
+ // prepending the package info
+ const manager = this.manager;
+ const workingPath = THREE.LoaderUtils.extractUrlBase(urdf);
+ const urdfPath = this.manager.resolveURL(urdf);
+
+ manager.itemStart(urdfPath);
+
+ fetch(urdfPath, this.fetchOptions)
+ .then(res => {
+
+ if (res.ok) {
+
+ if (onProgress) {
+
+ onProgress(null);
+
+ }
+ return res.text();
+
+ } else {
+
+ throw new Error(`URDFLoader: Failed to load url '${ urdfPath }' with error code ${ res.status } : ${ res.statusText }.`);
+
+ }
+
+ })
+ .then(data => {
+
+ const model = this.parse(data, this.workingPath || workingPath);
+ onComplete(model);
+ manager.itemEnd(urdfPath);
+
+ })
+ .catch(e => {
+
+ if (onError) {
+
+ onError(e);
+
+ } else {
+
+ console.error('URDFLoader: Error loading file.', e);
+
+ }
+ manager.itemError(urdfPath);
+ manager.itemEnd(urdfPath);
+
+ });
+
+ }
+
+ parse(content, workingPath = this.workingPath) {
+
+ const packages = this.packages;
+ const loadMeshCb = this.loadMeshCb;
+ const parseVisual = this.parseVisual;
+ const parseCollision = this.parseCollision;
+ const manager = this.manager;
+ const linkMap = {};
+ const jointMap = {};
+ const materialMap = {};
+
+ // Resolves the path of mesh files
+ function resolvePath(path) {
+
+ if (!/^package:\/\//.test(path)) {
+
+ return workingPath ? workingPath + path : path;
+
+ }
+
+ // Remove "package://" keyword and split meshPath at the first slash
+ const [targetPkg, relPath] = path.replace(/^package:\/\//, '').split(/\/(.+)/);
+
+ if (typeof packages === 'string') {
+
+ // "pkg" is one single package
+ if (packages.endsWith(targetPkg)) {
+
+ // "pkg" is the target package
+ return packages + '/' + relPath;
+
+ } else {
+
+ // Assume "pkg" is the target package's parent directory
+ return packages + '/' + targetPkg + '/' + relPath;
+
+ }
+
+ } else if (packages instanceof Function) {
+
+ return packages(targetPkg) + '/' + relPath;
+
+ } else if (typeof packages === 'object') {
+
+ // "pkg" is a map of packages
+ if (targetPkg in packages) {
+
+ return packages[targetPkg] + '/' + relPath;
+
+ } else {
+
+ console.error(`URDFLoader : ${ targetPkg } not found in provided package list.`);
+ return null;
+
+ }
+
+ }
+
+ }
+
+ // Process the URDF text format
+ function processUrdf(data) {
+
+ let children;
+ if (data instanceof Document) {
+
+ children = [ ...data.children ];
+
+ } else if (data instanceof Element) {
+
+ children = [ data ];
+
+ } else {
+
+ const parser = new DOMParser();
+ const urdf = parser.parseFromString(data, 'text/xml');
+ children = [ ...urdf.children ];
+
+ }
+
+ const robotNode = children.filter(c => c.nodeName === 'robot').pop();
+ return processRobot(robotNode);
+
+ }
+
+ // Process the node
+ function processRobot(robot) {
+
+ const robotNodes = [ ...robot.children ];
+ const links = robotNodes.filter(c => c.nodeName.toLowerCase() === 'link');
+ const joints = robotNodes.filter(c => c.nodeName.toLowerCase() === 'joint');
+ const materials = robotNodes.filter(c => c.nodeName.toLowerCase() === 'material');
+ const obj = new URDFRobot();
+
+ obj.robotName = robot.getAttribute('name');
+ obj.urdfRobotNode = robot;
+
+ // Create the map
+ materials.forEach(m => {
+
+ const name = m.getAttribute('name');
+ materialMap[name] = processMaterial(m);
+
+ });
+
+ // Create the map
+ const visualMap = {};
+ const colliderMap = {};
+ links.forEach(l => {
+
+ const name = l.getAttribute('name');
+ const isRoot = robot.querySelector(`child[link="${ name }"]`) === null;
+ linkMap[name] = processLink(l, visualMap, colliderMap, isRoot ? obj : null);
+
+ });
+
+ // Create the map
+ joints.forEach(j => {
+
+ const name = j.getAttribute('name');
+ jointMap[name] = processJoint(j);
+
+ });
+
+ obj.joints = jointMap;
+ obj.links = linkMap;
+ obj.colliders = colliderMap;
+ obj.visual = visualMap;
+
+ // Link up mimic joints
+ const jointList = Object.values(jointMap);
+ jointList.forEach(j => {
+
+ if (j instanceof URDFMimicJoint) {
+
+ jointMap[j.mimicJoint].mimicJoints.push(j);
+
+ }
+
+ });
+
+ // Detect infinite loops of mimic joints
+ jointList.forEach(j => {
+
+ const uniqueJoints = new Set();
+ const iterFunction = joint => {
+
+ if (uniqueJoints.has(joint)) {
+
+ throw new Error('URDFLoader: Detected an infinite loop of mimic joints.');
+
+ }
+
+ uniqueJoints.add(joint);
+ joint.mimicJoints.forEach(j => {
+
+ iterFunction(j);
+
+ });
+
+ };
+
+ iterFunction(j);
+ });
+
+ obj.frames = {
+ ...colliderMap,
+ ...visualMap,
+ ...linkMap,
+ ...jointMap,
+ };
+
+ return obj;
+
+ }
+
+ // Process joint nodes and parent them
+ function processJoint(joint) {
+
+ const children = [ ...joint.children ];
+ const jointType = joint.getAttribute('type');
+
+ let obj;
+
+ const mimicTag = children.find(n => n.nodeName.toLowerCase() === 'mimic');
+ if (mimicTag) {
+
+ obj = new URDFMimicJoint();
+ obj.mimicJoint = mimicTag.getAttribute('joint');
+ obj.multiplier = parseFloat(mimicTag.getAttribute('multiplier') || 1.0);
+ obj.offset = parseFloat(mimicTag.getAttribute('offset') || 0.0);
+
+ } else {
+
+ obj = new URDFJoint();
+
+ }
+
+ obj.urdfNode = joint;
+ obj.name = joint.getAttribute('name');
+ obj.urdfName = obj.name;
+ obj.jointType = jointType;
+
+ let parent = null;
+ let child = null;
+ let xyz = [0, 0, 0];
+ let rpy = [0, 0, 0];
+
+ // Extract the attributes
+ children.forEach(n => {
+
+ const type = n.nodeName.toLowerCase();
+ if (type === 'origin') {
+
+ xyz = processTuple(n.getAttribute('xyz'));
+ rpy = processTuple(n.getAttribute('rpy'));
+
+ } else if (type === 'child') {
+
+ child = linkMap[n.getAttribute('link')];
+
+ } else if (type === 'parent') {
+
+ parent = linkMap[n.getAttribute('link')];
+
+ } else if (type === 'limit') {
+
+ obj.limit.lower = parseFloat(n.getAttribute('lower') || obj.limit.lower);
+ obj.limit.upper = parseFloat(n.getAttribute('upper') || obj.limit.upper);
+
+ }
+ });
+
+ // Join the links
+ parent.add(obj);
+ obj.add(child);
+ applyRotation(obj, rpy);
+ obj.position.set(xyz[0], xyz[1], xyz[2]);
+
+ // Set up the rotate function
+ const axisNode = children.filter(n => n.nodeName.toLowerCase() === 'axis')[0];
+
+ if (axisNode) {
+
+ const axisXYZ = axisNode.getAttribute('xyz').split(/\s+/g).map(num => parseFloat(num));
+ obj.axis = new THREE.Vector3(axisXYZ[0], axisXYZ[1], axisXYZ[2]);
+ obj.axis.normalize();
+
+ }
+
+ return obj;
+
+ }
+
+ // Process the nodes
+ function processLink(link, visualMap, colliderMap, target = null) {
+
+ if (target === null) {
+
+ target = new URDFLink();
+
+ }
+
+ const children = [ ...link.children ];
+ target.name = link.getAttribute('name');
+ target.urdfName = target.name;
+ target.urdfNode = link;
+
+ if (parseVisual) {
+
+ const visualNodes = children.filter(n => n.nodeName.toLowerCase() === 'visual');
+ visualNodes.forEach(vn => {
+
+ const v = processLinkElement(vn, materialMap);
+ target.add(v);
+
+ if (vn.hasAttribute('name')) {
+
+ const name = vn.getAttribute('name');
+ v.name = name;
+ v.urdfName = name;
+ visualMap[name] = v;
+
+ }
+
+ });
+
+ }
+
+ if (parseCollision) {
+
+ const collisionNodes = children.filter(n => n.nodeName.toLowerCase() === 'collision');
+ collisionNodes.forEach(cn => {
+
+ const c = processLinkElement(cn);
+ target.add(c);
+
+ if (cn.hasAttribute('name')) {
+
+ const name = cn.getAttribute('name');
+ c.name = name;
+ c.urdfName = name;
+ colliderMap[name] = c;
+
+ }
+
+ });
+
+ }
+
+ return target;
+
+ }
+
+ function processMaterial(node) {
+
+ const matNodes = [ ...node.children ];
+ const material = new THREE.MeshPhongMaterial();
+
+ material.name = node.getAttribute('name') || '';
+ matNodes.forEach(n => {
+
+ const type = n.nodeName.toLowerCase();
+ if (type === 'color') {
+
+ const rgba =
+ n
+ .getAttribute('rgba')
+ .split(/\s/g)
+ .map(v => parseFloat(v));
+
+ material.color.setRGB(rgba[0], rgba[1], rgba[2]);
+ material.opacity = rgba[3];
+ material.transparent = rgba[3] < 1;
+ material.depthWrite = !material.transparent;
+
+ } else if (type === 'texture') {
+
+ // The URDF spec does not require that the tag include
+ // a filename attribute so skip loading the texture if not provided.
+ const filename = n.getAttribute('filename');
+ if (filename) {
+
+ const loader = new THREE.TextureLoader(manager);
+ const filePath = resolvePath(filename);
+ material.map = loader.load(filePath);
+ material.map.colorSpace = THREE.SRGBColorSpace;
+
+ }
+
+ }
+ });
+
+ return material;
+
+ }
+
+ // Process the visual and collision nodes into meshes
+ function processLinkElement(vn, materialMap = {}) {
+
+ const isCollisionNode = vn.nodeName.toLowerCase() === 'collision';
+ const children = [ ...vn.children ];
+ let material = null;
+
+ // get the material first
+ const materialNode = children.filter(n => n.nodeName.toLowerCase() === 'material')[0];
+ if (materialNode) {
+
+ const name = materialNode.getAttribute('name');
+ if (name && name in materialMap) {
+
+ material = materialMap[name];
+
+ } else {
+
+ material = processMaterial(materialNode);
+
+ }
+
+ } else {
+
+ material = new THREE.MeshPhongMaterial();
+
+ }
+
+ const group = isCollisionNode ? new URDFCollider() : new URDFVisual();
+ group.urdfNode = vn;
+
+ children.forEach(n => {
+
+ const type = n.nodeName.toLowerCase();
+ if (type === 'geometry') {
+
+ const geoType = n.children[0].nodeName.toLowerCase();
+ if (geoType === 'mesh') {
+
+ const filename = n.children[0].getAttribute('filename');
+ const filePath = resolvePath(filename);
+
+ // file path is null if a package directory is not provided.
+ if (filePath !== null) {
+
+ const scaleAttr = n.children[0].getAttribute('scale');
+ if (scaleAttr) {
+
+ const scale = processTuple(scaleAttr);
+ group.scale.set(scale[0], scale[1], scale[2]);
+
+ }
+
+ loadMeshCb(filePath, manager, (obj, err) => {
+
+ if (err) {
+
+ console.error('URDFLoader: Error loading mesh.', err);
+
+ } else if (obj) {
+
+ if (obj instanceof THREE.Mesh) {
+
+ obj.material = material;
+
+ }
+
+ // We don't expect non identity rotations or positions. In the case of
+ // COLLADA files the model might come in with a custom scale for unit
+ // conversion.
+ obj.position.set(0, 0, 0);
+ obj.quaternion.identity();
+ group.add(obj);
+
+ }
+
+ });
+
+ }
+
+ } else if (geoType === 'box') {
+
+ const primitiveModel = new THREE.Mesh();
+ primitiveModel.geometry = new THREE.BoxGeometry(1, 1, 1);
+ primitiveModel.material = material;
+
+ const size = processTuple(n.children[0].getAttribute('size'));
+ primitiveModel.scale.set(size[0], size[1], size[2]);
+
+ group.add(primitiveModel);
+
+ } else if (geoType === 'sphere') {
+
+ const primitiveModel = new THREE.Mesh();
+ primitiveModel.geometry = new THREE.SphereGeometry(1, 30, 30);
+ primitiveModel.material = material;
+
+ const radius = parseFloat(n.children[0].getAttribute('radius')) || 0;
+ primitiveModel.scale.set(radius, radius, radius);
+
+ group.add(primitiveModel);
+
+ } else if (geoType === 'cylinder') {
+
+ const primitiveModel = new THREE.Mesh();
+ primitiveModel.geometry = new THREE.CylinderGeometry(1, 1, 1, 30);
+ primitiveModel.material = material;
+
+ const radius = parseFloat(n.children[0].getAttribute('radius')) || 0;
+ const length = parseFloat(n.children[0].getAttribute('length')) || 0;
+ primitiveModel.scale.set(radius, length, radius);
+ primitiveModel.rotation.set(Math.PI / 2, 0, 0);
+
+ group.add(primitiveModel);
+
+ }
+
+ } else if (type === 'origin') {
+
+ const xyz = processTuple(n.getAttribute('xyz'));
+ const rpy = processTuple(n.getAttribute('rpy'));
+
+ group.position.set(xyz[0], xyz[1], xyz[2]);
+ group.rotation.set(0, 0, 0);
+ applyRotation(group, rpy);
+
+ }
+
+ });
+
+ return group;
+
+ }
+
+ return processUrdf(content);
+
+ }
+
+ // Default mesh loading function
+ defaultMeshLoader(path, manager, done) {
+
+ if (/\.stl$/i.test(path)) {
+
+ const loader = new STLLoader(manager);
+ loader.load(path, geom => {
+ const mesh = new THREE.Mesh(geom, new THREE.MeshPhongMaterial());
+ done(mesh);
+ });
+
+ } else if (/\.dae$/i.test(path)) {
+
+ const loader = new ColladaLoader(manager);
+ loader.load(path, dae => done(dae.scene));
+
+ } else {
+
+ console.warn(`URDFLoader: Could not load model at ${ path }.\nNo loader available`);
+
+ }
+
+ }
+
+};
\ No newline at end of file
diff --git a/ros_robot_visualiser/urdf/output.urdf b/ros_robot_visualiser/urdf/output.urdf
new file mode 100644
index 0000000..f7b186e
--- /dev/null
+++ b/ros_robot_visualiser/urdf/output.urdf
@@ -0,0 +1,446 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/ros_robot_visualiser/urdf/sample.urdf b/ros_robot_visualiser/urdf/sample.urdf
new file mode 100644
index 0000000..727e565
--- /dev/null
+++ b/ros_robot_visualiser/urdf/sample.urdf
@@ -0,0 +1,705 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ transmission_interface/SimpleTransmission
+
+ PositionJointInterface
+
+
+ 1
+ PositionJointInterface
+ 4096
+ 200
+ 3500
+ 270
+
+
+
+
+ transmission_interface/SimpleTransmission
+
+ PositionJointInterface
+
+
+ 1
+ PositionJointInterface
+ 4096
+ 200
+ 3500
+ 270
+
+
+
+
+ transmission_interface/SimpleTransmission
+
+ PositionJointInterface
+
+
+ 1
+ PositionJointInterface
+ 4096
+ 200
+ 3500
+ 270
+
+
+
+
+ transmission_interface/SimpleTransmission
+
+ PositionJointInterface
+
+
+ 1
+ PositionJointInterface
+ 4096
+ 200
+ 3500
+ 270
+
+
+
+
+ transmission_interface/SimpleTransmission
+
+ PositionJointInterface
+
+
+ 1
+ PositionJointInterface
+ 4096
+ 200
+ 3500
+ 270
+
+
+
+
+ transmission_interface/SimpleTransmission
+
+ PositionJointInterface
+
+
+ 1
+ PositionJointInterface
+ 4096
+ 200
+ 3500
+ 270
+
+
+
+
+ transmission_interface/SimpleTransmission
+
+ PositionJointInterface
+
+
+ 1
+ PositionJointInterface
+ 4096
+ 200
+ 3500
+ 270
+
+
+
+
+ transmission_interface/SimpleTransmission
+
+ PositionJointInterface
+
+
+ 1
+ PositionJointInterface
+ 4096
+ 200
+ 3500
+ 270
+
+
+
+
+ transmission_interface/SimpleTransmission
+
+ PositionJointInterface
+
+
+ 1
+ PositionJointInterface
+ 4096
+ 200
+ 3500
+ 270
+
+
+
+
+ transmission_interface/SimpleTransmission
+
+ PositionJointInterface
+
+
+ 1
+ PositionJointInterface
+ 4096
+ 200
+ 3500
+ 270
+
+
+
+
+ transmission_interface/SimpleTransmission
+
+ PositionJointInterface
+
+
+ 1
+ PositionJointInterface
+ 4096
+ 200
+ 3500
+ 270
+
+
+
+
+ transmission_interface/SimpleTransmission
+
+ PositionJointInterface
+
+
+ 1
+ PositionJointInterface
+ 4096
+ 200
+ 3500
+ 270
+
+
+
+
+ transmission_interface/SimpleTransmission
+
+ PositionJointInterface
+
+
+ 1
+ PositionJointInterface
+ 4096
+ 200
+ 3500
+ 270
+
+
+
+ transmission_interface/SimpleTransmission
+
+ PositionJointInterface
+
+
+ 1
+ PositionJointInterface
+ 4096
+ 200
+ 3500
+ 270
+
+
+
+ transmission_interface/SimpleTransmission
+
+ PositionJointInterface
+
+
+ 1
+ PositionJointInterface
+ 4096
+ 500
+ 4095
+ 270
+
+
+
+
\ No newline at end of file
diff --git a/ros_robot_visualiser/urdf/test b/ros_robot_visualiser/urdf/test
new file mode 100644
index 0000000..e69de29