@viamrobotics/motion-tools 1.27.1 → 1.28.1

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 (35) hide show
  1. package/dist/components/App.svelte +11 -3
  2. package/dist/components/App.svelte.d.ts +1 -1
  3. package/dist/components/Camera.svelte +1 -1
  4. package/dist/components/CameraControls.svelte +1 -15
  5. package/dist/components/Focus.svelte +5 -19
  6. package/dist/components/MeasureTool/MeasureTool.svelte +2 -1
  7. package/dist/components/Scene.svelte +1 -1
  8. package/dist/components/SceneProviders.svelte +2 -8
  9. package/dist/components/SceneProviders.svelte.d.ts +0 -2
  10. package/dist/components/Selection/Ellipse.svelte +10 -8
  11. package/dist/components/Selection/Lasso.svelte +10 -8
  12. package/dist/components/overlay/Details.svelte +37 -5
  13. package/dist/components/overlay/FloatingPanel.svelte +8 -3
  14. package/dist/components/overlay/FloatingPanel.svelte.d.ts +5 -0
  15. package/dist/components/overlay/controls/Controls.svelte +40 -0
  16. package/dist/components/overlay/controls/Controls.svelte.d.ts +3 -0
  17. package/dist/components/overlay/dashboard/Button.svelte +3 -3
  18. package/dist/components/overlay/dashboard/Button.svelte.d.ts +1 -1
  19. package/dist/components/overlay/dashboard/Dashboard.svelte +15 -38
  20. package/dist/components/overlay/widgets/FramePov.svelte +202 -0
  21. package/dist/components/overlay/widgets/FramePov.svelte.d.ts +6 -0
  22. package/dist/ecs/hierarchy.d.ts +16 -0
  23. package/dist/ecs/hierarchy.js +36 -14
  24. package/dist/ecs/worldMatrix.js +18 -5
  25. package/dist/hooks/useControls.svelte.d.ts +3 -2
  26. package/dist/hooks/useControls.svelte.js +13 -5
  27. package/dist/hooks/useSettings.svelte.d.ts +1 -0
  28. package/dist/hooks/useSettings.svelte.js +1 -0
  29. package/dist/plugins/Skybox/Skybox.svelte +73 -0
  30. package/dist/plugins/Skybox/Skybox.svelte.d.ts +26 -0
  31. package/dist/plugins/index.d.ts +1 -0
  32. package/dist/plugins/index.js +2 -0
  33. package/package.json +5 -1
  34. /package/dist/{plugins → hooks/plugins}/bvh.svelte.d.ts +0 -0
  35. /package/dist/{plugins → hooks/plugins}/bvh.svelte.js +0 -0
@@ -10,14 +10,14 @@
10
10
  import { provideToast, ToastContainer } from '@viamrobotics/prime-core'
11
11
  import { ThemeUtils } from 'svelte-tweakpane-ui'
12
12
 
13
- import type { CameraPose } from '../hooks/useControls.svelte'
14
-
13
+ import Controls from './overlay/controls/Controls.svelte'
15
14
  import Dashboard from './overlay/dashboard/Dashboard.svelte'
16
15
  import Details from './overlay/Details.svelte'
17
16
  import TreeContainer from './overlay/left-pane/TreeContainer.svelte'
18
17
  import Settings from './overlay/settings/Settings.svelte'
19
18
  import XR from './xr/XR.svelte'
20
19
  import { provideWorld } from '../ecs'
20
+ import { type CameraPose, provideCameraControls } from '../hooks/useControls.svelte'
21
21
  import {
22
22
  type DrawConnectionConfig,
23
23
  provideDrawConnectionConfig,
@@ -36,6 +36,7 @@
36
36
  import Logs from './overlay/Logs.svelte'
37
37
  import ArmPositions from './overlay/widgets/ArmPositions.svelte'
38
38
  import Camera from './overlay/widgets/Camera.svelte'
39
+ import FramePov from './overlay/widgets/FramePov.svelte'
39
40
  import Scene from './Scene.svelte'
40
41
  import SceneProviders from './SceneProviders.svelte'
41
42
 
@@ -89,8 +90,10 @@
89
90
  const settings = provideSettings()
90
91
  const environment = provideEnvironment()
91
92
  const currentRobotCameraWidgets = $derived(settings.current.openCameraWidgets[partID] || [])
93
+ const currentFramePovWidgets = $derived(settings.current.openFramePovWidgets[partID] || [])
92
94
  const { isPresenting } = useXR()
93
95
 
96
+ provideCameraControls(() => cameraPose)
94
97
  createPartIDContext(() => partID)
95
98
  provideDrawConnectionConfig(() => drawConnectionConfig)
96
99
  provideWeblabs()
@@ -129,7 +132,7 @@
129
132
  renderMode="on-demand"
130
133
  dpr={[1, 2]}
131
134
  >
132
- <SceneProviders {cameraPose}>
135
+ <SceneProviders>
133
136
  {#snippet children({ focus })}
134
137
  <Scene>
135
138
  {@render appChildren?.()}
@@ -145,6 +148,7 @@
145
148
  <div {@attach domPortal(root)}>
146
149
  <FileDrop />
147
150
  <Dashboard {dashboard} />
151
+ <Controls />
148
152
  <Details {details} />
149
153
 
150
154
  {#if environment.current.isStandalone}
@@ -163,6 +167,10 @@
163
167
  {#each currentRobotCameraWidgets as cameraName (cameraName)}
164
168
  <Camera name={cameraName} />
165
169
  {/each}
170
+
171
+ {#each currentFramePovWidgets as povFrameName (povFrameName)}
172
+ <FramePov frameName={povFrameName} />
173
+ {/each}
166
174
  {/if}
167
175
 
168
176
  <PortalTarget id="dom" />
@@ -1,7 +1,7 @@
1
1
  import type { Struct } from '@viamrobotics/sdk';
2
2
  import type { Entity } from 'koota';
3
3
  import type { Snippet } from 'svelte';
4
- import type { CameraPose } from '../hooks/useControls.svelte';
4
+ import { type CameraPose } from '../hooks/useControls.svelte';
5
5
  import { type DrawConnectionConfig } from '../hooks/useDrawConnectionConfig.svelte';
6
6
  interface LocalConfigProps {
7
7
  current: Struct;
@@ -12,7 +12,7 @@
12
12
  {#if mode === 'perspective'}
13
13
  <T.PerspectiveCamera
14
14
  makeDefault
15
- near={0.01}
15
+ near={0.001}
16
16
  up={[0, 0, 1]}
17
17
  oncreate={(ref) => {
18
18
  ref.lookAt(0, 0, 0)
@@ -1,8 +1,7 @@
1
1
  <script lang="ts">
2
- import { CameraControls, type CameraControlsRef, Gizmo, Portal } from '@threlte/extras'
2
+ import { CameraControls, type CameraControlsRef, Gizmo } from '@threlte/extras'
3
3
  import { MathUtils } from 'three'
4
4
 
5
- import Button from './overlay/dashboard/Button.svelte'
6
5
  import { useCameraControls, useTransformControls } from '../hooks/useControls.svelte'
7
6
  import { useEnvironment } from '../hooks/useEnvironment.svelte'
8
7
 
@@ -15,19 +14,6 @@
15
14
  const inputBindingsEnabled = $derived(environment.current.inputBindingsEnabled)
16
15
  </script>
17
16
 
18
- <Portal id="dashboard">
19
- <fieldset>
20
- <Button
21
- active
22
- icon="camera-outline"
23
- description="Reset camera"
24
- onclick={() => {
25
- cameraControls.setInitialPose()
26
- }}
27
- />
28
- </fieldset>
29
- </Portal>
30
-
31
17
  <CameraControls
32
18
  enabled={!transformControls.active}
33
19
  oncreate={(ref) => {
@@ -1,10 +1,9 @@
1
1
  <script lang="ts">
2
2
  import { T } from '@threlte/core'
3
- import { Gizmo, Portal, TrackballControls } from '@threlte/extras'
3
+ import { Gizmo, TrackballControls } from '@threlte/extras'
4
4
  import { Box3, type Object3D, Vector3 } from 'three'
5
- import { TrackballControls as ThreeTrackballControls } from 'three/examples/jsm/controls/TrackballControls.js'
6
5
 
7
- import Button from './overlay/dashboard/Button.svelte'
6
+ import { useCameraControls } from '../hooks/useControls.svelte'
8
7
 
9
8
  import Camera from './Camera.svelte'
10
9
 
@@ -14,14 +13,14 @@
14
13
 
15
14
  let { object3d }: Props = $props()
16
15
 
16
+ const cameraControls = useCameraControls()
17
+
17
18
  const box = new Box3()
18
19
  const vec = new Vector3()
19
20
 
20
21
  let center = $state.raw<[number, number, number]>([0, 0, 0])
21
22
  let size = $state.raw<[number, number, number]>([0, 0, 0])
22
23
 
23
- let controls = $state.raw<ThreeTrackballControls>()
24
-
25
24
  $effect.pre(() => {
26
25
  box.setFromObject(object3d)
27
26
  size = box.getSize(vec).toArray()
@@ -29,23 +28,10 @@
29
28
  })
30
29
  </script>
31
30
 
32
- <Portal id="dashboard">
33
- <fieldset>
34
- <Button
35
- active
36
- icon="camera-outline"
37
- description="Reset camera"
38
- onclick={() => {
39
- controls?.reset()
40
- }}
41
- />
42
- </fieldset>
43
- </Portal>
44
-
45
31
  <Camera position={[size[0] + 1, size[0] + 1, size[0] + 1]}>
46
32
  <TrackballControls
47
- bind:ref={controls}
48
33
  target={center}
34
+ oncreate={(ref) => cameraControls.set(ref)}
49
35
  >
50
36
  <Gizmo placement="bottom-right" />
51
37
  </TrackballControls>
@@ -84,6 +84,7 @@
84
84
  <div class="flex">
85
85
  <Button
86
86
  active={enabled}
87
+ class="rounded-r-none"
87
88
  icon="ruler"
88
89
  description="{enabled ? 'Disable' : 'Enable'} measurement"
89
90
  onclick={() => {
@@ -95,7 +96,7 @@
95
96
  <Button
96
97
  {...triggerProps}
97
98
  active={enabled}
98
- class="border-l-0"
99
+ class="rounded-l-none border-l-0"
99
100
  icon="filter-sliders"
100
101
  description="Measurement settings"
101
102
  />
@@ -12,9 +12,9 @@
12
12
  import Selected from './Selected.svelte'
13
13
  import SelectedTransformControls from './SelectedTransformControls.svelte'
14
14
  import StaticGeometries from './StaticGeometries.svelte'
15
+ import { bvh } from '../hooks/plugins/bvh.svelte'
15
16
  import { useFocusedObject3d } from '../hooks/useSelection.svelte'
16
17
  import { useSettings } from '../hooks/useSettings.svelte'
17
- import { bvh } from '../plugins/bvh.svelte'
18
18
 
19
19
  import hdrImage from '../assets/ferndale_studio_11_1k.hdr'
20
20
  import BatchedArrows from './BatchedArrows.svelte'
@@ -6,11 +6,7 @@
6
6
  import { provideArmClient } from '../hooks/useArmClient.svelte'
7
7
  import { provideArmKinematics } from '../hooks/useArmKinematics.svelte'
8
8
  import { provideConfigFrames } from '../hooks/useConfigFrames.svelte'
9
- import {
10
- type CameraPose,
11
- provideCameraControls,
12
- provideTransformControls,
13
- } from '../hooks/useControls.svelte'
9
+ import { provideTransformControls } from '../hooks/useControls.svelte'
14
10
  import { provideDrawAPI } from '../hooks/useDrawAPI.svelte'
15
11
  import { provideDrawService } from '../hooks/useDrawService.svelte'
16
12
  import { provideFrameEditSession } from '../hooks/useFrameEditSession.svelte'
@@ -30,15 +26,13 @@
30
26
  import { provideOrigin } from './xr/useOrigin.svelte'
31
27
 
32
28
  interface Props {
33
- cameraPose?: CameraPose
34
29
  children: Snippet<[{ focus: boolean }]>
35
30
  }
36
31
 
37
- let { cameraPose, children }: Props = $props()
32
+ let { children }: Props = $props()
38
33
 
39
34
  const partID = usePartID()
40
35
 
41
- provideCameraControls(() => cameraPose)
42
36
  provideTransformControls()
43
37
  provideLogs()
44
38
 
@@ -1,7 +1,5 @@
1
1
  import type { Snippet } from 'svelte';
2
- import { type CameraPose } from '../hooks/useControls.svelte';
3
2
  interface Props {
4
- cameraPose?: CameraPose;
5
3
  children: Snippet<[{
6
4
  focus: boolean;
7
5
  }]>;
@@ -263,16 +263,18 @@
263
263
 
264
264
  const currentControls = controls.current
265
265
 
266
- const { minPolarAngle, maxPolarAngle } = currentControls
266
+ if ('minPolarAngle' in currentControls) {
267
+ const { minPolarAngle, maxPolarAngle } = currentControls
267
268
 
268
- // Locks the camera to top down while this component is mounted
269
- currentControls.polarAngle = 0
270
- currentControls.minPolarAngle = 0
271
- currentControls.maxPolarAngle = 0
269
+ // Locks the camera to top down while this component is mounted
270
+ currentControls.polarAngle = 0
271
+ currentControls.minPolarAngle = 0
272
+ currentControls.maxPolarAngle = 0
272
273
 
273
- return () => {
274
- currentControls.minPolarAngle = minPolarAngle
275
- currentControls.maxPolarAngle = maxPolarAngle
274
+ return () => {
275
+ currentControls.minPolarAngle = minPolarAngle
276
+ currentControls.maxPolarAngle = maxPolarAngle
277
+ }
276
278
  }
277
279
  })
278
280
 
@@ -244,16 +244,18 @@
244
244
 
245
245
  const currentControls = controls.current
246
246
 
247
- const { minPolarAngle, maxPolarAngle } = currentControls
247
+ if ('minPolarAngle' in currentControls) {
248
+ const { minPolarAngle, maxPolarAngle } = currentControls
248
249
 
249
- // Locks the camera to top down while this component is mounted
250
- currentControls.polarAngle = 0
251
- currentControls.minPolarAngle = 0
252
- currentControls.maxPolarAngle = 0
250
+ // Locks the camera to top down while this component is mounted
251
+ currentControls.polarAngle = 0
252
+ currentControls.minPolarAngle = 0
253
+ currentControls.maxPolarAngle = 0
253
254
 
254
- return () => {
255
- currentControls.minPolarAngle = minPolarAngle
256
- currentControls.maxPolarAngle = maxPolarAngle
255
+ return () => {
256
+ currentControls.minPolarAngle = minPolarAngle
257
+ currentControls.maxPolarAngle = maxPolarAngle
258
+ }
257
259
  }
258
260
  })
259
261
 
@@ -45,6 +45,7 @@
45
45
  import { useEnvironment } from '../../hooks/useEnvironment.svelte'
46
46
  import { useLinkedEntities } from '../../hooks/useLinked.svelte'
47
47
  import { usePartConfig } from '../../hooks/usePartConfig.svelte'
48
+ import { usePartID } from '../../hooks/usePartID.svelte'
48
49
  import { useResourceByName } from '../../hooks/useResourceByName.svelte'
49
50
  import {
50
51
  useFocusedEntity,
@@ -52,6 +53,7 @@
52
53
  useSelectedEntity,
53
54
  useSelectedObject3d,
54
55
  } from '../../hooks/useSelection.svelte'
56
+ import { useSettings } from '../../hooks/useSettings.svelte'
55
57
  import { createPose, matrixToPose } from '../../transform'
56
58
 
57
59
  interface Props {
@@ -67,6 +69,8 @@
67
69
  const resourceByName = useResourceByName()
68
70
  const configFrames = useConfigFrames()
69
71
  const partConfig = usePartConfig()
72
+ const partID = usePartID()
73
+ const settings = useSettings()
70
74
  const selectedEntity = useSelectedEntity()
71
75
  const selectedObject3d = useSelectedObject3d()
72
76
  const environment = useEnvironment()
@@ -331,11 +335,13 @@
331
335
  onclick={() => {
332
336
  const padding = 0.4
333
337
 
334
- if (!controls.current) return
338
+ const currentControls = controls.current
335
339
 
336
- const { azimuthAngle, polarAngle } = controls.current
340
+ if (!currentControls || !('fitToBox' in currentControls)) return
337
341
 
338
- controls.current.fitToBox(object3d, true, {
342
+ const { azimuthAngle, polarAngle } = currentControls
343
+
344
+ currentControls.fitToBox(object3d, true, {
339
345
  paddingTop: padding,
340
346
  paddingBottom: padding,
341
347
  paddingLeft: padding,
@@ -343,8 +349,8 @@
343
349
  })
344
350
 
345
351
  // Preserve previous rotation
346
- controls.current?.rotateAzimuthTo(azimuthAngle, true)
347
- controls.current?.rotatePolarTo(polarAngle, true)
352
+ currentControls.rotateAzimuthTo(azimuthAngle, true)
353
+ currentControls.rotatePolarTo(polarAngle, true)
348
354
  }}
349
355
  >
350
356
  <Icon name="image-filter-center-focus" />
@@ -353,6 +359,32 @@
353
359
  </Tooltip>
354
360
  {/if}
355
361
 
362
+ {#if name.current}
363
+ <Tooltip
364
+ let:tooltipID
365
+ location="bottom"
366
+ >
367
+ <button
368
+ class="text-subtle-2"
369
+ aria-describedby={tooltipID}
370
+ aria-label="Open view from this frame"
371
+ onclick={() => {
372
+ const frameName = name.current
373
+ if (!frameName) return
374
+ const list = settings.current.openFramePovWidgets[partID.current] ?? []
375
+ if (list.includes(frameName)) return
376
+ settings.current.openFramePovWidgets = {
377
+ ...settings.current.openFramePovWidgets,
378
+ [partID.current]: [...list, frameName],
379
+ }
380
+ }}
381
+ >
382
+ <Icon name="camera-outline" />
383
+ </button>
384
+ <p slot="description">View from this frame</p>
385
+ </Tooltip>
386
+ {/if}
387
+
356
388
  {#if removable.current}
357
389
  <Tooltip
358
390
  let:tooltipID
@@ -1,5 +1,6 @@
1
1
  <script lang="ts">
2
2
  import type { Snippet } from 'svelte'
3
+ import type { ClassValue } from 'svelte/elements'
3
4
 
4
5
  import { useThrelte } from '@threlte/core'
5
6
  import { Icon } from '@viamrobotics/prime-core'
@@ -14,6 +15,9 @@
14
15
  resizable?: boolean
15
16
  persistRect?: boolean
16
17
  isOpen?: boolean
18
+ bodyClass?: ClassValue
19
+ onPositionChange?: (details: floatingPanel.PositionChangeDetails) => void
20
+ onSizeChange?: (details: floatingPanel.SizeChangeDetails) => void
17
21
  children: Snippet
18
22
  }
19
23
 
@@ -25,6 +29,7 @@
25
29
  resizable = false,
26
30
  persistRect = true,
27
31
  isOpen = $bindable(false),
32
+ bodyClass = 'bg-white',
28
33
  children,
29
34
  ...props
30
35
  }: Props = $props()
@@ -56,7 +61,7 @@
56
61
  >
57
62
  <div
58
63
  {...api.getContentProps()}
59
- class="border-medium border-1 bg-white dark:text-black"
64
+ class="border-medium border-1 dark:text-black"
60
65
  >
61
66
  <div
62
67
  {...api.getDragTriggerProps()}
@@ -64,7 +69,7 @@
64
69
  >
65
70
  <div
66
71
  {...api.getHeaderProps()}
67
- class="border-medium flex items-center justify-between border-b p-2"
72
+ class="border-medium flex items-center justify-between border-b bg-white p-2"
68
73
  >
69
74
  <h3
70
75
  {...api.getTitleProps()}
@@ -97,7 +102,7 @@
97
102
  -->
98
103
  <div
99
104
  {...api.getBodyProps()}
100
- class="relative h-[calc(100%-33px)]"
105
+ class={['relative h-[calc(100%-33px)]', bodyClass]}
101
106
  >
102
107
  {#if isOpen}
103
108
  {@render children()}
@@ -1,4 +1,6 @@
1
1
  import type { Snippet } from 'svelte';
2
+ import type { ClassValue } from 'svelte/elements';
3
+ import * as floatingPanel from '@zag-js/floating-panel';
2
4
  interface Props {
3
5
  title?: string;
4
6
  defaultSize?: {
@@ -13,6 +15,9 @@ interface Props {
13
15
  resizable?: boolean;
14
16
  persistRect?: boolean;
15
17
  isOpen?: boolean;
18
+ bodyClass?: ClassValue;
19
+ onPositionChange?: (details: floatingPanel.PositionChangeDetails) => void;
20
+ onSizeChange?: (details: floatingPanel.SizeChangeDetails) => void;
16
21
  children: Snippet;
17
22
  }
18
23
  declare const FloatingPanel: import("svelte").Component<Props, {}, "isOpen">;
@@ -0,0 +1,40 @@
1
+ <script lang="ts">
2
+ import { PortalTarget } from '@threlte/extras'
3
+
4
+ import Button from '../dashboard/Button.svelte'
5
+ import { useCameraControls } from '../../../hooks/useControls.svelte'
6
+ import { useSettings } from '../../../hooks/useSettings.svelte'
7
+
8
+ const settings = useSettings()
9
+ const cameraControls = useCameraControls()
10
+
11
+ const isOrthographic = $derived(settings.current.cameraMode === 'orthographic')
12
+ </script>
13
+
14
+ <div class="absolute right-2 bottom-26 z-4 flex flex-col items-end gap-2">
15
+ <PortalTarget id="controls" />
16
+
17
+ <fieldset class="flex flex-col">
18
+ <Button
19
+ active
20
+ class="rounded-b-none"
21
+ icon="camera-outline"
22
+ description="Reset camera"
23
+ tooltipLocation="left"
24
+ onclick={() => {
25
+ cameraControls.setInitialPose()
26
+ }}
27
+ />
28
+ <Button
29
+ active
30
+ class="-my-0.5 rounded-t-none"
31
+ icon={isOrthographic ? 'grid-orthographic' : 'grid-perspective'}
32
+ description={isOrthographic ? 'Switch to perspective view' : 'Switch to orthographic view'}
33
+ hotkey="C"
34
+ tooltipLocation="left"
35
+ onclick={() => {
36
+ settings.current.cameraMode = isOrthographic ? 'perspective' : 'orthographic'
37
+ }}
38
+ />
39
+ </fieldset>
40
+ </div>
@@ -0,0 +1,3 @@
1
+ declare const Controls: import("svelte").Component<Record<string, never>, {}, "">;
2
+ type Controls = ReturnType<typeof Controls>;
3
+ export default Controls;
@@ -10,7 +10,7 @@
10
10
  description: string
11
11
  hotkey?: string
12
12
  class?: ClassValue | null | undefined
13
- tooltipLocation?: 'bottom' | 'right'
13
+ tooltipLocation?: 'bottom' | 'right' | 'left' | 'top'
14
14
  onclick?: MouseEventHandler<HTMLButtonElement> | null | undefined
15
15
  }
16
16
 
@@ -33,13 +33,13 @@
33
33
  <label
34
34
  class={[
35
35
  className,
36
- 'relative block border',
36
+ 'relative block rounded-md border',
37
37
  active ? 'border-gray-5 text-gray-8 z-4 bg-white' : 'bg-light border-medium text-disabled',
38
38
  ]}
39
39
  aria-describedby={tooltipID}
40
40
  >
41
41
  <button
42
- class="p-1.5"
42
+ class=" p-1.5"
43
43
  role="radio"
44
44
  aria-label={description}
45
45
  aria-checked={active}
@@ -6,7 +6,7 @@ interface Props extends HTMLButtonAttributes {
6
6
  description: string;
7
7
  hotkey?: string;
8
8
  class?: ClassValue | null | undefined;
9
- tooltipLocation?: 'bottom' | 'right';
9
+ tooltipLocation?: 'bottom' | 'right' | 'left' | 'top';
10
10
  onclick?: MouseEventHandler<HTMLButtonElement> | null | undefined;
11
11
  }
12
12
  declare const Button: import("svelte").Component<Props, {}, "">;
@@ -14,33 +14,11 @@
14
14
  class="absolute top-2 z-4 flex w-full items-center justify-center gap-2"
15
15
  {...rest}
16
16
  >
17
- <!-- camera view -->
18
- <fieldset class="flex">
19
- <Button
20
- icon="grid-orthographic"
21
- active={settings.current.cameraMode === 'orthographic'}
22
- description="Orthographic view"
23
- hotkey="C"
24
- onclick={() => {
25
- settings.current.cameraMode = 'orthographic'
26
- }}
27
- />
28
- <Button
29
- icon="grid-perspective"
30
- active={settings.current.cameraMode === 'perspective'}
31
- description="Perspective view"
32
- hotkey="C"
33
- class="-ml-px"
34
- onclick={() => {
35
- settings.current.cameraMode = 'perspective'
36
- }}
37
- />
38
- </fieldset>
39
-
40
17
  <!-- transform -->
41
18
  <fieldset class="flex">
42
19
  <Button
43
20
  icon="mouse-pointer"
21
+ class="rounded-r-none"
44
22
  active={settings.current.transformMode === 'none'}
45
23
  description="No transform controls"
46
24
  hotkey="0"
@@ -50,30 +28,30 @@
50
28
  />
51
29
  <Button
52
30
  icon="cursor-move"
31
+ class="-ml-px rounded-none"
53
32
  active={settings.current.transformMode === 'translate'}
54
33
  description="Translate"
55
34
  hotkey="1"
56
- class="-ml-px"
57
35
  onclick={() => {
58
36
  settings.current.transformMode = 'translate'
59
37
  }}
60
38
  />
61
39
  <Button
62
40
  icon="sync"
41
+ class="-ml-px rounded-none"
63
42
  active={settings.current.transformMode === 'rotate'}
64
43
  description="Rotate"
65
44
  hotkey="2"
66
- class="-ml-px"
67
45
  onclick={() => {
68
46
  settings.current.transformMode = 'rotate'
69
47
  }}
70
48
  />
71
49
  <Button
72
50
  icon="resize"
51
+ class="-ml-px rounded-l-none"
73
52
  active={settings.current.transformMode === 'scale'}
74
53
  description="Scale"
75
54
  hotkey="3"
76
- class="-ml-px"
77
55
  onclick={() => {
78
56
  settings.current.transformMode = 'scale'
79
57
  }}
@@ -81,18 +59,17 @@
81
59
  </fieldset>
82
60
 
83
61
  <!-- snapping -->
84
- {#if settings.current.transformMode !== 'none'}
85
- <fieldset class="flex">
86
- <Button
87
- icon={settings.current.snapping ? 'magnet' : 'magnet-off'}
88
- active={settings.current.snapping}
89
- description="Snapping"
90
- onclick={() => {
91
- settings.current.snapping = !settings.current.snapping
92
- }}
93
- />
94
- </fieldset>
95
- {/if}
62
+
63
+ <fieldset class="flex">
64
+ <Button
65
+ icon={settings.current.snapping ? 'magnet' : 'magnet-off'}
66
+ active={settings.current.snapping}
67
+ description="Snapping"
68
+ onclick={() => {
69
+ settings.current.snapping = !settings.current.snapping
70
+ }}
71
+ />
72
+ </fieldset>
96
73
 
97
74
  <PortalTarget id="dashboard" />
98
75
 
@@ -0,0 +1,202 @@
1
+ <script lang="ts">
2
+ import { useTask, useThrelte } from '@threlte/core'
3
+ import { Slider, type SliderChangeEvent } from 'svelte-tweakpane-ui'
4
+ import { Matrix4, OrthographicCamera, PerspectiveCamera, WebGLRenderer } from 'three'
5
+
6
+ import { traits, useQuery } from '../../../ecs'
7
+ import { usePartID } from '../../../hooks/usePartID.svelte'
8
+ import { useSettings } from '../../../hooks/useSettings.svelte'
9
+
10
+ import { useOrigin } from '../../xr/useOrigin.svelte'
11
+ import Button from '../dashboard/Button.svelte'
12
+ import FloatingPanel from '../FloatingPanel.svelte'
13
+
14
+ interface Props {
15
+ frameName: string
16
+ }
17
+
18
+ const { frameName }: Props = $props()
19
+
20
+ const { scene, renderer: mainRenderer, renderStage, invalidate } = useThrelte()
21
+ const settings = useSettings()
22
+ const partID = usePartID()
23
+ const origin = useOrigin()
24
+
25
+ // Three.js cameras look down -Z; Viam camera frames conventionally have the
26
+ // optical axis along +Z with image-down along +Y. A 180° rotation around X
27
+ // flips both axes so a Three.js render matches "what a sensor at this frame
28
+ // would see." If empirical testing shows the view is rolled, swap to
29
+ // makeRotationY for an X-flip instead.
30
+ const VIAM_TO_THREE_CAMERA = new Matrix4().makeRotationX(Math.PI)
31
+
32
+ const PERSPECTIVE_FOV_DEG = 60
33
+ // Ortho frustum vertical extent at zoom=1, sized to match what the
34
+ // perspective camera sees at 1 m. zoom > 1 narrows the frustum (zoom in);
35
+ // zoom < 1 widens it (zoom out).
36
+ const BASE_ORTHO_HEIGHT = 2 * Math.tan((PERSPECTIVE_FOV_DEG * Math.PI) / 360)
37
+
38
+ const namedEntities = useQuery(traits.Name)
39
+ const entity = $derived(namedEntities.current.find((e) => e.get(traits.Name) === frameName))
40
+
41
+ const perspectiveCamera = new PerspectiveCamera(PERSPECTIVE_FOV_DEG, 1, 0.01, 1000)
42
+ perspectiveCamera.up.set(0, 0, 1)
43
+
44
+ const orthographicCamera = new OrthographicCamera(-1, 1, 1, -1, 0.01, 1000)
45
+ orthographicCamera.up.set(0, 0, 1)
46
+
47
+ let isOpen = $state(true)
48
+ let cameraMode = $state<'perspective' | 'orthographic'>('perspective')
49
+ let orthoZoom = $state(1)
50
+ let canvasEl = $state.raw<HTMLCanvasElement>()
51
+ let povRenderer = $state.raw<WebGLRenderer | undefined>()
52
+
53
+ const orthoHeight = $derived(BASE_ORTHO_HEIGHT / orthoZoom)
54
+
55
+ const composed = new Matrix4()
56
+ const originMat = new Matrix4()
57
+
58
+ $effect(() => {
59
+ if (!canvasEl) return
60
+ const r = new WebGLRenderer({ canvas: canvasEl, antialias: true, alpha: true })
61
+ // Match the main renderer so colors/tone/transparency are consistent
62
+ // with the main view.
63
+ r.outputColorSpace = mainRenderer.outputColorSpace
64
+ r.toneMapping = mainRenderer.toneMapping
65
+ r.toneMappingExposure = mainRenderer.toneMappingExposure
66
+ r.setPixelRatio(mainRenderer.getPixelRatio())
67
+ r.setSize(canvasEl.clientWidth, canvasEl.clientHeight, false)
68
+ povRenderer = r
69
+ invalidate()
70
+ return () => {
71
+ r.dispose()
72
+ povRenderer = undefined
73
+ }
74
+ })
75
+
76
+ $effect(() => {
77
+ if (!canvasEl) return
78
+ const ro = new ResizeObserver(() => {
79
+ const r = povRenderer
80
+ if (!r || !canvasEl) return
81
+ r.setSize(canvasEl.clientWidth, canvasEl.clientHeight, false)
82
+ invalidate()
83
+ })
84
+ ro.observe(canvasEl)
85
+ return () => ro.disconnect()
86
+ })
87
+
88
+ $effect(() => {
89
+ void cameraMode
90
+ void orthoZoom
91
+ invalidate()
92
+ })
93
+
94
+ $effect(() => {
95
+ if (entity === undefined) {
96
+ isOpen = false
97
+ }
98
+ })
99
+
100
+ $effect(() => {
101
+ if (isOpen) return
102
+ const list = settings.current.openFramePovWidgets[partID.current] ?? []
103
+ const next = list.filter((n) => n !== frameName)
104
+ if (next.length === list.length) return
105
+ settings.current.openFramePovWidgets = {
106
+ ...settings.current.openFramePovWidgets,
107
+ [partID.current]: next,
108
+ }
109
+ })
110
+
111
+ useTask(
112
+ () => {
113
+ const r = povRenderer
114
+ if (!r || !canvasEl || !entity) return
115
+ const worldMat = entity.get(traits.WorldMatrix)
116
+ if (!worldMat) return
117
+
118
+ const width = canvasEl.clientWidth
119
+ const height = canvasEl.clientHeight
120
+ if (width <= 0 || height <= 0) return
121
+
122
+ const povCamera = cameraMode === 'perspective' ? perspectiveCamera : orthographicCamera
123
+
124
+ // Compose origin × worldMatrix × VIAM_TO_THREE_CAMERA. The frame
125
+ // entities' WorldMatrix lives in ECS world space; the rendered scene
126
+ // is wrapped in a T.Group that applies `origin` on top, so the POV
127
+ // camera needs the same origin transform to share coordinate space
128
+ // with the meshes it's rendering.
129
+ originMat
130
+ .makeRotationZ(origin.rotation)
131
+ .setPosition(origin.position[0], origin.position[1], origin.position[2])
132
+ composed.copy(originMat).multiply(worldMat).multiply(VIAM_TO_THREE_CAMERA)
133
+ composed.decompose(povCamera.position, povCamera.quaternion, povCamera.scale)
134
+
135
+ const aspect = width / height
136
+ if (povCamera === perspectiveCamera) {
137
+ perspectiveCamera.aspect = aspect
138
+ } else {
139
+ const halfH = orthoHeight / 2
140
+ const halfW = halfH * aspect
141
+ orthographicCamera.left = -halfW
142
+ orthographicCamera.right = halfW
143
+ orthographicCamera.top = halfH
144
+ orthographicCamera.bottom = -halfH
145
+ }
146
+ povCamera.updateProjectionMatrix()
147
+ povCamera.updateMatrixWorld(true)
148
+
149
+ r.render(scene, povCamera)
150
+ },
151
+ { stage: renderStage, autoInvalidate: false }
152
+ )
153
+
154
+ const handleZoomChange = (event: SliderChangeEvent) => {
155
+ if (event.detail.origin !== 'internal') return
156
+ orthoZoom = event.detail.value as number
157
+ }
158
+ </script>
159
+
160
+ <FloatingPanel
161
+ title={`POV: ${frameName}`}
162
+ bind:isOpen
163
+ defaultSize={{ width: 320, height: 240 }}
164
+ resizable
165
+ onPositionChange={invalidate}
166
+ onSizeChange={invalidate}
167
+ >
168
+ <canvas
169
+ bind:this={canvasEl}
170
+ class="absolute inset-0 block h-full w-full"
171
+ ></canvas>
172
+
173
+ <fieldset class="absolute top-1 right-1 z-1 flex">
174
+ <Button
175
+ icon="grid-orthographic"
176
+ active={cameraMode === 'orthographic'}
177
+ description="Orthographic view"
178
+ onclick={() => (cameraMode = 'orthographic')}
179
+ />
180
+ <Button
181
+ icon="grid-perspective"
182
+ active={cameraMode === 'perspective'}
183
+ description="Perspective view"
184
+ class="-ml-px"
185
+ onclick={() => (cameraMode = 'perspective')}
186
+ />
187
+ </fieldset>
188
+
189
+ {#if cameraMode === 'orthographic'}
190
+ <div class="absolute right-1 bottom-1 left-1 z-1 rounded bg-white/85 p-1">
191
+ <Slider
192
+ label="zoom"
193
+ value={orthoZoom}
194
+ min={0.25}
195
+ max={5}
196
+ step={0.05}
197
+ format={(v) => `${v.toFixed(2)}×`}
198
+ on:change={handleZoomChange}
199
+ />
200
+ </div>
201
+ {/if}
202
+ </FloatingPanel>
@@ -0,0 +1,6 @@
1
+ interface Props {
2
+ frameName: string;
3
+ }
4
+ declare const FramePov: import("svelte").Component<Props, {}, "">;
5
+ type FramePov = ReturnType<typeof FramePov>;
6
+ export default FramePov;
@@ -38,5 +38,21 @@ export declare const destroyEntityTree: (world: World, entity: Entity) => void;
38
38
  * the world. Called by `provideHierarchy` when the orphan/named query sets
39
39
  * change or when a `Name` is renamed; also exposed for tests so they can
40
40
  * drive resolution without mounting a component.
41
+ *
42
+ * The first loop builds a `name → entity` map. The second loop reads each
43
+ * orphan's wanted parent name from that map and attaches `ChildOf` to the
44
+ * entity it found.
45
+ *
46
+ * Two checks prevent an entity from being parented to itself (a `ChildOf`
47
+ * cycle would loop `recomputeWorldMatrix` forever):
48
+ *
49
+ * 1. When two entities have the same `Name`, the map keeps whichever
50
+ * one does NOT have `Orphan`. An entity that still has `Orphan` is
51
+ * one we're still trying to resolve — it could be the same entity
52
+ * the second loop looks up. Letting it fill the slot would make the
53
+ * lookup return the orphan itself.
54
+ * 2. In the second loop, if the lookup returns the orphan itself, skip
55
+ * it. This catches the case where the orphan is the only entity in
56
+ * the world with that `Name`.
41
57
  */
42
58
  export declare const resolveOrphans: (named: QueryResult<[Trait<() => string>]>, orphans: QueryResult<[Trait<() => string>]>) => void;
@@ -1,6 +1,6 @@
1
1
  import {} from 'koota';
2
2
  import { ChildOf } from './relations';
3
- import { Name, Orphan } from './traits';
3
+ import * as traits from './traits';
4
4
  /**
5
5
  * Trait list for `world.spawn(...)`. Always emits `Orphan(name)` for non-root
6
6
  * parents; the hierarchy resolver (`provideHierarchy`) swaps it to
@@ -10,7 +10,7 @@ import { Name, Orphan } from './traits';
10
10
  export const parentTraits = (name) => {
11
11
  if (!name || name === 'world')
12
12
  return [];
13
- return [Orphan(name)];
13
+ return [traits.Orphan(name)];
14
14
  };
15
15
  /**
16
16
  * Set or clear an entity's parent. Strips any existing `ChildOf` or `Orphan`,
@@ -26,15 +26,15 @@ export const parentTraits = (name) => {
26
26
  export const setParent = (entity, name) => {
27
27
  const desired = !name || name === 'world' ? undefined : name;
28
28
  const target = entity.targetFor(ChildOf);
29
- const current = (target?.isAlive() ? target.get(Name) : undefined) ?? entity.get(Orphan);
29
+ const current = (target?.isAlive() ? target.get(traits.Name) : undefined) ?? entity.get(traits.Orphan);
30
30
  if (current === desired)
31
31
  return;
32
32
  if (target)
33
33
  entity.remove(ChildOf(target));
34
- entity.remove(Orphan);
34
+ entity.remove(traits.Orphan);
35
35
  if (desired === undefined)
36
36
  return;
37
- entity.add(Orphan(desired));
37
+ entity.add(traits.Orphan(desired));
38
38
  };
39
39
  /** The parent entity, or `undefined` at the world root or while orphaned. */
40
40
  export const getParentEntity = (entity) => entity.targetFor(ChildOf);
@@ -45,9 +45,10 @@ export const getParentEntity = (entity) => entity.targetFor(ChildOf);
45
45
  */
46
46
  export const getParentName = (entity) => {
47
47
  const parent = entity.targetFor(ChildOf);
48
- if (parent && parent.isAlive())
49
- return parent.get(Name);
50
- const orphanFor = entity.get(Orphan);
48
+ if (parent && parent.isAlive()) {
49
+ return parent.get(traits.Name);
50
+ }
51
+ const orphanFor = entity.get(traits.Orphan);
51
52
  return orphanFor || undefined;
52
53
  };
53
54
  /**
@@ -69,22 +70,43 @@ export const destroyEntityTree = (world, entity) => {
69
70
  * the world. Called by `provideHierarchy` when the orphan/named query sets
70
71
  * change or when a `Name` is renamed; also exposed for tests so they can
71
72
  * drive resolution without mounting a component.
73
+ *
74
+ * The first loop builds a `name → entity` map. The second loop reads each
75
+ * orphan's wanted parent name from that map and attaches `ChildOf` to the
76
+ * entity it found.
77
+ *
78
+ * Two checks prevent an entity from being parented to itself (a `ChildOf`
79
+ * cycle would loop `recomputeWorldMatrix` forever):
80
+ *
81
+ * 1. When two entities have the same `Name`, the map keeps whichever
82
+ * one does NOT have `Orphan`. An entity that still has `Orphan` is
83
+ * one we're still trying to resolve — it could be the same entity
84
+ * the second loop looks up. Letting it fill the slot would make the
85
+ * lookup return the orphan itself.
86
+ * 2. In the second loop, if the lookup returns the orphan itself, skip
87
+ * it. This catches the case where the orphan is the only entity in
88
+ * the world with that `Name`.
72
89
  */
73
90
  export const resolveOrphans = (named, orphans) => {
74
91
  const index = new Map();
75
92
  for (const entity of named) {
76
- const name = entity.get(Name);
77
- if (name)
78
- index.set(name, entity);
93
+ const name = entity.get(traits.Name);
94
+ if (!name)
95
+ continue;
96
+ const existing = index.get(name);
97
+ if (existing && !existing.has(traits.Orphan)) {
98
+ continue;
99
+ }
100
+ index.set(name, entity);
79
101
  }
80
102
  for (const orphan of orphans) {
81
- const wantedName = orphan.get(Orphan);
103
+ const wantedName = orphan.get(traits.Orphan);
82
104
  if (!wantedName)
83
105
  continue;
84
106
  const parent = index.get(wantedName);
85
- if (!parent)
107
+ if (!parent || parent === orphan)
86
108
  continue;
87
- orphan.remove(Orphan);
109
+ orphan.remove(traits.Orphan);
88
110
  orphan.add(ChildOf(parent));
89
111
  }
90
112
  };
@@ -2,7 +2,7 @@ import {} from 'koota';
2
2
  import { Matrix4 } from 'three';
3
3
  import { composeLocalMatrix } from '../transform';
4
4
  import { ChildOf } from './relations';
5
- import { EditedMatrix, LiveMatrix, Matrix, WorldMatrix } from './traits';
5
+ import { EditedMatrix, LiveMatrix, Matrix, Name, WorldMatrix } from './traits';
6
6
  /**
7
7
  * Compute the entity's local-to-parent transform into `out`. Mirrors the
8
8
  * blend used by `Frame.svelte` so `WorldMatrix` agrees with the displayed
@@ -36,14 +36,25 @@ const toLocalMatrix = (entity, out) => {
36
36
  * Synchronously compute and write `WorldMatrix` for every entity in `dirty`
37
37
  * and every descendant via `ChildOf`. Memoizes per-entity world matrices in
38
38
  * `cache` so siblings reuse a parent's result. Caller passes a fresh `cache`
39
- * map per flush.
39
+ * map and `inProgress` set per flush.
40
+ *
41
+ * `inProgress` is the cycle guard: if the parent walk revisits an entity
42
+ * whose computation hasn't finished, we treat that branch as if it had no
43
+ * parent rather than recursing forever. `resolveOrphans` already prevents
44
+ * the only known way to introduce a `ChildOf` cycle; this is here so a
45
+ * future bug downgrades to a soft visual glitch instead of a hard crash.
40
46
  */
41
- const recomputeWorldMatrix = (world, entity, cache) => {
47
+ const recomputeWorldMatrix = (world, entity, cache, inProgress) => {
42
48
  if (!entity.isAlive())
43
49
  return undefined;
44
50
  const cached = cache.get(entity);
45
51
  if (cached)
46
52
  return cached;
53
+ if (inProgress.has(entity)) {
54
+ console.warn('[worldMatrix] ChildOf cycle detected at entity', entity.get(Name) ?? entity);
55
+ return undefined;
56
+ }
57
+ inProgress.add(entity);
47
58
  // Reuse the entity's existing `WorldMatrix` storage when present so a
48
59
  // flush doesn't allocate a throwaway matrix per entity. First-time
49
60
  // entities get a fresh `Matrix4` that's added as the trait below.
@@ -53,10 +64,11 @@ const recomputeWorldMatrix = (world, entity, cache) => {
53
64
  out.identity();
54
65
  const parent = entity.targetFor(ChildOf);
55
66
  if (parent && parent.isAlive()) {
56
- const parentWorld = recomputeWorldMatrix(world, parent, cache);
67
+ const parentWorld = recomputeWorldMatrix(world, parent, cache, inProgress);
57
68
  if (parentWorld)
58
69
  out.premultiply(parentWorld);
59
70
  }
71
+ inProgress.delete(entity);
60
72
  cache.set(entity, out);
61
73
  return out;
62
74
  };
@@ -64,6 +76,7 @@ const flushDirty = (world, dirty) => {
64
76
  if (dirty.size === 0)
65
77
  return;
66
78
  const cache = new Map();
79
+ const inProgress = new Set();
67
80
  const expanded = new Set();
68
81
  const collect = (entity) => {
69
82
  if (expanded.has(entity))
@@ -79,7 +92,7 @@ const flushDirty = (world, dirty) => {
79
92
  for (const entity of expanded) {
80
93
  if (!entity.isAlive())
81
94
  continue;
82
- const worldMat = recomputeWorldMatrix(world, entity, cache);
95
+ const worldMat = recomputeWorldMatrix(world, entity, cache, inProgress);
83
96
  if (!worldMat)
84
97
  continue;
85
98
  if (entity.has(WorldMatrix)) {
@@ -1,12 +1,13 @@
1
1
  import type { CameraControlsRef } from '@threlte/extras';
2
2
  import type { Vector3Tuple } from 'three';
3
+ import type { TrackballControls } from 'three/examples/jsm/Addons.js';
3
4
  export interface CameraPose {
4
5
  position: Vector3Tuple;
5
6
  lookAt: Vector3Tuple;
6
7
  }
7
8
  interface CameraControlsContext {
8
- current: CameraControlsRef | undefined;
9
- set(current: CameraControlsRef): void;
9
+ current: CameraControlsRef | TrackballControls | undefined;
10
+ set(current: CameraControlsRef | TrackballControls): void;
10
11
  setPose(pose: CameraPose, animate?: boolean): void;
11
12
  setInitialPose(): void;
12
13
  setZoom(zoom: number): void;
@@ -6,15 +6,23 @@ export const provideCameraControls = (initialCameraPose) => {
6
6
  const setPose = (pose, animate = false) => {
7
7
  const [x, y, z] = pose.position;
8
8
  const [lookAtX, lookAtY, lookAtZ] = pose.lookAt;
9
- controls?.setPosition(x, y, z, animate);
10
- controls?.setLookAt(x, y, z, lookAtX, lookAtY, lookAtZ, animate);
9
+ if (controls && 'setPosition' in controls) {
10
+ controls.setPosition(x, y, z, animate);
11
+ controls.setLookAt(x, y, z, lookAtX, lookAtY, lookAtZ, animate);
12
+ }
11
13
  };
12
14
  const setZoom = (zoom) => {
13
- controls?.zoomTo(zoom);
15
+ if (controls && 'zoomTo' in controls)
16
+ controls?.zoomTo(zoom);
14
17
  };
15
18
  const setInitialPose = () => {
16
- const pose = initialCameraPose();
17
- setPose(pose ?? { position: [3, 3, 3], lookAt: [0, 0, 0] }, true);
19
+ if (controls && 'setPosition' in controls) {
20
+ const pose = initialCameraPose();
21
+ setPose(pose ?? { position: [3, 3, 3], lookAt: [0, 0, 0] }, true);
22
+ }
23
+ else if (controls) {
24
+ controls.reset();
25
+ }
18
26
  };
19
27
  $effect(() => {
20
28
  const pose = initialCameraPose();
@@ -26,6 +26,7 @@ export interface Settings {
26
26
  enableQueryDevtools: boolean;
27
27
  enableArmPositionsWidget: boolean;
28
28
  openCameraWidgets: Record<string, string[]>;
29
+ openFramePovWidgets: Record<string, string[]>;
29
30
  renderStats: boolean;
30
31
  renderArmModels: 'colliders' | 'colliders+model' | 'model';
31
32
  renderSubEntityHoverDetail: boolean;
@@ -33,6 +33,7 @@ const defaults = () => ({
33
33
  enableQueryDevtools: false,
34
34
  enableArmPositionsWidget: false,
35
35
  openCameraWidgets: {},
36
+ openFramePovWidgets: {},
36
37
  renderStats: false,
37
38
  renderArmModels: 'colliders+model',
38
39
  renderSubEntityHoverDetail: false,
@@ -0,0 +1,73 @@
1
+ <script lang="ts">
2
+ import { T } from '@threlte/core'
3
+ import { EquirectangularReflectionMapping, type Texture, TextureLoader } from 'three'
4
+ import { GroundedSkybox } from 'three/examples/jsm/objects/GroundedSkybox.js'
5
+
6
+ interface Props {
7
+ url: string
8
+ /**
9
+ * World-space position `[x, y, z]`. Defaults to `[0, 0, height]` so the
10
+ * dome's ground sits flush with the world XY plane in this Z-up scene.
11
+ */
12
+ position?: [x: number, y: number, z: number]
13
+ /**
14
+ * Euler rotation `[x, y, z]` in radians. Default aligns the
15
+ * equirectangular image's vertical axis (+Y) with this scene's vertical
16
+ * axis (+Z); the Z component then acts as yaw around world +Z.
17
+ */
18
+ rotation?: [x: number, y: number, z: number]
19
+ /**
20
+ * Camera height above ground when the source photo was taken. Larger
21
+ * values magnify the lower portion of the image.
22
+ */
23
+ height?: number
24
+ /**
25
+ * Skybox dome radius. Must exceed the scene camera's reach.
26
+ */
27
+ radius?: number
28
+ }
29
+
30
+ const {
31
+ url,
32
+ position,
33
+ rotation = [Math.PI / 2, 0, 0],
34
+ height = 15,
35
+ radius = 100,
36
+ }: Props = $props()
37
+
38
+ let texture = $state.raw<Texture | undefined>()
39
+
40
+ $effect.pre(() => {
41
+ let cancelled = false
42
+ let loaded: Texture | undefined
43
+
44
+ new TextureLoader().load(url, (t) => {
45
+ if (cancelled) {
46
+ t.dispose()
47
+ return
48
+ }
49
+ t.mapping = EquirectangularReflectionMapping
50
+ loaded = t
51
+ texture = t
52
+ })
53
+
54
+ return () => {
55
+ cancelled = true
56
+ loaded?.dispose()
57
+ texture = undefined
58
+ }
59
+ })
60
+
61
+ const resolvedPosition = $derived(position ?? ([0, 0, height] as [number, number, number]))
62
+ </script>
63
+
64
+ {#if texture}
65
+ <T
66
+ is={GroundedSkybox}
67
+ args={[texture, height, radius]}
68
+ position={resolvedPosition}
69
+ {rotation}
70
+ raycast={() => null}
71
+ bvh={{ enabled: false }}
72
+ />
73
+ {/if}
@@ -0,0 +1,26 @@
1
+ interface Props {
2
+ url: string;
3
+ /**
4
+ * World-space position `[x, y, z]`. Defaults to `[0, 0, height]` so the
5
+ * dome's ground sits flush with the world XY plane in this Z-up scene.
6
+ */
7
+ position?: [x: number, y: number, z: number];
8
+ /**
9
+ * Euler rotation `[x, y, z]` in radians. Default aligns the
10
+ * equirectangular image's vertical axis (+Y) with this scene's vertical
11
+ * axis (+Z); the Z component then acts as yaw around world +Z.
12
+ */
13
+ rotation?: [x: number, y: number, z: number];
14
+ /**
15
+ * Camera height above ground when the source photo was taken. Larger
16
+ * values magnify the lower portion of the image.
17
+ */
18
+ height?: number;
19
+ /**
20
+ * Skybox dome radius. Must exceed the scene camera's reach.
21
+ */
22
+ radius?: number;
23
+ }
24
+ declare const Skybox: import("svelte").Component<Props, {}, "">;
25
+ type Skybox = ReturnType<typeof Skybox>;
26
+ export default Skybox;
@@ -0,0 +1 @@
1
+ export { default as Skybox } from './Skybox/Skybox.svelte';
@@ -0,0 +1,2 @@
1
+ // Skybox
2
+ export { default as Skybox } from './Skybox/Skybox.svelte';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@viamrobotics/motion-tools",
3
- "version": "1.27.1",
3
+ "version": "1.28.1",
4
4
  "description": "Motion visualization with Viam",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -122,6 +122,10 @@
122
122
  "./lib": {
123
123
  "types": "./dist/lib.d.ts",
124
124
  "svelte": "./dist/lib.js"
125
+ },
126
+ "./plugins": {
127
+ "types": "./dist/plugins/index.d.ts",
128
+ "svelte": "./dist/plugins/index.js"
125
129
  }
126
130
  },
127
131
  "repository": {
File without changes
File without changes