@viamrobotics/test-widgets 0.4.0 → 0.6.0

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.
Files changed (27) hide show
  1. package/dist/components/copy-button.svelte +10 -10
  2. package/dist/components/mutation-section.svelte +1 -1
  3. package/dist/components/paste-button.svelte +38 -0
  4. package/dist/components/paste-button.svelte.d.ts +7 -0
  5. package/dist/components/widgets/arm/arm.svelte +7 -3
  6. package/dist/components/widgets/arm/joint-position-limits.d.ts +11 -0
  7. package/dist/components/widgets/arm/joint-position-limits.js +26 -0
  8. package/dist/components/widgets/arm/move-to-joint-positions-widget.svelte +9 -4
  9. package/dist/components/widgets/arm/move-to-joint-positions.svelte +86 -17
  10. package/dist/components/widgets/arm/move-to-joint-positions.svelte.d.ts +2 -0
  11. package/dist/components/widgets/arm/move-to-position.svelte +11 -3
  12. package/dist/components/widgets/camera/camera.svelte +54 -13
  13. package/dist/components/widgets/camera/decode-viam-depth.d.ts +37 -0
  14. package/dist/components/widgets/camera/decode-viam-depth.js +114 -0
  15. package/dist/components/widgets/camera/export-screenshot.svelte +33 -12
  16. package/dist/components/widgets/camera/export-screenshot.svelte.d.ts +1 -0
  17. package/dist/components/widgets/camera/get-xmp-json-from-image.d.ts +3 -0
  18. package/dist/components/widgets/camera/get-xmp-json-from-image.js +134 -0
  19. package/dist/components/widgets/camera/live-or-polling-video.svelte +33 -6
  20. package/dist/components/widgets/camera/live-or-polling-video.svelte.d.ts +1 -0
  21. package/dist/components/widgets/camera/pick-image-for-source.d.ts +7 -0
  22. package/dist/components/widgets/camera/pick-image-for-source.js +13 -0
  23. package/dist/components/widgets/camera/three-sixty-camera-view.svelte +69 -0
  24. package/dist/components/widgets/camera/three-sixty-camera-view.svelte.d.ts +8 -0
  25. package/dist/components/widgets/do-command/do-command.svelte +14 -1
  26. package/dist/components/widgets/do-command/do-command.svelte.d.ts +6 -0
  27. package/package.json +4 -4
@@ -1,5 +1,5 @@
1
1
  <script lang="ts">
2
- import { Icon } from '@viamrobotics/prime-core'
2
+ import { IconButton, Tooltip } from '@viamrobotics/prime-core'
3
3
 
4
4
  interface Props {
5
5
  data: string
@@ -25,13 +25,13 @@
25
25
  }
26
26
  </script>
27
27
 
28
- <button
29
- onclick={handleCopyClick}
30
- aria-label={ariaLabel}
31
- class="text-gray-6 hover:border-medium hover:bg-medium active:bg-gray-2 justify-items-end p-0.5"
32
- >
33
- <Icon
34
- name={showCopySuccess ? 'check' : 'content-copy'}
35
- size="xs"
28
+ <Tooltip>
29
+ <IconButton
30
+ onclick={handleCopyClick}
31
+ aria-label={ariaLabel}
32
+ class="text-gray-6 hover:border-medium hover:bg-medium active:bg-gray-2 justify-items-end p-0.5"
33
+ icon={showCopySuccess ? 'check' : 'content-copy'}
34
+ label="Copy to clipboard"
36
35
  />
37
- </button>
36
+ <span slot="description">{ariaLabel}</span>
37
+ </Tooltip>
@@ -32,7 +32,7 @@
32
32
  </script>
33
33
 
34
34
  <section
35
- class="flex flex-col gap-2 p-4"
35
+ class="flex w-full flex-col gap-2 p-4"
36
36
  aria-labelledby={headingID}
37
37
  >
38
38
  <div class={twMerge('flex grow flex-row flex-wrap gap-2', className)}>
@@ -0,0 +1,38 @@
1
+ <script lang="ts">
2
+ import { IconButton, Tooltip } from '@viamrobotics/prime-core'
3
+
4
+ interface Props {
5
+ onPaste: (data: string) => boolean
6
+ ariaLabel?: string
7
+ }
8
+
9
+ const { onPaste, ariaLabel = 'Paste from clipboard' }: Props = $props()
10
+
11
+ let isPasteSuccessful = $state<boolean | null>(null)
12
+
13
+ const handlePasteClick = async (event: Event) => {
14
+ event.stopPropagation()
15
+ event.preventDefault()
16
+ const data = await globalThis.navigator.clipboard.readText()
17
+ isPasteSuccessful = onPaste(data)
18
+ setTimeout(() => {
19
+ isPasteSuccessful = null
20
+ }, 750)
21
+ }
22
+
23
+ const iconName = $derived(
24
+ // eslint-disable-next-line unicorn/no-nested-ternary
25
+ isPasteSuccessful === null ? 'content-paste' : isPasteSuccessful ? 'check' : 'close'
26
+ )
27
+ </script>
28
+
29
+ <Tooltip>
30
+ <IconButton
31
+ onclick={handlePasteClick}
32
+ aria-label={ariaLabel}
33
+ class="text-gray-6 hover:border-medium hover:bg-medium active:bg-gray-2 justify-items-end p-0.5"
34
+ icon={iconName}
35
+ label="Paste from clipboard"
36
+ />
37
+ <span slot="description">{ariaLabel}</span>
38
+ </Tooltip>
@@ -0,0 +1,7 @@
1
+ interface Props {
2
+ onPaste: (data: string) => boolean;
3
+ ariaLabel?: string;
4
+ }
5
+ declare const PasteButton: import("svelte").Component<Props, {}, "">;
6
+ type PasteButton = ReturnType<typeof PasteButton>;
7
+ export default PasteButton;
@@ -11,10 +11,12 @@
11
11
  import ApiSection from '../../api-section.svelte'
12
12
  import ConnectionStatus from '../../connection-status.svelte'
13
13
  import IsMoving from '../../is-moving.svelte'
14
+ import Queries from '../../queries.svelte'
14
15
  import Query from '../../query.svelte'
15
16
  import StopButton from '../../stop-button.svelte'
16
17
 
17
18
  import GetJointPositions from './get-joint-positions.svelte'
19
+ import { getJointPositionLimits, type KinematicsJSON } from './joint-position-limits'
18
20
  import MoveToJointPositions from './move-to-joint-positions.svelte'
19
21
  import MoveToPosition from './move-to-position.svelte'
20
22
  import QuickMove from './quick-move.svelte'
@@ -35,6 +37,7 @@
35
37
  const options = { refetchInterval: 500 }
36
38
  const jointPositionsQuery = createResourceQuery(client, 'getJointPositions', options)
37
39
  const endPositionQuery = createResourceQuery(client, 'getEndPosition', options)
40
+ const kinematicsQuery = createResourceQuery(client, 'getKinematics', options)
38
41
 
39
42
  const moveToJointPosMutation = createResourceMutation(client, 'moveToJointPositions')
40
43
  const quickMoveToJointPosMutation = createResourceMutation(client, 'moveToJointPositions')
@@ -72,15 +75,16 @@
72
75
  </Query>
73
76
  </ApiSection>
74
77
  <ApiSection title="MoveToJointPositions">
75
- <Query query={jointPositionsQuery}>
76
- {#if jointPositionsQuery.data}
78
+ <Queries queries={[jointPositionsQuery, kinematicsQuery]}>
79
+ {#if jointPositionsQuery.data && kinematicsQuery.data}
77
80
  <MoveToJointPositions
78
81
  positions={jointPositionsQuery.data.values}
79
82
  {moveToJointPositions}
80
83
  lastError={moveToJointPosMutation.error}
84
+ jointLimitsDegrees={getJointPositionLimits(kinematicsQuery.data as KinematicsJSON)}
81
85
  />
82
86
  {/if}
83
- </Query>
87
+ </Queries>
84
88
  </ApiSection>
85
89
  <ApiSection title="MoveToPosition">
86
90
  <Query query={endPositionQuery}>
@@ -0,0 +1,11 @@
1
+ export type JointLimit = {
2
+ minDegrees: number;
3
+ maxDegrees: number;
4
+ };
5
+ export type OutOfBoundsSide = 'below-min' | 'above-max';
6
+ export interface KinematicsJSON {
7
+ kinematic_param_type: string;
8
+ }
9
+ export declare const isOutsideJointLimit: (value: number, min: number, max: number) => boolean;
10
+ export declare const getOutOfBoundsSide: (value: number, min: number, max: number) => OutOfBoundsSide | null;
11
+ export declare const getJointPositionLimits: (kinematics: KinematicsJSON) => JointLimit[];
@@ -0,0 +1,26 @@
1
+ export const isOutsideJointLimit = (value, min, max) => value < min || value > max;
2
+ export const getOutOfBoundsSide = (value, min, max) => {
3
+ if (value < min) {
4
+ return 'below-min';
5
+ }
6
+ if (value > max) {
7
+ return 'above-max';
8
+ }
9
+ return null;
10
+ };
11
+ export const getJointPositionLimits = (kinematics) => {
12
+ if (kinematics.kinematic_param_type === 'SVA') {
13
+ return parseSVAKinematics(kinematics);
14
+ }
15
+ return [];
16
+ };
17
+ const parseSVAKinematics = (kinematics) => {
18
+ const joints = kinematics.joints;
19
+ const limits = joints.map((joint) => {
20
+ return {
21
+ minDegrees: joint.min,
22
+ maxDegrees: joint.max,
23
+ };
24
+ });
25
+ return limits;
26
+ };
@@ -6,8 +6,9 @@
6
6
  createResourceQuery,
7
7
  } from '@viamrobotics/svelte-sdk'
8
8
 
9
- import Query from '../../query.svelte'
9
+ import Queries from '../../queries.svelte'
10
10
 
11
+ import { getJointPositionLimits, type KinematicsJSON } from './joint-position-limits'
11
12
  import MoveToJointPositions from './move-to-joint-positions.svelte'
12
13
 
13
14
  interface Props {
@@ -26,6 +27,9 @@
26
27
  const jointPositionsQuery = createResourceQuery(client, 'getJointPositions', {
27
28
  refetchInterval: 500,
28
29
  })
30
+ const kinematicsQuery = createResourceQuery(client, 'getKinematics', {
31
+ refetchInterval: 500,
32
+ })
29
33
 
30
34
  const moveToJointPosMutation = createResourceMutation(client, 'moveToJointPositions')
31
35
 
@@ -34,12 +38,13 @@
34
38
  }
35
39
  </script>
36
40
 
37
- <Query query={jointPositionsQuery}>
38
- {#if jointPositionsQuery.data}
41
+ <Queries queries={[jointPositionsQuery, kinematicsQuery]}>
42
+ {#if jointPositionsQuery.data && kinematicsQuery.data}
39
43
  <MoveToJointPositions
40
44
  positions={jointPositionsQuery.data.values}
41
45
  {moveToJointPositions}
42
46
  lastError={moveToJointPosMutation.error}
47
+ jointLimitsDegrees={getJointPositionLimits(kinematicsQuery.data as KinematicsJSON)}
43
48
  />
44
49
  {/if}
45
- </Query>
50
+ </Queries>
@@ -4,17 +4,21 @@
4
4
  import AngleUnitToggle from '../../angle-unit-toggle.svelte'
5
5
  import CopyButton from '../../copy-button.svelte'
6
6
  import ErrorDisplay from '../../error.svelte'
7
+ import PasteButton from '../../paste-button.svelte'
7
8
  import Table from '../../table.svelte'
8
9
  import { numberValueFromEvent } from '../../../event-handlers'
9
10
  import { degreesToRadians, formatNumeric, radiansToDegrees } from '../../../format'
10
11
 
12
+ import { getOutOfBoundsSide, isOutsideJointLimit, type JointLimit } from './joint-position-limits'
13
+
11
14
  interface Props {
12
15
  positions: number[]
13
16
  moveToJointPositions: (jointPositions: number[]) => void
14
17
  lastError: Error | null
18
+ jointLimitsDegrees: JointLimit[]
15
19
  }
16
20
 
17
- const { positions, moveToJointPositions, lastError }: Props = $props()
21
+ const { positions, moveToJointPositions, lastError, jointLimitsDegrees }: Props = $props()
18
22
 
19
23
  // svelte-ignore state_referenced_locally
20
24
  let desiredPositions = $state([...positions])
@@ -28,23 +32,77 @@
28
32
  desiredPositions = [...positions]
29
33
  }
30
34
 
31
- const displayPositions = $derived(
32
- desiredPositions.map((pos) => (useRadians ? degreesToRadians(pos) : pos))
33
- )
34
-
35
- const copyData = $derived(`[${displayPositions.join(', ')}]`)
36
-
37
35
  const handleJointInputChange = (index: number, inputValue: number) => {
38
36
  // default is degrees, so if user has toggle to radians, convert back to degrees before setting
39
37
  // (we only convert to radians when displaying)
40
38
  desiredPositions[index] = useRadians ? radiansToDegrees(inputValue) : inputValue
41
39
  }
40
+
41
+ const handlePaste = (data: string): boolean => {
42
+ try {
43
+ desiredPositions = JSON.parse(data) as number[]
44
+ } catch {
45
+ return false
46
+ }
47
+ return true
48
+ }
49
+ const degreesToDisplayAngle = (degrees: number) => {
50
+ return useRadians ? degreesToRadians(degrees) : degrees
51
+ }
52
+
53
+ const outOfBoundsMessage = (displayValue: number, limit: JointLimit) => {
54
+ const min = degreesToDisplayAngle(limit.minDegrees)
55
+ const max = degreesToDisplayAngle(limit.maxDegrees)
56
+ const unit = useRadians ? 'rad' : 'deg'
57
+ const side = getOutOfBoundsSide(displayValue, min, max)
58
+
59
+ if (side === 'below-min') {
60
+ return `Min value is ${formatNumeric(min)} ${unit}`
61
+ }
62
+
63
+ if (side === 'above-max') {
64
+ return `Max value is ${formatNumeric(max)} ${unit}`
65
+ }
66
+ }
67
+
68
+ const displayPositions = $derived(desiredPositions.map((pos) => degreesToDisplayAngle(pos)))
69
+
70
+ const copyData = $derived(`[${displayPositions.join(', ')}]`)
71
+
72
+ const outOfBoundsByIndex = $derived(
73
+ displayPositions.map((displayValue, index) => {
74
+ const limit = jointLimitsDegrees[index]
75
+ if (!limit) {
76
+ return false
77
+ }
78
+ return isOutsideJointLimit(
79
+ displayValue,
80
+ degreesToDisplayAngle(limit.minDegrees),
81
+ degreesToDisplayAngle(limit.maxDegrees)
82
+ )
83
+ })
84
+ )
85
+
86
+ const hasOutOfBoundsPositions = $derived(outOfBoundsByIndex.some(Boolean))
42
87
  </script>
43
88
 
44
89
  <div class="flex min-w-0 flex-col gap-4">
45
90
  <!-- Controls Header -->
46
91
  <div class="flex items-center justify-between">
47
- <span class="text-sm">Joint Positions</span>
92
+ <span class="flex flex-row items-center gap-1 text-sm">
93
+ Joint Positions
94
+ <Tooltip>
95
+ <Icon
96
+ name="information-outline"
97
+ cx="text-gray-6"
98
+ />
99
+
100
+ <span slot="description">
101
+ Joint position limits are based solely on the arm kinematics and do not take into account
102
+ motion service limit overrides.
103
+ </span>
104
+ </Tooltip>
105
+ </span>
48
106
  <div class="flex gap-1">
49
107
  <AngleUnitToggle
50
108
  {useRadians}
@@ -53,6 +111,7 @@
53
111
  }}
54
112
  />
55
113
  <CopyButton data={copyData} />
114
+ <PasteButton onPaste={handlePaste} />
56
115
  </div>
57
116
  </div>
58
117
 
@@ -65,18 +124,27 @@
65
124
  </thead>
66
125
  <tbody>
67
126
  {#each { length: positions.length }, index}
127
+ {@const limit = jointLimitsDegrees[index]}
68
128
  {@const value = Number.parseFloat(formatNumeric(displayPositions[index]))}
129
+ {@const outOfBounds = outOfBoundsByIndex[index]}
69
130
  <tr>
70
- <th> {index} </th>
131
+ <th>{index}</th>
71
132
  <th>
72
- <NumericInput
73
- cx="max-w-[76px]"
74
- {value}
75
- on:change={(event) => {
76
- const inputValue = numberValueFromEvent(event) ?? 0
77
- handleJointInputChange(index, inputValue)
78
- }}
79
- />
133
+ <div class="flex flex-col items-center gap-0.5 py-0.5">
134
+ <NumericInput
135
+ cx="max-w-[76px]"
136
+ {value}
137
+ on:change={(event) => {
138
+ const inputValue = numberValueFromEvent(event) ?? 0
139
+ handleJointInputChange(index, inputValue)
140
+ }}
141
+ />
142
+ {#if limit && outOfBounds}
143
+ <p class="text-danger-dark text-center text-[10px] leading-snug whitespace-normal">
144
+ {outOfBoundsMessage(displayPositions[index], limit)}
145
+ </p>
146
+ {/if}
147
+ </div>
80
148
  </th>
81
149
  </tr>
82
150
  {/each}
@@ -104,6 +172,7 @@
104
172
  </div>
105
173
  <Button
106
174
  class="mt-auto w-fit"
175
+ disabled={hasOutOfBoundsPositions}
107
176
  icon="play-circle-outline"
108
177
  variant="dark"
109
178
  onclick={() => moveToJointPositions(desiredPositions)}
@@ -1,7 +1,9 @@
1
+ import { type JointLimit } from './joint-position-limits';
1
2
  interface Props {
2
3
  positions: number[];
3
4
  moveToJointPositions: (jointPositions: number[]) => void;
4
5
  lastError: Error | null;
6
+ jointLimitsDegrees: JointLimit[];
5
7
  }
6
8
  declare const MoveToJointPositions: import("svelte").Component<Props, {}, "">;
7
9
  type MoveToJointPositions = ReturnType<typeof MoveToJointPositions>;
@@ -6,6 +6,7 @@
6
6
  import AngleUnitToggle from '../../angle-unit-toggle.svelte'
7
7
  import CopyButton from '../../copy-button.svelte'
8
8
  import ErrorDisplay from '../../error.svelte'
9
+ import PasteButton from '../../paste-button.svelte'
9
10
  import Table from '../../table.svelte'
10
11
  import { numberValueFromEvent } from '../../../event-handlers'
11
12
  import { degreesToRadians, formatNumeric, radiansToDegrees } from '../../../format'
@@ -48,9 +49,15 @@
48
49
  theta: useRadians ? degreesToRadians(desiredPosition.theta) : desiredPosition.theta,
49
50
  })
50
51
 
51
- const copyData = $derived(
52
- `{x: ${displayPosition.x}, y: ${displayPosition.y}, z: ${displayPosition.z}, o_x: ${displayPosition.oX}, o_y: ${displayPosition.oY}, o_z: ${displayPosition.oZ}, theta: ${displayPosition.theta}}`
53
- )
52
+ const copyData = $derived(JSON.stringify(displayPosition))
53
+ const handlePaste = (data: string): boolean => {
54
+ try {
55
+ desiredPosition = JSON.parse(data) as Pose
56
+ } catch {
57
+ return false
58
+ }
59
+ return true
60
+ }
54
61
 
55
62
  const handleAngleInputChange = (key: keyof Pose, inputValue: number) => {
56
63
  if (key === 'theta') {
@@ -84,6 +91,7 @@
84
91
  }}
85
92
  />
86
93
  <CopyButton data={copyData} />
94
+ <PasteButton onPaste={handlePaste} />
87
95
  </div>
88
96
  </div>
89
97
 
@@ -1,4 +1,5 @@
1
1
  <script lang="ts">
2
+ import { Canvas } from '@threlte/core'
2
3
  import { Button, Label, Select, Switch, ToggleButtons } from '@viamrobotics/prime-core'
3
4
  import { CameraClient } from '@viamrobotics/sdk'
4
5
  import { createResourceClient, createResourceQuery } from '@viamrobotics/svelte-sdk'
@@ -15,8 +16,11 @@
15
16
  import PCDWidget from '../pcd/pcd-widget.svelte'
16
17
  import ExportScreenshot from './export-screenshot.svelte'
17
18
  import { getSourceNames } from './get-source-names'
19
+ import { getXmpJsonFromImageBytes, type XmpJson } from './get-xmp-json-from-image'
18
20
  import LiveOrPollingVideo from './live-or-polling-video.svelte'
21
+ import { pickImageForSource } from './pick-image-for-source'
19
22
  import PictureInPictureButton from './picture-in-picture-button.svelte'
23
+ import ThreeSixtyCameraView from './three-sixty-camera-view.svelte'
20
24
 
21
25
  interface Props {
22
26
  partID: string
@@ -41,6 +45,7 @@
41
45
  let isShowingPointcloud = $state(false)
42
46
  let selectedSource = $state('')
43
47
  let sourceNames = $state<string[]>([])
48
+ let displayAs360 = $state(false)
44
49
 
45
50
  const { addImageToDataset } = useAddImageToDataset()
46
51
  const setIsShowingPointcloud = (event: CustomEvent<boolean>) => {
@@ -73,6 +78,20 @@
73
78
  }
74
79
  })
75
80
 
81
+ const xmpJson = $derived.by((): XmpJson | null => {
82
+ const imageRecord = imageQuery.data?.images?.[0]
83
+ const image = imageRecord?.image
84
+ if (!image) {
85
+ return null
86
+ }
87
+
88
+ return getXmpJsonFromImageBytes(new Uint8Array(image), imageRecord.mimeType)
89
+ })
90
+
91
+ const is360EnabledImage = $derived.by((): boolean => {
92
+ return xmpJson?.['viam:is360'] === 'true'
93
+ })
94
+
76
95
  const pointcloudQuery = createResourceQuery(client, 'getPointCloud', () => ({
77
96
  enabled: isShowingPointcloud,
78
97
  refetchInterval:
@@ -135,22 +154,42 @@
135
154
  </Select>
136
155
  </Label>
137
156
  {/if}
157
+ {#if is360EnabledImage}
158
+ <Label>
159
+ Display as 360°
160
+ <ToggleButtons
161
+ slot="input"
162
+ options={['On', 'Off']}
163
+ selected={displayAs360 ? 'On' : 'Off'}
164
+ on:input={(event) => {
165
+ displayAs360 = event.detail === 'On'
166
+ }}
167
+ />
168
+ </Label>
169
+ {/if}
138
170
  </div>
139
171
  {/if}
140
172
  <div class="flex h-full w-full gap-4 p-4">
141
173
  <div class="grow">
142
174
  {#if isPlaying}
143
- <LiveOrPollingVideo
144
- {partID}
145
- {resourceName}
146
- showResolutionOptions
147
- isLive={refetchInterval.current === RefetchIntervals.LIVE}
148
- data={imageQuery.data}
149
- error={imageQuery.error}
150
- isLoading={imageQuery.isLoading}
151
- refetch={imageQuery.refetch}
152
- showMousePositionTooltip={mousePostionTooltip === 'On'}
153
- />
175
+ {#if displayAs360}
176
+ <Canvas>
177
+ <ThreeSixtyCameraView data={imageQuery.data} />
178
+ </Canvas>
179
+ {:else}
180
+ <LiveOrPollingVideo
181
+ {partID}
182
+ {resourceName}
183
+ showResolutionOptions
184
+ isLive={refetchInterval.current === RefetchIntervals.LIVE}
185
+ data={imageQuery.data}
186
+ error={imageQuery.error}
187
+ isLoading={imageQuery.isLoading}
188
+ refetch={imageQuery.refetch}
189
+ showMousePositionTooltip={mousePostionTooltip === 'On'}
190
+ sourceName={selectedSource}
191
+ />
192
+ {/if}
154
193
  {:else}
155
194
  <div class="bg-medium flex h-64 w-80 items-center justify-center">
156
195
  <Button
@@ -169,6 +208,7 @@
169
208
  <div class="flex flex-col items-start gap-2">
170
209
  <ExportScreenshot
171
210
  name={client.current?.name ?? ''}
211
+ sourceName={selectedSource}
172
212
  getImage={exportScreenshotQuery.refetch}
173
213
  />
174
214
  {#if addImageToDataset}
@@ -184,8 +224,9 @@
184
224
  imgData = imageQuery.data
185
225
  }
186
226
 
187
- const image = imgData?.images?.[0]?.image
188
- const mimeType = imgData?.images?.[0]?.mimeType
227
+ const matchingImage = pickImageForSource(imgData?.images, selectedSource)
228
+ const image = matchingImage?.image
229
+ const mimeType = matchingImage?.mimeType
189
230
 
190
231
  if (image) {
191
232
  addImageToDataset({
@@ -0,0 +1,37 @@
1
+ /** MIME type used by Viam's custom 16-bit depth format. */
2
+ export declare const VIAM_DEPTH_MIME_TYPE = "image/vnd.viam.dep";
3
+ /**
4
+ * Decode the Viam depth format (`image/vnd.viam.dep`) and return a colorized
5
+ * visualization (warm = near, cool = far), matching the hue sweep that RDK's
6
+ * `DepthMap.ToPrettyPicture` produces. Pixels with depth 0 are rendered as
7
+ * opaque black to match RDK. The wire format is:
8
+ *
9
+ * - bytes 0–8: magic `DEPTHMAP` (big-endian)
10
+ * - bytes 8–16: width (big-endian uint64)
11
+ * - bytes 16–24: height (big-endian uint64)
12
+ * - bytes 24–end: depth values as big-endian uint16, row-major
13
+ *
14
+ * The browser cannot decode this MIME type natively, so depth frames must be
15
+ * rendered to a canvas ourselves. References:
16
+ *
17
+ * - Reader: https://github.com/viamrobotics/rdk/blob/main/rimage/depth_map_raw.go (readDepthMapViam)
18
+ * - Format registration: https://github.com/viamrobotics/rdk/blob/main/rimage/image_file.go
19
+ * - Visualization (`DepthMap.ToPrettyPicture`):
20
+ * https://github.com/viamrobotics/rdk/blob/main/rimage/depth_map.go
21
+ * - Python equivalent (`ViamImage.bytes_to_depth_array`):
22
+ * https://github.com/viamrobotics/viam-python-sdk/blob/main/src/viam/media/video.py
23
+ *
24
+ * Returns undefined if the buffer is too short to be a valid depth image.
25
+ */
26
+ export declare const decodeViamDepth: (bytes: Uint8Array) => {
27
+ width: number;
28
+ height: number;
29
+ pixels: Uint8ClampedArray;
30
+ } | undefined;
31
+ /**
32
+ * Decode the Viam depth format and return a PNG `Blob` suitable for display or export,
33
+ * using the same colorization as `decodeViamDepth`.
34
+ *
35
+ * Returns `undefined` if the buffer is too short or a canvas context is unavailable.
36
+ */
37
+ export declare const getBlobForViamDepth: (bytes: Uint8Array) => Promise<Blob | undefined>;
@@ -0,0 +1,114 @@
1
+ /** MIME type used by Viam's custom 16-bit depth format. */
2
+ export const VIAM_DEPTH_MIME_TYPE = 'image/vnd.viam.dep';
3
+ const HEADER_BYTES = 24;
4
+ // Hue sweep used by RDK's ToPrettyPicture: near = warm (~30°), far = cool (~230°).
5
+ const HUE_NEAR = 30;
6
+ const HUE_FAR = 230;
7
+ const hueToRgb = (hue) => {
8
+ const sector = hue / 60;
9
+ const x = Math.round((1 - Math.abs((sector % 2) - 1)) * 255);
10
+ if (sector < 1)
11
+ return [255, x, 0];
12
+ if (sector < 2)
13
+ return [x, 255, 0];
14
+ if (sector < 3)
15
+ return [0, 255, x];
16
+ if (sector < 4)
17
+ return [0, x, 255];
18
+ if (sector < 5)
19
+ return [x, 0, 255];
20
+ return [255, 0, x];
21
+ };
22
+ /**
23
+ * Decode the Viam depth format (`image/vnd.viam.dep`) and return a colorized
24
+ * visualization (warm = near, cool = far), matching the hue sweep that RDK's
25
+ * `DepthMap.ToPrettyPicture` produces. Pixels with depth 0 are rendered as
26
+ * opaque black to match RDK. The wire format is:
27
+ *
28
+ * - bytes 0–8: magic `DEPTHMAP` (big-endian)
29
+ * - bytes 8–16: width (big-endian uint64)
30
+ * - bytes 16–24: height (big-endian uint64)
31
+ * - bytes 24–end: depth values as big-endian uint16, row-major
32
+ *
33
+ * The browser cannot decode this MIME type natively, so depth frames must be
34
+ * rendered to a canvas ourselves. References:
35
+ *
36
+ * - Reader: https://github.com/viamrobotics/rdk/blob/main/rimage/depth_map_raw.go (readDepthMapViam)
37
+ * - Format registration: https://github.com/viamrobotics/rdk/blob/main/rimage/image_file.go
38
+ * - Visualization (`DepthMap.ToPrettyPicture`):
39
+ * https://github.com/viamrobotics/rdk/blob/main/rimage/depth_map.go
40
+ * - Python equivalent (`ViamImage.bytes_to_depth_array`):
41
+ * https://github.com/viamrobotics/viam-python-sdk/blob/main/src/viam/media/video.py
42
+ *
43
+ * Returns undefined if the buffer is too short to be a valid depth image.
44
+ */
45
+ export const decodeViamDepth = (bytes) => {
46
+ if (bytes.length < HEADER_BYTES) {
47
+ return undefined;
48
+ }
49
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
50
+ // Verify the 8-byte "DEPTHMAP" magic (0x4445505448_4d4150).
51
+ const magic = view.getBigUint64(0, false);
52
+ if (magic !== 0x44455054484d4150n) {
53
+ return undefined;
54
+ }
55
+ const width = Number(view.getBigUint64(8, false));
56
+ const height = Number(view.getBigUint64(16, false));
57
+ const pixelCount = width * height;
58
+ if (bytes.length < HEADER_BYTES + pixelCount * 2) {
59
+ return undefined;
60
+ }
61
+ let min = Number.POSITIVE_INFINITY;
62
+ let max = 0;
63
+ for (let i = 0; i < pixelCount; i++) {
64
+ const depth = view.getUint16(HEADER_BYTES + i * 2, false);
65
+ if (depth === 0)
66
+ continue;
67
+ if (depth < min)
68
+ min = depth;
69
+ if (depth > max)
70
+ max = depth;
71
+ }
72
+ const span = Number.isFinite(min) ? Math.max(1, max - min) : 1;
73
+ const base = Number.isFinite(min) ? min : 0;
74
+ const hueSpan = HUE_FAR - HUE_NEAR;
75
+ const pixels = new Uint8ClampedArray(pixelCount * 4);
76
+ for (let i = 0; i < pixelCount; i++) {
77
+ const depth = view.getUint16(HEADER_BYTES + i * 2, false);
78
+ const idx = i * 4;
79
+ pixels[idx + 3] = 255;
80
+ if (depth === 0) {
81
+ // Unmeasured pixel — leave RGB at 0 (black) to match RDK.
82
+ continue;
83
+ }
84
+ const ratio = (depth - base) / span;
85
+ const [r, g, b] = hueToRgb(HUE_NEAR + ratio * hueSpan);
86
+ pixels[idx] = r;
87
+ pixels[idx + 1] = g;
88
+ pixels[idx + 2] = b;
89
+ }
90
+ return { width, height, pixels };
91
+ };
92
+ /**
93
+ * Decode the Viam depth format and return a PNG `Blob` suitable for display or export,
94
+ * using the same colorization as `decodeViamDepth`.
95
+ *
96
+ * Returns `undefined` if the buffer is too short or a canvas context is unavailable.
97
+ */
98
+ export const getBlobForViamDepth = (bytes) => {
99
+ const decoded = decodeViamDepth(bytes);
100
+ if (!decoded)
101
+ return Promise.resolve(undefined);
102
+ const offscreen = document.createElement('canvas');
103
+ offscreen.width = decoded.width;
104
+ offscreen.height = decoded.height;
105
+ const ctx = offscreen.getContext('2d');
106
+ if (!ctx)
107
+ return Promise.resolve(undefined);
108
+ const imageData = ctx.createImageData(decoded.width, decoded.height);
109
+ imageData.data.set(decoded.pixels);
110
+ ctx.putImageData(imageData, 0, 0);
111
+ return new Promise((resolve) => {
112
+ offscreen.toBlob((blob) => resolve(blob ?? undefined), 'image/png');
113
+ });
114
+ };
@@ -6,12 +6,16 @@
6
6
 
7
7
  import ErrorDisplay from '../../error.svelte'
8
8
 
9
+ import { getBlobForViamDepth, VIAM_DEPTH_MIME_TYPE } from './decode-viam-depth'
10
+ import { pickImageForSource } from './pick-image-for-source'
11
+
9
12
  interface Props {
10
13
  name: string
14
+ sourceName?: string
11
15
  getImage: () => Promise<QueryObserverResult<Awaited<ReturnType<CameraClient['getImages']>>>>
12
16
  }
13
17
 
14
- const { name, getImage }: Props = $props()
18
+ const { name, sourceName = '', getImage }: Props = $props()
15
19
 
16
20
  let lastError = $state<Error>()
17
21
 
@@ -35,7 +39,6 @@
35
39
  }
36
40
 
37
41
  const handleExport = async () => {
38
- const exportFilename = `${name}-${getDateString()}.jpeg`
39
42
  const image = await getImage()
40
43
  if (image.error) {
41
44
  lastError = image.error
@@ -43,18 +46,36 @@
43
46
  }
44
47
 
45
48
  lastError = undefined
46
- if (image.data?.images?.[0]?.image) {
47
- const imageBlob = new Blob([new Uint8Array(image.data.images[0].image)], {
48
- type: image.data.images[0].mimeType || 'image/jpeg',
49
- })
49
+ const matchingImage = pickImageForSource(image.data?.images, sourceName)
50
+ if (!matchingImage?.image) {
51
+ return
52
+ }
50
53
 
51
- const link = document.createElement('a')
52
- const dataUrl = URL.createObjectURL(imageBlob)
53
- link.href = dataUrl
54
- link.download = exportFilename
55
- link.click()
56
- URL.revokeObjectURL(dataUrl)
54
+ const bytes = new Uint8Array(matchingImage.image)
55
+ let blob: Blob
56
+ let ext: string
57
+
58
+ if (matchingImage.mimeType === VIAM_DEPTH_MIME_TYPE) {
59
+ // Decode depth frames to a viewable PNG so the exported file matches the live feed.
60
+ const depthBlob = await getBlobForViamDepth(bytes)
61
+ if (!depthBlob) {
62
+ lastError = new Error('Failed to decode depth image')
63
+ return
64
+ }
65
+ blob = depthBlob
66
+ ext = 'png'
67
+ } else {
68
+ blob = new Blob([bytes], { type: matchingImage.mimeType || 'image/jpeg' })
69
+ ext = 'jpeg'
57
70
  }
71
+
72
+ const exportFilename = `${name}-${getDateString()}.${ext}`
73
+ const link = document.createElement('a')
74
+ const dataUrl = URL.createObjectURL(blob)
75
+ link.href = dataUrl
76
+ link.download = exportFilename
77
+ link.click()
78
+ URL.revokeObjectURL(dataUrl)
58
79
  }
59
80
  </script>
60
81
 
@@ -2,6 +2,7 @@ import type { QueryObserverResult } from '@tanstack/svelte-query';
2
2
  import type { CameraClient } from '@viamrobotics/sdk';
3
3
  interface Props {
4
4
  name: string;
5
+ sourceName?: string;
5
6
  getImage: () => Promise<QueryObserverResult<Awaited<ReturnType<CameraClient['getImages']>>>>;
6
7
  }
7
8
  declare const ExportScreenshot: import("svelte").Component<Props, {}, "">;
@@ -0,0 +1,3 @@
1
+ export type XmpJson = Record<string, unknown>;
2
+ /** Extract XMP metadata from image bytes and return it as a plain object. */
3
+ export declare const getXmpJsonFromImageBytes: (image: Uint8Array, mimeType?: string) => XmpJson | null;
@@ -0,0 +1,134 @@
1
+ const JPEG_SOI = 0xffd8;
2
+ const JPEG_MARKER_PREFIX = 0xff;
3
+ const JPEG_APP1_MARKER = 0xe1;
4
+ const JPEG_EOI_MARKER = 0xd9;
5
+ const XMP_IDENTIFIER = 'http://ns.adobe.com/xap/1.0/\0';
6
+ /** Extract XMP metadata from image bytes and return it as a plain object. */
7
+ export const getXmpJsonFromImageBytes = (image, mimeType) => {
8
+ if (mimeType?.includes('png')) {
9
+ return getXmpJsonFromPng(image);
10
+ }
11
+ return getXmpJsonFromJpeg(image);
12
+ };
13
+ const getXmpJsonFromJpeg = (image) => {
14
+ if (image.length < 4 || readUint16(image, 0) !== JPEG_SOI) {
15
+ return null;
16
+ }
17
+ const xmpXml = readXmpXmlFromJpeg(image);
18
+ if (!xmpXml) {
19
+ return null;
20
+ }
21
+ return xmpXmlToJson(xmpXml);
22
+ };
23
+ const readXmpXmlFromJpeg = (image) => {
24
+ const identifier = new TextEncoder().encode(XMP_IDENTIFIER);
25
+ let offset = 2;
26
+ while (offset + 4 < image.length) {
27
+ if (image[offset] !== JPEG_MARKER_PREFIX) {
28
+ break;
29
+ }
30
+ const marker = image[offset + 1];
31
+ if (marker === undefined) {
32
+ break;
33
+ }
34
+ if (marker === JPEG_EOI_MARKER) {
35
+ break;
36
+ }
37
+ const segmentLength = readUint16(image, offset + 2);
38
+ if (segmentLength < 2 || offset + 2 + segmentLength > image.length) {
39
+ break;
40
+ }
41
+ if (marker === JPEG_APP1_MARKER) {
42
+ const segmentData = image.subarray(offset + 4, offset + 2 + segmentLength);
43
+ if (startsWith(segmentData, identifier)) {
44
+ const xmpBytes = segmentData.subarray(identifier.length);
45
+ return new TextDecoder('utf-8').decode(xmpBytes);
46
+ }
47
+ }
48
+ offset += 2 + segmentLength;
49
+ }
50
+ return null;
51
+ };
52
+ const getXmpJsonFromPng = (image) => {
53
+ const signature = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
54
+ if (image.length < signature.length || !startsWith(image, new Uint8Array(signature))) {
55
+ return null;
56
+ }
57
+ let offset = signature.length;
58
+ while (offset + 12 <= image.length) {
59
+ const chunkLength = readUint32(image, offset);
60
+ const chunkType = new TextDecoder('ascii').decode(image.subarray(offset + 4, offset + 8));
61
+ if (chunkType === 'iTXt') {
62
+ const chunkData = image.subarray(offset + 8, offset + 8 + chunkLength);
63
+ const xmpXml = readXmpXmlFromPngITXtChunk(chunkData);
64
+ if (xmpXml) {
65
+ return xmpXmlToJson(xmpXml);
66
+ }
67
+ }
68
+ offset += 12 + chunkLength;
69
+ }
70
+ return null;
71
+ };
72
+ const readXmpXmlFromPngITXtChunk = (chunkData) => {
73
+ let index = 0;
74
+ const readNullTerminated = () => {
75
+ const start = index;
76
+ while (index < chunkData.length && chunkData[index] !== 0) {
77
+ index += 1;
78
+ }
79
+ if (index >= chunkData.length) {
80
+ return null;
81
+ }
82
+ const value = new TextDecoder('utf-8').decode(chunkData.subarray(start, index));
83
+ index += 1;
84
+ return value;
85
+ };
86
+ const keyword = readNullTerminated();
87
+ if (keyword !== 'XML:com.adobe.xmp') {
88
+ return null;
89
+ }
90
+ readNullTerminated(); // compression flag
91
+ readNullTerminated(); // compression method
92
+ readNullTerminated(); // language tag
93
+ readNullTerminated(); // translated keyword
94
+ if (index >= chunkData.length) {
95
+ return null;
96
+ }
97
+ return new TextDecoder('utf-8').decode(chunkData.subarray(index));
98
+ };
99
+ const xmpXmlToJson = (xmpXml) => {
100
+ const doc = new DOMParser().parseFromString(xmpXml, 'application/xml');
101
+ if (doc.querySelector('parsererror')) {
102
+ return null;
103
+ }
104
+ const json = {};
105
+ for (const element of doc.querySelectorAll('*')) {
106
+ for (const attribute of element.attributes) {
107
+ if (attribute.localName === 'about' || attribute.name === 'rdf:about') {
108
+ continue;
109
+ }
110
+ json[attribute.name] = attribute.value;
111
+ }
112
+ if (element.childElementCount === 0 && element.textContent?.trim()) {
113
+ const key = element.prefix ? `${element.prefix}:${element.localName}` : element.localName;
114
+ json[key] = element.textContent.trim();
115
+ }
116
+ }
117
+ return Object.keys(json).length > 0 ? json : null;
118
+ };
119
+ const readUint16 = (bytes, offset) => (bytes[offset] << 8) | bytes[offset + 1];
120
+ const readUint32 = (bytes, offset) => (bytes[offset] << 24) |
121
+ (bytes[offset + 1] << 16) |
122
+ (bytes[offset + 2] << 8) |
123
+ bytes[offset + 3];
124
+ const startsWith = (bytes, prefix) => {
125
+ if (bytes.length < prefix.length) {
126
+ return false;
127
+ }
128
+ for (let index = 0; index < prefix.length; index += 1) {
129
+ if (bytes[index] !== prefix[index]) {
130
+ return false;
131
+ }
132
+ }
133
+ return true;
134
+ };
@@ -15,6 +15,9 @@
15
15
  import { formatNumeric } from '../../../format'
16
16
  import { useMeasureFps } from '../../../fps.svelte'
17
17
 
18
+ import { getBlobForViamDepth, VIAM_DEPTH_MIME_TYPE } from './decode-viam-depth'
19
+ import { pickImageForSource } from './pick-image-for-source'
20
+
18
21
  interface Props {
19
22
  resourceName: string
20
23
  partID: string
@@ -25,6 +28,7 @@
25
28
  isLoading: boolean
26
29
  videoClass?: string
27
30
  showMousePositionTooltip?: boolean
31
+ sourceName?: string
28
32
  refetch: () => Promise<unknown>
29
33
  }
30
34
 
@@ -38,6 +42,7 @@
38
42
  isLoading,
39
43
  videoClass = '',
40
44
  showMousePositionTooltip = false,
45
+ sourceName = '',
41
46
  refetch,
42
47
  }: Props = $props()
43
48
 
@@ -211,16 +216,38 @@
211
216
  })
212
217
 
213
218
  $effect(() => {
214
- if (!data?.images?.[0]?.image) {
219
+ const matchingImage = pickImageForSource(data?.images, sourceName)
220
+ if (!matchingImage?.image) {
215
221
  return
216
222
  }
217
223
 
218
- const imageBlob = new Blob([new Uint8Array(data.images[0].image)], {
219
- type: data.images[0].mimeType || 'image/jpeg',
220
- })
224
+ const bytes = new Uint8Array(matchingImage.image)
225
+ let cancelled = false
226
+ let objectUrl: string | undefined
227
+
228
+ const render = async () => {
229
+ let blob: Blob | undefined
230
+ if (matchingImage.mimeType === VIAM_DEPTH_MIME_TYPE) {
231
+ blob = await getBlobForViamDepth(bytes)
232
+ if (!blob) {
233
+ lastError = new Error('Failed to decode depth frame: truncated or corrupt buffer')
234
+ return
235
+ }
236
+ } else {
237
+ blob = new Blob([bytes], { type: matchingImage.mimeType || 'image/jpeg' })
238
+ }
239
+ if (cancelled) return
240
+ lastError = undefined
241
+ objectUrl = URL.createObjectURL(blob)
242
+ img.src = objectUrl
243
+ }
244
+
245
+ void render()
221
246
 
222
- img.src = URL.createObjectURL(imageBlob)
223
- return () => URL.revokeObjectURL(img.src)
247
+ return () => {
248
+ cancelled = true
249
+ if (objectUrl) URL.revokeObjectURL(objectUrl)
250
+ }
224
251
  })
225
252
 
226
253
  let contentRect = $state.raw<DOMRect>()
@@ -10,6 +10,7 @@ interface Props {
10
10
  isLoading: boolean;
11
11
  videoClass?: string;
12
12
  showMousePositionTooltip?: boolean;
13
+ sourceName?: string;
13
14
  refetch: () => Promise<unknown>;
14
15
  }
15
16
  declare const LiveOrPollingVideo: import("svelte").Component<Props, {}, "">;
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Pick the image whose `sourceName` matches the given source, falling back to
3
+ * the first image.
4
+ */
5
+ export declare const pickImageForSource: <T extends {
6
+ sourceName: string;
7
+ }>(images: T[] | undefined, sourceName: string) => T | undefined;
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Pick the image whose `sourceName` matches the given source, falling back to
3
+ * the first image.
4
+ */
5
+ export const pickImageForSource = (images, sourceName) => {
6
+ if (!images || images.length === 0) {
7
+ return undefined;
8
+ }
9
+ if (!sourceName) {
10
+ return images[0];
11
+ }
12
+ return images.find((img) => img.sourceName === sourceName) ?? images[0];
13
+ };
@@ -0,0 +1,69 @@
1
+ <script lang="ts">
2
+ import type { QueryObserverResult } from '@tanstack/svelte-query'
3
+ import type { OrbitControls as OrbitControlsType } from 'three/examples/jsm/controls/OrbitControls.js'
4
+
5
+ import { T } from '@threlte/core'
6
+ import { OrbitControls, useTexture } from '@threlte/extras'
7
+ import { CameraClient } from '@viamrobotics/sdk'
8
+ import { BackSide } from 'three'
9
+
10
+ const {
11
+ data,
12
+ }: { data: QueryObserverResult<Awaited<ReturnType<CameraClient['getImages']>>>['data'] } =
13
+ $props()
14
+
15
+ let imageUrl = $state.raw('')
16
+ let controlsRef = $state<OrbitControlsType>()
17
+
18
+ $effect(() => {
19
+ const imageRecord = data?.images?.[0]
20
+ const image = imageRecord?.image
21
+ if (!image) {
22
+ imageUrl = ''
23
+ return
24
+ }
25
+
26
+ const imageBytes = new Uint8Array(image)
27
+ const imageBlob = new Blob([imageBytes], {
28
+ type: imageRecord.mimeType || 'image/jpeg',
29
+ })
30
+ const url = URL.createObjectURL(imageBlob)
31
+ imageUrl = url
32
+
33
+ return () => {
34
+ URL.revokeObjectURL(url)
35
+ }
36
+ })
37
+
38
+ const texture = $derived.by(() => (imageUrl ? useTexture(imageUrl) : null))
39
+ </script>
40
+
41
+ <T.PerspectiveCamera
42
+ makeDefault
43
+ position={[0, 0, 0.1]}
44
+ fov={75}
45
+ >
46
+ <OrbitControls
47
+ bind:ref={controlsRef}
48
+ enableZoom={true}
49
+ enablePan={false}
50
+ />
51
+ </T.PerspectiveCamera>
52
+
53
+ {#if $texture}
54
+ {#await texture then map}
55
+ <T.Mesh scale={[-1, 1, 1]}>
56
+ <T.SphereGeometry args={[500, 60, 40]} />
57
+ <T.MeshBasicMaterial
58
+ {map}
59
+ side={BackSide}
60
+ />
61
+ </T.Mesh>
62
+ {/await}
63
+ {/if}
64
+
65
+ <T.AmbientLight intensity={0.5} />
66
+ <T.DirectionalLight
67
+ position={[5, 5, 5]}
68
+ intensity={1}
69
+ />
@@ -0,0 +1,8 @@
1
+ import type { QueryObserverResult } from '@tanstack/svelte-query';
2
+ import { CameraClient } from '@viamrobotics/sdk';
3
+ type $$ComponentProps = {
4
+ data: QueryObserverResult<Awaited<ReturnType<CameraClient['getImages']>>>['data'];
5
+ };
6
+ declare const ThreeSixtyCameraView: import("svelte").Component<$$ComponentProps, {}, "">;
7
+ type ThreeSixtyCameraView = ReturnType<typeof ThreeSixtyCameraView>;
8
+ export default ThreeSixtyCameraView;
@@ -1,4 +1,6 @@
1
1
  <script lang="ts">
2
+ import type { Snippet } from 'svelte'
3
+
2
4
  import { Button, Progress } from '@viamrobotics/prime-core'
3
5
  import { CodeEditor } from '@viamrobotics/prime-core/code-editor'
4
6
  import { type ResourceName, Struct } from '@viamrobotics/sdk'
@@ -15,9 +17,11 @@
15
17
  interface Props {
16
18
  partID: string
17
19
  resource: ResourceName
20
+ /** Rendered above the input/output editor row. */
21
+ header?: Snippet<[{ input: string; setInput: (value: string) => void }]>
18
22
  }
19
23
 
20
- const { partID, resource }: Props = $props()
24
+ const { partID, resource, header }: Props = $props()
21
25
 
22
26
  const client = createDoCommandClient(
23
27
  () => resource,
@@ -37,6 +41,10 @@
37
41
 
38
42
  let output = $state('')
39
43
 
44
+ const setInput = (value: string) => {
45
+ input.current = value
46
+ }
47
+
40
48
  const execute = async () => {
41
49
  try {
42
50
  lastErr = null
@@ -52,6 +60,11 @@
52
60
  </script>
53
61
 
54
62
  {#if isSupported}
63
+ {#if header}
64
+ <div class="border-b">
65
+ {@render header({ input: input.current ?? '{}', setInput })}
66
+ </div>
67
+ {/if}
55
68
  <div class="flex flex-row items-center justify-between">
56
69
  <div class="flex w-[45%] flex-col gap-2 border-r py-2">
57
70
  <span class="text-gray-9 px-4 text-sm font-medium">Input</span>
@@ -1,7 +1,13 @@
1
+ import type { Snippet } from 'svelte';
1
2
  import { type ResourceName } from '@viamrobotics/sdk';
2
3
  interface Props {
3
4
  partID: string;
4
5
  resource: ResourceName;
6
+ /** Rendered above the input/output editor row. */
7
+ header?: Snippet<[{
8
+ input: string;
9
+ setInput: (value: string) => void;
10
+ }]>;
5
11
  }
6
12
  declare const DoCommand: import("svelte").Component<Props, {}, "">;
7
13
  type DoCommand = ReturnType<typeof DoCommand>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@viamrobotics/test-widgets",
3
- "version": "0.4.0",
3
+ "version": "0.6.0",
4
4
  "license": "Apache-2.0",
5
5
  "type": "module",
6
6
  "files": [
@@ -49,7 +49,7 @@
49
49
  "@playwright/test": "^1.59.1",
50
50
  "@sentry/svelte": "^10.50.0",
51
51
  "@sveltejs/adapter-static": "^3.0.10",
52
- "@sveltejs/kit": "^2.58.0",
52
+ "@sveltejs/kit": "^2.60.1",
53
53
  "@sveltejs/package": "^2.5.7",
54
54
  "@sveltejs/vite-plugin-svelte": "^7.0.0",
55
55
  "@tailwindcss/postcss": "^4.2.4",
@@ -67,12 +67,12 @@
67
67
  "@types/node": "^25.6.0",
68
68
  "@types/three": "^0.183.1",
69
69
  "@viamrobotics/motion-tools": "^1.19.1",
70
- "@viamrobotics/prime-core": "^0.1.21",
70
+ "@viamrobotics/prime-core": "^0.1.22",
71
71
  "@viamrobotics/sdk": "^0.69.0",
72
72
  "@viamrobotics/svelte-sdk": "^1.2.1",
73
73
  "@viamrobotics/tailwind-config": "^1.0.0",
74
74
  "@viamrobotics/three": "^0.0.9",
75
- "@vitest/browser": "^4.1.5",
75
+ "@vitest/browser": "^4.1.6",
76
76
  "@vitest/browser-playwright": "^4.1.5",
77
77
  "@vitest/coverage-v8": "^4.1.5",
78
78
  "eslint": "^10.2.1",