@viamrobotics/test-widgets 0.6.0 → 0.7.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/README.md +1 -1
- package/dist/api-docs-href.d.ts +8 -0
- package/dist/api-docs-href.js +17 -0
- package/dist/components/angle-unit-toggle.svelte +1 -4
- package/dist/components/api-section.svelte +21 -23
- package/dist/components/api-section.svelte.d.ts +2 -0
- package/dist/components/index.d.ts +1 -0
- package/dist/components/index.js +1 -0
- package/dist/components/is-moving.svelte +3 -1
- package/dist/components/is-moving.svelte.d.ts +1 -0
- package/dist/components/mutation-section.svelte +19 -22
- package/dist/components/mutation-section.svelte.d.ts +2 -0
- package/dist/components/section-title.svelte +63 -0
- package/dist/components/section-title.svelte.d.ts +14 -0
- package/dist/components/widgets/arm/arm.svelte +17 -24
- package/dist/components/widgets/arm/joint-position-editor.svelte +89 -0
- package/dist/components/widgets/arm/joint-position-editor.svelte.d.ts +11 -0
- package/dist/components/widgets/arm/joint-position-quick-move.svelte +85 -0
- package/dist/components/widgets/arm/joint-position-quick-move.svelte.d.ts +9 -0
- package/dist/components/widgets/arm/joint-position-slider.svelte +36 -0
- package/dist/components/widgets/arm/joint-position-slider.svelte.d.ts +9 -0
- package/dist/components/widgets/arm/move-to-joint-positions.svelte +73 -127
- package/dist/components/widgets/arm/move-to-joint-positions.svelte.d.ts +1 -0
- package/dist/components/widgets/audio-input/audio-input.svelte +2 -0
- package/dist/components/widgets/audio-output/audio-output.svelte +2 -0
- package/dist/components/widgets/base/base.svelte +9 -1
- package/dist/components/widgets/camera/camera.svelte +184 -135
- package/dist/components/widgets/camera/get-pano-coverage-from-xmp.d.ts +31 -0
- package/dist/components/widgets/camera/get-pano-coverage-from-xmp.js +56 -0
- package/dist/components/widgets/camera/live-or-polling-video.svelte +1 -0
- package/dist/components/widgets/camera/live-three-sixty-camera-view.svelte +61 -0
- package/dist/components/widgets/camera/live-three-sixty-camera-view.svelte.d.ts +9 -0
- package/dist/components/widgets/camera/pano-sphere-scene.svelte +54 -0
- package/dist/components/widgets/camera/pano-sphere-scene.svelte.d.ts +10 -0
- package/dist/components/widgets/camera/three-sixty-camera-view.svelte +33 -43
- package/dist/components/widgets/camera/three-sixty-camera-view.svelte.d.ts +5 -3
- package/dist/components/widgets/discovery/discovery.svelte +1 -0
- package/dist/components/widgets/encoder/encoder.svelte +2 -0
- package/dist/components/widgets/gantry/gantry.svelte +28 -5
- package/dist/components/widgets/generic/generic.svelte +8 -1
- package/dist/components/widgets/gripper/gripper.svelte +11 -5
- package/dist/components/widgets/gripper/is-holding-something.svelte +1 -0
- package/dist/components/widgets/input-controller/input-controller.svelte +2 -1
- package/dist/components/widgets/motor/motor.svelte +11 -1
- package/dist/components/widgets/movement-sensor/movement-sensor.svelte +53 -26
- package/dist/components/widgets/power-sensor/power-sensor.svelte +4 -0
- package/dist/components/widgets/sensor/sensor.svelte +1 -0
- package/dist/components/widgets/servo/servo.svelte +10 -2
- package/dist/components/widgets/slam/move-on-map.svelte +4 -1
- package/dist/components/widgets/slam/slam.svelte +4 -1
- package/dist/components/widgets/vision-service/context.svelte.d.ts +1 -0
- package/dist/components/widgets/vision-service/context.svelte.js +7 -0
- package/dist/components/widgets/vision-service/detection-row.svelte +38 -0
- package/dist/components/widgets/vision-service/detection-row.svelte.d.ts +8 -0
- package/dist/components/widgets/vision-service/detection.svelte +7 -4
- package/dist/components/widgets/vision-service/legend.svelte +14 -11
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/package.json +5 -2
|
@@ -12,12 +12,15 @@
|
|
|
12
12
|
createRefetchIntervalStore,
|
|
13
13
|
RefetchIntervals,
|
|
14
14
|
} from '../../refetch-interval-store.svelte'
|
|
15
|
+
import SectionTitle from '../../section-title.svelte'
|
|
15
16
|
|
|
16
17
|
import PCDWidget from '../pcd/pcd-widget.svelte'
|
|
17
18
|
import ExportScreenshot from './export-screenshot.svelte'
|
|
19
|
+
import { getPanoCoverageFromXmp } from './get-pano-coverage-from-xmp'
|
|
18
20
|
import { getSourceNames } from './get-source-names'
|
|
19
21
|
import { getXmpJsonFromImageBytes, type XmpJson } from './get-xmp-json-from-image'
|
|
20
22
|
import LiveOrPollingVideo from './live-or-polling-video.svelte'
|
|
23
|
+
import LiveThreeSixtyCameraView from './live-three-sixty-camera-view.svelte'
|
|
21
24
|
import { pickImageForSource } from './pick-image-for-source'
|
|
22
25
|
import PictureInPictureButton from './picture-in-picture-button.svelte'
|
|
23
26
|
import ThreeSixtyCameraView from './three-sixty-camera-view.svelte'
|
|
@@ -58,16 +61,30 @@
|
|
|
58
61
|
() => resourceName
|
|
59
62
|
)
|
|
60
63
|
|
|
64
|
+
const isLive = $derived(refetchInterval.current === RefetchIntervals.LIVE)
|
|
65
|
+
|
|
61
66
|
const imageQuery = createResourceQuery(
|
|
62
67
|
client,
|
|
63
68
|
'getImages',
|
|
64
69
|
() => (selectedSource ? ([[selectedSource]] as [string[]]) : ([] as [])),
|
|
65
70
|
() => ({
|
|
66
|
-
enabled: isPlaying &&
|
|
71
|
+
enabled: isPlaying && !isLive,
|
|
67
72
|
refetchInterval: refetchInterval.current,
|
|
68
73
|
})
|
|
69
74
|
)
|
|
70
75
|
|
|
76
|
+
// The live WebRTC stream carries no XMP, so when live we fetch a single frame
|
|
77
|
+
// to learn the GPano crop geometry needed to render the stream on the right band.
|
|
78
|
+
const liveCoverageProbe = createResourceQuery(
|
|
79
|
+
client,
|
|
80
|
+
'getImages',
|
|
81
|
+
() => (selectedSource ? ([[selectedSource]] as [string[]]) : ([] as [])),
|
|
82
|
+
() => ({
|
|
83
|
+
enabled: isPlaying && isLive,
|
|
84
|
+
refetchInterval: false,
|
|
85
|
+
})
|
|
86
|
+
)
|
|
87
|
+
|
|
71
88
|
$effect(() => {
|
|
72
89
|
if (sourceNames.length === 0 && imageQuery.data?.images) {
|
|
73
90
|
const names = getSourceNames(imageQuery.data.images)
|
|
@@ -79,7 +96,7 @@
|
|
|
79
96
|
})
|
|
80
97
|
|
|
81
98
|
const xmpJson = $derived.by((): XmpJson | null => {
|
|
82
|
-
const imageRecord = imageQuery.data?.images?.[0]
|
|
99
|
+
const imageRecord = isLive ? liveCoverageProbe.data?.images?.[0] : imageQuery.data?.images?.[0]
|
|
83
100
|
const image = imageRecord?.image
|
|
84
101
|
if (!image) {
|
|
85
102
|
return null
|
|
@@ -88,9 +105,14 @@
|
|
|
88
105
|
return getXmpJsonFromImageBytes(new Uint8Array(image), imageRecord.mimeType)
|
|
89
106
|
})
|
|
90
107
|
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
108
|
+
// Covers both the full-sphere `viam:equirectangular` tag and GPano
|
|
109
|
+
// (Google Photo Sphere) cropped-area metadata. Non-null means the image
|
|
110
|
+
// can be rendered on a viewing sphere.
|
|
111
|
+
const panoCoverage = $derived.by(() => getPanoCoverageFromXmp(xmpJson))
|
|
112
|
+
// A GPano frame is a partial band, not a full 360° sphere.
|
|
113
|
+
const panoToggleLabel = $derived(
|
|
114
|
+
panoCoverage?.kind === 'gpano' ? 'Display as panorama' : 'Display as 360°'
|
|
115
|
+
)
|
|
94
116
|
|
|
95
117
|
const pointcloudQuery = createResourceQuery(client, 'getPointCloud', () => ({
|
|
96
118
|
enabled: isShowingPointcloud,
|
|
@@ -129,152 +151,179 @@
|
|
|
129
151
|
}
|
|
130
152
|
|
|
131
153
|
const headingID = $props.id()
|
|
154
|
+
const getImagesHeadingID = `${headingID}-images`
|
|
155
|
+
const getPointCloudHeadingID = `${headingID}-point-cloud`
|
|
132
156
|
</script>
|
|
133
157
|
|
|
134
158
|
<ConnectionStatus {partID}>
|
|
135
159
|
{#snippet connected()}
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
160
|
+
<section
|
|
161
|
+
class="flex flex-col"
|
|
162
|
+
aria-labelledby={getImagesHeadingID}
|
|
163
|
+
>
|
|
164
|
+
<div class="flex flex-col gap-0.5 p-4 pb-0">
|
|
165
|
+
<SectionTitle
|
|
166
|
+
title="GetImages"
|
|
167
|
+
api="rdk:component:camera"
|
|
168
|
+
headingId={getImagesHeadingID}
|
|
142
169
|
/>
|
|
143
|
-
{#if sourceNames.length > 0 && refetchInterval.current !== RefetchIntervals.LIVE}
|
|
144
|
-
<Label position="left">
|
|
145
|
-
Source
|
|
146
|
-
<Select
|
|
147
|
-
value={selectedSource}
|
|
148
|
-
on:change={onSourceSelect}
|
|
149
|
-
slot="input"
|
|
150
|
-
>
|
|
151
|
-
{#each sourceNames as name (name)}
|
|
152
|
-
<option value={name}>{name}</option>
|
|
153
|
-
{/each}
|
|
154
|
-
</Select>
|
|
155
|
-
</Label>
|
|
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}
|
|
170
|
-
</div>
|
|
171
|
-
{/if}
|
|
172
|
-
<div class="flex h-full w-full gap-4 p-4">
|
|
173
|
-
<div class="grow">
|
|
174
|
-
{#if isPlaying}
|
|
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}
|
|
193
|
-
{:else}
|
|
194
|
-
<div class="bg-medium flex h-64 w-80 items-center justify-center">
|
|
195
|
-
<Button
|
|
196
|
-
icon="play-circle-outline"
|
|
197
|
-
variant="dark"
|
|
198
|
-
onclick={() => {
|
|
199
|
-
isPlaying = true
|
|
200
|
-
}}
|
|
201
|
-
>
|
|
202
|
-
Start feed
|
|
203
|
-
</Button>
|
|
204
|
-
</div>
|
|
205
|
-
{/if}
|
|
206
170
|
</div>
|
|
207
171
|
{#if isPlaying}
|
|
208
|
-
<div class="flex
|
|
209
|
-
<
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
172
|
+
<div class="flex gap-4 p-4 pb-3">
|
|
173
|
+
<RefetchController
|
|
174
|
+
{refetchInterval}
|
|
175
|
+
allowLive
|
|
176
|
+
queries={[imageQuery, pointcloudQuery]}
|
|
213
177
|
/>
|
|
214
|
-
{#if
|
|
215
|
-
<
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
const matchingImage = pickImageForSource(imgData?.images, selectedSource)
|
|
228
|
-
const image = matchingImage?.image
|
|
229
|
-
const mimeType = matchingImage?.mimeType
|
|
230
|
-
|
|
231
|
-
if (image) {
|
|
232
|
-
addImageToDataset({
|
|
233
|
-
binaryData: new Uint8Array(image),
|
|
234
|
-
partID,
|
|
235
|
-
componentType: 'camera',
|
|
236
|
-
componentName: resourceName,
|
|
237
|
-
methodName: 'captureAllFromCamera',
|
|
238
|
-
mimeType: mimeType ?? 'image/png',
|
|
239
|
-
dataRequestTimes: [new Date(), new Date()],
|
|
240
|
-
})
|
|
241
|
-
}
|
|
242
|
-
}}
|
|
243
|
-
>
|
|
244
|
-
Add current image to dataset
|
|
245
|
-
</Button>
|
|
178
|
+
{#if sourceNames.length > 0 && refetchInterval.current !== RefetchIntervals.LIVE}
|
|
179
|
+
<Label position="left">
|
|
180
|
+
Source
|
|
181
|
+
<Select
|
|
182
|
+
value={selectedSource}
|
|
183
|
+
on:change={onSourceSelect}
|
|
184
|
+
slot="input"
|
|
185
|
+
>
|
|
186
|
+
{#each sourceNames as name (name)}
|
|
187
|
+
<option value={name}>{name}</option>
|
|
188
|
+
{/each}
|
|
189
|
+
</Select>
|
|
190
|
+
</Label>
|
|
246
191
|
{/if}
|
|
247
|
-
{#if
|
|
248
|
-
<
|
|
249
|
-
{
|
|
250
|
-
|
|
251
|
-
|
|
192
|
+
{#if panoCoverage}
|
|
193
|
+
<Label>
|
|
194
|
+
{panoToggleLabel}
|
|
195
|
+
<ToggleButtons
|
|
196
|
+
slot="input"
|
|
197
|
+
options={['On', 'Off']}
|
|
198
|
+
selected={displayAs360 ? 'On' : 'Off'}
|
|
199
|
+
on:input={(event) => {
|
|
200
|
+
displayAs360 = event.detail === 'On'
|
|
201
|
+
}}
|
|
202
|
+
/>
|
|
203
|
+
</Label>
|
|
252
204
|
{/if}
|
|
253
|
-
<Label>
|
|
254
|
-
Mouse Position Tooltip
|
|
255
|
-
|
|
256
|
-
<ToggleButtons
|
|
257
|
-
slot="input"
|
|
258
|
-
options={['On', 'Off']}
|
|
259
|
-
selected={mousePostionTooltip}
|
|
260
|
-
on:input={setMousePostionTooltip}
|
|
261
|
-
/>
|
|
262
|
-
</Label>
|
|
263
205
|
</div>
|
|
264
206
|
{/if}
|
|
265
|
-
|
|
207
|
+
<div class="flex h-full w-full gap-4 p-4">
|
|
208
|
+
<div class="grow">
|
|
209
|
+
{#if isPlaying}
|
|
210
|
+
{#if displayAs360}
|
|
211
|
+
{#if isLive}
|
|
212
|
+
<!-- renderMode="always" keeps the live VideoTexture advancing each frame -->
|
|
213
|
+
<Canvas renderMode="always">
|
|
214
|
+
<LiveThreeSixtyCameraView
|
|
215
|
+
{partID}
|
|
216
|
+
{resourceName}
|
|
217
|
+
coverage={panoCoverage}
|
|
218
|
+
/>
|
|
219
|
+
</Canvas>
|
|
220
|
+
{:else}
|
|
221
|
+
<Canvas>
|
|
222
|
+
<ThreeSixtyCameraView
|
|
223
|
+
data={imageQuery.data}
|
|
224
|
+
coverage={panoCoverage}
|
|
225
|
+
/>
|
|
226
|
+
</Canvas>
|
|
227
|
+
{/if}
|
|
228
|
+
{:else}
|
|
229
|
+
<LiveOrPollingVideo
|
|
230
|
+
{partID}
|
|
231
|
+
{resourceName}
|
|
232
|
+
showResolutionOptions
|
|
233
|
+
{isLive}
|
|
234
|
+
data={imageQuery.data}
|
|
235
|
+
error={imageQuery.error}
|
|
236
|
+
isLoading={imageQuery.isLoading}
|
|
237
|
+
refetch={imageQuery.refetch}
|
|
238
|
+
showMousePositionTooltip={mousePostionTooltip === 'On'}
|
|
239
|
+
sourceName={selectedSource}
|
|
240
|
+
/>
|
|
241
|
+
{/if}
|
|
242
|
+
{:else}
|
|
243
|
+
<div class="bg-medium flex h-64 w-80 items-center justify-center">
|
|
244
|
+
<Button
|
|
245
|
+
icon="play-circle-outline"
|
|
246
|
+
variant="dark"
|
|
247
|
+
onclick={() => {
|
|
248
|
+
isPlaying = true
|
|
249
|
+
}}
|
|
250
|
+
>
|
|
251
|
+
Start feed
|
|
252
|
+
</Button>
|
|
253
|
+
</div>
|
|
254
|
+
{/if}
|
|
255
|
+
</div>
|
|
256
|
+
{#if isPlaying}
|
|
257
|
+
<div class="flex flex-col items-start gap-2">
|
|
258
|
+
<ExportScreenshot
|
|
259
|
+
name={client.current?.name ?? ''}
|
|
260
|
+
sourceName={selectedSource}
|
|
261
|
+
getImage={exportScreenshotQuery.refetch}
|
|
262
|
+
/>
|
|
263
|
+
{#if addImageToDataset}
|
|
264
|
+
<Button
|
|
265
|
+
icon="layers-triple-outline"
|
|
266
|
+
onclick={async () => {
|
|
267
|
+
let imgData
|
|
268
|
+
if (refetchInterval.current === RefetchIntervals.LIVE) {
|
|
269
|
+
const { isSuccess, data } = await imageQuery.refetch()
|
|
270
|
+
if (!isSuccess) return
|
|
271
|
+
imgData = data
|
|
272
|
+
} else {
|
|
273
|
+
imgData = imageQuery.data
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
const matchingImage = pickImageForSource(imgData?.images, selectedSource)
|
|
277
|
+
const image = matchingImage?.image
|
|
278
|
+
const mimeType = matchingImage?.mimeType
|
|
279
|
+
|
|
280
|
+
if (image) {
|
|
281
|
+
addImageToDataset({
|
|
282
|
+
binaryData: new Uint8Array(image),
|
|
283
|
+
partID,
|
|
284
|
+
componentType: 'camera',
|
|
285
|
+
componentName: resourceName,
|
|
286
|
+
methodName: 'captureAllFromCamera',
|
|
287
|
+
mimeType: mimeType ?? 'image/png',
|
|
288
|
+
dataRequestTimes: [new Date(), new Date()],
|
|
289
|
+
})
|
|
290
|
+
}
|
|
291
|
+
}}
|
|
292
|
+
>
|
|
293
|
+
Add current image to dataset
|
|
294
|
+
</Button>
|
|
295
|
+
{/if}
|
|
296
|
+
{#if !isFirefox}
|
|
297
|
+
<PictureInPictureButton
|
|
298
|
+
{resourceName}
|
|
299
|
+
rate={refetchInterval.current}
|
|
300
|
+
/>
|
|
301
|
+
{/if}
|
|
302
|
+
<Label>
|
|
303
|
+
Mouse Position Tooltip
|
|
304
|
+
|
|
305
|
+
<ToggleButtons
|
|
306
|
+
slot="input"
|
|
307
|
+
options={['On', 'Off']}
|
|
308
|
+
selected={mousePostionTooltip}
|
|
309
|
+
on:input={setMousePostionTooltip}
|
|
310
|
+
/>
|
|
311
|
+
</Label>
|
|
312
|
+
</div>
|
|
313
|
+
{/if}
|
|
314
|
+
</div>
|
|
315
|
+
</section>
|
|
266
316
|
|
|
267
317
|
<section
|
|
268
318
|
class="flex flex-col gap-4 p-4"
|
|
269
|
-
aria-labelledby={
|
|
319
|
+
aria-labelledby={getPointCloudHeadingID}
|
|
270
320
|
>
|
|
271
|
-
<div>
|
|
272
|
-
<
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
</h3>
|
|
321
|
+
<div class="flex flex-col gap-0.5">
|
|
322
|
+
<SectionTitle
|
|
323
|
+
title="GetPointCloud"
|
|
324
|
+
api="rdk:component:camera"
|
|
325
|
+
headingId={getPointCloudHeadingID}
|
|
326
|
+
/>
|
|
278
327
|
<p class="text-subtle-2 text-xs">
|
|
279
328
|
Get depth measurement data from cameras with depth sensing support
|
|
280
329
|
</p>
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { XmpJson } from './get-xmp-json-from-image';
|
|
2
|
+
/**
|
|
3
|
+
* Angular extent of a (partial) sphere, in Three.js `SphereGeometry` terms. The
|
|
4
|
+
* `kind` discriminator records which metadata form produced it so the UI can pick
|
|
5
|
+
* a matching label; the renderer only consumes the angles.
|
|
6
|
+
*/
|
|
7
|
+
export interface PanoCoverage {
|
|
8
|
+
kind: 'equirectangular' | 'gpano';
|
|
9
|
+
/** Azimuth (longitude) start, radians. */
|
|
10
|
+
phiStart: number;
|
|
11
|
+
/** Azimuth span, radians (full sphere = 2π). */
|
|
12
|
+
phiLength: number;
|
|
13
|
+
/** Polar start from the +Y north pole, radians. */
|
|
14
|
+
thetaStart: number;
|
|
15
|
+
/** Polar span, radians (full sphere = π). */
|
|
16
|
+
thetaLength: number;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Resolve how an image's pixels map onto a viewing sphere from its XMP metadata.
|
|
20
|
+
*
|
|
21
|
+
* - `viam:equirectangular="true"` → a full 360°×180° sphere.
|
|
22
|
+
* - `GPano:ProjectionType="equirectangular"` → the cropped sub-rectangle described
|
|
23
|
+
* by the GPano `FullPano*`/`CroppedArea*` pixel fields, mapped to a partial sphere
|
|
24
|
+
* (e.g. an equatorial band with empty poles). Malformed/zero fields fall back to
|
|
25
|
+
* a full sphere rather than producing NaN geometry.
|
|
26
|
+
*
|
|
27
|
+
* @param xmp - Parsed XMP attributes, or `null` when the image has none.
|
|
28
|
+
* @returns The sphere coverage, or `null` if the image is not a sphere-displayable
|
|
29
|
+
* equirectangular panorama.
|
|
30
|
+
*/
|
|
31
|
+
export declare const getPanoCoverageFromXmp: (xmp: XmpJson | null) => PanoCoverage | null;
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
const TAU = Math.PI * 2;
|
|
2
|
+
const FULL_ANGLES = {
|
|
3
|
+
phiStart: 0,
|
|
4
|
+
phiLength: TAU,
|
|
5
|
+
thetaStart: 0,
|
|
6
|
+
thetaLength: Math.PI,
|
|
7
|
+
};
|
|
8
|
+
/**
|
|
9
|
+
* Resolve how an image's pixels map onto a viewing sphere from its XMP metadata.
|
|
10
|
+
*
|
|
11
|
+
* - `viam:equirectangular="true"` → a full 360°×180° sphere.
|
|
12
|
+
* - `GPano:ProjectionType="equirectangular"` → the cropped sub-rectangle described
|
|
13
|
+
* by the GPano `FullPano*`/`CroppedArea*` pixel fields, mapped to a partial sphere
|
|
14
|
+
* (e.g. an equatorial band with empty poles). Malformed/zero fields fall back to
|
|
15
|
+
* a full sphere rather than producing NaN geometry.
|
|
16
|
+
*
|
|
17
|
+
* @param xmp - Parsed XMP attributes, or `null` when the image has none.
|
|
18
|
+
* @returns The sphere coverage, or `null` if the image is not a sphere-displayable
|
|
19
|
+
* equirectangular panorama.
|
|
20
|
+
*/
|
|
21
|
+
export const getPanoCoverageFromXmp = (xmp) => {
|
|
22
|
+
if (!xmp) {
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
if (xmp['viam:equirectangular'] === 'true') {
|
|
26
|
+
return { kind: 'equirectangular', ...FULL_ANGLES };
|
|
27
|
+
}
|
|
28
|
+
if (xmp['GPano:ProjectionType'] !== 'equirectangular') {
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
const num = (key) => Number(xmp[key]);
|
|
32
|
+
const fullWidth = num('GPano:FullPanoWidthPixels');
|
|
33
|
+
const fullHeight = num('GPano:FullPanoHeightPixels');
|
|
34
|
+
// Malformed/zero dims → full sphere rather than NaN geometry.
|
|
35
|
+
if (!(fullWidth > 0) || !(fullHeight > 0)) {
|
|
36
|
+
return { kind: 'gpano', ...FULL_ANGLES };
|
|
37
|
+
}
|
|
38
|
+
const croppedWidth = num('GPano:CroppedAreaImageWidthPixels');
|
|
39
|
+
const croppedHeight = num('GPano:CroppedAreaImageHeightPixels');
|
|
40
|
+
// Malformed/zero cropped dims → full sphere rather than zero-area geometry.
|
|
41
|
+
if (!(croppedWidth > 0) || !(croppedHeight > 0)) {
|
|
42
|
+
return { kind: 'gpano', ...FULL_ANGLES };
|
|
43
|
+
}
|
|
44
|
+
const coverage = {
|
|
45
|
+
kind: 'gpano',
|
|
46
|
+
phiStart: TAU * (num('GPano:CroppedAreaLeftPixels') / fullWidth),
|
|
47
|
+
phiLength: TAU * (croppedWidth / fullWidth),
|
|
48
|
+
thetaStart: Math.PI * (num('GPano:CroppedAreaTopPixels') / fullHeight),
|
|
49
|
+
thetaLength: Math.PI * (croppedHeight / fullHeight),
|
|
50
|
+
};
|
|
51
|
+
// Any non-finite cropped field → full sphere fallback.
|
|
52
|
+
const angles = [coverage.phiStart, coverage.phiLength, coverage.thetaStart, coverage.thetaLength];
|
|
53
|
+
return angles.every((element) => Number.isFinite(element))
|
|
54
|
+
? coverage
|
|
55
|
+
: { kind: 'gpano', ...FULL_ANGLES };
|
|
56
|
+
};
|
|
@@ -393,6 +393,7 @@
|
|
|
393
393
|
{#if showResolutionOptions && isLive}
|
|
394
394
|
<MutationSection
|
|
395
395
|
title="SetStreamOptions"
|
|
396
|
+
api="rdk:component:camera"
|
|
396
397
|
description="Change the resolution of the live stream video feed"
|
|
397
398
|
lastError={resolutionMutation.error}
|
|
398
399
|
class="-m-4 mt-4"
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { useThrelte } from '@threlte/core'
|
|
3
|
+
import { createStreamClient } from '@viamrobotics/svelte-sdk'
|
|
4
|
+
import { VideoTexture } from 'three'
|
|
5
|
+
|
|
6
|
+
import type { PanoCoverage } from './get-pano-coverage-from-xmp'
|
|
7
|
+
|
|
8
|
+
import PanoSphereScene from './pano-sphere-scene.svelte'
|
|
9
|
+
|
|
10
|
+
interface Props {
|
|
11
|
+
partID: string
|
|
12
|
+
resourceName: string
|
|
13
|
+
coverage?: PanoCoverage | null
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const { partID, resourceName, coverage = null }: Props = $props()
|
|
17
|
+
|
|
18
|
+
const stream = createStreamClient(
|
|
19
|
+
() => partID,
|
|
20
|
+
() => resourceName
|
|
21
|
+
)
|
|
22
|
+
const { renderer } = useThrelte()
|
|
23
|
+
|
|
24
|
+
// A muted, inline, autoplaying <video> backs the VideoTexture. Mirrors the
|
|
25
|
+
// element setup in live-or-polling-video.svelte; created up front like the
|
|
26
|
+
// other camera widgets create their <img>/<canvas> elements.
|
|
27
|
+
const video = document.createElement('video')
|
|
28
|
+
video.muted = true
|
|
29
|
+
video.autoplay = true
|
|
30
|
+
video.playsInline = true
|
|
31
|
+
|
|
32
|
+
let map = $state.raw<VideoTexture>()
|
|
33
|
+
|
|
34
|
+
$effect(() => {
|
|
35
|
+
const mediaStream = stream.mediaStream
|
|
36
|
+
if (!mediaStream) {
|
|
37
|
+
video.srcObject = null
|
|
38
|
+
map = undefined
|
|
39
|
+
return
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
video.srcObject = mediaStream
|
|
43
|
+
void video.play()
|
|
44
|
+
|
|
45
|
+
const texture = new VideoTexture(video)
|
|
46
|
+
texture.colorSpace = renderer.outputColorSpace
|
|
47
|
+
map = texture
|
|
48
|
+
|
|
49
|
+
return () => {
|
|
50
|
+
texture.dispose()
|
|
51
|
+
video.pause()
|
|
52
|
+
video.srcObject = null
|
|
53
|
+
map = undefined
|
|
54
|
+
}
|
|
55
|
+
})
|
|
56
|
+
</script>
|
|
57
|
+
|
|
58
|
+
<PanoSphereScene
|
|
59
|
+
{coverage}
|
|
60
|
+
{map}
|
|
61
|
+
/>
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { PanoCoverage } from './get-pano-coverage-from-xmp';
|
|
2
|
+
interface Props {
|
|
3
|
+
partID: string;
|
|
4
|
+
resourceName: string;
|
|
5
|
+
coverage?: PanoCoverage | null;
|
|
6
|
+
}
|
|
7
|
+
declare const LiveThreeSixtyCameraView: import("svelte").Component<Props, {}, "">;
|
|
8
|
+
type LiveThreeSixtyCameraView = ReturnType<typeof LiveThreeSixtyCameraView>;
|
|
9
|
+
export default LiveThreeSixtyCameraView;
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { T } from '@threlte/core'
|
|
3
|
+
import { OrbitControls } from '@threlte/extras'
|
|
4
|
+
import { BackSide, type Texture } from 'three'
|
|
5
|
+
|
|
6
|
+
import type { PanoCoverage } from './get-pano-coverage-from-xmp'
|
|
7
|
+
|
|
8
|
+
interface Props {
|
|
9
|
+
coverage?: PanoCoverage | null
|
|
10
|
+
/** Texture to paint on the inside of the sphere (image frame or live video). */
|
|
11
|
+
map?: Texture
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const { coverage = null, map }: Props = $props()
|
|
15
|
+
|
|
16
|
+
// Default to a full sphere so a missing coverage renders the original 360° view.
|
|
17
|
+
// `kind` is irrelevant to geometry; only the angles are consumed below.
|
|
18
|
+
const sphere = $derived(
|
|
19
|
+
coverage ?? { phiStart: 0, phiLength: Math.PI * 2, thetaStart: 0, thetaLength: Math.PI }
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
// A band that covers less than the full pole-to-pole sweep is a horizontal-only
|
|
23
|
+
// view: fitting the camera's vertical FOV to the band removes the empty sphere
|
|
24
|
+
// above and below it, and locking the vertical tilt keeps the user from swinging
|
|
25
|
+
// toward those empty caps. A full sphere keeps the original free 75° view.
|
|
26
|
+
const isPartialBand = $derived(sphere.thetaLength < Math.PI - 1e-3)
|
|
27
|
+
const verticalCenter = $derived(sphere.thetaStart + sphere.thetaLength / 2)
|
|
28
|
+
const fov = $derived(isPartialBand ? (sphere.thetaLength * 180) / Math.PI : 75)
|
|
29
|
+
</script>
|
|
30
|
+
|
|
31
|
+
<T.PerspectiveCamera
|
|
32
|
+
makeDefault
|
|
33
|
+
position={[0, 0, 0.1]}
|
|
34
|
+
{fov}
|
|
35
|
+
>
|
|
36
|
+
<OrbitControls
|
|
37
|
+
enableZoom={true}
|
|
38
|
+
enablePan={false}
|
|
39
|
+
minPolarAngle={isPartialBand ? verticalCenter : 0}
|
|
40
|
+
maxPolarAngle={isPartialBand ? verticalCenter : Math.PI}
|
|
41
|
+
/>
|
|
42
|
+
</T.PerspectiveCamera>
|
|
43
|
+
|
|
44
|
+
{#if map}
|
|
45
|
+
<T.Mesh scale={[-1, 1, 1]}>
|
|
46
|
+
<T.SphereGeometry
|
|
47
|
+
args={[500, 60, 40, sphere.phiStart, sphere.phiLength, sphere.thetaStart, sphere.thetaLength]}
|
|
48
|
+
/>
|
|
49
|
+
<T.MeshBasicMaterial
|
|
50
|
+
{map}
|
|
51
|
+
side={BackSide}
|
|
52
|
+
/>
|
|
53
|
+
</T.Mesh>
|
|
54
|
+
{/if}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { type Texture } from 'three';
|
|
2
|
+
import type { PanoCoverage } from './get-pano-coverage-from-xmp';
|
|
3
|
+
interface Props {
|
|
4
|
+
coverage?: PanoCoverage | null;
|
|
5
|
+
/** Texture to paint on the inside of the sphere (image frame or live video). */
|
|
6
|
+
map?: Texture;
|
|
7
|
+
}
|
|
8
|
+
declare const PanoSphereScene: import("svelte").Component<Props, {}, "">;
|
|
9
|
+
type PanoSphereScene = ReturnType<typeof PanoSphereScene>;
|
|
10
|
+
export default PanoSphereScene;
|