@viamrobotics/motion-tools 1.21.0 → 1.22.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.
@@ -23,6 +23,5 @@ export declare class FrameConfigUpdater {
23
23
  setFrameParent: (entity: Entity, parentName: string) => void;
24
24
  deleteFrame: (entity: Entity) => void;
25
25
  setGeometryType: (entity: Entity, type: "none" | "box" | "sphere" | "capsule") => void;
26
- private sanitizeFloatValue;
27
26
  }
28
27
  export {};
@@ -7,9 +7,7 @@ export class FrameConfigUpdater {
7
7
  this.removeFrame = removeFrame;
8
8
  }
9
9
  updateLocalPosition = (entity, position) => {
10
- const x = this.sanitizeFloatValue(position.x);
11
- const y = this.sanitizeFloatValue(position.y);
12
- const z = this.sanitizeFloatValue(position.z);
10
+ const { x, y, z } = position;
13
11
  if (x === undefined && y === undefined && z === undefined)
14
12
  return;
15
13
  const change = {};
@@ -28,10 +26,7 @@ export class FrameConfigUpdater {
28
26
  }
29
27
  };
30
28
  updateLocalOrientation = (entity, orientation) => {
31
- const oX = this.sanitizeFloatValue(orientation.oX);
32
- const oY = this.sanitizeFloatValue(orientation.oY);
33
- const oZ = this.sanitizeFloatValue(orientation.oZ);
34
- const theta = this.sanitizeFloatValue(orientation.theta);
29
+ const { oX, oY, oZ, theta } = orientation;
35
30
  if (oX === undefined && oY === undefined && oZ === undefined && theta === undefined) {
36
31
  return;
37
32
  }
@@ -57,9 +52,7 @@ export class FrameConfigUpdater {
57
52
  const parent = entity.get(traits.Parent) ?? 'world';
58
53
  const pose = entity.get(traits.EditedPose);
59
54
  if (geometry?.type === 'box') {
60
- const x = this.sanitizeFloatValue(geometry.x);
61
- const y = this.sanitizeFloatValue(geometry.y);
62
- const z = this.sanitizeFloatValue(geometry.z);
55
+ const { x, y, z } = geometry;
63
56
  if (x === undefined && y === undefined && z === undefined)
64
57
  return;
65
58
  const change = {};
@@ -76,7 +69,7 @@ export class FrameConfigUpdater {
76
69
  }
77
70
  }
78
71
  else if (geometry?.type === 'sphere') {
79
- const r = this.sanitizeFloatValue(geometry.r);
72
+ const { r } = geometry;
80
73
  if (r === undefined)
81
74
  return;
82
75
  entity.set(traits.Sphere, { r });
@@ -86,8 +79,7 @@ export class FrameConfigUpdater {
86
79
  }
87
80
  }
88
81
  else if (geometry?.type === 'capsule') {
89
- const r = this.sanitizeFloatValue(geometry.r);
90
- const l = this.sanitizeFloatValue(geometry.l);
82
+ const { r, l } = geometry;
91
83
  if (r === undefined && l === undefined)
92
84
  return;
93
85
  const change = {};
@@ -95,7 +87,7 @@ export class FrameConfigUpdater {
95
87
  change.r = r;
96
88
  if (l !== undefined)
97
89
  change.l = l;
98
- entity.set(traits.Capsule, { r, l });
90
+ entity.set(traits.Capsule, change);
99
91
  const capsule = entity.get(traits.Capsule);
100
92
  if (name && capsule && pose) {
101
93
  this.updateFrame(name, parent, pose, { type: 'capsule', ...capsule });
@@ -134,14 +126,4 @@ export class FrameConfigUpdater {
134
126
  this.updateFrame(name, parent, pose, { type: 'capsule', r: 20, l: 100 });
135
127
  }
136
128
  };
137
- sanitizeFloatValue = (value) => {
138
- if (value === undefined) {
139
- return undefined;
140
- }
141
- const num = Number.parseFloat(value.toFixed(2));
142
- if (Number.isNaN(num)) {
143
- return undefined;
144
- }
145
- return value;
146
- };
147
129
  }
@@ -6,7 +6,7 @@
6
6
  import { traits, useTrait } from '../../ecs'
7
7
  import { usePartConfig } from '../../hooks/usePartConfig.svelte'
8
8
  import { usePose } from '../../hooks/usePose.svelte'
9
- import { matrixToPose, poseToMatrix } from '../../transform'
9
+ import { composeRenderedPose } from '../../transform'
10
10
 
11
11
  interface Props {
12
12
  entity: Entity
@@ -25,22 +25,27 @@
25
25
  () => parent.current
26
26
  )
27
27
 
28
- const resolvedPose = $derived.by(() => {
29
- if (pose.current === undefined || partConfig.hasPendingSave) {
30
- return editedPose.current
31
- }
28
+ $effect.pre(() => {
29
+ if (pose.current === undefined) return
32
30
 
33
- if (!entityPose.current || !editedPose.current) {
34
- return
31
+ if (entity.has(traits.LivePose)) {
32
+ entity.set(traits.LivePose, pose.current)
33
+ } else {
34
+ entity.add(traits.LivePose(pose.current))
35
35
  }
36
+ })
36
37
 
37
- const poseNetwork = poseToMatrix(entityPose.current)
38
- const poseUsePose = poseToMatrix(pose.current)
39
- const poseLocalEditedPose = poseToMatrix(editedPose.current)
38
+ // Always render through the live blend: live × network⁻¹ × edited. With
39
+ // `edited === network` (no edits) this collapses to `live`, so the rendered
40
+ // pose tracks the robot's kinematics-resolved position. With edits, the
41
+ // formula composes the staged delta on top of live. Input handlers that
42
+ // drive edits (gizmo onChange, Details panel) compute `edited` such that
43
+ // the blend renders to the user's intent.
44
+ const resolvedPose = $derived.by(() => {
45
+ if (pose.current === undefined || partConfig.hasPendingSave) return editedPose.current
46
+ if (!entityPose.current || !editedPose.current) return undefined
40
47
 
41
- const poseNetworkInverse = poseNetwork.invert()
42
- const resultMatrix = poseUsePose.multiply(poseNetworkInverse).multiply(poseLocalEditedPose)
43
- return matrixToPose(resultMatrix)
48
+ return composeRenderedPose(pose.current, entityPose.current, editedPose.current)
44
49
  })
45
50
  </script>
46
51
 
@@ -2,6 +2,9 @@ import { Extensions, parseFileName, Prefixes, readFile } from './file-names';
2
2
  import { pcdDropper } from './pcd-dropper';
3
3
  import { plyDropper } from './ply-dropper';
4
4
  import { snapshotDropper } from './snapshot-dropper';
5
+ const hasDraggedFiles = (dataTransfer) => {
6
+ return dataTransfer?.types?.includes('Files') ?? false;
7
+ };
5
8
  const createFileDropper = (extension, prefix) => {
6
9
  switch (prefix) {
7
10
  case Prefixes.Snapshot: {
@@ -22,11 +25,15 @@ export const useFileDrop = (onSuccess, onError) => {
22
25
  let dropState = $state('inactive');
23
26
  // prevent default to allow drop
24
27
  const ondragenter = (event) => {
28
+ if (!hasDraggedFiles(event.dataTransfer))
29
+ return;
25
30
  event.preventDefault();
26
31
  dropState = 'hovering';
27
32
  };
28
33
  // prevent default to allow drop
29
34
  const ondragover = (event) => {
35
+ if (!hasDraggedFiles(event.dataTransfer))
36
+ return;
30
37
  event.preventDefault();
31
38
  };
32
39
  const ondragleave = (event) => {
@@ -40,10 +47,17 @@ export const useFileDrop = (onSuccess, onError) => {
40
47
  dropState = 'inactive';
41
48
  };
42
49
  const ondrop = (event) => {
50
+ const { dataTransfer } = event;
51
+ if (dataTransfer === null || !hasDraggedFiles(dataTransfer)) {
52
+ dropState = 'inactive';
53
+ return;
54
+ }
43
55
  event.preventDefault();
44
- if (event.dataTransfer === null)
56
+ const { files } = dataTransfer;
57
+ if (files.length === 0) {
58
+ dropState = 'inactive';
45
59
  return;
46
- const { files } = event.dataTransfer;
60
+ }
47
61
  let completed = 0;
48
62
  for (const file of files) {
49
63
  const fileName = parseFileName(file.name);
@@ -23,7 +23,7 @@
23
23
  onpointerdown={() => {
24
24
  cameraDown.copy(camera.current.position)
25
25
  }}
26
- onpointerup={() => {
26
+ onclick={() => {
27
27
  if (transformControls.active) {
28
28
  return
29
29
  }
@@ -10,6 +10,7 @@
10
10
  import Entities from './Entities/Entities.svelte'
11
11
  import Focus from './Focus.svelte'
12
12
  import Selected from './Selected.svelte'
13
+ import SelectedTransformControls from './SelectedTransformControls.svelte'
13
14
  import StaticGeometries from './StaticGeometries.svelte'
14
15
  import { useFocusedObject3d } from '../hooks/useSelection.svelte'
15
16
  import { useSettings } from '../hooks/useSettings.svelte'
@@ -77,6 +78,7 @@
77
78
 
78
79
  <StaticGeometries />
79
80
  <Selected />
81
+ <SelectedTransformControls />
80
82
 
81
83
  {#if !$isPresenting && settings.current.grid}
82
84
  <Grid
@@ -12,6 +12,7 @@
12
12
  } from '../hooks/useControls.svelte'
13
13
  import { provideDrawAPI } from '../hooks/useDrawAPI.svelte'
14
14
  import { provideDrawService } from '../hooks/useDrawService.svelte'
15
+ import { provideFrameEditSession } from '../hooks/useFrameEditSession.svelte'
15
16
  import { provideFramelessComponents } from '../hooks/useFramelessComponents.svelte'
16
17
  import { provideFrames } from '../hooks/useFrames.svelte'
17
18
  import { provideGeometries } from '../hooks/useGeometries.svelte'
@@ -47,6 +48,7 @@
47
48
 
48
49
  provideResourceByName(() => partID.current)
49
50
  provideConfigFrames()
51
+ provideFrameEditSession(() => partID.current)
50
52
  provideFrames(() => partID.current)
51
53
  provideGeometries(() => partID.current)
52
54
  provide3DModels(() => partID.current)
@@ -0,0 +1,227 @@
1
+ <script lang="ts">
2
+ import { TransformControls } from '@threlte/extras'
3
+ import { Quaternion, Vector3 } from 'three'
4
+
5
+ import type { FrameEditSession } from '../editing/FrameEditSession'
6
+
7
+ import { traits, useTrait } from '../ecs'
8
+ import { useTransformControls } from '../hooks/useControls.svelte'
9
+ import { useEnvironment } from '../hooks/useEnvironment.svelte'
10
+ import { useFrameEditSession } from '../hooks/useFrameEditSession.svelte'
11
+ import { useSelectedEntity, useSelectedObject3d } from '../hooks/useSelection.svelte'
12
+ import { useSettings } from '../hooks/useSettings.svelte'
13
+ import {
14
+ composeEditedPoseForRenderedPose,
15
+ createPose,
16
+ quaternionToPose,
17
+ vector3ToPose,
18
+ } from '../transform'
19
+
20
+ const settings = useSettings()
21
+ const environment = useEnvironment()
22
+ const transformControls = useTransformControls()
23
+ const selectedEntity = useSelectedEntity()
24
+ const selectedObject3d = useSelectedObject3d()
25
+ const sessions = useFrameEditSession()
26
+
27
+ const mode = $derived(settings.current.transformMode)
28
+ const entity = $derived(selectedEntity.current)
29
+ const transformable = useTrait(() => entity, traits.Transformable)
30
+ const networkPose = useTrait(() => entity, traits.Pose)
31
+ const livePose = useTrait(() => entity, traits.LivePose)
32
+ const box = useTrait(() => entity, traits.Box)
33
+ const sphere = useTrait(() => entity, traits.Sphere)
34
+ const capsule = useTrait(() => entity, traits.Capsule)
35
+ const hasScalableGeometry = $derived(
36
+ box.current !== undefined || sphere.current !== undefined || capsule.current !== undefined
37
+ )
38
+
39
+ // Mesh sets name={entity} on its inner mesh, so useSelectedObject3d resolves
40
+ // to that mesh — not the parent Frame Group we actually want to drive. Walk
41
+ // up to the Group so translate/rotate/scale apply to the whole frame, not
42
+ // the geometry inside it.
43
+ const ref = $derived(selectedObject3d.current?.parent ?? selectedObject3d.current)
44
+
45
+ const activeMode = $derived.by(() => {
46
+ if (mode === 'none' || !transformable.current) return undefined
47
+ // Scale only does anything for primitive geometries the gizmo can size.
48
+ if (mode === 'scale' && !hasScalableGeometry) return undefined
49
+ return mode
50
+ })
51
+ const isSphereScale = $derived(activeMode === 'scale' && sphere.current !== undefined)
52
+ const isCapsuleScale = $derived(activeMode === 'scale' && capsule.current !== undefined)
53
+
54
+ const quaternion = new Quaternion()
55
+ const vector3 = new Vector3()
56
+ const refPose = createPose()
57
+
58
+ let session: FrameEditSession | undefined
59
+ let scaleStart:
60
+ | { type: 'box'; x: number; y: number; z: number }
61
+ | { type: 'sphere'; r: number }
62
+ | { type: 'capsule'; r: number; l: number }
63
+ | undefined
64
+
65
+ const captureScaleStart = () => {
66
+ if (!entity || activeMode !== 'scale') {
67
+ scaleStart = undefined
68
+ return
69
+ }
70
+
71
+ const box = entity.get(traits.Box)
72
+ if (box) {
73
+ scaleStart = { type: 'box', ...box }
74
+ return
75
+ }
76
+
77
+ const sphere = entity.get(traits.Sphere)
78
+ if (sphere) {
79
+ scaleStart = { type: 'sphere', ...sphere }
80
+ return
81
+ }
82
+
83
+ const capsule = entity.get(traits.Capsule)
84
+ if (capsule) {
85
+ scaleStart = { type: 'capsule', ...capsule }
86
+ return
87
+ }
88
+
89
+ scaleStart = undefined
90
+ }
91
+
92
+ const onMouseDown = () => {
93
+ if (entity?.has(traits.FramesAPI)) {
94
+ session = sessions.begin([entity])
95
+ }
96
+ captureScaleStart()
97
+ environment.current.viewerMode = 'edit'
98
+ transformControls.setActive(true)
99
+ }
100
+
101
+ const onChange = () => {
102
+ if (!ref || !entity || !activeMode) {
103
+ return
104
+ }
105
+
106
+ const isFrameEntity = entity.has(traits.FramesAPI)
107
+
108
+ if (activeMode === 'translate' || activeMode === 'rotate') {
109
+ if (isFrameEntity) {
110
+ stageFrameTransform()
111
+ } else {
112
+ const pose = entity.get(traits.Pose)
113
+ if (pose) {
114
+ if (activeMode === 'translate') {
115
+ vector3ToPose(ref.getWorldPosition(vector3), pose)
116
+ } else {
117
+ quaternionToPose(ref.getWorldQuaternion(quaternion), pose)
118
+ ref.quaternion.copy(quaternion)
119
+ }
120
+ entity.set(traits.Pose, pose)
121
+ }
122
+ }
123
+ } else {
124
+ // scale → bake the gizmo's scale factor into the geometry trait,
125
+ // then reset the object's scale so subsequent drags start from 1.
126
+ if (!scaleStart) {
127
+ captureScaleStart()
128
+ }
129
+
130
+ if (scaleStart?.type === 'box') {
131
+ const next = {
132
+ x: scaleStart.x * ref.scale.x,
133
+ y: scaleStart.y * ref.scale.y,
134
+ z: scaleStart.z * ref.scale.z,
135
+ }
136
+ if (isFrameEntity) {
137
+ session?.stageGeometry(entity, { type: 'box', ...next })
138
+ } else {
139
+ entity.set(traits.Box, next)
140
+ }
141
+ } else if (scaleStart?.type === 'sphere') {
142
+ const next = { r: scaleStart.r * ref.scale.x }
143
+ if (isFrameEntity) {
144
+ session?.stageGeometry(entity, { type: 'sphere', ...next })
145
+ } else {
146
+ entity.set(traits.Sphere, next)
147
+ }
148
+ } else if (scaleStart?.type === 'capsule') {
149
+ const next = { r: scaleStart.r * ref.scale.x, l: scaleStart.l * ref.scale.y }
150
+ if (isFrameEntity) {
151
+ session?.stageGeometry(entity, { type: 'capsule', ...next })
152
+ } else {
153
+ entity.set(traits.Capsule, next)
154
+ }
155
+ }
156
+
157
+ ref.scale.setScalar(1)
158
+ }
159
+ }
160
+
161
+ const onMouseUp = () => {
162
+ session?.commit()
163
+ session = undefined
164
+ scaleStart = undefined
165
+ transformControls.setActive(false)
166
+ }
167
+
168
+ // Pose.svelte renders frame entities through the live blend
169
+ // render = M(live) × M(network)⁻¹ × M(edited)
170
+ // so for the user's drag to render where they pulled the gizmo to, EditedPose
171
+ // must satisfy
172
+ // M(edited) = M(network) × M(live)⁻¹ × M(ref)
173
+ // where M(ref) is the gizmo-driven group's parent-relative matrix in mm.
174
+ // When live ≈ network (no kinematic offset), this collapses to M(edited) =
175
+ // M(ref) — the same as the naive writeback. When they diverge (e.g. an arm
176
+ // whose joints have moved away from its config pose), this composition is
177
+ // what keeps the rendering anchored to the user's pointer instead of
178
+ // shearing through the live × baseline⁻¹ offset.
179
+ const stageFrameTransform = () => {
180
+ if (!ref || !entity) return
181
+
182
+ vector3ToPose(ref.position, refPose)
183
+ quaternionToPose(ref.quaternion, refPose)
184
+
185
+ const live = livePose.current
186
+ const network = networkPose.current
187
+
188
+ if (!live || !network) {
189
+ // No live pose available — Pose.svelte's blend short-circuits to
190
+ // editedPose, so naive writeback is correct.
191
+ if (activeMode === 'translate') {
192
+ session?.stagePose(entity, {
193
+ x: refPose.x,
194
+ y: refPose.y,
195
+ z: refPose.z,
196
+ })
197
+ } else if (activeMode === 'rotate') {
198
+ session?.stagePose(entity, {
199
+ oX: refPose.oX,
200
+ oY: refPose.oY,
201
+ oZ: refPose.oZ,
202
+ theta: refPose.theta,
203
+ })
204
+ }
205
+ return
206
+ }
207
+
208
+ session?.stagePose(entity, composeEditedPoseForRenderedPose(network, live, refPose))
209
+ }
210
+ </script>
211
+
212
+ {#if ref && entity && activeMode}
213
+ {#key entity}
214
+ <TransformControls
215
+ object={ref}
216
+ mode={activeMode}
217
+ translationSnap={settings.current.snapping ? 0.1 : undefined}
218
+ rotationSnap={settings.current.snapping ? Math.PI / 24 : undefined}
219
+ scaleSnap={settings.current.snapping ? 0.1 : undefined}
220
+ showY={!isSphereScale}
221
+ showZ={!isSphereScale && !isCapsuleScale}
222
+ onmouseDown={onMouseDown}
223
+ onobjectChange={onChange}
224
+ onmouseUp={onMouseUp}
225
+ />
226
+ {/key}
227
+ {/if}
@@ -0,0 +1,3 @@
1
+ declare const SelectedTransformControls: import("svelte").Component<Record<string, never>, {}, "">;
2
+ type SelectedTransformControls = ReturnType<typeof SelectedTransformControls>;
3
+ export default SelectedTransformControls;
@@ -8,22 +8,15 @@
8
8
  <script lang="ts">
9
9
  import type { Entity } from 'koota'
10
10
 
11
- import { TransformControls } from '@threlte/extras'
12
11
  import { PressedKeys } from 'runed'
13
12
  import { SvelteSet } from 'svelte/reactivity'
14
- import { Quaternion, Vector3 } from 'three'
15
13
 
16
14
  import { traits, useWorld } from '../ecs'
17
- import { useTransformControls } from '../hooks/useControls.svelte'
18
15
  import { useSelectedEntity } from '../hooks/useSelection.svelte'
19
- import { useSettings } from '../hooks/useSettings.svelte'
20
- import { quaternionToPose, vector3ToPose } from '../transform'
21
16
 
22
17
  import Frame from './Entities/Frame.svelte'
23
18
 
24
19
  const world = useWorld()
25
- const settings = useSettings()
26
- const transformControls = useTransformControls()
27
20
  const selectedEntity = useSelectedEntity()
28
21
 
29
22
  const entities = new SvelteSet<Entity>()
@@ -31,11 +24,6 @@
31
24
  [...entities].find((entity) => entity === selectedEntity.current)
32
25
  )
33
26
 
34
- const mode = $derived(settings.current.transformMode)
35
-
36
- const quaternion = new Quaternion()
37
- const vector3 = new Vector3()
38
-
39
27
  const keys = new PressedKeys()
40
28
 
41
29
  keys.onKeys('=', () => {
@@ -43,7 +31,8 @@
43
31
  traits.Name(`custom geometry ${++index}`),
44
32
  traits.Pose,
45
33
  traits.Box({ x: 100, y: 100, z: 100 }),
46
- traits.Removable
34
+ traits.Removable,
35
+ traits.Transformable
47
36
  )
48
37
 
49
38
  entities.add(entity)
@@ -57,50 +46,8 @@
57
46
  selectedEntity.set()
58
47
  }
59
48
  })
60
-
61
- $effect(() => {
62
- settings.current.transforming = selectedCustomGeometry !== undefined
63
- })
64
49
  </script>
65
50
 
66
51
  {#each entities as entity (entity)}
67
- <Frame {entity}>
68
- {#snippet children({ ref })}
69
- {#if selectedEntity.current === entity}
70
- {#key mode}
71
- <TransformControls
72
- object={ref}
73
- {mode}
74
- translationSnap={settings.current.snapping ? 0.1 : undefined}
75
- rotationSnap={settings.current.snapping ? Math.PI / 24 : undefined}
76
- scaleSnap={settings.current.snapping ? 0.1 : undefined}
77
- onmouseDown={() => {
78
- transformControls.setActive(true)
79
- }}
80
- onmouseUp={() => {
81
- transformControls.setActive(false)
82
-
83
- const pose = entity.get(traits.Pose)
84
- const box = entity.get(traits.Box)
85
-
86
- if (pose && mode === 'translate') {
87
- vector3ToPose(ref.getWorldPosition(vector3), pose)
88
- entity.set(traits.Pose, pose)
89
- } else if (pose && mode === 'rotate') {
90
- quaternionToPose(ref.getWorldQuaternion(quaternion), pose)
91
- ref.quaternion.copy(quaternion)
92
- entity.set(traits.Pose, pose)
93
- } else if (box && mode === 'scale') {
94
- box.x *= ref.scale.x
95
- box.y *= ref.scale.y
96
- box.z *= ref.scale.z
97
- entity.set(traits.Box, box)
98
- ref.scale.setScalar(1)
99
- }
100
- }}
101
- />
102
- {/key}
103
- {/if}
104
- {/snippet}
105
- </Frame>
52
+ <Frame {entity} />
106
53
  {/each}
@@ -11,12 +11,6 @@
11
11
  const quaternion = new Quaternion()
12
12
  const ov = new OrientationVector()
13
13
  const euler = new Euler()
14
-
15
- ThemeUtils.setGlobalDefaultTheme({
16
- ...ThemeUtils.presets.light,
17
- baseBackgroundColor: '#fbfbfc',
18
- baseShadowColor: 'transparent',
19
- })
20
14
  </script>
21
15
 
22
16
  <script lang="ts">
@@ -139,8 +133,6 @@
139
133
  }
140
134
  })
141
135
 
142
- const formatTwoDecimals = (value: number) => value.toFixed(2)
143
-
144
136
  const detailConfigUpdater = new FrameConfigUpdater(partConfig.updateFrame, partConfig.deleteFrame)
145
137
 
146
138
  const handlePositionChange = (event: PointChangeEvent) => {
@@ -295,6 +287,12 @@
295
287
  2
296
288
  )
297
289
  }
290
+
291
+ ThemeUtils.setGlobalDefaultTheme({
292
+ ...ThemeUtils.presets.light,
293
+ baseBackgroundColor: '#fbfbfc',
294
+ baseShadowColor: 'transparent',
295
+ })
298
296
  </script>
299
297
 
300
298
  {#snippet ImmutableField({
@@ -321,9 +319,7 @@
321
319
  {#if entity}
322
320
  <div
323
321
  id="details-panel"
324
- class="border-medium bg-extralight absolute top-0 right-0 z-4 m-2 {showEditFrameOptions
325
- ? 'w-80'
326
- : 'w-60'} border p-2 text-xs dark:text-black"
322
+ class="border-medium bg-extralight absolute top-0 right-0 z-4 m-2 w-70 border p-2 text-xs dark:text-black"
327
323
  use:draggable={{
328
324
  bounds: 'body',
329
325
  handle: dragElement,
@@ -492,7 +488,6 @@
492
488
  y: localPose.current.y,
493
489
  z: localPose.current.z,
494
490
  }}
495
- format={formatTwoDecimals}
496
491
  on:change={handlePositionChange}
497
492
  />
498
493
  </div>
@@ -531,7 +526,6 @@
531
526
  z: localPose.current.oZ,
532
527
  w: localPose.current.theta,
533
528
  }}
534
- format={formatTwoDecimals}
535
529
  on:change={handleOrientationOVChange}
536
530
  />
537
531
  </TabPage>
@@ -578,48 +572,49 @@
578
572
  <div aria-label="mutable geometry">
579
573
  <TabGroup bind:selectedIndex={geometryTabIndex}>
580
574
  <TabPage title="None" />
581
- <TabPage title="Box" />
582
- <TabPage title="Sphere" />
583
- <TabPage title="Capsule" />
575
+ <TabPage title="Box">
576
+ {#if box.current}
577
+ <div aria-label="mutable box dimensions">
578
+ <Point
579
+ value={{
580
+ x: box.current.x,
581
+ y: box.current.y,
582
+ z: box.current.z,
583
+ }}
584
+ on:change={handleBoxChange}
585
+ />
586
+ </div>
587
+ {/if}
588
+ </TabPage>
589
+ <TabPage title="Sphere">
590
+ {#if sphere.current}
591
+ <div aria-label="mutable sphere dimensions">
592
+ <Slider
593
+ label="r"
594
+ value={sphere.current.r}
595
+ on:change={handleSphereRChange}
596
+ />
597
+ </div>
598
+ {/if}
599
+ </TabPage>
600
+ <TabPage title="Capsule">
601
+ {#if capsule.current}
602
+ <div aria-label="mutable capsule dimensions">
603
+ <Slider
604
+ label="r"
605
+ value={capsule.current.r}
606
+ on:change={handleCapsuleRChange}
607
+ />
608
+ <Slider
609
+ label="l"
610
+ value={capsule.current.l}
611
+ on:change={handleCapsuleLChange}
612
+ />
613
+ </div>
614
+ {/if}
615
+ </TabPage>
584
616
  </TabGroup>
585
617
  </div>
586
- {#if geometryTabIndex === 1 && box.current}
587
- <div aria-label="mutable box dimensions">
588
- <Point
589
- value={{
590
- x: box.current.x,
591
- y: box.current.y,
592
- z: box.current.z,
593
- }}
594
- format={formatTwoDecimals}
595
- on:change={handleBoxChange}
596
- />
597
- </div>
598
- {:else if geometryTabIndex === 2 && sphere.current}
599
- <div aria-label="mutable sphere dimensions">
600
- <Slider
601
- label="r"
602
- value={sphere.current.r}
603
- format={formatTwoDecimals}
604
- on:change={handleSphereRChange}
605
- />
606
- </div>
607
- {:else if geometryTabIndex === 3 && capsule.current}
608
- <div aria-label="mutable capsule dimensions">
609
- <Slider
610
- label="r"
611
- value={capsule.current.r}
612
- format={formatTwoDecimals}
613
- on:change={handleCapsuleRChange}
614
- />
615
- <Slider
616
- label="l"
617
- value={capsule.current.l}
618
- format={formatTwoDecimals}
619
- on:change={handleCapsuleLChange}
620
- />
621
- </div>
622
- {/if}
623
618
  </div>
624
619
  {:else if box.current}
625
620
  <div>
@@ -2,10 +2,10 @@
2
2
  import type { ClassValue, HTMLButtonAttributes, MouseEventHandler } from 'svelte/elements'
3
3
 
4
4
  import { Icon, type IconName, Tooltip } from '@viamrobotics/prime-core'
5
- import { Ruler } from 'lucide-svelte'
5
+ import { MousePointer2, Ruler } from 'lucide-svelte'
6
6
 
7
7
  interface Props extends HTMLButtonAttributes {
8
- icon: IconName | 'ruler'
8
+ icon: IconName | 'ruler' | 'mouse-pointer'
9
9
  active?: boolean
10
10
  description: string
11
11
  hotkey?: string
@@ -48,6 +48,8 @@
48
48
  >
49
49
  {#if icon === 'ruler'}
50
50
  <Ruler size="16" />
51
+ {:else if icon === 'mouse-pointer'}
52
+ <MousePointer2 size="16" />
51
53
  {:else}
52
54
  <Icon name={icon} />
53
55
  {/if}
@@ -1,7 +1,7 @@
1
1
  import type { ClassValue, HTMLButtonAttributes, MouseEventHandler } from 'svelte/elements';
2
2
  import { type IconName } from '@viamrobotics/prime-core';
3
3
  interface Props extends HTMLButtonAttributes {
4
- icon: IconName | 'ruler';
4
+ icon: IconName | 'ruler' | 'mouse-pointer';
5
5
  active?: boolean;
6
6
  description: string;
7
7
  hotkey?: string;
@@ -38,40 +38,50 @@
38
38
  </fieldset>
39
39
 
40
40
  <!-- transform -->
41
- {#if settings.current.transforming}
42
- <fieldset class="flex">
43
- <Button
44
- icon="cursor-move"
45
- active={settings.current.transformMode === 'translate'}
46
- description="Translate"
47
- hotkey="1"
48
- onclick={() => {
49
- settings.current.transformMode = 'translate'
50
- }}
51
- />
52
- <Button
53
- icon="sync"
54
- active={settings.current.transformMode === 'rotate'}
55
- description="Rotate"
56
- hotkey="2"
57
- class="-ml-px"
58
- onclick={() => {
59
- settings.current.transformMode = 'rotate'
60
- }}
61
- />
62
- <Button
63
- icon="resize"
64
- active={settings.current.transformMode === 'scale'}
65
- description="Scale"
66
- hotkey="3"
67
- class="-ml-px"
68
- onclick={() => {
69
- settings.current.transformMode = 'scale'
70
- }}
71
- />
72
- </fieldset>
41
+ <fieldset class="flex">
42
+ <Button
43
+ icon="mouse-pointer"
44
+ active={settings.current.transformMode === 'none'}
45
+ description="No transform controls"
46
+ hotkey="0"
47
+ onclick={() => {
48
+ settings.current.transformMode = 'none'
49
+ }}
50
+ />
51
+ <Button
52
+ icon="cursor-move"
53
+ active={settings.current.transformMode === 'translate'}
54
+ description="Translate"
55
+ hotkey="1"
56
+ class="-ml-px"
57
+ onclick={() => {
58
+ settings.current.transformMode = 'translate'
59
+ }}
60
+ />
61
+ <Button
62
+ icon="sync"
63
+ active={settings.current.transformMode === 'rotate'}
64
+ description="Rotate"
65
+ hotkey="2"
66
+ class="-ml-px"
67
+ onclick={() => {
68
+ settings.current.transformMode = 'rotate'
69
+ }}
70
+ />
71
+ <Button
72
+ icon="resize"
73
+ active={settings.current.transformMode === 'scale'}
74
+ description="Scale"
75
+ hotkey="3"
76
+ class="-ml-px"
77
+ onclick={() => {
78
+ settings.current.transformMode = 'scale'
79
+ }}
80
+ />
81
+ </fieldset>
73
82
 
74
- <!-- snapping -->
83
+ <!-- snapping -->
84
+ {#if settings.current.transformMode !== 'none'}
75
85
  <fieldset class="flex">
76
86
  <Button
77
87
  icon={settings.current.snapping ? 'magnet' : 'magnet-off'}
@@ -23,6 +23,15 @@ export declare const EditedPose: import("koota").Trait<{
23
23
  oZ: number;
24
24
  theta: number;
25
25
  }>;
26
+ export declare const LivePose: import("koota").Trait<{
27
+ x: number;
28
+ y: number;
29
+ z: number;
30
+ oX: number;
31
+ oY: number;
32
+ oZ: number;
33
+ theta: number;
34
+ }>;
26
35
  export declare const Center: import("koota").Trait<{
27
36
  x: number;
28
37
  y: number;
@@ -146,6 +155,12 @@ export declare const SnapshotAPI: import("koota").Trait<() => boolean>;
146
155
  * Marker trait for entities created from user-dropped files (PLY, PCD, etc.)
147
156
  */
148
157
  export declare const DroppedFile: import("koota").Trait<() => boolean>;
158
+ /**
159
+ * Marker trait for entities the dashboard's TransformControls may attach to —
160
+ * editable frames and ad-hoc custom geometries. Other entity kinds (lines,
161
+ * points, batched arrows, etc.) are deliberately excluded.
162
+ */
163
+ export declare const Transformable: import("koota").Trait<() => boolean>;
149
164
  export declare const ShowAxesHelper: import("koota").Trait<() => boolean>;
150
165
  /**
151
166
  * Marker trait for entities that should be rendered in screen space (CSS pixels)
@@ -11,6 +11,7 @@ export const Parent = trait(() => 'world');
11
11
  export const UUID = trait(() => '');
12
12
  export const Pose = trait({ x: 0, y: 0, z: 0, oX: 0, oY: 0, oZ: 1, theta: 0 });
13
13
  export const EditedPose = trait({ x: 0, y: 0, z: 0, oX: 0, oY: 0, oZ: 1, theta: 0 });
14
+ export const LivePose = trait({ x: 0, y: 0, z: 0, oX: 0, oY: 0, oZ: 1, theta: 0 });
14
15
  export const Center = trait({ x: 0, y: 0, z: 0, oX: 0, oY: 0, oZ: 1, theta: 0 });
15
16
  export const InstancedPose = trait({
16
17
  x: 0,
@@ -103,6 +104,12 @@ export const SnapshotAPI = trait(() => true);
103
104
  * Marker trait for entities created from user-dropped files (PLY, PCD, etc.)
104
105
  */
105
106
  export const DroppedFile = trait(() => true);
107
+ /**
108
+ * Marker trait for entities the dashboard's TransformControls may attach to —
109
+ * editable frames and ad-hoc custom geometries. Other entity kinds (lines,
110
+ * points, batched arrows, etc.) are deliberately excluded.
111
+ */
112
+ export const Transformable = trait(() => true);
106
113
  export const ShowAxesHelper = trait(() => true);
107
114
  /**
108
115
  * Marker trait for entities that should be rendered in screen space (CSS pixels)
@@ -0,0 +1,37 @@
1
+ import type { Pose } from '@viamrobotics/sdk';
2
+ import type { Entity } from 'koota';
3
+ import type { Frame } from '../frame';
4
+ export type UpdateFrameFn = (componentName: string, referenceFrame: string, pose: Pose, geometry?: Frame['geometry']) => void;
5
+ export type DeleteFrameFn = (componentName: string) => void;
6
+ /**
7
+ * A single user gesture against one or more frames (drag, parent change, geometry tweak).
8
+ * Owns the affected entities until commit() or abort() runs. Snapshots their pre-gesture
9
+ * trait state so abort() can restore — both the ECS view and the dirty part config.
10
+ *
11
+ * Replaces the Transforming marker trait: while a session is active, useFrames asks
12
+ * `session.owns(entity)` instead of inspecting a per-entity flag.
13
+ */
14
+ export declare class FrameEditSession {
15
+ #private;
16
+ private snapshots;
17
+ private updateFrame;
18
+ private deleteFrame;
19
+ private onClose;
20
+ constructor(entities: Entity[], updateFrame: UpdateFrameFn, deleteFrame: DeleteFrameFn, onClose: () => void);
21
+ get isClosed(): boolean;
22
+ owns(entity: Entity | undefined): boolean;
23
+ stagePose: (entity: Entity, pose: Partial<Pose>) => void;
24
+ stageGeometry: (entity: Entity, geometry: Frame["geometry"]) => void;
25
+ stageParent: (entity: Entity, parent: string) => void;
26
+ stageDelete: (entity: Entity) => void;
27
+ /**
28
+ * Validate and close. Returns true on success. On invalid pose data
29
+ * (NaN/infinite from a degenerate gizmo state), aborts and returns false.
30
+ */
31
+ commit: () => boolean;
32
+ /**
33
+ * Restore each owned entity's traits to its pre-session state and re-issue
34
+ * an updateFrame so the dirty part config matches.
35
+ */
36
+ abort: () => void;
37
+ }
@@ -0,0 +1,178 @@
1
+ import { traits } from '../ecs';
2
+ import { isFinitePose } from '../transform';
3
+ const captureGeometry = (entity) => {
4
+ const box = entity.get(traits.Box);
5
+ if (box)
6
+ return { type: 'box', box: { ...box } };
7
+ const sphere = entity.get(traits.Sphere);
8
+ if (sphere)
9
+ return { type: 'sphere', sphere: { ...sphere } };
10
+ const capsule = entity.get(traits.Capsule);
11
+ if (capsule)
12
+ return { type: 'capsule', capsule: { ...capsule } };
13
+ return { type: 'none' };
14
+ };
15
+ const restoreGeometryTrait = (entity, snap) => {
16
+ if (snap.type === 'none') {
17
+ entity.remove(traits.Box, traits.Sphere, traits.Capsule);
18
+ return;
19
+ }
20
+ if (snap.type === 'box' && snap.box) {
21
+ entity.remove(traits.Sphere, traits.Capsule);
22
+ if (entity.has(traits.Box))
23
+ entity.set(traits.Box, snap.box);
24
+ else
25
+ entity.add(traits.Box(snap.box));
26
+ return;
27
+ }
28
+ if (snap.type === 'sphere' && snap.sphere) {
29
+ entity.remove(traits.Box, traits.Capsule);
30
+ if (entity.has(traits.Sphere))
31
+ entity.set(traits.Sphere, snap.sphere);
32
+ else
33
+ entity.add(traits.Sphere(snap.sphere));
34
+ return;
35
+ }
36
+ if (snap.type === 'capsule' && snap.capsule) {
37
+ entity.remove(traits.Box, traits.Sphere);
38
+ if (entity.has(traits.Capsule))
39
+ entity.set(traits.Capsule, snap.capsule);
40
+ else
41
+ entity.add(traits.Capsule(snap.capsule));
42
+ }
43
+ };
44
+ const snapshotToFrameGeometry = (snap) => {
45
+ if (snap.type === 'box' && snap.box)
46
+ return { type: 'box', ...snap.box };
47
+ if (snap.type === 'sphere' && snap.sphere)
48
+ return { type: 'sphere', ...snap.sphere };
49
+ if (snap.type === 'capsule' && snap.capsule)
50
+ return { type: 'capsule', ...snap.capsule };
51
+ return { type: 'none' };
52
+ };
53
+ const liveGeometry = (entity) => snapshotToFrameGeometry(captureGeometry(entity));
54
+ /**
55
+ * A single user gesture against one or more frames (drag, parent change, geometry tweak).
56
+ * Owns the affected entities until commit() or abort() runs. Snapshots their pre-gesture
57
+ * trait state so abort() can restore — both the ECS view and the dirty part config.
58
+ *
59
+ * Replaces the Transforming marker trait: while a session is active, useFrames asks
60
+ * `session.owns(entity)` instead of inspecting a per-entity flag.
61
+ */
62
+ export class FrameEditSession {
63
+ snapshots = new Map();
64
+ updateFrame;
65
+ deleteFrame;
66
+ onClose;
67
+ #closed = false;
68
+ constructor(entities, updateFrame, deleteFrame, onClose) {
69
+ this.updateFrame = updateFrame;
70
+ this.deleteFrame = deleteFrame;
71
+ this.onClose = onClose;
72
+ for (const entity of entities) {
73
+ const name = entity.get(traits.Name);
74
+ const editedPose = entity.get(traits.EditedPose);
75
+ if (!name || !editedPose)
76
+ continue;
77
+ this.snapshots.set(entity, {
78
+ name,
79
+ parent: entity.get(traits.Parent) ?? 'world',
80
+ editedPose: { ...editedPose },
81
+ geometry: captureGeometry(entity),
82
+ });
83
+ }
84
+ }
85
+ get isClosed() {
86
+ return this.#closed;
87
+ }
88
+ owns(entity) {
89
+ return entity !== undefined && !this.#closed && this.snapshots.has(entity);
90
+ }
91
+ stagePose = (entity, pose) => {
92
+ const snap = this.snapshots.get(entity);
93
+ if (!snap || this.#closed)
94
+ return;
95
+ const current = entity.get(traits.EditedPose);
96
+ if (!current)
97
+ return;
98
+ const next = { ...current, ...pose };
99
+ entity.set(traits.EditedPose, next);
100
+ this.updateFrame(snap.name, entity.get(traits.Parent) ?? 'world', next, liveGeometry(entity));
101
+ };
102
+ stageGeometry = (entity, geometry) => {
103
+ const snap = this.snapshots.get(entity);
104
+ if (!snap || this.#closed || !geometry)
105
+ return;
106
+ if (geometry.type === 'none') {
107
+ entity.remove(traits.Box, traits.Sphere, traits.Capsule);
108
+ }
109
+ else if (geometry.type === 'box') {
110
+ const data = { x: geometry.x, y: geometry.y, z: geometry.z };
111
+ restoreGeometryTrait(entity, { type: 'box', box: data });
112
+ }
113
+ else if (geometry.type === 'sphere') {
114
+ restoreGeometryTrait(entity, { type: 'sphere', sphere: { r: geometry.r } });
115
+ }
116
+ else if (geometry.type === 'capsule') {
117
+ restoreGeometryTrait(entity, { type: 'capsule', capsule: { r: geometry.r, l: geometry.l } });
118
+ }
119
+ const editedPose = entity.get(traits.EditedPose);
120
+ if (editedPose) {
121
+ this.updateFrame(snap.name, entity.get(traits.Parent) ?? 'world', editedPose, geometry);
122
+ }
123
+ };
124
+ stageParent = (entity, parent) => {
125
+ const snap = this.snapshots.get(entity);
126
+ if (!snap || this.#closed)
127
+ return;
128
+ traits.setParentTrait(entity, parent === 'world' ? undefined : parent);
129
+ const editedPose = entity.get(traits.EditedPose);
130
+ if (editedPose) {
131
+ this.updateFrame(snap.name, parent, editedPose, liveGeometry(entity));
132
+ }
133
+ };
134
+ stageDelete = (entity) => {
135
+ const snap = this.snapshots.get(entity);
136
+ if (!snap || this.#closed)
137
+ return;
138
+ this.deleteFrame(snap.name);
139
+ };
140
+ /**
141
+ * Validate and close. Returns true on success. On invalid pose data
142
+ * (NaN/infinite from a degenerate gizmo state), aborts and returns false.
143
+ */
144
+ commit = () => {
145
+ if (this.#closed)
146
+ return false;
147
+ for (const [entity] of this.snapshots) {
148
+ const pose = entity.get(traits.EditedPose);
149
+ if (pose && !isFinitePose(pose)) {
150
+ this.abort();
151
+ return false;
152
+ }
153
+ }
154
+ this.#close();
155
+ return true;
156
+ };
157
+ /**
158
+ * Restore each owned entity's traits to its pre-session state and re-issue
159
+ * an updateFrame so the dirty part config matches.
160
+ */
161
+ abort = () => {
162
+ if (this.#closed)
163
+ return;
164
+ for (const [entity, snap] of this.snapshots) {
165
+ if (entity.isAlive()) {
166
+ entity.set(traits.EditedPose, snap.editedPose);
167
+ traits.setParentTrait(entity, snap.parent === 'world' ? undefined : snap.parent);
168
+ restoreGeometryTrait(entity, snap.geometry);
169
+ }
170
+ this.updateFrame(snap.name, snap.parent, snap.editedPose, snapshotToFrameGeometry(snap.geometry));
171
+ }
172
+ this.#close();
173
+ };
174
+ #close = () => {
175
+ this.#closed = true;
176
+ this.onClose();
177
+ };
178
+ }
@@ -0,0 +1,15 @@
1
+ import type { Entity } from 'koota';
2
+ import { FrameEditSession } from '../editing/FrameEditSession';
3
+ interface FrameEditSessionContext {
4
+ /** The currently-active session, or undefined when no gesture is in flight. */
5
+ current: FrameEditSession | undefined;
6
+ /**
7
+ * Open a new session over the given entities. If a previous session is still
8
+ * active (e.g. selection changed mid-drag and onMouseUp never fired), it is
9
+ * aborted first so its snapshot is restored.
10
+ */
11
+ begin: (entities: Entity[]) => FrameEditSession;
12
+ }
13
+ export declare const provideFrameEditSession: (partID: () => string) => void;
14
+ export declare const useFrameEditSession: () => FrameEditSessionContext;
15
+ export {};
@@ -0,0 +1,36 @@
1
+ import { getContext, setContext } from 'svelte';
2
+ import { FrameEditSession } from '../editing/FrameEditSession';
3
+ import { usePartConfig } from './usePartConfig.svelte';
4
+ const key = Symbol('frame-edit-session-context');
5
+ export const provideFrameEditSession = (partID) => {
6
+ const partConfig = usePartConfig();
7
+ let active = $state(undefined);
8
+ const begin = (entities) => {
9
+ active?.abort();
10
+ const session = new FrameEditSession(entities, partConfig.updateFrame, partConfig.deleteFrame, () => {
11
+ if (active === session)
12
+ active = undefined;
13
+ });
14
+ active = session;
15
+ return session;
16
+ };
17
+ // Drop any in-flight session when the partID changes — its snapshots reference
18
+ // entities from the old world that useFrames will destroy, and aborting it
19
+ // after the swap would write old frame names into the new part's config.
20
+ let lastPartID;
21
+ $effect.pre(() => {
22
+ const id = partID();
23
+ if (lastPartID !== undefined && lastPartID !== id) {
24
+ active?.abort();
25
+ active = undefined;
26
+ }
27
+ lastPartID = id;
28
+ });
29
+ setContext(key, {
30
+ get current() {
31
+ return active;
32
+ },
33
+ begin,
34
+ });
35
+ };
36
+ export const useFrameEditSession = () => getContext(key);
@@ -7,6 +7,7 @@ import { traits, useWorld } from '../ecs';
7
7
  import { createPose } from '../transform';
8
8
  import { useConfigFrames } from './useConfigFrames.svelte';
9
9
  import { useEnvironment } from './useEnvironment.svelte';
10
+ import { useFrameEditSession } from './useFrameEditSession.svelte';
10
11
  import { useLogs } from './useLogs.svelte';
11
12
  import { usePartConfig } from './usePartConfig.svelte';
12
13
  import { useResourceByName } from './useResourceByName.svelte';
@@ -14,6 +15,7 @@ const key = Symbol('frames-context');
14
15
  export const provideFrames = (partID) => {
15
16
  const configFrames = useConfigFrames();
16
17
  const partConfig = usePartConfig();
18
+ const editSession = useFrameEditSession();
17
19
  const environment = useEnvironment();
18
20
  const world = useWorld();
19
21
  const resourceByName = useResourceByName();
@@ -23,6 +25,16 @@ export const provideFrames = (partID) => {
23
25
  const logs = useLogs();
24
26
  const pendingSaveKey = $derived(`viam-pending-save-revision:${partID()}`);
25
27
  let didRecentlyEdit = $state(false);
28
+ let lastPartID;
29
+ $effect.pre(() => {
30
+ const id = partID();
31
+ if (lastPartID !== undefined && lastPartID !== id) {
32
+ // Stale across parts: keeps the configFrames-priority merge branch
33
+ // active when switching to a new part that hasn't been edited.
34
+ didRecentlyEdit = false;
35
+ }
36
+ lastPartID = id;
37
+ });
26
38
  const isEditMode = $derived(environment.current.viewerMode === 'edit');
27
39
  const query = createRobotQuery(client, 'frameSystemConfig', () => ({
28
40
  refetchOnWindowFocus: false,
@@ -73,7 +85,7 @@ export const provideFrames = (partID) => {
73
85
  });
74
86
  const current = $derived(Object.values(frames));
75
87
  const entities = new Map();
76
- $effect.pre(() => {
88
+ $effect(() => {
77
89
  if (revision) {
78
90
  untrack(() => query.refetch());
79
91
  }
@@ -145,6 +157,13 @@ export const provideFrames = (partID) => {
145
157
  subtypeToColor(currentComponentSubtypeByName[frame.referenceFrame]);
146
158
  const existing = entities.get(entityKey);
147
159
  if (existing) {
160
+ // Active edit session owns the entity's traits for the duration of
161
+ // the user's gesture. Skip the entire re-sync — re-setting Parent
162
+ // would re-evaluate the <Portal> id and re-mount the group,
163
+ // detaching the gizmo's drag target mid-stroke.
164
+ if (editSession.current?.owns(existing)) {
165
+ continue;
166
+ }
148
167
  traits.setParentTrait(existing, parent);
149
168
  if (color) {
150
169
  existing.set(traits.Color, color);
@@ -153,14 +172,30 @@ export const provideFrames = (partID) => {
153
172
  existing.set(traits.Center, center);
154
173
  }
155
174
  traits.updateGeometryTrait(existing, frame.physicalObject);
156
- existing.set(traits.EditedPose, pose);
175
+ if (!isEditMode && !partConfig.hasPendingSave) {
176
+ existing.set(traits.Pose, pose);
177
+ }
178
+ if (!existing.has(traits.LivePose)) {
179
+ existing.add(traits.LivePose(pose));
180
+ }
181
+ // Skip the EditedPose overwrite while in edit mode. The merged
182
+ // `frames` source can differ from query.data once didRecentlyEdit
183
+ // flips (fragment overrides, round-trip drift), and writing those
184
+ // values would shift entities whose parents the user is portaling
185
+ // into — the gizmo's drag target moves underneath it. Once we're
186
+ // back in monitor mode, the next sync resumes the overwrite.
187
+ if (!isEditMode) {
188
+ existing.set(traits.EditedPose, pose);
189
+ }
157
190
  continue;
158
191
  }
159
192
  const entityTraits = [
160
193
  traits.Name(name),
161
194
  traits.Pose(pose),
162
195
  traits.EditedPose(pose),
196
+ traits.LivePose(pose),
163
197
  traits.FramesAPI,
198
+ traits.Transformable,
164
199
  traits.ShowAxesHelper,
165
200
  ...traits.getParentTrait(parent),
166
201
  ];
@@ -273,7 +273,17 @@ const useStandalonePartConfig = (partID) => {
273
273
  }
274
274
  return results;
275
275
  });
276
+ let lastPartID;
276
277
  $effect.pre(() => {
278
+ const id = partID();
279
+ if (lastPartID !== undefined && lastPartID !== id) {
280
+ // Part changed: drop any in-memory edits/pending-save state from the
281
+ // previous part. `current` is left for the existing sync below to
282
+ // repopulate once the new part's networkPartConfig arrives.
283
+ isDirty = false;
284
+ hasPendingSave = false;
285
+ }
286
+ lastPartID = id;
277
287
  if (!networkPartConfig || isDirty) {
278
288
  return;
279
289
  }
@@ -9,9 +9,8 @@ export interface Settings {
9
9
  };
10
10
  disabledCameras: Record<string, boolean>;
11
11
  disabledVisionServices: Record<string, boolean>;
12
- transforming: boolean;
13
12
  snapping: boolean;
14
- transformMode: 'translate' | 'rotate' | 'scale';
13
+ transformMode: 'none' | 'translate' | 'rotate' | 'scale';
15
14
  grid: boolean;
16
15
  gridCellSize: number;
17
16
  gridSectionSize: number;
@@ -15,9 +15,8 @@ const defaults = () => ({
15
15
  },
16
16
  disabledCameras: {},
17
17
  disabledVisionServices: {},
18
- transforming: false,
19
18
  snapping: false,
20
- transformMode: 'translate',
19
+ transformMode: 'none',
21
20
  grid: true,
22
21
  gridCellSize: 0.5,
23
22
  gridSectionSize: 10,
package/dist/transform.js CHANGED
@@ -105,3 +105,16 @@ export const matrixToPose = (matrix) => {
105
105
  pose.theta = MathUtils.radToDeg(ov.th);
106
106
  return pose;
107
107
  };
108
+ export const composeRenderedPose = (livePose, baselinePose, editedPose) => matrixToPose(poseToMatrix(livePose)
109
+ .multiply(poseToMatrix(baselinePose).invert())
110
+ .multiply(poseToMatrix(editedPose)));
111
+ export const composeEditedPoseForRenderedPose = (baselinePose, livePose, renderedPose) => matrixToPose(poseToMatrix(baselinePose)
112
+ .multiply(poseToMatrix(livePose).invert())
113
+ .multiply(poseToMatrix(renderedPose)));
114
+ export const isFinitePose = (pose) => Number.isFinite(pose.x) &&
115
+ Number.isFinite(pose.y) &&
116
+ Number.isFinite(pose.z) &&
117
+ Number.isFinite(pose.oX) &&
118
+ Number.isFinite(pose.oY) &&
119
+ Number.isFinite(pose.oZ) &&
120
+ Number.isFinite(pose.theta);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@viamrobotics/motion-tools",
3
- "version": "1.21.0",
3
+ "version": "1.22.0",
4
4
  "description": "Motion visualization with Viam",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -25,8 +25,8 @@
25
25
  "@testing-library/jest-dom": "6.8.0",
26
26
  "@testing-library/svelte": "5.2.8",
27
27
  "@testing-library/user-event": "^14.6.1",
28
- "@threlte/core": "8.5.5",
29
- "@threlte/extras": "9.14.1",
28
+ "@threlte/core": "8.5.11",
29
+ "@threlte/extras": "9.15.0",
30
30
  "@threlte/rapier": "3.4.1",
31
31
  "@threlte/xr": "1.5.2",
32
32
  "@types/bun": "1.2.21",