diff --git a/.env b/.env index ef759af..658fd09 100644 --- a/.env +++ b/.env @@ -1,3 +1,3 @@ VITE_DEVICE_ID = 'agv_src2200' -VITE_SERVICE_PORT = 3000 \ No newline at end of file +VITE_SERVICE_PORT = 5175 \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index e156e62..1f125b5 100644 --- a/Dockerfile +++ b/Dockerfile @@ -30,9 +30,9 @@ COPY server/ ./server/ # 从构建阶段拷贝前端产物 COPY --from=builder /app/dist ./dist -EXPOSE 3001 +EXPOSE 5175 ENV NODE_ENV=production -ENV VITE_SERVICE_PORT=3000 +ENV VITE_SERVICE_PORT=5175 CMD ["node", "server/index.js"] \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index 991853c..77f3b00 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -5,10 +5,10 @@ services: dockerfile: Dockerfile container_name: inspection-host-computer ports: - - "3000:3000" # 映射端口 + - "5175:5175" # 映射端口 environment: - NODE_ENV=production - - VITE_SERVICE_PORT=3000 # 传给 Express 的环境变量 + - VITE_SERVICE_PORT=5175 # 传给 Express 的环境变量 restart: unless-stopped # 容器意外停止后自动重启 # 如果需要挂载日志或配置,可以加 volumes # volumes: diff --git a/package.json b/package.json index 3032681..0c7f507 100644 --- a/package.json +++ b/package.json @@ -23,8 +23,11 @@ "fast-glob": "^3.3.3", "pinia": "^3.0.4", "pinia-plugin-persistedstate": "^4.7.1", + "three": "^0.181.2", + "urdf-loader": "^0.12.6", "vue": "^3.5.38", - "vue-router": "^5.1.0" + "vue-router": "^5.1.0", + "xacro-parser": "^0.3.11" }, "devDependencies": { "@vitejs/plugin-vue": "^6.0.7", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3a32555..a887fd9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -41,12 +41,21 @@ importers: pinia-plugin-persistedstate: specifier: ^4.7.1 version: 4.7.1(pinia@3.0.4(vue@3.5.38)) + three: + specifier: ^0.181.2 + version: 0.181.2 + urdf-loader: + specifier: ^0.12.6 + version: 0.12.7(three@0.181.2) vue: specifier: ^3.5.38 version: 3.5.38 vue-router: specifier: ^5.1.0 version: 5.1.0(@vue/compiler-sfc@3.5.38)(pinia@3.0.4(vue@3.5.38))(vite@8.1.0(@types/node@26.0.0)(sass@1.101.0)(yaml@2.9.0))(vue@3.5.38) + xacro-parser: + specifier: ^0.3.11 + version: 0.3.11 devDependencies: '@vitejs/plugin-vue': specifier: ^6.0.7 @@ -963,6 +972,10 @@ packages: resolution: {integrity: sha512-w/ozOKR9Obk3qoWeY/WDi6MFta9AoMR+zud60mdnbniMcBxRuFJyDt2LdX/14A1UABeqk+Uk+LDfUpvoGKppZA==} engines: {node: '>=0.10.0'} + expr-eval-fork@3.0.3: + resolution: {integrity: sha512-BhC+hbc5lIVjygr840n5DEkW3MQq7H9o+mc1/N7Z5uIiCFVyESLL5DIE7LNq4CYUNxy+XjA+3jRrL/h0Kt2xcg==} + engines: {node: '>=16.9.0'} + express@5.2.1: resolution: {integrity: sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==} engines: {node: '>= 18'} @@ -2045,6 +2058,9 @@ packages: engines: {node: '>=14.0.0'} hasBin: true + three@0.181.2: + resolution: {integrity: sha512-k/CjiZ80bYss6Qs7/ex1TBlPD11whT9oKfT8oTGiHa34W4JRd1NiH/Tr1DbHWQ2/vMUypxksLnF2CfmlmM5XFQ==} + tinyglobby@0.2.17: resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} engines: {node: '>=12.0.0'} @@ -2168,6 +2184,11 @@ packages: resolution: {integrity: sha512-PcA2tsuGSF9cnySLHTLSh2qrQiJ70mn+r+Glzxv2TWZblxsxCC52BDlZoPCsz7STd9pN7EZetkWZBAvk4cgZdQ==} engines: {node: '>=0.10.0'} + urdf-loader@0.12.7: + resolution: {integrity: sha512-9RQDhWt4x9K6R3uHjv68NLwX+JDm3A2XjhksrMgq6xl9NJcwqV21+htHU0cbG619hnqH1d4mUMFuqHo22R8DMA==} + peerDependencies: + three: '>=0.152.0' + urix@0.1.0: resolution: {integrity: sha512-Am1ousAhSLBeB9cG/7k7r2R0zj50uDRlZHPGbazid5s9rlF1F/QKYObEKSIunSjIOkJZqwRRLpvewjEkM7pSqg==} deprecated: Please see https://github.com/lydell/urix#deprecated @@ -2300,6 +2321,9 @@ packages: wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + xacro-parser@0.3.11: + resolution: {integrity: sha512-zyRzHmf6/BySLZQP77zp6iEtdryq1g7tWtfwj/28GOPyf9ijtPvouivOAeaA/yAu0/Bwh20DtA4x+BrYiV6qKQ==} + y18n@5.0.8: resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} engines: {node: '>=10'} @@ -3282,6 +3306,8 @@ snapshots: transitivePeerDependencies: - supports-color + expr-eval-fork@3.0.3: {} + express@5.2.1: dependencies: accepts: 2.0.0 @@ -4487,6 +4513,8 @@ snapshots: picocolors: 1.1.1 sax: 1.6.0 + three@0.181.2: {} + tinyglobby@0.2.17: dependencies: fdir: 6.5.0(picomatch@4.0.4) @@ -4660,6 +4688,10 @@ snapshots: has-value: 0.3.1 isobject: 3.0.1 + urdf-loader@0.12.7(three@0.181.2): + dependencies: + three: 0.181.2 + urix@0.1.0: {} use@3.1.1: {} @@ -4799,6 +4831,12 @@ snapshots: wrappy@1.0.2: {} + xacro-parser@0.3.11: + dependencies: + expr-eval-fork: 3.0.3 + yaml: 2.9.0 + yargs: 17.7.3 + y18n@5.0.8: {} yaml@2.9.0: {} diff --git a/public/inspection/elfin10/elfin10.urdf.xacro b/public/inspection/elfin10/elfin10.urdf.xacro new file mode 100644 index 0000000..4aa83c3 --- /dev/null +++ b/public/inspection/elfin10/elfin10.urdf.xacro @@ -0,0 +1,465 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/public/inspection/elfin10/elfin10_gazebo.xacro b/public/inspection/elfin10/elfin10_gazebo.xacro new file mode 100644 index 0000000..01581e9 --- /dev/null +++ b/public/inspection/elfin10/elfin10_gazebo.xacro @@ -0,0 +1,134 @@ + + + + + + + + + gazebo_ros2_control/GazeboSystem + $(find elfin10_ros2_gazebo)/config/elfin_arm_controller.yaml + + + + + + + + elfin_hardware_interface/ElfinHWInterface + + + + + fake_components/GenericSystem + + + + gazebo_ros2_control/GazeboSystem + + + + + + + + -3.14 + 3.14 + + + -1.57 + 1.57 + + + + + + + -2.35 + 2.35 + + + -1.57 + 1.57 + + + + + + + -2.61 + 2.61 + + + -1.57 + 1.57 + + + + + + + -3.14 + 3.14 + + + -1.57 + 1.57 + + + + + + + -2.56 + 2.56 + + + -1.57 + 1.57 + + + + + + + -3.14 + 3.14 + + + -1.57 + 1.57 + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/public/inspection/elfin10/elfin10_transmission.xacro b/public/inspection/elfin10/elfin10_transmission.xacro new file mode 100644 index 0000000..12dffc7 --- /dev/null +++ b/public/inspection/elfin10/elfin10_transmission.xacro @@ -0,0 +1,69 @@ + + + + + + + + transmission_interface/SimpleTransmission + + hardware_interface/PositionJointInterface + + + 1 + + + + + transmission_interface/SimpleTransmission + + hardware_interface/PositionJointInterface + + + 1 + + + + + transmission_interface/SimpleTransmission + + hardware_interface/PositionJointInterface + + + 1 + + + + + transmission_interface/SimpleTransmission + + hardware_interface/PositionJointInterface + + + 1 + + + + + transmission_interface/SimpleTransmission + + hardware_interface/PositionJointInterface + + + 1 + + + + + transmission_interface/SimpleTransmission + + hardware_interface/PositionJointInterface + + + 1 + + + + + + \ No newline at end of file diff --git a/public/inspection/elfin10/meshes/elfin10/elfin_base.STL b/public/inspection/elfin10/meshes/elfin10/elfin_base.STL new file mode 100644 index 0000000..fa9dd2b Binary files /dev/null and b/public/inspection/elfin10/meshes/elfin10/elfin_base.STL differ diff --git a/public/inspection/elfin10/meshes/elfin10/elfin_end_link.STL b/public/inspection/elfin10/meshes/elfin10/elfin_end_link.STL new file mode 100644 index 0000000..8536f5d Binary files /dev/null and b/public/inspection/elfin10/meshes/elfin10/elfin_end_link.STL differ diff --git a/public/inspection/elfin10/meshes/elfin10/elfin_link1.STL b/public/inspection/elfin10/meshes/elfin10/elfin_link1.STL new file mode 100644 index 0000000..ca0e111 Binary files /dev/null and b/public/inspection/elfin10/meshes/elfin10/elfin_link1.STL differ diff --git a/public/inspection/elfin10/meshes/elfin10/elfin_link2.STL b/public/inspection/elfin10/meshes/elfin10/elfin_link2.STL new file mode 100644 index 0000000..e5a2628 Binary files /dev/null and b/public/inspection/elfin10/meshes/elfin10/elfin_link2.STL differ diff --git a/public/inspection/elfin10/meshes/elfin10/elfin_link3.STL b/public/inspection/elfin10/meshes/elfin10/elfin_link3.STL new file mode 100644 index 0000000..bcefa4f Binary files /dev/null and b/public/inspection/elfin10/meshes/elfin10/elfin_link3.STL differ diff --git a/public/inspection/elfin10/meshes/elfin10/elfin_link4.STL b/public/inspection/elfin10/meshes/elfin10/elfin_link4.STL new file mode 100644 index 0000000..565329d Binary files /dev/null and b/public/inspection/elfin10/meshes/elfin10/elfin_link4.STL differ diff --git a/public/inspection/elfin10/meshes/elfin10/elfin_link5.STL b/public/inspection/elfin10/meshes/elfin10/elfin_link5.STL new file mode 100644 index 0000000..a6d7816 Binary files /dev/null and b/public/inspection/elfin10/meshes/elfin10/elfin_link5.STL differ diff --git a/public/inspection/elfin10/meshes/elfin10/elfin_link6.STL b/public/inspection/elfin10/meshes/elfin10/elfin_link6.STL new file mode 100644 index 0000000..996d478 Binary files /dev/null and b/public/inspection/elfin10/meshes/elfin10/elfin_link6.STL differ diff --git a/src/adapters/URDFAdapter.js b/src/adapters/URDFAdapter.js new file mode 100644 index 0000000..cbbfceb --- /dev/null +++ b/src/adapters/URDFAdapter.js @@ -0,0 +1,461 @@ +/** + * URDF Adapter + * Converts urdf-loaders result to unified model + */ +import * as THREE from 'three'; +import { UnifiedRobotModel, Link, Joint, JointLimits, VisualGeometry, CollisionGeometry, InertialProperties } from '@/models/UnifiedRobotModel.js'; + +export class URDFAdapter { + /** + * Convert urdf-loaders robot object to unified model + * @param {THREE.Group} robot - Robot object returned by urdf-loaders + * @param {string} urdfXML - Original URDF XML content (optional, for extracting inertial data) + * @returns {UnifiedRobotModel} + */ + static convert(robot, urdfXML = null) { + const model = new UnifiedRobotModel(); + model.name = robot.name || 'robot'; + model.threeObject = robot; + + // Mark model type as URDF + if (!robot.userData) robot.userData = {}; + robot.userData.type = 'urdf'; + + if (!robot.links || !robot.joints) { + console.warn('URDF model missing links or joints information'); + return model; + } + + // If XML provided, parse inertial data + let inertialData = {}; + if (urdfXML) { + inertialData = this.parseInertialFromXML(urdfXML); + } + + // Convert links + Object.values(robot.links).forEach(urdfLink => { + const link = this.convertLink(urdfLink); + + // If urdf-loader didn't parse inertial, get from XML + if (!link.inertial && inertialData[urdfLink.name]) { + link.inertial = inertialData[urdfLink.name]; + } + + model.addLink(link); + }); + + // Convert joints + Object.values(robot.joints).forEach(urdfJoint => { + const joint = this.convertJoint(urdfJoint); + model.addJoint(joint); + }); + + // If XML provided, supplement effort and velocity from XML (urdf-loaders may not have parsed) + if (urdfXML) { + this.supplementJointLimitsFromXML(model, urdfXML); + } + + // Find root link (link that is not a child of any joint) + const allChildren = new Set( + Array.from(model.joints.values()).map(j => j.child).filter(c => c) + ); + const rootLinks = Array.from(model.links.keys()).filter( + name => !allChildren.has(name) + ); + if (rootLinks.length > 0) { + model.rootLink = rootLinks[0]; + } + + // Enhance materials for better lighting (MuJoCo style) + this.enhanceMaterials(robot); + + return model; + } + + /** + * Enhance materials for better lighting (MuJoCo style) + * Applies consistent shininess and specular properties to all materials + * Directly modifies materials in place to ensure changes persist + */ + static enhanceMaterials(robotObject) { + robotObject.traverse((child) => { + if (child.isMesh && child.material) { + // Handle material arrays + if (Array.isArray(child.material)) { + child.material = child.material.map(mat => { + return this.enhanceSingleMaterial(mat); + }); + } else { + child.material = this.enhanceSingleMaterial(child.material); + } + } + }); + } + + /** + * Enhance a single material with better lighting properties + * Returns enhanced material (may be cloned or modified in place) + * Saves original properties for lighting toggle + */ + static enhanceSingleMaterial(material) { + if (material.isMeshPhongMaterial || material.isMeshStandardMaterial) { + // Save original properties if not already saved (for lighting toggle) + if (material.userData.originalShininess === undefined) { + material.userData.originalShininess = material.shininess !== undefined ? material.shininess : 30; + // IMPORTANT: Save original color to preserve URDF material colors + if (!material.userData.originalColor && material.color) { + material.userData.originalColor = material.color.clone(); + } + // Save original specular - if material had no specular, save null + if (!material.specular) { + material.userData.originalSpecular = null; + } else if (material.specular.isColor) { + const spec = material.specular; + if (spec.r < 0.1 && spec.g < 0.1 && spec.b < 0.1) { + material.userData.originalSpecular = null; // Likely default + } else { + material.userData.originalSpecular = spec.clone(); + } + } else if (typeof material.specular === 'number') { + if (material.specular === 0x111111 || material.specular < 0x111111) { + material.userData.originalSpecular = null; + } else { + material.userData.originalSpecular = new THREE.Color(material.specular); + } + } else { + material.userData.originalSpecular = null; + } + } + + // IMPORTANT: Preserve original color from URDF material definitions + // Do not modify material.color - urdf-loaders already sets the correct color + + // Apply enhanced lighting (default enabled) + // Increase shininess for better highlights + if (material.shininess === undefined || material.shininess < 50) { + material.shininess = 50; + } + + // Enhance specular reflection - ensure it's a Color object with proper values + if (!material.specular) { + material.specular = new THREE.Color(0.3, 0.3, 0.3); + } else if (material.specular.isColor) { + // If it's already a Color object, update values + if (material.specular.r < 0.2 || material.specular.g < 0.2 || material.specular.b < 0.2) { + material.specular.setRGB(0.3, 0.3, 0.3); + } + } else if (typeof material.specular === 'number') { + // Convert number to Color object + if (material.specular < 0x333333) { + material.specular = new THREE.Color(0.3, 0.3, 0.3); + } else { + // Convert hex to Color + material.specular = new THREE.Color(material.specular); + if (material.specular.r < 0.2) { + material.specular.setRGB(0.3, 0.3, 0.3); + } + } + } + + // Mark material as needing update + material.needsUpdate = true; + return material; + } else if (material.type === 'MeshBasicMaterial') { + // Convert MeshBasicMaterial to MeshPhongMaterial + const oldMaterial = material; + const envMap = typeof window !== 'undefined' && window.app?.sceneManager?.environmentManager?.getEnvironmentMap(); + const newMaterial = new THREE.MeshPhongMaterial({ + color: oldMaterial.color, + map: oldMaterial.map, + transparent: oldMaterial.transparent, + opacity: oldMaterial.opacity, + side: oldMaterial.side, + shininess: 50, + specular: new THREE.Color(0.3, 0.3, 0.3), + envMap: envMap || null, + reflectivity: envMap ? 0.3 : 0 + }); + // Save original properties for lighting toggle + newMaterial.userData.originalShininess = 30; + newMaterial.userData.originalSpecular = null; // MeshBasicMaterial had no specular + return newMaterial; + } + + return material; + } + + static convertLink(urdfLink) { + const link = new Link(urdfLink.name); + link.threeObject = urdfLink; + + // Convert inertial properties + if (urdfLink.inertial) { + link.inertial = this.convertInertial(urdfLink.inertial); + } + + // Note: urdf-loaders has already converted visual and collision to Three.js objects + // We mainly extract metadata, actual meshes are in threeObject + + return link; + } + + static convertJoint(urdfJoint) { + // URDF joint object has jointType property (not type) + const jointType = urdfJoint.jointType || urdfJoint.type || 'fixed'; + const joint = new Joint(urdfJoint.name, jointType); + + // Extract parent name (urdf-loader returns Three.js object reference) + joint.parent = urdfJoint.parent?.name || null; + + // child may not be in urdfJoint.child, but in Three.js children array + // In Three.js scene graph, Joint is parent node, Child Link is Joint's child node + if (urdfJoint.child && urdfJoint.child.name) { + joint.child = urdfJoint.child.name; + } else if (urdfJoint.children && urdfJoint.children.length > 0) { + // Find Link object from children array + const childLink = urdfJoint.children.find(child => + child.isURDFLink || child.type === 'URDFLink' + ); + if (childLink) { + joint.child = childLink.name; + } + } + + joint.threeObject = urdfJoint; + + // Convert origin + if (urdfJoint.origin) { + joint.origin = { + xyz: urdfJoint.origin.xyz || [0, 0, 0], + rpy: urdfJoint.origin.rpy || [0, 0, 0] + }; + } + + // Convert axis + if (urdfJoint.axis) { + joint.axis = { xyz: urdfJoint.axis.xyz || [0, 0, 1] }; + } + + // Convert limits + if (urdfJoint.limit) { + joint.limits = this.convertLimits(urdfJoint.limit); + } + + // Get current value + if (urdfJoint.angle !== undefined) { + joint.currentValue = urdfJoint.angle; + } else if (urdfJoint.jointValue !== undefined) { + joint.currentValue = urdfJoint.jointValue; + } + + return joint; + } + + static convertLimits(limit) { + const limits = new JointLimits(); + if (limit.lower !== undefined) limits.lower = limit.lower; + if (limit.upper !== undefined) limits.upper = limit.upper; + if (limit.effort !== undefined) limits.effort = limit.effort; + if (limit.velocity !== undefined) limits.velocity = limit.velocity; + return limits; + } + + /** + * Supplement joint effort and velocity information from URDF XML + * (because urdf-loaders may not have parsed these attributes) + */ + static supplementJointLimitsFromXML(model, urdfXML) { + try { + const parser = new DOMParser(); + const doc = parser.parseFromString(urdfXML, 'text/xml'); + + const jointElements = doc.querySelectorAll('joint'); + jointElements.forEach(jointEl => { + const jointName = jointEl.getAttribute('name'); + const joint = model.joints.get(jointName); + + if (joint) { + const limitEl = jointEl.querySelector('limit'); + if (limitEl) { + if (!joint.limits) { + joint.limits = new JointLimits(); + } + + // Read effort and velocity + const effort = limitEl.getAttribute('effort'); + const velocity = limitEl.getAttribute('velocity'); + + if (effort !== null) { + joint.limits.effort = parseFloat(effort); + } + if (velocity !== null) { + joint.limits.velocity = parseFloat(velocity); + } + + // Also ensure lower and upper are correctly set + const lower = limitEl.getAttribute('lower'); + const upper = limitEl.getAttribute('upper'); + if (lower !== null) { + joint.limits.lower = parseFloat(lower); + } + if (upper !== null) { + joint.limits.upper = parseFloat(upper); + } + } + } + }); + } catch (error) { + console.error('Failed to supplement joint limit information from XML:', error); + } + } + + static convertInertial(inertial) { + const props = new InertialProperties(); + if (inertial.mass !== undefined) props.mass = inertial.mass; + if (inertial.origin) { + props.origin = { + xyz: inertial.origin.xyz || [0, 0, 0], + rpy: inertial.origin.rpy || [0, 0, 0] + }; + } + if (inertial.ixx !== undefined) props.ixx = inertial.ixx; + if (inertial.iyy !== undefined) props.iyy = inertial.iyy; + if (inertial.izz !== undefined) props.izz = inertial.izz; + if (inertial.ixy !== undefined) props.ixy = inertial.ixy; + if (inertial.ixz !== undefined) props.ixz = inertial.ixz; + if (inertial.iyz !== undefined) props.iyz = inertial.iyz; + return props; + } + + /** + * Parse inertial data from URDF XML + * @param {string} xmlContent - URDF XML content + * @returns {Object} Mapping from link names to inertial data + */ + static parseInertialFromXML(xmlContent) { + const inertialData = {}; + + try { + const parser = new DOMParser(); + const xmlDoc = parser.parseFromString(xmlContent, 'text/xml'); + + // Get all link elements + const links = xmlDoc.getElementsByTagName('link'); + + for (let i = 0; i < links.length; i++) { + const linkElement = links[i]; + const linkName = linkElement.getAttribute('name'); + + // Find inertial element + const inertialElement = linkElement.getElementsByTagName('inertial')[0]; + if (!inertialElement) continue; + + const inertial = new InertialProperties(); + + // Parse mass + const massElement = inertialElement.getElementsByTagName('mass')[0]; + if (massElement) { + inertial.mass = parseFloat(massElement.getAttribute('value')); + } + + // Parse origin + const originElement = inertialElement.getElementsByTagName('origin')[0]; + if (originElement) { + const xyz = originElement.getAttribute('xyz'); + const rpy = originElement.getAttribute('rpy'); + inertial.origin = { + xyz: xyz ? xyz.split(' ').map(parseFloat) : [0, 0, 0], + rpy: rpy ? rpy.split(' ').map(parseFloat) : [0, 0, 0] + }; + } else { + inertial.origin = { xyz: [0, 0, 0], rpy: [0, 0, 0] }; + } + + // Parse inertia + const inertiaElement = inertialElement.getElementsByTagName('inertia')[0]; + if (inertiaElement) { + inertial.ixx = parseFloat(inertiaElement.getAttribute('ixx')) || 0; + inertial.iyy = parseFloat(inertiaElement.getAttribute('iyy')) || 0; + inertial.izz = parseFloat(inertiaElement.getAttribute('izz')) || 0; + inertial.ixy = parseFloat(inertiaElement.getAttribute('ixy')) || 0; + inertial.ixz = parseFloat(inertiaElement.getAttribute('ixz')) || 0; + inertial.iyz = parseFloat(inertiaElement.getAttribute('iyz')) || 0; + } + + inertialData[linkName] = inertial; + } + } catch (error) { + console.error('Failed to parse URDF XML inertial data:', error); + } + + return inertialData; + } + + /** + * Set joint angle (using urdf-loaders' setJointValue method) + * Reference URDFClasses.js setJointValue implementation + */ + static setJointAngle(joint, angle, ignoreLimits = false) { + joint.currentValue = angle; + + // URDF format: use urdf-loader's setJointValue method + if (joint.threeObject) { + // If ignoring limits, temporarily modify URDF joint object's limit values + let originalLimits = null; + if (ignoreLimits && joint.threeObject.limit) { + originalLimits = { + lower: joint.threeObject.limit.lower, + upper: joint.threeObject.limit.upper + }; + joint.threeObject.limit.lower = -Math.PI * 2; + joint.threeObject.limit.upper = Math.PI * 2; + } + + // Prefer setJointValue method (urdf-loader's standard method) + if (typeof joint.threeObject.setJointValue === 'function') { + joint.threeObject.setJointValue(angle); + + // Restore original limits + if (originalLimits && joint.threeObject.limit) { + joint.threeObject.limit.lower = originalLimits.lower; + joint.threeObject.limit.upper = originalLimits.upper; + } + return; + } else if (typeof joint.threeObject.setAngle === 'function') { + joint.threeObject.setAngle(angle); + + // Restore original limits + if (originalLimits && joint.threeObject.limit) { + joint.threeObject.limit.lower = originalLimits.lower; + joint.threeObject.limit.upper = originalLimits.upper; + } + return; + } + + // Restore original limits (if none of the above methods executed) + if (originalLimits && joint.threeObject.limit) { + joint.threeObject.limit.lower = originalLimits.lower; + joint.threeObject.limit.upper = originalLimits.upper; + } + + // If none available, manually set rotation + // Note: For URDF, we usually shouldn't reach here, as urdf-loader should provide setJointValue + console.warn(`Joint ${joint.name} has no setJointValue or setAngle method, attempting manual setup`); + if (joint.type === 'revolute' || joint.type === 'continuous') { + const axis = joint.axis ? new THREE.Vector3(...joint.axis.xyz).normalize() : new THREE.Vector3(0, 0, 1); + // Note: joint.threeObject may not be a Three.js object, cannot directly set rotation + if (joint.threeObject.rotation) { + joint.threeObject.rotation.setFromAxisAngle(axis, angle); + } + } else if (joint.type === 'prismatic') { + const axis = joint.axis ? new THREE.Vector3(...joint.axis.xyz).normalize() : new THREE.Vector3(1, 0, 0); + if (joint.threeObject.position) { + joint.threeObject.position.copy(axis.multiplyScalar(angle)); + } + } + } else { + console.warn(`Joint ${joint.name} has no threeObject`); + } + } +} + diff --git a/src/adapters/XacroAdapter.js b/src/adapters/XacroAdapter.js new file mode 100644 index 0000000..9a7275f --- /dev/null +++ b/src/adapters/XacroAdapter.js @@ -0,0 +1,624 @@ +/** + * Xacro Adapter + * + * Parses ROS Xacro files and converts them to URDF format using xacro-parser. + * + * Supported Features: + * - Property definitions and substitutions (xacro:property) + * - Macro definitions with parameters (xacro:macro) + * - Conditional blocks (xacro:if, xacro:unless) + * - File inclusions (xacro:include) with package:// and relative paths + * - Python-style boolean constants (True/False) + * - Arithmetic expressions and property evaluation + * + * Compatibility: + * - ROS Jade and later (inOrder=true, requirePrefix=true, localProperties=true) + * - Automatically injects True/False constants for compatibility with ROS xacro files + * + * Usage: + * - Upload all related xacro files (main file and included files) + * - The adapter automatically resolves file inclusions from the uploaded file map + * - Mesh files are resolved using package:// paths or relative paths + */ +import { XacroParser } from 'xacro-parser'; +import { URDFAdapter } from './URDFAdapter.js'; + +export class XacroAdapter { + /** + * Parse xacro content and convert to unified model + * @param {string} xacroContent - Xacro file content + * @param {string} fileName - Xacro file name (for working path) + * @param {Map} fileMap - File map (path -> File object) + * @param {File} file - Original file object (optional) + * @returns {Promise} + */ + static async parse(xacroContent, fileName, fileMap = null, file = null) { + try { + // Create xacro parser + const parser = new XacroParser(); + + // Configure parser for ROS Jade and later (default settings) + parser.inOrder = true; + parser.requirePrefix = true; + parser.localProperties = true; + + // Set working path (directory where xacro file is located) + const workingPath = fileName.includes('/') + ? fileName.substring(0, fileName.lastIndexOf('/') + 1) + : ''; + parser.workingPath = workingPath; + + // Inject Python-style boolean constants as xacro properties + // Some xacro files use True/False (capitalized) in conditions + xacroContent = this.injectBooleanConstants(xacroContent); + + // Extract and set xacro arguments with their default values + // Also add common ROS arguments that might be used without definition + const xacroArgs = this.extractXacroArguments(xacroContent); + + // Add common ROS arguments with sensible defaults if not already defined + if (!xacroArgs.hasOwnProperty('DEBUG')) { + xacroArgs.DEBUG = 'false'; + } + if (!xacroArgs.hasOwnProperty('SELF_COLLIDE')) { + xacroArgs.SELF_COLLIDE = 'false'; + } + + parser.arguments = xacroArgs; + + // If fileMap provided, setup custom file loader + if (fileMap) { + parser.getFileContents = async (path) => { + return await this.loadFileFromMap(path, fileMap, workingPath); + }; + } + + // Parse xacro to URDF XML + const urdfXML = await parser.parse(xacroContent); + + + // Convert XMLDocument to string + const serializer = new XMLSerializer(); + let urdfString = serializer.serializeToString(urdfXML); + + // Clean up the XML string - remove empty xmlns attributes that might cause issues + urdfString = urdfString.replace(/\sxmlns=""/g, ''); + + // Re-parse the URDF string with DOMParser to create a clean XMLDocument + // This removes any xacro-specific nodes that urdf-loader might not handle + const domParser = new DOMParser(); + const cleanUrdfXML = domParser.parseFromString(urdfString, 'text/xml'); + + // Check for parsing errors + const parseError = cleanUrdfXML.querySelector('parsererror'); + if (parseError) { + console.error('[XacroAdapter] XML parsing error:', parseError.textContent); + throw new Error('Generated URDF has invalid XML: ' + parseError.textContent); + } + + // Remove problematic elements that urdf-loader might not handle well + // Remove gazebo elements (plugins, sensors, etc.) + const gazeboElements = cleanUrdfXML.querySelectorAll('gazebo'); + gazeboElements.forEach(el => el.parentNode?.removeChild(el)); + + // Remove transmission elements (these are for Gazebo/ROS control) + const transmissionElements = cleanUrdfXML.querySelectorAll('transmission'); + transmissionElements.forEach(el => el.parentNode?.removeChild(el)); + + // Remove visual/collision elements with empty geometry tags + this.removeEmptyGeometry(cleanUrdfXML); + + // Clean up empty text nodes and comments that might cause issues + this.cleanXMLNodes(cleanUrdfXML.documentElement); + + // Convert the clean XMLDocument back to string for URDFLoader + const finalUrdfString = serializer.serializeToString(cleanUrdfXML); + + // Now use existing URDF loading infrastructure + // Import URDFLoader dynamically + const urdfModule = await import('urdf-loader'); + const URDFLoader = urdfModule.URDFLoader || urdfModule.default || urdfModule; + + return new Promise((resolve, reject) => { + const loader = new URDFLoader(); + loader.parseCollision = true; + + // Extract directory where URDF file is located + const urdfDir = workingPath; + + // If file map provided, setup resource loader (same as URDF loading) + if (fileMap) { + // Parse URDF content, find all used package names + const packages = this.extractPackagesFromURDF(finalUrdfString); + + // Build package map + const packageMap = {}; + packages.forEach(pkg => { + packageMap[pkg] = pkg; + }); + packageMap[''] = ''; + + loader.packages = packageMap; + + // Set URL Modifier (same as URDF loading) + const urlModifier = (url) => { + // Handle blob URLs + if (url.startsWith('blob:')) { + const blobMatch = url.match(/^blob:https?:\/\/[^\/]+\/(.+)$/); + if (blobMatch && blobMatch[1]) { + const fileName = blobMatch[1]; + if (/\.(jpg|jpeg|png|gif|bmp|tga|tiff|webp|dae|stl|obj|gltf|glb)$/i.test(fileName)) { + url = fileName; + } else { + return url; + } + } else { + return url; + } + } + + const isTextureFile = /\.(jpg|jpeg|png|gif|bmp|tga|tiff|webp)$/i.test(url); + const isMeshFile = /\.(dae|stl|obj|gltf|glb)$/i.test(url); + + let meshPath = url; + + // Remove http:// or https:// prefix + if (meshPath.startsWith('http://') || meshPath.startsWith('https://')) { + try { + const urlObj = new URL(meshPath); + meshPath = urlObj.pathname; + if (meshPath.startsWith('/')) { + meshPath = meshPath.substring(1); + } + } catch (e) { + // Invalid URL, use as-is + } + } + + // Remove package:// prefix + if (meshPath.startsWith('package://')) { + meshPath = meshPath.replace(/^package:\/\//, ''); + const parts = meshPath.split('/'); + if (parts.length > 1) { + meshPath = parts.slice(1).join('/'); + } + } + + // Remove leading ./ + meshPath = meshPath.replace(/^\.\//, ''); + + // Handle relative paths + let normalizedPath = meshPath; + if (meshPath.includes('../')) { + const parts = meshPath.split('/'); + const resolvedParts = []; + for (const part of parts) { + if (part === '..') { + resolvedParts.pop(); + } else if (part !== '.' && part !== '') { + resolvedParts.push(part); + } + } + normalizedPath = resolvedParts.join('/'); + } + + // Build full path + const fullPath = urdfDir + normalizedPath; + const altPath = urdfDir + meshPath; + + // Find file in fileMap + let matchedFile = fileMap.get(fullPath); + + if (!matchedFile && altPath !== fullPath) { + matchedFile = fileMap.get(altPath); + } + + if (!matchedFile) { + matchedFile = fileMap.get(normalizedPath); + } + + if (!matchedFile) { + matchedFile = fileMap.get(meshPath); + } + + if (!matchedFile) { + const targetFileName = normalizedPath.split('/').pop() || meshPath.split('/').pop(); + for (const [key, file] of fileMap.entries()) { + const keyFileName = key.split('/').pop(); + if (keyFileName === targetFileName) { + matchedFile = file; + break; + } + } + } + + if (matchedFile) { + const bloburl = URL.createObjectURL(matchedFile); + return bloburl; + } + + return url; + }; + + loader.manager.setURLModifier(urlModifier); + + // Custom loadMeshCb (same as URDF loading) + const originalLoadMeshCb = loader.loadMeshCb || loader.defaultMeshLoader.bind(loader); + loader.loadMeshCb = (path, manager, done) => { + this.findFileInMapByPath(path, fileMap, urdfDir).then(file => { + if (file) { + const ext = (file.name || path).toLowerCase().split('.').pop(); + this.loadMeshFileAsync(file, ext, manager).then(meshObject => { + if (meshObject) { + done(meshObject, null); + } else { + done(null, new Error(`Failed to load mesh file: ${path}`)); + } + }).catch(err => { + console.error(`Failed to load mesh: ${path}`, err); + done(null, err); + }); + } else { + let stlPath = path + stlPath = stlPath.replace('file:///', 'inspection/elfin10') + console.log(stlPath, manager, done) + originalLoadMeshCb(path, manager, done); + } + }).catch(error => { + console.error(`Failed to find file: ${path}`, error); + originalLoadMeshCb(path, manager, done); + }); + }; + } + + // Create temporary URL for URDF content + const blob = new Blob([finalUrdfString], { type: 'text/xml' }); + const url = URL.createObjectURL(blob); + + loader.load(url, (robot) => { + URL.revokeObjectURL(url); + + // Convert to unified model using URDFAdapter + try { + const model = URDFAdapter.convert(robot, finalUrdfString); + resolve(model); + } catch (error) { + console.error('[XacroAdapter] URDF conversion error:', error); + reject(new Error('URDF conversion failed: ' + error.message)); + } + }, undefined, (error) => { + URL.revokeObjectURL(url); + console.error('[XacroAdapter] URDF loading error:', error); + reject(new Error('URDF loading failed: ' + (error.message || error))); + }); + }); + } catch (error) { + console.error('[XacroAdapter] Xacro parsing error:', error); + throw new Error('Xacro parsing failed: ' + error.message); + } + } + + /** + * Load file from fileMap for xacro includes + * @param {string} path - File path + * @param {Map} fileMap - File map + * @param {string} workingPath - Working directory + * @returns {Promise} + */ + static async loadFileFromMap(path, fileMap, workingPath) { + console.log('path', path) + console.log('fileMap', fileMap) + console.log('workingPath', workingPath) + // Clean path - remove leading slash if present + let cleanPath = path; + // if (cleanPath.startsWith('/')) { + // cleanPath = cleanPath.substring(1); + // } + + // // Remove leading ./ + // cleanPath = cleanPath.replace(/^\.\//, ''); + cleanPath = cleanPath.split('///')[1]; + console.log('cleanPath', cleanPath) + // Try different path combinations + const possiblePaths = [ + cleanPath, + workingPath + cleanPath, + path, + workingPath + path.replace(/^\/+/, ''), + // Also try without the first directory component + cleanPath.includes('/') ? cleanPath.substring(cleanPath.indexOf('/') + 1) : cleanPath, + ]; + console.log('possiblePaths', possiblePaths) + // Try each path + for (const tryPath of possiblePaths) { + console.log('tryPath', tryPath) + const file = fileMap.get(tryPath); + if (file) { + console.log(12345) + const content = await file.text(); + return content; + } + } + + // Try filename only match + const fileName = cleanPath.split('/').pop(); + for (const [key, file] of fileMap.entries()) { + const keyFileName = key.split('/').pop(); + if (keyFileName === fileName) { + const content = await file.text(); + return content; + } + } + + console.error('[XacroAdapter] Cannot find included file:', path); + throw new Error(`Cannot find included file: ${path}`); + } + + /** + * Remove visual/collision elements with empty geometry tags + * xacro-parser may not fully expand conditional geometry (xacro:if) + * We'll just remove these empty visual elements rather than try to fix them + * @param {XMLDocument} xmlDoc - XML document to clean + */ + static removeEmptyGeometry(xmlDoc) { + // Find all geometry elements + const geometryElements = xmlDoc.querySelectorAll('geometry'); + + geometryElements.forEach(geom => { + // Check if geometry is empty (no child elements) + if (!geom.children || geom.children.length === 0) { + // Find the parent visual or collision element + let parent = geom.parentNode; + if (parent) { + const grandParent = parent.parentNode; + if (grandParent) { + if (parent.nodeName === 'visual' || parent.nodeName === 'collision') { + // Remove empty visual/collision elements + grandParent.removeChild(parent); + } + } + } + } + }); + } + + /** + * Recursively clean XML nodes - remove empty text nodes and comments + * @param {Element} element - Element to clean + */ + static cleanXMLNodes(element) { + if (!element || !element.childNodes) return; + + const nodesToRemove = []; + + // Collect nodes to remove + for (let i = 0; i < element.childNodes.length; i++) { + const node = element.childNodes[i]; + + // Remove comments + if (node.nodeType === Node.COMMENT_NODE) { + nodesToRemove.push(node); + } + // Remove empty or whitespace-only text nodes + else if (node.nodeType === Node.TEXT_NODE) { + if (!node.nodeValue || node.nodeValue.trim() === '') { + nodesToRemove.push(node); + } + } + // Recursively clean element nodes + else if (node.nodeType === Node.ELEMENT_NODE) { + this.cleanXMLNodes(node); + } + } + + // Remove collected nodes + nodesToRemove.forEach(node => { + element.removeChild(node); + }); + } + + /** + * Extract package names from URDF content + * @param {string} urdfContent - URDF content + * @returns {Set} + */ + static extractPackagesFromURDF(urdfContent) { + const packages = new Set(); + const packageRegex = /package:\/\/([^\/]+)/g; + let match; + while ((match = packageRegex.exec(urdfContent)) !== null) { + packages.add(match[1]); + } + return packages; + } + + /** + * Find file in fileMap by path + * @param {string} path - File path + * @param {Map} fileMap - File map + * @param {string} urdfDir - URDF directory + * @returns {Promise} + */ + static async findFileInMapByPath(path, fileMap, urdfDir) { + let meshPath = path; + + // Remove blob: prefix + meshPath = meshPath.replace(/^blob:[^\/]+\//, ''); + + // Remove package:// prefix + if (meshPath.startsWith('package://')) { + meshPath = meshPath.replace(/^package:\/\//, ''); + const parts = meshPath.split('/'); + if (parts.length > 1) { + meshPath = parts.slice(1).join('/'); + } + } + + // Remove leading ./ + meshPath = meshPath.replace(/^\.\//, ''); + + // Build full path + const fullPath = urdfDir + meshPath; + + // Try full path + let file = fileMap.get(fullPath); + if (file) return file; + + // Try path without directory + file = fileMap.get(meshPath); + if (file) return file; + + // Try filename match + const targetFileName = meshPath.split('/').pop(); + for (const [key, f] of fileMap.entries()) { + const keyFileName = key.split('/').pop(); + if (keyFileName === targetFileName) { + return f; + } + } + + return null; + } + + /** + * Load mesh file asynchronously + * @param {File} file - File object + * @param {string} ext - File extension + * @param {THREE.LoadingManager} manager - Loading manager + * @returns {Promise} + */ + static async loadMeshFileAsync(file, ext, manager) { + const THREE = await import('three'); + const blobUrl = URL.createObjectURL(file); + + try { + let meshObject = null; + + switch (ext) { + case 'stl': { + const { STLLoader } = await import('three/examples/jsm/loaders/STLLoader.js'); + const stlLoader = new STLLoader(manager); + const stlGeometry = await new Promise((resolve, reject) => { + stlLoader.load(blobUrl, resolve, undefined, reject); + }); + const stlMaterial = new THREE.MeshPhongMaterial(); + meshObject = new THREE.Mesh(stlGeometry, stlMaterial); + break; + } + + case 'dae': { + const { ColladaLoader } = await import('three/examples/jsm/loaders/ColladaLoader.js'); + const colladaLoader = new ColladaLoader(manager); + const colladaModel = await new Promise((resolve, reject) => { + colladaLoader.load(blobUrl, resolve, undefined, reject); + }); + meshObject = colladaModel.scene; + + // Remove lights + if (meshObject && meshObject.traverse) { + const lightsToRemove = []; + meshObject.traverse(child => { + if (child.isLight) { + lightsToRemove.push(child); + } + }); + lightsToRemove.forEach(light => { + if (light.parent) { + light.parent.remove(light); + } + }); + } + break; + } + + case 'obj': { + const { OBJLoader } = await import('three/examples/jsm/loaders/OBJLoader.js'); + const objLoader = new OBJLoader(manager); + meshObject = await new Promise((resolve, reject) => { + objLoader.load(blobUrl, resolve, undefined, reject); + }); + break; + } + + case 'gltf': + case 'glb': { + const { GLTFLoader } = await import('three/examples/jsm/loaders/GLTFLoader.js'); + const gltfLoader = new GLTFLoader(manager); + const gltfModel = await new Promise((resolve, reject) => { + gltfLoader.load(blobUrl, resolve, undefined, reject); + }); + meshObject = gltfModel.scene; + break; + } + + default: + console.warn(`Unsupported file format: ${ext}`); + URL.revokeObjectURL(blobUrl); + return null; + } + + URL.revokeObjectURL(blobUrl); + return meshObject; + } catch (error) { + URL.revokeObjectURL(blobUrl); + console.error(`Failed to load mesh file: ${file.name}`, error); + throw error; + } + } + + /** + * Inject Python-style boolean constants (True/False) into xacro content + * Some xacro files use capitalized True/False in conditional expressions + * The parameters are passed as strings ("True", "False") but compared to constants + * We define the constants to match the string values for comparison to work + * @param {string} xacroContent - Original xacro content + * @returns {string} - Modified xacro content with boolean constants defined + */ + static injectBooleanConstants(xacroContent) { + // Find the robot tag opening + const robotTagMatch = xacroContent.match(/(]*>)/); + if (!robotTagMatch) { + return xacroContent; + } + + // Define True/False as string properties for comparison + // When mirror_dae="True" is passed, it becomes the string "True" + // We want ${mirror_dae == True} to work, so True must also be "True" + const booleanProperties = ` + + + +`; + + const insertPosition = robotTagMatch.index + robotTagMatch[0].length; + return xacroContent.substring(0, insertPosition) + + booleanProperties + + xacroContent.substring(insertPosition); + } + + /** + * Extract xacro arguments and their default values from xacro content + * Xacro files can define arguments using + * These need to be provided to the parser via parser.arguments + * @param {string} xacroContent - Xacro file content + * @returns {Object} - Object with argument names as keys and default values + */ + static extractXacroArguments(xacroContent) { + const args = {}; + + // Match + // Also match (without xacro: prefix) + const argPattern = /<(?:xacro:)?arg\s+name=["']([^"']+)["'](?:\s+default=["']([^"']*)["'])?/g; + + let match; + while ((match = argPattern.exec(xacroContent)) !== null) { + const argName = match[1]; + const defaultValue = match[2] || 'false'; // Default to 'false' if no default specified + args[argName] = defaultValue; + } + + return args; + } +} + + diff --git a/src/controllers/FileHandler.js b/src/controllers/FileHandler.js new file mode 100644 index 0000000..1843e8e --- /dev/null +++ b/src/controllers/FileHandler.js @@ -0,0 +1,553 @@ +/** + * FileHandler - File handling module + * Handles file drag-drop, loading, type detection, etc. + */ +import * as THREE from 'three'; +import { ModelLoaderFactory } from '../loaders/ModelLoaderFactory.js'; +import { readFileContent, getFileFromEntry, getFileTypeFromExtension, getFileDisplayType } from '../utils/FileUtils.js'; + +export class FileHandler { + constructor() { + this.fileMap = new Map(); + this.availableModels = []; + this.currentModelFile = null; + this.onModelLoaded = null; // Callback function + this.usdViewerManager = null; // USD viewer manager (lazy loaded) + } + + /** + * Set USD viewer manager + */ + setUSDViewerManager(manager) { + this.usdViewerManager = manager; + } + + /** + * Set USD viewer initializer callback (for lazy loading) + */ + setUSDViewerInitializer(initializer) { + this.usdViewerInitializer = initializer; + } + + /** + * Setup file drag-drop + */ + setupFileDrop() { + const body = document.body; + + const preventDefaults = (e) => { + e.preventDefault(); + e.stopPropagation(); + }; + + ['dragenter', 'dragover', 'dragleave', 'drop'].forEach(eventName => { + body.addEventListener(eventName, preventDefaults, false); + }); + + let dragCounter = 0; + body.addEventListener('dragenter', (e) => { + dragCounter++; + const dropZone = document.getElementById('drop-zone'); + if (dropZone) dropZone.classList.add('drag-over'); + }, false); + + body.addEventListener('dragleave', (e) => { + dragCounter--; + if (dragCounter === 0) { + const dropZone = document.getElementById('drop-zone'); + if (dropZone) dropZone.classList.remove('drag-over'); + } + }, false); + + body.addEventListener('drop', (e) => { + dragCounter = 0; + const dropZone = document.getElementById('drop-zone'); + if (dropZone) dropZone.classList.remove('drag-over'); + this.handleDrop(e); + }, false); + } + + + /** + * Handle file drop + */ + async handleDrop(e) { + const items = e.dataTransfer.items; + if (!items || items.length === 0) return; + + this.fileMap.clear(); + + const entries = []; + for (let i = 0; i < items.length; i++) { + const item = items[i]; + if (item.webkitGetAsEntry) { + const entry = item.webkitGetAsEntry(); + if (entry) { + entries.push(entry); + } + } + } + + if (entries.length > 0) { + await this.processEntries(entries); + } else { + const files = e.dataTransfer.files; + if (files.length > 0) { + for (const file of files) { + const path = file.webkitRelativePath || file.name; + this.fileMap.set(path, file); + // Only add file.name as a separate key if there's no webkitRelativePath + // This prevents duplicate entries in the file tree + if (!file.webkitRelativePath) { + this.fileMap.set(file.name, file); + } + } + + const loadableFiles = await this.findAllLoadableFiles(Array.from(files)); + if (loadableFiles.length > 0) { + this.availableModels = loadableFiles; + this.onFilesLoaded?.(loadableFiles); + await this.loadFileOrMesh(loadableFiles[0]); + } + } + } + } + + /** + * Process file system entries + */ + async processEntries(entries) { + const files = []; + + for (const entry of entries) { + if (entry.isFile) { + const file = await getFileFromEntry(entry); + const path = entry.fullPath || entry.name; + this.fileMap.set(path, file); + files.push(file); + } else if (entry.isDirectory) { + const dirFiles = await this.readDirectory(entry); + files.push(...dirFiles); + } + } + + if (files.length === 0) return; + + const loadableFiles = await this.findAllLoadableFiles(files); + + if (loadableFiles.length === 0) { + this.onFilesLoaded?.([]); + return; + } + + this.availableModels = loadableFiles; + this.onFilesLoaded?.(loadableFiles); + + if (loadableFiles.length > 0) { + await this.loadFileOrMesh(loadableFiles[0]); + } + } + + /** + * Recursively read directory + */ + async readDirectory(dirEntry) { + const files = []; + + return new Promise((resolve, reject) => { + const reader = dirEntry.createReader(); + + const readEntries = () => { + reader.readEntries(async (entries) => { + if (entries.length === 0) { + resolve(files); + return; + } + + for (const entry of entries) { + if (entry.isFile) { + const file = await getFileFromEntry(entry); + const path = entry.fullPath || entry.name; + this.fileMap.set(path, file); + files.push(file); + } else if (entry.isDirectory) { + const subFiles = await this.readDirectory(entry); + files.push(...subFiles); + } + } + + readEntries(); + }, reject); + }; + + readEntries(); + }); + } + + /** + * Find all loadable files + */ + async findAllLoadableFiles(files) { + const supportedExtensions = { + model: ['urdf', 'xacro', 'xml', 'usd', 'usda', 'usdc', 'usdz'], + mesh: ['dae', 'stl', 'obj', 'collada'] + }; + const loadableFiles = []; + + const checkPromises = files.map(async (file) => { + const ext = file.name.toLowerCase().split('.').pop(); + + if (supportedExtensions.model.includes(ext)) { + if (ext === 'xml') { + try { + const content = await readFileContent(file); + const fileType = ModelLoaderFactory.detectFileType(file.name, content); + + if (!fileType) { + return null; + } + + return { + file: file, + name: file.name, + type: fileType, + path: file.webkitRelativePath || file.name, + category: 'model' + }; + } catch (error) { + console.error(`Failed to read XML file: ${file.name}`, error); + return null; + } + } else { + const fileType = getFileTypeFromExtension(ext); + return { + file: file, + name: file.name, + type: fileType, + path: file.webkitRelativePath || file.name, + category: 'model' + }; + } + } else if (supportedExtensions.mesh.includes(ext)) { + return { + file: file, + name: file.name, + type: 'mesh', + path: file.webkitRelativePath || file.name, + category: 'mesh' + }; + } + return null; + }); + + const results = await Promise.all(checkPromises); + + results.forEach(result => { + if (result) { + loadableFiles.push(result); + } + }); + + loadableFiles.sort((a, b) => { + if (a.category !== b.category) { + return a.category === 'model' ? -1 : 1; + } + return a.name.localeCompare(b.name); + }); + + return loadableFiles; + } + + /** + * Load file or mesh + */ + async loadFileOrMesh(fileInfo) { + if (fileInfo.category === 'model') { + await this.loadFile(fileInfo.file); + } else if (fileInfo.category === 'mesh') { + await this.loadMeshAsModel(fileInfo.file, fileInfo.name); + } + } + + /** + * Load model file + */ + async loadFile(file) { + this.currentModelFile = file; + + try { + const fileName = file.name.toLowerCase(); + + // Get the full path from fileMap + let fullPath = file.name; + for (const [path, f] of this.fileMap.entries()) { + if (f === file) { + fullPath = path; + break; + } + } + + // Handle USD format files (all USD formats use WASM) + const isUSD = fileName.endsWith('.usd') || fileName.endsWith('.usda') || + fileName.endsWith('.usdc') || fileName.endsWith('.usdz'); + if (isUSD) { + // Ensure USD viewer is initialized + if (!this.usdViewerManager && this.usdViewerInitializer) { + try { + await this.usdViewerInitializer(); + } catch (error) { + console.error('USD viewer initialization failed:', error); + return; + } + } + + if (this.fileMap.size === 0) { + this.fileMap.set(file.name, file); + } + + // USD files need File object passed + const model = await ModelLoaderFactory.loadModel( + 'usd', + null, + fullPath, + this.fileMap, + file, + { usdViewerManager: this.usdViewerManager } + ); + + this.onModelLoaded?.(model, file, false, null); + document.getElementById('drop-zone')?.classList.remove('show'); + document.getElementById('drop-zone')?.classList.remove('drag-over'); + return; + } + + // For other files, read text content + const content = await readFileContent(file); + + // Detect if USDC binary format (based on content) + if (this.isUSDCBinaryContent(content)) { + console.error('Cannot load USDC binary format, please convert to USDZ or USDA'); + return; + } + + const fileType = ModelLoaderFactory.detectFileType(file.name, content); + + if (!fileType) { + console.error(`${window.i18n.t('unsupportedFormat')}: ${file.name}`); + return; + } + + if (this.fileMap.size === 0) { + this.fileMap.set(file.name, file); + } + + const model = await ModelLoaderFactory.loadModel( + fileType, + content, + fullPath, // Use full path instead of just file.name + this.fileMap, + file, + { usdViewerManager: this.usdViewerManager } + ); + + // Notify model loaded (pass null as snapshot, let main.js create it) + this.onModelLoaded?.(model, file, false, null); + + document.getElementById('drop-zone')?.classList.remove('show'); + document.getElementById('drop-zone')?.classList.remove('drag-over'); + + } catch (error) { + console.error('Failed to load file:', error); + + // If error message contains USDC related content + if (error.message && (error.message.includes('USDC') || error.message.includes('binary format'))) { + console.error('Cannot load USDC binary format, please convert to USDZ or USDA'); + } else { + console.error(`${window.i18n.t('loadFailed')}: ${error.message}`); + } + + // Remove snapshot if exists + const snapshot = document.getElementById('canvas-snapshot'); + if (snapshot?.parentNode) { + snapshot.parentNode.removeChild(snapshot); + } + } + } + + /** + * Detect if file content is USDC binary format + */ + isUSDCBinaryContent(content) { + if (!content || typeof content !== 'string') { + return false; + } + + // Detect USDC binary format characteristics + // 1. Starts with "PXR-USDC" (USDC file magic bytes) + if (content.startsWith('PXR-USDC')) { + return true; + } + + // 2. Check first 100 bytes, if contains many null bytes, likely binary + const firstBytes = content.substring(0, 100); + const nullCount = (firstBytes.match(/\x00/g) || []).length; + if (nullCount > 10) { + return true; + } + + // 3. Check if valid ASCII USD file + // Valid USDA files usually start with #usda or contain def keywords + if (content.includes('#usda') || + (content.includes('def ') && content.includes('{'))) { + return false; + } + + // 4. File starts with non-printable characters (excluding newline, carriage return, tab, space) + const firstChar = content.charCodeAt(0); + if (firstChar < 32 && firstChar !== 10 && firstChar !== 13 && firstChar !== 9 && firstChar !== 32) { + return true; + } + + return false; + } + + /** + * Create loading snapshot + */ + createLoadingSnapshot() { + const canvas = document.getElementById('canvas'); + if (!canvas) return null; + + try { + const dataURL = canvas.toDataURL('image/png'); + const snapshot = document.createElement('div'); + snapshot.id = 'canvas-snapshot'; + snapshot.style.cssText = ` + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + background-image: url(${dataURL}); + background-size: cover; + background-position: center; + background-color: var(--bg-primary); + background-repeat: no-repeat; + z-index: 2; + pointer-events: none; + `; + + const canvasContainer = document.getElementById('canvas-container'); + if (canvasContainer) { + canvasContainer.appendChild(snapshot); + } + + return snapshot; + } catch (error) { + console.error('Failed to create snapshot:', error); + return null; + } + } + + /** + * Remove loading snapshot + */ + removeLoadingSnapshot(snapshot) { + if (!snapshot || !snapshot.parentNode) return; + + snapshot.style.transition = 'opacity 0.3s ease'; + snapshot.style.opacity = '0'; + + setTimeout(() => { + if (snapshot.parentNode) { + snapshot.parentNode.removeChild(snapshot); + } + }, 300); + } + + /** + * Load single mesh file as model + */ + async loadMeshAsModel(file, fileName) { + try { + const meshObject = await ModelLoaderFactory.loadMeshFileDirect(file, fileName); + + if (!meshObject) { + throw new Error(window.i18n.t('cannotLoadMesh')); + } + + // Ensure mesh materials support lighting and shadows + meshObject.traverse((child) => { + if (child.isMesh) { + if (child.material?.type === 'MeshBasicMaterial') { + const oldMaterial = child.material; + child.material = new THREE.MeshPhongMaterial({ + color: oldMaterial.color, + map: oldMaterial.map, + transparent: oldMaterial.transparent, + opacity: oldMaterial.opacity, + side: oldMaterial.side, + shininess: 30 + }); + if (child.material.map) { + child.material.map.colorSpace = THREE.SRGBColorSpace; + } + } + child.castShadow = true; + child.receiveShadow = true; + } + }); + + const simpleMeshModel = { + name: fileName, + rootLink: 'mesh_root', + links: new Map([ + ['mesh_root', { + name: 'mesh_root', + threeObject: meshObject, + visuals: [{ + threeObject: meshObject, + geometry: { mesh: fileName } + }], + inertial: null + }] + ]), + joints: new Map(), + threeObject: meshObject + }; + + this.currentModelFile = file; + this.onModelLoaded?.(simpleMeshModel, file, true, null); + + } catch (error) { + console.error('Failed to load mesh file:', error); + + const snapshot = document.getElementById('canvas-snapshot'); + if (snapshot?.parentNode) { + snapshot.parentNode.removeChild(snapshot); + } + } + } + + /** + * Get file map + */ + getFileMap() { + return this.fileMap; + } + + /** + * Get available models list + */ + getAvailableModels() { + return this.availableModels; + } + + /** + * Get current model file + */ + getCurrentModelFile() { + return this.currentModelFile; + } +} + diff --git a/src/models/UnifiedRobotModel.js b/src/models/UnifiedRobotModel.js new file mode 100644 index 0000000..1ef44f8 --- /dev/null +++ b/src/models/UnifiedRobotModel.js @@ -0,0 +1,181 @@ +/** + * Unified robot model data interface + * All formats (URDF, MJCF, USD) are converted to this unified format + */ +export class UnifiedRobotModel { + constructor() { + this.name = ''; + this.links = new Map(); // Map + this.joints = new Map(); // Map + this.materials = new Map(); // Map + this.constraints = new Map(); // Map - for parallel mechanism constraints + this.rootLink = null; // Root link name + this.threeObject = null; // Three.js object (if available) + } + + addLink(link) { + this.links.set(link.name, link); + } + + addJoint(joint) { + this.joints.set(joint.name, joint); + } + + addConstraint(constraint) { + this.constraints.set(constraint.name, constraint); + } + + getLink(name) { + return this.links.get(name); + } + + getJoint(name) { + return this.joints.get(name); + } + + getConstraint(name) { + return this.constraints.get(name); + } +} + +/** + * Link interface + */ +export class Link { + constructor(name) { + this.name = name; + this.visuals = []; // VisualGeometry[] + this.collisions = []; // CollisionGeometry[] + this.inertial = null; // InertialProperties + this.threeObject = null; // Three.js object + this.userData = {}; // User-defined data (for adapters to store additional information) + } +} + +/** + * VisualGeometry interface + */ +export class VisualGeometry { + constructor() { + this.name = ''; + this.origin = { xyz: [0, 0, 0], rpy: [0, 0, 0] }; + this.geometry = null; // GeometryType + this.material = null; // Material + this.threeObject = null; // Three.js Mesh + } +} + +/** + * CollisionGeometry interface + */ +export class CollisionGeometry { + constructor() { + this.name = ''; + this.origin = { xyz: [0, 0, 0], rpy: [0, 0, 0] }; + this.geometry = null; // GeometryType + this.threeObject = null; // Three.js Mesh + } +} + +/** + * GeometryType interface + */ +export class GeometryType { + constructor(type) { + this.type = type; // 'box' | 'sphere' | 'cylinder' | 'mesh' + this.size = null; // Size parameters (varies by type) + this.filename = null; // Mesh file path (if mesh type) + } + + clone() { + const cloned = new GeometryType(this.type); + cloned.size = this.size ? { ...this.size } : null; + cloned.filename = this.filename; + return cloned; + } +} + +/** + * InertialProperties interface + */ +export class InertialProperties { + constructor() { + this.mass = 0; + this.origin = { xyz: [0, 0, 0], rpy: [0, 0, 0] }; + this.ixx = 0; + this.iyy = 0; + this.izz = 0; + this.ixy = 0; + this.ixz = 0; + this.iyz = 0; + } +} + +/** + * Joint interface + */ +export class Joint { + constructor(name, type) { + this.name = name; + this.type = type; // 'revolute' | 'prismatic' | 'fixed' | 'continuous' + this.parent = null; // Parent link name + this.child = null; // Child link name + this.origin = { xyz: [0, 0, 0], rpy: [0, 0, 0] }; + this.axis = { xyz: [0, 0, 1] }; // Default z-axis + this.limits = null; // JointLimits + this.currentValue = 0; // Current joint value + this.threeObject = null; // Three.js object (if available) + } +} + +/** + * JointLimits interface + */ +export class JointLimits { + constructor() { + this.lower = -Math.PI; + this.upper = Math.PI; + this.effort = null; + this.velocity = null; + } +} + +/** + * Material interface + */ +export class Material { + constructor(name) { + this.name = name; + this.color = { r: 0.8, g: 0.8, b: 0.8 }; + this.texture = null; + } +} + +/** + * Constraint interface - for describing closed-chain constraints of parallel mechanisms + * Supports MuJoCo equality constraint types + */ +export class Constraint { + constructor(name, type) { + this.name = name; + this.type = type; // 'connect' | 'weld' | 'joint' | 'tendon' | 'distance' + + // Constraint objects (may be body, geom, joint, etc. depending on type) + this.body1 = null; + this.body2 = null; + this.anchor = null; // Connection point coordinates + this.torquescale = null; // Torque scale + + // Joint constraint specific properties + this.joint1 = null; + this.joint2 = null; + this.polycoef = null; // Polynomial coefficients [a0, a1, a2, a3, a4] + + // Visualization object + this.threeObject = null; // Three.js object for displaying constraint + + // Original data (for debugging) + this.userData = {}; + } +} + diff --git a/src/utils/FileUtils.js b/src/utils/FileUtils.js new file mode 100644 index 0000000..59ec411 --- /dev/null +++ b/src/utils/FileUtils.js @@ -0,0 +1,144 @@ +/** + * File operation utility functions + */ + +/** + * Read file content as text + */ +export function readFileContent(file) { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = (e) => resolve(e.target.result); + reader.onerror = reject; + reader.readAsText(file); + }); +} + +/** + * Get File object from file system entry + */ +export function getFileFromEntry(entry) { + return new Promise((resolve, reject) => { + entry.file(resolve, reject); + }); +} + +/** + * Recursively read directory + */ +export async function readDirectory(dirEntry, fileMap) { + const files = []; + + return new Promise((resolve, reject) => { + const reader = dirEntry.createReader(); + + function readEntries() { + reader.readEntries(async (entries) => { + if (entries.length === 0) { + resolve(files); + return; + } + + for (const entry of entries) { + if (entry.isFile) { + const file = await getFileFromEntry(entry); + const path = entry.fullPath || entry.name; + fileMap.set(path, file); + files.push(file); + } else if (entry.isDirectory) { + const subFiles = await readDirectory(entry, fileMap); + files.push(...subFiles); + } + } + + readEntries(); + }, reject); + } + + readEntries(); + }); +} + +/** + * Get file type from extension + */ +export function getFileTypeFromExtension(ext) { + const typeMap = { + 'urdf': 'urdf', + 'xacro': 'xacro', + 'xml': 'mjcf', + 'usd': 'usd', + 'usda': 'usd', + 'usdc': 'usd', + 'usdz': 'usd' + }; + return typeMap[ext] || 'unknown'; +} + +/** + * Get file display type + */ +export function getFileDisplayType(ext, fileName) { + const modelExts = ['urdf', 'xacro', 'xml', 'usd', 'usda', 'usdc', 'usdz']; + const meshExts = ['dae', 'stl', 'obj', 'collada']; + + if (modelExts.includes(ext)) { + return 'model'; + } else if (meshExts.includes(ext)) { + return 'mesh'; + } + return 'file'; +} + +/** + * Normalize path + */ +export function normalizePath(path) { + if (!path) return ''; + return path.replace(/\\/g, '/').replace(/^\/+/, '').replace(/\/+/g, '/'); +} + +export const detectFileType = (fileName, content) => { + const ext = fileName.toLowerCase().split('.').pop(); + + switch (ext) { + case 'urdf': + return 'urdf'; + case 'xacro': + return 'xacro'; + case 'xml': + if (content) { + if (isValidRobotMJCF(content)) { + return 'mjcf'; + } else { + return null; + } + } + return null; + case 'usd': + case 'usda': + case 'usdc': + case 'usdz': + return 'usd'; + default: + return null; + } +} + +const isValidRobotMJCF = (content) => { + if (!content || typeof content !== 'string') { + return false; + } + + if (!content.includes(' - - \ No newline at end of file diff --git a/src/views/arm/MechanicalArm.vue b/src/views/arm/MechanicalArm.vue new file mode 100644 index 0000000..4b5e125 --- /dev/null +++ b/src/views/arm/MechanicalArm.vue @@ -0,0 +1,403 @@ + + + + + \ No newline at end of file diff --git a/src/views/arm/UrdfView.vue b/src/views/arm/UrdfView.vue new file mode 100644 index 0000000..494f7f2 --- /dev/null +++ b/src/views/arm/UrdfView.vue @@ -0,0 +1,237 @@ + + + + + \ No newline at end of file diff --git a/src/views/chassis/Chassis.vue b/src/views/chassis/Chassis.vue index 1d6c032..0734376 100644 --- a/src/views/chassis/Chassis.vue +++ b/src/views/chassis/Chassis.vue @@ -216,7 +216,7 @@ onBeforeUnmount(() => { }) display: flex; flex-direction: column; height: 100%; - background: $bg-deep; + background: #030729; color: $text; font-family: $font-b; overflow: hidden; @@ -241,7 +241,7 @@ onBeforeUnmount(() => { }) justify-content: space-between; height: 40px; padding: 0 16px; - background: $bg-card; + background: #030729; border-bottom: 1px solid $border; flex-shrink: 0;