@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.
Files changed (59) hide show
  1. package/README.md +1 -1
  2. package/dist/api-docs-href.d.ts +8 -0
  3. package/dist/api-docs-href.js +17 -0
  4. package/dist/components/angle-unit-toggle.svelte +1 -4
  5. package/dist/components/api-section.svelte +21 -23
  6. package/dist/components/api-section.svelte.d.ts +2 -0
  7. package/dist/components/index.d.ts +1 -0
  8. package/dist/components/index.js +1 -0
  9. package/dist/components/is-moving.svelte +3 -1
  10. package/dist/components/is-moving.svelte.d.ts +1 -0
  11. package/dist/components/mutation-section.svelte +19 -22
  12. package/dist/components/mutation-section.svelte.d.ts +2 -0
  13. package/dist/components/section-title.svelte +63 -0
  14. package/dist/components/section-title.svelte.d.ts +14 -0
  15. package/dist/components/widgets/arm/arm.svelte +17 -24
  16. package/dist/components/widgets/arm/joint-position-editor.svelte +89 -0
  17. package/dist/components/widgets/arm/joint-position-editor.svelte.d.ts +11 -0
  18. package/dist/components/widgets/arm/joint-position-quick-move.svelte +85 -0
  19. package/dist/components/widgets/arm/joint-position-quick-move.svelte.d.ts +9 -0
  20. package/dist/components/widgets/arm/joint-position-slider.svelte +36 -0
  21. package/dist/components/widgets/arm/joint-position-slider.svelte.d.ts +9 -0
  22. package/dist/components/widgets/arm/move-to-joint-positions.svelte +73 -127
  23. package/dist/components/widgets/arm/move-to-joint-positions.svelte.d.ts +1 -0
  24. package/dist/components/widgets/audio-input/audio-input.svelte +2 -0
  25. package/dist/components/widgets/audio-output/audio-output.svelte +2 -0
  26. package/dist/components/widgets/base/base.svelte +9 -1
  27. package/dist/components/widgets/camera/camera.svelte +184 -135
  28. package/dist/components/widgets/camera/get-pano-coverage-from-xmp.d.ts +31 -0
  29. package/dist/components/widgets/camera/get-pano-coverage-from-xmp.js +56 -0
  30. package/dist/components/widgets/camera/live-or-polling-video.svelte +1 -0
  31. package/dist/components/widgets/camera/live-three-sixty-camera-view.svelte +61 -0
  32. package/dist/components/widgets/camera/live-three-sixty-camera-view.svelte.d.ts +9 -0
  33. package/dist/components/widgets/camera/pano-sphere-scene.svelte +54 -0
  34. package/dist/components/widgets/camera/pano-sphere-scene.svelte.d.ts +10 -0
  35. package/dist/components/widgets/camera/three-sixty-camera-view.svelte +33 -43
  36. package/dist/components/widgets/camera/three-sixty-camera-view.svelte.d.ts +5 -3
  37. package/dist/components/widgets/discovery/discovery.svelte +1 -0
  38. package/dist/components/widgets/encoder/encoder.svelte +2 -0
  39. package/dist/components/widgets/gantry/gantry.svelte +28 -5
  40. package/dist/components/widgets/generic/generic.svelte +8 -1
  41. package/dist/components/widgets/gripper/gripper.svelte +11 -5
  42. package/dist/components/widgets/gripper/is-holding-something.svelte +1 -0
  43. package/dist/components/widgets/input-controller/input-controller.svelte +2 -1
  44. package/dist/components/widgets/motor/motor.svelte +11 -1
  45. package/dist/components/widgets/movement-sensor/movement-sensor.svelte +53 -26
  46. package/dist/components/widgets/power-sensor/power-sensor.svelte +4 -0
  47. package/dist/components/widgets/sensor/sensor.svelte +1 -0
  48. package/dist/components/widgets/servo/servo.svelte +10 -2
  49. package/dist/components/widgets/slam/move-on-map.svelte +4 -1
  50. package/dist/components/widgets/slam/slam.svelte +4 -1
  51. package/dist/components/widgets/vision-service/context.svelte.d.ts +1 -0
  52. package/dist/components/widgets/vision-service/context.svelte.js +7 -0
  53. package/dist/components/widgets/vision-service/detection-row.svelte +38 -0
  54. package/dist/components/widgets/vision-service/detection-row.svelte.d.ts +8 -0
  55. package/dist/components/widgets/vision-service/detection.svelte +7 -4
  56. package/dist/components/widgets/vision-service/legend.svelte +14 -11
  57. package/dist/index.d.ts +1 -0
  58. package/dist/index.js +1 -0
  59. 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 && refetchInterval.current !== RefetchIntervals.LIVE,
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
- const is360EnabledImage = $derived.by((): boolean => {
92
- return xmpJson?.['viam:is360'] === 'true'
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
- {#if isPlaying}
137
- <div class="flex gap-4 p-4 pb-3">
138
- <RefetchController
139
- {refetchInterval}
140
- allowLive
141
- queries={[imageQuery, pointcloudQuery]}
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 flex-col items-start gap-2">
209
- <ExportScreenshot
210
- name={client.current?.name ?? ''}
211
- sourceName={selectedSource}
212
- getImage={exportScreenshotQuery.refetch}
172
+ <div class="flex gap-4 p-4 pb-3">
173
+ <RefetchController
174
+ {refetchInterval}
175
+ allowLive
176
+ queries={[imageQuery, pointcloudQuery]}
213
177
  />
214
- {#if addImageToDataset}
215
- <Button
216
- icon="layers-triple-outline"
217
- onclick={async () => {
218
- let imgData
219
- if (refetchInterval.current === RefetchIntervals.LIVE) {
220
- const { isSuccess, data } = await imageQuery.refetch()
221
- if (!isSuccess) return
222
- imgData = data
223
- } else {
224
- imgData = imageQuery.data
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 !isFirefox}
248
- <PictureInPictureButton
249
- {resourceName}
250
- rate={refetchInterval.current}
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
- </div>
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={headingID}
319
+ aria-labelledby={getPointCloudHeadingID}
270
320
  >
271
- <div>
272
- <h3
273
- id={headingID}
274
- class="pb-1.5 font-semibold"
275
- >
276
- GetPointCloud
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;