blanchon commited on
Commit
18b0fa5
·
1 Parent(s): deb5ef6

Mostly UI Update

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .gitattributes +1 -0
  2. .prettierignore +6 -0
  3. .prettierrc +1 -1
  4. DOCKER_README.md +0 -335
  5. README.md +10 -0
  6. ROBOT_ARCHITECTURE.md +469 -330
  7. ROBOT_INSTANCING_README.md +0 -73
  8. bun.lock +21 -18
  9. components.json +2 -2
  10. docker-compose.yml +3 -3
  11. eslint.config.js +12 -12
  12. package.json +2 -1
  13. {src/lib → packages}/feetech.js/README.md +1 -3
  14. packages/feetech.js/index.d.ts +37 -0
  15. packages/feetech.js/index.mjs +57 -0
  16. packages/feetech.js/lowLevelSDK.mjs +1232 -0
  17. packages/feetech.js/package.json +38 -0
  18. packages/feetech.js/scsServoSDK.mjs +910 -0
  19. packages/feetech.js/scsServoSDKUnlock.mjs +217 -0
  20. {src/lib → packages}/feetech.js/scsservo_constants.mjs +14 -14
  21. packages/feetech.js/test.html +770 -0
  22. src-python/README.md +192 -138
  23. src/app.css +105 -105
  24. src/lib/components/3d/GridCustom.svelte +379 -0
  25. src/lib/components/3d/Robot.svelte +493 -40
  26. src/lib/components/3d/robot/URDF/createRobot.svelte.ts +19 -19
  27. src/lib/components/3d/robot/URDF/interfaces/IUrdfBox.ts +2 -2
  28. src/lib/components/3d/robot/URDF/interfaces/IUrdfCylinder.ts +3 -3
  29. src/lib/components/3d/robot/URDF/interfaces/IUrdfJoint.ts +40 -40
  30. src/lib/components/3d/robot/URDF/interfaces/IUrdfLink.ts +18 -18
  31. src/lib/components/3d/robot/URDF/interfaces/IUrdfMesh.ts +4 -4
  32. src/lib/components/3d/robot/URDF/interfaces/IUrdfRobot.ts +8 -8
  33. src/lib/components/3d/robot/URDF/interfaces/IUrdfVisual.ts +38 -38
  34. src/lib/components/3d/robot/URDF/interfaces/index.ts +7 -7
  35. src/lib/components/3d/robot/URDF/mesh/DAE.svelte +6 -6
  36. src/lib/components/3d/robot/URDF/mesh/OBJ.svelte +5 -5
  37. src/lib/components/3d/robot/URDF/mesh/STL.svelte +7 -7
  38. src/lib/components/3d/robot/URDF/primitives/UrdfJoint.svelte +29 -15
  39. src/lib/components/3d/robot/URDF/primitives/UrdfLink.svelte +15 -24
  40. src/lib/components/3d/robot/URDF/primitives/UrdfThree.svelte +11 -11
  41. src/lib/components/3d/robot/URDF/primitives/UrdfVisual.svelte +20 -32
  42. src/lib/components/3d/robot/URDF/runes/urdf_state.svelte.ts +106 -106
  43. src/lib/components/3d/robot/URDF/utils/UrdfParser.ts +446 -448
  44. src/lib/components/3d/robot/URDF/utils/helper.ts +39 -36
  45. src/lib/components/ButtonBar.svelte +0 -161
  46. src/lib/components/ControlsSheet.svelte +0 -76
  47. src/lib/components/Overlay.svelte +0 -36
  48. src/lib/components/PanelWrapper.svelte +0 -32
  49. src/lib/components/RobotStatusDemo.svelte +0 -65
  50. src/lib/components/SettingsSheet.svelte +0 -109
.gitattributes CHANGED
@@ -1 +1,2 @@
1
  *.stl filter=lfs diff=lfs merge=lfs -text
 
 
1
  *.stl filter=lfs diff=lfs merge=lfs -text
2
+ *.png filter=lfs diff=lfs merge=lfs -text
.prettierignore CHANGED
@@ -4,3 +4,9 @@ pnpm-lock.yaml
4
  yarn.lock
5
  bun.lock
6
  bun.lockb
 
 
 
 
 
 
 
4
  yarn.lock
5
  bun.lock
6
  bun.lockb
7
+
8
+ # Src python
9
+ src-python/
10
+ node_modules/
11
+ build/
12
+ .svelte-kit/
.prettierrc CHANGED
@@ -1,6 +1,6 @@
1
  {
2
  "useTabs": true,
3
- "singleQuote": true,
4
  "trailingComma": "none",
5
  "printWidth": 100,
6
  "plugins": ["prettier-plugin-svelte", "prettier-plugin-tailwindcss"],
 
1
  {
2
  "useTabs": true,
3
+ "singleQuote": false,
4
  "trailingComma": "none",
5
  "printWidth": 100,
6
  "plugins": ["prettier-plugin-svelte", "prettier-plugin-tailwindcss"],
DOCKER_README.md DELETED
@@ -1,335 +0,0 @@
1
- # 🐳 LeRobot Arena Docker Setup
2
-
3
- This Docker setup provides a containerized environment that runs both the Python backend and Svelte frontend for LeRobot Arena using modern tools like Bun, uv, and box-packager.
4
-
5
- ## 🏗️ Architecture
6
-
7
- The application consists of:
8
- - **Svelte Frontend**: Built as static files and served by FastAPI
9
- - **FastAPI Backend**: WebSocket server with HTTP API for robot control
10
- - **Single Port**: Both frontend and backend served on port 7860
11
-
12
- ## 🚀 Quick Start
13
-
14
- ### Build and Run with Docker
15
-
16
- ```bash
17
- # Build the image
18
- docker build -t lerobot-arena .
19
-
20
- # Run the container
21
- docker run -p 7860:7860 lerobot-arena
22
-
23
- # Access the application
24
- open http://localhost:7860
25
- ```
26
-
27
- ### Using Docker Compose
28
-
29
- ```bash
30
- # Start the application
31
- docker-compose up
32
-
33
- # Access the application
34
- open http://localhost:7860
35
- ```
36
-
37
- ## 🌐 Deployment
38
-
39
- ### Hugging Face Spaces
40
-
41
- This project is configured for deployment on Hugging Face Spaces:
42
-
43
- 1. Push to a Hugging Face Space repository
44
- 2. The Dockerfile will automatically build and serve the application
45
- 3. Both frontend and backend run on port 7860 (HF Spaces default)
46
- 4. Environment detection automatically configures URLs for HF Spaces
47
-
48
- ### Local Development vs Production
49
-
50
- - **Local Development**: Frontend dev server on 5173, backend on 7860
51
- - **Docker/Production**: Both frontend and backend served by FastAPI on 7860
52
- - **HF Spaces**: Single FastAPI server on 7860 with automatic HTTPS and domain detection
53
-
54
- ## 🔧 Environment Variables
55
-
56
- - `SPACE_HOST`: Set automatically by Hugging Face Spaces for proper URL configuration
57
- - `PORT`: Override the default port (7860)
58
-
59
- ## 📡 API Endpoints
60
-
61
- - **Frontend**: `http://localhost:7860/` (Served by FastAPI)
62
- - **API**: `http://localhost:7860/api/*`
63
- - **WebSocket**: `ws://localhost:7860/ws/*`
64
- - **Status**: `http://localhost:7860/status`
65
-
66
- ## 🛠️ Development
67
-
68
- For local development without Docker:
69
-
70
- ```bash
71
- # Start backend
72
- cd src-python
73
- uv sync
74
- uv run python start_server.py
75
-
76
- # Start frontend (in another terminal)
77
- bun install
78
- bun run dev
79
- ```
80
-
81
- The frontend dev server (port 5173) will automatically proxy API requests to the backend (port 7860).
82
-
83
- ## 📋 What's Included
84
-
85
- The Docker container includes:
86
-
87
- 1. **Frontend**:
88
- - Svelte application built as static files using **Bun**
89
- - Served on port 7860 using Python's built-in HTTP server
90
- - Production-ready build with all optimizations
91
-
92
- 2. **Backend**:
93
- - FastAPI Python server with dependencies managed by **uv**
94
- - **Standalone executable** created with **box-packager** for faster startup
95
- - WebSocket support for real-time robot communication
96
- - Runs on port 8080
97
- - Auto-configured for container environment
98
-
99
- ## 🛠️ Development vs Production
100
-
101
- ### Production (Default)
102
- The Dockerfile builds the Svelte app as static files using Bun and packages the Python backend as a standalone executable using box-packager.
103
-
104
- ### Development
105
- For development with hot-reload, you can use local tools:
106
-
107
- ```bash
108
- # Terminal 1: Frontend development
109
- bun run dev
110
-
111
- # Terminal 2: Backend development
112
- cd src-python && python start_server.py
113
- ```
114
-
115
- ## 📁 Container Structure
116
-
117
- ```
118
- /home/user/app/
119
- ├── src-python/ # Python backend source
120
- │ └── target/release/ # Box-packager standalone executable
121
- ├── static-frontend/ # Built Svelte app (static files)
122
- └── start_services.sh # Startup script for both services
123
- ```
124
-
125
- ## 🔧 Build Process
126
-
127
- The Docker build process includes these key steps:
128
-
129
- 1. **Frontend Build Stage (Bun)**:
130
- - Uses `oven/bun:1-alpine` for fast package management
131
- - Builds Svelte app with `bun run build`
132
- - Produces optimized static files
133
-
134
- 2. **Backend Build Stage (Python + Rust)**:
135
- - Installs Rust/Cargo for box-packager
136
- - Uses `uv` for fast Python dependency management
137
- - Packages backend with `box package` into standalone executable
138
- - Configures proper user permissions for HF Spaces
139
-
140
- 3. **Runtime**:
141
- - Runs standalone executable (faster startup)
142
- - Serves frontend static files
143
- - Both services managed by startup script
144
-
145
- ## 🚀 Performance Benefits
146
-
147
- ### Box-packager Advantages:
148
- - **Faster startup**: No Python interpreter overhead
149
- - **Self-contained**: All dependencies bundled
150
- - **Smaller runtime**: No need for full Python environment
151
- - **Cross-platform**: Single executable works anywhere
152
-
153
- ### Build Optimization:
154
- - **Bun**: Faster JavaScript package manager and bundler
155
- - **uv**: Ultra-fast Python package manager
156
- - **Multi-stage build**: Minimal final image size
157
- - **Alpine base**: Lightweight frontend build stage
158
-
159
- ## 🔧 Customization
160
-
161
- ### Environment Variables
162
-
163
- You can customize the container behavior using environment variables:
164
-
165
- ```bash
166
- docker run -p 8080:8080 -p 7860:7860 \
167
- -e PYTHONUNBUFFERED=1 \
168
- -e NODE_ENV=production \
169
- lerobot-arena
170
- ```
171
-
172
- ### Port Configuration
173
-
174
- To use different ports:
175
-
176
- ```bash
177
- # Map to different host ports
178
- docker run -p 9000:8080 -p 3000:7860 lerobot-arena
179
- ```
180
-
181
- Then access:
182
- - Frontend: http://localhost:3000
183
- - Backend: http://localhost:9000
184
-
185
- ### Volume Mounts for Persistence
186
-
187
- ```bash
188
- # Mount data directory for persistence
189
- docker run -p 8080:8080 -p 7860:7860 \
190
- -v $(pwd)/data:/home/user/app/data \
191
- lerobot-arena
192
- ```
193
-
194
- ## 🐛 Troubleshooting
195
-
196
- ### Container won't start
197
- ```bash
198
- # Check logs
199
- docker-compose logs lerobot-arena
200
-
201
- # Or for direct docker run
202
- docker logs <container-id>
203
- ```
204
-
205
- ### Port conflicts
206
- ```bash
207
- # Check what's using the ports
208
- lsof -i :8080
209
- lsof -i :7860
210
-
211
- # Kill processes or use different ports
212
- docker run -p 8081:8080 -p 7861:7860 lerobot-arena
213
- ```
214
-
215
- ### Frontend not loading
216
- ```bash
217
- # Verify the frontend was built correctly
218
- docker exec -it <container-id> ls -la /home/user/app/static-frontend
219
-
220
- # Check if frontend server is running
221
- docker exec -it <container-id> ps aux | grep python
222
- ```
223
-
224
- ### Backend API errors
225
- ```bash
226
- # Check if standalone executable is running
227
- docker exec -it <container-id> ps aux | grep lerobot-arena-server
228
-
229
- # Test backend directly
230
- curl http://localhost:8080/
231
- ```
232
-
233
- ### Box-packager Build Issues
234
- ```bash
235
- # Force rebuild without cache to fix cargo/box issues
236
- docker-compose build --no-cache
237
- docker-compose up
238
-
239
- # Check if Rust/Cargo is properly installed
240
- docker exec -it <container-id> cargo --version
241
- ```
242
-
243
- ## 🔄 Updates and Rebuilding
244
-
245
- ```bash
246
- # Pull latest code and rebuild
247
- git pull
248
- docker-compose down
249
- docker-compose up --build
250
-
251
- # Force rebuild without cache
252
- docker-compose build --no-cache
253
- docker-compose up
254
- ```
255
-
256
- ## 🚀 Hugging Face Spaces Deployment
257
-
258
- This Docker setup is optimized for Hugging Face Spaces:
259
-
260
- ### Key Features for HF Spaces:
261
- - **Port 7860**: Uses the default HF Spaces port
262
- - **User permissions**: Runs as user ID 1000 as required
263
- - **Proper ownership**: All files owned by the user
264
- - **Git support**: Includes git for dependency resolution
265
- - **Standalone executable**: Faster cold starts on HF infrastructure
266
-
267
- ### Deployment Steps:
268
- 1. Push your code to a GitHub repository
269
- 2. Create a new Space on Hugging Face Spaces
270
- 3. Connect your GitHub repository
271
- 4. The YAML frontmatter in README.md will auto-configure the Space
272
- 5. HF will build and deploy your Docker container automatically
273
-
274
- ### Accessing on HF Spaces:
275
- - Your Space URL will serve the frontend directly
276
- - Backend API will be available at your-space-url/api/ (if using a reverse proxy)
277
-
278
- ## 🔧 Advanced Configuration
279
-
280
- ### Using with nginx (Production)
281
- ```nginx
282
- server {
283
- listen 80;
284
-
285
- # Serve frontend
286
- location / {
287
- proxy_pass http://localhost:7860;
288
- }
289
-
290
- # Proxy API calls
291
- location /api/ {
292
- proxy_pass http://localhost:8080;
293
- proxy_http_version 1.1;
294
- proxy_set_header Upgrade $http_upgrade;
295
- proxy_set_header Connection 'upgrade';
296
- proxy_set_header Host $host;
297
- proxy_cache_bypass $http_upgrade;
298
- }
299
-
300
- # WebSocket support
301
- location /ws/ {
302
- proxy_pass http://localhost:8080;
303
- proxy_http_version 1.1;
304
- proxy_set_header Upgrade $http_upgrade;
305
- proxy_set_header Connection "upgrade";
306
- }
307
- }
308
- ```
309
-
310
- ## 📊 Container Stats
311
-
312
- ```bash
313
- # Monitor resource usage
314
- docker stats lerobot-arena
315
-
316
- # View container info
317
- docker inspect lerobot-arena
318
- ```
319
-
320
- ## 🧹 Cleanup
321
-
322
- ```bash
323
- # Stop and remove containers
324
- docker-compose down
325
-
326
- # Remove images
327
- docker rmi lerobot-arena
328
-
329
- # Clean up unused images and containers
330
- docker system prune -a
331
- ```
332
-
333
- ---
334
-
335
- **Happy containerized robotics! 🤖🐳**
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
README.md CHANGED
@@ -37,17 +37,20 @@ The easiest way to run LeRobot Arena is using Docker, which sets up both the fro
37
  ### Step-by-Step Instructions
38
 
39
  1. **Clone the repository**
 
40
  ```bash
41
  git clone <your-repo-url>
42
  cd lerobot-arena
43
  ```
44
 
45
  2. **Build and start the services**
 
46
  ```bash
47
  docker-compose up --build
48
  ```
49
 
50
  3. **Access the application**
 
51
  - **Frontend**: http://localhost:7860
52
  - **Backend API**: http://localhost:8080
53
  - **API Documentation**: http://localhost:8080/docs
@@ -158,17 +161,20 @@ For detailed Docker documentation, see [DOCKER_README.md](./DOCKER_README.md).
158
  ## 🔧 Building for Production
159
 
160
  ### Frontend Only
 
161
  ```bash
162
  bun run build
163
  ```
164
 
165
  ### Backend Standalone Executable
 
166
  ```bash
167
  cd src-python
168
  box package
169
  ```
170
 
171
  ### Complete Docker Build
 
172
  ```bash
173
  docker-compose up --build
174
  ```
@@ -185,6 +191,7 @@ docker-compose up --build
185
  ## 🚨 Troubleshooting
186
 
187
  ### Port Conflicts
 
188
  If ports 8080 or 7860 are already in use:
189
 
190
  ```bash
@@ -197,6 +204,7 @@ docker run -p 8081:8080 -p 7861:7860 lerobot-arena
197
  ```
198
 
199
  ### Container Issues
 
200
  ```bash
201
  # View logs
202
  docker-compose logs lerobot-arena
@@ -207,6 +215,7 @@ docker-compose up
207
  ```
208
 
209
  ### Development Issues
 
210
  ```bash
211
  # Clear node modules and reinstall
212
  rm -rf node_modules
@@ -218,6 +227,7 @@ bun run dev
218
  ```
219
 
220
  ### Box-packager Issues
 
221
  ```bash
222
  # Clean build artifacts
223
  cd src-python
 
37
  ### Step-by-Step Instructions
38
 
39
  1. **Clone the repository**
40
+
41
  ```bash
42
  git clone <your-repo-url>
43
  cd lerobot-arena
44
  ```
45
 
46
  2. **Build and start the services**
47
+
48
  ```bash
49
  docker-compose up --build
50
  ```
51
 
52
  3. **Access the application**
53
+
54
  - **Frontend**: http://localhost:7860
55
  - **Backend API**: http://localhost:8080
56
  - **API Documentation**: http://localhost:8080/docs
 
161
  ## 🔧 Building for Production
162
 
163
  ### Frontend Only
164
+
165
  ```bash
166
  bun run build
167
  ```
168
 
169
  ### Backend Standalone Executable
170
+
171
  ```bash
172
  cd src-python
173
  box package
174
  ```
175
 
176
  ### Complete Docker Build
177
+
178
  ```bash
179
  docker-compose up --build
180
  ```
 
191
  ## 🚨 Troubleshooting
192
 
193
  ### Port Conflicts
194
+
195
  If ports 8080 or 7860 are already in use:
196
 
197
  ```bash
 
204
  ```
205
 
206
  ### Container Issues
207
+
208
  ```bash
209
  # View logs
210
  docker-compose logs lerobot-arena
 
215
  ```
216
 
217
  ### Development Issues
218
+
219
  ```bash
220
  # Clear node modules and reinstall
221
  rm -rf node_modules
 
227
  ```
228
 
229
  ### Box-packager Issues
230
+
231
  ```bash
232
  # Clean build artifacts
233
  cd src-python
ROBOT_ARCHITECTURE.md CHANGED
@@ -1,416 +1,555 @@
1
- # Robot Control Architecture v2.0 - Master/Slave Pattern
2
 
3
- This document describes the revolutionary new **Master-Slave Architecture** that enables sophisticated robot control with clear separation between command sources (Masters) and execution targets (Slaves).
 
4
 
5
  ## 🏗️ Architecture Overview
6
 
7
- The new architecture follows a **Master-Slave Pattern** with complete separation of concerns:
8
 
9
  ```
10
  ┌─────────────────┐ ┌──────────────────┐ ┌─────────────────┐
11
- UI Panel │ │ Robot Manager │ │ Masters │
12
  │ │◄──►│ │◄──►│ │
 
13
  │ • Manual Control│ │ • Master/Slave │ │ • Remote Server │
14
- │ • Monitoring │ │ • Command Router │ │ • Mock Sequence │
15
- │ (disabled if │ │ • State Sync │ │ Script Player
16
  │ master active) │ └──────────────────┘ └─────────────────┘
17
  └─────────────────┘ │
18
 
19
  ┌──────────────────┐ ┌─────────────────┐
20
- │ Robot Model │◄──►│ Slaves │
21
  │ │ │ │
22
- │ • URDF State │ │ • USB Robots
23
- │ • Joint States │ │ • Mock Hardware
24
- │ • Command Queue │ │ • Simulations
 
25
  └──────────────────┘ └─────────────────┘
 
 
 
 
 
 
 
 
 
26
  ```
27
 
28
- ## 🎯 Key Concepts
29
-
30
- ### Masters (Command Sources)
31
- - **Purpose**: Generate commands and control sequences
32
- - **Examples**: Remote servers, predefined sequences, scripts
33
- - **Limitation**: Only 1 master per robot (exclusive control)
34
- - **Effect**: When active, disables manual panel control
35
 
36
- ### Slaves (Execution Targets)
37
- - **Purpose**: Execute commands on physical/virtual robots
38
- - **Examples**: USB robots, mock robots, simulations
39
- - **Limitation**: Multiple slaves per robot (parallel execution)
40
- - **Effect**: All connected slaves execute the same commands
 
 
 
 
 
41
 
42
- ### Control Flow
43
- 1. **No Master**: Manual panel control → All slaves
44
- 2. **With Master**: Master commands → All slaves (panel disabled)
45
- 3. **Bidirectional**: Slaves provide real-time feedback
46
 
47
- ## 📁 File Structure
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
48
 
 
49
  ```
50
- src/lib/
51
- ├── robot/
52
- ├── Robot.svelte.ts # Master-slave robot management
53
- ├── RobotManager.svelte.ts # Global orchestration
54
- └── drivers/
55
- │ ├── masters/
56
- │ │ ├── MockSequenceMaster.ts # Demo sequences
57
- │ │ ├── RemoteServerMaster.ts # HTTP/WebSocket commands
58
- │ │ └── ScriptPlayerMaster.ts # Script execution
59
- │ └── slaves/
60
- │ ├── MockSlave.ts # Development/testing
61
- │ ├── USBSlave.ts # Physical USB robots
62
- │ └── SimulationSlave.ts # Physics simulation
63
- ├── types/
64
- │ └── robotDriver.ts # Master/Slave interfaces
65
- └── components/
66
- ├── scene/
67
- │ └── Robot.svelte # 3D visualization
68
- └── panel/
69
- └── RobotControlPanel.svelte # Master/Slave connection UI
70
  ```
71
 
72
- ## 🔧 Core Components
73
 
74
- ### RobotManager
75
- **Central orchestrator with master-slave management**
76
 
77
  ```typescript
78
- import { robotManager } from '$lib/robot/RobotManager.svelte';
79
 
80
- // Create robot
81
- const robot = await robotManager.createRobot('my-robot', urdfConfig);
82
-
83
- // Connect demo sequence master (disables manual control)
84
- await robotManager.connectDemoSequences('my-robot', true);
 
85
 
86
- // Connect mock slave (executes commands)
87
- await robotManager.connectMockSlave('my-robot', 50);
 
88
 
89
- // Connect USB slave (real hardware)
90
- await robotManager.connectUSBSlave('my-robot');
91
 
92
- // Disconnect master (restores manual control)
93
- await robotManager.disconnectMaster('my-robot');
94
  ```
95
 
96
- ### Robot Class
97
- **Individual robot with master-slave coordination**
98
 
99
  ```typescript
100
- // Master management
101
- await robot.setMaster(sequenceMaster);
102
- await robot.removeMaster();
103
-
104
- // Slave management
105
- await robot.addSlave(usbSlave);
106
- await robot.addSlave(mockSlave);
107
- await robot.removeSlave(slaveId);
108
-
109
- // Control state
110
- robot.controlState.hasActiveMaster // true when master connected
111
- robot.controlState.manualControlEnabled // false when master active
112
- robot.controlState.lastCommandSource // "master" | "manual" | "none"
113
-
114
- // Manual control (only when no master)
115
- await robot.updateJointValue('joint_1', 45);
116
- ```
117
 
118
- ## 🔌 Master Drivers
 
119
 
120
- ### Mock Sequence Master
121
- **Predefined movement sequences for testing**
 
122
 
123
- ```typescript
124
- const config: MasterDriverConfig = {
125
- type: "mock-sequence",
126
- sequences: DEMO_SEQUENCES, // Wave, Scan, Pick-Place patterns
127
- autoStart: true,
128
- loopMode: true
129
- };
130
-
131
- await robotManager.connectMaster('robot-1', config);
132
- ```
133
 
134
- **Demo Sequences:**
135
- - **Wave Pattern**: Greeting gesture with wrist roll
136
- - **Scanning Pattern**: Horizontal sweep with pitch variation
137
- - **Pick and Place**: Complete manipulation sequence
138
 
139
- ### Remote Server Master *(Planned)*
140
- **HTTP/WebSocket command reception**
141
 
142
- ```typescript
143
- const config: MasterDriverConfig = {
144
- type: "remote-server",
145
- url: "ws://robot-server:8080/ws",
146
- apiKey: "your-api-key",
147
- pollInterval: 100
148
- };
149
- ```
150
 
151
- ### Script Player Master *(Planned)*
152
- **JavaScript/Python script execution**
153
 
154
  ```typescript
155
- const config: MasterDriverConfig = {
156
- type: "script-player",
157
- scriptUrl: "/scripts/robot-dance.js",
158
- variables: { speed: 1.5, amplitude: 45 }
159
- };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
160
  ```
161
 
162
- ## 🤖 Slave Drivers
 
 
 
 
163
 
164
- ### Mock Slave
165
- **Perfect simulation for development**
 
166
 
167
  ```typescript
168
- const config: SlaveDriverConfig = {
169
- type: "mock-slave",
170
- simulateLatency: 50, // ms response delay
171
- simulateErrors: false, // random failures
172
- responseDelay: 20 // command execution time
173
- };
174
-
175
- await robotManager.connectSlave('robot-1', config);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
176
  ```
177
 
178
  **Features:**
179
- - Perfect command execution (real = virtual)
180
- - Configurable latency and errors
181
- - Real-time state feedback
182
- - Instant connection
183
 
184
- ### USB Slave *(Planned)*
185
- **Physical robot via feetech.js**
 
186
 
187
  ```typescript
188
- const config: SlaveDriverConfig = {
189
- type: "usb-slave",
190
- port: "/dev/ttyUSB0", // auto-detect if undefined
191
- baudRate: 115200
192
- };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
193
  ```
194
 
195
- **Features:**
196
- - ✅ Direct hardware control
197
- - Position/speed commands
198
- - ✅ Real servo feedback
199
- - ✅ Calibration support
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
200
 
201
- ### Simulation Slave *(Planned)*
202
- **Physics-based simulation**
203
 
204
  ```typescript
205
- const config: SlaveDriverConfig = {
206
- type: "simulation-slave",
207
- physics: true,
208
- collisionDetection: true
209
- };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
210
  ```
211
 
212
  ## 🔄 Command Flow Architecture
213
 
214
  ### Command Structure
 
215
  ```typescript
216
  interface RobotCommand {
217
  timestamp: number;
218
  joints: {
219
  name: string;
220
- value: number; // degrees for revolute, speed for continuous
221
- speed?: number; // optional movement speed
222
  }[];
223
- duration?: number; // optional execution time
224
  metadata?: Record<string, unknown>;
225
  }
226
  ```
227
 
228
- ### Command Sources
229
-
230
- #### Master Commands
231
- ```typescript
232
- // Continuous sequence from master
233
- master.onCommand((commands) => {
234
- // Route to all connected slaves
235
- robot.slaves.forEach(slave => slave.executeCommands(commands));
236
- });
237
- ```
238
-
239
- #### Manual Commands
240
- ```typescript
241
- // Only when no master active
242
- if (robot.manualControlEnabled) {
243
- await robot.updateJointValue('shoulder', 45);
244
- }
245
- ```
246
-
247
- ### Command Execution
248
- ```typescript
249
- // All slaves execute in parallel
250
- const executePromises = robot.connectedSlaves.map(slave =>
251
- slave.executeCommand(command)
252
- );
253
- await Promise.allSettled(executePromises);
254
- ```
255
-
256
- ## 🎮 Usage Examples
257
-
258
- ### Basic Master-Slave Setup
259
- ```typescript
260
- // 1. Create robot
261
- const robot = await robotManager.createRobot('arm-1', urdfConfig);
262
-
263
- // 2. Add slaves for execution
264
- await robotManager.connectMockSlave('arm-1'); // Development
265
- await robotManager.connectUSBSlave('arm-1'); // Real hardware
266
-
267
- // 3. Connect master for control
268
- await robotManager.connectDemoSequences('arm-1'); // Automated sequences
269
-
270
- // 4. Monitor status
271
- const status = robotManager.getRobotStatus('arm-1');
272
- console.log(`Master: ${status.masterName}, Slaves: ${status.connectedSlaves}`);
273
- ```
274
-
275
- ### Multiple Robots with Different Masters
276
- ```typescript
277
- // Robot 1: Demo sequences
278
- await robotManager.createRobot('demo-1', armConfig);
279
- await robotManager.connectDemoSequences('demo-1', true);
280
- await robotManager.connectMockSlave('demo-1');
281
-
282
- // Robot 2: Remote control
283
- await robotManager.createRobot('remote-1', armConfig);
284
- await robotManager.connectMaster('remote-1', remoteServerConfig);
285
- await robotManager.connectUSBSlave('remote-1');
286
-
287
- // Robot 3: Manual control only
288
- await robotManager.createRobot('manual-1', armConfig);
289
- await robotManager.connectMockSlave('manual-1');
290
- // No master = manual control enabled
291
- ```
292
 
293
- ### Master Switching
294
- ```typescript
295
- // Switch from manual to automated control
296
- await robotManager.connectDemoSequences('robot-1');
297
- // Panel inputs now disabled, sequences control robot
298
 
299
- // Switch back to manual control
300
- await robotManager.disconnectMaster('robot-1');
301
- // Panel inputs re-enabled, manual control restored
302
- ```
303
 
304
- ## 🚀 Benefits
305
-
306
- ### 1. **Clear Control Hierarchy**
307
- - Masters provide commands exclusively
308
- - Slaves execute commands in parallel
309
- - No ambiguity about command source
310
-
311
- ### 2. **Flexible Command Sources**
312
- - Remote servers for network control
313
- - Predefined sequences for automation
314
- - Manual control for testing/setup
315
- - Easy to add new master types
316
-
317
- ### 3. **Multiple Execution Targets**
318
- - Physical robots via USB
319
- - Simulated robots for testing
320
- - Mock robots for development
321
- - All execute same commands simultaneously
322
-
323
- ### 4. **Automatic Panel Management**
324
- - Panel disabled when master active
325
- - Panel re-enabled when master disconnected
326
- - Clear visual feedback about control state
327
-
328
- ### 5. **Safe Operation**
329
- - Masters cannot conflict (only 1 allowed)
330
- - Graceful disconnection with rest positioning
331
- - Error isolation between slaves
332
-
333
- ### 6. **Development Workflow**
334
- - Test with mock slaves during development
335
- - Add real slaves for deployment
336
- - Switch masters for different scenarios
337
-
338
- ## 🔮 Implementation Roadmap
339
-
340
- ### Phase 1: Core Architecture ✅
341
- - [x] Master-Slave interfaces
342
- - [x] Robot class with dual management
343
- - [x] RobotManager orchestration
344
- - [x] MockSequenceMaster with demo patterns
345
- - [x] MockSlave for testing
346
-
347
- ### Phase 2: Real Hardware
348
- - [ ] USBSlave implementation (feetech.js integration)
349
- - [ ] Calibration system for hardware slaves
350
- - [ ] Error handling and recovery
351
-
352
- ### Phase 3: Remote Control
353
- - [ ] RemoteServerMaster (HTTP/WebSocket)
354
- - [ ] Authentication and security
355
- - [ ] Real-time command streaming
356
-
357
- ### Phase 4: Advanced Features
358
- - [ ] ScriptPlayerMaster (JS/Python execution)
359
- - [ ] SimulationSlave (physics integration)
360
- - [ ] Command recording and playback
361
- - [ ] Multi-robot coordination
362
-
363
- ## 🛡️ Safety Features
364
-
365
- ### Master Safety
366
- - Only 1 master per robot prevents conflicts
367
- - Master disconnect automatically restores manual control
368
- - Command validation before execution
369
-
370
- ### Slave Safety
371
- - Rest positioning before disconnect
372
- - Smooth movement transitions
373
- - Individual slave error isolation
374
- - Calibration offset compensation
375
-
376
- ### System Safety
377
- - Graceful degradation on slave failures
378
- - Command queuing prevents overwhelming
379
- - Real-time state monitoring
380
- - Emergency stop capabilities *(planned)*
381
-
382
- ## 🔧 Migration from v1.0
383
-
384
- The new architecture is **completely different** from the old driver pattern:
385
-
386
- ### Old Architecture (v1.0)
387
  ```typescript
388
- // Single driver per robot
389
- const driver = new USBRobotDriver(config);
390
- await robot.setDriver(driver);
391
- await robot.updateJointValue('joint', 45);
392
- ```
393
-
394
- ### New Architecture (v2.0)
395
- ```typescript
396
- // Separate masters and slaves
397
- await robotManager.connectMaster(robotId, masterConfig); // Command source
398
- await robotManager.connectSlave(robotId, slaveConfig); // Execution target
399
-
400
- // Manual control only when no master
401
- if (robot.manualControlEnabled) {
402
- await robot.updateJointValue('joint', 45);
 
 
 
 
403
  }
404
  ```
405
 
406
- ### Key Changes
407
- 1. **Drivers → Masters + Slaves**: Split command/execution concerns
408
- 2. **Single Connection → Multiple**: 1 master + N slaves per robot
409
- 3. **Always Manual → Conditional**: Panel disabled when master active
410
- 4. **Direct Control → Command Routing**: Masters route to all slaves
411
 
412
- The new architecture provides **dramatically more flexibility** while maintaining complete backward compatibility for URDF visualization and joint control.
 
 
 
 
 
 
413
 
414
  ---
415
 
416
- *This architecture enables sophisticated robot control scenarios from simple manual operation to complex multi-robot coordination, all with a clean, extensible design.*
 
1
+ # LeRobot Arena - Robot Control Architecture v2.0
2
 
3
+ > **Master-Slave Pattern for Scalable Robot Control**
4
+ > A revolutionary architecture that separates command generation (Masters) from execution (Slaves), enabling sophisticated robot control scenarios from simple manual operation to complex multi-robot coordination.
5
 
6
  ## 🏗️ Architecture Overview
7
 
8
+ The architecture follows a **Master-Slave Pattern** with complete separation of concerns:
9
 
10
  ```
11
  ┌─────────────────┐ ┌──────────────────┐ ┌─────────────────┐
12
+ Web Frontend │ │ RobotManager │ │ Masters │
13
  │ │◄──►│ │◄──►│ │
14
+ │ • 3D Visualization │ • Robot Creation │ │ • USB Master │
15
  │ • Manual Control│ │ • Master/Slave │ │ • Remote Server │
16
+ │ • Monitoring │ │ Orchestration │ │ • Mock Sequence │
17
+ │ (disabled when │ │ • State Sync │ │ (1 per robot)
18
  │ master active) │ └──────────────────┘ └─────────────────┘
19
  └─────────────────┘ │
20
 
21
  ┌──────────────────┐ ┌─────────────────┐
22
+ │ Robot Class │◄──►│ Slaves │
23
  │ │ │ │
24
+ │ • Joint States │ │ • USB Robot
25
+ │ • URDF Model │ │ • Remote Robot
26
+ │ • Command Queue │ │ • WebSocket
27
+ │ • Calibration │ │ (N per robot) │
28
  └──────────────────┘ └─────────────────┘
29
+
30
+
31
+ ┌──────────────────┐
32
+ │ Python Backend │
33
+ │ │
34
+ │ • WebSocket API │
35
+ │ • Connection Mgr │
36
+ │ • Robot Manager │
37
+ └──────────────────┘
38
  ```
39
 
40
+ ### Control Flow States
 
 
 
 
 
 
41
 
42
+ ```
43
+ ┌─────────────────┐ Master Connected ┌─────────────────┐
44
+ │ Manual Mode │ ──────────────────────► │ Master Mode │
45
+ │ │ │ │
46
+ Panel Active │ │ Panel Locked │
47
+ │ ✅ Direct Control│ │ ✅ Master Commands│
48
+ │ ❌ No Master │ │ ✅ All Slaves Exec│
49
+ └─────────────────┘ ◄────────────────────── └─────────────────┘
50
+ Master Disconnected
51
+ ```
52
 
53
+ ## 🎯 Core Concepts
 
 
 
54
 
55
+ ### Masters (Command Sources)
56
+ **Purpose**: Generate and provide robot control commands
57
+
58
+ | Type | Description | Connection | Use Case |
59
+ |------|-------------|------------|----------|
60
+ | **USB Master** | Physical robot as command source | USB/Serial | Teleoperation, motion teaching |
61
+ | **Remote Server** | WebSocket/HTTP command reception | Network | External control systems |
62
+ | **Mock Sequence** | Predefined movement patterns | Internal | Testing, demonstrations |
63
+
64
+ **Key Rules**:
65
+ - 🔒 **Exclusive Control**: Only 1 master per robot
66
+ - 🚫 **Panel Lock**: Manual control disabled when master active
67
+ - 🔄 **Seamless Switch**: Masters can be swapped dynamically
68
+
69
+ ### Slaves (Execution Targets)
70
+ **Purpose**: Execute commands on physical or virtual robots
71
+
72
+ | Type | Description | Connection | Use Case |
73
+ |------|-------------|------------|----------|
74
+ | **USB Slave** | Physical robot control | USB/Serial | Hardware execution |
75
+ | **Remote Server Slave** | Network robot control | WebSocket | Distributed robots |
76
+ | **WebSocket Slave** | Real-time WebSocket execution | WebSocket | Cloud robots |
77
+
78
+ **Key Rules**:
79
+ - 🔢 **Multiple Allowed**: N slaves per robot
80
+ - 🎯 **Parallel Execution**: All slaves execute same commands
81
+ - 🔄 **Independent Operation**: Slaves can fail independently
82
+
83
+ ### Architecture Comparison
84
+
85
+ | Aspect | v1.0 (Single Driver) | v2.0 (Master-Slave) |
86
+ |--------|---------------------|---------------------|
87
+ | **Connection Model** | 1 Driver ↔ 1 Robot | 1 Master + N Slaves ↔ 1 Robot |
88
+ | **Command Source** | Always UI Panel | Master OR UI Panel |
89
+ | **Execution Targets** | Single Connection | Multiple Parallel |
90
+ | **Control Hierarchy** | Flat | Hierarchical |
91
+ | **Scalability** | Limited | Unlimited |
92
+
93
+ ## 📁 Project Structure
94
+
95
+ ### Frontend Architecture (TypeScript + Svelte)
96
+ ```
97
+ src/lib/robot/
98
+ ├── Robot.svelte.ts # Individual robot master-slave coordination
99
+ ├── RobotManager.svelte.ts # Global robot orchestration
100
+ └── drivers/
101
+ ├── USBMaster.ts # Physical robot as command source
102
+ ├── RemoteServerMaster.ts # WebSocket command reception
103
+ ├── USBSlave.ts # Physical robot execution
104
+ ├── RemoteServerSlave.ts # Network robot execution
105
+ └── WebSocketSlave.ts # Real-time WebSocket execution
106
+
107
+ src/lib/types/
108
+ ├── robotDriver.ts # Master/Slave interfaces
109
+ └── robot.ts # Robot state management
110
+ ```
111
 
112
+ ### Backend Architecture (Python + FastAPI)
113
  ```
114
+ src-python/src/
115
+ ├── main.py # FastAPI server + WebSocket endpoints
116
+ ├── robot_manager.py # Server-side robot lifecycle
117
+ ├── connection_manager.py # WebSocket connection handling
118
+ └── models.py # Pydantic data models
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
119
  ```
120
 
121
+ ## 🎮 Usage Examples
122
 
123
+ ### Basic Robot Setup
 
124
 
125
  ```typescript
126
+ import { robotManager } from "$lib/robot/RobotManager.svelte";
127
 
128
+ // Create robot from URDF
129
+ const robot = await robotManager.createRobot("demo-arm", {
130
+ urdfPath: "/robots/so-arm100/robot.urdf",
131
+ jointNameIdMap: { "Rotation": 1, "Pitch": 2, "Elbow": 3 },
132
+ restPosition: { "Rotation": 0, "Pitch": 0, "Elbow": 0 }
133
+ });
134
 
135
+ // Add execution targets (slaves)
136
+ await robotManager.connectUSBSlave("demo-arm"); // Real hardware
137
+ await robotManager.connectRemoteServerSlave("demo-arm"); // Network robot
138
 
139
+ // Connect command source (master) - panel becomes locked
140
+ await robotManager.connectUSBMaster("demo-arm");
141
 
142
+ // Result: USB master controls both USB and Remote slaves
 
143
  ```
144
 
145
+ ### Master Switching Workflow
 
146
 
147
  ```typescript
148
+ const robot = robotManager.getRobot("my-robot");
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
149
 
150
+ // Start with manual control
151
+ console.log(robot.manualControlEnabled); // ✅ true
152
 
153
+ // Switch to USB master (robot becomes command source)
154
+ await robotManager.connectUSBMaster("my-robot");
155
+ console.log(robot.manualControlEnabled); // ❌ false (panel locked)
156
 
157
+ // Switch to remote control
158
+ await robotManager.disconnectMaster("my-robot");
159
+ await robotManager.connectMaster("my-robot", {
160
+ type: "remote-server",
161
+ url: "ws://robot-controller:8080/ws"
162
+ });
 
 
 
 
163
 
164
+ // Restore manual control
165
+ await robotManager.disconnectMaster("my-robot");
166
+ console.log(robot.manualControlEnabled); // true (panel restored)
167
+ ```
168
 
169
+ ## 🔌 Driver Implementations
 
170
 
171
+ ### USB Master Driver
 
 
 
 
 
 
 
172
 
173
+ **Physical robot as command source for teleoperation**
 
174
 
175
  ```typescript
176
+ // USBMaster.ts - Core implementation
177
+ export class USBMaster implements MasterDriver {
178
+ readonly type = "master" as const;
179
+ private feetechDriver: FeetechSerialDriver;
180
+ private pollIntervalId?: number;
181
+
182
+ async connect(): Promise<void> {
183
+ // Initialize feetech.js serial connection
184
+ this.feetechDriver = new FeetechSerialDriver({
185
+ port: this.config.port || await this.detectPort(),
186
+ baudRate: this.config.baudRate || 115200
187
+ });
188
+
189
+ await this.feetechDriver.connect();
190
+ this.startPolling();
191
+ }
192
+
193
+ private startPolling(): void {
194
+ this.pollIntervalId = setInterval(async () => {
195
+ try {
196
+ // Read current joint positions from hardware
197
+ const jointStates = await this.readAllJoints();
198
+
199
+ // Convert to robot commands
200
+ const commands = this.convertToCommands(jointStates);
201
+
202
+ // Emit commands to slaves
203
+ this.notifyCommand(commands);
204
+ } catch (error) {
205
+ console.error('USB Master polling error:', error);
206
+ }
207
+ }, this.config.pollInterval || 100);
208
+ }
209
+
210
+ private async readAllJoints(): Promise<DriverJointState[]> {
211
+ const states: DriverJointState[] = [];
212
+
213
+ for (const [jointName, servoId] of Object.entries(this.jointMap)) {
214
+ const position = await this.feetechDriver.readPosition(servoId);
215
+ states.push({
216
+ name: jointName,
217
+ servoId,
218
+ type: "revolute",
219
+ virtualValue: position,
220
+ realValue: position
221
+ });
222
+ }
223
+
224
+ return states;
225
+ }
226
+ }
227
  ```
228
 
229
+ **Usage Pattern:**
230
+ - Connect USB robot as master
231
+ - Physical robot becomes the command source
232
+ - Move robot manually → slaves follow the movement
233
+ - Ideal for: Teleoperation, motion teaching, demonstration recording
234
 
235
+ ### USB Slave Driver
236
+
237
+ **Physical robot as execution target**
238
 
239
  ```typescript
240
+ // USBSlave.ts - Core implementation
241
+ export class USBSlave implements SlaveDriver {
242
+ readonly type = "slave" as const;
243
+ private feetechDriver: FeetechSerialDriver;
244
+ private calibrationOffsets: Map<string, number> = new Map();
245
+
246
+ async executeCommand(command: RobotCommand): Promise<void> {
247
+ for (const joint of command.joints) {
248
+ const servoId = this.getServoId(joint.name);
249
+ if (!servoId) continue;
250
+
251
+ // Apply calibration offset
252
+ const offset = this.calibrationOffsets.get(joint.name) || 0;
253
+ const adjustedValue = joint.value + offset;
254
+
255
+ // Send to hardware via feetech.js
256
+ await this.feetechDriver.writePosition(servoId, adjustedValue, {
257
+ speed: joint.speed || 100,
258
+ acceleration: 50
259
+ });
260
+ }
261
+ }
262
+
263
+ async readJointStates(): Promise<DriverJointState[]> {
264
+ const states: DriverJointState[] = [];
265
+
266
+ for (const joint of this.jointStates) {
267
+ const position = await this.feetechDriver.readPosition(joint.servoId);
268
+ const offset = this.calibrationOffsets.get(joint.name) || 0;
269
+
270
+ states.push({
271
+ ...joint,
272
+ realValue: position - offset // Remove offset for accurate state
273
+ });
274
+ }
275
+
276
+ return states;
277
+ }
278
+
279
+ async calibrate(): Promise<void> {
280
+ console.log('Calibrating USB robot...');
281
+
282
+ for (const joint of this.jointStates) {
283
+ // Read current hardware position
284
+ const currentPos = await this.feetechDriver.readPosition(joint.servoId);
285
+
286
+ // Calculate offset: desired_rest - actual_position
287
+ const offset = joint.restPosition - currentPos;
288
+ this.calibrationOffsets.set(joint.name, offset);
289
+
290
+ console.log(`Joint ${joint.name}: offset=${offset.toFixed(1)}°`);
291
+ }
292
+ }
293
+ }
294
  ```
295
 
296
  **Features:**
297
+ - Direct hardware control via feetech.js
298
+ - Real position feedback
299
+ - Calibration offset support
300
+ - Smooth motion interpolation
301
 
302
+ ### Remote Server Master
303
+
304
+ **Network command reception via WebSocket**
305
 
306
  ```typescript
307
+ // RemoteServerMaster.ts - Core implementation
308
+ export class RemoteServerMaster implements MasterDriver {
309
+ readonly type = "master" as const;
310
+ private websocket?: WebSocket;
311
+ private reconnectAttempts = 0;
312
+
313
+ async connect(): Promise<void> {
314
+ const wsUrl = `${this.config.url}/ws/master/${this.robotId}`;
315
+ this.websocket = new WebSocket(wsUrl);
316
+
317
+ this.websocket.onopen = () => {
318
+ console.log(`Remote master connected: ${wsUrl}`);
319
+ this.reconnectAttempts = 0;
320
+ this.updateStatus({ isConnected: true });
321
+ };
322
+
323
+ this.websocket.onmessage = (event) => {
324
+ try {
325
+ const message = JSON.parse(event.data);
326
+ this.handleServerMessage(message);
327
+ } catch (error) {
328
+ console.error('Failed to parse server message:', error);
329
+ }
330
+ };
331
+
332
+ this.websocket.onclose = () => {
333
+ this.updateStatus({ isConnected: false });
334
+ this.attemptReconnect();
335
+ };
336
+ }
337
+
338
+ private handleServerMessage(message: any): void {
339
+ switch (message.type) {
340
+ case 'command':
341
+ // Convert server message to robot command
342
+ const command: RobotCommand = {
343
+ timestamp: Date.now(),
344
+ joints: message.data.joints.map((j: any) => ({
345
+ name: j.name,
346
+ value: j.value,
347
+ speed: j.speed
348
+ }))
349
+ };
350
+ this.notifyCommand([command]);
351
+ break;
352
+
353
+ case 'sequence':
354
+ // Handle command sequence
355
+ const sequence: CommandSequence = message.data;
356
+ this.notifySequence(sequence);
357
+ break;
358
+ }
359
+ }
360
+
361
+ async sendSlaveStatus(slaveStates: DriverJointState[]): Promise<void> {
362
+ if (!this.websocket) return;
363
+
364
+ const statusMessage = {
365
+ type: 'slave_status',
366
+ timestamp: new Date().toISOString(),
367
+ robot_id: this.robotId,
368
+ data: {
369
+ joints: slaveStates.map(state => ({
370
+ name: state.name,
371
+ virtual_value: state.virtualValue,
372
+ real_value: state.realValue
373
+ }))
374
+ }
375
+ };
376
+
377
+ this.websocket.send(JSON.stringify(statusMessage));
378
+ }
379
+ }
380
  ```
381
 
382
+ **Protocol:**
383
+ ```json
384
+ // Command from server to robot
385
+ {
386
+ "type": "command",
387
+ "timestamp": "2024-01-15T10:30:00Z",
388
+ "data": {
389
+ "joints": [
390
+ { "name": "Rotation", "value": 45, "speed": 100 },
391
+ { "name": "Elbow", "value": -30, "speed": 80 }
392
+ ]
393
+ }
394
+ }
395
+
396
+ // Status from robot to server
397
+ {
398
+ "type": "slave_status",
399
+ "timestamp": "2024-01-15T10:30:01Z",
400
+ "robot_id": "robot-1",
401
+ "data": {
402
+ "joints": [
403
+ { "name": "Rotation", "virtual_value": 45, "real_value": 44.8 },
404
+ { "name": "Elbow", "virtual_value": -30, "real_value": -29.9 }
405
+ ]
406
+ }
407
+ }
408
+ ```
409
+
410
+ ### Remote Server Slave
411
 
412
+ **Network robot execution via WebSocket**
 
413
 
414
  ```typescript
415
+ // RemoteServerSlave.ts - Core implementation
416
+ export class RemoteServerSlave implements SlaveDriver {
417
+ readonly type = "slave" as const;
418
+ private websocket?: WebSocket;
419
+
420
+ async executeCommand(command: RobotCommand): Promise<void> {
421
+ if (!this.websocket) throw new Error('Not connected');
422
+
423
+ const message = {
424
+ type: 'command',
425
+ timestamp: new Date().toISOString(),
426
+ robot_id: this.config.robotId,
427
+ data: {
428
+ joints: command.joints.map(j => ({
429
+ name: j.name,
430
+ value: j.value,
431
+ speed: j.speed
432
+ }))
433
+ }
434
+ };
435
+
436
+ this.websocket.send(JSON.stringify(message));
437
+
438
+ // Wait for acknowledgment
439
+ return new Promise((resolve, reject) => {
440
+ const timeout = setTimeout(() => reject(new Error('Command timeout')), 5000);
441
+
442
+ const messageHandler = (event: MessageEvent) => {
443
+ const response = JSON.parse(event.data);
444
+ if (response.type === 'command_ack') {
445
+ clearTimeout(timeout);
446
+ this.websocket?.removeEventListener('message', messageHandler);
447
+ resolve();
448
+ }
449
+ };
450
+
451
+ this.websocket.addEventListener('message', messageHandler);
452
+ });
453
+ }
454
+
455
+ async readJointStates(): Promise<DriverJointState[]> {
456
+ if (!this.websocket) throw new Error('Not connected');
457
+
458
+ const message = {
459
+ type: 'status_request',
460
+ timestamp: new Date().toISOString(),
461
+ robot_id: this.config.robotId
462
+ };
463
+
464
+ this.websocket.send(JSON.stringify(message));
465
+
466
+ return new Promise((resolve, reject) => {
467
+ const timeout = setTimeout(() => reject(new Error('Status timeout')), 3000);
468
+
469
+ const messageHandler = (event: MessageEvent) => {
470
+ const response = JSON.parse(event.data);
471
+ if (response.type === 'joint_states') {
472
+ clearTimeout(timeout);
473
+ this.websocket?.removeEventListener('message', messageHandler);
474
+
475
+ const states = response.data.joints.map((j: any) => ({
476
+ name: j.name,
477
+ servoId: j.servo_id,
478
+ type: j.type,
479
+ virtualValue: j.virtual_value,
480
+ realValue: j.real_value
481
+ }));
482
+
483
+ resolve(states);
484
+ }
485
+ };
486
+
487
+ this.websocket.addEventListener('message', messageHandler);
488
+ });
489
+ }
490
+ }
491
  ```
492
 
493
  ## 🔄 Command Flow Architecture
494
 
495
  ### Command Structure
496
+
497
  ```typescript
498
  interface RobotCommand {
499
  timestamp: number;
500
  joints: {
501
  name: string;
502
+ value: number; // degrees for revolute, speed for continuous
503
+ speed?: number; // optional movement speed
504
  }[];
505
+ duration?: number; // optional execution time
506
  metadata?: Record<string, unknown>;
507
  }
508
  ```
509
 
510
+ ### Control Flow
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
511
 
512
+ 1. **Master Generation**: Masters generate commands from various sources
513
+ 2. **Robot Routing**: Robot class routes commands to all connected slaves
514
+ 3. **Parallel Execution**: All slaves execute commands simultaneously
515
+ 4. **State Feedback**: Slaves report back real joint positions
516
+ 5. **Synchronization**: Robot maintains synchronized state across all slaves
517
 
518
+ ### State Management
 
 
 
519
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
520
  ```typescript
521
+ // Robot.svelte.ts - Core state management
522
+ export interface ManagedJointState {
523
+ name: string;
524
+ urdfJoint: IUrdfJoint;
525
+ servoId?: number;
526
+
527
+ // State values
528
+ virtualValue: number; // What the UI shows
529
+ realValue?: number; // What hardware reports
530
+ commandedValue: number; // Last commanded value
531
+
532
+ // Calibration
533
+ calibrationOffset: number; // Hardware compensation
534
+ restPosition: number; // Safe default position
535
+
536
+ // Synchronization
537
+ lastVirtualUpdate: Date;
538
+ lastRealUpdate?: Date;
539
+ lastCommandUpdate?: Date;
540
  }
541
  ```
542
 
543
+ ## 📊 Benefits Summary
 
 
 
 
544
 
545
+ | Benefit | Description | Impact |
546
+ |---------|-------------|---------|
547
+ | **🔒 Clear Control Hierarchy** | Masters provide commands exclusively, slaves execute in parallel | No command conflicts, predictable behavior |
548
+ | **🔄 Flexible Command Sources** | Easy switching between manual, automated, and remote control | Supports development, testing, and production |
549
+ | **📡 Multiple Execution Targets** | Same commands executed on multiple robots simultaneously | Real hardware + simulation testing |
550
+ | **🎛️ Automatic Panel Management** | UI automatically adapts to master presence | Intuitive user experience |
551
+ | **🚀 Development Workflow** | Clear separation enables independent development | Faster iteration cycles |
552
 
553
  ---
554
 
555
+ **This architecture provides unprecedented flexibility for robot control, from simple manual operation to sophisticated multi-robot coordination, all with a clean, extensible, and production-ready design.**
ROBOT_INSTANCING_README.md DELETED
@@ -1,73 +0,0 @@
1
- # Robot Instancing Optimization
2
-
3
- This implementation optimizes the rendering of multiple robots using Threlte's instancing capabilities for improved performance.
4
-
5
- ## Key Features
6
-
7
- ### 1. **Smart Instancing by Robot Type**
8
- - Robots are grouped by their URDF type (URL) for optimal instancing
9
- - Single robots render with full detail
10
- - Multiple robots of the same type use instanced rendering
11
-
12
- ### 2. **Geometry-Specific Instancing**
13
- - **Box Geometries**: Instanced with `T.boxGeometry`
14
- - **Cylinder Geometries**: Instanced with `T.cylinderGeometry`
15
- - **Mesh Geometries**: Instanced with simplified `T.sphereGeometry` for performance
16
-
17
- ### 3. **Hybrid Rendering Strategy**
18
- - First robot of each type: Full detailed rendering with all URDF components
19
- - Additional robots: Simplified instanced representation
20
- - Maintains visual quality while optimizing performance
21
-
22
- ### 4. **Performance Benefits**
23
- - Reduces draw calls when rendering multiple robots
24
- - Optimizes GPU memory usage through instancing
25
- - Scales better with increasing robot count
26
- - Maintains interactivity for detailed robots
27
-
28
- ## Implementation Details
29
-
30
- ### State Management
31
- ```typescript
32
- // Robots grouped by URDF type for optimal batching
33
- let robotsByType: Record<string, Array<{
34
- id: string;
35
- position: [number, number, number];
36
- robotState: RobotState
37
- }>> = $state({});
38
- ```
39
-
40
- ### Instancing Logic
41
- 1. **Single Robot**: Full `UrdfLink` rendering with all details
42
- 2. **Multiple Robots**:
43
- - Geometry analysis and grouping
44
- - Instanced rendering for primitive shapes
45
- - Simplified representations for complex meshes
46
-
47
- ### Automatic Demonstration
48
- - Spawns additional robots after 2 seconds to showcase instancing
49
- - Shows performance difference between single and multiple robot rendering
50
-
51
- ## Usage
52
-
53
- Simply use the `Robot.svelte` component with a `urdfConfig`. The component automatically:
54
- - Detects when multiple robots of the same type exist
55
- - Switches to instanced rendering for optimal performance
56
- - Maintains full detail for single robots
57
-
58
- ```svelte
59
- <Robot {urdfConfig} />
60
- ```
61
-
62
- ## Performance Impact
63
-
64
- - **Before**: O(n) draw calls for n robots
65
- - **After**: O(1) draw calls per geometry type regardless of robot count
66
- - **Memory**: Shared geometry instances reduce GPU memory usage
67
- - **Scalability**: Linear performance improvement with robot count
68
-
69
- This optimization is particularly beneficial for:
70
- - Robot swarms
71
- - Multi-robot simulations
72
- - Arena scenarios with many similar robots
73
- - Performance-critical real-time applications
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
bun.lock CHANGED
@@ -6,8 +6,9 @@
6
  "dependencies": {
7
  "@threlte/core": "^8.0.4",
8
  "@threlte/extras": "^9.2.1",
9
- "@types/three": "^0.176.0",
10
  "clsx": "^2.1.1",
 
11
  "tailwind-merge": "^3.3.0",
12
  "three": "^0.177.0",
13
  "threlte-postprocessing": "^0.0.9",
@@ -148,7 +149,7 @@
148
 
149
  "@humanwhocodes/retry": ["@humanwhocodes/[email protected]", "", {}, "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ=="],
150
 
151
- "@iconify/json": ["@iconify/[email protected].344", "", { "dependencies": { "@iconify/types": "*", "pathe": "^1.1.2" } }, "sha512-wY+TYoq4WSAaNunAbecUS+5S4VobTW/3cWpTLEWezRW9Wv4V3MqguNirNN3c/jSq/dfIMJNvr9Gz+N9iNFMInA=="],
152
 
153
  "@iconify/tailwind4": ["@iconify/[email protected]", "", { "dependencies": { "@iconify/types": "^2.0.0", "@iconify/utils": "^2.2.1" }, "peerDependencies": { "tailwindcss": ">= 4" } }, "sha512-43ZXe+bC7CuE2LCgROdqbQeFYJi/J7L/k1UpSy8KDQlWVsWxPzLSWbWhlJx4uRYLOh1NRyw02YlDOgzBOFNd+A=="],
154
 
@@ -238,7 +239,7 @@
238
 
239
  "@sveltejs/kit": ["@sveltejs/[email protected]", "", { "dependencies": { "@sveltejs/acorn-typescript": "^1.0.5", "@types/cookie": "^0.6.0", "acorn": "^8.14.1", "cookie": "^0.6.0", "devalue": "^5.1.0", "esm-env": "^1.2.2", "kleur": "^4.1.5", "magic-string": "^0.30.5", "mrmime": "^2.0.0", "sade": "^1.8.1", "set-cookie-parser": "^2.6.0", "sirv": "^3.0.0" }, "peerDependencies": { "@sveltejs/vite-plugin-svelte": "^3.0.0 || ^4.0.0-next.1 || ^5.0.0", "svelte": "^4.0.0 || ^5.0.0-next.0", "vite": "^5.0.3 || ^6.0.0" }, "bin": { "svelte-kit": "svelte-kit.js" } }, "sha512-vLbtVwtDcK8LhJKnFkFYwM0uCdFmzioQnif0bjEYH1I24Arz22JPr/hLUiXGVYAwhu8INKx5qrdvr4tHgPwX6w=="],
240
 
241
- "@sveltejs/vite-plugin-svelte": ["@sveltejs/[email protected].3", "", { "dependencies": { "@sveltejs/vite-plugin-svelte-inspector": "^4.0.1", "debug": "^4.4.0", "deepmerge": "^4.3.1", "kleur": "^4.1.5", "magic-string": "^0.30.15", "vitefu": "^1.0.4" }, "peerDependencies": { "svelte": "^5.0.0", "vite": "^6.0.0" } }, "sha512-MCFS6CrQDu1yGwspm4qtli0e63vaPCehf6V7pIMP15AsWgMKrqDGCPFF/0kn4SP0ii4aySu4Pa62+fIRGFMjgw=="],
242
 
243
  "@sveltejs/vite-plugin-svelte-inspector": ["@sveltejs/[email protected]", "", { "dependencies": { "debug": "^4.3.7" }, "peerDependencies": { "@sveltejs/vite-plugin-svelte": "^5.0.0", "svelte": "^5.0.0", "vite": "^6.0.0" } }, "sha512-J/Nmb2Q2y7mck2hyCX4ckVHcR5tu2J+MtBEQqpDrrgELZ2uvraQcK/ioCV61AqkdXFgriksOKIceDcQmqnGhVw=="],
244
 
@@ -296,29 +297,29 @@
296
 
297
  "@types/stats.js": ["@types/[email protected]", "", {}, "sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA=="],
298
 
299
- "@types/three": ["@types/three@0.176.0", "", { "dependencies": { "@dimforge/rapier3d-compat": "^0.12.0", "@tweenjs/tween.js": "~23.1.3", "@types/stats.js": "*", "@types/webxr": "*", "@webgpu/types": "*", "fflate": "~0.8.2", "meshoptimizer": "~0.18.1" } }, "sha512-FwfPXxCqOtP7EdYMagCFePNKoG1AGBDUEVKtluv2BTVRpSt7b+X27xNsirPCTCqY1pGYsPUzaM3jgWP7dXSxlw=="],
300
 
301
  "@types/webxr": ["@types/[email protected]", "", {}, "sha512-Vr6Stjv5jPRqH690f5I5GLjVk8GSsoQSYJ2FVd/3jJF7KaqfwPi3ehfBS96mlQ2kPCwZaX6U0rG2+NGHBKkA/A=="],
302
 
303
- "@typescript-eslint/eslint-plugin": ["@typescript-eslint/[email protected].0", "", { "dependencies": { "@eslint-community/regexpp": "^4.10.0", "@typescript-eslint/scope-manager": "8.33.0", "@typescript-eslint/type-utils": "8.33.0", "@typescript-eslint/utils": "8.33.0", "@typescript-eslint/visitor-keys": "8.33.0", "graphemer": "^1.4.0", "ignore": "^7.0.0", "natural-compare": "^1.4.0", "ts-api-utils": "^2.1.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.33.0", "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <5.9.0" } }, "sha512-CACyQuqSHt7ma3Ns601xykeBK/rDeZa3w6IS6UtMQbixO5DWy+8TilKkviGDH6jtWCo8FGRKEK5cLLkPvEammQ=="],
304
 
305
- "@typescript-eslint/parser": ["@typescript-eslint/[email protected].0", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.33.0", "@typescript-eslint/types": "8.33.0", "@typescript-eslint/typescript-estree": "8.33.0", "@typescript-eslint/visitor-keys": "8.33.0", "debug": "^4.3.4" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <5.9.0" } }, "sha512-JaehZvf6m0yqYp34+RVnihBAChkqeH+tqqhS0GuX1qgPpwLvmTPheKEs6OeCK6hVJgXZHJ2vbjnC9j119auStQ=="],
306
 
307
- "@typescript-eslint/project-service": ["@typescript-eslint/[email protected].0", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.33.0", "@typescript-eslint/types": "^8.33.0", "debug": "^4.3.4" } }, "sha512-d1hz0u9l6N+u/gcrk6s6gYdl7/+pp8yHheRTqP6X5hVDKALEaTn8WfGiit7G511yueBEL3OpOEpD+3/MBdoN+A=="],
308
 
309
- "@typescript-eslint/scope-manager": ["@typescript-eslint/[email protected].0", "", { "dependencies": { "@typescript-eslint/types": "8.33.0", "@typescript-eslint/visitor-keys": "8.33.0" } }, "sha512-LMi/oqrzpqxyO72ltP+dBSP6V0xiUb4saY7WLtxSfiNEBI8m321LLVFU9/QDJxjDQG9/tjSqKz/E3380TEqSTw=="],
310
 
311
- "@typescript-eslint/tsconfig-utils": ["@typescript-eslint/[email protected].0", "", { "peerDependencies": { "typescript": ">=4.8.4 <5.9.0" } }, "sha512-sTkETlbqhEoiFmGr1gsdq5HyVbSOF0145SYDJ/EQmXHtKViCaGvnyLqWFFHtEXoS0J1yU8Wyou2UGmgW88fEug=="],
312
 
313
- "@typescript-eslint/type-utils": ["@typescript-eslint/[email protected].0", "", { "dependencies": { "@typescript-eslint/typescript-estree": "8.33.0", "@typescript-eslint/utils": "8.33.0", "debug": "^4.3.4", "ts-api-utils": "^2.1.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <5.9.0" } }, "sha512-lScnHNCBqL1QayuSrWeqAL5GmqNdVUQAAMTaCwdYEdWfIrSrOGzyLGRCHXcCixa5NK6i5l0AfSO2oBSjCjf4XQ=="],
314
 
315
- "@typescript-eslint/types": ["@typescript-eslint/[email protected].0", "", {}, "sha512-DKuXOKpM5IDT1FA2g9x9x1Ug81YuKrzf4mYX8FAVSNu5Wo/LELHWQyM1pQaDkI42bX15PWl0vNPt1uGiIFUOpg=="],
316
 
317
- "@typescript-eslint/typescript-estree": ["@typescript-eslint/[email protected].0", "", { "dependencies": { "@typescript-eslint/project-service": "8.33.0", "@typescript-eslint/tsconfig-utils": "8.33.0", "@typescript-eslint/types": "8.33.0", "@typescript-eslint/visitor-keys": "8.33.0", "debug": "^4.3.4", "fast-glob": "^3.3.2", "is-glob": "^4.0.3", "minimatch": "^9.0.4", "semver": "^7.6.0", "ts-api-utils": "^2.1.0" }, "peerDependencies": { "typescript": ">=4.8.4 <5.9.0" } }, "sha512-vegY4FQoB6jL97Tu/lWRsAiUUp8qJTqzAmENH2k59SJhw0Th1oszb9Idq/FyyONLuNqT1OADJPXfyUNOR8SzAQ=="],
318
 
319
- "@typescript-eslint/utils": ["@typescript-eslint/[email protected].0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.7.0", "@typescript-eslint/scope-manager": "8.33.0", "@typescript-eslint/types": "8.33.0", "@typescript-eslint/typescript-estree": "8.33.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <5.9.0" } }, "sha512-lPFuQaLA9aSNa7D5u2EpRiqdAUhzShwGg/nhpBlc4GR6kcTABttCuyjFs8BcEZ8VWrjCBof/bePhP3Q3fS+Yrw=="],
320
 
321
- "@typescript-eslint/visitor-keys": ["@typescript-eslint/[email protected].0", "", { "dependencies": { "@typescript-eslint/types": "8.33.0", "eslint-visitor-keys": "^4.2.0" } }, "sha512-7RW7CMYoskiz5OOGAWjJFxgb7c5UNjTG292gYhWeOAcFmYCtVCSqjqSBj5zMhxbXo2JOW95YYrUWJfU0zrpaGQ=="],
322
 
323
  "@webgpu/types": ["@webgpu/[email protected]", "", {}, "sha512-w2HbBvH+qO19SB5pJOJFKs533CdZqxl3fcGonqL321VHkW7W/iBo6H8bjDy6pr/+pbMwIu5dnuaAxH7NxBqUrQ=="],
324
 
@@ -462,7 +463,7 @@
462
 
463
  "eslint-config-prettier": ["[email protected]", "", { "peerDependencies": { "eslint": ">=7.0.0" }, "bin": { "eslint-config-prettier": "bin/cli.js" } }, "sha512-zc1UmCpNltmVY34vuLRV61r1K27sWuX39E+uyUnY8xS2Bex88VV9cugG+UZbRSRGtGyFboj+D8JODyme1plMpw=="],
464
 
465
- "eslint-plugin-svelte": ["[email protected].0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.6.1", "@jridgewell/sourcemap-codec": "^1.5.0", "esutils": "^2.0.3", "globals": "^16.0.0", "known-css-properties": "^0.36.0", "postcss": "^8.4.49", "postcss-load-config": "^3.1.4", "postcss-safe-parser": "^7.0.0", "semver": "^7.6.3", "svelte-eslint-parser": "^1.2.0" }, "peerDependencies": { "eslint": "^8.57.1 || ^9.0.0", "svelte": "^3.37.0 || ^4.0.0 || ^5.0.0" }, "optionalPeers": ["svelte"] }, "sha512-nvIUNyyPGbr5922Kd1p/jXe+FfNdVPXsxLyrrXpwfSbZZEFdAYva9O/gm2lObC/wXkQo/AUmQkAihfmNJYeCjA=="],
466
 
467
  "eslint-scope": ["[email protected]", "", { "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^5.2.0" } }, "sha512-pUNxi75F8MJ/GdeKtVLSbYg4ZI34J6C0C7sbL4YOp2exGwen7ZsuBqKzUhXd0qMQ362yET3z+uPwKeg/0C2XCQ=="],
468
 
@@ -498,6 +499,8 @@
498
 
499
  "fdir": ["[email protected]", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-4BG7puHpVsIYxZUbiUE3RqGloLaSSwzYie5jvasC4LWuBWzZawynvYouhjbQKw2JuIGYdm0DzIxl8iVidKlUEw=="],
500
 
 
 
501
  "fflate": ["[email protected]", "", {}, "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A=="],
502
 
503
  "file-entry-cache": ["[email protected]", "", { "dependencies": { "flat-cache": "^4.0.0" } }, "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ=="],
@@ -726,7 +729,7 @@
726
 
727
  "supports-color": ["[email protected]", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="],
728
 
729
- "svelte": ["[email protected].10", "", { "dependencies": { "@ampproject/remapping": "^2.3.0", "@jridgewell/sourcemap-codec": "^1.5.0", "@sveltejs/acorn-typescript": "^1.0.5", "@types/estree": "^1.0.5", "acorn": "^8.12.1", "aria-query": "^5.3.1", "axobject-query": "^4.1.0", "clsx": "^2.1.1", "esm-env": "^1.2.1", "esrap": "^1.4.6", "is-reference": "^3.0.3", "locate-character": "^3.0.0", "magic-string": "^0.30.11", "zimmerframe": "^1.1.2" } }, "sha512-/yArPQIBoQS2p86LKnvJywOXkVHeEXnFgrDPSxkEfIAEkykopYuy2bF6UUqHG4IbZlJD6OurLxJT8Kn7kTk9WA=="],
730
 
731
  "svelte-check": ["[email protected]", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.25", "chokidar": "^4.0.1", "fdir": "^6.2.0", "picocolors": "^1.0.0", "sade": "^1.7.4" }, "peerDependencies": { "svelte": "^4.0.0 || ^5.0.0-next.0", "typescript": ">=5.0.0" }, "bin": { "svelte-check": "bin/svelte-check" } }, "sha512-e49SU1RStvQhoipkQ/aonDhHnG3qxHSBtNfBRb9pxVXoa+N7qybAo32KgA9wEb2PCYFNaDg7bZCdhLD1vHpdYA=="],
732
 
@@ -788,7 +791,7 @@
788
 
789
  "typescript": ["[email protected]", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ=="],
790
 
791
- "typescript-eslint": ["[email protected].0", "", { "dependencies": { "@typescript-eslint/eslint-plugin": "8.33.0", "@typescript-eslint/parser": "8.33.0", "@typescript-eslint/utils": "8.33.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <5.9.0" } }, "sha512-5YmNhF24ylCsvdNW2oJwMzTbaeO4bg90KeGtMjUw0AGtHksgEPLRTUil+coHwCfiu4QjVJFnjp94DmU6zV7DhQ=="],
792
 
793
  "ufo": ["[email protected]", "", {}, "sha512-9a4/uxlTWJ4+a5i0ooc1rU7C7YOw3wT+UGqdeNNHWnOF9qcMBgLRS+4IYUqbczewFx4mLEig6gawh7X6mFlEkA=="],
794
 
@@ -820,7 +823,7 @@
820
 
821
  "zimmerframe": ["[email protected]", "", {}, "sha512-rAbqEGa8ovJy4pyBxZM70hg4pE6gDgaQ0Sl9M3enG3I0d6H4XSAM3GeNGLKnsBpuijUow064sf7ww1nutC5/3w=="],
822
 
823
- "zod": ["[email protected].42", "", {}, "sha512-PcALTLskaucbeHc41tU/xfjfhcz8z0GdhhDcSgrCTmSazUuqnYqiXO63M0QUBVwpBlsLsNVn5qHSC5Dw3KZvaQ=="],
824
 
825
  "@eslint-community/eslint-utils/eslint-visitor-keys": ["[email protected]", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="],
826
 
 
6
  "dependencies": {
7
  "@threlte/core": "^8.0.4",
8
  "@threlte/extras": "^9.2.1",
9
+ "@types/three": "0.177.0",
10
  "clsx": "^2.1.1",
11
+ "feetech.js": "file:packages/feetech.js",
12
  "tailwind-merge": "^3.3.0",
13
  "three": "^0.177.0",
14
  "threlte-postprocessing": "^0.0.9",
 
149
 
150
  "@humanwhocodes/retry": ["@humanwhocodes/[email protected]", "", {}, "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ=="],
151
 
152
+ "@iconify/json": ["@iconify/[email protected].345", "", { "dependencies": { "@iconify/types": "*", "pathe": "^1.1.2" } }, "sha512-cWcTkpSw42OcltXXlLRMp4bnoFEMvEXEIZDPazqqpT7nr4dPN/ztEqOk6T3z0fXrN2E3OEgW0GnHlQqZz4qDgw=="],
153
 
154
  "@iconify/tailwind4": ["@iconify/[email protected]", "", { "dependencies": { "@iconify/types": "^2.0.0", "@iconify/utils": "^2.2.1" }, "peerDependencies": { "tailwindcss": ">= 4" } }, "sha512-43ZXe+bC7CuE2LCgROdqbQeFYJi/J7L/k1UpSy8KDQlWVsWxPzLSWbWhlJx4uRYLOh1NRyw02YlDOgzBOFNd+A=="],
155
 
 
239
 
240
  "@sveltejs/kit": ["@sveltejs/[email protected]", "", { "dependencies": { "@sveltejs/acorn-typescript": "^1.0.5", "@types/cookie": "^0.6.0", "acorn": "^8.14.1", "cookie": "^0.6.0", "devalue": "^5.1.0", "esm-env": "^1.2.2", "kleur": "^4.1.5", "magic-string": "^0.30.5", "mrmime": "^2.0.0", "sade": "^1.8.1", "set-cookie-parser": "^2.6.0", "sirv": "^3.0.0" }, "peerDependencies": { "@sveltejs/vite-plugin-svelte": "^3.0.0 || ^4.0.0-next.1 || ^5.0.0", "svelte": "^4.0.0 || ^5.0.0-next.0", "vite": "^5.0.3 || ^6.0.0" }, "bin": { "svelte-kit": "svelte-kit.js" } }, "sha512-vLbtVwtDcK8LhJKnFkFYwM0uCdFmzioQnif0bjEYH1I24Arz22JPr/hLUiXGVYAwhu8INKx5qrdvr4tHgPwX6w=="],
241
 
242
+ "@sveltejs/vite-plugin-svelte": ["@sveltejs/vite-plugin-svelte@5.1.0", "", { "dependencies": { "@sveltejs/vite-plugin-svelte-inspector": "^4.0.1", "debug": "^4.4.1", "deepmerge": "^4.3.1", "kleur": "^4.1.5", "magic-string": "^0.30.17", "vitefu": "^1.0.6" }, "peerDependencies": { "svelte": "^5.0.0", "vite": "^6.0.0" } }, "sha512-wojIS/7GYnJDYIg1higWj2ROA6sSRWvcR1PO/bqEyFr/5UZah26c8Cz4u0NaqjPeVltzsVpt2Tm8d2io0V+4Tw=="],
243
 
244
  "@sveltejs/vite-plugin-svelte-inspector": ["@sveltejs/[email protected]", "", { "dependencies": { "debug": "^4.3.7" }, "peerDependencies": { "@sveltejs/vite-plugin-svelte": "^5.0.0", "svelte": "^5.0.0", "vite": "^6.0.0" } }, "sha512-J/Nmb2Q2y7mck2hyCX4ckVHcR5tu2J+MtBEQqpDrrgELZ2uvraQcK/ioCV61AqkdXFgriksOKIceDcQmqnGhVw=="],
245
 
 
297
 
298
  "@types/stats.js": ["@types/[email protected]", "", {}, "sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA=="],
299
 
300
+ "@types/three": ["@types/three@0.177.0", "", { "dependencies": { "@dimforge/rapier3d-compat": "~0.12.0", "@tweenjs/tween.js": "~23.1.3", "@types/stats.js": "*", "@types/webxr": "*", "@webgpu/types": "*", "fflate": "~0.8.2", "meshoptimizer": "~0.18.1" } }, "sha512-/ZAkn4OLUijKQySNci47lFO+4JLE1TihEjsGWPUT+4jWqxtwOPPEwJV1C3k5MEx0mcBPCdkFjzRzDOnHEI1R+A=="],
301
 
302
  "@types/webxr": ["@types/[email protected]", "", {}, "sha512-Vr6Stjv5jPRqH690f5I5GLjVk8GSsoQSYJ2FVd/3jJF7KaqfwPi3ehfBS96mlQ2kPCwZaX6U0rG2+NGHBKkA/A=="],
303
 
304
+ "@typescript-eslint/eslint-plugin": ["@typescript-eslint/[email protected].1", "", { "dependencies": { "@eslint-community/regexpp": "^4.10.0", "@typescript-eslint/scope-manager": "8.33.1", "@typescript-eslint/type-utils": "8.33.1", "@typescript-eslint/utils": "8.33.1", "@typescript-eslint/visitor-keys": "8.33.1", "graphemer": "^1.4.0", "ignore": "^7.0.0", "natural-compare": "^1.4.0", "ts-api-utils": "^2.1.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.33.1", "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <5.9.0" } }, "sha512-TDCXj+YxLgtvxvFlAvpoRv9MAncDLBV2oT9Bd7YBGC/b/sEURoOYuIwLI99rjWOfY3QtDzO+mk0n4AmdFExW8A=="],
305
 
306
+ "@typescript-eslint/parser": ["@typescript-eslint/[email protected].1", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.33.1", "@typescript-eslint/types": "8.33.1", "@typescript-eslint/typescript-estree": "8.33.1", "@typescript-eslint/visitor-keys": "8.33.1", "debug": "^4.3.4" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <5.9.0" } }, "sha512-qwxv6dq682yVvgKKp2qWwLgRbscDAYktPptK4JPojCwwi3R9cwrvIxS4lvBpzmcqzR4bdn54Z0IG1uHFskW4dA=="],
307
 
308
+ "@typescript-eslint/project-service": ["@typescript-eslint/[email protected].1", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.33.1", "@typescript-eslint/types": "^8.33.1", "debug": "^4.3.4" }, "peerDependencies": { "typescript": ">=4.8.4 <5.9.0" } }, "sha512-DZR0efeNklDIHHGRpMpR5gJITQpu6tLr9lDJnKdONTC7vvzOlLAG/wcfxcdxEWrbiZApcoBCzXqU/Z458Za5Iw=="],
309
 
310
+ "@typescript-eslint/scope-manager": ["@typescript-eslint/[email protected].1", "", { "dependencies": { "@typescript-eslint/types": "8.33.1", "@typescript-eslint/visitor-keys": "8.33.1" } }, "sha512-dM4UBtgmzHR9bS0Rv09JST0RcHYearoEoo3pG5B6GoTR9XcyeqX87FEhPo+5kTvVfKCvfHaHrcgeJQc6mrDKrA=="],
311
 
312
+ "@typescript-eslint/tsconfig-utils": ["@typescript-eslint/[email protected].1", "", { "peerDependencies": { "typescript": ">=4.8.4 <5.9.0" } }, "sha512-STAQsGYbHCF0/e+ShUQ4EatXQ7ceh3fBCXkNU7/MZVKulrlq1usH7t2FhxvCpuCi5O5oi1vmVaAjrGeL71OK1g=="],
313
 
314
+ "@typescript-eslint/type-utils": ["@typescript-eslint/[email protected].1", "", { "dependencies": { "@typescript-eslint/typescript-estree": "8.33.1", "@typescript-eslint/utils": "8.33.1", "debug": "^4.3.4", "ts-api-utils": "^2.1.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <5.9.0" } }, "sha512-1cG37d9xOkhlykom55WVwG2QRNC7YXlxMaMzqw2uPeJixBFfKWZgaP/hjAObqMN/u3fr5BrTwTnc31/L9jQ2ww=="],
315
 
316
+ "@typescript-eslint/types": ["@typescript-eslint/[email protected].1", "", {}, "sha512-xid1WfizGhy/TKMTwhtVOgalHwPtV8T32MS9MaH50Cwvz6x6YqRIPdD2WvW0XaqOzTV9p5xdLY0h/ZusU5Lokg=="],
317
 
318
+ "@typescript-eslint/typescript-estree": ["@typescript-eslint/[email protected].1", "", { "dependencies": { "@typescript-eslint/project-service": "8.33.1", "@typescript-eslint/tsconfig-utils": "8.33.1", "@typescript-eslint/types": "8.33.1", "@typescript-eslint/visitor-keys": "8.33.1", "debug": "^4.3.4", "fast-glob": "^3.3.2", "is-glob": "^4.0.3", "minimatch": "^9.0.4", "semver": "^7.6.0", "ts-api-utils": "^2.1.0" }, "peerDependencies": { "typescript": ">=4.8.4 <5.9.0" } }, "sha512-+s9LYcT8LWjdYWu7IWs7FvUxpQ/DGkdjZeE/GGulHvv8rvYwQvVaUZ6DE+j5x/prADUgSbbCWZ2nPI3usuVeOA=="],
319
 
320
+ "@typescript-eslint/utils": ["@typescript-eslint/[email protected].1", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.7.0", "@typescript-eslint/scope-manager": "8.33.1", "@typescript-eslint/types": "8.33.1", "@typescript-eslint/typescript-estree": "8.33.1" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <5.9.0" } }, "sha512-52HaBiEQUaRYqAXpfzWSR2U3gxk92Kw006+xZpElaPMg3C4PgM+A5LqwoQI1f9E5aZ/qlxAZxzm42WX+vn92SQ=="],
321
 
322
+ "@typescript-eslint/visitor-keys": ["@typescript-eslint/[email protected].1", "", { "dependencies": { "@typescript-eslint/types": "8.33.1", "eslint-visitor-keys": "^4.2.0" } }, "sha512-3i8NrFcZeeDHJ+7ZUuDkGT+UHq+XoFGsymNK2jZCOHcfEzRQ0BdpRtdpSx/Iyf3MHLWIcLS0COuOPibKQboIiQ=="],
323
 
324
  "@webgpu/types": ["@webgpu/[email protected]", "", {}, "sha512-w2HbBvH+qO19SB5pJOJFKs533CdZqxl3fcGonqL321VHkW7W/iBo6H8bjDy6pr/+pbMwIu5dnuaAxH7NxBqUrQ=="],
325
 
 
463
 
464
  "eslint-config-prettier": ["[email protected]", "", { "peerDependencies": { "eslint": ">=7.0.0" }, "bin": { "eslint-config-prettier": "bin/cli.js" } }, "sha512-zc1UmCpNltmVY34vuLRV61r1K27sWuX39E+uyUnY8xS2Bex88VV9cugG+UZbRSRGtGyFboj+D8JODyme1plMpw=="],
465
 
466
+ "eslint-plugin-svelte": ["[email protected].1", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.6.1", "@jridgewell/sourcemap-codec": "^1.5.0", "esutils": "^2.0.3", "globals": "^16.0.0", "known-css-properties": "^0.36.0", "postcss": "^8.4.49", "postcss-load-config": "^3.1.4", "postcss-safe-parser": "^7.0.0", "semver": "^7.6.3", "svelte-eslint-parser": "^1.2.0" }, "peerDependencies": { "eslint": "^8.57.1 || ^9.0.0", "svelte": "^3.37.0 || ^4.0.0 || ^5.0.0" }, "optionalPeers": ["svelte"] }, "sha512-mXFulSdD/0/p+zwENjPNsiVwAqmSRp90sy5zvVQBX1yAXhJbdhIn6C/tn8BZYjU94Ia7Y87d1Xdbvi49DeWyHQ=="],
467
 
468
  "eslint-scope": ["[email protected]", "", { "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^5.2.0" } }, "sha512-pUNxi75F8MJ/GdeKtVLSbYg4ZI34J6C0C7sbL4YOp2exGwen7ZsuBqKzUhXd0qMQ362yET3z+uPwKeg/0C2XCQ=="],
469
 
 
499
 
500
  "fdir": ["[email protected]", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-4BG7puHpVsIYxZUbiUE3RqGloLaSSwzYie5jvasC4LWuBWzZawynvYouhjbQKw2JuIGYdm0DzIxl8iVidKlUEw=="],
501
 
502
+ "feetech.js": ["feetech.js@file:packages/feetech.js", {}],
503
+
504
  "fflate": ["[email protected]", "", {}, "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A=="],
505
 
506
  "file-entry-cache": ["[email protected]", "", { "dependencies": { "flat-cache": "^4.0.0" } }, "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ=="],
 
729
 
730
  "supports-color": ["[email protected]", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="],
731
 
732
+ "svelte": ["[email protected].14", "", { "dependencies": { "@ampproject/remapping": "^2.3.0", "@jridgewell/sourcemap-codec": "^1.5.0", "@sveltejs/acorn-typescript": "^1.0.5", "@types/estree": "^1.0.5", "acorn": "^8.12.1", "aria-query": "^5.3.1", "axobject-query": "^4.1.0", "clsx": "^2.1.1", "esm-env": "^1.2.1", "esrap": "^1.4.6", "is-reference": "^3.0.3", "locate-character": "^3.0.0", "magic-string": "^0.30.11", "zimmerframe": "^1.1.2" } }, "sha512-kRlbhIlMTijbFmVDQFDeKXPLlX1/ovXwV0I162wRqQhRcygaqDIcu1d/Ese3H2uI+yt3uT8E7ndgDthQv5v5BA=="],
733
 
734
  "svelte-check": ["[email protected]", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.25", "chokidar": "^4.0.1", "fdir": "^6.2.0", "picocolors": "^1.0.0", "sade": "^1.7.4" }, "peerDependencies": { "svelte": "^4.0.0 || ^5.0.0-next.0", "typescript": ">=5.0.0" }, "bin": { "svelte-check": "bin/svelte-check" } }, "sha512-e49SU1RStvQhoipkQ/aonDhHnG3qxHSBtNfBRb9pxVXoa+N7qybAo32KgA9wEb2PCYFNaDg7bZCdhLD1vHpdYA=="],
735
 
 
791
 
792
  "typescript": ["[email protected]", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ=="],
793
 
794
+ "typescript-eslint": ["[email protected].1", "", { "dependencies": { "@typescript-eslint/eslint-plugin": "8.33.1", "@typescript-eslint/parser": "8.33.1", "@typescript-eslint/utils": "8.33.1" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <5.9.0" } }, "sha512-AgRnV4sKkWOiZ0Kjbnf5ytTJXMUZQ0qhSVdQtDNYLPLnjsATEYhaO94GlRQwi4t4gO8FfjM6NnikHeKjUm8D7A=="],
795
 
796
  "ufo": ["[email protected]", "", {}, "sha512-9a4/uxlTWJ4+a5i0ooc1rU7C7YOw3wT+UGqdeNNHWnOF9qcMBgLRS+4IYUqbczewFx4mLEig6gawh7X6mFlEkA=="],
797
 
 
823
 
824
  "zimmerframe": ["[email protected]", "", {}, "sha512-rAbqEGa8ovJy4pyBxZM70hg4pE6gDgaQ0Sl9M3enG3I0d6H4XSAM3GeNGLKnsBpuijUow064sf7ww1nutC5/3w=="],
825
 
826
+ "zod": ["[email protected].49", "", {}, "sha512-JMMPMy9ZBk3XFEdbM3iL1brx4NUSejd6xr3ELrrGEfGb355gjhiAWtG3K5o+AViV/3ZfkIrCzXsZn6SbLwTR8Q=="],
827
 
828
  "@eslint-community/eslint-utils/eslint-visitor-keys": ["[email protected]", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="],
829
 
components.json CHANGED
@@ -5,9 +5,9 @@
5
  "baseColor": "stone"
6
  },
7
  "aliases": {
8
- "components": "$lib/components",
9
  "utils": "$lib/utils",
10
- "ui": "$lib/components/ui",
11
  "hooks": "$lib/hooks",
12
  "lib": "$lib"
13
  },
 
5
  "baseColor": "stone"
6
  },
7
  "aliases": {
8
+ "components": "@/components",
9
  "utils": "$lib/utils",
10
+ "ui": "@/components/ui",
11
  "hooks": "$lib/hooks",
12
  "lib": "$lib"
13
  },
docker-compose.yml CHANGED
@@ -1,14 +1,14 @@
1
- version: '3.8'
2
 
3
  services:
4
  lerobot-arena:
5
  build: .
6
  ports:
7
- - "7860:7860" # FastAPI server serving both frontend and backend
8
  environment:
9
  - NODE_ENV=production
10
  # volumes:
11
  # # Optional: Mount local development files for debugging
12
  # - ./src-python:/home/user/app/src-python
13
  # - ./static-frontend:/home/user/app/static-frontend
14
- restart: unless-stopped
 
1
+ version: "3.8"
2
 
3
  services:
4
  lerobot-arena:
5
  build: .
6
  ports:
7
+ - "7860:7860" # FastAPI server serving both frontend and backend
8
  environment:
9
  - NODE_ENV=production
10
  # volumes:
11
  # # Optional: Mount local development files for debugging
12
  # - ./src-python:/home/user/app/src-python
13
  # - ./static-frontend:/home/user/app/static-frontend
14
+ restart: unless-stopped
eslint.config.js CHANGED
@@ -1,13 +1,13 @@
1
- import prettier from 'eslint-config-prettier';
2
- import js from '@eslint/js';
3
- import { includeIgnoreFile } from '@eslint/compat';
4
- import svelte from 'eslint-plugin-svelte';
5
- import globals from 'globals';
6
- import { fileURLToPath } from 'node:url';
7
- import ts from 'typescript-eslint';
8
- import svelteConfig from './svelte.config.js';
9
 
10
- const gitignorePath = fileURLToPath(new URL('./.gitignore', import.meta.url));
11
 
12
  export default ts.config(
13
  includeIgnoreFile(gitignorePath),
@@ -20,14 +20,14 @@ export default ts.config(
20
  languageOptions: {
21
  globals: { ...globals.browser, ...globals.node }
22
  },
23
- rules: { 'no-undef': 'off' }
24
  },
25
  {
26
- files: ['**/*.svelte', '**/*.svelte.ts', '**/*.svelte.js'],
27
  languageOptions: {
28
  parserOptions: {
29
  projectService: true,
30
- extraFileExtensions: ['.svelte'],
31
  parser: ts.parser,
32
  svelteConfig
33
  }
 
1
+ import prettier from "eslint-config-prettier";
2
+ import js from "@eslint/js";
3
+ import { includeIgnoreFile } from "@eslint/compat";
4
+ import svelte from "eslint-plugin-svelte";
5
+ import globals from "globals";
6
+ import { fileURLToPath } from "node:url";
7
+ import ts from "typescript-eslint";
8
+ import svelteConfig from "./svelte.config.js";
9
 
10
+ const gitignorePath = fileURLToPath(new URL("./.gitignore", import.meta.url));
11
 
12
  export default ts.config(
13
  includeIgnoreFile(gitignorePath),
 
20
  languageOptions: {
21
  globals: { ...globals.browser, ...globals.node }
22
  },
23
+ rules: { "no-undef": "off" }
24
  },
25
  {
26
+ files: ["**/*.svelte", "**/*.svelte.ts", "**/*.svelte.js"],
27
  languageOptions: {
28
  parserOptions: {
29
  projectService: true,
30
+ extraFileExtensions: [".svelte"],
31
  parser: ts.parser,
32
  svelteConfig
33
  }
package.json CHANGED
@@ -52,8 +52,9 @@
52
  "dependencies": {
53
  "@threlte/core": "^8.0.4",
54
  "@threlte/extras": "^9.2.1",
55
- "@types/three": "^0.176.0",
56
  "clsx": "^2.1.1",
 
57
  "tailwind-merge": "^3.3.0",
58
  "three": "^0.177.0",
59
  "threlte-postprocessing": "^0.0.9",
 
52
  "dependencies": {
53
  "@threlte/core": "^8.0.4",
54
  "@threlte/extras": "^9.2.1",
55
+ "@types/three": "0.177.0",
56
  "clsx": "^2.1.1",
57
+ "feetech.js": "file:packages/feetech.js",
58
  "tailwind-merge": "^3.3.0",
59
  "three": "^0.177.0",
60
  "threlte-postprocessing": "^0.0.9",
{src/lib → packages}/feetech.js/README.md RENAMED
@@ -10,7 +10,7 @@ npm install feetech.js
10
  ```
11
 
12
  ```javascript
13
- import { scsServoSDK } from 'feetech.js';
14
 
15
  await scsServoSDK.connect();
16
 
@@ -22,5 +22,3 @@ console.log(position); // 1122
22
 
23
  - simple example: [test.html](./test.html)
24
  - the bambot website: [bambot.org](https://bambot.org)
25
-
26
-
 
10
  ```
11
 
12
  ```javascript
13
+ import { scsServoSDK } from "feetech.js";
14
 
15
  await scsServoSDK.connect();
16
 
 
22
 
23
  - simple example: [test.html](./test.html)
24
  - the bambot website: [bambot.org](https://bambot.org)
 
 
packages/feetech.js/index.d.ts ADDED
@@ -0,0 +1,37 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ export type ConnectionOptions = {
2
+ baudRate?: number;
3
+ protocolEnd?: number;
4
+ };
5
+
6
+ export type ServoPositions = Map<number, number> | Record<number, number>;
7
+ export type ServoSpeeds = Map<number, number> | Record<number, number>; // New type alias for speeds
8
+
9
+ export interface ScsServoSDK {
10
+ connect(options?: ConnectionOptions): Promise<true>;
11
+ disconnect(): Promise<true>;
12
+ readPosition(servoId: number): Promise<number>;
13
+ readBaudRate(servoId: number): Promise<number>;
14
+ readMode(servoId: number): Promise<number>;
15
+ writePosition(servoId: number, position: number): Promise<"success">;
16
+ writeTorqueEnable(servoId: number, enable: boolean): Promise<"success">;
17
+ writeAcceleration(servoId: number, acceleration: number): Promise<"success">;
18
+ setWheelMode(servoId: number): Promise<"success">;
19
+ setPositionMode(servoId: number): Promise<"success">;
20
+ writeWheelSpeed(servoId: number, speed: number): Promise<"success">;
21
+ syncReadPositions(servoIds: number[]): Promise<Map<number, number>>;
22
+ syncWritePositions(servoPositions: ServoPositions): Promise<"success">;
23
+ syncWriteWheelSpeed(servoSpeeds: ServoSpeeds): Promise<"success">; // Add syncWriteWheelSpeed definition
24
+ setBaudRate(servoId: number, baudRateIndex: number): Promise<"success">;
25
+ setServoId(currentServoId: number, newServoId: number): Promise<"success">;
26
+ }
27
+
28
+ export const scsServoSDK: ScsServoSDK;
29
+
30
+ export interface ScsServoSDKUnlock {
31
+ connect(options?: ConnectionOptions): Promise<true>;
32
+ disconnect(): Promise<true>;
33
+ readPosition(servoId: number): Promise<number>;
34
+ syncReadPositions(servoIds: number[]): Promise<Map<number, number>>;
35
+ }
36
+
37
+ export const scsServoSDKUnlock: ScsServoSDKUnlock;
packages/feetech.js/index.mjs ADDED
@@ -0,0 +1,57 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Import all functions from the new scsServoSDK module
2
+ import {
3
+ connect,
4
+ disconnect,
5
+ readPosition,
6
+ readBaudRate,
7
+ readMode,
8
+ writePosition,
9
+ writeTorqueEnable,
10
+ writeAcceleration,
11
+ setWheelMode,
12
+ setPositionMode,
13
+ writeWheelSpeed,
14
+ syncReadPositions,
15
+ syncWritePositions,
16
+ syncWriteWheelSpeed, // Import the new function
17
+ setBaudRate,
18
+ setServoId
19
+ } from "./scsServoSDK.mjs";
20
+
21
+ // Create an object to hold all the SCS servo functions
22
+ export const scsServoSDK = {
23
+ connect,
24
+ disconnect,
25
+ readPosition,
26
+ readBaudRate,
27
+ readMode,
28
+ writePosition,
29
+ writeTorqueEnable,
30
+ writeAcceleration,
31
+ setWheelMode,
32
+ setPositionMode,
33
+ writeWheelSpeed,
34
+ syncReadPositions,
35
+ syncWritePositions,
36
+ syncWriteWheelSpeed, // Add the new function to the export
37
+ setBaudRate,
38
+ setServoId
39
+ };
40
+
41
+ import {
42
+ connect as connectUnlock,
43
+ disconnect as disconnectUnlock,
44
+ readPosition as readPositionUnlock,
45
+ syncReadPositions as syncReadPositionsUnlock
46
+ } from "./scsServoSDKUnlock.mjs";
47
+
48
+ export const scsServoSDKUnlock = {
49
+ connect: connectUnlock,
50
+ disconnect: disconnectUnlock,
51
+ readPosition: readPositionUnlock,
52
+ syncReadPositions: syncReadPositionsUnlock
53
+ };
54
+
55
+ // Future: You can add exports for other servo types here, e.g.:
56
+ // export { stsServoSDK } from './stsServoSDK.mjs';
57
+ // export { smsServoSDK } from './smsServoSDK.mjs';
packages/feetech.js/lowLevelSDK.mjs ADDED
@@ -0,0 +1,1232 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Constants
2
+ export const BROADCAST_ID = 0xfe; // 254
3
+ export const MAX_ID = 0xfc; // 252
4
+
5
+ // Protocol instructions
6
+ export const INST_PING = 1;
7
+ export const INST_READ = 2;
8
+ export const INST_WRITE = 3;
9
+ export const INST_REG_WRITE = 4;
10
+ export const INST_ACTION = 5;
11
+ export const INST_SYNC_WRITE = 131; // 0x83
12
+ export const INST_SYNC_READ = 130; // 0x82
13
+ export const INST_STATUS = 85; // 0x55, status packet instruction (0x55)
14
+
15
+ // Communication results
16
+ export const COMM_SUCCESS = 0; // tx or rx packet communication success
17
+ export const COMM_PORT_BUSY = -1; // Port is busy (in use)
18
+ export const COMM_TX_FAIL = -2; // Failed transmit instruction packet
19
+ export const COMM_RX_FAIL = -3; // Failed get status packet
20
+ export const COMM_TX_ERROR = -4; // Incorrect instruction packet
21
+ export const COMM_RX_WAITING = -5; // Now receiving status packet
22
+ export const COMM_RX_TIMEOUT = -6; // There is no status packet
23
+ export const COMM_RX_CORRUPT = -7; // Incorrect status packet
24
+ export const COMM_NOT_AVAILABLE = -9;
25
+
26
+ // Packet constants
27
+ export const TXPACKET_MAX_LEN = 250;
28
+ export const RXPACKET_MAX_LEN = 250;
29
+
30
+ // Protocol Packet positions
31
+ export const PKT_HEADER0 = 0;
32
+ export const PKT_HEADER1 = 1;
33
+ export const PKT_ID = 2;
34
+ export const PKT_LENGTH = 3;
35
+ export const PKT_INSTRUCTION = 4;
36
+ export const PKT_ERROR = 4;
37
+ export const PKT_PARAMETER0 = 5;
38
+
39
+ // Protocol Error bits
40
+ export const ERRBIT_VOLTAGE = 1;
41
+ export const ERRBIT_ANGLE = 2;
42
+ export const ERRBIT_OVERHEAT = 4;
43
+ export const ERRBIT_OVERELE = 8;
44
+ export const ERRBIT_OVERLOAD = 32;
45
+
46
+ // Default settings
47
+ const DEFAULT_BAUDRATE = 1000000;
48
+ const LATENCY_TIMER = 16;
49
+
50
+ // Global protocol end state
51
+ let SCS_END = 0; // (STS/SMS=0, SCS=1)
52
+
53
+ // Utility functions for handling word operations
54
+ export function SCS_LOWORD(l) {
55
+ return l & 0xffff;
56
+ }
57
+
58
+ export function SCS_HIWORD(l) {
59
+ return (l >> 16) & 0xffff;
60
+ }
61
+
62
+ export function SCS_LOBYTE(w) {
63
+ if (SCS_END === 0) {
64
+ return w & 0xff;
65
+ } else {
66
+ return (w >> 8) & 0xff;
67
+ }
68
+ }
69
+
70
+ export function SCS_HIBYTE(w) {
71
+ if (SCS_END === 0) {
72
+ return (w >> 8) & 0xff;
73
+ } else {
74
+ return w & 0xff;
75
+ }
76
+ }
77
+
78
+ export function SCS_MAKEWORD(a, b) {
79
+ if (SCS_END === 0) {
80
+ return (a & 0xff) | ((b & 0xff) << 8);
81
+ } else {
82
+ return (b & 0xff) | ((a & 0xff) << 8);
83
+ }
84
+ }
85
+
86
+ export function SCS_MAKEDWORD(a, b) {
87
+ return (a & 0xffff) | ((b & 0xffff) << 16);
88
+ }
89
+
90
+ export function SCS_TOHOST(a, b) {
91
+ if (a & (1 << b)) {
92
+ return -(a & ~(1 << b));
93
+ } else {
94
+ return a;
95
+ }
96
+ }
97
+
98
+ export class PortHandler {
99
+ constructor() {
100
+ this.port = null;
101
+ this.reader = null;
102
+ this.writer = null;
103
+ this.isOpen = false;
104
+ this.isUsing = false;
105
+ this.baudrate = DEFAULT_BAUDRATE;
106
+ this.packetStartTime = 0;
107
+ this.packetTimeout = 0;
108
+ this.txTimePerByte = 0;
109
+ }
110
+
111
+ async requestPort() {
112
+ try {
113
+ this.port = await navigator.serial.requestPort();
114
+ return true;
115
+ } catch (err) {
116
+ console.error("Error requesting serial port:", err);
117
+ return false;
118
+ }
119
+ }
120
+
121
+ async openPort() {
122
+ if (!this.port) {
123
+ return false;
124
+ }
125
+
126
+ try {
127
+ await this.port.open({ baudRate: this.baudrate });
128
+ this.reader = this.port.readable.getReader();
129
+ this.writer = this.port.writable.getWriter();
130
+ this.isOpen = true;
131
+ this.txTimePerByte = (1000.0 / this.baudrate) * 10.0;
132
+ return true;
133
+ } catch (err) {
134
+ console.error("Error opening port:", err);
135
+ return false;
136
+ }
137
+ }
138
+
139
+ async closePort() {
140
+ if (this.reader) {
141
+ await this.reader.releaseLock();
142
+ this.reader = null;
143
+ }
144
+
145
+ if (this.writer) {
146
+ await this.writer.releaseLock();
147
+ this.writer = null;
148
+ }
149
+
150
+ if (this.port && this.isOpen) {
151
+ await this.port.close();
152
+ this.isOpen = false;
153
+ }
154
+ }
155
+
156
+ async clearPort() {
157
+ if (this.reader) {
158
+ await this.reader.releaseLock();
159
+ this.reader = this.port.readable.getReader();
160
+ }
161
+ }
162
+
163
+ setBaudRate(baudrate) {
164
+ this.baudrate = baudrate;
165
+ this.txTimePerByte = (1000.0 / this.baudrate) * 10.0;
166
+ return true;
167
+ }
168
+
169
+ getBaudRate() {
170
+ return this.baudrate;
171
+ }
172
+
173
+ async writePort(data) {
174
+ if (!this.isOpen || !this.writer) {
175
+ return 0;
176
+ }
177
+
178
+ try {
179
+ await this.writer.write(new Uint8Array(data));
180
+ return data.length;
181
+ } catch (err) {
182
+ console.error("Error writing to port:", err);
183
+ return 0;
184
+ }
185
+ }
186
+
187
+ async readPort(length) {
188
+ if (!this.isOpen || !this.reader) {
189
+ return [];
190
+ }
191
+
192
+ try {
193
+ // Increase timeout for more reliable data reception
194
+ const timeoutMs = 500;
195
+ let totalBytes = [];
196
+ const startTime = performance.now();
197
+
198
+ // Continue reading until we get enough bytes or timeout
199
+ while (totalBytes.length < length) {
200
+ // Create a timeout promise
201
+ const timeoutPromise = new Promise((resolve) => {
202
+ setTimeout(() => resolve({ value: new Uint8Array(), done: false, timeout: true }), 100); // Short internal timeout
203
+ });
204
+
205
+ // Race between reading and timeout
206
+ const result = await Promise.race([this.reader.read(), timeoutPromise]);
207
+
208
+ if (result.timeout) {
209
+ // Internal timeout - check if we've exceeded total timeout
210
+ if (performance.now() - startTime > timeoutMs) {
211
+ console.log(`readPort total timeout after ${timeoutMs}ms`);
212
+ break;
213
+ }
214
+ continue; // Try reading again
215
+ }
216
+
217
+ if (result.done) {
218
+ console.log("Reader done, stream closed");
219
+ break;
220
+ }
221
+
222
+ if (result.value.length === 0) {
223
+ // If there's no data but we haven't timed out yet, wait briefly and try again
224
+ await new Promise((resolve) => setTimeout(resolve, 10));
225
+
226
+ // Check if we've exceeded total timeout
227
+ if (performance.now() - startTime > timeoutMs) {
228
+ console.log(`readPort total timeout after ${timeoutMs}ms`);
229
+ break;
230
+ }
231
+ continue;
232
+ }
233
+
234
+ // Add received bytes to our total
235
+ const newData = Array.from(result.value);
236
+ totalBytes.push(...newData);
237
+ console.log(
238
+ `Read ${newData.length} bytes:`,
239
+ newData.map((b) => b.toString(16).padStart(2, "0")).join(" ")
240
+ );
241
+
242
+ // If we've got enough data, we can stop
243
+ if (totalBytes.length >= length) {
244
+ break;
245
+ }
246
+ }
247
+
248
+ return totalBytes;
249
+ } catch (err) {
250
+ console.error("Error reading from port:", err);
251
+ return [];
252
+ }
253
+ }
254
+
255
+ setPacketTimeout(packetLength) {
256
+ this.packetStartTime = this.getCurrentTime();
257
+ this.packetTimeout = this.txTimePerByte * packetLength + LATENCY_TIMER * 2.0 + 2.0;
258
+ }
259
+
260
+ setPacketTimeoutMillis(msec) {
261
+ this.packetStartTime = this.getCurrentTime();
262
+ this.packetTimeout = msec;
263
+ }
264
+
265
+ isPacketTimeout() {
266
+ if (this.getTimeSinceStart() > this.packetTimeout) {
267
+ this.packetTimeout = 0;
268
+ return true;
269
+ }
270
+ return false;
271
+ }
272
+
273
+ getCurrentTime() {
274
+ return performance.now();
275
+ }
276
+
277
+ getTimeSinceStart() {
278
+ const timeSince = this.getCurrentTime() - this.packetStartTime;
279
+ if (timeSince < 0.0) {
280
+ this.packetStartTime = this.getCurrentTime();
281
+ }
282
+ return timeSince;
283
+ }
284
+ }
285
+
286
+ export class PacketHandler {
287
+ constructor(protocolEnd = 0) {
288
+ SCS_END = protocolEnd;
289
+ console.log(`PacketHandler initialized with protocol_end=${protocolEnd} (STS/SMS=0, SCS=1)`);
290
+ }
291
+
292
+ getProtocolVersion() {
293
+ return 1.0;
294
+ }
295
+
296
+ // 获取当前协议端设置的方法
297
+ getProtocolEnd() {
298
+ return SCS_END;
299
+ }
300
+
301
+ getTxRxResult(result) {
302
+ if (result === COMM_SUCCESS) {
303
+ return "[TxRxResult] Communication success!";
304
+ } else if (result === COMM_PORT_BUSY) {
305
+ return "[TxRxResult] Port is in use!";
306
+ } else if (result === COMM_TX_FAIL) {
307
+ return "[TxRxResult] Failed transmit instruction packet!";
308
+ } else if (result === COMM_RX_FAIL) {
309
+ return "[TxRxResult] Failed get status packet from device!";
310
+ } else if (result === COMM_TX_ERROR) {
311
+ return "[TxRxResult] Incorrect instruction packet!";
312
+ } else if (result === COMM_RX_WAITING) {
313
+ return "[TxRxResult] Now receiving status packet!";
314
+ } else if (result === COMM_RX_TIMEOUT) {
315
+ return "[TxRxResult] There is no status packet!";
316
+ } else if (result === COMM_RX_CORRUPT) {
317
+ return "[TxRxResult] Incorrect status packet!";
318
+ } else if (result === COMM_NOT_AVAILABLE) {
319
+ return "[TxRxResult] Protocol does not support this function!";
320
+ } else {
321
+ return "";
322
+ }
323
+ }
324
+
325
+ getRxPacketError(error) {
326
+ if (error & ERRBIT_VOLTAGE) {
327
+ return "[RxPacketError] Input voltage error!";
328
+ }
329
+ if (error & ERRBIT_ANGLE) {
330
+ return "[RxPacketError] Angle sen error!";
331
+ }
332
+ if (error & ERRBIT_OVERHEAT) {
333
+ return "[RxPacketError] Overheat error!";
334
+ }
335
+ if (error & ERRBIT_OVERELE) {
336
+ return "[RxPacketError] OverEle error!";
337
+ }
338
+ if (error & ERRBIT_OVERLOAD) {
339
+ return "[RxPacketError] Overload error!";
340
+ }
341
+ return "";
342
+ }
343
+
344
+ async txPacket(port, txpacket) {
345
+ let checksum = 0;
346
+ const totalPacketLength = txpacket[PKT_LENGTH] + 4; // 4: HEADER0 HEADER1 ID LENGTH
347
+
348
+ if (port.isUsing) {
349
+ return COMM_PORT_BUSY;
350
+ }
351
+ port.isUsing = true;
352
+
353
+ // Check max packet length
354
+ if (totalPacketLength > TXPACKET_MAX_LEN) {
355
+ port.isUsing = false;
356
+ return COMM_TX_ERROR;
357
+ }
358
+
359
+ // Make packet header
360
+ txpacket[PKT_HEADER0] = 0xff;
361
+ txpacket[PKT_HEADER1] = 0xff;
362
+
363
+ // Add checksum to packet
364
+ for (let idx = 2; idx < totalPacketLength - 1; idx++) {
365
+ checksum += txpacket[idx];
366
+ }
367
+
368
+ txpacket[totalPacketLength - 1] = ~checksum & 0xff;
369
+
370
+ // TX packet
371
+ await port.clearPort();
372
+ const writtenPacketLength = await port.writePort(txpacket);
373
+ if (totalPacketLength !== writtenPacketLength) {
374
+ port.isUsing = false;
375
+ return COMM_TX_FAIL;
376
+ }
377
+
378
+ return COMM_SUCCESS;
379
+ }
380
+
381
+ async rxPacket(port) {
382
+ let rxpacket = [];
383
+ let result = COMM_RX_FAIL;
384
+
385
+ let waitLength = 6; // minimum length (HEADER0 HEADER1 ID LENGTH)
386
+
387
+ while (true) {
388
+ const data = await port.readPort(waitLength - rxpacket.length);
389
+ rxpacket.push(...data);
390
+
391
+ if (rxpacket.length >= waitLength) {
392
+ // Find packet header
393
+ let headerIndex = -1;
394
+ for (let i = 0; i < rxpacket.length - 1; i++) {
395
+ if (rxpacket[i] === 0xff && rxpacket[i + 1] === 0xff) {
396
+ headerIndex = i;
397
+ break;
398
+ }
399
+ }
400
+
401
+ if (headerIndex === 0) {
402
+ // Found at the beginning of the packet
403
+ if (rxpacket[PKT_ID] > 0xfd || rxpacket[PKT_LENGTH] > RXPACKET_MAX_LEN) {
404
+ // Invalid ID or length
405
+ rxpacket.shift();
406
+ continue;
407
+ }
408
+
409
+ // Recalculate expected packet length
410
+ if (waitLength !== rxpacket[PKT_LENGTH] + PKT_LENGTH + 1) {
411
+ waitLength = rxpacket[PKT_LENGTH] + PKT_LENGTH + 1;
412
+ continue;
413
+ }
414
+
415
+ if (rxpacket.length < waitLength) {
416
+ // Check timeout
417
+ if (port.isPacketTimeout()) {
418
+ result = rxpacket.length === 0 ? COMM_RX_TIMEOUT : COMM_RX_CORRUPT;
419
+ break;
420
+ }
421
+ continue;
422
+ }
423
+
424
+ // Calculate checksum
425
+ let checksum = 0;
426
+ for (let i = 2; i < waitLength - 1; i++) {
427
+ checksum += rxpacket[i];
428
+ }
429
+ checksum = ~checksum & 0xff;
430
+
431
+ // Verify checksum
432
+ if (rxpacket[waitLength - 1] === checksum) {
433
+ result = COMM_SUCCESS;
434
+ } else {
435
+ result = COMM_RX_CORRUPT;
436
+ }
437
+ break;
438
+ } else if (headerIndex > 0) {
439
+ // Remove unnecessary bytes before header
440
+ rxpacket = rxpacket.slice(headerIndex);
441
+ continue;
442
+ }
443
+ }
444
+
445
+ // Check timeout
446
+ if (port.isPacketTimeout()) {
447
+ result = rxpacket.length === 0 ? COMM_RX_TIMEOUT : COMM_RX_CORRUPT;
448
+ break;
449
+ }
450
+ }
451
+
452
+ if (result !== COMM_SUCCESS) {
453
+ console.log(
454
+ `rxPacket result: ${result}, packet: ${rxpacket.map((b) => "0x" + b.toString(16).padStart(2, "0")).join(" ")}`
455
+ );
456
+ } else {
457
+ console.debug(
458
+ `rxPacket successful: ${rxpacket.map((b) => "0x" + b.toString(16).padStart(2, "0")).join(" ")}`
459
+ );
460
+ }
461
+ return [rxpacket, result];
462
+ }
463
+
464
+ async txRxPacket(port, txpacket) {
465
+ let rxpacket = null;
466
+ let error = 0;
467
+ let result = COMM_TX_FAIL;
468
+
469
+ try {
470
+ // Check if port is already in use
471
+ if (port.isUsing) {
472
+ console.log("Port is busy, cannot start new transaction");
473
+ return [rxpacket, COMM_PORT_BUSY, error];
474
+ }
475
+
476
+ // TX packet
477
+ console.log(
478
+ "Sending packet:",
479
+ txpacket.map((b) => "0x" + b.toString(16).padStart(2, "0")).join(" ")
480
+ );
481
+
482
+ // Remove retry logic and just send once
483
+ result = await this.txPacket(port, txpacket);
484
+ console.log(`TX result: ${result}`);
485
+
486
+ if (result !== COMM_SUCCESS) {
487
+ console.log(`TX failed with result: ${result}`);
488
+ port.isUsing = false; // Important: Release the port on TX failure
489
+ return [rxpacket, result, error];
490
+ }
491
+
492
+ // If ID is broadcast, no need to wait for status packet
493
+ if (txpacket[PKT_ID] === BROADCAST_ID) {
494
+ port.isUsing = false;
495
+ return [rxpacket, result, error];
496
+ }
497
+
498
+ // Set packet timeout
499
+ if (txpacket[PKT_INSTRUCTION] === INST_READ) {
500
+ const length = txpacket[PKT_PARAMETER0 + 1];
501
+ // For READ instructions, we expect response to include the data
502
+ port.setPacketTimeout(length + 10); // Add extra buffer
503
+ console.log(`Set READ packet timeout for ${length + 10} bytes`);
504
+ } else {
505
+ // For other instructions, we expect a status packet
506
+ port.setPacketTimeout(10); // HEADER0 HEADER1 ID LENGTH ERROR CHECKSUM + buffer
507
+ console.log(`Set standard packet timeout for 10 bytes`);
508
+ }
509
+
510
+ // RX packet - no retries, just attempt once
511
+ console.log(`Receiving packet`);
512
+
513
+ // Clear port before receiving to ensure clean state
514
+ await port.clearPort();
515
+
516
+ const [rxpacketResult, resultRx] = await this.rxPacket(port);
517
+ rxpacket = rxpacketResult;
518
+
519
+ // Check if received packet is valid
520
+ if (resultRx !== COMM_SUCCESS) {
521
+ console.log(`Rx failed with result: ${resultRx}`);
522
+ port.isUsing = false;
523
+ return [rxpacket, resultRx, error];
524
+ }
525
+
526
+ // Verify packet structure
527
+ if (rxpacket.length < 6) {
528
+ console.log(`Received packet too short (${rxpacket.length} bytes)`);
529
+ port.isUsing = false;
530
+ return [rxpacket, COMM_RX_CORRUPT, error];
531
+ }
532
+
533
+ // Verify packet ID matches the sent ID
534
+ if (rxpacket[PKT_ID] !== txpacket[PKT_ID]) {
535
+ console.log(
536
+ `Received packet ID (${rxpacket[PKT_ID]}) doesn't match sent ID (${txpacket[PKT_ID]})`
537
+ );
538
+ port.isUsing = false;
539
+ return [rxpacket, COMM_RX_CORRUPT, error];
540
+ }
541
+
542
+ // Packet looks valid
543
+ error = rxpacket[PKT_ERROR];
544
+ port.isUsing = false; // Release port on success
545
+ return [rxpacket, resultRx, error];
546
+ } catch (err) {
547
+ console.error("Exception in txRxPacket:", err);
548
+ port.isUsing = false; // Release port on exception
549
+ return [rxpacket, COMM_RX_FAIL, error];
550
+ }
551
+ }
552
+
553
+ async ping(port, scsId) {
554
+ let modelNumber = 0;
555
+ let error = 0;
556
+
557
+ try {
558
+ if (scsId >= BROADCAST_ID) {
559
+ console.log(`Cannot ping broadcast ID ${scsId}`);
560
+ return [modelNumber, COMM_NOT_AVAILABLE, error];
561
+ }
562
+
563
+ const txpacket = new Array(6).fill(0);
564
+ txpacket[PKT_ID] = scsId;
565
+ txpacket[PKT_LENGTH] = 2;
566
+ txpacket[PKT_INSTRUCTION] = INST_PING;
567
+
568
+ console.log(`Pinging servo ID ${scsId}...`);
569
+
570
+ // 发送ping指令并获取响应
571
+ const [rxpacket, result, err] = await this.txRxPacket(port, txpacket);
572
+ error = err;
573
+
574
+ // 与Python SDK保持一致:如果ping成功,尝试��取地址3的型号信息
575
+ if (result === COMM_SUCCESS) {
576
+ console.log(`Ping to servo ID ${scsId} succeeded, reading model number from address 3`);
577
+ // 读取地址3的型号信息(2字节)
578
+ const [data, dataResult, dataError] = await this.readTxRx(port, scsId, 3, 2);
579
+
580
+ if (dataResult === COMM_SUCCESS && data && data.length >= 2) {
581
+ modelNumber = SCS_MAKEWORD(data[0], data[1]);
582
+ console.log(`Model number read: ${modelNumber}`);
583
+ } else {
584
+ console.log(`Could not read model number: ${this.getTxRxResult(dataResult)}`);
585
+ }
586
+ } else {
587
+ console.log(`Ping failed with result: ${result}, error: ${error}`);
588
+ }
589
+
590
+ return [modelNumber, result, error];
591
+ } catch (error) {
592
+ console.error(`Exception in ping():`, error);
593
+ return [0, COMM_RX_FAIL, 0];
594
+ }
595
+ }
596
+
597
+ // Read methods
598
+ async readTxRx(port, scsId, address, length) {
599
+ if (scsId >= BROADCAST_ID) {
600
+ console.log("Cannot read from broadcast ID");
601
+ return [[], COMM_NOT_AVAILABLE, 0];
602
+ }
603
+
604
+ // Create read packet
605
+ const txpacket = new Array(8).fill(0);
606
+ txpacket[PKT_ID] = scsId;
607
+ txpacket[PKT_LENGTH] = 4;
608
+ txpacket[PKT_INSTRUCTION] = INST_READ;
609
+ txpacket[PKT_PARAMETER0] = address;
610
+ txpacket[PKT_PARAMETER0 + 1] = length;
611
+
612
+ console.log(`Reading ${length} bytes from address ${address} for servo ID ${scsId}`);
613
+
614
+ // Send packet and get response
615
+ const [rxpacket, result, error] = await this.txRxPacket(port, txpacket);
616
+
617
+ // Process the result
618
+ if (result !== COMM_SUCCESS) {
619
+ console.log(`Read failed with result: ${result}, error: ${error}`);
620
+ return [[], result, error];
621
+ }
622
+
623
+ if (!rxpacket || rxpacket.length < PKT_PARAMETER0 + length) {
624
+ console.log(
625
+ `Invalid response packet: expected at least ${PKT_PARAMETER0 + length} bytes, got ${rxpacket ? rxpacket.length : 0}`
626
+ );
627
+ return [[], COMM_RX_CORRUPT, error];
628
+ }
629
+
630
+ // Extract data from response
631
+ const data = [];
632
+ console.log(
633
+ `Response packet length: ${rxpacket.length}, extracting ${length} bytes from offset ${PKT_PARAMETER0}`
634
+ );
635
+ console.log(
636
+ `Response data bytes: ${rxpacket
637
+ .slice(PKT_PARAMETER0, PKT_PARAMETER0 + length)
638
+ .map((b) => "0x" + b.toString(16).padStart(2, "0"))
639
+ .join(" ")}`
640
+ );
641
+
642
+ for (let i = 0; i < length; i++) {
643
+ data.push(rxpacket[PKT_PARAMETER0 + i]);
644
+ }
645
+
646
+ console.log(
647
+ `Successfully read ${length} bytes: ${data.map((b) => "0x" + b.toString(16).padStart(2, "0")).join(" ")}`
648
+ );
649
+ return [data, result, error];
650
+ }
651
+
652
+ async read1ByteTxRx(port, scsId, address) {
653
+ const [data, result, error] = await this.readTxRx(port, scsId, address, 1);
654
+ const value = data.length > 0 ? data[0] : 0;
655
+ return [value, result, error];
656
+ }
657
+
658
+ async read2ByteTxRx(port, scsId, address) {
659
+ const [data, result, error] = await this.readTxRx(port, scsId, address, 2);
660
+
661
+ let value = 0;
662
+ if (data.length >= 2) {
663
+ value = SCS_MAKEWORD(data[0], data[1]);
664
+ }
665
+
666
+ return [value, result, error];
667
+ }
668
+
669
+ async read4ByteTxRx(port, scsId, address) {
670
+ const [data, result, error] = await this.readTxRx(port, scsId, address, 4);
671
+
672
+ let value = 0;
673
+ if (data.length >= 4) {
674
+ const loword = SCS_MAKEWORD(data[0], data[1]);
675
+ const hiword = SCS_MAKEWORD(data[2], data[3]);
676
+ value = SCS_MAKEDWORD(loword, hiword);
677
+
678
+ console.log(
679
+ `read4ByteTxRx: data=${data.map((b) => "0x" + b.toString(16).padStart(2, "0")).join(" ")}`
680
+ );
681
+ console.log(
682
+ ` loword=${loword} (0x${loword.toString(16)}), hiword=${hiword} (0x${hiword.toString(16)})`
683
+ );
684
+ console.log(` value=${value} (0x${value.toString(16)})`);
685
+ }
686
+
687
+ return [value, result, error];
688
+ }
689
+
690
+ // Write methods
691
+ async writeTxRx(port, scsId, address, length, data) {
692
+ if (scsId >= BROADCAST_ID) {
693
+ return [COMM_NOT_AVAILABLE, 0];
694
+ }
695
+
696
+ // Create write packet
697
+ const txpacket = new Array(length + 7).fill(0);
698
+ txpacket[PKT_ID] = scsId;
699
+ txpacket[PKT_LENGTH] = length + 3;
700
+ txpacket[PKT_INSTRUCTION] = INST_WRITE;
701
+ txpacket[PKT_PARAMETER0] = address;
702
+
703
+ // Add data
704
+ for (let i = 0; i < length; i++) {
705
+ txpacket[PKT_PARAMETER0 + 1 + i] = data[i] & 0xff;
706
+ }
707
+
708
+ // Send packet and get response
709
+ const [rxpacket, result, error] = await this.txRxPacket(port, txpacket);
710
+
711
+ return [result, error];
712
+ }
713
+
714
+ async write1ByteTxRx(port, scsId, address, data) {
715
+ const dataArray = [data & 0xff];
716
+ return await this.writeTxRx(port, scsId, address, 1, dataArray);
717
+ }
718
+
719
+ async write2ByteTxRx(port, scsId, address, data) {
720
+ const dataArray = [SCS_LOBYTE(data), SCS_HIBYTE(data)];
721
+ return await this.writeTxRx(port, scsId, address, 2, dataArray);
722
+ }
723
+
724
+ async write4ByteTxRx(port, scsId, address, data) {
725
+ const dataArray = [
726
+ SCS_LOBYTE(SCS_LOWORD(data)),
727
+ SCS_HIBYTE(SCS_LOWORD(data)),
728
+ SCS_LOBYTE(SCS_HIWORD(data)),
729
+ SCS_HIBYTE(SCS_HIWORD(data))
730
+ ];
731
+ return await this.writeTxRx(port, scsId, address, 4, dataArray);
732
+ }
733
+
734
+ // Add syncReadTx for GroupSyncRead functionality
735
+ async syncReadTx(port, startAddress, dataLength, param, paramLength) {
736
+ // Create packet: HEADER0 HEADER1 ID LEN INST START_ADDR DATA_LEN PARAM... CHKSUM
737
+ const txpacket = new Array(paramLength + 8).fill(0);
738
+
739
+ txpacket[PKT_ID] = BROADCAST_ID;
740
+ txpacket[PKT_LENGTH] = paramLength + 4; // 4: INST START_ADDR DATA_LEN CHKSUM
741
+ txpacket[PKT_INSTRUCTION] = INST_SYNC_READ;
742
+ txpacket[PKT_PARAMETER0] = startAddress;
743
+ txpacket[PKT_PARAMETER0 + 1] = dataLength;
744
+
745
+ // Add parameters
746
+ for (let i = 0; i < paramLength; i++) {
747
+ txpacket[PKT_PARAMETER0 + 2 + i] = param[i];
748
+ }
749
+
750
+ // Calculate checksum
751
+ const totalLen = txpacket[PKT_LENGTH] + 4; // 4: HEADER0 HEADER1 ID LENGTH
752
+
753
+ // Add headers
754
+ txpacket[PKT_HEADER0] = 0xff;
755
+ txpacket[PKT_HEADER1] = 0xff;
756
+
757
+ // Calculate checksum
758
+ let checksum = 0;
759
+ for (let i = 2; i < totalLen - 1; i++) {
760
+ checksum += txpacket[i] & 0xff;
761
+ }
762
+ txpacket[totalLen - 1] = ~checksum & 0xff;
763
+
764
+ console.log(
765
+ `SyncReadTx: ${txpacket.map((b) => "0x" + b.toString(16).padStart(2, "0")).join(" ")}`
766
+ );
767
+
768
+ // Send packet
769
+ await port.clearPort();
770
+ const bytesWritten = await port.writePort(txpacket);
771
+ if (bytesWritten !== totalLen) {
772
+ return COMM_TX_FAIL;
773
+ }
774
+
775
+ // Set timeout based on expected response size
776
+ port.setPacketTimeout((6 + dataLength) * paramLength);
777
+
778
+ return COMM_SUCCESS;
779
+ }
780
+
781
+ // Add syncWriteTxOnly for GroupSyncWrite functionality
782
+ async syncWriteTxOnly(port, startAddress, dataLength, param, paramLength) {
783
+ // Create packet: HEADER0 HEADER1 ID LEN INST START_ADDR DATA_LEN PARAM... CHKSUM
784
+ const txpacket = new Array(paramLength + 8).fill(0);
785
+
786
+ txpacket[PKT_ID] = BROADCAST_ID;
787
+ txpacket[PKT_LENGTH] = paramLength + 4; // 4: INST START_ADDR DATA_LEN CHKSUM
788
+ txpacket[PKT_INSTRUCTION] = INST_SYNC_WRITE;
789
+ txpacket[PKT_PARAMETER0] = startAddress;
790
+ txpacket[PKT_PARAMETER0 + 1] = dataLength;
791
+
792
+ // Add parameters
793
+ for (let i = 0; i < paramLength; i++) {
794
+ txpacket[PKT_PARAMETER0 + 2 + i] = param[i];
795
+ }
796
+
797
+ // Calculate checksum
798
+ const totalLen = txpacket[PKT_LENGTH] + 4; // 4: HEADER0 HEADER1 ID LENGTH
799
+
800
+ // Add headers
801
+ txpacket[PKT_HEADER0] = 0xff;
802
+ txpacket[PKT_HEADER1] = 0xff;
803
+
804
+ // Calculate checksum
805
+ let checksum = 0;
806
+ for (let i = 2; i < totalLen - 1; i++) {
807
+ checksum += txpacket[i] & 0xff;
808
+ }
809
+ txpacket[totalLen - 1] = ~checksum & 0xff;
810
+
811
+ console.log(
812
+ `SyncWriteTxOnly: ${txpacket.map((b) => "0x" + b.toString(16).padStart(2, "0")).join(" ")}`
813
+ );
814
+
815
+ // Send packet - for sync write, we don't need a response
816
+ await port.clearPort();
817
+ const bytesWritten = await port.writePort(txpacket);
818
+ if (bytesWritten !== totalLen) {
819
+ return COMM_TX_FAIL;
820
+ }
821
+
822
+ return COMM_SUCCESS;
823
+ }
824
+
825
+ // 辅助方法:格式化数据包结构以方便调试
826
+ formatPacketStructure(packet) {
827
+ if (!packet || packet.length < 4) {
828
+ return "Invalid packet (too short)";
829
+ }
830
+
831
+ try {
832
+ let result = "";
833
+ result += `HEADER: ${packet[0].toString(16).padStart(2, "0")} ${packet[1].toString(16).padStart(2, "0")} | `;
834
+ result += `ID: ${packet[2]} | `;
835
+ result += `LENGTH: ${packet[3]} | `;
836
+
837
+ if (packet.length >= 5) {
838
+ result += `ERROR/INST: ${packet[4].toString(16).padStart(2, "0")} | `;
839
+ }
840
+
841
+ if (packet.length >= 6) {
842
+ result += "PARAMS: ";
843
+ for (let i = 5; i < packet.length - 1; i++) {
844
+ result += `${packet[i].toString(16).padStart(2, "0")} `;
845
+ }
846
+ result += `| CHECKSUM: ${packet[packet.length - 1].toString(16).padStart(2, "0")}`;
847
+ }
848
+
849
+ return result;
850
+ } catch (e) {
851
+ return "Error formatting packet: " + e.message;
852
+ }
853
+ }
854
+
855
+ /**
856
+ * 从响应包中解析舵机型号
857
+ * @param {Array} rxpacket - 响应数据包
858
+ * @returns {number} 舵机型号
859
+ */
860
+ parseModelNumber(rxpacket) {
861
+ if (!rxpacket || rxpacket.length < 7) {
862
+ return 0;
863
+ }
864
+
865
+ // 检查是否有参数字段
866
+ if (rxpacket.length <= PKT_PARAMETER0 + 1) {
867
+ return 0;
868
+ }
869
+
870
+ const param1 = rxpacket[PKT_PARAMETER0];
871
+ const param2 = rxpacket[PKT_PARAMETER0 + 1];
872
+
873
+ if (SCS_END === 0) {
874
+ // STS/SMS 协议的字节顺序
875
+ return SCS_MAKEWORD(param1, param2);
876
+ } else {
877
+ // SCS 协议的字节顺序
878
+ return SCS_MAKEWORD(param2, param1);
879
+ }
880
+ }
881
+
882
+ /**
883
+ * Verify packet header
884
+ * @param {Array} packet - The packet to verify
885
+ * @returns {Number} COMM_SUCCESS if packet is valid, error code otherwise
886
+ */
887
+ getPacketHeader(packet) {
888
+ if (!packet || packet.length < 4) {
889
+ return COMM_RX_CORRUPT;
890
+ }
891
+
892
+ // Check header
893
+ if (packet[PKT_HEADER0] !== 0xff || packet[PKT_HEADER1] !== 0xff) {
894
+ return COMM_RX_CORRUPT;
895
+ }
896
+
897
+ // Check ID validity
898
+ if (packet[PKT_ID] > 0xfd) {
899
+ return COMM_RX_CORRUPT;
900
+ }
901
+
902
+ // Check length
903
+ if (packet.length != packet[PKT_LENGTH] + 4) {
904
+ return COMM_RX_CORRUPT;
905
+ }
906
+
907
+ // Calculate checksum
908
+ let checksum = 0;
909
+ for (let i = 2; i < packet.length - 1; i++) {
910
+ checksum += packet[i] & 0xff;
911
+ }
912
+ checksum = ~checksum & 0xff;
913
+
914
+ // Verify checksum
915
+ if (packet[packet.length - 1] !== checksum) {
916
+ return COMM_RX_CORRUPT;
917
+ }
918
+
919
+ return COMM_SUCCESS;
920
+ }
921
+ }
922
+
923
+ /**
924
+ * GroupSyncRead class
925
+ * - This class is used to read multiple servos with the same control table address at once
926
+ */
927
+ export class GroupSyncRead {
928
+ constructor(port, ph, startAddress, dataLength) {
929
+ this.port = port;
930
+ this.ph = ph;
931
+ this.startAddress = startAddress;
932
+ this.dataLength = dataLength;
933
+
934
+ this.isAvailableServiceID = new Set();
935
+ this.dataDict = new Map();
936
+ this.param = [];
937
+ this.clearParam();
938
+ }
939
+
940
+ makeParam() {
941
+ this.param = [];
942
+ for (const id of this.isAvailableServiceID) {
943
+ this.param.push(id);
944
+ }
945
+ return this.param.length;
946
+ }
947
+
948
+ addParam(scsId) {
949
+ if (this.isAvailableServiceID.has(scsId)) {
950
+ return false;
951
+ }
952
+
953
+ this.isAvailableServiceID.add(scsId);
954
+ this.dataDict.set(scsId, new Array(this.dataLength).fill(0));
955
+ return true;
956
+ }
957
+
958
+ removeParam(scsId) {
959
+ if (!this.isAvailableServiceID.has(scsId)) {
960
+ return false;
961
+ }
962
+
963
+ this.isAvailableServiceID.delete(scsId);
964
+ this.dataDict.delete(scsId);
965
+ return true;
966
+ }
967
+
968
+ clearParam() {
969
+ this.isAvailableServiceID.clear();
970
+ this.dataDict.clear();
971
+ return true;
972
+ }
973
+
974
+ async txPacket() {
975
+ if (this.isAvailableServiceID.size === 0) {
976
+ return COMM_NOT_AVAILABLE;
977
+ }
978
+
979
+ const paramLength = this.makeParam();
980
+ return await this.ph.syncReadTx(
981
+ this.port,
982
+ this.startAddress,
983
+ this.dataLength,
984
+ this.param,
985
+ paramLength
986
+ );
987
+ }
988
+
989
+ async rxPacket() {
990
+ let result = COMM_RX_FAIL;
991
+
992
+ if (this.isAvailableServiceID.size === 0) {
993
+ return COMM_NOT_AVAILABLE;
994
+ }
995
+
996
+ // Set all servos' data as invalid
997
+ for (const id of this.isAvailableServiceID) {
998
+ this.dataDict.set(id, new Array(this.dataLength).fill(0));
999
+ }
1000
+
1001
+ const [rxpacket, rxResult] = await this.ph.rxPacket(this.port);
1002
+ if (rxResult !== COMM_SUCCESS || !rxpacket || rxpacket.length === 0) {
1003
+ return rxResult;
1004
+ }
1005
+
1006
+ // More tolerant of packets with unexpected values in the PKT_ERROR field
1007
+ // Don't require INST_STATUS to be exactly 0x55
1008
+ console.log(
1009
+ `GroupSyncRead rxPacket: ID=${rxpacket[PKT_ID]}, ERROR/INST field=0x${rxpacket[PKT_ERROR].toString(16)}`
1010
+ );
1011
+
1012
+ // Check if the packet matches any of the available IDs
1013
+ if (!this.isAvailableServiceID.has(rxpacket[PKT_ID])) {
1014
+ console.log(
1015
+ `Received packet with ID ${rxpacket[PKT_ID]} which is not in the available IDs list`
1016
+ );
1017
+ return COMM_RX_CORRUPT;
1018
+ }
1019
+
1020
+ // Extract data for the matching ID
1021
+ const scsId = rxpacket[PKT_ID];
1022
+ const data = new Array(this.dataLength).fill(0);
1023
+
1024
+ // Extract the parameter data, which should start at PKT_PARAMETER0
1025
+ if (rxpacket.length < PKT_PARAMETER0 + this.dataLength) {
1026
+ console.log(
1027
+ `Packet too short: expected ${PKT_PARAMETER0 + this.dataLength} bytes, got ${rxpacket.length}`
1028
+ );
1029
+ return COMM_RX_CORRUPT;
1030
+ }
1031
+
1032
+ for (let i = 0; i < this.dataLength; i++) {
1033
+ data[i] = rxpacket[PKT_PARAMETER0 + i];
1034
+ }
1035
+
1036
+ // Update the data dict
1037
+ this.dataDict.set(scsId, data);
1038
+ console.log(
1039
+ `Updated data for servo ID ${scsId}: ${data.map((b) => "0x" + b.toString(16).padStart(2, "0")).join(" ")}`
1040
+ );
1041
+
1042
+ // Continue receiving until timeout or all data is received
1043
+ if (this.isAvailableServiceID.size > 1) {
1044
+ result = await this.rxPacket();
1045
+ } else {
1046
+ result = COMM_SUCCESS;
1047
+ }
1048
+
1049
+ return result;
1050
+ }
1051
+
1052
+ async txRxPacket() {
1053
+ try {
1054
+ // First check if port is being used
1055
+ if (this.port.isUsing) {
1056
+ console.log("Port is busy, cannot start sync read operation");
1057
+ return COMM_PORT_BUSY;
1058
+ }
1059
+
1060
+ // Start the transmission
1061
+ console.log("Starting sync read TX/RX operation...");
1062
+ let result = await this.txPacket();
1063
+ if (result !== COMM_SUCCESS) {
1064
+ console.log(`Sync read TX failed with result: ${result}`);
1065
+ return result;
1066
+ }
1067
+
1068
+ // Get a single response with a standard timeout
1069
+ console.log(`Attempting to receive a response...`);
1070
+
1071
+ // Receive a single response
1072
+ result = await this.rxPacket();
1073
+
1074
+ // Release port
1075
+ this.port.isUsing = false;
1076
+
1077
+ return result;
1078
+ } catch (error) {
1079
+ console.error("Exception in GroupSyncRead txRxPacket:", error);
1080
+ // Make sure port is released
1081
+ this.port.isUsing = false;
1082
+ return COMM_RX_FAIL;
1083
+ }
1084
+ }
1085
+
1086
+ isAvailable(scsId, address, dataLength) {
1087
+ if (!this.isAvailableServiceID.has(scsId)) {
1088
+ return false;
1089
+ }
1090
+
1091
+ const startAddr = this.startAddress;
1092
+ const endAddr = startAddr + this.dataLength - 1;
1093
+
1094
+ const reqStartAddr = address;
1095
+ const reqEndAddr = reqStartAddr + dataLength - 1;
1096
+
1097
+ if (reqStartAddr < startAddr || reqEndAddr > endAddr) {
1098
+ return false;
1099
+ }
1100
+
1101
+ const data = this.dataDict.get(scsId);
1102
+ if (!data || data.length === 0) {
1103
+ return false;
1104
+ }
1105
+
1106
+ return true;
1107
+ }
1108
+
1109
+ getData(scsId, address, dataLength) {
1110
+ if (!this.isAvailable(scsId, address, dataLength)) {
1111
+ return 0;
1112
+ }
1113
+
1114
+ const startAddr = this.startAddress;
1115
+ const data = this.dataDict.get(scsId);
1116
+
1117
+ // Calculate data offset
1118
+ const dataOffset = address - startAddr;
1119
+
1120
+ // Combine bytes according to dataLength
1121
+ switch (dataLength) {
1122
+ case 1:
1123
+ return data[dataOffset];
1124
+ case 2:
1125
+ return SCS_MAKEWORD(data[dataOffset], data[dataOffset + 1]);
1126
+ case 4:
1127
+ return SCS_MAKEDWORD(
1128
+ SCS_MAKEWORD(data[dataOffset], data[dataOffset + 1]),
1129
+ SCS_MAKEWORD(data[dataOffset + 2], data[dataOffset + 3])
1130
+ );
1131
+ default:
1132
+ return 0;
1133
+ }
1134
+ }
1135
+ }
1136
+
1137
+ /**
1138
+ * GroupSyncWrite class
1139
+ * - This class is used to write multiple servos with the same control table address at once
1140
+ */
1141
+ export class GroupSyncWrite {
1142
+ constructor(port, ph, startAddress, dataLength) {
1143
+ this.port = port;
1144
+ this.ph = ph;
1145
+ this.startAddress = startAddress;
1146
+ this.dataLength = dataLength;
1147
+
1148
+ this.isAvailableServiceID = new Set();
1149
+ this.dataDict = new Map();
1150
+ this.param = [];
1151
+ this.clearParam();
1152
+ }
1153
+
1154
+ makeParam() {
1155
+ this.param = [];
1156
+ for (const id of this.isAvailableServiceID) {
1157
+ // Add ID to parameter
1158
+ this.param.push(id);
1159
+
1160
+ // Add data to parameter
1161
+ const data = this.dataDict.get(id);
1162
+ for (let i = 0; i < this.dataLength; i++) {
1163
+ this.param.push(data[i]);
1164
+ }
1165
+ }
1166
+ return this.param.length;
1167
+ }
1168
+
1169
+ addParam(scsId, data) {
1170
+ if (this.isAvailableServiceID.has(scsId)) {
1171
+ return false;
1172
+ }
1173
+
1174
+ if (data.length !== this.dataLength) {
1175
+ console.error(
1176
+ `Data length (${data.length}) doesn't match required length (${this.dataLength})`
1177
+ );
1178
+ return false;
1179
+ }
1180
+
1181
+ this.isAvailableServiceID.add(scsId);
1182
+ this.dataDict.set(scsId, data);
1183
+ return true;
1184
+ }
1185
+
1186
+ removeParam(scsId) {
1187
+ if (!this.isAvailableServiceID.has(scsId)) {
1188
+ return false;
1189
+ }
1190
+
1191
+ this.isAvailableServiceID.delete(scsId);
1192
+ this.dataDict.delete(scsId);
1193
+ return true;
1194
+ }
1195
+
1196
+ changeParam(scsId, data) {
1197
+ if (!this.isAvailableServiceID.has(scsId)) {
1198
+ return false;
1199
+ }
1200
+
1201
+ if (data.length !== this.dataLength) {
1202
+ console.error(
1203
+ `Data length (${data.length}) doesn't match required length (${this.dataLength})`
1204
+ );
1205
+ return false;
1206
+ }
1207
+
1208
+ this.dataDict.set(scsId, data);
1209
+ return true;
1210
+ }
1211
+
1212
+ clearParam() {
1213
+ this.isAvailableServiceID.clear();
1214
+ this.dataDict.clear();
1215
+ return true;
1216
+ }
1217
+
1218
+ async txPacket() {
1219
+ if (this.isAvailableServiceID.size === 0) {
1220
+ return COMM_NOT_AVAILABLE;
1221
+ }
1222
+
1223
+ const paramLength = this.makeParam();
1224
+ return await this.ph.syncWriteTxOnly(
1225
+ this.port,
1226
+ this.startAddress,
1227
+ this.dataLength,
1228
+ this.param,
1229
+ paramLength
1230
+ );
1231
+ }
1232
+ }
packages/feetech.js/package.json ADDED
@@ -0,0 +1,38 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "feetech.js",
3
+ "version": "0.0.8",
4
+ "description": "javascript sdk for feetech servos",
5
+ "main": "index.mjs",
6
+ "files": [
7
+ "*.mjs",
8
+ "*.ts"
9
+ ],
10
+ "type": "module",
11
+ "engines": {
12
+ "node": ">=12.17.0"
13
+ },
14
+ "scripts": {
15
+ "test": "echo \"Error: no test specified\" && exit 1"
16
+ },
17
+ "repository": {
18
+ "type": "git",
19
+ "url": "git+https://github.com/timqian/bambot/tree/main/feetech.js"
20
+ },
21
+ "keywords": [
22
+ "feetech",
23
+ "sdk",
24
+ "js",
25
+ "javascript",
26
+ "sts3215",
27
+ "3215",
28
+ "scs",
29
+ "scs3215",
30
+ "st3215"
31
+ ],
32
+ "author": "timqian",
33
+ "license": "MIT",
34
+ "bugs": {
35
+ "url": "https://github.com/timqian/bambot/issues"
36
+ },
37
+ "homepage": "https://github.com/timqian/bambot/tree/main/feetech.js"
38
+ }
packages/feetech.js/scsServoSDK.mjs ADDED
@@ -0,0 +1,910 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import {
2
+ PortHandler,
3
+ PacketHandler,
4
+ COMM_SUCCESS,
5
+ COMM_RX_TIMEOUT,
6
+ COMM_RX_CORRUPT,
7
+ COMM_TX_FAIL,
8
+ COMM_NOT_AVAILABLE,
9
+ SCS_LOBYTE,
10
+ SCS_HIBYTE,
11
+ SCS_MAKEWORD,
12
+ GroupSyncRead, // Import GroupSyncRead
13
+ GroupSyncWrite // Import GroupSyncWrite
14
+ } from "./lowLevelSDK.mjs";
15
+
16
+ // Import address constants from the correct file
17
+ import {
18
+ ADDR_SCS_PRESENT_POSITION,
19
+ ADDR_SCS_GOAL_POSITION,
20
+ ADDR_SCS_TORQUE_ENABLE,
21
+ ADDR_SCS_GOAL_ACC,
22
+ ADDR_SCS_GOAL_SPEED
23
+ } from "./scsservo_constants.mjs";
24
+
25
+ // Define constants not present in scsservo_constants.mjs
26
+ const ADDR_SCS_MODE = 33;
27
+ const ADDR_SCS_LOCK = 55;
28
+ const ADDR_SCS_ID = 5; // Address for Servo ID
29
+ const ADDR_SCS_BAUD_RATE = 6; // Address for Baud Rate
30
+
31
+ // Module-level variables for handlers
32
+ let portHandler = null;
33
+ let packetHandler = null;
34
+
35
+ /**
36
+ * Connects to the serial port and initializes handlers.
37
+ * @param {object} [options] - Connection options.
38
+ * @param {number} [options.baudRate=1000000] - The baud rate for the serial connection.
39
+ * @param {number} [options.protocolEnd=0] - The protocol end setting (0 for STS/SMS, 1 for SCS).
40
+ * @returns {Promise<true>} Resolves with true on successful connection.
41
+ * @throws {Error} If connection fails or port cannot be opened/selected.
42
+ */
43
+ export async function connect(options = {}) {
44
+ if (portHandler && portHandler.isOpen) {
45
+ console.log("Already connected.");
46
+ return true;
47
+ }
48
+
49
+ const { baudRate = 1000000, protocolEnd = 0 } = options;
50
+
51
+ try {
52
+ portHandler = new PortHandler();
53
+ const portRequested = await portHandler.requestPort();
54
+ if (!portRequested) {
55
+ portHandler = null;
56
+ throw new Error("Failed to select a serial port.");
57
+ }
58
+
59
+ portHandler.setBaudRate(baudRate);
60
+ const portOpened = await portHandler.openPort();
61
+ if (!portOpened) {
62
+ await portHandler.closePort().catch(console.error); // Attempt cleanup
63
+ portHandler = null;
64
+ throw new Error(`Failed to open port at baudrate ${baudRate}.`);
65
+ }
66
+
67
+ packetHandler = new PacketHandler(protocolEnd);
68
+ console.log(`Connected to serial port at ${baudRate} baud, protocol end: ${protocolEnd}.`);
69
+ return true;
70
+ } catch (err) {
71
+ console.error("Error during connection:", err);
72
+ if (portHandler) {
73
+ try {
74
+ await portHandler.closePort();
75
+ } catch (closeErr) {
76
+ console.error("Error closing port after connection failure:", closeErr);
77
+ }
78
+ }
79
+ portHandler = null;
80
+ packetHandler = null;
81
+ // Re-throw the original or a new error
82
+ throw new Error(`Connection failed: ${err.message}`);
83
+ }
84
+ }
85
+
86
+ /**
87
+ * Disconnects from the serial port.
88
+ * @returns {Promise<true>} Resolves with true on successful disconnection.
89
+ * @throws {Error} If disconnection fails.
90
+ */
91
+ export async function disconnect() {
92
+ if (!portHandler || !portHandler.isOpen) {
93
+ console.log("Already disconnected.");
94
+ return true;
95
+ }
96
+
97
+ try {
98
+ await portHandler.closePort();
99
+ portHandler = null;
100
+ packetHandler = null;
101
+ console.log("Disconnected from serial port.");
102
+ return true;
103
+ } catch (err) {
104
+ console.error("Error during disconnection:", err);
105
+ // Attempt to nullify handlers even if close fails
106
+ portHandler = null;
107
+ packetHandler = null;
108
+ throw new Error(`Disconnection failed: ${err.message}`);
109
+ }
110
+ }
111
+
112
+ /**
113
+ * Checks if the SDK is connected. Throws an error if not.
114
+ * @throws {Error} If not connected.
115
+ */
116
+ function checkConnection() {
117
+ if (!portHandler || !packetHandler) {
118
+ throw new Error("Not connected. Call connect() first.");
119
+ }
120
+ }
121
+
122
+ /**
123
+ * Reads the current position of a servo.
124
+ * @param {number} servoId - The ID of the servo (1-252).
125
+ * @returns {Promise<number>} Resolves with the position (0-4095).
126
+ * @throws {Error} If not connected, read fails, or an exception occurs.
127
+ */
128
+ export async function readPosition(servoId) {
129
+ checkConnection();
130
+ try {
131
+ const [position, result, error] = await packetHandler.read2ByteTxRx(
132
+ portHandler,
133
+ servoId,
134
+ ADDR_SCS_PRESENT_POSITION
135
+ );
136
+
137
+ if (result !== COMM_SUCCESS) {
138
+ throw new Error(
139
+ `Error reading position from servo ${servoId}: ${packetHandler.getTxRxResult(
140
+ result
141
+ )}, Error code: ${error}`
142
+ );
143
+ }
144
+ return position & 0xffff; // Ensure it's within 16 bits
145
+ } catch (err) {
146
+ console.error(`Exception reading position from servo ${servoId}:`, err);
147
+ throw new Error(`Exception reading position from servo ${servoId}: ${err.message}`);
148
+ }
149
+ }
150
+
151
+ /**
152
+ * Reads the current baud rate index of a servo.
153
+ * @param {number} servoId - The ID of the servo (1-252).
154
+ * @returns {Promise<number>} Resolves with the baud rate index (0-7).
155
+ * @throws {Error} If not connected, read fails, or an exception occurs.
156
+ */
157
+ export async function readBaudRate(servoId) {
158
+ checkConnection();
159
+ try {
160
+ const [baudIndex, result, error] = await packetHandler.read1ByteTxRx(
161
+ portHandler,
162
+ servoId,
163
+ ADDR_SCS_BAUD_RATE
164
+ );
165
+
166
+ if (result !== COMM_SUCCESS) {
167
+ throw new Error(
168
+ `Error reading baud rate from servo ${servoId}: ${packetHandler.getTxRxResult(
169
+ result
170
+ )}, Error code: ${error}`
171
+ );
172
+ }
173
+ return baudIndex;
174
+ } catch (err) {
175
+ console.error(`Exception reading baud rate from servo ${servoId}:`, err);
176
+ throw new Error(`Exception reading baud rate from servo ${servoId}: ${err.message}`);
177
+ }
178
+ }
179
+
180
+ /**
181
+ * Reads the current operating mode of a servo.
182
+ * @param {number} servoId - The ID of the servo (1-252).
183
+ * @returns {Promise<number>} Resolves with the mode (0 for position, 1 for wheel).
184
+ * @throws {Error} If not connected, read fails, or an exception occurs.
185
+ */
186
+ export async function readMode(servoId) {
187
+ checkConnection();
188
+ try {
189
+ const [modeValue, result, error] = await packetHandler.read1ByteTxRx(
190
+ portHandler,
191
+ servoId,
192
+ ADDR_SCS_MODE
193
+ );
194
+
195
+ if (result !== COMM_SUCCESS) {
196
+ throw new Error(
197
+ `Error reading mode from servo ${servoId}: ${packetHandler.getTxRxResult(
198
+ result
199
+ )}, Error code: ${error}`
200
+ );
201
+ }
202
+ return modeValue;
203
+ } catch (err) {
204
+ console.error(`Exception reading mode from servo ${servoId}:`, err);
205
+ throw new Error(`Exception reading mode from servo ${servoId}: ${err.message}`);
206
+ }
207
+ }
208
+
209
+ /**
210
+ * Writes a target position to a servo.
211
+ * @param {number} servoId - The ID of the servo (1-252).
212
+ * @param {number} position - The target position value (0-4095).
213
+ * @returns {Promise<"success">} Resolves with "success".
214
+ * @throws {Error} If not connected, position is out of range, write fails, or an exception occurs.
215
+ */
216
+ export async function writePosition(servoId, position) {
217
+ checkConnection();
218
+ try {
219
+ // Validate position range
220
+ if (position < 0 || position > 4095) {
221
+ throw new Error(
222
+ `Invalid position value ${position} for servo ${servoId}. Must be between 0 and 4095.`
223
+ );
224
+ }
225
+ const targetPosition = Math.round(position); // Ensure integer value
226
+
227
+ const [result, error] = await packetHandler.write2ByteTxRx(
228
+ portHandler,
229
+ servoId,
230
+ ADDR_SCS_GOAL_POSITION,
231
+ targetPosition
232
+ );
233
+
234
+ if (result !== COMM_SUCCESS) {
235
+ throw new Error(
236
+ `Error writing position to servo ${servoId}: ${packetHandler.getTxRxResult(
237
+ result
238
+ )}, Error code: ${error}`
239
+ );
240
+ }
241
+ return "success";
242
+ } catch (err) {
243
+ console.error(`Exception writing position to servo ${servoId}:`, err);
244
+ // Re-throw the original error or a new one wrapping it
245
+ throw new Error(`Failed to write position to servo ${servoId}: ${err.message}`);
246
+ }
247
+ }
248
+
249
+ /**
250
+ * Enables or disables the torque of a servo.
251
+ * @param {number} servoId - The ID of the servo (1-252).
252
+ * @param {boolean} enable - True to enable torque, false to disable.
253
+ * @returns {Promise<"success">} Resolves with "success".
254
+ * @throws {Error} If not connected, write fails, or an exception occurs.
255
+ */
256
+ export async function writeTorqueEnable(servoId, enable) {
257
+ checkConnection();
258
+ try {
259
+ const enableValue = enable ? 1 : 0;
260
+ const [result, error] = await packetHandler.write1ByteTxRx(
261
+ portHandler,
262
+ servoId,
263
+ ADDR_SCS_TORQUE_ENABLE,
264
+ enableValue
265
+ );
266
+
267
+ if (result !== COMM_SUCCESS) {
268
+ throw new Error(
269
+ `Error setting torque for servo ${servoId}: ${packetHandler.getTxRxResult(
270
+ result
271
+ )}, Error code: ${error}`
272
+ );
273
+ }
274
+ return "success";
275
+ } catch (err) {
276
+ console.error(`Exception setting torque for servo ${servoId}:`, err);
277
+ throw new Error(`Exception setting torque for servo ${servoId}: ${err.message}`);
278
+ }
279
+ }
280
+
281
+ /**
282
+ * Sets the acceleration profile for a servo's movement.
283
+ * @param {number} servoId - The ID of the servo (1-252).
284
+ * @param {number} acceleration - The acceleration value (0-254).
285
+ * @returns {Promise<"success">} Resolves with "success".
286
+ * @throws {Error} If not connected, write fails, or an exception occurs.
287
+ */
288
+ export async function writeAcceleration(servoId, acceleration) {
289
+ checkConnection();
290
+ try {
291
+ const clampedAcceleration = Math.max(0, Math.min(254, Math.round(acceleration)));
292
+ const [result, error] = await packetHandler.write1ByteTxRx(
293
+ portHandler,
294
+ servoId,
295
+ ADDR_SCS_GOAL_ACC,
296
+ clampedAcceleration
297
+ );
298
+
299
+ if (result !== COMM_SUCCESS) {
300
+ throw new Error(
301
+ `Error writing acceleration to servo ${servoId}: ${packetHandler.getTxRxResult(
302
+ result
303
+ )}, Error code: ${error}`
304
+ );
305
+ }
306
+ return "success";
307
+ } catch (err) {
308
+ console.error(`Exception writing acceleration to servo ${servoId}:`, err);
309
+ throw new Error(`Exception writing acceleration to servo ${servoId}: ${err.message}`);
310
+ }
311
+ }
312
+
313
+ /**
314
+ * Helper to attempt locking a servo, logging errors without throwing.
315
+ * @param {number} servoId
316
+ */
317
+ async function tryLockServo(servoId) {
318
+ try {
319
+ await packetHandler.write1ByteTxRx(portHandler, servoId, ADDR_SCS_LOCK, 1);
320
+ } catch (lockErr) {
321
+ console.error(`Failed to re-lock servo ${servoId}:`, lockErr);
322
+ }
323
+ }
324
+
325
+ /**
326
+ * Sets a servo to wheel mode (continuous rotation).
327
+ * Requires unlocking, setting mode, and locking the configuration.
328
+ * @param {number} servoId - The ID of the servo (1-252).
329
+ * @returns {Promise<"success">} Resolves with "success".
330
+ * @throws {Error} If not connected, any step fails, or an exception occurs.
331
+ */
332
+ export async function setWheelMode(servoId) {
333
+ checkConnection();
334
+ let unlocked = false;
335
+ try {
336
+ console.log(`Setting servo ${servoId} to wheel mode...`);
337
+
338
+ // 1. Unlock servo configuration
339
+ const [resUnlock, errUnlock] = await packetHandler.write1ByteTxRx(
340
+ portHandler,
341
+ servoId,
342
+ ADDR_SCS_LOCK,
343
+ 0
344
+ );
345
+ if (resUnlock !== COMM_SUCCESS) {
346
+ throw new Error(
347
+ `Failed to unlock servo ${servoId}: ${packetHandler.getTxRxResult(
348
+ resUnlock
349
+ )}, Error: ${errUnlock}`
350
+ );
351
+ }
352
+ unlocked = true;
353
+
354
+ // 2. Set mode to 1 (Wheel/Speed mode)
355
+ const [resMode, errMode] = await packetHandler.write1ByteTxRx(
356
+ portHandler,
357
+ servoId,
358
+ ADDR_SCS_MODE,
359
+ 1
360
+ );
361
+ if (resMode !== COMM_SUCCESS) {
362
+ throw new Error(
363
+ `Failed to set wheel mode for servo ${servoId}: ${packetHandler.getTxRxResult(
364
+ resMode
365
+ )}, Error: ${errMode}`
366
+ );
367
+ }
368
+
369
+ // 3. Lock servo configuration
370
+ const [resLock, errLock] = await packetHandler.write1ByteTxRx(
371
+ portHandler,
372
+ servoId,
373
+ ADDR_SCS_LOCK,
374
+ 1
375
+ );
376
+ if (resLock !== COMM_SUCCESS) {
377
+ // Mode was set, but lock failed. Still an error state.
378
+ throw new Error(
379
+ `Failed to lock servo ${servoId} after setting mode: ${packetHandler.getTxRxResult(
380
+ resLock
381
+ )}, Error: ${errLock}`
382
+ );
383
+ }
384
+ unlocked = false; // Successfully locked
385
+
386
+ console.log(`Successfully set servo ${servoId} to wheel mode.`);
387
+ return "success";
388
+ } catch (err) {
389
+ console.error(`Exception setting wheel mode for servo ${servoId}:`, err);
390
+ if (unlocked) {
391
+ // Attempt to re-lock if an error occurred after unlocking
392
+ await tryLockServo(servoId);
393
+ }
394
+ // Re-throw the original error or a new one wrapping it
395
+ throw new Error(`Failed to set wheel mode for servo ${servoId}: ${err.message}`);
396
+ }
397
+ }
398
+
399
+ /**
400
+ * Sets a servo back to position control mode from wheel mode.
401
+ * Requires unlocking, setting mode, and locking the configuration.
402
+ * @param {number} servoId - The ID of the servo (1-252).
403
+ * @returns {Promise<"success">} Resolves with "success".
404
+ * @throws {Error} If not connected, any step fails, or an exception occurs.
405
+ */
406
+ export async function setPositionMode(servoId) {
407
+ checkConnection();
408
+ let unlocked = false;
409
+ try {
410
+ console.log(`Setting servo ${servoId} back to position mode...`);
411
+
412
+ // 1. Unlock servo configuration
413
+ const [resUnlock, errUnlock] = await packetHandler.write1ByteTxRx(
414
+ portHandler,
415
+ servoId,
416
+ ADDR_SCS_LOCK,
417
+ 0
418
+ );
419
+ if (resUnlock !== COMM_SUCCESS) {
420
+ throw new Error(
421
+ `Failed to unlock servo ${servoId}: ${packetHandler.getTxRxResult(
422
+ resUnlock
423
+ )}, Error: ${errUnlock}`
424
+ );
425
+ }
426
+ unlocked = true;
427
+
428
+ // 2. Set mode to 0 (Position/Servo mode)
429
+ const [resMode, errMode] = await packetHandler.write1ByteTxRx(
430
+ portHandler,
431
+ servoId,
432
+ ADDR_SCS_MODE,
433
+ 0 // 0 for position mode
434
+ );
435
+ if (resMode !== COMM_SUCCESS) {
436
+ throw new Error(
437
+ `Failed to set position mode for servo ${servoId}: ${packetHandler.getTxRxResult(
438
+ resMode
439
+ )}, Error: ${errMode}`
440
+ );
441
+ }
442
+
443
+ // 3. Lock servo configuration
444
+ const [resLock, errLock] = await packetHandler.write1ByteTxRx(
445
+ portHandler,
446
+ servoId,
447
+ ADDR_SCS_LOCK,
448
+ 1
449
+ );
450
+ if (resLock !== COMM_SUCCESS) {
451
+ throw new Error(
452
+ `Failed to lock servo ${servoId} after setting mode: ${packetHandler.getTxRxResult(
453
+ resLock
454
+ )}, Error: ${errLock}`
455
+ );
456
+ }
457
+ unlocked = false; // Successfully locked
458
+
459
+ console.log(`Successfully set servo ${servoId} back to position mode.`);
460
+ return "success";
461
+ } catch (err) {
462
+ console.error(`Exception setting position mode for servo ${servoId}:`, err);
463
+ if (unlocked) {
464
+ // Attempt to re-lock if an error occurred after unlocking
465
+ await tryLockServo(servoId);
466
+ }
467
+ throw new Error(`Failed to set position mode for servo ${servoId}: ${err.message}`);
468
+ }
469
+ }
470
+
471
+ /**
472
+ * Writes a target speed for a servo in wheel mode.
473
+ * @param {number} servoId - The ID of the servo
474
+ * @param {number} speed - The target speed value (-10000 to 10000). Negative values indicate reverse direction. 0 stops the wheel.
475
+ * @returns {Promise<"success">} Resolves with "success".
476
+ * @throws {Error} If not connected, either write fails, or an exception occurs.
477
+ */
478
+ export async function writeWheelSpeed(servoId, speed) {
479
+ checkConnection();
480
+ try {
481
+ // Validate and clamp the speed to the new range
482
+ const clampedSpeed = Math.max(-10000, Math.min(10000, Math.round(speed)));
483
+ let speedValue = Math.abs(clampedSpeed) & 0x7fff; // Get absolute value, ensure within 15 bits
484
+
485
+ // Set the direction bit (MSB of the 16-bit value) if speed is negative
486
+ if (clampedSpeed < 0) {
487
+ speedValue |= 0x8000; // Set the 16th bit for reverse direction
488
+ }
489
+
490
+ // Use write2ByteTxRx to write the 16-bit speed value
491
+ const [result, error] = await packetHandler.write2ByteTxRx(
492
+ portHandler,
493
+ servoId,
494
+ ADDR_SCS_GOAL_SPEED, // Starting address for the 2-byte speed value
495
+ speedValue
496
+ );
497
+
498
+ if (result !== COMM_SUCCESS) {
499
+ throw new Error(
500
+ `Error writing wheel speed to servo ${servoId}: ${packetHandler.getTxRxResult(
501
+ result
502
+ )}, Error: ${error}`
503
+ );
504
+ }
505
+
506
+ return "success";
507
+ } catch (err) {
508
+ console.error(`Exception writing wheel speed to servo ${servoId}:`, err);
509
+ throw new Error(`Exception writing wheel speed to servo ${servoId}: ${err.message}`);
510
+ }
511
+ }
512
+
513
+ /**
514
+ * Writes target speeds to multiple servos in wheel mode synchronously.
515
+ * @param {Map<number, number> | object} servoSpeeds - A Map or object where keys are servo IDs (1-252) and values are target speeds (-10000 to 10000).
516
+ * @returns {Promise<"success">} Resolves with "success".
517
+ * @throws {Error} If not connected, any speed is out of range, transmission fails, or an exception occurs.
518
+ */
519
+ export async function syncWriteWheelSpeed(servoSpeeds) {
520
+ checkConnection();
521
+
522
+ const groupSyncWrite = new GroupSyncWrite(
523
+ portHandler,
524
+ packetHandler,
525
+ ADDR_SCS_GOAL_SPEED,
526
+ 2 // Data length for speed (2 bytes)
527
+ );
528
+ let paramAdded = false;
529
+
530
+ const entries = servoSpeeds instanceof Map ? servoSpeeds.entries() : Object.entries(servoSpeeds);
531
+
532
+ // Second pass: Add valid parameters
533
+ for (const [idStr, speed] of entries) {
534
+ const servoId = parseInt(idStr, 10); // Already validated
535
+
536
+ if (isNaN(servoId) || servoId < 1 || servoId > 252) {
537
+ throw new Error(`Invalid servo ID "${idStr}" in syncWriteWheelSpeed.`);
538
+ }
539
+ if (speed < -10000 || speed > 10000) {
540
+ throw new Error(
541
+ `Invalid speed value ${speed} for servo ${servoId} in syncWriteWheelSpeed. Must be between -10000 and 10000.`
542
+ );
543
+ }
544
+
545
+ const clampedSpeed = Math.max(-10000, Math.min(10000, Math.round(speed))); // Ensure integer, already validated range
546
+ let speedValue = Math.abs(clampedSpeed) & 0x7fff; // Get absolute value, ensure within 15 bits
547
+
548
+ // Set the direction bit (MSB of the 16-bit value) if speed is negative
549
+ if (clampedSpeed < 0) {
550
+ speedValue |= 0x8000; // Set the 16th bit for reverse direction
551
+ }
552
+
553
+ const data = [SCS_LOBYTE(speedValue), SCS_HIBYTE(speedValue)];
554
+
555
+ if (groupSyncWrite.addParam(servoId, data)) {
556
+ paramAdded = true;
557
+ } else {
558
+ // This should ideally not happen if IDs are unique, but handle defensively
559
+ console.warn(
560
+ `Failed to add servo ${servoId} to sync write speed group (possibly duplicate).`
561
+ );
562
+ }
563
+ }
564
+
565
+ if (!paramAdded) {
566
+ console.log("Sync Write Speed: No valid servo speeds provided or added.");
567
+ return "success"; // Nothing to write is considered success
568
+ }
569
+
570
+ try {
571
+ // Send the Sync Write instruction
572
+ const result = await groupSyncWrite.txPacket();
573
+ if (result !== COMM_SUCCESS) {
574
+ throw new Error(`Sync Write Speed txPacket failed: ${packetHandler.getTxRxResult(result)}`);
575
+ }
576
+ return "success";
577
+ } catch (err) {
578
+ console.error("Exception during syncWriteWheelSpeed:", err);
579
+ // Re-throw the original error or a new one wrapping it
580
+ throw new Error(`Sync Write Speed failed: ${err.message}`);
581
+ }
582
+ }
583
+
584
+ /**
585
+ * Reads the current position of multiple servos synchronously.
586
+ * @param {number[]} servoIds - An array of servo IDs (1-252) to read from.
587
+ * @returns {Promise<Map<number, number>>} Resolves with a Map where keys are servo IDs and values are positions (0-4095).
588
+ * @throws {Error} If not connected, transmission fails, reception fails, or data for any requested servo is unavailable.
589
+ */
590
+ export async function syncReadPositions(servoIds) {
591
+ checkConnection();
592
+ if (!Array.isArray(servoIds) || servoIds.length === 0) {
593
+ console.log("Sync Read: No servo IDs provided.");
594
+ return new Map(); // Return empty map for empty input
595
+ }
596
+
597
+ const startAddress = ADDR_SCS_PRESENT_POSITION;
598
+ const dataLength = 2;
599
+ const groupSyncRead = new GroupSyncRead(portHandler, packetHandler, startAddress, dataLength);
600
+ const positions = new Map();
601
+ const validIds = [];
602
+
603
+ // 1. Add parameters for each valid servo ID
604
+ servoIds.forEach((id) => {
605
+ if (id >= 1 && id <= 252) {
606
+ if (groupSyncRead.addParam(id)) {
607
+ validIds.push(id);
608
+ } else {
609
+ console.warn(
610
+ `Sync Read: Failed to add param for servo ID ${id} (maybe duplicate or invalid).`
611
+ );
612
+ }
613
+ } else {
614
+ console.warn(`Sync Read: Invalid servo ID ${id} skipped.`);
615
+ }
616
+ });
617
+
618
+ if (validIds.length === 0) {
619
+ console.log("Sync Read: No valid servo IDs to read.");
620
+ return new Map(); // Return empty map if no valid IDs
621
+ }
622
+
623
+ try {
624
+ // 2. Send the Sync Read instruction packet
625
+ let txResult = await groupSyncRead.txPacket();
626
+ if (txResult !== COMM_SUCCESS) {
627
+ throw new Error(`Sync Read txPacket failed: ${packetHandler.getTxRxResult(txResult)}`);
628
+ }
629
+
630
+ // 3. Receive the response packets
631
+ let rxResult = await groupSyncRead.rxPacket();
632
+ // Even if rxPacket reports an overall issue (like timeout), we still check individual servos.
633
+ // A specific error will be thrown later if any servo data is missing.
634
+ if (rxResult !== COMM_SUCCESS) {
635
+ console.warn(
636
+ `Sync Read rxPacket overall result: ${packetHandler.getTxRxResult(
637
+ rxResult
638
+ )}. Checking individual servos.`
639
+ );
640
+ }
641
+
642
+ // 4. Check data availability and retrieve data for each servo
643
+ const failedIds = [];
644
+ validIds.forEach((id) => {
645
+ const isAvailable = groupSyncRead.isAvailable(id, startAddress, dataLength);
646
+ if (isAvailable) {
647
+ const position = groupSyncRead.getData(id, startAddress, dataLength);
648
+ positions.set(id, position & 0xffff);
649
+ } else {
650
+ failedIds.push(id);
651
+ }
652
+ });
653
+
654
+ // 5. Check if all requested servos responded
655
+ if (failedIds.length > 0) {
656
+ throw new Error(
657
+ `Sync Read failed: Data not available for servo IDs: ${failedIds.join(
658
+ ", "
659
+ )}. Overall RX result: ${packetHandler.getTxRxResult(rxResult)}`
660
+ );
661
+ }
662
+
663
+ return positions;
664
+ } catch (err) {
665
+ console.error("Exception or failure during syncReadPositions:", err);
666
+ // Re-throw the caught error or a new one wrapping it
667
+ throw new Error(`Sync Read failed: ${err.message}`);
668
+ }
669
+ }
670
+
671
+ /**
672
+ * Writes target positions to multiple servos synchronously.
673
+ * @param {Map<number, number> | object} servoPositions - A Map or object where keys are servo IDs (1-252) and values are target positions (0-4095).
674
+ * @returns {Promise<"success">} Resolves with "success".
675
+ * @throws {Error} If not connected, any position is out of range, transmission fails, or an exception occurs.
676
+ */
677
+ export async function syncWritePositions(servoPositions) {
678
+ checkConnection();
679
+
680
+ const groupSyncWrite = new GroupSyncWrite(
681
+ portHandler,
682
+ packetHandler,
683
+ ADDR_SCS_GOAL_POSITION,
684
+ 2 // Data length for position
685
+ );
686
+ let paramAdded = false;
687
+
688
+ const entries =
689
+ servoPositions instanceof Map ? servoPositions.entries() : Object.entries(servoPositions);
690
+
691
+ // Second pass: Add valid parameters
692
+ for (const [idStr, position] of entries) {
693
+ const servoId = parseInt(idStr, 10); // Already validated
694
+ if (isNaN(servoId) || servoId < 1 || servoId > 252) {
695
+ throw new Error(`Invalid servo ID "${idStr}" in syncWritePositions.`);
696
+ }
697
+ if (position < 0 || position > 4095) {
698
+ throw new Error(
699
+ `Invalid position value ${position} for servo ${servoId} in syncWritePositions. Must be between 0 and 4095.`
700
+ );
701
+ }
702
+ const targetPosition = Math.round(position); // Ensure integer, already validated range
703
+ const data = [SCS_LOBYTE(targetPosition), SCS_HIBYTE(targetPosition)];
704
+
705
+ if (groupSyncWrite.addParam(servoId, data)) {
706
+ paramAdded = true;
707
+ } else {
708
+ // This should ideally not happen if IDs are unique, but handle defensively
709
+ console.warn(`Failed to add servo ${servoId} to sync write group (possibly duplicate).`);
710
+ }
711
+ }
712
+
713
+ if (!paramAdded) {
714
+ console.log("Sync Write: No valid servo positions provided or added.");
715
+ return "success"; // Nothing to write is considered success
716
+ }
717
+
718
+ try {
719
+ // Send the Sync Write instruction
720
+ const result = await groupSyncWrite.txPacket();
721
+ if (result !== COMM_SUCCESS) {
722
+ throw new Error(`Sync Write txPacket failed: ${packetHandler.getTxRxResult(result)}`);
723
+ }
724
+ return "success";
725
+ } catch (err) {
726
+ console.error("Exception during syncWritePositions:", err);
727
+ // Re-throw the original error or a new one wrapping it
728
+ throw new Error(`Sync Write failed: ${err.message}`);
729
+ }
730
+ }
731
+
732
+ /**
733
+ * Sets the Baud Rate of a servo.
734
+ * NOTE: After changing the baud rate, you might need to disconnect and reconnect
735
+ * at the new baud rate to communicate with the servo further.
736
+ * @param {number} servoId - The current ID of the servo to configure (1-252).
737
+ * @param {number} baudRateIndex - The index representing the new baud rate (0-7).
738
+ * @returns {Promise<"success">} Resolves with "success".
739
+ * @throws {Error} If not connected, input is invalid, any step fails, or an exception occurs.
740
+ */
741
+ export async function setBaudRate(servoId, baudRateIndex) {
742
+ checkConnection();
743
+
744
+ // Validate inputs
745
+ if (servoId < 1 || servoId > 252) {
746
+ throw new Error(`Invalid servo ID provided: ${servoId}. Must be between 1 and 252.`);
747
+ }
748
+ if (baudRateIndex < 0 || baudRateIndex > 7) {
749
+ throw new Error(`Invalid baudRateIndex: ${baudRateIndex}. Must be between 0 and 7.`);
750
+ }
751
+
752
+ let unlocked = false;
753
+ try {
754
+ console.log(`Setting baud rate for servo ${servoId}: Index=${baudRateIndex}`);
755
+
756
+ // 1. Unlock servo configuration
757
+ const [resUnlock, errUnlock] = await packetHandler.write1ByteTxRx(
758
+ portHandler,
759
+ servoId,
760
+ ADDR_SCS_LOCK,
761
+ 0 // 0 to unlock
762
+ );
763
+ if (resUnlock !== COMM_SUCCESS) {
764
+ throw new Error(
765
+ `Failed to unlock servo ${servoId}: ${packetHandler.getTxRxResult(
766
+ resUnlock
767
+ )}, Error: ${errUnlock}`
768
+ );
769
+ }
770
+ unlocked = true;
771
+
772
+ // 2. Write new Baud Rate index
773
+ const [resBaud, errBaud] = await packetHandler.write1ByteTxRx(
774
+ portHandler,
775
+ servoId,
776
+ ADDR_SCS_BAUD_RATE,
777
+ baudRateIndex
778
+ );
779
+ if (resBaud !== COMM_SUCCESS) {
780
+ throw new Error(
781
+ `Failed to write baud rate index ${baudRateIndex} to servo ${servoId}: ${packetHandler.getTxRxResult(
782
+ resBaud
783
+ )}, Error: ${errBaud}`
784
+ );
785
+ }
786
+
787
+ // 3. Lock servo configuration
788
+ const [resLock, errLock] = await packetHandler.write1ByteTxRx(
789
+ portHandler,
790
+ servoId,
791
+ ADDR_SCS_LOCK,
792
+ 1
793
+ );
794
+ if (resLock !== COMM_SUCCESS) {
795
+ throw new Error(
796
+ `Failed to lock servo ${servoId} after setting baud rate: ${packetHandler.getTxRxResult(
797
+ resLock
798
+ )}, Error: ${errLock}.`
799
+ );
800
+ }
801
+ unlocked = false; // Successfully locked
802
+
803
+ console.log(
804
+ `Successfully set baud rate for servo ${servoId}. Index: ${baudRateIndex}. Remember to potentially reconnect with the new baud rate.`
805
+ );
806
+ return "success";
807
+ } catch (err) {
808
+ console.error(`Exception during setBaudRate for servo ID ${servoId}:`, err);
809
+ if (unlocked) {
810
+ await tryLockServo(servoId);
811
+ }
812
+ throw new Error(`Failed to set baud rate for servo ${servoId}: ${err.message}`);
813
+ }
814
+ }
815
+
816
+ /**
817
+ * Sets the ID of a servo.
818
+ * NOTE: Changing the ID requires using the new ID for subsequent commands.
819
+ * @param {number} currentServoId - The current ID of the servo to configure (1-252).
820
+ * @param {number} newServoId - The new ID to set for the servo (1-252).
821
+ * @returns {Promise<"success">} Resolves with "success".
822
+ * @throws {Error} If not connected, input is invalid, any step fails, or an exception occurs.
823
+ */
824
+ export async function setServoId(currentServoId, newServoId) {
825
+ checkConnection();
826
+
827
+ // Validate inputs
828
+ if (currentServoId < 1 || currentServoId > 252 || newServoId < 1 || newServoId > 252) {
829
+ throw new Error(
830
+ `Invalid servo ID provided. Current: ${currentServoId}, New: ${newServoId}. Must be between 1 and 252.`
831
+ );
832
+ }
833
+
834
+ if (currentServoId === newServoId) {
835
+ console.log(`Servo ID is already ${newServoId}. No change needed.`);
836
+ return "success";
837
+ }
838
+
839
+ let unlocked = false;
840
+ let idWritten = false;
841
+ try {
842
+ console.log(`Setting servo ID: From ${currentServoId} to ${newServoId}`);
843
+
844
+ // 1. Unlock servo configuration (using current ID)
845
+ const [resUnlock, errUnlock] = await packetHandler.write1ByteTxRx(
846
+ portHandler,
847
+ currentServoId,
848
+ ADDR_SCS_LOCK,
849
+ 0 // 0 to unlock
850
+ );
851
+ if (resUnlock !== COMM_SUCCESS) {
852
+ throw new Error(
853
+ `Failed to unlock servo ${currentServoId}: ${packetHandler.getTxRxResult(
854
+ resUnlock
855
+ )}, Error: ${errUnlock}`
856
+ );
857
+ }
858
+ unlocked = true;
859
+
860
+ // 2. Write new Servo ID (using current ID)
861
+ const [resId, errId] = await packetHandler.write1ByteTxRx(
862
+ portHandler,
863
+ currentServoId,
864
+ ADDR_SCS_ID,
865
+ newServoId
866
+ );
867
+ if (resId !== COMM_SUCCESS) {
868
+ throw new Error(
869
+ `Failed to write new ID ${newServoId} to servo ${currentServoId}: ${packetHandler.getTxRxResult(
870
+ resId
871
+ )}, Error: ${errId}`
872
+ );
873
+ }
874
+ idWritten = true;
875
+
876
+ // 3. Lock servo configuration (using NEW ID)
877
+ const [resLock, errLock] = await packetHandler.write1ByteTxRx(
878
+ portHandler,
879
+ newServoId, // Use NEW ID here
880
+ ADDR_SCS_LOCK,
881
+ 1 // 1 to lock
882
+ );
883
+ if (resLock !== COMM_SUCCESS) {
884
+ // ID was likely changed, but lock failed. Critical state.
885
+ throw new Error(
886
+ `Failed to lock servo with new ID ${newServoId}: ${packetHandler.getTxRxResult(
887
+ resLock
888
+ )}, Error: ${errLock}. Configuration might be incomplete.`
889
+ );
890
+ }
891
+ unlocked = false; // Successfully locked with new ID
892
+
893
+ console.log(
894
+ `Successfully set servo ID from ${currentServoId} to ${newServoId}. Remember to use the new ID for future commands.`
895
+ );
896
+ return "success";
897
+ } catch (err) {
898
+ console.error(`Exception during setServoId for current ID ${currentServoId}:`, err);
899
+ if (unlocked) {
900
+ // If unlock succeeded but subsequent steps failed, attempt to re-lock.
901
+ // If ID write failed, use current ID. If ID write succeeded but lock failed, use new ID.
902
+ const idToLock = idWritten ? newServoId : currentServoId;
903
+ console.warn(`Attempting to re-lock servo using ID ${idToLock}...`);
904
+ await tryLockServo(idToLock);
905
+ }
906
+ throw new Error(
907
+ `Failed to set servo ID from ${currentServoId} to ${newServoId}: ${err.message}`
908
+ );
909
+ }
910
+ }
packages/feetech.js/scsServoSDKUnlock.mjs ADDED
@@ -0,0 +1,217 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Read-Only Servo SDK for USB Master
3
+ * Simplified version that only reads positions without any locking
4
+ */
5
+
6
+ import { PortHandler, PacketHandler, COMM_SUCCESS, GroupSyncRead } from "./lowLevelSDK.mjs";
7
+
8
+ import { ADDR_SCS_PRESENT_POSITION } from "./scsservo_constants.mjs";
9
+
10
+ // Module-level variables for handlers
11
+ let portHandler = null;
12
+ let packetHandler = null;
13
+
14
+ /**
15
+ * Connects to the serial port and initializes handlers.
16
+ * @param {object} [options] - Connection options.
17
+ * @param {number} [options.baudRate=1000000] - The baud rate for the serial connection.
18
+ * @param {number} [options.protocolEnd=0] - The protocol end setting (0 for STS/SMS, 1 for SCS).
19
+ * @returns {Promise<true>} Resolves with true on successful connection.
20
+ * @throws {Error} If connection fails or port cannot be opened/selected.
21
+ */
22
+ export async function connect(options = {}) {
23
+ if (portHandler && portHandler.isOpen) {
24
+ console.log("🔓 Already connected to USB robot (read-only mode).");
25
+ return true;
26
+ }
27
+
28
+ const { baudRate = 1000000, protocolEnd = 0 } = options;
29
+
30
+ try {
31
+ portHandler = new PortHandler();
32
+ const portRequested = await portHandler.requestPort();
33
+ if (!portRequested) {
34
+ portHandler = null;
35
+ throw new Error("Failed to select a serial port.");
36
+ }
37
+
38
+ portHandler.setBaudRate(baudRate);
39
+ const portOpened = await portHandler.openPort();
40
+ if (!portOpened) {
41
+ await portHandler.closePort().catch(console.error);
42
+ portHandler = null;
43
+ throw new Error(`Failed to open port at baudrate ${baudRate}.`);
44
+ }
45
+
46
+ packetHandler = new PacketHandler(protocolEnd);
47
+ console.log(
48
+ `🔓 Connected to USB robot (read-only mode) at ${baudRate} baud, protocol end: ${protocolEnd}.`
49
+ );
50
+ return true;
51
+ } catch (err) {
52
+ console.error("Error during USB robot connection:", err);
53
+ if (portHandler) {
54
+ try {
55
+ await portHandler.closePort();
56
+ } catch (closeErr) {
57
+ console.error("Error closing port after connection failure:", closeErr);
58
+ }
59
+ }
60
+ portHandler = null;
61
+ packetHandler = null;
62
+ throw new Error(`USB robot connection failed: ${err.message}`);
63
+ }
64
+ }
65
+
66
+ /**
67
+ * Disconnects from the serial port.
68
+ * @returns {Promise<true>} Resolves with true on successful disconnection.
69
+ * @throws {Error} If disconnection fails.
70
+ */
71
+ export async function disconnect() {
72
+ if (!portHandler || !portHandler.isOpen) {
73
+ console.log("Already disconnected from USB robot.");
74
+ return true;
75
+ }
76
+
77
+ try {
78
+ await portHandler.closePort();
79
+ portHandler = null;
80
+ packetHandler = null;
81
+ console.log("🔓 Disconnected from USB robot (read-only mode).");
82
+ return true;
83
+ } catch (err) {
84
+ console.error("Error during USB robot disconnection:", err);
85
+ portHandler = null;
86
+ packetHandler = null;
87
+ throw new Error(`USB robot disconnection failed: ${err.message}`);
88
+ }
89
+ }
90
+
91
+ /**
92
+ * Checks if the SDK is connected. Throws an error if not.
93
+ * @throws {Error} If not connected.
94
+ */
95
+ function checkConnection() {
96
+ if (!portHandler || !packetHandler) {
97
+ throw new Error("Not connected to USB robot. Call connect() first.");
98
+ }
99
+ }
100
+
101
+ /**
102
+ * Reads the current position of a servo.
103
+ * @param {number} servoId - The ID of the servo (1-252).
104
+ * @returns {Promise<number>} Resolves with the position (0-4095).
105
+ * @throws {Error} If not connected, read fails, or an exception occurs.
106
+ */
107
+ export async function readPosition(servoId) {
108
+ checkConnection();
109
+ try {
110
+ const [position, result, error] = await packetHandler.read2ByteTxRx(
111
+ portHandler,
112
+ servoId,
113
+ ADDR_SCS_PRESENT_POSITION
114
+ );
115
+
116
+ if (result !== COMM_SUCCESS) {
117
+ throw new Error(
118
+ `Error reading position from servo ${servoId}: ${packetHandler.getTxRxResult(
119
+ result
120
+ )}, Error code: ${error}`
121
+ );
122
+ }
123
+ return position & 0xffff;
124
+ } catch (err) {
125
+ console.error(`Exception reading position from servo ${servoId}:`, err);
126
+ throw new Error(`Exception reading position from servo ${servoId}: ${err.message}`);
127
+ }
128
+ }
129
+
130
+ /**
131
+ * Reads the current position of multiple servos synchronously.
132
+ * Returns positions for all servos that respond, skipping failed ones gracefully.
133
+ * @param {number[]} servoIds - An array of servo IDs (1-252) to read from.
134
+ * @returns {Promise<Map<number, number>>} Resolves with a Map where keys are servo IDs and values are positions (0-4095).
135
+ * @throws {Error} If not connected or transmission fails completely.
136
+ */
137
+ export async function syncReadPositions(servoIds) {
138
+ checkConnection();
139
+ if (!Array.isArray(servoIds) || servoIds.length === 0) {
140
+ console.log("Sync Read: No servo IDs provided.");
141
+ return new Map();
142
+ }
143
+
144
+ const startAddress = ADDR_SCS_PRESENT_POSITION;
145
+ const dataLength = 2;
146
+ const groupSyncRead = new GroupSyncRead(portHandler, packetHandler, startAddress, dataLength);
147
+ const positions = new Map();
148
+ const validIds = [];
149
+
150
+ // Add parameters for each valid servo ID
151
+ servoIds.forEach((id) => {
152
+ if (id >= 1 && id <= 252) {
153
+ if (groupSyncRead.addParam(id)) {
154
+ validIds.push(id);
155
+ } else {
156
+ console.warn(
157
+ `Sync Read: Failed to add param for servo ID ${id} (maybe duplicate or invalid).`
158
+ );
159
+ }
160
+ } else {
161
+ console.warn(`Sync Read: Invalid servo ID ${id} skipped.`);
162
+ }
163
+ });
164
+
165
+ if (validIds.length === 0) {
166
+ console.log("Sync Read: No valid servo IDs to read.");
167
+ return new Map();
168
+ }
169
+
170
+ try {
171
+ // Send the Sync Read instruction packet
172
+ let txResult = await groupSyncRead.txPacket();
173
+ if (txResult !== COMM_SUCCESS) {
174
+ throw new Error(`Sync Read txPacket failed: ${packetHandler.getTxRxResult(txResult)}`);
175
+ }
176
+
177
+ // Receive the response packets
178
+ let rxResult = await groupSyncRead.rxPacket();
179
+ if (rxResult !== COMM_SUCCESS) {
180
+ console.warn(
181
+ `Sync Read rxPacket overall result: ${packetHandler.getTxRxResult(
182
+ rxResult
183
+ )}. Checking individual servos.`
184
+ );
185
+ }
186
+
187
+ // Check data availability and retrieve data for each servo
188
+ const failedIds = [];
189
+ validIds.forEach((id) => {
190
+ const isAvailable = groupSyncRead.isAvailable(id, startAddress, dataLength);
191
+ if (isAvailable) {
192
+ const position = groupSyncRead.getData(id, startAddress, dataLength);
193
+ const finalPosition = position & 0xffff;
194
+ console.log(
195
+ `🔍 Debug Servo ${id}: raw=${position}, final=${finalPosition}, hex=${position.toString(16)}`
196
+ );
197
+ positions.set(id, finalPosition);
198
+ } else {
199
+ failedIds.push(id);
200
+ }
201
+ });
202
+
203
+ // Log failed servos but don't throw error - return available data
204
+ if (failedIds.length > 0) {
205
+ console.warn(
206
+ `Sync Read: Data not available for servo IDs: ${failedIds.join(
207
+ ", "
208
+ )}. Got ${positions.size}/${validIds.length} servos successfully.`
209
+ );
210
+ }
211
+
212
+ return positions;
213
+ } catch (err) {
214
+ console.error("Exception during syncReadPositions:", err);
215
+ throw new Error(`Sync Read failed: ${err.message}`);
216
+ }
217
+ }
{src/lib → packages}/feetech.js/scsservo_constants.mjs RENAMED
@@ -1,8 +1,8 @@
1
  // Constants for FeetTech SCS servos
2
 
3
  // Constants
4
- export const BROADCAST_ID = 0xFE; // 254
5
- export const MAX_ID = 0xFC; // 252
6
 
7
  // Protocol instructions
8
  export const INST_PING = 1;
@@ -10,19 +10,19 @@ export const INST_READ = 2;
10
  export const INST_WRITE = 3;
11
  export const INST_REG_WRITE = 4;
12
  export const INST_ACTION = 5;
13
- export const INST_SYNC_WRITE = 131; // 0x83
14
- export const INST_SYNC_READ = 130; // 0x82
15
- export const INST_STATUS = 85; // 0x55, status packet instruction (0x55)
16
 
17
  // Communication results
18
- export const COMM_SUCCESS = 0; // tx or rx packet communication success
19
- export const COMM_PORT_BUSY = -1; // Port is busy (in use)
20
- export const COMM_TX_FAIL = -2; // Failed transmit instruction packet
21
- export const COMM_RX_FAIL = -3; // Failed get status packet
22
- export const COMM_TX_ERROR = -4; // Incorrect instruction packet
23
- export const COMM_RX_WAITING = -5; // Now receiving status packet
24
- export const COMM_RX_TIMEOUT = -6; // There is no status packet
25
- export const COMM_RX_CORRUPT = -7; // Incorrect status packet
26
  export const COMM_NOT_AVAILABLE = -9;
27
 
28
  // Packet constants
@@ -50,4 +50,4 @@ export const ADDR_SCS_TORQUE_ENABLE = 40;
50
  export const ADDR_SCS_GOAL_ACC = 41;
51
  export const ADDR_SCS_GOAL_POSITION = 42;
52
  export const ADDR_SCS_GOAL_SPEED = 46;
53
- export const ADDR_SCS_PRESENT_POSITION = 56;
 
1
  // Constants for FeetTech SCS servos
2
 
3
  // Constants
4
+ export const BROADCAST_ID = 0xfe; // 254
5
+ export const MAX_ID = 0xfc; // 252
6
 
7
  // Protocol instructions
8
  export const INST_PING = 1;
 
10
  export const INST_WRITE = 3;
11
  export const INST_REG_WRITE = 4;
12
  export const INST_ACTION = 5;
13
+ export const INST_SYNC_WRITE = 131; // 0x83
14
+ export const INST_SYNC_READ = 130; // 0x82
15
+ export const INST_STATUS = 85; // 0x55, status packet instruction (0x55)
16
 
17
  // Communication results
18
+ export const COMM_SUCCESS = 0; // tx or rx packet communication success
19
+ export const COMM_PORT_BUSY = -1; // Port is busy (in use)
20
+ export const COMM_TX_FAIL = -2; // Failed transmit instruction packet
21
+ export const COMM_RX_FAIL = -3; // Failed get status packet
22
+ export const COMM_TX_ERROR = -4; // Incorrect instruction packet
23
+ export const COMM_RX_WAITING = -5; // Now receiving status packet
24
+ export const COMM_RX_TIMEOUT = -6; // There is no status packet
25
+ export const COMM_RX_CORRUPT = -7; // Incorrect status packet
26
  export const COMM_NOT_AVAILABLE = -9;
27
 
28
  // Packet constants
 
50
  export const ADDR_SCS_GOAL_ACC = 41;
51
  export const ADDR_SCS_GOAL_POSITION = 42;
52
  export const ADDR_SCS_GOAL_SPEED = 46;
53
+ export const ADDR_SCS_PRESENT_POSITION = 56;
packages/feetech.js/test.html ADDED
@@ -0,0 +1,770 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>Feetech Servo Test</title>
7
+ <style>
8
+ body {
9
+ font-family: sans-serif;
10
+ line-height: 1.6;
11
+ padding: 20px;
12
+ }
13
+ .container {
14
+ max-width: 800px;
15
+ margin: auto;
16
+ }
17
+ .section {
18
+ border: 1px solid #ccc;
19
+ padding: 15px;
20
+ margin-bottom: 20px;
21
+ border-radius: 5px;
22
+ }
23
+ h2 {
24
+ margin-top: 0;
25
+ }
26
+ label {
27
+ display: inline-block;
28
+ min-width: 100px;
29
+ margin-bottom: 5px;
30
+ }
31
+ input[type="number"],
32
+ input[type="text"] {
33
+ width: 100px;
34
+ padding: 5px;
35
+ margin-right: 10px;
36
+ margin-bottom: 10px;
37
+ }
38
+ button {
39
+ padding: 8px 15px;
40
+ margin-right: 10px;
41
+ cursor: pointer;
42
+ }
43
+ pre {
44
+ background-color: #f4f4f4;
45
+ padding: 10px;
46
+ border: 1px solid #ddd;
47
+ border-radius: 3px;
48
+ white-space: pre-wrap;
49
+ word-wrap: break-word;
50
+ }
51
+ .status {
52
+ font-weight: bold;
53
+ }
54
+ .success {
55
+ color: green;
56
+ }
57
+ .error {
58
+ color: red;
59
+ }
60
+ .log-area {
61
+ margin-top: 10px;
62
+ }
63
+ </style>
64
+ </head>
65
+ <body>
66
+ <div class="container">
67
+ <h1>Feetech Servo Test Page</h1>
68
+
69
+ <details class="section">
70
+ <summary>Key Concepts</summary>
71
+ <p>Understanding these parameters is crucial for controlling Feetech servos:</p>
72
+ <ul>
73
+ <li>
74
+ <strong>Mode:</strong> Determines the servo's primary function.
75
+ <ul>
76
+ <li>
77
+ <code>Mode 0</code>: Position/Servo Mode. The servo moves to and holds a specific
78
+ angular position.
79
+ </li>
80
+ <li>
81
+ <code>Mode 1</code>: Wheel/Speed Mode. The servo rotates continuously at a specified
82
+ speed and direction, like a motor.
83
+ </li>
84
+ </ul>
85
+ Changing the mode requires unlocking, writing the mode value (0 or 1), and locking the
86
+ configuration.
87
+ </li>
88
+ <li>
89
+ <strong>Position:</strong> In Position Mode (Mode 0), this value represents the target
90
+ or current angular position of the servo's output shaft.
91
+ <ul>
92
+ <li>
93
+ Range: Typically <code>0</code> to <code>4095</code> (representing a 12-bit
94
+ resolution).
95
+ </li>
96
+ <li>
97
+ Meaning: Corresponds to the servo's rotational range (e.g., 0-360 degrees or 0-270
98
+ degrees, depending on the specific servo model). <code>0</code> is one end of the
99
+ range, <code>4095</code> is the other.
100
+ </li>
101
+ </ul>
102
+ </li>
103
+ <li>
104
+ <strong>Speed (Wheel Mode):</strong> In Wheel Mode (Mode 1), this value controls the
105
+ rotational speed and direction.
106
+ <ul>
107
+ <li>
108
+ Range: Typically <code>-2500</code> to <code>+2500</code>. (Note: Some documentation
109
+ might mention -1023 to +1023, but the SDK example uses a wider range).
110
+ </li>
111
+ <li>
112
+ Meaning: <code>0</code> stops the wheel. Positive values rotate in one direction
113
+ (e.g., clockwise), negative values rotate in the opposite direction (e.g.,
114
+ counter-clockwise). The magnitude determines the speed (larger absolute value means
115
+ faster rotation).
116
+ </li>
117
+ <li>Control Address: <code>ADDR_SCS_GOAL_SPEED</code> (Register 46/47).</li>
118
+ </ul>
119
+ </li>
120
+ <li>
121
+ <strong>Acceleration:</strong> Controls how quickly the servo changes speed to reach its
122
+ target position (in Position Mode) or target speed (in Wheel Mode).
123
+ <ul>
124
+ <li>Range: Typically <code>0</code> to <code>254</code>.</li>
125
+ <li>
126
+ Meaning: Defines the rate of change of speed. The unit is 100 steps/s².
127
+ <code>0</code> usually means instantaneous acceleration (or minimal delay). Higher
128
+ values result in slower, smoother acceleration and deceleration. For example, a
129
+ value of <code>10</code> means the speed changes by 10 * 100 = 1000 steps per
130
+ second, per second. This helps reduce jerky movements and mechanical stress.
131
+ </li>
132
+ <li>Control Address: <code>ADDR_SCS_GOAL_ACC</code> (Register 41).</li>
133
+ </ul>
134
+ </li>
135
+ <li>
136
+ <strong>Baud Rate:</strong> The speed of communication between the controller and the
137
+ servo. It must match on both ends. Servos often support multiple baud rates, selectable
138
+ via an index:
139
+ <ul>
140
+ <li>Index 0: 1,000,000 bps</li>
141
+ <li>Index 1: 500,000 bps</li>
142
+ <li>Index 2: 250,000 bps</li>
143
+ <li>Index 3: 128,000 bps</li>
144
+ <li>Index 4: 115,200 bps</li>
145
+ <li>Index 5: 76,800 bps</li>
146
+ <li>Index 6: 57,600 bps</li>
147
+ <li>Index 7: 38,400 bps</li>
148
+ </ul>
149
+ </li>
150
+ </ul>
151
+ </details>
152
+
153
+ <div class="section">
154
+ <h2>Connection</h2>
155
+ <button id="connectBtn">Connect</button>
156
+ <button id="disconnectBtn">Disconnect</button>
157
+ <p>Status: <span id="connectionStatus" class="status error">Disconnected</span></p>
158
+ <label for="baudRate">Baud Rate:</label>
159
+ <input type="number" id="baudRate" value="1000000" />
160
+ <label for="protocolEnd">Protocol End (0=STS/SMS, 1=SCS):</label>
161
+ <input type="number" id="protocolEnd" value="0" min="0" max="1" />
162
+ </div>
163
+
164
+ <div class="section">
165
+ <h2>Scan Servos</h2>
166
+ <label for="scanStartId">Start ID:</label>
167
+ <input type="number" id="scanStartId" value="1" min="1" max="252" />
168
+ <label for="scanEndId">End ID:</label>
169
+ <input type="number" id="scanEndId" value="15" min="1" max="252" />
170
+ <button id="scanServosBtn">Scan</button>
171
+ <p>Scan Results:</p>
172
+ <pre id="scanResultsOutput" style="max-height: 200px; overflow-y: auto"></pre>
173
+ <!-- Added element for results -->
174
+ </div>
175
+
176
+ <div class="section">
177
+ <h2>Single Servo Control</h2>
178
+ <label for="servoId">Servo ID:</label>
179
+ <input type="number" id="servoId" value="1" min="1" max="252" /><br />
180
+
181
+ <label for="idWrite">Change servo ID:</label>
182
+ <input type="number" id="idWrite" value="1" min="1" max="252" />
183
+ <button id="writeIdBtn">Write</button><br />
184
+
185
+ <label for="baudRead">Read Baud Rate:</label>
186
+ <button id="readBaudBtn">Read</button>
187
+ <span id="readBaudResult"></span><br />
188
+
189
+ <label for="baudWrite">Write Baud Rate Index:</label>
190
+ <input type="number" id="baudWrite" value="6" min="0" max="7" />
191
+ <!-- Assuming index 0-7 -->
192
+ <button id="writeBaudBtn">Write</button><br />
193
+
194
+ <label for="positionRead">Read Position:</label>
195
+ <button id="readPosBtn">Read</button>
196
+ <span id="readPosResult"></span><br />
197
+
198
+ <label for="positionWrite">Write Position:</label>
199
+ <input type="number" id="positionWrite" value="1000" min="0" max="4095" />
200
+ <button id="writePosBtn">Write</button><br />
201
+
202
+ <label for="torqueEnable">Torque:</label>
203
+ <button id="torqueEnableBtn">Enable</button>
204
+ <button id="torqueDisableBtn">Disable</button><br />
205
+
206
+ <label for="accelerationWrite">Write Acceleration:</label>
207
+ <input type="number" id="accelerationWrite" value="50" min="0" max="254" />
208
+ <button id="writeAccBtn">Write</button><br />
209
+
210
+ <label for="wheelMode">Wheel Mode:</label>
211
+ <button id="setWheelModeBtn">Set Wheel Mode</button>
212
+ <button id="removeWheelModeBtn">Set Position Mode</button><br />
213
+
214
+ <label for="wheelSpeedWrite">Write Wheel Speed:</label>
215
+ <input type="number" id="wheelSpeedWrite" value="0" min="-2500" max="2500" />
216
+ <button id="writeWheelSpeedBtn">Write Speed</button>
217
+ </div>
218
+
219
+ <div class="section">
220
+ <h2>Sync Operations</h2>
221
+ <label for="syncReadIds">Sync Read IDs (csv):</label>
222
+ <input type="text" id="syncReadIds" value="1,2,3" style="width: 150px" />
223
+ <button id="syncReadBtn">Sync Read Positions</button><br />
224
+
225
+ <label for="syncWriteData">Sync Write (id:pos,...):</label>
226
+ <input type="text" id="syncWriteData" value="1:1500,2:2500" style="width: 200px" />
227
+ <button id="syncWriteBtn">Sync Write Positions</button><br />
228
+
229
+ <label for="syncWriteSpeedData">Sync Write Speed (id:speed,...):</label>
230
+ <input type="text" id="syncWriteSpeedData" value="1:500,2:-1000" style="width: 200px" />
231
+ <button id="syncWriteSpeedBtn">Sync Write Speeds</button>
232
+ <!-- New Button -->
233
+ </div>
234
+
235
+ <div class="section">
236
+ <h2>Log Output</h2>
237
+ <pre id="logOutput"></pre>
238
+ </div>
239
+ </div>
240
+
241
+ <script type="module">
242
+ // Import the scsServoSDK object from index.mjs
243
+ import { scsServoSDK } from "./index.mjs";
244
+ // No longer need COMM_SUCCESS etc. here as errors are thrown
245
+
246
+ const connectBtn = document.getElementById("connectBtn");
247
+ const disconnectBtn = document.getElementById("disconnectBtn");
248
+ const connectionStatus = document.getElementById("connectionStatus");
249
+ const baudRateInput = document.getElementById("baudRate");
250
+ const protocolEndInput = document.getElementById("protocolEnd");
251
+
252
+ const servoIdInput = document.getElementById("servoId");
253
+ const readIdBtn = document.getElementById("readIdBtn"); // New
254
+ const readIdResult = document.getElementById("readIdResult"); // New
255
+ const idWriteInput = document.getElementById("idWrite"); // New
256
+ const writeIdBtn = document.getElementById("writeIdBtn"); // New
257
+ const readBaudBtn = document.getElementById("readBaudBtn"); // New
258
+ const readBaudResult = document.getElementById("readBaudResult"); // New
259
+ const baudWriteInput = document.getElementById("baudWrite"); // New
260
+ const writeBaudBtn = document.getElementById("writeBaudBtn"); // New
261
+ const readPosBtn = document.getElementById("readPosBtn");
262
+ const readPosResult = document.getElementById("readPosResult");
263
+ const positionWriteInput = document.getElementById("positionWrite");
264
+ const writePosBtn = document.getElementById("writePosBtn");
265
+ const torqueEnableBtn = document.getElementById("torqueEnableBtn");
266
+ const torqueDisableBtn = document.getElementById("torqueDisableBtn");
267
+ const accelerationWriteInput = document.getElementById("accelerationWrite");
268
+ const writeAccBtn = document.getElementById("writeAccBtn");
269
+ const setWheelModeBtn = document.getElementById("setWheelModeBtn");
270
+ const removeWheelModeBtn = document.getElementById("removeWheelModeBtn"); // Get reference to the new button
271
+ const wheelSpeedWriteInput = document.getElementById("wheelSpeedWrite");
272
+ const writeWheelSpeedBtn = document.getElementById("writeWheelSpeedBtn");
273
+
274
+ const syncReadIdsInput = document.getElementById("syncReadIds");
275
+ const syncReadBtn = document.getElementById("syncReadBtn");
276
+ const syncWriteDataInput = document.getElementById("syncWriteData");
277
+ const syncWriteBtn = document.getElementById("syncWriteBtn");
278
+ const syncWriteSpeedDataInput = document.getElementById("syncWriteSpeedData"); // New Input
279
+ const syncWriteSpeedBtn = document.getElementById("syncWriteSpeedBtn"); // New Button
280
+ const scanServosBtn = document.getElementById("scanServosBtn"); // Get reference to the scan button
281
+ const scanStartIdInput = document.getElementById("scanStartId"); // Get reference to start ID input
282
+ const scanEndIdInput = document.getElementById("scanEndId"); // Get reference to end ID input
283
+ const scanResultsOutput = document.getElementById("scanResultsOutput"); // Get reference to the new results area
284
+
285
+ const logOutput = document.getElementById("logOutput");
286
+
287
+ let isConnected = false;
288
+
289
+ function log(message) {
290
+ console.log(message);
291
+ const timestamp = new Date().toLocaleTimeString();
292
+ logOutput.textContent = `[${timestamp}] ${message}\n` + logOutput.textContent;
293
+ // Limit log size
294
+ const lines = logOutput.textContent.split("\n"); // Use '\n' instead of literal newline
295
+ if (lines.length > 50) {
296
+ logOutput.textContent = lines.slice(0, 50).join("\n"); // Use '\n' instead of literal newline
297
+ }
298
+ }
299
+
300
+ function updateConnectionStatus(connected, message) {
301
+ isConnected = connected;
302
+ connectionStatus.textContent = message || (connected ? "Connected" : "Disconnected");
303
+ connectionStatus.className = `status ${connected ? "success" : "error"}`;
304
+ log(`Connection status: ${connectionStatus.textContent}`);
305
+ }
306
+
307
+ connectBtn.onclick = async () => {
308
+ log("Attempting to connect...");
309
+ try {
310
+ const baudRate = parseInt(baudRateInput.value, 10);
311
+ const protocolEnd = parseInt(protocolEndInput.value, 10);
312
+ // Use scsServoSDK - throws on error
313
+ await scsServoSDK.connect({ baudRate, protocolEnd });
314
+ updateConnectionStatus(true, "Connected");
315
+ } catch (err) {
316
+ updateConnectionStatus(false, `Connection error: ${err.message}`);
317
+ console.error(err);
318
+ }
319
+ };
320
+
321
+ disconnectBtn.onclick = async () => {
322
+ log("Attempting to disconnect...");
323
+ try {
324
+ // Use scsServoSDK - throws on error
325
+ await scsServoSDK.disconnect();
326
+ updateConnectionStatus(false, "Disconnected"); // Success means disconnected
327
+ } catch (err) {
328
+ // Assuming disconnect might fail if already disconnected or other issues
329
+ updateConnectionStatus(false, `Disconnection error: ${err.message}`);
330
+ console.error(err);
331
+ }
332
+ };
333
+
334
+ writeIdBtn.onclick = async () => {
335
+ // New handler
336
+ if (!isConnected) {
337
+ log("Error: Not connected");
338
+ return;
339
+ }
340
+ const currentId = parseInt(servoIdInput.value, 10);
341
+ const newId = parseInt(idWriteInput.value, 10);
342
+ if (isNaN(newId) || newId < 1 || newId > 252) {
343
+ log(`Error: Invalid new ID ${newId}. Must be between 1 and 252.`);
344
+ return;
345
+ }
346
+ log(`Writing new ID ${newId} to servo ${currentId}...`);
347
+ try {
348
+ // Use scsServoSDK - throws on error
349
+ await scsServoSDK.setServoId(currentId, newId);
350
+ log(`Successfully wrote new ID ${newId} to servo (was ${currentId}).`);
351
+ // IMPORTANT: Update the main ID input to reflect the change
352
+ servoIdInput.value = newId;
353
+ log(`Servo ID input field updated to ${newId}.`);
354
+ } catch (err) {
355
+ log(`Error writing ID for servo ${currentId}: ${err.message}`);
356
+ console.error(err);
357
+ }
358
+ };
359
+
360
+ readBaudBtn.onclick = async () => {
361
+ // New handler
362
+ if (!isConnected) {
363
+ log("Error: Not connected");
364
+ return;
365
+ }
366
+ const id = parseInt(servoIdInput.value, 10);
367
+ log(`Reading Baud Rate Index for servo ${id}...`);
368
+ readBaudResult.textContent = "Reading...";
369
+ try {
370
+ // Use scsServoSDK - returns value directly or throws
371
+ const baudRateIndex = await scsServoSDK.readBaudRate(id);
372
+ readBaudResult.textContent = `Baud Index: ${baudRateIndex}`;
373
+ log(`Servo ${id} Baud Rate Index: ${baudRateIndex}`);
374
+ } catch (err) {
375
+ readBaudResult.textContent = `Error: ${err.message}`;
376
+ log(`Error reading Baud Rate Index for servo ${id}: ${err.message}`);
377
+ console.error(err);
378
+ }
379
+ };
380
+
381
+ writeBaudBtn.onclick = async () => {
382
+ // New handler
383
+ if (!isConnected) {
384
+ log("Error: Not connected");
385
+ return;
386
+ }
387
+ const id = parseInt(servoIdInput.value, 10);
388
+ const newBaudIndex = parseInt(baudWriteInput.value, 10);
389
+ if (isNaN(newBaudIndex) || newBaudIndex < 0 || newBaudIndex > 7) {
390
+ // Adjust max index if needed
391
+ log(`Error: Invalid new Baud Rate Index ${newBaudIndex}. Check valid range.`);
392
+ return;
393
+ }
394
+ log(`Writing new Baud Rate Index ${newBaudIndex} to servo ${id}...`);
395
+ try {
396
+ // Use scsServoSDK - throws on error
397
+ await scsServoSDK.setBaudRate(id, newBaudIndex);
398
+ log(`Successfully wrote new Baud Rate Index ${newBaudIndex} to servo ${id}.`);
399
+ log(
400
+ `IMPORTANT: You may need to disconnect and reconnect with the new baud rate if it differs from the current connection baud rate.`
401
+ );
402
+ } catch (err) {
403
+ log(`Error writing Baud Rate Index for servo ${id}: ${err.message}`);
404
+ console.error(err);
405
+ }
406
+ };
407
+
408
+ readPosBtn.onclick = async () => {
409
+ if (!isConnected) {
410
+ log("Error: Not connected");
411
+ return;
412
+ }
413
+ const id = parseInt(servoIdInput.value, 10);
414
+ log(`Reading position for servo ${id}...`);
415
+ readPosResult.textContent = "Reading...";
416
+ try {
417
+ // Use scsServoSDK - returns value directly or throws
418
+ const position = await scsServoSDK.readPosition(id);
419
+ readPosResult.textContent = `Position: ${position}`;
420
+ log(`Servo ${id} position: ${position}`);
421
+ } catch (err) {
422
+ readPosResult.textContent = `Error: ${err.message}`;
423
+ log(`Error reading position for servo ${id}: ${err.message}`);
424
+ console.error(err);
425
+ }
426
+ };
427
+
428
+ writePosBtn.onclick = async () => {
429
+ if (!isConnected) {
430
+ log("Error: Not connected");
431
+ return;
432
+ }
433
+ const id = parseInt(servoIdInput.value, 10);
434
+ const pos = parseInt(positionWriteInput.value, 10);
435
+ log(`Writing position ${pos} to servo ${id}...`);
436
+ try {
437
+ // Use scsServoSDK - throws on error
438
+ await scsServoSDK.writePosition(id, pos);
439
+ log(`Successfully wrote position ${pos} to servo ${id}.`);
440
+ } catch (err) {
441
+ log(`Error writing position for servo ${id}: ${err.message}`);
442
+ console.error(err);
443
+ }
444
+ };
445
+
446
+ torqueEnableBtn.onclick = async () => {
447
+ if (!isConnected) {
448
+ log("Error: Not connected");
449
+ return;
450
+ }
451
+ const id = parseInt(servoIdInput.value, 10);
452
+ log(`Enabling torque for servo ${id}...`);
453
+ try {
454
+ // Use scsServoSDK - throws on error
455
+ await scsServoSDK.writeTorqueEnable(id, true);
456
+ log(`Successfully enabled torque for servo ${id}.`);
457
+ } catch (err) {
458
+ log(`Error enabling torque for servo ${id}: ${err.message}`);
459
+ console.error(err);
460
+ }
461
+ };
462
+
463
+ torqueDisableBtn.onclick = async () => {
464
+ if (!isConnected) {
465
+ log("Error: Not connected");
466
+ return;
467
+ }
468
+ const id = parseInt(servoIdInput.value, 10);
469
+ log(`Disabling torque for servo ${id}...`);
470
+ try {
471
+ // Use scsServoSDK - throws on error
472
+ await scsServoSDK.writeTorqueEnable(id, false);
473
+ log(`Successfully disabled torque for servo ${id}.`);
474
+ } catch (err) {
475
+ log(`Error disabling torque for servo ${id}: ${err.message}`);
476
+ console.error(err);
477
+ }
478
+ };
479
+
480
+ writeAccBtn.onclick = async () => {
481
+ if (!isConnected) {
482
+ log("Error: Not connected");
483
+ return;
484
+ }
485
+ const id = parseInt(servoIdInput.value, 10);
486
+ const acc = parseInt(accelerationWriteInput.value, 10);
487
+ log(`Writing acceleration ${acc} to servo ${id}...`);
488
+ try {
489
+ // Use scsServoSDK - throws on error
490
+ await scsServoSDK.writeAcceleration(id, acc);
491
+ log(`Successfully wrote acceleration ${acc} to servo ${id}.`);
492
+ } catch (err) {
493
+ log(`Error writing acceleration for servo ${id}: ${err.message}`);
494
+ console.error(err);
495
+ }
496
+ };
497
+
498
+ setWheelModeBtn.onclick = async () => {
499
+ if (!isConnected) {
500
+ log("Error: Not connected");
501
+ return;
502
+ }
503
+ const id = parseInt(servoIdInput.value, 10);
504
+ log(`Setting servo ${id} to wheel mode...`);
505
+ try {
506
+ // Use scsServoSDK - throws on error
507
+ await scsServoSDK.setWheelMode(id);
508
+ log(`Successfully set servo ${id} to wheel mode.`);
509
+ } catch (err) {
510
+ log(`Error setting wheel mode for servo ${id}: ${err.message}`);
511
+ console.error(err);
512
+ }
513
+ };
514
+
515
+ // Add event listener for the new button
516
+ removeWheelModeBtn.onclick = async () => {
517
+ if (!isConnected) {
518
+ log("Error: Not connected");
519
+ return;
520
+ }
521
+ const id = parseInt(servoIdInput.value, 10);
522
+ log(`Setting servo ${id} back to position mode...`);
523
+ try {
524
+ // Use scsServoSDK - throws on error
525
+ await scsServoSDK.setPositionMode(id);
526
+ log(`Successfully set servo ${id} back to position mode.`);
527
+ } catch (err) {
528
+ log(`Error setting position mode for servo ${id}: ${err.message}`);
529
+ console.error(err);
530
+ }
531
+ };
532
+
533
+ writeWheelSpeedBtn.onclick = async () => {
534
+ if (!isConnected) {
535
+ log("Error: Not connected");
536
+ return;
537
+ }
538
+ const id = parseInt(servoIdInput.value, 10);
539
+ const speed = parseInt(wheelSpeedWriteInput.value, 10);
540
+ log(`Writing wheel speed ${speed} to servo ${id}...`);
541
+ try {
542
+ // Use scsServoSDK - throws on error
543
+ await scsServoSDK.writeWheelSpeed(id, speed);
544
+ log(`Successfully wrote wheel speed ${speed} to servo ${id}.`);
545
+ } catch (err) {
546
+ log(`Error writing wheel speed for servo ${id}: ${err.message}`);
547
+ console.error(err);
548
+ }
549
+ };
550
+
551
+ syncReadBtn.onclick = async () => {
552
+ if (!isConnected) {
553
+ log("Error: Not connected");
554
+ return;
555
+ }
556
+ const idsString = syncReadIdsInput.value;
557
+ const ids = idsString
558
+ .split(",")
559
+ .map((s) => parseInt(s.trim(), 10))
560
+ .filter((id) => !isNaN(id) && id > 0 && id < 253);
561
+ if (ids.length === 0) {
562
+ log("Sync Read: No valid servo IDs provided.");
563
+ return;
564
+ }
565
+ log(`Sync reading positions for servos: ${ids.join(", ")}...`);
566
+ try {
567
+ // Use scsServoSDK - returns Map or throws
568
+ const positions = await scsServoSDK.syncReadPositions(ids);
569
+ let logMsg = "Sync Read Successful:\n";
570
+ positions.forEach((pos, id) => {
571
+ logMsg += ` Servo ${id}: Position=${pos}\n`;
572
+ });
573
+ log(logMsg.trim());
574
+ } catch (err) {
575
+ log(`Sync Read Failed: ${err.message}`);
576
+ console.error(err);
577
+ }
578
+ };
579
+
580
+ syncWriteBtn.onclick = async () => {
581
+ if (!isConnected) {
582
+ log("Error: Not connected");
583
+ return;
584
+ }
585
+ const dataString = syncWriteDataInput.value;
586
+ const positionMap = new Map();
587
+ const pairs = dataString.split(",");
588
+ let validData = false;
589
+
590
+ pairs.forEach((pair) => {
591
+ const parts = pair.split(":");
592
+ if (parts.length === 2) {
593
+ const id = parseInt(parts[0].trim(), 10);
594
+ const pos = parseInt(parts[1].trim(), 10);
595
+ // Position validation (0-4095)
596
+ if (!isNaN(id) && id > 0 && id < 253 && !isNaN(pos) && pos >= 0 && pos <= 4095) {
597
+ positionMap.set(id, pos);
598
+ validData = true;
599
+ } else {
600
+ log(
601
+ `Sync Write Position: Invalid data pair "${pair}". ID (1-252), Pos (0-4095). Skipping.`
602
+ );
603
+ }
604
+ } else {
605
+ log(`Sync Write Position: Invalid format "${pair}". Skipping.`);
606
+ }
607
+ });
608
+
609
+ if (!validData) {
610
+ log("Sync Write Position: No valid servo position data provided.");
611
+ return;
612
+ }
613
+
614
+ log(
615
+ `Sync writing positions: ${Array.from(positionMap.entries())
616
+ .map(([id, pos]) => `${id}:${pos}`)
617
+ .join(", ")}...`
618
+ );
619
+ try {
620
+ // Use scsServoSDK - throws on error
621
+ await scsServoSDK.syncWritePositions(positionMap);
622
+ log(`Sync write position command sent successfully.`);
623
+ } catch (err) {
624
+ log(`Sync Write Position Failed: ${err.message}`);
625
+ console.error(err);
626
+ }
627
+ };
628
+
629
+ // New handler for Sync Write Speed
630
+ syncWriteSpeedBtn.onclick = async () => {
631
+ if (!isConnected) {
632
+ log("Error: Not connected");
633
+ return;
634
+ }
635
+ const dataString = syncWriteSpeedDataInput.value;
636
+ const speedMap = new Map();
637
+ const pairs = dataString.split(",");
638
+ let validData = false;
639
+
640
+ pairs.forEach((pair) => {
641
+ const parts = pair.split(":");
642
+ if (parts.length === 2) {
643
+ const id = parseInt(parts[0].trim(), 10);
644
+ const speed = parseInt(parts[1].trim(), 10);
645
+ // Speed validation (-10000 to 10000)
646
+ if (
647
+ !isNaN(id) &&
648
+ id > 0 &&
649
+ id < 253 &&
650
+ !isNaN(speed) &&
651
+ speed >= -10000 &&
652
+ speed <= 10000
653
+ ) {
654
+ speedMap.set(id, speed);
655
+ validData = true;
656
+ } else {
657
+ log(
658
+ `Sync Write Speed: Invalid data pair "${pair}". ID (1-252), Speed (-10000 to 10000). Skipping.`
659
+ );
660
+ }
661
+ } else {
662
+ log(`Sync Write Speed: Invalid format "${pair}". Skipping.`);
663
+ }
664
+ });
665
+
666
+ if (!validData) {
667
+ log("Sync Write Speed: No valid servo speed data provided.");
668
+ return;
669
+ }
670
+
671
+ log(
672
+ `Sync writing speeds: ${Array.from(speedMap.entries())
673
+ .map(([id, speed]) => `${id}:${speed}`)
674
+ .join(", ")}...`
675
+ );
676
+ try {
677
+ // Use scsServoSDK - throws on error
678
+ await scsServoSDK.syncWriteWheelSpeed(speedMap);
679
+ log(`Sync write speed command sent successfully.`);
680
+ } catch (err) {
681
+ log(`Sync Write Speed Failed: ${err.message}`);
682
+ console.error(err);
683
+ }
684
+ };
685
+
686
+ scanServosBtn.onclick = async () => {
687
+ if (!isConnected) {
688
+ log("Error: Not connected");
689
+ return;
690
+ }
691
+
692
+ const startId = parseInt(scanStartIdInput.value, 10);
693
+ const endId = parseInt(scanEndIdInput.value, 10);
694
+
695
+ if (isNaN(startId) || isNaN(endId) || startId < 1 || endId > 252 || startId > endId) {
696
+ const errorMsg =
697
+ "Error: Invalid scan ID range. Please enter values between 1 and 252, with Start ID <= End ID.";
698
+ log(errorMsg);
699
+ scanResultsOutput.textContent = errorMsg; // Show error in results area too
700
+ return;
701
+ }
702
+
703
+ const startMsg = `Starting servo scan (IDs ${startId}-${endId})...`;
704
+ log(startMsg);
705
+ scanResultsOutput.textContent = startMsg + "\n"; // Clear and start results area
706
+ scanServosBtn.disabled = true; // Disable button during scan
707
+
708
+ let foundCount = 0;
709
+
710
+ for (let id = startId; id <= endId; id++) {
711
+ let resultMsg = `Scanning ID ${id}... `;
712
+ try {
713
+ // Attempt to read position. If it succeeds, the servo exists.
714
+ // If it throws, the servo likely doesn't exist or there's another issue.
715
+ const position = await scsServoSDK.readPosition(id);
716
+ foundCount++;
717
+
718
+ // Servo found, now try to read mode and baud rate
719
+ let mode = "ReadError";
720
+ let baudRateIndex = "ReadError";
721
+ try {
722
+ mode = await scsServoSDK.readMode(id);
723
+ } catch (modeErr) {
724
+ log(` Servo ${id}: Error reading mode: ${modeErr.message}`);
725
+ }
726
+ try {
727
+ baudRateIndex = await scsServoSDK.readBaudRate(id);
728
+ } catch (baudErr) {
729
+ log(` Servo ${id}: Error reading baud rate: ${baudErr.message}`);
730
+ }
731
+
732
+ resultMsg += `FOUND: Pos=${position}, Mode=${mode}, BaudIdx=${baudRateIndex}`;
733
+ log(
734
+ ` Servo ${id} FOUND: Position=${position}, Mode=${mode}, BaudIndex=${baudRateIndex}`
735
+ );
736
+ } catch (err) {
737
+ // Check if the error message indicates a timeout or non-response, which is expected for non-existent IDs
738
+ // This check might need refinement based on the exact error messages thrown by readPosition
739
+ if (
740
+ err.message.includes("timeout") ||
741
+ err.message.includes("No response") ||
742
+ err.message.includes("failed: RX")
743
+ ) {
744
+ resultMsg += `No response`;
745
+ // log(` Servo ${id}: No response`); // Optional: reduce log noise
746
+ } else {
747
+ // Log other unexpected errors
748
+ resultMsg += `Error: ${err.message}`;
749
+ log(` Servo ${id}: Error during scan: ${err.message}`);
750
+ console.error(`Error scanning servo ${id}:`, err);
751
+ }
752
+ }
753
+ scanResultsOutput.textContent += resultMsg + "\n"; // Append result to the results area
754
+ scanResultsOutput.scrollTop = scanResultsOutput.scrollHeight; // Auto-scroll
755
+ // Optional small delay between scans if needed
756
+ // await new Promise(resolve => setTimeout(resolve, 10));
757
+ }
758
+
759
+ const finishMsg = `Servo scan finished. Found ${foundCount} servo(s).`;
760
+ log(finishMsg);
761
+ scanResultsOutput.textContent += finishMsg + "\n"; // Add finish message to results area
762
+ scanResultsOutput.scrollTop = scanResultsOutput.scrollHeight; // Auto-scroll
763
+ scanServosBtn.disabled = false; // Re-enable button
764
+ };
765
+
766
+ // Initial log
767
+ log("Test page loaded. Please connect to a servo controller.");
768
+ </script>
769
+ </body>
770
+ </html>
src-python/README.md CHANGED
@@ -5,6 +5,7 @@ A comprehensive WebSocket-based robot control platform featuring **master-slave
5
  ## 🏛️ Core Architecture
6
 
7
  ### Master-Slave Pattern
 
8
  ```
9
  ┌─────────────────┐ Commands ┌─────────────────┐ Execution ┌─────────────────┐
10
  │ 🎮 MASTERS │ ───────────────►│ 🤖 ROBOT CORE │ ───────────────►│ 🔧 SLAVES │
@@ -17,8 +18,9 @@ A comprehensive WebSocket-based robot control platform featuring **master-slave
17
  ```
18
 
19
  **Key Principles:**
 
20
  - 🎯 **Single Source of Truth**: Only one master per robot at a time
21
- - 🔒 **Safety First**: Manual control disabled when master active
22
  - 🌐 **Multi-Modal**: Same commands work across all slave types
23
  - 🔄 **Real-Time Sync**: Virtual and physical states synchronized
24
  - 📡 **Network Native**: Built for remote operation from day one
@@ -26,6 +28,7 @@ A comprehensive WebSocket-based robot control platform featuring **master-slave
26
  ## 🚀 Quick Start Guide
27
 
28
  ### 1. Server Setup
 
29
  ```bash
30
  # Install dependencies
31
  cd src-python
@@ -37,6 +40,7 @@ python start_server.py
37
  ```
38
 
39
  ### 2. Frontend Integration
 
40
  ```bash
41
  # In your Svelte app
42
  npm run dev
@@ -44,6 +48,7 @@ npm run dev
44
  ```
45
 
46
  ### 3. Create & Control Robot
 
47
  ```javascript
48
  // Create robot in UI or via API
49
  const robot = await robotManager.createRobot('my-robot', robotUrdfConfig);
@@ -67,44 +72,49 @@ curl -X POST http://localhost:8080/api/robots/my-robot/play-sequence/gentle-wave
67
  ## 🎮 Master Drivers (Command Sources)
68
 
69
  ### MockSequenceMaster
 
70
  **Pre-programmed movement patterns for testing and demos**
71
 
72
  ```typescript
73
  const config: MasterDriverConfig = {
74
- type: "mock-sequence",
75
- sequences: DEMO_SEQUENCES,
76
- autoStart: true,
77
- loopMode: true
78
  };
79
 
80
- await robotManager.connectMaster('robot-1', config);
81
  ```
82
 
83
  **Available Sequences:**
 
84
  - **🌊 Gentle Wave Pattern** (6s): Smooth greeting gesture with wrist movements
85
- - **🔍 Small Scanning Pattern** (8s): Horizontal sweep for environment scanning
86
  - **💪 Tiny Flex Pattern** (8s): Articulation demonstration with elbow/jaw coordination
87
 
88
  **Features:**
 
89
  - ✅ Safe movement ranges (±25° max)
90
  - ✅ Smooth interpolation between keyframes
91
  - ✅ Loop mode for continuous operation
92
  - ✅ Automatic master takeover
93
 
94
  ### RemoteServerMaster
 
95
  **WebSocket connection to external control systems**
96
 
97
  ```typescript
98
  const config: MasterDriverConfig = {
99
- type: "remote-server",
100
- url: "ws://localhost:8080",
101
- apiKey: "optional-auth-token"
102
  };
103
 
104
- await robotManager.connectMaster('robot-1', config);
105
  ```
106
 
107
  **Features:**
 
108
  - ✅ Real-time bidirectional communication
109
  - ✅ Automatic reconnection with exponential backoff
110
  - ✅ Heartbeat monitoring (30s intervals)
@@ -112,36 +122,37 @@ await robotManager.connectMaster('robot-1', config);
112
  - ✅ Status and error reporting
113
 
114
  **Message Format:**
 
115
  ```json
116
  {
117
- "type": "command",
118
- "timestamp": "2024-01-01T12:00:00Z",
119
- "data": {
120
- "timestamp": 1704110400000,
121
- "joints": [
122
- {"name": "Rotation", "value": 45, "speed": 100}
123
- ]
124
- }
125
  }
126
  ```
127
 
128
  ## 🔧 Slave Drivers (Execution Targets)
129
 
130
  ### MockSlave
 
131
  **Perfect simulation for development and testing**
132
 
133
  ```typescript
134
  const config: SlaveDriverConfig = {
135
- type: "mock-slave",
136
- simulateLatency: 50, // Realistic response delay
137
- simulateErrors: false, // Random connection issues
138
- responseDelay: 20 // Command execution time
139
  };
140
 
141
- await robotManager.connectSlave('robot-1', config);
142
  ```
143
 
144
  **Features:**
 
145
  - ✅ Perfect command execution (real_value = virtual_value)
146
  - ✅ Configurable network latency simulation
147
  - ✅ Error injection for robustness testing
@@ -149,19 +160,21 @@ await robotManager.connectSlave('robot-1', config);
149
  - ✅ Real-time state feedback
150
 
151
  ### USBSlave
 
152
  **Direct serial communication with physical robots**
153
 
154
  ```typescript
155
  const config: SlaveDriverConfig = {
156
- type: "usb-slave",
157
- port: "/dev/ttyUSB0", // Auto-detect if undefined
158
- baudRate: 115200
159
  };
160
 
161
- await robotManager.connectSlave('robot-1', config);
162
  ```
163
 
164
  **Features:**
 
165
  - ✅ Feetech servo protocol support
166
  - ✅ Position and speed control
167
  - ✅ Real servo position feedback
@@ -169,25 +182,28 @@ await robotManager.connectSlave('robot-1', config);
169
  - ✅ Error handling and recovery
170
 
171
  **Calibration Process:**
 
172
  1. Manually position robot to match digital twin
173
  2. Click "Calibrate" to sync virtual/real coordinates
174
  3. System calculates offset: `real_pos = raw_pos + offset`
175
  4. All future commands automatically compensated
176
 
177
  ### WebSocketSlave
 
178
  **Remote robot control via WebSocket relay**
179
 
180
  ```typescript
181
  const config: SlaveDriverConfig = {
182
- type: "websocket-slave",
183
- url: "ws://robot-proxy:8080",
184
- robotId: "remote-arm-1"
185
  };
186
 
187
- await robotManager.connectSlave('robot-1', config);
188
  ```
189
 
190
  **Use Cases:**
 
191
  - 🌐 Control robots across internet
192
  - 🏢 Enterprise robot fleet management
193
  - 🔒 Firewall-friendly robot access
@@ -198,104 +214,110 @@ await robotManager.connectSlave('robot-1', config);
198
  ### Master → Server Communication
199
 
200
  #### Send Joint Command
 
201
  ```json
202
  {
203
- "type": "command",
204
- "timestamp": "2024-01-01T12:00:00Z",
205
- "data": {
206
- "timestamp": 1704110400000,
207
- "joints": [
208
- {"name": "Rotation", "value": 45, "speed": 100},
209
- {"name": "Pitch", "value": -30, "speed": 150}
210
- ],
211
- "duration": 2000,
212
- "metadata": {"source": "manual_control"}
213
- }
214
  }
215
  ```
216
 
217
  #### Send Movement Sequence
 
218
  ```json
219
  {
220
- "type": "sequence",
221
- "timestamp": "2024-01-01T12:00:00Z",
222
- "data": {
223
- "id": "custom-dance",
224
- "name": "Custom Dance Sequence",
225
- "commands": [
226
- {
227
- "timestamp": 0,
228
- "joints": [{"name": "Rotation", "value": -30}],
229
- "duration": 1000
230
- },
231
- {
232
- "timestamp": 1000,
233
- "joints": [{"name": "Rotation", "value": 30}],
234
- "duration": 1000
235
- }
236
- ],
237
- "total_duration": 2000,
238
- "loop": false
239
- }
240
  }
241
  ```
242
 
243
  #### Heartbeat
 
244
  ```json
245
  {
246
- "type": "heartbeat",
247
- "timestamp": "2024-01-01T12:00:00Z"
248
  }
249
  ```
250
 
251
  ### Slave → Server Communication
252
 
253
  #### Status Update
 
254
  ```json
255
  {
256
- "type": "status_update",
257
- "timestamp": "2024-01-01T12:00:00Z",
258
- "data": {
259
- "isConnected": true,
260
- "lastConnected": "2024-01-01T11:58:00Z",
261
- "error": null
262
- }
263
  }
264
  ```
265
 
266
  #### Joint State Feedback
 
267
  ```json
268
  {
269
- "type": "joint_states",
270
- "timestamp": "2024-01-01T12:00:00Z",
271
- "data": [
272
- {
273
- "name": "Rotation",
274
- "servo_id": 1,
275
- "type": "revolute",
276
- "virtual_value": 45.0,
277
- "real_value": 44.8,
278
- "limits": {
279
- "lower": -180,
280
- "upper": 180,
281
- "velocity": 200
282
- }
283
- }
284
- ]
285
  }
286
  ```
287
 
288
  #### Error Reporting
 
289
  ```json
290
  {
291
- "type": "error",
292
- "timestamp": "2024-01-01T12:00:00Z",
293
- "data": {
294
- "code": "SERVO_TIMEOUT",
295
- "message": "Servo 3 not responding",
296
- "joint": "Elbow",
297
- "severity": "warning"
298
- }
299
  }
300
  ```
301
 
@@ -303,33 +325,34 @@ await robotManager.connectSlave('robot-1', config);
303
 
304
  ### Robot Management
305
 
306
- | Method | Endpoint | Description | Example |
307
- |--------|----------|-------------|---------|
308
- | GET | `/` | Server status & metrics | `curl http://localhost:8080/` |
309
- | GET | `/api/robots` | List all robots | `curl http://localhost:8080/api/robots` |
310
- | POST | `/api/robots` | Create new robot | `curl -X POST -H "Content-Type: application/json" -d '{"robot_type":"so-arm100","name":"My Robot"}' http://localhost:8080/api/robots` |
311
- | GET | `/api/robots/{id}` | Get robot details | `curl http://localhost:8080/api/robots/robot-123` |
312
- | GET | `/api/robots/{id}/status` | Get connection status | `curl http://localhost:8080/api/robots/robot-123/status` |
313
- | DELETE | `/api/robots/{id}` | Delete robot | `curl -X DELETE http://localhost:8080/api/robots/robot-123` |
314
 
315
  ### Sequence Control
316
 
317
- | Method | Endpoint | Description | Example |
318
- |--------|----------|-------------|---------|
319
- | GET | `/api/sequences` | List demo sequences | `curl http://localhost:8080/api/sequences` |
320
- | POST | `/api/robots/{id}/play-sequence/{seq_id}` | Play sequence | `curl -X POST http://localhost:8080/api/robots/robot-123/play-sequence/gentle-wave` |
321
- | POST | `/api/robots/{id}/stop-sequence` | Stop sequences | `curl -X POST http://localhost:8080/api/robots/robot-123/stop-sequence` |
322
 
323
  ### WebSocket Endpoints
324
 
325
- | Endpoint | Purpose | Client Type | Example |
326
- |----------|---------|-------------|---------|
327
- | `/ws/master/{robot_id}` | Send commands | Control sources | `ws://localhost:8080/ws/master/robot-123` |
328
- | `/ws/slave/{robot_id}` | Receive commands | Execution targets | `ws://localhost:8080/ws/slave/robot-123` |
329
 
330
  ## 🎯 Usage Scenarios
331
 
332
  ### 1. 🧪 Development & Testing
 
333
  ```bash
334
  # Create robot with mock slave for safe testing
335
  robot = await robotManager.createRobot('test-bot', urdfConfig);
@@ -338,12 +361,14 @@ await robotManager.connectDemoSequences('test-bot');
338
  ```
339
 
340
  **Perfect for:**
 
341
  - Algorithm development without hardware risk
342
  - UI/UX testing with realistic feedback
343
  - Automated testing pipelines
344
  - Demo presentations
345
 
346
  ### 2. 🦾 Physical Robot Control
 
347
  ```bash
348
  # Connect real hardware
349
  robot = await robotManager.createRobot('real-bot', urdfConfig);
@@ -355,35 +380,39 @@ await robotManager.connectDemoSequences('real-bot');
355
  ```
356
 
357
  **Calibration Workflow:**
 
358
  1. Connect USB slave to robot
359
  2. Manually position to match 3D model rest pose
360
  3. Click "Calibrate" to sync coordinate systems
361
  4. Robot now mirrors 3D model movements precisely
362
 
363
  ### 3. 🌐 Remote Operation
 
364
  ```bash
365
  # Master controls slave over internet
366
  # Master side:
367
  await robotManager.connectMaster('local-avatar', {
368
- type: "remote-server",
369
  url: "wss://robot-farm.com:8080"
370
  });
371
 
372
  # Slave side (at robot location):
373
  await robotManager.connectSlave('physical-robot', {
374
  type: "websocket-slave",
375
- url: "wss://robot-farm.com:8080",
376
  robotId: "local-avatar"
377
  });
378
  ```
379
 
380
  **Use Cases:**
 
381
  - Telepresence robotics
382
  - Remote maintenance and inspection
383
  - Distributed manufacturing
384
  - Educational robot sharing
385
 
386
  ### 4. 🤖 Multi-Robot Coordination
 
387
  ```bash
388
  # One master controlling multiple robots
389
  await robotManager.connectMaster('fleet-commander', masterConfig);
@@ -395,12 +424,14 @@ for (const robot of robotFleet) {
395
  ```
396
 
397
  **Applications:**
 
398
  - Synchronized dance performances
399
  - Assembly line coordination
400
  - Swarm robotics research
401
  - Entertainment shows
402
 
403
  ### 5. 🧠 AI Agent Integration
 
404
  ```javascript
405
  // AI agent as master driver
406
  class AIAgentMaster implements MasterDriver {
@@ -417,26 +448,28 @@ await robot.setMaster(new AIAgentMaster(config));
417
  ## 🔧 Integration Guide
418
 
419
  ### Frontend Integration (Svelte)
 
420
  ```typescript
421
- import { robotManager } from '$lib/robot/RobotManager.svelte';
422
- import { robotUrdfConfigMap } from '$lib/configs/robotUrdfConfig';
423
 
424
  // Create robot
425
- const robot = await robotManager.createRobot('my-robot', robotUrdfConfigMap['so-arm100']);
426
 
427
- // Add visualization
428
- await robotManager.connectMockSlave('my-robot');
429
 
430
  // Add control
431
- await robotManager.connectDemoSequences('my-robot', true);
432
 
433
  // Monitor state
434
- robot.joints.forEach(joint => {
435
- console.log(`${joint.name}: virtual=${joint.virtualValue}° real=${joint.realValue}°`);
436
  });
437
  ```
438
 
439
  ### Backend Integration (Python)
 
440
  ```python
441
  import asyncio
442
  import websockets
@@ -455,7 +488,7 @@ async def robot_controller():
455
  }
456
  }
457
  await websocket.send(json.dumps(command))
458
-
459
  # Listen for responses
460
  async for message in websocket:
461
  data = json.loads(message)
@@ -466,6 +499,7 @@ asyncio.run(robot_controller())
466
  ```
467
 
468
  ### Hardware Integration (Arduino/C++)
 
469
  ```cpp
470
  #include <WiFi.h>
471
  #include <WebSocketsClient.h>
@@ -477,7 +511,7 @@ void onWebSocketEvent(WStype_t type, uint8_t * payload, size_t length) {
477
  if (type == WStype_TEXT) {
478
  DynamicJsonDocument doc(1024);
479
  deserializeJson(doc, payload);
480
-
481
  if (doc["type"] == "execute_command") {
482
  JsonArray joints = doc["data"]["joints"];
483
  for (JsonObject joint : joints) {
@@ -498,22 +532,28 @@ void setup() {
498
  ## 🛡️ Security & Production
499
 
500
  ### Authentication
 
501
  ```typescript
502
  // API key authentication (planned)
503
- const master = new RemoteServerMaster({
504
- type: "remote-server",
505
- url: "wss://secure-robot-farm.com:8080",
506
- apiKey: "your-secret-api-key"
507
- }, robotId);
 
 
 
508
  ```
509
 
510
  ### TLS/SSL
 
511
  ```bash
512
  # Production deployment with SSL
513
  uvicorn main:app --host 0.0.0.0 --port 443 --ssl-keyfile key.pem --ssl-certfile cert.pem
514
  ```
515
 
516
  ### Rate Limiting & Safety
 
517
  ```python
518
  # Built-in protections
519
  - Command rate limiting (100 commands/second max)
@@ -526,11 +566,13 @@ uvicorn main:app --host 0.0.0.0 --port 443 --ssl-keyfile key.pem --ssl-certfile
526
  ## 🚀 Deployment Options
527
 
528
  ### Development
 
529
  ```bash
530
  cd src-python && python start_server.py
531
  ```
532
 
533
  ### Docker
 
534
  ```dockerfile
535
  FROM python:3.12-slim
536
  COPY src-python/ /app/
@@ -541,12 +583,14 @@ CMD ["python", "start_server.py"]
541
  ```
542
 
543
  ### Cloud (Railway/Heroku)
 
544
  ```bash
545
  # Procfile
546
  web: cd src-python && python start_server.py
547
  ```
548
 
549
  ### Raspberry Pi (Edge)
 
550
  ```bash
551
  # systemd service for autostart
552
  sudo systemctl enable lerobot-arena
@@ -556,25 +600,28 @@ sudo systemctl start lerobot-arena
556
  ## 🧪 Testing & Debugging
557
 
558
  ### Unit Tests
 
559
  ```bash
560
  cd src-python
561
  pytest tests/ -v
562
  ```
563
 
564
  ### Integration Tests
 
565
  ```javascript
566
  // Frontend testing
567
- import { expect, test } from '@playwright/test';
568
 
569
- test('robot creation and control', async ({ page }) => {
570
- await page.goto('/');
571
- await page.click('[data-testid="create-robot"]');
572
- await page.click('[data-testid="connect-demo-sequences"]');
573
- await expect(page.locator('[data-testid="robot-status"]')).toContainText('Master + Slaves');
574
  });
575
  ```
576
 
577
  ### Debug Mode
 
578
  ```bash
579
  # Enable verbose logging
580
  export LOG_LEVEL=DEBUG
@@ -586,6 +633,7 @@ npm run dev
586
  ```
587
 
588
  ### Health Monitoring
 
589
  ```bash
590
  # Check server health
591
  curl http://localhost:8080/
@@ -597,18 +645,21 @@ curl http://localhost:8080/api/robots
597
  ## 🔮 Roadmap
598
 
599
  ### v2.0 - Enhanced Control
 
600
  - [ ] **Script Player Master**: Execute Python/JS scripts
601
  - [ ] **Simulation Slave**: Physics-based simulation
602
  - [ ] **Force Control**: Torque and compliance modes
603
  - [ ] **Vision Integration**: Camera feeds and computer vision
604
 
605
  ### v2.1 - Enterprise Features
 
606
  - [ ] **Authentication**: JWT tokens and user management
607
  - [ ] **Multi-tenancy**: Isolated robot fleets per organization
608
  - [ ] **Monitoring**: Prometheus metrics and Grafana dashboards
609
  - [ ] **Recording**: Command sequences and replay
610
 
611
  ### v2.2 - Advanced Robotics
 
612
  - [ ] **Path Planning**: Trajectory optimization
613
  - [ ] **Collision Detection**: Safety in shared workspaces
614
  - [ ] **AI Integration**: Reinforcement learning environments
@@ -617,12 +668,13 @@ curl http://localhost:8080/api/robots
617
  ## 🤝 Contributing
618
 
619
  ### Development Setup
 
620
  ```bash
621
  # Frontend
622
  npm install
623
  npm run dev
624
 
625
- # Backend
626
  cd src-python
627
  uv sync
628
  python start_server.py
@@ -633,11 +685,13 @@ cd src-python && pytest
633
  ```
634
 
635
  ### Code Style
 
636
  - **TypeScript**: ESLint + Prettier
637
  - **Python**: Black + isort + mypy
638
  - **Commits**: Conventional commits format
639
 
640
  ### Pull Request Process
 
641
  1. Fork repository
642
  2. Create feature branch
643
  3. Add tests for new functionality
@@ -653,4 +707,4 @@ MIT License - Feel free to use in commercial and personal projects.
653
 
654
  **Built with ❤️ for the robotics community**
655
 
656
- *LeRobot Arena bridges the gap between digital twins and physical robots, making robotics accessible to developers, researchers, and enthusiasts worldwide.*
 
5
  ## 🏛️ Core Architecture
6
 
7
  ### Master-Slave Pattern
8
+
9
  ```
10
  ┌─────────────────┐ Commands ┌─────────────────┐ Execution ┌─────────────────┐
11
  │ 🎮 MASTERS │ ───────────────►│ 🤖 ROBOT CORE │ ───────────────►│ 🔧 SLAVES │
 
18
  ```
19
 
20
  **Key Principles:**
21
+
22
  - 🎯 **Single Source of Truth**: Only one master per robot at a time
23
+ - 🔒 **Safety First**: Manual control disabled when master active
24
  - 🌐 **Multi-Modal**: Same commands work across all slave types
25
  - 🔄 **Real-Time Sync**: Virtual and physical states synchronized
26
  - 📡 **Network Native**: Built for remote operation from day one
 
28
  ## 🚀 Quick Start Guide
29
 
30
  ### 1. Server Setup
31
+
32
  ```bash
33
  # Install dependencies
34
  cd src-python
 
40
  ```
41
 
42
  ### 2. Frontend Integration
43
+
44
  ```bash
45
  # In your Svelte app
46
  npm run dev
 
48
  ```
49
 
50
  ### 3. Create & Control Robot
51
+
52
  ```javascript
53
  // Create robot in UI or via API
54
  const robot = await robotManager.createRobot('my-robot', robotUrdfConfig);
 
72
  ## 🎮 Master Drivers (Command Sources)
73
 
74
  ### MockSequenceMaster
75
+
76
  **Pre-programmed movement patterns for testing and demos**
77
 
78
  ```typescript
79
  const config: MasterDriverConfig = {
80
+ type: "mock-sequence",
81
+ sequences: DEMO_SEQUENCES,
82
+ autoStart: true,
83
+ loopMode: true
84
  };
85
 
86
+ await robotManager.connectMaster("robot-1", config);
87
  ```
88
 
89
  **Available Sequences:**
90
+
91
  - **🌊 Gentle Wave Pattern** (6s): Smooth greeting gesture with wrist movements
92
+ - **🔍 Small Scanning Pattern** (8s): Horizontal sweep for environment scanning
93
  - **💪 Tiny Flex Pattern** (8s): Articulation demonstration with elbow/jaw coordination
94
 
95
  **Features:**
96
+
97
  - ✅ Safe movement ranges (±25° max)
98
  - ✅ Smooth interpolation between keyframes
99
  - ✅ Loop mode for continuous operation
100
  - ✅ Automatic master takeover
101
 
102
  ### RemoteServerMaster
103
+
104
  **WebSocket connection to external control systems**
105
 
106
  ```typescript
107
  const config: MasterDriverConfig = {
108
+ type: "remote-server",
109
+ url: "ws://localhost:8080",
110
+ apiKey: "optional-auth-token"
111
  };
112
 
113
+ await robotManager.connectMaster("robot-1", config);
114
  ```
115
 
116
  **Features:**
117
+
118
  - ✅ Real-time bidirectional communication
119
  - ✅ Automatic reconnection with exponential backoff
120
  - ✅ Heartbeat monitoring (30s intervals)
 
122
  - ✅ Status and error reporting
123
 
124
  **Message Format:**
125
+
126
  ```json
127
  {
128
+ "type": "command",
129
+ "timestamp": "2024-01-01T12:00:00Z",
130
+ "data": {
131
+ "timestamp": 1704110400000,
132
+ "joints": [{ "name": "Rotation", "value": 45, "speed": 100 }]
133
+ }
 
 
134
  }
135
  ```
136
 
137
  ## 🔧 Slave Drivers (Execution Targets)
138
 
139
  ### MockSlave
140
+
141
  **Perfect simulation for development and testing**
142
 
143
  ```typescript
144
  const config: SlaveDriverConfig = {
145
+ type: "mock-slave",
146
+ simulateLatency: 50, // Realistic response delay
147
+ simulateErrors: false, // Random connection issues
148
+ responseDelay: 20 // Command execution time
149
  };
150
 
151
+ await robotManager.connectSlave("robot-1", config);
152
  ```
153
 
154
  **Features:**
155
+
156
  - ✅ Perfect command execution (real_value = virtual_value)
157
  - ✅ Configurable network latency simulation
158
  - ✅ Error injection for robustness testing
 
160
  - ✅ Real-time state feedback
161
 
162
  ### USBSlave
163
+
164
  **Direct serial communication with physical robots**
165
 
166
  ```typescript
167
  const config: SlaveDriverConfig = {
168
+ type: "usb-slave",
169
+ port: "/dev/ttyUSB0", // Auto-detect if undefined
170
+ baudRate: 115200
171
  };
172
 
173
+ await robotManager.connectSlave("robot-1", config);
174
  ```
175
 
176
  **Features:**
177
+
178
  - ✅ Feetech servo protocol support
179
  - ✅ Position and speed control
180
  - ✅ Real servo position feedback
 
182
  - ✅ Error handling and recovery
183
 
184
  **Calibration Process:**
185
+
186
  1. Manually position robot to match digital twin
187
  2. Click "Calibrate" to sync virtual/real coordinates
188
  3. System calculates offset: `real_pos = raw_pos + offset`
189
  4. All future commands automatically compensated
190
 
191
  ### WebSocketSlave
192
+
193
  **Remote robot control via WebSocket relay**
194
 
195
  ```typescript
196
  const config: SlaveDriverConfig = {
197
+ type: "websocket-slave",
198
+ url: "ws://robot-proxy:8080",
199
+ robotId: "remote-arm-1"
200
  };
201
 
202
+ await robotManager.connectSlave("robot-1", config);
203
  ```
204
 
205
  **Use Cases:**
206
+
207
  - 🌐 Control robots across internet
208
  - 🏢 Enterprise robot fleet management
209
  - 🔒 Firewall-friendly robot access
 
214
  ### Master → Server Communication
215
 
216
  #### Send Joint Command
217
+
218
  ```json
219
  {
220
+ "type": "command",
221
+ "timestamp": "2024-01-01T12:00:00Z",
222
+ "data": {
223
+ "timestamp": 1704110400000,
224
+ "joints": [
225
+ { "name": "Rotation", "value": 45, "speed": 100 },
226
+ { "name": "Pitch", "value": -30, "speed": 150 }
227
+ ],
228
+ "duration": 2000,
229
+ "metadata": { "source": "manual_control" }
230
+ }
231
  }
232
  ```
233
 
234
  #### Send Movement Sequence
235
+
236
  ```json
237
  {
238
+ "type": "sequence",
239
+ "timestamp": "2024-01-01T12:00:00Z",
240
+ "data": {
241
+ "id": "custom-dance",
242
+ "name": "Custom Dance Sequence",
243
+ "commands": [
244
+ {
245
+ "timestamp": 0,
246
+ "joints": [{ "name": "Rotation", "value": -30 }],
247
+ "duration": 1000
248
+ },
249
+ {
250
+ "timestamp": 1000,
251
+ "joints": [{ "name": "Rotation", "value": 30 }],
252
+ "duration": 1000
253
+ }
254
+ ],
255
+ "total_duration": 2000,
256
+ "loop": false
257
+ }
258
  }
259
  ```
260
 
261
  #### Heartbeat
262
+
263
  ```json
264
  {
265
+ "type": "heartbeat",
266
+ "timestamp": "2024-01-01T12:00:00Z"
267
  }
268
  ```
269
 
270
  ### Slave → Server Communication
271
 
272
  #### Status Update
273
+
274
  ```json
275
  {
276
+ "type": "status_update",
277
+ "timestamp": "2024-01-01T12:00:00Z",
278
+ "data": {
279
+ "isConnected": true,
280
+ "lastConnected": "2024-01-01T11:58:00Z",
281
+ "error": null
282
+ }
283
  }
284
  ```
285
 
286
  #### Joint State Feedback
287
+
288
  ```json
289
  {
290
+ "type": "joint_states",
291
+ "timestamp": "2024-01-01T12:00:00Z",
292
+ "data": [
293
+ {
294
+ "name": "Rotation",
295
+ "servo_id": 1,
296
+ "type": "revolute",
297
+ "virtual_value": 45.0,
298
+ "real_value": 44.8,
299
+ "limits": {
300
+ "lower": -180,
301
+ "upper": 180,
302
+ "velocity": 200
303
+ }
304
+ }
305
+ ]
306
  }
307
  ```
308
 
309
  #### Error Reporting
310
+
311
  ```json
312
  {
313
+ "type": "error",
314
+ "timestamp": "2024-01-01T12:00:00Z",
315
+ "data": {
316
+ "code": "SERVO_TIMEOUT",
317
+ "message": "Servo 3 not responding",
318
+ "joint": "Elbow",
319
+ "severity": "warning"
320
+ }
321
  }
322
  ```
323
 
 
325
 
326
  ### Robot Management
327
 
328
+ | Method | Endpoint | Description | Example |
329
+ | ------ | ------------------------- | ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------- |
330
+ | GET | `/` | Server status & metrics | `curl http://localhost:8080/` |
331
+ | GET | `/api/robots` | List all robots | `curl http://localhost:8080/api/robots` |
332
+ | POST | `/api/robots` | Create new robot | `curl -X POST -H "Content-Type: application/json" -d '{"robot_type":"so-arm100","name":"My Robot"}' http://localhost:8080/api/robots` |
333
+ | GET | `/api/robots/{id}` | Get robot details | `curl http://localhost:8080/api/robots/robot-123` |
334
+ | GET | `/api/robots/{id}/status` | Get connection status | `curl http://localhost:8080/api/robots/robot-123/status` |
335
+ | DELETE | `/api/robots/{id}` | Delete robot | `curl -X DELETE http://localhost:8080/api/robots/robot-123` |
336
 
337
  ### Sequence Control
338
 
339
+ | Method | Endpoint | Description | Example |
340
+ | ------ | ----------------------------------------- | ------------------- | ----------------------------------------------------------------------------------- |
341
+ | GET | `/api/sequences` | List demo sequences | `curl http://localhost:8080/api/sequences` |
342
+ | POST | `/api/robots/{id}/play-sequence/{seq_id}` | Play sequence | `curl -X POST http://localhost:8080/api/robots/robot-123/play-sequence/gentle-wave` |
343
+ | POST | `/api/robots/{id}/stop-sequence` | Stop sequences | `curl -X POST http://localhost:8080/api/robots/robot-123/stop-sequence` |
344
 
345
  ### WebSocket Endpoints
346
 
347
+ | Endpoint | Purpose | Client Type | Example |
348
+ | ----------------------- | ---------------- | ----------------- | ----------------------------------------- |
349
+ | `/ws/master/{robot_id}` | Send commands | Control sources | `ws://localhost:8080/ws/master/robot-123` |
350
+ | `/ws/slave/{robot_id}` | Receive commands | Execution targets | `ws://localhost:8080/ws/slave/robot-123` |
351
 
352
  ## 🎯 Usage Scenarios
353
 
354
  ### 1. 🧪 Development & Testing
355
+
356
  ```bash
357
  # Create robot with mock slave for safe testing
358
  robot = await robotManager.createRobot('test-bot', urdfConfig);
 
361
  ```
362
 
363
  **Perfect for:**
364
+
365
  - Algorithm development without hardware risk
366
  - UI/UX testing with realistic feedback
367
  - Automated testing pipelines
368
  - Demo presentations
369
 
370
  ### 2. 🦾 Physical Robot Control
371
+
372
  ```bash
373
  # Connect real hardware
374
  robot = await robotManager.createRobot('real-bot', urdfConfig);
 
380
  ```
381
 
382
  **Calibration Workflow:**
383
+
384
  1. Connect USB slave to robot
385
  2. Manually position to match 3D model rest pose
386
  3. Click "Calibrate" to sync coordinate systems
387
  4. Robot now mirrors 3D model movements precisely
388
 
389
  ### 3. 🌐 Remote Operation
390
+
391
  ```bash
392
  # Master controls slave over internet
393
  # Master side:
394
  await robotManager.connectMaster('local-avatar', {
395
+ type: "remote-server",
396
  url: "wss://robot-farm.com:8080"
397
  });
398
 
399
  # Slave side (at robot location):
400
  await robotManager.connectSlave('physical-robot', {
401
  type: "websocket-slave",
402
+ url: "wss://robot-farm.com:8080",
403
  robotId: "local-avatar"
404
  });
405
  ```
406
 
407
  **Use Cases:**
408
+
409
  - Telepresence robotics
410
  - Remote maintenance and inspection
411
  - Distributed manufacturing
412
  - Educational robot sharing
413
 
414
  ### 4. 🤖 Multi-Robot Coordination
415
+
416
  ```bash
417
  # One master controlling multiple robots
418
  await robotManager.connectMaster('fleet-commander', masterConfig);
 
424
  ```
425
 
426
  **Applications:**
427
+
428
  - Synchronized dance performances
429
  - Assembly line coordination
430
  - Swarm robotics research
431
  - Entertainment shows
432
 
433
  ### 5. 🧠 AI Agent Integration
434
+
435
  ```javascript
436
  // AI agent as master driver
437
  class AIAgentMaster implements MasterDriver {
 
448
  ## 🔧 Integration Guide
449
 
450
  ### Frontend Integration (Svelte)
451
+
452
  ```typescript
453
+ import { robotManager } from "$lib/robot/RobotManager.svelte";
454
+ import { robotUrdfConfigMap } from "$lib/configs/robotUrdfConfig";
455
 
456
  // Create robot
457
+ const robot = await robotManager.createRobot("my-robot", robotUrdfConfigMap["so-arm100"]);
458
 
459
+ // Add visualization
460
+ await robotManager.connectMockSlave("my-robot");
461
 
462
  // Add control
463
+ await robotManager.connectDemoSequences("my-robot", true);
464
 
465
  // Monitor state
466
+ robot.joints.forEach((joint) => {
467
+ console.log(`${joint.name}: virtual=${joint.virtualValue}° real=${joint.realValue}°`);
468
  });
469
  ```
470
 
471
  ### Backend Integration (Python)
472
+
473
  ```python
474
  import asyncio
475
  import websockets
 
488
  }
489
  }
490
  await websocket.send(json.dumps(command))
491
+
492
  # Listen for responses
493
  async for message in websocket:
494
  data = json.loads(message)
 
499
  ```
500
 
501
  ### Hardware Integration (Arduino/C++)
502
+
503
  ```cpp
504
  #include <WiFi.h>
505
  #include <WebSocketsClient.h>
 
511
  if (type == WStype_TEXT) {
512
  DynamicJsonDocument doc(1024);
513
  deserializeJson(doc, payload);
514
+
515
  if (doc["type"] == "execute_command") {
516
  JsonArray joints = doc["data"]["joints"];
517
  for (JsonObject joint : joints) {
 
532
  ## 🛡️ Security & Production
533
 
534
  ### Authentication
535
+
536
  ```typescript
537
  // API key authentication (planned)
538
+ const master = new RemoteServerMaster(
539
+ {
540
+ type: "remote-server",
541
+ url: "wss://secure-robot-farm.com:8080",
542
+ apiKey: "your-secret-api-key"
543
+ },
544
+ robotId
545
+ );
546
  ```
547
 
548
  ### TLS/SSL
549
+
550
  ```bash
551
  # Production deployment with SSL
552
  uvicorn main:app --host 0.0.0.0 --port 443 --ssl-keyfile key.pem --ssl-certfile cert.pem
553
  ```
554
 
555
  ### Rate Limiting & Safety
556
+
557
  ```python
558
  # Built-in protections
559
  - Command rate limiting (100 commands/second max)
 
566
  ## 🚀 Deployment Options
567
 
568
  ### Development
569
+
570
  ```bash
571
  cd src-python && python start_server.py
572
  ```
573
 
574
  ### Docker
575
+
576
  ```dockerfile
577
  FROM python:3.12-slim
578
  COPY src-python/ /app/
 
583
  ```
584
 
585
  ### Cloud (Railway/Heroku)
586
+
587
  ```bash
588
  # Procfile
589
  web: cd src-python && python start_server.py
590
  ```
591
 
592
  ### Raspberry Pi (Edge)
593
+
594
  ```bash
595
  # systemd service for autostart
596
  sudo systemctl enable lerobot-arena
 
600
  ## 🧪 Testing & Debugging
601
 
602
  ### Unit Tests
603
+
604
  ```bash
605
  cd src-python
606
  pytest tests/ -v
607
  ```
608
 
609
  ### Integration Tests
610
+
611
  ```javascript
612
  // Frontend testing
613
+ import { expect, test } from "@playwright/test";
614
 
615
+ test("robot creation and control", async ({ page }) => {
616
+ await page.goto("/");
617
+ await page.click('[data-testid="create-robot"]');
618
+ await page.click('[data-testid="connect-demo-sequences"]');
619
+ await expect(page.locator('[data-testid="robot-status"]')).toContainText("Master + Slaves");
620
  });
621
  ```
622
 
623
  ### Debug Mode
624
+
625
  ```bash
626
  # Enable verbose logging
627
  export LOG_LEVEL=DEBUG
 
633
  ```
634
 
635
  ### Health Monitoring
636
+
637
  ```bash
638
  # Check server health
639
  curl http://localhost:8080/
 
645
  ## 🔮 Roadmap
646
 
647
  ### v2.0 - Enhanced Control
648
+
649
  - [ ] **Script Player Master**: Execute Python/JS scripts
650
  - [ ] **Simulation Slave**: Physics-based simulation
651
  - [ ] **Force Control**: Torque and compliance modes
652
  - [ ] **Vision Integration**: Camera feeds and computer vision
653
 
654
  ### v2.1 - Enterprise Features
655
+
656
  - [ ] **Authentication**: JWT tokens and user management
657
  - [ ] **Multi-tenancy**: Isolated robot fleets per organization
658
  - [ ] **Monitoring**: Prometheus metrics and Grafana dashboards
659
  - [ ] **Recording**: Command sequences and replay
660
 
661
  ### v2.2 - Advanced Robotics
662
+
663
  - [ ] **Path Planning**: Trajectory optimization
664
  - [ ] **Collision Detection**: Safety in shared workspaces
665
  - [ ] **AI Integration**: Reinforcement learning environments
 
668
  ## 🤝 Contributing
669
 
670
  ### Development Setup
671
+
672
  ```bash
673
  # Frontend
674
  npm install
675
  npm run dev
676
 
677
+ # Backend
678
  cd src-python
679
  uv sync
680
  python start_server.py
 
685
  ```
686
 
687
  ### Code Style
688
+
689
  - **TypeScript**: ESLint + Prettier
690
  - **Python**: Black + isort + mypy
691
  - **Commits**: Conventional commits format
692
 
693
  ### Pull Request Process
694
+
695
  1. Fork repository
696
  2. Create feature branch
697
  3. Add tests for new functionality
 
707
 
708
  **Built with ❤️ for the robotics community**
709
 
710
+ _LeRobot Arena bridges the gap between digital twins and physical robots, making robotics accessible to developers, researchers, and enthusiasts worldwide._
src/app.css CHANGED
@@ -6,117 +6,117 @@
6
  @custom-variant dark (&:is(.dark *));
7
 
8
  :root {
9
- --radius: 0.625rem;
10
- --background: oklch(1 0 0);
11
- --foreground: oklch(0.147 0.004 49.25);
12
- --card: oklch(1 0 0);
13
- --card-foreground: oklch(0.147 0.004 49.25);
14
- --popover: oklch(1 0 0);
15
- --popover-foreground: oklch(0.147 0.004 49.25);
16
- --primary: oklch(0.216 0.006 56.043);
17
- --primary-foreground: oklch(0.985 0.001 106.423);
18
- --secondary: oklch(0.97 0.001 106.424);
19
- --secondary-foreground: oklch(0.216 0.006 56.043);
20
- --muted: oklch(0.97 0.001 106.424);
21
- --muted-foreground: oklch(0.553 0.013 58.071);
22
- --accent: oklch(0.97 0.001 106.424);
23
- --accent-foreground: oklch(0.216 0.006 56.043);
24
- --destructive: oklch(0.577 0.245 27.325);
25
- --border: oklch(0.923 0.003 48.717);
26
- --input: oklch(0.923 0.003 48.717);
27
- --ring: oklch(0.709 0.01 56.259);
28
- --chart-1: oklch(0.646 0.222 41.116);
29
- --chart-2: oklch(0.6 0.118 184.704);
30
- --chart-3: oklch(0.398 0.07 227.392);
31
- --chart-4: oklch(0.828 0.189 84.429);
32
- --chart-5: oklch(0.769 0.188 70.08);
33
- --sidebar: oklch(0.985 0.001 106.423);
34
- --sidebar-foreground: oklch(0.147 0.004 49.25);
35
- --sidebar-primary: oklch(0.216 0.006 56.043);
36
- --sidebar-primary-foreground: oklch(0.985 0.001 106.423);
37
- --sidebar-accent: oklch(0.97 0.001 106.424);
38
- --sidebar-accent-foreground: oklch(0.216 0.006 56.043);
39
- --sidebar-border: oklch(0.923 0.003 48.717);
40
- --sidebar-ring: oklch(0.709 0.01 56.259);
41
  }
42
 
43
  .dark {
44
- --background: oklch(0.147 0.004 49.25);
45
- --foreground: oklch(0.985 0.001 106.423);
46
- --card: oklch(0.216 0.006 56.043);
47
- --card-foreground: oklch(0.985 0.001 106.423);
48
- --popover: oklch(0.216 0.006 56.043);
49
- --popover-foreground: oklch(0.985 0.001 106.423);
50
- --primary: oklch(0.923 0.003 48.717);
51
- --primary-foreground: oklch(0.216 0.006 56.043);
52
- --secondary: oklch(0.268 0.007 34.298);
53
- --secondary-foreground: oklch(0.985 0.001 106.423);
54
- --muted: oklch(0.268 0.007 34.298);
55
- --muted-foreground: oklch(0.709 0.01 56.259);
56
- --accent: oklch(0.268 0.007 34.298);
57
- --accent-foreground: oklch(0.985 0.001 106.423);
58
- --destructive: oklch(0.704 0.191 22.216);
59
- --border: oklch(1 0 0 / 10%);
60
- --input: oklch(1 0 0 / 15%);
61
- --ring: oklch(0.553 0.013 58.071);
62
- --chart-1: oklch(0.488 0.243 264.376);
63
- --chart-2: oklch(0.696 0.17 162.48);
64
- --chart-3: oklch(0.769 0.188 70.08);
65
- --chart-4: oklch(0.627 0.265 303.9);
66
- --chart-5: oklch(0.645 0.246 16.439);
67
- --sidebar: oklch(0.216 0.006 56.043);
68
- --sidebar-foreground: oklch(0.985 0.001 106.423);
69
- --sidebar-primary: oklch(0.488 0.243 264.376);
70
- --sidebar-primary-foreground: oklch(0.985 0.001 106.423);
71
- --sidebar-accent: oklch(0.268 0.007 34.298);
72
- --sidebar-accent-foreground: oklch(0.985 0.001 106.423);
73
- --sidebar-border: oklch(1 0 0 / 10%);
74
- --sidebar-ring: oklch(0.553 0.013 58.071);
75
  }
76
 
77
  @theme inline {
78
- --radius-sm: calc(var(--radius) - 4px);
79
- --radius-md: calc(var(--radius) - 2px);
80
- --radius-lg: var(--radius);
81
- --radius-xl: calc(var(--radius) + 4px);
82
- --color-background: var(--background);
83
- --color-foreground: var(--foreground);
84
- --color-card: var(--card);
85
- --color-card-foreground: var(--card-foreground);
86
- --color-popover: var(--popover);
87
- --color-popover-foreground: var(--popover-foreground);
88
- --color-primary: var(--primary);
89
- --color-primary-foreground: var(--primary-foreground);
90
- --color-secondary: var(--secondary);
91
- --color-secondary-foreground: var(--secondary-foreground);
92
- --color-muted: var(--muted);
93
- --color-muted-foreground: var(--muted-foreground);
94
- --color-accent: var(--accent);
95
- --color-accent-foreground: var(--accent-foreground);
96
- --color-destructive: var(--destructive);
97
- --color-border: var(--border);
98
- --color-input: var(--input);
99
- --color-ring: var(--ring);
100
- --color-chart-1: var(--chart-1);
101
- --color-chart-2: var(--chart-2);
102
- --color-chart-3: var(--chart-3);
103
- --color-chart-4: var(--chart-4);
104
- --color-chart-5: var(--chart-5);
105
- --color-sidebar: var(--sidebar);
106
- --color-sidebar-foreground: var(--sidebar-foreground);
107
- --color-sidebar-primary: var(--sidebar-primary);
108
- --color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
109
- --color-sidebar-accent: var(--sidebar-accent);
110
- --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
111
- --color-sidebar-border: var(--sidebar-border);
112
- --color-sidebar-ring: var(--sidebar-ring);
113
  }
114
 
115
  @layer base {
116
- * {
117
- @apply border-border outline-ring/50;
118
- }
119
- body {
120
- @apply bg-background text-foreground;
121
- }
122
- }
 
6
  @custom-variant dark (&:is(.dark *));
7
 
8
  :root {
9
+ --radius: 0.625rem;
10
+ --background: oklch(1 0 0);
11
+ --foreground: oklch(0.147 0.004 49.25);
12
+ --card: oklch(1 0 0);
13
+ --card-foreground: oklch(0.147 0.004 49.25);
14
+ --popover: oklch(1 0 0);
15
+ --popover-foreground: oklch(0.147 0.004 49.25);
16
+ --primary: oklch(0.216 0.006 56.043);
17
+ --primary-foreground: oklch(0.985 0.001 106.423);
18
+ --secondary: oklch(0.97 0.001 106.424);
19
+ --secondary-foreground: oklch(0.216 0.006 56.043);
20
+ --muted: oklch(0.97 0.001 106.424);
21
+ --muted-foreground: oklch(0.553 0.013 58.071);
22
+ --accent: oklch(0.97 0.001 106.424);
23
+ --accent-foreground: oklch(0.216 0.006 56.043);
24
+ --destructive: oklch(0.577 0.245 27.325);
25
+ --border: oklch(0.923 0.003 48.717);
26
+ --input: oklch(0.923 0.003 48.717);
27
+ --ring: oklch(0.709 0.01 56.259);
28
+ --chart-1: oklch(0.646 0.222 41.116);
29
+ --chart-2: oklch(0.6 0.118 184.704);
30
+ --chart-3: oklch(0.398 0.07 227.392);
31
+ --chart-4: oklch(0.828 0.189 84.429);
32
+ --chart-5: oklch(0.769 0.188 70.08);
33
+ --sidebar: oklch(0.985 0.001 106.423);
34
+ --sidebar-foreground: oklch(0.147 0.004 49.25);
35
+ --sidebar-primary: oklch(0.216 0.006 56.043);
36
+ --sidebar-primary-foreground: oklch(0.985 0.001 106.423);
37
+ --sidebar-accent: oklch(0.97 0.001 106.424);
38
+ --sidebar-accent-foreground: oklch(0.216 0.006 56.043);
39
+ --sidebar-border: oklch(0.923 0.003 48.717);
40
+ --sidebar-ring: oklch(0.709 0.01 56.259);
41
  }
42
 
43
  .dark {
44
+ --background: oklch(0.147 0.004 49.25);
45
+ --foreground: oklch(0.985 0.001 106.423);
46
+ --card: oklch(0.216 0.006 56.043);
47
+ --card-foreground: oklch(0.985 0.001 106.423);
48
+ --popover: oklch(0.216 0.006 56.043);
49
+ --popover-foreground: oklch(0.985 0.001 106.423);
50
+ --primary: oklch(0.923 0.003 48.717);
51
+ --primary-foreground: oklch(0.216 0.006 56.043);
52
+ --secondary: oklch(0.268 0.007 34.298);
53
+ --secondary-foreground: oklch(0.985 0.001 106.423);
54
+ --muted: oklch(0.268 0.007 34.298);
55
+ --muted-foreground: oklch(0.709 0.01 56.259);
56
+ --accent: oklch(0.268 0.007 34.298);
57
+ --accent-foreground: oklch(0.985 0.001 106.423);
58
+ --destructive: oklch(0.704 0.191 22.216);
59
+ --border: oklch(1 0 0 / 10%);
60
+ --input: oklch(1 0 0 / 15%);
61
+ --ring: oklch(0.553 0.013 58.071);
62
+ --chart-1: oklch(0.488 0.243 264.376);
63
+ --chart-2: oklch(0.696 0.17 162.48);
64
+ --chart-3: oklch(0.769 0.188 70.08);
65
+ --chart-4: oklch(0.627 0.265 303.9);
66
+ --chart-5: oklch(0.645 0.246 16.439);
67
+ --sidebar: oklch(0.216 0.006 56.043);
68
+ --sidebar-foreground: oklch(0.985 0.001 106.423);
69
+ --sidebar-primary: oklch(0.488 0.243 264.376);
70
+ --sidebar-primary-foreground: oklch(0.985 0.001 106.423);
71
+ --sidebar-accent: oklch(0.268 0.007 34.298);
72
+ --sidebar-accent-foreground: oklch(0.985 0.001 106.423);
73
+ --sidebar-border: oklch(1 0 0 / 10%);
74
+ --sidebar-ring: oklch(0.553 0.013 58.071);
75
  }
76
 
77
  @theme inline {
78
+ --radius-sm: calc(var(--radius) - 4px);
79
+ --radius-md: calc(var(--radius) - 2px);
80
+ --radius-lg: var(--radius);
81
+ --radius-xl: calc(var(--radius) + 4px);
82
+ --color-background: var(--background);
83
+ --color-foreground: var(--foreground);
84
+ --color-card: var(--card);
85
+ --color-card-foreground: var(--card-foreground);
86
+ --color-popover: var(--popover);
87
+ --color-popover-foreground: var(--popover-foreground);
88
+ --color-primary: var(--primary);
89
+ --color-primary-foreground: var(--primary-foreground);
90
+ --color-secondary: var(--secondary);
91
+ --color-secondary-foreground: var(--secondary-foreground);
92
+ --color-muted: var(--muted);
93
+ --color-muted-foreground: var(--muted-foreground);
94
+ --color-accent: var(--accent);
95
+ --color-accent-foreground: var(--accent-foreground);
96
+ --color-destructive: var(--destructive);
97
+ --color-border: var(--border);
98
+ --color-input: var(--input);
99
+ --color-ring: var(--ring);
100
+ --color-chart-1: var(--chart-1);
101
+ --color-chart-2: var(--chart-2);
102
+ --color-chart-3: var(--chart-3);
103
+ --color-chart-4: var(--chart-4);
104
+ --color-chart-5: var(--chart-5);
105
+ --color-sidebar: var(--sidebar);
106
+ --color-sidebar-foreground: var(--sidebar-foreground);
107
+ --color-sidebar-primary: var(--sidebar-primary);
108
+ --color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
109
+ --color-sidebar-accent: var(--sidebar-accent);
110
+ --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
111
+ --color-sidebar-border: var(--sidebar-border);
112
+ --color-sidebar-ring: var(--sidebar-ring);
113
  }
114
 
115
  @layer base {
116
+ * {
117
+ @apply border-border outline-ring/50;
118
+ }
119
+ body {
120
+ @apply bg-background text-foreground;
121
+ }
122
+ }
src/lib/components/3d/GridCustom.svelte ADDED
@@ -0,0 +1,379 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!-- Credits to Fyrestar for the https://github.com/Fyrestar/THREE.InfiniteGridHelper -->
2
+ <script lang="ts">
3
+ import { T, useTask, useThrelte } from "@threlte/core";
4
+ import { Color, DoubleSide, Plane, Vector3, Mesh } from "three";
5
+ import * as THREE from "three";
6
+
7
+ // Grid shader code with improved precision and stability
8
+ import { revision } from "@threlte/core";
9
+
10
+ // Props
11
+ let {
12
+ cellColor = "#71717A",
13
+ sectionColor = "#707070",
14
+ cellSize = 1,
15
+ backgroundColor = "#000000",
16
+ backgroundOpacity = 0,
17
+ sectionSize = 10,
18
+ plane = "xz",
19
+ gridSize = [20, 20],
20
+ followCamera = false,
21
+ infiniteGrid = false,
22
+ fadeDistance = 100,
23
+ fadeStrength = 1,
24
+ fadeOrigin = undefined,
25
+ cellThickness = 1,
26
+ sectionThickness = 2,
27
+ side = DoubleSide,
28
+ type = "grid",
29
+ axis = "x",
30
+ maxRadius = 0,
31
+ cellDividers = 6,
32
+ sectionDividers = 2,
33
+ floorColor = "#2a2a2a",
34
+ floorOpacity = 0.3,
35
+ ref = $bindable(),
36
+ children = undefined,
37
+ ...props
38
+ } = $props();
39
+
40
+ // Shared fade calculation function for both shaders
41
+ const fadeCalculation = /*glsl*/ `
42
+ float calculateFade(vec3 worldPos, float viewZ, vec3 fadeOrigin, float fadeDistance, float fadeStrength, float cameraNear, float cameraFar) {
43
+ float dist = distance(fadeOrigin, worldPos);
44
+ float fadeFactor = 1.0 - clamp(dist / fadeDistance, 0.0, 1.0);
45
+ fadeFactor = pow(fadeFactor, fadeStrength);
46
+
47
+ float viewDepthFade = 1.0 - clamp((viewZ - cameraNear) / (cameraFar - cameraNear), 0.0, 1.0);
48
+ viewDepthFade = smoothstep(0.0, 0.3, viewDepthFade);
49
+
50
+ return min(fadeFactor, viewDepthFade);
51
+ }
52
+ `;
53
+
54
+ const vertexShader = /*glsl*/ `
55
+ varying vec3 localPosition;
56
+ varying vec4 worldPosition;
57
+ varying float vViewZ;
58
+
59
+ uniform vec3 worldCamProjPosition;
60
+ uniform vec3 worldPlanePosition;
61
+ uniform float fadeDistance;
62
+ uniform bool infiniteGrid;
63
+ uniform bool followCamera;
64
+ uniform int coord0, coord1, coord2;
65
+
66
+ void main() {
67
+ localPosition = vec3(position[coord0], position[coord1], position[coord2]);
68
+ if (infiniteGrid) localPosition *= 1.0 + fadeDistance;
69
+
70
+ worldPosition = modelMatrix * vec4(localPosition, 1.0);
71
+ if (followCamera) {
72
+ worldPosition.xyz += (worldCamProjPosition - worldPlanePosition);
73
+ localPosition = (inverse(modelMatrix) * worldPosition).xyz;
74
+ }
75
+
76
+ vec4 mvPosition = viewMatrix * worldPosition;
77
+ vViewZ = -mvPosition.z;
78
+ gl_Position = projectionMatrix * mvPosition;
79
+ }
80
+ `;
81
+
82
+ const fragmentShader = /*glsl*/ `
83
+ #define PI 3.141592653589793
84
+
85
+ varying vec3 localPosition;
86
+ varying vec4 worldPosition;
87
+ varying float vViewZ;
88
+
89
+ uniform float cellSize, sectionSize, cellThickness, sectionThickness;
90
+ uniform vec3 cellColor, sectionColor, backgroundColor, fadeOrigin;
91
+ uniform float backgroundOpacity, fadeDistance, fadeStrength, cameraNear, cameraFar;
92
+ uniform bool infiniteGrid;
93
+ uniform int coord0, coord1, coord2, gridType, lineGridCoord;
94
+ uniform float circleGridMaxRadius, polarCellDividers, polarSectionDividers;
95
+
96
+ ${fadeCalculation}
97
+
98
+ float getSquareGrid(float size, float thickness, vec3 localPos) {
99
+ vec2 coord = localPos.xy / size;
100
+ vec2 derivative = fwidth(coord);
101
+ vec2 grid = abs(fract(coord - 0.5) - 0.5) / derivative;
102
+ float line = min(grid.x, grid.y) + 1.0 - thickness;
103
+ return clamp(1.0 - line, 0.0, 1.0);
104
+ }
105
+
106
+ float getLinesGrid(float size, float thickness, vec3 localPos) {
107
+ float coord = localPos[lineGridCoord] / size;
108
+ float derivative = fwidth(coord);
109
+ float line = abs(fract(coord - 0.5) - 0.5) / derivative - thickness * 0.5;
110
+ return clamp(1.0 - line, 0.0, 1.0);
111
+ }
112
+
113
+ float getCirclesGrid(float size, float thickness, vec3 localPos) {
114
+ float coord = length(localPos.xy) / size;
115
+ float derivative = fwidth(coord);
116
+ float line = abs(fract(coord - 0.5) - 0.5) / derivative - thickness * 0.5;
117
+ if (!infiniteGrid && circleGridMaxRadius > 0.0 && coord > circleGridMaxRadius + thickness * 0.1) discard;
118
+ return clamp(1.0 - line, 0.0, 1.0);
119
+ }
120
+
121
+ float getPolarGrid(float size, float thickness, float polarDividers, vec3 localPos) {
122
+ float rad = length(localPos.xy) / size;
123
+ vec2 coord = vec2(rad, atan(localPos.x, localPos.y) * polarDividers / PI);
124
+ vec2 derivative = fwidth(coord);
125
+ vec2 grid = abs(fract(coord - 0.5) - 0.5) / derivative;
126
+ float line = min(grid.x, grid.y) + 1.0 - thickness;
127
+ if (!infiniteGrid && circleGridMaxRadius > 0.0 && rad > circleGridMaxRadius + thickness * 0.1) discard;
128
+ return clamp(1.0 - line, 0.0, 1.0);
129
+ }
130
+
131
+ void main() {
132
+ float g1 = 0.0, g2 = 0.0;
133
+ vec3 localPos = vec3(localPosition[coord0], localPosition[coord1], localPosition[coord2]);
134
+
135
+ if (gridType == 0) {
136
+ g1 = getSquareGrid(cellSize, cellThickness, localPos);
137
+ g2 = getSquareGrid(sectionSize, sectionThickness, localPos);
138
+ } else if (gridType == 1) {
139
+ g1 = getLinesGrid(cellSize, cellThickness, localPos);
140
+ g2 = getLinesGrid(sectionSize, sectionThickness, localPos);
141
+ } else if (gridType == 2) {
142
+ g1 = getCirclesGrid(cellSize, cellThickness, localPos);
143
+ g2 = getCirclesGrid(sectionSize, sectionThickness, localPos);
144
+ } else if (gridType == 3) {
145
+ g1 = getPolarGrid(cellSize, cellThickness, polarCellDividers, localPos);
146
+ g2 = getPolarGrid(sectionSize, sectionThickness, polarSectionDividers, localPos);
147
+ }
148
+
149
+ float fadeFactor = calculateFade(worldPosition.xyz, vViewZ, fadeOrigin, fadeDistance, fadeStrength, cameraNear, cameraFar);
150
+ vec3 color = mix(cellColor, sectionColor, clamp(sectionThickness * g2, 0.0, 1.0));
151
+ float gridAlpha = clamp((g1 + g2) * fadeFactor, 0.0, 1.0);
152
+
153
+ if (backgroundOpacity > 0.0) {
154
+ vec3 finalColor = mix(backgroundColor, color, gridAlpha);
155
+ float blendedAlpha = clamp(max(gridAlpha, backgroundOpacity * fadeFactor), 0.0, 1.0);
156
+ gl_FragColor = vec4(finalColor, blendedAlpha);
157
+ } else {
158
+ gl_FragColor = vec4(color, gridAlpha);
159
+ }
160
+
161
+ if (gl_FragColor.a < 0.05) discard;
162
+
163
+ #include <tonemapping_fragment>
164
+ #include <${revision < 154 ? "encodings_fragment" : "colorspace_fragment"}>
165
+ }
166
+ `;
167
+
168
+ // Simple floor shader
169
+ const floorVertexShader = /*glsl*/ `
170
+ varying vec3 vWorldPosition;
171
+ varying float vViewZ;
172
+
173
+ void main() {
174
+ vec4 worldPosition = modelMatrix * vec4(position, 1.0);
175
+ vWorldPosition = worldPosition.xyz;
176
+ vec4 mvPosition = viewMatrix * worldPosition;
177
+ vViewZ = -mvPosition.z;
178
+ gl_Position = projectionMatrix * mvPosition;
179
+ }
180
+ `;
181
+
182
+ const floorFragmentShader = /*glsl*/ `
183
+ uniform vec3 floorColor, fadeOrigin;
184
+ uniform float floorOpacity, fadeDistance, fadeStrength, cameraNear, cameraFar;
185
+ varying vec3 vWorldPosition;
186
+ varying float vViewZ;
187
+
188
+ ${fadeCalculation}
189
+
190
+ void main() {
191
+ float fadeFactor = calculateFade(vWorldPosition, vViewZ, fadeOrigin, fadeDistance, fadeStrength, cameraNear, cameraFar);
192
+ float finalOpacity = floorOpacity * fadeFactor;
193
+ gl_FragColor = vec4(floorColor, finalOpacity);
194
+ if (gl_FragColor.a < 0.01) discard;
195
+ }
196
+ `;
197
+
198
+ const mesh = new Mesh();
199
+ const { invalidate, camera } = useThrelte();
200
+ const gridPlane = new Plane();
201
+ const upVector = new Vector3(0, 1, 0);
202
+ const zeroVector = new Vector3(0, 0, 0);
203
+
204
+ const axisToInt: Record<string, number> = { x: 0, y: 1, z: 2 };
205
+ const planeToAxes: Record<string, string> = { xz: "xzy", xy: "xyz", zy: "zyx" };
206
+ const gridType = { grid: 0, lines: 1, circular: 2, polar: 3 };
207
+
208
+ // Shared uniforms (used by both grid and floor)
209
+ const sharedUniforms = {
210
+ fadeOrigin: { value: new Vector3() },
211
+ fadeDistance: { value: fadeDistance },
212
+ fadeStrength: { value: fadeStrength },
213
+ cameraNear: { value: 0.1 },
214
+ cameraFar: { value: 1000 }
215
+ };
216
+
217
+ // Grid uniforms
218
+ const uniforms = {
219
+ ...sharedUniforms,
220
+ cellSize: { value: cellSize },
221
+ sectionSize: { value: sectionSize },
222
+ cellColor: { value: new Color(cellColor) },
223
+ sectionColor: { value: new Color(sectionColor) },
224
+ backgroundColor: { value: new Color(backgroundColor) },
225
+ backgroundOpacity: { value: backgroundOpacity },
226
+ cellThickness: { value: cellThickness },
227
+ sectionThickness: { value: sectionThickness },
228
+ infiniteGrid: { value: infiniteGrid },
229
+ followCamera: { value: followCamera },
230
+ coord0: { value: 0 },
231
+ coord1: { value: 2 },
232
+ coord2: { value: 1 },
233
+ gridType: { value: gridType.grid },
234
+ lineGridCoord: { value: axisToInt[axis as keyof typeof axisToInt] || 0 },
235
+ circleGridMaxRadius: { value: maxRadius },
236
+ polarCellDividers: { value: cellDividers },
237
+ polarSectionDividers: { value: sectionDividers },
238
+ worldCamProjPosition: { value: new Vector3() },
239
+ worldPlanePosition: { value: new Vector3() }
240
+ };
241
+
242
+ // Floor uniforms (simpler, reusing shared uniforms)
243
+ const floorUniforms = {
244
+ ...sharedUniforms,
245
+ floorColor: { value: new Color(floorColor) },
246
+ floorOpacity: { value: floorOpacity }
247
+ };
248
+
249
+ // Single update effect for all uniforms
250
+ $effect.pre(() => {
251
+ const axes = planeToAxes[plane] || "xzy";
252
+ const [c0, c1, c2] = [axes.charAt(0), axes.charAt(1), axes.charAt(2)].map(
253
+ (c) => axisToInt[c as keyof typeof axisToInt]
254
+ );
255
+
256
+ // Update grid uniforms
257
+ Object.assign(uniforms, {
258
+ coord0: { value: c0 },
259
+ coord1: { value: c1 },
260
+ coord2: { value: c2 },
261
+ cellSize: { value: cellSize },
262
+ sectionSize: { value: sectionSize },
263
+ cellThickness: { value: cellThickness },
264
+ sectionThickness: { value: sectionThickness },
265
+ backgroundOpacity: { value: backgroundOpacity },
266
+ infiniteGrid: { value: infiniteGrid },
267
+ followCamera: { value: followCamera }
268
+ });
269
+
270
+ uniforms.cellColor.value.set(cellColor);
271
+ uniforms.sectionColor.value.set(sectionColor);
272
+ uniforms.backgroundColor.value.set(backgroundColor);
273
+
274
+ // Update shared uniforms (affects both grid and floor)
275
+ sharedUniforms.fadeDistance.value = fadeDistance;
276
+ sharedUniforms.fadeStrength.value = fadeStrength;
277
+ floorUniforms.floorColor.value.set(floorColor);
278
+ floorUniforms.floorOpacity.value = floorOpacity;
279
+
280
+ // Update camera uniforms
281
+ if (camera.current && "near" in camera.current && "far" in camera.current) {
282
+ const cam = camera.current as THREE.PerspectiveCamera;
283
+ sharedUniforms.cameraNear.value = cam.near;
284
+ sharedUniforms.cameraFar.value = cam.far;
285
+ }
286
+
287
+ // Update grid type
288
+ const typeMap = { grid: 0, lines: 1, circular: 2, polar: 3 };
289
+ uniforms.gridType.value = typeMap[type as keyof typeof typeMap] || 0;
290
+ if (type === "lines")
291
+ uniforms.lineGridCoord.value = axisToInt[axis as keyof typeof axisToInt] || 0;
292
+ if (type === "circular" || type === "polar") {
293
+ uniforms.circleGridMaxRadius.value = maxRadius;
294
+ if (type === "polar") {
295
+ uniforms.polarCellDividers.value = cellDividers;
296
+ uniforms.polarSectionDividers.value = sectionDividers;
297
+ }
298
+ }
299
+
300
+ invalidate();
301
+ });
302
+
303
+ // Single task for both grid and floor fade origins
304
+ useTask(
305
+ () => {
306
+ gridPlane.setFromNormalAndCoplanarPoint(upVector, zeroVector).applyMatrix4(mesh.matrixWorld);
307
+ const material = mesh.material as THREE.ShaderMaterial;
308
+ if (material?.uniforms) {
309
+ const { worldCamProjPosition, worldPlanePosition } = material.uniforms;
310
+ const projectedPoint = gridPlane.projectPoint(
311
+ camera.current.position,
312
+ worldCamProjPosition.value
313
+ );
314
+ if (!fadeOrigin) sharedUniforms.fadeOrigin.value = projectedPoint;
315
+ worldPlanePosition.value.set(0, 0, 0).applyMatrix4(mesh.matrixWorld);
316
+ }
317
+ },
318
+ { autoInvalidate: false }
319
+ );
320
+ </script>
321
+
322
+ <!-- Shadow-receiving floor underneath -->
323
+ <T.Mesh rotation={[-Math.PI / 2, 0, 0]} receiveShadow position.y={0} {...props}>
324
+ <T.PlaneGeometry
325
+ args={infiniteGrid
326
+ ? [1000, 1000]
327
+ : typeof gridSize == "number"
328
+ ? [gridSize, gridSize]
329
+ : gridSize}
330
+ />
331
+ <T.ShadowMaterial
332
+ transparent={true}
333
+ opacity={0.3}
334
+ polygonOffset={true}
335
+ polygonOffsetFactor={1}
336
+ polygonOffsetUnits={1}
337
+ />
338
+ </T.Mesh>
339
+
340
+ <!-- Fading floor -->
341
+ <T.Mesh rotation={[-Math.PI / 2, 0, 0]} position.y={0} {...props}>
342
+ <T.PlaneGeometry
343
+ args={infiniteGrid
344
+ ? [1000, 1000]
345
+ : typeof gridSize == "number"
346
+ ? [gridSize, gridSize]
347
+ : gridSize}
348
+ />
349
+ <T.ShaderMaterial
350
+ vertexShader={floorVertexShader}
351
+ fragmentShader={floorFragmentShader}
352
+ uniforms={floorUniforms}
353
+ transparent={true}
354
+ side={THREE.DoubleSide}
355
+ depthTest={true}
356
+ depthWrite={false}
357
+ polygonOffset={true}
358
+ polygonOffsetFactor={-1}
359
+ polygonOffsetUnits={-1}
360
+ />
361
+ </T.Mesh>
362
+
363
+ <!-- Grid lines -->
364
+ <T is={mesh} bind:ref frustumCulled={false} position.y={0.005} {...props}>
365
+ <T.ShaderMaterial
366
+ {fragmentShader}
367
+ {vertexShader}
368
+ {uniforms}
369
+ transparent
370
+ {side}
371
+ depthTest={true}
372
+ depthWrite={false}
373
+ />
374
+ {#if children}
375
+ {@render children({ ref: mesh })}
376
+ {:else}
377
+ <T.PlaneGeometry args={typeof gridSize == "number" ? [gridSize, gridSize] : gridSize} />
378
+ {/if}
379
+ </T>
src/lib/components/3d/Robot.svelte CHANGED
@@ -1,53 +1,506 @@
1
  <script lang="ts">
2
- import { T } from '@threlte/core';
3
- import { getRootLinks } from '$lib/components/3d/robot/URDF/utils/UrdfParser';
4
- import UrdfLink from '$lib/components/3d/robot/URDF/primitives/UrdfLink.svelte';
5
- import { robotManager } from '$lib/robot/RobotManager.svelte';
6
- import Selectable from './Selectable.svelte';
 
 
 
 
 
 
 
 
 
 
7
 
8
  interface Props {}
9
 
10
  let {}: Props = $props();
11
 
 
 
 
 
 
 
 
 
 
12
  // Get all robots from the manager
13
  const robots = $derived(robotManager.robots);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
14
  </script>
15
 
16
  {#each robots as robot, index (robot.id)}
17
- {@const xPosition = index * 5}
18
- <T.Group position.x={xPosition} position.y={0} position.z={0} quaternion={[0, 0, 0, 1]} scale={[10, 10, 10]} rotation={[-Math.PI / 2, 0, 0]}>
19
- <Selectable
20
- hoverColor="#ff6b6b"
21
- hoverOpacity={0.5}
22
- enableEdit={true}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
23
  >
24
- {#snippet content({ isHighlighted })}
25
- {#each getRootLinks(robot.robotState.robot) as link}
26
- <UrdfLink
27
- robot={robot.robotState.robot}
28
- {link}
29
- textScale={0.2}
30
- showName={isHighlighted}
31
- showVisual={true}
32
- showCollision={false}
33
- visualColor="#333333"
34
- visualOpacity={isHighlighted ? 0.1 : 1.0}
35
- collisionOpacity={1.0}
36
- collisionColor="#813d9c"
37
- jointNames={isHighlighted}
38
- joints={isHighlighted}
39
- jointColor="#62a0ea"
40
- jointIndicatorColor="#f66151"
41
- nameHeight={0.1}
42
- selectedLink={robot.robotState.selection.selectedLink}
43
- selectedJoint={robot.robotState.selection.selectedJoint}
44
- highlightColor="#ffa348"
45
- showLine={isHighlighted}
46
- opacity={1}
47
- isInteractive={false}
48
- />
49
- {/each}
50
- {/snippet}
51
- </Selectable>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
52
  </T.Group>
53
- {/each}
 
 
 
 
 
 
 
 
 
 
1
  <script lang="ts">
2
+ import * as THREE from "three";
3
+ import { T, useThrelte } from "@threlte/core";
4
+ import { getRootLinks } from "@/components/3d/robot/URDF/utils/UrdfParser";
5
+ import UrdfLink from "@/components/3d/robot/URDF/primitives/UrdfLink.svelte";
6
+ import { robotManager } from "$lib/robot/RobotManager.svelte";
7
+ import { Billboard, HTML } from "@threlte/extras";
8
+ import { scale } from "svelte/transition";
9
+ import { onMount } from "svelte";
10
+ import { robotUrdfConfigMap } from "@/configs/robotUrdfConfig";
11
+ import MasterConnectionModal from "@/components/interface/overlay/MasterConnectionModal.svelte";
12
+ import SlaveConnectionModal from "@/components/interface/overlay/SlaveConnectionModal.svelte";
13
+ import ManualControlModal from "@/components/interface/overlay/ManualControlSheet.svelte";
14
+ import type { Robot } from "$lib/robot/Robot.svelte";
15
+ import Hoverable from "@/components/utils/Hoverable.svelte";
16
+ import { generateName } from "$lib/utils/generateName";
17
 
18
  interface Props {}
19
 
20
  let {}: Props = $props();
21
 
22
+ // Get camera controls
23
+ const { camera } = useThrelte();
24
+
25
+ // Modal state using runes
26
+ let isMasterModalOpen = $state(false);
27
+ let isSlaveModalOpen = $state(false);
28
+ let isManualControlModalOpen = $state(false);
29
+ let selectedRobot = $state<Robot | null>(null);
30
+
31
  // Get all robots from the manager
32
  const robots = $derived(robotManager.robots);
33
+
34
+ // Helper function to get connection status
35
+ function getConnectionStatus(robot: any) {
36
+ const status = robotManager.getRobotStatus(robot.id);
37
+ if (!status) return "offline";
38
+
39
+ if (status.hasActiveMaster && status.connectedSlaves > 0) {
40
+ return "active";
41
+ } else if (status.hasActiveMaster) {
42
+ return "Master Only";
43
+ } else if (status.connectedSlaves > 0) {
44
+ return "Slave Only";
45
+ }
46
+ return "idle";
47
+ }
48
+
49
+ // Helper function to get status color
50
+ function getStatusVariant(status: string) {
51
+ switch (status) {
52
+ case "Master + Slaves":
53
+ return "default"; // Green
54
+ case "Master Only":
55
+ return "secondary"; // Yellow
56
+ case "Slave Only":
57
+ return "outline"; // Blue
58
+ default:
59
+ return "destructive"; // Red/Gray
60
+ }
61
+ }
62
+
63
+ // Camera movement function
64
+ function moveCameraToRobot(robot: Robot, index: number) {
65
+ if (!camera.current) return;
66
+
67
+ // Calculate robot position (same logic as used in the template)
68
+ const gridWidth = 3;
69
+ const spacing = 6;
70
+ const totalRows = Math.ceil(robots.length / gridWidth);
71
+ const row = Math.floor(index / gridWidth);
72
+ const col = 1 + (index % gridWidth);
73
+ const xPosition = (col - Math.floor(gridWidth / 2)) * spacing;
74
+ const zPosition = (row - Math.floor(totalRows / 2)) * spacing;
75
+
76
+ // Camera positioning parameters - adjust these to change the rotation angle
77
+ const cameraDistance = 12; // Distance from robot
78
+ const cameraHeight = 8; // Height above ground
79
+ const angleOffset = Math.PI / 4; // 45 degrees - change this for different angles
80
+
81
+ // Calculate camera position using polar coordinates for easy angle control
82
+ const cameraX = xPosition + Math.cos(angleOffset) * cameraDistance;
83
+ const cameraZ = zPosition + Math.sin(angleOffset) * cameraDistance;
84
+
85
+ // Create target positions
86
+ const targetPosition = new THREE.Vector3(cameraX, cameraHeight, cameraZ);
87
+ const lookAtPosition = new THREE.Vector3(xPosition, 1, zPosition); // Look at robot center
88
+
89
+ // Animate camera movement
90
+ const startPosition = camera.current.position.clone();
91
+ const startTime = Date.now();
92
+ const duration = 1000; // 1 second animation
93
+
94
+ function animateCamera() {
95
+ const elapsed = Date.now() - startTime;
96
+ const progress = Math.min(elapsed / duration, 1);
97
+
98
+ // Use easing function for smooth animation
99
+ const easeProgress = 1 - Math.pow(1 - progress, 3); // easeOutCubic
100
+
101
+ // Interpolate position
102
+ camera.current.position.lerpVectors(startPosition, targetPosition, easeProgress);
103
+ camera.current.lookAt(lookAtPosition);
104
+
105
+ if (progress < 1) {
106
+ requestAnimationFrame(animateCamera);
107
+ }
108
+ }
109
+
110
+ animateCamera();
111
+ }
112
+
113
+ // Modal management functions
114
+ function openMasterModal(robot: Robot) {
115
+ console.log("Opening master modal for robot:", robot.id);
116
+ selectedRobot = robot;
117
+ isMasterModalOpen = true;
118
+ }
119
+
120
+ function openSlaveModal(robot: Robot) {
121
+ console.log("Opening slave modal for robot:", robot.id);
122
+ selectedRobot = robot;
123
+ isSlaveModalOpen = true;
124
+ }
125
+
126
+ function openManualControlModal(robot: Robot) {
127
+ console.log("Opening manual control modal for robot:", robot.id);
128
+ selectedRobot = robot;
129
+ isManualControlModalOpen = true;
130
+ }
131
+
132
+ // Handle stop propagation for nested buttons
133
+ function handleAddButtonClick(event: Event, robot: Robot, tab: "master" | "slaves") {
134
+ console.log("Handling add button click for robot:", robot.id, "tab:", tab);
135
+ event.stopPropagation();
136
+ if (tab === "master") {
137
+ openMasterModal(robot);
138
+ } else {
139
+ openSlaveModal(robot);
140
+ }
141
+ }
142
+
143
+ // Handle box clicks (using mousedown since it works reliably in 3D context)
144
+ function handleBoxClick(robot: Robot, type: "master" | "slaves" | "manual") {
145
+ console.log("Box clicked:", type, "for robot:", robot.id);
146
+ if (type === "master") {
147
+ openMasterModal(robot);
148
+ } else if (type === "slaves") {
149
+ openSlaveModal(robot);
150
+ } else if (type === "manual") {
151
+ openManualControlModal(robot);
152
+ }
153
+ }
154
+
155
+ onMount(() => {
156
+ function createRobot() {
157
+ const urdfConfig = robotUrdfConfigMap["so-arm100"];
158
+
159
+ if (!urdfConfig) {
160
+ return;
161
+ }
162
+
163
+ const robotId = generateName();
164
+ console.log("Creating robot with ID:", robotId, "and config:", urdfConfig);
165
+
166
+ robotManager.createRobot(robotId, urdfConfig);
167
+ }
168
+
169
+ // If no robot then create one
170
+ if (robots.length === 0) {
171
+ createRobot();
172
+ }
173
+ });
174
+
175
+ function generateRobotName() {
176
+ throw new Error("Function not implemented.");
177
+ }
178
  </script>
179
 
180
  {#each robots as robot, index (robot.id)}
181
+ {@const gridWidth = 3}
182
+ <!-- Number of robots per row -->
183
+ {@const spacing = 6}
184
+ <!-- Space between robots -->
185
+ {@const totalRows = Math.ceil(robots.length / gridWidth)}
186
+ {@const row = Math.floor(index / gridWidth)}
187
+ {@const col = 1 + (index % gridWidth)}
188
+ {@const xPosition = (col - Math.floor(gridWidth / 2)) * spacing}
189
+ <!-- Center the grid on x-axis -->
190
+ {@const zPosition = (row - Math.floor(totalRows / 2)) * spacing}
191
+ <!-- Center the grid on z-axis -->
192
+ {@const robotStatus = robotManager.getRobotStatus(robot.id)}
193
+ {@const connectionStatus = getConnectionStatus(robot)}
194
+ {@const statusVariant = getStatusVariant(connectionStatus)}
195
+ <T.Group
196
+ position.x={xPosition}
197
+ position.y={0}
198
+ position.z={zPosition}
199
+ quaternion={[0, 0, 0, 1]}
200
+ scale={[10, 10, 10]}
201
+ rotation={[-Math.PI / 2, 0, 0]}
202
+ >
203
+ <Hoverable
204
+ onClick={() => {
205
+ moveCameraToRobot(robot, index);
206
+ handleBoxClick(robot, "manual");
207
+ }}
208
  >
209
+ {#snippet content({ isHovered, isSelected })}
210
+ {#each getRootLinks(robot.robotState.robot) as link}
211
+ <UrdfLink
212
+ robot={robot.robotState.robot}
213
+ {link}
214
+ textScale={0.2}
215
+ showName={isHovered || isSelected}
216
+ showVisual={true}
217
+ showCollision={false}
218
+ visualColor="#333333"
219
+ visualOpacity={isHovered || isSelected ? 0.4 : 1.0}
220
+ collisionOpacity={1.0}
221
+ collisionColor="#813d9c"
222
+ jointNames={isHovered || isSelected}
223
+ joints={isHovered || isSelected}
224
+ jointColor="#62a0ea"
225
+ jointIndicatorColor="#f66151"
226
+ nameHeight={0.1}
227
+ selectedLink={robot.robotState.selection.selectedLink}
228
+ selectedJoint={robot.robotState.selection.selectedJoint}
229
+ highlightColor="#ffa348"
230
+ showLine={isHovered || isSelected}
231
+ opacity={1}
232
+ isInteractive={false}
233
+ />
234
+ {/each}
235
+
236
+ <T.Group position.z={0.25} rotation={[Math.PI / 2, 0, 0]} scale={[0.12, 0.12, 0.12]}>
237
+ <Billboard>
238
+ <HTML
239
+ transform
240
+ autoRender={true}
241
+ center={true}
242
+ distanceFactor={3}
243
+ pointerEvents="auto"
244
+ style="
245
+ pointer-events: auto !important;
246
+ image-rendering: auto;
247
+ image-rendering: smooth;
248
+ text-rendering: optimizeLegibility;
249
+ -webkit-font-smoothing: subpixel-antialiased;
250
+ -moz-osx-font-smoothing: auto;
251
+ backface-visibility: hidden;
252
+ transform-style: preserve-3d;
253
+ will-change: transform;
254
+ "
255
+ >
256
+ {#if isHovered || isSelected}
257
+ <div
258
+ class="pointer-events-auto select-none"
259
+ style="pointer-events: auto !important;"
260
+ in:scale={{ duration: 200, start: 0.5 }}
261
+ >
262
+ <div class="flex items-center gap-3">
263
+ <!-- Manual Control Box -->
264
+ <button
265
+ class={[
266
+ "relative min-h-[80px] min-w-[90px] cursor-pointer rounded-lg border border-purple-500/40 bg-purple-950/20 bg-slate-900/80 px-3 py-3 backdrop-blur-sm transition-colors hover:border-purple-400/60",
267
+ robotStatus?.hasActiveMaster
268
+ ? "cursor-not-allowed border-dashed opacity-40"
269
+ : robot.manualControlEnabled
270
+ ? ""
271
+ : "border-dashed opacity-60"
272
+ ]}
273
+ style="pointer-events: auto !important;"
274
+ onmousedown={() =>
275
+ !robotStatus?.hasActiveMaster && handleBoxClick(robot, "manual")}
276
+ onclick={() =>
277
+ !robotStatus?.hasActiveMaster && handleBoxClick(robot, "manual")}
278
+ disabled={robotStatus?.hasActiveMaster}
279
+ >
280
+ {#if robotStatus?.hasActiveMaster}
281
+ <div class="flex flex-col items-center">
282
+ <div class="flex items-center gap-1">
283
+ <span class="icon-[mdi--lock] size-3 text-purple-400/60"></span>
284
+ <span
285
+ class="text-xs leading-tight font-medium text-purple-400/60 uppercase"
286
+ >CONTROL DISABLED</span
287
+ >
288
+ </div>
289
+ <span class="mt-0.5 text-center text-xs leading-tight text-purple-300/60">
290
+ Control managed by
291
+ </span>
292
+ <span
293
+ class="text-center text-xs leading-tight font-semibold text-purple-300/60"
294
+ >
295
+ {robot.master?.name?.slice(0, 20) || "Master"}
296
+ </span>
297
+ </div>
298
+ {:else if robot.manualControlEnabled}
299
+ <div class="flex flex-col items-center">
300
+ <div class="flex items-center gap-1">
301
+ <span class="icon-[mdi--tune] size-3 text-purple-400"></span>
302
+ <span
303
+ class="text-xs leading-tight font-semibold text-purple-400 uppercase"
304
+ >MANUAL</span
305
+ >
306
+ </div>
307
+ <span class="mt-0.5 text-xs leading-tight text-purple-200">
308
+ {robot.activeJoints.length} Joints Active
309
+ </span>
310
+ <span class="text-xs leading-tight text-purple-300/80">
311
+ Click to take manual control
312
+ </span>
313
+ </div>
314
+ {:else}
315
+ <div class="flex flex-col items-center">
316
+ <div class="flex items-center gap-1">
317
+ <span class="icon-[mdi--tune] size-3 text-purple-400/60"></span>
318
+ <span class="text-xs leading-tight font-medium text-purple-400/60"
319
+ >MANUAL OFF</span
320
+ >
321
+ </div>
322
+ <span class="mt-0.5 text-xs leading-tight text-purple-300/40"
323
+ >Click to Configure</span
324
+ >
325
+ <div class="mt-2 text-purple-400/60">
326
+ <span class="icon-[mdi--cog-outline] size-4"></span>
327
+ </div>
328
+ </div>
329
+ {/if}
330
+ </button>
331
+ </div>
332
+ </div>
333
+ {:else}
334
+ <div
335
+ class="pointer-events-auto select-none"
336
+ style="pointer-events: auto !important;"
337
+ in:scale={{ duration: 200, start: 0.5 }}
338
+ >
339
+ <div class="flex items-center gap-3">
340
+ <!-- Master Box -->
341
+ <button
342
+ class={[
343
+ "relative min-h-[80px] min-w-[90px] cursor-pointer rounded-lg border border-green-500/40 bg-green-950/20 bg-slate-900/80 px-3 py-3 backdrop-blur-sm transition-colors hover:border-green-400/60",
344
+ robotStatus?.hasActiveMaster ? "" : "border-dashed opacity-60"
345
+ ]}
346
+ style="pointer-events: auto !important;"
347
+ onmousedown={() => handleBoxClick(robot, "master")}
348
+ onclick={() => handleBoxClick(robot, "master")}
349
+ >
350
+ {#if robotStatus?.hasActiveMaster}
351
+ <div class="flex flex-col items-center">
352
+ <div class="flex items-center gap-1">
353
+ <span class="icon-[mdi--speak] size-3 text-green-400"></span>
354
+ <span
355
+ class="text-xs leading-tight font-semibold text-green-400 uppercase"
356
+ >MASTER</span
357
+ >
358
+ </div>
359
+ <span class="mt-0.5 text-xs leading-tight text-green-200">
360
+ {robot.master?.name.slice(0, 30) || "Unknown"}
361
+ </span>
362
+ {#if robot.master?.constructor.name}
363
+ <span class="text-xs leading-tight text-green-300/80">
364
+ {robot.master?.constructor.name.replace("Driver", "").slice(0, 30)}
365
+ </span>
366
+ {:else}
367
+ <span class="text-xs leading-tight text-red-300/80"> N/A </span>
368
+ {/if}
369
+ <div
370
+ class="mt-1 h-1.5 w-1.5 animate-pulse rounded-full bg-green-400"
371
+ ></div>
372
+ </div>
373
+ {:else}
374
+ <div class="flex flex-col items-center">
375
+ <div class="flex items-center gap-1">
376
+ <span class="icon-[mdi--speak] size-3 text-green-400/60"></span>
377
+ <span class="text-xs leading-tight font-medium text-green-400/60"
378
+ >NO MASTER</span
379
+ >
380
+ </div>
381
+ <span class="mt-0.5 text-xs leading-tight text-green-300/40"
382
+ >Click to Connect</span
383
+ >
384
+ <div class="mt-2 text-green-400/60">
385
+ <span class="icon-[mdi--plus-circle] size-4"></span>
386
+ </div>
387
+ </div>
388
+ {/if}
389
+ </button>
390
+
391
+ <!-- Arrow 1: Master to Robot -->
392
+ <div class="font-mono text-sm text-slate-400">
393
+ {#if robotStatus?.hasActiveMaster}
394
+ <span class="text-green-400">→</span>
395
+ {:else}
396
+ <span class="text-green-400/50">⇢</span>
397
+ {/if}
398
+ </div>
399
+
400
+ <!-- Robot Box (Simplified) -->
401
+ <div
402
+ class={[
403
+ "min-h-[80px] min-w-[90px] rounded-lg border border-amber-500/40 bg-slate-900/80 px-3 py-3 backdrop-blur-sm"
404
+ ]}
405
+ >
406
+ <div class="flex flex-col items-center">
407
+ <div class="flex items-center gap-1">
408
+ <span class="icon-[mdi--connection] size-3 text-amber-400"></span>
409
+ <span
410
+ class="text-xs leading-tight font-semibold text-amber-400 uppercase"
411
+ >
412
+ Robot
413
+ </span>
414
+ </div>
415
+ <span class="mt-0.5 text-xs leading-tight text-amber-200">
416
+ {robot.id}
417
+ </span>
418
+ </div>
419
+ </div>
420
+
421
+ <!-- Arrow 2: Robot to Slaves -->
422
+ <div class="font-mono text-sm text-slate-400">
423
+ {#if robotStatus?.connectedSlaves && robotStatus.connectedSlaves > 0}
424
+ <span class="text-blue-400">→</span>
425
+ {:else}
426
+ <span class="text-blue-400/50">⇢</span>
427
+ {/if}
428
+ </div>
429
+
430
+ <!-- Slaves Box -->
431
+ <button
432
+ class={[
433
+ "relative min-h-[80px] min-w-[90px] cursor-pointer rounded-lg border border-blue-500/40 bg-blue-950/20 bg-slate-900/80 px-3 py-3 backdrop-blur-sm transition-colors hover:border-blue-400/60",
434
+ robotStatus?.connectedSlaves && robotStatus.connectedSlaves > 0
435
+ ? ""
436
+ : "border-dashed opacity-60"
437
+ ]}
438
+ style="pointer-events: auto !important;"
439
+ onmousedown={() => handleBoxClick(robot, "slaves")}
440
+ onclick={() => handleBoxClick(robot, "slaves")}
441
+ >
442
+ {#if robotStatus?.connectedSlaves && robotStatus.connectedSlaves > 0}
443
+ <div class="flex flex-col items-center">
444
+ <div class="flex items-center gap-1">
445
+ <span class="icon-[fa6-solid--ear-listen] size-3 text-blue-400"></span>
446
+ <span
447
+ class="text-xs leading-tight font-semibold text-blue-400 uppercase"
448
+ >SLAVES</span
449
+ >
450
+ </div>
451
+ {#if robot.slaves.length > 0}
452
+ <div class="mt-1 flex flex-col items-center gap-0.5">
453
+ {#each robot.slaves.slice(0, 2) as slave}
454
+ <div class="mt-0.5 text-xs leading-tight text-blue-300/80">
455
+ {slave.name.slice(0, 30) || "Slave"}
456
+ </div>
457
+ <div class="text-xs leading-tight text-blue-200">
458
+ {slave.constructor.name.replace("Driver", "").slice(0, 30) ||
459
+ "N/A"}
460
+ </div>
461
+ {/each}
462
+ {#if robot.slaves.length > 2}
463
+ <span class="text-xs text-blue-400/60"
464
+ >+{robot.slaves.length - 2} more</span
465
+ >
466
+ {/if}
467
+ </div>
468
+ {/if}
469
+ </div>
470
+ {:else}
471
+ <div class="flex flex-col items-center">
472
+ <div class="flex items-center gap-1">
473
+ <span class="icon-[fa6-solid--ear-listen] size-3 text-blue-400/60"
474
+ ></span>
475
+ <span class="text-xs leading-tight font-medium text-blue-400/60"
476
+ >NO SLAVES</span
477
+ >
478
+ </div>
479
+ <span class="mt-0.5 text-xs leading-tight text-blue-300/40"
480
+ >Click to Connect</span
481
+ >
482
+ <div class="mt-2 text-blue-400/60">
483
+ <span class="icon-[mdi--plus-circle] size-4"></span>
484
+ </div>
485
+ </div>
486
+ {/if}
487
+ </button>
488
+ </div>
489
+ </div>
490
+ {/if}
491
+ </HTML>
492
+ </Billboard>
493
+ </T.Group>
494
+ {/snippet}
495
+ </Hoverable>
496
  </T.Group>
497
+ {/each}
498
+
499
+ <!-- Master Connection Modal -->
500
+ <MasterConnectionModal bind:open={isMasterModalOpen} robot={selectedRobot} />
501
+
502
+ <!-- Slave Connection Modal -->
503
+ <SlaveConnectionModal bind:open={isSlaveModalOpen} robot={selectedRobot} />
504
+
505
+ <!-- Manual Control Modal -->
506
+ <ManualControlModal bind:open={isManualControlModalOpen} robot={selectedRobot} />
src/lib/components/3d/robot/URDF/createRobot.svelte.ts CHANGED
@@ -3,25 +3,25 @@ import type { RobotUrdfConfig } from "$lib/types/urdf";
3
  import { UrdfParser } from "./utils/UrdfParser";
4
 
5
  export async function createRobot(urdfConfig: RobotUrdfConfig): Promise<RobotState> {
6
- const customParser = new UrdfParser(urdfConfig.urdfUrl, "/robots/so-100/");
7
- const urdfData = await customParser.load();
8
- const robot = customParser.fromString(urdfData);
9
 
10
- // Make the robot data reactive so mutations to joint.rotation trigger reactivity
11
- const reactiveRobot = $state(robot);
12
-
13
- // Make the selection state reactive as well
14
- const reactiveSelection = $state({
15
- isSelected: false,
16
- selectedLink: undefined,
17
- selectedJoint: undefined
18
- });
19
 
20
- const robotState: RobotState = {
21
- robot: reactiveRobot,
22
- urdfConfig: urdfConfig,
23
- selection: reactiveSelection
24
- };
 
25
 
26
- return robotState;
27
- }
 
 
 
 
 
 
 
3
  import { UrdfParser } from "./utils/UrdfParser";
4
 
5
  export async function createRobot(urdfConfig: RobotUrdfConfig): Promise<RobotState> {
6
+ const customParser = new UrdfParser(urdfConfig.urdfUrl, "/robots/so-100/");
7
+ const urdfData = await customParser.load();
8
+ const robot = customParser.fromString(urdfData);
9
 
10
+ // Make the robot data reactive so mutations to joint.rotation trigger reactivity
11
+ const reactiveRobot = $state(robot);
 
 
 
 
 
 
 
12
 
13
+ // Make the selection state reactive as well
14
+ const reactiveSelection = $state({
15
+ isSelected: false,
16
+ selectedLink: undefined,
17
+ selectedJoint: undefined
18
+ });
19
 
20
+ const robotState: RobotState = {
21
+ robot: reactiveRobot,
22
+ urdfConfig: urdfConfig,
23
+ selection: reactiveSelection
24
+ };
25
+
26
+ return robotState;
27
+ }
src/lib/components/3d/robot/URDF/interfaces/IUrdfBox.ts CHANGED
@@ -1,3 +1,3 @@
1
  export default interface IUrdfBox {
2
- size: [x: number, y: number, z: number]
3
- }
 
1
  export default interface IUrdfBox {
2
+ size: [x: number, y: number, z: number];
3
+ }
src/lib/components/3d/robot/URDF/interfaces/IUrdfCylinder.ts CHANGED
@@ -1,4 +1,4 @@
1
  export default interface IUrdfCylinder {
2
- radius: number;
3
- length: number
4
- }
 
1
  export default interface IUrdfCylinder {
2
+ radius: number;
3
+ length: number;
4
+ }
src/lib/components/3d/robot/URDF/interfaces/IUrdfJoint.ts CHANGED
@@ -1,42 +1,42 @@
1
- import type IUrdfLink from "./IUrdfLink"
2
 
3
  export default interface IUrdfJoint {
4
- name?: string
5
- type?: 'revolute' | 'continuous' | 'prismatic' | 'fixed' | 'floating' | 'planar'
6
- // rpy = roll, pitch, yaw (values between -pi and +pi)
7
- origin_rpy: [roll: number, pitch: number, yaw: number]
8
- origin_xyz: [x: number, y: number, z: number]
9
- // calculated rotation for non-fixed joints based on origin_rpy and axis_xyz
10
- rotation: [x: number, y: number, z: number]
11
- parent: IUrdfLink
12
- child: IUrdfLink
13
- // axis for revolute and continuous joints defaults to (1,0,0)
14
- axis_xyz?: [x: number, y: number, z: number]
15
- calibration?: {
16
- rising?: number, // Calibration rising value in radians
17
- falling?: number // Calibration falling value in radians
18
- }
19
- dynamics?: {
20
- damping?: number
21
- friction?: number
22
- }
23
- // only for revolute joints
24
- limit?: {
25
- lower?: number
26
- upper?: number
27
- effort: number
28
- velocity: number
29
- }
30
- mimic?: {
31
- joint: string
32
- multiplier?: number
33
- offset?: number
34
- }
35
- safety_controller?: {
36
- soft_lower_limit?: number
37
- soft_upper_limit?: number
38
- k_position?: number
39
- k_velocity: number
40
- }
41
- elem: Element
42
- }
 
1
+ import type IUrdfLink from "./IUrdfLink";
2
 
3
  export default interface IUrdfJoint {
4
+ name?: string;
5
+ type?: "revolute" | "continuous" | "prismatic" | "fixed" | "floating" | "planar";
6
+ // rpy = roll, pitch, yaw (values between -pi and +pi)
7
+ origin_rpy: [roll: number, pitch: number, yaw: number];
8
+ origin_xyz: [x: number, y: number, z: number];
9
+ // calculated rotation for non-fixed joints based on origin_rpy and axis_xyz
10
+ rotation: [x: number, y: number, z: number];
11
+ parent: IUrdfLink;
12
+ child: IUrdfLink;
13
+ // axis for revolute and continuous joints defaults to (1,0,0)
14
+ axis_xyz?: [x: number, y: number, z: number];
15
+ calibration?: {
16
+ rising?: number; // Calibration rising value in radians
17
+ falling?: number; // Calibration falling value in radians
18
+ };
19
+ dynamics?: {
20
+ damping?: number;
21
+ friction?: number;
22
+ };
23
+ // only for revolute joints
24
+ limit?: {
25
+ lower?: number;
26
+ upper?: number;
27
+ effort: number;
28
+ velocity: number;
29
+ };
30
+ mimic?: {
31
+ joint: string;
32
+ multiplier?: number;
33
+ offset?: number;
34
+ };
35
+ safety_controller?: {
36
+ soft_lower_limit?: number;
37
+ soft_upper_limit?: number;
38
+ k_position?: number;
39
+ k_velocity: number;
40
+ };
41
+ elem: Element;
42
+ }
src/lib/components/3d/robot/URDF/interfaces/IUrdfLink.ts CHANGED
@@ -1,23 +1,23 @@
1
- import type {IUrdfVisual} from "./IUrdfVisual"
2
 
3
  interface IUrdfInertia {
4
- ixx: number;
5
- ixy: number;
6
- ixz: number;
7
- iyy: number;
8
- iyz: number;
9
- izz: number;
10
  }
11
 
12
  export default interface IUrdfLink {
13
- name: string
14
- inertial?: {
15
- origin_xyz?: [x: number, y: number, z: number]
16
- origin_rpy?: [roll: number, pitch: number, yaw: number]
17
- mass: number
18
- inertia: IUrdfInertia
19
- }
20
- visual: IUrdfVisual[]
21
- collision: IUrdfVisual[]
22
- elem: Element
23
- }
 
1
+ import type { IUrdfVisual } from "./IUrdfVisual";
2
 
3
  interface IUrdfInertia {
4
+ ixx: number;
5
+ ixy: number;
6
+ ixz: number;
7
+ iyy: number;
8
+ iyz: number;
9
+ izz: number;
10
  }
11
 
12
  export default interface IUrdfLink {
13
+ name: string;
14
+ inertial?: {
15
+ origin_xyz?: [x: number, y: number, z: number];
16
+ origin_rpy?: [roll: number, pitch: number, yaw: number];
17
+ mass: number;
18
+ inertia: IUrdfInertia;
19
+ };
20
+ visual: IUrdfVisual[];
21
+ collision: IUrdfVisual[];
22
+ elem: Element;
23
+ }
src/lib/components/3d/robot/URDF/interfaces/IUrdfMesh.ts CHANGED
@@ -1,5 +1,5 @@
1
  export default interface IUrdfMesh {
2
- filename: string;
3
- type: 'stl' | 'fbx' | 'obj' | 'dae';
4
- scale: [x: number, y: number, z: number];
5
- }
 
1
  export default interface IUrdfMesh {
2
+ filename: string;
3
+ type: "stl" | "fbx" | "obj" | "dae";
4
+ scale: [x: number, y: number, z: number];
5
+ }
src/lib/components/3d/robot/URDF/interfaces/IUrdfRobot.ts CHANGED
@@ -1,10 +1,10 @@
1
- import type IUrdfJoint from "./IUrdfJoint"
2
- import type IUrdfLink from "./IUrdfLink"
3
 
4
  export default interface IUrdfRobot {
5
- name: string
6
- links: {[name: string]: IUrdfLink}
7
- joints: IUrdfJoint[]
8
- // the DOM element holding the XML, so we can work non-destructive
9
- elem?: Element
10
- }
 
1
+ import type IUrdfJoint from "./IUrdfJoint";
2
+ import type IUrdfLink from "./IUrdfLink";
3
 
4
  export default interface IUrdfRobot {
5
+ name: string;
6
+ links: { [name: string]: IUrdfLink };
7
+ joints: IUrdfJoint[];
8
+ // the DOM element holding the XML, so we can work non-destructive
9
+ elem?: Element;
10
+ }
src/lib/components/3d/robot/URDF/interfaces/IUrdfVisual.ts CHANGED
@@ -4,52 +4,52 @@ import type IUrdfMesh from "./IUrdfMesh";
4
 
5
  // 1) Box‐type visual
6
  interface IUrdfVisualBox {
7
- name: string;
8
- origin_xyz: [x: number, y: number, z: number];
9
- origin_rpy: [roll: number, pitch: number, yaw: number];
10
- geometry: IUrdfBox;
11
- material?: {
12
- name: string;
13
- color?: string;
14
- texture?: string;
15
- },
16
- type: 'box';
17
- // optional RGBA color override
18
- color_rgba?: [r: number, g: number, b: number, a: number];
19
- // XML Element reference
20
- elem: Element;
21
  }
22
 
23
  // 2) Cylinder‐type visual
24
  interface IUrdfVisualCylinder {
25
- name: string;
26
- origin_xyz: [x: number, y: number, z: number];
27
- origin_rpy: [roll: number, pitch: number, yaw: number];
28
- geometry: IUrdfCylinder;
29
- material?: {
30
- name: string;
31
- color?: string;
32
- texture?: string;
33
- },
34
- type: 'cylinder';
35
- color_rgba?: [r: number, g: number, b: number, a: number];
36
- elem: Element;
37
  }
38
 
39
  // 3) Mesh‐type visual
40
  interface IUrdfVisualMesh {
41
- name: string;
42
- origin_xyz: [x: number, y: number, z: number];
43
- origin_rpy: [roll: number, pitch: number, yaw: number];
44
- geometry: IUrdfMesh;
45
- material?: {
46
- name: string;
47
- color?: string;
48
- texture?: string;
49
- },
50
- type: 'mesh';
51
- color_rgba?: [r: number, g: number, b: number, a: number];
52
- elem: Element;
53
  }
54
 
55
  // Now make a union of the three:
 
4
 
5
  // 1) Box‐type visual
6
  interface IUrdfVisualBox {
7
+ name: string;
8
+ origin_xyz: [x: number, y: number, z: number];
9
+ origin_rpy: [roll: number, pitch: number, yaw: number];
10
+ geometry: IUrdfBox;
11
+ material?: {
12
+ name: string;
13
+ color?: string;
14
+ texture?: string;
15
+ };
16
+ type: "box";
17
+ // optional RGBA color override
18
+ color_rgba?: [r: number, g: number, b: number, a: number];
19
+ // XML Element reference
20
+ elem: Element;
21
  }
22
 
23
  // 2) Cylinder‐type visual
24
  interface IUrdfVisualCylinder {
25
+ name: string;
26
+ origin_xyz: [x: number, y: number, z: number];
27
+ origin_rpy: [roll: number, pitch: number, yaw: number];
28
+ geometry: IUrdfCylinder;
29
+ material?: {
30
+ name: string;
31
+ color?: string;
32
+ texture?: string;
33
+ };
34
+ type: "cylinder";
35
+ color_rgba?: [r: number, g: number, b: number, a: number];
36
+ elem: Element;
37
  }
38
 
39
  // 3) Mesh‐type visual
40
  interface IUrdfVisualMesh {
41
+ name: string;
42
+ origin_xyz: [x: number, y: number, z: number];
43
+ origin_rpy: [roll: number, pitch: number, yaw: number];
44
+ geometry: IUrdfMesh;
45
+ material?: {
46
+ name: string;
47
+ color?: string;
48
+ texture?: string;
49
+ };
50
+ type: "mesh";
51
+ color_rgba?: [r: number, g: number, b: number, a: number];
52
+ elem: Element;
53
  }
54
 
55
  // Now make a union of the three:
src/lib/components/3d/robot/URDF/interfaces/index.ts CHANGED
@@ -1,7 +1,7 @@
1
- export * from './IUrdfBox';
2
- export * from './IUrdfCylinder';
3
- export * from './IUrdfJoint';
4
- export * from './IUrdfLink';
5
- export * from './IUrdfMesh';
6
- export * from './IUrdfRobot';
7
- export * from './IUrdfVisual';
 
1
+ export * from "./IUrdfBox";
2
+ export * from "./IUrdfCylinder";
3
+ export * from "./IUrdfJoint";
4
+ export * from "./IUrdfLink";
5
+ export * from "./IUrdfMesh";
6
+ export * from "./IUrdfRobot";
7
+ export * from "./IUrdfVisual";
src/lib/components/3d/robot/URDF/mesh/DAE.svelte CHANGED
@@ -1,8 +1,8 @@
1
  <script lang="ts">
2
- import { T, useLoader } from '@threlte/core';
3
- import type { InteractivityProps } from '@threlte/extras';
4
- import { DoubleSide, Mesh, type Side } from 'three';
5
- import { ColladaLoader } from 'three/examples/jsm/loaders/ColladaLoader.js';
6
 
7
  type Props = InteractivityProps & {
8
  filename: string;
@@ -19,7 +19,7 @@
19
  };
20
  let {
21
  filename,
22
- color = '#ffffff',
23
  scale = [1, 1, 1],
24
  position = [0, 0, 0],
25
  rotation = [0, 0, 0],
@@ -47,7 +47,7 @@
47
  rotation={dae.scene.rotation ? dae.scene.rotation.toArray() : [0, 0, 0]}
48
  >
49
  {#each dae.scene.children as obj}
50
- {#if obj.type === 'Mesh'}
51
  {@const mesh = obj as Mesh}
52
  <T.Mesh
53
  {castShadow}
 
1
  <script lang="ts">
2
+ import { T, useLoader } from "@threlte/core";
3
+ import type { InteractivityProps } from "@threlte/extras";
4
+ import { DoubleSide, Mesh, type Side } from "three";
5
+ import { ColladaLoader } from "three/examples/jsm/loaders/ColladaLoader.js";
6
 
7
  type Props = InteractivityProps & {
8
  filename: string;
 
19
  };
20
  let {
21
  filename,
22
+ color = "#ffffff",
23
  scale = [1, 1, 1],
24
  position = [0, 0, 0],
25
  rotation = [0, 0, 0],
 
47
  rotation={dae.scene.rotation ? dae.scene.rotation.toArray() : [0, 0, 0]}
48
  >
49
  {#each dae.scene.children as obj}
50
+ {#if obj.type === "Mesh"}
51
  {@const mesh = obj as Mesh}
52
  <T.Mesh
53
  {castShadow}
src/lib/components/3d/robot/URDF/mesh/OBJ.svelte CHANGED
@@ -1,8 +1,8 @@
1
  <script lang="ts">
2
- import { T, useLoader } from '@threlte/core';
3
- import type { InteractivityProps } from '@threlte/extras';
4
- import { DoubleSide, type Side } from 'three';
5
- import { OBJLoader } from 'three/examples/jsm/loaders/OBJLoader.js';
6
 
7
  type Props = InteractivityProps & {
8
  filename: string;
@@ -19,7 +19,7 @@
19
  };
20
  let {
21
  filename,
22
- color = '#ffffff',
23
  scale = [1, 1, 1],
24
  rotation = [0, 0, 0],
25
  position = [0, 0, 0],
 
1
  <script lang="ts">
2
+ import { T, useLoader } from "@threlte/core";
3
+ import type { InteractivityProps } from "@threlte/extras";
4
+ import { DoubleSide, type Side } from "three";
5
+ import { OBJLoader } from "three/examples/jsm/loaders/OBJLoader.js";
6
 
7
  type Props = InteractivityProps & {
8
  filename: string;
 
19
  };
20
  let {
21
  filename,
22
+ color = "#ffffff",
23
  scale = [1, 1, 1],
24
  rotation = [0, 0, 0],
25
  position = [0, 0, 0],
src/lib/components/3d/robot/URDF/mesh/STL.svelte CHANGED
@@ -1,9 +1,9 @@
1
  <script lang="ts">
2
- import { T, useLoader } from '@threlte/core';
3
- import type { InteractivityProps } from '@threlte/extras';
4
- import { STLLoader } from 'three/examples/jsm/loaders/STLLoader.js';
5
- import { type Side, DoubleSide } from 'three';
6
- import { interactivity } from '@threlte/extras';
7
 
8
  type Props = InteractivityProps & {
9
  filename: string;
@@ -21,7 +21,7 @@
21
 
22
  let {
23
  filename,
24
- color = '#ffffff',
25
  scale = [1, 1, 1],
26
  position = [0, 0, 0],
27
  rotation = [0, 0, 0],
@@ -44,7 +44,7 @@
44
  {#await load(filename) then stl}
45
  {@html `<!-- include stl: ${filename} ${scale} -->`}
46
  <T.Mesh geometry={stl} {scale} {castShadow} {receiveShadow} {position} {rotation} {...restProps}>
47
- <T.MeshLambertMaterial color={color} {opacity} transparent={opacity < 1.0} {wireframe} {side} />
48
  </T.Mesh>
49
  {/await}
50
 
 
1
  <script lang="ts">
2
+ import { T, useLoader } from "@threlte/core";
3
+ import type { InteractivityProps } from "@threlte/extras";
4
+ import { STLLoader } from "three/examples/jsm/loaders/STLLoader.js";
5
+ import { type Side, DoubleSide } from "three";
6
+ import { interactivity } from "@threlte/extras";
7
 
8
  type Props = InteractivityProps & {
9
  filename: string;
 
21
 
22
  let {
23
  filename,
24
+ color = "#ffffff",
25
  scale = [1, 1, 1],
26
  position = [0, 0, 0],
27
  rotation = [0, 0, 0],
 
44
  {#await load(filename) then stl}
45
  {@html `<!-- include stl: ${filename} ${scale} -->`}
46
  <T.Mesh geometry={stl} {scale} {castShadow} {receiveShadow} {position} {rotation} {...restProps}>
47
+ <T.MeshLambertMaterial {color} {opacity} transparent={opacity < 1.0} {wireframe} {side} />
48
  </T.Mesh>
49
  {/await}
50
 
src/lib/components/3d/robot/URDF/primitives/UrdfJoint.svelte CHANGED
@@ -1,20 +1,21 @@
1
  <script lang="ts">
2
- import type IUrdfJoint from '../interfaces/IUrdfJoint';
3
- import { T } from '@threlte/core';
4
- import UrdfLink from './UrdfLink.svelte';
5
- import { Vector3 } from 'three';
6
  import {
7
  Billboard,
8
  interactivity,
9
  MeshLineGeometry,
10
  Text,
11
  type InteractivityProps
12
- } from '@threlte/extras';
13
 
14
- import type IUrdfLink from '../interfaces/IUrdfLink';
15
- import type IUrdfRobot from '../interfaces/IUrdfRobot';
 
16
 
17
- const defaultOnClick: InteractivityProps['onclick'] = (event) => {
18
  event.stopPropagation();
19
  selectedLink = undefined;
20
  selectedJoint = joint;
@@ -49,9 +50,9 @@
49
  selectedLink = $bindable(),
50
  showLine = false,
51
  nameHeight = 0.02,
52
- highlightColor = '#ff0000',
53
- jointColor = '#000000',
54
- jointIndicatorColor = '#000000',
55
  opacity = 0.7,
56
  isInteractive = false,
57
  showName = false,
@@ -59,7 +60,7 @@
59
  showCollision = true,
60
  visualOpacity = 1,
61
  collisionOpacity = 1,
62
- collisionColor = '#000000',
63
  jointNames = true,
64
  onclick = defaultOnClick,
65
  ...restProps
@@ -68,10 +69,23 @@
68
  if (isInteractive) {
69
  interactivity();
70
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
71
  </script>
72
 
73
  {@html `<!-- Joint ${joint.name} (${joint.type}) -->`}
74
-
75
  <!-- draw line from parent-frame to joint origin -->
76
  {#if showLine}
77
  <T.Line>
@@ -91,7 +105,7 @@
91
  <UrdfLink
92
  {robot}
93
  link={joint.child}
94
- textScale={0.02}
95
  {showName}
96
  {showVisual}
97
  {showCollision}
@@ -141,7 +155,7 @@
141
  <Text
142
  scale={nameHeight}
143
  color={selectedJoint == joint ? highlightColor : jointColor}
144
- text={joint.name}
145
  characters="ABCDEFGHIJKLMNOPQRSTUVWXYZ"
146
  renderOrder={999}
147
  ></Text>
 
1
  <script lang="ts">
2
+ import type IUrdfJoint from "../interfaces/IUrdfJoint";
3
+ import { T } from "@threlte/core";
4
+ import UrdfLink from "./UrdfLink.svelte";
5
+ import { Vector3 } from "three";
6
  import {
7
  Billboard,
8
  interactivity,
9
  MeshLineGeometry,
10
  Text,
11
  type InteractivityProps
12
+ } from "@threlte/extras";
13
 
14
+ import type IUrdfLink from "../interfaces/IUrdfLink";
15
+ import type IUrdfRobot from "../interfaces/IUrdfRobot";
16
+ import { radiansToDegrees } from "@/utils";
17
 
18
+ const defaultOnClick: InteractivityProps["onclick"] = (event) => {
19
  event.stopPropagation();
20
  selectedLink = undefined;
21
  selectedJoint = joint;
 
50
  selectedLink = $bindable(),
51
  showLine = false,
52
  nameHeight = 0.02,
53
+ highlightColor = "#ff0000",
54
+ jointColor = "#000000",
55
+ jointIndicatorColor = "#000000",
56
  opacity = 0.7,
57
  isInteractive = false,
58
  showName = false,
 
60
  showCollision = true,
61
  visualOpacity = 1,
62
  collisionOpacity = 1,
63
+ collisionColor = "#000000",
64
  jointNames = true,
65
  onclick = defaultOnClick,
66
  ...restProps
 
69
  if (isInteractive) {
70
  interactivity();
71
  }
72
+
73
+ // Helper function to get current joint rotation value in degrees
74
+ function getJointRotationValue(joint: any): number {
75
+ const axis = joint.axis_xyz || [0, 0, 1];
76
+ const rotation = joint.rotation || [0, 0, 0];
77
+
78
+ // Find the primary axis and get the rotation value
79
+ for (let i = 0; i < 3; i++) {
80
+ if (Math.abs(axis[i]) > 0.001) {
81
+ return radiansToDegrees(rotation[i] / axis[i]);
82
+ }
83
+ }
84
+ return 0;
85
+ }
86
  </script>
87
 
88
  {@html `<!-- Joint ${joint.name} (${joint.type}) -->`}
 
89
  <!-- draw line from parent-frame to joint origin -->
90
  {#if showLine}
91
  <T.Line>
 
105
  <UrdfLink
106
  {robot}
107
  link={joint.child}
108
+ textScale={0.2}
109
  {showName}
110
  {showVisual}
111
  {showCollision}
 
155
  <Text
156
  scale={nameHeight}
157
  color={selectedJoint == joint ? highlightColor : jointColor}
158
+ text={joint.name + " " + getJointRotationValue(joint).toFixed(0) + "°"}
159
  characters="ABCDEFGHIJKLMNOPQRSTUVWXYZ"
160
  renderOrder={999}
161
  ></Text>
src/lib/components/3d/robot/URDF/primitives/UrdfLink.svelte CHANGED
@@ -1,12 +1,11 @@
1
  <script lang="ts">
2
  // The link element describes a rigid body with an inertia, visual features, and collision properties.
3
- import type IUrdfLink from '../interfaces/IUrdfLink';
4
- import UrdfVisual from './UrdfVisual.svelte';
5
- import { getChildJoints } from '../utils/UrdfParser';
6
- import UrdfJoint from './UrdfJoint.svelte';
7
- import { Billboard, Text } from '@threlte/extras';
8
- import type IUrdfRobot from '../interfaces/IUrdfRobot';
9
- import type IUrdfJoint from '../interfaces/IUrdfJoint';
10
 
11
  interface Props {
12
  robot: IUrdfRobot;
@@ -39,18 +38,18 @@
39
  showName = true,
40
  showVisual = true,
41
  showCollision = true,
42
- visualColor = '#000000',
43
  visualOpacity = 1,
44
  collisionOpacity = 1,
45
- collisionColor = '#000000',
46
  jointNames = true,
47
  joints = true,
48
- jointColor = '#000000',
49
- jointIndicatorColor = '#000000',
50
  nameHeight = 0.1,
51
  selectedLink = undefined,
52
  selectedJoint = undefined,
53
- highlightColor = '#000000',
54
  showLine = true,
55
  opacity = 0.7,
56
  isInteractive = false
@@ -58,30 +57,22 @@
58
  </script>
59
 
60
  {@html `<!-- Link ${link.name} -->`}
61
- {#if showName}
62
  <Billboard position.x={0} position.y={0} position.z={0}>
63
  <Text anchorY={-0.2} scale={textScale} text={link.name} characters="ABCDEFGHIJKLMNOPQRSTUVWXYZ"
64
  ></Text>
65
  </Billboard>
66
- {/if}
67
 
68
  {#if showVisual}
69
  {#each link.visual as visual}
70
- <UrdfVisual
71
- opacity={visualOpacity}
72
- {visual}
73
- defaultColor={visualColor}
74
- />
75
  {/each}
76
  {/if}
77
 
78
  {#if showCollision}
79
  {#each link.collision as visual}
80
- <UrdfVisual
81
- opacity={collisionOpacity}
82
- visual={visual}
83
- defaultColor={collisionColor}
84
- />
85
  {/each}
86
  {/if}
87
 
 
1
  <script lang="ts">
2
  // The link element describes a rigid body with an inertia, visual features, and collision properties.
3
+ import type IUrdfLink from "../interfaces/IUrdfLink";
4
+ import UrdfVisual from "./UrdfVisual.svelte";
5
+ import { getChildJoints } from "../utils/UrdfParser";
6
+ import UrdfJoint from "./UrdfJoint.svelte";
7
+ import type IUrdfRobot from "../interfaces/IUrdfRobot";
8
+ import type IUrdfJoint from "../interfaces/IUrdfJoint";
 
9
 
10
  interface Props {
11
  robot: IUrdfRobot;
 
38
  showName = true,
39
  showVisual = true,
40
  showCollision = true,
41
+ visualColor = "#000000",
42
  visualOpacity = 1,
43
  collisionOpacity = 1,
44
+ collisionColor = "#000000",
45
  jointNames = true,
46
  joints = true,
47
+ jointColor = "#000000",
48
+ jointIndicatorColor = "#000000",
49
  nameHeight = 0.1,
50
  selectedLink = undefined,
51
  selectedJoint = undefined,
52
+ highlightColor = "#000000",
53
  showLine = true,
54
  opacity = 0.7,
55
  isInteractive = false
 
57
  </script>
58
 
59
  {@html `<!-- Link ${link.name} -->`}
60
+ <!-- {#if showName}
61
  <Billboard position.x={0} position.y={0} position.z={0}>
62
  <Text anchorY={-0.2} scale={textScale} text={link.name} characters="ABCDEFGHIJKLMNOPQRSTUVWXYZ"
63
  ></Text>
64
  </Billboard>
65
+ {/if} -->
66
 
67
  {#if showVisual}
68
  {#each link.visual as visual}
69
+ <UrdfVisual opacity={visualOpacity} {visual} defaultColor={visualColor} />
 
 
 
 
70
  {/each}
71
  {/if}
72
 
73
  {#if showCollision}
74
  {#each link.collision as visual}
75
+ <UrdfVisual opacity={collisionOpacity} {visual} defaultColor={collisionColor} />
 
 
 
 
76
  {/each}
77
  {/if}
78
 
src/lib/components/3d/robot/URDF/primitives/UrdfThree.svelte CHANGED
@@ -1,12 +1,12 @@
1
  <script lang="ts">
2
  // Three.js visualization of an URDF.
3
- import { T } from '@threlte/core';
4
- import { getRootLinks } from '../utils/UrdfParser';
5
- import UrdfLink from './UrdfLink.svelte';
6
- import { scale } from 'svelte/transition';
7
- import type IUrdfLink from '../interfaces/IUrdfLink';
8
- import type IUrdfJoint from '../interfaces/IUrdfJoint';
9
- import type IUrdfRobot from '../interfaces/IUrdfRobot';
10
 
11
  interface Props {
12
  robot: IUrdfRobot;
@@ -37,16 +37,16 @@
37
  showCollision = true,
38
  visualOpacity = 1,
39
  collisionOpacity = 1,
40
- collisionColor = '#000000',
41
  jointNames = true,
42
  joints = true,
43
- jointColor = '#000000',
44
- jointIndicatorColor = '#000000',
45
  nameHeight = 0.1,
46
  selectedLink = undefined,
47
  selectedJoint = undefined,
48
  textScale = 1,
49
- highlightColor = '#000000'
50
  }: Props = $props();
51
  </script>
52
 
 
1
  <script lang="ts">
2
  // Three.js visualization of an URDF.
3
+ import { T } from "@threlte/core";
4
+ import { getRootLinks } from "../utils/UrdfParser";
5
+ import UrdfLink from "./UrdfLink.svelte";
6
+ import { scale } from "svelte/transition";
7
+ import type IUrdfLink from "../interfaces/IUrdfLink";
8
+ import type IUrdfJoint from "../interfaces/IUrdfJoint";
9
+ import type IUrdfRobot from "../interfaces/IUrdfRobot";
10
 
11
  interface Props {
12
  robot: IUrdfRobot;
 
37
  showCollision = true,
38
  visualOpacity = 1,
39
  collisionOpacity = 1,
40
+ collisionColor = "#000000",
41
  jointNames = true,
42
  joints = true,
43
+ jointColor = "#000000",
44
+ jointIndicatorColor = "#000000",
45
  nameHeight = 0.1,
46
  selectedLink = undefined,
47
  selectedJoint = undefined,
48
  textScale = 1,
49
+ highlightColor = "#000000"
50
  }: Props = $props();
51
  </script>
52
 
src/lib/components/3d/robot/URDF/primitives/UrdfVisual.svelte CHANGED
@@ -1,14 +1,12 @@
1
  <script lang="ts">
2
- import type { IUrdfVisual } from '../interfaces/IUrdfVisual';
3
- import { numberArrayToColor } from '../utils/helper';
4
- import DAE from '../mesh/DAE.svelte';
5
- import OBJ from '../mesh/OBJ.svelte';
6
- import STL from '../mesh/STL.svelte';
7
- import { T } from '@threlte/core';
8
- import { interactivity, type InteractivityProps } from '@threlte/extras';
9
- import type IUrdfLink from '../interfaces/IUrdfLink';
10
- import { DoubleSide, type Side } from 'three';
11
- import { getContext } from 'svelte';
12
 
13
  type Props = InteractivityProps & {
14
  visual: IUrdfVisual;
@@ -26,7 +24,7 @@
26
  let {
27
  visual,
28
  opacity = 1.0,
29
- defaultColor = '#000000',
30
  position = [0, 0, 0],
31
  rotation = [0, 0, 0],
32
  castShadow = true,
@@ -41,15 +39,13 @@
41
  const baseColor = visual?.color_rgba
42
  ? numberArrayToColor(visual.color_rgba.slice(0, 3) as [number, number, number])
43
  : defaultColor;
44
-
45
-
46
  </script>
47
 
48
- {#if visual.type === 'mesh'}
49
- {#if visual.geometry.type === 'stl'}
50
  <STL
51
  color={baseColor}
52
- opacity={opacity}
53
  filename={visual.geometry.filename}
54
  scale={visual.geometry.scale}
55
  {position}
@@ -60,10 +56,10 @@
60
  {side}
61
  {...restProps}
62
  />
63
- {:else if visual.geometry.type === 'obj'}
64
  <OBJ
65
  color={baseColor}
66
- opacity={opacity}
67
  scale={visual.geometry.scale}
68
  filename={visual.geometry.filename}
69
  {position}
@@ -74,11 +70,11 @@
74
  {side}
75
  {...restProps}
76
  />
77
- {:else if visual.geometry.type === 'dae'}
78
  <DAE
79
  filename={visual.geometry.filename}
80
  color={baseColor}
81
- opacity={opacity}
82
  scale={visual.geometry.scale}
83
  {position}
84
  {rotation}
@@ -89,24 +85,16 @@
89
  {...restProps}
90
  />
91
  {/if}
92
- {:else if visual.type === 'cylinder'}
93
  <T.Mesh castShadow receiveShadow rotation={[Math.PI / 2, 0, 0]} {onclick}>
94
  <T.CylinderGeometry
95
  args={[visual.geometry.radius, visual.geometry.radius, visual.geometry.length]}
96
  />
97
- <T.MeshBasicMaterial
98
- color={baseColor}
99
- opacity={opacity}
100
- transparent={opacity < 1.0}
101
- />
102
  </T.Mesh>
103
- {:else if visual.type === 'box'}
104
  <T.Mesh castShadow receiveShadow scale={visual.geometry.size} {onclick}>
105
  <T.BoxGeometry />
106
- <T.MeshBasicMaterial
107
- color={baseColor}
108
- opacity={opacity}
109
- transparent={opacity < 1.0}
110
- />
111
  </T.Mesh>
112
  {/if}
 
1
  <script lang="ts">
2
+ import type { IUrdfVisual } from "../interfaces/IUrdfVisual";
3
+ import { numberArrayToColor } from "../utils/helper";
4
+ import DAE from "../mesh/DAE.svelte";
5
+ import OBJ from "../mesh/OBJ.svelte";
6
+ import STL from "../mesh/STL.svelte";
7
+ import { T } from "@threlte/core";
8
+ import { type InteractivityProps } from "@threlte/extras";
9
+ import { DoubleSide, type Side } from "three";
 
 
10
 
11
  type Props = InteractivityProps & {
12
  visual: IUrdfVisual;
 
24
  let {
25
  visual,
26
  opacity = 1.0,
27
+ defaultColor = "#000000",
28
  position = [0, 0, 0],
29
  rotation = [0, 0, 0],
30
  castShadow = true,
 
39
  const baseColor = visual?.color_rgba
40
  ? numberArrayToColor(visual.color_rgba.slice(0, 3) as [number, number, number])
41
  : defaultColor;
 
 
42
  </script>
43
 
44
+ {#if visual.type === "mesh"}
45
+ {#if visual.geometry.type === "stl"}
46
  <STL
47
  color={baseColor}
48
+ {opacity}
49
  filename={visual.geometry.filename}
50
  scale={visual.geometry.scale}
51
  {position}
 
56
  {side}
57
  {...restProps}
58
  />
59
+ {:else if visual.geometry.type === "obj"}
60
  <OBJ
61
  color={baseColor}
62
+ {opacity}
63
  scale={visual.geometry.scale}
64
  filename={visual.geometry.filename}
65
  {position}
 
70
  {side}
71
  {...restProps}
72
  />
73
+ {:else if visual.geometry.type === "dae"}
74
  <DAE
75
  filename={visual.geometry.filename}
76
  color={baseColor}
77
+ {opacity}
78
  scale={visual.geometry.scale}
79
  {position}
80
  {rotation}
 
85
  {...restProps}
86
  />
87
  {/if}
88
+ {:else if visual.type === "cylinder"}
89
  <T.Mesh castShadow receiveShadow rotation={[Math.PI / 2, 0, 0]} {onclick}>
90
  <T.CylinderGeometry
91
  args={[visual.geometry.radius, visual.geometry.radius, visual.geometry.length]}
92
  />
93
+ <T.MeshBasicMaterial color={baseColor} {opacity} transparent={opacity < 1.0} />
 
 
 
 
94
  </T.Mesh>
95
+ {:else if visual.type === "box"}
96
  <T.Mesh castShadow receiveShadow scale={visual.geometry.size} {onclick}>
97
  <T.BoxGeometry />
98
+ <T.MeshBasicMaterial color={baseColor} {opacity} transparent={opacity < 1.0} />
 
 
 
 
99
  </T.Mesh>
100
  {/if}
src/lib/components/3d/robot/URDF/runes/urdf_state.svelte.ts CHANGED
@@ -1,15 +1,15 @@
1
- import type IUrdfJoint from "../interfaces/IUrdfJoint"
2
- import type IUrdfLink from "../interfaces/IUrdfLink"
3
- import type IUrdfRobot from "../interfaces/IUrdfRobot"
4
 
5
  // Color constants for better maintainability
6
  export const URDF_COLORS = {
7
- COLLISION: "#813d9c", // purple
8
- JOINT: "#62a0ea", // blue
9
- LINK: "#57e389", // green
10
- JOINT_INDICATOR: "#f66151", // red
11
- HIGHLIGHT: "#ffa348", // orange
12
- BACKGROUND: "#241f31", // dark purple
13
  } as const;
14
 
15
  // Transform tool types
@@ -17,128 +17,128 @@ export type TransformTool = "translate" | "rotate" | "scale";
17
 
18
  // Joint state tracking
19
  export interface JointStates {
20
- continuous: Record<string, number>;
21
- revolute: Record<string, number>;
22
  }
23
 
24
  // Selection state
25
  export interface SelectionState {
26
- selectedLink?: IUrdfLink;
27
- selectedJoint?: IUrdfJoint;
28
  }
29
 
30
  // Visibility configuration
31
  export interface VisibilityConfig {
32
- visual: boolean;
33
- collision: boolean;
34
- joints: boolean;
35
- jointNames: boolean;
36
- linkNames: boolean;
37
  }
38
 
39
  // Appearance settings
40
  export interface AppearanceConfig {
41
- colors: {
42
- collision: string;
43
- joint: string;
44
- link: string;
45
- jointIndicator: string;
46
- highlight: string;
47
- background: string;
48
- };
49
- opacity: {
50
- visual: number;
51
- collision: number;
52
- link: number;
53
- };
54
  }
55
 
56
  // Editor configuration
57
  export interface EditorConfig {
58
- isEditMode: boolean;
59
- currentTool: TransformTool;
60
- snap: {
61
- translation: number;
62
- scale: number;
63
- rotation: number;
64
- };
65
  }
66
 
67
  // View configuration
68
  export interface ViewConfig {
69
- zoom: {
70
- current: number;
71
- initial: number;
72
- };
73
- nameHeight: number;
74
  }
75
 
76
  // Main URDF state interface
77
  export interface UrdfState extends SelectionState {
78
- robot?: IUrdfRobot;
79
- jointStates: JointStates;
80
- visibility: VisibilityConfig;
81
- appearance: AppearanceConfig;
82
- editor: EditorConfig;
83
- view: ViewConfig;
84
  }
85
 
86
  // Create the reactive state
87
  export const urdfState = $state<UrdfState>({
88
- // Selection
89
- selectedLink: undefined,
90
- selectedJoint: undefined,
91
-
92
- // Robot data
93
- robot: undefined,
94
- jointStates: {
95
- continuous: {},
96
- revolute: {},
97
- },
98
-
99
- // Visibility settings
100
- visibility: {
101
- visual: true,
102
- collision: false,
103
- joints: true,
104
- jointNames: true,
105
- linkNames: true,
106
- },
107
-
108
- // Appearance settings
109
- appearance: {
110
- colors: {
111
- collision: URDF_COLORS.COLLISION,
112
- joint: URDF_COLORS.JOINT,
113
- link: URDF_COLORS.LINK,
114
- jointIndicator: URDF_COLORS.JOINT_INDICATOR,
115
- highlight: URDF_COLORS.HIGHLIGHT,
116
- background: URDF_COLORS.BACKGROUND,
117
- },
118
- opacity: {
119
- visual: 1.0,
120
- collision: 0.7,
121
- link: 1.0,
122
- },
123
- },
124
-
125
- // Editor configuration
126
- editor: {
127
- isEditMode: false,
128
- currentTool: 'translate',
129
- snap: {
130
- translation: 0.001,
131
- scale: 0.001,
132
- rotation: 1,
133
- },
134
- },
135
-
136
- // View configuration
137
- view: {
138
- zoom: {
139
- current: 1.3,
140
- initial: 1.3,
141
- },
142
- nameHeight: 0.05,
143
- },
144
- });
 
1
+ import type IUrdfJoint from "../interfaces/IUrdfJoint";
2
+ import type IUrdfLink from "../interfaces/IUrdfLink";
3
+ import type IUrdfRobot from "../interfaces/IUrdfRobot";
4
 
5
  // Color constants for better maintainability
6
  export const URDF_COLORS = {
7
+ COLLISION: "#813d9c", // purple
8
+ JOINT: "#62a0ea", // blue
9
+ LINK: "#57e389", // green
10
+ JOINT_INDICATOR: "#f66151", // red
11
+ HIGHLIGHT: "#ffa348", // orange
12
+ BACKGROUND: "#241f31" // dark purple
13
  } as const;
14
 
15
  // Transform tool types
 
17
 
18
  // Joint state tracking
19
  export interface JointStates {
20
+ continuous: Record<string, number>;
21
+ revolute: Record<string, number>;
22
  }
23
 
24
  // Selection state
25
  export interface SelectionState {
26
+ selectedLink?: IUrdfLink;
27
+ selectedJoint?: IUrdfJoint;
28
  }
29
 
30
  // Visibility configuration
31
  export interface VisibilityConfig {
32
+ visual: boolean;
33
+ collision: boolean;
34
+ joints: boolean;
35
+ jointNames: boolean;
36
+ linkNames: boolean;
37
  }
38
 
39
  // Appearance settings
40
  export interface AppearanceConfig {
41
+ colors: {
42
+ collision: string;
43
+ joint: string;
44
+ link: string;
45
+ jointIndicator: string;
46
+ highlight: string;
47
+ background: string;
48
+ };
49
+ opacity: {
50
+ visual: number;
51
+ collision: number;
52
+ link: number;
53
+ };
54
  }
55
 
56
  // Editor configuration
57
  export interface EditorConfig {
58
+ isEditMode: boolean;
59
+ currentTool: TransformTool;
60
+ snap: {
61
+ translation: number;
62
+ scale: number;
63
+ rotation: number;
64
+ };
65
  }
66
 
67
  // View configuration
68
  export interface ViewConfig {
69
+ zoom: {
70
+ current: number;
71
+ initial: number;
72
+ };
73
+ nameHeight: number;
74
  }
75
 
76
  // Main URDF state interface
77
  export interface UrdfState extends SelectionState {
78
+ robot?: IUrdfRobot;
79
+ jointStates: JointStates;
80
+ visibility: VisibilityConfig;
81
+ appearance: AppearanceConfig;
82
+ editor: EditorConfig;
83
+ view: ViewConfig;
84
  }
85
 
86
  // Create the reactive state
87
  export const urdfState = $state<UrdfState>({
88
+ // Selection
89
+ selectedLink: undefined,
90
+ selectedJoint: undefined,
91
+
92
+ // Robot data
93
+ robot: undefined,
94
+ jointStates: {
95
+ continuous: {},
96
+ revolute: {}
97
+ },
98
+
99
+ // Visibility settings
100
+ visibility: {
101
+ visual: true,
102
+ collision: false,
103
+ joints: true,
104
+ jointNames: true,
105
+ linkNames: true
106
+ },
107
+
108
+ // Appearance settings
109
+ appearance: {
110
+ colors: {
111
+ collision: URDF_COLORS.COLLISION,
112
+ joint: URDF_COLORS.JOINT,
113
+ link: URDF_COLORS.LINK,
114
+ jointIndicator: URDF_COLORS.JOINT_INDICATOR,
115
+ highlight: URDF_COLORS.HIGHLIGHT,
116
+ background: URDF_COLORS.BACKGROUND
117
+ },
118
+ opacity: {
119
+ visual: 1.0,
120
+ collision: 0.7,
121
+ link: 1.0
122
+ }
123
+ },
124
+
125
+ // Editor configuration
126
+ editor: {
127
+ isEditMode: false,
128
+ currentTool: "translate",
129
+ snap: {
130
+ translation: 0.001,
131
+ scale: 0.001,
132
+ rotation: 1
133
+ }
134
+ },
135
+
136
+ // View configuration
137
+ view: {
138
+ zoom: {
139
+ current: 1.3,
140
+ initial: 1.3
141
+ },
142
+ nameHeight: 0.05
143
+ }
144
+ });
src/lib/components/3d/robot/URDF/utils/UrdfParser.ts CHANGED
@@ -17,23 +17,23 @@ import type IUrdfRobot from "../interfaces/IUrdfRobot";
17
  * @returns An array of IUrdfLink objects that have no parent joint (i.e. root links)
18
  */
19
  export function getRootLinks(robot: IUrdfRobot): IUrdfLink[] {
20
- const links: IUrdfLink[] = [];
21
- const joints = robot.joints;
22
-
23
- for (const link of Object.values(robot.links)) {
24
- let isRoot = true;
25
- for (const joint of joints) {
26
- if (joint.child.name === link.name) {
27
- isRoot = false;
28
- break;
29
- }
30
- }
31
- if (isRoot) {
32
- links.push(link);
33
- }
34
- }
35
-
36
- return links;
37
  }
38
 
39
  /**
@@ -47,26 +47,26 @@ export function getRootLinks(robot: IUrdfRobot): IUrdfLink[] {
47
  * @returns An array of IUrdfJoint objects with no parent joint (i.e. root joints)
48
  */
49
  export function getRootJoints(robot: IUrdfRobot): IUrdfJoint[] {
50
- const joints = robot.joints;
51
- const rootJoints: IUrdfJoint[] = [];
52
-
53
- for (const joint of joints) {
54
- let isRoot = true;
55
-
56
- // If any other joint's child matches this joint's parent, then this joint isn't root
57
- for (const parentJoint of joints) {
58
- if (joint.parent.name === parentJoint.child.name) {
59
- isRoot = false;
60
- break;
61
- }
62
- }
63
-
64
- if (isRoot) {
65
- rootJoints.push(joint);
66
- }
67
- }
68
-
69
- return rootJoints;
70
  }
71
 
72
  /**
@@ -76,23 +76,20 @@ export function getRootJoints(robot: IUrdfRobot): IUrdfJoint[] {
76
  * @param parent - An IUrdfLink object to use as the "parent" in comparison
77
  * @returns A list of IUrdfJoint objects whose parent.name matches parent.name
78
  */
79
- export function getChildJoints(
80
- robot: IUrdfRobot,
81
- parent: IUrdfLink
82
- ): IUrdfJoint[] {
83
- const childJoints: IUrdfJoint[] = [];
84
- const joints = robot.joints;
85
- if (!joints) {
86
- return [];
87
- }
88
-
89
- for (const joint of joints) {
90
- if (joint.parent.name === parent.name) {
91
- childJoints.push(joint);
92
- }
93
- }
94
-
95
- return childJoints;
96
  }
97
 
98
  /**
@@ -102,9 +99,9 @@ export function getChildJoints(
102
  * @param posable - Either an IUrdfJoint or an IUrdfVisual whose `.elem` has an <origin> child
103
  */
104
  export function updateOrigin(posable: IUrdfJoint | IUrdfVisual) {
105
- const origin = posable.elem.getElementsByTagName("origin")[0];
106
- origin.setAttribute("xyz", posable.origin_xyz.join(" "));
107
- origin.setAttribute("rpy", posable.origin_rpy.join(" "));
108
  }
109
 
110
  /**
@@ -116,399 +113,400 @@ export function updateOrigin(posable: IUrdfJoint | IUrdfVisual) {
116
  * 5) Build an IUrdfRobot data structure that is easier to traverse in JS/Three.js
117
  */
118
  export class UrdfParser {
119
- filename: string;
120
- prefix: string; // e.g. "robots/so_arm100/"
121
- colors: { [name: string]: [number, number, number, number] } = {};
122
- robot: IUrdfRobot = { name: "", links: {}, joints: [] };
123
-
124
- /**
125
- * @param filename - Path or URL to the URDF file (XML). May be relative.
126
- * @param prefix - A folder prefix used when resolving "package://" or relative mesh paths.
127
- */
128
- constructor(filename: string, prefix: string = "") {
129
- this.filename = filename;
130
- // Ensure prefix ends with exactly one slash
131
- this.prefix = prefix.endsWith("/") ? prefix : prefix + "/";
132
- }
133
-
134
- /**
135
- * Fetch the URDF file from `this.filename` and return its text.
136
- * @returns A promise that resolves to the raw URDF XML string.
137
- */
138
- async load(): Promise<string> {
139
- return fetch(this.filename).then((res) => res.text());
140
- }
141
-
142
- /**
143
- * Clear any previously parsed robot data, preparing for a fresh parse.
144
- */
145
- reset() {
146
- this.robot = { name: "", links: {}, joints: [] };
147
- }
148
-
149
- /**
150
- * Parse a URDF XML string and produce an IUrdfRobot object.
151
- *
152
- * @param data - A string containing valid URDF XML.
153
- * @returns The fully populated IUrdfRobot, including colors, links, and joints.
154
- * @throws If the root element is not <robot>.
155
- */
156
- fromString(data: string): IUrdfRobot {
157
- this.reset();
158
- const dom = new window.DOMParser().parseFromString(data, "text/xml");
159
- this.robot.elem = dom.documentElement;
160
- return this.parseRobotXMLNode(dom.documentElement);
161
- }
162
-
163
- /**
164
- * Internal helper: ensure the root node is <robot>, then parse its children.
165
- *
166
- * @param robotNode - The <robot> Element from the DOMParser.
167
- * @returns The populated IUrdfRobot data structure.
168
- * @throws If robotNode.nodeName !== "robot"
169
- */
170
- private parseRobotXMLNode(robotNode: Element): IUrdfRobot {
171
- if (robotNode.nodeName !== "robot") {
172
- throw new Error(`Invalid URDF: no <robot> (found <${robotNode.nodeName}>)`);
173
- }
174
-
175
- this.robot.name = robotNode.getAttribute("name") || "";
176
- this.parseColorsFromRobot(robotNode);
177
- this.parseLinks(robotNode);
178
- this.parseJoints(robotNode);
179
- return this.robot;
180
- }
181
-
182
- /**
183
- * Look at all <material> tags under <robot> and store their names → RGBA values.
184
- *
185
- * @param robotNode - The <robot> Element.
186
- */
187
- private parseColorsFromRobot(robotNode: Element) {
188
- const xmlMaterials = robotNode.getElementsByTagName("material");
189
- for (let i = 0; i < xmlMaterials.length; i++) {
190
- const matNode = xmlMaterials[i];
191
- if (!matNode.hasAttribute("name")) {
192
- console.warn("Found <material> with no name attribute");
193
- continue;
194
- }
195
- const name = matNode.getAttribute("name")!;
196
- const colorTags = matNode.getElementsByTagName("color");
197
- if (colorTags.length === 0) continue;
198
-
199
- const colorElem = colorTags[0];
200
- if (!colorElem.hasAttribute("rgba")) continue;
201
-
202
- // e.g. "0.06 0.4 0.1 1.0"
203
- const rgba = rgbaFromString(colorElem) || [0, 0, 0, 1];
204
- this.colors[name] = rgba;
205
- }
206
- }
207
-
208
- /**
209
- * Parse every <link> under <robot> and build an IUrdfLink entry containing:
210
- * - name
211
- * - arrays of IUrdfVisual for <visual> tags
212
- * - arrays of IUrdfVisual for <collision> tags
213
- * - a pointer to its original XML Element (elem)
214
- *
215
- * @param robotNode - The <robot> Element.
216
- */
217
- private parseLinks(robotNode: Element) {
218
- const xmlLinks = robotNode.getElementsByTagName("link");
219
- for (let i = 0; i < xmlLinks.length; i++) {
220
- const linkXml = xmlLinks[i];
221
- if (!linkXml.hasAttribute("name")) {
222
- console.error("Link without a name:", linkXml);
223
- continue;
224
- }
225
- const linkName = linkXml.getAttribute("name")!;
226
-
227
- const linkObj: IUrdfLink = {
228
- name: linkName,
229
- visual: [],
230
- collision: [],
231
- elem: linkXml,
232
- };
233
- this.robot.links[linkName] = linkObj;
234
-
235
- // Parse all <visual> children
236
- const visualXmls = linkXml.getElementsByTagName("visual");
237
- for (let j = 0; j < visualXmls.length; j++) {
238
- linkObj.visual.push(this.parseVisual(visualXmls[j]));
239
- }
240
-
241
- // Parse all <collision> children (reuse parseVisual; color is ignored later)
242
- const collXmls = linkXml.getElementsByTagName("collision");
243
- for (let j = 0; j < collXmls.length; j++) {
244
- linkObj.collision.push(this.parseVisual(collXmls[j]));
245
- }
246
- }
247
- }
248
-
249
- /**
250
- * Parse a <visual> or <collision> element into an IUrdfVisual. Reads:
251
- * - <geometry> (calls parseGeometry to extract mesh, cylinder, box, etc.)
252
- * - <origin> (xyz, rpy)
253
- * - <material> (either embedded <color> or named reference)
254
- *
255
- * @param node - The <visual> or <collision> Element.
256
- * @returns A fully populated IUrdfVisual object.
257
- */
258
- private parseVisual(node: Element): IUrdfVisual {
259
- const visual: Partial<IUrdfVisual> = { elem: node };
260
-
261
- for (let i = 0; i < node.childNodes.length; i++) {
262
- const child = node.childNodes[i];
263
-
264
- // Skip non-element nodes (like text nodes containing whitespace)
265
- if (child.nodeType !== Node.ELEMENT_NODE) {
266
- continue;
267
- }
268
-
269
- const childElement = child as Element;
270
- switch (childElement.nodeName) {
271
- case "geometry": {
272
- this.parseGeometry(childElement, visual);
273
- break;
274
- }
275
- case "origin": {
276
- const pos = xyzFromString(childElement);
277
- const rpy = rpyFromString(childElement);
278
- if (pos) visual.origin_xyz = pos;
279
- if (rpy) visual.origin_rpy = rpy;
280
- break;
281
- }
282
- case "material": {
283
- const cols = childElement.getElementsByTagName("color");
284
- if (cols.length > 0 && cols[0].hasAttribute("rgba")) {
285
- // Inline color specification
286
- visual.color_rgba = rgbaFromString(cols[0])!;
287
- } else if (childElement.hasAttribute("name")) {
288
- // Named material → look up previously parsed RGBA
289
- const nm = childElement.getAttribute("name")!;
290
- visual.color_rgba = this.colors[nm];
291
- }
292
- break;
293
- }
294
- default: {
295
- console.warn("Unknown child node:", childElement.nodeName);
296
- break;
297
- }
298
- }
299
- }
300
-
301
- return visual as IUrdfVisual;
302
- }
303
-
304
- /**
305
- * Parse a <geometry> element inside <visual> or <collision>.
306
- * Currently only supports <mesh>. If you need <cylinder> or <box>,
307
- * you can extend this function similarly.
308
- *
309
- * @param node - The <geometry> Element.
310
- * @param visual - A partial IUrdfVisual object to populate
311
- */
312
- private parseGeometry(node: Element, visual: Partial<IUrdfVisual>) {
313
- for (let i = 0; i < node.childNodes.length; i++) {
314
- const child = node.childNodes[i];
315
-
316
- // Skip non-element nodes (like text nodes containing whitespace)
317
- if (child.nodeType !== Node.ELEMENT_NODE) {
318
- continue;
319
- }
320
-
321
- const childElement = child as Element;
322
- if (childElement.nodeName === "mesh") {
323
- const rawFilename = childElement.getAttribute("filename");
324
- if (!rawFilename) {
325
- console.warn("<mesh> missing filename!");
326
- return;
327
- }
328
-
329
- // 1) Resolve the URL (handles "package://" or relative paths)
330
- const resolvedUrl = this.resolveFilename(rawFilename);
331
-
332
- // 2) Parse optional scale (e.g. "1 1 1")
333
- let scale: [number, number, number] = [1, 1, 1];
334
- if (childElement.hasAttribute("scale")) {
335
- const parts = childElement.getAttribute("scale")!.split(" ").map(parseFloat);
336
- if (parts.length === 3) {
337
- scale = [parts[0], parts[1], parts[2]];
338
- }
339
- }
340
-
341
- // 3) Deduce mesh type from file extension
342
- const ext = resolvedUrl.slice(resolvedUrl.lastIndexOf(".") + 1).toLowerCase();
343
- let type: "stl" | "fbx" | "obj" | "dae";
344
- switch (ext) {
345
- case "stl":
346
- type = "stl";
347
- break;
348
- case "fbx":
349
- type = "fbx";
350
- break;
351
- case "obj":
352
- type = "obj";
353
- break;
354
- case "dae":
355
- type = "dae";
356
- break;
357
- default:
358
- throw new Error("Unknown mesh extension: " + ext);
359
- }
360
-
361
- visual.geometry = { filename: resolvedUrl, type, scale } as IUrdfMesh;
362
- visual.type = "mesh";
363
- return;
364
- }
365
-
366
- // If you also want <cylinder> or <box>, copy your previous logic here:
367
- // e.g. if (childElement.nodeName === "cylinder") { … }
368
- }
369
- }
370
-
371
- /**
372
- * Transform a URI‐like string into an actual URL. Handles:
373
- * 1) http(s):// or data: → leave unchanged
374
- * 2) package://some_package/... → replace with prefix + "some_package/...
375
- * 3) package:/some_package/... → same as above
376
- * 4) Anything else (e.g. "meshes/Foo.stl") is treated as relative.
377
- *
378
- * @param raw - The raw filename from URDF (e.g. "meshes/Base.stl" or "package://my_pkg/mesh.dae")
379
- * @returns The fully resolved URL string
380
- */
381
- private resolveFilename(raw: string): string {
382
- // 1) absolute http(s) or data URIs
383
- if (/^https?:\/\//.test(raw) || raw.startsWith("data:")) {
384
- return raw;
385
- }
386
-
387
- // 2) package://some_package/…
388
- if (raw.startsWith("package://")) {
389
- const rel = raw.substring("package://".length);
390
- return this.joinUrl(this.prefix, rel);
391
- }
392
-
393
- // 3) package:/some_package/…
394
- if (raw.startsWith("package:/")) {
395
- const rel = raw.substring("package:/".length);
396
- return this.joinUrl(this.prefix, rel);
397
- }
398
-
399
- // 4) anything else (e.g. "meshes/Foo.stl") is treated as relative
400
- return this.joinUrl(this.prefix, raw);
401
- }
402
-
403
- /**
404
- * Helper to join a base URL with a relative path, ensuring exactly one '/' in between
405
- *
406
- * @param base - e.g. "/robots/so_arm100/"
407
- * @param rel - e.g. "meshes/Base.stl" (with or without a leading slash)
408
- * @returns A string like "/robots/so_arm100/meshes/Base.stl"
409
- */
410
- private joinUrl(base: string, rel: string): string {
411
- if (!base.startsWith("/")) base = "/" + base;
412
- if (!base.endsWith("/")) base = base + "/";
413
- if (rel.startsWith("/")) rel = rel.substring(1);
414
- return base + rel;
415
- }
416
-
417
- /**
418
- * Parse every <joint> under <robot> and build an IUrdfJoint entry. For each joint:
419
- * 1) parent link (lookup in `this.robot.links[parentName]`)
420
- * 2) child link (lookup in `this.robot.links[childName]`)
421
- * 3) origin: xyz + rpy
422
- * 4) axis (default [0,0,1] if absent)
423
- * 5) limit (if present, lower/upper/effort/velocity)
424
- *
425
- * @param robotNode - The <robot> Element.
426
- * @throws If a joint references a link name that doesn't exist.
427
- */
428
- private parseJoints(robotNode: Element) {
429
- const links = this.robot.links;
430
- const joints: IUrdfJoint[] = [];
431
- this.robot.joints = joints;
432
-
433
- const xmlJoints = robotNode.getElementsByTagName("joint");
434
- for (let i = 0; i < xmlJoints.length; i++) {
435
- const jointXml = xmlJoints[i];
436
- const parentElems = jointXml.getElementsByTagName("parent");
437
- const childElems = jointXml.getElementsByTagName("child");
438
- if (parentElems.length !== 1 || childElems.length !== 1) {
439
- console.warn("Joint without exactly one <parent> or <child>:", jointXml);
440
- continue;
441
- }
442
-
443
- const parentName = parentElems[0].getAttribute("link")!;
444
- const childName = childElems[0].getAttribute("link")!;
445
-
446
- const parentLink = links[parentName];
447
- const childLink = links[childName];
448
- if (!parentLink || !childLink) {
449
- throw new Error(
450
- `Joint references missing link: ${parentName} or ${childName}`
451
- );
452
- }
453
-
454
- // Default origin and rpy
455
- let xyz: [number, number, number] = [0, 0, 0];
456
- let rpy: [number, number, number] = [0, 0, 0];
457
- const originTags = jointXml.getElementsByTagName("origin");
458
- if (originTags.length === 1) {
459
- xyz = xyzFromString(originTags[0]) || xyz;
460
- rpy = rpyFromString(originTags[0]) || rpy;
461
- }
462
-
463
- // Default axis
464
- let axis: [number, number, number] = [0, 0, 1];
465
- const axisTags = jointXml.getElementsByTagName("axis");
466
- if (axisTags.length === 1) {
467
- axis = xyzFromString(axisTags[0]) || axis;
468
- }
469
-
470
- // Optional limit
471
- let limit;
472
- const limitTags = jointXml.getElementsByTagName("limit");
473
- if (limitTags.length === 1) {
474
- const lim = limitTags[0];
475
- limit = {
476
- lower: parseFloat(lim.getAttribute("lower") || "0"),
477
- upper: parseFloat(lim.getAttribute("upper") || "0"),
478
- effort: parseFloat(lim.getAttribute("effort") || "0"),
479
- velocity: parseFloat(lim.getAttribute("velocity") || "0"),
480
- };
481
- }
482
-
483
- joints.push({
484
- name: jointXml.getAttribute("name") || undefined,
485
- type: jointXml.getAttribute("type") as "revolute" | "continuous" | "prismatic" | "fixed" | "floating" | "planar",
486
- origin_xyz: xyz,
487
- origin_rpy: rpy,
488
- axis_xyz: axis,
489
- rotation: [0, 0, 0],
490
- parent: parentLink,
491
- child: childLink,
492
- limit: limit,
493
- elem: jointXml,
494
- });
495
- }
496
- }
497
-
498
- /**
499
- * If you ever want to re‐serialize the robot back to URDF XML,
500
- * this method returns the stringified root <robot> element.
501
- *
502
- * @returns A string beginning with '<?xml version="1.0" ?>' followed by the current XML.
503
- */
504
- getURDFXML(): string {
505
- return this.robot.elem
506
- ? '<?xml version="1.0" ?>\n' + this.robot.elem.outerHTML
507
- : "";
508
- }
 
 
509
  }
510
 
511
-
512
  /**
513
  * ==============================================================================
514
  * Example of how the parsed data (IUrdfRobot) maps from the URDF XML ("so_arm100"):
 
17
  * @returns An array of IUrdfLink objects that have no parent joint (i.e. root links)
18
  */
19
  export function getRootLinks(robot: IUrdfRobot): IUrdfLink[] {
20
+ const links: IUrdfLink[] = [];
21
+ const joints = robot.joints;
22
+
23
+ for (const link of Object.values(robot.links)) {
24
+ let isRoot = true;
25
+ for (const joint of joints) {
26
+ if (joint.child.name === link.name) {
27
+ isRoot = false;
28
+ break;
29
+ }
30
+ }
31
+ if (isRoot) {
32
+ links.push(link);
33
+ }
34
+ }
35
+
36
+ return links;
37
  }
38
 
39
  /**
 
47
  * @returns An array of IUrdfJoint objects with no parent joint (i.e. root joints)
48
  */
49
  export function getRootJoints(robot: IUrdfRobot): IUrdfJoint[] {
50
+ const joints = robot.joints;
51
+ const rootJoints: IUrdfJoint[] = [];
52
+
53
+ for (const joint of joints) {
54
+ let isRoot = true;
55
+
56
+ // If any other joint's child matches this joint's parent, then this joint isn't root
57
+ for (const parentJoint of joints) {
58
+ if (joint.parent.name === parentJoint.child.name) {
59
+ isRoot = false;
60
+ break;
61
+ }
62
+ }
63
+
64
+ if (isRoot) {
65
+ rootJoints.push(joint);
66
+ }
67
+ }
68
+
69
+ return rootJoints;
70
  }
71
 
72
  /**
 
76
  * @param parent - An IUrdfLink object to use as the "parent" in comparison
77
  * @returns A list of IUrdfJoint objects whose parent.name matches parent.name
78
  */
79
+ export function getChildJoints(robot: IUrdfRobot, parent: IUrdfLink): IUrdfJoint[] {
80
+ const childJoints: IUrdfJoint[] = [];
81
+ const joints = robot.joints;
82
+ if (!joints) {
83
+ return [];
84
+ }
85
+
86
+ for (const joint of joints) {
87
+ if (joint.parent.name === parent.name) {
88
+ childJoints.push(joint);
89
+ }
90
+ }
91
+
92
+ return childJoints;
 
 
 
93
  }
94
 
95
  /**
 
99
  * @param posable - Either an IUrdfJoint or an IUrdfVisual whose `.elem` has an <origin> child
100
  */
101
  export function updateOrigin(posable: IUrdfJoint | IUrdfVisual) {
102
+ const origin = posable.elem.getElementsByTagName("origin")[0];
103
+ origin.setAttribute("xyz", posable.origin_xyz.join(" "));
104
+ origin.setAttribute("rpy", posable.origin_rpy.join(" "));
105
  }
106
 
107
  /**
 
113
  * 5) Build an IUrdfRobot data structure that is easier to traverse in JS/Three.js
114
  */
115
  export class UrdfParser {
116
+ filename: string;
117
+ prefix: string; // e.g. "robots/so_arm100/"
118
+ colors: { [name: string]: [number, number, number, number] } = {};
119
+ robot: IUrdfRobot = { name: "", links: {}, joints: [] };
120
+
121
+ /**
122
+ * @param filename - Path or URL to the URDF file (XML). May be relative.
123
+ * @param prefix - A folder prefix used when resolving "package://" or relative mesh paths.
124
+ */
125
+ constructor(filename: string, prefix: string = "") {
126
+ this.filename = filename;
127
+ // Ensure prefix ends with exactly one slash
128
+ this.prefix = prefix.endsWith("/") ? prefix : prefix + "/";
129
+ }
130
+
131
+ /**
132
+ * Fetch the URDF file from `this.filename` and return its text.
133
+ * @returns A promise that resolves to the raw URDF XML string.
134
+ */
135
+ async load(): Promise<string> {
136
+ return fetch(this.filename).then((res) => res.text());
137
+ }
138
+
139
+ /**
140
+ * Clear any previously parsed robot data, preparing for a fresh parse.
141
+ */
142
+ reset() {
143
+ this.robot = { name: "", links: {}, joints: [] };
144
+ }
145
+
146
+ /**
147
+ * Parse a URDF XML string and produce an IUrdfRobot object.
148
+ *
149
+ * @param data - A string containing valid URDF XML.
150
+ * @returns The fully populated IUrdfRobot, including colors, links, and joints.
151
+ * @throws If the root element is not <robot>.
152
+ */
153
+ fromString(data: string): IUrdfRobot {
154
+ this.reset();
155
+ const dom = new window.DOMParser().parseFromString(data, "text/xml");
156
+ this.robot.elem = dom.documentElement;
157
+ return this.parseRobotXMLNode(dom.documentElement);
158
+ }
159
+
160
+ /**
161
+ * Internal helper: ensure the root node is <robot>, then parse its children.
162
+ *
163
+ * @param robotNode - The <robot> Element from the DOMParser.
164
+ * @returns The populated IUrdfRobot data structure.
165
+ * @throws If robotNode.nodeName !== "robot"
166
+ */
167
+ private parseRobotXMLNode(robotNode: Element): IUrdfRobot {
168
+ if (robotNode.nodeName !== "robot") {
169
+ throw new Error(`Invalid URDF: no <robot> (found <${robotNode.nodeName}>)`);
170
+ }
171
+
172
+ this.robot.name = robotNode.getAttribute("name") || "";
173
+ this.parseColorsFromRobot(robotNode);
174
+ this.parseLinks(robotNode);
175
+ this.parseJoints(robotNode);
176
+ return this.robot;
177
+ }
178
+
179
+ /**
180
+ * Look at all <material> tags under <robot> and store their names → RGBA values.
181
+ *
182
+ * @param robotNode - The <robot> Element.
183
+ */
184
+ private parseColorsFromRobot(robotNode: Element) {
185
+ const xmlMaterials = robotNode.getElementsByTagName("material");
186
+ for (let i = 0; i < xmlMaterials.length; i++) {
187
+ const matNode = xmlMaterials[i];
188
+ if (!matNode.hasAttribute("name")) {
189
+ console.warn("Found <material> with no name attribute");
190
+ continue;
191
+ }
192
+ const name = matNode.getAttribute("name")!;
193
+ const colorTags = matNode.getElementsByTagName("color");
194
+ if (colorTags.length === 0) continue;
195
+
196
+ const colorElem = colorTags[0];
197
+ if (!colorElem.hasAttribute("rgba")) continue;
198
+
199
+ // e.g. "0.06 0.4 0.1 1.0"
200
+ const rgba = rgbaFromString(colorElem) || [0, 0, 0, 1];
201
+ this.colors[name] = rgba;
202
+ }
203
+ }
204
+
205
+ /**
206
+ * Parse every <link> under <robot> and build an IUrdfLink entry containing:
207
+ * - name
208
+ * - arrays of IUrdfVisual for <visual> tags
209
+ * - arrays of IUrdfVisual for <collision> tags
210
+ * - a pointer to its original XML Element (elem)
211
+ *
212
+ * @param robotNode - The <robot> Element.
213
+ */
214
+ private parseLinks(robotNode: Element) {
215
+ const xmlLinks = robotNode.getElementsByTagName("link");
216
+ for (let i = 0; i < xmlLinks.length; i++) {
217
+ const linkXml = xmlLinks[i];
218
+ if (!linkXml.hasAttribute("name")) {
219
+ console.error("Link without a name:", linkXml);
220
+ continue;
221
+ }
222
+ const linkName = linkXml.getAttribute("name")!;
223
+
224
+ const linkObj: IUrdfLink = {
225
+ name: linkName,
226
+ visual: [],
227
+ collision: [],
228
+ elem: linkXml
229
+ };
230
+ this.robot.links[linkName] = linkObj;
231
+
232
+ // Parse all <visual> children
233
+ const visualXmls = linkXml.getElementsByTagName("visual");
234
+ for (let j = 0; j < visualXmls.length; j++) {
235
+ linkObj.visual.push(this.parseVisual(visualXmls[j]));
236
+ }
237
+
238
+ // Parse all <collision> children (reuse parseVisual; color is ignored later)
239
+ const collXmls = linkXml.getElementsByTagName("collision");
240
+ for (let j = 0; j < collXmls.length; j++) {
241
+ linkObj.collision.push(this.parseVisual(collXmls[j]));
242
+ }
243
+ }
244
+ }
245
+
246
+ /**
247
+ * Parse a <visual> or <collision> element into an IUrdfVisual. Reads:
248
+ * - <geometry> (calls parseGeometry to extract mesh, cylinder, box, etc.)
249
+ * - <origin> (xyz, rpy)
250
+ * - <material> (either embedded <color> or named reference)
251
+ *
252
+ * @param node - The <visual> or <collision> Element.
253
+ * @returns A fully populated IUrdfVisual object.
254
+ */
255
+ private parseVisual(node: Element): IUrdfVisual {
256
+ const visual: Partial<IUrdfVisual> = { elem: node };
257
+
258
+ for (let i = 0; i < node.childNodes.length; i++) {
259
+ const child = node.childNodes[i];
260
+
261
+ // Skip non-element nodes (like text nodes containing whitespace)
262
+ if (child.nodeType !== Node.ELEMENT_NODE) {
263
+ continue;
264
+ }
265
+
266
+ const childElement = child as Element;
267
+ switch (childElement.nodeName) {
268
+ case "geometry": {
269
+ this.parseGeometry(childElement, visual);
270
+ break;
271
+ }
272
+ case "origin": {
273
+ const pos = xyzFromString(childElement);
274
+ const rpy = rpyFromString(childElement);
275
+ if (pos) visual.origin_xyz = pos;
276
+ if (rpy) visual.origin_rpy = rpy;
277
+ break;
278
+ }
279
+ case "material": {
280
+ const cols = childElement.getElementsByTagName("color");
281
+ if (cols.length > 0 && cols[0].hasAttribute("rgba")) {
282
+ // Inline color specification
283
+ visual.color_rgba = rgbaFromString(cols[0])!;
284
+ } else if (childElement.hasAttribute("name")) {
285
+ // Named material → look up previously parsed RGBA
286
+ const nm = childElement.getAttribute("name")!;
287
+ visual.color_rgba = this.colors[nm];
288
+ }
289
+ break;
290
+ }
291
+ default: {
292
+ console.warn("Unknown child node:", childElement.nodeName);
293
+ break;
294
+ }
295
+ }
296
+ }
297
+
298
+ return visual as IUrdfVisual;
299
+ }
300
+
301
+ /**
302
+ * Parse a <geometry> element inside <visual> or <collision>.
303
+ * Currently only supports <mesh>. If you need <cylinder> or <box>,
304
+ * you can extend this function similarly.
305
+ *
306
+ * @param node - The <geometry> Element.
307
+ * @param visual - A partial IUrdfVisual object to populate
308
+ */
309
+ private parseGeometry(node: Element, visual: Partial<IUrdfVisual>) {
310
+ for (let i = 0; i < node.childNodes.length; i++) {
311
+ const child = node.childNodes[i];
312
+
313
+ // Skip non-element nodes (like text nodes containing whitespace)
314
+ if (child.nodeType !== Node.ELEMENT_NODE) {
315
+ continue;
316
+ }
317
+
318
+ const childElement = child as Element;
319
+ if (childElement.nodeName === "mesh") {
320
+ const rawFilename = childElement.getAttribute("filename");
321
+ if (!rawFilename) {
322
+ console.warn("<mesh> missing filename!");
323
+ return;
324
+ }
325
+
326
+ // 1) Resolve the URL (handles "package://" or relative paths)
327
+ const resolvedUrl = this.resolveFilename(rawFilename);
328
+
329
+ // 2) Parse optional scale (e.g. "1 1 1")
330
+ let scale: [number, number, number] = [1, 1, 1];
331
+ if (childElement.hasAttribute("scale")) {
332
+ const parts = childElement.getAttribute("scale")!.split(" ").map(parseFloat);
333
+ if (parts.length === 3) {
334
+ scale = [parts[0], parts[1], parts[2]];
335
+ }
336
+ }
337
+
338
+ // 3) Deduce mesh type from file extension
339
+ const ext = resolvedUrl.slice(resolvedUrl.lastIndexOf(".") + 1).toLowerCase();
340
+ let type: "stl" | "fbx" | "obj" | "dae";
341
+ switch (ext) {
342
+ case "stl":
343
+ type = "stl";
344
+ break;
345
+ case "fbx":
346
+ type = "fbx";
347
+ break;
348
+ case "obj":
349
+ type = "obj";
350
+ break;
351
+ case "dae":
352
+ type = "dae";
353
+ break;
354
+ default:
355
+ throw new Error("Unknown mesh extension: " + ext);
356
+ }
357
+
358
+ visual.geometry = { filename: resolvedUrl, type, scale } as IUrdfMesh;
359
+ visual.type = "mesh";
360
+ return;
361
+ }
362
+
363
+ // If you also want <cylinder> or <box>, copy your previous logic here:
364
+ // e.g. if (childElement.nodeName === "cylinder") { … }
365
+ }
366
+ }
367
+
368
+ /**
369
+ * Transform a URI‐like string into an actual URL. Handles:
370
+ * 1) http(s):// or data: → leave unchanged
371
+ * 2) package://some_package/... → replace with prefix + "some_package/...
372
+ * 3) package:/some_package/... → same as above
373
+ * 4) Anything else (e.g. "meshes/Foo.stl") is treated as relative.
374
+ *
375
+ * @param raw - The raw filename from URDF (e.g. "meshes/Base.stl" or "package://my_pkg/mesh.dae")
376
+ * @returns The fully resolved URL string
377
+ */
378
+ private resolveFilename(raw: string): string {
379
+ // 1) absolute http(s) or data URIs
380
+ if (/^https?:\/\//.test(raw) || raw.startsWith("data:")) {
381
+ return raw;
382
+ }
383
+
384
+ // 2) package://some_package/…
385
+ if (raw.startsWith("package://")) {
386
+ const rel = raw.substring("package://".length);
387
+ return this.joinUrl(this.prefix, rel);
388
+ }
389
+
390
+ // 3) package:/some_package/…
391
+ if (raw.startsWith("package:/")) {
392
+ const rel = raw.substring("package:/".length);
393
+ return this.joinUrl(this.prefix, rel);
394
+ }
395
+
396
+ // 4) anything else (e.g. "meshes/Foo.stl") is treated as relative
397
+ return this.joinUrl(this.prefix, raw);
398
+ }
399
+
400
+ /**
401
+ * Helper to join a base URL with a relative path, ensuring exactly one '/' in between
402
+ *
403
+ * @param base - e.g. "/robots/so_arm100/"
404
+ * @param rel - e.g. "meshes/Base.stl" (with or without a leading slash)
405
+ * @returns A string like "/robots/so_arm100/meshes/Base.stl"
406
+ */
407
+ private joinUrl(base: string, rel: string): string {
408
+ if (!base.startsWith("/")) base = "/" + base;
409
+ if (!base.endsWith("/")) base = base + "/";
410
+ if (rel.startsWith("/")) rel = rel.substring(1);
411
+ return base + rel;
412
+ }
413
+
414
+ /**
415
+ * Parse every <joint> under <robot> and build an IUrdfJoint entry. For each joint:
416
+ * 1) parent link (lookup in `this.robot.links[parentName]`)
417
+ * 2) child link (lookup in `this.robot.links[childName]`)
418
+ * 3) origin: xyz + rpy
419
+ * 4) axis (default [0,0,1] if absent)
420
+ * 5) limit (if present, lower/upper/effort/velocity)
421
+ *
422
+ * @param robotNode - The <robot> Element.
423
+ * @throws If a joint references a link name that doesn't exist.
424
+ */
425
+ private parseJoints(robotNode: Element) {
426
+ const links = this.robot.links;
427
+ const joints: IUrdfJoint[] = [];
428
+ this.robot.joints = joints;
429
+
430
+ const xmlJoints = robotNode.getElementsByTagName("joint");
431
+ for (let i = 0; i < xmlJoints.length; i++) {
432
+ const jointXml = xmlJoints[i];
433
+ const parentElems = jointXml.getElementsByTagName("parent");
434
+ const childElems = jointXml.getElementsByTagName("child");
435
+ if (parentElems.length !== 1 || childElems.length !== 1) {
436
+ console.warn("Joint without exactly one <parent> or <child>:", jointXml);
437
+ continue;
438
+ }
439
+
440
+ const parentName = parentElems[0].getAttribute("link")!;
441
+ const childName = childElems[0].getAttribute("link")!;
442
+
443
+ const parentLink = links[parentName];
444
+ const childLink = links[childName];
445
+ if (!parentLink || !childLink) {
446
+ throw new Error(`Joint references missing link: ${parentName} or ${childName}`);
447
+ }
448
+
449
+ // Default origin and rpy
450
+ let xyz: [number, number, number] = [0, 0, 0];
451
+ let rpy: [number, number, number] = [0, 0, 0];
452
+ const originTags = jointXml.getElementsByTagName("origin");
453
+ if (originTags.length === 1) {
454
+ xyz = xyzFromString(originTags[0]) || xyz;
455
+ rpy = rpyFromString(originTags[0]) || rpy;
456
+ }
457
+
458
+ // Default axis
459
+ let axis: [number, number, number] = [0, 0, 1];
460
+ const axisTags = jointXml.getElementsByTagName("axis");
461
+ if (axisTags.length === 1) {
462
+ axis = xyzFromString(axisTags[0]) || axis;
463
+ }
464
+
465
+ // Optional limit
466
+ let limit;
467
+ const limitTags = jointXml.getElementsByTagName("limit");
468
+ if (limitTags.length === 1) {
469
+ const lim = limitTags[0];
470
+ limit = {
471
+ lower: parseFloat(lim.getAttribute("lower") || "0"),
472
+ upper: parseFloat(lim.getAttribute("upper") || "0"),
473
+ effort: parseFloat(lim.getAttribute("effort") || "0"),
474
+ velocity: parseFloat(lim.getAttribute("velocity") || "0")
475
+ };
476
+ }
477
+
478
+ joints.push({
479
+ name: jointXml.getAttribute("name") || undefined,
480
+ type: jointXml.getAttribute("type") as
481
+ | "revolute"
482
+ | "continuous"
483
+ | "prismatic"
484
+ | "fixed"
485
+ | "floating"
486
+ | "planar",
487
+ origin_xyz: xyz,
488
+ origin_rpy: rpy,
489
+ axis_xyz: axis,
490
+ rotation: [0, 0, 0],
491
+ parent: parentLink,
492
+ child: childLink,
493
+ limit: limit,
494
+ elem: jointXml
495
+ });
496
+ }
497
+ }
498
+
499
+ /**
500
+ * If you ever want to re‐serialize the robot back to URDF XML,
501
+ * this method returns the stringified root <robot> element.
502
+ *
503
+ * @returns A string beginning with '<?xml version="1.0" ?>' followed by the current XML.
504
+ */
505
+ getURDFXML(): string {
506
+ return this.robot.elem ? '<?xml version="1.0" ?>\n' + this.robot.elem.outerHTML : "";
507
+ }
508
  }
509
 
 
510
  /**
511
  * ==============================================================================
512
  * Example of how the parsed data (IUrdfRobot) maps from the URDF XML ("so_arm100"):
src/lib/components/3d/robot/URDF/utils/helper.ts CHANGED
@@ -1,50 +1,53 @@
1
  export function xyzFromString(child: Element): [x: number, y: number, z: number] | undefined {
2
- const arr = numberStringToArray(child, 'xyz');
3
- if (!arr || arr.length != 3) {
4
- return
5
- }
6
- return arr as [x: number, y: number, z: number];
7
  }
8
 
9
- export function rpyFromString(child: Element): [roll: number, pitch: number, yaw: number] | undefined {
10
- const arr = numberStringToArray(child, 'rpy');
11
- if (!arr || arr.length != 3) {
12
- return
13
- }
14
- return arr as [roll: number, pitch: number, yaw: number];
 
 
15
  }
16
 
17
- export function rgbaFromString(child: Element): [r: number, g: number, b: number, a: number] | undefined {
18
- const arr = numberStringToArray(child, 'rgba');
19
- if (!arr || arr.length != 4) {
20
- return
21
- }
22
- return arr as [r: number, g: number, b: number, a: number];
 
 
23
  }
24
 
25
- export function numberStringToArray(
26
- child: Element, name: string = 'xyz'): number[] | undefined {
27
- // parse a list of values from a string
28
- // (like "1.0 2.2 3.0" into an array like [1, 2.2, 3])
29
- // used in URDF for position, orientation an color values
30
- if (child.hasAttribute(name)) {
31
- const xyzStr = child.getAttribute(name)?.split(' ')
32
- if (xyzStr) {
33
- const arr = []
34
- for (const nr of xyzStr) {
35
- arr.push(parseFloat(nr));
36
- }
37
- return arr;
38
- }
39
- }
40
  }
41
 
42
  export function radToEuler(rad: number): number {
43
- return rad * 180 / Math.PI
44
  }
45
 
46
  export function numberArrayToColor([r, g, b]: [number, number, number]): string {
47
- const toHex = (n: number) => Math.round(n).toString(16).padStart(2, '0');
48
- // 0.06, 0.4, 0.1, 1
49
- return `#${toHex(r * 255)}${toHex(g * 255)}${toHex(b * 255)}`;
50
  }
 
1
  export function xyzFromString(child: Element): [x: number, y: number, z: number] | undefined {
2
+ const arr = numberStringToArray(child, "xyz");
3
+ if (!arr || arr.length != 3) {
4
+ return;
5
+ }
6
+ return arr as [x: number, y: number, z: number];
7
  }
8
 
9
+ export function rpyFromString(
10
+ child: Element
11
+ ): [roll: number, pitch: number, yaw: number] | undefined {
12
+ const arr = numberStringToArray(child, "rpy");
13
+ if (!arr || arr.length != 3) {
14
+ return;
15
+ }
16
+ return arr as [roll: number, pitch: number, yaw: number];
17
  }
18
 
19
+ export function rgbaFromString(
20
+ child: Element
21
+ ): [r: number, g: number, b: number, a: number] | undefined {
22
+ const arr = numberStringToArray(child, "rgba");
23
+ if (!arr || arr.length != 4) {
24
+ return;
25
+ }
26
+ return arr as [r: number, g: number, b: number, a: number];
27
  }
28
 
29
+ export function numberStringToArray(child: Element, name: string = "xyz"): number[] | undefined {
30
+ // parse a list of values from a string
31
+ // (like "1.0 2.2 3.0" into an array like [1, 2.2, 3])
32
+ // used in URDF for position, orientation an color values
33
+ if (child.hasAttribute(name)) {
34
+ const xyzStr = child.getAttribute(name)?.split(" ");
35
+ if (xyzStr) {
36
+ const arr = [];
37
+ for (const nr of xyzStr) {
38
+ arr.push(parseFloat(nr));
39
+ }
40
+ return arr;
41
+ }
42
+ }
 
43
  }
44
 
45
  export function radToEuler(rad: number): number {
46
+ return (rad * 180) / Math.PI;
47
  }
48
 
49
  export function numberArrayToColor([r, g, b]: [number, number, number]): string {
50
+ const toHex = (n: number) => Math.round(n).toString(16).padStart(2, "0");
51
+ // 0.06, 0.4, 0.1, 1
52
+ return `#${toHex(r * 255)}${toHex(g * 255)}${toHex(b * 255)}`;
53
  }
src/lib/components/ButtonBar.svelte DELETED
@@ -1,161 +0,0 @@
1
- <script lang="ts">
2
- import { Button } from '$lib/components/ui/button';
3
- import { Badge } from '$lib/components/ui/badge';
4
- import * as Popover from '$lib/components/ui/popover';
5
- import { toast } from 'svelte-sonner';
6
- import { cn } from '$lib/utils';
7
- import { robotManager } from "$lib/robot/RobotManager.svelte";
8
- import { robotUrdfConfigMap } from "$lib/configs/robotUrdfConfig";
9
-
10
- interface Props {
11
- controlsOpen?: boolean;
12
- onToggleControls?: () => void;
13
- }
14
-
15
- let {
16
- controlsOpen = $bindable(false),
17
- onToggleControls
18
- }: Props = $props();
19
-
20
- let isCreating = $state(false);
21
- let selectedRobotType = $state("so-arm100"); // Default to SO-100
22
- let showRobotSelector = $state(false);
23
-
24
- // Get available robot types
25
- const robotTypes = Object.keys(robotUrdfConfigMap);
26
-
27
- // Function to add robot using the proper robot creation logic
28
- async function addRobot() {
29
- if (isCreating) return;
30
-
31
- console.log(`Creating ${selectedRobotType} robot...`);
32
- isCreating = true;
33
-
34
- try {
35
- const urdfConfig = robotUrdfConfigMap[selectedRobotType];
36
-
37
- if (!urdfConfig) {
38
- throw new Error(`Unknown robot type: ${selectedRobotType}`);
39
- }
40
-
41
- const robotId = `robot-${Date.now()}`;
42
- console.log('Creating robot with ID:', robotId, 'and config:', urdfConfig);
43
-
44
- const robot = await robotManager.createRobot(robotId, urdfConfig);
45
- console.log('Robot created successfully:', robot);
46
-
47
- toast.success("Robot Added", {
48
- description: `${selectedRobotType} robot ${robotId.slice(0, 12)}... created successfully.`
49
- });
50
-
51
- showRobotSelector = false; // Close selector on success
52
-
53
- } catch (error) {
54
- console.error('Robot creation failed:', error);
55
- toast.error("Failed to Add Robot", {
56
- description: `Could not create ${selectedRobotType} robot: ${error}`
57
- });
58
- } finally {
59
- isCreating = false;
60
- }
61
- }
62
-
63
- // Quick add SO-100 function
64
- async function quickAddSO100() {
65
- selectedRobotType = "so-arm100";
66
- await addRobot();
67
- }
68
- </script>
69
-
70
- <!-- Button Bar Container -->
71
- <div class="fixed top-4 left-4 z-50 flex gap-2">
72
- <!-- Add Robot Button with Dropdown -->
73
- <div class="flex">
74
- <!-- Main Add Button (SO-100) -->
75
- <Button
76
- variant="default"
77
- size="sm"
78
- onclick={quickAddSO100}
79
- disabled={isCreating}
80
- class="shadow-lg bg-green-600 hover:bg-green-500 disabled:bg-gray-600 text-white border-green-500 transition-all duration-200 group rounded-r-none border-r-0"
81
- >
82
- <span class={cn(
83
- "size-4 mr-2 transition-transform duration-200",
84
- isCreating ? "icon-[mdi--loading] animate-spin" : "icon-[mdi--plus] group-hover:rotate-90"
85
- )}></span>
86
- {isCreating ? 'Creating...' : 'Add SO-100'}
87
- </Button>
88
-
89
- <!-- Dropdown Button -->
90
- <Popover.Root bind:open={showRobotSelector}>
91
- <Popover.Trigger>
92
- {#snippet child({ props })}
93
- <Button
94
- {...props}
95
- variant="default"
96
- size="sm"
97
- disabled={isCreating}
98
- class="shadow-lg bg-green-600 hover:bg-green-500 disabled:bg-gray-600 text-white border-green-500 transition-all duration-200 rounded-l-none border-l border-green-400 px-2"
99
- >
100
- <span class="icon-[mdi--chevron-down] size-4"></span>
101
- </Button>
102
- {/snippet}
103
- </Popover.Trigger>
104
- <Popover.Content class="w-64 p-3 bg-slate-800 border-slate-600">
105
- <div class="space-y-3">
106
- <h4 class="text-sm font-medium text-slate-100">Select Robot Type</h4>
107
- <div class="space-y-2">
108
- {#each robotTypes as robotType}
109
- <Button
110
- variant={selectedRobotType === robotType ? "default" : "ghost"}
111
- size="sm"
112
- onclick={async () => {
113
- selectedRobotType = robotType;
114
- await addRobot();
115
- }}
116
- disabled={isCreating}
117
- class="w-full justify-start text-left"
118
- >
119
- <span class="icon-[mdi--robot] size-4 mr-2"></span>
120
- {robotType}
121
- {#if robotType === "so-arm100"}
122
- <Badge variant="secondary" class="ml-auto text-xs">SO-100</Badge>
123
- {/if}
124
- </Button>
125
- {/each}
126
- </div>
127
- </div>
128
- </Popover.Content>
129
- </Popover.Root>
130
- </div>
131
-
132
- <!-- Controls Button -->
133
- <Button
134
- variant="outline"
135
- size="sm"
136
- onclick={onToggleControls}
137
- class={cn(
138
- "shadow-lg transition-all duration-200 border-slate-600 backdrop-blur-sm group",
139
- controlsOpen
140
- ? "bg-blue-600/90 text-white hover:bg-blue-500 border-blue-500"
141
- : "bg-slate-800/90 text-slate-100 hover:bg-slate-700"
142
- )}
143
- >
144
- <span class={cn(
145
- "size-4 mr-2 transition-transform duration-200",
146
- controlsOpen
147
- ? "icon-[mdi--close] group-hover:rotate-90"
148
- : "icon-[mdi--gamepad-variant] group-hover:scale-110"
149
- )}></span>
150
- {controlsOpen ? 'Close' : 'Controls'}
151
-
152
- {#if !controlsOpen}
153
- <Badge
154
- variant="secondary"
155
- class="ml-2 bg-blue-400/20 text-blue-300 border-blue-400/30 text-xs"
156
- >
157
- Robot
158
- </Badge>
159
- {/if}
160
- </Button>
161
- </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src/lib/components/ControlsSheet.svelte DELETED
@@ -1,76 +0,0 @@
1
- <script lang="ts">
2
- import { Button } from '$lib/components/ui/button';
3
- import * as Sheet from '$lib/components/ui/sheet';
4
- import { Badge } from '$lib/components/ui/badge';
5
- import { Separator } from '$lib/components/ui/separator';
6
- import ControlPanel from '$lib/components/panel/ControlPanel.svelte';
7
-
8
- interface Props {
9
- isOpen?: boolean;
10
- }
11
-
12
- let { isOpen = $bindable(false) }: Props = $props();
13
-
14
- function toggleSheet() {
15
- isOpen = !isOpen;
16
- }
17
- </script>
18
-
19
- <!-- Controls Sheet -->
20
- <Sheet.Root bind:open={isOpen}>
21
- <Sheet.Content
22
- side="right"
23
- class="w-80 sm:w-96 p-0 bg-gradient-to-b from-slate-700 to-slate-800 text-white border-l border-slate-600"
24
- >
25
- <!-- Header -->
26
- <Sheet.Header class="border-b border-slate-600 bg-slate-700/80 backdrop-blur-sm p-6">
27
- <div class="flex items-center justify-between">
28
- <div class="flex items-center gap-3">
29
- <span class="icon-[mdi--gamepad-variant] size-6 text-blue-400"></span>
30
- <div>
31
- <Sheet.Title class="text-xl font-semibold text-slate-100">
32
- Robot Controls
33
- </Sheet.Title>
34
- <p class="text-sm text-slate-400 mt-1">
35
- Control your robot
36
- </p>
37
- </div>
38
- </div>
39
- <Badge variant="secondary" class="bg-blue-400 text-slate-900 font-medium px-3">
40
- Active
41
- </Badge>
42
- </div>
43
- </Sheet.Header>
44
-
45
- <!-- Content -->
46
- <div class="flex-1 overflow-y-auto scrollbar-thin scrollbar-track-slate-700 scrollbar-thumb-slate-500 px-4">
47
- <div class="space-y-4">
48
- <div class="flex items-center gap-2 text-sm text-slate-300">
49
- <span class="icon-[mdi--information-outline] size-4"></span>
50
- Control your robot's movements and actions
51
- </div>
52
- <Separator class="bg-slate-600" />
53
- <ControlPanel />
54
- </div>
55
- </div>
56
-
57
- <!-- Footer -->
58
- <div class="border-t border-slate-600 bg-slate-800/50 p-4">
59
- <div class="flex items-center justify-between">
60
- <div class="flex items-center gap-2 text-xs text-slate-400">
61
- <span class="icon-[mdi--gamepad-variant] size-4"></span>
62
- <span>Robot Controls</span>
63
- </div>
64
- <Button
65
- variant="ghost"
66
- size="sm"
67
- onclick={toggleSheet}
68
- class="text-slate-400 hover:text-slate-100 text-xs px-2 py-1 h-auto"
69
- >
70
- <span class="icon-[mdi--close] size-3 mr-1"></span>
71
- Close
72
- </Button>
73
- </div>
74
- </div>
75
- </Sheet.Content>
76
- </Sheet.Root>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src/lib/components/Overlay.svelte DELETED
@@ -1,36 +0,0 @@
1
- <script lang="ts">
2
- import ButtonBar from '$lib/components/ButtonBar.svelte';
3
- import ControlsSheet from '$lib/components/ControlsSheet.svelte';
4
- import SettingsSheet from '$lib/components/SettingsSheet.svelte';
5
-
6
- interface Props {
7
- // Optional props for controlling the state externally if needed
8
- controlsOpen?: boolean;
9
- settingsOpen?: boolean;
10
- }
11
-
12
- let {
13
- controlsOpen = $bindable(false),
14
- settingsOpen = $bindable(false)
15
- }: Props = $props();
16
-
17
- function toggleControls() {
18
- controlsOpen = !controlsOpen;
19
- }
20
-
21
- function toggleSettings() {
22
- settingsOpen = !settingsOpen;
23
- }
24
- </script>
25
-
26
- <!-- Button Bar with Add Robot and Controls (Top Left) -->
27
- <ButtonBar
28
- bind:controlsOpen
29
- onToggleControls={toggleControls}
30
- />
31
-
32
- <!-- Controls Sheet (Right Side) -->
33
- <ControlsSheet bind:isOpen={controlsOpen} />
34
-
35
- <!-- Settings Button and Sheet (Top Right, Left Side) -->
36
- <SettingsSheet bind:isOpen={settingsOpen} />
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src/lib/components/PanelWrapper.svelte DELETED
@@ -1,32 +0,0 @@
1
- <script lang="ts">
2
- import * as Card from '$lib/components/ui/card';
3
- import { Separator } from '$lib/components/ui/separator';
4
-
5
- interface Props {
6
- title?: string;
7
- subtitle?: string;
8
- children: any;
9
- }
10
-
11
- let { title, subtitle, children }: Props = $props();
12
- </script>
13
-
14
- <Card.Root class="bg-slate-700/50 border-slate-600">
15
- {#if title}
16
- <Card.Header class="pb-3">
17
- <Card.Title class="text-slate-100 flex items-center gap-2">
18
- {title}
19
- </Card.Title>
20
- {#if subtitle}
21
- <Card.Description class="text-slate-400 text-sm">
22
- {subtitle}
23
- </Card.Description>
24
- {/if}
25
- </Card.Header>
26
- <Separator class="bg-slate-600" />
27
- {/if}
28
-
29
- <Card.Content class="p-4 space-y-4">
30
- {@render children()}
31
- </Card.Content>
32
- </Card.Root>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src/lib/components/RobotStatusDemo.svelte DELETED
@@ -1,65 +0,0 @@
1
- <script lang="ts">
2
- import StatusCard from '$lib/components/StatusCard.svelte';
3
- import { Button } from '$lib/components/ui/button';
4
- import { Badge } from '$lib/components/ui/badge';
5
- import { environment } from '$lib/runes/env.svelte';
6
-
7
- // Sample usage of StatusCard component
8
- const robotStats = $derived(() => {
9
- const robotCount = Object.keys(environment.robots).length;
10
- return {
11
- robotCount,
12
- status: (robotCount > 0 ? 'active' : 'inactive') as 'active' | 'inactive',
13
- lastUpdate: new Date().toLocaleTimeString()
14
- };
15
- });
16
- </script>
17
-
18
- <div class="space-y-4">
19
- <h3 class="text-lg font-semibold text-slate-100 mb-4">Robot Status Overview</h3>
20
-
21
- <div class="grid grid-cols-1 gap-3">
22
- <StatusCard
23
- title="Connected Robots"
24
- status={robotStats().status}
25
- value={robotStats().robotCount}
26
- description="Active robot instances"
27
- icon="icon-[mdi--robot]"
28
- >
29
- {#if robotStats().robotCount > 0}
30
- <div class="flex flex-wrap gap-1">
31
- {#each Object.entries(environment.robots) as [id, robot]}
32
- <Badge variant="outline" class="text-xs text-slate-300 border-slate-500">
33
- {id.slice(0, 8)}...
34
- </Badge>
35
- {/each}
36
- </div>
37
- {:else}
38
- <p class="text-xs text-slate-500">No robots currently active</p>
39
- {/if}
40
- </StatusCard>
41
-
42
- <StatusCard
43
- title="System Status"
44
- status="active"
45
- description="All systems operational"
46
- icon="icon-[mdi--check-circle]"
47
- >
48
- <div class="text-xs text-slate-400">
49
- Last updated: {robotStats().lastUpdate}
50
- </div>
51
- </StatusCard>
52
-
53
- <StatusCard
54
- title="Control Mode"
55
- status="active"
56
- value="Manual"
57
- description="Direct joint control enabled"
58
- icon="icon-[mdi--gamepad-variant]"
59
- >
60
- <Button size="sm" variant="outline" class="w-full text-xs">
61
- Switch to Autonomous
62
- </Button>
63
- </StatusCard>
64
- </div>
65
- </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src/lib/components/SettingsSheet.svelte DELETED
@@ -1,109 +0,0 @@
1
- <script lang="ts">
2
- import { Button } from '$lib/components/ui/button';
3
- import * as Sheet from '$lib/components/ui/sheet';
4
- import { Badge } from '$lib/components/ui/badge';
5
- import { Separator } from '$lib/components/ui/separator';
6
- import SettingsPanel from '$lib/components/panel/SettingsPanel.svelte';
7
- import { cn } from '$lib/utils';
8
-
9
- interface Props {
10
- isOpen?: boolean;
11
- }
12
-
13
- let { isOpen = $bindable(false) }: Props = $props();
14
-
15
- function toggleSheet() {
16
- isOpen = !isOpen;
17
- }
18
- </script>
19
-
20
- <!-- Settings Trigger Button -->
21
- <div class="fixed top-4 right-4 z-50">
22
- <Button
23
- variant="outline"
24
- size="sm"
25
- onclick={toggleSheet}
26
- class={cn(
27
- "shadow-lg transition-all duration-200 border-slate-600 backdrop-blur-sm group",
28
- isOpen
29
- ? "bg-orange-600/90 text-white hover:bg-orange-500 border-orange-500"
30
- : "bg-slate-800/90 text-slate-100 hover:bg-slate-700"
31
- )}
32
- >
33
- <span class={cn(
34
- "size-4 mr-2 transition-transform duration-200",
35
- isOpen
36
- ? "icon-[mdi--close] group-hover:rotate-90"
37
- : "icon-[mdi--cog] group-hover:rotate-45"
38
- )}></span>
39
- {isOpen ? 'Close' : 'Settings'}
40
-
41
- {#if !isOpen}
42
- <Badge
43
- variant="secondary"
44
- class="ml-2 bg-orange-400/20 text-orange-300 border-orange-400/30 text-xs"
45
- >
46
- Joints
47
- </Badge>
48
- {/if}
49
- </Button>
50
- </div>
51
-
52
- <!-- Settings Sheet -->
53
- <Sheet.Root bind:open={isOpen}>
54
- <Sheet.Content
55
- side="left"
56
- class="w-80 sm:w-96 p-0 bg-gradient-to-b from-slate-700 to-slate-800 text-white border-r border-slate-600"
57
- >
58
- <!-- Header -->
59
- <Sheet.Header class="border-b border-slate-600 bg-slate-700/80 backdrop-blur-sm p-6">
60
- <div class="flex items-center justify-between">
61
- <div class="flex items-center gap-3">
62
- <span class="icon-[mdi--cog] size-6 text-orange-400"></span>
63
- <div>
64
- <Sheet.Title class="text-xl font-semibold text-slate-100">
65
- Robot Settings
66
- </Sheet.Title>
67
- <p class="text-sm text-slate-400 mt-1">
68
- Configure joints and robot parameters
69
- </p>
70
- </div>
71
- </div>
72
- <Badge variant="secondary" class="bg-orange-400 text-slate-900 font-medium px-3">
73
- Config
74
- </Badge>
75
- </div>
76
- </Sheet.Header>
77
-
78
- <!-- Content -->
79
- <div class="flex-1 overflow-y-auto scrollbar-thin scrollbar-track-slate-700 scrollbar-thumb-slate-500 p-4">
80
- <div class="space-y-4">
81
- <div class="flex items-center gap-2 text-sm text-slate-300">
82
- <span class="icon-[mdi--wrench] size-4"></span>
83
- Configure joints and robot parameters
84
- </div>
85
- <Separator class="bg-slate-600" />
86
- <SettingsPanel />
87
- </div>
88
- </div>
89
-
90
- <!-- Footer -->
91
- <div class="border-t border-slate-600 bg-slate-800/50 p-4">
92
- <div class="flex items-center justify-between">
93
- <div class="flex items-center gap-2 text-xs text-slate-400">
94
- <span class="icon-[mdi--cog] size-4"></span>
95
- <span>Robot Settings</span>
96
- </div>
97
- <Button
98
- variant="ghost"
99
- size="sm"
100
- onclick={toggleSheet}
101
- class="text-slate-400 hover:text-slate-100 text-xs px-2 py-1 h-auto"
102
- >
103
- <span class="icon-[mdi--close] size-3 mr-1"></span>
104
- Close
105
- </Button>
106
- </div>
107
- </div>
108
- </Sheet.Content>
109
- </Sheet.Root>