jurmy24 commited on
Commit
16ab111
·
1 Parent(s): 67748b3

feat(wip): add viewer

Browse files
viewer/.gitignore ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Logs
2
+ logs
3
+ *.log
4
+ npm-debug.log*
5
+ yarn-debug.log*
6
+ yarn-error.log*
7
+ pnpm-debug.log*
8
+ lerna-debug.log*
9
+
10
+ node_modules
11
+ dist
12
+ dist-ssr
13
+ *.local
14
+
15
+ # Editor directories and files
16
+ .vscode/*
17
+ !.vscode/extensions.json
18
+ .idea
19
+ .DS_Store
20
+ *.suo
21
+ *.ntvs*
22
+ *.njsproj
23
+ *.sln
24
+ *.sw?
viewer/README.md ADDED
@@ -0,0 +1,54 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # React + TypeScript + Vite
2
+
3
+ This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
4
+
5
+ Currently, two official plugins are available:
6
+
7
+ - [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) for Fast Refresh
8
+ - [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
9
+
10
+ ## Expanding the ESLint configuration
11
+
12
+ If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
13
+
14
+ ```js
15
+ export default tseslint.config({
16
+ extends: [
17
+ // Remove ...tseslint.configs.recommended and replace with this
18
+ ...tseslint.configs.recommendedTypeChecked,
19
+ // Alternatively, use this for stricter rules
20
+ ...tseslint.configs.strictTypeChecked,
21
+ // Optionally, add this for stylistic rules
22
+ ...tseslint.configs.stylisticTypeChecked,
23
+ ],
24
+ languageOptions: {
25
+ // other options...
26
+ parserOptions: {
27
+ project: ['./tsconfig.node.json', './tsconfig.app.json'],
28
+ tsconfigRootDir: import.meta.dirname,
29
+ },
30
+ },
31
+ })
32
+ ```
33
+
34
+ You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
35
+
36
+ ```js
37
+ // eslint.config.js
38
+ import reactX from 'eslint-plugin-react-x'
39
+ import reactDom from 'eslint-plugin-react-dom'
40
+
41
+ export default tseslint.config({
42
+ plugins: {
43
+ // Add the react-x and react-dom plugins
44
+ 'react-x': reactX,
45
+ 'react-dom': reactDom,
46
+ },
47
+ rules: {
48
+ // other rules...
49
+ // Enable its recommended typescript rules
50
+ ...reactX.configs['recommended-typescript'].rules,
51
+ ...reactDom.configs.recommended.rules,
52
+ },
53
+ })
54
+ ```
viewer/eslint.config.js ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import js from '@eslint/js'
2
+ import globals from 'globals'
3
+ import reactHooks from 'eslint-plugin-react-hooks'
4
+ import reactRefresh from 'eslint-plugin-react-refresh'
5
+ import tseslint from 'typescript-eslint'
6
+
7
+ export default tseslint.config(
8
+ { ignores: ['dist'] },
9
+ {
10
+ extends: [js.configs.recommended, ...tseslint.configs.recommended],
11
+ files: ['**/*.{ts,tsx}'],
12
+ languageOptions: {
13
+ ecmaVersion: 2020,
14
+ globals: globals.browser,
15
+ },
16
+ plugins: {
17
+ 'react-hooks': reactHooks,
18
+ 'react-refresh': reactRefresh,
19
+ },
20
+ rules: {
21
+ ...reactHooks.configs.recommended.rules,
22
+ 'react-refresh/only-export-components': [
23
+ 'warn',
24
+ { allowConstantExport: true },
25
+ ],
26
+ },
27
+ },
28
+ )
viewer/index.html ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <link rel="icon" type="image/svg+xml" href="/vite.svg" />
6
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
7
+ <title>Vite + React + TS</title>
8
+ </head>
9
+ <body>
10
+ <div id="root"></div>
11
+ <script type="module" src="/src/main.tsx"></script>
12
+ </body>
13
+ </html>
viewer/package.json ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "viewer",
3
+ "private": true,
4
+ "version": "0.0.0",
5
+ "type": "module",
6
+ "scripts": {
7
+ "dev": "vite",
8
+ "build": "tsc -b && vite build",
9
+ "lint": "eslint .",
10
+ "preview": "vite preview"
11
+ },
12
+ "dependencies": {
13
+ "@types/node": "^22.14.1",
14
+ "clsx": "^2.1.1",
15
+ "react": "^19.0.0",
16
+ "react-dom": "^19.0.0",
17
+ "sonner": "^2.0.3",
18
+ "tailwind-merge": "^3.2.0",
19
+ "urdf-loader": "^0.12.4"
20
+ },
21
+ "devDependencies": {
22
+ "@eslint/js": "^9.22.0",
23
+ "@types/react": "^19.0.10",
24
+ "@types/react-dom": "^19.0.4",
25
+ "@vitejs/plugin-react": "^4.3.4",
26
+ "eslint": "^9.22.0",
27
+ "eslint-plugin-react-hooks": "^5.2.0",
28
+ "eslint-plugin-react-refresh": "^0.4.19",
29
+ "globals": "^16.0.0",
30
+ "typescript": "~5.7.2",
31
+ "typescript-eslint": "^8.26.1",
32
+ "vite": "^6.3.1"
33
+ }
34
+ }
viewer/public/vite.svg ADDED
viewer/src/App.css ADDED
@@ -0,0 +1,42 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #root {
2
+ max-width: 1280px;
3
+ margin: 0 auto;
4
+ padding: 2rem;
5
+ text-align: center;
6
+ }
7
+
8
+ .logo {
9
+ height: 6em;
10
+ padding: 1.5em;
11
+ will-change: filter;
12
+ transition: filter 300ms;
13
+ }
14
+ .logo:hover {
15
+ filter: drop-shadow(0 0 2em #646cffaa);
16
+ }
17
+ .logo.react:hover {
18
+ filter: drop-shadow(0 0 2em #61dafbaa);
19
+ }
20
+
21
+ @keyframes logo-spin {
22
+ from {
23
+ transform: rotate(0deg);
24
+ }
25
+ to {
26
+ transform: rotate(360deg);
27
+ }
28
+ }
29
+
30
+ @media (prefers-reduced-motion: no-preference) {
31
+ a:nth-of-type(2) .logo {
32
+ animation: logo-spin infinite 20s linear;
33
+ }
34
+ }
35
+
36
+ .card {
37
+ padding: 2em;
38
+ }
39
+
40
+ .read-the-docs {
41
+ color: #888;
42
+ }
viewer/src/App.tsx ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { DragAndDropProvider } from "@/contexts/DragAndDropContext";
2
+ import { UrdfProvider } from "@/contexts/UrdfContext";
3
+ import { Toaster } from "@/components/ui/toaster";
4
+ import { Toaster as Sonner } from "@/components/ui/sonner";
5
+ import { TooltipProvider } from "@/components/ui/tooltip";
6
+ import UrdfView from "@/pages/UrdfView";
7
+ import "@/App.css";
8
+
9
+ function App() {
10
+ return (
11
+ <TooltipProvider>
12
+ <UrdfProvider>
13
+ <DragAndDropProvider>
14
+ <Toaster />
15
+ <Sonner />
16
+ <UrdfView />
17
+ </DragAndDropProvider>
18
+ </UrdfProvider>
19
+ </TooltipProvider>
20
+ );
21
+ }
22
+
23
+ export default App;
viewer/src/assets/react.svg ADDED
viewer/src/components/UrdfSelectionModalContainer.tsx ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useUrdf } from "@/hooks/useUrdf";
2
+ import { UrdfSelectionModal } from "@/components/ui/UrdfSelectionModal";
3
+
4
+ /**
5
+ * Container component that manages the URDF selection modal.
6
+ * This is meant to be placed in the application layout to ensure the modal
7
+ * is accessible throughout the application without nesting issues.
8
+ */
9
+ export function UrdfSelectionModalContainer() {
10
+ const {
11
+ isSelectionModalOpen,
12
+ setIsSelectionModalOpen,
13
+ urdfModelOptions,
14
+ selectUrdfModel,
15
+ } = useUrdf();
16
+
17
+ return (
18
+ <UrdfSelectionModal
19
+ isOpen={isSelectionModalOpen}
20
+ onClose={() => setIsSelectionModalOpen(false)}
21
+ urdfModels={urdfModelOptions}
22
+ onSelectModel={selectUrdfModel}
23
+ />
24
+ );
25
+ }
viewer/src/components/UrdfViewer.tsx ADDED
@@ -0,0 +1,295 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useEffect, useRef, useState, useMemo } from "react";
2
+ import { Play, Pause } from "lucide-react";
3
+ import { cn } from "@/lib/utils";
4
+
5
+ import URDFManipulator from "urdf-loader/src/urdf-manipulator-element.js";
6
+ import { useUrdf } from "@/hooks/useUrdf";
7
+ import {
8
+ createUrdfViewer,
9
+ setupMeshLoader,
10
+ setupJointHighlighting,
11
+ setupModelLoading,
12
+ } from "@/lib/urdfViewerHelpers";
13
+ import {
14
+ Tooltip,
15
+ TooltipContent,
16
+ TooltipProvider,
17
+ TooltipTrigger,
18
+ } from "@/components/ui/tooltip";
19
+
20
+ // Register the URDFManipulator as a custom element if it hasn't been already
21
+ if (typeof window !== "undefined" && !customElements.get("urdf-viewer")) {
22
+ customElements.define("urdf-viewer", URDFManipulator);
23
+ }
24
+
25
+ // Extend the interface for the URDF viewer element to include background property
26
+ interface URDFViewerElement extends HTMLElement {
27
+ background?: string;
28
+ setJointValue?: (jointName: string, value: number) => void;
29
+ }
30
+
31
+ interface URDFViewerProps {
32
+ hasAnimation?: boolean;
33
+ headerHeight?: number;
34
+ }
35
+
36
+ const URDFViewer: React.FC<URDFViewerProps> = ({
37
+ hasAnimation = false,
38
+ headerHeight = 0,
39
+ }) => {
40
+ const { theme } = useTheme();
41
+ const isDarkMode = theme === "dark";
42
+ const containerRef = useRef<HTMLDivElement>(null);
43
+ const [highlightedJoint, setHighlightedJoint] = useState<string | null>(null);
44
+ const {
45
+ registerUrdfProcessor,
46
+ alternativeUrdfModels,
47
+ isDefaultModel,
48
+ currentAnimationConfig,
49
+ } = useUrdf();
50
+
51
+ // Add state for animation control
52
+ const [isAnimating, setIsAnimating] = useState<boolean>(isDefaultModel);
53
+ const [showAnimationControl, setShowAnimationControl] = useState<boolean>(
54
+ isDefaultModel || hasAnimation
55
+ );
56
+ const cleanupAnimationRef = useRef<(() => void) | null>(null);
57
+ const viewerRef = useRef<URDFViewerElement | null>(null);
58
+ const hasInitializedRef = useRef<boolean>(false);
59
+
60
+ // Add state for custom URDF path
61
+ const [customUrdfPath, setCustomUrdfPath] = useState<string | null>(null);
62
+ const [urlModifierFunc, setUrlModifierFunc] = useState<
63
+ ((url: string) => string) | null
64
+ >(null);
65
+
66
+ const packageRef = useRef<string>("");
67
+
68
+ // Implement UrdfProcessor interface for drag and drop
69
+ const urdfProcessor = useMemo(
70
+ () => ({
71
+ loadUrdf: (urdfPath: string) => {
72
+ setCustomUrdfPath(urdfPath);
73
+ },
74
+ setUrlModifierFunc: (func: (url: string) => string) => {
75
+ setUrlModifierFunc(() => func);
76
+ },
77
+ getPackage: () => {
78
+ return packageRef.current;
79
+ },
80
+ }),
81
+ []
82
+ );
83
+
84
+ // Register the URDF processor with the global drag and drop context
85
+ useEffect(() => {
86
+ registerUrdfProcessor(urdfProcessor);
87
+ }, [registerUrdfProcessor, urdfProcessor]);
88
+
89
+ // Effect to update animation control visibility based on props
90
+ useEffect(() => {
91
+ // Show animation controls for default model or when hasAnimation is true
92
+ setShowAnimationControl(isDefaultModel || hasAnimation);
93
+ }, [isDefaultModel, hasAnimation]);
94
+
95
+ // Toggle animation function
96
+ const toggleAnimation = () => {
97
+ setIsAnimating((prev) => !prev);
98
+ };
99
+
100
+ // Main effect to create and setup the viewer only once
101
+ useEffect(() => {
102
+ if (!containerRef.current) return;
103
+
104
+ // Create and configure the URDF viewer element
105
+ const viewer = createUrdfViewer(containerRef.current, isDarkMode);
106
+ viewerRef.current = viewer; // Store reference to the viewer
107
+
108
+ // Setup mesh loading function
109
+ setupMeshLoader(viewer, urlModifierFunc);
110
+
111
+ // Determine which URDF to load
112
+ const urdfPath = isDefaultModel
113
+ ? "/urdf/T12/urdf/T12.URDF"
114
+ : customUrdfPath || "";
115
+
116
+ // Setup model loading if a path is available
117
+ let cleanupModelLoading = () => {};
118
+ if (urdfPath) {
119
+ cleanupModelLoading = setupModelLoading(
120
+ viewer,
121
+ urdfPath,
122
+ packageRef.current,
123
+ setCustomUrdfPath,
124
+ alternativeUrdfModels
125
+ );
126
+ }
127
+
128
+ // Setup joint highlighting
129
+ const cleanupJointHighlighting = setupJointHighlighting(
130
+ viewer,
131
+ setHighlightedJoint
132
+ );
133
+
134
+ // Setup animation event handler for the default model or when hasAnimation is true
135
+ const onModelProcessed = () => {
136
+ hasInitializedRef.current = true;
137
+ if (isAnimating && "setJointValue" in viewer) {
138
+ // Clear any existing animation
139
+ if (cleanupAnimationRef.current) {
140
+ cleanupAnimationRef.current();
141
+ cleanupAnimationRef.current = null;
142
+ }
143
+
144
+ // Use the appropriate animation based on whether it's the default model or hasAnimation
145
+ if (isDefaultModel) {
146
+ // Start the hexapod animation when it's the default model
147
+ cleanupAnimationRef.current = animateHexapodRobot(
148
+ viewer as import("@/lib/urdfAnimationHelpers").URDFViewerElement
149
+ );
150
+ } else if (hasAnimation) {
151
+ // Use the custom animation config from the context if available, otherwise fall back to cassieWalkingConfig
152
+ const animationConfig = currentAnimationConfig || cassieWalkingConfig;
153
+
154
+ // Start the animation using the selected configuration
155
+ cleanupAnimationRef.current = animateRobot(
156
+ viewer as import("@/lib/urdfAnimationHelpers").URDFViewerElement,
157
+ animationConfig
158
+ );
159
+ }
160
+ }
161
+ };
162
+
163
+ viewer.addEventListener("urdf-processed", onModelProcessed);
164
+
165
+ // Return cleanup function
166
+ return () => {
167
+ if (cleanupAnimationRef.current) {
168
+ cleanupAnimationRef.current();
169
+ cleanupAnimationRef.current = null;
170
+ }
171
+ hasInitializedRef.current = false;
172
+ cleanupJointHighlighting();
173
+ cleanupModelLoading();
174
+ viewer.removeEventListener("urdf-processed", onModelProcessed);
175
+ };
176
+ }, [
177
+ isDefaultModel,
178
+ customUrdfPath,
179
+ urlModifierFunc,
180
+ hasAnimation,
181
+ currentAnimationConfig,
182
+ ]);
183
+
184
+ // Separate effect to handle theme changes without recreating the viewer
185
+ useEffect(() => {
186
+ if (!viewerRef.current) return;
187
+
188
+ // Update only the visual aspects based on theme
189
+ if (viewerRef.current.background !== undefined) {
190
+ if (isDarkMode) {
191
+ viewerRef.current.background = "#1f2937"; // Dark background
192
+ } else {
193
+ viewerRef.current.background = "#e0e7ff"; // Light background
194
+ }
195
+ }
196
+ }, [isDarkMode]);
197
+
198
+ // Effect to handle animation toggling after initial load
199
+ useEffect(() => {
200
+ if (!viewerRef.current || !hasInitializedRef.current) return;
201
+
202
+ // Only manage animation if viewer has setJointValue (required for animation)
203
+ if (!("setJointValue" in viewerRef.current)) return;
204
+
205
+ if (isAnimating) {
206
+ if (!cleanupAnimationRef.current) {
207
+ // Only start animation if it's not already running
208
+ if (isDefaultModel) {
209
+ cleanupAnimationRef.current = animateHexapodRobot(
210
+ viewerRef.current as import("@/lib/urdfAnimationHelpers").URDFViewerElement
211
+ );
212
+ } else if (hasAnimation) {
213
+ // Use the custom animation config from the context if available, otherwise fall back to cassieWalkingConfig
214
+ const animationConfig = currentAnimationConfig;
215
+
216
+ // Start animation using the selected configuration
217
+ cleanupAnimationRef.current = animateRobot(
218
+ viewerRef.current as import("@/lib/urdfAnimationHelpers").URDFViewerElement,
219
+ animationConfig
220
+ );
221
+ }
222
+ }
223
+ } else {
224
+ if (cleanupAnimationRef.current) {
225
+ // Just cancel the animation frame without resetting anything
226
+ cleanupAnimationRef.current();
227
+ cleanupAnimationRef.current = null;
228
+ }
229
+ }
230
+ }, [isAnimating, isDefaultModel, hasAnimation, currentAnimationConfig]);
231
+
232
+ return (
233
+ <div
234
+ className={cn(
235
+ "w-full h-full transition-all duration-300 ease-in-out relative",
236
+ isDarkMode
237
+ ? "bg-gradient-to-br from-gray-900 to-gray-800"
238
+ : "bg-gradient-to-br from-blue-50 to-indigo-50"
239
+ )}
240
+ >
241
+ <div ref={containerRef} className="w-full h-full" />
242
+
243
+ {/* Control buttons container in top right, with vertical padding for header */}
244
+ <div
245
+ className="absolute right-4 flex items-center space-x-2 z-10"
246
+ style={{ top: `${headerHeight + 16}px` }}
247
+ >
248
+ {/* ModeToggle button */}
249
+ <ModeToggle />
250
+
251
+ {/* Animation control button with icon - show for both default model and when animation is available */}
252
+ {showAnimationControl && (
253
+ <TooltipProvider>
254
+ <Tooltip>
255
+ <TooltipTrigger asChild>
256
+ <button
257
+ onClick={toggleAnimation}
258
+ className={cn(
259
+ "p-2.5 rounded-full shadow-md transition-all duration-300",
260
+ isDarkMode
261
+ ? "bg-gray-700 hover:bg-gray-600 text-white"
262
+ : "bg-white/80 hover:bg-white text-gray-800"
263
+ )}
264
+ aria-label={
265
+ isAnimating ? "Stop Animation" : "Start Animation"
266
+ }
267
+ >
268
+ {isAnimating ? (
269
+ <Pause className="h-5 w-5" />
270
+ ) : (
271
+ <Play className="h-5 w-5" />
272
+ )}
273
+ </button>
274
+ </TooltipTrigger>
275
+ <TooltipContent side="bottom">
276
+ <p className="font-mono text-xs">
277
+ {isAnimating ? "Stop Animation" : "Start Animation"}
278
+ </p>
279
+ </TooltipContent>
280
+ </Tooltip>
281
+ </TooltipProvider>
282
+ )}
283
+ </div>
284
+
285
+ {/* Joint highlight indicator */}
286
+ {highlightedJoint && (
287
+ <div className="absolute bottom-4 right-4 bg-black/70 text-white px-3 py-2 rounded-md text-sm font-mono z-10">
288
+ Joint: {highlightedJoint}
289
+ </div>
290
+ )}
291
+ </div>
292
+ );
293
+ };
294
+
295
+ export default URDFViewer;
viewer/src/components/ui/UrdfSelectionModal.tsx ADDED
@@ -0,0 +1,79 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React from "react";
2
+ import {
3
+ Dialog,
4
+ DialogContent,
5
+ DialogHeader,
6
+ DialogTitle,
7
+ DialogDescription,
8
+ } from "@/components/ui/dialog";
9
+ import {
10
+ Table,
11
+ TableBody,
12
+ TableCell,
13
+ TableHead,
14
+ TableHeader,
15
+ TableRow,
16
+ } from "@/components/ui/table";
17
+ import { Button } from "@/components/ui/button";
18
+ import { UrdfFileModel } from "@/lib/types";
19
+
20
+ interface UrdfSelectionModalProps {
21
+ isOpen: boolean;
22
+ onClose: () => void;
23
+ urdfModels: UrdfFileModel[];
24
+ onSelectModel: (model: UrdfFileModel) => void;
25
+ }
26
+
27
+ export function UrdfSelectionModal({
28
+ isOpen,
29
+ onClose,
30
+ urdfModels,
31
+ onSelectModel,
32
+ }: UrdfSelectionModalProps) {
33
+ return (
34
+ <Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
35
+ <DialogContent className="sm:max-w-[600px]">
36
+ <DialogHeader>
37
+ <DialogTitle>Select URDF Model</DialogTitle>
38
+ <DialogDescription>
39
+ Multiple URDF files were found. Please select the model you want to
40
+ visualize.
41
+ </DialogDescription>
42
+ </DialogHeader>
43
+
44
+ <div className="max-h-[400px] overflow-y-auto">
45
+ <Table>
46
+ <TableHeader>
47
+ <TableRow>
48
+ <TableHead>File Name</TableHead>
49
+ <TableHead>Path</TableHead>
50
+ <TableHead className="w-[100px]">Action</TableHead>
51
+ </TableRow>
52
+ </TableHeader>
53
+ <TableBody>
54
+ {urdfModels.map((model) => (
55
+ <TableRow key={model.path}>
56
+ <TableCell className="font-medium">
57
+ {model.path.split("/").pop()}
58
+ </TableCell>
59
+ <TableCell className="text-sm text-muted-foreground truncate max-w-[250px]">
60
+ {model.path}
61
+ </TableCell>
62
+ <TableCell>
63
+ <Button
64
+ onClick={() => onSelectModel(model)}
65
+ variant="secondary"
66
+ size="sm"
67
+ >
68
+ Select
69
+ </Button>
70
+ </TableCell>
71
+ </TableRow>
72
+ ))}
73
+ </TableBody>
74
+ </Table>
75
+ </div>
76
+ </DialogContent>
77
+ </Dialog>
78
+ );
79
+ }
viewer/src/components/ui/sonner.tsx ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useTheme } from "next-themes";
2
+ import { Toaster as Sonner } from "sonner";
3
+
4
+ type ToasterProps = React.ComponentProps<typeof Sonner>;
5
+
6
+ const Toaster = ({ ...props }: ToasterProps) => {
7
+ const { theme = "system" } = useTheme();
8
+
9
+ return (
10
+ <Sonner
11
+ theme={theme as ToasterProps["theme"]}
12
+ className="toaster group"
13
+ toastOptions={{
14
+ classNames: {
15
+ toast:
16
+ "group toast group-[.toaster]:bg-background group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg",
17
+ description: "group-[.toast]:text-muted-foreground",
18
+ actionButton:
19
+ "group-[.toast]:bg-primary group-[.toast]:text-primary-foreground",
20
+ cancelButton:
21
+ "group-[.toast]:bg-muted group-[.toast]:text-muted-foreground",
22
+ },
23
+ }}
24
+ {...props}
25
+ />
26
+ );
27
+ };
28
+
29
+ export { Toaster };
viewer/src/components/ui/toast.tsx ADDED
@@ -0,0 +1,127 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import * as React from "react"
2
+ import * as ToastPrimitives from "@radix-ui/react-toast"
3
+ import { cva, type VariantProps } from "class-variance-authority"
4
+ import { X } from "lucide-react"
5
+
6
+ import { cn } from "@/lib/utils"
7
+
8
+ const ToastProvider = ToastPrimitives.Provider
9
+
10
+ const ToastViewport = React.forwardRef<
11
+ React.ElementRef<typeof ToastPrimitives.Viewport>,
12
+ React.ComponentPropsWithoutRef<typeof ToastPrimitives.Viewport>
13
+ >(({ className, ...props }, ref) => (
14
+ <ToastPrimitives.Viewport
15
+ ref={ref}
16
+ className={cn(
17
+ "fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]",
18
+ className
19
+ )}
20
+ {...props}
21
+ />
22
+ ))
23
+ ToastViewport.displayName = ToastPrimitives.Viewport.displayName
24
+
25
+ const toastVariants = cva(
26
+ "group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border p-6 pr-8 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full",
27
+ {
28
+ variants: {
29
+ variant: {
30
+ default: "border bg-background text-foreground",
31
+ destructive:
32
+ "destructive group border-destructive bg-destructive text-destructive-foreground",
33
+ },
34
+ },
35
+ defaultVariants: {
36
+ variant: "default",
37
+ },
38
+ }
39
+ )
40
+
41
+ const Toast = React.forwardRef<
42
+ React.ElementRef<typeof ToastPrimitives.Root>,
43
+ React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> &
44
+ VariantProps<typeof toastVariants>
45
+ >(({ className, variant, ...props }, ref) => {
46
+ return (
47
+ <ToastPrimitives.Root
48
+ ref={ref}
49
+ className={cn(toastVariants({ variant }), className)}
50
+ {...props}
51
+ />
52
+ )
53
+ })
54
+ Toast.displayName = ToastPrimitives.Root.displayName
55
+
56
+ const ToastAction = React.forwardRef<
57
+ React.ElementRef<typeof ToastPrimitives.Action>,
58
+ React.ComponentPropsWithoutRef<typeof ToastPrimitives.Action>
59
+ >(({ className, ...props }, ref) => (
60
+ <ToastPrimitives.Action
61
+ ref={ref}
62
+ className={cn(
63
+ "inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 text-sm font-medium ring-offset-background transition-colors hover:bg-secondary focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 group-[.destructive]:border-muted/40 group-[.destructive]:hover:border-destructive/30 group-[.destructive]:hover:bg-destructive group-[.destructive]:hover:text-destructive-foreground group-[.destructive]:focus:ring-destructive",
64
+ className
65
+ )}
66
+ {...props}
67
+ />
68
+ ))
69
+ ToastAction.displayName = ToastPrimitives.Action.displayName
70
+
71
+ const ToastClose = React.forwardRef<
72
+ React.ElementRef<typeof ToastPrimitives.Close>,
73
+ React.ComponentPropsWithoutRef<typeof ToastPrimitives.Close>
74
+ >(({ className, ...props }, ref) => (
75
+ <ToastPrimitives.Close
76
+ ref={ref}
77
+ className={cn(
78
+ "absolute right-2 top-2 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-2 group-hover:opacity-100 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600",
79
+ className
80
+ )}
81
+ toast-close=""
82
+ {...props}
83
+ >
84
+ <X className="h-4 w-4" />
85
+ </ToastPrimitives.Close>
86
+ ))
87
+ ToastClose.displayName = ToastPrimitives.Close.displayName
88
+
89
+ const ToastTitle = React.forwardRef<
90
+ React.ElementRef<typeof ToastPrimitives.Title>,
91
+ React.ComponentPropsWithoutRef<typeof ToastPrimitives.Title>
92
+ >(({ className, ...props }, ref) => (
93
+ <ToastPrimitives.Title
94
+ ref={ref}
95
+ className={cn("text-sm font-semibold", className)}
96
+ {...props}
97
+ />
98
+ ))
99
+ ToastTitle.displayName = ToastPrimitives.Title.displayName
100
+
101
+ const ToastDescription = React.forwardRef<
102
+ React.ElementRef<typeof ToastPrimitives.Description>,
103
+ React.ComponentPropsWithoutRef<typeof ToastPrimitives.Description>
104
+ >(({ className, ...props }, ref) => (
105
+ <ToastPrimitives.Description
106
+ ref={ref}
107
+ className={cn("text-sm opacity-90", className)}
108
+ {...props}
109
+ />
110
+ ))
111
+ ToastDescription.displayName = ToastPrimitives.Description.displayName
112
+
113
+ type ToastProps = React.ComponentPropsWithoutRef<typeof Toast>
114
+
115
+ type ToastActionElement = React.ReactElement<typeof ToastAction>
116
+
117
+ export {
118
+ type ToastProps,
119
+ type ToastActionElement,
120
+ ToastProvider,
121
+ ToastViewport,
122
+ Toast,
123
+ ToastTitle,
124
+ ToastDescription,
125
+ ToastClose,
126
+ ToastAction,
127
+ }
viewer/src/components/ui/toaster.tsx ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useToast } from "@/hooks/use-toast"
2
+ import {
3
+ Toast,
4
+ ToastClose,
5
+ ToastDescription,
6
+ ToastProvider,
7
+ ToastTitle,
8
+ ToastViewport,
9
+ } from "@/components/ui/toast"
10
+
11
+ export function Toaster() {
12
+ const { toasts } = useToast()
13
+
14
+ return (
15
+ <ToastProvider>
16
+ {toasts.map(function ({ id, title, description, action, ...props }) {
17
+ return (
18
+ <Toast key={id} {...props}>
19
+ <div className="grid gap-1">
20
+ {title && <ToastTitle>{title}</ToastTitle>}
21
+ {description && (
22
+ <ToastDescription>{description}</ToastDescription>
23
+ )}
24
+ </div>
25
+ {action}
26
+ <ToastClose />
27
+ </Toast>
28
+ )
29
+ })}
30
+ <ToastViewport />
31
+ </ToastProvider>
32
+ )
33
+ }
viewer/src/components/ui/tooltip.tsx ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import * as React from "react";
2
+ import * as TooltipPrimitive from "@radix-ui/react-tooltip";
3
+
4
+ import { cn } from "@/lib/utils";
5
+
6
+ const TooltipProvider = TooltipPrimitive.Provider;
7
+
8
+ const Tooltip = TooltipPrimitive.Root;
9
+
10
+ const TooltipTrigger = TooltipPrimitive.Trigger;
11
+
12
+ const TooltipContent = React.forwardRef<
13
+ React.ElementRef<typeof TooltipPrimitive.Content>,
14
+ React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
15
+ >(({ className, sideOffset = 4, ...props }, ref) => (
16
+ <TooltipPrimitive.Content
17
+ ref={ref}
18
+ sideOffset={sideOffset}
19
+ className={cn(
20
+ "z-50 overflow-hidden rounded-md border bg-popover px-3 py-1.5 text-sm text-popover-foreground shadow-md animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
21
+ className
22
+ )}
23
+ {...props}
24
+ />
25
+ ));
26
+ TooltipContent.displayName = TooltipPrimitive.Content.displayName;
27
+
28
+ export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };
viewer/src/contexts/DragAndDropContext.tsx ADDED
@@ -0,0 +1,123 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, {
2
+ createContext,
3
+ useState,
4
+ useEffect,
5
+ ReactNode,
6
+ useCallback,
7
+ } from "react";
8
+
9
+ import { processDroppedFiles } from "@/lib/UrdfDragAndDrop";
10
+ import { useUrdf } from "@/hooks/useUrdf";
11
+
12
+ export type DragAndDropContextType = {
13
+ isDragging: boolean;
14
+ setIsDragging: (isDragging: boolean) => void;
15
+ handleDrop: (e: DragEvent) => Promise<void>;
16
+ };
17
+
18
+ export const DragAndDropContext = createContext<
19
+ DragAndDropContextType | undefined
20
+ >(undefined);
21
+
22
+ interface DragAndDropProviderProps {
23
+ children: ReactNode;
24
+ }
25
+
26
+ export const DragAndDropProvider: React.FC<DragAndDropProviderProps> = ({
27
+ children,
28
+ }) => {
29
+ const [isDragging, setIsDragging] = useState(false);
30
+
31
+ // Get the URDF context
32
+ const { urdfProcessor, processUrdfFiles } = useUrdf();
33
+
34
+ const handleDragOver = (e: DragEvent) => {
35
+ e.preventDefault();
36
+ e.stopPropagation();
37
+ };
38
+
39
+ const handleDragEnter = (e: DragEvent) => {
40
+ e.preventDefault();
41
+ e.stopPropagation();
42
+ setIsDragging(true);
43
+ };
44
+
45
+ const handleDragLeave = (e: DragEvent) => {
46
+ e.preventDefault();
47
+ e.stopPropagation();
48
+
49
+ // Only set isDragging to false if we're leaving the document
50
+ // This prevents flickering when moving between elements
51
+ if (!e.relatedTarget || !(e.relatedTarget as Element).closest("html")) {
52
+ setIsDragging(false);
53
+ }
54
+ };
55
+
56
+ const handleDrop = useCallback(
57
+ async (e: DragEvent) => {
58
+ e.preventDefault();
59
+ e.stopPropagation();
60
+ setIsDragging(false);
61
+
62
+ console.log("🔄 DragAndDropContext: Drop event detected");
63
+
64
+ if (!e.dataTransfer || !urdfProcessor) {
65
+ console.error("❌ No dataTransfer or urdfProcessor available");
66
+ return;
67
+ }
68
+
69
+ try {
70
+ console.log("🔍 Processing dropped files with urdfProcessor");
71
+
72
+ // Process files first
73
+ const { availableModels, files } = await processDroppedFiles(
74
+ e.dataTransfer,
75
+ urdfProcessor
76
+ );
77
+
78
+ // Delegate further processing to UrdfContext
79
+ await processUrdfFiles(files, availableModels);
80
+ } catch (error) {
81
+ console.error("❌ Error in handleDrop:", error);
82
+ }
83
+ },
84
+ [urdfProcessor, processUrdfFiles]
85
+ );
86
+
87
+ // Set up global event listeners
88
+ useEffect(() => {
89
+ document.addEventListener("dragover", handleDragOver);
90
+ document.addEventListener("dragenter", handleDragEnter);
91
+ document.addEventListener("dragleave", handleDragLeave);
92
+ document.addEventListener("drop", handleDrop);
93
+
94
+ return () => {
95
+ document.removeEventListener("dragover", handleDragOver);
96
+ document.removeEventListener("dragenter", handleDragEnter);
97
+ document.removeEventListener("dragleave", handleDragLeave);
98
+ document.removeEventListener("drop", handleDrop);
99
+ };
100
+ }, [handleDrop]); // Re-register when handleDrop changes
101
+
102
+ return (
103
+ <DragAndDropContext.Provider
104
+ value={{
105
+ isDragging,
106
+ setIsDragging,
107
+ handleDrop,
108
+ }}
109
+ >
110
+ {children}
111
+ {isDragging && (
112
+ <div className="fixed inset-0 bg-primary/10 pointer-events-none z-50 flex items-center justify-center">
113
+ <div className="bg-background p-8 rounded-lg shadow-lg text-center">
114
+ <div className="text-3xl font-bold mb-4">Drop URDF Files Here</div>
115
+ <p className="text-muted-foreground">
116
+ Release to upload your robot model
117
+ </p>
118
+ </div>
119
+ </div>
120
+ )}
121
+ </DragAndDropContext.Provider>
122
+ );
123
+ };
viewer/src/contexts/UrdfContext.tsx ADDED
@@ -0,0 +1,495 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, {
2
+ createContext,
3
+ useState,
4
+ useCallback,
5
+ ReactNode,
6
+ useRef,
7
+ useEffect,
8
+ } from "react";
9
+ import { toast } from "sonner";
10
+ import { UrdfProcessor, readUrdfFileContent } from "@/lib/UrdfDragAndDrop";
11
+ import { UrdfFileModel } from "@/lib/types";
12
+
13
+ // Define the result interface for URDF detection
14
+ interface UrdfDetectionResult {
15
+ hasUrdf: boolean;
16
+ modelName?: string;
17
+ }
18
+
19
+ // Define the context type
20
+ export type UrdfContextType = {
21
+ urdfProcessor: UrdfProcessor | null;
22
+ registerUrdfProcessor: (processor: UrdfProcessor) => void;
23
+ onUrdfDetected: (
24
+ callback: (result: UrdfDetectionResult) => void
25
+ ) => () => void;
26
+ processUrdfFiles: (
27
+ files: Record<string, File>,
28
+ availableModels: string[]
29
+ ) => Promise<void>;
30
+ urdfBlobUrls: Record<string, string>;
31
+ alternativeUrdfModels: string[];
32
+ isSelectionModalOpen: boolean;
33
+ setIsSelectionModalOpen: (isOpen: boolean) => void;
34
+ urdfModelOptions: UrdfFileModel[];
35
+ selectUrdfModel: (model: UrdfFileModel) => void;
36
+
37
+ isDefaultModel: boolean;
38
+ setIsDefaultModel: (isDefault: boolean) => void;
39
+ resetToDefaultModel: () => void;
40
+ urdfContent: string | null;
41
+ };
42
+
43
+ // Create the context
44
+ export const UrdfContext = createContext<UrdfContextType | undefined>(
45
+ undefined
46
+ );
47
+
48
+ // Props for the provider component
49
+ interface UrdfProviderProps {
50
+ children: ReactNode;
51
+ }
52
+
53
+ export const UrdfProvider: React.FC<UrdfProviderProps> = ({ children }) => {
54
+ // State for URDF processor
55
+ const [urdfProcessor, setUrdfProcessor] = useState<UrdfProcessor | null>(
56
+ null
57
+ );
58
+
59
+ // State for blob URLs (replacing window.urdfBlobUrls)
60
+ const [urdfBlobUrls, setUrdfBlobUrls] = useState<Record<string, string>>({});
61
+
62
+ // State for alternative models (replacing window.alternativeUrdfModels)
63
+ const [alternativeUrdfModels, setAlternativeUrdfModels] = useState<string[]>(
64
+ []
65
+ );
66
+
67
+ // State for the URDF selection modal
68
+ const [isSelectionModalOpen, setIsSelectionModalOpen] = useState(false);
69
+ const [urdfModelOptions, setUrdfModelOptions] = useState<UrdfFileModel[]>([]);
70
+
71
+ // New state for centralized robot data management
72
+ const [isDefaultModel, setIsDefaultModel] = useState(true);
73
+ const [urdfContent, setUrdfContent] = useState<string | null>(null);
74
+
75
+ // Fetch the default URDF content when the component mounts
76
+ useEffect(() => {
77
+ // Only fetch if we don't have content and we're using the default model
78
+ if (isDefaultModel && !urdfContent) {
79
+ const fetchDefaultUrdf = async () => {
80
+ try {
81
+ // Path to the default T12 URDF file
82
+ const defaultUrdfPath = "/urdf/T12/urdf/T12.URDF";
83
+
84
+ // Fetch the URDF content
85
+ const response = await fetch(defaultUrdfPath);
86
+
87
+ if (!response.ok) {
88
+ throw new Error(
89
+ `Failed to fetch default URDF: ${response.statusText}`
90
+ );
91
+ }
92
+
93
+ const defaultUrdfContent = await response.text();
94
+ console.log(
95
+ `📄 Default URDF content loaded, length: ${defaultUrdfContent.length} characters`
96
+ );
97
+
98
+ // Set the URDF content in state
99
+ setUrdfContent(defaultUrdfContent);
100
+ } catch (error) {
101
+ console.error("❌ Error loading default URDF content:", error);
102
+ }
103
+ };
104
+
105
+ fetchDefaultUrdf();
106
+ }
107
+ }, [isDefaultModel, urdfContent]);
108
+
109
+ // Reference for callbacks
110
+ const urdfCallbacksRef = useRef<((result: UrdfDetectionResult) => void)[]>(
111
+ []
112
+ );
113
+
114
+ // Reset to default model
115
+ const resetToDefaultModel = useCallback(() => {
116
+ setIsDefaultModel(true);
117
+ setUrdfContent(null);
118
+
119
+ toast.info("Switched to default model", {
120
+ description: "The default T12 robot model is now displayed.",
121
+ });
122
+ }, []);
123
+
124
+ // Register a callback for URDF detection
125
+ const onUrdfDetected = useCallback(
126
+ (callback: (result: UrdfDetectionResult) => void) => {
127
+ urdfCallbacksRef.current.push(callback);
128
+
129
+ return () => {
130
+ urdfCallbacksRef.current = urdfCallbacksRef.current.filter(
131
+ (cb) => cb !== callback
132
+ );
133
+ };
134
+ },
135
+ []
136
+ );
137
+
138
+ // Register a URDF processor
139
+ const registerUrdfProcessor = useCallback((processor: UrdfProcessor) => {
140
+ setUrdfProcessor(processor);
141
+ }, []);
142
+
143
+ // Internal function to notify callbacks and update central state
144
+ const notifyUrdfCallbacks = useCallback(
145
+ (result: UrdfDetectionResult) => {
146
+ console.log("📣 Notifying URDF callbacks with result:", result);
147
+
148
+ // Update our internal state based on the result
149
+ if (result.hasUrdf) {
150
+ // Always ensure we set isDefaultModel to false when we have a URDF
151
+ setIsDefaultModel(false);
152
+
153
+ if (result.modelName) {
154
+ setUrdfContent(result.modelName);
155
+ }
156
+
157
+ // Set description if available
158
+ if (result.modelName) {
159
+ setUrdfContent(
160
+ "A detailed 3D model of a robotic system with articulated joints and components."
161
+ );
162
+ }
163
+ } else {
164
+ // If no URDF, reset to default
165
+ resetToDefaultModel();
166
+ }
167
+
168
+ // Call all registered callbacks
169
+ urdfCallbacksRef.current.forEach((callback) => callback(result));
170
+ },
171
+ [resetToDefaultModel]
172
+ );
173
+
174
+ // Helper function to process the selected URDF model
175
+ const processSelectedUrdf = useCallback(
176
+ async (model: UrdfFileModel) => {
177
+ if (!urdfProcessor) return;
178
+
179
+ // Find the file in our files record
180
+ const files = Object.values(urdfBlobUrls)
181
+ .filter((url) => url === model.blobUrl)
182
+ .map((url) => {
183
+ const path = Object.keys(urdfBlobUrls).find(
184
+ (key) => urdfBlobUrls[key] === url
185
+ );
186
+ return path ? { path, url } : null;
187
+ })
188
+ .filter((item) => item !== null);
189
+
190
+ if (files.length === 0) {
191
+ console.error("❌ Could not find file for selected URDF model");
192
+ return;
193
+ }
194
+
195
+ // Show a toast notification that we're parsing the URDF
196
+ const parsingToast = toast.loading("Analyzing URDF model...", {
197
+ description: "Extracting robot information",
198
+ duration: 10000, // Long duration since we'll dismiss it manually
199
+ });
200
+
201
+ try {
202
+ // Get the file from our record
203
+ const filePath = files[0]?.path;
204
+ if (!filePath || !urdfBlobUrls[filePath]) {
205
+ throw new Error("File not found in records");
206
+ }
207
+
208
+ // Get the actual File object
209
+ const response = await fetch(model.blobUrl);
210
+ const blob = await response.blob();
211
+ const file = new File(
212
+ [blob],
213
+ filePath.split("/").pop() || "model.urdf",
214
+ {
215
+ type: "application/xml",
216
+ }
217
+ );
218
+
219
+ // Read the URDF content
220
+ const urdfContent = await readUrdfFileContent(file);
221
+
222
+ console.log(
223
+ `📏 URDF content read, length: ${urdfContent.length} characters`
224
+ );
225
+
226
+ // Store the URDF content in state
227
+ setUrdfContent(urdfContent);
228
+
229
+ // Dismiss the toast
230
+ toast.dismiss(parsingToast);
231
+
232
+ // Always set isDefaultModel to false when processing a custom URDF
233
+ setIsDefaultModel(false);
234
+ } catch (error) {
235
+ // Error case
236
+ console.error("❌ Error processing selected URDF:", error);
237
+ toast.dismiss(parsingToast);
238
+ toast.error("Error analyzing URDF", {
239
+ description: `Error: ${
240
+ error instanceof Error ? error.message : String(error)
241
+ }`,
242
+ duration: 3000,
243
+ });
244
+
245
+ // Keep showing the custom model even if parsing failed
246
+ // No need to reset to default unless user explicitly chooses to
247
+ }
248
+ },
249
+ [urdfBlobUrls, urdfProcessor]
250
+ );
251
+
252
+ // Function to handle selecting a URDF model from the modal
253
+ const selectUrdfModel = useCallback(
254
+ (model: UrdfFileModel) => {
255
+ if (!urdfProcessor) {
256
+ console.error("❌ No URDF processor available");
257
+ return;
258
+ }
259
+
260
+ console.log(`🤖 Selected model: ${model.name || model.path}`);
261
+
262
+ // Close the modal
263
+ setIsSelectionModalOpen(false);
264
+
265
+ // Extract model name
266
+ const modelName =
267
+ model.name ||
268
+ model.path
269
+ .split("/")
270
+ .pop()
271
+ ?.replace(/\.urdf$/i, "") ||
272
+ "Unknown";
273
+
274
+ // Load the selected URDF model
275
+ urdfProcessor.loadUrdf(model.blobUrl);
276
+
277
+ // Update our state immediately even before parsing
278
+ setIsDefaultModel(false);
279
+
280
+ // Show a toast notification that we're loading the model
281
+ toast.info(`Loading model: ${modelName}`, {
282
+ description: "Preparing 3D visualization",
283
+ duration: 2000,
284
+ });
285
+
286
+ // Notify callbacks about the selection before parsing
287
+ notifyUrdfCallbacks({
288
+ hasUrdf: true,
289
+ modelName,
290
+ });
291
+
292
+ // Try to parse the model - this will update the UI when complete
293
+ processSelectedUrdf(model);
294
+ },
295
+ [urdfProcessor, notifyUrdfCallbacks, processSelectedUrdf]
296
+ );
297
+
298
+ // Process URDF files - moved from DragAndDropContext
299
+ const processUrdfFiles = useCallback(
300
+ async (files: Record<string, File>, availableModels: string[]) => {
301
+ // Clear previous blob URLs to prevent memory leaks
302
+ Object.values(urdfBlobUrls).forEach(URL.revokeObjectURL);
303
+ setUrdfBlobUrls({});
304
+ setAlternativeUrdfModels([]);
305
+ setUrdfModelOptions([]);
306
+
307
+ try {
308
+ // Check if we have any URDF files
309
+ if (availableModels.length > 0 && urdfProcessor) {
310
+ console.log(
311
+ `🤖 Found ${availableModels.length} URDF models:`,
312
+ availableModels
313
+ );
314
+
315
+ // Create blob URLs for all models
316
+ const newUrdfBlobUrls: Record<string, string> = {};
317
+ availableModels.forEach((path) => {
318
+ if (files[path]) {
319
+ newUrdfBlobUrls[path] = URL.createObjectURL(files[path]);
320
+ }
321
+ });
322
+ setUrdfBlobUrls(newUrdfBlobUrls);
323
+
324
+ // Save alternative models for reference
325
+ setAlternativeUrdfModels(availableModels);
326
+
327
+ // Create model options for the selection modal
328
+ const modelOptions: UrdfFileModel[] = availableModels.map((path) => {
329
+ const fileName = path.split("/").pop() || "";
330
+ const modelName = fileName.replace(/\.urdf$/i, "");
331
+ return {
332
+ path,
333
+ blobUrl: newUrdfBlobUrls[path],
334
+ name: modelName,
335
+ };
336
+ });
337
+
338
+ setUrdfModelOptions(modelOptions);
339
+
340
+ // If there's only one model, use it directly
341
+ if (availableModels.length === 1) {
342
+ // Extract model name from the URDF file
343
+ const fileName = availableModels[0].split("/").pop() || "";
344
+ const modelName = fileName.replace(/\.urdf$/i, "");
345
+ console.log(`📄 Using model: ${modelName} (${fileName})`);
346
+
347
+ // Use the blob URL instead of the file path
348
+ const blobUrl = newUrdfBlobUrls[availableModels[0]];
349
+ if (blobUrl) {
350
+ console.log(`🔗 Using blob URL for URDF: ${blobUrl}`);
351
+ urdfProcessor.loadUrdf(blobUrl);
352
+
353
+ // Immediately update model state
354
+ setIsDefaultModel(false);
355
+
356
+ // Process the URDF file for parsing
357
+ if (files[availableModels[0]]) {
358
+ console.log(
359
+ "📄 Reading URDF content for edge function parsing..."
360
+ );
361
+
362
+ // Show a toast notification that we're parsing the URDF
363
+ const parsingToast = toast.loading("Analyzing URDF model...", {
364
+ description: "Extracting robot information",
365
+ duration: 10000, // Long duration since we'll dismiss it manually
366
+ });
367
+
368
+ try {
369
+ const urdfContent = await readUrdfFileContent(
370
+ files[availableModels[0]]
371
+ );
372
+
373
+ console.log(
374
+ `📏 URDF content read, length: ${urdfContent.length} characters`
375
+ );
376
+
377
+ // Store the URDF content in state
378
+ setUrdfContent(urdfContent);
379
+
380
+ // Dismiss the parsing toast
381
+ toast.dismiss(parsingToast);
382
+ } catch (parseError) {
383
+ console.error("❌ Error parsing URDF:", parseError);
384
+ toast.dismiss(parsingToast);
385
+ toast.error("Error analyzing URDF", {
386
+ description: `Error: ${
387
+ parseError instanceof Error
388
+ ? parseError.message
389
+ : String(parseError)
390
+ }`,
391
+ duration: 3000,
392
+ });
393
+
394
+ // Still notify callbacks without parsed data
395
+ notifyUrdfCallbacks({
396
+ hasUrdf: true,
397
+ modelName,
398
+ });
399
+ }
400
+ } else {
401
+ console.error(
402
+ "❌ Could not find file for URDF model:",
403
+ availableModels[0]
404
+ );
405
+ console.log("📦 Available files:", Object.keys(files));
406
+
407
+ // Still notify callbacks without parsed data
408
+ notifyUrdfCallbacks({
409
+ hasUrdf: true,
410
+ modelName,
411
+ });
412
+ }
413
+ } else {
414
+ console.warn(
415
+ `⚠️ No blob URL found for ${availableModels[0]}, using path directly`
416
+ );
417
+ urdfProcessor.loadUrdf(availableModels[0]);
418
+
419
+ // Update the state even without a blob URL
420
+ setIsDefaultModel(false);
421
+ }
422
+ } else {
423
+ // Multiple URDF files found, show selection modal
424
+ console.log(
425
+ "📋 Multiple URDF files found, showing selection modal"
426
+ );
427
+ setIsSelectionModalOpen(true);
428
+
429
+ // Notify that URDF files are available but selection is needed
430
+ notifyUrdfCallbacks({
431
+ hasUrdf: true,
432
+ modelName: "Multiple models available",
433
+ });
434
+ }
435
+ } else {
436
+ console.warn(
437
+ "❌ No URDF models found in dropped files or no processor available"
438
+ );
439
+ notifyUrdfCallbacks({ hasUrdf: false });
440
+
441
+ // Reset to default model when no URDF files are found
442
+ resetToDefaultModel();
443
+
444
+ toast.error("No URDF file found", {
445
+ description: "Please upload a folder containing a .urdf file.",
446
+ duration: 3000,
447
+ });
448
+ }
449
+ } catch (error) {
450
+ console.error("❌ Error processing URDF files:", error);
451
+ toast.error("Error processing files", {
452
+ description: `Error: ${
453
+ error instanceof Error ? error.message : String(error)
454
+ }`,
455
+ duration: 3000,
456
+ });
457
+
458
+ // Reset to default model on error
459
+ resetToDefaultModel();
460
+ }
461
+ },
462
+ [notifyUrdfCallbacks, urdfBlobUrls, urdfProcessor, resetToDefaultModel]
463
+ );
464
+
465
+ // Clean up blob URLs when component unmounts
466
+ React.useEffect(() => {
467
+ return () => {
468
+ Object.values(urdfBlobUrls).forEach(URL.revokeObjectURL);
469
+ };
470
+ }, [urdfBlobUrls]);
471
+
472
+ // Create the context value
473
+ const contextValue: UrdfContextType = {
474
+ urdfProcessor,
475
+ registerUrdfProcessor,
476
+ onUrdfDetected,
477
+ processUrdfFiles,
478
+ urdfBlobUrls,
479
+ alternativeUrdfModels,
480
+ isSelectionModalOpen,
481
+ setIsSelectionModalOpen,
482
+ urdfModelOptions,
483
+ selectUrdfModel,
484
+
485
+ // New properties for centralized robot data management
486
+ isDefaultModel,
487
+ setIsDefaultModel,
488
+ resetToDefaultModel,
489
+ urdfContent,
490
+ };
491
+
492
+ return (
493
+ <UrdfContext.Provider value={contextValue}>{children}</UrdfContext.Provider>
494
+ );
495
+ };
viewer/src/hooks/use-toast.ts ADDED
@@ -0,0 +1,191 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import * as React from "react"
2
+
3
+ import type {
4
+ ToastActionElement,
5
+ ToastProps,
6
+ } from "@/components/ui/toast"
7
+
8
+ const TOAST_LIMIT = 1
9
+ const TOAST_REMOVE_DELAY = 1000000
10
+
11
+ type ToasterToast = ToastProps & {
12
+ id: string
13
+ title?: React.ReactNode
14
+ description?: React.ReactNode
15
+ action?: ToastActionElement
16
+ }
17
+
18
+ const actionTypes = {
19
+ ADD_TOAST: "ADD_TOAST",
20
+ UPDATE_TOAST: "UPDATE_TOAST",
21
+ DISMISS_TOAST: "DISMISS_TOAST",
22
+ REMOVE_TOAST: "REMOVE_TOAST",
23
+ } as const
24
+
25
+ let count = 0
26
+
27
+ function genId() {
28
+ count = (count + 1) % Number.MAX_SAFE_INTEGER
29
+ return count.toString()
30
+ }
31
+
32
+ type ActionType = typeof actionTypes
33
+
34
+ type Action =
35
+ | {
36
+ type: ActionType["ADD_TOAST"]
37
+ toast: ToasterToast
38
+ }
39
+ | {
40
+ type: ActionType["UPDATE_TOAST"]
41
+ toast: Partial<ToasterToast>
42
+ }
43
+ | {
44
+ type: ActionType["DISMISS_TOAST"]
45
+ toastId?: ToasterToast["id"]
46
+ }
47
+ | {
48
+ type: ActionType["REMOVE_TOAST"]
49
+ toastId?: ToasterToast["id"]
50
+ }
51
+
52
+ interface State {
53
+ toasts: ToasterToast[]
54
+ }
55
+
56
+ const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>()
57
+
58
+ const addToRemoveQueue = (toastId: string) => {
59
+ if (toastTimeouts.has(toastId)) {
60
+ return
61
+ }
62
+
63
+ const timeout = setTimeout(() => {
64
+ toastTimeouts.delete(toastId)
65
+ dispatch({
66
+ type: "REMOVE_TOAST",
67
+ toastId: toastId,
68
+ })
69
+ }, TOAST_REMOVE_DELAY)
70
+
71
+ toastTimeouts.set(toastId, timeout)
72
+ }
73
+
74
+ export const reducer = (state: State, action: Action): State => {
75
+ switch (action.type) {
76
+ case "ADD_TOAST":
77
+ return {
78
+ ...state,
79
+ toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
80
+ }
81
+
82
+ case "UPDATE_TOAST":
83
+ return {
84
+ ...state,
85
+ toasts: state.toasts.map((t) =>
86
+ t.id === action.toast.id ? { ...t, ...action.toast } : t
87
+ ),
88
+ }
89
+
90
+ case "DISMISS_TOAST": {
91
+ const { toastId } = action
92
+
93
+ // ! Side effects ! - This could be extracted into a dismissToast() action,
94
+ // but I'll keep it here for simplicity
95
+ if (toastId) {
96
+ addToRemoveQueue(toastId)
97
+ } else {
98
+ state.toasts.forEach((toast) => {
99
+ addToRemoveQueue(toast.id)
100
+ })
101
+ }
102
+
103
+ return {
104
+ ...state,
105
+ toasts: state.toasts.map((t) =>
106
+ t.id === toastId || toastId === undefined
107
+ ? {
108
+ ...t,
109
+ open: false,
110
+ }
111
+ : t
112
+ ),
113
+ }
114
+ }
115
+ case "REMOVE_TOAST":
116
+ if (action.toastId === undefined) {
117
+ return {
118
+ ...state,
119
+ toasts: [],
120
+ }
121
+ }
122
+ return {
123
+ ...state,
124
+ toasts: state.toasts.filter((t) => t.id !== action.toastId),
125
+ }
126
+ }
127
+ }
128
+
129
+ const listeners: Array<(state: State) => void> = []
130
+
131
+ let memoryState: State = { toasts: [] }
132
+
133
+ function dispatch(action: Action) {
134
+ memoryState = reducer(memoryState, action)
135
+ listeners.forEach((listener) => {
136
+ listener(memoryState)
137
+ })
138
+ }
139
+
140
+ type Toast = Omit<ToasterToast, "id">
141
+
142
+ function toast({ ...props }: Toast) {
143
+ const id = genId()
144
+
145
+ const update = (props: ToasterToast) =>
146
+ dispatch({
147
+ type: "UPDATE_TOAST",
148
+ toast: { ...props, id },
149
+ })
150
+ const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id })
151
+
152
+ dispatch({
153
+ type: "ADD_TOAST",
154
+ toast: {
155
+ ...props,
156
+ id,
157
+ open: true,
158
+ onOpenChange: (open) => {
159
+ if (!open) dismiss()
160
+ },
161
+ },
162
+ })
163
+
164
+ return {
165
+ id: id,
166
+ dismiss,
167
+ update,
168
+ }
169
+ }
170
+
171
+ function useToast() {
172
+ const [state, setState] = React.useState<State>(memoryState)
173
+
174
+ React.useEffect(() => {
175
+ listeners.push(setState)
176
+ return () => {
177
+ const index = listeners.indexOf(setState)
178
+ if (index > -1) {
179
+ listeners.splice(index, 1)
180
+ }
181
+ }
182
+ }, [state])
183
+
184
+ return {
185
+ ...state,
186
+ toast,
187
+ dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }),
188
+ }
189
+ }
190
+
191
+ export { useToast, toast }
viewer/src/hooks/useDragAndDrop.tsx ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import {
2
+ DragAndDropContextType,
3
+ DragAndDropContext,
4
+ } from "@/contexts/DragAndDropContext";
5
+ import { useContext } from "react";
6
+
7
+ // Custom hook to use the DragAndDrop context
8
+ export const useDragAndDrop = (): DragAndDropContextType => {
9
+ const context = useContext(DragAndDropContext);
10
+ if (context === undefined) {
11
+ throw new Error("useDragAndDrop must be used within a DragAndDropProvider");
12
+ }
13
+ return context;
14
+ };
viewer/src/hooks/useUrdf.ts ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { UrdfContextType, UrdfContext } from "@/contexts/UrdfContext";
2
+ import { useContext } from "react";
3
+
4
+ // Custom hook to use the URDF context
5
+ export const useUrdf = (): UrdfContextType => {
6
+ const context = useContext(UrdfContext);
7
+ if (context === undefined) {
8
+ throw new Error("useUrdf must be used within a UrdfProvider");
9
+ }
10
+ return context;
11
+ };
viewer/src/index.css ADDED
@@ -0,0 +1,68 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ :root {
2
+ font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
3
+ line-height: 1.5;
4
+ font-weight: 400;
5
+
6
+ color-scheme: light dark;
7
+ color: rgba(255, 255, 255, 0.87);
8
+ background-color: #242424;
9
+
10
+ font-synthesis: none;
11
+ text-rendering: optimizeLegibility;
12
+ -webkit-font-smoothing: antialiased;
13
+ -moz-osx-font-smoothing: grayscale;
14
+ }
15
+
16
+ a {
17
+ font-weight: 500;
18
+ color: #646cff;
19
+ text-decoration: inherit;
20
+ }
21
+ a:hover {
22
+ color: #535bf2;
23
+ }
24
+
25
+ body {
26
+ margin: 0;
27
+ display: flex;
28
+ place-items: center;
29
+ min-width: 320px;
30
+ min-height: 100vh;
31
+ }
32
+
33
+ h1 {
34
+ font-size: 3.2em;
35
+ line-height: 1.1;
36
+ }
37
+
38
+ button {
39
+ border-radius: 8px;
40
+ border: 1px solid transparent;
41
+ padding: 0.6em 1.2em;
42
+ font-size: 1em;
43
+ font-weight: 500;
44
+ font-family: inherit;
45
+ background-color: #1a1a1a;
46
+ cursor: pointer;
47
+ transition: border-color 0.25s;
48
+ }
49
+ button:hover {
50
+ border-color: #646cff;
51
+ }
52
+ button:focus,
53
+ button:focus-visible {
54
+ outline: 4px auto -webkit-focus-ring-color;
55
+ }
56
+
57
+ @media (prefers-color-scheme: light) {
58
+ :root {
59
+ color: #213547;
60
+ background-color: #ffffff;
61
+ }
62
+ a:hover {
63
+ color: #747bff;
64
+ }
65
+ button {
66
+ background-color: #f9f9f9;
67
+ }
68
+ }
viewer/src/lib/UrdfDragAndDrop.ts ADDED
@@ -0,0 +1,413 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ /**
3
+ * URDF Drag and Drop Utility
4
+ *
5
+ * This file provides functionality for handling drag and drop of URDF folders.
6
+ * It converts the dropped files into accessible blobs for visualization.
7
+ */
8
+
9
+ /**
10
+ * Converts a DataTransfer structure into an object with all paths and files.
11
+ * @param dataTransfer The DataTransfer object from the drop event
12
+ * @returns A promise that resolves with the file structure object
13
+ */
14
+ export function dataTransferToFiles(
15
+ dataTransfer: DataTransfer
16
+ ): Promise<Record<string, File>> {
17
+ if (!(dataTransfer instanceof DataTransfer)) {
18
+ throw new Error('Data must be of type "DataTransfer"');
19
+ }
20
+
21
+ const files: Record<string, File> = {};
22
+
23
+ /**
24
+ * Recursively processes a directory entry to extract all files
25
+ * Using type 'unknown' and then type checking for safety with WebKit's non-standard API
26
+ */
27
+ function recurseDirectory(item: unknown): Promise<void> {
28
+ // Type guard for file entries
29
+ const isFileEntry = (
30
+ entry: unknown
31
+ ): entry is {
32
+ isFile: boolean;
33
+ fullPath: string;
34
+ file: (callback: (file: File) => void) => void;
35
+ } =>
36
+ entry !== null &&
37
+ typeof entry === "object" &&
38
+ "isFile" in entry &&
39
+ typeof (entry as Record<string, unknown>).file === "function" &&
40
+ "fullPath" in entry;
41
+
42
+ // Type guard for directory entries
43
+ const isDirEntry = (
44
+ entry: unknown
45
+ ): entry is {
46
+ isFile: boolean;
47
+ createReader: () => {
48
+ readEntries: (callback: (entries: unknown[]) => void) => void;
49
+ };
50
+ } =>
51
+ entry !== null &&
52
+ typeof entry === "object" &&
53
+ "isFile" in entry &&
54
+ typeof (entry as Record<string, unknown>).createReader === "function";
55
+
56
+ if (isFileEntry(item) && item.isFile) {
57
+ return new Promise((resolve) => {
58
+ item.file((file: File) => {
59
+ files[item.fullPath] = file;
60
+ resolve();
61
+ });
62
+ });
63
+ } else if (isDirEntry(item) && !item.isFile) {
64
+ const reader = item.createReader();
65
+
66
+ return new Promise((resolve) => {
67
+ const promises: Promise<void>[] = [];
68
+
69
+ // Exhaustively read all directory entries
70
+ function readNextEntries() {
71
+ reader.readEntries((entries: unknown[]) => {
72
+ if (entries.length === 0) {
73
+ Promise.all(promises).then(() => resolve());
74
+ } else {
75
+ entries.forEach((entry) => {
76
+ promises.push(recurseDirectory(entry));
77
+ });
78
+ readNextEntries();
79
+ }
80
+ });
81
+ }
82
+
83
+ readNextEntries();
84
+ });
85
+ }
86
+
87
+ return Promise.resolve();
88
+ }
89
+
90
+ return new Promise((resolve) => {
91
+ // Process dropped items
92
+ const dtitems = dataTransfer.items && Array.from(dataTransfer.items);
93
+ const dtfiles = Array.from(dataTransfer.files);
94
+
95
+ if (dtitems && dtitems.length && "webkitGetAsEntry" in dtitems[0]) {
96
+ const promises: Promise<void>[] = [];
97
+
98
+ for (let i = 0; i < dtitems.length; i++) {
99
+ const item = dtitems[i] as unknown as {
100
+ webkitGetAsEntry: () => unknown;
101
+ };
102
+
103
+ if (typeof item.webkitGetAsEntry === "function") {
104
+ const entry = item.webkitGetAsEntry();
105
+ if (entry) {
106
+ promises.push(recurseDirectory(entry));
107
+ }
108
+ }
109
+ }
110
+
111
+ Promise.all(promises).then(() => resolve(files));
112
+ } else {
113
+ // Add a '/' prefix to match the file directory entry on webkit browsers
114
+ dtfiles
115
+ .filter((f) => f.size !== 0)
116
+ .forEach((f) => (files["/" + f.name] = f));
117
+
118
+ resolve(files);
119
+ }
120
+ });
121
+ }
122
+
123
+ /**
124
+ * Cleans a file path by removing '..' and '.' tokens and normalizing slashes
125
+ */
126
+ export function cleanFilePath(path: string): string {
127
+ return path
128
+ .replace(/\\/g, "/")
129
+ .split(/\//g)
130
+ .reduce((acc, el) => {
131
+ if (el === "..") acc.pop();
132
+ else if (el !== ".") acc.push(el);
133
+ return acc;
134
+ }, [] as string[])
135
+ .join("/");
136
+ }
137
+
138
+ /**
139
+ * Interface representing the structure of an URDF processor
140
+ */
141
+ export interface UrdfProcessor {
142
+ loadUrdf: (path: string) => void;
143
+ setUrlModifierFunc: (func: (url: string) => string) => void;
144
+ getPackage: () => string;
145
+ }
146
+
147
+ // Reference to hold the package path
148
+ const packageRef = { current: "" };
149
+
150
+ /**
151
+ * Reads the content of a URDF file
152
+ * @param file The URDF file object
153
+ * @returns A promise that resolves with the content of the file as a string
154
+ */
155
+ export function readUrdfFileContent(file: File): Promise<string> {
156
+ return new Promise((resolve, reject) => {
157
+ const reader = new FileReader();
158
+ reader.onload = (event) => {
159
+ if (event.target && event.target.result) {
160
+ resolve(event.target.result as string);
161
+ } else {
162
+ reject(new Error("Failed to read URDF file content"));
163
+ }
164
+ };
165
+ reader.onerror = () => reject(new Error("Error reading URDF file"));
166
+ reader.readAsText(file);
167
+ });
168
+ }
169
+
170
+ /**
171
+ * Downloads a zip file from a URL and extracts its contents
172
+ * @param zipUrl URL of the zip file to download
173
+ * @param urdfProcessor The URDF processor to use for loading
174
+ * @returns A promise that resolves with the extraction results
175
+ */
176
+ export async function downloadAndExtractZip(
177
+ zipUrl: string,
178
+ urdfProcessor: UrdfProcessor
179
+ ): Promise<{
180
+ files: Record<string, File>;
181
+ availableModels: string[];
182
+ blobUrls: Record<string, string>;
183
+ }> {
184
+ console.log("🔄 Downloading zip file from:", zipUrl);
185
+
186
+ try {
187
+ // Download the zip file
188
+ const response = await fetch(zipUrl);
189
+ if (!response.ok) {
190
+ throw new Error(`Failed to download zip: ${response.statusText}`);
191
+ }
192
+
193
+ const zipBlob = await response.blob();
194
+
195
+ // Load JSZip dynamically since it's much easier to work with than manual Blob handling
196
+ // We use dynamic import to avoid adding a dependency
197
+ const JSZip = (await import('jszip')).default;
198
+ const zip = new JSZip();
199
+
200
+ // Load the zip content
201
+ const contents = await zip.loadAsync(zipBlob);
202
+
203
+ // Convert zip contents to files
204
+ const files: Record<string, File> = {};
205
+ const filePromises: Promise<void>[] = [];
206
+
207
+ // Process each file in the zip
208
+ contents.forEach((relativePath, zipEntry) => {
209
+ if (!zipEntry.dir) {
210
+ const promise = zipEntry.async('blob').then(blob => {
211
+ // Create a file with the proper name and path
212
+ const path = '/' + relativePath;
213
+ files[path] = new File([blob], relativePath.split('/').pop() || 'unknown', {
214
+ type: getMimeType(relativePath.split('.').pop() || '')
215
+ });
216
+ });
217
+ filePromises.push(promise);
218
+ }
219
+ });
220
+
221
+ // Wait for all files to be processed
222
+ await Promise.all(filePromises);
223
+
224
+ // Get all file paths and clean them
225
+ const fileNames = Object.keys(files).map((n) => cleanFilePath(n));
226
+
227
+ // Filter all files ending in URDF
228
+ const availableModels = fileNames.filter((n) => /urdf$/i.test(n));
229
+
230
+ // Create blob URLs for URDF files
231
+ const blobUrls: Record<string, string> = {};
232
+ availableModels.forEach((path) => {
233
+ blobUrls[path] = URL.createObjectURL(files[path]);
234
+ });
235
+
236
+ // Extract the package base path from the first URDF model for reference
237
+ let packageBasePath = "";
238
+ if (availableModels.length > 0) {
239
+ // Extract the main directory path (e.g., '/cassie_description/')
240
+ const firstModel = availableModels[0];
241
+ const packageMatch = firstModel.match(/^(\/[^/]+\/)/);
242
+ if (packageMatch && packageMatch[1]) {
243
+ packageBasePath = packageMatch[1];
244
+ }
245
+ }
246
+
247
+ // Store the package path for future reference
248
+ const packagePathRef = packageBasePath;
249
+ urdfProcessor.setUrlModifierFunc((url) => {
250
+ // Find the matching file given the requested URL
251
+
252
+ // Store package reference for future use
253
+ if (packagePathRef) {
254
+ packageRef.current = packagePathRef;
255
+ }
256
+
257
+ // Simple approach: just find the first file that matches the end of the URL
258
+ const cleaned = cleanFilePath(url);
259
+
260
+ // Get the filename from the URL
261
+ const urlFilename = cleaned.split("/").pop() || "";
262
+
263
+ // Find the first file that ends with this filename
264
+ let fileName = fileNames.find((name) => name.endsWith(urlFilename));
265
+
266
+ // If no match found, just take the first file with a similar extension
267
+ if (!fileName && urlFilename.includes(".")) {
268
+ const extension = "." + urlFilename.split(".").pop();
269
+ fileName = fileNames.find((name) => name.endsWith(extension));
270
+ }
271
+
272
+ if (fileName !== undefined && fileName !== null) {
273
+ // Extract file extension for content type
274
+ const fileExtension = fileName.split(".").pop()?.toLowerCase() || "";
275
+
276
+ // Create blob URL with extension in the searchParams to help with format detection
277
+ const blob = new Blob([files[fileName]], {
278
+ type: getMimeType(fileExtension),
279
+ });
280
+ const blobUrl = URL.createObjectURL(blob) + "#." + fileExtension;
281
+
282
+ // Don't revoke immediately, wait for the mesh to be loaded
283
+ setTimeout(() => URL.revokeObjectURL(blobUrl), 5000);
284
+ return blobUrl;
285
+ }
286
+
287
+ console.warn(`No matching file found for: ${url}`);
288
+ return url;
289
+ });
290
+
291
+ return {
292
+ files,
293
+ availableModels,
294
+ blobUrls,
295
+ };
296
+ } catch (error) {
297
+ console.error("❌ Error downloading or extracting zip:", error);
298
+ throw error;
299
+ }
300
+ }
301
+
302
+ /**
303
+ * Processes dropped files and returns information about available URDF models
304
+ */
305
+ export async function processDroppedFiles(
306
+ dataTransfer: DataTransfer,
307
+ urdfProcessor: UrdfProcessor
308
+ ): Promise<{
309
+ files: Record<string, File>;
310
+ availableModels: string[];
311
+ blobUrls: Record<string, string>;
312
+ }> {
313
+ // Reset the package reference
314
+ packageRef.current = "";
315
+
316
+ // Convert dropped files into a structured format
317
+ const files = await dataTransferToFiles(dataTransfer);
318
+
319
+ // Get all file paths and clean them
320
+ const fileNames = Object.keys(files).map((n) => cleanFilePath(n));
321
+
322
+ // Filter all files ending in URDF
323
+ const availableModels = fileNames.filter((n) => /urdf$/i.test(n));
324
+
325
+ // Create blob URLs for URDF files
326
+ const blobUrls: Record<string, string> = {};
327
+ availableModels.forEach((path) => {
328
+ blobUrls[path] = URL.createObjectURL(files[path]);
329
+ });
330
+
331
+ // Extract the package base path from the first URDF model for reference
332
+ let packageBasePath = "";
333
+ if (availableModels.length > 0) {
334
+ // Extract the main directory path (e.g., '/cassie_description/')
335
+ const firstModel = availableModels[0];
336
+ const packageMatch = firstModel.match(/^(\/[^/]+\/)/);
337
+ if (packageMatch && packageMatch[1]) {
338
+ packageBasePath = packageMatch[1];
339
+ }
340
+ }
341
+
342
+ // Store the package path for future reference
343
+ const packagePathRef = packageBasePath;
344
+ urdfProcessor.setUrlModifierFunc((url) => {
345
+ // Find the matching file given the requested URL
346
+
347
+ // Store package reference for future use
348
+ if (packagePathRef) {
349
+ packageRef.current = packagePathRef;
350
+ }
351
+
352
+ // Simple approach: just find the first file that matches the end of the URL
353
+ const cleaned = cleanFilePath(url);
354
+
355
+ // Get the filename from the URL
356
+ const urlFilename = cleaned.split("/").pop() || "";
357
+
358
+ // Find the first file that ends with this filename
359
+ let fileName = fileNames.find((name) => name.endsWith(urlFilename));
360
+
361
+ // If no match found, just take the first file with a similar extension
362
+ if (!fileName && urlFilename.includes(".")) {
363
+ const extension = "." + urlFilename.split(".").pop();
364
+ fileName = fileNames.find((name) => name.endsWith(extension));
365
+ }
366
+
367
+ if (fileName !== undefined && fileName !== null) {
368
+ // Extract file extension for content type
369
+ const fileExtension = fileName.split(".").pop()?.toLowerCase() || "";
370
+
371
+ // Create blob URL with extension in the searchParams to help with format detection
372
+ const blob = new Blob([files[fileName]], {
373
+ type: getMimeType(fileExtension),
374
+ });
375
+ const blobUrl = URL.createObjectURL(blob) + "#." + fileExtension;
376
+
377
+ // Don't revoke immediately, wait for the mesh to be loaded
378
+ setTimeout(() => URL.revokeObjectURL(blobUrl), 5000);
379
+ return blobUrl;
380
+ }
381
+
382
+ console.warn(`No matching file found for: ${url}`);
383
+ return url;
384
+ });
385
+
386
+ return {
387
+ files,
388
+ availableModels,
389
+ blobUrls,
390
+ };
391
+ }
392
+
393
+ /**
394
+ * Get the MIME type for a file extension
395
+ */
396
+ function getMimeType(extension: string): string {
397
+ switch (extension.toLowerCase()) {
398
+ case "stl":
399
+ return "model/stl";
400
+ case "obj":
401
+ return "model/obj";
402
+ case "gltf":
403
+ case "glb":
404
+ return "model/gltf+json";
405
+ case "dae":
406
+ return "model/vnd.collada+xml";
407
+ case "urdf":
408
+ return "application/xml";
409
+ default:
410
+ return "application/octet-stream";
411
+ }
412
+ }
413
+
viewer/src/lib/meshLoaders.ts ADDED
@@ -0,0 +1,94 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import {
2
+ LoadingManager,
3
+ MeshPhongMaterial,
4
+ Mesh,
5
+ Color,
6
+ Object3D,
7
+ Group,
8
+ BufferGeometry,
9
+ } from "three";
10
+ import { STLLoader } from "three/examples/jsm/loaders/STLLoader.js";
11
+ import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";
12
+ import { ColladaLoader } from "three/examples/jsm/loaders/ColladaLoader.js";
13
+ import { OBJLoader } from "three/examples/jsm/loaders/OBJLoader.js";
14
+
15
+ /**
16
+ * Loads mesh files of different formats
17
+ * @param path The path to the mesh file
18
+ * @param manager The THREE.js loading manager
19
+ * @param done Callback function when loading is complete
20
+ */
21
+ export const loadMeshFile = (
22
+ path: string,
23
+ manager: LoadingManager,
24
+ done: (result: Object3D | Group | Mesh | null, err?: Error) => void
25
+ ) => {
26
+ // First try to get extension from the original path
27
+ let ext = path.split(/\./g).pop()?.toLowerCase();
28
+
29
+ // If the URL is a blob URL with a fragment containing the extension, use that
30
+ if (path.startsWith("blob:") && path.includes("#.")) {
31
+ const fragmentExt = path.split("#.").pop();
32
+ if (fragmentExt) {
33
+ ext = fragmentExt.toLowerCase();
34
+ }
35
+ }
36
+
37
+ // If we can't determine extension, try to check Content-Type
38
+ if (!ext) {
39
+ console.error(`Could not determine file extension for: ${path}`);
40
+ done(null, new Error(`Unsupported file format: ${path}`));
41
+ return;
42
+ }
43
+
44
+ switch (ext) {
45
+ case "gltf":
46
+ case "glb":
47
+ new GLTFLoader(manager).load(
48
+ path,
49
+ (result) => done(result.scene),
50
+ null,
51
+ (err) => done(null, err as Error)
52
+ );
53
+ break;
54
+ case "obj":
55
+ new OBJLoader(manager).load(
56
+ path,
57
+ (result) => done(result),
58
+ null,
59
+ (err) => done(null, err as Error)
60
+ );
61
+ break;
62
+ case "dae":
63
+ new ColladaLoader(manager).load(
64
+ path,
65
+ (result) => done(result.scene),
66
+ null,
67
+ (err) => done(null, err as Error)
68
+ );
69
+ break;
70
+ case "stl":
71
+ new STLLoader(manager).load(
72
+ path,
73
+ (result) => {
74
+ const material = new MeshPhongMaterial();
75
+ const mesh = new Mesh(result, material);
76
+ done(mesh);
77
+ },
78
+ null,
79
+ (err) => done(null, err as Error)
80
+ );
81
+ break;
82
+ default:
83
+ done(null, new Error(`Unsupported file format: ${ext}`));
84
+ }
85
+ };
86
+
87
+ /**
88
+ * Creates a color in THREE.js format from a CSS color string
89
+ * @param color The CSS color string
90
+ * @returns A THREE.js Color
91
+ */
92
+ export const createColor = (color: string): Color => {
93
+ return new Color(color);
94
+ };
viewer/src/lib/types.ts ADDED
@@ -0,0 +1,97 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Shared type definitions for URDF parsing from supabase edge function
3
+ */
4
+
5
+ export interface UrdfData {
6
+ name?: string;
7
+ description?: string;
8
+ mass?: number;
9
+ dofs?: number;
10
+ joints?: {
11
+ revolute?: number;
12
+ prismatic?: number;
13
+ continuous?: number;
14
+ fixed?: number;
15
+ other?: number;
16
+ };
17
+ links?: {
18
+ name?: string;
19
+ mass?: number;
20
+ }[];
21
+ materials?: {
22
+ name?: string;
23
+ percentage?: number;
24
+ }[];
25
+ }
26
+
27
+ /**
28
+ * Interface representing a URDF file model
29
+ */
30
+ export interface UrdfFileModel {
31
+ /**
32
+ * Path to the URDF file
33
+ */
34
+ path: string;
35
+
36
+ /**
37
+ * Blob URL for accessing the file
38
+ */
39
+ blobUrl: string;
40
+
41
+ /**
42
+ * Name of the model extracted from the file path
43
+ */
44
+ name?: string;
45
+ }
46
+
47
+ /**
48
+ * Joint animation configuration interface
49
+ */
50
+ export interface JointAnimationConfig {
51
+ /** Joint name in the URDF */
52
+ name: string;
53
+ /** Animation type (sine, linear, etc.) */
54
+ type: "sine" | "linear" | "constant";
55
+ /** Minimum value for the joint */
56
+ min: number;
57
+ /** Maximum value for the joint */
58
+ max: number;
59
+ /** Speed multiplier for the animation (lower = slower) */
60
+ speed: number;
61
+ /** Phase offset in radians */
62
+ offset: number;
63
+ /** Whether angles are in degrees (will be converted to radians) */
64
+ isDegrees?: boolean;
65
+ /** For more complex movements, a custom function that takes time and returns a value between 0 and 1 */
66
+ customEasing?: (time: number) => number;
67
+ }
68
+
69
+ /**
70
+ * Robot animation configuration interface
71
+ */
72
+ export interface RobotAnimationConfig {
73
+ /** Array of joint configurations */
74
+ joints: JointAnimationConfig[];
75
+ /** Global speed multiplier */
76
+ speedMultiplier?: number;
77
+ }
78
+
79
+ export interface AnimationRequest {
80
+ robotName: string;
81
+ urdfContent: string;
82
+ description: string; // Natural language description of the desired animation
83
+ }
84
+
85
+ export interface ContentItem {
86
+ id: string;
87
+ title: string;
88
+ imageUrl: string;
89
+ description?: string;
90
+ categories: string[];
91
+ urdfPath: string;
92
+ }
93
+
94
+ export interface Category {
95
+ id: string;
96
+ name: string;
97
+ }
viewer/src/lib/urdfViewerHelpers.ts ADDED
@@ -0,0 +1,161 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { LoadingManager, Object3D } from "three";
2
+ import { toast } from "sonner";
3
+ import { loadMeshFile } from "./meshLoaders";
4
+ import { URDFViewerElement } from "./urdfAnimationHelpers";
5
+
6
+ // Extended URDF Viewer Element with mesh loading capability
7
+ export interface ExtendedURDFViewerElement extends URDFViewerElement {
8
+ loadMeshFunc: (
9
+ path: string,
10
+ manager: LoadingManager,
11
+ done: (result: Object3D | null, err?: Error) => void
12
+ ) => void;
13
+ }
14
+
15
+ /**
16
+ * Creates and configures a URDF viewer element
17
+ */
18
+ export function createUrdfViewer(
19
+ container: HTMLDivElement,
20
+ isDarkMode: boolean
21
+ ): ExtendedURDFViewerElement {
22
+ // Clear any existing content
23
+ container.innerHTML = "";
24
+
25
+ // Create the urdf-viewer element
26
+ const viewer = document.createElement(
27
+ "urdf-viewer"
28
+ ) as ExtendedURDFViewerElement;
29
+ viewer.classList.add("w-full", "h-full");
30
+
31
+ // Add the element to the container
32
+ container.appendChild(viewer);
33
+
34
+ // Set initial viewer properties
35
+ viewer.setAttribute("up", "Z");
36
+ viewer.setAttribute("highlight-color", isDarkMode ? "#5b9aff" : "#3373ff");
37
+ viewer.setAttribute("ambient-color", isDarkMode ? "#202a30" : "#cfd8dc");
38
+ viewer.setAttribute("auto-redraw", "true");
39
+ viewer.setAttribute("display-shadow", ""); // Enable shadows
40
+
41
+ return viewer;
42
+ }
43
+
44
+ /**
45
+ * Setup mesh loading function for URDF viewer
46
+ */
47
+ export function setupMeshLoader(
48
+ viewer: ExtendedURDFViewerElement,
49
+ urlModifierFunc: ((url: string) => string) | null
50
+ ): void {
51
+ if ("loadMeshFunc" in viewer) {
52
+ viewer.loadMeshFunc = (
53
+ path: string,
54
+ manager: LoadingManager,
55
+ done: (result: Object3D | null, err?: Error) => void
56
+ ) => {
57
+ // Apply URL modifier if available (for custom uploads)
58
+ const modifiedPath = urlModifierFunc ? urlModifierFunc(path) : path;
59
+
60
+ // If loading fails, log the error but continue
61
+ try {
62
+ loadMeshFile(modifiedPath, manager, (result, err) => {
63
+ if (err) {
64
+ console.warn(`Error loading mesh ${modifiedPath}:`, err);
65
+ // Try to continue with other meshes
66
+ done(null);
67
+ } else {
68
+ done(result);
69
+ }
70
+ });
71
+ } catch (err) {
72
+ console.error(`Exception loading mesh ${modifiedPath}:`, err);
73
+ done(null, err as Error);
74
+ }
75
+ };
76
+ }
77
+ }
78
+
79
+ /**
80
+ * Setup event handlers for joint highlighting
81
+ */
82
+ export function setupJointHighlighting(
83
+ viewer: URDFViewerElement,
84
+ setHighlightedJoint: (joint: string | null) => void
85
+ ): () => void {
86
+ const onJointMouseover = (e: Event) => {
87
+ const customEvent = e as CustomEvent;
88
+ setHighlightedJoint(customEvent.detail);
89
+ };
90
+
91
+ const onJointMouseout = () => {
92
+ setHighlightedJoint(null);
93
+ };
94
+
95
+ // Add event listeners
96
+ viewer.addEventListener("joint-mouseover", onJointMouseover);
97
+ viewer.addEventListener("joint-mouseout", onJointMouseout);
98
+
99
+ // Return cleanup function
100
+ return () => {
101
+ viewer.removeEventListener("joint-mouseover", onJointMouseover);
102
+ viewer.removeEventListener("joint-mouseout", onJointMouseout);
103
+ };
104
+ }
105
+
106
+ /**
107
+ * Setup model loading and error handling
108
+ */
109
+ export function setupModelLoading(
110
+ viewer: URDFViewerElement,
111
+ urdfPath: string,
112
+ packagePath: string,
113
+ setCustomUrdfPath: (path: string) => void,
114
+ alternativeUrdfModels: string[] = [] // Add parameter for alternative models
115
+ ): () => void {
116
+ // Add XML content type hint for blob URLs
117
+ const loadPath =
118
+ urdfPath.startsWith("blob:") && !urdfPath.includes("#.")
119
+ ? urdfPath + "#.urdf" // Add extension hint if it's a blob URL
120
+ : urdfPath;
121
+
122
+ // Set the URDF path
123
+ viewer.setAttribute("urdf", loadPath);
124
+ viewer.setAttribute("package", packagePath);
125
+
126
+ // Handle error loading
127
+ const onLoadError = () => {
128
+ toast.error("Failed to load model", {
129
+ description: "There was an error loading the URDF model.",
130
+ duration: 3000,
131
+ });
132
+
133
+ // Use the provided alternativeUrdfModels instead of the global window object
134
+ if (alternativeUrdfModels.length > 0) {
135
+ const nextModel = alternativeUrdfModels[0];
136
+ if (nextModel) {
137
+ setCustomUrdfPath(nextModel);
138
+ toast.info("Trying alternative model...", {
139
+ description: `First model failed to load. Trying ${
140
+ nextModel.split("/").pop() || "alternative model"
141
+ }`,
142
+ duration: 2000,
143
+ });
144
+ }
145
+ }
146
+ };
147
+
148
+ viewer.addEventListener("error", onLoadError);
149
+
150
+ // Return cleanup function
151
+ return () => {
152
+ viewer.removeEventListener("error", onLoadError);
153
+ };
154
+ }
155
+
156
+ // For backward compatibility - to be removed in the future
157
+ declare global {
158
+ interface Window {
159
+ alternativeUrdfModels?: string[];
160
+ }
161
+ }
viewer/src/lib/utils.ts ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ import { clsx, type ClassValue } from "clsx";
2
+ import { twMerge } from "tailwind-merge";
3
+
4
+ export function cn(...inputs: ClassValue[]) {
5
+ return twMerge(clsx(inputs));
6
+ }
viewer/src/main.tsx ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ import { StrictMode } from 'react'
2
+ import { createRoot } from 'react-dom/client'
3
+ import './index.css'
4
+ import App from './App.tsx'
5
+
6
+ createRoot(document.getElementById('root')!).render(
7
+ <StrictMode>
8
+ <App />
9
+ </StrictMode>,
10
+ )
viewer/src/pages/UrdfView.tsx ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useEffect } from "react";
2
+ import { UrdfSelectionModalContainer } from "@/components/UrdfSelectionModalContainer";
3
+ import { useUrdf } from "@/hooks/useUrdf";
4
+ import URDFViewer from "@/components/UrdfViewer";
5
+ // This is needed to make TypeScript recognize webkitdirectory as a valid attribute
6
+ declare module "react" {
7
+ interface InputHTMLAttributes<T> extends React.HTMLAttributes<T> {
8
+ directory?: string;
9
+ webkitdirectory?: string;
10
+ }
11
+ }
12
+
13
+ const UrdfView: React.FC = () => {
14
+ // Get the setIsDefaultModel function from the useUrdf hook
15
+ const { setIsDefaultModel } = useUrdf();
16
+ // Set isDefaultModel to true when the component mounts
17
+ useEffect(() => {
18
+ setIsDefaultModel(true);
19
+ console.log("🤖 Playground opened: Setting default model to true");
20
+ }, [setIsDefaultModel]);
21
+
22
+ return (
23
+ <div className="relative w-full h-full overflow-hidden">
24
+ {/* Main content that fills the container */}
25
+ <UrdfSelectionModalContainer />
26
+ <div className="w-full h-full">
27
+ <URDFViewer />
28
+ </div>
29
+ </div>
30
+ );
31
+ };
32
+
33
+ export default UrdfView;
viewer/src/vite-env.d.ts ADDED
@@ -0,0 +1 @@
 
 
1
+ /// <reference types="vite/client" />
viewer/tsconfig.app.json ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "extends": "./tsconfig.json",
3
+ "compilerOptions": {
4
+ "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
5
+ "target": "ES2020",
6
+ "useDefineForClassFields": true,
7
+ "lib": ["ES2020", "DOM", "DOM.Iterable"],
8
+ "module": "ESNext",
9
+ "skipLibCheck": true,
10
+
11
+ /* Bundler mode */
12
+ "moduleResolution": "bundler",
13
+ "allowImportingTsExtensions": true,
14
+ "isolatedModules": true,
15
+ "moduleDetection": "force",
16
+ "noEmit": true,
17
+ "jsx": "react-jsx",
18
+
19
+ /* Linting */
20
+ "strict": true,
21
+ "noUnusedLocals": true,
22
+ "noUnusedParameters": true,
23
+ "noFallthroughCasesInSwitch": true,
24
+ "noUncheckedSideEffectImports": true,
25
+
26
+ /* Path alias configuration */
27
+ "baseUrl": ".",
28
+ "paths": {
29
+ "@/*": ["src/*"]
30
+ }
31
+ },
32
+ "include": ["src/**/*.ts", "src/**/*.tsx"]
33
+ }
viewer/tsconfig.json ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "files": [],
3
+ "references": [
4
+ { "path": "./tsconfig.app.json" },
5
+ { "path": "./tsconfig.node.json" }
6
+ ],
7
+ "compilerOptions": {
8
+ "baseUrl": ".",
9
+ "paths": {
10
+ "@/*": ["src/*"]
11
+ }
12
+ }
13
+ }
viewer/tsconfig.node.json ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "compilerOptions": {
3
+ "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
4
+ "target": "ES2022",
5
+ "lib": ["ES2023"],
6
+ "module": "ESNext",
7
+ "skipLibCheck": true,
8
+
9
+ /* Bundler mode */
10
+ "moduleResolution": "bundler",
11
+ "allowImportingTsExtensions": true,
12
+ "isolatedModules": true,
13
+ "moduleDetection": "force",
14
+ "noEmit": true,
15
+
16
+ /* Linting */
17
+ "strict": true,
18
+ "noUnusedLocals": true,
19
+ "noUnusedParameters": true,
20
+ "noFallthroughCasesInSwitch": true,
21
+ "noUncheckedSideEffectImports": true
22
+ },
23
+ "include": ["vite.config.ts"]
24
+ }
viewer/vite.config.ts ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { defineConfig } from "vite";
2
+ import path from "path";
3
+ import react from "@vitejs/plugin-react";
4
+
5
+ // https://vite.dev/config/
6
+ export default defineConfig({
7
+ plugins: [react()],
8
+ resolve: {
9
+ alias: {
10
+ "@": path.resolve(__dirname, "./src"),
11
+ },
12
+ },
13
+ });