@viamrobotics/test-widgets 0.3.1 → 0.5.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 (40) hide show
  1. package/dist/client-map.d.ts +1 -1
  2. package/dist/client-map.js +1 -1
  3. package/dist/components/copy-button.svelte +10 -10
  4. package/dist/components/mutation-section.svelte +1 -1
  5. package/dist/components/paste-button.svelte +38 -0
  6. package/dist/components/paste-button.svelte.d.ts +7 -0
  7. package/dist/components/refetch-interval-store.svelte.d.ts +1 -1
  8. package/dist/components/widgets/arm/arm.svelte +7 -3
  9. package/dist/components/widgets/arm/joint-position-limits.d.ts +11 -0
  10. package/dist/components/widgets/arm/joint-position-limits.js +26 -0
  11. package/dist/components/widgets/arm/move-to-joint-positions-widget.svelte +9 -4
  12. package/dist/components/widgets/arm/move-to-joint-positions.svelte +86 -17
  13. package/dist/components/widgets/arm/move-to-joint-positions.svelte.d.ts +2 -0
  14. package/dist/components/widgets/arm/move-to-position.svelte +11 -3
  15. package/dist/components/widgets/camera/camera.svelte +54 -13
  16. package/dist/components/widgets/camera/decode-viam-depth.d.ts +37 -0
  17. package/dist/components/widgets/camera/decode-viam-depth.js +114 -0
  18. package/dist/components/widgets/camera/export-screenshot.svelte +33 -12
  19. package/dist/components/widgets/camera/export-screenshot.svelte.d.ts +1 -0
  20. package/dist/components/widgets/camera/get-xmp-json-from-image.d.ts +3 -0
  21. package/dist/components/widgets/camera/get-xmp-json-from-image.js +134 -0
  22. package/dist/components/widgets/camera/live-or-polling-video.svelte +33 -6
  23. package/dist/components/widgets/camera/live-or-polling-video.svelte.d.ts +1 -0
  24. package/dist/components/widgets/camera/pick-image-for-source.d.ts +7 -0
  25. package/dist/components/widgets/camera/pick-image-for-source.js +13 -0
  26. package/dist/components/widgets/camera/three-sixty-camera-view.svelte +69 -0
  27. package/dist/components/widgets/camera/three-sixty-camera-view.svelte.d.ts +8 -0
  28. package/dist/components/widgets/do-command/do-command.svelte +2 -1
  29. package/dist/components/widgets/gripper/gripper.svelte +5 -0
  30. package/dist/components/widgets/gripper/is-holding-something.svelte +57 -0
  31. package/dist/components/widgets/gripper/is-holding-something.svelte.d.ts +7 -0
  32. package/dist/get-resource-api.d.ts +2 -0
  33. package/dist/get-resource-api.js +1 -0
  34. package/dist/get-resource-key.d.ts +2 -0
  35. package/dist/get-resource-key.js +2 -0
  36. package/dist/index.d.ts +2 -1
  37. package/dist/index.js +2 -1
  38. package/dist/resource.d.ts +0 -2
  39. package/dist/resource.js +1 -2
  40. package/package.json +7 -5
@@ -30,5 +30,5 @@ export declare const clientMap: {
30
30
  readonly 'rdk:service:world_state_store': typeof WorldStateStoreClient;
31
31
  readonly 'rdk:service:video': typeof VideoClient;
32
32
  };
33
- export declare const clientForResource: (resource: ResourceName) => typeof ArmClient | typeof BaseClient | typeof GantryClient | typeof GripperClient | typeof MotorClient | typeof ServoClient | typeof AudioInClient | typeof AudioOutClient | typeof BoardClient | typeof ButtonClient | typeof CameraClient | typeof DiscoveryClient | typeof EncoderClient | typeof GenericComponentClient | typeof InputControllerClient | typeof MovementSensorClient | typeof PoseTrackerClient | typeof PowerSensorClient | typeof SensorClient | typeof SwitchClient | typeof DataManagerClient | typeof GenericServiceClient | typeof MLModelClient | typeof MotionClient | typeof NavigationClient | typeof SlamClient | typeof VisionClient | typeof WorldStateStoreClient | typeof VideoClient | undefined;
33
+ export declare const clientForResource: (resource: ResourceName) => typeof ArmClient | typeof AudioInClient | typeof AudioOutClient | typeof BaseClient | typeof BoardClient | typeof ButtonClient | typeof CameraClient | typeof EncoderClient | typeof GantryClient | typeof GenericComponentClient | typeof GripperClient | typeof InputControllerClient | typeof MotorClient | typeof MovementSensorClient | typeof PoseTrackerClient | typeof PowerSensorClient | typeof SensorClient | typeof ServoClient | typeof SwitchClient | typeof DataManagerClient | typeof DiscoveryClient | typeof GenericServiceClient | typeof MLModelClient | typeof MotionClient | typeof NavigationClient | typeof SlamClient | typeof VisionClient | typeof WorldStateStoreClient | typeof VideoClient | undefined;
34
34
  export declare const supportsDoCommand: (resource: ResourceName) => boolean;
@@ -1,5 +1,5 @@
1
1
  import { ArmClient, AudioInClient, AudioOutClient, BaseClient, BoardClient, ButtonClient, CameraClient, DataManagerClient, DiscoveryClient, EncoderClient, GantryClient, GenericComponentClient, GenericServiceClient, GripperClient, InputControllerClient, MLModelClient, MotionClient, MotorClient, MovementSensorClient, NavigationClient, PoseTrackerClient, PowerSensorClient, SensorClient, ServoClient, SlamClient, SwitchClient, VideoClient, VisionClient, WorldStateStoreClient, } from '@viamrobotics/sdk';
2
- import { getResourceAPI } from "./resource.js";
2
+ import { getResourceAPI } from "./get-resource-api.js";
3
3
  export const clientMap = {
4
4
  'rdk:component:arm': ArmClient,
5
5
  'rdk:component:audio_in': AudioInClient,
@@ -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;
@@ -14,5 +14,5 @@ export declare const RefetchIntervals: {
14
14
  };
15
15
  export type RefetchRate = ValueOf<typeof RefetchRates>;
16
16
  export type RefetchInterval = ValueOf<typeof RefetchIntervals>;
17
- export declare const RefetchRateToInterval: ["Live" | "Refresh every second" | "Refresh every 5 seconds" | "Manual refresh", false | 33.3333 | 1000 | 5000][];
17
+ export declare const RefetchRateToInterval: ["Live" | "Refresh every second" | "Refresh every 5 seconds" | "Manual refresh", false | 1000 | 33.3333 | 5000][];
18
18
  export declare const createRefetchIntervalStore: (partID: () => string, resourceName: () => string, scope: string, defaultInterval?: RefetchInterval) => PersistedState<RefetchInterval>;
@@ -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>;