@wandelbots/wandelbots-js-react-components 2.32.0-pr.feature-robot-precondition-list.372.297bf4f → 2.32.0-pr.feature-robot-precondition-list.372.5d8a86e

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,568 @@
1
+ import {
2
+ Box,
3
+ Button,
4
+ Card,
5
+ Divider,
6
+ Typography,
7
+ useMediaQuery,
8
+ useTheme,
9
+ } from "@mui/material"
10
+ import { Bounds, useBounds } from "@react-three/drei"
11
+ import { Canvas } from "@react-three/fiber"
12
+ import type {
13
+ ConnectedMotionGroup,
14
+ RobotControllerStateOperationModeEnum,
15
+ RobotControllerStateSafetyStateEnum,
16
+ } from "@wandelbots/nova-js/v1"
17
+ import { observer } from "mobx-react-lite"
18
+ import { useCallback, useEffect, useRef, useState } from "react"
19
+ import { useTranslation } from "react-i18next"
20
+ import type { Group } from "three"
21
+ import { externalizeComponent } from "../externalizeComponent"
22
+ import { PresetEnvironment } from "./3d-viewport/PresetEnvironment"
23
+ import { CycleTimer } from "./CycleTimer"
24
+ import type { ProgramState } from "./ProgramControl"
25
+ import { ProgramStateIndicator } from "./ProgramStateIndicator"
26
+ import { Robot } from "./robots/Robot"
27
+
28
+ // Component to refresh bounds when model renders
29
+ function BoundsRefresher({
30
+ modelRenderTrigger,
31
+ children,
32
+ isPortrait = false,
33
+ }: {
34
+ modelRenderTrigger: number
35
+ children: React.ReactNode
36
+ isPortrait?: boolean
37
+ }) {
38
+ const bounds = useBounds()
39
+
40
+ useEffect(() => {
41
+ if (modelRenderTrigger > 0) {
42
+ // For portrait mode, use more aggressive fitting
43
+ if (isPortrait) {
44
+ bounds.refresh().clip().fit()
45
+ } else {
46
+ bounds.refresh().clip().fit()
47
+ }
48
+ }
49
+ }, [modelRenderTrigger, bounds, isPortrait])
50
+
51
+ return <>{children}</>
52
+ }
53
+
54
+ export interface RobotCardProps {
55
+ /** Name of the robot displayed at the top */
56
+ robotName: string
57
+ /** Current program state */
58
+ programState: ProgramState
59
+ /** Current safety state of the robot controller */
60
+ safetyState: RobotControllerStateSafetyStateEnum
61
+ /** Current operation mode of the robot controller */
62
+ operationMode: RobotControllerStateOperationModeEnum
63
+ /** Whether the "Drive to Home" button should be enabled */
64
+ driveToHomeEnabled?: boolean
65
+ /** Callback fired when "Drive to Home" button is pressed */
66
+ onDriveToHomePress?: () => void
67
+ /** Callback fired when "Drive to Home" button is released */
68
+ onDriveToHomeRelease?: () => void
69
+ /** Connected motion group for the robot */
70
+ connectedMotionGroup: ConnectedMotionGroup
71
+ /** Custom robot component to render (optional, defaults to Robot) */
72
+ robotComponent?: React.ComponentType<{
73
+ connectedMotionGroup: ConnectedMotionGroup
74
+ flangeRef?: React.Ref<Group>
75
+ postModelRender?: () => void
76
+ transparentColor?: string
77
+ getModel?: (modelFromController: string) => string
78
+ }>
79
+ /** Custom cycle timer component (optional, defaults to CycleTimer) */
80
+ cycleTimerComponent?: React.ComponentType<{
81
+ variant?: "default" | "small"
82
+ compact?: boolean
83
+ onCycleComplete: (controls: {
84
+ startNewCycle: (maxTimeSeconds: number, elapsedSeconds?: number) => void
85
+ pause: () => void
86
+ resume: () => void
87
+ isPaused: () => boolean
88
+ }) => void
89
+ onCycleEnd?: () => void
90
+ autoStart?: boolean
91
+ className?: string
92
+ }>
93
+ /** Additional CSS class name */
94
+ className?: string
95
+ }
96
+
97
+ /**
98
+ * A responsive card component that displays a 3D robot with states and controls.
99
+ * The card automatically adapts to its container's size and aspect ratio.
100
+ *
101
+ * Features:
102
+ * - Fully responsive Material-UI Card that adapts to container dimensions
103
+ * - Automatic layout switching based on aspect ratio:
104
+ * - Portrait mode: Vertical layout with robot in center
105
+ * - Landscape mode: Horizontal layout with robot on left, content on right (left-aligned)
106
+ * - Responsive 3D robot rendering:
107
+ * - Scales dynamically with container size
108
+ * - Hides at very small sizes to preserve usability
109
+ * - Adaptive margin based on available space
110
+ * - Smart spacing and padding that reduces at smaller sizes
111
+ * - Minimum size constraints for usability while maximizing content density
112
+ * - Robot name displayed in Typography h6 at top-left
113
+ * - Program state indicator below the name
114
+ * - Auto-fitting 3D robot model that scales with container size
115
+ * - Compact cycle time component with small variant
116
+ * - Transparent gray divider line
117
+ * - "Drive to Home" button with press-and-hold functionality
118
+ * - Localization support via react-i18next
119
+ * - Material-UI theming integration
120
+ */
121
+ export const RobotCard = externalizeComponent(
122
+ observer(
123
+ ({
124
+ robotName,
125
+ programState,
126
+ safetyState,
127
+ operationMode,
128
+ driveToHomeEnabled = false,
129
+ onDriveToHomePress,
130
+ onDriveToHomeRelease,
131
+ connectedMotionGroup,
132
+ robotComponent: RobotComponent = Robot,
133
+ cycleTimerComponent: CycleTimerComponent = CycleTimer,
134
+ className,
135
+ }: RobotCardProps) => {
136
+ const theme = useTheme()
137
+ const { t } = useTranslation()
138
+ const [isDriveToHomePressed, setIsDriveToHomePressed] = useState(false)
139
+ const driveButtonRef = useRef<HTMLButtonElement>(null)
140
+ const robotRef = useRef<Group>(null)
141
+ const cardRef = useRef<HTMLDivElement>(null)
142
+ const [isLandscape, setIsLandscape] = useState(false)
143
+ const [cardSize, setCardSize] = useState<{
144
+ width: number
145
+ height: number
146
+ }>({ width: 400, height: 600 })
147
+ const [modelRenderTrigger, setModelRenderTrigger] = useState(0)
148
+
149
+ // Responsive breakpoints
150
+ const isExtraSmall = useMediaQuery(theme.breakpoints.down("sm"))
151
+ const isSmall = useMediaQuery(theme.breakpoints.down("md"))
152
+
153
+ // Hook to detect aspect ratio and size changes
154
+ useEffect(() => {
155
+ const checkDimensions = () => {
156
+ if (cardRef.current) {
157
+ const { offsetWidth, offsetHeight } = cardRef.current
158
+ setIsLandscape(offsetWidth > offsetHeight)
159
+ setCardSize({ width: offsetWidth, height: offsetHeight })
160
+ }
161
+ }
162
+
163
+ // Initial check
164
+ checkDimensions()
165
+
166
+ // Set up ResizeObserver to watch for size changes
167
+ const resizeObserver = new ResizeObserver(checkDimensions)
168
+ if (cardRef.current) {
169
+ resizeObserver.observe(cardRef.current)
170
+ }
171
+
172
+ return () => {
173
+ resizeObserver.disconnect()
174
+ }
175
+ }, [])
176
+
177
+ const handleModelRender = useCallback(() => {
178
+ // Trigger bounds refresh when model renders
179
+ setModelRenderTrigger((prev) => prev + 1)
180
+ }, [])
181
+
182
+ const handleDriveToHomeMouseDown = useCallback(() => {
183
+ if (!driveToHomeEnabled || !onDriveToHomePress) return
184
+ setIsDriveToHomePressed(true)
185
+ onDriveToHomePress()
186
+ }, [driveToHomeEnabled, onDriveToHomePress])
187
+
188
+ const handleDriveToHomeMouseUp = useCallback(() => {
189
+ if (!driveToHomeEnabled || !onDriveToHomeRelease) return
190
+ setIsDriveToHomePressed(false)
191
+ onDriveToHomeRelease()
192
+ }, [driveToHomeEnabled, onDriveToHomeRelease])
193
+
194
+ const handleDriveToHomeMouseLeave = useCallback(() => {
195
+ if (isDriveToHomePressed && onDriveToHomeRelease) {
196
+ setIsDriveToHomePressed(false)
197
+ onDriveToHomeRelease()
198
+ }
199
+ }, [isDriveToHomePressed, onDriveToHomeRelease])
200
+
201
+ // Mock cycle timer controls for now
202
+ const handleCycleComplete = useCallback(
203
+ (_controls: {
204
+ startNewCycle: (
205
+ maxTimeSeconds: number,
206
+ elapsedSeconds?: number,
207
+ ) => void
208
+ pause: () => void
209
+ resume: () => void
210
+ isPaused: () => boolean
211
+ }) => {
212
+ // TODO: Implement cycle timer integration if needed
213
+ // Controls are available here for future integration
214
+ },
215
+ [],
216
+ )
217
+
218
+ // Determine if robot should be hidden at small sizes to save space
219
+ // Less aggressive hiding for portrait mode since robot is in center
220
+ const shouldHideRobot = isLandscape
221
+ ? cardSize.width < 350 || cardSize.height < 250 // Aggressive for landscape
222
+ : cardSize.width < 250 || cardSize.height < 180 // Less aggressive for portrait
223
+
224
+ // Calculate responsive robot scale based on card size and orientation
225
+ const getRobotScale = () => {
226
+ if (shouldHideRobot) return 0
227
+
228
+ if (isLandscape) {
229
+ // More aggressive scaling for landscape since robot is on side
230
+ if (cardSize.width < 450) return 0.8
231
+ if (cardSize.width < 550) return 0.9
232
+ return 1
233
+ } else {
234
+ // Less aggressive scaling for portrait since robot is central
235
+ if (cardSize.width < 300) return 0.8
236
+ if (cardSize.width < 400) return 0.9
237
+ return 1
238
+ }
239
+ }
240
+
241
+ const robotScale = getRobotScale()
242
+
243
+ return (
244
+ <Card
245
+ ref={cardRef}
246
+ className={className}
247
+ elevation={5}
248
+ sx={{
249
+ width: "100%",
250
+ height: "100%",
251
+ display: "flex",
252
+ flexDirection: isLandscape ? "row" : "column",
253
+ position: "relative",
254
+ overflow: "hidden",
255
+ minWidth: { xs: 180, sm: 220, md: 250 },
256
+ minHeight: isLandscape
257
+ ? { xs: 160, sm: 200, md: 250 }
258
+ : { xs: 200, sm: 280, md: 350 },
259
+ border:
260
+ "1px solid var(--secondary-_states-outlinedBorder, #FFFFFF1F)",
261
+ borderRadius: "18px",
262
+ boxShadow: "none",
263
+ }}
264
+ >
265
+ {isLandscape ? (
266
+ <>
267
+ {/* Landscape Layout: Robot on left, content on right */}
268
+ <Box
269
+ sx={{
270
+ flex: "0 0 50%",
271
+ position: "relative",
272
+ height: "100%",
273
+ minHeight: "100%",
274
+ maxHeight: "100%",
275
+ borderRadius: 1,
276
+ m: { xs: 1.5, sm: 2, md: 3 },
277
+ mr: { xs: 0.75, sm: 1, md: 1.5 },
278
+ overflow: "hidden", // Prevent content from affecting container size
279
+ display: shouldHideRobot ? "none" : "block",
280
+ }}
281
+ >
282
+ {!shouldHideRobot && (
283
+ <Canvas
284
+ camera={{
285
+ position: [4, 4, 4], // Move camera further back for orthographic view
286
+ fov: 15, // Low FOV for near-orthographic projection
287
+ }}
288
+ shadows
289
+ frameloop="demand"
290
+ style={{
291
+ borderRadius: theme.shape.borderRadius,
292
+ width: "100%",
293
+ height: "100%",
294
+ background: "transparent",
295
+ position: "absolute",
296
+ top: 0,
297
+ left: 0,
298
+ }}
299
+ dpr={[1, 2]}
300
+ gl={{ alpha: true, antialias: true }}
301
+ >
302
+ <PresetEnvironment />
303
+ <Bounds fit clip observe margin={1}>
304
+ <BoundsRefresher
305
+ modelRenderTrigger={modelRenderTrigger}
306
+ isPortrait={false}
307
+ >
308
+ <group ref={robotRef} scale={robotScale}>
309
+ <RobotComponent
310
+ connectedMotionGroup={connectedMotionGroup}
311
+ postModelRender={handleModelRender}
312
+ />
313
+ </group>
314
+ </BoundsRefresher>
315
+ </Bounds>
316
+ </Canvas>
317
+ )}
318
+ </Box>
319
+
320
+ {/* Content container on right */}
321
+ <Box
322
+ sx={{
323
+ flex: shouldHideRobot ? "1" : "1",
324
+ display: "flex",
325
+ flexDirection: "column",
326
+ justifyContent: "flex-start",
327
+ width: shouldHideRobot ? "100%" : "50%",
328
+ }}
329
+ >
330
+ {/* Header section with robot name and program state */}
331
+ <Box
332
+ sx={{
333
+ p: { xs: 1.5, sm: 2, md: 3 },
334
+ pb: { xs: 1, sm: 1.5, md: 2 },
335
+ textAlign: "left",
336
+ }}
337
+ >
338
+ <Typography variant="h6" component="h2" sx={{ mb: 1 }}>
339
+ {robotName}
340
+ </Typography>
341
+ <ProgramStateIndicator
342
+ programState={programState}
343
+ safetyState={safetyState}
344
+ operationMode={operationMode}
345
+ />
346
+ </Box>
347
+
348
+ {/* Bottom section with runtime, cycle time, and button */}
349
+ <Box
350
+ sx={{
351
+ p: { xs: 1.5, sm: 2, md: 3 },
352
+ pt: 0,
353
+ flex: "1",
354
+ display: "flex",
355
+ flexDirection: "column",
356
+ justifyContent: "space-between",
357
+ }}
358
+ >
359
+ <Box>
360
+ {/* Runtime display */}
361
+ <Typography
362
+ variant="body1"
363
+ sx={{
364
+ mb: 0,
365
+ color: "var(--text-secondary, #FFFFFFB2)",
366
+ textAlign: "left",
367
+ }}
368
+ >
369
+ {t("RobotCard.Runtime.lb")}
370
+ </Typography>
371
+
372
+ {/* Compact cycle time component directly below runtime */}
373
+ <Box sx={{ textAlign: "left" }}>
374
+ <CycleTimerComponent
375
+ variant="small"
376
+ compact
377
+ onCycleComplete={handleCycleComplete}
378
+ />
379
+ </Box>
380
+
381
+ {/* Divider */}
382
+ <Divider
383
+ sx={{
384
+ mt: 1,
385
+ mb: 0,
386
+ borderColor: theme.palette.divider,
387
+ opacity: 0.5,
388
+ }}
389
+ />
390
+ </Box>
391
+
392
+ <Box sx={{ mt: "auto" }}>
393
+ {/* Drive to Home button with some space */}
394
+ <Box
395
+ sx={{
396
+ display: "flex",
397
+ justifyContent: "flex-start",
398
+ mt: { xs: 1, sm: 1.5, md: 2 },
399
+ mb: { xs: 0.5, sm: 0.75, md: 1 },
400
+ }}
401
+ >
402
+ <Button
403
+ ref={driveButtonRef}
404
+ variant="contained"
405
+ color="secondary"
406
+ size="small"
407
+ disabled={!driveToHomeEnabled}
408
+ onMouseDown={handleDriveToHomeMouseDown}
409
+ onMouseUp={handleDriveToHomeMouseUp}
410
+ onMouseLeave={handleDriveToHomeMouseLeave}
411
+ onTouchStart={handleDriveToHomeMouseDown}
412
+ onTouchEnd={handleDriveToHomeMouseUp}
413
+ sx={{
414
+ textTransform: "none",
415
+ px: 1.5,
416
+ py: 0.5,
417
+ }}
418
+ >
419
+ {t("RobotCard.DriveToHome.bt")}
420
+ </Button>
421
+ </Box>
422
+ </Box>
423
+ </Box>
424
+ </Box>
425
+ </>
426
+ ) : (
427
+ <>
428
+ {/* Portrait Layout: Header, Robot, Footer */}
429
+ <Box
430
+ sx={{
431
+ p: 3,
432
+ height: "100%",
433
+ display: "flex",
434
+ flexDirection: "column",
435
+ }}
436
+ >
437
+ {/* Header section with robot name and program state */}
438
+ <Box>
439
+ <Typography variant="h6" component="h2" sx={{ mb: 1 }}>
440
+ {robotName}
441
+ </Typography>
442
+ <ProgramStateIndicator
443
+ programState={programState}
444
+ safetyState={safetyState}
445
+ operationMode={operationMode}
446
+ />
447
+ </Box>
448
+
449
+ {/* 3D Robot viewport in center */}
450
+ <Box
451
+ sx={{
452
+ flex: shouldHideRobot ? 0 : 1,
453
+ position: "relative",
454
+ minHeight: shouldHideRobot
455
+ ? 0
456
+ : { xs: 120, sm: 150, md: 200 },
457
+ height: shouldHideRobot ? 0 : "auto",
458
+ borderRadius: 1,
459
+ overflow: "hidden",
460
+ display: shouldHideRobot ? "none" : "block",
461
+ }}
462
+ >
463
+ {!shouldHideRobot && (
464
+ <Canvas
465
+ camera={{
466
+ position: [3, 3, 3], // Closer camera position for portrait to make robot larger
467
+ fov: 20, // Slightly higher FOV for portrait to fill better
468
+ }}
469
+ shadows
470
+ frameloop="demand"
471
+ style={{
472
+ borderRadius: theme.shape.borderRadius,
473
+ width: "100%",
474
+ height: "100%",
475
+ background: "transparent",
476
+ position: "absolute",
477
+ top: 0,
478
+ left: 0,
479
+ }}
480
+ dpr={[1, 2]}
481
+ gl={{ alpha: true, antialias: true }}
482
+ >
483
+ <PresetEnvironment />
484
+ <Bounds fit observe margin={1}>
485
+ <BoundsRefresher
486
+ modelRenderTrigger={modelRenderTrigger}
487
+ isPortrait={true}
488
+ >
489
+ <group ref={robotRef} scale={robotScale}>
490
+ <RobotComponent
491
+ connectedMotionGroup={connectedMotionGroup}
492
+ postModelRender={handleModelRender}
493
+ />
494
+ </group>
495
+ </BoundsRefresher>
496
+ </Bounds>
497
+ </Canvas>
498
+ )}
499
+ </Box>
500
+
501
+ {/* Bottom section with runtime, cycle time, and button */}
502
+ <Box>
503
+ {/* Runtime display */}
504
+ <Typography
505
+ variant="body1"
506
+ sx={{
507
+ mb: 0,
508
+ color: "var(--text-secondary, #FFFFFFB2)",
509
+ }}
510
+ >
511
+ {t("RobotCard.Runtime.lb")}
512
+ </Typography>
513
+
514
+ {/* Compact cycle time component directly below runtime */}
515
+ <CycleTimerComponent
516
+ variant="small"
517
+ compact
518
+ onCycleComplete={handleCycleComplete}
519
+ />
520
+
521
+ {/* Divider */}
522
+ <Divider
523
+ sx={{
524
+ mt: 1,
525
+ mb: 0,
526
+ borderColor: theme.palette.divider,
527
+ opacity: 0.5,
528
+ }}
529
+ />
530
+
531
+ {/* Drive to Home button with some space */}
532
+ <Box
533
+ sx={{
534
+ display: "flex",
535
+ justifyContent: "flex-start",
536
+ mt: { xs: 1, sm: 2, md: 5 },
537
+ mb: { xs: 0.5, sm: 0.75, md: 1 },
538
+ }}
539
+ >
540
+ <Button
541
+ ref={driveButtonRef}
542
+ variant="contained"
543
+ color="secondary"
544
+ size="small"
545
+ disabled={!driveToHomeEnabled}
546
+ onMouseDown={handleDriveToHomeMouseDown}
547
+ onMouseUp={handleDriveToHomeMouseUp}
548
+ onMouseLeave={handleDriveToHomeMouseLeave}
549
+ onTouchStart={handleDriveToHomeMouseDown}
550
+ onTouchEnd={handleDriveToHomeMouseUp}
551
+ sx={{
552
+ textTransform: "none",
553
+ px: 1.5,
554
+ py: 0.5,
555
+ }}
556
+ >
557
+ {t("RobotCard.DriveToHome.bt")}
558
+ </Button>
559
+ </Box>
560
+ </Box>
561
+ </Box>
562
+ </>
563
+ )}
564
+ </Card>
565
+ )
566
+ },
567
+ ),
568
+ )
@@ -112,8 +112,10 @@ export const RobotSetupReadinessIndicator = externalizeComponent(
112
112
  backgroundColor,
113
113
  color: theme.palette.getContrastText(backgroundColor),
114
114
  fontWeight: 500,
115
+ height: "auto",
115
116
  "& .MuiChip-label": {
116
117
  paddingX: 1.5,
118
+ paddingY: 0.5,
117
119
  },
118
120
  }}
119
121
  />
@@ -1,15 +1,16 @@
1
- import { type ThreeElements } from "@react-three/fiber"
1
+ import type { ThreeElements } from "@react-three/fiber"
2
2
 
3
3
  import type { ConnectedMotionGroup } from "@wandelbots/nova-js/v1"
4
4
  import type { Group } from "three"
5
- import { SupportedRobot } from "./SupportedRobot"
6
5
  import { defaultGetModel } from "./robotModelLogic"
6
+ import { SupportedRobot } from "./SupportedRobot"
7
7
 
8
8
  export type RobotProps = {
9
9
  connectedMotionGroup: ConnectedMotionGroup
10
10
  getModel?: (modelFromController: string) => string
11
11
  flangeRef?: React.Ref<Group>
12
12
  transparentColor?: string
13
+ postModelRender?: () => void
13
14
  } & ThreeElements["group"]
14
15
 
15
16
  /**
@@ -28,6 +29,7 @@ export function Robot({
28
29
  getModel = defaultGetModel,
29
30
  flangeRef,
30
31
  transparentColor,
32
+ postModelRender,
31
33
  ...props
32
34
  }: RobotProps) {
33
35
  if (!connectedMotionGroup.dhParameters) {
@@ -44,6 +46,7 @@ export function Robot({
44
46
  getModel={getModel}
45
47
  flangeRef={flangeRef}
46
48
  transparentColor={transparentColor}
49
+ postModelRender={postModelRender}
47
50
  {...props}
48
51
  />
49
52
  )
@@ -64,5 +64,7 @@
64
64
  "ProgramStateIndicator.ManualT2.lb": "Manuell T2",
65
65
  "RobotSetupReadinessIndicator.Ready.lb": "Bereit",
66
66
  "RobotSetupReadinessIndicator.RobotDisconnected.lb": "Roboter getrennt",
67
- "RobotSetupReadinessIndicator.PreconditionNotFulfilled.lb": "Voraussetzung nicht erfüllt"
67
+ "RobotSetupReadinessIndicator.PreconditionNotFulfilled.lb": "Voraussetzung nicht erfüllt",
68
+ "RobotCard.Runtime.lb": "Laufzeit",
69
+ "RobotCard.DriveToHome.bt": "Zur Home-Position fahren"
68
70
  }
@@ -65,5 +65,7 @@
65
65
  "ProgramStateIndicator.ManualT2.lb": "Manual T2",
66
66
  "RobotSetupReadinessIndicator.Ready.lb": "Ready",
67
67
  "RobotSetupReadinessIndicator.RobotDisconnected.lb": "Robot disconnected",
68
- "RobotSetupReadinessIndicator.PreconditionNotFulfilled.lb": "Precondition not fulfilled"
68
+ "RobotSetupReadinessIndicator.PreconditionNotFulfilled.lb": "Precondition not fulfilled",
69
+ "RobotCard.Runtime.lb": "Runtime",
70
+ "RobotCard.DriveToHome.bt": "Drive to Home"
69
71
  }
package/src/index.ts CHANGED
@@ -12,6 +12,7 @@ export * from "./components/LoadingCover"
12
12
  export * from "./components/modal/NoMotionGroupModal"
13
13
  export * from "./components/ProgramControl"
14
14
  export * from "./components/ProgramStateIndicator"
15
+ export * from "./components/RobotCard"
15
16
  export * from "./components/RobotListItem"
16
17
  export * from "./components/robots/AxisConfig"
17
18
  export * from "./components/robots/Robot"