diff --git a/README.md b/README.md index 88248211..d453def9 100644 --- a/README.md +++ b/README.md @@ -78,9 +78,12 @@ API documentation is available [online](https://robotwebtools.github.io/rclnodej Create rich, interactive desktop applications using Electron and web technologies like Three.js. Build 3D visualizations, monitoring dashboards, and control interfaces that run on Windows, macOS, and Linux. -Try the `electron_demo/turtle_tf2` demo for real-time coordinate frame visualization with dynamic transforms and keyboard-controlled turtle movement. More examples in [electron_demo](https://github.com/RobotWebTools/rclnodejs/tree/develop/electron_demo). +| Demo | Description | Screenshot | +| :-----------------------------------------------: | :-----------------------------------------------------------------------------------------------------------------------------------: | :--------------------------------------------------------------: | +| **🐢 [turtle_tf2](./electron_demo/turtle_tf2)** | Real-time coordinate frame visualization with turtle control. Features TF2 transforms, keyboard control, and dynamic frame updates. | ![turtle_tf2](./electron_demo/turtle_tf2/turtle-tf2-demo.png) | +| **🦾 [manipulator](./electron_demo/manipulator)** | Interactive two-joint robotic arm simulation. Features 3D joint visualization, manual/automatic control, and visual movement markers. | ![manipulator](./electron_demo/manipulator/manipulator-demo.png) | -![demo screenshot](./electron_demo/turtle_tf2/turtle-tf2-demo.png) +Explore more examples in [electron_demo](https://github.com/RobotWebTools/rclnodejs/tree/develop/electron_demo). ## Using rclnodejs with TypeScript diff --git a/electron_demo/manipulator/README.md b/electron_demo/manipulator/README.md new file mode 100644 index 00000000..63f4b860 --- /dev/null +++ b/electron_demo/manipulator/README.md @@ -0,0 +1,300 @@ +# Two-Joint Manipulator Demo + +An interactive Electron application demonstrating a two-joint robotic manipulator visualization using rclnodejs and Three.js. + +![Manipulator Demo](./manipulator-demo.gif) + +## 🚀 Features + +- **Real-time 3D Visualization**: Interactive two-joint robotic arm with Three.js +- **ROS2 Integration**: Publishes and subscribes to `sensor_msgs/msg/JointState` topics +- **Interactive Control**: Manual joint control with sliders +- **Automatic Animation**: Smooth sinusoidal motion patterns +- **Live Feedback**: Real-time joint position display and ROS2 message frequency +- **Visual Movement Markers**: Color-coded rings, arrows, and labels to identify joint movements +- **Modern UI**: Clean, responsive interface with 3D orbit controls + +## 📋 Prerequisites + +- **Node.js** (>= 16.13.0) - JavaScript runtime +- **ROS 2** (Humble, Jazzy, or newer) - Robot Operating System 2 +- **rclnodejs compatible environment** - Linux recommended (tested on Ubuntu/WSL) + +## 🛠️ Installation + +1. **Navigate to the demo directory**: + + ```bash + cd rclnodejs/electron_demo/manipulator + ``` + +2. **Install dependencies**: + + ```bash + npm install + ``` + +3. **Rebuild native modules for Electron**: + ```bash + npm run rebuild + ``` + +## 📜 Available Scripts + +- **`npm start`** - Run demo (requires manual ROS2 environment setup) +- **`npm run rebuild`** - Rebuild native modules after dependency changes + +## 🚀 Quick Start + +### Option 1: Simple Demo (Works Everywhere) + +```bash +npm start +``` + +- ✅ **No ROS2 environment required** +- ✅ **Works without external setup** +- ✅ **Pure visualization and manual control** +- ⚠️ No ROS2 topic publishing (local mode only) + +### Option 2: Manual ROS2 Setup (Recommended for ROS2 Integration) + +1. **Source your ROS2 environment**: + + ```bash + source /opt/ros/humble/setup.bash # or your ROS2 installation path + ``` + +2. **Run the demo**: + ```bash + npm start + ``` + +- ✅ **Publishes to `/joint_states` topic** +- ✅ **Full ROS2 ecosystem integration** +- ✅ **Real-time ROS2 message monitoring** + +## 🎮 Usage + +### Interactive Controls + +- **Joint Sliders**: Use the sliders in the control panel to manually adjust joint angles + + - **Joint 1 (Base)**: Rotates the entire arm around the vertical axis (±180°) + - **Joint 2 (Elbow)**: Bends the upper arm segment (±135°) + +- **Animation**: Click "Start Animation" for automatic smooth motion +- **Reset**: Click "Reset Position" to return to zero configuration + +### 3D Visualization Controls + +- **Orbit**: Click and drag to rotate the camera around the manipulator +- **Zoom**: Use mouse wheel to zoom in/out +- **View**: The manipulator is shown with color-coded components: + - **Gray Base**: Fixed mounting base + - **Red Joint 1**: Base rotation joint + - **Blue Link 1**: Upper arm segment + - **Green Joint 2**: Elbow rotation joint + - **Yellow Link 2**: Forearm segment + - **Purple End Effector**: Tool attachment point + +### Visual Movement Markers + +The demo includes visual indicators to help identify joint movements: + +- **🔴 Red Markers**: Joint 1 (Base rotation) + + - Red ring around the base joint + - Red arrow showing rotation direction + - "Joint1" text label + +- **🟢 Green Markers**: Joint 2 (Elbow) + + - Green ring around the elbow joint + - Green arrow showing rotation direction + - "Joint2" text label + +- **⚪ White Reference Ring**: Fixed base reference point + +These markers make it easy to visually confirm which joints are moving during manual control or animation. + +## 🔧 ROS2 Topics + +### Published Topics + +- **`/joint_states`** (`sensor_msgs/msg/JointState`) + - Joint names: `['joint1', 'joint2']` + - Positions: Current joint angles in radians + - Published at 10 Hz + - Velocity and effort fields included (set to zero) + +### Subscribed Topics + +- **`/joint_states`** (`sensor_msgs/msg/JointState`) + - Receives external joint commands + - Updates visualization in real-time + - Displays message frequency and count + +### Monitoring Topics + +To verify the demo is working correctly, you can monitor the published topics: + +```bash +# In a separate terminal, source ROS2 environment +source /opt/ros/humble/setup.bash # or your ROS2 installation + +# List all available topics +ros2 topic list + +# Monitor joint state messages +ros2 topic echo /joint_states + +# Check publishing frequency +ros2 topic hz /joint_states + +# View topic info +ros2 topic info /joint_states +``` + +## 🏗️ Architecture + +### Main Process (`main.js`) + +- **ROS2 Node Management**: Creates and manages the manipulator node +- **Joint State Publishing**: Publishes current joint positions at 10 Hz +- **Animation Control**: Handles smooth motion generation +- **IPC Communication**: Bridges ROS2 data with renderer process + +### Renderer Process (`renderer.js`) + +- **3D Visualization**: Three.js scene with interactive manipulator model +- **Visual Markers**: Color-coded rings, arrows, and labels for joint identification +- **User Interface**: Control panels and status displays +- **Real-time Updates**: Smooth joint motion and camera controls +- **Message Handling**: Processes ROS2 data and user interactions + +### Component Hierarchy + +``` +Scene +├── Lighting (Ambient + Directional + Point) +├── Ground Plane +├── Coordinate Axes +├── Base (Fixed) +├── Joint Markers (Red/Green Rings, Arrows, Labels) +└── Joint1 Group (Rotates around Y-axis) + ├── Joint1 Sphere + ├── Link1 Cylinder + └── Joint2 Group (Rotates around Z-axis) + ├── Joint2 Sphere + ├── Link2 Cylinder + └── End Effector Cube +``` + +## 🎯 Technical Details + +### Joint Configuration + +- **Joint 1 (Base)**: Revolute joint, Y-axis rotation, ±180° range +- **Joint 2 (Elbow)**: Revolute joint, Z-axis rotation, ±135° range +- **Forward Kinematics**: Hierarchical transformation chains +- **Coordinate System**: Right-handed, Z-up convention + +### Animation Patterns + +```javascript +// Sinusoidal motion with different frequencies (from main.js) +joint1_angle = (sin(time * 1.0) * π) / 3; // ±60° at 1.0x speed +joint2_angle = (sin(time * 1.5) * π) / 4; // ±45° at 1.5x speed +``` + +### Performance + +- **Rendering**: 60 FPS with requestAnimationFrame +- **ROS2 Publishing**: 10 Hz joint state updates +- **Animation**: 20 Hz smooth motion updates +- **UI Updates**: Real-time slider and display synchronization + +## 🛠️ Development + +### File Structure + +``` +manipulator/ +├── package.json # Dependencies and scripts +├── main.js # Electron main process + ROS2 integration +├── renderer.js # Three.js visualization + UI controls +├── index.html # Application UI layout and styling +├── start-demo.sh # Convenient ROS2 startup script +├── README.md # This documentation +├── VERSION_UPGRADE.md # rclnodejs upgrade details +└── COMPLETION_SUMMARY.md # Implementation summary +``` + +### Key Dependencies + +- **electron**: `^31.7.7` - Desktop application framework +- **rclnodejs**: `^1.5.1` - ROS2 JavaScript client library (latest compatible) +- **@electron/rebuild**: `^3.7.2` - Native module rebuilding tool +- **three.js**: `r128` - 3D graphics library (loaded via CDN) + +### Debugging + +- Press `F12` to open Developer Tools +- Console logs show ROS2 initialization and message flow +- Check ROS2 topics with: `ros2 topic list` and `ros2 topic echo /joint_states` + +## 🔍 Troubleshooting + +### Common Issues + +1. **"librcl.so not found"** + + ```bash + # Source ROS2 environment manually + source /opt/ros/humble/setup.bash # or your ROS2 installation path + npm start + ``` + +2. **Build errors with latest Electron** + + - This demo uses Electron 31.7.7 for rclnodejs compatibility + - Electron 38+ requires C++20, which rclnodejs doesn't support yet + - The current versions are tested and stable + +3. **No ROS2 messages received** + + - Check if ROS2 daemon is running: `ros2 daemon start` + - Verify topic exists: `ros2 topic list` + - Check message flow: `ros2 topic echo /joint_states` + +4. **Blank 3D scene** + - Check browser console for Three.js errors + - Ensure internet connection for Three.js CDN + - Press F12 to open Developer Tools for debugging + +### Performance Tips + +- Reduce animation frequency for slower systems +- Disable shadows in Three.js for better performance +- Adjust rendering quality in renderer settings + +## 📚 Learning Resources + +- **ROS2 Concepts**: [ROS2 Documentation](https://docs.ros.org/en/humble/) +- **Three.js Guide**: [Three.js Documentation](https://threejs.org/docs/) +- **Electron Tutorials**: [Electron Documentation](https://electronjs.org/docs) +- **rclnodejs API**: [rclnodejs Repository](https://github.com/RobotWebTools/rclnodejs) + +## 🤝 Contributing + +This demo is part of the rclnodejs project. Contributions welcome! + +1. Fork the repository +2. Create your feature branch +3. Add tests for new functionality +4. Submit a pull request + +## 📄 License + +Licensed under the Apache License, Version 2.0. See the main rclnodejs repository for details. diff --git a/electron_demo/manipulator/index.html b/electron_demo/manipulator/index.html new file mode 100644 index 00000000..8de52b26 --- /dev/null +++ b/electron_demo/manipulator/index.html @@ -0,0 +1,170 @@ + + + + + Two-Joint Manipulator Demo + + + +
+
+ +
+

Manipulator Control

+ +
+ + 🔴 Red markers - Rotates entire arm around vertical axis + +
+ +
+ + 🟢 Green markers - Bends upper arm segment + +
+ +
+ + +
+
+ +
+

ROS2 Status

+
Disconnected
+
+
Topic: /joint_states
+
Frequency: 0.0 Hz
+
Messages: 0
+
+
+ +
+

Two-Joint Manipulator Demo

+

This demo shows a simple 2-DOF robotic arm using:

+ +

Use the sliders to control joint angles or start automatic animation.

+
+
🎯 What to Look For:
+ +
+
+ + + + + \ No newline at end of file diff --git a/electron_demo/manipulator/main.js b/electron_demo/manipulator/main.js new file mode 100644 index 00000000..f3e29946 --- /dev/null +++ b/electron_demo/manipulator/main.js @@ -0,0 +1,218 @@ +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +const { app, BrowserWindow, ipcMain } = require('electron'); +const rclnodejs = require('rclnodejs'); + +let mainWindow; +let manipulatorNode; +let jointStatePublisher; +let jointStateSubscriber; +let isAnimating = false; +let animationInterval; + +// Current joint positions (in radians) +let currentJointPositions = { + joint1: 0.0, // Base rotation + joint2: 0.0, // Elbow rotation +}; + +// Animation parameters +let animationTime = 0; +const animationSpeed = 0.02; + +function createWindow() { + mainWindow = new BrowserWindow({ + width: 1200, + height: 800, + webPreferences: { + nodeIntegration: true, + contextIsolation: false, + }, + }); + + mainWindow.loadFile('index.html'); + + // Open DevTools in development + if (process.env.NODE_ENV === 'development') { + mainWindow.webContents.openDevTools(); + } +} + +app.whenReady().then(async () => { + createWindow(); + + // Wait for window to be ready before initializing ROS2 + mainWindow.webContents.once('did-finish-load', async () => { + try { + await initializeROS2(); + console.log('ROS2 initialized successfully'); + } catch (error) { + console.error('Failed to initialize ROS2:', error); + // Send error status to renderer + mainWindow.webContents.send('ros2-status', { + connected: false, + error: error.message, + }); + } + }); + + app.on('activate', function () { + if (BrowserWindow.getAllWindows().length === 0) createWindow(); + }); +}); + +app.on('window-all-closed', function () { + if (process.platform !== 'darwin') { + // Clean up ROS2 resources + if (animationInterval) { + clearInterval(animationInterval); + } + app.quit(); + } +}); + +async function initializeROS2() { + console.log('Initializing ROS2...'); + + // Initialize rclnodejs + await rclnodejs.init(); + + // Create the manipulator node + manipulatorNode = rclnodejs.createNode('manipulator_demo'); + + // Create publisher for joint states + jointStatePublisher = manipulatorNode.createPublisher( + 'sensor_msgs/msg/JointState', + '/joint_states' + ); + + // Create subscriber to receive joint state commands (for external control) + jointStateSubscriber = manipulatorNode.createSubscription( + 'sensor_msgs/msg/JointState', + '/joint_states', + (msg) => { + // Forward joint state data to renderer + mainWindow.webContents.send('joint-state-received', { + names: msg.name, + positions: msg.position, + velocities: msg.velocity || [], + efforts: msg.effort || [], + }); + } + ); + + // Start spinning the node + rclnodejs.spin(manipulatorNode); + + // Send success status to renderer + mainWindow.webContents.send('ros2-status', { + connected: true, + message: 'Connected to ROS2', + }); + + // Start publishing joint states + startJointStatePublishing(); +} + +function startJointStatePublishing() { + // Publish joint states at 10 Hz + setInterval(() => { + publishJointStates(); + }, 100); // 100ms = 10 Hz +} + +function publishJointStates() { + if (!jointStatePublisher) return; + + const now = Date.now() / 1000; // Convert to seconds + + const jointStateMsg = { + header: { + stamp: { + sec: Math.floor(now), + nanosec: Math.floor((now - Math.floor(now)) * 1e9), + }, + frame_id: 'base_link', + }, + name: ['joint1', 'joint2'], + position: [currentJointPositions.joint1, currentJointPositions.joint2], + velocity: [0.0, 0.0], // For simplicity, set velocities to zero + effort: [0.0, 0.0], // For simplicity, set efforts to zero + }; + + jointStatePublisher.publish(jointStateMsg); +} + +// IPC handlers for communication with renderer process +ipcMain.on('set-joint-positions', (event, positions) => { + currentJointPositions.joint1 = positions.joint1; + currentJointPositions.joint2 = positions.joint2; +}); + +ipcMain.on('start-animation', (event) => { + if (!isAnimating) { + isAnimating = true; + animationTime = 0; + + animationInterval = setInterval(() => { + animationTime += animationSpeed; + + // Create smooth sinusoidal motion for both joints + currentJointPositions.joint1 = (Math.sin(animationTime) * Math.PI) / 3; // ±60 degrees + currentJointPositions.joint2 = + (Math.sin(animationTime * 1.5) * Math.PI) / 4; // ±45 degrees + + // Send updated positions to renderer + event.sender.send('joint-positions-updated', { + joint1: currentJointPositions.joint1, + joint2: currentJointPositions.joint2, + }); + }, 50); // 50ms = 20 Hz animation + + event.sender.send('animation-status', { running: true }); + } +}); + +ipcMain.on('stop-animation', (event) => { + if (isAnimating) { + isAnimating = false; + if (animationInterval) { + clearInterval(animationInterval); + animationInterval = null; + } + event.sender.send('animation-status', { running: false }); + } +}); + +ipcMain.on('reset-position', (event) => { + // Stop animation if running + if (isAnimating) { + isAnimating = false; + if (animationInterval) { + clearInterval(animationInterval); + animationInterval = null; + } + } + + // Reset to zero position + currentJointPositions.joint1 = 0.0; + currentJointPositions.joint2 = 0.0; + + // Send updated positions to renderer + event.sender.send('joint-positions-updated', { + joint1: 0.0, + joint2: 0.0, + }); + + event.sender.send('animation-status', { running: false }); +}); diff --git a/electron_demo/manipulator/manipulator-demo.gif b/electron_demo/manipulator/manipulator-demo.gif new file mode 100644 index 00000000..ca72eddf Binary files /dev/null and b/electron_demo/manipulator/manipulator-demo.gif differ diff --git a/electron_demo/manipulator/manipulator-demo.png b/electron_demo/manipulator/manipulator-demo.png new file mode 100644 index 00000000..d2dc09b7 Binary files /dev/null and b/electron_demo/manipulator/manipulator-demo.png differ diff --git a/electron_demo/manipulator/package.json b/electron_demo/manipulator/package.json new file mode 100644 index 00000000..f46413da --- /dev/null +++ b/electron_demo/manipulator/package.json @@ -0,0 +1,29 @@ +{ + "name": "rclnodejs-manipulator-demo", + "version": "1.0.0", + "description": "Electron application demonstrating a two-joint manipulator visualization using rclnodejs and Three.js", + "main": "main.js", + "scripts": { + "start": "electron .", + "rebuild": "electron-rebuild" + }, + "keywords": [ + "Electron", + "rclnodejs", + "manipulator", + "robotics", + "joints", + "Three.js", + "3D", + "visualization", + "demo" + ], + "license": "Apache-2.0", + "dependencies": { + "rclnodejs": "^1.5.1" + }, + "devDependencies": { + "@electron/rebuild": "^3.7.2", + "electron": "^31.7.7" + } +} diff --git a/electron_demo/manipulator/renderer.js b/electron_demo/manipulator/renderer.js new file mode 100644 index 00000000..5f2da319 --- /dev/null +++ b/electron_demo/manipulator/renderer.js @@ -0,0 +1,486 @@ +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +const { ipcRenderer } = require('electron'); + +// Three.js scene components +let scene, camera, renderer, controls; +let manipulator = {}; +let isAnimating = false; +let messageCount = 0; +let lastMessageTime = 0; +let frequencyUpdateInterval; + +// Joint angles (in radians) +let jointAngles = { + joint1: 0.0, + joint2: 0.0, +}; + +// Initialize the 3D scene +function initScene() { + const container = document.getElementById('canvas-container'); + + // Scene setup + scene = new THREE.Scene(); + scene.background = new THREE.Color(0x222222); + + // Camera setup + camera = new THREE.PerspectiveCamera( + 75, + window.innerWidth / window.innerHeight, + 0.1, + 1000 + ); + camera.position.set(5, 5, 5); + camera.lookAt(0, 0, 0); + + // Renderer setup + renderer = new THREE.WebGLRenderer({ antialias: true }); + renderer.setSize(window.innerWidth, window.innerHeight); + renderer.shadowMap.enabled = true; + renderer.shadowMap.type = THREE.PCFSoftShadowMap; + container.appendChild(renderer.domElement); + + // Lighting + setupLighting(); + + // Create the manipulator + createManipulator(); + + // Create ground plane + createGround(); + + // Setup camera controls (basic orbit controls) + setupCameraControls(); + + // Start render loop + animate(); +} + +function setupLighting() { + // Ambient light + const ambientLight = new THREE.AmbientLight(0x404040, 0.3); + scene.add(ambientLight); + + // Directional light (main light) + const directionalLight = new THREE.DirectionalLight(0xffffff, 0.8); + directionalLight.position.set(10, 10, 5); + directionalLight.castShadow = true; + directionalLight.shadow.mapSize.width = 2048; + directionalLight.shadow.mapSize.height = 2048; + scene.add(directionalLight); + + // Point light for additional illumination + const pointLight = new THREE.PointLight(0xffffff, 0.5, 100); + pointLight.position.set(-5, 5, 5); + scene.add(pointLight); +} + +function createManipulator() { + // Base (fixed) + const baseGeometry = new THREE.CylinderGeometry(0.8, 1.0, 0.5, 16); + const baseMaterial = new THREE.MeshLambertMaterial({ color: 0x444444 }); + manipulator.base = new THREE.Mesh(baseGeometry, baseMaterial); + manipulator.base.position.y = 0.25; + manipulator.base.castShadow = true; + manipulator.base.receiveShadow = true; + scene.add(manipulator.base); + + // Joint 1 (rotating around Y-axis) - this will be a child of base + const joint1Geometry = new THREE.SphereGeometry(0.3, 16, 16); + const joint1Material = new THREE.MeshLambertMaterial({ color: 0xff4444 }); + manipulator.joint1 = new THREE.Mesh(joint1Geometry, joint1Material); + manipulator.joint1.position.y = 0.5; + manipulator.joint1.castShadow = true; + scene.add(manipulator.joint1); + + // Link 1 (between joint1 and joint2) + const link1Geometry = new THREE.CylinderGeometry(0.15, 0.15, 2.5, 16); + const link1Material = new THREE.MeshLambertMaterial({ color: 0x4444ff }); + manipulator.link1 = new THREE.Mesh(link1Geometry, link1Material); + manipulator.link1.position.set(0, 1.25, 0); + manipulator.link1.castShadow = true; + + // Create a group for joint1 and its children (link1, joint2, link2, endEffector) + manipulator.joint1Group = new THREE.Group(); + manipulator.joint1Group.position.y = 0.5; // Position at top of base + manipulator.joint1Group.add(manipulator.joint1); + manipulator.joint1Group.add(manipulator.link1); + scene.add(manipulator.joint1Group); + + // Joint 2 (rotating around Z-axis) + const joint2Geometry = new THREE.SphereGeometry(0.25, 16, 16); + const joint2Material = new THREE.MeshLambertMaterial({ color: 0x44ff44 }); + manipulator.joint2 = new THREE.Mesh(joint2Geometry, joint2Material); + manipulator.joint2.position.set(0, 2.5, 0); + manipulator.joint2.castShadow = true; + + // Link 2 (end effector arm) + const link2Geometry = new THREE.CylinderGeometry(0.1, 0.1, 2.0, 16); + const link2Material = new THREE.MeshLambertMaterial({ color: 0xffff44 }); + manipulator.link2 = new THREE.Mesh(link2Geometry, link2Material); + manipulator.link2.position.set(0, 1.0, 0); + manipulator.link2.castShadow = true; + + // End effector + const endEffectorGeometry = new THREE.BoxGeometry(0.3, 0.3, 0.3); + const endEffectorMaterial = new THREE.MeshLambertMaterial({ + color: 0xff44ff, + }); + manipulator.endEffector = new THREE.Mesh( + endEffectorGeometry, + endEffectorMaterial + ); + manipulator.endEffector.position.set(0, 2.0, 0); + manipulator.endEffector.castShadow = true; + + // Create a group for joint2 and its children (link2, endEffector) + manipulator.joint2Group = new THREE.Group(); + manipulator.joint2Group.position.set(0, 2.5, 0); // Position at top of link1 + manipulator.joint2Group.add(manipulator.joint2); + manipulator.joint2Group.add(manipulator.link2); + manipulator.joint2Group.add(manipulator.endEffector); + + // Add joint2 group to joint1 group so it moves with joint1 + manipulator.joint1Group.add(manipulator.joint2Group); + + // Create coordinate axes for reference + createCoordinateAxes(); + + // Add visual markers for joints + createJointMarkers(); +} + +function createCoordinateAxes() { + const axesHelper = new THREE.AxesHelper(1); + scene.add(axesHelper); +} + +function createJointMarkers() { + // Create 3D text labels and visual indicators for joints + + // Base marker - Fixed reference point + const baseMarkerGeometry = new THREE.RingGeometry(1.2, 1.4, 16); + const baseMarkerMaterial = new THREE.MeshBasicMaterial({ + color: 0xffffff, + side: THREE.DoubleSide, + transparent: true, + opacity: 0.8, + }); + const baseMarker = new THREE.Mesh(baseMarkerGeometry, baseMarkerMaterial); + baseMarker.rotation.x = -Math.PI / 2; // Rotate to lie flat on ground + baseMarker.position.y = 0.01; // Just above ground + scene.add(baseMarker); + + // Joint 1 (Base) indicator - Rotates with the arm + const joint1IndicatorGeometry = new THREE.TorusGeometry(0.5, 0.05, 8, 16); + const joint1IndicatorMaterial = new THREE.MeshBasicMaterial({ + color: 0xff0000, + transparent: true, + opacity: 0.7, + }); + manipulator.joint1Indicator = new THREE.Mesh( + joint1IndicatorGeometry, + joint1IndicatorMaterial + ); + manipulator.joint1Indicator.position.y = 0.5; + manipulator.joint1Indicator.rotation.x = Math.PI / 2; // Rotate to be horizontal + manipulator.joint1Group.add(manipulator.joint1Indicator); + + // Joint 2 (Elbow) indicator - Rotates with the elbow + const joint2IndicatorGeometry = new THREE.TorusGeometry(0.4, 0.04, 8, 16); + const joint2IndicatorMaterial = new THREE.MeshBasicMaterial({ + color: 0x00ff00, + transparent: true, + opacity: 0.7, + }); + manipulator.joint2Indicator = new THREE.Mesh( + joint2IndicatorGeometry, + joint2IndicatorMaterial + ); + manipulator.joint2Indicator.position.set(0, 2.5, 0); + // This will rotate around Z-axis with joint2Group + manipulator.joint2Group.add(manipulator.joint2Indicator); + + // Create arrow indicators to show rotation directions + createRotationArrows(); + + // Create text labels (using simple 3D geometry since we can't easily use text) + createJointLabels(); +} + +function createRotationArrows() { + // Joint 1 rotation arrow (around Y-axis) + const arrowGeometry1 = new THREE.ConeGeometry(0.1, 0.3, 8); + const arrowMaterial1 = new THREE.MeshBasicMaterial({ color: 0xff0000 }); + + // Create multiple arrow cones to show circular motion + for (let i = 0; i < 4; i++) { + const arrow = new THREE.Mesh(arrowGeometry1, arrowMaterial1); + const angle = (i / 4) * Math.PI * 2; + arrow.position.set(Math.cos(angle) * 0.8, 0.7, Math.sin(angle) * 0.8); + arrow.rotation.y = angle + Math.PI / 2; // Point in rotation direction + arrow.rotation.z = Math.PI / 2; // Point horizontally + manipulator.joint1Group.add(arrow); + } + + // Joint 2 rotation arrow (around Z-axis) + const arrowGeometry2 = new THREE.ConeGeometry(0.08, 0.25, 8); + const arrowMaterial2 = new THREE.MeshBasicMaterial({ color: 0x00ff00 }); + + for (let i = 0; i < 4; i++) { + const arrow = new THREE.Mesh(arrowGeometry2, arrowMaterial2); + const angle = (i / 4) * Math.PI * 2; + arrow.position.set(Math.cos(angle) * 0.6, 2.5, Math.sin(angle) * 0.6); + arrow.rotation.y = angle + Math.PI / 2; // Point in rotation direction + arrow.rotation.z = Math.PI / 2; // Point horizontally + manipulator.joint2Group.add(arrow); + } +} + +function createJointLabels() { + // Create simple geometric shapes to represent labels + + // "BASE" label using boxes + const labelMaterial = new THREE.MeshBasicMaterial({ color: 0xffffff }); + + // BASE label at ground level + const baseLabel = new THREE.Group(); + // Create letter-like shapes for "BASE" - simplified geometric representation + const baseLabelGeometry = new THREE.BoxGeometry(0.8, 0.1, 0.1); + const baseLabelMesh = new THREE.Mesh(baseLabelGeometry, labelMaterial); + baseLabelMesh.position.set(2, 0.2, 0); + scene.add(baseLabelMesh); + + // "JOINT 1" label near the base joint + const joint1LabelGeometry = new THREE.BoxGeometry(0.6, 0.08, 0.08); + const joint1LabelMesh = new THREE.Mesh( + joint1LabelGeometry, + new THREE.MeshBasicMaterial({ color: 0xff0000 }) + ); + joint1LabelMesh.position.set(1.5, 0.8, 0); + manipulator.joint1Group.add(joint1LabelMesh); + + // "JOINT 2" label near the elbow joint + const joint2LabelGeometry = new THREE.BoxGeometry(0.6, 0.08, 0.08); + const joint2LabelMesh = new THREE.Mesh( + joint2LabelGeometry, + new THREE.MeshBasicMaterial({ color: 0x00ff00 }) + ); + joint2LabelMesh.position.set(1.2, 2.5, 0); + manipulator.joint2Group.add(joint2LabelMesh); +} + +function createGround() { + const groundGeometry = new THREE.PlaneGeometry(20, 20); + const groundMaterial = new THREE.MeshLambertMaterial({ color: 0x555555 }); + const ground = new THREE.Mesh(groundGeometry, groundMaterial); + ground.rotation.x = -Math.PI / 2; + ground.receiveShadow = true; + scene.add(ground); +} + +function setupCameraControls() { + // Simple orbit controls using mouse + let isDragging = false; + let previousMousePosition = { x: 0, y: 0 }; + + renderer.domElement.addEventListener('mousedown', (event) => { + isDragging = true; + previousMousePosition = { x: event.clientX, y: event.clientY }; + }); + + renderer.domElement.addEventListener('mousemove', (event) => { + if (isDragging) { + const deltaMove = { + x: event.clientX - previousMousePosition.x, + y: event.clientY - previousMousePosition.y, + }; + + // Rotate camera around the scene + const spherical = new THREE.Spherical(); + spherical.setFromVector3(camera.position); + spherical.theta -= deltaMove.x * 0.01; + spherical.phi += deltaMove.y * 0.01; + spherical.phi = Math.max(0.1, Math.min(Math.PI - 0.1, spherical.phi)); + + camera.position.setFromSpherical(spherical); + camera.lookAt(0, 1, 0); + + previousMousePosition = { x: event.clientX, y: event.clientY }; + } + }); + + renderer.domElement.addEventListener('mouseup', () => { + isDragging = false; + }); + + // Zoom with mouse wheel + renderer.domElement.addEventListener('wheel', (event) => { + const scale = event.deltaY > 0 ? 1.1 : 0.9; + camera.position.multiplyScalar(scale); + event.preventDefault(); + }); +} + +function updateManipulator() { + if (manipulator.joint1Group && manipulator.joint2Group) { + // Update joint1 (base rotation around Y-axis) + manipulator.joint1Group.rotation.y = jointAngles.joint1; + + // Update joint2 (elbow rotation around Z-axis) + manipulator.joint2Group.rotation.z = jointAngles.joint2; + } +} + +function animate() { + requestAnimationFrame(animate); + updateManipulator(); + renderer.render(scene, camera); +} + +// Handle window resize +window.addEventListener('resize', () => { + camera.aspect = window.innerWidth / window.innerHeight; + camera.updateProjectionMatrix(); + renderer.setSize(window.innerWidth, window.innerHeight); +}); + +// UI event handlers +document.addEventListener('DOMContentLoaded', () => { + initScene(); + setupUIEventHandlers(); + startFrequencyMeasurement(); +}); + +function setupUIEventHandlers() { + const joint1Slider = document.getElementById('joint1-slider'); + const joint2Slider = document.getElementById('joint2-slider'); + const joint1Value = document.getElementById('joint1-value'); + const joint2Value = document.getElementById('joint2-value'); + const animateBtn = document.getElementById('animate-btn'); + const resetBtn = document.getElementById('reset-btn'); + + // Joint sliders + joint1Slider.addEventListener('input', (e) => { + const degrees = parseFloat(e.target.value); + const radians = (degrees * Math.PI) / 180; + jointAngles.joint1 = radians; + joint1Value.textContent = degrees.toFixed(1) + '°'; + + // Send to main process + ipcRenderer.send('set-joint-positions', { + joint1: radians, + joint2: jointAngles.joint2, + }); + }); + + joint2Slider.addEventListener('input', (e) => { + const degrees = parseFloat(e.target.value); + const radians = (degrees * Math.PI) / 180; + jointAngles.joint2 = radians; + joint2Value.textContent = degrees.toFixed(1) + '°'; + + // Send to main process + ipcRenderer.send('set-joint-positions', { + joint1: jointAngles.joint1, + joint2: radians, + }); + }); + + // Animation button + animateBtn.addEventListener('click', () => { + if (!isAnimating) { + ipcRenderer.send('start-animation'); + } else { + ipcRenderer.send('stop-animation'); + } + }); + + // Reset button + resetBtn.addEventListener('click', () => { + ipcRenderer.send('reset-position'); + }); +} + +function startFrequencyMeasurement() { + // Update frequency display every second + frequencyUpdateInterval = setInterval(() => { + const now = Date.now(); + const timeDiff = (now - lastMessageTime) / 1000; + const frequency = timeDiff > 0 ? messageCount / timeDiff : 0; + + document.getElementById('frequency').textContent = + frequency.toFixed(1) + ' Hz'; + messageCount = 0; + lastMessageTime = now; + }, 1000); +} + +// IPC event handlers +ipcRenderer.on('ros2-status', (event, status) => { + const statusElement = document.getElementById('connection-status'); + if (status.connected) { + statusElement.textContent = 'Connected'; + statusElement.className = 'status-connected'; + } else { + statusElement.textContent = 'Disconnected'; + statusElement.className = 'status-disconnected'; + if (status.error) { + console.error('ROS2 Error:', status.error); + } + } +}); + +ipcRenderer.on('joint-state-received', (event, data) => { + messageCount++; + document.getElementById('msg-count').textContent = messageCount.toString(); + + // Update joint angles from received data + if (data.names && data.positions) { + for (let i = 0; i < data.names.length; i++) { + if (data.names[i] === 'joint1') { + jointAngles.joint1 = data.positions[i]; + } else if (data.names[i] === 'joint2') { + jointAngles.joint2 = data.positions[i]; + } + } + + // Update UI sliders + updateUIFromJointAngles(); + } +}); + +ipcRenderer.on('joint-positions-updated', (event, positions) => { + jointAngles.joint1 = positions.joint1; + jointAngles.joint2 = positions.joint2; + updateUIFromJointAngles(); +}); + +ipcRenderer.on('animation-status', (event, status) => { + isAnimating = status.running; + const animateBtn = document.getElementById('animate-btn'); + animateBtn.textContent = isAnimating ? 'Stop Animation' : 'Start Animation'; +}); + +function updateUIFromJointAngles() { + const joint1Degrees = (jointAngles.joint1 * 180) / Math.PI; + const joint2Degrees = (jointAngles.joint2 * 180) / Math.PI; + + document.getElementById('joint1-slider').value = joint1Degrees; + document.getElementById('joint2-slider').value = joint2Degrees; + document.getElementById('joint1-value').textContent = + joint1Degrees.toFixed(1) + '°'; + document.getElementById('joint2-value').textContent = + joint2Degrees.toFixed(1) + '°'; +} diff --git a/electron_demo/turtle_tf2/turtle-tf2-demo.png b/electron_demo/turtle_tf2/turtle-tf2-demo.png index e5565c51..a485afb9 100644 Binary files a/electron_demo/turtle_tf2/turtle-tf2-demo.png and b/electron_demo/turtle_tf2/turtle-tf2-demo.png differ diff --git a/scripts/npmjs-readme.md b/scripts/npmjs-readme.md index 03646987..43bc3512 100644 --- a/scripts/npmjs-readme.md +++ b/scripts/npmjs-readme.md @@ -73,9 +73,12 @@ See [TypeScript demos](https://github.com/RobotWebTools/rclnodejs/tree/develop/t Create rich, interactive desktop applications using Electron and web technologies like Three.js. Build 3D visualizations, monitoring dashboards, and control interfaces that run on Windows, macOS, and Linux. -Try the `electron_demo/turtle_tf2` demo for real-time coordinate frame visualization with dynamic transforms and keyboard-controlled turtle movement. More examples in [electron_demo](https://github.com/RobotWebTools/rclnodejs/tree/develop/electron_demo). +| Demo | Description | Screenshot | +| :-----------------------------------------------------------------------------------------------------: | :-----------------------------------------------------------------------------------------------------------------------------------: | :-----------------------------------------------------------------------------------------------------------------------------: | +| **🐢 [turtle_tf2](https://github.com/RobotWebTools/rclnodejs/tree/develop/electron_demo/turtle_tf2)** | Real-time coordinate frame visualization with turtle control. Features TF2 transforms, keyboard control, and dynamic frame updates. | ![turtle_tf2](https://github.com/RobotWebTools/rclnodejs/blob/develop/electron_demo/turtle_tf2/turtle-tf2-demo.png?raw=true) | +| **🦾 [manipulator](https://github.com/RobotWebTools/rclnodejs/tree/develop/electron_demo/manipulator)** | Interactive two-joint robotic arm simulation. Features 3D joint visualization, manual/automatic control, and visual movement markers. | ![manipulator](https://github.com/RobotWebTools/rclnodejs/blob/develop/electron_demo/manipulator/manipulator-demo.png?raw=true) | -![demo screenshot](https://github.com/RobotWebTools/rclnodejs/blob/develop/electron_demo/turtle_tf2/turtle-tf2-demo.gif?raw=true) +Explore more examples in [electron_demo](https://github.com/RobotWebTools/rclnodejs/tree/develop/electron_demo). ## License