@viamrobotics/test-widgets 0.4.0 → 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.
- package/dist/components/copy-button.svelte +10 -10
- package/dist/components/mutation-section.svelte +1 -1
- package/dist/components/paste-button.svelte +38 -0
- package/dist/components/paste-button.svelte.d.ts +7 -0
- package/dist/components/widgets/arm/arm.svelte +7 -3
- package/dist/components/widgets/arm/joint-position-limits.d.ts +11 -0
- package/dist/components/widgets/arm/joint-position-limits.js +26 -0
- package/dist/components/widgets/arm/move-to-joint-positions-widget.svelte +9 -4
- package/dist/components/widgets/arm/move-to-joint-positions.svelte +86 -17
- package/dist/components/widgets/arm/move-to-joint-positions.svelte.d.ts +2 -0
- package/dist/components/widgets/arm/move-to-position.svelte +11 -3
- package/dist/components/widgets/camera/camera.svelte +54 -13
- package/dist/components/widgets/camera/decode-viam-depth.d.ts +37 -0
- package/dist/components/widgets/camera/decode-viam-depth.js +114 -0
- package/dist/components/widgets/camera/export-screenshot.svelte +33 -12
- package/dist/components/widgets/camera/export-screenshot.svelte.d.ts +1 -0
- package/dist/components/widgets/camera/get-xmp-json-from-image.d.ts +3 -0
- package/dist/components/widgets/camera/get-xmp-json-from-image.js +134 -0
- package/dist/components/widgets/camera/live-or-polling-video.svelte +33 -6
- package/dist/components/widgets/camera/live-or-polling-video.svelte.d.ts +1 -0
- package/dist/components/widgets/camera/pick-image-for-source.d.ts +7 -0
- package/dist/components/widgets/camera/pick-image-for-source.js +13 -0
- package/dist/components/widgets/camera/three-sixty-camera-view.svelte +69 -0
- package/dist/components/widgets/camera/three-sixty-camera-view.svelte.d.ts +8 -0
- package/package.json +2 -2
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
|
-
import {
|
|
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
|
-
<
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
-
</
|
|
36
|
+
<span slot="description">{ariaLabel}</span>
|
|
37
|
+
</Tooltip>
|
|
@@ -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>
|
|
@@ -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
|
-
<
|
|
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
|
-
</
|
|
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
|
|
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
|
-
<
|
|
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
|
-
</
|
|
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">
|
|
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>
|
|
131
|
+
<th>{index}</th>
|
|
71
132
|
<th>
|
|
72
|
-
<
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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
|
-
|
|
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
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
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
|
|
188
|
-
const
|
|
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
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
49
|
+
const matchingImage = pickImageForSource(image.data?.images, sourceName)
|
|
50
|
+
if (!matchingImage?.image) {
|
|
51
|
+
return
|
|
52
|
+
}
|
|
50
53
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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,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
|
-
|
|
219
|
+
const matchingImage = pickImageForSource(data?.images, sourceName)
|
|
220
|
+
if (!matchingImage?.image) {
|
|
215
221
|
return
|
|
216
222
|
}
|
|
217
223
|
|
|
218
|
-
const
|
|
219
|
-
|
|
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
|
-
|
|
223
|
-
|
|
247
|
+
return () => {
|
|
248
|
+
cancelled = true
|
|
249
|
+
if (objectUrl) URL.revokeObjectURL(objectUrl)
|
|
250
|
+
}
|
|
224
251
|
})
|
|
225
252
|
|
|
226
253
|
let contentRect = $state.raw<DOMRect>()
|
|
@@ -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;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@viamrobotics/test-widgets",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"license": "Apache-2.0",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"files": [
|
|
@@ -67,7 +67,7 @@
|
|
|
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.
|
|
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",
|