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'; import { clamp } from 'three/src/math/MathUtils.js'; const gltfLoader = new GLTFLoader(); const SyncMode = { None: 0, SimToReal: 1, RealToSim: 2, Calibrate: 3 }; export class URDFEditor { constructor(canvas, sendMotorPosition, serial, curveEditor, tryConnect) { this.canvas = canvas; this.sendMotorPosition = sendMotorPosition; this.serial = serial; this.curveEditor = curveEditor; this.tryConnect = tryConnect; 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.currentSyncMode = SyncMode.None; this.hoveredJoint = null; this.draggedJoint = null; this.selectedJoint = 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(); const editorCallbacks = {} this.overlay = new ViewerOverlay( this.renderer, this.robot, this.jointAngles, this.findObjectByName.bind(this), 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)); } resetEditor() { if (!this.robot) return; const toRemove = this.scene.children.filter(c => c.type === 'URDFLink' || c.constructor.name === 'URDFRobot'); toRemove.forEach(robot => { // detach from scene this.scene.remove(robot); // dispose GPU resources robot.traverse(obj => { if (obj.geometry) obj.geometry.dispose(); if (obj.material) { if (Array.isArray(obj.material)) obj.material.forEach(m => m.dispose()); else obj.material.dispose(); } }); }); // Clear references this.robot.urdfRobotNode = null; this.robot.joints = {}; this.robot.links = {}; this.robot.sceneObject = null; this.robot = null; this.overlay.resetEditor(); } setAnimationLength(totalFrames) { //this.overlay. } async loadURDF() { if (this.robot) { console.log("resetting editor"); this.resetEditor(); } 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() { if (this.robot) { console.log("resetting editor"); this.resetEditor(); } 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() { const serializer = new XMLSerializer(); const urdfString = serializer.serializeToString(this.robot.urdfRobotNode); const a = document.createElement("a"); a.href = "data:text/xml;charset=utf-8," + encodeURIComponent(urdfString); a.download = "robot.urdf"; a.click(); } async uploadURDF() { // 1. Let the user pick a file const input = document.createElement('input'); input.type = 'file'; input.accept = '.urdf,.xml'; // restrict to URDF/XML files if (this.robot) { console.log("resetting editor"); this.resetEditor(); } input.onchange = async (e) => { const file = e.target.files[0]; if (!file) return; // 2. Reset editor if a robot is already loaded if (this.robot) { console.log("resetting editor"); this.resetEditor(); } // 3. Read the file contents const urdfText = await file.text(); // 4. Parse and load robot const robot = await this.loader.loadFromString(urdfText); robot.rotation.x = -Math.PI / 2; this.scene.add(robot); this.robot = robot; // 5. Initialize overlay this.overlay.init(robot); }; // trigger the file picker input.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; const jointTransmission = getJointTransmission(jointName, this.robot); this.onRotateMotor(jointTransmission.motorID, radiansToTicks(clampedAngle, jointTransmission).toFixed(0)); // ✅ Apply mimic relationships automatically 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 // Hover highlighting if (intersects.length > 0) { const target = intersects[0].object; // reset previous hover if it's not selected if (this.hoveredJoint && this.hoveredJoint !== this.selectedJoint) { if (this.hoveredJoint?.material?.emissive) { this.hoveredJoint.material.emissive.setHex(0x000000); } } this.hoveredJoint = target; if (this.hoveredJoint.material?.emissive) { // use a different color if it's also selected const color = (this.hoveredJoint === this.selectedJoint) ? 0x555555 : 0x333333; this.hoveredJoint.material.emissive.setHex(color); } } else { if (this.hoveredJoint && this.hoveredJoint !== this.selectedJoint) { if (this.hoveredJoint?.material?.emissive) { this.hoveredJoint.material.emissive.setHex(0x000000); } } this.hoveredJoint = null; } } setSyncMode(newMode) { console.log("changing from " + this.currentSyncMode + " to " + newMode); this.currentSyncMode = newMode; if (this.currentSyncMode == SyncMode.RealToSim) { this.serial.requestPositionStreaming(true); } else { this.serial.requestPositionStreaming(false); } } findMotorByID(motorID) { for (const jointName in this.robot.joints) { const joint = this.robot.joints[jointName]; const transmission = joint.transmission; if (!transmission) continue; if (transmission.motorID === motorID) { return transmission; } } } findAllMotorIDs() { let list = []; for (const jointName in this.robot.joints) { const joint = this.robot.joints[jointName]; const transmission = joint.transmission; if (!transmission) continue; list.push(transmission.motorID); } return list; } getMotorTicks(motorID) { if (!this.robot?.joints) return null; for (const [jointName, jointData] of Object.entries(this.robot.joints)) { const t = jointData.transmission; if (!t) continue; //console.log(t); if (t.motorID === motorID) { const angle = this.jointAngles?.[jointName] ?? 0; return parseInt(radiansToTicks(angle, t)); } } return null; // not found } syncWithCurveEditor() { let allIDs = this.findAllMotorIDs(); let ids = []; let positions = []; allIDs.forEach(id => { let pos = this.curveEditor.getMotorPositionAtTime(id, this.curveEditor.currentTime); if (this.currentSyncMode !== SyncMode.RealToSim) { this.setMotorPosition(id, pos); } ids.push(id); positions.push(pos); }); if (this.currentSyncMode === SyncMode.SimToReal) { this.sendMotorPosition(ids, positions); } } setMotorPosition(motorID, positionTicks) { for (const jointName in this.robot.joints) { const joint = this.robot.joints[jointName]; const transmission = joint.transmission; if (!transmission) continue; if (transmission.motorID === motorID) { // 1) Convert ticks → target joint angle (radians) const targetAngle = ticksToRadians(positionTicks, transmission); // 2) Compute delta vs tracked state const currentAngle = this.jointAngles[jointName] ?? 0; const delta = targetAngle - currentAngle; // 3) Rotate driven joint joint.rotateOnAxis(joint.axis, delta); this.jointAngles[jointName] = targetAngle; // 4) ✅ Apply mimic relationships for (const [name, jointObj] of Object.entries(this.robot.joints)) { if (jointObj.type === 'URDFMimicJoint') { const { mimicJoint, multiplier = 1.0, offset = 0.0 } = jointObj; if (mimicJoint === jointName) { const mimicAngle = targetAngle * multiplier + offset; const mimicCurrent = this.jointAngles[name] ?? 0; const mimicDelta = mimicAngle - mimicCurrent; const mimicNode = this.robot.getObjectByName(name); mimicNode.rotateOnAxis(mimicNode.axis, mimicDelta); this.jointAngles[name] = mimicAngle; } } } break; // found and updated the matching joint } } } onRotateMotor(motorID, positionTicks) { if (this.currentSyncMode === SyncMode.SimToReal) { this.sendMotorPosition(motorID, positionTicks); } } onPointerDown(event) { if (!this.hoveredJoint) return; if (this.hoveredJoint) { // reset previous selection if (this.selectedJoint) { this.selectedJoint.material.emissive.setHex(0x000000); } this.selectedJoint = this.hoveredJoint; this.selectedJoint.material.emissive.setHex(0x555555); // selection color } 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; } applyCalibrationOffsets() { console.log(this.jointAngles); if (!this.robot?.urdfRobotNode || !this.jointAngles) return; for (const jointName in this.jointAngles) { const offset = this.jointAngles[jointName]; // radians const jointNode = this.robot.urdfRobotNode.querySelector(`joint[name="${jointName}"]`); if (!jointNode) continue; // get or create let originNode = jointNode.querySelector("origin"); let [rx, ry, rz] = originNode?.getAttribute("rpy")?.split(" ").map(parseFloat) || [0, 0, 0]; // determine axis of rotation const axisNode = jointNode.querySelector("axis"); const [ax, ay, az] = axisNode ? axisNode.getAttribute("xyz").split(" ").map(parseFloat) : [0, 0, 1]; // apply offset along the correct axis if (Math.abs(ax) === 1) rx += offset * ax; else if (Math.abs(ay) === 1) ry += offset * ay; else if (Math.abs(az) === 1) rz += offset * az; // update the URDF DOM console.log(jointName, [rx, ry, rz]); this.updateJointOriginRpy(jointName, [rx, ry, rz]); } } updateJointOriginRpy(jointName, rpyArray) { //console.log("Updating URDF joint rpy:", jointName, rpyArray); const urdfNode = this.robot?.urdfRobotNode; if (!urdfNode) return; // find joint anywhere in the URDF const jointNode = urdfNode.querySelector(`joint[name="${jointName}"]`); if (!jointNode) return; // find or create the child let originNode = jointNode.querySelector("origin"); if (!originNode) { originNode = urdfNode.ownerDocument.createElement("origin"); jointNode.appendChild(originNode); } // ensure we have 3 numbers [rx, ry, rz] const [rx, ry, rz] = rpyArray; originNode.setAttribute("rpy", `${rx} ${ry} ${rz}`); } 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); } }