@viamrobotics/motion-tools 1.31.0 → 1.33.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 (130) hide show
  1. package/dist/components/App.svelte +64 -53
  2. package/dist/components/App.svelte.d.ts +14 -7
  3. package/dist/components/Entities/Arrows/Arrows.svelte +4 -7
  4. package/dist/components/Entities/hooks/useEntityEvents.svelte.d.ts +0 -1
  5. package/dist/components/Entities/hooks/useEntityEvents.svelte.js +30 -16
  6. package/dist/components/InputBindings.svelte +0 -43
  7. package/dist/components/KeyboardBindings.svelte +38 -0
  8. package/dist/components/KeyboardBindings.svelte.d.ts +18 -0
  9. package/dist/components/PointerMissBox.svelte +6 -3
  10. package/dist/components/Scene.svelte +43 -61
  11. package/dist/components/SceneProviders.svelte +2 -7
  12. package/dist/components/SceneProviders.svelte.d.ts +1 -3
  13. package/dist/components/Selected.svelte +20 -27
  14. package/dist/components/SelectedTransformControls.svelte +8 -7
  15. package/dist/components/StaticGeometries.svelte +3 -5
  16. package/dist/components/hover/HoveredEntities.svelte +15 -14
  17. package/dist/components/hover/HoveredEntities.svelte.d.ts +17 -2
  18. package/dist/components/hover/HoveredEntity.svelte +8 -5
  19. package/dist/components/hover/HoveredEntity.svelte.d.ts +5 -1
  20. package/dist/components/hover/LinkedHoveredEntity.svelte +7 -11
  21. package/dist/components/hover/LinkedHoveredEntity.svelte.d.ts +1 -0
  22. package/dist/components/overlay/Details.svelte +22 -37
  23. package/dist/components/overlay/Details.svelte.d.ts +3 -1
  24. package/dist/components/overlay/controls/Controls.svelte +0 -2
  25. package/dist/components/overlay/dashboard/Button.svelte +5 -3
  26. package/dist/components/overlay/dashboard/Button.svelte.d.ts +1 -1
  27. package/dist/components/overlay/left-pane/Tree.svelte +13 -10
  28. package/dist/components/overlay/left-pane/TreeContainer.svelte +9 -4
  29. package/dist/components/overlay/left-pane/TreeNode.svelte +6 -4
  30. package/dist/components/overlay/settings/ConnectionSettings.svelte +42 -0
  31. package/dist/components/overlay/settings/ConnectionSettings.svelte.d.ts +18 -0
  32. package/dist/components/overlay/settings/DebugSettings.svelte +13 -0
  33. package/dist/components/{xr/frame-configure/Controllers.svelte.d.ts → overlay/settings/DebugSettings.svelte.d.ts} +3 -3
  34. package/dist/components/overlay/settings/PointcloudSettings.svelte +61 -0
  35. package/dist/components/overlay/settings/PointcloudSettings.svelte.d.ts +3 -0
  36. package/dist/components/overlay/settings/SceneSettings.svelte +110 -0
  37. package/dist/components/overlay/settings/SceneSettings.svelte.d.ts +18 -0
  38. package/dist/components/overlay/settings/Settings.svelte +27 -312
  39. package/dist/components/overlay/settings/Settings.svelte.d.ts +8 -1
  40. package/dist/components/overlay/settings/Tabs.svelte +5 -3
  41. package/dist/components/overlay/settings/Tabs.svelte.d.ts +3 -3
  42. package/dist/components/overlay/settings/VisionSettings.svelte +31 -0
  43. package/dist/components/overlay/settings/VisionSettings.svelte.d.ts +3 -0
  44. package/dist/components/overlay/settings/WeblabSettings.svelte +27 -0
  45. package/dist/components/overlay/settings/WeblabSettings.svelte.d.ts +18 -0
  46. package/dist/components/overlay/settings/WidgetSettings.svelte +49 -0
  47. package/dist/components/overlay/settings/WidgetSettings.svelte.d.ts +3 -0
  48. package/dist/components/overlay/widgets/FramePov.svelte +1 -12
  49. package/dist/draw.d.ts +1 -0
  50. package/dist/draw.js +1 -1
  51. package/dist/ecs/index.d.ts +1 -0
  52. package/dist/ecs/index.js +1 -0
  53. package/dist/ecs/traits.d.ts +22 -5
  54. package/dist/ecs/traits.js +33 -4
  55. package/dist/ecs/useTag.svelte.d.ts +5 -0
  56. package/dist/ecs/useTag.svelte.js +43 -0
  57. package/dist/hooks/useEnvironment.svelte.d.ts +1 -1
  58. package/dist/hooks/useLinked.svelte.js +7 -8
  59. package/dist/hooks/useMouseRaycaster.svelte.d.ts +4 -3
  60. package/dist/hooks/useMouseRaycaster.svelte.js +1 -0
  61. package/dist/hooks/useSettings.svelte.d.ts +1 -1
  62. package/dist/plugins/Focus/Focus.svelte +45 -0
  63. package/dist/plugins/Focus/Focus.svelte.d.ts +3 -0
  64. package/dist/plugins/Focus/FocusBox.svelte +75 -0
  65. package/dist/plugins/Focus/FocusBox.svelte.d.ts +3 -0
  66. package/dist/plugins/Focus/provideFocus.svelte.d.ts +1 -0
  67. package/dist/plugins/Focus/provideFocus.svelte.js +61 -0
  68. package/dist/{components → plugins}/MeasureTool/MeasureTool.svelte +6 -8
  69. package/dist/plugins/Selection/SelectionTool.svelte +10 -3
  70. package/dist/{components/xr → plugins/XR}/ArmTeleop.svelte +3 -5
  71. package/dist/plugins/XR/DebugPanel.svelte +29 -0
  72. package/dist/plugins/XR/DebugPanel.svelte.d.ts +3 -0
  73. package/dist/plugins/XR/OriginMarker.svelte +341 -0
  74. package/dist/plugins/XR/PendingEditsPanel.svelte +60 -0
  75. package/dist/plugins/XR/PendingEditsPanel.svelte.d.ts +18 -0
  76. package/dist/plugins/XR/WristDisplay.svelte +60 -0
  77. package/dist/plugins/XR/WristDisplay.svelte.d.ts +19 -0
  78. package/dist/{components/xr → plugins/XR}/XR.svelte +69 -23
  79. package/dist/plugins/XR/XRPlugins.svelte +9 -0
  80. package/dist/plugins/XR/XRPlugins.svelte.d.ts +26 -0
  81. package/dist/plugins/XR/XRSettings.svelte +240 -0
  82. package/dist/plugins/XR/XRSettings.svelte.d.ts +3 -0
  83. package/dist/{components/xr → plugins/XR}/XRToast.svelte +6 -9
  84. package/dist/plugins/XR/debug.svelte.d.ts +7 -0
  85. package/dist/plugins/XR/debug.svelte.js +13 -0
  86. package/dist/plugins/XR/frame-configure/Controllers.svelte +413 -0
  87. package/dist/plugins/XR/teleop/Controllers.svelte.d.ts +3 -0
  88. package/dist/{components/xr → plugins/XR}/useAnchors.svelte.d.ts +4 -0
  89. package/dist/{components/xr → plugins/XR}/useAnchors.svelte.js +22 -0
  90. package/dist/plugins/XR/useOrigin.svelte.d.ts +24 -0
  91. package/dist/plugins/XR/useOrigin.svelte.js +50 -0
  92. package/dist/plugins/index.d.ts +4 -0
  93. package/dist/plugins/index.js +4 -0
  94. package/dist/three/OBBHelper.js +1 -0
  95. package/dist/three/arrow.d.ts +2 -0
  96. package/dist/three/arrow.js +3 -1
  97. package/package.json +16 -4
  98. package/dist/components/Focus.svelte +0 -46
  99. package/dist/components/Focus.svelte.d.ts +0 -7
  100. package/dist/components/xr/OriginMarker.svelte +0 -151
  101. package/dist/components/xr/XRControllerSettings.svelte +0 -242
  102. package/dist/components/xr/XRControllerSettings.svelte.d.ts +0 -3
  103. package/dist/components/xr/frame-configure/Controllers.svelte +0 -6
  104. package/dist/components/xr/useOrigin.svelte.d.ts +0 -9
  105. package/dist/components/xr/useOrigin.svelte.js +0 -27
  106. package/dist/hooks/useSelection.svelte.d.ts +0 -33
  107. package/dist/hooks/useSelection.svelte.js +0 -94
  108. /package/dist/{components → plugins}/MeasureTool/MeasurePoint.svelte +0 -0
  109. /package/dist/{components → plugins}/MeasureTool/MeasurePoint.svelte.d.ts +0 -0
  110. /package/dist/{components → plugins}/MeasureTool/MeasureTool.svelte.d.ts +0 -0
  111. /package/dist/{components/xr → plugins/XR}/ArmTeleop.svelte.d.ts +0 -0
  112. /package/dist/{components/xr → plugins/XR}/BentPlaneGeometry.svelte +0 -0
  113. /package/dist/{components/xr → plugins/XR}/BentPlaneGeometry.svelte.d.ts +0 -0
  114. /package/dist/{components/xr → plugins/XR}/CameraFeed.svelte +0 -0
  115. /package/dist/{components/xr → plugins/XR}/CameraFeed.svelte.d.ts +0 -0
  116. /package/dist/{components/xr → plugins/XR}/JointLimitsWidget.svelte +0 -0
  117. /package/dist/{components/xr → plugins/XR}/JointLimitsWidget.svelte.d.ts +0 -0
  118. /package/dist/{components/xr → plugins/XR}/OriginMarker.svelte.d.ts +0 -0
  119. /package/dist/{components/xr → plugins/XR}/PointDistance.svelte +0 -0
  120. /package/dist/{components/xr → plugins/XR}/PointDistance.svelte.d.ts +0 -0
  121. /package/dist/{components/xr → plugins/XR}/XR.svelte.d.ts +0 -0
  122. /package/dist/{components/xr → plugins/XR}/XRConfigPanel.svelte +0 -0
  123. /package/dist/{components/xr → plugins/XR}/XRConfigPanel.svelte.d.ts +0 -0
  124. /package/dist/{components/xr → plugins/XR}/XRToast.svelte.d.ts +0 -0
  125. /package/dist/{components/xr/teleop → plugins/XR/frame-configure}/Controllers.svelte.d.ts +0 -0
  126. /package/dist/{components/xr → plugins/XR}/math.d.ts +0 -0
  127. /package/dist/{components/xr → plugins/XR}/math.js +0 -0
  128. /package/dist/{components/xr → plugins/XR}/teleop/Controllers.svelte +0 -0
  129. /package/dist/{components/xr → plugins/XR}/toasts.svelte.d.ts +0 -0
  130. /package/dist/{components/xr → plugins/XR}/toasts.svelte.js +0 -0
@@ -0,0 +1,61 @@
1
+ import { trait } from 'koota';
2
+ import { untrack } from 'svelte';
3
+ import { relations, traits, useQuery, useWorld } from '../../ecs';
4
+ const HiddenByFocus = trait();
5
+ export const provideFocus = (focusing) => {
6
+ const world = useWorld();
7
+ const selected = useQuery(traits.Selected);
8
+ $effect(() => {
9
+ if (!focusing()) {
10
+ for (const entity of world.query(HiddenByFocus)) {
11
+ entity.remove(HiddenByFocus, traits.Invisible);
12
+ }
13
+ return;
14
+ }
15
+ /**
16
+ * Snapshot the selection at the moment focus is entered. Reading it
17
+ * untracked makes `focusing()` this effect's only dependency, so the
18
+ * focused view stays frozen: selecting or deselecting entities while
19
+ * focused must not change what's hidden. Everything is restored when
20
+ * focus exits.
21
+ */
22
+ const selectedEntities = untrack(() => selected.current);
23
+ /**
24
+ * Entities only render when their `InheritedInvisible` is unset, and that
25
+ * trait is computed by walking `ChildOf` ancestors (see
26
+ * `useInheritedInvisible`). So hiding a selected entity's parent — or its
27
+ * renderable sub-entities, which are `ChildOf` children that never carry
28
+ * `Selected` — makes the selection itself disappear. Keep the whole
29
+ * connected subtree of each selection visible: its ancestors (so the
30
+ * cascade can't reach it) and its descendants (so its geometry shows).
31
+ */
32
+ const keep = new Set();
33
+ const keepSubtree = (entity) => {
34
+ if (keep.has(entity))
35
+ return;
36
+ keep.add(entity);
37
+ for (const child of world.query(relations.ChildOf(entity))) {
38
+ keepSubtree(child);
39
+ }
40
+ };
41
+ for (const entity of selectedEntities) {
42
+ let ancestor = entity.targetFor(relations.ChildOf);
43
+ while (ancestor?.isAlive()) {
44
+ keep.add(ancestor);
45
+ ancestor = ancestor.targetFor(relations.ChildOf);
46
+ }
47
+ keepSubtree(entity);
48
+ }
49
+ /**
50
+ * Hide the rest. Skip already-invisible entities so we don't take
51
+ * ownership of — and later wrongly reveal — user-hidden entities.
52
+ */
53
+ for (const entity of world.query(traits.Name)) {
54
+ if (keep.has(entity))
55
+ continue;
56
+ if (!entity.has(traits.Invisible)) {
57
+ entity.add(HiddenByFocus, traits.Invisible);
58
+ }
59
+ }
60
+ });
61
+ };
@@ -4,16 +4,14 @@
4
4
  import { untrack } from 'svelte'
5
5
  import { type Intersection, Vector3 } from 'three'
6
6
 
7
- import Button from '../overlay/dashboard/Button.svelte'
7
+ import Button from '../../components/overlay/dashboard/Button.svelte'
8
+ import Popover from '../../components/overlay/Popover.svelte'
9
+ import ToggleGroup from '../../components/overlay/ToggleGroup.svelte'
8
10
  import { useMouseRaycaster } from '../../hooks/useMouseRaycaster.svelte'
9
- import { useFocusedEntity } from '../../hooks/useSelection.svelte'
10
11
  import { useSettings } from '../../hooks/useSettings.svelte'
11
12
 
12
- import Popover from '../overlay/Popover.svelte'
13
- import ToggleGroup from '../overlay/ToggleGroup.svelte'
14
13
  import MeasurePoint from './MeasurePoint.svelte'
15
14
 
16
- const focusedEntity = useFocusedEntity()
17
15
  const settings = useSettings()
18
16
 
19
17
  const htmlPosition = new Vector3()
@@ -73,9 +71,9 @@
73
71
  }
74
72
 
75
73
  $effect(() => {
76
- // eslint-disable-next-line @typescript-eslint/no-unused-expressions
77
- ;(focusedEntity.current, enabled)
78
- untrack(() => clear())
74
+ if (!enabled) {
75
+ untrack(() => clear())
76
+ }
79
77
  })
80
78
  </script>
81
79
 
@@ -9,7 +9,7 @@
9
9
  import DashboardButton from '../../components/overlay/dashboard/Button.svelte'
10
10
  import Popover from '../../components/overlay/Popover.svelte'
11
11
  import ToggleGroup from '../../components/overlay/ToggleGroup.svelte'
12
- import { useSelectedEntity } from '../../hooks/useSelection.svelte'
12
+ import { traits, useWorld } from '../../ecs'
13
13
  import { useSettings } from '../../hooks/useSettings.svelte'
14
14
 
15
15
  import Ellipse from './Ellipse.svelte'
@@ -29,11 +29,12 @@
29
29
  let { enabled = false, autoSelectNewEntities = false, children }: Props = $props()
30
30
 
31
31
  const { dom } = useThrelte()
32
+ const world = useWorld()
32
33
  const settings = useSettings()
33
34
  const isSelectionMode = $derived(settings.current.interactionMode === 'select')
34
35
 
35
36
  const selectionPlugin = provideSelectionPlugin()
36
- const selectedEntity = useSelectedEntity()
37
+
37
38
  let selectionType = $state<SelectionType>('lasso')
38
39
 
39
40
  $effect(() => {
@@ -58,7 +59,13 @@
58
59
 
59
60
  const newest = newEntities.at(-1)
60
61
  if (newest === undefined) return
61
- selectedEntity.set(newest)
62
+
63
+ const selected = world.query(traits.Selected)
64
+ for (const entity of selected) {
65
+ entity.remove(traits.Selected)
66
+ }
67
+
68
+ newest.add(traits.Selected)
62
69
  })
63
70
 
64
71
  const rect = new ElementRect(() => dom)
@@ -6,14 +6,12 @@
6
6
  import { createResourceClient } from '@viamrobotics/svelte-sdk'
7
7
  import { Quaternion, Vector3 } from 'three'
8
8
 
9
- import {
10
- calculatePositionTarget,
11
- getFrameTransformationQuaternion,
12
- } from './math'
13
- import { xrToast } from './toasts.svelte'
14
9
  import { usePartID } from '../../hooks/usePartID.svelte'
15
10
  import { OrientationVector } from '../../three/OrientationVector'
16
11
 
12
+ import { calculatePositionTarget, getFrameTransformationQuaternion } from './math'
13
+ import { xrToast } from './toasts.svelte'
14
+
17
15
  interface Props {
18
16
  armName: string
19
17
  gripperName?: string
@@ -0,0 +1,29 @@
1
+ <script lang="ts">
2
+ import { Text } from 'threlte-uikit'
3
+ import { Panel } from 'threlte-uikit/horizon'
4
+
5
+ import { xrDebug } from './debug.svelte'
6
+ import WristDisplay from './WristDisplay.svelte'
7
+
8
+ const messages = $derived(xrDebug.messages)
9
+ </script>
10
+
11
+ <WristDisplay position={[0, 0.005, 0.2]}>
12
+ <Panel
13
+ flexDirection="column"
14
+ padding={12}
15
+ gap={4}
16
+ backgroundColor="#0a0a0a"
17
+ borderRadius={8}
18
+ minWidth={400}
19
+ minHeight={40}
20
+ >
21
+ {#each messages as message, i (i)}
22
+ <Text
23
+ text={message}
24
+ fontSize={14}
25
+ color="#ffffff"
26
+ />
27
+ {/each}
28
+ </Panel>
29
+ </WristDisplay>
@@ -0,0 +1,3 @@
1
+ declare const DebugPanel: import("svelte").Component<Record<string, never>, {}, "">;
2
+ type DebugPanel = ReturnType<typeof DebugPanel>;
3
+ export default DebugPanel;
@@ -0,0 +1,341 @@
1
+ <script lang="ts">
2
+ import { useTask, useThrelte } from '@threlte/core'
3
+ import { useGamepad } from '@threlte/extras'
4
+ import { Hand, useController, useHand, useHeadset, useXR } from '@threlte/xr'
5
+ import { useDebounce } from 'runed'
6
+ import { Euler, Quaternion, Vector3 } from 'three'
7
+
8
+ import { usePartID } from '../../hooks/usePartID.svelte'
9
+
10
+ import { useAnchors } from './useAnchors.svelte'
11
+ import { useOrigin } from './useOrigin.svelte'
12
+
13
+ const origin = useOrigin()
14
+ const anchors = useAnchors()
15
+ const partID = usePartID()
16
+ const headset = useHeadset()
17
+
18
+ const storageKey = $derived(`xr-origin-anchor:${partID.current}`)
19
+
20
+ const DEFAULT_ORIGIN: [number, number, number] = [-1, -1, 0]
21
+ const COMMIT_DEBOUNCE_MS = 500
22
+
23
+ const leftPad = useGamepad({ xr: true, hand: 'left' })
24
+ const rightPad = useGamepad({ xr: true, hand: 'right' })
25
+ const leftController = useController('left')
26
+ const rightController = useController('right')
27
+
28
+ const THUMBSTICK_SPEED = 0.05
29
+
30
+ // Head-relative translation basis. Recomputed per thumbstick tick so
31
+ // stick-forward always moves content away from the viewer, regardless of
32
+ // how the scene was rotated or which way the user is physically facing.
33
+ const headForward = new Vector3()
34
+ const headRight = new Vector3()
35
+
36
+ // The anchor that currently represents the persisted origin. Kept so we can
37
+ // delete it when a new calibration is committed.
38
+ let persistedAnchor: XRAnchor | undefined
39
+
40
+ // The restored anchor we're waiting to localize on session start. Once the
41
+ // device reports it as tracked we snap origin to its pose and clear this.
42
+ let pendingRestore = $state.raw<XRAnchor | undefined>(undefined)
43
+
44
+ const restoreQuat = new Quaternion()
45
+ const restoreEuler = new Euler(0, 0, 0, 'ZYX')
46
+
47
+ const commitVec = new Vector3()
48
+ const commitQuat = new Quaternion()
49
+
50
+ const commit = useDebounce(async () => {
51
+ // origin.position/rotation define the composed XR reference space's
52
+ // offset from zUp, so an anchor at identity in the current (composed)
53
+ // space IS the anchor at origin's pose in zUp. commitVec/commitQuat
54
+ // are left at their default-constructed zero/identity values.
55
+ const anchor = await anchors.createAnchor(commitVec, commitQuat)
56
+ if (!anchor) return
57
+
58
+ const uuid = await anchors.persist(anchor)
59
+ if (!uuid) {
60
+ anchor.delete()
61
+ return
62
+ }
63
+
64
+ const prev = localStorage.getItem(storageKey)
65
+ if (prev && prev !== uuid) {
66
+ anchors.remove(prev).catch(() => {})
67
+ }
68
+
69
+ if (persistedAnchor && persistedAnchor !== anchor) {
70
+ persistedAnchor.delete()
71
+ }
72
+
73
+ persistedAnchor = anchor
74
+ localStorage.setItem(storageKey, uuid)
75
+ }, COMMIT_DEBOUNCE_MS)
76
+
77
+ origin.registerCommit(() => commit())
78
+
79
+ leftPad.thumbstick.on('change', ({ value }) => {
80
+ // While the grip is held, the left controller drives fine rotation;
81
+ // ignore the thumbstick so the two inputs don't fight each other.
82
+ if (leftPad.squeeze.pressed || typeof value === 'number') return
83
+
84
+ const { x: vx, y: vy } = value
85
+ const [x, y, z] = origin.position
86
+
87
+ origin.set([x, y, z + vy * THUMBSTICK_SPEED], origin.rotation + vx * THUMBSTICK_SPEED)
88
+ commit()
89
+ })
90
+
91
+ rightPad.thumbstick.on('change', ({ value }) => {
92
+ if (rightPad.squeeze.pressed || typeof value === 'number') return
93
+
94
+ const { x: vx, y: vy } = value
95
+ const [x, y, z] = origin.position
96
+
97
+ headset.getWorldDirection(headForward)
98
+
99
+ // Flatten onto the XY (ground) plane so pitch doesn't bleed into horizontal motion.
100
+ headForward.z = 0
101
+ if (headForward.lengthSq() < 1e-6) {
102
+ // Viewer gaze was purely vertical (or pose not yet reported) — fall back to scene +Y.
103
+ headForward.set(0, 1, 0)
104
+ } else {
105
+ headForward.normalize()
106
+ }
107
+ // headForward is in the composed XR reference space; rotate into zUp
108
+ // so the stick direction maps to the user's physical gaze regardless
109
+ // of the current origin rotation.
110
+ origin.toZUpDir(headForward)
111
+ headRight.set(headForward.y, -headForward.x, 0)
112
+
113
+ const deltaX = headRight.x * vx * THUMBSTICK_SPEED + headForward.x * vy * THUMBSTICK_SPEED
114
+ const deltaY = headRight.y * vx * THUMBSTICK_SPEED + headForward.y * vy * THUMBSTICK_SPEED
115
+
116
+ origin.set([x + deltaX, y + deltaY, z], origin.rotation)
117
+ commit()
118
+ })
119
+
120
+ // Fine calibration: hold the grip, then move/rotate the controller to nudge
121
+ // the origin 1:1 with the controller delta. Extract world-Z yaw from the
122
+ // controller quaternion for rotation so pitch/roll don't leak in.
123
+ const quatYaw = (q: Quaternion) =>
124
+ Math.atan2(2 * (q.w * q.z + q.x * q.y), 1 - 2 * (q.y * q.y + q.z * q.z))
125
+
126
+ const fineTranslateStart = new Vector3()
127
+ const fineTranslateOriginStart = new Vector3()
128
+ const fineTranslateCurrent = new Vector3()
129
+ let fineTranslating = $state(false)
130
+
131
+ rightPad.squeeze.on('change', () => {
132
+ const ray = rightController.current?.targetRay
133
+ if (rightPad.squeeze.pressed && ray) {
134
+ // Save start position in zUp so the delta stays stable as the
135
+ // composed reference space recomposes each tick on origin changes.
136
+ origin.toZUpPos(fineTranslateStart, ray.position)
137
+ const [ox, oy, oz] = origin.position
138
+ fineTranslateOriginStart.set(ox, oy, oz)
139
+ fineTranslating = true
140
+ } else {
141
+ fineTranslating = false
142
+ }
143
+ })
144
+
145
+ useTask(
146
+ () => {
147
+ const ray = rightController.current?.targetRay
148
+ if (!ray) return
149
+ origin.toZUpPos(fineTranslateCurrent, ray.position)
150
+ origin.set(
151
+ [
152
+ fineTranslateOriginStart.x + fineTranslateCurrent.x - fineTranslateStart.x,
153
+ fineTranslateOriginStart.y + fineTranslateCurrent.y - fineTranslateStart.y,
154
+ fineTranslateOriginStart.z + fineTranslateCurrent.z - fineTranslateStart.z,
155
+ ],
156
+ origin.rotation
157
+ )
158
+ commit()
159
+ },
160
+ { running: () => fineTranslating }
161
+ )
162
+
163
+ let fineRotateStartYaw = 0
164
+ let fineRotateOriginStart = 0
165
+ let fineRotating = $state(false)
166
+
167
+ leftPad.squeeze.on('change', () => {
168
+ const ray = leftController.current?.targetRay
169
+ if (leftPad.squeeze.pressed && ray) {
170
+ // Controller yaw in composed = yaw_zUp − origin.rotation; convert
171
+ // to zUp so the delta stays stable while origin.rotation updates.
172
+ fineRotateStartYaw = quatYaw(ray.quaternion) + origin.rotation
173
+ fineRotateOriginStart = origin.rotation
174
+ fineRotating = true
175
+ } else {
176
+ fineRotating = false
177
+ }
178
+ })
179
+
180
+ useTask(
181
+ () => {
182
+ const ray = leftController.current?.targetRay
183
+ if (!ray) return
184
+ const yawZup = quatYaw(ray.quaternion) + origin.rotation
185
+ origin.set(origin.position, fineRotateOriginStart + yawZup - fineRotateStartYaw)
186
+ commit()
187
+ },
188
+ { running: () => fineRotating }
189
+ )
190
+
191
+ let startLeftPinchTranslation = new Vector3()
192
+ let leftPinchTranslation = new Vector3()
193
+ let startRightPinchTranslation = new Vector3()
194
+ let rightPinchCurrent = new Vector3()
195
+ let startRightPinchRotation = 0
196
+
197
+ const leftHand = useHand('left')
198
+ const rightHand = useHand('right')
199
+
200
+ let translating = $state(false)
201
+ let rotating = $state(false)
202
+
203
+ const { renderer } = useThrelte()
204
+ const { isPresenting } = useXR()
205
+
206
+ // Session start: try to restore a persisted anchor for this part. If none
207
+ // exists or restore fails, fall back to a default offset so the origin is
208
+ // visible in front of the user.
209
+ $effect(() => {
210
+ if (!$isPresenting) {
211
+ pendingRestore = undefined
212
+ persistedAnchor = undefined
213
+ return
214
+ }
215
+
216
+ const uuid = localStorage.getItem(storageKey)
217
+ if (!uuid) {
218
+ origin.set(DEFAULT_ORIGIN, 0)
219
+ return
220
+ }
221
+
222
+ let cancelled = false
223
+ anchors
224
+ .restore(uuid)
225
+ .then((anchor) => {
226
+ if (cancelled) return
227
+ if (anchor) {
228
+ persistedAnchor = anchor
229
+ pendingRestore = anchor
230
+ } else {
231
+ localStorage.removeItem(storageKey)
232
+ origin.set(DEFAULT_ORIGIN, 0)
233
+ }
234
+ })
235
+ .catch(() => {
236
+ if (cancelled) return
237
+ localStorage.removeItem(storageKey)
238
+ origin.set(DEFAULT_ORIGIN, 0)
239
+ })
240
+
241
+ return () => {
242
+ cancelled = true
243
+ }
244
+ })
245
+
246
+ // Once the restored anchor has localized, snap origin to its pose. This
247
+ // runs only while a restore is pending so it won't fight user input.
248
+ useTask(
249
+ () => {
250
+ const anchor = pendingRestore
251
+ if (!anchor) return
252
+
253
+ const pose = anchors.getAnchorPose(anchor)
254
+ if (!pose) return
255
+
256
+ const { position: p, orientation: o } = pose.transform
257
+ restoreQuat.set(o.x, o.y, o.z, o.w)
258
+ restoreEuler.setFromQuaternion(restoreQuat, 'ZYX')
259
+ origin.set([p.x, p.y, p.z], restoreEuler.z)
260
+ pendingRestore = undefined
261
+ },
262
+ {
263
+ running: () => pendingRestore !== undefined,
264
+ }
265
+ )
266
+
267
+ $effect(() => {
268
+ if (!$isPresenting) {
269
+ return
270
+ }
271
+ renderer.xr.getHand(0).addEventListener('pinchstart', () => {
272
+ const p = leftHand.current?.targetRay.position
273
+ if (p) {
274
+ translating = true
275
+ // Pinch start position in zUp; delta is cumulative from here.
276
+ origin.toZUpPos(startLeftPinchTranslation, p)
277
+ }
278
+ })
279
+ })
280
+
281
+ useTask(
282
+ () => {
283
+ const p = leftHand.current?.targetRay.position
284
+ if (p && translating) {
285
+ origin.toZUpPos(leftPinchTranslation, p)
286
+ origin.set(
287
+ [
288
+ leftPinchTranslation.x - startLeftPinchTranslation.x,
289
+ leftPinchTranslation.y - startLeftPinchTranslation.y,
290
+ leftPinchTranslation.z - startLeftPinchTranslation.z,
291
+ ],
292
+ origin.rotation
293
+ )
294
+ commit()
295
+ }
296
+ },
297
+ {
298
+ running: () => translating,
299
+ }
300
+ )
301
+
302
+ useTask(
303
+ () => {
304
+ const p = rightHand.current?.targetRay.position
305
+ if (p && rotating) {
306
+ origin.toZUpPos(rightPinchCurrent, p)
307
+ const deltaX = rightPinchCurrent.x - startRightPinchTranslation.x
308
+ origin.set(origin.position, startRightPinchRotation + deltaX)
309
+ commit()
310
+ }
311
+ },
312
+ {
313
+ running: () => rotating,
314
+ }
315
+ )
316
+ </script>
317
+
318
+ <Hand
319
+ left
320
+ onpinchstart={() => {
321
+ const p = leftHand.current?.targetRay.position
322
+ if (p) {
323
+ translating = true
324
+ origin.toZUpPos(startLeftPinchTranslation, p)
325
+ }
326
+ }}
327
+ onpinchend={() => (translating = false)}
328
+ />
329
+
330
+ <Hand
331
+ right
332
+ onpinchstart={() => {
333
+ const p = rightHand.current?.targetRay.position
334
+ if (p) {
335
+ rotating = true
336
+ origin.toZUpPos(startRightPinchTranslation, p)
337
+ startRightPinchRotation = origin.rotation
338
+ }
339
+ }}
340
+ onpinchend={() => (rotating = false)}
341
+ />
@@ -0,0 +1,60 @@
1
+ <script lang="ts">
2
+ import { Text } from 'threlte-uikit'
3
+ import { Button, ButtonLabel, Panel } from 'threlte-uikit/horizon'
4
+
5
+ import { usePartConfig } from '../../hooks/usePartConfig.svelte'
6
+
7
+ import WristDisplay from './WristDisplay.svelte'
8
+
9
+ const partConfig = usePartConfig()
10
+ </script>
11
+
12
+ {#if partConfig.isDirty}
13
+ <WristDisplay position={[0, 0.005, 0.1]}>
14
+ <Panel
15
+ flexDirection="column"
16
+ padding={16}
17
+ gap={12}
18
+ backgroundColor="#111"
19
+ borderRadius={16}
20
+ minWidth={420}
21
+ >
22
+ <Text
23
+ text="Pending frame edits"
24
+ fontSize={18}
25
+ color="#ffffff"
26
+ />
27
+ <Panel
28
+ flexDirection="row"
29
+ gap={8}
30
+ >
31
+ <Button
32
+ variant="tertiary"
33
+ size="sm"
34
+ onclick={() => partConfig.discardChanges()}
35
+ >
36
+ <ButtonLabel>
37
+ <Text
38
+ text="Discard"
39
+ fontSize={14}
40
+ color="#ffffff"
41
+ />
42
+ </ButtonLabel>
43
+ </Button>
44
+ <Button
45
+ variant="primary"
46
+ size="sm"
47
+ onclick={() => partConfig.save()}
48
+ >
49
+ <ButtonLabel>
50
+ <Text
51
+ text="Save"
52
+ fontSize={14}
53
+ color="#ffffff"
54
+ />
55
+ </ButtonLabel>
56
+ </Button>
57
+ </Panel>
58
+ </Panel>
59
+ </WristDisplay>
60
+ {/if}
@@ -0,0 +1,18 @@
1
+ interface $$__sveltets_2_IsomorphicComponent<Props extends Record<string, any> = any, Events extends Record<string, any> = any, Slots extends Record<string, any> = any, Exports = {}, Bindings = string> {
2
+ new (options: import('svelte').ComponentConstructorOptions<Props>): import('svelte').SvelteComponent<Props, Events, Slots> & {
3
+ $$bindings?: Bindings;
4
+ } & Exports;
5
+ (internal: unknown, props: {
6
+ $$events?: Events;
7
+ $$slots?: Slots;
8
+ }): Exports & {
9
+ $set?: any;
10
+ $on?: any;
11
+ };
12
+ z_$$bindings?: Bindings;
13
+ }
14
+ declare const PendingEditsPanel: $$__sveltets_2_IsomorphicComponent<Record<string, never>, {
15
+ [evt: string]: CustomEvent<any>;
16
+ }, {}, {}, string>;
17
+ type PendingEditsPanel = InstanceType<typeof PendingEditsPanel>;
18
+ export default PendingEditsPanel;
@@ -0,0 +1,60 @@
1
+ <script lang="ts">
2
+ import type { Snippet } from 'svelte'
3
+
4
+ import { T } from '@threlte/core'
5
+ import { useController, useHandJoint } from '@threlte/xr'
6
+ import { fromStore } from 'svelte/store'
7
+ import { Group, type Vector3Tuple } from 'three'
8
+ import { provideDefaultProperties } from 'threlte-uikit'
9
+
10
+ interface Props {
11
+ /** Offset from the wrist in the wrist-local frame, meters. */
12
+ position?: Vector3Tuple
13
+ /**
14
+ * Rotation in the wrist-local frame. The default orients uikit content
15
+ * (panels face their own +Z) onto the dorsal side of the wrist, so the
16
+ * user sees it when turning their palm down, like a smartwatch.
17
+ */
18
+ rotation?: Vector3Tuple
19
+ /** Uniform scale for the wrist group. Smaller than the HUD default
20
+ * because panels live at arm's length instead of ~1 m away. */
21
+ scale?: number
22
+ children?: Snippet
23
+ }
24
+
25
+ const {
26
+ position = [0, 0.005, 0.08],
27
+ rotation = [-Math.PI / 2, 0, 0],
28
+ scale = 0.03,
29
+ children,
30
+ }: Props = $props()
31
+
32
+ // Draw uikit content on top of the scene (real-world depth, selection OBB,
33
+ // etc.), matching the old HUD behavior.
34
+ provideDefaultProperties(() => ({
35
+ depthTest: false,
36
+ renderOrder: 999,
37
+ }))
38
+
39
+ const leftController = fromStore(useController('left'))
40
+ const leftWrist = fromStore(useHandJoint('left', 'wrist'))
41
+
42
+ // Prefer the hand wrist joint when hand tracking is active; fall back to
43
+ // the controller grip. Both are three.js Groups updated per frame by
44
+ // WebXR, so attaching as a child follows the wrist automatically.
45
+ const parent = $derived(leftWrist.current ?? leftController.current?.grip)
46
+
47
+ const group = new Group()
48
+ </script>
49
+
50
+ {#if parent}
51
+ <T
52
+ is={group}
53
+ attach={parent}
54
+ {position}
55
+ {rotation}
56
+ {scale}
57
+ >
58
+ {@render children?.()}
59
+ </T>
60
+ {/if}
@@ -0,0 +1,19 @@
1
+ import type { Snippet } from 'svelte';
2
+ import { type Vector3Tuple } from 'three';
3
+ interface Props {
4
+ /** Offset from the wrist in the wrist-local frame, meters. */
5
+ position?: Vector3Tuple;
6
+ /**
7
+ * Rotation in the wrist-local frame. The default orients uikit content
8
+ * (panels face their own +Z) onto the dorsal side of the wrist, so the
9
+ * user sees it when turning their palm down, like a smartwatch.
10
+ */
11
+ rotation?: Vector3Tuple;
12
+ /** Uniform scale for the wrist group. Smaller than the HUD default
13
+ * because panels live at arm's length instead of ~1 m away. */
14
+ scale?: number;
15
+ children?: Snippet;
16
+ }
17
+ declare const WristDisplay: import("svelte").Component<Props, {}, "">;
18
+ type WristDisplay = ReturnType<typeof WristDisplay>;
19
+ export default WristDisplay;