@viamrobotics/motion-tools 1.26.1 → 1.27.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 (58) hide show
  1. package/dist/FrameConfigUpdater.svelte.js +42 -29
  2. package/dist/assert.d.ts +13 -0
  3. package/dist/assert.js +20 -0
  4. package/dist/buf/common/v1/common_pb.d.ts +19 -0
  5. package/dist/buf/common/v1/common_pb.js +32 -0
  6. package/dist/components/BatchedArrows.svelte +43 -45
  7. package/dist/components/Entities/Arrows/Arrows.svelte +35 -29
  8. package/dist/components/Entities/Entities.svelte +3 -8
  9. package/dist/components/Entities/Frame.svelte +31 -32
  10. package/dist/components/Entities/Frame.svelte.d.ts +0 -2
  11. package/dist/components/Entities/GLTF.svelte +27 -36
  12. package/dist/components/Entities/Geometry.svelte +35 -24
  13. package/dist/components/Entities/Line.svelte +37 -43
  14. package/dist/components/Entities/Mesh.svelte +12 -18
  15. package/dist/components/Entities/Points.svelte +25 -28
  16. package/dist/components/Entities/Pose.svelte +17 -24
  17. package/dist/components/Entities/Pose.svelte.d.ts +1 -4
  18. package/dist/components/Entities/hooks/useEntityEvents.svelte.js +40 -41
  19. package/dist/components/Scene.svelte +7 -1
  20. package/dist/components/SceneProviders.svelte +2 -1
  21. package/dist/components/SelectedTransformControls.svelte +57 -34
  22. package/dist/components/StaticGeometries.svelte +1 -1
  23. package/dist/components/hover/HoveredEntity.svelte +33 -3
  24. package/dist/components/hover/LinkedHoveredEntity.svelte +2 -3
  25. package/dist/components/overlay/Details.svelte +72 -94
  26. package/dist/components/overlay/__tests__/__fixtures__/entity.js +14 -17
  27. package/dist/components/overlay/left-pane/Tree.svelte +9 -9
  28. package/dist/components/overlay/left-pane/Tree.svelte.d.ts +1 -2
  29. package/dist/components/overlay/left-pane/TreeContainer.svelte +4 -15
  30. package/dist/components/overlay/left-pane/TreeNode.svelte +1 -1
  31. package/dist/components/overlay/left-pane/TreeNode.svelte.d.ts +1 -1
  32. package/dist/components/overlay/left-pane/useTree.svelte.d.ts +14 -0
  33. package/dist/components/overlay/left-pane/useTree.svelte.js +63 -0
  34. package/dist/draw.js +24 -9
  35. package/dist/ecs/index.d.ts +1 -0
  36. package/dist/ecs/index.js +1 -0
  37. package/dist/ecs/provideWorldMatrix.svelte.d.ts +8 -0
  38. package/dist/ecs/provideWorldMatrix.svelte.js +13 -0
  39. package/dist/ecs/traits.d.ts +41 -50
  40. package/dist/ecs/traits.js +57 -29
  41. package/dist/ecs/useTrait.svelte.d.ts +1 -6
  42. package/dist/ecs/useTrait.svelte.js +21 -13
  43. package/dist/ecs/worldMatrix.d.ts +10 -0
  44. package/dist/ecs/worldMatrix.js +138 -0
  45. package/dist/editing/FrameEditSession.js +31 -18
  46. package/dist/hooks/use3DModels.svelte.js +1 -1
  47. package/dist/hooks/useConfigFrames.svelte.js +12 -0
  48. package/dist/hooks/useDrawAPI.svelte.js +14 -6
  49. package/dist/hooks/useDrawService.svelte.js +4 -7
  50. package/dist/hooks/useFrames.svelte.js +23 -11
  51. package/dist/hooks/useGeometries.svelte.js +11 -3
  52. package/dist/hooks/usePartConfig.svelte.js +43 -6
  53. package/dist/hooks/useWorldState.svelte.js +10 -2
  54. package/dist/plugins/bvh.svelte.js +37 -26
  55. package/dist/transform.js +55 -21
  56. package/package.json +3 -3
  57. package/dist/components/overlay/left-pane/buildTree.d.ts +0 -13
  58. package/dist/components/overlay/left-pane/buildTree.js +0 -48
@@ -0,0 +1,10 @@
1
+ import { type World } from 'koota';
2
+ /**
3
+ * Wire up listeners that maintain `WorldMatrix` reactively. Subscribes to
4
+ * add/change/remove on `Matrix`, `EditedMatrix`, `LiveMatrix`, and `ChildOf`;
5
+ * enqueues affected entities and flushes on the next microtask.
6
+ *
7
+ * Returns an unsubscribe function. Plain function (not a rune hook) so tests
8
+ * can drive the lifecycle without mounting Svelte.
9
+ */
10
+ export declare const installWorldMatrixListeners: (world: World) => (() => void);
@@ -0,0 +1,138 @@
1
+ import {} from 'koota';
2
+ import { Matrix4 } from 'three';
3
+ import { composeLocalMatrix } from '../transform';
4
+ import { ChildOf } from './relations';
5
+ import { EditedMatrix, LiveMatrix, Matrix, WorldMatrix } from './traits';
6
+ /**
7
+ * Compute the entity's local-to-parent transform into `out`. Mirrors the
8
+ * blend used by `Frame.svelte` so `WorldMatrix` agrees with the displayed
9
+ * scenegraph.
10
+ *
11
+ * - All three matrix traits present: `live × baseline⁻¹ × edited`.
12
+ * - Otherwise: prefer `EditedMatrix` over `Matrix`.
13
+ *
14
+ * Returns `true` after writing to `out`; returns `false` and leaves `out`
15
+ * untouched when the entity has no matrix-shaped trait.
16
+ */
17
+ const toLocalMatrix = (entity, out) => {
18
+ const matrix = entity.get(Matrix);
19
+ const editedMatrix = entity.get(EditedMatrix);
20
+ const liveMatrix = entity.get(LiveMatrix);
21
+ if (liveMatrix && matrix && editedMatrix) {
22
+ composeLocalMatrix(liveMatrix, matrix, editedMatrix, out);
23
+ return true;
24
+ }
25
+ if (editedMatrix) {
26
+ out.copy(editedMatrix);
27
+ return true;
28
+ }
29
+ if (matrix) {
30
+ out.copy(matrix);
31
+ return true;
32
+ }
33
+ return false;
34
+ };
35
+ /**
36
+ * Synchronously compute and write `WorldMatrix` for every entity in `dirty`
37
+ * and every descendant via `ChildOf`. Memoizes per-entity world matrices in
38
+ * `cache` so siblings reuse a parent's result. Caller passes a fresh `cache`
39
+ * map per flush.
40
+ */
41
+ const recomputeWorldMatrix = (world, entity, cache) => {
42
+ if (!entity.isAlive())
43
+ return undefined;
44
+ const cached = cache.get(entity);
45
+ if (cached)
46
+ return cached;
47
+ // Reuse the entity's existing `WorldMatrix` storage when present so a
48
+ // flush doesn't allocate a throwaway matrix per entity. First-time
49
+ // entities get a fresh `Matrix4` that's added as the trait below.
50
+ const out = entity.get(WorldMatrix) ?? new Matrix4();
51
+ const hasLocal = toLocalMatrix(entity, out);
52
+ if (!hasLocal)
53
+ out.identity();
54
+ const parent = entity.targetFor(ChildOf);
55
+ if (parent && parent.isAlive()) {
56
+ const parentWorld = recomputeWorldMatrix(world, parent, cache);
57
+ if (parentWorld)
58
+ out.premultiply(parentWorld);
59
+ }
60
+ cache.set(entity, out);
61
+ return out;
62
+ };
63
+ const flushDirty = (world, dirty) => {
64
+ if (dirty.size === 0)
65
+ return;
66
+ const cache = new Map();
67
+ const expanded = new Set();
68
+ const collect = (entity) => {
69
+ if (expanded.has(entity))
70
+ return;
71
+ expanded.add(entity);
72
+ for (const child of world.query(ChildOf(entity))) {
73
+ collect(child);
74
+ }
75
+ };
76
+ for (const entity of dirty)
77
+ collect(entity);
78
+ dirty.clear();
79
+ for (const entity of expanded) {
80
+ if (!entity.isAlive())
81
+ continue;
82
+ const worldMat = recomputeWorldMatrix(world, entity, cache);
83
+ if (!worldMat)
84
+ continue;
85
+ if (entity.has(WorldMatrix)) {
86
+ entity.changed(WorldMatrix);
87
+ }
88
+ else {
89
+ entity.add(WorldMatrix(worldMat));
90
+ }
91
+ }
92
+ };
93
+ /**
94
+ * Wire up listeners that maintain `WorldMatrix` reactively. Subscribes to
95
+ * add/change/remove on `Matrix`, `EditedMatrix`, `LiveMatrix`, and `ChildOf`;
96
+ * enqueues affected entities and flushes on the next microtask.
97
+ *
98
+ * Returns an unsubscribe function. Plain function (not a rune hook) so tests
99
+ * can drive the lifecycle without mounting Svelte.
100
+ */
101
+ export const installWorldMatrixListeners = (world) => {
102
+ const dirty = new Set();
103
+ let scheduled = false;
104
+ const enqueue = (entity) => {
105
+ dirty.add(entity);
106
+ if (scheduled)
107
+ return;
108
+ scheduled = true;
109
+ queueMicrotask(() => {
110
+ scheduled = false;
111
+ flushDirty(world, dirty);
112
+ });
113
+ };
114
+ for (const entity of world.query(Matrix))
115
+ enqueue(entity);
116
+ for (const entity of world.query(EditedMatrix))
117
+ enqueue(entity);
118
+ for (const entity of world.query(LiveMatrix))
119
+ enqueue(entity);
120
+ const unsubs = [
121
+ world.onAdd(Matrix, enqueue),
122
+ world.onChange(Matrix, enqueue),
123
+ world.onRemove(Matrix, enqueue),
124
+ world.onAdd(EditedMatrix, enqueue),
125
+ world.onChange(EditedMatrix, enqueue),
126
+ world.onRemove(EditedMatrix, enqueue),
127
+ world.onAdd(LiveMatrix, enqueue),
128
+ world.onChange(LiveMatrix, enqueue),
129
+ world.onRemove(LiveMatrix, enqueue),
130
+ world.onAdd(ChildOf, enqueue),
131
+ world.onChange(ChildOf, enqueue),
132
+ world.onRemove(ChildOf, enqueue),
133
+ ];
134
+ return () => {
135
+ for (const unsub of unsubs)
136
+ unsub();
137
+ };
138
+ };
@@ -1,5 +1,6 @@
1
1
  import { hierarchy, traits } from '../ecs';
2
- import { isFinitePose } from '../transform';
2
+ import { createPose, isFinitePose, matrixToPose, poseToMatrix } from '../transform';
3
+ const tempPose = createPose();
3
4
  const captureGeometry = (entity) => {
4
5
  const box = entity.get(traits.Box);
5
6
  if (box)
@@ -71,13 +72,14 @@ export class FrameEditSession {
71
72
  this.onClose = onClose;
72
73
  for (const entity of entities) {
73
74
  const name = entity.get(traits.Name);
74
- const editedPose = entity.get(traits.EditedPose);
75
- if (!name || !editedPose)
75
+ const editedMatrix = entity.get(traits.EditedMatrix);
76
+ if (!name || !editedMatrix)
76
77
  continue;
78
+ matrixToPose(editedMatrix, tempPose);
77
79
  this.snapshots.set(entity, {
78
80
  name,
79
81
  parent: hierarchy.getParentName(entity) ?? 'world',
80
- editedPose: { ...editedPose },
82
+ editedPose: { ...tempPose },
81
83
  geometry: captureGeometry(entity),
82
84
  });
83
85
  }
@@ -92,11 +94,13 @@ export class FrameEditSession {
92
94
  const snap = this.snapshots.get(entity);
93
95
  if (!snap || this.#closed)
94
96
  return;
95
- const current = entity.get(traits.EditedPose);
97
+ const current = entity.get(traits.EditedMatrix);
96
98
  if (!current)
97
99
  return;
98
- const next = { ...current, ...pose };
99
- entity.set(traits.EditedPose, next);
100
+ matrixToPose(current, tempPose);
101
+ const next = { ...tempPose, ...pose };
102
+ poseToMatrix(next, current);
103
+ entity.changed(traits.EditedMatrix);
100
104
  this.updateFrame(snap.name, hierarchy.getParentName(entity) ?? 'world', next, liveGeometry(entity));
101
105
  };
102
106
  stageGeometry = (entity, geometry) => {
@@ -116,9 +120,10 @@ export class FrameEditSession {
116
120
  else if (geometry.type === 'capsule') {
117
121
  restoreGeometryTrait(entity, { type: 'capsule', capsule: { r: geometry.r, l: geometry.l } });
118
122
  }
119
- const editedPose = entity.get(traits.EditedPose);
120
- if (editedPose) {
121
- this.updateFrame(snap.name, hierarchy.getParentName(entity) ?? 'world', editedPose, geometry);
123
+ const editedMatrix = entity.get(traits.EditedMatrix);
124
+ if (editedMatrix) {
125
+ matrixToPose(editedMatrix, tempPose);
126
+ this.updateFrame(snap.name, hierarchy.getParentName(entity) ?? 'world', { ...tempPose }, geometry);
122
127
  }
123
128
  };
124
129
  stageParent = (entity, parent) => {
@@ -126,9 +131,10 @@ export class FrameEditSession {
126
131
  if (!snap || this.#closed)
127
132
  return;
128
133
  hierarchy.setParent(entity, parent === 'world' ? undefined : parent);
129
- const editedPose = entity.get(traits.EditedPose);
130
- if (editedPose) {
131
- this.updateFrame(snap.name, parent, editedPose, liveGeometry(entity));
134
+ const editedMatrix = entity.get(traits.EditedMatrix);
135
+ if (editedMatrix) {
136
+ matrixToPose(editedMatrix, tempPose);
137
+ this.updateFrame(snap.name, parent, { ...tempPose }, liveGeometry(entity));
132
138
  }
133
139
  };
134
140
  stageDelete = (entity) => {
@@ -145,10 +151,13 @@ export class FrameEditSession {
145
151
  if (this.#closed)
146
152
  return false;
147
153
  for (const [entity] of this.snapshots) {
148
- const pose = entity.get(traits.EditedPose);
149
- if (pose && !isFinitePose(pose)) {
150
- this.abort();
151
- return false;
154
+ const matrix = entity.get(traits.EditedMatrix);
155
+ if (matrix) {
156
+ matrixToPose(matrix, tempPose);
157
+ if (!isFinitePose(tempPose)) {
158
+ this.abort();
159
+ return false;
160
+ }
152
161
  }
153
162
  }
154
163
  this.#close();
@@ -163,7 +172,11 @@ export class FrameEditSession {
163
172
  return;
164
173
  for (const [entity, snap] of this.snapshots) {
165
174
  if (entity.isAlive()) {
166
- entity.set(traits.EditedPose, snap.editedPose);
175
+ const matrix = entity.get(traits.EditedMatrix);
176
+ if (matrix) {
177
+ poseToMatrix(snap.editedPose, matrix);
178
+ entity.changed(traits.EditedMatrix);
179
+ }
167
180
  hierarchy.setParent(entity, snap.parent === 'world' ? undefined : snap.parent);
168
181
  restoreGeometryTrait(entity, snap.geometry);
169
182
  }
@@ -48,13 +48,13 @@ export const provide3DModels = (partID) => {
48
48
  }
49
49
  });
50
50
  }
51
- current = next;
52
51
  }
53
52
  catch (error) {
54
53
  // some arms may not implement this api yet
55
54
  console.warn(`${client.current.name} returned an error: ${error} when getting 3D models`);
56
55
  }
57
56
  }
57
+ current = next;
58
58
  };
59
59
  $effect(() => {
60
60
  const shouldFetchModels = settings.isLoaded && settings.current.renderArmModels.includes('model');
@@ -57,6 +57,18 @@ export const provideConfigFrames = () => {
57
57
  const frameValues = $derived(Object.values(frames));
58
58
  const getParentFrameOptions = (componentName) => {
59
59
  const validFrames = new Set(frameValues.map((frame) => frame.referenceFrame));
60
+ /**
61
+ * Fragment components without a mod don't appear in frameValues (we only
62
+ * track frames with explicit $set mods), but the fragment itself supplies
63
+ * their frame so they render in the scene and are valid parents. Exclude
64
+ * any whose frame the user has $unset.
65
+ */
66
+ const unsetFragmentNames = new Set(fragmentUnsetFrameNames);
67
+ for (const name of Object.keys(partConfig.componentNameToFragmentId)) {
68
+ if (!unsetFragmentNames.has(name)) {
69
+ validFrames.add(name);
70
+ }
71
+ }
60
72
  validFrames.add('world');
61
73
  const frameNameQueue = [componentName];
62
74
  while (frameNameQueue.length > 0) {
@@ -1,7 +1,7 @@
1
1
  import { useThrelte } from '@threlte/core';
2
2
  import {} from 'koota';
3
3
  import { getContext, setContext } from 'svelte';
4
- import { Color, Vector3, Vector4 } from 'three';
4
+ import { Color, Matrix4, Vector3, Vector4 } from 'three';
5
5
  import { NURBSCurve } from 'three/addons/curves/NURBSCurve.js';
6
6
  import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js';
7
7
  import { UuidTool } from 'uuid-tool';
@@ -11,7 +11,7 @@ import { asRGB, STRIDE } from '../buffer';
11
11
  import { hierarchy, traits, useWorld } from '../ecs';
12
12
  import { createBox, createCapsule, createSphere } from '../geometry';
13
13
  import { parsePlyInput } from '../ply';
14
- import { createPose, createPoseFromFrame } from '../transform';
14
+ import { createPose, createPoseFromFrame, poseToMatrix } from '../transform';
15
15
  import { useCameraControls } from './useControls.svelte';
16
16
  import { useDrawConnectionConfig } from './useDrawConnectionConfig.svelte';
17
17
  import { useLogs } from './useLogs.svelte';
@@ -126,7 +126,11 @@ export const provideDrawAPI = () => {
126
126
  const parent = frame.parent;
127
127
  const existing = entities.get(name);
128
128
  if (existing) {
129
- existing.set(traits.Pose, pose);
129
+ const matrix = existing.get(traits.Matrix);
130
+ if (matrix) {
131
+ poseToMatrix(pose, matrix);
132
+ existing.changed(traits.Matrix);
133
+ }
130
134
  hierarchy.setParent(existing, parent);
131
135
  continue;
132
136
  }
@@ -146,7 +150,7 @@ export const provideDrawAPI = () => {
146
150
  if (frame.geometry) {
147
151
  entityTraits.push(geometryTrait());
148
152
  }
149
- entityTraits.push(traits.Name(name), traits.Pose(pose), traits.DrawAPI, traits.ReferenceFrame, traits.Removable, traits.ShowAxesHelper);
153
+ entityTraits.push(traits.Name(name), traits.Matrix(poseToMatrix(pose, new Matrix4())), traits.DrawAPI, traits.ReferenceFrame, traits.Removable, traits.ShowAxesHelper);
150
154
  const entity = world.spawn(...entityTraits);
151
155
  entities.set(name, entity);
152
156
  }
@@ -157,7 +161,11 @@ export const provideDrawAPI = () => {
157
161
  const pose = createPose(data.center);
158
162
  const existing = entities.get(name);
159
163
  if (existing) {
160
- existing.set(traits.Pose, pose);
164
+ const matrix = existing.get(traits.Matrix);
165
+ if (matrix) {
166
+ poseToMatrix(pose, matrix);
167
+ existing.changed(traits.Matrix);
168
+ }
161
169
  return;
162
170
  }
163
171
  const geometryTrait = () => {
@@ -179,7 +187,7 @@ export const provideDrawAPI = () => {
179
187
  const entityTraits = [
180
188
  traits.Name(data.label ?? ++geometryIndex),
181
189
  ...hierarchy.parentTraits(parent),
182
- traits.Pose(pose),
190
+ traits.Matrix(poseToMatrix(pose, new Matrix4())),
183
191
  traits.Color(colorUtil.set(color)),
184
192
  geometryTrait(),
185
193
  traits.DrawAPI,
@@ -10,7 +10,7 @@ import { DrawService } from '../buf/draw/v1/service_connect';
10
10
  import { CreateRelationshipRequest, DeleteRelationshipRequest, EntityChangeType, StreamEntityChangesResponse, } from '../buf/draw/v1/service_pb';
11
11
  import { asFloat32Array, inMeters, STRIDE } from '../buffer';
12
12
  import { drawDrawing, drawTransform, updateDrawing, updateModel, updateTransform, uuidStringToBytes, } from '../draw';
13
- import { traits, useWorld } from '../ecs';
13
+ import { hierarchy, traits, useWorld } from '../ecs';
14
14
  import { useCameraControls } from './useControls.svelte';
15
15
  import { useDrawConnectionConfig } from './useDrawConnectionConfig.svelte';
16
16
  import { useRelationships } from './useRelationships.svelte';
@@ -50,8 +50,7 @@ export function provideDrawService() {
50
50
  const entity = drawingEntities.get(uuidStr);
51
51
  if (!entity)
52
52
  return;
53
- if (world.has(entity))
54
- entity.destroy();
53
+ hierarchy.destroyEntityTree(world, entity);
55
54
  drawingEntities.delete(uuidStr);
56
55
  };
57
56
  const processEvent = (event) => {
@@ -334,13 +333,11 @@ export function provideDrawService() {
334
333
  connectionStatus = ConnectionStatus.DISCONNECTED;
335
334
  activeClient = undefined;
336
335
  for (const entity of transformEntities.values()) {
337
- if (world.has(entity))
338
- entity.destroy();
336
+ hierarchy.destroyEntityTree(world, entity);
339
337
  }
340
338
  transformEntities.clear();
341
339
  for (const entity of drawingEntities.values()) {
342
- if (world.has(entity))
343
- entity.destroy();
340
+ hierarchy.destroyEntityTree(world, entity);
344
341
  }
345
342
  drawingEntities.clear();
346
343
  relationships.clear();
@@ -2,9 +2,10 @@ import { MachineConnectionEvent, Transform } from '@viamrobotics/sdk';
2
2
  import { createRobotQuery, useConnectionStatus, useMachineStatus, useRobotClient, } from '@viamrobotics/svelte-sdk';
3
3
  import {} from 'koota';
4
4
  import { getContext, setContext, untrack } from 'svelte';
5
+ import { Matrix4 } from 'three';
5
6
  import { resourceNameToColor, subtypeToColor } from '../color';
6
7
  import { hierarchy, traits, useWorld } from '../ecs';
7
- import { createPose } from '../transform';
8
+ import { createPose, isPoseEqual, poseToMatrix } from '../transform';
8
9
  import { useConfigFrames } from './useConfigFrames.svelte';
9
10
  import { useEnvironment } from './useEnvironment.svelte';
10
11
  import { useFrameEditSession } from './useFrameEditSession.svelte';
@@ -171,34 +172,45 @@ export const provideFrames = (partID) => {
171
172
  }
172
173
  hierarchy.setParent(existing, parent);
173
174
  if (color) {
174
- existing.set(traits.Color, color);
175
+ const cur = existing.get(traits.Color);
176
+ if (!cur || cur.r !== color.r || cur.g !== color.g || cur.b !== color.b) {
177
+ existing.set(traits.Color, color);
178
+ }
175
179
  }
176
- if (center) {
180
+ if (center && !isPoseEqual(existing.get(traits.Center), center)) {
177
181
  existing.set(traits.Center, center);
178
182
  }
179
183
  traits.updateGeometryTrait(existing, frame.physicalObject);
180
184
  if (!isEditMode && !partConfig.hasPendingSave) {
181
- existing.set(traits.Pose, pose);
185
+ const baseline = existing.get(traits.Matrix);
186
+ if (baseline) {
187
+ poseToMatrix(pose, baseline);
188
+ existing.changed(traits.Matrix);
189
+ }
182
190
  }
183
- if (!existing.has(traits.LivePose)) {
184
- existing.add(traits.LivePose(pose));
191
+ if (!existing.has(traits.LiveMatrix)) {
192
+ existing.add(traits.LiveMatrix(poseToMatrix(pose, new Matrix4())));
185
193
  }
186
- // Skip the EditedPose overwrite while in edit mode. The merged
194
+ // Skip the EditedMatrix overwrite while in edit mode. The merged
187
195
  // `frames` source can differ from query.data once didRecentlyEdit
188
196
  // flips (fragment overrides, round-trip drift), and writing those
189
197
  // values would shift entities whose parents the user is portaling
190
198
  // into — the gizmo's drag target moves underneath it. Once we're
191
199
  // back in monitor mode, the next sync resumes the overwrite.
192
200
  if (!isEditMode) {
193
- existing.set(traits.EditedPose, pose);
201
+ const edited = existing.get(traits.EditedMatrix);
202
+ if (edited) {
203
+ poseToMatrix(pose, edited);
204
+ existing.changed(traits.EditedMatrix);
205
+ }
194
206
  }
195
207
  continue;
196
208
  }
197
209
  const entityTraits = [
198
210
  traits.Name(name),
199
- traits.Pose(pose),
200
- traits.EditedPose(pose),
201
- traits.LivePose(pose),
211
+ traits.Matrix(poseToMatrix(pose, new Matrix4())),
212
+ traits.EditedMatrix(poseToMatrix(pose, new Matrix4())),
213
+ traits.LiveMatrix(poseToMatrix(pose, new Matrix4())),
202
214
  traits.FramesAPI,
203
215
  traits.Transformable,
204
216
  traits.ShowAxesHelper,
@@ -1,4 +1,4 @@
1
- import { ArmClient, BaseClient, CameraClient, GantryClient, GripperClient } from '@viamrobotics/sdk';
1
+ import { ArmClient, BaseClient, CameraClient, GantryClient, GenericComponentClient, GripperClient, } from '@viamrobotics/sdk';
2
2
  import { createResourceClient, createResourceQuery, useResourceNames, } from '@viamrobotics/svelte-sdk';
3
3
  import {} from 'koota';
4
4
  import { getContext, setContext, untrack } from 'svelte';
@@ -7,7 +7,7 @@ import { resourceColors } from '../color';
7
7
  import { RefetchRates } from '../components/overlay/RefreshRate.svelte';
8
8
  import { hierarchy, traits, useWorld } from '../ecs';
9
9
  import { updateGeometryTrait } from '../ecs/traits';
10
- import { createPose } from '../transform';
10
+ import { createPose, isPoseEqual } from '../transform';
11
11
  import { useEnvironment } from './useEnvironment.svelte';
12
12
  import { useLogs } from './useLogs.svelte';
13
13
  import { useResourceByName } from './useResourceByName.svelte';
@@ -24,6 +24,7 @@ export const provideGeometries = (partID) => {
24
24
  const cameras = useResourceNames(partID, 'camera');
25
25
  const grippers = useResourceNames(partID, 'gripper');
26
26
  const gantries = useResourceNames(partID, 'gantry');
27
+ const generics = useResourceNames(partID, 'generic');
27
28
  const settings = useSettings();
28
29
  const { refreshRates } = $derived(settings.current);
29
30
  const armClients = $derived(arms.current.map((arm) => createResourceClient(ArmClient, partID, () => arm.name)));
@@ -31,6 +32,9 @@ export const provideGeometries = (partID) => {
31
32
  const gripperClients = $derived(grippers.current.map((gripper) => createResourceClient(GripperClient, partID, () => gripper.name)));
32
33
  const cameraClients = $derived(cameras.current.map((camera) => createResourceClient(CameraClient, partID, () => camera.name)));
33
34
  const gantryClients = $derived(gantries.current.map((gantry) => createResourceClient(GantryClient, partID, () => gantry.name)));
35
+ const genericClients = $derived(generics.current
36
+ .filter((generic) => generic.type === 'component')
37
+ .map((generic) => createResourceClient(GenericComponentClient, partID, () => generic.name)));
34
38
  const interval = $derived(refreshRates[RefreshRates.poses]);
35
39
  const options = $derived({
36
40
  enabled: interval !== RefetchRates.OFF && environment.current.viewerMode === 'monitor',
@@ -41,12 +45,14 @@ export const provideGeometries = (partID) => {
41
45
  const gripperQueries = $derived(gripperClients.map((client) => [client.current?.name, createResourceQuery(client, 'getGeometries', () => options)]));
42
46
  const cameraQueries = $derived(cameraClients.map((client) => [client.current?.name, createResourceQuery(client, 'getGeometries', () => options)]));
43
47
  const gantryQueries = $derived(gantryClients.map((client) => [client.current?.name, createResourceQuery(client, 'getGeometries', () => options)]));
48
+ const genericQueries = $derived(genericClients.map((client) => [client.current?.name, createResourceQuery(client, 'getGeometries', () => options)]));
44
49
  const queries = $derived([
45
50
  ...armQueries,
46
51
  ...baseQueries,
47
52
  ...gripperQueries,
48
53
  ...cameraQueries,
49
54
  ...gantryQueries,
55
+ ...genericQueries,
50
56
  ]);
51
57
  $effect(() => {
52
58
  if (interval === RefetchRates.FPS_30 || interval === RefetchRates.FPS_60) {
@@ -91,7 +97,9 @@ export const provideGeometries = (partID) => {
91
97
  const existing = entities.get(entityKey);
92
98
  if (existing) {
93
99
  hierarchy.setParent(existing, name);
94
- existing.set(traits.Center, center);
100
+ if (!isPoseEqual(existing.get(traits.Center), center)) {
101
+ existing.set(traits.Center, center);
102
+ }
95
103
  updateGeometryTrait(existing, geometry);
96
104
  continue;
97
105
  }
@@ -8,7 +8,7 @@ export const providePartConfig = (partID, params) => {
8
8
  const props = $derived(params());
9
9
  const config = $derived(props ? useEmbeddedPartConfig(props) : useStandalonePartConfig(partID));
10
10
  const getCurrent = () => {
11
- return (config.current.toJson?.() ?? { components: [] });
11
+ return (config.current?.toJson?.() ?? { components: [] });
12
12
  };
13
13
  const current = $derived(getCurrent());
14
14
  const createFragmentFrame = (fragmentId, componentName) => {
@@ -204,9 +204,40 @@ export const usePartConfig = () => {
204
204
  return getContext(key);
205
205
  };
206
206
  const useEmbeddedPartConfig = (props) => {
207
+ let hasPendingSave = $state(false);
208
+ let prevIsDirty = false;
209
+ let cleanSnapshot;
210
+ const snapshot = (current) => {
211
+ const json = current?.toJson?.();
212
+ return json === undefined ? undefined : JSON.stringify(json);
213
+ };
214
+ /**
215
+ * The host app owns saving, and we aren't notified directly. Set hasPendingSave
216
+ * to watch isDirty: true -> false transitions, representing a save.
217
+ *
218
+ * `useFrames` clears the flag on the next `revision` change
219
+ * once the server reports the new framesystem.
220
+ */
221
+ $effect.pre(() => {
222
+ const dirty = props.isDirty;
223
+ const current = props.current;
224
+ if (prevIsDirty && !dirty) {
225
+ const next = snapshot(current);
226
+ if (next !== undefined && cleanSnapshot !== undefined && next !== cleanSnapshot) {
227
+ hasPendingSave = true;
228
+ }
229
+ cleanSnapshot = next;
230
+ }
231
+ else if (!prevIsDirty && !dirty) {
232
+ cleanSnapshot = snapshot(current);
233
+ }
234
+ prevIsDirty = dirty;
235
+ });
207
236
  return {
208
237
  hasEditPermissions: true,
209
- hasPendingSave: false,
238
+ get hasPendingSave() {
239
+ return hasPendingSave;
240
+ },
210
241
  get isDirty() {
211
242
  return props.isDirty;
212
243
  },
@@ -220,8 +251,12 @@ const useEmbeddedPartConfig = (props) => {
220
251
  const struct = Struct.fromJson(config);
221
252
  return props.setLocalPartConfig(struct);
222
253
  },
223
- clearPendingSave() { },
224
- setPendingSave() { },
254
+ clearPendingSave() {
255
+ hasPendingSave = false;
256
+ },
257
+ setPendingSave() {
258
+ hasPendingSave = true;
259
+ },
225
260
  };
226
261
  };
227
262
  const useStandalonePartConfig = (partID) => {
@@ -278,10 +313,12 @@ const useStandalonePartConfig = (partID) => {
278
313
  const id = partID();
279
314
  if (lastPartID !== undefined && lastPartID !== id) {
280
315
  // 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.
316
+ // previous part, and clear `current` so consumers don't keep
317
+ // rendering the old config's frames while the new part loads
318
+ // (offline parts may never load, leaving the old frames forever).
283
319
  isDirty = false;
284
320
  hasPendingSave = false;
321
+ current = undefined;
285
322
  }
286
323
  lastPartID = id;
287
324
  if (!networkPartConfig || isDirty) {
@@ -1,13 +1,14 @@
1
1
  import { useThrelte } from '@threlte/core';
2
2
  import { Struct, TransformChangeType, WorldStateStoreClient, } from '@viamrobotics/sdk';
3
3
  import { createResourceClient, createResourceQuery, createResourceStream, useResourceNames, } from '@viamrobotics/svelte-sdk';
4
+ import { Matrix4 } from 'three';
4
5
  import { asFloat32Array, inMeters } from '../buffer';
5
6
  import { createChunkLoader } from '../chunking';
6
7
  import { drawTransform, updateMetadata } from '../draw';
7
8
  import { traits, useWorld } from '../ecs';
8
9
  import { isPointCloud } from '../geometry';
9
10
  import { metadataFromStruct } from '../metadata';
10
- import { createPose } from '../transform';
11
+ import { createPose, poseToMatrix } from '../transform';
11
12
  import { usePartID } from './usePartID.svelte';
12
13
  import { useRelationships } from './useRelationships.svelte';
13
14
  export const provideWorldStates = () => {
@@ -133,7 +134,14 @@ const createWorldState = (client) => {
133
134
  for (const path of changes) {
134
135
  if (typeof path === 'string') {
135
136
  if (path.startsWith('poseInObserverFrame.pose')) {
136
- entity.set(traits.Pose, transform.poseInObserverFrame?.pose ?? createPose());
137
+ const matrix = entity.get(traits.Matrix);
138
+ if (matrix) {
139
+ poseToMatrix(createPose(transform.poseInObserverFrame?.pose), matrix);
140
+ entity.changed(traits.Matrix);
141
+ }
142
+ else {
143
+ entity.add(traits.Matrix(poseToMatrix(createPose(transform.poseInObserverFrame?.pose), new Matrix4())));
144
+ }
137
145
  }
138
146
  else if (path.startsWith('physicalObject') && transform.physicalObject) {
139
147
  traits.updateGeometryTrait(entity, transform.physicalObject);