Spaces:
Running
Running
Update
Browse filesThis view is limited to 50 files because it contains too many changes.
See raw diff
- .prettierignore +9 -0
- README.md +85 -79
- src/app.css +105 -105
- src/app.d.ts +1 -1
- src/lib/components/3d/elements/compute/ComputeGridItem.svelte +0 -1
- src/lib/components/3d/elements/compute/Computes.svelte +21 -5
- src/lib/components/3d/elements/compute/GPU.svelte +11 -14
- src/lib/components/3d/elements/compute/GPUModel.svelte +167 -182
- src/lib/components/3d/elements/compute/modal/AISessionConnectionModal.svelte +11 -9
- src/lib/components/3d/elements/compute/status/ComputeBoxUIKit.svelte +1 -5
- src/lib/components/3d/elements/compute/status/ComputeStatusBillboard.svelte +13 -7
- src/lib/components/3d/elements/compute/status/RobotOutputBoxUIKit.svelte +20 -14
- src/lib/components/3d/elements/compute/status/VideoInputBoxUIKit.svelte +18 -16
- src/lib/components/3d/elements/robot/RobotGridItem.svelte +11 -8
- src/lib/components/3d/elements/robot/URDF/primitives/UrdfJoint.svelte +2 -8
- src/lib/components/3d/elements/robot/URDF/primitives/UrdfLink.svelte +1 -1
- src/lib/components/3d/elements/robot/URDF/utils/UrdfParser.ts +0 -2
- src/lib/components/3d/elements/robot/modal/InputConnectionModal.svelte +6 -5
- src/lib/components/3d/elements/robot/modal/OutputConnectionModal.svelte +11 -10
- src/lib/components/3d/elements/robot/status/ConnectionFlowBoxUIkit.svelte +21 -7
- src/lib/components/3d/elements/robot/status/RobotBoxUIKit.svelte +17 -28
- src/lib/components/3d/elements/robot/status/RobotStatusBillboard.svelte +28 -28
- src/lib/components/3d/elements/video/modal/VideoInputConnectionModal.svelte +6 -5
- src/lib/components/3d/elements/video/modal/VideoOutputConnectionModal.svelte +6 -6
- src/lib/components/3d/elements/video/status/VideoBoxUIKit.svelte +17 -27
- src/lib/components/3d/elements/video/status/VideoConnectionFlowBoxUIKit.svelte +20 -7
- src/lib/components/3d/elements/video/status/VideoStatusBillboard.svelte +26 -26
- src/lib/components/3d/ui/StatusArrow.svelte +10 -28
- src/lib/components/3d/ui/StatusButton.svelte +3 -9
- src/lib/components/3d/ui/StatusContent.svelte +14 -18
- src/lib/components/3d/ui/icons.ts +4 -3
- src/lib/components/3d/ui/index.ts +6 -6
- src/lib/components/interface/overlay/AddAIButton.svelte +9 -7
- src/lib/components/interface/overlay/AddRobotButton.svelte +4 -3
- src/lib/components/interface/overlay/AddSensorButton.svelte +28 -29
- src/lib/components/interface/overlay/Overlay.svelte +9 -7
- src/lib/components/interface/overlay/SettingsSheet.svelte +2 -1
- src/lib/configs/robotUrdfConfig.ts +2 -2
- src/lib/elements/compute/RemoteCompute.svelte.ts +150 -144
- src/lib/elements/compute/RemoteComputeManager.svelte.ts +486 -477
- src/lib/elements/compute/index.ts +11 -5
- src/lib/elements/robot/Robot.svelte.ts +535 -494
- src/lib/elements/robot/RobotManager.svelte.ts +269 -256
- src/lib/elements/robot/calibration/CalibrationState.svelte.ts +290 -269
- src/lib/elements/robot/calibration/USBCalibrationPanel.svelte +14 -5
- src/lib/elements/robot/calibration/index.ts +2 -2
- src/lib/elements/robot/components/ConnectionPanel.svelte +5 -7
- src/lib/elements/robot/components/RobotGrid.svelte +101 -96
- src/lib/elements/robot/components/RobotItem.svelte +212 -200
- src/lib/elements/robot/components/index.ts +4 -4
.prettierignore
CHANGED
@@ -10,3 +10,12 @@ src-python/
|
|
10 |
node_modules/
|
11 |
build/
|
12 |
.svelte-kit/
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
10 |
node_modules/
|
11 |
build/
|
12 |
.svelte-kit/
|
13 |
+
|
14 |
+
# External
|
15 |
+
external/
|
16 |
+
|
17 |
+
# Build
|
18 |
+
build/
|
19 |
+
|
20 |
+
# Package
|
21 |
+
packages/
|
README.md
CHANGED
@@ -15,14 +15,14 @@ app_port: 8000
|
|
15 |
pinned: true
|
16 |
license: mit
|
17 |
fullWidth: true
|
18 |
-
short_description: Web interface of the RobotHub platform
|
19 |
---
|
20 |
|
21 |
# 🤖 RobotHub Arena – Frontend
|
22 |
|
23 |
RobotHub is an **open-source, end-to-end robotics stack** that combines real-time communication, 3-D visualisation, and modern AI policies to control both simulated and physical robots.
|
24 |
|
25 |
-
**This repository contains the
|
26 |
|
27 |
1. **[RobotHub Transport Server](https://github.com/julien-blanchon/RobotHub-TransportServer)**
|
28 |
– WebSocket / WebRTC switch-board for video streams & robot joint messages.
|
@@ -46,22 +46,22 @@ RobotHub is an **open-source, end-to-end robotics stack** that combines real-tim
|
|
46 |
## ✨ Key Features
|
47 |
|
48 |
• **Digital-Twin 3-D Scene** – inspect robots, cameras & AI compute blocks in real-time.
|
49 |
-
• **Multi-Workspace Collaboration** – share a hash URL and others join the
|
50 |
• **Drag-&-Drop Add-ons** – spawn robots, cameras or AI models from the toolbar.
|
51 |
• **Transport-Agnostic** – control physical hardware over USB, or send/receive via WebRTC rooms.
|
52 |
• **Model Agnostic** – any policy exposed by the Inference Server can be used (ACT, Diffusion, …).
|
53 |
-
• **Reactive Core** – built with
|
54 |
|
55 |
---
|
56 |
|
57 |
## 📂 Repository Layout (short)
|
58 |
|
59 |
-
| Path
|
60 |
-
|
61 |
-
| `src/`
|
62 |
-
| `src/lib/elements`
|
63 |
-
| `external/RobotHub-*`
|
64 |
-
| `static/`
|
65 |
|
66 |
A more in-depth component overview can be found in `/src/lib/components/**` – every major popup/modal has its own Svelte file.
|
67 |
|
@@ -96,34 +96,34 @@ $ python launch_simple.py # → http://localhost:8001
|
|
96 |
$ bun run dev -- --open # → http://localhost:5173 (hash = workspace-id)
|
97 |
```
|
98 |
|
99 |
-
The **workspace-id** in the URL hash ties all three services together.
|
100 |
|
101 |
---
|
102 |
|
103 |
## 🛠️ Usage Walk-Through
|
104 |
|
105 |
-
1. **Open the web-app** → a fresh
|
106 |
-
2. Click
|
107 |
-
3. Click
|
108 |
-
4. Click
|
109 |
-
5. On the Compute block choose
|
110 |
6. Connect:
|
111 |
-
•
|
112 |
-
•
|
113 |
-
•
|
114 |
-
7. Press
|
115 |
|
116 |
-
All modals (`AISessionConnectionModal`, `RobotInputConnectionModal`, …) expose precisely what is happening under the hood: which room ID, whether you are
|
117 |
|
118 |
---
|
119 |
|
120 |
## 🧩 Package Relations
|
121 |
|
122 |
-
| Package
|
123 |
-
|
124 |
-
| **Transport Server**
|
125 |
-
| **Inference Server**
|
126 |
-
| **Frontend (this repo)** | UI + 3-D scene.
|
127 |
|
128 |
> Because the two backend repos are included as git sub-modules you can develop & debug the whole trio in one repo clone.
|
129 |
|
@@ -135,7 +135,7 @@ All modals (`AISessionConnectionModal`, `RobotInputConnectionModal`, …) expose
|
|
135 |
• `RobotManager` – talks to Transport Server and USB drivers.
|
136 |
• `VideoManager` – handles local/remote camera streams and WebRTC.
|
137 |
|
138 |
-
Each element is a small class with `$state` fields which Svelte 5 picks up automatically.
|
139 |
|
140 |
```
|
141 |
AISessionConnectionModal – create / start / stop AI sessions
|
@@ -163,9 +163,9 @@ See `Dockerfile` for the full build – it also performs `bun test` & `bun run b
|
|
163 |
|
164 |
## 🧑💻 Contributing
|
165 |
|
166 |
-
PRs are welcome!
|
167 |
|
168 |
-
1. `bun test` – unit tests.
|
169 |
2. `bun run typecheck` – strict TS config.
|
170 |
|
171 |
Please run `bun format` before committing – ESLint + Prettier configs are included.
|
@@ -176,7 +176,8 @@ Please run `bun format` before committing – ESLint + Prettier configs are incl
|
|
176 |
|
177 |
Huge gratitude to [Tim Qian](https://github.com/timqian) ([X/Twitter](https://x.com/tim_qian)) and the
|
178 |
[bambot project](https://bambot.org/) for open-sourcing **feetech.js** – the
|
179 |
-
delightful js driver that powers our USB communication layer.
|
|
|
180 |
---
|
181 |
|
182 |
## 📄 License
|
@@ -187,34 +188,34 @@ MIT – see `LICENSE` in the root.
|
|
187 |
|
188 |
RobotHub follows a **separation-of-concerns** design:
|
189 |
|
190 |
-
|
191 |
-
|
192 |
-
|
193 |
|
194 |
> By decoupling the pipeline we can deploy each piece on separate hardware or even different clouds, swap alternative implementations (e.g. ROS bridge instead of WebRTC) and scale each micro-service independently.
|
195 |
|
196 |
---
|
197 |
|
198 |
-
## 🛰
|
199 |
|
200 |
```
|
201 |
Browser / Robot ⟷ 🌐 Transport Server ⟷ Other Browser / AI / HW
|
202 |
```
|
203 |
|
204 |
-
|
205 |
-
|
206 |
-
|
207 |
-
|
208 |
|
209 |
Why useful?
|
210 |
|
211 |
-
|
212 |
-
|
213 |
-
|
214 |
|
215 |
-
## 🏢
|
216 |
|
217 |
-
A **workspace** is simply a UUID namespace in the Transport Server.
|
218 |
|
219 |
```
|
220 |
/robotics/workspaces/{workspace_id}/rooms/{room_id}
|
@@ -223,105 +224,110 @@ A **workspace** is simply a UUID namespace in the Transport Server. Every room
|
|
223 |
|
224 |
Why bother?
|
225 |
|
226 |
-
1. **Privacy / Security** – clients in workspace
|
227 |
2. **Organisation** – keep each class, project or experiment separated without spinning up extra servers.
|
228 |
-
3. **Zero-config sharing** – the Frontend stores the workspace ID in the URL hash (e.g. `/#d742e85d-c9e9-4f7b-…`).
|
229 |
4. **Stateless Scale-out** – Transport Server holds no global state; deleting a workspace removes all rooms in one call.
|
230 |
|
231 |
Typical lifecycle:
|
232 |
|
233 |
-
|
234 |
-
|
235 |
|
236 |
> Practical tip: Use one workspace per demo to prevent collisions, then recycle it afterwards.
|
237 |
|
238 |
---
|
239 |
|
240 |
-
## 🧠
|
241 |
|
242 |
1. **Create session**
|
243 |
`POST /api/sessions` with JSON:
|
244 |
```jsonc
|
245 |
{
|
246 |
-
|
247 |
-
|
248 |
-
|
249 |
-
|
250 |
-
|
251 |
}
|
252 |
```
|
253 |
-
2. **Receive response**
|
254 |
```jsonc
|
255 |
{
|
256 |
-
|
257 |
-
|
258 |
-
|
259 |
-
|
260 |
}
|
261 |
```
|
262 |
3. **Wire connections**
|
263 |
-
|
264 |
-
|
265 |
-
|
266 |
4. **Start inference**
|
267 |
`POST /api/sessions/{id}/start` – server loads the model and begins publishing commands.
|
268 |
-
5. **Stop / delete** as needed.
|
269 |
|
270 |
-
The Frontend automates steps 1-4 via the
|
271 |
|
272 |
---
|
273 |
|
274 |
## 🌐 Hosted Demo End-Points
|
275 |
|
276 |
-
| Service
|
277 |
-
|
278 |
-
| Transport Server
|
279 |
-
| Inference Server
|
280 |
-
| Frontend (read-only preview) | <https://blanchon-robothub-frontend.hf.space>
|
281 |
|
282 |
-
Point the
|
283 |
|
284 |
---
|
285 |
|
286 |
## 🎯 Main Use-Cases
|
287 |
|
288 |
-
Below are typical connection patterns you can set-up **entirely from the UI**.
|
289 |
|
290 |
### Direct Tele-Operation (Leader ➜ Follower)
|
291 |
-
|
|
|
292 |
|
293 |
> One human moves Robot A, Robot B mirrors the motion in real-time. Works with any number of followers – just add more consumers to the same room.
|
294 |
>
|
295 |
-
> 📺
|
296 |
|
297 |
### Web-UI Manual Control
|
|
|
298 |
**Browser sliders** (`ManualControlSheet`) → `Remote producer` → **Robot (USB)**
|
299 |
|
300 |
> No physical master arm needed – drive joints from any device.
|
301 |
>
|
302 |
-
> 📺
|
303 |
|
304 |
### AI Inference Loop
|
|
|
305 |
**Robot (USB)** ➜ `Remote producer` → **joint-input room**
|
306 |
**Camera PC** ➜ `Video producer` → **camera room(s)**
|
307 |
**Inference Server** (consumer) → processes → publishes to **joint-output room** → `Remote consumer` ➜ **Robot**
|
308 |
|
309 |
> Lets a low-power robot PC stream data while a beefy GPU node does the heavy lifting.
|
310 |
>
|
311 |
-
> 📺
|
312 |
|
313 |
### Hybrid Classroom (Multi-Follower AI)
|
314 |
-
|
|
|
315 |
|
316 |
> Useful for swarm behaviours or classroom demonstrations.
|
317 |
>
|
318 |
-
> 📺
|
319 |
|
320 |
### Split Video / Robot Across Machines
|
|
|
321 |
**Laptop A** (near cameras) → streams video → Transport
|
322 |
-
**Laptop B** (near robot)
|
323 |
-
**Browser** anywhere
|
324 |
|
325 |
> Ideal when the camera PC stays close to sensors and you want minimal upstream bandwidth.
|
326 |
>
|
327 |
-
> 📺
|
|
|
15 |
pinned: true
|
16 |
license: mit
|
17 |
fullWidth: true
|
18 |
+
short_description: Web interface of the RobotHub platform
|
19 |
---
|
20 |
|
21 |
# 🤖 RobotHub Arena – Frontend
|
22 |
|
23 |
RobotHub is an **open-source, end-to-end robotics stack** that combines real-time communication, 3-D visualisation, and modern AI policies to control both simulated and physical robots.
|
24 |
|
25 |
+
**This repository contains the _Frontend_** – a SvelteKit web application that runs completely in the browser (or inside Electron / Tauri). It talks to two backend micro-services that live in their own repositories:
|
26 |
|
27 |
1. **[RobotHub Transport Server](https://github.com/julien-blanchon/RobotHub-TransportServer)**
|
28 |
– WebSocket / WebRTC switch-board for video streams & robot joint messages.
|
|
|
46 |
## ✨ Key Features
|
47 |
|
48 |
• **Digital-Twin 3-D Scene** – inspect robots, cameras & AI compute blocks in real-time.
|
49 |
+
• **Multi-Workspace Collaboration** – share a hash URL and others join the _same_ WS rooms instantly.
|
50 |
• **Drag-&-Drop Add-ons** – spawn robots, cameras or AI models from the toolbar.
|
51 |
• **Transport-Agnostic** – control physical hardware over USB, or send/receive via WebRTC rooms.
|
52 |
• **Model Agnostic** – any policy exposed by the Inference Server can be used (ACT, Diffusion, …).
|
53 |
+
• **Reactive Core** – built with _Svelte 5 runes_ – state is automatically pushed into the UI.
|
54 |
|
55 |
---
|
56 |
|
57 |
## 📂 Repository Layout (short)
|
58 |
|
59 |
+
| Path | Purpose |
|
60 |
+
| --------------------- | ----------------------------------------------------------------------------- |
|
61 |
+
| `src/` | SvelteKit app (routes, components) |
|
62 |
+
| `src/lib/elements` | Runtime domain logic (robots, video, compute) |
|
63 |
+
| `external/RobotHub-*` | Git sub-modules for the backend services – used for generated clients & tests |
|
64 |
+
| `static/` | URDFs, STL meshes, textures, favicon |
|
65 |
|
66 |
A more in-depth component overview can be found in `/src/lib/components/**` – every major popup/modal has its own Svelte file.
|
67 |
|
|
|
96 |
$ bun run dev -- --open # → http://localhost:5173 (hash = workspace-id)
|
97 |
```
|
98 |
|
99 |
+
The **workspace-id** in the URL hash ties all three services together. Share `http://localhost:5173/#<id>` and a collaborator instantly joins the same set of rooms.
|
100 |
|
101 |
---
|
102 |
|
103 |
## 🛠️ Usage Walk-Through
|
104 |
|
105 |
+
1. **Open the web-app** → a fresh _workspace_ is created (☝ left corner shows 🌐 ID).
|
106 |
+
2. Click _Add Robot_ → spawns an SO-100 6-DoF arm (URDF).
|
107 |
+
3. Click _Add Sensor → Camera_ → creates a virtual camera element.
|
108 |
+
4. Click _Add Model → ACT_ → spawns a _Compute_ block.
|
109 |
+
5. On the Compute block choose _Create Session_ – select model path (`LaetusH/act_so101_beyond`) and cameras (`front`).
|
110 |
6. Connect:
|
111 |
+
• _Video Input_ – local webcam → `front` room.
|
112 |
+
• _Robot Input_ – robot → _joint-input_ room (producer).
|
113 |
+
• _Robot Output_ – robot ← AI predictions (consumer).
|
114 |
+
7. Press _Start Inference_ – the model will predict the next joint trajectory every few frames. 🎉
|
115 |
|
116 |
+
All modals (`AISessionConnectionModal`, `RobotInputConnectionModal`, …) expose precisely what is happening under the hood: which room ID, whether you are _producer_ or _consumer_, and the live status.
|
117 |
|
118 |
---
|
119 |
|
120 |
## 🧩 Package Relations
|
121 |
|
122 |
+
| Package | Role | Artifacts exposed to this repo |
|
123 |
+
| ------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------ |
|
124 |
+
| **Transport Server** | Low-latency switch-board (WS/WebRTC). Creates _rooms_ for video & joint messages. | TypeScript & Python client libraries (imported from sub-module) |
|
125 |
+
| **Inference Server** | Loads checkpoints (ACT, Pi-0, …) and manages _sessions_. Each session automatically asks the Transport Server to create dedicated rooms. | Generated TS SDK (`@robothub/inference-server-client`) – auto-called from `RemoteComputeManager` |
|
126 |
+
| **Frontend (this repo)** | UI + 3-D scene. Manages _robots_, _videos_ & _compute_ blocks and connects them to the correct rooms. | – |
|
127 |
|
128 |
> Because the two backend repos are included as git sub-modules you can develop & debug the whole trio in one repo clone.
|
129 |
|
|
|
135 |
• `RobotManager` – talks to Transport Server and USB drivers.
|
136 |
• `VideoManager` – handles local/remote camera streams and WebRTC.
|
137 |
|
138 |
+
Each element is a small class with `$state` fields which Svelte 5 picks up automatically. The modals listed below are _thin_ UI shells around those classes:
|
139 |
|
140 |
```
|
141 |
AISessionConnectionModal – create / start / stop AI sessions
|
|
|
163 |
|
164 |
## 🧑💻 Contributing
|
165 |
|
166 |
+
PRs are welcome! The codebase is organised into **domain managers** (robot / video / compute) and **pure-UI** components. If you add a new feature, create a manager first so that business logic can be unit-tested without DOM.
|
167 |
|
168 |
+
1. `bun test` – unit tests.
|
169 |
2. `bun run typecheck` – strict TS config.
|
170 |
|
171 |
Please run `bun format` before committing – ESLint + Prettier configs are included.
|
|
|
176 |
|
177 |
Huge gratitude to [Tim Qian](https://github.com/timqian) ([X/Twitter](https://x.com/tim_qian)) and the
|
178 |
[bambot project](https://bambot.org/) for open-sourcing **feetech.js** – the
|
179 |
+
delightful js driver that powers our USB communication layer.
|
180 |
+
|
181 |
---
|
182 |
|
183 |
## 📄 License
|
|
|
188 |
|
189 |
RobotHub follows a **separation-of-concerns** design:
|
190 |
|
191 |
+
- **Transport Server** is the single source of truth for _real-time_ data – video frames, joint values, heart-beats. Every participant (browser, Python script, robot firmware) only needs one WebSocket/WebRTC connection, no matter how many peers join later.
|
192 |
+
- **Inference Server** is stateless with regard to connectivity; it spins up / tears down _sessions_ that rely on rooms in the Transport Server. This lets heavy AI models live on a GPU box while cameras and robots stay on the edge.
|
193 |
+
- **Frontend** stays 100 % in the browser – no secret keys or device drivers required – and simply wires together rooms that already exist.
|
194 |
|
195 |
> By decoupling the pipeline we can deploy each piece on separate hardware or even different clouds, swap alternative implementations (e.g. ROS bridge instead of WebRTC) and scale each micro-service independently.
|
196 |
|
197 |
---
|
198 |
|
199 |
+
## 🛰 Transport Server – Real-Time Router
|
200 |
|
201 |
```
|
202 |
Browser / Robot ⟷ 🌐 Transport Server ⟷ Other Browser / AI / HW
|
203 |
```
|
204 |
|
205 |
+
- **Creates rooms** – `POST /robotics/workspaces/{ws}/rooms` or `POST /video/workspaces/{ws}/rooms`.
|
206 |
+
- **Manages roles** – every WebSocket identifies as _producer_ (source) or _consumer_ (sink).
|
207 |
+
- **Does zero processing** – it only forwards JSON (robotics) or WebRTC SDP/ICE (video).
|
208 |
+
- **Health-check** – `GET /api/health` returns a JSON heartbeat.
|
209 |
|
210 |
Why useful?
|
211 |
|
212 |
+
- You never expose robot hardware directly to the internet – it only speaks to the Transport Server.
|
213 |
+
- Multiple followers can subscribe to the _same_ producer without extra bandwidth on the producer side (server fans out messages).
|
214 |
+
- Works across NAT thanks to WebRTC TURN support.
|
215 |
|
216 |
+
## 🏢 Workspaces – Lightweight Multi-Tenant Isolation
|
217 |
|
218 |
+
A **workspace** is simply a UUID namespace in the Transport Server. Every room URL starts with:
|
219 |
|
220 |
```
|
221 |
/robotics/workspaces/{workspace_id}/rooms/{room_id}
|
|
|
224 |
|
225 |
Why bother?
|
226 |
|
227 |
+
1. **Privacy / Security** – clients in workspace _A_ can neither list nor join rooms from workspace _B_. A workspace id is like a private password that keeps the rooms in the same workspace isolated from each other.
|
228 |
2. **Organisation** – keep each class, project or experiment separated without spinning up extra servers.
|
229 |
+
3. **Zero-config sharing** – the Frontend stores the workspace ID in the URL hash (e.g. `/#d742e85d-c9e9-4f7b-…`). Send that link to a teammate and they automatically connect to the _same_ namespace – all existing video feeds, robot rooms and AI sessions become visible.
|
230 |
4. **Stateless Scale-out** – Transport Server holds no global state; deleting a workspace removes all rooms in one call.
|
231 |
|
232 |
Typical lifecycle:
|
233 |
|
234 |
+
- **Create** – Frontend generates `crypto.randomUUID()` if the hash is empty. Back-end rooms are lazily created when the first producer/consumer calls the REST API.
|
235 |
+
- **Share** – click the _#workspace_ badge → _Copy URL_ (handled by `WorkspaceIdButton.svelte`)
|
236 |
|
237 |
> Practical tip: Use one workspace per demo to prevent collisions, then recycle it afterwards.
|
238 |
|
239 |
---
|
240 |
|
241 |
+
## 🧠 Inference Server – Session Lifecycle
|
242 |
|
243 |
1. **Create session**
|
244 |
`POST /api/sessions` with JSON:
|
245 |
```jsonc
|
246 |
{
|
247 |
+
"session_id": "pick_place_demo",
|
248 |
+
"policy_path": "LaetusH/act_so101_beyond",
|
249 |
+
"camera_names": ["front", "wrist"],
|
250 |
+
"transport_server_url": "http://localhost:8000",
|
251 |
+
"workspace_id": "<existing-or-new>" // optional
|
252 |
}
|
253 |
```
|
254 |
+
2. **Receive response**
|
255 |
```jsonc
|
256 |
{
|
257 |
+
"workspace_id": "ws-uuid",
|
258 |
+
"camera_room_ids": { "front": "room-id-a", "wrist": "room-id-b" },
|
259 |
+
"joint_input_room_id": "room-id-c",
|
260 |
+
"joint_output_room_id": "room-id-d"
|
261 |
}
|
262 |
```
|
263 |
3. **Wire connections**
|
264 |
+
- Camera PC joins `front` / `wrist` rooms as **producer** (WebRTC).
|
265 |
+
- Robot joins `joint_input_room_id` as **producer** (joint states).
|
266 |
+
- Robot (or simulator) joins `joint_output_room_id` as **consumer** (commands).
|
267 |
4. **Start inference**
|
268 |
`POST /api/sessions/{id}/start` – server loads the model and begins publishing commands.
|
269 |
+
5. **Stop / delete** as needed. Stats & health are available via `GET /api/sessions`.
|
270 |
|
271 |
+
The Frontend automates steps 1-4 via the _AI Session_ modal – you only click buttons.
|
272 |
|
273 |
---
|
274 |
|
275 |
## 🌐 Hosted Demo End-Points
|
276 |
|
277 |
+
| Service | URL | Status |
|
278 |
+
| ---------------------------- | -------------------------------------------------------- | ---------------------- |
|
279 |
+
| Transport Server | <https://blanchon-robothub-transportserver.hf.space/api> | Public & healthy |
|
280 |
+
| Inference Server | <https://blanchon-robothub-inferenceserver.hf.space/api> | `{"status":"healthy"}` |
|
281 |
+
| Frontend (read-only preview) | <https://blanchon-robothub-frontend.hf.space> | latest `main` |
|
282 |
|
283 |
+
Point the _Settings → Server Configuration_ panel to these URLs and you can play without any local backend.
|
284 |
|
285 |
---
|
286 |
|
287 |
## 🎯 Main Use-Cases
|
288 |
|
289 |
+
Below are typical connection patterns you can set-up **entirely from the UI**. Each example lists the raw data-flow (→ = producer to consumer/AI) plus a video placeholder you can swap for a screen-capture.
|
290 |
|
291 |
### Direct Tele-Operation (Leader ➜ Follower)
|
292 |
+
|
293 |
+
_Leader PC_ `USB` ➜ **Robot A** ➜ `Remote producer` → **Transport room** → `Remote consumer` ➜ **Robot B** (`USB`)
|
294 |
|
295 |
> One human moves Robot A, Robot B mirrors the motion in real-time. Works with any number of followers – just add more consumers to the same room.
|
296 |
>
|
297 |
+
> 📺 _demo-teleop-1.mp4_
|
298 |
|
299 |
### Web-UI Manual Control
|
300 |
+
|
301 |
**Browser sliders** (`ManualControlSheet`) → `Remote producer` → **Robot (USB)**
|
302 |
|
303 |
> No physical master arm needed – drive joints from any device.
|
304 |
>
|
305 |
+
> 📺 _demo-webui.mp4_
|
306 |
|
307 |
### AI Inference Loop
|
308 |
+
|
309 |
**Robot (USB)** ➜ `Remote producer` → **joint-input room**
|
310 |
**Camera PC** ➜ `Video producer` → **camera room(s)**
|
311 |
**Inference Server** (consumer) → processes → publishes to **joint-output room** → `Remote consumer` ➜ **Robot**
|
312 |
|
313 |
> Lets a low-power robot PC stream data while a beefy GPU node does the heavy lifting.
|
314 |
>
|
315 |
+
> 📺 _demo-inference.mp4_
|
316 |
|
317 |
### Hybrid Classroom (Multi-Follower AI)
|
318 |
+
|
319 |
+
_Same as AI Inference Loop_ with additional **Robot C, D…** subscribing to `joint_output_room_id` to run the same policy in parallel.
|
320 |
|
321 |
> Useful for swarm behaviours or classroom demonstrations.
|
322 |
>
|
323 |
+
> 📺 _demo-classroom.mp4_
|
324 |
|
325 |
### Split Video / Robot Across Machines
|
326 |
+
|
327 |
**Laptop A** (near cameras) → streams video → Transport
|
328 |
+
**Laptop B** (near robot) → joins joint rooms
|
329 |
+
**Browser** anywhere → watches video consumer & sends manual overrides
|
330 |
|
331 |
> Ideal when the camera PC stays close to sensors and you want minimal upstream bandwidth.
|
332 |
>
|
333 |
+
> 📺 _demo-splitio.mp4_
|
src/app.css
CHANGED
@@ -6,117 +6,117 @@
|
|
6 |
@custom-variant dark (&:is(.dark *));
|
7 |
|
8 |
:root {
|
9 |
-
|
10 |
-
|
11 |
-
|
12 |
-
|
13 |
-
|
14 |
-
|
15 |
-
|
16 |
-
|
17 |
-
|
18 |
-
|
19 |
-
|
20 |
-
|
21 |
-
|
22 |
-
|
23 |
-
|
24 |
-
|
25 |
-
|
26 |
-
|
27 |
-
|
28 |
-
|
29 |
-
|
30 |
-
|
31 |
-
|
32 |
-
|
33 |
-
|
34 |
-
|
35 |
-
|
36 |
-
|
37 |
-
|
38 |
-
|
39 |
-
|
40 |
-
|
41 |
}
|
42 |
|
43 |
.dark {
|
44 |
-
|
45 |
-
|
46 |
-
|
47 |
-
|
48 |
-
|
49 |
-
|
50 |
-
|
51 |
-
|
52 |
-
|
53 |
-
|
54 |
-
|
55 |
-
|
56 |
-
|
57 |
-
|
58 |
-
|
59 |
-
|
60 |
-
|
61 |
-
|
62 |
-
|
63 |
-
|
64 |
-
|
65 |
-
|
66 |
-
|
67 |
-
|
68 |
-
|
69 |
-
|
70 |
-
|
71 |
-
|
72 |
-
|
73 |
-
|
74 |
-
|
75 |
}
|
76 |
|
77 |
@theme inline {
|
78 |
-
|
79 |
-
|
80 |
-
|
81 |
-
|
82 |
-
|
83 |
-
|
84 |
-
|
85 |
-
|
86 |
-
|
87 |
-
|
88 |
-
|
89 |
-
|
90 |
-
|
91 |
-
|
92 |
-
|
93 |
-
|
94 |
-
|
95 |
-
|
96 |
-
|
97 |
-
|
98 |
-
|
99 |
-
|
100 |
-
|
101 |
-
|
102 |
-
|
103 |
-
|
104 |
-
|
105 |
-
|
106 |
-
|
107 |
-
|
108 |
-
|
109 |
-
|
110 |
-
|
111 |
-
|
112 |
-
|
113 |
}
|
114 |
|
115 |
@layer base {
|
116 |
-
|
117 |
-
|
118 |
-
|
119 |
-
|
120 |
-
|
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/app.d.ts
CHANGED
@@ -1,4 +1,4 @@
|
|
1 |
-
import type { InteractivityProps } from
|
2 |
|
3 |
// See https://svelte.dev/docs/kit/types#app.d.ts
|
4 |
// for information about these interfaces
|
|
|
1 |
+
import type { InteractivityProps } from "@threlte/extras";
|
2 |
|
3 |
// See https://svelte.dev/docs/kit/types#app.d.ts
|
4 |
// for information about these interfaces
|
src/lib/components/3d/elements/compute/ComputeGridItem.svelte
CHANGED
@@ -23,7 +23,6 @@
|
|
23 |
event.stopPropagation();
|
24 |
isToggled = !isToggled;
|
25 |
}
|
26 |
-
|
27 |
</script>
|
28 |
|
29 |
<T.Group
|
|
|
23 |
event.stopPropagation();
|
24 |
isToggled = !isToggled;
|
25 |
}
|
|
|
26 |
</script>
|
27 |
|
28 |
<T.Group
|
src/lib/components/3d/elements/compute/Computes.svelte
CHANGED
@@ -85,14 +85,30 @@
|
|
85 |
|
86 |
{#if selectedCompute}
|
87 |
<!-- Inference Session Configuration Modal (for existing computes without sessions) -->
|
88 |
-
<AISessionConnectionModal
|
|
|
|
|
|
|
|
|
89 |
<!-- Video Input Connection Modal -->
|
90 |
-
<VideoInputConnectionModal
|
|
|
|
|
|
|
|
|
91 |
<!-- Robot Input Connection Modal -->
|
92 |
-
<RobotInputConnectionModal
|
|
|
|
|
|
|
|
|
93 |
<!-- Robot Output Connection Modal -->
|
94 |
-
<RobotOutputConnectionModal
|
|
|
|
|
|
|
|
|
95 |
{/if}
|
96 |
|
97 |
<!-- AI Model Configuration Modal (for creating new models) -->
|
98 |
-
<AIModelConfigurationModal bind:open={isAIConfigModalOpen} {workspaceId} />
|
|
|
85 |
|
86 |
{#if selectedCompute}
|
87 |
<!-- Inference Session Configuration Modal (for existing computes without sessions) -->
|
88 |
+
<AISessionConnectionModal
|
89 |
+
bind:open={isAISessionModalOpen}
|
90 |
+
compute={selectedCompute}
|
91 |
+
{workspaceId}
|
92 |
+
/>
|
93 |
<!-- Video Input Connection Modal -->
|
94 |
+
<VideoInputConnectionModal
|
95 |
+
bind:open={isVideoInputModalOpen}
|
96 |
+
compute={selectedCompute}
|
97 |
+
{workspaceId}
|
98 |
+
/>
|
99 |
<!-- Robot Input Connection Modal -->
|
100 |
+
<RobotInputConnectionModal
|
101 |
+
bind:open={isRobotInputModalOpen}
|
102 |
+
compute={selectedCompute}
|
103 |
+
{workspaceId}
|
104 |
+
/>
|
105 |
<!-- Robot Output Connection Modal -->
|
106 |
+
<RobotOutputConnectionModal
|
107 |
+
bind:open={isRobotOutputModalOpen}
|
108 |
+
compute={selectedCompute}
|
109 |
+
{workspaceId}
|
110 |
+
/>
|
111 |
{/if}
|
112 |
|
113 |
<!-- AI Model Configuration Modal (for creating new models) -->
|
114 |
+
<AIModelConfigurationModal bind:open={isAIConfigModalOpen} {workspaceId} />
|
src/lib/components/3d/elements/compute/GPU.svelte
CHANGED
@@ -15,7 +15,12 @@
|
|
15 |
}
|
16 |
|
17 |
// Props with defaults
|
18 |
-
let {
|
|
|
|
|
|
|
|
|
|
|
19 |
|
20 |
// Create the TV frame geometry (outer rounded rectangle)
|
21 |
function createTVFrame(
|
@@ -87,7 +92,7 @@
|
|
87 |
|
88 |
let fan_rotation = $state(0);
|
89 |
let rotationPerSeconds = $state(1); // 1 rotation per second by default
|
90 |
-
|
91 |
onMount(() => {
|
92 |
const interval = setInterval(() => {
|
93 |
// Calculate angle increment per frame for desired rotations per second
|
@@ -95,24 +100,16 @@
|
|
95 |
const angleIncrement = (Math.PI * 2 * rotationPerSeconds) / 60;
|
96 |
fan_rotation = fan_rotation + angleIncrement;
|
97 |
}
|
98 |
-
}, 1000/60); // Run at ~60fps
|
99 |
|
100 |
return () => {
|
101 |
clearInterval(interval);
|
102 |
};
|
103 |
});
|
104 |
-
|
105 |
-
|
106 |
</script>
|
107 |
|
108 |
-
<T.Group
|
109 |
-
{
|
110 |
-
|
111 |
-
{scale}
|
112 |
-
>
|
113 |
-
<T.Group
|
114 |
-
scale={[1, 1, 1]}
|
115 |
-
>
|
116 |
-
<Model fan_rotation={fan_rotation} />
|
117 |
</T.Group>
|
118 |
</T.Group>
|
|
|
15 |
}
|
16 |
|
17 |
// Props with defaults
|
18 |
+
let {
|
19 |
+
position = [0, 0, 0],
|
20 |
+
rotation = [0, 0, 0],
|
21 |
+
scale = [1, 1, 1],
|
22 |
+
rotating = false
|
23 |
+
}: Props = $props();
|
24 |
|
25 |
// Create the TV frame geometry (outer rounded rectangle)
|
26 |
function createTVFrame(
|
|
|
92 |
|
93 |
let fan_rotation = $state(0);
|
94 |
let rotationPerSeconds = $state(1); // 1 rotation per second by default
|
95 |
+
|
96 |
onMount(() => {
|
97 |
const interval = setInterval(() => {
|
98 |
// Calculate angle increment per frame for desired rotations per second
|
|
|
100 |
const angleIncrement = (Math.PI * 2 * rotationPerSeconds) / 60;
|
101 |
fan_rotation = fan_rotation + angleIncrement;
|
102 |
}
|
103 |
+
}, 1000 / 60); // Run at ~60fps
|
104 |
|
105 |
return () => {
|
106 |
clearInterval(interval);
|
107 |
};
|
108 |
});
|
|
|
|
|
109 |
</script>
|
110 |
|
111 |
+
<T.Group {position} {rotation} {scale}>
|
112 |
+
<T.Group scale={[1, 1, 1]}>
|
113 |
+
<Model {fan_rotation} />
|
|
|
|
|
|
|
|
|
|
|
|
|
114 |
</T.Group>
|
115 |
</T.Group>
|
src/lib/components/3d/elements/compute/GPUModel.svelte
CHANGED
@@ -8,193 +8,178 @@ Title: Nvidia GeForce RTX 3090
|
|
8 |
-->
|
9 |
|
10 |
<script lang="ts">
|
11 |
-
|
12 |
|
13 |
-
|
14 |
-
|
15 |
-
|
16 |
|
17 |
-
|
18 |
-
|
19 |
-
|
20 |
-
|
21 |
-
|
22 |
-
|
23 |
-
|
24 |
-
|
25 |
-
|
26 |
-
|
27 |
-
|
28 |
-
|
29 |
-
|
30 |
-
|
31 |
|
32 |
-
|
33 |
-
|
34 |
-
|
35 |
-
|
36 |
-
|
37 |
-
|
38 |
-
|
39 |
-
|
40 |
-
|
41 |
-
|
42 |
-
|
43 |
-
|
44 |
-
|
45 |
-
|
46 |
-
|
47 |
-
|
48 |
-
|
49 |
-
|
50 |
-
|
51 |
-
|
52 |
-
|
53 |
-
|
54 |
-
|
55 |
-
|
56 |
-
|
57 |
-
|
58 |
-
|
59 |
-
|
60 |
-
|
61 |
-
|
62 |
-
|
63 |
|
64 |
-
|
65 |
</script>
|
66 |
|
67 |
-
<T.Group
|
68 |
-
|
69 |
-
|
70 |
-
|
71 |
-
>
|
72 |
-
|
73 |
-
|
74 |
-
|
75 |
-
|
76 |
-
|
77 |
-
|
78 |
-
|
79 |
-
|
80 |
-
|
81 |
-
|
82 |
-
|
83 |
-
|
84 |
-
|
85 |
-
|
86 |
-
|
87 |
-
|
88 |
-
|
89 |
-
|
90 |
-
|
91 |
-
|
92 |
-
|
93 |
-
|
94 |
-
|
95 |
-
|
96 |
-
|
97 |
-
|
98 |
-
|
99 |
-
|
100 |
-
|
101 |
-
|
102 |
-
|
103 |
-
|
104 |
-
|
105 |
-
|
106 |
-
|
107 |
-
|
108 |
-
|
109 |
-
|
110 |
-
|
111 |
-
|
112 |
-
|
113 |
-
|
114 |
-
|
115 |
-
|
116 |
-
|
117 |
-
|
118 |
-
|
119 |
-
|
120 |
-
|
121 |
-
|
122 |
-
|
123 |
-
|
124 |
-
|
125 |
-
|
126 |
-
|
127 |
-
|
128 |
-
|
129 |
-
|
130 |
-
|
131 |
-
|
132 |
-
|
133 |
-
|
134 |
-
|
135 |
-
|
136 |
-
|
137 |
-
|
138 |
-
|
139 |
-
|
140 |
-
|
141 |
-
|
142 |
-
|
143 |
-
|
144 |
-
|
145 |
-
|
146 |
-
|
147 |
-
|
148 |
-
|
149 |
-
|
150 |
-
|
151 |
-
|
152 |
-
|
153 |
-
|
154 |
-
|
155 |
-
|
156 |
-
|
157 |
-
|
158 |
-
|
159 |
-
|
160 |
-
|
161 |
-
|
162 |
-
|
163 |
-
|
164 |
-
|
165 |
-
|
166 |
-
|
167 |
-
|
168 |
-
|
169 |
-
|
170 |
-
|
171 |
-
|
172 |
-
|
173 |
-
|
174 |
-
|
175 |
-
|
176 |
-
|
177 |
-
|
178 |
-
|
179 |
-
|
180 |
-
|
181 |
-
|
182 |
-
|
183 |
-
position={[131.49, 88.84, -23.02]}
|
184 |
-
rotation={[Math.PI / 2, 0, 0]}
|
185 |
-
scale={[1, 1, 1.02]}
|
186 |
-
/>
|
187 |
-
<T.Mesh
|
188 |
-
geometry={gltf.nodes.Grills_F002_Metal_Black_0.geometry}
|
189 |
-
material={gltf.materials.Metal_Black}
|
190 |
-
position={[-128.18, 88.84, -4.17]}
|
191 |
-
rotation={[Math.PI / 2, 0, Math.PI]}
|
192 |
-
scale={[1, 0.97, 1.02]}
|
193 |
-
/>
|
194 |
-
</T.Group>
|
195 |
-
{:catch err}
|
196 |
-
{@render error?.({ error: err })}
|
197 |
-
{/await}
|
198 |
|
199 |
-
|
200 |
</T.Group>
|
|
|
8 |
-->
|
9 |
|
10 |
<script lang="ts">
|
11 |
+
import type * as THREE from "three";
|
12 |
|
13 |
+
import type { Snippet } from "svelte";
|
14 |
+
import { T, type Props } from "@threlte/core";
|
15 |
+
import { useGltf } from "@threlte/extras";
|
16 |
|
17 |
+
let {
|
18 |
+
fan_rotation = 0,
|
19 |
+
fallback,
|
20 |
+
error,
|
21 |
+
children,
|
22 |
+
ref = $bindable(),
|
23 |
+
...props
|
24 |
+
}: Props<THREE.Group<THREE.Object3DEventMap>> & {
|
25 |
+
fan_rotation?: number;
|
26 |
+
ref?: THREE.Group<THREE.Object3DEventMap> | undefined;
|
27 |
+
children?: Snippet<[{ ref: THREE.Group<THREE.Object3DEventMap> | undefined }]>;
|
28 |
+
fallback?: Snippet;
|
29 |
+
error?: Snippet<[{ error: Error }]>;
|
30 |
+
} = $props();
|
31 |
|
32 |
+
type GLTFResult = {
|
33 |
+
nodes: {
|
34 |
+
Metal_Frame_Metal_0: THREE.Mesh;
|
35 |
+
Front_Cover_Black_0: THREE.Mesh;
|
36 |
+
Fan_Circle_Black_Fan_0: THREE.Mesh;
|
37 |
+
Fan_F_Black_Fan_0: THREE.Mesh;
|
38 |
+
Fan_F_Slot1_0: THREE.Mesh;
|
39 |
+
Front_Cover_U_Black_0: THREE.Mesh;
|
40 |
+
Front_Cover_T_Black_0: THREE.Mesh;
|
41 |
+
Fan_Circle_B_Black_Fan_0: THREE.Mesh;
|
42 |
+
Grills_U_Metal_Black_0: THREE.Mesh;
|
43 |
+
Grills_T_Metal_Black_0: THREE.Mesh;
|
44 |
+
Plane010_Black001_0: THREE.Mesh;
|
45 |
+
Socket_Slot_0: THREE.Mesh;
|
46 |
+
Side_Metal_Part_Metal_S_0: THREE.Mesh;
|
47 |
+
Grills_F003_Metal_Black_0: THREE.Mesh;
|
48 |
+
Grills_F002_Metal_Black_0: THREE.Mesh;
|
49 |
+
Fan_B_Black_Fan_0: THREE.Mesh;
|
50 |
+
Fan_B_Slot1_0: THREE.Mesh;
|
51 |
+
};
|
52 |
+
materials: {
|
53 |
+
Metal: THREE.MeshStandardMaterial;
|
54 |
+
Black: THREE.MeshStandardMaterial;
|
55 |
+
Black_Fan: THREE.MeshStandardMaterial;
|
56 |
+
["Slot.1"]: THREE.MeshStandardMaterial;
|
57 |
+
Metal_Black: THREE.MeshStandardMaterial;
|
58 |
+
["Black.001"]: THREE.MeshStandardMaterial;
|
59 |
+
Slot: THREE.MeshStandardMaterial;
|
60 |
+
Metal_S: THREE.MeshStandardMaterial;
|
61 |
+
};
|
62 |
+
};
|
63 |
|
64 |
+
const gltf = useGltf<GLTFResult>("/gpu/scene.gltf");
|
65 |
</script>
|
66 |
|
67 |
+
<T.Group bind:ref dispose={false} {...props as any}>
|
68 |
+
{#await gltf}
|
69 |
+
{@render fallback?.()}
|
70 |
+
{:then gltf}
|
71 |
+
<T.Group scale={0.01}>
|
72 |
+
<T.Group position={[127.5, 88.51, 10.29]} rotation={[Math.PI / 2, 0.05, 0]} scale={0.3}>
|
73 |
+
<T.Mesh
|
74 |
+
geometry={gltf.nodes.Fan_F_Black_Fan_0.geometry}
|
75 |
+
material={gltf.materials.Black_Fan}
|
76 |
+
rotation={[0, fan_rotation, 0]}
|
77 |
+
/>
|
78 |
+
<T.Mesh geometry={gltf.nodes.Fan_F_Slot1_0.geometry} material={gltf.materials["Slot.1"]} />
|
79 |
+
</T.Group>
|
80 |
+
<T.Group
|
81 |
+
position={[-123.9, 88.51, -37.82]}
|
82 |
+
rotation={[Math.PI / 2, -0.05, Math.PI]}
|
83 |
+
scale={0.3}
|
84 |
+
>
|
85 |
+
<T.Mesh
|
86 |
+
geometry={gltf.nodes.Fan_B_Black_Fan_0.geometry}
|
87 |
+
material={gltf.materials.Black_Fan}
|
88 |
+
rotation={[0, fan_rotation, 0]}
|
89 |
+
/>
|
90 |
+
<T.Mesh geometry={gltf.nodes.Fan_B_Slot1_0.geometry} material={gltf.materials["Slot.1"]} />
|
91 |
+
</T.Group>
|
92 |
+
<T.Mesh
|
93 |
+
geometry={gltf.nodes.Metal_Frame_Metal_0.geometry}
|
94 |
+
material={gltf.materials.Metal}
|
95 |
+
position={[0, 88.3, -8.47]}
|
96 |
+
rotation={[Math.PI / 2, 0, 0]}
|
97 |
+
/>
|
98 |
+
<T.Mesh
|
99 |
+
geometry={gltf.nodes.Front_Cover_Black_0.geometry}
|
100 |
+
material={gltf.materials.Black}
|
101 |
+
position={[-122.3, 89.69, 12.11]}
|
102 |
+
rotation={[Math.PI / 2, 0, 0]}
|
103 |
+
scale={[1, 1, 0.84]}
|
104 |
+
/>
|
105 |
+
<T.Mesh
|
106 |
+
geometry={gltf.nodes.Fan_Circle_Black_Fan_0.geometry}
|
107 |
+
material={gltf.materials.Black_Fan}
|
108 |
+
position={[127.5, 88.51, 10.29]}
|
109 |
+
rotation={[Math.PI / 2, 0, 0]}
|
110 |
+
scale={0.79}
|
111 |
+
/>
|
112 |
+
<T.Mesh
|
113 |
+
geometry={gltf.nodes.Front_Cover_U_Black_0.geometry}
|
114 |
+
material={gltf.materials.Black}
|
115 |
+
position={[0.02, 26.08, 14.09]}
|
116 |
+
rotation={[Math.PI / 2, 0, 0]}
|
117 |
+
/>
|
118 |
+
<T.Mesh
|
119 |
+
geometry={gltf.nodes.Front_Cover_T_Black_0.geometry}
|
120 |
+
material={gltf.materials.Black}
|
121 |
+
position={[-4.75, 163.4, 14.09]}
|
122 |
+
rotation={[-Math.PI / 2, 0, -Math.PI]}
|
123 |
+
/>
|
124 |
+
<T.Mesh
|
125 |
+
geometry={gltf.nodes.Fan_Circle_B_Black_Fan_0.geometry}
|
126 |
+
material={gltf.materials.Black_Fan}
|
127 |
+
position={[-124.15, 88.51, -40.18]}
|
128 |
+
rotation={[Math.PI / 2, 0, Math.PI]}
|
129 |
+
scale={0.79}
|
130 |
+
/>
|
131 |
+
<T.Mesh
|
132 |
+
geometry={gltf.nodes.Grills_U_Metal_Black_0.geometry}
|
133 |
+
material={gltf.materials.Metal_Black}
|
134 |
+
position={[-0.12, 3.16, 3.09]}
|
135 |
+
rotation={[Math.PI / 2, -Math.PI / 4, 0]}
|
136 |
+
scale={[0.55, 11.75, 0.55]}
|
137 |
+
/>
|
138 |
+
<T.Mesh
|
139 |
+
geometry={gltf.nodes.Grills_T_Metal_Black_0.geometry}
|
140 |
+
material={gltf.materials.Metal_Black}
|
141 |
+
position={[0.8, 174.49, 3.09]}
|
142 |
+
rotation={[-Math.PI / 2, Math.PI / 4, -Math.PI]}
|
143 |
+
scale={[0.55, 11.75, 0.55]}
|
144 |
+
/>
|
145 |
+
<T.Mesh
|
146 |
+
geometry={gltf.nodes.Plane010_Black001_0.geometry}
|
147 |
+
material={gltf.materials["Black.001"]}
|
148 |
+
position={[121.84, 88.42, -34.24]}
|
149 |
+
rotation={[-Math.PI / 2, 0, -Math.PI]}
|
150 |
+
scale={[1, 1, 0.84]}
|
151 |
+
/>
|
152 |
+
<T.Mesh
|
153 |
+
geometry={gltf.nodes.Socket_Slot_0.geometry}
|
154 |
+
material={gltf.materials.Slot}
|
155 |
+
position={[-149.71, 187.47, -39.01]}
|
156 |
+
rotation={[Math.PI / 2, 0, 0]}
|
157 |
+
scale={[1, 1.93, 1]}
|
158 |
+
/>
|
159 |
+
<T.Mesh
|
160 |
+
geometry={gltf.nodes.Side_Metal_Part_Metal_S_0.geometry}
|
161 |
+
material={gltf.materials.Metal_S}
|
162 |
+
position={[-225.87, 118.09, -12.54]}
|
163 |
+
rotation={[Math.PI / 2, 0, 0]}
|
164 |
+
/>
|
165 |
+
<T.Mesh
|
166 |
+
geometry={gltf.nodes.Grills_F003_Metal_Black_0.geometry}
|
167 |
+
material={gltf.materials.Metal_Black}
|
168 |
+
position={[131.49, 88.84, -23.02]}
|
169 |
+
rotation={[Math.PI / 2, 0, 0]}
|
170 |
+
scale={[1, 1, 1.02]}
|
171 |
+
/>
|
172 |
+
<T.Mesh
|
173 |
+
geometry={gltf.nodes.Grills_F002_Metal_Black_0.geometry}
|
174 |
+
material={gltf.materials.Metal_Black}
|
175 |
+
position={[-128.18, 88.84, -4.17]}
|
176 |
+
rotation={[Math.PI / 2, 0, Math.PI]}
|
177 |
+
scale={[1, 0.97, 1.02]}
|
178 |
+
/>
|
179 |
+
</T.Group>
|
180 |
+
{:catch err}
|
181 |
+
{@render error?.({ error: err })}
|
182 |
+
{/await}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
183 |
|
184 |
+
{@render children?.({ ref })}
|
185 |
</T.Group>
|
src/lib/components/3d/elements/compute/modal/AISessionConnectionModal.svelte
CHANGED
@@ -380,9 +380,9 @@
|
|
380 |
placeholder="front, wrist, overhead"
|
381 |
class="border-slate-300 bg-slate-50 text-slate-900 dark:border-slate-600 dark:bg-slate-800 dark:text-slate-100"
|
382 |
/>
|
383 |
-
|
384 |
-
|
385 |
-
|
386 |
</div>
|
387 |
|
388 |
{#if modelConfig.requiresLanguageInstruction}
|
@@ -403,7 +403,9 @@
|
|
403 |
</div>
|
404 |
{/if}
|
405 |
|
406 |
-
<div
|
|
|
|
|
407 |
<div class="flex items-center gap-2">
|
408 |
<span class="icon-[mdi--folder] size-4 text-green-600 dark:text-green-400"></span>
|
409 |
<span class="text-sm font-medium text-green-700 dark:text-green-300">
|
@@ -420,9 +422,9 @@
|
|
420 |
>
|
421 |
<div class="text-xs text-slate-600 dark:text-slate-400">
|
422 |
<span class="icon-[mdi--lightbulb] size-3"></span>
|
423 |
-
<strong>Tip:</strong> This will create a new {modelConfig.label} inference session
|
424 |
-
inputs, joint inputs, and joint outputs in the inference
|
425 |
-
system.
|
426 |
</div>
|
427 |
</div>
|
428 |
|
@@ -450,8 +452,8 @@
|
|
450 |
class="rounded border border-slate-300 bg-slate-100/30 p-2 text-xs text-slate-600 dark:border-slate-700 dark:bg-slate-800/30 dark:text-slate-500"
|
451 |
>
|
452 |
<span class="icon-[mdi--information] mr-1 size-3"></span>
|
453 |
-
Inference Sessions require a trained {modelConfig.label} and create dedicated communication rooms
|
454 |
-
inputs, robot joint states, and control outputs in the inference server system.
|
455 |
</div>
|
456 |
</div>
|
457 |
</Dialog.Content>
|
|
|
380 |
placeholder="front, wrist, overhead"
|
381 |
class="border-slate-300 bg-slate-50 text-slate-900 dark:border-slate-600 dark:bg-slate-800 dark:text-slate-100"
|
382 |
/>
|
383 |
+
<p class="text-xs text-slate-600 dark:text-slate-400">
|
384 |
+
Comma-separated camera names
|
385 |
+
</p>
|
386 |
</div>
|
387 |
|
388 |
{#if modelConfig.requiresLanguageInstruction}
|
|
|
403 |
</div>
|
404 |
{/if}
|
405 |
|
406 |
+
<div
|
407 |
+
class="rounded-lg border border-green-300/30 bg-green-100/20 p-3 dark:border-green-500/30 dark:bg-green-900/20"
|
408 |
+
>
|
409 |
<div class="flex items-center gap-2">
|
410 |
<span class="icon-[mdi--folder] size-4 text-green-600 dark:text-green-400"></span>
|
411 |
<span class="text-sm font-medium text-green-700 dark:text-green-300">
|
|
|
422 |
>
|
423 |
<div class="text-xs text-slate-600 dark:text-slate-400">
|
424 |
<span class="icon-[mdi--lightbulb] size-3"></span>
|
425 |
+
<strong>Tip:</strong> This will create a new {modelConfig.label} inference session
|
426 |
+
with dedicated rooms for camera inputs, joint inputs, and joint outputs in the inference
|
427 |
+
server communication system.
|
428 |
</div>
|
429 |
</div>
|
430 |
|
|
|
452 |
class="rounded border border-slate-300 bg-slate-100/30 p-2 text-xs text-slate-600 dark:border-slate-700 dark:bg-slate-800/30 dark:text-slate-500"
|
453 |
>
|
454 |
<span class="icon-[mdi--information] mr-1 size-3"></span>
|
455 |
+
Inference Sessions require a trained {modelConfig.label} and create dedicated communication rooms
|
456 |
+
for video inputs, robot joint states, and control outputs in the inference server system.
|
457 |
</div>
|
458 |
</div>
|
459 |
</Dialog.Content>
|
src/lib/components/3d/elements/compute/status/ComputeBoxUIKit.svelte
CHANGED
@@ -1,11 +1,7 @@
|
|
1 |
<script lang="ts">
|
2 |
import type { RemoteCompute } from "$lib/elements/compute//RemoteCompute.svelte";
|
3 |
import { ICON } from "$lib/utils/icon";
|
4 |
-
import {
|
5 |
-
BaseStatusBox,
|
6 |
-
StatusHeader,
|
7 |
-
StatusContent
|
8 |
-
} from "$lib/components/3d/ui";
|
9 |
|
10 |
interface Props {
|
11 |
compute: RemoteCompute;
|
|
|
1 |
<script lang="ts">
|
2 |
import type { RemoteCompute } from "$lib/elements/compute//RemoteCompute.svelte";
|
3 |
import { ICON } from "$lib/utils/icon";
|
4 |
+
import { BaseStatusBox, StatusHeader, StatusContent } from "$lib/components/3d/ui";
|
|
|
|
|
|
|
|
|
5 |
|
6 |
interface Props {
|
7 |
compute: RemoteCompute;
|
src/lib/components/3d/elements/compute/status/ComputeStatusBillboard.svelte
CHANGED
@@ -5,7 +5,7 @@
|
|
5 |
import ComputeConnectionFlowBoxUIKit from "./ComputeConnectionFlowBoxUIKit.svelte";
|
6 |
import type { RemoteCompute } from "$lib/elements/compute//RemoteCompute.svelte";
|
7 |
import { Tween } from "svelte/motion";
|
8 |
-
import { cubicOut } from "svelte/easing";
|
9 |
|
10 |
interface Props {
|
11 |
compute: RemoteCompute;
|
@@ -29,12 +29,18 @@
|
|
29 |
|
30 |
interactivity();
|
31 |
|
32 |
-
const tweenedScale = Tween.of(
|
33 |
-
|
34 |
-
|
35 |
-
|
36 |
-
|
37 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
38 |
</script>
|
39 |
|
40 |
<T.Group
|
|
|
5 |
import ComputeConnectionFlowBoxUIKit from "./ComputeConnectionFlowBoxUIKit.svelte";
|
6 |
import type { RemoteCompute } from "$lib/elements/compute//RemoteCompute.svelte";
|
7 |
import { Tween } from "svelte/motion";
|
8 |
+
import { cubicOut } from "svelte/easing";
|
9 |
|
10 |
interface Props {
|
11 |
compute: RemoteCompute;
|
|
|
29 |
|
30 |
interactivity();
|
31 |
|
32 |
+
const tweenedScale = Tween.of(
|
33 |
+
() => {
|
34 |
+
return visible ? 1 : 0;
|
35 |
+
},
|
36 |
+
{ duration: duration, easing: cubicOut, delay: delay }
|
37 |
+
);
|
38 |
+
const tweenedOpacity = Tween.of(
|
39 |
+
() => {
|
40 |
+
return visible ? 1 : 0;
|
41 |
+
},
|
42 |
+
{ duration: duration, easing: cubicOut, delay: delay }
|
43 |
+
);
|
44 |
</script>
|
45 |
|
46 |
<T.Group
|
src/lib/components/3d/elements/compute/status/RobotOutputBoxUIKit.svelte
CHANGED
@@ -1,7 +1,13 @@
|
|
1 |
<script lang="ts">
|
2 |
import type { RemoteCompute } from "$lib/elements/compute//RemoteCompute.svelte";
|
3 |
import { ICON } from "$lib/utils/icon";
|
4 |
-
import {
|
|
|
|
|
|
|
|
|
|
|
|
|
5 |
|
6 |
interface Props {
|
7 |
compute: RemoteCompute;
|
@@ -13,7 +19,7 @@
|
|
13 |
const outputColor = "rgb(59, 130, 246)";
|
14 |
</script>
|
15 |
|
16 |
-
<BaseStatusBox
|
17 |
color={outputColor}
|
18 |
borderOpacity={0.6}
|
19 |
backgroundOpacity={0.2}
|
@@ -22,14 +28,14 @@
|
|
22 |
>
|
23 |
{#if compute.hasSession && compute.outputConnections}
|
24 |
<!-- Active Robot Output State -->
|
25 |
-
<StatusHeader
|
26 |
-
icon={ICON["icon-[ix--robotic-arm]"].svg}
|
27 |
-
text="COMMANDS"
|
28 |
color={outputColor}
|
29 |
opacity={0.9}
|
30 |
/>
|
31 |
|
32 |
-
<StatusContent
|
33 |
title={compute.isRunning ? "AI Commands Active" : "Session Ready"}
|
34 |
subtitle="Motor Control"
|
35 |
color="rgb(37, 99, 235)"
|
@@ -46,25 +52,25 @@
|
|
46 |
{/if}
|
47 |
{:else}
|
48 |
<!-- No Session State -->
|
49 |
-
<StatusHeader
|
50 |
-
icon={ICON["icon-[ix--robotic-arm]"].svg}
|
51 |
-
text="NO OUTPUT"
|
52 |
color={outputColor}
|
53 |
opacity={0.7}
|
54 |
/>
|
55 |
|
56 |
-
<StatusContent
|
57 |
-
title={!compute.hasSession ?
|
58 |
color="rgb(59, 130, 246)"
|
59 |
variant="secondary"
|
60 |
/>
|
61 |
|
62 |
-
<StatusButton
|
63 |
-
icon={ICON["icon-[ix--robotic-arm]"].svg}
|
64 |
text="Setup Output"
|
65 |
color={outputColor}
|
66 |
backgroundOpacity={0.1}
|
67 |
textOpacity={0.7}
|
68 |
/>
|
69 |
{/if}
|
70 |
-
</BaseStatusBox>
|
|
|
1 |
<script lang="ts">
|
2 |
import type { RemoteCompute } from "$lib/elements/compute//RemoteCompute.svelte";
|
3 |
import { ICON } from "$lib/utils/icon";
|
4 |
+
import {
|
5 |
+
BaseStatusBox,
|
6 |
+
StatusHeader,
|
7 |
+
StatusContent,
|
8 |
+
StatusIndicator,
|
9 |
+
StatusButton
|
10 |
+
} from "$lib/components/3d/ui";
|
11 |
|
12 |
interface Props {
|
13 |
compute: RemoteCompute;
|
|
|
19 |
const outputColor = "rgb(59, 130, 246)";
|
20 |
</script>
|
21 |
|
22 |
+
<BaseStatusBox
|
23 |
color={outputColor}
|
24 |
borderOpacity={0.6}
|
25 |
backgroundOpacity={0.2}
|
|
|
28 |
>
|
29 |
{#if compute.hasSession && compute.outputConnections}
|
30 |
<!-- Active Robot Output State -->
|
31 |
+
<StatusHeader
|
32 |
+
icon={ICON["icon-[ix--robotic-arm]"].svg}
|
33 |
+
text="COMMANDS"
|
34 |
color={outputColor}
|
35 |
opacity={0.9}
|
36 |
/>
|
37 |
|
38 |
+
<StatusContent
|
39 |
title={compute.isRunning ? "AI Commands Active" : "Session Ready"}
|
40 |
subtitle="Motor Control"
|
41 |
color="rgb(37, 99, 235)"
|
|
|
52 |
{/if}
|
53 |
{:else}
|
54 |
<!-- No Session State -->
|
55 |
+
<StatusHeader
|
56 |
+
icon={ICON["icon-[ix--robotic-arm]"].svg}
|
57 |
+
text="NO OUTPUT"
|
58 |
color={outputColor}
|
59 |
opacity={0.7}
|
60 |
/>
|
61 |
|
62 |
+
<StatusContent
|
63 |
+
title={!compute.hasSession ? "Need Session" : "Click to Configure"}
|
64 |
color="rgb(59, 130, 246)"
|
65 |
variant="secondary"
|
66 |
/>
|
67 |
|
68 |
+
<StatusButton
|
69 |
+
icon={ICON["icon-[ix--robotic-arm]"].svg}
|
70 |
text="Setup Output"
|
71 |
color={outputColor}
|
72 |
backgroundOpacity={0.1}
|
73 |
textOpacity={0.7}
|
74 |
/>
|
75 |
{/if}
|
76 |
+
</BaseStatusBox>
|
src/lib/components/3d/elements/compute/status/VideoInputBoxUIKit.svelte
CHANGED
@@ -1,6 +1,12 @@
|
|
1 |
<script lang="ts">
|
2 |
import type { RemoteCompute } from "$lib/elements/compute//RemoteCompute.svelte";
|
3 |
-
import {
|
|
|
|
|
|
|
|
|
|
|
|
|
4 |
import { ICON } from "@/utils/icon";
|
5 |
|
6 |
interface Props {
|
@@ -13,7 +19,7 @@
|
|
13 |
const inputColor = "rgb(34, 197, 94)";
|
14 |
</script>
|
15 |
|
16 |
-
<BaseStatusBox
|
17 |
minWidth={100}
|
18 |
minHeight={65}
|
19 |
color={inputColor}
|
@@ -24,16 +30,16 @@
|
|
24 |
>
|
25 |
{#if compute.hasSession && compute.inputConnections}
|
26 |
<!-- Active Video Input State -->
|
27 |
-
<StatusHeader
|
28 |
-
icon={ICON["icon-[mdi--video]"].svg}
|
29 |
-
text="VIDEO"
|
30 |
color={inputColor}
|
31 |
opacity={0.9}
|
32 |
fontSize={11}
|
33 |
/>
|
34 |
|
35 |
<!-- Camera Streams -->
|
36 |
-
<StatusContent
|
37 |
title={`${Object.keys(compute.inputConnections.cameras).length} Cameras`}
|
38 |
color="rgb(21, 128, 61)"
|
39 |
variant="primary"
|
@@ -43,21 +49,17 @@
|
|
43 |
<StatusIndicator color={inputColor} />
|
44 |
{:else}
|
45 |
<!-- No Session State -->
|
46 |
-
<StatusHeader
|
47 |
-
icon={ICON["icon-[mdi--video-off]"].svg}
|
48 |
-
text="NO VIDEO"
|
49 |
color={inputColor}
|
50 |
opacity={0.7}
|
51 |
fontSize={11}
|
52 |
/>
|
53 |
|
54 |
-
<StatusContent
|
55 |
-
title="Setup Video"
|
56 |
-
color="rgb(34, 197, 94)"
|
57 |
-
variant="secondary"
|
58 |
-
/>
|
59 |
|
60 |
-
<StatusButton
|
61 |
icon={ICON["icon-[mdi--plus]"].svg}
|
62 |
text="Add"
|
63 |
color={inputColor}
|
@@ -65,4 +67,4 @@
|
|
65 |
textOpacity={0.7}
|
66 |
/>
|
67 |
{/if}
|
68 |
-
</BaseStatusBox>
|
|
|
1 |
<script lang="ts">
|
2 |
import type { RemoteCompute } from "$lib/elements/compute//RemoteCompute.svelte";
|
3 |
+
import {
|
4 |
+
BaseStatusBox,
|
5 |
+
StatusHeader,
|
6 |
+
StatusContent,
|
7 |
+
StatusIndicator,
|
8 |
+
StatusButton
|
9 |
+
} from "$lib/components/3d/ui";
|
10 |
import { ICON } from "@/utils/icon";
|
11 |
|
12 |
interface Props {
|
|
|
19 |
const inputColor = "rgb(34, 197, 94)";
|
20 |
</script>
|
21 |
|
22 |
+
<BaseStatusBox
|
23 |
minWidth={100}
|
24 |
minHeight={65}
|
25 |
color={inputColor}
|
|
|
30 |
>
|
31 |
{#if compute.hasSession && compute.inputConnections}
|
32 |
<!-- Active Video Input State -->
|
33 |
+
<StatusHeader
|
34 |
+
icon={ICON["icon-[mdi--video]"].svg}
|
35 |
+
text="VIDEO"
|
36 |
color={inputColor}
|
37 |
opacity={0.9}
|
38 |
fontSize={11}
|
39 |
/>
|
40 |
|
41 |
<!-- Camera Streams -->
|
42 |
+
<StatusContent
|
43 |
title={`${Object.keys(compute.inputConnections.cameras).length} Cameras`}
|
44 |
color="rgb(21, 128, 61)"
|
45 |
variant="primary"
|
|
|
49 |
<StatusIndicator color={inputColor} />
|
50 |
{:else}
|
51 |
<!-- No Session State -->
|
52 |
+
<StatusHeader
|
53 |
+
icon={ICON["icon-[mdi--video-off]"].svg}
|
54 |
+
text="NO VIDEO"
|
55 |
color={inputColor}
|
56 |
opacity={0.7}
|
57 |
fontSize={11}
|
58 |
/>
|
59 |
|
60 |
+
<StatusContent title="Setup Video" color="rgb(34, 197, 94)" variant="secondary" />
|
|
|
|
|
|
|
|
|
61 |
|
62 |
+
<StatusButton
|
63 |
icon={ICON["icon-[mdi--plus]"].svg}
|
64 |
text="Add"
|
65 |
color={inputColor}
|
|
|
67 |
textOpacity={0.7}
|
68 |
/>
|
69 |
{/if}
|
70 |
+
</BaseStatusBox>
|
src/lib/components/3d/elements/robot/RobotGridItem.svelte
CHANGED
@@ -106,7 +106,6 @@
|
|
106 |
}
|
107 |
|
108 |
const { onPointerEnter, onPointerLeave, hovering } = useCursor();
|
109 |
-
|
110 |
|
111 |
let isToggled = $state(false);
|
112 |
|
@@ -124,13 +123,17 @@
|
|
124 |
scale={[10, 10, 10]}
|
125 |
rotation={[-Math.PI / 2, 0, 0]}
|
126 |
>
|
127 |
-
<T.Group
|
128 |
-
event
|
129 |
-
|
130 |
-
|
131 |
-
|
132 |
-
|
133 |
-
|
|
|
|
|
|
|
|
|
134 |
{#if urdfRobotState}
|
135 |
{#each getRootLinks(urdfRobotState) as link}
|
136 |
<UrdfLink
|
|
|
106 |
}
|
107 |
|
108 |
const { onPointerEnter, onPointerLeave, hovering } = useCursor();
|
|
|
109 |
|
110 |
let isToggled = $state(false);
|
111 |
|
|
|
123 |
scale={[10, 10, 10]}
|
124 |
rotation={[-Math.PI / 2, 0, 0]}
|
125 |
>
|
126 |
+
<T.Group
|
127 |
+
onpointerenter={(event) => {
|
128 |
+
event.stopPropagation();
|
129 |
+
onPointerEnter();
|
130 |
+
}}
|
131 |
+
onpointerleave={(event) => {
|
132 |
+
event.stopPropagation();
|
133 |
+
onPointerLeave();
|
134 |
+
}}
|
135 |
+
onclick={handleClick}
|
136 |
+
>
|
137 |
{#if urdfRobotState}
|
138 |
{#each getRootLinks(urdfRobotState) as link}
|
139 |
<UrdfLink
|
src/lib/components/3d/elements/robot/URDF/primitives/UrdfJoint.svelte
CHANGED
@@ -81,8 +81,6 @@
|
|
81 |
// return norm * 200 - 100; // → [-100, 100]
|
82 |
// }
|
83 |
|
84 |
-
|
85 |
-
|
86 |
// function denormalizeAngle(normValue: number, name: keyof typeof ANGLE_RANGES): number {
|
87 |
// if (!(name in ANGLE_RANGES)) {
|
88 |
// throw new Error(`Unknown angle name: ${name}`);
|
@@ -156,11 +154,7 @@
|
|
156 |
|
157 |
<T.Mesh rotation={[Math.PI / 2, 0, 0]} {...restProps}>
|
158 |
<T.CylinderGeometry args={[0.004, 0.004, 0.03]} />
|
159 |
-
<T.MeshBasicMaterial
|
160 |
-
color={jointColor}
|
161 |
-
{opacity}
|
162 |
-
transparent={opacity < 1.0}
|
163 |
-
/>
|
164 |
</T.Mesh>
|
165 |
{/if}
|
166 |
</T.Group>
|
@@ -174,7 +168,7 @@
|
|
174 |
renderOrder={999}
|
175 |
frustumCulled={false}
|
176 |
>
|
177 |
-
|
178 |
<Text
|
179 |
scale={nameHeight}
|
180 |
color={jointColor}
|
|
|
81 |
// return norm * 200 - 100; // → [-100, 100]
|
82 |
// }
|
83 |
|
|
|
|
|
84 |
// function denormalizeAngle(normValue: number, name: keyof typeof ANGLE_RANGES): number {
|
85 |
// if (!(name in ANGLE_RANGES)) {
|
86 |
// throw new Error(`Unknown angle name: ${name}`);
|
|
|
154 |
|
155 |
<T.Mesh rotation={[Math.PI / 2, 0, 0]} {...restProps}>
|
156 |
<T.CylinderGeometry args={[0.004, 0.004, 0.03]} />
|
157 |
+
<T.MeshBasicMaterial color={jointColor} {opacity} transparent={opacity < 1.0} />
|
|
|
|
|
|
|
|
|
158 |
</T.Mesh>
|
159 |
{/if}
|
160 |
</T.Group>
|
|
|
168 |
renderOrder={999}
|
169 |
frustumCulled={false}
|
170 |
>
|
171 |
+
<!-- text={joint.name + " " + getJointRotationValue(joint).toFixed(0) + "°" + " (" + normalizeAngle2(getJointRotationValue(joint)).toFixed(0) + ")"} -->
|
172 |
<Text
|
173 |
scale={nameHeight}
|
174 |
color={jointColor}
|
src/lib/components/3d/elements/robot/URDF/primitives/UrdfLink.svelte
CHANGED
@@ -47,7 +47,7 @@
|
|
47 |
jointIndicatorColor = "#000000",
|
48 |
nameHeight = 0.1,
|
49 |
showLine = true,
|
50 |
-
opacity = 0.7
|
51 |
}: Props = $props();
|
52 |
|
53 |
let showPointCloud = false;
|
|
|
47 |
jointIndicatorColor = "#000000",
|
48 |
nameHeight = 0.1,
|
49 |
showLine = true,
|
50 |
+
opacity = 0.7
|
51 |
}: Props = $props();
|
52 |
|
53 |
let showPointCloud = false;
|
src/lib/components/3d/elements/robot/URDF/utils/UrdfParser.ts
CHANGED
@@ -35,8 +35,6 @@ export function getRootLinks(robot: IUrdfRobot): IUrdfLink[] {
|
|
35 |
}
|
36 |
}
|
37 |
|
38 |
-
|
39 |
-
|
40 |
return links;
|
41 |
}
|
42 |
|
|
|
35 |
}
|
36 |
}
|
37 |
|
|
|
|
|
38 |
return links;
|
39 |
}
|
40 |
|
src/lib/components/3d/elements/robot/modal/InputConnectionModal.svelte
CHANGED
@@ -499,13 +499,15 @@
|
|
499 |
>
|
500 |
{room.id}
|
501 |
</p>
|
502 |
-
<div
|
|
|
|
|
503 |
<span>{room.has_producer ? "📤 Has Output" : "📥 No Output"}</span>
|
504 |
<span>👥 {room.participants?.total || 0} users</span>
|
505 |
<!-- Monitoring links -->
|
506 |
<div class="flex gap-1">
|
507 |
<a
|
508 |
-
href={`${settings.transportServerUrl.replace(
|
509 |
target="_blank"
|
510 |
rel="noopener noreferrer"
|
511 |
class="inline-flex items-center gap-1 rounded bg-blue-500/10 px-1.5 py-0.5 text-xs text-blue-600 hover:bg-blue-500/20 dark:bg-blue-400/10 dark:text-blue-400 dark:hover:bg-blue-400/20"
|
@@ -515,7 +517,7 @@
|
|
515 |
Consumer
|
516 |
</a>
|
517 |
<a
|
518 |
-
href={`${settings.transportServerUrl.replace(
|
519 |
target="_blank"
|
520 |
rel="noopener noreferrer"
|
521 |
class="inline-flex items-center gap-1 rounded bg-green-500/10 px-1.5 py-0.5 text-xs text-green-600 hover:bg-green-500/20 dark:bg-green-400/10 dark:text-green-400 dark:hover:bg-green-400/20"
|
@@ -526,9 +528,8 @@
|
|
526 |
</a>
|
527 |
</div>
|
528 |
</div>
|
529 |
-
|
530 |
</div>
|
531 |
-
|
532 |
<Button
|
533 |
variant="secondary"
|
534 |
size="sm"
|
|
|
499 |
>
|
500 |
{room.id}
|
501 |
</p>
|
502 |
+
<div
|
503 |
+
class="flex items-center gap-3 text-xs text-slate-600 dark:text-slate-400"
|
504 |
+
>
|
505 |
<span>{room.has_producer ? "📤 Has Output" : "📥 No Output"}</span>
|
506 |
<span>👥 {room.participants?.total || 0} users</span>
|
507 |
<!-- Monitoring links -->
|
508 |
<div class="flex gap-1">
|
509 |
<a
|
510 |
+
href={`${settings.transportServerUrl.replace("/api", "")}/${workspaceId}/robotics/consumer?room=${room.id}`}
|
511 |
target="_blank"
|
512 |
rel="noopener noreferrer"
|
513 |
class="inline-flex items-center gap-1 rounded bg-blue-500/10 px-1.5 py-0.5 text-xs text-blue-600 hover:bg-blue-500/20 dark:bg-blue-400/10 dark:text-blue-400 dark:hover:bg-blue-400/20"
|
|
|
517 |
Consumer
|
518 |
</a>
|
519 |
<a
|
520 |
+
href={`${settings.transportServerUrl.replace("/api", "")}/${workspaceId}/robotics/producer?room=${room.id}`}
|
521 |
target="_blank"
|
522 |
rel="noopener noreferrer"
|
523 |
class="inline-flex items-center gap-1 rounded bg-green-500/10 px-1.5 py-0.5 text-xs text-green-600 hover:bg-green-500/20 dark:bg-green-400/10 dark:text-green-400 dark:hover:bg-green-400/20"
|
|
|
528 |
</a>
|
529 |
</div>
|
530 |
</div>
|
|
|
531 |
</div>
|
532 |
+
|
533 |
<Button
|
534 |
variant="secondary"
|
535 |
size="sm"
|
src/lib/components/3d/elements/robot/modal/OutputConnectionModal.svelte
CHANGED
@@ -191,14 +191,14 @@
|
|
191 |
showUSBCalibration = false;
|
192 |
pendingUSBConnection = null;
|
193 |
isConnecting = false;
|
194 |
-
|
195 |
// Clean up the uncalibrated USB producer
|
196 |
const uncalibratedDrivers = robot.getUncalibratedUSBDrivers();
|
197 |
if (uncalibratedDrivers.length > 0) {
|
198 |
// Remove the most recent producer (should be the one we just added)
|
199 |
const lastProducer = robot.producers[robot.producers.length - 1];
|
200 |
if (lastProducer) {
|
201 |
-
robot.removeProducer(lastProducer.id).catch(err => {
|
202 |
console.error("Failed to clean up USB producer after calibration cancel:", err);
|
203 |
});
|
204 |
}
|
@@ -277,9 +277,7 @@
|
|
277 |
onCancel={onCalibrationCancel}
|
278 |
/>
|
279 |
{:else}
|
280 |
-
<div class="text-center text-slate-400">
|
281 |
-
No USB drivers require calibration
|
282 |
-
</div>
|
283 |
{/each}
|
284 |
</Card.Content>
|
285 |
</Card.Root>
|
@@ -340,13 +338,14 @@
|
|
340 |
<div class="flex items-center justify-between">
|
341 |
<div>
|
342 |
<Card.Title
|
343 |
-
class="flex items-center gap-2 text-base text-orange-700 dark:text-orange-200
|
344 |
>
|
345 |
<span class="icon-[mdi--cloud-sync] size-4"></span>
|
346 |
Remote Control
|
347 |
</Card.Title>
|
348 |
<Card.Description class="text-xs text-orange-600/70 dark:text-orange-300/70">
|
349 |
-
Broadcast robot movements to remote robots or AI systems from anywhere in the
|
|
|
350 |
</Card.Description>
|
351 |
</div>
|
352 |
<Button
|
@@ -441,13 +440,15 @@
|
|
441 |
>
|
442 |
{room.id}
|
443 |
</p>
|
444 |
-
<div
|
|
|
|
|
445 |
<span>{room.has_producer ? "🔴 Occupied" : "🟢 Available"}</span>
|
446 |
<span>👥 {room.participants?.total || 0} users</span>
|
447 |
<!-- Monitoring links -->
|
448 |
<div class="flex gap-1">
|
449 |
<a
|
450 |
-
href={`${settings.transportServerUrl.replace(
|
451 |
target="_blank"
|
452 |
rel="noopener noreferrer"
|
453 |
class="inline-flex items-center gap-1 rounded bg-blue-500/10 px-1.5 py-0.5 text-xs text-blue-600 hover:bg-blue-500/20 dark:bg-blue-400/10 dark:text-blue-400 dark:hover:bg-blue-400/20"
|
@@ -457,7 +458,7 @@
|
|
457 |
Consumer
|
458 |
</a>
|
459 |
<a
|
460 |
-
href={`${settings.transportServerUrl.replace(
|
461 |
target="_blank"
|
462 |
rel="noopener noreferrer"
|
463 |
class="inline-flex items-center gap-1 rounded bg-green-500/10 px-1.5 py-0.5 text-xs text-green-600 hover:bg-green-500/20 dark:bg-green-400/10 dark:text-green-400 dark:hover:bg-green-400/20"
|
|
|
191 |
showUSBCalibration = false;
|
192 |
pendingUSBConnection = null;
|
193 |
isConnecting = false;
|
194 |
+
|
195 |
// Clean up the uncalibrated USB producer
|
196 |
const uncalibratedDrivers = robot.getUncalibratedUSBDrivers();
|
197 |
if (uncalibratedDrivers.length > 0) {
|
198 |
// Remove the most recent producer (should be the one we just added)
|
199 |
const lastProducer = robot.producers[robot.producers.length - 1];
|
200 |
if (lastProducer) {
|
201 |
+
robot.removeProducer(lastProducer.id).catch((err) => {
|
202 |
console.error("Failed to clean up USB producer after calibration cancel:", err);
|
203 |
});
|
204 |
}
|
|
|
277 |
onCancel={onCalibrationCancel}
|
278 |
/>
|
279 |
{:else}
|
280 |
+
<div class="text-center text-slate-400">No USB drivers require calibration</div>
|
|
|
|
|
281 |
{/each}
|
282 |
</Card.Content>
|
283 |
</Card.Root>
|
|
|
338 |
<div class="flex items-center justify-between">
|
339 |
<div>
|
340 |
<Card.Title
|
341 |
+
class="flex items-center gap-2 pb-1 text-base text-orange-700 dark:text-orange-200"
|
342 |
>
|
343 |
<span class="icon-[mdi--cloud-sync] size-4"></span>
|
344 |
Remote Control
|
345 |
</Card.Title>
|
346 |
<Card.Description class="text-xs text-orange-600/70 dark:text-orange-300/70">
|
347 |
+
Broadcast robot movements to remote robots or AI systems from anywhere in the
|
348 |
+
world
|
349 |
</Card.Description>
|
350 |
</div>
|
351 |
<Button
|
|
|
440 |
>
|
441 |
{room.id}
|
442 |
</p>
|
443 |
+
<div
|
444 |
+
class="flex items-center gap-3 text-xs text-slate-600 dark:text-slate-400"
|
445 |
+
>
|
446 |
<span>{room.has_producer ? "🔴 Occupied" : "🟢 Available"}</span>
|
447 |
<span>👥 {room.participants?.total || 0} users</span>
|
448 |
<!-- Monitoring links -->
|
449 |
<div class="flex gap-1">
|
450 |
<a
|
451 |
+
href={`${settings.transportServerUrl.replace("/api", "")}/${workspaceId}/robotics/consumer?room=${room.id}`}
|
452 |
target="_blank"
|
453 |
rel="noopener noreferrer"
|
454 |
class="inline-flex items-center gap-1 rounded bg-blue-500/10 px-1.5 py-0.5 text-xs text-blue-600 hover:bg-blue-500/20 dark:bg-blue-400/10 dark:text-blue-400 dark:hover:bg-blue-400/20"
|
|
|
458 |
Consumer
|
459 |
</a>
|
460 |
<a
|
461 |
+
href={`${settings.transportServerUrl.replace("/api", "")}/${workspaceId}/robotics/producer?room=${room.id}`}
|
462 |
target="_blank"
|
463 |
rel="noopener noreferrer"
|
464 |
class="inline-flex items-center gap-1 rounded bg-green-500/10 px-1.5 py-0.5 text-xs text-green-600 hover:bg-green-500/20 dark:bg-green-400/10 dark:text-green-400 dark:hover:bg-green-400/20"
|
src/lib/components/3d/elements/robot/status/ConnectionFlowBoxUIkit.svelte
CHANGED
@@ -18,17 +18,31 @@
|
|
18 |
delay?: number;
|
19 |
}
|
20 |
|
21 |
-
let {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
22 |
|
23 |
const inputColor = "rgb(34, 197, 94)";
|
24 |
const outputColor = "rgb(59, 130, 246)";
|
25 |
|
26 |
-
const tweenedScale = Tween.of(
|
27 |
-
|
28 |
-
|
29 |
-
|
30 |
-
|
31 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
32 |
</script>
|
33 |
|
34 |
<Container
|
|
|
18 |
delay?: number;
|
19 |
}
|
20 |
|
21 |
+
let {
|
22 |
+
visible,
|
23 |
+
robot,
|
24 |
+
onInputBoxClick,
|
25 |
+
onRobotBoxClick,
|
26 |
+
onOutputBoxClick,
|
27 |
+
duration = 100,
|
28 |
+
delay = 0
|
29 |
+
}: Props = $props();
|
30 |
|
31 |
const inputColor = "rgb(34, 197, 94)";
|
32 |
const outputColor = "rgb(59, 130, 246)";
|
33 |
|
34 |
+
const tweenedScale = Tween.of(
|
35 |
+
() => {
|
36 |
+
return visible ? 1 : 0;
|
37 |
+
},
|
38 |
+
{ duration: duration, easing: cubicOut, delay: delay }
|
39 |
+
);
|
40 |
+
const tweenedOpacity = Tween.of(
|
41 |
+
() => {
|
42 |
+
return visible ? 1 : 0;
|
43 |
+
},
|
44 |
+
{ duration: duration, easing: cubicOut, delay: delay }
|
45 |
+
);
|
46 |
</script>
|
47 |
|
48 |
<Container
|
src/lib/components/3d/elements/robot/status/RobotBoxUIKit.svelte
CHANGED
@@ -1,12 +1,7 @@
|
|
1 |
<script lang="ts">
|
2 |
import type { Robot } from "$lib/elements/robot/Robot.svelte";
|
3 |
import { ICON } from "$lib/utils/icon";
|
4 |
-
import {
|
5 |
-
BaseStatusBox,
|
6 |
-
StatusHeader,
|
7 |
-
StatusContent,
|
8 |
-
StatusButton
|
9 |
-
} from "$lib/components/3d/ui";
|
10 |
|
11 |
interface Props {
|
12 |
robot: Robot;
|
@@ -18,34 +13,28 @@
|
|
18 |
const robotColor = "rgb(245, 158, 11)";
|
19 |
</script>
|
20 |
|
21 |
-
|
22 |
<BaseStatusBox
|
23 |
color={robotColor}
|
24 |
borderOpacity={0.6}
|
25 |
backgroundOpacity={0.2}
|
26 |
onclick={() => onRobotBoxClick(robot)}
|
27 |
>
|
28 |
-
|
29 |
-
|
30 |
-
|
31 |
-
|
32 |
-
|
33 |
-
|
34 |
-
|
35 |
|
36 |
-
|
37 |
-
|
38 |
-
title={robot.id}
|
39 |
-
subtitle="Active"
|
40 |
-
color={robotColor}
|
41 |
-
variant="primary"
|
42 |
-
/>
|
43 |
|
44 |
-
|
45 |
-
|
46 |
-
|
47 |
-
|
48 |
-
|
49 |
-
|
50 |
-
|
51 |
</BaseStatusBox>
|
|
|
1 |
<script lang="ts">
|
2 |
import type { Robot } from "$lib/elements/robot/Robot.svelte";
|
3 |
import { ICON } from "$lib/utils/icon";
|
4 |
+
import { BaseStatusBox, StatusHeader, StatusContent, StatusButton } from "$lib/components/3d/ui";
|
|
|
|
|
|
|
|
|
|
|
5 |
|
6 |
interface Props {
|
7 |
robot: Robot;
|
|
|
13 |
const robotColor = "rgb(245, 158, 11)";
|
14 |
</script>
|
15 |
|
|
|
16 |
<BaseStatusBox
|
17 |
color={robotColor}
|
18 |
borderOpacity={0.6}
|
19 |
backgroundOpacity={0.2}
|
20 |
onclick={() => onRobotBoxClick(robot)}
|
21 |
>
|
22 |
+
<!-- Robot Header -->
|
23 |
+
<StatusHeader
|
24 |
+
icon={ICON["icon-[ix--robotic-arm]"].svg}
|
25 |
+
text="ROBOT"
|
26 |
+
color={robotColor}
|
27 |
+
opacity={0.9}
|
28 |
+
/>
|
29 |
|
30 |
+
<!-- Robot Info -->
|
31 |
+
<StatusContent title={robot.id} subtitle="Active" color={robotColor} variant="primary" />
|
|
|
|
|
|
|
|
|
|
|
32 |
|
33 |
+
<!-- Status Button -->
|
34 |
+
<StatusButton
|
35 |
+
text="Active"
|
36 |
+
icon={ICON["icon-[iconamoon--lightning-1-duotone]"].svg}
|
37 |
+
color={robotColor}
|
38 |
+
textOpacity={0.8}
|
39 |
+
/>
|
40 |
</BaseStatusBox>
|
src/lib/components/3d/elements/robot/status/RobotStatusBillboard.svelte
CHANGED
@@ -27,34 +27,34 @@
|
|
27 |
</script>
|
28 |
|
29 |
<!-- {#if visible} -->
|
30 |
-
|
31 |
-
|
32 |
-
|
33 |
-
|
34 |
-
|
35 |
-
|
36 |
-
|
37 |
-
|
38 |
-
|
39 |
-
|
40 |
-
|
41 |
-
|
42 |
-
|
43 |
-
|
44 |
-
|
45 |
-
|
46 |
-
|
47 |
-
|
48 |
-
|
49 |
-
|
50 |
-
|
51 |
-
|
52 |
-
|
53 |
-
|
54 |
-
|
55 |
-
|
56 |
|
57 |
-
|
58 |
<HTML
|
59 |
transform
|
60 |
autoRender={true}
|
@@ -86,5 +86,5 @@
|
|
86 |
</div>
|
87 |
</HTML>
|
88 |
</Billboard> -->
|
89 |
-
|
90 |
<!-- {/if} -->
|
|
|
27 |
</script>
|
28 |
|
29 |
<!-- {#if visible} -->
|
30 |
+
<T.Group
|
31 |
+
position.z={0.35}
|
32 |
+
rotation={[Math.PI / 2, 0, 0]}
|
33 |
+
scale={[0.12, 0.12, 0.12]}
|
34 |
+
padding={10}
|
35 |
+
pointerEvents="listener"
|
36 |
+
>
|
37 |
+
<Billboard>
|
38 |
+
<Root name={`robot-status-billboard-${robot.id}`}>
|
39 |
+
<Container
|
40 |
+
width="100%"
|
41 |
+
height="100%"
|
42 |
+
alignItems="center"
|
43 |
+
justifyContent="center"
|
44 |
+
padding={20}
|
45 |
+
>
|
46 |
+
<ConnectionFlowBoxUIkit
|
47 |
+
{visible}
|
48 |
+
{robot}
|
49 |
+
{onInputBoxClick}
|
50 |
+
{onRobotBoxClick}
|
51 |
+
{onOutputBoxClick}
|
52 |
+
/>
|
53 |
+
</Container>
|
54 |
+
</Root>
|
55 |
+
</Billboard>
|
56 |
|
57 |
+
<!-- <Billboard>
|
58 |
<HTML
|
59 |
transform
|
60 |
autoRender={true}
|
|
|
86 |
</div>
|
87 |
</HTML>
|
88 |
</Billboard> -->
|
89 |
+
</T.Group>
|
90 |
<!-- {/if} -->
|
src/lib/components/3d/elements/video/modal/VideoInputConnectionModal.svelte
CHANGED
@@ -356,7 +356,7 @@
|
|
356 |
<div class="flex items-center justify-between">
|
357 |
<div>
|
358 |
<Card.Title
|
359 |
-
class="flex items-center gap-2 text-base text-purple-700 dark:text-purple-200
|
360 |
>
|
361 |
<span class="icon-[mdi--cloud-download] size-4"></span>
|
362 |
Remote Control
|
@@ -484,7 +484,9 @@
|
|
484 |
>
|
485 |
{room.id}
|
486 |
</p>
|
487 |
-
<div
|
|
|
|
|
488 |
<span
|
489 |
>{room.participants?.producer
|
490 |
? "📹 Has Output"
|
@@ -494,7 +496,7 @@
|
|
494 |
<!-- Monitoring links -->
|
495 |
<div class="flex gap-1">
|
496 |
<a
|
497 |
-
href={`${settings.transportServerUrl.replace(
|
498 |
target="_blank"
|
499 |
rel="noopener noreferrer"
|
500 |
class="inline-flex items-center gap-1 rounded bg-blue-500/10 px-1.5 py-0.5 text-xs text-blue-600 hover:bg-blue-500/20 dark:bg-blue-400/10 dark:text-blue-400 dark:hover:bg-blue-400/20"
|
@@ -504,7 +506,7 @@
|
|
504 |
Consumer
|
505 |
</a>
|
506 |
<a
|
507 |
-
href={`${settings.transportServerUrl.replace(
|
508 |
target="_blank"
|
509 |
rel="noopener noreferrer"
|
510 |
class="inline-flex items-center gap-1 rounded bg-green-500/10 px-1.5 py-0.5 text-xs text-green-600 hover:bg-green-500/20 dark:bg-green-400/10 dark:text-green-400 dark:hover:bg-green-400/20"
|
@@ -552,7 +554,6 @@
|
|
552 |
{/if}
|
553 |
</Card.Content>
|
554 |
</Card.Root>
|
555 |
-
|
556 |
</div>
|
557 |
</div>
|
558 |
</Dialog.Content>
|
|
|
356 |
<div class="flex items-center justify-between">
|
357 |
<div>
|
358 |
<Card.Title
|
359 |
+
class="flex items-center gap-2 pb-1 text-base text-purple-700 dark:text-purple-200"
|
360 |
>
|
361 |
<span class="icon-[mdi--cloud-download] size-4"></span>
|
362 |
Remote Control
|
|
|
484 |
>
|
485 |
{room.id}
|
486 |
</p>
|
487 |
+
<div
|
488 |
+
class="flex items-center gap-3 text-xs text-slate-600 dark:text-slate-400"
|
489 |
+
>
|
490 |
<span
|
491 |
>{room.participants?.producer
|
492 |
? "📹 Has Output"
|
|
|
496 |
<!-- Monitoring links -->
|
497 |
<div class="flex gap-1">
|
498 |
<a
|
499 |
+
href={`${settings.transportServerUrl.replace("/api", "")}/${workspaceId}/video/consumer?room=${room.id}`}
|
500 |
target="_blank"
|
501 |
rel="noopener noreferrer"
|
502 |
class="inline-flex items-center gap-1 rounded bg-blue-500/10 px-1.5 py-0.5 text-xs text-blue-600 hover:bg-blue-500/20 dark:bg-blue-400/10 dark:text-blue-400 dark:hover:bg-blue-400/20"
|
|
|
506 |
Consumer
|
507 |
</a>
|
508 |
<a
|
509 |
+
href={`${settings.transportServerUrl.replace("/api", "")}/${workspaceId}/video/producer?room=${room.id}`}
|
510 |
target="_blank"
|
511 |
rel="noopener noreferrer"
|
512 |
class="inline-flex items-center gap-1 rounded bg-green-500/10 px-1.5 py-0.5 text-xs text-green-600 hover:bg-green-500/20 dark:bg-green-400/10 dark:text-green-400 dark:hover:bg-green-400/20"
|
|
|
554 |
{/if}
|
555 |
</Card.Content>
|
556 |
</Card.Root>
|
|
|
557 |
</div>
|
558 |
</div>
|
559 |
</Dialog.Content>
|
src/lib/components/3d/elements/video/modal/VideoOutputConnectionModal.svelte
CHANGED
@@ -366,7 +366,7 @@
|
|
366 |
<div class="flex items-center justify-between">
|
367 |
<div>
|
368 |
<Card.Title
|
369 |
-
class="flex items-center gap-2 text-base text-purple-700 dark:text-purple-200
|
370 |
>
|
371 |
<span class="icon-[mdi--cloud-upload] size-4"></span>
|
372 |
Remote Control
|
@@ -494,7 +494,9 @@
|
|
494 |
>
|
495 |
{room.id}
|
496 |
</p>
|
497 |
-
<div
|
|
|
|
|
498 |
<span
|
499 |
>{room.participants?.producer
|
500 |
? "🔴 Has Output"
|
@@ -504,7 +506,7 @@
|
|
504 |
<!-- Monitoring links -->
|
505 |
<div class="flex gap-1">
|
506 |
<a
|
507 |
-
href={`${settings.transportServerUrl.replace(
|
508 |
target="_blank"
|
509 |
rel="noopener noreferrer"
|
510 |
class="inline-flex items-center gap-1 rounded bg-blue-500/10 px-1.5 py-0.5 text-xs text-blue-600 hover:bg-blue-500/20 dark:bg-blue-400/10 dark:text-blue-400 dark:hover:bg-blue-400/20"
|
@@ -514,7 +516,7 @@
|
|
514 |
Consumer
|
515 |
</a>
|
516 |
<a
|
517 |
-
href={`${settings.transportServerUrl.replace(
|
518 |
target="_blank"
|
519 |
rel="noopener noreferrer"
|
520 |
class="inline-flex items-center gap-1 rounded bg-green-500/10 px-1.5 py-0.5 text-xs text-green-600 hover:bg-green-500/20 dark:bg-green-400/10 dark:text-green-400 dark:hover:bg-green-400/20"
|
@@ -562,8 +564,6 @@
|
|
562 |
{/if}
|
563 |
</Card.Content>
|
564 |
</Card.Root>
|
565 |
-
|
566 |
-
|
567 |
</div>
|
568 |
</div>
|
569 |
</Dialog.Content>
|
|
|
366 |
<div class="flex items-center justify-between">
|
367 |
<div>
|
368 |
<Card.Title
|
369 |
+
class="flex items-center gap-2 pb-1 text-base text-purple-700 dark:text-purple-200"
|
370 |
>
|
371 |
<span class="icon-[mdi--cloud-upload] size-4"></span>
|
372 |
Remote Control
|
|
|
494 |
>
|
495 |
{room.id}
|
496 |
</p>
|
497 |
+
<div
|
498 |
+
class="flex items-center gap-3 text-xs text-slate-600 dark:text-slate-400"
|
499 |
+
>
|
500 |
<span
|
501 |
>{room.participants?.producer
|
502 |
? "🔴 Has Output"
|
|
|
506 |
<!-- Monitoring links -->
|
507 |
<div class="flex gap-1">
|
508 |
<a
|
509 |
+
href={`${settings.transportServerUrl.replace("/api", "")}/${workspaceId}/video/consumer?room=${room.id}`}
|
510 |
target="_blank"
|
511 |
rel="noopener noreferrer"
|
512 |
class="inline-flex items-center gap-1 rounded bg-blue-500/10 px-1.5 py-0.5 text-xs text-blue-600 hover:bg-blue-500/20 dark:bg-blue-400/10 dark:text-blue-400 dark:hover:bg-blue-400/20"
|
|
|
516 |
Consumer
|
517 |
</a>
|
518 |
<a
|
519 |
+
href={`${settings.transportServerUrl.replace("/api", "")}/${workspaceId}/video/producer?room=${room.id}`}
|
520 |
target="_blank"
|
521 |
rel="noopener noreferrer"
|
522 |
class="inline-flex items-center gap-1 rounded bg-green-500/10 px-1.5 py-0.5 text-xs text-green-600 hover:bg-green-500/20 dark:bg-green-400/10 dark:text-green-400 dark:hover:bg-green-400/20"
|
|
|
564 |
{/if}
|
565 |
</Card.Content>
|
566 |
</Card.Root>
|
|
|
|
|
567 |
</div>
|
568 |
</div>
|
569 |
</Dialog.Content>
|
src/lib/components/3d/elements/video/status/VideoBoxUIKit.svelte
CHANGED
@@ -1,11 +1,7 @@
|
|
1 |
<script lang="ts">
|
2 |
import { ICON } from "$lib/utils/icon";
|
3 |
import type { VideoInstance } from "$lib/elements/video/VideoManager.svelte";
|
4 |
-
import {
|
5 |
-
BaseStatusBox,
|
6 |
-
StatusHeader,
|
7 |
-
StatusContent
|
8 |
-
} from "$lib/components/3d/ui";
|
9 |
|
10 |
interface Props {
|
11 |
video: VideoInstance;
|
@@ -16,26 +12,20 @@
|
|
16 |
const videoColor = "rgb(217, 119, 6)";
|
17 |
</script>
|
18 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
19 |
|
20 |
-
|
21 |
-
|
22 |
-
|
23 |
-
|
24 |
-
|
25 |
-
|
26 |
-
|
27 |
-
|
28 |
-
icon={ICON["icon-[mdi--video]"].svg}
|
29 |
-
text="VIDEO"
|
30 |
-
color={videoColor}
|
31 |
-
opacity={0.9}
|
32 |
-
/>
|
33 |
-
|
34 |
-
<!-- Video Info -->
|
35 |
-
<StatusContent
|
36 |
-
title={video.name}
|
37 |
-
subtitle={video.id.slice(0, 8)}
|
38 |
-
color="rgb(253, 230, 138)"
|
39 |
-
variant="primary"
|
40 |
-
/>
|
41 |
-
</BaseStatusBox>
|
|
|
1 |
<script lang="ts">
|
2 |
import { ICON } from "$lib/utils/icon";
|
3 |
import type { VideoInstance } from "$lib/elements/video/VideoManager.svelte";
|
4 |
+
import { BaseStatusBox, StatusHeader, StatusContent } from "$lib/components/3d/ui";
|
|
|
|
|
|
|
|
|
5 |
|
6 |
interface Props {
|
7 |
video: VideoInstance;
|
|
|
12 |
const videoColor = "rgb(217, 119, 6)";
|
13 |
</script>
|
14 |
|
15 |
+
<BaseStatusBox color={videoColor} borderOpacity={0.6} backgroundOpacity={0.2} clickable={false}>
|
16 |
+
<!-- Video Header -->
|
17 |
+
<StatusHeader
|
18 |
+
icon={ICON["icon-[mdi--video]"].svg}
|
19 |
+
text="VIDEO"
|
20 |
+
color={videoColor}
|
21 |
+
opacity={0.9}
|
22 |
+
/>
|
23 |
|
24 |
+
<!-- Video Info -->
|
25 |
+
<StatusContent
|
26 |
+
title={video.name}
|
27 |
+
subtitle={video.id.slice(0, 8)}
|
28 |
+
color="rgb(253, 230, 138)"
|
29 |
+
variant="primary"
|
30 |
+
/>
|
31 |
+
</BaseStatusBox>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
src/lib/components/3d/elements/video/status/VideoConnectionFlowBoxUIKit.svelte
CHANGED
@@ -17,17 +17,30 @@
|
|
17 |
delay?: number;
|
18 |
}
|
19 |
|
20 |
-
let {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
21 |
|
22 |
const inputColor = "rgb(34, 197, 94)";
|
23 |
const outputColor = "rgb(59, 130, 246)";
|
24 |
|
25 |
-
const tweenedScale = Tween.of(
|
26 |
-
|
27 |
-
|
28 |
-
|
29 |
-
|
30 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
31 |
</script>
|
32 |
|
33 |
<Container
|
|
|
17 |
delay?: number;
|
18 |
}
|
19 |
|
20 |
+
let {
|
21 |
+
visible,
|
22 |
+
video,
|
23 |
+
onInputBoxClick,
|
24 |
+
onOutputBoxClick,
|
25 |
+
duration = 100,
|
26 |
+
delay = 0
|
27 |
+
}: Props = $props();
|
28 |
|
29 |
const inputColor = "rgb(34, 197, 94)";
|
30 |
const outputColor = "rgb(59, 130, 246)";
|
31 |
|
32 |
+
const tweenedScale = Tween.of(
|
33 |
+
() => {
|
34 |
+
return visible ? 1 : 0;
|
35 |
+
},
|
36 |
+
{ duration: duration, easing: cubicOut, delay: delay }
|
37 |
+
);
|
38 |
+
const tweenedOpacity = Tween.of(
|
39 |
+
() => {
|
40 |
+
return visible ? 1 : 0;
|
41 |
+
},
|
42 |
+
{ duration: duration, easing: cubicOut, delay: delay }
|
43 |
+
);
|
44 |
</script>
|
45 |
|
46 |
<Container
|
src/lib/components/3d/elements/video/status/VideoStatusBillboard.svelte
CHANGED
@@ -19,32 +19,32 @@
|
|
19 |
</script>
|
20 |
|
21 |
<!-- {#if visible} -->
|
22 |
-
|
23 |
-
|
24 |
-
|
25 |
-
|
26 |
-
|
27 |
-
|
28 |
-
|
29 |
-
|
30 |
-
|
31 |
-
|
32 |
-
|
33 |
-
|
34 |
-
|
35 |
-
|
36 |
-
|
37 |
-
|
38 |
-
|
39 |
-
|
40 |
-
|
41 |
-
|
42 |
-
|
43 |
-
|
44 |
-
|
45 |
-
|
46 |
|
47 |
-
|
48 |
<HTML
|
49 |
transform
|
50 |
autoRender={true}
|
@@ -72,5 +72,5 @@
|
|
72 |
</div>
|
73 |
</HTML>
|
74 |
</Billboard> -->
|
75 |
-
|
76 |
<!-- {/if} -->
|
|
|
19 |
</script>
|
20 |
|
21 |
<!-- {#if visible} -->
|
22 |
+
<T.Group
|
23 |
+
onpointerdown={(e) => e.stopPropagation()}
|
24 |
+
onpointerup={(e) => e.stopPropagation()}
|
25 |
+
onpointermove={(e) => e.stopPropagation()}
|
26 |
+
onclick={(e) => e.stopPropagation()}
|
27 |
+
position.z={0.22}
|
28 |
+
padding={10}
|
29 |
+
rotation={[Math.PI / 2, 0, 0]}
|
30 |
+
scale={[0.12, 0.12, 0.12]}
|
31 |
+
pointerEvents="listener"
|
32 |
+
>
|
33 |
+
<Billboard>
|
34 |
+
<Root name={`video-status-billboard-${video.id}`}>
|
35 |
+
<Container
|
36 |
+
width="100%"
|
37 |
+
height="100%"
|
38 |
+
alignItems="center"
|
39 |
+
justifyContent="center"
|
40 |
+
padding={20}
|
41 |
+
>
|
42 |
+
<VideoConnectionFlowBoxUIKit {visible} {video} {onInputBoxClick} {onOutputBoxClick} />
|
43 |
+
</Container>
|
44 |
+
</Root>
|
45 |
+
</Billboard>
|
46 |
|
47 |
+
<!-- <Billboard>
|
48 |
<HTML
|
49 |
transform
|
50 |
autoRender={true}
|
|
|
72 |
</div>
|
73 |
</HTML>
|
74 |
</Billboard> -->
|
75 |
+
</T.Group>
|
76 |
<!-- {/if} -->
|
src/lib/components/3d/ui/StatusArrow.svelte
CHANGED
@@ -6,7 +6,7 @@
|
|
6 |
color?: string;
|
7 |
opacity?: number;
|
8 |
size?: number;
|
9 |
-
direction?:
|
10 |
minWidth?: number;
|
11 |
minHeight?: number;
|
12 |
}
|
@@ -15,7 +15,7 @@
|
|
15 |
color = "rgb(139, 69, 219)",
|
16 |
opacity = 1,
|
17 |
size = 12,
|
18 |
-
direction =
|
19 |
minWidth = 20,
|
20 |
minHeight = 12
|
21 |
}: Props = $props();
|
@@ -29,7 +29,7 @@
|
|
29 |
{minWidth}
|
30 |
{minHeight}
|
31 |
>
|
32 |
-
{#if direction ===
|
33 |
<SVG
|
34 |
width={size}
|
35 |
height={size}
|
@@ -37,29 +37,11 @@
|
|
37 |
{opacity}
|
38 |
src={ICON[`icon-[formkit--arrowright]`].svg}
|
39 |
/>
|
40 |
-
{:else if direction ===
|
41 |
-
<SVG
|
42 |
-
|
43 |
-
|
44 |
-
|
45 |
-
|
46 |
-
src={ICON[`icon-[formkit--arrowdown]`].svg}
|
47 |
-
/>
|
48 |
-
{:else if direction === 'left'}
|
49 |
-
<SVG
|
50 |
-
width={size}
|
51 |
-
height={size}
|
52 |
-
{color}
|
53 |
-
{opacity}
|
54 |
-
src={ICON[`icon-[formkit--arrowleft]`].svg}
|
55 |
-
/>
|
56 |
-
{:else if direction === 'up'}
|
57 |
-
<SVG
|
58 |
-
width={size}
|
59 |
-
height={size}
|
60 |
-
{color}
|
61 |
-
{opacity}
|
62 |
-
src={ICON[`icon-[formkit--arrowup]`].svg}
|
63 |
-
/>
|
64 |
{/if}
|
65 |
-
</Container>
|
|
|
6 |
color?: string;
|
7 |
opacity?: number;
|
8 |
size?: number;
|
9 |
+
direction?: "right" | "down" | "left" | "up";
|
10 |
minWidth?: number;
|
11 |
minHeight?: number;
|
12 |
}
|
|
|
15 |
color = "rgb(139, 69, 219)",
|
16 |
opacity = 1,
|
17 |
size = 12,
|
18 |
+
direction = "right",
|
19 |
minWidth = 20,
|
20 |
minHeight = 12
|
21 |
}: Props = $props();
|
|
|
29 |
{minWidth}
|
30 |
{minHeight}
|
31 |
>
|
32 |
+
{#if direction === "right"}
|
33 |
<SVG
|
34 |
width={size}
|
35 |
height={size}
|
|
|
37 |
{opacity}
|
38 |
src={ICON[`icon-[formkit--arrowright]`].svg}
|
39 |
/>
|
40 |
+
{:else if direction === "down"}
|
41 |
+
<SVG width={size} height={size} {color} {opacity} src={ICON[`icon-[formkit--arrowdown]`].svg} />
|
42 |
+
{:else if direction === "left"}
|
43 |
+
<SVG width={size} height={size} {color} {opacity} src={ICON[`icon-[formkit--arrowleft]`].svg} />
|
44 |
+
{:else if direction === "up"}
|
45 |
+
<SVG width={size} height={size} {color} {opacity} src={ICON[`icon-[formkit--arrowup]`].svg} />
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
46 |
{/if}
|
47 |
+
</Container>
|
src/lib/components/3d/ui/StatusButton.svelte
CHANGED
@@ -47,15 +47,9 @@
|
|
47 |
{onclick}
|
48 |
>
|
49 |
{#if icon}
|
50 |
-
<SVG
|
51 |
-
width={iconSize}
|
52 |
-
height={iconSize}
|
53 |
-
{color}
|
54 |
-
opacity={textOpacity}
|
55 |
-
src={icon}
|
56 |
-
/>
|
57 |
{/if}
|
58 |
-
|
59 |
<Text
|
60 |
{text}
|
61 |
fontSize={textSize}
|
@@ -64,4 +58,4 @@
|
|
64 |
opacity={textOpacity}
|
65 |
textAlign="center"
|
66 |
/>
|
67 |
-
</Container>
|
|
|
47 |
{onclick}
|
48 |
>
|
49 |
{#if icon}
|
50 |
+
<SVG width={iconSize} height={iconSize} {color} opacity={textOpacity} src={icon} />
|
|
|
|
|
|
|
|
|
|
|
|
|
51 |
{/if}
|
52 |
+
|
53 |
<Text
|
54 |
{text}
|
55 |
fontSize={textSize}
|
|
|
58 |
opacity={textOpacity}
|
59 |
textAlign="center"
|
60 |
/>
|
61 |
+
</Container>
|
src/lib/components/3d/ui/StatusContent.svelte
CHANGED
@@ -6,10 +6,10 @@
|
|
6 |
subtitle?: string;
|
7 |
description?: string;
|
8 |
color?: string;
|
9 |
-
variant?:
|
10 |
-
size?:
|
11 |
-
align?:
|
12 |
-
children?: import(
|
13 |
}
|
14 |
|
15 |
let {
|
@@ -17,9 +17,9 @@
|
|
17 |
subtitle,
|
18 |
description,
|
19 |
color = "rgb(221, 214, 254)",
|
20 |
-
variant =
|
21 |
-
size =
|
22 |
-
align =
|
23 |
children
|
24 |
}: Props = $props();
|
25 |
|
@@ -40,19 +40,15 @@
|
|
40 |
const config = sizeConfigs[size];
|
41 |
const opacities = opacityLevels[variant];
|
42 |
|
43 |
-
const flexAlign = align ===
|
44 |
</script>
|
45 |
|
46 |
-
<Container
|
47 |
-
padding={config.padding}
|
48 |
-
marginBottom={4}
|
49 |
-
width="100%"
|
50 |
-
>
|
51 |
{#if children}
|
52 |
{@render children()}
|
53 |
{:else}
|
54 |
-
<Container
|
55 |
-
flexDirection="column"
|
56 |
alignItems={flexAlign}
|
57 |
justifyContent="center"
|
58 |
gap={config.gap}
|
@@ -68,7 +64,7 @@
|
|
68 |
width="100%"
|
69 |
/>
|
70 |
{/if}
|
71 |
-
|
72 |
{#if subtitle}
|
73 |
<Text
|
74 |
text={subtitle}
|
@@ -80,7 +76,7 @@
|
|
80 |
width="100%"
|
81 |
/>
|
82 |
{/if}
|
83 |
-
|
84 |
{#if description}
|
85 |
<Text
|
86 |
text={description}
|
@@ -94,4 +90,4 @@
|
|
94 |
{/if}
|
95 |
</Container>
|
96 |
{/if}
|
97 |
-
</Container>
|
|
|
6 |
subtitle?: string;
|
7 |
description?: string;
|
8 |
color?: string;
|
9 |
+
variant?: "primary" | "secondary" | "tertiary";
|
10 |
+
size?: "sm" | "md" | "lg";
|
11 |
+
align?: "left" | "center" | "right";
|
12 |
+
children?: import("svelte").Snippet;
|
13 |
}
|
14 |
|
15 |
let {
|
|
|
17 |
subtitle,
|
18 |
description,
|
19 |
color = "rgb(221, 214, 254)",
|
20 |
+
variant = "primary",
|
21 |
+
size = "md",
|
22 |
+
align = "center",
|
23 |
children
|
24 |
}: Props = $props();
|
25 |
|
|
|
40 |
const config = sizeConfigs[size];
|
41 |
const opacities = opacityLevels[variant];
|
42 |
|
43 |
+
const flexAlign = align === "left" ? "flex-start" : align === "right" ? "flex-end" : "center";
|
44 |
</script>
|
45 |
|
46 |
+
<Container padding={config.padding} marginBottom={4} width="100%">
|
|
|
|
|
|
|
|
|
47 |
{#if children}
|
48 |
{@render children()}
|
49 |
{:else}
|
50 |
+
<Container
|
51 |
+
flexDirection="column"
|
52 |
alignItems={flexAlign}
|
53 |
justifyContent="center"
|
54 |
gap={config.gap}
|
|
|
64 |
width="100%"
|
65 |
/>
|
66 |
{/if}
|
67 |
+
|
68 |
{#if subtitle}
|
69 |
<Text
|
70 |
text={subtitle}
|
|
|
76 |
width="100%"
|
77 |
/>
|
78 |
{/if}
|
79 |
+
|
80 |
{#if description}
|
81 |
<Text
|
82 |
text={description}
|
|
|
90 |
{/if}
|
91 |
</Container>
|
92 |
{/if}
|
93 |
+
</Container>
|
src/lib/components/3d/ui/icons.ts
CHANGED
@@ -1,7 +1,8 @@
|
|
1 |
// Common generic SVG icons encoded as base64 data URLs
|
2 |
export const icons = {
|
3 |
plus: "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0Ij48cGF0aCBmaWxsPSIjMDAwIiBkPSJNMTkgMTNoLTZ2NmgtMnYtNkg1di0yaDZWNWgydjZoNnoiLz48L3N2Zz4=",
|
4 |
-
settings:
|
|
|
5 |
} as const;
|
6 |
-
|
7 |
-
export type IconName = keyof typeof icons;
|
|
|
1 |
// Common generic SVG icons encoded as base64 data URLs
|
2 |
export const icons = {
|
3 |
plus: "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0Ij48cGF0aCBmaWxsPSIjMDAwIiBkPSJNMTkgMTNoLTZ2NmgtMnYtNkg1di0yaDZWNWgydjZoNnoiLz48L3N2Zz4=",
|
4 |
+
settings:
|
5 |
+
"data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0Ij48cGF0aCBmaWxsPSIjMDAwIiBkPSJtOS4yNSAyMmwtLjQtMy4ycS0uMzI1LS4xMjUtLjYxMi0uM3QtLjU2My0uMzc1TDQuNyAxOS4zNzVsLTIuNzUtNC43NWwyLjU3NS0xLjk1UTQuNSAxMi41IDQuNSAxMi4zMzh2LS42NzVxMC0uMTYzLjAyNS0uMzM4TDEuOTUgOS4zNzVsMi43NS00Ljc1bDIuOTc5IDEuMjVxLjI3NS0uMi41NzUtLjM3NXQuNi0uM2wuNC0zLjJoNS41bC40IDMuMnEuMzI1LjEyNS42MTMuM3QuNTYyLjM3NWwyLjk3NS0xLjI1bDIuNzUgNC43NWwtMi41NzUgMS45NXEuMDI1LjE3NS4wMjUuMzM4di42NzRxMCAuMTYzLS4wNS4zMzhsMi41NzUgMS45NWwtMi43NSA0Ljc1bC0yLjk1LTEuMjVxLS4yNzUuMi0uNTc1LjM3NXQtLjYuM2wtLjQgMy4yem0yLjgtNi41cTEuNDUgMCAyLjQ3NS0xLjAyNVQxNS41NSAxMnQtMS4wMjUtMi40NzVUMTIuMDUgOC41cS0xLjQ3NSAwLTIuNDg4IDEuMDI1VDguNTUgMTJ0MS4wMTMgMi40NzVUMTIuMDUgMTUuNSIvPjwvc3ZnPg=="
|
6 |
} as const;
|
7 |
+
|
8 |
+
export type IconName = keyof typeof icons;
|
src/lib/components/3d/ui/index.ts
CHANGED
@@ -1,6 +1,6 @@
|
|
1 |
-
export { default as BaseStatusBox } from
|
2 |
-
export { default as StatusHeader } from
|
3 |
-
export { default as StatusContent } from
|
4 |
-
export { default as StatusIndicator } from
|
5 |
-
export { default as StatusButton } from
|
6 |
-
export { default as StatusArrow } from
|
|
|
1 |
+
export { default as BaseStatusBox } from "./BaseStatusBox.svelte";
|
2 |
+
export { default as StatusHeader } from "./StatusHeader.svelte";
|
3 |
+
export { default as StatusContent } from "./StatusContent.svelte";
|
4 |
+
export { default as StatusIndicator } from "./StatusIndicator.svelte";
|
5 |
+
export { default as StatusButton } from "./StatusButton.svelte";
|
6 |
+
export { default as StatusArrow } from "./StatusArrow.svelte";
|
src/lib/components/interface/overlay/AddAIButton.svelte
CHANGED
@@ -13,10 +13,10 @@
|
|
13 |
let { open = $bindable(), workspaceId }: Props = $props();
|
14 |
|
15 |
let isConfigModalOpen = $state(false);
|
16 |
-
let selectedModelType = $state<ModelType>(
|
17 |
|
18 |
// Get available model types
|
19 |
-
const availableModels = Object.values(MODEL_TYPES).filter(model => model.enabled);
|
20 |
|
21 |
function openConfigModal(modelType: ModelType) {
|
22 |
selectedModelType = modelType;
|
@@ -25,7 +25,7 @@
|
|
25 |
}
|
26 |
|
27 |
function quickAddACT() {
|
28 |
-
openConfigModal(
|
29 |
}
|
30 |
|
31 |
function formatModelType(modelType: string): string {
|
@@ -87,10 +87,12 @@
|
|
87 |
{/snippet}
|
88 |
</DropdownMenu.Trigger>
|
89 |
|
90 |
-
<DropdownMenu.Content
|
|
|
|
|
91 |
{#each availableModels as model}
|
92 |
<DropdownMenu.Item
|
93 |
-
class="flex items-center gap-3 p-3
|
94 |
onclick={() => openConfigModal(model.id)}
|
95 |
>
|
96 |
<span class="{model.icon} size-5 text-purple-500 dark:text-purple-400"></span>
|
@@ -108,8 +110,8 @@
|
|
108 |
</DropdownMenu.Root>
|
109 |
|
110 |
<!-- Configuration Modal -->
|
111 |
-
<AIModelConfigurationModal
|
112 |
-
bind:open={isConfigModalOpen}
|
113 |
{workspaceId}
|
114 |
initialModelType={selectedModelType}
|
115 |
/>
|
|
|
13 |
let { open = $bindable(), workspaceId }: Props = $props();
|
14 |
|
15 |
let isConfigModalOpen = $state(false);
|
16 |
+
let selectedModelType = $state<ModelType>("act");
|
17 |
|
18 |
// Get available model types
|
19 |
+
const availableModels = Object.values(MODEL_TYPES).filter((model) => model.enabled);
|
20 |
|
21 |
function openConfigModal(modelType: ModelType) {
|
22 |
selectedModelType = modelType;
|
|
|
25 |
}
|
26 |
|
27 |
function quickAddACT() {
|
28 |
+
openConfigModal("act");
|
29 |
}
|
30 |
|
31 |
function formatModelType(modelType: string): string {
|
|
|
87 |
{/snippet}
|
88 |
</DropdownMenu.Trigger>
|
89 |
|
90 |
+
<DropdownMenu.Content
|
91 |
+
class="w-64 border-slate-300 bg-slate-100 dark:border-slate-600 dark:bg-slate-900"
|
92 |
+
>
|
93 |
{#each availableModels as model}
|
94 |
<DropdownMenu.Item
|
95 |
+
class="flex cursor-pointer items-center gap-3 p-3 hover:bg-purple-100 dark:hover:bg-purple-900/30"
|
96 |
onclick={() => openConfigModal(model.id)}
|
97 |
>
|
98 |
<span class="{model.icon} size-5 text-purple-500 dark:text-purple-400"></span>
|
|
|
110 |
</DropdownMenu.Root>
|
111 |
|
112 |
<!-- Configuration Modal -->
|
113 |
+
<AIModelConfigurationModal
|
114 |
+
bind:open={isConfigModalOpen}
|
115 |
{workspaceId}
|
116 |
initialModelType={selectedModelType}
|
117 |
/>
|
src/lib/components/interface/overlay/AddRobotButton.svelte
CHANGED
@@ -45,7 +45,7 @@
|
|
45 |
}
|
46 |
|
47 |
async function quickAddDefault() {
|
48 |
-
const defaultRobotType = robotTypes.find(type => robotUrdfConfigMap[type].isDefault);
|
49 |
if (defaultRobotType) {
|
50 |
await addRobot(defaultRobotType);
|
51 |
} else {
|
@@ -53,7 +53,6 @@
|
|
53 |
await addRobot(robotTypes[0]);
|
54 |
}
|
55 |
}
|
56 |
-
|
57 |
</script>
|
58 |
|
59 |
<!-- Main Add Button (Default Robot) -->
|
@@ -127,7 +126,9 @@
|
|
127 |
<span class="font-medium text-white transition-colors duration-200"
|
128 |
>{urdfConfig.displayName || robotType.replace(/-/g, " ").toUpperCase()}</span
|
129 |
>
|
130 |
-
<span
|
|
|
|
|
131 |
{urdfConfig.description || "Robot"}
|
132 |
</span>
|
133 |
</div>
|
|
|
45 |
}
|
46 |
|
47 |
async function quickAddDefault() {
|
48 |
+
const defaultRobotType = robotTypes.find((type) => robotUrdfConfigMap[type].isDefault);
|
49 |
if (defaultRobotType) {
|
50 |
await addRobot(defaultRobotType);
|
51 |
} else {
|
|
|
53 |
await addRobot(robotTypes[0]);
|
54 |
}
|
55 |
}
|
|
|
56 |
</script>
|
57 |
|
58 |
<!-- Main Add Button (Default Robot) -->
|
|
|
126 |
<span class="font-medium text-white transition-colors duration-200"
|
127 |
>{urdfConfig.displayName || robotType.replace(/-/g, " ").toUpperCase()}</span
|
128 |
>
|
129 |
+
<span
|
130 |
+
class="text-xs text-emerald-100 transition-colors duration-200 dark:text-emerald-200"
|
131 |
+
>
|
132 |
{urdfConfig.description || "Robot"}
|
133 |
</span>
|
134 |
</div>
|
src/lib/components/interface/overlay/AddSensorButton.svelte
CHANGED
@@ -21,34 +21,34 @@
|
|
21 |
}
|
22 |
|
23 |
const sensorConfigs: SensorConfig[] = [
|
24 |
-
{
|
25 |
-
id:
|
26 |
-
label:
|
27 |
-
description:
|
28 |
-
icon:
|
29 |
enabled: true,
|
30 |
isDefault: true
|
31 |
},
|
32 |
-
{
|
33 |
-
id:
|
34 |
-
label:
|
35 |
-
description:
|
36 |
-
icon:
|
37 |
-
enabled: false
|
38 |
},
|
39 |
-
{
|
40 |
-
id:
|
41 |
-
label:
|
42 |
-
description:
|
43 |
-
icon:
|
44 |
-
enabled: false
|
45 |
},
|
46 |
-
{
|
47 |
-
id:
|
48 |
-
label:
|
49 |
-
description:
|
50 |
-
icon:
|
51 |
-
enabled: false
|
52 |
}
|
53 |
];
|
54 |
|
@@ -58,7 +58,7 @@
|
|
58 |
if (!sensorType) return;
|
59 |
|
60 |
const sensorId = `${sensorType}_${Date.now()}`;
|
61 |
-
|
62 |
if (sensorType === "camera") {
|
63 |
// Create video camera
|
64 |
const video = videoManager.createVideo(sensorId);
|
@@ -67,7 +67,7 @@
|
|
67 |
});
|
68 |
} else {
|
69 |
// Placeholder for other sensor types
|
70 |
-
const config = sensorConfigs.find(c => c.id === sensorType);
|
71 |
toast.success("Sensor Added", {
|
72 |
description: `${config?.label || sensorType} sensor ${sensorId.slice(0, 12)}... created successfully.`
|
73 |
});
|
@@ -105,7 +105,7 @@
|
|
105 |
|
106 |
<!-- Dropdown Menu Button -->
|
107 |
<DropdownMenu.Root bind:open>
|
108 |
-
<DropdownMenu.Trigger
|
109 |
{#snippet child({ props })}
|
110 |
<Button
|
111 |
{...props}
|
@@ -155,8 +155,7 @@
|
|
155 |
]}
|
156 |
></span>
|
157 |
<div class="flex flex-1 flex-col">
|
158 |
-
<span class="font-medium text-white transition-colors duration-200"
|
159 |
-
>{sensor.label}</span
|
160 |
>
|
161 |
<span class="text-xs text-blue-100 transition-colors duration-200 dark:text-blue-200">
|
162 |
{sensor.description}
|
@@ -174,4 +173,4 @@
|
|
174 |
{/each}
|
175 |
</DropdownMenu.Group>
|
176 |
</DropdownMenu.Content>
|
177 |
-
</DropdownMenu.Root>
|
|
|
21 |
}
|
22 |
|
23 |
const sensorConfigs: SensorConfig[] = [
|
24 |
+
{
|
25 |
+
id: "camera",
|
26 |
+
label: "Camera",
|
27 |
+
description: "Video Camera Sensor",
|
28 |
+
icon: "icon-[mdi--camera]",
|
29 |
enabled: true,
|
30 |
isDefault: true
|
31 |
},
|
32 |
+
{
|
33 |
+
id: "depth-camera",
|
34 |
+
label: "Depth Camera",
|
35 |
+
description: "Depth Camera Sensor",
|
36 |
+
icon: "icon-[mdi--camera]",
|
37 |
+
enabled: false
|
38 |
},
|
39 |
+
{
|
40 |
+
id: "lidar",
|
41 |
+
label: "Lidar",
|
42 |
+
description: "Distance Sensor",
|
43 |
+
icon: "icon-[mdi--radar]",
|
44 |
+
enabled: false
|
45 |
},
|
46 |
+
{
|
47 |
+
id: "imu",
|
48 |
+
label: "IMU",
|
49 |
+
description: "Motion Sensor",
|
50 |
+
icon: "icon-[mdi--radar]",
|
51 |
+
enabled: false
|
52 |
}
|
53 |
];
|
54 |
|
|
|
58 |
if (!sensorType) return;
|
59 |
|
60 |
const sensorId = `${sensorType}_${Date.now()}`;
|
61 |
+
|
62 |
if (sensorType === "camera") {
|
63 |
// Create video camera
|
64 |
const video = videoManager.createVideo(sensorId);
|
|
|
67 |
});
|
68 |
} else {
|
69 |
// Placeholder for other sensor types
|
70 |
+
const config = sensorConfigs.find((c) => c.id === sensorType);
|
71 |
toast.success("Sensor Added", {
|
72 |
description: `${config?.label || sensorType} sensor ${sensorId.slice(0, 12)}... created successfully.`
|
73 |
});
|
|
|
105 |
|
106 |
<!-- Dropdown Menu Button -->
|
107 |
<DropdownMenu.Root bind:open>
|
108 |
+
<DropdownMenu.Trigger>
|
109 |
{#snippet child({ props })}
|
110 |
<Button
|
111 |
{...props}
|
|
|
155 |
]}
|
156 |
></span>
|
157 |
<div class="flex flex-1 flex-col">
|
158 |
+
<span class="font-medium text-white transition-colors duration-200">{sensor.label}</span
|
|
|
159 |
>
|
160 |
<span class="text-xs text-blue-100 transition-colors duration-200 dark:text-blue-200">
|
161 |
{sensor.description}
|
|
|
173 |
{/each}
|
174 |
</DropdownMenu.Group>
|
175 |
</DropdownMenu.Content>
|
176 |
+
</DropdownMenu.Root>
|
src/lib/components/interface/overlay/Overlay.svelte
CHANGED
@@ -27,32 +27,34 @@
|
|
27 |
|
28 |
<div class="select-none">
|
29 |
<!-- Responsive Button Bar Container -->
|
30 |
-
<div
|
|
|
|
|
31 |
<!-- Left Group: Logo + Add Buttons -->
|
32 |
-
<div class="flex items-center gap-1
|
33 |
<!-- Logo/Favicon -->
|
34 |
<div class="flex items-center justify-center">
|
35 |
<img
|
36 |
src="/favicon_1024.png"
|
37 |
alt="Logo"
|
38 |
draggable="false"
|
39 |
-
class="h-8 w-8 invert-0 filter
|
40 |
/>
|
41 |
</div>
|
42 |
-
|
43 |
<!-- Add Robot Button Group -->
|
44 |
<div class="flex items-center justify-center overflow-hidden rounded-lg">
|
45 |
<AddRobotButton bind:open={addRobotDropdownMenuOpen} />
|
46 |
</div>
|
47 |
|
48 |
<!-- Add Sensor Button Group - Hidden on very small screens -->
|
49 |
-
<div class="hidden
|
50 |
<AddSensorButton bind:open={addSensorDropdownMenuOpen} />
|
51 |
</div>
|
52 |
|
53 |
<!-- Add AI Button Group - Hidden on small screens -->
|
54 |
-
<div class="hidden
|
55 |
-
<AddAIButton bind:open={addAIDropdownMenuOpen}
|
56 |
</div>
|
57 |
</div>
|
58 |
|
|
|
27 |
|
28 |
<div class="select-none">
|
29 |
<!-- Responsive Button Bar Container -->
|
30 |
+
<div
|
31 |
+
class="fixed top-2 right-2 left-2 z-50 flex flex-wrap items-center justify-between gap-1 select-none md:top-4 md:right-4 md:left-4 md:gap-2"
|
32 |
+
>
|
33 |
<!-- Left Group: Logo + Add Buttons -->
|
34 |
+
<div class="flex flex-wrap items-center gap-1 md:gap-2">
|
35 |
<!-- Logo/Favicon -->
|
36 |
<div class="flex items-center justify-center">
|
37 |
<img
|
38 |
src="/favicon_1024.png"
|
39 |
alt="Logo"
|
40 |
draggable="false"
|
41 |
+
class="h-8 w-8 invert-0 filter md:h-10 md:w-10 dark:invert"
|
42 |
/>
|
43 |
</div>
|
44 |
+
|
45 |
<!-- Add Robot Button Group -->
|
46 |
<div class="flex items-center justify-center overflow-hidden rounded-lg">
|
47 |
<AddRobotButton bind:open={addRobotDropdownMenuOpen} />
|
48 |
</div>
|
49 |
|
50 |
<!-- Add Sensor Button Group - Hidden on very small screens -->
|
51 |
+
<div class="hidden items-center justify-center overflow-hidden rounded-lg min-[480px]:flex">
|
52 |
<AddSensorButton bind:open={addSensorDropdownMenuOpen} />
|
53 |
</div>
|
54 |
|
55 |
<!-- Add AI Button Group - Hidden on small screens -->
|
56 |
+
<div class="hidden items-center justify-center overflow-hidden rounded-lg min-[560px]:flex">
|
57 |
+
<AddAIButton bind:open={addAIDropdownMenuOpen} {workspaceId} />
|
58 |
</div>
|
59 |
</div>
|
60 |
|
src/lib/components/interface/overlay/SettingsSheet.svelte
CHANGED
@@ -130,7 +130,8 @@
|
|
130 |
>Inference Server URL</Label
|
131 |
>
|
132 |
<p class="text-xs text-slate-600 dark:text-slate-400">
|
133 |
-
URL for the remote AI inference server to run policies using remote compute
|
|
|
134 |
</p>
|
135 |
</div>
|
136 |
<div class="flex gap-2">
|
|
|
130 |
>Inference Server URL</Label
|
131 |
>
|
132 |
<p class="text-xs text-slate-600 dark:text-slate-400">
|
133 |
+
URL for the remote AI inference server to run policies using remote compute
|
134 |
+
resources
|
135 |
</p>
|
136 |
</div>
|
137 |
<div class="flex gap-2">
|
src/lib/configs/robotUrdfConfig.ts
CHANGED
@@ -13,7 +13,7 @@ export const robotUrdfConfigMap: { [key: string]: RobotUrdfConfig } = {
|
|
13 |
Elbow: 3,
|
14 |
Wrist_Pitch: 4,
|
15 |
Wrist_Roll: 5,
|
16 |
-
Jaw: 6
|
17 |
// camera_mount: 7
|
18 |
},
|
19 |
// Rest position - robot in neutral/calibration pose (all joints at 0 degrees)
|
@@ -23,7 +23,7 @@ export const robotUrdfConfigMap: { [key: string]: RobotUrdfConfig } = {
|
|
23 |
Elbow: 0,
|
24 |
Wrist_Pitch: 0,
|
25 |
Wrist_Roll: 0,
|
26 |
-
Jaw: 0
|
27 |
// camera_mount: 0
|
28 |
},
|
29 |
compoundMovements: [
|
|
|
13 |
Elbow: 3,
|
14 |
Wrist_Pitch: 4,
|
15 |
Wrist_Roll: 5,
|
16 |
+
Jaw: 6
|
17 |
// camera_mount: 7
|
18 |
},
|
19 |
// Rest position - robot in neutral/calibration pose (all joints at 0 degrees)
|
|
|
23 |
Elbow: 0,
|
24 |
Wrist_Pitch: 0,
|
25 |
Wrist_Roll: 0,
|
26 |
+
Jaw: 0
|
27 |
// camera_mount: 0
|
28 |
},
|
29 |
compoundMovements: [
|
src/lib/elements/compute/RemoteCompute.svelte.ts
CHANGED
@@ -1,147 +1,153 @@
|
|
1 |
-
import type { Positionable, Position3D } from
|
2 |
-
import type { AISessionConfig, AISessionResponse, ModelType } from
|
3 |
|
4 |
-
export type ComputeStatus =
|
5 |
|
6 |
export class RemoteCompute implements Positionable {
|
7 |
-
|
8 |
-
|
9 |
-
|
10 |
-
|
11 |
-
|
12 |
-
|
13 |
-
|
14 |
-
|
15 |
-
|
16 |
-
|
17 |
-
|
18 |
-
|
19 |
-
|
20 |
-
|
21 |
-
|
22 |
-
|
23 |
-
|
24 |
-
|
25 |
-
|
26 |
-
|
27 |
-
|
28 |
-
|
29 |
-
|
30 |
-
|
31 |
-
|
32 |
-
|
33 |
-
|
34 |
-
|
35 |
-
|
36 |
-
|
37 |
-
|
38 |
-
|
39 |
-
|
40 |
-
|
41 |
-
|
42 |
-
|
43 |
-
|
44 |
-
|
45 |
-
|
46 |
-
|
47 |
-
|
48 |
-
|
49 |
-
|
50 |
-
|
51 |
-
|
52 |
-
|
53 |
-
|
54 |
-
|
55 |
-
|
56 |
-
|
57 |
-
|
58 |
-
|
59 |
-
|
60 |
-
|
61 |
-
|
62 |
-
|
63 |
-
|
64 |
-
|
65 |
-
|
66 |
-
|
67 |
-
|
68 |
-
|
69 |
-
|
70 |
-
|
71 |
-
|
72 |
-
|
73 |
-
|
74 |
-
|
75 |
-
|
76 |
-
|
77 |
-
|
78 |
-
|
79 |
-
|
80 |
-
|
81 |
-
|
82 |
-
|
83 |
-
|
84 |
-
|
85 |
-
|
86 |
-
|
87 |
-
|
88 |
-
|
89 |
-
|
90 |
-
|
91 |
-
|
92 |
-
|
93 |
-
|
94 |
-
|
95 |
-
|
96 |
-
|
97 |
-
|
98 |
-
|
99 |
-
|
100 |
-
|
101 |
-
|
102 |
-
|
103 |
-
|
104 |
-
|
105 |
-
|
106 |
-
|
107 |
-
|
108 |
-
|
109 |
-
|
110 |
-
|
111 |
-
|
112 |
-
|
113 |
-
|
114 |
-
|
115 |
-
|
116 |
-
|
117 |
-
|
118 |
-
|
119 |
-
|
120 |
-
|
121 |
-
|
122 |
-
|
123 |
-
|
124 |
-
|
125 |
-
|
126 |
-
|
127 |
-
|
128 |
-
|
129 |
-
|
130 |
-
|
131 |
-
|
132 |
-
|
133 |
-
|
134 |
-
|
135 |
-
|
136 |
-
|
137 |
-
|
138 |
-
|
139 |
-
|
140 |
-
|
141 |
-
|
142 |
-
|
143 |
-
|
144 |
-
|
145 |
-
|
146 |
-
|
147 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import type { Positionable, Position3D } from "$lib/types/positionable.js";
|
2 |
+
import type { AISessionConfig, AISessionResponse, ModelType } from "./RemoteComputeManager.svelte";
|
3 |
|
4 |
+
export type ComputeStatus = "disconnected" | "ready" | "running" | "stopped" | "initializing";
|
5 |
|
6 |
export class RemoteCompute implements Positionable {
|
7 |
+
readonly id: string;
|
8 |
+
|
9 |
+
// Reactive state using Svelte 5 runes
|
10 |
+
position = $state<Position3D>({ x: 0, y: 0, z: 0 });
|
11 |
+
name = $state<string>("");
|
12 |
+
status = $state<ComputeStatus>("disconnected");
|
13 |
+
modelType = $state<ModelType>("act"); // Default to ACT model
|
14 |
+
|
15 |
+
// Session data
|
16 |
+
sessionId = $state<string | null>(null);
|
17 |
+
sessionConfig = $state<AISessionConfig | null>(null);
|
18 |
+
sessionData = $state<AISessionResponse | null>(null);
|
19 |
+
|
20 |
+
// Derived reactive values
|
21 |
+
hasSession = $derived(this.sessionId !== null);
|
22 |
+
isRunning = $derived(this.status === "running");
|
23 |
+
canStart = $derived(this.status === "ready" || this.status === "stopped");
|
24 |
+
canStop = $derived(this.status === "running");
|
25 |
+
|
26 |
+
constructor(id: string, name?: string) {
|
27 |
+
this.id = id;
|
28 |
+
this.name = name || `Compute ${id}`;
|
29 |
+
}
|
30 |
+
|
31 |
+
/**
|
32 |
+
* Get input connections (camera and joint inputs)
|
33 |
+
*/
|
34 |
+
get inputConnections() {
|
35 |
+
if (!this.sessionData) return null;
|
36 |
+
|
37 |
+
return {
|
38 |
+
cameras: this.sessionData.camera_room_ids,
|
39 |
+
jointInput: this.sessionData.joint_input_room_id,
|
40 |
+
workspaceId: this.sessionData.workspace_id
|
41 |
+
};
|
42 |
+
}
|
43 |
+
|
44 |
+
/**
|
45 |
+
* Get output connections (joint output)
|
46 |
+
*/
|
47 |
+
get outputConnections() {
|
48 |
+
if (!this.sessionData) return null;
|
49 |
+
|
50 |
+
return {
|
51 |
+
jointOutput: this.sessionData.joint_output_room_id,
|
52 |
+
workspaceId: this.sessionData.workspace_id
|
53 |
+
};
|
54 |
+
}
|
55 |
+
|
56 |
+
/**
|
57 |
+
* Get display information for UI
|
58 |
+
*/
|
59 |
+
get displayInfo() {
|
60 |
+
return {
|
61 |
+
id: this.id,
|
62 |
+
name: this.name,
|
63 |
+
status: this.status,
|
64 |
+
sessionId: this.sessionId,
|
65 |
+
policyPath: this.sessionConfig?.policyPath,
|
66 |
+
cameraNames: this.sessionConfig?.cameraNames || [],
|
67 |
+
hasSession: this.hasSession,
|
68 |
+
isRunning: this.isRunning,
|
69 |
+
canStart: this.canStart,
|
70 |
+
canStop: this.canStop
|
71 |
+
};
|
72 |
+
}
|
73 |
+
|
74 |
+
/**
|
75 |
+
* Get status for billboard display
|
76 |
+
*/
|
77 |
+
get statusInfo() {
|
78 |
+
const status = this.status;
|
79 |
+
let statusText = "";
|
80 |
+
let statusColor = "";
|
81 |
+
|
82 |
+
switch (status) {
|
83 |
+
case "disconnected":
|
84 |
+
statusText = "Disconnected";
|
85 |
+
statusColor = "rgb(107, 114, 128)"; // gray
|
86 |
+
break;
|
87 |
+
case "ready":
|
88 |
+
statusText = "Ready";
|
89 |
+
statusColor = "rgb(245, 158, 11)"; // yellow
|
90 |
+
break;
|
91 |
+
case "running":
|
92 |
+
statusText = "Running";
|
93 |
+
statusColor = "rgb(34, 197, 94)"; // green
|
94 |
+
break;
|
95 |
+
case "stopped":
|
96 |
+
statusText = "Stopped";
|
97 |
+
statusColor = "rgb(239, 68, 68)"; // red
|
98 |
+
break;
|
99 |
+
case "initializing":
|
100 |
+
statusText = "Initializing";
|
101 |
+
statusColor = "rgb(59, 130, 246)"; // blue
|
102 |
+
break;
|
103 |
+
}
|
104 |
+
|
105 |
+
return {
|
106 |
+
status,
|
107 |
+
statusText,
|
108 |
+
statusColor,
|
109 |
+
emoji: this.getStatusEmoji()
|
110 |
+
};
|
111 |
+
}
|
112 |
+
|
113 |
+
private getStatusEmoji(): string {
|
114 |
+
switch (this.status) {
|
115 |
+
case "disconnected":
|
116 |
+
return "⚪";
|
117 |
+
case "ready":
|
118 |
+
return "🟡";
|
119 |
+
case "running":
|
120 |
+
return "🟢";
|
121 |
+
case "stopped":
|
122 |
+
return "🔴";
|
123 |
+
case "initializing":
|
124 |
+
return "🟠";
|
125 |
+
default:
|
126 |
+
return "⚪";
|
127 |
+
}
|
128 |
+
}
|
129 |
+
|
130 |
+
/**
|
131 |
+
* Reset session data
|
132 |
+
*/
|
133 |
+
resetSession(): void {
|
134 |
+
this.sessionId = null;
|
135 |
+
this.sessionConfig = null;
|
136 |
+
this.sessionData = null;
|
137 |
+
this.status = "disconnected";
|
138 |
+
}
|
139 |
+
|
140 |
+
/**
|
141 |
+
* Update position
|
142 |
+
*/
|
143 |
+
updatePosition(newPosition: Position3D): void {
|
144 |
+
this.position = { ...newPosition };
|
145 |
+
}
|
146 |
+
|
147 |
+
/**
|
148 |
+
* Update name
|
149 |
+
*/
|
150 |
+
updateName(newName: string): void {
|
151 |
+
this.name = newName;
|
152 |
+
}
|
153 |
+
}
|
src/lib/elements/compute/RemoteComputeManager.svelte.ts
CHANGED
@@ -1,497 +1,506 @@
|
|
1 |
-
import { RemoteCompute } from
|
2 |
-
import type { Position3D } from
|
3 |
-
import { generateName } from
|
4 |
-
import { positionManager } from
|
5 |
-
import {
|
6 |
-
|
7 |
-
|
8 |
-
|
9 |
-
|
10 |
-
|
11 |
-
|
12 |
-
|
13 |
-
} from
|
14 |
-
import { settings } from
|
15 |
-
import type {
|
16 |
-
|
17 |
-
|
18 |
-
} from
|
19 |
-
|
20 |
-
export type ModelType =
|
21 |
|
22 |
export interface ModelTypeConfig {
|
23 |
-
|
24 |
-
|
25 |
-
|
26 |
-
|
27 |
-
|
28 |
-
|
29 |
-
|
30 |
-
|
31 |
}
|
32 |
|
33 |
export const MODEL_TYPES: Record<ModelType, ModelTypeConfig> = {
|
34 |
-
|
35 |
-
|
36 |
-
|
37 |
-
|
38 |
-
|
39 |
-
|
40 |
-
|
41 |
-
|
42 |
-
|
43 |
-
|
44 |
-
|
45 |
-
|
46 |
-
|
47 |
-
|
48 |
-
|
49 |
-
|
50 |
-
|
51 |
-
|
52 |
-
|
53 |
-
|
54 |
-
|
55 |
-
|
56 |
-
|
57 |
-
|
58 |
-
|
59 |
-
|
60 |
-
|
61 |
-
|
62 |
-
|
63 |
-
|
64 |
-
|
65 |
-
|
66 |
-
|
67 |
-
|
68 |
-
|
69 |
-
|
70 |
-
|
71 |
-
|
72 |
-
|
73 |
-
|
74 |
-
|
75 |
-
|
76 |
-
|
77 |
-
|
78 |
-
|
79 |
-
|
80 |
-
|
81 |
-
|
82 |
-
|
83 |
-
|
84 |
-
|
85 |
-
|
86 |
-
|
87 |
-
|
88 |
-
|
89 |
-
|
90 |
};
|
91 |
|
92 |
export interface AISessionConfig {
|
93 |
-
|
94 |
-
|
95 |
-
|
96 |
-
|
97 |
-
|
98 |
-
|
99 |
-
|
100 |
}
|
101 |
|
102 |
export interface AISessionResponse {
|
103 |
-
|
104 |
-
|
105 |
-
|
106 |
-
|
107 |
}
|
108 |
|
109 |
export interface AISessionStatus {
|
110 |
-
|
111 |
-
|
112 |
-
|
113 |
-
|
114 |
-
|
115 |
-
|
116 |
-
|
117 |
-
|
118 |
-
|
119 |
-
|
120 |
-
|
121 |
-
|
122 |
-
|
123 |
-
|
124 |
-
|
125 |
-
|
126 |
-
|
127 |
-
|
128 |
-
|
129 |
-
|
130 |
-
|
131 |
-
|
132 |
-
|
133 |
-
|
134 |
-
|
135 |
-
|
136 |
-
|
137 |
-
|
138 |
}
|
139 |
|
140 |
export class RemoteComputeManager {
|
141 |
-
|
142 |
-
|
143 |
-
|
144 |
-
|
145 |
-
|
146 |
-
|
147 |
-
|
148 |
-
|
149 |
-
|
150 |
-
|
151 |
-
|
152 |
-
|
153 |
-
|
154 |
-
|
155 |
-
|
156 |
-
|
157 |
-
|
158 |
-
|
159 |
-
|
160 |
-
|
161 |
-
|
162 |
-
|
163 |
-
|
164 |
-
|
165 |
-
|
166 |
-
|
167 |
-
|
168 |
-
|
169 |
-
|
170 |
-
|
171 |
-
|
172 |
-
|
173 |
-
|
174 |
-
|
175 |
-
|
176 |
-
|
177 |
-
|
178 |
-
|
179 |
-
|
180 |
-
|
181 |
-
|
182 |
-
|
183 |
-
|
184 |
-
|
185 |
-
|
186 |
-
|
187 |
-
|
188 |
-
|
189 |
-
|
190 |
-
|
191 |
-
|
192 |
-
|
193 |
-
|
194 |
-
|
195 |
-
|
196 |
-
|
197 |
-
|
198 |
-
|
199 |
-
|
200 |
-
|
201 |
-
|
202 |
-
|
203 |
-
|
204 |
-
|
205 |
-
|
206 |
-
|
207 |
-
|
208 |
-
|
209 |
-
|
210 |
-
|
211 |
-
|
212 |
-
|
213 |
-
|
214 |
-
|
215 |
-
|
216 |
-
|
217 |
-
|
218 |
-
|
219 |
-
|
220 |
-
|
221 |
-
|
222 |
-
|
223 |
-
|
224 |
-
|
225 |
-
|
226 |
-
|
227 |
-
|
228 |
-
|
229 |
-
|
230 |
-
|
231 |
-
|
232 |
-
|
233 |
-
|
234 |
-
|
235 |
-
|
236 |
-
|
237 |
-
|
238 |
-
|
239 |
-
|
240 |
-
|
241 |
-
|
242 |
-
|
243 |
-
|
244 |
-
|
245 |
-
|
246 |
-
|
247 |
-
|
248 |
-
|
249 |
-
|
250 |
-
|
251 |
-
|
252 |
-
|
253 |
-
|
254 |
-
|
255 |
-
|
256 |
-
|
257 |
-
|
258 |
-
|
259 |
-
|
260 |
-
|
261 |
-
|
262 |
-
|
263 |
-
|
264 |
-
|
265 |
-
|
266 |
-
|
267 |
-
|
268 |
-
|
269 |
-
|
270 |
-
|
271 |
-
|
272 |
-
|
273 |
-
|
274 |
-
|
275 |
-
|
276 |
-
|
277 |
-
|
278 |
-
|
279 |
-
|
280 |
-
|
281 |
-
|
282 |
-
|
283 |
-
|
284 |
-
|
285 |
-
|
286 |
-
|
287 |
-
|
288 |
-
|
289 |
-
|
290 |
-
|
291 |
-
|
292 |
-
|
293 |
-
|
294 |
-
|
295 |
-
|
296 |
-
|
297 |
-
|
298 |
-
|
299 |
-
|
300 |
-
|
301 |
-
|
302 |
-
|
303 |
-
|
304 |
-
|
305 |
-
|
306 |
-
|
307 |
-
|
308 |
-
|
309 |
-
|
310 |
-
|
311 |
-
|
312 |
-
|
313 |
-
|
314 |
-
|
315 |
-
|
316 |
-
|
317 |
-
|
318 |
-
|
319 |
-
|
320 |
-
|
321 |
-
|
322 |
-
|
323 |
-
|
324 |
-
|
325 |
-
|
326 |
-
|
327 |
-
|
328 |
-
|
329 |
-
|
330 |
-
|
331 |
-
|
332 |
-
|
333 |
-
|
334 |
-
|
335 |
-
|
336 |
-
|
337 |
-
|
338 |
-
|
339 |
-
|
340 |
-
|
341 |
-
|
342 |
-
|
343 |
-
|
344 |
-
|
345 |
-
|
346 |
-
|
347 |
-
|
348 |
-
|
349 |
-
|
350 |
-
|
351 |
-
|
352 |
-
|
353 |
-
|
354 |
-
|
355 |
-
|
356 |
-
|
357 |
-
|
358 |
-
|
359 |
-
|
360 |
-
|
361 |
-
|
362 |
-
|
363 |
-
|
364 |
-
|
365 |
-
|
366 |
-
|
367 |
-
|
368 |
-
|
369 |
-
|
370 |
-
|
371 |
-
|
372 |
-
|
373 |
-
|
374 |
-
|
375 |
-
|
376 |
-
|
377 |
-
|
378 |
-
|
379 |
-
|
380 |
-
|
381 |
-
|
382 |
-
|
383 |
-
|
384 |
-
|
385 |
-
|
386 |
-
|
387 |
-
|
388 |
-
|
389 |
-
|
390 |
-
|
391 |
-
|
392 |
-
|
393 |
-
|
394 |
-
|
395 |
-
|
396 |
-
|
397 |
-
|
398 |
-
|
399 |
-
|
400 |
-
|
401 |
-
|
402 |
-
|
403 |
-
|
404 |
-
|
405 |
-
|
406 |
-
|
407 |
-
|
408 |
-
|
409 |
-
|
410 |
-
|
411 |
-
|
412 |
-
|
413 |
-
|
414 |
-
|
415 |
-
|
416 |
-
|
417 |
-
|
418 |
-
|
419 |
-
|
420 |
-
|
421 |
-
|
422 |
-
|
423 |
-
|
424 |
-
|
425 |
-
|
426 |
-
|
427 |
-
|
428 |
-
|
429 |
-
|
430 |
-
|
431 |
-
|
432 |
-
|
433 |
-
|
434 |
-
|
435 |
-
|
436 |
-
|
437 |
-
|
438 |
-
|
439 |
-
|
440 |
-
|
441 |
-
|
442 |
-
|
443 |
-
|
444 |
-
|
445 |
-
|
446 |
-
|
447 |
-
|
448 |
-
|
449 |
-
|
450 |
-
|
451 |
-
|
452 |
-
|
453 |
-
|
454 |
-
|
455 |
-
|
456 |
-
|
457 |
-
|
458 |
-
|
459 |
-
|
460 |
-
|
461 |
-
|
462 |
-
|
463 |
-
|
464 |
-
|
465 |
-
|
466 |
-
|
467 |
-
|
468 |
-
|
469 |
-
|
470 |
-
|
471 |
-
|
472 |
-
|
473 |
-
|
474 |
-
|
475 |
-
|
476 |
-
|
477 |
-
|
478 |
-
|
479 |
-
|
480 |
-
|
481 |
-
|
482 |
-
|
483 |
-
|
484 |
-
|
485 |
-
|
486 |
-
|
487 |
-
|
488 |
-
|
489 |
-
|
490 |
-
|
491 |
-
|
492 |
-
|
493 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
494 |
}
|
495 |
|
496 |
// Global compute manager instance
|
497 |
-
export const remoteComputeManager = new RemoteComputeManager();
|
|
|
1 |
+
import { RemoteCompute } from "./RemoteCompute.svelte";
|
2 |
+
import type { Position3D } from "$lib/types/positionable.js";
|
3 |
+
import { generateName } from "$lib/utils/generateName.js";
|
4 |
+
import { positionManager } from "$lib/utils/positionManager.js";
|
5 |
+
import {
|
6 |
+
rootGet,
|
7 |
+
healthCheckHealthGet,
|
8 |
+
listSessionsSessionsGet,
|
9 |
+
createSessionSessionsPost,
|
10 |
+
startInferenceSessionsSessionIdStartPost,
|
11 |
+
stopInferenceSessionsSessionIdStopPost,
|
12 |
+
deleteSessionSessionsSessionIdDelete
|
13 |
+
} from "@robothub/inference-server-client";
|
14 |
+
import { settings } from "$lib/runes/settings.svelte";
|
15 |
+
import type {
|
16 |
+
CreateSessionRequest,
|
17 |
+
CreateSessionResponse
|
18 |
+
} from "@robothub/inference-server-client";
|
19 |
+
|
20 |
+
export type ModelType = "act" | "diffusion" | "smolvla" | "pi0" | "groot" | "custom";
|
21 |
|
22 |
export interface ModelTypeConfig {
|
23 |
+
id: ModelType;
|
24 |
+
label: string;
|
25 |
+
icon: string;
|
26 |
+
description: string;
|
27 |
+
defaultPolicyPath: string;
|
28 |
+
defaultCameraNames: string[];
|
29 |
+
requiresLanguageInstruction?: boolean;
|
30 |
+
enabled: boolean;
|
31 |
}
|
32 |
|
33 |
export const MODEL_TYPES: Record<ModelType, ModelTypeConfig> = {
|
34 |
+
act: {
|
35 |
+
id: "act",
|
36 |
+
label: "ACT Model",
|
37 |
+
icon: "icon-[mdi--brain]",
|
38 |
+
description: "Action Chunking with Transformers",
|
39 |
+
defaultPolicyPath: "LaetusH/act_so101_beyond",
|
40 |
+
defaultCameraNames: ["front"],
|
41 |
+
enabled: true
|
42 |
+
},
|
43 |
+
diffusion: {
|
44 |
+
id: "diffusion",
|
45 |
+
label: "Diffusion Policy",
|
46 |
+
icon: "icon-[mdi--creation]",
|
47 |
+
description: "Diffusion-based robot control",
|
48 |
+
defaultPolicyPath: "diffusion_policy/default",
|
49 |
+
defaultCameraNames: ["front", "wrist"],
|
50 |
+
enabled: true
|
51 |
+
},
|
52 |
+
smolvla: {
|
53 |
+
id: "smolvla",
|
54 |
+
label: "SmolVLA",
|
55 |
+
icon: "icon-[mdi--eye-outline]",
|
56 |
+
description: "Small Vision-Language-Action model",
|
57 |
+
defaultPolicyPath: "smolvla/latest",
|
58 |
+
defaultCameraNames: ["front"],
|
59 |
+
requiresLanguageInstruction: true,
|
60 |
+
enabled: true
|
61 |
+
},
|
62 |
+
pi0: {
|
63 |
+
id: "pi0",
|
64 |
+
label: "Pi0",
|
65 |
+
icon: "icon-[mdi--pi]",
|
66 |
+
description: "Lightweight robotics model",
|
67 |
+
defaultPolicyPath: "pi0/base",
|
68 |
+
defaultCameraNames: ["front"],
|
69 |
+
enabled: true
|
70 |
+
},
|
71 |
+
groot: {
|
72 |
+
id: "groot",
|
73 |
+
label: "NVIDIA Groot",
|
74 |
+
icon: "icon-[mdi--robot-outline]",
|
75 |
+
description: "Humanoid robotics foundation model",
|
76 |
+
defaultPolicyPath: "nvidia/groot",
|
77 |
+
defaultCameraNames: ["front", "left", "right"],
|
78 |
+
requiresLanguageInstruction: true,
|
79 |
+
enabled: false // Not yet implemented
|
80 |
+
},
|
81 |
+
custom: {
|
82 |
+
id: "custom",
|
83 |
+
label: "Custom Model",
|
84 |
+
icon: "icon-[mdi--cog]",
|
85 |
+
description: "Custom model configuration",
|
86 |
+
defaultPolicyPath: "",
|
87 |
+
defaultCameraNames: ["front"],
|
88 |
+
enabled: true
|
89 |
+
}
|
90 |
};
|
91 |
|
92 |
export interface AISessionConfig {
|
93 |
+
sessionId: string;
|
94 |
+
modelType: ModelType;
|
95 |
+
policyPath: string;
|
96 |
+
cameraNames: string[];
|
97 |
+
transportServerUrl: string;
|
98 |
+
workspaceId?: string;
|
99 |
+
languageInstruction?: string;
|
100 |
}
|
101 |
|
102 |
export interface AISessionResponse {
|
103 |
+
workspace_id: string;
|
104 |
+
camera_room_ids: Record<string, string>;
|
105 |
+
joint_input_room_id: string;
|
106 |
+
joint_output_room_id: string;
|
107 |
}
|
108 |
|
109 |
export interface AISessionStatus {
|
110 |
+
session_id: string;
|
111 |
+
status: "initializing" | "ready" | "running" | "stopped";
|
112 |
+
policy_path: string;
|
113 |
+
camera_names: string[];
|
114 |
+
workspace_id: string;
|
115 |
+
rooms: {
|
116 |
+
workspace_id: string;
|
117 |
+
camera_room_ids: Record<string, string>;
|
118 |
+
joint_input_room_id: string;
|
119 |
+
joint_output_room_id: string;
|
120 |
+
};
|
121 |
+
stats: {
|
122 |
+
inference_count: number;
|
123 |
+
commands_sent: number;
|
124 |
+
joints_received: number;
|
125 |
+
images_received: Record<string, number>;
|
126 |
+
errors: number;
|
127 |
+
actions_in_queue: number;
|
128 |
+
};
|
129 |
+
inference_stats?: {
|
130 |
+
inference_count: number;
|
131 |
+
total_inference_time: number;
|
132 |
+
average_inference_time: number;
|
133 |
+
average_fps: number;
|
134 |
+
is_loaded: boolean;
|
135 |
+
device: string;
|
136 |
+
};
|
137 |
+
error_message?: string;
|
138 |
}
|
139 |
|
140 |
export class RemoteComputeManager {
|
141 |
+
private _computes = $state<RemoteCompute[]>([]);
|
142 |
+
|
143 |
+
constructor() {
|
144 |
+
// No client initialization needed anymore
|
145 |
+
}
|
146 |
+
|
147 |
+
// Reactive getters
|
148 |
+
get computes(): RemoteCompute[] {
|
149 |
+
return this._computes;
|
150 |
+
}
|
151 |
+
|
152 |
+
get computeCount(): number {
|
153 |
+
return this._computes.length;
|
154 |
+
}
|
155 |
+
|
156 |
+
get runningComputes(): RemoteCompute[] {
|
157 |
+
return this._computes.filter((compute) => compute.status === "running");
|
158 |
+
}
|
159 |
+
|
160 |
+
/**
|
161 |
+
* Get available model types
|
162 |
+
*/
|
163 |
+
get availableModelTypes(): ModelTypeConfig[] {
|
164 |
+
return Object.values(MODEL_TYPES).filter((model) => model.enabled);
|
165 |
+
}
|
166 |
+
|
167 |
+
/**
|
168 |
+
* Get model type configuration
|
169 |
+
*/
|
170 |
+
getModelTypeConfig(modelType: ModelType): ModelTypeConfig | undefined {
|
171 |
+
return MODEL_TYPES[modelType];
|
172 |
+
}
|
173 |
+
|
174 |
+
/**
|
175 |
+
* Create a new AI compute instance with full configuration
|
176 |
+
*/
|
177 |
+
async createComputeWithSession(
|
178 |
+
config: AISessionConfig,
|
179 |
+
computeId?: string,
|
180 |
+
computeName?: string,
|
181 |
+
position?: Position3D
|
182 |
+
): Promise<{ success: boolean; error?: string; compute?: RemoteCompute }> {
|
183 |
+
const finalComputeId = computeId || generateName();
|
184 |
+
|
185 |
+
// Check if compute already exists
|
186 |
+
if (this._computes.find((c) => c.id === finalComputeId)) {
|
187 |
+
return { success: false, error: `Compute with ID ${finalComputeId} already exists` };
|
188 |
+
}
|
189 |
+
|
190 |
+
try {
|
191 |
+
// Create compute instance
|
192 |
+
const compute = new RemoteCompute(finalComputeId, computeName);
|
193 |
+
compute.modelType = config.modelType;
|
194 |
+
|
195 |
+
// Set position (from position manager if not provided)
|
196 |
+
compute.position = position || positionManager.getNextPosition();
|
197 |
+
|
198 |
+
// Add to reactive array
|
199 |
+
this._computes.push(compute);
|
200 |
+
|
201 |
+
// Create the session immediately
|
202 |
+
const sessionResult = await this.createSession(compute.id, config);
|
203 |
+
if (!sessionResult.success) {
|
204 |
+
// Remove compute if session creation failed
|
205 |
+
await this.removeCompute(compute.id);
|
206 |
+
return { success: false, error: sessionResult.error };
|
207 |
+
}
|
208 |
+
|
209 |
+
console.log(
|
210 |
+
`Created compute ${finalComputeId} with ${config.modelType} model at position (${compute.position.x.toFixed(1)}, ${compute.position.y.toFixed(1)}, ${compute.position.z.toFixed(1)}). Total computes: ${this._computes.length}`
|
211 |
+
);
|
212 |
+
|
213 |
+
return { success: true, compute };
|
214 |
+
} catch (error) {
|
215 |
+
console.error("Failed to create compute with session:", error);
|
216 |
+
return {
|
217 |
+
success: false,
|
218 |
+
error: error instanceof Error ? error.message : String(error)
|
219 |
+
};
|
220 |
+
}
|
221 |
+
}
|
222 |
+
|
223 |
+
/**
|
224 |
+
* Create a new AI compute instance (legacy method)
|
225 |
+
*/
|
226 |
+
createCompute(id?: string, name?: string, position?: Position3D): RemoteCompute {
|
227 |
+
const computeId = id || generateName();
|
228 |
+
|
229 |
+
// Check if compute already exists
|
230 |
+
if (this._computes.find((c) => c.id === computeId)) {
|
231 |
+
throw new Error(`Compute with ID ${computeId} already exists`);
|
232 |
+
}
|
233 |
+
|
234 |
+
// Create compute instance
|
235 |
+
const compute = new RemoteCompute(computeId, name);
|
236 |
+
|
237 |
+
// Set position (from position manager if not provided)
|
238 |
+
compute.position = position || positionManager.getNextPosition();
|
239 |
+
|
240 |
+
// Add to reactive array
|
241 |
+
this._computes.push(compute);
|
242 |
+
|
243 |
+
console.log(
|
244 |
+
`Created compute ${computeId} at position (${compute.position.x.toFixed(1)}, ${compute.position.y.toFixed(1)}, ${compute.position.z.toFixed(1)}). Total computes: ${this._computes.length}`
|
245 |
+
);
|
246 |
+
|
247 |
+
return compute;
|
248 |
+
}
|
249 |
+
|
250 |
+
/**
|
251 |
+
* Get compute by ID
|
252 |
+
*/
|
253 |
+
getCompute(id: string): RemoteCompute | undefined {
|
254 |
+
return this._computes.find((c) => c.id === id);
|
255 |
+
}
|
256 |
+
|
257 |
+
/**
|
258 |
+
* Remove a compute instance
|
259 |
+
*/
|
260 |
+
async removeCompute(id: string): Promise<void> {
|
261 |
+
const computeIndex = this._computes.findIndex((c) => c.id === id);
|
262 |
+
if (computeIndex === -1) return;
|
263 |
+
|
264 |
+
const compute = this._computes[computeIndex];
|
265 |
+
|
266 |
+
// Clean up compute resources
|
267 |
+
await this.stopSession(id);
|
268 |
+
await this.deleteSession(id);
|
269 |
+
|
270 |
+
// Remove from reactive array
|
271 |
+
this._computes.splice(computeIndex, 1);
|
272 |
+
|
273 |
+
console.log(`Removed compute ${id}. Remaining computes: ${this._computes.length}`);
|
274 |
+
}
|
275 |
+
|
276 |
+
/**
|
277 |
+
* Create an Inference Session
|
278 |
+
*/
|
279 |
+
async createSession(
|
280 |
+
computeId: string,
|
281 |
+
config: AISessionConfig
|
282 |
+
): Promise<{ success: boolean; error?: string; data?: AISessionResponse }> {
|
283 |
+
const compute = this.getCompute(computeId);
|
284 |
+
if (!compute) {
|
285 |
+
return { success: false, error: `Compute ${computeId} not found` };
|
286 |
+
}
|
287 |
+
|
288 |
+
try {
|
289 |
+
const request: CreateSessionRequest = {
|
290 |
+
session_id: config.sessionId,
|
291 |
+
policy_path: config.policyPath,
|
292 |
+
camera_names: config.cameraNames,
|
293 |
+
transport_server_url: config.transportServerUrl,
|
294 |
+
workspace_id: config.workspaceId || undefined,
|
295 |
+
policy_type: config.modelType, // Use model type as policy type
|
296 |
+
language_instruction: config.languageInstruction || undefined
|
297 |
+
};
|
298 |
+
|
299 |
+
const response = await createSessionSessionsPost({
|
300 |
+
body: request,
|
301 |
+
baseUrl: settings.inferenceServerUrl
|
302 |
+
});
|
303 |
+
|
304 |
+
if (!response.data) {
|
305 |
+
throw new Error("Failed to create session - no data returned");
|
306 |
+
}
|
307 |
+
|
308 |
+
const data: CreateSessionResponse = response.data;
|
309 |
+
|
310 |
+
// Update compute with session info
|
311 |
+
compute.sessionId = config.sessionId;
|
312 |
+
compute.status = "ready";
|
313 |
+
compute.sessionConfig = config;
|
314 |
+
compute.sessionData = {
|
315 |
+
workspace_id: data.workspace_id,
|
316 |
+
camera_room_ids: data.camera_room_ids,
|
317 |
+
joint_input_room_id: data.joint_input_room_id,
|
318 |
+
joint_output_room_id: data.joint_output_room_id
|
319 |
+
};
|
320 |
+
|
321 |
+
return { success: true, data: compute.sessionData };
|
322 |
+
} catch (error) {
|
323 |
+
console.error(`Failed to create session for compute ${computeId}:`, error);
|
324 |
+
return {
|
325 |
+
success: false,
|
326 |
+
error: error instanceof Error ? error.message : String(error)
|
327 |
+
};
|
328 |
+
}
|
329 |
+
}
|
330 |
+
|
331 |
+
/**
|
332 |
+
* Start inference for a session
|
333 |
+
*/
|
334 |
+
async startSession(computeId: string): Promise<{ success: boolean; error?: string }> {
|
335 |
+
const compute = this.getCompute(computeId);
|
336 |
+
if (!compute || !compute.sessionId) {
|
337 |
+
return { success: false, error: "No session to start" };
|
338 |
+
}
|
339 |
+
|
340 |
+
try {
|
341 |
+
await startInferenceSessionsSessionIdStartPost({
|
342 |
+
path: { session_id: compute.sessionId },
|
343 |
+
baseUrl: settings.inferenceServerUrl
|
344 |
+
});
|
345 |
+
compute.status = "running";
|
346 |
+
return { success: true };
|
347 |
+
} catch (error) {
|
348 |
+
console.error(`Failed to start session for compute ${computeId}:`, error);
|
349 |
+
return {
|
350 |
+
success: false,
|
351 |
+
error: error instanceof Error ? error.message : String(error)
|
352 |
+
};
|
353 |
+
}
|
354 |
+
}
|
355 |
+
|
356 |
+
/**
|
357 |
+
* Stop inference for a session
|
358 |
+
*/
|
359 |
+
async stopSession(computeId: string): Promise<{ success: boolean; error?: string }> {
|
360 |
+
const compute = this.getCompute(computeId);
|
361 |
+
if (!compute || !compute.sessionId) {
|
362 |
+
return { success: false, error: "No session to stop" };
|
363 |
+
}
|
364 |
+
|
365 |
+
try {
|
366 |
+
await stopInferenceSessionsSessionIdStopPost({
|
367 |
+
path: { session_id: compute.sessionId },
|
368 |
+
baseUrl: settings.inferenceServerUrl
|
369 |
+
});
|
370 |
+
compute.status = "stopped";
|
371 |
+
return { success: true };
|
372 |
+
} catch (error) {
|
373 |
+
console.error(`Failed to stop session for compute ${computeId}:`, error);
|
374 |
+
return {
|
375 |
+
success: false,
|
376 |
+
error: error instanceof Error ? error.message : String(error)
|
377 |
+
};
|
378 |
+
}
|
379 |
+
}
|
380 |
+
|
381 |
+
/**
|
382 |
+
* Delete a session
|
383 |
+
*/
|
384 |
+
async deleteSession(computeId: string): Promise<{ success: boolean; error?: string }> {
|
385 |
+
const compute = this.getCompute(computeId);
|
386 |
+
if (!compute || !compute.sessionId) {
|
387 |
+
return { success: true }; // Already deleted
|
388 |
+
}
|
389 |
+
|
390 |
+
try {
|
391 |
+
await deleteSessionSessionsSessionIdDelete({
|
392 |
+
path: { session_id: compute.sessionId },
|
393 |
+
baseUrl: settings.inferenceServerUrl
|
394 |
+
});
|
395 |
+
|
396 |
+
// Reset compute session info
|
397 |
+
compute.sessionId = null;
|
398 |
+
compute.status = "disconnected";
|
399 |
+
compute.sessionConfig = null;
|
400 |
+
compute.sessionData = null;
|
401 |
+
|
402 |
+
return { success: true };
|
403 |
+
} catch (error) {
|
404 |
+
console.error(`Failed to delete session for compute ${computeId}:`, error);
|
405 |
+
return {
|
406 |
+
success: false,
|
407 |
+
error: error instanceof Error ? error.message : String(error)
|
408 |
+
};
|
409 |
+
}
|
410 |
+
}
|
411 |
+
|
412 |
+
/**
|
413 |
+
* Get session status
|
414 |
+
*/
|
415 |
+
async getSessionStatus(
|
416 |
+
computeId: string
|
417 |
+
): Promise<{ success: boolean; data?: AISessionStatus; error?: string }> {
|
418 |
+
const compute = this.getCompute(computeId);
|
419 |
+
if (!compute || !compute.sessionId) {
|
420 |
+
return { success: false, error: "No session found" };
|
421 |
+
}
|
422 |
+
|
423 |
+
try {
|
424 |
+
// Get all sessions and find the one we want
|
425 |
+
const response = await listSessionsSessionsGet({
|
426 |
+
baseUrl: settings.inferenceServerUrl
|
427 |
+
});
|
428 |
+
|
429 |
+
if (!response.data) {
|
430 |
+
throw new Error("Failed to get sessions list");
|
431 |
+
}
|
432 |
+
|
433 |
+
const session = response.data.find((s) => s.session_id === compute.sessionId);
|
434 |
+
if (!session) {
|
435 |
+
throw new Error(`Session ${compute.sessionId} not found`);
|
436 |
+
}
|
437 |
+
|
438 |
+
// Update compute status
|
439 |
+
compute.status = session.status as "initializing" | "ready" | "running" | "stopped";
|
440 |
+
|
441 |
+
// Convert to AISessionStatus format
|
442 |
+
const sessionStatus: AISessionStatus = {
|
443 |
+
session_id: session.session_id,
|
444 |
+
status: session.status as "initializing" | "ready" | "running" | "stopped",
|
445 |
+
policy_path: session.policy_path,
|
446 |
+
camera_names: session.camera_names,
|
447 |
+
workspace_id: session.workspace_id,
|
448 |
+
rooms: session.rooms as any,
|
449 |
+
stats: session.stats as any,
|
450 |
+
inference_stats: session.inference_stats as any,
|
451 |
+
error_message: session.error_message || undefined
|
452 |
+
};
|
453 |
+
|
454 |
+
return { success: true, data: sessionStatus };
|
455 |
+
} catch (error) {
|
456 |
+
console.error(`Failed to get session status for compute ${computeId}:`, error);
|
457 |
+
return {
|
458 |
+
success: false,
|
459 |
+
error: error instanceof Error ? error.message : String(error)
|
460 |
+
};
|
461 |
+
}
|
462 |
+
}
|
463 |
+
|
464 |
+
/**
|
465 |
+
* Check AI server health
|
466 |
+
*/
|
467 |
+
async checkServerHealth(): Promise<{ success: boolean; data?: any; error?: string }> {
|
468 |
+
try {
|
469 |
+
const healthResponse = await rootGet({
|
470 |
+
baseUrl: settings.inferenceServerUrl
|
471 |
+
});
|
472 |
+
|
473 |
+
if (!healthResponse.data) {
|
474 |
+
return { success: false, error: "Server is not healthy" };
|
475 |
+
}
|
476 |
+
|
477 |
+
// Get detailed health info
|
478 |
+
const detailedHealthResponse = await healthCheckHealthGet({
|
479 |
+
baseUrl: settings.inferenceServerUrl
|
480 |
+
});
|
481 |
+
|
482 |
+
return { success: true, data: detailedHealthResponse.data };
|
483 |
+
} catch (error) {
|
484 |
+
console.error("Failed to check AI server health:", error);
|
485 |
+
return {
|
486 |
+
success: false,
|
487 |
+
error: error instanceof Error ? error.message : String(error)
|
488 |
+
};
|
489 |
+
}
|
490 |
+
}
|
491 |
+
|
492 |
+
/**
|
493 |
+
* Clean up all computes
|
494 |
+
*/
|
495 |
+
async destroy(): Promise<void> {
|
496 |
+
const cleanupPromises = this._computes.map(async (compute) => {
|
497 |
+
await this.stopSession(compute.id);
|
498 |
+
await this.deleteSession(compute.id);
|
499 |
+
});
|
500 |
+
await Promise.allSettled(cleanupPromises);
|
501 |
+
this._computes.length = 0;
|
502 |
+
}
|
503 |
}
|
504 |
|
505 |
// Global compute manager instance
|
506 |
+
export const remoteComputeManager = new RemoteComputeManager();
|
src/lib/elements/compute/index.ts
CHANGED
@@ -1,5 +1,11 @@
|
|
1 |
-
export { RemoteComputeManager, remoteComputeManager } from
|
2 |
-
export { RemoteCompute } from
|
3 |
-
export type {
|
4 |
-
|
5 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
export { RemoteComputeManager, remoteComputeManager } from "./RemoteComputeManager.svelte.js";
|
2 |
+
export { RemoteCompute } from "./RemoteCompute.svelte.js";
|
3 |
+
export type {
|
4 |
+
AISessionConfig,
|
5 |
+
AISessionResponse,
|
6 |
+
AISessionStatus,
|
7 |
+
ModelType,
|
8 |
+
ModelTypeConfig
|
9 |
+
} from "./RemoteComputeManager.svelte.js";
|
10 |
+
export { MODEL_TYPES } from "./RemoteComputeManager.svelte.js";
|
11 |
+
export type { ComputeStatus } from "./RemoteCompute.svelte.js";
|
src/lib/elements/robot/Robot.svelte.ts
CHANGED
@@ -1,496 +1,537 @@
|
|
1 |
-
import type {
|
2 |
-
|
3 |
-
|
4 |
-
|
5 |
-
|
6 |
-
|
7 |
-
|
8 |
-
|
9 |
-
} from
|
10 |
-
import type { Positionable, Position3D } from
|
11 |
-
import { USBConsumer } from
|
12 |
-
import { USBProducer } from
|
13 |
-
import { RemoteConsumer } from
|
14 |
-
import { RemoteProducer } from
|
15 |
-
import { USBServoDriver } from
|
16 |
-
|
17 |
-
import { ROBOT_CONFIG } from
|
18 |
-
import type IUrdfRobot from
|
19 |
|
20 |
export class Robot implements Positionable {
|
21 |
-
|
22 |
-
|
23 |
-
|
24 |
-
|
25 |
-
|
26 |
-
|
27 |
-
|
28 |
-
|
29 |
-
|
30 |
-
|
31 |
-
|
32 |
-
|
33 |
-
|
34 |
-
|
35 |
-
|
36 |
-
|
37 |
-
|
38 |
-
|
39 |
-
|
40 |
-
|
41 |
-
|
42 |
-
|
43 |
-
|
44 |
-
|
45 |
-
|
46 |
-
|
47 |
-
|
48 |
-
|
49 |
-
|
50 |
-
|
51 |
-
|
52 |
-
|
53 |
-
|
54 |
-
|
55 |
-
|
56 |
-
|
57 |
-
|
58 |
-
|
59 |
-
|
60 |
-
|
61 |
-
|
62 |
-
|
63 |
-
|
64 |
-
|
65 |
-
|
66 |
-
|
67 |
-
|
68 |
-
|
69 |
-
|
70 |
-
|
71 |
-
|
72 |
-
|
73 |
-
|
74 |
-
|
75 |
-
|
76 |
-
|
77 |
-
|
78 |
-
|
79 |
-
|
80 |
-
|
81 |
-
|
82 |
-
|
83 |
-
|
84 |
-
|
85 |
-
|
86 |
-
|
87 |
-
|
88 |
-
|
89 |
-
|
90 |
-
|
91 |
-
|
92 |
-
|
93 |
-
|
94 |
-
|
95 |
-
|
96 |
-
|
97 |
-
|
98 |
-
|
99 |
-
|
100 |
-
|
101 |
-
|
102 |
-
|
103 |
-
|
104 |
-
|
105 |
-
|
106 |
-
|
107 |
-
|
108 |
-
|
109 |
-
|
110 |
-
|
111 |
-
|
112 |
-
|
113 |
-
|
114 |
-
|
115 |
-
|
116 |
-
|
117 |
-
|
118 |
-
|
119 |
-
|
120 |
-
|
121 |
-
|
122 |
-
|
123 |
-
|
124 |
-
|
125 |
-
|
126 |
-
|
127 |
-
|
128 |
-
|
129 |
-
|
130 |
-
|
131 |
-
|
132 |
-
|
133 |
-
|
134 |
-
|
135 |
-
|
136 |
-
|
137 |
-
|
138 |
-
|
139 |
-
|
140 |
-
|
141 |
-
|
142 |
-
|
143 |
-
|
144 |
-
|
145 |
-
|
146 |
-
|
147 |
-
|
148 |
-
|
149 |
-
|
150 |
-
|
151 |
-
|
152 |
-
|
153 |
-
|
154 |
-
|
155 |
-
|
156 |
-
|
157 |
-
|
158 |
-
|
159 |
-
|
160 |
-
|
161 |
-
|
162 |
-
|
163 |
-
|
164 |
-
|
165 |
-
|
166 |
-
|
167 |
-
|
168 |
-
|
169 |
-
|
170 |
-
|
171 |
-
|
172 |
-
|
173 |
-
|
174 |
-
|
175 |
-
|
176 |
-
|
177 |
-
|
178 |
-
|
179 |
-
|
180 |
-
|
181 |
-
|
182 |
-
|
183 |
-
|
184 |
-
|
185 |
-
|
186 |
-
|
187 |
-
|
188 |
-
|
189 |
-
|
190 |
-
|
191 |
-
|
192 |
-
|
193 |
-
|
194 |
-
|
195 |
-
|
196 |
-
|
197 |
-
|
198 |
-
|
199 |
-
|
200 |
-
|
201 |
-
|
202 |
-
|
203 |
-
|
204 |
-
|
205 |
-
|
206 |
-
|
207 |
-
|
208 |
-
|
209 |
-
|
210 |
-
|
211 |
-
|
212 |
-
|
213 |
-
|
214 |
-
|
215 |
-
|
216 |
-
|
217 |
-
|
218 |
-
|
219 |
-
|
220 |
-
|
221 |
-
|
222 |
-
|
223 |
-
|
224 |
-
|
225 |
-
|
226 |
-
|
227 |
-
|
228 |
-
|
229 |
-
|
230 |
-
|
231 |
-
|
232 |
-
|
233 |
-
|
234 |
-
|
235 |
-
|
236 |
-
|
237 |
-
|
238 |
-
|
239 |
-
|
240 |
-
|
241 |
-
|
242 |
-
|
243 |
-
|
244 |
-
|
245 |
-
|
246 |
-
|
247 |
-
|
248 |
-
|
249 |
-
|
250 |
-
|
251 |
-
|
252 |
-
|
253 |
-
|
254 |
-
|
255 |
-
|
256 |
-
|
257 |
-
|
258 |
-
|
259 |
-
|
260 |
-
|
261 |
-
|
262 |
-
|
263 |
-
|
264 |
-
|
265 |
-
|
266 |
-
|
267 |
-
|
268 |
-
|
269 |
-
|
270 |
-
|
271 |
-
|
272 |
-
|
273 |
-
|
274 |
-
|
275 |
-
|
276 |
-
|
277 |
-
|
278 |
-
|
279 |
-
|
280 |
-
|
281 |
-
|
282 |
-
|
283 |
-
|
284 |
-
|
285 |
-
|
286 |
-
|
287 |
-
|
288 |
-
|
289 |
-
|
290 |
-
|
291 |
-
|
292 |
-
|
293 |
-
|
294 |
-
|
295 |
-
|
296 |
-
|
297 |
-
|
298 |
-
|
299 |
-
|
300 |
-
|
301 |
-
|
302 |
-
|
303 |
-
|
304 |
-
|
305 |
-
|
306 |
-
|
307 |
-
|
308 |
-
|
309 |
-
|
310 |
-
|
311 |
-
|
312 |
-
|
313 |
-
|
314 |
-
|
315 |
-
|
316 |
-
|
317 |
-
|
318 |
-
|
319 |
-
|
320 |
-
|
321 |
-
|
322 |
-
|
323 |
-
|
324 |
-
|
325 |
-
|
326 |
-
|
327 |
-
|
328 |
-
|
329 |
-
|
330 |
-
|
331 |
-
|
332 |
-
|
333 |
-
|
334 |
-
|
335 |
-
|
336 |
-
|
337 |
-
|
338 |
-
|
339 |
-
|
340 |
-
|
341 |
-
|
342 |
-
|
343 |
-
|
344 |
-
|
345 |
-
|
346 |
-
|
347 |
-
|
348 |
-
|
349 |
-
|
350 |
-
|
351 |
-
|
352 |
-
|
353 |
-
|
354 |
-
|
355 |
-
|
356 |
-
|
357 |
-
|
358 |
-
|
359 |
-
|
360 |
-
|
361 |
-
|
362 |
-
|
363 |
-
|
364 |
-
|
365 |
-
|
366 |
-
|
367 |
-
|
368 |
-
|
369 |
-
|
370 |
-
|
371 |
-
|
372 |
-
|
373 |
-
|
374 |
-
|
375 |
-
|
376 |
-
|
377 |
-
|
378 |
-
|
379 |
-
|
380 |
-
|
381 |
-
|
382 |
-
|
383 |
-
|
384 |
-
|
385 |
-
|
386 |
-
|
387 |
-
|
388 |
-
|
389 |
-
|
390 |
-
|
391 |
-
|
392 |
-
|
393 |
-
|
394 |
-
|
395 |
-
|
396 |
-
|
397 |
-
|
398 |
-
|
399 |
-
|
400 |
-
|
401 |
-
|
402 |
-
|
403 |
-
|
404 |
-
|
405 |
-
|
406 |
-
|
407 |
-
|
408 |
-
|
409 |
-
|
410 |
-
|
411 |
-
|
412 |
-
|
413 |
-
|
414 |
-
|
415 |
-
|
416 |
-
|
417 |
-
|
418 |
-
|
419 |
-
|
420 |
-
|
421 |
-
|
422 |
-
|
423 |
-
|
424 |
-
|
425 |
-
|
426 |
-
|
427 |
-
|
428 |
-
|
429 |
-
|
430 |
-
|
431 |
-
|
432 |
-
|
433 |
-
|
434 |
-
|
435 |
-
|
436 |
-
|
437 |
-
|
438 |
-
|
439 |
-
|
440 |
-
|
441 |
-
|
442 |
-
|
443 |
-
|
444 |
-
|
445 |
-
|
446 |
-
|
447 |
-
|
448 |
-
|
449 |
-
|
450 |
-
|
451 |
-
|
452 |
-
|
453 |
-
|
454 |
-
|
455 |
-
|
456 |
-
|
457 |
-
|
458 |
-
|
459 |
-
|
460 |
-
|
461 |
-
|
462 |
-
|
463 |
-
|
464 |
-
|
465 |
-
|
466 |
-
|
467 |
-
|
468 |
-
|
469 |
-
|
470 |
-
|
471 |
-
|
472 |
-
|
473 |
-
|
474 |
-
|
475 |
-
|
476 |
-
|
477 |
-
|
478 |
-
|
479 |
-
|
480 |
-
|
481 |
-
|
482 |
-
|
483 |
-
|
484 |
-
|
485 |
-
|
486 |
-
|
487 |
-
|
488 |
-
|
489 |
-
|
490 |
-
|
491 |
-
|
492 |
-
|
493 |
-
|
494 |
-
|
495 |
-
|
496 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import type {
|
2 |
+
JointState,
|
3 |
+
RobotCommand,
|
4 |
+
ConnectionStatus,
|
5 |
+
USBDriverConfig,
|
6 |
+
RemoteDriverConfig,
|
7 |
+
Consumer,
|
8 |
+
Producer
|
9 |
+
} from "./models.js";
|
10 |
+
import type { Positionable, Position3D } from "$lib/types/positionable.js";
|
11 |
+
import { USBConsumer } from "./drivers/USBConsumer.js";
|
12 |
+
import { USBProducer } from "./drivers/USBProducer.js";
|
13 |
+
import { RemoteConsumer } from "./drivers/RemoteConsumer.js";
|
14 |
+
import { RemoteProducer } from "./drivers/RemoteProducer.js";
|
15 |
+
import { USBServoDriver } from "./drivers/USBServoDriver.js";
|
16 |
+
|
17 |
+
import { ROBOT_CONFIG } from "./config.js";
|
18 |
+
import type IUrdfRobot from "@/components/3d/elements/robot/URDF/interfaces/IUrdfRobot.js";
|
19 |
|
20 |
export class Robot implements Positionable {
|
21 |
+
// Core robot data
|
22 |
+
readonly id: string;
|
23 |
+
private unsubscribeFns: (() => void)[] = [];
|
24 |
+
|
25 |
+
// Command synchronization to prevent state conflicts
|
26 |
+
private commandMutex = $state(false);
|
27 |
+
private pendingCommands: RobotCommand[] = [];
|
28 |
+
|
29 |
+
// Command deduplication to prevent rapid duplicate commands
|
30 |
+
private lastCommandTime = 0;
|
31 |
+
private lastCommandValues: Record<string, number> = {};
|
32 |
+
|
33 |
+
// Memory management
|
34 |
+
private lastCleanup = 0;
|
35 |
+
|
36 |
+
// Single consumer and multiple producers using Svelte 5 runes - PUBLIC for reactive access
|
37 |
+
consumer = $state<Consumer | null>(null);
|
38 |
+
producers = $state<Producer[]>([]);
|
39 |
+
|
40 |
+
// Reactive state using Svelte 5 runes - PUBLIC for reactive access
|
41 |
+
joints = $state<Record<string, JointState>>({});
|
42 |
+
position = $state<Position3D>({ x: 0, y: 0, z: 0 });
|
43 |
+
isManualControlEnabled = $state(true);
|
44 |
+
connectionStatus = $state<ConnectionStatus>({ isConnected: false });
|
45 |
+
|
46 |
+
// URDF robot state for 3D visualization - PUBLIC for reactive access
|
47 |
+
urdfRobotState = $state<IUrdfRobot | null>(null);
|
48 |
+
|
49 |
+
// Derived reactive values for components
|
50 |
+
jointArray = $derived(Object.values(this.joints));
|
51 |
+
hasProducers = $derived(this.producers.length > 0);
|
52 |
+
hasConsumer = $derived(this.consumer !== null && this.consumer.status.isConnected);
|
53 |
+
outputDriverCount = $derived(this.producers.filter((d) => d.status.isConnected).length);
|
54 |
+
|
55 |
+
constructor(id: string, initialJoints: JointState[], urdfRobotState?: IUrdfRobot) {
|
56 |
+
this.id = id;
|
57 |
+
|
58 |
+
// Store URDF robot state if provided
|
59 |
+
this.urdfRobotState = urdfRobotState || null;
|
60 |
+
|
61 |
+
// Initialize joints with normalized values
|
62 |
+
initialJoints.forEach((joint) => {
|
63 |
+
const isGripper =
|
64 |
+
joint.name.toLowerCase() === "jaw" || joint.name.toLowerCase() === "gripper";
|
65 |
+
this.joints[joint.name] = {
|
66 |
+
...joint,
|
67 |
+
value: isGripper ? 0 : 0 // Start at neutral position
|
68 |
+
};
|
69 |
+
});
|
70 |
+
}
|
71 |
+
|
72 |
+
// Method to set URDF robot state after creation (for async loading)
|
73 |
+
setUrdfRobotState(urdfRobotState: any): void {
|
74 |
+
this.urdfRobotState = urdfRobotState;
|
75 |
+
}
|
76 |
+
|
77 |
+
/**
|
78 |
+
* Update position (implements Positionable interface)
|
79 |
+
*/
|
80 |
+
updatePosition(newPosition: Position3D): void {
|
81 |
+
this.position = { ...newPosition };
|
82 |
+
}
|
83 |
+
|
84 |
+
// Get all USB drivers (both consumer and producers) for calibration
|
85 |
+
getUSBDrivers(): USBServoDriver[] {
|
86 |
+
const usbDrivers: USBServoDriver[] = [];
|
87 |
+
|
88 |
+
// Check consumer
|
89 |
+
if (this.consumer && USBServoDriver.isUSBDriver(this.consumer)) {
|
90 |
+
usbDrivers.push(this.consumer);
|
91 |
+
}
|
92 |
+
|
93 |
+
// Check producers
|
94 |
+
this.producers.forEach((producer) => {
|
95 |
+
if (USBServoDriver.isUSBDriver(producer)) {
|
96 |
+
usbDrivers.push(producer);
|
97 |
+
}
|
98 |
+
});
|
99 |
+
|
100 |
+
return usbDrivers;
|
101 |
+
}
|
102 |
+
|
103 |
+
// Get uncalibrated USB drivers that need calibration
|
104 |
+
getUncalibratedUSBDrivers(): USBServoDriver[] {
|
105 |
+
return this.getUSBDrivers().filter((driver) => driver.needsCalibration);
|
106 |
+
}
|
107 |
+
|
108 |
+
// Check if robot has any USB drivers
|
109 |
+
hasUSBDrivers(): boolean {
|
110 |
+
return this.getUSBDrivers().length > 0;
|
111 |
+
}
|
112 |
+
|
113 |
+
// Check if all USB drivers are calibrated
|
114 |
+
areAllUSBDriversCalibrated(): boolean {
|
115 |
+
const usbDrivers = this.getUSBDrivers();
|
116 |
+
return usbDrivers.length > 0 && usbDrivers.every((driver) => driver.isCalibrated);
|
117 |
+
}
|
118 |
+
|
119 |
+
// Joint value updates (normalized) - for manual control
|
120 |
+
updateJoint(name: string, normalizedValue: number): void {
|
121 |
+
if (!this.isManualControlEnabled) {
|
122 |
+
console.warn("Manual control is disabled");
|
123 |
+
return;
|
124 |
+
}
|
125 |
+
|
126 |
+
this.updateJointValue(name, normalizedValue, true);
|
127 |
+
}
|
128 |
+
|
129 |
+
// Internal joint value update (used by both manual control and USB calibration sync)
|
130 |
+
updateJointValue(name: string, normalizedValue: number, sendToProducers: boolean = false): void {
|
131 |
+
const joint = this.joints[name];
|
132 |
+
if (!joint) {
|
133 |
+
console.warn(`Joint ${name} not found`);
|
134 |
+
return;
|
135 |
+
}
|
136 |
+
|
137 |
+
// Clamp to appropriate normalized range based on joint type
|
138 |
+
if (name.toLowerCase() === "jaw" || name.toLowerCase() === "gripper") {
|
139 |
+
normalizedValue = Math.max(0, Math.min(100, normalizedValue));
|
140 |
+
} else {
|
141 |
+
normalizedValue = Math.max(-100, Math.min(100, normalizedValue));
|
142 |
+
}
|
143 |
+
|
144 |
+
console.debug(
|
145 |
+
`[Robot ${this.id}] Update joint ${name} to ${normalizedValue} (normalized, sendToProducers: ${sendToProducers})`
|
146 |
+
);
|
147 |
+
|
148 |
+
// Create a new joint object to ensure reactivity
|
149 |
+
this.joints[name] = { ...joint, value: normalizedValue };
|
150 |
+
|
151 |
+
// Send normalized command to producers if requested
|
152 |
+
if (sendToProducers) {
|
153 |
+
this.sendToProducers({ joints: [{ name, value: normalizedValue }] });
|
154 |
+
}
|
155 |
+
}
|
156 |
+
|
157 |
+
executeCommand(command: RobotCommand): void {
|
158 |
+
// Command deduplication - skip if same values sent within dedup window
|
159 |
+
const now = Date.now();
|
160 |
+
if (now - this.lastCommandTime < ROBOT_CONFIG.commands.dedupWindow) {
|
161 |
+
const hasChanges = command.joints.some(
|
162 |
+
(joint) => Math.abs((this.lastCommandValues[joint.name] || 0) - joint.value) > 0.5
|
163 |
+
);
|
164 |
+
if (!hasChanges) {
|
165 |
+
console.debug(
|
166 |
+
`[Robot ${this.id}] 🔄 Skipping duplicate command within ${ROBOT_CONFIG.commands.dedupWindow}ms window`
|
167 |
+
);
|
168 |
+
return;
|
169 |
+
}
|
170 |
+
}
|
171 |
+
|
172 |
+
// Update deduplication tracking
|
173 |
+
this.lastCommandTime = now;
|
174 |
+
command.joints.forEach((joint) => {
|
175 |
+
this.lastCommandValues[joint.name] = joint.value;
|
176 |
+
});
|
177 |
+
|
178 |
+
// Queue command if mutex is locked to prevent race conditions
|
179 |
+
if (this.commandMutex) {
|
180 |
+
if (this.pendingCommands.length >= ROBOT_CONFIG.commands.maxQueueSize) {
|
181 |
+
console.warn(`[Robot ${this.id}] Command queue full, dropping oldest command`);
|
182 |
+
this.pendingCommands.shift();
|
183 |
+
}
|
184 |
+
this.pendingCommands.push(command);
|
185 |
+
return;
|
186 |
+
}
|
187 |
+
|
188 |
+
this.commandMutex = true;
|
189 |
+
|
190 |
+
try {
|
191 |
+
console.debug(
|
192 |
+
`[Robot ${this.id}] Executing command with ${command.joints.length} joints:`,
|
193 |
+
command.joints.map((j) => `${j.name}=${j.value}`).join(", ")
|
194 |
+
);
|
195 |
+
|
196 |
+
// Update virtual robot joints with normalized values
|
197 |
+
command.joints.forEach((jointCmd) => {
|
198 |
+
const joint = this.joints[jointCmd.name];
|
199 |
+
if (joint) {
|
200 |
+
// Clamp to appropriate normalized range based on joint type
|
201 |
+
let normalizedValue: number;
|
202 |
+
if (jointCmd.name.toLowerCase() === "jaw" || jointCmd.name.toLowerCase() === "gripper") {
|
203 |
+
normalizedValue = Math.max(0, Math.min(100, jointCmd.value));
|
204 |
+
} else {
|
205 |
+
normalizedValue = Math.max(-100, Math.min(100, jointCmd.value));
|
206 |
+
}
|
207 |
+
|
208 |
+
console.debug(
|
209 |
+
`[Robot ${this.id}] Joint ${jointCmd.name}: ${jointCmd.value} -> ${normalizedValue} (normalized)`
|
210 |
+
);
|
211 |
+
|
212 |
+
// Create a new joint object to ensure reactivity
|
213 |
+
this.joints[jointCmd.name] = { ...joint, value: normalizedValue };
|
214 |
+
} else {
|
215 |
+
console.warn(`[Robot ${this.id}] Joint ${jointCmd.name} not found`);
|
216 |
+
}
|
217 |
+
});
|
218 |
+
|
219 |
+
// Send normalized command to producers
|
220 |
+
this.sendToProducers(command);
|
221 |
+
} finally {
|
222 |
+
this.commandMutex = false;
|
223 |
+
|
224 |
+
// Periodic cleanup to prevent memory leaks
|
225 |
+
const now = Date.now();
|
226 |
+
if (now - this.lastCleanup > ROBOT_CONFIG.performance.memoryCleanupInterval) {
|
227 |
+
// Clear old command values that haven't been updated recently
|
228 |
+
Object.keys(this.lastCommandValues).forEach((jointName) => {
|
229 |
+
if (now - this.lastCommandTime > ROBOT_CONFIG.performance.memoryCleanupInterval) {
|
230 |
+
delete this.lastCommandValues[jointName];
|
231 |
+
}
|
232 |
+
});
|
233 |
+
this.lastCleanup = now;
|
234 |
+
}
|
235 |
+
|
236 |
+
// Process any pending commands
|
237 |
+
if (this.pendingCommands.length > 0) {
|
238 |
+
const nextCommand = this.pendingCommands.shift();
|
239 |
+
if (nextCommand) {
|
240 |
+
// Use setTimeout to prevent stack overflow with rapid commands
|
241 |
+
setTimeout(() => this.executeCommand(nextCommand), 0);
|
242 |
+
}
|
243 |
+
}
|
244 |
+
}
|
245 |
+
}
|
246 |
+
|
247 |
+
// Consumer management (input driver) - SINGLE consumer only
|
248 |
+
async setConsumer(config: USBDriverConfig | RemoteDriverConfig): Promise<string> {
|
249 |
+
return this._setConsumer(config, false);
|
250 |
+
}
|
251 |
+
|
252 |
+
// Join existing room as consumer (for Inference Session integration)
|
253 |
+
async joinAsConsumer(config: RemoteDriverConfig): Promise<string> {
|
254 |
+
if (config.type !== "remote") {
|
255 |
+
throw new Error("joinAsConsumer only supports remote drivers");
|
256 |
+
}
|
257 |
+
return this._setConsumer(config, true);
|
258 |
+
}
|
259 |
+
|
260 |
+
private async _setConsumer(
|
261 |
+
config: USBDriverConfig | RemoteDriverConfig,
|
262 |
+
joinExistingRoom: boolean
|
263 |
+
): Promise<string> {
|
264 |
+
// Remove existing consumer if any
|
265 |
+
if (this.consumer) {
|
266 |
+
await this.removeConsumer();
|
267 |
+
}
|
268 |
+
|
269 |
+
const consumer = this.createConsumer(config);
|
270 |
+
|
271 |
+
// Set up calibration completion callback for USB drivers
|
272 |
+
if (USBServoDriver.isUSBDriver(consumer)) {
|
273 |
+
const calibrationUnsubscribe = consumer.onCalibrationCompleteWithPositions(
|
274 |
+
async (finalPositions: Record<string, number>) => {
|
275 |
+
console.log(`[Robot ${this.id}] Calibration complete, syncing robot to final positions`);
|
276 |
+
consumer.syncRobotPositions(
|
277 |
+
finalPositions,
|
278 |
+
(jointName: string, normalizedValue: number) => {
|
279 |
+
this.updateJointValue(jointName, normalizedValue, false); // Don't send to producers to avoid feedback loop
|
280 |
+
}
|
281 |
+
);
|
282 |
+
|
283 |
+
// Start listening now that calibration is complete
|
284 |
+
if ("startListening" in consumer && consumer.startListening) {
|
285 |
+
try {
|
286 |
+
await consumer.startListening();
|
287 |
+
console.log(`[Robot ${this.id}] Started listening after calibration completion`);
|
288 |
+
} catch (error) {
|
289 |
+
console.error(
|
290 |
+
`[Robot ${this.id}] Failed to start listening after calibration:`,
|
291 |
+
error
|
292 |
+
);
|
293 |
+
}
|
294 |
+
}
|
295 |
+
}
|
296 |
+
);
|
297 |
+
this.unsubscribeFns.push(calibrationUnsubscribe);
|
298 |
+
}
|
299 |
+
|
300 |
+
// Only pass joinExistingRoom to remote drivers
|
301 |
+
if (config.type === "remote") {
|
302 |
+
await (consumer as RemoteConsumer).connect(joinExistingRoom);
|
303 |
+
} else {
|
304 |
+
await consumer.connect();
|
305 |
+
}
|
306 |
+
|
307 |
+
// Set up command listening
|
308 |
+
const commandUnsubscribe = consumer.onCommand((command: RobotCommand) => {
|
309 |
+
this.executeCommand(command);
|
310 |
+
});
|
311 |
+
this.unsubscribeFns.push(commandUnsubscribe);
|
312 |
+
|
313 |
+
// Monitor status changes
|
314 |
+
const statusUnsubscribe = consumer.onStatusChange(() => {
|
315 |
+
this.updateStates();
|
316 |
+
});
|
317 |
+
this.unsubscribeFns.push(statusUnsubscribe);
|
318 |
+
|
319 |
+
// Start listening for consumers with this capability (only if calibrated for USB)
|
320 |
+
if ("startListening" in consumer && consumer.startListening) {
|
321 |
+
// For USB consumers, only start listening if calibrated
|
322 |
+
if (USBServoDriver.isUSBDriver(consumer)) {
|
323 |
+
if (consumer.isCalibrated) {
|
324 |
+
await consumer.startListening();
|
325 |
+
}
|
326 |
+
// If not calibrated, startListening will be called after calibration completion
|
327 |
+
} else {
|
328 |
+
// For non-USB consumers, start listening immediately
|
329 |
+
await consumer.startListening();
|
330 |
+
}
|
331 |
+
}
|
332 |
+
|
333 |
+
this.consumer = consumer;
|
334 |
+
this.updateStates();
|
335 |
+
|
336 |
+
return consumer.id;
|
337 |
+
}
|
338 |
+
|
339 |
+
// Producer management (output drivers) - MULTIPLE allowed
|
340 |
+
async addProducer(config: USBDriverConfig | RemoteDriverConfig): Promise<string> {
|
341 |
+
return this._addProducer(config, false);
|
342 |
+
}
|
343 |
+
|
344 |
+
// Join existing room as producer (for Inference Session integration)
|
345 |
+
async joinAsProducer(config: RemoteDriverConfig): Promise<string> {
|
346 |
+
if (config.type !== "remote") {
|
347 |
+
throw new Error("joinAsProducer only supports remote drivers");
|
348 |
+
}
|
349 |
+
return this._addProducer(config, true);
|
350 |
+
}
|
351 |
+
|
352 |
+
private async _addProducer(
|
353 |
+
config: USBDriverConfig | RemoteDriverConfig,
|
354 |
+
joinExistingRoom: boolean
|
355 |
+
): Promise<string> {
|
356 |
+
const producer = this.createProducer(config);
|
357 |
+
|
358 |
+
// Set up calibration completion callback for USB drivers
|
359 |
+
if (USBServoDriver.isUSBDriver(producer)) {
|
360 |
+
const calibrationUnsubscribe = producer.onCalibrationCompleteWithPositions(
|
361 |
+
async (finalPositions: Record<string, number>) => {
|
362 |
+
console.log(`[Robot ${this.id}] Calibration complete, syncing robot to final positions`);
|
363 |
+
producer.syncRobotPositions(
|
364 |
+
finalPositions,
|
365 |
+
(jointName: string, normalizedValue: number) => {
|
366 |
+
this.updateJointValue(jointName, normalizedValue, false); // Don't send to producers to avoid feedback loop
|
367 |
+
}
|
368 |
+
);
|
369 |
+
|
370 |
+
console.log(
|
371 |
+
`[Robot ${this.id}] USB Producer calibration completed and ready for commands`
|
372 |
+
);
|
373 |
+
}
|
374 |
+
);
|
375 |
+
this.unsubscribeFns.push(calibrationUnsubscribe);
|
376 |
+
}
|
377 |
+
|
378 |
+
// Only pass joinExistingRoom to remote drivers
|
379 |
+
if (config.type === "remote") {
|
380 |
+
await (producer as RemoteProducer).connect(joinExistingRoom);
|
381 |
+
} else {
|
382 |
+
await producer.connect();
|
383 |
+
}
|
384 |
+
|
385 |
+
// Monitor status changes
|
386 |
+
const statusUnsubscribe = producer.onStatusChange(() => {
|
387 |
+
this.updateStates();
|
388 |
+
});
|
389 |
+
this.unsubscribeFns.push(statusUnsubscribe);
|
390 |
+
|
391 |
+
this.producers.push(producer);
|
392 |
+
this.updateStates();
|
393 |
+
|
394 |
+
return producer.id;
|
395 |
+
}
|
396 |
+
|
397 |
+
async removeConsumer(): Promise<void> {
|
398 |
+
if (this.consumer) {
|
399 |
+
// Stop listening for consumers with this capability
|
400 |
+
if ("stopListening" in this.consumer && this.consumer.stopListening) {
|
401 |
+
await this.consumer.stopListening();
|
402 |
+
}
|
403 |
+
await this.consumer.disconnect();
|
404 |
+
|
405 |
+
this.consumer = null;
|
406 |
+
this.updateStates();
|
407 |
+
}
|
408 |
+
}
|
409 |
+
|
410 |
+
async removeProducer(driverId: string): Promise<void> {
|
411 |
+
const driverIndex = this.producers.findIndex((d) => d.id === driverId);
|
412 |
+
if (driverIndex >= 0) {
|
413 |
+
const driver = this.producers[driverIndex];
|
414 |
+
await driver.disconnect();
|
415 |
+
|
416 |
+
this.producers.splice(driverIndex, 1);
|
417 |
+
this.updateStates();
|
418 |
+
}
|
419 |
+
}
|
420 |
+
|
421 |
+
// Private methods
|
422 |
+
private createConsumer(config: USBDriverConfig | RemoteDriverConfig): Consumer {
|
423 |
+
switch (config.type) {
|
424 |
+
case "usb":
|
425 |
+
return new USBConsumer(config);
|
426 |
+
case "remote":
|
427 |
+
return new RemoteConsumer(config);
|
428 |
+
default:
|
429 |
+
const _exhaustive: never = config;
|
430 |
+
throw new Error(`Unknown consumer type: ${JSON.stringify(_exhaustive)}`);
|
431 |
+
}
|
432 |
+
}
|
433 |
+
|
434 |
+
private createProducer(config: USBDriverConfig | RemoteDriverConfig): Producer {
|
435 |
+
switch (config.type) {
|
436 |
+
case "usb":
|
437 |
+
return new USBProducer(config);
|
438 |
+
case "remote":
|
439 |
+
return new RemoteProducer(config);
|
440 |
+
default:
|
441 |
+
const _exhaustive: never = config;
|
442 |
+
throw new Error(`Unknown producer type: ${JSON.stringify(_exhaustive)}`);
|
443 |
+
}
|
444 |
+
}
|
445 |
+
|
446 |
+
// Convert normalized values to URDF radians for 3D visualization
|
447 |
+
convertNormalizedToUrdfRadians(jointName: string, normalizedValue: number): number {
|
448 |
+
const joint = this.joints[jointName];
|
449 |
+
if (!joint?.limits || joint.limits.lower === undefined || joint.limits.upper === undefined) {
|
450 |
+
// Default ranges
|
451 |
+
if (jointName.toLowerCase() === "jaw" || jointName.toLowerCase() === "gripper") {
|
452 |
+
return (normalizedValue / 100) * Math.PI;
|
453 |
+
} else {
|
454 |
+
return (normalizedValue / 100) * Math.PI;
|
455 |
+
}
|
456 |
+
}
|
457 |
+
|
458 |
+
const { lower, upper } = joint.limits;
|
459 |
+
|
460 |
+
// Map normalized value to URDF range
|
461 |
+
let normalizedRatio: number;
|
462 |
+
if (jointName.toLowerCase() === "jaw" || jointName.toLowerCase() === "gripper") {
|
463 |
+
normalizedRatio = normalizedValue / 100; // 0-100 -> 0-1
|
464 |
+
} else {
|
465 |
+
normalizedRatio = (normalizedValue + 100) / 200; // -100-+100 -> 0-1
|
466 |
+
}
|
467 |
+
|
468 |
+
const urdfRadians = lower + normalizedRatio * (upper - lower);
|
469 |
+
|
470 |
+
console.debug(
|
471 |
+
`[Robot ${this.id}] Joint ${jointName}: ${normalizedValue} (norm) -> ${urdfRadians.toFixed(3)} (rad)`
|
472 |
+
);
|
473 |
+
|
474 |
+
return urdfRadians;
|
475 |
+
}
|
476 |
+
|
477 |
+
private async sendToProducers(command: RobotCommand): Promise<void> {
|
478 |
+
const connectedProducers = this.producers.filter((d) => d.status.isConnected);
|
479 |
+
|
480 |
+
console.debug(
|
481 |
+
`[Robot ${this.id}] Sending command to ${connectedProducers.length} producers:`,
|
482 |
+
command
|
483 |
+
);
|
484 |
+
|
485 |
+
// Send to all connected producers
|
486 |
+
await Promise.all(
|
487 |
+
connectedProducers.map(async (producer) => {
|
488 |
+
try {
|
489 |
+
await producer.sendCommand(command);
|
490 |
+
} catch (error) {
|
491 |
+
console.error(
|
492 |
+
`[Robot ${this.id}] Failed to send command to producer ${producer.id}:`,
|
493 |
+
error
|
494 |
+
);
|
495 |
+
}
|
496 |
+
})
|
497 |
+
);
|
498 |
+
}
|
499 |
+
|
500 |
+
private updateStates(): void {
|
501 |
+
// Update connection status
|
502 |
+
const hasConnectedDrivers =
|
503 |
+
this.consumer?.status.isConnected || this.producers.some((d) => d.status.isConnected);
|
504 |
+
|
505 |
+
this.connectionStatus = {
|
506 |
+
isConnected: hasConnectedDrivers,
|
507 |
+
lastConnected: hasConnectedDrivers ? new Date() : this.connectionStatus.lastConnected
|
508 |
+
};
|
509 |
+
|
510 |
+
// Manual control is enabled when no connected consumer
|
511 |
+
this.isManualControlEnabled = !this.consumer?.status.isConnected;
|
512 |
+
}
|
513 |
+
|
514 |
+
// Cleanup
|
515 |
+
async destroy(): Promise<void> {
|
516 |
+
// Unsubscribe from all callbacks
|
517 |
+
this.unsubscribeFns.forEach((fn) => fn());
|
518 |
+
this.unsubscribeFns = [];
|
519 |
+
|
520 |
+
// Disconnect all drivers
|
521 |
+
const allDrivers = [this.consumer, ...this.producers].filter(Boolean) as (
|
522 |
+
| Consumer
|
523 |
+
| Producer
|
524 |
+
)[];
|
525 |
+
await Promise.allSettled(
|
526 |
+
allDrivers.map(async (driver) => {
|
527 |
+
try {
|
528 |
+
await driver.disconnect();
|
529 |
+
} catch (error) {
|
530 |
+
console.error(`Error disconnecting driver ${driver.id}:`, error);
|
531 |
+
}
|
532 |
+
})
|
533 |
+
);
|
534 |
+
|
535 |
+
// Calibration cleanup is handled by individual USB drivers
|
536 |
+
}
|
537 |
+
}
|
src/lib/elements/robot/RobotManager.svelte.ts
CHANGED
@@ -1,262 +1,275 @@
|
|
1 |
-
import { Robot } from
|
2 |
-
import type { JointState, USBDriverConfig, RemoteDriverConfig } from
|
3 |
-
import type { Position3D } from
|
4 |
-
import { createUrdfRobot } from
|
5 |
-
import type { RobotUrdfConfig } from
|
6 |
-
import { generateName } from
|
7 |
-
import { positionManager } from
|
8 |
-
import { settings } from
|
9 |
-
import { robotics } from
|
10 |
-
import type { robotics as roboticsTypes } from
|
11 |
import { robotUrdfConfigMap } from "$lib/configs/robotUrdfConfig";
|
12 |
|
13 |
export class RobotManager {
|
14 |
-
|
15 |
-
|
16 |
-
|
17 |
-
|
18 |
-
|
19 |
-
|
20 |
-
|
21 |
-
|
22 |
-
|
23 |
-
|
24 |
-
|
25 |
-
|
26 |
-
|
27 |
-
|
28 |
-
|
29 |
-
|
30 |
-
|
31 |
-
|
32 |
-
|
33 |
-
|
34 |
-
|
35 |
-
|
36 |
-
|
37 |
-
|
38 |
-
|
39 |
-
|
40 |
-
|
41 |
-
|
42 |
-
|
43 |
-
|
44 |
-
|
45 |
-
|
46 |
-
|
47 |
-
|
48 |
-
|
49 |
-
|
50 |
-
|
51 |
-
|
52 |
-
|
53 |
-
|
54 |
-
|
55 |
-
|
56 |
-
|
57 |
-
|
58 |
-
|
59 |
-
|
60 |
-
|
61 |
-
|
62 |
-
|
63 |
-
|
64 |
-
|
65 |
-
|
66 |
-
|
67 |
-
|
68 |
-
|
69 |
-
|
70 |
-
|
71 |
-
|
72 |
-
|
73 |
-
|
74 |
-
|
75 |
-
|
76 |
-
|
77 |
-
|
78 |
-
|
79 |
-
|
80 |
-
|
81 |
-
|
82 |
-
|
83 |
-
|
84 |
-
|
85 |
-
|
86 |
-
|
87 |
-
|
88 |
-
|
89 |
-
|
90 |
-
|
91 |
-
|
92 |
-
|
93 |
-
|
94 |
-
|
95 |
-
|
96 |
-
|
97 |
-
|
98 |
-
|
99 |
-
|
100 |
-
|
101 |
-
|
102 |
-
|
103 |
-
|
104 |
-
|
105 |
-
|
106 |
-
|
107 |
-
|
108 |
-
|
109 |
-
|
110 |
-
|
111 |
-
|
112 |
-
|
113 |
-
|
114 |
-
|
115 |
-
|
116 |
-
|
117 |
-
|
118 |
-
|
119 |
-
|
120 |
-
|
121 |
-
|
122 |
-
|
123 |
-
|
124 |
-
|
125 |
-
|
126 |
-
|
127 |
-
|
128 |
-
|
129 |
-
|
130 |
-
|
131 |
-
|
132 |
-
|
133 |
-
|
134 |
-
|
135 |
-
|
136 |
-
|
137 |
-
|
138 |
-
|
139 |
-
|
140 |
-
|
141 |
-
|
142 |
-
|
143 |
-
|
144 |
-
|
145 |
-
|
146 |
-
|
147 |
-
|
148 |
-
|
149 |
-
|
150 |
-
|
151 |
-
|
152 |
-
|
153 |
-
|
154 |
-
|
155 |
-
|
156 |
-
|
157 |
-
|
158 |
-
|
159 |
-
|
160 |
-
|
161 |
-
|
162 |
-
|
163 |
-
|
164 |
-
|
165 |
-
|
166 |
-
|
167 |
-
|
168 |
-
|
169 |
-
|
170 |
-
|
171 |
-
|
172 |
-
|
173 |
-
|
174 |
-
|
175 |
-
|
176 |
-
|
177 |
-
|
178 |
-
|
179 |
-
|
180 |
-
|
181 |
-
|
182 |
-
|
183 |
-
|
184 |
-
|
185 |
-
|
186 |
-
|
187 |
-
|
188 |
-
|
189 |
-
|
190 |
-
|
191 |
-
|
192 |
-
|
193 |
-
|
194 |
-
|
195 |
-
|
196 |
-
|
197 |
-
|
198 |
-
|
199 |
-
|
200 |
-
|
201 |
-
|
202 |
-
|
203 |
-
|
204 |
-
|
205 |
-
|
206 |
-
|
207 |
-
|
208 |
-
|
209 |
-
|
210 |
-
|
211 |
-
|
212 |
-
|
213 |
-
|
214 |
-
|
215 |
-
|
216 |
-
|
217 |
-
|
218 |
-
|
219 |
-
|
220 |
-
|
221 |
-
|
222 |
-
|
223 |
-
|
224 |
-
|
225 |
-
|
226 |
-
|
227 |
-
|
228 |
-
|
229 |
-
|
230 |
-
|
231 |
-
|
232 |
-
|
233 |
-
|
234 |
-
|
235 |
-
|
236 |
-
|
237 |
-
|
238 |
-
|
239 |
-
|
240 |
-
|
241 |
-
|
242 |
-
|
243 |
-
|
244 |
-
|
245 |
-
|
246 |
-
|
247 |
-
|
248 |
-
|
249 |
-
|
250 |
-
|
251 |
-
|
252 |
-
|
253 |
-
|
254 |
-
|
255 |
-
|
256 |
-
|
257 |
-
|
258 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
259 |
}
|
260 |
|
261 |
// Global robot manager instance
|
262 |
-
export const robotManager = new RobotManager();
|
|
|
1 |
+
import { Robot } from "./Robot.svelte.js";
|
2 |
+
import type { JointState, USBDriverConfig, RemoteDriverConfig } from "./models.js";
|
3 |
+
import type { Position3D } from "$lib/types/positionable.js";
|
4 |
+
import { createUrdfRobot } from "@/elements/robot/createRobot.svelte.js";
|
5 |
+
import type { RobotUrdfConfig } from "$lib/types/urdf.js";
|
6 |
+
import { generateName } from "$lib/utils/generateName.js";
|
7 |
+
import { positionManager } from "$lib/utils/positionManager.js";
|
8 |
+
import { settings } from "$lib/runes/settings.svelte";
|
9 |
+
import { robotics } from "@robothub/transport-server-client";
|
10 |
+
import type { robotics as roboticsTypes } from "@robothub/transport-server-client";
|
11 |
import { robotUrdfConfigMap } from "$lib/configs/robotUrdfConfig";
|
12 |
|
13 |
export class RobotManager {
|
14 |
+
private _robots = $state<Robot[]>([]);
|
15 |
+
|
16 |
+
// Room management state - using transport server for communication
|
17 |
+
rooms = $state<roboticsTypes.RoomInfo[]>([]);
|
18 |
+
roomsLoading = $state(false);
|
19 |
+
|
20 |
+
// Reactive getters
|
21 |
+
get robots(): Robot[] {
|
22 |
+
return this._robots;
|
23 |
+
}
|
24 |
+
|
25 |
+
get robotCount(): number {
|
26 |
+
return this._robots.length;
|
27 |
+
}
|
28 |
+
|
29 |
+
/**
|
30 |
+
* Room Management Methods
|
31 |
+
*/
|
32 |
+
async listRooms(workspaceId: string): Promise<roboticsTypes.RoomInfo[]> {
|
33 |
+
try {
|
34 |
+
const client = new robotics.RoboticsClientCore(settings.transportServerUrl);
|
35 |
+
const rooms = await client.listRooms(workspaceId);
|
36 |
+
this.rooms = rooms;
|
37 |
+
return rooms;
|
38 |
+
} catch (error) {
|
39 |
+
console.error("Failed to list robotics rooms:", error);
|
40 |
+
return [];
|
41 |
+
}
|
42 |
+
}
|
43 |
+
|
44 |
+
async refreshRooms(workspaceId: string): Promise<void> {
|
45 |
+
this.roomsLoading = true;
|
46 |
+
try {
|
47 |
+
await this.listRooms(workspaceId);
|
48 |
+
} finally {
|
49 |
+
this.roomsLoading = false;
|
50 |
+
}
|
51 |
+
}
|
52 |
+
|
53 |
+
async createRoboticsRoom(
|
54 |
+
workspaceId: string,
|
55 |
+
roomId?: string
|
56 |
+
): Promise<{ success: boolean; roomId?: string; error?: string }> {
|
57 |
+
try {
|
58 |
+
const client = new robotics.RoboticsClientCore(settings.transportServerUrl);
|
59 |
+
const result = await client.createRoom(workspaceId, roomId);
|
60 |
+
// Refresh rooms list to include the new room
|
61 |
+
await this.refreshRooms(workspaceId);
|
62 |
+
return { success: true, roomId: result.roomId };
|
63 |
+
} catch (error) {
|
64 |
+
console.error("Failed to create robotics room:", error);
|
65 |
+
return { success: false, error: error instanceof Error ? error.message : "Unknown error" };
|
66 |
+
}
|
67 |
+
}
|
68 |
+
|
69 |
+
generateRoomId(robotId: string): string {
|
70 |
+
return `${robotId}-${generateName()}`;
|
71 |
+
}
|
72 |
+
|
73 |
+
/**
|
74 |
+
* Connect consumer to an existing robotics room as consumer
|
75 |
+
* This will receive commands from producers in that room
|
76 |
+
*/
|
77 |
+
async connectConsumerToRoom(workspaceId: string, robotId: string, roomId: string): Promise<void> {
|
78 |
+
const robot = this.getRobot(robotId);
|
79 |
+
if (!robot) {
|
80 |
+
throw new Error(`Robot ${robotId} not found`);
|
81 |
+
}
|
82 |
+
|
83 |
+
const config: RemoteDriverConfig = {
|
84 |
+
type: "remote",
|
85 |
+
url: settings.transportServerUrl.replace("http://", "ws://").replace("https://", "wss://"),
|
86 |
+
robotId: roomId,
|
87 |
+
workspaceId: workspaceId
|
88 |
+
};
|
89 |
+
|
90 |
+
// Use joinAsConsumer to join existing room
|
91 |
+
await robot.joinAsConsumer(config);
|
92 |
+
}
|
93 |
+
|
94 |
+
/**
|
95 |
+
* Connect producer to an existing robotics room as producer
|
96 |
+
* This will send commands to consumers in that room
|
97 |
+
*/
|
98 |
+
async connectProducerToRoom(workspaceId: string, robotId: string, roomId: string): Promise<void> {
|
99 |
+
const robot = this.getRobot(robotId);
|
100 |
+
if (!robot) {
|
101 |
+
throw new Error(`Robot ${robotId} not found`);
|
102 |
+
}
|
103 |
+
|
104 |
+
const config: RemoteDriverConfig = {
|
105 |
+
type: "remote",
|
106 |
+
url: settings.transportServerUrl.replace("http://", "ws://").replace("https://", "wss://"),
|
107 |
+
robotId: roomId,
|
108 |
+
workspaceId: workspaceId
|
109 |
+
};
|
110 |
+
|
111 |
+
// Use joinAsProducer to join existing room
|
112 |
+
await robot.joinAsProducer(config);
|
113 |
+
}
|
114 |
+
|
115 |
+
/**
|
116 |
+
* Create and connect producer as producer to a new room
|
117 |
+
*/
|
118 |
+
async connectProducerAsProducer(
|
119 |
+
workspaceId: string,
|
120 |
+
robotId: string,
|
121 |
+
roomId?: string
|
122 |
+
): Promise<{ success: boolean; roomId?: string; error?: string }> {
|
123 |
+
try {
|
124 |
+
// Create room first if roomId provided, otherwise generate one
|
125 |
+
const finalRoomId = roomId || this.generateRoomId(robotId);
|
126 |
+
const createResult = await this.createRoboticsRoom(workspaceId, finalRoomId);
|
127 |
+
|
128 |
+
if (!createResult.success) {
|
129 |
+
return createResult;
|
130 |
+
}
|
131 |
+
|
132 |
+
// Connect producer to the new room
|
133 |
+
await this.connectProducerToRoom(workspaceId, robotId, createResult.roomId!);
|
134 |
+
|
135 |
+
return { success: true, roomId: createResult.roomId };
|
136 |
+
} catch (error) {
|
137 |
+
return { success: false, error: error instanceof Error ? error.message : "Unknown error" };
|
138 |
+
}
|
139 |
+
}
|
140 |
+
|
141 |
+
/**
|
142 |
+
* Create a robot with the default SO-100 arm configuration
|
143 |
+
*/
|
144 |
+
async createSO100Robot(id?: string, position?: Position3D): Promise<Robot> {
|
145 |
+
const robotId = id || `so100-${Date.now()}`;
|
146 |
+
const urdfConfig = robotUrdfConfigMap["so-arm100"];
|
147 |
+
|
148 |
+
return this.createRobotFromUrdf(robotId, urdfConfig, position);
|
149 |
+
}
|
150 |
+
|
151 |
+
/**
|
152 |
+
* Create a new robot directly from URDF configuration - automatically extracts joint limits
|
153 |
+
*/
|
154 |
+
async createRobotFromUrdf(
|
155 |
+
id: string,
|
156 |
+
urdfConfig: RobotUrdfConfig,
|
157 |
+
position?: Position3D
|
158 |
+
): Promise<Robot> {
|
159 |
+
// Check if robot already exists
|
160 |
+
if (this._robots.find((r) => r.id === id)) {
|
161 |
+
throw new Error(`Robot with ID ${id} already exists`);
|
162 |
+
}
|
163 |
+
|
164 |
+
try {
|
165 |
+
// Load and parse URDF
|
166 |
+
const robotState = await createUrdfRobot(urdfConfig);
|
167 |
+
|
168 |
+
// Extract joint information from URDF
|
169 |
+
const joints: JointState[] = [];
|
170 |
+
let servoId = 1; // Auto-assign servo IDs in order
|
171 |
+
|
172 |
+
for (const urdfJoint of robotState.urdfRobot.joints) {
|
173 |
+
// Only include revolute joints (movable joints)
|
174 |
+
if (urdfJoint.type === "revolute" && urdfJoint.name) {
|
175 |
+
const jointState: JointState = {
|
176 |
+
name: urdfJoint.name,
|
177 |
+
value: 0, // Start at center (0%)
|
178 |
+
servoId: servoId++
|
179 |
+
};
|
180 |
+
|
181 |
+
// Extract limits from URDF if available
|
182 |
+
if (urdfJoint.limit) {
|
183 |
+
jointState.limits = {
|
184 |
+
lower: urdfJoint.limit.lower,
|
185 |
+
upper: urdfJoint.limit.upper
|
186 |
+
};
|
187 |
+
}
|
188 |
+
|
189 |
+
joints.push(jointState);
|
190 |
+
}
|
191 |
+
}
|
192 |
+
|
193 |
+
console.log(
|
194 |
+
`Extracted ${joints.length} joints from URDF:`,
|
195 |
+
joints.map(
|
196 |
+
(j) => `${j.name} [${j.limits?.lower?.toFixed(2)}:${j.limits?.upper?.toFixed(2)}]`
|
197 |
+
)
|
198 |
+
);
|
199 |
+
|
200 |
+
// Create robot with extracted joints AND URDF robot state
|
201 |
+
const robot = new Robot(id, joints, robotState.urdfRobot);
|
202 |
+
|
203 |
+
// Set position (from position manager if not provided)
|
204 |
+
robot.position = position || positionManager.getNextPosition();
|
205 |
+
|
206 |
+
// Add to reactive array
|
207 |
+
this._robots.push(robot);
|
208 |
+
|
209 |
+
console.log(`Created robot ${id} from URDF. Total robots: ${this._robots.length}`);
|
210 |
+
return robot;
|
211 |
+
} catch (error) {
|
212 |
+
console.error(`Failed to create robot ${id} from URDF:`, error);
|
213 |
+
throw error;
|
214 |
+
}
|
215 |
+
}
|
216 |
+
|
217 |
+
/**
|
218 |
+
* Create a new robot with joints defined at initialization (for backwards compatibility)
|
219 |
+
*/
|
220 |
+
createRobot(id: string, joints: JointState[], position?: Position3D): Robot {
|
221 |
+
// Check if robot already exists
|
222 |
+
if (this._robots.find((r) => r.id === id)) {
|
223 |
+
throw new Error(`Robot with ID ${id} already exists`);
|
224 |
+
}
|
225 |
+
|
226 |
+
// Create robot
|
227 |
+
const robot = new Robot(id, joints);
|
228 |
+
|
229 |
+
// Set position (from position manager if not provided)
|
230 |
+
robot.position = position || positionManager.getNextPosition();
|
231 |
+
|
232 |
+
// Add to reactive array
|
233 |
+
this._robots.push(robot);
|
234 |
+
|
235 |
+
console.log(`Created robot ${id}. Total robots: ${this._robots.length}`);
|
236 |
+
return robot;
|
237 |
+
}
|
238 |
+
|
239 |
+
/**
|
240 |
+
* Remove a robot
|
241 |
+
*/
|
242 |
+
async removeRobot(id: string): Promise<void> {
|
243 |
+
const robotIndex = this._robots.findIndex((r) => r.id === id);
|
244 |
+
if (robotIndex === -1) return;
|
245 |
+
|
246 |
+
const robot = this._robots[robotIndex];
|
247 |
+
|
248 |
+
// Clean up robot resources
|
249 |
+
await robot.destroy();
|
250 |
+
|
251 |
+
// Remove from reactive array
|
252 |
+
this._robots.splice(robotIndex, 1);
|
253 |
+
|
254 |
+
console.log(`Removed robot ${id}. Remaining robots: ${this._robots.length}`);
|
255 |
+
}
|
256 |
+
|
257 |
+
/**
|
258 |
+
* Get robot by ID
|
259 |
+
*/
|
260 |
+
getRobot(id: string): Robot | undefined {
|
261 |
+
return this._robots.find((r) => r.id === id);
|
262 |
+
}
|
263 |
+
|
264 |
+
/**
|
265 |
+
* Clean up all robots
|
266 |
+
*/
|
267 |
+
async destroy(): Promise<void> {
|
268 |
+
const cleanupPromises = this._robots.map((robot) => robot.destroy());
|
269 |
+
await Promise.allSettled(cleanupPromises);
|
270 |
+
this._robots.length = 0;
|
271 |
+
}
|
272 |
}
|
273 |
|
274 |
// Global robot manager instance
|
275 |
+
export const robotManager = new RobotManager();
|
src/lib/elements/robot/calibration/CalibrationState.svelte.ts
CHANGED
@@ -1,271 +1,292 @@
|
|
1 |
-
import type { JointCalibration } from
|
2 |
-
import { ROBOT_CONFIG } from
|
3 |
|
4 |
export class CalibrationState {
|
5 |
-
|
6 |
-
|
7 |
-
|
8 |
-
|
9 |
-
|
10 |
-
|
11 |
-
|
12 |
-
|
13 |
-
|
14 |
-
|
15 |
-
|
16 |
-
|
17 |
-
|
18 |
-
|
19 |
-
|
20 |
-
|
21 |
-
|
22 |
-
|
23 |
-
|
24 |
-
|
25 |
-
|
26 |
-
|
27 |
-
|
28 |
-
|
29 |
-
|
30 |
-
|
31 |
-
|
32 |
-
|
33 |
-
|
34 |
-
|
35 |
-
|
36 |
-
|
37 |
-
|
38 |
-
|
39 |
-
|
40 |
-
|
41 |
-
|
42 |
-
|
43 |
-
|
44 |
-
|
45 |
-
|
46 |
-
|
47 |
-
|
48 |
-
|
49 |
-
|
50 |
-
|
51 |
-
|
52 |
-
|
53 |
-
|
54 |
-
|
55 |
-
|
56 |
-
|
57 |
-
|
58 |
-
|
59 |
-
|
60 |
-
|
61 |
-
|
62 |
-
|
63 |
-
|
64 |
-
|
65 |
-
|
66 |
-
|
67 |
-
|
68 |
-
|
69 |
-
|
70 |
-
|
71 |
-
|
72 |
-
|
73 |
-
|
74 |
-
|
75 |
-
|
76 |
-
|
77 |
-
|
78 |
-
|
79 |
-
|
80 |
-
|
81 |
-
|
82 |
-
|
83 |
-
|
84 |
-
|
85 |
-
|
86 |
-
|
87 |
-
|
88 |
-
|
89 |
-
|
90 |
-
|
91 |
-
|
92 |
-
|
93 |
-
|
94 |
-
|
95 |
-
|
96 |
-
|
97 |
-
|
98 |
-
|
99 |
-
|
100 |
-
|
101 |
-
|
102 |
-
|
103 |
-
|
104 |
-
|
105 |
-
|
106 |
-
|
107 |
-
|
108 |
-
|
109 |
-
|
110 |
-
|
111 |
-
|
112 |
-
|
113 |
-
|
114 |
-
|
115 |
-
|
116 |
-
|
117 |
-
|
118 |
-
|
119 |
-
|
120 |
-
|
121 |
-
|
122 |
-
|
123 |
-
|
124 |
-
|
125 |
-
|
126 |
-
|
127 |
-
|
128 |
-
|
129 |
-
|
130 |
-
|
131 |
-
|
132 |
-
|
133 |
-
|
134 |
-
|
135 |
-
|
136 |
-
|
137 |
-
|
138 |
-
|
139 |
-
|
140 |
-
|
141 |
-
|
142 |
-
|
143 |
-
|
144 |
-
|
145 |
-
|
146 |
-
|
147 |
-
|
148 |
-
|
149 |
-
|
150 |
-
|
151 |
-
|
152 |
-
|
153 |
-
|
154 |
-
|
155 |
-
|
156 |
-
|
157 |
-
|
158 |
-
|
159 |
-
|
160 |
-
|
161 |
-
|
162 |
-
|
163 |
-
|
164 |
-
|
165 |
-
|
166 |
-
|
167 |
-
|
168 |
-
|
169 |
-
|
170 |
-
|
171 |
-
|
172 |
-
|
173 |
-
|
174 |
-
|
175 |
-
|
176 |
-
|
177 |
-
|
178 |
-
|
179 |
-
|
180 |
-
|
181 |
-
|
182 |
-
|
183 |
-
|
184 |
-
|
185 |
-
|
186 |
-
|
187 |
-
|
188 |
-
|
189 |
-
|
190 |
-
|
191 |
-
|
192 |
-
|
193 |
-
|
194 |
-
|
195 |
-
|
196 |
-
|
197 |
-
|
198 |
-
|
199 |
-
|
200 |
-
|
201 |
-
|
202 |
-
|
203 |
-
|
204 |
-
|
205 |
-
|
206 |
-
|
207 |
-
|
208 |
-
|
209 |
-
|
210 |
-
|
211 |
-
|
212 |
-
|
213 |
-
|
214 |
-
|
215 |
-
|
216 |
-
|
217 |
-
|
218 |
-
|
219 |
-
|
220 |
-
|
221 |
-
|
222 |
-
|
223 |
-
|
224 |
-
|
225 |
-
|
226 |
-
|
227 |
-
|
228 |
-
|
229 |
-
|
230 |
-
|
231 |
-
|
232 |
-
|
233 |
-
|
234 |
-
|
235 |
-
|
236 |
-
|
237 |
-
|
238 |
-
|
239 |
-
|
240 |
-
|
241 |
-
|
242 |
-
|
243 |
-
|
244 |
-
|
245 |
-
|
246 |
-
|
247 |
-
|
248 |
-
|
249 |
-
|
250 |
-
|
251 |
-
|
252 |
-
|
253 |
-
|
254 |
-
|
255 |
-
|
256 |
-
|
257 |
-
|
258 |
-
|
259 |
-
|
260 |
-
|
261 |
-
|
262 |
-
|
263 |
-
|
264 |
-
|
265 |
-
|
266 |
-
|
267 |
-
|
268 |
-
|
269 |
-
|
270 |
-
|
271 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import type { JointCalibration } from "../models.js";
|
2 |
+
import { ROBOT_CONFIG } from "../config.js";
|
3 |
|
4 |
export class CalibrationState {
|
5 |
+
// Reactive calibration state
|
6 |
+
isCalibrating = $state(false);
|
7 |
+
progress = $state(0);
|
8 |
+
|
9 |
+
// Joint calibration data
|
10 |
+
private jointCalibrations = $state<Record<string, JointCalibration>>({});
|
11 |
+
private currentValues = $state<Record<string, number>>({});
|
12 |
+
|
13 |
+
// Callbacks for completion with final positions
|
14 |
+
private completionCallbacks: Array<(positions: Record<string, number>) => void> = [];
|
15 |
+
|
16 |
+
constructor() {
|
17 |
+
// Initialize calibration data for expected joints
|
18 |
+
const jointNames = ["Rotation", "Pitch", "Elbow", "Wrist_Pitch", "Wrist_Roll", "Jaw"];
|
19 |
+
jointNames.forEach((name) => {
|
20 |
+
this.jointCalibrations[name] = {
|
21 |
+
isCalibrated: false,
|
22 |
+
minServoValue: undefined,
|
23 |
+
maxServoValue: undefined
|
24 |
+
};
|
25 |
+
this.currentValues[name] = 0;
|
26 |
+
});
|
27 |
+
}
|
28 |
+
|
29 |
+
// Computed properties
|
30 |
+
get needsCalibration(): boolean {
|
31 |
+
return Object.values(this.jointCalibrations).some((cal) => !cal.isCalibrated);
|
32 |
+
}
|
33 |
+
|
34 |
+
get isCalibrated(): boolean {
|
35 |
+
return Object.values(this.jointCalibrations).every((cal) => cal.isCalibrated);
|
36 |
+
}
|
37 |
+
|
38 |
+
// Update current servo value during calibration
|
39 |
+
updateCurrentValue(jointName: string, servoValue: number): void {
|
40 |
+
this.currentValues[jointName] = servoValue;
|
41 |
+
|
42 |
+
// Update calibration range if calibrating
|
43 |
+
if (this.isCalibrating) {
|
44 |
+
const calibration = this.jointCalibrations[jointName];
|
45 |
+
if (calibration) {
|
46 |
+
// Update min/max values
|
47 |
+
if (calibration.minServoValue === undefined || servoValue < calibration.minServoValue) {
|
48 |
+
calibration.minServoValue = servoValue;
|
49 |
+
}
|
50 |
+
if (calibration.maxServoValue === undefined || servoValue > calibration.maxServoValue) {
|
51 |
+
calibration.maxServoValue = servoValue;
|
52 |
+
}
|
53 |
+
|
54 |
+
// Update progress based on range coverage
|
55 |
+
this.updateProgress();
|
56 |
+
}
|
57 |
+
}
|
58 |
+
}
|
59 |
+
|
60 |
+
// Get current value for a joint
|
61 |
+
getCurrentValue(jointName: string): number | undefined {
|
62 |
+
return this.currentValues[jointName];
|
63 |
+
}
|
64 |
+
|
65 |
+
// Get calibration data for a joint
|
66 |
+
getJointCalibration(jointName: string): JointCalibration | undefined {
|
67 |
+
return this.jointCalibrations[jointName];
|
68 |
+
}
|
69 |
+
|
70 |
+
// Get formatted range string for display
|
71 |
+
getJointRange(jointName: string): string {
|
72 |
+
const calibration = this.jointCalibrations[jointName];
|
73 |
+
if (
|
74 |
+
!calibration ||
|
75 |
+
calibration.minServoValue === undefined ||
|
76 |
+
calibration.maxServoValue === undefined
|
77 |
+
) {
|
78 |
+
return "Not set";
|
79 |
+
}
|
80 |
+
return `${calibration.minServoValue}-${calibration.maxServoValue}`;
|
81 |
+
}
|
82 |
+
|
83 |
+
// Format servo value for display
|
84 |
+
formatServoValue(value: number | undefined): string {
|
85 |
+
return value !== undefined ? value.toString() : "---";
|
86 |
+
}
|
87 |
+
|
88 |
+
// Start calibration process
|
89 |
+
startCalibration(): void {
|
90 |
+
console.log("[CalibrationState] Starting calibration...");
|
91 |
+
this.isCalibrating = true;
|
92 |
+
this.progress = 0;
|
93 |
+
|
94 |
+
// Reset calibration data
|
95 |
+
Object.keys(this.jointCalibrations).forEach((jointName) => {
|
96 |
+
this.jointCalibrations[jointName] = {
|
97 |
+
isCalibrated: false,
|
98 |
+
minServoValue: undefined,
|
99 |
+
maxServoValue: undefined
|
100 |
+
};
|
101 |
+
});
|
102 |
+
}
|
103 |
+
|
104 |
+
// Complete calibration and mark joints as calibrated
|
105 |
+
completeCalibration(): Record<string, number> {
|
106 |
+
console.log("[CalibrationState] Completing calibration...");
|
107 |
+
|
108 |
+
const finalPositions: Record<string, number> = {};
|
109 |
+
|
110 |
+
// Mark all joints with sufficient range as calibrated
|
111 |
+
Object.keys(this.jointCalibrations).forEach((jointName) => {
|
112 |
+
const calibration = this.jointCalibrations[jointName];
|
113 |
+
if (calibration.minServoValue !== undefined && calibration.maxServoValue !== undefined) {
|
114 |
+
const range = calibration.maxServoValue - calibration.minServoValue;
|
115 |
+
if (range >= ROBOT_CONFIG.calibration.minRangeThreshold) {
|
116 |
+
calibration.isCalibrated = true;
|
117 |
+
finalPositions[jointName] = this.currentValues[jointName] || 0;
|
118 |
+
console.log(
|
119 |
+
`[CalibrationState] Joint ${jointName} calibrated: ${this.getJointRange(jointName)} (range: ${range})`
|
120 |
+
);
|
121 |
+
} else {
|
122 |
+
console.warn(
|
123 |
+
`[CalibrationState] Joint ${jointName} range too small: ${range} < ${ROBOT_CONFIG.calibration.minRangeThreshold}`
|
124 |
+
);
|
125 |
+
}
|
126 |
+
}
|
127 |
+
});
|
128 |
+
|
129 |
+
this.isCalibrating = false;
|
130 |
+
this.progress = 100;
|
131 |
+
|
132 |
+
// Notify completion callbacks
|
133 |
+
this.completionCallbacks.forEach((callback) => {
|
134 |
+
try {
|
135 |
+
callback(finalPositions);
|
136 |
+
} catch (error) {
|
137 |
+
console.error("[CalibrationState] Error in completion callback:", error);
|
138 |
+
}
|
139 |
+
});
|
140 |
+
|
141 |
+
return finalPositions;
|
142 |
+
}
|
143 |
+
|
144 |
+
// Cancel calibration
|
145 |
+
cancelCalibration(): void {
|
146 |
+
console.log("[CalibrationState] Calibration cancelled");
|
147 |
+
this.isCalibrating = false;
|
148 |
+
this.progress = 0;
|
149 |
+
|
150 |
+
// Reset calibration data
|
151 |
+
Object.keys(this.jointCalibrations).forEach((jointName) => {
|
152 |
+
this.jointCalibrations[jointName] = {
|
153 |
+
isCalibrated: false,
|
154 |
+
minServoValue: undefined,
|
155 |
+
maxServoValue: undefined
|
156 |
+
};
|
157 |
+
});
|
158 |
+
}
|
159 |
+
|
160 |
+
// Skip calibration (use predefined values)
|
161 |
+
skipCalibration(): void {
|
162 |
+
console.log("[CalibrationState] Skipping calibration with predefined values");
|
163 |
+
|
164 |
+
// Set predefined calibration values for SO-100 arm
|
165 |
+
const predefinedCalibrations = {
|
166 |
+
Rotation: { minServoValue: 500, maxServoValue: 3500, isCalibrated: true },
|
167 |
+
Pitch: { minServoValue: 500, maxServoValue: 3500, isCalibrated: true },
|
168 |
+
Elbow: { minServoValue: 500, maxServoValue: 3500, isCalibrated: true },
|
169 |
+
Wrist_Pitch: { minServoValue: 500, maxServoValue: 3500, isCalibrated: true },
|
170 |
+
Wrist_Roll: { minServoValue: 500, maxServoValue: 3500, isCalibrated: true },
|
171 |
+
Jaw: { minServoValue: 1000, maxServoValue: 3000, isCalibrated: true }
|
172 |
+
};
|
173 |
+
|
174 |
+
Object.entries(predefinedCalibrations).forEach(([jointName, calibration]) => {
|
175 |
+
this.jointCalibrations[jointName] = calibration;
|
176 |
+
});
|
177 |
+
|
178 |
+
this.isCalibrating = false;
|
179 |
+
this.progress = 100;
|
180 |
+
}
|
181 |
+
|
182 |
+
// Convert raw servo value to normalized percentage (for USB INPUT - reading from servo)
|
183 |
+
normalizeValue(rawValue: number, jointName: string): number {
|
184 |
+
const calibration = this.jointCalibrations[jointName];
|
185 |
+
if (
|
186 |
+
!calibration ||
|
187 |
+
!calibration.isCalibrated ||
|
188 |
+
calibration.minServoValue === undefined ||
|
189 |
+
calibration.maxServoValue === undefined
|
190 |
+
) {
|
191 |
+
// No calibration, use appropriate default conversion
|
192 |
+
const isGripper = jointName.toLowerCase() === "jaw" || jointName.toLowerCase() === "gripper";
|
193 |
+
if (isGripper) {
|
194 |
+
return Math.max(0, Math.min(100, (rawValue / 4095) * 100));
|
195 |
+
} else {
|
196 |
+
return Math.max(-100, Math.min(100, ((rawValue - 2048) / 2048) * 100));
|
197 |
+
}
|
198 |
+
}
|
199 |
+
|
200 |
+
const { minServoValue, maxServoValue } = calibration;
|
201 |
+
if (maxServoValue === minServoValue) return 0;
|
202 |
+
|
203 |
+
// Bound the input servo value to calibrated range
|
204 |
+
const bounded = Math.max(minServoValue, Math.min(maxServoValue, rawValue));
|
205 |
+
|
206 |
+
const isGripper = jointName.toLowerCase() === "jaw" || jointName.toLowerCase() === "gripper";
|
207 |
+
if (isGripper) {
|
208 |
+
// Gripper: 0-100%
|
209 |
+
return ((bounded - minServoValue) / (maxServoValue - minServoValue)) * 100;
|
210 |
+
} else {
|
211 |
+
// Regular joint: -100 to +100%
|
212 |
+
return ((bounded - minServoValue) / (maxServoValue - minServoValue)) * 200 - 100;
|
213 |
+
}
|
214 |
+
}
|
215 |
+
|
216 |
+
// Convert normalized percentage to raw servo value (for USB OUTPUT - writing to servo)
|
217 |
+
denormalizeValue(normalizedValue: number, jointName: string): number {
|
218 |
+
const calibration = this.jointCalibrations[jointName];
|
219 |
+
if (
|
220 |
+
!calibration ||
|
221 |
+
!calibration.isCalibrated ||
|
222 |
+
calibration.minServoValue === undefined ||
|
223 |
+
calibration.maxServoValue === undefined
|
224 |
+
) {
|
225 |
+
// No calibration, use appropriate default conversion
|
226 |
+
const isGripper = jointName.toLowerCase() === "jaw" || jointName.toLowerCase() === "gripper";
|
227 |
+
if (isGripper) {
|
228 |
+
return Math.round((normalizedValue / 100) * 4095);
|
229 |
+
} else {
|
230 |
+
return Math.round(2048 + (normalizedValue / 100) * 2048);
|
231 |
+
}
|
232 |
+
}
|
233 |
+
|
234 |
+
const { minServoValue, maxServoValue } = calibration;
|
235 |
+
const range = maxServoValue - minServoValue;
|
236 |
+
|
237 |
+
let normalizedRatio: number;
|
238 |
+
const isGripper = jointName.toLowerCase() === "jaw" || jointName.toLowerCase() === "gripper";
|
239 |
+
if (isGripper) {
|
240 |
+
// Gripper: 0-100% -> 0-1
|
241 |
+
normalizedRatio = Math.max(0, Math.min(1, normalizedValue / 100));
|
242 |
+
} else {
|
243 |
+
// Regular joint: -100 to +100% -> 0-1
|
244 |
+
normalizedRatio = Math.max(0, Math.min(1, (normalizedValue + 100) / 200));
|
245 |
+
}
|
246 |
+
|
247 |
+
return Math.round(minServoValue + normalizedRatio * range);
|
248 |
+
}
|
249 |
+
|
250 |
+
// Register callback for calibration completion with final positions
|
251 |
+
onCalibrationCompleteWithPositions(
|
252 |
+
callback: (positions: Record<string, number>) => void
|
253 |
+
): () => void {
|
254 |
+
this.completionCallbacks.push(callback);
|
255 |
+
|
256 |
+
// Return unsubscribe function
|
257 |
+
return () => {
|
258 |
+
const index = this.completionCallbacks.indexOf(callback);
|
259 |
+
if (index >= 0) {
|
260 |
+
this.completionCallbacks.splice(index, 1);
|
261 |
+
}
|
262 |
+
};
|
263 |
+
}
|
264 |
+
|
265 |
+
// Update progress based on calibration coverage
|
266 |
+
private updateProgress(): void {
|
267 |
+
if (!this.isCalibrating) return;
|
268 |
+
|
269 |
+
let totalProgress = 0;
|
270 |
+
let jointCount = 0;
|
271 |
+
|
272 |
+
Object.values(this.jointCalibrations).forEach((calibration) => {
|
273 |
+
jointCount++;
|
274 |
+
if (calibration.minServoValue !== undefined && calibration.maxServoValue !== undefined) {
|
275 |
+
const range = calibration.maxServoValue - calibration.minServoValue;
|
276 |
+
// Progress is based on range size (more range = more progress)
|
277 |
+
const jointProgress = Math.min(
|
278 |
+
100,
|
279 |
+
(range / ROBOT_CONFIG.calibration.minRangeThreshold) * 100
|
280 |
+
);
|
281 |
+
totalProgress += jointProgress;
|
282 |
+
}
|
283 |
+
});
|
284 |
+
|
285 |
+
this.progress = jointCount > 0 ? totalProgress / jointCount : 0;
|
286 |
+
}
|
287 |
+
|
288 |
+
// Cleanup
|
289 |
+
destroy(): void {
|
290 |
+
this.completionCallbacks = [];
|
291 |
+
}
|
292 |
+
}
|
src/lib/elements/robot/calibration/USBCalibrationPanel.svelte
CHANGED
@@ -39,7 +39,6 @@
|
|
39 |
}
|
40 |
);
|
41 |
|
42 |
-
|
43 |
async function startCalibration() {
|
44 |
await calibrationManager.startCalibration();
|
45 |
}
|
@@ -109,7 +108,8 @@
|
|
109 |
<div class="grid grid-cols-2 gap-2">
|
110 |
{#each jointNames as jointName}
|
111 |
{@const currentValue = calibrationManager.calibrationState.getCurrentValue(jointName)}
|
112 |
-
{@const calibration =
|
|
|
113 |
|
114 |
<div class="space-y-1 rounded bg-slate-700/50 p-2">
|
115 |
<div class="flex items-center justify-between">
|
@@ -120,8 +120,16 @@
|
|
120 |
</div>
|
121 |
|
122 |
<div class="flex justify-between text-xs text-slate-500">
|
123 |
-
<span
|
124 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
125 |
</div>
|
126 |
|
127 |
{#if calibration?.minServoValue !== undefined && calibration?.maxServoValue !== undefined && currentValue !== undefined}
|
@@ -167,7 +175,8 @@
|
|
167 |
<div class="max-h-32 overflow-y-auto">
|
168 |
<div class="grid grid-cols-2 gap-1">
|
169 |
{#each jointNames as jointName}
|
170 |
-
{@const calibration =
|
|
|
171 |
{@const range = calibrationManager.calibrationState.getJointRange(jointName)}
|
172 |
|
173 |
<div class="flex items-center justify-between rounded bg-slate-700/30 p-2 text-xs">
|
|
|
39 |
}
|
40 |
);
|
41 |
|
|
|
42 |
async function startCalibration() {
|
43 |
await calibrationManager.startCalibration();
|
44 |
}
|
|
|
108 |
<div class="grid grid-cols-2 gap-2">
|
109 |
{#each jointNames as jointName}
|
110 |
{@const currentValue = calibrationManager.calibrationState.getCurrentValue(jointName)}
|
111 |
+
{@const calibration =
|
112 |
+
calibrationManager.calibrationState.getJointCalibration(jointName)}
|
113 |
|
114 |
<div class="space-y-1 rounded bg-slate-700/50 p-2">
|
115 |
<div class="flex items-center justify-between">
|
|
|
120 |
</div>
|
121 |
|
122 |
<div class="flex justify-between text-xs text-slate-500">
|
123 |
+
<span
|
124 |
+
>Min: {calibrationManager.calibrationState.formatServoValue(
|
125 |
+
calibration?.minServoValue
|
126 |
+
)}</span
|
127 |
+
>
|
128 |
+
<span
|
129 |
+
>Max: {calibrationManager.calibrationState.formatServoValue(
|
130 |
+
calibration?.maxServoValue
|
131 |
+
)}</span
|
132 |
+
>
|
133 |
</div>
|
134 |
|
135 |
{#if calibration?.minServoValue !== undefined && calibration?.maxServoValue !== undefined && currentValue !== undefined}
|
|
|
175 |
<div class="max-h-32 overflow-y-auto">
|
176 |
<div class="grid grid-cols-2 gap-1">
|
177 |
{#each jointNames as jointName}
|
178 |
+
{@const calibration =
|
179 |
+
calibrationManager.calibrationState.getJointCalibration(jointName)}
|
180 |
{@const range = calibrationManager.calibrationState.getJointRange(jointName)}
|
181 |
|
182 |
<div class="flex items-center justify-between rounded bg-slate-700/30 p-2 text-xs">
|
src/lib/elements/robot/calibration/index.ts
CHANGED
@@ -1,2 +1,2 @@
|
|
1 |
-
export { CalibrationState } from
|
2 |
-
export { default as USBCalibrationPanel } from
|
|
|
1 |
+
export { CalibrationState } from "./CalibrationState.svelte.js";
|
2 |
+
export { default as USBCalibrationPanel } from "./USBCalibrationPanel.svelte";
|
src/lib/elements/robot/components/ConnectionPanel.svelte
CHANGED
@@ -42,11 +42,11 @@
|
|
42 |
// Find USB driver for calibration (if any)
|
43 |
function getUSBDriver(): any {
|
44 |
// Check consumer first
|
45 |
-
if (robot.consumer &&
|
46 |
return robot.consumer;
|
47 |
}
|
48 |
// Then check producers
|
49 |
-
return robot.producers.find(p =>
|
50 |
}
|
51 |
|
52 |
async function connectUSBConsumer() {
|
@@ -62,7 +62,7 @@
|
|
62 |
} catch (err) {
|
63 |
console.error("Failed to connect USB consumer:", err);
|
64 |
// Check if it's a calibration error
|
65 |
-
if (err instanceof Error && err.message.includes(
|
66 |
pendingUSBConnection = "consumer";
|
67 |
showUSBCalibration = true;
|
68 |
return;
|
@@ -104,7 +104,7 @@
|
|
104 |
} catch (err) {
|
105 |
console.error("Failed to connect USB producer:", err);
|
106 |
// Check if it's a calibration error
|
107 |
-
if (err instanceof Error && err.message.includes(
|
108 |
pendingUSBConnection = "producer";
|
109 |
showUSBCalibration = true;
|
110 |
return;
|
@@ -545,9 +545,7 @@
|
|
545 |
onCancel={onCalibrationCancel}
|
546 |
/>
|
547 |
{:else}
|
548 |
-
<div class="text-center text-slate-400">
|
549 |
-
No USB driver available for calibration
|
550 |
-
</div>
|
551 |
{/if}
|
552 |
</div>
|
553 |
</div>
|
|
|
42 |
// Find USB driver for calibration (if any)
|
43 |
function getUSBDriver(): any {
|
44 |
// Check consumer first
|
45 |
+
if (robot.consumer && "calibrationState" in robot.consumer) {
|
46 |
return robot.consumer;
|
47 |
}
|
48 |
// Then check producers
|
49 |
+
return robot.producers.find((p) => "calibrationState" in p) || null;
|
50 |
}
|
51 |
|
52 |
async function connectUSBConsumer() {
|
|
|
62 |
} catch (err) {
|
63 |
console.error("Failed to connect USB consumer:", err);
|
64 |
// Check if it's a calibration error
|
65 |
+
if (err instanceof Error && err.message.includes("calibration")) {
|
66 |
pendingUSBConnection = "consumer";
|
67 |
showUSBCalibration = true;
|
68 |
return;
|
|
|
104 |
} catch (err) {
|
105 |
console.error("Failed to connect USB producer:", err);
|
106 |
// Check if it's a calibration error
|
107 |
+
if (err instanceof Error && err.message.includes("calibration")) {
|
108 |
pendingUSBConnection = "producer";
|
109 |
showUSBCalibration = true;
|
110 |
return;
|
|
|
545 |
onCancel={onCalibrationCancel}
|
546 |
/>
|
547 |
{:else}
|
548 |
+
<div class="text-center text-slate-400">No USB driver available for calibration</div>
|
|
|
|
|
549 |
{/if}
|
550 |
</div>
|
551 |
</div>
|
src/lib/elements/robot/components/RobotGrid.svelte
CHANGED
@@ -1,106 +1,111 @@
|
|
1 |
<script lang="ts">
|
2 |
-
|
3 |
-
|
4 |
-
|
5 |
-
|
6 |
-
|
7 |
-
|
8 |
-
let selectedRobot = $state<Robot | null>(null);
|
9 |
-
let showConnectionModal = $state(false);
|
10 |
-
let modalType = $state<'consumer' | 'producer' | 'manual'>('consumer');
|
11 |
|
12 |
-
|
13 |
-
|
14 |
-
|
15 |
-
showConnectionModal = true;
|
16 |
-
}
|
17 |
|
18 |
-
|
19 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
20 |
</script>
|
21 |
|
22 |
<T.Group>
|
23 |
-
|
24 |
-
|
25 |
-
|
26 |
-
onInteract={handleRobotClick}
|
27 |
-
/>
|
28 |
-
{/each}
|
29 |
</T.Group>
|
30 |
|
31 |
<!-- Connection modal will be added here -->
|
32 |
{#if showConnectionModal && selectedRobot}
|
33 |
-
|
34 |
-
|
35 |
-
|
36 |
-
|
37 |
-
|
38 |
-
|
39 |
-
|
40 |
-
|
41 |
-
|
42 |
-
|
43 |
-
|
44 |
-
|
45 |
-
|
46 |
-
|
47 |
-
|
48 |
-
|
49 |
-
|
50 |
-
|
51 |
-
|
52 |
-
|
53 |
-
|
54 |
-
|
55 |
-
|
56 |
-
|
57 |
-
|
58 |
-
|
59 |
-
|
60 |
-
|
61 |
-
|
62 |
-
|
63 |
-
|
64 |
-
|
65 |
-
|
66 |
-
|
67 |
-
|
68 |
-
|
69 |
-
|
70 |
-
|
71 |
-
|
72 |
-
|
73 |
-
|
74 |
-
|
75 |
-
|
76 |
-
|
77 |
-
|
78 |
-
|
79 |
-
|
80 |
-
|
81 |
-
|
82 |
-
|
83 |
-
|
84 |
-
|
85 |
-
|
86 |
-
|
87 |
-
|
88 |
-
|
89 |
-
|
90 |
-
|
91 |
-
|
92 |
-
|
93 |
-
|
94 |
-
|
95 |
-
|
96 |
-
|
97 |
-
|
98 |
-
|
99 |
-
|
100 |
-
|
101 |
-
|
102 |
-
|
103 |
-
|
104 |
-
|
105 |
-
|
106 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
<script lang="ts">
|
2 |
+
import { T } from "@threlte/core";
|
3 |
+
import { robotManager } from "../RobotManager.svelte.js";
|
4 |
+
import { settings } from "$lib/runes/settings.svelte";
|
5 |
+
import RobotItem from "./RobotItem.svelte";
|
6 |
+
import type { Robot } from "../Robot.svelte.js";
|
|
|
|
|
|
|
|
|
7 |
|
8 |
+
let selectedRobot = $state<Robot | null>(null);
|
9 |
+
let showConnectionModal = $state(false);
|
10 |
+
let modalType = $state<"consumer" | "producer" | "manual">("consumer");
|
|
|
|
|
11 |
|
12 |
+
function handleRobotClick(robot: Robot, type: "consumer" | "producer" | "manual") {
|
13 |
+
selectedRobot = robot;
|
14 |
+
modalType = type;
|
15 |
+
showConnectionModal = true;
|
16 |
+
}
|
17 |
+
|
18 |
+
// Access reactive robots
|
19 |
+
const robots = $derived(robotManager.robots);
|
20 |
</script>
|
21 |
|
22 |
<T.Group>
|
23 |
+
{#each robots as robot (robot.id)}
|
24 |
+
<RobotItem {robot} onInteract={handleRobotClick} />
|
25 |
+
{/each}
|
|
|
|
|
|
|
26 |
</T.Group>
|
27 |
|
28 |
<!-- Connection modal will be added here -->
|
29 |
{#if showConnectionModal && selectedRobot}
|
30 |
+
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
31 |
+
<div class="m-4 w-full max-w-md space-y-4 rounded-lg bg-slate-800 p-6">
|
32 |
+
<div class="flex items-center justify-between">
|
33 |
+
<h2 class="text-lg font-semibold text-white">
|
34 |
+
{modalType === "consumer"
|
35 |
+
? "Consumer Driver"
|
36 |
+
: modalType === "producer"
|
37 |
+
? "Producer Drivers"
|
38 |
+
: "Manual Control"}
|
39 |
+
</h2>
|
40 |
+
<button
|
41 |
+
onclick={() => (showConnectionModal = false)}
|
42 |
+
class="text-gray-400 hover:text-white"
|
43 |
+
>
|
44 |
+
✕
|
45 |
+
</button>
|
46 |
+
</div>
|
47 |
+
|
48 |
+
<div class="space-y-3">
|
49 |
+
{#if modalType === "consumer"}
|
50 |
+
<button
|
51 |
+
onclick={async () => {
|
52 |
+
await selectedRobot?.setConsumer({ type: "usb", baudRate: 1000000 });
|
53 |
+
showConnectionModal = false;
|
54 |
+
}}
|
55 |
+
class="w-full rounded-md bg-blue-600 px-4 py-2 text-white hover:bg-blue-700"
|
56 |
+
>
|
57 |
+
Connect USB Consumer
|
58 |
+
</button>
|
59 |
+
<button
|
60 |
+
onclick={async () => {
|
61 |
+
await selectedRobot?.setConsumer({
|
62 |
+
type: "remote",
|
63 |
+
url: settings.transportServerUrl
|
64 |
+
.replace("http://", "ws://")
|
65 |
+
.replace("https://", "wss://"),
|
66 |
+
robotId: selectedRobot.id
|
67 |
+
});
|
68 |
+
showConnectionModal = false;
|
69 |
+
}}
|
70 |
+
class="w-full rounded-md bg-purple-600 px-4 py-2 text-white hover:bg-purple-700"
|
71 |
+
>
|
72 |
+
Connect Transport Consumer
|
73 |
+
</button>
|
74 |
+
{:else if modalType === "producer"}
|
75 |
+
<button
|
76 |
+
onclick={async () => {
|
77 |
+
await selectedRobot?.addProducer({ type: "usb", baudRate: 1000000 });
|
78 |
+
showConnectionModal = false;
|
79 |
+
}}
|
80 |
+
class="w-full rounded-md bg-green-600 px-4 py-2 text-white hover:bg-green-700"
|
81 |
+
>
|
82 |
+
Connect USB Producer
|
83 |
+
</button>
|
84 |
+
<button
|
85 |
+
onclick={async () => {
|
86 |
+
await selectedRobot?.addProducer({
|
87 |
+
type: "remote",
|
88 |
+
url: settings.transportServerUrl
|
89 |
+
.replace("http://", "ws://")
|
90 |
+
.replace("https://", "wss://"),
|
91 |
+
robotId: selectedRobot.id
|
92 |
+
});
|
93 |
+
showConnectionModal = false;
|
94 |
+
}}
|
95 |
+
class="w-full rounded-md bg-orange-600 px-4 py-2 text-white hover:bg-orange-700"
|
96 |
+
>
|
97 |
+
Connect Transport Producer
|
98 |
+
</button>
|
99 |
+
{:else}
|
100 |
+
<p class="text-gray-300">Manual control interface would go here</p>
|
101 |
+
{/if}
|
102 |
+
</div>
|
103 |
+
|
104 |
+
<div class="text-center text-xs text-slate-500">
|
105 |
+
{#if modalType !== "manual"}
|
106 |
+
Note: USB connections will prompt for calibration if needed
|
107 |
+
{/if}
|
108 |
+
</div>
|
109 |
+
</div>
|
110 |
+
</div>
|
111 |
+
{/if}
|
src/lib/elements/robot/components/RobotItem.svelte
CHANGED
@@ -1,205 +1,217 @@
|
|
1 |
<script lang="ts">
|
2 |
-
|
3 |
-
|
4 |
-
|
5 |
-
|
6 |
-
|
7 |
-
|
8 |
-
|
9 |
-
|
10 |
-
|
11 |
-
|
12 |
-
|
13 |
-
|
14 |
-
|
15 |
-
|
16 |
-
|
17 |
-
|
18 |
-
|
19 |
-
|
20 |
-
|
21 |
-
|
22 |
-
|
23 |
-
|
24 |
-
|
25 |
-
|
26 |
-
|
27 |
-
|
28 |
-
|
29 |
-
|
30 |
-
|
31 |
-
|
32 |
-
|
33 |
-
|
34 |
-
|
35 |
-
|
36 |
-
|
37 |
-
|
38 |
-
|
39 |
-
|
40 |
-
|
41 |
-
|
42 |
-
|
43 |
-
|
44 |
-
|
45 |
-
|
46 |
-
|
47 |
-
|
48 |
-
|
49 |
-
|
50 |
-
|
51 |
-
|
52 |
-
|
53 |
-
|
54 |
-
|
55 |
-
|
56 |
-
|
57 |
-
|
58 |
-
|
59 |
-
|
60 |
-
|
61 |
-
|
62 |
-
|
63 |
-
|
64 |
-
|
65 |
-
|
66 |
-
|
67 |
-
|
68 |
-
|
69 |
-
|
70 |
-
|
71 |
-
|
72 |
-
|
73 |
-
|
74 |
-
|
75 |
-
|
76 |
-
|
77 |
-
|
78 |
-
|
79 |
-
|
80 |
-
|
81 |
-
|
82 |
-
|
83 |
-
|
84 |
-
|
85 |
-
|
86 |
-
|
87 |
-
|
88 |
-
|
89 |
-
|
90 |
-
|
91 |
-
|
92 |
-
|
|
|
|
|
|
|
|
|
93 |
</script>
|
94 |
|
95 |
<T.Group
|
96 |
-
|
97 |
-
|
98 |
-
|
99 |
-
|
100 |
-
|
101 |
>
|
102 |
-
|
103 |
-
|
104 |
-
|
105 |
-
|
106 |
-
|
107 |
-
|
108 |
-
|
109 |
-
|
110 |
-
|
111 |
-
|
112 |
-
|
113 |
-
|
114 |
-
|
115 |
-
|
116 |
-
|
117 |
-
|
118 |
-
|
119 |
-
|
120 |
-
|
121 |
-
|
122 |
-
|
123 |
-
|
124 |
-
|
125 |
-
|
126 |
-
|
127 |
-
|
128 |
-
|
129 |
-
|
130 |
-
|
131 |
-
|
132 |
-
|
133 |
-
|
134 |
-
|
135 |
-
|
136 |
-
|
137 |
-
|
138 |
-
|
139 |
-
|
140 |
-
|
141 |
-
|
142 |
-
|
143 |
-
|
144 |
-
|
145 |
-
|
146 |
-
|
147 |
-
|
148 |
-
|
149 |
-
|
150 |
-
|
151 |
-
|
152 |
-
|
153 |
-
|
154 |
-
|
155 |
-
|
156 |
-
|
157 |
-
|
158 |
-
|
159 |
-
|
160 |
-
|
161 |
-
|
162 |
-
|
163 |
-
|
164 |
-
|
165 |
-
|
166 |
-
|
167 |
-
|
168 |
-
|
169 |
-
|
170 |
-
|
171 |
-
|
172 |
-
|
173 |
-
|
174 |
-
|
175 |
-
|
176 |
-
|
177 |
-
|
178 |
-
|
179 |
-
|
180 |
-
|
181 |
-
|
182 |
-
|
183 |
-
|
184 |
-
|
185 |
-
|
186 |
-
|
187 |
-
|
188 |
-
|
189 |
-
|
190 |
-
|
191 |
-
|
192 |
-
|
193 |
-
|
194 |
-
|
195 |
-
|
196 |
-
|
197 |
-
|
198 |
-
|
199 |
-
|
200 |
-
|
201 |
-
|
202 |
-
|
203 |
-
|
204 |
-
|
205 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
<script lang="ts">
|
2 |
+
import { T } from "@threlte/core";
|
3 |
+
import { Billboard } from "@threlte/extras";
|
4 |
+
import type { Robot } from "../Robot.svelte.js";
|
5 |
+
import { interactivity, type IntersectionEvent, useCursor } from "@threlte/extras";
|
6 |
+
import { getRootLinks } from "@/components/3d/elements/robot/URDF/utils/UrdfParser";
|
7 |
+
import UrdfLink from "@/components/3d/elements/robot/URDF/primitives/UrdfLink.svelte";
|
8 |
+
import { ROBOT_CONFIG } from "../config.js";
|
9 |
+
|
10 |
+
interface Props {
|
11 |
+
robot: Robot;
|
12 |
+
onInteract: (robot: Robot, type: "consumer" | "producer" | "manual") => void;
|
13 |
+
}
|
14 |
+
|
15 |
+
let { robot, onInteract }: Props = $props();
|
16 |
+
|
17 |
+
// Reactive values
|
18 |
+
const position = $derived(robot.position);
|
19 |
+
const hasConsumer = $derived(robot.hasConsumer);
|
20 |
+
const outputDriverCount = $derived(robot.outputDriverCount);
|
21 |
+
const isManualControl = $derived(robot.isManualControlEnabled);
|
22 |
+
const connectionStatus = $derived(robot.connectionStatus);
|
23 |
+
const jointArray = $derived(robot.jointArray);
|
24 |
+
|
25 |
+
// Use the robot's stored URDF state (loaded once when robot was created)
|
26 |
+
const urdfRobotState = $derived(robot.urdfRobotState);
|
27 |
+
|
28 |
+
let isHovered = $state(false);
|
29 |
+
let isSelected = $state(false);
|
30 |
+
let lastJointValues = $state<Record<string, number>>({});
|
31 |
+
|
32 |
+
// Sync joint values from simplified Robot to URDF joints with optimized updates
|
33 |
+
$effect(() => {
|
34 |
+
if (!urdfRobotState || jointArray.length === 0) return;
|
35 |
+
|
36 |
+
// Check if this is the initial sync (no previous values recorded)
|
37 |
+
const isInitialSync = Object.keys(lastJointValues).length === 0;
|
38 |
+
|
39 |
+
// Check if any joint values have actually changed (using config threshold)
|
40 |
+
const threshold = isInitialSync ? 0 : ROBOT_CONFIG.performance.uiUpdateThreshold;
|
41 |
+
const hasSignificantChanges =
|
42 |
+
isInitialSync ||
|
43 |
+
jointArray.some(
|
44 |
+
(joint) => Math.abs((lastJointValues[joint.name] || 0) - joint.value) > threshold
|
45 |
+
);
|
46 |
+
if (!hasSignificantChanges) return;
|
47 |
+
|
48 |
+
// Batch update all joints that have changed (or all joints on initial sync)
|
49 |
+
let updatedCount = 0;
|
50 |
+
jointArray.forEach((joint) => {
|
51 |
+
if (isInitialSync || Math.abs((lastJointValues[joint.name] || 0) - joint.value) > threshold) {
|
52 |
+
lastJointValues[joint.name] = joint.value;
|
53 |
+
const urdfJoint = findUrdfJoint(urdfRobotState, joint.name);
|
54 |
+
if (urdfJoint) {
|
55 |
+
// Initialize rotation array if it doesn't exist
|
56 |
+
if (!urdfJoint.rotation) {
|
57 |
+
urdfJoint.rotation = [0, 0, 0];
|
58 |
+
}
|
59 |
+
|
60 |
+
// Use the Robot's conversion method for proper coordinate mapping
|
61 |
+
const radians = robot.convertNormalizedToUrdfRadians(joint.name, joint.value);
|
62 |
+
const axis = urdfJoint.axis_xyz || [0, 0, 1];
|
63 |
+
|
64 |
+
// Reset rotation and apply to the appropriate axis
|
65 |
+
urdfJoint.rotation = [0, 0, 0];
|
66 |
+
for (let i = 0; i < 3; i++) {
|
67 |
+
if (Math.abs(axis[i]) > 0.001) {
|
68 |
+
urdfJoint.rotation[i] = radians * axis[i];
|
69 |
+
}
|
70 |
+
}
|
71 |
+
updatedCount++;
|
72 |
+
}
|
73 |
+
}
|
74 |
+
});
|
75 |
+
|
76 |
+
if (updatedCount > 0) {
|
77 |
+
console.debug(
|
78 |
+
`${isInitialSync ? "Initial sync: " : ""}Updated ${updatedCount} URDF joints for robot ${robot.id}`
|
79 |
+
);
|
80 |
+
}
|
81 |
+
});
|
82 |
+
|
83 |
+
function findUrdfJoint(robot: any, jointName: string): any {
|
84 |
+
// Search through the robot's joints array
|
85 |
+
if (robot.joints && Array.isArray(robot.joints)) {
|
86 |
+
for (const joint of robot.joints) {
|
87 |
+
if (joint.name === jointName) {
|
88 |
+
return joint;
|
89 |
+
}
|
90 |
+
}
|
91 |
+
}
|
92 |
+
return null;
|
93 |
+
}
|
94 |
+
|
95 |
+
const { onPointerEnter, onPointerLeave } = useCursor();
|
96 |
+
interactivity();
|
97 |
</script>
|
98 |
|
99 |
<T.Group
|
100 |
+
position.x={position.x}
|
101 |
+
position.y={position.y}
|
102 |
+
position.z={position.z}
|
103 |
+
scale={[10, 10, 10]}
|
104 |
+
rotation={[-Math.PI / 2, 0, 0]}
|
105 |
>
|
106 |
+
{#if urdfRobotState}
|
107 |
+
<!-- URDF Robot representation -->
|
108 |
+
<T.Group
|
109 |
+
onclick={(event: IntersectionEvent<MouseEvent>) => {
|
110 |
+
event.stopPropagation();
|
111 |
+
isSelected = true;
|
112 |
+
onInteract(robot, "manual");
|
113 |
+
}}
|
114 |
+
onpointerenter={(event: IntersectionEvent<PointerEvent>) => {
|
115 |
+
event.stopPropagation();
|
116 |
+
onPointerEnter();
|
117 |
+
isHovered = true;
|
118 |
+
}}
|
119 |
+
onpointerleave={(event: IntersectionEvent<PointerEvent>) => {
|
120 |
+
event.stopPropagation();
|
121 |
+
onPointerLeave();
|
122 |
+
isHovered = false;
|
123 |
+
}}
|
124 |
+
>
|
125 |
+
{#each getRootLinks(urdfRobotState) as link}
|
126 |
+
<UrdfLink
|
127 |
+
robot={urdfRobotState}
|
128 |
+
{link}
|
129 |
+
textScale={0.2}
|
130 |
+
showName={isHovered || isSelected}
|
131 |
+
showVisual={true}
|
132 |
+
showCollision={false}
|
133 |
+
visualColor={connectionStatus.isConnected ? "#10b981" : "#6b7280"}
|
134 |
+
visualOpacity={isHovered || isSelected ? 0.4 : 1.0}
|
135 |
+
collisionOpacity={1.0}
|
136 |
+
collisionColor="#813d9c"
|
137 |
+
jointNames={isHovered}
|
138 |
+
joints={isHovered}
|
139 |
+
jointColor="#62a0ea"
|
140 |
+
jointIndicatorColor="#f66151"
|
141 |
+
nameHeight={0.1}
|
142 |
+
showLine={isHovered || isSelected}
|
143 |
+
opacity={1}
|
144 |
+
/>
|
145 |
+
{/each}
|
146 |
+
</T.Group>
|
147 |
+
{:else}
|
148 |
+
<!-- Fallback simple representation while URDF loads -->
|
149 |
+
<T.Mesh
|
150 |
+
onpointerenter={() => (isHovered = true)}
|
151 |
+
onpointerleave={() => (isHovered = false)}
|
152 |
+
onclick={() => onInteract(robot, "manual")}
|
153 |
+
>
|
154 |
+
<T.BoxGeometry args={[0.1, 0.1, 0.1]} />
|
155 |
+
<T.MeshStandardMaterial
|
156 |
+
color={connectionStatus.isConnected ? "#10b981" : "#6b7280"}
|
157 |
+
opacity={isHovered ? 0.8 : 1.0}
|
158 |
+
transparent
|
159 |
+
/>
|
160 |
+
</T.Mesh>
|
161 |
+
{/if}
|
162 |
+
|
163 |
+
<!-- Status billboard when hovered -->
|
164 |
+
{#if isHovered}
|
165 |
+
<Billboard>
|
166 |
+
<T.Group position.y={1.5}>
|
167 |
+
<div class="min-w-48 rounded-lg bg-slate-800/90 p-3 text-sm text-white backdrop-blur">
|
168 |
+
<div class="mb-2 font-semibold">{robot.id}</div>
|
169 |
+
|
170 |
+
<!-- Connection status boxes -->
|
171 |
+
<div class="mb-2 flex gap-2">
|
172 |
+
<!-- Consumer status -->
|
173 |
+
<button
|
174 |
+
onclick={() => onInteract(robot, "consumer")}
|
175 |
+
class="flex-1 rounded border p-2 transition-colors {hasConsumer
|
176 |
+
? 'border-green-500 bg-green-600'
|
177 |
+
: 'border-gray-500 bg-gray-600 hover:bg-gray-500'}"
|
178 |
+
>
|
179 |
+
<div class="text-xs">Consumer</div>
|
180 |
+
<div class="text-[10px] opacity-75">
|
181 |
+
{hasConsumer ? "Connected" : "None"}
|
182 |
+
</div>
|
183 |
+
</button>
|
184 |
+
|
185 |
+
<!-- Robot status -->
|
186 |
+
<div class="flex-1 rounded border border-yellow-500 bg-yellow-600 p-2">
|
187 |
+
<div class="text-xs">Robot</div>
|
188 |
+
<div class="text-[10px] opacity-75">{robot.jointArray.length} joints</div>
|
189 |
+
</div>
|
190 |
+
|
191 |
+
<!-- Producer status -->
|
192 |
+
<button
|
193 |
+
onclick={() => onInteract(robot, "producer")}
|
194 |
+
class="flex-1 rounded border p-2 transition-colors {outputDriverCount > 0
|
195 |
+
? 'border-blue-500 bg-blue-600'
|
196 |
+
: 'border-gray-500 bg-gray-600 hover:bg-gray-500'}"
|
197 |
+
>
|
198 |
+
<div class="text-xs">Producer</div>
|
199 |
+
<div class="text-[10px] opacity-75">
|
200 |
+
{outputDriverCount} driver{outputDriverCount !== 1 ? "s" : ""}
|
201 |
+
</div>
|
202 |
+
</button>
|
203 |
+
</div>
|
204 |
+
|
205 |
+
<!-- Control status -->
|
206 |
+
<div
|
207 |
+
class="rounded px-2 py-1 text-center text-xs {isManualControl
|
208 |
+
? 'bg-purple-600'
|
209 |
+
: 'bg-orange-600'}"
|
210 |
+
>
|
211 |
+
{isManualControl ? "Manual Control" : "External Control"}
|
212 |
+
</div>
|
213 |
+
</div>
|
214 |
+
</T.Group>
|
215 |
+
</Billboard>
|
216 |
+
{/if}
|
217 |
+
</T.Group>
|
src/lib/elements/robot/components/index.ts
CHANGED
@@ -1,5 +1,5 @@
|
|
1 |
// Robot components exports
|
2 |
-
export { default as RobotItem } from
|
3 |
-
export { default as RobotGrid } from
|
4 |
-
export { default as ConnectionPanel } from
|
5 |
-
export { default as RobotControls } from
|
|
|
1 |
// Robot components exports
|
2 |
+
export { default as RobotItem } from "./RobotItem.svelte";
|
3 |
+
export { default as RobotGrid } from "./RobotGrid.svelte";
|
4 |
+
export { default as ConnectionPanel } from "./ConnectionPanel.svelte";
|
5 |
+
export { default as RobotControls } from "./RobotControls.svelte";
|